PR chage requests, revision to GPS logging feature, code cleanup

This commit is contained in:
Carlos Guerra
2026-04-08 19:00:16 +02:00
committed by Will Greenberg
parent dbe102e366
commit 0b91a6e5d3
8 changed files with 192 additions and 39 deletions

View File

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

View File

@@ -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::<f64>()
Value::String(s) => s
.trim()
.parse::<f64>()
.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<FixedOffset> {
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<FixedOffset> {
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<GpsRecord> {
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<Arc<ServerState>>,
) -> Result<Json<GpsData>, StatusCode> {
pub async fn get_gps(State(state): State<Arc<ServerState>>) -> Result<Json<GpsData>, StatusCode> {
let gps = state.gps_state.read().await;
match gps.as_ref() {
Some(data) => Ok(Json(data.clone())),

View File

@@ -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");

View File

@@ -75,6 +75,7 @@ pub(crate) async fn load_gps_records_for_entry(
) -> Vec<GpsRecord> {
// 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<GpsPoint> {
fn find_nearest_gps(records: &[GpsRecord], packet_unix_ts: i64) -> Option<GpsPoint> {
if records.is_empty() {
return None;
}
@@ -106,9 +121,79 @@ fn find_nearest_gps(records: &[GpsRecord], packet_unix_ts: u32) -> Option<GpsPoi
&records[records.len() - 1]
} else {
let (before, after) = (&records[idx - 1], &records[idx]);
if packet_unix_ts - before.unix_ts <= after.unix_ts - packet_unix_ts { before } else { after }
let before_delta = packet_unix_ts - before.unix_ts;
let after_delta = after.unix_ts - packet_unix_ts;
if before_delta <= after_delta {
before
} else {
after
}
};
Some(GpsPoint { latitude: record.lat, longitude: record.lon, unix_ts: record.unix_ts })
Some(GpsPoint {
latitude: record.lat,
longitude: record.lon,
unix_ts: record.unix_ts,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn rec(unix_ts: i64, lat: f64, lon: f64) -> 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<R, W>(
@@ -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())

View File

@@ -105,9 +105,7 @@ impl ManifestEntry {
}
pub fn get_gps_filepath<P: AsRef<Path>>(&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))
}
}

View File

@@ -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.

View File

@@ -70,12 +70,10 @@
>
</p>
{/if}
{#if entry.gps_mode !== undefined}
<p>
<b>GPS Mode:</b>
{entry.gps_mode === 0 ? 'Disabled' : entry.gps_mode === 1 ? 'Fixed coordinates' : 'API endpoint'}
</p>
{/if}
<p>
<b>GPS Mode:</b>
{(entry.gps_mode ?? 0) === 0 ? 'Disabled' : entry.gps_mode === 1 ? 'Fixed coordinates' : 'API endpoint'}
</p>
</div>
{#if metadata && metadata.analyzers}
<div>

View File

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