diff --git a/src/config.rs b/src/config.rs index 2504450..a9bf931 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,69 +1,69 @@ -// src/config.rs - -use notify::{ - recommended_watcher, Event, EventKind, RecommendedWatcher, RecursiveMode, Result as NotifyResult, - Watcher, -}; -use serde::Deserialize; -use std::{ - fs::File, - io::Read, - path::PathBuf, - sync::{Arc, Mutex}, -}; - -#[derive(Deserialize)] -pub struct Config { - pub hardware_offset_ms: i64, -} - -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 }, - }; - let mut contents = String::new(); - if file.read_to_string(&mut contents).is_err() { - return Self { hardware_offset_ms: 0 }; - } - serde_json::from_str(&contents).unwrap_or(Self { hardware_offset_ms: 0 }) - } -} - -pub fn watch_config(path: &str) -> Arc> { - let initial = Config::load(&PathBuf::from(path)).hardware_offset_ms; - let offset = Arc::new(Mutex::new(initial)); - - // 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); - - 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); - } - } - }) - .expect("Failed to create file watcher"); - - // Use the original `watch_path` here - watcher - .watch(&watch_path, RecursiveMode::NonRecursive) - .expect("Failed to watch config.json"); - - loop { - std::thread::sleep(std::time::Duration::from_secs(60)); - } - }); - - offset -} +// src/config.rs + +use notify::{ + recommended_watcher, Event, EventKind, RecommendedWatcher, RecursiveMode, Result as NotifyResult, + Watcher, +}; +use serde::Deserialize; +use std::{ + fs::File, + io::Read, + path::PathBuf, + sync::{Arc, Mutex}, +}; + +#[derive(Deserialize)] +pub struct Config { + pub hardware_offset_ms: i64, +} + +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 }, + }; + let mut contents = String::new(); + if file.read_to_string(&mut contents).is_err() { + return Self { hardware_offset_ms: 0 }; + } + serde_json::from_str(&contents).unwrap_or(Self { hardware_offset_ms: 0 }) + } +} + +pub fn watch_config(path: &str) -> Arc> { + let initial = Config::load(&PathBuf::from(path)).hardware_offset_ms; + let offset = Arc::new(Mutex::new(initial)); + + // 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); + + 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); + } + } + }) + .expect("Failed to create file watcher"); + + // Use the original `watch_path` here + watcher + .watch(&watch_path, RecursiveMode::NonRecursive) + .expect("Failed to watch config.json"); + + loop { + std::thread::sleep(std::time::Duration::from_secs(60)); + } + }); + + offset +} diff --git a/src/main.rs b/src/main.rs index 68cf071..2464a0e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,81 +1,81 @@ -// src/main.rs - -mod config; -mod sync_logic; -mod serial_input; -mod ui; - -use crate::config::watch_config; -use crate::sync_logic::LtcState; -use crate::serial_input::start_serial_thread; -use crate::ui::start_ui; - -use std::{ - fs, - path::Path, - sync::{Arc, Mutex, mpsc}, - thread, -}; - -/// Embed the default config.json at compile time. -const DEFAULT_CONFIG: &str = include_str!("../config.json"); - -/// If no `config.json` exists alongside the binary, write out the default. -fn ensure_config() { - let p = Path::new("config.json"); - if !p.exists() { - fs::write(p, DEFAULT_CONFIG) - .expect("Failed to write default config.json"); - eprintln!("⚙️ Emitted default config.json"); - } -} - -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)..."); - - // 2️⃣ Channel for raw LTC frames - let (tx, rx) = mpsc::channel(); - println!("✅ Channel created"); - - // 3️⃣ Shared state for UI and serial reader - let ltc_state = Arc::new(Mutex::new(LtcState::new())); - println!("✅ State initialised"); - - // 4️⃣ Spawn the serial reader thread (no offset here) - { - let tx_clone = tx.clone(); - let state_clone = ltc_state.clone(); - thread::spawn(move || { - println!("🚀 Serial thread launched"); - start_serial_thread( - "/dev/ttyACM0", - 115200, - tx_clone, - state_clone, - 0, // ignored in serial path - ); - }); - } - - // 5️⃣ Spawn the UI renderer thread, passing the live offset Arc - { - let ui_state = ltc_state.clone(); - let offset_clone = hw_offset.clone(); - let port = "/dev/ttyACM0".to_string(); - thread::spawn(move || { - println!("🖥️ UI thread launched"); - start_ui(ui_state, port, offset_clone); - }); - } - - // 6️⃣ Keep main thread alive - println!("📡 Main thread entering loop..."); - for _frame in rx { - // no-op - } -} +// src/main.rs + +mod config; +mod sync_logic; +mod serial_input; +mod ui; + +use crate::config::watch_config; +use crate::sync_logic::LtcState; +use crate::serial_input::start_serial_thread; +use crate::ui::start_ui; + +use std::{ + fs, + path::Path, + sync::{Arc, Mutex, mpsc}, + thread, +}; + +/// Embed the default config.json at compile time. +const DEFAULT_CONFIG: &str = include_str!("../config.json"); + +/// If no `config.json` exists alongside the binary, write out the default. +fn ensure_config() { + let p = Path::new("config.json"); + if !p.exists() { + fs::write(p, DEFAULT_CONFIG) + .expect("Failed to write default config.json"); + eprintln!("⚙️ Emitted default config.json"); + } +} + +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)..."); + + // 2️⃣ Channel for raw LTC frames + let (tx, rx) = mpsc::channel(); + println!("✅ Channel created"); + + // 3️⃣ Shared state for UI and serial reader + let ltc_state = Arc::new(Mutex::new(LtcState::new())); + println!("✅ State initialised"); + + // 4️⃣ Spawn the serial reader thread (no offset here) + { + let tx_clone = tx.clone(); + let state_clone = ltc_state.clone(); + thread::spawn(move || { + println!("🚀 Serial thread launched"); + start_serial_thread( + "/dev/ttyACM0", + 115200, + tx_clone, + state_clone, + 0, // ignored in serial path + ); + }); + } + + // 5️⃣ Spawn the UI renderer thread, passing the live offset Arc + { + let ui_state = ltc_state.clone(); + let offset_clone = hw_offset.clone(); + let port = "/dev/ttyACM0".to_string(); + thread::spawn(move || { + println!("🖥️ UI thread launched"); + start_ui(ui_state, port, offset_clone); + }); + } + + // 6️⃣ Keep main thread alive + println!("📡 Main thread entering loop..."); + for _frame in rx { + // no-op + } +} diff --git a/src/serial_input.rs b/src/serial_input.rs index c180433..39e2be7 100644 --- a/src/serial_input.rs +++ b/src/serial_input.rs @@ -1,56 +1,56 @@ -// src/serial_input.rs - -use std::io::BufRead; -use std::sync::{Arc, Mutex}; -use std::sync::mpsc::Sender; -use chrono::Utc; -use regex::Regex; -use crate::sync_logic::{LtcFrame, LtcState}; - -pub fn start_serial_thread( - port_path: &str, - baud_rate: u32, - sender: Sender, - state: Arc>, - _hardware_offset_ms: i64, // no longer used here -) { - println!("📡 Opening serial port {} @ {} baud", port_path, baud_rate); - - let port = match serialport::new(port_path, baud_rate) - .timeout(std::time::Duration::from_millis(1000)) - .open() - { - Ok(p) => { - println!("✅ Serial port opened"); - p - } - Err(e) => { - eprintln!("❌ Serial open failed: {}", e); - return; - } - }; - - let reader = std::io::BufReader::new(port); - let re = Regex::new( - r"\[(LOCK|FREE)\]\s+(\d{2}):(\d{2}):(\d{2})[:;](\d{2})\s+\|\s+([\d.]+)fps", - ) - .unwrap(); - - println!("🔄 Entering LTC read loop…"); - for line in reader.lines() { - if let Ok(text) = line { - if let Some(caps) = re.captures(&text) { - let arrival = Utc::now(); - if let Some(frame) = LtcFrame::from_regex(&caps, arrival) { - // update LOCK/FREE counts & timestamp - { - let mut st = state.lock().unwrap(); - st.update(frame.clone()); - } - // forward raw frame - let _ = sender.send(frame); - } - } - } - } -} +// src/serial_input.rs + +use std::io::BufRead; +use std::sync::{Arc, Mutex}; +use std::sync::mpsc::Sender; +use chrono::Utc; +use regex::Regex; +use crate::sync_logic::{LtcFrame, LtcState}; + +pub fn start_serial_thread( + port_path: &str, + baud_rate: u32, + sender: Sender, + state: Arc>, + _hardware_offset_ms: i64, // no longer used here +) { + println!("📡 Opening serial port {} @ {} baud", port_path, baud_rate); + + let port = match serialport::new(port_path, baud_rate) + .timeout(std::time::Duration::from_millis(1000)) + .open() + { + Ok(p) => { + println!("✅ Serial port opened"); + p + } + Err(e) => { + eprintln!("❌ Serial open failed: {}", e); + return; + } + }; + + let reader = std::io::BufReader::new(port); + let re = Regex::new( + r"\[(LOCK|FREE)\]\s+(\d{2}):(\d{2}):(\d{2})[:;](\d{2})\s+\|\s+([\d.]+)fps", + ) + .unwrap(); + + println!("🔄 Entering LTC read loop…"); + for line in reader.lines() { + if let Ok(text) = line { + if let Some(caps) = re.captures(&text) { + let arrival = Utc::now(); + if let Some(frame) = LtcFrame::from_regex(&caps, arrival) { + // update LOCK/FREE counts & timestamp + { + let mut st = state.lock().unwrap(); + st.update(frame.clone()); + } + // forward raw frame + let _ = sender.send(frame); + } + } + } + } +} diff --git a/src/sync_logic.rs b/src/sync_logic.rs index f2a0e2b..3b9f435 100644 --- a/src/sync_logic.rs +++ b/src/sync_logic.rs @@ -1,137 +1,137 @@ -// src/sync_logic.rs - -use chrono::{DateTime, Local, Timelike, Utc}; -use regex::Captures; -use std::collections::VecDeque; - -#[derive(Clone, Debug)] -pub struct LtcFrame { - pub status: String, - pub hours: u32, - pub minutes: u32, - pub seconds: u32, - pub frames: u32, - pub frame_rate: f64, - pub timestamp: DateTime, // arrival stamp -} - -impl LtcFrame { - pub fn from_regex(caps: &Captures, timestamp: DateTime) -> Option { - Some(Self { - status: caps[1].to_string(), - hours: caps[2].parse().ok()?, - minutes: caps[3].parse().ok()?, - seconds: caps[4].parse().ok()?, - frames: caps[5].parse().ok()?, - frame_rate: caps[6].parse().ok()?, - timestamp, - }) - } - - /// Compare just HH:MM:SS against local time. - pub fn matches_system_time(&self) -> bool { - let local = Local::now(); - local.hour() == self.hours - && local.minute() == self.minutes - && local.second() == self.seconds - } -} - -pub struct LtcState { - pub latest: Option, - pub lock_count: u32, - pub free_count: u32, - /// Stores the last up-to-20 raw offset measurements in ms. - pub offset_history: VecDeque, - pub last_match_status: String, - pub last_match_check: i64, -} - -impl LtcState { - pub fn new() -> Self { - Self { - latest: None, - lock_count: 0, - free_count: 0, - offset_history: VecDeque::with_capacity(20), - last_match_status: "UNKNOWN".into(), - last_match_check: 0, - } - } - - /// Record one measured offset in ms, maintaining a sliding window of up to 20 samples. - pub fn record_offset(&mut self, offset_ms: i64) { - if self.offset_history.len() == 20 { - self.offset_history.pop_front(); - } - self.offset_history.push_back(offset_ms); - } - - /// Clear all stored offset measurements (e.g. on FREE-run). - pub fn clear_offsets(&mut self) { - self.offset_history.clear(); - } - - /// Update LOCK/FREE counts, clear offsets on FREE, and refresh timecode-match every 5 s. - pub fn update(&mut self, frame: LtcFrame) { - match frame.status.as_str() { - "LOCK" => { - self.lock_count += 1; - } - "FREE" => { - self.free_count += 1; - self.clear_offsets(); - self.last_match_status = "UNKNOWN".into(); - } - _ => {} - } - - // Every 5 seconds, recompute whether HH:MM:SS matches local time - let now_secs = Utc::now().timestamp(); - if now_secs - self.last_match_check >= 5 { - self.last_match_status = if frame.matches_system_time() { - "IN SYNC".into() - } else { - "OUT OF SYNC".into() - }; - self.last_match_check = now_secs; - } - - self.latest = Some(frame); - } - - /// Average jitter over the stored history, in milliseconds. - pub fn average_jitter(&self) -> i64 { - if self.offset_history.is_empty() { - 0 - } else { - let sum: i64 = self.offset_history.iter().sum(); - sum / (self.offset_history.len() as i64) - } - } - - /// Convert that average jitter into frames (rounded). - pub fn average_frames(&self) -> i64 { - if let Some(frame) = &self.latest { - let ms_per_frame = 1000.0 / frame.frame_rate; - (self.average_jitter() as f64 / ms_per_frame).round() as i64 - } else { - 0 - } - } - - /// Percentage of samples seen in LOCK state versus total. - pub fn lock_ratio(&self) -> f64 { - let total = self.lock_count + self.free_count; - if total == 0 { - 0.0 - } else { - (self.lock_count as f64 / total as f64) * 100.0 - } - } - - /// Get the last computed timecode‐match status ("IN SYNC", "OUT OF SYNC", or "UNKNOWN"). - pub fn timecode_match(&self) -> &str { - &self.last_match_status - } -} +// src/sync_logic.rs + +use chrono::{DateTime, Local, Timelike, Utc}; +use regex::Captures; +use std::collections::VecDeque; + +#[derive(Clone, Debug)] +pub struct LtcFrame { + pub status: String, + pub hours: u32, + pub minutes: u32, + pub seconds: u32, + pub frames: u32, + pub frame_rate: f64, + pub timestamp: DateTime, // arrival stamp +} + +impl LtcFrame { + pub fn from_regex(caps: &Captures, timestamp: DateTime) -> Option { + Some(Self { + status: caps[1].to_string(), + hours: caps[2].parse().ok()?, + minutes: caps[3].parse().ok()?, + seconds: caps[4].parse().ok()?, + frames: caps[5].parse().ok()?, + frame_rate: caps[6].parse().ok()?, + timestamp, + }) + } + + /// Compare just HH:MM:SS against local time. + pub fn matches_system_time(&self) -> bool { + let local = Local::now(); + local.hour() == self.hours + && local.minute() == self.minutes + && local.second() == self.seconds + } +} + +pub struct LtcState { + pub latest: Option, + pub lock_count: u32, + pub free_count: u32, + /// Stores the last up-to-20 raw offset measurements in ms. + pub offset_history: VecDeque, + pub last_match_status: String, + pub last_match_check: i64, +} + +impl LtcState { + pub fn new() -> Self { + Self { + latest: None, + lock_count: 0, + free_count: 0, + offset_history: VecDeque::with_capacity(20), + last_match_status: "UNKNOWN".into(), + last_match_check: 0, + } + } + + /// Record one measured offset in ms, maintaining a sliding window of up to 20 samples. + pub fn record_offset(&mut self, offset_ms: i64) { + if self.offset_history.len() == 20 { + self.offset_history.pop_front(); + } + self.offset_history.push_back(offset_ms); + } + + /// Clear all stored offset measurements (e.g. on FREE-run). + pub fn clear_offsets(&mut self) { + self.offset_history.clear(); + } + + /// Update LOCK/FREE counts, clear offsets on FREE, and refresh timecode-match every 5 s. + pub fn update(&mut self, frame: LtcFrame) { + match frame.status.as_str() { + "LOCK" => { + self.lock_count += 1; + } + "FREE" => { + self.free_count += 1; + self.clear_offsets(); + self.last_match_status = "UNKNOWN".into(); + } + _ => {} + } + + // Every 5 seconds, recompute whether HH:MM:SS matches local time + let now_secs = Utc::now().timestamp(); + if now_secs - self.last_match_check >= 5 { + self.last_match_status = if frame.matches_system_time() { + "IN SYNC".into() + } else { + "OUT OF SYNC".into() + }; + self.last_match_check = now_secs; + } + + self.latest = Some(frame); + } + + /// Average jitter over the stored history, in milliseconds. + pub fn average_jitter(&self) -> i64 { + if self.offset_history.is_empty() { + 0 + } else { + let sum: i64 = self.offset_history.iter().sum(); + sum / (self.offset_history.len() as i64) + } + } + + /// Convert that average jitter into frames (rounded). + pub fn average_frames(&self) -> i64 { + if let Some(frame) = &self.latest { + let ms_per_frame = 1000.0 / frame.frame_rate; + (self.average_jitter() as f64 / ms_per_frame).round() as i64 + } else { + 0 + } + } + + /// Percentage of samples seen in LOCK state versus total. + pub fn lock_ratio(&self) -> f64 { + let total = self.lock_count + self.free_count; + if total == 0 { + 0.0 + } else { + (self.lock_count as f64 / total as f64) * 100.0 + } + } + + /// Get the last computed timecode‐match status ("IN SYNC", "OUT OF SYNC", or "UNKNOWN"). + pub fn timecode_match(&self) -> &str { + &self.last_match_status + } +} diff --git a/src/ui.rs b/src/ui.rs index 66c485f..68dd064 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,212 +1,212 @@ -// src/ui.rs - -use std::{ - io::{stdout, Write}, - process::{self, Command}, - sync::{Arc, Mutex}, - thread, - time::Duration, -}; - -use chrono::{Local, Timelike, Utc}; -use crossterm::{ - cursor::{Hide, MoveTo, Show}, - event::{poll, read, Event, KeyCode}, - execute, queue, - style::{Color, Print, ResetColor, SetForegroundColor}, - terminal::{self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen}, -}; - -use crate::sync_logic::LtcState; - -/// Launch the TUI; reads `offset` live from the file-watcher. -pub fn start_ui( - state: Arc>, - serial_port: String, - offset: Arc>, -) { - let mut stdout = stdout(); - execute!(stdout, EnterAlternateScreen).unwrap(); - terminal::enable_raw_mode().unwrap(); - - loop { - // 1️⃣ Read current hardware offset - let hw_offset_ms = *offset.lock().unwrap(); - - // 2️⃣ Measure & record jitter only when LOCKED; clear on FREE - { - let mut st = state.lock().unwrap(); - if let Some(frame) = &st.latest { - if frame.status == "LOCK" { - let now = Utc::now(); - let raw = (now - frame.timestamp).num_milliseconds(); - let measured = raw - hw_offset_ms; - st.record_offset(measured); - } else { - st.clear_offsets(); - } - } - } - - // 3️⃣ Draw static UI - queue!( - stdout, - MoveTo(0, 0), - Clear(ClearType::All), - Hide, - MoveTo(2, 1), Print("NTP Timeturner v2 - Rust Port"), - MoveTo(2, 2), Print(format!("Using Serial Port: {}", serial_port)), - ) - .unwrap(); - - if let Ok(st) = state.lock() { - if let Some(frame) = &st.latest { - queue!( - stdout, - MoveTo(2, 4), Print(format!("LTC Status : {}", frame.status)), - MoveTo(2, 5), Print(format!( - "LTC Timecode : {:02}:{:02}:{:02}:{:02}", - frame.hours, frame.minutes, frame.seconds, frame.frames - )), - MoveTo(2, 6), Print(format!("Frame Rate : {:.2}fps", frame.frame_rate)), - ) - .unwrap(); - } else { - queue!( - stdout, - MoveTo(2, 4), Print("LTC Status : (waiting)"), - MoveTo(2, 5), Print("LTC Timecode : …"), - MoveTo(2, 6), Print("Frame Rate : …"), - ) - .unwrap(); - } - - let now_local = Local::now(); - let sys_str = format!( - "{:02}:{:02}:{:02}.{:03}", - now_local.hour(), - now_local.minute(), - now_local.second(), - now_local.timestamp_subsec_millis() - ); - queue!( - stdout, - MoveTo(2, 7), - Print(format!("System Clock : {}", sys_str)) - ) - .unwrap(); - } - - // Footer - queue!( - stdout, - MoveTo(2, 12), - Print("[S] Set system clock to LTC [Q] Quit") - ) - .unwrap(); - - stdout.flush().unwrap(); - - // 4️⃣ Overlay Sync Jitter / Status / Ratio - if let Ok(st) = state.lock() { - let avg_ms = st.average_jitter(); - let avg_frames = st.average_frames(); - let (jcol, jtxt) = if avg_ms.abs() < 10 { - (Color::Green, format!("{:+} ms ({:+} frames)", avg_ms, avg_frames)) - } else if avg_ms.abs() < 40 { - (Color::Yellow, format!("{:+} ms ({:+} frames)", avg_ms, avg_frames)) - } else { - (Color::Red, format!("{:+} ms ({:+} frames)", avg_ms, avg_frames)) - }; - queue!( - stdout, - MoveTo(2, 8), - SetForegroundColor(jcol), - Print("Sync Jitter : "), - Print(jtxt), - ResetColor, - ) - .ok(); - - let status = st.timecode_match(); - let scol = if status == "IN SYNC" { Color::Green } else { Color::Red }; - queue!( - stdout, - MoveTo(2, 9), - SetForegroundColor(scol), - Print(format!("Sync Status : {}", status)), - ResetColor, - ) - .ok(); - - let ratio = st.lock_ratio(); - queue!( - stdout, - MoveTo(2, 10), - Print(format!("Lock Ratio : {:.1}% LOCK", ratio)), - ) - .ok(); - - stdout.flush().ok(); - } - - // 5️⃣ Handle keypress - if poll(Duration::from_millis(0)).unwrap() { - if let Event::Key(evt) = read().unwrap() { - match evt.code { - KeyCode::Char(c) if c.eq_ignore_ascii_case(&'s') => { - // SYNC now - if let Ok(st) = state.lock() { - if let Some(frame) = &st.latest { - // compute ms from frames - let ms_from_frames = - ((frame.frames as f64 / frame.frame_rate) * 1000.0).round() as i64; - // total microseconds - let total_us = (ms_from_frames + hw_offset_ms) * 1000; - // build date string "HH:MM:SS.mmm" - let ts = format!( - "{:02}:{:02}:{:02}.{:03}", - frame.hours, - frame.minutes, - frame.seconds, - ((total_us / 1000) % 1000) - ); - // run `sudo date -s "HH:MM:SS.mmm"` - let status = Command::new("sudo") - .arg("date") - .arg("-s") - .arg(&ts) - .status(); - let msg = if let Ok(s) = status { - if s.success() { - format!("✔ Synced to LTC: {}", ts) - } else { - format!("❌ date cmd failed") - } - } else { - format!("❌ failed to spawn date") - }; - // print confirmation at row 14 - queue!( - stdout, - MoveTo(2, 14), - Print(msg), - ) - .ok(); - stdout.flush().ok(); - } - } - } - KeyCode::Char(c) if c.eq_ignore_ascii_case(&'q') => { - execute!(stdout, Show, LeaveAlternateScreen).unwrap(); - terminal::disable_raw_mode().unwrap(); - process::exit(0); - } - _ => {} - } - } - } - - thread::sleep(Duration::from_millis(50)); - } -} +// src/ui.rs + +use std::{ + io::{stdout, Write}, + process::{self, Command}, + sync::{Arc, Mutex}, + thread, + time::Duration, +}; + +use chrono::{Local, Timelike, Utc}; +use crossterm::{ + cursor::{Hide, MoveTo, Show}, + event::{poll, read, Event, KeyCode}, + execute, queue, + style::{Color, Print, ResetColor, SetForegroundColor}, + terminal::{self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen}, +}; + +use crate::sync_logic::LtcState; + +/// Launch the TUI; reads `offset` live from the file-watcher. +pub fn start_ui( + state: Arc>, + serial_port: String, + offset: Arc>, +) { + let mut stdout = stdout(); + execute!(stdout, EnterAlternateScreen).unwrap(); + terminal::enable_raw_mode().unwrap(); + + loop { + // 1️⃣ Read current hardware offset + let hw_offset_ms = *offset.lock().unwrap(); + + // 2️⃣ Measure & record jitter only when LOCKED; clear on FREE + { + let mut st = state.lock().unwrap(); + if let Some(frame) = &st.latest { + if frame.status == "LOCK" { + let now = Utc::now(); + let raw = (now - frame.timestamp).num_milliseconds(); + let measured = raw - hw_offset_ms; + st.record_offset(measured); + } else { + st.clear_offsets(); + } + } + } + + // 3️⃣ Draw static UI + queue!( + stdout, + MoveTo(0, 0), + Clear(ClearType::All), + Hide, + MoveTo(2, 1), Print("NTP Timeturner v2 - Rust Port"), + MoveTo(2, 2), Print(format!("Using Serial Port: {}", serial_port)), + ) + .unwrap(); + + if let Ok(st) = state.lock() { + if let Some(frame) = &st.latest { + queue!( + stdout, + MoveTo(2, 4), Print(format!("LTC Status : {}", frame.status)), + MoveTo(2, 5), Print(format!( + "LTC Timecode : {:02}:{:02}:{:02}:{:02}", + frame.hours, frame.minutes, frame.seconds, frame.frames + )), + MoveTo(2, 6), Print(format!("Frame Rate : {:.2}fps", frame.frame_rate)), + ) + .unwrap(); + } else { + queue!( + stdout, + MoveTo(2, 4), Print("LTC Status : (waiting)"), + MoveTo(2, 5), Print("LTC Timecode : …"), + MoveTo(2, 6), Print("Frame Rate : …"), + ) + .unwrap(); + } + + let now_local = Local::now(); + let sys_str = format!( + "{:02}:{:02}:{:02}.{:03}", + now_local.hour(), + now_local.minute(), + now_local.second(), + now_local.timestamp_subsec_millis() + ); + queue!( + stdout, + MoveTo(2, 7), + Print(format!("System Clock : {}", sys_str)) + ) + .unwrap(); + } + + // Footer + queue!( + stdout, + MoveTo(2, 12), + Print("[S] Set system clock to LTC [Q] Quit") + ) + .unwrap(); + + stdout.flush().unwrap(); + + // 4️⃣ Overlay Sync Jitter / Status / Ratio + if let Ok(st) = state.lock() { + let avg_ms = st.average_jitter(); + let avg_frames = st.average_frames(); + let (jcol, jtxt) = if avg_ms.abs() < 10 { + (Color::Green, format!("{:+} ms ({:+} frames)", avg_ms, avg_frames)) + } else if avg_ms.abs() < 40 { + (Color::Yellow, format!("{:+} ms ({:+} frames)", avg_ms, avg_frames)) + } else { + (Color::Red, format!("{:+} ms ({:+} frames)", avg_ms, avg_frames)) + }; + queue!( + stdout, + MoveTo(2, 8), + SetForegroundColor(jcol), + Print("Sync Jitter : "), + Print(jtxt), + ResetColor, + ) + .ok(); + + let status = st.timecode_match(); + let scol = if status == "IN SYNC" { Color::Green } else { Color::Red }; + queue!( + stdout, + MoveTo(2, 9), + SetForegroundColor(scol), + Print(format!("Sync Status : {}", status)), + ResetColor, + ) + .ok(); + + let ratio = st.lock_ratio(); + queue!( + stdout, + MoveTo(2, 10), + Print(format!("Lock Ratio : {:.1}% LOCK", ratio)), + ) + .ok(); + + stdout.flush().ok(); + } + + // 5️⃣ Handle keypress + if poll(Duration::from_millis(0)).unwrap() { + if let Event::Key(evt) = read().unwrap() { + match evt.code { + KeyCode::Char(c) if c.eq_ignore_ascii_case(&'s') => { + // SYNC now + if let Ok(st) = state.lock() { + if let Some(frame) = &st.latest { + // compute ms from frames + let ms_from_frames = + ((frame.frames as f64 / frame.frame_rate) * 1000.0).round() as i64; + // total microseconds + let total_us = (ms_from_frames + hw_offset_ms) * 1000; + // build date string "HH:MM:SS.mmm" + let ts = format!( + "{:02}:{:02}:{:02}.{:03}", + frame.hours, + frame.minutes, + frame.seconds, + ((total_us / 1000) % 1000) + ); + // run `sudo date -s "HH:MM:SS.mmm"` + let status = Command::new("sudo") + .arg("date") + .arg("-s") + .arg(&ts) + .status(); + let msg = if let Ok(s) = status { + if s.success() { + format!("✔ Synced to LTC: {}", ts) + } else { + format!("❌ date cmd failed") + } + } else { + format!("❌ failed to spawn date") + }; + // print confirmation at row 14 + queue!( + stdout, + MoveTo(2, 14), + Print(msg), + ) + .ok(); + stdout.flush().ok(); + } + } + } + KeyCode::Char(c) if c.eq_ignore_ascii_case(&'q') => { + execute!(stdout, Show, LeaveAlternateScreen).unwrap(); + terminal::disable_raw_mode().unwrap(); + process::exit(0); + } + _ => {} + } + } + } + + thread::sleep(Duration::from_millis(50)); + } +}