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:
Chaos Rogers 2025-07-21 19:42:46 +01:00
parent d55a11b074
commit 1d9bc1e25e
7 changed files with 244 additions and 281 deletions

View file

@ -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." }))

View file

@ -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;
}

View file

@ -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
View 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
View file

@ -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!("🔄 Autosynced to LTC: {}", ts),
Err(_) => "❌ Autosync 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");
}
}