diff --git a/Cargo.toml b/Cargo.toml index f50e457..06498ec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,8 +10,10 @@ crossterm = "0.29" regex = "1.11" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0.141" +serde_yaml = "0.9" notify = "8.1.0" get_if_addrs = "0.5" actix-web = "4" +actix-files = "0.6" tokio = { version = "1", features = ["full"] } diff --git a/config.json b/config.json deleted file mode 100644 index 5ba71c3..0000000 --- a/config.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "hardware_offset_ms": 20 -} diff --git a/config.yml b/config.yml new file mode 100644 index 0000000..470c6c9 --- /dev/null +++ b/config.yml @@ -0,0 +1,10 @@ +# Hardware offset in milliseconds for correcting capture latency. +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 diff --git a/src/api.rs b/src/api.rs index 66b6c04..659cd73 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,4 +1,5 @@ +use actix_files as fs; use actix_web::{get, post, web, App, HttpResponse, HttpServer, Responder}; use chrono::{Local, Timelike}; use get_if_addrs::get_if_addrs; @@ -30,13 +31,14 @@ struct ApiStatus { // AppState to hold shared data pub struct AppState { pub ltc_state: Arc>, - pub hw_offset: Arc>, + pub config: Arc>, } #[get("/api/status")] async fn get_status(data: web::Data) -> impl Responder { let state = data.ltc_state.lock().unwrap(); - let hw_offset_ms = *data.hw_offset.lock().unwrap(); + let config = data.config.lock().unwrap(); + let hw_offset_ms = config.hardware_offset_ms; let ltc_status = state.latest.as_ref().map_or("(waiting)".to_string(), |f| f.status.clone()); let ltc_timecode = state.latest.as_ref().map_or("…".to_string(), |f| { @@ -62,7 +64,7 @@ async fn get_status(data: web::Data) -> impl Responder { delta_frames = ((avg_delta as f64 / frame_ms).round()) as i64; } - let sync_status = ui::get_sync_status(avg_delta).to_string(); + let sync_status = ui::get_sync_status(avg_delta, &config).to_string(); let jitter_status = ui::get_jitter_status(state.average_jitter()).to_string(); let lock_ratio = state.lock_ratio(); @@ -93,8 +95,9 @@ async fn get_status(data: web::Data) -> impl Responder { #[post("/api/sync")] async fn manual_sync(data: web::Data) -> impl Responder { let state = data.ltc_state.lock().unwrap(); + let config = data.config.lock().unwrap(); if let Some(frame) = &state.latest { - if ui::trigger_sync(frame).is_ok() { + if ui::trigger_sync(frame, &config).is_ok() { HttpResponse::Ok().json(serde_json::json!({ "status": "success", "message": "Sync command issued." })) } else { HttpResponse::InternalServerError().json(serde_json::json!({ "status": "error", "message": "Sync command failed." })) @@ -104,49 +107,35 @@ async fn manual_sync(data: web::Data) -> impl Responder { } } -#[derive(Serialize, Deserialize)] -struct ConfigResponse { - hardware_offset_ms: i64, -} - #[get("/api/config")] async fn get_config(data: web::Data) -> impl Responder { - let hw_offset_ms = *data.hw_offset.lock().unwrap(); - HttpResponse::Ok().json(ConfigResponse { hardware_offset_ms: hw_offset_ms }) -} - -#[derive(Deserialize)] -struct UpdateConfigRequest { - hardware_offset_ms: i64, + let config = data.config.lock().unwrap(); + HttpResponse::Ok().json(&*config) } #[post("/api/config")] async fn update_config( data: web::Data, - req: web::Json, + req: web::Json, ) -> impl Responder { - let mut hw_offset = data.hw_offset.lock().unwrap(); - *hw_offset = req.hardware_offset_ms; + let mut config = data.config.lock().unwrap(); + *config = req.into_inner(); - let new_config = Config { - hardware_offset_ms: *hw_offset, - }; - - if config::save_config("config.json", &new_config).is_ok() { - eprintln!("🔄 Saved hardware_offset_ms = {} via API", *hw_offset); - HttpResponse::Ok().json(&new_config) + if config::save_config("config.yml", &config).is_ok() { + eprintln!("🔄 Saved config via API: {:?}", *config); + HttpResponse::Ok().json(&*config) } else { - HttpResponse::InternalServerError().json(serde_json::json!({ "status": "error", "message": "Failed to write config.json" })) + HttpResponse::InternalServerError().json(serde_json::json!({ "status": "error", "message": "Failed to write config.yml" })) } } pub async fn start_api_server( state: Arc>, - offset: Arc>, + config: Arc>, ) -> std::io::Result<()> { let app_state = web::Data::new(AppState { ltc_state: state, - hw_offset: offset, + config: config, }); println!("🚀 Starting API server at http://0.0.0.0:8080"); @@ -158,6 +147,8 @@ pub async fn start_api_server( .service(manual_sync) .service(get_config) .service(update_config) + // Serve frontend static files + .service(fs::Files::new("/", "static/").index_file("index.html")) }) .bind("0.0.0.0:8080")? .run() @@ -167,6 +158,7 @@ pub async fn start_api_server( #[cfg(test)] mod tests { use super::*; + use crate::config::TimeturnerOffset; use crate::sync_logic::LtcFrame; use actix_web::{test, App}; use chrono::Utc; @@ -174,7 +166,7 @@ mod tests { use std::fs; // Helper to create a default LtcState for tests - fn get_test_state() -> LtcState { + fn get_test_ltc_state() -> LtcState { LtcState { latest: Some(LtcFrame { status: "LOCK".to_string(), @@ -194,16 +186,21 @@ mod tests { } } + // Helper to create a default AppState for tests + fn get_test_app_state() -> web::Data { + 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 + } + })); + web::Data::new(AppState { ltc_state, config }) + } + #[actix_web::test] async fn test_get_status() { - let ltc_state = Arc::new(Mutex::new(get_test_state())); - let hw_offset = Arc::new(Mutex::new(10i64)); - - let app_state = web::Data::new(AppState { - ltc_state: ltc_state.clone(), - hw_offset: hw_offset.clone(), - }); - + let app_state = get_test_app_state(); let app = test::init_service( App::new() .app_data(app_state.clone()) @@ -222,13 +219,8 @@ mod tests { #[actix_web::test] async fn test_get_config() { - let ltc_state = Arc::new(Mutex::new(LtcState::new())); - let hw_offset = Arc::new(Mutex::new(25i64)); - - let app_state = web::Data::new(AppState { - ltc_state: ltc_state.clone(), - hw_offset: hw_offset.clone(), - }); + let app_state = get_test_app_state(); + app_state.config.lock().unwrap().hardware_offset_ms = 25; let app = test::init_service( App::new() @@ -238,26 +230,20 @@ mod tests { .await; let req = test::TestRequest::get().uri("/api/config").to_request(); - let resp: ConfigResponse = test::call_and_read_body_json(&app, req).await; + let resp: Config = test::call_and_read_body_json(&app, req).await; assert_eq!(resp.hardware_offset_ms, 25); } #[actix_web::test] async fn test_update_config() { - let ltc_state = Arc::new(Mutex::new(LtcState::new())); - let hw_offset = Arc::new(Mutex::new(0i64)); - let config_path = "config.json"; + let app_state = get_test_app_state(); + let config_path = "config.yml"; - // This test has the side effect of writing to `config.json`. + // This test has the side effect of writing to `config.yml`. // We ensure it's cleaned up after. let _ = fs::remove_file(config_path); - let app_state = web::Data::new(AppState { - ltc_state: ltc_state.clone(), - hw_offset: hw_offset.clone(), - }); - let app = test::init_service( App::new() .app_data(app_state.clone()) @@ -265,20 +251,29 @@ mod tests { ) .await; + let new_config_json = serde_json::json!({ + "hardwareOffsetMs": 55, + "timeturnerOffset": { "hours": 1, "minutes": 2, "seconds": 3, "frames": 4 } + }); + let req = test::TestRequest::post() .uri("/api/config") - .set_json(&serde_json::json!({ "hardware_offset_ms": 55 })) + .set_json(&new_config_json) .to_request(); let resp: Config = test::call_and_read_body_json(&app, req).await; assert_eq!(resp.hardware_offset_ms, 55); - assert_eq!(*hw_offset.lock().unwrap(), 55); + assert_eq!(resp.timeturner_offset.hours, 1); + let final_config = app_state.config.lock().unwrap(); + assert_eq!(final_config.hardware_offset_ms, 55); + assert_eq!(final_config.timeturner_offset.hours, 1); // 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("\"hardware_offset_ms\": 55")); + assert!(contents.contains("hardwareOffsetMs: 55")); + assert!(contents.contains("hours: 1")); // Cleanup let _ = fs::remove_file(config_path); @@ -286,14 +281,9 @@ mod tests { #[actix_web::test] async fn test_manual_sync_no_ltc() { + let app_state = get_test_app_state(); // State with no LTC frame - let ltc_state = Arc::new(Mutex::new(LtcState::new())); - let hw_offset = Arc::new(Mutex::new(0i64)); - - let app_state = web::Data::new(AppState { - ltc_state: ltc_state.clone(), - hw_offset: hw_offset.clone(), - }); + app_state.ltc_state.lock().unwrap().latest = None; let app = test::init_service( App::new() diff --git a/src/config.rs b/src/config.rs index 6c6b639..c7caf15 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,5 +1,4 @@ // src/config.rs - use notify::{ recommended_watcher, Event, EventKind, RecommendedWatcher, RecursiveMode, Result as NotifyResult, Watcher, @@ -13,63 +12,90 @@ use std::{ sync::{Arc, Mutex}, }; -#[derive(Deserialize, Serialize, Clone)] +#[derive(Deserialize, Serialize, Clone, Debug, Default)] +#[serde(rename_all = "camelCase")] +pub struct TimeturnerOffset { + pub hours: i64, + pub minutes: i64, + pub seconds: i64, + pub frames: i64, +} + +impl TimeturnerOffset { + pub fn is_active(&self) -> bool { + self.hours != 0 || self.minutes != 0 || self.seconds != 0 || self.frames != 0 + } +} + +#[derive(Deserialize, Serialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] pub struct Config { pub hardware_offset_ms: i64, + #[serde(default)] + pub timeturner_offset: TimeturnerOffset, } impl Config { pub fn load(path: &PathBuf) -> Self { let mut file = match File::open(path) { Ok(f) => f, - Err(_) => return Self { hardware_offset_ms: 0 }, + Err(_) => return Self::default(), }; let mut contents = String::new(); if file.read_to_string(&mut contents).is_err() { - return Self { hardware_offset_ms: 0 }; + return Self::default(); } - serde_json::from_str(&contents).unwrap_or(Self { hardware_offset_ms: 0 }) + serde_yaml::from_str(&contents).unwrap_or_else(|e| { + eprintln!("Failed to parse config, using default: {}", e); + Self::default() + }) } } -pub fn save_config(path: &str, config: &Config) -> std::io::Result<()> { - let contents = serde_json::to_string_pretty(config)?; - fs::write(path, contents) +impl Default for Config { + fn default() -> Self { + Self { + hardware_offset_ms: 0, + timeturner_offset: TimeturnerOffset::default(), + } + } } -pub fn watch_config(path: &str) -> Arc> { - let initial = Config::load(&PathBuf::from(path)).hardware_offset_ms; - let offset = Arc::new(Mutex::new(initial)); +pub fn save_config(path: &str, config: &Config) -> Result<(), Box> { + let contents = serde_yaml::to_string(config)?; + fs::write(path, contents)?; + Ok(()) +} + +pub fn watch_config(path: &str) -> Arc> { + let initial_config = Config::load(&PathBuf::from(path)); + let config = Arc::new(Mutex::new(initial_config)); - // Owned PathBuf for watch() call let watch_path = PathBuf::from(path); - // Clone for moving into the closure let watch_path_for_cb = watch_path.clone(); - let offset_for_cb = Arc::clone(&offset); + let config_for_cb = Arc::clone(&config); std::thread::spawn(move || { - // Move `watch_path_for_cb` into the callback let mut watcher: RecommendedWatcher = recommended_watcher(move |res: NotifyResult| { if let Ok(evt) = res { if matches!(evt.kind, EventKind::Modify(_)) { let new_cfg = Config::load(&watch_path_for_cb); - let mut hw = offset_for_cb.lock().unwrap(); - *hw = new_cfg.hardware_offset_ms; - eprintln!("🔄 Reloaded hardware_offset_ms = {}", *hw); + let mut cfg = config_for_cb.lock().unwrap(); + *cfg = new_cfg; + eprintln!("🔄 Reloaded config.yml: {:?}", *cfg); } } }) .expect("Failed to create file watcher"); - // Use the original `watch_path` here watcher .watch(&watch_path, RecursiveMode::NonRecursive) - .expect("Failed to watch config.json"); + .expect("Failed to watch config.yml"); loop { std::thread::sleep(std::time::Duration::from_secs(60)); } }); - offset + config } diff --git a/src/main.rs b/src/main.rs index f3a2f31..f019aa4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,17 +21,26 @@ use std::{ use tokio::task::{self, LocalSet}; /// Default config content, embedded in the binary. -const DEFAULT_CONFIG: &str = r#"{ - "hardware_offset_ms": 20 -}"#; +const DEFAULT_CONFIG: &str = r#" +# Hardware offset in milliseconds for correcting capture latency. +hardwareOffsetMs: 20 -/// If no `config.json` exists alongside the binary, write out the default. +# 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 +"#; + +/// If no `config.yml` exists alongside the binary, write out the default. fn ensure_config() { - let p = Path::new("config.json"); + let p = Path::new("config.yml"); if !p.exists() { - fs::write(p, DEFAULT_CONFIG) - .expect("Failed to write default config.json"); - eprintln!("⚙️ Emitted default config.json"); + fs::write(p, DEFAULT_CONFIG.trim()) + .expect("Failed to write default config.yml"); + eprintln!("⚙️ Emitted default config.yml"); } } @@ -40,9 +49,9 @@ async fn main() { // 🔄 Ensure there's always a config.json present ensure_config(); - // 1️⃣ Start watching config.json for changes - let hw_offset = watch_config("config.json"); - println!("🔧 Watching config.json (hardware_offset_ms)..."); + // 1️⃣ Start watching config.yml for changes + let config = watch_config("config.yml"); + println!("🔧 Watching config.yml..."); // 2️⃣ Channel for raw LTC frames let (tx, rx) = mpsc::channel(); @@ -68,14 +77,14 @@ async fn main() { }); } - // 5️⃣ Spawn the UI renderer thread, passing the live offset Arc + // 5️⃣ Spawn the UI renderer thread, passing the live config Arc { let ui_state = ltc_state.clone(); - let offset_clone = hw_offset.clone(); + let config_clone = config.clone(); let port = "/dev/ttyACM0".to_string(); thread::spawn(move || { println!("🖥️ UI thread launched"); - start_ui(ui_state, port, offset_clone); + start_ui(ui_state, port, config_clone); }); } @@ -86,9 +95,9 @@ async fn main() { // 7️⃣ Spawn the API server thread { let api_state = ltc_state.clone(); - let offset_clone = hw_offset.clone(); + let config_clone = config.clone(); task::spawn_local(async move { - if let Err(e) = start_api_server(api_state, offset_clone).await { + if let Err(e) = start_api_server(api_state, config_clone).await { eprintln!("API server error: {}", e); } }); @@ -118,7 +127,7 @@ mod tests { impl Drop for ConfigGuard { fn drop(&mut self) { - let _ = fs::remove_file("config.json"); + let _ = fs::remove_file("config.yml"); } } @@ -127,28 +136,28 @@ mod tests { let _guard = ConfigGuard; // Cleanup when _guard goes out of scope. // --- Test 1: File creation --- - // Pre-condition: config.json does not exist. - let _ = fs::remove_file("config.json"); + // Pre-condition: config.yml does not exist. + let _ = fs::remove_file("config.yml"); ensure_config(); - // Post-condition: config.json exists and has default content. - let p = Path::new("config.json"); - assert!(p.exists(), "config.json should have been created"); - let contents = fs::read_to_string(p).expect("Failed to read created config.json"); - assert_eq!(contents, DEFAULT_CONFIG, "config.json content should match default"); + // Post-condition: config.yml exists and has default content. + let p = Path::new("config.yml"); + assert!(p.exists(), "config.yml should have been created"); + let contents = fs::read_to_string(p).expect("Failed to read created config.yml"); + assert_eq!(contents, DEFAULT_CONFIG.trim(), "config.yml content should match default"); // --- Test 2: File is not overwritten --- - // Pre-condition: config.json exists with different content. - let custom_content = "{\"hardware_offset_ms\": 999}"; - fs::write("config.json", custom_content) - .expect("Failed to write custom config.json for test"); + // Pre-condition: config.yml exists with different content. + let custom_content = "hardwareOffsetMs: 999"; + fs::write("config.yml", custom_content) + .expect("Failed to write custom config.yml for test"); ensure_config(); - // Post-condition: config.json still has the custom content. - let contents_after = fs::read_to_string("config.json") - .expect("Failed to read config.json after second ensure_config call"); - assert_eq!(contents_after, custom_content, "config.json should not be overwritten"); + // Post-condition: config.yml still has the custom content. + let contents_after = fs::read_to_string("config.yml") + .expect("Failed to read config.yml after second ensure_config call"); + assert_eq!(contents_after, custom_content, "config.yml should not be overwritten"); } } diff --git a/src/ui.rs b/src/ui.rs index 3dbcbb8..15eb66d 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -9,7 +9,7 @@ use std::collections::VecDeque; use chrono::{ DateTime, Local, Timelike, Utc, - NaiveTime, TimeZone, + NaiveTime, TimeZone, Duration as ChronoDuration, }; use crossterm::{ cursor::{Hide, MoveTo, Show}, @@ -19,6 +19,7 @@ use crossterm::{ terminal::{self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen}, }; +use crate::config::Config; use get_if_addrs::get_if_addrs; use crate::sync_logic::{LtcFrame, LtcState}; @@ -39,8 +40,10 @@ fn ntp_service_toggle(start: bool) { let _ = Command::new("systemctl").args(&[action, "chrony"]).status(); } -pub fn get_sync_status(delta_ms: i64) -> &'static str { - if delta_ms.abs() <= 8 { +pub fn get_sync_status(delta_ms: i64, config: &Config) -> &'static str { + if config.timeturner_offset.is_active() { + "TIMETURNING" + } else if delta_ms.abs() <= 8 { "IN SYNC" } else if delta_ms > 10 { "CLOCK AHEAD" @@ -59,18 +62,27 @@ pub fn get_jitter_status(jitter_ms: i64) -> &'static str { } } -pub fn trigger_sync(frame: &LtcFrame) -> Result { +pub fn trigger_sync(frame: &LtcFrame, config: &Config) -> Result { let today_local = Local::now().date_naive(); - let ms = ((frame.frames as f64 / frame.frame_rate) * 1000.0) - .round() as u32; - let timecode = NaiveTime::from_hms_milli_opt( - frame.hours, frame.minutes, frame.seconds, ms, - ).expect("Invalid LTC timecode"); + let ms = ((frame.frames as f64 / frame.frame_rate) * 1000.0).round() as u32; + let timecode = NaiveTime::from_hms_milli_opt(frame.hours, frame.minutes, frame.seconds, ms) + .expect("Invalid LTC timecode"); + let naive_dt = today_local.and_time(timecode); - let dt_local = Local + let mut dt_local = Local .from_local_datetime(&naive_dt) .single() .expect("Ambiguous or invalid local time"); + + // Apply timeturner offset + let offset = &config.timeturner_offset; + dt_local = dt_local + + ChronoDuration::hours(offset.hours) + + ChronoDuration::minutes(offset.minutes) + + ChronoDuration::seconds(offset.seconds); + // Frame offset needs to be converted to milliseconds + 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); #[cfg(target_os = "linux")] let (ts, success) = { let ts = dt_local.format("%H:%M:%S.%3f").to_string(); @@ -115,7 +127,7 @@ pub fn trigger_sync(frame: &LtcFrame) -> Result { pub fn start_ui( state: Arc>, serial_port: String, - offset: Arc>, + config: Arc>, ) { let mut stdout = stdout(); execute!(stdout, EnterAlternateScreen, Hide).unwrap(); @@ -128,8 +140,9 @@ pub fn start_ui( let mut cached_delta_frames: i64 = 0; loop { - // 1️⃣ hardware offset - let hw_offset_ms = *offset.lock().unwrap(); + // 1️⃣ config + let cfg = config.lock().unwrap().clone(); + let hw_offset_ms = cfg.hardware_offset_ms; // 2️⃣ Chrony + interfaces let ntp_active = ntp_service_active(); @@ -151,18 +164,27 @@ pub fn start_ui( let measured = raw - hw_offset_ms; st.record_offset(measured); - // Δ = system clock - LTC timecode (use LOCAL time) + // Δ = 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 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 dt_local = Local + 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 { @@ -197,14 +219,14 @@ pub fn start_ui( } // 6️⃣ sync status wording - let sync_status = get_sync_status(cached_delta_ms); + let sync_status = get_sync_status(cached_delta_ms, &cfg); // 7️⃣ auto‑sync (same as manual but delayed) - if sync_status != "IN SYNC" { + 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 trigger_sync(frame) { + let entry = match trigger_sync(frame, &cfg) { Ok(ts) => format!("🔄 Auto‑synced to LTC: {}", ts), Err(_) => "❌ Auto‑sync failed".into(), }; @@ -283,6 +305,8 @@ pub fn start_ui( // sync status let scol = if sync_status == "IN SYNC" { Color::Green + } else if sync_status == "TIMETURNING" { + Color::Cyan } else { Color::Red }; @@ -337,7 +361,7 @@ pub fn start_ui( } KeyCode::Char(c) if c.eq_ignore_ascii_case(&'s') => { if let Some(frame) = &state.lock().unwrap().latest { - let entry = match trigger_sync(frame) { + let entry = match trigger_sync(frame, &cfg) { Ok(ts) => format!("✔ Synced exactly to LTC: {}", ts), Err(_) => "❌ date cmd failed".into(), }; @@ -358,16 +382,24 @@ pub fn start_ui( mod tests { use super::*; + use crate::config::TimeturnerOffset; + #[test] fn test_get_sync_status() { - assert_eq!(get_sync_status(0), "IN SYNC"); - assert_eq!(get_sync_status(8), "IN SYNC"); - assert_eq!(get_sync_status(-8), "IN SYNC"); - assert_eq!(get_sync_status(9), "CLOCK BEHIND"); - assert_eq!(get_sync_status(10), "CLOCK BEHIND"); - assert_eq!(get_sync_status(11), "CLOCK AHEAD"); - assert_eq!(get_sync_status(-9), "CLOCK BEHIND"); - assert_eq!(get_sync_status(-100), "CLOCK BEHIND"); + let mut config = Config::default(); + assert_eq!(get_sync_status(0, &config), "IN SYNC"); + assert_eq!(get_sync_status(8, &config), "IN SYNC"); + assert_eq!(get_sync_status(-8, &config), "IN SYNC"); + assert_eq!(get_sync_status(9, &config), "CLOCK BEHIND"); + assert_eq!(get_sync_status(10, &config), "CLOCK BEHIND"); + assert_eq!(get_sync_status(11, &config), "CLOCK AHEAD"); + assert_eq!(get_sync_status(-9, &config), "CLOCK BEHIND"); + assert_eq!(get_sync_status(-100, &config), "CLOCK BEHIND"); + + // 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"); } #[test] diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..74a8a17 --- /dev/null +++ b/static/index.html @@ -0,0 +1,69 @@ + + + + + + NTP TimeTurner + + + +
+

