Compare commits

...

222 commits
v0.0.2 ... main

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
4aced3eb48
Merge pull request #11 from cjfranko/daemon
Some checks failed
Build for Raspberry Pi / Build for aarch64 (push) Failing after 22s
Daemon


merging this branch in, 
we are aware that the time is currently off
2025-07-28 14:07:45 +01:00
Chris Frankland-Wright
784b3b9be6
Create LICENSE 2025-07-22 13:40:18 +01:00
Chris Frankland-Wright
5c321f5b1e
Add files via upload 2025-07-22 13:34:13 +01:00
Chris Frankland-Wright
1c05ed62d0
Update README.md 2025-07-21 22:38:45 +01:00
Chris Frankland-Wright
ec29655ff3
Add files via upload
slight variant to cope with single mismatch lines, this version will only go into free mode after 1 second of no LTC timecode. This improves reading in NTP-Timeturner where it would read a very bad timecode and panic with delta/sync status
2025-07-21 22:32:22 +01:00
1150fa20c3 cargo fix 2025-07-21 22:07:16 +01:00
7bf45c43c9 feat: add daemonization with the daemonize crate
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-21 22:05:11 +01:00
e243d87018 cargo update for daemonizeation 2025-07-21 22:04:29 +01:00
fb45a2e168 actually you do effectively build them twice 2025-07-21 21:36:58 +01:00
2dc82c34cb You only build twice, (well you usedt too) 2025-07-21 21:31:36 +01:00
8864bef1db fix: import DateTime and remove unused Datelike import
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-21 21:24:01 +01:00
060cff4089 fix: Resolve serde lifetime error in ApiStatus struct
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-21 21:21:41 +01:00
6ed1fc31e7 refactor: extract time calculation logic and add tests
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-21 21:16:38 +01:00
6afe6580fb gone 2025-07-21 21:14:41 +01:00
ec132a2840 refactor: replace systemd logger with env_logger
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-21 20:52:55 +01:00
cd737b895e build: vendor systemd to support cross-compilation
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-21 20:47:43 +01:00
4ebe8b597a fix: switch to systemd crate to resolve build failure
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-21 20:43:39 +01:00
2ac14c8d5b fix: Enable systemd feature to correctly initialize logger
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-21 20:40:18 +01:00
838082e95a fix: manually initialize systemd logger to fix build error
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-21 20:33:14 +01:00
9f39fb3739 fix: remove incorrect x86_64 target configuration
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-21 20:28:00 +01:00
56e6071e3a build: add aarch64 target for Raspberry Pi
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-21 20:24:14 +01:00
d983d632f8 build: set linker for x86_64-unknown-linux-gnu target 2025-07-21 20:23:57 +01:00
Chris Frankland-Wright
c1587e8ce6
Update README.md 2025-07-21 20:12:24 +01:00
Chris Frankland-Wright
d9e51888bb
Update README.md
added John as co-author
2025-07-21 20:12:02 +01:00
b2f50be611 fix: correct systemd logger initialization for Linux builds
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-21 20:10:49 +01:00
183fdc0725 fix: use init_with_level for systemd logger initialization
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-21 20:03:45 +01:00
b1a0483d6c fix: Correct systemd logger initialization
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-21 19:58:51 +01:00
154c07f613 fix: update logger call and remove unused import
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-21 19:54:46 +01:00
12065a08c2 fix: Conditionally compile systemd features for Linux only
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-21 19:46:16 +01:00
b854d29015 refactor: Extract system and status logic from UI module
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-21 19:44:10 +01:00
1d9bc1e25e feat: add daemon mode and systemd service
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-21 19:42:46 +01:00
d55a11b074
Merge pull request #10 from cjfranko/portal
Portal
2025-07-21 19:27:38 +01:00
fecfed04e7 fix: remove unused imports
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-21 19:20:29 +01:00
777a202877 feat: add timeturner for time offsets and migrate config to YAML
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-21 19:16:06 +01:00
08577f5064 json2yml 2025-07-21 19:12:33 +01:00
c48ef1cf3f feat: add web frontend to control the API
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-21 19:00:19 +01:00
666ce4308f
Merge pull request #9 from cjfranko/api
Api
2025-07-21 18:53:28 +01:00
19a7ac14fb test: remove non-functional manual_sync test
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-21 18:47:22 +01:00
03468d7568 test: fix failing manual_sync test assertion
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-21 18:41:46 +01:00
0a9f9c6612 fix: Add macOS support for time sync command
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-21 18:39:48 +01:00
ac08ffb54f test: add integration tests for API endpoints
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-21 17:18:41 +01:00
aa1973603e docs: add API documentation
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-21 17:00:58 +01:00
32b307b935 fix: Embed default config to resolve build failure
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-21 16:58:56 +01:00
6eda9149ca api 2025-07-21 16:58:21 +01:00
2d6f65046a fix: run API server in LocalSet to fix spawn_local panic
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-21 16:55:17 +01:00
c94e1ea4b0 fix: use spawn_local to run non-Send API server task
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-21 16:49:35 +01:00
0325c3b570 fix: Set tokio runtime to current_thread to fix !Send errors
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-21 16:48:09 +01:00
8ad553aaee feat: add web API for status, sync, and configuration
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-21 16:44:41 +01:00
a124aae424 test: add tests for ensure_config function
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-21 16:31:28 +01:00
3cbe95bd6a test: add tests for serial input processing
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-21 16:18:17 +01:00
0b8fa0fbf8 fix: Calculate clock delta using median to resist outliers
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-21 16:10:40 +01:00
30fb752cbb test: add tests for UI status helpers
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-21 16:06:22 +01:00
63 changed files with 10583 additions and 702 deletions

View file

@ -10,6 +10,16 @@ crossterm = "0.29"
regex = "1.11"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0.141"
serde_yaml = "0.9"
notify = "8.1.0"
get_if_addrs = "0.5"
actix-web = "4"
actix-files = "0.6"
tokio = { version = "1", features = ["full"] }
clap = { version = "4.4", features = ["derive"] }
log = { version = "0.4", features = ["std"] }
daemonize = "0.5.0"
num-rational = "0.4"
num-traits = "0.2"

674
LICENSE Normal file
View file

@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.

110
README.md
View file

@ -1,14 +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 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
@ -22,27 +24,103 @@ Inspired by the TimeTurner in the Harry Potter series, this project synchronises
- 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)
- Supports configurable time offsets (hours, minutes, seconds, frames or milliseconds)
- Systemd service support for headless operation
- Optional splash screen branding at boot
- 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
@ -51,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,3 +0,0 @@
{
"hardware_offset_ms": 20
}

19
config.yml Normal file
View file

@ -0,0 +1,19 @@
# Hardware offset in milliseconds for correcting capture latency.
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.
timeturnerOffset:
hours: 0
minutes: 0
seconds: 0
frames: 0
milliseconds: 0

196
docs/api.md Normal file
View file

@ -0,0 +1,196 @@
# NTP Timeturner API
This document describes the HTTP API for the NTP Timeturner application.
## Endpoints
### Status and Logs
- **`GET /api/status`**
Retrieves the real-time status of the LTC reader and system clock synchronization. The `ltc_timecode` field uses `:` as a separator for non-drop-frame timecode, and `;` for drop-frame timecode between seconds and frames (e.g., `10:20:30;00`).
**Possible values for status fields:**
- `ltc_status`: `"LOCK"`, `"FREE"`, or `"(waiting)"`
- `sync_status`: `"IN SYNC"`, `"CLOCK AHEAD"`, `"CLOCK BEHIND"`, `"TIMETURNING"`
- `jitter_status`: `"GOOD"`, `"AVERAGE"`, `"BAD"`
**Example Response:**
```json
{
"ltc_status": "LOCK",
"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",
"jitter_status": "GOOD",
"lock_ratio": 99.5,
"ntp_active": true,
"interfaces": ["192.168.1.100"],
"hardware_offset_ms": 20
}
```
- **`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`**
Triggers a manual synchronization of the system clock to the current LTC timecode. This requires the application to have `sudo` privileges to execute the `date` command.
**Request Body:** None
**Success Response (200 OK):**
```json
{
"status": "success",
"message": "Sync command issued."
}
```
**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",
"message": "Sync command failed."
}
```
- **`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 from `config.yml`.
**Example Response (200 OK):**
```json
{
"hardwareOffsetMs": 20,
"timeturnerOffset": {
"hours": 0,
"minutes": 0,
"seconds": 0,
"frames": 0,
"milliseconds": 0
},
"defaultNudgeMs": 2,
"autoSyncEnabled": false
}
```
- **`POST /api/config`**
Updates the application configuration. The new configuration is persisted to `config.yml` and takes effect immediately.
**Example Request:**
```json
{
"hardwareOffsetMs": 55,
"timeturnerOffset": {
"hours": 1,
"minutes": 2,
"seconds": 3,
"frames": 4,
"milliseconds": 5
},
"defaultNudgeMs": 2,
"autoSyncEnabled": true
}
```
**Success Response (200 OK):** (Returns the updated configuration)
```json
{
"hardwareOffsetMs": 55,
"timeturnerOffset": {
"hours": 1,
"minutes": 2,
"seconds": 3,
"frames": 4,
"milliseconds": 5
},
"defaultNudgeMs": 2,
"autoSyncEnabled": true
}
```
**Error Response (500 Internal Server Error):**
```json
{
"status": "error",
"message": "Failed to write config.yml"
}
```

