Compare commits

..

31 commits

Author SHA1 Message Date
Chris Frankland-Wright
6bc1f5ddbf
Merge pull request #21 from cjfranko/add_date
Some checks failed
Build for Raspberry Pi / Build for aarch64 (push) Failing after 16s
feat: Add system date display and setting via API
2025-07-30 22:41:49 +01:00
Chris Frankland-Wright
58a1d243e4 feat: Add system date display and setting via API
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-07-30 22:36:19 +01:00
Chris Frankland-Wright
af43388e4b
Update README.md 2025-07-30 22:30:29 +01:00
Chris Frankland-Wright
584840f1f3
Merge pull request #16 from cjfranko/webextras-andfixes
Webextras andfixes
2025-07-30 22:29:49 +01:00
Chris Frankland-Wright
3df9466754 animate timecode 2025-07-30 22:25:10 +01:00
Chris Frankland-Wright
0745883e0d Revert "docs: Correct README for time offset features"
This reverts commit 871fd192b0.
2025-07-30 22:21:09 +01:00
Chris Frankland-Wright
0c6e1b0f43 feat: Animate system and LTC clocks client-side for dynamic display
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-07-30 22:06:43 +01:00
Chris Frankland-Wright
871fd192b0 docs: Correct README for time offset features
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-07-30 21:58:45 +01:00
d814b05a26 fix: Display 'TIME LOCK ACTIVE' status for auto-sync
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-29 15:24:20 +01:00
992720041b updated config with config bodge in tests. 2025-07-29 14:49:26 +01:00
68dc16344a fix: preserve comments in config.yml when saving
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-29 14:42:33 +01:00
9a97027870 fix: remove unused out_of_sync_since variable
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-29 14:38:23 +01:00
d015794b03 feat: implement auto-sync with periodic clock nudging
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-29 14:18:10 +01:00
4cb421b3d6 fix: clarify timeturner offset controls with labels
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-29 12:25:40 +01:00
c0613c3682 revert 2025-07-29 12:21:54 +01:00
fcbd5bd647 clarification in this 2025-07-29 12:18:24 +01:00
f929bacdfd config tweak, 2025-07-29 12:10:56 +01:00
89849c6e04 refactor: simplify default configuration
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-29 11:59:46 +01:00
4090fee0a6 test: Restore original config.yml after tests
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-29 11:49:22 +01:00
fb8088c704 test: add missing milliseconds field to TimeturnerOffset init
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-29 11:44:59 +01:00
c712014bb9 feat: Allow millisecond offset for timeturner
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-29 11:39:46 +01:00
a12ee88b9b feat: Force sync on config save with timeturner offset
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-29 00:13:23 +01:00
917a844874 refactor: remove empty test module from ui.rs
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-29 00:09:17 +01:00
aee69679ef fix: remove unused chrono imports
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-29 00:02:46 +01:00
80faf4db9a fix: resolve build errors by adapting to clock delta refactor
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-28 23:58:52 +01:00
cc782fcd7e feat: add EWMA clock delta and adjtimex nudge controls
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-28 23:51:27 +01:00
6a45660e03 fix: process LTC frames in background to update app state
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-28 23:43:28 +01:00
985ccc6819 fix: Enable std feature for log and remove clock history
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-28 23:39:01 +01:00
5a86493824 feat: add daemon log viewer to web UI
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-28 23:36:51 +01:00
b803de93de feat: display clock delta history in UI
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-28 23:14:31 +01:00
7738d14097 addded comments to config.yml 2025-07-28 23:04:48 +01:00
13 changed files with 691 additions and 184 deletions

View file

@ -17,8 +17,7 @@ actix-web = "4"
actix-files = "0.6"
tokio = { version = "1", features = ["full"] }
clap = { version = "4.4", features = ["derive"] }
log = "0.4"
env_logger = "0.11"
log = { version = "0.4", features = ["std"] }
daemonize = "0.5.0"

View file

@ -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, milliseconds) - NOT AVAILABLE
- Supports configurable time offsets (hours, minutes, seconds, frames or milliseconds)
- Systemd service support for headless operation
---

View file

@ -1,10 +1,19 @@
# Hardware offset in milliseconds for correcting capture latency.
hardwareOffsetMs: 20
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
# 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
hours: 1
minutes: 2
seconds: 3
frames: 4
milliseconds: 5

