Compare commits

..

No commits in common. "992720041bc9f59ce806b44091b96c5fc42837e1" and "a12ee88b9b370a909dbb20eece9171136bb89023" have entirely different histories.

9 changed files with 54 additions and 196 deletions

View file

@ -1,19 +1,10 @@
# Hardware offset in milliseconds for correcting capture latency. # Hardware offset in milliseconds for correcting capture latency.
hardwareOffsetMs: 55 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: true
# Default nudge in milliseconds for adjtimex control.
defaultNudgeMs: 2
# 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:
hours: 1 hours: 0
minutes: 2 minutes: 0
seconds: 3 seconds: 0
frames: 4 frames: 0
milliseconds: 5

View file

@ -238,7 +238,6 @@ 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 {
@ -304,8 +303,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 }
"timeturnerOffset": { "hours": 1, "minutes": 2, "seconds": 3, "frames": 4, "milliseconds": 5 }
}); });
let req = test::TestRequest::post() let req = test::TestRequest::post()
@ -316,22 +314,16 @@ 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);
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);
// Test that the file was written // Test that the file was written
assert!(fs::metadata(config_path).is_ok()); assert!(fs::metadata(config_path).is_ok());
let contents = fs::read_to_string(config_path).unwrap(); let contents = fs::read_to_string(config_path).unwrap();
assert!(contents.contains("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"));
// Cleanup // Cleanup
let _ = fs::remove_file(config_path); let _ = fs::remove_file(config_path);

View file

@ -19,17 +19,11 @@ pub struct TimeturnerOffset {
pub minutes: i64, pub minutes: i64,
pub seconds: i64, pub seconds: i64,
pub frames: i64, pub frames: i64,
#[serde(default)]
pub milliseconds: i64,
} }
impl TimeturnerOffset { impl TimeturnerOffset {
pub fn is_active(&self) -> bool { pub fn is_active(&self) -> bool {
self.hours != 0 self.hours != 0 || self.minutes != 0 || self.seconds != 0 || self.frames != 0
|| self.minutes != 0
|| self.seconds != 0
|| self.frames != 0
|| self.milliseconds != 0
} }
} }
@ -41,8 +35,6 @@ 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 {
@ -72,34 +64,13 @@ 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,
} }
} }
} }
pub fn save_config(path: &str, config: &Config) -> Result<(), Box<dyn std::error::Error>> { pub fn save_config(path: &str, config: &Config) -> Result<(), Box<dyn std::error::Error>> {
let mut s = String::new(); let contents = serde_yaml::to_string(config)?;
s.push_str("# Hardware offset in milliseconds for correcting capture latency.\n"); fs::write(path, contents)?;
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(()) Ok(())
} }

View file

@ -42,11 +42,6 @@ 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
# Default nudge in milliseconds for adjtimex control. # Default nudge in milliseconds for adjtimex control.
defaultNudgeMs: 2 defaultNudgeMs: 2
@ -57,7 +52,6 @@ timeturnerOffset:
minutes: 0 minutes: 0
seconds: 0 seconds: 0
frames: 0 frames: 0
milliseconds: 0
"#; "#;
/// If no `config.yml` exists alongside the binary, write out the default. /// If no `config.yml` exists alongside the binary, write out the default.
@ -142,79 +136,11 @@ async fn main() {
log::info!("🚀 Starting TimeTurner daemon..."); log::info!("🚀 Starting TimeTurner daemon...");
} }
// 6⃣ Spawn the auto-sync thread // 6⃣ Set up a LocalSet for the API server and main loop
{
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 {
// 8️⃣ Spawn the API server thread // 7⃣ 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();
@ -228,7 +154,7 @@ async fn main() {
}); });
} }
// 9️⃣ Main logic loop: process frames from serial and update state // 8️⃣ 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 || {
@ -249,7 +175,7 @@ async fn main() {
} }
}); });
// 1⃣0️⃣ Keep main thread alive // 9️⃣ 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;
@ -269,35 +195,18 @@ mod tests {
use std::fs; use std::fs;
use std::path::Path; use std::path::Path;
/// RAII guard to manage config file during tests. /// RAII guard to ensure config file is cleaned up after test.
/// It saves the original content of `config.yml` if it exists, struct ConfigGuard;
/// 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 { impl Drop for ConfigGuard {
fn drop(&mut self) { fn drop(&mut self) {
if let Some(content) = &self.original_content { let _ = fs::remove_file("config.yml");
fs::write("config.yml", content).expect("Failed to restore config.yml");
} else {
let _ = fs::remove_file("config.yml");
}
} }
} }
#[test] #[test]
fn test_ensure_config() { 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 --- // --- Test 1: File creation ---
// Pre-condition: config.yml does not exist. // Pre-condition: config.yml does not exist.

View file

@ -348,7 +348,7 @@ mod tests {
assert_eq!(get_sync_status(-100, &config), "CLOCK BEHIND"); assert_eq!(get_sync_status(-100, &config), "CLOCK BEHIND");
// Test TIMETURNING status // Test TIMETURNING status
config.timeturner_offset = TimeturnerOffset { hours: 1, minutes: 0, seconds: 0, frames: 0, milliseconds: 0 }; 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(0, &config), "TIMETURNING");
assert_eq!(get_sync_status(100, &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); + ChronoDuration::seconds(offset.seconds);
// Frame offset needs to be converted to milliseconds // Frame offset needs to be converted to milliseconds
let frame_offset_ms = (offset.frames as f64 / frame.frame_rate * 1000.0).round() as i64; 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, ()> { pub fn trigger_sync(frame: &LtcFrame, config: &Config) -> Result<String, ()> {
@ -177,7 +177,6 @@ mod tests {
minutes: 5, minutes: 5,
seconds: 10, seconds: 10,
frames: 12, // 12 frames at 25fps is 480ms frames: 12, // 12 frames at 25fps is 480ms
milliseconds: 20,
}; };
let target_time = calculate_target_time(&frame, &config); let target_time = calculate_target_time(&frame, &config);
@ -185,8 +184,8 @@ mod tests {
assert_eq!(target_time.hour(), 11); assert_eq!(target_time.hour(), 11);
assert_eq!(target_time.minute(), 25); assert_eq!(target_time.minute(), 25);
assert_eq!(target_time.second(), 40); assert_eq!(target_time.second(), 40);
// 480ms + 20ms = 500ms // 480ms
assert_eq!(target_time.nanosecond(), 500_000_000); assert_eq!(target_time.nanosecond(), 480_000_000);
} }
#[test] #[test]
@ -198,15 +197,14 @@ mod tests {
minutes: -5, minutes: -5,
seconds: -10, seconds: -10,
frames: -12, // -480ms frames: -12, // -480ms
milliseconds: -80,
}; };
let target_time = calculate_target_time(&frame, &config); let target_time = calculate_target_time(&frame, &config);
assert_eq!(target_time.hour(), 9); assert_eq!(target_time.hour(), 9);
assert_eq!(target_time.minute(), 15); assert_eq!(target_time.minute(), 15);
assert_eq!(target_time.second(), 19); assert_eq!(target_time.second(), 20);
assert_eq!(target_time.nanosecond(), 920_000_000); assert_eq!(target_time.nanosecond(), 0);
} }
#[test] #[test]

View file

@ -34,6 +34,7 @@ pub fn start_ui(
terminal::enable_raw_mode().unwrap(); terminal::enable_raw_mode().unwrap();
let mut logs: VecDeque<String> = VecDeque::with_capacity(10); 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 last_delta_update = Instant::now() - Duration::from_secs(1);
let mut cached_delta_ms: i64 = 0; let mut cached_delta_ms: i64 = 0;
let mut cached_delta_frames: i64 = 0; let mut cached_delta_frames: i64 = 0;
@ -93,7 +94,28 @@ 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⃣ header & LTC metrics display // 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
{ {
let st = state.lock().unwrap(); let st = state.lock().unwrap();
let opt = st.latest.as_ref(); let opt = st.latest.as_ref();

View file

@ -50,33 +50,14 @@
<input type="number" id="hw-offset" name="hw-offset"> <input type="number" id="hw-offset" name="hw-offset">
</div> </div>
<div class="control-group"> <div class="control-group">
<input type="checkbox" id="auto-sync-enabled" name="auto-sync-enabled" style="vertical-align: middle;"> <label>Timeturner Offset:, HH:MM:SS:f</label>
<label for="auto-sync-enabled" style="vertical-align: middle;">Enable Auto Sync</label> <input type="number" id="offset-h" placeholder="H">
</div> <label>:</label>
<div class="control-group"> <input type="number" id="offset-m" placeholder="M">
<label>Timeturner Offset</label> <label>:</label>
<div style="display: flex; flex-wrap: wrap; gap: 1rem; align-items: flex-start;"> <input type="number" id="offset-s" placeholder="S">
<div style="display: flex; flex-direction: column;"> <label>.</label>
<label for="offset-h">Hours</label> <input type="number" id="offset-f" placeholder="F">
<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>
<div class="control-group"> <div class="control-group">
<button id="save-config">Save Config</button> <button id="save-config">Save Config</button>

View file

@ -15,13 +15,11 @@ 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'),
s: document.getElementById('offset-s'), s: document.getElementById('offset-s'),
f: document.getElementById('offset-f'), f: document.getElementById('offset-f'),
ms: document.getElementById('offset-ms'),
}; };
const saveConfigButton = document.getElementById('save-config'); const saveConfigButton = document.getElementById('save-config');
const manualSyncButton = document.getElementById('manual-sync'); const manualSyncButton = document.getElementById('manual-sync');
@ -82,12 +80,10 @@ 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;
offsetInputs.f.value = data.timeturnerOffset.frames; offsetInputs.f.value = data.timeturnerOffset.frames;
offsetInputs.ms.value = data.timeturnerOffset.milliseconds || 0;
nudgeValueInput.value = data.defaultNudgeMs; nudgeValueInput.value = data.defaultNudgeMs;
} catch (error) { } catch (error) {
console.error('Error fetching config:', error); console.error('Error fetching config:', error);
@ -97,14 +93,12 @@ 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,
minutes: parseInt(offsetInputs.m.value, 10) || 0, minutes: parseInt(offsetInputs.m.value, 10) || 0,
seconds: parseInt(offsetInputs.s.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,
} }
}; };