Merge pull request #2 from Johnr24/withmergeresolve

Feat: Add CI Workflow, Unit Tests, and Fix Sync Logic, update two dependencies to most recent version,
This commit is contained in:
Chaos Rogers 2025-07-21 14:25:39 +01:00 committed by GitHub
commit d55d64ee5f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 747 additions and 516 deletions

6
.github/dependabot.yml vendored Normal file
View file

@ -0,0 +1,6 @@
version: 2
updates:
- package-ecosystem: "cargo"
directory: "/" # Location of Cargo.toml
schedule:
interval: "weekly"

70
.github/workflows/build.yml vendored Normal file
View file

@ -0,0 +1,70 @@
name: Build for Raspberry Pi
on:
push:
branches:
- main
pull_request:
branches:
- main
env:
CARGO_TERM_COLOR: always
# Target for 64-bit Raspberry Pi (Raspberry Pi OS)
RUST_TARGET: aarch64-unknown-linux-gnu
jobs:
build:
name: Build for aarch64
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ env.RUST_TARGET }}
- name: Install cross-compilation dependencies
run: |
sudo dpkg --add-architecture arm64
# Configure sources for ARM64 packages - all ARM64 packages come from ports.ubuntu.com
sudo tee /etc/apt/sources.list.d/arm64.list > /dev/null <<'EOF'
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports jammy main restricted universe multiverse
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports jammy-updates main restricted universe multiverse
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports jammy-backports main restricted universe multiverse
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports jammy-security main restricted universe multiverse
EOF
# Modify existing sources to exclude arm64 architecture
sudo sed -i 's/^deb /deb [arch=amd64] /' /etc/apt/sources.list
sudo apt-get update -y
# Install build tools and cross-compilation libraries for Raspberry Pi 5
sudo apt-get install -y gcc-aarch64-linux-gnu libudev-dev:arm64 pkg-config cmake libudev-dev
# Ensure pkg-config can find ARM64 libraries
sudo apt-get install -y libpkgconf3:arm64
- name: Install Rust dependencies
run: cargo fetch --target ${{ env.RUST_TARGET }}
- name: Build release binary
run: cargo build --release --target ${{ env.RUST_TARGET }}
env:
# Set linker for the target
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc
# Configure pkg-config for cross-compilation
PKG_CONFIG_ALLOW_CROSS: 1
PKG_CONFIG_PATH: /usr/lib/aarch64-linux-gnu/pkgconfig
PKG_CONFIG_LIBDIR: /usr/lib/aarch64-linux-gnu/pkgconfig
PKG_CONFIG_SYSROOT_DIR: /
PKG_CONFIG_ALLOW_SYSTEM_LIBS: 1
PKG_CONFIG_ALLOW_SYSTEM_CFLAGS: 1
# Add library path for the cross-compiler's linker
RUSTFLAGS: -L/usr/lib/aarch64-linux-gnu
- name: Run tests on native platform
run: cargo test --release --bin ntp_timeturner
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: timeturner-aarch64
path: target/${{ env.RUST_TARGET }}/release/ntp_timeturner

18
.gitignore vendored
View file

@ -359,3 +359,21 @@ MigrationBackup/
# Fody - auto-generated XML schema # Fody - auto-generated XML schema
FodyWeavers.xsd FodyWeavers.xsd
.aider*
# Generated by Cargo
# will have compiled files and executables
debug
target
# These are backup files generated by rustfmt
**/*.rs.bk
# MSVC Windows builds of rustc generate these, which store debugging information
# Generated by cargo mutants
# Contains mutation testing data
**/mutants.out*/
# RustRover
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
cargo.lock

View file

@ -6,9 +6,10 @@ edition = "2021"
[dependencies] [dependencies]
serialport = "4.2" serialport = "4.2"
chrono = "0.4" chrono = "0.4"
crossterm = "0.27" crossterm = "0.29"
regex = "1.11" regex = "1.11"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0.141"
notify = "5.1.0" notify = "8.1.0"
get_if_addrs = "0.5" get_if_addrs = "0.5"

View file

@ -28,8 +28,13 @@ Inspired by the TimeTurner in the Harry Potter series, this project synchronises
--- ---
## 🚀 Installation ## 🚀 Installation (to update)
For Rust install you can do
```bash
cargo install --git https://github.com/cjfranko/NTP-Timeturner
```
Clone and run the installer: Clone and run the installer:
```bash ```bash

View file

