feat: implement auto-sync with periodic clock nudging

Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
This commit is contained in:
Chaos Rogers 2025-07-29 14:18:10 +01:00
parent 4cb421b3d6
commit d015794b03
6 changed files with 93 additions and 26 deletions

View file

@ -238,6 +238,7 @@ mod tests {
hardware_offset_ms: 10,
timeturner_offset: TimeturnerOffset::default(),
default_nudge_ms: 2,
auto_sync_enabled: false,
}));
let log_buffer = Arc::new(Mutex::new(VecDeque::new()));
web::Data::new(AppState {
@ -303,6 +304,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 }
});
@ -314,10 +316,12 @@ 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);
@ -325,6 +329,7 @@ mod tests {
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"));

View file

@ -41,6 +41,8 @@ pub struct Config {
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 {
@ -70,6 +72,7 @@ impl Default for Config {
hardware_offset_ms: 0,
timeturner_offset: TimeturnerOffset::default(),
default_nudge_ms: default_nudge_ms(),
auto_sync_enabled: false,
}
}
}

View file

@ -42,6 +42,11 @@ 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
# Time-turning offsets. All values are added to the incoming LTC time.
# These can be positive or negative.
timeturnerOffset:
@ -133,11 +138,79 @@ async fn main() {
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();
@ -151,7 +224,7 @@ async fn main() {
});
}
// 8️⃣ Main logic loop: process frames from serial and update state
// 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 || {
@ -172,7 +245,7 @@ async fn main() {
}
});
// 9️⃣ Keep main thread alive
// 1⃣0️⃣ Keep main thread alive
if args.command.is_some() {
// In daemon mode, wait forever. The logic_task runs in the background.
std::future::pending::<()>().await;

View file

@ -94,28 +94,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();