diff --git a/Cargo.toml b/Cargo.toml index b280adf..1d38d1c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,5 +19,7 @@ tokio = { version = "1", features = ["full"] } clap = { version = "4.4", features = ["derive"] } log = { version = "0.4", features = ["std"] } daemonize = "0.5.0" +num-rational = "0.4" +num-traits = "0.2" diff --git a/README.md b/README.md index d7ed822..5f94e52 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,16 @@ -ο»Ώ# πŸ•°οΈ NTP Timeturner (alpha) +ο»Ώ# Fetch | Hachi (alpha) -**An LTC-driven NTP server for Raspberry Pi, built with broadcast precision and a hint of magic.** +**An LTC-driven NTP server for Raspberry Pi, built with broadcast precision** -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. +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. -Created by Chris Frankland-Wright and John Rogers +Created by Chris Frankland-Wright and Chaos Rogers --- ## πŸ“¦ Hardware Requirements -- Raspberry Pi 5 (Dev Platform) but should be supported by Pi v3 (or better) +- Raspberry Pi 5 2GB (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,26 +24,103 @@ Created by Chris Frankland-Wright and John 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, milliseconds) - NOT AVAILABLE +- Supports configurable time offsets (hours, minutes, seconds, frames or milliseconds) - Systemd service support for headless operation +- Web-based UI for monitoring and control when running as a daemon --- -## πŸš€ Installation (to update) +## πŸ–₯️ Web Interface & API +When running as a background daemon, Hachi provides a web interface for monitoring and configuration. -For Rust install you can do -```bash -cargo install --git https://github.com/cjfranko/NTP-Timeturner -``` -Clone and run the installer: +- **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: ```bash -wget https://raw.githubusercontent.com/cjfranko/NTP-Timeturner/master/setup.sh -chmod +x setup.sh +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 && \ ./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 @@ -52,10 +129,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β€―10 +# Serve the system clock as a reference at stratumβ€―1 server 127.127.1.0 allow 127.0.0.0/8 -local stratum 10 +local stratum 1 Add to bottom: # Allow LAN clients diff --git a/SECURITY.MD b/SECURITY.MD new file mode 100644 index 0000000..14b8058 --- /dev/null +++ b/SECURITY.MD @@ -0,0 +1,9 @@ +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 bf892f4..bc552ee 100644 --- a/config.yml +++ b/config.yml @@ -12,8 +12,8 @@ defaultNudgeMs: 2 # Time-turning offsets. All values are added to the incoming LTC time. # These can be positive or negative. timeturnerOffset: - hours: 1 - minutes: 2 - seconds: 3 - frames: 4 - milliseconds: 5 + hours: 0 + minutes: 0 + seconds: 0 + frames: 0 + milliseconds: 0 diff --git a/docs/api.md b/docs/api.md index 1b76262..6657028 100644 --- a/docs/api.md +++ b/docs/api.md @@ -4,19 +4,25 @@ This document describes the HTTP API for the NTP Timeturner application. ## Endpoints -### Status +### Status and Logs - **`GET /api/status`** - Retrieves the real-time status of the LTC reader and system clock synchronization. + 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"` **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", @@ -24,11 +30,23 @@ 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": 0 + "hardware_offset_ms": 20 } ``` -### Sync +- **`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 - **`POST /api/sync`** @@ -36,7 +54,7 @@ This document describes the HTTP API for the NTP Timeturner application. **Request Body:** None - **Success Response:** + **Success Response (200 OK):** ```json { "status": "success", @@ -44,13 +62,14 @@ This document describes the HTTP API for the NTP Timeturner application. } ``` - **Error Responses:** + **Error Response (400 Bad Request):** ```json { "status": "error", "message": "No LTC timecode available to sync to." } ``` + **Error Response (500 Internal Server Error):** ```json { "status": "error", @@ -58,33 +77,120 @@ 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. + Retrieves the current application configuration from `config.yml`. - **Example Response:** + **Example Response (200 OK):** ```json { - "hardware_offset_ms": 0 + "hardwareOffsetMs": 20, + "timeturnerOffset": { + "hours": 0, + "minutes": 0, + "seconds": 0, + "frames": 0, + "milliseconds": 0 + }, + "defaultNudgeMs": 2, + "autoSyncEnabled": false } ``` - **`POST /api/config`** - Updates the `hardware_offset_ms` configuration. The new value is persisted to `config.json` and reloaded by the application automatically. + Updates the application configuration. The new configuration is persisted to `config.yml` and takes effect immediately. **Example Request:** ```json { - "hardware_offset_ms": 10 + "hardwareOffsetMs": 55, + "timeturnerOffset": { + "hours": 1, + "minutes": 2, + "seconds": 3, + "frames": 4, + "milliseconds": 5 + }, + "defaultNudgeMs": 2, + "autoSyncEnabled": true } ``` - **Success Response:** + **Success Response (200 OK):** (Returns the updated configuration) ```json { - "hardware_offset_ms": 10 + "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" } ``` diff --git a/setup.sh b/setup.sh index 4dd5685..0b68c12 100644 --- a/setup.sh +++ b/setup.sh @@ -3,14 +3,226 @@ set -e echo "--- TimeTurner Setup ---" -# 1. Build the release binary -echo "πŸ“¦ Building release binary with Cargo..." -if ! command -v cargo &> /dev/null -then - echo "❌ Cargo is not installed. Please install Rust and Cargo first." - echo "Visit https://rustup.rs/ for instructions." +# 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 <) -> 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| { - format!("{:02}:{:02}:{:02}:{:02}", f.hours, f.minutes, f.seconds, f.frames) + let sep = if f.is_drop_frame { ';' } else { ':' }; + format!( + "{:02}:{:02}:{:02}{}{:02}", + f.hours, f.minutes, f.seconds, sep, f.frames + ) }); let frame_rate = state.latest.as_ref().map_or("…".to_string(), |f| { - format!("{:.2}fps", f.frame_rate) + format!("{:.2}fps", f.frame_rate.to_f64().unwrap_or(0.0)) }); let now_local = Local::now(); @@ -58,12 +65,14 @@ 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 mut delta_frames = 0; if let Some(frame) = &state.latest { - let frame_ms = 1000.0 / frame.frame_rate; - delta_frames = ((avg_delta as f64 / frame_ms).round()) as i64; + 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 sync_status = sync_logic::get_sync_status(avg_delta, &config); @@ -83,6 +92,7 @@ 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(), @@ -135,6 +145,22 @@ async fn nudge_clock(req: web::Json) -> impl Responder { } } +#[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, @@ -192,6 +218,7 @@ pub async fn start_api_server( .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")) }) @@ -219,7 +246,8 @@ mod tests { minutes: 2, seconds: 3, frames: 4, - frame_rate: 25.0, + is_drop_frame: false, + frame_rate: Ratio::new(25, 1), timestamp: Utc::now(), }), lock_count: 10, @@ -267,6 +295,32 @@ 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(); diff --git a/src/config.rs b/src/config.rs index 974d60b..8669e62 100644 --- a/src/config.rs +++ b/src/config.rs @@ -64,6 +64,7 @@ impl Config { Self::default() }) } + } impl Default for Config { diff --git a/src/main.rs b/src/main.rs index e265210..8006681 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,6 +15,7 @@ use crate::sync_logic::LtcState; use crate::ui::start_ui; use clap::Parser; use daemonize::Daemonize; +use serialport; use std::{ fs, @@ -35,6 +36,8 @@ 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. @@ -70,30 +73,85 @@ fn ensure_config() { } } +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::Daemon) = &args.command { - log::info!("πŸš€ Starting daemon..."); + if let Some(command) = &args.command { + match command { + Command::Daemon => { + log::info!("πŸš€ 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 + 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; } } } @@ -110,13 +168,23 @@ async fn main() { // 3️⃣ Shared state for UI and serial reader let ltc_state = Arc::new(Mutex::new(LtcState::new())); - // 4️⃣ Spawn the serial reader thread + // 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); + { let tx_clone = tx.clone(); let state_clone = ltc_state.clone(); + let port_clone = serial_port_path.clone(); thread::spawn(move || { start_serial_thread( - "/dev/ttyACM0", + &port_clone, 115200, tx_clone, state_clone, @@ -125,20 +193,24 @@ async fn main() { }); } - // 5️⃣ Spawn UI or setup daemon logging + // 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. if args.command.is_none() { + // --- Interactive TUI Mode --- log::info!("πŸ”§ Watching config.yml..."); log::info!("πŸš€ Serial thread launched"); - log::info!("πŸ–₯️ UI thread launched"); + log::info!("πŸ–₯️ UI thread launched"); let ui_state = ltc_state.clone(); let config_clone = config.clone(); - let port = "/dev/ttyACM0".to_string(); + let port = serial_port_path; 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 systemd service will capture it. The web service (API and static files) + // is launched later in the main async block. log::info!("πŸš€ Starting TimeTurner daemon..."); } @@ -214,7 +286,10 @@ async fn main() { let local = LocalSet::new(); local .run_until(async move { - // 8️⃣ Spawn the API server thread + // 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. { let api_state = ltc_state.clone(); let config_clone = config.clone(); diff --git a/src/serial_input.rs b/src/serial_input.rs index 10c3626..d1dea36 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,11 +60,12 @@ 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() } @@ -119,7 +120,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, 29.97); + assert_eq!(received_frame.frame_rate, Ratio::new(30000, 1001)); } #[test] diff --git a/src/sync_logic.rs b/src/sync_logic.rs index d54f2cc..c6a3e80 100644 --- a/src/sync_logic.rs +++ b/src/sync_logic.rs @@ -1,10 +1,22 @@ ο»Ώ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, @@ -12,7 +24,8 @@ pub struct LtcFrame { pub minutes: u32, pub seconds: u32, pub frames: u32, - pub frame_rate: f64, + pub is_drop_frame: bool, + pub frame_rate: Ratio, pub timestamp: DateTime, // arrival stamp } @@ -23,8 +36,9 @@ impl LtcFrame { hours: caps[2].parse().ok()?, minutes: caps[3].parse().ok()?, seconds: caps[4].parse().ok()?, - frames: caps[5].parse().ok()?, - frame_rate: caps[6].parse().ok()?, + is_drop_frame: &caps[5] == ";", + frames: caps[6].parse().ok()?, + frame_rate: get_frame_rate_ratio(&caps[7])?, timestamp, }) } @@ -129,8 +143,9 @@ impl LtcState { /// Convert average jitter into frames (rounded). pub fn average_frames(&self) -> i64 { if let Some(frame) = &self.latest { - let ms_per_frame = 1000.0 / frame.frame_rate; - (self.average_jitter() as f64 / ms_per_frame).round() as i64 + 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() } else { 0 } @@ -192,7 +207,8 @@ mod tests { minutes: m, seconds: s, frames: 0, - frame_rate: 25.0, + is_drop_frame: false, + frame_rate: Ratio::new(25, 1), timestamp: Utc::now(), } } @@ -347,7 +363,11 @@ mod tests { assert_eq!(get_sync_status(-9, &config), "CLOCK BEHIND"); assert_eq!(get_sync_status(-100, &config), "CLOCK BEHIND"); - // Test TIMETURNING status + // 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 }; 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 c3918f6..8db481d 100644 --- a/src/system.rs +++ b/src/system.rs @@ -1,6 +1,7 @@ use crate::config::Config; use crate::sync_logic::LtcFrame; -use chrono::{DateTime, Duration as ChronoDuration, Local, NaiveTime, TimeZone}; +use chrono::{DateTime, Duration as ChronoDuration, Local, TimeZone}; +use num_rational::Ratio; use std::process::Command; /// Check if Chrony is active @@ -39,11 +40,24 @@ 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"); - let naive_dt = today_local.and_time(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 mut dt_local = Local .from_local_datetime(&naive_dt) .single() @@ -56,7 +70,8 @@ pub fn calculate_target_time(frame: &LtcFrame, config: &Config) -> DateTime Result<(), ()> { } } +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 { @@ -145,7 +189,8 @@ mod tests { minutes: m, seconds: s, frames: f, - frame_rate: 25.0, + is_drop_frame: false, + frame_rate: Ratio::new(25, 1), timestamp: Utc::now(), } } diff --git a/src/ui.rs b/src/ui.rs index b36e9e3..5854f4a 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -19,9 +19,11 @@ 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( @@ -82,8 +84,9 @@ 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 frame_ms = 1000.0 / frame.frame_rate; - cached_delta_frames = ((avg_delta as f64 / frame_ms).round()) as i64; + 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(); } else { cached_delta_frames = 0; } @@ -104,7 +107,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), + Some(f) => format!("Frame Rate : {:.2}fps", f.frame_rate.to_f64().unwrap_or(0.0)), None => "Frame Rate : …".to_string(), }; diff --git a/static/assets/FuturaStdHeavy.otf b/static/assets/FuturaStdHeavy.otf new file mode 100644 index 0000000..7b8c22d Binary files /dev/null and b/static/assets/FuturaStdHeavy.otf differ diff --git a/static/assets/HaveBlueTransWh.png b/static/assets/HaveBlueTransWh.png new file mode 100644 index 0000000..d9a123d Binary files /dev/null and b/static/assets/HaveBlueTransWh.png differ diff --git a/static/assets/favicon.png b/static/assets/favicon.png new file mode 100644 index 0000000..3683c35 Binary files /dev/null and b/static/assets/favicon.png differ diff --git a/static/assets/header.png b/static/assets/header.png new file mode 100644 index 0000000..f1677ed Binary files /dev/null and b/static/assets/header.png differ diff --git a/static/assets/quartz-ms-regular.ttf b/static/assets/quartz-ms-regular.ttf new file mode 100644 index 0000000..15c7ce4 Binary files /dev/null and b/static/assets/quartz-ms-regular.ttf differ diff --git a/static/assets/timeturner_2398.png b/static/assets/timeturner_2398.png new file mode 100644 index 0000000..763bcba Binary files /dev/null and b/static/assets/timeturner_2398.png differ diff --git a/static/assets/timeturner_24.png b/static/assets/timeturner_24.png new file mode 100644 index 0000000..ffc75d0 Binary files /dev/null and b/static/assets/timeturner_24.png differ diff --git a/static/assets/timeturner_25.png b/static/assets/timeturner_25.png new file mode 100644 index 0000000..3b44c93 Binary files /dev/null and b/static/assets/timeturner_25.png differ diff --git a/static/assets/timeturner_2997.png b/static/assets/timeturner_2997.png new file mode 100644 index 0000000..0bd27fd Binary files /dev/null and b/static/assets/timeturner_2997.png differ diff --git a/static/assets/timeturner_2997DF.png b/static/assets/timeturner_2997DF.png new file mode 100644 index 0000000..bf03215 Binary files /dev/null and b/static/assets/timeturner_2997DF.png differ diff --git a/static/assets/timeturner_30.png b/static/assets/timeturner_30.png new file mode 100644 index 0000000..4ce0211 Binary files /dev/null and b/static/assets/timeturner_30.png differ diff --git a/static/assets/timeturner_controls.png b/static/assets/timeturner_controls.png new file mode 100644 index 0000000..a91f39b Binary files /dev/null and b/static/assets/timeturner_controls.png differ diff --git a/static/assets/timeturner_default.png b/static/assets/timeturner_default.png new file mode 100644 index 0000000..734aa8d Binary files /dev/null and b/static/assets/timeturner_default.png differ diff --git a/static/assets/timeturner_delta_green.png b/static/assets/timeturner_delta_green.png new file mode 100644 index 0000000..ddc84b9 Binary files /dev/null and b/static/assets/timeturner_delta_green.png differ diff --git a/static/assets/timeturner_delta_orange.png b/static/assets/timeturner_delta_orange.png new file mode 100644 index 0000000..64e9776 Binary files /dev/null and b/static/assets/timeturner_delta_orange.png differ diff --git a/static/assets/timeturner_delta_red.png b/static/assets/timeturner_delta_red.png new file mode 100644 index 0000000..c7272ac Binary files /dev/null and b/static/assets/timeturner_delta_red.png differ diff --git a/static/assets/timeturner_jitter_green.png b/static/assets/timeturner_jitter_green.png new file mode 100644 index 0000000..8cc64e3 Binary files /dev/null and b/static/assets/timeturner_jitter_green.png differ diff --git a/static/assets/timeturner_jitter_orange.png b/static/assets/timeturner_jitter_orange.png new file mode 100644 index 0000000..96c5f84 Binary files /dev/null and b/static/assets/timeturner_jitter_orange.png differ diff --git a/static/assets/timeturner_jitter_red.png b/static/assets/timeturner_jitter_red.png new file mode 100644 index 0000000..8813159 Binary files /dev/null and b/static/assets/timeturner_jitter_red.png differ diff --git a/static/assets/timeturner_lock_green.png b/static/assets/timeturner_lock_green.png new file mode 100644 index 0000000..0659c60 Binary files /dev/null and b/static/assets/timeturner_lock_green.png differ diff --git a/static/assets/timeturner_lock_orange.png b/static/assets/timeturner_lock_orange.png new file mode 100644 index 0000000..836a376 Binary files /dev/null and b/static/assets/timeturner_lock_orange.png differ diff --git a/static/assets/timeturner_lock_red.png b/static/assets/timeturner_lock_red.png new file mode 100644 index 0000000..aa8740d Binary files /dev/null and b/static/assets/timeturner_lock_red.png differ diff --git a/static/assets/timeturner_logs.png b/static/assets/timeturner_logs.png new file mode 100644 index 0000000..6bdd935 Binary files /dev/null and b/static/assets/timeturner_logs.png differ diff --git a/static/assets/timeturner_ltc_green.png b/static/assets/timeturner_ltc_green.png new file mode 100644 index 0000000..4329913 Binary files /dev/null and b/static/assets/timeturner_ltc_green.png differ diff --git a/static/assets/timeturner_ltc_orange.png b/static/assets/timeturner_ltc_orange.png new file mode 100644 index 0000000..b060ac2 Binary files /dev/null and b/static/assets/timeturner_ltc_orange.png differ diff --git a/static/assets/timeturner_ltc_red.png b/static/assets/timeturner_ltc_red.png new file mode 100644 index 0000000..a8e7f96 Binary files /dev/null and b/static/assets/timeturner_ltc_red.png differ diff --git a/static/assets/timeturner_network.png b/static/assets/timeturner_network.png new file mode 100644 index 0000000..06ec4b9 Binary files /dev/null and b/static/assets/timeturner_network.png differ diff --git a/static/assets/timeturner_ntp_green.png b/static/assets/timeturner_ntp_green.png new file mode 100644 index 0000000..caf824d Binary files /dev/null and b/static/assets/timeturner_ntp_green.png differ diff --git a/static/assets/timeturner_ntp_orange.png b/static/assets/timeturner_ntp_orange.png new file mode 100644 index 0000000..88319b5 Binary files /dev/null and b/static/assets/timeturner_ntp_orange.png differ diff --git a/static/assets/timeturner_ntp_red.png b/static/assets/timeturner_ntp_red.png new file mode 100644 index 0000000..16e66ee Binary files /dev/null and b/static/assets/timeturner_ntp_red.png differ diff --git a/static/assets/timeturner_sync_green.png b/static/assets/timeturner_sync_green.png new file mode 100644 index 0000000..9b4988e Binary files /dev/null and b/static/assets/timeturner_sync_green.png differ diff --git a/static/assets/timeturner_sync_orange.png b/static/assets/timeturner_sync_orange.png new file mode 100644 index 0000000..0b41130 Binary files /dev/null and b/static/assets/timeturner_sync_orange.png differ diff --git a/static/assets/timeturner_sync_red.png b/static/assets/timeturner_sync_red.png new file mode 100644 index 0000000..1c4c4c9 Binary files /dev/null and b/static/assets/timeturner_sync_red.png differ diff --git a/static/assets/timeturner_timeturning.png b/static/assets/timeturner_timeturning.png new file mode 100644 index 0000000..fd3eaeb Binary files /dev/null and b/static/assets/timeturner_timeturning.png differ diff --git a/static/favicon.ico b/static/favicon.ico new file mode 100644 index 0000000..83d6317 Binary files /dev/null and b/static/favicon.ico differ diff --git a/static/icon-map.js b/static/icon-map.js new file mode 100644 index 0000000..64336b3 --- /dev/null +++ b/static/icon-map.js @@ -0,0 +1,43 @@ +// 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 eb074af..02bb279 100644 --- a/static/index.html +++ b/static/index.html @@ -3,102 +3,139 @@ - NTP TimeTurner + Fetch | Hachi +
-

