Compare commits

..

170 commits

Author SHA1 Message Date
Chris Frankland-Wright
2e8bc9ac5e updated some masthead and readme
Some checks failed
Build for Raspberry Pi / Build for aarch64 (push) Has been cancelled
2025-08-31 22:13:47 +01:00
Chris Frankland-Wright
3e423416a8 fixed error in naming of service 2025-08-31 21:54:18 +01:00
Chris Frankland-Wright
4a07b29728 removed captive portal for now 2025-08-31 18:42:58 +01:00
Chris Frankland-Wright
2d46fccfbe include ifup ifdown
Some checks are pending
Build for Raspberry Pi / Build for aarch64 (push) Waiting to run
2025-08-31 11:00:55 +01:00
Chris Frankland-Wright
fdddf4eb76 revert to dchcpcd 2025-08-31 10:56:22 +01:00
Chris Frankland-Wright
46892884a1 ignore all eth for dnsq 2025-08-31 10:48:46 +01:00
Chris Frankland-Wright
04165f2686 last chance saloon!
Some checks are pending
Build for Raspberry Pi / Build for aarch64 (push) Waiting to run
2025-08-31 00:19:24 +01:00
Chris Frankland-Wright
459e44250e ugh another try... 2025-08-31 00:06:32 +01:00
Chris Frankland-Wright
604d118d25 force dns and dhcp 2025-08-30 23:59:34 +01:00
Chris Frankland-Wright
320174fe53 final attempt for the night 2025-08-30 23:54:12 +01:00
Chris Frankland-Wright
8903d6d006 DHCP issues 2025-08-30 23:48:24 +01:00
Chris Frankland-Wright
32e785bd88 update installer with reinstall options 2025-08-30 23:39:05 +01:00
Chris Frankland-Wright
fb4ecc5f2a bug fix for AP captive 2025-08-30 23:25:06 +01:00
Chris Frankland-Wright
0c51fd77fa rename AP to Hachi 2025-08-30 23:15:47 +01:00
Chris Frankland-Wright
474e62d487 created updater 2025-08-30 23:02:48 +01:00
Chris Frankland-Wright
ea55d087b5 change default yaml to not have timeturning
Some checks are pending
Build for Raspberry Pi / Build for aarch64 (push) Waiting to run
2025-08-30 22:52:42 +01:00
Chris Frankland-Wright
af6dbcc9a7 added in chrony settings 2025-08-30 22:42:32 +01:00
Chris Frankland-Wright
169c9b9aef allow update of nodogsplash 2025-08-30 22:32:03 +01:00
Chris Frankland-Wright
6221eea98c removed pip for install 2025-08-30 22:29:25 +01:00
Chris Frankland-Wright
ac035a8e0b fix tmp and install python 2025-08-30 22:27:28 +01:00
Chris Frankland-Wright
f2e2fa9c7f renambled dns thing 2025-08-30 22:22:02 +01:00
Chris Frankland-Wright
3c73a0487b fix nodog install issue 2025-08-30 22:19:30 +01:00
Chris Frankland-Wright
360e0751f2 ugh... 2025-08-30 22:16:04 +01:00
Chris Frankland-Wright
a764b4d4ad remove dnsmasq 2025-08-30 22:12:32 +01:00
Chris Frankland-Wright
63bd17b71e asdfghjk 2025-08-30 22:07:17 +01:00
Chris Frankland-Wright
7db595259f more network config 2025-08-30 22:05:09 +01:00
Chris Frankland-Wright
e19b50fe2b moved nodogsplash to nmtui 2025-08-30 22:01:11 +01:00
Chris Frankland-Wright
cc1335f1a9 blah 2025-08-30 21:56:44 +01:00
Chris Frankland-Wright
5ca32b6f36 premature exit issue 2025-08-30 21:54:17 +01:00
Chris Frankland-Wright
1caa09ac46 added delay in process 2025-08-30 21:51:03 +01:00
Chris Frankland-Wright
57de9a98a5 updated to network-manager 2025-08-30 21:46:38 +01:00
Chris Frankland-Wright
0e7b583829 fix bug with service creation 2025-08-30 21:43:00 +01:00
Chris Frankland-Wright
e4c59b412b install json handler 2025-08-30 21:40:15 +01:00
Chris Frankland-Wright
dad5c2d06a update to pull nodogsplash and configure 2025-08-30 21:37:25 +01:00
Chris Frankland-Wright
baf674edd8 updated version of dogsplash 2025-08-30 21:20:26 +01:00
Chris Frankland-Wright
762f872e7c updated to pull directly 2025-08-30 21:17:39 +01:00
Chris Frankland-Wright
5eb706601f updated for nodogsplash 2025-08-30 21:14:09 +01:00
Chris Frankland-Wright
7773e62402 Merge branch 'main' of https://github.com/cjfranko/NTP-Timeturner 2025-08-30 21:08:26 +01:00
Chris Frankland-Wright
24c09fa233 updated setup.sh file 2025-08-30 21:07:48 +01:00
Chris Frankland-Wright
7c5b7fe031
Revise README for clarity and accuracy
Some checks failed
Build for Raspberry Pi / Build for aarch64 (push) Failing after 13s
Updated project description and corrected a typo.
2025-08-29 13:12:10 +01:00
Chris Frankland-Wright
01c0d0495f
Rename project to 'Fetch | Hachi' and revise text
Updated project name and refined description.
2025-08-29 13:11:20 +01:00
Chris Frankland-Wright
3f99488ea0 include yaml 2025-08-26 12:09:53 +01:00
Chris Frankland-Wright
e2d48391ea create localised hotspot
Some checks failed
Build for Raspberry Pi / Build for aarch64 (push) Failing after 27s
2025-08-26 11:19:53 +01:00
Chris Frankland-Wright
8362435e12 feat: Implement custom Plymouth splash screen installation
Co-authored-by: aider (gemini/gemini-2.5-flash) <aider@aider.chat>
2025-08-24 13:16:19 +01:00
Chris Frankland-Wright
cd9ac5a141 feat: Add system package update to setup script
Co-authored-by: aider (gemini/gemini-2.5-flash) <aider@aider.chat>
2025-08-24 13:07:08 +01:00
Chris Frankland-Wright
b6a7606e1a feat: Add automated dependency installation for Rust, Chrony, NMTUI, and adjtimex
Co-authored-by: aider (gemini/gemini-2.5-flash) <aider@aider.chat>
2025-08-24 13:06:07 +01:00
Chris Frankland-Wright
9c57c32c68
Update README.md
Some checks failed
Build for Raspberry Pi / Build for aarch64 (push) Failing after 44s
2025-08-22 22:36:41 +01:00
Chris Frankland-Wright
c2b1aedaba
Merge pull request #32 from cjfranko/updated-web-ui-setup
Some checks failed
Build for Raspberry Pi / Build for aarch64 (push) Failing after 20s
Updated web UI setup
2025-08-12 16:48:40 +01:00
Chris Frankland-Wright
a009dd35c9 updated web ui 2025-08-12 16:28:32 +01:00
Chris Frankland-Wright
4d0b4ebae4 docs: Detail setup.sh installation process in README
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-12 16:27:44 +01:00
Chris Frankland-Wright
5d206b564b style: Set button font to Arial
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-12 16:26:03 +01:00
Chris Frankland-Wright
b03d935a9e style: Reduce background image size 2025-08-12 16:25:58 +01:00
Chris Frankland-Wright
cbacf14ca1 Style: Improve Timeturner offset layout with compact inputs and side labels
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-12 16:19:53 +01:00
Chris Frankland-Wright
22ac073922 style: Update button and input field styling
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-12 16:16:39 +01:00
Chris Frankland-Wright
acab0fbc04 style: Hide hardware offset, auto sync, and nudge controls
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-12 16:13:43 +01:00
Chris Frankland-Wright
048ae41739 feat: Restore hardware offset, auto sync, and nudge controls 2025-08-12 16:13:35 +01:00
Chris Frankland-Wright
1075be6e24 hide sections 2025-08-12 16:06:38 +01:00
Chris Frankland-Wright
8e369a2e3a fix: Ensure static web assets are installed and clarify service config
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-12 16:02:25 +01:00
Chris Frankland-Wright
af0a512187 docs: Document web interface and clarify API server startup
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-12 16:00:43 +01:00
Chris Frankland-Wright
95fcb6f26a feat: Add systemd service for TimeTurner auto-start
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-12 15:58:21 +01:00
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
Chris Frankland-Wright
bda4d4e6f5
Merge pull request #28 from cjfranko/build_fix_error
Some checks failed
Build for Raspberry Pi / Build for aarch64 (push) Failing after 20s
fix build on native
2025-08-03 13:28:46 +01:00
Chris Frankland-Wright
8453f18a3c fix: Adjust sync status thresholds to pass tests
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-03 13:24:14 +01:00
Chris Frankland-Wright
049a85685c fix: Address unused import and Ratio type mismatch in tests
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-03 13:10:18 +01:00
Chris Frankland-Wright
d13ffdc057
Merge pull request #27 from cjfranko/recalculate_fps
fix for fractional frame rates issue
2025-08-03 13:00:56 +01:00
Chris Frankland-Wright
459500e402 fix: Correct clock drift for fractional frame rates
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-03 12:38:15 +01:00
Chris Frankland-Wright
4ee791c817 build: Add num-traits dependency
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-03 12:26:31 +01:00
Chris Frankland-Wright
3d6a106f1e refactor: Use rational numbers for LtcFrame frame rate
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-02 12:28:59 +01:00
Chris Frankland-Wright
a1da396874 refactor: Use rational numbers for accurate frame rate calculations
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-02 12:26:17 +01:00
Chris Frankland-Wright
b71e13d4c4 uncomment to fix build error
Some checks failed
Build for Raspberry Pi / Build for aarch64 (push) Failing after 19s
2025-07-31 08:10:54 +01:00
Chris Frankland-Wright
91f8f7dc96
Merge pull request #23 from cjfranko/update-ui
Some checks failed
Build for Raspberry Pi / Build for aarch64 (push) Failing after 17s
updated UI elements
2025-07-31 00:04:18 +01:00
Chris Frankland-Wright
c27b4f5dbb further reduced down 2025-07-30 23:35:31 +01:00
Chris Frankland-Wright
2c78b20301 reduce window for CLOCK AHEAD/BEHIND status 2025-07-30 23:35:07 +01:00
Chris Frankland-Wright
d2c4f1a4af removed TIME LOCK ACTIVE status, it should just use IN SYNC status 2025-07-30 23:33:38 +01:00
Chris Frankland-Wright
f39db7e67d fix: Enforce YYYY-MM-DD format for date input
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-07-30 23:22:43 +01:00
Chris Frankland-Wright
02842c3495 style: Reorder LTC status display elements 2025-07-30 23:22:37 +01:00
Chris Frankland-Wright
6bc1f5ddbf
Merge pull request #21 from cjfranko/add_date
Some checks failed
Build for Raspberry Pi / Build for aarch64 (push) Failing after 16s
feat: Add system date display and setting via API
2025-07-30 22:41:49 +01:00
Chris Frankland-Wright
58a1d243e4 feat: Add system date display and setting via API
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-07-30 22:36:19 +01:00
Chris Frankland-Wright
af43388e4b
Update README.md 2025-07-30 22:30:29 +01:00
Chris Frankland-Wright
584840f1f3
Merge pull request #16 from cjfranko/webextras-andfixes
Webextras andfixes
2025-07-30 22:29:49 +01:00
Chris Frankland-Wright
3df9466754 animate timecode 2025-07-30 22:25:10 +01:00
Chris Frankland-Wright
0745883e0d Revert "docs: Correct README for time offset features"
This reverts commit 871fd192b0.
2025-07-30 22:21:09 +01:00
Chris Frankland-Wright
0c6e1b0f43 feat: Animate system and LTC clocks client-side for dynamic display
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-07-30 22:06:43 +01:00
Chris Frankland-Wright
871fd192b0 docs: Correct README for time offset features
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-07-30 21:58:45 +01:00
d814b05a26 fix: Display 'TIME LOCK ACTIVE' status for auto-sync
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-29 15:24:20 +01:00
992720041b updated config with config bodge in tests. 2025-07-29 14:49:26 +01:00
68dc16344a fix: preserve comments in config.yml when saving
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-29 14:42:33 +01:00
9a97027870 fix: remove unused out_of_sync_since variable
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-29 14:38:23 +01:00
d015794b03 feat: implement auto-sync with periodic clock nudging
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-29 14:18:10 +01:00
4cb421b3d6 fix: clarify timeturner offset controls with labels
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-29 12:25:40 +01:00
c0613c3682 revert 2025-07-29 12:21:54 +01:00
fcbd5bd647 clarification in this 2025-07-29 12:18:24 +01:00
f929bacdfd config tweak, 2025-07-29 12:10:56 +01:00
89849c6e04 refactor: simplify default configuration
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-29 11:59:46 +01:00
4090fee0a6 test: Restore original config.yml after tests
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-29 11:49:22 +01:00
fb8088c704 test: add missing milliseconds field to TimeturnerOffset init
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-29 11:44:59 +01:00
c712014bb9 feat: Allow millisecond offset for timeturner
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-29 11:39:46 +01:00
3f953cff2f
Create SECURITY.MD
Some checks failed
Build for Raspberry Pi / Build for aarch64 (push) Failing after 23s
2025-07-29 11:29:46 +01:00
a12ee88b9b feat: Force sync on config save with timeturner offset
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-29 00:13:23 +01:00
917a844874 refactor: remove empty test module from ui.rs
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-29 00:09:17 +01:00
aee69679ef fix: remove unused chrono imports
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-29 00:02:46 +01:00
80faf4db9a fix: resolve build errors by adapting to clock delta refactor
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-28 23:58:52 +01:00
cc782fcd7e feat: add EWMA clock delta and adjtimex nudge controls
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-28 23:51:27 +01:00
6a45660e03 fix: process LTC frames in background to update app state
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-28 23:43:28 +01:00
985ccc6819 fix: Enable std feature for log and remove clock history
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-28 23:39:01 +01:00
5a86493824 feat: add daemon log viewer to web UI
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-28 23:36:51 +01:00
b803de93de feat: display clock delta history in UI
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-28 23:14:31 +01:00
7738d14097 addded comments to config.yml 2025-07-28 23:04:48 +01:00
58 changed files with 2149 additions and 341 deletions

View file

@ -17,8 +17,9 @@ actix-web = "4"
actix-files = "0.6"
tokio = { version = "1", features = ["full"] }
clap = { version = "4.4", features = ["derive"] }
log = "0.4"
env_logger = "0.11"
log = { version = "0.4", features = ["std"] }
daemonize = "0.5.0"
num-rational = "0.4"
num-traits = "0.2"

