diff --git a/Cargo.lock b/Cargo.lock index 5fed8d51..449ec57d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3141,6 +3141,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "toml", + "toml_edit 0.22.27", "tracing", "trx-core", "trx-decode-log", diff --git a/README.md b/README.md index 6597a294..1d65ab18 100644 --- a/README.md +++ b/README.md @@ -93,13 +93,17 @@ The wizard walks you through rig selection, serial port detection, audio settings, and frontend options, then writes `trx-server.toml` and `trx-client.toml`. -Alternatively, generate example configs and edit them by hand: +Alternatively, copy `trx-rs.toml.example` — a commented example covering every +setting — and edit it by hand: ```bash -./target/release/trx-server --print-config > trx-server.toml -./target/release/trx-client --print-config > trx-client.toml +cp trx-rs.toml.example trx-rs.toml +./target/release/trx-server --check-config --config trx-rs.toml ``` +`--check-config` reports everything wrong with a config without starting +anything. `--print-config` prints the same settings without comments. + ### 4. Run ```bash @@ -107,6 +111,9 @@ Alternatively, generate example configs and edit them by hand: ./target/release/trx-client --config trx-client.toml ``` +A single `trx-rs.toml` can configure both: the server reads its `[trx-server]` +section and the client reads `[trx-client]`. + Open the configured HTTP frontend address in a browser (default `http://localhost:8080`). ## How It Works diff --git a/docs/User-Manual.md b/docs/User-Manual.md index 2c4a10b0..62a235bc 100644 --- a/docs/User-Manual.md +++ b/docs/User-Manual.md @@ -17,30 +17,61 @@ frontends. ## Configuration -Both `trx-server` and `trx-client` use TOML configuration files. Use -`--print-config` to generate a fully commented example. +Both `trx-server` and `trx-client` read TOML. The server takes its settings +from the `[trx-server]` section and the client from `[trx-client]`, so one +`trx-rs.toml` can configure both — or each may live in its own file with the +section header left off. + +`trx-rs.toml.example` in the repository root is a complete, commented example +generated from the config definitions themselves. `--print-config` prints the +same settings without the comments. ### File Locations -**trx-server** lookup order: -1. `--config ` -2. `./trx-server.toml` -3. `~/.trx-server.toml` -4. `~/.config/trx-rs/server.toml` -5. `/etc/trx-rs/server.toml` +Both binaries use the same lookup order: -**trx-client** lookup order: 1. `--config ` -2. `./trx-client.toml` -3. `~/.config/trx-rs/client.toml` -4. `/etc/trx-rs/client.toml` +2. `./trx-rs.toml` +3. `~/.config/trx-rs/trx-rs.toml` +4. `/etc/trx-rs/trx-rs.toml` CLI arguments override config file values. -### Environment Variables +### Checking a Config -- `TRX_PLUGIN_DIRS`: additional plugin directories (path-separated), used by - both server and client. +`--check-config` loads the file, reports every problem it finds — unknown keys, +invalid values, listeners fighting over a port — and exits without starting +anything: + +```bash +trx-server --check-config --config trx-rs.toml +trx-client --check-config --config trx-rs.toml +``` + +Unknown keys are warnings by default, so a config written for a newer version +still runs on an older binary. `--strict-config` makes them fatal. + +`trx-configurator --check ` runs the same checks. + +### Environment Variables and Secrets + +Any string in the config may reference an environment variable as `${VAR}`; +an unset variable is an error rather than an empty value. + +Credentials can be kept out of the config entirely by pointing at a file +instead. Every secret has a `*_file` sibling — set one or the other, never +both: + +| Inline key | File key | Contents | +|------------|----------|----------| +| `[listen.auth].tokens` | `tokens_file` | one token per line | +| `[[remotes]].auth.token` | `token_file` | the token | +| `[frontends.http.auth].rx_passphrase` | `rx_passphrase_file` | the passphrase | +| `[frontends.http.auth].control_passphrase` | `control_passphrase_file` | the passphrase | +| `[frontends.http_json.auth].tokens` | `tokens_file` | one token per line | + +Blank lines and `#` comments are ignored in the list files. A config that holds +credentials inline and is readable by group or others is flagged at startup. ### Server Options @@ -96,6 +127,7 @@ CLI arguments override config file values. | Field | Type | Default | Description | |-------|------|---------|-------------| | `tokens` | string[] | `[]` | Allowed auth tokens (empty = no auth) | +| `tokens_file` | string | — | Read tokens from this file, one per line | #### `[audio]` @@ -197,6 +229,29 @@ Notes: Files are appended in JSON Lines format. Supported date tokens: `%YYYY%`, `%MM%`, `%DD%` (UTC). +#### `[decoders]` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `enabled` | string[] | all decoders | Decoders to run for this rig | +| `output_dir` | string | `"$XDG_CACHE_HOME/trx-rs"` | Base directory for decoders that write images | + +Valid decoder names: `aprs`, `aprs_hf`, `ais`, `cw`, `ft2`, `ft4`, `ft8`, +`lrpt`, `sstv`, `vdes`, `wefax`, `wspr` — the same names `[[sdr.channels]]` +uses. An unrecognised name is a config error. + +Every decoder runs by default, which costs real CPU on a small machine. On a +station that only works digital modes, listing just what you use is worth it: + +```toml +[decoders] +enabled = ["ft8", "ft4", "wspr"] +``` + +`sstv`, `wefax` and `lrpt` write images into a subdirectory of `output_dir` +named after the decoder. `ais` and `vdes` additionally require an SDR channel +configured to feed them. + #### Multi-Rig Configuration Use `[[rigs]]` arrays instead of the flat `[rig]` section for multi-rig setups: @@ -246,6 +301,25 @@ Rigs without an explicit `id` get auto-generated IDs like `ft817_0`, `soapysdr_1 | Field | Type | Default | Description | |-------|------|---------|-------------| | `token` | string | — | Auth token (must not be empty if set) | +| `token_file` | string | — | Read the token from this file instead | + +#### `[[remotes]]` + +Preferred over the single `[remote]` section: one entry per rig, each mapping a +short name to a server and an optional server-side rig id. + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `name` | string | — | Short name used everywhere in the client | +| `url` | string | — | Server address (`host:port`) | +| `rig_id` | string | — | Rig id on a multi-rig server | +| `auth.token` | string | — | Auth token | +| `auth.token_file` | string | — | Read the token from this file instead | +| `poll_interval_ms` | u64 | `750` | State poll interval | + +The `name` is the key used by `default_rig_name`, `rigctl.rig_ports`, +`audio.rig_urls`, `audio.rig_ports` and `decode_history_retention_min_by_rig`. +A name in any of those maps that no remote answers to is a config error. #### `[frontends.http]` @@ -254,6 +328,31 @@ Rigs without an explicit `id` get auto-generated IDs like `ft817_0`, `soapysdr_1 | `enabled` | bool | `true` | Enable web UI | | `listen` | ip | `127.0.0.1` | Bind address | | `port` | u16 | `8080` | Bind port | +| `default_rig_name` | string | — | Remote selected on startup | +| `initial_map_zoom` | u8 | `10` | Starting zoom for the APRS map | +| `show_sdr_gain_control` | bool | `true` | Expose the RF gain control | +| `bandplan_enabled` | bool | `true` | Show the bandplan strip | +| `bandplan_region` | string | `"iaru_r1"` | `iaru_r1`, `iaru_r2`, or `iaru_r3` | +| `decode_history_retention_min` | u64 | `1440` | Decode history retention | +| `decode_history_retention_min_by_rig` | table | `{}` | Per-remote retention override | +| `spectrum_coverage_margin_hz` | u32 | `50000` | Centre-retune guard margin | +| `spectrum_usable_span_ratio` | f32 | `0.92` | Usable fraction of the sampled span | + +#### `[frontends.http.auth]` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `enabled` | bool | `false` | Require a passphrase | +| `rx_passphrase` | string | — | Passphrase granting receive-only access | +| `rx_passphrase_file` | string | — | Read it from this file instead | +| `control_passphrase` | string | — | Passphrase granting full control | +| `control_passphrase_file` | string | — | Read it from this file instead | +| `tx_access_control_enabled` | bool | `true` | Hide TX from unauthenticated users | +| `session_ttl_min` | u64 | `480` | Session lifetime | +| `cookie_secure` | bool | `false` | Set Secure on the session cookie (needs HTTPS) | +| `cookie_same_site` | string | `"Lax"` | `Strict`, `Lax`, or `None` | + +With `enabled = true`, at least one passphrase must be set. #### `[frontends.rigctl]` @@ -261,7 +360,11 @@ Rigs without an explicit `id` get auto-generated IDs like `ft817_0`, `soapysdr_1 |-------|------|---------|-------------| | `enabled` | bool | `false` | Enable Hamlib rigctl | | `listen` | ip | `127.0.0.1` | Bind address | -| `port` | u16 | `4532` | Bind port | +| `rig_ports` | table | `{}` | Remote name → local port; one listener each | + +One listener is started per `rig_ports` entry, each routing to its rig, so +`rig_ports` must name at least one remote when the frontend is enabled. The +older single `port` key and `--rigctl-port` are ignored. #### `[frontends.http_json]` @@ -271,13 +374,17 @@ Rigs without an explicit `id` get auto-generated IDs like `ft817_0`, `soapysdr_1 | `listen` | ip | `127.0.0.1` | Bind address | | `port` | u16 | `0` | Bind port (0 = ephemeral) | | `auth.tokens` | string[] | `[]` | Allowed auth tokens | +| `auth.tokens_file` | string | — | Read tokens from this file, one per line | #### `[frontends.audio]` | Field | Type | Default | Description | |-------|------|---------|-------------| | `enabled` | bool | `true` | Enable audio client | -| `server_port` | u16 | `4531` | Server audio port | +| `server_url` | string | — | Audio endpoint for every remote | +| `rig_urls` | table | `{}` | Remote name → audio URL (wins over `server_url`) | +| `server_port` | u16 | `4531` | Fallback port when no URL is configured | +| `rig_ports` | table | `{}` | Remote name → port; superseded by `rig_urls` | | `bridge.enabled` | bool | `false` | Enable local CPAL audio bridge | | `bridge.rx_output_device` | string | — | Local playback device | | `bridge.tx_input_device` | string | — | Local capture device | @@ -290,13 +397,17 @@ loopback on Linux, BlackHole on macOS). ### CLI Override Summary **trx-server:** -`--config`, `--print-config`, `--rig`, `--access`, `--callsign`, `--listen`, -`--port`. SDR options are file-only. +`--config`, `--print-config`, `--check-config`, `--strict-config`, `--rig`, +`--access`, `--callsign`, `--listen`, `--port`. SDR options are file-only. **trx-client:** -`--config`, `--print-config`, `--url`, `--token`, `--poll-interval`, -`--frontend`, `--http-listen`, `--http-port`, `--rigctl-listen`, -`--rigctl-port`, `--http-json-listen`, `--http-json-port`, `--callsign`. +`--config`, `--print-config`, `--check-config`, `--strict-config`, `--url`, +`--token`, `--poll-interval`, `--rig-id`, `--frontend`, `--http-listen`, +`--http-port`, `--rigctl-listen`, `--http-json-listen`, `--http-json-port`, +`--callsign`. + +`--listen` on the server overrides the bind address of both the control +listener and every rig's audio listener. --- diff --git a/src/trx-config/Cargo.toml b/src/trx-config/Cargo.toml index 733ac0c6..62b19c62 100644 --- a/src/trx-config/Cargo.toml +++ b/src/trx-config/Cargo.toml @@ -18,6 +18,7 @@ trx-core = { path = "../trx-core" } trx-decode-log = { path = "../decoders/trx-decode-log" } trx-reporting = { path = "../trx-reporting" } serde_ignored = "0.1" +toml_edit = "0.22" [dev-dependencies] tempfile = "3" diff --git a/src/trx-config/examples/generate_example.rs b/src/trx-config/examples/generate_example.rs new file mode 100644 index 00000000..093048ba --- /dev/null +++ b/src/trx-config/examples/generate_example.rs @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// SPDX-License-Identifier: GPL-2.0-or-later + +//! Regenerate `trx-rs.toml.example` from the config structs. +//! +//! Run from anywhere in the workspace: +//! +//! ```text +//! cargo run -p trx-config --example generate_example +//! ``` +//! +//! A test in `trx_config::example` fails when the checked-in file no longer +//! matches, which is the reminder to run this. + +use std::path::PathBuf; + +fn main() -> std::io::Result<()> { + let target: PathBuf = std::env::args() + .nth(1) + .map(PathBuf::from) + .unwrap_or_else(|| { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../trx-rs.toml.example") + }); + + std::fs::write(&target, trx_config::example::combined_example())?; + println!("Wrote {}", target.display()); + Ok(()) +} diff --git a/src/trx-config/src/client.rs b/src/trx-config/src/client.rs index 4cd0df93..7d2f32de 100644 --- a/src/trx-config/src/client.rs +++ b/src/trx-config/src/client.rs @@ -807,13 +807,10 @@ impl ClientConfig { /// Generate an example configuration wrapped under the `[trx-client]` /// section header, suitable for use in a combined `trx-rs.toml` file. - pub fn example_combined_toml() -> String { - #[derive(serde::Serialize)] - struct Wrapper { - #[serde(rename = "trx-client")] - inner: ClientConfig, - } - let example = ClientConfig { + /// The example configuration used by `--print-config` and by the + /// generated `trx-rs.toml.example`. + pub fn example_config() -> Self { + ClientConfig { general: GeneralConfig { callsign: Some("N0CALL".to_string()), website_url: Some("https://haxx.space".to_string()), @@ -879,8 +876,21 @@ impl ClientConfig { http_json: HttpJsonFrontendConfig::default(), audio: AudioClientConfig::default(), }, - }; - toml::to_string_pretty(&Wrapper { inner: example }).unwrap_or_default() + } + } + + /// Generate an example configuration wrapped under the `[trx-client]` + /// section header, suitable for use in a combined `trx-rs.toml` file. + pub fn example_combined_toml() -> String { + #[derive(serde::Serialize)] + struct Wrapper { + #[serde(rename = "trx-client")] + inner: ClientConfig, + } + toml::to_string_pretty(&Wrapper { + inner: ClientConfig::example_config(), + }) + .unwrap_or_default() } } diff --git a/src/trx-config/src/example.rs b/src/trx-config/src/example.rs new file mode 100644 index 00000000..9537a332 --- /dev/null +++ b/src/trx-config/src/example.rs @@ -0,0 +1,255 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// SPDX-License-Identifier: GPL-2.0-or-later + +//! Generating the example configuration from the config structs. +//! +//! `trx-rs.toml.example` used to be maintained by hand and had fallen years +//! behind the code — no `[[rigs]]`, no `[[remotes]]`, no `[timeouts]`, no +//! bandplan settings. It is now produced from the structs themselves, so a new +//! field appears in the example the moment it exists, and a test fails if the +//! checked-in copy drifts. +//! +//! Section comments come from the table below. A section without an entry is +//! still emitted — only its explanatory text is missing — so forgetting to add +//! one can never drop a setting from the example. + +use toml_edit::{DocumentMut, Item}; + +use crate::{ClientConfig, ServerConfig}; + +const HEADER: &str = "\ +# trx-rs example configuration +# +# Generated from the config structs; regenerate with: +# cargo run -p trx-config --example generate_example +# +# Both sections are optional: trx-server reads [trx-server], trx-client reads +# [trx-client], and either may live in its own file with the section header +# omitted. Any string may use ${ENV_VAR}, and credentials may be moved out of +# this file with the matching *_file keys. +# +# Check a config without starting anything: +# trx-server --check-config --config trx-rs.toml +# trx-client --check-config --config trx-rs.toml +"; + +/// Explanatory comments for config sections, keyed by dotted path. +const SECTION_COMMENTS: &[(&str, &str)] = &[ + ("trx-server", "Server: drives the radio hardware."), + ( + "trx-server.general", + "Station identity. Coordinates feed PSKReporter and the map.", + ), + ( + "trx-server.rig", + "Single-rig layout. For several radios, delete this and use [[rigs]].", + ), + ( + "trx-server.rig.access", + "How to reach the radio: serial, tcp, or sdr.", + ), + ("trx-server.behavior", "CAT polling and retry behaviour."), + ( + "trx-server.listen", + "JSON control listener that trx-client connects to.", + ), + ( + "trx-server.listen.auth", + "Tokens clients must present. Empty means no authentication.\n\ + Use tokens_file = \"/etc/trx-rs/tokens\" to keep them out of this file.", + ), + ("trx-server.audio", "Opus audio stream for trx-client."), + ( + "trx-server.decoders", + "Which decoders run. Trimming this list saves real CPU on small boxes.\n\ + Valid names: aprs, aprs_hf, ais, cw, ft2, ft4, ft8, lrpt, sstv, vdes, wefax, wspr.", + ), + ( + "trx-server.pskreporter", + "Report FT8/FT4/WSPR spots to pskreporter.info.", + ), + ("trx-server.aprsfi", "Forward received APRS frames to APRS-IS."), + ("trx-server.decode_logs", "Write decodes to JSON Lines files."), + ( + "trx-server.sdr", + "SoapySDR pipeline; used when [rig.access] type = \"sdr\".", + ), + ("trx-server.sdr.gain", "\"auto\" for hardware AGC, or \"manual\"."), + ("trx-server.sdr.squelch", "Software squelch on demodulated audio."), + ( + "trx-server.sdr.noise_blanker", + "Impulse-noise suppression on the IQ stream.", + ), + ( + "trx-server.timeouts", + "Timeout and buffer tuning. The defaults suit most setups.", + ), + ("trx-client", "Client: exposes the radio to users."), + ("trx-client.general", "Labels shown in the web UI."), + ( + "trx-client.remote", + "Legacy single-remote form; prefer [[remotes]] below.", + ), + ( + "trx-client.frontends.http", + "Web UI. default_rig_name and the per-rig maps are keyed by the\n\ + [[remotes]] name, not the server-side rig id.", + ), + ( + "trx-client.frontends.http.auth", + "Passphrase login for the web UI. rx_passphrase_file and\n\ + control_passphrase_file keep the secrets out of this file.", + ), + ( + "trx-client.frontends.rigctl", + "Hamlib-compatible TCP interface, one listener per rig.", + ), + ("trx-client.frontends.http_json", "JSON-over-TCP control interface."), + ("trx-client.frontends.audio", "Where to fetch the audio stream from."), + ( + "trx-client.frontends.audio.bridge", + "Play RX audio on a local sound device and capture TX from one.", + ), +]; + +/// Render the combined `trx-rs.toml.example` contents. +pub fn combined_example() -> String { + let mut doc = DocumentMut::new(); + doc.decor_mut().set_prefix(HEADER); + + doc.insert("trx-server", section_item(&ServerConfig::example_config())); + doc.insert("trx-client", section_item(&ClientConfig::example_config())); + + // Each section was serialized on its own, so both carry table positions + // starting at zero and would otherwise render interleaved. + renumber_tables(&mut doc); + + for (path, comment) in SECTION_COMMENTS { + annotate(&mut doc, path, comment); + } + + doc.to_string() +} + +/// Renumber every table so the document renders in tree order. +fn renumber_tables(doc: &mut DocumentMut) { + fn walk(item: &mut Item, next: &mut usize) { + match item { + Item::Table(table) => { + table.set_position(*next); + *next += 1; + for (_, child) in table.iter_mut() { + walk(child, next); + } + } + Item::ArrayOfTables(array) => { + for table in array.iter_mut() { + table.set_position(*next); + *next += 1; + for (_, child) in table.iter_mut() { + walk(child, next); + } + } + } + _ => {} + } + } + + let mut next = 0; + for (_, item) in doc.as_table_mut().iter_mut() { + walk(item, &mut next); + } +} + +/// Serialize one config into a toml_edit table. +fn section_item(config: &T) -> Item { + let rendered = toml::to_string_pretty(config).unwrap_or_default(); + let doc: DocumentMut = rendered.parse().expect("serialized config must re-parse"); + Item::Table(doc.as_table().clone()) +} + +/// Attach a comment above the table at `path`, if it exists. +fn annotate(doc: &mut DocumentMut, path: &str, comment: &str) { + let mut item: Option<&mut Item> = None; + for segment in path.split('.') { + let next = match item { + None => doc.get_mut(segment), + Some(current) => current.as_table_mut().and_then(|t| t.get_mut(segment)), + }; + match next { + Some(found) => item = Some(found), + None => return, + } + } + + let Some(table) = item.and_then(|i| i.as_table_mut()) else { + return; + }; + let body: String = comment + .lines() + .map(|line| format!("# {}\n", line.trim_start())) + .collect(); + table.decor_mut().set_prefix(format!("\n{body}")); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ConfigFile; + + /// The checked-in example must match what the structs produce, so a new + /// config field cannot land without showing up in the example. + #[test] + fn test_checked_in_example_is_up_to_date() { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../trx-rs.toml.example") + .canonicalize() + .expect("example file must exist"); + let on_disk = std::fs::read_to_string(&path).expect("example file must be readable"); + assert_eq!( + on_disk, + combined_example(), + "trx-rs.toml.example is out of date; regenerate with \ + `cargo run -p trx-config --example generate_example`" + ); + } + + #[test] + fn test_example_loads_and_validates() { + let mut file = tempfile::NamedTempFile::new().unwrap(); + std::io::Write::write_all(&mut file, combined_example().as_bytes()).unwrap(); + + let server = ServerConfig::load_from_file(file.path()).expect("server section loads"); + assert!( + server.unknown_keys.is_empty(), + "the generated example must not contain unknown keys: {:?}", + server.unknown_keys + ); + server.config.validate().expect("server section validates"); + + let client = ClientConfig::load_from_file(file.path()).expect("client section loads"); + assert!( + client.unknown_keys.is_empty(), + "the generated example must not contain unknown keys: {:?}", + client.unknown_keys + ); + client.config.validate().expect("client section validates"); + } + + /// Every section that gained a comment must still exist under that path. + #[test] + fn test_section_comments_match_real_sections() { + let doc: DocumentMut = combined_example().parse().unwrap(); + for (path, _) in SECTION_COMMENTS { + let mut item = None; + for segment in path.split('.') { + item = match item { + None => doc.get(segment), + Some(current) => current.as_table().and_then(|t| t.get(segment)), + }; + assert!(item.is_some(), "commented section [{path}] no longer exists"); + } + } + } +} diff --git a/src/trx-config/src/lib.rs b/src/trx-config/src/lib.rs index e9cdf81e..336e1fe2 100644 --- a/src/trx-config/src/lib.rs +++ b/src/trx-config/src/lib.rs @@ -10,6 +10,7 @@ //! it with, so the two can never drift apart. pub mod client; +pub mod example; pub mod file; pub mod secrets; pub mod server; diff --git a/src/trx-config/src/server.rs b/src/trx-config/src/server.rs index 109e0e2e..586f83a2 100644 --- a/src/trx-config/src/server.rs +++ b/src/trx-config/src/server.rs @@ -727,13 +727,10 @@ impl ServerConfig { /// Generate an example configuration wrapped under the `[trx-server]` /// section header, suitable for use in a combined `trx-rs.toml` file. - pub fn example_combined_toml() -> String { - #[derive(serde::Serialize)] - struct Wrapper { - #[serde(rename = "trx-server")] - inner: ServerConfig, - } - let example = ServerConfig { + /// The example configuration used by `--print-config` and by the + /// generated `trx-rs.toml.example`. + pub fn example_config() -> Self { + ServerConfig { general: GeneralConfig { callsign: Some("N0CALL".to_string()), log_level: Some("info".to_string()), @@ -763,8 +760,21 @@ impl ServerConfig { sdr: SdrConfig::default(), timeouts: TimeoutsConfig::default(), rigs: Vec::new(), - }; - toml::to_string_pretty(&Wrapper { inner: example }).unwrap_or_default() + } + } + + /// Generate an example configuration wrapped under the `[trx-server]` + /// section header, suitable for use in a combined `trx-rs.toml` file. + pub fn example_combined_toml() -> String { + #[derive(serde::Serialize)] + struct Wrapper { + #[serde(rename = "trx-server")] + inner: ServerConfig, + } + toml::to_string_pretty(&Wrapper { + inner: ServerConfig::example_config(), + }) + .unwrap_or_default() } } diff --git a/trx-rs.toml.example b/trx-rs.toml.example index 1e7e65ee..9f552ee7 100644 --- a/trx-rs.toml.example +++ b/trx-rs.toml.example @@ -1,36 +1,60 @@ +# trx-rs example configuration +# +# Generated from the config structs; regenerate with: +# cargo run -p trx-config --example generate_example +# +# Both sections are optional: trx-server reads [trx-server], trx-client reads +# [trx-client], and either may live in its own file with the section header +# omitted. Any string may use ${ENV_VAR}, and credentials may be moved out of +# this file with the matching *_file keys. +# +# Check a config without starting anything: +# trx-server --check-config --config trx-rs.toml +# trx-client --check-config --config trx-rs.toml + +# Server: drives the radio hardware. [trx-server] rigs = [] +# Station identity. Coordinates feed PSKReporter and the map. [trx-server.general] callsign = "N0CALL" log_level = "info" latitude = 52.2297 longitude = 21.0122 +# Single-rig layout. For several radios, delete this and use [[rigs]]. [trx-server.rig] model = "ft817" initial_freq_hz = 144300000 initial_mode = "USB" +# How to reach the radio: serial, tcp, or sdr. [trx-server.rig.access] type = "serial" port = "/dev/ttyUSB0" baud = 9600 +# CAT polling and retry behaviour. [trx-server.behavior] poll_interval_ms = 500 poll_interval_tx_ms = 100 max_retries = 3 retry_base_delay_ms = 100 +vfo_prime = true +# JSON control listener that trx-client connects to. [trx-server.listen] enabled = true listen = "127.0.0.1" port = 4530 +# Tokens clients must present. Empty means no authentication. +# Use tokens_file = "/etc/trx-rs/tokens" to keep them out of this file. [trx-server.listen.auth] tokens = [] +# Opus audio stream for trx-client. [trx-server.audio] enabled = true listen = "127.0.0.1" @@ -42,25 +66,52 @@ channels = 2 frame_duration_ms = 20 bitrate_bps = 256000 +# Report FT8/FT4/WSPR spots to pskreporter.info. [trx-server.pskreporter] enabled = false host = "report.pskreporter.info" port = 4739 +# Forward received APRS frames to APRS-IS. [trx-server.aprsfi] enabled = false host = "rotate.aprs.net" port = 14580 passcode = -1 +beacon = false +beacon_interval_secs = 1200 +beacon_symbol_table = "/" +beacon_symbol_code = "-" +# Write decodes to JSON Lines files. [trx-server.decode_logs] enabled = false -dir = "/path/to/log/dir" +dir = "/Users/sjg/Library/Caches/trx-rs/decoders" 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" +wefax_file = "TRXRS-WEFAX-%YYYY%-%MM%-%DD%.log" +# Which decoders run. Trimming this list saves real CPU on small boxes. +# Valid names: aprs, aprs_hf, ais, cw, ft2, ft4, ft8, lrpt, sstv, vdes, wefax, wspr. +[trx-server.decoders] +enabled = [ + "aprs", + "aprs_hf", + "ais", + "cw", + "ft2", + "ft4", + "ft8", + "lrpt", + "sstv", + "vdes", + "wefax", + "wspr", +] + +# SoapySDR pipeline; used when [rig.access] type = "sdr". [trx-server.sdr] sample_rate = 1920000 bandwidth = 1500000 @@ -69,16 +120,35 @@ center_offset_hz = 100000 channels = [] max_virtual_channels = 4 +# "auto" for hardware AGC, or "manual". [trx-server.sdr.gain] mode = "auto" value = 30.0 +# Software squelch on demodulated audio. [trx-server.sdr.squelch] enabled = false threshold_db = -65.0 hysteresis_db = 3.0 tail_ms = 180 +# Impulse-noise suppression on the IQ stream. +[trx-server.sdr.noise_blanker] +enabled = false +threshold = 10.0 + +# Timeout and buffer tuning. The defaults suit most setups. +[trx-server.timeouts] +command_exec_timeout_ms = 10000 +poll_refresh_timeout_ms = 8000 +io_timeout_ms = 10000 +request_timeout_ms = 12000 +rig_task_channel_buffer = 32 + +# Client: exposes the radio to users. +[trx-client] + +# Labels shown in the web UI. [trx-client.general] callsign = "N0CALL" website_url = "https://haxx.space" @@ -86,24 +156,49 @@ website_name = "haxx.space" ais_vessel_url_base = "https://www.vesselfinder.com/?mmsi=" log_level = "info" +# Legacy single-remote form; prefer [[remotes]] below. [trx-client.remote] -url = "192.168.1.100:9000" -rig_id = "hf" poll_interval_ms = 750 [trx-client.remote.auth] + +[[trx-client.remotes]] +name = "home-hf" +url = "192.168.1.100:4530" +rig_id = "hf" +poll_interval_ms = 750 + +[trx-client.remotes.auth] token = "my-token" +[[trx-client.remotes]] +name = "home-vhf" +url = "192.168.1.100:4530" +rig_id = "vhf" +poll_interval_ms = 750 + +[trx-client.remotes.auth] +token = "my-token" + +# Web UI. default_rig_name and the per-rig maps are keyed by the +# [[remotes]] name, not the server-side rig id. [trx-client.frontends.http] enabled = true listen = "127.0.0.1" port = 8080 -default_rig_id = "hf" +default_rig_name = "home-hf" initial_map_zoom = 10 spectrum_coverage_margin_hz = 50000 spectrum_usable_span_ratio = 0.9200000166893005 show_sdr_gain_control = true +bandplan_enabled = true +bandplan_region = "iaru_r1" +decode_history_retention_min = 1440 +[trx-client.frontends.http.decode_history_retention_min_by_rig] + +# Passphrase login for the web UI. rx_passphrase_file and +# control_passphrase_file keep the secrets out of this file. [trx-client.frontends.http.auth] enabled = false rx_passphrase = "rx-passphrase-example" @@ -113,6 +208,7 @@ session_ttl_min = 480 cookie_secure = false cookie_same_site = "Lax" +# Hamlib-compatible TCP interface, one listener per rig. [trx-client.frontends.rigctl] enabled = false listen = "127.0.0.1" @@ -120,6 +216,7 @@ port = 4532 [trx-client.frontends.rigctl.rig_ports] +# JSON-over-TCP control interface. [trx-client.frontends.http_json] enabled = true listen = "127.0.0.1" @@ -128,12 +225,16 @@ port = 0 [trx-client.frontends.http_json.auth] tokens = [] +# Where to fetch the audio stream from. [trx-client.frontends.audio] enabled = true server_port = 4531 +[trx-client.frontends.audio.rig_urls] + [trx-client.frontends.audio.rig_ports] +# Play RX audio on a local sound device and capture TX from one. [trx-client.frontends.audio.bridge] enabled = false bitrate_bps = 192000