NTP TimeTurner

+
+ +
+

LTC Status

+

--

+

--:--:--:--

+

-- fps

+

Lock Ratio: --%

+
+ + +
+

System Clock

+

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

+

NTP Service: --

+

Sync Status: --

+
+ + +
+

Clock Offset

+

Delta: -- ms (-- frames)

+

Jitter: --

+
+ + +
+

Network

+
    +
  • --
  • +
+
+ + +
+

Controls

+
+ + +
+
+ + + + + +
+
+ + + +
+
+
+
+ + + diff --git a/static/script.js b/static/script.js new file mode 100644 index 0000000..2195bfd --- /dev/null +++ b/static/script.js @@ -0,0 +1,137 @@ +document.addEventListener('DOMContentLoaded', () => { + 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'), + 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'), + }; + + const hwOffsetInput = document.getElementById('hw-offset'); + const offsetInputs = { + h: document.getElementById('offset-h'), + m: document.getElementById('offset-m'), + s: document.getElementById('offset-s'), + f: document.getElementById('offset-f'), + }; + const saveConfigButton = document.getElementById('save-config'); + const manualSyncButton = document.getElementById('manual-sync'); + const syncMessage = document.getElementById('sync-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.ntpActive.textContent = data.ntp_active ? 'Active' : 'Inactive'; + statusElements.ntpActive.className = data.ntp_active ? 'active' : 'inactive'; + + statusElements.syncStatus.textContent = data.sync_status; + statusElements.syncStatus.className = data.sync_status.replace(/\s+/g, '-').toLowerCase(); + + 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(); + + statusElements.interfaces.innerHTML = ''; + if (data.interfaces.length > 0) { + data.interfaces.forEach(ip => { + const li = document.createElement('li'); + li.textContent = ip; + statusElements.interfaces.appendChild(li); + }); + } else { + const li = document.createElement('li'); + li.textContent = 'No active interfaces found.'; + statusElements.interfaces.appendChild(li); + } + } + + 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); + } catch (error) { + console.error('Error fetching status:', error); + } + } + + async function fetchConfig() { + try { + const response = await fetch('/api/config'); + if (!response.ok) throw new Error('Failed to fetch config'); + const data = await response.json(); + hwOffsetInput.value = data.hardwareOffsetMs; + offsetInputs.h.value = data.timeturnerOffset.hours; + offsetInputs.m.value = data.timeturnerOffset.minutes; + offsetInputs.s.value = data.timeturnerOffset.seconds; + offsetInputs.f.value = data.timeturnerOffset.frames; + } catch (error) { + console.error('Error fetching config:', error); + } + } + + async function saveConfig() { + const config = { + hardwareOffsetMs: parseInt(hwOffsetInput.value, 10) || 0, + timeturnerOffset: { + hours: parseInt(offsetInputs.h.value, 10) || 0, + minutes: parseInt(offsetInputs.m.value, 10) || 0, + seconds: parseInt(offsetInputs.s.value, 10) || 0, + frames: parseInt(offsetInputs.f.value, 10) || 0, + } + }; + + try { + const response = await fetch('/api/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config), + }); + if (!response.ok) throw new Error('Failed to save config'); + alert('Configuration saved.'); + } catch (error) { + console.error('Error saving config:', error); + alert('Error saving configuration.'); + } + } + + async function triggerManualSync() { + syncMessage.textContent = 'Issuing sync command...'; + try { + const response = await fetch('/api/sync', { method: 'POST' }); + const data = await response.json(); + if (response.ok) { + syncMessage.textContent = `Success: ${data.message}`; + } else { + syncMessage.textContent = `Error: ${data.message}`; + } + } catch (error) { + console.error('Error triggering sync:', error); + syncMessage.textContent = 'Failed to send sync command.'; + } + setTimeout(() => { syncMessage.textContent = ''; }, 5000); + } + + saveConfigButton.addEventListener('click', saveConfig); + manualSyncButton.addEventListener('click', triggerManualSync); + + // Initial data load + fetchStatus(); + fetchConfig(); + + // Refresh data every 2 seconds + setInterval(fetchStatus, 2000); +}); diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..7bd9c20 --- /dev/null +++ b/static/style.css @@ -0,0 +1,91 @@ +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + background-color: #f4f4f9; + color: #333; + margin: 0; + padding: 20px; + display: flex; + justify-content: center; +} + +.container { + width: 100%; + max-width: 960px; +} + +h1 { + text-align: center; + color: #444; +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 20px; +} + +.card { + background: #fff; + border-radius: 8px; + padding: 20px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.card h2 { + margin-top: 0; + color: #0056b3; +} + +.card p, .card ul { + margin: 10px 0; +} + +.card ul { + padding-left: 20px; + list-style: none; +} + +.full-width { + grid-column: 1 / -1; +} + +.control-group { + margin-bottom: 15px; + display: flex; + align-items: center; + gap: 10px; +} + +input[type="number"] { + padding: 8px; + border: 1px solid #ccc; + border-radius: 4px; + width: 80px; +} + +button { + padding: 8px 15px; + border: none; + border-radius: 4px; + background-color: #007bff; + color: white; + cursor: pointer; + font-size: 14px; +} + +button:hover { + background-color: #0056b3; +} + +#sync-message { + font-style: italic; + color: #555; +} + +/* Status-specific colors */ +#sync-status.in-sync, #jitter-status.good { font-weight: bold; color: #28a745; } +#sync-status.clock-ahead, #sync-status.clock-behind, #jitter-status.average { font-weight: bold; color: #ffc107; } +#sync-status.timeturning { font-weight: bold; color: #17a2b8; } +#jitter-status.bad { font-weight: bold; color: #dc3545; } +#ntp-active.active { font-weight: bold; color: #28a745; } +#ntp-active.inactive { font-weight: bold; color: #dc3545; }