mirror of
https://github.com/cjfranko/NTP-Timeturner.git
synced 2025-11-08 18:32:02 +00:00
Compare commits
12 commits
a12ee88b9b
...
992720041b
| Author | SHA1 | Date | |
|---|---|---|---|
| 992720041b | |||
| 68dc16344a | |||
| 9a97027870 | |||
| d015794b03 | |||
| 4cb421b3d6 | |||
| c0613c3682 | |||
| fcbd5bd647 | |||
| f929bacdfd | |||
| 89849c6e04 | |||
| 4090fee0a6 | |||
| fb8088c704 | |||
| c712014bb9 |
9 changed files with 196 additions and 54 deletions
19
config.yml
19
config.yml
|
|
@ -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
|
||||
|
|
|
|||
10
src/api.rs
10
src/api.rs
|
|
@ -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,7 +304,8 @@ mod tests {
|
|||
let new_config_json = serde_json::json!({
|
||||
"hardwareOffsetMs": 55,
|
||||
"defaultNudgeMs": 2,
|
||||
"timeturnerOffset": { "hours": 1, "minutes": 2, "seconds": 3, "frames": 4 }
|
||||
"autoSyncEnabled": true,
|
||||
"timeturnerOffset": { "hours": 1, "minutes": 2, "seconds": 3, "frames": 4, "milliseconds": 5 }
|
||||
});
|
||||
|
||||
let req = test::TestRequest::post()
|
||||
|
|
@ -314,16 +316,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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -35,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 {
|
||||
|
|
@ -64,13 +72,34 @@ impl Default for Config {
|
|||
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(())
|
||||
}
|
||||
|
||||
|
|
|
|||
107
src/main.rs
107
src/main.rs
|
|
@ -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
|
||||
|
||||
# Default nudge in milliseconds for adjtimex control.
|
||||
defaultNudgeMs: 2
|
||||
|
||||
|
|
@ -52,6 +57,7 @@ timeturnerOffset:
|
|||
minutes: 0
|
||||
seconds: 0
|
||||
frames: 0
|
||||
milliseconds: 0
|
||||
"#;
|
||||
|
||||
/// If no `config.yml` exists alongside the binary, write out the default.
|
||||
|
|
@ -136,11 +142,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();
|
||||
|
|
@ -154,7 +228,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 || {
|
||||
|
|
@ -175,7 +249,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;
|
||||
|
|
@ -195,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.
|
||||
|
|
|
|||
|
|
@ -348,7 +348,7 @@ mod tests {
|
|||
assert_eq!(get_sync_status(-100, &config), "CLOCK BEHIND");
|
||||
|
||||
// Test TIMETURNING status
|
||||
config.timeturner_offset = TimeturnerOffset { hours: 1, minutes: 0, seconds: 0, frames: 0 };
|
||||
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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, ()> {
|
||||
|
|
@ -177,6 +177,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);
|
||||
|
|
@ -184,8 +185,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]
|
||||
|
|
@ -197,14 +198,15 @@ 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]
|
||||
|
|
|
|||
24
src/ui.rs
24
src/ui.rs
|
|
@ -34,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;
|
||||
|
|
@ -94,28 +93,7 @@ pub fn start_ui(
|
|||
// 6️⃣ sync status wording
|
||||
let sync_status = get_sync_status(cached_delta_ms, &cfg);
|
||||
|
||||
// 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
|
||||
// 7️⃣ header & LTC metrics display
|
||||
{
|
||||
let st = state.lock().unwrap();
|
||||
let opt = st.latest.as_ref();
|
||||
|
|
|
|||
|
|
@ -50,14 +50,33 @@
|
|||
<input type="number" id="hw-offset" name="hw-offset">
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label>Timeturner Offset:, HH:MM:SS:f</label>
|
||||
<input type="number" id="offset-h" placeholder="H">
|
||||
<label>:</label>
|
||||
<input type="number" id="offset-m" placeholder="M">
|
||||
<label>:</label>
|
||||
<input type="number" id="offset-s" placeholder="S">
|
||||
<label>.</label>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -15,11 +15,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
};
|
||||
|
||||
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');
|
||||
|
|
@ -80,10 +82,12 @@ 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);
|
||||
|
|
@ -93,12 +97,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,
|
||||
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,
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue