diff --git a/Cargo.toml b/Cargo.toml index 1d38d1c..3e04d14 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,9 +17,8 @@ actix-web = "4" actix-files = "0.6" tokio = { version = "1", features = ["full"] } clap = { version = "4.4", features = ["derive"] } -log = { version = "0.4", features = ["std"] } +log = "0.4" +env_logger = "0.11" daemonize = "0.5.0" -num-rational = "0.4" -num-traits = "0.2" diff --git a/README.md b/README.md index 5f94e52..d7ed822 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,16 @@ -# Fetch | Hachi (alpha) +# 🕰️ NTP Timeturner (alpha) -**An LTC-driven NTP server for Raspberry Pi, built with broadcast precision** +**An LTC-driven NTP server for Raspberry Pi, built with broadcast precision and a hint of magic.** -Hachi synchronises timecode-locked systems by decoding incoming LTC (Linear Time Code) and broadcasting it as NTP/PTP — with the dedication our namesake would insist upon. +Inspired by the TimeTurner in the Harry Potter series, this project synchronises timecode-locked systems by decoding incoming LTC (Linear Time Code) and broadcasting it as NTP — with precision as Hermione would insist upon. -Created by Chris Frankland-Wright and Chaos Rogers +Created by Chris Frankland-Wright and John Rogers --- ## 📦 Hardware Requirements -- Raspberry Pi 5 2GB (Dev Platform) but should be supported by Pi v3 (or better) +- Raspberry Pi 5 (Dev Platform) but should be supported by Pi v3 (or better) - Debian Bookworm (64-bit recommended) - Teensy 4.0 - https://thepihut.com/products/teensy-4-0-headers - Audio Adapter Board for Teensy 4.0 (Rev D) - https://thepihut.com/products/audio-adapter-board-for-teensy-4-0 @@ -24,103 +24,26 @@ Created by Chris Frankland-Wright and Chaos Rogers - Reads SMPTE LTC from Audio Interface (3.5mm TRS but adaptable to BNC/XLR) - Converts LTC into NTP-synced time - Broadcasts time via local NTP server -- Supports configurable time offsets (hours, minutes, seconds, frames or milliseconds) +- Supports configurable time offsets (hours, minutes, seconds, milliseconds) - NOT AVAILABLE - Systemd service support for headless operation -- Web-based UI for monitoring and control when running as a daemon --- -## 🖥️ Web Interface & API +## 🚀 Installation (to update) -When running as a background daemon, Hachi provides a web interface for monitoring and configuration. -- **Access**: The web UI is available at `http://:8080`. -- **Functionality**: You can view the real-time sync status, see logs, and change all configuration options directly from your browser. -- **API**: A JSON API is also exposed for programmatic access. See `docs/api.md` for full details. - ---- - -## 🛠️ Known Issues - -- Supported Frame Rates: 24/25fps -- Non Supported Frame Rates: 23.98/30/59.94/60 -- Fractional framerates have drift or wrong wall clock sync issues - ---- - -## 🚀 Installation - -The `setup.sh` script compiles and installs the Hachi application. You can run it by cloning the repository with `git` or by using the `curl` command below for a git-free installation. - -### Prerequisites - -- **Internet Connection**: To download dependencies. -- **Curl and Unzip**: The script requires `curl` to download files and `unzip` for the git-free method. The setup script will attempt to install these if they are missing. - -### Running the Installer (Recommended) - -This command downloads the latest version, unpacks it, and runs the setup script. Paste it into your Raspberry Pi terminal: +For Rust install you can do +```bash +cargo install --git https://github.com/cjfranko/NTP-Timeturner +``` +Clone and run the installer: ```bash -curl -L https://github.com/cjfranko/NTP-Timeturner/archive/refs/heads/main.zip -o NTP-Timeturner.zip && \ -unzip NTP-Timeturner.zip && \ -cd NTP-Timeturner-main && \ -chmod +x setup.sh && \ +wget https://raw.githubusercontent.com/cjfranko/NTP-Timeturner/master/setup.sh +chmod +x setup.sh ./setup.sh ``` -### What the Script Does - -The installation script automates the following steps: - -1. **Installs Dependencies**: Installs `git`, `curl`, `unzip`, and necessary build tools. -2. **Compiles the Binary**: Runs `cargo build --release` to create an optimised executable. -3. **Creates Directories**: Creates `/opt/timeturner` to store the application files. -4. **Installs Files**: - - The compiled binary is copied to `/opt/timeturner/timeturner`. - - The web interface assets from the `static/` directory are copied to `/opt/timeturner/static`. - - A symbolic link is created from `/usr/local/bin/timeturner` to the binary, allowing it to be run from any location. -5. **Sets up Systemd Service**: - - Copies the `timeturner.service` file to `/etc/systemd/system/`. - - Enables the service to start automatically on system boot. - -After installation is complete, the script will provide instructions to start the service manually or to run the application in its interactive terminal mode. - -```bash -The working directory is /opt/timeturner. -Default 'config.yml' installed to /opt/timeturner. - -To start the service, run: - sudo systemctl start timeturner.service - -To view live logs, run: - journalctl -u timeturner.service -f - -To run the interactive TUI instead, simply run from the project directory: - cargo run -Or from anywhere after installation: - timeturner -``` - ---- - -## 🔄 Updating - -If you installed Hachi by cloning the repository with `git`, you can use the `update.sh` script to easily update to the latest version. - -**Note**: This script will not work if you used the `curl` one-line command for installation, as that method does not create a Git repository. - -To run the update script, navigate to the `NTP-Timeturner-main` directory and run: -```bash -chmod +x update.sh && ./update.sh -``` - -The update script automates the following: -1. Pulls the latest code from the `main` branch on GitHub. -2. Rebuilds the application binary. -3. Copies the new binary to `/opt/timeturner/`. -4. Restarts the `timeturner` service to apply the changes. - --- ## 🕰️ Chrony NTP ```bash @@ -129,10 +52,10 @@ chronyc tracking | NTP Tracking sudo nano /etc/chrony/chrony.conf | Default Chrony Conf File Add to top: -# Serve the system clock as a reference at stratum 1 +# Serve the system clock as a reference at stratum 10 server 127.127.1.0 allow 127.0.0.0/8 -local stratum 1 +local stratum 10 Add to bottom: # Allow LAN clients diff --git a/SECURITY.MD b/SECURITY.MD deleted file mode 100644 index 14b8058..0000000 --- a/SECURITY.MD +++ /dev/null @@ -1,9 +0,0 @@ -Reporting Security Issues - -The TimeTurner team and community take security bugs in TimeTurner seriously. We appreciate your efforts to responsibly disclose your findings, and will make every effort to acknowledge your contributions. - -To report a security issue, please use the GitHub Security Advisory "Report a Vulnerability" tab. - -The TimeTurner team will send a response indicating the next steps in handling your report. After the initial reply to your report, the security team will keep you informed of the progress towards a fix and full announcement, and may ask for additional information or guidance. - -Report security bugs in third-party modules to the person or team maintaining the module. diff --git a/config.yml b/config.yml index bc552ee..470c6c9 100644 --- a/config.yml +++ b/config.yml @@ -1,13 +1,5 @@ # Hardware offset in milliseconds for correcting capture latency. -hardwareOffsetMs: 55 - -# Enable automatic clock synchronization. -# When enabled, the system will perform an initial full sync, then periodically -# nudge the clock to keep it aligned with the LTC source. -autoSyncEnabled: true - -# Default nudge in milliseconds for adjtimex control. -defaultNudgeMs: 2 +hardwareOffsetMs: 20 # Time-turning offsets. All values are added to the incoming LTC time. # These can be positive or negative. @@ -16,4 +8,3 @@ timeturnerOffset: minutes: 0 seconds: 0 frames: 0 - milliseconds: 0 diff --git a/docs/api.md b/docs/api.md index 6657028..1b76262 100644 --- a/docs/api.md +++ b/docs/api.md @@ -4,25 +4,19 @@ This document describes the HTTP API for the NTP Timeturner application. ## Endpoints -### Status and Logs +### Status - **`GET /api/status`** - Retrieves the real-time status of the LTC reader and system clock synchronization. The `ltc_timecode` field uses `:` as a separator for non-drop-frame timecode, and `;` for drop-frame timecode between seconds and frames (e.g., `10:20:30;00`). - - **Possible values for status fields:** - - `ltc_status`: `"LOCK"`, `"FREE"`, or `"(waiting)"` - - `sync_status`: `"IN SYNC"`, `"CLOCK AHEAD"`, `"CLOCK BEHIND"`, `"TIMETURNING"` - - `jitter_status`: `"GOOD"`, `"AVERAGE"`, `"BAD"` + Retrieves the real-time status of the LTC reader and system clock synchronization. **Example Response:** ```json { "ltc_status": "LOCK", - "ltc_timecode": "10:20:30;00", + "ltc_timecode": "10:20:30:00", "frame_rate": "25.00fps", "system_clock": "10:20:30.005", - "system_date": "2025-07-30", "timecode_delta_ms": 5, "timecode_delta_frames": 0, "sync_status": "IN SYNC", @@ -30,23 +24,11 @@ This document describes the HTTP API for the NTP Timeturner application. "lock_ratio": 99.5, "ntp_active": true, "interfaces": ["192.168.1.100"], - "hardware_offset_ms": 20 + "hardware_offset_ms": 0 } ``` -- **`GET /api/logs`** - - Retrieves the last 100 log entries from the application. - - **Example Response:** - ```json - [ - "2025-08-07 10:00:00 [INFO] Starting TimeTurner daemon...", - "2025-08-07 10:00:01 [INFO] Found serial port: /dev/ttyACM0" - ] - ``` - -### System Clock Control +### Sync - **`POST /api/sync`** @@ -54,7 +36,7 @@ This document describes the HTTP API for the NTP Timeturner application. **Request Body:** None - **Success Response (200 OK):** + **Success Response:** ```json { "status": "success", @@ -62,14 +44,13 @@ This document describes the HTTP API for the NTP Timeturner application. } ``` - **Error Response (400 Bad Request):** + **Error Responses:** ```json { "status": "error", "message": "No LTC timecode available to sync to." } ``` - **Error Response (500 Internal Server Error):** ```json { "status": "error", @@ -77,120 +58,33 @@ This document describes the HTTP API for the NTP Timeturner application. } ``` -- **`POST /api/nudge_clock`** - - Nudges the system clock by a specified number of microseconds. This requires `sudo` privileges to run `adjtimex`. - - **Example Request:** - ```json - { - "microseconds": -2000 - } - ``` - **Success Response (200 OK):** - ```json - { - "status": "success", - "message": "Clock nudge command issued." - } - ``` - **Error Response (500 Internal Server Error):** - ```json - { - "status": "error", - "message": "Clock nudge command failed." - } - ``` - - -- **`POST /api/set_date`** - - Sets the system date. This is useful as LTC does not contain date information. Requires `sudo` privileges. - - **Example Request:** - ```json - { - "date": "2025-07-30" - } - ``` - - **Success Response (200 OK):** - ```json - { - "status": "success", - "message": "Date update command issued." - } - ``` - - **Error Response (500 Internal Server Error):** - ```json - { - "status": "error", - "message": "Date update command failed." - } - ``` - ### Configuration - **`GET /api/config`** - Retrieves the current application configuration from `config.yml`. + Retrieves the current application configuration. - **Example Response (200 OK):** + **Example Response:** ```json { - "hardwareOffsetMs": 20, - "timeturnerOffset": { - "hours": 0, - "minutes": 0, - "seconds": 0, - "frames": 0, - "milliseconds": 0 - }, - "defaultNudgeMs": 2, - "autoSyncEnabled": false + "hardware_offset_ms": 0 } ``` - **`POST /api/config`** - Updates the application configuration. The new configuration is persisted to `config.yml` and takes effect immediately. + Updates the `hardware_offset_ms` configuration. The new value is persisted to `config.json` and reloaded by the application automatically. **Example Request:** ```json { - "hardwareOffsetMs": 55, - "timeturnerOffset": { - "hours": 1, - "minutes": 2, - "seconds": 3, - "frames": 4, - "milliseconds": 5 - }, - "defaultNudgeMs": 2, - "autoSyncEnabled": true + "hardware_offset_ms": 10 } ``` - **Success Response (200 OK):** (Returns the updated configuration) + **Success Response:** ```json { - "hardwareOffsetMs": 55, - "timeturnerOffset": { - "hours": 1, - "minutes": 2, - "seconds": 3, - "frames": 4, - "milliseconds": 5 - }, - "defaultNudgeMs": 2, - "autoSyncEnabled": true - } - ``` - **Error Response (500 Internal Server Error):** - ```json - { - "status": "error", - "message": "Failed to write config.yml" + "hardware_offset_ms": 10 } ``` diff --git a/setup.sh b/setup.sh index 0b68c12..4dd5685 100644 --- a/setup.sh +++ b/setup.sh @@ -3,226 +3,14 @@ set -e echo "--- TimeTurner Setup ---" -# Check if TimeTurner is already installed. -INSTALL_DIR="/opt/timeturner" -if [ -f "${INSTALL_DIR}/timeturner" ]; then - echo "✅ TimeTurner is already installed." - # Ask the user what to do - read -p "Do you want to (U)pdate, (R)einstall, or (A)bort? [U/r/a] " choice - case "$choice" in - r|R ) - echo "Proceeding with full re-installation..." - # Stop the service to allow overwriting the binary, ignore errors if not running - echo "Stopping existing TimeTurner service..." - sudo systemctl stop timeturner.service || true - # The script will continue to the installation steps below. - ;; - a|A ) - echo "Aborting setup." - exit 0 - ;; - * ) # Default to Update - echo "Attempting to run the update script..." - # Ensure we are in a git repository and the update script exists - if [ -d ".git" ] && [ -f "update.sh" ]; then - chmod +x update.sh - ./update.sh - # Exit cleanly after the update - exit 0 - else - echo "⚠️ Could not find 'update.sh' or not in a git repository." - echo "Please re-clone the repository to get the update script, or remove the existing installation to run setup again:" - echo " sudo rm -rf ${INSTALL_DIR}" - exit 1 - fi - ;; - esac -fi - - -# Determine package manager -PKG_MANAGER="" -if command -v apt &> /dev/null; then - PKG_MANAGER="apt" -elif command -v dnf &> /dev/null; then - PKG_MANAGER="dnf" -elif command -v pacman &> /dev/null; then - PKG_MANAGER="pacman" -else - echo "Error: No supported package manager (apt, dnf, pacman) found. Please install dependencies manually." - exit 1 -fi - -echo "Detected package manager: $PKG_MANAGER" - -# --- Update System Packages --- -echo "Updating system packages..." -if [ "$PKG_MANAGER" == "apt" ]; then - sudo apt update - sudo DEBIAN_FRONTEND=noninteractive apt upgrade -y -o Dpkg::Options::="--force-confold" -elif [ "$PKG_MANAGER" == "dnf" ]; then - sudo dnf upgrade -y -elif [ "$PKG_MANAGER" == "pacman" ]; then - sudo pacman -Syu --noconfirm -fi -echo "System packages updated." - -# --- Install Rust/Cargo if not installed --- -if ! command -v cargo &> /dev/null; then - echo "Rust/Cargo not found. Installing Rustup..." - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y - # Source cargo's env for the current shell session - # This is for the current script's execution path, typically rustup adds to .bashrc/.profile for future sessions. - # We need it now, but for non-interactive script, sourcing won't affect parent shell. - # However, cargo build below will rely on it being in PATH. rustup makes sure of this if it installs. - # For safety, ensure PATH is updated. - export PATH="$HOME/.cargo/bin:$PATH" - echo "Rust/Cargo installed successfully." -else - echo "Rust/Cargo is already installed." -fi - -# --- Install common build dependencies for Rust --- -echo "Installing common build dependencies..." -if [ "$PKG_MANAGER" == "apt" ]; then - sudo apt update - sudo apt install -y build-essential libudev-dev pkg-config curl wget -elif [ "$PKG_MANAGER" == "dnf" ]; then - sudo dnf install -y gcc make perl-devel libudev-devel pkg-config curl wget -elif [ "$PKG_MANAGER" == "pacman" ]; then - sudo pacman -Sy --noconfirm base-devel libudev pkg-config curl -fi -echo "Common build dependencies installed." - -# --- Install Python dependencies for testing --- -echo "🐍 Installing Python dependencies for test scripts..." -if [ "$PKG_MANAGER" == "apt" ]; then - # We no longer need hotspot dependencies - sudo apt install -y python3 python3-pip python3-serial -elif [ "$PKG_MANAGER" == "dnf" ]; then - # python3-pyserial is the name for pyserial in dnf - sudo dnf install -y python3 python3-pip python3-pyserial -elif [ "$PKG_MANAGER" == "pacman" ]; then - # python-pyserial is the name for pyserial in pacman - sudo pacman -Sy --noconfirm python python-pip python-pyserial -fi -# sudo pip3 install pyserial # This is replaced by the native package manager installs above -echo "✅ Python dependencies installed." - -# --- Apply custom splash screen --- -if [[ "$(uname)" == "Linux" ]]; then - echo "🖼️ Applying custom splash screen..." - SPLASH_URL="https://raw.githubusercontent.com/cjfranko/NTP-Timeturner/refs/heads/main/splash.png" - PLYMOUTH_THEME_DIR="/usr/share/plymouth/themes/pix" - PLYMOUTH_IMAGE_PATH="${PLYMOUTH_THEME_DIR}/splash.png" - - sudo mkdir -p "${PLYMOUTH_THEME_DIR}" - echo "Downloading splash image from ${SPLASH_URL}..." - sudo curl -L "${SPLASH_URL}" -o "${PLYMOUTH_IMAGE_PATH}" - - if [ -f "${PLYMOUTH_IMAGE_PATH}" ]; then - echo "Splash image downloaded. Updating Plymouth configuration..." - # Set 'pix' as the default plymouth theme if not already. - # This is a common theme that expects splash.png. - sudo update-alternatives --install /usr/share/plymouth/themes/default.plymouth default.plymouth "${PLYMOUTH_THEME_DIR}/pix.plymouth" 100 || true - # Ensure the pix theme exists and is linked - if [ ! -f "${PLYMOUTH_THEME_DIR}/pix.plymouth" ]; then - echo "Creating dummy pix.plymouth for update-initramfs" - echo "[Plymouth Theme]" | sudo tee "${PLYMOUTH_THEME_DIR}/pix.plymouth" > /dev/null - echo "Name=Pi Splash" | sudo tee -a "${PLYMOUTH_THEME_DIR}/pix.plymouth" > /dev/null - echo "Description=TimeTurner Raspberry Pi Splash Screen" | sudo tee -a "${PLYMOUTH_THEME_DIR}/pix.plymouth" > /dev/null - echo "SpriteAnimation=/splash.png" | sudo tee -a "${PLYMOUTH_THEME_DIR}/pix.plymouth" > /dev/null - fi - - # Update the initial RAM filesystem to include the new splash screen - sudo update-initramfs -u - echo "✅ Custom splash screen applied. Reboot may be required to see changes." - else - echo "❌ Failed to download splash image from ${SPLASH_URL}." - fi -else - echo "⚠️ Skipping splash screen configuration on non-Linux OS." -fi - -# --- Remove NTPD and install Chrony, NMTUI, Adjtimex --- -echo "Removing NTPD (if installed) and installing Chrony, NMTUI, Adjtimex..." - -# --- Remove NTPD and install Chrony, NMTUI, Adjtimex --- -echo "Removing NTPD (if installed) and installing Chrony, NMTUI, Adjtimex..." - -if [ "$PKG_MANAGER" == "apt" ]; then - sudo apt update - sudo apt remove -y ntp || true # Remove ntp if it exists, ignore if not - sudo apt install -y chrony network-manager adjtimex - sudo systemctl enable chrony --now -elif [ "$PKG_MANAGER" == "dnf" ]; then - sudo dnf remove -y ntp - sudo dnf install -y chrony NetworkManager-tui adjtimex - sudo systemctl enable chronyd --now -elif [ "$PKG_MANAGER" == "pacman" ]; then - sudo pacman -Sy --noconfirm ntp || true - sudo pacman -R --noconfirm ntp || true # Ensure ntp is removed - sudo pacman -Sy --noconfirm chrony networkmanager adjtimex - sudo systemctl enable chronyd --now - sudo systemctl enable NetworkManager --now # nmtui relies on NetworkManager -fi - -echo "NTPD removed (if present). Chrony, NMTUI, and Adjtimex installed and configured." - -# --- Configure Chrony to act as a local NTP server --- -echo "⚙️ Configuring Chrony to serve local time..." -# The path to chrony.conf can vary -if [ -f /etc/chrony/chrony.conf ]; then - CHRONY_CONF="/etc/chrony/chrony.conf" -elif [ -f /etc/chrony.conf ]; then - CHRONY_CONF="/etc/chrony.conf" -else - CHRONY_CONF="" -fi - -if [ -n "$CHRONY_CONF" ]; then - # Comment out any existing pool, server, or sourcedir lines to prevent syncing with external sources - echo "Disabling external NTP sources..." - sudo sed -i -E 's/^(pool|server|sourcedir)/#&/' "$CHRONY_CONF" - - # Add settings to the top of the file to serve local clock - # Using a temp file to prepend is safer than multiple sed calls - TEMP_CONF=$(mktemp) - cat < "$TEMP_CONF" -# Serve the system clock as a reference at stratum 1 -server 127.127.1.0 -allow 127.0.0.0/8 -local stratum 1 - -EOF - # Append the rest of the original config file after our new lines - cat "$CHRONY_CONF" >> "$TEMP_CONF" - sudo mv "$TEMP_CONF" "$CHRONY_CONF" - - - # Add settings to the bottom of the file to allow LAN clients - echo "Allowing LAN clients..." - sudo tee -a "$CHRONY_CONF" > /dev/null < /dev/null +then + echo "❌ Cargo is not installed. Please install Rust and Cargo first." + echo "Visit https://rustup.rs/ for instructions." + exit 1 +fi cargo build --release echo "✅ Build complete." @@ -233,16 +21,13 @@ echo "🔧 Creating directories..." sudo mkdir -p $INSTALL_DIR echo "✅ Directory $INSTALL_DIR created." -# 3. Install binary and static web files -echo "🚀 Installing timeturner binary and web assets..." +# 3. Install binary +echo "🚀 Installing timeturner binary..." sudo cp target/release/ntp_timeturner $INSTALL_DIR/timeturner -# The static directory contains the web UI files -sudo cp -r static $INSTALL_DIR/ sudo ln -sf $INSTALL_DIR/timeturner $BIN_DIR/timeturner -echo "✅ Binary and assets installed to $INSTALL_DIR, and binary linked to $BIN_DIR." +echo "✅ Binary installed to $INSTALL_DIR and linked to $BIN_DIR." # 4. Install systemd service file -# Only needed for Linux systems (e.g., Raspberry Pi OS) if [[ "$(uname)" == "Linux" ]]; then echo "⚙️ Installing systemd service for Linux..." sudo cp timeturner.service /etc/systemd/system/ @@ -257,13 +42,7 @@ echo "" echo "--- Setup Complete ---" echo "The TimeTurner daemon is now installed." echo "The working directory is $INSTALL_DIR." -# Copy default config.yml from repo if it exists -if [ -f config.yml ]; then - sudo cp config.yml $INSTALL_DIR/ - echo "Default 'config.yml' installed to $INSTALL_DIR." -else - echo "⚠️ No default 'config.yml' found in repository. Please add one if needed." -fi +echo "A default 'config.yml' will be created there on first run." echo "" if [[ "$(uname)" == "Linux" ]]; then echo "To start the service, run:" diff --git a/src/api.rs b/src/api.rs index 14b0da4..0bd2d2a 100644 --- a/src/api.rs +++ b/src/api.rs @@ -5,14 +5,11 @@ use chrono::{Local, Timelike}; use get_if_addrs::get_if_addrs; use serde::{Deserialize, Serialize}; use serde_json; -use std::collections::VecDeque; use std::sync::{Arc, Mutex}; use crate::config::{self, Config}; use crate::sync_logic::{self, LtcState}; use crate::system; -use num_rational::Ratio; -use num_traits::ToPrimitive; // Data structure for the main status response #[derive(Serialize, Deserialize)] @@ -21,7 +18,6 @@ struct ApiStatus { ltc_timecode: String, frame_rate: String, system_clock: String, - system_date: String, timecode_delta_ms: i64, timecode_delta_frames: i64, sync_status: String, @@ -36,7 +32,6 @@ struct ApiStatus { pub struct AppState { pub ltc_state: Arc>, pub config: Arc>, - pub log_buffer: Arc>>, } #[get("/api/status")] @@ -47,14 +42,10 @@ async fn get_status(data: web::Data) -> impl Responder { let ltc_status = state.latest.as_ref().map_or("(waiting)".to_string(), |f| f.status.clone()); let ltc_timecode = state.latest.as_ref().map_or("…".to_string(), |f| { - let sep = if f.is_drop_frame { ';' } else { ':' }; - format!( - "{:02}:{:02}:{:02}{}{:02}", - f.hours, f.minutes, f.seconds, sep, f.frames - ) + format!("{:02}:{:02}:{:02}:{:02}", f.hours, f.minutes, f.seconds, f.frames) }); let frame_rate = state.latest.as_ref().map_or("…".to_string(), |f| { - format!("{:.2}fps", f.frame_rate.to_f64().unwrap_or(0.0)) + format!("{:.2}fps", f.frame_rate) }); let now_local = Local::now(); @@ -65,14 +56,12 @@ async fn get_status(data: web::Data) -> impl Responder { now_local.second(), now_local.timestamp_subsec_millis(), ); - let system_date = now_local.format("%Y-%m-%d").to_string(); - let avg_delta = state.get_ewma_clock_delta(); + let avg_delta = state.average_clock_delta(); let mut delta_frames = 0; if let Some(frame) = &state.latest { - let delta_ms_ratio = Ratio::new(avg_delta, 1); - let frames_ratio = delta_ms_ratio * frame.frame_rate / Ratio::new(1000, 1); - delta_frames = frames_ratio.round().to_integer(); + let frame_ms = 1000.0 / frame.frame_rate; + delta_frames = ((avg_delta as f64 / frame_ms).round()) as i64; } let sync_status = sync_logic::get_sync_status(avg_delta, &config); @@ -92,7 +81,6 @@ async fn get_status(data: web::Data) -> impl Responder { ltc_timecode, frame_rate, system_clock, - system_date, timecode_delta_ms: avg_delta, timecode_delta_frames: delta_frames, sync_status: sync_status.to_string(), @@ -125,42 +113,6 @@ async fn get_config(data: web::Data) -> impl Responder { HttpResponse::Ok().json(&*config) } -#[get("/api/logs")] -async fn get_logs(data: web::Data) -> impl Responder { - let logs = data.log_buffer.lock().unwrap(); - HttpResponse::Ok().json(&*logs) -} - -#[derive(Deserialize)] -struct NudgeRequest { - microseconds: i64, -} - -#[post("/api/nudge_clock")] -async fn nudge_clock(req: web::Json) -> impl Responder { - if system::nudge_clock(req.microseconds).is_ok() { - HttpResponse::Ok().json(serde_json::json!({ "status": "success", "message": "Clock nudge command issued." })) - } else { - HttpResponse::InternalServerError().json(serde_json::json!({ "status": "error", "message": "Clock nudge command failed." })) - } -} - -#[derive(Deserialize)] -struct SetDateRequest { - date: String, -} - -#[post("/api/set_date")] -async fn set_date(req: web::Json) -> impl Responder { - if system::set_date(&req.date).is_ok() { - HttpResponse::Ok() - .json(serde_json::json!({ "status": "success", "message": "Date update command issued." })) - } else { - HttpResponse::InternalServerError() - .json(serde_json::json!({ "status": "error", "message": "Date update command failed." })) - } -} - #[post("/api/config")] async fn update_config( data: web::Data, @@ -170,44 +122,23 @@ async fn update_config( *config = req.into_inner(); if config::save_config("config.yml", &config).is_ok() { - log::info!("🔄 Saved config via API: {:?}", *config); - - // If timeturner offset is active, trigger a sync immediately. - if config.timeturner_offset.is_active() { - let state = data.ltc_state.lock().unwrap(); - if let Some(frame) = &state.latest { - log::info!("Timeturner offset is active, triggering sync..."); - if system::trigger_sync(frame, &config).is_ok() { - log::info!("Sync triggered successfully after config change."); - } else { - log::error!("Sync failed after config change."); - } - } else { - log::warn!("Timeturner offset is active, but no LTC frame available to sync."); - } - } - + eprintln!("🔄 Saved config via API: {:?}", *config); HttpResponse::Ok().json(&*config) } else { - log::error!("Failed to write config.yml"); - HttpResponse::InternalServerError().json( - serde_json::json!({ "status": "error", "message": "Failed to write config.yml" }), - ) + HttpResponse::InternalServerError().json(serde_json::json!({ "status": "error", "message": "Failed to write config.yml" })) } } pub async fn start_api_server( state: Arc>, config: Arc>, - log_buffer: Arc>>, ) -> std::io::Result<()> { let app_state = web::Data::new(AppState { ltc_state: state, config: config, - log_buffer: log_buffer, }); - log::info!("🚀 Starting API server at http://0.0.0.0:8080"); + println!("🚀 Starting API server at http://0.0.0.0:8080"); HttpServer::new(move || { App::new() @@ -216,9 +147,6 @@ pub async fn start_api_server( .service(manual_sync) .service(get_config) .service(update_config) - .service(get_logs) - .service(nudge_clock) - .service(set_date) // Serve frontend static files .service(fs::Files::new("/", "static/").index_file("index.html")) }) @@ -246,14 +174,13 @@ mod tests { minutes: 2, seconds: 3, frames: 4, - is_drop_frame: false, - frame_rate: Ratio::new(25, 1), + frame_rate: 25.0, timestamp: Utc::now(), }), lock_count: 10, free_count: 1, offset_history: VecDeque::from(vec![1, 2, 3]), - ewma_clock_delta: Some(5.0), + clock_delta_history: VecDeque::from(vec![4, 5, 6]), last_match_status: "IN SYNC".to_string(), last_match_check: Utc::now().timestamp(), } @@ -264,16 +191,11 @@ mod tests { let ltc_state = Arc::new(Mutex::new(get_test_ltc_state())); let config = Arc::new(Mutex::new(Config { hardware_offset_ms: 10, - timeturner_offset: TimeturnerOffset::default(), - default_nudge_ms: 2, - auto_sync_enabled: false, + timeturner_offset: TimeturnerOffset { + hours: 0, minutes: 0, seconds: 0, frames: 0 + } })); - let log_buffer = Arc::new(Mutex::new(VecDeque::new())); - web::Data::new(AppState { - ltc_state, - config, - log_buffer, - }) + web::Data::new(AppState { ltc_state, config }) } #[actix_web::test] @@ -295,32 +217,6 @@ mod tests { assert_eq!(resp.hardware_offset_ms, 10); } - #[actix_web::test] - async fn test_get_status_drop_frame() { - let app_state = get_test_app_state(); - // Set state to drop frame - app_state - .ltc_state - .lock() - .unwrap() - .latest - .as_mut() - .unwrap() - .is_drop_frame = true; - - let app = test::init_service( - App::new() - .app_data(app_state.clone()) - .service(get_status), - ) - .await; - - let req = test::TestRequest::get().uri("/api/status").to_request(); - let resp: ApiStatus = test::call_and_read_body_json(&app, req).await; - - assert_eq!(resp.ltc_timecode, "01:02:03;04"); - } - #[actix_web::test] async fn test_get_config() { let app_state = get_test_app_state(); @@ -357,9 +253,7 @@ mod tests { let new_config_json = serde_json::json!({ "hardwareOffsetMs": 55, - "defaultNudgeMs": 2, - "autoSyncEnabled": true, - "timeturnerOffset": { "hours": 1, "minutes": 2, "seconds": 3, "frames": 4, "milliseconds": 5 } + "timeturnerOffset": { "hours": 1, "minutes": 2, "seconds": 3, "frames": 4 } }); let req = test::TestRequest::post() @@ -370,22 +264,16 @@ mod tests { let resp: Config = test::call_and_read_body_json(&app, req).await; assert_eq!(resp.hardware_offset_ms, 55); - assert_eq!(resp.auto_sync_enabled, true); assert_eq!(resp.timeturner_offset.hours, 1); - assert_eq!(resp.timeturner_offset.milliseconds, 5); let final_config = app_state.config.lock().unwrap(); assert_eq!(final_config.hardware_offset_ms, 55); - assert_eq!(final_config.auto_sync_enabled, true); assert_eq!(final_config.timeturner_offset.hours, 1); - assert_eq!(final_config.timeturner_offset.milliseconds, 5); // Test that the file was written assert!(fs::metadata(config_path).is_ok()); let contents = fs::read_to_string(config_path).unwrap(); assert!(contents.contains("hardwareOffsetMs: 55")); - assert!(contents.contains("autoSyncEnabled: true")); assert!(contents.contains("hours: 1")); - assert!(contents.contains("milliseconds: 5")); // Cleanup let _ = fs::remove_file(config_path); diff --git a/src/config.rs b/src/config.rs index 8669e62..c7caf15 100644 --- a/src/config.rs +++ b/src/config.rs @@ -19,17 +19,11 @@ pub struct TimeturnerOffset { pub minutes: i64, pub seconds: i64, pub frames: i64, - #[serde(default)] - pub milliseconds: i64, } impl TimeturnerOffset { pub fn is_active(&self) -> bool { - self.hours != 0 - || self.minutes != 0 - || self.seconds != 0 - || self.frames != 0 - || self.milliseconds != 0 + self.hours != 0 || self.minutes != 0 || self.seconds != 0 || self.frames != 0 } } @@ -39,14 +33,6 @@ pub struct Config { pub hardware_offset_ms: i64, #[serde(default)] pub timeturner_offset: TimeturnerOffset, - #[serde(default = "default_nudge_ms")] - pub default_nudge_ms: i64, - #[serde(default)] - pub auto_sync_enabled: bool, -} - -fn default_nudge_ms() -> i64 { - 2 // Default nudge is 2ms } impl Config { @@ -60,11 +46,10 @@ impl Config { return Self::default(); } serde_yaml::from_str(&contents).unwrap_or_else(|e| { - log::warn!("Failed to parse config, using default: {}", e); + eprintln!("Failed to parse config, using default: {}", e); Self::default() }) } - } impl Default for Config { @@ -72,35 +57,13 @@ impl Default for Config { Self { hardware_offset_ms: 0, timeturner_offset: TimeturnerOffset::default(), - default_nudge_ms: default_nudge_ms(), - auto_sync_enabled: false, } } } pub fn save_config(path: &str, config: &Config) -> Result<(), Box> { - let mut s = String::new(); - s.push_str("# Hardware offset in milliseconds for correcting capture latency.\n"); - s.push_str(&format!("hardwareOffsetMs: {}\n\n", config.hardware_offset_ms)); - - s.push_str("# Enable automatic clock synchronization.\n"); - s.push_str("# When enabled, the system will perform an initial full sync, then periodically\n"); - s.push_str("# nudge the clock to keep it aligned with the LTC source.\n"); - s.push_str(&format!("autoSyncEnabled: {}\n\n", config.auto_sync_enabled)); - - s.push_str("# Default nudge in milliseconds for adjtimex control.\n"); - s.push_str(&format!("defaultNudgeMs: {}\n\n", config.default_nudge_ms)); - - s.push_str("# Time-turning offsets. All values are added to the incoming LTC time.\n"); - s.push_str("# These can be positive or negative.\n"); - s.push_str("timeturnerOffset:\n"); - s.push_str(&format!(" hours: {}\n", config.timeturner_offset.hours)); - s.push_str(&format!(" minutes: {}\n", config.timeturner_offset.minutes)); - s.push_str(&format!(" seconds: {}\n", config.timeturner_offset.seconds)); - s.push_str(&format!(" frames: {}\n", config.timeturner_offset.frames)); - s.push_str(&format!(" milliseconds: {}\n", config.timeturner_offset.milliseconds)); - - fs::write(path, s)?; + let contents = serde_yaml::to_string(config)?; + fs::write(path, contents)?; Ok(()) } @@ -119,7 +82,7 @@ pub fn watch_config(path: &str) -> Arc> { let new_cfg = Config::load(&watch_path_for_cb); let mut cfg = config_for_cb.lock().unwrap(); *cfg = new_cfg; - log::info!("🔄 Reloaded config.yml: {:?}", *cfg); + eprintln!("🔄 Reloaded config.yml: {:?}", *cfg); } } }) diff --git a/src/logger.rs b/src/logger.rs deleted file mode 100644 index 33c410e..0000000 --- a/src/logger.rs +++ /dev/null @@ -1,52 +0,0 @@ -use chrono::Local; -use log::{LevelFilter, Log, Metadata, Record}; -use std::collections::VecDeque; -use std::sync::{Arc, Mutex}; - -const MAX_LOG_ENTRIES: usize = 100; - -struct RingBufferLogger { - buffer: Arc>>, -} - -impl Log for RingBufferLogger { - fn enabled(&self, metadata: &Metadata) -> bool { - metadata.level() <= LevelFilter::Info - } - - fn log(&self, record: &Record) { - if self.enabled(record.metadata()) { - let msg = format!( - "{} [{}] {}", - Local::now().format("%Y-%m-%d %H:%M:%S"), - record.level(), - record.args() - ); - - // Also print to stderr for console/daemon logging - eprintln!("{}", msg); - - let mut buffer = self.buffer.lock().unwrap(); - if buffer.len() == MAX_LOG_ENTRIES { - buffer.pop_front(); - } - buffer.push_back(msg); - } - } - - fn flush(&self) {} -} - -pub fn setup_logger() -> Arc>> { - let buffer = Arc::new(Mutex::new(VecDeque::with_capacity(MAX_LOG_ENTRIES))); - let logger = RingBufferLogger { - buffer: buffer.clone(), - }; - - // We use `set_boxed_logger` to install our custom logger. - // The `log` crate will then route all log messages to it. - log::set_boxed_logger(Box::new(logger)).expect("Failed to set logger"); - log::set_max_level(LevelFilter::Info); - - buffer -} diff --git a/src/main.rs b/src/main.rs index 8006681..5ee280e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,7 +2,6 @@ mod api; mod config; -mod logger; mod serial_input; mod sync_logic; mod system; @@ -15,7 +14,7 @@ use crate::sync_logic::LtcState; use crate::ui::start_ui; use clap::Parser; use daemonize::Daemonize; -use serialport; +use env_logger; use std::{ fs, @@ -36,8 +35,6 @@ struct Args { enum Command { /// Run as a background daemon providing a web UI. Daemon, - /// Stop the running daemon process. - Kill, } /// Default config content, embedded in the binary. @@ -45,14 +42,6 @@ const DEFAULT_CONFIG: &str = r#" # Hardware offset in milliseconds for correcting capture latency. hardwareOffsetMs: 20 -# Enable automatic clock synchronization. -# When enabled, the system will perform an initial full sync, then periodically -# nudge the clock to keep it aligned with the LTC source. -autoSyncEnabled: false - -# Default nudge in milliseconds for adjtimex control. -defaultNudgeMs: 2 - # Time-turning offsets. All values are added to the incoming LTC time. # These can be positive or negative. timeturnerOffset: @@ -60,7 +49,6 @@ timeturnerOffset: minutes: 0 seconds: 0 frames: 0 - milliseconds: 0 "#; /// If no `config.yml` exists alongside the binary, write out the default. @@ -69,89 +57,32 @@ fn ensure_config() { if !p.exists() { fs::write(p, DEFAULT_CONFIG.trim()) .expect("Failed to write default config.yml"); - log::info!("⚙️ Emitted default config.yml"); + eprintln!("⚙️ Emitted default config.yml"); } } -fn find_serial_port() -> Option { - if let Ok(ports) = serialport::available_ports() { - for p in ports { - if p.port_name.starts_with("/dev/ttyACM") - || p.port_name.starts_with("/dev/ttyAMA") - || p.port_name.starts_with("/dev/ttyUSB") - { - return Some(p.port_name); - } - } - } - None -} - #[tokio::main(flavor = "current_thread")] async fn main() { - // This must be called before any logging statements. - let log_buffer = logger::setup_logger(); let args = Args::parse(); - if let Some(command) = &args.command { - match command { - Command::Daemon => { - log::info!("🚀 Starting daemon..."); + 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"); + // Create files for stdout and stderr in the current directory + let stdout = fs::File::create("daemon.out").expect("Could not create daemon.out"); + let stderr = fs::File::create("daemon.err").expect("Could not create daemon.err"); - let daemonize = Daemonize::new() - .pid_file("ntp_timeturner.pid") // Create a PID file - .working_directory(".") // Keep the same working directory - .stdout(stdout) - .stderr(stderr); + let daemonize = Daemonize::new() + .pid_file("ntp_timeturner.pid") // Create a PID file + .working_directory(".") // Keep the same working directory + .stdout(stdout) + .stderr(stderr); - match daemonize.start() { - Ok(_) => { /* Process is now daemonized */ } - Err(e) => { - log::error!("Error daemonizing: {}", e); - return; // Exit if daemonization fails - } - } - } - Command::Kill => { - log::info!("🛑 Stopping daemon..."); - let pid_file = "ntp_timeturner.pid"; - match fs::read_to_string(pid_file) { - Ok(pid_str) => { - let pid_str = pid_str.trim(); - log::info!("Found daemon with PID: {}", pid_str); - match std::process::Command::new("kill").arg("-9").arg(format!("-{}", pid_str)).status() { - Ok(status) => { - if status.success() { - log::info!("✅ Daemon stopped successfully."); - if fs::remove_file(pid_file).is_err() { - log::warn!("Could not remove PID file '{}'. It may need to be removed manually.", pid_file); - } - } else { - log::error!("'kill' command failed with status: {}. The daemon may not be running, or you may not have permission to stop it.", status); - log::warn!("Attempting to remove stale PID file '{}'...", pid_file); - if fs::remove_file(pid_file).is_ok() { - log::info!("Removed stale PID file."); - } else { - log::warn!("Could not remove PID file."); - } - } - } - Err(e) => { - log::error!("Failed to execute 'kill' command. Is 'kill' in your PATH? Error: {}", e); - } - } - } - Err(_) => { - log::error!("Could not read PID file '{}'. Is the daemon running in this directory?", pid_file); - } - } - return; + match daemonize.start() { + Ok(_) => { /* Process is now daemonized */ } + Err(e) => { + eprintln!("Error daemonizing: {}", e); + return; // Exit if daemonization fails } } } @@ -168,23 +99,13 @@ async fn main() { // 3️⃣ Shared state for UI and serial reader let ltc_state = Arc::new(Mutex::new(LtcState::new())); - // 4️⃣ Find serial port and spawn the serial reader thread - let serial_port_path = match find_serial_port() { - Some(port) => port, - None => { - log::error!("❌ No serial port found. Please connect the Teensy device."); - return; - } - }; - log::info!("Found serial port: {}", serial_port_path); - + // 4️⃣ Spawn the serial reader thread { let tx_clone = tx.clone(); let state_clone = ltc_state.clone(); - let port_clone = serial_port_path.clone(); thread::spawn(move || { start_serial_thread( - &port_clone, + "/dev/ttyACM0", 115200, tx_clone, state_clone, @@ -193,146 +114,53 @@ async fn main() { }); } - // 5️⃣ Spawn UI or setup daemon logging. The web service is only started - // when running as a daemon. The TUI is for interactive foreground use. + // 5️⃣ Spawn UI or setup daemon logging if args.command.is_none() { - // --- Interactive TUI Mode --- - log::info!("🔧 Watching config.yml..."); - log::info!("🚀 Serial thread launched"); - log::info!("🖥️ UI thread launched"); + 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 = serial_port_path; + let port = "/dev/ttyACM0".to_string(); thread::spawn(move || { start_ui(ui_state, port, config_clone); }); } else { - // --- Daemon Mode --- - // In daemon mode, logging is already set up to go to stderr. - // The systemd service will capture it. The web service (API and static files) - // is launched later in the main async block. + // 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️⃣ Spawn the auto-sync thread - { - let sync_state = ltc_state.clone(); - let sync_config = config.clone(); - thread::spawn(move || { - // Wait for the first LTC frame to arrive - loop { - if sync_state.lock().unwrap().latest.is_some() { - log::info!("Auto-sync: Initial LTC frame detected."); - break; - } - thread::sleep(std::time::Duration::from_secs(1)); - } - - // Initial sync - { - let state = sync_state.lock().unwrap(); - let config = sync_config.lock().unwrap(); - if config.auto_sync_enabled { - if let Some(frame) = &state.latest { - log::info!("Auto-sync: Performing initial full sync."); - if system::trigger_sync(frame, &config).is_ok() { - log::info!("Auto-sync: Initial sync successful."); - } else { - log::error!("Auto-sync: Initial sync failed."); - } - } - } - } - - thread::sleep(std::time::Duration::from_secs(10)); - - // Main auto-sync loop - loop { - { - let state = sync_state.lock().unwrap(); - let config = sync_config.lock().unwrap(); - - if config.auto_sync_enabled && state.latest.is_some() { - let delta = state.get_ewma_clock_delta(); - let frame = state.latest.as_ref().unwrap(); - - if delta.abs() > 40 { - log::info!("Auto-sync: Delta > 40ms ({}ms), performing full sync.", delta); - if system::trigger_sync(frame, &config).is_ok() { - log::info!("Auto-sync: Full sync successful."); - } else { - log::error!("Auto-sync: Full sync failed."); - } - } else if delta.abs() >= 1 { - // nudge_clock takes microseconds. A positive delta means clock is - // ahead, so we need a negative nudge. - let nudge_us = -delta * 1000; - log::info!("Auto-sync: Delta is {}ms, nudging clock by {}us.", delta, nudge_us); - if system::nudge_clock(nudge_us).is_ok() { - log::info!("Auto-sync: Clock nudge successful."); - } else { - log::error!("Auto-sync: Clock nudge failed."); - } - } - } - } // locks released here - - thread::sleep(std::time::Duration::from_secs(10)); - } - }); - } - - // 7️⃣ Set up a LocalSet for the API server and main loop + // 6️⃣ Set up a LocalSet for the API server and main loop let local = LocalSet::new(); local .run_until(async move { - // 8️⃣ Spawn the API server task. - // This server provides the JSON API and serves the static web UI files - // from the `static/` directory. It runs in both TUI and daemon modes, - // but is primarily for the web UI used in daemon mode. + // 7️⃣ Spawn the API server thread { let api_state = ltc_state.clone(); let config_clone = config.clone(); - let log_buffer_clone = log_buffer.clone(); task::spawn_local(async move { - if let Err(e) = - start_api_server(api_state, config_clone, log_buffer_clone).await - { - log::error!("API server error: {}", e); + if let Err(e) = start_api_server(api_state, config_clone).await { + eprintln!("API server error: {}", e); } }); } - // 9️⃣ Main logic loop: process frames from serial and update state - let loop_state = ltc_state.clone(); - let loop_config = config.clone(); - let logic_task = task::spawn_blocking(move || { - for frame in rx { - let mut state = loop_state.lock().unwrap(); - let config = loop_config.lock().unwrap(); - - // Only calculate delta for LOCK frames - if frame.status == "LOCK" { - let target_time = system::calculate_target_time(&frame, &config); - let arrival_time_local: chrono::DateTime = - frame.timestamp.with_timezone(&chrono::Local); - let delta = arrival_time_local.signed_duration_since(target_time); - state.record_and_update_ewma_clock_delta(delta.num_milliseconds()); - } - - state.update(frame); - } - }); - - // 1️⃣0️⃣ Keep main thread alive + // 8️⃣ Keep main thread alive if args.command.is_some() { - // In daemon mode, wait forever. The logic_task runs in the background. + // In daemon mode, wait forever. std::future::pending::<()>().await; } else { - // In TUI mode, block until the logic_task finishes (e.g. serial port disconnects) - // This keeps the TUI running. - log::info!("📡 Main thread entering loop..."); - let _ = logic_task.await; + // In TUI mode, block on the channel. + println!("📡 Main thread entering loop..."); + let _ = task::spawn_blocking(move || { + for _frame in rx { + // no-op + } + }) + .await; } }) .await; @@ -344,35 +172,18 @@ mod tests { use std::fs; use std::path::Path; - /// RAII guard to manage config file during tests. - /// It saves the original content of `config.yml` if it exists, - /// and restores it when the guard goes out of scope. - /// If the file didn't exist, it's removed. - struct ConfigGuard { - original_content: Option, - } - - impl ConfigGuard { - fn new() -> Self { - Self { - original_content: fs::read_to_string("config.yml").ok(), - } - } - } + /// RAII guard to ensure config file is cleaned up after test. + struct ConfigGuard; impl Drop for ConfigGuard { fn drop(&mut self) { - if let Some(content) = &self.original_content { - fs::write("config.yml", content).expect("Failed to restore config.yml"); - } else { - let _ = fs::remove_file("config.yml"); - } + let _ = fs::remove_file("config.yml"); } } #[test] fn test_ensure_config() { - let _guard = ConfigGuard::new(); // Cleanup when _guard goes out of scope. + let _guard = ConfigGuard; // Cleanup when _guard goes out of scope. // --- Test 1: File creation --- // Pre-condition: config.yml does not exist. diff --git a/src/serial_input.rs b/src/serial_input.rs index d1dea36..10c3626 100644 --- a/src/serial_input.rs +++ b/src/serial_input.rs @@ -32,7 +32,7 @@ pub fn start_serial_thread( let reader = std::io::BufReader::new(port); let re = Regex::new( - r"\[(LOCK|FREE)\]\s+(\d{2}):(\d{2}):(\d{2})([:;])(\d{2})\s+\|\s+([\d.]+)fps", + r"\[(LOCK|FREE)\]\s+(\d{2}):(\d{2}):(\d{2})[:;](\d{2})\s+\|\s+([\d.]+)fps", ) .unwrap(); @@ -60,12 +60,11 @@ mod tests { use super::*; use std::sync::mpsc; use crate::sync_logic::LtcState; - use num_rational::Ratio; use regex::Regex; fn get_ltc_regex() -> Regex { Regex::new( - r"\[(LOCK|FREE)\]\s+(\d{2}):(\d{2}):(\d{2})([:;])(\d{2})\s+\|\s+([\d.]+)fps", + r"\[(LOCK|FREE)\]\s+(\d{2}):(\d{2}):(\d{2})[:;](\d{2})\s+\|\s+([\d.]+)fps", ).unwrap() } @@ -120,7 +119,7 @@ mod tests { assert_eq!(st.free_count, 1); let received_frame = rx.try_recv().unwrap(); assert_eq!(received_frame.status, "FREE"); - assert_eq!(received_frame.frame_rate, Ratio::new(30000, 1001)); + assert_eq!(received_frame.frame_rate, 29.97); } #[test] diff --git a/src/sync_logic.rs b/src/sync_logic.rs index c6a3e80..b1cbf8b 100644 --- a/src/sync_logic.rs +++ b/src/sync_logic.rs @@ -1,22 +1,8 @@ use crate::config::Config; use chrono::{DateTime, Local, Timelike, Utc}; -use num_rational::Ratio; use regex::Captures; use std::collections::VecDeque; -const EWMA_ALPHA: f64 = 0.1; - -fn get_frame_rate_ratio(rate_str: &str) -> Option> { - match rate_str { - "23.98" => Some(Ratio::new(24000, 1001)), - "24.00" => Some(Ratio::new(24, 1)), - "25.00" => Some(Ratio::new(25, 1)), - "29.97" => Some(Ratio::new(30000, 1001)), - "30.00" => Some(Ratio::new(30, 1)), - _ => None, - } -} - #[derive(Clone, Debug)] pub struct LtcFrame { pub status: String, @@ -24,8 +10,7 @@ pub struct LtcFrame { pub minutes: u32, pub seconds: u32, pub frames: u32, - pub is_drop_frame: bool, - pub frame_rate: Ratio, + pub frame_rate: f64, pub timestamp: DateTime, // arrival stamp } @@ -36,9 +21,8 @@ impl LtcFrame { hours: caps[2].parse().ok()?, minutes: caps[3].parse().ok()?, seconds: caps[4].parse().ok()?, - is_drop_frame: &caps[5] == ";", - frames: caps[6].parse().ok()?, - frame_rate: get_frame_rate_ratio(&caps[7])?, + frames: caps[5].parse().ok()?, + frame_rate: caps[6].parse().ok()?, timestamp, }) } @@ -58,8 +42,8 @@ pub struct LtcState { pub free_count: u32, /// Stores the last up-to-20 raw offset measurements in ms. pub offset_history: VecDeque, - /// EWMA of clock delta. - pub ewma_clock_delta: Option, + /// Stores the last up-to-20 timecode Δ measurements in ms. + pub clock_delta_history: VecDeque, pub last_match_status: String, pub last_match_check: i64, } @@ -71,7 +55,7 @@ impl LtcState { lock_count: 0, free_count: 0, offset_history: VecDeque::with_capacity(20), - ewma_clock_delta: None, + clock_delta_history: VecDeque::with_capacity(20), last_match_status: "UNKNOWN".into(), last_match_check: 0, } @@ -85,14 +69,12 @@ impl LtcState { self.offset_history.push_back(offset_ms); } - /// Update EWMA of clock delta. - pub fn record_and_update_ewma_clock_delta(&mut self, delta_ms: i64) { - let new_delta = delta_ms as f64; - if let Some(current_ewma) = self.ewma_clock_delta { - self.ewma_clock_delta = Some(EWMA_ALPHA * new_delta + (1.0 - EWMA_ALPHA) * current_ewma); - } else { - self.ewma_clock_delta = Some(new_delta); + /// Record one timecode Δ in ms. + pub fn record_clock_delta(&mut self, delta_ms: i64) { + if self.clock_delta_history.len() == 20 { + self.clock_delta_history.pop_front(); } + self.clock_delta_history.push_back(delta_ms); } /// Clear all stored jitter measurements. @@ -100,6 +82,11 @@ impl LtcState { self.offset_history.clear(); } + /// Clear all stored timecode Δ measurements. + pub fn clear_clock_deltas(&mut self) { + self.clock_delta_history.clear(); + } + /// Update LOCK/FREE counts and timecode-match status every 5 s. pub fn update(&mut self, frame: LtcFrame) { match frame.status.as_str() { @@ -121,7 +108,7 @@ impl LtcState { "FREE" => { self.free_count += 1; self.clear_offsets(); - self.ewma_clock_delta = None; + self.clear_clock_deltas(); self.last_match_status = "UNKNOWN".into(); } _ => {} @@ -143,17 +130,30 @@ impl LtcState { /// Convert average jitter into frames (rounded). pub fn average_frames(&self) -> i64 { if let Some(frame) = &self.latest { - let jitter_ms_ratio = Ratio::new(self.average_jitter(), 1); - let frames_ratio = jitter_ms_ratio * frame.frame_rate / Ratio::new(1000, 1); - frames_ratio.round().to_integer() + let ms_per_frame = 1000.0 / frame.frame_rate; + (self.average_jitter() as f64 / ms_per_frame).round() as i64 } else { 0 } } - /// Get EWMA of clock delta, in ms. - pub fn get_ewma_clock_delta(&self) -> i64 { - self.ewma_clock_delta.map_or(0, |v| v.round() as i64) + /// Median timecode Δ over stored history, in ms. + pub fn average_clock_delta(&self) -> i64 { + if self.clock_delta_history.is_empty() { + return 0; + } + + let mut sorted_deltas: Vec = self.clock_delta_history.iter().cloned().collect(); + sorted_deltas.sort_unstable(); + + let mid = sorted_deltas.len() / 2; + if sorted_deltas.len() % 2 == 0 { + // Even number of elements, average the two middle ones + (sorted_deltas[mid - 1] + sorted_deltas[mid]) / 2 + } else { + // Odd number of elements, return the middle one + sorted_deltas[mid] + } } /// Percentage of samples seen in LOCK state versus total. @@ -207,8 +207,7 @@ mod tests { minutes: m, seconds: s, frames: 0, - is_drop_frame: false, - frame_rate: Ratio::new(25, 1), + frame_rate: 25.0, timestamp: Utc::now(), } } @@ -327,28 +326,35 @@ mod tests { } #[test] - fn test_ewma_clock_delta() { + fn test_average_clock_delta_is_median() { let mut state = LtcState::new(); - assert_eq!(state.get_ewma_clock_delta(), 0); - // First value initializes the EWMA - state.record_and_update_ewma_clock_delta(100); - assert_eq!(state.get_ewma_clock_delta(), 100); + // Establish a stable set of values + for _ in 0..19 { + state.record_clock_delta(2); + } + state.record_clock_delta(100); // Add an outlier - // Second value moves it - state.record_and_update_ewma_clock_delta(200); - // 0.1 * 200 + 0.9 * 100 = 20 + 90 = 110 - assert_eq!(state.get_ewma_clock_delta(), 110); + // With 19 `2`s and one `100`, the median should still be `2`. + // The simple average would be (19*2 + 100) / 20 = 138 / 20 = 6. + assert_eq!( + state.average_clock_delta(), + 2, + "Median should ignore the outlier" + ); - // Third value - state.record_and_update_ewma_clock_delta(100); - // 0.1 * 100 + 0.9 * 110 = 10 + 99 = 109 - assert_eq!(state.get_ewma_clock_delta(), 109); - - // Reset on FREE frame - state.update(get_test_frame("FREE", 0, 0, 0)); - assert_eq!(state.get_ewma_clock_delta(), 0); - assert!(state.ewma_clock_delta.is_none()); + // Test with an even number of elements + state.clear_clock_deltas(); + state.record_clock_delta(1); + state.record_clock_delta(2); + state.record_clock_delta(3); + state.record_clock_delta(100); + // sorted: [1, 2, 3, 100]. mid two are 2, 3. average is (2+3)/2 = 2. + assert_eq!( + state.average_clock_delta(), + 2, + "Median of even numbers should be correct" + ); } #[test] @@ -363,12 +369,8 @@ mod tests { assert_eq!(get_sync_status(-9, &config), "CLOCK BEHIND"); assert_eq!(get_sync_status(-100, &config), "CLOCK BEHIND"); - // Test auto-sync status - config.auto_sync_enabled = true; - assert_eq!(get_sync_status(0, &config), "IN SYNC"); - - // Test TIMETURNING status takes precedence - config.timeturner_offset = TimeturnerOffset { hours: 1, minutes: 0, seconds: 0, frames: 0, milliseconds: 0 }; + // 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"); } diff --git a/src/system.rs b/src/system.rs index 8db481d..979df17 100644 --- a/src/system.rs +++ b/src/system.rs @@ -1,7 +1,6 @@ use crate::config::Config; use crate::sync_logic::LtcFrame; -use chrono::{DateTime, Duration as ChronoDuration, Local, TimeZone}; -use num_rational::Ratio; +use chrono::{DateTime, Duration as ChronoDuration, Local, NaiveTime, TimeZone}; use std::process::Command; /// Check if Chrony is active @@ -40,24 +39,11 @@ pub fn ntp_service_toggle(start: bool) { pub fn calculate_target_time(frame: &LtcFrame, config: &Config) -> DateTime { 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"); - // Total seconds from timecode components - let timecode_secs = - frame.hours as i64 * 3600 + frame.minutes as i64 * 60 + frame.seconds as i64; - - // Timecode is always treated as wall-clock time. NDF scaling is not applied - // as the LTC source appears to be pre-compensated. - let total_duration_secs = - Ratio::new(timecode_secs, 1) + Ratio::new(frame.frames as i64, 1) / frame.frame_rate; - - // Convert to milliseconds - let total_ms = (total_duration_secs * Ratio::new(1000, 1)) - .round() - .to_integer(); - - let naive_midnight = today_local.and_hms_opt(0, 0, 0).unwrap(); - let naive_dt = naive_midnight + ChronoDuration::milliseconds(total_ms); - + let naive_dt = today_local.and_time(timecode); let mut dt_local = Local .from_local_datetime(&naive_dt) .single() @@ -70,9 +56,8 @@ pub fn calculate_target_time(frame: &LtcFrame, config: &Config) -> DateTime Result { @@ -119,67 +104,11 @@ pub fn trigger_sync(frame: &LtcFrame, config: &Config) -> Result { } } -pub fn nudge_clock(microseconds: i64) -> Result<(), ()> { - #[cfg(target_os = "linux")] - { - let success = Command::new("sudo") - .arg("adjtimex") - .arg("--singleshot") - .arg(microseconds.to_string()) - .status() - .map(|s| s.success()) - .unwrap_or(false); - - if success { - log::info!("Nudged clock by {} us", microseconds); - Ok(()) - } else { - log::error!("Failed to nudge clock with adjtimex"); - Err(()) - } - } - #[cfg(not(target_os = "linux"))] - { - let _ = microseconds; - log::warn!("Clock nudging is only supported on Linux."); - Err(()) - } -} - -pub fn set_date(date: &str) -> Result<(), ()> { - #[cfg(target_os = "linux")] - { - let datetime_str = format!("{} 10:00:00", date); - let success = Command::new("sudo") - .arg("date") - .arg("--set") - .arg(&datetime_str) - .status() - .map(|s| s.success()) - .unwrap_or(false); - - if success { - log::info!("Set system date and time to {}", datetime_str); - Ok(()) - } else { - log::error!("Failed to set system date and time"); - Err(()) - } - } - #[cfg(not(target_os = "linux"))] - { - let _ = date; - log::warn!("Date setting is only supported on Linux."); - Err(()) - } -} - #[cfg(test)] mod tests { use super::*; use crate::config::TimeturnerOffset; use chrono::{Timelike, Utc}; - use num_rational::Ratio; // Helper to create a test frame fn get_test_frame(h: u32, m: u32, s: u32, f: u32) -> LtcFrame { @@ -189,8 +118,7 @@ mod tests { minutes: m, seconds: s, frames: f, - is_drop_frame: false, - frame_rate: Ratio::new(25, 1), + frame_rate: 25.0, timestamp: Utc::now(), } } @@ -222,7 +150,6 @@ mod tests { minutes: 5, seconds: 10, frames: 12, // 12 frames at 25fps is 480ms - milliseconds: 20, }; let target_time = calculate_target_time(&frame, &config); @@ -230,8 +157,8 @@ mod tests { assert_eq!(target_time.hour(), 11); assert_eq!(target_time.minute(), 25); assert_eq!(target_time.second(), 40); - // 480ms + 20ms = 500ms - assert_eq!(target_time.nanosecond(), 500_000_000); + // 480ms + assert_eq!(target_time.nanosecond(), 480_000_000); } #[test] @@ -243,20 +170,13 @@ mod tests { minutes: -5, seconds: -10, frames: -12, // -480ms - milliseconds: -80, }; 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(), 19); - assert_eq!(target_time.nanosecond(), 920_000_000); - } - - #[test] - fn test_nudge_clock_on_non_linux() { - #[cfg(not(target_os = "linux"))] - assert!(nudge_clock(1000).is_err()); + assert_eq!(target_time.second(), 20); + assert_eq!(target_time.nanosecond(), 0); } } diff --git a/src/ui.rs b/src/ui.rs index 5854f4a..7d1c265 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -9,6 +9,7 @@ use std::collections::VecDeque; use chrono::{ DateTime, Local, Timelike, Utc, + NaiveTime, TimeZone, Duration as ChronoDuration, }; use crossterm::{ cursor::{Hide, MoveTo, Show}, @@ -19,11 +20,9 @@ use crossterm::{ }; use crate::config::Config; +use get_if_addrs::get_if_addrs; use crate::sync_logic::{get_jitter_status, get_sync_status, LtcState}; use crate::system; -use get_if_addrs::get_if_addrs; -use num_rational::Ratio; -use num_traits::ToPrimitive; pub fn start_ui( @@ -36,6 +35,7 @@ pub fn start_ui( terminal::enable_raw_mode().unwrap(); let mut logs: VecDeque = VecDeque::with_capacity(10); + let mut out_of_sync_since: Option = None; let mut last_delta_update = Instant::now() - Duration::from_secs(1); let mut cached_delta_ms: i64 = 0; let mut cached_delta_frames: i64 = 0; @@ -54,7 +54,7 @@ pub fn start_ui( .map(|ifa| ifa.ip().to_string()) .collect(); - // 3️⃣ jitter + // 3️⃣ jitter + Δ { let mut st = state.lock().unwrap(); if let Some(frame) = st.latest.clone() { @@ -64,6 +64,33 @@ pub fn start_ui( let raw = (now_utc - frame.timestamp).num_milliseconds(); let measured = raw - hw_offset_ms; st.record_offset(measured); + + // Δ = system clock - LTC timecode (use LOCAL time, with offset) + let today_local = Local::now().date_naive(); + let ms = ((frame.frames as f64 / frame.frame_rate) * 1000.0).round() as u32; + let tc_naive = NaiveTime::from_hms_milli_opt( + frame.hours, frame.minutes, frame.seconds, ms, + ).expect("Invalid LTC timecode"); + let naive_dt_local = today_local.and_time(tc_naive); + let mut dt_local = Local + .from_local_datetime(&naive_dt_local) + .single() + .expect("Invalid local time"); + + // Apply timeturner offset before calculating delta + let offset = &cfg.timeturner_offset; + dt_local = dt_local + + ChronoDuration::hours(offset.hours) + + ChronoDuration::minutes(offset.minutes) + + ChronoDuration::seconds(offset.seconds); + 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); + + let delta_ms = (Local::now() - dt_local).num_milliseconds(); + st.record_clock_delta(delta_ms); + } else { + st.clear_offsets(); + st.clear_clock_deltas(); } } } @@ -76,7 +103,7 @@ pub fn start_ui( st.average_frames(), st.timecode_match().to_string(), st.lock_ratio(), - st.get_ewma_clock_delta(), + st.average_clock_delta(), ) }; @@ -84,9 +111,8 @@ pub fn start_ui( if last_delta_update.elapsed() >= Duration::from_secs(1) { cached_delta_ms = avg_delta; if let Some(frame) = &state.lock().unwrap().latest { - let delta_ms_ratio = Ratio::new(avg_delta, 1); - let frames_ratio = delta_ms_ratio * frame.frame_rate / Ratio::new(1000, 1); - cached_delta_frames = frames_ratio.round().to_integer(); + let frame_ms = 1000.0 / frame.frame_rate; + cached_delta_frames = ((avg_delta as f64 / frame_ms).round()) as i64; } else { cached_delta_frames = 0; } @@ -96,7 +122,28 @@ pub fn start_ui( // 6️⃣ sync status wording let sync_status = get_sync_status(cached_delta_ms, &cfg); - // 7️⃣ header & LTC metrics display + // 7️⃣ auto‑sync (same as manual but delayed) + if sync_status != "IN SYNC" && sync_status != "TIMETURNING" { + 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 system::trigger_sync(frame, &cfg) { + Ok(ts) => format!("🔄 Auto‑synced to LTC: {}", ts), + Err(_) => "❌ Auto‑sync failed".into(), + }; + if logs.len() == 10 { logs.pop_front(); } + logs.push_back(entry); + } + out_of_sync_since = None; + } + } else { + out_of_sync_since = Some(Instant::now()); + } + } else { + out_of_sync_since = None; + } + + // 8️⃣ header & LTC metrics display { let st = state.lock().unwrap(); let opt = st.latest.as_ref(); @@ -107,7 +154,7 @@ pub fn start_ui( None => "LTC Timecode : …".to_string(), }; let fr_str = match opt { - Some(f) => format!("Frame Rate : {:.2}fps", f.frame_rate.to_f64().unwrap_or(0.0)), + Some(f) => format!("Frame Rate : {:.2}fps", f.frame_rate), None => "Frame Rate : …".to_string(), }; @@ -232,3 +279,10 @@ pub fn start_ui( } } +#[cfg(test)] +mod tests { + #[allow(unused_imports)] + use super::*; + #[allow(unused_imports)] + use crate::config::TimeturnerOffset; +} diff --git a/static/assets/FuturaStdHeavy.otf b/static/assets/FuturaStdHeavy.otf deleted file mode 100644 index 7b8c22d..0000000 Binary files a/static/assets/FuturaStdHeavy.otf and /dev/null differ diff --git a/static/assets/HaveBlueTransWh.png b/static/assets/HaveBlueTransWh.png deleted file mode 100644 index d9a123d..0000000 Binary files a/static/assets/HaveBlueTransWh.png and /dev/null differ diff --git a/static/assets/favicon.png b/static/assets/favicon.png deleted file mode 100644 index 3683c35..0000000 Binary files a/static/assets/favicon.png and /dev/null differ diff --git a/static/assets/header.png b/static/assets/header.png deleted file mode 100644 index f1677ed..0000000 Binary files a/static/assets/header.png and /dev/null differ diff --git a/static/assets/quartz-ms-regular.ttf b/static/assets/quartz-ms-regular.ttf deleted file mode 100644 index 15c7ce4..0000000 Binary files a/static/assets/quartz-ms-regular.ttf and /dev/null differ diff --git a/static/assets/timeturner_2398.png b/static/assets/timeturner_2398.png deleted file mode 100644 index 763bcba..0000000 Binary files a/static/assets/timeturner_2398.png and /dev/null differ diff --git a/static/assets/timeturner_24.png b/static/assets/timeturner_24.png deleted file mode 100644 index ffc75d0..0000000 Binary files a/static/assets/timeturner_24.png and /dev/null differ diff --git a/static/assets/timeturner_25.png b/static/assets/timeturner_25.png deleted file mode 100644 index 3b44c93..0000000 Binary files a/static/assets/timeturner_25.png and /dev/null differ diff --git a/static/assets/timeturner_2997.png b/static/assets/timeturner_2997.png deleted file mode 100644 index 0bd27fd..0000000 Binary files a/static/assets/timeturner_2997.png and /dev/null differ diff --git a/static/assets/timeturner_2997DF.png b/static/assets/timeturner_2997DF.png deleted file mode 100644 index bf03215..0000000 Binary files a/static/assets/timeturner_2997DF.png and /dev/null differ diff --git a/static/assets/timeturner_30.png b/static/assets/timeturner_30.png deleted file mode 100644 index 4ce0211..0000000 Binary files a/static/assets/timeturner_30.png and /dev/null differ diff --git a/static/assets/timeturner_controls.png b/static/assets/timeturner_controls.png deleted file mode 100644 index a91f39b..0000000 Binary files a/static/assets/timeturner_controls.png and /dev/null differ diff --git a/static/assets/timeturner_default.png b/static/assets/timeturner_default.png deleted file mode 100644 index 734aa8d..0000000 Binary files a/static/assets/timeturner_default.png and /dev/null differ diff --git a/static/assets/timeturner_delta_green.png b/static/assets/timeturner_delta_green.png deleted file mode 100644 index ddc84b9..0000000 Binary files a/static/assets/timeturner_delta_green.png and /dev/null differ diff --git a/static/assets/timeturner_delta_orange.png b/static/assets/timeturner_delta_orange.png deleted file mode 100644 index 64e9776..0000000 Binary files a/static/assets/timeturner_delta_orange.png and /dev/null differ diff --git a/static/assets/timeturner_delta_red.png b/static/assets/timeturner_delta_red.png deleted file mode 100644 index c7272ac..0000000 Binary files a/static/assets/timeturner_delta_red.png and /dev/null differ diff --git a/static/assets/timeturner_jitter_green.png b/static/assets/timeturner_jitter_green.png deleted file mode 100644 index 8cc64e3..0000000 Binary files a/static/assets/timeturner_jitter_green.png and /dev/null differ diff --git a/static/assets/timeturner_jitter_orange.png b/static/assets/timeturner_jitter_orange.png deleted file mode 100644 index 96c5f84..0000000 Binary files a/static/assets/timeturner_jitter_orange.png and /dev/null differ diff --git a/static/assets/timeturner_jitter_red.png b/static/assets/timeturner_jitter_red.png deleted file mode 100644 index 8813159..0000000 Binary files a/static/assets/timeturner_jitter_red.png and /dev/null differ diff --git a/static/assets/timeturner_lock_green.png b/static/assets/timeturner_lock_green.png deleted file mode 100644 index 0659c60..0000000 Binary files a/static/assets/timeturner_lock_green.png and /dev/null differ diff --git a/static/assets/timeturner_lock_orange.png b/static/assets/timeturner_lock_orange.png deleted file mode 100644 index 836a376..0000000 Binary files a/static/assets/timeturner_lock_orange.png and /dev/null differ diff --git a/static/assets/timeturner_lock_red.png b/static/assets/timeturner_lock_red.png deleted file mode 100644 index aa8740d..0000000 Binary files a/static/assets/timeturner_lock_red.png and /dev/null differ diff --git a/static/assets/timeturner_logs.png b/static/assets/timeturner_logs.png deleted file mode 100644 index 6bdd935..0000000 Binary files a/static/assets/timeturner_logs.png and /dev/null differ diff --git a/static/assets/timeturner_ltc_green.png b/static/assets/timeturner_ltc_green.png deleted file mode 100644 index 4329913..0000000 Binary files a/static/assets/timeturner_ltc_green.png and /dev/null differ diff --git a/static/assets/timeturner_ltc_orange.png b/static/assets/timeturner_ltc_orange.png deleted file mode 100644 index b060ac2..0000000 Binary files a/static/assets/timeturner_ltc_orange.png and /dev/null differ diff --git a/static/assets/timeturner_ltc_red.png b/static/assets/timeturner_ltc_red.png deleted file mode 100644 index a8e7f96..0000000 Binary files a/static/assets/timeturner_ltc_red.png and /dev/null differ diff --git a/static/assets/timeturner_network.png b/static/assets/timeturner_network.png deleted file mode 100644 index 06ec4b9..0000000 Binary files a/static/assets/timeturner_network.png and /dev/null differ diff --git a/static/assets/timeturner_ntp_green.png b/static/assets/timeturner_ntp_green.png deleted file mode 100644 index caf824d..0000000 Binary files a/static/assets/timeturner_ntp_green.png and /dev/null differ diff --git a/static/assets/timeturner_ntp_orange.png b/static/assets/timeturner_ntp_orange.png deleted file mode 100644 index 88319b5..0000000 Binary files a/static/assets/timeturner_ntp_orange.png and /dev/null differ diff --git a/static/assets/timeturner_ntp_red.png b/static/assets/timeturner_ntp_red.png deleted file mode 100644 index 16e66ee..0000000 Binary files a/static/assets/timeturner_ntp_red.png and /dev/null differ diff --git a/static/assets/timeturner_sync_green.png b/static/assets/timeturner_sync_green.png deleted file mode 100644 index 9b4988e..0000000 Binary files a/static/assets/timeturner_sync_green.png and /dev/null differ diff --git a/static/assets/timeturner_sync_orange.png b/static/assets/timeturner_sync_orange.png deleted file mode 100644 index 0b41130..0000000 Binary files a/static/assets/timeturner_sync_orange.png and /dev/null differ diff --git a/static/assets/timeturner_sync_red.png b/static/assets/timeturner_sync_red.png deleted file mode 100644 index 1c4c4c9..0000000 Binary files a/static/assets/timeturner_sync_red.png and /dev/null differ diff --git a/static/assets/timeturner_timeturning.png b/static/assets/timeturner_timeturning.png deleted file mode 100644 index fd3eaeb..0000000 Binary files a/static/assets/timeturner_timeturning.png and /dev/null differ diff --git a/static/favicon.ico b/static/favicon.ico deleted file mode 100644 index 83d6317..0000000 Binary files a/static/favicon.ico and /dev/null differ diff --git a/static/icon-map.js b/static/icon-map.js deleted file mode 100644 index 64336b3..0000000 --- a/static/icon-map.js +++ /dev/null @@ -1,43 +0,0 @@ -// In this file, you can define the paths to your local icon image files. -const iconMap = { - ltcStatus: { - 'LOCK': { src: 'assets/timeturner_ltc_green.png', tooltip: 'LTC signal is locked and stable.' }, - 'FREE': { src: 'assets/timeturner_ltc_orange.png', tooltip: 'LTC signal is in freewheel mode.' }, - 'default': { src: 'assets/timeturner_ltc_red.png', tooltip: 'LTC signal is not detected.' } - }, - ntpActive: { - true: { src: 'assets/timeturner_ntp_green.png', tooltip: 'NTP service is active.' }, - false: { src: 'assets/timeturner_ntp_red.png', tooltip: 'NTP service is inactive.' } - }, - syncStatus: { - 'IN SYNC': { src: 'assets/timeturner_sync_green.png', tooltip: 'System clock is in sync with LTC source.' }, - 'CLOCK AHEAD': { src: 'assets/timeturner_sync_orange.png', tooltip: 'System clock is ahead of the LTC source.' }, - 'CLOCK BEHIND': { src: 'assets/timeturner_sync_orange.png', tooltip: 'System clock is behind the LTC source.' }, - 'TIMETURNING': { src: 'assets/timeturner_timeturning.png', tooltip: 'Timeturner offset is active.' }, - 'default': { src: 'assets/timeturner_sync_red.png', tooltip: 'Sync status is unknown.' } - }, - jitterStatus: { - 'GOOD': { src: 'assets/timeturner_jitter_green.png', tooltip: 'Clock jitter is within acceptable limits.' }, - 'AVERAGE': { src: 'assets/timeturner_jitter_orange.png', tooltip: 'Clock jitter is moderate.' }, - 'BAD': { src: 'assets/timeturner_jitter_red.png', tooltip: 'Clock jitter is high and may affect accuracy.' }, - 'default': { src: 'assets/timeturner_jitter_red.png', tooltip: 'Jitter status is unknown.' } - }, - deltaStatus: { - 'good': { src: 'assets/timeturner_delta_green.png', tooltip: 'Clock delta is 0ms.' }, - 'average': { src: 'assets/timeturner_delta_orange.png', tooltip: 'Clock delta is less than 10ms.' }, - 'bad': { src: 'assets/timeturner_delta_red.png', tooltip: 'Clock delta is 10ms or greater.' } - }, - frameRate: { - '23.98fps': { src: 'assets/timeturner_2398.png', tooltip: '23.98 frames per second' }, - '24.00fps': { src: 'assets/timeturner_24.png', tooltip: '24.00 frames per second' }, - '25.00fps': { src: 'assets/timeturner_25.png', tooltip: '25.00 frames per second' }, - '29.97fps': { src: 'assets/timeturner_2997.png', tooltip: '29.97 frames per second' }, - '30.00fps': { src: 'assets/timeturner_30.png', tooltip: '30.00 frames per second' }, - 'default': { src: 'assets/timeturner_default.png', tooltip: 'Unknown frame rate' } - }, - lockRatio: { - 'good': { src: 'assets/timeturner_lock_green.png', tooltip: 'Lock ratio is 100%.' }, - 'average': { src: 'assets/timeturner_lock_orange.png', tooltip: 'Lock ratio is 90% or higher.' }, - 'bad': { src: 'assets/timeturner_lock_red.png', tooltip: 'Lock ratio is below 90%.' } - } -}; diff --git a/static/index.html b/static/index.html index 02bb279..74a8a17 100644 --- a/static/index.html +++ b/static/index.html @@ -3,139 +3,67 @@ - Fetch | Hachi + NTP TimeTurner -
- - - - - +

