Rust ported and tested version

This commit is contained in:
Chris Frankland-Wright 2025-07-09 01:18:49 +01:00
parent 138b1e07a8
commit bd2111d77a
8 changed files with 415 additions and 109 deletions

241
src/ui.rs
View file

@ -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<Mutex<LtcState>>) -> std::io::Result<()> {
/// Launch the TUI; reads `offset` live from the file-watcher.
pub fn start_ui(
state: Arc<Mutex<LtcState>>,
serial_port: String,
offset: Arc<Mutex<i64>>,
) {
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<Mutex<LtcState>>) {
// 🧠 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));
}
}