From 7738d1409765674464114bfd01ba653fcdd9b3f2 Mon Sep 17 00:00:00 2001 From: John Rogers Date: Mon, 28 Jul 2025 23:04:48 +0100 Subject: [PATCH 01/29] addded comments to config.yml --- static/index.html | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/static/index.html b/static/index.html index 74a8a17..6b4c64a 100644 --- a/static/index.html +++ b/static/index.html @@ -50,10 +50,13 @@
- + + + +
From b803de93de6202e3cef92e2c5abd4ac8a3118f55 Mon Sep 17 00:00:00 2001 From: John Rogers Date: Mon, 28 Jul 2025 23:14:31 +0100 Subject: [PATCH 02/29] feat: display clock delta history in UI Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) --- src/api.rs | 5 +++++ static/index.html | 1 + static/script.js | 3 +++ 3 files changed, 9 insertions(+) diff --git a/src/api.rs b/src/api.rs index 0bd2d2a..7b84c3f 100644 --- a/src/api.rs +++ b/src/api.rs @@ -26,6 +26,7 @@ struct ApiStatus { ntp_active: bool, interfaces: Vec, hardware_offset_ms: i64, + clock_delta_history: Vec, } // AppState to hold shared data @@ -76,6 +77,8 @@ async fn get_status(data: web::Data) -> impl Responder { .map(|ifa| ifa.ip().to_string()) .collect(); + let clock_delta_history: Vec = state.clock_delta_history.iter().cloned().collect(); + HttpResponse::Ok().json(ApiStatus { ltc_status, ltc_timecode, @@ -89,6 +92,7 @@ async fn get_status(data: web::Data) -> impl Responder { ntp_active, interfaces, hardware_offset_ms: hw_offset_ms, + clock_delta_history, }) } @@ -215,6 +219,7 @@ mod tests { assert_eq!(resp.ltc_timecode, "01:02:03:04"); assert_eq!(resp.frame_rate, "25.00fps"); assert_eq!(resp.hardware_offset_ms, 10); + assert_eq!(resp.clock_delta_history, vec![4, 5, 6]); } #[actix_web::test] diff --git a/static/index.html b/static/index.html index 6b4c64a..1aa7110 100644 --- a/static/index.html +++ b/static/index.html @@ -32,6 +32,7 @@

Clock Offset

Delta: -- ms (-- frames)

Jitter: --

+

History (ms): --

