Compare commits

...

63 commits

Author SHA1 Message Date
Chris Frankland-Wright
b510af2d8d fix: Wrap long log entries in log box
Some checks failed
Build for Raspberry Pi / Build for aarch64 (push) Failing after 17s
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-08 01:35:00 +01:00
Chris Frankland-Wright
cf24c9029e
Merge pull request #31 from cjfranko/webui_updates
web ui update! We are for initial release!
2025-08-08 01:32:14 +01:00
Chris Frankland-Wright
89cf0e5d97 added Δ to text 2025-08-08 01:23:34 +01:00
Chris Frankland-Wright
94687da414 final push for web ui version 1 2025-08-08 01:21:42 +01:00
Chris Frankland-Wright
02487bda97 feat: Move delta value below icon and add label
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-08 01:17:38 +01:00
Chris Frankland-Wright
982aad3ec9 chore: Switch to live API data 2025-08-08 01:17:32 +01:00
Chris Frankland-Wright
49287e5e16 feat: Add favicon link to HTML
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-08 01:15:48 +01:00
Chris Frankland-Wright
f909a90caa fix: Update frame rate format to include 'fps' suffix
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-08 01:13:54 +01:00
Chris Frankland-Wright
fad7ddedb5 refactor: Update icon map asset paths 2025-08-08 01:13:48 +01:00
Chris Frankland-Wright
89628b974b style: Add Have Blue logo to page background
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-08 01:08:08 +01:00
Chris Frankland-Wright
886006420b feat: add footer with build information and GitHub link
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-08 01:05:49 +01:00
Chris Frankland-Wright
5b0dcadac2 test: Add subnet masks to mock IP addresses
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-08 01:03:29 +01:00
Chris Frankland-Wright
5fee17e1ab fix: Ensure network interfaces display on single line with scroll
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-08 01:02:06 +01:00
Chris Frankland-Wright
ba855d520a refactor: Display network interfaces on a single line
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-08 01:00:51 +01:00
Chris Frankland-Wright
4c5fa69d1d style: Remove redundant 'Date:' label from system clock 2025-08-08 01:00:46 +01:00
Chris Frankland-Wright
54ebc0b242 refactor: Single column layout; group delta icon; style date
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-08 00:58:40 +01:00
Chris Frankland-Wright
534754be4e refactor: Consolidate status cards for 2-column layout
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-08 00:54:53 +01:00
Chris Frankland-Wright
840fca7bcf style: Remove redundant text labels from status icons
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-08 00:51:57 +01:00
Chris Frankland-Wright
87e8ae7711 style: Constrain header logo max width 2025-08-08 00:51:49 +01:00
Chris Frankland-Wright
4af732dab0 style: Update portal styling with new colours and header image
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-08 00:47:07 +01:00
Chris Frankland-Wright
fbae58fb1d feat: Add dynamic lock ratio icon with thresholds
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-08 00:42:43 +01:00
Chris Frankland-Wright
fffc123475 refactor: Update frame rate icon asset paths 2025-08-08 00:42:37 +01:00
Chris Frankland-Wright
ba9b897157 feat: Add dynamic FPS icon display to web UI
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-08 00:40:21 +01:00
Chris Frankland-Wright
9360e0011c chore: Enable mock data and simplify clock info display 2025-08-08 00:39:53 +01:00
Chris Frankland-Wright
adae9026ad feat: Limit log display to 20 latest entries, newest first
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-08 00:23:32 +01:00
Chris Frankland-Wright
463856a330 chore: Disable mock data mode
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-08 00:19:30 +01:00
Chris Frankland-Wright
6726cf393a feat: Adjust delta status thresholds for 0ms, <10ms, >=10ms
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-08 00:18:55 +01:00
Chris Frankland-Wright
d4ff2568e3 feat: Add network icon to 'Network' card header
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-08 00:16:06 +01:00
Chris Frankland-Wright
3374646de5 feat: Autofill date input with system date, prevent user overwrite
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-08 00:13:37 +01:00
Chris Frankland-Wright
cfc9a79ab8 feat: Hide controls and logs behind toggleable dropdown cards
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-08 00:11:16 +01:00
Chris Frankland-Wright
7e7ca42220 feat: Add dynamic icon for clock delta based on offset value
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-08 00:07:43 +01:00
Chris Frankland-Wright
e419cd506e feat: Add configurable tooltips to status icons
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-08 00:04:52 +01:00
Chris Frankland-Wright
0baf7588da fix: Correct typo in icon image paths 2025-08-08 00:04:47 +01:00
Chris Frankland-Wright
fe9ac76942 style: Resize status icons to 60x60px and adjust layout
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-08 00:02:43 +01:00
Chris Frankland-Wright
26dca4fd18 style: Use Quartz font for LTC and system clock displays
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-07 23:59:17 +01:00
Chris Frankland-Wright
8da42b87d0 style: Apply custom FuturaStdHeavy font
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-07 23:58:14 +01:00
Chris Frankland-Wright
c97d1841b5 feat: Add mock data toggle and scenarios for UI testing
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-07 23:53:14 +01:00
Chris Frankland-Wright
0ba46fbd71 fix: Restore live API calls by removing mock data
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-07 23:49:55 +01:00
Chris Frankland-Wright
8636ed4ec4 chore: Decouple UI from API by adding mock data
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-07 23:47:46 +01:00
Chris Frankland-Wright
f0ac2ed3d4 fix: Safely handle null status for default icon display
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-07 23:44:52 +01:00
Chris Frankland-Wright
90f43ff87e fix: Correct icon map asset spellings
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-07 23:34:16 +01:00
Chris Frankland-Wright
abce5373d7 fix: Correct icon image paths from timetuner to timeturner 2025-08-07 23:34:10 +01:00
Chris Frankland-Wright
08d664efd1 fix: Correct icon asset paths to 'timetuner' spelling
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-07 23:22:08 +01:00
Chris Frankland-Wright
cd922d5403 style: Replace generic icons with TimeTurner themed images 2025-08-07 23:22:04 +01:00
Chris Frankland-Wright
8150241db2 refactor: Standardise status element styling and icon alt attributes
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-07 23:18:00 +01:00
Chris Frankland-Wright
8b7e832225 feat: Decouple status icons; use local images via icon-map.js
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-07 23:11:22 +01:00
Chris Frankland-Wright
80953e7f6d style: Style local status icons for vertical alignment
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-07 23:08:57 +01:00
Chris Frankland-Wright
dad59ed9ff feat: Add Font Awesome icons for status indicators
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-07 23:05:03 +01:00
Chris Frankland-Wright
32712d1f3c docs: Update API docs with new endpoints and response details
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-07 21:59:32 +01:00
Chris Frankland-Wright
5f35139f3b
Update README.md
added known issues section
2025-08-07 19:58:45 +01:00
Chris Frankland-Wright
69569c0a01
Merge pull request #29 from cjfranko/non-fractional-mismatch-tc
fixed some sync issues, fractional still an issue at 29.97 NDF