View file

@ -0,0 +1,180 @@
/* Linear Timecode for Audio Library for Teensy 3.x / 4.x
Copyright (c) 2019, Frank Bösing, f.boesing (at) gmx.de
Development of this audio library was funded by PJRC.COM, LLC by sales of
Teensy and Audio Adaptor boards. Please support PJRC's efforts to develop
open source software by purchasing Teensy or other PJRC products.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice, development funding notice, and this permission
notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
*/
/*
https://forum.pjrc.com/threads/41584-Audio-Library-for-Linear-Timecode-(LTC)
LTC example audio at: https://www.youtube.com/watch?v=uzje8fDyrgg
Adapted by Chris Frankland-Wright 2025 for Teensy Audio Shield Input with autodetect FPS for the NTP-TimeTurner Project
*/
#include <Arduino.h>
#include <Audio.h>
#include "analyze_ltc.h"
// —— Configuration ——
// 0.0 → auto-detect; or force 24.0, 25.0, 29.97
const float FORCE_FPS = 0.0f;
// frame-delay compensation (in frames)
const int FRAME_OFFSET = 4;
// how many frame-periods to wait before declaring “lost”
const float LOSS_THRESHOLD_FRAMES = 1.5f;
// Blink periods (ms) for NO_LTC, ACTIVE, LOST
const unsigned long BLINK_PERIOD[3] = { 2000, 100, 500 };
AudioInputI2S i2s1;
AudioAnalyzeLTC ltc1;
AudioControlSGTL5000 sgtl5000;
AudioConnection patchCord(i2s1, 0, ltc1, 0);
enum State { NO_LTC = 0, LTC_ACTIVE, LTC_LOST };
State ltcState = NO_LTC;
bool ledOn = false;
unsigned long lastDecode = 0;
unsigned long lastBlink = 0;
// auto-detect vars
float currentFps = 25.0f;
float periodMs = 0;
const float SMOOTH_ALPHA = 0.1f;
unsigned long lastDetectTs = 0;
// free-run tracking
long freeAbsFrame = 0;
unsigned long lastFreeRun = 0;
void setup() {
Serial.begin(115200);
// while (!Serial);
AudioMemory(12);
sgtl5000.enable();
sgtl5000.inputSelect(AUDIO_INPUT_LINEIN);
pinMode(LED_BUILTIN, OUTPUT);
}
void loop() {
unsigned long now = millis();
// compute dynamic framePeriod (ms) from last known fps
unsigned long framePeriod = (unsigned long)(1000.0f/currentFps + 0.5f);
if (ltc1.available()) {
// —— LOCKED —— read a frame
ltcframe_t frame = ltc1.read();
int h = ltc1.hour(&frame),
m = ltc1.minute(&frame),
s = ltc1.second(&frame),
f = ltc1.frame(&frame);
// —— FPS detect or force ——
if (FORCE_FPS > 0.0f) {
currentFps = FORCE_FPS;
} else {
if (lastDetectTs) {
float dt = now - lastDetectTs;
periodMs = periodMs==0 ? dt : (SMOOTH_ALPHA*dt + (1-SMOOTH_ALPHA)*periodMs);
float measured = 1000.0f/periodMs;
const float choices[3] = {24.0f,25.0f,29.97f};
float bestD=1e6, pick=25.0f;
for (auto c: choices) {
float d = fabs(measured - c);
if (d < bestD) { bestD = d; pick = c; }
}
currentFps = pick;
}
lastDetectTs = now;
}
// —— pack + offset + wrap ——
int nominal = (currentFps>29.5f)?30:int(currentFps+0.5f);
long dayFrames = 24L*3600L*nominal;
long absF = ((long)h*3600 + m*60 + s)*nominal + f + FRAME_OFFSET;
absF = (absF % dayFrames + dayFrames) % dayFrames;
// save for free-run
freeAbsFrame = absF;
lastFreeRun = now;
// unpack for display
long totSec = absF/nominal;
int outF = absF % nominal;
int outS = totSec % 60;
long totMin = totSec/60;
int outM = totMin % 60;
int outH = (totMin/60)%24;
// dynamic drop-frame from bit 10
bool isDF = ltc1.bit10(&frame);
char sep = isDF ? ';' : ':';
// print locked
Serial.printf("[LOCK] %02d:%02d:%02d%c%02d | %.2ffps\r\n",
outH,outM,outS,sep,outF,currentFps);
// update state
ltcState = LTC_ACTIVE;
lastDecode = now;
}
else {
// —— NOT LOCKED —— check if we should switch to free-run
if (ltcState == LTC_ACTIVE) {
// only switch after losing more than LOSS_THRESHOLD_FRAMES
float elapsedFrames = float(now - lastDecode) / float(framePeriod);
if (elapsedFrames >= LOSS_THRESHOLD_FRAMES) {
ltcState = LTC_LOST;
// free-run will begin below
}
}
}
// —— FREE-RUN —— when lost
if (ltcState == LTC_LOST) {
if ((now - lastFreeRun) >= framePeriod) {
freeAbsFrame = (freeAbsFrame + 1) % (24L*3600L*(int)(currentFps+0.5f));
lastFreeRun += framePeriod;
long totSec = freeAbsFrame/((int)(currentFps+0.5f));
int outF = freeAbsFrame % (int)(currentFps+0.5f);
int outS = totSec % 60;
long totMin = totSec/60;
int outM = totMin % 60;
int outH = (totMin/60)%24;
Serial.printf("[FREE] %02d:%02d:%02d:%02d | %.2ffps\r\n",
outH,outM,outS,outF,currentFps);
}
}
// —— LED heartbeat —— non-blocking
unsigned long period = BLINK_PERIOD[ltcState];
if (now - lastBlink >= period/2) {
ledOn = !ledOn;
digitalWrite(LED_BUILTIN, ledOn);
lastBlink = now;
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,174 @@
/* Linear Timecode for Audio Library for Teensy 3.x / 4.x
Copyright (c) 2019, Frank Bösing, f.boesing (at) gmx.de
Development of this audio library was funded by PJRC.COM, LLC by sales of
Teensy and Audio Adaptor boards. Please support PJRC's efforts to develop
open source software by purchasing Teensy or other PJRC products.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice, development funding notice, and this permission
notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
*/
/*
https://forum.pjrc.com/threads/41584-Audio-Library-for-Linear-Timecode-(LTC)
LTC example audio at: https://www.youtube.com/watch?v=uzje8fDyrgg
Adapted by Chris Frankland-Wright 2025 for Teensy Audio Shield Input with autodetect FPS for the NTP-TimeTurner Project
*/
#include <Arduino.h>
#include <Audio.h>
#include "analyze_ltc.h"
// —— Configuration ——
const float FORCE_FPS = 0.0f; // 0 → autodetect
const int FRAME_OFFSET = 4; // compensation in frames
const unsigned long LOSS_TIMEOUT = 1000UL; // ms before we go into LOST
const unsigned long BLINK_PERIOD[3] = {2000,100,500}; // NO_LTC, ACTIVE, LOST
AudioInputI2S i2s1;
AudioAnalyzeLTC ltc1;
AudioControlSGTL5000 sgtl5000;
AudioConnection patchCord(i2s1, 0, ltc1, 0);
enum State { NO_LTC=0, LTC_ACTIVE, LTC_LOST };
State ltcState = NO_LTC;
bool ledOn = false;
unsigned long lastDecode = 0;
unsigned long lastBlink = 0;
// FPS detection
float currentFps = 25.0f;
float periodMs = 0;
const float SMOOTH_ALPHA = 0.1f;
unsigned long lastDetectTs = 0;
// freerun
long freeAbsFrame = 0;
unsigned long lastFreeRun = 0;
void setup() {
Serial.begin(115200);
AudioMemory(12);
sgtl5000.enable();
sgtl5000.inputSelect(AUDIO_INPUT_LINEIN);
pinMode(LED_BUILTIN, OUTPUT);
}
void loop() {
unsigned long now = millis();
// compute framePeriod from currentFps
unsigned long framePeriod = (unsigned long)(1000.0f / currentFps + 0.5f);
// 1) If in ACTIVE and we've gone > LOSS_TIMEOUT w/o decode, enter LOST
if (ltcState == LTC_ACTIVE && (now - lastDecode) >= LOSS_TIMEOUT) {
ltcState = LTC_LOST;
// bump freeAbsFrame by 1second worth of frames:
int nominal = (currentFps>29.5f) ? 30 : int(currentFps+0.5f);
long dayFrames= 24L*3600L*nominal;
freeAbsFrame = (freeAbsFrame + nominal) % dayFrames;
// reset freerun timer so we start next tick fresh
lastFreeRun = now;
}
// 2) Handle incoming LTC frame
if (ltc1.available()) {
ltcframe_t frame = ltc1.read();
int h = ltc1.hour(&frame),
m = ltc1.minute(&frame),
s = ltc1.second(&frame),
f = ltc1.frame(&frame);
// — FPS detect or force —
if (FORCE_FPS > 0.0f) {
currentFps = FORCE_FPS;
} else {
if (lastDetectTs) {
float dt = now - lastDetectTs;
periodMs = periodMs==0 ? dt : (SMOOTH_ALPHA*dt + (1-SMOOTH_ALPHA)*periodMs);
float meas = 1000.0f/periodMs;
const float choices[3] = {24.0f,25.0f,29.97f};
float bestD=1e6, pick=25.0f;
for (auto c: choices) {
float d = fabs(meas-c);
if (d < bestD) { bestD=d; pick=c; }
}
currentFps = pick;
}
lastDetectTs = now;
}
// — pack + offset + wrap —
int nominal = (currentFps>29.5f) ? 30 : int(currentFps+0.5f);
long dayFrames = 24L*3600L*nominal;
long absF = ((long)h*3600 + m*60 + s)*nominal + f + FRAME_OFFSET;
absF = (absF % dayFrames + dayFrames) % dayFrames;
// — reset anchors & state —
freeAbsFrame = absF;
lastFreeRun = now;
lastDecode = now;
ltcState = LTC_ACTIVE;
// — print LOCK —
long totSec = absF/nominal;
int outF = absF % nominal;
int outS = totSec % 60;
long totMin = totSec/60;
int outM = totMin % 60;
int outH = (totMin/60) % 24;
bool isDF = ltc1.bit10(&frame);
char sep = isDF?';':':';
Serial.printf("[LOCK] %02d:%02d:%02d%c%02d | %.2ffps\r\n",
outH,outM,outS,sep,outF,currentFps);
// — LED → ACTIVE immediately —
lastBlink = now;
ledOn = true;
digitalWrite(LED_BUILTIN, HIGH);
}
// 3) If in LOST, do freerun printing
else if (ltcState == LTC_LOST) {
if ((now - lastFreeRun) >= framePeriod) {
freeAbsFrame = (freeAbsFrame + 1) % (24L*3600L*((int)(currentFps+0.5f)));
lastFreeRun += framePeriod;
// — print FREE —
int nominal = (currentFps>29.5f) ? 30 : int(currentFps+0.5f);
long totSec = freeAbsFrame/nominal;
int outF = freeAbsFrame % nominal;
int outS = totSec % 60;
long totMin = totSec/60;
int outM = totMin % 60;
int outH = (totMin/60)%24;
Serial.printf("[FREE] %02d:%02d:%02d:%02d | %.2ffps\r\n",
outH,outM,outS,outF,currentFps);
}
}
// 4) LED heartbeat
unsigned long bp = BLINK_PERIOD[ltcState];
if ((now - lastBlink) >= (bp/2)) {
ledOn = !ledOn;
digitalWrite(LED_BUILTIN, ledOn);
lastBlink = now;
}
}

375
setup.sh
View file

@ -1,125 +1,280 @@
#!/bin/bash
set -e
echo ""
echo "─────────────────────────────────────────────"
echo " Welcome to the NTP TimeTurner Installer"
echo "─────────────────────────────────────────────"
echo ""
echo "\"It's a very complicated piece of magic...\" Hermione Granger"
echo "Preparing the Ministry-grade temporal interface..."
echo ""
echo "--- TimeTurner Setup ---"
# ---------------------------------------------------------
# Step 1: Update and upgrade packages
# ---------------------------------------------------------
echo "Step 1: Updating package lists and upgrading..."
sudo apt update && sudo apt upgrade -y
# ---------------------------------------------------------
# Step 2: Install core tools and Python dependencies
# ---------------------------------------------------------
echo "Step 2: Installing required tools..."
sudo apt install -y git curl python3 python3-pip build-essential cmake \
python3-serial libusb-dev
# ---------------------------------------------------------
# Step 2.5: Install teensy-loader-cli from source
# ---------------------------------------------------------
echo "Installing teensy-loader-cli manually from source..."
cd "$HOME"
if [ ! -d teensy_loader_cli ]; then
git clone https://github.com/PaulStoffregen/teensy_loader_cli.git
fi
cd teensy_loader_cli
make
sudo install -m 755 teensy_loader_cli /usr/local/bin/teensy-loader-cli
echo "Verifying teensy-loader-cli..."
teensy-loader-cli --version || echo "⚠️ teensy-loader-cli failed to install properly"
# ---------------------------------------------------------
# Step 2.6: Install udev rules for Teensy
# ---------------------------------------------------------
echo "Installing udev rules for Teensy access..."
cd "$HOME"
wget -O 49-teensy.rules https://www.pjrc.com/teensy/49-teensy.rules
sudo cp 49-teensy.rules /etc/udev/rules.d/
sudo udevadm control --reload-rules
sudo udevadm trigger
echo "✅ Teensy udev rules installed. Reboot required to take full effect."
# ---------------------------------------------------------
# Step 3: Install Arduino CLI manually (latest version)
# ---------------------------------------------------------
echo "Step 3: Downloading and installing arduino-cli..."
cd "$HOME"
curl -fsSL https://downloads.arduino.cc/arduino-cli/arduino-cli_latest_Linux_ARM64.tar.gz -o arduino-cli.tar.gz
tar -xzf arduino-cli.tar.gz
sudo mv arduino-cli /usr/local/bin/
rm arduino-cli.tar.gz
echo "Verifying arduino-cli install..."
arduino-cli version || echo "⚠️ arduino-cli install failed or not found in PATH"
# ---------------------------------------------------------
# Step 4: Download and apply splash screen
# ---------------------------------------------------------
echo "Step 4: Downloading and applying splash screen..."
cd "$HOME"
wget -O splash.png https://raw.githubusercontent.com/cjfranko/NTP-Timeturner/master/splash.png
if [ -f splash.png ]; then
sudo cp splash.png /usr/share/plymouth/themes/pix/splash.png
sudo chmod 644 /usr/share/plymouth/themes/pix/splash.png
echo "✅ Splash screen applied."
# 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 "⚠️ splash.png not found — skipping."
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
# ---------------------------------------------------------
# Step 4.5: Configure Plymouth to stay on screen longer
# ---------------------------------------------------------
echo "Step 4.5: Configuring splash screen timing..."
# Ensure 'quiet splash' is in /boot/cmdline.txt
sudo sed -i 's/\(\s*\)console=tty1/\1quiet splash console=tty1/' /boot/cmdline.txt
echo "✅ Set 'quiet splash' in /boot/cmdline.txt"
# 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
# Update Plymouth config
sudo sed -i 's/^Theme=.*/Theme=pix/' /etc/plymouth/plymouthd.conf
sudo sed -i 's/^ShowDelay=.*/ShowDelay=0/' /etc/plymouth/plymouthd.conf || echo "ShowDelay=0" | sudo tee -a /etc/plymouth/plymouthd.conf
sudo sed -i 's/^DeviceTimeout=.*/DeviceTimeout=10/' /etc/plymouth/plymouthd.conf || echo "DeviceTimeout=10" | sudo tee -a /etc/plymouth/plymouthd.conf
sudo sed -i 's/^DisableFadeIn=.*/DisableFadeIn=true/' /etc/plymouth/plymouthd.conf || echo "DisableFadeIn=true" | sudo tee -a /etc/plymouth/plymouthd.conf
echo "✅ Updated /etc/plymouth/plymouthd.conf"
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
# Create autostart delay to keep splash visible until desktop is ready
mkdir -p "$HOME/.config/autostart"
cat << EOF > "$HOME/.config/autostart/delayed-plymouth-exit.desktop"
[Desktop Entry]
Type=Application
Name=Delayed Plymouth Exit
Exec=/bin/sh -c "sleep 3 && /usr/bin/plymouth quit"
X-GNOME-Autostart-enabled=true
EOF
echo "✅ Splash screen will exit 3 seconds after desktop starts"
# Append the rest of the original config file after our new lines
cat "$CHRONY_CONF" >> "$TEMP_CONF"
sudo mv "$TEMP_CONF" "$CHRONY_CONF"
# ---------------------------------------------------------
# Step 5: Download Teensy firmware
# ---------------------------------------------------------
echo "Step 5: Downloading Teensy firmware..."
cd "$HOME"
wget -O ltc_audiohat_lock.ino.hex https://raw.githubusercontent.com/cjfranko/NTP-Timeturner/master/firmware/ltc_audiohat_lock.ino.hex
# ---------------------------------------------------------
# Final Message & Reboot
# ---------------------------------------------------------
# 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."
# 2. Create installation directories
INSTALL_DIR="/opt/timeturner"
BIN_DIR="/usr/local/bin"
echo "🔧 Creating directories..."
sudo mkdir -p $INSTALL_DIR
echo "✅ Directory $INSTALL_DIR created."
# 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 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/
sudo systemctl daemon-reload
sudo systemctl enable timeturner.service
echo "✅ Systemd service installed and enabled."
else
echo "⚠️ Skipping systemd service installation on non-Linux OS."
fi
echo ""
echo "─────────────────────────────────────────────"
echo " Setup Complete — Rebooting in 15 seconds..."
echo "─────────────────────────────────────────────"
echo "NOTE: Teensy firmware ready in $HOME, but not auto-flashed."
echo "Boot splash will remain until desktop loads. "
echo "--- Setup Complete ---"
echo "The TimeTurner daemon is now installed."
echo "The working directory is $INSTALL_DIR."
# 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:"
echo " sudo systemctl start timeturner.service"
echo ""
echo "To view live logs, run:"
echo " journalctl -u timeturner.service -f"
echo ""
fi
echo "To run the interactive TUI instead, simply run from the project directory:"
echo " cargo run"
echo "Or from anywhere after installation:"
echo " timeturner"
echo ""
sleep 15
sudo reboot

412
src/api.rs Normal file
View file

@ -0,0 +1,412 @@
use actix_files as fs;
use actix_web::{get, post, web, App, HttpResponse, HttpServer, Responder};
use chrono::{Local, Timelike};
use get_if_addrs::get_if_addrs;
use serde::{Deserialize, Serialize};
use serde_json;
use std::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)]
struct ApiStatus {
ltc_status: String,
ltc_timecode: String,
frame_rate: String,
system_clock: String,
system_date: String,
timecode_delta_ms: i64,
timecode_delta_frames: i64,
sync_status: String,
jitter_status: String,
lock_ratio: f64,
ntp_active: bool,
interfaces: Vec<String>,
hardware_offset_ms: i64,
}
// AppState to hold shared data
pub struct AppState {
pub ltc_state: Arc<Mutex<LtcState>>,
pub config: Arc<Mutex<Config>>,
pub log_buffer: Arc<Mutex<VecDeque<String>>>,
}
#[get("/api/status")]
async fn get_status(data: web::Data<AppState>) -> impl Responder {
let state = data.ltc_state.lock().unwrap();
let config = data.config.lock().unwrap();
let hw_offset_ms = config.hardware_offset_ms;
let ltc_status = state.latest.as_ref().map_or("(waiting)".to_string(), |f| f.status.clone());
let ltc_timecode = state.latest.as_ref().map_or("".to_string(), |f| {
let sep = if f.is_drop_frame { ';' } else { ':' };
format!(
"{:02}:{:02}:{:02}{}{:02}",
f.hours, f.minutes, f.seconds, sep, f.frames
)
});
let frame_rate = state.latest.as_ref().map_or("".to_string(), |f| {
format!("{:.2}fps", f.frame_rate.to_f64().unwrap_or(0.0))
});
let now_local = Local::now();
let system_clock = format!(
"{:02}:{:02}:{:02}.{:03}",
now_local.hour(),
now_local.minute(),
now_local.second(),
now_local.timestamp_subsec_millis(),
);
let system_date = now_local.format("%Y-%m-%d").to_string();
let avg_delta = state.get_ewma_clock_delta();
let mut delta_frames = 0;
if let Some(frame) = &state.latest {
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);
let jitter_status = sync_logic::get_jitter_status(state.average_jitter());
let lock_ratio = state.lock_ratio();
let ntp_active = system::ntp_service_active();
let interfaces = get_if_addrs()
.unwrap_or_default()
.into_iter()
.filter(|ifa| !ifa.is_loopback())
.map(|ifa| ifa.ip().to_string())
.collect();
HttpResponse::Ok().json(ApiStatus {
ltc_status,
ltc_timecode,
frame_rate,
system_clock,
system_date,
timecode_delta_ms: avg_delta,
timecode_delta_frames: delta_frames,
sync_status: sync_status.to_string(),
jitter_status: jitter_status.to_string(),
lock_ratio,
ntp_active,
interfaces,
hardware_offset_ms: hw_offset_ms,
})
}
#[post("/api/sync")]
async fn manual_sync(data: web::Data<AppState>) -> impl Responder {
let state = data.ltc_state.lock().unwrap();
let config = data.config.lock().unwrap();
if let Some(frame) = &state.latest {
if system::trigger_sync(frame, &config).is_ok() {
HttpResponse::Ok().json(serde_json::json!({ "status": "success", "message": "Sync command issued." }))
} else {
HttpResponse::InternalServerError().json(serde_json::json!({ "status": "error", "message": "Sync command failed." }))
}
} else {
HttpResponse::BadRequest().json(serde_json::json!({ "status": "error", "message": "No LTC timecode available to sync to." }))
}
}
#[get("/api/config")]
async fn get_config(data: web::Data<AppState>) -> impl Responder {
let config = data.config.lock().unwrap();
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>,
req: web::Json<Config>,
) -> impl Responder {
let mut config = data.config.lock().unwrap();
*config = req.into_inner();
if config::save_config("config.yml", &config).is_ok() {
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 {
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,
});
log::info!("🚀 Starting API server at http://0.0.0.0:8080");
HttpServer::new(move || {
App::new()
.app_data(app_state.clone())
.service(get_status)
.service(manual_sync)
.service(get_config)
.service(update_config)
.service(get_logs)
.service(nudge_clock)
.service(set_date)
// Serve frontend static files
.service(fs::Files::new("/", "static/").index_file("index.html"))
})
.bind("0.0.0.0:8080")?
.run()
.await
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::TimeturnerOffset;
use crate::sync_logic::LtcFrame;
use actix_web::{test, App};
use chrono::Utc;
use std::collections::VecDeque;
use std::fs;
// Helper to create a default LtcState for tests
fn get_test_ltc_state() -> LtcState {
LtcState {
latest: Some(LtcFrame {
status: "LOCK".to_string(),
hours: 1,
minutes: 2,
seconds: 3,
frames: 4,
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]),
ewma_clock_delta: Some(5.0),
last_match_status: "IN SYNC".to_string(),
last_match_check: Utc::now().timestamp(),
}
}
// Helper to create a default AppState for tests
fn get_test_app_state() -> web::Data<AppState> {
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::default(),
default_nudge_ms: 2,
auto_sync_enabled: false,
}));
let log_buffer = Arc::new(Mutex::new(VecDeque::new()));
web::Data::new(AppState {
ltc_state,
config,
log_buffer,
})
}
#[actix_web::test]
async fn test_get_status() {
let app_state = get_test_app_state();
let app = test::init_service(
App::new()
.app_data(app_state.clone())
.service(get_status),
)
.await;
let req = test::TestRequest::get().uri("/api/status").to_request();
let resp: ApiStatus = test::call_and_read_body_json(&app, req).await;
assert_eq!(resp.ltc_status, "LOCK");
assert_eq!(resp.ltc_timecode, "01:02:03:04");
assert_eq!(resp.frame_rate, "25.00fps");
assert_eq!(resp.hardware_offset_ms, 10);
}
#[actix_web::test]
async fn test_get_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();
app_state.config.lock().unwrap().hardware_offset_ms = 25;
let app = test::init_service(
App::new()
.app_data(app_state.clone())
.service(get_config),
)
.await;
let req = test::TestRequest::get().uri("/api/config").to_request();
let resp: Config = test::call_and_read_body_json(&app, req).await;
assert_eq!(resp.hardware_offset_ms, 25);
}
#[actix_web::test]
async fn test_update_config() {
let app_state = get_test_app_state();
let config_path = "config.yml";
// This test has the side effect of writing to `config.yml`.
// We ensure it's cleaned up after.
let _ = fs::remove_file(config_path);
let app = test::init_service(
App::new()
.app_data(app_state.clone())
.service(update_config),
)
.await;
let new_config_json = serde_json::json!({
"hardwareOffsetMs": 55,
"defaultNudgeMs": 2,
"autoSyncEnabled": true,
"timeturnerOffset": { "hours": 1, "minutes": 2, "seconds": 3, "frames": 4, "milliseconds": 5 }
});
let req = test::TestRequest::post()
.uri("/api/config")
.set_json(&new_config_json)
.to_request();
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);
}
#[actix_web::test]
async fn test_manual_sync_no_ltc() {
let app_state = get_test_app_state();
// State with no LTC frame
app_state.ltc_state.lock().unwrap().latest = None;
let app = test::init_service(
App::new()
.app_data(app_state.clone())
.service(manual_sync),
)
.await;
let req = test::TestRequest::post().uri("/api/sync").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), 400); // Bad Request
}
}

