Compare commits

..
1 Commits
Author SHA1 Message Date
sjgandClaude Opus 5 dbab4c5b99 [feat](trx-rs): make spectrum affordable over a slow link
CI / lint (pull_request) Successful in 2m23s
CI / test (pull_request) Successful in 9m35s
CI / frontend (pull_request) Successful in 4m34s
CI / reuse (pull_request) Successful in 6s
Spectrum dominates the server↔client connection, and all three things that
govern its cost were working against a poor link.

**It was polled, one round trip per frame.** The client asked for a frame every
50 ms on a dedicated connection and waited for the reply, so the frame rate was
capped at 1/RTT — on a 200 ms link, five frames a second no matter what was
configured.  Add SubscribeSpectrum alongside the existing SubscribeMeter: the
server pushes frames from a per-rig broadcast that rig_task fills only while
somebody is subscribed.  A server too old to know the command answers with an
error and leaves the connection usable, so the client falls back to polling on
the same connection without reconnecting.

**Bins were JSON floats.** 1024 bins spelled out as decimal text is around
10 KB a frame, ~200 KB/s at full rate — while the very next hop, client to
browser, already sends the same information as base64 i8 in about 1.4 KB.  Bins
now travel base64-encoded whole dBFS, the resolution the display draws at
anyway.  Decoding still accepts the old array form.

