feat: Integrate statime for PTP time sync

Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
This commit is contained in:
Chaos Rogers 2025-07-10 16:33:47 +01:00
parent 41ae37b6b7
commit 78ea1aefed
7 changed files with 200 additions and 40 deletions

View file

@ -10,4 +10,9 @@ crossterm = "0.29"
regex = "1.11" regex = "1.11"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
notify = "8.1.0" notify = "8.1.0"
statime = "0.4.0"
statime-linux = "0.4.0"
tokio = { version = "1", features = ["full"] }
log = "0.4"
env_logger = "0.11"

View file

@ -1,3 +1,5 @@
{ {
"hardware_offset_ms": 20 "hardware_offset_ms": 20,
"ptp_enabled": true,
"ptp_interface": "eth0"
} }

View file

@ -12,50 +12,69 @@ use std::{
sync::{Arc, Mutex}, sync::{Arc, Mutex},
}; };
#[derive(Deserialize)] #[derive(Deserialize, Clone, Debug)]
pub struct Config { pub struct Config {
pub hardware_offset_ms: i64, pub hardware_offset_ms: i64,
#[serde(default)]
pub ptp_enabled: bool,
#[serde(default = "default_ptp_interface")]
pub ptp_interface: String,
}
fn default_ptp_interface() -> String {
"eth0".to_string()
}
impl Default for Config {
fn default() -> Self {
Self {
hardware_offset_ms: 0,
ptp_enabled: false,
ptp_interface: default_ptp_interface(),
}
}
} }
impl Config { impl Config {
pub fn load(path: &PathBuf) -> Self { pub fn load(path: &PathBuf) -> Self {
let mut file = match File::open(path) { let mut file = match File::open(path) {
Ok(f) => f, Ok(f) => f,
Err(_) => return Self { hardware_offset_ms: 0 }, Err(_) => return Self::default(),
}; };
let mut contents = String::new(); let mut contents = String::new();
if file.read_to_string(&mut contents).is_err() { if file.read_to_string(&mut contents).is_err() {
return Self { hardware_offset_ms: 0 }; return Self::default();
} }
serde_json::from_str(&contents).unwrap_or(Self { hardware_offset_ms: 0 }) serde_json::from_str(&contents).unwrap_or_else(|e| {
eprintln!("Failed to parse config.json: {}, using default", e);
Self::default()
})
} }
} }
pub fn watch_config(path: &str) -> Arc<Mutex<i64>> { pub fn watch_config(path: &str) -> Arc<Mutex<Config>> {
let initial = Config::load(&PathBuf::from(path)).hardware_offset_ms; let initial_config = Config::load(&PathBuf::from(path));
let offset = Arc::new(Mutex::new(initial)); let config = Arc::new(Mutex::new(initial_config));
// Owned PathBuf for watch() call
let watch_path = PathBuf::from(path); let watch_path = PathBuf::from(path);
// Clone for moving into the closure
let watch_path_for_cb = watch_path.clone(); let watch_path_for_cb = watch_path.clone();
let offset_for_cb = Arc::clone(&offset); let config_for_cb = Arc::clone(&config);
std::thread::spawn(move || { std::thread::spawn(move || {
// Move `watch_path_for_cb` into the callback let event_handler = move |res: NotifyResult<Event>| {
let mut watcher: RecommendedWatcher = recommended_watcher(move |res: NotifyResult<Event>| {
if let Ok(evt) = res { if let Ok(evt) = res {
if matches!(evt.kind, EventKind::Modify(_)) { if matches!(evt.kind, EventKind::Modify(_)) {
let new_cfg = Config::load(&watch_path_for_cb); let new_cfg = Config::load(&watch_path_for_cb);
let mut hw = offset_for_cb.lock().unwrap(); eprintln!("🔄 Reloaded config.json: {:?}", new_cfg);
*hw = new_cfg.hardware_offset_ms; let mut cfg = config_for_cb.lock().unwrap();
eprintln!("🔄 Reloaded hardware_offset_ms = {}", *hw); *cfg = new_cfg;
} }
} }
}) };
.expect("Failed to create file watcher");
let mut watcher: RecommendedWatcher =
recommended_watcher(event_handler).expect("Failed to create file watcher");
// Use the original `watch_path` here
watcher watcher
.watch(&watch_path, RecursiveMode::NonRecursive) .watch(&watch_path, RecursiveMode::NonRecursive)
.expect("Failed to watch config.json"); .expect("Failed to watch config.json");
@ -65,5 +84,5 @@ pub fn watch_config(path: &str) -> Arc<Mutex<i64>> {
} }
}); });
offset config
} }

