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
This commit is contained in:
Chris Frankland-Wright 2025-08-07 19:56:08 +01:00 committed by GitHub
commit 69569c0a01
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 141 additions and 42 deletions

View file

@ -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",

View file

@ -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();

View file

@ -64,6 +64,7 @@ impl Config {
Self::default()
})
}
}
impl Default for Config {

View file

@ -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,30 +73,85 @@ 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 {
log::info!("🚀 Starting daemon...");
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");
// 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 daemonize = Daemonize::new()
.pid_file("ntp_timeturner.pid") // Create a PID file
.working_directory(".") // Keep the same working directory
.stdout(stdout)
.stderr(stderr);
let daemonize = Daemonize::new()
.pid_file("ntp_timeturner.pid") // Create a PID file
.working_directory(".") // Keep the same working directory
.stdout(stdout)
.stderr(stderr);
match daemonize.start() {
Ok(_) => { /* Process is now daemonized */ }
Err(e) => {
log::error!("Error daemonizing: {}", e);
return; // Exit if daemonization fails
match daemonize.start() {
Ok(_) => { /* Process is now daemonized */ }
Err(e) => {
log::error!("Error daemonizing: {}", e);
return; // Exit if daemonization fails
}
}
}
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;
}
}
}
@ -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);
});

View file

@ -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()
}

View file

@ -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(),
}

View file

@ -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(),
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 981 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 955 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 913 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -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')}`;
}
}
}