NTP-Timeturner/src/main.rs
John Rogers 68dc16344a fix: preserve comments in config.yml when saving
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-29 14:42:33 +01:00

327 lines
11 KiB
Rust
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 std::{
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<Command>,
}
#[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.
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");
}
}
#[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...");
// 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
}
}
}
// 🔄 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⃣ Spawn the serial reader thread
{
let tx_clone = tx.clone();
let state_clone = ltc_state.clone();
thread::spawn(move || {
start_serial_thread(
"/dev/ttyACM0",
115200,
tx_clone,
state_clone,
0, // ignored in serial path
);
});
}
// 5⃣ Spawn UI or setup daemon logging
if args.command.is_none() {
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 = "/dev/ttyACM0".to_string();
thread::spawn(move || {
start_ui(ui_state, port, config_clone);
});
} else {
// In daemon mode, logging is already set up to go to stderr.
// The systemd service will capture it.
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 thread
{
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<chrono::Local> =
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<String>,
}
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");
}
}