mirror of
https://github.com/cjfranko/NTP-Timeturner.git
synced 2025-11-08 18:32:02 +00:00
Compare commits
No commits in common. "6bc1f5ddbfcb8b677ea6bfbc60dac84c8e6763ba" and "3f953cff2ff457ca9eb98b8a64eaf2c9a1a33afe" have entirely different histories.
6bc1f5ddbf
...
3f953cff2f
13 changed files with 184 additions and 691 deletions
|
|
@ -17,7 +17,8 @@ actix-web = "4"
|
|||
actix-files = "0.6"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
clap = { version = "4.4", features = ["derive"] }
|
||||
log = { version = "0.4", features = ["std"] }
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
daemonize = "0.5.0"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ Created by Chris Frankland-Wright and John Rogers
|
|||
- Reads SMPTE LTC from Audio Interface (3.5mm TRS but adaptable to BNC/XLR)
|
||||
- Converts LTC into NTP-synced time
|
||||
- Broadcasts time via local NTP server
|
||||
- Supports configurable time offsets (hours, minutes, seconds, frames or milliseconds)
|
||||
- Supports configurable time offsets (hours, minutes, seconds, milliseconds) - NOT AVAILABLE
|
||||
- Systemd service support for headless operation
|
||||
|
||||
---
|
||||
|
|
|
|||
19
config.yml
19
config.yml
|
|
@ -1,19 +1,10 @@
|
|||
# Hardware offset in milliseconds for correcting capture latency.
|
||||
hardwareOffsetMs: 55
|
||||
|
||||
# Enable automatic clock synchronization.
|
||||
# When enabled, the system will perform an initial full sync, then periodically
|
||||
# nudge the clock to keep it aligned with the LTC source.
|
||||
autoSyncEnabled: true
|
||||
|
||||
# Default nudge in milliseconds for adjtimex control.
|
||||
defaultNudgeMs: 2
|
||||
hardwareOffsetMs: 20
|
||||
|
||||
# Time-turning offsets. All values are added to the incoming LTC time.
|
||||
# These can be positive or negative.
|
||||
timeturnerOffset:
|
||||
hours: 1
|
||||
minutes: 2
|
||||
seconds: 3
|
||||
frames: 4
|
||||
milliseconds: 5
|
||||
hours: 0
|
||||
minutes: 0
|
||||
seconds: 0
|
||||
frames: 0
|
||||
|
|
|
|||
28
docs/api.md
28
docs/api.md
|
|
@ -17,7 +17,6 @@ This document describes the HTTP API for the NTP Timeturner application.
|
|||
"ltc_timecode": "10:20:30:00",
|
||||
"frame_rate": "25.00fps",
|
||||
"system_clock": "10:20:30.005",
|
||||
"system_date": "2025-07-30",
|
||||
"timecode_delta_ms": 5,
|
||||
"timecode_delta_frames": 0,
|
||||
"sync_status": "IN SYNC",
|
||||
|
|
@ -59,33 +58,6 @@ This document describes the HTTP API for the NTP Timeturner application.
|
|||
}
|
||||
```
|
||||
|
||||
- **`POST /api/set_date`**
|
||||
|
||||
Sets the system date. This is useful as LTC does not contain date information. Requires `sudo` privileges.
|
||||
|
||||
**Example Request:**
|
||||
```json
|
||||
{
|
||||
"date": "2025-07-30"
|
||||
}
|
||||
```
|
||||
|
||||
**Success Response:**
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Date update command issued."
|
||||
}
|
||||
```
|
||||
|
||||
**Error Response:**
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Date update command failed."
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
- **`GET /api/config`**
|
||||
|
|
|
|||
98
src/api.rs
98
src/api.rs
|
|
@ -5,7 +5,6 @@ use chrono::{Local, Timelike};
|
|||
use get_if_addrs::get_if_addrs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json;
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::config::{self, Config};
|
||||
|
|
@ -19,7 +18,6 @@ struct ApiStatus {
|
|||
ltc_timecode: String,
|
||||
frame_rate: String,
|
||||
system_clock: String,
|
||||
system_date: String,
|
||||
timecode_delta_ms: i64,
|
||||
timecode_delta_frames: i64,
|
||||
sync_status: String,
|
||||
|
|
@ -34,7 +32,6 @@ struct ApiStatus {
|
|||
pub struct AppState {
|
||||
pub ltc_state: Arc<Mutex<LtcState>>,
|
||||
pub config: Arc<Mutex<Config>>,
|
||||
pub log_buffer: Arc<Mutex<VecDeque<String>>>,
|
||||
}
|
||||
|
||||
#[get("/api/status")]
|
||||
|
|
@ -59,9 +56,8 @@ async fn get_status(data: web::Data<AppState>) -> impl Responder {
|
|||
now_local.second(),
|
||||
now_local.timestamp_subsec_millis(),
|
||||
);
|
||||
let system_date = now_local.format("%Y-%m-%d").to_string();
|
||||
|
||||
let avg_delta = state.get_ewma_clock_delta();
|
||||
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;
|
||||
|
|
@ -85,7 +81,6 @@ async fn get_status(data: web::Data<AppState>) -> impl Responder {
|
|||
ltc_timecode,
|
||||
frame_rate,
|
||||
system_clock,
|
||||
system_date,
|
||||
timecode_delta_ms: avg_delta,
|
||||
timecode_delta_frames: delta_frames,
|
||||
sync_status: sync_status.to_string(),
|
||||
|
|
@ -118,42 +113,6 @@ async fn get_config(data: web::Data<AppState>) -> impl Responder {
|
|||
HttpResponse::Ok().json(&*config)
|
||||
}
|
||||
|
||||
#[get("/api/logs")]
|
||||
async fn get_logs(data: web::Data<AppState>) -> impl Responder {
|
||||
let logs = data.log_buffer.lock().unwrap();
|
||||
HttpResponse::Ok().json(&*logs)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct NudgeRequest {
|
||||
microseconds: i64,
|
||||
}
|
||||
|
||||
#[post("/api/nudge_clock")]
|
||||
async fn nudge_clock(req: web::Json<NudgeRequest>) -> impl Responder {
|
||||
if system::nudge_clock(req.microseconds).is_ok() {
|
||||
HttpResponse::Ok().json(serde_json::json!({ "status": "success", "message": "Clock nudge command issued." }))
|
||||
} else {
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({ "status": "error", "message": "Clock nudge command failed." }))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SetDateRequest {
|
||||
date: String,
|
||||
}
|
||||
|
||||
#[post("/api/set_date")]
|
||||
async fn set_date(req: web::Json<SetDateRequest>) -> impl Responder {
|
||||
if system::set_date(&req.date).is_ok() {
|
||||
HttpResponse::Ok()
|
||||
.json(serde_json::json!({ "status": "success", "message": "Date update command issued." }))
|
||||
} else {
|
||||
HttpResponse::InternalServerError()
|
||||
.json(serde_json::json!({ "status": "error", "message": "Date update command failed." }))
|
||||
}
|
||||
}
|
||||
|
||||
#[post("/api/config")]
|
||||
async fn update_config(
|
||||
data: web::Data<AppState>,
|
||||
|
|
@ -163,44 +122,23 @@ async fn update_config(
|
|||
*config = req.into_inner();
|
||||
|
||||
if config::save_config("config.yml", &config).is_ok() {
|
||||
log::info!("🔄 Saved config via API: {:?}", *config);
|
||||
|
||||
// If timeturner offset is active, trigger a sync immediately.
|
||||
if config.timeturner_offset.is_active() {
|
||||
let state = data.ltc_state.lock().unwrap();
|
||||
if let Some(frame) = &state.latest {
|
||||
log::info!("Timeturner offset is active, triggering sync...");
|
||||
if system::trigger_sync(frame, &config).is_ok() {
|
||||
log::info!("Sync triggered successfully after config change.");
|
||||
} else {
|
||||
log::error!("Sync failed after config change.");
|
||||
}
|
||||
} else {
|
||||
log::warn!("Timeturner offset is active, but no LTC frame available to sync.");
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!("🔄 Saved config via API: {:?}", *config);
|
||||
HttpResponse::Ok().json(&*config)
|
||||
} else {
|
||||
log::error!("Failed to write config.yml");
|
||||
HttpResponse::InternalServerError().json(
|
||||
serde_json::json!({ "status": "error", "message": "Failed to write config.yml" }),
|
||||
)
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({ "status": "error", "message": "Failed to write config.yml" }))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn start_api_server(
|
||||
state: Arc<Mutex<LtcState>>,
|
||||
config: Arc<Mutex<Config>>,
|
||||
log_buffer: Arc<Mutex<VecDeque<String>>>,
|
||||
) -> std::io::Result<()> {
|
||||
let app_state = web::Data::new(AppState {
|
||||
ltc_state: state,
|
||||
config: config,
|
||||
log_buffer: log_buffer,
|
||||
});
|
||||
|
||||
log::info!("🚀 Starting API server at http://0.0.0.0:8080");
|
||||
println!("🚀 Starting API server at http://0.0.0.0:8080");
|
||||
|
||||
HttpServer::new(move || {
|
||||
App::new()
|
||||
|
|
@ -209,9 +147,6 @@ pub async fn start_api_server(
|
|||
.service(manual_sync)
|
||||
.service(get_config)
|
||||
.service(update_config)
|
||||
.service(get_logs)
|
||||
.service(nudge_clock)
|
||||
.service(set_date)
|
||||
// Serve frontend static files
|
||||
.service(fs::Files::new("/", "static/").index_file("index.html"))
|
||||
})
|
||||
|
|
@ -245,7 +180,7 @@ mod tests {
|
|||
lock_count: 10,
|
||||
free_count: 1,
|
||||
offset_history: VecDeque::from(vec![1, 2, 3]),
|
||||
ewma_clock_delta: Some(5.0),
|
||||
clock_delta_history: VecDeque::from(vec![4, 5, 6]),
|
||||
last_match_status: "IN SYNC".to_string(),
|
||||
last_match_check: Utc::now().timestamp(),
|
||||
}
|
||||
|
|
@ -256,16 +191,11 @@ mod tests {
|
|||
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::default(),
|
||||
default_nudge_ms: 2,
|
||||
auto_sync_enabled: false,
|
||||
timeturner_offset: TimeturnerOffset {
|
||||
hours: 0, minutes: 0, seconds: 0, frames: 0
|
||||
}
|
||||
}));
|
||||
let log_buffer = Arc::new(Mutex::new(VecDeque::new()));
|
||||
web::Data::new(AppState {
|
||||
ltc_state,
|
||||
config,
|
||||
log_buffer,
|
||||
})
|
||||
web::Data::new(AppState { ltc_state, config })
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
|
|
@ -323,9 +253,7 @@ mod tests {
|
|||
|
||||
let new_config_json = serde_json::json!({
|
||||
"hardwareOffsetMs": 55,
|
||||
"defaultNudgeMs": 2,
|
||||
"autoSyncEnabled": true,
|
||||
"timeturnerOffset": { "hours": 1, "minutes": 2, "seconds": 3, "frames": 4, "milliseconds": 5 }
|
||||
"timeturnerOffset": { "hours": 1, "minutes": 2, "seconds": 3, "frames": 4 }
|
||||
});
|
||||
|
||||
let req = test::TestRequest::post()
|
||||
|
|
@ -336,22 +264,16 @@ mod tests {
|
|||
let resp: Config = test::call_and_read_body_json(&app, req).await;
|
||||
|
||||
assert_eq!(resp.hardware_offset_ms, 55);
|
||||
assert_eq!(resp.auto_sync_enabled, true);
|
||||
assert_eq!(resp.timeturner_offset.hours, 1);
|
||||
assert_eq!(resp.timeturner_offset.milliseconds, 5);
|
||||
let final_config = app_state.config.lock().unwrap();
|
||||
assert_eq!(final_config.hardware_offset_ms, 55);
|
||||
assert_eq!(final_config.auto_sync_enabled, true);
|
||||
assert_eq!(final_config.timeturner_offset.hours, 1);
|
||||
assert_eq!(final_config.timeturner_offset.milliseconds, 5);
|
||||
|
||||
// Test that the file was written
|
||||
assert!(fs::metadata(config_path).is_ok());
|
||||
let contents = fs::read_to_string(config_path).unwrap();
|
||||
assert!(contents.contains("hardwareOffsetMs: 55"));
|
||||
assert!(contents.contains("autoSyncEnabled: true"));
|
||||
assert!(contents.contains("hours: 1"));
|
||||
assert!(contents.contains("milliseconds: 5"));
|
||||
|
||||
// Cleanup
|
||||
let _ = fs::remove_file(config_path);
|
||||
|
|
|
|||
|
|
@ -19,17 +19,11 @@ pub struct TimeturnerOffset {
|
|||
pub minutes: i64,
|
||||
pub seconds: i64,
|
||||
pub frames: i64,
|
||||
#[serde(default)]
|
||||
pub milliseconds: i64,
|
||||
}
|
||||
|
||||
impl TimeturnerOffset {
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.hours != 0
|
||||
|| self.minutes != 0
|
||||
|| self.seconds != 0
|
||||
|| self.frames != 0
|
||||
|| self.milliseconds != 0
|
||||
self.hours != 0 || self.minutes != 0 || self.seconds != 0 || self.frames != 0
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -39,14 +33,6 @@ pub struct Config {
|
|||
pub hardware_offset_ms: i64,
|
||||
#[serde(default)]
|
||||
pub timeturner_offset: TimeturnerOffset,
|
||||
#[serde(default = "default_nudge_ms")]
|
||||
pub default_nudge_ms: i64,
|
||||
#[serde(default)]
|
||||
pub auto_sync_enabled: bool,
|
||||
}
|
||||
|
||||
fn default_nudge_ms() -> i64 {
|
||||
2 // Default nudge is 2ms
|
||||
}
|
||||
|
||||
impl Config {
|
||||
|
|
@ -60,7 +46,7 @@ impl Config {
|
|||
return Self::default();
|
||||
}
|
||||
serde_yaml::from_str(&contents).unwrap_or_else(|e| {
|
||||
log::warn!("Failed to parse config, using default: {}", e);
|
||||
eprintln!("Failed to parse config, using default: {}", e);
|
||||
Self::default()
|
||||
})
|
||||
}
|
||||
|
|
@ -71,35 +57,13 @@ impl Default for Config {
|
|||
Self {
|
||||
hardware_offset_ms: 0,
|
||||
timeturner_offset: TimeturnerOffset::default(),
|
||||
default_nudge_ms: default_nudge_ms(),
|
||||
auto_sync_enabled: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_config(path: &str, config: &Config) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut s = String::new();
|
||||
s.push_str("# Hardware offset in milliseconds for correcting capture latency.\n");
|
||||
s.push_str(&format!("hardwareOffsetMs: {}\n\n", config.hardware_offset_ms));
|
||||
|
||||
s.push_str("# Enable automatic clock synchronization.\n");
|
||||
s.push_str("# When enabled, the system will perform an initial full sync, then periodically\n");
|
||||
s.push_str("# nudge the clock to keep it aligned with the LTC source.\n");
|
||||
s.push_str(&format!("autoSyncEnabled: {}\n\n", config.auto_sync_enabled));
|
||||
|
||||
s.push_str("# Default nudge in milliseconds for adjtimex control.\n");
|
||||
s.push_str(&format!("defaultNudgeMs: {}\n\n", config.default_nudge_ms));
|
||||
|
||||
s.push_str("# Time-turning offsets. All values are added to the incoming LTC time.\n");
|
||||
s.push_str("# These can be positive or negative.\n");
|
||||
s.push_str("timeturnerOffset:\n");
|
||||
s.push_str(&format!(" hours: {}\n", config.timeturner_offset.hours));
|
||||
s.push_str(&format!(" minutes: {}\n", config.timeturner_offset.minutes));
|
||||
s.push_str(&format!(" seconds: {}\n", config.timeturner_offset.seconds));
|
||||
s.push_str(&format!(" frames: {}\n", config.timeturner_offset.frames));
|
||||
s.push_str(&format!(" milliseconds: {}\n", config.timeturner_offset.milliseconds));
|
||||
|
||||
fs::write(path, s)?;
|
||||
let contents = serde_yaml::to_string(config)?;
|
||||
fs::write(path, contents)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -118,7 +82,7 @@ pub fn watch_config(path: &str) -> Arc<Mutex<Config>> {
|
|||
let new_cfg = Config::load(&watch_path_for_cb);
|
||||
let mut cfg = config_for_cb.lock().unwrap();
|
||||
*cfg = new_cfg;
|
||||
log::info!("🔄 Reloaded config.yml: {:?}", *cfg);
|
||||
eprintln!("🔄 Reloaded config.yml: {:?}", *cfg);
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,52 +0,0 @@
|
|||
use chrono::Local;
|
||||
use log::{LevelFilter, Log, Metadata, Record};
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
const MAX_LOG_ENTRIES: usize = 100;
|
||||
|
||||
struct RingBufferLogger {
|
||||
buffer: Arc<Mutex<VecDeque<String>>>,
|
||||
}
|
||||
|
||||
impl Log for RingBufferLogger {
|
||||
fn enabled(&self, metadata: &Metadata) -> bool {
|
||||
metadata.level() <= LevelFilter::Info
|
||||
}
|
||||
|
||||
fn log(&self, record: &Record) {
|
||||
if self.enabled(record.metadata()) {
|
||||
let msg = format!(
|
||||
"{} [{}] {}",
|
||||
Local::now().format("%Y-%m-%d %H:%M:%S"),
|
||||
record.level(),
|
||||
record.args()
|
||||
);
|
||||
|
||||
// Also print to stderr for console/daemon logging
|
||||
eprintln!("{}", msg);
|
||||
|
||||
let mut buffer = self.buffer.lock().unwrap();
|
||||
if buffer.len() == MAX_LOG_ENTRIES {
|
||||
buffer.pop_front();
|
||||
}
|
||||
buffer.push_back(msg);
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
pub fn setup_logger() -> Arc<Mutex<VecDeque<String>>> {
|
||||
let buffer = Arc::new(Mutex::new(VecDeque::with_capacity(MAX_LOG_ENTRIES)));
|
||||
let logger = RingBufferLogger {
|
||||
buffer: buffer.clone(),
|
||||
};
|
||||
|
||||
// We use `set_boxed_logger` to install our custom logger.
|
||||
// The `log` crate will then route all log messages to it.
|
||||
log::set_boxed_logger(Box::new(logger)).expect("Failed to set logger");
|
||||
log::set_max_level(LevelFilter::Info);
|
||||
|
||||
buffer
|
||||
}
|
||||
172
src/main.rs
172
src/main.rs
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
mod api;
|
||||
mod config;
|
||||
mod logger;
|
||||
mod serial_input;
|
||||
mod sync_logic;
|
||||
mod system;
|
||||
|
|
@ -15,6 +14,7 @@ use crate::sync_logic::LtcState;
|
|||
use crate::ui::start_ui;
|
||||
use clap::Parser;
|
||||
use daemonize::Daemonize;
|
||||
use env_logger;
|
||||
|
||||
use std::{
|
||||
fs,
|
||||
|
|
@ -42,14 +42,6 @@ const DEFAULT_CONFIG: &str = r#"
|
|||
# Hardware offset in milliseconds for correcting capture latency.
|
||||
hardwareOffsetMs: 20
|
||||
|
||||
# Enable automatic clock synchronization.
|
||||
# When enabled, the system will perform an initial full sync, then periodically
|
||||
# nudge the clock to keep it aligned with the LTC source.
|
||||
autoSyncEnabled: false
|
||||
|
||||
# Default nudge in milliseconds for adjtimex control.
|
||||
defaultNudgeMs: 2
|
||||
|
||||
# Time-turning offsets. All values are added to the incoming LTC time.
|
||||
# These can be positive or negative.
|
||||
timeturnerOffset:
|
||||
|
|
@ -57,7 +49,6 @@ timeturnerOffset:
|
|||
minutes: 0
|
||||
seconds: 0
|
||||
frames: 0
|
||||
milliseconds: 0
|
||||
"#;
|
||||
|
||||
/// If no `config.yml` exists alongside the binary, write out the default.
|
||||
|
|
@ -66,18 +57,16 @@ fn ensure_config() {
|
|||
if !p.exists() {
|
||||
fs::write(p, DEFAULT_CONFIG.trim())
|
||||
.expect("Failed to write default config.yml");
|
||||
log::info!("⚙️ Emitted default config.yml");
|
||||
eprintln!("⚙️ Emitted default config.yml");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn main() {
|
||||
// This must be called before any logging statements.
|
||||
let log_buffer = logger::setup_logger();
|
||||
let args = Args::parse();
|
||||
|
||||
if let Some(Command::Daemon) = &args.command {
|
||||
log::info!("🚀 Starting daemon...");
|
||||
println!("🚀 Starting daemon...");
|
||||
|
||||
// Create files for stdout and stderr in the current directory
|
||||
let stdout = fs::File::create("daemon.out").expect("Could not create daemon.out");
|
||||
|
|
@ -92,7 +81,7 @@ async fn main() {
|
|||
match daemonize.start() {
|
||||
Ok(_) => { /* Process is now daemonized */ }
|
||||
Err(e) => {
|
||||
log::error!("Error daemonizing: {}", e);
|
||||
eprintln!("Error daemonizing: {}", e);
|
||||
return; // Exit if daemonization fails
|
||||
}
|
||||
}
|
||||
|
|
@ -127,9 +116,9 @@ async fn main() {
|
|||
|
||||
// 5️⃣ Spawn UI or setup daemon logging
|
||||
if args.command.is_none() {
|
||||
log::info!("🔧 Watching config.yml...");
|
||||
log::info!("🚀 Serial thread launched");
|
||||
log::info!("🖥️ UI thread launched");
|
||||
println!("🔧 Watching config.yml...");
|
||||
println!("🚀 Serial thread launched");
|
||||
println!("🖥️ UI thread launched");
|
||||
let ui_state = ltc_state.clone();
|
||||
let config_clone = config.clone();
|
||||
let port = "/dev/ttyACM0".to_string();
|
||||
|
|
@ -137,127 +126,41 @@ async fn main() {
|
|||
start_ui(ui_state, port, config_clone);
|
||||
});
|
||||
} else {
|
||||
// In daemon mode, logging is already set up to go to stderr.
|
||||
// The systemd service will capture it.
|
||||
// In daemon mode, we initialize env_logger.
|
||||
// This will log to stdout, and the systemd service will capture it.
|
||||
// The RUST_LOG env var controls the log level (e.g., RUST_LOG=info).
|
||||
env_logger::init();
|
||||
log::info!("🚀 Starting TimeTurner daemon...");
|
||||
}
|
||||
|
||||
// 6️⃣ Spawn the auto-sync thread
|
||||
{
|
||||
let sync_state = ltc_state.clone();
|
||||
let sync_config = config.clone();
|
||||
thread::spawn(move || {
|
||||
// Wait for the first LTC frame to arrive
|
||||
loop {
|
||||
if sync_state.lock().unwrap().latest.is_some() {
|
||||
log::info!("Auto-sync: Initial LTC frame detected.");
|
||||
break;
|
||||
}
|
||||
thread::sleep(std::time::Duration::from_secs(1));
|
||||
}
|
||||
|
||||
// Initial sync
|
||||
{
|
||||
let state = sync_state.lock().unwrap();
|
||||
let config = sync_config.lock().unwrap();
|
||||
if config.auto_sync_enabled {
|
||||
if let Some(frame) = &state.latest {
|
||||
log::info!("Auto-sync: Performing initial full sync.");
|
||||
if system::trigger_sync(frame, &config).is_ok() {
|
||||
log::info!("Auto-sync: Initial sync successful.");
|
||||
} else {
|
||||
log::error!("Auto-sync: Initial sync failed.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
thread::sleep(std::time::Duration::from_secs(10));
|
||||
|
||||
// Main auto-sync loop
|
||||
loop {
|
||||
{
|
||||
let state = sync_state.lock().unwrap();
|
||||
let config = sync_config.lock().unwrap();
|
||||
|
||||
if config.auto_sync_enabled && state.latest.is_some() {
|
||||
let delta = state.get_ewma_clock_delta();
|
||||
let frame = state.latest.as_ref().unwrap();
|
||||
|
||||
if delta.abs() > 40 {
|
||||
log::info!("Auto-sync: Delta > 40ms ({}ms), performing full sync.", delta);
|
||||
if system::trigger_sync(frame, &config).is_ok() {
|
||||
log::info!("Auto-sync: Full sync successful.");
|
||||
} else {
|
||||
log::error!("Auto-sync: Full sync failed.");
|
||||
}
|
||||
} else if delta.abs() >= 1 {
|
||||
// nudge_clock takes microseconds. A positive delta means clock is
|
||||
// ahead, so we need a negative nudge.
|
||||
let nudge_us = -delta * 1000;
|
||||
log::info!("Auto-sync: Delta is {}ms, nudging clock by {}us.", delta, nudge_us);
|
||||
if system::nudge_clock(nudge_us).is_ok() {
|
||||
log::info!("Auto-sync: Clock nudge successful.");
|
||||
} else {
|
||||
log::error!("Auto-sync: Clock nudge failed.");
|
||||
}
|
||||
}
|
||||
}
|
||||
} // locks released here
|
||||
|
||||
thread::sleep(std::time::Duration::from_secs(10));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 7️⃣ Set up a LocalSet for the API server and main loop
|
||||
// 6️⃣ Set up a LocalSet for the API server and main loop
|
||||
let local = LocalSet::new();
|
||||
local
|
||||
.run_until(async move {
|
||||
// 8️⃣ Spawn the API server thread
|
||||
// 7️⃣ Spawn the API server thread
|
||||
{
|
||||
let api_state = ltc_state.clone();
|
||||
let config_clone = config.clone();
|
||||
let log_buffer_clone = log_buffer.clone();
|
||||
task::spawn_local(async move {
|
||||
if let Err(e) =
|
||||
start_api_server(api_state, config_clone, log_buffer_clone).await
|
||||
{
|
||||
log::error!("API server error: {}", e);
|
||||
if let Err(e) = start_api_server(api_state, config_clone).await {
|
||||
eprintln!("API server error: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 9️⃣ Main logic loop: process frames from serial and update state
|
||||
let loop_state = ltc_state.clone();
|
||||
let loop_config = config.clone();
|
||||
let logic_task = task::spawn_blocking(move || {
|
||||
for frame in rx {
|
||||
let mut state = loop_state.lock().unwrap();
|
||||
let config = loop_config.lock().unwrap();
|
||||
|
||||
// Only calculate delta for LOCK frames
|
||||
if frame.status == "LOCK" {
|
||||
let target_time = system::calculate_target_time(&frame, &config);
|
||||
let arrival_time_local: chrono::DateTime<chrono::Local> =
|
||||
frame.timestamp.with_timezone(&chrono::Local);
|
||||
let delta = arrival_time_local.signed_duration_since(target_time);
|
||||
state.record_and_update_ewma_clock_delta(delta.num_milliseconds());
|
||||
}
|
||||
|
||||
state.update(frame);
|
||||
}
|
||||
});
|
||||
|
||||
// 1️⃣0️⃣ Keep main thread alive
|
||||
// 8️⃣ Keep main thread alive
|
||||
if args.command.is_some() {
|
||||
// In daemon mode, wait forever. The logic_task runs in the background.
|
||||
// In daemon mode, wait forever.
|
||||
std::future::pending::<()>().await;
|
||||
} else {
|
||||
// In TUI mode, block until the logic_task finishes (e.g. serial port disconnects)
|
||||
// This keeps the TUI running.
|
||||
log::info!("📡 Main thread entering loop...");
|
||||
let _ = logic_task.await;
|
||||
// In TUI mode, block on the channel.
|
||||
println!("📡 Main thread entering loop...");
|
||||
let _ = task::spawn_blocking(move || {
|
||||
for _frame in rx {
|
||||
// no-op
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
|
@ -269,35 +172,18 @@ mod tests {
|
|||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
/// RAII guard to manage config file during tests.
|
||||
/// It saves the original content of `config.yml` if it exists,
|
||||
/// and restores it when the guard goes out of scope.
|
||||
/// If the file didn't exist, it's removed.
|
||||
struct ConfigGuard {
|
||||
original_content: Option<String>,
|
||||
}
|
||||
|
||||
impl ConfigGuard {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
original_content: fs::read_to_string("config.yml").ok(),
|
||||
}
|
||||
}
|
||||
}
|
||||
/// RAII guard to ensure config file is cleaned up after test.
|
||||
struct ConfigGuard;
|
||||
|
||||
impl Drop for ConfigGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Some(content) = &self.original_content {
|
||||
fs::write("config.yml", content).expect("Failed to restore config.yml");
|
||||
} else {
|
||||
let _ = fs::remove_file("config.yml");
|
||||
}
|
||||
let _ = fs::remove_file("config.yml");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ensure_config() {
|
||||
let _guard = ConfigGuard::new(); // Cleanup when _guard goes out of scope.
|
||||
let _guard = ConfigGuard; // Cleanup when _guard goes out of scope.
|
||||
|
||||
// --- Test 1: File creation ---
|
||||
// Pre-condition: config.yml does not exist.
|
||||
|
|
|
|||
|
|
@ -3,8 +3,6 @@ use chrono::{DateTime, Local, Timelike, Utc};
|
|||
use regex::Captures;
|
||||
use std::collections::VecDeque;
|
||||
|
||||
const EWMA_ALPHA: f64 = 0.1;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct LtcFrame {
|
||||
pub status: String,
|
||||
|
|
@ -44,8 +42,8 @@ pub struct LtcState {
|
|||
pub free_count: u32,
|
||||
/// Stores the last up-to-20 raw offset measurements in ms.
|
||||
pub offset_history: VecDeque<i64>,
|
||||
/// EWMA of clock delta.
|
||||
pub ewma_clock_delta: Option<f64>,
|
||||
/// Stores the last up-to-20 timecode Δ measurements in ms.
|
||||
pub clock_delta_history: VecDeque<i64>,
|
||||
pub last_match_status: String,
|
||||
pub last_match_check: i64,
|
||||
}
|
||||
|
|
@ -57,7 +55,7 @@ impl LtcState {
|
|||
lock_count: 0,
|
||||
free_count: 0,
|
||||
offset_history: VecDeque::with_capacity(20),
|
||||
ewma_clock_delta: None,
|
||||
clock_delta_history: VecDeque::with_capacity(20),
|
||||
last_match_status: "UNKNOWN".into(),
|
||||
last_match_check: 0,
|
||||
}
|
||||
|
|
@ -71,14 +69,12 @@ impl LtcState {
|
|||
self.offset_history.push_back(offset_ms);
|
||||
}
|
||||
|
||||
/// Update EWMA of clock delta.
|
||||
pub fn record_and_update_ewma_clock_delta(&mut self, delta_ms: i64) {
|
||||
let new_delta = delta_ms as f64;
|
||||
if let Some(current_ewma) = self.ewma_clock_delta {
|
||||
self.ewma_clock_delta = Some(EWMA_ALPHA * new_delta + (1.0 - EWMA_ALPHA) * current_ewma);
|
||||
} else {
|
||||
self.ewma_clock_delta = Some(new_delta);
|
||||
/// Record one timecode Δ in ms.
|
||||
pub fn record_clock_delta(&mut self, delta_ms: i64) {
|
||||
if self.clock_delta_history.len() == 20 {
|
||||
self.clock_delta_history.pop_front();
|
||||
}
|
||||
self.clock_delta_history.push_back(delta_ms);
|
||||
}
|
||||
|
||||
/// Clear all stored jitter measurements.
|
||||
|
|
@ -86,6 +82,11 @@ impl LtcState {
|
|||
self.offset_history.clear();
|
||||
}
|
||||
|
||||
/// Clear all stored timecode Δ measurements.
|
||||
pub fn clear_clock_deltas(&mut self) {
|
||||
self.clock_delta_history.clear();
|
||||
}
|
||||
|
||||
/// Update LOCK/FREE counts and timecode-match status every 5 s.
|
||||
pub fn update(&mut self, frame: LtcFrame) {
|
||||
match frame.status.as_str() {
|
||||
|
|
@ -107,7 +108,7 @@ impl LtcState {
|
|||
"FREE" => {
|
||||
self.free_count += 1;
|
||||
self.clear_offsets();
|
||||
self.ewma_clock_delta = None;
|
||||
self.clear_clock_deltas();
|
||||
self.last_match_status = "UNKNOWN".into();
|
||||
}
|
||||
_ => {}
|
||||
|
|
@ -136,9 +137,23 @@ impl LtcState {
|
|||
}
|
||||
}
|
||||
|
||||
/// Get EWMA of clock delta, in ms.
|
||||
pub fn get_ewma_clock_delta(&self) -> i64 {
|
||||
self.ewma_clock_delta.map_or(0, |v| v.round() as i64)
|
||||
/// Median timecode Δ over stored history, in ms.
|
||||
pub fn average_clock_delta(&self) -> i64 {
|
||||
if self.clock_delta_history.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let mut sorted_deltas: Vec<i64> = self.clock_delta_history.iter().cloned().collect();
|
||||
sorted_deltas.sort_unstable();
|
||||
|
||||
let mid = sorted_deltas.len() / 2;
|
||||
if sorted_deltas.len() % 2 == 0 {
|
||||
// Even number of elements, average the two middle ones
|
||||
(sorted_deltas[mid - 1] + sorted_deltas[mid]) / 2
|
||||
} else {
|
||||
// Odd number of elements, return the middle one
|
||||
sorted_deltas[mid]
|
||||
}
|
||||
}
|
||||
|
||||
/// Percentage of samples seen in LOCK state versus total.
|
||||
|
|
@ -160,8 +175,6 @@ impl LtcState {
|
|||
pub fn get_sync_status(delta_ms: i64, config: &Config) -> &'static str {
|
||||
if config.timeturner_offset.is_active() {
|
||||
"TIMETURNING"
|
||||
} else if config.auto_sync_enabled {
|
||||
"TIME LOCK ACTIVE"
|
||||
} else if delta_ms.abs() <= 8 {
|
||||
"IN SYNC"
|
||||
} else if delta_ms > 10 {
|
||||
|
|
@ -313,28 +326,35 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn test_ewma_clock_delta() {
|
||||
fn test_average_clock_delta_is_median() {
|
||||
let mut state = LtcState::new();
|
||||
assert_eq!(state.get_ewma_clock_delta(), 0);
|
||||
|
||||
// First value initializes the EWMA
|
||||
state.record_and_update_ewma_clock_delta(100);
|
||||
assert_eq!(state.get_ewma_clock_delta(), 100);
|
||||
// Establish a stable set of values
|
||||
for _ in 0..19 {
|
||||
state.record_clock_delta(2);
|
||||
}
|
||||
state.record_clock_delta(100); // Add an outlier
|
||||
|
||||
// Second value moves it
|
||||
state.record_and_update_ewma_clock_delta(200);
|
||||
// 0.1 * 200 + 0.9 * 100 = 20 + 90 = 110
|
||||
assert_eq!(state.get_ewma_clock_delta(), 110);
|
||||
// With 19 `2`s and one `100`, the median should still be `2`.
|
||||
// The simple average would be (19*2 + 100) / 20 = 138 / 20 = 6.
|
||||
assert_eq!(
|
||||
state.average_clock_delta(),
|
||||
2,
|
||||
"Median should ignore the outlier"
|
||||
);
|
||||
|
||||
// Third value
|
||||
state.record_and_update_ewma_clock_delta(100);
|
||||
// 0.1 * 100 + 0.9 * 110 = 10 + 99 = 109
|
||||
assert_eq!(state.get_ewma_clock_delta(), 109);
|
||||
|
||||
// Reset on FREE frame
|
||||
state.update(get_test_frame("FREE", 0, 0, 0));
|
||||
assert_eq!(state.get_ewma_clock_delta(), 0);
|
||||
assert!(state.ewma_clock_delta.is_none());
|
||||
// Test with an even number of elements
|
||||
state.clear_clock_deltas();
|
||||
state.record_clock_delta(1);
|
||||
state.record_clock_delta(2);
|
||||
state.record_clock_delta(3);
|
||||
state.record_clock_delta(100);
|
||||
// sorted: [1, 2, 3, 100]. mid two are 2, 3. average is (2+3)/2 = 2.
|
||||
assert_eq!(
|
||||
state.average_clock_delta(),
|
||||
2,
|
||||
"Median of even numbers should be correct"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -349,12 +369,8 @@ mod tests {
|
|||
assert_eq!(get_sync_status(-9, &config), "CLOCK BEHIND");
|
||||
assert_eq!(get_sync_status(-100, &config), "CLOCK BEHIND");
|
||||
|
||||
// Test auto-sync status
|
||||
config.auto_sync_enabled = true;
|
||||
assert_eq!(get_sync_status(0, &config), "TIME LOCK ACTIVE");
|
||||
|
||||
// Test TIMETURNING status takes precedence
|
||||
config.timeturner_offset = TimeturnerOffset { hours: 1, minutes: 0, seconds: 0, frames: 0, milliseconds: 0 };
|
||||
// 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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ pub fn calculate_target_time(frame: &LtcFrame, config: &Config) -> DateTime<Loca
|
|||
+ 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 + ChronoDuration::milliseconds(frame_offset_ms + offset.milliseconds)
|
||||
dt_local + ChronoDuration::milliseconds(frame_offset_ms)
|
||||
}
|
||||
|
||||
pub fn trigger_sync(frame: &LtcFrame, config: &Config) -> Result<String, ()> {
|
||||
|
|
@ -104,60 +104,6 @@ pub fn trigger_sync(frame: &LtcFrame, config: &Config) -> Result<String, ()> {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn nudge_clock(microseconds: i64) -> Result<(), ()> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let success = Command::new("sudo")
|
||||
.arg("adjtimex")
|
||||
.arg("--singleshot")
|
||||
.arg(microseconds.to_string())
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
|
||||
if success {
|
||||
log::info!("Nudged clock by {} us", microseconds);
|
||||
Ok(())
|
||||
} else {
|
||||
log::error!("Failed to nudge clock with adjtimex");
|
||||
Err(())
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let _ = microseconds;
|
||||
log::warn!("Clock nudging is only supported on Linux.");
|
||||
Err(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_date(date: &str) -> Result<(), ()> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let success = Command::new("sudo")
|
||||
.arg("date")
|
||||
.arg("--set")
|
||||
.arg(date)
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
|
||||
if success {
|
||||
log::info!("Set system date to {}", date);
|
||||
Ok(())
|
||||
} else {
|
||||
log::error!("Failed to set system date");
|
||||
Err(())
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let _ = date;
|
||||
log::warn!("Date setting is only supported on Linux.");
|
||||
Err(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -204,7 +150,6 @@ mod tests {
|
|||
minutes: 5,
|
||||
seconds: 10,
|
||||
frames: 12, // 12 frames at 25fps is 480ms
|
||||
milliseconds: 20,
|
||||
};
|
||||
|
||||
let target_time = calculate_target_time(&frame, &config);
|
||||
|
|
@ -212,8 +157,8 @@ mod tests {
|
|||
assert_eq!(target_time.hour(), 11);
|
||||
assert_eq!(target_time.minute(), 25);
|
||||
assert_eq!(target_time.second(), 40);
|
||||
// 480ms + 20ms = 500ms
|
||||
assert_eq!(target_time.nanosecond(), 500_000_000);
|
||||
// 480ms
|
||||
assert_eq!(target_time.nanosecond(), 480_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -225,20 +170,13 @@ mod tests {
|
|||
minutes: -5,
|
||||
seconds: -10,
|
||||
frames: -12, // -480ms
|
||||
milliseconds: -80,
|
||||
};
|
||||
|
||||
let target_time = calculate_target_time(&frame, &config);
|
||||
|
||||
assert_eq!(target_time.hour(), 9);
|
||||
assert_eq!(target_time.minute(), 15);
|
||||
assert_eq!(target_time.second(), 19);
|
||||
assert_eq!(target_time.nanosecond(), 920_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nudge_clock_on_non_linux() {
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
assert!(nudge_clock(1000).is_err());
|
||||
assert_eq!(target_time.second(), 20);
|
||||
assert_eq!(target_time.nanosecond(), 0);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
63
src/ui.rs
63
src/ui.rs
|
|
@ -9,6 +9,7 @@ use std::collections::VecDeque;
|
|||
|
||||
use chrono::{
|
||||
DateTime, Local, Timelike, Utc,
|
||||
NaiveTime, TimeZone, Duration as ChronoDuration,
|
||||
};
|
||||
use crossterm::{
|
||||
cursor::{Hide, MoveTo, Show},
|
||||
|
|
@ -34,6 +35,7 @@ pub fn start_ui(
|
|||
terminal::enable_raw_mode().unwrap();
|
||||
|
||||
let mut logs: VecDeque<String> = VecDeque::with_capacity(10);
|
||||
let mut out_of_sync_since: Option<Instant> = None;
|
||||
let mut last_delta_update = Instant::now() - Duration::from_secs(1);
|
||||
let mut cached_delta_ms: i64 = 0;
|
||||
let mut cached_delta_frames: i64 = 0;
|
||||
|
|
@ -52,7 +54,7 @@ pub fn start_ui(
|
|||
.map(|ifa| ifa.ip().to_string())
|
||||
.collect();
|
||||
|
||||
// 3️⃣ jitter
|
||||
// 3️⃣ jitter + Δ
|
||||
{
|
||||
let mut st = state.lock().unwrap();
|
||||
if let Some(frame) = st.latest.clone() {
|
||||
|
|
@ -62,6 +64,33 @@ pub fn start_ui(
|
|||
let raw = (now_utc - frame.timestamp).num_milliseconds();
|
||||
let measured = raw - hw_offset_ms;
|
||||
st.record_offset(measured);
|
||||
|
||||
// Δ = system clock - LTC timecode (use LOCAL time, with offset)
|
||||
let today_local = Local::now().date_naive();
|
||||
let ms = ((frame.frames as f64 / frame.frame_rate) * 1000.0).round() as u32;
|
||||
let tc_naive = NaiveTime::from_hms_milli_opt(
|
||||
frame.hours, frame.minutes, frame.seconds, ms,
|
||||
).expect("Invalid LTC timecode");
|
||||
let naive_dt_local = today_local.and_time(tc_naive);
|
||||
let mut dt_local = Local
|
||||
.from_local_datetime(&naive_dt_local)
|
||||
.single()
|
||||
.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();
|
||||
st.record_clock_delta(delta_ms);
|
||||
} else {
|
||||
st.clear_offsets();
|
||||
st.clear_clock_deltas();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -74,7 +103,7 @@ pub fn start_ui(
|
|||
st.average_frames(),
|
||||
st.timecode_match().to_string(),
|
||||
st.lock_ratio(),
|
||||
st.get_ewma_clock_delta(),
|
||||
st.average_clock_delta(),
|
||||
)
|
||||
};
|
||||
|
||||
|
|
@ -93,7 +122,28 @@ pub fn start_ui(
|
|||
// 6️⃣ sync status wording
|
||||
let sync_status = get_sync_status(cached_delta_ms, &cfg);
|
||||
|
||||
// 7️⃣ header & LTC metrics display
|
||||
// 7️⃣ auto‑sync (same as manual but delayed)
|
||||
if sync_status != "IN SYNC" && sync_status != "TIMETURNING" {
|
||||
if let Some(start) = out_of_sync_since {
|
||||
if start.elapsed() >= Duration::from_secs(5) {
|
||||
if let Some(frame) = &state.lock().unwrap().latest {
|
||||
let entry = match system::trigger_sync(frame, &cfg) {
|
||||
Ok(ts) => format!("🔄 Auto‑synced to LTC: {}", ts),
|
||||
Err(_) => "❌ Auto‑sync failed".into(),
|
||||
};
|
||||
if logs.len() == 10 { logs.pop_front(); }
|
||||
logs.push_back(entry);
|
||||
}
|
||||
out_of_sync_since = None;
|
||||
}
|
||||
} else {
|
||||
out_of_sync_since = Some(Instant::now());
|
||||
}
|
||||
} else {
|
||||
out_of_sync_since = None;
|
||||
}
|
||||
|
||||
// 8️⃣ header & LTC metrics display
|
||||
{
|
||||
let st = state.lock().unwrap();
|
||||
let opt = st.latest.as_ref();
|
||||
|
|
@ -229,3 +279,10 @@ pub fn start_ui(
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[allow(unused_imports)]
|
||||
use super::*;
|
||||
#[allow(unused_imports)]
|
||||
use crate::config::TimeturnerOffset;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@
|
|||
<div class="card">
|
||||
<h2>System Clock</h2>
|
||||
<p id="system-clock">--:--:--.---</p>
|
||||
<p>Date: <span id="system-date">---- -- --</span></p>
|
||||
<p>NTP Service: <span id="ntp-active">--</span></p>
|
||||
<p>Sync Status: <span id="sync-status">--</span></p>
|
||||
</div>
|
||||
|
|
@ -51,58 +50,17 @@
|
|||
<input type="number" id="hw-offset" name="hw-offset">
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<input type="checkbox" id="auto-sync-enabled" name="auto-sync-enabled" style="vertical-align: middle;">
|
||||
<label for="auto-sync-enabled" style="vertical-align: middle;">Enable Auto Sync</label>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label>Timeturner Offset</label>
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 1rem; align-items: flex-start;">
|
||||
<div style="display: flex; flex-direction: column;">
|
||||
<label for="offset-h">Hours</label>
|
||||
<input type="number" id="offset-h" style="width: 60px;">
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column;">
|
||||
<label for="offset-m">Minutes</label>
|
||||
<input type="number" id="offset-m" style="width: 60px;">
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column;">
|
||||
<label for="offset-s">Seconds</label>
|
||||
<input type="number" id="offset-s" style="width: 60px;">
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column;">
|
||||
<label for="offset-f">Frames</label>
|
||||
<input type="number" id="offset-f" style="width: 60px;">
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column;">
|
||||
<label for="offset-ms">Milliseconds</label>
|
||||
<input type="number" id="offset-ms" style="width: 60px;">
|
||||
</div>
|
||||
</div>
|
||||
<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 class="control-group">
|
||||
<label>Nudge Clock (ms):</label>
|
||||
<button id="nudge-down">-</button>
|
||||
<input type="number" id="nudge-value" style="width: 60px;">
|
||||
<button id="nudge-up">+</button>
|
||||
<span id="nudge-message"></span>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label for="date-input">Set System Date:</label>
|
||||
<input type="date" id="date-input">
|
||||
<button id="set-date">Set Date</button>
|
||||
<span id="date-message"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Logs -->
|
||||
<div class="card full-width">
|
||||
<h2>Logs</h2>
|
||||
<pre id="logs" class="log-box"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
168
static/script.js
168
static/script.js
|
|
@ -1,53 +1,36 @@
|
|||
document.addEventListener('DOMContentLoaded', () => {
|
||||
let lastApiData = null;
|
||||
let lastApiFetchTime = null;
|
||||
|
||||
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'),
|
||||
systemDate: document.getElementById('system-date'),
|
||||
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'),
|
||||
logs: document.getElementById('logs'),
|
||||
};
|
||||
|
||||
const hwOffsetInput = document.getElementById('hw-offset');
|
||||
const autoSyncCheckbox = document.getElementById('auto-sync-enabled');
|
||||
const offsetInputs = {
|
||||
h: document.getElementById('offset-h'),
|
||||
m: document.getElementById('offset-m'),
|
||||
s: document.getElementById('offset-s'),
|
||||
f: document.getElementById('offset-f'),
|
||||
ms: document.getElementById('offset-ms'),
|
||||
};
|
||||
const saveConfigButton = document.getElementById('save-config');
|
||||
const manualSyncButton = document.getElementById('manual-sync');
|
||||
const syncMessage = document.getElementById('sync-message');
|
||||
|
||||
const nudgeDownButton = document.getElementById('nudge-down');
|
||||
const nudgeUpButton = document.getElementById('nudge-up');
|
||||
const nudgeValueInput = document.getElementById('nudge-value');
|
||||
const nudgeMessage = document.getElementById('nudge-message');
|
||||
|
||||
const dateInput = document.getElementById('date-input');
|
||||
const setDateButton = document.getElementById('set-date');
|
||||
const dateMessage = document.getElementById('date-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.systemDate.textContent = data.system_date;
|
||||
|
||||
|
||||
statusElements.ntpActive.textContent = data.ntp_active ? 'Active' : 'Inactive';
|
||||
statusElements.ntpActive.className = data.ntp_active ? 'active' : 'inactive';
|
||||
|
||||
|
|
@ -56,7 +39,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
|
||||
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();
|
||||
|
||||
|
|
@ -74,75 +57,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
}
|
||||
}
|
||||
|
||||
function animateClocks() {
|
||||
if (!lastApiData || !lastApiFetchTime) return;
|
||||
|
||||
const elapsedMs = new Date() - lastApiFetchTime;
|
||||
|
||||
// Animate System Clock
|
||||
if (lastApiData.system_clock && lastApiData.system_clock.includes(':')) {
|
||||
const parts = lastApiData.system_clock.split(/[:.]/);
|
||||
if (parts.length === 4) {
|
||||
const baseDate = new Date();
|
||||
baseDate.setHours(parseInt(parts[0], 10), parseInt(parts[1], 10), parseInt(parts[2], 10));
|
||||
baseDate.setMilliseconds(parseInt(parts[3], 10));
|
||||
|
||||
const newDate = new Date(baseDate.getTime() + elapsedMs);
|
||||
|
||||
const h = String(newDate.getHours()).padStart(2, '0');
|
||||
const m = String(newDate.getMinutes()).padStart(2, '0');
|
||||
const s = String(newDate.getSeconds()).padStart(2, '0');
|
||||
const ms = String(newDate.getMilliseconds()).padStart(3, '0');
|
||||
statusElements.systemClock.textContent = `${h}:${m}:${s}.${ms}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Animate LTC Timecode - only if status is LOCK
|
||||
if (lastApiData.ltc_status === 'LOCK' && lastApiData.ltc_timecode && lastApiData.ltc_timecode.includes(':') && lastApiData.frame_rate) {
|
||||
const tcParts = lastApiData.ltc_timecode.split(':');
|
||||
const frameRate = parseFloat(lastApiData.frame_rate);
|
||||
if (tcParts.length === 4 && !isNaN(frameRate) && frameRate > 0) {
|
||||
let h = parseInt(tcParts[0], 10);
|
||||
let m = parseInt(tcParts[1], 10);
|
||||
let s = parseInt(tcParts[2], 10);
|
||||
let f = parseInt(tcParts[3], 10);
|
||||
|
||||
const msPerFrame = 1000.0 / frameRate;
|
||||
const elapsedFrames = Math.floor(elapsedMs / msPerFrame);
|
||||
|
||||
f += elapsedFrames;
|
||||
|
||||
const frameRateInt = Math.round(frameRate);
|
||||
|
||||
s += Math.floor(f / frameRateInt);
|
||||
f %= frameRateInt;
|
||||
|
||||
m += Math.floor(s / 60);
|
||||
s %= 60;
|
||||
|
||||
h += Math.floor(m / 60);
|
||||
m %= 60;
|
||||
|
||||
h %= 24;
|
||||
|
||||
statusElements.ltcTimecode.textContent =
|
||||
`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}:${String(f).padStart(2, '0')}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
lastApiData = data;
|
||||
lastApiFetchTime = new Date();
|
||||
} catch (error) {
|
||||
console.error('Error fetching status:', error);
|
||||
lastApiData = null;
|
||||
lastApiFetchTime = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -152,13 +74,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
if (!response.ok) throw new Error('Failed to fetch config');
|
||||
const data = await response.json();
|
||||
hwOffsetInput.value = data.hardwareOffsetMs;
|
||||
autoSyncCheckbox.checked = data.autoSyncEnabled;
|
||||
offsetInputs.h.value = data.timeturnerOffset.hours;
|
||||
offsetInputs.m.value = data.timeturnerOffset.minutes;
|
||||
offsetInputs.s.value = data.timeturnerOffset.seconds;
|
||||
offsetInputs.f.value = data.timeturnerOffset.frames;
|
||||
offsetInputs.ms.value = data.timeturnerOffset.milliseconds || 0;
|
||||
nudgeValueInput.value = data.defaultNudgeMs;
|
||||
} catch (error) {
|
||||
console.error('Error fetching config:', error);
|
||||
}
|
||||
|
|
@ -167,14 +86,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
async function saveConfig() {
|
||||
const config = {
|
||||
hardwareOffsetMs: parseInt(hwOffsetInput.value, 10) || 0,
|
||||
autoSyncEnabled: autoSyncCheckbox.checked,
|
||||
defaultNudgeMs: parseInt(nudgeValueInput.value, 10) || 0,
|
||||
timeturnerOffset: {
|
||||
hours: parseInt(offsetInputs.h.value, 10) || 0,
|
||||
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,
|
||||
milliseconds: parseInt(offsetInputs.ms.value, 10) || 0,
|
||||
frames: parseInt(offsetInputs.f.value, 10) || 0,
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -192,20 +108,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
}
|
||||
}
|
||||
|
||||
async function fetchLogs() {
|
||||
try {
|
||||
const response = await fetch('/api/logs');
|
||||
if (!response.ok) throw new Error('Failed to fetch logs');
|
||||
const logs = await response.json();
|
||||
statusElements.logs.textContent = logs.join('\n');
|
||||
// Auto-scroll to the bottom
|
||||
statusElements.logs.scrollTop = statusElements.logs.scrollHeight;
|
||||
} catch (error) {
|
||||
console.error('Error fetching logs:', error);
|
||||
statusElements.logs.textContent = 'Error fetching logs.';
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerManualSync() {
|
||||
syncMessage.textContent = 'Issuing sync command...';
|
||||
try {
|
||||
|
|
@ -223,75 +125,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
setTimeout(() => { syncMessage.textContent = ''; }, 5000);
|
||||
}
|
||||
|
||||
async function nudgeClock(ms) {
|
||||
nudgeMessage.textContent = 'Nudging clock...';
|
||||
try {
|
||||
const response = await fetch('/api/nudge_clock', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ microseconds: ms * 1000 }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (response.ok) {
|
||||
nudgeMessage.textContent = `Success: ${data.message}`;
|
||||
} else {
|
||||
nudgeMessage.textContent = `Error: ${data.message}`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error nudging clock:', error);
|
||||
nudgeMessage.textContent = 'Failed to send nudge command.';
|
||||
}
|
||||
setTimeout(() => { nudgeMessage.textContent = ''; }, 3000);
|
||||
}
|
||||
|
||||
async function setDate() {
|
||||
const date = dateInput.value;
|
||||
if (!date) {
|
||||
alert('Please select a date.');
|
||||
return;
|
||||
}
|
||||
|
||||
dateMessage.textContent = 'Setting date...';
|
||||
try {
|
||||
const response = await fetch('/api/set_date', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ date: date }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (response.ok) {
|
||||
dateMessage.textContent = `Success: ${data.message}`;
|
||||
// Fetch status again to update the displayed date immediately
|
||||
fetchStatus();
|
||||
} else {
|
||||
dateMessage.textContent = `Error: ${data.message}`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error setting date:', error);
|
||||
dateMessage.textContent = 'Failed to send date command.';
|
||||
}
|
||||
setTimeout(() => { dateMessage.textContent = ''; }, 5000);
|
||||
}
|
||||
|
||||
saveConfigButton.addEventListener('click', saveConfig);
|
||||
manualSyncButton.addEventListener('click', triggerManualSync);
|
||||
nudgeDownButton.addEventListener('click', () => {
|
||||
const ms = parseInt(nudgeValueInput.value, 10) || 0;
|
||||
nudgeClock(-ms);
|
||||
});
|
||||
nudgeUpButton.addEventListener('click', () => {
|
||||
const ms = parseInt(nudgeValueInput.value, 10) || 0;
|
||||
nudgeClock(ms);
|
||||
});
|
||||
setDateButton.addEventListener('click', setDate);
|
||||
|
||||
// Initial data load
|
||||
fetchStatus();
|
||||
fetchConfig();
|
||||
fetchLogs();
|
||||
|
||||
// Refresh data every 2 seconds
|
||||
setInterval(fetchStatus, 2000);
|
||||
setInterval(fetchLogs, 2000);
|
||||
setInterval(animateClocks, 50); // High-frequency clock animation
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue