From 0b91a6e5d3356bb4ac95ad02443200db73b82f63 Mon Sep 17 00:00:00 2001
From: Carlos Guerra
Date: Wed, 8 Apr 2026 19:00:16 +0200
Subject: [PATCH] PR chage requests, revision to GPS logging feature, code
cleanup
---
daemon/src/diag.rs | 29 ++++-
daemon/src/gps.rs | 52 +++++++--
daemon/src/main.rs | 7 +-
daemon/src/pcap.rs | 108 ++++++++++++++++--
daemon/src/qmdl_store.rs | 4 +-
daemon/src/server.rs | 9 +-
.../src/lib/components/AnalysisView.svelte | 10 +-
lib/src/pcap.rs | 12 +-
8 files changed, 192 insertions(+), 39 deletions(-)
diff --git a/daemon/src/diag.rs b/daemon/src/diag.rs
index 9a309c5..10d5e3a 100644
--- a/daemon/src/diag.rs
+++ b/daemon/src/diag.rs
@@ -12,7 +12,9 @@ use futures::{StreamExt, TryStreamExt, future};
use log::{debug, error, info, warn};
use rayhunter::Device;
use tokio::fs::File;
-use tokio::io::{AsyncBufReadExt, BufReader};
+use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
+
+use crate::gps::GpsRecord;
use tokio::sync::mpsc::{Receiver, Sender};
use tokio::sync::{RwLock, oneshot};
use tokio_stream::wrappers::LinesStream;
@@ -57,6 +59,7 @@ pub struct DiagTask {
min_space_to_start_mb: u64,
min_space_to_continue_mb: u64,
gps_mode: u8,
+ gps_fixed_coords: Option<(f64, f64)>,
state: DiagState,
max_type_seen: EventType,
bytes_since_space_check: usize,
@@ -106,6 +109,7 @@ impl DiagTask {
min_space_to_start_mb: u64,
min_space_to_continue_mb: u64,
gps_mode: u8,
+ gps_fixed_coords: Option<(f64, f64)>,
) -> Self {
Self {
ui_update_sender,
@@ -115,6 +119,7 @@ impl DiagTask {
min_space_to_start_mb,
min_space_to_continue_mb,
gps_mode,
+ gps_fixed_coords,
state: DiagState::Stopped,
max_type_seen: EventType::Informational,
bytes_since_space_check: 0,
@@ -155,6 +160,26 @@ impl DiagTask {
return Err(msg);
}
};
+ // For fixed-mode sessions, write the configured coordinates to the sidecar
+ // immediately so the per-session GPS is stored durably and isn't affected
+ // by future config changes or GPS API calls.
+ if self.gps_mode == 1 {
+ if let Some((lat, lon)) = self.gps_fixed_coords {
+ if let Some((entry_idx, _)) = qmdl_store.get_current_entry() {
+ if let Ok(mut gps_file) = qmdl_store.open_entry_gps_for_append(entry_idx).await
+ {
+ let record = GpsRecord {
+ unix_ts: 0,
+ lat,
+ lon,
+ };
+ if let Ok(json) = serde_json::to_string(&record) {
+ let _ = gps_file.write_all(format!("{json}\n").as_bytes()).await;
+ }
+ }
+ }
+ }
+ }
self.stop_current_recording().await;
let qmdl_writer = QmdlWriter::new(qmdl_file);
let analysis_writer = match AnalysisWriter::new(analysis_file, &self.analyzer_config).await
@@ -385,6 +410,7 @@ pub fn run_diag_read_thread(
min_space_to_start_mb: u64,
min_space_to_continue_mb: u64,
gps_mode: u8,
+ gps_fixed_coords: Option<(f64, f64)>,
) {
task_tracker.spawn(async move {
info!("Using configuration for device: {0:?}", device);
@@ -402,6 +428,7 @@ pub fn run_diag_read_thread(
min_space_to_start_mb,
min_space_to_continue_mb,
gps_mode,
+ gps_fixed_coords,
);
qmdl_file_tx
.send(DiagDeviceCtrlMessage::StartRecording { response_tx: None })
diff --git a/daemon/src/gps.rs b/daemon/src/gps.rs
index dd9db37..44893af 100644
--- a/daemon/src/gps.rs
+++ b/daemon/src/gps.rs
@@ -1,6 +1,8 @@
use axum::Json;
use axum::extract::State;
use axum::http::StatusCode;
+use chrono::{DateTime, FixedOffset, Utc};
+use log::error;
use serde::{Deserialize, Deserializer, Serialize};
use std::sync::Arc;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
@@ -14,13 +16,18 @@ where
use serde::de;
use serde_json::Value;
match Value::deserialize(deserializer)? {
- Value::Number(n) => n.as_i64()
+ Value::Number(n) => n
+ .as_i64()
.or_else(|| n.as_f64().map(|f| f as i64))
.ok_or_else(|| de::Error::custom("timestamp out of range")),
- Value::String(s) => s.trim().parse::()
+ Value::String(s) => s
+ .trim()
+ .parse::()
.map(|f| f as i64)
.map_err(|_| de::Error::custom("timestamp must be a numeric value")),
- _ => Err(de::Error::custom("timestamp must be a number or numeric string")),
+ _ => Err(de::Error::custom(
+ "timestamp must be a number or numeric string",
+ )),
}
}
@@ -32,14 +39,30 @@ pub struct GpsData {
pub timestamp: i64,
}
+impl GpsData {
+ pub fn to_datetime(&self) -> DateTime {
+ DateTime::from_timestamp(self.timestamp, 0)
+ .unwrap_or_default()
+ .fixed_offset()
+ }
+}
+
#[derive(Serialize, Deserialize)]
pub struct GpsRecord {
- pub unix_ts: u32,
+ pub unix_ts: i64,
pub lat: f64,
pub lon: f64,
}
-/// Reads all GPS records from a sidecar file, skipping malformed lines.
+impl GpsRecord {
+ pub fn to_datetime(&self) -> DateTime {
+ DateTime::from_timestamp(self.unix_ts, 0)
+ .unwrap_or_default()
+ .fixed_offset()
+ }
+}
+
+/// Reads all GPS records from a sidecar NDJSON file, skipping malformed lines.
pub async fn load_gps_records(file: tokio::fs::File) -> Vec {
let reader = BufReader::new(file);
let mut lines = reader.lines();
@@ -69,9 +92,18 @@ pub async fn post_gps(
let qmdl_store = state.qmdl_store_lock.read().await;
if let Some((entry_idx, _)) = qmdl_store.get_current_entry() {
if let Ok(mut file) = qmdl_store.open_entry_gps_for_append(entry_idx).await {
- let record = GpsRecord { unix_ts: chrono::Utc::now().timestamp() as u32, lat: gps_data.latitude, lon: gps_data.longitude };
- if let Ok(json) = serde_json::to_string(&record) {
- let _ = file.write_all(format!("{json}\n").as_bytes()).await;
+ let record = GpsRecord {
+ unix_ts: Utc::now().timestamp(),
+ lat: gps_data.latitude,
+ lon: gps_data.longitude,
+ };
+ match serde_json::to_string(&record) {
+ Ok(json) => {
+ if let Err(e) = file.write_all(format!("{json}\n").as_bytes()).await {
+ error!("failed to write GPS record to sidecar: {e}");
+ }
+ }
+ Err(e) => error!("failed to serialize GPS record: {e}"),
}
}
}
@@ -79,9 +111,7 @@ pub async fn post_gps(
Ok(StatusCode::OK)
}
-pub async fn get_gps(
- State(state): State>,
-) -> Result, StatusCode> {
+pub async fn get_gps(State(state): State>) -> Result, StatusCode> {
let gps = state.gps_state.read().await;
match gps.as_ref() {
Some(data) => Ok(Json(data.clone())),
diff --git a/daemon/src/main.rs b/daemon/src/main.rs
index 08c8cd0..549908b 100644
--- a/daemon/src/main.rs
+++ b/daemon/src/main.rs
@@ -21,10 +21,10 @@ use crate::battery::run_battery_notification_worker;
use crate::config::{parse_args, parse_config};
use crate::diag::run_diag_read_thread;
use crate::error::RayhunterError;
+use crate::gps::{get_gps, post_gps};
use crate::notifications::{NotificationService, run_notification_worker};
use crate::pcap::get_pcap;
use crate::qmdl_store::RecordingStore;
-use crate::gps::{get_gps, post_gps};
use crate::server::{
ServerState, debug_set_display_state, get_config, get_qmdl, get_time, get_wifi_status, get_zip,
scan_wifi, serve_static, set_config, set_time_offset, test_notification,
@@ -220,6 +220,10 @@ async fn run_with_config(
if !config.debug_mode {
info!("Starting Diag Thread");
+ let gps_fixed_coords = match (config.gps_fixed_latitude, config.gps_fixed_longitude) {
+ (Some(lat), Some(lon)) => Some((lat, lon)),
+ _ => None,
+ };
run_diag_read_thread(
&task_tracker,
config.device.clone(),
@@ -233,6 +237,7 @@ async fn run_with_config(
config.min_space_to_start_recording_mb,
config.min_space_to_continue_recording_mb,
config.gps_mode,
+ gps_fixed_coords,
);
info!("Starting UI");
diff --git a/daemon/src/pcap.rs b/daemon/src/pcap.rs
index 46417ff..603028f 100644
--- a/daemon/src/pcap.rs
+++ b/daemon/src/pcap.rs
@@ -75,6 +75,7 @@ pub(crate) async fn load_gps_records_for_entry(
) -> Vec {
// Always try the per-session sidecar first — it reflects what was actually
// recorded regardless of what the current gps_mode config is.
+ let entry_gps_mode;
{
let qmdl_store = state.qmdl_store_lock.read().await;
if let Ok(file) = qmdl_store.open_entry_gps(entry_index).await {
@@ -83,19 +84,33 @@ pub(crate) async fn load_gps_records_for_entry(
return records;
}
}
+ // Capture the entry's recorded GPS mode before releasing the lock.
+ entry_gps_mode = qmdl_store
+ .manifest
+ .entries
+ .get(entry_index)
+ .and_then(|e| e.gps_mode);
}
- // Sidecar missing or empty — fall back to current config.
- if state.config.gps_mode == 1 {
- let guard = state.gps_state.read().await;
- return guard
- .as_ref()
- .map(|g| vec![GpsRecord { unix_ts: 0, lat: g.latitude, lon: g.longitude }])
- .unwrap_or_default();
+ // Sidecar missing or empty — fall back using the entry's own recorded GPS mode,
+ // not the current config, so old fixed-mode sessions still get coordinates even
+ // if the mode has since been changed. Use the configured fixed coords directly
+ // rather than gps_state, which can be overwritten by API calls or be None.
+ if entry_gps_mode == Some(1) {
+ if let (Some(lat), Some(lon)) = (
+ state.config.gps_fixed_latitude,
+ state.config.gps_fixed_longitude,
+ ) {
+ return vec![GpsRecord {
+ unix_ts: 0,
+ lat,
+ lon,
+ }];
+ }
}
vec![]
}
-fn find_nearest_gps(records: &[GpsRecord], packet_unix_ts: u32) -> Option {
+fn find_nearest_gps(records: &[GpsRecord], packet_unix_ts: i64) -> Option {
if records.is_empty() {
return None;
}
@@ -106,9 +121,79 @@ fn find_nearest_gps(records: &[GpsRecord], packet_unix_ts: u32) -> Option GpsRecord {
+ GpsRecord { unix_ts, lat, lon }
+ }
+
+ #[test]
+ fn test_empty_returns_none() {
+ assert!(find_nearest_gps(&[], 100).is_none());
+ }
+
+ #[test]
+ fn test_single_record_always_returned() {
+ let records = vec![rec(100, 1.0, 2.0)];
+ assert_eq!(find_nearest_gps(&records, 0).unwrap().unix_ts, 100);
+ assert_eq!(find_nearest_gps(&records, 200).unwrap().unix_ts, 100);
+ }
+
+ #[test]
+ fn test_before_all_records_returns_first() {
+ let records = vec![rec(100, 1.0, 2.0), rec(200, 3.0, 4.0)];
+ assert_eq!(find_nearest_gps(&records, 50).unwrap().unix_ts, 100);
+ }
+
+ #[test]
+ fn test_after_all_records_returns_last() {
+ let records = vec![rec(100, 1.0, 2.0), rec(200, 3.0, 4.0)];
+ assert_eq!(find_nearest_gps(&records, 300).unwrap().unix_ts, 200);
+ }
+
+ #[test]
+ fn test_exact_match() {
+ let records = vec![rec(100, 1.0, 2.0), rec(200, 3.0, 4.0), rec(300, 5.0, 6.0)];
+ assert_eq!(find_nearest_gps(&records, 200).unwrap().unix_ts, 200);
+ }
+
+ #[test]
+ fn test_closer_to_before() {
+ // packet at 130: delta to before(100)=30, delta to after(200)=70 → picks before
+ let records = vec![rec(100, 1.0, 2.0), rec(200, 3.0, 4.0)];
+ assert_eq!(find_nearest_gps(&records, 130).unwrap().unix_ts, 100);
+ }
+
+ #[test]
+ fn test_closer_to_after() {
+ // packet at 170: delta to before(100)=70, delta to after(200)=30 → picks after
+ let records = vec![rec(100, 1.0, 2.0), rec(200, 3.0, 4.0)];
+ assert_eq!(find_nearest_gps(&records, 170).unwrap().unix_ts, 200);
+ }
+
+ #[test]
+ fn test_equidistant_prefers_before() {
+ // packet at 150: delta to before(100)=50, delta to after(200)=50 → tie, picks before
+ let records = vec![rec(100, 1.0, 2.0), rec(200, 3.0, 4.0)];
+ assert_eq!(find_nearest_gps(&records, 150).unwrap().unix_ts, 100);
+ }
}
pub async fn generate_pcap_data(
@@ -135,8 +220,7 @@ where
Ok(msg) => {
let maybe_gsmtap_msg = gsmtap_parser::parse(msg)?;
if let Some((timestamp, gsmtap_msg)) = maybe_gsmtap_msg {
- let packet_unix_ts =
- timestamp.to_datetime().timestamp().max(0) as u32;
+ let packet_unix_ts = timestamp.to_datetime().timestamp();
let gps = find_nearest_gps(&gps_records, packet_unix_ts);
pcap_writer
.write_gsmtap_message(gsmtap_msg, timestamp, gps.as_ref())
diff --git a/daemon/src/qmdl_store.rs b/daemon/src/qmdl_store.rs
index a66cb8f..7a0ef7c 100644
--- a/daemon/src/qmdl_store.rs
+++ b/daemon/src/qmdl_store.rs
@@ -105,9 +105,7 @@ impl ManifestEntry {
}
pub fn get_gps_filepath>(&self, path: P) -> PathBuf {
- let mut filepath = path.as_ref().join(&self.name);
- filepath.set_extension("gps.ndjson");
- filepath
+ path.as_ref().join(format!("{}-gps.ndjson", self.name))
}
}
diff --git a/daemon/src/server.rs b/daemon/src/server.rs
index e4bc204..15bfe9a 100644
--- a/daemon/src/server.rs
+++ b/daemon/src/server.rs
@@ -388,8 +388,13 @@ pub async fn get_zip(
.take(qmdl_size_bytes as u64)
};
- if let Err(e) =
- generate_pcap_data(&mut entry_writer, qmdl_file_for_pcap, qmdl_size_bytes, gps_records).await
+ if let Err(e) = generate_pcap_data(
+ &mut entry_writer,
+ qmdl_file_for_pcap,
+ qmdl_size_bytes,
+ gps_records,
+ )
+ .await
{
// if we fail to generate the PCAP file, we should still continue and give the
// user the QMDL.
diff --git a/daemon/web/src/lib/components/AnalysisView.svelte b/daemon/web/src/lib/components/AnalysisView.svelte
index 82be121..0d5d763 100644
--- a/daemon/web/src/lib/components/AnalysisView.svelte
+++ b/daemon/web/src/lib/components/AnalysisView.svelte
@@ -70,12 +70,10 @@
>
{/if}
- {#if entry.gps_mode !== undefined}
-
- GPS Mode:
- {entry.gps_mode === 0 ? 'Disabled' : entry.gps_mode === 1 ? 'Fixed coordinates' : 'API endpoint'}
-
- {/if}
+
+ GPS Mode:
+ {(entry.gps_mode ?? 0) === 0 ? 'Disabled' : entry.gps_mode === 1 ? 'Fixed coordinates' : 'API endpoint'}
+
{#if metadata && metadata.analyzers}
diff --git a/lib/src/pcap.rs b/lib/src/pcap.rs
index 497ede0..c82d7fb 100644
--- a/lib/src/pcap.rs
+++ b/lib/src/pcap.rs
@@ -30,7 +30,7 @@ pub struct GpsPoint {
pub latitude: f64,
pub longitude: f64,
/// Unix timestamp of the GPS fix. 0 means fixed/synthetic (no real GPS time).
- pub unix_ts: u32,
+ pub unix_ts: i64,
}
pub struct GsmtapPcapWriter
@@ -149,9 +149,15 @@ where
let mut options = vec![];
if let Some(p) = gps {
let comment = if p.unix_ts == 0 {
- format!("GPS fixed lat={:.7} lon={:.7}", p.latitude, p.longitude)
+ format!(
+ r#"{{"latitude":{:.7},"longitude":{:.7}}}"#,
+ p.latitude, p.longitude
+ )
} else {
- format!("GPS lat={:.7} lon={:.7} ts={}", p.latitude, p.longitude, p.unix_ts)
+ format!(
+ r#"{{"latitude":{:.7},"longitude":{:.7},"timestamp":{}}}"#,
+ p.latitude, p.longitude, p.unix_ts
+ )
};
options.push(EnhancedPacketOption::Comment(Cow::Owned(comment)));
}