Merge pull request #29 from cjfranko/non-fractional-mismatch-tc
fixed some sync issues, fractional still an issue at 29.97 NDF Drift issues with all fractionals, 29.97NDF has a system clock sync issue
|
|
@ -8,13 +8,13 @@ This document describes the HTTP API for the NTP Timeturner application.
|
|||
|
||||
- **`GET /api/status`**
|
||||
|
||||
Retrieves the real-time status of the LTC reader and system clock synchronization.
|
||||
Retrieves the real-time status of the LTC reader and system clock synchronization. The `ltc_timecode` field uses `:` as a separator for non-drop-frame timecode, and `;` for drop-frame timecode between seconds and frames (e.g., `10:20:30;00`).
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"ltc_status": "LOCK",
|
||||
"ltc_timecode": "10:20:30:00",
|
||||
"ltc_timecode": "10:20:30;00",
|
||||
"frame_rate": "25.00fps",
|
||||
"system_clock": "10:20:30.005",
|
||||
"system_date": "2025-07-30",
|
||||
|
|
|
|||
33
src/api.rs
|
|
@ -47,7 +47,11 @@ async fn get_status(data: web::Data<AppState>) -> impl Responder {
|
|||
|
||||
let ltc_status = state.latest.as_ref().map_or("(waiting)".to_string(), |f| f.status.clone());
|
||||
let ltc_timecode = state.latest.as_ref().map_or("…".to_string(), |f| {
|
||||
format!("{:02}:{:02}:{:02}:{:02}", f.hours, f.minutes, f.seconds, f.frames)
|
||||
let sep = if f.is_drop_frame { ';' } else { ':' };
|
||||
format!(
|
||||
"{:02}:{:02}:{:02}{}{:02}",
|
||||
f.hours, f.minutes, f.seconds, sep, f.frames
|
||||
)
|
||||
});
|
||||
let frame_rate = state.latest.as_ref().map_or("…".to_string(), |f| {
|
||||
format!("{:.2}fps", f.frame_rate.to_f64().unwrap_or(0.0))
|
||||
|
|
@ -242,6 +246,7 @@ mod tests {
|
|||
minutes: 2,
|
||||
seconds: 3,
|
||||
frames: 4,
|
||||
is_drop_frame: false,
|
||||
frame_rate: Ratio::new(25, 1),
|
||||
timestamp: Utc::now(),
|
||||
}),
|
||||
|
|
@ -290,6 +295,32 @@ mod tests {
|
|||
assert_eq!(resp.hardware_offset_ms, 10);
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn test_get_status_drop_frame() {
|
||||
let app_state = get_test_app_state();
|
||||
// Set state to drop frame
|
||||
app_state
|
||||
.ltc_state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.latest
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.is_drop_frame = true;
|
||||
|
||||
let app = test::init_service(
|
||||
App::new()
|
||||
.app_data(app_state.clone())
|
||||
.service(get_status),
|
||||
)
|
||||
.await;
|
||||
|
||||
let req = test::TestRequest::get().uri("/api/status").to_request();
|
||||
let resp: ApiStatus = test::call_and_read_body_json(&app, req).await;
|
||||
|
||||
assert_eq!(resp.ltc_timecode, "01:02:03;04");
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn test_get_config() {
|
||||
let app_state = get_test_app_state();
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ impl Config {
|
|||
Self::default()
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
|
|
|
|||
80
src/main.rs
|
|
@ -15,6 +15,7 @@ use crate::sync_logic::LtcState;
|
|||
use crate::ui::start_ui;
|
||||
use clap::Parser;
|
||||
use daemonize::Daemonize;
|
||||
use serialport;
|
||||
|
||||
use std::{
|
||||
fs,
|
||||
|
|
@ -35,6 +36,8 @@ struct Args {
|
|||
enum Command {
|
||||
/// Run as a background daemon providing a web UI.
|
||||
Daemon,
|
||||
/// Stop the running daemon process.
|
||||
Kill,
|
||||
}
|
||||
|
||||
/// Default config content, embedded in the binary.
|
||||
|
|
@ -70,18 +73,36 @@ fn ensure_config() {
|
|||
}
|
||||
}
|
||||
|
||||
fn find_serial_port() -> Option<String> {
|
||||
if let Ok(ports) = serialport::available_ports() {
|
||||
for p in ports {
|
||||
if p.port_name.starts_with("/dev/ttyACM")
|
||||
|| p.port_name.starts_with("/dev/ttyAMA")
|
||||
|| p.port_name.starts_with("/dev/ttyUSB")
|
||||
{
|
||||
return Some(p.port_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[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 {
|
||||
if let Some(command) = &args.command {
|
||||
match command {
|
||||
Command::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");
|
||||
let stderr = fs::File::create("daemon.err").expect("Could not create daemon.err");
|
||||
let stdout =
|
||||
fs::File::create("daemon.out").expect("Could not create daemon.out");
|
||||
let stderr =
|
||||
fs::File::create("daemon.err").expect("Could not create daemon.err");
|
||||
|
||||
let daemonize = Daemonize::new()
|
||||
.pid_file("ntp_timeturner.pid") // Create a PID file
|
||||
|
|
@ -97,6 +118,43 @@ async fn main() {
|
|||
}
|
||||
}
|
||||
}
|
||||
Command::Kill => {
|
||||
log::info!("🛑 Stopping daemon...");
|
||||
let pid_file = "ntp_timeturner.pid";
|
||||
match fs::read_to_string(pid_file) {
|
||||
Ok(pid_str) => {
|
||||
let pid_str = pid_str.trim();
|
||||
log::info!("Found daemon with PID: {}", pid_str);
|
||||
match std::process::Command::new("kill").arg("-9").arg(format!("-{}", pid_str)).status() {
|
||||
Ok(status) => {
|
||||
if status.success() {
|
||||
log::info!("✅ Daemon stopped successfully.");
|
||||
if fs::remove_file(pid_file).is_err() {
|
||||
log::warn!("Could not remove PID file '{}'. It may need to be removed manually.", pid_file);
|
||||
}
|
||||
} else {
|
||||
log::error!("'kill' command failed with status: {}. The daemon may not be running, or you may not have permission to stop it.", status);
|
||||
log::warn!("Attempting to remove stale PID file '{}'...", pid_file);
|
||||
if fs::remove_file(pid_file).is_ok() {
|
||||
log::info!("Removed stale PID file.");
|
||||
} else {
|
||||
log::warn!("Could not remove PID file.");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to execute 'kill' command. Is 'kill' in your PATH? Error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
log::error!("Could not read PID file '{}'. Is the daemon running in this directory?", pid_file);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 🔄 Ensure there's always a config.yml present
|
||||
ensure_config();
|
||||
|
|
@ -110,13 +168,23 @@ async fn main() {
|
|||
// 3️⃣ Shared state for UI and serial reader
|
||||
let ltc_state = Arc::new(Mutex::new(LtcState::new()));
|
||||
|
||||
// 4️⃣ Spawn the serial reader thread
|
||||
// 4️⃣ Find serial port and spawn the serial reader thread
|
||||
let serial_port_path = match find_serial_port() {
|
||||
Some(port) => port,
|
||||
None => {
|
||||
log::error!("❌ No serial port found. Please connect the Teensy device.");
|
||||
return;
|
||||
}
|
||||
};
|
||||
log::info!("Found serial port: {}", serial_port_path);
|
||||
|
||||
{
|
||||
let tx_clone = tx.clone();
|
||||
let state_clone = ltc_state.clone();
|
||||
let port_clone = serial_port_path.clone();
|
||||
thread::spawn(move || {
|
||||
start_serial_thread(
|
||||
"/dev/ttyACM0",
|
||||
&port_clone,
|
||||
115200,
|
||||
tx_clone,
|
||||
state_clone,
|
||||
|
|
@ -132,7 +200,7 @@ async fn main() {
|
|||
log::info!("🖥️ UI thread launched");
|
||||
let ui_state = ltc_state.clone();
|
||||
let config_clone = config.clone();
|
||||
let port = "/dev/ttyACM0".to_string();
|
||||
let port = serial_port_path;
|
||||
thread::spawn(move || {
|
||||
start_ui(ui_state, port, config_clone);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ pub fn start_serial_thread(
|
|||
|
||||
let reader = std::io::BufReader::new(port);
|
||||
let re = Regex::new(
|
||||
r"\[(LOCK|FREE)\]\s+(\d{2}):(\d{2}):(\d{2})[:;](\d{2})\s+\|\s+([\d.]+)fps",
|
||||
r"\[(LOCK|FREE)\]\s+(\d{2}):(\d{2}):(\d{2})([:;])(\d{2})\s+\|\s+([\d.]+)fps",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
|
|
@ -65,7 +65,7 @@ mod tests {
|
|||
|
||||
fn get_ltc_regex() -> Regex {
|
||||
Regex::new(
|
||||
r"\[(LOCK|FREE)\]\s+(\d{2}):(\d{2}):(\d{2})[:;](\d{2})\s+\|\s+([\d.]+)fps",
|
||||
r"\[(LOCK|FREE)\]\s+(\d{2}):(\d{2}):(\d{2})([:;])(\d{2})\s+\|\s+([\d.]+)fps",
|
||||
).unwrap()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ pub struct LtcFrame {
|
|||
pub minutes: u32,
|
||||
pub seconds: u32,
|
||||
pub frames: u32,
|
||||
pub is_drop_frame: bool,
|
||||
pub frame_rate: Ratio<i64>,
|
||||
pub timestamp: DateTime<Utc>, // arrival stamp
|
||||
}
|
||||
|
|
@ -35,8 +36,9 @@ impl LtcFrame {
|
|||
hours: caps[2].parse().ok()?,
|
||||
minutes: caps[3].parse().ok()?,
|
||||
seconds: caps[4].parse().ok()?,
|
||||
frames: caps[5].parse().ok()?,
|
||||
frame_rate: get_frame_rate_ratio(&caps[6])?,
|
||||
is_drop_frame: &caps[5] == ";",
|
||||
frames: caps[6].parse().ok()?,
|
||||
frame_rate: get_frame_rate_ratio(&caps[7])?,
|
||||
timestamp,
|
||||
})
|
||||
}
|
||||
|
|
@ -205,6 +207,7 @@ mod tests {
|
|||
minutes: m,
|
||||
seconds: s,
|
||||
frames: 0,
|
||||
is_drop_frame: false,
|
||||
frame_rate: Ratio::new(25, 1),
|
||||
timestamp: Utc::now(),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,21 +45,13 @@ pub fn calculate_target_time(frame: &LtcFrame, config: &Config) -> DateTime<Loca
|
|||
let timecode_secs =
|
||||
frame.hours as i64 * 3600 + frame.minutes as i64 * 60 + frame.seconds as i64;
|
||||
|
||||
// Total duration in seconds as a rational number, including frames
|
||||
// Timecode is always treated as wall-clock time. NDF scaling is not applied
|
||||
// as the LTC source appears to be pre-compensated.
|
||||
let total_duration_secs =
|
||||
Ratio::new(timecode_secs, 1) + Ratio::new(frame.frames as i64, 1) / frame.frame_rate;
|
||||
|
||||
// For fractional frame rates (23.98, 29.97), timecode runs slower than wall clock.
|
||||
// We need to scale the timecode duration up to get wall clock time.
|
||||
// The scaling factor is 1001/1000.
|
||||
let scaled_duration_secs = if *frame.frame_rate.denom() == 1001 {
|
||||
total_duration_secs * Ratio::new(1001, 1000)
|
||||
} else {
|
||||
total_duration_secs
|
||||
};
|
||||
|
||||
// Convert to milliseconds
|
||||
let total_ms = (scaled_duration_secs * Ratio::new(1000, 1))
|
||||
let total_ms = (total_duration_secs * Ratio::new(1000, 1))
|
||||
.round()
|
||||
.to_integer();
|
||||
|
||||
|
|
@ -157,19 +149,20 @@ pub fn nudge_clock(microseconds: i64) -> Result<(), ()> {
|
|||
pub fn set_date(date: &str) -> Result<(), ()> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let datetime_str = format!("{} 10:00:00", date);
|
||||
let success = Command::new("sudo")
|
||||
.arg("date")
|
||||
.arg("--set")
|
||||
.arg(date)
|
||||
.arg(&datetime_str)
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
|
||||
if success {
|
||||
log::info!("Set system date to {}", date);
|
||||
log::info!("Set system date and time to {}", datetime_str);
|
||||
Ok(())
|
||||
} else {
|
||||
log::error!("Failed to set system date");
|
||||
log::error!("Failed to set system date and time");
|
||||
Err(())
|
||||
}
|
||||
}
|
||||
|
|
@ -196,6 +189,7 @@ mod tests {
|
|||
minutes: m,
|
||||
seconds: s,
|
||||
frames: f,
|
||||
is_drop_frame: false,
|
||||
frame_rate: Ratio::new(25, 1),
|
||||
timestamp: Utc::now(),
|
||||
}
|
||||
|
|
|
|||
BIN
static/assets/FuturaStdHeavy.otf
Normal file
BIN
static/assets/quartz-ms-regular.ttf
Normal file
BIN
static/assets/timeturner_controls.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
static/assets/timeturner_delta_green.png
Normal file
|
After Width: | Height: | Size: 981 B |
BIN
static/assets/timeturner_delta_orange.png
Normal file
|
After Width: | Height: | Size: 955 B |
BIN
static/assets/timeturner_delta_red.png
Normal file
|
After Width: | Height: | Size: 913 B |
BIN
static/assets/timeturner_jitter_green.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
static/assets/timeturner_jitter_orange.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
static/assets/timeturner_jitter_red.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
static/assets/timeturner_logs.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
static/assets/timeturner_ltc_green.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
static/assets/timeturner_ltc_orange.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
static/assets/timeturner_ltc_red.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
static/assets/timeturner_network.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
static/assets/timeturner_ntp_green.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
static/assets/timeturner_ntp_orange.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
static/assets/timeturner_ntp_red.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
static/assets/timeturner_sync_green.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
static/assets/timeturner_sync_orange.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
static/assets/timeturner_sync_red.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
|
|
@ -98,9 +98,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
}
|
||||
|
||||
// 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(':');
|
||||
if (lastApiData.ltc_status === 'LOCK' && lastApiData.ltc_timecode && lastApiData.ltc_timecode.match(/[:;]/) && lastApiData.frame_rate) {
|
||||
const separator = lastApiData.ltc_timecode.includes(';') ? ';' : ':';
|
||||
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);
|
||||
|
|
@ -126,7 +128,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
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')}`;
|
||||
`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}${separator}${String(f).padStart(2, '0')}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||