Compare commits
223 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e8bc9ac5e | ||
|
|
3e423416a8 | ||
|
|
4a07b29728 | ||
|
|
2d46fccfbe | ||
|
|
fdddf4eb76 | ||
|
|
46892884a1 | ||
|
|
04165f2686 | ||
|
|
459e44250e | ||
|
|
604d118d25 | ||
|
|
320174fe53 | ||
|
|
8903d6d006 | ||
|
|
32e785bd88 | ||
|
|
fb4ecc5f2a | ||
|
|
0c51fd77fa | ||
|
|
474e62d487 | ||
|
|
ea55d087b5 | ||
|
|
af6dbcc9a7 | ||
|
|
169c9b9aef | ||
|
|
6221eea98c | ||
|
|
ac035a8e0b | ||
|
|
f2e2fa9c7f | ||
|
|
3c73a0487b | ||
|
|
360e0751f2 | ||
|
|
a764b4d4ad | ||
|
|
63bd17b71e | ||
|
|
7db595259f | ||
|
|
e19b50fe2b | ||
|
|
cc1335f1a9 | ||
|
|
5ca32b6f36 | ||
|
|
1caa09ac46 | ||
|
|
57de9a98a5 | ||
|
|
0e7b583829 | ||
|
|
e4c59b412b | ||
|
|
dad5c2d06a | ||
|
|
baf674edd8 | ||
|
|
762f872e7c | ||
|
|
5eb706601f | ||
|
|
7773e62402 | ||
|
|
24c09fa233 | ||
|
|
7c5b7fe031 | ||
|
|
01c0d0495f | ||
|
|
3f99488ea0 | ||
|
|
e2d48391ea | ||
|
|
8362435e12 | ||
|
|
cd9ac5a141 | ||
|
|
b6a7606e1a | ||
|
|
9c57c32c68 | ||
|
|
c2b1aedaba | ||
|
|
a009dd35c9 | ||
|
|
4d0b4ebae4 | ||
|
|
5d206b564b | ||
|
|
b03d935a9e | ||
|
|
cbacf14ca1 | ||
|
|
22ac073922 | ||
|
|
acab0fbc04 | ||
|
|
048ae41739 | ||
|
|
1075be6e24 | ||
|
|
8e369a2e3a | ||
|
|
af0a512187 | ||
|
|
95fcb6f26a | ||
|
|
b510af2d8d | ||
|
|
cf24c9029e | ||
|
|
89cf0e5d97 | ||
|
|
94687da414 | ||
|
|
02487bda97 | ||
|
|
982aad3ec9 | ||
|
|
49287e5e16 | ||
|
|
f909a90caa | ||
|
|
fad7ddedb5 | ||
|
|
89628b974b | ||
|
|
886006420b | ||
|
|
5b0dcadac2 | ||
|
|
5fee17e1ab | ||
|
|
ba855d520a | ||
|
|
4c5fa69d1d | ||
|
|
54ebc0b242 | ||
|
|
534754be4e | ||
|
|
840fca7bcf | ||
|
|
87e8ae7711 | ||
|
|
4af732dab0 | ||
|
|
fbae58fb1d | ||
|
|
fffc123475 | ||
|
|
ba9b897157 | ||
|
|
9360e0011c | ||
|
|
adae9026ad | ||
|
|
463856a330 | ||
|
|
6726cf393a | ||
|
|
d4ff2568e3 | ||
|
|
3374646de5 | ||
|
|
cfc9a79ab8 | ||
|
|
7e7ca42220 | ||
|
|
e419cd506e | ||
|
|
0baf7588da | ||
|
|
fe9ac76942 | ||
|
|
26dca4fd18 | ||
|
|
8da42b87d0 | ||
|
|
c97d1841b5 | ||
|
|
0ba46fbd71 | ||
|
|
8636ed4ec4 | ||
|
|
f0ac2ed3d4 | ||
|
|
90f43ff87e | ||
|
|
abce5373d7 | ||
|
|
08d664efd1 | ||
|
|
cd922d5403 | ||
|
|
8150241db2 | ||
|
|
8b7e832225 | ||
|
|
80953e7f6d | ||
|
|
dad59ed9ff | ||
|
|
32712d1f3c | ||
|
|
5f35139f3b | ||
|
|
69569c0a01 | ||
|
|
4cdead5aa4 | ||
|
|
d99b57a98a | ||
|
|
1842419f10 | ||
|
|
82fbefce0c | ||
|
|
e4c49a1e78 | ||
|
|
ed48c1284d | ||
|
|
43a3fc7aad | ||
|
|
a4bf025fd0 | ||
|
|
c9c6320abb | ||
|
|
65dd107514 | ||
|
|
3ffb54e9aa | ||
|
|
22dc01e80f | ||
|
|
bda4d4e6f5 | ||
|
|
8453f18a3c | ||
|
|
049a85685c | ||
|
|
d13ffdc057 | ||
|
|
459500e402 | ||
|
|
4ee791c817 | ||
|
|
3d6a106f1e | ||
|
|
a1da396874 | ||
|
|
b71e13d4c4 | ||
|
|
91f8f7dc96 | ||
|
|
c27b4f5dbb | ||
|
|
2c78b20301 | ||
|
|
d2c4f1a4af | ||
|
|
f39db7e67d | ||
|
|
02842c3495 | ||
|
|
6bc1f5ddbf | ||
|
|
58a1d243e4 | ||
|
|
af43388e4b | ||
|
|
584840f1f3 | ||
|
|
3df9466754 | ||
|
|
0745883e0d | ||
|
|
0c6e1b0f43 | ||
|
|
871fd192b0 | ||
| d814b05a26 | |||
| 992720041b | |||
| 68dc16344a | |||
| 9a97027870 | |||
| d015794b03 | |||
| 4cb421b3d6 | |||
| c0613c3682 | |||
| fcbd5bd647 | |||
| f929bacdfd | |||
| 89849c6e04 | |||
| 4090fee0a6 | |||
| fb8088c704 | |||
| c712014bb9 | |||
| 3f953cff2f | |||
| a12ee88b9b | |||
| 917a844874 | |||
| aee69679ef | |||
| 80faf4db9a | |||
| cc782fcd7e | |||
| 6a45660e03 | |||
| 985ccc6819 | |||
| 5a86493824 | |||
| b803de93de | |||
| 7738d14097 | |||
| 4aced3eb48 | |||
|
|
784b3b9be6 | ||
|
|
5c321f5b1e | ||
|
|
1c05ed62d0 | ||
|
|
ec29655ff3 | ||
| 1150fa20c3 | |||
| 7bf45c43c9 | |||
| e243d87018 | |||
| fb45a2e168 | |||
| 2dc82c34cb | |||
| 8864bef1db | |||
| 060cff4089 | |||
| 6ed1fc31e7 | |||
| 6afe6580fb | |||
| ec132a2840 | |||
| cd737b895e | |||
| 4ebe8b597a | |||
| 2ac14c8d5b | |||
| 838082e95a | |||
| 9f39fb3739 | |||
| 56e6071e3a | |||
| d983d632f8 | |||
|
|
c1587e8ce6 | ||
|
|
d9e51888bb | ||
| b2f50be611 | |||
| 183fdc0725 | |||
| b1a0483d6c | |||
| 154c07f613 | |||
| 12065a08c2 | |||
| b854d29015 | |||
| 1d9bc1e25e | |||
| d55a11b074 | |||
| fecfed04e7 | |||
| 777a202877 | |||
| 08577f5064 | |||
| c48ef1cf3f | |||
| 666ce4308f | |||
| 19a7ac14fb | |||
| 03468d7568 | |||
| 0a9f9c6612 | |||
| ac08ffb54f | |||
| aa1973603e | |||
| 32b307b935 | |||
| 6eda9149ca | |||
| 2d6f65046a | |||
| c94e1ea4b0 | |||
| 0325c3b570 | |||
| 8ad553aaee | |||
| a124aae424 | |||
| 3cbe95bd6a | |||
| 0b8fa0fbf8 | |||
| 30fb752cbb | |||
| 39ba5d90bb |
15
.github/workflows/release.yml
vendored
|
|
@ -15,6 +15,14 @@ jobs:
|
|||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libudev-dev pkg-config
|
||||
|
||||
- name: Cache cargo dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
|
|
@ -28,13 +36,6 @@ jobs:
|
|||
restore-keys: |
|
||||
${{ runner.os }}-cargo-
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
profile: minimal
|
||||
override: true
|
||||
|
||||
- name: Build binary
|
||||
run: cargo build --release --verbose
|
||||
|
||||
|
|
|
|||
10
Cargo.toml
|
|
@ -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
|
|
@ -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
|
|
@ -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 stratum 10
|
||||
# Serve the system clock as a reference at stratum 1
|
||||
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
|
|
@ -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.
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
{
|
||||
"hardware_offset_ms": 20
|
||||
}
|
||||
19
config.yml
Normal 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
|
|
@ -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"
|
||||
}
|
||||
```
|
||||
180
firmware/ltc_audiohat_lock.ino
Normal 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;
|
||||
}
|
||||
}
|
||||
5895
firmware/ltc_audiohat_lock.ino_v2.hex
Normal file
174
firmware/ltc_audiohat_lock_v2.ino
Normal 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 → auto‑detect
|
||||
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;
|
||||
|
||||
// free‑run
|
||||
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 1 second worth of frames:
|
||||
int nominal = (currentFps>29.5f) ? 30 : int(currentFps+0.5f);
|
||||
long dayFrames= 24L*3600L*nominal;
|
||||
freeAbsFrame = (freeAbsFrame + nominal) % dayFrames;
|
||||
// reset free‑run 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 free‑run 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
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
}
|
||||
107
src/config.rs
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
381
src/main.rs
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
|
@ -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️⃣ auto‑sync (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!("🔄 Auto‑synced to LTC: {}", ts)
|
||||
} else {
|
||||
"❌ Auto‑sync 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));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
BIN
static/assets/FuturaStdHeavy.otf
Normal file
BIN
static/assets/HaveBlueTransWh.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
static/assets/favicon.png
Normal file
|
After Width: | Height: | Size: 3 KiB |
BIN
static/assets/header.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
static/assets/quartz-ms-regular.ttf
Normal file
BIN
static/assets/timeturner_2398.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
static/assets/timeturner_24.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
static/assets/timeturner_25.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
static/assets/timeturner_2997.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
static/assets/timeturner_2997DF.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
static/assets/timeturner_30.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
static/assets/timeturner_controls.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
static/assets/timeturner_default.png
Normal file
|
After Width: | Height: | Size: 898 B |
BIN
static/assets/timeturner_delta_green.png
Normal file
|
After Width: | Height: | Size: 981 B |
BIN
static/assets/timeturner_delta_orange.png
Normal file
|
After Width: | Height: | Size: 955 B |
BIN
static/assets/timeturner_delta_red.png
Normal file
|
After Width: | Height: | Size: 913 B |
BIN
static/assets/timeturner_jitter_green.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
static/assets/timeturner_jitter_orange.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
static/assets/timeturner_jitter_red.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
static/assets/timeturner_lock_green.png
Normal file
|
After Width: | Height: | Size: 810 B |
BIN
static/assets/timeturner_lock_orange.png
Normal file
|
After Width: | Height: | Size: 805 B |
BIN
static/assets/timeturner_lock_red.png
Normal file
|
After Width: | Height: | Size: 804 B |
BIN
static/assets/timeturner_logs.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
static/assets/timeturner_ltc_green.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
static/assets/timeturner_ltc_orange.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
static/assets/timeturner_ltc_red.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
static/assets/timeturner_network.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
static/assets/timeturner_ntp_green.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
static/assets/timeturner_ntp_orange.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
static/assets/timeturner_ntp_red.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
static/assets/timeturner_sync_green.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
static/assets/timeturner_sync_orange.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
static/assets/timeturner_sync_red.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
static/assets/timeturner_timeturning.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
static/favicon.ico
Normal file
|
After Width: | Height: | Size: 198 KiB |
43
static/icon-map.js
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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; }
|
||||
|
|
@ -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
|
|
@ -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
|
|
@ -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"
|
||||