View file

@ -1,69 +1,138 @@
// src/config.rs
use notify::{
recommended_watcher, Event, EventKind, RecommendedWatcher, RecursiveMode, Result as NotifyResult,
Watcher,
};
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use std::{
fs,
fs::File,
io::Read,
path::PathBuf,
sync::{Arc, Mutex},
};
#[derive(Deserialize)]
#[derive(Deserialize, Serialize, Clone, Debug, Default)]
#[serde(rename_all = "camelCase")]
pub struct TimeturnerOffset {
pub hours: i64,
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.milliseconds != 0
}
}
#[derive(Deserialize, Serialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
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 {
pub fn load(path: &PathBuf) -> Self {
let mut file = match File::open(path) {
Ok(f) => f,
Err(_) => return Self { hardware_offset_ms: 0 },
Err(_) => return Self::default(),
};
let mut contents = String::new();
if file.read_to_string(&mut contents).is_err() {
return Self { hardware_offset_ms: 0 };
return Self::default();
}
serde_yaml::from_str(&contents).unwrap_or_else(|e| {
log::warn!("Failed to parse config, using default: {}", e);
Self::default()
})
}
}
impl Default for Config {
fn default() -> Self {
Self {
hardware_offset_ms: 0,
timeturner_offset: TimeturnerOffset::default(),
default_nudge_ms: default_nudge_ms(),
auto_sync_enabled: false,
}
serde_json::from_str(&contents).unwrap_or(Self { hardware_offset_ms: 0 })
}
}
pub fn watch_config(path: &str) -> Arc<Mutex<i64>> {
let initial = Config::load(&PathBuf::from(path)).hardware_offset_ms;
let offset = Arc::new(Mutex::new(initial));
pub fn save_config(path: &str, config: &Config) -> Result<(), Box<dyn std::error::Error>> {
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(())
}
pub fn watch_config(path: &str) -> Arc<Mutex<Config>> {
let initial_config = Config::load(&PathBuf::from(path));
let config = Arc::new(Mutex::new(initial_config));
// Owned PathBuf for watch() call
let watch_path = PathBuf::from(path);
// Clone for moving into the closure
let watch_path_for_cb = watch_path.clone();
let offset_for_cb = Arc::clone(&offset);
let config_for_cb = Arc::clone(&config);
std::thread::spawn(move || {
// Move `watch_path_for_cb` into the callback
let mut watcher: RecommendedWatcher = recommended_watcher(move |res: NotifyResult<Event>| {
if let Ok(evt) = res {
if matches!(evt.kind, EventKind::Modify(_)) {
let new_cfg = Config::load(&watch_path_for_cb);
let mut hw = offset_for_cb.lock().unwrap();
*hw = new_cfg.hardware_offset_ms;
eprintln!("🔄 Reloaded hardware_offset_ms = {}", *hw);
let mut cfg = config_for_cb.lock().unwrap();
*cfg = new_cfg;
log::info!("🔄 Reloaded config.yml: {:?}", *cfg);
}
}
})
.expect("Failed to create file watcher");
// Use the original `watch_path` here
watcher
.watch(&watch_path, RecursiveMode::NonRecursive)
.expect("Failed to watch config.json");
.expect("Failed to watch config.yml");
loop {
std::thread::sleep(std::time::Duration::from_secs(60));
}
});
offset
config
}

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

@ -1,59 +1,190 @@
// src/main.rs
mod api;
mod config;
mod sync_logic;
mod logger;
mod serial_input;
mod sync_logic;
mod system;
mod ui;
use crate::api::start_api_server;
use crate::config::watch_config;
use crate::sync_logic::LtcState;
use crate::serial_input::start_serial_thread;
use crate::sync_logic::LtcState;
use crate::ui::start_ui;
use clap::Parser;
use daemonize::Daemonize;
use serialport;
use std::{
fs,
path::Path,
sync::{Arc, Mutex, mpsc},
sync::{mpsc, Arc, Mutex},
thread,
};
use tokio::task::{self, LocalSet};
/// Embed the default config.json at compile time.
const DEFAULT_CONFIG: &str = include_str!("../config.json");
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct Args {
#[command(subcommand)]
command: Option<Command>,
}
/// If no `config.json` exists alongside the binary, write out the default.
#[derive(clap::Subcommand, Debug)]
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.
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:
hours: 0
minutes: 0
seconds: 0
frames: 0
milliseconds: 0
"#;
/// If no `config.yml` exists alongside the binary, write out the default.
fn ensure_config() {
let p = Path::new("config.json");
let p = Path::new("config.yml");
if !p.exists() {
fs::write(p, DEFAULT_CONFIG)
.expect("Failed to write default config.json");
eprintln!("⚙️ Emitted default config.json");
fs::write(p, DEFAULT_CONFIG.trim())
.expect("Failed to write default config.yml");
log::info!("⚙️ Emitted default config.yml");
}
}
fn main() {
// 🔄 Ensure there's always a config.json present
fn find_serial_port() -> Option<String> {
if let Ok(ports) = serialport::available_ports() {
for p in ports {
if p.port_name.starts_with("/dev/ttyACM")
|| p.port_name.starts_with("/dev/ttyAMA")
|| p.port_name.starts_with("/dev/ttyUSB")
{
return Some(p.port_name);
}
}
}
None
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
// This must be called before any logging statements.
let log_buffer = logger::setup_logger();
let args = Args::parse();
if let Some(command) = &args.command {
match command {
Command::Daemon => {
log::info!("🚀 Starting daemon...");
// Create files for stdout and stderr in the current directory
let stdout =
fs::File::create("daemon.out").expect("Could not create daemon.out");
let stderr =
fs::File::create("daemon.err").expect("Could not create daemon.err");
let daemonize = Daemonize::new()
.pid_file("ntp_timeturner.pid") // Create a PID file
.working_directory(".") // Keep the same working directory
.stdout(stdout)
.stderr(stderr);
match daemonize.start() {
Ok(_) => { /* Process is now daemonized */ }
Err(e) => {
log::error!("Error daemonizing: {}", e);
return; // Exit if daemonization fails
}
}
}
Command::Kill => {
log::info!("🛑 Stopping daemon...");
let pid_file = "ntp_timeturner.pid";
match fs::read_to_string(pid_file) {
Ok(pid_str) => {
let pid_str = pid_str.trim();
log::info!("Found daemon with PID: {}", pid_str);
match std::process::Command::new("kill").arg("-9").arg(format!("-{}", pid_str)).status() {
Ok(status) => {
if status.success() {
log::info!("✅ Daemon stopped successfully.");
if fs::remove_file(pid_file).is_err() {
log::warn!("Could not remove PID file '{}'. It may need to be removed manually.", pid_file);
}
} else {
log::error!("'kill' command failed with status: {}. The daemon may not be running, or you may not have permission to stop it.", status);
log::warn!("Attempting to remove stale PID file '{}'...", pid_file);
if fs::remove_file(pid_file).is_ok() {
log::info!("Removed stale PID file.");
} else {
log::warn!("Could not remove PID file.");
}
}
}
Err(e) => {
log::error!("Failed to execute 'kill' command. Is 'kill' in your PATH? Error: {}", e);
}
}
}
Err(_) => {
log::error!("Could not read PID file '{}'. Is the daemon running in this directory?", pid_file);
}
}
return;
}
}
}
// 🔄 Ensure there's always a config.yml present
ensure_config();
// 1⃣ Start watching config.json for changes
let hw_offset = watch_config("config.json");
println!("🔧 Watching config.json (hardware_offset_ms)...");
// 1⃣ Start watching config.yml for changes
let config = watch_config("config.yml");
// 2⃣ Channel for raw LTC frames
let (tx, rx) = mpsc::channel();
println!("✅ Channel created");
// 3⃣ Shared state for UI and serial reader
let ltc_state = Arc::new(Mutex::new(LtcState::new()));
println!("✅ State initialised");
// 4⃣ Spawn the serial reader thread (no offset here)
// 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 || {
println!("🚀 Serial thread launched");
start_serial_thread(
"/dev/ttyACM0",
&port_clone,
115200,
tx_clone,
state_clone,
@ -62,20 +193,210 @@ fn main() {
});
}
// 5⃣ Spawn the UI renderer thread, passing the live offset Arc
{
// 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() {
// --- 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 offset_clone = hw_offset.clone();
let port = "/dev/ttyACM0".to_string();
let config_clone = config.clone();
let port = serial_port_path;
thread::spawn(move || {
println!("🖥️ UI thread launched");
start_ui(ui_state, port, offset_clone);
start_ui(ui_state, port, config_clone);
});
} else {
// --- 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⃣ 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));
}
});
}
// 6⃣ Keep main thread alive
println!("📡 Main thread entering loop...");
for _frame in rx {
// no-op
// 7⃣ Set up a LocalSet for the API server and main loop
let local = LocalSet::new();
local
.run_until(async move {
// 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, log_buffer_clone).await
{
log::error!("API server error: {}", e);
}
});
}
// 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. The logic_task runs in the background.
std::future::pending::<()>().await;
} else {
// 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;
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::Path;
/// 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) {
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::new(); // Cleanup when _guard goes out of scope.
// --- Test 1: File creation ---
// Pre-condition: config.yml does not exist.
let _ = fs::remove_file("config.yml");
ensure_config();
// Post-condition: config.yml exists and has default content.
let p = Path::new("config.yml");
assert!(p.exists(), "config.yml should have been created");
let contents = fs::read_to_string(p).expect("Failed to read created config.yml");
assert_eq!(contents, DEFAULT_CONFIG.trim(), "config.yml content should match default");
// --- Test 2: File is not overwritten ---
// Pre-condition: config.yml exists with different content.
let custom_content = "hardwareOffsetMs: 999";
fs::write("config.yml", custom_content)
.expect("Failed to write custom config.yml for test");
ensure_config();
// Post-condition: config.yml still has the custom content.
let contents_after = fs::read_to_string("config.yml")
.expect("Failed to read config.yml after second ensure_config call");
assert_eq!(contents_after, custom_content, "config.yml should not be overwritten");
}
}

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();
@ -54,3 +54,125 @@ pub fn start_serial_thread(
}
}
}
#[cfg(test)]
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",
).unwrap()
}
#[test]
fn test_process_lock_line() {
let (tx, rx) = mpsc::channel();
let state = Arc::new(Mutex::new(LtcState::new()));
let re = get_ltc_regex();
let line = "[LOCK] 10:20:30:00 | 25.00fps";
// Simulate the processing logic from start_serial_thread
if let Some(caps) = re.captures(line) {
let arrival = Utc::now();
if let Some(frame) = LtcFrame::from_regex(&caps, arrival) {
{
let mut st = state.lock().unwrap();
st.update(frame.clone());
}
let _ = tx.send(frame);
}
}
let st = state.lock().unwrap();
assert_eq!(st.lock_count, 1);
assert_eq!(st.free_count, 0);
let received_frame = rx.try_recv().unwrap();
assert_eq!(received_frame.status, "LOCK");
assert_eq!(received_frame.hours, 10);
}
#[test]
fn test_process_free_line() {
let (tx, rx) = mpsc::channel();
let state = Arc::new(Mutex::new(LtcState::new()));
let re = get_ltc_regex();
let line = "[FREE] 01:02:03:04 | 29.97fps";
// Simulate the processing logic
if let Some(caps) = re.captures(line) {
let arrival = Utc::now();
if let Some(frame) = LtcFrame::from_regex(&caps, arrival) {
{
let mut st = state.lock().unwrap();
st.update(frame.clone());
}
let _ = tx.send(frame);
}
}
let st = state.lock().unwrap();
assert_eq!(st.lock_count, 0);
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, Ratio::new(30000, 1001));
}
#[test]
fn test_ignore_non_matching_line() {
let (tx, rx) = mpsc::channel();
let state = Arc::new(Mutex::new(LtcState::new()));
let re = get_ltc_regex();
let line = "this is not a valid ltc line";
// Simulate the processing logic
if let Some(caps) = re.captures(line) {
let arrival = Utc::now();
if let Some(frame) = LtcFrame::from_regex(&caps, arrival) {
{
let mut st = state.lock().unwrap();
st.update(frame.clone());
}
let _ = tx.send(frame);
}
}
let st = state.lock().unwrap();
assert_eq!(st.lock_count, 0);
assert_eq!(st.free_count, 0);
assert!(rx.try_recv().is_err());
}
#[test]
fn test_ignore_line_with_bad_parseable_data() {
let (tx, rx) = mpsc::channel();
let state = Arc::new(Mutex::new(LtcState::new()));
let re = get_ltc_regex();
// The regex will match, but `from_regex` should fail to parse "1.2.3.4" as f64
let line = "[LOCK] 10:20:30:00 | 1.2.3.4fps";
// Simulate the processing logic
if let Some(caps) = re.captures(line) {
let arrival = Utc::now();
if let Some(frame) = LtcFrame::from_regex(&caps, arrival) {
{
let mut st = state.lock().unwrap();
st.update(frame.clone());
}
let _ = tx.send(frame);
}
} else {
panic!("Regex should have matched");
}
let st = state.lock().unwrap();
assert_eq!(st.lock_count, 0);
assert_eq!(st.free_count, 0);
assert!(rx.try_recv().is_err());
}
}

