[feat](trx-server): add optional per-decoder log files
Co-authored-by: OpenAI Codex <codex@openai.com> Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
This commit is contained in:
@@ -97,6 +97,19 @@ Notes:
|
|||||||
- If `receiver_locator` is omitted, server tries deriving it from `[general].latitude`/`longitude`.
|
- If `receiver_locator` is omitted, server tries deriving it from `[general].latitude`/`longitude`.
|
||||||
- PSK Reporter software ID is hardcoded to: `trx-server v<version> by SP2SJG`.
|
- PSK Reporter software ID is hardcoded to: `trx-server v<version> 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
|
## `trx-client` Options
|
||||||
|
|
||||||
### `[general]`
|
### `[general]`
|
||||||
|
|||||||
Generated
+100
-2
@@ -261,6 +261,15 @@ dependencies = [
|
|||||||
"pkg-config",
|
"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]]
|
[[package]]
|
||||||
name = "anstream"
|
name = "anstream"
|
||||||
version = "0.6.21"
|
version = "0.6.21"
|
||||||
@@ -454,6 +463,17 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
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]]
|
[[package]]
|
||||||
name = "clang-sys"
|
name = "clang-sys"
|
||||||
version = "1.8.1"
|
version = "1.8.1"
|
||||||
@@ -971,6 +991,30 @@ version = "1.0.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
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]]
|
[[package]]
|
||||||
name = "icu_collections"
|
name = "icu_collections"
|
||||||
version = "2.1.1"
|
version = "2.1.1"
|
||||||
@@ -2359,6 +2403,7 @@ name = "trx-server"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"cpal",
|
"cpal",
|
||||||
"dirs",
|
"dirs",
|
||||||
@@ -2575,7 +2620,7 @@ version = "0.54.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49"
|
checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-core",
|
"windows-core 0.54.0",
|
||||||
"windows-targets 0.52.6",
|
"windows-targets 0.52.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2585,10 +2630,45 @@ version = "0.54.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65"
|
checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-result",
|
"windows-result 0.1.2",
|
||||||
"windows-targets 0.52.6",
|
"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]]
|
[[package]]
|
||||||
name = "windows-link"
|
name = "windows-link"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
@@ -2604,6 +2684,24 @@ dependencies = [
|
|||||||
"windows-targets 0.52.6",
|
"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]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.45.0"
|
version = "0.45.0"
|
||||||
|
|||||||
@@ -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 + '<name>.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/<new>/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",
|
||||||
|
];
|
||||||
@@ -16,6 +16,7 @@ toml = { workspace = true }
|
|||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
clap = { workspace = true, features = ["derive"] }
|
clap = { workspace = true, features = ["derive"] }
|
||||||
dirs = "6"
|
dirs = "6"
|
||||||
|
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||||
bytes = "1"
|
bytes = "1"
|
||||||
cpal = "0.15"
|
cpal = "0.15"
|
||||||
opus = "0.3"
|
opus = "0.3"
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ use trx_wspr::WsprDecoder;
|
|||||||
|
|
||||||
use crate::config::AudioConfig;
|
use crate::config::AudioConfig;
|
||||||
use crate::decode;
|
use crate::decode;
|
||||||
|
use crate::decode_logs::DecoderLoggers;
|
||||||
|
|
||||||
const APRS_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
|
const APRS_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
|
||||||
const FT8_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<Vec<f32>>,
|
mut pcm_rx: broadcast::Receiver<Vec<f32>>,
|
||||||
mut state_rx: watch::Receiver<RigState>,
|
mut state_rx: watch::Receiver<RigState>,
|
||||||
decode_tx: broadcast::Sender<DecodedMessage>,
|
decode_tx: broadcast::Sender<DecodedMessage>,
|
||||||
|
decode_logs: Option<Arc<DecoderLoggers>>,
|
||||||
) {
|
) {
|
||||||
info!("APRS decoder started ({}Hz, {} ch)", sample_rate, channels);
|
info!("APRS decoder started ({}Hz, {} ch)", sample_rate, channels);
|
||||||
let mut decoder = decode::aprs::AprsDecoder::new(sample_rate);
|
let mut decoder = decode::aprs::AprsDecoder::new(sample_rate);
|
||||||
@@ -662,6 +664,9 @@ pub async fn run_aprs_decoder(
|
|||||||
was_active = true;
|
was_active = true;
|
||||||
for pkt in decoder.process_samples(&mono) {
|
for pkt in decoder.process_samples(&mono) {
|
||||||
record_aprs_packet(pkt.clone());
|
record_aprs_packet(pkt.clone());
|
||||||
|
if let Some(logger) = decode_logs.as_ref() {
|
||||||
|
logger.log_aprs(&pkt);
|
||||||
|
}
|
||||||
let _ = decode_tx.send(DecodedMessage::Aprs(pkt));
|
let _ = decode_tx.send(DecodedMessage::Aprs(pkt));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -703,6 +708,7 @@ pub async fn run_cw_decoder(
|
|||||||
mut pcm_rx: broadcast::Receiver<Vec<f32>>,
|
mut pcm_rx: broadcast::Receiver<Vec<f32>>,
|
||||||
mut state_rx: watch::Receiver<RigState>,
|
mut state_rx: watch::Receiver<RigState>,
|
||||||
decode_tx: broadcast::Sender<DecodedMessage>,
|
decode_tx: broadcast::Sender<DecodedMessage>,
|
||||||
|
decode_logs: Option<Arc<DecoderLoggers>>,
|
||||||
) {
|
) {
|
||||||
info!("CW decoder started ({}Hz, {} ch)", sample_rate, channels);
|
info!("CW decoder started ({}Hz, {} ch)", sample_rate, channels);
|
||||||
let mut decoder = decode::cw::CwDecoder::new(sample_rate);
|
let mut decoder = decode::cw::CwDecoder::new(sample_rate);
|
||||||
@@ -785,6 +791,9 @@ pub async fn run_cw_decoder(
|
|||||||
|
|
||||||
was_active = true;
|
was_active = true;
|
||||||
for evt in decoder.process_samples(&mono) {
|
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));
|
let _ = decode_tx.send(DecodedMessage::Cw(evt));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -872,6 +881,7 @@ pub async fn run_ft8_decoder(
|
|||||||
mut pcm_rx: broadcast::Receiver<Vec<f32>>,
|
mut pcm_rx: broadcast::Receiver<Vec<f32>>,
|
||||||
mut state_rx: watch::Receiver<RigState>,
|
mut state_rx: watch::Receiver<RigState>,
|
||||||
decode_tx: broadcast::Sender<DecodedMessage>,
|
decode_tx: broadcast::Sender<DecodedMessage>,
|
||||||
|
decode_logs: Option<Arc<DecoderLoggers>>,
|
||||||
) {
|
) {
|
||||||
info!("FT8 decoder started ({}Hz, {} ch)", sample_rate, channels);
|
info!("FT8 decoder started ({}Hz, {} ch)", sample_rate, channels);
|
||||||
let mut decoder = match Ft8Decoder::new(FT8_SAMPLE_RATE) {
|
let mut decoder = match Ft8Decoder::new(FT8_SAMPLE_RATE) {
|
||||||
@@ -957,6 +967,9 @@ pub async fn run_ft8_decoder(
|
|||||||
message: res.text,
|
message: res.text,
|
||||||
};
|
};
|
||||||
record_ft8_message(msg.clone());
|
record_ft8_message(msg.clone());
|
||||||
|
if let Some(logger) = decode_logs.as_ref() {
|
||||||
|
logger.log_ft8(&msg);
|
||||||
|
}
|
||||||
let _ = decode_tx.send(DecodedMessage::Ft8(msg));
|
let _ = decode_tx.send(DecodedMessage::Ft8(msg));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1004,6 +1017,7 @@ pub async fn run_wspr_decoder(
|
|||||||
mut pcm_rx: broadcast::Receiver<Vec<f32>>,
|
mut pcm_rx: broadcast::Receiver<Vec<f32>>,
|
||||||
mut state_rx: watch::Receiver<RigState>,
|
mut state_rx: watch::Receiver<RigState>,
|
||||||
decode_tx: broadcast::Sender<DecodedMessage>,
|
decode_tx: broadcast::Sender<DecodedMessage>,
|
||||||
|
decode_logs: Option<Arc<DecoderLoggers>>,
|
||||||
) {
|
) {
|
||||||
info!("WSPR decoder started ({}Hz, {} ch)", sample_rate, channels);
|
info!("WSPR decoder started ({}Hz, {} ch)", sample_rate, channels);
|
||||||
let decoder = match WsprDecoder::new() {
|
let decoder = match WsprDecoder::new() {
|
||||||
@@ -1069,6 +1083,9 @@ pub async fn run_wspr_decoder(
|
|||||||
message: res.message,
|
message: res.message,
|
||||||
};
|
};
|
||||||
record_wspr_message(msg.clone());
|
record_wspr_message(msg.clone());
|
||||||
|
if let Some(logger) = decode_logs.as_ref() {
|
||||||
|
logger.log_wspr(&msg);
|
||||||
|
}
|
||||||
let _ = decode_tx.send(DecodedMessage::Wspr(msg));
|
let _ = decode_tx.send(DecodedMessage::Wspr(msg));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ pub struct ServerConfig {
|
|||||||
pub audio: AudioConfig,
|
pub audio: AudioConfig,
|
||||||
/// PSK Reporter uplink configuration
|
/// PSK Reporter uplink configuration
|
||||||
pub pskreporter: PskReporterConfig,
|
pub pskreporter: PskReporterConfig,
|
||||||
|
/// Decoder file logging configuration
|
||||||
|
pub decode_logs: DecodeLogsConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// General application settings.
|
/// 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 {
|
impl ServerConfig {
|
||||||
pub fn validate(&self) -> Result<(), String> {
|
pub fn validate(&self) -> Result<(), String> {
|
||||||
validate_log_level(self.general.log_level.as_deref())?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,6 +405,7 @@ impl ServerConfig {
|
|||||||
listen: ListenConfig::default(),
|
listen: ListenConfig::default(),
|
||||||
audio: AudioConfig::default(),
|
audio: AudioConfig::default(),
|
||||||
pskreporter: PskReporterConfig::default(),
|
pskreporter: PskReporterConfig::default(),
|
||||||
|
decode_logs: DecodeLogsConfig::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
toml::to_string_pretty(&example).unwrap_or_default()
|
toml::to_string_pretty(&example).unwrap_or_default()
|
||||||
@@ -478,6 +538,11 @@ mod tests {
|
|||||||
assert_eq!(config.audio.sample_rate, 48000);
|
assert_eq!(config.audio.sample_rate, 48000);
|
||||||
assert!(!config.pskreporter.enabled);
|
assert!(!config.pskreporter.enabled);
|
||||||
assert_eq!(config.pskreporter.port, 4739);
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||||
|
//
|
||||||
|
// 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<DecoderFileState>,
|
||||||
|
label: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DecoderFileState {
|
||||||
|
current_file_name: String,
|
||||||
|
writer: BufWriter<File>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<BufWriter<File>, 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<Self, String> {
|
||||||
|
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<T: serde::Serialize>(&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<Option<Arc<Self>>, 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
mod audio;
|
mod audio;
|
||||||
mod config;
|
mod config;
|
||||||
mod decode;
|
mod decode;
|
||||||
|
mod decode_logs;
|
||||||
mod error;
|
mod error;
|
||||||
mod listener;
|
mod listener;
|
||||||
mod pskreporter;
|
mod pskreporter;
|
||||||
@@ -33,6 +34,7 @@ use trx_core::rig::state::RigState;
|
|||||||
use trx_core::DynResult;
|
use trx_core::DynResult;
|
||||||
|
|
||||||
use config::ServerConfig;
|
use config::ServerConfig;
|
||||||
|
use decode_logs::DecoderLoggers;
|
||||||
|
|
||||||
const PKG_DESCRIPTION: &str = concat!(env!("CARGO_PKG_NAME"), " - rig server daemon");
|
const PKG_DESCRIPTION: &str = concat!(env!("CARGO_PKG_NAME"), " - rig server daemon");
|
||||||
const RIG_TASK_CHANNEL_BUFFER: usize = 32;
|
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 {
|
if cfg.audio.rx_enabled {
|
||||||
let _capture_thread =
|
let _capture_thread =
|
||||||
audio::spawn_audio_capture(&cfg.audio, rx_audio_tx.clone(), Some(pcm_tx.clone()));
|
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_sr = cfg.audio.sample_rate;
|
||||||
let aprs_ch = cfg.audio.channels;
|
let aprs_ch = cfg.audio.channels;
|
||||||
let aprs_shutdown_rx = shutdown_rx.clone();
|
let aprs_shutdown_rx = shutdown_rx.clone();
|
||||||
|
let aprs_logs = decoder_logs.clone();
|
||||||
task_handles.push(tokio::spawn(async move {
|
task_handles.push(tokio::spawn(async move {
|
||||||
tokio::select! {
|
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) => {}
|
_ = wait_for_shutdown(aprs_shutdown_rx) => {}
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
@@ -427,9 +438,10 @@ async fn main() -> DynResult<()> {
|
|||||||
let cw_sr = cfg.audio.sample_rate;
|
let cw_sr = cfg.audio.sample_rate;
|
||||||
let cw_ch = cfg.audio.channels;
|
let cw_ch = cfg.audio.channels;
|
||||||
let cw_shutdown_rx = shutdown_rx.clone();
|
let cw_shutdown_rx = shutdown_rx.clone();
|
||||||
|
let cw_logs = decoder_logs.clone();
|
||||||
task_handles.push(tokio::spawn(async move {
|
task_handles.push(tokio::spawn(async move {
|
||||||
tokio::select! {
|
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) => {}
|
_ = wait_for_shutdown(cw_shutdown_rx) => {}
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
@@ -441,9 +453,10 @@ async fn main() -> DynResult<()> {
|
|||||||
let ft8_sr = cfg.audio.sample_rate;
|
let ft8_sr = cfg.audio.sample_rate;
|
||||||
let ft8_ch = cfg.audio.channels;
|
let ft8_ch = cfg.audio.channels;
|
||||||
let ft8_shutdown_rx = shutdown_rx.clone();
|
let ft8_shutdown_rx = shutdown_rx.clone();
|
||||||
|
let ft8_logs = decoder_logs.clone();
|
||||||
task_handles.push(tokio::spawn(async move {
|
task_handles.push(tokio::spawn(async move {
|
||||||
tokio::select! {
|
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) => {}
|
_ = wait_for_shutdown(ft8_shutdown_rx) => {}
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
@@ -455,9 +468,10 @@ async fn main() -> DynResult<()> {
|
|||||||
let wspr_sr = cfg.audio.sample_rate;
|
let wspr_sr = cfg.audio.sample_rate;
|
||||||
let wspr_ch = cfg.audio.channels;
|
let wspr_ch = cfg.audio.channels;
|
||||||
let wspr_shutdown_rx = shutdown_rx.clone();
|
let wspr_shutdown_rx = shutdown_rx.clone();
|
||||||
|
let wspr_logs = decoder_logs.clone();
|
||||||
task_handles.push(tokio::spawn(async move {
|
task_handles.push(tokio::spawn(async move {
|
||||||
tokio::select! {
|
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) => {}
|
_ = wait_for_shutdown(wspr_shutdown_rx) => {}
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -75,3 +75,19 @@ port = 4739
|
|||||||
# Optional receiver locator (4 or 6-char Maidenhead).
|
# Optional receiver locator (4 or 6-char Maidenhead).
|
||||||
# If omitted, it is derived from [general].latitude/[general].longitude.
|
# If omitted, it is derived from [general].latitude/[general].longitude.
|
||||||
# receiver_locator = "JO93"
|
# 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"
|
||||||
|
|||||||
Reference in New Issue
Block a user