diff --git a/Cargo.toml b/Cargo.toml index 3e04d14..06498ec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,9 +16,4 @@ get_if_addrs = "0.5" actix-web = "4" actix-files = "0.6" tokio = { version = "1", features = ["full"] } -clap = { version = "4.4", features = ["derive"] } -log = "0.4" -env_logger = "0.11" -daemonize = "0.5.0" - diff --git a/setup.sh b/setup.sh index 4dd5685..efef9d9 100644 --- a/setup.sh +++ b/setup.sh @@ -1,59 +1,125 @@ #!/bin/bash set -e -echo "--- TimeTurner Setup ---" +echo "" +echo "─────────────────────────────────────────────" +echo " Welcome to the NTP TimeTurner Installer" +echo "─────────────────────────────────────────────" +echo "" +echo "\"It's a very complicated piece of magic...\" – Hermione Granger" +echo "Preparing the Ministry-grade temporal interface..." +echo "" -# 1. Build the release binary -echo "📦 Building release binary with Cargo..." -if ! command -v cargo &> /dev/null -then - echo "❌ Cargo is not installed. Please install Rust and Cargo first." - echo "Visit https://rustup.rs/ for instructions." - exit 1 +# --------------------------------------------------------- +# Step 1: Update and upgrade packages +# --------------------------------------------------------- +echo "Step 1: Updating package lists and upgrading..." +sudo apt update && sudo apt upgrade -y + +# --------------------------------------------------------- +# Step 2: Install core tools and Python dependencies +# --------------------------------------------------------- +echo "Step 2: Installing required tools..." +sudo apt install -y git curl python3 python3-pip build-essential cmake \ + python3-serial libusb-dev + +# --------------------------------------------------------- +# Step 2.5: Install teensy-loader-cli from source +# --------------------------------------------------------- +echo "Installing teensy-loader-cli manually from source..." +cd "$HOME" +if [ ! -d teensy_loader_cli ]; then + git clone https://github.com/PaulStoffregen/teensy_loader_cli.git fi -cargo build --release -echo "✅ Build complete." +cd teensy_loader_cli +make +sudo install -m 755 teensy_loader_cli /usr/local/bin/teensy-loader-cli -# 2. Create installation directories -INSTALL_DIR="/opt/timeturner" -BIN_DIR="/usr/local/bin" -echo "🔧 Creating directories..." -sudo mkdir -p $INSTALL_DIR -echo "✅ Directory $INSTALL_DIR created." +echo "Verifying teensy-loader-cli..." +teensy-loader-cli --version || echo "⚠️ teensy-loader-cli failed to install properly" -# 3. Install binary -echo "🚀 Installing timeturner binary..." -sudo cp target/release/ntp_timeturner $INSTALL_DIR/timeturner -sudo ln -sf $INSTALL_DIR/timeturner $BIN_DIR/timeturner -echo "✅ Binary installed to $INSTALL_DIR and linked to $BIN_DIR." +# --------------------------------------------------------- +# Step 2.6: Install udev rules for Teensy +# --------------------------------------------------------- +echo "Installing udev rules for Teensy access..." +cd "$HOME" +wget -O 49-teensy.rules https://www.pjrc.com/teensy/49-teensy.rules +sudo cp 49-teensy.rules /etc/udev/rules.d/ +sudo udevadm control --reload-rules +sudo udevadm trigger +echo "✅ Teensy udev rules installed. Reboot required to take full effect." -# 4. Install systemd service file -if [[ "$(uname)" == "Linux" ]]; then - echo "⚙️ Installing systemd service for Linux..." - sudo cp timeturner.service /etc/systemd/system/ - sudo systemctl daemon-reload - sudo systemctl enable timeturner.service - echo "✅ Systemd service installed and enabled." +# --------------------------------------------------------- +# Step 3: Install Arduino CLI manually (latest version) +# --------------------------------------------------------- +echo "Step 3: Downloading and installing arduino-cli..." +cd "$HOME" +curl -fsSL https://downloads.arduino.cc/arduino-cli/arduino-cli_latest_Linux_ARM64.tar.gz -o arduino-cli.tar.gz +tar -xzf arduino-cli.tar.gz +sudo mv arduino-cli /usr/local/bin/ +rm arduino-cli.tar.gz + +echo "Verifying arduino-cli install..." +arduino-cli version || echo "⚠️ arduino-cli install failed or not found in PATH" + +# --------------------------------------------------------- +# Step 4: Download and apply splash screen +# --------------------------------------------------------- +echo "Step 4: Downloading and applying splash screen..." +cd "$HOME" +wget -O splash.png https://raw.githubusercontent.com/cjfranko/NTP-Timeturner/master/splash.png + +if [ -f splash.png ]; then + sudo cp splash.png /usr/share/plymouth/themes/pix/splash.png + sudo chmod 644 /usr/share/plymouth/themes/pix/splash.png + echo "✅ Splash screen applied." else - echo "⚠️ Skipping systemd service installation on non-Linux OS." + echo "⚠️ splash.png not found — skipping." fi +# --------------------------------------------------------- +# Step 4.5: Configure Plymouth to stay on screen longer +# --------------------------------------------------------- +echo "Step 4.5: Configuring splash screen timing..." + +# Ensure 'quiet splash' is in /boot/cmdline.txt +sudo sed -i 's/\(\s*\)console=tty1/\1quiet splash console=tty1/' /boot/cmdline.txt +echo "✅ Set 'quiet splash' in /boot/cmdline.txt" + +# Update Plymouth config +sudo sed -i 's/^Theme=.*/Theme=pix/' /etc/plymouth/plymouthd.conf +sudo sed -i 's/^ShowDelay=.*/ShowDelay=0/' /etc/plymouth/plymouthd.conf || echo "ShowDelay=0" | sudo tee -a /etc/plymouth/plymouthd.conf +sudo sed -i 's/^DeviceTimeout=.*/DeviceTimeout=10/' /etc/plymouth/plymouthd.conf || echo "DeviceTimeout=10" | sudo tee -a /etc/plymouth/plymouthd.conf +sudo sed -i 's/^DisableFadeIn=.*/DisableFadeIn=true/' /etc/plymouth/plymouthd.conf || echo "DisableFadeIn=true" | sudo tee -a /etc/plymouth/plymouthd.conf +echo "✅ Updated /etc/plymouth/plymouthd.conf" + +# Create autostart delay to keep splash visible until desktop is ready +mkdir -p "$HOME/.config/autostart" +cat << EOF > "$HOME/.config/autostart/delayed-plymouth-exit.desktop" +[Desktop Entry] +Type=Application +Name=Delayed Plymouth Exit +Exec=/bin/sh -c "sleep 3 && /usr/bin/plymouth quit" +X-GNOME-Autostart-enabled=true +EOF +echo "✅ Splash screen will exit 3 seconds after desktop starts" + +# --------------------------------------------------------- +# Step 5: Download Teensy firmware +# --------------------------------------------------------- +echo "Step 5: Downloading Teensy firmware..." +cd "$HOME" +wget -O ltc_audiohat_lock.ino.hex https://raw.githubusercontent.com/cjfranko/NTP-Timeturner/master/firmware/ltc_audiohat_lock.ino.hex + +# --------------------------------------------------------- +# Final Message & Reboot +# --------------------------------------------------------- echo "" -echo "--- Setup Complete ---" -echo "The TimeTurner daemon is now installed." -echo "The working directory is $INSTALL_DIR." -echo "A default 'config.yml' will be created there on first run." -echo "" -if [[ "$(uname)" == "Linux" ]]; then - echo "To start the service, run:" - echo " sudo systemctl start timeturner.service" - echo "" - echo "To view live logs, run:" - echo " journalctl -u timeturner.service -f" - echo "" -fi -echo "To run the interactive TUI instead, simply run from the project directory:" -echo " cargo run" -echo "Or from anywhere after installation:" -echo " timeturner" +echo "─────────────────────────────────────────────" +echo " Setup Complete — Rebooting in 15 seconds..." +echo "─────────────────────────────────────────────" +echo "NOTE: Teensy firmware ready in $HOME, but not auto-flashed." +echo "Boot splash will remain until desktop loads. " echo "" +sleep 15 +sudo reboot diff --git a/src/api.rs b/src/api.rs index 0bd2d2a..659cd73 100644 --- a/src/api.rs +++ b/src/api.rs @@ -8,8 +8,8 @@ use serde_json; use std::sync::{Arc, Mutex}; use crate::config::{self, Config}; -use crate::sync_logic::{self, LtcState}; -use crate::system; +use crate::sync_logic::LtcState; +use crate::ui; // Data structure for the main status response #[derive(Serialize, Deserialize)] @@ -64,11 +64,11 @@ async fn get_status(data: web::Data) -> impl Responder { delta_frames = ((avg_delta as f64 / frame_ms).round()) as i64; } - let sync_status = sync_logic::get_sync_status(avg_delta, &config); - let jitter_status = sync_logic::get_jitter_status(state.average_jitter()); + let sync_status = ui::get_sync_status(avg_delta, &config).to_string(); + let jitter_status = ui::get_jitter_status(state.average_jitter()).to_string(); let lock_ratio = state.lock_ratio(); - let ntp_active = system::ntp_service_active(); + let ntp_active = ui::ntp_service_active(); let interfaces = get_if_addrs() .unwrap_or_default() .into_iter() @@ -83,8 +83,8 @@ async fn get_status(data: web::Data) -> impl Responder { system_clock, timecode_delta_ms: avg_delta, timecode_delta_frames: delta_frames, - sync_status: sync_status.to_string(), - jitter_status: jitter_status.to_string(), + sync_status, + jitter_status, lock_ratio, ntp_active, interfaces, @@ -97,7 +97,7 @@ async fn manual_sync(data: web::Data) -> impl Responder { let state = data.ltc_state.lock().unwrap(); let config = data.config.lock().unwrap(); if let Some(frame) = &state.latest { - if system::trigger_sync(frame, &config).is_ok() { + if ui::trigger_sync(frame, &config).is_ok() { HttpResponse::Ok().json(serde_json::json!({ "status": "success", "message": "Sync command issued." })) } else { HttpResponse::InternalServerError().json(serde_json::json!({ "status": "error", "message": "Sync command failed." })) diff --git a/src/main.rs b/src/main.rs index 5ee280e..f019aa4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,41 +2,24 @@ mod api; mod config; -mod serial_input; mod sync_logic; -mod system; +mod serial_input; mod ui; use crate::api::start_api_server; use crate::config::watch_config; -use crate::serial_input::start_serial_thread; use crate::sync_logic::LtcState; +use crate::serial_input::start_serial_thread; use crate::ui::start_ui; -use clap::Parser; -use daemonize::Daemonize; -use env_logger; use std::{ fs, path::Path, - sync::{mpsc, Arc, Mutex}, + sync::{Arc, Mutex, mpsc}, thread, }; use tokio::task::{self, LocalSet}; -#[derive(Parser, Debug)] -#[command(author, version, about, long_about = None)] -struct Args { - #[command(subcommand)] - command: Option, -} - -#[derive(clap::Subcommand, Debug)] -enum Command { - /// Run as a background daemon providing a web UI. - Daemon, -} - /// Default config content, embedded in the binary. const DEFAULT_CONFIG: &str = r#" # Hardware offset in milliseconds for correcting capture latency. @@ -63,47 +46,27 @@ fn ensure_config() { #[tokio::main(flavor = "current_thread")] async fn main() { - let args = Args::parse(); - - if let Some(Command::Daemon) = &args.command { - println!("🚀 Starting daemon..."); - - // Create files for stdout and stderr in the current directory - let stdout = fs::File::create("daemon.out").expect("Could not create daemon.out"); - let stderr = fs::File::create("daemon.err").expect("Could not create daemon.err"); - - let daemonize = Daemonize::new() - .pid_file("ntp_timeturner.pid") // Create a PID file - .working_directory(".") // Keep the same working directory - .stdout(stdout) - .stderr(stderr); - - match daemonize.start() { - Ok(_) => { /* Process is now daemonized */ } - Err(e) => { - eprintln!("Error daemonizing: {}", e); - return; // Exit if daemonization fails - } - } - } - - // 🔄 Ensure there's always a config.yml present + // 🔄 Ensure there's always a config.json present ensure_config(); // 1️⃣ Start watching config.yml for changes let config = watch_config("config.yml"); + println!("🔧 Watching config.yml..."); // 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 + // 4️⃣ Spawn the serial reader thread (no offset here) { - let tx_clone = tx.clone(); + let tx_clone = tx.clone(); let state_clone = ltc_state.clone(); thread::spawn(move || { + println!("🚀 Serial thread launched"); start_serial_thread( "/dev/ttyACM0", 115200, @@ -114,26 +77,18 @@ async fn main() { }); } - // 5️⃣ Spawn UI or setup daemon logging - if args.command.is_none() { - println!("🔧 Watching config.yml..."); - println!("🚀 Serial thread launched"); - println!("🖥️ UI thread launched"); - let ui_state = ltc_state.clone(); + // 5️⃣ Spawn the UI renderer thread, passing the live config Arc + { + let ui_state = ltc_state.clone(); let config_clone = config.clone(); - let port = "/dev/ttyACM0".to_string(); + let port = "/dev/ttyACM0".to_string(); thread::spawn(move || { + println!("🖥️ UI thread launched"); start_ui(ui_state, port, config_clone); }); - } else { - // In daemon mode, we initialize env_logger. - // This will log to stdout, and the systemd service will capture it. - // The RUST_LOG env var controls the log level (e.g., RUST_LOG=info). - env_logger::init(); - log::info!("🚀 Starting TimeTurner daemon..."); } - // 6️⃣ Set up a LocalSet for the API server and main loop + // 6️⃣ Set up a LocalSet for the API server. let local = LocalSet::new(); local .run_until(async move { @@ -148,20 +103,15 @@ async fn main() { }); } - // 8️⃣ Keep main thread alive - if args.command.is_some() { - // In daemon mode, wait forever. - std::future::pending::<()>().await; - } else { - // In TUI mode, block on the channel. - println!("📡 Main thread entering loop..."); - let _ = task::spawn_blocking(move || { - for _frame in rx { - // no-op - } - }) - .await; - } + // 8️⃣ Keep main thread alive by consuming LTC frames in a blocking task + println!("📡 Main thread entering loop..."); + let _ = task::spawn_blocking(move || { + // This will block the thread, but it's a blocking-safe thread. + for _frame in rx { + // no-op + } + }) + .await; }) .await; } diff --git a/src/sync_logic.rs b/src/sync_logic.rs index b1cbf8b..86ad746 100644 --- a/src/sync_logic.rs +++ b/src/sync_logic.rs @@ -1,5 +1,4 @@ -use crate::config::Config; -use chrono::{DateTime, Local, Timelike, Utc}; +use chrono::{DateTime, Local, Timelike, Utc}; use regex::Captures; use std::collections::VecDeque; @@ -171,33 +170,10 @@ impl LtcState { &self.last_match_status } } - -pub fn get_sync_status(delta_ms: i64, config: &Config) -> &'static str { - if config.timeturner_offset.is_active() { - "TIMETURNING" - } else if delta_ms.abs() <= 8 { - "IN SYNC" - } else if delta_ms > 10 { - "CLOCK AHEAD" - } else { - "CLOCK BEHIND" - } -} - -pub fn get_jitter_status(jitter_ms: i64) -> &'static str { - if jitter_ms.abs() < 10 { - "GOOD" - } else if jitter_ms.abs() < 40 { - "AVERAGE" - } else { - "BAD" - } -} // This module provides the logic for handling LTC (Linear Timecode) frames and maintaining state. #[cfg(test)] mod tests { use super::*; - use crate::config::{Config, TimeturnerOffset}; use chrono::{Local, Utc}; fn get_test_frame(status: &str, h: u32, m: u32, s: u32) -> LtcFrame { @@ -356,34 +332,4 @@ mod tests { "Median of even numbers should be correct" ); } - - #[test] - fn test_get_sync_status() { - let mut config = Config::default(); - assert_eq!(get_sync_status(0, &config), "IN SYNC"); - assert_eq!(get_sync_status(8, &config), "IN SYNC"); - assert_eq!(get_sync_status(-8, &config), "IN SYNC"); - assert_eq!(get_sync_status(9, &config), "CLOCK BEHIND"); - assert_eq!(get_sync_status(10, &config), "CLOCK BEHIND"); - assert_eq!(get_sync_status(11, &config), "CLOCK AHEAD"); - assert_eq!(get_sync_status(-9, &config), "CLOCK BEHIND"); - assert_eq!(get_sync_status(-100, &config), "CLOCK BEHIND"); - - // Test TIMETURNING status - config.timeturner_offset = TimeturnerOffset { hours: 1, minutes: 0, seconds: 0, frames: 0 }; - assert_eq!(get_sync_status(0, &config), "TIMETURNING"); - assert_eq!(get_sync_status(100, &config), "TIMETURNING"); - } - - #[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"); - } } diff --git a/src/system.rs b/src/system.rs deleted file mode 100644 index 979df17..0000000 --- a/src/system.rs +++ /dev/null @@ -1,182 +0,0 @@ -use crate::config::Config; -use crate::sync_logic::LtcFrame; -use chrono::{DateTime, Duration as ChronoDuration, Local, NaiveTime, TimeZone}; -use std::process::Command; - -/// Check if Chrony is active -pub fn ntp_service_active() -> bool { - #[cfg(target_os = "linux")] - { - if let Ok(output) = Command::new("systemctl").args(&["is-active", "chrony"]).output() { - output.status.success() - && String::from_utf8_lossy(&output.stdout).trim() == "active" - } else { - false - } - } - #[cfg(not(target_os = "linux"))] - { - // systemctl is not available on non-Linux platforms. - false - } -} - -/// Toggle Chrony (not used yet) -#[allow(dead_code)] -pub fn ntp_service_toggle(start: bool) { - #[cfg(target_os = "linux")] - { - let action = if start { "start" } else { "stop" }; - let _ = Command::new("systemctl").args(&[action, "chrony"]).status(); - } - #[cfg(not(target_os = "linux"))] - { - // No-op on non-Linux. - // The parameter is unused, but the function is dead code anyway. - let _ = start; - } -} - -pub fn calculate_target_time(frame: &LtcFrame, config: &Config) -> DateTime { - let today_local = Local::now().date_naive(); - let ms = ((frame.frames as f64 / frame.frame_rate) * 1000.0).round() as u32; - let timecode = NaiveTime::from_hms_milli_opt(frame.hours, frame.minutes, frame.seconds, ms) - .expect("Invalid LTC timecode"); - - let naive_dt = today_local.and_time(timecode); - let mut dt_local = Local - .from_local_datetime(&naive_dt) - .single() - .expect("Ambiguous or invalid local time"); - - // Apply timeturner offset - let offset = &config.timeturner_offset; - dt_local = dt_local - + ChronoDuration::hours(offset.hours) - + ChronoDuration::minutes(offset.minutes) - + ChronoDuration::seconds(offset.seconds); - // Frame offset needs to be converted to milliseconds - let frame_offset_ms = (offset.frames as f64 / frame.frame_rate * 1000.0).round() as i64; - dt_local + ChronoDuration::milliseconds(frame_offset_ms) -} - -pub fn trigger_sync(frame: &LtcFrame, config: &Config) -> Result { - let dt_local = calculate_target_time(frame, config); - - #[cfg(target_os = "linux")] - let (ts, success) = { - let ts = dt_local.format("%H:%M:%S.%3f").to_string(); - let success = Command::new("sudo") - .arg("date") - .arg("-s") - .arg(&ts) - .status() - .map(|s| s.success()) - .unwrap_or(false); - (ts, success) - }; - - #[cfg(target_os = "macos")] - let (ts, success) = { - // macOS `date` command format is `mmddHHMMccyy.SS` - let ts = dt_local.format("%m%d%H%M%y.%S").to_string(); - let success = Command::new("sudo") - .arg("date") - .arg(&ts) - .status() - .map(|s| s.success()) - .unwrap_or(false); - (ts, success) - }; - - #[cfg(not(any(target_os = "linux", target_os = "macos")))] - let (ts, success) = { - // Unsupported OS, always fail - let ts = dt_local.format("%H:%M:%S.%3f").to_string(); - eprintln!("Unsupported OS for time synchronization"); - (ts, false) - }; - - if success { - Ok(ts) - } else { - Err(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::config::TimeturnerOffset; - use chrono::{Timelike, Utc}; - - // Helper to create a test frame - fn get_test_frame(h: u32, m: u32, s: u32, f: u32) -> LtcFrame { - LtcFrame { - status: "LOCK".to_string(), - hours: h, - minutes: m, - seconds: s, - frames: f, - frame_rate: 25.0, - timestamp: Utc::now(), - } - } - - #[test] - fn test_ntp_service_active_on_non_linux() { - // On non-Linux platforms, this should always be false. - #[cfg(not(target_os = "linux"))] - assert!(!ntp_service_active()); - } - - #[test] - fn test_calculate_target_time_no_offset() { - let frame = get_test_frame(10, 20, 30, 0); - let config = Config::default(); - let target_time = calculate_target_time(&frame, &config); - - assert_eq!(target_time.hour(), 10); - assert_eq!(target_time.minute(), 20); - assert_eq!(target_time.second(), 30); - } - - #[test] - fn test_calculate_target_time_with_positive_offset() { - let frame = get_test_frame(10, 20, 30, 0); - let mut config = Config::default(); - config.timeturner_offset = TimeturnerOffset { - hours: 1, - minutes: 5, - seconds: 10, - frames: 12, // 12 frames at 25fps is 480ms - }; - - let target_time = calculate_target_time(&frame, &config); - - assert_eq!(target_time.hour(), 11); - assert_eq!(target_time.minute(), 25); - assert_eq!(target_time.second(), 40); - // 480ms - assert_eq!(target_time.nanosecond(), 480_000_000); - } - - #[test] - fn test_calculate_target_time_with_negative_offset() { - let frame = get_test_frame(10, 20, 30, 12); // 12 frames = 480ms - let mut config = Config::default(); - config.timeturner_offset = TimeturnerOffset { - hours: -1, - minutes: -5, - seconds: -10, - frames: -12, // -480ms - }; - - let target_time = calculate_target_time(&frame, &config); - - assert_eq!(target_time.hour(), 9); - assert_eq!(target_time.minute(), 15); - assert_eq!(target_time.second(), 20); - assert_eq!(target_time.nanosecond(), 0); - } -} diff --git a/src/ui.rs b/src/ui.rs index 7d1c265..15eb66d 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,6 +1,6 @@ use std::{ io::{stdout, Write}, - process::{self}, + process::{self, Command}, sync::{Arc, Mutex}, thread, time::{Duration, Instant}, @@ -21,9 +21,108 @@ use crossterm::{ use crate::config::Config; use get_if_addrs::get_if_addrs; -use crate::sync_logic::{get_jitter_status, get_sync_status, LtcState}; -use crate::system; +use crate::sync_logic::{LtcFrame, LtcState}; +/// Check if Chrony is active +pub fn ntp_service_active() -> bool { + if let Ok(output) = Command::new("systemctl").args(&["is-active", "chrony"]).output() { + output.status.success() + && String::from_utf8_lossy(&output.stdout).trim() == "active" + } else { + false + } +} + +/// Toggle Chrony (not used yet) +#[allow(dead_code)] +fn ntp_service_toggle(start: bool) { + let action = if start { "start" } else { "stop" }; + let _ = Command::new("systemctl").args(&[action, "chrony"]).status(); +} + +pub fn get_sync_status(delta_ms: i64, config: &Config) -> &'static str { + if config.timeturner_offset.is_active() { + "TIMETURNING" + } else if delta_ms.abs() <= 8 { + "IN SYNC" + } else if delta_ms > 10 { + "CLOCK AHEAD" + } else { + "CLOCK BEHIND" + } +} + +pub fn get_jitter_status(jitter_ms: i64) -> &'static str { + if jitter_ms.abs() < 10 { + "GOOD" + } else if jitter_ms.abs() < 40 { + "AVERAGE" + } else { + "BAD" + } +} + +pub fn trigger_sync(frame: &LtcFrame, config: &Config) -> Result { + let today_local = Local::now().date_naive(); + let ms = ((frame.frames as f64 / frame.frame_rate) * 1000.0).round() as u32; + let timecode = NaiveTime::from_hms_milli_opt(frame.hours, frame.minutes, frame.seconds, ms) + .expect("Invalid LTC timecode"); + + let naive_dt = today_local.and_time(timecode); + let mut dt_local = Local + .from_local_datetime(&naive_dt) + .single() + .expect("Ambiguous or invalid local time"); + + // Apply timeturner offset + let offset = &config.timeturner_offset; + dt_local = dt_local + + ChronoDuration::hours(offset.hours) + + ChronoDuration::minutes(offset.minutes) + + ChronoDuration::seconds(offset.seconds); + // Frame offset needs to be converted to milliseconds + let frame_offset_ms = (offset.frames as f64 / frame.frame_rate * 1000.0).round() as i64; + dt_local = dt_local + ChronoDuration::milliseconds(frame_offset_ms); + #[cfg(target_os = "linux")] + let (ts, success) = { + let ts = dt_local.format("%H:%M:%S.%3f").to_string(); + let success = Command::new("sudo") + .arg("date") + .arg("-s") + .arg(&ts) + .status() + .map(|s| s.success()) + .unwrap_or(false); + (ts, success) + }; + + #[cfg(target_os = "macos")] + let (ts, success) = { + // macOS `date` command format is `mmddHHMMccyy.SS` + let ts = dt_local.format("%m%d%H%M%y.%S").to_string(); + let success = Command::new("sudo") + .arg("date") + .arg(&ts) + .status() + .map(|s| s.success()) + .unwrap_or(false); + (ts, success) + }; + + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + let (ts, success) = { + // Unsupported OS, always fail + let ts = dt_local.format("%H:%M:%S.%3f").to_string(); + eprintln!("Unsupported OS for time synchronization"); + (ts, false) + }; + + if success { + Ok(ts) + } else { + Err(()) + } +} pub fn start_ui( state: Arc>, @@ -46,7 +145,7 @@ pub fn start_ui( let hw_offset_ms = cfg.hardware_offset_ms; // 2️⃣ Chrony + interfaces - let ntp_active = system::ntp_service_active(); + let ntp_active = ntp_service_active(); let interfaces: Vec = get_if_addrs() .unwrap_or_default() .into_iter() @@ -127,7 +226,7 @@ pub fn start_ui( if let Some(start) = out_of_sync_since { if start.elapsed() >= Duration::from_secs(5) { if let Some(frame) = &state.lock().unwrap().latest { - let entry = match system::trigger_sync(frame, &cfg) { + let entry = match trigger_sync(frame, &cfg) { Ok(ts) => format!("🔄 Auto‑synced to LTC: {}", ts), Err(_) => "❌ Auto‑sync failed".into(), }; @@ -262,7 +361,7 @@ pub fn start_ui( } KeyCode::Char(c) if c.eq_ignore_ascii_case(&'s') => { if let Some(frame) = &state.lock().unwrap().latest { - let entry = match system::trigger_sync(frame, &cfg) { + let entry = match trigger_sync(frame, &cfg) { Ok(ts) => format!("✔ Synced exactly to LTC: {}", ts), Err(_) => "❌ date cmd failed".into(), }; @@ -281,8 +380,37 @@ pub fn start_ui( #[cfg(test)] mod tests { - #[allow(unused_imports)] use super::*; - #[allow(unused_imports)] + use crate::config::TimeturnerOffset; + + #[test] + fn test_get_sync_status() { + let mut config = Config::default(); + assert_eq!(get_sync_status(0, &config), "IN SYNC"); + assert_eq!(get_sync_status(8, &config), "IN SYNC"); + assert_eq!(get_sync_status(-8, &config), "IN SYNC"); + assert_eq!(get_sync_status(9, &config), "CLOCK BEHIND"); + assert_eq!(get_sync_status(10, &config), "CLOCK BEHIND"); + assert_eq!(get_sync_status(11, &config), "CLOCK AHEAD"); + assert_eq!(get_sync_status(-9, &config), "CLOCK BEHIND"); + assert_eq!(get_sync_status(-100, &config), "CLOCK BEHIND"); + + // Test TIMETURNING status + config.timeturner_offset = TimeturnerOffset { hours: 1, minutes: 0, seconds: 0, frames: 0 }; + assert_eq!(get_sync_status(0, &config), "TIMETURNING"); + assert_eq!(get_sync_status(100, &config), "TIMETURNING"); + } + + #[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"); + } }