NTP TimeTurner

-

LTC Input

+

LTC Status

+

--

--:--:--:--

-
- - - -
+

-- fps

+

Lock Ratio: --%

-

NTP Clock

+

System Clock

--:--:--.---

-

---- -- --

-
- - - - -
-

Δ -- ms (-- frames)

+

NTP Service: --

+

Sync Status: --

+
+ + +
+

Clock Offset

+

Delta: -- ms (-- frames)

+

Jitter: --

-
- Network Icon -

Network

-
-

--

+

Network

+
    +
  • --
  • +
-
-
- Controls Icon -

Controls

+
+

Controls

+
+ +
-
- - -
- -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
-
- - - -
- -
- - - - -
+
+ + + + +
-
- - -
-
- Logs Icon -

Logs

-
-
-

-                        
-
+
+ + +
- +
+
- - diff --git a/static/index_dev.html b/static/index_dev.html deleted file mode 100644 index edc555d..0000000 --- a/static/index_dev.html +++ /dev/null @@ -1,141 +0,0 @@ - - - - - - NTP TimeTurner - - - - -
- - - - - -
- -
-

LTC Input

-

--:--:--:--

-
- - - -
-
- - -
-

NTP Clock

-

--:--:--.---

-

---- -- --

-
- - - - -
-