NTP TimeTurner

+ + + + +
-

LTC Status

-

--

+

LTC Input

--:--:--:--

-

-- fps

-

Lock Ratio: --%

+
+ + + +
-

System Clock

+

NTP Clock

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

-

NTP Service: --

-

Sync Status: --

-
- - -
-

Clock Offset

-

Delta: -- ms (-- frames)

-

Jitter: --

+

---- -- --

+
+ + + + +
+

Ξ” -- ms (-- frames)

-

Network

-
    -
  • --
  • -
+
+ Network Icon +

Network

+
+

--

-
-

Controls

-
- - +
+
+ Controls Icon +

Controls

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

Logs

-
- - -
-
- - -
-
- - -
-
- - +
+

                         
-
- - - -
-
- - - - - -
-
- - -
-

Logs

-

-            
-
+
+ + diff --git a/static/index_dev.html b/static/index_dev.html new file mode 100644 index 0000000..edc555d --- /dev/null +++ b/static/index_dev.html @@ -0,0 +1,141 @@ + + + + + + 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 new file mode 100644 index 0000000..a953e59 --- /dev/null +++ b/static/mock-data.js @@ -0,0 +1,168 @@ +// 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 22c8dd7..634ed33 100644 --- a/static/script.js +++ b/static/script.js @@ -1,15 +1,24 @@ -document.addEventListener('DOMContentLoaded', () => { +ο»Ώ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; + 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'), - deltaMs: document.getElementById('delta-ms'), - deltaFrames: document.getElementById('delta-frames'), + deltaStatus: document.getElementById('delta-status'), jitterStatus: document.getElementById('jitter-status'), + deltaText: document.getElementById('delta-text'), interfaces: document.getElementById('interfaces'), logs: document.getElementById('logs'), }; @@ -32,51 +41,211 @@ document.addEventListener('DOMContentLoaded', () => { const nudgeValueInput = document.getElementById('nudge-value'); const nudgeMessage = document.getElementById('nudge-message'); - function updateStatus(data) { - statusElements.ltcStatus.textContent = data.ltc_status; - statusElements.ltcTimecode.textContent = data.ltc_timecode; - statusElements.frameRate.textContent = data.frame_rate; - statusElements.lockRatio.textContent = data.lock_ratio.toFixed(2); - statusElements.systemClock.textContent = data.system_clock; - - statusElements.ntpActive.textContent = data.ntp_active ? 'Active' : 'Inactive'; - statusElements.ntpActive.className = data.ntp_active ? 'active' : 'inactive'; + const dateInput = document.getElementById('date-input'); + const setDateButton = document.getElementById('set-date'); + const dateMessage = document.getElementById('date-message'); - statusElements.syncStatus.textContent = data.sync_status; - statusElements.syncStatus.className = data.sync_status.replace(/\s+/g, '-').toLowerCase(); + // --- 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'); - 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(); + // --- Mock Controls Setup --- + const mockControls = document.getElementById('mock-controls'); + const mockDataSelector = document.getElementById('mock-data-selector'); - statusElements.interfaces.innerHTML = ''; - if (data.interfaces.length > 0) { - data.interfaces.forEach(ip => { - const li = document.createElement('li'); - li.textContent = ip; - statusElements.interfaces.appendChild(li); + 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.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 { - const li = document.createElement('li'); - li.textContent = 'No active interfaces found.'; - statusElements.interfaces.appendChild(li); + lockRatioCategory = 'bad'; + } + const lockRatioIconInfo = iconMap.lockRatio[lockRatioCategory]; + statusElements.lockRatio.innerHTML = ``; + statusElements.systemClock.textContent = data.system_clock; + statusElements.systemDate.textContent = data.system_date; + + // Autofill the date input, but don't overwrite user edits. + if (!lastApiData || dateInput.value === lastApiData.system_date) { + dateInput.value = data.system_date; + } + + 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(); + + if (data.interfaces.length > 0) { + statusElements.interfaces.textContent = data.interfaces.join(' | '); + } 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')}`; + } } } 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'); @@ -100,14 +269,22 @@ document.addEventListener('DOMContentLoaded', () => { 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, + frames: parseInt(offsetInputs.f.value, 10) || 0, milliseconds: parseInt(offsetInputs.ms.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', @@ -123,13 +300,21 @@ document.addEventListener('DOMContentLoaded', () => { } 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(); - statusElements.logs.textContent = logs.join('\n'); - // Auto-scroll to the bottom - statusElements.logs.scrollTop = statusElements.logs.scrollHeight; + // 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.'; @@ -138,6 +323,11 @@ document.addEventListener('DOMContentLoaded', () => { 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(); @@ -155,6 +345,11 @@ document.addEventListener('DOMContentLoaded', () => { 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', @@ -174,6 +369,42 @@ document.addEventListener('DOMContentLoaded', () => { 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', () => { @@ -184,13 +415,29 @@ document.addEventListener('DOMContentLoaded', () => { 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 - setInterval(fetchStatus, 2000); - setInterval(fetchLogs, 2000); + // 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 }); diff --git a/static/style.css b/static/style.css index 7bd9c20..bc53cce 100644 --- a/static/style.css +++ b/static/style.css @@ -1,6 +1,21 @@ +@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: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; - background-color: #f4f4f9; + 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; color: #333; margin: 0; padding: 20px; @@ -13,19 +28,20 @@ body { max-width: 960px; } -h1 { - text-align: center; - color: #444; +.header-logo { + display: block; + margin: 0 auto 20px auto; + max-width: 60%; } .grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + grid-template-columns: 1fr; gap: 20px; } .card { - background: #fff; + background: #c5ced6; border-radius: 8px; padding: 20px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); @@ -33,16 +49,32 @@ h1 { .card h2 { margin-top: 0; - color: #0056b3; + color: #1a7db6; +} + +#ltc-timecode, #system-clock { + font-family: 'Quartz', monospace; + font-size: 2em; + text-align: center; + letter-spacing: 2px; } .card p, .card ul { margin: 10px 0; } -.card ul { - padding-left: 20px; - list-style: none; +.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 */ } .full-width { @@ -56,25 +88,75 @@ h1 { gap: 10px; } -input[type="number"] { +input[type="number"], +input[type="text"] { padding: 8px; - border: 1px solid #ccc; + 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"] { width: 80px; } +input[type="text"] { + width: auto; +} + button { padding: 8px 15px; border: none; border-radius: 4px; - background-color: #007bff; + background-color: #1a7db6; color: white; cursor: pointer; font-size: 14px; + font-family: Arial, sans-serif; + font-weight: bold; + transition: background-color 0.2s; } button:hover { - background-color: #0056b3; + 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; } #sync-message { @@ -82,6 +164,103 @@ 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; } @@ -89,3 +268,6 @@ button: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 92f8cb2..49fe40b 100644 --- a/timeturner.py +++ b/timeturner.py @@ -9,10 +9,11 @@ import threading import queue import json from collections import deque +from fractions import Fraction SERIAL_PORT = None BAUD_RATE = 115200 -FRAME_RATE = 25.0 +FRAME_RATE = Fraction(25, 1) CONFIG_PATH = "config.json" sync_pending = False @@ -30,6 +31,14 @@ 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: @@ -50,13 +59,16 @@ 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": float(fps) + "frame_rate": rate } def serial_thread(port, baud, q): @@ -154,7 +166,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 : {FRAME_RATE:.2f}fps") + stdscr.addstr(5, 2, f"Frame Rate : {float(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 new file mode 100644 index 0000000..f3daec8 --- /dev/null +++ b/timeturner.service @@ -0,0 +1,18 @@ +[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 new file mode 100644 index 0000000..ad9fcb9 --- /dev/null +++ b/update.sh @@ -0,0 +1,28 @@ +#!/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