feat: add web API for status, sync, and configuration

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 16:44:41 +01:00
parent a124aae424
commit 8ad553aaee
5 changed files with 297 additions and 125 deletions

View file

@ -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"] }

165
src/api.rs Normal file
View file

@ -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<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::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<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)]
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 })
}
#[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
}

View file

@ -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<Mutex<i64>> {
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<Event>| {
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<Mutex<i64>> {
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<Event>| {
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
}

View file

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

View file

@ -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<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");
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<Mutex<LtcState>>,
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!("🔄 Autosynced to LTC: {}", ts)
} else {
"❌ Autosync failed".into()
let entry = match trigger_sync(frame) {
Ok(ts) => format!("🔄 Autosynced to LTC: {}", ts),
Err(_) => "❌ Autosync 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);