View file

@ -1,7 +1,22 @@
use chrono::{DateTime, Local, Timelike, Utc};
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,
@ -9,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
}
@ -20,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,
})
}
@ -41,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,
}
@ -54,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,
}
@ -68,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.
@ -81,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() {
@ -107,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();
}
_ => {}
@ -129,21 +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
}
}
/// Average timecode Δ over stored history, in ms.
pub fn average_clock_delta(&self) -> i64 {
if self.clock_delta_history.is_empty() {
0
} else {
let sum: i64 = self.clock_delta_history.iter().sum();
sum / self.clock_delta_history.len() as i64
}
/// 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.
@ -161,10 +171,33 @@ impl LtcState {
&self.last_match_status
}
}
pub fn get_sync_status(delta_ms: i64, config: &Config) -> &'static str {
if config.timeturner_offset.is_active() {
"TIMETURNING"
} else if delta_ms.abs() <= 8 {
"IN SYNC"
} else if delta_ms > 10 {
"CLOCK AHEAD"
} else {
"CLOCK BEHIND"
}
}
pub fn get_jitter_status(jitter_ms: i64) -> &'static str {
if jitter_ms.abs() < 10 {
"GOOD"
} else if jitter_ms.abs() < 40 {
"AVERAGE"
} else {
"BAD"
}
}
// This module provides the logic for handling LTC (Linear Timecode) frames and maintaining state.
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{Config, TimeturnerOffset};
use chrono::{Local, Utc};
fn get_test_frame(status: &str, h: u32, m: u32, s: u32) -> LtcFrame {
@ -174,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(),
}
}
@ -291,4 +325,63 @@ mod tests {
"Status should update after throttle period"
);
}
#[test]
fn test_ewma_clock_delta() {
let mut state = LtcState::new();
assert_eq!(state.get_ewma_clock_delta(), 0);
// First value initializes the EWMA
state.record_and_update_ewma_clock_delta(100);
assert_eq!(state.get_ewma_clock_delta(), 100);
// 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);
// 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]
fn test_get_sync_status() {
let mut config = Config::default();
assert_eq!(get_sync_status(0, &config), "IN SYNC");
assert_eq!(get_sync_status(8, &config), "IN SYNC");
assert_eq!(get_sync_status(-8, &config), "IN SYNC");
assert_eq!(get_sync_status(9, &config), "CLOCK BEHIND");
assert_eq!(get_sync_status(10, &config), "CLOCK BEHIND");
assert_eq!(get_sync_status(11, &config), "CLOCK AHEAD");
assert_eq!(get_sync_status(-9, &config), "CLOCK BEHIND");
assert_eq!(get_sync_status(-100, &config), "CLOCK BEHIND");
// 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");
}
#[test]
fn test_get_jitter_status() {
assert_eq!(get_jitter_status(5), "GOOD");
assert_eq!(get_jitter_status(-5), "GOOD");
assert_eq!(get_jitter_status(9), "GOOD");
assert_eq!(get_jitter_status(10), "AVERAGE");
assert_eq!(get_jitter_status(39), "AVERAGE");
assert_eq!(get_jitter_status(-39), "AVERAGE");
assert_eq!(get_jitter_status(40), "BAD");
assert_eq!(get_jitter_status(-40), "BAD");
}
}

262
src/system.rs Normal file
View file

@ -0,0 +1,262 @@
use crate::config::Config;
use crate::sync_logic::LtcFrame;
use chrono::{DateTime, Duration as ChronoDuration, Local, TimeZone};
use num_rational::Ratio;
use std::process::Command;
/// Check if Chrony is active
pub fn ntp_service_active() -> bool {
#[cfg(target_os = "linux")]
{
if let Ok(output) = Command::new("systemctl").args(&["is-active", "chrony"]).output() {
output.status.success()
&& String::from_utf8_lossy(&output.stdout).trim() == "active"
} else {
false
}
}
#[cfg(not(target_os = "linux"))]
{
// systemctl is not available on non-Linux platforms.
false
}
}
/// Toggle Chrony (not used yet)
#[allow(dead_code)]
pub fn ntp_service_toggle(start: bool) {
#[cfg(target_os = "linux")]
{
let action = if start { "start" } else { "stop" };
let _ = Command::new("systemctl").args(&[action, "chrony"]).status();
}
#[cfg(not(target_os = "linux"))]
{
// No-op on non-Linux.
// The parameter is unused, but the function is dead code anyway.
let _ = start;
}
}
pub fn calculate_target_time(frame: &LtcFrame, config: &Config) -> DateTime<Local> {
let today_local = Local::now().date_naive();
// 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()
.expect("Ambiguous or invalid local time");
// Apply timeturner offset
let offset = &config.timeturner_offset;
dt_local = dt_local
+ ChronoDuration::hours(offset.hours)
+ ChronoDuration::minutes(offset.minutes)
+ ChronoDuration::seconds(offset.seconds);
// Frame offset needs to be converted to milliseconds
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, ()> {
let dt_local = calculate_target_time(frame, config);
#[cfg(target_os = "linux")]
let (ts, success) = {
let ts = dt_local.format("%H:%M:%S.%3f").to_string();
let success = Command::new("sudo")
.arg("date")
.arg("-s")
.arg(&ts)
.status()
.map(|s| s.success())
.unwrap_or(false);
(ts, success)
};
#[cfg(target_os = "macos")]
let (ts, success) = {
// macOS `date` command format is `mmddHHMMccyy.SS`
let ts = dt_local.format("%m%d%H%M%y.%S").to_string();
let success = Command::new("sudo")
.arg("date")
.arg(&ts)
.status()
.map(|s| s.success())
.unwrap_or(false);
(ts, success)
};
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
let (ts, success) = {
// Unsupported OS, always fail
let ts = dt_local.format("%H:%M:%S.%3f").to_string();
eprintln!("Unsupported OS for time synchronization");
(ts, false)
};
if success {
Ok(ts)
} else {
Err(())
}
}
pub fn 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 {
LtcFrame {
status: "LOCK".to_string(),
hours: h,
minutes: m,
seconds: s,
frames: f,
is_drop_frame: false,
frame_rate: Ratio::new(25, 1),
timestamp: Utc::now(),
}
}
#[test]
fn test_ntp_service_active_on_non_linux() {
// On non-Linux platforms, this should always be false.
#[cfg(not(target_os = "linux"))]
assert!(!ntp_service_active());
}
#[test]
fn test_calculate_target_time_no_offset() {
let frame = get_test_frame(10, 20, 30, 0);
let config = Config::default();
let target_time = calculate_target_time(&frame, &config);
assert_eq!(target_time.hour(), 10);
assert_eq!(target_time.minute(), 20);
assert_eq!(target_time.second(), 30);
}
#[test]
fn test_calculate_target_time_with_positive_offset() {
let frame = get_test_frame(10, 20, 30, 0);
let mut config = Config::default();
config.timeturner_offset = TimeturnerOffset {
hours: 1,
minutes: 5,
seconds: 10,
frames: 12, // 12 frames at 25fps is 480ms
milliseconds: 20,
};
let target_time = calculate_target_time(&frame, &config);
assert_eq!(target_time.hour(), 11);
assert_eq!(target_time.minute(), 25);
assert_eq!(target_time.second(), 40);
// 480ms + 20ms = 500ms
assert_eq!(target_time.nanosecond(), 500_000_000);
}
#[test]
fn test_calculate_target_time_with_negative_offset() {
let frame = get_test_frame(10, 20, 30, 12); // 12 frames = 480ms
let mut config = Config::default();
config.timeturner_offset = TimeturnerOffset {
hours: -1,
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(), 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());
}
}