109
README.md
View file

@ -1,16 +1,16 @@
# 🕰️ NTP Timeturner (alpha)
# Fetch | Hachi (alpha)
**An LTC-driven NTP server for Raspberry Pi, built with broadcast precision and a hint of magic.**
**An LTC-driven NTP server for Raspberry Pi, built with broadcast precision**
Inspired by the TimeTurner in the Harry Potter series, this project synchronises timecode-locked systems by decoding incoming LTC (Linear Time Code) and broadcasting it as NTP — with precision as Hermione would insist upon.
Hachi synchronises timecode-locked systems by decoding incoming LTC (Linear Time Code) and broadcasting it as NTP/PTP — with the dedication our namesake would insist upon.
Created by Chris Frankland-Wright and John Rogers
Created by Chris Frankland-Wright and Chaos Rogers
---
## 📦 Hardware Requirements
- Raspberry Pi 5 (Dev Platform) but should be supported by Pi v3 (or better)
- Raspberry Pi 5 2GB (Dev Platform) but should be supported by Pi v3 (or better)
- Debian Bookworm (64-bit recommended)
- Teensy 4.0 - https://thepihut.com/products/teensy-4-0-headers
- Audio Adapter Board for Teensy 4.0 (Rev D) - https://thepihut.com/products/audio-adapter-board-for-teensy-4-0
@ -24,26 +24,103 @@ Created by Chris Frankland-Wright and John Rogers
- Reads SMPTE LTC from Audio Interface (3.5mm TRS but adaptable to BNC/XLR)
- Converts LTC into NTP-synced time
- Broadcasts time via local NTP server
- Supports configurable time offsets (hours, minutes, seconds, milliseconds) - NOT AVAILABLE
- Supports configurable time offsets (hours, minutes, seconds, frames or milliseconds)
- Systemd service support for headless operation
- Web-based UI for monitoring and control when running as a daemon
---
## 🚀 Installation (to update)
## 🖥️ Web Interface & API
When running as a background daemon, Hachi provides a web interface for monitoring and configuration.
For Rust install you can do
```bash
cargo install --git https://github.com/cjfranko/NTP-Timeturner
```
Clone and run the installer:
- **Access**: The web UI is available at `http://<raspberry_pi_ip>:8080`.
- **Functionality**: You can view the real-time sync status, see logs, and change all configuration options directly from your browser.
- **API**: A JSON API is also exposed for programmatic access. See `docs/api.md` for full details.
---
## 🛠️ Known Issues
- Supported Frame Rates: 24/25fps
- Non Supported Frame Rates: 23.98/30/59.94/60
- Fractional framerates have drift or wrong wall clock sync issues
---
## 🚀 Installation
The `setup.sh` script compiles and installs the Hachi application. You can run it by cloning the repository with `git` or by using the `curl` command below for a git-free installation.
### Prerequisites
- **Internet Connection**: To download dependencies.
- **Curl and Unzip**: The script requires `curl` to download files and `unzip` for the git-free method. The setup script will attempt to install these if they are missing.
### Running the Installer (Recommended)
This command downloads the latest version, unpacks it, and runs the setup script. Paste it into your Raspberry Pi terminal:
```bash
wget https://raw.githubusercontent.com/cjfranko/NTP-Timeturner/master/setup.sh
chmod +x setup.sh
curl -L https://github.com/cjfranko/NTP-Timeturner/archive/refs/heads/main.zip -o NTP-Timeturner.zip && \
unzip NTP-Timeturner.zip && \
cd NTP-Timeturner-main && \
chmod +x setup.sh && \
./setup.sh
```
### What the Script Does
The installation script automates the following steps:
1. **Installs Dependencies**: Installs `git`, `curl`, `unzip`, and necessary build tools.
2. **Compiles the Binary**: Runs `cargo build --release` to create an optimised executable.
3. **Creates Directories**: Creates `/opt/timeturner` to store the application files.
4. **Installs Files**:
- The compiled binary is copied to `/opt/timeturner/timeturner`.
- The web interface assets from the `static/` directory are copied to `/opt/timeturner/static`.
- A symbolic link is created from `/usr/local/bin/timeturner` to the binary, allowing it to be run from any location.
5. **Sets up Systemd Service**:
- Copies the `timeturner.service` file to `/etc/systemd/system/`.
- Enables the service to start automatically on system boot.
After installation is complete, the script will provide instructions to start the service manually or to run the application in its interactive terminal mode.
```bash
The working directory is /opt/timeturner.
Default 'config.yml' installed to /opt/timeturner.
To start the service, run:
sudo systemctl start timeturner.service
To view live logs, run:
journalctl -u timeturner.service -f
To run the interactive TUI instead, simply run from the project directory:
cargo run
Or from anywhere after installation:
timeturner
```
---
## 🔄 Updating
If you installed Hachi by cloning the repository with `git`, you can use the `update.sh` script to easily update to the latest version.
**Note**: This script will not work if you used the `curl` one-line command for installation, as that method does not create a Git repository.
To run the update script, navigate to the `NTP-Timeturner-main` directory and run:
```bash
chmod +x update.sh && ./update.sh
```
The update script automates the following:
1. Pulls the latest code from the `main` branch on GitHub.
2. Rebuilds the application binary.
3. Copies the new binary to `/opt/timeturner/`.
4. Restarts the `timeturner` service to apply the changes.
---
## 🕰️ Chrony NTP
```bash
@ -52,10 +129,10 @@ chronyc tracking | NTP Tracking
sudo nano /etc/chrony/chrony.conf | Default Chrony Conf File
Add to top:
# Serve the system clock as a reference at stratum10
# Serve the system clock as a reference at stratum1
server 127.127.1.0
allow 127.0.0.0/8
local stratum 10
local stratum 1
Add to bottom:
# Allow LAN clients

9
SECURITY.MD Normal file
View file

@ -0,0 +1,9 @@
Reporting Security Issues
The TimeTurner team and community take security bugs in TimeTurner seriously. We appreciate your efforts to responsibly disclose your findings, and will make every effort to acknowledge your contributions.
To report a security issue, please use the GitHub Security Advisory "Report a Vulnerability" tab.
The TimeTurner team will send a response indicating the next steps in handling your report. After the initial reply to your report, the security team will keep you informed of the progress towards a fix and full announcement, and may ask for additional information or guidance.
Report security bugs in third-party modules to the person or team maintaining the module.

View file

@ -1,5 +1,13 @@
# Hardware offset in milliseconds for correcting capture latency.
hardwareOffsetMs: 20
hardwareOffsetMs: 55
# Enable automatic clock synchronization.
# When enabled, the system will perform an initial full sync, then periodically
# nudge the clock to keep it aligned with the LTC source.
autoSyncEnabled: true
# Default nudge in milliseconds for adjtimex control.
defaultNudgeMs: 2
# Time-turning offsets. All values are added to the incoming LTC time.
# These can be positive or negative.
@ -8,3 +16,4 @@ timeturnerOffset:
minutes: 0
seconds: 0
frames: 0
milliseconds: 0

View file

@ -4,19 +4,25 @@ This document describes the HTTP API for the NTP Timeturner application.
## Endpoints
### Status
### Status and Logs
- **`GET /api/status`**
Retrieves the real-time status of the LTC reader and system clock synchronization.
Retrieves the real-time status of the LTC reader and system clock synchronization. The `ltc_timecode` field uses `:` as a separator for non-drop-frame timecode, and `;` for drop-frame timecode between seconds and frames (e.g., `10:20:30;00`).
**Possible values for status fields:**
- `ltc_status`: `"LOCK"`, `"FREE"`, or `"(waiting)"`
- `sync_status`: `"IN SYNC"`, `"CLOCK AHEAD"`, `"CLOCK BEHIND"`, `"TIMETURNING"`
- `jitter_status`: `"GOOD"`, `"AVERAGE"`, `"BAD"`
**Example Response:**
```json
{
"ltc_status": "LOCK",
"ltc_timecode": "10:20:30:00",
"ltc_timecode": "10:20:30;00",
"frame_rate": "25.00fps",
"system_clock": "10:20:30.005",
"system_date": "2025-07-30",
"timecode_delta_ms": 5,
"timecode_delta_frames": 0,
"sync_status": "IN SYNC",
@ -24,11 +30,23 @@ This document describes the HTTP API for the NTP Timeturner application.
"lock_ratio": 99.5,
"ntp_active": true,
"interfaces": ["192.168.1.100"],
"hardware_offset_ms": 0
"hardware_offset_ms": 20
}
```
### Sync
- **`GET /api/logs`**
Retrieves the last 100 log entries from the application.
**Example Response:**
```json
[
"2025-08-07 10:00:00 [INFO] Starting TimeTurner daemon...",
"2025-08-07 10:00:01 [INFO] Found serial port: /dev/ttyACM0"
]
```
### System Clock Control
- **`POST /api/sync`**
@ -36,7 +54,7 @@ This document describes the HTTP API for the NTP Timeturner application.
**Request Body:** None
**Success Response:**
**Success Response (200 OK):**
```json
{
"status": "success",
@ -44,13 +62,14 @@ This document describes the HTTP API for the NTP Timeturner application.
}
```
**Error Responses:**
**Error Response (400 Bad Request):**
```json
{
"status": "error",
"message": "No LTC timecode available to sync to."
}
```
**Error Response (500 Internal Server Error):**
```json
{
"status": "error",
@ -58,33 +77,120 @@ This document describes the HTTP API for the NTP Timeturner application.
}
```
- **`POST /api/nudge_clock`**
Nudges the system clock by a specified number of microseconds. This requires `sudo` privileges to run `adjtimex`.
**Example Request:**
```json
{
"microseconds": -2000
}
```
**Success Response (200 OK):**
```json
{
"status": "success",
"message": "Clock nudge command issued."
}
```
**Error Response (500 Internal Server Error):**
```json
{
"status": "error",
"message": "Clock nudge command failed."
}
```
- **`POST /api/set_date`**
Sets the system date. This is useful as LTC does not contain date information. Requires `sudo` privileges.
**Example Request:**
```json
{
"date": "2025-07-30"
}
```
**Success Response (200 OK):**
```json
{
"status": "success",
"message": "Date update command issued."
}
```
**Error Response (500 Internal Server Error):**
```json
{
"status": "error",
"message": "Date update command failed."
}
```
### Configuration
- **`GET /api/config`**
Retrieves the current application configuration.
Retrieves the current application configuration from `config.yml`.
**Example Response:**
**Example Response (200 OK):**
```json
{
"hardware_offset_ms": 0
"hardwareOffsetMs": 20,
"timeturnerOffset": {
"hours": 0,
"minutes": 0,
"seconds": 0,
"frames": 0,
"milliseconds": 0
},
"defaultNudgeMs": 2,
"autoSyncEnabled": false
}
```
- **`POST /api/config`**
Updates the `hardware_offset_ms` configuration. The new value is persisted to `config.json` and reloaded by the application automatically.
Updates the application configuration. The new configuration is persisted to `config.yml` and takes effect immediately.
**Example Request:**
```json
{
"hardware_offset_ms": 10
"hardwareOffsetMs": 55,
"timeturnerOffset": {
"hours": 1,
"minutes": 2,
"seconds": 3,
"frames": 4,
"milliseconds": 5
},
"defaultNudgeMs": 2,
"autoSyncEnabled": true
}
```
**Success Response:**
**Success Response (200 OK):** (Returns the updated configuration)
```json
{
"hardware_offset_ms": 10
"hardwareOffsetMs": 55,
"timeturnerOffset": {
"hours": 1,
"minutes": 2,
"seconds": 3,
"frames": 4,
"milliseconds": 5
},
"defaultNudgeMs": 2,
"autoSyncEnabled": true
}
```
**Error Response (500 Internal Server Error):**
```json
{
"status": "error",
"message": "Failed to write config.yml"
}
```

241
setup.sh
View file

@ -3,14 +3,226 @@ set -e
echo "--- TimeTurner Setup ---"
# 1. Build the release binary
echo "📦 Building release binary with Cargo..."
if ! command -v cargo &> /dev/null
then
echo "❌ Cargo is not installed. Please install Rust and Cargo first."
echo "Visit https://rustup.rs/ for instructions."
# Check if TimeTurner is already installed.
INSTALL_DIR="/opt/timeturner"
if [ -f "${INSTALL_DIR}/timeturner" ]; then
echo "✅ TimeTurner is already installed."
# Ask the user what to do
read -p "Do you want to (U)pdate, (R)einstall, or (A)bort? [U/r/a] " choice
case "$choice" in
r|R )
echo "Proceeding with full re-installation..."
# Stop the service to allow overwriting the binary, ignore errors if not running
echo "Stopping existing TimeTurner service..."
sudo systemctl stop timeturner.service || true
# The script will continue to the installation steps below.
;;
a|A )
echo "Aborting setup."
exit 0
;;
* ) # Default to Update
echo "Attempting to run the update script..."
# Ensure we are in a git repository and the update script exists
if [ -d ".git" ] && [ -f "update.sh" ]; then
chmod +x update.sh
./update.sh
# Exit cleanly after the update
exit 0
else
echo "⚠️ Could not find 'update.sh' or not in a git repository."
echo "Please re-clone the repository to get the update script, or remove the existing installation to run setup again:"
echo " sudo rm -rf ${INSTALL_DIR}"
exit 1
fi
;;
esac
fi
# Determine package manager
PKG_MANAGER=""
if command -v apt &> /dev/null; then
PKG_MANAGER="apt"
elif command -v dnf &> /dev/null; then
PKG_MANAGER="dnf"
elif command -v pacman &> /dev/null; then
PKG_MANAGER="pacman"
else
echo "Error: No supported package manager (apt, dnf, pacman) found. Please install dependencies manually."
exit 1
fi
echo "Detected package manager: $PKG_MANAGER"
# --- Update System Packages ---
echo "Updating system packages..."
if [ "$PKG_MANAGER" == "apt" ]; then
sudo apt update
sudo DEBIAN_FRONTEND=noninteractive apt upgrade -y -o Dpkg::Options::="--force-confold"
elif [ "$PKG_MANAGER" == "dnf" ]; then
sudo dnf upgrade -y
elif [ "$PKG_MANAGER" == "pacman" ]; then
sudo pacman -Syu --noconfirm
fi
echo "System packages updated."
# --- Install Rust/Cargo if not installed ---
if ! command -v cargo &> /dev/null; then
echo "Rust/Cargo not found. Installing Rustup..."
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
# Source cargo's env for the current shell session
# This is for the current script's execution path, typically rustup adds to .bashrc/.profile for future sessions.
# We need it now, but for non-interactive script, sourcing won't affect parent shell.
# However, cargo build below will rely on it being in PATH. rustup makes sure of this if it installs.
# For safety, ensure PATH is updated.
export PATH="$HOME/.cargo/bin:$PATH"
echo "Rust/Cargo installed successfully."
else
echo "Rust/Cargo is already installed."
fi
# --- Install common build dependencies for Rust ---
echo "Installing common build dependencies..."
if [ "$PKG_MANAGER" == "apt" ]; then
sudo apt update
sudo apt install -y build-essential libudev-dev pkg-config curl wget
elif [ "$PKG_MANAGER" == "dnf" ]; then
sudo dnf install -y gcc make perl-devel libudev-devel pkg-config curl wget
elif [ "$PKG_MANAGER" == "pacman" ]; then
sudo pacman -Sy --noconfirm base-devel libudev pkg-config curl
fi
echo "Common build dependencies installed."
# --- Install Python dependencies for testing ---
echo "🐍 Installing Python dependencies for test scripts..."
if [ "$PKG_MANAGER" == "apt" ]; then
# We no longer need hotspot dependencies
sudo apt install -y python3 python3-pip python3-serial
elif [ "$PKG_MANAGER" == "dnf" ]; then
# python3-pyserial is the name for pyserial in dnf
sudo dnf install -y python3 python3-pip python3-pyserial
elif [ "$PKG_MANAGER" == "pacman" ]; then
# python-pyserial is the name for pyserial in pacman
sudo pacman -Sy --noconfirm python python-pip python-pyserial
fi
# sudo pip3 install pyserial # This is replaced by the native package manager installs above
echo "✅ Python dependencies installed."
# --- Apply custom splash screen ---
if [[ "$(uname)" == "Linux" ]]; then
echo "🖼️ Applying custom splash screen..."
SPLASH_URL="https://raw.githubusercontent.com/cjfranko/NTP-Timeturner/refs/heads/main/splash.png"
PLYMOUTH_THEME_DIR="/usr/share/plymouth/themes/pix"
PLYMOUTH_IMAGE_PATH="${PLYMOUTH_THEME_DIR}/splash.png"
sudo mkdir -p "${PLYMOUTH_THEME_DIR}"
echo "Downloading splash image from ${SPLASH_URL}..."
sudo curl -L "${SPLASH_URL}" -o "${PLYMOUTH_IMAGE_PATH}"
if [ -f "${PLYMOUTH_IMAGE_PATH}" ]; then
echo "Splash image downloaded. Updating Plymouth configuration..."
# Set 'pix' as the default plymouth theme if not already.
# This is a common theme that expects splash.png.
sudo update-alternatives --install /usr/share/plymouth/themes/default.plymouth default.plymouth "${PLYMOUTH_THEME_DIR}/pix.plymouth" 100 || true
# Ensure the pix theme exists and is linked
if [ ! -f "${PLYMOUTH_THEME_DIR}/pix.plymouth" ]; then
echo "Creating dummy pix.plymouth for update-initramfs"
echo "[Plymouth Theme]" | sudo tee "${PLYMOUTH_THEME_DIR}/pix.plymouth" > /dev/null
echo "Name=Pi Splash" | sudo tee -a "${PLYMOUTH_THEME_DIR}/pix.plymouth" > /dev/null
echo "Description=TimeTurner Raspberry Pi Splash Screen" | sudo tee -a "${PLYMOUTH_THEME_DIR}/pix.plymouth" > /dev/null
echo "SpriteAnimation=/splash.png" | sudo tee -a "${PLYMOUTH_THEME_DIR}/pix.plymouth" > /dev/null
fi
# Update the initial RAM filesystem to include the new splash screen
sudo update-initramfs -u
echo "✅ Custom splash screen applied. Reboot may be required to see changes."
else
echo "❌ Failed to download splash image from ${SPLASH_URL}."
fi
else
echo "⚠️ Skipping splash screen configuration on non-Linux OS."
fi
# --- Remove NTPD and install Chrony, NMTUI, Adjtimex ---
echo "Removing NTPD (if installed) and installing Chrony, NMTUI, Adjtimex..."
# --- Remove NTPD and install Chrony, NMTUI, Adjtimex ---
echo "Removing NTPD (if installed) and installing Chrony, NMTUI, Adjtimex..."
if [ "$PKG_MANAGER" == "apt" ]; then
sudo apt update
sudo apt remove -y ntp || true # Remove ntp if it exists, ignore if not
sudo apt install -y chrony network-manager adjtimex
sudo systemctl enable chrony --now
elif [ "$PKG_MANAGER" == "dnf" ]; then
sudo dnf remove -y ntp
sudo dnf install -y chrony NetworkManager-tui adjtimex
sudo systemctl enable chronyd --now
elif [ "$PKG_MANAGER" == "pacman" ]; then
sudo pacman -Sy --noconfirm ntp || true
sudo pacman -R --noconfirm ntp || true # Ensure ntp is removed
sudo pacman -Sy --noconfirm chrony networkmanager adjtimex
sudo systemctl enable chronyd --now
sudo systemctl enable NetworkManager --now # nmtui relies on NetworkManager
fi
echo "NTPD removed (if present). Chrony, NMTUI, and Adjtimex installed and configured."
# --- Configure Chrony to act as a local NTP server ---
echo "⚙️ Configuring Chrony to serve local time..."
# The path to chrony.conf can vary
if [ -f /etc/chrony/chrony.conf ]; then
CHRONY_CONF="/etc/chrony/chrony.conf"
elif [ -f /etc/chrony.conf ]; then
CHRONY_CONF="/etc/chrony.conf"
else
CHRONY_CONF=""
fi
if [ -n "$CHRONY_CONF" ]; then
# Comment out any existing pool, server, or sourcedir lines to prevent syncing with external sources
echo "Disabling external NTP sources..."
sudo sed -i -E 's/^(pool|server|sourcedir)/#&/' "$CHRONY_CONF"
# Add settings to the top of the file to serve local clock
# Using a temp file to prepend is safer than multiple sed calls
TEMP_CONF=$(mktemp)
cat <<EOF > "$TEMP_CONF"
# Serve the system clock as a reference at stratum 1
server 127.127.1.0
allow 127.0.0.0/8
local stratum 1
EOF
# Append the rest of the original config file after our new lines
cat "$CHRONY_CONF" >> "$TEMP_CONF"
sudo mv "$TEMP_CONF" "$CHRONY_CONF"
# Add settings to the bottom of the file to allow LAN clients
echo "Allowing LAN clients..."
sudo tee -a "$CHRONY_CONF" > /dev/null <<EOF
# Allow LAN clients to connect
allow 0.0.0.0/0
EOF
# Restart chrony to apply changes (service name can be chrony or chronyd)
echo "Restarting Chrony service..."
sudo systemctl restart chrony || sudo systemctl restart chronyd
echo "✅ Chrony configured."
else
echo "⚠️ Warning: chrony.conf not found. Skipping Chrony configuration."
fi
# --- The entire WiFi hotspot and captive portal section has been removed ---
# 1. Build the release binary
echo "📦 Building release binary with Cargo..."
# No need to check for cargo again, as it's handled above
cargo build --release
echo "✅ Build complete."
@ -21,13 +233,16 @@ echo "🔧 Creating directories..."
sudo mkdir -p $INSTALL_DIR
echo "✅ Directory $INSTALL_DIR created."
# 3. Install binary
echo "🚀 Installing timeturner binary..."
# 3. Install binary and static web files
echo "🚀 Installing timeturner binary and web assets..."
sudo cp target/release/ntp_timeturner $INSTALL_DIR/timeturner
# The static directory contains the web UI files
sudo cp -r static $INSTALL_DIR/
sudo ln -sf $INSTALL_DIR/timeturner $BIN_DIR/timeturner
echo "✅ Binary installed to $INSTALL_DIR and linked to $BIN_DIR."
echo "✅ Binary and assets installed to $INSTALL_DIR, and binary linked to $BIN_DIR."
# 4. Install systemd service file
# Only needed for Linux systems (e.g., Raspberry Pi OS)
if [[ "$(uname)" == "Linux" ]]; then
echo "⚙️ Installing systemd service for Linux..."
sudo cp timeturner.service /etc/systemd/system/
@ -42,7 +257,13 @@ echo ""
echo "--- Setup Complete ---"
echo "The TimeTurner daemon is now installed."
echo "The working directory is $INSTALL_DIR."
echo "A default 'config.yml' will be created there on first run."
# Copy default config.yml from repo if it exists
if [ -f config.yml ]; then
sudo cp config.yml $INSTALL_DIR/
echo "Default 'config.yml' installed to $INSTALL_DIR."
else
echo "⚠️ No default 'config.yml' found in repository. Please add one if needed."
fi
echo ""
if [[ "$(uname)" == "Linux" ]]; then
echo "To start the service, run:"

View file

@ -5,11 +5,14 @@ use chrono::{Local, Timelike};
use get_if_addrs::get_if_addrs;
use serde::{Deserialize, Serialize};
use serde_json;
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
use crate::config::{self, Config};
use crate::sync_logic::{self, LtcState};
use crate::system;
use num_rational::Ratio;
use num_traits::ToPrimitive;
// Data structure for the main status response
#[derive(Serialize, Deserialize)]
@ -18,6 +21,7 @@ struct ApiStatus {
ltc_timecode: String,
frame_rate: String,
system_clock: String,
system_date: String,
timecode_delta_ms: i64,
timecode_delta_frames: i64,
sync_status: String,
@ -32,6 +36,7 @@ struct ApiStatus {
pub struct AppState {
pub ltc_state: Arc<Mutex<LtcState>>,
pub config: Arc<Mutex<Config>>,
pub log_buffer: Arc<Mutex<VecDeque<String>>>,
}
#[get("/api/status")]
@ -42,10 +47,14 @@ async fn get_status(data: web::Data<AppState>) -> impl Responder {
let ltc_status = state.latest.as_ref().map_or("(waiting)".to_string(), |f| f.status.clone());
let ltc_timecode = state.latest.as_ref().map_or("".to_string(), |f| {
format!("{:02}:{:02}:{:02}:{:02}", f.hours, f.minutes, f.seconds, f.frames)
let sep = if f.is_drop_frame { ';' } else { ':' };
format!(
"{:02}:{:02}:{:02}{}{:02}",
f.hours, f.minutes, f.seconds, sep, f.frames
)
});
let frame_rate = state.latest.as_ref().map_or("".to_string(), |f| {
format!("{:.2}fps", f.frame_rate)
format!("{:.2}fps", f.frame_rate.to_f64().unwrap_or(0.0))
});
let now_local = Local::now();
@ -56,12 +65,14 @@ async fn get_status(data: web::Data<AppState>) -> impl Responder {
now_local.second(),
now_local.timestamp_subsec_millis(),
);
let system_date = now_local.format("%Y-%m-%d").to_string();
let avg_delta = state.average_clock_delta();
let avg_delta = state.get_ewma_clock_delta();
let mut delta_frames = 0;
if let Some(frame) = &state.latest {
let frame_ms = 1000.0 / frame.frame_rate;
delta_frames = ((avg_delta as f64 / frame_ms).round()) as i64;
let delta_ms_ratio = Ratio::new(avg_delta, 1);
let frames_ratio = delta_ms_ratio * frame.frame_rate / Ratio::new(1000, 1);
delta_frames = frames_ratio.round().to_integer();
}
let sync_status = sync_logic::get_sync_status(avg_delta, &config);
@ -81,6 +92,7 @@ async fn get_status(data: web::Data<AppState>) -> impl Responder {
ltc_timecode,
frame_rate,
system_clock,
system_date,
timecode_delta_ms: avg_delta,
timecode_delta_frames: delta_frames,
sync_status: sync_status.to_string(),
@ -113,6 +125,42 @@ async fn get_config(data: web::Data<AppState>) -> impl Responder {
HttpResponse::Ok().json(&*config)
}
#[get("/api/logs")]
async fn get_logs(data: web::Data<AppState>) -> impl Responder {
let logs = data.log_buffer.lock().unwrap();
HttpResponse::Ok().json(&*logs)
}
#[derive(Deserialize)]
struct NudgeRequest {
microseconds: i64,
}
#[post("/api/nudge_clock")]
async fn nudge_clock(req: web::Json<NudgeRequest>) -> impl Responder {
if system::nudge_clock(req.microseconds).is_ok() {
HttpResponse::Ok().json(serde_json::json!({ "status": "success", "message": "Clock nudge command issued." }))
} else {
HttpResponse::InternalServerError().json(serde_json::json!({ "status": "error", "message": "Clock nudge command failed." }))
}
}
#[derive(Deserialize)]
struct SetDateRequest {
date: String,
}
#[post("/api/set_date")]
async fn set_date(req: web::Json<SetDateRequest>) -> impl Responder {
if system::set_date(&req.date).is_ok() {
HttpResponse::Ok()
.json(serde_json::json!({ "status": "success", "message": "Date update command issued." }))
} else {
HttpResponse::InternalServerError()
.json(serde_json::json!({ "status": "error", "message": "Date update command failed." }))
}
}
#[post("/api/config")]
async fn update_config(
data: web::Data<AppState>,
@ -122,23 +170,44 @@ async fn update_config(
*config = req.into_inner();
if config::save_config("config.yml", &config).is_ok() {
eprintln!("🔄 Saved config via API: {:?}", *config);
log::info!("🔄 Saved config via API: {:?}", *config);
// If timeturner offset is active, trigger a sync immediately.
if config.timeturner_offset.is_active() {
let state = data.ltc_state.lock().unwrap();
if let Some(frame) = &state.latest {
log::info!("Timeturner offset is active, triggering sync...");
if system::trigger_sync(frame, &config).is_ok() {
log::info!("Sync triggered successfully after config change.");
} else {
log::error!("Sync failed after config change.");
}
} else {
log::warn!("Timeturner offset is active, but no LTC frame available to sync.");
}
}
HttpResponse::Ok().json(&*config)
} else {
HttpResponse::InternalServerError().json(serde_json::json!({ "status": "error", "message": "Failed to write config.yml" }))
log::error!("Failed to write config.yml");
HttpResponse::InternalServerError().json(
serde_json::json!({ "status": "error", "message": "Failed to write config.yml" }),
)
}
}
pub async fn start_api_server(
state: Arc<Mutex<LtcState>>,
config: Arc<Mutex<Config>>,
log_buffer: Arc<Mutex<VecDeque<String>>>,
) -> std::io::Result<()> {
let app_state = web::Data::new(AppState {
ltc_state: state,
config: config,
log_buffer: log_buffer,
});
println!("🚀 Starting API server at http://0.0.0.0:8080");
log::info!("🚀 Starting API server at http://0.0.0.0:8080");
HttpServer::new(move || {
App::new()
@ -147,6 +216,9 @@ pub async fn start_api_server(
.service(manual_sync)
.service(get_config)
.service(update_config)
.service(get_logs)
.service(nudge_clock)
.service(set_date)
// Serve frontend static files
.service(fs::Files::new("/", "static/").index_file("index.html"))
})
@ -174,13 +246,14 @@ mod tests {
minutes: 2,
seconds: 3,
frames: 4,
frame_rate: 25.0,
is_drop_frame: false,
frame_rate: Ratio::new(25, 1),
timestamp: Utc::now(),
}),
lock_count: 10,
free_count: 1,
offset_history: VecDeque::from(vec![1, 2, 3]),
clock_delta_history: VecDeque::from(vec![4, 5, 6]),
ewma_clock_delta: Some(5.0),
last_match_status: "IN SYNC".to_string(),
last_match_check: Utc::now().timestamp(),
}
@ -191,11 +264,16 @@ mod tests {
let ltc_state = Arc::new(Mutex::new(get_test_ltc_state()));
let config = Arc::new(Mutex::new(Config {
hardware_offset_ms: 10,
timeturner_offset: TimeturnerOffset {
hours: 0, minutes: 0, seconds: 0, frames: 0
}
timeturner_offset: TimeturnerOffset::default(),
default_nudge_ms: 2,
auto_sync_enabled: false,
}));
web::Data::new(AppState { ltc_state, config })
let log_buffer = Arc::new(Mutex::new(VecDeque::new()));
web::Data::new(AppState {
ltc_state,
config,
log_buffer,
})
}
#[actix_web::test]
@ -217,6 +295,32 @@ mod tests {
assert_eq!(resp.hardware_offset_ms, 10);
}
#[actix_web::test]
async fn test_get_status_drop_frame() {
let app_state = get_test_app_state();
// Set state to drop frame
app_state
.ltc_state
.lock()
.unwrap()
.latest
.as_mut()
.unwrap()
.is_drop_frame = true;
let app = test::init_service(
App::new()
.app_data(app_state.clone())
.service(get_status),
)
.await;
let req = test::TestRequest::get().uri("/api/status").to_request();
let resp: ApiStatus = test::call_and_read_body_json(&app, req).await;
assert_eq!(resp.ltc_timecode, "01:02:03;04");
}
#[actix_web::test]
async fn test_get_config() {
let app_state = get_test_app_state();
@ -253,7 +357,9 @@ mod tests {
let new_config_json = serde_json::json!({
"hardwareOffsetMs": 55,
"timeturnerOffset": { "hours": 1, "minutes": 2, "seconds": 3, "frames": 4 }
"defaultNudgeMs": 2,
"autoSyncEnabled": true,
"timeturnerOffset": { "hours": 1, "minutes": 2, "seconds": 3, "frames": 4, "milliseconds": 5 }
});
let req = test::TestRequest::post()
@ -264,16 +370,22 @@ mod tests {
let resp: Config = test::call_and_read_body_json(&app, req).await;
assert_eq!(resp.hardware_offset_ms, 55);
assert_eq!(resp.auto_sync_enabled, true);
assert_eq!(resp.timeturner_offset.hours, 1);
assert_eq!(resp.timeturner_offset.milliseconds, 5);
let final_config = app_state.config.lock().unwrap();
assert_eq!(final_config.hardware_offset_ms, 55);
assert_eq!(final_config.auto_sync_enabled, true);
assert_eq!(final_config.timeturner_offset.hours, 1);
assert_eq!(final_config.timeturner_offset.milliseconds, 5);
// Test that the file was written
assert!(fs::metadata(config_path).is_ok());
let contents = fs::read_to_string(config_path).unwrap();
assert!(contents.contains("hardwareOffsetMs: 55"));
assert!(contents.contains("autoSyncEnabled: true"));
assert!(contents.contains("hours: 1"));
assert!(contents.contains("milliseconds: 5"));
// Cleanup
let _ = fs::remove_file(config_path);

View file

@ -19,11 +19,17 @@ pub struct TimeturnerOffset {
pub minutes: i64,
pub seconds: i64,
pub frames: i64,
#[serde(default)]
pub milliseconds: i64,
}
impl TimeturnerOffset {
pub fn is_active(&self) -> bool {
self.hours != 0 || self.minutes != 0 || self.seconds != 0 || self.frames != 0
self.hours != 0
|| self.minutes != 0
|| self.seconds != 0
|| self.frames != 0
|| self.milliseconds != 0
}
}
@ -33,6 +39,14 @@ pub struct Config {
pub hardware_offset_ms: i64,
#[serde(default)]
pub timeturner_offset: TimeturnerOffset,
#[serde(default = "default_nudge_ms")]
pub default_nudge_ms: i64,
#[serde(default)]
pub auto_sync_enabled: bool,
}
fn default_nudge_ms() -> i64 {
2 // Default nudge is 2ms
}
impl Config {
@ -46,10 +60,11 @@ impl Config {
return Self::default();
}
serde_yaml::from_str(&contents).unwrap_or_else(|e| {
eprintln!("Failed to parse config, using default: {}", e);
log::warn!("Failed to parse config, using default: {}", e);
Self::default()
})
}
}
impl Default for Config {
@ -57,13 +72,35 @@ impl Default for Config {
Self {
hardware_offset_ms: 0,
timeturner_offset: TimeturnerOffset::default(),
default_nudge_ms: default_nudge_ms(),
auto_sync_enabled: false,
}
}
}
pub fn save_config(path: &str, config: &Config) -> Result<(), Box<dyn std::error::Error>> {
let contents = serde_yaml::to_string(config)?;
fs::write(path, contents)?;
let mut s = String::new();
s.push_str("# Hardware offset in milliseconds for correcting capture latency.\n");
s.push_str(&format!("hardwareOffsetMs: {}\n\n", config.hardware_offset_ms));
s.push_str("# Enable automatic clock synchronization.\n");
s.push_str("# When enabled, the system will perform an initial full sync, then periodically\n");
s.push_str("# nudge the clock to keep it aligned with the LTC source.\n");
s.push_str(&format!("autoSyncEnabled: {}\n\n", config.auto_sync_enabled));
s.push_str("# Default nudge in milliseconds for adjtimex control.\n");
s.push_str(&format!("defaultNudgeMs: {}\n\n", config.default_nudge_ms));
s.push_str("# Time-turning offsets. All values are added to the incoming LTC time.\n");
s.push_str("# These can be positive or negative.\n");
s.push_str("timeturnerOffset:\n");
s.push_str(&format!(" hours: {}\n", config.timeturner_offset.hours));
s.push_str(&format!(" minutes: {}\n", config.timeturner_offset.minutes));
s.push_str(&format!(" seconds: {}\n", config.timeturner_offset.seconds));
s.push_str(&format!(" frames: {}\n", config.timeturner_offset.frames));
s.push_str(&format!(" milliseconds: {}\n", config.timeturner_offset.milliseconds));
fs::write(path, s)?;
Ok(())
}
@ -82,7 +119,7 @@ pub fn watch_config(path: &str) -> Arc<Mutex<Config>> {
let new_cfg = Config::load(&watch_path_for_cb);
let mut cfg = config_for_cb.lock().unwrap();
*cfg = new_cfg;
eprintln!("🔄 Reloaded config.yml: {:?}", *cfg);
log::info!("🔄 Reloaded config.yml: {:?}", *cfg);
}
}
})

52
src/logger.rs Normal file
View file

@ -0,0 +1,52 @@
use chrono::Local;
use log::{LevelFilter, Log, Metadata, Record};
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
const MAX_LOG_ENTRIES: usize = 100;
struct RingBufferLogger {
buffer: Arc<Mutex<VecDeque<String>>>,
}
impl Log for RingBufferLogger {
fn enabled(&self, metadata: &Metadata) -> bool {
metadata.level() <= LevelFilter::Info
}
fn log(&self, record: &Record) {
if self.enabled(record.metadata()) {
let msg = format!(
"{} [{}] {}",
Local::now().format("%Y-%m-%d %H:%M:%S"),
record.level(),
record.args()
);
// Also print to stderr for console/daemon logging
eprintln!("{}", msg);
let mut buffer = self.buffer.lock().unwrap();
if buffer.len() == MAX_LOG_ENTRIES {
buffer.pop_front();
}
buffer.push_back(msg);
}
}
fn flush(&self) {}
}
pub fn setup_logger() -> Arc<Mutex<VecDeque<String>>> {
let buffer = Arc::new(Mutex::new(VecDeque::with_capacity(MAX_LOG_ENTRIES)));
let logger = RingBufferLogger {
buffer: buffer.clone(),
};
// We use `set_boxed_logger` to install our custom logger.
// The `log` crate will then route all log messages to it.
log::set_boxed_logger(Box::new(logger)).expect("Failed to set logger");
log::set_max_level(LevelFilter::Info);
buffer
}

View file

@ -2,6 +2,7 @@
mod api;
mod config;
mod logger;
mod serial_input;
mod sync_logic;
mod system;
@ -14,7 +15,7 @@ use crate::sync_logic::LtcState;
use crate::ui::start_ui;
use clap::Parser;
use daemonize::Daemonize;
use env_logger;
use serialport;
use std::{
fs,
@ -35,6 +36,8 @@ struct Args {
enum Command {
/// Run as a background daemon providing a web UI.
Daemon,
/// Stop the running daemon process.
Kill,
}
/// Default config content, embedded in the binary.
@ -42,6 +45,14 @@ const DEFAULT_CONFIG: &str = r#"
# Hardware offset in milliseconds for correcting capture latency.
hardwareOffsetMs: 20
# Enable automatic clock synchronization.
# When enabled, the system will perform an initial full sync, then periodically
# nudge the clock to keep it aligned with the LTC source.
autoSyncEnabled: false
# Default nudge in milliseconds for adjtimex control.
defaultNudgeMs: 2
# Time-turning offsets. All values are added to the incoming LTC time.
# These can be positive or negative.
timeturnerOffset:
@ -49,6 +60,7 @@ timeturnerOffset:
minutes: 0
seconds: 0
frames: 0
milliseconds: 0
"#;
/// If no `config.yml` exists alongside the binary, write out the default.
@ -57,32 +69,89 @@ fn ensure_config() {
if !p.exists() {
fs::write(p, DEFAULT_CONFIG.trim())
.expect("Failed to write default config.yml");
eprintln!("⚙️ Emitted default config.yml");
log::info!("⚙️ Emitted default config.yml");
}
}
fn find_serial_port() -> Option<String> {
if let Ok(ports) = serialport::available_ports() {
for p in ports {
if p.port_name.starts_with("/dev/ttyACM")
|| p.port_name.starts_with("/dev/ttyAMA")
|| p.port_name.starts_with("/dev/ttyUSB")
{
return Some(p.port_name);
}
}
}
None
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
// This must be called before any logging statements.
let log_buffer = logger::setup_logger();
let args = Args::parse();
if let Some(Command::Daemon) = &args.command {
println!("🚀 Starting daemon...");
if let Some(command) = &args.command {
match command {
Command::Daemon => {
log::info!("🚀 Starting daemon...");
// Create files for stdout and stderr in the current directory
let stdout = fs::File::create("daemon.out").expect("Could not create daemon.out");
let stderr = fs::File::create("daemon.err").expect("Could not create daemon.err");
// 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) => {
eprintln!("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;
}
}
}
@ -99,13 +168,23 @@ async fn main() {
// 3⃣ Shared state for UI and serial reader
let ltc_state = Arc::new(Mutex::new(LtcState::new()));
// 4⃣ Spawn the serial reader thread
// 4⃣ Find serial port and spawn the serial reader thread
let serial_port_path = match find_serial_port() {
Some(port) => port,
None => {
log::error!("❌ No serial port found. Please connect the Teensy device.");
return;
}
};
log::info!("Found serial port: {}", serial_port_path);
{
let tx_clone = tx.clone();
let state_clone = ltc_state.clone();
let port_clone = serial_port_path.clone();
thread::spawn(move || {
start_serial_thread(
"/dev/ttyACM0",
&port_clone,
115200,
tx_clone,
state_clone,
@ -114,53 +193,146 @@ async fn main() {
});
}
// 5⃣ Spawn UI or setup daemon logging
// 5⃣ Spawn UI or setup daemon logging. The web service is only started
// when running as a daemon. The TUI is for interactive foreground use.
if args.command.is_none() {
println!("🔧 Watching config.yml...");
println!("🚀 Serial thread launched");
println!("🖥️ UI thread launched");
// --- Interactive TUI Mode ---
log::info!("🔧 Watching config.yml...");
log::info!("🚀 Serial thread launched");
log::info!("🖥️ UI thread launched");
let ui_state = ltc_state.clone();
let config_clone = config.clone();
let port = "/dev/ttyACM0".to_string();
let port = serial_port_path;
thread::spawn(move || {
start_ui(ui_state, port, config_clone);
});
} else {
// In daemon mode, we initialize env_logger.
// This will log to stdout, and the systemd service will capture it.
// The RUST_LOG env var controls the log level (e.g., RUST_LOG=info).
env_logger::init();
// --- Daemon Mode ---
// In daemon mode, logging is already set up to go to stderr.
// The systemd service will capture it. The web service (API and static files)
// is launched later in the main async block.
log::info!("🚀 Starting TimeTurner daemon...");
}
// 6⃣ Set up a LocalSet for the API server and main loop
// 6⃣ Spawn the auto-sync thread
{
let sync_state = ltc_state.clone();
let sync_config = config.clone();
thread::spawn(move || {
// Wait for the first LTC frame to arrive
loop {
if sync_state.lock().unwrap().latest.is_some() {
log::info!("Auto-sync: Initial LTC frame detected.");
break;
}
thread::sleep(std::time::Duration::from_secs(1));
}
// Initial sync
{
let state = sync_state.lock().unwrap();
let config = sync_config.lock().unwrap();
if config.auto_sync_enabled {
if let Some(frame) = &state.latest {
log::info!("Auto-sync: Performing initial full sync.");
if system::trigger_sync(frame, &config).is_ok() {
log::info!("Auto-sync: Initial sync successful.");
} else {
log::error!("Auto-sync: Initial sync failed.");
}
}
}
}
thread::sleep(std::time::Duration::from_secs(10));
// Main auto-sync loop
loop {
{
let state = sync_state.lock().unwrap();
let config = sync_config.lock().unwrap();
if config.auto_sync_enabled && state.latest.is_some() {
let delta = state.get_ewma_clock_delta();
let frame = state.latest.as_ref().unwrap();
if delta.abs() > 40 {
log::info!("Auto-sync: Delta > 40ms ({}ms), performing full sync.", delta);
if system::trigger_sync(frame, &config).is_ok() {
log::info!("Auto-sync: Full sync successful.");
} else {
log::error!("Auto-sync: Full sync failed.");
}
} else if delta.abs() >= 1 {
// nudge_clock takes microseconds. A positive delta means clock is
// ahead, so we need a negative nudge.
let nudge_us = -delta * 1000;
log::info!("Auto-sync: Delta is {}ms, nudging clock by {}us.", delta, nudge_us);
if system::nudge_clock(nudge_us).is_ok() {
log::info!("Auto-sync: Clock nudge successful.");
} else {
log::error!("Auto-sync: Clock nudge failed.");
}
}
}
} // locks released here
thread::sleep(std::time::Duration::from_secs(10));
}
});
}
// 7⃣ Set up a LocalSet for the API server and main loop
let local = LocalSet::new();
local
.run_until(async move {
// 7⃣ Spawn the API server thread
// 8⃣ Spawn the API server task.
// This server provides the JSON API and serves the static web UI files
// from the `static/` directory. It runs in both TUI and daemon modes,
// but is primarily for the web UI used in daemon mode.
{
let api_state = ltc_state.clone();
let config_clone = config.clone();
let log_buffer_clone = log_buffer.clone();
task::spawn_local(async move {
if let Err(e) = start_api_server(api_state, config_clone).await {
eprintln!("API server error: {}", e);
if let Err(e) =
start_api_server(api_state, config_clone, log_buffer_clone).await
{
log::error!("API server error: {}", e);
}
});
}
// 8⃣ Keep main thread alive
// 9⃣ Main logic loop: process frames from serial and update state
let loop_state = ltc_state.clone();
let loop_config = config.clone();
let logic_task = task::spawn_blocking(move || {
for frame in rx {
let mut state = loop_state.lock().unwrap();
let config = loop_config.lock().unwrap();
// Only calculate delta for LOCK frames
if frame.status == "LOCK" {
let target_time = system::calculate_target_time(&frame, &config);
let arrival_time_local: chrono::DateTime<chrono::Local> =
frame.timestamp.with_timezone(&chrono::Local);
let delta = arrival_time_local.signed_duration_since(target_time);
state.record_and_update_ewma_clock_delta(delta.num_milliseconds());
}
state.update(frame);
}
});
// 1⃣0⃣ Keep main thread alive
if args.command.is_some() {
// In daemon mode, wait forever.
// In daemon mode, wait forever. The logic_task runs in the background.
std::future::pending::<()>().await;
} else {
// In TUI mode, block on the channel.
println!("📡 Main thread entering loop...");
let _ = task::spawn_blocking(move || {
for _frame in rx {
// no-op
}
})
.await;
// In TUI mode, block until the logic_task finishes (e.g. serial port disconnects)
// This keeps the TUI running.
log::info!("📡 Main thread entering loop...");
let _ = logic_task.await;
}
})
.await;
@ -172,18 +344,35 @@ mod tests {
use std::fs;
use std::path::Path;
/// RAII guard to ensure config file is cleaned up after test.
struct ConfigGuard;
/// RAII guard to manage config file during tests.
/// It saves the original content of `config.yml` if it exists,
/// and restores it when the guard goes out of scope.
/// If the file didn't exist, it's removed.
struct ConfigGuard {
original_content: Option<String>,
}
impl ConfigGuard {
fn new() -> Self {
Self {
original_content: fs::read_to_string("config.yml").ok(),
}
}
}
impl Drop for ConfigGuard {
fn drop(&mut self) {
let _ = fs::remove_file("config.yml");
if let Some(content) = &self.original_content {
fs::write("config.yml", content).expect("Failed to restore config.yml");
} else {
let _ = fs::remove_file("config.yml");
}
}
}
#[test]
fn test_ensure_config() {
let _guard = ConfigGuard; // Cleanup when _guard goes out of scope.
let _guard = ConfigGuard::new(); // Cleanup when _guard goes out of scope.
// --- Test 1: File creation ---
// Pre-condition: config.yml does not exist.

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();
@ -60,11 +60,12 @@ mod tests {
use super::*;
use std::sync::mpsc;
use crate::sync_logic::LtcState;
use num_rational::Ratio;
use regex::Regex;
fn get_ltc_regex() -> Regex {
Regex::new(
r"\[(LOCK|FREE)\]\s+(\d{2}):(\d{2}):(\d{2})[:;](\d{2})\s+\|\s+([\d.]+)fps",
r"\[(LOCK|FREE)\]\s+(\d{2}):(\d{2}):(\d{2})([:;])(\d{2})\s+\|\s+([\d.]+)fps",
).unwrap()
}
@ -119,7 +120,7 @@ mod tests {
assert_eq!(st.free_count, 1);
let received_frame = rx.try_recv().unwrap();
assert_eq!(received_frame.status, "FREE");
assert_eq!(received_frame.frame_rate, 29.97);
assert_eq!(received_frame.frame_rate, Ratio::new(30000, 1001));
}
#[test]

View file

@ -1,8 +1,22 @@
use crate::config::Config;
use chrono::{DateTime, Local, Timelike, Utc};
use num_rational::Ratio;
use regex::Captures;
use std::collections::VecDeque;
const EWMA_ALPHA: f64 = 0.1;
fn get_frame_rate_ratio(rate_str: &str) -> Option<Ratio<i64>> {
match rate_str {
"23.98" => Some(Ratio::new(24000, 1001)),
"24.00" => Some(Ratio::new(24, 1)),
"25.00" => Some(Ratio::new(25, 1)),
"29.97" => Some(Ratio::new(30000, 1001)),
"30.00" => Some(Ratio::new(30, 1)),
_ => None,
}
}
#[derive(Clone, Debug)]
pub struct LtcFrame {
pub status: String,
@ -10,7 +24,8 @@ pub struct LtcFrame {
pub minutes: u32,
pub seconds: u32,
pub frames: u32,
pub frame_rate: f64,
pub is_drop_frame: bool,
pub frame_rate: Ratio<i64>,
pub timestamp: DateTime<Utc>, // arrival stamp
}
@ -21,8 +36,9 @@ impl LtcFrame {
hours: caps[2].parse().ok()?,
minutes: caps[3].parse().ok()?,
seconds: caps[4].parse().ok()?,
frames: caps[5].parse().ok()?,
frame_rate: caps[6].parse().ok()?,
is_drop_frame: &caps[5] == ";",
frames: caps[6].parse().ok()?,
frame_rate: get_frame_rate_ratio(&caps[7])?,
timestamp,
})
}
@ -42,8 +58,8 @@ pub struct LtcState {
pub free_count: u32,
/// Stores the last up-to-20 raw offset measurements in ms.
pub offset_history: VecDeque<i64>,
/// Stores the last up-to-20 timecode Δ measurements in ms.
pub clock_delta_history: VecDeque<i64>,
/// EWMA of clock delta.
pub ewma_clock_delta: Option<f64>,
pub last_match_status: String,
pub last_match_check: i64,
}
@ -55,7 +71,7 @@ impl LtcState {
lock_count: 0,
free_count: 0,
offset_history: VecDeque::with_capacity(20),
clock_delta_history: VecDeque::with_capacity(20),
ewma_clock_delta: None,
last_match_status: "UNKNOWN".into(),
last_match_check: 0,
}
@ -69,12 +85,14 @@ impl LtcState {
self.offset_history.push_back(offset_ms);
}
/// Record one timecode Δ in ms.
pub fn record_clock_delta(&mut self, delta_ms: i64) {
if self.clock_delta_history.len() == 20 {
self.clock_delta_history.pop_front();
/// Update EWMA of clock delta.
pub fn record_and_update_ewma_clock_delta(&mut self, delta_ms: i64) {
let new_delta = delta_ms as f64;
if let Some(current_ewma) = self.ewma_clock_delta {
self.ewma_clock_delta = Some(EWMA_ALPHA * new_delta + (1.0 - EWMA_ALPHA) * current_ewma);
} else {
self.ewma_clock_delta = Some(new_delta);
}
self.clock_delta_history.push_back(delta_ms);
}
/// Clear all stored jitter measurements.
@ -82,11 +100,6 @@ impl LtcState {
self.offset_history.clear();
}
/// Clear all stored timecode Δ measurements.
pub fn clear_clock_deltas(&mut self) {
self.clock_delta_history.clear();
}
/// Update LOCK/FREE counts and timecode-match status every 5 s.
pub fn update(&mut self, frame: LtcFrame) {
match frame.status.as_str() {
@ -108,7 +121,7 @@ impl LtcState {
"FREE" => {
self.free_count += 1;
self.clear_offsets();
self.clear_clock_deltas();
self.ewma_clock_delta = None;
self.last_match_status = "UNKNOWN".into();
}
_ => {}
@ -130,30 +143,17 @@ impl LtcState {
/// Convert average jitter into frames (rounded).
pub fn average_frames(&self) -> i64 {
if let Some(frame) = &self.latest {
let ms_per_frame = 1000.0 / frame.frame_rate;
(self.average_jitter() as f64 / ms_per_frame).round() as i64
let jitter_ms_ratio = Ratio::new(self.average_jitter(), 1);
let frames_ratio = jitter_ms_ratio * frame.frame_rate / Ratio::new(1000, 1);
frames_ratio.round().to_integer()
} else {
0
}
}
/// Median timecode Δ over stored history, in ms.
pub fn average_clock_delta(&self) -> i64 {
if self.clock_delta_history.is_empty() {
return 0;
}
let mut sorted_deltas: Vec<i64> = self.clock_delta_history.iter().cloned().collect();
sorted_deltas.sort_unstable();
let mid = sorted_deltas.len() / 2;
if sorted_deltas.len() % 2 == 0 {
// Even number of elements, average the two middle ones
(sorted_deltas[mid - 1] + sorted_deltas[mid]) / 2
} else {
// Odd number of elements, return the middle one
sorted_deltas[mid]
}
/// Get EWMA of clock delta, in ms.
pub fn get_ewma_clock_delta(&self) -> i64 {
self.ewma_clock_delta.map_or(0, |v| v.round() as i64)
}
/// Percentage of samples seen in LOCK state versus total.
@ -207,7 +207,8 @@ mod tests {
minutes: m,
seconds: s,
frames: 0,
frame_rate: 25.0,
is_drop_frame: false,
frame_rate: Ratio::new(25, 1),
timestamp: Utc::now(),
}
}
@ -326,35 +327,28 @@ mod tests {
}
#[test]
fn test_average_clock_delta_is_median() {
fn test_ewma_clock_delta() {
let mut state = LtcState::new();
assert_eq!(state.get_ewma_clock_delta(), 0);
// Establish a stable set of values
for _ in 0..19 {
state.record_clock_delta(2);
}
state.record_clock_delta(100); // Add an outlier
// First value initializes the EWMA
state.record_and_update_ewma_clock_delta(100);
assert_eq!(state.get_ewma_clock_delta(), 100);
// With 19 `2`s and one `100`, the median should still be `2`.
// The simple average would be (19*2 + 100) / 20 = 138 / 20 = 6.
assert_eq!(
state.average_clock_delta(),
2,
"Median should ignore the outlier"
);
// Second value moves it
state.record_and_update_ewma_clock_delta(200);
// 0.1 * 200 + 0.9 * 100 = 20 + 90 = 110
assert_eq!(state.get_ewma_clock_delta(), 110);
// Test with an even number of elements
state.clear_clock_deltas();
state.record_clock_delta(1);
state.record_clock_delta(2);
state.record_clock_delta(3);
state.record_clock_delta(100);
// sorted: [1, 2, 3, 100]. mid two are 2, 3. average is (2+3)/2 = 2.
assert_eq!(
state.average_clock_delta(),
2,
"Median of even numbers should be correct"
);
// Third value
state.record_and_update_ewma_clock_delta(100);
// 0.1 * 100 + 0.9 * 110 = 10 + 99 = 109
assert_eq!(state.get_ewma_clock_delta(), 109);
// Reset on FREE frame
state.update(get_test_frame("FREE", 0, 0, 0));
assert_eq!(state.get_ewma_clock_delta(), 0);
assert!(state.ewma_clock_delta.is_none());
}
#[test]
@ -369,8 +363,12 @@ mod tests {
assert_eq!(get_sync_status(-9, &config), "CLOCK BEHIND");
assert_eq!(get_sync_status(-100, &config), "CLOCK BEHIND");
// Test TIMETURNING status
config.timeturner_offset = TimeturnerOffset { hours: 1, minutes: 0, seconds: 0, frames: 0 };
// Test auto-sync status
config.auto_sync_enabled = true;
assert_eq!(get_sync_status(0, &config), "IN SYNC");
// Test TIMETURNING status takes precedence
config.timeturner_offset = TimeturnerOffset { hours: 1, minutes: 0, seconds: 0, frames: 0, milliseconds: 0 };
assert_eq!(get_sync_status(0, &config), "TIMETURNING");
assert_eq!(get_sync_status(100, &config), "TIMETURNING");
}

View file

@ -1,6 +1,7 @@
use crate::config::Config;
use crate::sync_logic::LtcFrame;
use chrono::{DateTime, Duration as ChronoDuration, Local, NaiveTime, TimeZone};
use chrono::{DateTime, Duration as ChronoDuration, Local, TimeZone};
use num_rational::Ratio;
use std::process::Command;
/// Check if Chrony is active
@ -39,11 +40,24 @@ pub fn ntp_service_toggle(start: bool) {
pub fn calculate_target_time(frame: &LtcFrame, config: &Config) -> DateTime<Local> {
let today_local = Local::now().date_naive();
let ms = ((frame.frames as f64 / frame.frame_rate) * 1000.0).round() as u32;
let timecode = NaiveTime::from_hms_milli_opt(frame.hours, frame.minutes, frame.seconds, ms)
.expect("Invalid LTC timecode");
let naive_dt = today_local.and_time(timecode);
// Total seconds from timecode components
let timecode_secs =
frame.hours as i64 * 3600 + frame.minutes as i64 * 60 + frame.seconds as i64;
// Timecode is always treated as wall-clock time. NDF scaling is not applied
// as the LTC source appears to be pre-compensated.
let total_duration_secs =
Ratio::new(timecode_secs, 1) + Ratio::new(frame.frames as i64, 1) / frame.frame_rate;
// Convert to milliseconds
let total_ms = (total_duration_secs * Ratio::new(1000, 1))
.round()
.to_integer();
let naive_midnight = today_local.and_hms_opt(0, 0, 0).unwrap();
let naive_dt = naive_midnight + ChronoDuration::milliseconds(total_ms);
let mut dt_local = Local
.from_local_datetime(&naive_dt)
.single()
@ -56,8 +70,9 @@ pub fn calculate_target_time(frame: &LtcFrame, config: &Config) -> DateTime<Loca
+ ChronoDuration::minutes(offset.minutes)
+ ChronoDuration::seconds(offset.seconds);
// Frame offset needs to be converted to milliseconds
let frame_offset_ms = (offset.frames as f64 / frame.frame_rate * 1000.0).round() as i64;
dt_local + ChronoDuration::milliseconds(frame_offset_ms)
let frame_offset_ms_ratio = Ratio::new(offset.frames * 1000, 1) / frame.frame_rate;
let frame_offset_ms = frame_offset_ms_ratio.round().to_integer();
dt_local + ChronoDuration::milliseconds(frame_offset_ms + offset.milliseconds)
}
pub fn trigger_sync(frame: &LtcFrame, config: &Config) -> Result<String, ()> {
@ -104,11 +119,67 @@ pub fn trigger_sync(frame: &LtcFrame, config: &Config) -> Result<String, ()> {
}
}
pub fn nudge_clock(microseconds: i64) -> Result<(), ()> {
#[cfg(target_os = "linux")]
{
let success = Command::new("sudo")
.arg("adjtimex")
.arg("--singleshot")
.arg(microseconds.to_string())
.status()
.map(|s| s.success())
.unwrap_or(false);
if success {
log::info!("Nudged clock by {} us", microseconds);
Ok(())
} else {
log::error!("Failed to nudge clock with adjtimex");
Err(())
}
}
#[cfg(not(target_os = "linux"))]
{
let _ = microseconds;
log::warn!("Clock nudging is only supported on Linux.");
Err(())
}
}
pub fn set_date(date: &str) -> Result<(), ()> {
#[cfg(target_os = "linux")]
{
let datetime_str = format!("{} 10:00:00", date);
let success = Command::new("sudo")
.arg("date")
.arg("--set")
.arg(&datetime_str)
.status()
.map(|s| s.success())
.unwrap_or(false);
if success {
log::info!("Set system date and time to {}", datetime_str);
Ok(())
} else {
log::error!("Failed to set system date and time");
Err(())
}
}
#[cfg(not(target_os = "linux"))]
{
let _ = date;
log::warn!("Date setting is only supported on Linux.");
Err(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::TimeturnerOffset;
use chrono::{Timelike, Utc};
use num_rational::Ratio;
// Helper to create a test frame
fn get_test_frame(h: u32, m: u32, s: u32, f: u32) -> LtcFrame {
@ -118,7 +189,8 @@ mod tests {
minutes: m,
seconds: s,
frames: f,
frame_rate: 25.0,
is_drop_frame: false,
frame_rate: Ratio::new(25, 1),
timestamp: Utc::now(),
}
}
@ -150,6 +222,7 @@ mod tests {
minutes: 5,
seconds: 10,
frames: 12, // 12 frames at 25fps is 480ms
milliseconds: 20,
};
let target_time = calculate_target_time(&frame, &config);
@ -157,8 +230,8 @@ mod tests {
assert_eq!(target_time.hour(), 11);
assert_eq!(target_time.minute(), 25);
assert_eq!(target_time.second(), 40);
// 480ms
assert_eq!(target_time.nanosecond(), 480_000_000);
// 480ms + 20ms = 500ms
assert_eq!(target_time.nanosecond(), 500_000_000);
}
#[test]
@ -170,13 +243,20 @@ mod tests {
minutes: -5,
seconds: -10,
frames: -12, // -480ms
milliseconds: -80,
};
let target_time = calculate_target_time(&frame, &config);
assert_eq!(target_time.hour(), 9);
assert_eq!(target_time.minute(), 15);
assert_eq!(target_time.second(), 20);
assert_eq!(target_time.nanosecond(), 0);
assert_eq!(target_time.second(), 19);
assert_eq!(target_time.nanosecond(), 920_000_000);
}
#[test]
fn test_nudge_clock_on_non_linux() {
#[cfg(not(target_os = "linux"))]
assert!(nudge_clock(1000).is_err());
}
}

View file

@ -9,7 +9,6 @@ use std::collections::VecDeque;
use chrono::{
DateTime, Local, Timelike, Utc,
NaiveTime, TimeZone, Duration as ChronoDuration,
};
use crossterm::{
cursor::{Hide, MoveTo, Show},
@ -20,9 +19,11 @@ use crossterm::{
};
use crate::config::Config;
use get_if_addrs::get_if_addrs;
use crate::sync_logic::{get_jitter_status, get_sync_status, LtcState};
use crate::system;
use get_if_addrs::get_if_addrs;
use num_rational::Ratio;
use num_traits::ToPrimitive;
pub fn start_ui(
@ -35,7 +36,6 @@ pub fn start_ui(
terminal::enable_raw_mode().unwrap();
let mut logs: VecDeque<String> = VecDeque::with_capacity(10);
let mut out_of_sync_since: Option<Instant> = None;
let mut last_delta_update = Instant::now() - Duration::from_secs(1);
let mut cached_delta_ms: i64 = 0;
let mut cached_delta_frames: i64 = 0;
@ -54,7 +54,7 @@ pub fn start_ui(
.map(|ifa| ifa.ip().to_string())
.collect();
// 3⃣ jitter + Δ
// 3⃣ jitter
{
let mut st = state.lock().unwrap();
if let Some(frame) = st.latest.clone() {
@ -64,33 +64,6 @@ pub fn start_ui(
let raw = (now_utc - frame.timestamp).num_milliseconds();
let measured = raw - hw_offset_ms;
st.record_offset(measured);
// Δ = system clock - LTC timecode (use LOCAL time, with offset)
let today_local = Local::now().date_naive();
let ms = ((frame.frames as f64 / frame.frame_rate) * 1000.0).round() as u32;
let tc_naive = NaiveTime::from_hms_milli_opt(
frame.hours, frame.minutes, frame.seconds, ms,
).expect("Invalid LTC timecode");
let naive_dt_local = today_local.and_time(tc_naive);
let mut dt_local = Local
.from_local_datetime(&naive_dt_local)
.single()
.expect("Invalid local time");
// Apply timeturner offset before calculating delta
let offset = &cfg.timeturner_offset;
dt_local = dt_local
+ ChronoDuration::hours(offset.hours)
+ ChronoDuration::minutes(offset.minutes)
+ ChronoDuration::seconds(offset.seconds);
let frame_offset_ms = (offset.frames as f64 / frame.frame_rate * 1000.0).round() as i64;
dt_local = dt_local + ChronoDuration::milliseconds(frame_offset_ms);
let delta_ms = (Local::now() - dt_local).num_milliseconds();
st.record_clock_delta(delta_ms);
} else {
st.clear_offsets();
st.clear_clock_deltas();
}
}
}
@ -103,7 +76,7 @@ pub fn start_ui(
st.average_frames(),
st.timecode_match().to_string(),
st.lock_ratio(),
st.average_clock_delta(),
st.get_ewma_clock_delta(),
)
};
@ -111,8 +84,9 @@ pub fn start_ui(
if last_delta_update.elapsed() >= Duration::from_secs(1) {
cached_delta_ms = avg_delta;
if let Some(frame) = &state.lock().unwrap().latest {
let frame_ms = 1000.0 / frame.frame_rate;
cached_delta_frames = ((avg_delta as f64 / frame_ms).round()) as i64;
let delta_ms_ratio = Ratio::new(avg_delta, 1);
let frames_ratio = delta_ms_ratio * frame.frame_rate / Ratio::new(1000, 1);
cached_delta_frames = frames_ratio.round().to_integer();
} else {
cached_delta_frames = 0;
}
@ -122,28 +96,7 @@ pub fn start_ui(
// 6⃣ sync status wording
let sync_status = get_sync_status(cached_delta_ms, &cfg);
// 7⃣ autosync (same as manual but delayed)
if sync_status != "IN SYNC" && sync_status != "TIMETURNING" {
if let Some(start) = out_of_sync_since {
if start.elapsed() >= Duration::from_secs(5) {
if let Some(frame) = &state.lock().unwrap().latest {
let entry = match system::trigger_sync(frame, &cfg) {
Ok(ts) => format!("🔄 Autosynced to LTC: {}", ts),
Err(_) => "❌ Autosync failed".into(),
};
if logs.len() == 10 { logs.pop_front(); }
logs.push_back(entry);
}
out_of_sync_since = None;
}
} else {
out_of_sync_since = Some(Instant::now());
}
} else {
out_of_sync_since = None;
}
// 8⃣ header & LTC metrics display
// 7⃣ header & LTC metrics display
{
let st = state.lock().unwrap();
let opt = st.latest.as_ref();
@ -154,7 +107,7 @@ pub fn start_ui(
None => "LTC Timecode : …".to_string(),
};
let fr_str = match opt {
Some(f) => format!("Frame Rate : {:.2}fps", f.frame_rate),
Some(f) => format!("Frame Rate : {:.2}fps", f.frame_rate.to_f64().unwrap_or(0.0)),
None => "Frame Rate : …".to_string(),
};
@ -279,10 +232,3 @@ pub fn start_ui(
}
}
#[cfg(test)]
mod tests {
#[allow(unused_imports)]
use super::*;
#[allow(unused_imports)]
use crate::config::TimeturnerOffset;
}

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

@ -3,67 +3,139 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NTP TimeTurner</title>
<title>Fetch | Hachi</title>
<link rel="stylesheet" href="style.css">
<link rel="icon" href="favicon.ico" type="image/x-icon">
</head>
<body>
<div class="container">
<h1>NTP TimeTurner</h1>
<img src="assets/header.png" alt="NTP Timeturner" class="header-logo">
<!-- Mock Data Controls (hidden by default) -->
<div id="mock-controls" class="card full-width" style="display: none;">
<h2>Mock Data Controls</h2>
<div class="control-group">
<label for="mock-data-selector">Select Mock Data Scenario:</label>
<select id="mock-data-selector"></select>
</div>
</div>
<div class="grid">
<!-- LTC Status -->
<div class="card">
<h2>LTC Status</h2>
<p id="ltc-status">--</p>
<h2>LTC Input</h2>
<p id="ltc-timecode">--:--:--:--</p>
<p id="frame-rate">-- fps</p>
<p>Lock Ratio: <span id="lock-ratio">--</span>%</p>
<div class="icon-group">
<span id="ltc-status"></span>
<span id="frame-rate"></span>
<span id="lock-ratio"></span>
</div>
</div>
<!-- System Clock & Sync -->
<div class="card">
<h2>System Clock</h2>
<h2>NTP Clock</h2>
<p id="system-clock">--:--:--.---</p>
<p>NTP Service: <span id="ntp-active">--</span></p>
<p>Sync Status: <span id="sync-status">--</span></p>
</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="control-group">
<label for="hw-offset">Hardware Offset (ms):</label>
<input type="number" id="hw-offset" name="hw-offset">
<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="control-group">
<label>Timeturner Offset:</label>
<input type="number" id="offset-h" placeholder="H">
<input type="number" id="offset-m" placeholder="M">
<input type="number" id="offset-s" placeholder="S">
<input type="number" id="offset-f" placeholder="F">
</div>
<div class="control-group">
<button id="save-config">Save Config</button>
<button id="manual-sync">Manual Sync</button>
<span id="sync-message"></span>
</div>
</div>
</div>
<div class="collapsible-content" id="controls-content">
<div class="control-group" style="display: none;">
<label for="hw-offset">Hardware Offset (ms):</label>
<input type="number" id="hw-offset" name="hw-offset">
</div>
<div class="control-group" style="display: none;">
<input type="checkbox" id="auto-sync-enabled" name="auto-sync-enabled" style="vertical-align: middle;">
<label for="auto-sync-enabled" style="vertical-align: middle;">Enable Auto Sync</label>
</div>
<div class="control-group">
<label>Timeturner Offset</label>
<div class="offset-controls-container">
<div class="offset-control">
<input type="number" id="offset-h" min="-99" max="99">
<label for="offset-h">hr</label>
</div>
<div class="offset-control">
<input type="number" id="offset-m" min="-99" max="99">
<label for="offset-m">min</label>
</div>
<div class="offset-control">
<input type="number" id="offset-s" min="-99" max="99">
<label for="offset-s">sec</label>
</div>
<div class="offset-control">
<input type="number" id="offset-f" min="-99" max="99">
<label for="offset-f">fr</label>
</div>
<div class="offset-control">
<input type="number" id="offset-ms">
<label for="offset-ms">ms</label>
</div>
</div>
</div>
<div class="control-group">
<button id="save-config">Save Timeturner Config</button>
<button id="manual-sync">Send Manual Sync</button>
<span id="sync-message"></span>
</div>
<div class="control-group" style="display: none;">
<label>Nudge Clock (ms):</label>
<button id="nudge-down">-</button>
<input type="number" id="nudge-value" style="width: 60px;">
<button id="nudge-up">+</button>
<span id="nudge-message"></span>
</div>
<div class="control-group">
<label for="date-input">Set System Date:</label>
<input type="text" id="date-input" placeholder="YYYY-MM-DD" pattern="[0-9]{4}-[0-9]{2}-[0-9]{2}">
<button id="set-date">Set Date</button>
<span id="date-message"></span>
</div>
</div>
</div>
<!-- Logs -->
<div class="card full-width collapsible-card">
<div class="toggle-header" id="logs-toggle">
<img src="assets/timeturner_logs.png" class="toggle-icon" alt="Logs Icon">
<h2>Logs</h2>
</div>
<div class="collapsible-content" id="logs-content">
<pre id="logs" class="log-box"></pre>
</div>
</div>
</div>
<footer>
<p>
Built by Chris Frankland-Wright and Chaos Rogers | Have Blue Broadcast Media |
<a href="https://github.com/cjfranko/NTP-Timeturner" target="_blank" rel="noopener noreferrer">https://github.com/cjfranko/NTP-Timeturner</a>
</p>
</footer>
</div>
<script src="icon-map.js"></script>
<script src="mock-data.js"></script>
<script src="script.js"></script>
</body>
</html>

141
static/index_dev.html Normal file
View file

@ -0,0 +1,141 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NTP TimeTurner</title>
<link rel="stylesheet" href="style.css">
<link rel="icon" href="favicon.ico" type="image/x-icon">
</head>
<body>
<div class="container">
<img src="assets/header.png" alt="NTP Timeturner" class="header-logo">
<!-- Mock Data Controls (hidden by default) -->
<div id="mock-controls" class="card full-width" style="display: none;">
<h2>Mock Data Controls</h2>
<div class="control-group">
<label for="mock-data-selector">Select Mock Data Scenario:</label>
<select id="mock-data-selector"></select>
</div>
</div>
<div class="grid">
<!-- LTC Status -->
<div class="card">
<h2>LTC Input</h2>
<p id="ltc-timecode">--:--:--:--</p>
<div class="icon-group">
<span id="ltc-status"></span>
<span id="frame-rate"></span>
<span id="lock-ratio"></span>
</div>
</div>
<!-- System Clock & Sync -->
<div class="card">
<h2>NTP Clock</h2>
<p id="system-clock">--:--:--.---</p>
<p class="system-date-display"><span id="system-date">---- -- --</span></p>
<div class="icon-group">
<span id="ntp-active"></span>
<span id="sync-status"></span>
<span id="jitter-status"></span>
<span id="delta-status"></span>
</div>
<p id="delta-text">Δ -- ms (-- frames)</p>
</div>
<!-- Network Interfaces -->
<div class="card">
<div class="card-header">
<img src="assets/timeturner_network.png" class="header-icon" alt="Network Icon">
<h2>Network</h2>
</div>
<p id="interfaces">--</p>
</div>
<!-- Controls -->
<div class="card full-width collapsible-card">
<div class="toggle-header" id="controls-toggle">
<img src="assets/timeturner_controls.png" class="toggle-icon" alt="Controls Icon">
<h2>Controls</h2>
</div>
<div class="collapsible-content" id="controls-content">
<div class="control-group">
<label for="hw-offset">Hardware Offset (ms):</label>
<input type="number" id="hw-offset" name="hw-offset">
</div>
<div class="control-group">
<input type="checkbox" id="auto-sync-enabled" name="auto-sync-enabled" style="vertical-align: middle;">
<label for="auto-sync-enabled" style="vertical-align: middle;">Enable Auto Sync</label>
</div>
<div class="control-group">
<label>Timeturner Offset</label>
<div style="display: flex; flex-wrap: wrap; gap: 1rem; align-items: flex-start;">
<div style="display: flex; flex-direction: column;">
<label for="offset-h">Hours</label>
<input type="number" id="offset-h" style="width: 60px;">
</div>
<div style="display: flex; flex-direction: column;">
<label for="offset-m">Minutes</label>
<input type="number" id="offset-m" style="width: 60px;">
</div>
<div style="display: flex; flex-direction: column;">
<label for="offset-s">Seconds</label>
<input type="number" id="offset-s" style="width: 60px;">
</div>
<div style="display: flex; flex-direction: column;">
<label for="offset-f">Frames</label>
<input type="number" id="offset-f" style="width: 60px;">
</div>
<div style="display: flex; flex-direction: column;">
<label for="offset-ms">Milliseconds</label>
<input type="number" id="offset-ms" style="width: 60px;">
</div>
</div>
</div>
<div class="control-group">
<button id="save-config">Save Config</button>
<button id="manual-sync">Manual Sync</button>
<span id="sync-message"></span>
</div>
<div class="control-group">
<label>Nudge Clock (ms):</label>
<button id="nudge-down">-</button>
<input type="number" id="nudge-value" style="width: 60px;">
<button id="nudge-up">+</button>
<span id="nudge-message"></span>
</div>
<div class="control-group">
<label for="date-input">Set System Date:</label>
<input type="text" id="date-input" placeholder="YYYY-MM-DD" pattern="[0-9]{4}-[0-9]{2}-[0-9]{2}">
<button id="set-date">Set Date</button>
<span id="date-message"></span>
</div>
</div>
</div>
<!-- Logs -->
<div class="card full-width collapsible-card">
<div class="toggle-header" id="logs-toggle">
<img src="assets/timeturner_logs.png" class="toggle-icon" alt="Logs Icon">
<h2>Logs</h2>
</div>
<div class="collapsible-content" id="logs-content">
<pre id="logs" class="log-box"></pre>
</div>
</div>
</div>
<footer>
<p>
Built by Chris Frankland-Wright and John Rogers | Have Blue Broadcast Media |
<a href="https://github.com/cjfranko/NTP-Timeturner" target="_blank" rel="noopener noreferrer">https://github.com/cjfranko/NTP-Timeturner</a>
</p>
</footer>
</div>
<script src="icon-map.js"></script>
<script src="mock-data.js"></script>
<script src="script.js"></script>
</body>
</html>

168
static/mock-data.js Normal file
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,83 +1,263 @@
document.addEventListener('DOMContentLoaded', () => {
document.addEventListener('DOMContentLoaded', () => {
// --- Mock Data Configuration ---
// Set to true to use mock data, false for live API.
const useMockData = false;
let currentMockSetKey = 'allGood'; // Default mock data set
let lastApiData = null;
let lastApiFetchTime = null;
const statusElements = {
ltcStatus: document.getElementById('ltc-status'),
ltcTimecode: document.getElementById('ltc-timecode'),
frameRate: document.getElementById('frame-rate'),
lockRatio: document.getElementById('lock-ratio'),
systemClock: document.getElementById('system-clock'),
systemDate: document.getElementById('system-date'),
ntpActive: document.getElementById('ntp-active'),
syncStatus: document.getElementById('sync-status'),
deltaMs: document.getElementById('delta-ms'),
deltaFrames: document.getElementById('delta-frames'),
deltaStatus: document.getElementById('delta-status'),
jitterStatus: document.getElementById('jitter-status'),
deltaText: document.getElementById('delta-text'),
interfaces: document.getElementById('interfaces'),
logs: document.getElementById('logs'),
};
const hwOffsetInput = document.getElementById('hw-offset');
const autoSyncCheckbox = document.getElementById('auto-sync-enabled');
const offsetInputs = {
h: document.getElementById('offset-h'),
m: document.getElementById('offset-m'),
s: document.getElementById('offset-s'),
f: document.getElementById('offset-f'),
ms: document.getElementById('offset-ms'),
};
const saveConfigButton = document.getElementById('save-config');
const manualSyncButton = document.getElementById('manual-sync');
const syncMessage = document.getElementById('sync-message');
function updateStatus(data) {
statusElements.ltcStatus.textContent = data.ltc_status;
statusElements.ltcTimecode.textContent = data.ltc_timecode;
statusElements.frameRate.textContent = data.frame_rate;
statusElements.lockRatio.textContent = data.lock_ratio.toFixed(2);
statusElements.systemClock.textContent = data.system_clock;
const nudgeDownButton = document.getElementById('nudge-down');
const nudgeUpButton = document.getElementById('nudge-up');
const nudgeValueInput = document.getElementById('nudge-value');
const nudgeMessage = document.getElementById('nudge-message');
statusElements.ntpActive.textContent = data.ntp_active ? 'Active' : 'Inactive';
statusElements.ntpActive.className = data.ntp_active ? 'active' : 'inactive';
const dateInput = document.getElementById('date-input');
const setDateButton = document.getElementById('set-date');
const dateMessage = document.getElementById('date-message');
statusElements.syncStatus.textContent = data.sync_status;
statusElements.syncStatus.className = data.sync_status.replace(/\s+/g, '-').toLowerCase();
// --- Collapsible Sections ---
const controlsToggle = document.getElementById('controls-toggle');
const controlsContent = document.getElementById('controls-content');
const logsToggle = document.getElementById('logs-toggle');
const logsContent = document.getElementById('logs-content');
statusElements.deltaMs.textContent = data.timecode_delta_ms;
statusElements.deltaFrames.textContent = data.timecode_delta_frames;
// --- Mock Controls Setup ---
const mockControls = document.getElementById('mock-controls');
const mockDataSelector = document.getElementById('mock-data-selector');
statusElements.jitterStatus.textContent = data.jitter_status;
statusElements.jitterStatus.className = data.jitter_status.toLowerCase();
function setupMockControls() {
if (useMockData) {
mockControls.style.display = 'block';
statusElements.interfaces.innerHTML = '';
if (data.interfaces.length > 0) {
data.interfaces.forEach(ip => {
const li = document.createElement('li');
li.textContent = ip;
statusElements.interfaces.appendChild(li);
// Populate dropdown
Object.keys(mockApiDataSets).forEach(key => {
const option = document.createElement('option');
option.value = key;
option.textContent = key.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase());
mockDataSelector.appendChild(option);
});
mockDataSelector.value = currentMockSetKey;
// Handle selection change
mockDataSelector.addEventListener('change', (event) => {
currentMockSetKey = event.target.value;
// Re-fetch all data from the new mock set
fetchStatus();
fetchConfig();
fetchLogs();
});
}
}
function updateStatus(data) {
const ltcStatus = data.ltc_status || 'UNKNOWN';
const ltcIconInfo = iconMap.ltcStatus[ltcStatus] || iconMap.ltcStatus.default;
statusElements.ltcStatus.innerHTML = `<img src="${ltcIconInfo.src}" class="status-icon" alt="" title="${ltcIconInfo.tooltip}">`;
statusElements.ltcStatus.className = ltcStatus.toLowerCase();
statusElements.ltcTimecode.textContent = data.ltc_timecode;
const frameRate = data.frame_rate || 'unknown';
const frameRateIconInfo = iconMap.frameRate[frameRate] || iconMap.frameRate.default;
statusElements.frameRate.innerHTML = `<img src="${frameRateIconInfo.src}" class="status-icon" alt="" title="${frameRateIconInfo.tooltip}">`;
const lockRatio = data.lock_ratio;
let lockRatioCategory;
if (lockRatio === 100) {
lockRatioCategory = 'good';
} else if (lockRatio >= 90) {
lockRatioCategory = 'average';
} else {
const li = document.createElement('li');
li.textContent = 'No active interfaces found.';
statusElements.interfaces.appendChild(li);
lockRatioCategory = 'bad';
}
const lockRatioIconInfo = iconMap.lockRatio[lockRatioCategory];
statusElements.lockRatio.innerHTML = `<img src="${lockRatioIconInfo.src}" class="status-icon" alt="" title="${lockRatioIconInfo.tooltip}">`;
statusElements.systemClock.textContent = data.system_clock;
statusElements.systemDate.textContent = data.system_date;
// Autofill the date input, but don't overwrite user edits.
if (!lastApiData || dateInput.value === lastApiData.system_date) {
dateInput.value = data.system_date;
}
const ntpIconInfo = iconMap.ntpActive[!!data.ntp_active];
if (data.ntp_active) {
statusElements.ntpActive.innerHTML = `<img src="${ntpIconInfo.src}" class="status-icon" alt="" title="${ntpIconInfo.tooltip}">`;
statusElements.ntpActive.className = 'active';
} else {
statusElements.ntpActive.innerHTML = `<img src="${ntpIconInfo.src}" class="status-icon" alt="" title="${ntpIconInfo.tooltip}">`;
statusElements.ntpActive.className = 'inactive';
}
const syncStatus = data.sync_status || 'UNKNOWN';
const syncIconInfo = iconMap.syncStatus[syncStatus] || iconMap.syncStatus.default;
statusElements.syncStatus.innerHTML = `<img src="${syncIconInfo.src}" class="status-icon" alt="" title="${syncIconInfo.tooltip}">`;
statusElements.syncStatus.className = syncStatus.replace(/\s+/g, '-').toLowerCase();
// Delta Status
const deltaMs = data.timecode_delta_ms;
let deltaCategory;
if (deltaMs === 0) {
deltaCategory = 'good';
} else if (Math.abs(deltaMs) < 10) {
deltaCategory = 'average';
} else {
deltaCategory = 'bad';
}
const deltaIconInfo = iconMap.deltaStatus[deltaCategory];
statusElements.deltaStatus.innerHTML = `<img src="${deltaIconInfo.src}" class="status-icon" alt="" title="${deltaIconInfo.tooltip}">`;
const deltaTextValue = `${data.timecode_delta_ms} ms (${data.timecode_delta_frames} frames)`;
statusElements.deltaText.textContent = `Δ ${deltaTextValue}`;
const jitterStatus = data.jitter_status || 'UNKNOWN';
const jitterIconInfo = iconMap.jitterStatus[jitterStatus] || iconMap.jitterStatus.default;
statusElements.jitterStatus.innerHTML = `<img src="${jitterIconInfo.src}" class="status-icon" alt="" title="${jitterIconInfo.tooltip}">`;
statusElements.jitterStatus.className = jitterStatus.toLowerCase();
if (data.interfaces.length > 0) {
statusElements.interfaces.textContent = data.interfaces.join(' | ');
} else {
statusElements.interfaces.textContent = 'No active interfaces found.';
}
}
function animateClocks() {
if (!lastApiData || !lastApiFetchTime) return;
const elapsedMs = new Date() - lastApiFetchTime;
// Animate System Clock
if (lastApiData.system_clock && lastApiData.system_clock.includes(':')) {
const parts = lastApiData.system_clock.split(/[:.]/);
if (parts.length === 4) {
const baseDate = new Date();
baseDate.setHours(parseInt(parts[0], 10), parseInt(parts[1], 10), parseInt(parts[2], 10));
baseDate.setMilliseconds(parseInt(parts[3], 10));
const newDate = new Date(baseDate.getTime() + elapsedMs);
const h = String(newDate.getHours()).padStart(2, '0');
const m = String(newDate.getMinutes()).padStart(2, '0');
const s = String(newDate.getSeconds()).padStart(2, '0');
const ms = String(newDate.getMilliseconds()).padStart(3, '0');
statusElements.systemClock.textContent = `${h}:${m}:${s}.${ms}`;
}
}
// Animate LTC Timecode - only if status is LOCK
if (lastApiData.ltc_status === 'LOCK' && lastApiData.ltc_timecode && lastApiData.ltc_timecode.match(/[:;]/) && lastApiData.frame_rate) {
const separator = lastApiData.ltc_timecode.includes(';') ? ';' : ':';
const tcParts = lastApiData.ltc_timecode.split(/[:;]/);
const frameRate = parseFloat(lastApiData.frame_rate);
if (tcParts.length === 4 && !isNaN(frameRate) && frameRate > 0) {
let h = parseInt(tcParts[0], 10);
let m = parseInt(tcParts[1], 10);
let s = parseInt(tcParts[2], 10);
let f = parseInt(tcParts[3], 10);
const msPerFrame = 1000.0 / frameRate;
const elapsedFrames = Math.floor(elapsedMs / msPerFrame);
f += elapsedFrames;
const frameRateInt = Math.round(frameRate);
s += Math.floor(f / frameRateInt);
f %= frameRateInt;
m += Math.floor(s / 60);
s %= 60;
h += Math.floor(m / 60);
m %= 60;
h %= 24;
statusElements.ltcTimecode.textContent =
`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}${separator}${String(f).padStart(2, '0')}`;
}
}
}
async function fetchStatus() {
if (useMockData) {
const data = mockApiDataSets[currentMockSetKey].status;
updateStatus(data);
lastApiData = data;
lastApiFetchTime = new Date();
return;
}
try {
const response = await fetch('/api/status');
if (!response.ok) throw new Error('Failed to fetch status');
const data = await response.json();
updateStatus(data);
lastApiData = data;
lastApiFetchTime = new Date();
} catch (error) {
console.error('Error fetching status:', error);
lastApiData = null;
lastApiFetchTime = null;
}
}
async function fetchConfig() {
if (useMockData) {
const data = mockApiDataSets[currentMockSetKey].config;
hwOffsetInput.value = data.hardwareOffsetMs;
autoSyncCheckbox.checked = data.autoSyncEnabled;
offsetInputs.h.value = data.timeturnerOffset.hours;
offsetInputs.m.value = data.timeturnerOffset.minutes;
offsetInputs.s.value = data.timeturnerOffset.seconds;
offsetInputs.f.value = data.timeturnerOffset.frames;
offsetInputs.ms.value = data.timeturnerOffset.milliseconds || 0;
nudgeValueInput.value = data.defaultNudgeMs;
return;
}
try {
const response = await fetch('/api/config');
if (!response.ok) throw new Error('Failed to fetch config');
const data = await response.json();
hwOffsetInput.value = data.hardwareOffsetMs;
autoSyncCheckbox.checked = data.autoSyncEnabled;
offsetInputs.h.value = data.timeturnerOffset.hours;
offsetInputs.m.value = data.timeturnerOffset.minutes;
offsetInputs.s.value = data.timeturnerOffset.seconds;
offsetInputs.f.value = data.timeturnerOffset.frames;
offsetInputs.ms.value = data.timeturnerOffset.milliseconds || 0;
nudgeValueInput.value = data.defaultNudgeMs;
} catch (error) {
console.error('Error fetching config:', error);
}
@ -86,14 +266,25 @@ document.addEventListener('DOMContentLoaded', () => {
async function saveConfig() {
const config = {
hardwareOffsetMs: parseInt(hwOffsetInput.value, 10) || 0,
autoSyncEnabled: autoSyncCheckbox.checked,
defaultNudgeMs: parseInt(nudgeValueInput.value, 10) || 0,
timeturnerOffset: {
hours: parseInt(offsetInputs.h.value, 10) || 0,
hours: parseInt(offsetInputs.h.value, 10) || 0,
minutes: parseInt(offsetInputs.m.value, 10) || 0,
seconds: parseInt(offsetInputs.s.value, 10) || 0,
frames: parseInt(offsetInputs.f.value, 10) || 0,
frames: parseInt(offsetInputs.f.value, 10) || 0,
milliseconds: parseInt(offsetInputs.ms.value, 10) || 0,
}
};
if (useMockData) {
console.log('Mock save:', config);
alert('Configuration saved (mock).');
// We can also update the mock data in memory to see changes reflected
mockApiDataSets[currentMockSetKey].config = config;
return;
}
try {
const response = await fetch('/api/config', {
method: 'POST',
@ -108,8 +299,35 @@ document.addEventListener('DOMContentLoaded', () => {
}
}
async function fetchLogs() {
if (useMockData) {
// Use a copy to avoid mutating the original mock data array
const logs = mockApiDataSets[currentMockSetKey].logs.slice();
// Show latest 20 logs, with the newest at the top.
logs.reverse();
statusElements.logs.textContent = logs.slice(0, 20).join('\n');
return;
}
try {
const response = await fetch('/api/logs');
if (!response.ok) throw new Error('Failed to fetch logs');
const logs = await response.json();
// Show latest 20 logs, with the newest at the top.
logs.reverse();
statusElements.logs.textContent = logs.slice(0, 20).join('\n');
} catch (error) {
console.error('Error fetching logs:', error);
statusElements.logs.textContent = 'Error fetching logs.';
}
}
async function triggerManualSync() {
syncMessage.textContent = 'Issuing sync command...';
if (useMockData) {
syncMessage.textContent = 'Success: Manual sync triggered (mock).';
setTimeout(() => { syncMessage.textContent = ''; }, 5000);
return;
}
try {
const response = await fetch('/api/sync', { method: 'POST' });
const data = await response.json();
@ -125,13 +343,101 @@ document.addEventListener('DOMContentLoaded', () => {
setTimeout(() => { syncMessage.textContent = ''; }, 5000);
}
async function nudgeClock(ms) {
nudgeMessage.textContent = 'Nudging clock...';
if (useMockData) {
nudgeMessage.textContent = `Success: Clock nudged by ${ms}ms (mock).`;
setTimeout(() => { nudgeMessage.textContent = ''; }, 3000);
return;
}
try {
const response = await fetch('/api/nudge_clock', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ microseconds: ms * 1000 }),
});
const data = await response.json();
if (response.ok) {
nudgeMessage.textContent = `Success: ${data.message}`;
} else {
nudgeMessage.textContent = `Error: ${data.message}`;
}
} catch (error) {
console.error('Error nudging clock:', error);
nudgeMessage.textContent = 'Failed to send nudge command.';
}
setTimeout(() => { nudgeMessage.textContent = ''; }, 3000);
}
async function setDate() {
const date = dateInput.value;
if (!date) {
alert('Please select a date.');
return;
}
dateMessage.textContent = 'Setting date...';
if (useMockData) {
mockApiDataSets[currentMockSetKey].status.system_date = date;
dateMessage.textContent = `Success: Date set to ${date} (mock).`;
fetchStatus(); // re-render
setTimeout(() => { dateMessage.textContent = ''; }, 5000);
return;
}
try {
const response = await fetch('/api/set_date', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ date: date }),
});
const data = await response.json();
if (response.ok) {
dateMessage.textContent = `Success: ${data.message}`;
// Fetch status again to update the displayed date immediately
fetchStatus();
} else {
dateMessage.textContent = `Error: ${data.message}`;
}
} catch (error) {
console.error('Error setting date:', error);
dateMessage.textContent = 'Failed to send date command.';
}
setTimeout(() => { dateMessage.textContent = ''; }, 5000);
}
saveConfigButton.addEventListener('click', saveConfig);
manualSyncButton.addEventListener('click', triggerManualSync);
nudgeDownButton.addEventListener('click', () => {
const ms = parseInt(nudgeValueInput.value, 10) || 0;
nudgeClock(-ms);
});
nudgeUpButton.addEventListener('click', () => {
const ms = parseInt(nudgeValueInput.value, 10) || 0;
nudgeClock(ms);
});
setDateButton.addEventListener('click', setDate);
// --- Collapsible Section Listeners ---
controlsToggle.addEventListener('click', () => {
const isActive = controlsContent.classList.toggle('active');
controlsToggle.classList.toggle('active', isActive);
});
logsToggle.addEventListener('click', () => {
const isActive = logsContent.classList.toggle('active');
logsToggle.classList.toggle('active', isActive);
});
// Initial data load
setupMockControls();
fetchStatus();
fetchConfig();
fetchLogs();
// Refresh data every 2 seconds
setInterval(fetchStatus, 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: 100px;
color: #333;
margin: 0;
padding: 20px;
@ -13,19 +28,20 @@ body {
max-width: 960px;
}
h1 {
text-align: center;
color: #444;
.header-logo {
display: block;
margin: 0 auto 20px auto;
max-width: 60%;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
grid-template-columns: 1fr;
gap: 20px;
}
.card {
background: #fff;
background: #c5ced6;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
@ -33,16 +49,32 @@ h1 {
.card h2 {
margin-top: 0;
color: #0056b3;
color: #1a7db6;
}
#ltc-timecode, #system-clock {
font-family: 'Quartz', monospace;
font-size: 2em;
text-align: center;
letter-spacing: 2px;
}
.card p, .card ul {
margin: 10px 0;
}
.card ul {
padding-left: 20px;
list-style: none;
.system-date-display {
text-align: center;
font-size: 1.5em;
font-family: 'Quartz', monospace;
letter-spacing: 2px;
}
#interfaces {
text-align: center;
white-space: nowrap;
overflow-x: auto;
padding-bottom: 5px; /* Add some space for the scrollbar if it appears */
}
.full-width {
@ -56,25 +88,75 @@ h1 {
gap: 10px;
}
input[type="number"] {
input[type="number"],
input[type="text"] {
padding: 8px;
border: 1px solid #ccc;
border: 1px solid #9fb3c8;
border-radius: 4px;
background-color: #f0f4f8;
font-family: inherit;
font-size: 14px;
color: #333;
transition: border-color 0.2s, box-shadow 0.2s;
}
input[type="number"]:focus,
input[type="text"]:focus {
outline: none;
border-color: #1a7db6;
box-shadow: 0 0 0 2px rgba(26, 125, 182, 0.2);
}
input[type="number"] {
width: 80px;
}
input[type="text"] {
width: auto;
}
button {
padding: 8px 15px;
border: none;
border-radius: 4px;
background-color: #007bff;
background-color: #1a7db6;
color: white;
cursor: pointer;
font-size: 14px;
font-family: Arial, sans-serif;
font-weight: bold;
transition: background-color 0.2s;
}
button:hover {
background-color: #0056b3;
background-color: #166999;
}
.offset-controls-container {
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
align-items: center;
}
.offset-control {
display: flex;
align-items: center;
gap: 5px;
}
.offset-control input[type="number"] {
width: 40px;
text-align: center;
}
.offset-control label {
font-size: 14px;
color: #333;
}
#offset-ms {
width: 60px;
}
#sync-message {
@ -82,6 +164,103 @@ button:hover {
color: #555;
}
.icon-group {
display: flex;
justify-content: center;
align-items: center;
gap: 20px;
margin: 10px 0;
}
#delta-text {
text-align: center;
}
#ltc-status, #ntp-active, #sync-status, #jitter-status, #frame-rate, #lock-ratio, #delta-status {
display: flex;
justify-content: center;
align-items: center;
}
.status-icon {
width: 60px;
height: 60px;
}
.collapsible-card {
padding: 0;
}
.collapsible-card .toggle-header {
display: flex;
align-items: center;
gap: 15px;
padding: 20px;
cursor: pointer;
border-radius: 8px;
}
.collapsible-card .toggle-header.active {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
border-bottom: 1px solid #eee;
}
.collapsible-card .toggle-header:hover {
background-color: #e9e9f3;
}
.toggle-icon {
width: 40px;
height: 40px;
}
.header-icon {
width: 40px;
height: 40px;
}
.card-header {
display: flex;
align-items: center;
gap: 15px;
}
.log-box {
white-space: pre-wrap;
overflow-wrap: break-word;
}
.collapsible-content {
display: none;
padding: 20px;
}
.collapsible-content.active {
display: block;
}
footer {
text-align: center;
margin-top: 40px;
padding-top: 20px;
border-top: 1px solid #444;
color: #c5ced6;
}
footer p {
margin: 0;
}
footer a {
color: #1a7db6;
text-decoration: none;
}
footer a:hover {
text-decoration: underline;
}
/* Status-specific colors */
#sync-status.in-sync, #jitter-status.good { font-weight: bold; color: #28a745; }
#sync-status.clock-ahead, #sync-status.clock-behind, #jitter-status.average { font-weight: bold; color: #ffc107; }
@ -89,3 +268,6 @@ button:hover {
#jitter-status.bad { font-weight: bold; color: #dc3545; }
#ntp-active.active { font-weight: bold; color: #28a745; }
#ntp-active.inactive { font-weight: bold; color: #dc3545; }
#ltc-status.lock { font-weight: bold; color: #28a745; }
#ltc-status.free { font-weight: bold; color: #ffc107; }

View file

@ -9,10 +9,11 @@ import threading
import queue
import json
from collections import deque
from fractions import Fraction
SERIAL_PORT = None
BAUD_RATE = 115200
FRAME_RATE = 25.0
FRAME_RATE = Fraction(25, 1)
CONFIG_PATH = "config.json"
sync_pending = False
@ -30,6 +31,14 @@ sync_enabled = False
last_match_check = 0
timecode_match_status = "UNKNOWN"
def framerate_str_to_fraction(s):
if s == "23.98": return Fraction(24000, 1001)
if s == "24.00": return Fraction(24, 1)
if s == "25.00": return Fraction(25, 1)
if s == "29.97": return Fraction(30000, 1001)
if s == "30.00": return Fraction(30, 1)
return None
def load_config():
global hardware_offset_ms
try:
@ -50,13 +59,16 @@ def parse_ltc_line(line):
if not match:
return None
status, hh, mm, ss, ff, fps = match.groups()
rate = framerate_str_to_fraction(fps)
if not rate:
return None
return {
"status": status,
"hours": int(hh),
"minutes": int(mm),
"seconds": int(ss),
"frames": int(ff),
"frame_rate": float(fps)
"frame_rate": rate
}
def serial_thread(port, baud, q):
@ -154,7 +166,7 @@ def run_curses(stdscr):
parsed, arrival_time = latest_ltc
stdscr.addstr(3, 2, f"LTC Status : {parsed['status']}")
stdscr.addstr(4, 2, f"LTC Timecode : {parsed['hours']:02}:{parsed['minutes']:02}:{parsed['seconds']:02}:{parsed['frames']:02}")
stdscr.addstr(5, 2, f"Frame Rate : {FRAME_RATE:.2f}fps")
stdscr.addstr(5, 2, f"Frame Rate : {float(FRAME_RATE):.2f}fps")
stdscr.addstr(6, 2, f"System Clock : {format_time(get_system_time())}")
if ltc_locked and sync_enabled and offset_history:

18
timeturner.service Normal file
View file

@ -0,0 +1,18 @@
[Unit]
Description=NTP TimeTurner Daemon
After=network.target
[Service]
Type=forking
# The 'timeturner daemon' command starts the background process.
# It requires 'config.yml' and the 'static/' web assets directory
# to be present in the WorkingDirectory.
ExecStart=/opt/timeturner/timeturner daemon
WorkingDirectory=/opt/timeturner
PIDFile=/opt/timeturner/ntp_timeturner.pid
Restart=always
User=root
Group=root
[Install]
WantedBy=multi-user.target

28
update.sh Normal file
View file

@ -0,0 +1,28 @@
#!/bin/bash
set -e
echo "--- TimeTurner Update Script ---"
# 1. Fetch the latest changes from the git repository
echo "🔄 Pulling latest changes from GitHub..."
git pull origin main
# 2. Rebuild the release binary
echo "📦 Building release binary with Cargo..."
cargo build --release
# 3. Stop the currently running service to release the file lock
echo "🛑 Stopping TimeTurner service..."
sudo systemctl stop timeturner.service || true
# 4. Copy the new binary to the installation directory
echo "🚀 Deploying new binary..."
sudo cp target/release/ntp_timeturner /opt/timeturner/timeturner
# 5. Restart the service with the new binary
echo "✅ Restarting TimeTurner service..."
sudo systemctl restart timeturner.service
echo ""
echo "Update complete. To check the status of the service, run:"
echo " systemctl status timeturner.service"