// src/main.rs mod api; mod config; mod logger; mod serial_input; mod sync_logic; mod system; 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::ui::start_ui; use clap::Parser; use daemonize::Daemonize; use serialport; use std::{ env, fs, path::Path, 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, /// Stop the running daemon process. Kill, } /// Default config content, embedded in the binary. const DEFAULT_CONFIG: &str = r#" # Hardware offset in milliseconds for correcting capture latency. hardwareOffsetMs: 20 # Enable automatic clock synchronization. # When enabled, the system will perform an initial full sync, then periodically # nudge the clock to keep it aligned with the LTC source. autoSyncEnabled: false # Default nudge in milliseconds for adjtimex control. defaultNudgeMs: 2 # Time-turning offsets. All values are added to the incoming LTC time. # These can be positive or negative. timeturnerOffset: hours: 0 minutes: 0 seconds: 0 frames: 0 milliseconds: 0 "#; /// If no `config.yml` exists alongside the binary, write out the default. fn ensure_config() { let p = Path::new("config.yml"); if !p.exists() { fs::write(p, DEFAULT_CONFIG.trim()) .expect("Failed to write default config.yml"); log::info!("⚙️ Emitted default config.yml"); } } fn find_serial_port() -> Option { // Prefer environment variable if provided (e.g., by entrypoint for mock PTY) if let Ok(path) = env::var("HACI_SERIAL_PORT") { let p = Path::new(&path); if !p.exists() { // Wait up to ~5 seconds for the PTY to appear for _ in 0..50 { if p.exists() { break; } thread::sleep(std::time::Duration::from_millis(100)); } } if p.exists() { return Some(path); } else { log::warn!( "HACI_SERIAL_PORT='{}' does not exist. Falling back to port scan.", path ); } } if let Ok(ports) = serialport::available_ports() { for p in ports { if p.port_name.starts_with("/dev/ttyACM") || p.port_name.starts_with("/dev/ttyAMA") || p.port_name.starts_with("/dev/ttyUSB") { return Some(p.port_name); } } } None } #[tokio::main(flavor = "current_thread")] async fn main() { // This must be called before any logging statements. let log_buffer = logger::setup_logger(); let args = Args::parse(); if let Some(command) = &args.command { match command { Command::Daemon => { log::info!("🚀 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) => { log::error!("Error daemonizing: {}", e); return; // Exit if daemonization fails } } } Command::Kill => { log::info!("🛑 Stopping daemon..."); let pid_file = "ntp_timeturner.pid"; match fs::read_to_string(pid_file) { Ok(pid_str) => { let pid_str = pid_str.trim(); log::info!("Found daemon with PID: {}", pid_str); match std::process::Command::new("kill").arg("-9").arg(format!("-{}", pid_str)).status() { Ok(status) => { if status.success() { log::info!("✅ Daemon stopped successfully."); if fs::remove_file(pid_file).is_err() { log::warn!("Could not remove PID file '{}'. It may need to be removed manually.", pid_file); } } else { log::error!("'kill' command failed with status: {}. The daemon may not be running, or you may not have permission to stop it.", status); log::warn!("Attempting to remove stale PID file '{}'...", pid_file); if fs::remove_file(pid_file).is_ok() { log::info!("Removed stale PID file."); } else { log::warn!("Could not remove PID file."); } } } Err(e) => { log::error!("Failed to execute 'kill' command. Is 'kill' in your PATH? Error: {}", e); } } } Err(_) => { log::error!("Could not read PID file '{}'. Is the daemon running in this directory?", pid_file); } } return; } } } // 🔄 Ensure there's always a config.yml present ensure_config(); // 1️⃣ Start watching config.yml for changes let config = watch_config("config.yml"); // 2️⃣ Channel for raw LTC frames let (tx, rx) = mpsc::channel(); // 3️⃣ Shared state for UI and serial reader let ltc_state = Arc::new(Mutex::new(LtcState::new())); // 4️⃣ Find serial port and spawn the serial reader thread let serial_port_path = match find_serial_port() { Some(port) => port, None => { log::error!("❌ No serial port found. Set HACI_SERIAL_PORT to a device path (e.g., /dev/ttyACM0) or connect the Teensy device."); return; } }; log::info!("Found serial port: {}", serial_port_path); { let tx_clone = tx.clone(); let state_clone = ltc_state.clone(); let port_clone = serial_port_path.clone(); thread::spawn(move || { start_serial_thread( &port_clone, 115200, tx_clone, state_clone, 0, // ignored in serial path ); }); } // 5️⃣ Spawn UI or setup daemon logging. The web service is only started // when running as a daemon. The TUI is for interactive foreground use. if args.command.is_none() { // --- Interactive TUI Mode --- log::info!("🔧 Watching config.yml..."); log::info!("🚀 Serial thread launched"); log::info!("🖥️ UI thread launched"); let ui_state = ltc_state.clone(); let config_clone = config.clone(); let port = serial_port_path; thread::spawn(move || { start_ui(ui_state, port, config_clone); }); } else { // --- Daemon Mode --- // In daemon mode, logging is already set up to go to stderr. // The systemd service will capture it. The web service (API and static files) // is launched later in the main async block. log::info!("🚀 Starting TimeTurner daemon..."); } // 6️⃣ Spawn the auto-sync thread { let sync_state = ltc_state.clone(); let sync_config = config.clone(); thread::spawn(move || { // Wait for the first LTC frame to arrive loop { if sync_state.lock().unwrap().latest.is_some() { log::info!("Auto-sync: Initial LTC frame detected."); break; } thread::sleep(std::time::Duration::from_secs(1)); } // Initial sync { let state = sync_state.lock().unwrap(); let config = sync_config.lock().unwrap(); if config.auto_sync_enabled { if let Some(frame) = &state.latest { log::info!("Auto-sync: Performing initial full sync."); if system::trigger_sync(frame, &config).is_ok() { log::info!("Auto-sync: Initial sync successful."); } else { log::error!("Auto-sync: Initial sync failed."); } } } } thread::sleep(std::time::Duration::from_secs(10)); // Main auto-sync loop loop { { let state = sync_state.lock().unwrap(); let config = sync_config.lock().unwrap(); if config.auto_sync_enabled && state.latest.is_some() { let delta = state.get_ewma_clock_delta(); let frame = state.latest.as_ref().unwrap(); if delta.abs() > 40 { log::info!("Auto-sync: Delta > 40ms ({}ms), performing full sync.", delta); if system::trigger_sync(frame, &config).is_ok() { log::info!("Auto-sync: Full sync successful."); } else { log::error!("Auto-sync: Full sync failed."); } } else if delta.abs() >= 1 { // nudge_clock takes microseconds. A positive delta means clock is // ahead, so we need a negative nudge. let nudge_us = -delta * 1000; log::info!("Auto-sync: Delta is {}ms, nudging clock by {}us.", delta, nudge_us); if system::nudge_clock(nudge_us).is_ok() { log::info!("Auto-sync: Clock nudge successful."); } else { log::error!("Auto-sync: Clock nudge failed."); } } } } // locks released here thread::sleep(std::time::Duration::from_secs(10)); } }); } // 7️⃣ Set up a LocalSet for the API server and main loop let local = LocalSet::new(); local .run_until(async move { // 8️⃣ Spawn the API server task. // This server provides the JSON API and serves the static web UI files // from the `static/` directory. It runs in both TUI and daemon modes, // but is primarily for the web UI used in daemon mode. { let api_state = ltc_state.clone(); let config_clone = config.clone(); let log_buffer_clone = log_buffer.clone(); task::spawn_local(async move { if let Err(e) = start_api_server(api_state, config_clone, log_buffer_clone).await { log::error!("API server error: {}", e); } }); } // 9️⃣ Main logic loop: process frames from serial and update state let loop_state = ltc_state.clone(); let loop_config = config.clone(); let logic_task = task::spawn_blocking(move || { for frame in rx { let mut state = loop_state.lock().unwrap(); let config = loop_config.lock().unwrap(); // Only calculate delta for LOCK frames if frame.status == "LOCK" { let target_time = system::calculate_target_time(&frame, &config); let arrival_time_local: chrono::DateTime = frame.timestamp.with_timezone(&chrono::Local); let delta = arrival_time_local.signed_duration_since(target_time); state.record_and_update_ewma_clock_delta(delta.num_milliseconds()); } state.update(frame); } }); // 1️⃣0️⃣ Keep main thread alive if args.command.is_some() { // In daemon mode, wait forever. The logic_task runs in the background. std::future::pending::<()>().await; } else { // In TUI mode, block until the logic_task finishes (e.g. serial port disconnects) // This keeps the TUI running. log::info!("📡 Main thread entering loop..."); let _ = logic_task.await; } }) .await; } #[cfg(test)] mod tests { use super::*; use std::fs; use std::path::Path; /// RAII guard to manage config file during tests. /// It saves the original content of `config.yml` if it exists, /// and restores it when the guard goes out of scope. /// If the file didn't exist, it's removed. struct ConfigGuard { original_content: Option, } impl ConfigGuard { fn new() -> Self { Self { original_content: fs::read_to_string("config.yml").ok(), } } } impl Drop for ConfigGuard { fn drop(&mut self) { if let Some(content) = &self.original_content { fs::write("config.yml", content).expect("Failed to restore config.yml"); } else { let _ = fs::remove_file("config.yml"); } } } #[test] fn test_ensure_config() { let _guard = ConfigGuard::new(); // Cleanup when _guard goes out of scope. // --- Test 1: File creation --- // Pre-condition: config.yml does not exist. let _ = fs::remove_file("config.yml"); ensure_config(); // Post-condition: config.yml exists and has default content. let p = Path::new("config.yml"); assert!(p.exists(), "config.yml should have been created"); let contents = fs::read_to_string(p).expect("Failed to read created config.yml"); assert_eq!(contents, DEFAULT_CONFIG.trim(), "config.yml content should match default"); // --- Test 2: File is not overwritten --- // Pre-condition: config.yml exists with different content. let custom_content = "hardwareOffsetMs: 999"; fs::write("config.yml", custom_content) .expect("Failed to write custom config.yml for test"); ensure_config(); // Post-condition: config.yml still has the custom content. let contents_after = fs::read_to_string("config.yml") .expect("Failed to read config.yml after second ensure_config call"); assert_eq!(contents_after, custom_content, "config.yml should not be overwritten"); } }