diff --git a/Cargo.lock b/Cargo.lock index 34868560..29dda59f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4596,6 +4596,7 @@ dependencies = [ "rustls-rustcrypto", "serde", "serde_json", + "serde_repr", "tempfile", "thiserror 1.0.69", "tokio", diff --git a/check/src/main.rs b/check/src/main.rs index 87105341..a449030a 100644 --- a/check/src/main.rs +++ b/check/src/main.rs @@ -158,7 +158,7 @@ async fn pcapify(qmdl_path: &PathBuf) { for msg in container.into_messages().into_iter().flatten() { if let Ok(Some((timestamp, parsed))) = gsmtap_parser::parse(msg) { pcap_writer - .write_gsmtap_message(parsed, timestamp) + .write_gsmtap_message(parsed, timestamp, None) .await .expect("failed to write"); } diff --git a/daemon/Cargo.toml b/daemon/Cargo.toml index a593b895..9bf53cc2 100644 --- a/daemon/Cargo.toml +++ b/daemon/Cargo.toml @@ -24,6 +24,7 @@ rayhunter = { path = "../lib" } wifi-station = { git = "https://github.com/BeigeBox/wifi-station", rev = "e8ec5b4" } toml = "0.8.8" serde = { version = "1.0.193", features = ["derive"] } +serde_repr = "0.1" tokio = { version = "1.44.2", default-features = false, features = ["fs", "signal", "process", "rt"] } axum = { version = "0.8", default-features = false, features = ["http1", "tokio", "json"] } thiserror = "1.0.52" diff --git a/daemon/src/config.rs b/daemon/src/config.rs index 7f66af0f..8e3c64bd 100644 --- a/daemon/src/config.rs +++ b/daemon/src/config.rs @@ -1,10 +1,40 @@ use log::warn; use serde::{Deserialize, Serialize}; +use serde_repr::{Deserialize_repr, Serialize_repr}; use rayhunter::Device; use rayhunter::analysis::analyzer::AnalyzerConfig; use crate::error::RayhunterError; + +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Serialize_repr, Deserialize_repr)] +#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))] +pub enum GpsMode { + Disabled = 0, + Fixed = 1, + Api = 2, +} + +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Serialize_repr, Deserialize_repr)] +#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))] +pub enum UiLevel { + Invisible = 0, + Subtle = 1, + Demo = 2, + EffLogo = 3, + HighVisibility = 4, + TransFlag = 128, +} + +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Serialize_repr, Deserialize_repr)] +#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))] +pub enum KeyInputMode { + Disabled = 0, + DoubleTapPower = 1, +} use crate::notifications::NotificationType; /// The structure of a valid rayhunter configuration @@ -21,11 +51,11 @@ pub struct Config { /// Internal device name pub device: Device, /// UI level - pub ui_level: u8, + pub ui_level: UiLevel, /// Colorblind mode pub colorblind_mode: bool, /// Key input mode - pub key_input_mode: u8, + pub key_input_mode: KeyInputMode, /// ntfy.sh URL pub ntfy_url: Option, /// Vector containing the types of enabled notifications @@ -36,6 +66,12 @@ pub struct Config { pub min_space_to_start_recording_mb: u64, /// Minimum disk space required to continue a recording pub min_space_to_continue_recording_mb: u64, + /// GPS mode + pub gps_mode: GpsMode, + /// Fixed latitude used when gps_mode=1 + pub gps_fixed_latitude: Option, + /// Fixed longitude used when gps_mode=1 + pub gps_fixed_longitude: Option, /// Wifi client SSID pub wifi_ssid: Option, /// Wifi client password @@ -96,14 +132,17 @@ impl Default for Config { port: 8080, debug_mode: false, device: Device::Orbic, - ui_level: 1, + ui_level: UiLevel::Subtle, colorblind_mode: false, - key_input_mode: 0, + key_input_mode: KeyInputMode::Disabled, analyzers: AnalyzerConfig::default(), ntfy_url: None, enabled_notifications: vec![NotificationType::Warning, NotificationType::LowBattery], min_space_to_start_recording_mb: 1, min_space_to_continue_recording_mb: 1, + gps_mode: GpsMode::Disabled, + gps_fixed_latitude: None, + gps_fixed_longitude: None, wifi_ssid: None, wifi_password: None, wifi_security: None, diff --git a/daemon/src/diag.rs b/daemon/src/diag.rs index 3db74927..b8e92153 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; @@ -26,6 +28,7 @@ use rayhunter::diag_device::DiagDevice; use rayhunter::qmdl::QmdlWriter; use crate::analysis::{AnalysisCtrlMessage, AnalysisWriter}; +use crate::config::GpsMode; use crate::display; use crate::notifications::{Notification, NotificationType}; use crate::qmdl_store::{RecordingStore, RecordingStoreError}; @@ -56,6 +59,8 @@ pub struct DiagTask { notification_channel: tokio::sync::mpsc::Sender, min_space_to_start_mb: u64, min_space_to_continue_mb: u64, + gps_mode: GpsMode, + gps_fixed_coords: Option<(f64, f64)>, state: DiagState, max_type_seen: EventType, bytes_since_space_check: usize, @@ -104,6 +109,8 @@ impl DiagTask { notification_channel: tokio::sync::mpsc::Sender, min_space_to_start_mb: u64, min_space_to_continue_mb: u64, + gps_mode: GpsMode, + gps_fixed_coords: Option<(f64, f64)>, ) -> Self { Self { ui_update_sender, @@ -112,6 +119,8 @@ impl DiagTask { notification_channel, 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, @@ -144,7 +153,7 @@ impl DiagTask { DiskSpaceCheck::Failed => {} } - let (qmdl_file, analysis_file) = match qmdl_store.new_entry().await { + let (qmdl_file, analysis_file) = match qmdl_store.new_entry(self.gps_mode).await { Ok(files) => files, Err(e) => { let msg = format!("failed creating QMDL file entry: {e}"); @@ -152,6 +161,30 @@ 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 == GpsMode::Fixed + && let Some((lat, lon)) = self.gps_fixed_coords + && let Some((entry_idx, _)) = qmdl_store.get_current_entry() + { + match qmdl_store.open_entry_gps_for_append(entry_idx).await { + Ok(Some(mut gps_file)) => { + 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; + } + } + Ok(None) => { + error!("GPS sidecar directory not found, cannot write fixed-mode coordinates") + } + Err(e) => error!("failed to open GPS sidecar for fixed-mode entry: {e}"), + } + } self.stop_current_recording().await; let qmdl_writer = QmdlWriter::new(qmdl_file); let analysis_writer = match AnalysisWriter::new(analysis_file, &self.analyzer_config).await @@ -381,6 +414,8 @@ pub fn run_diag_read_thread( notification_channel: tokio::sync::mpsc::Sender, min_space_to_start_mb: u64, min_space_to_continue_mb: u64, + gps_mode: GpsMode, + gps_fixed_coords: Option<(f64, f64)>, ) { task_tracker.spawn(async move { info!("Using configuration for device: {0:?}", device); @@ -396,7 +431,9 @@ pub fn run_diag_read_thread( analyzer_config, notification_channel, min_space_to_start_mb, - min_space_to_continue_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/display/generic_framebuffer.rs b/daemon/src/display/generic_framebuffer.rs index a37b2368..9fcfb4fc 100644 --- a/daemon/src/display/generic_framebuffer.rs +++ b/daemon/src/display/generic_framebuffer.rs @@ -3,7 +3,7 @@ use image::{AnimationDecoder, DynamicImage, codecs::gif::GifDecoder, imageops::F use std::io::Cursor; use std::time::Duration; -use crate::config; +use crate::config::{self, UiLevel}; use crate::display::DisplayState; use rayhunter::analysis::analyzer::EventType; @@ -176,7 +176,7 @@ pub fn update_ui( ) { static IMAGE_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/images/"); let display_level = config.ui_level; - if display_level == 0 { + if display_level == UiLevel::Invisible { info!("Invisible mode, not spawning UI."); return; } @@ -187,14 +187,14 @@ pub fn update_ui( task_tracker.spawn(async move { // this feels wrong, is there a more rusty way to do this? let mut img: Option<&[u8]> = None; - if display_level == 2 { + if display_level == UiLevel::Demo { img = Some( IMAGE_DIR .get_file("orca.gif") .expect("failed to read orca.gif") .contents(), ); - } else if display_level == 3 { + } else if display_level == UiLevel::EffLogo { img = Some( IMAGE_DIR .get_file("eff.png") @@ -217,20 +217,19 @@ pub fn update_ui( let mut status_bar_height = 2; match display_level { - 2 => fb.draw_gif(img.unwrap()).await, - 3 => fb.draw_img(img.unwrap()).await, - 4 => { + UiLevel::Demo => fb.draw_gif(img.unwrap()).await, + UiLevel::EffLogo => fb.draw_img(img.unwrap()).await, + UiLevel::HighVisibility => { status_bar_height = fb.dimensions().height; } - 128 => { + UiLevel::TransFlag => { fb.draw_line(Color::Cyan, 128).await; fb.draw_line(Color::Pink, 102).await; fb.draw_line(Color::White, 76).await; fb.draw_line(Color::Pink, 50).await; fb.draw_line(Color::Cyan, 25).await; } - // this branch is for ui_level 1, which is also the default if an - // unknown value is used + // UiLevel::Subtle (1) and anything else: just the status bar line _ => {} }; let (color, pattern) = display_style; diff --git a/daemon/src/display/tmobile.rs b/daemon/src/display/tmobile.rs index 1a39fc12..e50e6a81 100644 --- a/daemon/src/display/tmobile.rs +++ b/daemon/src/display/tmobile.rs @@ -9,7 +9,7 @@ use tokio_util::task::TaskTracker; use std::time::Duration; -use crate::config; +use crate::config::{self, UiLevel}; use crate::display::DisplayState; macro_rules! led { @@ -31,7 +31,7 @@ pub fn update_ui( mut ui_update_rx: mpsc::Receiver, ) { let mut invisible: bool = false; - if config.ui_level == 0 { + if config.ui_level == UiLevel::Invisible { info!("Invisible mode, not spawning UI."); invisible = true; } diff --git a/daemon/src/display/tplink.rs b/daemon/src/display/tplink.rs index fe512777..a3d515e3 100644 --- a/daemon/src/display/tplink.rs +++ b/daemon/src/display/tplink.rs @@ -3,7 +3,7 @@ use tokio::sync::mpsc::Receiver; use tokio_util::sync::CancellationToken; use tokio_util::task::TaskTracker; -use crate::config; +use crate::config::{self, UiLevel}; use crate::display::{DisplayState, tplink_framebuffer, tplink_onebit}; use std::fs; @@ -15,7 +15,7 @@ pub fn update_ui( ui_update_rx: Receiver, ) { let display_level = config.ui_level; - if display_level == 0 { + if display_level == UiLevel::Invisible { info!("Invisible mode, not spawning UI."); } diff --git a/daemon/src/display/tplink_onebit.rs b/daemon/src/display/tplink_onebit.rs index fbf3ded4..06d60983 100644 --- a/daemon/src/display/tplink_onebit.rs +++ b/daemon/src/display/tplink_onebit.rs @@ -1,7 +1,7 @@ /// Display module for the TP-Link M7350 oled one-bit display. /// /// https://github.com/m0veax/tplink_m7350/tree/main/oled -use crate::config; +use crate::config::{self, UiLevel}; use crate::display::DisplayState; use log::{error, info}; @@ -115,7 +115,7 @@ pub fn update_ui( mut ui_update_rx: Receiver, ) { let display_level = config.ui_level; - if display_level == 0 { + if display_level == UiLevel::Invisible { info!("Invisible mode, not spawning UI."); } @@ -140,7 +140,7 @@ pub fn update_ui( // we write the status every second because it may have been overwritten through menu // navigation. - if display_level != 0 + if display_level != UiLevel::Invisible && let Err(e) = tokio::fs::write(OLED_PATH, pixels).await { error!("failed to write to display: {e}"); diff --git a/daemon/src/display/uz801.rs b/daemon/src/display/uz801.rs index 1f7bd592..df2cc7ea 100644 --- a/daemon/src/display/uz801.rs +++ b/daemon/src/display/uz801.rs @@ -9,7 +9,7 @@ use tokio_util::task::TaskTracker; use std::time::Duration; -use crate::config; +use crate::config::{self, UiLevel}; use crate::display::DisplayState; macro_rules! led { @@ -31,7 +31,7 @@ pub fn update_ui( mut ui_update_rx: mpsc::Receiver, ) { let mut invisible: bool = false; - if config.ui_level == 0 { + if config.ui_level == UiLevel::Invisible { info!("Invisible mode, not spawning UI."); invisible = true; } diff --git a/daemon/src/gps.rs b/daemon/src/gps.rs new file mode 100644 index 00000000..b41622aa --- /dev/null +++ b/daemon/src/gps.rs @@ -0,0 +1,165 @@ +use axum::Json; +use axum::extract::State; +use axum::http::StatusCode; +use log::{error, info, warn}; +use serde::{Deserialize, Deserializer, Serialize}; +use std::sync::Arc; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + +use crate::config::GpsMode; +use crate::server::ServerState; + +fn deserialize_latitude<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + use serde::de; + let v = f64::deserialize(deserializer)?; + if !(-90.0..=90.0).contains(&v) { + return Err(de::Error::custom(format!( + "latitude {v} out of range [-90, 90]" + ))); + } + Ok(v) +} + +fn deserialize_longitude<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + use serde::de; + let v = f64::deserialize(deserializer)?; + if !(-180.0..=180.0).contains(&v) { + return Err(de::Error::custom(format!( + "longitude {v} out of range [-180, 180]" + ))); + } + Ok(v) +} + +fn deserialize_unix_ts<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + use serde::de; + use serde_json::Value; + match Value::deserialize(deserializer)? { + 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::() + .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", + )), + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct GpsData { + #[serde(deserialize_with = "deserialize_latitude")] + pub latitude: f64, + #[serde(deserialize_with = "deserialize_longitude")] + pub longitude: f64, + #[serde(deserialize_with = "deserialize_unix_ts")] + pub timestamp: i64, +} + +#[derive(Serialize, Deserialize)] +pub struct GpsRecord { + pub unix_ts: i64, + pub lat: f64, + pub lon: f64, +} + +/// Reads all GPS records from a sidecar NDJSON file, logging and skipping malformed lines. +pub async fn load_gps_records(file: tokio::fs::File) -> Vec { + let reader = BufReader::new(file); + let mut lines = reader.lines(); + let mut records = Vec::new(); + loop { + match lines.next_line().await { + Ok(Some(line)) => match serde_json::from_str::(&line) { + Ok(record) => records.push(record), + Err(e) => warn!("skipping malformed GPS sidecar line: {e}"), + }, + Ok(None) => break, + Err(e) => { + error!("error reading GPS sidecar file: {e}"); + break; + } + } + } + records.sort_by_key(|r| r.unix_ts); + records +} + +pub async fn post_gps( + State(state): State>, + Json(gps_data): Json, +) -> Result { + if state.config.gps_mode != GpsMode::Api { + return Err(( + StatusCode::FORBIDDEN, + "GPS API endpoint is disabled. Set gps_mode to API endpoint in configuration." + .to_string(), + )); + } + let mut gps = state.gps_state.write().await; + *gps = Some(gps_data.clone()); + drop(gps); + + let qmdl_store = state.qmdl_store_lock.read().await; + if let Some((entry_idx, _)) = qmdl_store.get_current_entry() { + match qmdl_store.open_entry_gps_for_append(entry_idx).await { + Ok(Some(mut file)) => { + let record = GpsRecord { + unix_ts: gps_data.timestamp, + lat: gps_data.latitude, + lon: gps_data.longitude, + }; + let json = serde_json::to_string(&record).map_err(|e| { + error!("failed to serialize GPS record: {e}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("failed to serialize GPS record: {e}"), + ) + })?; + file.write_all(format!("{json}\n").as_bytes()) + .await + .map_err(|e| { + error!("failed to write GPS record to sidecar: {e}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("failed to write GPS record to sidecar: {e}"), + ) + })?; + } + Ok(None) => error!("GPS sidecar directory not found, cannot write GPS record"), + Err(e) => { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + format!("failed to open GPS sidecar: {e}"), + )); + } + } + } else { + info!( + "GPS data received but no recording is active — position updated in memory only, not persisted to sidecar" + ); + } + + Ok(StatusCode::OK) +} + +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())), + None => Err(StatusCode::NOT_FOUND), + } +} diff --git a/daemon/src/key_input.rs b/daemon/src/key_input.rs index 7e75fbbc..0cea6ab4 100644 --- a/daemon/src/key_input.rs +++ b/daemon/src/key_input.rs @@ -6,7 +6,7 @@ use tokio::sync::mpsc::Sender; use tokio_util::sync::CancellationToken; use tokio_util::task::TaskTracker; -use crate::config; +use crate::config::{self, KeyInputMode}; use crate::diag::DiagDeviceCtrlMessage; #[derive(Debug)] @@ -23,7 +23,7 @@ pub fn run_key_input_thread( diag_tx: Sender, cancellation_token: CancellationToken, ) { - if config.key_input_mode == 0 { + if config.key_input_mode == KeyInputMode::Disabled { return; } diff --git a/daemon/src/lib.rs b/daemon/src/lib.rs index 18353792..ce0f83dd 100644 --- a/daemon/src/lib.rs +++ b/daemon/src/lib.rs @@ -6,6 +6,7 @@ pub mod diag; pub mod display; pub mod error; pub mod firewall; +pub mod gps; pub mod key_input; pub mod notifications; pub mod pcap; diff --git a/daemon/src/main.rs b/daemon/src/main.rs index 80cde339..171be411 100644 --- a/daemon/src/main.rs +++ b/daemon/src/main.rs @@ -6,6 +6,7 @@ mod diag; mod display; mod error; mod firewall; +mod gps; mod key_input; mod notifications; mod pcap; @@ -18,9 +19,10 @@ use std::net::SocketAddr; use std::sync::Arc; use crate::battery::run_battery_notification_worker; -use crate::config::{parse_args, parse_config}; +use crate::config::{GpsMode, 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; @@ -42,7 +44,7 @@ use diag::{ DiagDeviceCtrlMessage, delete_all_recordings, delete_recording, get_analysis_report, start_recording, stop_recording, }; -use log::{error, info}; +use log::{error, info, warn}; use qmdl_store::RecordingStoreError; use rayhunter::Device; use stats::get_log; @@ -79,6 +81,8 @@ fn get_router() -> AppRouter { .route("/api/time", get(get_time)) .route("/api/time-offset", post(set_time_offset)) .route("/api/debug/display-state", post(debug_set_display_state)) + .route("/api/gps", get(get_gps)) + .route("/api/gps", post(post_gps)) .route("/", get(|| async { Redirect::permanent("/index.html") })) .route("/{*path}", get(serve_static)) } @@ -217,6 +221,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(), @@ -229,6 +237,8 @@ async fn run_with_config( notification_service.new_handler(), config.min_space_to_start_recording_mb, config.min_space_to_continue_recording_mb, + config.gps_mode, + gps_fixed_coords, ); info!("Starting UI"); @@ -298,6 +308,25 @@ async fn run_with_config( webdav_config.into(), ); } + // For fixed configuration, we use timestamp 0 to not break other + // the GET request for GPS but user won't see the 0 in PCAPs + let initial_gps = if config.gps_mode == GpsMode::Fixed { + match (config.gps_fixed_latitude, config.gps_fixed_longitude) { + (Some(lat), Some(lon)) => Some(gps::GpsData { + latitude: lat, + longitude: lon, + timestamp: 0, + }), + _ => { + warn!( + "gps_mode is Fixed but gps_fixed_latitude or gps_fixed_longitude is missing from config — no GPS coordinates will be recorded" + ); + None + } + } + } else { + None + }; let state = Arc::new(ServerState { config_path: args.config_path.clone(), @@ -310,6 +339,7 @@ async fn run_with_config( ui_update_sender: Some(ui_update_tx), wifi_status, wifi_scan_lock: tokio::sync::Mutex::new(()), + gps_state: Arc::new(tokio::sync::RwLock::new(initial_gps)), }); run_server(&task_tracker, state, shutdown_token.clone()).await; diff --git a/daemon/src/pcap.rs b/daemon/src/pcap.rs index fce37d64..eb93a8d1 100644 --- a/daemon/src/pcap.rs +++ b/daemon/src/pcap.rs @@ -1,5 +1,7 @@ +use crate::gps::{GpsRecord, load_gps_records}; use crate::server::ServerState; +use crate::config::GpsMode; use anyhow::Error; use axum::body::Body; use axum::extract::{Path, State}; @@ -9,15 +11,12 @@ use axum::response::{IntoResponse, Response}; use log::error; use rayhunter::diag::DataType; use rayhunter::gsmtap_parser; -use rayhunter::pcap::GsmtapPcapWriter; +use rayhunter::pcap::{GpsPoint, GsmtapPcapWriter}; use rayhunter::qmdl::QmdlReader; use std::sync::Arc; use tokio::io::{AsyncRead, AsyncWrite, duplex}; use tokio_util::io::ReaderStream; -// Streams a pcap file chunk-by-chunk to the client by reading the QMDL data -// written so far. This is done by spawning a thread which streams chunks of -// pcap data to a channel that's piped to the client. #[cfg_attr(feature = "apidocs", utoipa::path( get, path = "/api/pcap/{name}", @@ -56,12 +55,12 @@ pub async fn get_pcap( .open_entry_qmdl(entry_index) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}")))?; - // the QMDL reader should stop at the last successfully written data chunk - // (entry.size_bytes) let (reader, writer) = duplex(1024); + let gps_records = load_gps_records_for_entry(&state, entry_index).await; + drop(qmdl_store); tokio::spawn(async move { - if let Err(e) = generate_pcap_data(writer, qmdl_file, qmdl_size_bytes).await { + if let Err(e) = generate_pcap_data(writer, qmdl_file, qmdl_size_bytes, gps_records).await { error!("failed to generate PCAP: {e:?}"); } }); @@ -71,10 +70,124 @@ pub async fn get_pcap( Ok((headers, body).into_response()) } +pub(crate) async fn load_gps_records_for_entry( + state: &Arc, + entry_index: usize, +) -> Vec { + let qmdl_store = state.qmdl_store_lock.read().await; + match qmdl_store.open_entry_gps(entry_index).await { + Ok(Some(file)) => load_gps_records(file).await, + Ok(None) => { + let gps_mode = qmdl_store + .manifest + .entries + .get(entry_index) + .and_then(|e| e.gps_mode); + if gps_mode.is_some_and(|m| m != GpsMode::Disabled) { + error!( + "GPS sidecar expected for entry {entry_index} (mode: {gps_mode:?}) but not found" + ); + } + vec![] + } + Err(e) => { + error!("failed to open GPS sidecar: {e}"); + vec![] + } + } +} + +fn find_nearest_gps(records: &[GpsRecord], packet_unix_ts: i64) -> Option { + if records.is_empty() { + return None; + } + let idx = records.partition_point(|r| r.unix_ts <= packet_unix_ts); + let record = if idx == 0 { + &records[0] + } else if idx >= records.len() { + &records[records.len() - 1] + } else { + let (before, after) = (&records[idx - 1], &records[idx]); + 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, + }) +} + +#[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( writer: W, qmdl_file: R, qmdl_size_bytes: usize, + gps_records: Vec, ) -> Result<(), Error> where W: AsyncWrite + Unpin + Send, @@ -94,8 +207,10 @@ 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(); + let gps = find_nearest_gps(&gps_records, packet_unix_ts); pcap_writer - .write_gsmtap_message(gsmtap_msg, timestamp) + .write_gsmtap_message(gsmtap_msg, timestamp, gps.as_ref()) .await?; } } diff --git a/daemon/src/qmdl_store.rs b/daemon/src/qmdl_store.rs index 445a504e..3452050e 100644 --- a/daemon/src/qmdl_store.rs +++ b/daemon/src/qmdl_store.rs @@ -2,6 +2,7 @@ use std::io::{self, ErrorKind}; use std::os::unix::fs::MetadataExt; use std::path::{Path, PathBuf}; +use crate::config::GpsMode; use chrono::{DateTime, Local, TimeDelta}; use log::{info, warn}; use rayhunter::util::RuntimeMetadata; @@ -70,10 +71,12 @@ pub struct ManifestEntry { /// When the manifest was uploaded to a WebDAV server #[cfg_attr(feature = "apidocs", schema(value_type = String))] pub upload_time: Option>, + #[serde(default)] + pub gps_mode: Option, } impl ManifestEntry { - fn new() -> Self { + fn new(gps_mode: GpsMode) -> Self { let now = rayhunter::clock::get_adjusted_now(); let metadata = RuntimeMetadata::new(); ManifestEntry { @@ -86,6 +89,7 @@ impl ManifestEntry { arch: Some(metadata.arch), stop_reason: None, upload_time: None, + gps_mode: Some(gps_mode), } } @@ -100,6 +104,10 @@ impl ManifestEntry { filepath.set_extension("ndjson"); filepath } + + pub fn get_gps_filepath>(&self, path: P) -> PathBuf { + path.as_ref().join(format!("{}-gps.ndjson", self.name)) + } } impl RecordingStore { @@ -217,6 +225,7 @@ impl RecordingStore { arch: None, stop_reason: None, upload_time: None, + gps_mode: None, }); } @@ -249,12 +258,15 @@ impl RecordingStore { // Closes the current entry (if needed), creates a new entry based on the // current time, and updates the manifest. Returns a tuple of the entry's // newly created QMDL file and analysis file. - pub async fn new_entry(&mut self) -> Result<(File, File), RecordingStoreError> { + pub async fn new_entry( + &mut self, + gps_mode: GpsMode, + ) -> Result<(File, File), RecordingStoreError> { // if we've already got an entry open, close it if self.current_entry.is_some() { self.close_current_entry().await?; } - let new_entry = ManifestEntry::new(); + let new_entry = ManifestEntry::new(gps_mode); let qmdl_filepath = new_entry.get_qmdl_filepath(&self.path); let qmdl_file = File::create(&qmdl_filepath) .await @@ -263,6 +275,10 @@ impl RecordingStore { let analysis_file = File::create(&analysis_filepath) .await .map_err(RecordingStoreError::CreateFileError)?; + let gps_filepath = new_entry.get_gps_filepath(&self.path); + File::create(&gps_filepath) + .await + .map_err(RecordingStoreError::CreateFileError)?; self.manifest.entries.push(new_entry); self.current_entry = Some(self.manifest.entries.len() - 1); self.write_manifest().await?; @@ -288,6 +304,35 @@ impl RecordingStore { .map_err(RecordingStoreError::ReadFileError) } + pub async fn open_entry_gps( + &self, + entry_index: usize, + ) -> Result, RecordingStoreError> { + let entry = &self.manifest.entries[entry_index]; + match File::open(entry.get_gps_filepath(&self.path)).await { + Ok(file) => Ok(Some(file)), + Err(e) if e.kind() == ErrorKind::NotFound => Ok(None), + Err(e) => Err(RecordingStoreError::ReadFileError(e)), + } + } + + pub async fn open_entry_gps_for_append( + &self, + entry_index: usize, + ) -> Result, RecordingStoreError> { + let entry = &self.manifest.entries[entry_index]; + match OpenOptions::new() + .create(true) + .append(true) + .open(entry.get_gps_filepath(&self.path)) + .await + { + Ok(file) => Ok(Some(file)), + Err(e) if e.kind() == ErrorKind::NotFound => Ok(None), + Err(e) => Err(RecordingStoreError::CreateFileError(e)), + } + } + pub async fn clear_and_open_entry_analysis( &mut self, entry_index: usize, @@ -436,12 +481,16 @@ impl RecordingStore { self.write_manifest().await?; let qmdl_filepath = entry_to_delete.get_qmdl_filepath(&self.path); let analysis_filepath = entry_to_delete.get_analysis_filepath(&self.path); + let gps_filepath = entry_to_delete.get_gps_filepath(&self.path); remove_file_if_exists(&qmdl_filepath) .await .map_err(RecordingStoreError::DeleteFileError)?; remove_file_if_exists(&analysis_filepath) .await .map_err(RecordingStoreError::DeleteFileError)?; + remove_file_if_exists(&gps_filepath) + .await + .map_err(RecordingStoreError::DeleteFileError)?; Ok(()) } @@ -468,6 +517,9 @@ impl RecordingStore { continue; } + let gps_filepath = entry.get_gps_filepath(&self.path); + remove_file_if_exists(&gps_filepath).await.ok(); + keep.push(false); } @@ -508,7 +560,7 @@ mod tests { async fn test_creating_updating_and_closing_entries() { let dir = make_temp_dir(); let mut store = RecordingStore::create(dir.path()).await.unwrap(); - let _ = store.new_entry().await.unwrap(); + let _ = store.new_entry(GpsMode::Disabled).await.unwrap(); let entry_index = store.current_entry.unwrap(); assert_eq!( RecordingStore::read_manifest(dir.path()).await.unwrap(), @@ -545,7 +597,7 @@ mod tests { async fn test_create_on_existing_store() { let dir = make_temp_dir(); let mut store = RecordingStore::create(dir.path()).await.unwrap(); - let _ = store.new_entry().await.unwrap(); + let _ = store.new_entry(GpsMode::Disabled).await.unwrap(); let entry_index = store.current_entry.unwrap(); store .update_entry_qmdl_size(entry_index, 1000) @@ -559,9 +611,9 @@ mod tests { async fn test_repeated_new_entries() { let dir = make_temp_dir(); let mut store = RecordingStore::create(dir.path()).await.unwrap(); - let _ = store.new_entry().await.unwrap(); + let _ = store.new_entry(GpsMode::Disabled).await.unwrap(); let entry_index = store.current_entry.unwrap(); - let _ = store.new_entry().await.unwrap(); + let _ = store.new_entry(GpsMode::Disabled).await.unwrap(); let new_entry_index = store.current_entry.unwrap(); assert_ne!(entry_index, new_entry_index); assert_eq!(store.manifest.entries.len(), 2); @@ -571,7 +623,7 @@ mod tests { async fn test_delete_all_entries() { let dir = make_temp_dir(); let mut store = RecordingStore::create(dir.path()).await.unwrap(); - let _ = store.new_entry().await.unwrap(); + let _ = store.new_entry(GpsMode::Disabled).await.unwrap(); assert!(store.current_entry.is_some()); store.delete_all_entries().await.unwrap(); @@ -587,7 +639,7 @@ mod tests { async fn test_mark_entry_as_uploaded_sets_time_and_persists() { let dir = make_temp_dir(); let mut store = RecordingStore::create(dir.path()).await.unwrap(); - let _ = store.new_entry().await.unwrap(); + let _ = store.new_entry(GpsMode::Disabled).await.unwrap(); let name = store.manifest.entries[0].name.clone(); store.close_current_entry().await.unwrap(); @@ -618,7 +670,7 @@ mod tests { let mut store = RecordingStore::create(dir.path()).await.unwrap(); for _ in 0..3 { - let _ = store.new_entry().await.unwrap(); + let _ = store.new_entry(GpsMode::Disabled).await.unwrap(); } store.manifest.entries[0].name = "entry-0".to_owned(); diff --git a/daemon/src/server.rs b/daemon/src/server.rs index b4422387..ee3865bd 100644 --- a/daemon/src/server.rs +++ b/daemon/src/server.rs @@ -22,11 +22,12 @@ use tokio_util::io::ReaderStream; use tokio_util::sync::CancellationToken; use crate::analysis::{AnalysisCtrlMessage, AnalysisStatus}; -use crate::config::Config; +use crate::config::{Config, GpsMode}; use crate::diag::DiagDeviceCtrlMessage; use crate::display::DisplayState; +use crate::gps::GpsData; use crate::notifications::DEFAULT_NOTIFICATION_TIMEOUT; -use crate::pcap::generate_pcap_data; +use crate::pcap::{generate_pcap_data, load_gps_records_for_entry}; use crate::qmdl_store::RecordingStore; pub struct ServerState { @@ -40,6 +41,7 @@ pub struct ServerState { pub ui_update_sender: Option>, pub wifi_status: Arc>, pub wifi_scan_lock: tokio::sync::Mutex<()>, + pub gps_state: Arc>>, } #[cfg_attr(feature = "apidocs", utoipa::path( @@ -160,8 +162,12 @@ pub async fn get_config( ))] pub async fn set_config( State(state): State>, - Json(config): Json, + Json(mut config): Json, ) -> Result<(StatusCode, String), (StatusCode, String)> { + if config.gps_mode != GpsMode::Fixed { + config.gps_fixed_latitude = None; + config.gps_fixed_longitude = None; + } let mut config_to_write = config.clone(); config_to_write.wifi_ssid = None; config_to_write.wifi_password = None; @@ -343,6 +349,7 @@ pub async fn get_zip( }; let qmdl_store_lock = state.qmdl_store_lock.clone(); + let gps_records = load_gps_records_for_entry(&state, entry_index).await; let (reader, writer) = duplex(8192); @@ -385,8 +392,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).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. @@ -501,6 +513,7 @@ pub async fn debug_set_display_state( #[cfg(test)] mod tests { use super::*; + use crate::config::GpsMode; use async_zip::base::read::mem::ZipFileReader; use axum::extract::{Path, State}; use tempfile::TempDir; @@ -520,7 +533,7 @@ mod tests { ) -> String { let entry_name = { let mut store = store_lock.write().await; - let (mut qmdl_file, _analysis_file) = store.new_entry().await.unwrap(); + let (mut qmdl_file, _analysis_file) = store.new_entry(GpsMode::Disabled).await.unwrap(); if !test_data.is_empty() { use tokio::io::AsyncWriteExt; @@ -566,6 +579,7 @@ mod tests { ui_update_sender: None, wifi_status: Arc::new(RwLock::new(wifi_station::WifiStatus::default())), wifi_scan_lock: tokio::sync::Mutex::new(()), + gps_state: Arc::new(RwLock::new(None)), }) } diff --git a/daemon/src/webdav.rs b/daemon/src/webdav.rs index c22752b6..479928ad 100644 --- a/daemon/src/webdav.rs +++ b/daemon/src/webdav.rs @@ -11,7 +11,7 @@ use tokio::{select, sync::RwLock, time}; use tokio_util::io::ReaderStream; use tokio_util::{sync::CancellationToken, task::TaskTracker}; -use crate::config::WebdavConfig; +use crate::config::{GpsMode, WebdavConfig}; use crate::qmdl_store::RecordingStore; pub struct WebdavUploadWorkerConfig { @@ -313,7 +313,7 @@ mod tests { dir: &std::path::Path, ) -> (Arc>, String) { let mut store = RecordingStore::create(dir).await.unwrap(); - let (mut qmdl_file, mut analysis_file) = store.new_entry().await.unwrap(); + let (mut qmdl_file, mut analysis_file) = store.new_entry(GpsMode::Disabled).await.unwrap(); qmdl_file.write_all(b"fake qmdl payload").await.unwrap(); qmdl_file.flush().await.unwrap(); analysis_file diff --git a/daemon/web/src/lib/components/AnalysisView.svelte b/daemon/web/src/lib/components/AnalysisView.svelte index 3ffd8bce..7f5ab6c9 100644 --- a/daemon/web/src/lib/components/AnalysisView.svelte +++ b/daemon/web/src/lib/components/AnalysisView.svelte @@ -1,6 +1,7 @@