Compare commits
170 commits
v0.1.0_pre
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e8bc9ac5e | ||
|
|
3e423416a8 | ||
|
|
4a07b29728 | ||
|
|
2d46fccfbe | ||
|
|
fdddf4eb76 | ||
|
|
46892884a1 | ||
|
|
04165f2686 | ||
|
|
459e44250e | ||
|
|
604d118d25 | ||
|
|
320174fe53 | ||
|
|
8903d6d006 | ||
|
|
32e785bd88 | ||
|
|
fb4ecc5f2a | ||
|
|
0c51fd77fa | ||
|
|
474e62d487 | ||
|
|
ea55d087b5 | ||
|
|
af6dbcc9a7 | ||
|
|
169c9b9aef | ||
|
|
6221eea98c | ||
|
|
ac035a8e0b | ||
|
|
f2e2fa9c7f | ||
|
|
3c73a0487b | ||
|
|
360e0751f2 | ||
|
|
a764b4d4ad | ||
|
|
63bd17b71e | ||
|
|
7db595259f | ||
|
|
e19b50fe2b | ||
|
|
cc1335f1a9 | ||
|
|
5ca32b6f36 | ||
|
|
1caa09ac46 | ||
|
|
57de9a98a5 | ||
|
|
0e7b583829 | ||
|
|
e4c59b412b | ||
|
|
dad5c2d06a | ||
|
|
baf674edd8 | ||
|
|
762f872e7c | ||
|
|
5eb706601f | ||
|
|
7773e62402 | ||
|
|
24c09fa233 | ||
|
|
7c5b7fe031 | ||
|
|
01c0d0495f | ||
|
|
3f99488ea0 | ||
|
|
e2d48391ea | ||
|
|
8362435e12 | ||
|
|
cd9ac5a141 | ||
|
|
b6a7606e1a | ||
|
|
9c57c32c68 | ||
|
|
c2b1aedaba | ||
|
|
a009dd35c9 | ||
|
|
4d0b4ebae4 | ||
|
|
5d206b564b | ||
|
|
b03d935a9e | ||
|
|
cbacf14ca1 | ||
|
|
22ac073922 | ||
|
|
acab0fbc04 | ||
|
|
048ae41739 | ||
|
|
1075be6e24 | ||
|
|
8e369a2e3a | ||
|
|
af0a512187 | ||
|
|
95fcb6f26a | ||
|
|
b510af2d8d | ||
|
|
cf24c9029e | ||
|
|
89cf0e5d97 | ||
|
|
94687da414 | ||
|
|
02487bda97 | ||
|
|
982aad3ec9 | ||
|
|
49287e5e16 | ||
|
|
f909a90caa | ||
|
|
fad7ddedb5 | ||
|
|
89628b974b | ||
|
|
886006420b | ||
|
|
5b0dcadac2 | ||
|
|
5fee17e1ab | ||
|
|
ba855d520a | ||
|
|
4c5fa69d1d | ||
|
|
54ebc0b242 | ||
|
|
534754be4e | ||
|
|
840fca7bcf | ||
|
|
87e8ae7711 | ||
|
|
4af732dab0 | ||
|
|
fbae58fb1d | ||
|
|
fffc123475 | ||
|
|
ba9b897157 | ||
|
|
9360e0011c | ||
|
|
adae9026ad | ||
|
|
463856a330 | ||
|
|
6726cf393a | ||
|
|
d4ff2568e3 | ||
|
|
3374646de5 | ||
|
|
cfc9a79ab8 | ||
|
|
7e7ca42220 | ||
|
|
e419cd506e | ||
|
|
0baf7588da | ||
|
|
fe9ac76942 | ||
|
|
26dca4fd18 | ||
|
|
8da42b87d0 | ||
|
|
c97d1841b5 | ||
|
|
0ba46fbd71 | ||
|
|
8636ed4ec4 | ||
|
|
f0ac2ed3d4 | ||
|
|
90f43ff87e | ||
|
|
abce5373d7 | ||
|
|
08d664efd1 | ||
|
|
cd922d5403 | ||
|
|
8150241db2 | ||
|
|
8b7e832225 | ||
|
|
80953e7f6d | ||
|
|
dad59ed9ff | ||
|
|
32712d1f3c | ||
|
|
5f35139f3b | ||
|
|
69569c0a01 | ||
|
|
4cdead5aa4 | ||
|
|
d99b57a98a | ||
|
|
1842419f10 | ||
|
|
82fbefce0c | ||
|
|
e4c49a1e78 | ||
|
|
ed48c1284d | ||
|
|
43a3fc7aad | ||
|
|
a4bf025fd0 | ||
|
|
c9c6320abb | ||
|
|
65dd107514 | ||
|
|
3ffb54e9aa | ||
|
|
22dc01e80f | ||
|
|
bda4d4e6f5 | ||
|
|
8453f18a3c | ||
|
|
049a85685c | ||
|
|
d13ffdc057 | ||
|
|
459500e402 | ||
|
|
4ee791c817 | ||
|
|
3d6a106f1e | ||
|
|
a1da396874 | ||
|
|
b71e13d4c4 | ||
|
|
91f8f7dc96 | ||
|
|
c27b4f5dbb | ||
|
|
2c78b20301 | ||
|
|
d2c4f1a4af | ||
|
|
f39db7e67d | ||
|
|
02842c3495 | ||
|
|
6bc1f5ddbf | ||
|
|
58a1d243e4 | ||
|
|
af43388e4b | ||
|
|
584840f1f3 | ||
|
|
3df9466754 | ||
|
|
0745883e0d | ||
|
|
0c6e1b0f43 | ||
|
|
871fd192b0 | ||
| d814b05a26 | |||
| 992720041b | |||
| 68dc16344a | |||
| 9a97027870 | |||
| d015794b03 | |||
| 4cb421b3d6 | |||
| c0613c3682 | |||
| fcbd5bd647 | |||
| f929bacdfd | |||
| 89849c6e04 | |||
| 4090fee0a6 | |||
| fb8088c704 | |||
| c712014bb9 | |||
| 3f953cff2f | |||
| a12ee88b9b | |||
| 917a844874 | |||
| aee69679ef | |||
| 80faf4db9a | |||
| cc782fcd7e | |||
| 6a45660e03 | |||
| 985ccc6819 | |||
| 5a86493824 | |||
| b803de93de | |||
| 7738d14097 |
|
|
@ -17,8 +17,9 @@ actix-web = "4"
|
|||
actix-files = "0.6"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
clap = { version = "4.4", features = ["derive"] }
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
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 @@
|
|||
# 🕰️ NTP Timeturner (alpha)
|
||||
# Fetch | Hachi (alpha)
|
||||
|
||||
**An LTC-driven NTP server for Raspberry Pi, built with broadcast precision and a hint of magic.**
|
||||
**An LTC-driven NTP server for Raspberry Pi, built with broadcast precision**
|
||||
|
||||
Inspired by the TimeTurner in the Harry Potter series, this project synchronises timecode-locked systems by decoding incoming LTC (Linear Time Code) and broadcasting it as NTP — with precision as Hermione would insist upon.
|
||||
Hachi synchronises timecode-locked systems by decoding incoming LTC (Linear Time Code) and broadcasting it as NTP/PTP — with the dedication our namesake would insist upon.
|
||||
|
||||
Created by Chris Frankland-Wright and John Rogers
|
||||
Created by Chris Frankland-Wright and Chaos Rogers
|
||||
|
||||
---
|
||||
|
||||
## 📦 Hardware Requirements
|
||||
|
||||
- Raspberry Pi 5 (Dev Platform) but should be supported by Pi v3 (or better)
|
||||
- Raspberry Pi 5 2GB (Dev Platform) but should be supported by Pi v3 (or better)
|
||||
- Debian Bookworm (64-bit recommended)
|
||||
- Teensy 4.0 - https://thepihut.com/products/teensy-4-0-headers
|
||||
- Audio Adapter Board for Teensy 4.0 (Rev D) - https://thepihut.com/products/audio-adapter-board-for-teensy-4-0
|
||||
|
|
@ -24,26 +24,103 @@ Created by Chris Frankland-Wright and John Rogers
|
|||
- Reads SMPTE LTC from Audio Interface (3.5mm TRS but adaptable to BNC/XLR)
|
||||
- Converts LTC into NTP-synced time
|
||||
- Broadcasts time via local NTP server
|
||||
- Supports configurable time offsets (hours, minutes, seconds, milliseconds) - NOT AVAILABLE
|
||||
- Supports configurable time offsets (hours, minutes, seconds, frames or milliseconds)
|
||||
- Systemd service support for headless operation
|
||||
- Web-based UI for monitoring and control when running as a daemon
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Installation (to update)
|
||||
## 🖥️ Web Interface & API
|
||||
|
||||
When running as a background daemon, Hachi provides a web interface for monitoring and configuration.
|
||||
|
||||
For Rust install you can do
|
||||
```bash
|
||||
cargo install --git https://github.com/cjfranko/NTP-Timeturner
|
||||
```
|
||||
Clone and run the installer:
|
||||
- **Access**: The web UI is available at `http://<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:
|
||||
|
||||
```bash
|
||||
wget https://raw.githubusercontent.com/cjfranko/NTP-Timeturner/master/setup.sh
|
||||
chmod +x setup.sh
|
||||
curl -L https://github.com/cjfranko/NTP-Timeturner/archive/refs/heads/main.zip -o NTP-Timeturner.zip && \
|
||||
unzip NTP-Timeturner.zip && \
|
||||
cd NTP-Timeturner-main && \
|
||||
chmod +x setup.sh && \
|
||||
./setup.sh
|
||||
```
|
||||
|
||||
### What the Script Does
|
||||
|
||||
The installation script automates the following steps:
|
||||
|
||||
1. **Installs Dependencies**: Installs `git`, `curl`, `unzip`, and necessary build tools.
|
||||
2. **Compiles the Binary**: Runs `cargo build --release` to create an optimised executable.
|
||||
3. **Creates Directories**: Creates `/opt/timeturner` to store the application files.
|
||||
4. **Installs Files**:
|
||||
- The compiled binary is copied to `/opt/timeturner/timeturner`.
|
||||
- The web interface assets from the `static/` directory are copied to `/opt/timeturner/static`.
|
||||
- A symbolic link is created from `/usr/local/bin/timeturner` to the binary, allowing it to be run from any location.
|
||||
5. **Sets up Systemd Service**:
|
||||
- Copies the `timeturner.service` file to `/etc/systemd/system/`.
|
||||
- Enables the service to start automatically on system boot.
|
||||
|
||||
After installation is complete, the script will provide instructions to start the service manually or to run the application in its interactive terminal mode.
|
||||
|
||||
```bash
|
||||
The working directory is /opt/timeturner.
|
||||
Default 'config.yml' installed to /opt/timeturner.
|
||||
|
||||
To start the service, run:
|
||||
sudo systemctl start timeturner.service
|
||||
|
||||
To view live logs, run:
|
||||
journalctl -u timeturner.service -f
|
||||
|
||||
To run the interactive TUI instead, simply run from the project directory:
|
||||
cargo run
|
||||
Or from anywhere after installation:
|
||||
timeturner
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Updating
|
||||
|
||||
If you installed Hachi by cloning the repository with `git`, you can use the `update.sh` script to easily update to the latest version.
|
||||
|
||||
**Note**: This script will not work if you used the `curl` one-line command for installation, as that method does not create a Git repository.
|
||||
|
||||
To run the update script, navigate to the `NTP-Timeturner-main` directory and run:
|
||||
```bash
|
||||
chmod +x update.sh && ./update.sh
|
||||
```
|
||||
|
||||
The update script automates the following:
|
||||
1. Pulls the latest code from the `main` branch on GitHub.
|
||||
2. Rebuilds the application binary.
|
||||
3. Copies the new binary to `/opt/timeturner/`.
|
||||
4. Restarts the `timeturner` service to apply the changes.
|
||||
|
||||
---
|
||||
## 🕰️ Chrony NTP
|
||||
```bash
|
||||
|
|
@ -52,10 +129,10 @@ chronyc tracking | NTP Tracking
|
|||
sudo nano /etc/chrony/chrony.conf | Default Chrony Conf File
|
||||
|
||||
Add to top:
|
||||
# Serve the system clock as a reference at stratum 10
|
||||
# Serve the system clock as a reference at stratum 1
|
||||
server 127.127.1.0
|
||||
allow 127.0.0.0/8
|
||||
local stratum 10
|
||||
local stratum 1
|
||||
|
||||
Add to bottom:
|
||||
# Allow LAN clients
|
||||
|
|
|
|||
9
SECURITY.MD
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
Reporting Security Issues
|
||||
|
||||
The TimeTurner team and community take security bugs in TimeTurner seriously. We appreciate your efforts to responsibly disclose your findings, and will make every effort to acknowledge your contributions.
|
||||
|
||||
To report a security issue, please use the GitHub Security Advisory "Report a Vulnerability" tab.
|
||||
|
||||
The TimeTurner team will send a response indicating the next steps in handling your report. After the initial reply to your report, the security team will keep you informed of the progress towards a fix and full announcement, and may ask for additional information or guidance.
|
||||
|
||||
Report security bugs in third-party modules to the person or team maintaining the module.
|
||||
11
config.yml
|
|
@ -1,5 +1,13 @@
|
|||
# Hardware offset in milliseconds for correcting capture latency.
|
||||
hardwareOffsetMs: 20
|
||||
hardwareOffsetMs: 55
|
||||
|
||||
# Enable automatic clock synchronization.
|
||||
# When enabled, the system will perform an initial full sync, then periodically
|
||||
# nudge the clock to keep it aligned with the LTC source.
|
||||
autoSyncEnabled: true
|
||||
|
||||
# Default nudge in milliseconds for adjtimex control.
|
||||
defaultNudgeMs: 2
|
||||
|
||||
# Time-turning offsets. All values are added to the incoming LTC time.
|
||||
# These can be positive or negative.
|
||||
|
|
@ -8,3 +16,4 @@ timeturnerOffset:
|
|||
minutes: 0
|
||||
seconds: 0
|
||||
frames: 0
|
||||
milliseconds: 0
|
||||
|
|
|
|||
134
docs/api.md
|
|
@ -4,19 +4,25 @@ This document describes the HTTP API for the NTP Timeturner application.
|
|||
|
||||
## Endpoints
|
||||
|
||||
### Status
|
||||
### Status and Logs
|
||||
|
||||
- **`GET /api/status`**
|
||||
|
||||
Retrieves the real-time status of the LTC reader and system clock synchronization.
|
||||
Retrieves the real-time status of the LTC reader and system clock synchronization. The `ltc_timecode` field uses `:` as a separator for non-drop-frame timecode, and `;` for drop-frame timecode between seconds and frames (e.g., `10:20:30;00`).
|
||||
|
||||
**Possible values for status fields:**
|
||||
- `ltc_status`: `"LOCK"`, `"FREE"`, or `"(waiting)"`
|
||||
- `sync_status`: `"IN SYNC"`, `"CLOCK AHEAD"`, `"CLOCK BEHIND"`, `"TIMETURNING"`
|
||||
- `jitter_status`: `"GOOD"`, `"AVERAGE"`, `"BAD"`
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"ltc_status": "LOCK",
|
||||
"ltc_timecode": "10:20:30:00",
|
||||
"ltc_timecode": "10:20:30;00",
|
||||
"frame_rate": "25.00fps",
|
||||
"system_clock": "10:20:30.005",
|
||||
"system_date": "2025-07-30",
|
||||
"timecode_delta_ms": 5,
|
||||
"timecode_delta_frames": 0,
|
||||
"sync_status": "IN SYNC",
|
||||
|
|
@ -24,11 +30,23 @@ This document describes the HTTP API for the NTP Timeturner application.
|
|||
"lock_ratio": 99.5,
|
||||
"ntp_active": true,
|
||||
"interfaces": ["192.168.1.100"],
|
||||
"hardware_offset_ms": 0
|
||||
"hardware_offset_ms": 20
|
||||
}
|
||||
```
|
||||
|
||||
### Sync
|
||||
- **`GET /api/logs`**
|
||||
|
||||
Retrieves the last 100 log entries from the application.
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
[
|
||||
"2025-08-07 10:00:00 [INFO] Starting TimeTurner daemon...",
|
||||
"2025-08-07 10:00:01 [INFO] Found serial port: /dev/ttyACM0"
|
||||
]
|
||||
```
|
||||
|
||||
### System Clock Control
|
||||
|
||||
- **`POST /api/sync`**
|
||||
|
||||
|
|
@ -36,7 +54,7 @@ This document describes the HTTP API for the NTP Timeturner application.
|
|||
|
||||
**Request Body:** None
|
||||
|
||||
**Success Response:**
|
||||
**Success Response (200 OK):**
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
|
|
@ -44,13 +62,14 @@ This document describes the HTTP API for the NTP Timeturner application.
|
|||
}
|
||||
```
|
||||
|
||||
**Error Responses:**
|
||||
**Error Response (400 Bad Request):**
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "No LTC timecode available to sync to."
|
||||
}
|
||||
```
|
||||
**Error Response (500 Internal Server Error):**
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
|
|
@ -58,33 +77,120 @@ This document describes the HTTP API for the NTP Timeturner application.
|
|||
}
|
||||
```
|
||||
|
||||
- **`POST /api/nudge_clock`**
|
||||
|
||||
Nudges the system clock by a specified number of microseconds. This requires `sudo` privileges to run `adjtimex`.
|
||||
|
||||
**Example Request:**
|
||||
```json
|
||||
{
|
||||
"microseconds": -2000
|
||||
}
|
||||
```
|
||||
**Success Response (200 OK):**
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Clock nudge command issued."
|
||||
}
|
||||
```
|
||||
**Error Response (500 Internal Server Error):**
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Clock nudge command failed."
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
- **`POST /api/set_date`**
|
||||
|
||||
Sets the system date. This is useful as LTC does not contain date information. Requires `sudo` privileges.
|
||||
|
||||
**Example Request:**
|
||||
```json
|
||||
{
|
||||
"date": "2025-07-30"
|
||||
}
|
||||
```
|
||||
|
||||
**Success Response (200 OK):**
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Date update command issued."
|
||||
}
|
||||
```
|
||||
|
||||
**Error Response (500 Internal Server Error):**
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Date update command failed."
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
- **`GET /api/config`**
|
||||
|
||||
Retrieves the current application configuration.
|
||||
Retrieves the current application configuration from `config.yml`.
|
||||
|
||||
**Example Response:**
|
||||
**Example Response (200 OK):**
|
||||
```json
|
||||
{
|
||||
"hardware_offset_ms": 0
|
||||
"hardwareOffsetMs": 20,
|
||||
"timeturnerOffset": {
|
||||
"hours": 0,
|
||||
"minutes": 0,
|
||||
"seconds": 0,
|
||||
"frames": 0,
|
||||
"milliseconds": 0
|
||||
},
|
||||
"defaultNudgeMs": 2,
|
||||
"autoSyncEnabled": false
|
||||
}
|
||||
```
|
||||
|
||||
- **`POST /api/config`**
|
||||
|
||||
Updates the `hardware_offset_ms` configuration. The new value is persisted to `config.json` and reloaded by the application automatically.
|
||||
Updates the application configuration. The new configuration is persisted to `config.yml` and takes effect immediately.
|
||||
|
||||
**Example Request:**
|
||||
```json
|
||||
{
|
||||
"hardware_offset_ms": 10
|
||||
"hardwareOffsetMs": 55,
|
||||
"timeturnerOffset": {
|
||||
"hours": 1,
|
||||
"minutes": 2,
|
||||
"seconds": 3,
|
||||
"frames": 4,
|
||||
"milliseconds": 5
|
||||
},
|
||||
"defaultNudgeMs": 2,
|
||||
"autoSyncEnabled": true
|
||||
}
|
||||
```
|
||||
|
||||
**Success Response:**
|
||||
**Success Response (200 OK):** (Returns the updated configuration)
|
||||
```json
|
||||
{
|
||||
"hardware_offset_ms": 10
|
||||
"hardwareOffsetMs": 55,
|
||||
"timeturnerOffset": {
|
||||
"hours": 1,
|
||||
"minutes": 2,
|
||||
"seconds": 3,
|
||||
"frames": 4,
|
||||
"milliseconds": 5
|
||||
},
|
||||
"defaultNudgeMs": 2,
|
||||
"autoSyncEnabled": true
|
||||
}
|
||||
```
|
||||
**Error Response (500 Internal Server Error):**
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Failed to write config.yml"
|
||||
}
|
||||
```
|
||||
|
|
|
|||
241
setup.sh
|
|
@ -3,14 +3,226 @@ set -e
|
|||
|
||||
echo "--- TimeTurner Setup ---"
|
||||
|
||||
# 1. Build the release binary
|
||||
echo "📦 Building release binary with Cargo..."
|
||||
if ! command -v cargo &> /dev/null
|
||||
then
|
||||
echo "❌ Cargo is not installed. Please install Rust and Cargo first."
|
||||
echo "Visit https://rustup.rs/ for instructions."
|
||||
# Check if TimeTurner is already installed.
|
||||
INSTALL_DIR="/opt/timeturner"
|
||||
if [ -f "${INSTALL_DIR}/timeturner" ]; then
|
||||
echo "✅ TimeTurner is already installed."
|
||||
# Ask the user what to do
|
||||
read -p "Do you want to (U)pdate, (R)einstall, or (A)bort? [U/r/a] " choice
|
||||
case "$choice" in
|
||||
r|R )
|
||||
echo "Proceeding with full re-installation..."
|
||||
# Stop the service to allow overwriting the binary, ignore errors if not running
|
||||
echo "Stopping existing TimeTurner service..."
|
||||
sudo systemctl stop timeturner.service || true
|
||||
# The script will continue to the installation steps below.
|
||||
;;
|
||||
a|A )
|
||||
echo "Aborting setup."
|
||||
exit 0
|
||||
;;
|
||||
* ) # Default to Update
|
||||
echo "Attempting to run the update script..."
|
||||
# Ensure we are in a git repository and the update script exists
|
||||
if [ -d ".git" ] && [ -f "update.sh" ]; then
|
||||
chmod +x update.sh
|
||||
./update.sh
|
||||
# Exit cleanly after the update
|
||||
exit 0
|
||||
else
|
||||
echo "⚠️ Could not find 'update.sh' or not in a git repository."
|
||||
echo "Please re-clone the repository to get the update script, or remove the existing installation to run setup again:"
|
||||
echo " sudo rm -rf ${INSTALL_DIR}"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
|
||||
# Determine package manager
|
||||
PKG_MANAGER=""
|
||||
if command -v apt &> /dev/null; then
|
||||
PKG_MANAGER="apt"
|
||||
elif command -v dnf &> /dev/null; then
|
||||
PKG_MANAGER="dnf"
|
||||
elif command -v pacman &> /dev/null; then
|
||||
PKG_MANAGER="pacman"
|
||||
else
|
||||
echo "Error: No supported package manager (apt, dnf, pacman) found. Please install dependencies manually."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Detected package manager: $PKG_MANAGER"
|
||||
|
||||
# --- Update System Packages ---
|
||||
echo "Updating system packages..."
|
||||
if [ "$PKG_MANAGER" == "apt" ]; then
|
||||
sudo apt update
|
||||
sudo DEBIAN_FRONTEND=noninteractive apt upgrade -y -o Dpkg::Options::="--force-confold"
|
||||
elif [ "$PKG_MANAGER" == "dnf" ]; then
|
||||
sudo dnf upgrade -y
|
||||
elif [ "$PKG_MANAGER" == "pacman" ]; then
|
||||
sudo pacman -Syu --noconfirm
|
||||
fi
|
||||
echo "System packages updated."
|
||||
|
||||
# --- Install Rust/Cargo if not installed ---
|
||||
if ! command -v cargo &> /dev/null; then
|
||||
echo "Rust/Cargo not found. Installing Rustup..."
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||
# Source cargo's env for the current shell session
|
||||
# This is for the current script's execution path, typically rustup adds to .bashrc/.profile for future sessions.
|
||||
# We need it now, but for non-interactive script, sourcing won't affect parent shell.
|
||||
# However, cargo build below will rely on it being in PATH. rustup makes sure of this if it installs.
|
||||
# For safety, ensure PATH is updated.
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
echo "Rust/Cargo installed successfully."
|
||||
else
|
||||
echo "Rust/Cargo is already installed."
|
||||
fi
|
||||
|
||||
# --- Install common build dependencies for Rust ---
|
||||
echo "Installing common build dependencies..."
|
||||
if [ "$PKG_MANAGER" == "apt" ]; then
|
||||
sudo apt update
|
||||
sudo apt install -y build-essential libudev-dev pkg-config curl wget
|
||||
elif [ "$PKG_MANAGER" == "dnf" ]; then
|
||||
sudo dnf install -y gcc make perl-devel libudev-devel pkg-config curl wget
|
||||
elif [ "$PKG_MANAGER" == "pacman" ]; then
|
||||
sudo pacman -Sy --noconfirm base-devel libudev pkg-config curl
|
||||
fi
|
||||
echo "Common build dependencies installed."
|
||||
|
||||
# --- Install Python dependencies for testing ---
|
||||
echo "🐍 Installing Python dependencies for test scripts..."
|
||||
if [ "$PKG_MANAGER" == "apt" ]; then
|
||||
# We no longer need hotspot dependencies
|
||||
sudo apt install -y python3 python3-pip python3-serial
|
||||
elif [ "$PKG_MANAGER" == "dnf" ]; then
|
||||
# python3-pyserial is the name for pyserial in dnf
|
||||
sudo dnf install -y python3 python3-pip python3-pyserial
|
||||
elif [ "$PKG_MANAGER" == "pacman" ]; then
|
||||
# python-pyserial is the name for pyserial in pacman
|
||||
sudo pacman -Sy --noconfirm python python-pip python-pyserial
|
||||
fi
|
||||
# sudo pip3 install pyserial # This is replaced by the native package manager installs above
|
||||
echo "✅ Python dependencies installed."
|
||||
|
||||
# --- Apply custom splash screen ---
|
||||
if [[ "$(uname)" == "Linux" ]]; then
|
||||
echo "🖼️ Applying custom splash screen..."
|
||||
SPLASH_URL="https://raw.githubusercontent.com/cjfranko/NTP-Timeturner/refs/heads/main/splash.png"
|
||||
PLYMOUTH_THEME_DIR="/usr/share/plymouth/themes/pix"
|
||||
PLYMOUTH_IMAGE_PATH="${PLYMOUTH_THEME_DIR}/splash.png"
|
||||
|
||||
sudo mkdir -p "${PLYMOUTH_THEME_DIR}"
|
||||
echo "Downloading splash image from ${SPLASH_URL}..."
|
||||
sudo curl -L "${SPLASH_URL}" -o "${PLYMOUTH_IMAGE_PATH}"
|
||||
|
||||
if [ -f "${PLYMOUTH_IMAGE_PATH}" ]; then
|
||||
echo "Splash image downloaded. Updating Plymouth configuration..."
|
||||
# Set 'pix' as the default plymouth theme if not already.
|
||||
# This is a common theme that expects splash.png.
|
||||
sudo update-alternatives --install /usr/share/plymouth/themes/default.plymouth default.plymouth "${PLYMOUTH_THEME_DIR}/pix.plymouth" 100 || true
|
||||
# Ensure the pix theme exists and is linked
|
||||
if [ ! -f "${PLYMOUTH_THEME_DIR}/pix.plymouth" ]; then
|
||||
echo "Creating dummy pix.plymouth for update-initramfs"
|
||||
echo "[Plymouth Theme]" | sudo tee "${PLYMOUTH_THEME_DIR}/pix.plymouth" > /dev/null
|
||||
echo "Name=Pi Splash" | sudo tee -a "${PLYMOUTH_THEME_DIR}/pix.plymouth" > /dev/null
|
||||
echo "Description=TimeTurner Raspberry Pi Splash Screen" | sudo tee -a "${PLYMOUTH_THEME_DIR}/pix.plymouth" > /dev/null
|
||||
echo "SpriteAnimation=/splash.png" | sudo tee -a "${PLYMOUTH_THEME_DIR}/pix.plymouth" > /dev/null
|
||||
fi
|
||||
|
||||
# Update the initial RAM filesystem to include the new splash screen
|
||||
sudo update-initramfs -u
|
||||
echo "✅ Custom splash screen applied. Reboot may be required to see changes."
|
||||
else
|
||||
echo "❌ Failed to download splash image from ${SPLASH_URL}."
|
||||
fi
|
||||
else
|
||||
echo "⚠️ Skipping splash screen configuration on non-Linux OS."
|
||||
fi
|
||||
|
||||
# --- Remove NTPD and install Chrony, NMTUI, Adjtimex ---
|
||||
echo "Removing NTPD (if installed) and installing Chrony, NMTUI, Adjtimex..."
|
||||
|
||||
# --- Remove NTPD and install Chrony, NMTUI, Adjtimex ---
|
||||
echo "Removing NTPD (if installed) and installing Chrony, NMTUI, Adjtimex..."
|
||||
|
||||
if [ "$PKG_MANAGER" == "apt" ]; then
|
||||
sudo apt update
|
||||
sudo apt remove -y ntp || true # Remove ntp if it exists, ignore if not
|
||||
sudo apt install -y chrony network-manager adjtimex
|
||||
sudo systemctl enable chrony --now
|
||||
elif [ "$PKG_MANAGER" == "dnf" ]; then
|
||||
sudo dnf remove -y ntp
|
||||
sudo dnf install -y chrony NetworkManager-tui adjtimex
|
||||
sudo systemctl enable chronyd --now
|
||||
elif [ "$PKG_MANAGER" == "pacman" ]; then
|
||||
sudo pacman -Sy --noconfirm ntp || true
|
||||
sudo pacman -R --noconfirm ntp || true # Ensure ntp is removed
|
||||
sudo pacman -Sy --noconfirm chrony networkmanager adjtimex
|
||||
sudo systemctl enable chronyd --now
|
||||
sudo systemctl enable NetworkManager --now # nmtui relies on NetworkManager
|
||||
fi
|
||||
|
||||
echo "NTPD removed (if present). Chrony, NMTUI, and Adjtimex installed and configured."
|
||||
|
||||
# --- Configure Chrony to act as a local NTP server ---
|
||||
echo "⚙️ Configuring Chrony to serve local time..."
|
||||
# The path to chrony.conf can vary
|
||||
if [ -f /etc/chrony/chrony.conf ]; then
|
||||
CHRONY_CONF="/etc/chrony/chrony.conf"
|
||||
elif [ -f /etc/chrony.conf ]; then
|
||||
CHRONY_CONF="/etc/chrony.conf"
|
||||
else
|
||||
CHRONY_CONF=""
|
||||
fi
|
||||
|
||||
if [ -n "$CHRONY_CONF" ]; then
|
||||
# Comment out any existing pool, server, or sourcedir lines to prevent syncing with external sources
|
||||
echo "Disabling external NTP sources..."
|
||||
sudo sed -i -E 's/^(pool|server|sourcedir)/#&/' "$CHRONY_CONF"
|
||||
|
||||
# Add settings to the top of the file to serve local clock
|
||||
# Using a temp file to prepend is safer than multiple sed calls
|
||||
TEMP_CONF=$(mktemp)
|
||||
cat <<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
|
||||
cargo build --release
|
||||
echo "✅ Build complete."
|
||||
|
||||
|
|
@ -21,13 +233,16 @@ echo "🔧 Creating directories..."
|
|||
sudo mkdir -p $INSTALL_DIR
|
||||
echo "✅ Directory $INSTALL_DIR created."
|
||||
|
||||
# 3. Install binary
|
||||
echo "🚀 Installing timeturner binary..."
|
||||
# 3. Install binary and static web files
|
||||
echo "🚀 Installing timeturner binary and web assets..."
|
||||
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 installed to $INSTALL_DIR and linked to $BIN_DIR."
|
||||
echo "✅ Binary and assets installed to $INSTALL_DIR, and binary 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/
|
||||
|
|
@ -42,7 +257,13 @@ echo ""
|
|||
echo "--- Setup Complete ---"
|
||||
echo "The TimeTurner daemon is now installed."
|
||||
echo "The working directory is $INSTALL_DIR."
|
||||
echo "A default 'config.yml' will be created there on first run."
|
||||
# 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 ""
|
||||
if [[ "$(uname)" == "Linux" ]]; then
|
||||
echo "To start the service, run:"
|
||||
|
|
|
|||
142
src/api.rs
|
|
@ -5,11 +5,14 @@ use chrono::{Local, Timelike};
|
|||
use get_if_addrs::get_if_addrs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json;
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::config::{self, Config};
|
||||
use crate::sync_logic::{self, LtcState};
|
||||
use crate::system;
|
||||
use num_rational::Ratio;
|
||||
use num_traits::ToPrimitive;
|
||||
|
||||
// Data structure for the main status response
|
||||
#[derive(Serialize, Deserialize)]
|
||||
|
|
@ -18,6 +21,7 @@ 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,
|
||||
|
|
@ -32,6 +36,7 @@ struct ApiStatus {
|
|||
pub struct AppState {
|
||||
pub ltc_state: Arc<Mutex<LtcState>>,
|
||||
pub config: Arc<Mutex<Config>>,
|
||||
pub log_buffer: Arc<Mutex<VecDeque<String>>>,
|
||||
}
|
||||
|
||||
#[get("/api/status")]
|
||||
|
|
@ -42,10 +47,14 @@ 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| {
|
||||
format!("{:02}:{:02}:{:02}:{:02}", f.hours, f.minutes, f.seconds, f.frames)
|
||||
let sep = if f.is_drop_frame { ';' } else { ':' };
|
||||
format!(
|
||||
"{:02}:{:02}:{:02}{}{:02}",
|
||||
f.hours, f.minutes, f.seconds, sep, f.frames
|
||||
)
|
||||
});
|
||||
let frame_rate = state.latest.as_ref().map_or("…".to_string(), |f| {
|
||||
format!("{:.2}fps", f.frame_rate)
|
||||
format!("{:.2}fps", f.frame_rate.to_f64().unwrap_or(0.0))
|
||||
});
|
||||
|
||||
let now_local = Local::now();
|
||||
|
|
@ -56,12 +65,14 @@ 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.average_clock_delta();
|
||||
let avg_delta = state.get_ewma_clock_delta();
|
||||
let mut delta_frames = 0;
|
||||
if let Some(frame) = &state.latest {
|
||||
let frame_ms = 1000.0 / frame.frame_rate;
|
||||
delta_frames = ((avg_delta as f64 / frame_ms).round()) as i64;
|
||||
let delta_ms_ratio = Ratio::new(avg_delta, 1);
|
||||
let frames_ratio = delta_ms_ratio * frame.frame_rate / Ratio::new(1000, 1);
|
||||
delta_frames = frames_ratio.round().to_integer();
|
||||
}
|
||||
|
||||
let sync_status = sync_logic::get_sync_status(avg_delta, &config);
|
||||
|
|
@ -81,6 +92,7 @@ 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(),
|
||||
|
|
@ -113,6 +125,42 @@ async fn get_config(data: web::Data<AppState>) -> impl Responder {
|
|||
HttpResponse::Ok().json(&*config)
|
||||
}
|
||||
|
||||
#[get("/api/logs")]
|
||||
async fn get_logs(data: web::Data<AppState>) -> impl Responder {
|
||||
let logs = data.log_buffer.lock().unwrap();
|
||||
HttpResponse::Ok().json(&*logs)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct NudgeRequest {
|
||||
microseconds: i64,
|
||||
}
|
||||
|
||||
#[post("/api/nudge_clock")]
|
||||
async fn nudge_clock(req: web::Json<NudgeRequest>) -> impl Responder {
|
||||
if system::nudge_clock(req.microseconds).is_ok() {
|
||||
HttpResponse::Ok().json(serde_json::json!({ "status": "success", "message": "Clock nudge command issued." }))
|
||||
} else {
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({ "status": "error", "message": "Clock nudge command failed." }))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SetDateRequest {
|
||||
date: String,
|
||||
}
|
||||
|
||||
#[post("/api/set_date")]
|
||||
async fn set_date(req: web::Json<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>,
|
||||
|
|
@ -122,23 +170,44 @@ async fn update_config(
|
|||
*config = req.into_inner();
|
||||
|
||||
if config::save_config("config.yml", &config).is_ok() {
|
||||
eprintln!("🔄 Saved config via API: {:?}", *config);
|
||||
log::info!("🔄 Saved config via API: {:?}", *config);
|
||||
|
||||
// If timeturner offset is active, trigger a sync immediately.
|
||||
if config.timeturner_offset.is_active() {
|
||||
let state = data.ltc_state.lock().unwrap();
|
||||
if let Some(frame) = &state.latest {
|
||||
log::info!("Timeturner offset is active, triggering sync...");
|
||||
if system::trigger_sync(frame, &config).is_ok() {
|
||||
log::info!("Sync triggered successfully after config change.");
|
||||
} else {
|
||||
log::error!("Sync failed after config change.");
|
||||
}
|
||||
} else {
|
||||
log::warn!("Timeturner offset is active, but no LTC frame available to sync.");
|
||||
}
|
||||
}
|
||||
|
||||
HttpResponse::Ok().json(&*config)
|
||||
} else {
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({ "status": "error", "message": "Failed to write config.yml" }))
|
||||
log::error!("Failed to write config.yml");
|
||||
HttpResponse::InternalServerError().json(
|
||||
serde_json::json!({ "status": "error", "message": "Failed to write config.yml" }),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn start_api_server(
|
||||
state: Arc<Mutex<LtcState>>,
|
||||
config: Arc<Mutex<Config>>,
|
||||
log_buffer: Arc<Mutex<VecDeque<String>>>,
|
||||
) -> std::io::Result<()> {
|
||||
let app_state = web::Data::new(AppState {
|
||||
ltc_state: state,
|
||||
config: config,
|
||||
log_buffer: log_buffer,
|
||||
});
|
||||
|
||||
println!("🚀 Starting API server at http://0.0.0.0:8080");
|
||||
log::info!("🚀 Starting API server at http://0.0.0.0:8080");
|
||||
|
||||
HttpServer::new(move || {
|
||||
App::new()
|
||||
|
|
@ -147,6 +216,9 @@ pub async fn start_api_server(
|
|||
.service(manual_sync)
|
||||
.service(get_config)
|
||||
.service(update_config)
|
||||
.service(get_logs)
|
||||
.service(nudge_clock)
|
||||
.service(set_date)
|
||||
// Serve frontend static files
|
||||
.service(fs::Files::new("/", "static/").index_file("index.html"))
|
||||
})
|
||||
|
|
@ -174,13 +246,14 @@ mod tests {
|
|||
minutes: 2,
|
||||
seconds: 3,
|
||||
frames: 4,
|
||||
frame_rate: 25.0,
|
||||
is_drop_frame: false,
|
||||
frame_rate: Ratio::new(25, 1),
|
||||
timestamp: Utc::now(),
|
||||
}),
|
||||
lock_count: 10,
|
||||
free_count: 1,
|
||||
offset_history: VecDeque::from(vec![1, 2, 3]),
|
||||
clock_delta_history: VecDeque::from(vec![4, 5, 6]),
|
||||
ewma_clock_delta: Some(5.0),
|
||||
last_match_status: "IN SYNC".to_string(),
|
||||
last_match_check: Utc::now().timestamp(),
|
||||
}
|
||||
|
|
@ -191,11 +264,16 @@ mod tests {
|
|||
let ltc_state = Arc::new(Mutex::new(get_test_ltc_state()));
|
||||
let config = Arc::new(Mutex::new(Config {
|
||||
hardware_offset_ms: 10,
|
||||
timeturner_offset: TimeturnerOffset {
|
||||
hours: 0, minutes: 0, seconds: 0, frames: 0
|
||||
}
|
||||
timeturner_offset: TimeturnerOffset::default(),
|
||||
default_nudge_ms: 2,
|
||||
auto_sync_enabled: false,
|
||||
}));
|
||||
web::Data::new(AppState { ltc_state, config })
|
||||
let log_buffer = Arc::new(Mutex::new(VecDeque::new()));
|
||||
web::Data::new(AppState {
|
||||
ltc_state,
|
||||
config,
|
||||
log_buffer,
|
||||
})
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
|
|
@ -217,6 +295,32 @@ mod tests {
|
|||
assert_eq!(resp.hardware_offset_ms, 10);
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn test_get_status_drop_frame() {
|
||||
let app_state = get_test_app_state();
|
||||
// Set state to drop frame
|
||||
app_state
|
||||
.ltc_state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.latest
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.is_drop_frame = true;
|
||||
|
||||
let app = test::init_service(
|
||||
App::new()
|
||||
.app_data(app_state.clone())
|
||||
.service(get_status),
|
||||
)
|
||||
.await;
|
||||
|
||||
let req = test::TestRequest::get().uri("/api/status").to_request();
|
||||
let resp: ApiStatus = test::call_and_read_body_json(&app, req).await;
|
||||
|
||||
assert_eq!(resp.ltc_timecode, "01:02:03;04");
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn test_get_config() {
|
||||
let app_state = get_test_app_state();
|
||||
|
|
@ -253,7 +357,9 @@ mod tests {
|
|||
|
||||
let new_config_json = serde_json::json!({
|
||||
"hardwareOffsetMs": 55,
|
||||
"timeturnerOffset": { "hours": 1, "minutes": 2, "seconds": 3, "frames": 4 }
|
||||
"defaultNudgeMs": 2,
|
||||
"autoSyncEnabled": true,
|
||||
"timeturnerOffset": { "hours": 1, "minutes": 2, "seconds": 3, "frames": 4, "milliseconds": 5 }
|
||||
});
|
||||
|
||||
let req = test::TestRequest::post()
|
||||
|
|
@ -264,16 +370,22 @@ mod tests {
|
|||
let resp: Config = test::call_and_read_body_json(&app, req).await;
|
||||
|
||||
assert_eq!(resp.hardware_offset_ms, 55);
|
||||
assert_eq!(resp.auto_sync_enabled, true);
|
||||
assert_eq!(resp.timeturner_offset.hours, 1);
|
||||
assert_eq!(resp.timeturner_offset.milliseconds, 5);
|
||||
let final_config = app_state.config.lock().unwrap();
|
||||
assert_eq!(final_config.hardware_offset_ms, 55);
|
||||
assert_eq!(final_config.auto_sync_enabled, true);
|
||||
assert_eq!(final_config.timeturner_offset.hours, 1);
|
||||
assert_eq!(final_config.timeturner_offset.milliseconds, 5);
|
||||
|
||||
// Test that the file was written
|
||||
assert!(fs::metadata(config_path).is_ok());
|
||||
let contents = fs::read_to_string(config_path).unwrap();
|
||||
assert!(contents.contains("hardwareOffsetMs: 55"));
|
||||
assert!(contents.contains("autoSyncEnabled: true"));
|
||||
assert!(contents.contains("hours: 1"));
|
||||
assert!(contents.contains("milliseconds: 5"));
|
||||
|
||||
// Cleanup
|
||||
let _ = fs::remove_file(config_path);
|
||||
|
|
|
|||
|
|
@ -19,11 +19,17 @@ pub struct TimeturnerOffset {
|
|||
pub minutes: i64,
|
||||
pub seconds: i64,
|
||||
pub frames: i64,
|
||||
#[serde(default)]
|
||||
pub milliseconds: i64,
|
||||
}
|
||||
|
||||
impl TimeturnerOffset {
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.hours != 0 || self.minutes != 0 || self.seconds != 0 || self.frames != 0
|
||||
self.hours != 0
|
||||
|| self.minutes != 0
|
||||
|| self.seconds != 0
|
||||
|| self.frames != 0
|
||||
|| self.milliseconds != 0
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -33,6 +39,14 @@ pub struct Config {
|
|||
pub hardware_offset_ms: i64,
|
||||
#[serde(default)]
|
||||
pub timeturner_offset: TimeturnerOffset,
|
||||
#[serde(default = "default_nudge_ms")]
|
||||
pub default_nudge_ms: i64,
|
||||
#[serde(default)]
|
||||
pub auto_sync_enabled: bool,
|
||||
}
|
||||
|
||||
fn default_nudge_ms() -> i64 {
|
||||
2 // Default nudge is 2ms
|
||||
}
|
||||
|
||||
impl Config {
|
||||
|
|
@ -46,10 +60,11 @@ impl Config {
|
|||
return Self::default();
|
||||
}
|
||||
serde_yaml::from_str(&contents).unwrap_or_else(|e| {
|
||||
eprintln!("Failed to parse config, using default: {}", e);
|
||||
log::warn!("Failed to parse config, using default: {}", e);
|
||||
Self::default()
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
|
|
@ -57,13 +72,35 @@ impl Default for Config {
|
|||
Self {
|
||||
hardware_offset_ms: 0,
|
||||
timeturner_offset: TimeturnerOffset::default(),
|
||||
default_nudge_ms: default_nudge_ms(),
|
||||
auto_sync_enabled: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_config(path: &str, config: &Config) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let contents = serde_yaml::to_string(config)?;
|
||||
fs::write(path, contents)?;
|
||||
let mut s = String::new();
|
||||
s.push_str("# Hardware offset in milliseconds for correcting capture latency.\n");
|
||||
s.push_str(&format!("hardwareOffsetMs: {}\n\n", config.hardware_offset_ms));
|
||||
|
||||
s.push_str("# Enable automatic clock synchronization.\n");
|
||||
s.push_str("# When enabled, the system will perform an initial full sync, then periodically\n");
|
||||
s.push_str("# nudge the clock to keep it aligned with the LTC source.\n");
|
||||
s.push_str(&format!("autoSyncEnabled: {}\n\n", config.auto_sync_enabled));
|
||||
|
||||
s.push_str("# Default nudge in milliseconds for adjtimex control.\n");
|
||||
s.push_str(&format!("defaultNudgeMs: {}\n\n", config.default_nudge_ms));
|
||||
|
||||
s.push_str("# Time-turning offsets. All values are added to the incoming LTC time.\n");
|
||||
s.push_str("# These can be positive or negative.\n");
|
||||
s.push_str("timeturnerOffset:\n");
|
||||
s.push_str(&format!(" hours: {}\n", config.timeturner_offset.hours));
|
||||
s.push_str(&format!(" minutes: {}\n", config.timeturner_offset.minutes));
|
||||
s.push_str(&format!(" seconds: {}\n", config.timeturner_offset.seconds));
|
||||
s.push_str(&format!(" frames: {}\n", config.timeturner_offset.frames));
|
||||
s.push_str(&format!(" milliseconds: {}\n", config.timeturner_offset.milliseconds));
|
||||
|
||||
fs::write(path, s)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -82,7 +119,7 @@ pub fn watch_config(path: &str) -> Arc<Mutex<Config>> {
|
|||
let new_cfg = Config::load(&watch_path_for_cb);
|
||||
let mut cfg = config_for_cb.lock().unwrap();
|
||||
*cfg = new_cfg;
|
||||
eprintln!("🔄 Reloaded config.yml: {:?}", *cfg);
|
||||
log::info!("🔄 Reloaded config.yml: {:?}", *cfg);
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
52
src/logger.rs
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
use chrono::Local;
|
||||
use log::{LevelFilter, Log, Metadata, Record};
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
const MAX_LOG_ENTRIES: usize = 100;
|
||||
|
||||
struct RingBufferLogger {
|
||||
buffer: Arc<Mutex<VecDeque<String>>>,
|
||||
}
|
||||
|
||||
impl Log for RingBufferLogger {
|
||||
fn enabled(&self, metadata: &Metadata) -> bool {
|
||||
metadata.level() <= LevelFilter::Info
|
||||
}
|
||||
|
||||
fn log(&self, record: &Record) {
|
||||
if self.enabled(record.metadata()) {
|
||||
let msg = format!(
|
||||
"{} [{}] {}",
|
||||
Local::now().format("%Y-%m-%d %H:%M:%S"),
|
||||
record.level(),
|
||||
record.args()
|
||||
);
|
||||
|
||||
// Also print to stderr for console/daemon logging
|
||||
eprintln!("{}", msg);
|
||||
|
||||
let mut buffer = self.buffer.lock().unwrap();
|
||||
if buffer.len() == MAX_LOG_ENTRIES {
|
||||
buffer.pop_front();
|
||||
}
|
||||
buffer.push_back(msg);
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
pub fn setup_logger() -> Arc<Mutex<VecDeque<String>>> {
|
||||
let buffer = Arc::new(Mutex::new(VecDeque::with_capacity(MAX_LOG_ENTRIES)));
|
||||
let logger = RingBufferLogger {
|
||||
buffer: buffer.clone(),
|
||||
};
|
||||
|
||||
// We use `set_boxed_logger` to install our custom logger.
|
||||
// The `log` crate will then route all log messages to it.
|
||||
log::set_boxed_logger(Box::new(logger)).expect("Failed to set logger");
|
||||
log::set_max_level(LevelFilter::Info);
|
||||
|
||||
buffer
|
||||
}
|
||||
271
src/main.rs
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
mod api;
|
||||
mod config;
|
||||
mod logger;
|
||||
mod serial_input;
|
||||
mod sync_logic;
|
||||
mod system;
|
||||
|
|
@ -14,7 +15,7 @@ use crate::sync_logic::LtcState;
|
|||
use crate::ui::start_ui;
|
||||
use clap::Parser;
|
||||
use daemonize::Daemonize;
|
||||
use env_logger;
|
||||
use serialport;
|
||||
|
||||
use std::{
|
||||
fs,
|
||||
|
|
@ -35,6 +36,8 @@ struct Args {
|
|||
enum Command {
|
||||
/// Run as a background daemon providing a web UI.
|
||||
Daemon,
|
||||
/// Stop the running daemon process.
|
||||
Kill,
|
||||
}
|
||||
|
||||
/// Default config content, embedded in the binary.
|
||||
|
|
@ -42,6 +45,14 @@ const DEFAULT_CONFIG: &str = r#"
|
|||
# Hardware offset in milliseconds for correcting capture latency.
|
||||
hardwareOffsetMs: 20
|
||||
|
||||
# Enable automatic clock synchronization.
|
||||
# When enabled, the system will perform an initial full sync, then periodically
|
||||
# nudge the clock to keep it aligned with the LTC source.
|
||||
autoSyncEnabled: false
|
||||
|
||||
# Default nudge in milliseconds for adjtimex control.
|
||||
defaultNudgeMs: 2
|
||||
|
||||
# Time-turning offsets. All values are added to the incoming LTC time.
|
||||
# These can be positive or negative.
|
||||
timeturnerOffset:
|
||||
|
|
@ -49,6 +60,7 @@ timeturnerOffset:
|
|||
minutes: 0
|
||||
seconds: 0
|
||||
frames: 0
|
||||
milliseconds: 0
|
||||
"#;
|
||||
|
||||
/// If no `config.yml` exists alongside the binary, write out the default.
|
||||
|
|
@ -57,20 +69,40 @@ fn ensure_config() {
|
|||
if !p.exists() {
|
||||
fs::write(p, DEFAULT_CONFIG.trim())
|
||||
.expect("Failed to write default config.yml");
|
||||
eprintln!("⚙️ Emitted default config.yml");
|
||||
log::info!("⚙️ Emitted default config.yml");
|
||||
}
|
||||
}
|
||||
|
||||
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::Daemon) = &args.command {
|
||||
println!("🚀 Starting daemon...");
|
||||
if let Some(command) = &args.command {
|
||||
match command {
|
||||
Command::Daemon => {
|
||||
log::info!("🚀 Starting daemon...");
|
||||
|
||||
// Create files for stdout and stderr in the current directory
|
||||
let stdout = fs::File::create("daemon.out").expect("Could not create daemon.out");
|
||||
let stderr = fs::File::create("daemon.err").expect("Could not create daemon.err");
|
||||
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
|
||||
|
|
@ -81,11 +113,48 @@ async fn main() {
|
|||
match daemonize.start() {
|
||||
Ok(_) => { /* Process is now daemonized */ }
|
||||
Err(e) => {
|
||||
eprintln!("Error daemonizing: {}", 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 🔄 Ensure there's always a config.yml present
|
||||
ensure_config();
|
||||
|
|
@ -99,13 +168,23 @@ async fn main() {
|
|||
// 3️⃣ Shared state for UI and serial reader
|
||||
let ltc_state = Arc::new(Mutex::new(LtcState::new()));
|
||||
|
||||
// 4️⃣ Spawn the serial reader thread
|
||||
// 4️⃣ Find serial port and spawn the serial reader thread
|
||||
let serial_port_path = match find_serial_port() {
|
||||
Some(port) => port,
|
||||
None => {
|
||||
log::error!("❌ No serial port found. Please connect the Teensy device.");
|
||||
return;
|
||||
}
|
||||
};
|
||||
log::info!("Found serial port: {}", serial_port_path);
|
||||
|
||||
{
|
||||
let tx_clone = tx.clone();
|
||||
let state_clone = ltc_state.clone();
|
||||
let port_clone = serial_port_path.clone();
|
||||
thread::spawn(move || {
|
||||
start_serial_thread(
|
||||
"/dev/ttyACM0",
|
||||
&port_clone,
|
||||
115200,
|
||||
tx_clone,
|
||||
state_clone,
|
||||
|
|
@ -114,53 +193,146 @@ async fn main() {
|
|||
});
|
||||
}
|
||||
|
||||
// 5️⃣ Spawn UI or setup daemon logging
|
||||
// 5️⃣ Spawn UI or setup daemon logging. The web service is only started
|
||||
// when running as a daemon. The TUI is for interactive foreground use.
|
||||
if args.command.is_none() {
|
||||
println!("🔧 Watching config.yml...");
|
||||
println!("🚀 Serial thread launched");
|
||||
println!("🖥️ UI thread launched");
|
||||
// --- Interactive TUI Mode ---
|
||||
log::info!("🔧 Watching config.yml...");
|
||||
log::info!("🚀 Serial thread launched");
|
||||
log::info!("🖥️ UI thread launched");
|
||||
let ui_state = ltc_state.clone();
|
||||
let config_clone = config.clone();
|
||||
let port = "/dev/ttyACM0".to_string();
|
||||
let port = serial_port_path;
|
||||
thread::spawn(move || {
|
||||
start_ui(ui_state, port, config_clone);
|
||||
});
|
||||
} else {
|
||||
// In daemon mode, we initialize env_logger.
|
||||
// This will log to stdout, and the systemd service will capture it.
|
||||
// The RUST_LOG env var controls the log level (e.g., RUST_LOG=info).
|
||||
env_logger::init();
|
||||
// --- 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.
|
||||
log::info!("🚀 Starting TimeTurner daemon...");
|
||||
}
|
||||
|
||||
// 6️⃣ Set up a LocalSet for the API server and main loop
|
||||
let local = LocalSet::new();
|
||||
local
|
||||
.run_until(async move {
|
||||
// 7️⃣ Spawn the API server thread
|
||||
// 6️⃣ Spawn the auto-sync thread
|
||||
{
|
||||
let api_state = ltc_state.clone();
|
||||
let config_clone = config.clone();
|
||||
task::spawn_local(async move {
|
||||
if let Err(e) = start_api_server(api_state, config_clone).await {
|
||||
eprintln!("API server error: {}", e);
|
||||
let sync_state = ltc_state.clone();
|
||||
let sync_config = config.clone();
|
||||
thread::spawn(move || {
|
||||
// Wait for the first LTC frame to arrive
|
||||
loop {
|
||||
if sync_state.lock().unwrap().latest.is_some() {
|
||||
log::info!("Auto-sync: Initial LTC frame detected.");
|
||||
break;
|
||||
}
|
||||
thread::sleep(std::time::Duration::from_secs(1));
|
||||
}
|
||||
|
||||
// Initial sync
|
||||
{
|
||||
let state = sync_state.lock().unwrap();
|
||||
let config = sync_config.lock().unwrap();
|
||||
if config.auto_sync_enabled {
|
||||
if let Some(frame) = &state.latest {
|
||||
log::info!("Auto-sync: Performing initial full sync.");
|
||||
if system::trigger_sync(frame, &config).is_ok() {
|
||||
log::info!("Auto-sync: Initial sync successful.");
|
||||
} else {
|
||||
log::error!("Auto-sync: Initial sync failed.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
thread::sleep(std::time::Duration::from_secs(10));
|
||||
|
||||
// Main auto-sync loop
|
||||
loop {
|
||||
{
|
||||
let state = sync_state.lock().unwrap();
|
||||
let config = sync_config.lock().unwrap();
|
||||
|
||||
if config.auto_sync_enabled && state.latest.is_some() {
|
||||
let delta = state.get_ewma_clock_delta();
|
||||
let frame = state.latest.as_ref().unwrap();
|
||||
|
||||
if delta.abs() > 40 {
|
||||
log::info!("Auto-sync: Delta > 40ms ({}ms), performing full sync.", delta);
|
||||
if system::trigger_sync(frame, &config).is_ok() {
|
||||
log::info!("Auto-sync: Full sync successful.");
|
||||
} else {
|
||||
log::error!("Auto-sync: Full sync failed.");
|
||||
}
|
||||
} else if delta.abs() >= 1 {
|
||||
// nudge_clock takes microseconds. A positive delta means clock is
|
||||
// ahead, so we need a negative nudge.
|
||||
let nudge_us = -delta * 1000;
|
||||
log::info!("Auto-sync: Delta is {}ms, nudging clock by {}us.", delta, nudge_us);
|
||||
if system::nudge_clock(nudge_us).is_ok() {
|
||||
log::info!("Auto-sync: Clock nudge successful.");
|
||||
} else {
|
||||
log::error!("Auto-sync: Clock nudge failed.");
|
||||
}
|
||||
}
|
||||
}
|
||||
} // locks released here
|
||||
|
||||
thread::sleep(std::time::Duration::from_secs(10));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 8️⃣ Keep main thread alive
|
||||
// 7️⃣ Set up a LocalSet for the API server and main loop
|
||||
let local = LocalSet::new();
|
||||
local
|
||||
.run_until(async move {
|
||||
// 8️⃣ Spawn the API server task.
|
||||
// This server provides the JSON API and serves the static web UI files
|
||||
// from the `static/` directory. It runs in both TUI and daemon modes,
|
||||
// but is primarily for the web UI used in daemon mode.
|
||||
{
|
||||
let api_state = ltc_state.clone();
|
||||
let config_clone = config.clone();
|
||||
let log_buffer_clone = log_buffer.clone();
|
||||
task::spawn_local(async move {
|
||||
if let Err(e) =
|
||||
start_api_server(api_state, config_clone, log_buffer_clone).await
|
||||
{
|
||||
log::error!("API server error: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 9️⃣ Main logic loop: process frames from serial and update state
|
||||
let loop_state = ltc_state.clone();
|
||||
let loop_config = config.clone();
|
||||
let logic_task = task::spawn_blocking(move || {
|
||||
for frame in rx {
|
||||
let mut state = loop_state.lock().unwrap();
|
||||
let config = loop_config.lock().unwrap();
|
||||
|
||||
// Only calculate delta for LOCK frames
|
||||
if frame.status == "LOCK" {
|
||||
let target_time = system::calculate_target_time(&frame, &config);
|
||||
let arrival_time_local: chrono::DateTime<chrono::Local> =
|
||||
frame.timestamp.with_timezone(&chrono::Local);
|
||||
let delta = arrival_time_local.signed_duration_since(target_time);
|
||||
state.record_and_update_ewma_clock_delta(delta.num_milliseconds());
|
||||
}
|
||||
|
||||
state.update(frame);
|
||||
}
|
||||
});
|
||||
|
||||
// 1️⃣0️⃣ Keep main thread alive
|
||||
if args.command.is_some() {
|
||||
// In daemon mode, wait forever.
|
||||
// In daemon mode, wait forever. The logic_task runs in the background.
|
||||
std::future::pending::<()>().await;
|
||||
} else {
|
||||
// In TUI mode, block on the channel.
|
||||
println!("📡 Main thread entering loop...");
|
||||
let _ = task::spawn_blocking(move || {
|
||||
for _frame in rx {
|
||||
// no-op
|
||||
}
|
||||
})
|
||||
.await;
|
||||
// In TUI mode, block until the logic_task finishes (e.g. serial port disconnects)
|
||||
// This keeps the TUI running.
|
||||
log::info!("📡 Main thread entering loop...");
|
||||
let _ = logic_task.await;
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
|
@ -172,18 +344,35 @@ mod tests {
|
|||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
/// RAII guard to ensure config file is cleaned up after test.
|
||||
struct ConfigGuard;
|
||||
/// RAII guard to manage config file during tests.
|
||||
/// It saves the original content of `config.yml` if it exists,
|
||||
/// and restores it when the guard goes out of scope.
|
||||
/// If the file didn't exist, it's removed.
|
||||
struct ConfigGuard {
|
||||
original_content: Option<String>,
|
||||
}
|
||||
|
||||
impl ConfigGuard {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
original_content: fs::read_to_string("config.yml").ok(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ConfigGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Some(content) = &self.original_content {
|
||||
fs::write("config.yml", content).expect("Failed to restore config.yml");
|
||||
} else {
|
||||
let _ = fs::remove_file("config.yml");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ensure_config() {
|
||||
let _guard = ConfigGuard; // Cleanup when _guard goes out of scope.
|
||||
let _guard = ConfigGuard::new(); // Cleanup when _guard goes out of scope.
|
||||
|
||||
// --- Test 1: File creation ---
|
||||
// Pre-condition: config.yml does not exist.
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ pub fn start_serial_thread(
|
|||
|
||||
let reader = std::io::BufReader::new(port);
|
||||
let re = Regex::new(
|
||||
r"\[(LOCK|FREE)\]\s+(\d{2}):(\d{2}):(\d{2})[:;](\d{2})\s+\|\s+([\d.]+)fps",
|
||||
r"\[(LOCK|FREE)\]\s+(\d{2}):(\d{2}):(\d{2})([:;])(\d{2})\s+\|\s+([\d.]+)fps",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
|
|
@ -60,11 +60,12 @@ mod tests {
|
|||
use super::*;
|
||||
use std::sync::mpsc;
|
||||
use crate::sync_logic::LtcState;
|
||||
use num_rational::Ratio;
|
||||
use regex::Regex;
|
||||
|
||||
fn get_ltc_regex() -> Regex {
|
||||
Regex::new(
|
||||
r"\[(LOCK|FREE)\]\s+(\d{2}):(\d{2}):(\d{2})[:;](\d{2})\s+\|\s+([\d.]+)fps",
|
||||
r"\[(LOCK|FREE)\]\s+(\d{2}):(\d{2}):(\d{2})([:;])(\d{2})\s+\|\s+([\d.]+)fps",
|
||||
).unwrap()
|
||||
}
|
||||
|
||||
|
|
@ -119,7 +120,7 @@ mod tests {
|
|||
assert_eq!(st.free_count, 1);
|
||||
let received_frame = rx.try_recv().unwrap();
|
||||
assert_eq!(received_frame.status, "FREE");
|
||||
assert_eq!(received_frame.frame_rate, 29.97);
|
||||
assert_eq!(received_frame.frame_rate, Ratio::new(30000, 1001));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -1,8 +1,22 @@
|
|||
use crate::config::Config;
|
||||
use chrono::{DateTime, Local, Timelike, Utc};
|
||||
use num_rational::Ratio;
|
||||
use regex::Captures;
|
||||
use std::collections::VecDeque;
|
||||
|
||||
const EWMA_ALPHA: f64 = 0.1;
|
||||
|
||||
fn get_frame_rate_ratio(rate_str: &str) -> Option<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,
|
||||
|
|
@ -10,7 +24,8 @@ pub struct LtcFrame {
|
|||
pub minutes: u32,
|
||||
pub seconds: u32,
|
||||
pub frames: u32,
|
||||
pub frame_rate: f64,
|
||||
pub is_drop_frame: bool,
|
||||
pub frame_rate: Ratio<i64>,
|
||||
pub timestamp: DateTime<Utc>, // arrival stamp
|
||||
}
|
||||
|
||||
|
|
@ -21,8 +36,9 @@ impl LtcFrame {
|
|||
hours: caps[2].parse().ok()?,
|
||||
minutes: caps[3].parse().ok()?,
|
||||
seconds: caps[4].parse().ok()?,
|
||||
frames: caps[5].parse().ok()?,
|
||||
frame_rate: caps[6].parse().ok()?,
|
||||
is_drop_frame: &caps[5] == ";",
|
||||
frames: caps[6].parse().ok()?,
|
||||
frame_rate: get_frame_rate_ratio(&caps[7])?,
|
||||
timestamp,
|
||||
})
|
||||
}
|
||||
|
|
@ -42,8 +58,8 @@ pub struct LtcState {
|
|||
pub free_count: u32,
|
||||
/// Stores the last up-to-20 raw offset measurements in ms.
|
||||
pub offset_history: VecDeque<i64>,
|
||||
/// Stores the last up-to-20 timecode Δ measurements in ms.
|
||||
pub clock_delta_history: VecDeque<i64>,
|
||||
/// EWMA of clock delta.
|
||||
pub ewma_clock_delta: Option<f64>,
|
||||
pub last_match_status: String,
|
||||
pub last_match_check: i64,
|
||||
}
|
||||
|
|
@ -55,7 +71,7 @@ impl LtcState {
|
|||
lock_count: 0,
|
||||
free_count: 0,
|
||||
offset_history: VecDeque::with_capacity(20),
|
||||
clock_delta_history: VecDeque::with_capacity(20),
|
||||
ewma_clock_delta: None,
|
||||
last_match_status: "UNKNOWN".into(),
|
||||
last_match_check: 0,
|
||||
}
|
||||
|
|
@ -69,12 +85,14 @@ impl LtcState {
|
|||
self.offset_history.push_back(offset_ms);
|
||||
}
|
||||
|
||||
/// Record one timecode Δ in ms.
|
||||
pub fn record_clock_delta(&mut self, delta_ms: i64) {
|
||||
if self.clock_delta_history.len() == 20 {
|
||||
self.clock_delta_history.pop_front();
|
||||
/// Update EWMA of clock delta.
|
||||
pub fn record_and_update_ewma_clock_delta(&mut self, delta_ms: i64) {
|
||||
let new_delta = delta_ms as f64;
|
||||
if let Some(current_ewma) = self.ewma_clock_delta {
|
||||
self.ewma_clock_delta = Some(EWMA_ALPHA * new_delta + (1.0 - EWMA_ALPHA) * current_ewma);
|
||||
} else {
|
||||
self.ewma_clock_delta = Some(new_delta);
|
||||
}
|
||||
self.clock_delta_history.push_back(delta_ms);
|
||||
}
|
||||
|
||||
/// Clear all stored jitter measurements.
|
||||
|
|
@ -82,11 +100,6 @@ impl LtcState {
|
|||
self.offset_history.clear();
|
||||
}
|
||||
|
||||
/// Clear all stored timecode Δ measurements.
|
||||
pub fn clear_clock_deltas(&mut self) {
|
||||
self.clock_delta_history.clear();
|
||||
}
|
||||
|
||||
/// Update LOCK/FREE counts and timecode-match status every 5 s.
|
||||
pub fn update(&mut self, frame: LtcFrame) {
|
||||
match frame.status.as_str() {
|
||||
|
|
@ -108,7 +121,7 @@ impl LtcState {
|
|||
"FREE" => {
|
||||
self.free_count += 1;
|
||||
self.clear_offsets();
|
||||
self.clear_clock_deltas();
|
||||
self.ewma_clock_delta = None;
|
||||
self.last_match_status = "UNKNOWN".into();
|
||||
}
|
||||
_ => {}
|
||||
|
|
@ -130,30 +143,17 @@ impl LtcState {
|
|||
/// Convert average jitter into frames (rounded).
|
||||
pub fn average_frames(&self) -> i64 {
|
||||
if let Some(frame) = &self.latest {
|
||||
let ms_per_frame = 1000.0 / frame.frame_rate;
|
||||
(self.average_jitter() as f64 / ms_per_frame).round() as i64
|
||||
let jitter_ms_ratio = Ratio::new(self.average_jitter(), 1);
|
||||
let frames_ratio = jitter_ms_ratio * frame.frame_rate / Ratio::new(1000, 1);
|
||||
frames_ratio.round().to_integer()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Median timecode Δ over stored history, in ms.
|
||||
pub fn average_clock_delta(&self) -> i64 {
|
||||
if self.clock_delta_history.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let mut sorted_deltas: Vec<i64> = self.clock_delta_history.iter().cloned().collect();
|
||||
sorted_deltas.sort_unstable();
|
||||
|
||||
let mid = sorted_deltas.len() / 2;
|
||||
if sorted_deltas.len() % 2 == 0 {
|
||||
// Even number of elements, average the two middle ones
|
||||
(sorted_deltas[mid - 1] + sorted_deltas[mid]) / 2
|
||||
} else {
|
||||
// Odd number of elements, return the middle one
|
||||
sorted_deltas[mid]
|
||||
}
|
||||
/// Get EWMA of clock delta, in ms.
|
||||
pub fn get_ewma_clock_delta(&self) -> i64 {
|
||||
self.ewma_clock_delta.map_or(0, |v| v.round() as i64)
|
||||
}
|
||||
|
||||
/// Percentage of samples seen in LOCK state versus total.
|
||||
|
|
@ -207,7 +207,8 @@ mod tests {
|
|||
minutes: m,
|
||||
seconds: s,
|
||||
frames: 0,
|
||||
frame_rate: 25.0,
|
||||
is_drop_frame: false,
|
||||
frame_rate: Ratio::new(25, 1),
|
||||
timestamp: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
|
@ -326,35 +327,28 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn test_average_clock_delta_is_median() {
|
||||
fn test_ewma_clock_delta() {
|
||||
let mut state = LtcState::new();
|
||||
assert_eq!(state.get_ewma_clock_delta(), 0);
|
||||
|
||||
// Establish a stable set of values
|
||||
for _ in 0..19 {
|
||||
state.record_clock_delta(2);
|
||||
}
|
||||
state.record_clock_delta(100); // Add an outlier
|
||||
// First value initializes the EWMA
|
||||
state.record_and_update_ewma_clock_delta(100);
|
||||
assert_eq!(state.get_ewma_clock_delta(), 100);
|
||||
|
||||
// With 19 `2`s and one `100`, the median should still be `2`.
|
||||
// The simple average would be (19*2 + 100) / 20 = 138 / 20 = 6.
|
||||
assert_eq!(
|
||||
state.average_clock_delta(),
|
||||
2,
|
||||
"Median should ignore the outlier"
|
||||
);
|
||||
// Second value moves it
|
||||
state.record_and_update_ewma_clock_delta(200);
|
||||
// 0.1 * 200 + 0.9 * 100 = 20 + 90 = 110
|
||||
assert_eq!(state.get_ewma_clock_delta(), 110);
|
||||
|
||||
// Test with an even number of elements
|
||||
state.clear_clock_deltas();
|
||||
state.record_clock_delta(1);
|
||||
state.record_clock_delta(2);
|
||||
state.record_clock_delta(3);
|
||||
state.record_clock_delta(100);
|
||||
// sorted: [1, 2, 3, 100]. mid two are 2, 3. average is (2+3)/2 = 2.
|
||||
assert_eq!(
|
||||
state.average_clock_delta(),
|
||||
2,
|
||||
"Median of even numbers should be correct"
|
||||
);
|
||||
// Third value
|
||||
state.record_and_update_ewma_clock_delta(100);
|
||||
// 0.1 * 100 + 0.9 * 110 = 10 + 99 = 109
|
||||
assert_eq!(state.get_ewma_clock_delta(), 109);
|
||||
|
||||
// Reset on FREE frame
|
||||
state.update(get_test_frame("FREE", 0, 0, 0));
|
||||
assert_eq!(state.get_ewma_clock_delta(), 0);
|
||||
assert!(state.ewma_clock_delta.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -369,8 +363,12 @@ mod tests {
|
|||
assert_eq!(get_sync_status(-9, &config), "CLOCK BEHIND");
|
||||
assert_eq!(get_sync_status(-100, &config), "CLOCK BEHIND");
|
||||
|
||||
// Test TIMETURNING status
|
||||
config.timeturner_offset = TimeturnerOffset { hours: 1, minutes: 0, seconds: 0, frames: 0 };
|
||||
// Test auto-sync status
|
||||
config.auto_sync_enabled = true;
|
||||
assert_eq!(get_sync_status(0, &config), "IN SYNC");
|
||||
|
||||
// Test TIMETURNING status takes precedence
|
||||
config.timeturner_offset = TimeturnerOffset { hours: 1, minutes: 0, seconds: 0, frames: 0, milliseconds: 0 };
|
||||
assert_eq!(get_sync_status(0, &config), "TIMETURNING");
|
||||
assert_eq!(get_sync_status(100, &config), "TIMETURNING");
|
||||
}
|
||||
|
|
|
|||
104
src/system.rs
|
|
@ -1,6 +1,7 @@
|
|||
use crate::config::Config;
|
||||
use crate::sync_logic::LtcFrame;
|
||||
use chrono::{DateTime, Duration as ChronoDuration, Local, NaiveTime, TimeZone};
|
||||
use chrono::{DateTime, Duration as ChronoDuration, Local, TimeZone};
|
||||
use num_rational::Ratio;
|
||||
use std::process::Command;
|
||||
|
||||
/// Check if Chrony is active
|
||||
|
|
@ -39,11 +40,24 @@ pub fn ntp_service_toggle(start: bool) {
|
|||
|
||||
pub fn calculate_target_time(frame: &LtcFrame, config: &Config) -> DateTime<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");
|
||||
|
||||
let naive_dt = today_local.and_time(timecode);
|
||||
// Total seconds from timecode components
|
||||
let timecode_secs =
|
||||
frame.hours as i64 * 3600 + frame.minutes as i64 * 60 + frame.seconds as i64;
|
||||
|
||||
// Timecode is always treated as wall-clock time. NDF scaling is not applied
|
||||
// as the LTC source appears to be pre-compensated.
|
||||
let total_duration_secs =
|
||||
Ratio::new(timecode_secs, 1) + Ratio::new(frame.frames as i64, 1) / frame.frame_rate;
|
||||
|
||||
// Convert to milliseconds
|
||||
let total_ms = (total_duration_secs * Ratio::new(1000, 1))
|
||||
.round()
|
||||
.to_integer();
|
||||
|
||||
let naive_midnight = today_local.and_hms_opt(0, 0, 0).unwrap();
|
||||
let naive_dt = naive_midnight + ChronoDuration::milliseconds(total_ms);
|
||||
|
||||
let mut dt_local = Local
|
||||
.from_local_datetime(&naive_dt)
|
||||
.single()
|
||||
|
|
@ -56,8 +70,9 @@ 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 = (offset.frames as f64 / frame.frame_rate * 1000.0).round() as i64;
|
||||
dt_local + ChronoDuration::milliseconds(frame_offset_ms)
|
||||
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();
|
||||
dt_local + ChronoDuration::milliseconds(frame_offset_ms + offset.milliseconds)
|
||||
}
|
||||
|
||||
pub fn trigger_sync(frame: &LtcFrame, config: &Config) -> Result<String, ()> {
|
||||
|
|
@ -104,11 +119,67 @@ pub fn trigger_sync(frame: &LtcFrame, config: &Config) -> Result<String, ()> {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn nudge_clock(microseconds: i64) -> Result<(), ()> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let success = Command::new("sudo")
|
||||
.arg("adjtimex")
|
||||
.arg("--singleshot")
|
||||
.arg(microseconds.to_string())
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
|
||||
if success {
|
||||
log::info!("Nudged clock by {} us", microseconds);
|
||||
Ok(())
|
||||
} else {
|
||||
log::error!("Failed to nudge clock with adjtimex");
|
||||
Err(())
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let _ = microseconds;
|
||||
log::warn!("Clock nudging is only supported on Linux.");
|
||||
Err(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_date(date: &str) -> Result<(), ()> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let datetime_str = format!("{} 10:00:00", date);
|
||||
let success = Command::new("sudo")
|
||||
.arg("date")
|
||||
.arg("--set")
|
||||
.arg(&datetime_str)
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
|
||||
if success {
|
||||
log::info!("Set system date and time to {}", datetime_str);
|
||||
Ok(())
|
||||
} else {
|
||||
log::error!("Failed to set system date and time");
|
||||
Err(())
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let _ = date;
|
||||
log::warn!("Date setting is only supported on Linux.");
|
||||
Err(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::TimeturnerOffset;
|
||||
use chrono::{Timelike, Utc};
|
||||
use num_rational::Ratio;
|
||||
|
||||
// Helper to create a test frame
|
||||
fn get_test_frame(h: u32, m: u32, s: u32, f: u32) -> LtcFrame {
|
||||
|
|
@ -118,7 +189,8 @@ mod tests {
|
|||
minutes: m,
|
||||
seconds: s,
|
||||
frames: f,
|
||||
frame_rate: 25.0,
|
||||
is_drop_frame: false,
|
||||
frame_rate: Ratio::new(25, 1),
|
||||
timestamp: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
|
@ -150,6 +222,7 @@ mod tests {
|
|||
minutes: 5,
|
||||
seconds: 10,
|
||||
frames: 12, // 12 frames at 25fps is 480ms
|
||||
milliseconds: 20,
|
||||
};
|
||||
|
||||
let target_time = calculate_target_time(&frame, &config);
|
||||
|
|
@ -157,8 +230,8 @@ mod tests {
|
|||
assert_eq!(target_time.hour(), 11);
|
||||
assert_eq!(target_time.minute(), 25);
|
||||
assert_eq!(target_time.second(), 40);
|
||||
// 480ms
|
||||
assert_eq!(target_time.nanosecond(), 480_000_000);
|
||||
// 480ms + 20ms = 500ms
|
||||
assert_eq!(target_time.nanosecond(), 500_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -170,13 +243,20 @@ mod tests {
|
|||
minutes: -5,
|
||||
seconds: -10,
|
||||
frames: -12, // -480ms
|
||||
milliseconds: -80,
|
||||
};
|
||||
|
||||
let target_time = calculate_target_time(&frame, &config);
|
||||
|
||||
assert_eq!(target_time.hour(), 9);
|
||||
assert_eq!(target_time.minute(), 15);
|
||||
assert_eq!(target_time.second(), 20);
|
||||
assert_eq!(target_time.nanosecond(), 0);
|
||||
assert_eq!(target_time.second(), 19);
|
||||
assert_eq!(target_time.nanosecond(), 920_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nudge_clock_on_non_linux() {
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
assert!(nudge_clock(1000).is_err());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
74
src/ui.rs
|
|
@ -9,7 +9,6 @@ use std::collections::VecDeque;
|
|||
|
||||
use chrono::{
|
||||
DateTime, Local, Timelike, Utc,
|
||||
NaiveTime, TimeZone, Duration as ChronoDuration,
|
||||
};
|
||||
use crossterm::{
|
||||
cursor::{Hide, MoveTo, Show},
|
||||
|
|
@ -20,9 +19,11 @@ use crossterm::{
|
|||
};
|
||||
|
||||
use crate::config::Config;
|
||||
use get_if_addrs::get_if_addrs;
|
||||
use crate::sync_logic::{get_jitter_status, get_sync_status, LtcState};
|
||||
use crate::system;
|
||||
use get_if_addrs::get_if_addrs;
|
||||
use num_rational::Ratio;
|
||||
use num_traits::ToPrimitive;
|
||||
|
||||
|
||||
pub fn start_ui(
|
||||
|
|
@ -35,7 +36,6 @@ pub fn start_ui(
|
|||
terminal::enable_raw_mode().unwrap();
|
||||
|
||||
let mut logs: VecDeque<String> = VecDeque::with_capacity(10);
|
||||
let mut out_of_sync_since: Option<Instant> = None;
|
||||
let mut last_delta_update = Instant::now() - Duration::from_secs(1);
|
||||
let mut cached_delta_ms: i64 = 0;
|
||||
let mut cached_delta_frames: i64 = 0;
|
||||
|
|
@ -54,7 +54,7 @@ pub fn start_ui(
|
|||
.map(|ifa| ifa.ip().to_string())
|
||||
.collect();
|
||||
|
||||
// 3️⃣ jitter + Δ
|
||||
// 3️⃣ jitter
|
||||
{
|
||||
let mut st = state.lock().unwrap();
|
||||
if let Some(frame) = st.latest.clone() {
|
||||
|
|
@ -64,33 +64,6 @@ pub fn start_ui(
|
|||
let raw = (now_utc - frame.timestamp).num_milliseconds();
|
||||
let measured = raw - hw_offset_ms;
|
||||
st.record_offset(measured);
|
||||
|
||||
// Δ = system clock - LTC timecode (use LOCAL time, with offset)
|
||||
let today_local = Local::now().date_naive();
|
||||
let ms = ((frame.frames as f64 / frame.frame_rate) * 1000.0).round() as u32;
|
||||
let tc_naive = NaiveTime::from_hms_milli_opt(
|
||||
frame.hours, frame.minutes, frame.seconds, ms,
|
||||
).expect("Invalid LTC timecode");
|
||||
let naive_dt_local = today_local.and_time(tc_naive);
|
||||
let mut dt_local = Local
|
||||
.from_local_datetime(&naive_dt_local)
|
||||
.single()
|
||||
.expect("Invalid local time");
|
||||
|
||||
// Apply timeturner offset before calculating delta
|
||||
let offset = &cfg.timeturner_offset;
|
||||
dt_local = dt_local
|
||||
+ ChronoDuration::hours(offset.hours)
|
||||
+ ChronoDuration::minutes(offset.minutes)
|
||||
+ ChronoDuration::seconds(offset.seconds);
|
||||
let frame_offset_ms = (offset.frames as f64 / frame.frame_rate * 1000.0).round() as i64;
|
||||
dt_local = dt_local + ChronoDuration::milliseconds(frame_offset_ms);
|
||||
|
||||
let delta_ms = (Local::now() - dt_local).num_milliseconds();
|
||||
st.record_clock_delta(delta_ms);
|
||||
} else {
|
||||
st.clear_offsets();
|
||||
st.clear_clock_deltas();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -103,7 +76,7 @@ pub fn start_ui(
|
|||
st.average_frames(),
|
||||
st.timecode_match().to_string(),
|
||||
st.lock_ratio(),
|
||||
st.average_clock_delta(),
|
||||
st.get_ewma_clock_delta(),
|
||||
)
|
||||
};
|
||||
|
||||
|
|
@ -111,8 +84,9 @@ pub fn start_ui(
|
|||
if last_delta_update.elapsed() >= Duration::from_secs(1) {
|
||||
cached_delta_ms = avg_delta;
|
||||
if let Some(frame) = &state.lock().unwrap().latest {
|
||||
let frame_ms = 1000.0 / frame.frame_rate;
|
||||
cached_delta_frames = ((avg_delta as f64 / frame_ms).round()) as i64;
|
||||
let delta_ms_ratio = Ratio::new(avg_delta, 1);
|
||||
let frames_ratio = delta_ms_ratio * frame.frame_rate / Ratio::new(1000, 1);
|
||||
cached_delta_frames = frames_ratio.round().to_integer();
|
||||
} else {
|
||||
cached_delta_frames = 0;
|
||||
}
|
||||
|
|
@ -122,28 +96,7 @@ pub fn start_ui(
|
|||
// 6️⃣ sync status wording
|
||||
let sync_status = get_sync_status(cached_delta_ms, &cfg);
|
||||
|
||||
// 7️⃣ auto‑sync (same as manual but delayed)
|
||||
if sync_status != "IN SYNC" && sync_status != "TIMETURNING" {
|
||||
if let Some(start) = out_of_sync_since {
|
||||
if start.elapsed() >= Duration::from_secs(5) {
|
||||
if let Some(frame) = &state.lock().unwrap().latest {
|
||||
let entry = match system::trigger_sync(frame, &cfg) {
|
||||
Ok(ts) => format!("🔄 Auto‑synced to LTC: {}", ts),
|
||||
Err(_) => "❌ Auto‑sync failed".into(),
|
||||
};
|
||||
if logs.len() == 10 { logs.pop_front(); }
|
||||
logs.push_back(entry);
|
||||
}
|
||||
out_of_sync_since = None;
|
||||
}
|
||||
} else {
|
||||
out_of_sync_since = Some(Instant::now());
|
||||
}
|
||||
} else {
|
||||
out_of_sync_since = None;
|
||||
}
|
||||
|
||||
// 8️⃣ header & LTC metrics display
|
||||
// 7️⃣ header & LTC metrics display
|
||||
{
|
||||
let st = state.lock().unwrap();
|
||||
let opt = st.latest.as_ref();
|
||||
|
|
@ -154,7 +107,7 @@ pub fn start_ui(
|
|||
None => "LTC Timecode : …".to_string(),
|
||||
};
|
||||
let fr_str = match opt {
|
||||
Some(f) => format!("Frame Rate : {:.2}fps", f.frame_rate),
|
||||
Some(f) => format!("Frame Rate : {:.2}fps", f.frame_rate.to_f64().unwrap_or(0.0)),
|
||||
None => "Frame Rate : …".to_string(),
|
||||
};
|
||||
|
||||
|
|
@ -279,10 +232,3 @@ pub fn start_ui(
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[allow(unused_imports)]
|
||||
use super::*;
|
||||
#[allow(unused_imports)]
|
||||
use crate::config::TimeturnerOffset;
|
||||
}
|
||||
|
|
|
|||
BIN
static/assets/FuturaStdHeavy.otf
Normal file
BIN
static/assets/HaveBlueTransWh.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
static/assets/favicon.png
Normal file
|
After Width: | Height: | Size: 3 KiB |
BIN
static/assets/header.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
static/assets/quartz-ms-regular.ttf
Normal file
BIN
static/assets/timeturner_2398.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
static/assets/timeturner_24.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
static/assets/timeturner_25.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
static/assets/timeturner_2997.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
static/assets/timeturner_2997DF.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
static/assets/timeturner_30.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
static/assets/timeturner_controls.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
static/assets/timeturner_default.png
Normal file
|
After Width: | Height: | Size: 898 B |
BIN
static/assets/timeturner_delta_green.png
Normal file
|
After Width: | Height: | Size: 981 B |
BIN
static/assets/timeturner_delta_orange.png
Normal file
|
After Width: | Height: | Size: 955 B |
BIN
static/assets/timeturner_delta_red.png
Normal file
|
After Width: | Height: | Size: 913 B |
BIN
static/assets/timeturner_jitter_green.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
static/assets/timeturner_jitter_orange.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
static/assets/timeturner_jitter_red.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
static/assets/timeturner_lock_green.png
Normal file
|
After Width: | Height: | Size: 810 B |
BIN
static/assets/timeturner_lock_orange.png
Normal file
|
After Width: | Height: | Size: 805 B |
BIN
static/assets/timeturner_lock_red.png
Normal file
|
After Width: | Height: | Size: 804 B |
BIN
static/assets/timeturner_logs.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
static/assets/timeturner_ltc_green.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
static/assets/timeturner_ltc_orange.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
static/assets/timeturner_ltc_red.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
static/assets/timeturner_network.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
static/assets/timeturner_ntp_green.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
static/assets/timeturner_ntp_orange.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
static/assets/timeturner_ntp_red.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
static/assets/timeturner_sync_green.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
static/assets/timeturner_sync_orange.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
static/assets/timeturner_sync_red.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
static/assets/timeturner_timeturning.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
static/favicon.ico
Normal file
|
After Width: | Height: | Size: 198 KiB |
43
static/icon-map.js
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
// In this file, you can define the paths to your local icon image files.
|
||||
const iconMap = {
|
||||
ltcStatus: {
|
||||
'LOCK': { src: 'assets/timeturner_ltc_green.png', tooltip: 'LTC signal is locked and stable.' },
|
||||
'FREE': { src: 'assets/timeturner_ltc_orange.png', tooltip: 'LTC signal is in freewheel mode.' },
|
||||
'default': { src: 'assets/timeturner_ltc_red.png', tooltip: 'LTC signal is not detected.' }
|
||||
},
|
||||
ntpActive: {
|
||||
true: { src: 'assets/timeturner_ntp_green.png', tooltip: 'NTP service is active.' },
|
||||
false: { src: 'assets/timeturner_ntp_red.png', tooltip: 'NTP service is inactive.' }
|
||||
},
|
||||
syncStatus: {
|
||||
'IN SYNC': { src: 'assets/timeturner_sync_green.png', tooltip: 'System clock is in sync with LTC source.' },
|
||||
'CLOCK AHEAD': { src: 'assets/timeturner_sync_orange.png', tooltip: 'System clock is ahead of the LTC source.' },
|
||||
'CLOCK BEHIND': { src: 'assets/timeturner_sync_orange.png', tooltip: 'System clock is behind the LTC source.' },
|
||||
'TIMETURNING': { src: 'assets/timeturner_timeturning.png', tooltip: 'Timeturner offset is active.' },
|
||||
'default': { src: 'assets/timeturner_sync_red.png', tooltip: 'Sync status is unknown.' }
|
||||
},
|
||||
jitterStatus: {
|
||||
'GOOD': { src: 'assets/timeturner_jitter_green.png', tooltip: 'Clock jitter is within acceptable limits.' },
|
||||
'AVERAGE': { src: 'assets/timeturner_jitter_orange.png', tooltip: 'Clock jitter is moderate.' },
|
||||
'BAD': { src: 'assets/timeturner_jitter_red.png', tooltip: 'Clock jitter is high and may affect accuracy.' },
|
||||
'default': { src: 'assets/timeturner_jitter_red.png', tooltip: 'Jitter status is unknown.' }
|
||||
},
|
||||
deltaStatus: {
|
||||
'good': { src: 'assets/timeturner_delta_green.png', tooltip: 'Clock delta is 0ms.' },
|
||||
'average': { src: 'assets/timeturner_delta_orange.png', tooltip: 'Clock delta is less than 10ms.' },
|
||||
'bad': { src: 'assets/timeturner_delta_red.png', tooltip: 'Clock delta is 10ms or greater.' }
|
||||
},
|
||||
frameRate: {
|
||||
'23.98fps': { src: 'assets/timeturner_2398.png', tooltip: '23.98 frames per second' },
|
||||
'24.00fps': { src: 'assets/timeturner_24.png', tooltip: '24.00 frames per second' },
|
||||
'25.00fps': { src: 'assets/timeturner_25.png', tooltip: '25.00 frames per second' },
|
||||
'29.97fps': { src: 'assets/timeturner_2997.png', tooltip: '29.97 frames per second' },
|
||||
'30.00fps': { src: 'assets/timeturner_30.png', tooltip: '30.00 frames per second' },
|
||||
'default': { src: 'assets/timeturner_default.png', tooltip: 'Unknown frame rate' }
|
||||
},
|
||||
lockRatio: {
|
||||
'good': { src: 'assets/timeturner_lock_green.png', tooltip: 'Lock ratio is 100%.' },
|
||||
'average': { src: 'assets/timeturner_lock_orange.png', tooltip: 'Lock ratio is 90% or higher.' },
|
||||
'bad': { src: 'assets/timeturner_lock_red.png', tooltip: 'Lock ratio is below 90%.' }
|
||||
}
|
||||
};
|
||||
|
|
@ -3,67 +3,139 @@
|
|||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>NTP TimeTurner</title>
|
||||
<title>Fetch | Hachi</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<link rel="icon" href="favicon.ico" type="image/x-icon">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>NTP TimeTurner</h1>
|
||||
<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 Status</h2>
|
||||
<p id="ltc-status">--</p>
|
||||
<h2>LTC Input</h2>
|
||||
<p id="ltc-timecode">--:--:--:--</p>
|
||||
<p id="frame-rate">-- fps</p>
|
||||
<p>Lock Ratio: <span id="lock-ratio">--</span>%</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>System Clock</h2>
|
||||
<h2>NTP Clock</h2>
|
||||
<p id="system-clock">--:--:--.---</p>
|
||||
<p>NTP Service: <span id="ntp-active">--</span></p>
|
||||
<p>Sync Status: <span id="sync-status">--</span></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>
|
||||
|
||||
<!-- 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>
|
||||
<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>
|
||||
<ul id="interfaces">
|
||||
<li>--</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p id="interfaces">--</p>
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
<div class="card full-width">
|
||||
<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="control-group">
|
||||
</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">
|
||||
<label>Timeturner Offset:</label>
|
||||
<input type="number" id="offset-h" placeholder="H">
|
||||
<input type="number" id="offset-m" placeholder="M">
|
||||
<input type="number" id="offset-s" placeholder="S">
|
||||
<input type="number" id="offset-f" placeholder="F">
|
||||
<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">
|
||||
<button id="save-config">Save Config</button>
|
||||
<button id="manual-sync">Manual Sync</button>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<!-- 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>
|
||||
</div>
|
||||
<script src="icon-map.js"></script>
|
||||
<script src="mock-data.js"></script>
|
||||
<script src="script.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
141
static/index_dev.html
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
<!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>
|
||||
168
static/mock-data.js
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
// This file contains mock data sets for UI development and testing without a live backend.
|
||||
const mockApiDataSets = {
|
||||
allGood: {
|
||||
status: {
|
||||
ltc_status: 'LOCK',
|
||||
ltc_timecode: '10:20:30:00',
|
||||
frame_rate: '25.00fps',
|
||||
lock_ratio: 99.5,
|
||||
system_clock: '10:20:30.500',
|
||||
system_date: '2025-08-07',
|
||||
ntp_active: true,
|
||||
sync_status: 'IN SYNC',
|
||||
timecode_delta_ms: 5,
|
||||
timecode_delta_frames: 0.125,
|
||||
jitter_status: 'GOOD',
|
||||
interfaces: ['192.168.1.100/24 (eth0)', '10.0.0.5/8 (wlan0)'],
|
||||
},
|
||||
config: {
|
||||
hardwareOffsetMs: 10,
|
||||
autoSyncEnabled: true,
|
||||
defaultNudgeMs: 2,
|
||||
timeturnerOffset: { hours: 1, minutes: 2, seconds: 3, frames: 4, milliseconds: 50 },
|
||||
},
|
||||
logs: [
|
||||
'2025-08-07 10:20:30 [INFO] Starting up...',
|
||||
'2025-08-07 10:20:32 [INFO] LTC LOCK detected. Frame rate: 25.00fps.',
|
||||
'2025-08-07 10:20:35 [INFO] Initial sync complete. Clock adjusted by -15ms.',
|
||||
]
|
||||
},
|
||||
ltcFree: {
|
||||
status: {
|
||||
ltc_status: 'FREE',
|
||||
ltc_timecode: '11:22:33:11',
|
||||
frame_rate: '25.00fps',
|
||||
lock_ratio: 40.2,
|
||||
system_clock: '11:22:33.800',
|
||||
system_date: '2025-08-07',
|
||||
ntp_active: true,
|
||||
sync_status: 'IN SYNC',
|
||||
timecode_delta_ms: 3,
|
||||
timecode_delta_frames: 0.075,
|
||||
jitter_status: 'GOOD',
|
||||
interfaces: ['192.168.1.100/24 (eth0)'],
|
||||
},
|
||||
config: {
|
||||
hardwareOffsetMs: 10,
|
||||
autoSyncEnabled: true,
|
||||
defaultNudgeMs: 2,
|
||||
timeturnerOffset: { hours: 0, minutes: 0, seconds: 0, frames: 0, milliseconds: 0 },
|
||||
},
|
||||
logs: [ '2025-08-07 11:22:30 [WARN] LTC signal lost, entering freewheel.' ]
|
||||
},
|
||||
clockAhead: {
|
||||
status: {
|
||||
ltc_status: 'LOCK',
|
||||
ltc_timecode: '12:00:05:00',
|
||||
frame_rate: '25.00fps',
|
||||
lock_ratio: 98.1,
|
||||
system_clock: '12:00:04.500',
|
||||
system_date: '2025-08-07',
|
||||
ntp_active: true,
|
||||
sync_status: 'CLOCK AHEAD',
|
||||
timecode_delta_ms: -500,
|
||||
timecode_delta_frames: -12.5,
|
||||
jitter_status: 'AVERAGE',
|
||||
interfaces: ['192.168.1.100/24 (eth0)'],
|
||||
},
|
||||
config: {
|
||||
hardwareOffsetMs: 10,
|
||||
autoSyncEnabled: true,
|
||||
defaultNudgeMs: 2,
|
||||
timeturnerOffset: { hours: 0, minutes: 0, seconds: 0, frames: 0, milliseconds: 0 },
|
||||
},
|
||||
logs: [ '2025-08-07 12:00:00 [WARN] System clock is ahead of LTC source by 500ms.' ]
|
||||
},
|
||||
clockBehind: {
|
||||
status: {
|
||||
ltc_status: 'LOCK',
|
||||
ltc_timecode: '13:30:10:00',
|
||||
frame_rate: '25.00fps',
|
||||
lock_ratio: 99.9,
|
||||
system_clock: '13:30:10.800',
|
||||
system_date: '2025-08-07',
|
||||
ntp_active: true,
|
||||
sync_status: 'CLOCK BEHIND',
|
||||
timecode_delta_ms: 800,
|
||||
timecode_delta_frames: 20,
|
||||
jitter_status: 'AVERAGE',
|
||||
interfaces: ['192.168.1.100/24 (eth0)'],
|
||||
},
|
||||
config: {
|
||||
hardwareOffsetMs: 10,
|
||||
autoSyncEnabled: true,
|
||||
defaultNudgeMs: 2,
|
||||
timeturnerOffset: { hours: 0, minutes: 0, seconds: 0, frames: 0, milliseconds: 0 },
|
||||
},
|
||||
logs: [ '2025-08-07 13:30:00 [WARN] System clock is behind LTC source by 800ms.' ]
|
||||
},
|
||||
timeturning: {
|
||||
status: {
|
||||
ltc_status: 'LOCK',
|
||||
ltc_timecode: '14:00:00:00',
|
||||
frame_rate: '25.00fps',
|
||||
lock_ratio: 100,
|
||||
system_clock: '15:02:03.050',
|
||||
system_date: '2025-08-07',
|
||||
ntp_active: true,
|
||||
sync_status: 'TIMETURNING',
|
||||
timecode_delta_ms: 3723050, // a big number
|
||||
timecode_delta_frames: 93076,
|
||||
jitter_status: 'GOOD',
|
||||
interfaces: ['192.168.1.100/24 (eth0)'],
|
||||
},
|
||||
config: {
|
||||
hardwareOffsetMs: 10,
|
||||
autoSyncEnabled: false,
|
||||
defaultNudgeMs: 2,
|
||||
timeturnerOffset: { hours: 1, minutes: 2, seconds: 3, frames: 4, milliseconds: 50 },
|
||||
},
|
||||
logs: [ '2025-08-07 14:00:00 [INFO] Timeturner offset is active.' ]
|
||||
},
|
||||
badJitter: {
|
||||
status: {
|
||||
ltc_status: 'LOCK',
|
||||
ltc_timecode: '15:15:15:15',
|
||||
frame_rate: '25.00fps',
|
||||
lock_ratio: 95.0,
|
||||
system_clock: '15:15:15.515',
|
||||
system_date: '2025-08-07',
|
||||
ntp_active: true,
|
||||
sync_status: 'IN SYNC',
|
||||
timecode_delta_ms: 10,
|
||||
timecode_delta_frames: 0.25,
|
||||
jitter_status: 'BAD',
|
||||
interfaces: ['192.168.1.100/24 (eth0)'],
|
||||
},
|
||||
config: {
|
||||
hardwareOffsetMs: 10,
|
||||
autoSyncEnabled: true,
|
||||
defaultNudgeMs: 2,
|
||||
timeturnerOffset: { hours: 0, minutes: 0, seconds: 0, frames: 0, milliseconds: 0 },
|
||||
},
|
||||
logs: [ '2025-08-07 15:15:00 [ERROR] High jitter detected on LTC source.' ]
|
||||
},
|
||||
ntpInactive: {
|
||||
status: {
|
||||
ltc_status: 'UNKNOWN',
|
||||
ltc_timecode: '--:--:--:--',
|
||||
frame_rate: '--',
|
||||
lock_ratio: 0,
|
||||
system_clock: '16:00:00.000',
|
||||
system_date: '2025-08-07',
|
||||
ntp_active: false,
|
||||
sync_status: 'UNKNOWN',
|
||||
timecode_delta_ms: 0,
|
||||
timecode_delta_frames: 0,
|
||||
jitter_status: 'UNKNOWN',
|
||||
interfaces: [],
|
||||
},
|
||||
config: {
|
||||
hardwareOffsetMs: 0,
|
||||
autoSyncEnabled: false,
|
||||
defaultNudgeMs: 2,
|
||||
timeturnerOffset: { hours: 0, minutes: 0, seconds: 0, frames: 0, milliseconds: 0 },
|
||||
},
|
||||
logs: [ '2025-08-07 16:00:00 [INFO] NTP service is inactive.' ]
|
||||
}
|
||||
};
|
||||
360
static/script.js
|
|
@ -1,83 +1,263 @@
|
|||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// --- Mock Data Configuration ---
|
||||
// Set to true to use mock data, false for live API.
|
||||
const useMockData = false;
|
||||
let currentMockSetKey = 'allGood'; // Default mock data set
|
||||
|
||||
let lastApiData = null;
|
||||
let lastApiFetchTime = null;
|
||||
|
||||
const statusElements = {
|
||||
ltcStatus: document.getElementById('ltc-status'),
|
||||
ltcTimecode: document.getElementById('ltc-timecode'),
|
||||
frameRate: document.getElementById('frame-rate'),
|
||||
lockRatio: document.getElementById('lock-ratio'),
|
||||
systemClock: document.getElementById('system-clock'),
|
||||
systemDate: document.getElementById('system-date'),
|
||||
ntpActive: document.getElementById('ntp-active'),
|
||||
syncStatus: document.getElementById('sync-status'),
|
||||
deltaMs: document.getElementById('delta-ms'),
|
||||
deltaFrames: document.getElementById('delta-frames'),
|
||||
deltaStatus: document.getElementById('delta-status'),
|
||||
jitterStatus: document.getElementById('jitter-status'),
|
||||
deltaText: document.getElementById('delta-text'),
|
||||
interfaces: document.getElementById('interfaces'),
|
||||
logs: document.getElementById('logs'),
|
||||
};
|
||||
|
||||
const hwOffsetInput = document.getElementById('hw-offset');
|
||||
const autoSyncCheckbox = document.getElementById('auto-sync-enabled');
|
||||
const offsetInputs = {
|
||||
h: document.getElementById('offset-h'),
|
||||
m: document.getElementById('offset-m'),
|
||||
s: document.getElementById('offset-s'),
|
||||
f: document.getElementById('offset-f'),
|
||||
ms: document.getElementById('offset-ms'),
|
||||
};
|
||||
const saveConfigButton = document.getElementById('save-config');
|
||||
const manualSyncButton = document.getElementById('manual-sync');
|
||||
const syncMessage = document.getElementById('sync-message');
|
||||
|
||||
function updateStatus(data) {
|
||||
statusElements.ltcStatus.textContent = data.ltc_status;
|
||||
statusElements.ltcTimecode.textContent = data.ltc_timecode;
|
||||
statusElements.frameRate.textContent = data.frame_rate;
|
||||
statusElements.lockRatio.textContent = data.lock_ratio.toFixed(2);
|
||||
statusElements.systemClock.textContent = data.system_clock;
|
||||
const nudgeDownButton = document.getElementById('nudge-down');
|
||||
const nudgeUpButton = document.getElementById('nudge-up');
|
||||
const nudgeValueInput = document.getElementById('nudge-value');
|
||||
const nudgeMessage = document.getElementById('nudge-message');
|
||||
|
||||
statusElements.ntpActive.textContent = data.ntp_active ? 'Active' : 'Inactive';
|
||||
statusElements.ntpActive.className = data.ntp_active ? 'active' : 'inactive';
|
||||
const dateInput = document.getElementById('date-input');
|
||||
const setDateButton = document.getElementById('set-date');
|
||||
const dateMessage = document.getElementById('date-message');
|
||||
|
||||
statusElements.syncStatus.textContent = data.sync_status;
|
||||
statusElements.syncStatus.className = data.sync_status.replace(/\s+/g, '-').toLowerCase();
|
||||
// --- Collapsible Sections ---
|
||||
const controlsToggle = document.getElementById('controls-toggle');
|
||||
const controlsContent = document.getElementById('controls-content');
|
||||
const logsToggle = document.getElementById('logs-toggle');
|
||||
const logsContent = document.getElementById('logs-content');
|
||||
|
||||
statusElements.deltaMs.textContent = data.timecode_delta_ms;
|
||||
statusElements.deltaFrames.textContent = data.timecode_delta_frames;
|
||||
// --- Mock Controls Setup ---
|
||||
const mockControls = document.getElementById('mock-controls');
|
||||
const mockDataSelector = document.getElementById('mock-data-selector');
|
||||
|
||||
statusElements.jitterStatus.textContent = data.jitter_status;
|
||||
statusElements.jitterStatus.className = data.jitter_status.toLowerCase();
|
||||
function setupMockControls() {
|
||||
if (useMockData) {
|
||||
mockControls.style.display = 'block';
|
||||
|
||||
statusElements.interfaces.innerHTML = '';
|
||||
if (data.interfaces.length > 0) {
|
||||
data.interfaces.forEach(ip => {
|
||||
const li = document.createElement('li');
|
||||
li.textContent = ip;
|
||||
statusElements.interfaces.appendChild(li);
|
||||
// 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.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 {
|
||||
const li = document.createElement('li');
|
||||
li.textContent = 'No active interfaces found.';
|
||||
statusElements.interfaces.appendChild(li);
|
||||
lockRatioCategory = 'bad';
|
||||
}
|
||||
const lockRatioIconInfo = iconMap.lockRatio[lockRatioCategory];
|
||||
statusElements.lockRatio.innerHTML = `<img src="${lockRatioIconInfo.src}" class="status-icon" alt="" title="${lockRatioIconInfo.tooltip}">`;
|
||||
statusElements.systemClock.textContent = data.system_clock;
|
||||
statusElements.systemDate.textContent = data.system_date;
|
||||
|
||||
// Autofill the date input, but don't overwrite user edits.
|
||||
if (!lastApiData || dateInput.value === lastApiData.system_date) {
|
||||
dateInput.value = data.system_date;
|
||||
}
|
||||
|
||||
const ntpIconInfo = iconMap.ntpActive[!!data.ntp_active];
|
||||
if (data.ntp_active) {
|
||||
statusElements.ntpActive.innerHTML = `<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();
|
||||
|
||||
if (data.interfaces.length > 0) {
|
||||
statusElements.interfaces.textContent = data.interfaces.join(' | ');
|
||||
} else {
|
||||
statusElements.interfaces.textContent = 'No active interfaces found.';
|
||||
}
|
||||
}
|
||||
|
||||
function animateClocks() {
|
||||
if (!lastApiData || !lastApiFetchTime) return;
|
||||
|
||||
const elapsedMs = new Date() - lastApiFetchTime;
|
||||
|
||||
// Animate System Clock
|
||||
if (lastApiData.system_clock && lastApiData.system_clock.includes(':')) {
|
||||
const parts = lastApiData.system_clock.split(/[:.]/);
|
||||
if (parts.length === 4) {
|
||||
const baseDate = new Date();
|
||||
baseDate.setHours(parseInt(parts[0], 10), parseInt(parts[1], 10), parseInt(parts[2], 10));
|
||||
baseDate.setMilliseconds(parseInt(parts[3], 10));
|
||||
|
||||
const newDate = new Date(baseDate.getTime() + elapsedMs);
|
||||
|
||||
const h = String(newDate.getHours()).padStart(2, '0');
|
||||
const m = String(newDate.getMinutes()).padStart(2, '0');
|
||||
const s = String(newDate.getSeconds()).padStart(2, '0');
|
||||
const ms = String(newDate.getMilliseconds()).padStart(3, '0');
|
||||
statusElements.systemClock.textContent = `${h}:${m}:${s}.${ms}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Animate LTC Timecode - only if status is LOCK
|
||||
if (lastApiData.ltc_status === 'LOCK' && lastApiData.ltc_timecode && lastApiData.ltc_timecode.match(/[:;]/) && lastApiData.frame_rate) {
|
||||
const separator = lastApiData.ltc_timecode.includes(';') ? ';' : ':';
|
||||
const tcParts = lastApiData.ltc_timecode.split(/[:;]/);
|
||||
const frameRate = parseFloat(lastApiData.frame_rate);
|
||||
|
||||
if (tcParts.length === 4 && !isNaN(frameRate) && frameRate > 0) {
|
||||
let h = parseInt(tcParts[0], 10);
|
||||
let m = parseInt(tcParts[1], 10);
|
||||
let s = parseInt(tcParts[2], 10);
|
||||
let f = parseInt(tcParts[3], 10);
|
||||
|
||||
const msPerFrame = 1000.0 / frameRate;
|
||||
const elapsedFrames = Math.floor(elapsedMs / msPerFrame);
|
||||
|
||||
f += elapsedFrames;
|
||||
|
||||
const frameRateInt = Math.round(frameRate);
|
||||
|
||||
s += Math.floor(f / frameRateInt);
|
||||
f %= frameRateInt;
|
||||
|
||||
m += Math.floor(s / 60);
|
||||
s %= 60;
|
||||
|
||||
h += Math.floor(m / 60);
|
||||
m %= 60;
|
||||
|
||||
h %= 24;
|
||||
|
||||
statusElements.ltcTimecode.textContent =
|
||||
`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}${separator}${String(f).padStart(2, '0')}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchStatus() {
|
||||
if (useMockData) {
|
||||
const data = mockApiDataSets[currentMockSetKey].status;
|
||||
updateStatus(data);
|
||||
lastApiData = data;
|
||||
lastApiFetchTime = new Date();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch('/api/status');
|
||||
if (!response.ok) throw new Error('Failed to fetch status');
|
||||
const data = await response.json();
|
||||
updateStatus(data);
|
||||
lastApiData = data;
|
||||
lastApiFetchTime = new Date();
|
||||
} catch (error) {
|
||||
console.error('Error fetching status:', error);
|
||||
lastApiData = null;
|
||||
lastApiFetchTime = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchConfig() {
|
||||
if (useMockData) {
|
||||
const data = mockApiDataSets[currentMockSetKey].config;
|
||||
hwOffsetInput.value = data.hardwareOffsetMs;
|
||||
autoSyncCheckbox.checked = data.autoSyncEnabled;
|
||||
offsetInputs.h.value = data.timeturnerOffset.hours;
|
||||
offsetInputs.m.value = data.timeturnerOffset.minutes;
|
||||
offsetInputs.s.value = data.timeturnerOffset.seconds;
|
||||
offsetInputs.f.value = data.timeturnerOffset.frames;
|
||||
offsetInputs.ms.value = data.timeturnerOffset.milliseconds || 0;
|
||||
nudgeValueInput.value = data.defaultNudgeMs;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch('/api/config');
|
||||
if (!response.ok) throw new Error('Failed to fetch config');
|
||||
const data = await response.json();
|
||||
hwOffsetInput.value = data.hardwareOffsetMs;
|
||||
autoSyncCheckbox.checked = data.autoSyncEnabled;
|
||||
offsetInputs.h.value = data.timeturnerOffset.hours;
|
||||
offsetInputs.m.value = data.timeturnerOffset.minutes;
|
||||
offsetInputs.s.value = data.timeturnerOffset.seconds;
|
||||
offsetInputs.f.value = data.timeturnerOffset.frames;
|
||||
offsetInputs.ms.value = data.timeturnerOffset.milliseconds || 0;
|
||||
nudgeValueInput.value = data.defaultNudgeMs;
|
||||
} catch (error) {
|
||||
console.error('Error fetching config:', error);
|
||||
}
|
||||
|
|
@ -86,14 +266,25 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
async function saveConfig() {
|
||||
const config = {
|
||||
hardwareOffsetMs: parseInt(hwOffsetInput.value, 10) || 0,
|
||||
autoSyncEnabled: autoSyncCheckbox.checked,
|
||||
defaultNudgeMs: parseInt(nudgeValueInput.value, 10) || 0,
|
||||
timeturnerOffset: {
|
||||
hours: parseInt(offsetInputs.h.value, 10) || 0,
|
||||
minutes: parseInt(offsetInputs.m.value, 10) || 0,
|
||||
seconds: parseInt(offsetInputs.s.value, 10) || 0,
|
||||
frames: parseInt(offsetInputs.f.value, 10) || 0,
|
||||
milliseconds: parseInt(offsetInputs.ms.value, 10) || 0,
|
||||
}
|
||||
};
|
||||
|
||||
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',
|
||||
|
|
@ -108,8 +299,35 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
}
|
||||
}
|
||||
|
||||
async function fetchLogs() {
|
||||
if (useMockData) {
|
||||
// Use a copy to avoid mutating the original mock data array
|
||||
const logs = mockApiDataSets[currentMockSetKey].logs.slice();
|
||||
// Show latest 20 logs, with the newest at the top.
|
||||
logs.reverse();
|
||||
statusElements.logs.textContent = logs.slice(0, 20).join('\n');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch('/api/logs');
|
||||
if (!response.ok) throw new Error('Failed to fetch logs');
|
||||
const logs = await response.json();
|
||||
// Show latest 20 logs, with the newest at the top.
|
||||
logs.reverse();
|
||||
statusElements.logs.textContent = logs.slice(0, 20).join('\n');
|
||||
} catch (error) {
|
||||
console.error('Error fetching logs:', error);
|
||||
statusElements.logs.textContent = 'Error fetching logs.';
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerManualSync() {
|
||||
syncMessage.textContent = 'Issuing sync command...';
|
||||
if (useMockData) {
|
||||
syncMessage.textContent = 'Success: Manual sync triggered (mock).';
|
||||
setTimeout(() => { syncMessage.textContent = ''; }, 5000);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch('/api/sync', { method: 'POST' });
|
||||
const data = await response.json();
|
||||
|
|
@ -125,13 +343,101 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
setTimeout(() => { syncMessage.textContent = ''; }, 5000);
|
||||
}
|
||||
|
||||
async function nudgeClock(ms) {
|
||||
nudgeMessage.textContent = 'Nudging clock...';
|
||||
if (useMockData) {
|
||||
nudgeMessage.textContent = `Success: Clock nudged by ${ms}ms (mock).`;
|
||||
setTimeout(() => { nudgeMessage.textContent = ''; }, 3000);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch('/api/nudge_clock', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ microseconds: ms * 1000 }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (response.ok) {
|
||||
nudgeMessage.textContent = `Success: ${data.message}`;
|
||||
} else {
|
||||
nudgeMessage.textContent = `Error: ${data.message}`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error nudging clock:', error);
|
||||
nudgeMessage.textContent = 'Failed to send nudge command.';
|
||||
}
|
||||
setTimeout(() => { nudgeMessage.textContent = ''; }, 3000);
|
||||
}
|
||||
|
||||
async function setDate() {
|
||||
const date = dateInput.value;
|
||||
if (!date) {
|
||||
alert('Please select a date.');
|
||||
return;
|
||||
}
|
||||
|
||||
dateMessage.textContent = 'Setting date...';
|
||||
if (useMockData) {
|
||||
mockApiDataSets[currentMockSetKey].status.system_date = date;
|
||||
dateMessage.textContent = `Success: Date set to ${date} (mock).`;
|
||||
fetchStatus(); // re-render
|
||||
setTimeout(() => { dateMessage.textContent = ''; }, 5000);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch('/api/set_date', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ date: date }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (response.ok) {
|
||||
dateMessage.textContent = `Success: ${data.message}`;
|
||||
// Fetch status again to update the displayed date immediately
|
||||
fetchStatus();
|
||||
} else {
|
||||
dateMessage.textContent = `Error: ${data.message}`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error setting date:', error);
|
||||
dateMessage.textContent = 'Failed to send date command.';
|
||||
}
|
||||
setTimeout(() => { dateMessage.textContent = ''; }, 5000);
|
||||
}
|
||||
|
||||
saveConfigButton.addEventListener('click', saveConfig);
|
||||
manualSyncButton.addEventListener('click', triggerManualSync);
|
||||
nudgeDownButton.addEventListener('click', () => {
|
||||
const ms = parseInt(nudgeValueInput.value, 10) || 0;
|
||||
nudgeClock(-ms);
|
||||
});
|
||||
nudgeUpButton.addEventListener('click', () => {
|
||||
const ms = parseInt(nudgeValueInput.value, 10) || 0;
|
||||
nudgeClock(ms);
|
||||
});
|
||||
setDateButton.addEventListener('click', setDate);
|
||||
|
||||
// --- Collapsible Section Listeners ---
|
||||
controlsToggle.addEventListener('click', () => {
|
||||
const isActive = controlsContent.classList.toggle('active');
|
||||
controlsToggle.classList.toggle('active', isActive);
|
||||
});
|
||||
|
||||
logsToggle.addEventListener('click', () => {
|
||||
const isActive = logsContent.classList.toggle('active');
|
||||
logsToggle.classList.toggle('active', isActive);
|
||||
});
|
||||
|
||||
// Initial data load
|
||||
setupMockControls();
|
||||
fetchStatus();
|
||||
fetchConfig();
|
||||
fetchLogs();
|
||||
|
||||
// Refresh data every 2 seconds
|
||||
// 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
|
||||
});
|
||||
|
|
|
|||
212
static/style.css
|
|
@ -1,6 +1,21 @@
|
|||
@font-face {
|
||||
font-family: 'FuturaStdHeavy';
|
||||
src: url('assets/FuturaStdHeavy.otf') format('opentype');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Quartz';
|
||||
src: url('assets/quartz-ms-regular.ttf') format('truetype');
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
background-color: #f4f4f9;
|
||||
font-family: 'FuturaStdHeavy', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
background-color: #221f1f;
|
||||
background-image: url('assets/HaveBlueTransWh.png');
|
||||
background-repeat: no-repeat;
|
||||
background-position: bottom 20px right 20px;
|
||||
background-attachment: fixed;
|
||||
background-size: 100px;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
|
|
@ -13,19 +28,20 @@ body {
|
|||
max-width: 960px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
text-align: center;
|
||||
color: #444;
|
||||
.header-logo {
|
||||
display: block;
|
||||
margin: 0 auto 20px auto;
|
||||
max-width: 60%;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
grid-template-columns: 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #fff;
|
||||
background: #c5ced6;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
|
|
@ -33,16 +49,32 @@ h1 {
|
|||
|
||||
.card h2 {
|
||||
margin-top: 0;
|
||||
color: #0056b3;
|
||||
color: #1a7db6;
|
||||
}
|
||||
|
||||
#ltc-timecode, #system-clock {
|
||||
font-family: 'Quartz', monospace;
|
||||
font-size: 2em;
|
||||
text-align: center;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.card p, .card ul {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.card ul {
|
||||
padding-left: 20px;
|
||||
list-style: none;
|
||||
.system-date-display {
|
||||
text-align: center;
|
||||
font-size: 1.5em;
|
||||
font-family: 'Quartz', monospace;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
#interfaces {
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 5px; /* Add some space for the scrollbar if it appears */
|
||||
}
|
||||
|
||||
.full-width {
|
||||
|
|
@ -56,25 +88,75 @@ h1 {
|
|||
gap: 10px;
|
||||
}
|
||||
|
||||
input[type="number"] {
|
||||
input[type="number"],
|
||||
input[type="text"] {
|
||||
padding: 8px;
|
||||
border: 1px solid #ccc;
|
||||
border: 1px solid #9fb3c8;
|
||||
border-radius: 4px;
|
||||
background-color: #f0f4f8;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
input[type="number"]:focus,
|
||||
input[type="text"]:focus {
|
||||
outline: none;
|
||||
border-color: #1a7db6;
|
||||
box-shadow: 0 0 0 2px rgba(26, 125, 182, 0.2);
|
||||
}
|
||||
|
||||
input[type="number"] {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 8px 15px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background-color: #007bff;
|
||||
background-color: #1a7db6;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-family: Arial, sans-serif;
|
||||
font-weight: bold;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: #0056b3;
|
||||
background-color: #166999;
|
||||
}
|
||||
|
||||
.offset-controls-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.offset-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.offset-control input[type="number"] {
|
||||
width: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.offset-control label {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
#offset-ms {
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
#sync-message {
|
||||
|
|
@ -82,6 +164,103 @@ button:hover {
|
|||
color: #555;
|
||||
}
|
||||
|
||||
.icon-group {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
#delta-text {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#ltc-status, #ntp-active, #sync-status, #jitter-status, #frame-rate, #lock-ratio, #delta-status {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.collapsible-card {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.collapsible-card .toggle-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
padding: 20px;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.collapsible-card .toggle-header.active {
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.collapsible-card .toggle-header:hover {
|
||||
background-color: #e9e9f3;
|
||||
}
|
||||
|
||||
.toggle-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.log-box {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.collapsible-content {
|
||||
display: none;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.collapsible-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
footer {
|
||||
text-align: center;
|
||||
margin-top: 40px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #444;
|
||||
color: #c5ced6;
|
||||
}
|
||||
|
||||
footer p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
footer a {
|
||||
color: #1a7db6;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Status-specific colors */
|
||||
#sync-status.in-sync, #jitter-status.good { font-weight: bold; color: #28a745; }
|
||||
#sync-status.clock-ahead, #sync-status.clock-behind, #jitter-status.average { font-weight: bold; color: #ffc107; }
|
||||
|
|
@ -89,3 +268,6 @@ button:hover {
|
|||
#jitter-status.bad { font-weight: bold; color: #dc3545; }
|
||||
#ntp-active.active { font-weight: bold; color: #28a745; }
|
||||
#ntp-active.inactive { font-weight: bold; color: #dc3545; }
|
||||
|
||||
#ltc-status.lock { font-weight: bold; color: #28a745; }
|
||||
#ltc-status.free { font-weight: bold; color: #ffc107; }
|
||||
|
|
|
|||
|
|
@ -9,10 +9,11 @@ import threading
|
|||
import queue
|
||||
import json
|
||||
from collections import deque
|
||||
from fractions import Fraction
|
||||
|
||||
SERIAL_PORT = None
|
||||
BAUD_RATE = 115200
|
||||
FRAME_RATE = 25.0
|
||||
FRAME_RATE = Fraction(25, 1)
|
||||
CONFIG_PATH = "config.json"
|
||||
|
||||
sync_pending = False
|
||||
|
|
@ -30,6 +31,14 @@ sync_enabled = False
|
|||
last_match_check = 0
|
||||
timecode_match_status = "UNKNOWN"
|
||||
|
||||
def framerate_str_to_fraction(s):
|
||||
if s == "23.98": return Fraction(24000, 1001)
|
||||
if s == "24.00": return Fraction(24, 1)
|
||||
if s == "25.00": return Fraction(25, 1)
|
||||
if s == "29.97": return Fraction(30000, 1001)
|
||||
if s == "30.00": return Fraction(30, 1)
|
||||
return None
|
||||
|
||||
def load_config():
|
||||
global hardware_offset_ms
|
||||
try:
|
||||
|
|
@ -50,13 +59,16 @@ def parse_ltc_line(line):
|
|||
if not match:
|
||||
return None
|
||||
status, hh, mm, ss, ff, fps = match.groups()
|
||||
rate = framerate_str_to_fraction(fps)
|
||||
if not rate:
|
||||
return None
|
||||
return {
|
||||
"status": status,
|
||||
"hours": int(hh),
|
||||
"minutes": int(mm),
|
||||
"seconds": int(ss),
|
||||
"frames": int(ff),
|
||||
"frame_rate": float(fps)
|
||||
"frame_rate": rate
|
||||
}
|
||||
|
||||
def serial_thread(port, baud, q):
|
||||
|
|
@ -154,7 +166,7 @@ def run_curses(stdscr):
|
|||
parsed, arrival_time = latest_ltc
|
||||
stdscr.addstr(3, 2, f"LTC Status : {parsed['status']}")
|
||||
stdscr.addstr(4, 2, f"LTC Timecode : {parsed['hours']:02}:{parsed['minutes']:02}:{parsed['seconds']:02}:{parsed['frames']:02}")
|
||||
stdscr.addstr(5, 2, f"Frame Rate : {FRAME_RATE:.2f}fps")
|
||||
stdscr.addstr(5, 2, f"Frame Rate : {float(FRAME_RATE):.2f}fps")
|
||||
stdscr.addstr(6, 2, f"System Clock : {format_time(get_system_time())}")
|
||||
|
||||
if ltc_locked and sync_enabled and offset_history:
|
||||
|
|
|
|||
18
timeturner.service
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
[Unit]
|
||||
Description=NTP TimeTurner Daemon
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=forking
|
||||
# The 'timeturner daemon' command starts the background process.
|
||||
# It requires 'config.yml' and the 'static/' web assets directory
|
||||
# to be present in the WorkingDirectory.
|
||||
ExecStart=/opt/timeturner/timeturner daemon
|
||||
WorkingDirectory=/opt/timeturner
|
||||
PIDFile=/opt/timeturner/ntp_timeturner.pid
|
||||
Restart=always
|
||||
User=root
|
||||
Group=root
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
28
update.sh
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "--- TimeTurner Update Script ---"
|
||||
|
||||
# 1. Fetch the latest changes from the git repository
|
||||
echo "🔄 Pulling latest changes from GitHub..."
|
||||
git pull origin main
|
||||
|
||||
# 2. Rebuild the release binary
|
||||
echo "📦 Building release binary with Cargo..."
|
||||
cargo build --release
|
||||
|
||||
# 3. Stop the currently running service to release the file lock
|
||||
echo "🛑 Stopping TimeTurner service..."
|
||||
sudo systemctl stop timeturner.service || true
|
||||
|
||||
# 4. Copy the new binary to the installation directory
|
||||
echo "🚀 Deploying new binary..."
|
||||
sudo cp target/release/ntp_timeturner /opt/timeturner/timeturner
|
||||
|
||||
# 5. Restart the service with the new binary
|
||||
echo "✅ Restarting TimeTurner service..."
|
||||
sudo systemctl restart timeturner.service
|
||||
|
||||
echo ""
|
||||
echo "Update complete. To check the status of the service, run:"
|
||||
echo " systemctl status timeturner.service"
|
||||