mirror of
https://github.com/cjfranko/NTP-Timeturner.git
synced 2025-11-08 18:32:02 +00:00
feat: add timeturner for time offsets and migrate config to YAML
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
This commit is contained in:
parent
08577f5064
commit
777a202877
9 changed files with 245 additions and 160 deletions
|
|
@ -10,6 +10,7 @@ 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"
|
||||||
|
|
|
||||||
11
config.yml
11
config.yml
|
|
@ -1 +1,10 @@
|
||||||
hardware_offset_ms: 20
|
# 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
|
||||||
|
|
|
||||||
118
src/api.rs
118
src/api.rs
|
|
@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize};
|
||||||
use serde_json;
|
use serde_json;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use crate::config::{self, Config};
|
use crate::config::{self, Config, TimeturnerOffset};
|
||||||
use crate::sync_logic::LtcState;
|
use crate::sync_logic::LtcState;
|
||||||
use crate::ui;
|
use crate::ui;
|
||||||
|
|
||||||
|
|
@ -31,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| {
|
||||||
|
|
@ -63,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();
|
||||||
|
|
||||||
|
|
@ -94,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." }))
|
||||||
|
|
@ -105,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");
|
||||||
|
|
@ -177,7 +165,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(),
|
||||||
|
|
@ -197,16 +185,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())
|
||||||
|
|
@ -225,13 +218,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()
|
||||||
|
|
@ -241,26 +229,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())
|
||||||
|
|
@ -268,20 +250,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);
|
||||||
|
|
@ -289,14 +280,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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
75
src/main.rs
75
src/main.rs
|
|
@ -7,7 +7,7 @@ mod serial_input;
|
||||||
mod ui;
|
mod ui;
|
||||||
|
|
||||||
use crate::api::start_api_server;
|
use crate::api::start_api_server;
|
||||||
use crate::config::watch_config;
|
use crate::config::{watch_config, 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;
|
||||||
use crate::ui::start_ui;
|
use crate::ui::start_ui;
|
||||||
|
|
@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
90
src/ui.rs
90
src/ui.rs
|
|
@ -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️⃣ auto‑sync (same as manual but delayed)
|
// 7️⃣ auto‑sync (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!("🔄 Auto‑synced to LTC: {}", ts),
|
Ok(ts) => format!("🔄 Auto‑synced to LTC: {}", ts),
|
||||||
Err(_) => "❌ Auto‑sync failed".into(),
|
Err(_) => "❌ Auto‑sync 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]
|
||||||
|
|
|
||||||
|
|
@ -48,9 +48,16 @@
|
||||||
<div class="control-group">
|
<div class="control-group">
|
||||||
<label for="hw-offset">Hardware Offset (ms):</label>
|
<label for="hw-offset">Hardware Offset (ms):</label>
|
||||||
<input type="number" id="hw-offset" name="hw-offset">
|
<input type="number" id="hw-offset" name="hw-offset">
|
||||||
<button id="save-offset">Save</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="control-group">
|
<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>
|
<button id="manual-sync">Manual Sync</button>
|
||||||
<span id="sync-message"></span>
|
<span id="sync-message"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const hwOffsetInput = document.getElementById('hw-offset');
|
const hwOffsetInput = document.getElementById('hw-offset');
|
||||||
const saveOffsetButton = document.getElementById('save-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 manualSyncButton = document.getElementById('manual-sync');
|
||||||
const syncMessage = document.getElementById('sync-message');
|
const syncMessage = document.getElementById('sync-message');
|
||||||
|
|
||||||
|
|
@ -67,24 +73,32 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
const response = await fetch('/api/config');
|
const response = await fetch('/api/config');
|
||||||
if (!response.ok) throw new Error('Failed to fetch config');
|
if (!response.ok) throw new Error('Failed to fetch config');
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
hwOffsetInput.value = data.hardware_offset_ms;
|
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) {
|
} catch (error) {
|
||||||
console.error('Error fetching config:', error);
|
console.error('Error fetching config:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveConfig() {
|
async function saveConfig() {
|
||||||
const offset = parseInt(hwOffsetInput.value, 10);
|
const config = {
|
||||||
if (isNaN(offset)) {
|
hardwareOffsetMs: parseInt(hwOffsetInput.value, 10) || 0,
|
||||||
alert('Invalid hardware offset value.');
|
timeturnerOffset: {
|
||||||
return;
|
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 {
|
try {
|
||||||
const response = await fetch('/api/config', {
|
const response = await fetch('/api/config', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ hardware_offset_ms: offset }),
|
body: JSON.stringify(config),
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error('Failed to save config');
|
if (!response.ok) throw new Error('Failed to save config');
|
||||||
alert('Configuration saved.');
|
alert('Configuration saved.');
|
||||||
|
|
@ -111,7 +125,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
setTimeout(() => { syncMessage.textContent = ''; }, 5000);
|
setTimeout(() => { syncMessage.textContent = ''; }, 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
saveOffsetButton.addEventListener('click', saveConfig);
|
saveConfigButton.addEventListener('click', saveConfig);
|
||||||
manualSyncButton.addEventListener('click', triggerManualSync);
|
manualSyncButton.addEventListener('click', triggerManualSync);
|
||||||
|
|
||||||
// Initial data load
|
// Initial data load
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,7 @@ button:hover {
|
||||||
/* Status-specific colors */
|
/* Status-specific colors */
|
||||||
#sync-status.in-sync, #jitter-status.good { font-weight: bold; color: #28a745; }
|
#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.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; }
|
#jitter-status.bad { font-weight: bold; color: #dc3545; }
|
||||||
#ntp-active.active { font-weight: bold; color: #28a745; }
|
#ntp-active.active { font-weight: bold; color: #28a745; }
|
||||||
#ntp-active.inactive { font-weight: bold; color: #dc3545; }
|
#ntp-active.inactive { font-weight: bold; color: #dc3545; }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue