mirror of
https://github.com/cjfranko/NTP-Timeturner.git
synced 2025-11-08 18:32:02 +00:00
395 lines
14 KiB
Rust
395 lines
14 KiB
Rust
// 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::{
|
||
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,
|
||
/// 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<String> {
|
||
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. 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(
|
||
&port_clone,
|
||
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 = serial_port_path;
|
||
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");
|
||
}
|
||
}
|