mirror of
https://github.com/cjfranko/NTP-Timeturner.git
synced 2025-11-08 18:32:02 +00:00
commit
666ce4308f
6 changed files with 575 additions and 131 deletions
|
|
@ -12,4 +12,6 @@ serde = { version = "1.0", features = ["derive"] }
|
|||
serde_json = "1.0.141"
|
||||
notify = "8.1.0"
|
||||
get_if_addrs = "0.5"
|
||||
actix-web = "4"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
|
||||
|
|
|
|||
90
docs/api.md
Normal file
90
docs/api.md
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
# NTP Timeturner API
|
||||
|
||||
This document describes the HTTP API for the NTP Timeturner application.
|
||||
|
||||
## Endpoints
|
||||
|
||||
### Status
|
||||
|
||||
- **`GET /api/status`**
|
||||
|
||||
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",
|
||||
"frame_rate": "25.00fps",
|
||||
"system_clock": "10:20:30.005",
|
||||
"timecode_delta_ms": 5,
|
||||
"timecode_delta_frames": 0,
|
||||
"sync_status": "IN SYNC",
|
||||
"jitter_status": "GOOD",
|
||||
"lock_ratio": 99.5,
|
||||
"ntp_active": true,
|
||||
"interfaces": ["192.168.1.100"],
|
||||
"hardware_offset_ms": 0
|
||||
}
|
||||
```
|
||||
|
||||
### Sync
|
||||
|
||||
- **`POST /api/sync`**
|
||||
|
||||
Triggers a manual synchronization of the system clock to the current LTC timecode. This requires the application to have `sudo` privileges to execute the `date` command.
|
||||
|
||||
**Request Body:** None
|
||||
|
||||
**Success Response:**
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Sync command issued."
|
||||
}
|
||||
```
|
||||
|
||||
**Error Responses:**
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "No LTC timecode available to sync to."
|
||||
}
|
||||
```
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Sync command failed."
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
- **`GET /api/config`**
|
||||
|
||||
Retrieves the current application configuration.
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"hardware_offset_ms": 0
|
||||
}
|
||||
```
|
||||
|
||||
- **`POST /api/config`**
|
||||
|
||||
Updates the `hardware_offset_ms` configuration. The new value is persisted to `config.json` and reloaded by the application automatically.
|
||||
|
||||
**Example Request:**
|
||||
```json
|
||||
{
|
||||
"hardware_offset_ms": 10
|
||||
}
|
||||
```
|
||||
|
||||
**Success Response:**
|
||||
```json
|
||||
{
|
||||
"hardware_offset_ms": 10
|
||||
}
|
||||
```
|
||||
310
src/api.rs
Normal file
310
src/api.rs
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
|
||||
use actix_web::{get, post, web, App, HttpResponse, HttpServer, Responder};
|
||||
use chrono::{Local, Timelike};
|
||||
use get_if_addrs::get_if_addrs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::config::{self, Config};
|
||||
use crate::sync_logic::LtcState;
|
||||
use crate::ui;
|
||||
|
||||
// Data structure for the main status response
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct ApiStatus {
|
||||
ltc_status: String,
|
||||
ltc_timecode: String,
|
||||
frame_rate: String,
|
||||
system_clock: String,
|
||||
timecode_delta_ms: i64,
|
||||
timecode_delta_frames: i64,
|
||||
sync_status: String,
|
||||
jitter_status: String,
|
||||
lock_ratio: f64,
|
||||
ntp_active: bool,
|
||||
interfaces: Vec<String>,
|
||||
hardware_offset_ms: i64,
|
||||
}
|
||||
|
||||
// AppState to hold shared data
|
||||
pub struct AppState {
|
||||
pub ltc_state: Arc<Mutex<LtcState>>,
|
||||
pub hw_offset: Arc<Mutex<i64>>,
|
||||
}
|
||||
|
||||
#[get("/api/status")]
|
||||
async fn get_status(data: web::Data<AppState>) -> impl Responder {
|
||||
let state = data.ltc_state.lock().unwrap();
|
||||
let hw_offset_ms = *data.hw_offset.lock().unwrap();
|
||||
|
||||
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 frame_rate = state.latest.as_ref().map_or("…".to_string(), |f| {
|
||||
format!("{:.2}fps", f.frame_rate)
|
||||
});
|
||||
|
||||
let now_local = Local::now();
|
||||
let system_clock = format!(
|
||||
"{:02}:{:02}:{:02}.{:03}",
|
||||
now_local.hour(),
|
||||
now_local.minute(),
|
||||
now_local.second(),
|
||||
now_local.timestamp_subsec_millis(),
|
||||
);
|
||||
|
||||
let avg_delta = state.average_clock_delta();
|
||||
let mut delta_frames = 0;
|
||||
if let Some(frame) = &state.latest {
|
||||
let frame_ms = 1000.0 / frame.frame_rate;
|
||||
delta_frames = ((avg_delta as f64 / frame_ms).round()) as i64;
|
||||
}
|
||||
|
||||
let sync_status = ui::get_sync_status(avg_delta).to_string();
|
||||
let jitter_status = ui::get_jitter_status(state.average_jitter()).to_string();
|
||||
let lock_ratio = state.lock_ratio();
|
||||
|
||||
let ntp_active = ui::ntp_service_active();
|
||||
let interfaces = get_if_addrs()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|ifa| !ifa.is_loopback())
|
||||
.map(|ifa| ifa.ip().to_string())
|
||||
.collect();
|
||||
|
||||
HttpResponse::Ok().json(ApiStatus {
|
||||
ltc_status,
|
||||
ltc_timecode,
|
||||
frame_rate,
|
||||
system_clock,
|
||||
timecode_delta_ms: avg_delta,
|
||||
timecode_delta_frames: delta_frames,
|
||||
sync_status,
|
||||
jitter_status,
|
||||
lock_ratio,
|
||||
ntp_active,
|
||||
interfaces,
|
||||
hardware_offset_ms: hw_offset_ms,
|
||||
})
|
||||
}
|
||||
|
||||
#[post("/api/sync")]
|
||||
async fn manual_sync(data: web::Data<AppState>) -> impl Responder {
|
||||
let state = data.ltc_state.lock().unwrap();
|
||||
if let Some(frame) = &state.latest {
|
||||
if ui::trigger_sync(frame).is_ok() {
|
||||
HttpResponse::Ok().json(serde_json::json!({ "status": "success", "message": "Sync command issued." }))
|
||||
} else {
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({ "status": "error", "message": "Sync command failed." }))
|
||||
}
|
||||
} else {
|
||||
HttpResponse::BadRequest().json(serde_json::json!({ "status": "error", "message": "No LTC timecode available to sync to." }))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct ConfigResponse {
|
||||
hardware_offset_ms: i64,
|
||||
}
|
||||
|
||||
#[get("/api/config")]
|
||||
async fn get_config(data: web::Data<AppState>) -> impl Responder {
|
||||
let hw_offset_ms = *data.hw_offset.lock().unwrap();
|
||||
HttpResponse::Ok().json(ConfigResponse { hardware_offset_ms: hw_offset_ms })
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct UpdateConfigRequest {
|
||||
hardware_offset_ms: i64,
|
||||
}
|
||||
|
||||
#[post("/api/config")]
|
||||
async fn update_config(
|
||||
data: web::Data<AppState>,
|
||||
req: web::Json<UpdateConfigRequest>,
|
||||
) -> impl Responder {
|
||||
let mut hw_offset = data.hw_offset.lock().unwrap();
|
||||
*hw_offset = req.hardware_offset_ms;
|
||||
|
||||
let new_config = Config {
|
||||
hardware_offset_ms: *hw_offset,
|
||||
};
|
||||
|
||||
if config::save_config("config.json", &new_config).is_ok() {
|
||||
eprintln!("🔄 Saved hardware_offset_ms = {} via API", *hw_offset);
|
||||
HttpResponse::Ok().json(&new_config)
|
||||
} else {
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({ "status": "error", "message": "Failed to write config.json" }))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn start_api_server(
|
||||
state: Arc<Mutex<LtcState>>,
|
||||
offset: Arc<Mutex<i64>>,
|
||||
) -> std::io::Result<()> {
|
||||
let app_state = web::Data::new(AppState {
|
||||
ltc_state: state,
|
||||
hw_offset: offset,
|
||||
});
|
||||
|
||||
println!("🚀 Starting API server at http://0.0.0.0:8080");
|
||||
|
||||
HttpServer::new(move || {
|
||||
App::new()
|
||||
.app_data(app_state.clone())
|
||||
.service(get_status)
|
||||
.service(manual_sync)
|
||||
.service(get_config)
|
||||
.service(update_config)
|
||||
})
|
||||
.bind("0.0.0.0:8080")?
|
||||
.run()
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::sync_logic::LtcFrame;
|
||||
use actix_web::{test, App};
|
||||
use chrono::Utc;
|
||||
use std::collections::VecDeque;
|
||||
use std::fs;
|
||||
|
||||
// Helper to create a default LtcState for tests
|
||||
fn get_test_state() -> LtcState {
|
||||
LtcState {
|
||||
latest: Some(LtcFrame {
|
||||
status: "LOCK".to_string(),
|
||||
hours: 1,
|
||||
minutes: 2,
|
||||
seconds: 3,
|
||||
frames: 4,
|
||||
frame_rate: 25.0,
|
||||
timestamp: Utc::now(),
|
||||
}),
|
||||
lock_count: 10,
|
||||
free_count: 1,
|
||||
offset_history: VecDeque::from(vec![1, 2, 3]),
|
||||
clock_delta_history: VecDeque::from(vec![4, 5, 6]),
|
||||
last_match_status: "IN SYNC".to_string(),
|
||||
last_match_check: Utc::now().timestamp(),
|
||||
}
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn test_get_status() {
|
||||
let ltc_state = Arc::new(Mutex::new(get_test_state()));
|
||||
let hw_offset = Arc::new(Mutex::new(10i64));
|
||||
|
||||
let app_state = web::Data::new(AppState {
|
||||
ltc_state: ltc_state.clone(),
|
||||
hw_offset: hw_offset.clone(),
|
||||
});
|
||||
|
||||
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_status, "LOCK");
|
||||
assert_eq!(resp.ltc_timecode, "01:02:03:04");
|
||||
assert_eq!(resp.frame_rate, "25.00fps");
|
||||
assert_eq!(resp.hardware_offset_ms, 10);
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn test_get_config() {
|
||||
let ltc_state = Arc::new(Mutex::new(LtcState::new()));
|
||||
let hw_offset = Arc::new(Mutex::new(25i64));
|
||||
|
||||
let app_state = web::Data::new(AppState {
|
||||
ltc_state: ltc_state.clone(),
|
||||
hw_offset: hw_offset.clone(),
|
||||
});
|
||||
|
||||
let app = test::init_service(
|
||||
App::new()
|
||||
.app_data(app_state.clone())
|
||||
.service(get_config),
|
||||
)
|
||||
.await;
|
||||
|
||||
let req = test::TestRequest::get().uri("/api/config").to_request();
|
||||
let resp: ConfigResponse = test::call_and_read_body_json(&app, req).await;
|
||||
|
||||
assert_eq!(resp.hardware_offset_ms, 25);
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn test_update_config() {
|
||||
let ltc_state = Arc::new(Mutex::new(LtcState::new()));
|
||||
let hw_offset = Arc::new(Mutex::new(0i64));
|
||||
let config_path = "config.json";
|
||||
|
||||
// This test has the side effect of writing to `config.json`.
|
||||
// We ensure it's cleaned up after.
|
||||
let _ = fs::remove_file(config_path);
|
||||
|
||||
let app_state = web::Data::new(AppState {
|
||||
ltc_state: ltc_state.clone(),
|
||||
hw_offset: hw_offset.clone(),
|
||||
});
|
||||
|
||||
let app = test::init_service(
|
||||
App::new()
|
||||
.app_data(app_state.clone())
|
||||
.service(update_config),
|
||||
)
|
||||
.await;
|
||||
|
||||
let req = test::TestRequest::post()
|
||||
.uri("/api/config")
|
||||
.set_json(&serde_json::json!({ "hardware_offset_ms": 55 }))
|
||||
.to_request();
|
||||
|
||||
let resp: Config = test::call_and_read_body_json(&app, req).await;
|
||||
|
||||
assert_eq!(resp.hardware_offset_ms, 55);
|
||||
assert_eq!(*hw_offset.lock().unwrap(), 55);
|
||||
|
||||
// Test that the file was written
|
||||
assert!(fs::metadata(config_path).is_ok());
|
||||
let contents = fs::read_to_string(config_path).unwrap();
|
||||
assert!(contents.contains("\"hardware_offset_ms\": 55"));
|
||||
|
||||
// Cleanup
|
||||
let _ = fs::remove_file(config_path);
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn test_manual_sync_no_ltc() {
|
||||
// State with no LTC frame
|
||||
let ltc_state = Arc::new(Mutex::new(LtcState::new()));
|
||||
let hw_offset = Arc::new(Mutex::new(0i64));
|
||||
|
||||
let app_state = web::Data::new(AppState {
|
||||
ltc_state: ltc_state.clone(),
|
||||
hw_offset: hw_offset.clone(),
|
||||
});
|
||||
|
||||
let app = test::init_service(
|
||||
App::new()
|
||||
.app_data(app_state.clone())
|
||||
.service(manual_sync),
|
||||
)
|
||||
.await;
|
||||
|
||||
let req = test::TestRequest::post().uri("/api/sync").to_request();
|
||||
let resp = test::call_service(&app, req).await;
|
||||
|
||||
assert_eq!(resp.status(), 400); // Bad Request
|
||||
}
|
||||
}
|
||||
|
|
@ -4,15 +4,16 @@ use notify::{
|
|||
recommended_watcher, Event, EventKind, RecommendedWatcher, RecursiveMode, Result as NotifyResult,
|
||||
Watcher,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
fs,
|
||||
fs::File,
|
||||
io::Read,
|
||||
path::PathBuf,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Deserialize, Serialize, Clone)]
|
||||
pub struct Config {
|
||||
pub hardware_offset_ms: i64,
|
||||
}
|
||||
|
|
@ -31,6 +32,11 @@ impl Config {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn save_config(path: &str, config: &Config) -> std::io::Result<()> {
|
||||
let contents = serde_json::to_string_pretty(config)?;
|
||||
fs::write(path, contents)
|
||||
}
|
||||
|
||||
pub fn watch_config(path: &str) -> Arc<Mutex<i64>> {
|
||||
let initial = Config::load(&PathBuf::from(path)).hardware_offset_ms;
|
||||
let offset = Arc::new(Mutex::new(initial));
|
||||
|
|
|
|||
35
src/main.rs
35
src/main.rs
|
|
@ -1,10 +1,12 @@
|
|||
// src/main.rs
|
||||
|
||||
mod api;
|
||||
mod config;
|
||||
mod sync_logic;
|
||||
mod serial_input;
|
||||
mod ui;
|
||||
|
||||
use crate::api::start_api_server;
|
||||
use crate::config::watch_config;
|
||||
use crate::sync_logic::LtcState;
|
||||
use crate::serial_input::start_serial_thread;
|
||||
|
|
@ -16,9 +18,12 @@ use std::{
|
|||
sync::{Arc, Mutex, mpsc},
|
||||
thread,
|
||||
};
|
||||
use tokio::task::{self, LocalSet};
|
||||
|
||||
/// Embed the default config.json at compile time.
|
||||
const DEFAULT_CONFIG: &str = include_str!("../config.json");
|
||||
/// Default config content, embedded in the binary.
|
||||
const DEFAULT_CONFIG: &str = r#"{
|
||||
"hardware_offset_ms": 20
|
||||
}"#;
|
||||
|
||||
/// If no `config.json` exists alongside the binary, write out the default.
|
||||
fn ensure_config() {
|
||||
|
|
@ -30,7 +35,8 @@ fn ensure_config() {
|
|||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn main() {
|
||||
// 🔄 Ensure there's always a config.json present
|
||||
ensure_config();
|
||||
|
||||
|
|
@ -73,11 +79,32 @@ fn main() {
|
|||
});
|
||||
}
|
||||
|
||||
// 6️⃣ Keep main thread alive
|
||||
// 6️⃣ Set up a LocalSet for the API server.
|
||||
let local = LocalSet::new();
|
||||
local
|
||||
.run_until(async move {
|
||||
// 7️⃣ Spawn the API server thread
|
||||
{
|
||||
let api_state = ltc_state.clone();
|
||||
let offset_clone = hw_offset.clone();
|
||||
task::spawn_local(async move {
|
||||
if let Err(e) = start_api_server(api_state, offset_clone).await {
|
||||
eprintln!("API server error: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 8️⃣ Keep main thread alive by consuming LTC frames in a blocking task
|
||||
println!("📡 Main thread entering loop...");
|
||||
let _ = task::spawn_blocking(move || {
|
||||
// This will block the thread, but it's a blocking-safe thread.
|
||||
for _frame in rx {
|
||||
// no-op
|
||||
}
|
||||
})
|
||||
.await;
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
117
src/ui.rs
117
src/ui.rs
|
|
@ -20,10 +20,10 @@ use crossterm::{
|
|||
};
|
||||
|
||||
use get_if_addrs::get_if_addrs;
|
||||
use crate::sync_logic::LtcState;
|
||||
use crate::sync_logic::{LtcFrame, LtcState};
|
||||
|
||||
/// Check if Chrony is active
|
||||
fn ntp_service_active() -> bool {
|
||||
pub fn ntp_service_active() -> bool {
|
||||
if let Ok(output) = Command::new("systemctl").args(&["is-active", "chrony"]).output() {
|
||||
output.status.success()
|
||||
&& String::from_utf8_lossy(&output.stdout).trim() == "active"
|
||||
|
|
@ -39,7 +39,7 @@ fn ntp_service_toggle(start: bool) {
|
|||
let _ = Command::new("systemctl").args(&[action, "chrony"]).status();
|
||||
}
|
||||
|
||||
fn get_sync_status(delta_ms: i64) -> &'static str {
|
||||
pub fn get_sync_status(delta_ms: i64) -> &'static str {
|
||||
if delta_ms.abs() <= 8 {
|
||||
"IN SYNC"
|
||||
} else if delta_ms > 10 {
|
||||
|
|
@ -49,7 +49,7 @@ fn get_sync_status(delta_ms: i64) -> &'static str {
|
|||
}
|
||||
}
|
||||
|
||||
fn get_jitter_status(jitter_ms: i64) -> &'static str {
|
||||
pub fn get_jitter_status(jitter_ms: i64) -> &'static str {
|
||||
if jitter_ms.abs() < 10 {
|
||||
"GOOD"
|
||||
} else if jitter_ms.abs() < 40 {
|
||||
|
|
@ -59,6 +59,59 @@ fn get_jitter_status(jitter_ms: i64) -> &'static str {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn trigger_sync(frame: &LtcFrame) -> Result<String, ()> {
|
||||
let today_local = Local::now().date_naive();
|
||||
let ms = ((frame.frames as f64 / frame.frame_rate) * 1000.0)
|
||||
.round() as u32;
|
||||
let timecode = NaiveTime::from_hms_milli_opt(
|
||||
frame.hours, frame.minutes, frame.seconds, ms,
|
||||
).expect("Invalid LTC timecode");
|
||||
let naive_dt = today_local.and_time(timecode);
|
||||
let dt_local = Local
|
||||
.from_local_datetime(&naive_dt)
|
||||
.single()
|
||||
.expect("Ambiguous or invalid local time");
|
||||
#[cfg(target_os = "linux")]
|
||||
let (ts, success) = {
|
||||
let ts = dt_local.format("%H:%M:%S.%3f").to_string();
|
||||
let success = Command::new("sudo")
|
||||
.arg("date")
|
||||
.arg("-s")
|
||||
.arg(&ts)
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
(ts, success)
|
||||
};
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
let (ts, success) = {
|
||||
// macOS `date` command format is `mmddHHMMccyy.SS`
|
||||
let ts = dt_local.format("%m%d%H%M%y.%S").to_string();
|
||||
let success = Command::new("sudo")
|
||||
.arg("date")
|
||||
.arg(&ts)
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
(ts, success)
|
||||
};
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||
let (ts, success) = {
|
||||
// Unsupported OS, always fail
|
||||
let ts = dt_local.format("%H:%M:%S.%3f").to_string();
|
||||
eprintln!("Unsupported OS for time synchronization");
|
||||
(ts, false)
|
||||
};
|
||||
|
||||
if success {
|
||||
Ok(ts)
|
||||
} else {
|
||||
Err(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start_ui(
|
||||
state: Arc<Mutex<LtcState>>,
|
||||
serial_port: String,
|
||||
|
|
@ -151,31 +204,9 @@ pub fn start_ui(
|
|||
if let Some(start) = out_of_sync_since {
|
||||
if start.elapsed() >= Duration::from_secs(5) {
|
||||
if let Some(frame) = &state.lock().unwrap().latest {
|
||||
let today_local = Local::now().date_naive();
|
||||
let ms = ((frame.frames as f64 / frame.frame_rate) * 1000.0)
|
||||
.round() as u32;
|
||||
let timecode = NaiveTime::from_hms_milli_opt(
|
||||
frame.hours, frame.minutes, frame.seconds, ms,
|
||||
).expect("Invalid LTC timecode");
|
||||
let naive_dt = today_local.and_time(timecode);
|
||||
let dt_local = Local
|
||||
.from_local_datetime(&naive_dt)
|
||||
.single()
|
||||
.expect("Ambiguous or invalid local time");
|
||||
let ts = dt_local.format("%H:%M:%S.%3f").to_string();
|
||||
|
||||
let success = Command::new("sudo")
|
||||
.arg("date")
|
||||
.arg("-s")
|
||||
.arg(&ts)
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
|
||||
let entry = if success {
|
||||
format!("🔄 Auto‑synced to LTC: {}", ts)
|
||||
} else {
|
||||
"❌ Auto‑sync failed".into()
|
||||
let entry = match trigger_sync(frame) {
|
||||
Ok(ts) => format!("🔄 Auto‑synced to LTC: {}", ts),
|
||||
Err(_) => "❌ Auto‑sync failed".into(),
|
||||
};
|
||||
if logs.len() == 10 { logs.pop_front(); }
|
||||
logs.push_back(entry);
|
||||
|
|
@ -306,31 +337,9 @@ pub fn start_ui(
|
|||
}
|
||||
KeyCode::Char(c) if c.eq_ignore_ascii_case(&'s') => {
|
||||
if let Some(frame) = &state.lock().unwrap().latest {
|
||||
let today_local = Local::now().date_naive();
|
||||
let ms = ((frame.frames as f64 / frame.frame_rate) * 1000.0)
|
||||
.round() as u32;
|
||||
let timecode = NaiveTime::from_hms_milli_opt(
|
||||
frame.hours, frame.minutes, frame.seconds, ms,
|
||||
).expect("Invalid LTC timecode");
|
||||
let naive_dt = today_local.and_time(timecode);
|
||||
let dt_local = Local
|
||||
.from_local_datetime(&naive_dt)
|
||||
.single()
|
||||
.expect("Ambiguous or invalid local time");
|
||||
let ts = dt_local.format("%H:%M:%S.%3f").to_string();
|
||||
|
||||
let success = Command::new("sudo")
|
||||
.arg("date")
|
||||
.arg("-s")
|
||||
.arg(&ts)
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
|
||||
let entry = if success {
|
||||
format!("✔ Synced exactly to LTC: {}", ts)
|
||||
} else {
|
||||
"❌ date cmd failed".into()
|
||||
let entry = match trigger_sync(frame) {
|
||||
Ok(ts) => format!("✔ Synced exactly to LTC: {}", ts),
|
||||
Err(_) => "❌ date cmd failed".into(),
|
||||
};
|
||||
if logs.len() == 10 { logs.pop_front(); }
|
||||
logs.push_back(entry);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue