diff --git a/MULTI.md b/MULTI.md new file mode 100644 index 0000000..04229b1 --- /dev/null +++ b/MULTI.md @@ -0,0 +1,156 @@ +# Multi-Rig Support + +This document specifies the requirements for running N simultaneous rig backends in one `trx-server` process and the protocol/config changes required to support them. + +--- + +## Progress + +> **For AI agents:** This section is the single source of truth for implementation status. +> Each task has a unique ID (e.g. `MR-01`), a status badge, a description, the files it touches, and any blocking dependencies. +> +> Status legend: `[ ]` not started · `[~]` in progress · `[x]` done · `[!]` blocked + +### Foundational (parallel) + +| ID | Status | Task | Files | Needs | +|----|--------|------|-------|-------| +| MR-01 | `[x]` | Add `rig_id: Option` to `ClientEnvelope`; add `rig_id: Option` to `ClientResponse`; add `ClientCommand::GetRigs`; add `GetRigsResponseBody` + `RigEntry`; add sentinel arm in `mapping.rs` | `src/trx-protocol/src/types.rs`, `mapping.rs`, `lib.rs` | — | +| MR-02 | `[x]` | Add `RigInstanceConfig`; add `rigs: Vec` to `ServerConfig`; implement `resolved_rigs()`; extend `validate()` for unique IDs + unique audio ports | `src/trx-server/src/config.rs` | — | +| MR-03 | `[x]` | Remove four `OnceLock` statics from `audio.rs`; add `DecoderHistories { aprs, ft8, wspr }` struct + `new()`; convert history free-fns to take `&DecoderHistories`; update decoder task signatures + `run_audio_listener` | `src/trx-server/src/audio.rs` | — | +| MR-04 | `[x]` | Create `src/trx-server/src/rig_handle.rs` with `RigHandle { rig_id, rig_tx, state_rx }`; declare mod in `main.rs` | `src/trx-server/src/rig_handle.rs`, `main.rs` | — | + +### Sequential + +| ID | Status | Task | Files | Needs | +|----|--------|------|-------|-------| +| MR-05 | `[x]` | Add `rig_id: String` + `histories: Arc` to `RigTaskConfig`; fix `clear_*_history` calls in `process_command` | `src/trx-server/src/rig_task.rs` | MR-03 | +| MR-06 | `[x]` | Rewrite `run_listener` to take `Arc>` + `default_rig_id`; route by `envelope.rig_id`; add `GetRigs` fast path; populate `rig_id` in every `ClientResponse` | `src/trx-server/src/listener.rs` | MR-01, MR-04 | +| MR-07 | `[x]` | Rewrite `main.rs` spawn loop over `resolved_rigs()`; extract `spawn_rig_audio_stack()`; per-rig pskreporter + aprsfi; build `HashMap`; pass to `run_listener` | `src/trx-server/src/main.rs` | MR-02–06 | + +### Tests + +| ID | Status | Task | Files | Needs | +|----|--------|------|-------|-------| +| MR-08 | `[x]` | Config tests: `resolved_rigs()` with multi-rig TOML and legacy TOML; duplicate ID/port rejection | `src/trx-server/src/config.rs` | MR-02 | +| MR-09 | `[x]` | Protocol tests: `ClientEnvelope` absent `rig_id` parses; `rig_id` in responses; `GetRigs` round-trip; existing tests still pass | `src/trx-protocol/src/codec.rs` | MR-01 | + +--- + +## Goals + +- Run N simultaneous rig backends (SDR, transceivers, or any mix) in one server process +- Route control commands to the correct rig via `rig_id` in the JSON protocol +- Backward compatibility: single-rig configs (`[rig]`/`[audio]` at top level) continue to work unchanged +- Per-rig audio streaming on separate TCP ports +- New `GetRigs` command to enumerate all connected rigs and their states + +--- + +## Non-Goals + +- Load-balancing or failover between rigs +- Sharing a single audio port across multiple rigs (each rig keeps its own port) + +--- + +## Architecture + +``` +Single [listen] port (4530) + └─ listener.rs: Arc> + ├─ route by envelope.rig_id (absent → first rig, backward compat) + └─ GetRigs → aggregate all states + +Per-rig: + rig_task ←→ RigHandle (rig_tx + state_rx) + audio capture → pcm_tx → decoder tasks → decode_tx + run_audio_listener (own TCP port per rig) + pskreporter + aprsfi tasks +``` + +--- + +## TOML Format + +### Multi-rig (`[[rigs]]` array) + +```toml +[general] +callsign = "W1AW" + +[listen] +port = 4530 + +[[rigs]] +id = "hf" +[rigs.rig] +model = "ft450d" +initial_freq_hz = 14074000 +[rigs.rig.access] +type = "serial" +port = "/dev/ttyUSB0" +baud = 9600 +[rigs.audio] +port = 4531 + +[[rigs]] +id = "sdr" +[rigs.rig] +model = "soapysdr" +[rigs.rig.access] +type = "sdr" +args = "driver=rtlsdr" +[rigs.audio] +port = 4532 +[rigs.sdr] +sample_rate = 1920000 +``` + +### Legacy (flat `[rig]` + `[audio]`) — continues to work unchanged + +```toml +[rig] +model = "ft817" +[rig.access] +type = "serial" +port = "/dev/ttyUSB0" +baud = 9600 +[audio] +port = 4531 +``` + +Legacy configs are synthesised into a single-element `[[rigs]]` list with `id = "default"` via `resolved_rigs()`. + +--- + +## Protocol Wire Format + +Request (`rig_id` optional; absent = first rig): +```json +{"rig_id": "hf", "cmd": "set_freq", "freq_hz": 14074000} +{"cmd": "get_state"} +``` + +Response (`rig_id` always present): +```json +{"success": true, "rig_id": "hf", "state": {...}} +{"success": false, "rig_id": "default", "error": "Unknown rig_id: xyz"} +``` + +`GetRigs` response: +```json +{"success": true, "rig_id": "server", "rigs": [ + {"rig_id": "hf", "state": {...}}, + {"rig_id": "sdr", "state": {...}} +]} +``` + +--- + +## Validation Rules (startup) + +- When `[[rigs]]` is non-empty: each `id` must be unique (case-sensitive). +- When `[[rigs]]` is non-empty: each `audio.port` must be unique. +- When `[[rigs]]` is empty: legacy flat fields are used with `id = "default"`. +- Mixing `[[rigs]]` and legacy flat `[rig]`/`[audio]` is undefined; `[[rigs]]` takes precedence.