Merge pull request #10 from cjfranko/portal

Portal
This commit is contained in:
Chaos Rogers 2025-07-21 19:27:38 +01:00 committed by GitHub
commit d55a11b074
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 513 additions and 150 deletions

View file

@ -10,8 +10,10 @@ crossterm = "0.29"
regex = "1.11" regex = "1.11"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0.141" serde_json = "1.0.141"
serde_yaml = "0.9"
notify = "8.1.0" notify = "8.1.0"
get_if_addrs = "0.5" get_if_addrs = "0.5"
actix-web = "4" actix-web = "4"
actix-files = "0.6"
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }

View file

@ -1,3 +0,0 @@
{
"hardware_offset_ms": 20
}

10
config.yml Normal file
View file

@ -0,0 +1,10 @@
# Hardware offset in milliseconds for correcting capture latency.
hardwareOffsetMs: 20
# Time-turning offsets. All values are added to the incoming LTC time.
# These can be positive or negative.
timeturnerOffset:
hours: 0
minutes: 0
seconds: 0
frames: 0

View file

@ -1,4 +1,5 @@
use actix_files as fs;
use actix_web::{get, post, web, App, HttpResponse, HttpServer, Responder}; use actix_web::{get, post, web, App, HttpResponse, HttpServer, Responder};
use chrono::{Local, Timelike}; use chrono::{Local, Timelike};
use get_if_addrs::get_if_addrs; use get_if_addrs::get_if_addrs;
@ -30,13 +31,14 @@ struct ApiStatus {
// AppState to hold shared data // AppState to hold shared data
pub struct AppState { pub struct AppState {
pub ltc_state: Arc<Mutex<LtcState>>, pub ltc_state: Arc<Mutex<LtcState>>,
pub hw_offset: Arc<Mutex<i64>>, pub config: Arc<Mutex<Config>>,
} }
#[get("/api/status")] #[get("/api/status")]
async fn get_status(data: web::Data<AppState>) -> impl Responder { async fn get_status(data: web::Data<AppState>) -> impl Responder {
let state = data.ltc_state.lock().unwrap(); let state = data.ltc_state.lock().unwrap();
let hw_offset_ms = *data.hw_offset.lock().unwrap(); let config = data.config.lock().unwrap();
let hw_offset_ms = config.hardware_offset_ms;
let ltc_status = state.latest.as_ref().map_or("(waiting)".to_string(), |f| f.status.clone()); 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| { let ltc_timecode = state.latest.as_ref().map_or("".to_string(), |f| {
@ -62,7 +64,7 @@ async fn get_status(data: web::Data<AppState>) -> impl Responder {
delta_frames = ((avg_delta as f64 / frame_ms).round()) as i64; delta_frames = ((avg_delta as f64 / frame_ms).round()) as i64;
} }
let sync_status = ui::get_sync_status(avg_delta).to_string(); 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 jitter_status = ui::get_jitter_status(state.average_jitter()).to_string();
let lock_ratio = state.lock_ratio(); let lock_ratio = state.lock_ratio();
@ -93,8 +95,9 @@ async fn get_status(data: web::Data<AppState>) -> impl Responder {
#[post("/api/sync")] #[post("/api/sync")]
async fn manual_sync(data: web::Data<AppState>) -> impl Responder { async fn manual_sync(data: web::Data<AppState>) -> impl Responder {
let state = data.ltc_state.lock().unwrap(); let state = data.ltc_state.lock().unwrap();
let config = data.config.lock().unwrap();
if let Some(frame) = &state.latest { if let Some(frame) = &state.latest {
if ui::trigger_sync(frame).is_ok() { if ui::trigger_sync(frame, &config).is_ok() {
HttpResponse::Ok().json(serde_json::json!({ "status": "success", "message": "Sync command issued." })) HttpResponse::Ok().json(serde_json::json!({ "status": "success", "message": "Sync command issued." }))
} else { } else {
HttpResponse::InternalServerError().json(serde_json::json!({ "status": "error", "message": "Sync command failed." })) HttpResponse::InternalServerError().json(serde_json::json!({ "status": "error", "message": "Sync command failed." }))
@ -104,49 +107,35 @@ async fn manual_sync(data: web::Data<AppState>) -> impl Responder {
} }
} }
#[derive(Serialize, Deserialize)]
struct ConfigResponse {
hardware_offset_ms: i64,
}
#[get("/api/config")] #[get("/api/config")]
async fn get_config(data: web::Data<AppState>) -> impl Responder { async fn get_config(data: web::Data<AppState>) -> impl Responder {
let hw_offset_ms = *data.hw_offset.lock().unwrap(); let config = data.config.lock().unwrap();
HttpResponse::Ok().json(ConfigResponse { hardware_offset_ms: hw_offset_ms }) HttpResponse::Ok().json(&*config)
}
#[derive(Deserialize)]
struct UpdateConfigRequest {
hardware_offset_ms: i64,
} }
#[post("/api/config")] #[post("/api/config")]
async fn update_config( async fn update_config(
data: web::Data<AppState>, data: web::Data<AppState>,
req: web::Json<UpdateConfigRequest>, req: web::Json<Config>,
) -> impl Responder { ) -> impl Responder {
let mut hw_offset = data.hw_offset.lock().unwrap(); let mut config = data.config.lock().unwrap();
*hw_offset = req.hardware_offset_ms; *config = req.into_inner();
let new_config = Config { if config::save_config("config.yml", &config).is_ok() {
hardware_offset_ms: *hw_offset, eprintln!("🔄 Saved config via API: {:?}", *config);
}; HttpResponse::Ok().json(&*config)
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 { } else {
HttpResponse::InternalServerError().json(serde_json::json!({ "status": "error", "message": "Failed to write config.json" })) HttpResponse::InternalServerError().json(serde_json::json!({ "status": "error", "message": "Failed to write config.yml" }))
} }
} }
pub async fn start_api_server( pub async fn start_api_server(
state: Arc<Mutex<LtcState>>, state: Arc<Mutex<LtcState>>,
offset: Arc<Mutex<i64>>, config: Arc<Mutex<Config>>,
) -> std::io::Result<()> { ) -> std::io::Result<()> {
let app_state = web::Data::new(AppState { let app_state = web::Data::new(AppState {
ltc_state: state, ltc_state: state,
hw_offset: offset, config: config,
}); });
println!("🚀 Starting API server at http://0.0.0.0:8080"); println!("🚀 Starting API server at http://0.0.0.0:8080");
@ -158,6 +147,8 @@ pub async fn start_api_server(
.service(manual_sync) .service(manual_sync)
.service(get_config) .service(get_config)
.service(update_config) .service(update_config)
// Serve frontend static files
.service(fs::Files::new("/", "static/").index_file("index.html"))
}) })
.bind("0.0.0.0:8080")? .bind("0.0.0.0:8080")?
.run() .run()
@ -167,6 +158,7 @@ pub async fn start_api_server(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::config::TimeturnerOffset;
use crate::sync_logic::LtcFrame; use crate::sync_logic::LtcFrame;
use actix_web::{test, App}; use actix_web::{test, App};
use chrono::Utc; use chrono::Utc;
@ -174,7 +166,7 @@ mod tests {
use std::fs; use std::fs;
// Helper to create a default LtcState for tests // Helper to create a default LtcState for tests
fn get_test_state() -> LtcState { fn get_test_ltc_state() -> LtcState {
LtcState { LtcState {
latest: Some(LtcFrame { latest: Some(LtcFrame {
status: "LOCK".to_string(), status: "LOCK".to_string(),
@ -194,16 +186,21 @@ mod tests {
} }
} }
// Helper to create a default AppState for tests
fn get_test_app_state() -> web::Data<AppState> {
let ltc_state = Arc::new(Mutex::new(get_test_ltc_state()));
let config = Arc::new(Mutex::new(Config {
hardware_offset_ms: 10,
timeturner_offset: TimeturnerOffset {
hours: 0, minutes: 0, seconds: 0, frames: 0
}
}));
web::Data::new(AppState { ltc_state, config })
}
#[actix_web::test] #[actix_web::test]
async fn test_get_status() { async fn test_get_status() {
let ltc_state = Arc::new(Mutex::new(get_test_state())); let app_state = get_test_app_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( let app = test::init_service(
App::new() App::new()
.app_data(app_state.clone()) .app_data(app_state.clone())
@ -222,13 +219,8 @@ mod tests {
#[actix_web::test] #[actix_web::test]
async fn test_get_config() { async fn test_get_config() {
let ltc_state = Arc::new(Mutex::new(LtcState::new())); let app_state = get_test_app_state();
let hw_offset = Arc::new(Mutex::new(25i64)); app_state.config.lock().unwrap().hardware_offset_ms = 25;
let app_state = web::Data::new(AppState {
ltc_state: ltc_state.clone(),
hw_offset: hw_offset.clone(),
});
let app = test::init_service( let app = test::init_service(
App::new() App::new()
@ -238,26 +230,20 @@ mod tests {
.await; .await;
let req = test::TestRequest::get().uri("/api/config").to_request(); let req = test::TestRequest::get().uri("/api/config").to_request();
let resp: ConfigResponse = test::call_and_read_body_json(&app, req).await; let resp: Config = test::call_and_read_body_json(&app, req).await;
assert_eq!(resp.hardware_offset_ms, 25); assert_eq!(resp.hardware_offset_ms, 25);
} }
#[actix_web::test] #[actix_web::test]
async fn test_update_config() { async fn test_update_config() {
let ltc_state = Arc::new(Mutex::new(LtcState::new())); let app_state = get_test_app_state();
let hw_offset = Arc::new(Mutex::new(0i64)); let config_path = "config.yml";
let config_path = "config.json";
// This test has the side effect of writing to `config.json`. // This test has the side effect of writing to `config.yml`.
// We ensure it's cleaned up after. // We ensure it's cleaned up after.
let _ = fs::remove_file(config_path); 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( let app = test::init_service(
App::new() App::new()
.app_data(app_state.clone()) .app_data(app_state.clone())
@ -265,20 +251,29 @@ mod tests {
) )
.await; .await;
let new_config_json = serde_json::json!({
"hardwareOffsetMs": 55,
"timeturnerOffset": { "hours": 1, "minutes": 2, "seconds": 3, "frames": 4 }
});
let req = test::TestRequest::post() let req = test::TestRequest::post()
.uri("/api/config") .uri("/api/config")
.set_json(&serde_json::json!({ "hardware_offset_ms": 55 })) .set_json(&new_config_json)
.to_request(); .to_request();
let resp: Config = test::call_and_read_body_json(&app, req).await; let resp: Config = test::call_and_read_body_json(&app, req).await;
assert_eq!(resp.hardware_offset_ms, 55); assert_eq!(resp.hardware_offset_ms, 55);
assert_eq!(*hw_offset.lock().unwrap(), 55); assert_eq!(resp.timeturner_offset.hours, 1);
let final_config = app_state.config.lock().unwrap();
assert_eq!(final_config.hardware_offset_ms, 55);
assert_eq!(final_config.timeturner_offset.hours, 1);
// Test that the file was written // Test that the file was written
assert!(fs::metadata(config_path).is_ok()); assert!(fs::metadata(config_path).is_ok());
let contents = fs::read_to_string(config_path).unwrap(); let contents = fs::read_to_string(config_path).unwrap();
assert!(contents.contains("\"hardware_offset_ms\": 55")); assert!(contents.contains("hardwareOffsetMs: 55"));
assert!(contents.contains("hours: 1"));
// Cleanup // Cleanup
let _ = fs::remove_file(config_path); let _ = fs::remove_file(config_path);
@ -286,14 +281,9 @@ mod tests {
#[actix_web::test] #[actix_web::test]
async fn test_manual_sync_no_ltc() { async fn test_manual_sync_no_ltc() {
let app_state = get_test_app_state();
// State with no LTC frame // State with no LTC frame
let ltc_state = Arc::new(Mutex::new(LtcState::new())); app_state.ltc_state.lock().unwrap().latest = None;
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( let app = test::init_service(
App::new() App::new()

View file

@ -1,5 +1,4 @@
// 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,
@ -13,63 +12,90 @@ use std::{
sync::{Arc, Mutex}, sync::{Arc, Mutex},
}; };
#[derive(Deserialize, Serialize, Clone)] #[derive(Deserialize, Serialize, Clone, Debug, Default)]
#[serde(rename_all = "camelCase")]
pub struct TimeturnerOffset {
pub hours: i64,
pub minutes: i64,
pub seconds: i64,
pub frames: i64,
}
impl TimeturnerOffset {
pub fn is_active(&self) -> bool {
self.hours != 0 || self.minutes != 0 || self.seconds != 0 || self.frames != 0
}
}
#[derive(Deserialize, Serialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct Config { pub struct Config {
pub hardware_offset_ms: i64, pub hardware_offset_ms: i64,
#[serde(default)]
pub timeturner_offset: TimeturnerOffset,
} }
impl Config { impl Config {
pub fn load(path: &PathBuf) -> Self { pub fn load(path: &PathBuf) -> Self {
let mut file = match File::open(path) { let mut file = match File::open(path) {
Ok(f) => f, Ok(f) => f,
Err(_) => return Self { hardware_offset_ms: 0 }, Err(_) => return Self::default(),
}; };
let mut contents = String::new(); let mut contents = String::new();
if file.read_to_string(&mut contents).is_err() { if file.read_to_string(&mut contents).is_err() {
return Self { hardware_offset_ms: 0 }; return Self::default();
} }
serde_json::from_str(&contents).unwrap_or(Self { hardware_offset_ms: 0 }) serde_yaml::from_str(&contents).unwrap_or_else(|e| {
eprintln!("Failed to parse config, using default: {}", e);
Self::default()
})
} }
} }
pub fn save_config(path: &str, config: &Config) -> std::io::Result<()> { impl Default for Config {
let contents = serde_json::to_string_pretty(config)?; fn default() -> Self {
fs::write(path, contents) Self {
hardware_offset_ms: 0,
timeturner_offset: TimeturnerOffset::default(),
}
}
} }
pub fn watch_config(path: &str) -> Arc<Mutex<i64>> { pub fn save_config(path: &str, config: &Config) -> Result<(), Box<dyn std::error::Error>> {
let initial = Config::load(&PathBuf::from(path)).hardware_offset_ms; let contents = serde_yaml::to_string(config)?;
let offset = Arc::new(Mutex::new(initial)); fs::write(path, contents)?;
Ok(())
}
pub fn watch_config(path: &str) -> Arc<Mutex<Config>> {
let initial_config = Config::load(&PathBuf::from(path));
let config = Arc::new(Mutex::new(initial_config));
// Owned PathBuf for watch() call
let watch_path = PathBuf::from(path); let watch_path = PathBuf::from(path);
// Clone for moving into the closure
let watch_path_for_cb = watch_path.clone(); let watch_path_for_cb = watch_path.clone();
let offset_for_cb = Arc::clone(&offset); let config_for_cb = Arc::clone(&config);
std::thread::spawn(move || { std::thread::spawn(move || {
// Move `watch_path_for_cb` into the callback
let mut watcher: RecommendedWatcher = recommended_watcher(move |res: NotifyResult<Event>| { let mut watcher: RecommendedWatcher = recommended_watcher(move |res: NotifyResult<Event>| {
if let Ok(evt) = res { if let Ok(evt) = res {
if matches!(evt.kind, EventKind::Modify(_)) { if matches!(evt.kind, EventKind::Modify(_)) {
let new_cfg = Config::load(&watch_path_for_cb); let new_cfg = Config::load(&watch_path_for_cb);
let mut hw = offset_for_cb.lock().unwrap(); let mut cfg = config_for_cb.lock().unwrap();
*hw = new_cfg.hardware_offset_ms; *cfg = new_cfg;
eprintln!("🔄 Reloaded hardware_offset_ms = {}", *hw); eprintln!("🔄 Reloaded config.yml: {:?}", *cfg);
} }
} }
}) })
.expect("Failed to create file watcher"); .expect("Failed to create file watcher");
// Use the original `watch_path` here
watcher watcher
.watch(&watch_path, RecursiveMode::NonRecursive) .watch(&watch_path, RecursiveMode::NonRecursive)
.expect("Failed to watch config.json"); .expect("Failed to watch config.yml");
loop { loop {
std::thread::sleep(std::time::Duration::from_secs(60)); std::thread::sleep(std::time::Duration::from_secs(60));
} }
}); });
offset config
} }

View file

@ -21,17 +21,26 @@ use std::{
use tokio::task::{self, LocalSet}; use tokio::task::{self, LocalSet};
/// Default config content, embedded in the binary. /// Default config content, embedded in the binary.
const DEFAULT_CONFIG: &str = r#"{ const DEFAULT_CONFIG: &str = r#"
"hardware_offset_ms": 20 # Hardware offset in milliseconds for correcting capture latency.
}"#; hardwareOffsetMs: 20
/// If no `config.json` exists alongside the binary, write out the default. # Time-turning offsets. All values are added to the incoming LTC time.
# These can be positive or negative.
timeturnerOffset:
hours: 0
minutes: 0
seconds: 0
frames: 0
"#;
/// If no `config.yml` exists alongside the binary, write out the default.
fn ensure_config() { fn ensure_config() {
let p = Path::new("config.json"); let p = Path::new("config.yml");
if !p.exists() { if !p.exists() {
fs::write(p, DEFAULT_CONFIG) fs::write(p, DEFAULT_CONFIG.trim())
.expect("Failed to write default config.json"); .expect("Failed to write default config.yml");
eprintln!("⚙️ Emitted default config.json"); eprintln!("⚙️ Emitted default config.yml");
} }
} }
@ -40,9 +49,9 @@ async fn main() {
// 🔄 Ensure there's always a config.json present // 🔄 Ensure there's always a config.json present
ensure_config(); ensure_config();
// 1⃣ Start watching config.json for changes // 1⃣ Start watching config.yml for changes
let hw_offset = watch_config("config.json"); let config = watch_config("config.yml");
println!("🔧 Watching config.json (hardware_offset_ms)..."); println!("🔧 Watching config.yml...");
// 2⃣ Channel for raw LTC frames // 2⃣ Channel for raw LTC frames
let (tx, rx) = mpsc::channel(); let (tx, rx) = mpsc::channel();
@ -68,14 +77,14 @@ async fn main() {
}); });
} }
// 5⃣ Spawn the UI renderer thread, passing the live offset Arc // 5⃣ Spawn the UI renderer thread, passing the live config Arc
{ {
let ui_state = ltc_state.clone(); let ui_state = ltc_state.clone();
let offset_clone = hw_offset.clone(); let config_clone = config.clone();
let port = "/dev/ttyACM0".to_string(); let port = "/dev/ttyACM0".to_string();
thread::spawn(move || { thread::spawn(move || {
println!("🖥️ UI thread launched"); println!("🖥️ UI thread launched");
start_ui(ui_state, port, offset_clone); start_ui(ui_state, port, config_clone);
}); });
} }
@ -86,9 +95,9 @@ async fn main() {
// 7⃣ Spawn the API server thread // 7⃣ Spawn the API server thread
{ {
let api_state = ltc_state.clone(); let api_state = ltc_state.clone();
let offset_clone = hw_offset.clone(); let config_clone = config.clone();
task::spawn_local(async move { task::spawn_local(async move {
if let Err(e) = start_api_server(api_state, offset_clone).await { if let Err(e) = start_api_server(api_state, config_clone).await {
eprintln!("API server error: {}", e); eprintln!("API server error: {}", e);
} }
}); });
@ -118,7 +127,7 @@ mod tests {
impl Drop for ConfigGuard { impl Drop for ConfigGuard {
fn drop(&mut self) { fn drop(&mut self) {
let _ = fs::remove_file("config.json"); let _ = fs::remove_file("config.yml");
} }
} }
@ -127,28 +136,28 @@ mod tests {
let _guard = ConfigGuard; // Cleanup when _guard goes out of scope. let _guard = ConfigGuard; // Cleanup when _guard goes out of scope.
// --- Test 1: File creation --- // --- Test 1: File creation ---
// Pre-condition: config.json does not exist. // Pre-condition: config.yml does not exist.
let _ = fs::remove_file("config.json"); let _ = fs::remove_file("config.yml");
ensure_config(); ensure_config();
// Post-condition: config.json exists and has default content. // Post-condition: config.yml exists and has default content.
let p = Path::new("config.json"); let p = Path::new("config.yml");
assert!(p.exists(), "config.json should have been created"); assert!(p.exists(), "config.yml should have been created");
let contents = fs::read_to_string(p).expect("Failed to read created config.json"); let contents = fs::read_to_string(p).expect("Failed to read created config.yml");
assert_eq!(contents, DEFAULT_CONFIG, "config.json content should match default"); assert_eq!(contents, DEFAULT_CONFIG.trim(), "config.yml content should match default");
// --- Test 2: File is not overwritten --- // --- Test 2: File is not overwritten ---
// Pre-condition: config.json exists with different content. // Pre-condition: config.yml exists with different content.
let custom_content = "{\"hardware_offset_ms\": 999}"; let custom_content = "hardwareOffsetMs: 999";
fs::write("config.json", custom_content) fs::write("config.yml", custom_content)
.expect("Failed to write custom config.json for test"); .expect("Failed to write custom config.yml for test");
ensure_config(); ensure_config();
// Post-condition: config.json still has the custom content. // Post-condition: config.yml still has the custom content.
let contents_after = fs::read_to_string("config.json") let contents_after = fs::read_to_string("config.yml")
.expect("Failed to read config.json after second ensure_config call"); .expect("Failed to read config.yml after second ensure_config call");
assert_eq!(contents_after, custom_content, "config.json should not be overwritten"); assert_eq!(contents_after, custom_content, "config.yml should not be overwritten");
} }
} }

View file

@ -9,7 +9,7 @@ use std::collections::VecDeque;
use chrono::{ use chrono::{
DateTime, Local, Timelike, Utc, DateTime, Local, Timelike, Utc,
NaiveTime, TimeZone, NaiveTime, TimeZone, Duration as ChronoDuration,
}; };
use crossterm::{ use crossterm::{
cursor::{Hide, MoveTo, Show}, cursor::{Hide, MoveTo, Show},
@ -19,6 +19,7 @@ use crossterm::{
terminal::{self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen}, terminal::{self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen},
}; };
use crate::config::Config;
use get_if_addrs::get_if_addrs; use get_if_addrs::get_if_addrs;
use crate::sync_logic::{LtcFrame, LtcState}; use crate::sync_logic::{LtcFrame, LtcState};
@ -39,8 +40,10 @@ fn ntp_service_toggle(start: bool) {
let _ = Command::new("systemctl").args(&[action, "chrony"]).status(); let _ = Command::new("systemctl").args(&[action, "chrony"]).status();
} }
pub fn get_sync_status(delta_ms: i64) -> &'static str { pub fn get_sync_status(delta_ms: i64, config: &Config) -> &'static str {
if delta_ms.abs() <= 8 { if config.timeturner_offset.is_active() {
"TIMETURNING"
} else if delta_ms.abs() <= 8 {
"IN SYNC" "IN SYNC"
} else if delta_ms > 10 { } else if delta_ms > 10 {
"CLOCK AHEAD" "CLOCK AHEAD"
@ -59,18 +62,27 @@ pub fn get_jitter_status(jitter_ms: i64) -> &'static str {
} }
} }
pub fn trigger_sync(frame: &LtcFrame) -> Result<String, ()> { pub fn trigger_sync(frame: &LtcFrame, config: &Config) -> Result<String, ()> {
let today_local = Local::now().date_naive(); let today_local = Local::now().date_naive();
let ms = ((frame.frames as f64 / frame.frame_rate) * 1000.0) let ms = ((frame.frames as f64 / frame.frame_rate) * 1000.0).round() as u32;
.round() as u32; let timecode = NaiveTime::from_hms_milli_opt(frame.hours, frame.minutes, frame.seconds, ms)
let timecode = NaiveTime::from_hms_milli_opt( .expect("Invalid LTC timecode");
frame.hours, frame.minutes, frame.seconds, ms,
).expect("Invalid LTC timecode");
let naive_dt = today_local.and_time(timecode); let naive_dt = today_local.and_time(timecode);
let dt_local = Local let mut dt_local = Local
.from_local_datetime(&naive_dt) .from_local_datetime(&naive_dt)
.single() .single()
.expect("Ambiguous or invalid local time"); .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")] #[cfg(target_os = "linux")]
let (ts, success) = { let (ts, success) = {
let ts = dt_local.format("%H:%M:%S.%3f").to_string(); let ts = dt_local.format("%H:%M:%S.%3f").to_string();
@ -115,7 +127,7 @@ pub fn trigger_sync(frame: &LtcFrame) -> Result<String, ()> {
pub fn start_ui( pub fn start_ui(
state: Arc<Mutex<LtcState>>, state: Arc<Mutex<LtcState>>,
serial_port: String, serial_port: String,
offset: Arc<Mutex<i64>>, config: Arc<Mutex<Config>>,
) { ) {
let mut stdout = stdout(); let mut stdout = stdout();
execute!(stdout, EnterAlternateScreen, Hide).unwrap(); execute!(stdout, EnterAlternateScreen, Hide).unwrap();
@ -128,8 +140,9 @@ pub fn start_ui(
let mut cached_delta_frames: i64 = 0; let mut cached_delta_frames: i64 = 0;
loop { loop {
// 1⃣ hardware offset // 1⃣ config
let hw_offset_ms = *offset.lock().unwrap(); let cfg = config.lock().unwrap().clone();
let hw_offset_ms = cfg.hardware_offset_ms;
// 2⃣ Chrony + interfaces // 2⃣ Chrony + interfaces
let ntp_active = ntp_service_active(); let ntp_active = ntp_service_active();
@ -151,18 +164,27 @@ pub fn start_ui(
let measured = raw - hw_offset_ms; let measured = raw - hw_offset_ms;
st.record_offset(measured); st.record_offset(measured);
// Δ = system clock - LTC timecode (use LOCAL time) // Δ = system clock - LTC timecode (use LOCAL time, with offset)
let today_local = Local::now().date_naive(); let today_local = Local::now().date_naive();
let ms = ((frame.frames as f64 / frame.frame_rate) * 1000.0) let ms = ((frame.frames as f64 / frame.frame_rate) * 1000.0).round() as u32;
.round() as u32;
let tc_naive = NaiveTime::from_hms_milli_opt( let tc_naive = NaiveTime::from_hms_milli_opt(
frame.hours, frame.minutes, frame.seconds, ms, frame.hours, frame.minutes, frame.seconds, ms,
).expect("Invalid LTC timecode"); ).expect("Invalid LTC timecode");
let naive_dt_local = today_local.and_time(tc_naive); let naive_dt_local = today_local.and_time(tc_naive);
let dt_local = Local let mut dt_local = Local
.from_local_datetime(&naive_dt_local) .from_local_datetime(&naive_dt_local)
.single() .single()
.expect("Invalid local time"); .expect("Invalid local time");
// Apply timeturner offset before calculating delta
let offset = &cfg.timeturner_offset;
dt_local = dt_local
+ ChronoDuration::hours(offset.hours)
+ ChronoDuration::minutes(offset.minutes)
+ ChronoDuration::seconds(offset.seconds);
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);
let delta_ms = (Local::now() - dt_local).num_milliseconds(); let delta_ms = (Local::now() - dt_local).num_milliseconds();
st.record_clock_delta(delta_ms); st.record_clock_delta(delta_ms);
} else { } else {
@ -197,14 +219,14 @@ pub fn start_ui(
} }
// 6⃣ sync status wording // 6⃣ sync status wording
let sync_status = get_sync_status(cached_delta_ms); let sync_status = get_sync_status(cached_delta_ms, &cfg);
// 7⃣ autosync (same as manual but delayed) // 7⃣ autosync (same as manual but delayed)
if sync_status != "IN SYNC" { if sync_status != "IN SYNC" && sync_status != "TIMETURNING" {
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 entry = match trigger_sync(frame) { let entry = match trigger_sync(frame, &cfg) {
Ok(ts) => format!("🔄 Autosynced to LTC: {}", ts), Ok(ts) => format!("🔄 Autosynced to LTC: {}", ts),
Err(_) => "❌ Autosync failed".into(), Err(_) => "❌ Autosync failed".into(),
}; };
@ -283,6 +305,8 @@ pub fn start_ui(
// sync status // sync status
let scol = if sync_status == "IN SYNC" { let scol = if sync_status == "IN SYNC" {
Color::Green Color::Green
} else if sync_status == "TIMETURNING" {
Color::Cyan
} else { } else {
Color::Red Color::Red
}; };
@ -337,7 +361,7 @@ 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 entry = match trigger_sync(frame) { let entry = match trigger_sync(frame, &cfg) {
Ok(ts) => format!("✔ Synced exactly to LTC: {}", ts), Ok(ts) => format!("✔ Synced exactly to LTC: {}", ts),
Err(_) => "❌ date cmd failed".into(), Err(_) => "❌ date cmd failed".into(),
}; };
@ -358,16 +382,24 @@ pub fn start_ui(
mod tests { mod tests {
use super::*; use super::*;
use crate::config::TimeturnerOffset;
#[test] #[test]
fn test_get_sync_status() { fn test_get_sync_status() {
assert_eq!(get_sync_status(0), "IN SYNC"); let mut config = Config::default();
assert_eq!(get_sync_status(8), "IN SYNC"); assert_eq!(get_sync_status(0, &config), "IN SYNC");
assert_eq!(get_sync_status(-8), "IN SYNC"); assert_eq!(get_sync_status(8, &config), "IN SYNC");
assert_eq!(get_sync_status(9), "CLOCK BEHIND"); assert_eq!(get_sync_status(-8, &config), "IN SYNC");
assert_eq!(get_sync_status(10), "CLOCK BEHIND"); assert_eq!(get_sync_status(9, &config), "CLOCK BEHIND");
assert_eq!(get_sync_status(11), "CLOCK AHEAD"); assert_eq!(get_sync_status(10, &config), "CLOCK BEHIND");
assert_eq!(get_sync_status(-9), "CLOCK BEHIND"); assert_eq!(get_sync_status(11, &config), "CLOCK AHEAD");
assert_eq!(get_sync_status(-100), "CLOCK BEHIND"); 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] #[test]

69
static/index.html Normal file
View file

@ -0,0 +1,69 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NTP TimeTurner</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<h1>NTP TimeTurner</h1>
<div class="grid">
<!-- LTC Status -->
<div class="card">
<h2>LTC Status</h2>
<p id="ltc-status">--</p>
<p id="ltc-timecode">--:--:--:--</p>
<p id="frame-rate">-- fps</p>
<p>Lock Ratio: <span id="lock-ratio">--</span>%</p>
</div>
<!-- System Clock & Sync -->
<div class="card">
<h2>System Clock</h2>
<p id="system-clock">--:--:--.---</p>
<p>NTP Service: <span id="ntp-active">--</span></p>
<p>Sync Status: <span id="sync-status">--</span></p>
</div>
<!-- Delta & Jitter -->
<div class="card">
<h2>Clock Offset</h2>
<p>Delta: <span id="delta-ms">--</span> ms (<span id="delta-frames">--</span> frames)</p>
<p>Jitter: <span id="jitter-status">--</span></p>
</div>
<!-- Network Interfaces -->
<div class="card">
<h2>Network</h2>
<ul id="interfaces">
<li>--</li>
</ul>
</div>
<!-- Controls -->
<div class="card full-width">
<h2>Controls</h2>
<div class="control-group">
<label for="hw-offset">Hardware Offset (ms):</label>
<input type="number" id="hw-offset" name="hw-offset">
</div>
<div class="control-group">
<label>Timeturner Offset:</label>
<input type="number" id="offset-h" placeholder="H">
<input type="number" id="offset-m" placeholder="M">
<input type="number" id="offset-s" placeholder="S">
<input type="number" id="offset-f" placeholder="F">
</div>
<div class="control-group">
<button id="save-config">Save Config</button>
<button id="manual-sync">Manual Sync</button>
<span id="sync-message"></span>
</div>
</div>
</div>
</div>
<script src="script.js"></script>
</body>
</html>

137
static/script.js Normal file
View file

@ -0,0 +1,137 @@
document.addEventListener('DOMContentLoaded', () => {
const statusElements = {
ltcStatus: document.getElementById('ltc-status'),
ltcTimecode: document.getElementById('ltc-timecode'),
frameRate: document.getElementById('frame-rate'),
lockRatio: document.getElementById('lock-ratio'),
systemClock: document.getElementById('system-clock'),
ntpActive: document.getElementById('ntp-active'),
syncStatus: document.getElementById('sync-status'),
deltaMs: document.getElementById('delta-ms'),
deltaFrames: document.getElementById('delta-frames'),
jitterStatus: document.getElementById('jitter-status'),
interfaces: document.getElementById('interfaces'),
};
const hwOffsetInput = document.getElementById('hw-offset');
const offsetInputs = {
h: document.getElementById('offset-h'),
m: document.getElementById('offset-m'),
s: document.getElementById('offset-s'),
f: document.getElementById('offset-f'),
};
const saveConfigButton = document.getElementById('save-config');
const manualSyncButton = document.getElementById('manual-sync');
const syncMessage = document.getElementById('sync-message');
function updateStatus(data) {
statusElements.ltcStatus.textContent = data.ltc_status;
statusElements.ltcTimecode.textContent = data.ltc_timecode;
statusElements.frameRate.textContent = data.frame_rate;
statusElements.lockRatio.textContent = data.lock_ratio.toFixed(2);
statusElements.systemClock.textContent = data.system_clock;
statusElements.ntpActive.textContent = data.ntp_active ? 'Active' : 'Inactive';
statusElements.ntpActive.className = data.ntp_active ? 'active' : 'inactive';
statusElements.syncStatus.textContent = data.sync_status;
statusElements.syncStatus.className = data.sync_status.replace(/\s+/g, '-').toLowerCase();
statusElements.deltaMs.textContent = data.timecode_delta_ms;
statusElements.deltaFrames.textContent = data.timecode_delta_frames;
statusElements.jitterStatus.textContent = data.jitter_status;
statusElements.jitterStatus.className = data.jitter_status.toLowerCase();
statusElements.interfaces.innerHTML = '';
if (data.interfaces.length > 0) {
data.interfaces.forEach(ip => {
const li = document.createElement('li');
li.textContent = ip;
statusElements.interfaces.appendChild(li);
});
} else {
const li = document.createElement('li');
li.textContent = 'No active interfaces found.';
statusElements.interfaces.appendChild(li);
}
}
async function fetchStatus() {
try {
const response = await fetch('/api/status');
if (!response.ok) throw new Error('Failed to fetch status');
const data = await response.json();
updateStatus(data);
} catch (error) {
console.error('Error fetching status:', error);
}
}
async function fetchConfig() {
try {
const response = await fetch('/api/config');
if (!response.ok) throw new Error('Failed to fetch config');
const data = await response.json();
hwOffsetInput.value = data.hardwareOffsetMs;
offsetInputs.h.value = data.timeturnerOffset.hours;
offsetInputs.m.value = data.timeturnerOffset.minutes;
offsetInputs.s.value = data.timeturnerOffset.seconds;
offsetInputs.f.value = data.timeturnerOffset.frames;
} catch (error) {
console.error('Error fetching config:', error);
}
}
async function saveConfig() {
const config = {
hardwareOffsetMs: parseInt(hwOffsetInput.value, 10) || 0,
timeturnerOffset: {
hours: parseInt(offsetInputs.h.value, 10) || 0,
minutes: parseInt(offsetInputs.m.value, 10) || 0,
seconds: parseInt(offsetInputs.s.value, 10) || 0,
frames: parseInt(offsetInputs.f.value, 10) || 0,
}
};
try {
const response = await fetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
});
if (!response.ok) throw new Error('Failed to save config');
alert('Configuration saved.');
} catch (error) {
console.error('Error saving config:', error);
alert('Error saving configuration.');
}
}
async function triggerManualSync() {
syncMessage.textContent = 'Issuing sync command...';
try {
const response = await fetch('/api/sync', { method: 'POST' });
const data = await response.json();
if (response.ok) {
syncMessage.textContent = `Success: ${data.message}`;
} else {
syncMessage.textContent = `Error: ${data.message}`;
}
} catch (error) {
console.error('Error triggering sync:', error);
syncMessage.textContent = 'Failed to send sync command.';
}
setTimeout(() => { syncMessage.textContent = ''; }, 5000);
}
saveConfigButton.addEventListener('click', saveConfig);
manualSyncButton.addEventListener('click', triggerManualSync);
// Initial data load
fetchStatus();
fetchConfig();
// Refresh data every 2 seconds
setInterval(fetchStatus, 2000);
});

91
static/style.css Normal file
View file

@ -0,0 +1,91 @@
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background-color: #f4f4f9;
color: #333;
margin: 0;
padding: 20px;
display: flex;
justify-content: center;
}
.container {
width: 100%;
max-width: 960px;
}
h1 {
text-align: center;
color: #444;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
}
.card {
background: #fff;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.card h2 {
margin-top: 0;
color: #0056b3;
}
.card p, .card ul {
margin: 10px 0;
}
.card ul {
padding-left: 20px;
list-style: none;
}
.full-width {
grid-column: 1 / -1;
}
.control-group {
margin-bottom: 15px;
display: flex;
align-items: center;
gap: 10px;
}
input[type="number"] {
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
width: 80px;
}
button {
padding: 8px 15px;
border: none;
border-radius: 4px;
background-color: #007bff;
color: white;
cursor: pointer;
font-size: 14px;
}
button:hover {
background-color: #0056b3;
}
#sync-message {
font-style: italic;
color: #555;
}
/* Status-specific colors */
#sync-status.in-sync, #jitter-status.good { font-weight: bold; color: #28a745; }
#sync-status.clock-ahead, #sync-status.clock-behind, #jitter-status.average { font-weight: bold; color: #ffc107; }
#sync-status.timeturning { font-weight: bold; color: #17a2b8; }
#jitter-status.bad { font-weight: bold; color: #dc3545; }
#ntp-active.active { font-weight: bold; color: #28a745; }
#ntp-active.inactive { font-weight: bold; color: #dc3545; }