View file

@ -1,19 +1,22 @@
// src/main.rs // src/main.rs
mod config; mod config;
mod sync_logic; mod ptp;
mod serial_input; mod serial_input;
mod sync_logic;
mod ui; mod ui;
use crate::config::watch_config; use crate::config::watch_config;
use crate::sync_logic::LtcState; use crate::ptp::start_ptp_client;
use crate::serial_input::start_serial_thread; use crate::serial_input::start_serial_thread;
use crate::sync_logic::LtcState;
use crate::ui::start_ui; use crate::ui::start_ui;
use std::{ use std::{
fs, fs,
path::Path, path::Path,
sync::{Arc, Mutex, mpsc}, sync::{mpsc, Arc, Mutex},
thread, thread,
}; };
@ -30,13 +33,15 @@ fn ensure_config() {
} }
} }
fn main() { #[tokio::main]
async fn main() {
// 🔄 Ensure there's always a config.json present // 🔄 Ensure there's always a config.json present
ensure_config(); ensure_config();
env_logger::init();
// 1⃣ Start watching config.json for changes // 1⃣ Start watching config.json for changes
let hw_offset = watch_config("config.json"); let config_arc = watch_config("config.json");
println!("🔧 Watching config.json (hardware_offset_ms)..."); println!("🔧 Watching config.json...");
// 2⃣ Channel for raw LTC frames // 2⃣ Channel for raw LTC frames
let (tx, rx) = mpsc::channel(); let (tx, rx) = mpsc::channel();
@ -62,19 +67,29 @@ fn main() {
}); });
} }
// 5⃣ Spawn the UI renderer thread, passing the live offset Arc // 5⃣ Spawn PTP client task
{ {
let ui_state = ltc_state.clone(); let ptp_state = ltc_state.clone();
let offset_clone = hw_offset.clone(); let config_clone = config_arc.clone();
let port = "/dev/ttyACM0".to_string(); tokio::spawn(async move {
thread::spawn(move || { println!("🚀 PTP task launched");
println!("🖥️ UI thread launched"); start_ptp_client(ptp_state, config_clone).await;
start_ui(ui_state, port, offset_clone);
}); });
} }
// 6⃣ Keep main thread alive // 6⃣ Spawn the UI renderer thread, passing the live config Arc
println!("📡 Main thread entering loop..."); {
let ui_state = ltc_state.clone();
let config_clone = config_arc.clone();
let port = "/dev/ttyACM0".to_string();
thread::spawn(move || {
println!("🖥️ UI thread launched");
start_ui(ui_state, port, config_clone);
});
}
// 7⃣ Keep main thread alive for LTC frames
println!("📡 Main thread entering LTC frame loop...");
for _frame in rx { for _frame in rx {
// no-op // no-op
} }

88
src/ptp.rs Normal file
View file