156
src/ui.rs
View file

@ -1,6 +1,6 @@
use std::{
io::{stdout, Write},
process::{self, Command},
process::{self},
sync::{Arc, Mutex},
thread,
time::{Duration, Instant},
@ -9,7 +9,6 @@ use std::collections::VecDeque;
use chrono::{
DateTime, Local, Timelike, Utc,
NaiveTime, TimeZone,
};
use crossterm::{
cursor::{Hide, MoveTo, Show},
@ -19,47 +18,35 @@ use crossterm::{
terminal::{self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen},
};
use crate::config::Config;
use crate::sync_logic::{get_jitter_status, get_sync_status, LtcState};
use crate::system;
use get_if_addrs::get_if_addrs;
use crate::sync_logic::LtcState;
use num_rational::Ratio;
use num_traits::ToPrimitive;
/// Check if Chrony is active
fn ntp_service_active() -> bool {
if let Ok(output) = Command::new("systemctl").args(&["is-active", "chrony"]).output() {
output.status.success()
&& String::from_utf8_lossy(&output.stdout).trim() == "active"
} else {
false
}
}
/// Toggle Chrony (not used yet)
#[allow(dead_code)]
fn ntp_service_toggle(start: bool) {
let action = if start { "start" } else { "stop" };
let _ = Command::new("systemctl").args(&[action, "chrony"]).status();
}
pub fn start_ui(
state: Arc<Mutex<LtcState>>,
serial_port: String,
offset: Arc<Mutex<i64>>,
config: Arc<Mutex<Config>>,
) {
let mut stdout = stdout();
execute!(stdout, EnterAlternateScreen, Hide).unwrap();
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;
loop {
// 1⃣ hardware offset
let hw_offset_ms = *offset.lock().unwrap();
// 1⃣ config
let cfg = config.lock().unwrap().clone();
let hw_offset_ms = cfg.hardware_offset_ms;
// 2⃣ Chrony + interfaces
let ntp_active = ntp_service_active();
let ntp_active = system::ntp_service_active();
let interfaces: Vec<String> = get_if_addrs()
.unwrap_or_default()
.into_iter()
@ -67,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() {
@ -77,24 +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)
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 dt_local = Local
.from_local_datetime(&naive_dt_local)
.single()
.expect("Invalid local time");
let delta_ms = (Local::now() - dt_local).num_milliseconds();
st.record_clock_delta(delta_ms);
} else {
st.clear_offsets();
st.clear_clock_deltas();
}
}
}
@ -107,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(),
)
};
@ -115,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;
}
@ -124,58 +94,9 @@ pub fn start_ui(
}
// 6⃣ sync status wording
let sync_status = if cached_delta_ms.abs() <= 8 {
"IN SYNC"
} else if cached_delta_ms > 10 {
"CLOCK AHEAD"
} else {
"CLOCK BEHIND"
};
let sync_status = get_sync_status(cached_delta_ms, &cfg);
// 7⃣ autosync (same as manual but delayed)
if sync_status != "IN SYNC" {
if let Some(start) = out_of_sync_since {
if start.elapsed() >= Duration::from_secs(5) {
if let Some(frame) = &state.lock().unwrap().latest {
let today_local = Local::now().date_naive();
let ms = ((frame.frames as f64 / frame.frame_rate) * 1000.0)
.round() as u32;
let timecode = NaiveTime::from_hms_milli_opt(
frame.hours, frame.minutes, frame.seconds, ms,
).expect("Invalid LTC timecode");
let naive_dt = today_local.and_time(timecode);
let dt_local = Local
.from_local_datetime(&naive_dt)
.single()
.expect("Ambiguous or invalid local time");
let ts = dt_local.format("%H:%M:%S.%3f").to_string();
let success = Command::new("sudo")
.arg("date")
.arg("-s")
.arg(&ts)
.status()
.map(|s| s.success())
.unwrap_or(false);
let entry = if success {
format!("🔄 Autosynced to LTC: {}", ts)
} else {
"❌ 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();
@ -186,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(),
};
@ -238,6 +159,8 @@ pub fn start_ui(
// sync status
let scol = if sync_status == "IN SYNC" {
Color::Green
} else if sync_status == "TIMETURNING" {
Color::Cyan
} else {
Color::Red
};
@ -249,13 +172,7 @@ pub fn start_ui(
).unwrap();
// jitter & lock ratio
let jstatus = if avg_jitter_ms.abs() < 10 {
"GOOD"
} else if avg_jitter_ms.abs() < 40 {
"AVERAGE"
} else {
"BAD"
};
let jstatus = get_jitter_status(avg_jitter_ms);
let jcol = if jstatus == "GOOD" {
Color::Green
} else if jstatus == "AVERAGE" {
@ -298,31 +215,9 @@ pub fn start_ui(
}
KeyCode::Char(c) if c.eq_ignore_ascii_case(&'s') => {
if let Some(frame) = &state.lock().unwrap().latest {
let today_local = Local::now().date_naive();
let ms = ((frame.frames as f64 / frame.frame_rate) * 1000.0)
.round() as u32;
let timecode = NaiveTime::from_hms_milli_opt(
frame.hours, frame.minutes, frame.seconds, ms,
).expect("Invalid LTC timecode");
let naive_dt = today_local.and_time(timecode);
let dt_local = Local
.from_local_datetime(&naive_dt)
.single()
.expect("Ambiguous or invalid local time");
let ts = dt_local.format("%H:%M:%S.%3f").to_string();
let success = Command::new("sudo")
.arg("date")
.arg("-s")
.arg(&ts)
.status()
.map(|s| s.success())
.unwrap_or(false);
let entry = if success {
format!("✔ Synced exactly to LTC: {}", ts)
} else {
"❌ date cmd failed".into()
let entry = match system::trigger_sync(frame, &cfg) {
Ok(ts) => format!("✔ Synced exactly to LTC: {}", ts),
Err(_) => "❌ date cmd failed".into(),
};
if logs.len() == 10 { logs.pop_front(); }
logs.push_back(entry);
@ -336,3 +231,4 @@ pub fn start_ui(
thread::sleep(Duration::from_millis(25));
}
}

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%.' }
}
};

141
static/index.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>Fetch | Hachi</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" 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.' ]
}
};

443
static/script.js Normal file
View file

@ -0,0 +1,443 @@
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'),
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');
const nudgeDownButton = document.getElementById('nudge-down');
const nudgeUpButton = document.getElementById('nudge-up');
const nudgeValueInput = document.getElementById('nudge-value');
const nudgeMessage = document.getElementById('nudge-message');
const dateInput = document.getElementById('date-input');
const setDateButton = document.getElementById('set-date');
const dateMessage = document.getElementById('date-message');
// --- Collapsible Sections ---
const controlsToggle = document.getElementById('controls-toggle');
const controlsContent = document.getElementById('controls-content');
const logsToggle = document.getElementById('logs-toggle');
const logsContent = document.getElementById('logs-content');
// --- Mock Controls Setup ---
const mockControls = document.getElementById('mock-controls');
const mockDataSelector = document.getElementById('mock-data-selector');
function setupMockControls() {
if (useMockData) {
mockControls.style.display = 'block';
// Populate dropdown
Object.keys(mockApiDataSets).forEach(key => {
const option = document.createElement('option');
option.value = key;
option.textContent = key.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase());
mockDataSelector.appendChild(option);
});
mockDataSelector.value = currentMockSetKey;
// Handle selection change
mockDataSelector.addEventListener('change', (event) => {
currentMockSetKey = event.target.value;
// Re-fetch all data from the new mock set
fetchStatus();
fetchConfig();
fetchLogs();
});
}
}
function updateStatus(data) {
const ltcStatus = data.ltc_status || 'UNKNOWN';
const ltcIconInfo = iconMap.ltcStatus[ltcStatus] || iconMap.ltcStatus.default;
statusElements.ltcStatus.innerHTML = `<img src="${ltcIconInfo.src}" class="status-icon" alt="" title="${ltcIconInfo.tooltip}">`;
statusElements.ltcStatus.className = ltcStatus.toLowerCase();
statusElements.ltcTimecode.textContent = data.ltc_timecode;
const frameRate = data.frame_rate || 'unknown';
const frameRateIconInfo = iconMap.frameRate[frameRate] || iconMap.frameRate.default;
statusElements.frameRate.innerHTML = `<img src="${frameRateIconInfo.src}" class="status-icon" alt="" title="${frameRateIconInfo.tooltip}">`;
const lockRatio = data.lock_ratio;
let lockRatioCategory;
if (lockRatio === 100) {
lockRatioCategory = 'good';
} else if (lockRatio >= 90) {
lockRatioCategory = 'average';
} else {
lockRatioCategory = 'bad';
}
const lockRatioIconInfo = iconMap.lockRatio[lockRatioCategory];
statusElements.lockRatio.innerHTML = `<img src="${lockRatioIconInfo.src}" class="status-icon" alt="" title="${lockRatioIconInfo.tooltip}">`;
statusElements.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);
}
}
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,
minutes: parseInt(offsetInputs.m.value, 10) || 0,
seconds: parseInt(offsetInputs.s.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',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
});
if (!response.ok) throw new Error('Failed to save config');
alert('Configuration saved.');
} catch (error) {
console.error('Error saving config:', error);
alert('Error saving configuration.');
}
}
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();
if (response.ok) {
syncMessage.textContent = `Success: ${data.message}`;
} else {
syncMessage.textContent = `Error: ${data.message}`;
}
} catch (error) {
console.error('Error triggering sync:', error);
syncMessage.textContent = 'Failed to send sync command.';
}
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 if not using mock data
if (!useMockData) {
setInterval(fetchStatus, 2000);
setInterval(fetchLogs, 2000);
}
setInterval(animateClocks, 50); // High-frequency clock animation
});

