diff --git a/timeturner.py b/timeturner.py index 3aa9185..2d9725e 100644 --- a/timeturner.py +++ b/timeturner.py @@ -1,183 +1,217 @@ import serial +import curses import time import datetime -import curses import re +import subprocess import os +import threading +import queue import json -from threading import Thread, Lock from collections import deque -# Config -CONFIG_FILE = "config.json" -DEFAULT_CONFIG = { - "serial_port": "auto", - "hardware_offset_ms": 0 -} - -# Globals -SERIAL_PORT = "/dev/ttyUSB0" +SERIAL_PORT = None BAUD_RATE = 115200 FRAME_RATE = 25.0 +CONFIG_PATH = "config.json" + +sync_pending = False +ltc_data_queue = queue.Queue() +latest_ltc = None +offset_history = deque(maxlen=20) + +lock_total = 0 +free_total = 0 hardware_offset_ms = 0 +ltc_locked = False +lock_stable_since = None +sync_enabled = False -# Shared data -latest_line = None -latest_timestamp = None -line_lock = Lock() - -# Sync Jitter buffer -offset_buffer = deque(maxlen=50) - -# Load config -if os.path.exists(CONFIG_FILE): - with open(CONFIG_FILE) as f: - try: +def load_config(): + global hardware_offset_ms + try: + with open(CONFIG_PATH, "r") as f: config = json.load(f) - SERIAL_PORT = config.get("serial_port", SERIAL_PORT) - hardware_offset_ms = config.get("hardware_offset_ms", 0) - except json.JSONDecodeError: - print("⚠️ Failed to parse config.json. Using defaults.") -else: - config = DEFAULT_CONFIG + hardware_offset_ms = int(config.get("hardware_offset_ms", 0)) + except Exception: + hardware_offset_ms = 0 -def auto_detect_serial_port(): - for dev in os.listdir("/dev"): - if dev.startswith("ttyACM") or dev.startswith("ttyUSB"): - return f"/dev/{dev}" - return SERIAL_PORT +def find_teensy_serial(): + for dev in os.listdir('/dev'): + if dev.startswith('ttyACM') or dev.startswith('ttyUSB'): + return f'/dev/{dev}' + return None def parse_ltc_line(line): match = re.match(r"\[(LOCK|FREE)\]\s+(\d{2}):(\d{2}):(\d{2})[:;](\d{2})\s+\|\s+([\d.]+)fps", line) if not match: return None + status, hh, mm, ss, ff, fps = match.groups() return { - "lock": match.group(1), - "hours": int(match.group(2)), - "minutes": int(match.group(3)), - "seconds": int(match.group(4)), - "frames": int(match.group(5)), - "fps": float(match.group(6)) + "status": status, + "hours": int(hh), + "minutes": int(mm), + "seconds": int(ss), + "frames": int(ff), + "frame_rate": float(fps) } -def serial_reader(port, baud): - global latest_line, latest_timestamp - ser = serial.Serial(port, baud, timeout=1) - while True: - line = ser.readline().decode(errors='ignore').strip() - if line: - with line_lock: - latest_line = line - latest_timestamp = datetime.datetime.now() +def serial_thread(port, baud, q): + try: + ser = serial.Serial(port, baud, timeout=1) + while True: + line = ser.readline().decode(errors='ignore').strip() + timestamp = datetime.datetime.now() + parsed = parse_ltc_line(line) + if parsed: + q.put((parsed, timestamp)) + except Exception as e: + print(f"Serial thread error: {e}") + +def get_system_time(): + return datetime.datetime.now() + +def format_time(dt): + return dt.strftime("%H:%M:%S.%f")[:-3] def run_curses(stdscr): + global FRAME_RATE, sync_pending, SERIAL_PORT, latest_ltc + global offset_history, lock_total, free_total + global ltc_locked, lock_stable_since, sync_enabled + curses.curs_set(0) stdscr.nodelay(True) - stdscr.timeout(100) + stdscr.timeout(50) - serial_port = auto_detect_serial_port() - stdscr.addstr(1, 2, f"NTP Timeturner v1.3") - stdscr.addstr(2, 2, f"Using Serial Port: {serial_port}") - stdscr.refresh() + load_config() - reader_thread = Thread(target=serial_reader, args=(serial_port, BAUD_RATE), daemon=True) - reader_thread.start() + SERIAL_PORT = find_teensy_serial() + if not SERIAL_PORT: + stdscr.addstr(0, 0, "❌ No serial device found.") + stdscr.refresh() + time.sleep(2) + return - lock_count = 0 - free_count = 0 - sync_allowed = False + thread = threading.Thread(target=serial_thread, args=(SERIAL_PORT, BAUD_RATE, ltc_data_queue), daemon=True) + thread.start() while True: - now = datetime.datetime.now() - with line_lock: - line = latest_line - timestamp = latest_timestamp + try: + while not ltc_data_queue.empty(): + parsed, arrival_time = ltc_data_queue.get_nowait() + latest_ltc = (parsed, arrival_time) - parsed = parse_ltc_line(line) if line else None + FRAME_RATE = parsed["frame_rate"] + status = parsed["status"] - stdscr.erase() - stdscr.addstr(1, 2, f"NTP Timeturner v1.3") - stdscr.addstr(2, 2, f"Using Serial Port: {serial_port}") - - if parsed: - lock_state = parsed["lock"] - if lock_state == "LOCK": - lock_count += 1 - else: - free_count += 1 - - stdscr.addstr(4, 2, f"LTC Status : {lock_state}", curses.color_pair(2 if lock_state == "LOCK" else 3)) - - timecode_str = f"{parsed['hours']:02}:{parsed['minutes']:02}:{parsed['seconds']:02}:{parsed['frames']:02}" - stdscr.addstr(5, 2, f"LTC Timecode : {timecode_str}") - stdscr.addstr(6, 2, f"Frame Rate : {parsed['fps']:.2f}fps") - - # Generate LTC datetime object - ltc_time = datetime.datetime.now().replace( - hour=parsed["hours"], - minute=parsed["minutes"], - second=parsed["seconds"], - microsecond=int((parsed["frames"] / parsed["fps"]) * 1_000_000) - ) - - # Show system clock - system_time = datetime.datetime.now() - stdscr.addstr(7, 2, f"System Clock : {system_time.strftime('%H:%M:%S.%f')[:-3]}") - - # Calculate Sync Jitter - if lock_state == "LOCK": - offset_ms = (system_time - ltc_time).total_seconds() * 1000 - hardware_offset_ms - offset_buffer.append(offset_ms) - if offset_buffer: - avg_offset = sum(offset_buffer) / len(offset_buffer) - frame_error = round((avg_offset / 1000) * parsed["fps"]) - stdscr.addstr(8, 2, f"Sync Jitter : {avg_offset:+.0f} ms ({frame_error:+} frames)", - curses.color_pair(0 if abs(avg_offset) < 5 else 3)) + if status == "LOCK": + lock_total += 1 + if not ltc_locked: + lock_stable_since = time.time() + ltc_locked = True + elif time.time() - lock_stable_since > 1.0: + sync_enabled = True else: - stdscr.addstr(8, 2, f"Sync Jitter : --") - else: - stdscr.addstr(8, 2, f"Sync Jitter : -- (FREE mode)", curses.color_pair(3)) + free_total += 1 + ltc_locked = False + sync_enabled = False + lock_stable_since = None + offset_history.clear() - # Timecode Match (HH:MM:SS only) - ltc_time_str = ltc_time.strftime('%H:%M:%S') - sys_time_str = system_time.strftime('%H:%M:%S') - if lock_state == "LOCK": - if ltc_time_str == sys_time_str: - stdscr.addstr(9, 2, "Timecode Match: MATCHED", curses.color_pair(2)) + if ltc_locked and sync_enabled: + offset_ms = (get_system_time() - arrival_time).total_seconds() * 1000 - hardware_offset_ms + offset_frames = offset_ms / (1000 / FRAME_RATE) + offset_history.append((offset_ms, offset_frames)) + + if sync_pending and ltc_locked and sync_enabled: + do_sync(stdscr, parsed, arrival_time) + sync_pending = False + + stdscr.erase() + stdscr.addstr(0, 2, "NTP Timeturner v1.2") + stdscr.addstr(1, 2, f"Using Serial Port: {SERIAL_PORT}") + + if latest_ltc: + parsed, arrival_time = latest_ltc + stdscr.addstr(3, 2, f"LTC Status : {parsed['status']}") + stdscr.addstr(4, 2, f"LTC Timecode : {parsed['hours']:02}:{parsed['minutes']:02}:{parsed['seconds']:02}:{parsed['frames']:02}") + stdscr.addstr(5, 2, f"Frame Rate : {FRAME_RATE:.2f}fps") + stdscr.addstr(6, 2, f"System Clock : {format_time(get_system_time())}") + + if ltc_locked and sync_enabled and offset_history: + avg_ms = sum(x[0] for x in offset_history) / len(offset_history) + avg_frames = sum(x[1] for x in offset_history) / len(offset_history) + + if abs(avg_ms) < 10: + color = curses.color_pair(2) + elif abs(avg_ms) < 40: + color = curses.color_pair(3) + else: + color = curses.color_pair(1) + + stdscr.attron(color) + stdscr.addstr(7, 2, f"Sync Offset : {avg_ms:+.0f} ms ({avg_frames:+.0f} frames)") + stdscr.attroff(color) + elif parsed["status"] == "FREE": + stdscr.attron(curses.color_pair(3)) + stdscr.addstr(7, 2, "⚠️ LTC UNSYNCED — offset unavailable") + stdscr.attroff(curses.color_pair(3)) else: - stdscr.addstr(9, 2, "Timecode Match: OUT OF SYNC", curses.color_pair(3)) + stdscr.addstr(7, 2, "Sync Offset : …") + + total = lock_total + free_total + lock_pct = (lock_total / total) * 100 if total else 0 + if ltc_locked and sync_enabled: + stdscr.addstr(8, 2, f"Lock Ratio : {lock_pct:.1f}% LOCK") + else: + stdscr.attron(curses.color_pair(3)) + stdscr.addstr(8, 2, f"Lock Ratio : {lock_pct:.1f}% (not stable)") + stdscr.attroff(curses.color_pair(3)) else: - stdscr.addstr(9, 2, "Timecode Match: UNKNOWN", curses.color_pair(3)) + stdscr.addstr(3, 2, "LTC Status : (waiting)") + stdscr.addstr(4, 2, "LTC Timecode : …") + stdscr.addstr(5, 2, "Frame Rate : …") + stdscr.addstr(6, 2, f"System Clock : {format_time(get_system_time())}") + stdscr.addstr(7, 2, "Sync Offset : …") + stdscr.addstr(8, 2, "Lock Ratio : …") - sync_allowed = lock_state == "LOCK" + if sync_enabled: + stdscr.addstr(10, 2, "[S] Set system clock to LTC [Ctrl+C] Quit") + else: + stdscr.addstr(10, 2, "(Sync disabled — LTC not locked) [Ctrl+C] Quit") - # Lock Ratio - total = lock_count + free_count - if total > 0: - lock_ratio = (lock_count / total) * 100 - stdscr.addstr(10, 2, f"Lock Ratio : {lock_ratio:.1f}% LOCK", curses.color_pair(2 if lock_ratio > 90 else 3)) - else: - stdscr.addstr(4, 2, "Waiting for LTC data...", curses.color_pair(3)) + stdscr.refresh() - stdscr.addstr(12, 2, "[S] Set system clock to LTC [Ctrl+C] Quit") + key = stdscr.getch() + if key in (ord('s'), ord('S')) and latest_ltc and sync_enabled: + sync_pending = True - key = stdscr.getch() - if key in (ord('s'), ord('S')) and parsed and sync_allowed: - clock_str = f"{parsed['hours']:02}:{parsed['minutes']:02}:{parsed['seconds']:02}" - os.system(f"sudo date -s \"{clock_str}\"") + except KeyboardInterrupt: + break + except Exception as e: + stdscr.addstr(13, 2, f"⚠️ Error: {e}") + stdscr.refresh() + time.sleep(1) - stdscr.refresh() - time.sleep(0.05) - -def main(): - curses.wrapper(start_curses) - -def start_curses(stdscr): - curses.start_color() - curses.init_pair(1, curses.COLOR_WHITE, curses.COLOR_BLACK) - curses.init_pair(2, curses.COLOR_GREEN, curses.COLOR_BLACK) - curses.init_pair(3, curses.COLOR_RED, curses.COLOR_BLACK) - run_curses(stdscr) +def do_sync(stdscr, parsed, arrival_time): + try: + ms = int((parsed["frames"] / parsed["frame_rate"]) * 1000) + sync_time = arrival_time.replace( + hour=parsed["hours"], + minute=parsed["minutes"], + second=parsed["seconds"], + microsecond=(ms + hardware_offset_ms) * 1000 + ) + timestamp = sync_time.strftime("%H:%M:%S.%f")[:-3] + subprocess.run(["sudo", "date", "-s", timestamp], check=True) + stdscr.addstr(13, 2, f"✔️ Synced to LTC: {timestamp}") + except Exception as e: + stdscr.addstr(13, 2, f"❌ Sync failed: {e}") if __name__ == "__main__": - main() + curses.initscr() + curses.start_color() + curses.init_pair(1, curses.COLOR_RED, curses.COLOR_BLACK) + curses.init_pair(2, curses.COLOR_GREEN, curses.COLOR_BLACK) + curses.init_pair(3, curses.COLOR_YELLOW, curses.COLOR_BLACK) + curses.wrapper(run_curses)