diff --git a/static/script.js b/static/script.js index 2195bfd..1a7385c 100644 --- a/static/script.js +++ b/static/script.js @@ -10,6 +10,7 @@ document.addEventListener('DOMContentLoaded', () => { deltaMs: document.getElementById('delta-ms'), deltaFrames: document.getElementById('delta-frames'), jitterStatus: document.getElementById('jitter-status'), + deltaHistory: document.getElementById('delta-history'), interfaces: document.getElementById('interfaces'), }; @@ -43,6 +44,8 @@ document.addEventListener('DOMContentLoaded', () => { statusElements.jitterStatus.textContent = data.jitter_status; statusElements.jitterStatus.className = data.jitter_status.toLowerCase(); + statusElements.deltaHistory.textContent = data.clock_delta_history.join(', '); + statusElements.interfaces.innerHTML = ''; if (data.interfaces.length > 0) { data.interfaces.forEach(ip => { From 5a864938248a1c7ccf58c16a59d3e15c5883b050 Mon Sep 17 00:00:00 2001 From: John Rogers Date: Mon, 28 Jul 2025 23:36:51 +0100 Subject: [PATCH 03/29] feat: add daemon log viewer to web UI Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) --- Cargo.toml | 1 - src/api.rs | 20 +++++++++++++++--- src/config.rs | 4 ++-- src/logger.rs | 52 +++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 31 +++++++++++++++------------- static/index.html | 6 ++++++ static/script.js | 17 ++++++++++++++++ 7 files changed, 111 insertions(+), 20 deletions(-) create mode 100644 src/logger.rs diff --git a/Cargo.toml b/Cargo.toml index 3e04d14..2321b82 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,6 @@ actix-files = "0.6" tokio = { version = "1", features = ["full"] } clap = { version = "4.4", features = ["derive"] } log = "0.4" -env_logger = "0.11" daemonize = "0.5.0" diff --git a/src/api.rs b/src/api.rs index 7b84c3f..bdb1278 100644 --- a/src/api.rs +++ b/src/api.rs @@ -5,6 +5,7 @@ use chrono::{Local, Timelike}; use get_if_addrs::get_if_addrs; use serde::{Deserialize, Serialize}; use serde_json; +use std::collections::VecDeque; use std::sync::{Arc, Mutex}; use crate::config::{self, Config}; @@ -33,6 +34,7 @@ struct ApiStatus { pub struct AppState { pub ltc_state: Arc>, pub config: Arc>, + pub log_buffer: Arc>>, } #[get("/api/status")] @@ -117,6 +119,12 @@ async fn get_config(data: web::Data) -> impl Responder { HttpResponse::Ok().json(&*config) } +#[get("/api/logs")] +async fn get_logs(data: web::Data) -> impl Responder { + let logs = data.log_buffer.lock().unwrap(); + HttpResponse::Ok().json(&*logs) +} + #[post("/api/config")] async fn update_config( data: web::Data, @@ -126,23 +134,28 @@ async fn update_config( *config = req.into_inner(); if config::save_config("config.yml", &config).is_ok() { - eprintln!("🔄 Saved config via API: {:?}", *config); + log::info!("🔄 Saved config via API: {:?}", *config); HttpResponse::Ok().json(&*config) } else { - HttpResponse::InternalServerError().json(serde_json::json!({ "status": "error", "message": "Failed to write config.yml" })) + log::error!("Failed to write config.yml"); + HttpResponse::InternalServerError().json( + serde_json::json!({ "status": "error", "message": "Failed to write config.yml" }), + ) } } pub async fn start_api_server( state: Arc>, config: Arc>, + log_buffer: Arc>>, ) -> std::io::Result<()> { let app_state = web::Data::new(AppState { ltc_state: state, config: config, + log_buffer: log_buffer, }); - println!("🚀 Starting API server at http://0.0.0.0:8080"); + log::info!("🚀 Starting API server at http://0.0.0.0:8080"); HttpServer::new(move || { App::new() @@ -151,6 +164,7 @@ pub async fn start_api_server( .service(manual_sync) .service(get_config) .service(update_config) + .service(get_logs) // Serve frontend static files .service(fs::Files::new("/", "static/").index_file("index.html")) }) diff --git a/src/config.rs b/src/config.rs index c7caf15..a287a6b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -46,7 +46,7 @@ impl Config { return Self::default(); } serde_yaml::from_str(&contents).unwrap_or_else(|e| { - eprintln!("Failed to parse config, using default: {}", e); + log::warn!("Failed to parse config, using default: {}", e); Self::default() }) } @@ -82,7 +82,7 @@ pub fn watch_config(path: &str) -> Arc> { let new_cfg = Config::load(&watch_path_for_cb); let mut cfg = config_for_cb.lock().unwrap(); *cfg = new_cfg; - eprintln!("🔄 Reloaded config.yml: {:?}", *cfg); + log::info!("🔄 Reloaded config.yml: {:?}", *cfg); } } }) diff --git a/src/logger.rs b/src/logger.rs new file mode 100644 index 0000000..33c410e --- /dev/null +++ b/src/logger.rs @@ -0,0 +1,52 @@ +use chrono::Local; +use log::{LevelFilter, Log, Metadata, Record}; +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; + +const MAX_LOG_ENTRIES: usize = 100; + +struct RingBufferLogger { + buffer: Arc>>, +} + +impl Log for RingBufferLogger { + fn enabled(&self, metadata: &Metadata) -> bool { + metadata.level() <= LevelFilter::Info + } + + fn log(&self, record: &Record) { + if self.enabled(record.metadata()) { + let msg = format!( + "{} [{}] {}", + Local::now().format("%Y-%m-%d %H:%M:%S"), + record.level(), + record.args() + ); + + // Also print to stderr for console/daemon logging + eprintln!("{}", msg); + + let mut buffer = self.buffer.lock().unwrap(); + if buffer.len() == MAX_LOG_ENTRIES { + buffer.pop_front(); + } + buffer.push_back(msg); + } + } + + fn flush(&self) {} +} + +pub fn setup_logger() -> Arc>> { + let buffer = Arc::new(Mutex::new(VecDeque::with_capacity(MAX_LOG_ENTRIES))); + let logger = RingBufferLogger { + buffer: buffer.clone(), + }; + + // We use `set_boxed_logger` to install our custom logger. + // The `log` crate will then route all log messages to it. + log::set_boxed_logger(Box::new(logger)).expect("Failed to set logger"); + log::set_max_level(LevelFilter::Info); + + buffer +} diff --git a/src/main.rs b/src/main.rs index 5ee280e..f85d298 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod api; mod config; +mod logger; mod serial_input; mod sync_logic; mod system; @@ -14,7 +15,6 @@ use crate::sync_logic::LtcState; use crate::ui::start_ui; use clap::Parser; use daemonize::Daemonize; -use env_logger; use std::{ fs, @@ -57,16 +57,18 @@ fn ensure_config() { if !p.exists() { fs::write(p, DEFAULT_CONFIG.trim()) .expect("Failed to write default config.yml"); - eprintln!("⚙️ Emitted default config.yml"); + log::info!("⚙️ Emitted default config.yml"); } } #[tokio::main(flavor = "current_thread")] async fn main() { + // This must be called before any logging statements. + let log_buffer = logger::setup_logger(); let args = Args::parse(); if let Some(Command::Daemon) = &args.command { - println!("🚀 Starting daemon..."); + log::info!("🚀 Starting daemon..."); // Create files for stdout and stderr in the current directory let stdout = fs::File::create("daemon.out").expect("Could not create daemon.out"); @@ -81,7 +83,7 @@ async fn main() { match daemonize.start() { Ok(_) => { /* Process is now daemonized */ } Err(e) => { - eprintln!("Error daemonizing: {}", e); + log::error!("Error daemonizing: {}", e); return; // Exit if daemonization fails } } @@ -116,9 +118,9 @@ async fn main() { // 5️⃣ Spawn UI or setup daemon logging if args.command.is_none() { - println!("🔧 Watching config.yml..."); - println!("🚀 Serial thread launched"); - println!("🖥️ UI thread launched"); + log::info!("🔧 Watching config.yml..."); + log::info!("🚀 Serial thread launched"); + log::info!("🖥️ UI thread launched"); let ui_state = ltc_state.clone(); let config_clone = config.clone(); let port = "/dev/ttyACM0".to_string(); @@ -126,10 +128,8 @@ async fn main() { start_ui(ui_state, port, config_clone); }); } else { - // In daemon mode, we initialize env_logger. - // This will log to stdout, and the systemd service will capture it. - // The RUST_LOG env var controls the log level (e.g., RUST_LOG=info). - env_logger::init(); + // In daemon mode, logging is already set up to go to stderr. + // The systemd service will capture it. log::info!("🚀 Starting TimeTurner daemon..."); } @@ -141,9 +141,12 @@ async fn main() { { let api_state = ltc_state.clone(); let config_clone = config.clone(); + let log_buffer_clone = log_buffer.clone(); task::spawn_local(async move { - if let Err(e) = start_api_server(api_state, config_clone).await { - eprintln!("API server error: {}", e); + if let Err(e) = + start_api_server(api_state, config_clone, log_buffer_clone).await + { + log::error!("API server error: {}", e); } }); } @@ -154,7 +157,7 @@ async fn main() { std::future::pending::<()>().await; } else { // In TUI mode, block on the channel. - println!("📡 Main thread entering loop..."); + log::info!("📡 Main thread entering loop..."); let _ = task::spawn_blocking(move || { for _frame in rx { // no-op diff --git a/static/index.html b/static/index.html index 1aa7110..f4999e0 100644 --- a/static/index.html +++ b/static/index.html @@ -66,6 +66,12 @@ + + +
+

Logs

+

+            
diff --git a/static/script.js b/static/script.js index 1a7385c..439d07c 100644 --- a/static/script.js +++ b/static/script.js @@ -12,6 +12,7 @@ document.addEventListener('DOMContentLoaded', () => { jitterStatus: document.getElementById('jitter-status'), deltaHistory: document.getElementById('delta-history'), interfaces: document.getElementById('interfaces'), + logs: document.getElementById('logs'), }; const hwOffsetInput = document.getElementById('hw-offset'); @@ -111,6 +112,20 @@ document.addEventListener('DOMContentLoaded', () => { } } + async function fetchLogs() { + try { + const response = await fetch('/api/logs'); + if (!response.ok) throw new Error('Failed to fetch logs'); + const logs = await response.json(); + statusElements.logs.textContent = logs.join('\n'); + // Auto-scroll to the bottom + statusElements.logs.scrollTop = statusElements.logs.scrollHeight; + } catch (error) { + console.error('Error fetching logs:', error); + statusElements.logs.textContent = 'Error fetching logs.'; + } + } + async function triggerManualSync() { syncMessage.textContent = 'Issuing sync command...'; try { @@ -134,7 +149,9 @@ document.addEventListener('DOMContentLoaded', () => { // Initial data load fetchStatus(); fetchConfig(); + fetchLogs(); // Refresh data every 2 seconds setInterval(fetchStatus, 2000); + setInterval(fetchLogs, 2000); }); From 985ccc6819b646f9d18f0b23f1555b6c92cb4fdd Mon Sep 17 00:00:00 2001 From: John Rogers Date: Mon, 28 Jul 2025 23:39:01 +0100 Subject: [PATCH 04/29] fix: Enable std feature for log and remove clock history Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) --- Cargo.toml | 2 +- src/api.rs | 5 ----- static/index.html | 1 - static/script.js | 3 --- 4 files changed, 1 insertion(+), 10 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 2321b82..b280adf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,7 @@ actix-web = "4" actix-files = "0.6" tokio = { version = "1", features = ["full"] } clap = { version = "4.4", features = ["derive"] } -log = "0.4" +log = { version = "0.4", features = ["std"] } daemonize = "0.5.0" diff --git a/src/api.rs b/src/api.rs index bdb1278..eb27fb8 100644 --- a/src/api.rs +++ b/src/api.rs @@ -27,7 +27,6 @@ struct ApiStatus { ntp_active: bool, interfaces: Vec, hardware_offset_ms: i64, - clock_delta_history: Vec, } // AppState to hold shared data @@ -79,8 +78,6 @@ async fn get_status(data: web::Data) -> impl Responder { .map(|ifa| ifa.ip().to_string()) .collect(); - let clock_delta_history: Vec = state.clock_delta_history.iter().cloned().collect(); - HttpResponse::Ok().json(ApiStatus { ltc_status, ltc_timecode, @@ -94,7 +91,6 @@ async fn get_status(data: web::Data) -> impl Responder { ntp_active, interfaces, hardware_offset_ms: hw_offset_ms, - clock_delta_history, }) } @@ -233,7 +229,6 @@ mod tests { assert_eq!(resp.ltc_timecode, "01:02:03:04"); assert_eq!(resp.frame_rate, "25.00fps"); assert_eq!(resp.hardware_offset_ms, 10); - assert_eq!(resp.clock_delta_history, vec![4, 5, 6]); } #[actix_web::test] diff --git a/static/index.html b/static/index.html index f4999e0..d5b2670 100644 --- a/static/index.html +++ b/static/index.html @@ -32,7 +32,6 @@

Clock Offset

Delta: -- ms (-- frames)

Jitter: --

-

History (ms): --

diff --git a/static/script.js b/static/script.js index 439d07c..0944f5e 100644 --- a/static/script.js +++ b/static/script.js @@ -10,7 +10,6 @@ document.addEventListener('DOMContentLoaded', () => { deltaMs: document.getElementById('delta-ms'), deltaFrames: document.getElementById('delta-frames'), jitterStatus: document.getElementById('jitter-status'), - deltaHistory: document.getElementById('delta-history'), interfaces: document.getElementById('interfaces'), logs: document.getElementById('logs'), }; @@ -45,8 +44,6 @@ document.addEventListener('DOMContentLoaded', () => { statusElements.jitterStatus.textContent = data.jitter_status; statusElements.jitterStatus.className = data.jitter_status.toLowerCase(); - statusElements.deltaHistory.textContent = data.clock_delta_history.join(', '); - statusElements.interfaces.innerHTML = ''; if (data.interfaces.length > 0) { data.interfaces.forEach(ip => { From 6a45660e037ac284521a1a77767454eca5ab6de5 Mon Sep 17 00:00:00 2001 From: John Rogers Date: Mon, 28 Jul 2025 23:43:28 +0100 Subject: [PATCH 05/29] fix: process LTC frames in background to update app state Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) --- src/main.rs | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/src/main.rs b/src/main.rs index f85d298..e55fa63 100644 --- a/src/main.rs +++ b/src/main.rs @@ -151,19 +151,23 @@ async fn main() { }); } - // 8️⃣ Keep main thread alive + // 8️⃣ Main logic loop: process frames from serial and update state + let loop_state = ltc_state.clone(); + let logic_task = task::spawn_blocking(move || { + for frame in rx { + loop_state.lock().unwrap().update(frame); + } + }); + + // 9️⃣ Keep main thread alive if args.command.is_some() { - // In daemon mode, wait forever. + // In daemon mode, wait forever. The logic_task runs in the background. std::future::pending::<()>().await; } else { - // In TUI mode, block on the channel. + // In TUI mode, block until the logic_task finishes (e.g. serial port disconnects) + // This keeps the TUI running. log::info!("📡 Main thread entering loop..."); - let _ = task::spawn_blocking(move || { - for _frame in rx { - // no-op - } - }) - .await; + let _ = logic_task.await; } }) .await; From cc782fcd7e9b48e9e48ecda4a58f6e3bbce47d10 Mon Sep 17 00:00:00 2001 From: John Rogers Date: Mon, 28 Jul 2025 23:51:27 +0100 Subject: [PATCH 06/29] feat: add EWMA clock delta and adjtimex nudge controls Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) --- src/api.rs | 19 ++++++++-- src/config.rs | 7 ++++ src/main.rs | 19 +++++++++- src/sync_logic.rs | 90 ++++++++++++++++++----------------------------- src/system.rs | 33 +++++++++++++++++ static/index.html | 7 ++++ static/script.js | 36 +++++++++++++++++++ 7 files changed, 152 insertions(+), 59 deletions(-) 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(); From 80faf4db9ae58551a846154615310ac6f8514aed Mon Sep 17 00:00:00 2001 From: John Rogers Date: Mon, 28 Jul 2025 23:58:52 +0100 Subject: [PATCH 07/29] fix: resolve build errors by adapting to clock delta refactor Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) --- src/api.rs | 13 +++++++++---- src/main.rs | 1 - src/ui.rs | 31 ++----------------------------- 3 files changed, 11 insertions(+), 34 deletions(-) diff --git a/src/api.rs b/src/api.rs index 588acdb..aa3314a 100644 --- a/src/api.rs +++ b/src/api.rs @@ -220,11 +220,15 @@ mod tests { let ltc_state = Arc::new(Mutex::new(get_test_ltc_state())); let config = Arc::new(Mutex::new(Config { hardware_offset_ms: 10, - timeturner_offset: TimeturnerOffset { - hours: 0, minutes: 0, seconds: 0, frames: 0 - } + timeturner_offset: TimeturnerOffset::default(), + default_nudge_ms: 2, })); - web::Data::new(AppState { ltc_state, config }) + let log_buffer = Arc::new(Mutex::new(VecDeque::new())); + web::Data::new(AppState { + ltc_state, + config, + log_buffer, + }) } #[actix_web::test] @@ -282,6 +286,7 @@ mod tests { let new_config_json = serde_json::json!({ "hardwareOffsetMs": 55, + "defaultNudgeMs": 2, "timeturnerOffset": { "hours": 1, "minutes": 2, "seconds": 3, "frames": 4 } }); diff --git a/src/main.rs b/src/main.rs index f418f91..698eabe 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,7 +13,6 @@ 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; diff --git a/src/ui.rs b/src/ui.rs index 7d1c265..2f04ccb 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -54,7 +54,7 @@ pub fn start_ui( .map(|ifa| ifa.ip().to_string()) .collect(); - // 3️⃣ jitter + Δ + // 3️⃣ jitter { let mut st = state.lock().unwrap(); if let Some(frame) = st.latest.clone() { @@ -64,33 +64,6 @@ pub fn start_ui( let raw = (now_utc - frame.timestamp).num_milliseconds(); let measured = raw - hw_offset_ms; st.record_offset(measured); - - // Δ = system clock - LTC timecode (use LOCAL time, with offset) - let today_local = Local::now().date_naive(); - let ms = ((frame.frames as f64 / frame.frame_rate) * 1000.0).round() as u32; - let tc_naive = NaiveTime::from_hms_milli_opt( - frame.hours, frame.minutes, frame.seconds, ms, - ).expect("Invalid LTC timecode"); - let naive_dt_local = today_local.and_time(tc_naive); - let mut dt_local = Local - .from_local_datetime(&naive_dt_local) - .single() - .expect("Invalid local time"); - - // Apply timeturner offset before calculating delta - let offset = &cfg.timeturner_offset; - dt_local = dt_local - + ChronoDuration::hours(offset.hours) - + ChronoDuration::minutes(offset.minutes) - + ChronoDuration::seconds(offset.seconds); - let frame_offset_ms = (offset.frames as f64 / frame.frame_rate * 1000.0).round() as i64; - dt_local = dt_local + ChronoDuration::milliseconds(frame_offset_ms); - - let delta_ms = (Local::now() - dt_local).num_milliseconds(); - st.record_clock_delta(delta_ms); - } else { - st.clear_offsets(); - st.clear_clock_deltas(); } } } @@ -103,7 +76,7 @@ pub fn start_ui( st.average_frames(), st.timecode_match().to_string(), st.lock_ratio(), - st.average_clock_delta(), + st.get_ewma_clock_delta(), ) }; From aee69679ef9f9e0a99e8a0e31fc3a6ddfba16b4e Mon Sep 17 00:00:00 2001 From: John Rogers Date: Tue, 29 Jul 2025 00:02:46 +0100 Subject: [PATCH 08/29] fix: remove unused chrono imports Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) --- src/ui.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/ui.rs b/src/ui.rs index 2f04ccb..3af24a5 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -9,7 +9,6 @@ use std::collections::VecDeque; use chrono::{ DateTime, Local, Timelike, Utc, - NaiveTime, TimeZone, Duration as ChronoDuration, }; use crossterm::{ cursor::{Hide, MoveTo, Show}, From 917a844874a7c6264abdbb65fb25e0d402cfe31f Mon Sep 17 00:00:00 2001 From: John Rogers Date: Tue, 29 Jul 2025 00:09:17 +0100 Subject: [PATCH 09/29] refactor: remove empty test module from ui.rs Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) --- src/ui.rs | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/ui.rs b/src/ui.rs index 3af24a5..fd7d71b 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -251,10 +251,3 @@ pub fn start_ui( } } -#[cfg(test)] -mod tests { - #[allow(unused_imports)] - use super::*; - #[allow(unused_imports)] - use crate::config::TimeturnerOffset; -} From a12ee88b9b370a909dbb20eece9171136bb89023 Mon Sep 17 00:00:00 2001 From: John Rogers Date: Tue, 29 Jul 2025 00:13:23 +0100 Subject: [PATCH 10/29] feat: Force sync on config save with timeturner offset Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) --- src/api.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/api.rs b/src/api.rs index aa3314a..19e58a7 100644 --- a/src/api.rs +++ b/src/api.rs @@ -145,6 +145,22 @@ async fn update_config( if config::save_config("config.yml", &config).is_ok() { log::info!("🔄 Saved config via API: {:?}", *config); + + // If timeturner offset is active, trigger a sync immediately. + if config.timeturner_offset.is_active() { + let state = data.ltc_state.lock().unwrap(); + if let Some(frame) = &state.latest { + log::info!("Timeturner offset is active, triggering sync..."); + if system::trigger_sync(frame, &config).is_ok() { + log::info!("Sync triggered successfully after config change."); + } else { + log::error!("Sync failed after config change."); + } + } else { + log::warn!("Timeturner offset is active, but no LTC frame available to sync."); + } + } + HttpResponse::Ok().json(&*config) } else { log::error!("Failed to write config.yml"); From c712014bb90847c27d60bd9c9436839ec4281be6 Mon Sep 17 00:00:00 2001 From: John Rogers Date: Tue, 29 Jul 2025 11:39:46 +0100 Subject: [PATCH 11/29] feat: Allow millisecond offset for timeturner Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) --- src/api.rs | 5 ++++- src/config.rs | 8 +++++++- src/main.rs | 1 + src/system.rs | 12 +++++++----- static/index.html | 4 +++- static/script.js | 3 +++ 6 files changed, 25 insertions(+), 8 deletions(-) diff --git a/src/api.rs b/src/api.rs index 19e58a7..e1a15cb 100644 --- a/src/api.rs +++ b/src/api.rs @@ -303,7 +303,7 @@ mod tests { let new_config_json = serde_json::json!({ "hardwareOffsetMs": 55, "defaultNudgeMs": 2, - "timeturnerOffset": { "hours": 1, "minutes": 2, "seconds": 3, "frames": 4 } + "timeturnerOffset": { "hours": 1, "minutes": 2, "seconds": 3, "frames": 4, "milliseconds": 5 } }); let req = test::TestRequest::post() @@ -315,15 +315,18 @@ mod tests { assert_eq!(resp.hardware_offset_ms, 55); 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.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("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..16cd774 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 } } diff --git a/src/main.rs b/src/main.rs index 698eabe..d7d40e4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -52,6 +52,7 @@ timeturnerOffset: minutes: 0 seconds: 0 frames: 0 + milliseconds: 0 "#; /// If no `config.yml` exists alongside the binary, write out the default. 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/static/index.html b/static/index.html index 79bfd80..d5969db 100644 --- a/static/index.html +++ b/static/index.html @@ -50,7 +50,7 @@
- + @@ -58,6 +58,8 @@ + +
diff --git a/static/script.js b/static/script.js index ad9178c..d15d9a6 100644 --- a/static/script.js +++ b/static/script.js @@ -20,6 +20,7 @@ document.addEventListener('DOMContentLoaded', () => { 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'); @@ -84,6 +85,7 @@ document.addEventListener('DOMContentLoaded', () => { 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); @@ -99,6 +101,7 @@ document.addEventListener('DOMContentLoaded', () => { 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, } }; From fb8088c7041b42d7dc6df22329ef2a0a4de42eb6 Mon Sep 17 00:00:00 2001 From: John Rogers Date: Tue, 29 Jul 2025 11:44:59 +0100 Subject: [PATCH 12/29] test: add missing milliseconds field to TimeturnerOffset init Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) --- src/sync_logic.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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"); } From 4090fee0a605333e595b23f40fc63156d421aebd Mon Sep 17 00:00:00 2001 From: John Rogers Date: Tue, 29 Jul 2025 11:49:22 +0100 Subject: [PATCH 13/29] test: Restore original config.yml after tests Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) --- src/main.rs | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/src/main.rs b/src/main.rs index d7d40e4..1d7e496 100644 --- a/src/main.rs +++ b/src/main.rs @@ -196,18 +196,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. From 89849c6e0436e54a1ce502816e5f794c168b0e5a Mon Sep 17 00:00:00 2001 From: John Rogers Date: Tue, 29 Jul 2025 11:59:46 +0100 Subject: [PATCH 14/29] refactor: simplify default configuration Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) --- src/main.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/main.rs b/src/main.rs index 1d7e496..7aeeb53 100644 --- a/src/main.rs +++ b/src/main.rs @@ -42,9 +42,6 @@ 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: @@ -52,7 +49,6 @@ timeturnerOffset: minutes: 0 seconds: 0 frames: 0 - milliseconds: 0 "#; /// If no `config.yml` exists alongside the binary, write out the default. From f929bacdfd138bf3379f7a0195fa514963c15d47 Mon Sep 17 00:00:00 2001 From: John Rogers Date: Tue, 29 Jul 2025 12:10:56 +0100 Subject: [PATCH 15/29] config tweak, --- config.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/config.yml b/config.yml index 470c6c9..8422c84 100644 --- a/config.yml +++ b/config.yml @@ -4,7 +4,9 @@ hardwareOffsetMs: 20 # 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 +defaultNudgeMs: 2 From fcbd5bd647df391653d479eb87d2c6a6575ede73 Mon Sep 17 00:00:00 2001 From: John Rogers Date: Tue, 29 Jul 2025 12:18:24 +0100 Subject: [PATCH 16/29] clarification in this --- src/sync_logic.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sync_logic.rs b/src/sync_logic.rs index d54f2cc..a0317af 100644 --- a/src/sync_logic.rs +++ b/src/sync_logic.rs @@ -159,7 +159,7 @@ impl LtcState { pub fn get_sync_status(delta_ms: i64, config: &Config) -> &'static str { if config.timeturner_offset.is_active() { - "TIMETURNING" + "TIME LOCK ACTIVE" } else if delta_ms.abs() <= 8 { "IN SYNC" } else if delta_ms > 10 { From c0613c3682a6c2fe066f764d40f801fe83dedf61 Mon Sep 17 00:00:00 2001 From: John Rogers Date: Tue, 29 Jul 2025 12:21:54 +0100 Subject: [PATCH 17/29] revert --- src/sync_logic.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sync_logic.rs b/src/sync_logic.rs index a0317af..d54f2cc 100644 --- a/src/sync_logic.rs +++ b/src/sync_logic.rs @@ -159,7 +159,7 @@ impl LtcState { pub fn get_sync_status(delta_ms: i64, config: &Config) -> &'static str { if config.timeturner_offset.is_active() { - "TIME LOCK ACTIVE" + "TIMETURNING" } else if delta_ms.abs() <= 8 { "IN SYNC" } else if delta_ms > 10 { From 4cb421b3d6e671b7fa22f012b404288b2ad64ede Mon Sep 17 00:00:00 2001 From: John Rogers Date: Tue, 29 Jul 2025 12:25:40 +0100 Subject: [PATCH 18/29] fix: clarify timeturner offset controls with labels Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) --- static/index.html | 33 +++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/static/index.html b/static/index.html index d5969db..594d022 100644 --- a/static/index.html +++ b/static/index.html @@ -50,16 +50,29 @@
- - - - - - - - - - + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
From d015794b03bb657ed82dd7f6bac7926a2c00f99f Mon Sep 17 00:00:00 2001 From: John Rogers Date: Tue, 29 Jul 2025 14:18:10 +0100 Subject: [PATCH 19/29] 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, From 9a9702787070ae66ab0fc64733474784e410d4a4 Mon Sep 17 00:00:00 2001 From: John Rogers Date: Tue, 29 Jul 2025 14:38:23 +0100 Subject: [PATCH 20/29] fix: remove unused out_of_sync_since variable Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) --- src/ui.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/ui.rs b/src/ui.rs index dfa0023..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; From 68dc16344aa8cedabd2f0de6e5405e3c04036a84 Mon Sep 17 00:00:00 2001 From: John Rogers Date: Tue, 29 Jul 2025 14:42:33 +0100 Subject: [PATCH 21/29] fix: preserve comments in config.yml when saving Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) --- src/config.rs | 24 ++++++++++++++++++++++-- src/main.rs | 4 ++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/config.rs b/src/config.rs index dd37850..974d60b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -78,8 +78,28 @@ impl Default for Config { } 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 516d482..e265210 100644 --- a/src/main.rs +++ b/src/main.rs @@ -47,6 +47,9 @@ hardwareOffsetMs: 20 # nudge the clock to keep it aligned with the LTC source. autoSyncEnabled: false +# 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: @@ -54,6 +57,7 @@ timeturnerOffset: minutes: 0 seconds: 0 frames: 0 + milliseconds: 0 "#; /// If no `config.yml` exists alongside the binary, write out the default. From 992720041bc9f59ce806b44091b96c5fc42837e1 Mon Sep 17 00:00:00 2001 From: John Rogers Date: Tue, 29 Jul 2025 14:49:26 +0100 Subject: [PATCH 22/29] updated config with config bodge in tests. --- config.yml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/config.yml b/config.yml index 8422c84..bf892f4 100644 --- a/config.yml +++ b/config.yml @@ -1,5 +1,13 @@ # 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. @@ -9,4 +17,3 @@ timeturnerOffset: seconds: 3 frames: 4 milliseconds: 5 -defaultNudgeMs: 2 From d814b05a26bf218708f342b0815442bbcc86537c Mon Sep 17 00:00:00 2001 From: John Rogers Date: Tue, 29 Jul 2025 15:24:20 +0100 Subject: [PATCH 23/29] fix: Display 'TIME LOCK ACTIVE' status for auto-sync Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) --- src/sync_logic.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/sync_logic.rs b/src/sync_logic.rs index d54f2cc..fc8536d 100644 --- a/src/sync_logic.rs +++ b/src/sync_logic.rs @@ -160,6 +160,8 @@ impl LtcState { pub fn get_sync_status(delta_ms: i64, config: &Config) -> &'static str { if config.timeturner_offset.is_active() { "TIMETURNING" + } else if config.auto_sync_enabled { + "TIME LOCK ACTIVE" } else if delta_ms.abs() <= 8 { "IN SYNC" } else if delta_ms > 10 { @@ -347,7 +349,11 @@ mod tests { assert_eq!(get_sync_status(-9, &config), "CLOCK BEHIND"); assert_eq!(get_sync_status(-100, &config), "CLOCK BEHIND"); - // Test TIMETURNING status + // Test auto-sync status + config.auto_sync_enabled = true; + assert_eq!(get_sync_status(0, &config), "TIME LOCK ACTIVE"); + + // Test TIMETURNING status takes precedence 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"); From 871fd192b0638d55b63b470eeae760e3be76766c Mon Sep 17 00:00:00 2001 From: Chris Frankland-Wright Date: Wed, 30 Jul 2025 21:58:45 +0100 Subject: [PATCH 24/29] docs: Correct README for time offset features Co-authored-by: aider (gemini/gemini-2.5-pro) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d7ed822..3e438eb 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Created by Chris Frankland-Wright and John Rogers - Reads SMPTE LTC from Audio Interface (3.5mm TRS but adaptable to BNC/XLR) - Converts LTC into NTP-synced time - Broadcasts time via local NTP server -- Supports configurable time offsets (hours, minutes, seconds, milliseconds) - NOT AVAILABLE +- Supports configurable time offsets (hours, minutes, seconds, frames, and milliseconds) - Systemd service support for headless operation --- From 0c6e1b0f436b8f8ca0a7463b00e2638dea075054 Mon Sep 17 00:00:00 2001 From: Chris Frankland-Wright Date: Wed, 30 Jul 2025 22:06:43 +0100 Subject: [PATCH 25/29] feat: Animate system and LTC clocks client-side for dynamic display Co-authored-by: aider (gemini/gemini-2.5-pro) --- static/script.js | 65 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/static/script.js b/static/script.js index 22c8dd7..0dce505 100644 --- a/static/script.js +++ b/static/script.js @@ -1,4 +1,7 @@ document.addEventListener('DOMContentLoaded', () => { + let lastApiData = null; + let lastApiFetchTime = null; + const statusElements = { ltcStatus: document.getElementById('ltc-status'), ltcTimecode: document.getElementById('ltc-timecode'), @@ -65,14 +68,75 @@ document.addEventListener('DOMContentLoaded', () => { } } + function animateClocks() { + if (!lastApiData || !lastApiFetchTime) return; + + const elapsedMs = new Date() - lastApiFetchTime; + + // Animate System Clock + if (lastApiData.system_clock && lastApiData.system_clock.includes(':')) { + const parts = lastApiData.system_clock.split(/[:.]/); + if (parts.length === 4) { + const baseDate = new Date(); + baseDate.setHours(parseInt(parts[0], 10), parseInt(parts[1], 10), parseInt(parts[2], 10)); + baseDate.setMilliseconds(parseInt(parts[3], 10)); + + const newDate = new Date(baseDate.getTime() + elapsedMs); + + const h = String(newDate.getHours()).padStart(2, '0'); + const m = String(newDate.getMinutes()).padStart(2, '0'); + const s = String(newDate.getSeconds()).padStart(2, '0'); + const ms = String(newDate.getMilliseconds()).padStart(3, '0'); + statusElements.systemClock.textContent = `${h}:${m}:${s}.${ms}`; + } + } + + // Animate LTC Timecode - only if status is LOCK + if (lastApiData.ltc_status === 'LOCK' && lastApiData.ltc_timecode && lastApiData.ltc_timecode.includes(':') && lastApiData.frame_rate) { + const tcParts = lastApiData.ltc_timecode.split(':'); + const frameRate = parseFloat(lastApiData.frame_rate); + if (tcParts.length === 4 && !isNaN(frameRate) && frameRate > 0) { + let h = parseInt(tcParts[0], 10); + let m = parseInt(tcParts[1], 10); + let s = parseInt(tcParts[2], 10); + let f = parseInt(tcParts[3], 10); + + const msPerFrame = 1000.0 / frameRate; + const elapsedFrames = Math.floor(elapsedMs / msPerFrame); + + f += elapsedFrames; + + const frameRateInt = Math.round(frameRate); + + s += Math.floor(f / frameRateInt); + f %= frameRateInt; + + m += Math.floor(s / 60); + s %= 60; + + h += Math.floor(m / 60); + m %= 60; + + h %= 24; + + statusElements.ltcTimecode.textContent = + `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}:${String(f).padStart(2, '0')}`; + } + } + } + async function fetchStatus() { try { const response = await fetch('/api/status'); if (!response.ok) throw new Error('Failed to fetch status'); const data = await response.json(); updateStatus(data); + lastApiData = data; + lastApiFetchTime = new Date(); } catch (error) { console.error('Error fetching status:', error); + lastApiData = null; + lastApiFetchTime = null; } } @@ -193,4 +257,5 @@ document.addEventListener('DOMContentLoaded', () => { // Refresh data every 2 seconds setInterval(fetchStatus, 2000); setInterval(fetchLogs, 2000); + setInterval(animateClocks, 50); // High-frequency clock animation }); From 0745883e0dcfb6fd67b4c2b6d50069deacca12b0 Mon Sep 17 00:00:00 2001 From: Chris Frankland-Wright Date: Wed, 30 Jul 2025 22:21:09 +0100 Subject: [PATCH 26/29] Revert "docs: Correct README for time offset features" This reverts commit 871fd192b0638d55b63b470eeae760e3be76766c. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3e438eb..d7ed822 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Created by Chris Frankland-Wright and John Rogers - Reads SMPTE LTC from Audio Interface (3.5mm TRS but adaptable to BNC/XLR) - Converts LTC into NTP-synced time - Broadcasts time via local NTP server -- Supports configurable time offsets (hours, minutes, seconds, frames, and milliseconds) +- Supports configurable time offsets (hours, minutes, seconds, milliseconds) - NOT AVAILABLE - Systemd service support for headless operation --- From 3df94667549a272b7d5e0322643476b5d9541566 Mon Sep 17 00:00:00 2001 From: Chris Frankland-Wright Date: Wed, 30 Jul 2025 22:25:10 +0100 Subject: [PATCH 27/29] animate timecode --- static/script.js | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/static/script.js b/static/script.js index 0dce505..ba997a4 100644 --- a/static/script.js +++ b/static/script.js @@ -41,7 +41,7 @@ document.addEventListener('DOMContentLoaded', () => { statusElements.frameRate.textContent = data.frame_rate; statusElements.lockRatio.textContent = data.lock_ratio.toFixed(2); statusElements.systemClock.textContent = data.system_clock; - + statusElements.ntpActive.textContent = data.ntp_active ? 'Active' : 'Inactive'; statusElements.ntpActive.className = data.ntp_active ? 'active' : 'inactive'; @@ -50,7 +50,7 @@ document.addEventListener('DOMContentLoaded', () => { statusElements.deltaMs.textContent = data.timecode_delta_ms; statusElements.deltaFrames.textContent = data.timecode_delta_frames; - + statusElements.jitterStatus.textContent = data.jitter_status; statusElements.jitterStatus.className = data.jitter_status.toLowerCase(); @@ -80,9 +80,9 @@ document.addEventListener('DOMContentLoaded', () => { const baseDate = new Date(); baseDate.setHours(parseInt(parts[0], 10), parseInt(parts[1], 10), parseInt(parts[2], 10)); baseDate.setMilliseconds(parseInt(parts[3], 10)); - + const newDate = new Date(baseDate.getTime() + elapsedMs); - + const h = String(newDate.getHours()).padStart(2, '0'); const m = String(newDate.getMinutes()).padStart(2, '0'); const s = String(newDate.getSeconds()).padStart(2, '0'); @@ -100,26 +100,26 @@ document.addEventListener('DOMContentLoaded', () => { let m = parseInt(tcParts[1], 10); let s = parseInt(tcParts[2], 10); let f = parseInt(tcParts[3], 10); - + const msPerFrame = 1000.0 / frameRate; const elapsedFrames = Math.floor(elapsedMs / msPerFrame); - + f += elapsedFrames; - + const frameRateInt = Math.round(frameRate); - + s += Math.floor(f / frameRateInt); f %= frameRateInt; - + m += Math.floor(s / 60); s %= 60; - + h += Math.floor(m / 60); m %= 60; - + h %= 24; - - statusElements.ltcTimecode.textContent = + + statusElements.ltcTimecode.textContent = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}:${String(f).padStart(2, '0')}`; } } @@ -164,10 +164,10 @@ document.addEventListener('DOMContentLoaded', () => { autoSyncEnabled: autoSyncCheckbox.checked, defaultNudgeMs: parseInt(nudgeValueInput.value, 10) || 0, timeturnerOffset: { - hours: parseInt(offsetInputs.h.value, 10) || 0, + 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, + frames: parseInt(offsetInputs.f.value, 10) || 0, milliseconds: parseInt(offsetInputs.ms.value, 10) || 0, } }; From af43388e4beb4095a6b7c9c0024b8277158e7271 Mon Sep 17 00:00:00 2001 From: Chris Frankland-Wright <85807217+cjfranko@users.noreply.github.com> Date: Wed, 30 Jul 2025 22:30:29 +0100 Subject: [PATCH 28/29] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d7ed822..115e262 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Created by Chris Frankland-Wright and John Rogers - Reads SMPTE LTC from Audio Interface (3.5mm TRS but adaptable to BNC/XLR) - Converts LTC into NTP-synced time - Broadcasts time via local NTP server -- Supports configurable time offsets (hours, minutes, seconds, milliseconds) - NOT AVAILABLE +- Supports configurable time offsets (hours, minutes, seconds, frames or milliseconds) - Systemd service support for headless operation --- From 58a1d243e4dc78a4785021465b19e0a0f8d1c7ae Mon Sep 17 00:00:00 2001 From: Chris Frankland-Wright Date: Wed, 30 Jul 2025 22:36:19 +0100 Subject: [PATCH 29/29] feat: Add system date display and setting via API Co-authored-by: aider (gemini/gemini-2.5-pro) --- docs/api.md | 28 ++++++++++++++++++++++++++++ src/api.rs | 20 ++++++++++++++++++++ src/system.rs | 27 +++++++++++++++++++++++++++ static/index.html | 7 +++++++ static/script.js | 36 ++++++++++++++++++++++++++++++++++++ 5 files changed, 118 insertions(+) diff --git a/docs/api.md b/docs/api.md index 1b76262..2959c95 100644 --- a/docs/api.md +++ b/docs/api.md @@ -17,6 +17,7 @@ This document describes the HTTP API for the NTP Timeturner application. "ltc_timecode": "10:20:30:00", "frame_rate": "25.00fps", "system_clock": "10:20:30.005", + "system_date": "2025-07-30", "timecode_delta_ms": 5, "timecode_delta_frames": 0, "sync_status": "IN SYNC", @@ -58,6 +59,33 @@ This document describes the HTTP API for the NTP Timeturner application. } ``` +- **`POST /api/set_date`** + + Sets the system date. This is useful as LTC does not contain date information. Requires `sudo` privileges. + + **Example Request:** + ```json + { + "date": "2025-07-30" + } + ``` + + **Success Response:** + ```json + { + "status": "success", + "message": "Date update command issued." + } + ``` + + **Error Response:** + ```json + { + "status": "error", + "message": "Date update command failed." + } + ``` + ### Configuration - **`GET /api/config`** diff --git a/src/api.rs b/src/api.rs index 93a883d..3917815 100644 --- a/src/api.rs +++ b/src/api.rs @@ -19,6 +19,7 @@ struct ApiStatus { ltc_timecode: String, frame_rate: String, system_clock: String, + system_date: String, timecode_delta_ms: i64, timecode_delta_frames: i64, sync_status: String, @@ -58,6 +59,7 @@ async fn get_status(data: web::Data) -> impl Responder { now_local.second(), now_local.timestamp_subsec_millis(), ); + let system_date = now_local.format("%Y-%m-%d").to_string(); let avg_delta = state.get_ewma_clock_delta(); let mut delta_frames = 0; @@ -83,6 +85,7 @@ async fn get_status(data: web::Data) -> impl Responder { ltc_timecode, frame_rate, system_clock, + system_date, timecode_delta_ms: avg_delta, timecode_delta_frames: delta_frames, sync_status: sync_status.to_string(), @@ -135,6 +138,22 @@ async fn nudge_clock(req: web::Json) -> impl Responder { } } +#[derive(Deserialize)] +struct SetDateRequest { + date: String, +} + +#[post("/api/set_date")] +async fn set_date(req: web::Json) -> impl Responder { + if system::set_date(&req.date).is_ok() { + HttpResponse::Ok() + .json(serde_json::json!({ "status": "success", "message": "Date update command issued." })) + } else { + HttpResponse::InternalServerError() + .json(serde_json::json!({ "status": "error", "message": "Date update command failed." })) + } +} + #[post("/api/config")] async fn update_config( data: web::Data, @@ -192,6 +211,7 @@ pub async fn start_api_server( .service(update_config) .service(get_logs) .service(nudge_clock) + .service(set_date) // Serve frontend static files .service(fs::Files::new("/", "static/").index_file("index.html")) }) diff --git a/src/system.rs b/src/system.rs index c3918f6..c15e452 100644 --- a/src/system.rs +++ b/src/system.rs @@ -131,6 +131,33 @@ pub fn nudge_clock(microseconds: i64) -> Result<(), ()> { } } +pub fn set_date(date: &str) -> Result<(), ()> { + #[cfg(target_os = "linux")] + { + let success = Command::new("sudo") + .arg("date") + .arg("--set") + .arg(date) + .status() + .map(|s| s.success()) + .unwrap_or(false); + + if success { + log::info!("Set system date to {}", date); + Ok(()) + } else { + log::error!("Failed to set system date"); + Err(()) + } + } + #[cfg(not(target_os = "linux"))] + { + let _ = date; + log::warn!("Date setting is only supported on Linux."); + Err(()) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/static/index.html b/static/index.html index eb074af..ee453d8 100644 --- a/static/index.html +++ b/static/index.html @@ -23,6 +23,7 @@

System Clock

--:--:--.---

+

Date: ---- -- --

NTP Service: --

Sync Status: --

@@ -90,6 +91,12 @@
+
+ + + + +
diff --git a/static/script.js b/static/script.js index ba997a4..2094cc4 100644 --- a/static/script.js +++ b/static/script.js @@ -8,6 +8,7 @@ document.addEventListener('DOMContentLoaded', () => { frameRate: document.getElementById('frame-rate'), lockRatio: document.getElementById('lock-ratio'), systemClock: document.getElementById('system-clock'), + systemDate: document.getElementById('system-date'), ntpActive: document.getElementById('ntp-active'), syncStatus: document.getElementById('sync-status'), deltaMs: document.getElementById('delta-ms'), @@ -35,12 +36,17 @@ document.addEventListener('DOMContentLoaded', () => { const nudgeValueInput = document.getElementById('nudge-value'); const nudgeMessage = document.getElementById('nudge-message'); + const dateInput = document.getElementById('date-input'); + const setDateButton = document.getElementById('set-date'); + const dateMessage = document.getElementById('date-message'); + function updateStatus(data) { statusElements.ltcStatus.textContent = data.ltc_status; statusElements.ltcTimecode.textContent = data.ltc_timecode; statusElements.frameRate.textContent = data.frame_rate; statusElements.lockRatio.textContent = data.lock_ratio.toFixed(2); statusElements.systemClock.textContent = data.system_clock; + statusElements.systemDate.textContent = data.system_date; statusElements.ntpActive.textContent = data.ntp_active ? 'Active' : 'Inactive'; statusElements.ntpActive.className = data.ntp_active ? 'active' : 'inactive'; @@ -238,6 +244,35 @@ document.addEventListener('DOMContentLoaded', () => { setTimeout(() => { nudgeMessage.textContent = ''; }, 3000); } + async function setDate() { + const date = dateInput.value; + if (!date) { + alert('Please select a date.'); + return; + } + + dateMessage.textContent = 'Setting date...'; + try { + const response = await fetch('/api/set_date', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ date: date }), + }); + const data = await response.json(); + if (response.ok) { + dateMessage.textContent = `Success: ${data.message}`; + // Fetch status again to update the displayed date immediately + fetchStatus(); + } else { + dateMessage.textContent = `Error: ${data.message}`; + } + } catch (error) { + console.error('Error setting date:', error); + dateMessage.textContent = 'Failed to send date command.'; + } + setTimeout(() => { dateMessage.textContent = ''; }, 5000); + } + saveConfigButton.addEventListener('click', saveConfig); manualSyncButton.addEventListener('click', triggerManualSync); nudgeDownButton.addEventListener('click', () => { @@ -248,6 +283,7 @@ document.addEventListener('DOMContentLoaded', () => { const ms = parseInt(nudgeValueInput.value, 10) || 0; nudgeClock(ms); }); + setDateButton.addEventListener('click', setDate); // Initial data load fetchStatus();