Compare commits
No commits in common. "main" and "v0.1.0_RC1" have entirely different histories.
main
...
v0.1.0_RC1
|
|
@ -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
|
|
@ -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 stratum 1
|
||||
# Serve the system clock as a reference at stratum 10
|
||||
server 127.127.1.0
|
||||
allow 127.0.0.0/8
|
||||
local stratum 1
|
||||
local stratum 10
|
||||
|
||||
Add to bottom:
|
||||
# Allow LAN clients
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
10
config.yml
|
|
@ -12,8 +12,8 @@ defaultNudgeMs: 2
|
|||
# Time-turning offsets. All values are added to the incoming LTC time.
|
||||
# These can be positive or negative.
|
||||
timeturnerOffset:
|
||||
hours: 0
|
||||
minutes: 0
|
||||
seconds: 0
|
||||
frames: 0
|
||||
milliseconds: 0
|
||||
hours: 1
|
||||
minutes: 2
|
||||
seconds: 3
|
||||
frames: 4
|
||||
milliseconds: 5
|
||||
|
|
|
|||
134
docs/api.md
|
|
@ -4,25 +4,19 @@ This document describes the HTTP API for the NTP Timeturner application.
|
|||
|
||||
## Endpoints
|
||||
|
||||
### Status and Logs
|
||||
### Status
|
||||
|
||||
- **`GET /api/status`**
|
||||
|
||||
Retrieves the real-time status of the LTC reader and system clock synchronization. The `ltc_timecode` field uses `:` as a separator for non-drop-frame timecode, and `;` for drop-frame timecode between seconds and frames (e.g., `10:20:30;00`).
|
||||
|
||||
**Possible values for status fields:**
|
||||
- `ltc_status`: `"LOCK"`, `"FREE"`, or `"(waiting)"`
|
||||
- `sync_status`: `"IN SYNC"`, `"CLOCK AHEAD"`, `"CLOCK BEHIND"`, `"TIMETURNING"`
|
||||
- `jitter_status`: `"GOOD"`, `"AVERAGE"`, `"BAD"`
|
||||
Retrieves the real-time status of the LTC reader and system clock synchronization.
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"ltc_status": "LOCK",
|
||||
"ltc_timecode": "10:20:30;00",
|
||||
"ltc_timecode": "10:20:30:00",
|
||||
"frame_rate": "25.00fps",
|
||||
"system_clock": "10:20:30.005",
|
||||
"system_date": "2025-07-30",
|
||||
"timecode_delta_ms": 5,
|
||||
"timecode_delta_frames": 0,
|
||||
"sync_status": "IN SYNC",
|
||||
|
|
@ -30,23 +24,11 @@ This document describes the HTTP API for the NTP Timeturner application.
|
|||
"lock_ratio": 99.5,
|
||||
"ntp_active": true,
|
||||
"interfaces": ["192.168.1.100"],
|
||||
"hardware_offset_ms": 20
|
||||
"hardware_offset_ms": 0
|
||||
}
|
||||
```
|
||||
|
||||
- **`GET /api/logs`**
|
||||
|
||||
Retrieves the last 100 log entries from the application.
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
[
|
||||
"2025-08-07 10:00:00 [INFO] Starting TimeTurner daemon...",
|
||||
"2025-08-07 10:00:01 [INFO] Found serial port: /dev/ttyACM0"
|
||||
]
|
||||
```
|
||||
|
||||
### System Clock Control
|
||||
### Sync
|
||||
|
||||
- **`POST /api/sync`**
|
||||
|
||||
|
|
@ -54,7 +36,7 @@ This document describes the HTTP API for the NTP Timeturner application.
|
|||
|
||||
**Request Body:** None
|
||||
|
||||
**Success Response (200 OK):**
|
||||
**Success Response:**
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
|
|
@ -62,14 +44,13 @@ This document describes the HTTP API for the NTP Timeturner application.
|
|||
}
|
||||
```
|
||||
|
||||
**Error Response (400 Bad Request):**
|
||||
**Error Responses:**
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "No LTC timecode available to sync to."
|
||||
}
|
||||
```
|
||||
**Error Response (500 Internal Server Error):**
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
|
|
@ -77,120 +58,33 @@ This document describes the HTTP API for the NTP Timeturner application.
|
|||
}
|
||||
```
|
||||
|
||||
- **`POST /api/nudge_clock`**
|
||||
|
||||
Nudges the system clock by a specified number of microseconds. This requires `sudo` privileges to run `adjtimex`.
|
||||
|
||||
**Example Request:**
|
||||
```json
|
||||
{
|
||||
"microseconds": -2000
|
||||
}
|
||||
```
|
||||
**Success Response (200 OK):**
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Clock nudge command issued."
|
||||
}
|
||||
```
|
||||
**Error Response (500 Internal Server Error):**
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Clock nudge command failed."
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
- **`POST /api/set_date`**
|
||||
|
||||
Sets the system date. This is useful as LTC does not contain date information. Requires `sudo` privileges.
|
||||
|
||||
**Example Request:**
|
||||
```json
|
||||
{
|
||||
"date": "2025-07-30"
|
||||
}
|
||||
```
|
||||
|
||||
**Success Response (200 OK):**
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Date update command issued."
|
||||
}
|
||||
```
|
||||
|
||||
**Error Response (500 Internal Server Error):**
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Date update command failed."
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
- **`GET /api/config`**
|
||||
|
||||
Retrieves the current application configuration from `config.yml`.
|
||||
Retrieves the current application configuration.
|
||||
|
||||
**Example Response (200 OK):**
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"hardwareOffsetMs": 20,
|
||||
"timeturnerOffset": {
|
||||
"hours": 0,
|
||||
"minutes": 0,
|
||||
"seconds": 0,
|
||||
"frames": 0,
|
||||
"milliseconds": 0
|
||||
},
|
||||
"defaultNudgeMs": 2,
|
||||
"autoSyncEnabled": false
|
||||
"hardware_offset_ms": 0
|
||||
}
|
||||
```
|
||||
|
||||
- **`POST /api/config`**
|
||||
|
||||
Updates the application configuration. The new configuration is persisted to `config.yml` and takes effect immediately.
|
||||
Updates the `hardware_offset_ms` configuration. The new value is persisted to `config.json` and reloaded by the application automatically.
|
||||
|
||||
**Example Request:**
|
||||
```json
|
||||
{
|
||||
"hardwareOffsetMs": 55,
|
||||
"timeturnerOffset": {
|
||||
"hours": 1,
|
||||
"minutes": 2,
|
||||
"seconds": 3,
|
||||
"frames": 4,
|
||||
"milliseconds": 5
|
||||
},
|
||||
"defaultNudgeMs": 2,
|
||||
"autoSyncEnabled": true
|
||||
"hardware_offset_ms": 10
|
||||
}
|
||||
```
|
||||
|
||||
**Success Response (200 OK):** (Returns the updated configuration)
|
||||
**Success Response:**
|
||||
```json
|
||||
{
|
||||
"hardwareOffsetMs": 55,
|
||||
"timeturnerOffset": {
|
||||
"hours": 1,
|
||||
"minutes": 2,
|
||||
"seconds": 3,
|
||||
"frames": 4,
|
||||
"milliseconds": 5
|
||||
},
|
||||
"defaultNudgeMs": 2,
|
||||
"autoSyncEnabled": true
|
||||
}
|
||||
```
|
||||
**Error Response (500 Internal Server Error):**
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Failed to write config.yml"
|
||||
"hardware_offset_ms": 10
|
||||
}
|
||||
```
|
||||
|
|
|
|||
241
setup.sh
|
|
@ -3,226 +3,14 @@ set -e
|
|||
|
||||
echo "--- TimeTurner Setup ---"
|
||||
|
||||
# Check if TimeTurner is already installed.
|
||||
INSTALL_DIR="/opt/timeturner"
|
||||
if [ -f "${INSTALL_DIR}/timeturner" ]; then
|
||||
echo "✅ TimeTurner is already installed."
|
||||
# Ask the user what to do
|
||||
read -p "Do you want to (U)pdate, (R)einstall, or (A)bort? [U/r/a] " choice
|
||||
case "$choice" in
|
||||
r|R )
|
||||
echo "Proceeding with full re-installation..."
|
||||
# Stop the service to allow overwriting the binary, ignore errors if not running
|
||||
echo "Stopping existing TimeTurner service..."
|
||||
sudo systemctl stop timeturner.service || true
|
||||
# The script will continue to the installation steps below.
|
||||
;;
|
||||
a|A )
|
||||
echo "Aborting setup."
|
||||
exit 0
|
||||
;;
|
||||
* ) # Default to Update
|
||||
echo "Attempting to run the update script..."
|
||||
# Ensure we are in a git repository and the update script exists
|
||||
if [ -d ".git" ] && [ -f "update.sh" ]; then
|
||||
chmod +x update.sh
|
||||
./update.sh
|
||||
# Exit cleanly after the update
|
||||
exit 0
|
||||
else
|
||||
echo "⚠️ Could not find 'update.sh' or not in a git repository."
|
||||
echo "Please re-clone the repository to get the update script, or remove the existing installation to run setup again:"
|
||||
echo " sudo rm -rf ${INSTALL_DIR}"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
|
||||
# Determine package manager
|
||||
PKG_MANAGER=""
|
||||
if command -v apt &> /dev/null; then
|
||||
PKG_MANAGER="apt"
|
||||
elif command -v dnf &> /dev/null; then
|
||||
PKG_MANAGER="dnf"
|
||||
elif command -v pacman &> /dev/null; then
|
||||
PKG_MANAGER="pacman"
|
||||
else
|
||||
echo "Error: No supported package manager (apt, dnf, pacman) found. Please install dependencies manually."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Detected package manager: $PKG_MANAGER"
|
||||
|
||||
# --- Update System Packages ---
|
||||
echo "Updating system packages..."
|
||||
if [ "$PKG_MANAGER" == "apt" ]; then
|
||||
sudo apt update
|
||||
sudo DEBIAN_FRONTEND=noninteractive apt upgrade -y -o Dpkg::Options::="--force-confold"
|
||||
elif [ "$PKG_MANAGER" == "dnf" ]; then
|
||||
sudo dnf upgrade -y
|
||||
elif [ "$PKG_MANAGER" == "pacman" ]; then
|
||||
sudo pacman -Syu --noconfirm
|
||||
fi
|
||||
echo "System packages updated."
|
||||
|
||||
# --- Install Rust/Cargo if not installed ---
|
||||
if ! command -v cargo &> /dev/null; then
|
||||
echo "Rust/Cargo not found. Installing Rustup..."
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||
# Source cargo's env for the current shell session
|
||||
# This is for the current script's execution path, typically rustup adds to .bashrc/.profile for future sessions.
|
||||
# We need it now, but for non-interactive script, sourcing won't affect parent shell.
|
||||
# However, cargo build below will rely on it being in PATH. rustup makes sure of this if it installs.
|
||||
# For safety, ensure PATH is updated.
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
echo "Rust/Cargo installed successfully."
|
||||
else
|
||||
echo "Rust/Cargo is already installed."
|
||||
fi
|
||||
|
||||
# --- Install common build dependencies for Rust ---
|
||||
echo "Installing common build dependencies..."
|
||||
if [ "$PKG_MANAGER" == "apt" ]; then
|
||||
sudo apt update
|
||||
sudo apt install -y build-essential libudev-dev pkg-config curl wget
|
||||
elif [ "$PKG_MANAGER" == "dnf" ]; then
|
||||
sudo dnf install -y gcc make perl-devel libudev-devel pkg-config curl wget
|
||||
elif [ "$PKG_MANAGER" == "pacman" ]; then
|
||||
sudo pacman -Sy --noconfirm base-devel libudev pkg-config curl
|
||||
fi
|
||||
echo "Common build dependencies installed."
|
||||
|
||||
# --- Install Python dependencies for testing ---
|
||||
echo "🐍 Installing Python dependencies for test scripts..."
|
||||
if [ "$PKG_MANAGER" == "apt" ]; then
|
||||
# We no longer need hotspot dependencies
|
||||
sudo apt install -y python3 python3-pip python3-serial
|
||||
elif [ "$PKG_MANAGER" == "dnf" ]; then
|
||||
# python3-pyserial is the name for pyserial in dnf
|
||||
sudo dnf install -y python3 python3-pip python3-pyserial
|
||||
elif [ "$PKG_MANAGER" == "pacman" ]; then
|
||||
# python-pyserial is the name for pyserial in pacman
|
||||
sudo pacman -Sy --noconfirm python python-pip python-pyserial
|
||||
fi
|
||||
# sudo pip3 install pyserial # This is replaced by the native package manager installs above
|
||||
echo "✅ Python dependencies installed."
|
||||
|
||||
# --- Apply custom splash screen ---
|
||||
if [[ "$(uname)" == "Linux" ]]; then
|
||||
echo "🖼️ Applying custom splash screen..."
|
||||
SPLASH_URL="https://raw.githubusercontent.com/cjfranko/NTP-Timeturner/refs/heads/main/splash.png"
|
||||
PLYMOUTH_THEME_DIR="/usr/share/plymouth/themes/pix"
|
||||
PLYMOUTH_IMAGE_PATH="${PLYMOUTH_THEME_DIR}/splash.png"
|
||||
|
||||
sudo mkdir -p "${PLYMOUTH_THEME_DIR}"
|
||||
echo "Downloading splash image from ${SPLASH_URL}..."
|
||||
sudo curl -L "${SPLASH_URL}" -o "${PLYMOUTH_IMAGE_PATH}"
|
||||
|
||||
if [ -f "${PLYMOUTH_IMAGE_PATH}" ]; then
|
||||
echo "Splash image downloaded. Updating Plymouth configuration..."
|
||||
# Set 'pix' as the default plymouth theme if not already.
|
||||
# This is a common theme that expects splash.png.
|
||||
sudo update-alternatives --install /usr/share/plymouth/themes/default.plymouth default.plymouth "${PLYMOUTH_THEME_DIR}/pix.plymouth" 100 || true
|
||||
# Ensure the pix theme exists and is linked
|
||||
if [ ! -f "${PLYMOUTH_THEME_DIR}/pix.plymouth" ]; then
|
||||
echo "Creating dummy pix.plymouth for update-initramfs"
|
||||
echo "[Plymouth Theme]" | sudo tee "${PLYMOUTH_THEME_DIR}/pix.plymouth" > /dev/null
|
||||
echo "Name=Pi Splash" | sudo tee -a "${PLYMOUTH_THEME_DIR}/pix.plymouth" > /dev/null
|
||||
echo "Description=TimeTurner Raspberry Pi Splash Screen" | sudo tee -a "${PLYMOUTH_THEME_DIR}/pix.plymouth" > /dev/null
|
||||
echo "SpriteAnimation=/splash.png" | sudo tee -a "${PLYMOUTH_THEME_DIR}/pix.plymouth" > /dev/null
|
||||
fi
|
||||
|
||||
# Update the initial RAM filesystem to include the new splash screen
|
||||
sudo update-initramfs -u
|
||||
echo "✅ Custom splash screen applied. Reboot may be required to see changes."
|
||||
else
|
||||
echo "❌ Failed to download splash image from ${SPLASH_URL}."
|
||||
fi
|
||||
else
|
||||
echo "⚠️ Skipping splash screen configuration on non-Linux OS."
|
||||
fi
|
||||
|
||||
# --- Remove NTPD and install Chrony, NMTUI, Adjtimex ---
|
||||
echo "Removing NTPD (if installed) and installing Chrony, NMTUI, Adjtimex..."
|
||||
|
||||
# --- Remove NTPD and install Chrony, NMTUI, Adjtimex ---
|
||||
echo "Removing NTPD (if installed) and installing Chrony, NMTUI, Adjtimex..."
|
||||
|
||||
if [ "$PKG_MANAGER" == "apt" ]; then
|
||||
sudo apt update
|
||||
sudo apt remove -y ntp || true # Remove ntp if it exists, ignore if not
|
||||
sudo apt install -y chrony network-manager adjtimex
|
||||
sudo systemctl enable chrony --now
|
||||
elif [ "$PKG_MANAGER" == "dnf" ]; then
|
||||
sudo dnf remove -y ntp
|
||||
sudo dnf install -y chrony NetworkManager-tui adjtimex
|
||||
sudo systemctl enable chronyd --now
|
||||
elif [ "$PKG_MANAGER" == "pacman" ]; then
|
||||
sudo pacman -Sy --noconfirm ntp || true
|
||||
sudo pacman -R --noconfirm ntp || true # Ensure ntp is removed
|
||||
sudo pacman -Sy --noconfirm chrony networkmanager adjtimex
|
||||
sudo systemctl enable chronyd --now
|
||||
sudo systemctl enable NetworkManager --now # nmtui relies on NetworkManager
|
||||
fi
|
||||
|
||||
echo "NTPD removed (if present). Chrony, NMTUI, and Adjtimex installed and configured."
|
||||
|
||||
# --- Configure Chrony to act as a local NTP server ---
|
||||
echo "⚙️ Configuring Chrony to serve local time..."
|
||||
# The path to chrony.conf can vary
|
||||
if [ -f /etc/chrony/chrony.conf ]; then
|
||||
CHRONY_CONF="/etc/chrony/chrony.conf"
|
||||
elif [ -f /etc/chrony.conf ]; then
|
||||
CHRONY_CONF="/etc/chrony.conf"
|
||||
else
|
||||
CHRONY_CONF=""
|
||||
fi
|
||||
|
||||
if [ -n "$CHRONY_CONF" ]; then
|
||||
# Comment out any existing pool, server, or sourcedir lines to prevent syncing with external sources
|
||||
echo "Disabling external NTP sources..."
|
||||
sudo sed -i -E 's/^(pool|server|sourcedir)/#&/' "$CHRONY_CONF"
|
||||
|
||||
# Add settings to the top of the file to serve local clock
|
||||
# Using a temp file to prepend is safer than multiple sed calls
|
||||
TEMP_CONF=$(mktemp)
|
||||
cat <<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:"
|
||||
|
|
|
|||
64
src/api.rs
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -64,7 +64,6 @@ impl Config {
|
|||
Self::default()
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
|
|
|
|||
119
src/main.rs
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
11
src/ui.rs
|
|
@ -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(),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 3 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 898 B |
|
Before Width: | Height: | Size: 981 B |
|
Before Width: | Height: | Size: 955 B |
|
Before Width: | Height: | Size: 913 B |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 810 B |
|
Before Width: | Height: | Size: 805 B |
|
Before Width: | Height: | Size: 804 B |
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 198 KiB |
|
|
@ -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%.' }
|
||||
}
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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.' ]
|
||||
}
|
||||
};
|
||||
313
static/script.js
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
214
static/style.css
|
|
@ -1,21 +1,6 @@
|
|||
@font-face {
|
||||
font-family: 'FuturaStdHeavy';
|
||||
src: url('assets/FuturaStdHeavy.otf') format('opentype');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Quartz';
|
||||
src: url('assets/quartz-ms-regular.ttf') format('truetype');
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'FuturaStdHeavy', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
background-color: #221f1f;
|
||||
background-image: url('assets/HaveBlueTransWh.png');
|
||||
background-repeat: no-repeat;
|
||||
background-position: bottom 20px right 20px;
|
||||
background-attachment: fixed;
|
||||
background-size: 100px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
background-color: #f4f4f9;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
|
|
@ -28,20 +13,19 @@ body {
|
|||
max-width: 960px;
|
||||
}
|
||||
|
||||
.header-logo {
|
||||
display: block;
|
||||
margin: 0 auto 20px auto;
|
||||
max-width: 60%;
|
||||
h1 {
|
||||
text-align: center;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #c5ced6;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
|
|
@ -49,32 +33,16 @@ body {
|
|||
|
||||
.card h2 {
|
||||
margin-top: 0;
|
||||
color: #1a7db6;
|
||||
}
|
||||
|
||||
#ltc-timecode, #system-clock {
|
||||
font-family: 'Quartz', monospace;
|
||||
font-size: 2em;
|
||||
text-align: center;
|
||||
letter-spacing: 2px;
|
||||
color: #0056b3;
|
||||
}
|
||||
|
||||
.card p, .card ul {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.system-date-display {
|
||||
text-align: center;
|
||||
font-size: 1.5em;
|
||||
font-family: 'Quartz', monospace;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
#interfaces {
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 5px; /* Add some space for the scrollbar if it appears */
|
||||
.card ul {
|
||||
padding-left: 20px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
|
|
@ -88,75 +56,25 @@ body {
|
|||
gap: 10px;
|
||||
}
|
||||
|
||||
input[type="number"],
|
||||
input[type="text"] {
|
||||
padding: 8px;
|
||||
border: 1px solid #9fb3c8;
|
||||
border-radius: 4px;
|
||||
background-color: #f0f4f8;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
input[type="number"]:focus,
|
||||
input[type="text"]:focus {
|
||||
outline: none;
|
||||
border-color: #1a7db6;
|
||||
box-shadow: 0 0 0 2px rgba(26, 125, 182, 0.2);
|
||||
}
|
||||
|
||||
input[type="number"] {
|
||||
padding: 8px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 8px 15px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background-color: #1a7db6;
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-family: Arial, sans-serif;
|
||||
font-weight: bold;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: #166999;
|
||||
}
|
||||
|
||||
.offset-controls-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.offset-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.offset-control input[type="number"] {
|
||||
width: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.offset-control label {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
#offset-ms {
|
||||
width: 60px;
|
||||
background-color: #0056b3;
|
||||
}
|
||||
|
||||
#sync-message {
|
||||
|
|
@ -164,103 +82,6 @@ button:hover {
|
|||
color: #555;
|
||||
}
|
||||
|
||||
.icon-group {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
#delta-text {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#ltc-status, #ntp-active, #sync-status, #jitter-status, #frame-rate, #lock-ratio, #delta-status {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.collapsible-card {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.collapsible-card .toggle-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
padding: 20px;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.collapsible-card .toggle-header.active {
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.collapsible-card .toggle-header:hover {
|
||||
background-color: #e9e9f3;
|
||||
}
|
||||
|
||||
.toggle-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.log-box {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.collapsible-content {
|
||||
display: none;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.collapsible-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
footer {
|
||||
text-align: center;
|
||||
margin-top: 40px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #444;
|
||||
color: #c5ced6;
|
||||
}
|
||||
|
||||
footer p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
footer a {
|
||||
color: #1a7db6;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Status-specific colors */
|
||||
#sync-status.in-sync, #jitter-status.good { font-weight: bold; color: #28a745; }
|
||||
#sync-status.clock-ahead, #sync-status.clock-behind, #jitter-status.average { font-weight: bold; color: #ffc107; }
|
||||
|
|
@ -268,6 +89,3 @@ footer a:hover {
|
|||
#jitter-status.bad { font-weight: bold; color: #dc3545; }
|
||||
#ntp-active.active { font-weight: bold; color: #28a745; }
|
||||
#ntp-active.inactive { font-weight: bold; color: #dc3545; }
|
||||
|
||||
#ltc-status.lock { font-weight: bold; color: #28a745; }
|
||||
#ltc-status.free { font-weight: bold; color: #ffc107; }
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
28
update.sh
|
|
@ -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"
|
||||