mirror of
https://github.com/cjfranko/NTP-Timeturner.git
synced 2025-11-08 18:32:02 +00:00
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:
parent
4cb421b3d6
commit
d015794b03
6 changed files with 93 additions and 26 deletions
|
|
@ -238,6 +238,7 @@ mod tests {
|
||||||
hardware_offset_ms: 10,
|
hardware_offset_ms: 10,
|
||||||
timeturner_offset: TimeturnerOffset::default(),
|
timeturner_offset: TimeturnerOffset::default(),
|
||||||
default_nudge_ms: 2,
|
default_nudge_ms: 2,
|
||||||
|
auto_sync_enabled: false,
|
||||||
}));
|
}));
|
||||||
let log_buffer = Arc::new(Mutex::new(VecDeque::new()));
|
let log_buffer = Arc::new(Mutex::new(VecDeque::new()));
|
||||||
web::Data::new(AppState {
|
web::Data::new(AppState {
|
||||||
|
|
@ -303,6 +304,7 @@ mod tests {
|
||||||
let new_config_json = serde_json::json!({
|
let new_config_json = serde_json::json!({
|
||||||
"hardwareOffsetMs": 55,
|
"hardwareOffsetMs": 55,
|
||||||
"defaultNudgeMs": 2,
|
"defaultNudgeMs": 2,
|
||||||
|
"autoSyncEnabled": true,
|
||||||
"timeturnerOffset": { "hours": 1, "minutes": 2, "seconds": 3, "frames": 4, "milliseconds": 5 }
|
"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;
|
let resp: Config = test::call_and_read_body_json(&app, req).await;
|
||||||
|
|
||||||
assert_eq!(resp.hardware_offset_ms, 55);
|
assert_eq!(resp.hardware_offset_ms, 55);
|
||||||
|
assert_eq!(resp.auto_sync_enabled, true);
|
||||||
assert_eq!(resp.timeturner_offset.hours, 1);
|
assert_eq!(resp.timeturner_offset.hours, 1);
|
||||||
assert_eq!(resp.timeturner_offset.milliseconds, 5);
|
assert_eq!(resp.timeturner_offset.milliseconds, 5);
|
||||||
let final_config = app_state.config.lock().unwrap();
|
let final_config = app_state.config.lock().unwrap();
|
||||||
assert_eq!(final_config.hardware_offset_ms, 55);
|
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.hours, 1);
|
||||||
assert_eq!(final_config.timeturner_offset.milliseconds, 5);
|
assert_eq!(final_config.timeturner_offset.milliseconds, 5);
|
||||||
|
|
||||||
|
|
@ -325,6 +329,7 @@ mod tests {
|
||||||
assert!(fs::metadata(config_path).is_ok());
|
assert!(fs::metadata(config_path).is_ok());
|
||||||
let contents = fs::read_to_string(config_path).unwrap();
|
let contents = fs::read_to_string(config_path).unwrap();
|
||||||
assert!(contents.contains("hardwareOffsetMs: 55"));
|
assert!(contents.contains("hardwareOffsetMs: 55"));
|
||||||
|
assert!(contents.contains("autoSyncEnabled: true"));
|
||||||
assert!(contents.contains("hours: 1"));
|
assert!(contents.contains("hours: 1"));
|
||||||
assert!(contents.contains("milliseconds: 5"));
|
assert!(contents.contains("milliseconds: 5"));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,8 @@ pub struct Config {
|
||||||
pub timeturner_offset: TimeturnerOffset,
|
pub timeturner_offset: TimeturnerOffset,
|
||||||
#[serde(default = "default_nudge_ms")]
|
#[serde(default = "default_nudge_ms")]
|
||||||
pub default_nudge_ms: i64,
|
pub default_nudge_ms: i64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub auto_sync_enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_nudge_ms() -> i64 {
|
fn default_nudge_ms() -> i64 {
|
||||||
|
|
@ -70,6 +72,7 @@ impl Default for Config {
|
||||||
hardware_offset_ms: 0,
|
hardware_offset_ms: 0,
|
||||||
timeturner_offset: TimeturnerOffset::default(),
|
timeturner_offset: TimeturnerOffset::default(),
|
||||||
default_nudge_ms: default_nudge_ms(),
|
default_nudge_ms: default_nudge_ms(),
|
||||||
|
auto_sync_enabled: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
81
src/main.rs
81
src/main.rs
|
|
@ -42,6 +42,11 @@ const DEFAULT_CONFIG: &str = r#"
|
||||||
# Hardware offset in milliseconds for correcting capture latency.
|
# Hardware offset in milliseconds for correcting capture latency.
|
||||||
hardwareOffsetMs: 20
|
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.
|
# Time-turning offsets. All values are added to the incoming LTC time.
|
||||||
# These can be positive or negative.
|
# These can be positive or negative.
|
||||||
timeturnerOffset:
|
timeturnerOffset:
|
||||||
|
|
@ -133,11 +138,79 @@ async fn main() {
|
||||||
log::info!("🚀 Starting TimeTurner daemon...");
|
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();
|
let local = LocalSet::new();
|
||||||
local
|
local
|
||||||
.run_until(async move {
|
.run_until(async move {
|
||||||
// 7️⃣ Spawn the API server thread
|
// 8️⃣ Spawn the API server thread
|
||||||
{
|
{
|
||||||
let api_state = ltc_state.clone();
|
let api_state = ltc_state.clone();
|
||||||
let config_clone = config.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_state = ltc_state.clone();
|
||||||
let loop_config = config.clone();
|
let loop_config = config.clone();
|
||||||
let logic_task = task::spawn_blocking(move || {
|
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() {
|
if args.command.is_some() {
|
||||||
// In daemon mode, wait forever. The logic_task runs in the background.
|
// In daemon mode, wait forever. The logic_task runs in the background.
|
||||||
std::future::pending::<()>().await;
|
std::future::pending::<()>().await;
|
||||||
|
|
|
||||||
23
src/ui.rs
23
src/ui.rs
|
|
@ -94,28 +94,7 @@ pub fn start_ui(
|
||||||
// 6️⃣ sync status wording
|
// 6️⃣ sync status wording
|
||||||
let sync_status = get_sync_status(cached_delta_ms, &cfg);
|
let sync_status = get_sync_status(cached_delta_ms, &cfg);
|
||||||
|
|
||||||
// 7️⃣ auto‑sync (same as manual but delayed)
|
// 7️⃣ header & LTC metrics display
|
||||||
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 st = state.lock().unwrap();
|
||||||
let opt = st.latest.as_ref();
|
let opt = st.latest.as_ref();
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,10 @@
|
||||||
<label for="hw-offset">Hardware Offset (ms):</label>
|
<label for="hw-offset">Hardware Offset (ms):</label>
|
||||||
<input type="number" id="hw-offset" name="hw-offset">
|
<input type="number" id="hw-offset" name="hw-offset">
|
||||||
</div>
|
</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">
|
<div class="control-group">
|
||||||
<label>Timeturner Offset</label>
|
<label>Timeturner Offset</label>
|
||||||
<div style="display: flex; flex-wrap: wrap; gap: 1rem; align-items: flex-start;">
|
<div style="display: flex; flex-wrap: wrap; gap: 1rem; align-items: flex-start;">
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const hwOffsetInput = document.getElementById('hw-offset');
|
const hwOffsetInput = document.getElementById('hw-offset');
|
||||||
|
const autoSyncCheckbox = document.getElementById('auto-sync-enabled');
|
||||||
const offsetInputs = {
|
const offsetInputs = {
|
||||||
h: document.getElementById('offset-h'),
|
h: document.getElementById('offset-h'),
|
||||||
m: document.getElementById('offset-m'),
|
m: document.getElementById('offset-m'),
|
||||||
|
|
@ -81,6 +82,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
if (!response.ok) throw new Error('Failed to fetch config');
|
if (!response.ok) throw new Error('Failed to fetch config');
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
hwOffsetInput.value = data.hardwareOffsetMs;
|
hwOffsetInput.value = data.hardwareOffsetMs;
|
||||||
|
autoSyncCheckbox.checked = data.autoSyncEnabled;
|
||||||
offsetInputs.h.value = data.timeturnerOffset.hours;
|
offsetInputs.h.value = data.timeturnerOffset.hours;
|
||||||
offsetInputs.m.value = data.timeturnerOffset.minutes;
|
offsetInputs.m.value = data.timeturnerOffset.minutes;
|
||||||
offsetInputs.s.value = data.timeturnerOffset.seconds;
|
offsetInputs.s.value = data.timeturnerOffset.seconds;
|
||||||
|
|
@ -95,6 +97,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
async function saveConfig() {
|
async function saveConfig() {
|
||||||
const config = {
|
const config = {
|
||||||
hardwareOffsetMs: parseInt(hwOffsetInput.value, 10) || 0,
|
hardwareOffsetMs: parseInt(hwOffsetInput.value, 10) || 0,
|
||||||
|
autoSyncEnabled: autoSyncCheckbox.checked,
|
||||||
defaultNudgeMs: parseInt(nudgeValueInput.value, 10) || 0,
|
defaultNudgeMs: parseInt(nudgeValueInput.value, 10) || 0,
|
||||||
timeturnerOffset: {
|
timeturnerOffset: {
|
||||||
hours: parseInt(offsetInputs.h.value, 10) || 0,
|
hours: parseInt(offsetInputs.h.value, 10) || 0,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue