Merge pull request #9 from cjfranko/api

Api
This commit is contained in:
Chaos Rogers 2025-07-21 18:53:28 +01:00 committed by GitHub
commit 666ce4308f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 575 additions and 131 deletions

View file

@ -12,4 +12,6 @@ serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0.141" serde_json = "1.0.141"
notify = "8.1.0" notify = "8.1.0"
get_if_addrs = "0.5" get_if_addrs = "0.5"
actix-web = "4"
tokio = { version = "1", features = ["full"] }

90
docs/api.md Normal file
View file

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

310
src/api.rs Normal file
View file

@ -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<String>,
hardware_offset_ms: i64,
}
// AppState to hold shared data
pub struct AppState {
pub ltc_state: Arc<Mutex<LtcState>>,
pub hw_offset: Arc<Mutex<i64>>,
}
#[get("/api/status")]
async fn get_status(data: web::Data<AppState>) -> 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<AppState>) -> 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<AppState>) -> 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<AppState>,
req: web::Json<UpdateConfigRequest>,
) -> 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<Mutex<LtcState>>,
offset: Arc<Mutex<i64>>,
) -> 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
}
}

View file

@ -1,69 +1,75 @@
// src/config.rs // src/config.rs
use notify::{ use notify::{
recommended_watcher, Event, EventKind, RecommendedWatcher, RecursiveMode, Result as NotifyResult, recommended_watcher, Event, EventKind, RecommendedWatcher, RecursiveMode, Result as NotifyResult,
Watcher, Watcher,
}; };
use serde::Deserialize; use serde::{Deserialize, Serialize};
use std::{ use std::{
fs::File, fs,
io::Read, fs::File,
path::PathBuf, io::Read,
sync::{Arc, Mutex}, path::PathBuf,
}; sync::{Arc, Mutex},
};
#[derive(Deserialize)]
pub struct Config { #[derive(Deserialize, Serialize, Clone)]
pub hardware_offset_ms: i64, pub struct Config {
} pub hardware_offset_ms: i64,
}
impl Config {
pub fn load(path: &PathBuf) -> Self { impl Config {
let mut file = match File::open(path) { pub fn load(path: &PathBuf) -> Self {
Ok(f) => f, let mut file = match File::open(path) {
Err(_) => return Self { hardware_offset_ms: 0 }, Ok(f) => f,
}; Err(_) => return Self { hardware_offset_ms: 0 },
let mut contents = String::new(); };
if file.read_to_string(&mut contents).is_err() { let mut contents = String::new();
return Self { hardware_offset_ms: 0 }; 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 }) }
} serde_json::from_str(&contents).unwrap_or(Self { hardware_offset_ms: 0 })
} }
}
pub fn watch_config(path: &str) -> Arc<Mutex<i64>> {
let initial = Config::load(&PathBuf::from(path)).hardware_offset_ms; pub fn save_config(path: &str, config: &Config) -> std::io::Result<()> {
let offset = Arc::new(Mutex::new(initial)); let contents = serde_json::to_string_pretty(config)?;
fs::write(path, contents)
// Owned PathBuf for watch() call }
let watch_path = PathBuf::from(path);
// Clone for moving into the closure pub fn watch_config(path: &str) -> Arc<Mutex<i64>> {
let watch_path_for_cb = watch_path.clone(); let initial = Config::load(&PathBuf::from(path)).hardware_offset_ms;
let offset_for_cb = Arc::clone(&offset); let offset = Arc::new(Mutex::new(initial));
std::thread::spawn(move || { // Owned PathBuf for watch() call
// Move `watch_path_for_cb` into the callback let watch_path = PathBuf::from(path);
let mut watcher: RecommendedWatcher = recommended_watcher(move |res: NotifyResult<Event>| { // Clone for moving into the closure
if let Ok(evt) = res { let watch_path_for_cb = watch_path.clone();
if matches!(evt.kind, EventKind::Modify(_)) { let offset_for_cb = Arc::clone(&offset);
let new_cfg = Config::load(&watch_path_for_cb);
let mut hw = offset_for_cb.lock().unwrap(); std::thread::spawn(move || {
*hw = new_cfg.hardware_offset_ms; // Move `watch_path_for_cb` into the callback
eprintln!("🔄 Reloaded hardware_offset_ms = {}", *hw); let mut watcher: RecommendedWatcher = recommended_watcher(move |res: NotifyResult<Event>| {
} if let Ok(evt) = res {
} if matches!(evt.kind, EventKind::Modify(_)) {
}) let new_cfg = Config::load(&watch_path_for_cb);
.expect("Failed to create file watcher"); let mut hw = offset_for_cb.lock().unwrap();
*hw = new_cfg.hardware_offset_ms;
// Use the original `watch_path` here eprintln!("🔄 Reloaded hardware_offset_ms = {}", *hw);
watcher }
.watch(&watch_path, RecursiveMode::NonRecursive) }
.expect("Failed to watch config.json"); })
.expect("Failed to create file watcher");
loop {
std::thread::sleep(std::time::Duration::from_secs(60)); // Use the original `watch_path` here
} watcher
}); .watch(&watch_path, RecursiveMode::NonRecursive)
.expect("Failed to watch config.json");
offset
} loop {
std::thread::sleep(std::time::Duration::from_secs(60));
}
});
offset
}

