Compare commits

..

No commits in common. "main" and "v0.1.0_RC1" have entirely different histories.

57 changed files with 215 additions and 1705 deletions

View file

@ -19,7 +19,5 @@ tokio = { version = "1", features = ["full"] }
clap = { version = "4.4", features = ["derive"] } clap = { version = "4.4", features = ["derive"] }
log = { version = "0.4", features = ["std"] } log = { version = "0.4", features = ["std"] }
daemonize = "0.5.0" daemonize = "0.5.0"
num-rational = "0.4"
num-traits = "0.2"

109
README.md
View file

@ -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 ## 📦 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) - Debian Bookworm (64-bit recommended)
- Teensy 4.0 - https://thepihut.com/products/teensy-4-0-headers - 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 - 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) - Reads SMPTE LTC from Audio Interface (3.5mm TRS but adaptable to BNC/XLR)
- Converts LTC into NTP-synced time - Converts LTC into NTP-synced time
- Broadcasts time via local NTP server - 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 - 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://<raspberry_pi_ip>:8080`. For Rust install you can do
- **Functionality**: You can view the real-time sync status, see logs, and change all configuration options directly from your browser. ```bash
- **API**: A JSON API is also exposed for programmatic access. See `docs/api.md` for full details. cargo install --git https://github.com/cjfranko/NTP-Timeturner
```
--- Clone and run the installer:
## 🛠️ 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 ```bash
curl -L https://github.com/cjfranko/NTP-Timeturner/archive/refs/heads/main.zip -o NTP-Timeturner.zip && \ wget https://raw.githubusercontent.com/cjfranko/NTP-Timeturner/master/setup.sh
unzip NTP-Timeturner.zip && \ chmod +x setup.sh
cd NTP-Timeturner-main && \
chmod +x setup.sh && \
./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 ## 🕰️ Chrony NTP
```bash ```bash
@ -129,10 +52,10 @@ chronyc tracking | NTP Tracking
sudo nano /etc/chrony/chrony.conf | Default Chrony Conf File sudo nano /etc/chrony/chrony.conf | Default Chrony Conf File
Add to top: Add to top:
# Serve the system clock as a reference at stratum1 # Serve the system clock as a reference at stratum10
server 127.127.1.0 server 127.127.1.0
allow 127.0.0.0/8 allow 127.0.0.0/8
local stratum 1 local stratum 10
Add to bottom: Add to bottom:
# Allow LAN clients # Allow LAN clients

View file

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

View file

@ -12,8 +12,8 @@ defaultNudgeMs: 2
# Time-turning offsets. All values are added to the incoming LTC time. # Time-turning offsets. All values are added to the incoming LTC time.
# These can be positive or negative. # These can be positive or negative.
timeturnerOffset: timeturnerOffset:
hours: 0 hours: 1
minutes: 0 minutes: 2
seconds: 0 seconds: 3
frames: 0 frames: 4
milliseconds: 0 milliseconds: 5

View file