@ -0,0 +1,88 @@
use crate::config::Config;
use crate::sync_logic::LtcState;
use statime::{Config as PtpConfig, PtpInstance};
use statime_linux::{LinuxClock, LinuxUdpSocket};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::time::sleep;
pub async fn start_ptp_client(state: Arc<Mutex<LtcState>>, config: Arc<Mutex<Config>>) {
loop {
let (enabled, interface) = {
let cfg = config.lock().unwrap();
(cfg.ptp_enabled, cfg.ptp_interface.clone())
};
if !enabled {
{
let mut st = state.lock().unwrap();
if st.ptp_state != "Disabled" {
st.ptp_state = "Disabled".to_string();
st.ptp_offset = None;
log::info!("PTP client disabled via config.");
}
}
sleep(Duration::from_secs(5)).await;
continue;
}
log::info!("Starting PTP client on interface {}", interface);
{
let mut st = state.lock().unwrap();
st.ptp_state = format!("Starting on {}", interface);
}
let result = run_ptp_session(state.clone(), config.clone()).await;
if let Err(e) = result {
log::error!("PTP client error: {}", e);
let mut st = state.lock().unwrap();
st.ptp_state = format!("Error: {}", e);
st.ptp_offset = None;
}
// Wait before retrying or checking config again
sleep(Duration::from_secs(5)).await;
}
}
async fn run_ptp_session(
state: Arc<Mutex<LtcState>>,
config: Arc<Mutex<Config>>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let interface = config.lock().unwrap().ptp_interface.clone();
let mut ptp_config = PtpConfig::default();
ptp_config.set_iface(interface.clone());
ptp_config.set_use_hardware_timestamping(false);
let clock = LinuxClock::new();
let socket = LinuxUdpSocket::new(ptp_config.clone())?;
let mut instance = PtpInstance::new(ptp_config, socket, clock);
let initial_interface = interface;
loop {
let (enabled, current_interface) = {
let cfg = config.lock().unwrap();
(cfg.ptp_enabled, cfg.ptp_interface.clone())
};
if !enabled || current_interface != initial_interface {
log::info!("PTP disabled or interface changed. Stopping PTP session.");
return Ok(());
}
if let Err(e) = instance.tick().await {
log::warn!("PTP tick error: {}", e);
}
let summary = instance.get_summary();
let mut st = state.lock().unwrap();
st.ptp_offset = summary.offset;
st.ptp_state = summary.state.to_string();
sleep(Duration::from_millis(200)).await;
}
}

View file

@ -45,6 +45,9 @@ pub struct LtcState {
pub offset_history: VecDeque<i64>, pub offset_history: VecDeque<i64>,
pub last_match_status: String, pub last_match_status: String,
pub last_match_check: i64, pub last_match_check: i64,
// PTP state
pub ptp_offset: Option<f64>,
pub ptp_state: String,
} }
impl LtcState { impl LtcState {
@ -56,6 +59,8 @@ impl LtcState {
offset_history: VecDeque::with_capacity(20), offset_history: VecDeque::with_capacity(20),
last_match_status: "UNKNOWN".into(), last_match_status: "UNKNOWN".into(),
last_match_check: 0, last_match_check: 0,
ptp_offset: None,
ptp_state: "Initializing".into(),
} }
} }

View file

@ -17,21 +17,25 @@ use crossterm::{
terminal::{self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen}, terminal::{self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen},
}; };
use crate::config::Config;
use crate::sync_logic::LtcState; use crate::sync_logic::LtcState;
/// Launch the TUI; reads `offset` live from the file-watcher. /// Launch the TUI; reads `config` live from the file-watcher.
pub fn start_ui( pub fn start_ui(
state: Arc<Mutex<LtcState>>, state: Arc<Mutex<LtcState>>,
serial_port: String, serial_port: String,
offset: Arc<Mutex<i64>>, config: Arc<Mutex<Config>>,
) { ) {
let mut stdout = stdout(); let mut stdout = stdout();
execute!(stdout, EnterAlternateScreen).unwrap(); execute!(stdout, EnterAlternateScreen).unwrap();
terminal::enable_raw_mode().unwrap(); terminal::enable_raw_mode().unwrap();
loop { loop {
// 1⃣ Read current hardware offset // 1⃣ Read current config
let hw_offset_ms = *offset.lock().unwrap(); let (hw_offset_ms, ptp_enabled) = {
let cfg = config.lock().unwrap();
(cfg.hardware_offset_ms, cfg.ptp_enabled)
};
// 2⃣ Measure & record jitter only when LOCKED; clear on FREE // 2⃣ Measure & record jitter only when LOCKED; clear on FREE
{ {
@ -97,6 +101,28 @@ pub fn start_ui(
.unwrap(); .unwrap();
} }
// PTP Status Display
if ptp_enabled {
if let Ok(st) = state.lock() {
let (ptp_state_str, ptp_offset_val) =
(st.ptp_state.clone(), st.ptp_offset);
let offset_display = if let Some(offset) = ptp_offset_val {
format!("{:.3}μs", offset / 1000.0)
} else {
"N/A".to_string()
};
queue!(
stdout,
MoveTo(45, 1), Print("PTP Status"),
MoveTo(45, 2), Print(format!("State : {}", ptp_state_str)),
MoveTo(45, 3), Print(format!("Offset : {}", offset_display)),
)
.unwrap();
}
}
// Footer // Footer
queue!( queue!(
stdout, stdout,