Drift issues with all fractionals, 29.97NDF has a system clock sync issue
2025-08-07 19:56:08 +01:00
Chris Frankland-Wright
4cdead5aa4 fix: Do not pause auto-sync with active timeturner
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-07 19:43:49 +01:00
Chris Frankland-Wright
d99b57a98a fix: Add is_auto_sync_paused to Config; remove unused import
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-07 19:40:43 +01:00
Chris Frankland-Wright
1842419f10 added assets for static 2025-08-07 19:15:22 +01:00
Chris Frankland-Wright
82fbefce0c fix: Remove NDF timecode scaling for pre-compensated LTC source
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-05 21:05:46 +01:00
Chris Frankland-Wright
e4c49a1e78 fix: Fix NDF LTC wall-clock time calculation
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-05 20:59:19 +01:00
Chris Frankland-Wright
ed48c1284d fix: Forcefully terminate daemon process group
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-05 20:43:20 +01:00
Chris Frankland-Wright
43a3fc7aad feat: Add kill subcommand to stop daemon process
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-05 20:20:46 +01:00
Chris Frankland-Wright
a4bf025fd0 feat: Implement configurable auto-sync pausing 2025-08-05 20:20:40 +01:00
Chris Frankland-Wright
c9c6320abb feat: Set system time to 10am when setting date
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-05 20:00:35 +01:00
Chris Frankland-Wright
65dd107514 fix: Dynamically find serial port instead of hardcoding path
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-03 15:53:26 +01:00
Chris Frankland-Wright
3ffb54e9aa fix: Handle drop-frame timecode separator in API and UI
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-03 15:44:35 +01:00
Chris Frankland-Wright
22dc01e80f fix: Account for drop-frame LTC in time calculation
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-03 15:37:25 +01:00
48 changed files with 827 additions and 126 deletions