Δ -- ms (-- frames)

-
- - -
-
- Network Icon -

Network

-
-

--

-
- - -
-
- Controls Icon -

Controls

-
-
-
- - -
-
- - -
-
- -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
-
- - - -
-
- - - - - -
-
- - - - -
-
-
- - -
-
- Logs Icon -

Logs

-
-
-

-                        
-
-
- -
- - - - - diff --git a/static/mock-data.js b/static/mock-data.js deleted file mode 100644 index a953e59..0000000 --- a/static/mock-data.js +++ /dev/null @@ -1,168 +0,0 @@ -// This file contains mock data sets for UI development and testing without a live backend. -const mockApiDataSets = { - allGood: { - status: { - ltc_status: 'LOCK', - ltc_timecode: '10:20:30:00', - frame_rate: '25.00fps', - lock_ratio: 99.5, - system_clock: '10:20:30.500', - system_date: '2025-08-07', - ntp_active: true, - sync_status: 'IN SYNC', - timecode_delta_ms: 5, - timecode_delta_frames: 0.125, - jitter_status: 'GOOD', - interfaces: ['192.168.1.100/24 (eth0)', '10.0.0.5/8 (wlan0)'], - }, - config: { - hardwareOffsetMs: 10, - autoSyncEnabled: true, - defaultNudgeMs: 2, - timeturnerOffset: { hours: 1, minutes: 2, seconds: 3, frames: 4, milliseconds: 50 }, - }, - logs: [ - '2025-08-07 10:20:30 [INFO] Starting up...', - '2025-08-07 10:20:32 [INFO] LTC LOCK detected. Frame rate: 25.00fps.', - '2025-08-07 10:20:35 [INFO] Initial sync complete. Clock adjusted by -15ms.', - ] - }, - ltcFree: { - status: { - ltc_status: 'FREE', - ltc_timecode: '11:22:33:11', - frame_rate: '25.00fps', - lock_ratio: 40.2, - system_clock: '11:22:33.800', - system_date: '2025-08-07', - ntp_active: true, - sync_status: 'IN SYNC', - timecode_delta_ms: 3, - timecode_delta_frames: 0.075, - jitter_status: 'GOOD', - interfaces: ['192.168.1.100/24 (eth0)'], - }, - config: { - hardwareOffsetMs: 10, - autoSyncEnabled: true, - defaultNudgeMs: 2, - timeturnerOffset: { hours: 0, minutes: 0, seconds: 0, frames: 0, milliseconds: 0 }, - }, - logs: [ '2025-08-07 11:22:30 [WARN] LTC signal lost, entering freewheel.' ] - }, - clockAhead: { - status: { - ltc_status: 'LOCK', - ltc_timecode: '12:00:05:00', - frame_rate: '25.00fps', - lock_ratio: 98.1, - system_clock: '12:00:04.500', - system_date: '2025-08-07', - ntp_active: true, - sync_status: 'CLOCK AHEAD', - timecode_delta_ms: -500, - timecode_delta_frames: -12.5, - jitter_status: 'AVERAGE', - interfaces: ['192.168.1.100/24 (eth0)'], - }, - config: { - hardwareOffsetMs: 10, - autoSyncEnabled: true, - defaultNudgeMs: 2, - timeturnerOffset: { hours: 0, minutes: 0, seconds: 0, frames: 0, milliseconds: 0 }, - }, - logs: [ '2025-08-07 12:00:00 [WARN] System clock is ahead of LTC source by 500ms.' ] - }, - clockBehind: { - status: { - ltc_status: 'LOCK', - ltc_timecode: '13:30:10:00', - frame_rate: '25.00fps', - lock_ratio: 99.9, - system_clock: '13:30:10.800', - system_date: '2025-08-07', - ntp_active: true, - sync_status: 'CLOCK BEHIND', - timecode_delta_ms: 800, - timecode_delta_frames: 20, - jitter_status: 'AVERAGE', - interfaces: ['192.168.1.100/24 (eth0)'], - }, - config: { - hardwareOffsetMs: 10, - autoSyncEnabled: true, - defaultNudgeMs: 2, - timeturnerOffset: { hours: 0, minutes: 0, seconds: 0, frames: 0, milliseconds: 0 }, - }, - logs: [ '2025-08-07 13:30:00 [WARN] System clock is behind LTC source by 800ms.' ] - }, - timeturning: { - status: { - ltc_status: 'LOCK', - ltc_timecode: '14:00:00:00', - frame_rate: '25.00fps', - lock_ratio: 100, - system_clock: '15:02:03.050', - system_date: '2025-08-07', - ntp_active: true, - sync_status: 'TIMETURNING', - timecode_delta_ms: 3723050, // a big number - timecode_delta_frames: 93076, - jitter_status: 'GOOD', - interfaces: ['192.168.1.100/24 (eth0)'], - }, - config: { - hardwareOffsetMs: 10, - autoSyncEnabled: false, - defaultNudgeMs: 2, - timeturnerOffset: { hours: 1, minutes: 2, seconds: 3, frames: 4, milliseconds: 50 }, - }, - logs: [ '2025-08-07 14:00:00 [INFO] Timeturner offset is active.' ] - }, - badJitter: { - status: { - ltc_status: 'LOCK', - ltc_timecode: '15:15:15:15', - frame_rate: '25.00fps', - lock_ratio: 95.0, - system_clock: '15:15:15.515', - system_date: '2025-08-07', - ntp_active: true, - sync_status: 'IN SYNC', - timecode_delta_ms: 10, - timecode_delta_frames: 0.25, - jitter_status: 'BAD', - interfaces: ['192.168.1.100/24 (eth0)'], - }, - config: { - hardwareOffsetMs: 10, - autoSyncEnabled: true, - defaultNudgeMs: 2, - timeturnerOffset: { hours: 0, minutes: 0, seconds: 0, frames: 0, milliseconds: 0 }, - }, - logs: [ '2025-08-07 15:15:00 [ERROR] High jitter detected on LTC source.' ] - }, - ntpInactive: { - status: { - ltc_status: 'UNKNOWN', - ltc_timecode: '--:--:--:--', - frame_rate: '--', - lock_ratio: 0, - system_clock: '16:00:00.000', - system_date: '2025-08-07', - ntp_active: false, - sync_status: 'UNKNOWN', - timecode_delta_ms: 0, - timecode_delta_frames: 0, - jitter_status: 'UNKNOWN', - interfaces: [], - }, - config: { - hardwareOffsetMs: 0, - autoSyncEnabled: false, - defaultNudgeMs: 2, - timeturnerOffset: { hours: 0, minutes: 0, seconds: 0, frames: 0, milliseconds: 0 }, - }, - logs: [ '2025-08-07 16:00:00 [INFO] NTP service is inactive.' ] - } -}; diff --git a/static/script.js b/static/script.js index 634ed33..2195bfd 100644 --- a/static/script.js +++ b/static/script.js @@ -1,263 +1,83 @@ -document.addEventListener('DOMContentLoaded', () => { - // --- Mock Data Configuration --- - // Set to true to use mock data, false for live API. - const useMockData = false; - let currentMockSetKey = 'allGood'; // Default mock data set - - let lastApiData = null; - let lastApiFetchTime = null; - +document.addEventListener('DOMContentLoaded', () => { const statusElements = { ltcStatus: document.getElementById('ltc-status'), ltcTimecode: document.getElementById('ltc-timecode'), frameRate: document.getElementById('frame-rate'), lockRatio: document.getElementById('lock-ratio'), systemClock: document.getElementById('system-clock'), - systemDate: document.getElementById('system-date'), ntpActive: document.getElementById('ntp-active'), syncStatus: document.getElementById('sync-status'), - deltaStatus: document.getElementById('delta-status'), + deltaMs: document.getElementById('delta-ms'), + deltaFrames: document.getElementById('delta-frames'), jitterStatus: document.getElementById('jitter-status'), - deltaText: document.getElementById('delta-text'), interfaces: document.getElementById('interfaces'), - logs: document.getElementById('logs'), }; const hwOffsetInput = document.getElementById('hw-offset'); - const autoSyncCheckbox = document.getElementById('auto-sync-enabled'); const offsetInputs = { h: document.getElementById('offset-h'), m: document.getElementById('offset-m'), s: document.getElementById('offset-s'), f: document.getElementById('offset-f'), - ms: document.getElementById('offset-ms'), }; const saveConfigButton = document.getElementById('save-config'); const manualSyncButton = document.getElementById('manual-sync'); const syncMessage = document.getElementById('sync-message'); - const nudgeDownButton = document.getElementById('nudge-down'); - const nudgeUpButton = document.getElementById('nudge-up'); - const nudgeValueInput = document.getElementById('nudge-value'); - const nudgeMessage = document.getElementById('nudge-message'); - - const dateInput = document.getElementById('date-input'); - const setDateButton = document.getElementById('set-date'); - const dateMessage = document.getElementById('date-message'); - - // --- Collapsible Sections --- - const controlsToggle = document.getElementById('controls-toggle'); - const controlsContent = document.getElementById('controls-content'); - const logsToggle = document.getElementById('logs-toggle'); - const logsContent = document.getElementById('logs-content'); - - // --- Mock Controls Setup --- - const mockControls = document.getElementById('mock-controls'); - const mockDataSelector = document.getElementById('mock-data-selector'); - - function setupMockControls() { - if (useMockData) { - mockControls.style.display = 'block'; - - // Populate dropdown - Object.keys(mockApiDataSets).forEach(key => { - const option = document.createElement('option'); - option.value = key; - option.textContent = key.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase()); - mockDataSelector.appendChild(option); - }); - - mockDataSelector.value = currentMockSetKey; - - // Handle selection change - mockDataSelector.addEventListener('change', (event) => { - currentMockSetKey = event.target.value; - // Re-fetch all data from the new mock set - fetchStatus(); - fetchConfig(); - fetchLogs(); - }); - } - } - function updateStatus(data) { - const ltcStatus = data.ltc_status || 'UNKNOWN'; - const ltcIconInfo = iconMap.ltcStatus[ltcStatus] || iconMap.ltcStatus.default; - statusElements.ltcStatus.innerHTML = ``; - statusElements.ltcStatus.className = ltcStatus.toLowerCase(); + statusElements.ltcStatus.textContent = data.ltc_status; statusElements.ltcTimecode.textContent = data.ltc_timecode; - - const frameRate = data.frame_rate || 'unknown'; - const frameRateIconInfo = iconMap.frameRate[frameRate] || iconMap.frameRate.default; - statusElements.frameRate.innerHTML = ``; - - const lockRatio = data.lock_ratio; - let lockRatioCategory; - if (lockRatio === 100) { - lockRatioCategory = 'good'; - } else if (lockRatio >= 90) { - lockRatioCategory = 'average'; - } else { - lockRatioCategory = 'bad'; - } - const lockRatioIconInfo = iconMap.lockRatio[lockRatioCategory]; - statusElements.lockRatio.innerHTML = ``; + statusElements.frameRate.textContent = data.frame_rate; + statusElements.lockRatio.textContent = data.lock_ratio.toFixed(2); statusElements.systemClock.textContent = data.system_clock; - statusElements.systemDate.textContent = data.system_date; + + statusElements.ntpActive.textContent = data.ntp_active ? 'Active' : 'Inactive'; + statusElements.ntpActive.className = data.ntp_active ? 'active' : 'inactive'; - // Autofill the date input, but don't overwrite user edits. - if (!lastApiData || dateInput.value === lastApiData.system_date) { - dateInput.value = data.system_date; - } + statusElements.syncStatus.textContent = data.sync_status; + statusElements.syncStatus.className = data.sync_status.replace(/\s+/g, '-').toLowerCase(); - const ntpIconInfo = iconMap.ntpActive[!!data.ntp_active]; - if (data.ntp_active) { - statusElements.ntpActive.innerHTML = ``; - statusElements.ntpActive.className = 'active'; - } else { - statusElements.ntpActive.innerHTML = ``; - statusElements.ntpActive.className = 'inactive'; - } - - const syncStatus = data.sync_status || 'UNKNOWN'; - const syncIconInfo = iconMap.syncStatus[syncStatus] || iconMap.syncStatus.default; - statusElements.syncStatus.innerHTML = ``; - statusElements.syncStatus.className = syncStatus.replace(/\s+/g, '-').toLowerCase(); - - // Delta Status - const deltaMs = data.timecode_delta_ms; - let deltaCategory; - if (deltaMs === 0) { - deltaCategory = 'good'; - } else if (Math.abs(deltaMs) < 10) { - deltaCategory = 'average'; - } else { - deltaCategory = 'bad'; - } - const deltaIconInfo = iconMap.deltaStatus[deltaCategory]; - statusElements.deltaStatus.innerHTML = ``; - - const deltaTextValue = `${data.timecode_delta_ms} ms (${data.timecode_delta_frames} frames)`; - statusElements.deltaText.textContent = `Δ ${deltaTextValue}`; - - const jitterStatus = data.jitter_status || 'UNKNOWN'; - const jitterIconInfo = iconMap.jitterStatus[jitterStatus] || iconMap.jitterStatus.default; - statusElements.jitterStatus.innerHTML = ``; - statusElements.jitterStatus.className = jitterStatus.toLowerCase(); + statusElements.deltaMs.textContent = data.timecode_delta_ms; + statusElements.deltaFrames.textContent = data.timecode_delta_frames; + + statusElements.jitterStatus.textContent = data.jitter_status; + statusElements.jitterStatus.className = data.jitter_status.toLowerCase(); + statusElements.interfaces.innerHTML = ''; if (data.interfaces.length > 0) { - statusElements.interfaces.textContent = data.interfaces.join(' | '); + data.interfaces.forEach(ip => { + const li = document.createElement('li'); + li.textContent = ip; + statusElements.interfaces.appendChild(li); + }); } else { - statusElements.interfaces.textContent = 'No active interfaces found.'; - } - } - - function animateClocks() { - if (!lastApiData || !lastApiFetchTime) return; - - const elapsedMs = new Date() - lastApiFetchTime; - - // Animate System Clock - if (lastApiData.system_clock && lastApiData.system_clock.includes(':')) { - const parts = lastApiData.system_clock.split(/[:.]/); - if (parts.length === 4) { - const baseDate = new Date(); - baseDate.setHours(parseInt(parts[0], 10), parseInt(parts[1], 10), parseInt(parts[2], 10)); - baseDate.setMilliseconds(parseInt(parts[3], 10)); - - const newDate = new Date(baseDate.getTime() + elapsedMs); - - const h = String(newDate.getHours()).padStart(2, '0'); - const m = String(newDate.getMinutes()).padStart(2, '0'); - const s = String(newDate.getSeconds()).padStart(2, '0'); - const ms = String(newDate.getMilliseconds()).padStart(3, '0'); - statusElements.systemClock.textContent = `${h}:${m}:${s}.${ms}`; - } - } - - // Animate LTC Timecode - only if status is LOCK - if (lastApiData.ltc_status === 'LOCK' && lastApiData.ltc_timecode && lastApiData.ltc_timecode.match(/[:;]/) && lastApiData.frame_rate) { - const separator = lastApiData.ltc_timecode.includes(';') ? ';' : ':'; - const tcParts = lastApiData.ltc_timecode.split(/[:;]/); - const frameRate = parseFloat(lastApiData.frame_rate); - - if (tcParts.length === 4 && !isNaN(frameRate) && frameRate > 0) { - let h = parseInt(tcParts[0], 10); - let m = parseInt(tcParts[1], 10); - let s = parseInt(tcParts[2], 10); - let f = parseInt(tcParts[3], 10); - - const msPerFrame = 1000.0 / frameRate; - const elapsedFrames = Math.floor(elapsedMs / msPerFrame); - - f += elapsedFrames; - - const frameRateInt = Math.round(frameRate); - - s += Math.floor(f / frameRateInt); - f %= frameRateInt; - - m += Math.floor(s / 60); - s %= 60; - - h += Math.floor(m / 60); - m %= 60; - - h %= 24; - - statusElements.ltcTimecode.textContent = - `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}${separator}${String(f).padStart(2, '0')}`; - } + const li = document.createElement('li'); + li.textContent = 'No active interfaces found.'; + statusElements.interfaces.appendChild(li); } } async function fetchStatus() { - if (useMockData) { - const data = mockApiDataSets[currentMockSetKey].status; - updateStatus(data); - lastApiData = data; - lastApiFetchTime = new Date(); - return; - } try { const response = await fetch('/api/status'); if (!response.ok) throw new Error('Failed to fetch status'); const data = await response.json(); updateStatus(data); - lastApiData = data; - lastApiFetchTime = new Date(); } catch (error) { console.error('Error fetching status:', error); - lastApiData = null; - lastApiFetchTime = null; } } async function fetchConfig() { - if (useMockData) { - const data = mockApiDataSets[currentMockSetKey].config; - hwOffsetInput.value = data.hardwareOffsetMs; - autoSyncCheckbox.checked = data.autoSyncEnabled; - offsetInputs.h.value = data.timeturnerOffset.hours; - offsetInputs.m.value = data.timeturnerOffset.minutes; - offsetInputs.s.value = data.timeturnerOffset.seconds; - offsetInputs.f.value = data.timeturnerOffset.frames; - offsetInputs.ms.value = data.timeturnerOffset.milliseconds || 0; - nudgeValueInput.value = data.defaultNudgeMs; - return; - } try { const response = await fetch('/api/config'); if (!response.ok) throw new Error('Failed to fetch config'); const data = await response.json(); hwOffsetInput.value = data.hardwareOffsetMs; - autoSyncCheckbox.checked = data.autoSyncEnabled; offsetInputs.h.value = data.timeturnerOffset.hours; offsetInputs.m.value = data.timeturnerOffset.minutes; offsetInputs.s.value = data.timeturnerOffset.seconds; offsetInputs.f.value = data.timeturnerOffset.frames; - offsetInputs.ms.value = data.timeturnerOffset.milliseconds || 0; - nudgeValueInput.value = data.defaultNudgeMs; } catch (error) { console.error('Error fetching config:', error); } @@ -266,25 +86,14 @@ async function saveConfig() { const config = { hardwareOffsetMs: parseInt(hwOffsetInput.value, 10) || 0, - autoSyncEnabled: autoSyncCheckbox.checked, - defaultNudgeMs: parseInt(nudgeValueInput.value, 10) || 0, timeturnerOffset: { - hours: parseInt(offsetInputs.h.value, 10) || 0, + hours: parseInt(offsetInputs.h.value, 10) || 0, minutes: parseInt(offsetInputs.m.value, 10) || 0, seconds: parseInt(offsetInputs.s.value, 10) || 0, - frames: parseInt(offsetInputs.f.value, 10) || 0, - milliseconds: parseInt(offsetInputs.ms.value, 10) || 0, + frames: parseInt(offsetInputs.f.value, 10) || 0, } }; - if (useMockData) { - console.log('Mock save:', config); - alert('Configuration saved (mock).'); - // We can also update the mock data in memory to see changes reflected - mockApiDataSets[currentMockSetKey].config = config; - return; - } - try { const response = await fetch('/api/config', { method: 'POST', @@ -299,35 +108,8 @@ } } - async function fetchLogs() { - if (useMockData) { - // Use a copy to avoid mutating the original mock data array - const logs = mockApiDataSets[currentMockSetKey].logs.slice(); - // Show latest 20 logs, with the newest at the top. - logs.reverse(); - statusElements.logs.textContent = logs.slice(0, 20).join('\n'); - return; - } - try { - const response = await fetch('/api/logs'); - if (!response.ok) throw new Error('Failed to fetch logs'); - const logs = await response.json(); - // Show latest 20 logs, with the newest at the top. - logs.reverse(); - statusElements.logs.textContent = logs.slice(0, 20).join('\n'); - } catch (error) { - console.error('Error fetching logs:', error); - statusElements.logs.textContent = 'Error fetching logs.'; - } - } - async function triggerManualSync() { syncMessage.textContent = 'Issuing sync command...'; - if (useMockData) { - syncMessage.textContent = 'Success: Manual sync triggered (mock).'; - setTimeout(() => { syncMessage.textContent = ''; }, 5000); - return; - } try { const response = await fetch('/api/sync', { method: 'POST' }); const data = await response.json(); @@ -343,101 +125,13 @@ setTimeout(() => { syncMessage.textContent = ''; }, 5000); } - async function nudgeClock(ms) { - nudgeMessage.textContent = 'Nudging clock...'; - if (useMockData) { - nudgeMessage.textContent = `Success: Clock nudged by ${ms}ms (mock).`; - setTimeout(() => { nudgeMessage.textContent = ''; }, 3000); - return; - } - try { - const response = await fetch('/api/nudge_clock', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ microseconds: ms * 1000 }), - }); - const data = await response.json(); - if (response.ok) { - nudgeMessage.textContent = `Success: ${data.message}`; - } else { - nudgeMessage.textContent = `Error: ${data.message}`; - } - } catch (error) { - console.error('Error nudging clock:', error); - nudgeMessage.textContent = 'Failed to send nudge command.'; - } - setTimeout(() => { nudgeMessage.textContent = ''; }, 3000); - } - - async function setDate() { - const date = dateInput.value; - if (!date) { - alert('Please select a date.'); - return; - } - - dateMessage.textContent = 'Setting date...'; - if (useMockData) { - mockApiDataSets[currentMockSetKey].status.system_date = date; - dateMessage.textContent = `Success: Date set to ${date} (mock).`; - fetchStatus(); // re-render - setTimeout(() => { dateMessage.textContent = ''; }, 5000); - return; - } - try { - const response = await fetch('/api/set_date', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ date: date }), - }); - const data = await response.json(); - if (response.ok) { - dateMessage.textContent = `Success: ${data.message}`; - // Fetch status again to update the displayed date immediately - fetchStatus(); - } else { - dateMessage.textContent = `Error: ${data.message}`; - } - } catch (error) { - console.error('Error setting date:', error); - dateMessage.textContent = 'Failed to send date command.'; - } - setTimeout(() => { dateMessage.textContent = ''; }, 5000); - } - saveConfigButton.addEventListener('click', saveConfig); manualSyncButton.addEventListener('click', triggerManualSync); - nudgeDownButton.addEventListener('click', () => { - const ms = parseInt(nudgeValueInput.value, 10) || 0; - nudgeClock(-ms); - }); - nudgeUpButton.addEventListener('click', () => { - const ms = parseInt(nudgeValueInput.value, 10) || 0; - nudgeClock(ms); - }); - setDateButton.addEventListener('click', setDate); - - // --- Collapsible Section Listeners --- - controlsToggle.addEventListener('click', () => { - const isActive = controlsContent.classList.toggle('active'); - controlsToggle.classList.toggle('active', isActive); - }); - - logsToggle.addEventListener('click', () => { - const isActive = logsContent.classList.toggle('active'); - logsToggle.classList.toggle('active', isActive); - }); // Initial data load - setupMockControls(); fetchStatus(); fetchConfig(); - fetchLogs(); - // Refresh data every 2 seconds if not using mock data - if (!useMockData) { - setInterval(fetchStatus, 2000); - setInterval(fetchLogs, 2000); - } - setInterval(animateClocks, 50); // High-frequency clock animation + // Refresh data every 2 seconds + setInterval(fetchStatus, 2000); }); diff --git a/static/style.css b/static/style.css index bc53cce..7bd9c20 100644 --- a/static/style.css +++ b/static/style.css @@ -1,21 +1,6 @@ -@font-face { - font-family: 'FuturaStdHeavy'; - src: url('assets/FuturaStdHeavy.otf') format('opentype'); -} - -@font-face { - font-family: 'Quartz'; - src: url('assets/quartz-ms-regular.ttf') format('truetype'); -} - body { - font-family: 'FuturaStdHeavy', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; - background-color: #221f1f; - background-image: url('assets/HaveBlueTransWh.png'); - background-repeat: no-repeat; - background-position: bottom 20px right 20px; - background-attachment: fixed; - background-size: 100px; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + background-color: #f4f4f9; color: #333; margin: 0; padding: 20px; @@ -28,20 +13,19 @@ body { max-width: 960px; } -.header-logo { - display: block; - margin: 0 auto 20px auto; - max-width: 60%; +h1 { + text-align: center; + color: #444; } .grid { display: grid; - grid-template-columns: 1fr; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 20px; } .card { - background: #c5ced6; + background: #fff; border-radius: 8px; padding: 20px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); @@ -49,32 +33,16 @@ body { .card h2 { margin-top: 0; - color: #1a7db6; -} - -#ltc-timecode, #system-clock { - font-family: 'Quartz', monospace; - font-size: 2em; - text-align: center; - letter-spacing: 2px; + color: #0056b3; } .card p, .card ul { margin: 10px 0; } -.system-date-display { - text-align: center; - font-size: 1.5em; - font-family: 'Quartz', monospace; - letter-spacing: 2px; -} - -#interfaces { - text-align: center; - white-space: nowrap; - overflow-x: auto; - padding-bottom: 5px; /* Add some space for the scrollbar if it appears */ +.card ul { + padding-left: 20px; + list-style: none; } .full-width { @@ -88,75 +56,25 @@ body { gap: 10px; } -input[type="number"], -input[type="text"] { - padding: 8px; - border: 1px solid #9fb3c8; - border-radius: 4px; - background-color: #f0f4f8; - font-family: inherit; - font-size: 14px; - color: #333; - transition: border-color 0.2s, box-shadow 0.2s; -} - -input[type="number"]:focus, -input[type="text"]:focus { - outline: none; - border-color: #1a7db6; - box-shadow: 0 0 0 2px rgba(26, 125, 182, 0.2); -} - input[type="number"] { + padding: 8px; + border: 1px solid #ccc; + border-radius: 4px; width: 80px; } -input[type="text"] { - width: auto; -} - button { padding: 8px 15px; border: none; border-radius: 4px; - background-color: #1a7db6; + background-color: #007bff; color: white; cursor: pointer; font-size: 14px; - font-family: Arial, sans-serif; - font-weight: bold; - transition: background-color 0.2s; } button:hover { - background-color: #166999; -} - -.offset-controls-container { - display: flex; - flex-wrap: wrap; - gap: 1.5rem; - align-items: center; -} - -.offset-control { - display: flex; - align-items: center; - gap: 5px; -} - -.offset-control input[type="number"] { - width: 40px; - text-align: center; -} - -.offset-control label { - font-size: 14px; - color: #333; -} - -#offset-ms { - width: 60px; + background-color: #0056b3; } #sync-message { @@ -164,103 +82,6 @@ button:hover { color: #555; } -.icon-group { - display: flex; - justify-content: center; - align-items: center; - gap: 20px; - margin: 10px 0; -} - -#delta-text { - text-align: center; -} - -#ltc-status, #ntp-active, #sync-status, #jitter-status, #frame-rate, #lock-ratio, #delta-status { - display: flex; - justify-content: center; - align-items: center; -} - -.status-icon { - width: 60px; - height: 60px; -} - -.collapsible-card { - padding: 0; -} - -.collapsible-card .toggle-header { - display: flex; - align-items: center; - gap: 15px; - padding: 20px; - cursor: pointer; - border-radius: 8px; -} - -.collapsible-card .toggle-header.active { - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; - border-bottom: 1px solid #eee; -} - -.collapsible-card .toggle-header:hover { - background-color: #e9e9f3; -} - -.toggle-icon { - width: 40px; - height: 40px; -} - -.header-icon { - width: 40px; - height: 40px; -} - -.card-header { - display: flex; - align-items: center; - gap: 15px; -} - -.log-box { - white-space: pre-wrap; - overflow-wrap: break-word; -} - -.collapsible-content { - display: none; - padding: 20px; -} - -.collapsible-content.active { - display: block; -} - -footer { - text-align: center; - margin-top: 40px; - padding-top: 20px; - border-top: 1px solid #444; - color: #c5ced6; -} - -footer p { - margin: 0; -} - -footer a { - color: #1a7db6; - text-decoration: none; -} - -footer a:hover { - text-decoration: underline; -} - /* Status-specific colors */ #sync-status.in-sync, #jitter-status.good { font-weight: bold; color: #28a745; } #sync-status.clock-ahead, #sync-status.clock-behind, #jitter-status.average { font-weight: bold; color: #ffc107; } @@ -268,6 +89,3 @@ footer a:hover { #jitter-status.bad { font-weight: bold; color: #dc3545; } #ntp-active.active { font-weight: bold; color: #28a745; } #ntp-active.inactive { font-weight: bold; color: #dc3545; } - -#ltc-status.lock { font-weight: bold; color: #28a745; } -#ltc-status.free { font-weight: bold; color: #ffc107; } diff --git a/timeturner.py b/timeturner.py index 49fe40b..92f8cb2 100644 --- a/timeturner.py +++ b/timeturner.py @@ -9,11 +9,10 @@ import threading import queue import json from collections import deque -from fractions import Fraction SERIAL_PORT = None BAUD_RATE = 115200 -FRAME_RATE = Fraction(25, 1) +FRAME_RATE = 25.0 CONFIG_PATH = "config.json" sync_pending = False @@ -31,14 +30,6 @@ sync_enabled = False last_match_check = 0 timecode_match_status = "UNKNOWN" -def framerate_str_to_fraction(s): - if s == "23.98": return Fraction(24000, 1001) - if s == "24.00": return Fraction(24, 1) - if s == "25.00": return Fraction(25, 1) - if s == "29.97": return Fraction(30000, 1001) - if s == "30.00": return Fraction(30, 1) - return None - def load_config(): global hardware_offset_ms try: @@ -59,16 +50,13 @@ def parse_ltc_line(line): if not match: return None status, hh, mm, ss, ff, fps = match.groups() - rate = framerate_str_to_fraction(fps) - if not rate: - return None return { "status": status, "hours": int(hh), "minutes": int(mm), "seconds": int(ss), "frames": int(ff), - "frame_rate": rate + "frame_rate": float(fps) } def serial_thread(port, baud, q): @@ -166,7 +154,7 @@ def run_curses(stdscr): parsed, arrival_time = latest_ltc stdscr.addstr(3, 2, f"LTC Status : {parsed['status']}") stdscr.addstr(4, 2, f"LTC Timecode : {parsed['hours']:02}:{parsed['minutes']:02}:{parsed['seconds']:02}:{parsed['frames']:02}") - stdscr.addstr(5, 2, f"Frame Rate : {float(FRAME_RATE):.2f}fps") + stdscr.addstr(5, 2, f"Frame Rate : {FRAME_RATE:.2f}fps") stdscr.addstr(6, 2, f"System Clock : {format_time(get_system_time())}") if ltc_locked and sync_enabled and offset_history: diff --git a/timeturner.service b/timeturner.service deleted file mode 100644 index f3daec8..0000000 --- a/timeturner.service +++ /dev/null @@ -1,18 +0,0 @@ -[Unit] -Description=NTP TimeTurner Daemon -After=network.target - -[Service] -Type=forking -# The 'timeturner daemon' command starts the background process. -# It requires 'config.yml' and the 'static/' web assets directory -# to be present in the WorkingDirectory. -ExecStart=/opt/timeturner/timeturner daemon -WorkingDirectory=/opt/timeturner -PIDFile=/opt/timeturner/ntp_timeturner.pid -Restart=always -User=root -Group=root - -[Install] -WantedBy=multi-user.target diff --git a/update.sh b/update.sh deleted file mode 100644 index ad9fcb9..0000000 --- a/update.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/bin/bash -set -e - -echo "--- TimeTurner Update Script ---" - -# 1. Fetch the latest changes from the git repository -echo "🔄 Pulling latest changes from GitHub..." -git pull origin main - -# 2. Rebuild the release binary -echo "📦 Building release binary with Cargo..." -cargo build --release - -# 3. Stop the currently running service to release the file lock -echo "🛑 Stopping TimeTurner service..." -sudo systemctl stop timeturner.service || true - -# 4. Copy the new binary to the installation directory -echo "🚀 Deploying new binary..." -sudo cp target/release/ntp_timeturner /opt/timeturner/timeturner - -# 5. Restart the service with the new binary -echo "✅ Restarting TimeTurner service..." -sudo systemctl restart timeturner.service - -echo "" -echo "Update complete. To check the status of the service, run:" -echo " systemctl status timeturner.service" \ No newline at end of file