View file

@ -1,10 +1,12 @@
// src/main.rs // src/main.rs
mod api;
mod config; mod config;
mod sync_logic; mod sync_logic;
mod serial_input; mod serial_input;
mod ui; mod ui;
use crate::api::start_api_server;
use crate::config::watch_config; use crate::config::watch_config;
use crate::sync_logic::LtcState; use crate::sync_logic::LtcState;
use crate::serial_input::start_serial_thread; use crate::serial_input::start_serial_thread;
@ -16,9 +18,12 @@ use std::{
sync::{Arc, Mutex, mpsc}, sync::{Arc, Mutex, mpsc},
thread, thread,
}; };
use tokio::task::{self, LocalSet};
/// Embed the default config.json at compile time. /// Default config content, embedded in the binary.
const DEFAULT_CONFIG: &str = include_str!("../config.json"); const DEFAULT_CONFIG: &str = r#"{
"hardware_offset_ms": 20
}"#;
/// If no `config.json` exists alongside the binary, write out the default. /// If no `config.json` exists alongside the binary, write out the default.
fn ensure_config() { 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 there's always a config.json present
ensure_config(); ensure_config();
@ -73,11 +79,32 @@ fn main() {
}); });
} }
// 6⃣ Keep main thread alive // 6⃣ Set up a LocalSet for the API server.
println!("📡 Main thread entering loop..."); let local = LocalSet::new();
for _frame in rx { local
// no-op .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)] #[cfg(test)]

117
src/ui.rs
View file

@ -20,10 +20,10 @@ use crossterm::{
}; };
use get_if_addrs::get_if_addrs; use get_if_addrs::get_if_addrs;
use crate::sync_logic::LtcState; use crate::sync_logic::{LtcFrame, LtcState};
/// Check if Chrony is active /// 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() { if let Ok(output) = Command::new("systemctl").args(&["is-active", "chrony"]).output() {
output.status.success() output.status.success()
&& String::from_utf8_lossy(&output.stdout).trim() == "active" && 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(); 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 { if delta_ms.abs() <= 8 {
"IN SYNC" "IN SYNC"
} else if delta_ms > 10 { } 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 { if jitter_ms.abs() < 10 {
"GOOD" "GOOD"
} else if jitter_ms.abs() < 40 { } 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<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 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( pub fn start_ui(
state: Arc<Mutex<LtcState>>, state: Arc<Mutex<LtcState>>,
serial_port: String, serial_port: String,
@ -151,31 +204,9 @@ pub fn start_ui(
if let Some(start) = out_of_sync_since { if let Some(start) = out_of_sync_since {
if start.elapsed() >= Duration::from_secs(5) { if start.elapsed() >= Duration::from_secs(5) {
if let Some(frame) = &state.lock().unwrap().latest { if let Some(frame) = &state.lock().unwrap().latest {
let today_local = Local::now().date_naive(); let entry = match trigger_sync(frame) {
let ms = ((frame.frames as f64 / frame.frame_rate) * 1000.0) Ok(ts) => format!("🔄 Autosynced to LTC: {}", ts),
.round() as u32; Err(_) => "❌ Autosync failed".into(),
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!("🔄 Autosynced to LTC: {}", ts)
} else {
"❌ Autosync failed".into()
}; };
if logs.len() == 10 { logs.pop_front(); } if logs.len() == 10 { logs.pop_front(); }
logs.push_back(entry); logs.push_back(entry);
@ -306,31 +337,9 @@ pub fn start_ui(
} }
KeyCode::Char(c) if c.eq_ignore_ascii_case(&'s') => { KeyCode::Char(c) if c.eq_ignore_ascii_case(&'s') => {
if let Some(frame) = &state.lock().unwrap().latest { if let Some(frame) = &state.lock().unwrap().latest {
let today_local = Local::now().date_naive(); let entry = match trigger_sync(frame) {
let ms = ((frame.frames as f64 / frame.frame_rate) * 1000.0) Ok(ts) => format!("✔ Synced exactly to LTC: {}", ts),
.round() as u32; Err(_) => "❌ date cmd failed".into(),
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()
}; };
if logs.len() == 10 { logs.pop_front(); } if logs.len() == 10 { logs.pop_front(); }
logs.push_back(entry); logs.push_back(entry);