diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..fc040de --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "ntp_timeturner" +version = "0.1.0" +edition = "2021" + +[dependencies] +serialport = "4.2" +chrono = "0.4" +crossterm = "0.27" +regex = "1.11" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +notify = "5.1.0" \ No newline at end of file diff --git a/config.json b/config.json index 829e0a8..5ba71c3 100644 --- a/config.json +++ b/config.json @@ -1,3 +1,3 @@ { - "hardware_offset_ms": 25 + "hardware_offset_ms": 20 } diff --git a/src/config.json b/src/config.json deleted file mode 100644 index 829e0a8..0000000 --- a/src/config.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "hardware_offset_ms": 25 -} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..2504450 --- /dev/null +++ b/src/config.rs @@ -0,0 +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 +} diff --git a/src/main.rs b/src/main.rs index 06093be..68cf071 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,38 +1,81 @@ -mod sync_logic; +// 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::sync::{Arc, Mutex, mpsc}; -use std::thread; +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() { - println!("🧪 Timeturner startup..."); + // 🔄 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"); - start_serial_thread("/dev/ttyACM0", 115200, tx.clone(), ltc_state.clone()); - println!("🚀 Serial thread launched"); + // 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 + ); + }); + } - let ui_state = ltc_state.clone(); - thread::spawn(move || { - println!("🖥️ UI thread started"); - start_ui(ui_state); - }); + // 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 { - println!( - "📥 Received LTC frame: {:02}:{:02}:{:02}:{:02} [{}]", - frame.hours, frame.minutes, frame.seconds, frame.frames, frame.status - ); + for _frame in rx { + // no-op } } diff --git a/src/serial_input.rs b/src/serial_input.rs index 9e66657..c180433 100644 --- a/src/serial_input.rs +++ b/src/serial_input.rs @@ -1,4 +1,6 @@ -use std::io::BufRead; +// src/serial_input.rs + +use std::io::BufRead; use std::sync::{Arc, Mutex}; use std::sync::mpsc::Sender; use chrono::Utc; @@ -10,35 +12,43 @@ pub fn start_serial_thread( baud_rate: u32, sender: Sender, state: Arc>, + _hardware_offset_ms: i64, // no longer used here ) { - println!("📡 Attempting to open serial port: {} @ {} baud", port_path, baud_rate); + println!("📡 Opening serial port {} @ {} baud", port_path, baud_rate); - let port = serialport::new(port_path, baud_rate) + let port = match serialport::new(port_path, baud_rate) .timeout(std::time::Duration::from_millis(1000)) - .open(); - - match &port { - Ok(_) => println!("✅ Serial port opened successfully"), - Err(e) => { - eprintln!("❌ Failed to open serial port: {}", e); - return; // Exit early, no point continuing + .open() + { + Ok(p) => { + println!("✅ Serial port opened"); + p } - } + Err(e) => { + eprintln!("❌ Serial open failed: {}", e); + return; + } + }; - let reader = std::io::BufReader::new(port.unwrap()); - let re = Regex::new(r"\[(LOCK|FREE)\]\s+(\d{2}):(\d{2}):(\d{2})[:;](\d{2})\s+\|\s+([\d.]+)fps") - .unwrap(); - - println!("🔄 Starting LTC read loop..."); + 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(line) = line { - if let Some(caps) = re.captures(&line) { - let frame = LtcFrame::from_regex(&caps, Utc::now()); - if let Some(frame) = frame { - sender.send(frame.clone()).ok(); - let mut state_lock = state.lock().unwrap(); - state_lock.update(frame); + 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 faed38d..f2a0e2b 100644 --- a/src/sync_logic.rs +++ b/src/sync_logic.rs @@ -1,3 +1,5 @@ +// src/sync_logic.rs + use chrono::{DateTime, Local, Timelike, Utc}; use regex::Captures; use std::collections::VecDeque; @@ -10,7 +12,7 @@ pub struct LtcFrame { pub seconds: u32, pub frames: u32, pub frame_rate: f64, - pub timestamp: DateTime, + pub timestamp: DateTime, // arrival stamp } impl LtcFrame { @@ -26,6 +28,7 @@ impl LtcFrame { }) } + /// Compare just HH:MM:SS against local time. pub fn matches_system_time(&self) -> bool { let local = Local::now(); local.hour() == self.hours @@ -38,8 +41,8 @@ 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 hardware_offset_ms: i64, pub last_match_status: String, pub last_match_check: i64, } @@ -51,62 +54,73 @@ impl LtcState { lock_count: 0, free_count: 0, offset_history: VecDeque::with_capacity(20), - hardware_offset_ms: 0, - last_match_status: "UNKNOWN".to_string(), + 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, + "LOCK" => { + self.lock_count += 1; + } "FREE" => { self.free_count += 1; - self.offset_history.clear(); - self.last_match_status = "UNKNOWN".to_string(); + self.clear_offsets(); + self.last_match_status = "UNKNOWN".into(); } _ => {} } - if frame.status == "LOCK" { - let now = Utc::now(); - let offset_ms = (now - frame.timestamp).num_milliseconds() - self.hardware_offset_ms; - if self.offset_history.len() == 20 { - self.offset_history.pop_front(); - } - self.offset_history.push_back(offset_ms); - } - + // 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" + "IN SYNC".into() } else { - "OUT OF SYNC" - } - .to_string(); + "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() { - return 0; + 0 + } else { + let sum: i64 = self.offset_history.iter().sum(); + sum / (self.offset_history.len() as i64) } - self.offset_history.iter().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 frame_time = 1000.0 / frame.frame_rate; - (self.average_jitter() as f64 / frame_time).round() as i64 + 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 { @@ -116,6 +130,7 @@ impl LtcState { } } + /// 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 43cc8ba..66c485f 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,53 +1,212 @@ -use std::io::{stdout, Write}; -use std::sync::{Arc, Mutex}; -use std::thread; -use std::time::Duration; +// src/ui.rs +use std::{ + io::{stdout, Write}, + process::{self, Command}, + sync::{Arc, Mutex}, + thread, + time::Duration, +}; + +use chrono::{Local, Timelike, Utc}; use crossterm::{ - execute, - terminal::{Clear, ClearType}, - cursor::MoveTo, + 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; -pub fn render_ui(state: &Arc>) -> std::io::Result<()> { +/// 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, Clear(ClearType::All), MoveTo(0, 0))?; + execute!(stdout, EnterAlternateScreen).unwrap(); + terminal::enable_raw_mode().unwrap(); - if let Ok(s) = state.lock() { - if let Some(frame) = &s.latest { - writeln!(stdout, "🕰️ NTP Timeturner (Rust Draft)")?; - writeln!(stdout, "LTC Status : {}", frame.status)?; - writeln!( - stdout, - "LTC Timecode : {:02}:{:02}:{:02}:{:02}", - frame.hours, frame.minutes, frame.seconds, frame.frames - )?; - writeln!(stdout, "Frame Rate : {:.2} fps", frame.frame_rate)?; - writeln!(stdout, "Timestamp : {}", frame.timestamp)?; - let total = s.lock_count + s.free_count; - let ratio = if total > 0 { - s.lock_count as f64 / total as f64 * 100.0 - } else { - 0.0 - }; - writeln!(stdout, "Lock Ratio : {:.1}% LOCK", ratio)?; - } else { - writeln!(stdout, "Waiting for LTC...")?; - } - } - - stdout.flush()?; - Ok(()) -} - -pub fn start_ui(state: Arc>) { - // 🧠 This thread now DOES the rendering loop loop { - if let Err(e) = render_ui(&state) { - eprintln!("UI error: {}", e); + // 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(); + } + } } - thread::sleep(Duration::from_millis(500)); + + // 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)); } }