diff --git a/docs/api.md b/docs/api.md index 2959c95..83471bb 100644 --- a/docs/api.md +++ b/docs/api.md @@ -8,13 +8,13 @@ This document describes the HTTP API for the NTP Timeturner application. - **`GET /api/status`** - Retrieves the real-time status of the LTC reader and system clock synchronization. + Retrieves the real-time status of the LTC reader and system clock synchronization. The `ltc_timecode` field uses `:` as a separator for non-drop-frame timecode, and `;` for drop-frame timecode between seconds and frames (e.g., `10:20:30;00`). **Example Response:** ```json { "ltc_status": "LOCK", - "ltc_timecode": "10:20:30:00", + "ltc_timecode": "10:20:30;00", "frame_rate": "25.00fps", "system_clock": "10:20:30.005", "system_date": "2025-07-30", diff --git a/src/api.rs b/src/api.rs index a622a0b..14b0da4 100644 --- a/src/api.rs +++ b/src/api.rs @@ -47,7 +47,11 @@ async fn get_status(data: web::Data) -> impl Responder { let ltc_status = state.latest.as_ref().map_or("(waiting)".to_string(), |f| f.status.clone()); let ltc_timecode = state.latest.as_ref().map_or("…".to_string(), |f| { - format!("{:02}:{:02}:{:02}:{:02}", f.hours, f.minutes, f.seconds, f.frames) + let sep = if f.is_drop_frame { ';' } else { ':' }; + format!( + "{:02}:{:02}:{:02}{}{:02}", + f.hours, f.minutes, f.seconds, sep, f.frames + ) }); let frame_rate = state.latest.as_ref().map_or("…".to_string(), |f| { format!("{:.2}fps", f.frame_rate.to_f64().unwrap_or(0.0)) @@ -242,6 +246,7 @@ mod tests { minutes: 2, seconds: 3, frames: 4, + is_drop_frame: false, frame_rate: Ratio::new(25, 1), timestamp: Utc::now(), }), @@ -290,6 +295,32 @@ mod tests { assert_eq!(resp.hardware_offset_ms, 10); } + #[actix_web::test] + async fn test_get_status_drop_frame() { + let app_state = get_test_app_state(); + // Set state to drop frame + app_state + .ltc_state + .lock() + .unwrap() + .latest + .as_mut() + .unwrap() + .is_drop_frame = true; + + let app = test::init_service( + App::new() + .app_data(app_state.clone()) + .service(get_status), + ) + .await; + + let req = test::TestRequest::get().uri("/api/status").to_request(); + let resp: ApiStatus = test::call_and_read_body_json(&app, req).await; + + assert_eq!(resp.ltc_timecode, "01:02:03;04"); + } + #[actix_web::test] async fn test_get_config() { let app_state = get_test_app_state(); diff --git a/src/config.rs b/src/config.rs index 974d60b..8669e62 100644 --- a/src/config.rs +++ b/src/config.rs @@ -64,6 +64,7 @@ impl Config { Self::default() }) } + } impl Default for Config { diff --git a/src/main.rs b/src/main.rs index e265210..ab9fa94 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,6 +15,7 @@ use crate::sync_logic::LtcState; use crate::ui::start_ui; use clap::Parser; use daemonize::Daemonize; +use serialport; use std::{ fs, @@ -35,6 +36,8 @@ struct Args { 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. @@ -70,30 +73,85 @@ fn ensure_config() { } } +fn find_serial_port() -> Option { + 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::Daemon) = &args.command { - log::info!("🚀 Starting daemon..."); + 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"); + // 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); + 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 + 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; } } } @@ -110,13 +168,23 @@ async fn main() { // 3️⃣ Shared state for UI and serial reader let ltc_state = Arc::new(Mutex::new(LtcState::new())); - // 4️⃣ Spawn the serial reader thread + // 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. Please 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( - "/dev/ttyACM0", + &port_clone, 115200, tx_clone, state_clone, @@ -132,7 +200,7 @@ async fn main() { log::info!("🖥️ UI thread launched"); let ui_state = ltc_state.clone(); let config_clone = config.clone(); - let port = "/dev/ttyACM0".to_string(); + let port = serial_port_path; thread::spawn(move || { start_ui(ui_state, port, config_clone); }); diff --git a/src/serial_input.rs b/src/serial_input.rs index b65cd5f..d1dea36 100644 --- a/src/serial_input.rs +++ b/src/serial_input.rs @@ -32,7 +32,7 @@ pub fn start_serial_thread( let reader = std::io::BufReader::new(port); let re = Regex::new( - r"\[(LOCK|FREE)\]\s+(\d{2}):(\d{2}):(\d{2})[:;](\d{2})\s+\|\s+([\d.]+)fps", + r"\[(LOCK|FREE)\]\s+(\d{2}):(\d{2}):(\d{2})([:;])(\d{2})\s+\|\s+([\d.]+)fps", ) .unwrap(); @@ -65,7 +65,7 @@ mod tests { fn get_ltc_regex() -> Regex { Regex::new( - r"\[(LOCK|FREE)\]\s+(\d{2}):(\d{2}):(\d{2})[:;](\d{2})\s+\|\s+([\d.]+)fps", + r"\[(LOCK|FREE)\]\s+(\d{2}):(\d{2}):(\d{2})([:;])(\d{2})\s+\|\s+([\d.]+)fps", ).unwrap() } diff --git a/src/sync_logic.rs b/src/sync_logic.rs index 630e879..c6a3e80 100644 --- a/src/sync_logic.rs +++ b/src/sync_logic.rs @@ -24,6 +24,7 @@ pub struct LtcFrame { pub minutes: u32, pub seconds: u32, pub frames: u32, + pub is_drop_frame: bool, pub frame_rate: Ratio, pub timestamp: DateTime, // arrival stamp } @@ -35,8 +36,9 @@ impl LtcFrame { hours: caps[2].parse().ok()?, minutes: caps[3].parse().ok()?, seconds: caps[4].parse().ok()?, - frames: caps[5].parse().ok()?, - frame_rate: get_frame_rate_ratio(&caps[6])?, + is_drop_frame: &caps[5] == ";", + frames: caps[6].parse().ok()?, + frame_rate: get_frame_rate_ratio(&caps[7])?, timestamp, }) } @@ -205,6 +207,7 @@ mod tests { minutes: m, seconds: s, frames: 0, + is_drop_frame: false, frame_rate: Ratio::new(25, 1), timestamp: Utc::now(), } diff --git a/src/system.rs b/src/system.rs index 7d089e6..8db481d 100644 --- a/src/system.rs +++ b/src/system.rs @@ -45,21 +45,13 @@ pub fn calculate_target_time(frame: &LtcFrame, config: &Config) -> DateTime Result<(), ()> { pub fn set_date(date: &str) -> Result<(), ()> { #[cfg(target_os = "linux")] { + let datetime_str = format!("{} 10:00:00", date); let success = Command::new("sudo") .arg("date") .arg("--set") - .arg(date) + .arg(&datetime_str) .status() .map(|s| s.success()) .unwrap_or(false); if success { - log::info!("Set system date to {}", date); + log::info!("Set system date and time to {}", datetime_str); Ok(()) } else { - log::error!("Failed to set system date"); + log::error!("Failed to set system date and time"); Err(()) } } @@ -196,6 +189,7 @@ mod tests { minutes: m, seconds: s, frames: f, + is_drop_frame: false, frame_rate: Ratio::new(25, 1), timestamp: Utc::now(), } diff --git a/static/assets/FuturaStdHeavy.otf b/static/assets/FuturaStdHeavy.otf new file mode 100644 index 0000000..7b8c22d Binary files /dev/null and b/static/assets/FuturaStdHeavy.otf differ diff --git a/static/assets/quartz-ms-regular.ttf b/static/assets/quartz-ms-regular.ttf new file mode 100644 index 0000000..15c7ce4 Binary files /dev/null and b/static/assets/quartz-ms-regular.ttf differ diff --git a/static/assets/timeturner_controls.png b/static/assets/timeturner_controls.png new file mode 100644 index 0000000..a91f39b Binary files /dev/null and b/static/assets/timeturner_controls.png differ diff --git a/static/assets/timeturner_delta_green.png b/static/assets/timeturner_delta_green.png new file mode 100644 index 0000000..ddc84b9 Binary files /dev/null and b/static/assets/timeturner_delta_green.png differ diff --git a/static/assets/timeturner_delta_orange.png b/static/assets/timeturner_delta_orange.png new file mode 100644 index 0000000..64e9776 Binary files /dev/null and b/static/assets/timeturner_delta_orange.png differ diff --git a/static/assets/timeturner_delta_red.png b/static/assets/timeturner_delta_red.png new file mode 100644 index 0000000..c7272ac Binary files /dev/null and b/static/assets/timeturner_delta_red.png differ diff --git a/static/assets/timeturner_jitter_green.png b/static/assets/timeturner_jitter_green.png new file mode 100644 index 0000000..8cc64e3 Binary files /dev/null and b/static/assets/timeturner_jitter_green.png differ diff --git a/static/assets/timeturner_jitter_orange.png b/static/assets/timeturner_jitter_orange.png new file mode 100644 index 0000000..96c5f84 Binary files /dev/null and b/static/assets/timeturner_jitter_orange.png differ diff --git a/static/assets/timeturner_jitter_red.png b/static/assets/timeturner_jitter_red.png new file mode 100644 index 0000000..8813159 Binary files /dev/null and b/static/assets/timeturner_jitter_red.png differ diff --git a/static/assets/timeturner_logs.png b/static/assets/timeturner_logs.png new file mode 100644 index 0000000..6bdd935 Binary files /dev/null and b/static/assets/timeturner_logs.png differ diff --git a/static/assets/timeturner_ltc_green.png b/static/assets/timeturner_ltc_green.png new file mode 100644 index 0000000..4329913 Binary files /dev/null and b/static/assets/timeturner_ltc_green.png differ diff --git a/static/assets/timeturner_ltc_orange.png b/static/assets/timeturner_ltc_orange.png new file mode 100644 index 0000000..b060ac2 Binary files /dev/null and b/static/assets/timeturner_ltc_orange.png differ diff --git a/static/assets/timeturner_ltc_red.png b/static/assets/timeturner_ltc_red.png new file mode 100644 index 0000000..a8e7f96 Binary files /dev/null and b/static/assets/timeturner_ltc_red.png differ diff --git a/static/assets/timeturner_network.png b/static/assets/timeturner_network.png new file mode 100644 index 0000000..06ec4b9 Binary files /dev/null and b/static/assets/timeturner_network.png differ diff --git a/static/assets/timeturner_ntp_green.png b/static/assets/timeturner_ntp_green.png new file mode 100644 index 0000000..caf824d Binary files /dev/null and b/static/assets/timeturner_ntp_green.png differ diff --git a/static/assets/timeturner_ntp_orange.png b/static/assets/timeturner_ntp_orange.png new file mode 100644 index 0000000..88319b5 Binary files /dev/null and b/static/assets/timeturner_ntp_orange.png differ diff --git a/static/assets/timeturner_ntp_red.png b/static/assets/timeturner_ntp_red.png new file mode 100644 index 0000000..16e66ee Binary files /dev/null and b/static/assets/timeturner_ntp_red.png differ diff --git a/static/assets/timeturner_sync_green.png b/static/assets/timeturner_sync_green.png new file mode 100644 index 0000000..9b4988e Binary files /dev/null and b/static/assets/timeturner_sync_green.png differ diff --git a/static/assets/timeturner_sync_orange.png b/static/assets/timeturner_sync_orange.png new file mode 100644 index 0000000..0b41130 Binary files /dev/null and b/static/assets/timeturner_sync_orange.png differ diff --git a/static/assets/timeturner_sync_red.png b/static/assets/timeturner_sync_red.png new file mode 100644 index 0000000..1c4c4c9 Binary files /dev/null and b/static/assets/timeturner_sync_red.png differ diff --git a/static/script.js b/static/script.js index 2094cc4..6fd3475 100644 --- a/static/script.js +++ b/static/script.js @@ -98,9 +98,11 @@ document.addEventListener('DOMContentLoaded', () => { } // Animate LTC Timecode - only if status is LOCK - if (lastApiData.ltc_status === 'LOCK' && lastApiData.ltc_timecode && lastApiData.ltc_timecode.includes(':') && lastApiData.frame_rate) { - const tcParts = lastApiData.ltc_timecode.split(':'); + if (lastApiData.ltc_status === 'LOCK' && lastApiData.ltc_timecode && lastApiData.ltc_timecode.match(/[:;]/) && lastApiData.frame_rate) { + const separator = lastApiData.ltc_timecode.includes(';') ? ';' : ':'; + const tcParts = lastApiData.ltc_timecode.split(/[:;]/); const frameRate = parseFloat(lastApiData.frame_rate); + if (tcParts.length === 4 && !isNaN(frameRate) && frameRate > 0) { let h = parseInt(tcParts[0], 10); let m = parseInt(tcParts[1], 10); @@ -126,7 +128,7 @@ document.addEventListener('DOMContentLoaded', () => { h %= 24; statusElements.ltcTimecode.textContent = - `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}:${String(f).padStart(2, '0')}`; + `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}${separator}${String(f).padStart(2, '0')}`; } } }