@ -91,6 +91,18 @@ impl LtcState {
match frame.status.as_str() { match frame.status.as_str() {
"LOCK" => { "LOCK" => {
self.lock_count += 1; self.lock_count += 1;
// Recompute timecode-match every 5 seconds
let now_secs = Utc::now().timestamp();
if now_secs - self.last_match_check >= 5 {
self.last_match_status = if frame.matches_system_time() {
"IN SYNC"
} else {
"OUT OF SYNC"
}
.into();
self.last_match_check = now_secs;
}
} }
"FREE" => { "FREE" => {
self.free_count += 1; self.free_count += 1;
@ -101,18 +113,6 @@ impl LtcState {
_ => {} _ => {}
} }
// Recompute timecode-match every 5 seconds
let now_secs = Utc::now().timestamp();
if now_secs - self.last_match_check >= 5 {
self.last_match_status = if let Some(frame) = &self.latest {
if frame.matches_system_time() { "IN SYNC" } else { "OUT OF SYNC" }
} else {
"UNKNOWN"
}
.into();
self.last_match_check = now_secs;
}
self.latest = Some(frame); self.latest = Some(frame);
} }
@ -161,3 +161,134 @@ impl LtcState {
&self.last_match_status &self.last_match_status
} }
} }
// This module provides the logic for handling LTC (Linear Timecode) frames and maintaining state.
#[cfg(test)]
mod tests {
use super::*;
use chrono::{Local, Utc};
fn get_test_frame(status: &str, h: u32, m: u32, s: u32) -> LtcFrame {
LtcFrame {
status: status.to_string(),
hours: h,
minutes: m,
seconds: s,
frames: 0,
frame_rate: 25.0,
timestamp: Utc::now(),
}
}
#[test]
fn test_ltc_frame_matches_system_time() {
let now = Local::now();
let frame = get_test_frame("LOCK", now.hour(), now.minute(), now.second());
assert!(frame.matches_system_time());
}
#[test]
fn test_ltc_frame_does_not_match_system_time() {
let now = Local::now();
// Create a time that is one hour ahead, wrapping around 23:00
let different_hour = (now.hour() + 1) % 24;
let frame = get_test_frame("LOCK", different_hour, now.minute(), now.second());
assert!(!frame.matches_system_time());
}
#[test]
fn test_ltc_state_update_lock() {
let mut state = LtcState::new();
let frame = get_test_frame("LOCK", 10, 20, 30);
state.update(frame);
assert_eq!(state.lock_count, 1);
assert_eq!(state.free_count, 0);
assert!(state.latest.is_some());
}
#[test]
fn test_ltc_state_update_free() {
let mut state = LtcState::new();
state.record_offset(100);
assert!(!state.offset_history.is_empty());
let frame = get_test_frame("FREE", 10, 20, 30);
state.update(frame);
assert_eq!(state.lock_count, 0);
assert_eq!(state.free_count, 1);
assert!(state.offset_history.is_empty()); // Offsets should be cleared
assert_eq!(state.last_match_status, "UNKNOWN");
}
#[test]
fn test_offset_history_management() {
let mut state = LtcState::new();
for i in 0..25 {
state.record_offset(i);
}
assert_eq!(state.offset_history.len(), 20);
assert_eq!(*state.offset_history.front().unwrap(), 5); // 0-4 are pushed out
assert_eq!(*state.offset_history.back().unwrap(), 24);
}
#[test]
fn test_timecode_match_status_in_sync() {
let mut state = LtcState::new();
state.last_match_check = 0; // Force check to run
let now = Local::now();
let frame_in_sync = get_test_frame("LOCK", now.hour(), now.minute(), now.second());
state.update(frame_in_sync);
assert_eq!(state.timecode_match(), "IN SYNC");
}
#[test]
fn test_timecode_match_status_out_of_sync() {
let mut state = LtcState::new();
state.last_match_check = 0; // Force check to run
let now = Local::now();
let different_hour = (now.hour() + 1) % 24;
let frame_out_of_sync = get_test_frame("LOCK", different_hour, now.minute(), now.second());
state.update(frame_out_of_sync);
assert_eq!(state.timecode_match(), "OUT OF SYNC");
}
#[test]
fn test_timecode_match_throttling() {
let mut state = LtcState::new();
let now = Local::now();
// First call. With the bug, status becomes UNKNOWN. With fix, OUT OF SYNC.
// The test is written for the fixed behavior.
state.last_match_check = 0;
let frame_out_of_sync =
get_test_frame("LOCK", (now.hour() + 1) % 24, now.minute(), now.second());
state.update(frame_out_of_sync.clone());
assert_eq!(
state.timecode_match(),
"OUT OF SYNC",
"Initial status should be out of sync"
);
// Second call, immediately. Check should be throttled.
// Status should not change, even though we pass an in-sync frame.
let frame_in_sync = get_test_frame("LOCK", now.hour(), now.minute(), now.second());
state.update(frame_in_sync.clone());
assert_eq!(
state.timecode_match(),
"OUT OF SYNC",
"Status should not change due to throttling"
);
// Third call, forcing check to run again.
// Status should now update to IN SYNC.
state.last_match_check = 0;
state.update(frame_in_sync.clone());
assert_eq!(
state.timecode_match(),
"IN SYNC",
"Status should update after throttle period"
);
}
}

View file

@ -1,4 +1,4 @@
// src/ui.rs // src/ui.rs
use std::{ use std::{
io::{stdout, Write}, io::{stdout, Write},
@ -32,8 +32,8 @@ fn ntp_service_active() -> bool {
} }
/// Toggle the Chrony service (start if `start` is true, stop otherwise) /// Toggle the Chrony service (start if `start` is true, stop otherwise)
#[allow(dead_code)]
fn ntp_service_toggle(start: bool) { fn _ntp_service_toggle(start: bool) {
let action = if start { "start" } else { "stop" }; let action = if start { "start" } else { "stop" };
let _ = Command::new("systemctl").args(&[action, "chrony"]).status(); let _ = Command::new("systemctl").args(&[action, "chrony"]).status();
} }