View file

@ -29,8 +29,15 @@ Created by Chris Frankland-Wright and John Rogers
---
## 🚀 Installation (to update)
## 🛠️ 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 (to update)
For Rust install you can do
```bash

View file

@ -4,17 +4,22 @@ 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",
@ -25,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`**
@ -37,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",
@ -45,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",
@ -59,6 +77,32 @@ 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.
@ -70,7 +114,7 @@ This document describes the HTTP API for the NTP Timeturner application.
}
```
**Success Response:**
**Success Response (200 OK):**
```json
{
"status": "success",
@ -78,7 +122,7 @@ This document describes the HTTP API for the NTP Timeturner application.
}
```
**Error Response:**
**Error Response (500 Internal Server Error):**
```json
{
"status": "error",
@ -90,29 +134,63 @@ This document describes the HTTP API for the NTP Timeturner application.
- **`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"
}
```

View file

@ -47,7 +47,11 @@ 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.to_f64().unwrap_or(0.0))
@ -242,6 +246,7 @@ mod tests {
minutes: 2,
seconds: 3,
frames: 4,
is_drop_frame: false,
frame_rate: Ratio::new(25, 1),
timestamp: Utc::now(),
}),
@ -290,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();

View file

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

View file

@ -15,6 +15,7 @@ use crate::sync_logic::LtcState;
use crate::ui::start_ui;
use clap::Parser;
use daemonize::Daemonize;
use serialport;
use std::{
fs,
@ -35,6 +36,8 @@ struct Args {
enum Command {
/// Run as a background daemon providing a web UI.
Daemon,
/// Stop the running daemon process.
Kill,
}
/// Default config content, embedded in the binary.
@ -70,30 +73,85 @@ fn ensure_config() {
}
}
fn find_serial_port() -> Option<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 {
log::info!("🚀 Starting daemon...");
if let Some(command) = &args.command {
match command {
Command::Daemon => {
log::info!("🚀 Starting daemon...");
// Create files for stdout and stderr in the current directory
let stdout = fs::File::create("daemon.out").expect("Could not create daemon.out");
let stderr = fs::File::create("daemon.err").expect("Could not create daemon.err");
// Create files for stdout and stderr in the current directory
let stdout =
fs::File::create("daemon.out").expect("Could not create daemon.out");
let stderr =
fs::File::create("daemon.err").expect("Could not create daemon.err");
let daemonize = Daemonize::new()
.pid_file("ntp_timeturner.pid") // Create a PID file
.working_directory(".") // Keep the same working directory
.stdout(stdout)
.stderr(stderr);
let daemonize = Daemonize::new()
.pid_file("ntp_timeturner.pid") // Create a PID file
.working_directory(".") // Keep the same working directory
.stdout(stdout)
.stderr(stderr);
match daemonize.start() {
Ok(_) => { /* Process is now daemonized */ }
Err(e) => {
log::error!("Error daemonizing: {}", e);
return; // Exit if daemonization fails
match daemonize.start() {
Ok(_) => { /* Process is now daemonized */ }
Err(e) => {
log::error!("Error daemonizing: {}", e);
return; // Exit if daemonization fails
}
}
}
Command::Kill => {
log::info!("🛑 Stopping daemon...");
let pid_file = "ntp_timeturner.pid";
match fs::read_to_string(pid_file) {
Ok(pid_str) => {
let pid_str = pid_str.trim();
log::info!("Found daemon with PID: {}", pid_str);
match std::process::Command::new("kill").arg("-9").arg(format!("-{}", pid_str)).status() {
Ok(status) => {
if status.success() {
log::info!("✅ Daemon stopped successfully.");
if fs::remove_file(pid_file).is_err() {
log::warn!("Could not remove PID file '{}'. It may need to be removed manually.", pid_file);
}
} else {
log::error!("'kill' command failed with status: {}. The daemon may not be running, or you may not have permission to stop it.", status);
log::warn!("Attempting to remove stale PID file '{}'...", pid_file);
if fs::remove_file(pid_file).is_ok() {
log::info!("Removed stale PID file.");
} else {
log::warn!("Could not remove PID file.");
}
}
}
Err(e) => {
log::error!("Failed to execute 'kill' command. Is 'kill' in your PATH? Error: {}", e);
}
}
}
Err(_) => {
log::error!("Could not read PID file '{}'. Is the daemon running in this directory?", pid_file);
}
}
return;
}
}
}
@ -110,13 +168,23 @@ async fn main() {
// 3⃣ Shared state for UI and serial reader
let ltc_state = Arc::new(Mutex::new(LtcState::new()));
// 4⃣ Spawn the serial reader thread
// 4⃣ Find serial port and spawn the serial reader thread
let serial_port_path = match find_serial_port() {
Some(port) => port,
None => {
log::error!("❌ No serial port found. Please connect the Teensy device.");
return;
}
};
log::info!("Found serial port: {}", serial_port_path);
{
let tx_clone = tx.clone();
let state_clone = ltc_state.clone();
let port_clone = serial_port_path.clone();
thread::spawn(move || {
start_serial_thread(
"/dev/ttyACM0",
&port_clone,
115200,
tx_clone,
state_clone,
@ -132,7 +200,7 @@ async fn main() {
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);
});

View file

@ -32,7 +32,7 @@ pub fn start_serial_thread(
let reader = std::io::BufReader::new(port);
let re = Regex::new(
r"\[(LOCK|FREE)\]\s+(\d{2}):(\d{2}):(\d{2})[:;](\d{2})\s+\|\s+([\d.]+)fps",
r"\[(LOCK|FREE)\]\s+(\d{2}):(\d{2}):(\d{2})([:;])(\d{2})\s+\|\s+([\d.]+)fps",
)
.unwrap();
@ -65,7 +65,7 @@ mod tests {
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()
}

View file

@ -24,6 +24,7 @@ pub struct LtcFrame {
pub minutes: u32,
pub seconds: u32,
pub frames: u32,
pub is_drop_frame: bool,
pub frame_rate: Ratio<i64>,
pub timestamp: DateTime<Utc>, // arrival stamp
}
@ -35,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: get_frame_rate_ratio(&caps[6])?,
is_drop_frame: &caps[5] == ";",
frames: caps[6].parse().ok()?,
frame_rate: get_frame_rate_ratio(&caps[7])?,
timestamp,
})
}
@ -205,6 +207,7 @@ mod tests {
minutes: m,
seconds: s,
frames: 0,
is_drop_frame: false,
frame_rate: Ratio::new(25, 1),
timestamp: Utc::now(),
}

View file

@ -45,21 +45,13 @@ pub fn calculate_target_time(frame: &LtcFrame, config: &Config) -> DateTime<Loca
let timecode_secs =
frame.hours as i64 * 3600 + frame.minutes as i64 * 60 + frame.seconds as i64;
// Total duration in seconds as a rational number, including frames
// 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;
// For fractional frame rates (23.98, 29.97), timecode runs slower than wall clock.
// We need to scale the timecode duration up to get wall clock time.
// The scaling factor is 1001/1000.
let scaled_duration_secs = if *frame.frame_rate.denom() == 1001 {
total_duration_secs * Ratio::new(1001, 1000)
} else {
total_duration_secs
};
// Convert to milliseconds
let total_ms = (scaled_duration_secs * Ratio::new(1000, 1))
let total_ms = (total_duration_secs * Ratio::new(1000, 1))
.round()
.to_integer();
@ -157,19 +149,20 @@ pub fn nudge_clock(microseconds: i64) -> Result<(), ()> {
pub fn set_date(date: &str) -> Result<(), ()> {
#[cfg(target_os = "linux")]
{
let datetime_str = format!("{} 10:00:00", date);
let success = Command::new("sudo")
.arg("date")
.arg("--set")
.arg(date)
.arg(&datetime_str)
.status()
.map(|s| s.success())
.unwrap_or(false);
if success {
log::info!("Set system date to {}", date);
log::info!("Set system date and time to {}", datetime_str);
Ok(())
} else {
log::error!("Failed to set system date");
log::error!("Failed to set system date and time");
Err(())
}
}
@ -196,6 +189,7 @@ mod tests {
minutes: m,
seconds: s,
frames: f,
is_drop_frame: false,
frame_rate: Ratio::new(25, 1),
timestamp: Utc::now(),
}

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
static/assets/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3 KiB

BIN
static/assets/header.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 898 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 981 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 955 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 913 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 810 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 805 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 804 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

BIN
static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

43
static/icon-map.js Normal file
View 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%.' }
}
};

View file

@ -5,47 +5,63 @@
<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">
<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>
<h2>LTC Input</h2>
<p id="ltc-timecode">--:--:--:--</p>
<p id="ltc-status">--</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>Date: <span id="system-date">---- -- --</span></p>
<p>NTP Service: <span id="ntp-active">--</span></p>
<p>Sync Status: <span id="sync-status">--</span></p>
</div>
<!-- Delta & Jitter -->
<div class="card">
<h2>Clock Offset</h2>
<p>Delta: <span id="delta-ms">--</span> ms (<span id="delta-frames">--</span> frames)</p>
<p>Jitter: <span id="jitter-status">--</span></p>
<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">
<h2>Network</h2>
<ul id="interfaces">
<li>--</li>
</ul>
<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">
<h2>Controls</h2>
<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">
@ -97,15 +113,29 @@
<button id="set-date">Set Date</button>
<span id="date-message"></span>
</div>
</div>
</div>
<!-- Logs -->
<div class="card full-width">
<h2>Logs</h2>
<pre id="logs" class="log-box"></pre>
<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
View 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.' ]
}
};

View file

@ -1,4 +1,9 @@
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;
@ -11,9 +16,9 @@ document.addEventListener('DOMContentLoaded', () => {
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'),
};
@ -40,37 +45,110 @@ document.addEventListener('DOMContentLoaded', () => {
const setDateButton = document.getElementById('set-date');
const dateMessage = document.getElementById('date-message');
// --- Collapsible Sections ---
const controlsToggle = document.getElementById('controls-toggle');
const controlsContent = document.getElementById('controls-content');
const logsToggle = document.getElementById('logs-toggle');
const logsContent = document.getElementById('logs-content');
// --- Mock Controls Setup ---
const mockControls = document.getElementById('mock-controls');
const mockDataSelector = document.getElementById('mock-data-selector');
function setupMockControls() {
if (useMockData) {
mockControls.style.display = 'block';
// Populate dropdown
Object.keys(mockApiDataSets).forEach(key => {
const option = document.createElement('option');
option.value = key;
option.textContent = key.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase());
mockDataSelector.appendChild(option);
});
mockDataSelector.value = currentMockSetKey;
// Handle selection change
mockDataSelector.addEventListener('change', (event) => {
currentMockSetKey = event.target.value;
// Re-fetch all data from the new mock set
fetchStatus();
fetchConfig();
fetchLogs();
});
}
}
function updateStatus(data) {
statusElements.ltcStatus.textContent = data.ltc_status;
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;
statusElements.frameRate.textContent = data.frame_rate;
statusElements.lockRatio.textContent = data.lock_ratio.toFixed(2);
const frameRate = data.frame_rate || 'unknown';
const frameRateIconInfo = iconMap.frameRate[frameRate] || iconMap.frameRate.default;
statusElements.frameRate.innerHTML = `<img src="${frameRateIconInfo.src}" class="status-icon" alt="" title="${frameRateIconInfo.tooltip}">`;
const lockRatio = data.lock_ratio;
let lockRatioCategory;
if (lockRatio === 100) {
lockRatioCategory = 'good';
} else if (lockRatio >= 90) {
lockRatioCategory = 'average';
} else {
lockRatioCategory = 'bad';
}
const lockRatioIconInfo = iconMap.lockRatio[lockRatioCategory];
statusElements.lockRatio.innerHTML = `<img src="${lockRatioIconInfo.src}" class="status-icon" alt="" title="${lockRatioIconInfo.tooltip}">`;
statusElements.systemClock.textContent = data.system_clock;
statusElements.systemDate.textContent = data.system_date;
statusElements.ntpActive.textContent = data.ntp_active ? 'Active' : 'Inactive';
statusElements.ntpActive.className = data.ntp_active ? 'active' : 'inactive';
// Autofill the date input, but don't overwrite user edits.
if (!lastApiData || dateInput.value === lastApiData.system_date) {
dateInput.value = data.system_date;
}
statusElements.syncStatus.textContent = data.sync_status;
statusElements.syncStatus.className = data.sync_status.replace(/\s+/g, '-').toLowerCase();
statusElements.deltaMs.textContent = data.timecode_delta_ms;
statusElements.deltaFrames.textContent = data.timecode_delta_frames;
statusElements.jitterStatus.textContent = data.jitter_status;
statusElements.jitterStatus.className = data.jitter_status.toLowerCase();
statusElements.interfaces.innerHTML = '';
if (data.interfaces.length > 0) {
data.interfaces.forEach(ip => {
const li = document.createElement('li');
li.textContent = ip;
statusElements.interfaces.appendChild(li);
});
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 {
const li = document.createElement('li');
li.textContent = 'No active interfaces found.';
statusElements.interfaces.appendChild(li);
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.';
}
}
@ -98,9 +176,11 @@ document.addEventListener('DOMContentLoaded', () => {
}
// Animate LTC Timecode - only if status is LOCK
if (lastApiData.ltc_status === 'LOCK' && lastApiData.ltc_timecode && lastApiData.ltc_timecode.includes(':') && lastApiData.frame_rate) {
const tcParts = lastApiData.ltc_timecode.split(':');
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);
@ -126,12 +206,19 @@ document.addEventListener('DOMContentLoaded', () => {
h %= 24;
statusElements.ltcTimecode.textContent =
`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}:${String(f).padStart(2, '0')}`;
`${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');
@ -147,6 +234,18 @@ document.addEventListener('DOMContentLoaded', () => {
}
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');
@ -178,6 +277,14 @@ document.addEventListener('DOMContentLoaded', () => {
}
};
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',
@ -193,13 +300,21 @@ document.addEventListener('DOMContentLoaded', () => {
}
async function fetchLogs() {
if (useMockData) {
// Use a copy to avoid mutating the original mock data array
const logs = mockApiDataSets[currentMockSetKey].logs.slice();
// Show latest 20 logs, with the newest at the top.
logs.reverse();
statusElements.logs.textContent = logs.slice(0, 20).join('\n');
return;
}
try {
const response = await fetch('/api/logs');
if (!response.ok) throw new Error('Failed to fetch logs');
const logs = await response.json();
statusElements.logs.textContent = logs.join('\n');
// Auto-scroll to the bottom
statusElements.logs.scrollTop = statusElements.logs.scrollHeight;
// Show latest 20 logs, with the newest at the top.
logs.reverse();
statusElements.logs.textContent = logs.slice(0, 20).join('\n');
} catch (error) {
console.error('Error fetching logs:', error);
statusElements.logs.textContent = 'Error fetching logs.';
@ -208,6 +323,11 @@ document.addEventListener('DOMContentLoaded', () => {
async function triggerManualSync() {
syncMessage.textContent = 'Issuing sync command...';
if (useMockData) {
syncMessage.textContent = 'Success: Manual sync triggered (mock).';
setTimeout(() => { syncMessage.textContent = ''; }, 5000);
return;
}
try {
const response = await fetch('/api/sync', { method: 'POST' });
const data = await response.json();
@ -225,6 +345,11 @@ document.addEventListener('DOMContentLoaded', () => {
async function nudgeClock(ms) {
nudgeMessage.textContent = 'Nudging clock...';
if (useMockData) {
nudgeMessage.textContent = `Success: Clock nudged by ${ms}ms (mock).`;
setTimeout(() => { nudgeMessage.textContent = ''; }, 3000);
return;
}
try {
const response = await fetch('/api/nudge_clock', {
method: 'POST',
@ -252,6 +377,13 @@ document.addEventListener('DOMContentLoaded', () => {
}
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',
@ -285,13 +417,27 @@ document.addEventListener('DOMContentLoaded', () => {
});
setDateButton.addEventListener('click', setDate);
// --- Collapsible Section Listeners ---
controlsToggle.addEventListener('click', () => {
const isActive = controlsContent.classList.toggle('active');
controlsToggle.classList.toggle('active', isActive);
});
logsToggle.addEventListener('click', () => {
const isActive = logsContent.classList.toggle('active');
logsToggle.classList.toggle('active', isActive);
});
// Initial data load
setupMockControls();
fetchStatus();
fetchConfig();
fetchLogs();
// Refresh data every 2 seconds
setInterval(fetchStatus, 2000);
setInterval(fetchLogs, 2000);
// Refresh data every 2 seconds if not using mock data
if (!useMockData) {
setInterval(fetchStatus, 2000);
setInterval(fetchLogs, 2000);
}
setInterval(animateClocks, 50); // High-frequency clock animation
});

View file

@ -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: 300px;
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 {
@ -82,6 +114,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 +218,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; }