diff --git a/Cargo.toml b/Cargo.toml index 06498ec..3e04d14 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,4 +16,9 @@ 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 efef9d9..4dd5685 100644 --- a/setup.sh +++ b/setup.sh @@ -1,125 +1,59 @@ #!/bin/bash set -e -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 "" +echo "--- TimeTurner Setup ---" -# --------------------------------------------------------- -# 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 +# 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 fi -cd teensy_loader_cli -make -sudo install -m 755 teensy_loader_cli /usr/local/bin/teensy-loader-cli +cargo build --release +echo "✅ Build complete." -echo "Verifying teensy-loader-cli..." -teensy-loader-cli --version || echo "⚠️ teensy-loader-cli failed to install properly" +# 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." -# --------------------------------------------------------- -# 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." +# 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 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." +# 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." else - echo "⚠️ splash.png not found — skipping." + echo "⚠️ Skipping systemd service installation on non-Linux OS." 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 "─────────────────────────────────────────────" -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 "--- 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 "" -sleep 15 -sudo reboot diff --git a/src/api.rs b/src/api.rs index 659cd73..0bd2d2a 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::LtcState; -use crate::ui; +use crate::sync_logic::{self, LtcState}; +use crate::system; // 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 = ui::get_sync_status(avg_delta, &config).to_string(); - let jitter_status = ui::get_jitter_status(state.average_jitter()).to_string(); + let sync_status = sync_logic::get_sync_status(avg_delta, &config); + let jitter_status = sync_logic::get_jitter_status(state.average_jitter()); let lock_ratio = state.lock_ratio(); - let ntp_active = ui::ntp_service_active(); + let ntp_active = system::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, - jitter_status, + sync_status: sync_status.to_string(), + jitter_status: jitter_status.to_string(), 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 ui::trigger_sync(frame, &config).is_ok() { + if system::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 f019aa4..5ee280e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,24 +2,41 @@ mod api; mod config; -mod sync_logic; mod serial_input; +mod sync_logic; +mod system; mod ui; use crate::api::start_api_server; use crate::config::watch_config; -use crate::sync_logic::LtcState; use crate::serial_input::start_serial_thread; +use crate::sync_logic::LtcState; use crate::ui::start_ui; +use clap::Parser; +use daemonize::Daemonize; +use env_logger; use std::{ fs, path::Path, - sync::{Arc, Mutex, mpsc}, + sync::{mpsc, Arc, Mutex}, 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. @@ -46,27 +63,47 @@ fn ensure_config() { #[tokio::main(flavor = "current_thread")] async fn main() { - // 🔄 Ensure there's always a config.json present + 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_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 (no offset here) + // 4️⃣ Spawn the serial reader thread { - 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, @@ -77,18 +114,26 @@ async fn main() { }); } - // 5️⃣ Spawn the UI renderer thread, passing the live config Arc - { - let ui_state = ltc_state.clone(); + // 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(); 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. + // 6️⃣ Set up a LocalSet for the API server and main loop let local = LocalSet::new(); local .run_until(async move { @@ -103,15 +148,20 @@ async fn main() { }); } - // 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; + // 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; + } }) .await; } diff --git a/src/sync_logic.rs b/src/sync_logic.rs index 86ad746..b1cbf8b 100644 --- a/src/sync_logic.rs +++ b/src/sync_logic.rs @@ -1,4 +1,5 @@ -use chrono::{DateTime, Local, Timelike, Utc}; +use crate::config::Config; +use chrono::{DateTime, Local, Timelike, Utc}; use regex::Captures; use std::collections::VecDeque; @@ -170,10 +171,33 @@ 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 { @@ -332,4 +356,34 @@ 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 new file mode 100644 index 0000000..979df17 --- /dev/null +++ b/src/system.rs @@ -0,0 +1,182 @@ +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 15eb66d..7d1c265 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,6 +1,6 @@ use std::{ io::{stdout, Write}, - process::{self, Command}, + process::{self}, sync::{Arc, Mutex}, thread, time::{Duration, Instant}, @@ -21,108 +21,9 @@ use crossterm::{ use crate::config::Config; use get_if_addrs::get_if_addrs; -use crate::sync_logic::{LtcFrame, LtcState}; +use crate::sync_logic::{get_jitter_status, get_sync_status, LtcState}; +use crate::system; -/// 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>, @@ -145,7 +46,7 @@ pub fn start_ui( let hw_offset_ms = cfg.hardware_offset_ms; // 2️⃣ Chrony + interfaces - let ntp_active = ntp_service_active(); + let ntp_active = system::ntp_service_active(); let interfaces: Vec = get_if_addrs() .unwrap_or_default() .into_iter() @@ -226,7 +127,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 trigger_sync(frame, &cfg) { + let entry = match system::trigger_sync(frame, &cfg) { Ok(ts) => format!("🔄 Auto‑synced to LTC: {}", ts), Err(_) => "❌ Auto‑sync failed".into(), }; @@ -361,7 +262,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 trigger_sync(frame, &cfg) { + let entry = match system::trigger_sync(frame, &cfg) { Ok(ts) => format!("✔ Synced exactly to LTC: {}", ts), Err(_) => "❌ date cmd failed".into(), }; @@ -380,37 +281,8 @@ 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"); - } }