@ -4,25 +4,19 @@ This document describes the HTTP API for the NTP Timeturner application.
## Endpoints ## Endpoints
### Status and Logs ### Status
- **`GET /api/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`). Retrieves the real-time status of the LTC reader and system clock synchronization.
**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:** **Example Response:**
```json ```json
{ {
"ltc_status": "LOCK", "ltc_status": "LOCK",
"ltc_timecode": "10:20:30;00", "ltc_timecode": "10:20:30:00",
"frame_rate": "25.00fps", "frame_rate": "25.00fps",
"system_clock": "10:20:30.005", "system_clock": "10:20:30.005",
"system_date": "2025-07-30",
"timecode_delta_ms": 5, "timecode_delta_ms": 5,
"timecode_delta_frames": 0, "timecode_delta_frames": 0,
"sync_status": "IN SYNC", "sync_status": "IN SYNC",
@ -30,23 +24,11 @@ This document describes the HTTP API for the NTP Timeturner application.
"lock_ratio": 99.5, "lock_ratio": 99.5,
"ntp_active": true, "ntp_active": true,
"interfaces": ["192.168.1.100"], "interfaces": ["192.168.1.100"],
"hardware_offset_ms": 20 "hardware_offset_ms": 0
} }
``` ```
- **`GET /api/logs`** ### Sync
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`** - **`POST /api/sync`**
@ -54,7 +36,7 @@ This document describes the HTTP API for the NTP Timeturner application.
**Request Body:** None **Request Body:** None
**Success Response (200 OK):** **Success Response:**
```json ```json
{ {
"status": "success", "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 ```json
{ {
"status": "error", "status": "error",
"message": "No LTC timecode available to sync to." "message": "No LTC timecode available to sync to."
} }
``` ```
**Error Response (500 Internal Server Error):**
```json ```json
{ {
"status": "error", "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 ### Configuration
- **`GET /api/config`** - **`GET /api/config`**
Retrieves the current application configuration from `config.yml`. Retrieves the current application configuration.
**Example Response (200 OK):** **Example Response:**
```json ```json
{ {
"hardwareOffsetMs": 20, "hardware_offset_ms": 0
"timeturnerOffset": {
"hours": 0,
"minutes": 0,
"seconds": 0,
"frames": 0,
"milliseconds": 0
},
"defaultNudgeMs": 2,
"autoSyncEnabled": false
} }
``` ```
- **`POST /api/config`** - **`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:** **Example Request:**
```json ```json
{ {
"hardwareOffsetMs": 55, "hardware_offset_ms": 10
"timeturnerOffset": {
"hours": 1,
"minutes": 2,
"seconds": 3,
"frames": 4,
"milliseconds": 5
},
"defaultNudgeMs": 2,
"autoSyncEnabled": true
} }
``` ```
**Success Response (200 OK):** (Returns the updated configuration) **Success Response:**
```json ```json
{ {
"hardwareOffsetMs": 55, "hardware_offset_ms": 10
"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"
} }
``` ```

241
setup.sh
View file

@ -3,226 +3,14 @@ set -e
echo "--- TimeTurner Setup ---" 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 <<EOF > "$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 <<EOF
# Allow LAN clients to connect
allow 0.0.0.0/0
EOF
# Restart chrony to apply changes (service name can be chrony or chronyd)
echo "Restarting Chrony service..."
sudo systemctl restart chrony || sudo systemctl restart chronyd
echo "✅ Chrony configured."
else
echo "⚠️ Warning: chrony.conf not found. Skipping Chrony configuration."
fi
# --- The entire WiFi hotspot and captive portal section has been removed ---
# 1. Build the release binary # 1. Build the release binary
echo "📦 Building release binary with Cargo..." echo "📦 Building release binary with Cargo..."
# No need to check for cargo again, as it's handled above if ! command -v cargo &> /dev/null
then
echo "❌ Cargo is not installed. Please install Rust and Cargo first."
echo "Visit https://rustup.rs/ for instructions."
exit 1
fi
cargo build --release cargo build --release
echo "✅ Build complete." echo "✅ Build complete."
@ -233,16 +21,13 @@ echo "🔧 Creating directories..."
sudo mkdir -p $INSTALL_DIR sudo mkdir -p $INSTALL_DIR
echo "✅ Directory $INSTALL_DIR created." echo "✅ Directory $INSTALL_DIR created."
# 3. Install binary and static web files # 3. Install binary
echo "🚀 Installing timeturner binary and web assets..." echo "🚀 Installing timeturner binary..."
sudo cp target/release/ntp_timeturner $INSTALL_DIR/timeturner 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 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 # 4. Install systemd service file
# Only needed for Linux systems (e.g., Raspberry Pi OS)
if [[ "$(uname)" == "Linux" ]]; then if [[ "$(uname)" == "Linux" ]]; then
echo "⚙️ Installing systemd service for Linux..." echo "⚙️ Installing systemd service for Linux..."
sudo cp timeturner.service /etc/systemd/system/ sudo cp timeturner.service /etc/systemd/system/
@ -257,13 +42,7 @@ echo ""
echo "--- Setup Complete ---" echo "--- Setup Complete ---"
echo "The TimeTurner daemon is now installed." echo "The TimeTurner daemon is now installed."
echo "The working directory is $INSTALL_DIR." echo "The working directory is $INSTALL_DIR."
# Copy default config.yml from repo if it exists echo "A default 'config.yml' will be created there on first run."
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 "" echo ""
if [[ "$(uname)" == "Linux" ]]; then if [[ "$(uname)" == "Linux" ]]; then
echo "To start the service, run:" echo "To start the service, run:"

View file

@ -11,8 +11,6 @@ use std::sync::{Arc, Mutex};
use crate::config::{self, Config}; use crate::config::{self, Config};
use crate::sync_logic::{self, LtcState}; use crate::sync_logic::{self, LtcState};
use crate::system; use crate::system;
use num_rational::Ratio;
use num_traits::ToPrimitive;
// Data structure for the main status response // Data structure for the main status response
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
@ -21,7 +19,6 @@ struct ApiStatus {
ltc_timecode: String, ltc_timecode: String,
frame_rate: String, frame_rate: String,
system_clock: String, system_clock: String,
system_date: String,
timecode_delta_ms: i64, timecode_delta_ms: i64,
timecode_delta_frames: i64, timecode_delta_frames: i64,
sync_status: String, sync_status: String,
@ -47,14 +44,10 @@ async fn get_status(data: web::Data<AppState>) -> impl Responder {
let ltc_status = state.latest.as_ref().map_or("(waiting)".to_string(), |f| f.status.clone()); 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 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, f.frames)
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| { 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(); let now_local = Local::now();
@ -65,14 +58,12 @@ async fn get_status(data: web::Data<AppState>) -> impl Responder {
now_local.second(), now_local.second(),
now_local.timestamp_subsec_millis(), 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.get_ewma_clock_delta();
let mut delta_frames = 0; let mut delta_frames = 0;
if let Some(frame) = &state.latest { if let Some(frame) = &state.latest {
let delta_ms_ratio = Ratio::new(avg_delta, 1); let frame_ms = 1000.0 / frame.frame_rate;
let frames_ratio = delta_ms_ratio * frame.frame_rate / Ratio::new(1000, 1); delta_frames = ((avg_delta as f64 / frame_ms).round()) as i64;
delta_frames = frames_ratio.round().to_integer();
} }
let sync_status = sync_logic::get_sync_status(avg_delta, &config); let sync_status = sync_logic::get_sync_status(avg_delta, &config);
@ -92,7 +83,6 @@ async fn get_status(data: web::Data<AppState>) -> impl Responder {
ltc_timecode, ltc_timecode,
frame_rate, frame_rate,
system_clock, system_clock,
system_date,
timecode_delta_ms: avg_delta, timecode_delta_ms: avg_delta,
timecode_delta_frames: delta_frames, timecode_delta_frames: delta_frames,
sync_status: sync_status.to_string(), sync_status: sync_status.to_string(),
@ -145,22 +135,6 @@ async fn nudge_clock(req: web::Json<NudgeRequest>) -> impl Responder {
} }
} }
#[derive(Deserialize)]
struct SetDateRequest {
date: String,
}
#[post("/api/set_date")]
async fn set_date(req: web::Json<SetDateRequest>) -> 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")] #[post("/api/config")]
async fn update_config( async fn update_config(
data: web::Data<AppState>, data: web::Data<AppState>,
@ -218,7 +192,6 @@ pub async fn start_api_server(
.service(update_config) .service(update_config)
.service(get_logs) .service(get_logs)
.service(nudge_clock) .service(nudge_clock)
.service(set_date)
// Serve frontend static files // Serve frontend static files
.service(fs::Files::new("/", "static/").index_file("index.html")) .service(fs::Files::new("/", "static/").index_file("index.html"))
}) })
@ -246,8 +219,7 @@ mod tests {
minutes: 2, minutes: 2,
seconds: 3, seconds: 3,
frames: 4, frames: 4,
is_drop_frame: false, frame_rate: 25.0,
frame_rate: Ratio::new(25, 1),
timestamp: Utc::now(), timestamp: Utc::now(),
}), }),
lock_count: 10, lock_count: 10,
@ -295,32 +267,6 @@ mod tests {
assert_eq!(resp.hardware_offset_ms, 10); 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] #[actix_web::test]
async fn test_get_config() { async fn test_get_config() {
let app_state = get_test_app_state(); let app_state = get_test_app_state();

View file

@ -64,7 +64,6 @@ impl Config {
Self::default() Self::default()
}) })
} }
} }
impl Default for Config { impl Default for Config {

View file

@ -15,7 +15,6 @@ use crate::sync_logic::LtcState;
use crate::ui::start_ui; use crate::ui::start_ui;
use clap::Parser; use clap::Parser;
use daemonize::Daemonize; use daemonize::Daemonize;
use serialport;
use std::{ use std::{
fs, fs,
@ -36,8 +35,6 @@ struct Args {
enum Command { enum Command {
/// Run as a background daemon providing a web UI. /// Run as a background daemon providing a web UI.
Daemon, Daemon,
/// Stop the running daemon process.
Kill,
} }
/// Default config content, embedded in the binary. /// Default config content, embedded in the binary.
@ -73,85 +70,30 @@ fn ensure_config() {
} }
} }
fn find_serial_port() -> Option<String> {
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")] #[tokio::main(flavor = "current_thread")]
async fn main() { async fn main() {
// This must be called before any logging statements. // This must be called before any logging statements.
let log_buffer = logger::setup_logger(); let log_buffer = logger::setup_logger();
let args = Args::parse(); let args = Args::parse();
if let Some(command) = &args.command { if let Some(Command::Daemon) = &args.command {
match command { log::info!("🚀 Starting daemon...");
Command::Daemon => {
log::info!("🚀 Starting daemon...");
// Create files for stdout and stderr in the current directory // Create files for stdout and stderr in the current directory
let stdout = let stdout = fs::File::create("daemon.out").expect("Could not create daemon.out");
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 stderr =
fs::File::create("daemon.err").expect("Could not create daemon.err");
let daemonize = Daemonize::new() let daemonize = Daemonize::new()
.pid_file("ntp_timeturner.pid") // Create a PID file .pid_file("ntp_timeturner.pid") // Create a PID file
.working_directory(".") // Keep the same working directory .working_directory(".") // Keep the same working directory
.stdout(stdout) .stdout(stdout)
.stderr(stderr); .stderr(stderr);
match daemonize.start() { match daemonize.start() {
Ok(_) => { /* Process is now daemonized */ } Ok(_) => { /* Process is now daemonized */ }
Err(e) => { Err(e) => {
log::error!("Error daemonizing: {}", e); log::error!("Error daemonizing: {}", e);
return; // Exit if daemonization fails 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;
} }
} }
} }
@ -168,23 +110,13 @@ async fn main() {
// 3⃣ Shared state for UI and serial reader // 3⃣ Shared state for UI and serial reader
let ltc_state = Arc::new(Mutex::new(LtcState::new())); let ltc_state = Arc::new(Mutex::new(LtcState::new()));
// 4⃣ Find serial port and spawn the serial reader thread // 4⃣ 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 tx_clone = tx.clone();
let state_clone = ltc_state.clone(); let state_clone = ltc_state.clone();
let port_clone = serial_port_path.clone();
thread::spawn(move || { thread::spawn(move || {
start_serial_thread( start_serial_thread(
&port_clone, "/dev/ttyACM0",
115200, 115200,
tx_clone, tx_clone,
state_clone, state_clone,
@ -193,24 +125,20 @@ async fn main() {
}); });
} }
// 5⃣ Spawn UI or setup daemon logging. The web service is only started // 5⃣ Spawn UI or setup daemon logging
// when running as a daemon. The TUI is for interactive foreground use.
if args.command.is_none() { if args.command.is_none() {
// --- Interactive TUI Mode ---
log::info!("🔧 Watching config.yml..."); log::info!("🔧 Watching config.yml...");
log::info!("🚀 Serial thread launched"); log::info!("🚀 Serial thread launched");
log::info!("🖥️ UI thread launched"); log::info!("🖥️ UI thread launched");
let ui_state = ltc_state.clone(); let ui_state = ltc_state.clone();
let config_clone = config.clone(); let config_clone = config.clone();
let port = serial_port_path; let port = "/dev/ttyACM0".to_string();
thread::spawn(move || { thread::spawn(move || {
start_ui(ui_state, port, config_clone); start_ui(ui_state, port, config_clone);
}); });
} else { } else {
// --- Daemon Mode ---
// In daemon mode, logging is already set up to go to stderr. // 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) // The systemd service will capture it.
// is launched later in the main async block.
log::info!("🚀 Starting TimeTurner daemon..."); log::info!("🚀 Starting TimeTurner daemon...");
} }
@ -286,10 +214,7 @@ async fn main() {
let local = LocalSet::new(); let local = LocalSet::new();
local local
.run_until(async move { .run_until(async move {
// 8⃣ Spawn the API server task. // 8⃣ Spawn the API server thread
// 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 api_state = ltc_state.clone();
let config_clone = config.clone(); let config_clone = config.clone();

View file

@ -32,7 +32,7 @@ pub fn start_serial_thread(
let reader = std::io::BufReader::new(port); let reader = std::io::BufReader::new(port);
let re = Regex::new( 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(); .unwrap();
@ -60,12 +60,11 @@ mod tests {
use super::*; use super::*;
use std::sync::mpsc; use std::sync::mpsc;
use crate::sync_logic::LtcState; use crate::sync_logic::LtcState;
use num_rational::Ratio;
use regex::Regex; use regex::Regex;
fn get_ltc_regex() -> Regex { fn get_ltc_regex() -> Regex {
Regex::new( 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() ).unwrap()
} }
@ -120,7 +119,7 @@ mod tests {
assert_eq!(st.free_count, 1); assert_eq!(st.free_count, 1);
let received_frame = rx.try_recv().unwrap(); let received_frame = rx.try_recv().unwrap();
assert_eq!(received_frame.status, "FREE"); 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] #[test]

View file

@ -1,22 +1,10 @@
use crate::config::Config; use crate::config::Config;
use chrono::{DateTime, Local, Timelike, Utc}; use chrono::{DateTime, Local, Timelike, Utc};
use num_rational::Ratio;
use regex::Captures; use regex::Captures;
use std::collections::VecDeque; use std::collections::VecDeque;
const EWMA_ALPHA: f64 = 0.1; const EWMA_ALPHA: f64 = 0.1;
fn get_frame_rate_ratio(rate_str: &str) -> Option<Ratio<i64>> {
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)] #[derive(Clone, Debug)]
pub struct LtcFrame { pub struct LtcFrame {
pub status: String, pub status: String,
@ -24,8 +12,7 @@ pub struct LtcFrame {
pub minutes: u32, pub minutes: u32,
pub seconds: u32, pub seconds: u32,
pub frames: u32, pub frames: u32,
pub is_drop_frame: bool, pub frame_rate: f64,
pub frame_rate: Ratio<i64>,
pub timestamp: DateTime<Utc>, // arrival stamp pub timestamp: DateTime<Utc>, // arrival stamp
} }
@ -36,9 +23,8 @@ impl LtcFrame {
hours: caps[2].parse().ok()?, hours: caps[2].parse().ok()?,
minutes: caps[3].parse().ok()?, minutes: caps[3].parse().ok()?,
seconds: caps[4].parse().ok()?, seconds: caps[4].parse().ok()?,
is_drop_frame: &caps[5] == ";", frames: caps[5].parse().ok()?,
frames: caps[6].parse().ok()?, frame_rate: caps[6].parse().ok()?,
frame_rate: get_frame_rate_ratio(&caps[7])?,
timestamp, timestamp,
}) })
} }
@ -143,9 +129,8 @@ impl LtcState {
/// Convert average jitter into frames (rounded). /// Convert average jitter into frames (rounded).
pub fn average_frames(&self) -> i64 { pub fn average_frames(&self) -> i64 {
if let Some(frame) = &self.latest { if let Some(frame) = &self.latest {
let jitter_ms_ratio = Ratio::new(self.average_jitter(), 1); let ms_per_frame = 1000.0 / frame.frame_rate;
let frames_ratio = jitter_ms_ratio * frame.frame_rate / Ratio::new(1000, 1); (self.average_jitter() as f64 / ms_per_frame).round() as i64
frames_ratio.round().to_integer()
} else { } else {
0 0
} }
@ -207,8 +192,7 @@ mod tests {
minutes: m, minutes: m,
seconds: s, seconds: s,
frames: 0, frames: 0,
is_drop_frame: false, frame_rate: 25.0,
frame_rate: Ratio::new(25, 1),
timestamp: Utc::now(), timestamp: Utc::now(),
} }
} }
@ -363,11 +347,7 @@ mod tests {
assert_eq!(get_sync_status(-9, &config), "CLOCK BEHIND"); assert_eq!(get_sync_status(-9, &config), "CLOCK BEHIND");
assert_eq!(get_sync_status(-100, &config), "CLOCK BEHIND"); assert_eq!(get_sync_status(-100, &config), "CLOCK BEHIND");
// Test auto-sync status // Test TIMETURNING 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 }; 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(0, &config), "TIMETURNING");
assert_eq!(get_sync_status(100, &config), "TIMETURNING"); assert_eq!(get_sync_status(100, &config), "TIMETURNING");

View file

@ -1,7 +1,6 @@
use crate::config::Config; use crate::config::Config;
use crate::sync_logic::LtcFrame; use crate::sync_logic::LtcFrame;
use chrono::{DateTime, Duration as ChronoDuration, Local, TimeZone}; use chrono::{DateTime, Duration as ChronoDuration, Local, NaiveTime, TimeZone};
use num_rational::Ratio;
use std::process::Command; use std::process::Command;
/// Check if Chrony is active /// 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<Local> { pub fn calculate_target_time(frame: &LtcFrame, config: &Config) -> DateTime<Local> {
let today_local = Local::now().date_naive(); 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 naive_dt = today_local.and_time(timecode);
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 let mut dt_local = Local
.from_local_datetime(&naive_dt) .from_local_datetime(&naive_dt)
.single() .single()
@ -70,8 +56,7 @@ pub fn calculate_target_time(frame: &LtcFrame, config: &Config) -> DateTime<Loca
+ ChronoDuration::minutes(offset.minutes) + ChronoDuration::minutes(offset.minutes)
+ ChronoDuration::seconds(offset.seconds); + ChronoDuration::seconds(offset.seconds);
// Frame offset needs to be converted to milliseconds // Frame offset needs to be converted to milliseconds
let frame_offset_ms_ratio = Ratio::new(offset.frames * 1000, 1) / frame.frame_rate; let frame_offset_ms = (offset.frames as f64 / frame.frame_rate * 1000.0).round() as i64;
let frame_offset_ms = frame_offset_ms_ratio.round().to_integer();
dt_local + ChronoDuration::milliseconds(frame_offset_ms + offset.milliseconds) dt_local + ChronoDuration::milliseconds(frame_offset_ms + offset.milliseconds)
} }
@ -146,40 +131,11 @@ pub fn nudge_clock(microseconds: i64) -> 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::config::TimeturnerOffset; use crate::config::TimeturnerOffset;
use chrono::{Timelike, Utc}; use chrono::{Timelike, Utc};
use num_rational::Ratio;
// Helper to create a test frame // Helper to create a test frame
fn get_test_frame(h: u32, m: u32, s: u32, f: u32) -> LtcFrame { fn get_test_frame(h: u32, m: u32, s: u32, f: u32) -> LtcFrame {
@ -189,8 +145,7 @@ mod tests {
minutes: m, minutes: m,
seconds: s, seconds: s,
frames: f, frames: f,
is_drop_frame: false, frame_rate: 25.0,
frame_rate: Ratio::new(25, 1),
timestamp: Utc::now(), timestamp: Utc::now(),
} }
} }

View file

@ -19,11 +19,9 @@ use crossterm::{
}; };
use crate::config::Config; use crate::config::Config;
use get_if_addrs::get_if_addrs;
use crate::sync_logic::{get_jitter_status, get_sync_status, LtcState}; use crate::sync_logic::{get_jitter_status, get_sync_status, LtcState};
use crate::system; use crate::system;
use get_if_addrs::get_if_addrs;
use num_rational::Ratio;
use num_traits::ToPrimitive;
pub fn start_ui( pub fn start_ui(
@ -84,9 +82,8 @@ pub fn start_ui(
if last_delta_update.elapsed() >= Duration::from_secs(1) { if last_delta_update.elapsed() >= Duration::from_secs(1) {
cached_delta_ms = avg_delta; cached_delta_ms = avg_delta;
if let Some(frame) = &state.lock().unwrap().latest { if let Some(frame) = &state.lock().unwrap().latest {
let delta_ms_ratio = Ratio::new(avg_delta, 1); let frame_ms = 1000.0 / frame.frame_rate;
let frames_ratio = delta_ms_ratio * frame.frame_rate / Ratio::new(1000, 1); cached_delta_frames = ((avg_delta as f64 / frame_ms).round()) as i64;
cached_delta_frames = frames_ratio.round().to_integer();
} else { } else {
cached_delta_frames = 0; cached_delta_frames = 0;
} }
@ -107,7 +104,7 @@ pub fn start_ui(
None => "LTC Timecode : …".to_string(), None => "LTC Timecode : …".to_string(),
}; };
let fr_str = match opt { 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(), None => "Frame Rate : …".to_string(),
}; };

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 898 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 981 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 955 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 913 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 810 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 805 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 804 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 198 KiB

View file

@ -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%.' }
}
};

View file

@ -3,139 +3,102 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Fetch | Hachi</title> <title>NTP TimeTurner</title>
<link rel="stylesheet" href="style.css"> <link rel="stylesheet" href="style.css">
<link rel="icon" href="favicon.ico" type="image/x-icon">
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<img src="assets/header.png" alt="NTP Timeturner" class="header-logo"> <h1>NTP TimeTurner</h1>
<!-- Mock Data Controls (hidden by default) -->
<div id="mock-controls" class="card full-width" style="display: none;">
<h2>Mock Data Controls</h2>
<div class="control-group">
<label for="mock-data-selector">Select Mock Data Scenario:</label>
<select id="mock-data-selector"></select>
</div>
</div>
<div class="grid"> <div class="grid">
<!-- LTC Status --> <!-- LTC Status -->
<div class="card"> <div class="card">
<h2>LTC Input</h2> <h2>LTC Status</h2>
<p id="ltc-status">--</p>
<p id="ltc-timecode">--:--:--:--</p> <p id="ltc-timecode">--:--:--:--</p>
<div class="icon-group"> <p id="frame-rate">-- fps</p>
<span id="ltc-status"></span> <p>Lock Ratio: <span id="lock-ratio">--</span>%</p>
<span id="frame-rate"></span>
<span id="lock-ratio"></span>
</div>
</div> </div>
<!-- System Clock & Sync --> <!-- System Clock & Sync -->
<div class="card"> <div class="card">
<h2>NTP Clock</h2> <h2>System Clock</h2>
<p id="system-clock">--:--:--.---</p> <p id="system-clock">--:--:--.---</p>
<p class="system-date-display"><span id="system-date">---- -- --</span></p> <p>NTP Service: <span id="ntp-active">--</span></p>
<div class="icon-group"> <p>Sync Status: <span id="sync-status">--</span></p>
<span id="ntp-active"></span> </div>
<span id="sync-status"></span>
<span id="jitter-status"></span> <!-- Delta & Jitter -->
<span id="delta-status"></span> <div class="card">
</div> <h2>Clock Offset</h2>
<p id="delta-text">Δ -- ms (-- frames)</p> <p>Delta: <span id="delta-ms">--</span> ms (<span id="delta-frames">--</span> frames)</p>
<p>Jitter: <span id="jitter-status">--</span></p>
</div> </div>
<!-- Network Interfaces --> <!-- Network Interfaces -->
<div class="card"> <div class="card">
<div class="card-header"> <h2>Network</h2>
<img src="assets/timeturner_network.png" class="header-icon" alt="Network Icon"> <ul id="interfaces">
<h2>Network</h2> <li>--</li>
</div> </ul>
<p id="interfaces">--</p>
</div> </div>
<!-- Controls --> <!-- Controls -->
<div class="card full-width collapsible-card"> <div class="card full-width">
<div class="toggle-header" id="controls-toggle"> <h2>Controls</h2>
<img src="assets/timeturner_controls.png" class="toggle-icon" alt="Controls Icon"> <div class="control-group">
<h2>Controls</h2> <label for="hw-offset">Hardware Offset (ms):</label>
<input type="number" id="hw-offset" name="hw-offset">
</div> </div>
<div class="collapsible-content" id="controls-content"> <div class="control-group">
<div class="control-group" style="display: none;"> <input type="checkbox" id="auto-sync-enabled" name="auto-sync-enabled" style="vertical-align: middle;">
<label for="hw-offset">Hardware Offset (ms):</label> <label for="auto-sync-enabled" style="vertical-align: middle;">Enable Auto Sync</label>
<input type="number" id="hw-offset" name="hw-offset"> </div>
</div> <div class="control-group">
<div class="control-group" style="display: none;"> <label>Timeturner Offset</label>
<input type="checkbox" id="auto-sync-enabled" name="auto-sync-enabled" style="vertical-align: middle;"> <div style="display: flex; flex-wrap: wrap; gap: 1rem; align-items: flex-start;">
<label for="auto-sync-enabled" style="vertical-align: middle;">Enable Auto Sync</label> <div style="display: flex; flex-direction: column;">
</div> <label for="offset-h">Hours</label>
<div class="control-group"> <input type="number" id="offset-h" style="width: 60px;">
<label>Timeturner Offset</label> </div>
<div class="offset-controls-container"> <div style="display: flex; flex-direction: column;">
<div class="offset-control"> <label for="offset-m">Minutes</label>
<input type="number" id="offset-h" min="-99" max="99"> <input type="number" id="offset-m" style="width: 60px;">
<label for="offset-h">hr</label> </div>
</div> <div style="display: flex; flex-direction: column;">
<div class="offset-control"> <label for="offset-s">Seconds</label>
<input type="number" id="offset-m" min="-99" max="99"> <input type="number" id="offset-s" style="width: 60px;">
<label for="offset-m">min</label> </div>
</div> <div style="display: flex; flex-direction: column;">
<div class="offset-control"> <label for="offset-f">Frames</label>
<input type="number" id="offset-s" min="-99" max="99"> <input type="number" id="offset-f" style="width: 60px;">
<label for="offset-s">sec</label> </div>
</div> <div style="display: flex; flex-direction: column;">
<div class="offset-control"> <label for="offset-ms">Milliseconds</label>
<input type="number" id="offset-f" min="-99" max="99"> <input type="number" id="offset-ms" style="width: 60px;">
<label for="offset-f">fr</label>
</div>
<div class="offset-control">
<input type="number" id="offset-ms">
<label for="offset-ms">ms</label>
</div>
</div> </div>
</div> </div>
<div class="control-group">
<button id="save-config">Save Timeturner Config</button>
<button id="manual-sync">Send Manual Sync</button>
<span id="sync-message"></span>
</div>
<div class="control-group" style="display: none;">
<label>Nudge Clock (ms):</label>
<button id="nudge-down">-</button>
<input type="number" id="nudge-value" style="width: 60px;">
<button id="nudge-up">+</button>
<span id="nudge-message"></span>
</div>
<div class="control-group">
<label for="date-input">Set System Date:</label>
<input type="text" id="date-input" placeholder="YYYY-MM-DD" pattern="[0-9]{4}-[0-9]{2}-[0-9]{2}">
<button id="set-date">Set Date</button>
<span id="date-message"></span>
</div>
</div> </div>
</div> <div class="control-group">
<button id="save-config">Save Config</button>
<button id="manual-sync">Manual Sync</button>
<span id="sync-message"></span>
</div>
<div class="control-group">
<label>Nudge Clock (ms):</label>
<button id="nudge-down">-</button>
<input type="number" id="nudge-value" style="width: 60px;">
<button id="nudge-up">+</button>
<span id="nudge-message"></span>
</div>
</div>
<!-- Logs --> <!-- Logs -->
<div class="card full-width collapsible-card"> <div class="card full-width">
<div class="toggle-header" id="logs-toggle"> <h2>Logs</h2>
<img src="assets/timeturner_logs.png" class="toggle-icon" alt="Logs Icon"> <pre id="logs" class="log-box"></pre>
<h2>Logs</h2> </div>
</div> </div>
<div class="collapsible-content" id="logs-content">
<pre id="logs" class="log-box"></pre>
</div>
</div>
</div>
<footer>
<p>
Built by Chris Frankland-Wright and Chaos Rogers | Have Blue Broadcast Media |
<a href="https://github.com/cjfranko/NTP-Timeturner" target="_blank" rel="noopener noreferrer">https://github.com/cjfranko/NTP-Timeturner</a>
</p>
</footer>
</div> </div>
<script src="icon-map.js"></script>
<script src="mock-data.js"></script>
<script src="script.js"></script> <script src="script.js"></script>
</body> </body>
</html> </html>

View file

@ -1,141 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NTP TimeTurner</title>
<link rel="stylesheet" href="style.css">
<link rel="icon" href="favicon.ico" type="image/x-icon">
</head>
<body>
<div class="container">
<img src="assets/header.png" alt="NTP Timeturner" class="header-logo">
<!-- Mock Data Controls (hidden by default) -->
<div id="mock-controls" class="card full-width" style="display: none;">
<h2>Mock Data Controls</h2>
<div class="control-group">
<label for="mock-data-selector">Select Mock Data Scenario:</label>
<select id="mock-data-selector"></select>
</div>
</div>
<div class="grid">
<!-- LTC Status -->
<div class="card">
<h2>LTC Input</h2>
<p id="ltc-timecode">--:--:--:--</p>
<div class="icon-group">
<span id="ltc-status"></span>
<span id="frame-rate"></span>
<span id="lock-ratio"></span>
</div>
</div>
<!-- System Clock & Sync -->
<div class="card">
<h2>NTP Clock</h2>
<p id="system-clock">--:--:--.---</p>
<p class="system-date-display"><span id="system-date">---- -- --</span></p>
<div class="icon-group">
<span id="ntp-active"></span>
<span id="sync-status"></span>
<span id="jitter-status"></span>
<span id="delta-status"></span>
</div>
<p id="delta-text">Δ -- ms (-- frames)</p>
</div>
<!-- Network Interfaces -->
<div class="card">
<div class="card-header">
<img src="assets/timeturner_network.png" class="header-icon" alt="Network Icon">
<h2>Network</h2>
</div>
<p id="interfaces">--</p>
</div>
<!-- Controls -->
<div class="card full-width collapsible-card">
<div class="toggle-header" id="controls-toggle">
<img src="assets/timeturner_controls.png" class="toggle-icon" alt="Controls Icon">
<h2>Controls</h2>
</div>
<div class="collapsible-content" id="controls-content">
<div class="control-group">
<label for="hw-offset">Hardware Offset (ms):</label>
<input type="number" id="hw-offset" name="hw-offset">
</div>
<div class="control-group">
<input type="checkbox" id="auto-sync-enabled" name="auto-sync-enabled" style="vertical-align: middle;">
<label for="auto-sync-enabled" style="vertical-align: middle;">Enable Auto Sync</label>
</div>
<div class="control-group">
<label>Timeturner Offset</label>
<div style="display: flex; flex-wrap: wrap; gap: 1rem; align-items: flex-start;">
<div style="display: flex; flex-direction: column;">
<label for="offset-h">Hours</label>
<input type="number" id="offset-h" style="width: 60px;">
</div>
<div style="display: flex; flex-direction: column;">
<label for="offset-m">Minutes</label>
<input type="number" id="offset-m" style="width: 60px;">
</div>
<div style="display: flex; flex-direction: column;">
<label for="offset-s">Seconds</label>
<input type="number" id="offset-s" style="width: 60px;">
</div>
<div style="display: flex; flex-direction: column;">
<label for="offset-f">Frames</label>
<input type="number" id="offset-f" style="width: 60px;">
</div>
<div style="display: flex; flex-direction: column;">
<label for="offset-ms">Milliseconds</label>
<input type="number" id="offset-ms" style="width: 60px;">
</div>
</div>
</div>
<div class="control-group">
<button id="save-config">Save Config</button>
<button id="manual-sync">Manual Sync</button>
<span id="sync-message"></span>
</div>
<div class="control-group">
<label>Nudge Clock (ms):</label>
<button id="nudge-down">-</button>
<input type="number" id="nudge-value" style="width: 60px;">
<button id="nudge-up">+</button>
<span id="nudge-message"></span>
</div>
<div class="control-group">
<label for="date-input">Set System Date:</label>
<input type="text" id="date-input" placeholder="YYYY-MM-DD" pattern="[0-9]{4}-[0-9]{2}-[0-9]{2}">
<button id="set-date">Set Date</button>
<span id="date-message"></span>
</div>
</div>
</div>
<!-- Logs -->
<div class="card full-width collapsible-card">
<div class="toggle-header" id="logs-toggle">
<img src="assets/timeturner_logs.png" class="toggle-icon" alt="Logs Icon">
<h2>Logs</h2>
</div>
<div class="collapsible-content" id="logs-content">
<pre id="logs" class="log-box"></pre>
</div>
</div>
</div>
<footer>
<p>
Built by Chris Frankland-Wright and John Rogers | Have Blue Broadcast Media |
<a href="https://github.com/cjfranko/NTP-Timeturner" target="_blank" rel="noopener noreferrer">https://github.com/cjfranko/NTP-Timeturner</a>
</p>
</footer>
</div>
<script src="icon-map.js"></script>
<script src="mock-data.js"></script>
<script src="script.js"></script>
</body>
</html>

View file

@ -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.' ]
}
};

View file

@ -1,24 +1,15 @@
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 = { const statusElements = {
ltcStatus: document.getElementById('ltc-status'), ltcStatus: document.getElementById('ltc-status'),
ltcTimecode: document.getElementById('ltc-timecode'), ltcTimecode: document.getElementById('ltc-timecode'),
frameRate: document.getElementById('frame-rate'), frameRate: document.getElementById('frame-rate'),
lockRatio: document.getElementById('lock-ratio'), lockRatio: document.getElementById('lock-ratio'),
systemClock: document.getElementById('system-clock'), systemClock: document.getElementById('system-clock'),
systemDate: document.getElementById('system-date'),
ntpActive: document.getElementById('ntp-active'), ntpActive: document.getElementById('ntp-active'),
syncStatus: document.getElementById('sync-status'), 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'), jitterStatus: document.getElementById('jitter-status'),
deltaText: document.getElementById('delta-text'),
interfaces: document.getElementById('interfaces'), interfaces: document.getElementById('interfaces'),
logs: document.getElementById('logs'), logs: document.getElementById('logs'),
}; };
@ -41,211 +32,51 @@
const nudgeValueInput = document.getElementById('nudge-value'); const nudgeValueInput = document.getElementById('nudge-value');
const nudgeMessage = document.getElementById('nudge-message'); 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) { function updateStatus(data) {
const ltcStatus = data.ltc_status || 'UNKNOWN'; statusElements.ltcStatus.textContent = data.ltc_status;
const ltcIconInfo = iconMap.ltcStatus[ltcStatus] || iconMap.ltcStatus.default;
statusElements.ltcStatus.innerHTML = `<img src="${ltcIconInfo.src}" class="status-icon" alt="" title="${ltcIconInfo.tooltip}">`;
statusElements.ltcStatus.className = ltcStatus.toLowerCase();
statusElements.ltcTimecode.textContent = data.ltc_timecode; statusElements.ltcTimecode.textContent = data.ltc_timecode;
statusElements.frameRate.textContent = data.frame_rate;
const frameRate = data.frame_rate || 'unknown'; statusElements.lockRatio.textContent = data.lock_ratio.toFixed(2);
const frameRateIconInfo = iconMap.frameRate[frameRate] || iconMap.frameRate.default;
statusElements.frameRate.innerHTML = `<img src="${frameRateIconInfo.src}" class="status-icon" alt="" title="${frameRateIconInfo.tooltip}">`;
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 = `<img src="${lockRatioIconInfo.src}" class="status-icon" alt="" title="${lockRatioIconInfo.tooltip}">`;
statusElements.systemClock.textContent = data.system_clock; 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. statusElements.syncStatus.textContent = data.sync_status;
if (!lastApiData || dateInput.value === lastApiData.system_date) { statusElements.syncStatus.className = data.sync_status.replace(/\s+/g, '-').toLowerCase();
dateInput.value = data.system_date;
}
const ntpIconInfo = iconMap.ntpActive[!!data.ntp_active]; statusElements.deltaMs.textContent = data.timecode_delta_ms;
if (data.ntp_active) { statusElements.deltaFrames.textContent = data.timecode_delta_frames;
statusElements.ntpActive.innerHTML = `<img src="${ntpIconInfo.src}" class="status-icon" alt="" title="${ntpIconInfo.tooltip}">`;
statusElements.ntpActive.className = 'active'; statusElements.jitterStatus.textContent = data.jitter_status;
} else { statusElements.jitterStatus.className = data.jitter_status.toLowerCase();
statusElements.ntpActive.innerHTML = `<img src="${ntpIconInfo.src}" class="status-icon" alt="" title="${ntpIconInfo.tooltip}">`;
statusElements.ntpActive.className = 'inactive';
}
const syncStatus = data.sync_status || 'UNKNOWN';
const syncIconInfo = iconMap.syncStatus[syncStatus] || iconMap.syncStatus.default;
statusElements.syncStatus.innerHTML = `<img src="${syncIconInfo.src}" class="status-icon" alt="" title="${syncIconInfo.tooltip}">`;
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 = `<img src="${deltaIconInfo.src}" class="status-icon" alt="" title="${deltaIconInfo.tooltip}">`;
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 = `<img src="${jitterIconInfo.src}" class="status-icon" alt="" title="${jitterIconInfo.tooltip}">`;
statusElements.jitterStatus.className = jitterStatus.toLowerCase();
statusElements.interfaces.innerHTML = '';
if (data.interfaces.length > 0) { 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 { } else {
statusElements.interfaces.textContent = 'No active interfaces found.'; const li = document.createElement('li');
} li.textContent = 'No active interfaces found.';
} statusElements.interfaces.appendChild(li);
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() { async function fetchStatus() {
if (useMockData) {
const data = mockApiDataSets[currentMockSetKey].status;
updateStatus(data);
lastApiData = data;
lastApiFetchTime = new Date();
return;
}
try { try {
const response = await fetch('/api/status'); const response = await fetch('/api/status');
if (!response.ok) throw new Error('Failed to fetch status'); if (!response.ok) throw new Error('Failed to fetch status');
const data = await response.json(); const data = await response.json();
updateStatus(data); updateStatus(data);
lastApiData = data;
lastApiFetchTime = new Date();
} catch (error) { } catch (error) {
console.error('Error fetching status:', error); console.error('Error fetching status:', error);
lastApiData = null;
lastApiFetchTime = null;
} }
} }
async function fetchConfig() { 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 { try {
const response = await fetch('/api/config'); const response = await fetch('/api/config');
if (!response.ok) throw new Error('Failed to fetch config'); if (!response.ok) throw new Error('Failed to fetch config');
@ -269,22 +100,14 @@
autoSyncEnabled: autoSyncCheckbox.checked, autoSyncEnabled: autoSyncCheckbox.checked,
defaultNudgeMs: parseInt(nudgeValueInput.value, 10) || 0, defaultNudgeMs: parseInt(nudgeValueInput.value, 10) || 0,
timeturnerOffset: { timeturnerOffset: {
hours: parseInt(offsetInputs.h.value, 10) || 0, hours: parseInt(offsetInputs.h.value, 10) || 0,
minutes: parseInt(offsetInputs.m.value, 10) || 0, minutes: parseInt(offsetInputs.m.value, 10) || 0,
seconds: parseInt(offsetInputs.s.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, 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 { try {
const response = await fetch('/api/config', { const response = await fetch('/api/config', {
method: 'POST', method: 'POST',
@ -300,21 +123,13 @@
} }
async function fetchLogs() { 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 { try {
const response = await fetch('/api/logs'); const response = await fetch('/api/logs');
if (!response.ok) throw new Error('Failed to fetch logs'); if (!response.ok) throw new Error('Failed to fetch logs');
const logs = await response.json(); const logs = await response.json();
// Show latest 20 logs, with the newest at the top. statusElements.logs.textContent = logs.join('\n');
logs.reverse(); // Auto-scroll to the bottom
statusElements.logs.textContent = logs.slice(0, 20).join('\n'); statusElements.logs.scrollTop = statusElements.logs.scrollHeight;
} catch (error) { } catch (error) {
console.error('Error fetching logs:', error); console.error('Error fetching logs:', error);
statusElements.logs.textContent = 'Error fetching logs.'; statusElements.logs.textContent = 'Error fetching logs.';
@ -323,11 +138,6 @@
async function triggerManualSync() { async function triggerManualSync() {
syncMessage.textContent = 'Issuing sync command...'; syncMessage.textContent = 'Issuing sync command...';
if (useMockData) {
syncMessage.textContent = 'Success: Manual sync triggered (mock).';
setTimeout(() => { syncMessage.textContent = ''; }, 5000);
return;
}
try { try {
const response = await fetch('/api/sync', { method: 'POST' }); const response = await fetch('/api/sync', { method: 'POST' });
const data = await response.json(); const data = await response.json();
@ -345,11 +155,6 @@
async function nudgeClock(ms) { async function nudgeClock(ms) {
nudgeMessage.textContent = 'Nudging clock...'; nudgeMessage.textContent = 'Nudging clock...';
if (useMockData) {
nudgeMessage.textContent = `Success: Clock nudged by ${ms}ms (mock).`;
setTimeout(() => { nudgeMessage.textContent = ''; }, 3000);
return;
}
try { try {
const response = await fetch('/api/nudge_clock', { const response = await fetch('/api/nudge_clock', {
method: 'POST', method: 'POST',
@ -369,42 +174,6 @@
setTimeout(() => { nudgeMessage.textContent = ''; }, 3000); 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); saveConfigButton.addEventListener('click', saveConfig);
manualSyncButton.addEventListener('click', triggerManualSync); manualSyncButton.addEventListener('click', triggerManualSync);
nudgeDownButton.addEventListener('click', () => { nudgeDownButton.addEventListener('click', () => {
@ -415,29 +184,13 @@
const ms = parseInt(nudgeValueInput.value, 10) || 0; const ms = parseInt(nudgeValueInput.value, 10) || 0;
nudgeClock(ms); 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 // Initial data load
setupMockControls();
fetchStatus(); fetchStatus();
fetchConfig(); fetchConfig();
fetchLogs(); fetchLogs();
// Refresh data every 2 seconds if not using mock data // Refresh data every 2 seconds
if (!useMockData) { setInterval(fetchStatus, 2000);
setInterval(fetchStatus, 2000); setInterval(fetchLogs, 2000);
setInterval(fetchLogs, 2000);
}
setInterval(animateClocks, 50); // High-frequency clock animation
}); });

View file

@ -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 { body {
font-family: 'FuturaStdHeavy', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background-color: #221f1f; background-color: #f4f4f9;
background-image: url('assets/HaveBlueTransWh.png');
background-repeat: no-repeat;
background-position: bottom 20px right 20px;
background-attachment: fixed;
background-size: 100px;
color: #333; color: #333;
margin: 0; margin: 0;
padding: 20px; padding: 20px;
@ -28,20 +13,19 @@ body {
max-width: 960px; max-width: 960px;
} }
.header-logo { h1 {
display: block; text-align: center;
margin: 0 auto 20px auto; color: #444;
max-width: 60%;
} }
.grid { .grid {
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px; gap: 20px;
} }
.card { .card {
background: #c5ced6; background: #fff;
border-radius: 8px; border-radius: 8px;
padding: 20px; padding: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1); box-shadow: 0 2px 4px rgba(0,0,0,0.1);
@ -49,32 +33,16 @@ body {
.card h2 { .card h2 {
margin-top: 0; margin-top: 0;
color: #1a7db6; color: #0056b3;
}
#ltc-timecode, #system-clock {
font-family: 'Quartz', monospace;
font-size: 2em;
text-align: center;
letter-spacing: 2px;
} }
.card p, .card ul { .card p, .card ul {
margin: 10px 0; margin: 10px 0;
} }
.system-date-display { .card ul {
text-align: center; padding-left: 20px;
font-size: 1.5em; list-style: none;
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 { .full-width {
@ -88,75 +56,25 @@ body {
gap: 10px; 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"] { input[type="number"] {
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
width: 80px; width: 80px;
} }
input[type="text"] {
width: auto;
}
button { button {
padding: 8px 15px; padding: 8px 15px;
border: none; border: none;
border-radius: 4px; border-radius: 4px;
background-color: #1a7db6; background-color: #007bff;
color: white; color: white;
cursor: pointer; cursor: pointer;
font-size: 14px; font-size: 14px;
font-family: Arial, sans-serif;
font-weight: bold;
transition: background-color 0.2s;
} }
button:hover { button:hover {
background-color: #166999; background-color: #0056b3;
}
.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 { #sync-message {
@ -164,103 +82,6 @@ button:hover {
color: #555; 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 */ /* Status-specific colors */
#sync-status.in-sync, #jitter-status.good { font-weight: bold; color: #28a745; } #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; } #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; } #jitter-status.bad { font-weight: bold; color: #dc3545; }
#ntp-active.active { font-weight: bold; color: #28a745; } #ntp-active.active { font-weight: bold; color: #28a745; }
#ntp-active.inactive { font-weight: bold; color: #dc3545; } #ntp-active.inactive { font-weight: bold; color: #dc3545; }
#ltc-status.lock { font-weight: bold; color: #28a745; }
#ltc-status.free { font-weight: bold; color: #ffc107; }

View file

@ -9,11 +9,10 @@ import threading
import queue import queue
import json import json
from collections import deque from collections import deque
from fractions import Fraction
SERIAL_PORT = None SERIAL_PORT = None
BAUD_RATE = 115200 BAUD_RATE = 115200
FRAME_RATE = Fraction(25, 1) FRAME_RATE = 25.0
CONFIG_PATH = "config.json" CONFIG_PATH = "config.json"
sync_pending = False sync_pending = False
@ -31,14 +30,6 @@ sync_enabled = False
last_match_check = 0 last_match_check = 0
timecode_match_status = "UNKNOWN" 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(): def load_config():
global hardware_offset_ms global hardware_offset_ms
try: try:
@ -59,16 +50,13 @@ def parse_ltc_line(line):
if not match: if not match:
return None return None
status, hh, mm, ss, ff, fps = match.groups() status, hh, mm, ss, ff, fps = match.groups()
rate = framerate_str_to_fraction(fps)
if not rate:
return None
return { return {
"status": status, "status": status,
"hours": int(hh), "hours": int(hh),
"minutes": int(mm), "minutes": int(mm),
"seconds": int(ss), "seconds": int(ss),
"frames": int(ff), "frames": int(ff),
"frame_rate": rate "frame_rate": float(fps)
} }
def serial_thread(port, baud, q): def serial_thread(port, baud, q):
@ -166,7 +154,7 @@ def run_curses(stdscr):
parsed, arrival_time = latest_ltc parsed, arrival_time = latest_ltc
stdscr.addstr(3, 2, f"LTC Status : {parsed['status']}") 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(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())}") stdscr.addstr(6, 2, f"System Clock : {format_time(get_system_time())}")
if ltc_locked and sync_enabled and offset_history: if ltc_locked and sync_enabled and offset_history:

View file

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

View file

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