View file

@ -17,6 +17,7 @@ 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",
@ -58,6 +59,33 @@ 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`**

View file

@ -5,6 +5,7 @@ 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};
@ -18,6 +19,7 @@ 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,
@ -32,6 +34,7 @@ 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")]
@ -56,8 +59,9 @@ 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.average_clock_delta();
let avg_delta = state.get_ewma_clock_delta();
let mut delta_frames = 0;
if let Some(frame) = &state.latest {
let frame_ms = 1000.0 / frame.frame_rate;
@ -81,6 +85,7 @@ 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(),
@ -113,6 +118,42 @@ 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>,
@ -122,23 +163,44 @@ async fn update_config(
*config = req.into_inner();
if config::save_config("config.yml", &config).is_ok() {
eprintln!("🔄 Saved config via API: {:?}", *config);
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.");
}
}
HttpResponse::Ok().json(&*config)
} else {
HttpResponse::InternalServerError().json(serde_json::json!({ "status": "error", "message": "Failed to write config.yml" }))
log::error!("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,
});
println!("🚀 Starting API server at http://0.0.0.0:8080");
log::info!("🚀 Starting API server at http://0.0.0.0:8080");
HttpServer::new(move || {
App::new()
@ -147,6 +209,9 @@ 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"))
})
@ -180,7 +245,7 @@ mod tests {
lock_count: 10,
free_count: 1,
offset_history: VecDeque::from(vec![1, 2, 3]),
clock_delta_history: VecDeque::from(vec![4, 5, 6]),
ewma_clock_delta: Some(5.0),
last_match_status: "IN SYNC".to_string(),
last_match_check: Utc::now().timestamp(),
}
@ -191,11 +256,16 @@ 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 {
hours: 0, minutes: 0, seconds: 0, frames: 0
}
timeturner_offset: TimeturnerOffset::default(),
default_nudge_ms: 2,
auto_sync_enabled: false,
}));
web::Data::new(AppState { ltc_state, config })
let log_buffer = Arc::new(Mutex::new(VecDeque::new()));
web::Data::new(AppState {
ltc_state,
config,
log_buffer,
})
}
#[actix_web::test]
@ -253,7 +323,9 @@ mod tests {
let new_config_json = serde_json::json!({
"hardwareOffsetMs": 55,
"timeturnerOffset": { "hours": 1, "minutes": 2, "seconds": 3, "frames": 4 }
"defaultNudgeMs": 2,
"autoSyncEnabled": true,
"timeturnerOffset": { "hours": 1, "minutes": 2, "seconds": 3, "frames": 4, "milliseconds": 5 }
});
let req = test::TestRequest::post()
@ -264,16 +336,22 @@ 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);

View file

@ -19,11 +19,17 @@ 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.hours != 0
|| self.minutes != 0
|| self.seconds != 0
|| self.frames != 0
|| self.milliseconds != 0
}
}
@ -33,6 +39,14 @@ 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 {
@ -46,7 +60,7 @@ impl Config {
return Self::default();
}
serde_yaml::from_str(&contents).unwrap_or_else(|e| {
eprintln!("Failed to parse config, using default: {}", e);
log::warn!("Failed to parse config, using default: {}", e);
Self::default()
})
}
@ -57,13 +71,35 @@ 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 contents = serde_yaml::to_string(config)?;
fs::write(path, contents)?;
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)?;
Ok(())
}
@ -82,7 +118,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;
eprintln!("🔄 Reloaded config.yml: {:?}", *cfg);
log::info!("🔄 Reloaded config.yml: {:?}", *cfg);
}
}
})

52
src/logger.rs Normal file
View file

@ -0,0 +1,52 @@
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
}

View file

@ -2,6 +2,7 @@
mod api;
mod config;
mod logger;
mod serial_input;
mod sync_logic;
mod system;
@ -14,7 +15,6 @@ use crate::sync_logic::LtcState;
use crate::ui::start_ui;
use clap::Parser;
use daemonize::Daemonize;
use env_logger;
use std::{
fs,
@ -42,6 +42,14 @@ 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:
@ -49,6 +57,7 @@ timeturnerOffset:
minutes: 0
seconds: 0
frames: 0
milliseconds: 0
"#;
/// If no `config.yml` exists alongside the binary, write out the default.
@ -57,16 +66,18 @@ fn ensure_config() {
if !p.exists() {
fs::write(p, DEFAULT_CONFIG.trim())
.expect("Failed to write default config.yml");
eprintln!("⚙️ Emitted default config.yml");
log::info!("⚙️ 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 {
println!("🚀 Starting daemon...");
log::info!("🚀 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");
@ -81,7 +92,7 @@ async fn main() {
match daemonize.start() {
Ok(_) => { /* Process is now daemonized */ }
Err(e) => {
eprintln!("Error daemonizing: {}", e);
log::error!("Error daemonizing: {}", e);
return; // Exit if daemonization fails
}
}
@ -116,9 +127,9 @@ async fn main() {
// 5⃣ Spawn UI or setup daemon logging
if args.command.is_none() {
println!("🔧 Watching config.yml...");
println!("🚀 Serial thread launched");
println!("🖥️ UI thread launched");
log::info!("🔧 Watching config.yml...");
log::info!("🚀 Serial thread launched");
log::info!("🖥️ UI thread launched");
let ui_state = ltc_state.clone();
let config_clone = config.clone();
let port = "/dev/ttyACM0".to_string();
@ -126,41 +137,127 @@ async fn main() {
start_ui(ui_state, port, config_clone);
});
} else {
// 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();
// In daemon mode, logging is already set up to go to stderr.
// The systemd service will capture it.
log::info!("🚀 Starting TimeTurner daemon...");
}
// 6⃣ Set up a LocalSet for the API server and main loop
// 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
let local = LocalSet::new();
local
.run_until(async move {
// 7⃣ Spawn the API server thread
// 8️⃣ 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).await {
eprintln!("API server error: {}", e);
if let Err(e) =
start_api_server(api_state, config_clone, log_buffer_clone).await
{
log::error!("API server error: {}", e);
}
});
}
// 8⃣ Keep main thread alive
// 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
if args.command.is_some() {
// In daemon mode, wait forever.
// In daemon mode, wait forever. The logic_task runs in the background.
std::future::pending::<()>().await;
} else {
// In TUI mode, block on the channel.
println!("📡 Main thread entering loop...");
let _ = task::spawn_blocking(move || {
for _frame in rx {
// no-op
}
})
.await;
// 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;
}
})
.await;
@ -172,18 +269,35 @@ mod tests {
use std::fs;
use std::path::Path;
/// RAII guard to ensure config file is cleaned up after test.
struct ConfigGuard;
/// 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(),
}
}
}
impl Drop for ConfigGuard {
fn drop(&mut self) {
let _ = fs::remove_file("config.yml");
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");
}
}
}
#[test]
fn test_ensure_config() {
let _guard = ConfigGuard; // Cleanup when _guard goes out of scope.
let _guard = ConfigGuard::new(); // Cleanup when _guard goes out of scope.
// --- Test 1: File creation ---
// Pre-condition: config.yml does not exist.

View file

@ -3,6 +3,8 @@ 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,
@ -42,8 +44,8 @@ pub struct LtcState {
pub free_count: u32,
/// Stores the last up-to-20 raw offset measurements in ms.
pub offset_history: VecDeque<i64>,
/// Stores the last up-to-20 timecode Δ measurements in ms.
pub clock_delta_history: VecDeque<i64>,
/// EWMA of clock delta.
pub ewma_clock_delta: Option<f64>,
pub last_match_status: String,
pub last_match_check: i64,
}
@ -55,7 +57,7 @@ impl LtcState {
lock_count: 0,
free_count: 0,
offset_history: VecDeque::with_capacity(20),
clock_delta_history: VecDeque::with_capacity(20),
ewma_clock_delta: None,
last_match_status: "UNKNOWN".into(),
last_match_check: 0,
}
@ -69,12 +71,14 @@ impl LtcState {
self.offset_history.push_back(offset_ms);
}
/// 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();
/// 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);
}
self.clock_delta_history.push_back(delta_ms);
}
/// Clear all stored jitter measurements.
@ -82,11 +86,6 @@ 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() {
@ -108,7 +107,7 @@ impl LtcState {
"FREE" => {
self.free_count += 1;
self.clear_offsets();
self.clear_clock_deltas();
self.ewma_clock_delta = None;
self.last_match_status = "UNKNOWN".into();
}
_ => {}
@ -137,23 +136,9 @@ impl LtcState {
}
}
/// 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]
}
/// 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)
}
/// Percentage of samples seen in LOCK state versus total.
@ -175,6 +160,8 @@ 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 {
@ -326,35 +313,28 @@ mod tests {
}
#[test]
fn test_average_clock_delta_is_median() {
fn test_ewma_clock_delta() {
let mut state = LtcState::new();
assert_eq!(state.get_ewma_clock_delta(), 0);
// Establish a stable set of values
for _ in 0..19 {
state.record_clock_delta(2);
}
state.record_clock_delta(100); // Add an outlier
// First value initializes the EWMA
state.record_and_update_ewma_clock_delta(100);
assert_eq!(state.get_ewma_clock_delta(), 100);
// 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"
);
// 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);
// 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"
);
// 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]
@ -369,8 +349,12 @@ mod tests {
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 };
// 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 };
assert_eq!(get_sync_status(0, &config), "TIMETURNING");
assert_eq!(get_sync_status(100, &config), "TIMETURNING");
}

View file

@ -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)
dt_local + ChronoDuration::milliseconds(frame_offset_ms + offset.milliseconds)
}
pub fn trigger_sync(frame: &LtcFrame, config: &Config) -> Result<String, ()> {
@ -104,6 +104,60 @@ 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::*;
@ -150,6 +204,7 @@ mod tests {
minutes: 5,
seconds: 10,
frames: 12, // 12 frames at 25fps is 480ms
milliseconds: 20,
};
let target_time = calculate_target_time(&frame, &config);
@ -157,8 +212,8 @@ mod tests {
assert_eq!(target_time.hour(), 11);
assert_eq!(target_time.minute(), 25);
assert_eq!(target_time.second(), 40);
// 480ms
assert_eq!(target_time.nanosecond(), 480_000_000);
// 480ms + 20ms = 500ms
assert_eq!(target_time.nanosecond(), 500_000_000);
}
#[test]
@ -170,13 +225,20 @@ 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(), 20);
assert_eq!(target_time.nanosecond(), 0);
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());
}
}

View file

@ -9,7 +9,6 @@ use std::collections::VecDeque;
use chrono::{
DateTime, Local, Timelike, Utc,
NaiveTime, TimeZone, Duration as ChronoDuration,
};
use crossterm::{
cursor::{Hide, MoveTo, Show},
@ -35,7 +34,6 @@ 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;
@ -54,7 +52,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() {
@ -64,33 +62,6 @@ 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();
}
}
}
@ -103,7 +74,7 @@ pub fn start_ui(
st.average_frames(),
st.timecode_match().to_string(),
st.lock_ratio(),
st.average_clock_delta(),
st.get_ewma_clock_delta(),
)
};
@ -122,28 +93,7 @@ pub fn start_ui(
// 6⃣ sync status wording
let sync_status = get_sync_status(cached_delta_ms, &cfg);
// 7⃣ autosync (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!("🔄 Autosynced to LTC: {}", ts),
Err(_) => "❌ Autosync 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
// 7⃣ header & LTC metrics display
{
let st = state.lock().unwrap();
let opt = st.latest.as_ref();
@ -279,10 +229,3 @@ pub fn start_ui(
}
}
#[cfg(test)]
mod tests {
#[allow(unused_imports)]
use super::*;
#[allow(unused_imports)]
use crate::config::TimeturnerOffset;
}

View file

@ -23,6 +23,7 @@
<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>
@ -50,17 +51,58 @@
<input type="number" id="hw-offset" name="hw-offset">
</div>
<div class="control-group">
<label>Timeturner Offset:</label>
<input type="number" id="offset-h" placeholder="H">
<input type="number" id="offset-m" placeholder="M">
<input type="number" id="offset-s" placeholder="S">
<input type="number" id="offset-f" placeholder="F">
<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>
</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>

View file

@ -1,36 +1,53 @@
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';
@ -39,7 +56,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();
@ -57,14 +74,75 @@ 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;
}
}
@ -74,10 +152,13 @@ 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);
}
@ -86,11 +167,14 @@ 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,
frames: parseInt(offsetInputs.f.value, 10) || 0,
milliseconds: parseInt(offsetInputs.ms.value, 10) || 0,
}
};
@ -108,6 +192,20 @@ 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 {
@ -125,13 +223,75 @@ 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
});