Compare commits

..

23 commits

Author SHA1 Message Date
4aced3eb48
Merge pull request #11 from cjfranko/daemon
Some checks failed
Build for Raspberry Pi / Build for aarch64 (push) Failing after 22s
Daemon


merging this branch in, 
we are aware that the time is currently off
2025-07-28 14:07:45 +01:00
1150fa20c3 cargo fix 2025-07-21 22:07:16 +01:00
7bf45c43c9 feat: add daemonization with the daemonize crate
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-21 22:05:11 +01:00
e243d87018 cargo update for daemonizeation 2025-07-21 22:04:29 +01:00
8864bef1db fix: import DateTime and remove unused Datelike import
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-21 21:24:01 +01:00
060cff4089 fix: Resolve serde lifetime error in ApiStatus struct
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-21 21:21:41 +01:00
6ed1fc31e7 refactor: extract time calculation logic and add tests
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-21 21:16:38 +01:00
6afe6580fb gone 2025-07-21 21:14:41 +01:00
ec132a2840 refactor: replace systemd logger with env_logger
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-21 20:52:55 +01:00
cd737b895e build: vendor systemd to support cross-compilation
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-21 20:47:43 +01:00
4ebe8b597a fix: switch to systemd crate to resolve build failure
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-21 20:43:39 +01:00
2ac14c8d5b fix: Enable systemd feature to correctly initialize logger
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-21 20:40:18 +01:00
838082e95a fix: manually initialize systemd logger to fix build error
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-21 20:33:14 +01:00
9f39fb3739 fix: remove incorrect x86_64 target configuration
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-21 20:28:00 +01:00
56e6071e3a build: add aarch64 target for Raspberry Pi
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-21 20:24:14 +01:00
d983d632f8 build: set linker for x86_64-unknown-linux-gnu target 2025-07-21 20:23:57 +01:00
b2f50be611 fix: correct systemd logger initialization for Linux builds
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-21 20:10:49 +01:00
183fdc0725 fix: use init_with_level for systemd logger initialization
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-21 20:03:45 +01:00
b1a0483d6c fix: Correct systemd logger initialization
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-21 19:58:51 +01:00
154c07f613 fix: update logger call and remove unused import
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-21 19:54:46 +01:00
12065a08c2 fix: Conditionally compile systemd features for Linux only
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-21 19:46:16 +01:00
b854d29015 refactor: Extract system and status logic from UI module
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-21 19:44:10 +01:00
1d9bc1e25e feat: add daemon mode and systemd service
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-21 19:42:46 +01:00
7 changed files with 379 additions and 282 deletions

View file

@ -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
View file

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

View file

@ -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." }))

View file

@ -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() {
println!("🔧 Watching config.yml...");
println!("🚀 Serial thread launched");
println!("🖥️ UI thread launched");
let ui_state = ltc_state.clone(); 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
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..."); println!("📡 Main thread entering loop...");
let _ = task::spawn_blocking(move || { let _ = task::spawn_blocking(move || {
// This will block the thread, but it's a blocking-safe thread.
for _frame in rx { for _frame in rx {
// no-op // no-op
} }
}) })
.await; .await;
}
}) })
.await; .await;
} }

View file

@ -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
View 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
View file

@ -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!("🔄 Autosynced to LTC: {}", ts), Ok(ts) => format!("🔄 Autosynced to LTC: {}", ts),
Err(_) => "❌ Autosync failed".into(), Err(_) => "❌ Autosync 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");
}
} }