test: add tests for UI status helpers

Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
This commit is contained in:
Chaos Rogers 2025-07-21 16:06:22 +01:00
parent 39ba5d90bb
commit 30fb752cbb

713
src/ui.rs
View file

@ -1,338 +1,375 @@
use std::{ use std::{
io::{stdout, Write}, io::{stdout, Write},
process::{self, Command}, process::{self, Command},
sync::{Arc, Mutex}, sync::{Arc, Mutex},
thread, thread,
time::{Duration, Instant}, time::{Duration, Instant},
}; };
use std::collections::VecDeque; use std::collections::VecDeque;
use chrono::{ use chrono::{
DateTime, Local, Timelike, Utc, DateTime, Local, Timelike, Utc,
NaiveTime, TimeZone, NaiveTime, TimeZone,
}; };
use crossterm::{ use crossterm::{
cursor::{Hide, MoveTo, Show}, cursor::{Hide, MoveTo, Show},
event::{poll, read, Event, KeyCode}, event::{poll, read, Event, KeyCode},
execute, queue, execute, queue,
style::{Color, Print, ResetColor, SetForegroundColor}, style::{Color, Print, ResetColor, SetForegroundColor},
terminal::{self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen}, terminal::{self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen},
}; };
use get_if_addrs::get_if_addrs; use get_if_addrs::get_if_addrs;
use crate::sync_logic::LtcState; use crate::sync_logic::LtcState;
/// Check if Chrony is active /// Check if Chrony is active
fn ntp_service_active() -> bool { fn ntp_service_active() -> bool {
if let Ok(output) = Command::new("systemctl").args(&["is-active", "chrony"]).output() { if let Ok(output) = Command::new("systemctl").args(&["is-active", "chrony"]).output() {
output.status.success() output.status.success()
&& String::from_utf8_lossy(&output.stdout).trim() == "active" && String::from_utf8_lossy(&output.stdout).trim() == "active"
} else { } else {
false false
} }
} }
/// Toggle Chrony (not used yet) /// Toggle Chrony (not used yet)
#[allow(dead_code)] #[allow(dead_code)]
fn ntp_service_toggle(start: bool) { fn ntp_service_toggle(start: bool) {
let action = if start { "start" } else { "stop" }; let action = if start { "start" } else { "stop" };
let _ = Command::new("systemctl").args(&[action, "chrony"]).status(); let _ = Command::new("systemctl").args(&[action, "chrony"]).status();
} }
pub fn start_ui( fn get_sync_status(delta_ms: i64) -> &'static str {
state: Arc<Mutex<LtcState>>, if delta_ms.abs() <= 8 {
serial_port: String, "IN SYNC"
offset: Arc<Mutex<i64>>, } else if delta_ms > 10 {
) { "CLOCK AHEAD"
let mut stdout = stdout(); } else {
execute!(stdout, EnterAlternateScreen, Hide).unwrap(); "CLOCK BEHIND"
terminal::enable_raw_mode().unwrap(); }
}
let mut logs: VecDeque<String> = VecDeque::with_capacity(10);
let mut out_of_sync_since: Option<Instant> = None; fn get_jitter_status(jitter_ms: i64) -> &'static str {
let mut last_delta_update = Instant::now() - Duration::from_secs(1); if jitter_ms.abs() < 10 {
let mut cached_delta_ms: i64 = 0; "GOOD"
let mut cached_delta_frames: i64 = 0; } else if jitter_ms.abs() < 40 {
"AVERAGE"
loop { } else {
// 1⃣ hardware offset "BAD"
let hw_offset_ms = *offset.lock().unwrap(); }
}
// 2⃣ Chrony + interfaces
let ntp_active = ntp_service_active(); pub fn start_ui(
let interfaces: Vec<String> = get_if_addrs() state: Arc<Mutex<LtcState>>,
.unwrap_or_default() serial_port: String,
.into_iter() offset: Arc<Mutex<i64>>,
.filter(|ifa| !ifa.is_loopback()) ) {
.map(|ifa| ifa.ip().to_string()) let mut stdout = stdout();
.collect(); execute!(stdout, EnterAlternateScreen, Hide).unwrap();
terminal::enable_raw_mode().unwrap();
// 3⃣ jitter + Δ
{ let mut logs: VecDeque<String> = VecDeque::with_capacity(10);
let mut st = state.lock().unwrap(); let mut out_of_sync_since: Option<Instant> = None;
if let Some(frame) = st.latest.clone() { let mut last_delta_update = Instant::now() - Duration::from_secs(1);
if frame.status == "LOCK" { let mut cached_delta_ms: i64 = 0;
// jitter let mut cached_delta_frames: i64 = 0;
let now_utc = Utc::now();
let raw = (now_utc - frame.timestamp).num_milliseconds(); loop {
let measured = raw - hw_offset_ms; // 1⃣ hardware offset
st.record_offset(measured); let hw_offset_ms = *offset.lock().unwrap();
// Δ = system clock - LTC timecode (use LOCAL time) // 2⃣ Chrony + interfaces
let today_local = Local::now().date_naive(); let ntp_active = ntp_service_active();
let ms = ((frame.frames as f64 / frame.frame_rate) * 1000.0) let interfaces: Vec<String> = get_if_addrs()
.round() as u32; .unwrap_or_default()
let tc_naive = NaiveTime::from_hms_milli_opt( .into_iter()
frame.hours, frame.minutes, frame.seconds, ms, .filter(|ifa| !ifa.is_loopback())
).expect("Invalid LTC timecode"); .map(|ifa| ifa.ip().to_string())
let naive_dt_local = today_local.and_time(tc_naive); .collect();
let dt_local = Local
.from_local_datetime(&naive_dt_local) // 3⃣ jitter + Δ
.single() {
.expect("Invalid local time"); let mut st = state.lock().unwrap();
let delta_ms = (Local::now() - dt_local).num_milliseconds(); if let Some(frame) = st.latest.clone() {
st.record_clock_delta(delta_ms); if frame.status == "LOCK" {
} else { // jitter
st.clear_offsets(); let now_utc = Utc::now();
st.clear_clock_deltas(); let raw = (now_utc - frame.timestamp).num_milliseconds();
} let measured = raw - hw_offset_ms;
} st.record_offset(measured);
}
// Δ = system clock - LTC timecode (use LOCAL time)
// 4⃣ averages & status override let today_local = Local::now().date_naive();
let (avg_jitter_ms, _avg_frames, _, lock_ratio, avg_delta) = { let ms = ((frame.frames as f64 / frame.frame_rate) * 1000.0)
let st = state.lock().unwrap(); .round() as u32;
( let tc_naive = NaiveTime::from_hms_milli_opt(
st.average_jitter(), frame.hours, frame.minutes, frame.seconds, ms,
st.average_frames(), ).expect("Invalid LTC timecode");
st.timecode_match().to_string(), let naive_dt_local = today_local.and_time(tc_naive);
st.lock_ratio(), let dt_local = Local
st.average_clock_delta(), .from_local_datetime(&naive_dt_local)
) .single()
}; .expect("Invalid local time");
let delta_ms = (Local::now() - dt_local).num_milliseconds();
// 5⃣ cache Δ once/sec & Δ in frames st.record_clock_delta(delta_ms);
if last_delta_update.elapsed() >= Duration::from_secs(1) { } else {
cached_delta_ms = avg_delta; st.clear_offsets();
if let Some(frame) = &state.lock().unwrap().latest { st.clear_clock_deltas();
let frame_ms = 1000.0 / frame.frame_rate; }
cached_delta_frames = ((avg_delta as f64 / frame_ms).round()) as i64; }
} else { }
cached_delta_frames = 0;
} // 4⃣ averages & status override
last_delta_update = Instant::now(); let (avg_jitter_ms, _avg_frames, _, lock_ratio, avg_delta) = {
} let st = state.lock().unwrap();
(
// 6⃣ sync status wording st.average_jitter(),
let sync_status = if cached_delta_ms.abs() <= 8 { st.average_frames(),
"IN SYNC" st.timecode_match().to_string(),
} else if cached_delta_ms > 10 { st.lock_ratio(),
"CLOCK AHEAD" st.average_clock_delta(),
} else { )
"CLOCK BEHIND" };
};
// 5⃣ cache Δ once/sec & Δ in frames
// 7⃣ autosync (same as manual but delayed) if last_delta_update.elapsed() >= Duration::from_secs(1) {
if sync_status != "IN SYNC" { cached_delta_ms = avg_delta;
if let Some(start) = out_of_sync_since { if let Some(frame) = &state.lock().unwrap().latest {
if start.elapsed() >= Duration::from_secs(5) { let frame_ms = 1000.0 / frame.frame_rate;
if let Some(frame) = &state.lock().unwrap().latest { cached_delta_frames = ((avg_delta as f64 / frame_ms).round()) as i64;
let today_local = Local::now().date_naive(); } else {
let ms = ((frame.frames as f64 / frame.frame_rate) * 1000.0) cached_delta_frames = 0;
.round() as u32; }
let timecode = NaiveTime::from_hms_milli_opt( last_delta_update = Instant::now();
frame.hours, frame.minutes, frame.seconds, ms, }
).expect("Invalid LTC timecode");
let naive_dt = today_local.and_time(timecode); // 6⃣ sync status wording
let dt_local = Local let sync_status = get_sync_status(cached_delta_ms);
.from_local_datetime(&naive_dt)
.single() // 7⃣ autosync (same as manual but delayed)
.expect("Ambiguous or invalid local time"); if sync_status != "IN SYNC" {
let ts = dt_local.format("%H:%M:%S.%3f").to_string(); if let Some(start) = out_of_sync_since {
if start.elapsed() >= Duration::from_secs(5) {
let success = Command::new("sudo") if let Some(frame) = &state.lock().unwrap().latest {
.arg("date") let today_local = Local::now().date_naive();
.arg("-s") let ms = ((frame.frames as f64 / frame.frame_rate) * 1000.0)
.arg(&ts) .round() as u32;
.status() let timecode = NaiveTime::from_hms_milli_opt(
.map(|s| s.success()) frame.hours, frame.minutes, frame.seconds, ms,
.unwrap_or(false); ).expect("Invalid LTC timecode");
let naive_dt = today_local.and_time(timecode);
let entry = if success { let dt_local = Local
format!("🔄 Autosynced to LTC: {}", ts) .from_local_datetime(&naive_dt)
} else { .single()
"❌ Autosync failed".into() .expect("Ambiguous or invalid local time");
}; let ts = dt_local.format("%H:%M:%S.%3f").to_string();
if logs.len() == 10 { logs.pop_front(); }
logs.push_back(entry); let success = Command::new("sudo")
} .arg("date")
out_of_sync_since = None; .arg("-s")
} .arg(&ts)
} else { .status()
out_of_sync_since = Some(Instant::now()); .map(|s| s.success())
} .unwrap_or(false);
} else {
out_of_sync_since = None; let entry = if success {
} format!("🔄 Autosynced to LTC: {}", ts)
} else {
// 8⃣ header & LTC metrics display "❌ Autosync failed".into()
{ };
let st = state.lock().unwrap(); if logs.len() == 10 { logs.pop_front(); }
let opt = st.latest.as_ref(); logs.push_back(entry);
let status_str = opt.map(|f| f.status.as_str()).unwrap_or("(waiting)"); }
let tc_str = match opt { out_of_sync_since = None;
Some(f) => format!("LTC Timecode : {:02}:{:02}:{:02}:{:02}", }
f.hours, f.minutes, f.seconds, f.frames), } else {
None => "LTC Timecode : …".to_string(), out_of_sync_since = Some(Instant::now());
}; }
let fr_str = match opt { } else {
Some(f) => format!("Frame Rate : {:.2}fps", f.frame_rate), out_of_sync_since = None;
None => "Frame Rate : …".to_string(), }
};
// 8⃣ header & LTC metrics display
queue!( {
stdout, let st = state.lock().unwrap();
MoveTo(0, 0), Clear(ClearType::All), let opt = st.latest.as_ref();
MoveTo(2, 1), Print("Have Blue - NTP Timeturner"), let status_str = opt.map(|f| f.status.as_str()).unwrap_or("(waiting)");
MoveTo(2, 2), Print(format!("Serial Port : {}", serial_port)), let tc_str = match opt {
MoveTo(2, 3), Print(format!("Chrony Service : {}", Some(f) => format!("LTC Timecode : {:02}:{:02}:{:02}:{:02}",
if ntp_active { "RUNNING" } else { "MISSING" })), f.hours, f.minutes, f.seconds, f.frames),
MoveTo(2, 4), Print(format!("Interfaces : {}", None => "LTC Timecode : …".to_string(),
interfaces.join(", "))), };
MoveTo(2, 6), Print(format!("LTC Status : {}", status_str)), let fr_str = match opt {
MoveTo(2, 7), Print(tc_str), Some(f) => format!("Frame Rate : {:.2}fps", f.frame_rate),
MoveTo(2, 8), Print(fr_str), None => "Frame Rate : …".to_string(),
).unwrap(); };
}
queue!(
// system clock stdout,
let now_local: DateTime<Local> = DateTime::from(Utc::now()); MoveTo(0, 0), Clear(ClearType::All),
let sys_ts = format!( MoveTo(2, 1), Print("Have Blue - NTP Timeturner"),
"{:02}:{:02}:{:02}.{:03}", MoveTo(2, 2), Print(format!("Serial Port : {}", serial_port)),
now_local.hour(), MoveTo(2, 3), Print(format!("Chrony Service : {}",
now_local.minute(), if ntp_active { "RUNNING" } else { "MISSING" })),
now_local.second(), MoveTo(2, 4), Print(format!("Interfaces : {}",
now_local.timestamp_subsec_millis(), interfaces.join(", "))),
); MoveTo(2, 6), Print(format!("LTC Status : {}", status_str)),
queue!(stdout, MoveTo(2, 7), Print(tc_str),
MoveTo(2, 9), Print(format!( MoveTo(2, 8), Print(fr_str),
"System Clock : {}", ).unwrap();
sys_ts }
))).unwrap();
// system clock
// Δ display let now_local: DateTime<Local> = DateTime::from(Utc::now());
let dcol = if cached_delta_ms.abs() < 20 { let sys_ts = format!(
Color::Green "{:02}:{:02}:{:02}.{:03}",
} else if cached_delta_ms.abs() < 100 { now_local.hour(),
Color::Yellow now_local.minute(),
} else { now_local.second(),
Color::Red now_local.timestamp_subsec_millis(),
}; );
queue!( queue!(stdout,
stdout, MoveTo(2, 9), Print(format!(
MoveTo(2, 11), SetForegroundColor(dcol), "System Clock : {}",
Print(format!("Timecode Δ : {:+} ms ({:+} frames)", cached_delta_ms, cached_delta_frames)), sys_ts
ResetColor, ))).unwrap();
).unwrap();
// Δ display
// sync status let dcol = if cached_delta_ms.abs() < 20 {
let scol = if sync_status == "IN SYNC" { Color::Green
Color::Green } else if cached_delta_ms.abs() < 100 {
} else { Color::Yellow
Color::Red } else {
}; Color::Red
queue!( };
stdout, queue!(
MoveTo(2, 12), SetForegroundColor(scol), stdout,
Print(format!("Sync Status : {}", sync_status)), MoveTo(2, 11), SetForegroundColor(dcol),
ResetColor, Print(format!("Timecode Δ : {:+} ms ({:+} frames)", cached_delta_ms, cached_delta_frames)),
).unwrap(); ResetColor,
).unwrap();
// jitter & lock ratio
let jstatus = if avg_jitter_ms.abs() < 10 { // sync status
"GOOD" let scol = if sync_status == "IN SYNC" {
} else if avg_jitter_ms.abs() < 40 { Color::Green
"AVERAGE" } else {
} else { Color::Red
"BAD" };
}; queue!(
let jcol = if jstatus == "GOOD" { stdout,
Color::Green MoveTo(2, 12), SetForegroundColor(scol),
} else if jstatus == "AVERAGE" { Print(format!("Sync Status : {}", sync_status)),
Color::Yellow ResetColor,
} else { ).unwrap();
Color::Red
}; // jitter & lock ratio
queue!( let jstatus = get_jitter_status(avg_jitter_ms);
stdout, let jcol = if jstatus == "GOOD" {
MoveTo(2, 13), SetForegroundColor(jcol), Color::Green
Print(format!("Sync Jitter : {}", jstatus)), } else if jstatus == "AVERAGE" {
ResetColor, Color::Yellow
).unwrap(); } else {
queue!( Color::Red
stdout, };
MoveTo(2, 14), Print(format!("Lock Ratio : {:.1}% LOCK", queue!(
lock_ratio stdout,
)), MoveTo(2, 13), SetForegroundColor(jcol),
).unwrap(); Print(format!("Sync Jitter : {}", jstatus)),
ResetColor,
// footer + logs ).unwrap();
queue!( queue!(
stdout, stdout,
MoveTo(2, 16), Print("[S] Sync System Clock to LTC [Q] Quit"), MoveTo(2, 14), Print(format!("Lock Ratio : {:.1}% LOCK",
).unwrap(); lock_ratio
for (i, msg) in logs.iter().enumerate() { )),
queue!(stdout, MoveTo(2, 18 + i as u16), Print(msg)).unwrap(); ).unwrap();
}
// footer + logs
stdout.flush().unwrap(); queue!(
stdout,
// manual sync & quit MoveTo(2, 16), Print("[S] Sync System Clock to LTC [Q] Quit"),
if poll(Duration::from_millis(50)).unwrap() { ).unwrap();
if let Event::Key(evt) = read().unwrap() { for (i, msg) in logs.iter().enumerate() {
match evt.code { queue!(stdout, MoveTo(2, 18 + i as u16), Print(msg)).unwrap();
KeyCode::Char(c) if c.eq_ignore_ascii_case(&'q') => { }
execute!(stdout, Show, LeaveAlternateScreen).unwrap();
terminal::disable_raw_mode().unwrap(); stdout.flush().unwrap();
process::exit(0);
} // manual sync & quit
KeyCode::Char(c) if c.eq_ignore_ascii_case(&'s') => { if poll(Duration::from_millis(50)).unwrap() {
if let Some(frame) = &state.lock().unwrap().latest { if let Event::Key(evt) = read().unwrap() {
let today_local = Local::now().date_naive(); match evt.code {
let ms = ((frame.frames as f64 / frame.frame_rate) * 1000.0) KeyCode::Char(c) if c.eq_ignore_ascii_case(&'q') => {
.round() as u32; execute!(stdout, Show, LeaveAlternateScreen).unwrap();
let timecode = NaiveTime::from_hms_milli_opt( terminal::disable_raw_mode().unwrap();
frame.hours, frame.minutes, frame.seconds, ms, process::exit(0);
).expect("Invalid LTC timecode"); }
let naive_dt = today_local.and_time(timecode); KeyCode::Char(c) if c.eq_ignore_ascii_case(&'s') => {
let dt_local = Local if let Some(frame) = &state.lock().unwrap().latest {
.from_local_datetime(&naive_dt) let today_local = Local::now().date_naive();
.single() let ms = ((frame.frames as f64 / frame.frame_rate) * 1000.0)
.expect("Ambiguous or invalid local time"); .round() as u32;
let ts = dt_local.format("%H:%M:%S.%3f").to_string(); let timecode = NaiveTime::from_hms_milli_opt(
frame.hours, frame.minutes, frame.seconds, ms,
let success = Command::new("sudo") ).expect("Invalid LTC timecode");
.arg("date") let naive_dt = today_local.and_time(timecode);
.arg("-s") let dt_local = Local
.arg(&ts) .from_local_datetime(&naive_dt)
.status() .single()
.map(|s| s.success()) .expect("Ambiguous or invalid local time");
.unwrap_or(false); let ts = dt_local.format("%H:%M:%S.%3f").to_string();
let entry = if success { let success = Command::new("sudo")
format!("✔ Synced exactly to LTC: {}", ts) .arg("date")
} else { .arg("-s")
"❌ date cmd failed".into() .arg(&ts)
}; .status()
if logs.len() == 10 { logs.pop_front(); } .map(|s| s.success())
logs.push_back(entry); .unwrap_or(false);
}
} let entry = if success {
_ => {} format!("✔ Synced exactly to LTC: {}", ts)
} } else {
} "❌ date cmd failed".into()
} };
if logs.len() == 10 { logs.pop_front(); }
thread::sleep(Duration::from_millis(25)); logs.push_back(entry);
} }
} }
_ => {}
}
}
}
thread::sleep(Duration::from_millis(25));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[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");
}
#[test]
fn test_get_jitter_status() {
assert_eq!(get_jitter_status(5), "GOOD");
assert_eq!(get_jitter_status(-5), "GOOD");
assert_eq!(get_jitter_status(9), "GOOD");
assert_eq!(get_jitter_status(10), "AVERAGE");
assert_eq!(get_jitter_status(39), "AVERAGE");
assert_eq!(get_jitter_status(-39), "AVERAGE");
assert_eq!(get_jitter_status(40), "BAD");
assert_eq!(get_jitter_status(-40), "BAD");
}
}