From 8a4e9f5ed3171af7533b85b575baf8448cfc47ca Mon Sep 17 00:00:00 2001 From: Stanislaw Grams Date: Fri, 13 Feb 2026 09:29:28 +0100 Subject: [PATCH] [feat](trx-server): add optional per-decoder log files Co-authored-by: OpenAI Codex Signed-off-by: Stanislaw Grams --- CONFIGURATION.md | 13 +++ Cargo.lock | 102 ++++++++++++++++++- DECODER_LOG_IMPLEMENTATION.rs | 161 ++++++++++++++++++++++++++++++ src/trx-server/Cargo.toml | 1 + src/trx-server/src/audio.rs | 17 ++++ src/trx-server/src/config.rs | 65 ++++++++++++ src/trx-server/src/decode_logs.rs | 151 ++++++++++++++++++++++++++++ src/trx-server/src/main.rs | 22 +++- trx-server.toml.example | 16 +++ 9 files changed, 542 insertions(+), 6 deletions(-) create mode 100644 DECODER_LOG_IMPLEMENTATION.rs create mode 100644 src/trx-server/src/decode_logs.rs diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 48f8479..aa1ce6d 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -97,6 +97,19 @@ Notes: - If `receiver_locator` is omitted, server tries deriving it from `[general].latitude`/`longitude`. - PSK Reporter software ID is hardcoded to: `trx-server v by SP2SJG`. +### `[decode_logs]` +- `enabled` (`bool`, default: `false`) +- `dir` (`string`, default: `"$XDG_DATA_HOME/trx-rs/decoders"`; fallback: `"logs/decoders"`, must not be empty when enabled) +- `aprs_file` (`string`, default: `"TRXRS-APRS-%YYYY%-%MM%-%DD%.log"`, must not be empty when enabled) +- `cw_file` (`string`, default: `"TRXRS-CW-%YYYY%-%MM%-%DD%.log"`, must not be empty when enabled) +- `ft8_file` (`string`, default: `"TRXRS-FT8-%YYYY%-%MM%-%DD%.log"`, must not be empty when enabled) +- `wspr_file` (`string`, default: `"TRXRS-WSPR-%YYYY%-%MM%-%DD%.log"`, must not be empty when enabled) + +Notes: +- Decoder logs are server-side and split by decoder name (APRS/CW/FT8/WSPR). +- Files are appended in JSON Lines format (one JSON object per line). +- Supported filename date tokens: `%YYYY%`, `%MM%`, `%DD%` (UTC date). + ## `trx-client` Options ### `[general]` diff --git a/Cargo.lock b/Cargo.lock index f2ffdb1..5b2e19a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -261,6 +261,15 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "0.6.21" @@ -454,6 +463,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + [[package]] name = "clang-sys" version = "1.8.1" @@ -971,6 +991,30 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.1.1" @@ -2359,6 +2403,7 @@ name = "trx-server" version = "0.1.0" dependencies = [ "bytes", + "chrono", "clap", "cpal", "dirs", @@ -2575,7 +2620,7 @@ version = "0.54.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" dependencies = [ - "windows-core", + "windows-core 0.54.0", "windows-targets 0.52.6", ] @@ -2585,10 +2630,45 @@ version = "0.54.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" dependencies = [ - "windows-result", + "windows-result 0.1.2", "windows-targets 0.52.6", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result 0.4.1", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" @@ -2604,6 +2684,24 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.45.0" diff --git a/DECODER_LOG_IMPLEMENTATION.rs b/DECODER_LOG_IMPLEMENTATION.rs new file mode 100644 index 0000000..1cc0a37 --- /dev/null +++ b/DECODER_LOG_IMPLEMENTATION.rs @@ -0,0 +1,161 @@ +//! Decoder log files implementation plan (server-side) +//! +//! Goal: +//! - Persist decoded messages to disk. +//! - Split output by decoder name (APRS/CW/FT8/WSPR). +//! - Make file names/paths configurable in `trx-server.toml`. +//! - Keep runtime overhead low and avoid decoder stalls. +//! +//! This file is a planning artifact, not production code. + +/// Ordered rollout phases. +#[allow(dead_code)] +pub enum Phase { + ConfigSchema, + RuntimeWriters, + DecoderHookup, + FileFormat, + RotationAndRetention, + ErrorHandling, + Tests, + Docs, +} + +/// MVP checklist. +#[allow(dead_code)] +pub const MVP_CHECKLIST: &[&str] = &[ + "Add [decode_logs] config section in trx-server config", + "Add per-decoder output targets (aprs/cw/ft8/wspr)", + "Implement async file writer worker(s)", + "Emit one line per decoded event (JSONL)", + "Hook writers into existing record/send points in audio.rs", + "Ensure logger failures never crash decoder tasks", + "Add tests for config parsing + file output", + "Document usage in trx-server.toml.example + CONFIGURATION.md", +]; + +/// Proposed config shape. +#[allow(dead_code)] +pub const CONFIG_PROPOSAL: &str = r#" +[decode_logs] +enabled = false +format = "jsonl" # jsonl (initial MVP) +flush_interval_ms = 250 # buffered flush interval +create_dirs = true + +[decode_logs.files] +# Any omitted decoder uses default pattern in base_dir +base_dir = "./logs/decoders" +aprs = "aprs.log" +cw = "cw.log" +ft8 = "ft8.log" +wspr = "wspr.log" + +[decode_logs.rotation] +enabled = false +max_bytes = 10485760 # 10 MiB +max_files = 10 +"#; + +/// Config behavior rules. +#[allow(dead_code)] +pub const CONFIG_RULES: &[&str] = &[ + "If decode_logs.enabled=false, no file writers are started", + "If enabled=true and path missing, use base_dir + '.log'", + "Paths may be absolute or relative to current working directory", + "If create_dirs=true, create parent directories on startup", + "Invalid paths -> startup warning + decoder logging disabled for that target", +]; + +/// File layout / names strategy. +#[allow(dead_code)] +pub const NAMING_PLAN: &[&str] = &[ + "Split by decoder name: aprs/cw/ft8/wspr", + "Allow custom names per decoder via [decode_logs.files]", + "Support one shared directory + per-decoder file names", + "Keep deterministic defaults to simplify ops and tailing", +]; + +/// Runtime architecture. +#[allow(dead_code)] +pub const RUNTIME_ARCHITECTURE: &[&str] = &[ + "Create DecoderLogRouter with optional sender per decoder", + "Spawn one async writer task per enabled decoder target", + "Writer task receives already-serialized lines over bounded mpsc", + "On backpressure/full queue: drop oldest/newest by policy + increment metric", + "Periodic flush by timer; flush on shutdown signal", +]; + +/// Where to hook in existing server code. +#[allow(dead_code)] +pub const INTEGRATION_POINTS: &[&str] = &[ + "src/trx-server/src/main.rs: initialize router from config before decoder tasks", + "src/trx-server/src/audio.rs: after record_* and before/after decode_tx.send(...)", + "APRS: record_aprs_packet path in run_aprs_decoder", + "CW: event emission path in run_cw_decoder", + "FT8: record_ft8_message path in run_ft8_decoder", + "WSPR: record_wspr_message path in run_wspr_decoder", +]; + +/// Line format (MVP JSONL). +#[allow(dead_code)] +pub const JSONL_SCHEMA: &[&str] = &[ + "ts_utc: RFC3339 timestamp generated at log write time", + "decoder: one of aprs|cw|ft8|wspr", + "rig_freq_hz: optional current RF base frequency", + "payload: decoder-specific message object (existing serde struct)", + "example: {\"ts_utc\":\"...\",\"decoder\":\"ft8\",\"payload\":{...}}", +]; + +/// Rotation/retention plan (post-MVP but scoped). +#[allow(dead_code)] +pub const ROTATION_PLAN: &[&str] = &[ + "MVP can skip rotation if disabled", + "If enabled: rotate file when size exceeds max_bytes", + "Rename N->N+1, keep max_files, truncate/create active file", + "Rotation performed inside writer task to avoid lock contention", +]; + +/// Failure handling policy. +#[allow(dead_code)] +pub const FAILURE_POLICY: &[&str] = &[ + "Decoder pipeline must continue if file IO fails", + "Log write failures with throttled warnings", + "If a target becomes unavailable, retry reopen periodically", + "Never panic from logger worker on malformed payload", +]; + +/// Testing plan. +#[allow(dead_code)] +pub const TEST_PLAN: &[&str] = &[ + "Unit: config parse/default/validation for decode_logs", + "Unit: per-decoder path resolution", + "Unit: JSONL serializer output for each decoder type", + "Integration: run decoder emit path and assert lines written to correct files", + "Integration: disabled mode creates no files", + "Integration: queue overflow policy counters/warnings", + "Integration: rotation behavior when enabled", +]; + +/// Files expected to change. +#[allow(dead_code)] +pub const FILES_TO_TOUCH: &[&str] = &[ + "src/trx-server/src/config.rs", + "src/trx-server/src/main.rs", + "src/trx-server/src/audio.rs", + "src/trx-server/src//decode_logs.rs", + "trx-server.toml.example", + "CONFIGURATION.md", + "tests under src/trx-server (unit/integration)", +]; + +/// Implementation order recommendation. +#[allow(dead_code)] +pub const EXECUTION_ORDER: &[&str] = &[ + "1) Config + validation + defaults", + "2) Minimal writer (single file, JSONL)", + "3) Split-by-decoder routing", + "4) Hook into APRS/CW/FT8/WSPR emit sites", + "5) Add rotation/retention", + "6) Add tests + docs", +]; diff --git a/src/trx-server/Cargo.toml b/src/trx-server/Cargo.toml index d7b2b14..39a694a 100644 --- a/src/trx-server/Cargo.toml +++ b/src/trx-server/Cargo.toml @@ -16,6 +16,7 @@ toml = { workspace = true } tracing = { workspace = true } clap = { workspace = true, features = ["derive"] } dirs = "6" +chrono = { version = "0.4", default-features = false, features = ["clock"] } bytes = "1" cpal = "0.15" opus = "0.3" diff --git a/src/trx-server/src/audio.rs b/src/trx-server/src/audio.rs index 0cccad1..c2bed3d 100644 --- a/src/trx-server/src/audio.rs +++ b/src/trx-server/src/audio.rs @@ -27,6 +27,7 @@ use trx_wspr::WsprDecoder; use crate::config::AudioConfig; use crate::decode; +use crate::decode_logs::DecoderLoggers; const APRS_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60); const FT8_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60); @@ -609,6 +610,7 @@ pub async fn run_aprs_decoder( mut pcm_rx: broadcast::Receiver>, mut state_rx: watch::Receiver, decode_tx: broadcast::Sender, + decode_logs: Option>, ) { info!("APRS decoder started ({}Hz, {} ch)", sample_rate, channels); let mut decoder = decode::aprs::AprsDecoder::new(sample_rate); @@ -662,6 +664,9 @@ pub async fn run_aprs_decoder( was_active = true; for pkt in decoder.process_samples(&mono) { record_aprs_packet(pkt.clone()); + if let Some(logger) = decode_logs.as_ref() { + logger.log_aprs(&pkt); + } let _ = decode_tx.send(DecodedMessage::Aprs(pkt)); } } @@ -703,6 +708,7 @@ pub async fn run_cw_decoder( mut pcm_rx: broadcast::Receiver>, mut state_rx: watch::Receiver, decode_tx: broadcast::Sender, + decode_logs: Option>, ) { info!("CW decoder started ({}Hz, {} ch)", sample_rate, channels); let mut decoder = decode::cw::CwDecoder::new(sample_rate); @@ -785,6 +791,9 @@ pub async fn run_cw_decoder( was_active = true; for evt in decoder.process_samples(&mono) { + if let Some(logger) = decode_logs.as_ref() { + logger.log_cw(&evt); + } let _ = decode_tx.send(DecodedMessage::Cw(evt)); } } @@ -872,6 +881,7 @@ pub async fn run_ft8_decoder( mut pcm_rx: broadcast::Receiver>, mut state_rx: watch::Receiver, decode_tx: broadcast::Sender, + decode_logs: Option>, ) { info!("FT8 decoder started ({}Hz, {} ch)", sample_rate, channels); let mut decoder = match Ft8Decoder::new(FT8_SAMPLE_RATE) { @@ -957,6 +967,9 @@ pub async fn run_ft8_decoder( message: res.text, }; record_ft8_message(msg.clone()); + if let Some(logger) = decode_logs.as_ref() { + logger.log_ft8(&msg); + } let _ = decode_tx.send(DecodedMessage::Ft8(msg)); } } @@ -1004,6 +1017,7 @@ pub async fn run_wspr_decoder( mut pcm_rx: broadcast::Receiver>, mut state_rx: watch::Receiver, decode_tx: broadcast::Sender, + decode_logs: Option>, ) { info!("WSPR decoder started ({}Hz, {} ch)", sample_rate, channels); let decoder = match WsprDecoder::new() { @@ -1069,6 +1083,9 @@ pub async fn run_wspr_decoder( message: res.message, }; record_wspr_message(msg.clone()); + if let Some(logger) = decode_logs.as_ref() { + logger.log_wspr(&msg); + } let _ = decode_tx.send(DecodedMessage::Wspr(msg)); } } diff --git a/src/trx-server/src/config.rs b/src/trx-server/src/config.rs index 2bf9bd3..94be8c4 100644 --- a/src/trx-server/src/config.rs +++ b/src/trx-server/src/config.rs @@ -35,6 +35,8 @@ pub struct ServerConfig { pub audio: AudioConfig, /// PSK Reporter uplink configuration pub pskreporter: PskReporterConfig, + /// Decoder file logging configuration + pub decode_logs: DecodeLogsConfig, } /// General application settings. @@ -231,6 +233,48 @@ impl Default for PskReporterConfig { } } +fn default_decode_logs_dir() -> String { + if let Some(data_dir) = dirs::data_dir() { + return data_dir + .join("trx-rs") + .join("decoders") + .to_string_lossy() + .to_string(); + } + "logs/decoders".to_string() +} + +/// Server-side decoder file logging configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct DecodeLogsConfig { + /// Whether decoder file logging is enabled + pub enabled: bool, + /// Base directory for log files + pub dir: String, + /// APRS decoder log filename + pub aprs_file: String, + /// CW decoder log filename + pub cw_file: String, + /// FT8 decoder log filename + pub ft8_file: String, + /// WSPR decoder log filename + pub wspr_file: String, +} + +impl Default for DecodeLogsConfig { + fn default() -> Self { + Self { + enabled: false, + dir: default_decode_logs_dir(), + aprs_file: "TRXRS-APRS-%YYYY%-%MM%-%DD%.log".to_string(), + cw_file: "TRXRS-CW-%YYYY%-%MM%-%DD%.log".to_string(), + ft8_file: "TRXRS-FT8-%YYYY%-%MM%-%DD%.log".to_string(), + wspr_file: "TRXRS-WSPR-%YYYY%-%MM%-%DD%.log".to_string(), + } + } +} + impl ServerConfig { pub fn validate(&self) -> Result<(), String> { validate_log_level(self.general.log_level.as_deref())?; @@ -307,6 +351,21 @@ impl ServerConfig { } } + if self.decode_logs.enabled { + if self.decode_logs.dir.trim().is_empty() { + return Err("[decode_logs].dir must not be empty when enabled".to_string()); + } + if self.decode_logs.aprs_file.trim().is_empty() + || self.decode_logs.cw_file.trim().is_empty() + || self.decode_logs.ft8_file.trim().is_empty() + || self.decode_logs.wspr_file.trim().is_empty() + { + return Err( + "[decode_logs] file names must not be empty when enabled".to_string(), + ); + } + } + Ok(()) } @@ -346,6 +405,7 @@ impl ServerConfig { listen: ListenConfig::default(), audio: AudioConfig::default(), pskreporter: PskReporterConfig::default(), + decode_logs: DecodeLogsConfig::default(), }; toml::to_string_pretty(&example).unwrap_or_default() @@ -478,6 +538,11 @@ mod tests { assert_eq!(config.audio.sample_rate, 48000); assert!(!config.pskreporter.enabled); assert_eq!(config.pskreporter.port, 4739); + assert!(!config.decode_logs.enabled); + assert!( + std::path::Path::new(&config.decode_logs.dir) + .ends_with(std::path::Path::new("decoders")) + ); } #[test] diff --git a/src/trx-server/src/decode_logs.rs b/src/trx-server/src/decode_logs.rs new file mode 100644 index 0000000..0b447b9 --- /dev/null +++ b/src/trx-server/src/decode_logs.rs @@ -0,0 +1,151 @@ +// SPDX-FileCopyrightText: 2025 Stanislaw Grams +// +// SPDX-License-Identifier: BSD-2-Clause + +use std::fs::{create_dir_all, File, OpenOptions}; +use std::io::{BufWriter, Write}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use chrono::Utc; +use serde_json::json; +use tracing::warn; + +use crate::config::DecodeLogsConfig; +use trx_core::decode::{AprsPacket, CwEvent, Ft8Message, WsprMessage}; + +struct DecoderFileLogger { + base_dir: PathBuf, + file_template: String, + state: Mutex, + label: &'static str, +} + +struct DecoderFileState { + current_file_name: String, + writer: BufWriter, +} + +impl DecoderFileLogger { + fn resolve_file_name(template: &str) -> String { + let now = Utc::now(); + template + .replace("%YYYY%", &now.format("%Y").to_string()) + .replace("%MM%", &now.format("%m").to_string()) + .replace("%DD%", &now.format("%d").to_string()) + } + + fn open_writer(path: &Path, label: &'static str) -> Result, String> { + if let Some(parent) = path.parent() { + create_dir_all(parent) + .map_err(|e| format!("create {} log dir '{}': {}", label, parent.display(), e))?; + } + let file = OpenOptions::new() + .create(true) + .append(true) + .open(path) + .map_err(|e| format!("open {} log '{}': {}", label, path.display(), e))?; + Ok(BufWriter::new(file)) + } + + fn open(base_dir: &Path, template: &str, label: &'static str) -> Result { + let file_name = Self::resolve_file_name(template); + let path = base_dir.join(&file_name); + let writer = Self::open_writer(&path, label)?; + Ok(Self { + base_dir: base_dir.to_path_buf(), + file_template: template.to_string(), + state: Mutex::new(DecoderFileState { + current_file_name: file_name, + writer, + }), + label, + }) + } + + fn write_payload(&self, payload: &T) { + let ts_ms = match SystemTime::now().duration_since(UNIX_EPOCH) { + Ok(d) => d.as_millis() as u64, + Err(_) => 0, + }; + let line = json!({ + "ts_ms": ts_ms, + "decoder": self.label, + "payload": payload, + }); + let Ok(mut state) = self.state.lock() else { + warn!("decode log mutex poisoned for {}", self.label); + return; + }; + + let next_file_name = Self::resolve_file_name(&self.file_template); + if next_file_name != state.current_file_name { + let next_path = self.base_dir.join(&next_file_name); + match Self::open_writer(&next_path, self.label) { + Ok(next_writer) => { + state.current_file_name = next_file_name; + state.writer = next_writer; + } + Err(e) => { + warn!("decode log reopen failed for {}: {}", self.label, e); + return; + } + } + } + + if serde_json::to_writer(&mut state.writer, &line).is_err() { + warn!("decode log serialization failed for {}", self.label); + return; + } + if state.writer.write_all(b"\n").is_err() { + warn!("decode log write failed for {}", self.label); + return; + } + let _ = state.writer.flush(); + } +} + +pub struct DecoderLoggers { + aprs: DecoderFileLogger, + cw: DecoderFileLogger, + ft8: DecoderFileLogger, + wspr: DecoderFileLogger, +} + +impl DecoderLoggers { + pub fn from_config(cfg: &DecodeLogsConfig) -> Result>, String> { + if !cfg.enabled { + return Ok(None); + } + + let base_dir = PathBuf::from(cfg.dir.trim()); + create_dir_all(&base_dir) + .map_err(|e| format!("create decode log dir '{}': {}", base_dir.display(), e))?; + + let loggers = Self { + aprs: DecoderFileLogger::open(&base_dir, &cfg.aprs_file, "aprs")?, + cw: DecoderFileLogger::open(&base_dir, &cfg.cw_file, "cw")?, + ft8: DecoderFileLogger::open(&base_dir, &cfg.ft8_file, "ft8")?, + wspr: DecoderFileLogger::open(&base_dir, &cfg.wspr_file, "wspr")?, + }; + + Ok(Some(Arc::new(loggers))) + } + + pub fn log_aprs(&self, pkt: &AprsPacket) { + self.aprs.write_payload(pkt); + } + + pub fn log_cw(&self, evt: &CwEvent) { + self.cw.write_payload(evt); + } + + pub fn log_ft8(&self, msg: &Ft8Message) { + self.ft8.write_payload(msg); + } + + pub fn log_wspr(&self, msg: &WsprMessage) { + self.wspr.write_payload(msg); + } +} diff --git a/src/trx-server/src/main.rs b/src/trx-server/src/main.rs index 59e1021..c256bab 100644 --- a/src/trx-server/src/main.rs +++ b/src/trx-server/src/main.rs @@ -5,6 +5,7 @@ mod audio; mod config; mod decode; +mod decode_logs; mod error; mod listener; mod pskreporter; @@ -33,6 +34,7 @@ use trx_core::rig::state::RigState; use trx_core::DynResult; use config::ServerConfig; +use decode_logs::DecoderLoggers; const PKG_DESCRIPTION: &str = concat!(env!("CARGO_PKG_NAME"), " - rig server daemon"); const RIG_TASK_CHANNEL_BUFFER: usize = 32; @@ -402,6 +404,14 @@ async fn main() -> DynResult<()> { } } + let decoder_logs = match DecoderLoggers::from_config(&cfg.decode_logs) { + Ok(v) => v, + Err(e) => { + warn!("Decoder file logging disabled: {}", e); + None + } + }; + if cfg.audio.rx_enabled { let _capture_thread = audio::spawn_audio_capture(&cfg.audio, rx_audio_tx.clone(), Some(pcm_tx.clone())); @@ -413,9 +423,10 @@ async fn main() -> DynResult<()> { let aprs_sr = cfg.audio.sample_rate; let aprs_ch = cfg.audio.channels; let aprs_shutdown_rx = shutdown_rx.clone(); + let aprs_logs = decoder_logs.clone(); task_handles.push(tokio::spawn(async move { tokio::select! { - _ = audio::run_aprs_decoder(aprs_sr, aprs_ch as u16, aprs_pcm_rx, aprs_state_rx, aprs_decode_tx) => {} + _ = audio::run_aprs_decoder(aprs_sr, aprs_ch as u16, aprs_pcm_rx, aprs_state_rx, aprs_decode_tx, aprs_logs) => {} _ = wait_for_shutdown(aprs_shutdown_rx) => {} } })); @@ -427,9 +438,10 @@ async fn main() -> DynResult<()> { let cw_sr = cfg.audio.sample_rate; let cw_ch = cfg.audio.channels; let cw_shutdown_rx = shutdown_rx.clone(); + let cw_logs = decoder_logs.clone(); task_handles.push(tokio::spawn(async move { tokio::select! { - _ = audio::run_cw_decoder(cw_sr, cw_ch as u16, cw_pcm_rx, cw_state_rx, cw_decode_tx) => {} + _ = audio::run_cw_decoder(cw_sr, cw_ch as u16, cw_pcm_rx, cw_state_rx, cw_decode_tx, cw_logs) => {} _ = wait_for_shutdown(cw_shutdown_rx) => {} } })); @@ -441,9 +453,10 @@ async fn main() -> DynResult<()> { let ft8_sr = cfg.audio.sample_rate; let ft8_ch = cfg.audio.channels; let ft8_shutdown_rx = shutdown_rx.clone(); + let ft8_logs = decoder_logs.clone(); task_handles.push(tokio::spawn(async move { tokio::select! { - _ = audio::run_ft8_decoder(ft8_sr, ft8_ch as u16, ft8_pcm_rx, ft8_state_rx, ft8_decode_tx) => {} + _ = audio::run_ft8_decoder(ft8_sr, ft8_ch as u16, ft8_pcm_rx, ft8_state_rx, ft8_decode_tx, ft8_logs) => {} _ = wait_for_shutdown(ft8_shutdown_rx) => {} } })); @@ -455,9 +468,10 @@ async fn main() -> DynResult<()> { let wspr_sr = cfg.audio.sample_rate; let wspr_ch = cfg.audio.channels; let wspr_shutdown_rx = shutdown_rx.clone(); + let wspr_logs = decoder_logs.clone(); task_handles.push(tokio::spawn(async move { tokio::select! { - _ = audio::run_wspr_decoder(wspr_sr, wspr_ch as u16, wspr_pcm_rx, wspr_state_rx, wspr_decode_tx) => {} + _ = audio::run_wspr_decoder(wspr_sr, wspr_ch as u16, wspr_pcm_rx, wspr_state_rx, wspr_decode_tx, wspr_logs) => {} _ = wait_for_shutdown(wspr_shutdown_rx) => {} } })); diff --git a/trx-server.toml.example b/trx-server.toml.example index 967ee06..c2f89d0 100644 --- a/trx-server.toml.example +++ b/trx-server.toml.example @@ -75,3 +75,19 @@ port = 4739 # Optional receiver locator (4 or 6-char Maidenhead). # If omitted, it is derived from [general].latitude/[general].longitude. # receiver_locator = "JO93" + +[decode_logs] +# Optional decoder message logs to files (APRS/CW/FT8/WSPR) +enabled = false + +# Base directory for decoder logs. +# Default (if omitted): $XDG_DATA_HOME/trx-rs/decoders +# Fallback: logs/decoders +# dir = "/path/to/decoder-logs" + +# Per-decoder log file names +# Supported tokens: %YYYY% %MM% %DD% +aprs_file = "TRXRS-APRS-%YYYY%-%MM%-%DD%.log" +cw_file = "TRXRS-CW-%YYYY%-%MM%-%DD%.log" +ft8_file = "TRXRS-FT8-%YYYY%-%MM%-%DD%.log" +wspr_file = "TRXRS-WSPR-%YYYY%-%MM%-%DD%.log"