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"] }
log = { version = "0.4", features = ["std"] }
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
- Raspberry Pi 5 2GB (Dev Platform) but should be supported by Pi v3 (or better)
- Raspberry Pi 5 (Dev Platform) but should be supported by Pi v3 (or better)
- Debian Bookworm (64-bit recommended)
- Teensy 4.0 - https://thepihut.com/products/teensy-4-0-headers
- Audio Adapter Board for Teensy 4.0 (Rev D) - https://thepihut.com/products/audio-adapter-board-for-teensy-4-0
@ -24,103 +24,26 @@ Created by Chris Frankland-Wright and Chaos Rogers
- Reads SMPTE LTC from Audio Interface (3.5mm TRS but adaptable to BNC/XLR)
- Converts LTC into NTP-synced time
- Broadcasts time via local NTP server
- Supports configurable time offsets (hours, minutes, seconds, frames or milliseconds)
- Supports configurable time offsets (hours, minutes, seconds, milliseconds) - NOT AVAILABLE
- Systemd service support for headless operation
- Web-based UI for monitoring and control when running as a daemon
---
## 🖥️ Web Interface & API
## 🚀 Installation (to update)
When running as a background daemon, Hachi provides a web interface for monitoring and configuration.
- **Access**: The web UI is available at `http://<raspberry_pi_ip>:8080`.
- **Functionality**: You can view the real-time sync status, see logs, and change all configuration options directly from your browser.
- **API**: A JSON API is also exposed for programmatic access. See `docs/api.md` for full details.
---
## 🛠️ Known Issues
- Supported Frame Rates: 24/25fps
- Non Supported Frame Rates: 23.98/30/59.94/60
- Fractional framerates have drift or wrong wall clock sync issues
---
## 🚀 Installation
The `setup.sh` script compiles and installs the Hachi application. You can run it by cloning the repository with `git` or by using the `curl` command below for a git-free installation.
### Prerequisites
- **Internet Connection**: To download dependencies.
- **Curl and Unzip**: The script requires `curl` to download files and `unzip` for the git-free method. The setup script will attempt to install these if they are missing.
### Running the Installer (Recommended)
This command downloads the latest version, unpacks it, and runs the setup script. Paste it into your Raspberry Pi terminal:
For Rust install you can do
```bash
cargo install --git https://github.com/cjfranko/NTP-Timeturner
```
Clone and run the installer:
```bash
curl -L https://github.com/cjfranko/NTP-Timeturner/archive/refs/heads/main.zip -o NTP-Timeturner.zip && \
unzip NTP-Timeturner.zip && \
cd NTP-Timeturner-main && \
chmod +x setup.sh && \
wget https://raw.githubusercontent.com/cjfranko/NTP-Timeturner/master/setup.sh
chmod +x setup.sh
./setup.sh
```
### What the Script Does
The installation script automates the following steps:
1. **Installs Dependencies**: Installs `git`, `curl`, `unzip`, and necessary build tools.
2. **Compiles the Binary**: Runs `cargo build --release` to create an optimised executable.
3. **Creates Directories**: Creates `/opt/timeturner` to store the application files.
4. **Installs Files**:
- The compiled binary is copied to `/opt/timeturner/timeturner`.
- The web interface assets from the `static/` directory are copied to `/opt/timeturner/static`.
- A symbolic link is created from `/usr/local/bin/timeturner` to the binary, allowing it to be run from any location.
5. **Sets up Systemd Service**:
- Copies the `timeturner.service` file to `/etc/systemd/system/`.
- Enables the service to start automatically on system boot.
After installation is complete, the script will provide instructions to start the service manually or to run the application in its interactive terminal mode.
```bash
The working directory is /opt/timeturner.
Default 'config.yml' installed to /opt/timeturner.
To start the service, run:
sudo systemctl start timeturner.service
To view live logs, run:
journalctl -u timeturner.service -f
To run the interactive TUI instead, simply run from the project directory:
cargo run
Or from anywhere after installation:
timeturner
```
---
## 🔄 Updating
If you installed Hachi by cloning the repository with `git`, you can use the `update.sh` script to easily update to the latest version.
**Note**: This script will not work if you used the `curl` one-line command for installation, as that method does not create a Git repository.
To run the update script, navigate to the `NTP-Timeturner-main` directory and run:
```bash
chmod +x update.sh && ./update.sh
```
The update script automates the following:
1. Pulls the latest code from the `main` branch on GitHub.
2. Rebuilds the application binary.
3. Copies the new binary to `/opt/timeturner/`.
4. Restarts the `timeturner` service to apply the changes.
---
## 🕰️ Chrony NTP
```bash
@ -129,10 +52,10 @@ chronyc tracking | NTP Tracking
sudo nano /etc/chrony/chrony.conf | Default Chrony Conf File
Add to top:
# Serve the system clock as a reference at stratum1
# Serve the system clock as a reference at stratum10
server 127.127.1.0
allow 127.0.0.0/8
local stratum 1
local stratum 10
Add to bottom:
# 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.
# These can be positive or negative.
timeturnerOffset:
hours: 0
minutes: 0
seconds: 0
frames: 0
milliseconds: 0
hours: 1
minutes: 2
seconds: 3
frames: 4
milliseconds: 5

View file

@ -4,25 +4,19 @@ This document describes the HTTP API for the NTP Timeturner application.
## Endpoints
### Status and Logs
### Status
- **`GET /api/status`**
Retrieves the real-time status of the LTC reader and system clock synchronization. The `ltc_timecode` field uses `:` as a separator for non-drop-frame timecode, and `;` for drop-frame timecode between seconds and frames (e.g., `10:20:30;00`).
**Possible values for status fields:**
- `ltc_status`: `"LOCK"`, `"FREE"`, or `"(waiting)"`
- `sync_status`: `"IN SYNC"`, `"CLOCK AHEAD"`, `"CLOCK BEHIND"`, `"TIMETURNING"`
- `jitter_status`: `"GOOD"`, `"AVERAGE"`, `"BAD"`
Retrieves the real-time status of the LTC reader and system clock synchronization.
**Example Response:**
```json
{
"ltc_status": "LOCK",
"ltc_timecode": "10:20:30;00",
"ltc_timecode": "10:20:30:00",
"frame_rate": "25.00fps",
"system_clock": "10:20:30.005",
"system_date": "2025-07-30",
"timecode_delta_ms": 5,
"timecode_delta_frames": 0,
"sync_status": "IN SYNC",
@ -30,23 +24,11 @@ This document describes the HTTP API for the NTP Timeturner application.
"lock_ratio": 99.5,
"ntp_active": true,
"interfaces": ["192.168.1.100"],
"hardware_offset_ms": 20
"hardware_offset_ms": 0
}
```
- **`GET /api/logs`**
Retrieves the last 100 log entries from the application.
**Example Response:**
```json
[
"2025-08-07 10:00:00 [INFO] Starting TimeTurner daemon...",
"2025-08-07 10:00:01 [INFO] Found serial port: /dev/ttyACM0"
]
```
### System Clock Control
### Sync
- **`POST /api/sync`**
@ -54,7 +36,7 @@ This document describes the HTTP API for the NTP Timeturner application.
**Request Body:** None
**Success Response (200 OK):**
**Success Response:**
```json
{
"status": "success",
@ -62,14 +44,13 @@ This document describes the HTTP API for the NTP Timeturner application.
}
```
**Error Response (400 Bad Request):**
**Error Responses:**
```json
{
"status": "error",
"message": "No LTC timecode available to sync to."
}
```
**Error Response (500 Internal Server Error):**
```json
{
"status": "error",
@ -77,120 +58,33 @@ This document describes the HTTP API for the NTP Timeturner application.
}
```
- **`POST /api/nudge_clock`**
Nudges the system clock by a specified number of microseconds. This requires `sudo` privileges to run `adjtimex`.
**Example Request:**
```json
{
"microseconds": -2000
}
```
**Success Response (200 OK):**
```json
{
"status": "success",
"message": "Clock nudge command issued."
}
```
**Error Response (500 Internal Server Error):**
```json
{
"status": "error",
"message": "Clock nudge command failed."
}
```
- **`POST /api/set_date`**
Sets the system date. This is useful as LTC does not contain date information. Requires `sudo` privileges.
**Example Request:**
```json
{
"date": "2025-07-30"
}
```
**Success Response (200 OK):**
```json
{
"status": "success",
"message": "Date update command issued."
}
```
**Error Response (500 Internal Server Error):**
```json
{
"status": "error",
"message": "Date update command failed."
}
```
### Configuration
- **`GET /api/config`**
Retrieves the current application configuration from `config.yml`.
Retrieves the current application configuration.
**Example Response (200 OK):**
**Example Response:**
```json
{
"hardwareOffsetMs": 20,
"timeturnerOffset": {
"hours": 0,
"minutes": 0,
"seconds": 0,
"frames": 0,
"milliseconds": 0
},
"defaultNudgeMs": 2,
"autoSyncEnabled": false
"hardware_offset_ms": 0
}
```
- **`POST /api/config`**
Updates the application configuration. The new configuration is persisted to `config.yml` and takes effect immediately.
Updates the `hardware_offset_ms` configuration. The new value is persisted to `config.json` and reloaded by the application automatically.
**Example Request:**
```json
{
"hardwareOffsetMs": 55,
"timeturnerOffset": {
"hours": 1,
"minutes": 2,
"seconds": 3,
"frames": 4,
"milliseconds": 5
},
"defaultNudgeMs": 2,
"autoSyncEnabled": true
"hardware_offset_ms": 10
}
```
**Success Response (200 OK):** (Returns the updated configuration)
**Success Response:**
```json
{
"hardwareOffsetMs": 55,
"timeturnerOffset": {
"hours": 1,
"minutes": 2,
"seconds": 3,
"frames": 4,
"milliseconds": 5
},
"defaultNudgeMs": 2,
"autoSyncEnabled": true
}
```
**Error Response (500 Internal Server Error):**
```json
{
"status": "error",
"message": "Failed to write config.yml"
"hardware_offset_ms": 10
}
```

241
setup.sh
View file

@ -3,226 +3,14 @@ set -e
echo "--- TimeTurner Setup ---"
# Check if TimeTurner is already installed.
INSTALL_DIR="/opt/timeturner"
if [ -f "${INSTALL_DIR}/timeturner" ]; then
echo "✅ TimeTurner is already installed."
# Ask the user what to do
read -p "Do you want to (U)pdate, (R)einstall, or (A)bort? [U/r/a] " choice
case "$choice" in
r|R )
echo "Proceeding with full re-installation..."
# Stop the service to allow overwriting the binary, ignore errors if not running
echo "Stopping existing TimeTurner service..."
sudo systemctl stop timeturner.service || true
# The script will continue to the installation steps below.
;;
a|A )
echo "Aborting setup."
exit 0
;;
* ) # Default to Update
echo "Attempting to run the update script..."
# Ensure we are in a git repository and the update script exists
if [ -d ".git" ] && [ -f "update.sh" ]; then
chmod +x update.sh
./update.sh
# Exit cleanly after the update
exit 0
else
echo "⚠️ Could not find 'update.sh' or not in a git repository."
echo "Please re-clone the repository to get the update script, or remove the existing installation to run setup again:"
echo " sudo rm -rf ${INSTALL_DIR}"
exit 1
fi
;;
esac
fi
# Determine package manager
PKG_MANAGER=""
if command -v apt &> /dev/null; then
PKG_MANAGER="apt"
elif command -v dnf &> /dev/null; then
PKG_MANAGER="dnf"
elif command -v pacman &> /dev/null; then
PKG_MANAGER="pacman"
else
echo "Error: No supported package manager (apt, dnf, pacman) found. Please install dependencies manually."
exit 1
fi
echo "Detected package manager: $PKG_MANAGER"
# --- Update System Packages ---
echo "Updating system packages..."
if [ "$PKG_MANAGER" == "apt" ]; then
sudo apt update
sudo DEBIAN_FRONTEND=noninteractive apt upgrade -y -o Dpkg::Options::="--force-confold"
elif [ "$PKG_MANAGER" == "dnf" ]; then
sudo dnf upgrade -y
elif [ "$PKG_MANAGER" == "pacman" ]; then
sudo pacman -Syu --noconfirm
fi
echo "System packages updated."
# --- Install Rust/Cargo if not installed ---
if ! command -v cargo &> /dev/null; then
echo "Rust/Cargo not found. Installing Rustup..."
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
# Source cargo's env for the current shell session
# This is for the current script's execution path, typically rustup adds to .bashrc/.profile for future sessions.
# We need it now, but for non-interactive script, sourcing won't affect parent shell.
# However, cargo build below will rely on it being in PATH. rustup makes sure of this if it installs.
# For safety, ensure PATH is updated.
export PATH="$HOME/.cargo/bin:$PATH"
echo "Rust/Cargo installed successfully."
else
echo "Rust/Cargo is already installed."
fi
# --- Install common build dependencies for Rust ---
echo "Installing common build dependencies..."
if [ "$PKG_MANAGER" == "apt" ]; then
sudo apt update
sudo apt install -y build-essential libudev-dev pkg-config curl wget
elif [ "$PKG_MANAGER" == "dnf" ]; then
sudo dnf install -y gcc make perl-devel libudev-devel pkg-config curl wget
elif [ "$PKG_MANAGER" == "pacman" ]; then
sudo pacman -Sy --noconfirm base-devel libudev pkg-config curl
fi
echo "Common build dependencies installed."
# --- Install Python dependencies for testing ---
echo "🐍 Installing Python dependencies for test scripts..."
if [ "$PKG_MANAGER" == "apt" ]; then
# We no longer need hotspot dependencies
sudo apt install -y python3 python3-pip python3-serial
elif [ "$PKG_MANAGER" == "dnf" ]; then
# python3-pyserial is the name for pyserial in dnf
sudo dnf install -y python3 python3-pip python3-pyserial
elif [ "$PKG_MANAGER" == "pacman" ]; then
# python-pyserial is the name for pyserial in pacman
sudo pacman -Sy --noconfirm python python-pip python-pyserial
fi
# sudo pip3 install pyserial # This is replaced by the native package manager installs above
echo "✅ Python dependencies installed."
# --- Apply custom splash screen ---
if [[ "$(uname)" == "Linux" ]]; then
echo "🖼️ Applying custom splash screen..."
SPLASH_URL="https://raw.githubusercontent.com/cjfranko/NTP-Timeturner/refs/heads/main/splash.png"
PLYMOUTH_THEME_DIR="/usr/share/plymouth/themes/pix"
PLYMOUTH_IMAGE_PATH="${PLYMOUTH_THEME_DIR}/splash.png"
sudo mkdir -p "${PLYMOUTH_THEME_DIR}"
echo "Downloading splash image from ${SPLASH_URL}..."
sudo curl -L "${SPLASH_URL}" -o "${PLYMOUTH_IMAGE_PATH}"
if [ -f "${PLYMOUTH_IMAGE_PATH}" ]; then
echo "Splash image downloaded. Updating Plymouth configuration..."
# Set 'pix' as the default plymouth theme if not already.
# This is a common theme that expects splash.png.
sudo update-alternatives --install /usr/share/plymouth/themes/default.plymouth default.plymouth "${PLYMOUTH_THEME_DIR}/pix.plymouth" 100 || true
# Ensure the pix theme exists and is linked
if [ ! -f "${PLYMOUTH_THEME_DIR}/pix.plymouth" ]; then
echo "Creating dummy pix.plymouth for update-initramfs"
echo "[Plymouth Theme]" | sudo tee "${PLYMOUTH_THEME_DIR}/pix.plymouth" > /dev/null
echo "Name=Pi Splash" | sudo tee -a "${PLYMOUTH_THEME_DIR}/pix.plymouth" > /dev/null
echo "Description=TimeTurner Raspberry Pi Splash Screen" | sudo tee -a "${PLYMOUTH_THEME_DIR}/pix.plymouth" > /dev/null
echo "SpriteAnimation=/splash.png" | sudo tee -a "${PLYMOUTH_THEME_DIR}/pix.plymouth" > /dev/null
fi
# Update the initial RAM filesystem to include the new splash screen
sudo update-initramfs -u
echo "✅ Custom splash screen applied. Reboot may be required to see changes."
else
echo "❌ Failed to download splash image from ${SPLASH_URL}."
fi
else
echo "⚠️ Skipping splash screen configuration on non-Linux OS."
fi
# --- Remove NTPD and install Chrony, NMTUI, Adjtimex ---
echo "Removing NTPD (if installed) and installing Chrony, NMTUI, Adjtimex..."
# --- Remove NTPD and install Chrony, NMTUI, Adjtimex ---
echo "Removing NTPD (if installed) and installing Chrony, NMTUI, Adjtimex..."
if [ "$PKG_MANAGER" == "apt" ]; then
sudo apt update
sudo apt remove -y ntp || true # Remove ntp if it exists, ignore if not
sudo apt install -y chrony network-manager adjtimex
sudo systemctl enable chrony --now
elif [ "$PKG_MANAGER" == "dnf" ]; then
sudo dnf remove -y ntp
sudo dnf install -y chrony NetworkManager-tui adjtimex
sudo systemctl enable chronyd --now
elif [ "$PKG_MANAGER" == "pacman" ]; then
sudo pacman -Sy --noconfirm ntp || true
sudo pacman -R --noconfirm ntp || true # Ensure ntp is removed
sudo pacman -Sy --noconfirm chrony networkmanager adjtimex
sudo systemctl enable chronyd --now
sudo systemctl enable NetworkManager --now # nmtui relies on NetworkManager
fi
echo "NTPD removed (if present). Chrony, NMTUI, and Adjtimex installed and configured."
# --- Configure Chrony to act as a local NTP server ---
echo "⚙️ Configuring Chrony to serve local time..."
# The path to chrony.conf can vary
if [ -f /etc/chrony/chrony.conf ]; then
CHRONY_CONF="/etc/chrony/chrony.conf"
elif [ -f /etc/chrony.conf ]; then
CHRONY_CONF="/etc/chrony.conf"
else
CHRONY_CONF=""
fi
if [ -n "$CHRONY_CONF" ]; then
# Comment out any existing pool, server, or sourcedir lines to prevent syncing with external sources
echo "Disabling external NTP sources..."
sudo sed -i -E 's/^(pool|server|sourcedir)/#&/' "$CHRONY_CONF"
# Add settings to the top of the file to serve local clock
# Using a temp file to prepend is safer than multiple sed calls
TEMP_CONF=$(mktemp)
cat <<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
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
echo "✅ Build complete."
@ -233,16 +21,13 @@ echo "🔧 Creating directories..."
sudo mkdir -p $INSTALL_DIR
echo "✅ Directory $INSTALL_DIR created."
# 3. Install binary and static web files
echo "🚀 Installing timeturner binary and web assets..."
# 3. Install binary
echo "🚀 Installing timeturner binary..."
sudo cp target/release/ntp_timeturner $INSTALL_DIR/timeturner
# The static directory contains the web UI files
sudo cp -r static $INSTALL_DIR/
sudo ln -sf $INSTALL_DIR/timeturner $BIN_DIR/timeturner
echo "✅ Binary and assets installed to $INSTALL_DIR, and binary linked to $BIN_DIR."
echo "✅ Binary installed to $INSTALL_DIR and linked to $BIN_DIR."
# 4. Install systemd service file
# Only needed for Linux systems (e.g., Raspberry Pi OS)
if [[ "$(uname)" == "Linux" ]]; then
echo "⚙️ Installing systemd service for Linux..."
sudo cp timeturner.service /etc/systemd/system/
@ -257,13 +42,7 @@ echo ""
echo "--- Setup Complete ---"
echo "The TimeTurner daemon is now installed."
echo "The working directory is $INSTALL_DIR."
# Copy default config.yml from repo if it exists
if [ -f config.yml ]; then
sudo cp config.yml $INSTALL_DIR/
echo "Default 'config.yml' installed to $INSTALL_DIR."
else
echo "⚠️ No default 'config.yml' found in repository. Please add one if needed."
fi
echo "A default 'config.yml' will be created there on first run."
echo ""
if [[ "$(uname)" == "Linux" ]]; then
echo "To start the service, run:"

View file

@ -11,8 +11,6 @@ use std::sync::{Arc, Mutex};
use crate::config::{self, Config};
use crate::sync_logic::{self, LtcState};
use crate::system;
use num_rational::Ratio;
use num_traits::ToPrimitive;
// Data structure for the main status response
#[derive(Serialize, Deserialize)]
@ -21,7 +19,6 @@ struct ApiStatus {
ltc_timecode: String,
frame_rate: String,
system_clock: String,
system_date: String,
timecode_delta_ms: i64,
timecode_delta_frames: i64,
sync_status: String,
@ -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_timecode = state.latest.as_ref().map_or("".to_string(), |f| {
let sep = if f.is_drop_frame { ';' } else { ':' };
format!(
"{:02}:{:02}:{:02}{}{:02}",
f.hours, f.minutes, f.seconds, sep, f.frames
)
format!("{:02}:{:02}:{:02}:{:02}", f.hours, f.minutes, f.seconds, f.frames)
});
let frame_rate = state.latest.as_ref().map_or("".to_string(), |f| {
format!("{:.2}fps", f.frame_rate.to_f64().unwrap_or(0.0))
format!("{:.2}fps", f.frame_rate)
});
let now_local = Local::now();
@ -65,14 +58,12 @@ async fn get_status(data: web::Data<AppState>) -> impl Responder {
now_local.second(),
now_local.timestamp_subsec_millis(),
);
let system_date = now_local.format("%Y-%m-%d").to_string();
let avg_delta = state.get_ewma_clock_delta();
let mut delta_frames = 0;
if let Some(frame) = &state.latest {
let delta_ms_ratio = Ratio::new(avg_delta, 1);
let frames_ratio = delta_ms_ratio * frame.frame_rate / Ratio::new(1000, 1);
delta_frames = frames_ratio.round().to_integer();
let frame_ms = 1000.0 / frame.frame_rate;
delta_frames = ((avg_delta as f64 / frame_ms).round()) as i64;
}
let sync_status = sync_logic::get_sync_status(avg_delta, &config);
@ -92,7 +83,6 @@ async fn get_status(data: web::Data<AppState>) -> impl Responder {
ltc_timecode,
frame_rate,
system_clock,
system_date,
timecode_delta_ms: avg_delta,
timecode_delta_frames: delta_frames,
sync_status: sync_status.to_string(),
@ -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")]
async fn update_config(
data: web::Data<AppState>,
@ -218,7 +192,6 @@ pub async fn start_api_server(
.service(update_config)
.service(get_logs)
.service(nudge_clock)
.service(set_date)
// Serve frontend static files
.service(fs::Files::new("/", "static/").index_file("index.html"))
})
@ -246,8 +219,7 @@ mod tests {
minutes: 2,
seconds: 3,
frames: 4,
is_drop_frame: false,
frame_rate: Ratio::new(25, 1),
frame_rate: 25.0,
timestamp: Utc::now(),
}),
lock_count: 10,
@ -295,32 +267,6 @@ mod tests {
assert_eq!(resp.hardware_offset_ms, 10);
}
#[actix_web::test]
async fn test_get_status_drop_frame() {
let app_state = get_test_app_state();
// Set state to drop frame
app_state
.ltc_state
.lock()
.unwrap()
.latest
.as_mut()
.unwrap()
.is_drop_frame = true;
let app = test::init_service(
App::new()
.app_data(app_state.clone())
.service(get_status),
)
.await;
let req = test::TestRequest::get().uri("/api/status").to_request();
let resp: ApiStatus = test::call_and_read_body_json(&app, req).await;
assert_eq!(resp.ltc_timecode, "01:02:03;04");
}
#[actix_web::test]
async fn test_get_config() {
let app_state = get_test_app_state();

View file

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

View file

@ -15,7 +15,6 @@ use crate::sync_logic::LtcState;
use crate::ui::start_ui;
use clap::Parser;
use daemonize::Daemonize;
use serialport;
use std::{
fs,
@ -36,8 +35,6 @@ struct Args {
enum Command {
/// Run as a background daemon providing a web UI.
Daemon,
/// Stop the running daemon process.
Kill,
}
/// Default config content, embedded in the binary.
@ -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")]
async fn main() {
// This must be called before any logging statements.
let log_buffer = logger::setup_logger();
let args = Args::parse();
if let Some(command) = &args.command {
match command {
Command::Daemon => {
log::info!("🚀 Starting daemon...");
if let Some(Command::Daemon) = &args.command {
log::info!("🚀 Starting daemon...");
// Create files for stdout and stderr in the current directory
let stdout =
fs::File::create("daemon.out").expect("Could not create daemon.out");
let stderr =
fs::File::create("daemon.err").expect("Could not create daemon.err");
// Create files for stdout and stderr in the current directory
let stdout = fs::File::create("daemon.out").expect("Could not create daemon.out");
let stderr = fs::File::create("daemon.err").expect("Could not create daemon.err");
let daemonize = Daemonize::new()
.pid_file("ntp_timeturner.pid") // Create a PID file
.working_directory(".") // Keep the same working directory
.stdout(stdout)
.stderr(stderr);
let daemonize = Daemonize::new()
.pid_file("ntp_timeturner.pid") // Create a PID file
.working_directory(".") // Keep the same working directory
.stdout(stdout)
.stderr(stderr);
match daemonize.start() {
Ok(_) => { /* Process is now daemonized */ }
Err(e) => {
log::error!("Error daemonizing: {}", e);
return; // Exit if daemonization fails
}
}
}
Command::Kill => {
log::info!("🛑 Stopping daemon...");
let pid_file = "ntp_timeturner.pid";
match fs::read_to_string(pid_file) {
Ok(pid_str) => {
let pid_str = pid_str.trim();
log::info!("Found daemon with PID: {}", pid_str);
match std::process::Command::new("kill").arg("-9").arg(format!("-{}", pid_str)).status() {
Ok(status) => {
if status.success() {
log::info!("✅ Daemon stopped successfully.");
if fs::remove_file(pid_file).is_err() {
log::warn!("Could not remove PID file '{}'. It may need to be removed manually.", pid_file);
}
} else {
log::error!("'kill' command failed with status: {}. The daemon may not be running, or you may not have permission to stop it.", status);
log::warn!("Attempting to remove stale PID file '{}'...", pid_file);
if fs::remove_file(pid_file).is_ok() {
log::info!("Removed stale PID file.");
} else {
log::warn!("Could not remove PID file.");
}
}
}
Err(e) => {
log::error!("Failed to execute 'kill' command. Is 'kill' in your PATH? Error: {}", e);
}
}
}
Err(_) => {
log::error!("Could not read PID file '{}'. Is the daemon running in this directory?", pid_file);
}
}
return;
match daemonize.start() {
Ok(_) => { /* Process is now daemonized */ }
Err(e) => {
log::error!("Error daemonizing: {}", e);
return; // Exit if daemonization fails
}
}
}
@ -168,23 +110,13 @@ async fn main() {
// 3⃣ Shared state for UI and serial reader
let ltc_state = Arc::new(Mutex::new(LtcState::new()));
// 4⃣ Find serial port and spawn the serial reader thread
let serial_port_path = match find_serial_port() {
Some(port) => port,
None => {
log::error!("❌ No serial port found. Please connect the Teensy device.");
return;
}
};
log::info!("Found serial port: {}", serial_port_path);
// 4⃣ Spawn the serial reader thread
{
let tx_clone = tx.clone();
let state_clone = ltc_state.clone();
let port_clone = serial_port_path.clone();
thread::spawn(move || {
start_serial_thread(
&port_clone,
"/dev/ttyACM0",
115200,
tx_clone,
state_clone,
@ -193,24 +125,20 @@ async fn main() {
});
}
// 5⃣ Spawn UI or setup daemon logging. The web service is only started
// when running as a daemon. The TUI is for interactive foreground use.
// 5⃣ Spawn UI or setup daemon logging
if args.command.is_none() {
// --- Interactive TUI Mode ---
log::info!("🔧 Watching config.yml...");
log::info!("🚀 Serial thread launched");
log::info!("🖥️ UI thread launched");
log::info!("🖥️ UI thread launched");
let ui_state = ltc_state.clone();
let config_clone = config.clone();
let port = serial_port_path;
let port = "/dev/ttyACM0".to_string();
thread::spawn(move || {
start_ui(ui_state, port, config_clone);
});
} else {
// --- Daemon Mode ---
// In daemon mode, logging is already set up to go to stderr.
// The systemd service will capture it. The web service (API and static files)
// is launched later in the main async block.
// The systemd service will capture it.
log::info!("🚀 Starting TimeTurner daemon...");
}
@ -286,10 +214,7 @@ async fn main() {
let local = LocalSet::new();
local
.run_until(async move {
// 8⃣ Spawn the API server task.
// This server provides the JSON API and serves the static web UI files
// from the `static/` directory. It runs in both TUI and daemon modes,
// but is primarily for the web UI used in daemon mode.
// 8⃣ Spawn the API server thread
{
let api_state = ltc_state.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 re = Regex::new(
r"\[(LOCK|FREE)\]\s+(\d{2}):(\d{2}):(\d{2})([:;])(\d{2})\s+\|\s+([\d.]+)fps",
r"\[(LOCK|FREE)\]\s+(\d{2}):(\d{2}):(\d{2})[:;](\d{2})\s+\|\s+([\d.]+)fps",
)
.unwrap();
@ -60,12 +60,11 @@ mod tests {
use super::*;
use std::sync::mpsc;
use crate::sync_logic::LtcState;
use num_rational::Ratio;
use regex::Regex;
fn get_ltc_regex() -> Regex {
Regex::new(
r"\[(LOCK|FREE)\]\s+(\d{2}):(\d{2}):(\d{2})([:;])(\d{2})\s+\|\s+([\d.]+)fps",
r"\[(LOCK|FREE)\]\s+(\d{2}):(\d{2}):(\d{2})[:;](\d{2})\s+\|\s+([\d.]+)fps",
).unwrap()
}
@ -120,7 +119,7 @@ mod tests {
assert_eq!(st.free_count, 1);
let received_frame = rx.try_recv().unwrap();
assert_eq!(received_frame.status, "FREE");
assert_eq!(received_frame.frame_rate, Ratio::new(30000, 1001));
assert_eq!(received_frame.frame_rate, 29.97);
}
#[test]

View file

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

View file

@ -1,7 +1,6 @@
use crate::config::Config;
use crate::sync_logic::LtcFrame;
use chrono::{DateTime, Duration as ChronoDuration, Local, TimeZone};
use num_rational::Ratio;
use chrono::{DateTime, Duration as ChronoDuration, Local, NaiveTime, TimeZone};
use std::process::Command;
/// Check if Chrony is active
@ -40,24 +39,11 @@ pub fn ntp_service_toggle(start: bool) {
pub fn calculate_target_time(frame: &LtcFrame, config: &Config) -> DateTime<Local> {
let today_local = Local::now().date_naive();
let ms = ((frame.frames as f64 / frame.frame_rate) * 1000.0).round() as u32;
let timecode = NaiveTime::from_hms_milli_opt(frame.hours, frame.minutes, frame.seconds, ms)
.expect("Invalid LTC timecode");
// Total seconds from timecode components
let timecode_secs =
frame.hours as i64 * 3600 + frame.minutes as i64 * 60 + frame.seconds as i64;
// Timecode is always treated as wall-clock time. NDF scaling is not applied
// as the LTC source appears to be pre-compensated.
let total_duration_secs =
Ratio::new(timecode_secs, 1) + Ratio::new(frame.frames as i64, 1) / frame.frame_rate;
// Convert to milliseconds
let total_ms = (total_duration_secs * Ratio::new(1000, 1))
.round()
.to_integer();
let naive_midnight = today_local.and_hms_opt(0, 0, 0).unwrap();
let naive_dt = naive_midnight + ChronoDuration::milliseconds(total_ms);
let naive_dt = today_local.and_time(timecode);
let mut dt_local = Local
.from_local_datetime(&naive_dt)
.single()
@ -70,8 +56,7 @@ pub fn calculate_target_time(frame: &LtcFrame, config: &Config) -> DateTime<Loca
+ ChronoDuration::minutes(offset.minutes)
+ ChronoDuration::seconds(offset.seconds);
// 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 = frame_offset_ms_ratio.round().to_integer();
let frame_offset_ms = (offset.frames as f64 / frame.frame_rate * 1000.0).round() as i64;
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)]
mod tests {
use super::*;
use crate::config::TimeturnerOffset;
use chrono::{Timelike, Utc};
use num_rational::Ratio;
// Helper to create a test frame
fn get_test_frame(h: u32, m: u32, s: u32, f: u32) -> LtcFrame {
@ -189,8 +145,7 @@ mod tests {
minutes: m,
seconds: s,
frames: f,
is_drop_frame: false,
frame_rate: Ratio::new(25, 1),
frame_rate: 25.0,
timestamp: Utc::now(),
}
}

View file

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

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>
<meta charset="UTF-8">
<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="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>
<h1>NTP TimeTurner</h1>
<div class="grid">
<!-- LTC Status -->
<div class="card">
<h2>LTC Input</h2>
<h2>LTC Status</h2>
<p id="ltc-status">--</p>
<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>
<p id="frame-rate">-- fps</p>
<p>Lock Ratio: <span id="lock-ratio">--</span>%</p>
</div>
<!-- System Clock & Sync -->
<div class="card">
<h2>NTP Clock</h2>
<h2>System 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>
<p>NTP Service: <span id="ntp-active">--</span></p>
<p>Sync Status: <span id="sync-status">--</span></p>
</div>
<!-- Delta & Jitter -->
<div class="card">
<h2>Clock Offset</h2>
<p>Delta: <span id="delta-ms">--</span> ms (<span id="delta-frames">--</span> frames)</p>
<p>Jitter: <span id="jitter-status">--</span></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>
<h2>Network</h2>
<ul id="interfaces">
<li>--</li>
</ul>
</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 class="card full-width">
<h2>Controls</h2>
<div class="control-group">
<label for="hw-offset">Hardware Offset (ms):</label>
<input type="number" id="hw-offset" name="hw-offset">
</div>
<div class="collapsible-content" id="controls-content">
<div class="control-group" style="display: none;">
<label for="hw-offset">Hardware Offset (ms):</label>
<input type="number" id="hw-offset" name="hw-offset">
</div>
<div class="control-group" style="display: none;">
<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 class="offset-controls-container">
<div class="offset-control">
<input type="number" id="offset-h" min="-99" max="99">
<label for="offset-h">hr</label>
</div>
<div class="offset-control">
<input type="number" id="offset-m" min="-99" max="99">
<label for="offset-m">min</label>
</div>
<div class="offset-control">
<input type="number" id="offset-s" min="-99" max="99">
<label for="offset-s">sec</label>
</div>
<div class="offset-control">
<input type="number" id="offset-f" min="-99" max="99">
<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 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 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 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 -->
<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 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>
<!-- Logs -->
<div class="card full-width">
<h2>Logs</h2>
<pre id="logs" class="log-box"></pre>
</div>
</div>
</div>
<script src="icon-map.js"></script>
<script src="mock-data.js"></script>
<script src="script.js"></script>
</body>
</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', () => {
// --- Mock Data Configuration ---
// Set to true to use mock data, false for live API.
const useMockData = false;
let currentMockSetKey = 'allGood'; // Default mock data set
let lastApiData = null;
let lastApiFetchTime = null;
document.addEventListener('DOMContentLoaded', () => {
const statusElements = {
ltcStatus: document.getElementById('ltc-status'),
ltcTimecode: document.getElementById('ltc-timecode'),
frameRate: document.getElementById('frame-rate'),
lockRatio: document.getElementById('lock-ratio'),
systemClock: document.getElementById('system-clock'),
systemDate: document.getElementById('system-date'),
ntpActive: document.getElementById('ntp-active'),
syncStatus: document.getElementById('sync-status'),
deltaStatus: document.getElementById('delta-status'),
deltaMs: document.getElementById('delta-ms'),
deltaFrames: document.getElementById('delta-frames'),
jitterStatus: document.getElementById('jitter-status'),
deltaText: document.getElementById('delta-text'),
interfaces: document.getElementById('interfaces'),
logs: document.getElementById('logs'),
};
@ -41,211 +32,51 @@
const nudgeValueInput = document.getElementById('nudge-value');
const nudgeMessage = document.getElementById('nudge-message');
const dateInput = document.getElementById('date-input');
const setDateButton = document.getElementById('set-date');
const dateMessage = document.getElementById('date-message');
// --- Collapsible Sections ---
const controlsToggle = document.getElementById('controls-toggle');
const controlsContent = document.getElementById('controls-content');
const logsToggle = document.getElementById('logs-toggle');
const logsContent = document.getElementById('logs-content');
// --- Mock Controls Setup ---
const mockControls = document.getElementById('mock-controls');
const mockDataSelector = document.getElementById('mock-data-selector');
function setupMockControls() {
if (useMockData) {
mockControls.style.display = 'block';
// Populate dropdown
Object.keys(mockApiDataSets).forEach(key => {
const option = document.createElement('option');
option.value = key;
option.textContent = key.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase());
mockDataSelector.appendChild(option);
});
mockDataSelector.value = currentMockSetKey;
// Handle selection change
mockDataSelector.addEventListener('change', (event) => {
currentMockSetKey = event.target.value;
// Re-fetch all data from the new mock set
fetchStatus();
fetchConfig();
fetchLogs();
});
}
}
function updateStatus(data) {
const ltcStatus = data.ltc_status || 'UNKNOWN';
const ltcIconInfo = iconMap.ltcStatus[ltcStatus] || iconMap.ltcStatus.default;
statusElements.ltcStatus.innerHTML = `<img src="${ltcIconInfo.src}" class="status-icon" alt="" title="${ltcIconInfo.tooltip}">`;
statusElements.ltcStatus.className = ltcStatus.toLowerCase();
statusElements.ltcStatus.textContent = data.ltc_status;
statusElements.ltcTimecode.textContent = data.ltc_timecode;
const frameRate = data.frame_rate || 'unknown';
const frameRateIconInfo = iconMap.frameRate[frameRate] || iconMap.frameRate.default;
statusElements.frameRate.innerHTML = `<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.frameRate.textContent = data.frame_rate;
statusElements.lockRatio.textContent = data.lock_ratio.toFixed(2);
statusElements.systemClock.textContent = data.system_clock;
statusElements.systemDate.textContent = data.system_date;
statusElements.ntpActive.textContent = data.ntp_active ? 'Active' : 'Inactive';
statusElements.ntpActive.className = data.ntp_active ? 'active' : 'inactive';
// Autofill the date input, but don't overwrite user edits.
if (!lastApiData || dateInput.value === lastApiData.system_date) {
dateInput.value = data.system_date;
}
statusElements.syncStatus.textContent = data.sync_status;
statusElements.syncStatus.className = data.sync_status.replace(/\s+/g, '-').toLowerCase();
const ntpIconInfo = iconMap.ntpActive[!!data.ntp_active];
if (data.ntp_active) {
statusElements.ntpActive.innerHTML = `<img src="${ntpIconInfo.src}" class="status-icon" alt="" title="${ntpIconInfo.tooltip}">`;
statusElements.ntpActive.className = 'active';
} else {
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.deltaMs.textContent = data.timecode_delta_ms;
statusElements.deltaFrames.textContent = data.timecode_delta_frames;
statusElements.jitterStatus.textContent = data.jitter_status;
statusElements.jitterStatus.className = data.jitter_status.toLowerCase();
statusElements.interfaces.innerHTML = '';
if (data.interfaces.length > 0) {
statusElements.interfaces.textContent = data.interfaces.join(' | ');
data.interfaces.forEach(ip => {
const li = document.createElement('li');
li.textContent = ip;
statusElements.interfaces.appendChild(li);
});
} else {
statusElements.interfaces.textContent = 'No active interfaces found.';
}
}
function animateClocks() {
if (!lastApiData || !lastApiFetchTime) return;
const elapsedMs = new Date() - lastApiFetchTime;
// Animate System Clock
if (lastApiData.system_clock && lastApiData.system_clock.includes(':')) {
const parts = lastApiData.system_clock.split(/[:.]/);
if (parts.length === 4) {
const baseDate = new Date();
baseDate.setHours(parseInt(parts[0], 10), parseInt(parts[1], 10), parseInt(parts[2], 10));
baseDate.setMilliseconds(parseInt(parts[3], 10));
const newDate = new Date(baseDate.getTime() + elapsedMs);
const h = String(newDate.getHours()).padStart(2, '0');
const m = String(newDate.getMinutes()).padStart(2, '0');
const s = String(newDate.getSeconds()).padStart(2, '0');
const ms = String(newDate.getMilliseconds()).padStart(3, '0');
statusElements.systemClock.textContent = `${h}:${m}:${s}.${ms}`;
}
}
// Animate LTC Timecode - only if status is LOCK
if (lastApiData.ltc_status === 'LOCK' && lastApiData.ltc_timecode && lastApiData.ltc_timecode.match(/[:;]/) && lastApiData.frame_rate) {
const separator = lastApiData.ltc_timecode.includes(';') ? ';' : ':';
const tcParts = lastApiData.ltc_timecode.split(/[:;]/);
const frameRate = parseFloat(lastApiData.frame_rate);
if (tcParts.length === 4 && !isNaN(frameRate) && frameRate > 0) {
let h = parseInt(tcParts[0], 10);
let m = parseInt(tcParts[1], 10);
let s = parseInt(tcParts[2], 10);
let f = parseInt(tcParts[3], 10);
const msPerFrame = 1000.0 / frameRate;
const elapsedFrames = Math.floor(elapsedMs / msPerFrame);
f += elapsedFrames;
const frameRateInt = Math.round(frameRate);
s += Math.floor(f / frameRateInt);
f %= frameRateInt;
m += Math.floor(s / 60);
s %= 60;
h += Math.floor(m / 60);
m %= 60;
h %= 24;
statusElements.ltcTimecode.textContent =
`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}${separator}${String(f).padStart(2, '0')}`;
}
const li = document.createElement('li');
li.textContent = 'No active interfaces found.';
statusElements.interfaces.appendChild(li);
}
}
async function fetchStatus() {
if (useMockData) {
const data = mockApiDataSets[currentMockSetKey].status;
updateStatus(data);
lastApiData = data;
lastApiFetchTime = new Date();
return;
}
try {
const response = await fetch('/api/status');
if (!response.ok) throw new Error('Failed to fetch status');
const data = await response.json();
updateStatus(data);
lastApiData = data;
lastApiFetchTime = new Date();
} catch (error) {
console.error('Error fetching status:', error);
lastApiData = null;
lastApiFetchTime = null;
}
}
async function fetchConfig() {
if (useMockData) {
const data = mockApiDataSets[currentMockSetKey].config;
hwOffsetInput.value = data.hardwareOffsetMs;
autoSyncCheckbox.checked = data.autoSyncEnabled;
offsetInputs.h.value = data.timeturnerOffset.hours;
offsetInputs.m.value = data.timeturnerOffset.minutes;
offsetInputs.s.value = data.timeturnerOffset.seconds;
offsetInputs.f.value = data.timeturnerOffset.frames;
offsetInputs.ms.value = data.timeturnerOffset.milliseconds || 0;
nudgeValueInput.value = data.defaultNudgeMs;
return;
}
try {
const response = await fetch('/api/config');
if (!response.ok) throw new Error('Failed to fetch config');
@ -269,22 +100,14 @@
autoSyncEnabled: autoSyncCheckbox.checked,
defaultNudgeMs: parseInt(nudgeValueInput.value, 10) || 0,
timeturnerOffset: {
hours: parseInt(offsetInputs.h.value, 10) || 0,
hours: parseInt(offsetInputs.h.value, 10) || 0,
minutes: parseInt(offsetInputs.m.value, 10) || 0,
seconds: parseInt(offsetInputs.s.value, 10) || 0,
frames: parseInt(offsetInputs.f.value, 10) || 0,
frames: parseInt(offsetInputs.f.value, 10) || 0,
milliseconds: parseInt(offsetInputs.ms.value, 10) || 0,
}
};
if (useMockData) {
console.log('Mock save:', config);
alert('Configuration saved (mock).');
// We can also update the mock data in memory to see changes reflected
mockApiDataSets[currentMockSetKey].config = config;
return;
}
try {
const response = await fetch('/api/config', {
method: 'POST',
@ -300,21 +123,13 @@
}
async function fetchLogs() {
if (useMockData) {
// Use a copy to avoid mutating the original mock data array
const logs = mockApiDataSets[currentMockSetKey].logs.slice();
// Show latest 20 logs, with the newest at the top.
logs.reverse();
statusElements.logs.textContent = logs.slice(0, 20).join('\n');
return;
}
try {
const response = await fetch('/api/logs');
if (!response.ok) throw new Error('Failed to fetch logs');
const logs = await response.json();
// Show latest 20 logs, with the newest at the top.
logs.reverse();
statusElements.logs.textContent = logs.slice(0, 20).join('\n');
statusElements.logs.textContent = logs.join('\n');
// Auto-scroll to the bottom
statusElements.logs.scrollTop = statusElements.logs.scrollHeight;
} catch (error) {
console.error('Error fetching logs:', error);
statusElements.logs.textContent = 'Error fetching logs.';
@ -323,11 +138,6 @@
async function triggerManualSync() {
syncMessage.textContent = 'Issuing sync command...';
if (useMockData) {
syncMessage.textContent = 'Success: Manual sync triggered (mock).';
setTimeout(() => { syncMessage.textContent = ''; }, 5000);
return;
}
try {
const response = await fetch('/api/sync', { method: 'POST' });
const data = await response.json();
@ -345,11 +155,6 @@
async function nudgeClock(ms) {
nudgeMessage.textContent = 'Nudging clock...';
if (useMockData) {
nudgeMessage.textContent = `Success: Clock nudged by ${ms}ms (mock).`;
setTimeout(() => { nudgeMessage.textContent = ''; }, 3000);
return;
}
try {
const response = await fetch('/api/nudge_clock', {
method: 'POST',
@ -369,42 +174,6 @@
setTimeout(() => { nudgeMessage.textContent = ''; }, 3000);
}
async function setDate() {
const date = dateInput.value;
if (!date) {
alert('Please select a date.');
return;
}
dateMessage.textContent = 'Setting date...';
if (useMockData) {
mockApiDataSets[currentMockSetKey].status.system_date = date;
dateMessage.textContent = `Success: Date set to ${date} (mock).`;
fetchStatus(); // re-render
setTimeout(() => { dateMessage.textContent = ''; }, 5000);
return;
}
try {
const response = await fetch('/api/set_date', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ date: date }),
});
const data = await response.json();
if (response.ok) {
dateMessage.textContent = `Success: ${data.message}`;
// Fetch status again to update the displayed date immediately
fetchStatus();
} else {
dateMessage.textContent = `Error: ${data.message}`;
}
} catch (error) {
console.error('Error setting date:', error);
dateMessage.textContent = 'Failed to send date command.';
}
setTimeout(() => { dateMessage.textContent = ''; }, 5000);
}
saveConfigButton.addEventListener('click', saveConfig);
manualSyncButton.addEventListener('click', triggerManualSync);
nudgeDownButton.addEventListener('click', () => {
@ -415,29 +184,13 @@
const ms = parseInt(nudgeValueInput.value, 10) || 0;
nudgeClock(ms);
});
setDateButton.addEventListener('click', setDate);
// --- Collapsible Section Listeners ---
controlsToggle.addEventListener('click', () => {
const isActive = controlsContent.classList.toggle('active');
controlsToggle.classList.toggle('active', isActive);
});
logsToggle.addEventListener('click', () => {
const isActive = logsContent.classList.toggle('active');
logsToggle.classList.toggle('active', isActive);
});
// Initial data load
setupMockControls();
fetchStatus();
fetchConfig();
fetchLogs();
// Refresh data every 2 seconds if not using mock data
if (!useMockData) {
setInterval(fetchStatus, 2000);
setInterval(fetchLogs, 2000);
}
setInterval(animateClocks, 50); // High-frequency clock animation
// Refresh data every 2 seconds
setInterval(fetchStatus, 2000);
setInterval(fetchLogs, 2000);
});

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 {
font-family: 'FuturaStdHeavy', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background-color: #221f1f;
background-image: url('assets/HaveBlueTransWh.png');
background-repeat: no-repeat;
background-position: bottom 20px right 20px;
background-attachment: fixed;
background-size: 100px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background-color: #f4f4f9;
color: #333;
margin: 0;
padding: 20px;
@ -28,20 +13,19 @@ body {
max-width: 960px;
}
.header-logo {
display: block;
margin: 0 auto 20px auto;
max-width: 60%;
h1 {
text-align: center;
color: #444;
}
.grid {
display: grid;
grid-template-columns: 1fr;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
}
.card {
background: #c5ced6;
background: #fff;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
@ -49,32 +33,16 @@ body {
.card h2 {
margin-top: 0;
color: #1a7db6;
}
#ltc-timecode, #system-clock {
font-family: 'Quartz', monospace;
font-size: 2em;
text-align: center;
letter-spacing: 2px;
color: #0056b3;
}
.card p, .card ul {
margin: 10px 0;
}
.system-date-display {
text-align: center;
font-size: 1.5em;
font-family: 'Quartz', monospace;
letter-spacing: 2px;
}
#interfaces {
text-align: center;
white-space: nowrap;
overflow-x: auto;
padding-bottom: 5px; /* Add some space for the scrollbar if it appears */
.card ul {
padding-left: 20px;
list-style: none;
}
.full-width {
@ -88,75 +56,25 @@ body {
gap: 10px;
}
input[type="number"],
input[type="text"] {
padding: 8px;
border: 1px solid #9fb3c8;
border-radius: 4px;
background-color: #f0f4f8;
font-family: inherit;
font-size: 14px;
color: #333;
transition: border-color 0.2s, box-shadow 0.2s;
}
input[type="number"]:focus,
input[type="text"]:focus {
outline: none;
border-color: #1a7db6;
box-shadow: 0 0 0 2px rgba(26, 125, 182, 0.2);
}
input[type="number"] {
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
width: 80px;
}
input[type="text"] {
width: auto;
}
button {
padding: 8px 15px;
border: none;
border-radius: 4px;
background-color: #1a7db6;
background-color: #007bff;
color: white;
cursor: pointer;
font-size: 14px;
font-family: Arial, sans-serif;
font-weight: bold;
transition: background-color 0.2s;
}
button:hover {
background-color: #166999;
}
.offset-controls-container {
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
align-items: center;
}
.offset-control {
display: flex;
align-items: center;
gap: 5px;
}
.offset-control input[type="number"] {
width: 40px;
text-align: center;
}
.offset-control label {
font-size: 14px;
color: #333;
}
#offset-ms {
width: 60px;
background-color: #0056b3;
}
#sync-message {
@ -164,103 +82,6 @@ button:hover {
color: #555;
}
.icon-group {
display: flex;
justify-content: center;
align-items: center;
gap: 20px;
margin: 10px 0;
}
#delta-text {
text-align: center;
}
#ltc-status, #ntp-active, #sync-status, #jitter-status, #frame-rate, #lock-ratio, #delta-status {
display: flex;
justify-content: center;
align-items: center;
}
.status-icon {
width: 60px;
height: 60px;
}
.collapsible-card {
padding: 0;
}
.collapsible-card .toggle-header {
display: flex;
align-items: center;
gap: 15px;
padding: 20px;
cursor: pointer;
border-radius: 8px;
}
.collapsible-card .toggle-header.active {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
border-bottom: 1px solid #eee;
}
.collapsible-card .toggle-header:hover {
background-color: #e9e9f3;
}
.toggle-icon {
width: 40px;
height: 40px;
}
.header-icon {
width: 40px;
height: 40px;
}
.card-header {
display: flex;
align-items: center;
gap: 15px;
}
.log-box {
white-space: pre-wrap;
overflow-wrap: break-word;
}
.collapsible-content {
display: none;
padding: 20px;
}
.collapsible-content.active {
display: block;
}
footer {
text-align: center;
margin-top: 40px;
padding-top: 20px;
border-top: 1px solid #444;
color: #c5ced6;
}
footer p {
margin: 0;
}
footer a {
color: #1a7db6;
text-decoration: none;
}
footer a:hover {
text-decoration: underline;
}
/* Status-specific colors */
#sync-status.in-sync, #jitter-status.good { font-weight: bold; color: #28a745; }
#sync-status.clock-ahead, #sync-status.clock-behind, #jitter-status.average { font-weight: bold; color: #ffc107; }
@ -268,6 +89,3 @@ footer a:hover {
#jitter-status.bad { font-weight: bold; color: #dc3545; }
#ntp-active.active { font-weight: bold; color: #28a745; }
#ntp-active.inactive { font-weight: bold; color: #dc3545; }
#ltc-status.lock { font-weight: bold; color: #28a745; }
#ltc-status.free { font-weight: bold; color: #ffc107; }

View file

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

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"