mirror of
https://github.com/cjfranko/NTP-Timeturner.git
synced 2025-11-08 18:32:02 +00:00
feat: add daemon mode and systemd service
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
This commit is contained in:
parent
d55a11b074
commit
1d9bc1e25e
7 changed files with 244 additions and 281 deletions
16
src/api.rs
16
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)]
|
||||
|
|
@ -20,8 +20,8 @@ struct ApiStatus {
|
|||
system_clock: String,
|
||||
timecode_delta_ms: i64,
|
||||
timecode_delta_frames: i64,
|
||||
sync_status: String,
|
||||
jitter_status: String,
|
||||
sync_status: &'static str,
|
||||
jitter_status: &'static str,
|
||||
lock_ratio: f64,
|
||||
ntp_active: bool,
|
||||
interfaces: Vec<String>,
|
||||
|
|
@ -64,11 +64,11 @@ async fn get_status(data: web::Data<AppState>) -> 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()
|
||||
|
|
@ -97,7 +97,7 @@ async fn manual_sync(data: web::Data<AppState>) -> 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." }))
|
||||
|
|
|
|||
75
src/main.rs
75
src/main.rs
|
|
@ -2,24 +2,39 @@
|
|||
|
||||
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 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<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.
|
||||
|
|
@ -46,27 +61,25 @@ fn ensure_config() {
|
|||
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn main() {
|
||||
// 🔄 Ensure there's always a config.json present
|
||||
let args = Args::parse();
|
||||
|
||||
// 🔄 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 +90,25 @@ 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 {
|
||||
println!("🚀 Starting TimeTurner daemon...");
|
||||
systemd_journal_logger::init().unwrap();
|
||||
log::set_max_level(log::LevelFilter::Info);
|
||||
log::info!("TimeTurner daemon started. API server is running.");
|
||||
}
|
||||
|
||||
// 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 +123,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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -170,10 +170,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 +355,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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
83
src/system.rs
Normal file
83
src/system.rs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
use crate::config::Config;
|
||||
use crate::sync_logic::LtcFrame;
|
||||
use chrono::{Duration as ChronoDuration, Local, NaiveTime, TimeZone};
|
||||
use std::process::Command;
|
||||
|
||||
/// 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)]
|
||||
pub fn ntp_service_toggle(start: bool) {
|
||||
let action = if start { "start" } else { "stop" };
|
||||
let _ = Command::new("systemctl").args(&[action, "chrony"]).status();
|
||||
}
|
||||
|
||||
pub fn trigger_sync(frame: &LtcFrame, config: &Config) -> Result<String, ()> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
139
src/ui.rs
139
src/ui.rs
|
|
@ -23,106 +23,6 @@ use crate::config::Config;
|
|||
use get_if_addrs::get_if_addrs;
|
||||
use crate::sync_logic::{LtcFrame, LtcState};
|
||||
|
||||
/// 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<String, ()> {
|
||||
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<Mutex<LtcState>>,
|
||||
|
|
@ -145,7 +45,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<String> = get_if_addrs()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
|
|
@ -226,7 +126,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 +261,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 +280,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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue