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-web = "4"
|
||||||
actix-files = "0.6"
|
actix-files = "0.6"
|
||||||
tokio = { version = "1", features = ["full"] }
|
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
|
#!/bin/bash
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
echo ""
|
echo "--- TimeTurner Setup ---"
|
||||||
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 ""
|
|
||||||
|
|
||||||
# ---------------------------------------------------------
|
# 1. Build the release binary
|
||||||
# Step 1: Update and upgrade packages
|
echo "📦 Building release binary with Cargo..."
|
||||||
# ---------------------------------------------------------
|
if ! command -v cargo &> /dev/null
|
||||||
echo "Step 1: Updating package lists and upgrading..."
|
then
|
||||||
sudo apt update && sudo apt upgrade -y
|
echo "❌ Cargo is not installed. Please install Rust and Cargo first."
|
||||||
|
echo "Visit https://rustup.rs/ for instructions."
|
||||||
# ---------------------------------------------------------
|
exit 1
|
||||||
# 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
|
|
||||||
fi
|
fi
|
||||||
cd teensy_loader_cli
|
cargo build --release
|
||||||
make
|
echo "✅ Build complete."
|
||||||
sudo install -m 755 teensy_loader_cli /usr/local/bin/teensy-loader-cli
|
|
||||||
|
|
||||||
echo "Verifying teensy-loader-cli..."
|
# 2. Create installation directories
|
||||||
teensy-loader-cli --version || echo "⚠️ teensy-loader-cli failed to install properly"
|
INSTALL_DIR="/opt/timeturner"
|
||||||
|
BIN_DIR="/usr/local/bin"
|
||||||
|
echo "🔧 Creating directories..."
|
||||||
|
sudo mkdir -p $INSTALL_DIR
|
||||||
|
echo "✅ Directory $INSTALL_DIR created."
|
||||||
|
|
||||||
# ---------------------------------------------------------
|
# 3. Install binary
|
||||||
# Step 2.6: Install udev rules for Teensy
|
echo "🚀 Installing timeturner binary..."
|
||||||
# ---------------------------------------------------------
|
sudo cp target/release/ntp_timeturner $INSTALL_DIR/timeturner
|
||||||
echo "Installing udev rules for Teensy access..."
|
sudo ln -sf $INSTALL_DIR/timeturner $BIN_DIR/timeturner
|
||||||
cd "$HOME"
|
echo "✅ Binary installed to $INSTALL_DIR and linked to $BIN_DIR."
|
||||||
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."
|
|
||||||
|
|
||||||
# ---------------------------------------------------------
|
# 4. Install systemd service file
|
||||||
# Step 3: Install Arduino CLI manually (latest version)
|
if [[ "$(uname)" == "Linux" ]]; then
|
||||||
# ---------------------------------------------------------
|
echo "⚙️ Installing systemd service for Linux..."
|
||||||
echo "Step 3: Downloading and installing arduino-cli..."
|
sudo cp timeturner.service /etc/systemd/system/
|
||||||
cd "$HOME"
|
sudo systemctl daemon-reload
|
||||||
curl -fsSL https://downloads.arduino.cc/arduino-cli/arduino-cli_latest_Linux_ARM64.tar.gz -o arduino-cli.tar.gz
|
sudo systemctl enable timeturner.service
|
||||||
tar -xzf arduino-cli.tar.gz
|
echo "✅ Systemd service installed and enabled."
|
||||||
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."
|
|
||||||
else
|
else
|
||||||
echo "⚠️ splash.png not found — skipping."
|
echo "⚠️ Skipping systemd service installation on non-Linux OS."
|
||||||
fi
|
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 "─────────────────────────────────────────────"
|
echo "--- Setup Complete ---"
|
||||||
echo " Setup Complete — Rebooting in 15 seconds..."
|
echo "The TimeTurner daemon is now installed."
|
||||||
echo "─────────────────────────────────────────────"
|
echo "The working directory is $INSTALL_DIR."
|
||||||
echo "NOTE: Teensy firmware ready in $HOME, but not auto-flashed."
|
echo "A default 'config.yml' will be created there on first run."
|
||||||
echo "Boot splash will remain until desktop loads. "
|
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 ""
|
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 std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use crate::config::{self, Config};
|
use crate::config::{self, Config};
|
||||||
use crate::sync_logic::LtcState;
|
use crate::sync_logic::{self, LtcState};
|
||||||
use crate::ui;
|
use crate::system;
|
||||||
|
|
||||||
// Data structure for the main status response
|
// Data structure for the main status response
|
||||||
#[derive(Serialize, Deserialize)]
|
#[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;
|
delta_frames = ((avg_delta as f64 / frame_ms).round()) as i64;
|
||||||
}
|
}
|
||||||
|
|
||||||
let sync_status = ui::get_sync_status(avg_delta, &config).to_string();
|
let sync_status = sync_logic::get_sync_status(avg_delta, &config);
|
||||||
let jitter_status = ui::get_jitter_status(state.average_jitter()).to_string();
|
let jitter_status = sync_logic::get_jitter_status(state.average_jitter());
|
||||||
let lock_ratio = state.lock_ratio();
|
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()
|
let interfaces = get_if_addrs()
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|
@ -83,8 +83,8 @@ async fn get_status(data: web::Data<AppState>) -> impl Responder {
|
||||||
system_clock,
|
system_clock,
|
||||||
timecode_delta_ms: avg_delta,
|
timecode_delta_ms: avg_delta,
|
||||||
timecode_delta_frames: delta_frames,
|
timecode_delta_frames: delta_frames,
|
||||||
sync_status,
|
sync_status: sync_status.to_string(),
|
||||||
jitter_status,
|
jitter_status: jitter_status.to_string(),
|
||||||
lock_ratio,
|
lock_ratio,
|
||||||
ntp_active,
|
ntp_active,
|
||||||
interfaces,
|
interfaces,
|
||||||
|
|
@ -97,7 +97,7 @@ async fn manual_sync(data: web::Data<AppState>) -> impl Responder {
|
||||||
let state = data.ltc_state.lock().unwrap();
|
let state = data.ltc_state.lock().unwrap();
|
||||||
let config = data.config.lock().unwrap();
|
let config = data.config.lock().unwrap();
|
||||||
if let Some(frame) = &state.latest {
|
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." }))
|
HttpResponse::Ok().json(serde_json::json!({ "status": "success", "message": "Sync command issued." }))
|
||||||
} else {
|
} else {
|
||||||
HttpResponse::InternalServerError().json(serde_json::json!({ "status": "error", "message": "Sync command failed." }))
|
HttpResponse::InternalServerError().json(serde_json::json!({ "status": "error", "message": "Sync command failed." }))
|
||||||
|
|
|
||||||
100
src/main.rs
100
src/main.rs
|
|
@ -2,24 +2,41 @@
|
||||||
|
|
||||||
mod api;
|
mod api;
|
||||||
mod config;
|
mod config;
|
||||||
mod sync_logic;
|
|
||||||
mod serial_input;
|
mod serial_input;
|
||||||
|
mod sync_logic;
|
||||||
|
mod system;
|
||||||
mod ui;
|
mod ui;
|
||||||
|
|
||||||
use crate::api::start_api_server;
|
use crate::api::start_api_server;
|
||||||
use crate::config::watch_config;
|
use crate::config::watch_config;
|
||||||
use crate::sync_logic::LtcState;
|
|
||||||
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 clap::Parser;
|
||||||
|
use daemonize::Daemonize;
|
||||||
|
use env_logger;
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
fs,
|
fs,
|
||||||
path::Path,
|
path::Path,
|
||||||
sync::{Arc, Mutex, mpsc},
|
sync::{mpsc, Arc, Mutex},
|
||||||
thread,
|
thread,
|
||||||
};
|
};
|
||||||
use tokio::task::{self, LocalSet};
|
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.
|
/// Default config content, embedded in the binary.
|
||||||
const DEFAULT_CONFIG: &str = r#"
|
const DEFAULT_CONFIG: &str = r#"
|
||||||
# Hardware offset in milliseconds for correcting capture latency.
|
# Hardware offset in milliseconds for correcting capture latency.
|
||||||
|
|
@ -46,27 +63,47 @@ fn ensure_config() {
|
||||||
|
|
||||||
#[tokio::main(flavor = "current_thread")]
|
#[tokio::main(flavor = "current_thread")]
|
||||||
async fn main() {
|
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();
|
ensure_config();
|
||||||
|
|
||||||
// 1️⃣ Start watching config.yml for changes
|
// 1️⃣ Start watching config.yml for changes
|
||||||
let config = watch_config("config.yml");
|
let config = watch_config("config.yml");
|
||||||
println!("🔧 Watching config.yml...");
|
|
||||||
|
|
||||||
// 2️⃣ Channel for raw LTC frames
|
// 2️⃣ Channel for raw LTC frames
|
||||||
let (tx, rx) = mpsc::channel();
|
let (tx, rx) = mpsc::channel();
|
||||||
println!("✅ Channel created");
|
|
||||||
|
|
||||||
// 3️⃣ Shared state for UI and serial reader
|
// 3️⃣ Shared state for UI and serial reader
|
||||||
let ltc_state = Arc::new(Mutex::new(LtcState::new()));
|
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 tx_clone = tx.clone();
|
||||||
let state_clone = ltc_state.clone();
|
let state_clone = ltc_state.clone();
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
println!("🚀 Serial thread launched");
|
|
||||||
start_serial_thread(
|
start_serial_thread(
|
||||||
"/dev/ttyACM0",
|
"/dev/ttyACM0",
|
||||||
115200,
|
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() {
|
||||||
let ui_state = ltc_state.clone();
|
println!("🔧 Watching config.yml...");
|
||||||
|
println!("🚀 Serial thread launched");
|
||||||
|
println!("🖥️ UI thread launched");
|
||||||
|
let ui_state = ltc_state.clone();
|
||||||
let config_clone = config.clone();
|
let config_clone = config.clone();
|
||||||
let port = "/dev/ttyACM0".to_string();
|
let port = "/dev/ttyACM0".to_string();
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
println!("🖥️ UI thread launched");
|
|
||||||
start_ui(ui_state, port, config_clone);
|
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();
|
let local = LocalSet::new();
|
||||||
local
|
local
|
||||||
.run_until(async move {
|
.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
|
||||||
println!("📡 Main thread entering loop...");
|
if args.command.is_some() {
|
||||||
let _ = task::spawn_blocking(move || {
|
// In daemon mode, wait forever.
|
||||||
// This will block the thread, but it's a blocking-safe thread.
|
std::future::pending::<()>().await;
|
||||||
for _frame in rx {
|
} else {
|
||||||
// no-op
|
// In TUI mode, block on the channel.
|
||||||
}
|
println!("📡 Main thread entering loop...");
|
||||||
})
|
let _ = task::spawn_blocking(move || {
|
||||||
.await;
|
for _frame in rx {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.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 regex::Captures;
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
|
|
@ -170,10 +171,33 @@ impl LtcState {
|
||||||
&self.last_match_status
|
&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.
|
// This module provides the logic for handling LTC (Linear Timecode) frames and maintaining state.
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::config::{Config, TimeturnerOffset};
|
||||||
use chrono::{Local, Utc};
|
use chrono::{Local, Utc};
|
||||||
|
|
||||||
fn get_test_frame(status: &str, h: u32, m: u32, s: u32) -> LtcFrame {
|
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"
|
"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::{
|
use std::{
|
||||||
io::{stdout, Write},
|
io::{stdout, Write},
|
||||||
process::{self, Command},
|
process::{self},
|
||||||
sync::{Arc, Mutex},
|
sync::{Arc, Mutex},
|
||||||
thread,
|
thread,
|
||||||
time::{Duration, Instant},
|
time::{Duration, Instant},
|
||||||
|
|
@ -21,108 +21,9 @@ use crossterm::{
|
||||||
|
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use get_if_addrs::get_if_addrs;
|
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(
|
pub fn start_ui(
|
||||||
state: Arc<Mutex<LtcState>>,
|
state: Arc<Mutex<LtcState>>,
|
||||||
|
|
@ -145,7 +46,7 @@ pub fn start_ui(
|
||||||
let hw_offset_ms = cfg.hardware_offset_ms;
|
let hw_offset_ms = cfg.hardware_offset_ms;
|
||||||
|
|
||||||
// 2️⃣ Chrony + interfaces
|
// 2️⃣ Chrony + interfaces
|
||||||
let ntp_active = ntp_service_active();
|
let ntp_active = system::ntp_service_active();
|
||||||
let interfaces: Vec<String> = get_if_addrs()
|
let interfaces: Vec<String> = get_if_addrs()
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|
@ -226,7 +127,7 @@ pub fn start_ui(
|
||||||
if let Some(start) = out_of_sync_since {
|
if let Some(start) = out_of_sync_since {
|
||||||
if start.elapsed() >= Duration::from_secs(5) {
|
if start.elapsed() >= Duration::from_secs(5) {
|
||||||
if let Some(frame) = &state.lock().unwrap().latest {
|
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),
|
Ok(ts) => format!("🔄 Auto‑synced to LTC: {}", ts),
|
||||||
Err(_) => "❌ Auto‑sync failed".into(),
|
Err(_) => "❌ Auto‑sync failed".into(),
|
||||||
};
|
};
|
||||||
|
|
@ -361,7 +262,7 @@ pub fn start_ui(
|
||||||
}
|
}
|
||||||
KeyCode::Char(c) if c.eq_ignore_ascii_case(&'s') => {
|
KeyCode::Char(c) if c.eq_ignore_ascii_case(&'s') => {
|
||||||
if let Some(frame) = &state.lock().unwrap().latest {
|
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),
|
Ok(ts) => format!("✔ Synced exactly to LTC: {}", ts),
|
||||||
Err(_) => "❌ date cmd failed".into(),
|
Err(_) => "❌ date cmd failed".into(),
|
||||||
};
|
};
|
||||||
|
|
@ -380,37 +281,8 @@ pub fn start_ui(
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
#[allow(unused_imports)]
|
||||||
use super::*;
|
use super::*;
|
||||||
|
#[allow(unused_imports)]
|
||||||
use crate::config::TimeturnerOffset;
|
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