diff --git a/Cargo.toml b/Cargo.toml index b280adf..3e04d14 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,8 @@ actix-web = "4" actix-files = "0.6" tokio = { version = "1", features = ["full"] } clap = { version = "4.4", features = ["derive"] } -log = { version = "0.4", features = ["std"] } +log = "0.4" +env_logger = "0.11" daemonize = "0.5.0" diff --git a/README.md b/README.md index 115e262..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 or milliseconds) +- Supports configurable time offsets (hours, minutes, seconds, milliseconds) - NOT AVAILABLE - Systemd service support for headless operation --- diff --git a/config.yml b/config.yml index bf892f4..470c6c9 100644 --- a/config.yml +++ b/config.yml @@ -1,19 +1,10 @@ # Hardware offset in milliseconds for correcting capture latency. -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 +hardwareOffsetMs: 20 # Time-turning offsets. All values are added to the incoming LTC time. # These can be positive or negative. timeturnerOffset: - hours: 1 - minutes: 2 - seconds: 3 - frames: 4 - milliseconds: 5 + hours: 0 + minutes: 0 + seconds: 0 + frames: 0 diff --git a/docs/api.md b/docs/api.md index 2959c95..1b76262 100644 --- a/docs/api.md +++ b/docs/api.md @@ -17,7 +17,6 @@ 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", @@ -59,33 +58,6 @@ 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 3917815..0bd2d2a 100644 --- a/src/api.rs +++ b/src/api.rs @@ -5,7 +5,6 @@ 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}; @@ -19,7 +18,6 @@ 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, @@ -34,7 +32,6 @@ struct ApiStatus { pub struct AppState { pub ltc_state: Arc>, pub config: Arc>, - pub log_buffer: Arc>>, } #[get("/api/status")] @@ -59,9 +56,8 @@ 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 avg_delta = state.average_clock_delta(); let mut delta_frames = 0; if let Some(frame) = &state.latest { let frame_ms = 1000.0 / frame.frame_rate; @@ -85,7 +81,6 @@ 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(), @@ -118,42 +113,6 @@ 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) -} - -#[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." })) - } -} - -#[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, @@ -163,44 +122,23 @@ async fn update_config( *config = req.into_inner(); 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."); - } - } - + eprintln!("🔄 Saved config via API: {:?}", *config); HttpResponse::Ok().json(&*config) } else { - log::error!("Failed to write config.yml"); - HttpResponse::InternalServerError().json( - serde_json::json!({ "status": "error", "message": "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, }); - log::info!("🚀 Starting API server at http://0.0.0.0:8080"); + println!("🚀 Starting API server at http://0.0.0.0:8080"); HttpServer::new(move || { App::new() @@ -209,9 +147,6 @@ pub async fn start_api_server( .service(manual_sync) .service(get_config) .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")) }) @@ -245,7 +180,7 @@ mod tests { lock_count: 10, free_count: 1, offset_history: VecDeque::from(vec![1, 2, 3]), - ewma_clock_delta: Some(5.0), + clock_delta_history: VecDeque::from(vec![4, 5, 6]), last_match_status: "IN SYNC".to_string(), last_match_check: Utc::now().timestamp(), } @@ -256,16 +191,11 @@ 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::default(), - default_nudge_ms: 2, - auto_sync_enabled: false, + timeturner_offset: TimeturnerOffset { + hours: 0, minutes: 0, seconds: 0, frames: 0 + } })); - let log_buffer = Arc::new(Mutex::new(VecDeque::new())); - web::Data::new(AppState { - ltc_state, - config, - log_buffer, - }) + web::Data::new(AppState { ltc_state, config }) } #[actix_web::test] @@ -323,9 +253,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 } + "timeturnerOffset": { "hours": 1, "minutes": 2, "seconds": 3, "frames": 4 } }); let req = test::TestRequest::post() @@ -336,22 +264,16 @@ mod tests { let resp: Config = test::call_and_read_body_json(&app, req).await; assert_eq!(resp.hardware_offset_ms, 55); - assert_eq!(resp.auto_sync_enabled, true); assert_eq!(resp.timeturner_offset.hours, 1); - assert_eq!(resp.timeturner_offset.milliseconds, 5); let final_config = app_state.config.lock().unwrap(); assert_eq!(final_config.hardware_offset_ms, 55); - assert_eq!(final_config.auto_sync_enabled, true); assert_eq!(final_config.timeturner_offset.hours, 1); - assert_eq!(final_config.timeturner_offset.milliseconds, 5); // Test that the file was written assert!(fs::metadata(config_path).is_ok()); let contents = fs::read_to_string(config_path).unwrap(); assert!(contents.contains("hardwareOffsetMs: 55")); - assert!(contents.contains("autoSyncEnabled: true")); assert!(contents.contains("hours: 1")); - assert!(contents.contains("milliseconds: 5")); // Cleanup let _ = fs::remove_file(config_path); diff --git a/src/config.rs b/src/config.rs index 974d60b..c7caf15 100644 --- a/src/config.rs +++ b/src/config.rs @@ -19,17 +19,11 @@ 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.milliseconds != 0 + self.hours != 0 || self.minutes != 0 || self.seconds != 0 || self.frames != 0 } } @@ -39,14 +33,6 @@ pub struct Config { pub hardware_offset_ms: i64, #[serde(default)] 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 { - 2 // Default nudge is 2ms } impl Config { @@ -60,7 +46,7 @@ impl Config { return Self::default(); } serde_yaml::from_str(&contents).unwrap_or_else(|e| { - log::warn!("Failed to parse config, using default: {}", e); + eprintln!("Failed to parse config, using default: {}", e); Self::default() }) } @@ -71,35 +57,13 @@ impl Default for Config { Self { hardware_offset_ms: 0, timeturner_offset: TimeturnerOffset::default(), - default_nudge_ms: default_nudge_ms(), - auto_sync_enabled: false, } } } pub fn save_config(path: &str, config: &Config) -> Result<(), Box> { - let 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)?; + let contents = serde_yaml::to_string(config)?; + fs::write(path, contents)?; Ok(()) } @@ -118,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; - log::info!("🔄 Reloaded config.yml: {:?}", *cfg); + eprintln!("🔄 Reloaded config.yml: {:?}", *cfg); } } }) diff --git a/src/logger.rs b/src/logger.rs deleted file mode 100644 index 33c410e..0000000 --- a/src/logger.rs +++ /dev/null @@ -1,52 +0,0 @@ -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 e265210..5ee280e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,7 +2,6 @@ mod api; mod config; -mod logger; mod serial_input; mod sync_logic; mod system; @@ -15,6 +14,7 @@ use crate::sync_logic::LtcState; use crate::ui::start_ui; use clap::Parser; use daemonize::Daemonize; +use env_logger; use std::{ fs, @@ -42,14 +42,6 @@ const DEFAULT_CONFIG: &str = r#" # Hardware offset in milliseconds for correcting capture latency. hardwareOffsetMs: 20 -# Enable automatic clock synchronization. -# When enabled, the system will perform an initial full sync, then periodically -# nudge the clock to keep it aligned with the LTC source. -autoSyncEnabled: false - -# Default nudge in milliseconds for adjtimex control. -defaultNudgeMs: 2 - # Time-turning offsets. All values are added to the incoming LTC time. # These can be positive or negative. timeturnerOffset: @@ -57,7 +49,6 @@ timeturnerOffset: minutes: 0 seconds: 0 frames: 0 - milliseconds: 0 "#; /// If no `config.yml` exists alongside the binary, write out the default. @@ -66,18 +57,16 @@ fn ensure_config() { if !p.exists() { fs::write(p, DEFAULT_CONFIG.trim()) .expect("Failed to write default config.yml"); - log::info!("⚙️ Emitted default config.yml"); + eprintln!("⚙️ 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 { - log::info!("🚀 Starting daemon..."); + println!("🚀 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"); @@ -92,7 +81,7 @@ async fn main() { match daemonize.start() { Ok(_) => { /* Process is now daemonized */ } Err(e) => { - log::error!("Error daemonizing: {}", e); + eprintln!("Error daemonizing: {}", e); return; // Exit if daemonization fails } } @@ -127,9 +116,9 @@ async fn main() { // 5️⃣ Spawn UI or setup daemon logging if args.command.is_none() { - log::info!("🔧 Watching config.yml..."); - log::info!("🚀 Serial thread launched"); - log::info!("🖥️ UI thread launched"); + println!("🔧 Watching config.yml..."); + println!("🚀 Serial thread launched"); + println!("🖥️ UI thread launched"); let ui_state = ltc_state.clone(); let config_clone = config.clone(); let port = "/dev/ttyACM0".to_string(); @@ -137,127 +126,41 @@ async fn main() { start_ui(ui_state, port, config_clone); }); } else { - // In daemon mode, logging is already set up to go to stderr. - // The systemd service will capture it. + // 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(); log::info!("🚀 Starting TimeTurner daemon..."); } - // 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 + // 6️⃣ Set up a LocalSet for the API server and main loop let local = LocalSet::new(); local .run_until(async move { - // 8️⃣ Spawn the API server thread + // 7️⃣ Spawn the API server thread { 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, log_buffer_clone).await - { - log::error!("API server error: {}", e); + if let Err(e) = start_api_server(api_state, config_clone).await { + eprintln!("API server error: {}", e); } }); } - // 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 || { - for frame in rx { - 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); - } - }); - - // 1️⃣0️⃣ Keep main thread alive + // 8️⃣ Keep main thread alive if args.command.is_some() { - // In daemon mode, wait forever. The logic_task runs in the background. + // In daemon mode, wait forever. std::future::pending::<()>().await; } else { - // 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 _ = logic_task.await; + // In TUI mode, block on the channel. + println!("📡 Main thread entering loop..."); + let _ = task::spawn_blocking(move || { + for _frame in rx { + // no-op + } + }) + .await; } }) .await; @@ -269,35 +172,18 @@ mod tests { use std::fs; use std::path::Path; - /// 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(), - } - } - } + /// RAII guard to ensure config file is cleaned up after test. + struct ConfigGuard; impl Drop for ConfigGuard { fn drop(&mut self) { - 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"); - } + let _ = fs::remove_file("config.yml"); } } #[test] fn test_ensure_config() { - let _guard = ConfigGuard::new(); // Cleanup when _guard goes out of scope. + let _guard = ConfigGuard; // Cleanup when _guard goes out of scope. // --- Test 1: File creation --- // Pre-condition: config.yml does not exist. diff --git a/src/sync_logic.rs b/src/sync_logic.rs index fc8536d..b1cbf8b 100644 --- a/src/sync_logic.rs +++ b/src/sync_logic.rs @@ -3,8 +3,6 @@ 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, @@ -44,8 +42,8 @@ pub struct LtcState { pub free_count: u32, /// Stores the last up-to-20 raw offset measurements in ms. pub offset_history: VecDeque, - /// EWMA of clock delta. - pub ewma_clock_delta: Option, + /// Stores the last up-to-20 timecode Δ measurements in ms. + pub clock_delta_history: VecDeque, pub last_match_status: String, pub last_match_check: i64, } @@ -57,7 +55,7 @@ impl LtcState { lock_count: 0, free_count: 0, offset_history: VecDeque::with_capacity(20), - ewma_clock_delta: None, + clock_delta_history: VecDeque::with_capacity(20), last_match_status: "UNKNOWN".into(), last_match_check: 0, } @@ -71,14 +69,12 @@ impl LtcState { self.offset_history.push_back(offset_ms); } - /// 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); + /// 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(); } + self.clock_delta_history.push_back(delta_ms); } /// Clear all stored jitter measurements. @@ -86,6 +82,11 @@ 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() { @@ -107,7 +108,7 @@ impl LtcState { "FREE" => { self.free_count += 1; self.clear_offsets(); - self.ewma_clock_delta = None; + self.clear_clock_deltas(); self.last_match_status = "UNKNOWN".into(); } _ => {} @@ -136,9 +137,23 @@ impl LtcState { } } - /// 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) + /// 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] + } } /// Percentage of samples seen in LOCK state versus total. @@ -160,8 +175,6 @@ 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 { @@ -313,28 +326,35 @@ mod tests { } #[test] - fn test_ewma_clock_delta() { + fn test_average_clock_delta_is_median() { let mut state = LtcState::new(); - assert_eq!(state.get_ewma_clock_delta(), 0); - // First value initializes the EWMA - state.record_and_update_ewma_clock_delta(100); - assert_eq!(state.get_ewma_clock_delta(), 100); + // Establish a stable set of values + for _ in 0..19 { + state.record_clock_delta(2); + } + state.record_clock_delta(100); // Add an 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); + // 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" + ); - // 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 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" + ); } #[test] @@ -349,12 +369,8 @@ mod tests { assert_eq!(get_sync_status(-9, &config), "CLOCK BEHIND"); assert_eq!(get_sync_status(-100, &config), "CLOCK BEHIND"); - // 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 }; + // Test TIMETURNING status + config.timeturner_offset = TimeturnerOffset { hours: 1, minutes: 0, seconds: 0, frames: 0 }; assert_eq!(get_sync_status(0, &config), "TIMETURNING"); assert_eq!(get_sync_status(100, &config), "TIMETURNING"); } diff --git a/src/system.rs b/src/system.rs index c15e452..979df17 100644 --- a/src/system.rs +++ b/src/system.rs @@ -57,7 +57,7 @@ pub fn calculate_target_time(frame: &LtcFrame, config: &Config) -> DateTime Result { @@ -104,60 +104,6 @@ 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(()) - } -} - -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::*; @@ -204,7 +150,6 @@ mod tests { minutes: 5, seconds: 10, frames: 12, // 12 frames at 25fps is 480ms - milliseconds: 20, }; let target_time = calculate_target_time(&frame, &config); @@ -212,8 +157,8 @@ mod tests { assert_eq!(target_time.hour(), 11); assert_eq!(target_time.minute(), 25); assert_eq!(target_time.second(), 40); - // 480ms + 20ms = 500ms - assert_eq!(target_time.nanosecond(), 500_000_000); + // 480ms + assert_eq!(target_time.nanosecond(), 480_000_000); } #[test] @@ -225,20 +170,13 @@ 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(), 19); - assert_eq!(target_time.nanosecond(), 920_000_000); - } - - #[test] - fn test_nudge_clock_on_non_linux() { - #[cfg(not(target_os = "linux"))] - assert!(nudge_clock(1000).is_err()); + assert_eq!(target_time.second(), 20); + assert_eq!(target_time.nanosecond(), 0); } } diff --git a/src/ui.rs b/src/ui.rs index b36e9e3..7d1c265 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -9,6 +9,7 @@ use std::collections::VecDeque; use chrono::{ DateTime, Local, Timelike, Utc, + NaiveTime, TimeZone, Duration as ChronoDuration, }; use crossterm::{ cursor::{Hide, MoveTo, Show}, @@ -34,6 +35,7 @@ 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; @@ -52,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() { @@ -62,6 +64,33 @@ 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(); } } } @@ -74,7 +103,7 @@ pub fn start_ui( st.average_frames(), st.timecode_match().to_string(), st.lock_ratio(), - st.get_ewma_clock_delta(), + st.average_clock_delta(), ) }; @@ -93,7 +122,28 @@ pub fn start_ui( // 6️⃣ sync status wording let sync_status = get_sync_status(cached_delta_ms, &cfg); - // 7️⃣ header & LTC metrics display + // 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 { let st = state.lock().unwrap(); let opt = st.latest.as_ref(); @@ -229,3 +279,10 @@ pub fn start_ui( } } +#[cfg(test)] +mod tests { + #[allow(unused_imports)] + use super::*; + #[allow(unused_imports)] + use crate::config::TimeturnerOffset; +} diff --git a/static/index.html b/static/index.html index ee453d8..74a8a17 100644 --- a/static/index.html +++ b/static/index.html @@ -23,7 +23,6 @@

System Clock

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

-

Date: ---- -- --

NTP Service: --

Sync Status: --

@@ -51,58 +50,17 @@
- - -
-
- -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
+ + + + +
-
- - - - - -
-
- - - - -
- - - -
-

Logs

-

             
diff --git a/static/script.js b/static/script.js index 2094cc4..2195bfd 100644 --- a/static/script.js +++ b/static/script.js @@ -1,53 +1,36 @@ document.addEventListener('DOMContentLoaded', () => { - let lastApiData = null; - let lastApiFetchTime = null; - const statusElements = { ltcStatus: document.getElementById('ltc-status'), ltcTimecode: document.getElementById('ltc-timecode'), 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'), deltaFrames: document.getElementById('delta-frames'), jitterStatus: document.getElementById('jitter-status'), interfaces: document.getElementById('interfaces'), - logs: document.getElementById('logs'), }; const hwOffsetInput = document.getElementById('hw-offset'); - const autoSyncCheckbox = document.getElementById('auto-sync-enabled'); const offsetInputs = { h: document.getElementById('offset-h'), m: document.getElementById('offset-m'), s: document.getElementById('offset-s'), f: document.getElementById('offset-f'), - ms: document.getElementById('offset-ms'), }; const saveConfigButton = document.getElementById('save-config'); const manualSyncButton = document.getElementById('manual-sync'); 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'); - - 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'; @@ -56,7 +39,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(); @@ -74,75 +57,14 @@ 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; } } @@ -152,13 +74,10 @@ document.addEventListener('DOMContentLoaded', () => { if (!response.ok) throw new Error('Failed to fetch config'); const data = await response.json(); hwOffsetInput.value = data.hardwareOffsetMs; - autoSyncCheckbox.checked = data.autoSyncEnabled; offsetInputs.h.value = data.timeturnerOffset.hours; offsetInputs.m.value = data.timeturnerOffset.minutes; offsetInputs.s.value = data.timeturnerOffset.seconds; offsetInputs.f.value = data.timeturnerOffset.frames; - offsetInputs.ms.value = data.timeturnerOffset.milliseconds || 0; - nudgeValueInput.value = data.defaultNudgeMs; } catch (error) { console.error('Error fetching config:', error); } @@ -167,14 +86,11 @@ 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, + hours: parseInt(offsetInputs.h.value, 10) || 0, minutes: parseInt(offsetInputs.m.value, 10) || 0, seconds: parseInt(offsetInputs.s.value, 10) || 0, - frames: parseInt(offsetInputs.f.value, 10) || 0, - milliseconds: parseInt(offsetInputs.ms.value, 10) || 0, + frames: parseInt(offsetInputs.f.value, 10) || 0, } }; @@ -192,20 +108,6 @@ 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 { @@ -223,75 +125,13 @@ 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); - } - - 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', () => { - const ms = parseInt(nudgeValueInput.value, 10) || 0; - nudgeClock(-ms); - }); - nudgeUpButton.addEventListener('click', () => { - const ms = parseInt(nudgeValueInput.value, 10) || 0; - nudgeClock(ms); - }); - setDateButton.addEventListener('click', setDate); // Initial data load fetchStatus(); fetchConfig(); - fetchLogs(); // Refresh data every 2 seconds setInterval(fetchStatus, 2000); - setInterval(fetchLogs, 2000); - setInterval(animateClocks, 50); // High-frequency clock animation });