Compare commits
No commits in common. "b510af2d8dba5383668f69cc36eba8e090fadb65" and "bda4d4e6f55ae638bb772433451db38be5e0f236" have entirely different histories.
b510af2d8d
...
bda4d4e6f5
|
|
@ -29,16 +29,9 @@ Created by Chris Frankland-Wright and John Rogers
|
|||
|
||||
---
|
||||
|
||||
## 🛠️ 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
|
||||
cargo install --git https://github.com/cjfranko/NTP-Timeturner
|
||||
|
|
|
|||
110
docs/api.md
|
|
@ -4,22 +4,17 @@ This document describes the HTTP API for the NTP Timeturner application.
|
|||
|
||||
## Endpoints
|
||||
|
||||
### Status and Logs
|
||||
### Status
|
||||
|
||||
- **`GET /api/status`**
|
||||
|
||||
Retrieves the real-time status of the LTC reader and system clock synchronization. The `ltc_timecode` field uses `:` as a separator for non-drop-frame timecode, and `;` for drop-frame timecode between seconds and frames (e.g., `10:20:30;00`).
|
||||
|
||||
**Possible values for status fields:**
|
||||
- `ltc_status`: `"LOCK"`, `"FREE"`, or `"(waiting)"`
|
||||
- `sync_status`: `"IN SYNC"`, `"CLOCK AHEAD"`, `"CLOCK BEHIND"`, `"TIMETURNING"`
|
||||
- `jitter_status`: `"GOOD"`, `"AVERAGE"`, `"BAD"`
|
||||
Retrieves the real-time status of the LTC reader and system clock synchronization.
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"ltc_status": "LOCK",
|
||||
"ltc_timecode": "10:20:30;00",
|
||||
"ltc_timecode": "10:20:30:00",
|
||||
"frame_rate": "25.00fps",
|
||||
"system_clock": "10:20:30.005",
|
||||
"system_date": "2025-07-30",
|
||||
|
|
@ -30,23 +25,11 @@ This document describes the HTTP API for the NTP Timeturner application.
|
|||
"lock_ratio": 99.5,
|
||||
"ntp_active": true,
|
||||
"interfaces": ["192.168.1.100"],
|
||||
"hardware_offset_ms": 20
|
||||
"hardware_offset_ms": 0
|
||||
}
|
||||
```
|
||||
|
||||
- **`GET /api/logs`**
|
||||
|
||||
Retrieves the last 100 log entries from the application.
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
[
|
||||
"2025-08-07 10:00:00 [INFO] Starting TimeTurner daemon...",
|
||||
"2025-08-07 10:00:01 [INFO] Found serial port: /dev/ttyACM0"
|
||||
]
|
||||
```
|
||||
|
||||
### System Clock Control
|
||||
### Sync
|
||||
|
||||
- **`POST /api/sync`**
|
||||
|
||||
|
|
@ -54,7 +37,7 @@ This document describes the HTTP API for the NTP Timeturner application.
|
|||
|
||||
**Request Body:** None
|
||||
|
||||
**Success Response (200 OK):**
|
||||
**Success Response:**
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
|
|
@ -62,14 +45,13 @@ This document describes the HTTP API for the NTP Timeturner application.
|
|||
}
|
||||
```
|
||||
|
||||
**Error Response (400 Bad Request):**
|
||||
**Error Responses:**
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "No LTC timecode available to sync to."
|
||||
}
|
||||
```
|
||||
**Error Response (500 Internal Server Error):**
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
|
|
@ -77,32 +59,6 @@ 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.
|
||||
|
|
@ -114,7 +70,7 @@ This document describes the HTTP API for the NTP Timeturner application.
|
|||
}
|
||||
```
|
||||
|
||||
**Success Response (200 OK):**
|
||||
**Success Response:**
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
|
|
@ -122,7 +78,7 @@ This document describes the HTTP API for the NTP Timeturner application.
|
|||
}
|
||||
```
|
||||
|
||||
**Error Response (500 Internal Server Error):**
|
||||
**Error Response:**
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
|
|
@ -134,63 +90,29 @@ This document describes the HTTP API for the NTP Timeturner application.
|
|||
|
||||
- **`GET /api/config`**
|
||||
|
||||
Retrieves the current application configuration from `config.yml`.
|
||||
Retrieves the current application configuration.
|
||||
|
||||
**Example Response (200 OK):**
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"hardwareOffsetMs": 20,
|
||||
"timeturnerOffset": {
|
||||
"hours": 0,
|
||||
"minutes": 0,
|
||||
"seconds": 0,
|
||||
"frames": 0,
|
||||
"milliseconds": 0
|
||||
},
|
||||
"defaultNudgeMs": 2,
|
||||
"autoSyncEnabled": false
|
||||
"hardware_offset_ms": 0
|
||||
}
|
||||
```
|
||||
|
||||
- **`POST /api/config`**
|
||||
|
||||
Updates the application configuration. The new configuration is persisted to `config.yml` and takes effect immediately.
|
||||
Updates the `hardware_offset_ms` configuration. The new value is persisted to `config.json` and reloaded by the application automatically.
|
||||
|
||||
**Example Request:**
|
||||
```json
|
||||
{
|
||||
"hardwareOffsetMs": 55,
|
||||
"timeturnerOffset": {
|
||||
"hours": 1,
|
||||
"minutes": 2,
|
||||
"seconds": 3,
|
||||
"frames": 4,
|
||||
"milliseconds": 5
|
||||
},
|
||||
"defaultNudgeMs": 2,
|
||||
"autoSyncEnabled": true
|
||||
"hardware_offset_ms": 10
|
||||
}
|
||||
```
|
||||
|
||||
**Success Response (200 OK):** (Returns the updated configuration)
|
||||
**Success Response:**
|
||||
```json
|
||||
{
|
||||
"hardwareOffsetMs": 55,
|
||||
"timeturnerOffset": {
|
||||
"hours": 1,
|
||||
"minutes": 2,
|
||||
"seconds": 3,
|
||||
"frames": 4,
|
||||
"milliseconds": 5
|
||||
},
|
||||
"defaultNudgeMs": 2,
|
||||
"autoSyncEnabled": true
|
||||
}
|
||||
```
|
||||
**Error Response (500 Internal Server Error):**
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Failed to write config.yml"
|
||||
"hardware_offset_ms": 10
|
||||
}
|
||||
```
|
||||
|
|
|
|||
33
src/api.rs
|
|
@ -47,11 +47,7 @@ async fn get_status(data: web::Data<AppState>) -> impl Responder {
|
|||
|
||||
let ltc_status = state.latest.as_ref().map_or("(waiting)".to_string(), |f| f.status.clone());
|
||||
let ltc_timecode = state.latest.as_ref().map_or("…".to_string(), |f| {
|
||||
let sep = if f.is_drop_frame { ';' } else { ':' };
|
||||
format!(
|
||||
"{:02}:{:02}:{:02}{}{:02}",
|
||||
f.hours, f.minutes, f.seconds, sep, f.frames
|
||||
)
|
||||
format!("{:02}:{:02}:{:02}:{:02}", f.hours, f.minutes, f.seconds, f.frames)
|
||||
});
|
||||
let frame_rate = state.latest.as_ref().map_or("…".to_string(), |f| {
|
||||
format!("{:.2}fps", f.frame_rate.to_f64().unwrap_or(0.0))
|
||||
|
|
@ -246,7 +242,6 @@ mod tests {
|
|||
minutes: 2,
|
||||
seconds: 3,
|
||||
frames: 4,
|
||||
is_drop_frame: false,
|
||||
frame_rate: Ratio::new(25, 1),
|
||||
timestamp: Utc::now(),
|
||||
}),
|
||||
|
|
@ -295,32 +290,6 @@ mod tests {
|
|||
assert_eq!(resp.hardware_offset_ms, 10);
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn test_get_status_drop_frame() {
|
||||
let app_state = get_test_app_state();
|
||||
// Set state to drop frame
|
||||
app_state
|
||||
.ltc_state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.latest
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.is_drop_frame = true;
|
||||
|
||||
let app = test::init_service(
|
||||
App::new()
|
||||
.app_data(app_state.clone())
|
||||
.service(get_status),
|
||||
)
|
||||
.await;
|
||||
|
||||
let req = test::TestRequest::get().uri("/api/status").to_request();
|
||||
let resp: ApiStatus = test::call_and_read_body_json(&app, req).await;
|
||||
|
||||
assert_eq!(resp.ltc_timecode, "01:02:03;04");
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn test_get_config() {
|
||||
let app_state = get_test_app_state();
|
||||
|
|
|
|||
|
|
@ -64,7 +64,6 @@ impl Config {
|
|||
Self::default()
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
|
|
|
|||
80
src/main.rs
|
|
@ -15,7 +15,6 @@ use crate::sync_logic::LtcState;
|
|||
use crate::ui::start_ui;
|
||||
use clap::Parser;
|
||||
use daemonize::Daemonize;
|
||||
use serialport;
|
||||
|
||||
use std::{
|
||||
fs,
|
||||
|
|
@ -36,8 +35,6 @@ struct Args {
|
|||
enum Command {
|
||||
/// Run as a background daemon providing a web UI.
|
||||
Daemon,
|
||||
/// Stop the running daemon process.
|
||||
Kill,
|
||||
}
|
||||
|
||||
/// Default config content, embedded in the binary.
|
||||
|
|
@ -73,36 +70,18 @@ fn ensure_config() {
|
|||
}
|
||||
}
|
||||
|
||||
fn find_serial_port() -> Option<String> {
|
||||
if let Ok(ports) = serialport::available_ports() {
|
||||
for p in ports {
|
||||
if p.port_name.starts_with("/dev/ttyACM")
|
||||
|| p.port_name.starts_with("/dev/ttyAMA")
|
||||
|| p.port_name.starts_with("/dev/ttyUSB")
|
||||
{
|
||||
return Some(p.port_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn main() {
|
||||
// This must be called before any logging statements.
|
||||
let log_buffer = logger::setup_logger();
|
||||
let args = Args::parse();
|
||||
|
||||
if let Some(command) = &args.command {
|
||||
match command {
|
||||
Command::Daemon => {
|
||||
if let Some(Command::Daemon) = &args.command {
|
||||
log::info!("🚀 Starting daemon...");
|
||||
|
||||
// Create files for stdout and stderr in the current directory
|
||||
let stdout =
|
||||
fs::File::create("daemon.out").expect("Could not create daemon.out");
|
||||
let stderr =
|
||||
fs::File::create("daemon.err").expect("Could not create daemon.err");
|
||||
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
|
||||
|
|
@ -118,43 +97,6 @@ async fn main() {
|
|||
}
|
||||
}
|
||||
}
|
||||
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();
|
||||
|
|
@ -168,23 +110,13 @@ async fn main() {
|
|||
// 3️⃣ Shared state for UI and serial reader
|
||||
let ltc_state = Arc::new(Mutex::new(LtcState::new()));
|
||||
|
||||
// 4️⃣ Find serial port and spawn the serial reader thread
|
||||
let serial_port_path = match find_serial_port() {
|
||||
Some(port) => port,
|
||||
None => {
|
||||
log::error!("❌ No serial port found. Please connect the Teensy device.");
|
||||
return;
|
||||
}
|
||||
};
|
||||
log::info!("Found serial port: {}", serial_port_path);
|
||||
|
||||
// 4️⃣ Spawn the serial reader thread
|
||||
{
|
||||
let tx_clone = tx.clone();
|
||||
let state_clone = ltc_state.clone();
|
||||
let port_clone = serial_port_path.clone();
|
||||
thread::spawn(move || {
|
||||
start_serial_thread(
|
||||
&port_clone,
|
||||
"/dev/ttyACM0",
|
||||
115200,
|
||||
tx_clone,
|
||||
state_clone,
|
||||
|
|
@ -200,7 +132,7 @@ async fn main() {
|
|||
log::info!("🖥️ UI thread launched");
|
||||
let ui_state = ltc_state.clone();
|
||||
let config_clone = config.clone();
|
||||
let port = serial_port_path;
|
||||
let port = "/dev/ttyACM0".to_string();
|
||||
thread::spawn(move || {
|
||||
start_ui(ui_state, port, config_clone);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ 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
|
||||
}
|
||||
|
|
@ -36,9 +35,8 @@ impl LtcFrame {
|
|||
hours: caps[2].parse().ok()?,
|
||||
minutes: caps[3].parse().ok()?,
|
||||
seconds: caps[4].parse().ok()?,
|
||||
is_drop_frame: &caps[5] == ";",
|
||||
frames: caps[6].parse().ok()?,
|
||||
frame_rate: get_frame_rate_ratio(&caps[7])?,
|
||||
frames: caps[5].parse().ok()?,
|
||||
frame_rate: get_frame_rate_ratio(&caps[6])?,
|
||||
timestamp,
|
||||
})
|
||||
}
|
||||
|
|
@ -207,7 +205,6 @@ mod tests {
|
|||
minutes: m,
|
||||
seconds: s,
|
||||
frames: 0,
|
||||
is_drop_frame: false,
|
||||
frame_rate: Ratio::new(25, 1),
|
||||
timestamp: Utc::now(),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,13 +45,21 @@ 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;
|
||||
|
||||
// Timecode is always treated as wall-clock time. NDF scaling is not applied
|
||||
// as the LTC source appears to be pre-compensated.
|
||||
// Total duration in seconds as a rational number, including frames
|
||||
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 = (total_duration_secs * Ratio::new(1000, 1))
|
||||
let total_ms = (scaled_duration_secs * Ratio::new(1000, 1))
|
||||
.round()
|
||||
.to_integer();
|
||||
|
||||
|
|
@ -149,20 +157,19 @@ pub fn nudge_clock(microseconds: i64) -> Result<(), ()> {
|
|||
pub fn set_date(date: &str) -> Result<(), ()> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let datetime_str = format!("{} 10:00:00", date);
|
||||
let success = Command::new("sudo")
|
||||
.arg("date")
|
||||
.arg("--set")
|
||||
.arg(&datetime_str)
|
||||
.arg(date)
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
|
||||
if success {
|
||||
log::info!("Set system date and time to {}", datetime_str);
|
||||
log::info!("Set system date to {}", date);
|
||||
Ok(())
|
||||
} else {
|
||||
log::error!("Failed to set system date and time");
|
||||
log::error!("Failed to set system date");
|
||||
Err(())
|
||||
}
|
||||
}
|
||||
|
|
@ -189,7 +196,6 @@ mod tests {
|
|||
minutes: m,
|
||||
seconds: s,
|
||||
frames: f,
|
||||
is_drop_frame: false,
|
||||
frame_rate: Ratio::new(25, 1),
|
||||
timestamp: Utc::now(),
|
||||
}
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 3 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 898 B |
|
Before Width: | Height: | Size: 981 B |
|
Before Width: | Height: | Size: 955 B |
|
Before Width: | Height: | Size: 913 B |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 810 B |
|
Before Width: | Height: | Size: 805 B |
|
Before Width: | Height: | Size: 804 B |
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 198 KiB |
|
|
@ -1,43 +0,0 @@
|
|||
// In this file, you can define the paths to your local icon image files.
|
||||
const iconMap = {
|
||||
ltcStatus: {
|
||||
'LOCK': { src: 'assets/timeturner_ltc_green.png', tooltip: 'LTC signal is locked and stable.' },
|
||||
'FREE': { src: 'assets/timeturner_ltc_orange.png', tooltip: 'LTC signal is in freewheel mode.' },
|
||||
'default': { src: 'assets/timeturner_ltc_red.png', tooltip: 'LTC signal is not detected.' }
|
||||
},
|
||||
ntpActive: {
|
||||
true: { src: 'assets/timeturner_ntp_green.png', tooltip: 'NTP service is active.' },
|
||||
false: { src: 'assets/timeturner_ntp_red.png', tooltip: 'NTP service is inactive.' }
|
||||
},
|
||||
syncStatus: {
|
||||
'IN SYNC': { src: 'assets/timeturner_sync_green.png', tooltip: 'System clock is in sync with LTC source.' },
|
||||
'CLOCK AHEAD': { src: 'assets/timeturner_sync_orange.png', tooltip: 'System clock is ahead of the LTC source.' },
|
||||
'CLOCK BEHIND': { src: 'assets/timeturner_sync_orange.png', tooltip: 'System clock is behind the LTC source.' },
|
||||
'TIMETURNING': { src: 'assets/timeturner_timeturning.png', tooltip: 'Timeturner offset is active.' },
|
||||
'default': { src: 'assets/timeturner_sync_red.png', tooltip: 'Sync status is unknown.' }
|
||||
},
|
||||
jitterStatus: {
|
||||
'GOOD': { src: 'assets/timeturner_jitter_green.png', tooltip: 'Clock jitter is within acceptable limits.' },
|
||||
'AVERAGE': { src: 'assets/timeturner_jitter_orange.png', tooltip: 'Clock jitter is moderate.' },
|
||||
'BAD': { src: 'assets/timeturner_jitter_red.png', tooltip: 'Clock jitter is high and may affect accuracy.' },
|
||||
'default': { src: 'assets/timeturner_jitter_red.png', tooltip: 'Jitter status is unknown.' }
|
||||
},
|
||||
deltaStatus: {
|
||||
'good': { src: 'assets/timeturner_delta_green.png', tooltip: 'Clock delta is 0ms.' },
|
||||
'average': { src: 'assets/timeturner_delta_orange.png', tooltip: 'Clock delta is less than 10ms.' },
|
||||
'bad': { src: 'assets/timeturner_delta_red.png', tooltip: 'Clock delta is 10ms or greater.' }
|
||||
},
|
||||
frameRate: {
|
||||
'23.98fps': { src: 'assets/timeturner_2398.png', tooltip: '23.98 frames per second' },
|
||||
'24.00fps': { src: 'assets/timeturner_24.png', tooltip: '24.00 frames per second' },
|
||||
'25.00fps': { src: 'assets/timeturner_25.png', tooltip: '25.00 frames per second' },
|
||||
'29.97fps': { src: 'assets/timeturner_2997.png', tooltip: '29.97 frames per second' },
|
||||
'30.00fps': { src: 'assets/timeturner_30.png', tooltip: '30.00 frames per second' },
|
||||
'default': { src: 'assets/timeturner_default.png', tooltip: 'Unknown frame rate' }
|
||||
},
|
||||
lockRatio: {
|
||||
'good': { src: 'assets/timeturner_lock_green.png', tooltip: 'Lock ratio is 100%.' },
|
||||
'average': { src: 'assets/timeturner_lock_orange.png', tooltip: 'Lock ratio is 90% or higher.' },
|
||||
'bad': { src: 'assets/timeturner_lock_red.png', tooltip: 'Lock ratio is below 90%.' }
|
||||
}
|
||||
};
|
||||
|
|
@ -5,63 +5,47 @@
|
|||
<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>
|
||||
|
||||
<h1>NTP TimeTurner</h1>
|
||||
<div class="grid">
|
||||
<!-- LTC Status -->
|
||||
<div class="card">
|
||||
<h2>LTC Input</h2>
|
||||
<h2>LTC Status</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>
|
||||
<p id="ltc-status">--</p>
|
||||
<p id="frame-rate">-- fps</p>
|
||||
<p>Lock Ratio: <span id="lock-ratio">--</span>%</p>
|
||||
</div>
|
||||
|
||||
<!-- System Clock & Sync -->
|
||||
<div class="card">
|
||||
<h2>NTP Clock</h2>
|
||||
<h2>System Clock</h2>
|
||||
<p id="system-clock">--:--:--.---</p>
|
||||
<p class="system-date-display"><span id="system-date">---- -- --</span></p>
|
||||
<div class="icon-group">
|
||||
<span id="ntp-active"></span>
|
||||
<span id="sync-status"></span>
|
||||
<span id="jitter-status"></span>
|
||||
<span id="delta-status"></span>
|
||||
<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>
|
||||
<p id="delta-text">Δ -- ms (-- frames)</p>
|
||||
|
||||
<!-- Delta & Jitter -->
|
||||
<div class="card">
|
||||
<h2>Clock Offset</h2>
|
||||
<p>Delta: <span id="delta-ms">--</span> ms (<span id="delta-frames">--</span> frames)</p>
|
||||
<p>Jitter: <span id="jitter-status">--</span></p>
|
||||
</div>
|
||||
|
||||
<!-- Network Interfaces -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<img src="assets/timeturner_network.png" class="header-icon" alt="Network Icon">
|
||||
<h2>Network</h2>
|
||||
</div>
|
||||
<p id="interfaces">--</p>
|
||||
<ul id="interfaces">
|
||||
<li>--</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
<div class="card full-width collapsible-card">
|
||||
<div class="toggle-header" id="controls-toggle">
|
||||
<img src="assets/timeturner_controls.png" class="toggle-icon" alt="Controls Icon">
|
||||
<div class="card full-width">
|
||||
<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">
|
||||
|
|
@ -114,28 +98,14 @@
|
|||
<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">
|
||||
<div class="card full-width">
|
||||
<h2>Logs</h2>
|
||||
</div>
|
||||
<div class="collapsible-content" id="logs-content">
|
||||
<pre id="logs" class="log-box"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<footer>
|
||||
<p>
|
||||
Built by Chris Frankland-Wright and John Rogers | Have Blue Broadcast Media |
|
||||
<a href="https://github.com/cjfranko/NTP-Timeturner" target="_blank" rel="noopener noreferrer">https://github.com/cjfranko/NTP-Timeturner</a>
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
<script src="icon-map.js"></script>
|
||||
<script src="mock-data.js"></script>
|
||||
<script src="script.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,168 +0,0 @@
|
|||
// This file contains mock data sets for UI development and testing without a live backend.
|
||||
const mockApiDataSets = {
|
||||
allGood: {
|
||||
status: {
|
||||
ltc_status: 'LOCK',
|
||||
ltc_timecode: '10:20:30:00',
|
||||
frame_rate: '25.00fps',
|
||||
lock_ratio: 99.5,
|
||||
system_clock: '10:20:30.500',
|
||||
system_date: '2025-08-07',
|
||||
ntp_active: true,
|
||||
sync_status: 'IN SYNC',
|
||||
timecode_delta_ms: 5,
|
||||
timecode_delta_frames: 0.125,
|
||||
jitter_status: 'GOOD',
|
||||
interfaces: ['192.168.1.100/24 (eth0)', '10.0.0.5/8 (wlan0)'],
|
||||
},
|
||||
config: {
|
||||
hardwareOffsetMs: 10,
|
||||
autoSyncEnabled: true,
|
||||
defaultNudgeMs: 2,
|
||||
timeturnerOffset: { hours: 1, minutes: 2, seconds: 3, frames: 4, milliseconds: 50 },
|
||||
},
|
||||
logs: [
|
||||
'2025-08-07 10:20:30 [INFO] Starting up...',
|
||||
'2025-08-07 10:20:32 [INFO] LTC LOCK detected. Frame rate: 25.00fps.',
|
||||
'2025-08-07 10:20:35 [INFO] Initial sync complete. Clock adjusted by -15ms.',
|
||||
]
|
||||
},
|
||||
ltcFree: {
|
||||
status: {
|
||||
ltc_status: 'FREE',
|
||||
ltc_timecode: '11:22:33:11',
|
||||
frame_rate: '25.00fps',
|
||||
lock_ratio: 40.2,
|
||||
system_clock: '11:22:33.800',
|
||||
system_date: '2025-08-07',
|
||||
ntp_active: true,
|
||||
sync_status: 'IN SYNC',
|
||||
timecode_delta_ms: 3,
|
||||
timecode_delta_frames: 0.075,
|
||||
jitter_status: 'GOOD',
|
||||
interfaces: ['192.168.1.100/24 (eth0)'],
|
||||
},
|
||||
config: {
|
||||
hardwareOffsetMs: 10,
|
||||
autoSyncEnabled: true,
|
||||
defaultNudgeMs: 2,
|
||||
timeturnerOffset: { hours: 0, minutes: 0, seconds: 0, frames: 0, milliseconds: 0 },
|
||||
},
|
||||
logs: [ '2025-08-07 11:22:30 [WARN] LTC signal lost, entering freewheel.' ]
|
||||
},
|
||||
clockAhead: {
|
||||
status: {
|
||||
ltc_status: 'LOCK',
|
||||
ltc_timecode: '12:00:05:00',
|
||||
frame_rate: '25.00fps',
|
||||
lock_ratio: 98.1,
|
||||
system_clock: '12:00:04.500',
|
||||
system_date: '2025-08-07',
|
||||
ntp_active: true,
|
||||
sync_status: 'CLOCK AHEAD',
|
||||
timecode_delta_ms: -500,
|
||||
timecode_delta_frames: -12.5,
|
||||
jitter_status: 'AVERAGE',
|
||||
interfaces: ['192.168.1.100/24 (eth0)'],
|
||||
},
|
||||
config: {
|
||||
hardwareOffsetMs: 10,
|
||||
autoSyncEnabled: true,
|
||||
defaultNudgeMs: 2,
|
||||
timeturnerOffset: { hours: 0, minutes: 0, seconds: 0, frames: 0, milliseconds: 0 },
|
||||
},
|
||||
logs: [ '2025-08-07 12:00:00 [WARN] System clock is ahead of LTC source by 500ms.' ]
|
||||
},
|
||||
clockBehind: {
|
||||
status: {
|
||||
ltc_status: 'LOCK',
|
||||
ltc_timecode: '13:30:10:00',
|
||||
frame_rate: '25.00fps',
|
||||
lock_ratio: 99.9,
|
||||
system_clock: '13:30:10.800',
|
||||
system_date: '2025-08-07',
|
||||
ntp_active: true,
|
||||
sync_status: 'CLOCK BEHIND',
|
||||
timecode_delta_ms: 800,
|
||||
timecode_delta_frames: 20,
|
||||
jitter_status: 'AVERAGE',
|
||||
interfaces: ['192.168.1.100/24 (eth0)'],
|
||||
},
|
||||
config: {
|
||||
hardwareOffsetMs: 10,
|
||||
autoSyncEnabled: true,
|
||||
defaultNudgeMs: 2,
|
||||
timeturnerOffset: { hours: 0, minutes: 0, seconds: 0, frames: 0, milliseconds: 0 },
|
||||
},
|
||||
logs: [ '2025-08-07 13:30:00 [WARN] System clock is behind LTC source by 800ms.' ]
|
||||
},
|
||||
timeturning: {
|
||||
status: {
|
||||
ltc_status: 'LOCK',
|
||||
ltc_timecode: '14:00:00:00',
|
||||
frame_rate: '25.00fps',
|
||||
lock_ratio: 100,
|
||||
system_clock: '15:02:03.050',
|
||||
system_date: '2025-08-07',
|
||||
ntp_active: true,
|
||||
sync_status: 'TIMETURNING',
|
||||
timecode_delta_ms: 3723050, // a big number
|
||||
timecode_delta_frames: 93076,
|
||||
jitter_status: 'GOOD',
|
||||
interfaces: ['192.168.1.100/24 (eth0)'],
|
||||
},
|
||||
config: {
|
||||
hardwareOffsetMs: 10,
|
||||
autoSyncEnabled: false,
|
||||
defaultNudgeMs: 2,
|
||||
timeturnerOffset: { hours: 1, minutes: 2, seconds: 3, frames: 4, milliseconds: 50 },
|
||||
},
|
||||
logs: [ '2025-08-07 14:00:00 [INFO] Timeturner offset is active.' ]
|
||||
},
|
||||
badJitter: {
|
||||
status: {
|
||||
ltc_status: 'LOCK',
|
||||
ltc_timecode: '15:15:15:15',
|
||||
frame_rate: '25.00fps',
|
||||
lock_ratio: 95.0,
|
||||
system_clock: '15:15:15.515',
|
||||
system_date: '2025-08-07',
|
||||
ntp_active: true,
|
||||
sync_status: 'IN SYNC',
|
||||
timecode_delta_ms: 10,
|
||||
timecode_delta_frames: 0.25,
|
||||
jitter_status: 'BAD',
|
||||
interfaces: ['192.168.1.100/24 (eth0)'],
|
||||
},
|
||||
config: {
|
||||
hardwareOffsetMs: 10,
|
||||
autoSyncEnabled: true,
|
||||
defaultNudgeMs: 2,
|
||||
timeturnerOffset: { hours: 0, minutes: 0, seconds: 0, frames: 0, milliseconds: 0 },
|
||||
},
|
||||
logs: [ '2025-08-07 15:15:00 [ERROR] High jitter detected on LTC source.' ]
|
||||
},
|
||||
ntpInactive: {
|
||||
status: {
|
||||
ltc_status: 'UNKNOWN',
|
||||
ltc_timecode: '--:--:--:--',
|
||||
frame_rate: '--',
|
||||
lock_ratio: 0,
|
||||
system_clock: '16:00:00.000',
|
||||
system_date: '2025-08-07',
|
||||
ntp_active: false,
|
||||
sync_status: 'UNKNOWN',
|
||||
timecode_delta_ms: 0,
|
||||
timecode_delta_frames: 0,
|
||||
jitter_status: 'UNKNOWN',
|
||||
interfaces: [],
|
||||
},
|
||||
config: {
|
||||
hardwareOffsetMs: 0,
|
||||
autoSyncEnabled: false,
|
||||
defaultNudgeMs: 2,
|
||||
timeturnerOffset: { hours: 0, minutes: 0, seconds: 0, frames: 0, milliseconds: 0 },
|
||||
},
|
||||
logs: [ '2025-08-07 16:00:00 [INFO] NTP service is inactive.' ]
|
||||
}
|
||||
};
|
||||
206
static/script.js
|
|
@ -1,9 +1,4 @@
|
|||
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
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
let lastApiData = null;
|
||||
let lastApiFetchTime = null;
|
||||
|
||||
|
|
@ -16,9 +11,9 @@
|
|||
systemDate: document.getElementById('system-date'),
|
||||
ntpActive: document.getElementById('ntp-active'),
|
||||
syncStatus: document.getElementById('sync-status'),
|
||||
deltaStatus: document.getElementById('delta-status'),
|
||||
deltaMs: document.getElementById('delta-ms'),
|
||||
deltaFrames: document.getElementById('delta-frames'),
|
||||
jitterStatus: document.getElementById('jitter-status'),
|
||||
deltaText: document.getElementById('delta-text'),
|
||||
interfaces: document.getElementById('interfaces'),
|
||||
logs: document.getElementById('logs'),
|
||||
};
|
||||
|
|
@ -45,110 +40,37 @@
|
|||
const setDateButton = document.getElementById('set-date');
|
||||
const dateMessage = document.getElementById('date-message');
|
||||
|
||||
// --- Collapsible Sections ---
|
||||
const controlsToggle = document.getElementById('controls-toggle');
|
||||
const controlsContent = document.getElementById('controls-content');
|
||||
const logsToggle = document.getElementById('logs-toggle');
|
||||
const logsContent = document.getElementById('logs-content');
|
||||
|
||||
// --- Mock Controls Setup ---
|
||||
const mockControls = document.getElementById('mock-controls');
|
||||
const mockDataSelector = document.getElementById('mock-data-selector');
|
||||
|
||||
function setupMockControls() {
|
||||
if (useMockData) {
|
||||
mockControls.style.display = 'block';
|
||||
|
||||
// Populate dropdown
|
||||
Object.keys(mockApiDataSets).forEach(key => {
|
||||
const option = document.createElement('option');
|
||||
option.value = key;
|
||||
option.textContent = key.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase());
|
||||
mockDataSelector.appendChild(option);
|
||||
});
|
||||
|
||||
mockDataSelector.value = currentMockSetKey;
|
||||
|
||||
// Handle selection change
|
||||
mockDataSelector.addEventListener('change', (event) => {
|
||||
currentMockSetKey = event.target.value;
|
||||
// Re-fetch all data from the new mock set
|
||||
fetchStatus();
|
||||
fetchConfig();
|
||||
fetchLogs();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function updateStatus(data) {
|
||||
const ltcStatus = data.ltc_status || 'UNKNOWN';
|
||||
const ltcIconInfo = iconMap.ltcStatus[ltcStatus] || iconMap.ltcStatus.default;
|
||||
statusElements.ltcStatus.innerHTML = `<img src="${ltcIconInfo.src}" class="status-icon" alt="" title="${ltcIconInfo.tooltip}">`;
|
||||
statusElements.ltcStatus.className = ltcStatus.toLowerCase();
|
||||
statusElements.ltcStatus.textContent = data.ltc_status;
|
||||
statusElements.ltcTimecode.textContent = data.ltc_timecode;
|
||||
|
||||
const frameRate = data.frame_rate || 'unknown';
|
||||
const frameRateIconInfo = iconMap.frameRate[frameRate] || iconMap.frameRate.default;
|
||||
statusElements.frameRate.innerHTML = `<img src="${frameRateIconInfo.src}" class="status-icon" alt="" title="${frameRateIconInfo.tooltip}">`;
|
||||
|
||||
const lockRatio = data.lock_ratio;
|
||||
let lockRatioCategory;
|
||||
if (lockRatio === 100) {
|
||||
lockRatioCategory = 'good';
|
||||
} else if (lockRatio >= 90) {
|
||||
lockRatioCategory = 'average';
|
||||
} else {
|
||||
lockRatioCategory = 'bad';
|
||||
}
|
||||
const lockRatioIconInfo = iconMap.lockRatio[lockRatioCategory];
|
||||
statusElements.lockRatio.innerHTML = `<img src="${lockRatioIconInfo.src}" class="status-icon" alt="" title="${lockRatioIconInfo.tooltip}">`;
|
||||
statusElements.frameRate.textContent = data.frame_rate;
|
||||
statusElements.lockRatio.textContent = data.lock_ratio.toFixed(2);
|
||||
statusElements.systemClock.textContent = data.system_clock;
|
||||
statusElements.systemDate.textContent = data.system_date;
|
||||
|
||||
// Autofill the date input, but don't overwrite user edits.
|
||||
if (!lastApiData || dateInput.value === lastApiData.system_date) {
|
||||
dateInput.value = data.system_date;
|
||||
}
|
||||
statusElements.ntpActive.textContent = data.ntp_active ? 'Active' : 'Inactive';
|
||||
statusElements.ntpActive.className = data.ntp_active ? 'active' : 'inactive';
|
||||
|
||||
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';
|
||||
}
|
||||
statusElements.syncStatus.textContent = data.sync_status;
|
||||
statusElements.syncStatus.className = data.sync_status.replace(/\s+/g, '-').toLowerCase();
|
||||
|
||||
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();
|
||||
statusElements.deltaMs.textContent = data.timecode_delta_ms;
|
||||
statusElements.deltaFrames.textContent = data.timecode_delta_frames;
|
||||
|
||||
// Delta Status
|
||||
const deltaMs = data.timecode_delta_ms;
|
||||
let deltaCategory;
|
||||
if (deltaMs === 0) {
|
||||
deltaCategory = 'good';
|
||||
} else if (Math.abs(deltaMs) < 10) {
|
||||
deltaCategory = 'average';
|
||||
} else {
|
||||
deltaCategory = 'bad';
|
||||
}
|
||||
const deltaIconInfo = iconMap.deltaStatus[deltaCategory];
|
||||
statusElements.deltaStatus.innerHTML = `<img src="${deltaIconInfo.src}" class="status-icon" alt="" title="${deltaIconInfo.tooltip}">`;
|
||||
|
||||
const deltaTextValue = `${data.timecode_delta_ms} ms (${data.timecode_delta_frames} frames)`;
|
||||
statusElements.deltaText.textContent = `Δ ${deltaTextValue}`;
|
||||
|
||||
const jitterStatus = data.jitter_status || 'UNKNOWN';
|
||||
const jitterIconInfo = iconMap.jitterStatus[jitterStatus] || iconMap.jitterStatus.default;
|
||||
statusElements.jitterStatus.innerHTML = `<img src="${jitterIconInfo.src}" class="status-icon" alt="" title="${jitterIconInfo.tooltip}">`;
|
||||
statusElements.jitterStatus.className = jitterStatus.toLowerCase();
|
||||
statusElements.jitterStatus.textContent = data.jitter_status;
|
||||
statusElements.jitterStatus.className = data.jitter_status.toLowerCase();
|
||||
|
||||
statusElements.interfaces.innerHTML = '';
|
||||
if (data.interfaces.length > 0) {
|
||||
statusElements.interfaces.textContent = data.interfaces.join(' | ');
|
||||
data.interfaces.forEach(ip => {
|
||||
const li = document.createElement('li');
|
||||
li.textContent = ip;
|
||||
statusElements.interfaces.appendChild(li);
|
||||
});
|
||||
} else {
|
||||
statusElements.interfaces.textContent = 'No active interfaces found.';
|
||||
const li = document.createElement('li');
|
||||
li.textContent = 'No active interfaces found.';
|
||||
statusElements.interfaces.appendChild(li);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -176,11 +98,9 @@
|
|||
}
|
||||
|
||||
// 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(/[:;]/);
|
||||
if (lastApiData.ltc_status === 'LOCK' && lastApiData.ltc_timecode && lastApiData.ltc_timecode.includes(':') && lastApiData.frame_rate) {
|
||||
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);
|
||||
|
|
@ -206,19 +126,12 @@
|
|||
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')}`;
|
||||
`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}:${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');
|
||||
|
|
@ -234,18 +147,6 @@
|
|||
}
|
||||
|
||||
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');
|
||||
|
|
@ -277,14 +178,6 @@
|
|||
}
|
||||
};
|
||||
|
||||
if (useMockData) {
|
||||
console.log('Mock save:', config);
|
||||
alert('Configuration saved (mock).');
|
||||
// We can also update the mock data in memory to see changes reflected
|
||||
mockApiDataSets[currentMockSetKey].config = config;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/config', {
|
||||
method: 'POST',
|
||||
|
|
@ -300,21 +193,13 @@
|
|||
}
|
||||
|
||||
async function fetchLogs() {
|
||||
if (useMockData) {
|
||||
// Use a copy to avoid mutating the original mock data array
|
||||
const logs = mockApiDataSets[currentMockSetKey].logs.slice();
|
||||
// Show latest 20 logs, with the newest at the top.
|
||||
logs.reverse();
|
||||
statusElements.logs.textContent = logs.slice(0, 20).join('\n');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch('/api/logs');
|
||||
if (!response.ok) throw new Error('Failed to fetch logs');
|
||||
const logs = await response.json();
|
||||
// Show latest 20 logs, with the newest at the top.
|
||||
logs.reverse();
|
||||
statusElements.logs.textContent = logs.slice(0, 20).join('\n');
|
||||
statusElements.logs.textContent = logs.join('\n');
|
||||
// Auto-scroll to the bottom
|
||||
statusElements.logs.scrollTop = statusElements.logs.scrollHeight;
|
||||
} catch (error) {
|
||||
console.error('Error fetching logs:', error);
|
||||
statusElements.logs.textContent = 'Error fetching logs.';
|
||||
|
|
@ -323,11 +208,6 @@
|
|||
|
||||
async function triggerManualSync() {
|
||||
syncMessage.textContent = 'Issuing sync command...';
|
||||
if (useMockData) {
|
||||
syncMessage.textContent = 'Success: Manual sync triggered (mock).';
|
||||
setTimeout(() => { syncMessage.textContent = ''; }, 5000);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch('/api/sync', { method: 'POST' });
|
||||
const data = await response.json();
|
||||
|
|
@ -345,11 +225,6 @@
|
|||
|
||||
async function nudgeClock(ms) {
|
||||
nudgeMessage.textContent = 'Nudging clock...';
|
||||
if (useMockData) {
|
||||
nudgeMessage.textContent = `Success: Clock nudged by ${ms}ms (mock).`;
|
||||
setTimeout(() => { nudgeMessage.textContent = ''; }, 3000);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch('/api/nudge_clock', {
|
||||
method: 'POST',
|
||||
|
|
@ -377,13 +252,6 @@
|
|||
}
|
||||
|
||||
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',
|
||||
|
|
@ -417,27 +285,13 @@
|
|||
});
|
||||
setDateButton.addEventListener('click', setDate);
|
||||
|
||||
// --- Collapsible Section Listeners ---
|
||||
controlsToggle.addEventListener('click', () => {
|
||||
const isActive = controlsContent.classList.toggle('active');
|
||||
controlsToggle.classList.toggle('active', isActive);
|
||||
});
|
||||
|
||||
logsToggle.addEventListener('click', () => {
|
||||
const isActive = logsContent.classList.toggle('active');
|
||||
logsToggle.classList.toggle('active', isActive);
|
||||
});
|
||||
|
||||
// Initial data load
|
||||
setupMockControls();
|
||||
fetchStatus();
|
||||
fetchConfig();
|
||||
fetchLogs();
|
||||
|
||||
// Refresh data every 2 seconds if not using mock data
|
||||
if (!useMockData) {
|
||||
// Refresh data every 2 seconds
|
||||
setInterval(fetchStatus, 2000);
|
||||
setInterval(fetchLogs, 2000);
|
||||
}
|
||||
setInterval(animateClocks, 50); // High-frequency clock animation
|
||||
});
|
||||
|
|
|
|||
154
static/style.css
|
|
@ -1,21 +1,6 @@
|
|||
@font-face {
|
||||
font-family: 'FuturaStdHeavy';
|
||||
src: url('assets/FuturaStdHeavy.otf') format('opentype');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Quartz';
|
||||
src: url('assets/quartz-ms-regular.ttf') format('truetype');
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'FuturaStdHeavy', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
background-color: #221f1f;
|
||||
background-image: url('assets/HaveBlueTransWh.png');
|
||||
background-repeat: no-repeat;
|
||||
background-position: bottom 20px right 20px;
|
||||
background-attachment: fixed;
|
||||
background-size: 300px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
background-color: #f4f4f9;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
|
|
@ -28,20 +13,19 @@ body {
|
|||
max-width: 960px;
|
||||
}
|
||||
|
||||
.header-logo {
|
||||
display: block;
|
||||
margin: 0 auto 20px auto;
|
||||
max-width: 60%;
|
||||
h1 {
|
||||
text-align: center;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #c5ced6;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
|
|
@ -49,32 +33,16 @@ body {
|
|||
|
||||
.card h2 {
|
||||
margin-top: 0;
|
||||
color: #1a7db6;
|
||||
}
|
||||
|
||||
#ltc-timecode, #system-clock {
|
||||
font-family: 'Quartz', monospace;
|
||||
font-size: 2em;
|
||||
text-align: center;
|
||||
letter-spacing: 2px;
|
||||
color: #0056b3;
|
||||
}
|
||||
|
||||
.card p, .card ul {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.system-date-display {
|
||||
text-align: center;
|
||||
font-size: 1.5em;
|
||||
font-family: 'Quartz', monospace;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
#interfaces {
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 5px; /* Add some space for the scrollbar if it appears */
|
||||
.card ul {
|
||||
padding-left: 20px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
|
|
@ -114,103 +82,6 @@ button:hover {
|
|||
color: #555;
|
||||
}
|
||||
|
||||
.icon-group {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
#delta-text {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#ltc-status, #ntp-active, #sync-status, #jitter-status, #frame-rate, #lock-ratio, #delta-status {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.collapsible-card {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.collapsible-card .toggle-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
padding: 20px;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.collapsible-card .toggle-header.active {
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.collapsible-card .toggle-header:hover {
|
||||
background-color: #e9e9f3;
|
||||
}
|
||||
|
||||
.toggle-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.log-box {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.collapsible-content {
|
||||
display: none;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.collapsible-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
footer {
|
||||
text-align: center;
|
||||
margin-top: 40px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #444;
|
||||
color: #c5ced6;
|
||||
}
|
||||
|
||||
footer p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
footer a {
|
||||
color: #1a7db6;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Status-specific colors */
|
||||
#sync-status.in-sync, #jitter-status.good { font-weight: bold; color: #28a745; }
|
||||
#sync-status.clock-ahead, #sync-status.clock-behind, #jitter-status.average { font-weight: bold; color: #ffc107; }
|
||||
|
|
@ -218,6 +89,3 @@ footer a:hover {
|
|||
#jitter-status.bad { font-weight: bold; color: #dc3545; }
|
||||
#ntp-active.active { font-weight: bold; color: #28a745; }
|
||||
#ntp-active.inactive { font-weight: bold; color: #dc3545; }
|
||||
|
||||
#ltc-status.lock { font-weight: bold; color: #28a745; }
|
||||
#ltc-status.free { font-weight: bold; color: #ffc107; }
|
||||
|
|
|
|||