mirror of
https://github.com/cjfranko/NTP-Timeturner.git
synced 2025-11-08 18:32:02 +00:00
Compare commits
23 commits
784b3b9be6
...
4aced3eb48
| Author | SHA1 | Date | |
|---|---|---|---|
| 4aced3eb48 | |||
| 1150fa20c3 | |||
| 7bf45c43c9 | |||
| e243d87018 | |||
| 8864bef1db | |||
| 060cff4089 | |||
| 6ed1fc31e7 | |||
| 6afe6580fb | |||
| ec132a2840 | |||
| cd737b895e | |||
| 4ebe8b597a | |||
| 2ac14c8d5b | |||
| 838082e95a | |||
| 9f39fb3739 | |||
| 56e6071e3a | |||
| d983d632f8 | |||
| b2f50be611 | |||
| 183fdc0725 | |||
| b1a0483d6c | |||
| 154c07f613 | |||
| 12065a08c2 | |||
| b854d29015 | |||
| 1d9bc1e25e |
7 changed files with 379 additions and 282 deletions
|
|
@ -16,4 +16,9 @@ get_if_addrs = "0.5"
|
|||
actix-web = "4"
|
||||
actix-files = "0.6"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
clap = { version = "4.4", features = ["derive"] }
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
daemonize = "0.5.0"
|
||||
|
||||
|
||||
|
|
|
|||
158
setup.sh
158
setup.sh
|
|
@ -1,125 +1,59 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo ""
|
||||
echo "─────────────────────────────────────────────"
|
||||
echo " Welcome to the NTP TimeTurner Installer"
|
||||
echo "─────────────────────────────────────────────"
|
||||
echo ""
|
||||
echo "\"It's a very complicated piece of magic...\" – Hermione Granger"
|
||||
echo "Preparing the Ministry-grade temporal interface..."
|
||||
echo ""
|
||||
echo "--- TimeTurner Setup ---"
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Step 1: Update and upgrade packages
|
||||
# ---------------------------------------------------------
|
||||
echo "Step 1: Updating package lists and upgrading..."
|
||||
sudo apt update && sudo apt upgrade -y
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Step 2: Install core tools and Python dependencies
|
||||
# ---------------------------------------------------------
|
||||
echo "Step 2: Installing required tools..."
|
||||
sudo apt install -y git curl python3 python3-pip build-essential cmake \
|
||||
python3-serial libusb-dev
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Step 2.5: Install teensy-loader-cli from source
|
||||
# ---------------------------------------------------------
|
||||
echo "Installing teensy-loader-cli manually from source..."
|
||||
cd "$HOME"
|
||||
if [ ! -d teensy_loader_cli ]; then
|
||||
git clone https://github.com/PaulStoffregen/teensy_loader_cli.git
|
||||
# 1. Build the release binary
|
||||
echo "📦 Building release binary with Cargo..."
|
||||
if ! command -v cargo &> /dev/null
|
||||
then
|
||||
echo "❌ Cargo is not installed. Please install Rust and Cargo first."
|
||||
echo "Visit https://rustup.rs/ for instructions."
|
||||
exit 1
|
||||
fi
|
||||
cd teensy_loader_cli
|
||||
make
|
||||
sudo install -m 755 teensy_loader_cli /usr/local/bin/teensy-loader-cli
|
||||
cargo build --release
|
||||
echo "✅ Build complete."
|
||||
|
||||
echo "Verifying teensy-loader-cli..."
|
||||
teensy-loader-cli --version || echo "⚠️ teensy-loader-cli failed to install properly"
|
||||
# 2. Create installation directories
|
||||
INSTALL_DIR="/opt/timeturner"
|
||||
BIN_DIR="/usr/local/bin"
|
||||
echo "🔧 Creating directories..."
|
||||
sudo mkdir -p $INSTALL_DIR
|
||||
echo "✅ Directory $INSTALL_DIR created."
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Step 2.6: Install udev rules for Teensy
|
||||
# ---------------------------------------------------------
|
||||
echo "Installing udev rules for Teensy access..."
|
||||
cd "$HOME"
|
||||
wget -O 49-teensy.rules https://www.pjrc.com/teensy/49-teensy.rules
|
||||
sudo cp 49-teensy.rules /etc/udev/rules.d/
|
||||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger
|
||||
echo "✅ Teensy udev rules installed. Reboot required to take full effect."
|
||||
# 3. Install binary
|
||||
echo "🚀 Installing timeturner binary..."
|
||||
sudo cp target/release/ntp_timeturner $INSTALL_DIR/timeturner
|
||||
sudo ln -sf $INSTALL_DIR/timeturner $BIN_DIR/timeturner
|
||||
echo "✅ Binary installed to $INSTALL_DIR and linked to $BIN_DIR."
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Step 3: Install Arduino CLI manually (latest version)
|
||||
# ---------------------------------------------------------
|
||||
echo "Step 3: Downloading and installing arduino-cli..."
|
||||
cd "$HOME"
|
||||
curl -fsSL https://downloads.arduino.cc/arduino-cli/arduino-cli_latest_Linux_ARM64.tar.gz -o arduino-cli.tar.gz
|
||||
tar -xzf arduino-cli.tar.gz
|
||||
sudo mv arduino-cli /usr/local/bin/
|
||||
rm arduino-cli.tar.gz
|
||||
|
||||
echo "Verifying arduino-cli install..."
|
||||
arduino-cli version || echo "⚠️ arduino-cli install failed or not found in PATH"
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Step 4: Download and apply splash screen
|
||||
# ---------------------------------------------------------
|
||||
echo "Step 4: Downloading and applying splash screen..."
|
||||
cd "$HOME"
|
||||
wget -O splash.png https://raw.githubusercontent.com/cjfranko/NTP-Timeturner/master/splash.png
|
||||
|
||||
if [ -f splash.png ]; then
|
||||
sudo cp splash.png /usr/share/plymouth/themes/pix/splash.png
|
||||
sudo chmod 644 /usr/share/plymouth/themes/pix/splash.png
|
||||
echo "✅ Splash screen applied."
|
||||
# 4. Install systemd service file
|
||||
if [[ "$(uname)" == "Linux" ]]; then
|
||||
echo "⚙️ Installing systemd service for Linux..."
|
||||
sudo cp timeturner.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable timeturner.service
|
||||
echo "✅ Systemd service installed and enabled."
|
||||
else
|
||||
echo "⚠️ splash.png not found — skipping."
|
||||
echo "⚠️ Skipping systemd service installation on non-Linux OS."
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Step 4.5: Configure Plymouth to stay on screen longer
|
||||
# ---------------------------------------------------------
|
||||
echo "Step 4.5: Configuring splash screen timing..."
|
||||
|
||||
# Ensure 'quiet splash' is in /boot/cmdline.txt
|
||||
sudo sed -i 's/\(\s*\)console=tty1/\1quiet splash console=tty1/' /boot/cmdline.txt
|
||||
echo "✅ Set 'quiet splash' in /boot/cmdline.txt"
|
||||
|
||||
# Update Plymouth config
|
||||
sudo sed -i 's/^Theme=.*/Theme=pix/' /etc/plymouth/plymouthd.conf
|
||||
sudo sed -i 's/^ShowDelay=.*/ShowDelay=0/' /etc/plymouth/plymouthd.conf || echo "ShowDelay=0" | sudo tee -a /etc/plymouth/plymouthd.conf
|
||||
sudo sed -i 's/^DeviceTimeout=.*/DeviceTimeout=10/' /etc/plymouth/plymouthd.conf || echo "DeviceTimeout=10" | sudo tee -a /etc/plymouth/plymouthd.conf
|
||||
sudo sed -i 's/^DisableFadeIn=.*/DisableFadeIn=true/' /etc/plymouth/plymouthd.conf || echo "DisableFadeIn=true" | sudo tee -a /etc/plymouth/plymouthd.conf
|
||||
echo "✅ Updated /etc/plymouth/plymouthd.conf"
|
||||
|
||||
# Create autostart delay to keep splash visible until desktop is ready
|
||||
mkdir -p "$HOME/.config/autostart"
|
||||
cat << EOF > "$HOME/.config/autostart/delayed-plymouth-exit.desktop"
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Delayed Plymouth Exit
|
||||
Exec=/bin/sh -c "sleep 3 && /usr/bin/plymouth quit"
|
||||
X-GNOME-Autostart-enabled=true
|
||||
EOF
|
||||
echo "✅ Splash screen will exit 3 seconds after desktop starts"
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Step 5: Download Teensy firmware
|
||||
# ---------------------------------------------------------
|
||||
echo "Step 5: Downloading Teensy firmware..."
|
||||
cd "$HOME"
|
||||
wget -O ltc_audiohat_lock.ino.hex https://raw.githubusercontent.com/cjfranko/NTP-Timeturner/master/firmware/ltc_audiohat_lock.ino.hex
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Final Message & Reboot
|
||||
# ---------------------------------------------------------
|
||||
echo ""
|
||||
echo "─────────────────────────────────────────────"
|
||||
echo " Setup Complete — Rebooting in 15 seconds..."
|
||||
echo "─────────────────────────────────────────────"
|
||||
echo "NOTE: Teensy firmware ready in $HOME, but not auto-flashed."
|
||||
echo "Boot splash will remain until desktop loads. "
|
||||
echo "--- Setup Complete ---"
|
||||
echo "The TimeTurner daemon is now installed."
|
||||
echo "The working directory is $INSTALL_DIR."
|
||||
echo "A default 'config.yml' will be created there on first run."
|
||||
echo ""
|
||||
if [[ "$(uname)" == "Linux" ]]; then
|
||||
echo "To start the service, run:"
|
||||
echo " sudo systemctl start timeturner.service"
|
||||
echo ""
|
||||
echo "To view live logs, run:"
|
||||
echo " journalctl -u timeturner.service -f"
|
||||
echo ""
|
||||
fi
|
||||
echo "To run the interactive TUI instead, simply run from the project directory:"
|
||||
echo " cargo run"
|
||||
echo "Or from anywhere after installation:"
|
||||
echo " timeturner"
|
||||
echo ""
|
||||
sleep 15
|
||||
sudo reboot
|
||||
|
|
|
|||
16
src/api.rs
16
src/api.rs
|
|
@ -8,8 +8,8 @@ use serde_json;
|
|||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::config::{self, Config};
|
||||
use crate::sync_logic::LtcState;
|
||||
use crate::ui;
|
||||
use crate::sync_logic::{self, LtcState};
|
||||
use crate::system;
|
||||
|
||||
// Data structure for the main status response
|
||||
#[derive(Serialize, Deserialize)]
|
||||
|
|
@ -64,11 +64,11 @@ async fn get_status(data: web::Data<AppState>) -> impl Responder {
|
|||
delta_frames = ((avg_delta as f64 / frame_ms).round()) as i64;
|
||||
}
|
||||
|
||||
let sync_status = ui::get_sync_status(avg_delta, &config).to_string();
|
||||
let jitter_status = ui::get_jitter_status(state.average_jitter()).to_string();
|
||||
let sync_status = sync_logic::get_sync_status(avg_delta, &config);
|
||||
let jitter_status = sync_logic::get_jitter_status(state.average_jitter());
|
||||
let lock_ratio = state.lock_ratio();
|
||||
|
||||
let ntp_active = ui::ntp_service_active();
|
||||
let ntp_active = system::ntp_service_active();
|
||||
let interfaces = get_if_addrs()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
|
|
@ -83,8 +83,8 @@ async fn get_status(data: web::Data<AppState>) -> impl Responder {
|
|||
system_clock,
|
||||
timecode_delta_ms: avg_delta,
|
||||
timecode_delta_frames: delta_frames,
|
||||
sync_status,
|
||||
jitter_status,
|
||||
sync_status: sync_status.to_string(),
|
||||
jitter_status: jitter_status.to_string(),
|
||||
lock_ratio,
|
||||
ntp_active,
|
||||
interfaces,
|
||||
|
|
@ -97,7 +97,7 @@ async fn manual_sync(data: web::Data<AppState>) -> impl Responder {
|
|||
let state = data.ltc_state.lock().unwrap();
|
||||
let config = data.config.lock().unwrap();
|
||||
if let Some(frame) = &state.latest {
|
||||
if ui::trigger_sync(frame, &config).is_ok() {
|
||||
if system::trigger_sync(frame, &config).is_ok() {
|
||||
HttpResponse::Ok().json(serde_json::json!({ "status": "success", "message": "Sync command issued." }))
|
||||
} else {
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({ "status": "error", "message": "Sync command failed." }))
|
||||
|
|
|
|||
80
src/main.rs
80
src/main.rs
|
|
@ -2,24 +2,41 @@
|
|||
|
||||
mod api;
|
||||
mod config;
|
||||
mod sync_logic;
|
||||
mod serial_input;
|
||||
mod sync_logic;
|
||||
mod system;
|
||||
mod ui;
|
||||
|
||||
use crate::api::start_api_server;
|
||||
use crate::config::watch_config;
|
||||
use crate::sync_logic::LtcState;
|
||||
use crate::serial_input::start_serial_thread;
|
||||
use crate::sync_logic::LtcState;
|
||||
use crate::ui::start_ui;
|
||||
use clap::Parser;
|
||||
use daemonize::Daemonize;
|
||||
use env_logger;
|
||||
|
||||
use std::{
|
||||
fs,
|
||||
path::Path,
|
||||
sync::{Arc, Mutex, mpsc},
|
||||
sync::{mpsc, Arc, Mutex},
|
||||
thread,
|
||||
};
|
||||
use tokio::task::{self, LocalSet};
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(author, version, about, long_about = None)]
|
||||
struct Args {
|
||||
#[command(subcommand)]
|
||||
command: Option<Command>,
|
||||
}
|
||||
|
||||
#[derive(clap::Subcommand, Debug)]
|
||||
enum Command {
|
||||
/// Run as a background daemon providing a web UI.
|
||||
Daemon,
|
||||
}
|
||||
|
||||
/// Default config content, embedded in the binary.
|
||||
const DEFAULT_CONFIG: &str = r#"
|
||||
# Hardware offset in milliseconds for correcting capture latency.
|
||||
|
|
@ -46,27 +63,47 @@ fn ensure_config() {
|
|||
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn main() {
|
||||
// 🔄 Ensure there's always a config.json present
|
||||
let args = Args::parse();
|
||||
|
||||
if let Some(Command::Daemon) = &args.command {
|
||||
println!("🚀 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 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) => {
|
||||
eprintln!("Error daemonizing: {}", e);
|
||||
return; // Exit if daemonization fails
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 🔄 Ensure there's always a config.yml present
|
||||
ensure_config();
|
||||
|
||||
// 1️⃣ Start watching config.yml for changes
|
||||
let config = watch_config("config.yml");
|
||||
println!("🔧 Watching config.yml...");
|
||||
|
||||
// 2️⃣ Channel for raw LTC frames
|
||||
let (tx, rx) = mpsc::channel();
|
||||
println!("✅ Channel created");
|
||||
|
||||
// 3️⃣ Shared state for UI and serial reader
|
||||
let ltc_state = Arc::new(Mutex::new(LtcState::new()));
|
||||
println!("✅ State initialised");
|
||||
|
||||
// 4️⃣ Spawn the serial reader thread (no offset here)
|
||||
// 4️⃣ Spawn the serial reader thread
|
||||
{
|
||||
let tx_clone = tx.clone();
|
||||
let state_clone = ltc_state.clone();
|
||||
thread::spawn(move || {
|
||||
println!("🚀 Serial thread launched");
|
||||
start_serial_thread(
|
||||
"/dev/ttyACM0",
|
||||
115200,
|
||||
|
|
@ -77,18 +114,26 @@ async fn main() {
|
|||
});
|
||||
}
|
||||
|
||||
// 5️⃣ Spawn the UI renderer thread, passing the live config Arc
|
||||
{
|
||||
// 5️⃣ Spawn UI or setup daemon logging
|
||||
if args.command.is_none() {
|
||||
println!("🔧 Watching config.yml...");
|
||||
println!("🚀 Serial thread launched");
|
||||
println!("🖥️ UI thread launched");
|
||||
let ui_state = ltc_state.clone();
|
||||
let config_clone = config.clone();
|
||||
let port = "/dev/ttyACM0".to_string();
|
||||
thread::spawn(move || {
|
||||
println!("🖥️ UI thread launched");
|
||||
start_ui(ui_state, port, config_clone);
|
||||
});
|
||||
} else {
|
||||
// In daemon mode, we initialize env_logger.
|
||||
// This will log to stdout, and the systemd service will capture it.
|
||||
// The RUST_LOG env var controls the log level (e.g., RUST_LOG=info).
|
||||
env_logger::init();
|
||||
log::info!("🚀 Starting TimeTurner daemon...");
|
||||
}
|
||||
|
||||
// 6️⃣ Set up a LocalSet for the API server.
|
||||
// 6️⃣ Set up a LocalSet for the API server and main loop
|
||||
let local = LocalSet::new();
|
||||
local
|
||||
.run_until(async move {
|
||||
|
|
@ -103,15 +148,20 @@ async fn main() {
|
|||
});
|
||||
}
|
||||
|
||||
// 8️⃣ Keep main thread alive by consuming LTC frames in a blocking task
|
||||
// 8️⃣ Keep main thread alive
|
||||
if args.command.is_some() {
|
||||
// In daemon mode, wait forever.
|
||||
std::future::pending::<()>().await;
|
||||
} else {
|
||||
// In TUI mode, block on the channel.
|
||||
println!("📡 Main thread entering loop...");
|
||||
let _ = task::spawn_blocking(move || {
|
||||
// This will block the thread, but it's a blocking-safe thread.
|
||||
for _frame in rx {
|
||||
// no-op
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
use chrono::{DateTime, Local, Timelike, Utc};
|
||||
use crate::config::Config;
|
||||
use chrono::{DateTime, Local, Timelike, Utc};
|
||||
use regex::Captures;
|
||||
use std::collections::VecDeque;
|
||||
|
||||
|
|
@ -170,10 +171,33 @@ impl LtcState {
|
|||
&self.last_match_status
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_sync_status(delta_ms: i64, config: &Config) -> &'static str {
|
||||
if config.timeturner_offset.is_active() {
|
||||
"TIMETURNING"
|
||||
} else if delta_ms.abs() <= 8 {
|
||||
"IN SYNC"
|
||||
} else if delta_ms > 10 {
|
||||
"CLOCK AHEAD"
|
||||
} else {
|
||||
"CLOCK BEHIND"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_jitter_status(jitter_ms: i64) -> &'static str {
|
||||
if jitter_ms.abs() < 10 {
|
||||
"GOOD"
|
||||
} else if jitter_ms.abs() < 40 {
|
||||
"AVERAGE"
|
||||
} else {
|
||||
"BAD"
|
||||
}
|
||||
}
|
||||
// This module provides the logic for handling LTC (Linear Timecode) frames and maintaining state.
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::{Config, TimeturnerOffset};
|
||||
use chrono::{Local, Utc};
|
||||
|
||||
fn get_test_frame(status: &str, h: u32, m: u32, s: u32) -> LtcFrame {
|
||||
|
|
@ -332,4 +356,34 @@ mod tests {
|
|||
"Median of even numbers should be correct"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_sync_status() {
|
||||
let mut config = Config::default();
|
||||
assert_eq!(get_sync_status(0, &config), "IN SYNC");
|
||||
assert_eq!(get_sync_status(8, &config), "IN SYNC");
|
||||
assert_eq!(get_sync_status(-8, &config), "IN SYNC");
|
||||
assert_eq!(get_sync_status(9, &config), "CLOCK BEHIND");
|
||||
assert_eq!(get_sync_status(10, &config), "CLOCK BEHIND");
|
||||
assert_eq!(get_sync_status(11, &config), "CLOCK AHEAD");
|
||||
assert_eq!(get_sync_status(-9, &config), "CLOCK BEHIND");
|
||||
assert_eq!(get_sync_status(-100, &config), "CLOCK BEHIND");
|
||||
|
||||
// Test TIMETURNING status
|
||||
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(100, &config), "TIMETURNING");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_jitter_status() {
|
||||
assert_eq!(get_jitter_status(5), "GOOD");
|
||||
assert_eq!(get_jitter_status(-5), "GOOD");
|
||||
assert_eq!(get_jitter_status(9), "GOOD");
|
||||
assert_eq!(get_jitter_status(10), "AVERAGE");
|
||||
assert_eq!(get_jitter_status(39), "AVERAGE");
|
||||
assert_eq!(get_jitter_status(-39), "AVERAGE");
|
||||
assert_eq!(get_jitter_status(40), "BAD");
|
||||
assert_eq!(get_jitter_status(-40), "BAD");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
182
src/system.rs
Normal file
182
src/system.rs
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
use crate::config::Config;
|
||||
use crate::sync_logic::LtcFrame;
|
||||
use chrono::{DateTime, Duration as ChronoDuration, Local, NaiveTime, TimeZone};
|
||||
use std::process::Command;
|
||||
|
||||
/// Check if Chrony is active
|
||||
pub fn ntp_service_active() -> bool {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if let Ok(output) = Command::new("systemctl").args(&["is-active", "chrony"]).output() {
|
||||
output.status.success()
|
||||
&& String::from_utf8_lossy(&output.stdout).trim() == "active"
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
// systemctl is not available on non-Linux platforms.
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle Chrony (not used yet)
|
||||
#[allow(dead_code)]
|
||||
pub fn ntp_service_toggle(start: bool) {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let action = if start { "start" } else { "stop" };
|
||||
let _ = Command::new("systemctl").args(&[action, "chrony"]).status();
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
// No-op on non-Linux.
|
||||
// The parameter is unused, but the function is dead code anyway.
|
||||
let _ = start;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn calculate_target_time(frame: &LtcFrame, config: &Config) -> DateTime<Local> {
|
||||
let today_local = Local::now().date_naive();
|
||||
let ms = ((frame.frames as f64 / frame.frame_rate) * 1000.0).round() as u32;
|
||||
let timecode = NaiveTime::from_hms_milli_opt(frame.hours, frame.minutes, frame.seconds, ms)
|
||||
.expect("Invalid LTC timecode");
|
||||
|
||||
let naive_dt = today_local.and_time(timecode);
|
||||
let mut dt_local = Local
|
||||
.from_local_datetime(&naive_dt)
|
||||
.single()
|
||||
.expect("Ambiguous or invalid local time");
|
||||
|
||||
// Apply timeturner offset
|
||||
let offset = &config.timeturner_offset;
|
||||
dt_local = dt_local
|
||||
+ ChronoDuration::hours(offset.hours)
|
||||
+ ChronoDuration::minutes(offset.minutes)
|
||||
+ 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)
|
||||
}
|
||||
|
||||
pub fn trigger_sync(frame: &LtcFrame, config: &Config) -> Result<String, ()> {
|
||||
let dt_local = calculate_target_time(frame, config);
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
let (ts, success) = {
|
||||
let ts = dt_local.format("%H:%M:%S.%3f").to_string();
|
||||
let success = Command::new("sudo")
|
||||
.arg("date")
|
||||
.arg("-s")
|
||||
.arg(&ts)
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
(ts, success)
|
||||
};
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
let (ts, success) = {
|
||||
// macOS `date` command format is `mmddHHMMccyy.SS`
|
||||
let ts = dt_local.format("%m%d%H%M%y.%S").to_string();
|
||||
let success = Command::new("sudo")
|
||||
.arg("date")
|
||||
.arg(&ts)
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
(ts, success)
|
||||
};
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||
let (ts, success) = {
|
||||
// Unsupported OS, always fail
|
||||
let ts = dt_local.format("%H:%M:%S.%3f").to_string();
|
||||
eprintln!("Unsupported OS for time synchronization");
|
||||
(ts, false)
|
||||
};
|
||||
|
||||
if success {
|
||||
Ok(ts)
|
||||
} else {
|
||||
Err(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::TimeturnerOffset;
|
||||
use chrono::{Timelike, Utc};
|
||||
|
||||
// Helper to create a test frame
|
||||
fn get_test_frame(h: u32, m: u32, s: u32, f: u32) -> LtcFrame {
|
||||
LtcFrame {
|
||||
status: "LOCK".to_string(),
|
||||
hours: h,
|
||||
minutes: m,
|
||||
seconds: s,
|
||||
frames: f,
|
||||
frame_rate: 25.0,
|
||||
timestamp: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ntp_service_active_on_non_linux() {
|
||||
// On non-Linux platforms, this should always be false.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
assert!(!ntp_service_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calculate_target_time_no_offset() {
|
||||
let frame = get_test_frame(10, 20, 30, 0);
|
||||
let config = Config::default();
|
||||
let target_time = calculate_target_time(&frame, &config);
|
||||
|
||||
assert_eq!(target_time.hour(), 10);
|
||||
assert_eq!(target_time.minute(), 20);
|
||||
assert_eq!(target_time.second(), 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calculate_target_time_with_positive_offset() {
|
||||
let frame = get_test_frame(10, 20, 30, 0);
|
||||
let mut config = Config::default();
|
||||
config.timeturner_offset = TimeturnerOffset {
|
||||
hours: 1,
|
||||
minutes: 5,
|
||||
seconds: 10,
|
||||
frames: 12, // 12 frames at 25fps is 480ms
|
||||
};
|
||||
|
||||
let target_time = calculate_target_time(&frame, &config);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calculate_target_time_with_negative_offset() {
|
||||
let frame = get_test_frame(10, 20, 30, 12); // 12 frames = 480ms
|
||||
let mut config = Config::default();
|
||||
config.timeturner_offset = TimeturnerOffset {
|
||||
hours: -1,
|
||||
minutes: -5,
|
||||
seconds: -10,
|
||||
frames: -12, // -480ms
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
144
src/ui.rs
144
src/ui.rs
|
|
@ -1,6 +1,6 @@
|
|||
use std::{
|
||||
io::{stdout, Write},
|
||||
process::{self, Command},
|
||||
process::{self},
|
||||
sync::{Arc, Mutex},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
|
|
@ -21,108 +21,9 @@ use crossterm::{
|
|||
|
||||
use crate::config::Config;
|
||||
use get_if_addrs::get_if_addrs;
|
||||
use crate::sync_logic::{LtcFrame, LtcState};
|
||||
use crate::sync_logic::{get_jitter_status, get_sync_status, LtcState};
|
||||
use crate::system;
|
||||
|
||||
/// Check if Chrony is active
|
||||
pub fn ntp_service_active() -> bool {
|
||||
if let Ok(output) = Command::new("systemctl").args(&["is-active", "chrony"]).output() {
|
||||
output.status.success()
|
||||
&& String::from_utf8_lossy(&output.stdout).trim() == "active"
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle Chrony (not used yet)
|
||||
#[allow(dead_code)]
|
||||
fn ntp_service_toggle(start: bool) {
|
||||
let action = if start { "start" } else { "stop" };
|
||||
let _ = Command::new("systemctl").args(&[action, "chrony"]).status();
|
||||
}
|
||||
|
||||
pub fn get_sync_status(delta_ms: i64, config: &Config) -> &'static str {
|
||||
if config.timeturner_offset.is_active() {
|
||||
"TIMETURNING"
|
||||
} else if delta_ms.abs() <= 8 {
|
||||
"IN SYNC"
|
||||
} else if delta_ms > 10 {
|
||||
"CLOCK AHEAD"
|
||||
} else {
|
||||
"CLOCK BEHIND"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_jitter_status(jitter_ms: i64) -> &'static str {
|
||||
if jitter_ms.abs() < 10 {
|
||||
"GOOD"
|
||||
} else if jitter_ms.abs() < 40 {
|
||||
"AVERAGE"
|
||||
} else {
|
||||
"BAD"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn trigger_sync(frame: &LtcFrame, config: &Config) -> Result<String, ()> {
|
||||
let today_local = Local::now().date_naive();
|
||||
let ms = ((frame.frames as f64 / frame.frame_rate) * 1000.0).round() as u32;
|
||||
let timecode = NaiveTime::from_hms_milli_opt(frame.hours, frame.minutes, frame.seconds, ms)
|
||||
.expect("Invalid LTC timecode");
|
||||
|
||||
let naive_dt = today_local.and_time(timecode);
|
||||
let mut dt_local = Local
|
||||
.from_local_datetime(&naive_dt)
|
||||
.single()
|
||||
.expect("Ambiguous or invalid local time");
|
||||
|
||||
// Apply timeturner offset
|
||||
let offset = &config.timeturner_offset;
|
||||
dt_local = dt_local
|
||||
+ ChronoDuration::hours(offset.hours)
|
||||
+ ChronoDuration::minutes(offset.minutes)
|
||||
+ 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 = dt_local + ChronoDuration::milliseconds(frame_offset_ms);
|
||||
#[cfg(target_os = "linux")]
|
||||
let (ts, success) = {
|
||||
let ts = dt_local.format("%H:%M:%S.%3f").to_string();
|
||||
let success = Command::new("sudo")
|
||||
.arg("date")
|
||||
.arg("-s")
|
||||
.arg(&ts)
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
(ts, success)
|
||||
};
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
let (ts, success) = {
|
||||
// macOS `date` command format is `mmddHHMMccyy.SS`
|
||||
let ts = dt_local.format("%m%d%H%M%y.%S").to_string();
|
||||
let success = Command::new("sudo")
|
||||
.arg("date")
|
||||
.arg(&ts)
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
(ts, success)
|
||||
};
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||
let (ts, success) = {
|
||||
// Unsupported OS, always fail
|
||||
let ts = dt_local.format("%H:%M:%S.%3f").to_string();
|
||||
eprintln!("Unsupported OS for time synchronization");
|
||||
(ts, false)
|
||||
};
|
||||
|
||||
if success {
|
||||
Ok(ts)
|
||||
} else {
|
||||
Err(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start_ui(
|
||||
state: Arc<Mutex<LtcState>>,
|
||||
|
|
@ -145,7 +46,7 @@ pub fn start_ui(
|
|||
let hw_offset_ms = cfg.hardware_offset_ms;
|
||||
|
||||
// 2️⃣ Chrony + interfaces
|
||||
let ntp_active = ntp_service_active();
|
||||
let ntp_active = system::ntp_service_active();
|
||||
let interfaces: Vec<String> = get_if_addrs()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
|
|
@ -226,7 +127,7 @@ pub fn start_ui(
|
|||
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 trigger_sync(frame, &cfg) {
|
||||
let entry = match system::trigger_sync(frame, &cfg) {
|
||||
Ok(ts) => format!("🔄 Auto‑synced to LTC: {}", ts),
|
||||
Err(_) => "❌ Auto‑sync failed".into(),
|
||||
};
|
||||
|
|
@ -361,7 +262,7 @@ pub fn start_ui(
|
|||
}
|
||||
KeyCode::Char(c) if c.eq_ignore_ascii_case(&'s') => {
|
||||
if let Some(frame) = &state.lock().unwrap().latest {
|
||||
let entry = match trigger_sync(frame, &cfg) {
|
||||
let entry = match system::trigger_sync(frame, &cfg) {
|
||||
Ok(ts) => format!("✔ Synced exactly to LTC: {}", ts),
|
||||
Err(_) => "❌ date cmd failed".into(),
|
||||
};
|
||||
|
|
@ -380,37 +281,8 @@ pub fn start_ui(
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[allow(unused_imports)]
|
||||
use super::*;
|
||||
|
||||
#[allow(unused_imports)]
|
||||
use crate::config::TimeturnerOffset;
|
||||
|
||||
#[test]
|
||||
fn test_get_sync_status() {
|
||||
let mut config = Config::default();
|
||||
assert_eq!(get_sync_status(0, &config), "IN SYNC");
|
||||
assert_eq!(get_sync_status(8, &config), "IN SYNC");
|
||||
assert_eq!(get_sync_status(-8, &config), "IN SYNC");
|
||||
assert_eq!(get_sync_status(9, &config), "CLOCK BEHIND");
|
||||
assert_eq!(get_sync_status(10, &config), "CLOCK BEHIND");
|
||||
assert_eq!(get_sync_status(11, &config), "CLOCK AHEAD");
|
||||
assert_eq!(get_sync_status(-9, &config), "CLOCK BEHIND");
|
||||
assert_eq!(get_sync_status(-100, &config), "CLOCK BEHIND");
|
||||
|
||||
// Test TIMETURNING status
|
||||
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(100, &config), "TIMETURNING");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_jitter_status() {
|
||||
assert_eq!(get_jitter_status(5), "GOOD");
|
||||
assert_eq!(get_jitter_status(-5), "GOOD");
|
||||
assert_eq!(get_jitter_status(9), "GOOD");
|
||||
assert_eq!(get_jitter_status(10), "AVERAGE");
|
||||
assert_eq!(get_jitter_status(39), "AVERAGE");
|
||||
assert_eq!(get_jitter_status(-39), "AVERAGE");
|
||||
assert_eq!(get_jitter_status(40), "BAD");
|
||||
assert_eq!(get_jitter_status(-40), "BAD");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue