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))); }