diff --git a/src/config.json b/src/config.json new file mode 100644 index 0000000..829e0a8 --- /dev/null +++ b/src/config.json @@ -0,0 +1,3 @@ +{ + "hardware_offset_ms": 25 +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..06093be --- /dev/null +++ b/src/main.rs @@ -0,0 +1,38 @@ +mod sync_logic; +mod serial_input; +mod ui; + +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; + +fn main() { + println!("🧪 Timeturner startup..."); + + let (tx, rx) = mpsc::channel(); + println!("✅ Channel created"); + + 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"); + + let ui_state = ltc_state.clone(); + thread::spawn(move || { + println!("🖥️ UI thread started"); + start_ui(ui_state); + }); + + 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 + ); + } +} diff --git a/src/serial_input.rs b/src/serial_input.rs new file mode 100644 index 0000000..9e66657 --- /dev/null +++ b/src/serial_input.rs @@ -0,0 +1,46 @@ +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>, +) { + println!("📡 Attempting to open serial port: {} @ {} baud", port_path, baud_rate); + + let port = 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 + } + } + + 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..."); + + 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); + } + } + } + } +} diff --git a/src/sync_logic.rs b/src/sync_logic.rs new file mode 100644 index 0000000..faed38d --- /dev/null +++ b/src/sync_logic.rs @@ -0,0 +1,122 @@ +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, +} + +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, + }) + } + + 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, + pub offset_history: VecDeque, + pub hardware_offset_ms: i64, + 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), + hardware_offset_ms: 0, + last_match_status: "UNKNOWN".to_string(), + last_match_check: 0, + } + } + + pub fn update(&mut self, frame: LtcFrame) { + match frame.status.as_str() { + "LOCK" => self.lock_count += 1, + "FREE" => { + self.free_count += 1; + self.offset_history.clear(); + self.last_match_status = "UNKNOWN".to_string(); + } + _ => {} + } + + 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); + } + + 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" + } else { + "OUT OF SYNC" + } + .to_string(); + self.last_match_check = now_secs; + } + + self.latest = Some(frame); + } + + pub fn average_jitter(&self) -> i64 { + if self.offset_history.is_empty() { + return 0; + } + self.offset_history.iter().sum::() / self.offset_history.len() as i64 + } + + 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 + } else { + 0 + } + } + + 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 + } + } + + pub fn timecode_match(&self) -> &str { + &self.last_match_status + } +} diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 0000000..43cc8ba --- /dev/null +++ b/src/ui.rs @@ -0,0 +1,53 @@ +use std::io::{stdout, Write}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::Duration; + +use crossterm::{ + execute, + terminal::{Clear, ClearType}, + cursor::MoveTo, +}; + +use crate::sync_logic::LtcState; + +pub fn render_ui(state: &Arc>) -> std::io::Result<()> { + let mut stdout = stdout(); + execute!(stdout, Clear(ClearType::All), MoveTo(0, 0))?; + + 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); + } + thread::sleep(Duration::from_millis(500)); + } +}