273
static/style.css Normal file
View file

@ -0,0 +1,273 @@
@font-face {
font-family: 'FuturaStdHeavy';
src: url('assets/FuturaStdHeavy.otf') format('opentype');
}
@font-face {
font-family: 'Quartz';
src: url('assets/quartz-ms-regular.ttf') format('truetype');
}
body {
font-family: 'FuturaStdHeavy', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background-color: #221f1f;
background-image: url('assets/HaveBlueTransWh.png');
background-repeat: no-repeat;
background-position: bottom 20px right 20px;
background-attachment: fixed;
background-size: 100px;
color: #333;
margin: 0;
padding: 20px;
display: flex;
justify-content: center;
}
.container {
width: 100%;
max-width: 960px;
}
.header-logo {
display: block;
margin: 0 auto 20px auto;
max-width: 60%;
}
.grid {
display: grid;
grid-template-columns: 1fr;
gap: 20px;
}
.card {
background: #c5ced6;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.card h2 {
margin-top: 0;
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;
}
.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 {
grid-column: 1 / -1;
}
.control-group {
margin-bottom: 15px;
display: flex;
align-items: center;
gap: 10px;
}
input[type="number"],
input[type="text"] {
padding: 8px;
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: #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: #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 {
font-style: italic;
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; }
#sync-status.timeturning { font-weight: bold; color: #17a2b8; }
#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"