diff --git a/Cargo.toml b/Cargo.toml index 6127547..f50e457 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,4 +12,6 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0.141" notify = "8.1.0" get_if_addrs = "0.5" +actix-web = "4" +tokio = { version = "1", features = ["full"] } diff --git a/src/api.rs b/src/api.rs new file mode 100644 index 0000000..e1c641a --- /dev/null +++ b/src/api.rs @@ -0,0 +1,165 @@ + +use actix_web::{get, post, web, App, HttpResponse, HttpServer, Responder}; +use chrono::{Local, Timelike}; +use get_if_addrs::get_if_addrs; +use serde::{Deserialize, Serialize}; +use serde_json; +use std::sync::{Arc, Mutex}; + +use crate::config::{self, Config}; +use crate::sync_logic::LtcState; +use crate::ui; + +// Data structure for the main status response +#[derive(Serialize)] +struct ApiStatus { + ltc_status: String, + ltc_timecode: String, + frame_rate: String, + system_clock: String, + timecode_delta_ms: i64, + timecode_delta_frames: i64, + sync_status: String, + jitter_status: String, + lock_ratio: f64, + ntp_active: bool, + interfaces: Vec, + hardware_offset_ms: i64, +} + +// AppState to hold shared data +pub struct AppState { + pub ltc_state: Arc>, + pub hw_offset: Arc>, +} + +#[get("/api/status")] +async fn get_status(data: web::Data) -> impl Responder { + let state = data.ltc_state.lock().unwrap(); + let hw_offset_ms = *data.hw_offset.lock().unwrap(); + + 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 frame_rate = state.latest.as_ref().map_or("…".to_string(), |f| { + format!("{:.2}fps", f.frame_rate) + }); + + let now_local = Local::now(); + let system_clock = format!( + "{:02}:{:02}:{:02}.{:03}", + now_local.hour(), + now_local.minute(), + now_local.second(), + now_local.timestamp_subsec_millis(), + ); + + let avg_delta = state.average_clock_delta(); + let mut delta_frames = 0; + if let Some(frame) = &state.latest { + let frame_ms = 1000.0 / frame.frame_rate; + delta_frames = ((avg_delta as f64 / frame_ms).round()) as i64; + } + + let sync_status = ui::get_sync_status(avg_delta).to_string(); + let jitter_status = ui::get_jitter_status(state.average_jitter()).to_string(); + let lock_ratio = state.lock_ratio(); + + let ntp_active = ui::ntp_service_active(); + let interfaces = get_if_addrs::get_if_addrs() + .unwrap_or_default() + .into_iter() + .filter(|ifa| !ifa.is_loopback()) + .map(|ifa| ifa.ip().to_string()) + .collect(); + + HttpResponse::Ok().json(ApiStatus { + ltc_status, + ltc_timecode, + frame_rate, + system_clock, + timecode_delta_ms: avg_delta, + timecode_delta_frames: delta_frames, + sync_status, + jitter_status, + lock_ratio, + ntp_active, + interfaces, + hardware_offset_ms, + }) +} + +#[post("/api/sync")] +async fn manual_sync(data: web::Data) -> impl Responder { + let state = data.ltc_state.lock().unwrap(); + if let Some(frame) = &state.latest { + if ui::trigger_sync(frame).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." })) + } + } else { + HttpResponse::BadRequest().json(serde_json::json!({ "status": "error", "message": "No LTC timecode available to sync to." })) + } +} + +#[derive(Serialize)] +struct ConfigResponse { + hardware_offset_ms: i64, +} + +#[get("/api/config")] +async fn get_config(data: web::Data) -> impl Responder { + let hw_offset_ms = *data.hw_offset.lock().unwrap(); + HttpResponse::Ok().json(ConfigResponse { hardware_offset_ms }) +} + +#[derive(Deserialize)] +struct UpdateConfigRequest { + hardware_offset_ms: i64, +} + +#[post("/api/config")] +async fn update_config( + data: web::Data, + req: web::Json, +) -> impl Responder { + let mut hw_offset = data.hw_offset.lock().unwrap(); + *hw_offset = req.hardware_offset_ms; + + let new_config = Config { + hardware_offset_ms: *hw_offset, + }; + + if config::save_config("config.json", &new_config).is_ok() { + eprintln!("🔄 Saved hardware_offset_ms = {} via API", *hw_offset); + HttpResponse::Ok().json(&new_config) + } else { + HttpResponse::InternalServerError().json(serde_json::json!({ "status": "error", "message": "Failed to write config.json" })) + } +} + +pub async fn start_api_server( + state: Arc>, + offset: Arc>, +) -> std::io::Result<()> { + let app_state = web::Data::new(AppState { + ltc_state: state, + hw_offset: offset, + }); + + println!("🚀 Starting API server at http://0.0.0.0:8080"); + + HttpServer::new(move || { + App::new() + .app_data(app_state.clone()) + .service(get_status) + .service(manual_sync) + .service(get_config) + .service(update_config) + }) + .bind("0.0.0.0:8080")? + .run() + .await +} diff --git a/src/config.rs b/src/config.rs index a9bf931..6c6b639 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,69 +1,75 @@ -// src/config.rs - -use notify::{ - recommended_watcher, Event, EventKind, RecommendedWatcher, RecursiveMode, Result as NotifyResult, - Watcher, -}; -use serde::Deserialize; -use std::{ - fs::File, - io::Read, - path::PathBuf, - sync::{Arc, Mutex}, -}; - -#[derive(Deserialize)] -pub struct Config { - pub hardware_offset_ms: i64, -} - -impl Config { - pub fn load(path: &PathBuf) -> Self { - let mut file = match File::open(path) { - Ok(f) => f, - Err(_) => return Self { hardware_offset_ms: 0 }, - }; - let mut contents = String::new(); - if file.read_to_string(&mut contents).is_err() { - return Self { hardware_offset_ms: 0 }; - } - serde_json::from_str(&contents).unwrap_or(Self { hardware_offset_ms: 0 }) - } -} - -pub fn watch_config(path: &str) -> Arc> { - let initial = Config::load(&PathBuf::from(path)).hardware_offset_ms; - let offset = Arc::new(Mutex::new(initial)); - - // Owned PathBuf for watch() call - let watch_path = PathBuf::from(path); - // Clone for moving into the closure - let watch_path_for_cb = watch_path.clone(); - let offset_for_cb = Arc::clone(&offset); - - std::thread::spawn(move || { - // Move `watch_path_for_cb` into the callback - let mut watcher: RecommendedWatcher = recommended_watcher(move |res: NotifyResult| { - if let Ok(evt) = res { - if matches!(evt.kind, EventKind::Modify(_)) { - let new_cfg = Config::load(&watch_path_for_cb); - let mut hw = offset_for_cb.lock().unwrap(); - *hw = new_cfg.hardware_offset_ms; - eprintln!("🔄 Reloaded hardware_offset_ms = {}", *hw); - } - } - }) - .expect("Failed to create file watcher"); - - // Use the original `watch_path` here - watcher - .watch(&watch_path, RecursiveMode::NonRecursive) - .expect("Failed to watch config.json"); - - loop { - std::thread::sleep(std::time::Duration::from_secs(60)); - } - }); - - offset -} +// src/config.rs + +use notify::{ + recommended_watcher, Event, EventKind, RecommendedWatcher, RecursiveMode, Result as NotifyResult, + Watcher, +}; +use serde::{Deserialize, Serialize}; +use std::{ + fs, + fs::File, + io::Read, + path::PathBuf, + sync::{Arc, Mutex}, +}; + +#[derive(Deserialize, Serialize, Clone)] +pub struct Config { + pub hardware_offset_ms: i64, +} + +impl Config { + pub fn load(path: &PathBuf) -> Self { + let mut file = match File::open(path) { + Ok(f) => f, + Err(_) => return Self { hardware_offset_ms: 0 }, + }; + let mut contents = String::new(); + if file.read_to_string(&mut contents).is_err() { + return Self { hardware_offset_ms: 0 }; + } + serde_json::from_str(&contents).unwrap_or(Self { hardware_offset_ms: 0 }) + } +} + +pub fn save_config(path: &str, config: &Config) -> std::io::Result<()> { + let contents = serde_json::to_string_pretty(config)?; + fs::write(path, contents) +} + +pub fn watch_config(path: &str) -> Arc> { + let initial = Config::load(&PathBuf::from(path)).hardware_offset_ms; + let offset = Arc::new(Mutex::new(initial)); + + // Owned PathBuf for watch() call + let watch_path = PathBuf::from(path); + // Clone for moving into the closure + let watch_path_for_cb = watch_path.clone(); + let offset_for_cb = Arc::clone(&offset); + + std::thread::spawn(move || { + // Move `watch_path_for_cb` into the callback + let mut watcher: RecommendedWatcher = recommended_watcher(move |res: NotifyResult| { + if let Ok(evt) = res { + if matches!(evt.kind, EventKind::Modify(_)) { + let new_cfg = Config::load(&watch_path_for_cb); + let mut hw = offset_for_cb.lock().unwrap(); + *hw = new_cfg.hardware_offset_ms; + eprintln!("🔄 Reloaded hardware_offset_ms = {}", *hw); + } + } + }) + .expect("Failed to create file watcher"); + + // Use the original `watch_path` here + watcher + .watch(&watch_path, RecursiveMode::NonRecursive) + .expect("Failed to watch config.json"); + + loop { + std::thread::sleep(std::time::Duration::from_secs(60)); + } + }); + + offset +} diff --git a/src/main.rs b/src/main.rs index 42d1706..1b9d9b4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,10 +1,12 @@ // src/main.rs +mod api; mod config; mod sync_logic; mod serial_input; 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; @@ -30,7 +32,8 @@ fn ensure_config() { } } -fn main() { +#[tokio::main] +async fn main() { // 🔄 Ensure there's always a config.json present ensure_config(); @@ -73,7 +76,18 @@ fn main() { }); } - // 6️⃣ Keep main thread alive + // 6️⃣ Spawn the API server thread + { + let api_state = ltc_state.clone(); + let offset_clone = hw_offset.clone(); + tokio::spawn(async move { + if let Err(e) = start_api_server(api_state, offset_clone).await { + eprintln!("API server error: {}", e); + } + }); + } + + // 7️⃣ Keep main thread alive by processing LTC frames println!("📡 Main thread entering loop..."); for _frame in rx { // no-op diff --git a/src/ui.rs b/src/ui.rs index 24099fb..b174739 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -20,10 +20,10 @@ use crossterm::{ }; use get_if_addrs::get_if_addrs; -use crate::sync_logic::LtcState; +use crate::sync_logic::{LtcFrame, LtcState}; /// Check if Chrony is active -fn ntp_service_active() -> bool { +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" @@ -39,7 +39,7 @@ fn ntp_service_toggle(start: bool) { let _ = Command::new("systemctl").args(&[action, "chrony"]).status(); } -fn get_sync_status(delta_ms: i64) -> &'static str { +pub fn get_sync_status(delta_ms: i64) -> &'static str { if delta_ms.abs() <= 8 { "IN SYNC" } else if delta_ms > 10 { @@ -49,7 +49,7 @@ fn get_sync_status(delta_ms: i64) -> &'static str { } } -fn get_jitter_status(jitter_ms: i64) -> &'static str { +pub fn get_jitter_status(jitter_ms: i64) -> &'static str { if jitter_ms.abs() < 10 { "GOOD" } else if jitter_ms.abs() < 40 { @@ -59,6 +59,35 @@ fn get_jitter_status(jitter_ms: i64) -> &'static str { } } +pub fn trigger_sync(frame: &LtcFrame) -> Result { + 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 dt_local = Local + .from_local_datetime(&naive_dt) + .single() + .expect("Ambiguous or invalid local time"); + 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); + + if success { + Ok(ts) + } else { + Err(()) + } +} + pub fn start_ui( state: Arc>, serial_port: String, @@ -151,31 +180,9 @@ 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 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 dt_local = Local - .from_local_datetime(&naive_dt) - .single() - .expect("Ambiguous or invalid local time"); - 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); - - let entry = if success { - format!("🔄 Auto‑synced to LTC: {}", ts) - } else { - "❌ Auto‑sync failed".into() + let entry = match trigger_sync(frame) { + Ok(ts) => format!("🔄 Auto‑synced to LTC: {}", ts), + Err(_) => "❌ Auto‑sync failed".into(), }; if logs.len() == 10 { logs.pop_front(); } logs.push_back(entry); @@ -306,31 +313,9 @@ pub fn start_ui( } KeyCode::Char(c) if c.eq_ignore_ascii_case(&'s') => { if let Some(frame) = &state.lock().unwrap().latest { - 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 dt_local = Local - .from_local_datetime(&naive_dt) - .single() - .expect("Ambiguous or invalid local time"); - 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); - - let entry = if success { - format!("✔ Synced exactly to LTC: {}", ts) - } else { - "❌ date cmd failed".into() + let entry = match trigger_sync(frame) { + Ok(ts) => format!("✔ Synced exactly to LTC: {}", ts), + Err(_) => "❌ date cmd failed".into(), }; if logs.len() == 10 { logs.pop_front(); } logs.push_back(entry);