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/docs/api.md b/docs/api.md new file mode 100644 index 0000000..1b76262 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,90 @@ +# NTP Timeturner API + +This document describes the HTTP API for the NTP Timeturner application. + +## Endpoints + +### Status + +- **`GET /api/status`** + + Retrieves the real-time status of the LTC reader and system clock synchronization. + + **Example Response:** + ```json + { + "ltc_status": "LOCK", + "ltc_timecode": "10:20:30:00", + "frame_rate": "25.00fps", + "system_clock": "10:20:30.005", + "timecode_delta_ms": 5, + "timecode_delta_frames": 0, + "sync_status": "IN SYNC", + "jitter_status": "GOOD", + "lock_ratio": 99.5, + "ntp_active": true, + "interfaces": ["192.168.1.100"], + "hardware_offset_ms": 0 + } + ``` + +### Sync + +- **`POST /api/sync`** + + Triggers a manual synchronization of the system clock to the current LTC timecode. This requires the application to have `sudo` privileges to execute the `date` command. + + **Request Body:** None + + **Success Response:** + ```json + { + "status": "success", + "message": "Sync command issued." + } + ``` + + **Error Responses:** + ```json + { + "status": "error", + "message": "No LTC timecode available to sync to." + } + ``` + ```json + { + "status": "error", + "message": "Sync command failed." + } + ``` + +### Configuration + +- **`GET /api/config`** + + Retrieves the current application configuration. + + **Example Response:** + ```json + { + "hardware_offset_ms": 0 + } + ``` + +- **`POST /api/config`** + + Updates the `hardware_offset_ms` configuration. The new value is persisted to `config.json` and reloaded by the application automatically. + + **Example Request:** + ```json + { + "hardware_offset_ms": 10 + } + ``` + + **Success Response:** + ```json + { + "hardware_offset_ms": 10 + } + ``` diff --git a/src/api.rs b/src/api.rs new file mode 100644 index 0000000..66b6c04 --- /dev/null +++ b/src/api.rs @@ -0,0 +1,310 @@ + +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, Deserialize)] +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() + .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: hw_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, Deserialize)] +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: hw_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 +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::sync_logic::LtcFrame; + use actix_web::{test, App}; + use chrono::Utc; + use std::collections::VecDeque; + use std::fs; + + // Helper to create a default LtcState for tests + fn get_test_state() -> LtcState { + LtcState { + latest: Some(LtcFrame { + status: "LOCK".to_string(), + hours: 1, + minutes: 2, + seconds: 3, + frames: 4, + frame_rate: 25.0, + timestamp: Utc::now(), + }), + lock_count: 10, + free_count: 1, + offset_history: VecDeque::from(vec![1, 2, 3]), + clock_delta_history: VecDeque::from(vec![4, 5, 6]), + last_match_status: "IN SYNC".to_string(), + last_match_check: Utc::now().timestamp(), + } + } + + #[actix_web::test] + async fn test_get_status() { + let ltc_state = Arc::new(Mutex::new(get_test_state())); + let hw_offset = Arc::new(Mutex::new(10i64)); + + let app_state = web::Data::new(AppState { + ltc_state: ltc_state.clone(), + hw_offset: hw_offset.clone(), + }); + + let app = test::init_service( + App::new() + .app_data(app_state.clone()) + .service(get_status), + ) + .await; + + let req = test::TestRequest::get().uri("/api/status").to_request(); + let resp: ApiStatus = test::call_and_read_body_json(&app, req).await; + + assert_eq!(resp.ltc_status, "LOCK"); + assert_eq!(resp.ltc_timecode, "01:02:03:04"); + assert_eq!(resp.frame_rate, "25.00fps"); + assert_eq!(resp.hardware_offset_ms, 10); + } + + #[actix_web::test] + async fn test_get_config() { + let ltc_state = Arc::new(Mutex::new(LtcState::new())); + let hw_offset = Arc::new(Mutex::new(25i64)); + + let app_state = web::Data::new(AppState { + ltc_state: ltc_state.clone(), + hw_offset: hw_offset.clone(), + }); + + let app = test::init_service( + App::new() + .app_data(app_state.clone()) + .service(get_config), + ) + .await; + + let req = test::TestRequest::get().uri("/api/config").to_request(); + let resp: ConfigResponse = test::call_and_read_body_json(&app, req).await; + + assert_eq!(resp.hardware_offset_ms, 25); + } + + #[actix_web::test] + async fn test_update_config() { + let ltc_state = Arc::new(Mutex::new(LtcState::new())); + let hw_offset = Arc::new(Mutex::new(0i64)); + let config_path = "config.json"; + + // This test has the side effect of writing to `config.json`. + // We ensure it's cleaned up after. + let _ = fs::remove_file(config_path); + + let app_state = web::Data::new(AppState { + ltc_state: ltc_state.clone(), + hw_offset: hw_offset.clone(), + }); + + let app = test::init_service( + App::new() + .app_data(app_state.clone()) + .service(update_config), + ) + .await; + + let req = test::TestRequest::post() + .uri("/api/config") + .set_json(&serde_json::json!({ "hardware_offset_ms": 55 })) + .to_request(); + + let resp: Config = test::call_and_read_body_json(&app, req).await; + + assert_eq!(resp.hardware_offset_ms, 55); + assert_eq!(*hw_offset.lock().unwrap(), 55); + + // Test that the file was written + assert!(fs::metadata(config_path).is_ok()); + let contents = fs::read_to_string(config_path).unwrap(); + assert!(contents.contains("\"hardware_offset_ms\": 55")); + + // Cleanup + let _ = fs::remove_file(config_path); + } + + #[actix_web::test] + async fn test_manual_sync_no_ltc() { + // State with no LTC frame + let ltc_state = Arc::new(Mutex::new(LtcState::new())); + let hw_offset = Arc::new(Mutex::new(0i64)); + + let app_state = web::Data::new(AppState { + ltc_state: ltc_state.clone(), + hw_offset: hw_offset.clone(), + }); + + let app = test::init_service( + App::new() + .app_data(app_state.clone()) + .service(manual_sync), + ) + .await; + + let req = test::TestRequest::post().uri("/api/sync").to_request(); + let resp = test::call_service(&app, req).await; + + assert_eq!(resp.status(), 400); // Bad Request + } +} 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..f3a2f31 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; @@ -16,9 +18,12 @@ use std::{ sync::{Arc, Mutex, mpsc}, thread, }; +use tokio::task::{self, LocalSet}; -/// Embed the default config.json at compile time. -const DEFAULT_CONFIG: &str = include_str!("../config.json"); +/// Default config content, embedded in the binary. +const DEFAULT_CONFIG: &str = r#"{ + "hardware_offset_ms": 20 +}"#; /// If no `config.json` exists alongside the binary, write out the default. fn ensure_config() { @@ -30,7 +35,8 @@ fn ensure_config() { } } -fn main() { +#[tokio::main(flavor = "current_thread")] +async fn main() { // 🔄 Ensure there's always a config.json present ensure_config(); @@ -73,11 +79,32 @@ fn main() { }); } - // 6️⃣ Keep main thread alive - println!("📡 Main thread entering loop..."); - for _frame in rx { - // no-op - } + // 6️⃣ Set up a LocalSet for the API server. + let local = LocalSet::new(); + local + .run_until(async move { + // 7️⃣ Spawn the API server thread + { + let api_state = ltc_state.clone(); + let offset_clone = hw_offset.clone(); + task::spawn_local(async move { + if let Err(e) = start_api_server(api_state, offset_clone).await { + eprintln!("API server error: {}", e); + } + }); + } + + // 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; + }) + .await; } #[cfg(test)] diff --git a/src/ui.rs b/src/ui.rs index 24099fb..3dbcbb8 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,59 @@ 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"); + #[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>, serial_port: String, @@ -151,31 +204,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 +337,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);