**Nothing was tunable.** [sdr].spectrum_fft_size and [sdr].spectrum_interval_ms
replace the compile-time FFT size and cadence; [[remotes]].spectrum_interval_ms
lets the client ask for less.  512 bins at 5 frames/s is roughly 3.5 KB/s
against roughly 200 KB/s before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-06 23:58:26 +02:00
31 changed files with 3616 additions and 6322 deletions
+1 -8
View File
@@ -24,12 +24,6 @@ cargo test -p trx-core
./target/release/trx-server --print-config > trx-server.toml ./target/release/trx-server --print-config > trx-server.toml
./target/release/trx-client --print-config > trx-client.toml ./target/release/trx-client --print-config > trx-client.toml
# Validate a config without starting anything (reports every problem)
./target/release/trx-server --check-config --config trx-rs.toml
# Regenerate trx-rs.toml.example after changing a config struct
cargo run -p trx-config --example generate_example
# Run server # Run server
./target/release/trx-server --config trx-server.toml ./target/release/trx-server --config trx-server.toml
# or via CLI args: # or via CLI args:
@@ -47,8 +41,7 @@ This is a Cargo workspace. All crates live under `src/`:
src/ src/
trx-core/ # Core types, traits, state machine, controller (~3,500 LOC) trx-core/ # Core types, traits, state machine, controller (~3,500 LOC)
trx-protocol/ # Client↔server protocol DTOs, auth, codec, mapping (~1,100 LOC) trx-protocol/ # Client↔server protocol DTOs, auth, codec, mapping (~1,100 LOC)
trx-app/ # Shared application helpers (logging init, name normalization) trx-app/ # Shared application helpers (config paths, logging init)
trx-config/ # Client + server config structs, loader, validators (~2,500 LOC)
trx-reporting/ # PSKReporter UDP uplink + APRS-IS TCP uplink (~1,150 LOC) trx-reporting/ # PSKReporter UDP uplink + APRS-IS TCP uplink (~1,150 LOC)
trx-server/ # Server binary: rig_task, audio pipeline, listener (~3,700 LOC) trx-server/ # Server binary: rig_task, audio pipeline, listener (~3,700 LOC)
trx-backend/ # Backend abstraction trait + factory + dummy trx-backend/ # Backend abstraction trait + factory + dummy
Generated
+4 -31
View File
@@ -2412,16 +2412,6 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "serde_ignored"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115dffd5f3853e06e746965a20dcbae6ee747ae30b543d91b0e089668bb07798"
dependencies = [
"serde",
"serde_core",
]
[[package]] [[package]]
name = "serde_json" name = "serde_json"
version = "1.0.149" version = "1.0.149"
@@ -3041,6 +3031,10 @@ dependencies = [
name = "trx-app" name = "trx-app"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"dirs",
"serde",
"thiserror 2.0.18",
"toml",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
] ]
@@ -3121,7 +3115,6 @@ dependencies = [
"toml", "toml",
"tracing", "tracing",
"trx-app", "trx-app",
"trx-config",
"trx-core", "trx-core",
"trx-frontend", "trx-frontend",
"trx-frontend-http", "trx-frontend-http",
@@ -3131,23 +3124,6 @@ dependencies = [
"uuid", "uuid",
] ]
[[package]]
name = "trx-config"
version = "0.1.0"
dependencies = [
"dirs",
"serde",
"serde_ignored",
"tempfile",
"thiserror 2.0.18",
"toml",
"toml_edit 0.22.27",
"tracing",
"trx-core",
"trx-decode-log",
"trx-reporting",
]
[[package]] [[package]]
name = "trx-configurator" name = "trx-configurator"
version = "0.1.0" version = "0.1.0"
@@ -3156,9 +3132,7 @@ dependencies = [
"dialoguer", "dialoguer",
"tempfile", "tempfile",
"tokio-serial", "tokio-serial",
"toml",
"toml_edit 0.22.27", "toml_edit 0.22.27",
"trx-config",
] ]
[[package]] [[package]]
@@ -3321,7 +3295,6 @@ dependencies = [
"trx-app", "trx-app",
"trx-aprs", "trx-aprs",
"trx-backend", "trx-backend",
"trx-config",
"trx-core", "trx-core",
"trx-cw", "trx-cw",
"trx-decode-log", "trx-decode-log",
-1
View File
@@ -18,7 +18,6 @@ members = [
"src/trx-core", "src/trx-core",
"src/trx-protocol", "src/trx-protocol",
"src/trx-app", "src/trx-app",
"src/trx-config",
"src/trx-reporting", "src/trx-reporting",
"src/trx-server", "src/trx-server",
"src/trx-server/trx-backend", "src/trx-server/trx-backend",
+3 -10
View File
@@ -93,17 +93,13 @@ The wizard walks you through rig selection, serial port detection, audio
settings, and frontend options, then writes `trx-server.toml` and settings, and frontend options, then writes `trx-server.toml` and
`trx-client.toml`. `trx-client.toml`.
Alternatively, copy `trx-rs.toml.example` — a commented example covering every Alternatively, generate example configs and edit them by hand:
setting — and edit it by hand:
```bash ```bash
cp trx-rs.toml.example trx-rs.toml ./target/release/trx-server --print-config > trx-server.toml
./target/release/trx-server --check-config --config trx-rs.toml ./target/release/trx-client --print-config > trx-client.toml
``` ```
`--check-config` reports everything wrong with a config without starting
anything. `--print-config` prints the same settings without comments.
### 4. Run ### 4. Run
```bash ```bash
@@ -111,9 +107,6 @@ anything. `--print-config` prints the same settings without comments.
./target/release/trx-client --config trx-client.toml ./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`). Open the configured HTTP frontend address in a browser (default `http://localhost:8080`).
## How It Works ## How It Works
+23 -134
View File
@@ -17,61 +17,30 @@ frontends.
## Configuration ## Configuration
Both `trx-server` and `trx-client` read TOML. The server takes its settings Both `trx-server` and `trx-client` use TOML configuration files. Use
from the `[trx-server]` section and the client from `[trx-client]`, so one `--print-config` to generate a fully commented example.
`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 ### File Locations
Both binaries use the same lookup order: **trx-server** lookup order:
1. `--config <FILE>` 1. `--config <FILE>`
2. `./trx-rs.toml` 2. `./trx-server.toml`
3. `~/.config/trx-rs/trx-rs.toml` 3. `~/.trx-server.toml`
4. `/etc/trx-rs/trx-rs.toml` 4. `~/.config/trx-rs/server.toml`
5. `/etc/trx-rs/server.toml`
**trx-client** lookup order:
1. `--config <FILE>`
2. `./trx-client.toml`
3. `~/.config/trx-rs/client.toml`
4. `/etc/trx-rs/client.toml`
CLI arguments override config file values. CLI arguments override config file values.
### Checking a Config ### Environment Variables
`--check-config` loads the file, reports every problem it finds — unknown keys, - `TRX_PLUGIN_DIRS`: additional plugin directories (path-separated), used by
invalid values, listeners fighting over a port — and exits without starting both server and client.
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 <FILE>` 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 ### Server Options
@@ -127,7 +96,6 @@ credentials inline and is readable by group or others is flagged at startup.
| Field | Type | Default | Description | | Field | Type | Default | Description |
|-------|------|---------|-------------| |-------|------|---------|-------------|
| `tokens` | string[] | `[]` | Allowed auth tokens (empty = no auth) | | `tokens` | string[] | `[]` | Allowed auth tokens (empty = no auth) |
| `tokens_file` | string | — | Read tokens from this file, one per line |
#### `[audio]` #### `[audio]`
@@ -236,29 +204,6 @@ Notes:
Files are appended in JSON Lines format. Supported date tokens: `%YYYY%`, Files are appended in JSON Lines format. Supported date tokens: `%YYYY%`,
`%MM%`, `%DD%` (UTC). `%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 #### Multi-Rig Configuration
Use `[[rigs]]` arrays instead of the flat `[rig]` section for multi-rig setups: Use `[[rigs]]` arrays instead of the flat `[rig]` section for multi-rig setups:
@@ -309,25 +254,6 @@ Rigs without an explicit `id` get auto-generated IDs like `ft817_0`, `soapysdr_1
| Field | Type | Default | Description | | Field | Type | Default | Description |
|-------|------|---------|-------------| |-------|------|---------|-------------|
| `token` | string | — | Auth token (must not be empty if set) | | `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]` #### `[frontends.http]`
@@ -336,31 +262,6 @@ A name in any of those maps that no remote answers to is a config error.
| `enabled` | bool | `true` | Enable web UI | | `enabled` | bool | `true` | Enable web UI |
| `listen` | ip | `127.0.0.1` | Bind address | | `listen` | ip | `127.0.0.1` | Bind address |
| `port` | u16 | `8080` | Bind port | | `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]` #### `[frontends.rigctl]`
@@ -368,11 +269,7 @@ With `enabled = true`, at least one passphrase must be set.
|-------|------|---------|-------------| |-------|------|---------|-------------|
| `enabled` | bool | `false` | Enable Hamlib rigctl | | `enabled` | bool | `false` | Enable Hamlib rigctl |
| `listen` | ip | `127.0.0.1` | Bind address | | `listen` | ip | `127.0.0.1` | Bind address |
| `rig_ports` | table | `{}` | Remote name → local port; one listener each | | `port` | u16 | `4532` | Bind port |
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]` #### `[frontends.http_json]`
@@ -382,17 +279,13 @@ older single `port` key and `--rigctl-port` are ignored.
| `listen` | ip | `127.0.0.1` | Bind address | | `listen` | ip | `127.0.0.1` | Bind address |
| `port` | u16 | `0` | Bind port (0 = ephemeral) | | `port` | u16 | `0` | Bind port (0 = ephemeral) |
| `auth.tokens` | string[] | `[]` | Allowed auth tokens | | `auth.tokens` | string[] | `[]` | Allowed auth tokens |
| `auth.tokens_file` | string | — | Read tokens from this file, one per line |
#### `[frontends.audio]` #### `[frontends.audio]`
| Field | Type | Default | Description | | Field | Type | Default | Description |
|-------|------|---------|-------------| |-------|------|---------|-------------|
| `enabled` | bool | `true` | Enable audio client | | `enabled` | bool | `true` | Enable audio client |
| `server_url` | string | — | Audio endpoint for every remote | | `server_port` | u16 | `4531` | Server audio port |
| `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.enabled` | bool | `false` | Enable local CPAL audio bridge |
| `bridge.rx_output_device` | string | — | Local playback device | | `bridge.rx_output_device` | string | — | Local playback device |
| `bridge.tx_input_device` | string | — | Local capture device | | `bridge.tx_input_device` | string | — | Local capture device |
@@ -440,17 +333,13 @@ roughly 200 KB/s for 1024 float bins at 20 frames/s.
### CLI Override Summary ### CLI Override Summary
**trx-server:** **trx-server:**
`--config`, `--print-config`, `--check-config`, `--strict-config`, `--rig`, `--config`, `--print-config`, `--rig`, `--access`, `--callsign`, `--listen`,
`--access`, `--callsign`, `--listen`, `--port`. SDR options are file-only. `--port`. SDR options are file-only.
**trx-client:** **trx-client:**
`--config`, `--print-config`, `--check-config`, `--strict-config`, `--url`, `--config`, `--print-config`, `--url`, `--token`, `--poll-interval`,
`--token`, `--poll-interval`, `--rig-id`, `--frontend`, `--http-listen`, `--frontend`, `--http-listen`, `--http-port`, `--rigctl-listen`,
`--http-port`, `--rigctl-listen`, `--http-json-listen`, `--http-json-port`, `--rigctl-port`, `--http-json-listen`, `--http-json-port`, `--callsign`.
`--callsign`.
`--listen` on the server overrides the bind address of both the control
listener and every rig's audio listener.
--- ---
+4
View File
@@ -9,5 +9,9 @@ edition = "2021"
license = "GPL-2.0-or-later" license = "GPL-2.0-or-later"
[dependencies] [dependencies]
serde = { workspace = true }
toml = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
tracing-subscriber = { workspace = true } tracing-subscriber = { workspace = true }
dirs = "6"
thiserror = "2"
+88
View File
@@ -0,0 +1,88 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
use serde::de::DeserializeOwned;
use std::path::{Path, PathBuf};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("Failed to read config file {0}: {1}")]
ReadError(PathBuf, String),
#[error("Failed to parse config file {0}: {1}")]
ParseError(PathBuf, String),
}
/// Returns the default search paths for `trx-rs.toml`
/// (current directory → XDG config → /etc).
fn config_search_paths() -> Vec<PathBuf> {
let mut paths = vec![PathBuf::from("trx-rs.toml")];
if let Some(config_dir) = dirs::config_dir() {
paths.push(config_dir.join("trx-rs").join("trx-rs.toml"));
}
paths.push(PathBuf::from("/etc/trx-rs/trx-rs.toml"));
paths
}
/// Extract and deserialize a named section from a TOML file.
///
/// Returns `Ok(Some(cfg))` when the section is present and parses cleanly,
/// `Ok(None)` when the section is absent, or `Err` on I/O / parse failure.
fn load_section_from_file<T: DeserializeOwned>(
path: &Path,
key: &str,
) -> Result<Option<T>, ConfigError> {
let content = std::fs::read_to_string(path)
.map_err(|e| ConfigError::ReadError(path.to_path_buf(), e.to_string()))?;
let table: toml::Table = toml::from_str(&content)
.map_err(|e| ConfigError::ParseError(path.to_path_buf(), e.to_string()))?;
let Some(section) = table.get(key) else {
return Ok(None);
};
// Re-serialize the section then parse as T so all serde defaults apply.
let section_toml = toml::to_string(section)
.map_err(|e| ConfigError::ParseError(path.to_path_buf(), e.to_string()))?;
let cfg = toml::from_str::<T>(&section_toml)
.map_err(|e| ConfigError::ParseError(path.to_path_buf(), e.to_string()))?;
Ok(Some(cfg))
}
/// Trait for loading configuration from a `trx-rs.toml` section.
pub trait ConfigFile: Sized + Default + DeserializeOwned {
/// Section key in `trx-rs.toml` (e.g. `"trx-server"` or `"trx-client"`).
fn section_key() -> &'static str;
/// Load the section from a specific file path.
///
/// Returns an error if the file cannot be read, is not valid TOML, or
/// does not contain the expected `[<section_key>]` header.
fn load_from_file(path: &Path) -> Result<Self, ConfigError> {
load_section_from_file::<Self>(path, Self::section_key())?.ok_or_else(|| {
ConfigError::ParseError(
path.to_path_buf(),
format!("missing [{}] section", Self::section_key()),
)
})
}
/// Search default paths (`trx-rs.toml` in CWD → XDG → /etc) and load
/// the first file that contains the expected section.
///
/// Returns `(config, path_where_found)` or `(Default::default(), None)`
/// when no config file is found.
fn load_from_default_paths() -> Result<(Self, Option<PathBuf>), ConfigError> {
for path in config_search_paths() {
if path.exists() {
if let Some(cfg) = load_section_from_file::<Self>(&path, Self::section_key())? {
return Ok((cfg, Some(path)));
}
}
}
Ok((Self::default(), None))
}
}
+4 -4
View File
@@ -2,12 +2,12 @@
// //
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
//! Shared application helpers. pub mod config;
//!
//! Configuration types and their loader live in the `trx-config` crate.
pub mod logging; pub mod logging;
pub mod shared_config;
pub mod util; pub mod util;
pub use config::{ConfigError, ConfigFile};
pub use logging::init_logging; pub use logging::init_logging;
pub use shared_config::{validate_log_level, validate_tokens};
pub use util::normalize_name; pub use util::normalize_name;
@@ -19,52 +19,6 @@
//! would either bloat both binaries with unused fields or require a trait //! would either bloat both binaries with unused fields or require a trait
//! abstraction that adds complexity without clear benefit. //! abstraction that adds complexity without clear benefit.
use std::net::IpAddr;
/// A socket a component intends to bind, and what it is for.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BoundSocket {
pub addr: IpAddr,
pub port: u16,
/// Human-readable owner, e.g. `[listen]` or `[frontends.http]`.
pub label: String,
}
impl BoundSocket {
pub fn new(addr: IpAddr, port: u16, label: impl Into<String>) -> Self {
Self {
addr,
port,
label: label.into(),
}
}
}
/// Reject two components trying to bind the same socket.
///
/// A wildcard address (`0.0.0.0` / `::`) conflicts with any other address on
/// the same port, since binding it claims every interface. Port 0 means "pick
/// an ephemeral port" and never conflicts.
pub fn check_socket_conflicts(sockets: &[BoundSocket]) -> Result<(), String> {
for (i, a) in sockets.iter().enumerate() {
if a.port == 0 {
continue;
}
for b in &sockets[i + 1..] {
if b.port != a.port {
continue;
}
if a.addr == b.addr || a.addr.is_unspecified() || b.addr.is_unspecified() {
return Err(format!(
"{} and {} would both bind {}:{}",
a.label, b.label, a.addr, a.port
));
}
}
}
Ok(())
}
/// Validate that a log level string is one of the accepted values. /// Validate that a log level string is one of the accepted values.
/// ///
/// Returns `Ok(())` when `level` is `None` (defaulting is handled elsewhere) /// Returns `Ok(())` when `level` is `None` (defaulting is handled elsewhere)
@@ -99,47 +53,6 @@ pub fn validate_tokens(path: &str, tokens: &[String]) -> Result<(), String> {
mod tests { mod tests {
use super::*; use super::*;
fn sock(addr: &str, port: u16, label: &str) -> BoundSocket {
BoundSocket::new(addr.parse().unwrap(), port, label)
}
#[test]
fn test_socket_conflicts_detects_exact_duplicate() {
let err = check_socket_conflicts(&[
sock("127.0.0.1", 8080, "[frontends.http]"),
sock("127.0.0.1", 8080, "[frontends.http_json]"),
])
.unwrap_err();
assert!(err.contains("127.0.0.1:8080"), "unexpected error: {err}");
}
#[test]
fn test_socket_conflicts_detects_wildcard_overlap() {
assert!(check_socket_conflicts(&[
sock("0.0.0.0", 4530, "[listen]"),
sock("127.0.0.1", 4530, "[audio]"),
])
.is_err());
}
#[test]
fn test_socket_conflicts_allows_distinct_addresses() {
assert!(check_socket_conflicts(&[
sock("127.0.0.1", 4530, "[listen]"),
sock("192.168.1.5", 4530, "[audio]"),
])
.is_ok());
}
#[test]
fn test_socket_conflicts_ignores_ephemeral_ports() {
assert!(check_socket_conflicts(&[
sock("127.0.0.1", 0, "[frontends.http_json]"),
sock("127.0.0.1", 0, "[frontends.http]"),
])
.is_ok());
}
#[test] #[test]
fn test_validate_log_level_none() { fn test_validate_log_level_none() {
assert!(validate_log_level(None).is_ok()); assert!(validate_log_level(None).is_ok());
-1
View File
@@ -21,7 +21,6 @@ uuid = { workspace = true }
cpal = "0.15" cpal = "0.15"
opus = "0.3" opus = "0.3"
trx-app = { path = "../trx-app" } trx-app = { path = "../trx-app" }
trx-config = { path = "../trx-config" }
trx-core = { path = "../trx-core" } trx-core = { path = "../trx-core" }
trx-protocol = { path = "../trx-protocol" } trx-protocol = { path = "../trx-protocol" }
trx-frontend = { path = "trx-frontend" } trx-frontend = { path = "trx-frontend" }
File diff suppressed because it is too large Load Diff
+8 -125
View File
@@ -20,7 +20,6 @@ use tokio::task::JoinHandle;
use tracing::{error, info}; use tracing::{error, info};
use trx_app::{init_logging, normalize_name}; use trx_app::{init_logging, normalize_name};
use trx_config::ConfigFile;
use trx_core::audio::AudioStreamInfo; use trx_core::audio::AudioStreamInfo;
use trx_core::decode::DecodedMessage; use trx_core::decode::DecodedMessage;
@@ -51,12 +50,6 @@ struct Cli {
/// Print example configuration and exit /// Print example configuration and exit
#[arg(long = "print-config")] #[arg(long = "print-config")]
print_config: bool, print_config: bool,
/// Treat unknown configuration keys as a fatal error
#[arg(long = "strict-config")]
strict_config: bool,
/// Validate the configuration and exit without starting anything
#[arg(long = "check-config")]
check_config: bool,
/// Remote server URL (host:port) /// Remote server URL (host:port)
#[arg(short = 'u', long = "url")] #[arg(short = 'u', long = "url")]
url: Option<String>, url: Option<String>,
@@ -81,7 +74,7 @@ struct Cli {
/// rigctl frontend listen address /// rigctl frontend listen address
#[arg(long = "rigctl-listen")] #[arg(long = "rigctl-listen")]
rigctl_listen: Option<IpAddr>, rigctl_listen: Option<IpAddr>,
/// Deprecated: ignored, use [frontends.rigctl].rig_ports /// rigctl frontend listen port
#[arg(long = "rigctl-port")] #[arg(long = "rigctl-port")]
rigctl_port: Option<u16>, rigctl_port: Option<u16>,
/// JSON TCP frontend listen address /// JSON TCP frontend listen address
@@ -116,65 +109,6 @@ async fn main() -> DynResult<()> {
Ok(()) Ok(())
} }
/// `--check-config`: report everything wrong with the configuration and exit.
///
/// Unlike startup, this reports every problem it finds rather than stopping at
/// the first, so a config can be fixed in one pass. The file is checked as
/// written, without CLI overrides.
fn check_config(loaded: &trx_config::ConfigLoad<ClientConfig>) -> DynResult<()> {
match &loaded.path {
Some(path) => println!("{}", path.display()),
None => println!("(no config file found; checking built-in defaults)"),
}
let mut warnings: Vec<String> = loaded.unknown_keys.iter().map(|k| k.to_string()).collect();
warnings.extend(ClientConfig::deprecations(&loaded.present_keys));
let mut cfg = loaded.config.clone();
let mut errors = Vec::new();
if let Err(e) = cfg.resolve_secrets(loaded.path.as_deref()) {
errors.push(e);
}
let cfg = &cfg;
let remotes = cfg.resolved_remotes();
if remotes.is_empty() {
warnings.push(
"no remotes configured; --url will be required at startup (add [[remotes]] entries)"
.to_string(),
);
}
errors.extend(cfg.validate_all());
if !remotes.is_empty() {
errors.extend(cfg.validate_resolved_all(&remotes));
}
for w in &warnings {
println!(" warning: {}", w);
}
for e in &errors {
println!(" error: {}", e);
}
if errors.is_empty() {
println!(
" OK: {} remote(s) configured: {}",
remotes.len(),
remotes
.iter()
.map(|r| r.name.as_str())
.collect::<Vec<_>>()
.join(", ")
);
if !warnings.is_empty() {
println!(" {} warning(s)", warnings.len());
}
Ok(())
} else {
Err(format!("{} error(s), {} warning(s)", errors.len(), warnings.len()).into())
}
}
/// Holds the state needed after async initialization completes. /// Holds the state needed after async initialization completes.
struct AppState { struct AppState {
shutdown_tx: watch::Sender<bool>, shutdown_tx: watch::Sender<bool>,
@@ -201,44 +135,20 @@ async fn async_init() -> DynResult<AppState> {
std::process::exit(0); std::process::exit(0);
} }
let loaded = if let Some(ref path) = cli.config { let (cfg, config_path) = if let Some(ref path) = cli.config {
ClientConfig::load_from_file(path)? let cfg = ClientConfig::load_from_file(path)?;
(cfg, Some(path.clone()))
} else { } else {
ClientConfig::load_from_default_paths()? ClientConfig::load_from_default_paths()?
}; };
let config_path = loaded.path.clone(); cfg.validate()
.map_err(|e| format!("Invalid client configuration: {}", e))?;
if cli.check_config { init_logging(cfg.general.log_level.as_deref());
match check_config(&loaded) {
Ok(()) => std::process::exit(0),
Err(e) => {
eprintln!("{}", e);
std::process::exit(1);
}
}
}
// Logging comes up before any config complaint so the warnings are visible.
init_logging(loaded.config.general.log_level.as_deref());
if let Some(ref path) = config_path { if let Some(ref path) = config_path {
info!("Loaded configuration from {}", path.display()); info!("Loaded configuration from {}", path.display());
} }
loaded.report_unknown_keys(cli.strict_config)?;
loaded.report_deprecations();
if cli.rigctl_port.is_some() {
tracing::warn!(
"--rigctl-port is ignored; give each rig its own listener via \
[frontends.rigctl].rig_ports"
);
}
let mut cfg = loaded.config;
// Secrets configured as *_file are read before validation, so everything
// downstream sees resolved values.
cfg.resolve_secrets(config_path.as_deref())?;
cfg.validate()
.map_err(|e| format!("Invalid client configuration: {}", e))?;
frontend_runtime.http_auth.tokens = cfg frontend_runtime.http_auth.tokens = cfg
.frontends .frontends
@@ -292,10 +202,7 @@ async fn async_init() -> DynResult<AppState> {
name, name,
url: url.clone(), url: url.clone(),
rig_id, rig_id,
auth: config::RemoteAuthConfig { auth: config::RemoteAuthConfig { token },
token,
token_file: None,
},
poll_interval_ms, poll_interval_ms,
}] }]
} else { } else {
@@ -356,30 +263,6 @@ async fn async_init() -> DynResult<AppState> {
.http_json_listen .http_json_listen
.unwrap_or(cfg.frontends.http_json.listen); .unwrap_or(cfg.frontends.http_json.listen);
let http_json_port = cli.http_json_port.unwrap_or(cfg.frontends.http_json.port); let http_json_port = cli.http_json_port.unwrap_or(cfg.frontends.http_json.port);
// Fold the CLI overrides back into the config so validation and the
// frontends agree on what is about to be bound.
cfg.frontends.http.enabled = frontends.iter().any(|f| f == "http");
cfg.frontends.rigctl.enabled = frontends.iter().any(|f| f == "rigctl");
cfg.frontends.http_json.enabled = frontends.iter().any(|f| f == "httpjson");
cfg.frontends.http.listen = http_listen;
cfg.frontends.http.port = http_port;
cfg.frontends.rigctl.listen = rigctl_listen;
cfg.frontends.http_json.listen = http_json_listen;
cfg.frontends.http_json.port = http_json_port;
// Second validation phase: the per-rig maps are keyed by remote short name,
// so they can only be checked once the remote list is final.
if cli.url.is_none() {
cfg.validate_resolved(&resolved_remotes)
.map_err(|e| format!("Invalid client configuration: {}", e))?;
} else {
// --url replaces the configured remotes outright, so only the socket
// checks still apply.
trx_config::shared::check_socket_conflicts(&cfg.bound_sockets())
.map_err(|e| format!("Invalid client configuration: {}", e))?;
}
let callsign = cli let callsign = cli
.callsign .callsign
.clone() .clone()
+98 -4
View File
@@ -22,16 +22,30 @@ use trx_protocol::rig_command_to_client;
use trx_protocol::types::RigEntry; use trx_protocol::types::RigEntry;
use trx_protocol::{ClientCommand, ClientEnvelope, ClientResponse, MeterUpdate, SpectrumFrame}; use trx_protocol::{ClientCommand, ClientEnvelope, ClientResponse, MeterUpdate, SpectrumFrame};
// Endpoint parsing lives in `trx-config` so config validation and the const DEFAULT_REMOTE_PORT: u16 = 4530;
// connection code agree on what a URL means. const DEFAULT_AUDIO_PORT: u16 = 4531;
pub use trx_config::url::{parse_audio_url, parse_remote_url, RemoteEndpoint};
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
const IO_TIMEOUT: Duration = Duration::from_secs(15); const IO_TIMEOUT: Duration = Duration::from_secs(15);
const SPECTRUM_IO_TIMEOUT: Duration = Duration::from_secs(3); const SPECTRUM_IO_TIMEOUT: Duration = Duration::from_secs(3);
const MAX_JSON_LINE_BYTES: usize = 256 * 1024; const MAX_JSON_LINE_BYTES: usize = 256 * 1024;
const MAX_CONSECUTIVE_POLL_FAILURES: u32 = 3; const MAX_CONSECUTIVE_POLL_FAILURES: u32 = 3;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RemoteEndpoint {
pub host: String,
pub port: u16,
}
impl RemoteEndpoint {
pub fn connect_addr(&self) -> String {
if self.host.contains(':') && !self.host.starts_with('[') {
format!("[{}]:{}", self.host, self.port)
} else {
format!("{}:{}", self.host, self.port)
}
}
}
// Default spectrum cadence when a config does not specify one. Both the push // Default spectrum cadence when a config does not specify one. Both the push
// stream and the poll fallback run at the configured rate; see // stream and the poll fallback run at the configured rate; see
// `[[remotes]].spectrum_interval_ms`. // `[[remotes]].spectrum_interval_ms`.
@@ -1308,6 +1322,86 @@ async fn read_limited_line<R: AsyncBufRead + Unpin>(
} }
} }
pub fn parse_remote_url(url: &str) -> Result<RemoteEndpoint, String> {
parse_endpoint_url(url, DEFAULT_REMOTE_PORT, "remote")
}
pub fn parse_audio_url(url: &str) -> Result<RemoteEndpoint, String> {
parse_endpoint_url(url, DEFAULT_AUDIO_PORT, "audio")
}
fn parse_endpoint_url(url: &str, default_port: u16, kind: &str) -> Result<RemoteEndpoint, String> {
let trimmed = url.trim();
if trimmed.is_empty() {
return Err(format!("{kind} url is empty"));
}
let addr = trimmed
.strip_prefix("tcp://")
.or_else(|| trimmed.strip_prefix("http-json://"))
.or_else(|| trimmed.strip_prefix("audio://"))
.unwrap_or(trimmed);
parse_host_port(addr, default_port, kind)
}
fn parse_host_port(input: &str, default_port: u16, kind: &str) -> Result<RemoteEndpoint, String> {
if let Some(rest) = input.strip_prefix('[') {
let closing = rest
.find(']')
.ok_or_else(|| format!("invalid {kind} url: missing closing ']' for IPv6 host"))?;
let host = &rest[..closing];
let remainder = &rest[closing + 1..];
if host.is_empty() {
return Err(format!("invalid {kind} url: host is empty"));
}
let port = if remainder.is_empty() {
default_port
} else if let Some(port_str) = remainder.strip_prefix(':') {
parse_port(port_str, kind)?
} else {
return Err(format!("invalid {kind} url: expected ':<port>' after ']'"));
};
return Ok(RemoteEndpoint {
host: host.to_string(),
port,
});
}
if input.contains(':') {
if input.matches(':').count() > 1 {
return Err(format!(
"invalid {kind} url: IPv6 host must be bracketed like [::1]:4532"
));
}
let (host, port_str) = input
.rsplit_once(':')
.ok_or_else(|| format!("invalid {kind} url: expected host:port"))?;
if host.is_empty() {
return Err(format!("invalid {kind} url: host is empty"));
}
return Ok(RemoteEndpoint {
host: host.to_string(),
port: parse_port(port_str, kind)?,
});
}
Ok(RemoteEndpoint {
host: input.to_string(),
port: default_port,
})
}
fn parse_port(port_str: &str, kind: &str) -> Result<u16, String> {
let port: u16 = port_str
.parse()
.map_err(|_| format!("invalid {kind} port: '{port_str}'"))?;
if port == 0 {
return Err(format!("invalid {kind} port: 0"));
}
Ok(port)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
#[allow(unused_imports)] #[allow(unused_imports)]
-24
View File
@@ -1,24 +0,0 @@
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
#
# SPDX-License-Identifier: GPL-2.0-or-later
[package]
name = "trx-config"
version.workspace = true
edition = "2021"
license = "GPL-2.0-or-later"
[dependencies]
serde = { workspace = true, features = ["derive"] }
toml = { workspace = true }
tracing = { workspace = true }
dirs = "6"
thiserror = "2"
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"
@@ -1,29 +0,0 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// 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(())
}
File diff suppressed because it is too large Load Diff
-295
View File
@@ -1,295 +0,0 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// 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.\n\
output_dir sets where sstv/wefax/lrpt write images (default: user cache dir).",
),
(
"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<T: serde::Serialize>(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::*;
/// 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`"
);
}
/// Nothing in the example may be derived from the machine that generated
/// it: [decode_logs].dir defaults to the running user's cache directory,
/// which made the generated file differ between a developer's laptop and
/// CI, and the up-to-date test fail for everyone but its author.
#[test]
fn test_example_has_no_machine_specific_paths() {
let example = combined_example();
for dir in [dirs::home_dir(), dirs::cache_dir(), dirs::config_dir()]
.into_iter()
.flatten()
{
let dir = dir.to_string_lossy().into_owned();
assert!(
!example.contains(&dir),
"the example contains this machine's {dir}; pin the value in example_config()"
);
}
}
#[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"
);
}
}
}
}
-252
View File
@@ -1,252 +0,0 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Loading a config section out of a TOML file.
//!
//! Two file shapes are accepted:
//!
//! - **Sectioned** — a combined `trx-rs.toml` with `[trx-server]` and/or
//! `[trx-client]` tables. This is what `--print-config` and
//! `trx-configurator` emit.
//! - **Bare** — a standalone file whose root *is* the section, i.e. `[general]`
//! and `[rig]` at the top level. Hand-written per-binary configs use this.
//!
//! A file that carries some other component's section but not ours is treated
//! as "section absent" rather than as a bare file, so loading a client-only
//! config with the server reports the missing section instead of silently
//! falling back to defaults.
use serde::de::DeserializeOwned;
use serde::Serialize;
use std::path::{Path, PathBuf};
use thiserror::Error;
use std::collections::BTreeSet;
use crate::unknown::{describe, flatten_paths, UnknownKey};
/// Every section key that may appear at the root of a combined config file.
pub const SECTION_KEYS: &[&str] = &["trx-server", "trx-client"];
/// A loaded config plus what the loader noticed on the way in.
#[derive(Debug, Clone)]
pub struct ConfigLoad<T> {
/// The deserialized configuration.
pub config: T,
/// File the config came from; `None` when nothing was found and defaults
/// were used.
pub path: Option<PathBuf>,
/// Keys present in the file that no config field claimed.
pub unknown_keys: Vec<UnknownKey>,
/// Every key path the file actually set. Defaults are indistinguishable
/// from explicit values once deserialized, so deprecation checks need this.
pub present_keys: BTreeSet<String>,
}
impl<T: ConfigFile> ConfigLoad<T> {
/// Log a warning for every deprecated key the file sets.
pub fn report_deprecations(&self) {
for message in T::deprecations(&self.present_keys) {
tracing::warn!("{}", message);
}
}
}
impl<T> ConfigLoad<T> {
/// Log every unknown key as a warning. With `strict`, also return an error
/// so the caller can refuse to start.
pub fn report_unknown_keys(&self, strict: bool) -> Result<(), String> {
for key in &self.unknown_keys {
tracing::warn!("{}", key);
}
if strict && !self.unknown_keys.is_empty() {
return Err(format!(
"{} unknown config key(s); refusing to start because --strict-config is set",
self.unknown_keys.len()
));
}
Ok(())
}
}
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("Failed to read config file {0}: {1}")]
ReadError(PathBuf, String),
#[error("Failed to parse config file {0}: {1}")]
ParseError(PathBuf, String),
}
/// Returns the default search paths for `trx-rs.toml`
/// (current directory → XDG config → /etc).
fn config_search_paths() -> Vec<PathBuf> {
let mut paths = vec![PathBuf::from("trx-rs.toml")];
if let Some(config_dir) = dirs::config_dir() {
paths.push(config_dir.join("trx-rs").join("trx-rs.toml"));
}
paths.push(PathBuf::from("/etc/trx-rs/trx-rs.toml"));
paths
}
/// Pick the table holding `key`'s settings out of a parsed document.
///
/// Returns the named section when present, the whole document when it carries
/// no section headers at all (a bare standalone file), or `None` when the file
/// is sectioned but has no section for `key`.
fn select_section(table: &toml::Table, key: &str) -> Option<toml::Value> {
if let Some(section) = table.get(key) {
return Some(section.clone());
}
let is_sectioned = SECTION_KEYS.iter().any(|k| table.contains_key(*k));
if is_sectioned {
return None;
}
Some(toml::Value::Table(table.clone()))
}
/// Extract and deserialize a named section from a TOML file.
///
/// Returns `Ok(Some((cfg, unknown_keys)))` when the section is present and
/// parses cleanly, `Ok(None)` when the section is absent, or `Err` on I/O /
/// parse failure.
type LoadedSection<T> = (T, Vec<UnknownKey>, BTreeSet<String>);
fn load_section_from_file<T: ConfigFile>(
path: &Path,
key: &str,
) -> Result<Option<LoadedSection<T>>, ConfigError> {
let content = std::fs::read_to_string(path)
.map_err(|e| ConfigError::ReadError(path.to_path_buf(), e.to_string()))?;
let table: toml::Table = toml::from_str(&content)
.map_err(|e| ConfigError::ParseError(path.to_path_buf(), e.to_string()))?;
let Some(mut section) = select_section(&table, key) else {
return Ok(None);
};
// ${VAR} references are expanded before deserializing, so any string in the
// file can come from the environment.
crate::secrets::expand_env_vars(&mut section)
.map_err(|e| ConfigError::ParseError(path.to_path_buf(), e))?;
let present_keys = flatten_paths(&section);
// Deserialize straight from the TOML value so serde applies every default,
// recording any key no field claimed.
let mut ignored: Vec<String> = Vec::new();
let cfg: T = serde_ignored::deserialize(section, |path| ignored.push(path.to_string()))
.map_err(|e| ConfigError::ParseError(path.to_path_buf(), e.to_string()))?;
Ok(Some((
cfg,
describe(&ignored, &T::reference_value()),
present_keys,
)))
}
/// Trait for loading configuration from a `trx-rs.toml` section.
pub trait ConfigFile: Sized + Default + DeserializeOwned + Serialize {
/// Section key in `trx-rs.toml` (e.g. `"trx-server"` or `"trx-client"`).
fn section_key() -> &'static str;
/// Warnings for deprecated keys the file sets, given every key path present
/// in it. Defaults to none.
fn deprecations(_present_keys: &BTreeSet<String>) -> Vec<String> {
Vec::new()
}
/// A TOML rendering of a populated config, used to suggest corrections for
/// unknown keys. Implementations should fill in list-valued sections such
/// as `[[rigs]]` so keys nested inside them can be suggested too.
fn reference_value() -> toml::Value {
toml::Value::try_from(Self::default())
.unwrap_or_else(|_| toml::Value::Table(toml::Table::new()))
}
/// Load the section from a specific file path.
///
/// Accepts both a sectioned file (`[<section_key>]` at the root) and a bare
/// file whose root is the section itself. Returns an error if the file
/// cannot be read, is not valid TOML, or is sectioned for some other
/// component only.
fn load_from_file(path: &Path) -> Result<ConfigLoad<Self>, ConfigError> {
let (config, unknown_keys, present_keys) =
load_section_from_file::<Self>(path, Self::section_key())?.ok_or_else(|| {
ConfigError::ParseError(
path.to_path_buf(),
format!("missing [{}] section", Self::section_key()),
)
})?;
Ok(ConfigLoad {
config,
path: Some(path.to_path_buf()),
unknown_keys,
present_keys,
})
}
/// Search default paths (`trx-rs.toml` in CWD → XDG → /etc) and load
/// the first file that contains the expected section.
///
/// Falls back to `Self::default()` with no path when nothing is found.
fn load_from_default_paths() -> Result<ConfigLoad<Self>, ConfigError> {
for path in config_search_paths() {
if path.exists() {
if let Some((config, unknown_keys, present_keys)) =
load_section_from_file::<Self>(&path, Self::section_key())?
{
return Ok(ConfigLoad {
config,
path: Some(path),
unknown_keys,
present_keys,
});
}
}
}
Ok(ConfigLoad {
config: Self::default(),
path: None,
unknown_keys: Vec::new(),
present_keys: BTreeSet::new(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn table(s: &str) -> toml::Table {
toml::from_str(s).unwrap()
}
#[test]
fn test_select_section_prefers_named_section() {
let t = table("[trx-server]\n[trx-server.general]\ncallsign = \"W1AW\"\n");
let section = select_section(&t, "trx-server").unwrap();
assert!(section.get("general").is_some());
}
#[test]
fn test_select_section_falls_back_to_root_for_bare_file() {
let t = table("[general]\ncallsign = \"W1AW\"\n");
let section = select_section(&t, "trx-server").unwrap();
assert!(section.get("general").is_some());
}
#[test]
fn test_select_section_absent_when_other_section_present() {
let t = table("[trx-client]\n[trx-client.general]\ncallsign = \"W1AW\"\n");
assert!(select_section(&t, "trx-server").is_none());
}
#[test]
fn test_select_section_empty_file_is_bare() {
let t = table("");
assert!(select_section(&t, "trx-server").is_some());
}
}
-26
View File
@@ -1,26 +0,0 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Configuration types shared by `trx-server`, `trx-client` and
//! `trx-configurator`.
//!
//! Keeping the structs, the loader and the validators in one crate means the
//! setup wizard checks a config with exactly the same code the binaries load
//! it with, so the two can never drift apart.
pub mod client;
pub mod example;
pub mod file;
pub mod secrets;
pub mod server;
pub mod shared;
pub mod unknown;
pub mod url;
pub use client::ClientConfig;
pub use file::{ConfigError, ConfigFile, ConfigLoad};
pub use server::ServerConfig;
pub use shared::{validate_log_level, validate_tokens};
pub use unknown::UnknownKey;
pub use url::{parse_audio_url, parse_remote_url, RemoteEndpoint};
-297
View File
@@ -1,297 +0,0 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Keeping credentials out of the config file.
//!
//! Tokens and passphrases used to have exactly one representation: written in
//! plain text in `trx-rs.toml`, which is awkward when the config is deployed by
//! a config-management tool, committed to a private repo, or shared between
//! machines. Two alternatives are offered:
//!
//! - `${VAR}` anywhere in a config string, expanded from the environment.
//! - A `*_file` sibling of any secret key, read from disk at startup.
//!
//! Plus a nudge: a config that holds secrets and is readable by group or others
//! gets a warning.
use std::path::Path;
/// Expand `${VAR}` references in every string in a TOML value.
///
/// An unset variable is an error rather than an empty string — a silently blank
/// passphrase is the kind of thing that disables authentication by accident.
pub fn expand_env_vars(value: &mut toml::Value) -> Result<(), String> {
match value {
toml::Value::String(s) => {
if let Some(expanded) = expand_str(s)? {
*s = expanded;
}
}
toml::Value::Table(table) => {
for (_, child) in table.iter_mut() {
expand_env_vars(child)?;
}
}
toml::Value::Array(items) => {
for item in items.iter_mut() {
expand_env_vars(item)?;
}
}
_ => {}
}
Ok(())
}
/// Expand `${VAR}` in one string; `None` when there was nothing to expand.
fn expand_str(input: &str) -> Result<Option<String>, String> {
if !input.contains("${") {
return Ok(None);
}
let mut out = String::with_capacity(input.len());
let mut rest = input;
while let Some(start) = rest.find("${") {
out.push_str(&rest[..start]);
let after = &rest[start + 2..];
let Some(end) = after.find('}') else {
// Unterminated: leave the rest exactly as written.
out.push_str(&rest[start..]);
return Ok(Some(out));
};
let name = &after[..end];
if name.is_empty() || !name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') {
// Not a variable reference; pass it through untouched.
out.push_str(&rest[start..start + 2 + end + 1]);
} else {
let value = std::env::var(name)
.map_err(|_| format!("config references unset environment variable ${{{name}}}"))?;
out.push_str(&value);
}
rest = &after[end + 1..];
}
out.push_str(rest);
Ok(Some(out))
}
/// Read a single secret from a file: the whole file, trimmed.
pub fn read_secret_file(path: &str, what: &str) -> Result<String, String> {
let content = std::fs::read_to_string(path)
.map_err(|e| format!("{what}: cannot read secret file {path}: {e}"))?;
let secret = content.trim().to_string();
if secret.is_empty() {
return Err(format!("{what}: secret file {path} is empty"));
}
warn_if_group_readable(Path::new(path), what);
Ok(secret)
}
/// Read a list of secrets, one per line. Blank lines and `#` comments are
/// skipped.
pub fn read_secret_list_file(path: &str, what: &str) -> Result<Vec<String>, String> {
let content = std::fs::read_to_string(path)
.map_err(|e| format!("{what}: cannot read secret file {path}: {e}"))?;
let secrets: Vec<String> = content
.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.map(str::to_string)
.collect();
if secrets.is_empty() {
return Err(format!("{what}: secret file {path} contains no entries"));
}
warn_if_group_readable(Path::new(path), what);
Ok(secrets)
}
/// Fill `inline` from `file` when the config used the `*_file` form.
///
/// Setting both is an error: which one wins would be a guess.
pub fn resolve_secret(
inline: &mut Option<String>,
file: &Option<String>,
what: &str,
) -> Result<(), String> {
let Some(path) = file else {
return Ok(());
};
if inline.is_some() {
return Err(format!(
"{what}: set either the value or its _file form, not both"
));
}
*inline = Some(read_secret_file(path, what)?);
Ok(())
}
/// Fill a token list from `file` when the config used the `*_file` form.
pub fn resolve_secret_list(
inline: &mut Vec<String>,
file: &Option<String>,
what: &str,
) -> Result<(), String> {
let Some(path) = file else {
return Ok(());
};
if !inline.is_empty() {
return Err(format!(
"{what}: set either the value or its _file form, not both"
));
}
*inline = read_secret_list_file(path, what)?;
Ok(())
}
/// Warn when a file holding secrets is readable beyond its owner.
///
/// Advisory only: plenty of valid setups (a dedicated service user, an
/// immutable image) are fine, so this never fails the load.
pub fn warn_if_group_readable(path: &Path, what: &str) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let Ok(meta) = std::fs::metadata(path) else {
return;
};
let mode = meta.permissions().mode() & 0o077;
if mode != 0 {
tracing::warn!(
"{} is readable by group or others (mode {:o}); it holds secrets ({}). \
Consider: chmod 600 {}",
path.display(),
meta.permissions().mode() & 0o777,
what,
path.display()
);
}
}
#[cfg(not(unix))]
{
let _ = (path, what);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn test_expand_leaves_plain_strings_alone() {
assert_eq!(expand_str("plain").unwrap(), None);
assert_eq!(expand_str("TRXRS-%YYYY%.log").unwrap(), None);
}
#[test]
fn test_expand_substitutes_variable() {
std::env::set_var("TRX_TEST_TOKEN", "s3cret");
assert_eq!(
expand_str("Bearer ${TRX_TEST_TOKEN}!").unwrap().as_deref(),
Some("Bearer s3cret!")
);
}
#[test]
fn test_expand_errors_on_unset_variable() {
let err = expand_str("${TRX_DEFINITELY_NOT_SET_12345}").unwrap_err();
assert!(err.contains("unset environment variable"), "{err}");
}
#[test]
fn test_expand_passes_through_non_variables() {
assert_eq!(
expand_str("${not a var}").unwrap().as_deref(),
Some("${not a var}")
);
assert_eq!(
expand_str("unterminated ${VAR").unwrap().as_deref(),
Some("unterminated ${VAR")
);
}
#[test]
fn test_expand_walks_nested_tables_and_arrays() {
std::env::set_var("TRX_TEST_HOST", "radio.example.com");
let mut value: toml::Value = toml::from_str(
r#"
[remote]
url = "${TRX_TEST_HOST}:4530"
hosts = ["${TRX_TEST_HOST}"]
"#,
)
.unwrap();
expand_env_vars(&mut value).unwrap();
assert_eq!(
value["remote"]["url"].as_str(),
Some("radio.example.com:4530")
);
assert_eq!(
value["remote"]["hosts"][0].as_str(),
Some("radio.example.com")
);
}
fn temp_file(content: &str) -> tempfile::NamedTempFile {
let mut f = tempfile::NamedTempFile::new().unwrap();
f.write_all(content.as_bytes()).unwrap();
f.flush().unwrap();
f
}
#[test]
fn test_read_secret_file_trims() {
let f = temp_file(" hunter2\n");
assert_eq!(read_secret_file(path_of(&f), "test").unwrap(), "hunter2");
}
#[test]
fn test_read_secret_file_rejects_empty() {
let f = temp_file(" \n");
assert!(read_secret_file(path_of(&f), "test").is_err());
}
#[test]
fn test_read_secret_list_skips_blanks_and_comments() {
let f = temp_file("# tokens\nalpha\n\n beta \n");
assert_eq!(
read_secret_list_file(path_of(&f), "test").unwrap(),
vec!["alpha".to_string(), "beta".to_string()]
);
}
#[test]
fn test_resolve_secret_fills_from_file() {
let f = temp_file("from-file");
let mut inline = None;
resolve_secret(&mut inline, &Some(path_of(&f).to_string()), "test").unwrap();
assert_eq!(inline.as_deref(), Some("from-file"));
}
#[test]
fn test_resolve_secret_rejects_both_forms() {
let f = temp_file("from-file");
let mut inline = Some("inline".to_string());
let err = resolve_secret(&mut inline, &Some(path_of(&f).to_string()), "test").unwrap_err();
assert!(err.contains("not both"), "{err}");
}
#[test]
fn test_resolve_secret_is_a_no_op_without_file() {
let mut inline = Some("inline".to_string());
resolve_secret(&mut inline, &None, "test").unwrap();
assert_eq!(inline.as_deref(), Some("inline"));
}
#[test]
fn test_resolve_secret_list_rejects_both_forms() {
let f = temp_file("alpha");
let mut inline = vec!["inline".to_string()];
let err =
resolve_secret_list(&mut inline, &Some(path_of(&f).to_string()), "test").unwrap_err();
assert!(err.contains("not both"), "{err}");
}
fn path_of(f: &tempfile::NamedTempFile) -> &str {
f.path().to_str().unwrap()
}
}
File diff suppressed because it is too large Load Diff
-263
View File
@@ -1,263 +0,0 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Reporting for config keys the deserializer ignored.
//!
//! Every config struct is `#[serde(default)]`, so a misspelled key used to be
//! dropped without a word and the setting silently kept its default. The
//! loader now collects the ignored key paths and pairs each with the closest
//! known key at the same level, so `prot = 9999` reads as a typo instead of
//! looking like it worked.
use std::collections::BTreeSet;
use std::fmt;
/// A config key that the deserializer did not recognise.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnknownKey {
/// Dotted path of the key, e.g. `listen.prot` or `rigs.0.audio.prot`.
pub path: String,
/// Closest known key at the same level, when one is near enough to suggest.
pub suggestion: Option<String>,
}
impl fmt::Display for UnknownKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.suggestion {
Some(s) => write!(
f,
"unknown config key '{}' (did you mean '{}'?)",
self.path, s
),
None => write!(f, "unknown config key '{}'", self.path),
}
}
}
/// Flatten a TOML value into the set of dotted key paths it contains.
///
/// Array indices are normalised to `0` so a path inside `[[rigs]]` matches
/// whichever entry it came from.
pub fn flatten_paths(value: &toml::Value) -> BTreeSet<String> {
let mut paths = BTreeSet::new();
walk(value, "", &mut paths);
paths
}
fn walk(value: &toml::Value, prefix: &str, paths: &mut BTreeSet<String>) {
let join = |seg: &str| {
if prefix.is_empty() {
seg.to_string()
} else {
format!("{prefix}.{seg}")
}
};
match value {
toml::Value::Table(table) => {
for (key, child) in table {
let path = join(key);
paths.insert(path.clone());
walk(child, &path, paths);
}
}
toml::Value::Array(items) => {
// Every entry of an array of tables has the same shape, so collapse
// them onto index 0 and let one entry stand for all.
for item in items {
let path = join("0");
walk(item, &path, paths);
}
}
_ => {}
}
}
/// Replace numeric path segments with `0` so array entries compare equal.
fn normalize(path: &str) -> String {
path.split('.')
.map(|seg| {
if !seg.is_empty() && seg.bytes().all(|b| b.is_ascii_digit()) {
"0"
} else {
seg
}
})
.collect::<Vec<_>>()
.join(".")
}
/// Suggest the closest known key that sits at the same level as `path`.
///
/// Returns `None` when nothing is close enough to be worth printing.
pub fn suggest(path: &str, known: &BTreeSet<String>) -> Option<String> {
let normalized = normalize(path);
let (parent, leaf) = match normalized.rsplit_once('.') {
Some((parent, leaf)) => (parent, leaf),
None => ("", normalized.as_str()),
};
// Anything longer than this is a different word, not a typo.
let limit = (leaf.chars().count() / 3).clamp(1, 3);
let mut best: Option<(usize, &str)> = None;
for candidate in known {
let (cand_parent, cand_leaf) = match candidate.rsplit_once('.') {
Some((p, l)) => (p, l),
None => ("", candidate.as_str()),
};
if cand_parent != parent || cand_leaf == leaf {
continue;
}
let distance = edit_distance(leaf, cand_leaf);
if distance <= limit && best.is_none_or(|(best_d, _)| distance < best_d) {
best = Some((distance, cand_leaf));
}
}
best.map(|(_, leaf)| {
if parent.is_empty() {
leaf.to_string()
} else {
format!("{parent}.{leaf}")
}
})
}
/// Pair each ignored path with a suggestion drawn from `reference`.
pub fn describe(paths: &[String], reference: &toml::Value) -> Vec<UnknownKey> {
let known = flatten_paths(reference);
paths
.iter()
.map(|path| UnknownKey {
path: path.clone(),
suggestion: suggest(path, &known),
})
.collect()
}
/// Optimal string alignment distance: Levenshtein plus transpositions, so the
/// common `port` → `prot` slip counts as one mistake rather than two.
fn edit_distance(a: &str, b: &str) -> usize {
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
if a.is_empty() {
return b.len();
}
if b.is_empty() {
return a.len();
}
let mut rows = vec![vec![0usize; b.len() + 1]; a.len() + 1];
for (i, row) in rows.iter_mut().enumerate() {
row[0] = i;
}
for (j, cell) in rows[0].iter_mut().enumerate() {
*cell = j;
}
for i in 1..=a.len() {
for j in 1..=b.len() {
let cost = usize::from(a[i - 1] != b[j - 1]);
let mut best = (rows[i - 1][j] + 1)
.min(rows[i][j - 1] + 1)
.min(rows[i - 1][j - 1] + cost);
if i > 1 && j > 1 && a[i - 1] == b[j - 2] && a[i - 2] == b[j - 1] {
best = best.min(rows[i - 2][j - 2] + 1);
}
rows[i][j] = best;
}
}
rows[a.len()][b.len()]
}
#[cfg(test)]
mod tests {
use super::*;
fn reference() -> toml::Value {
toml::from_str(
r#"
[general]
callsign = "N0CALL"
log_level = "info"
[listen]
enabled = true
port = 4530
[[rigs]]
id = "hf"
[rigs.audio]
port = 4531
sample_rate = 48000
"#,
)
.unwrap()
}
#[test]
fn test_flatten_collects_nested_paths() {
let paths = flatten_paths(&reference());
assert!(paths.contains("general.callsign"));
assert!(paths.contains("listen.port"));
assert!(paths.contains("rigs.0.audio.sample_rate"));
}
#[test]
fn test_suggest_finds_close_sibling() {
let known = flatten_paths(&reference());
assert_eq!(
suggest("listen.prot", &known).as_deref(),
Some("listen.port")
);
}
#[test]
fn test_suggest_inside_array_entry() {
let known = flatten_paths(&reference());
assert_eq!(
suggest("rigs.1.audio.prot", &known).as_deref(),
Some("rigs.0.audio.port")
);
}
#[test]
fn test_suggest_ignores_distant_names() {
let known = flatten_paths(&reference());
assert_eq!(suggest("listen.bananas", &known), None);
}
#[test]
fn test_suggest_does_not_cross_levels() {
let known = flatten_paths(&reference());
// `port` exists under [listen], but not under [general].
assert_eq!(suggest("general.port", &known), None);
}
#[test]
fn test_describe_formats_message() {
let described = describe(&["listen.prot".to_string()], &reference());
assert_eq!(
described[0].to_string(),
"unknown config key 'listen.prot' (did you mean 'listen.port'?)"
);
}
#[test]
fn test_describe_without_suggestion() {
let described = describe(&["listen.bananas".to_string()], &reference());
assert_eq!(
described[0].to_string(),
"unknown config key 'listen.bananas'"
);
}
#[test]
fn test_edit_distance_counts_transposition_once() {
assert_eq!(edit_distance("port", "prot"), 1);
assert_eq!(edit_distance("port", "port"), 0);
assert_eq!(edit_distance("", "port"), 4);
assert_eq!(edit_distance("sample_rat", "sample_rate"), 1);
}
}
-173
View File
@@ -1,173 +0,0 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Parsing for the `host:port` endpoint URLs used by the client's `[[remotes]]`
//! and `[frontends.audio]` settings.
//!
//! These live next to the config structs because validation needs them; the
//! client re-exports them for its connection code.
/// Default port for the server's JSON control listener.
pub const DEFAULT_REMOTE_PORT: u16 = 4530;
/// Default port for the server's Opus audio listener.
pub const DEFAULT_AUDIO_PORT: u16 = 4531;
/// A resolved `host:port` pair.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RemoteEndpoint {
pub host: String,
pub port: u16,
}
impl RemoteEndpoint {
/// Format as a connect string, bracketing bare IPv6 hosts.
pub fn connect_addr(&self) -> String {
if self.host.contains(':') && !self.host.starts_with('[') {
format!("[{}]:{}", self.host, self.port)
} else {
format!("{}:{}", self.host, self.port)
}
}
}
/// Parse a remote control URL, defaulting to port 4530.
pub fn parse_remote_url(url: &str) -> Result<RemoteEndpoint, String> {
parse_endpoint_url(url, DEFAULT_REMOTE_PORT, "remote")
}
/// Parse an audio stream URL, defaulting to port 4531.
pub fn parse_audio_url(url: &str) -> Result<RemoteEndpoint, String> {
parse_endpoint_url(url, DEFAULT_AUDIO_PORT, "audio")
}
fn parse_endpoint_url(url: &str, default_port: u16, kind: &str) -> Result<RemoteEndpoint, String> {
let trimmed = url.trim();
if trimmed.is_empty() {
return Err(format!("{kind} url is empty"));
}
let addr = trimmed
.strip_prefix("tcp://")
.or_else(|| trimmed.strip_prefix("http-json://"))
.or_else(|| trimmed.strip_prefix("audio://"))
.unwrap_or(trimmed);
parse_host_port(addr, default_port, kind)
}
fn parse_host_port(input: &str, default_port: u16, kind: &str) -> Result<RemoteEndpoint, String> {
if let Some(rest) = input.strip_prefix('[') {
let closing = rest
.find(']')
.ok_or_else(|| format!("invalid {kind} url: missing closing ']' for IPv6 host"))?;
let host = &rest[..closing];
let remainder = &rest[closing + 1..];
if host.is_empty() {
return Err(format!("invalid {kind} url: host is empty"));
}
let port = if remainder.is_empty() {
default_port
} else if let Some(port_str) = remainder.strip_prefix(':') {
parse_port(port_str, kind)?
} else {
return Err(format!("invalid {kind} url: expected ':<port>' after ']'"));
};
return Ok(RemoteEndpoint {
host: host.to_string(),
port,
});
}
if input.contains(':') {
if input.matches(':').count() > 1 {
return Err(format!(
"invalid {kind} url: IPv6 host must be bracketed like [::1]:4532"
));
}
let (host, port_str) = input
.rsplit_once(':')
.ok_or_else(|| format!("invalid {kind} url: expected host:port"))?;
if host.is_empty() {
return Err(format!("invalid {kind} url: host is empty"));
}
return Ok(RemoteEndpoint {
host: host.to_string(),
port: parse_port(port_str, kind)?,
});
}
Ok(RemoteEndpoint {
host: input.to_string(),
port: default_port,
})
}
fn parse_port(port_str: &str, kind: &str) -> Result<u16, String> {
let port: u16 = port_str
.parse()
.map_err(|_| format!("invalid {kind} port: '{port_str}'"))?;
if port == 0 {
return Err(format!("invalid {kind} port: 0"));
}
Ok(port)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_remote_url_defaults_port() {
let ep = parse_remote_url("example.com").unwrap();
assert_eq!(ep.host, "example.com");
assert_eq!(ep.port, DEFAULT_REMOTE_PORT);
}
#[test]
fn test_parse_audio_url_defaults_port() {
let ep = parse_audio_url("example.com").unwrap();
assert_eq!(ep.port, DEFAULT_AUDIO_PORT);
}
#[test]
fn test_parse_strips_schemes() {
for url in &[
"tcp://host:9000",
"http-json://host:9000",
"audio://host:9000",
] {
let ep = parse_remote_url(url).unwrap();
assert_eq!(ep.host, "host");
assert_eq!(ep.port, 9000);
}
}
#[test]
fn test_parse_bracketed_ipv6() {
let ep = parse_remote_url("[::1]:4532").unwrap();
assert_eq!(ep.host, "::1");
assert_eq!(ep.port, 4532);
assert_eq!(ep.connect_addr(), "[::1]:4532");
}
#[test]
fn test_parse_rejects_unbracketed_ipv6() {
assert!(parse_remote_url("::1:4532").is_err());
}
#[test]
fn test_parse_rejects_empty_host() {
assert!(parse_remote_url(":4530").is_err());
}
#[test]
fn test_parse_rejects_zero_port() {
assert!(parse_remote_url("host:0").is_err());
}
#[test]
fn test_parse_rejects_empty_url() {
assert!(parse_remote_url(" ").is_err());
}
}
-2
View File
@@ -15,9 +15,7 @@ path = "src/main.rs"
clap = { workspace = true, features = ["derive"] } clap = { workspace = true, features = ["derive"] }
dialoguer = "0.11" dialoguer = "0.11"
tokio-serial = { workspace = true } tokio-serial = { workspace = true }
toml = "0.8"
toml_edit = "0.22" toml_edit = "0.22"
trx-config = { path = "../trx-config" }
[dev-dependencies] [dev-dependencies]
tempfile = "3" tempfile = "3"
+319 -136
View File
@@ -2,37 +2,80 @@
// //
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
//! `trx-configurator --check`: run a config file through the same loader and
//! validators the binaries use.
//!
//! This used to be a second, hand-maintained implementation — lists of known
//! keys and a handful of re-implemented range checks — which drifted out of
//! date as soon as a field was added. It now defers entirely to `trx-config`,
//! so a config that checks clean here is one the binaries will accept.
use std::fmt::Write as _; use std::fmt::Write as _;
use std::path::Path; use std::path::Path;
use trx_config::{ClientConfig, ServerConfig}; use toml_edit::DocumentMut;
/// Top-level keys that only appear in a server config. Used solely to guess /// Known top-level keys for a standalone server config.
/// what a section-less file is meant to be; the real key checking is done by const SERVER_KEYS: &[&str] = &[
/// the loader. "general",
const SERVER_MARKERS: &[&str] = &[
"rig", "rig",
"rigs", "rigs",
"listen",
"behavior", "behavior",
"listen",
"audio",
"sdr", "sdr",
"pskreporter", "pskreporter",
"aprsfi", "aprsfi",
"decode_logs", "decode_logs",
"timeouts",
"audio",
]; ];
/// Top-level keys that only appear in a client config. /// Known top-level keys for a standalone client config.
const CLIENT_MARKERS: &[&str] = &["remote", "remotes", "frontends"]; const CLIENT_KEYS: &[&str] = &["general", "remote", "remotes", "frontends"];
/// Known top-level keys for a combined trx-rs.toml.
const COMBINED_KEYS: &[&str] = &["trx-server", "trx-client"];
/// Known sub-keys within [general] (server).
const SERVER_GENERAL_KEYS: &[&str] = &["callsign", "log_level", "latitude", "longitude"];
/// Known sub-keys within [general] (client).
const CLIENT_GENERAL_KEYS: &[&str] = &[
"callsign",
"log_level",
"website_url",
"website_name",
"ais_vessel_url_base",
];
/// Known sub-keys within [rig].
const RIG_KEYS: &[&str] = &["model", "initial_freq_hz", "initial_mode", "access"];
/// Known sub-keys within [rig.access].
const ACCESS_KEYS: &[&str] = &["type", "port", "baud", "host", "tcp_port", "args"];
/// Known sub-keys within [listen].
const LISTEN_KEYS: &[&str] = &["enabled", "listen", "port", "auth"];
/// Known sub-keys within [audio] (server).
const AUDIO_KEYS: &[&str] = &[
"enabled",
"listen",
"port",
"rx_enabled",
"tx_enabled",
"device",
"sample_rate",
"channels",
"frame_duration_ms",
"bitrate_bps",
];
/// Known sub-keys within [behavior].
const BEHAVIOR_KEYS: &[&str] = &[
"poll_interval_ms",
"poll_interval_tx_ms",
"max_retries",
"retry_base_delay_ms",
"vfo_prime",
];
/// Known sub-keys within [remote].
const REMOTE_KEYS: &[&str] = &["url", "rig_id", "auth", "poll_interval_ms"];
/// Known sub-keys within [frontends].
const FRONTENDS_KEYS: &[&str] = &["http", "rigctl", "http_json", "audio"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DetectedType { enum DetectedType {
@@ -57,38 +100,49 @@ pub fn check_file(path: &Path) -> Result<String, String> {
let content = std::fs::read_to_string(path) let content = std::fs::read_to_string(path)
.map_err(|e| format!("Failed to read {}: {}", path.display(), e))?; .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
let table: toml::Table = toml::from_str(&content) // Step 1: TOML syntax check
let doc: DocumentMut = content
.parse()
.map_err(|e| format!("{}: TOML syntax error: {}", path.display(), e))?; .map_err(|e| format!("{}: TOML syntax error: {}", path.display(), e))?;
let detected = detect_type(&table);
let mut report = String::new(); let mut report = String::new();
let mut warnings: Vec<String> = Vec::new(); let mut warnings: Vec<String> = Vec::new();
let mut errors: Vec<String> = Vec::new(); let mut errors: Vec<String> = Vec::new();
let table = doc.as_table();
// Step 2: Detect config type
let detected = detect_type(table);
writeln!(report, "{}: valid TOML", path.display()).unwrap(); writeln!(report, "{}: valid TOML", path.display()).unwrap();
writeln!(report, " Detected type: {}", detected).unwrap(); writeln!(report, " Detected type: {}", detected).unwrap();
// Step 3: Structural validation
match detected { match detected {
DetectedType::Server => check_server(path, &mut warnings, &mut errors), DetectedType::Server => {
DetectedType::Client => check_client(path, &mut warnings, &mut errors), check_unknown_keys(table, SERVER_KEYS, "", &mut warnings);
check_server_sections(table, "", &mut warnings, &mut errors);
}
DetectedType::Client => {
check_unknown_keys(table, CLIENT_KEYS, "", &mut warnings);
check_client_sections(table, "", &mut warnings, &mut errors);
}
DetectedType::Combined => { DetectedType::Combined => {
if table.contains_key("trx-server") { check_unknown_keys(table, COMBINED_KEYS, "", &mut warnings);
check_server(path, &mut warnings, &mut errors); if let Some(server) = table.get("trx-server").and_then(|v| v.as_table()) {
check_unknown_keys(server, SERVER_KEYS, "[trx-server].", &mut warnings);
check_server_sections(server, "[trx-server].", &mut warnings, &mut errors);
} }
if table.contains_key("trx-client") { if let Some(client) = table.get("trx-client").and_then(|v| v.as_table()) {
check_client(path, &mut warnings, &mut errors); check_unknown_keys(client, CLIENT_KEYS, "[trx-client].", &mut warnings);
check_client_sections(client, "[trx-client].", &mut warnings, &mut errors);
} }
} }
DetectedType::Unknown => { DetectedType::Unknown => {
warnings.push( warnings.push("Could not detect config type. Expected server, client, or combined (trx-rs.toml) layout.".to_string());
"Could not detect config type. Expected server, client, or combined \
(trx-rs.toml) layout."
.to_string(),
);
} }
} }
// Step 4: Format report
for w in &warnings { for w in &warnings {
writeln!(report, " warning: {}", w).unwrap(); writeln!(report, " warning: {}", w).unwrap();
} }
@@ -115,54 +169,23 @@ pub fn check_file(path: &Path) -> Result<String, String> {
} }
} }
fn check_server(path: &Path, warnings: &mut Vec<String>, errors: &mut Vec<String>) { fn detect_type(table: &toml_edit::Table) -> DetectedType {
match ServerConfig::load_from_file(path) {
Ok(loaded) => {
warnings.extend(loaded.unknown_keys.iter().map(|k| k.to_string()));
if let Err(e) = loaded.config.validate() {
errors.push(format!("[trx-server] {}", e));
}
errors.extend(
loaded
.config
.validate_sdr()
.into_iter()
.map(|e| format!("[trx-server] {}", e)),
);
}
Err(e) => errors.push(e.to_string()),
}
}
fn check_client(path: &Path, warnings: &mut Vec<String>, errors: &mut Vec<String>) {
match ClientConfig::load_from_file(path) {
Ok(loaded) => {
warnings.extend(loaded.unknown_keys.iter().map(|k| k.to_string()));
if let Err(e) = loaded.config.validate() {
errors.push(format!("[trx-client] {}", e));
}
}
Err(e) => errors.push(e.to_string()),
}
}
fn detect_type(table: &toml::Table) -> DetectedType {
if table.contains_key("trx-server") || table.contains_key("trx-client") { if table.contains_key("trx-server") || table.contains_key("trx-client") {
return DetectedType::Combined; return DetectedType::Combined;
} }
let keys: Vec<&str> = table.keys().map(|k| k.as_str()).collect(); let keys: Vec<&str> = table.iter().map(|(k, _)| k).collect();
// Distinguishing keys first, then a simple majority. let server_score = keys.iter().filter(|k| SERVER_KEYS.contains(k)).count();
if keys.iter().any(|k| ["rig", "rigs", "listen"].contains(k)) { let client_score = keys.iter().filter(|k| CLIENT_KEYS.contains(k)).count();
// Use distinguishing keys to break ties
if keys.contains(&"rig") || keys.contains(&"rigs") || keys.contains(&"listen") {
return DetectedType::Server; return DetectedType::Server;
} }
if keys.iter().any(|k| CLIENT_MARKERS.contains(k)) { if keys.contains(&"remote") || keys.contains(&"remotes") || keys.contains(&"frontends") {
return DetectedType::Client; return DetectedType::Client;
} }
let server_score = keys.iter().filter(|k| SERVER_MARKERS.contains(k)).count();
let client_score = keys.iter().filter(|k| CLIENT_MARKERS.contains(k)).count();
if server_score > client_score { if server_score > client_score {
DetectedType::Server DetectedType::Server
} else if client_score > server_score { } else if client_score > server_score {
@@ -174,6 +197,194 @@ fn detect_type(table: &toml::Table) -> DetectedType {
} }
} }
fn check_unknown_keys(
table: &toml_edit::Table,
known: &[&str],
prefix: &str,
warnings: &mut Vec<String>,
) {
for (key, _) in table.iter() {
if !known.contains(&key) {
warnings.push(format!("{}unknown key '{}'", prefix, key));
}
}
}
fn check_server_sections(
table: &toml_edit::Table,
prefix: &str,
warnings: &mut Vec<String>,
errors: &mut Vec<String>,
) {
if let Some(general) = table.get("general").and_then(|v| v.as_table()) {
check_unknown_keys(
general,
SERVER_GENERAL_KEYS,
&format!("{}[general].", prefix),
warnings,
);
validate_log_level(general, &format!("{}[general]", prefix), errors);
validate_coordinates(general, &format!("{}[general]", prefix), errors);
}
if let Some(rig) = table.get("rig").and_then(|v| v.as_table()) {
check_unknown_keys(rig, RIG_KEYS, &format!("{}[rig].", prefix), warnings);
if let Some(access) = rig.get("access").and_then(|v| v.as_table()) {
check_unknown_keys(
access,
ACCESS_KEYS,
&format!("{}[rig.access].", prefix),
warnings,
);
validate_access(access, &format!("{}[rig.access]", prefix), errors);
}
}
if let Some(listen) = table.get("listen").and_then(|v| v.as_table()) {
check_unknown_keys(
listen,
LISTEN_KEYS,
&format!("{}[listen].", prefix),
warnings,
);
validate_port(listen, "port", &format!("{}[listen]", prefix), errors);
}
if let Some(audio) = table.get("audio").and_then(|v| v.as_table()) {
check_unknown_keys(audio, AUDIO_KEYS, &format!("{}[audio].", prefix), warnings);
validate_port(audio, "port", &format!("{}[audio]", prefix), errors);
}
if let Some(behavior) = table.get("behavior").and_then(|v| v.as_table()) {
check_unknown_keys(
behavior,
BEHAVIOR_KEYS,
&format!("{}[behavior].", prefix),
warnings,
);
}
}
fn check_client_sections(
table: &toml_edit::Table,
prefix: &str,
warnings: &mut Vec<String>,
errors: &mut Vec<String>,
) {
if let Some(general) = table.get("general").and_then(|v| v.as_table()) {
check_unknown_keys(
general,
CLIENT_GENERAL_KEYS,
&format!("{}[general].", prefix),
warnings,
);
validate_log_level(general, &format!("{}[general]", prefix), errors);
}
if let Some(remote) = table.get("remote").and_then(|v| v.as_table()) {
check_unknown_keys(
remote,
REMOTE_KEYS,
&format!("{}[remote].", prefix),
warnings,
);
}
if let Some(frontends) = table.get("frontends").and_then(|v| v.as_table()) {
check_unknown_keys(
frontends,
FRONTENDS_KEYS,
&format!("{}[frontends].", prefix),
warnings,
);
if let Some(http) = frontends.get("http").and_then(|v| v.as_table()) {
validate_port(http, "port", &format!("{}[frontends.http]", prefix), errors);
}
if let Some(rigctl) = frontends.get("rigctl").and_then(|v| v.as_table()) {
validate_port(
rigctl,
"port",
&format!("{}[frontends.rigctl]", prefix),
errors,
);
}
}
}
// ── Value validators ────────────────────────────────────────────────────
fn validate_log_level(table: &toml_edit::Table, context: &str, errors: &mut Vec<String>) {
if let Some(level) = table.get("log_level").and_then(|v| v.as_str()) {
if !["trace", "debug", "info", "warn", "error"].contains(&level) {
errors.push(format!(
"{}.log_level '{}' is invalid (expected: trace, debug, info, warn, error)",
context, level
));
}
}
}
fn validate_coordinates(table: &toml_edit::Table, context: &str, errors: &mut Vec<String>) {
if let Some(lat) = table
.get("latitude")
.and_then(|v| v.as_float().or_else(|| v.as_integer().map(|i| i as f64)))
{
if !(-90.0..=90.0).contains(&lat) {
errors.push(format!(
"{}.latitude {} is out of range (-90..90)",
context, lat
));
}
}
if let Some(lon) = table
.get("longitude")
.and_then(|v| v.as_float().or_else(|| v.as_integer().map(|i| i as f64)))
{
if !(-180.0..=180.0).contains(&lon) {
errors.push(format!(
"{}.longitude {} is out of range (-180..180)",
context, lon
));
}
}
let has_lat = table.contains_key("latitude");
let has_lon = table.contains_key("longitude");
if has_lat != has_lon {
errors.push(format!(
"{}: latitude and longitude must be set together or both omitted",
context
));
}
}
fn validate_port(table: &toml_edit::Table, key: &str, context: &str, errors: &mut Vec<String>) {
if let Some(port) = table.get(key).and_then(|v| v.as_integer()) {
if let Some(enabled) = table.get("enabled").and_then(|v| v.as_bool()) {
if enabled && port <= 0 {
errors.push(format!("{}.{} must be > 0 when enabled", context, key));
}
}
if !(0..=65535).contains(&port) {
errors.push(format!(
"{}.{} {} is out of range (0..65535)",
context, key, port
));
}
}
}
fn validate_access(table: &toml_edit::Table, context: &str, errors: &mut Vec<String>) {
if let Some(access_type) = table.get("type").and_then(|v| v.as_str()) {
if !["serial", "tcp", "sdr"].contains(&access_type) {
errors.push(format!(
"{}.type '{}' is invalid (expected: serial, tcp, sdr)",
context, access_type
));
}
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -207,7 +418,8 @@ enabled = true
port = 4530 port = 4530
"#, "#,
); );
let report = result.expect("expected a clean report"); assert!(result.is_ok());
let report = result.unwrap();
assert!(report.contains("Detected type: server")); assert!(report.contains("Detected type: server"));
assert!(report.contains("No issues found")); assert!(report.contains("No issues found"));
} }
@@ -220,40 +432,41 @@ port = 4530
callsign = "W1AW" callsign = "W1AW"
[remote] [remote]
url = "192.168.1.10:4530" url = "localhost:4530"
[frontends.http] [frontends.http]
enabled = true enabled = true
port = 8080 port = 8080
"#, "#,
); );
let report = result.expect("expected a clean report"); assert!(result.is_ok());
let report = result.unwrap();
assert!(report.contains("Detected type: client")); assert!(report.contains("Detected type: client"));
assert!(report.contains("No issues found"));
} }
#[test] #[test]
fn test_valid_combined_config() { fn test_valid_combined_config() {
let result = check_toml( let result = check_toml(
r#" r#"
[trx-server.rig] [trx-server.general]
model = "ft817" callsign = "W1AW"
[trx-server.rig.access]
type = "serial" [trx-client.general]
port = "/dev/ttyUSB0" callsign = "W1AW"
baud = 9600
[trx-client.remote] [trx-client.remote]
url = "127.0.0.1:4530" url = "localhost:4530"
"#, "#,
); );
let report = result.expect("expected a clean report"); assert!(result.is_ok());
let report = result.unwrap();
assert!(report.contains("Detected type: combined")); assert!(report.contains("Detected type: combined"));
} }
#[test] #[test]
fn test_invalid_toml_syntax() { fn test_invalid_toml_syntax() {
let result = check_toml("[general\ncallsign = \"W1AW\"\n"); let result = check_toml("this is not [valid toml");
assert!(result.is_err());
assert!(result.unwrap_err().contains("TOML syntax error")); assert!(result.unwrap_err().contains("TOML syntax error"));
} }
@@ -261,22 +474,19 @@ url = "127.0.0.1:4530"
fn test_unknown_key_warning() { fn test_unknown_key_warning() {
let result = check_toml( let result = check_toml(
r#" r#"
[general]
callsign = "W1AW"
[rig] [rig]
model = "ft817" model = "ft817"
[rig.access]
type = "serial"
port = "/dev/ttyUSB0"
baud = 9600
[listen] [bogus_section]
prot = 4530 foo = "bar"
"#, "#,
); );
let report = result.expect("unknown keys are warnings, not errors"); assert!(result.is_ok());
assert!( let report = result.unwrap();
report.contains("unknown config key 'listen.prot' (did you mean 'listen.port'?)"), assert!(report.contains("unknown key 'bogus_section'"));
"unexpected report: {report}"
);
} }
#[test] #[test]
@@ -288,13 +498,11 @@ log_level = "verbose"
[rig] [rig]
model = "ft817" model = "ft817"
[rig.access]
type = "serial"
port = "/dev/ttyUSB0"
baud = 9600
"#, "#,
); );
assert!(result.unwrap_err().contains("log_level")); assert!(result.is_err());
let report = result.unwrap_err();
assert!(report.contains("log_level 'verbose' is invalid"));
} }
#[test] #[test]
@@ -302,17 +510,15 @@ baud = 9600
let result = check_toml( let result = check_toml(
r#" r#"
[general] [general]
latitude = 52.0 latitude = 45.0
[rig] [rig]
model = "ft817" model = "ft817"
[rig.access]
type = "serial"
port = "/dev/ttyUSB0"
baud = 9600
"#, "#,
); );
assert!(result.unwrap_err().contains("longitude")); assert!(result.is_err());
let report = result.unwrap_err();
assert!(report.contains("latitude and longitude must be set together"));
} }
#[test] #[test]
@@ -320,18 +526,16 @@ baud = 9600
let result = check_toml( let result = check_toml(
r#" r#"
[general] [general]
latitude = 120.0 latitude = 95.0
longitude = 10.0 longitude = 10.0
[rig] [rig]
model = "ft817" model = "ft817"
[rig.access]
type = "serial"
port = "/dev/ttyUSB0"
baud = 9600
"#, "#,
); );
assert!(result.unwrap_err().contains("latitude")); assert!(result.is_err());
let report = result.unwrap_err();
assert!(report.contains("latitude 95 is out of range"));
} }
#[test] #[test]
@@ -343,31 +547,10 @@ model = "ft817"
[rig.access] [rig.access]
type = "usb" type = "usb"
port = "/dev/ttyUSB0"
baud = 9600
"#, "#,
); );
assert!(result.unwrap_err().contains("access")); assert!(result.is_err());
} let report = result.unwrap_err();
assert!(report.contains("type 'usb' is invalid"));
/// The old checker only knew a fixed list of top-level keys and a few range
/// rules, so it passed configs the server rejects at startup.
#[test]
fn test_catches_errors_the_key_list_checker_missed() {
let result = check_toml(
r#"
[rig]
model = "ft817"
[rig.access]
type = "serial"
port = "/dev/ttyUSB0"
baud = 9600
[audio]
enabled = true
frame_duration_ms = 7
"#,
);
assert!(result.unwrap_err().contains("frame_duration_ms"));
} }
} }
+8 -68
View File
@@ -211,13 +211,10 @@ pub fn build_server(general: ServerGeneral, rig: RigSetup, listen: ListenSetup)
let mut doc = DocumentMut::new(); let mut doc = DocumentMut::new();
doc.decor_mut() doc.decor_mut()
.set_prefix("# trx-server configuration\n# Generated by trx-configurator\n"); .set_prefix("# trx-server configuration\n# Generated by trx-configurator\n");
// Emit the sectioned shape (`[trx-server]`) that trx-server writes with let tables = build_server_tables(general, rig, listen);
// --print-config, so a generated file can be dropped into a combined for (key, item) in tables.iter() {
// trx-rs.toml unchanged. doc.insert(key, item.clone());
doc.insert( }
"trx-server",
Item::Table(build_server_tables(general, rig, listen)),
);
doc doc
} }
@@ -353,10 +350,10 @@ pub fn build_client(
let mut doc = DocumentMut::new(); let mut doc = DocumentMut::new();
doc.decor_mut() doc.decor_mut()
.set_prefix("# trx-client configuration\n# Generated by trx-configurator\n"); .set_prefix("# trx-client configuration\n# Generated by trx-configurator\n");
doc.insert( let tables = build_client_tables(general, remote, frontends);
"trx-client", for (key, item) in tables.iter() {
Item::Table(build_client_tables(general, remote, frontends)), doc.insert(key, item.clone());
); }
doc doc
} }
@@ -456,60 +453,3 @@ pub fn write_file(doc: &DocumentMut, path: &Path) -> Result<(), String> {
println!("Wrote {}", path.display()); println!("Wrote {}", path.display());
Ok(()) Ok(())
} }
#[cfg(test)]
mod tests {
use super::*;
use trx_config::{ClientConfig, ServerConfig};
fn write_temp(doc: &DocumentMut) -> tempfile::NamedTempFile {
let file = tempfile::Builder::new().suffix(".toml").tempfile().unwrap();
std::fs::write(file.path(), doc.to_string()).unwrap();
file
}
/// The wizard used to emit root-level `[general]` / `[rig]` tables while the
/// loader demanded a `[trx-server]` section, so every generated standalone
/// config was rejected by the binary it was generated for.
#[test]
fn test_generated_server_config_loads_and_validates() {
let file = write_temp(&build_default(ConfigType::Server));
let cfg = ServerConfig::load_from_file(file.path())
.expect("generated config must load")
.config;
cfg.validate().expect("generated config must validate");
assert_eq!(cfg.rig.model.as_deref(), Some("ft817"));
assert_eq!(cfg.listen.port, 4530);
}
#[test]
fn test_generated_client_config_loads_and_validates() {
let file = write_temp(&build_default(ConfigType::Client));
let cfg = ClientConfig::load_from_file(file.path())
.expect("generated config must load")
.config;
cfg.validate().expect("generated config must validate");
assert_eq!(cfg.remote.url.as_deref(), Some("localhost:4530"));
}
#[test]
fn test_generated_combined_config_loads_both_sections() {
let file = write_temp(&build_default(ConfigType::Combined));
let server = ServerConfig::load_from_file(file.path())
.expect("server section must load")
.config;
server.validate().expect("server section must validate");
let client = ClientConfig::load_from_file(file.path())
.expect("client section must load")
.config;
client.validate().expect("client section must validate");
}
#[test]
fn test_generated_docs_are_sectioned() {
let doc = build_default(ConfigType::Server);
assert!(doc.as_table().contains_key("trx-server"));
let doc = build_default(ConfigType::Client);
assert!(doc.as_table().contains_key("trx-client"));
}
}
-1
View File
@@ -32,7 +32,6 @@ cpal = "0.15"
num-complex = "0.4" num-complex = "0.4"
opus = "0.3" opus = "0.3"
trx-app = { path = "../trx-app" } trx-app = { path = "../trx-app" }
trx-config = { path = "../trx-config" }
trx-backend = { path = "trx-backend", features = ["soapysdr"] } trx-backend = { path = "trx-backend", features = ["soapysdr"] }
trx-ais = { path = "../decoders/trx-ais" } trx-ais = { path = "../decoders/trx-ais" }
trx-vdes = { path = "../decoders/trx-vdes" } trx-vdes = { path = "../decoders/trx-vdes" }
+4 -1
View File
@@ -2291,12 +2291,15 @@ pub async fn run_wefax_decoder(
mut state_rx: watch::Receiver<RigState>, mut state_rx: watch::Receiver<RigState>,
decode_tx: broadcast::Sender<DecodedMessage>, decode_tx: broadcast::Sender<DecodedMessage>,
histories: Arc<DecoderHistories>, histories: Arc<DecoderHistories>,
wefax_output_dir: std::path::PathBuf,
) { ) {
use trx_wefax::{WefaxConfig, WefaxDecoder, WefaxEvent}; use trx_wefax::{WefaxConfig, WefaxDecoder, WefaxEvent};
info!("WEFAX decoder started ({}Hz, {} ch)", sample_rate, channels); info!("WEFAX decoder started ({}Hz, {} ch)", sample_rate, channels);
let wefax_output_dir = dirs::cache_dir()
.unwrap_or_else(|| std::path::PathBuf::from(".cache"))
.join("trx-rs")
.join("wefax");
let config = WefaxConfig { let config = WefaxConfig {
output_dir: Some(wefax_output_dir.to_string_lossy().into_owned()), output_dir: Some(wefax_output_dir.to_string_lossy().into_owned()),
..WefaxConfig::default() ..WefaxConfig::default()
File diff suppressed because it is too large Load Diff
+142 -259
View File
@@ -30,8 +30,6 @@ use trx_core::audio::AudioStreamInfo;
use trx_app::{init_logging, normalize_name}; use trx_app::{init_logging, normalize_name};
use trx_backend::{register_builtin_backends_on, RegistrationContext, RigAccess}; use trx_backend::{register_builtin_backends_on, RegistrationContext, RigAccess};
use trx_config::shared::BoundSocket;
use trx_config::ConfigFile;
use trx_core::rig::controller::{AdaptivePolling, ExponentialBackoff}; use trx_core::rig::controller::{AdaptivePolling, ExponentialBackoff};
use trx_core::rig::request::RigRequest; use trx_core::rig::request::RigRequest;
use trx_core::rig::state::RigState; use trx_core::rig::state::RigState;
@@ -58,12 +56,6 @@ struct Cli {
/// Print example configuration and exit /// Print example configuration and exit
#[arg(long = "print-config")] #[arg(long = "print-config")]
print_config: bool, print_config: bool,
/// Treat unknown configuration keys as a fatal error
#[arg(long = "strict-config")]
strict_config: bool,
/// Validate the configuration and exit without starting anything
#[arg(long = "check-config")]
check_config: bool,
/// Rig backend to use (e.g. ft817, ft450d) /// Rig backend to use (e.g. ft817, ft450d)
#[arg(short = 'r', long = "rig")] #[arg(short = 'r', long = "rig")]
rig: Option<String>, rig: Option<String>,
@@ -663,44 +655,38 @@ fn spawn_rig_audio_stack(
} }
// Spawn APRS decoder task // Spawn APRS decoder task
if rig_cfg.decoders.is_enabled("aprs") { let aprs_pcm_rx = pcm_tx.subscribe();
let aprs_pcm_rx = pcm_tx.subscribe(); let aprs_state_rx = state_rx.clone();
let aprs_state_rx = state_rx.clone(); let aprs_decode_tx = decode_tx.clone();
let aprs_decode_tx = decode_tx.clone(); let aprs_sr = rig_cfg.audio.sample_rate;
let aprs_sr = rig_cfg.audio.sample_rate; let aprs_ch = rig_cfg.audio.channels;
let aprs_ch = rig_cfg.audio.channels; let aprs_shutdown_rx = shutdown_rx.clone();
let aprs_shutdown_rx = shutdown_rx.clone(); let aprs_logs = decoder_logs.clone();
let aprs_logs = decoder_logs.clone(); let aprs_histories = histories.clone();
let aprs_histories = histories.clone(); handles.push(tokio::spawn(async move {
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, aprs_logs, aprs_histories) => {}
_ = audio::run_aprs_decoder(aprs_sr, aprs_ch as u16, aprs_pcm_rx, aprs_state_rx, aprs_decode_tx, aprs_logs, aprs_histories) => {} _ = wait_for_shutdown(aprs_shutdown_rx) => {}
_ = wait_for_shutdown(aprs_shutdown_rx) => {} }
} }));
}));
}
// Spawn HF APRS decoder task // Spawn HF APRS decoder task
if rig_cfg.decoders.is_enabled("aprs_hf") { let hf_aprs_pcm_rx = pcm_tx.subscribe();
let hf_aprs_pcm_rx = pcm_tx.subscribe(); let hf_aprs_state_rx = state_rx.clone();
let hf_aprs_state_rx = state_rx.clone(); let hf_aprs_decode_tx = decode_tx.clone();
let hf_aprs_decode_tx = decode_tx.clone(); let hf_aprs_sr = rig_cfg.audio.sample_rate;
let hf_aprs_sr = rig_cfg.audio.sample_rate; let hf_aprs_ch = rig_cfg.audio.channels;
let hf_aprs_ch = rig_cfg.audio.channels; let hf_aprs_shutdown_rx = shutdown_rx.clone();
let hf_aprs_shutdown_rx = shutdown_rx.clone(); let hf_aprs_logs = decoder_logs.clone();
let hf_aprs_logs = decoder_logs.clone(); let hf_aprs_histories = histories.clone();
let hf_aprs_histories = histories.clone(); handles.push(tokio::spawn(async move {
handles.push(tokio::spawn(async move { tokio::select! {
tokio::select! { _ = audio::run_hf_aprs_decoder(hf_aprs_sr, hf_aprs_ch as u16, hf_aprs_pcm_rx, hf_aprs_state_rx, hf_aprs_decode_tx, hf_aprs_logs, hf_aprs_histories) => {}
_ = audio::run_hf_aprs_decoder(hf_aprs_sr, hf_aprs_ch as u16, hf_aprs_pcm_rx, hf_aprs_state_rx, hf_aprs_decode_tx, hf_aprs_logs, hf_aprs_histories) => {} _ = wait_for_shutdown(hf_aprs_shutdown_rx) => {}
_ = wait_for_shutdown(hf_aprs_shutdown_rx) => {} }
} }));
}));
}
if let Some((ais_a_pcm_rx, ais_b_pcm_rx)) = if let Some((ais_a_pcm_rx, ais_b_pcm_rx)) = sdr_ais_pcm_rx {
sdr_ais_pcm_rx.filter(|_| rig_cfg.decoders.is_enabled("ais"))
{
let ais_state_rx = state_rx.clone(); let ais_state_rx = state_rx.clone();
let ais_decode_tx = decode_tx.clone(); let ais_decode_tx = decode_tx.clone();
let ais_shutdown_rx = shutdown_rx.clone(); let ais_shutdown_rx = shutdown_rx.clone();
@@ -715,7 +701,7 @@ fn spawn_rig_audio_stack(
})); }));
} }
if let Some(vdes_iq_rx) = sdr_vdes_iq_rx.filter(|_| rig_cfg.decoders.is_enabled("vdes")) { if let Some(vdes_iq_rx) = sdr_vdes_iq_rx {
let vdes_state_rx = state_rx.clone(); let vdes_state_rx = state_rx.clone();
let vdes_decode_tx = decode_tx.clone(); let vdes_decode_tx = decode_tx.clone();
let vdes_shutdown_rx = shutdown_rx.clone(); let vdes_shutdown_rx = shutdown_rx.clone();
@@ -737,61 +723,55 @@ fn spawn_rig_audio_stack(
} }
// Spawn CW decoder task // Spawn CW decoder task
if rig_cfg.decoders.is_enabled("cw") { let cw_pcm_rx = pcm_tx.subscribe();
let cw_pcm_rx = pcm_tx.subscribe(); let cw_state_rx = state_rx.clone();
let cw_state_rx = state_rx.clone(); let cw_decode_tx = decode_tx.clone();
let cw_decode_tx = decode_tx.clone(); let cw_sr = rig_cfg.audio.sample_rate;
let cw_sr = rig_cfg.audio.sample_rate; let cw_ch = rig_cfg.audio.channels;
let cw_ch = rig_cfg.audio.channels; let cw_shutdown_rx = shutdown_rx.clone();
let cw_shutdown_rx = shutdown_rx.clone(); let cw_logs = decoder_logs.clone();
let cw_logs = decoder_logs.clone(); let cw_histories = histories.clone();
let cw_histories = histories.clone(); handles.push(tokio::spawn(async move {
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, cw_logs, cw_histories) => {}
_ = audio::run_cw_decoder(cw_sr, cw_ch as u16, cw_pcm_rx, cw_state_rx, cw_decode_tx, cw_logs, cw_histories) => {} _ = wait_for_shutdown(cw_shutdown_rx) => {}
_ = wait_for_shutdown(cw_shutdown_rx) => {} }
} }));
}));
}
// Spawn FT8 decoder task // Spawn FT8 decoder task
if rig_cfg.decoders.is_enabled("ft8") { let ft8_pcm_rx = pcm_tx.subscribe();
let ft8_pcm_rx = pcm_tx.subscribe(); let ft8_state_rx = state_rx.clone();
let ft8_state_rx = state_rx.clone(); let ft8_decode_tx = decode_tx.clone();
let ft8_decode_tx = decode_tx.clone(); let ft8_sr = rig_cfg.audio.sample_rate;
let ft8_sr = rig_cfg.audio.sample_rate; let ft8_ch = rig_cfg.audio.channels;
let ft8_ch = rig_cfg.audio.channels; let ft8_shutdown_rx = shutdown_rx.clone();
let ft8_shutdown_rx = shutdown_rx.clone(); let ft8_logs = decoder_logs.clone();
let ft8_logs = decoder_logs.clone(); let ft8_histories = histories.clone();
let ft8_histories = histories.clone(); handles.push(tokio::spawn(async move {
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, ft8_logs, ft8_histories) => {}
_ = audio::run_ft8_decoder(ft8_sr, ft8_ch as u16, ft8_pcm_rx, ft8_state_rx, ft8_decode_tx, ft8_logs, ft8_histories) => {} _ = wait_for_shutdown(ft8_shutdown_rx) => {}
_ = wait_for_shutdown(ft8_shutdown_rx) => {} }
} }));
}));
}
// Spawn FT4 decoder task // Spawn FT4 decoder task
if rig_cfg.decoders.is_enabled("ft4") { let ft4_pcm_rx = pcm_tx.subscribe();
let ft4_pcm_rx = pcm_tx.subscribe(); let ft4_state_rx = state_rx.clone();
let ft4_state_rx = state_rx.clone(); let ft4_decode_tx = decode_tx.clone();
let ft4_decode_tx = decode_tx.clone(); let ft4_sr = rig_cfg.audio.sample_rate;
let ft4_sr = rig_cfg.audio.sample_rate; let ft4_ch = rig_cfg.audio.channels;
let ft4_ch = rig_cfg.audio.channels; let ft4_shutdown_rx = shutdown_rx.clone();
let ft4_shutdown_rx = shutdown_rx.clone(); let ft4_histories = histories.clone();
let ft4_histories = histories.clone(); handles.push(tokio::spawn(async move {
handles.push(tokio::spawn(async move { tokio::select! {
tokio::select! { _ = audio::run_ft4_decoder(ft4_sr, ft4_ch as u16, ft4_pcm_rx, ft4_state_rx, ft4_decode_tx, ft4_histories) => {}
_ = audio::run_ft4_decoder(ft4_sr, ft4_ch as u16, ft4_pcm_rx, ft4_state_rx, ft4_decode_tx, ft4_histories) => {} _ = wait_for_shutdown(ft4_shutdown_rx) => {}
_ = wait_for_shutdown(ft4_shutdown_rx) => {} }
} }));
}));
}
// Spawn FT2 decoder task // Spawn FT2 decoder task
#[cfg(feature = "ft2")] #[cfg(feature = "ft2")]
if rig_cfg.decoders.is_enabled("ft2") { {
let ft2_pcm_rx = pcm_tx.subscribe(); let ft2_pcm_rx = pcm_tx.subscribe();
let ft2_state_rx = state_rx.clone(); let ft2_state_rx = state_rx.clone();
let ft2_decode_tx = decode_tx.clone(); let ft2_decode_tx = decode_tx.clone();
@@ -808,76 +788,73 @@ fn spawn_rig_audio_stack(
} }
// Spawn WSPR decoder task // Spawn WSPR decoder task
if rig_cfg.decoders.is_enabled("wspr") { let wspr_pcm_rx = pcm_tx.subscribe();
let wspr_pcm_rx = pcm_tx.subscribe(); let wspr_state_rx = state_rx.clone();
let wspr_state_rx = state_rx.clone(); let wspr_decode_tx = decode_tx.clone();
let wspr_decode_tx = decode_tx.clone(); let wspr_sr = rig_cfg.audio.sample_rate;
let wspr_sr = rig_cfg.audio.sample_rate; let wspr_ch = rig_cfg.audio.channels;
let wspr_ch = rig_cfg.audio.channels; let wspr_shutdown_rx = shutdown_rx.clone();
let wspr_shutdown_rx = shutdown_rx.clone(); let wspr_logs = decoder_logs.clone();
let wspr_logs = decoder_logs.clone(); let wspr_histories = histories.clone();
let wspr_histories = histories.clone(); handles.push(tokio::spawn(async move {
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, wspr_logs, wspr_histories) => {}
_ = audio::run_wspr_decoder(wspr_sr, wspr_ch as u16, wspr_pcm_rx, wspr_state_rx, wspr_decode_tx, wspr_logs, wspr_histories) => {} _ = wait_for_shutdown(wspr_shutdown_rx) => {}
_ = wait_for_shutdown(wspr_shutdown_rx) => {} }
} }));
}));
}
// Spawn Meteor-M LRPT decoder task // Spawn Meteor-M LRPT decoder task
if rig_cfg.decoders.is_enabled("lrpt") { let lrpt_pcm_rx = pcm_tx.subscribe();
let lrpt_pcm_rx = pcm_tx.subscribe(); let lrpt_state_rx = state_rx.clone();
let lrpt_state_rx = state_rx.clone(); let lrpt_decode_tx = decode_tx.clone();
let lrpt_decode_tx = decode_tx.clone(); let lrpt_sr = rig_cfg.audio.sample_rate;
let lrpt_sr = rig_cfg.audio.sample_rate; let lrpt_ch = rig_cfg.audio.channels;
let lrpt_ch = rig_cfg.audio.channels; let lrpt_shutdown_rx = shutdown_rx.clone();
let lrpt_shutdown_rx = shutdown_rx.clone(); let lrpt_histories = histories.clone();
let lrpt_histories = histories.clone(); let lrpt_output_dir = dirs::cache_dir()
let lrpt_output_dir = rig_cfg.decoders.output_dir_for("lrpt"); .unwrap_or_else(|| std::path::PathBuf::from(".cache"))
handles.push(tokio::spawn(async move { .join("trx-rs")
tokio::select! { .join("lrpt");
_ = audio::run_lrpt_decoder(lrpt_sr, lrpt_ch as u16, lrpt_pcm_rx, lrpt_state_rx, lrpt_decode_tx, lrpt_histories, lrpt_output_dir) => {} handles.push(tokio::spawn(async move {
_ = wait_for_shutdown(lrpt_shutdown_rx) => {} tokio::select! {
} _ = audio::run_lrpt_decoder(lrpt_sr, lrpt_ch as u16, lrpt_pcm_rx, lrpt_state_rx, lrpt_decode_tx, lrpt_histories, lrpt_output_dir) => {}
})); _ = wait_for_shutdown(lrpt_shutdown_rx) => {}
} }
}));
// Spawn WEFAX decoder task // Spawn WEFAX decoder task
if rig_cfg.decoders.is_enabled("wefax") { let wefax_pcm_rx = pcm_tx.subscribe();
let wefax_pcm_rx = pcm_tx.subscribe(); let wefax_state_rx = state_rx.clone();
let wefax_state_rx = state_rx.clone(); let wefax_decode_tx = decode_tx.clone();
let wefax_decode_tx = decode_tx.clone(); let wefax_sr = rig_cfg.audio.sample_rate;
let wefax_sr = rig_cfg.audio.sample_rate; let wefax_ch = rig_cfg.audio.channels;
let wefax_ch = rig_cfg.audio.channels; let wefax_shutdown_rx = shutdown_rx.clone();
let wefax_shutdown_rx = shutdown_rx.clone(); let wefax_histories = histories.clone();
let wefax_histories = histories.clone(); handles.push(tokio::spawn(async move {
let wefax_output_dir = rig_cfg.decoders.output_dir_for("wefax"); tokio::select! {
handles.push(tokio::spawn(async move { _ = audio::run_wefax_decoder(wefax_sr, wefax_ch as u16, wefax_pcm_rx, wefax_state_rx, wefax_decode_tx, wefax_histories) => {}
tokio::select! { _ = wait_for_shutdown(wefax_shutdown_rx) => {}
_ = audio::run_wefax_decoder(wefax_sr, wefax_ch as u16, wefax_pcm_rx, wefax_state_rx, wefax_decode_tx, wefax_histories, wefax_output_dir) => {} }
_ = wait_for_shutdown(wefax_shutdown_rx) => {} }));
}
}));
}
// Spawn SSTV decoder task // Spawn SSTV decoder task
if rig_cfg.decoders.is_enabled("sstv") { let sstv_pcm_rx = pcm_tx.subscribe();
let sstv_pcm_rx = pcm_tx.subscribe(); let sstv_state_rx = state_rx.clone();
let sstv_state_rx = state_rx.clone(); let sstv_decode_tx = decode_tx.clone();
let sstv_decode_tx = decode_tx.clone(); let sstv_sr = rig_cfg.audio.sample_rate;
let sstv_sr = rig_cfg.audio.sample_rate; let sstv_ch = rig_cfg.audio.channels;
let sstv_ch = rig_cfg.audio.channels; let sstv_shutdown_rx = shutdown_rx.clone();
let sstv_shutdown_rx = shutdown_rx.clone(); let sstv_histories = histories.clone();
let sstv_histories = histories.clone(); let sstv_output_dir = dirs::cache_dir()
let sstv_output_dir = rig_cfg.decoders.output_dir_for("sstv"); .unwrap_or_else(|| std::path::PathBuf::from(".cache"))
handles.push(tokio::spawn(async move { .join("trx-rs")
tokio::select! { .join("sstv");
_ = audio::run_sstv_decoder(sstv_sr, sstv_ch as u16, sstv_pcm_rx, sstv_state_rx, sstv_decode_tx, sstv_histories, sstv_output_dir) => {} handles.push(tokio::spawn(async move {
_ = wait_for_shutdown(sstv_shutdown_rx) => {} tokio::select! {
} _ = audio::run_sstv_decoder(sstv_sr, sstv_ch as u16, sstv_pcm_rx, sstv_state_rx, sstv_decode_tx, sstv_histories, sstv_output_dir) => {}
})); _ = wait_for_shutdown(sstv_shutdown_rx) => {}
} }
}));
} }
if rig_cfg.audio.tx_enabled { if rig_cfg.audio.tx_enabled {
@@ -907,82 +884,6 @@ fn spawn_rig_audio_stack(
handles handles
} }
/// Sockets this process will bind, given the config and the CLI overrides.
///
/// `--listen` overrides the bind address of both the control listener and every
/// rig's audio listener, so the two callers of this must agree on the rules.
fn bound_sockets(cli: &Cli, cfg: &ServerConfig, rigs: &[RigInstanceConfig]) -> Vec<BoundSocket> {
let mut sockets = Vec::new();
if cfg.listen.enabled {
sockets.push(BoundSocket::new(
cli.listen.unwrap_or(cfg.listen.listen),
cli.port.unwrap_or(cfg.listen.port),
"[listen]",
));
}
let audio_ip = cli.listen.unwrap_or(cfg.audio.listen);
for rig in rigs {
if rig.audio.enabled {
sockets.push(BoundSocket::new(
audio_ip,
rig.audio.port,
format!("rig \"{}\" [audio]", rig.id),
));
}
}
sockets
}
/// `--check-config`: report everything wrong with the configuration and exit.
///
/// Unlike startup, this reports every problem it finds rather than stopping at
/// the first, so a config can be fixed in one pass.
fn check_config(cli: &Cli, loaded: &trx_config::ConfigLoad<ServerConfig>) -> DynResult<()> {
match &loaded.path {
Some(path) => println!("{}", path.display()),
None => println!("(no config file found; checking built-in defaults)"),
}
let mut warnings: Vec<String> = loaded.unknown_keys.iter().map(|k| k.to_string()).collect();
warnings.extend(ServerConfig::deprecations(&loaded.present_keys));
for warning in &warnings {
println!(" warning: {}", warning);
}
let mut cfg = loaded.config.clone();
let mut errors = Vec::new();
if let Err(e) = cfg.resolve_secrets(loaded.path.as_deref()) {
errors.push(e);
}
let cfg = &cfg;
let rigs = cfg.resolved_rigs();
errors.extend(cfg.validate_all());
errors.extend(cfg.validate_sdr());
errors.extend(cfg.validate_resolved_all(&rigs, &bound_sockets(cli, cfg, &rigs)));
for e in &errors {
println!(" error: {}", e);
}
if errors.is_empty() {
println!(
" OK: {} rig(s) configured: {}",
rigs.len(),
rigs.iter()
.map(|r| r.id.as_str())
.collect::<Vec<_>>()
.join(", ")
);
if !warnings.is_empty() {
println!(" {} warning(s)", warnings.len());
}
Ok(())
} else {
Err(format!("{} error(s), {} warning(s)", errors.len(), warnings.len()).into())
}
}
#[tokio::main] #[tokio::main]
async fn main() -> DynResult<()> { async fn main() -> DynResult<()> {
let mut bootstrap_ctx = RegistrationContext::new(); let mut bootstrap_ctx = RegistrationContext::new();
@@ -995,30 +896,12 @@ async fn main() -> DynResult<()> {
return Ok(()); return Ok(());
} }
let loaded = if let Some(ref path) = cli.config { let (cfg, config_path) = if let Some(ref path) = cli.config {
ServerConfig::load_from_file(path)? let cfg = ServerConfig::load_from_file(path)?;
(cfg, Some(path.clone()))
} else { } else {
ServerConfig::load_from_default_paths()? ServerConfig::load_from_default_paths()?
}; };
let config_path = loaded.path.clone();
if cli.check_config {
return check_config(&cli, &loaded);
}
// Logging comes up before any config complaint so the warnings are visible.
init_logging(loaded.config.general.log_level.as_deref());
if let Some(ref path) = config_path {
info!("Loaded configuration from {}", path.display());
}
loaded.report_unknown_keys(cli.strict_config)?;
loaded.report_deprecations();
let mut cfg = loaded.config;
// Secrets configured as *_file are read before validation, so everything
// downstream sees resolved values.
cfg.resolve_secrets(config_path.as_deref())?;
cfg.validate() cfg.validate()
.map_err(|e| format!("Invalid server configuration: {}", e))?; .map_err(|e| format!("Invalid server configuration: {}", e))?;
@@ -1031,6 +914,12 @@ async fn main() -> DynResult<()> {
std::process::exit(1); std::process::exit(1);
} }
init_logging(cfg.general.log_level.as_deref());
if let Some(ref path) = config_path {
info!("Loaded configuration from {}", path.display());
}
let registry = Arc::new(bootstrap_ctx); let registry = Arc::new(bootstrap_ctx);
// --- Resolve the effective rig list --- // --- Resolve the effective rig list ---
@@ -1092,12 +981,6 @@ async fn main() -> DynResult<()> {
(callsign, cfg.general.latitude, cfg.general.longitude) (callsign, cfg.general.latitude, cfg.general.longitude)
}; };
// Second validation phase: now that CLI overrides have been folded in, check
// the things that need the final rig list — chiefly that no two listeners
// claim the same socket.
cfg.validate_resolved(&resolved_rigs, &bound_sockets(&cli, &cfg, &resolved_rigs))
.map_err(|e| format!("Invalid server configuration: {}", e))?;
info!( info!(
"Starting trx-server with {} rig(s): {}", "Starting trx-server with {} rig(s): {}",
resolved_rigs.len(), resolved_rigs.len(),
+6 -110
View File
@@ -1,60 +1,36 @@
# 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] [trx-server]
rigs = [] rigs = []
# Station identity. Coordinates feed PSKReporter and the map.
[trx-server.general] [trx-server.general]
callsign = "N0CALL" callsign = "N0CALL"
log_level = "info" log_level = "info"
latitude = 52.2297 latitude = 52.2297
longitude = 21.0122 longitude = 21.0122
# Single-rig layout. For several radios, delete this and use [[rigs]].
[trx-server.rig] [trx-server.rig]
model = "ft817" model = "ft817"
initial_freq_hz = 144300000 initial_freq_hz = 144300000
initial_mode = "USB" initial_mode = "USB"
# How to reach the radio: serial, tcp, or sdr.
[trx-server.rig.access] [trx-server.rig.access]
type = "serial" type = "serial"
port = "/dev/ttyUSB0" port = "/dev/ttyUSB0"
baud = 9600 baud = 9600
# CAT polling and retry behaviour.
[trx-server.behavior] [trx-server.behavior]
poll_interval_ms = 500 poll_interval_ms = 500
poll_interval_tx_ms = 100 poll_interval_tx_ms = 100
max_retries = 3 max_retries = 3
retry_base_delay_ms = 100 retry_base_delay_ms = 100
vfo_prime = true
# JSON control listener that trx-client connects to.
[trx-server.listen] [trx-server.listen]
enabled = true enabled = true
listen = "127.0.0.1" listen = "127.0.0.1"
port = 4530 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] [trx-server.listen.auth]
tokens = [] tokens = []
# Opus audio stream for trx-client.
[trx-server.audio] [trx-server.audio]
enabled = true enabled = true
listen = "127.0.0.1" listen = "127.0.0.1"
@@ -66,92 +42,45 @@ channels = 2
frame_duration_ms = 20 frame_duration_ms = 20
bitrate_bps = 256000 bitrate_bps = 256000
# Report FT8/FT4/WSPR spots to pskreporter.info.
[trx-server.pskreporter] [trx-server.pskreporter]
enabled = false enabled = false
host = "report.pskreporter.info" host = "report.pskreporter.info"
port = 4739 port = 4739
# Forward received APRS frames to APRS-IS.
[trx-server.aprsfi] [trx-server.aprsfi]
enabled = false enabled = false
host = "rotate.aprs.net" host = "rotate.aprs.net"
port = 14580 port = 14580
passcode = -1 passcode = -1
beacon = false
beacon_interval_secs = 1200
beacon_symbol_table = "/"
beacon_symbol_code = "-"
# Write decodes to JSON Lines files.
[trx-server.decode_logs] [trx-server.decode_logs]
enabled = false enabled = false
dir = "/var/lib/trx-rs/decoders" dir = "/path/to/log/dir"
aprs_file = "TRXRS-APRS-%YYYY%-%MM%-%DD%.log" aprs_file = "TRXRS-APRS-%YYYY%-%MM%-%DD%.log"
cw_file = "TRXRS-CW-%YYYY%-%MM%-%DD%.log" cw_file = "TRXRS-CW-%YYYY%-%MM%-%DD%.log"
ft8_file = "TRXRS-FT8-%YYYY%-%MM%-%DD%.log" ft8_file = "TRXRS-FT8-%YYYY%-%MM%-%DD%.log"
wspr_file = "TRXRS-WSPR-%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.
# output_dir sets where sstv/wefax/lrpt write images (default: user cache dir).
[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] [trx-server.sdr]
sample_rate = 1920000 sample_rate = 1920000
spectrum_fft_size = 1024
spectrum_interval_ms = 50
bandwidth = 1500000 bandwidth = 1500000
wfm_deemphasis_us = 50 wfm_deemphasis_us = 50
center_offset_hz = 100000 center_offset_hz = 100000
channels = [] channels = []
max_virtual_channels = 4 max_virtual_channels = 4
spectrum_fft_size = 1024
spectrum_interval_ms = 50
# "auto" for hardware AGC, or "manual".
[trx-server.sdr.gain] [trx-server.sdr.gain]
mode = "auto" mode = "auto"
value = 30.0 value = 30.0
# Software squelch on demodulated audio.
[trx-server.sdr.squelch] [trx-server.sdr.squelch]
enabled = false enabled = false
threshold_db = -65.0 threshold_db = -65.0
hysteresis_db = 3.0 hysteresis_db = 3.0
tail_ms = 180 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] [trx-client.general]
callsign = "N0CALL" callsign = "N0CALL"
website_url = "https://haxx.space" website_url = "https://haxx.space"
@@ -159,52 +88,25 @@ website_name = "haxx.space"
ais_vessel_url_base = "https://www.vesselfinder.com/?mmsi=" ais_vessel_url_base = "https://www.vesselfinder.com/?mmsi="
log_level = "info" log_level = "info"
# Legacy single-remote form; prefer [[remotes]] below.
[trx-client.remote] [trx-client.remote]
poll_interval_ms = 750 url = "192.168.1.100:9000"
spectrum_interval_ms = 50
[trx-client.remote.auth]
[[trx-client.remotes]]
name = "home-hf"
url = "192.168.1.100:4530"
rig_id = "hf" rig_id = "hf"
poll_interval_ms = 750 poll_interval_ms = 750
spectrum_interval_ms = 50 spectrum_interval_ms = 50
[trx-client.remotes.auth] [trx-client.remote.auth]
token = "my-token" token = "my-token"
[[trx-client.remotes]]
name = "home-vhf"
url = "192.168.1.100:4530"
rig_id = "vhf"
poll_interval_ms = 750
spectrum_interval_ms = 50
[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] [trx-client.frontends.http]
enabled = true enabled = true
listen = "127.0.0.1" listen = "127.0.0.1"
port = 8080 port = 8080
default_rig_name = "home-hf" default_rig_id = "hf"
initial_map_zoom = 10 initial_map_zoom = 10
spectrum_coverage_margin_hz = 50000 spectrum_coverage_margin_hz = 50000
spectrum_usable_span_ratio = 0.9200000166893005 spectrum_usable_span_ratio = 0.9200000166893005
show_sdr_gain_control = true 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] [trx-client.frontends.http.auth]
enabled = false enabled = false
rx_passphrase = "rx-passphrase-example" rx_passphrase = "rx-passphrase-example"
@@ -214,7 +116,6 @@ session_ttl_min = 480
cookie_secure = false cookie_secure = false
cookie_same_site = "Lax" cookie_same_site = "Lax"
# Hamlib-compatible TCP interface, one listener per rig.
[trx-client.frontends.rigctl] [trx-client.frontends.rigctl]
enabled = false enabled = false
listen = "127.0.0.1" listen = "127.0.0.1"
@@ -222,7 +123,6 @@ port = 4532
[trx-client.frontends.rigctl.rig_ports] [trx-client.frontends.rigctl.rig_ports]
# JSON-over-TCP control interface.
[trx-client.frontends.http_json] [trx-client.frontends.http_json]
enabled = true enabled = true
listen = "127.0.0.1" listen = "127.0.0.1"
@@ -231,16 +131,12 @@ port = 0
[trx-client.frontends.http_json.auth] [trx-client.frontends.http_json.auth]
tokens = [] tokens = []
# Where to fetch the audio stream from.
[trx-client.frontends.audio] [trx-client.frontends.audio]
enabled = true enabled = true
server_port = 4531 server_port = 4531
[trx-client.frontends.audio.rig_urls]
[trx-client.frontends.audio.rig_ports] [trx-client.frontends.audio.rig_ports]
# Play RX audio on a local sound device and capture TX from one.
[trx-client.frontends.audio.bridge] [trx-client.frontends.audio.bridge]
enabled = false enabled = false
bitrate_bps = 192000 bitrate_bps = 192000