From c4e8594fc7accc81b2047fb6df019fc89f370a48 Mon Sep 17 00:00:00 2001 From: Mike Treit Date: Thu, 28 May 2026 13:02:04 -0700 Subject: [PATCH 01/36] Add design doc for qsoripper-cathub multi-client CAT hub (#467) Captures the proposed Rust daemon that owns the radio serial port and fans it out to multiple concurrent client apps (HDSDR via OmniRig, N1MM, and the QsoRipper engine via the Hamlib net protocol). Splits the design into a RadioBackend trait, a ClientDialect trait, and a universal state model so adding a new transceiver or a new client app is an additive change. Documents v1 scope (Kenwood TS-590 backend with TS-590 and TS-2000 dialects) and the v2 roadmap (Icom CI-V, Yaesu CAT, FlexRadio SmartSDR). Co-authored-by: Mike Treit (from Dev Box) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/design/multi-client-cat-hub.md | 300 ++++++++++++++++++++++++++++ 1 file changed, 300 insertions(+) create mode 100644 docs/design/multi-client-cat-hub.md diff --git a/docs/design/multi-client-cat-hub.md b/docs/design/multi-client-cat-hub.md new file mode 100644 index 0000000..d91cd74 --- /dev/null +++ b/docs/design/multi-client-cat-hub.md @@ -0,0 +1,300 @@ +# Design: `qsoripper-cathub` multi-client CAT hub + +> **Status:** Proposed. This document is the source of truth for implementing the hub. Once implementation lands, the contract sections graduate into `docs/architecture/` and this file becomes a historical record. +> +> **Audience:** An implementer (human or AI agent) with access to this repository, the Hamlib `rigctld` net protocol reference, the Kenwood TS-590 CAT command reference, the Icom CI-V reference, and the com0com virtual serial port documentation. + +## 1. Problem + +A modern ham radio station runs several pieces of software against one transceiver at the same time. A typical contest station wants: + +- A panadapter or SDR receiver such as HDSDR with click-to-tune on the waterfall. +- A contest logger such as N1MM Logger+ driving the rig directly for band changes, mode switches, and CAT PTT. +- The QsoRipper engine reading rig state for QSO enrichment. + +Each of these wants its own CAT link to the radio. The radio exposes a single physical serial port. Only one Windows process can own that port at a time. The result is a forced choice between contest logging, panadapter tuning, and engine awareness. + +The existing workaround uses Hamlib's `rigctld` to multiplex the radio over TCP and a Python "safe bridge" to translate OmniRig's serial CAT into raw Hamlib calls. The full report is at . That stack solves a real bug (the TS-590 oscillating between VFO A and VFO B because Hamlib's TS-2000 backend retargets VFOs on every status poll), but it is fragile in three ways: + +- `rigctlcom` and the Python bridge each occupy a process and a com0com endpoint, and both must agree about which one owns the radio. +- The fix depends on never letting Hamlib invoke its VFO-targeting APIs on the TS-590. Any future Hamlib backend change can reintroduce the oscillation. +- Adding a second client (N1MM, on its own virtual COM port) requires the bridge to multiplex serial faces, which the current Python script does not do. + +This design replaces the bridge, `rigctlcom`, and `rigctld` with one Rust daemon that owns the radio and fans it out to as many clients as the operator needs. + +## 2. Goals + +- One daemon owns the real radio port. All client traffic is serialized through it. +- Multiple client apps run simultaneously against one radio with full read and write parity. +- State changes from any client propagate to all the others through a shared in-memory cache, so HDSDR's waterfall follows N1MM's band changes and the engine's view stays current. +- The radio path never invokes Hamlib's VFO-targeting APIs. The oscillation bug is structurally impossible. +- The daemon is radio-agnostic by construction. Adding a new transceiver model is a single new `RadioBackend` implementation with no changes to dialects, faces, or the state cache. +- The daemon is client-agnostic by construction. Adding a new client app means picking an existing CAT dialect or writing one new `ClientDialect` implementation. +- The QsoRipper engine continues to consume rig state without code change by speaking the Hamlib net protocol to a built-in server in the daemon. + +## 3. Non-goals + +- Replacing OmniRig, HDSDR, N1MM, or any other client app. +- Reimplementing Hamlib in full. The daemon implements only the subset of the `rigctld` net protocol that QsoRipper and similar clients actually use. +- Supporting every transceiver in v1. v1 ships the Kenwood TS-590. The Icom, Yaesu, and FlexRadio backends are designed for but not shipped in v1. +- Remote operation across hosts. The daemon binds loopback only. Cross-host operation is a future extension. +- A GUI control panel. v1 ships configuration via TOML and observability via structured logs. + +## 4. Current architecture, for reference + +``` +HDSDR + | + v +OmniRig (TS-2000 profile, COM11, 115200 baud, 500 ms poll) + | + v +com0com pair (COM11 <-> COM10) + | + v +safe_ts590_omnirig_bridge.py on COM10 + | + v +rigctld (TCP 127.0.0.1:4532) + | + v +TS-590 on COM3 (single owner) +``` + +N1MM cannot insert itself anywhere in this chain without taking COM3 away from `rigctld`. The QsoRipper engine consumes state by speaking the Hamlib net protocol to `rigctld`. + +## 5. Proposed architecture + +The daemon (`qsoripper-cathub`) is one Rust binary. It owns the radio port. It exposes several client faces. Each client face is either a virtual COM port (for client apps that expect serial CAT) or a TCP server (for clients that speak the Hamlib net protocol). + +``` + +-----------------+ +HDSDR --> OmniRig (TS-2000) --> com0com --> COM_OMNIRIG_SIDE -->| | + | | +N1MM (native TS-590) ------------> com0com --> COM_N1MM_SIDE -->| qsoripper- | + | cathub |--> COM3 --> TS-590 +QsoRipper engine -- Hamlib net protocol --> 127.0.0.1:4532 --->| (Rust) | + | | + | shared state | + | cache | + +-----------------+ +``` + +Two com0com pairs are required on the operator's machine. OmniRig binds one end of the first pair, the daemon binds the other end. N1MM binds one end of the second pair, the daemon binds the other end. Neither client app needs any reconfiguration beyond pointing at its assigned virtual COM port. + +## 6. Component layout + +The crate lives at `src/rust/qsoripper-cathub/` and is added to the workspace members in `src/rust/Cargo.toml`. Internal modules: + +- `radio` owns the radio transport. v1 supports serial; v2 adds TCP for FlexRadio. The module exposes an async `submit(cmd) -> reply` API. All access is serialized through one tokio task with a bounded mpsc inbox. Framing is configurable per backend: semicolon-terminated for Kenwood and Yaesu, `0xFD`-terminated for Icom CI-V. Reconnect on disconnect is automatic and idempotent. +- `backend` defines the `RadioBackend` trait. Implementations live in `backend/kenwood/ts590.rs` and `backend/loopback.rs` for v1, with `backend/icom/ci_v.rs`, `backend/yaesu/ft991a.rs`, and `backend/flex/smartsdr.rs` as v2 modules. The trait is the only seam between the radio and everything else. +- `state` is the universal in-memory snapshot. It holds VFO A and B frequencies, mode, split, RIT and XIT offsets, S-meter, power, and PTT owner, with per-field `last_polled` and `last_set` timestamps. Mutations from any path go through this layer. Backends populate it. Dialects read and mutate it. The Hamlib net face reads and mutates it. None of those three touches the others directly. +- `poller` is a single background task that drives a low-rate baseline poll through the active backend into `state`. The baseline cadence is 200 ms by default. The cache TTL per field controls when client-driven reads piggyback on the next baseline cycle versus serve directly from cache. +- `dialect` defines the `ClientDialect` trait. Implementations: `dialect/kenwood/ts590.rs` for N1MM, `dialect/kenwood/ts2000.rs` for OmniRig, with `dialect/icom/ci_v.rs` and others as future additions. Dialects only touch the universal state. They never call the radio backend directly. This is what guarantees any dialect serves any backend. +- `serial_face` is the generic virtual-COM listener. Each configured face binds one COM port and routes its byte stream into a configured dialect. Faces run concurrently as independent tasks. Two faces serving the same dialect against the same backend is supported and expected (one for HDSDR, one for some other panadapter app). +- `hamlib_net` is the minimal `rigctld`-compatible TCP server. It binds `127.0.0.1:4532` by default. It supports the subset `qsoripper-core` actually uses: `f` (get freq), `m` (get mode), `v` (get vfo), `s` (get split), and raw `w` (write CAT) passthrough. It is implemented against the universal state, so it works for any backend without changes. The existing `RigctldProvider` in `src/rust/qsoripper-core/src/rig_control/rigctld.rs` connects to the daemon with no code change. +- `ptt` enforces PTT ownership. Routed through the state mutation API. The default policy honors `TX;` and `RX;` from a face marked `allow_ptt = true` in config and drops PTT writes from other faces with a logged warning. v1 expects only the N1MM face to set `allow_ptt = true`. +- `config` loads TOML from `%APPDATA%\QsoRipper\cathub.toml` on Windows and `$XDG_CONFIG_HOME/qsoripper/cathub.toml` on Linux. A `--config ` flag overrides the default. A `--dry-run` flag loads, validates, prints the resolved config, and exits without binding any ports. +- `logging` wires `tracing` with per-face spans, a rolling file appender under `%USERPROFILE%\qsoripper-cathub.log` on Windows or `$XDG_STATE_HOME/qsoripper/cathub.log` on Linux, and a periodic summary line carrying commands per second per face, cache hit ratio, real-radio reads per second, dropped PTT writes, and reconnect events. +- `main` wires everything, installs Ctrl+C and SIGTERM handlers that emit `RX;` on shutdown to avoid a stuck transmitter, and exits with a non-zero code on fatal initialization failures. + +## 7. Multi-radio model + +The `RadioBackend` trait is the radio-side abstraction. Conceptual shape: + +```rust +#[async_trait] +pub trait RadioBackend: Send + Sync { + async fn poll(&self, state: &StateHandle) -> Result<(), BackendError>; + async fn apply(&self, mutation: StateMutation, state: &StateHandle) -> Result<(), BackendError>; + fn capabilities(&self) -> BackendCapabilities; +} +``` + +`StateMutation` is a universal enum: + +```rust +pub enum StateMutation { + SetVfoFreq { vfo: Vfo, hz: u64 }, + SetMode { vfo: Vfo, mode: Mode }, + SetSplit { enabled: bool, tx_vfo: Option }, + SetRit { offset_hz: i32, enabled: bool }, + SetXit { offset_hz: i32, enabled: bool }, + SetPtt { keyed: bool }, +} +``` + +`BackendCapabilities` describes what the backend can do. A backend with no XIT reports `xit: false`, and the Hamlib net face returns `RPRT -11` for XIT commands against that backend without ever touching the wire. + +The `ClientDialect` trait is the client-side abstraction: + +```rust +#[async_trait] +pub trait ClientDialect: Send + Sync { + async fn handle(&self, request: &[u8], state: &StateHandle) -> Vec; +} +``` + +A dialect parses incoming bytes, either reads from `state` to build a reply or emits a `StateMutation` through `state`'s mutation API, and returns bytes to the client. The state layer dispatches mutations to the active backend through a bounded channel and updates the cached value on backend acknowledgment. + +Because dialects only touch the universal state, any dialect can run on top of any backend. A TS-2000 dialect served by an Icom backend works exactly as well as a TS-2000 dialect served by a Kenwood backend, because both backends keep the universal state populated. + +### 7.1 v1 scope + +- Backends: `Ts590Backend` and `LoopbackBackend`. The Kenwood code is factored so the Kenwood command table is the only thing that changes between models. `Ts2000Backend`, `Ts480Backend`, `Ts890Backend`, and friends drop in by replacing the table. +- Dialects: `Ts590Dialect` (for N1MM, native pass-through with state caching) and `Ts2000Dialect` (for OmniRig, translator that answers `IF;`, `FA;`, `FB;` from the universal state and rejects Hamlib-style VFO-target writes). + +### 7.2 v2 roadmap + +- `IcomCiVBackend` for IC-7300, IC-7610, and IC-705. Different framing (`0xFE 0xFE` preamble, sub-address byte, `0xFD` end-of-message) but the same universal state. +- `YaesuFt991aBackend` and similar Yaesu models. Kenwood-like ASCII with Yaesu-specific commands. +- `FlexSmartSdrBackend` over the native FlexRadio TCP API. No serial port involved on the radio side. +- `IcomCiVDialect` for client apps that prefer to speak CI-V. + +Each entry in the v2 roadmap is one new trait impl. None of them changes any existing module. + +## 8. Behavior contracts + +### 8.1 Serialization + +All real-radio I/O goes through the single radio task. No two commands are ever in flight to the transceiver. The radio task drains its mpsc inbox in FIFO order. Each command carries a oneshot reply channel. + +### 8.2 Coalesced polling + +A client-driven status read is answered from `state` when the relevant field's `last_polled` is within its TTL. Otherwise the request waits for the next baseline poll cycle to refresh the field. The result is that N concurrent client reads of `IF;` produce at most one real `IF;` to the radio per TTL window, regardless of how many client faces are active. + +### 8.3 Write atomicity + +When any face emits a state mutation, the daemon forwards the resulting CAT command to the radio, awaits the acknowledgment, updates `state`, then acks the client. Subsequent reads from any face see the new value immediately. This is what makes HDSDR's waterfall follow N1MM's band change without HDSDR ever talking to N1MM directly. + +### 8.4 PTT ownership + +The daemon honors PTT writes only from faces configured with `allow_ptt = true`. v1 enables this only on the N1MM face. PTT writes from other faces are dropped with a logged warning. The shutdown handler emits `RX;` to the radio whether or not PTT is currently held, so a daemon crash never leaves the transmitter keyed. + +### 8.5 Graceful recovery + +If the radio transport disappears (USB unplugged, radio powered off), the daemon keeps the client faces open and answers status reads from `state` with a `stale` flag. State mutations from clients return a non-fatal error. The radio task retries the transport with backoff. On reconnect, the baseline poller resumes and the stale flag clears. + +### 8.6 No Hamlib in the radio path + +The daemon never links Hamlib. The Hamlib net protocol server is a thin server-side reimplementation of the wire protocol; it is not Hamlib code. This is the structural guarantee that the original VFO-targeting bug cannot return through any path. + +## 9. Configuration + +The TOML schema is: + +```toml +[radio] +model = "kenwood-ts590" +transport = "serial" +port = "COM3" +baud = 115200 + +[poll] +baseline_ms = 200 +freq_ttl_ms = 50 +mode_ttl_ms = 200 + +[hamlib_net] +enabled = true +bind = "127.0.0.1:4532" + +[[face]] +name = "omnirig" +port = "COM10" +baud = 115200 +dialect = "kenwood-ts2000" +allow_ptt = false + +[[face]] +name = "n1mm" +port = "COM20" +baud = 115200 +dialect = "kenwood-ts590" +allow_ptt = true +``` + +The operator creates two com0com pairs. OmniRig binds `COM11` (the daemon binds `COM10`). N1MM binds `COM21` (the daemon binds `COM20`). The QsoRipper engine continues to use its existing `RigctldProvider` configuration, now pointed at the daemon's built-in Hamlib net server. + +A v2 Icom configuration is structurally identical: + +```toml +[radio] +model = "icom-ic7300" +transport = "serial" +port = "COM3" +baud = 115200 +ci_v_address = 0x94 +``` + +Faces, polling, and the Hamlib net face are unchanged. The same N1MM TS-590 dialect on the same com0com pair continues to drive the radio because the universal state model and the dialect do not depend on the backend. + +## 10. Validation strategy + +### 10.1 Unit tests + +- `state` cache TTL, mutation, staleness, and concurrent reader behavior. +- `dialect/kenwood/ts590.rs` and `dialect/kenwood/ts2000.rs` round-trips against recorded TS-590 transcripts. +- `backend/kenwood/ts590.rs` command table coverage against recorded byte sequences. +- `backend/loopback.rs` exposes a deterministic implementation used by every integration test. + +### 10.2 Integration tests + +- Two `tokio::io::duplex` pipes simulate the OmniRig and N1MM virtual COM ports. A `LoopbackBackend` records every mutation. The test asserts: no client poll causes more than one backend command per TTL window; no TS-2000 status poll ever causes a `FR0` or `FR1` to be sent; writes from one face are visible to reads on the other face. +- The existing `RigctldProvider` from `src/rust/qsoripper-core/src/rig_control/rigctld.rs` is pointed at the daemon's Hamlib net face. The test confirms the engine consumes state without code changes. +- A PTT routing test confirms that `TX;` from the N1MM face reaches the backend and `TX;` from the OmniRig face is dropped with a warning. A shutdown test confirms `RX;` is emitted on Ctrl+C and on panic. + +### 10.3 Live bench + +- Reproduce the VFO B scenario from the existing report. The daemon log must show zero `FR0` or `FR1` traffic and no front-panel flicker across a 30-minute soak. +- Run N1MM, HDSDR, and the QsoRipper engine simultaneously. Exercise band changes, mode changes, RIT, and CAT PTT from N1MM. Confirm HDSDR's waterfall follows N1MM's band changes and that `dotnet run --project src\dotnet\QsoRipper.Cli -- status` reports the same state. +- Stress test: poll at 50 ms from all faces simultaneously. Real-radio command rate should stay near the baseline poll rate, not the sum of client poll rates. + +## 11. Rollout + +The crate lands in phases so each phase is independently testable and useful. + +Phase 1 brings up the skeleton, the radio task, the `LoopbackBackend`, the universal state, the `Ts590Backend`, and the `Ts590Dialect`. The N1MM face works end-to-end against a real TS-590. The OmniRig face and the Hamlib net face are not yet wired. + +Phase 2 adds the `Ts2000Dialect` and the second serial face. The Python safe bridge and `rigctlcom` are retired from the operator's startup scripts. `rigctld` continues to own the radio in this phase, behind a configuration flag, so the operator can A/B against the daemon. Once the VFO B soak passes, the daemon takes COM3 directly. + +Phase 3 adds the Hamlib net protocol server. The QsoRipper engine config is repointed at the daemon. `rigctld` is removed from the chain entirely. + +Phase 4 adds PTT routing and ownership, structured metrics, and the `--dry-run` mode. + +Phase 5 updates `docs/architecture/engine-specification.md` to describe the daemon as the recommended rig control front door on shared-radio stations, with `rigctld` retained as a supported alternative for single-client setups. Adds an operator setup guide under `docs/integrations/cathub-setup.md` covering com0com pair creation, OmniRig and N1MM configuration, startup order, and a troubleshooting checklist. Adds `Start-CatHub`, `Stop-CatHub`, and `Get-CatHubLog` helpers to `scripts/profile-helpers.ps1` mirroring the existing `Start-Rigctld` and `Start-RigBridge` style. + +## 12. Risks + +- **com0com driver quirks on Windows.** Port-open failures must surface clear, actionable errors. The operator guide documents driver installation and the expected pair naming. +- **TS-2000 and TS-590 command set drift.** A few TS-2000 commands have no clean TS-590 equivalent. The dialect layer rejects unsupported commands with a logged reply rather than guessing. The universal state plus baseline polling keeps status reads honest. +- **Latency regressions versus the Python bridge.** The integration suite includes an end-to-end timing assertion. The budget is sub-five-millisecond added latency per CAT round-trip on loopback. +- **PTT routing bug locking the transmitter on.** The Ctrl+C, SIGTERM, and panic handlers emit `RX;` unconditionally. An integration test exercises the shutdown path. +- **Single radio, multiple writers stomping each other.** Serialization through the single radio task is structural, not advisory. An integration test fans writes in from all faces and confirms the radio sees a strict ordering. + +## 13. Validation gates for the implementation PRs + +When the implementation PRs land, each must pass: + +- `cargo fmt --manifest-path src\rust\Cargo.toml --all -- --check` +- `cargo clippy --manifest-path src\rust\Cargo.toml --all-targets -- -D warnings` +- `cargo test --manifest-path src\rust\Cargo.toml` +- `cargo llvm-cov --manifest-path src\rust\Cargo.toml --workspace --exclude qsoripper-stress --exclude qsoripper-stress-tui --lcov --output-path rust-coverage.lcov`, with the workspace line coverage staying at or above the project's 80 percent threshold. +- `Push-Location src\rust; cargo deny check --config deny.toml; Pop-Location` for the PRs that touch dependencies. +- `dotnet build src\dotnet\QsoRipper.slnx` to confirm no engine regressions when the engine spec or rigctld provider are touched. + +## 14. Open questions + +- Whether to ship a small CLI subcommand for daemonless one-shot CAT calls (`cathub send IF;`) for troubleshooting. Easy to add; out of scope for v1. +- Whether to expose a JSON over WebSocket face for browser clients. Out of scope for v1; trivial to add later as another `ClientDialect`-shaped seam. +- Whether to add an audio routing component in a future iteration so the same daemon can multiplex radio audio for digital modes. Explicitly out of scope and likely belongs in a separate daemon if pursued. + +## 15. References + +- Existing VFO B writeup: . +- Hamlib `rigctld` net protocol: . +- Kenwood TS-590 CAT command reference (Kenwood publishes the official PDF on the TS-590S and TS-590SG product pages). +- Icom CI-V reference (Icom publishes per-model CI-V command tables in each transceiver's PDF reference manual). +- com0com virtual serial port driver: . +- N1MM Logger+ rig configuration: . +- Existing QsoRipper rig control consumer: `src/rust/qsoripper-core/src/rig_control/rigctld.rs`. From 187d2f322e084ff07d18861e2699b8c2075e424c Mon Sep 17 00:00:00 2001 From: Randy Treit <38894017+rtreit@users.noreply.github.com> Date: Thu, 28 May 2026 14:10:52 -0700 Subject: [PATCH 02/36] docs: extend CAT hub design for ARCP-590, WSJT-X, and multi-client brokering (#468) Adds Kenwood ARCP-590 and WSJT-X as first-class clients and reworks the design to broker many apps against one TS-590 at high performance: full read+write Hamlib net face, central AI2 auto-information ownership with per-face virtualization and push fan-out, passthrough for unmodeled native commands, per-face permissions, per-face-FIFO priority scheduling, PTT lease arbitration, dual read-only/write Hamlib listeners, expanded split/VFO state, and a QsoRipper architecture-alignment section. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/design/multi-client-cat-hub.md | 274 +++++++++++++++++++--------- 1 file changed, 192 insertions(+), 82 deletions(-) diff --git a/docs/design/multi-client-cat-hub.md b/docs/design/multi-client-cat-hub.md index d91cd74..01e3c09 100644 --- a/docs/design/multi-client-cat-hub.md +++ b/docs/design/multi-client-cat-hub.md @@ -2,42 +2,49 @@ > **Status:** Proposed. This document is the source of truth for implementing the hub. Once implementation lands, the contract sections graduate into `docs/architecture/` and this file becomes a historical record. > -> **Audience:** An implementer (human or AI agent) with access to this repository, the Hamlib `rigctld` net protocol reference, the Kenwood TS-590 CAT command reference, the Icom CI-V reference, and the com0com virtual serial port documentation. +> **Audience:** An implementer (human or AI agent) with access to this repository, the Hamlib `rigctld` net protocol reference, the Kenwood TS-590 CAT command reference (including the `AI` auto-information command), the Kenwood ARCP-590 / ARHP-590 documentation, the WSJT-X / Hamlib rig-control documentation, the Icom CI-V reference, and the com0com virtual serial port documentation. ## 1. Problem -A modern ham radio station runs several pieces of software against one transceiver at the same time. A typical contest station wants: +A modern ham radio station runs several pieces of software against one transceiver at the same time. A typical TS-590 station wants any combination of: -- A panadapter or SDR receiver such as HDSDR with click-to-tune on the waterfall. -- A contest logger such as N1MM Logger+ driving the rig directly for band changes, mode switches, and CAT PTT. -- The QsoRipper engine reading rig state for QSO enrichment. +- A panadapter or SDR receiver such as HDSDR with click-to-tune on the waterfall (typically through OmniRig speaking the TS-2000 profile). +- A contest logger such as N1MM Logger+ driving the rig directly for band changes, mode switches, and CAT PTT (native TS-590 CAT). +- The manufacturer's own control software, **Kenwood ARCP-590**, which presents a full software faceplate for the radio: VFOs, modes, DSP and filter settings, the antenna tuner, meters, the CW/voice keyer, and the entire `EX` menu. ARCP-590 speaks the **native TS-590 CAT command set** and exercises a far larger command surface, and polls far more aggressively, than a logger does. +- A digital-mode application such as **WSJT-X**, which controls the rig through **Hamlib** — either Hamlib's built-in TS-590S/SG backend on a serial port or, more cleanly for a shared station, Hamlib's **NET rigctl** transport pointed at a `rigctld`-compatible TCP endpoint. WSJT-X sets frequency, mode, and CAT PTT during normal operation. +- The QsoRipper engine reading rig state for QSO enrichment (today via its `rigctld` provider). -Each of these wants its own CAT link to the radio. The radio exposes a single physical serial port. Only one Windows process can own that port at a time. The result is a forced choice between contest logging, panadapter tuning, and engine awareness. +Each of these wants its own CAT link to the radio. The radio exposes a single physical (or USB virtual) serial port. Only one host process can own that port at a time. The result is a forced choice between contest logging, panadapter tuning, manufacturer control, digital modes, and engine awareness. Worse, the clients have **incompatible expectations**: ARCP-590 and WSJT-X both want to *write* to the radio and both want PTT; ARCP-590 and any AI-aware client expect the radio to *push* unsolicited status updates; OmniRig speaks a different dialect (TS-2000) than the radio actually is (TS-590). -The existing workaround uses Hamlib's `rigctld` to multiplex the radio over TCP and a Python "safe bridge" to translate OmniRig's serial CAT into raw Hamlib calls. The full report is at . That stack solves a real bug (the TS-590 oscillating between VFO A and VFO B because Hamlib's TS-2000 backend retargets VFOs on every status poll), but it is fragile in three ways: +The existing workaround uses Hamlib's `rigctld` to multiplex the radio over TCP and a Python "safe bridge" to translate OmniRig's serial CAT into raw Hamlib calls. The full report is at . That stack solves a real bug (the TS-590 oscillating between VFO A and VFO B because Hamlib's TS-2000 backend retargets VFOs on every status poll), but it is fragile in several ways: - `rigctlcom` and the Python bridge each occupy a process and a com0com endpoint, and both must agree about which one owns the radio. - The fix depends on never letting Hamlib invoke its VFO-targeting APIs on the TS-590. Any future Hamlib backend change can reintroduce the oscillation. -- Adding a second client (N1MM, on its own virtual COM port) requires the bridge to multiplex serial faces, which the current Python script does not do. +- Adding a second serial client (N1MM, ARCP-590, on its own virtual COM port) requires the bridge to multiplex serial faces, which the current Python script does not do. +- There is no story for the radio's native auto-information push (`AI2;`), so every client polls independently and the real-radio command rate scales with the number of clients. -This design replaces the bridge, `rigctlcom`, and `rigctld` with one Rust daemon that owns the radio and fans it out to as many clients as the operator needs. +This design replaces the bridge, `rigctlcom`, and `rigctld` with one Rust daemon that owns the radio and fans it out to as many clients as the operator needs, regardless of whether a given client speaks native TS-590 CAT, a foreign Kenwood dialect (TS-2000), or the Hamlib net protocol. ## 2. Goals - One daemon owns the real radio port. All client traffic is serialized through it. -- Multiple client apps run simultaneously against one radio with full read and write parity. -- State changes from any client propagate to all the others through a shared in-memory cache, so HDSDR's waterfall follows N1MM's band changes and the engine's view stays current. +- Multiple client apps run simultaneously against one radio with full read and write parity. This explicitly includes a heavy native controller (ARCP-590), one or more loggers (N1MM), a foreign-dialect panadapter client (OmniRig/HDSDR), a Hamlib-net digital-mode client (WSJT-X), and the QsoRipper engine, all at once. +- State changes from any client — or from the radio's front panel — propagate to all the other clients through a shared in-memory cache, so HDSDR's waterfall follows N1MM's band changes, ARCP-590 reflects a knob turn made on the physical radio, and the engine's view stays current. - The radio path never invokes Hamlib's VFO-targeting APIs. The oscillation bug is structurally impossible. - The daemon is radio-agnostic by construction. Adding a new transceiver model is a single new `RadioBackend` implementation with no changes to dialects, faces, or the state cache. -- The daemon is client-agnostic by construction. Adding a new client app means picking an existing CAT dialect or writing one new `ClientDialect` implementation. +- The daemon is client-agnostic by construction. Adding a new client app means picking an existing CAT dialect, or writing one new `ClientDialect` implementation, or pointing the client at the Hamlib net face. +- The Hamlib net face supports the **read and write** subset that real Hamlib clients use, not reads only, so WSJT-X (set frequency, set mode, set PTT, plus `\dump_state` and `\chk_vfo` at connect time) works unmodified. +- Native rich controllers such as ARCP-590 work without the daemon having to model every CAT command. Commands the universal state does not model are forwarded transparently to the radio through the same serialized path (passthrough), with state-mutating commands additionally captured into the universal state. +- The radio's native auto-information mode (`AI2;`) is owned centrally by the daemon and fanned out to the clients that want it, so a single real-radio AI stream feeds every AI-aware client without each client polling. - The QsoRipper engine continues to consume rig state without code change by speaking the Hamlib net protocol to a built-in server in the daemon. ## 3. Non-goals -- Replacing OmniRig, HDSDR, N1MM, or any other client app. -- Reimplementing Hamlib in full. The daemon implements only the subset of the `rigctld` net protocol that QsoRipper and similar clients actually use. +- Replacing OmniRig, HDSDR, N1MM, ARCP-590, WSJT-X, or any other client app. The daemon brokers them; it does not reimplement them. +- Reimplementing Hamlib in full. The daemon implements only the subset of the `rigctld` net protocol that QsoRipper, WSJT-X, and similar clients actually use. +- Modeling the full TS-590 CAT command set in the universal state. The state models the hot, cross-client fields (frequency, mode, split, RIT/XIT, S-meter, power, PTT). Everything else (the `EX` menu, filter/DSP detail, keyer memories, tuner state) flows through passthrough. - Supporting every transceiver in v1. v1 ships the Kenwood TS-590. The Icom, Yaesu, and FlexRadio backends are designed for but not shipped in v1. -- Remote operation across hosts. The daemon binds loopback only. Cross-host operation is a future extension. +- Remote operation across hosts. The daemon binds loopback only. Cross-host operation is a future extension (ARHP-590-style remote heads are explicitly out of scope for v1). - A GUI control panel. v1 ships configuration via TOML and observability via structured logs. ## 4. Current architecture, for reference @@ -61,41 +68,54 @@ rigctld (TCP 127.0.0.1:4532) TS-590 on COM3 (single owner) ``` -N1MM cannot insert itself anywhere in this chain without taking COM3 away from `rigctld`. The QsoRipper engine consumes state by speaking the Hamlib net protocol to `rigctld`. +N1MM, ARCP-590, and WSJT-X cannot insert themselves anywhere in this chain without taking COM3 away from `rigctld`. The QsoRipper engine consumes state by speaking the Hamlib net protocol to `rigctld`. ## 5. Proposed architecture The daemon (`qsoripper-cathub`) is one Rust binary. It owns the radio port. It exposes several client faces. Each client face is either a virtual COM port (for client apps that expect serial CAT) or a TCP server (for clients that speak the Hamlib net protocol). ``` - +-----------------+ -HDSDR --> OmniRig (TS-2000) --> com0com --> COM_OMNIRIG_SIDE -->| | - | | -N1MM (native TS-590) ------------> com0com --> COM_N1MM_SIDE -->| qsoripper- | - | cathub |--> COM3 --> TS-590 -QsoRipper engine -- Hamlib net protocol --> 127.0.0.1:4532 --->| (Rust) | - | | - | shared state | - | cache | - +-----------------+ + +-----------------+ +HDSDR --> OmniRig (TS-2000) --> com0com --> COM_OMNIRIG_SIDE ---->| | + | | +N1MM (native TS-590) ------------> com0com --> COM_N1MM_SIDE ---->| | + | qsoripper- | +ARCP-590 (native TS-590) --------> com0com --> COM_ARCP_SIDE ---->| cathub |--> COM3 --> TS-590 + | (Rust) | +WSJT-X -- Hamlib NET rigctl ------> 127.0.0.1:4533 (rw) -------->| | + | shared state | +QsoRipper engine -- Hamlib net ---> 127.0.0.1:4532 (ro) -------->| cache + AI | + | fan-out | + +-----------------+ ``` -Two com0com pairs are required on the operator's machine. OmniRig binds one end of the first pair, the daemon binds the other end. N1MM binds one end of the second pair, the daemon binds the other end. Neither client app needs any reconfiguration beyond pointing at its assigned virtual COM port. +Each serial client gets its own virtual serial pair on the operator's machine. OmniRig, N1MM, and ARCP-590 each bind one end of a dedicated pair; the daemon binds the other end. WSJT-X and the QsoRipper engine connect over the loopback Hamlib net face; because they have different privilege needs (WSJT-X writes and keys PTT, the engine only reads) and a single TCP port cannot express per-client permissions, the daemon exposes two loopback Hamlib listeners — a read-only one for the engine and a write/PTT one for WSJT-X (each accepts multiple connections). Neither client app needs any reconfiguration beyond pointing at its assigned virtual COM port or Hamlib endpoint. + +On Windows the virtual serial pairs are com0com pairs. On Linux the equivalent is a PTY pair (for example via `socat -d -d pty,raw,echo=0 pty,raw,echo=0` or `tty0tty`); the daemon binds one node and the client binds the other. The daemon treats both identically — it opens a serial-style endpoint by path and does not depend on the pairing mechanism. ## 6. Component layout The crate lives at `src/rust/qsoripper-cathub/` and is added to the workspace members in `src/rust/Cargo.toml`. Internal modules: -- `radio` owns the radio transport. v1 supports serial; v2 adds TCP for FlexRadio. The module exposes an async `submit(cmd) -> reply` API. All access is serialized through one tokio task with a bounded mpsc inbox. Framing is configurable per backend: semicolon-terminated for Kenwood and Yaesu, `0xFD`-terminated for Icom CI-V. Reconnect on disconnect is automatic and idempotent. +- `radio` owns the radio transport. v1 supports serial; v2 adds TCP for FlexRadio. The module exposes an async `submit(cmd, priority) -> reply` API. All access is serialized through one tokio task. Scheduling preserves **per-face FIFO ordering** and prioritizes only between the ready heads of the per-face queues: a face's commands always reach the radio in the order that face submitted them, while across faces PTT outranks interactive writes, which outrank reads/passthrough, which outrank the background baseline poll. A read issued by a face after that face's own write is therefore guaranteed to observe its own write. This avoids reordering a single client's request stream (which serial CAT clients assume) while still keeping an operator's keying and tuning ahead of another client's bulk reads. Framing is configurable per backend: semicolon-terminated for Kenwood and Yaesu, `0xFD`-terminated for Icom CI-V. The radio task also demultiplexes **unsolicited** frames (see `ai` below) from solicited replies via an explicit frame matcher (§8.4). Reconnect on disconnect is automatic and idempotent. - `backend` defines the `RadioBackend` trait. Implementations live in `backend/kenwood/ts590.rs` and `backend/loopback.rs` for v1, with `backend/icom/ci_v.rs`, `backend/yaesu/ft991a.rs`, and `backend/flex/smartsdr.rs` as v2 modules. The trait is the only seam between the radio and everything else. -- `state` is the universal in-memory snapshot. It holds VFO A and B frequencies, mode, split, RIT and XIT offsets, S-meter, power, and PTT owner, with per-field `last_polled` and `last_set` timestamps. Mutations from any path go through this layer. Backends populate it. Dialects read and mutate it. The Hamlib net face reads and mutates it. None of those three touches the others directly. -- `poller` is a single background task that drives a low-rate baseline poll through the active backend into `state`. The baseline cadence is 200 ms by default. The cache TTL per field controls when client-driven reads piggyback on the next baseline cycle versus serve directly from cache. -- `dialect` defines the `ClientDialect` trait. Implementations: `dialect/kenwood/ts590.rs` for N1MM, `dialect/kenwood/ts2000.rs` for OmniRig, with `dialect/icom/ci_v.rs` and others as future additions. Dialects only touch the universal state. They never call the radio backend directly. This is what guarantees any dialect serves any backend. -- `serial_face` is the generic virtual-COM listener. Each configured face binds one COM port and routes its byte stream into a configured dialect. Faces run concurrently as independent tasks. Two faces serving the same dialect against the same backend is supported and expected (one for HDSDR, one for some other panadapter app). -- `hamlib_net` is the minimal `rigctld`-compatible TCP server. It binds `127.0.0.1:4532` by default. It supports the subset `qsoripper-core` actually uses: `f` (get freq), `m` (get mode), `v` (get vfo), `s` (get split), and raw `w` (write CAT) passthrough. It is implemented against the universal state, so it works for any backend without changes. The existing `RigctldProvider` in `src/rust/qsoripper-core/src/rig_control/rigctld.rs` connects to the daemon with no code change. -- `ptt` enforces PTT ownership. Routed through the state mutation API. The default policy honors `TX;` and `RX;` from a face marked `allow_ptt = true` in config and drops PTT writes from other faces with a logged warning. v1 expects only the N1MM face to set `allow_ptt = true`. +- `state` is the universal in-memory snapshot. It must be rich enough for split- and VFO-aware clients (Hamlib `v`/`V`/`s`/`S`, WSJT-X split, N1MM, ARCP-590), so v1 models, per VFO where applicable: per-VFO frequency (A and B), per-VFO mode and passband, the active RX VFO, the active TX VFO, split enabled plus split TX VFO/frequency, RIT and XIT enabled plus offsets, S-meter, power, and PTT owner. Each field carries `last_polled`, `last_set`, and an `ai_covered` flag (see `poller`/§8.4). Mutations from any path go through this layer. Backends populate it. Dialects read and mutate it. The Hamlib net face reads and mutates it. The `ai` module updates it from unsolicited radio frames. None of those touch the others directly. The state layer also exposes a **change-notification broadcast** (a `tokio::sync::broadcast` channel) so faces can push updates to AI-aware clients. +- `poller` is a single background task that drives a low-rate baseline poll through the active backend into `state`. The baseline cadence is 200 ms by default. AI back-off is **per field, not blanket**: only fields whose `ai_covered` flag is set (those the radio actually reports via unsolicited `AI2` frames — typically frequency, mode, split) degrade to a slow liveness/heartbeat poll while AI is active. Fields the radio does *not* push spontaneously (S-meter, power, tuner/DSP detail) keep their own polling cadence regardless of AI state. The cache TTL per field controls when client-driven reads piggyback on the next baseline cycle versus serve directly from cache. +- `dialect` defines the `ClientDialect` trait. Implementations: `dialect/kenwood/ts590.rs` for N1MM and ARCP-590 (native pass-through with state caching and unmodeled-command passthrough), `dialect/kenwood/ts2000.rs` for OmniRig (translator). Dialects only touch the universal state and the radio passthrough channel; they never call a specific backend directly. This is what guarantees any dialect serves any backend. A dialect can both answer a request *and* emit asynchronous frames to its client (see AI fan-out, §8.4). +- `serial_face` is the generic virtual-COM listener. Each configured face binds one COM/PTY endpoint and routes its byte stream into a configured dialect. Faces run concurrently as independent tasks. Two faces serving the same dialect against the same backend is supported and expected (one for HDSDR, one for some other panadapter app; or N1MM and ARCP-590 both on the native TS-590 dialect). Each face owns an **outbound queue** so the daemon can push unsolicited frames (AI updates) to that client independently of request/response traffic. +- `hamlib_net` is the minimal `rigctld`-compatible TCP server. It binds `127.0.0.1:4532` by default and accepts multiple simultaneous connections (WSJT-X and the engine at once). It supports the **read and write** subset that real Hamlib clients use: + - reads: `f` (get freq), `m` (get mode), `v` (get vfo), `s` (get split), `t` (get ptt), and `\get_powerstat`; + - writes: `F` (set freq), `M` (set mode), `V` (set vfo), `S` (set split), `T` (set ptt); + - protocol handshake: `\dump_state` and `\chk_vfo`, which WSJT-X and other Hamlib clients issue on connect to learn rig capabilities; + - raw `w`/`W` (write/read CAT) passthrough for escape hatches. + Every command is implemented against the universal state and the backend capability table, so it works for any backend without changes. Response formats — the `\dump_state` capability dump, `\chk_vfo`, the `M ` shape, split replies, and the exact `RPRT ` lines — must match what real `rigctld` emits closely enough for unforgiving clients like WSJT-X; the dialect is validated against golden transcripts captured from a real `rigctld` (§10.1). Unsupported operations return the specific `RPRT` code rigctld uses (for example `RPRT -11`, "not available") rather than guessing. + Because a single TCP endpoint cannot express per-client permissions and any local process could connect, write and PTT are **disabled by default** and the daemon supports more than one listener: a read-only endpoint for the QsoRipper engine and a separate write/PTT-enabled endpoint for WSJT-X. Each listener carries its own permission set (see `permissions`). The existing `RigctldProvider` in `src/rust/qsoripper-core/src/rig_control/rigctld.rs` connects to the read-only endpoint with no code change; WSJT-X connects to the write/PTT endpoint as a standard Hamlib NET rigctl rig. +- `ai` owns the radio's auto-information mode centrally. It sets `AI2;` on the real radio at startup, parses unsolicited frames the radio pushes (typically `IF;` responses and single-field updates) into universal-state mutations, and feeds the state change-notification broadcast. It also **virtualizes** the `AI` command per client: a client that sends `AI2;`/`AI1;`/`AI0;` toggles only its *own* face's push subscription; it never changes the real radio's AI state. This prevents clients from fighting over the single physical AI setting. +- `passthrough` handles native CAT commands the universal state does not model (the bulk of ARCP-590's traffic: `EX` menu reads/writes, filter/DSP queries, tuner, keyer). The dialect forwards the raw command through the serialized radio task and returns the radio's reply verbatim. Reads may be served from a short-TTL cache **keyed by the full normalized command** (not just a prefix — `EX` and similar commands carry parameters that select different values), and the cache is **invalidated by command family** whenever a write in that family is forwarded. Commands whose mutability or side effects are unknown are not cached. Passthrough writes that happen to mutate a modeled field also update the universal state so other clients stay consistent. Passthrough requires that the face's dialect native command set matches the active backend's native command set (see §7 caveat); it is not portable across radio families. +- `ptt` enforces PTT ownership and arbitration. Routed through the state mutation API. See §8.5. +- `permissions` defines a per-face (and per-Hamlib-listener) capability set driven by a **per-dialect command classification table** that tags each command as one of: modeled read, modeled write, passthrough read, transient write, PTT/TX-affecting write, persistent/config write, or denied/unknown. The coarse face flags — `read`, `write`, `ptt`, and `config_write` — gate those classes (`config_write` gates `EX`-menu and other persistent-setting writes). Unknown passthrough writes default to denied unless the face explicitly opts into unsafe full control. This lets the operator give ARCP-590 full control while restricting a panadapter face to read-only, and prevents a stray native command from keying TX or rewriting menu settings from an under-privileged face. - `config` loads TOML from `%APPDATA%\QsoRipper\cathub.toml` on Windows and `$XDG_CONFIG_HOME/qsoripper/cathub.toml` on Linux. A `--config ` flag overrides the default. A `--dry-run` flag loads, validates, prints the resolved config, and exits without binding any ports. -- `logging` wires `tracing` with per-face spans, a rolling file appender under `%USERPROFILE%\qsoripper-cathub.log` on Windows or `$XDG_STATE_HOME/qsoripper/cathub.log` on Linux, and a periodic summary line carrying commands per second per face, cache hit ratio, real-radio reads per second, dropped PTT writes, and reconnect events. +- `logging` wires `tracing` with per-face spans, a rolling file appender under `%USERPROFILE%\qsoripper-cathub.log` on Windows or `$XDG_STATE_HOME/qsoripper/cathub.log` on Linux, and a periodic summary line carrying commands per second per face, cache hit ratio, real-radio reads per second, passthrough reads per second, AI frames per second, dropped/denied PTT writes, denied config writes, and reconnect events. - `main` wires everything, installs Ctrl+C and SIGTERM handlers that emit `RX;` on shutdown to avoid a stuck transmitter, and exits with a non-zero code on fatal initialization failures. ## 7. Multi-radio model @@ -107,6 +127,10 @@ The `RadioBackend` trait is the radio-side abstraction. Conceptual shape: pub trait RadioBackend: Send + Sync { async fn poll(&self, state: &StateHandle) -> Result<(), BackendError>; async fn apply(&self, mutation: StateMutation, state: &StateHandle) -> Result<(), BackendError>; + /// Parse an unsolicited frame pushed by the radio (AI mode) into a state mutation, if recognized. + fn parse_unsolicited(&self, frame: &[u8]) -> Option; + /// Forward an opaque native command and return the raw reply (passthrough). + async fn passthrough(&self, raw: &[u8]) -> Result, BackendError>; fn capabilities(&self) -> BackendCapabilities; } ``` @@ -124,29 +148,32 @@ pub enum StateMutation { } ``` -`BackendCapabilities` describes what the backend can do. A backend with no XIT reports `xit: false`, and the Hamlib net face returns `RPRT -11` for XIT commands against that backend without ever touching the wire. +`BackendCapabilities` describes what the backend can do. A backend with no XIT reports `xit: false`, and the Hamlib net face returns `RPRT -11` for XIT commands against that backend without ever touching the wire. The capability table is also what `\dump_state` reports to Hamlib clients. The `ClientDialect` trait is the client-side abstraction: ```rust #[async_trait] pub trait ClientDialect: Send + Sync { - async fn handle(&self, request: &[u8], state: &StateHandle) -> Vec; + /// Handle one inbound request: read state, emit a mutation, or passthrough; return reply bytes. + async fn handle(&self, request: &[u8], ctx: &FaceContext) -> Vec; + /// Format a state-change notification as an unsolicited frame for this client (AI fan-out), + /// or return None if this dialect/client does not want pushes for this change. + fn format_notification(&self, change: &StateChange, ctx: &FaceContext) -> Option>; } ``` -A dialect parses incoming bytes, either reads from `state` to build a reply or emits a `StateMutation` through `state`'s mutation API, and returns bytes to the client. The state layer dispatches mutations to the active backend through a bounded channel and updates the cached value on backend acknowledgment. - -Because dialects only touch the universal state, any dialect can run on top of any backend. A TS-2000 dialect served by an Icom backend works exactly as well as a TS-2000 dialect served by a Kenwood backend, because both backends keep the universal state populated. +`FaceContext` carries the face's permission set, its per-face AI subscription state, and handles to the universal state and the radio passthrough channel. Because dialects only touch the universal state and passthrough, any dialect can run on top of any backend **for modeled commands**. A TS-2000 dialect served by an Icom backend answers modeled reads/writes exactly as well as one served by a Kenwood backend, because both backends keep the universal state populated. **Native passthrough is the exception:** it forwards radio-family-specific bytes, so a native TS-590 dialect (N1MM, ARCP-590) only fully works over a Kenwood backend. Pairing a native dialect with a foreign backend must either fail config validation or run in modeled-only mode with passthrough disabled — the daemon must not promise ARCP-590 against a non-Kenwood radio. ### 7.1 v1 scope - Backends: `Ts590Backend` and `LoopbackBackend`. The Kenwood code is factored so the Kenwood command table is the only thing that changes between models. `Ts2000Backend`, `Ts480Backend`, `Ts890Backend`, and friends drop in by replacing the table. -- Dialects: `Ts590Dialect` (for N1MM, native pass-through with state caching) and `Ts2000Dialect` (for OmniRig, translator that answers `IF;`, `FA;`, `FB;` from the universal state and rejects Hamlib-style VFO-target writes). +- Dialects: `Ts590Dialect` (native pass-through with state caching, AI fan-out, and unmodeled-command passthrough — serves both N1MM and ARCP-590) and `Ts2000Dialect` (for OmniRig, translator that answers `IF;`, `FA;`, `FB;` from the universal state and rejects Hamlib-style VFO-target writes). +- Faces: native serial faces for N1MM and ARCP-590, a TS-2000 serial face for OmniRig, and the Hamlib net face for WSJT-X and the engine. ### 7.2 v2 roadmap -- `IcomCiVBackend` for IC-7300, IC-7610, and IC-705. Different framing (`0xFE 0xFE` preamble, sub-address byte, `0xFD` end-of-message) but the same universal state. +- `IcomCiVBackend` for IC-7300, IC-7610, and IC-705. Different framing (`0xFE 0xFE` preamble, sub-address byte, `0xFD` end-of-message) but the same universal state. Icom transceive mode is the CI-V analogue of Kenwood AI and feeds the same `parse_unsolicited` path. - `YaesuFt991aBackend` and similar Yaesu models. Kenwood-like ASCII with Yaesu-specific commands. - `FlexSmartSdrBackend` over the native FlexRadio TCP API. No serial port involved on the radio side. - `IcomCiVDialect` for client apps that prefer to speak CI-V. @@ -155,29 +182,69 @@ Each entry in the v2 roadmap is one new trait impl. None of them changes any exi ## 8. Behavior contracts -### 8.1 Serialization +### 8.1 Serialization and ordering -All real-radio I/O goes through the single radio task. No two commands are ever in flight to the transceiver. The radio task drains its mpsc inbox in FIFO order. Each command carries a oneshot reply channel. +All real-radio I/O goes through the single radio task. No two commands are ever in flight to the transceiver. Each face has its own FIFO queue; the radio task selects the next command by priority **across the ready heads** of those queues — PTT (highest) > interactive client writes (frequency/mode/split) > client reads/passthrough > baseline poll (lowest) — and never reorders commands within a single face. A face's read therefore always observes that face's own earlier writes, and a client's request stream reaches the radio in submitted order, while an operator's keying and tuning still preempt another client's bulk reads. Each command carries a oneshot reply channel. ### 8.2 Coalesced polling -A client-driven status read is answered from `state` when the relevant field's `last_polled` is within its TTL. Otherwise the request waits for the next baseline poll cycle to refresh the field. The result is that N concurrent client reads of `IF;` produce at most one real `IF;` to the radio per TTL window, regardless of how many client faces are active. +A client-driven status read of a **modeled** field is answered from `state` when the relevant field's `last_polled` is within its TTL. Otherwise the request waits for the next baseline poll cycle (or, when AI is active, the most recent pushed value). The result is that N concurrent client reads of `IF;` produce at most one real `IF;` to the radio per TTL window, regardless of how many client faces are active. Reads of **unmodeled** fields go through passthrough (§8.6) and are coalesced only by the optional passthrough response cache, so a controller that polls unmodeled fields hard will generate proportional real-radio traffic; the priority scheme keeps that traffic from starving interactive writes. ### 8.3 Write atomicity -When any face emits a state mutation, the daemon forwards the resulting CAT command to the radio, awaits the acknowledgment, updates `state`, then acks the client. Subsequent reads from any face see the new value immediately. This is what makes HDSDR's waterfall follow N1MM's band change without HDSDR ever talking to N1MM directly. +Kenwood set commands do not all return an acknowledgment — many are "set and no reply." The daemon therefore classifies each modeled command's reply behavior in the backend command table as one of: + +- **no-reply write:** state is updated optimistically after the serial write succeeds; correctness is reconciled by the next `AI2` echo (for `ai_covered` fields) or a verify-read for fields that are not AI-covered; +- **write with reply:** the daemon waits for and parses the reply before updating state and acking the client; +- **read:** the daemon waits for the matching solicited reply (frame matcher, §8.4). + +For "write with reply" commands, subsequent reads from any face see the new value immediately after the ack. For "no-reply" commands the new value is visible after the optimistic update, then confirmed (and corrected if the radio rejected it) by the echo/verify path. Either way, HDSDR's waterfall follows N1MM's band change without HDSDR ever talking to N1MM directly. The atomicity guarantee is "ordered and eventually reconciled," not "blocked on an ack the radio never sends." + +### 8.4 Auto-information (AI) fan-out + +This is the mechanism that lets a knob turn on the physical radio, a band change in N1MM, a frequency set from WSJT-X, and a VFO change in ARCP-590 all stay mutually consistent at low cost. + +**Frame demultiplexing contract.** With `AI2` enabled the radio emits unsolicited semicolon-terminated frames that can be byte-identical to solicited replies (notably `IF...;`). The radio task disambiguates with an explicit matcher: each outbound command declares its expected reply verb(s) and whether it expects a reply at all. When a frame arrives, if a command is pending whose expected verb matches, the frame completes that command's oneshot; otherwise the frame is routed to the `ai` parser. Commands that expect no reply (no-reply writes, §8.3) never capture an incoming frame. This must be tested against the hard interleavings: a pending `IF;` read arriving concurrently with an unsolicited `IF` push, a no-reply write immediately followed by its `AI2` echo, and passthrough reads arriving while AI frames stream. + +- The `ai` module sets `AI2;` on the real radio once, at startup, and owns that setting for the daemon's lifetime. +- Unsolicited frames the radio pushes are parsed by the backend's `parse_unsolicited` into `StateMutation`s, applied to `state`, and emitted on the state change-notification broadcast. +- Each face subscribes to the broadcast. For each change, the face's dialect decides via `format_notification` whether and how to push it to its client. A native TS-590 client with its virtual AI enabled receives a synthesized `IF;`/field frame; a TS-2000 client receives the TS-2000-equivalent frame; a client with AI disabled receives nothing and continues to poll. +- The `AI` command from any client is **virtualized**: it toggles only that face's subscription, never the real radio's AI state. This removes the classic multi-client failure where one app sends `AI0;` and silences the auto-info another app depends on. +- Because the daemon, not each client, owns the real AI stream, one physical AI feed serves every AI-aware client and the baseline poll can back off (`poller`), cutting real-radio traffic. + +**Push ordering and backpressure.** Each face's outbound push queue is bounded. Pushes are interleaved with that face's solicited replies at frame boundaries (never mid-frame). If a slow client lets its queue fill, the daemon **coalesces** superseded updates (keeping only the latest value per field) rather than blocking the shared broadcast or growing unboundedly; a sustained overflow is logged. AI fan-out must never apply backpressure to the radio task or to other faces. + +### 8.4.1 AI mode granularity + +The `AI` command is virtualized per face, but `AI1` (post-command echo) and `AI2` (spontaneous updates) are not identical semantics. v1 may collapse both to a single per-face push subscription; if it does, that limitation is documented and ARCP-590 and N1MM are tested specifically to confirm they tolerate the collapse before either is claimed as first-class. If a tested client depends on the `AI1`/`AI2` distinction, the finer three-state (`AI0`/`AI1`/`AI2`) model is implemented for that dialect. + +### 8.5 PTT ownership and arbitration + +Multiple clients are now PTT-capable (N1MM CAT PTT, WSJT-X `T 1`, ARCP-590). The daemon arbitrates with a single-owner lease: -### 8.4 PTT ownership +- A face must have the `ptt` capability to key at all. Faces without it have their PTT writes dropped with a logged warning. +- The first capable face to key acquires the **PTT lease** and becomes the PTT owner. While the lease is held, PTT writes from any other face are rejected (Hamlib `RPRT` busy / native error) and logged, so two apps cannot key the transmitter into contention. +- The lease releases when the owner sends `RX;`/`T 0` (normal release), or after a configurable **maximum-transmit safety duration** (`ptt_max_tx_ms`) as a backstop against a client that keys and crashes. This timeout is a hard transmit-length ceiling, *not* a generic "no CAT activity" idle timer: WSJT-X keys PTT and then sends no CAT traffic for the length of a transmission, so an activity-based timeout would unkey it mid-over. `ptt_max_tx_ms` defaults above the longest expected digital-mode transmission and any safety release is logged loudly. +- The shutdown handler attempts to emit `RX;` to the radio on Ctrl+C and SIGTERM so an orderly stop never leaves the transmitter keyed. A panic or hard crash cannot reliably perform async serial I/O, so the daemon also relies on `ptt_max_tx_ms` and the radio's own TX timeout as the ultimate stuck-transmitter guards rather than promising the shutdown write always runs. -The daemon honors PTT writes only from faces configured with `allow_ptt = true`. v1 enables this only on the N1MM face. PTT writes from other faces are dropped with a logged warning. The shutdown handler emits `RX;` to the radio whether or not PTT is currently held, so a daemon crash never leaves the transmitter keyed. +In a normal station the operator drives one mode at a time, so the lease is almost never contended; its job is to make contention safe rather than to support simultaneous transmit. -### 8.5 Graceful recovery +### 8.6 Passthrough for rich native clients -If the radio transport disappears (USB unplugged, radio powered off), the daemon keeps the client faces open and answers status reads from `state` with a `stale` flag. State mutations from clients return a non-fatal error. The radio task retries the transport with backoff. On reconnect, the baseline poller resumes and the stale flag clears. +ARCP-590 (and any future faceplate-class controller) issues many commands the universal state does not model. The contract: -### 8.6 No Hamlib in the radio path +- A command whose normalized form maps to a modeled field is handled through the state path (cached read or atomic write) so it participates in coalescing, AI fan-out, and cross-client consistency. +- Any other command is forwarded verbatim through the serialized radio task and its raw reply is returned to the client unchanged. Reads may be served from a short-TTL cache keyed by the **full normalized command** (parameters included) and invalidated **by command family** when a write in that family is forwarded; commands with unknown side effects are not cached. +- Passthrough is gated by the command classification table and face permissions (§6 `permissions`): a face without `config_write` cannot push `EX`-menu or other persistent-setting writes, and unknown passthrough writes are denied by default; such attempts are logged. +- Passthrough never bypasses serialization. It is just another priority-classified entry in the radio task's inbox. -The daemon never links Hamlib. The Hamlib net protocol server is a thin server-side reimplementation of the wire protocol; it is not Hamlib code. This is the structural guarantee that the original VFO-targeting bug cannot return through any path. +### 8.7 Graceful recovery + +If the radio transport disappears (USB unplugged, radio powered off), the daemon keeps the client faces open and answers modeled status reads from `state` with a `stale` flag. State mutations and passthrough writes from clients return a non-fatal error. The radio task retries the transport with backoff. On reconnect, the daemon re-asserts `AI2;`, the baseline poller resumes, and the stale flag clears. + +### 8.8 No Hamlib in the radio path + +The daemon never links Hamlib. The Hamlib net protocol server is a thin server-side reimplementation of the wire protocol; it is not Hamlib code. This is the structural guarantee that the original VFO-targeting bug cannot return through any path, including the WSJT-X path, because the daemon's own `Ts590Backend` issues only TS-590 native commands and never the TS-2000-style VFO-target writes that triggered the oscillation. ## 9. Configuration @@ -191,30 +258,49 @@ port = "COM3" baud = 115200 [poll] -baseline_ms = 200 +baseline_ms = 200 # active when AI is unavailable +ai_heartbeat_ms = 2000 # slow liveness poll while AI2 is pushing updates freq_ttl_ms = 50 mode_ttl_ms = 200 -[hamlib_net] -enabled = true +[ai] +enabled = true # daemon sets AI2; on the real radio and owns it + +# Read-only Hamlib endpoint for the QsoRipper engine. +[[hamlib_net]] +name = "engine" bind = "127.0.0.1:4532" +permissions = ["read"] + +# Write/PTT Hamlib endpoint for WSJT-X. +[[hamlib_net]] +name = "wsjtx" +bind = "127.0.0.1:4533" +permissions = ["read", "write", "ptt"] [[face]] name = "omnirig" port = "COM10" baud = 115200 dialect = "kenwood-ts2000" -allow_ptt = false +permissions = ["read"] # panadapter follows; does not drive the radio [[face]] name = "n1mm" port = "COM20" baud = 115200 dialect = "kenwood-ts590" -allow_ptt = true +permissions = ["read", "write", "ptt"] + +[[face]] +name = "arcp590" +port = "COM30" +baud = 115200 +dialect = "kenwood-ts590" +permissions = ["read", "write", "ptt", "config_write"] # full faceplate control ``` -The operator creates two com0com pairs. OmniRig binds `COM11` (the daemon binds `COM10`). N1MM binds `COM21` (the daemon binds `COM20`). The QsoRipper engine continues to use its existing `RigctldProvider` configuration, now pointed at the daemon's built-in Hamlib net server. +The operator creates one virtual serial pair per serial client. OmniRig binds `COM11` (the daemon binds `COM10`), N1MM binds `COM21` (daemon `COM20`), ARCP-590 binds `COM31` (daemon `COM30`). Each `[[face]]` may also specify serial line parameters (`parity`, `stop_bits`, `data_bits`) and control-line behavior (`dtr`, `rts`); these default to 8-N-1 but are configurable because some clients assert DTR/RTS or expect specific framing and the virtual pair must match on both ends. WSJT-X is configured as a Hamlib **NET rigctl** rig pointed at the write/PTT Hamlib endpoint. The QsoRipper engine continues to use its existing `RigctldProvider` configuration, pointed at the read-only Hamlib endpoint (TCP supports both clients simultaneously). A v2 Icom configuration is structurally identical: @@ -227,52 +313,71 @@ baud = 115200 ci_v_address = 0x94 ``` -Faces, polling, and the Hamlib net face are unchanged. The same N1MM TS-590 dialect on the same com0com pair continues to drive the radio because the universal state model and the dialect do not depend on the backend. +Faces, polling, AI, and the Hamlib net face are unchanged for modeled commands. Note that a native TS-590 dialect's passthrough does *not* carry over to an Icom backend (§7): an Icom station would drive the radio through the Hamlib net face and the TS-2000/CI-V dialects for modeled state, and a native Kenwood faceplate app like ARCP-590 is simply not applicable to a non-Kenwood radio. The universal state model, AI fan-out, and modeled reads/writes remain backend-independent. ## 10. Validation strategy ### 10.1 Unit tests -- `state` cache TTL, mutation, staleness, and concurrent reader behavior. -- `dialect/kenwood/ts590.rs` and `dialect/kenwood/ts2000.rs` round-trips against recorded TS-590 transcripts. -- `backend/kenwood/ts590.rs` command table coverage against recorded byte sequences. +- `state` cache TTL, mutation, staleness, concurrent reader behavior, and change-notification broadcast delivery. +- `dialect/kenwood/ts590.rs` and `dialect/kenwood/ts2000.rs` round-trips against recorded TS-590 transcripts, including ARCP-590 `EX`-menu passthrough transcripts. +- `ai` parsing of recorded unsolicited `IF;` frames into mutations, and per-face AI virtualization (a client `AI0;` must not change the real radio's AI state). +- `permissions` enforcement: write/ptt/config_write denials for under-privileged faces. +- `hamlib_net` read and write command coverage validated against **golden transcripts captured from a real `rigctld`** (NET rigctl), including the `\dump_state` capability dump, `\chk_vfo`, `F`, `M `, `T`, `S`, `V`, simple vs extended response mode, and the exact `RPRT ` lines for unsupported operations. Per-endpoint permission enforcement (read-only endpoint rejects `F`/`M`/`T`). +- `backend/kenwood/ts590.rs` command table and `parse_unsolicited`/`passthrough` coverage against recorded byte sequences. - `backend/loopback.rs` exposes a deterministic implementation used by every integration test. ### 10.2 Integration tests -- Two `tokio::io::duplex` pipes simulate the OmniRig and N1MM virtual COM ports. A `LoopbackBackend` records every mutation. The test asserts: no client poll causes more than one backend command per TTL window; no TS-2000 status poll ever causes a `FR0` or `FR1` to be sent; writes from one face are visible to reads on the other face. -- The existing `RigctldProvider` from `src/rust/qsoripper-core/src/rig_control/rigctld.rs` is pointed at the daemon's Hamlib net face. The test confirms the engine consumes state without code changes. -- A PTT routing test confirms that `TX;` from the N1MM face reaches the backend and `TX;` from the OmniRig face is dropped with a warning. A shutdown test confirms `RX;` is emitted on Ctrl+C and on panic. +- Virtual serial pairs via `tokio::io::duplex` simulate the OmniRig, N1MM, and ARCP-590 ports; a TCP client simulates WSJT-X and the engine on the Hamlib net face. A `LoopbackBackend` records every mutation and passthrough. Assertions: no modeled-field client poll causes more than one backend command per TTL window; no TS-2000 status poll ever causes a `FR0`/`FR1` to be sent; writes from one face are visible to reads on every other face; a simulated front-panel change (unsolicited frame from the loopback backend) propagates to all AI-subscribed faces. +- The existing `RigctldProvider` from `src/rust/qsoripper-core/src/rig_control/rigctld.rs` is pointed at the daemon's read-only Hamlib endpoint and consumes state without code changes. A separate simulated WSJT-X client on the write/PTT endpoint sets frequency/mode and keys PTT concurrently with the engine reading state, and a test confirms the read-only endpoint rejects `F`/`M`/`T`. +- A PTT arbitration test confirms the first capable face acquires the lease, a second capable face is rejected while the lease is held, the lease releases on `RX;`/`T 0` and on the `ptt_max_tx_ms` safety ceiling (and that a CAT-idle but actively-transmitting client is *not* unkeyed before that ceiling), and PTT from a face lacking the `ptt` capability is dropped with a warning. A shutdown test confirms `RX;` is emitted on Ctrl+C and SIGTERM, and that `ptt_max_tx_ms` bounds a simulated crash that skips the shutdown write. +- A frame-demux test drives the matcher through a pending `IF;` read arriving with a concurrent unsolicited `IF` push, a no-reply write followed by its `AI2` echo, and passthrough reads interleaved with AI frames, asserting each oneshot completes against the correct frame and no push is lost. +- A passthrough test confirms an ARCP-590-style `EX`-menu read is forwarded verbatim and that a `config_write` denial is enforced on a face without that permission. ### 10.3 Live bench -- Reproduce the VFO B scenario from the existing report. The daemon log must show zero `FR0` or `FR1` traffic and no front-panel flicker across a 30-minute soak. -- Run N1MM, HDSDR, and the QsoRipper engine simultaneously. Exercise band changes, mode changes, RIT, and CAT PTT from N1MM. Confirm HDSDR's waterfall follows N1MM's band changes and that `dotnet run --project src\dotnet\QsoRipper.Cli -- status` reports the same state. -- Stress test: poll at 50 ms from all faces simultaneously. Real-radio command rate should stay near the baseline poll rate, not the sum of client poll rates. +- Reproduce the VFO B scenario from the existing report. The daemon log must show zero `FR0`/`FR1` traffic and no front-panel flicker across a 30-minute soak. +- Run N1MM, HDSDR (via OmniRig), ARCP-590, WSJT-X, and the QsoRipper engine simultaneously. Exercise band changes, mode changes, RIT, and CAT PTT from N1MM; full faceplate control and `EX`-menu access from ARCP-590; frequency/mode/PTT from WSJT-X. Turn the VFO knob on the physical radio. Confirm every client reflects the change, that `dotnet run --project src\dotnet\QsoRipper.Cli -- status` reports the same state, and that only one transmitter key is ever active. +- AI fan-out: with `AI2;` owned by the daemon, confirm a physical-knob frequency change reaches every AI-subscribed client without any client having polled, and that only `ai_covered` fields back off to the heartbeat rate while meter/power keep their own cadence. +- Serial line behavior: confirm ARCP-590, N1MM, and OmniRig open their virtual ports with the configured parity/stop/data bits and DTR/RTS handling, and that a mismatch surfaces a clear error rather than silent garbled CAT. +- Stress test: poll at 50 ms from all faces simultaneously while ARCP-590 streams `EX`-menu reads. Modeled-field real-radio command rate should stay near the baseline poll rate, not the sum of client poll rates; interactive writes (PTT, frequency set) must stay responsive (priority scheduling) even under the passthrough load. ## 11. Rollout The crate lands in phases so each phase is independently testable and useful. -Phase 1 brings up the skeleton, the radio task, the `LoopbackBackend`, the universal state, the `Ts590Backend`, and the `Ts590Dialect`. The N1MM face works end-to-end against a real TS-590. The OmniRig face and the Hamlib net face are not yet wired. +Phase 1 brings up the skeleton, the radio task with the priority inbox, the `LoopbackBackend`, the universal state with change-notification broadcast, the `Ts590Backend`, and the `Ts590Dialect` with passthrough. The N1MM face works end-to-end against a real TS-590. The OmniRig face, ARCP-590 face, AI fan-out, and the Hamlib net face are not yet wired. -Phase 2 adds the `Ts2000Dialect` and the second serial face. The Python safe bridge and `rigctlcom` are retired from the operator's startup scripts. `rigctld` continues to own the radio in this phase, behind a configuration flag, so the operator can A/B against the daemon. Once the VFO B soak passes, the daemon takes COM3 directly. +Phase 2 adds the `Ts2000Dialect` and the OmniRig serial face, plus the ARCP-590 native serial face and the `permissions` model. The Python safe bridge and `rigctlcom` are retired from the operator's startup scripts. For A/B comparison without two processes fighting over COM3, this phase ships a temporary `RigctldBackend` so the daemon can sit *in front of* a still-running `rigctld` (rigctld owns the physical port; the daemon owns the faces and AI fan-out) behind a configuration flag. Exactly one process owns COM3 at any time: either `rigctld` (via the `RigctldBackend`) or the daemon's `Ts590Backend` directly. Once the VFO B soak passes, the operator flips the flag and the daemon's `Ts590Backend` takes COM3 directly; the `RigctldBackend` is dropped after the transition. -Phase 3 adds the Hamlib net protocol server. The QsoRipper engine config is repointed at the daemon. `rigctld` is removed from the chain entirely. +Phase 3 adds the Hamlib net protocol server with full read/write support, `\dump_state`, and `\chk_vfo`. The QsoRipper engine config is repointed at the daemon, and WSJT-X is connected as a Hamlib NET rigctl rig. `rigctld` is removed from the chain entirely. -Phase 4 adds PTT routing and ownership, structured metrics, and the `--dry-run` mode. +Phase 4 adds the `ai` module (central `AI2;` ownership, unsolicited-frame parsing, per-face AI virtualization, fan-out, and poller back-off), PTT lease arbitration, structured metrics, and the `--dry-run` mode. -Phase 5 updates `docs/architecture/engine-specification.md` to describe the daemon as the recommended rig control front door on shared-radio stations, with `rigctld` retained as a supported alternative for single-client setups. Adds an operator setup guide under `docs/integrations/cathub-setup.md` covering com0com pair creation, OmniRig and N1MM configuration, startup order, and a troubleshooting checklist. Adds `Start-CatHub`, `Stop-CatHub`, and `Get-CatHubLog` helpers to `scripts/profile-helpers.ps1` mirroring the existing `Start-Rigctld` and `Start-RigBridge` style. +Phase 5 finalizes documentation, but per the project's engine-spec-currency rule the spec and operator docs are updated **in the same PR as the behavior that changes them**, not deferred wholesale to the end — for example, the PR that repoints the engine at the daemon (Phase 3) updates the relevant spec note in that same PR. Phase 5 then consolidates: it updates `docs/architecture/engine-specification.md` to describe the daemon as the recommended rig-control front door on shared-radio stations, with `rigctld` retained as a supported alternative for single-client setups. It clarifies that the cathub daemon is **station infrastructure that lives below the engine's `rigctld` provider**, not part of the gRPC engine contract, so the engine remains client-agnostic and the §3.4 `RigControlService` / §5.3 rigctld integration sections are unchanged on the engine side. Adds an operator setup guide under `docs/integrations/cathub-setup.md` covering virtual serial pair creation (com0com on Windows, PTY pairs on Linux), OmniRig, N1MM, ARCP-590, and WSJT-X configuration, startup order, AI behavior, and a troubleshooting checklist. Adds `Start-CatHub`, `Stop-CatHub`, and `Get-CatHubLog` helpers to `scripts/profile-helpers.ps1` mirroring the existing `Start-Rigctld` and `Start-RigBridge` style. ## 12. Risks -- **com0com driver quirks on Windows.** Port-open failures must surface clear, actionable errors. The operator guide documents driver installation and the expected pair naming. +- **com0com / PTY driver quirks.** Port-open failures must surface clear, actionable errors. The operator guide documents driver installation and the expected pair naming on both Windows and Linux. - **TS-2000 and TS-590 command set drift.** A few TS-2000 commands have no clean TS-590 equivalent. The dialect layer rejects unsupported commands with a logged reply rather than guessing. The universal state plus baseline polling keeps status reads honest. -- **Latency regressions versus the Python bridge.** The integration suite includes an end-to-end timing assertion. The budget is sub-five-millisecond added latency per CAT round-trip on loopback. -- **PTT routing bug locking the transmitter on.** The Ctrl+C, SIGTERM, and panic handlers emit `RX;` unconditionally. An integration test exercises the shutdown path. +- **ARCP-590 command surface beyond the modeled state.** Passthrough covers it, but a command that mutates a modeled field through an unmodeled prefix could desync the cache. The backend's prefix map for state-mutating commands must be tested against recorded ARCP-590 transcripts, and AI fan-out provides a correction path because the radio echoes the real change. +- **AI frame parsing and the AI ownership contract.** If a client manages to change the real radio's AI state, other clients lose their push feed. The virtualization in `ai` must be the only path that ever writes `AI` to the radio; a test enforces that a client `AI0;` does not reach the wire. +- **PTT contention and stuck transmitter.** The lease makes contention safe; orderly Ctrl+C and SIGTERM paths emit `RX;`; and because a panic/crash cannot reliably do async serial I/O, `ptt_max_tx_ms` plus the radio's own TX timeout are the ultimate stuck-transmitter guards. Integration tests exercise the arbitration, the safety ceiling, and the shutdown paths. +- **Latency regressions versus the Python bridge under heavy passthrough.** The integration suite includes an end-to-end timing assertion. The budget is sub-five-millisecond added latency per modeled CAT round-trip on loopback, with interactive writes prioritized ahead of passthrough reads so ARCP-590 polling cannot inflate PTT/tuning latency. - **Single radio, multiple writers stomping each other.** Serialization through the single radio task is structural, not advisory. An integration test fans writes in from all faces and confirms the radio sees a strict ordering. -## 13. Validation gates for the implementation PRs +## 13. Alignment with QsoRipper architecture + +This design is consistent with the project's architectural principles: + +- **Stable core, volatile edges.** The cathub daemon is an *edge* component (station infrastructure). The QsoRipper engine's stable core is untouched: it keeps consuming rig state through its existing `RigctldProvider` and the `RigControlService` gRPC contract. Hardware, dialect, and vendor-app quirks are isolated inside the daemon. +- **Normalize at the edge.** Vendor CAT dialects (TS-590 native, TS-2000, CI-V) and vendor apps (ARCP-590, OmniRig, WSJT-X) are normalized into one universal state model at the boundary, exactly as the engine normalizes QRZ and ADIF into project-owned proto/domain types. +- **Performance and low latency.** Priority scheduling, coalesced polling, AI-driven poll back-off, and a passthrough cache target the project's "everything should feel instant" goal for the interactive control path. +- **Engine specification currency.** Per project rules, Phase 5 updates `docs/architecture/engine-specification.md` in the same change that lands the behavioral shift, documenting the daemon as the recommended rig-control front door while keeping the engine's gRPC contract stable. +- **Cross-platform.** The daemon uses path-based serial endpoints and `std::path` semantics, supports com0com (Windows) and PTY pairs (Linux), and avoids hardcoded separators, satisfying the repository's Windows-and-Linux requirement even though the primary station is Windows. + +## 14. Validation gates for the implementation PRs When the implementation PRs land, each must pass: @@ -283,18 +388,23 @@ When the implementation PRs land, each must pass: - `Push-Location src\rust; cargo deny check --config deny.toml; Pop-Location` for the PRs that touch dependencies. - `dotnet build src\dotnet\QsoRipper.slnx` to confirm no engine regressions when the engine spec or rigctld provider are touched. -## 14. Open questions +## 15. Open questions - Whether to ship a small CLI subcommand for daemonless one-shot CAT calls (`cathub send IF;`) for troubleshooting. Easy to add; out of scope for v1. - Whether to expose a JSON over WebSocket face for browser clients. Out of scope for v1; trivial to add later as another `ClientDialect`-shaped seam. -- Whether to add an audio routing component in a future iteration so the same daemon can multiplex radio audio for digital modes. Explicitly out of scope and likely belongs in a separate daemon if pursued. +- Whether the per-face AI virtualization should support `AI1;` (post-command echo) semantics distinctly from `AI2;` (spontaneous), or collapse both to a single push subscription. v1 may collapse them; loggers that depend on the `AI1;` distinction would need the finer model. +- Whether to add an audio routing component in a future iteration so the same daemon can multiplex radio audio for digital modes (WSJT-X, fldigi). Explicitly out of scope and likely belongs in a separate daemon if pursued. +- Whether to support ARHP-590-style remote heads (cathub over the network) once loopback operation is proven. Out of scope for v1. -## 15. References +## 16. References - Existing VFO B writeup: . -- Hamlib `rigctld` net protocol: . -- Kenwood TS-590 CAT command reference (Kenwood publishes the official PDF on the TS-590S and TS-590SG product pages). +- Hamlib `rigctld` net protocol and command set (`F`/`M`/`T` sets, `\dump_state`, `\chk_vfo`): and . +- Kenwood TS-590 CAT command reference, including the `AI` auto-information command and the `IF` status response (Kenwood publishes the official PDF on the TS-590S and TS-590SG product pages). +- Kenwood ARCP-590 Radio Control Program and ARHP-590 Host Program (Kenwood publishes both on the TS-590S/SG support pages). +- WSJT-X user guide, rig control via Hamlib / Hamlib NET rigctl: . - Icom CI-V reference (Icom publishes per-model CI-V command tables in each transceiver's PDF reference manual). - com0com virtual serial port driver: . - N1MM Logger+ rig configuration: . - Existing QsoRipper rig control consumer: `src/rust/qsoripper-core/src/rig_control/rigctld.rs`. +- QsoRipper engine specification, rig control sections: `docs/architecture/engine-specification.md` (§3.4 RigControlService, §5.3 Rig Control). From 67915bf828334e83bfa08e252d0779fca68747ff Mon Sep 17 00:00:00 2001 From: Randy Treit <38894017+rtreit@users.noreply.github.com> Date: Thu, 28 May 2026 15:02:18 -0700 Subject: [PATCH 03/36] docs: make CAT hub rig-agnostic with a multi-strategy backend model (#470) Generalize the cathub design from TS-590-specific to rig-agnostic behind the RadioBackend trait and BackendCapabilities, and reason explicitly about which backend strategy is best rather than defaulting to wrapping rigctld. - Generalize the AI2 fan-out into a rig-neutral event/push abstraction (events module, RadioEventSource), with poll-diff synthesis for rigs without native push and back-off gated only on NativePush coverage. - Reframe the anti-oscillation guarantee as a wire-level no-VFO-retargeting invariant rather than a blanket no-Hamlib rule. - Add four backend strategies (native, out-of-process rigctld bridge, descriptor, in-process FFI) and an explicit best-backend rationale: native is the recommended default for the TS-590 (full fidelity, native passthrough, native push, lowest latency, invariant by construction); rigctld is the trusted breadth bridge for modeled control only; descriptor is the scaling endgame; FFI is a last resort. - Treat the rigctld bridge as uncertified by default and modeled-control only, certified per rigctld version+model+config via a radio-side wire soak across the client compatibility matrix; require a daemon-private, sole-client rigctld. - Finish event-naming cleanup (native_push_covered, [events] config, native-push metrics) and add OmniRig RigIni/OmniRig2 references. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/design/multi-client-cat-hub.md | 209 +++++++++++++++++++--------- 1 file changed, 145 insertions(+), 64 deletions(-) diff --git a/docs/design/multi-client-cat-hub.md b/docs/design/multi-client-cat-hub.md index 01e3c09..6747384 100644 --- a/docs/design/multi-client-cat-hub.md +++ b/docs/design/multi-client-cat-hub.md @@ -23,27 +23,30 @@ The existing workaround uses Hamlib's `rigctld` to multiplex the radio over TCP - Adding a second serial client (N1MM, ARCP-590, on its own virtual COM port) requires the bridge to multiplex serial faces, which the current Python script does not do. - There is no story for the radio's native auto-information push (`AI2;`), so every client polls independently and the real-radio command rate scales with the number of clients. -This design replaces the bridge, `rigctlcom`, and `rigctld` with one Rust daemon that owns the radio and fans it out to as many clients as the operator needs, regardless of whether a given client speaks native TS-590 CAT, a foreign Kenwood dialect (TS-2000), or the Hamlib net protocol. +This design replaces the legacy multiplexing chain (the Python safe bridge and a client-facing `rigctlcom`) with one Rust daemon that is the single owner of the radio link and fans it out to as many clients as the operator needs, regardless of whether a given client speaks native TS-590 CAT, a foreign Kenwood dialect (TS-2000), or the Hamlib net protocol. "Single owner of the radio link" has two forms (§7): in native mode the daemon owns the serial port directly; in bridge mode a private, daemon-owned `rigctld` owns the port and the daemon is its sole client. ## 2. Goals -- One daemon owns the real radio port. All client traffic is serialized through it. +- One daemon is the single owner of the radio link, and all client traffic is serialized through it. The link is owned either directly (a native backend holds the serial port) or as the sole client of a private `rigctld` the daemon owns (bridge mode). No other process may share that link. - Multiple client apps run simultaneously against one radio with full read and write parity. This explicitly includes a heavy native controller (ARCP-590), one or more loggers (N1MM), a foreign-dialect panadapter client (OmniRig/HDSDR), a Hamlib-net digital-mode client (WSJT-X), and the QsoRipper engine, all at once. - State changes from any client — or from the radio's front panel — propagate to all the other clients through a shared in-memory cache, so HDSDR's waterfall follows N1MM's band changes, ARCP-590 reflects a knob turn made on the physical radio, and the engine's view stays current. -- The radio path never invokes Hamlib's VFO-targeting APIs. The oscillation bug is structurally impossible. -- The daemon is radio-agnostic by construction. Adding a new transceiver model is a single new `RadioBackend` implementation with no changes to dialects, faces, or the state cache. +- **The daemon is rig-agnostic by construction.** Only the `RadioBackend` is rig-specific; the state cache, polling, event fan-out, faces, dialects, and PTT arbitration know nothing about any particular transceiver. All hub logic is **capability-driven** (`BackendCapabilities`), never branched on rig model. Adding a transceiver is adding a backend, in one of the trust tiers below, with no changes to the rest of the daemon. +- **The radio path never emits VFO-retargeting commands during polling.** This is the wire-level invariant that makes the original VFO A/B oscillation structurally impossible (see §8.8). It is stated in terms of bytes on the wire, not which library is linked, so it holds for hand-written, descriptor-driven, and (once certified) Hamlib-bridged backends alike. - The daemon is client-agnostic by construction. Adding a new client app means picking an existing CAT dialect, or writing one new `ClientDialect` implementation, or pointing the client at the Hamlib net face. -- The Hamlib net face supports the **read and write** subset that real Hamlib clients use, not reads only, so WSJT-X (set frequency, set mode, set PTT, plus `\dump_state` and `\chk_vfo` at connect time) works unmodified. -- Native rich controllers such as ARCP-590 work without the daemon having to model every CAT command. Commands the universal state does not model are forwarded transparently to the radio through the same serialized path (passthrough), with state-mutating commands additionally captured into the universal state. -- The radio's native auto-information mode (`AI2;`) is owned centrally by the daemon and fanned out to the clients that want it, so a single real-radio AI stream feeds every AI-aware client without each client polling. +- The Hamlib net face supports the **read and write** subset that modeled rig-control clients use (frequency, mode, PTT, split/VFO, plus the `\dump_state`/`\chk_vfo` handshake), validated per client against captured transcripts. This is the recommended **rig-agnostic universal tier**: it works against any backend because it is implemented entirely against the universal state and capabilities. +- Radios that can push spontaneous status updates (Kenwood `AI2;`, Icom CI-V transceive, Yaesu auto-information, Flex streaming) have that stream owned centrally by the daemon and fanned out to the clients that want it, so one real-radio event stream feeds every event-aware client without each client polling. Radios without push get the same downstream fan-out via poll-diff synthesis (see §8.4). +- Native rich controllers such as ARCP-590 work without the daemon having to model every CAT command. Commands the universal state does not model are forwarded transparently to the radio through the same serialized path (passthrough). Passthrough is a family-scoped power feature (a native dialect over a same-family backend); the rig-agnostic path for everything else is the universal tier above. - The QsoRipper engine continues to consume rig state without code change by speaking the Hamlib net protocol to a built-in server in the daemon. +- **The daemon is a universal bridge between the Hamlib world and the direct-CAT world.** Software that speaks Hamlib/`rigctld` (WSJT-X, the QsoRipper engine, most modern apps) connects to the Hamlib net face; software that expects to own a COM port and speak the rig's native CAT directly (ARCP-590, OmniRig/HDSDR, N1MM) connects to a virtual serial face speaking its expected dialect. Neither population has to change or learn about the other, and both drive the same physical radio simultaneously. Making these "just work" together is the central goal. +- **The radio backend may also be a trusted out-of-process `rigctld`.** `rigctld` is mature and, run against a rig's correct native model, drives many transceivers robustly. The daemon can therefore use a `RigctldBackend` that talks the `rigctld` net protocol *as a client* to a separate `rigctld` process (the daemon never links Hamlib; see §7.1, §8.8). This is the fast path to **breadth** — universal *modeled* control (frequency, mode, PTT, split, RIT/XIT) across the hundreds of rigs Hamlib supports — while the daemon supplies the multiplexing, caching, event fan-out, dialect translation, and PTT arbitration that bare `rigctld` does not. It is explicitly a **modeled-control bridge, not a transparent native-CAT pipe**: because `rigctld` normalizes the radio's CAT, rich native passthrough (an ARCP-590 `EX`-menu faceplate session) is *not* available through it. Full native fidelity — passthrough, native push, the no-VFO-retargeting guarantee by construction, and lowest latency — comes from a native backend (§7.1). For the TS-590, the native backend is the recommended default; `rigctld` is the breadth/bring-up path and a robustness-proven alternative. ## 3. Non-goals - Replacing OmniRig, HDSDR, N1MM, ARCP-590, WSJT-X, or any other client app. The daemon brokers them; it does not reimplement them. -- Reimplementing Hamlib in full. The daemon implements only the subset of the `rigctld` net protocol that QsoRipper, WSJT-X, and similar clients actually use. -- Modeling the full TS-590 CAT command set in the universal state. The state models the hot, cross-client fields (frequency, mode, split, RIT/XIT, S-meter, power, PTT). Everything else (the `EX` menu, filter/DSP detail, keyer memories, tuner state) flows through passthrough. -- Supporting every transceiver in v1. v1 ships the Kenwood TS-590. The Icom, Yaesu, and FlexRadio backends are designed for but not shipped in v1. +- Reimplementing Hamlib in full. The daemon implements only the subset of the `rigctld` net protocol that real clients are shown (by captured transcript) to use. The universal tier targets **modeled rig-control clients**, not literally every Hamlib feature; the supported set is a tested compatibility matrix (§10), not an open-ended "any client" promise. +- Modeling the full vendor CAT command set in the universal state. The state models the hot, cross-client fields (frequency, mode, split, RIT/XIT, S-meter, power, PTT). Everything else (the `EX` menu, filter/DSP detail, keyer memories, tuner state) flows through family-scoped passthrough. +- Shipping the data-driven descriptor backend interpreter or an **in-process** libhamlib (FFI) backend in v1. Both are deliberate roadmap items (§7.3): the descriptor language is deferred until at least two hand-written backends exist to validate its shape, and linking libhamlib is deferred behind a non-default feature flag because of native-dependency packaging cost. The **out-of-process** `RigctldBackend` (TCP client to a separate `rigctld` process, no linking) is *not* deferred — it ships in v1 as the broad-compatibility backend (§7.1, §7.2) so the daemon is immediately useful against any rigctld-supported rig. The hand-written, Hamlib-free `Ts590Backend` remains the certified first-class reference so the trust story and the core invariants are proven on at least one rig the project owns end to end. +- Supporting every transceiver in v1. v1 ships the Kenwood TS-590 as the first reference rig. The architecture is rig-agnostic; the Icom, Yaesu, and FlexRadio backends are designed for but not shipped in v1. - Remote operation across hosts. The daemon binds loopback only. Cross-host operation is a future extension (ARHP-590-style remote heads are explicitly out of scope for v1). - A GUI control panel. v1 ships configuration via TOML and observability via structured logs. @@ -72,7 +75,7 @@ N1MM, ARCP-590, and WSJT-X cannot insert themselves anywhere in this chain witho ## 5. Proposed architecture -The daemon (`qsoripper-cathub`) is one Rust binary. It owns the radio port. It exposes several client faces. Each client face is either a virtual COM port (for client apps that expect serial CAT) or a TCP server (for clients that speak the Hamlib net protocol). +The daemon (`qsoripper-cathub`) is one Rust binary. It is the single owner of the radio link — directly via a serial port (native backend) or as the sole client of a private `rigctld` (bridge backend, §7). It exposes several client faces. Each client face is either a virtual COM port (for client apps that expect serial CAT) or a TCP server (for clients that speak the Hamlib net protocol). ``` +-----------------+ @@ -97,11 +100,11 @@ On Windows the virtual serial pairs are com0com pairs. On Linux the equivalent i The crate lives at `src/rust/qsoripper-cathub/` and is added to the workspace members in `src/rust/Cargo.toml`. Internal modules: -- `radio` owns the radio transport. v1 supports serial; v2 adds TCP for FlexRadio. The module exposes an async `submit(cmd, priority) -> reply` API. All access is serialized through one tokio task. Scheduling preserves **per-face FIFO ordering** and prioritizes only between the ready heads of the per-face queues: a face's commands always reach the radio in the order that face submitted them, while across faces PTT outranks interactive writes, which outrank reads/passthrough, which outrank the background baseline poll. A read issued by a face after that face's own write is therefore guaranteed to observe its own write. This avoids reordering a single client's request stream (which serial CAT clients assume) while still keeping an operator's keying and tuning ahead of another client's bulk reads. Framing is configurable per backend: semicolon-terminated for Kenwood and Yaesu, `0xFD`-terminated for Icom CI-V. The radio task also demultiplexes **unsolicited** frames (see `ai` below) from solicited replies via an explicit frame matcher (§8.4). Reconnect on disconnect is automatic and idempotent. +- `radio` owns the radio transport. v1 supports a serial port (native backend) and a TCP connection to a private `rigctld` (bridge backend); v2 adds TCP for FlexRadio. The module exposes an async `submit(cmd, priority) -> reply` API. All access is serialized through one tokio task. Scheduling preserves **per-face FIFO ordering** and prioritizes only between the ready heads of the per-face queues: a face's commands always reach the radio in the order that face submitted them, while across faces PTT outranks interactive writes, which outrank reads/passthrough, which outrank the background baseline poll. A read issued by a face after that face's own write is therefore guaranteed to observe its own write. This avoids reordering a single client's request stream (which serial CAT clients assume) while still keeping an operator's keying and tuning ahead of another client's bulk reads. Framing is configurable per backend: semicolon-terminated for Kenwood and Yaesu, `0xFD`-terminated for Icom CI-V. The radio task also demultiplexes **spontaneous** frames (see `events` below) from solicited replies via an explicit frame matcher (§8.4). Reconnect on disconnect is automatic and idempotent. - `backend` defines the `RadioBackend` trait. Implementations live in `backend/kenwood/ts590.rs` and `backend/loopback.rs` for v1, with `backend/icom/ci_v.rs`, `backend/yaesu/ft991a.rs`, and `backend/flex/smartsdr.rs` as v2 modules. The trait is the only seam between the radio and everything else. -- `state` is the universal in-memory snapshot. It must be rich enough for split- and VFO-aware clients (Hamlib `v`/`V`/`s`/`S`, WSJT-X split, N1MM, ARCP-590), so v1 models, per VFO where applicable: per-VFO frequency (A and B), per-VFO mode and passband, the active RX VFO, the active TX VFO, split enabled plus split TX VFO/frequency, RIT and XIT enabled plus offsets, S-meter, power, and PTT owner. Each field carries `last_polled`, `last_set`, and an `ai_covered` flag (see `poller`/§8.4). Mutations from any path go through this layer. Backends populate it. Dialects read and mutate it. The Hamlib net face reads and mutates it. The `ai` module updates it from unsolicited radio frames. None of those touch the others directly. The state layer also exposes a **change-notification broadcast** (a `tokio::sync::broadcast` channel) so faces can push updates to AI-aware clients. -- `poller` is a single background task that drives a low-rate baseline poll through the active backend into `state`. The baseline cadence is 200 ms by default. AI back-off is **per field, not blanket**: only fields whose `ai_covered` flag is set (those the radio actually reports via unsolicited `AI2` frames — typically frequency, mode, split) degrade to a slow liveness/heartbeat poll while AI is active. Fields the radio does *not* push spontaneously (S-meter, power, tuner/DSP detail) keep their own polling cadence regardless of AI state. The cache TTL per field controls when client-driven reads piggyback on the next baseline cycle versus serve directly from cache. -- `dialect` defines the `ClientDialect` trait. Implementations: `dialect/kenwood/ts590.rs` for N1MM and ARCP-590 (native pass-through with state caching and unmodeled-command passthrough), `dialect/kenwood/ts2000.rs` for OmniRig (translator). Dialects only touch the universal state and the radio passthrough channel; they never call a specific backend directly. This is what guarantees any dialect serves any backend. A dialect can both answer a request *and* emit asynchronous frames to its client (see AI fan-out, §8.4). +- `state` is the universal in-memory snapshot. It must be rich enough for split- and VFO-aware clients (Hamlib `v`/`V`/`s`/`S`, WSJT-X split, N1MM, ARCP-590), so v1 models, per VFO where applicable: per-VFO frequency (A and B), per-VFO mode and passband, the active RX VFO, the active TX VFO, split enabled plus split TX VFO/frequency, RIT and XIT enabled plus offsets, S-meter, power, and PTT owner. Each field carries `last_polled`, `last_set`, and a `native_push_covered` flag (see `poller`/§8.4). Mutations from any path go through this layer. Backends populate it. Dialects read and mutate it. The Hamlib net face reads and mutates it. The `events` module updates it from spontaneous radio frames (Kenwood `AI2;`, CI-V transceive, etc.). None of those touch the others directly. The state layer also exposes a **change-notification broadcast** (a `tokio::sync::broadcast` channel) so faces can push updates to event-aware clients. +- `poller` is a single background task that drives a low-rate baseline poll through the active backend into `state`. The baseline cadence is 200 ms by default. Push back-off is **per field, not blanket**: only fields whose `native_push_covered` flag is set (those the radio actually reports via its native push stream — Kenwood `AI2;`, typically frequency, mode, split) degrade to a slow liveness/heartbeat poll while native push is active. Fields the radio does *not* push spontaneously (S-meter, power, tuner/DSP detail) keep their own polling cadence regardless. Crucially, only `RadioEventSource::NativePush` coverage sets this flag — poll-diff synthesized events (§8.4) never do — so a backend without native push (including the `RigctldBackend`) keeps polling at the baseline rate. The cache TTL per field controls when client-driven reads piggyback on the next baseline cycle versus serve directly from cache. +- `dialect` defines the `ClientDialect` trait. Implementations: `dialect/kenwood/ts590.rs` for N1MM and ARCP-590 (native pass-through with state caching and unmodeled-command passthrough), `dialect/kenwood/ts2000.rs` for OmniRig (translator). Dialects only touch the universal state and the radio passthrough channel; they never call a specific backend directly. This is what guarantees any dialect serves any backend. A dialect can both answer a request *and* emit asynchronous frames to its client (see event fan-out, §8.4). - `serial_face` is the generic virtual-COM listener. Each configured face binds one COM/PTY endpoint and routes its byte stream into a configured dialect. Faces run concurrently as independent tasks. Two faces serving the same dialect against the same backend is supported and expected (one for HDSDR, one for some other panadapter app; or N1MM and ARCP-590 both on the native TS-590 dialect). Each face owns an **outbound queue** so the daemon can push unsolicited frames (AI updates) to that client independently of request/response traffic. - `hamlib_net` is the minimal `rigctld`-compatible TCP server. It binds `127.0.0.1:4532` by default and accepts multiple simultaneous connections (WSJT-X and the engine at once). It supports the **read and write** subset that real Hamlib clients use: - reads: `f` (get freq), `m` (get mode), `v` (get vfo), `s` (get split), `t` (get ptt), and `\get_powerstat`; @@ -110,12 +113,12 @@ The crate lives at `src/rust/qsoripper-cathub/` and is added to the workspace me - raw `w`/`W` (write/read CAT) passthrough for escape hatches. Every command is implemented against the universal state and the backend capability table, so it works for any backend without changes. Response formats — the `\dump_state` capability dump, `\chk_vfo`, the `M ` shape, split replies, and the exact `RPRT ` lines — must match what real `rigctld` emits closely enough for unforgiving clients like WSJT-X; the dialect is validated against golden transcripts captured from a real `rigctld` (§10.1). Unsupported operations return the specific `RPRT` code rigctld uses (for example `RPRT -11`, "not available") rather than guessing. Because a single TCP endpoint cannot express per-client permissions and any local process could connect, write and PTT are **disabled by default** and the daemon supports more than one listener: a read-only endpoint for the QsoRipper engine and a separate write/PTT-enabled endpoint for WSJT-X. Each listener carries its own permission set (see `permissions`). The existing `RigctldProvider` in `src/rust/qsoripper-core/src/rig_control/rigctld.rs` connects to the read-only endpoint with no code change; WSJT-X connects to the write/PTT endpoint as a standard Hamlib NET rigctl rig. -- `ai` owns the radio's auto-information mode centrally. It sets `AI2;` on the real radio at startup, parses unsolicited frames the radio pushes (typically `IF;` responses and single-field updates) into universal-state mutations, and feeds the state change-notification broadcast. It also **virtualizes** the `AI` command per client: a client that sends `AI2;`/`AI1;`/`AI0;` toggles only its *own* face's push subscription; it never changes the real radio's AI state. This prevents clients from fighting over the single physical AI setting. +- `events` owns the radio's spontaneous-update (push) stream centrally. On a rig that supports it, it enables the native push mode at startup (Kenwood `AI2;`, Icom CI-V transceive, etc.), parses pushed frames (typically `IF;` responses and single-field updates) into universal-state mutations tagged `RadioEventSource::NativePush`, and feeds the state change-notification broadcast. On a rig without native push, the baseline poller diffs successive polls and feeds the same broadcast tagged `PollDiff`, so downstream fan-out is uniform. It also **virtualizes** the auto-info command per client: a client that sends `AI2;`/`AI1;`/`AI0;` toggles only its *own* face's push subscription; it never changes the real radio's push state. This prevents clients from fighting over the single physical push setting. - `passthrough` handles native CAT commands the universal state does not model (the bulk of ARCP-590's traffic: `EX` menu reads/writes, filter/DSP queries, tuner, keyer). The dialect forwards the raw command through the serialized radio task and returns the radio's reply verbatim. Reads may be served from a short-TTL cache **keyed by the full normalized command** (not just a prefix — `EX` and similar commands carry parameters that select different values), and the cache is **invalidated by command family** whenever a write in that family is forwarded. Commands whose mutability or side effects are unknown are not cached. Passthrough writes that happen to mutate a modeled field also update the universal state so other clients stay consistent. Passthrough requires that the face's dialect native command set matches the active backend's native command set (see §7 caveat); it is not portable across radio families. - `ptt` enforces PTT ownership and arbitration. Routed through the state mutation API. See §8.5. - `permissions` defines a per-face (and per-Hamlib-listener) capability set driven by a **per-dialect command classification table** that tags each command as one of: modeled read, modeled write, passthrough read, transient write, PTT/TX-affecting write, persistent/config write, or denied/unknown. The coarse face flags — `read`, `write`, `ptt`, and `config_write` — gate those classes (`config_write` gates `EX`-menu and other persistent-setting writes). Unknown passthrough writes default to denied unless the face explicitly opts into unsafe full control. This lets the operator give ARCP-590 full control while restricting a panadapter face to read-only, and prevents a stray native command from keying TX or rewriting menu settings from an under-privileged face. - `config` loads TOML from `%APPDATA%\QsoRipper\cathub.toml` on Windows and `$XDG_CONFIG_HOME/qsoripper/cathub.toml` on Linux. A `--config ` flag overrides the default. A `--dry-run` flag loads, validates, prints the resolved config, and exits without binding any ports. -- `logging` wires `tracing` with per-face spans, a rolling file appender under `%USERPROFILE%\qsoripper-cathub.log` on Windows or `$XDG_STATE_HOME/qsoripper/cathub.log` on Linux, and a periodic summary line carrying commands per second per face, cache hit ratio, real-radio reads per second, passthrough reads per second, AI frames per second, dropped/denied PTT writes, denied config writes, and reconnect events. +- `logging` wires `tracing` with per-face spans, a rolling file appender under `%USERPROFILE%\qsoripper-cathub.log` on Windows or `$XDG_STATE_HOME/qsoripper/cathub.log` on Linux, and a periodic summary line carrying commands per second per face, cache hit ratio, real-radio reads per second, passthrough reads per second, native-push frames per second, dropped/denied PTT writes, denied config writes, and reconnect events. - `main` wires everything, installs Ctrl+C and SIGTERM handlers that emit `RX;` on shutdown to avoid a stuck transmitter, and exits with a non-zero code on fatal initialization failures. ## 7. Multi-radio model @@ -127,14 +130,18 @@ The `RadioBackend` trait is the radio-side abstraction. Conceptual shape: pub trait RadioBackend: Send + Sync { async fn poll(&self, state: &StateHandle) -> Result<(), BackendError>; async fn apply(&self, mutation: StateMutation, state: &StateHandle) -> Result<(), BackendError>; - /// Parse an unsolicited frame pushed by the radio (AI mode) into a state mutation, if recognized. - fn parse_unsolicited(&self, frame: &[u8]) -> Option; - /// Forward an opaque native command and return the raw reply (passthrough). + /// Parse a spontaneous frame the radio pushed (Kenwood AI, CI-V transceive, Yaesu + /// auto-info, Flex stream) into a state mutation, if recognized. Backends without a + /// native push source return None and rely on poll-diff synthesis (§8.4). + fn parse_event(&self, frame: &[u8]) -> Option; + /// Forward an opaque native command and return the raw reply (family-scoped passthrough). async fn passthrough(&self, raw: &[u8]) -> Result, BackendError>; fn capabilities(&self) -> BackendCapabilities; } ``` +`BackendCapabilities` is the single source of rig-specific truth the rest of the daemon consults: VFO count and sub-receiver presence, supported modes and split style, RIT/XIT and meter availability, frequency ranges, native-push support and its per-field coverage, native command family (for passthrough compatibility), and the trust tier (§7.1). No hub module branches on rig model; it branches on capabilities. The capability table is also what `\dump_state` reports to Hamlib clients. A backend with no XIT reports `xit: false`, and the Hamlib net face returns `RPRT -11` for XIT commands against that backend without ever touching the wire. + `StateMutation` is a universal enum: ```rust @@ -148,7 +155,7 @@ pub enum StateMutation { } ``` -`BackendCapabilities` describes what the backend can do. A backend with no XIT reports `xit: false`, and the Hamlib net face returns `RPRT -11` for XIT commands against that backend without ever touching the wire. The capability table is also what `\dump_state` reports to Hamlib clients. +`BackendCapabilities` (above) is the rig-specific seam; nothing else in the daemon is. The `ClientDialect` trait is the client-side abstraction: @@ -157,28 +164,59 @@ The `ClientDialect` trait is the client-side abstraction: pub trait ClientDialect: Send + Sync { /// Handle one inbound request: read state, emit a mutation, or passthrough; return reply bytes. async fn handle(&self, request: &[u8], ctx: &FaceContext) -> Vec; - /// Format a state-change notification as an unsolicited frame for this client (AI fan-out), + /// Format a state-change notification as an unsolicited frame for this client (event fan-out), /// or return None if this dialect/client does not want pushes for this change. fn format_notification(&self, change: &StateChange, ctx: &FaceContext) -> Option>; } ``` -`FaceContext` carries the face's permission set, its per-face AI subscription state, and handles to the universal state and the radio passthrough channel. Because dialects only touch the universal state and passthrough, any dialect can run on top of any backend **for modeled commands**. A TS-2000 dialect served by an Icom backend answers modeled reads/writes exactly as well as one served by a Kenwood backend, because both backends keep the universal state populated. **Native passthrough is the exception:** it forwards radio-family-specific bytes, so a native TS-590 dialect (N1MM, ARCP-590) only fully works over a Kenwood backend. Pairing a native dialect with a foreign backend must either fail config validation or run in modeled-only mode with passthrough disabled — the daemon must not promise ARCP-590 against a non-Kenwood radio. +`FaceContext` carries the face's permission set, its per-face event subscription state, and handles to the universal state and the radio passthrough channel. There are two tiers of client compatibility, and the distinction is what makes the daemon rig-agnostic: + +- **Universal tier (rig-agnostic).** The Hamlib net face and any dialect's *modeled* commands are served entirely from the universal state and capabilities. They work against any backend. A TS-2000 modeled read served by an Icom backend answers exactly as well as one served by a Kenwood backend. This is the recommended path for the broad population of clients. +- **Native-dialect tier (family-scoped).** Raw passthrough forwards radio-family-specific bytes, so a native TS-590 dialect (N1MM, ARCP-590) only fully works over a Kenwood backend. Passthrough **fails closed**: pairing a native dialect with a non-matching backend family must fail config validation, or run in modeled-only mode with passthrough disabled. The daemon never promises ARCP-590 against a non-Kenwood radio. + +### 7.1 Backend strategies and trust tiers + +A backend is any implementation of `RadioBackend`. There are four ways to produce one, all interchangeable from the daemon's point of view, each declaring a **trust tier** in its capabilities: + +- **First-class native backend** (e.g. `Ts590Backend`): hand-written Rust, no Hamlib dependency, wire-trace certified to honor the no-VFO-retargeting-on-poll invariant (§8.8), and covered by transcript tests. This is the bug-proof reference for rigs the project supports directly. Command metadata (framing, per-field get/set templates, reply behavior, mode/band value maps, push command and parse rules, side-effect class) is held in **tables** rather than scattered through code, so it is straightforward to later serialize into a descriptor. +- **Out-of-process `rigctld` backend** (`RigctldBackend`, first-class for breadth, v1): the daemon connects to a separately launched, daemon-private `rigctld` over TCP and speaks the `rigctld` net protocol **as a client**. The daemon never links Hamlib, so this carries no native-linking packaging cost and cannot reintroduce in-process Hamlib state bugs. It gives immediate **modeled-control** breadth across every rig Hamlib supports, using each rig's **correct native Hamlib model** (notably *not* the TS-2000 model on a TS-590). It is the recommended way to bring up a rig that has no native backend yet, and a robustness-proven alternative on rigs that do. Two deliberate limits: (1) it is a *modeled-control* bridge — `rigctld` normalizes the native CAT away, so family-scoped native passthrough (ARCP-590 `EX` menu) is **not** available through it, and it should report `native_command_family: None` / no raw-passthrough capability; (2) it is **uncertified by default** for the no-VFO-retargeting invariant, because the daemon does not control `rigctld`'s radio-side traffic. A specific `rigctld` version + model + configuration is promoted to *certified* only after the §10.3 soak observes the **`rigctld`-to-radio wire** (not just daemon-to-`rigctld`) and finds no VFO-retargeting. Until then the pairing runs behind an explicit opt-in and does not advertise the guarantee in `\dump_state` trust metadata. +- **Descriptor backend** (roadmap, §7.3): one generic interpreter driven by a declarative descriptor (TOML/RON) carrying the same table data, so adding a same-family rig is data plus tests rather than code. This is the long-term path to native fidelity (passthrough, push, wire-level control) *without* per-rig code, where `rigctld` gives breadth without native fidelity. There is strong prior art for the format: OmniRig's per-rig `.ini` files describe each transceiver's CAT command set and status-polling/parse masks declaratively (see the RigIni format reference in §16), and Hamlib encodes equivalent per-rig knowledge in its backends. The descriptor language should be researched against OmniRig's `.ini` model as a starting point — while deliberately *not* copying its polling behavior, because OmniRig's own status polling (like Hamlib's TS-2000 backend) is a source of the VFO-switching issue this design exists to prevent. A descriptor is only "supported" when its test suite passes, including a certification that its poll command list emits no VFO-target traffic. Deferred until at least two native backends exist to prove the descriptor language must express enough (reply matching, event demux, no-reply/verify semantics, side-effect classification, passthrough invalidation, VFO semantics, per-field poll/push coverage). +- **In-process libhamlib (FFI) backend** (roadmap, §7.3, non-default `hamlib-ffi` feature): links libhamlib directly. Functionally similar reach to the out-of-process bridge but with native packaging cost (DLL/SO discovery, ABI drift, cross-build, licensing) and the in-process state-bug surface the out-of-process bridge avoids, so it is deferred and, when built, CI-gated on both Windows and Linux. First-class native backends remain Hamlib-free regardless. + +The out-of-process `rigctld` backend and the first-class native backend together cover the two realities: a hand-certified, dependency-free, full-fidelity driver for the primary rig, and a trusted breadth bridge to everything else Hamlib already drives well. The hub's durable value — single-owner serialization, coalesced polling, event fan-out, PTT arbitration, and dialect translation — is identical regardless of which strategy produced the backend. + +### 7.1.1 Which backend is best, and why the trait is the real abstraction + +The deliberate answer is *no single backend implementation is best for every rig* — which is exactly why the abstraction lives in the `RadioBackend` trait and `BackendCapabilities`, not in any one strategy. Backends trade along two axes: + +- **Fidelity** — exact bytes on the wire (so the no-VFO-retargeting invariant holds *by construction*), native push (AI2 / CI-V transceive) as a true event stream, transparent native-CAT passthrough for rich faceplate apps (ARCP-590's `EX` menu, keyer, tuner), and the lowest latency (no extra process or TCP hop). +- **Breadth and effort** — how many rigs are covered for how much engineering. + +A **hand-written native backend maximizes fidelity** and is therefore the **recommended default for the TS-590 and any rig the project supports richly**. It is the only strategy that delivers ARCP-590 faceplate passthrough, native AI2 push, the invariant by construction, and minimal latency — i.e. the fast, efficient TS-590 control this design targets. Its cost is per-rig engineering. + +The **`rigctld` bridge maximizes breadth** at near-zero per-rig cost by reusing Hamlib's mature drivers, but it is modeled-control-only and cannot guarantee the invariant or carry native passthrough (above). It is the best choice for bringing a *new* rig up quickly and as a robustness-proven alternative, not as the TS-590 default. + +The **descriptor backend** is the intended way to eventually get native fidelity *and* breadth together — declarative per-rig data over one interpreter — which is why it, not the `rigctld` bridge, is the long-term scaling target. The `rigctld` bridge remains valuable as the zero-effort fallback for the long tail of rigs no one has described yet. + +So the recommended composition is: native backend for the TS-590 (and each rig we invest in), `rigctld` bridge for immediate breadth and as a trusted alternative, descriptor backend as the scaling endgame, and in-process FFI only as a last resort. Picking the right strategy per rig is a capability/config choice; the rest of the daemon never changes. -### 7.1 v1 scope +### 7.2 v1 scope -- Backends: `Ts590Backend` and `LoopbackBackend`. The Kenwood code is factored so the Kenwood command table is the only thing that changes between models. `Ts2000Backend`, `Ts480Backend`, `Ts890Backend`, and friends drop in by replacing the table. -- Dialects: `Ts590Dialect` (native pass-through with state caching, AI fan-out, and unmodeled-command passthrough — serves both N1MM and ARCP-590) and `Ts2000Dialect` (for OmniRig, translator that answers `IF;`, `FA;`, `FB;` from the universal state and rejects Hamlib-style VFO-target writes). +- Backends: `Ts590Backend` (first-class native, the recommended default for the reference rig), `RigctldBackend` (out-of-process bridge for modeled control of any other rigctld-supported rig), and `LoopbackBackend`. The Kenwood code is table-driven so `Ts2000Backend`, `Ts480Backend`, `Ts890Backend`, and friends drop in by replacing the table; those tables are the future descriptor data. +- Dialects: `Ts590Dialect` (native pass-through with state caching, event fan-out, and family-scoped passthrough — serves both N1MM and ARCP-590) and `Ts2000Dialect` (for OmniRig, translator that answers `IF;`, `FA;`, `FB;` from the universal state and rejects Hamlib-style VFO-target writes). - Faces: native serial faces for N1MM and ARCP-590, a TS-2000 serial face for OmniRig, and the Hamlib net face for WSJT-X and the engine. -### 7.2 v2 roadmap +### 7.3 Roadmap -- `IcomCiVBackend` for IC-7300, IC-7610, and IC-705. Different framing (`0xFE 0xFE` preamble, sub-address byte, `0xFD` end-of-message) but the same universal state. Icom transceive mode is the CI-V analogue of Kenwood AI and feeds the same `parse_unsolicited` path. +- `IcomCiVBackend` for IC-7300, IC-7610, and IC-705. Different framing (`0xFE 0xFE` preamble, sub-address byte, `0xFD` end-of-message) but the same universal state. Icom transceive mode is the CI-V analogue of Kenwood AI and feeds the same `parse_event` path. - `YaesuFt991aBackend` and similar Yaesu models. Kenwood-like ASCII with Yaesu-specific commands. -- `FlexSmartSdrBackend` over the native FlexRadio TCP API. No serial port involved on the radio side. +- `FlexSmartSdrBackend` over the native FlexRadio TCP API. No serial port involved on the radio side; its status stream feeds `parse_event`. +- The **descriptor backend** interpreter, once the second native backend has clarified the descriptor language. +- The **in-process libhamlib (FFI) backend** (non-default `hamlib-ffi` feature), only if a deployment cannot run a separate `rigctld` process and the out-of-process `RigctldBackend` is therefore unavailable. - `IcomCiVDialect` for client apps that prefer to speak CI-V. -Each entry in the v2 roadmap is one new trait impl. None of them changes any existing module. +Each native/descriptor entry is one new backend (or descriptor file). None of them changes any existing module; the hub stays rig-agnostic. ## 8. Behavior contracts @@ -194,29 +232,40 @@ A client-driven status read of a **modeled** field is answered from `state` when Kenwood set commands do not all return an acknowledgment — many are "set and no reply." The daemon therefore classifies each modeled command's reply behavior in the backend command table as one of: -- **no-reply write:** state is updated optimistically after the serial write succeeds; correctness is reconciled by the next `AI2` echo (for `ai_covered` fields) or a verify-read for fields that are not AI-covered; +- **no-reply write:** state is updated optimistically after the serial write succeeds; correctness is reconciled by the next native-push echo (for `native_push_covered` fields) or a verify-read for fields that are not push-covered; - **write with reply:** the daemon waits for and parses the reply before updating state and acking the client; - **read:** the daemon waits for the matching solicited reply (frame matcher, §8.4). For "write with reply" commands, subsequent reads from any face see the new value immediately after the ack. For "no-reply" commands the new value is visible after the optimistic update, then confirmed (and corrected if the radio rejected it) by the echo/verify path. Either way, HDSDR's waterfall follows N1MM's band change without HDSDR ever talking to N1MM directly. The atomicity guarantee is "ordered and eventually reconciled," not "blocked on an ack the radio never sends." -### 8.4 Auto-information (AI) fan-out +### 8.4 Event (spontaneous update) fan-out -This is the mechanism that lets a knob turn on the physical radio, a band change in N1MM, a frequency set from WSJT-X, and a VFO change in ARCP-590 all stay mutually consistent at low cost. +This is the mechanism that lets a knob turn on the physical radio, a band change in N1MM, a frequency set from WSJT-X, and a VFO change in ARCP-590 all stay mutually consistent at low cost. It is **rig-agnostic**: the Kenwood `AI2;` auto-information stream is one concrete event source; Icom CI-V transceive, Yaesu auto-information, and the Flex status stream are others; and rigs with no push at all are handled by poll-diff synthesis. All of them normalize to one internal stream of state mutations, each tagged with a source: -**Frame demultiplexing contract.** With `AI2` enabled the radio emits unsolicited semicolon-terminated frames that can be byte-identical to solicited replies (notably `IF...;`). The radio task disambiguates with an explicit matcher: each outbound command declares its expected reply verb(s) and whether it expects a reply at all. When a frame arrives, if a command is pending whose expected verb matches, the frame completes that command's oneshot; otherwise the frame is routed to the `ai` parser. Commands that expect no reply (no-reply writes, §8.3) never capture an incoming frame. This must be tested against the hard interleavings: a pending `IF;` read arriving concurrently with an unsolicited `IF` push, a no-reply write immediately followed by its `AI2` echo, and passthrough reads arriving while AI frames stream. +```rust +pub enum RadioEventSource { + NativePush, // AI2 / CI-V transceive / Yaesu auto-info / Flex stream + PollDiff, // synthesized by the poller diffing successive polls + OptimisticWrite, // a client write we applied before reconciliation + VerifyRead, // a confirming read after a no-reply write +} +``` + +Downstream fan-out is identical regardless of source, so a station with a non-push rig behaves the same as one with `AI2`. The crucial distinction is that **only `NativePush` events count as evidence the radio provides spontaneous updates**: poll back-off (`poller`) and any field's `native_push_covered`/freshness flag are driven by `NativePush` coverage alone. Poll-diff events give downstream uniformity but never cause the poller to slow down or claim freshness it does not have. -- The `ai` module sets `AI2;` on the real radio once, at startup, and owns that setting for the daemon's lifetime. -- Unsolicited frames the radio pushes are parsed by the backend's `parse_unsolicited` into `StateMutation`s, applied to `state`, and emitted on the state change-notification broadcast. -- Each face subscribes to the broadcast. For each change, the face's dialect decides via `format_notification` whether and how to push it to its client. A native TS-590 client with its virtual AI enabled receives a synthesized `IF;`/field frame; a TS-2000 client receives the TS-2000-equivalent frame; a client with AI disabled receives nothing and continues to poll. -- The `AI` command from any client is **virtualized**: it toggles only that face's subscription, never the real radio's AI state. This removes the classic multi-client failure where one app sends `AI0;` and silences the auto-info another app depends on. -- Because the daemon, not each client, owns the real AI stream, one physical AI feed serves every AI-aware client and the baseline poll can back off (`poller`), cutting real-radio traffic. +**Frame demultiplexing contract.** When a native push source is enabled the radio emits frames that can be byte-identical to solicited replies (Kenwood `IF...;` is the classic case). The radio task disambiguates with an explicit matcher: each outbound command declares its expected reply verb(s) and whether it expects a reply at all. When a frame arrives, if a command is pending whose expected verb matches, the frame completes that command's oneshot; otherwise the frame is routed to the backend's `parse_event`. Commands that expect no reply (no-reply writes, §8.3) never capture an incoming frame. This must be tested against the hard interleavings: a pending `IF;` read arriving concurrently with an unsolicited `IF` push, a no-reply write immediately followed by its push echo, and passthrough reads arriving while push frames stream. -**Push ordering and backpressure.** Each face's outbound push queue is bounded. Pushes are interleaved with that face's solicited replies at frame boundaries (never mid-frame). If a slow client lets its queue fill, the daemon **coalesces** superseded updates (keeping only the latest value per field) rather than blocking the shared broadcast or growing unboundedly; a sustained overflow is logged. AI fan-out must never apply backpressure to the radio task or to other faces. +- The `events` module owns the radio's native push setting centrally (for Kenwood it sets `AI2;` once at startup; for Icom it enables transceive; etc.) and owns it for the daemon's lifetime. +- Native frames the radio pushes are parsed by the backend's `parse_event` into `StateMutation`s, tagged `NativePush`, applied to `state`, and emitted on the state change-notification broadcast. Where no native push exists, the poller emits the same broadcast tagged `PollDiff`. +- Each face subscribes to the broadcast. For each change, the face's dialect decides via `format_notification` whether and how to push it to its client. A native TS-590 client with its virtual auto-info enabled receives a synthesized `IF;`/field frame; a TS-2000 client receives the TS-2000-equivalent frame; a client with auto-info disabled receives nothing and continues to poll. +- The auto-info command from any client (Kenwood `AI`, etc.) is **virtualized**: it toggles only that face's subscription, never the real radio's push state. This removes the classic multi-client failure where one app disables auto-info and silences the stream another app depends on. +- Because the daemon, not each client, owns the real push stream, one physical event feed serves every event-aware client and the baseline poll can back off (for `NativePush`-covered fields only), cutting real-radio traffic. -### 8.4.1 AI mode granularity +**Push ordering and backpressure.** Each face's outbound push queue is bounded. Pushes are interleaved with that face's solicited replies at frame boundaries (never mid-frame). If a slow client lets its queue fill, the daemon **coalesces** superseded updates (keeping only the latest value per field) rather than blocking the shared broadcast or growing unboundedly; a sustained overflow is logged. Event fan-out must never apply backpressure to the radio task or to other faces. -The `AI` command is virtualized per face, but `AI1` (post-command echo) and `AI2` (spontaneous updates) are not identical semantics. v1 may collapse both to a single per-face push subscription; if it does, that limitation is documented and ARCP-590 and N1MM are tested specifically to confirm they tolerate the collapse before either is claimed as first-class. If a tested client depends on the `AI1`/`AI2` distinction, the finer three-state (`AI0`/`AI1`/`AI2`) model is implemented for that dialect. +### 8.4.1 Auto-info mode granularity + +The auto-info command is virtualized per face, but for Kenwood `AI1` (post-command echo) and `AI2` (spontaneous updates) are not identical semantics (other vendors have analogous distinctions). v1 may collapse them to a single per-face push subscription; if it does, that limitation is documented and ARCP-590 and N1MM are tested specifically to confirm they tolerate the collapse before either is claimed as first-class. If a tested client depends on the distinction, the finer model is implemented for that dialect. ### 8.5 PTT ownership and arbitration @@ -233,18 +282,25 @@ In a normal station the operator drives one mode at a time, so the lease is almo ARCP-590 (and any future faceplate-class controller) issues many commands the universal state does not model. The contract: -- A command whose normalized form maps to a modeled field is handled through the state path (cached read or atomic write) so it participates in coalescing, AI fan-out, and cross-client consistency. +- A command whose normalized form maps to a modeled field is handled through the state path (cached read or atomic write) so it participates in coalescing, event fan-out, and cross-client consistency. - Any other command is forwarded verbatim through the serialized radio task and its raw reply is returned to the client unchanged. Reads may be served from a short-TTL cache keyed by the **full normalized command** (parameters included) and invalidated **by command family** when a write in that family is forwarded; commands with unknown side effects are not cached. - Passthrough is gated by the command classification table and face permissions (§6 `permissions`): a face without `config_write` cannot push `EX`-menu or other persistent-setting writes, and unknown passthrough writes are denied by default; such attempts are logged. - Passthrough never bypasses serialization. It is just another priority-classified entry in the radio task's inbox. ### 8.7 Graceful recovery -If the radio transport disappears (USB unplugged, radio powered off), the daemon keeps the client faces open and answers modeled status reads from `state` with a `stale` flag. State mutations and passthrough writes from clients return a non-fatal error. The radio task retries the transport with backoff. On reconnect, the daemon re-asserts `AI2;`, the baseline poller resumes, and the stale flag clears. +If the radio transport disappears (USB unplugged, radio powered off), the daemon keeps the client faces open and answers modeled status reads from `state` with a `stale` flag. State mutations and passthrough writes from clients return a non-fatal error. The radio task retries the transport with backoff. On reconnect, the daemon re-asserts the native push setting (Kenwood `AI2;`, Icom transceive, etc.), the baseline poller resumes, and the stale flag clears. + +### 8.8 The no-VFO-retargeting invariant + +The structural guarantee against the original bug is stated at the **wire level**, not in terms of which library is linked: *baseline polling and modeled reads must never emit radio-side VFO-selection or VFO-retargeting commands; such commands are sent only for an explicit user/client VFO or split mutation.* This is what makes the TS-590 VFO A/B oscillation impossible, and it is the certification target for every backend (§10.3). -### 8.8 No Hamlib in the radio path +- The daemon **never links Hamlib** in any first-class path. The Hamlib net face is a thin server-side reimplementation of the wire protocol, not Hamlib code, and the out-of-process `RigctldBackend` is a TCP *client* of a separate `rigctld` process — neither links libhamlib. +- The first-class native `Ts590Backend` satisfies the invariant by construction: it issues only TS-590 native commands and never the TS-2000-style VFO-target writes that triggered the oscillation. Its poll command list is asserted in tests to contain no VFO-target verbs. +- The out-of-process `RigctldBackend` is **uncertified by default**. It satisfies the invariant only when `rigctld` runs against the rig's **correct native model** (e.g. the TS-590 model, never the TS-2000 model), and even then the daemon cannot prove it because it does not control `rigctld`'s radio-side traffic. A specific `rigctld` **version + model + configuration** is promoted to *certified* only after the §10.3 soak observes the **`rigctld`-to-radio wire** (a serial-line capture, not just the daemon-to-`rigctld` TCP traffic) and finds no VFO-retargeting under the full client compatibility matrix. Until certified, the pairing requires explicit operator opt-in and does not advertise the no-VFO guarantee in logs or `\dump_state` trust metadata. The bridge also reports no native push (it relies on poll-diff events, §8.4) unless a tested net-protocol push path is added. +- The optional in-process libhamlib (FFI) backend carries the same per-rig certification requirement and the additional in-process state-bug surface, which is exactly why the out-of-process bridge is preferred and FFI is deferred (§7.1). -The daemon never links Hamlib. The Hamlib net protocol server is a thin server-side reimplementation of the wire protocol; it is not Hamlib code. This is the structural guarantee that the original VFO-targeting bug cannot return through any path, including the WSJT-X path, because the daemon's own `Ts590Backend` issues only TS-590 native commands and never the TS-2000-style VFO-target writes that triggered the oscillation. +The invariant is enforced by a per-backend certification soak, not by a blanket "no Hamlib anywhere" rule, so the design can honor both a dependency-free certified driver and a trusted external `rigctld` without weakening the guarantee. ## 9. Configuration @@ -258,13 +314,13 @@ port = "COM3" baud = 115200 [poll] -baseline_ms = 200 # active when AI is unavailable -ai_heartbeat_ms = 2000 # slow liveness poll while AI2 is pushing updates +baseline_ms = 200 # active when native push is unavailable +heartbeat_ms = 2000 # slow liveness poll while native push covers a field freq_ttl_ms = 50 mode_ttl_ms = 200 -[ai] -enabled = true # daemon sets AI2; on the real radio and owns it +[events] +native_push = true # daemon enables the rig's push stream (AI2 on TS-590) and owns it # Read-only Hamlib endpoint for the QsoRipper engine. [[hamlib_net]] @@ -313,7 +369,22 @@ baud = 115200 ci_v_address = 0x94 ``` -Faces, polling, AI, and the Hamlib net face are unchanged for modeled commands. Note that a native TS-590 dialect's passthrough does *not* carry over to an Icom backend (§7): an Icom station would drive the radio through the Hamlib net face and the TS-2000/CI-V dialects for modeled state, and a native Kenwood faceplate app like ARCP-590 is simply not applicable to a non-Kenwood radio. The universal state model, AI fan-out, and modeled reads/writes remain backend-independent. +Faces, polling, events, and the Hamlib net face are unchanged for modeled commands. Note that a native TS-590 dialect's passthrough does *not* carry over to an Icom backend (§7): an Icom station would drive the radio through the Hamlib net face and the TS-2000/CI-V dialects for modeled state, and a native Kenwood faceplate app like ARCP-590 is simply not applicable to a non-Kenwood radio. The universal state model, event fan-out, and modeled reads/writes remain backend-independent. + +To drive a rig through a trusted out-of-process `rigctld` instead of a native backend, the operator points the radio at the bridge. The daemon owns the faces, cache, event fan-out, and arbitration; a **daemon-private** `rigctld` owns the physical port. That `rigctld` must accept the daemon as its **sole client** — no other app may connect to it directly, or it would bypass the daemon's serialization, PTT lease, and cache. The Hamlib model `rigctld` is launched with is the per-rig safety knob (§8.8): + +```toml +[radio] +backend = "rigctld" # out-of-process bridge; the daemon never links Hamlib +transport = "tcp" +host = "127.0.0.1" +port = 4532 # the daemon-private rigctld (no other client may connect) +# rigctld itself is started against the rig's correct native model, e.g. +# rigctld -m 2045 -r COM3 -s 115200 (TS-590SG native model) +# uncertified by default; promoted per rigctld version+model+config via the §10.3 soak. +``` + +The rest of the config — faces, permissions, polling, events — is identical for the **modeled** control surface, which is the point: a direct-CAT app (OmniRig, N1MM) gets the same frequency/mode/PTT/split behavior over its virtual COM face whether the backend is native or the `rigctld` bridge. The bridge does *not* carry native faceplate passthrough, so an `EX`-menu controller like ARCP-590 still requires a native same-family backend (§7.1). ## 10. Validation strategy @@ -321,25 +392,28 @@ Faces, polling, AI, and the Hamlib net face are unchanged for modeled commands. - `state` cache TTL, mutation, staleness, concurrent reader behavior, and change-notification broadcast delivery. - `dialect/kenwood/ts590.rs` and `dialect/kenwood/ts2000.rs` round-trips against recorded TS-590 transcripts, including ARCP-590 `EX`-menu passthrough transcripts. -- `ai` parsing of recorded unsolicited `IF;` frames into mutations, and per-face AI virtualization (a client `AI0;` must not change the real radio's AI state). +- `events` parsing of recorded spontaneous frames (Kenwood unsolicited `IF;`, and an Icom CI-V transceive sample) into mutations tagged `NativePush`, and per-face event virtualization (a client `AI0;` must not change the real radio's auto-info state). A poll-diff test confirms synthesized events are tagged `PollDiff` and never trigger poller back-off or set `native_push_covered`. - `permissions` enforcement: write/ptt/config_write denials for under-privileged faces. - `hamlib_net` read and write command coverage validated against **golden transcripts captured from a real `rigctld`** (NET rigctl), including the `\dump_state` capability dump, `\chk_vfo`, `F`, `M `, `T`, `S`, `V`, simple vs extended response mode, and the exact `RPRT ` lines for unsupported operations. Per-endpoint permission enforcement (read-only endpoint rejects `F`/`M`/`T`). -- `backend/kenwood/ts590.rs` command table and `parse_unsolicited`/`passthrough` coverage against recorded byte sequences. +- `backend/kenwood/ts590.rs` command table and `parse_event`/`passthrough` coverage against recorded byte sequences. A static assertion confirms the backend's poll command list contains no VFO-target verbs (the no-VFO-retargeting invariant, §8.8). +- `backend/rigctld.rs` (out-of-process bridge) protocol-client coverage against recorded `rigctld` net-protocol transcripts: read/set frequency, mode, PTT, split/VFO, and the `RPRT` error mapping into `BackendError`. A test confirms the bridge surfaces an uncertified rig+model pairing in its reported trust tier. - `backend/loopback.rs` exposes a deterministic implementation used by every integration test. ### 10.2 Integration tests -- Virtual serial pairs via `tokio::io::duplex` simulate the OmniRig, N1MM, and ARCP-590 ports; a TCP client simulates WSJT-X and the engine on the Hamlib net face. A `LoopbackBackend` records every mutation and passthrough. Assertions: no modeled-field client poll causes more than one backend command per TTL window; no TS-2000 status poll ever causes a `FR0`/`FR1` to be sent; writes from one face are visible to reads on every other face; a simulated front-panel change (unsolicited frame from the loopback backend) propagates to all AI-subscribed faces. +- Virtual serial pairs via `tokio::io::duplex` simulate the OmniRig, N1MM, and ARCP-590 ports; a TCP client simulates WSJT-X and the engine on the Hamlib net face. A `LoopbackBackend` records every mutation and passthrough. Assertions: no modeled-field client poll causes more than one backend command per TTL window; no TS-2000 status poll ever causes a `FR0`/`FR1` to be sent; writes from one face are visible to reads on every other face; a simulated front-panel change (unsolicited frame from the loopback backend) propagates to all event-subscribed faces. - The existing `RigctldProvider` from `src/rust/qsoripper-core/src/rig_control/rigctld.rs` is pointed at the daemon's read-only Hamlib endpoint and consumes state without code changes. A separate simulated WSJT-X client on the write/PTT endpoint sets frequency/mode and keys PTT concurrently with the engine reading state, and a test confirms the read-only endpoint rejects `F`/`M`/`T`. - A PTT arbitration test confirms the first capable face acquires the lease, a second capable face is rejected while the lease is held, the lease releases on `RX;`/`T 0` and on the `ptt_max_tx_ms` safety ceiling (and that a CAT-idle but actively-transmitting client is *not* unkeyed before that ceiling), and PTT from a face lacking the `ptt` capability is dropped with a warning. A shutdown test confirms `RX;` is emitted on Ctrl+C and SIGTERM, and that `ptt_max_tx_ms` bounds a simulated crash that skips the shutdown write. -- A frame-demux test drives the matcher through a pending `IF;` read arriving with a concurrent unsolicited `IF` push, a no-reply write followed by its `AI2` echo, and passthrough reads interleaved with AI frames, asserting each oneshot completes against the correct frame and no push is lost. +- A frame-demux test drives the matcher through a pending `IF;` read arriving with a concurrent unsolicited `IF` push, a no-reply write followed by its `AI2` echo, and passthrough reads interleaved with push frames, asserting each oneshot completes against the correct frame and no push is lost. - A passthrough test confirms an ARCP-590-style `EX`-menu read is forwarded verbatim and that a `config_write` denial is enforced on a face without that permission. +- A `RigctldBackend` integration test runs the daemon in front of a stub `rigctld` server (an in-process TCP listener replaying recorded net-protocol exchanges) and confirms every face — the OmniRig TS-2000 face, the N1MM native face, and the Hamlib net face — performs **modeled** reads and writes (frequency, mode, PTT, split) against the same radio through the bridge, so a direct-CAT app is demonstrably bridged to a rigctld-driven rig for modeled control. A companion test asserts the bridge declares no native-passthrough capability, so a native `EX`-menu passthrough attempt (ARCP-590 style) fails closed rather than being silently forwarded. +- A **client compatibility matrix** is maintained as a table of captured per-client transcripts (ARCP-590, N1MM, OmniRig/HDSDR, WSJT-X, the QsoRipper engine), each replayed against the daemon in CI. A client is "supported" only when its transcript passes; the matrix, not an open-ended "any client" claim, defines the supported set (§3). ### 10.3 Live bench -- Reproduce the VFO B scenario from the existing report. The daemon log must show zero `FR0`/`FR1` traffic and no front-panel flicker across a 30-minute soak. +- **Per-backend VFO-retargeting certification soak (§8.8).** For each backend a station will use, certify the no-VFO-retargeting invariant against the **radio-side wire** (a serial-line capture: the daemon's own port for the native backend, the `rigctld`-to-radio port for the bridge — never just the daemon-to-`rigctld` TCP hop). Reproduce the VFO B scenario from the existing report *and* replay the full client compatibility matrix (ARCP-590, N1MM, OmniRig, WSJT-X, engine) so retargeting triggered by modeled reads, split/VFO queries, or `\chk_vfo` paths is caught, not only the idle baseline poll. The capture must show zero `FR0`/`FR1` (VFO-target) traffic and no front-panel flicker across a 30-minute soak. A specific backend + (for the bridge) `rigctld` version + model + configuration that fails is not marked certified, regardless of trust tier. This soak is the gate that promotes a `rigctld`-driven rig from "uncertified bridge" to "certified." - Run N1MM, HDSDR (via OmniRig), ARCP-590, WSJT-X, and the QsoRipper engine simultaneously. Exercise band changes, mode changes, RIT, and CAT PTT from N1MM; full faceplate control and `EX`-menu access from ARCP-590; frequency/mode/PTT from WSJT-X. Turn the VFO knob on the physical radio. Confirm every client reflects the change, that `dotnet run --project src\dotnet\QsoRipper.Cli -- status` reports the same state, and that only one transmitter key is ever active. -- AI fan-out: with `AI2;` owned by the daemon, confirm a physical-knob frequency change reaches every AI-subscribed client without any client having polled, and that only `ai_covered` fields back off to the heartbeat rate while meter/power keep their own cadence. +- Event fan-out: with the native push stream (Kenwood `AI2;`) owned by the daemon, confirm a physical-knob frequency change reaches every event-subscribed client without any client having polled, and that only `native_push_covered` fields back off to the heartbeat rate while meter/power keep their own cadence. Repeat against the `RigctldBackend` to confirm poll-diff synthesized events fan out identically and that no field is marked `native_push_covered` (the bridge keeps polling at the baseline rate). - Serial line behavior: confirm ARCP-590, N1MM, and OmniRig open their virtual ports with the configured parity/stop/data bits and DTR/RTS handling, and that a mismatch surfaces a clear error rather than silent garbled CAT. - Stress test: poll at 50 ms from all faces simultaneously while ARCP-590 streams `EX`-menu reads. Modeled-field real-radio command rate should stay near the baseline poll rate, not the sum of client poll rates; interactive writes (PTT, frequency set) must stay responsive (priority scheduling) even under the passthrough load. @@ -347,13 +421,13 @@ Faces, polling, AI, and the Hamlib net face are unchanged for modeled commands. The crate lands in phases so each phase is independently testable and useful. -Phase 1 brings up the skeleton, the radio task with the priority inbox, the `LoopbackBackend`, the universal state with change-notification broadcast, the `Ts590Backend`, and the `Ts590Dialect` with passthrough. The N1MM face works end-to-end against a real TS-590. The OmniRig face, ARCP-590 face, AI fan-out, and the Hamlib net face are not yet wired. +Phase 1 brings up the skeleton, the radio task with the priority inbox, the `LoopbackBackend`, the universal state with change-notification broadcast, the `Ts590Backend`, and the `Ts590Dialect` with passthrough. The N1MM face works end-to-end against a real TS-590. The OmniRig face, ARCP-590 face, event fan-out, and the Hamlib net face are not yet wired. -Phase 2 adds the `Ts2000Dialect` and the OmniRig serial face, plus the ARCP-590 native serial face and the `permissions` model. The Python safe bridge and `rigctlcom` are retired from the operator's startup scripts. For A/B comparison without two processes fighting over COM3, this phase ships a temporary `RigctldBackend` so the daemon can sit *in front of* a still-running `rigctld` (rigctld owns the physical port; the daemon owns the faces and AI fan-out) behind a configuration flag. Exactly one process owns COM3 at any time: either `rigctld` (via the `RigctldBackend`) or the daemon's `Ts590Backend` directly. Once the VFO B soak passes, the operator flips the flag and the daemon's `Ts590Backend` takes COM3 directly; the `RigctldBackend` is dropped after the transition. +Phase 2 adds the `Ts2000Dialect` and the OmniRig serial face, plus the ARCP-590 native serial face and the `permissions` model. It also lands the **out-of-process `RigctldBackend`** as a first-class breadth backend: the daemon connects as the sole client of a daemon-private `rigctld` and presents that radio's **modeled** control surface (frequency, mode, PTT, split, RIT/XIT) to every face. This lets the daemon bring up *modeled* control of non-Kenwood and not-yet-natively-supported rigs immediately, and lets the operator migrate off the legacy Python safe bridge and `rigctlcom` while keeping the `rigctld` they trust. Native faceplate passthrough (ARCP-590 `EX` menu) is **not** carried over the bridge and still requires the native `Ts590Backend` (§7.1). Exactly one process owns the physical port: either `rigctld` (with the daemon as its sole client) or the daemon's native `Ts590Backend` directly — never both, and no third app connects to that `rigctld`. On a TS-590 the native backend is the recommended default; the bridge is the path for other rigs and a robustness-proven alternative. The legacy bridge and `rigctlcom` are retired from the operator's startup scripts. -Phase 3 adds the Hamlib net protocol server with full read/write support, `\dump_state`, and `\chk_vfo`. The QsoRipper engine config is repointed at the daemon, and WSJT-X is connected as a Hamlib NET rigctl rig. `rigctld` is removed from the chain entirely. +Phase 3 adds the Hamlib net protocol server with full read/write support, `\dump_state`, and `\chk_vfo`. The QsoRipper engine config is repointed at the daemon, and WSJT-X is connected as a Hamlib NET rigctl rig. The *legacy* multiplexing chain (the Python safe bridge plus a client-facing `rigctld`/`rigctlcom`) is removed; `rigctld` now appears only where it belongs, as the optional radio-side backend behind `RigctldBackend` for rigs without a native backend. -Phase 4 adds the `ai` module (central `AI2;` ownership, unsolicited-frame parsing, per-face AI virtualization, fan-out, and poller back-off), PTT lease arbitration, structured metrics, and the `--dry-run` mode. +Phase 4 adds the `events` module (central ownership of the radio's native push stream — Kenwood `AI2;`, CI-V transceive, etc. — spontaneous-frame parsing tagged by `RadioEventSource`, per-face push virtualization, fan-out, and `NativePush`-only poller back-off), PTT lease arbitration, structured metrics, and the `--dry-run` mode. Phase 5 finalizes documentation, but per the project's engine-spec-currency rule the spec and operator docs are updated **in the same PR as the behavior that changes them**, not deferred wholesale to the end — for example, the PR that repoints the engine at the daemon (Phase 3) updates the relevant spec note in that same PR. Phase 5 then consolidates: it updates `docs/architecture/engine-specification.md` to describe the daemon as the recommended rig-control front door on shared-radio stations, with `rigctld` retained as a supported alternative for single-client setups. It clarifies that the cathub daemon is **station infrastructure that lives below the engine's `rigctld` provider**, not part of the gRPC engine contract, so the engine remains client-agnostic and the §3.4 `RigControlService` / §5.3 rigctld integration sections are unchanged on the engine side. Adds an operator setup guide under `docs/integrations/cathub-setup.md` covering virtual serial pair creation (com0com on Windows, PTY pairs on Linux), OmniRig, N1MM, ARCP-590, and WSJT-X configuration, startup order, AI behavior, and a troubleshooting checklist. Adds `Start-CatHub`, `Stop-CatHub`, and `Get-CatHubLog` helpers to `scripts/profile-helpers.ps1` mirroring the existing `Start-Rigctld` and `Start-RigBridge` style. @@ -361,11 +435,12 @@ Phase 5 finalizes documentation, but per the project's engine-spec-currency rule - **com0com / PTY driver quirks.** Port-open failures must surface clear, actionable errors. The operator guide documents driver installation and the expected pair naming on both Windows and Linux. - **TS-2000 and TS-590 command set drift.** A few TS-2000 commands have no clean TS-590 equivalent. The dialect layer rejects unsupported commands with a logged reply rather than guessing. The universal state plus baseline polling keeps status reads honest. -- **ARCP-590 command surface beyond the modeled state.** Passthrough covers it, but a command that mutates a modeled field through an unmodeled prefix could desync the cache. The backend's prefix map for state-mutating commands must be tested against recorded ARCP-590 transcripts, and AI fan-out provides a correction path because the radio echoes the real change. -- **AI frame parsing and the AI ownership contract.** If a client manages to change the real radio's AI state, other clients lose their push feed. The virtualization in `ai` must be the only path that ever writes `AI` to the radio; a test enforces that a client `AI0;` does not reach the wire. +- **ARCP-590 command surface beyond the modeled state.** Passthrough covers it, but a command that mutates a modeled field through an unmodeled prefix could desync the cache. The backend's prefix map for state-mutating commands must be tested against recorded ARCP-590 transcripts, and event fan-out provides a correction path because the radio echoes the real change. +- **Spontaneous-event parsing and the push-ownership contract.** If a client manages to change the real radio's auto-info state (Kenwood `AI`), other clients lose their push feed. The virtualization in `events` must be the only path that ever writes the auto-info command to the radio; a test enforces that a client `AI0;` does not reach the wire. - **PTT contention and stuck transmitter.** The lease makes contention safe; orderly Ctrl+C and SIGTERM paths emit `RX;`; and because a panic/crash cannot reliably do async serial I/O, `ptt_max_tx_ms` plus the radio's own TX timeout are the ultimate stuck-transmitter guards. Integration tests exercise the arbitration, the safety ceiling, and the shutdown paths. - **Latency regressions versus the Python bridge under heavy passthrough.** The integration suite includes an end-to-end timing assertion. The budget is sub-five-millisecond added latency per modeled CAT round-trip on loopback, with interactive writes prioritized ahead of passthrough reads so ARCP-590 polling cannot inflate PTT/tuning latency. - **Single radio, multiple writers stomping each other.** Serialization through the single radio task is structural, not advisory. An integration test fans writes in from all faces and confirms the radio sees a strict ordering. +- **Out-of-process `rigctld` dependency.** The `RigctldBackend` adds a second process and a TCP hop, and inherits whatever model `rigctld` is launched with. Mitigations: the daemon supervises/launches `rigctld` and surfaces its exit clearly; an uncertified rig+model pairing is flagged in logs and trust metadata until the §10.3 soak passes; and a TS-590 station always has the native `Ts590Backend` as the dependency-free, certified alternative. ## 13. Alignment with QsoRipper architecture @@ -373,7 +448,8 @@ This design is consistent with the project's architectural principles: - **Stable core, volatile edges.** The cathub daemon is an *edge* component (station infrastructure). The QsoRipper engine's stable core is untouched: it keeps consuming rig state through its existing `RigctldProvider` and the `RigControlService` gRPC contract. Hardware, dialect, and vendor-app quirks are isolated inside the daemon. - **Normalize at the edge.** Vendor CAT dialects (TS-590 native, TS-2000, CI-V) and vendor apps (ARCP-590, OmniRig, WSJT-X) are normalized into one universal state model at the boundary, exactly as the engine normalizes QRZ and ADIF into project-owned proto/domain types. -- **Performance and low latency.** Priority scheduling, coalesced polling, AI-driven poll back-off, and a passthrough cache target the project's "everything should feel instant" goal for the interactive control path. +- **Rig-agnostic by construction (stable core, volatile edges, restated for hardware).** Rigs are edges, not core. Only the `RadioBackend` is rig-specific; the state cache, polling, event fan-out, faces, dialects, and arbitration are capability-driven and never branch on rig model. A native certified driver, a trusted out-of-process `rigctld`, and a future descriptor file are interchangeable behind one trait — so the daemon bridges the Hamlib world and the direct-CAT world for *many* rigs, not one, without the core ever learning a model name. +- **Performance and low latency.** Priority scheduling, coalesced polling, native-push-driven poll back-off, and a passthrough cache target the project's "everything should feel instant" goal for the interactive control path. - **Engine specification currency.** Per project rules, Phase 5 updates `docs/architecture/engine-specification.md` in the same change that lands the behavioral shift, documenting the daemon as the recommended rig-control front door while keeping the engine's gRPC contract stable. - **Cross-platform.** The daemon uses path-based serial endpoints and `std::path` semantics, supports com0com (Windows) and PTY pairs (Linux), and avoids hardcoded separators, satisfying the repository's Windows-and-Linux requirement even though the primary station is Windows. @@ -395,6 +471,9 @@ When the implementation PRs land, each must pass: - Whether the per-face AI virtualization should support `AI1;` (post-command echo) semantics distinctly from `AI2;` (spontaneous), or collapse both to a single push subscription. v1 may collapse them; loggers that depend on the `AI1;` distinction would need the finer model. - Whether to add an audio routing component in a future iteration so the same daemon can multiplex radio audio for digital modes (WSJT-X, fldigi). Explicitly out of scope and likely belongs in a separate daemon if pursued. - Whether to support ARHP-590-style remote heads (cathub over the network) once loopback operation is proven. Out of scope for v1. +- When to introduce the data-driven **descriptor backend** interpreter. The trigger is the second hand-written native backend (Icom), which clarifies what the descriptor language must express; OmniRig's `.ini` RigIni format and Hamlib's per-rig backends are the prior art to study (§7.1, §16). +- Whether an **in-process libhamlib (FFI) backend** is ever worth the native packaging cost, or whether the out-of-process `RigctldBackend` covers every realistic deployment. Current lean: keep FFI deferred behind a non-default feature unless a deployment genuinely cannot run a separate `rigctld`. +- How the daemon should **supervise `rigctld`** when using `RigctldBackend`: launch and own its lifecycle, or attach to an operator-managed instance. v1 leans toward attach-or-launch configurable, with clear surfacing of `rigctld` exit. ## 16. References @@ -406,5 +485,7 @@ When the implementation PRs land, each must pass: - Icom CI-V reference (Icom publishes per-model CI-V command tables in each transceiver's PDF reference manual). - com0com virtual serial port driver: . - N1MM Logger+ rig configuration: . +- OmniRig RigIni descriptor format (declarative per-rig CAT command + status-parse `.ini` files), studied as prior art for the future descriptor backend: . +- OmniRig2 (MIT-licensed open-source fork), referenced for descriptor-format inspiration only — not its polling/VFO behavior: . - Existing QsoRipper rig control consumer: `src/rust/qsoripper-core/src/rig_control/rigctld.rs`. - QsoRipper engine specification, rig control sections: `docs/architecture/engine-specification.md` (§3.4 RigControlService, §5.3 Rig Control). From 7b492ac60e2f0f972474829ee83efb4a3ed35558 Mon Sep 17 00:00:00 2001 From: Mike Treit Date: Thu, 28 May 2026 16:10:34 -0700 Subject: [PATCH 04/36] Add qsoripper-cathub phase 1: single-radio CAT hub skeleton (#471) Introduces the qsoripper-cathub lib+bin crate implementing phase 1 of the multi-client CAT hub design (docs/design/cathub-multi-client-cat-hub.md). A single tokio task owns the TS-590 serial port; a universal RigState is populated by a baseline poller and served to an N1MM-facing native Kenwood dialect over one serial face. Includes a LoopbackBackend for hardware-free testing, a TOML config loader with --dry-run, and thiserror typed errors. Uses serial2-tokio (BSD-2-Clause) for the serial transport to satisfy the permissive-only cargo deny license policy. Startup disables radio auto-information (AI0), primes the cache with an initial poll, and bounds the shutdown PTT release with a timeout. Co-authored-by: Mike Treit (from Dev Box) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/cathub/Cargo.toml | 35 +++ crates/cathub/src/backend/kenwood/mod.rs | 7 + crates/cathub/src/backend/kenwood/ts590.rs | 257 +++++++++++++++ crates/cathub/src/backend/loopback.rs | 162 ++++++++++ crates/cathub/src/backend/mod.rs | 51 +++ crates/cathub/src/config.rs | 350 +++++++++++++++++++++ crates/cathub/src/dialect/kenwood/mod.rs | 6 + crates/cathub/src/dialect/kenwood/ts590.rs | 277 ++++++++++++++++ crates/cathub/src/dialect/mod.rs | 110 +++++++ crates/cathub/src/error.rs | 81 +++++ crates/cathub/src/lib.rs | 153 +++++++++ crates/cathub/src/main.rs | 25 ++ crates/cathub/src/model.rs | 112 +++++++ crates/cathub/src/poller.rs | 45 +++ crates/cathub/src/radio.rs | 186 +++++++++++ crates/cathub/src/serial_face.rs | 201 ++++++++++++ crates/cathub/src/state.rs | 236 ++++++++++++++ crates/cathub/tests/cli.rs | 90 ++++++ 18 files changed, 2384 insertions(+) create mode 100644 crates/cathub/Cargo.toml create mode 100644 crates/cathub/src/backend/kenwood/mod.rs create mode 100644 crates/cathub/src/backend/kenwood/ts590.rs create mode 100644 crates/cathub/src/backend/loopback.rs create mode 100644 crates/cathub/src/backend/mod.rs create mode 100644 crates/cathub/src/config.rs create mode 100644 crates/cathub/src/dialect/kenwood/mod.rs create mode 100644 crates/cathub/src/dialect/kenwood/ts590.rs create mode 100644 crates/cathub/src/dialect/mod.rs create mode 100644 crates/cathub/src/error.rs create mode 100644 crates/cathub/src/lib.rs create mode 100644 crates/cathub/src/main.rs create mode 100644 crates/cathub/src/model.rs create mode 100644 crates/cathub/src/poller.rs create mode 100644 crates/cathub/src/radio.rs create mode 100644 crates/cathub/src/serial_face.rs create mode 100644 crates/cathub/src/state.rs create mode 100644 crates/cathub/tests/cli.rs diff --git a/crates/cathub/Cargo.toml b/crates/cathub/Cargo.toml new file mode 100644 index 0000000..e2a1d92 --- /dev/null +++ b/crates/cathub/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "qsoripper-cathub" +description = "Multi-client CAT hub daemon that owns the radio serial port and fans it out to multiple clients" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[lib] +name = "qsoripper_cathub" +path = "src/lib.rs" + +[[bin]] +name = "qsoripper-cathub" +path = "src/main.rs" + +[dependencies] +async-trait = { workspace = true } +bytes = { workspace = true } +clap = { workspace = true } +serde = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync", "io-util", "time", "net", "signal"] } +serial2-tokio = { workspace = true } +toml = { workspace = true } +tracing = { workspace = true } +tracing-appender = { workspace = true } +tracing-subscriber = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["test-util"] } + +[lints] +workspace = true diff --git a/crates/cathub/src/backend/kenwood/mod.rs b/crates/cathub/src/backend/kenwood/mod.rs new file mode 100644 index 0000000..09c1bbc --- /dev/null +++ b/crates/cathub/src/backend/kenwood/mod.rs @@ -0,0 +1,7 @@ +//! Kenwood CAT backend family. The TS-590 is the first concrete model; other +//! Kenwood radios share the command grammar and slot in via the same parsing +//! and formatting helpers. + +pub(crate) mod ts590; + +pub(crate) use ts590::Ts590Backend; diff --git a/crates/cathub/src/backend/kenwood/ts590.rs b/crates/cathub/src/backend/kenwood/ts590.rs new file mode 100644 index 0000000..c9fc17b --- /dev/null +++ b/crates/cathub/src/backend/kenwood/ts590.rs @@ -0,0 +1,257 @@ +//! Kenwood TS-590 backend. Maps universal [`StateMutation`]s to native CAT +//! commands and parses Kenwood replies (`FA`, `FB`, `MD`) back into universal +//! state. Phase 1 wires frequency, mode, and PTT to the wire. + +use async_trait::async_trait; + +use crate::backend::{RadioBackend, StateMutation}; +use crate::error::BackendError; +use crate::model::{Mode, Vfo}; +use crate::radio::RadioHandle; +use crate::state::StateHandle; + +/// Backend driving a Kenwood TS-590 over a serialized radio handle. +pub(crate) struct Ts590Backend { + radio: RadioHandle, +} + +impl Ts590Backend { + /// Construct a TS-590 backend over the given radio handle. + pub(crate) fn new(radio: RadioHandle) -> Self { + Self { radio } + } + + fn freq_set_command(vfo: Vfo, hz: u64) -> Vec { + let verb = match vfo { + Vfo::A => "FA", + Vfo::B => "FB", + }; + format!("{verb}{hz:011};").into_bytes() + } + + fn freq_query_command(vfo: Vfo) -> Vec { + match vfo { + Vfo::A => b"FA;".to_vec(), + Vfo::B => b"FB;".to_vec(), + } + } +} + +#[async_trait] +impl RadioBackend for Ts590Backend { + async fn poll(&self, state: &StateHandle) -> Result<(), BackendError> { + let fa = self + .radio + .send(Self::freq_query_command(Vfo::A), true) + .await?; + if let Some(hz) = parse_freq(&fa, b"FA") { + state.set_frequency(Vfo::A, hz).await; + } + let fb = self + .radio + .send(Self::freq_query_command(Vfo::B), true) + .await?; + if let Some(hz) = parse_freq(&fb, b"FB") { + state.set_frequency(Vfo::B, hz).await; + } + let md = self.radio.send(b"MD;".to_vec(), true).await?; + if let Some(mode) = parse_mode(&md) { + state.set_mode(Vfo::A, mode).await; + } + Ok(()) + } + + async fn apply( + &self, + mutation: StateMutation, + state: &StateHandle, + ) -> Result<(), BackendError> { + match mutation { + StateMutation::Frequency { vfo, hz } => { + self.radio + .send(Self::freq_set_command(vfo, hz), false) + .await?; + state.set_frequency(vfo, hz).await; + } + StateMutation::Mode { vfo, mode } => { + let cmd = format!("MD{};", mode.to_kenwood_digit()).into_bytes(); + self.radio.send(cmd, false).await?; + state.set_mode(vfo, mode).await; + } + StateMutation::Ptt { keyed } => { + let cmd = if keyed { + b"TX;".to_vec() + } else { + b"RX;".to_vec() + }; + self.radio.send(cmd, false).await?; + } + } + Ok(()) + } + + async fn passthrough(&self, raw: &[u8]) -> Result, BackendError> { + self.radio.send(raw.to_vec(), is_query(raw)).await + } +} + +/// Whether a raw command is a query (expects a reply): a verb followed only by +/// the terminator, with no parameter payload. +fn is_query(raw: &[u8]) -> bool { + let trimmed = raw.strip_suffix(b";").unwrap_or(raw); + !trimmed.is_empty() && trimmed.iter().all(u8::is_ascii_alphabetic) +} + +/// Parse a Kenwood frequency frame (`FA`/`FB` + 11 digits + `;`). +fn parse_freq(frame: &[u8], verb: &[u8]) -> Option { + let body = frame.strip_prefix(verb)?; + let digits = body.strip_suffix(b";")?; + if digits.len() != 11 || !digits.iter().all(u8::is_ascii_digit) { + return None; + } + std::str::from_utf8(digits).ok()?.parse::().ok() +} + +/// Parse a Kenwood mode frame (`MD` + 1 digit + `;`). +fn parse_mode(frame: &[u8]) -> Option { + let body = frame.strip_prefix(b"MD")?; + let digits = body.strip_suffix(b";")?; + let &[digit] = digits else { + return None; + }; + Mode::from_kenwood_digit(digit as char) +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)] +mod tests { + use super::*; + use crate::radio::{self, run_radio_task}; + use std::time::Duration; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + fn backend_with_fake() -> (Ts590Backend, tokio::io::DuplexStream) { + let (handle, inbox) = radio::channel(8); + let (client, radio_side) = tokio::io::duplex(1024); + tokio::spawn(run_radio_task( + radio_side, + inbox, + Duration::from_millis(200), + )); + (Ts590Backend::new(handle), client) + } + + #[test] + fn parse_freq_reads_eleven_digits() { + assert_eq!(parse_freq(b"FA00014074000;", b"FA"), Some(14_074_000)); + assert_eq!(parse_freq(b"FB00007030000;", b"FB"), Some(7_030_000)); + assert_eq!(parse_freq(b"FA0001407400;", b"FA"), None); + assert_eq!(parse_freq(b"MD2;", b"FA"), None); + } + + #[test] + fn parse_mode_reads_single_digit() { + assert_eq!(parse_mode(b"MD2;"), Some(Mode::Usb)); + assert_eq!(parse_mode(b"MD3;"), Some(Mode::Cw)); + assert_eq!(parse_mode(b"MD;"), None); + } + + #[test] + fn is_query_distinguishes_reads_from_writes() { + assert!(is_query(b"FA;")); + assert!(is_query(b"MD;")); + assert!(!is_query(b"FA00014074000;")); + assert!(!is_query(b"MD2;")); + } + + #[test] + fn freq_set_command_zero_pads() { + assert_eq!( + Ts590Backend::freq_set_command(Vfo::A, 14_074_000), + b"FA00014074000;".to_vec() + ); + assert_eq!( + Ts590Backend::freq_set_command(Vfo::B, 7_030_000), + b"FB00007030000;".to_vec() + ); + } + + #[tokio::test] + async fn apply_freq_writes_native_command_and_records_state() { + let (backend, mut fake) = backend_with_fake(); + let reader = tokio::spawn(async move { + let mut got = vec![0u8; 14]; + fake.read_exact(&mut got).await.expect("read set"); + assert_eq!(&got, b"FA00021074000;"); + }); + let (handle, _inbox) = StateHandle::new(16); + backend + .apply( + StateMutation::Frequency { + vfo: Vfo::A, + hz: 21_074_000, + }, + &handle, + ) + .await + .expect("apply"); + assert_eq!(handle.snapshot().await.freq_a, 21_074_000); + reader.await.expect("reader"); + } + + #[tokio::test] + async fn poll_parses_replies_into_state() { + let (backend, mut fake) = backend_with_fake(); + let responder = tokio::spawn(async move { + let mut buf = vec![0u8; 3]; + fake.read_exact(&mut buf).await.expect("FA?"); + assert_eq!(&buf, b"FA;"); + fake.write_all(b"FA00014074000;").await.expect("FA reply"); + let mut buf = vec![0u8; 3]; + fake.read_exact(&mut buf).await.expect("FB?"); + assert_eq!(&buf, b"FB;"); + fake.write_all(b"FB00007030000;").await.expect("FB reply"); + let mut buf = vec![0u8; 3]; + fake.read_exact(&mut buf).await.expect("MD?"); + assert_eq!(&buf, b"MD;"); + fake.write_all(b"MD3;").await.expect("MD reply"); + }); + let (handle, _inbox) = StateHandle::new(16); + backend.poll(&handle).await.expect("poll"); + let snap = handle.snapshot().await; + assert_eq!(snap.freq_a, 14_074_000); + assert_eq!(snap.freq_b, 7_030_000); + assert_eq!(snap.mode_a, Mode::Cw); + responder.await.expect("responder"); + } + + #[tokio::test] + async fn ptt_keys_the_transmitter() { + let (backend, mut fake) = backend_with_fake(); + let reader = tokio::spawn(async move { + let mut buf = vec![0u8; 3]; + fake.read_exact(&mut buf).await.expect("TX"); + assert_eq!(&buf, b"TX;"); + }); + let (handle, _inbox) = StateHandle::new(16); + backend + .apply(StateMutation::Ptt { keyed: true }, &handle) + .await + .expect("apply ptt"); + reader.await.expect("reader"); + } + + #[tokio::test] + async fn passthrough_query_returns_reply() { + let (backend, mut fake) = backend_with_fake(); + let responder = tokio::spawn(async move { + let mut buf = vec![0u8; 3]; + fake.read_exact(&mut buf).await.expect("PS?"); + assert_eq!(&buf, b"PS;"); + fake.write_all(b"PS1;").await.expect("PS reply"); + }); + let reply = backend.passthrough(b"PS;").await.expect("passthrough"); + assert_eq!(reply, b"PS1;".to_vec()); + responder.await.expect("responder"); + } +} diff --git a/crates/cathub/src/backend/loopback.rs b/crates/cathub/src/backend/loopback.rs new file mode 100644 index 0000000..c866fc2 --- /dev/null +++ b/crates/cathub/src/backend/loopback.rs @@ -0,0 +1,162 @@ +//! In-memory backend used by tests and `--dry-run`. Records every mutation and +//! passthrough, reflects mutations into the universal state, and serves canned +//! poll data without touching hardware. + +#[cfg(test)] +use std::sync::Mutex; + +use async_trait::async_trait; + +use crate::backend::{RadioBackend, StateMutation}; +use crate::error::BackendError; +use crate::model::{Mode, Vfo}; +use crate::state::StateHandle; + +/// A canned poll snapshot the loopback backend reports. +#[derive(Debug, Clone, Copy)] +pub(crate) struct CannedPoll { + /// VFO A frequency in Hz. + pub(crate) freq_a: u64, + /// VFO B frequency in Hz. + pub(crate) freq_b: u64, + /// VFO A mode. + pub(crate) mode_a: Mode, + /// VFO B mode. + pub(crate) mode_b: Mode, +} + +impl Default for CannedPoll { + fn default() -> Self { + Self { + freq_a: 14_074_000, + freq_b: 14_074_000, + mode_a: Mode::Usb, + mode_b: Mode::Usb, + } + } +} + +/// Backend that records interactions instead of driving a radio. +pub(crate) struct LoopbackBackend { + canned: CannedPoll, + #[cfg(test)] + mutations: Mutex>, + #[cfg(test)] + passthroughs: Mutex>>, +} + +impl LoopbackBackend { + /// Create a loopback backend with default canned poll data. + pub(crate) fn new() -> Self { + Self { + canned: CannedPoll::default(), + #[cfg(test)] + mutations: Mutex::new(Vec::new()), + #[cfg(test)] + passthroughs: Mutex::new(Vec::new()), + } + } + + /// Snapshot of mutations recorded so far (test introspection only). + #[cfg(test)] + pub(crate) fn recorded_mutations(&self) -> Vec { + self.mutations.lock().map(|g| g.clone()).unwrap_or_default() + } + + /// Snapshot of passthrough payloads recorded so far (test introspection only). + #[cfg(test)] + pub(crate) fn recorded_passthroughs(&self) -> Vec> { + self.passthroughs + .lock() + .map(|g| g.clone()) + .unwrap_or_default() + } + + #[cfg(test)] + fn record_mutation(&self, mutation: StateMutation) { + if let Ok(mut guard) = self.mutations.lock() { + guard.push(mutation); + } + } + + #[cfg(not(test))] + #[allow(clippy::unused_self)] + fn record_mutation(&self, _mutation: StateMutation) {} +} + +#[async_trait] +impl RadioBackend for LoopbackBackend { + async fn poll(&self, state: &StateHandle) -> Result<(), BackendError> { + state.set_frequency(Vfo::A, self.canned.freq_a).await; + state.set_frequency(Vfo::B, self.canned.freq_b).await; + state.set_mode(Vfo::A, self.canned.mode_a).await; + state.set_mode(Vfo::B, self.canned.mode_b).await; + Ok(()) + } + + async fn apply( + &self, + mutation: StateMutation, + state: &StateHandle, + ) -> Result<(), BackendError> { + self.record_mutation(mutation); + match mutation { + StateMutation::Frequency { vfo, hz } => state.set_frequency(vfo, hz).await, + StateMutation::Mode { vfo, mode } => state.set_mode(vfo, mode).await, + StateMutation::Ptt { .. } => {} + } + Ok(()) + } + + async fn passthrough(&self, raw: &[u8]) -> Result, BackendError> { + #[cfg(test)] + { + if let Ok(mut guard) = self.passthroughs.lock() { + guard.push(raw.to_vec()); + } + } + #[cfg(not(test))] + let _ = raw; + Ok(Vec::new()) + } +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)] +mod tests { + use super::*; + + #[tokio::test] + async fn poll_populates_state_from_canned_data() { + let (handle, _inbox) = StateHandle::new(16); + let backend = LoopbackBackend::new(); + backend.poll(&handle).await.expect("poll"); + assert_eq!(handle.snapshot().await.freq_a, 14_074_000); + } + + #[tokio::test] + async fn apply_records_and_reflects_mutation() { + let (handle, _inbox) = StateHandle::new(16); + let backend = LoopbackBackend::new(); + backend + .apply( + StateMutation::Mode { + vfo: Vfo::A, + mode: Mode::Cw, + }, + &handle, + ) + .await + .expect("apply"); + assert_eq!(handle.snapshot().await.mode_a, Mode::Cw); + assert_eq!(backend.recorded_mutations().len(), 1); + } + + #[tokio::test] + async fn passthrough_records_raw_bytes() { + let backend = LoopbackBackend::new(); + let reply = backend.passthrough(b"FA;").await.expect("passthrough"); + assert!(reply.is_empty()); + assert_eq!(backend.recorded_passthroughs(), vec![b"FA;".to_vec()]); + } +} diff --git a/crates/cathub/src/backend/mod.rs b/crates/cathub/src/backend/mod.rs new file mode 100644 index 0000000..eb82337 --- /dev/null +++ b/crates/cathub/src/backend/mod.rs @@ -0,0 +1,51 @@ +//! Radio backend abstraction: the seam that lets the hub drive different radio +//! families (Kenwood, Icom CI-V, Yaesu) behind one trait. + +pub(crate) mod kenwood; +pub(crate) mod loopback; + +use async_trait::async_trait; + +use crate::error::BackendError; +use crate::model::{Mode, Vfo}; +use crate::state::StateHandle; + +/// A single normalized change to apply to the radio. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum StateMutation { + /// Set a VFO frequency in Hz. + Frequency { + /// Target VFO. + vfo: Vfo, + /// Frequency in Hz. + hz: u64, + }, + /// Set a VFO mode. + Mode { + /// Target VFO. + vfo: Vfo, + /// Mode to set. + mode: Mode, + }, + /// Key or unkey the transmitter. + Ptt { + /// Whether the transmitter should be keyed. + keyed: bool, + }, +} + +/// A radio family backend. Implementations own the native command vocabulary +/// and the mapping to and from universal [`StateMutation`]s and [`StateHandle`]. +#[async_trait] +pub(crate) trait RadioBackend: Send + Sync { + /// Refresh the universal state by polling the radio. + async fn poll(&self, state: &StateHandle) -> Result<(), BackendError>; + + /// Apply a mutation to the radio and reflect it into the universal state. + async fn apply(&self, mutation: StateMutation, state: &StateHandle) + -> Result<(), BackendError>; + + /// Pass a raw native command straight through to the radio and return the + /// raw reply bytes (possibly empty for no-reply commands). + async fn passthrough(&self, raw: &[u8]) -> Result, BackendError>; +} diff --git a/crates/cathub/src/config.rs b/crates/cathub/src/config.rs new file mode 100644 index 0000000..dc399a7 --- /dev/null +++ b/crates/cathub/src/config.rs @@ -0,0 +1,350 @@ +//! Configuration schema and loading. The hub is configured from a TOML file +//! describing the radio, the baseline poll cadence, and one or more client +//! faces. Phase 1 supports serial faces; the Hamlib net section lands later. + +use std::fmt::Write as _; +use std::path::{Path, PathBuf}; + +use serde::Deserialize; + +use crate::dialect::Permissions; +use crate::error::ConfigError; + +/// Top-level daemon configuration. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Config { + /// Radio transport settings. + pub(crate) radio: RadioConfig, + /// Baseline polling settings. + #[serde(default)] + pub(crate) poll: PollConfig, + /// Client faces. + #[serde(default, rename = "face")] + pub(crate) faces: Vec, +} + +/// Which radio backend family to drive. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum BackendKind { + /// Kenwood TS-590. + Ts590, + /// In-memory loopback backend (testing and `--dry-run`). + Loopback, +} + +/// Which client dialect a face speaks. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum DialectKind { + /// Native Kenwood TS-590. + Ts590, +} + +/// Radio transport configuration. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct RadioConfig { + /// Serial port path (for example `COM3` or `/dev/ttyUSB0`). + pub(crate) port: String, + /// Serial baud rate. + #[serde(default = "default_baud")] + pub(crate) baud: u32, + /// Backend family. + pub(crate) backend: BackendKind, + /// Per-command reply timeout in milliseconds. + #[serde(default = "default_reply_timeout_ms")] + pub(crate) reply_timeout_ms: u64, +} + +/// Baseline polling configuration. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct PollConfig { + /// Baseline poll interval in milliseconds. + #[serde(default = "default_baseline_ms")] + pub(crate) baseline_ms: u64, +} + +impl Default for PollConfig { + fn default() -> Self { + Self { + baseline_ms: default_baseline_ms(), + } + } +} + +/// One client face. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct FaceConfig { + /// Face name (used in logs and as PTT owner identity). + pub(crate) name: String, + /// Serial port path the client connects to. + pub(crate) port: String, + /// Serial baud rate. + #[serde(default = "default_baud")] + pub(crate) baud: u32, + /// Dialect this face speaks. + pub(crate) dialect: DialectKind, + /// Whether the face may change frequency/mode/split. + #[serde(default = "default_true")] + pub(crate) allow_write: bool, + /// Whether the face may key PTT. + #[serde(default)] + pub(crate) allow_ptt: bool, + /// Whether the face may send raw passthrough commands. + #[serde(default = "default_true")] + pub(crate) allow_passthrough: bool, +} + +impl FaceConfig { + /// Permissions derived from this face configuration. + pub(crate) fn permissions(&self) -> Permissions { + Permissions { + allow_write: self.allow_write, + allow_ptt: self.allow_ptt, + allow_passthrough: self.allow_passthrough, + } + } +} + +impl Config { + /// Load and parse configuration from a TOML file. + pub(crate) fn load(path: &Path) -> Result { + let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Read { + path: path.display().to_string(), + source, + })?; + Self::parse(&text) + } + + /// Parse configuration from a TOML string. + pub(crate) fn parse(text: &str) -> Result { + toml::from_str(text).map_err(|e| ConfigError::Parse(e.to_string())) + } + + /// Validate semantic constraints not expressible in the schema. + pub(crate) fn validate(&self) -> Result<(), ConfigError> { + if self.radio.port.trim().is_empty() { + return Err(ConfigError::Invalid( + "radio.port must not be empty".to_string(), + )); + } + if self.radio.reply_timeout_ms == 0 { + return Err(ConfigError::Invalid( + "radio.reply_timeout_ms must be greater than zero".to_string(), + )); + } + if self.poll.baseline_ms == 0 { + return Err(ConfigError::Invalid( + "poll.baseline_ms must be greater than zero".to_string(), + )); + } + for face in &self.faces { + if face.name.trim().is_empty() { + return Err(ConfigError::Invalid( + "face.name must not be empty".to_string(), + )); + } + if face.port.trim().is_empty() { + return Err(ConfigError::Invalid(format!( + "face '{}' port must not be empty", + face.name + ))); + } + } + Ok(()) + } + + /// Render a human-readable summary of the resolved configuration. + pub(crate) fn describe(&self) -> String { + let mut out = String::new(); + let _ = writeln!( + out, + "radio: port={} baud={} backend={:?} reply_timeout_ms={}", + self.radio.port, self.radio.baud, self.radio.backend, self.radio.reply_timeout_ms + ); + let _ = writeln!(out, "poll: baseline_ms={}", self.poll.baseline_ms); + if self.faces.is_empty() { + let _ = writeln!(out, "faces: (none)"); + } + for face in &self.faces { + let _ = writeln!( + out, + "face: name={} port={} baud={} dialect={:?} write={} ptt={} passthrough={}", + face.name, + face.port, + face.baud, + face.dialect, + face.allow_write, + face.allow_ptt, + face.allow_passthrough + ); + } + out + } +} + +/// Resolve the default configuration file path from the environment. +pub(crate) fn default_config_path() -> Option { + if let Some(dir) = std::env::var_os("APPDATA") { + return Some(Path::new(&dir).join("qsoripper").join("cathub.toml")); + } + if let Some(dir) = std::env::var_os("XDG_CONFIG_HOME") { + return Some(Path::new(&dir).join("qsoripper").join("cathub.toml")); + } + if let Some(dir) = std::env::var_os("HOME") { + return Some( + Path::new(&dir) + .join(".config") + .join("qsoripper") + .join("cathub.toml"), + ); + } + None +} + +fn default_baud() -> u32 { + 115_200 +} + +fn default_reply_timeout_ms() -> u64 { + 250 +} + +fn default_baseline_ms() -> u64 { + 500 +} + +fn default_true() -> bool { + true +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)] +mod tests { + use super::*; + + const SAMPLE: &str = r#" +[radio] +port = "COM3" +baud = 115200 +backend = "ts590" +reply_timeout_ms = 200 + +[poll] +baseline_ms = 400 + +[[face]] +name = "n1mm" +port = "COM11" +dialect = "ts590" +allow_ptt = true +"#; + + #[test] + fn parses_full_config() { + let config = Config::parse(SAMPLE).expect("parse"); + assert_eq!(config.radio.port, "COM3"); + assert_eq!(config.radio.backend, BackendKind::Ts590); + assert_eq!(config.radio.reply_timeout_ms, 200); + assert_eq!(config.poll.baseline_ms, 400); + assert_eq!(config.faces.len(), 1); + let face = &config.faces[0]; + assert_eq!(face.name, "n1mm"); + assert_eq!(face.baud, 115_200); + assert!(face.allow_ptt); + assert!(face.allow_write); + assert!(face.allow_passthrough); + config.validate().expect("valid"); + } + + #[test] + fn defaults_apply_when_omitted() { + let config = Config::parse( + r#" +[radio] +port = "/dev/ttyUSB0" +backend = "loopback" +"#, + ) + .expect("parse"); + assert_eq!(config.radio.baud, 115_200); + assert_eq!(config.radio.reply_timeout_ms, 250); + assert_eq!(config.poll.baseline_ms, 500); + assert!(config.faces.is_empty()); + } + + #[test] + fn unknown_field_is_rejected() { + let err = Config::parse( + r#" +[radio] +port = "COM3" +backend = "ts590" +bogus = true +"#, + ) + .expect_err("should reject unknown field"); + assert!(matches!(err, ConfigError::Parse(_))); + } + + #[test] + fn empty_port_is_invalid() { + let config = Config::parse( + r#" +[radio] +port = " " +backend = "ts590" +"#, + ) + .expect("parse"); + assert!(matches!(config.validate(), Err(ConfigError::Invalid(_)))); + } + + #[test] + fn zero_timeout_is_invalid() { + let config = Config::parse( + r#" +[radio] +port = "COM3" +backend = "ts590" +reply_timeout_ms = 0 +"#, + ) + .expect("parse"); + assert!(matches!(config.validate(), Err(ConfigError::Invalid(_)))); + } + + #[test] + fn permissions_round_trip_from_face() { + let config = Config::parse(SAMPLE).expect("parse"); + let perms = config.faces[0].permissions(); + assert!(perms.allow_ptt); + assert!(perms.allow_write); + assert!(perms.allow_passthrough); + } + + #[test] + fn describe_lists_radio_and_faces() { + let config = Config::parse(SAMPLE).expect("parse"); + let described = config.describe(); + assert!(described.contains("port=COM3")); + assert!(described.contains("name=n1mm")); + } + + #[test] + fn describe_handles_no_faces() { + let config = Config::parse( + r#" +[radio] +port = "COM3" +backend = "ts590" +"#, + ) + .expect("parse"); + assert!(config.describe().contains("faces: (none)")); + } +} diff --git a/crates/cathub/src/dialect/kenwood/mod.rs b/crates/cathub/src/dialect/kenwood/mod.rs new file mode 100644 index 0000000..f106271 --- /dev/null +++ b/crates/cathub/src/dialect/kenwood/mod.rs @@ -0,0 +1,6 @@ +//! Kenwood client dialects. The TS-590 dialect serves native Kenwood clients +//! such as N1MM. + +pub(crate) mod ts590; + +pub(crate) use ts590::Ts590Dialect; diff --git a/crates/cathub/src/dialect/kenwood/ts590.rs b/crates/cathub/src/dialect/kenwood/ts590.rs new file mode 100644 index 0000000..f3d0285 --- /dev/null +++ b/crates/cathub/src/dialect/kenwood/ts590.rs @@ -0,0 +1,277 @@ +//! TS-590 client dialect. Serves native Kenwood clients such as N1MM: status +//! reads are answered from cached universal state, writes become universal +//! mutations, PTT is permission-gated, and anything unmodeled passes through to +//! the radio. + +use async_trait::async_trait; + +use crate::backend::StateMutation; +use crate::dialect::{ClientDialect, FaceContext}; +use crate::model::{Mode, Vfo}; +use crate::state::{StateChange, StateHandle}; + +/// Native Kenwood TS-590 dialect. +pub(crate) struct Ts590Dialect; + +impl Ts590Dialect { + /// Construct the TS-590 dialect. + pub(crate) fn new() -> Self { + Self + } + + async fn read_reply(verb: &[u8], state: &StateHandle) -> Option> { + let snap = state.snapshot().await; + match verb { + b"FA" => Some(format_freq(b"FA", snap.freq(Vfo::A))), + b"FB" => Some(format_freq(b"FB", snap.freq(Vfo::B))), + b"MD" => Some(format_mode(snap.mode(snap.rx_vfo))), + _ => None, + } + } + + async fn apply_write(verb: &[u8], payload: &[u8], ctx: &FaceContext) { + if !ctx.permissions().allow_write { + tracing::warn!(face = ctx.name(), "dropping write: face is read-only"); + return; + } + let mutation = + match verb { + b"FA" => parse_freq_payload(payload) + .map(|hz| StateMutation::Frequency { vfo: Vfo::A, hz }), + b"FB" => parse_freq_payload(payload) + .map(|hz| StateMutation::Frequency { vfo: Vfo::B, hz }), + b"MD" => parse_mode_payload(payload) + .map(|mode| StateMutation::Mode { vfo: Vfo::A, mode }), + _ => None, + }; + if let Some(mutation) = mutation { + if let Err(error) = ctx.state().apply_mutation(mutation).await { + tracing::warn!(face = ctx.name(), %error, "write failed"); + } + } + } + + async fn route_ptt(keyed: bool, ctx: &FaceContext) { + if !ctx.permissions().allow_ptt { + tracing::warn!(face = ctx.name(), keyed, "dropping PTT: face may not key"); + return; + } + if let Err(error) = ctx + .state() + .apply_mutation(StateMutation::Ptt { keyed }) + .await + { + tracing::warn!(face = ctx.name(), keyed, %error, "PTT routing failed"); + } + } +} + +#[async_trait] +impl ClientDialect for Ts590Dialect { + async fn handle(&self, request: &[u8], ctx: &FaceContext) -> Vec { + let Some((verb, payload)) = split_command(request) else { + return Vec::new(); + }; + + match verb.as_slice() { + b"FA" | b"FB" | b"MD" if payload.is_empty() => Self::read_reply(&verb, ctx.state()) + .await + .unwrap_or_default(), + b"FA" | b"FB" | b"MD" => { + Self::apply_write(&verb, &payload, ctx).await; + Vec::new() + } + b"TX" => { + Self::route_ptt(true, ctx).await; + Vec::new() + } + b"RX" => { + Self::route_ptt(false, ctx).await; + Vec::new() + } + b"AI" if payload.is_empty() => { + let level = u8::from(ctx.ai_enabled()); + format!("AI{level};").into_bytes() + } + b"AI" => { + let enabled = payload.first().is_some_and(|&b| b != b'0'); + ctx.set_ai_enabled(enabled); + Vec::new() + } + _ => { + if ctx.permissions().allow_passthrough { + ctx.backend().passthrough(request).await.unwrap_or_default() + } else { + tracing::warn!(face = ctx.name(), "dropping passthrough: not permitted"); + Vec::new() + } + } + } + } + + fn format_notification(&self, change: &StateChange, ctx: &FaceContext) -> Option> { + if !ctx.ai_enabled() { + return None; + } + match change { + StateChange::Frequency { vfo: Vfo::A, hz } => Some(format_freq(b"FA", *hz)), + StateChange::Frequency { vfo: Vfo::B, hz } => Some(format_freq(b"FB", *hz)), + StateChange::Mode { vfo: Vfo::A, mode } => Some(format_mode(*mode)), + StateChange::Mode { vfo: Vfo::B, .. } => None, + } + } +} + +fn format_freq(verb: &[u8], hz: u64) -> Vec { + let mut out = verb.to_vec(); + out.extend_from_slice(format!("{hz:011};").as_bytes()); + out +} + +fn format_mode(mode: Mode) -> Vec { + format!("MD{};", mode.to_kenwood_digit()).into_bytes() +} + +/// Split a terminated command into its alphabetic verb and remaining payload. +fn split_command(request: &[u8]) -> Option<(Vec, Vec)> { + let body = request.strip_suffix(b";").unwrap_or(request); + if body.is_empty() { + return None; + } + let verb_len = body.iter().take_while(|b| b.is_ascii_alphabetic()).count(); + if verb_len == 0 { + return None; + } + let (verb, payload) = body.split_at(verb_len); + Some((verb.to_vec(), payload.to_vec())) +} + +fn parse_freq_payload(payload: &[u8]) -> Option { + if payload.len() != 11 || !payload.iter().all(u8::is_ascii_digit) { + return None; + } + std::str::from_utf8(payload).ok()?.parse::().ok() +} + +fn parse_mode_payload(payload: &[u8]) -> Option { + let &[digit] = payload else { + return None; + }; + Mode::from_kenwood_digit(digit as char) +} + +#[cfg(test)] +#[allow( + clippy::expect_used, + clippy::unwrap_used, + clippy::indexing_slicing, + clippy::unused_async +)] +mod tests { + use super::*; + use crate::backend::loopback::LoopbackBackend; + use crate::dialect::Permissions; + use crate::state::run_mutation_dispatcher; + use std::sync::Arc; + + fn wired( + permissions: Permissions, + ) -> ( + Ts590Dialect, + FaceContext, + Arc, + tokio::task::JoinHandle<()>, + ) { + let (state, inbox) = StateHandle::new(16); + let backend = Arc::new(LoopbackBackend::new()); + let dispatcher = tokio::spawn(run_mutation_dispatcher( + inbox, + backend.clone(), + state.clone(), + )); + let ctx = FaceContext::new("test", permissions, state, backend.clone()); + (Ts590Dialect::new(), ctx, backend, dispatcher) + } + + #[tokio::test] + async fn read_serves_cached_frequency() { + let (dialect, ctx, _backend, dispatcher) = wired(Permissions::default()); + ctx.state().set_frequency(Vfo::A, 14_074_000).await; + let reply = dialect.handle(b"FA;", &ctx).await; + assert_eq!(reply, b"FA00014074000;".to_vec()); + dispatcher.abort(); + } + + #[tokio::test] + async fn write_applies_mutation_through_backend() { + let (dialect, ctx, backend, dispatcher) = wired(Permissions::default()); + let reply = dialect.handle(b"FA00021074000;", &ctx).await; + assert!(reply.is_empty()); + assert_eq!(ctx.state().snapshot().await.freq_a, 21_074_000); + assert_eq!(backend.recorded_mutations().len(), 1); + dispatcher.abort(); + } + + #[tokio::test] + async fn ptt_dropped_without_permission() { + let permissions = Permissions { + allow_ptt: false, + ..Permissions::default() + }; + let (dialect, ctx, backend, dispatcher) = wired(permissions); + let reply = dialect.handle(b"TX;", &ctx).await; + assert!(reply.is_empty()); + assert!(backend.recorded_mutations().is_empty()); + dispatcher.abort(); + } + + #[tokio::test] + async fn ptt_routed_with_permission() { + let permissions = Permissions { + allow_ptt: true, + ..Permissions::default() + }; + let (dialect, ctx, backend, dispatcher) = wired(permissions); + dialect.handle(b"TX;", &ctx).await; + assert_eq!( + backend.recorded_mutations(), + vec![StateMutation::Ptt { keyed: true }] + ); + dispatcher.abort(); + } + + #[tokio::test] + async fn unmodeled_command_passes_through() { + let (dialect, ctx, backend, dispatcher) = wired(Permissions::default()); + let reply = dialect.handle(b"PS;", &ctx).await; + assert!(reply.is_empty()); + assert_eq!(backend.recorded_passthroughs(), vec![b"PS;".to_vec()]); + dispatcher.abort(); + } + + #[tokio::test] + async fn ai_toggle_round_trips() { + let (dialect, ctx, _backend, dispatcher) = wired(Permissions::default()); + assert_eq!(dialect.handle(b"AI;", &ctx).await, b"AI0;".to_vec()); + dialect.handle(b"AI1;", &ctx).await; + assert!(ctx.ai_enabled()); + assert_eq!(dialect.handle(b"AI;", &ctx).await, b"AI1;".to_vec()); + dispatcher.abort(); + } + + #[tokio::test] + async fn notification_only_when_ai_enabled() { + let (dialect, ctx, _backend, dispatcher) = wired(Permissions::default()); + let change = StateChange::Frequency { + vfo: Vfo::A, + hz: 14_074_000, + }; + assert!(dialect.format_notification(&change, &ctx).is_none()); + ctx.set_ai_enabled(true); + assert_eq!( + dialect.format_notification(&change, &ctx), + Some(b"FA00014074000;".to_vec()) + ); + dispatcher.abort(); + } +} diff --git a/crates/cathub/src/dialect/mod.rs b/crates/cathub/src/dialect/mod.rs new file mode 100644 index 0000000..a282547 --- /dev/null +++ b/crates/cathub/src/dialect/mod.rs @@ -0,0 +1,110 @@ +//! Client dialect abstraction: the seam that lets one universal state serve +//! many client CAT vocabularies (native Kenwood for N1MM, TS-2000 emulation for +//! `OmniRig`, Hamlib net for the engine). + +pub(crate) mod kenwood; + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use async_trait::async_trait; + +use crate::backend::RadioBackend; +use crate::state::{StateChange, StateHandle}; + +/// What a face is allowed to do, enforced before any radio write. +#[derive(Debug, Clone, Copy)] +pub(crate) struct Permissions { + /// Whether the face may change frequency/mode/split. + pub(crate) allow_write: bool, + /// Whether the face may key PTT. + pub(crate) allow_ptt: bool, + /// Whether the face may send raw passthrough commands. + pub(crate) allow_passthrough: bool, +} + +impl Default for Permissions { + fn default() -> Self { + Self { + allow_write: true, + allow_ptt: false, + allow_passthrough: true, + } + } +} + +/// Per-face runtime context handed to a dialect on every request. +#[derive(Clone)] +pub(crate) struct FaceContext { + /// Face name, used in logs and as the PTT owner identity. + name: String, + /// What this face is permitted to do. + permissions: Permissions, + /// Shared universal state for reads and modeled writes. + state: StateHandle, + /// Active backend for raw passthrough commands. + backend: Arc, + /// Whether this face has auto-information fan-out enabled. + ai_enabled: Arc, +} + +impl FaceContext { + /// Build a face context. + pub(crate) fn new( + name: impl Into, + permissions: Permissions, + state: StateHandle, + backend: Arc, + ) -> Self { + Self { + name: name.into(), + permissions, + state, + backend, + ai_enabled: Arc::new(AtomicBool::new(false)), + } + } + + /// Face name. + pub(crate) fn name(&self) -> &str { + &self.name + } + + /// Face permissions. + pub(crate) fn permissions(&self) -> Permissions { + self.permissions + } + + /// Shared universal state. + pub(crate) fn state(&self) -> &StateHandle { + &self.state + } + + /// Active backend. + pub(crate) fn backend(&self) -> &Arc { + &self.backend + } + + /// Whether auto-information fan-out is enabled for this face. + pub(crate) fn ai_enabled(&self) -> bool { + self.ai_enabled.load(Ordering::Relaxed) + } + + /// Enable or disable auto-information fan-out for this face. + pub(crate) fn set_ai_enabled(&self, enabled: bool) { + self.ai_enabled.store(enabled, Ordering::Relaxed); + } +} + +/// A client CAT dialect. Implementations translate between a client's command +/// vocabulary and the universal state plus backend. +#[async_trait] +pub(crate) trait ClientDialect: Send + Sync { + /// Handle one client request frame, returning the bytes to write back to + /// the client (empty when the command produces no reply). + async fn handle(&self, request: &[u8], ctx: &FaceContext) -> Vec; + + /// Format a universal state change as an unsolicited frame for an + /// AI-subscribed client, or `None` if the dialect does not surface it. + fn format_notification(&self, change: &StateChange, ctx: &FaceContext) -> Option>; +} diff --git a/crates/cathub/src/error.rs b/crates/cathub/src/error.rs new file mode 100644 index 0000000..c43faf4 --- /dev/null +++ b/crates/cathub/src/error.rs @@ -0,0 +1,81 @@ +//! Typed error surfaces for the CAT hub. + +use std::io; + +use thiserror::Error; + +/// Errors raised while loading or validating configuration. +#[derive(Debug, Error)] +pub enum ConfigError { + /// The configuration file could not be read. + #[error("failed to read config file '{path}': {source}")] + Read { + /// Path that failed to load. + path: String, + /// Underlying I/O error. + source: io::Error, + }, + /// The configuration file was not valid TOML or failed schema parsing. + #[error("failed to parse config: {0}")] + Parse(String), + /// The configuration parsed but was semantically invalid. + #[error("invalid configuration: {0}")] + Invalid(String), +} + +/// Errors raised by a radio backend. +#[derive(Debug, Error)] +pub enum BackendError { + /// The radio transport failed (I/O, disconnect). + #[error("radio transport error: {0}")] + Transport(String), + /// A command timed out waiting for a reply. + #[error("radio command timed out: {0}")] + Timeout(String), + /// A reply could not be parsed. + #[error("failed to parse radio reply: {0}")] + Parse(String), + /// The backend does not support the requested operation. + #[error("operation not supported by backend: {0}")] + Unsupported(String), +} + +/// Errors raised while running a client face. +#[derive(Debug, Error)] +pub enum FaceError { + /// The face's serial or network endpoint could not be bound. + #[error("failed to bind face '{name}' to '{endpoint}': {message}")] + Bind { + /// Face name from configuration. + name: String, + /// Endpoint that failed to bind. + endpoint: String, + /// Description of the bind failure. + message: String, + }, + /// An I/O error occurred while serving the face. + #[error("face '{name}' I/O error: {source}")] + Io { + /// Face name from configuration. + name: String, + /// Underlying I/O error. + source: io::Error, + }, +} + +/// Top-level error type for the daemon. +#[derive(Debug, Error)] +pub enum CatHubError { + /// Configuration failed to load or validate. + #[error(transparent)] + Config(#[from] ConfigError), + /// A backend failed fatally during startup. + #[error(transparent)] + Backend(#[from] BackendError), + /// A face failed fatally during startup. + #[error(transparent)] + Face(#[from] FaceError), + /// A generic I/O failure during startup or shutdown. + #[error("I/O error: {0}")] + Io(#[from] io::Error), +} diff --git a/crates/cathub/src/lib.rs b/crates/cathub/src/lib.rs new file mode 100644 index 0000000..fc5a53f --- /dev/null +++ b/crates/cathub/src/lib.rs @@ -0,0 +1,153 @@ +//! `qsoripper-cathub`: a multi-client CAT hub that owns the radio's serial port +//! and fans it out to multiple logging, panadapter, and engine clients. +//! +//! This crate is built as a library plus a thin binary so integration tests and +//! the engine can drive the hub in-process. The public surface is intentionally +//! minimal; the daemon internals are crate-private. + +#![forbid(unsafe_code)] + +mod backend; +mod config; +mod dialect; +mod error; +mod model; +mod poller; +mod radio; +mod serial_face; +mod state; + +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use clap::Parser; + +use crate::backend::kenwood::Ts590Backend; +use crate::backend::loopback::LoopbackBackend; +use crate::backend::{RadioBackend, StateMutation}; +use crate::config::{BackendKind, Config, DialectKind}; +use crate::dialect::kenwood::Ts590Dialect; +use crate::dialect::{ClientDialect, FaceContext}; +use crate::error::{BackendError, ConfigError}; +use crate::poller::run_poller; +use crate::radio::run_radio_task; +use crate::state::{run_mutation_dispatcher, StateHandle}; + +pub use error::CatHubError; + +/// Command-line arguments for the CAT hub daemon. +#[derive(Debug, Parser)] +#[command( + name = "qsoripper-cathub", + about = "Multi-client CAT hub for shared radio control" +)] +pub struct Cli { + /// Path to the TOML configuration file. Defaults to the per-user config path. + #[arg(long)] + pub config: Option, + /// Path to a log file. When omitted, logs go to stderr. + #[arg(long)] + pub log: Option, + /// Load and validate the configuration, print it, and exit. + #[arg(long)] + pub dry_run: bool, +} + +/// Run the daemon to completion using the given command-line arguments. +/// +/// # Errors +/// +/// Returns a [`CatHubError`] if configuration fails to load or validate, if the +/// radio transport cannot be opened, or if a face fails to bind. +pub async fn run(cli: Cli) -> Result<(), CatHubError> { + let config_path = cli + .config + .or_else(config::default_config_path) + .ok_or_else(|| { + ConfigError::Invalid("no configuration path resolved; pass --config".to_string()) + })?; + let config = Config::load(&config_path)?; + config.validate()?; + + if cli.dry_run { + print!("{}", config.describe()); + return Ok(()); + } + + run_daemon(config).await +} + +async fn run_daemon(config: Config) -> Result<(), CatHubError> { + let (state, inbox) = StateHandle::new(256); + let reply_timeout = Duration::from_millis(config.radio.reply_timeout_ms); + + let backend: Arc = match config.radio.backend { + BackendKind::Ts590 => { + let (radio, inbox) = radio::channel(64); + let port = serial2_tokio::SerialPort::open(&config.radio.port, config.radio.baud) + .map_err(|e| { + BackendError::Transport(format!( + "opening radio port {}: {e}", + config.radio.port + )) + })?; + tokio::spawn(run_radio_task(port, inbox, reply_timeout)); + // Disable the radio's auto-information so unsolicited frames can't + // desynchronize the polled request/reply stream. + if let Err(error) = radio.send(b"AI0;".to_vec(), false).await { + tracing::warn!(%error, "failed to disable radio auto-information"); + } + Arc::new(Ts590Backend::new(radio)) + } + BackendKind::Loopback => Arc::new(LoopbackBackend::new()), + }; + + tokio::spawn(run_mutation_dispatcher( + inbox, + backend.clone(), + state.clone(), + )); + tokio::spawn(run_poller( + backend.clone(), + state.clone(), + Duration::from_millis(config.poll.baseline_ms), + )); + + // Prime the cache with one poll so early client reads see real radio state + // instead of defaults. Best-effort: the baseline poller retries on failure. + if let Err(error) = backend.poll(&state).await { + tracing::warn!(%error, "initial poll failed; serving defaults until next poll"); + } + + for face in &config.faces { + let ctx = FaceContext::new( + face.name.clone(), + face.permissions(), + state.clone(), + backend.clone(), + ); + let dialect: Arc = match face.dialect { + DialectKind::Ts590 => Arc::new(Ts590Dialect::new()), + }; + let port = serial_face::open_serial(&face.name, &face.port, face.baud)?; + let name = face.name.clone(); + let changes = state.subscribe(); + tokio::spawn(async move { + if let Err(error) = serial_face::run_face(port, dialect, ctx, changes).await { + tracing::error!(%error, face = %name, "face stopped"); + } + }); + } + + tracing::info!("cathub running; press Ctrl+C to stop"); + tokio::signal::ctrl_c().await?; + tracing::info!("shutdown requested; releasing PTT"); + let release = state.apply_mutation(StateMutation::Ptt { keyed: false }); + match tokio::time::timeout(Duration::from_millis(500), release).await { + Ok(Ok(())) => {} + Ok(Err(error)) => tracing::warn!(%error, "failed to release PTT on shutdown"), + Err(_) => tracing::warn!("timed out releasing PTT on shutdown"), + } + Ok(()) +} diff --git a/crates/cathub/src/main.rs b/crates/cathub/src/main.rs new file mode 100644 index 0000000..206fb4e --- /dev/null +++ b/crates/cathub/src/main.rs @@ -0,0 +1,25 @@ +//! Thin binary entry point for the CAT hub daemon: initialize logging, parse +//! arguments, and run the daemon until shutdown. + +use clap::Parser as _; +use qsoripper_cathub::{run, Cli}; +use tracing_subscriber::EnvFilter; + +#[tokio::main] +async fn main() -> std::process::ExitCode { + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")), + ) + .with_writer(std::io::stderr) + .init(); + + let cli = Cli::parse(); + match run(cli).await { + Ok(()) => std::process::ExitCode::SUCCESS, + Err(error) => { + tracing::error!(%error, "cathub failed"); + std::process::ExitCode::FAILURE + } + } +} diff --git a/crates/cathub/src/model.rs b/crates/cathub/src/model.rs new file mode 100644 index 0000000..ccaa1dc --- /dev/null +++ b/crates/cathub/src/model.rs @@ -0,0 +1,112 @@ +//! Core domain enums shared across the hub: VFO selection and operating mode. + +use std::fmt; + +/// Identifies one of the radio's two VFOs. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) enum Vfo { + /// VFO A. + A, + /// VFO B. + B, +} + +impl fmt::Display for Vfo { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Vfo::A => f.write_str("A"), + Vfo::B => f.write_str("B"), + } + } +} + +/// Operating mode, normalized across radio families. +/// +/// The numeric encoding mirrors the Kenwood `MD` command digits so the TS-590 +/// backend maps modes with a simple table while remaining a backend-independent +/// universal value for dialects. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) enum Mode { + /// Lower sideband. + Lsb, + /// Upper sideband. + Usb, + /// CW (normal). + Cw, + /// FM. + Fm, + /// AM. + Am, + /// FSK / RTTY. + Fsk, + /// CW reverse. + CwR, + /// FSK reverse. + FskR, +} + +impl Mode { + /// Render the mode as its Kenwood `MD` digit. + pub(crate) fn to_kenwood_digit(self) -> char { + match self { + Mode::Lsb => '1', + Mode::Usb => '2', + Mode::Cw => '3', + Mode::Fm => '4', + Mode::Am => '5', + Mode::Fsk => '6', + Mode::CwR => '7', + Mode::FskR => '9', + } + } + + /// Parse a Kenwood `MD` digit into a mode. + pub(crate) fn from_kenwood_digit(digit: char) -> Option { + match digit { + '1' => Some(Mode::Lsb), + '2' => Some(Mode::Usb), + '3' => Some(Mode::Cw), + '4' => Some(Mode::Fm), + '5' => Some(Mode::Am), + '6' => Some(Mode::Fsk), + '7' => Some(Mode::CwR), + '9' => Some(Mode::FskR), + _ => None, + } + } +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)] +mod tests { + use super::*; + + #[test] + fn kenwood_mode_digits_round_trip() { + for mode in [ + Mode::Lsb, + Mode::Usb, + Mode::Cw, + Mode::Fm, + Mode::Am, + Mode::Fsk, + Mode::CwR, + Mode::FskR, + ] { + let digit = mode.to_kenwood_digit(); + assert_eq!(Mode::from_kenwood_digit(digit), Some(mode)); + } + } + + #[test] + fn unknown_kenwood_digit_is_none() { + assert_eq!(Mode::from_kenwood_digit('0'), None); + assert_eq!(Mode::from_kenwood_digit('8'), None); + } + + #[test] + fn vfo_display_renders_letter() { + assert_eq!(Vfo::A.to_string(), "A"); + assert_eq!(Vfo::B.to_string(), "B"); + } +} diff --git a/crates/cathub/src/poller.rs b/crates/cathub/src/poller.rs new file mode 100644 index 0000000..3ce6c6e --- /dev/null +++ b/crates/cathub/src/poller.rs @@ -0,0 +1,45 @@ +//! Baseline poll task. Drives the active backend to refresh universal state on +//! a fixed cadence so client reads can be served from cache between polls. + +use std::sync::Arc; +use std::time::Duration; + +use crate::backend::RadioBackend; +use crate::state::StateHandle; + +/// Run the baseline poller until cancelled, refreshing state every `interval`. +pub(crate) async fn run_poller( + backend: Arc, + state: StateHandle, + interval: Duration, +) { + let mut ticker = tokio::time::interval(interval); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + ticker.tick().await; + if let Err(error) = backend.poll(&state).await { + tracing::warn!(%error, "baseline poll failed"); + } + } +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)] +mod tests { + use super::*; + use crate::backend::loopback::LoopbackBackend; + + #[tokio::test] + async fn poller_refreshes_state() { + let (state, _inbox) = StateHandle::new(16); + let backend: Arc = Arc::new(LoopbackBackend::new()); + let task = tokio::spawn(run_poller( + backend, + state.clone(), + Duration::from_millis(10), + )); + tokio::time::sleep(Duration::from_millis(40)).await; + assert_eq!(state.snapshot().await.freq_a, 14_074_000); + task.abort(); + } +} diff --git a/crates/cathub/src/radio.rs b/crates/cathub/src/radio.rs new file mode 100644 index 0000000..9fb254e --- /dev/null +++ b/crates/cathub/src/radio.rs @@ -0,0 +1,186 @@ +//! The radio task: the single owner of the serial transport. All radio access +//! is serialized through its command inbox so no two clients ever interleave +//! bytes on the wire. The task is generic over the transport so tests drive it +//! with an in-memory `tokio::io::duplex` pipe instead of a real port. + +use std::time::Duration; + +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; +use tokio::sync::{mpsc, oneshot}; + +use crate::error::BackendError; + +/// Kenwood-family command/reply terminator. +const TERMINATOR: u8 = b';'; + +/// One serialized radio command and its reply slot. +pub(crate) struct RadioCommand { + /// Raw bytes to write to the radio (already terminated). + pub(crate) bytes: Vec, + /// Whether to wait for and return a reply frame. + pub(crate) expect_reply: bool, + /// Where to deliver the reply (or error). + pub(crate) reply: oneshot::Sender, BackendError>>, +} + +/// Cloneable handle used by backends to submit commands to the radio task. +#[derive(Clone)] +pub(crate) struct RadioHandle { + tx: mpsc::Sender, +} + +impl RadioHandle { + /// Submit a command, optionally waiting for a terminated reply frame. + pub(crate) async fn send( + &self, + bytes: Vec, + expect_reply: bool, + ) -> Result, BackendError> { + let (reply, rx) = oneshot::channel(); + self.tx + .send(RadioCommand { + bytes, + expect_reply, + reply, + }) + .await + .map_err(|_| BackendError::Transport("radio task stopped".to_string()))?; + rx.await + .map_err(|_| BackendError::Transport("radio task dropped reply".to_string()))? + } +} + +/// Create a radio command channel, returning the client handle and the inbox +/// receiver to hand to [`run_radio_task`]. +pub(crate) fn channel(capacity: usize) -> (RadioHandle, mpsc::Receiver) { + let (tx, rx) = mpsc::channel(capacity); + (RadioHandle { tx }, rx) +} + +/// Run the radio task over the given transport until the inbox is closed. +/// +/// Commands are serviced strictly in order. For commands that expect a reply, +/// bytes are read until the Kenwood terminator or the per-command timeout. +pub(crate) async fn run_radio_task( + mut transport: T, + mut inbox: mpsc::Receiver, + reply_timeout: Duration, +) where + T: AsyncRead + AsyncWrite + Unpin, +{ + while let Some(command) = inbox.recv().await { + let result = service_command(&mut transport, &command, reply_timeout).await; + let _ = command.reply.send(result); + } +} + +async fn service_command( + transport: &mut T, + command: &RadioCommand, + reply_timeout: Duration, +) -> Result, BackendError> +where + T: AsyncRead + AsyncWrite + Unpin, +{ + transport + .write_all(&command.bytes) + .await + .map_err(|e| BackendError::Transport(e.to_string()))?; + transport + .flush() + .await + .map_err(|e| BackendError::Transport(e.to_string()))?; + + if !command.expect_reply { + return Ok(Vec::new()); + } + + match tokio::time::timeout(reply_timeout, read_frame(transport)).await { + Ok(result) => result, + Err(_) => Err(BackendError::Timeout(format!( + "no reply within {reply_timeout:?}" + ))), + } +} + +async fn read_frame(transport: &mut T) -> Result, BackendError> +where + T: AsyncRead + Unpin, +{ + let mut frame = Vec::new(); + loop { + let byte = transport + .read_u8() + .await + .map_err(|e| BackendError::Transport(e.to_string()))?; + frame.push(byte); + if byte == TERMINATOR { + return Ok(frame); + } + } +} + +#[cfg(test)] +#[allow( + clippy::expect_used, + clippy::unwrap_used, + clippy::indexing_slicing, + clippy::unused_async +)] +mod tests { + use super::*; + + async fn spawn_with_fake() -> (RadioHandle, tokio::io::DuplexStream) { + let (handle, inbox) = channel(8); + let (client_side, radio_side) = tokio::io::duplex(1024); + tokio::spawn(run_radio_task( + radio_side, + inbox, + Duration::from_millis(200), + )); + (handle, client_side) + } + + #[tokio::test] + async fn command_with_reply_reads_until_terminator() { + let (handle, mut fake) = spawn_with_fake().await; + let writer = tokio::spawn(async move { + let mut got = vec![0u8; 3]; + fake.read_exact(&mut got).await.expect("read request"); + assert_eq!(&got, b"FA;"); + fake.write_all(b"FA00014074000;") + .await + .expect("write reply"); + fake.flush().await.expect("flush"); + }); + let reply = handle.send(b"FA;".to_vec(), true).await.expect("reply"); + assert_eq!(reply, b"FA00014074000;".to_vec()); + writer.await.expect("writer task"); + } + + #[tokio::test] + async fn command_without_reply_returns_empty() { + let (handle, mut fake) = spawn_with_fake().await; + let reader = tokio::spawn(async move { + let mut got = vec![0u8; 14]; + fake.read_exact(&mut got).await.expect("read request"); + assert_eq!(&got, b"FA00021074000;"); + }); + let reply = handle + .send(b"FA00021074000;".to_vec(), false) + .await + .expect("ok"); + assert!(reply.is_empty()); + reader.await.expect("reader task"); + } + + #[tokio::test] + async fn missing_reply_times_out() { + let (handle, _fake) = spawn_with_fake().await; + let err = handle + .send(b"FA;".to_vec(), true) + .await + .expect_err("timeout"); + assert!(matches!(err, BackendError::Timeout(_))); + } +} diff --git a/crates/cathub/src/serial_face.rs b/crates/cathub/src/serial_face.rs new file mode 100644 index 0000000..c21bad8 --- /dev/null +++ b/crates/cathub/src/serial_face.rs @@ -0,0 +1,201 @@ +//! A serial face: one virtual COM port (or in-memory transport) bound to a +//! client dialect. Reads client commands, frames them on the Kenwood +//! terminator, dispatches them to the dialect, writes replies, and pushes +//! auto-information notifications when the face has AI enabled. + +use std::sync::Arc; + +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; +use tokio::sync::broadcast; +use tokio::sync::broadcast::error::RecvError; + +use crate::dialect::{ClientDialect, FaceContext}; +use crate::error::FaceError; +use crate::state::StateChange; + +/// Kenwood command terminator used for framing. +const TERMINATOR: u8 = b';'; + +/// Open a virtual serial port for a face. +pub(crate) fn open_serial( + name: &str, + port: &str, + baud: u32, +) -> Result { + serial2_tokio::SerialPort::open(port, baud).map_err(|error| FaceError::Bind { + name: name.to_string(), + endpoint: port.to_string(), + message: error.to_string(), + }) +} + +/// Drive one face over the given transport until the client disconnects. +/// +/// `changes` is the caller-provided change subscription; subscribing before the +/// task is spawned guarantees no state change is missed between spawn and the +/// first poll of the receiver. +pub(crate) async fn run_face( + mut transport: T, + dialect: Arc, + ctx: FaceContext, + mut changes: broadcast::Receiver, +) -> Result<(), FaceError> +where + T: AsyncRead + AsyncWrite + Unpin, +{ + let mut pending = Vec::new(); + let mut chunk = [0u8; 256]; + + loop { + tokio::select! { + read = transport.read(&mut chunk) => { + let n = read.map_err(|source| FaceError::Io { + name: ctx.name().to_string(), + source, + })?; + if n == 0 { + return Ok(()); + } + if let Some(slice) = chunk.get(..n) { + pending.extend_from_slice(slice); + } + drain_frames(&mut transport, &mut pending, dialect.as_ref(), &ctx).await?; + } + change = changes.recv() => { + match change { + Ok(change) => { + if let Some(frame) = dialect.format_notification(&change, &ctx) { + write_all(&mut transport, &frame, &ctx).await?; + } + } + Err(RecvError::Lagged(_)) => {} + Err(RecvError::Closed) => return Ok(()), + } + } + } + } +} + +async fn drain_frames( + transport: &mut T, + pending: &mut Vec, + dialect: &dyn ClientDialect, + ctx: &FaceContext, +) -> Result<(), FaceError> +where + T: AsyncWrite + Unpin, +{ + while let Some(pos) = pending.iter().position(|&b| b == TERMINATOR) { + let frame: Vec = pending.drain(..=pos).collect(); + let reply = dialect.handle(&frame, ctx).await; + if !reply.is_empty() { + write_all(transport, &reply, ctx).await?; + } + } + Ok(()) +} + +async fn write_all(transport: &mut T, bytes: &[u8], ctx: &FaceContext) -> Result<(), FaceError> +where + T: AsyncWrite + Unpin, +{ + transport + .write_all(bytes) + .await + .map_err(|source| FaceError::Io { + name: ctx.name().to_string(), + source, + })?; + transport.flush().await.map_err(|source| FaceError::Io { + name: ctx.name().to_string(), + source, + }) +} + +#[cfg(test)] +#[allow( + clippy::expect_used, + clippy::unwrap_used, + clippy::indexing_slicing, + clippy::unused_async +)] +mod tests { + use super::*; + use crate::backend::loopback::LoopbackBackend; + use crate::dialect::kenwood::Ts590Dialect; + use crate::dialect::Permissions; + use crate::model::Vfo; + use crate::state::{run_mutation_dispatcher, StateHandle}; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + #[tokio::test] + async fn face_answers_read_and_applies_write() { + let (state, inbox) = StateHandle::new(16); + let backend = Arc::new(LoopbackBackend::new()); + let dispatcher = tokio::spawn(run_mutation_dispatcher( + inbox, + backend.clone(), + state.clone(), + )); + state.set_frequency(Vfo::A, 14_074_000).await; + + let ctx = FaceContext::new( + "n1mm", + Permissions::default(), + state.clone(), + backend.clone(), + ); + let dialect: Arc = Arc::new(Ts590Dialect::new()); + let (mut client, face_side) = tokio::io::duplex(1024); + let changes = state.subscribe(); + let face = tokio::spawn(run_face(face_side, dialect, ctx, changes)); + + client.write_all(b"FA;").await.expect("write read cmd"); + let mut reply = vec![0u8; 14]; + client.read_exact(&mut reply).await.expect("read reply"); + assert_eq!(&reply, b"FA00014074000;"); + + client + .write_all(b"FA00021074000;") + .await + .expect("write set cmd"); + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + assert_eq!(state.snapshot().await.freq_a, 21_074_000); + + drop(client); + face.await.expect("face task").expect("face ok"); + dispatcher.abort(); + } + + #[tokio::test] + async fn ai_subscribed_face_receives_notifications() { + let (state, inbox) = StateHandle::new(16); + let backend = Arc::new(LoopbackBackend::new()); + let dispatcher = tokio::spawn(run_mutation_dispatcher( + inbox, + backend.clone(), + state.clone(), + )); + + let ctx = FaceContext::new( + "n1mm", + Permissions::default(), + state.clone(), + backend.clone(), + ); + ctx.set_ai_enabled(true); + let dialect: Arc = Arc::new(Ts590Dialect::new()); + let (mut client, face_side) = tokio::io::duplex(1024); + let changes = state.subscribe(); + let face = tokio::spawn(run_face(face_side, dialect, ctx, changes)); + + state.set_frequency(Vfo::A, 18_100_000).await; + let mut reply = vec![0u8; 14]; + client.read_exact(&mut reply).await.expect("notification"); + assert_eq!(&reply, b"FA00018100000;"); + + drop(client); + face.await.expect("face task").expect("face ok"); + dispatcher.abort(); + } +} diff --git a/crates/cathub/src/state.rs b/crates/cathub/src/state.rs new file mode 100644 index 0000000..eaa7106 --- /dev/null +++ b/crates/cathub/src/state.rs @@ -0,0 +1,236 @@ +//! Universal in-memory rig state, the mutation dispatch channel, and the +//! change-notification broadcast. +//! +//! Every path that observes or changes the radio goes through this layer: +//! backends populate it, dialects read and mutate it, and the poller refreshes +//! it. Faces subscribe to [`StateHandle::subscribe`] to push updates to +//! auto-information clients. + +use std::sync::Arc; + +use tokio::sync::{broadcast, mpsc, oneshot, RwLock}; + +use crate::backend::{RadioBackend, StateMutation}; +use crate::error::BackendError; +use crate::model::{Mode, Vfo}; + +/// A change to one universal-state field, broadcast to faces for AI fan-out. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum StateChange { + /// A VFO frequency changed. + Frequency { + /// Affected VFO. + vfo: Vfo, + /// New frequency in Hz. + hz: u64, + }, + /// A VFO mode changed. + Mode { + /// Affected VFO. + vfo: Vfo, + /// New mode. + mode: Mode, + }, +} + +/// The universal rig state snapshot. +#[derive(Debug, Clone, Copy)] +pub(crate) struct RigState { + /// VFO A frequency in Hz. + pub(crate) freq_a: u64, + /// VFO B frequency in Hz. + pub(crate) freq_b: u64, + /// VFO A mode. + pub(crate) mode_a: Mode, + /// VFO B mode. + pub(crate) mode_b: Mode, + /// Active receive VFO. + pub(crate) rx_vfo: Vfo, +} + +impl Default for RigState { + fn default() -> Self { + Self { + freq_a: 0, + freq_b: 0, + mode_a: Mode::Usb, + mode_b: Mode::Usb, + rx_vfo: Vfo::A, + } + } +} + +impl RigState { + /// Frequency for the given VFO. + pub(crate) fn freq(&self, vfo: Vfo) -> u64 { + match vfo { + Vfo::A => self.freq_a, + Vfo::B => self.freq_b, + } + } + + /// Mode for the given VFO. + pub(crate) fn mode(&self, vfo: Vfo) -> Mode { + match vfo { + Vfo::A => self.mode_a, + Vfo::B => self.mode_b, + } + } +} + +/// A request to mutate the radio, carried over the dispatch channel. +struct MutationRequest { + mutation: StateMutation, + reply: oneshot::Sender>, +} + +/// Opaque wrapper around the mutation receiver so the channel payload stays +/// private while the receiver can be handed to the dispatcher. +pub(crate) struct MutationInbox { + rx: mpsc::Receiver, +} + +/// Shared, cloneable handle to the universal rig state. +#[derive(Clone)] +pub(crate) struct StateHandle { + inner: Arc>, + changes: broadcast::Sender, + mutations: mpsc::Sender, +} + +impl StateHandle { + /// Create a state handle plus the receiver half of the mutation channel. + /// + /// The caller runs [`run_mutation_dispatcher`] with the returned inbox and + /// the active backend to service [`StateHandle::apply_mutation`] calls. + pub(crate) fn new(broadcast_capacity: usize) -> (Self, MutationInbox) { + let (changes, _) = broadcast::channel(broadcast_capacity); + let (mutations, rx) = mpsc::channel(64); + let handle = Self { + inner: Arc::new(RwLock::new(RigState::default())), + changes, + mutations, + }; + (handle, MutationInbox { rx }) + } + + /// Subscribe to the change-notification broadcast. + pub(crate) fn subscribe(&self) -> broadcast::Receiver { + self.changes.subscribe() + } + + /// Take a snapshot of the current state. + pub(crate) async fn snapshot(&self) -> RigState { + *self.inner.read().await + } + + fn broadcast(&self, change: StateChange) { + // A send error only means there are no subscribers, which is fine. + let _ = self.changes.send(change); + } + + /// Set a VFO frequency and broadcast the change. + pub(crate) async fn set_frequency(&self, vfo: Vfo, hz: u64) { + { + let mut state = self.inner.write().await; + match vfo { + Vfo::A => state.freq_a = hz, + Vfo::B => state.freq_b = hz, + } + } + self.broadcast(StateChange::Frequency { vfo, hz }); + } + + /// Set a VFO mode and broadcast the change. + pub(crate) async fn set_mode(&self, vfo: Vfo, mode: Mode) { + { + let mut state = self.inner.write().await; + match vfo { + Vfo::A => state.mode_a = mode, + Vfo::B => state.mode_b = mode, + } + } + self.broadcast(StateChange::Mode { vfo, mode }); + } + + /// Submit a mutation for the backend to apply, awaiting the result. + pub(crate) async fn apply_mutation(&self, mutation: StateMutation) -> Result<(), BackendError> { + let (reply, rx) = oneshot::channel(); + self.mutations + .send(MutationRequest { mutation, reply }) + .await + .map_err(|_| BackendError::Transport("mutation dispatcher stopped".to_string()))?; + rx.await + .map_err(|_| BackendError::Transport("mutation dispatcher dropped reply".to_string()))? + } +} + +/// Run the mutation dispatcher: pull mutation requests and apply them through +/// the backend, which updates the shared state on success. +pub(crate) async fn run_mutation_dispatcher( + mut inbox: MutationInbox, + backend: Arc, + state: StateHandle, +) { + while let Some(request) = inbox.rx.recv().await { + let result = backend.apply(request.mutation, &state).await; + let _ = request.reply.send(result); + } +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)] +mod tests { + use super::*; + use crate::backend::loopback::LoopbackBackend; + + #[tokio::test] + async fn set_frequency_updates_snapshot() { + let (handle, _inbox) = StateHandle::new(16); + handle.set_frequency(Vfo::A, 14_074_000).await; + assert_eq!(handle.snapshot().await.freq_a, 14_074_000); + } + + #[tokio::test] + async fn set_mode_updates_snapshot() { + let (handle, _inbox) = StateHandle::new(16); + handle.set_mode(Vfo::B, Mode::Cw).await; + assert_eq!(handle.snapshot().await.mode_b, Mode::Cw); + } + + #[tokio::test] + async fn subscribers_receive_changes() { + let (handle, _inbox) = StateHandle::new(16); + let mut rx = handle.subscribe(); + handle.set_mode(Vfo::A, Mode::Cw).await; + let change = rx.recv().await.expect("a change"); + assert_eq!( + change, + StateChange::Mode { + vfo: Vfo::A, + mode: Mode::Cw + } + ); + } + + #[tokio::test] + async fn apply_mutation_round_trips_through_backend() { + let (handle, inbox) = StateHandle::new(16); + let backend = Arc::new(LoopbackBackend::new()); + let dispatcher = tokio::spawn(run_mutation_dispatcher( + inbox, + backend.clone(), + handle.clone(), + )); + handle + .apply_mutation(StateMutation::Frequency { + vfo: Vfo::A, + hz: 21_074_000, + }) + .await + .expect("mutation applied"); + assert_eq!(handle.snapshot().await.freq_a, 21_074_000); + assert_eq!(backend.recorded_mutations().len(), 1); + dispatcher.abort(); + } +} diff --git a/crates/cathub/tests/cli.rs b/crates/cathub/tests/cli.rs new file mode 100644 index 0000000..72723b4 --- /dev/null +++ b/crates/cathub/tests/cli.rs @@ -0,0 +1,90 @@ +//! Integration coverage for the public CLI entry point: configuration +//! resolution, the dry-run path, and load/parse error surfaces. + +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use std::path::PathBuf; + +use qsoripper_cathub::{run, Cli}; + +fn temp_config(contents: &str) -> PathBuf { + let unique = format!( + "cathub-test-{}-{}.toml", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time after epoch") + .as_nanos() + ); + let path = std::env::temp_dir().join(unique); + std::fs::write(&path, contents).expect("write temp config"); + path +} + +#[tokio::test] +async fn dry_run_validates_and_returns_ok() { + let path = temp_config( + r#" +[radio] +port = "COM3" +backend = "loopback" + +[[face]] +name = "n1mm" +port = "COM11" +dialect = "ts590" +"#, + ); + let cli = Cli { + config: Some(path.clone()), + log: None, + dry_run: true, + }; + let result = run(cli).await; + std::fs::remove_file(&path).ok(); + assert!(result.is_ok(), "dry-run should succeed: {result:?}"); +} + +#[tokio::test] +async fn missing_config_file_is_an_error() { + let path = std::env::temp_dir().join("cathub-missing-config-should-not-exist.toml"); + std::fs::remove_file(&path).ok(); + let cli = Cli { + config: Some(path), + log: None, + dry_run: true, + }; + assert!(run(cli).await.is_err()); +} + +#[tokio::test] +async fn invalid_config_is_an_error() { + let path = temp_config("this is not valid [[ toml"); + let cli = Cli { + config: Some(path.clone()), + log: None, + dry_run: true, + }; + let result = run(cli).await; + std::fs::remove_file(&path).ok(); + assert!(result.is_err()); +} + +#[tokio::test] +async fn invalid_semantics_are_rejected() { + let path = temp_config( + r#" +[radio] +port = " " +backend = "loopback" +"#, + ); + let cli = Cli { + config: Some(path.clone()), + log: None, + dry_run: true, + }; + let result = run(cli).await; + std::fs::remove_file(&path).ok(); + assert!(result.is_err()); +} From e25f261a6d0e4251cabfca293a6c5574399b0ec4 Mon Sep 17 00:00:00 2001 From: Randy Treit <38894017+rtreit@users.noreply.github.com> Date: Thu, 28 May 2026 17:12:08 -0700 Subject: [PATCH 05/36] Revert "Add qsoripper-cathub phase 1: single-radio CAT hub skeleton (#471)" (#472) This reverts commit 7b492ac60e2f0f972474829ee83efb4a3ed35558. --- crates/cathub/Cargo.toml | 35 --- crates/cathub/src/backend/kenwood/mod.rs | 7 - crates/cathub/src/backend/kenwood/ts590.rs | 257 --------------- crates/cathub/src/backend/loopback.rs | 162 ---------- crates/cathub/src/backend/mod.rs | 51 --- crates/cathub/src/config.rs | 350 --------------------- crates/cathub/src/dialect/kenwood/mod.rs | 6 - crates/cathub/src/dialect/kenwood/ts590.rs | 277 ---------------- crates/cathub/src/dialect/mod.rs | 110 ------- crates/cathub/src/error.rs | 81 ----- crates/cathub/src/lib.rs | 153 --------- crates/cathub/src/main.rs | 25 -- crates/cathub/src/model.rs | 112 ------- crates/cathub/src/poller.rs | 45 --- crates/cathub/src/radio.rs | 186 ----------- crates/cathub/src/serial_face.rs | 201 ------------ crates/cathub/src/state.rs | 236 -------------- crates/cathub/tests/cli.rs | 90 ------ 18 files changed, 2384 deletions(-) delete mode 100644 crates/cathub/Cargo.toml delete mode 100644 crates/cathub/src/backend/kenwood/mod.rs delete mode 100644 crates/cathub/src/backend/kenwood/ts590.rs delete mode 100644 crates/cathub/src/backend/loopback.rs delete mode 100644 crates/cathub/src/backend/mod.rs delete mode 100644 crates/cathub/src/config.rs delete mode 100644 crates/cathub/src/dialect/kenwood/mod.rs delete mode 100644 crates/cathub/src/dialect/kenwood/ts590.rs delete mode 100644 crates/cathub/src/dialect/mod.rs delete mode 100644 crates/cathub/src/error.rs delete mode 100644 crates/cathub/src/lib.rs delete mode 100644 crates/cathub/src/main.rs delete mode 100644 crates/cathub/src/model.rs delete mode 100644 crates/cathub/src/poller.rs delete mode 100644 crates/cathub/src/radio.rs delete mode 100644 crates/cathub/src/serial_face.rs delete mode 100644 crates/cathub/src/state.rs delete mode 100644 crates/cathub/tests/cli.rs diff --git a/crates/cathub/Cargo.toml b/crates/cathub/Cargo.toml deleted file mode 100644 index e2a1d92..0000000 --- a/crates/cathub/Cargo.toml +++ /dev/null @@ -1,35 +0,0 @@ -[package] -name = "qsoripper-cathub" -description = "Multi-client CAT hub daemon that owns the radio serial port and fans it out to multiple clients" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true - -[lib] -name = "qsoripper_cathub" -path = "src/lib.rs" - -[[bin]] -name = "qsoripper-cathub" -path = "src/main.rs" - -[dependencies] -async-trait = { workspace = true } -bytes = { workspace = true } -clap = { workspace = true } -serde = { workspace = true } -thiserror = { workspace = true } -tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync", "io-util", "time", "net", "signal"] } -serial2-tokio = { workspace = true } -toml = { workspace = true } -tracing = { workspace = true } -tracing-appender = { workspace = true } -tracing-subscriber = { workspace = true } - -[dev-dependencies] -tokio = { workspace = true, features = ["test-util"] } - -[lints] -workspace = true diff --git a/crates/cathub/src/backend/kenwood/mod.rs b/crates/cathub/src/backend/kenwood/mod.rs deleted file mode 100644 index 09c1bbc..0000000 --- a/crates/cathub/src/backend/kenwood/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -//! Kenwood CAT backend family. The TS-590 is the first concrete model; other -//! Kenwood radios share the command grammar and slot in via the same parsing -//! and formatting helpers. - -pub(crate) mod ts590; - -pub(crate) use ts590::Ts590Backend; diff --git a/crates/cathub/src/backend/kenwood/ts590.rs b/crates/cathub/src/backend/kenwood/ts590.rs deleted file mode 100644 index c9fc17b..0000000 --- a/crates/cathub/src/backend/kenwood/ts590.rs +++ /dev/null @@ -1,257 +0,0 @@ -//! Kenwood TS-590 backend. Maps universal [`StateMutation`]s to native CAT -//! commands and parses Kenwood replies (`FA`, `FB`, `MD`) back into universal -//! state. Phase 1 wires frequency, mode, and PTT to the wire. - -use async_trait::async_trait; - -use crate::backend::{RadioBackend, StateMutation}; -use crate::error::BackendError; -use crate::model::{Mode, Vfo}; -use crate::radio::RadioHandle; -use crate::state::StateHandle; - -/// Backend driving a Kenwood TS-590 over a serialized radio handle. -pub(crate) struct Ts590Backend { - radio: RadioHandle, -} - -impl Ts590Backend { - /// Construct a TS-590 backend over the given radio handle. - pub(crate) fn new(radio: RadioHandle) -> Self { - Self { radio } - } - - fn freq_set_command(vfo: Vfo, hz: u64) -> Vec { - let verb = match vfo { - Vfo::A => "FA", - Vfo::B => "FB", - }; - format!("{verb}{hz:011};").into_bytes() - } - - fn freq_query_command(vfo: Vfo) -> Vec { - match vfo { - Vfo::A => b"FA;".to_vec(), - Vfo::B => b"FB;".to_vec(), - } - } -} - -#[async_trait] -impl RadioBackend for Ts590Backend { - async fn poll(&self, state: &StateHandle) -> Result<(), BackendError> { - let fa = self - .radio - .send(Self::freq_query_command(Vfo::A), true) - .await?; - if let Some(hz) = parse_freq(&fa, b"FA") { - state.set_frequency(Vfo::A, hz).await; - } - let fb = self - .radio - .send(Self::freq_query_command(Vfo::B), true) - .await?; - if let Some(hz) = parse_freq(&fb, b"FB") { - state.set_frequency(Vfo::B, hz).await; - } - let md = self.radio.send(b"MD;".to_vec(), true).await?; - if let Some(mode) = parse_mode(&md) { - state.set_mode(Vfo::A, mode).await; - } - Ok(()) - } - - async fn apply( - &self, - mutation: StateMutation, - state: &StateHandle, - ) -> Result<(), BackendError> { - match mutation { - StateMutation::Frequency { vfo, hz } => { - self.radio - .send(Self::freq_set_command(vfo, hz), false) - .await?; - state.set_frequency(vfo, hz).await; - } - StateMutation::Mode { vfo, mode } => { - let cmd = format!("MD{};", mode.to_kenwood_digit()).into_bytes(); - self.radio.send(cmd, false).await?; - state.set_mode(vfo, mode).await; - } - StateMutation::Ptt { keyed } => { - let cmd = if keyed { - b"TX;".to_vec() - } else { - b"RX;".to_vec() - }; - self.radio.send(cmd, false).await?; - } - } - Ok(()) - } - - async fn passthrough(&self, raw: &[u8]) -> Result, BackendError> { - self.radio.send(raw.to_vec(), is_query(raw)).await - } -} - -/// Whether a raw command is a query (expects a reply): a verb followed only by -/// the terminator, with no parameter payload. -fn is_query(raw: &[u8]) -> bool { - let trimmed = raw.strip_suffix(b";").unwrap_or(raw); - !trimmed.is_empty() && trimmed.iter().all(u8::is_ascii_alphabetic) -} - -/// Parse a Kenwood frequency frame (`FA`/`FB` + 11 digits + `;`). -fn parse_freq(frame: &[u8], verb: &[u8]) -> Option { - let body = frame.strip_prefix(verb)?; - let digits = body.strip_suffix(b";")?; - if digits.len() != 11 || !digits.iter().all(u8::is_ascii_digit) { - return None; - } - std::str::from_utf8(digits).ok()?.parse::().ok() -} - -/// Parse a Kenwood mode frame (`MD` + 1 digit + `;`). -fn parse_mode(frame: &[u8]) -> Option { - let body = frame.strip_prefix(b"MD")?; - let digits = body.strip_suffix(b";")?; - let &[digit] = digits else { - return None; - }; - Mode::from_kenwood_digit(digit as char) -} - -#[cfg(test)] -#[allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)] -mod tests { - use super::*; - use crate::radio::{self, run_radio_task}; - use std::time::Duration; - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - - fn backend_with_fake() -> (Ts590Backend, tokio::io::DuplexStream) { - let (handle, inbox) = radio::channel(8); - let (client, radio_side) = tokio::io::duplex(1024); - tokio::spawn(run_radio_task( - radio_side, - inbox, - Duration::from_millis(200), - )); - (Ts590Backend::new(handle), client) - } - - #[test] - fn parse_freq_reads_eleven_digits() { - assert_eq!(parse_freq(b"FA00014074000;", b"FA"), Some(14_074_000)); - assert_eq!(parse_freq(b"FB00007030000;", b"FB"), Some(7_030_000)); - assert_eq!(parse_freq(b"FA0001407400;", b"FA"), None); - assert_eq!(parse_freq(b"MD2;", b"FA"), None); - } - - #[test] - fn parse_mode_reads_single_digit() { - assert_eq!(parse_mode(b"MD2;"), Some(Mode::Usb)); - assert_eq!(parse_mode(b"MD3;"), Some(Mode::Cw)); - assert_eq!(parse_mode(b"MD;"), None); - } - - #[test] - fn is_query_distinguishes_reads_from_writes() { - assert!(is_query(b"FA;")); - assert!(is_query(b"MD;")); - assert!(!is_query(b"FA00014074000;")); - assert!(!is_query(b"MD2;")); - } - - #[test] - fn freq_set_command_zero_pads() { - assert_eq!( - Ts590Backend::freq_set_command(Vfo::A, 14_074_000), - b"FA00014074000;".to_vec() - ); - assert_eq!( - Ts590Backend::freq_set_command(Vfo::B, 7_030_000), - b"FB00007030000;".to_vec() - ); - } - - #[tokio::test] - async fn apply_freq_writes_native_command_and_records_state() { - let (backend, mut fake) = backend_with_fake(); - let reader = tokio::spawn(async move { - let mut got = vec![0u8; 14]; - fake.read_exact(&mut got).await.expect("read set"); - assert_eq!(&got, b"FA00021074000;"); - }); - let (handle, _inbox) = StateHandle::new(16); - backend - .apply( - StateMutation::Frequency { - vfo: Vfo::A, - hz: 21_074_000, - }, - &handle, - ) - .await - .expect("apply"); - assert_eq!(handle.snapshot().await.freq_a, 21_074_000); - reader.await.expect("reader"); - } - - #[tokio::test] - async fn poll_parses_replies_into_state() { - let (backend, mut fake) = backend_with_fake(); - let responder = tokio::spawn(async move { - let mut buf = vec![0u8; 3]; - fake.read_exact(&mut buf).await.expect("FA?"); - assert_eq!(&buf, b"FA;"); - fake.write_all(b"FA00014074000;").await.expect("FA reply"); - let mut buf = vec![0u8; 3]; - fake.read_exact(&mut buf).await.expect("FB?"); - assert_eq!(&buf, b"FB;"); - fake.write_all(b"FB00007030000;").await.expect("FB reply"); - let mut buf = vec![0u8; 3]; - fake.read_exact(&mut buf).await.expect("MD?"); - assert_eq!(&buf, b"MD;"); - fake.write_all(b"MD3;").await.expect("MD reply"); - }); - let (handle, _inbox) = StateHandle::new(16); - backend.poll(&handle).await.expect("poll"); - let snap = handle.snapshot().await; - assert_eq!(snap.freq_a, 14_074_000); - assert_eq!(snap.freq_b, 7_030_000); - assert_eq!(snap.mode_a, Mode::Cw); - responder.await.expect("responder"); - } - - #[tokio::test] - async fn ptt_keys_the_transmitter() { - let (backend, mut fake) = backend_with_fake(); - let reader = tokio::spawn(async move { - let mut buf = vec![0u8; 3]; - fake.read_exact(&mut buf).await.expect("TX"); - assert_eq!(&buf, b"TX;"); - }); - let (handle, _inbox) = StateHandle::new(16); - backend - .apply(StateMutation::Ptt { keyed: true }, &handle) - .await - .expect("apply ptt"); - reader.await.expect("reader"); - } - - #[tokio::test] - async fn passthrough_query_returns_reply() { - let (backend, mut fake) = backend_with_fake(); - let responder = tokio::spawn(async move { - let mut buf = vec![0u8; 3]; - fake.read_exact(&mut buf).await.expect("PS?"); - assert_eq!(&buf, b"PS;"); - fake.write_all(b"PS1;").await.expect("PS reply"); - }); - let reply = backend.passthrough(b"PS;").await.expect("passthrough"); - assert_eq!(reply, b"PS1;".to_vec()); - responder.await.expect("responder"); - } -} diff --git a/crates/cathub/src/backend/loopback.rs b/crates/cathub/src/backend/loopback.rs deleted file mode 100644 index c866fc2..0000000 --- a/crates/cathub/src/backend/loopback.rs +++ /dev/null @@ -1,162 +0,0 @@ -//! In-memory backend used by tests and `--dry-run`. Records every mutation and -//! passthrough, reflects mutations into the universal state, and serves canned -//! poll data without touching hardware. - -#[cfg(test)] -use std::sync::Mutex; - -use async_trait::async_trait; - -use crate::backend::{RadioBackend, StateMutation}; -use crate::error::BackendError; -use crate::model::{Mode, Vfo}; -use crate::state::StateHandle; - -/// A canned poll snapshot the loopback backend reports. -#[derive(Debug, Clone, Copy)] -pub(crate) struct CannedPoll { - /// VFO A frequency in Hz. - pub(crate) freq_a: u64, - /// VFO B frequency in Hz. - pub(crate) freq_b: u64, - /// VFO A mode. - pub(crate) mode_a: Mode, - /// VFO B mode. - pub(crate) mode_b: Mode, -} - -impl Default for CannedPoll { - fn default() -> Self { - Self { - freq_a: 14_074_000, - freq_b: 14_074_000, - mode_a: Mode::Usb, - mode_b: Mode::Usb, - } - } -} - -/// Backend that records interactions instead of driving a radio. -pub(crate) struct LoopbackBackend { - canned: CannedPoll, - #[cfg(test)] - mutations: Mutex>, - #[cfg(test)] - passthroughs: Mutex>>, -} - -impl LoopbackBackend { - /// Create a loopback backend with default canned poll data. - pub(crate) fn new() -> Self { - Self { - canned: CannedPoll::default(), - #[cfg(test)] - mutations: Mutex::new(Vec::new()), - #[cfg(test)] - passthroughs: Mutex::new(Vec::new()), - } - } - - /// Snapshot of mutations recorded so far (test introspection only). - #[cfg(test)] - pub(crate) fn recorded_mutations(&self) -> Vec { - self.mutations.lock().map(|g| g.clone()).unwrap_or_default() - } - - /// Snapshot of passthrough payloads recorded so far (test introspection only). - #[cfg(test)] - pub(crate) fn recorded_passthroughs(&self) -> Vec> { - self.passthroughs - .lock() - .map(|g| g.clone()) - .unwrap_or_default() - } - - #[cfg(test)] - fn record_mutation(&self, mutation: StateMutation) { - if let Ok(mut guard) = self.mutations.lock() { - guard.push(mutation); - } - } - - #[cfg(not(test))] - #[allow(clippy::unused_self)] - fn record_mutation(&self, _mutation: StateMutation) {} -} - -#[async_trait] -impl RadioBackend for LoopbackBackend { - async fn poll(&self, state: &StateHandle) -> Result<(), BackendError> { - state.set_frequency(Vfo::A, self.canned.freq_a).await; - state.set_frequency(Vfo::B, self.canned.freq_b).await; - state.set_mode(Vfo::A, self.canned.mode_a).await; - state.set_mode(Vfo::B, self.canned.mode_b).await; - Ok(()) - } - - async fn apply( - &self, - mutation: StateMutation, - state: &StateHandle, - ) -> Result<(), BackendError> { - self.record_mutation(mutation); - match mutation { - StateMutation::Frequency { vfo, hz } => state.set_frequency(vfo, hz).await, - StateMutation::Mode { vfo, mode } => state.set_mode(vfo, mode).await, - StateMutation::Ptt { .. } => {} - } - Ok(()) - } - - async fn passthrough(&self, raw: &[u8]) -> Result, BackendError> { - #[cfg(test)] - { - if let Ok(mut guard) = self.passthroughs.lock() { - guard.push(raw.to_vec()); - } - } - #[cfg(not(test))] - let _ = raw; - Ok(Vec::new()) - } -} - -#[cfg(test)] -#[allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)] -mod tests { - use super::*; - - #[tokio::test] - async fn poll_populates_state_from_canned_data() { - let (handle, _inbox) = StateHandle::new(16); - let backend = LoopbackBackend::new(); - backend.poll(&handle).await.expect("poll"); - assert_eq!(handle.snapshot().await.freq_a, 14_074_000); - } - - #[tokio::test] - async fn apply_records_and_reflects_mutation() { - let (handle, _inbox) = StateHandle::new(16); - let backend = LoopbackBackend::new(); - backend - .apply( - StateMutation::Mode { - vfo: Vfo::A, - mode: Mode::Cw, - }, - &handle, - ) - .await - .expect("apply"); - assert_eq!(handle.snapshot().await.mode_a, Mode::Cw); - assert_eq!(backend.recorded_mutations().len(), 1); - } - - #[tokio::test] - async fn passthrough_records_raw_bytes() { - let backend = LoopbackBackend::new(); - let reply = backend.passthrough(b"FA;").await.expect("passthrough"); - assert!(reply.is_empty()); - assert_eq!(backend.recorded_passthroughs(), vec![b"FA;".to_vec()]); - } -} diff --git a/crates/cathub/src/backend/mod.rs b/crates/cathub/src/backend/mod.rs deleted file mode 100644 index eb82337..0000000 --- a/crates/cathub/src/backend/mod.rs +++ /dev/null @@ -1,51 +0,0 @@ -//! Radio backend abstraction: the seam that lets the hub drive different radio -//! families (Kenwood, Icom CI-V, Yaesu) behind one trait. - -pub(crate) mod kenwood; -pub(crate) mod loopback; - -use async_trait::async_trait; - -use crate::error::BackendError; -use crate::model::{Mode, Vfo}; -use crate::state::StateHandle; - -/// A single normalized change to apply to the radio. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum StateMutation { - /// Set a VFO frequency in Hz. - Frequency { - /// Target VFO. - vfo: Vfo, - /// Frequency in Hz. - hz: u64, - }, - /// Set a VFO mode. - Mode { - /// Target VFO. - vfo: Vfo, - /// Mode to set. - mode: Mode, - }, - /// Key or unkey the transmitter. - Ptt { - /// Whether the transmitter should be keyed. - keyed: bool, - }, -} - -/// A radio family backend. Implementations own the native command vocabulary -/// and the mapping to and from universal [`StateMutation`]s and [`StateHandle`]. -#[async_trait] -pub(crate) trait RadioBackend: Send + Sync { - /// Refresh the universal state by polling the radio. - async fn poll(&self, state: &StateHandle) -> Result<(), BackendError>; - - /// Apply a mutation to the radio and reflect it into the universal state. - async fn apply(&self, mutation: StateMutation, state: &StateHandle) - -> Result<(), BackendError>; - - /// Pass a raw native command straight through to the radio and return the - /// raw reply bytes (possibly empty for no-reply commands). - async fn passthrough(&self, raw: &[u8]) -> Result, BackendError>; -} diff --git a/crates/cathub/src/config.rs b/crates/cathub/src/config.rs deleted file mode 100644 index dc399a7..0000000 --- a/crates/cathub/src/config.rs +++ /dev/null @@ -1,350 +0,0 @@ -//! Configuration schema and loading. The hub is configured from a TOML file -//! describing the radio, the baseline poll cadence, and one or more client -//! faces. Phase 1 supports serial faces; the Hamlib net section lands later. - -use std::fmt::Write as _; -use std::path::{Path, PathBuf}; - -use serde::Deserialize; - -use crate::dialect::Permissions; -use crate::error::ConfigError; - -/// Top-level daemon configuration. -#[derive(Debug, Clone, Deserialize)] -#[serde(deny_unknown_fields)] -pub(crate) struct Config { - /// Radio transport settings. - pub(crate) radio: RadioConfig, - /// Baseline polling settings. - #[serde(default)] - pub(crate) poll: PollConfig, - /// Client faces. - #[serde(default, rename = "face")] - pub(crate) faces: Vec, -} - -/// Which radio backend family to drive. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] -#[serde(rename_all = "lowercase")] -pub(crate) enum BackendKind { - /// Kenwood TS-590. - Ts590, - /// In-memory loopback backend (testing and `--dry-run`). - Loopback, -} - -/// Which client dialect a face speaks. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] -#[serde(rename_all = "lowercase")] -pub(crate) enum DialectKind { - /// Native Kenwood TS-590. - Ts590, -} - -/// Radio transport configuration. -#[derive(Debug, Clone, Deserialize)] -#[serde(deny_unknown_fields)] -pub(crate) struct RadioConfig { - /// Serial port path (for example `COM3` or `/dev/ttyUSB0`). - pub(crate) port: String, - /// Serial baud rate. - #[serde(default = "default_baud")] - pub(crate) baud: u32, - /// Backend family. - pub(crate) backend: BackendKind, - /// Per-command reply timeout in milliseconds. - #[serde(default = "default_reply_timeout_ms")] - pub(crate) reply_timeout_ms: u64, -} - -/// Baseline polling configuration. -#[derive(Debug, Clone, Deserialize)] -#[serde(deny_unknown_fields)] -pub(crate) struct PollConfig { - /// Baseline poll interval in milliseconds. - #[serde(default = "default_baseline_ms")] - pub(crate) baseline_ms: u64, -} - -impl Default for PollConfig { - fn default() -> Self { - Self { - baseline_ms: default_baseline_ms(), - } - } -} - -/// One client face. -#[derive(Debug, Clone, Deserialize)] -#[serde(deny_unknown_fields)] -pub(crate) struct FaceConfig { - /// Face name (used in logs and as PTT owner identity). - pub(crate) name: String, - /// Serial port path the client connects to. - pub(crate) port: String, - /// Serial baud rate. - #[serde(default = "default_baud")] - pub(crate) baud: u32, - /// Dialect this face speaks. - pub(crate) dialect: DialectKind, - /// Whether the face may change frequency/mode/split. - #[serde(default = "default_true")] - pub(crate) allow_write: bool, - /// Whether the face may key PTT. - #[serde(default)] - pub(crate) allow_ptt: bool, - /// Whether the face may send raw passthrough commands. - #[serde(default = "default_true")] - pub(crate) allow_passthrough: bool, -} - -impl FaceConfig { - /// Permissions derived from this face configuration. - pub(crate) fn permissions(&self) -> Permissions { - Permissions { - allow_write: self.allow_write, - allow_ptt: self.allow_ptt, - allow_passthrough: self.allow_passthrough, - } - } -} - -impl Config { - /// Load and parse configuration from a TOML file. - pub(crate) fn load(path: &Path) -> Result { - let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Read { - path: path.display().to_string(), - source, - })?; - Self::parse(&text) - } - - /// Parse configuration from a TOML string. - pub(crate) fn parse(text: &str) -> Result { - toml::from_str(text).map_err(|e| ConfigError::Parse(e.to_string())) - } - - /// Validate semantic constraints not expressible in the schema. - pub(crate) fn validate(&self) -> Result<(), ConfigError> { - if self.radio.port.trim().is_empty() { - return Err(ConfigError::Invalid( - "radio.port must not be empty".to_string(), - )); - } - if self.radio.reply_timeout_ms == 0 { - return Err(ConfigError::Invalid( - "radio.reply_timeout_ms must be greater than zero".to_string(), - )); - } - if self.poll.baseline_ms == 0 { - return Err(ConfigError::Invalid( - "poll.baseline_ms must be greater than zero".to_string(), - )); - } - for face in &self.faces { - if face.name.trim().is_empty() { - return Err(ConfigError::Invalid( - "face.name must not be empty".to_string(), - )); - } - if face.port.trim().is_empty() { - return Err(ConfigError::Invalid(format!( - "face '{}' port must not be empty", - face.name - ))); - } - } - Ok(()) - } - - /// Render a human-readable summary of the resolved configuration. - pub(crate) fn describe(&self) -> String { - let mut out = String::new(); - let _ = writeln!( - out, - "radio: port={} baud={} backend={:?} reply_timeout_ms={}", - self.radio.port, self.radio.baud, self.radio.backend, self.radio.reply_timeout_ms - ); - let _ = writeln!(out, "poll: baseline_ms={}", self.poll.baseline_ms); - if self.faces.is_empty() { - let _ = writeln!(out, "faces: (none)"); - } - for face in &self.faces { - let _ = writeln!( - out, - "face: name={} port={} baud={} dialect={:?} write={} ptt={} passthrough={}", - face.name, - face.port, - face.baud, - face.dialect, - face.allow_write, - face.allow_ptt, - face.allow_passthrough - ); - } - out - } -} - -/// Resolve the default configuration file path from the environment. -pub(crate) fn default_config_path() -> Option { - if let Some(dir) = std::env::var_os("APPDATA") { - return Some(Path::new(&dir).join("qsoripper").join("cathub.toml")); - } - if let Some(dir) = std::env::var_os("XDG_CONFIG_HOME") { - return Some(Path::new(&dir).join("qsoripper").join("cathub.toml")); - } - if let Some(dir) = std::env::var_os("HOME") { - return Some( - Path::new(&dir) - .join(".config") - .join("qsoripper") - .join("cathub.toml"), - ); - } - None -} - -fn default_baud() -> u32 { - 115_200 -} - -fn default_reply_timeout_ms() -> u64 { - 250 -} - -fn default_baseline_ms() -> u64 { - 500 -} - -fn default_true() -> bool { - true -} - -#[cfg(test)] -#[allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)] -mod tests { - use super::*; - - const SAMPLE: &str = r#" -[radio] -port = "COM3" -baud = 115200 -backend = "ts590" -reply_timeout_ms = 200 - -[poll] -baseline_ms = 400 - -[[face]] -name = "n1mm" -port = "COM11" -dialect = "ts590" -allow_ptt = true -"#; - - #[test] - fn parses_full_config() { - let config = Config::parse(SAMPLE).expect("parse"); - assert_eq!(config.radio.port, "COM3"); - assert_eq!(config.radio.backend, BackendKind::Ts590); - assert_eq!(config.radio.reply_timeout_ms, 200); - assert_eq!(config.poll.baseline_ms, 400); - assert_eq!(config.faces.len(), 1); - let face = &config.faces[0]; - assert_eq!(face.name, "n1mm"); - assert_eq!(face.baud, 115_200); - assert!(face.allow_ptt); - assert!(face.allow_write); - assert!(face.allow_passthrough); - config.validate().expect("valid"); - } - - #[test] - fn defaults_apply_when_omitted() { - let config = Config::parse( - r#" -[radio] -port = "/dev/ttyUSB0" -backend = "loopback" -"#, - ) - .expect("parse"); - assert_eq!(config.radio.baud, 115_200); - assert_eq!(config.radio.reply_timeout_ms, 250); - assert_eq!(config.poll.baseline_ms, 500); - assert!(config.faces.is_empty()); - } - - #[test] - fn unknown_field_is_rejected() { - let err = Config::parse( - r#" -[radio] -port = "COM3" -backend = "ts590" -bogus = true -"#, - ) - .expect_err("should reject unknown field"); - assert!(matches!(err, ConfigError::Parse(_))); - } - - #[test] - fn empty_port_is_invalid() { - let config = Config::parse( - r#" -[radio] -port = " " -backend = "ts590" -"#, - ) - .expect("parse"); - assert!(matches!(config.validate(), Err(ConfigError::Invalid(_)))); - } - - #[test] - fn zero_timeout_is_invalid() { - let config = Config::parse( - r#" -[radio] -port = "COM3" -backend = "ts590" -reply_timeout_ms = 0 -"#, - ) - .expect("parse"); - assert!(matches!(config.validate(), Err(ConfigError::Invalid(_)))); - } - - #[test] - fn permissions_round_trip_from_face() { - let config = Config::parse(SAMPLE).expect("parse"); - let perms = config.faces[0].permissions(); - assert!(perms.allow_ptt); - assert!(perms.allow_write); - assert!(perms.allow_passthrough); - } - - #[test] - fn describe_lists_radio_and_faces() { - let config = Config::parse(SAMPLE).expect("parse"); - let described = config.describe(); - assert!(described.contains("port=COM3")); - assert!(described.contains("name=n1mm")); - } - - #[test] - fn describe_handles_no_faces() { - let config = Config::parse( - r#" -[radio] -port = "COM3" -backend = "ts590" -"#, - ) - .expect("parse"); - assert!(config.describe().contains("faces: (none)")); - } -} diff --git a/crates/cathub/src/dialect/kenwood/mod.rs b/crates/cathub/src/dialect/kenwood/mod.rs deleted file mode 100644 index f106271..0000000 --- a/crates/cathub/src/dialect/kenwood/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -//! Kenwood client dialects. The TS-590 dialect serves native Kenwood clients -//! such as N1MM. - -pub(crate) mod ts590; - -pub(crate) use ts590::Ts590Dialect; diff --git a/crates/cathub/src/dialect/kenwood/ts590.rs b/crates/cathub/src/dialect/kenwood/ts590.rs deleted file mode 100644 index f3d0285..0000000 --- a/crates/cathub/src/dialect/kenwood/ts590.rs +++ /dev/null @@ -1,277 +0,0 @@ -//! TS-590 client dialect. Serves native Kenwood clients such as N1MM: status -//! reads are answered from cached universal state, writes become universal -//! mutations, PTT is permission-gated, and anything unmodeled passes through to -//! the radio. - -use async_trait::async_trait; - -use crate::backend::StateMutation; -use crate::dialect::{ClientDialect, FaceContext}; -use crate::model::{Mode, Vfo}; -use crate::state::{StateChange, StateHandle}; - -/// Native Kenwood TS-590 dialect. -pub(crate) struct Ts590Dialect; - -impl Ts590Dialect { - /// Construct the TS-590 dialect. - pub(crate) fn new() -> Self { - Self - } - - async fn read_reply(verb: &[u8], state: &StateHandle) -> Option> { - let snap = state.snapshot().await; - match verb { - b"FA" => Some(format_freq(b"FA", snap.freq(Vfo::A))), - b"FB" => Some(format_freq(b"FB", snap.freq(Vfo::B))), - b"MD" => Some(format_mode(snap.mode(snap.rx_vfo))), - _ => None, - } - } - - async fn apply_write(verb: &[u8], payload: &[u8], ctx: &FaceContext) { - if !ctx.permissions().allow_write { - tracing::warn!(face = ctx.name(), "dropping write: face is read-only"); - return; - } - let mutation = - match verb { - b"FA" => parse_freq_payload(payload) - .map(|hz| StateMutation::Frequency { vfo: Vfo::A, hz }), - b"FB" => parse_freq_payload(payload) - .map(|hz| StateMutation::Frequency { vfo: Vfo::B, hz }), - b"MD" => parse_mode_payload(payload) - .map(|mode| StateMutation::Mode { vfo: Vfo::A, mode }), - _ => None, - }; - if let Some(mutation) = mutation { - if let Err(error) = ctx.state().apply_mutation(mutation).await { - tracing::warn!(face = ctx.name(), %error, "write failed"); - } - } - } - - async fn route_ptt(keyed: bool, ctx: &FaceContext) { - if !ctx.permissions().allow_ptt { - tracing::warn!(face = ctx.name(), keyed, "dropping PTT: face may not key"); - return; - } - if let Err(error) = ctx - .state() - .apply_mutation(StateMutation::Ptt { keyed }) - .await - { - tracing::warn!(face = ctx.name(), keyed, %error, "PTT routing failed"); - } - } -} - -#[async_trait] -impl ClientDialect for Ts590Dialect { - async fn handle(&self, request: &[u8], ctx: &FaceContext) -> Vec { - let Some((verb, payload)) = split_command(request) else { - return Vec::new(); - }; - - match verb.as_slice() { - b"FA" | b"FB" | b"MD" if payload.is_empty() => Self::read_reply(&verb, ctx.state()) - .await - .unwrap_or_default(), - b"FA" | b"FB" | b"MD" => { - Self::apply_write(&verb, &payload, ctx).await; - Vec::new() - } - b"TX" => { - Self::route_ptt(true, ctx).await; - Vec::new() - } - b"RX" => { - Self::route_ptt(false, ctx).await; - Vec::new() - } - b"AI" if payload.is_empty() => { - let level = u8::from(ctx.ai_enabled()); - format!("AI{level};").into_bytes() - } - b"AI" => { - let enabled = payload.first().is_some_and(|&b| b != b'0'); - ctx.set_ai_enabled(enabled); - Vec::new() - } - _ => { - if ctx.permissions().allow_passthrough { - ctx.backend().passthrough(request).await.unwrap_or_default() - } else { - tracing::warn!(face = ctx.name(), "dropping passthrough: not permitted"); - Vec::new() - } - } - } - } - - fn format_notification(&self, change: &StateChange, ctx: &FaceContext) -> Option> { - if !ctx.ai_enabled() { - return None; - } - match change { - StateChange::Frequency { vfo: Vfo::A, hz } => Some(format_freq(b"FA", *hz)), - StateChange::Frequency { vfo: Vfo::B, hz } => Some(format_freq(b"FB", *hz)), - StateChange::Mode { vfo: Vfo::A, mode } => Some(format_mode(*mode)), - StateChange::Mode { vfo: Vfo::B, .. } => None, - } - } -} - -fn format_freq(verb: &[u8], hz: u64) -> Vec { - let mut out = verb.to_vec(); - out.extend_from_slice(format!("{hz:011};").as_bytes()); - out -} - -fn format_mode(mode: Mode) -> Vec { - format!("MD{};", mode.to_kenwood_digit()).into_bytes() -} - -/// Split a terminated command into its alphabetic verb and remaining payload. -fn split_command(request: &[u8]) -> Option<(Vec, Vec)> { - let body = request.strip_suffix(b";").unwrap_or(request); - if body.is_empty() { - return None; - } - let verb_len = body.iter().take_while(|b| b.is_ascii_alphabetic()).count(); - if verb_len == 0 { - return None; - } - let (verb, payload) = body.split_at(verb_len); - Some((verb.to_vec(), payload.to_vec())) -} - -fn parse_freq_payload(payload: &[u8]) -> Option { - if payload.len() != 11 || !payload.iter().all(u8::is_ascii_digit) { - return None; - } - std::str::from_utf8(payload).ok()?.parse::().ok() -} - -fn parse_mode_payload(payload: &[u8]) -> Option { - let &[digit] = payload else { - return None; - }; - Mode::from_kenwood_digit(digit as char) -} - -#[cfg(test)] -#[allow( - clippy::expect_used, - clippy::unwrap_used, - clippy::indexing_slicing, - clippy::unused_async -)] -mod tests { - use super::*; - use crate::backend::loopback::LoopbackBackend; - use crate::dialect::Permissions; - use crate::state::run_mutation_dispatcher; - use std::sync::Arc; - - fn wired( - permissions: Permissions, - ) -> ( - Ts590Dialect, - FaceContext, - Arc, - tokio::task::JoinHandle<()>, - ) { - let (state, inbox) = StateHandle::new(16); - let backend = Arc::new(LoopbackBackend::new()); - let dispatcher = tokio::spawn(run_mutation_dispatcher( - inbox, - backend.clone(), - state.clone(), - )); - let ctx = FaceContext::new("test", permissions, state, backend.clone()); - (Ts590Dialect::new(), ctx, backend, dispatcher) - } - - #[tokio::test] - async fn read_serves_cached_frequency() { - let (dialect, ctx, _backend, dispatcher) = wired(Permissions::default()); - ctx.state().set_frequency(Vfo::A, 14_074_000).await; - let reply = dialect.handle(b"FA;", &ctx).await; - assert_eq!(reply, b"FA00014074000;".to_vec()); - dispatcher.abort(); - } - - #[tokio::test] - async fn write_applies_mutation_through_backend() { - let (dialect, ctx, backend, dispatcher) = wired(Permissions::default()); - let reply = dialect.handle(b"FA00021074000;", &ctx).await; - assert!(reply.is_empty()); - assert_eq!(ctx.state().snapshot().await.freq_a, 21_074_000); - assert_eq!(backend.recorded_mutations().len(), 1); - dispatcher.abort(); - } - - #[tokio::test] - async fn ptt_dropped_without_permission() { - let permissions = Permissions { - allow_ptt: false, - ..Permissions::default() - }; - let (dialect, ctx, backend, dispatcher) = wired(permissions); - let reply = dialect.handle(b"TX;", &ctx).await; - assert!(reply.is_empty()); - assert!(backend.recorded_mutations().is_empty()); - dispatcher.abort(); - } - - #[tokio::test] - async fn ptt_routed_with_permission() { - let permissions = Permissions { - allow_ptt: true, - ..Permissions::default() - }; - let (dialect, ctx, backend, dispatcher) = wired(permissions); - dialect.handle(b"TX;", &ctx).await; - assert_eq!( - backend.recorded_mutations(), - vec![StateMutation::Ptt { keyed: true }] - ); - dispatcher.abort(); - } - - #[tokio::test] - async fn unmodeled_command_passes_through() { - let (dialect, ctx, backend, dispatcher) = wired(Permissions::default()); - let reply = dialect.handle(b"PS;", &ctx).await; - assert!(reply.is_empty()); - assert_eq!(backend.recorded_passthroughs(), vec![b"PS;".to_vec()]); - dispatcher.abort(); - } - - #[tokio::test] - async fn ai_toggle_round_trips() { - let (dialect, ctx, _backend, dispatcher) = wired(Permissions::default()); - assert_eq!(dialect.handle(b"AI;", &ctx).await, b"AI0;".to_vec()); - dialect.handle(b"AI1;", &ctx).await; - assert!(ctx.ai_enabled()); - assert_eq!(dialect.handle(b"AI;", &ctx).await, b"AI1;".to_vec()); - dispatcher.abort(); - } - - #[tokio::test] - async fn notification_only_when_ai_enabled() { - let (dialect, ctx, _backend, dispatcher) = wired(Permissions::default()); - let change = StateChange::Frequency { - vfo: Vfo::A, - hz: 14_074_000, - }; - assert!(dialect.format_notification(&change, &ctx).is_none()); - ctx.set_ai_enabled(true); - assert_eq!( - dialect.format_notification(&change, &ctx), - Some(b"FA00014074000;".to_vec()) - ); - dispatcher.abort(); - } -} diff --git a/crates/cathub/src/dialect/mod.rs b/crates/cathub/src/dialect/mod.rs deleted file mode 100644 index a282547..0000000 --- a/crates/cathub/src/dialect/mod.rs +++ /dev/null @@ -1,110 +0,0 @@ -//! Client dialect abstraction: the seam that lets one universal state serve -//! many client CAT vocabularies (native Kenwood for N1MM, TS-2000 emulation for -//! `OmniRig`, Hamlib net for the engine). - -pub(crate) mod kenwood; - -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; - -use async_trait::async_trait; - -use crate::backend::RadioBackend; -use crate::state::{StateChange, StateHandle}; - -/// What a face is allowed to do, enforced before any radio write. -#[derive(Debug, Clone, Copy)] -pub(crate) struct Permissions { - /// Whether the face may change frequency/mode/split. - pub(crate) allow_write: bool, - /// Whether the face may key PTT. - pub(crate) allow_ptt: bool, - /// Whether the face may send raw passthrough commands. - pub(crate) allow_passthrough: bool, -} - -impl Default for Permissions { - fn default() -> Self { - Self { - allow_write: true, - allow_ptt: false, - allow_passthrough: true, - } - } -} - -/// Per-face runtime context handed to a dialect on every request. -#[derive(Clone)] -pub(crate) struct FaceContext { - /// Face name, used in logs and as the PTT owner identity. - name: String, - /// What this face is permitted to do. - permissions: Permissions, - /// Shared universal state for reads and modeled writes. - state: StateHandle, - /// Active backend for raw passthrough commands. - backend: Arc, - /// Whether this face has auto-information fan-out enabled. - ai_enabled: Arc, -} - -impl FaceContext { - /// Build a face context. - pub(crate) fn new( - name: impl Into, - permissions: Permissions, - state: StateHandle, - backend: Arc, - ) -> Self { - Self { - name: name.into(), - permissions, - state, - backend, - ai_enabled: Arc::new(AtomicBool::new(false)), - } - } - - /// Face name. - pub(crate) fn name(&self) -> &str { - &self.name - } - - /// Face permissions. - pub(crate) fn permissions(&self) -> Permissions { - self.permissions - } - - /// Shared universal state. - pub(crate) fn state(&self) -> &StateHandle { - &self.state - } - - /// Active backend. - pub(crate) fn backend(&self) -> &Arc { - &self.backend - } - - /// Whether auto-information fan-out is enabled for this face. - pub(crate) fn ai_enabled(&self) -> bool { - self.ai_enabled.load(Ordering::Relaxed) - } - - /// Enable or disable auto-information fan-out for this face. - pub(crate) fn set_ai_enabled(&self, enabled: bool) { - self.ai_enabled.store(enabled, Ordering::Relaxed); - } -} - -/// A client CAT dialect. Implementations translate between a client's command -/// vocabulary and the universal state plus backend. -#[async_trait] -pub(crate) trait ClientDialect: Send + Sync { - /// Handle one client request frame, returning the bytes to write back to - /// the client (empty when the command produces no reply). - async fn handle(&self, request: &[u8], ctx: &FaceContext) -> Vec; - - /// Format a universal state change as an unsolicited frame for an - /// AI-subscribed client, or `None` if the dialect does not surface it. - fn format_notification(&self, change: &StateChange, ctx: &FaceContext) -> Option>; -} diff --git a/crates/cathub/src/error.rs b/crates/cathub/src/error.rs deleted file mode 100644 index c43faf4..0000000 --- a/crates/cathub/src/error.rs +++ /dev/null @@ -1,81 +0,0 @@ -//! Typed error surfaces for the CAT hub. - -use std::io; - -use thiserror::Error; - -/// Errors raised while loading or validating configuration. -#[derive(Debug, Error)] -pub enum ConfigError { - /// The configuration file could not be read. - #[error("failed to read config file '{path}': {source}")] - Read { - /// Path that failed to load. - path: String, - /// Underlying I/O error. - source: io::Error, - }, - /// The configuration file was not valid TOML or failed schema parsing. - #[error("failed to parse config: {0}")] - Parse(String), - /// The configuration parsed but was semantically invalid. - #[error("invalid configuration: {0}")] - Invalid(String), -} - -/// Errors raised by a radio backend. -#[derive(Debug, Error)] -pub enum BackendError { - /// The radio transport failed (I/O, disconnect). - #[error("radio transport error: {0}")] - Transport(String), - /// A command timed out waiting for a reply. - #[error("radio command timed out: {0}")] - Timeout(String), - /// A reply could not be parsed. - #[error("failed to parse radio reply: {0}")] - Parse(String), - /// The backend does not support the requested operation. - #[error("operation not supported by backend: {0}")] - Unsupported(String), -} - -/// Errors raised while running a client face. -#[derive(Debug, Error)] -pub enum FaceError { - /// The face's serial or network endpoint could not be bound. - #[error("failed to bind face '{name}' to '{endpoint}': {message}")] - Bind { - /// Face name from configuration. - name: String, - /// Endpoint that failed to bind. - endpoint: String, - /// Description of the bind failure. - message: String, - }, - /// An I/O error occurred while serving the face. - #[error("face '{name}' I/O error: {source}")] - Io { - /// Face name from configuration. - name: String, - /// Underlying I/O error. - source: io::Error, - }, -} - -/// Top-level error type for the daemon. -#[derive(Debug, Error)] -pub enum CatHubError { - /// Configuration failed to load or validate. - #[error(transparent)] - Config(#[from] ConfigError), - /// A backend failed fatally during startup. - #[error(transparent)] - Backend(#[from] BackendError), - /// A face failed fatally during startup. - #[error(transparent)] - Face(#[from] FaceError), - /// A generic I/O failure during startup or shutdown. - #[error("I/O error: {0}")] - Io(#[from] io::Error), -} diff --git a/crates/cathub/src/lib.rs b/crates/cathub/src/lib.rs deleted file mode 100644 index fc5a53f..0000000 --- a/crates/cathub/src/lib.rs +++ /dev/null @@ -1,153 +0,0 @@ -//! `qsoripper-cathub`: a multi-client CAT hub that owns the radio's serial port -//! and fans it out to multiple logging, panadapter, and engine clients. -//! -//! This crate is built as a library plus a thin binary so integration tests and -//! the engine can drive the hub in-process. The public surface is intentionally -//! minimal; the daemon internals are crate-private. - -#![forbid(unsafe_code)] - -mod backend; -mod config; -mod dialect; -mod error; -mod model; -mod poller; -mod radio; -mod serial_face; -mod state; - -use std::path::PathBuf; -use std::sync::Arc; -use std::time::Duration; - -use clap::Parser; - -use crate::backend::kenwood::Ts590Backend; -use crate::backend::loopback::LoopbackBackend; -use crate::backend::{RadioBackend, StateMutation}; -use crate::config::{BackendKind, Config, DialectKind}; -use crate::dialect::kenwood::Ts590Dialect; -use crate::dialect::{ClientDialect, FaceContext}; -use crate::error::{BackendError, ConfigError}; -use crate::poller::run_poller; -use crate::radio::run_radio_task; -use crate::state::{run_mutation_dispatcher, StateHandle}; - -pub use error::CatHubError; - -/// Command-line arguments for the CAT hub daemon. -#[derive(Debug, Parser)] -#[command( - name = "qsoripper-cathub", - about = "Multi-client CAT hub for shared radio control" -)] -pub struct Cli { - /// Path to the TOML configuration file. Defaults to the per-user config path. - #[arg(long)] - pub config: Option, - /// Path to a log file. When omitted, logs go to stderr. - #[arg(long)] - pub log: Option, - /// Load and validate the configuration, print it, and exit. - #[arg(long)] - pub dry_run: bool, -} - -/// Run the daemon to completion using the given command-line arguments. -/// -/// # Errors -/// -/// Returns a [`CatHubError`] if configuration fails to load or validate, if the -/// radio transport cannot be opened, or if a face fails to bind. -pub async fn run(cli: Cli) -> Result<(), CatHubError> { - let config_path = cli - .config - .or_else(config::default_config_path) - .ok_or_else(|| { - ConfigError::Invalid("no configuration path resolved; pass --config".to_string()) - })?; - let config = Config::load(&config_path)?; - config.validate()?; - - if cli.dry_run { - print!("{}", config.describe()); - return Ok(()); - } - - run_daemon(config).await -} - -async fn run_daemon(config: Config) -> Result<(), CatHubError> { - let (state, inbox) = StateHandle::new(256); - let reply_timeout = Duration::from_millis(config.radio.reply_timeout_ms); - - let backend: Arc = match config.radio.backend { - BackendKind::Ts590 => { - let (radio, inbox) = radio::channel(64); - let port = serial2_tokio::SerialPort::open(&config.radio.port, config.radio.baud) - .map_err(|e| { - BackendError::Transport(format!( - "opening radio port {}: {e}", - config.radio.port - )) - })?; - tokio::spawn(run_radio_task(port, inbox, reply_timeout)); - // Disable the radio's auto-information so unsolicited frames can't - // desynchronize the polled request/reply stream. - if let Err(error) = radio.send(b"AI0;".to_vec(), false).await { - tracing::warn!(%error, "failed to disable radio auto-information"); - } - Arc::new(Ts590Backend::new(radio)) - } - BackendKind::Loopback => Arc::new(LoopbackBackend::new()), - }; - - tokio::spawn(run_mutation_dispatcher( - inbox, - backend.clone(), - state.clone(), - )); - tokio::spawn(run_poller( - backend.clone(), - state.clone(), - Duration::from_millis(config.poll.baseline_ms), - )); - - // Prime the cache with one poll so early client reads see real radio state - // instead of defaults. Best-effort: the baseline poller retries on failure. - if let Err(error) = backend.poll(&state).await { - tracing::warn!(%error, "initial poll failed; serving defaults until next poll"); - } - - for face in &config.faces { - let ctx = FaceContext::new( - face.name.clone(), - face.permissions(), - state.clone(), - backend.clone(), - ); - let dialect: Arc = match face.dialect { - DialectKind::Ts590 => Arc::new(Ts590Dialect::new()), - }; - let port = serial_face::open_serial(&face.name, &face.port, face.baud)?; - let name = face.name.clone(); - let changes = state.subscribe(); - tokio::spawn(async move { - if let Err(error) = serial_face::run_face(port, dialect, ctx, changes).await { - tracing::error!(%error, face = %name, "face stopped"); - } - }); - } - - tracing::info!("cathub running; press Ctrl+C to stop"); - tokio::signal::ctrl_c().await?; - tracing::info!("shutdown requested; releasing PTT"); - let release = state.apply_mutation(StateMutation::Ptt { keyed: false }); - match tokio::time::timeout(Duration::from_millis(500), release).await { - Ok(Ok(())) => {} - Ok(Err(error)) => tracing::warn!(%error, "failed to release PTT on shutdown"), - Err(_) => tracing::warn!("timed out releasing PTT on shutdown"), - } - Ok(()) -} diff --git a/crates/cathub/src/main.rs b/crates/cathub/src/main.rs deleted file mode 100644 index 206fb4e..0000000 --- a/crates/cathub/src/main.rs +++ /dev/null @@ -1,25 +0,0 @@ -//! Thin binary entry point for the CAT hub daemon: initialize logging, parse -//! arguments, and run the daemon until shutdown. - -use clap::Parser as _; -use qsoripper_cathub::{run, Cli}; -use tracing_subscriber::EnvFilter; - -#[tokio::main] -async fn main() -> std::process::ExitCode { - tracing_subscriber::fmt() - .with_env_filter( - EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")), - ) - .with_writer(std::io::stderr) - .init(); - - let cli = Cli::parse(); - match run(cli).await { - Ok(()) => std::process::ExitCode::SUCCESS, - Err(error) => { - tracing::error!(%error, "cathub failed"); - std::process::ExitCode::FAILURE - } - } -} diff --git a/crates/cathub/src/model.rs b/crates/cathub/src/model.rs deleted file mode 100644 index ccaa1dc..0000000 --- a/crates/cathub/src/model.rs +++ /dev/null @@ -1,112 +0,0 @@ -//! Core domain enums shared across the hub: VFO selection and operating mode. - -use std::fmt; - -/// Identifies one of the radio's two VFOs. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub(crate) enum Vfo { - /// VFO A. - A, - /// VFO B. - B, -} - -impl fmt::Display for Vfo { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Vfo::A => f.write_str("A"), - Vfo::B => f.write_str("B"), - } - } -} - -/// Operating mode, normalized across radio families. -/// -/// The numeric encoding mirrors the Kenwood `MD` command digits so the TS-590 -/// backend maps modes with a simple table while remaining a backend-independent -/// universal value for dialects. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub(crate) enum Mode { - /// Lower sideband. - Lsb, - /// Upper sideband. - Usb, - /// CW (normal). - Cw, - /// FM. - Fm, - /// AM. - Am, - /// FSK / RTTY. - Fsk, - /// CW reverse. - CwR, - /// FSK reverse. - FskR, -} - -impl Mode { - /// Render the mode as its Kenwood `MD` digit. - pub(crate) fn to_kenwood_digit(self) -> char { - match self { - Mode::Lsb => '1', - Mode::Usb => '2', - Mode::Cw => '3', - Mode::Fm => '4', - Mode::Am => '5', - Mode::Fsk => '6', - Mode::CwR => '7', - Mode::FskR => '9', - } - } - - /// Parse a Kenwood `MD` digit into a mode. - pub(crate) fn from_kenwood_digit(digit: char) -> Option { - match digit { - '1' => Some(Mode::Lsb), - '2' => Some(Mode::Usb), - '3' => Some(Mode::Cw), - '4' => Some(Mode::Fm), - '5' => Some(Mode::Am), - '6' => Some(Mode::Fsk), - '7' => Some(Mode::CwR), - '9' => Some(Mode::FskR), - _ => None, - } - } -} - -#[cfg(test)] -#[allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)] -mod tests { - use super::*; - - #[test] - fn kenwood_mode_digits_round_trip() { - for mode in [ - Mode::Lsb, - Mode::Usb, - Mode::Cw, - Mode::Fm, - Mode::Am, - Mode::Fsk, - Mode::CwR, - Mode::FskR, - ] { - let digit = mode.to_kenwood_digit(); - assert_eq!(Mode::from_kenwood_digit(digit), Some(mode)); - } - } - - #[test] - fn unknown_kenwood_digit_is_none() { - assert_eq!(Mode::from_kenwood_digit('0'), None); - assert_eq!(Mode::from_kenwood_digit('8'), None); - } - - #[test] - fn vfo_display_renders_letter() { - assert_eq!(Vfo::A.to_string(), "A"); - assert_eq!(Vfo::B.to_string(), "B"); - } -} diff --git a/crates/cathub/src/poller.rs b/crates/cathub/src/poller.rs deleted file mode 100644 index 3ce6c6e..0000000 --- a/crates/cathub/src/poller.rs +++ /dev/null @@ -1,45 +0,0 @@ -//! Baseline poll task. Drives the active backend to refresh universal state on -//! a fixed cadence so client reads can be served from cache between polls. - -use std::sync::Arc; -use std::time::Duration; - -use crate::backend::RadioBackend; -use crate::state::StateHandle; - -/// Run the baseline poller until cancelled, refreshing state every `interval`. -pub(crate) async fn run_poller( - backend: Arc, - state: StateHandle, - interval: Duration, -) { - let mut ticker = tokio::time::interval(interval); - ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - loop { - ticker.tick().await; - if let Err(error) = backend.poll(&state).await { - tracing::warn!(%error, "baseline poll failed"); - } - } -} - -#[cfg(test)] -#[allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)] -mod tests { - use super::*; - use crate::backend::loopback::LoopbackBackend; - - #[tokio::test] - async fn poller_refreshes_state() { - let (state, _inbox) = StateHandle::new(16); - let backend: Arc = Arc::new(LoopbackBackend::new()); - let task = tokio::spawn(run_poller( - backend, - state.clone(), - Duration::from_millis(10), - )); - tokio::time::sleep(Duration::from_millis(40)).await; - assert_eq!(state.snapshot().await.freq_a, 14_074_000); - task.abort(); - } -} diff --git a/crates/cathub/src/radio.rs b/crates/cathub/src/radio.rs deleted file mode 100644 index 9fb254e..0000000 --- a/crates/cathub/src/radio.rs +++ /dev/null @@ -1,186 +0,0 @@ -//! The radio task: the single owner of the serial transport. All radio access -//! is serialized through its command inbox so no two clients ever interleave -//! bytes on the wire. The task is generic over the transport so tests drive it -//! with an in-memory `tokio::io::duplex` pipe instead of a real port. - -use std::time::Duration; - -use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; -use tokio::sync::{mpsc, oneshot}; - -use crate::error::BackendError; - -/// Kenwood-family command/reply terminator. -const TERMINATOR: u8 = b';'; - -/// One serialized radio command and its reply slot. -pub(crate) struct RadioCommand { - /// Raw bytes to write to the radio (already terminated). - pub(crate) bytes: Vec, - /// Whether to wait for and return a reply frame. - pub(crate) expect_reply: bool, - /// Where to deliver the reply (or error). - pub(crate) reply: oneshot::Sender, BackendError>>, -} - -/// Cloneable handle used by backends to submit commands to the radio task. -#[derive(Clone)] -pub(crate) struct RadioHandle { - tx: mpsc::Sender, -} - -impl RadioHandle { - /// Submit a command, optionally waiting for a terminated reply frame. - pub(crate) async fn send( - &self, - bytes: Vec, - expect_reply: bool, - ) -> Result, BackendError> { - let (reply, rx) = oneshot::channel(); - self.tx - .send(RadioCommand { - bytes, - expect_reply, - reply, - }) - .await - .map_err(|_| BackendError::Transport("radio task stopped".to_string()))?; - rx.await - .map_err(|_| BackendError::Transport("radio task dropped reply".to_string()))? - } -} - -/// Create a radio command channel, returning the client handle and the inbox -/// receiver to hand to [`run_radio_task`]. -pub(crate) fn channel(capacity: usize) -> (RadioHandle, mpsc::Receiver) { - let (tx, rx) = mpsc::channel(capacity); - (RadioHandle { tx }, rx) -} - -/// Run the radio task over the given transport until the inbox is closed. -/// -/// Commands are serviced strictly in order. For commands that expect a reply, -/// bytes are read until the Kenwood terminator or the per-command timeout. -pub(crate) async fn run_radio_task( - mut transport: T, - mut inbox: mpsc::Receiver, - reply_timeout: Duration, -) where - T: AsyncRead + AsyncWrite + Unpin, -{ - while let Some(command) = inbox.recv().await { - let result = service_command(&mut transport, &command, reply_timeout).await; - let _ = command.reply.send(result); - } -} - -async fn service_command( - transport: &mut T, - command: &RadioCommand, - reply_timeout: Duration, -) -> Result, BackendError> -where - T: AsyncRead + AsyncWrite + Unpin, -{ - transport - .write_all(&command.bytes) - .await - .map_err(|e| BackendError::Transport(e.to_string()))?; - transport - .flush() - .await - .map_err(|e| BackendError::Transport(e.to_string()))?; - - if !command.expect_reply { - return Ok(Vec::new()); - } - - match tokio::time::timeout(reply_timeout, read_frame(transport)).await { - Ok(result) => result, - Err(_) => Err(BackendError::Timeout(format!( - "no reply within {reply_timeout:?}" - ))), - } -} - -async fn read_frame(transport: &mut T) -> Result, BackendError> -where - T: AsyncRead + Unpin, -{ - let mut frame = Vec::new(); - loop { - let byte = transport - .read_u8() - .await - .map_err(|e| BackendError::Transport(e.to_string()))?; - frame.push(byte); - if byte == TERMINATOR { - return Ok(frame); - } - } -} - -#[cfg(test)] -#[allow( - clippy::expect_used, - clippy::unwrap_used, - clippy::indexing_slicing, - clippy::unused_async -)] -mod tests { - use super::*; - - async fn spawn_with_fake() -> (RadioHandle, tokio::io::DuplexStream) { - let (handle, inbox) = channel(8); - let (client_side, radio_side) = tokio::io::duplex(1024); - tokio::spawn(run_radio_task( - radio_side, - inbox, - Duration::from_millis(200), - )); - (handle, client_side) - } - - #[tokio::test] - async fn command_with_reply_reads_until_terminator() { - let (handle, mut fake) = spawn_with_fake().await; - let writer = tokio::spawn(async move { - let mut got = vec![0u8; 3]; - fake.read_exact(&mut got).await.expect("read request"); - assert_eq!(&got, b"FA;"); - fake.write_all(b"FA00014074000;") - .await - .expect("write reply"); - fake.flush().await.expect("flush"); - }); - let reply = handle.send(b"FA;".to_vec(), true).await.expect("reply"); - assert_eq!(reply, b"FA00014074000;".to_vec()); - writer.await.expect("writer task"); - } - - #[tokio::test] - async fn command_without_reply_returns_empty() { - let (handle, mut fake) = spawn_with_fake().await; - let reader = tokio::spawn(async move { - let mut got = vec![0u8; 14]; - fake.read_exact(&mut got).await.expect("read request"); - assert_eq!(&got, b"FA00021074000;"); - }); - let reply = handle - .send(b"FA00021074000;".to_vec(), false) - .await - .expect("ok"); - assert!(reply.is_empty()); - reader.await.expect("reader task"); - } - - #[tokio::test] - async fn missing_reply_times_out() { - let (handle, _fake) = spawn_with_fake().await; - let err = handle - .send(b"FA;".to_vec(), true) - .await - .expect_err("timeout"); - assert!(matches!(err, BackendError::Timeout(_))); - } -} diff --git a/crates/cathub/src/serial_face.rs b/crates/cathub/src/serial_face.rs deleted file mode 100644 index c21bad8..0000000 --- a/crates/cathub/src/serial_face.rs +++ /dev/null @@ -1,201 +0,0 @@ -//! A serial face: one virtual COM port (or in-memory transport) bound to a -//! client dialect. Reads client commands, frames them on the Kenwood -//! terminator, dispatches them to the dialect, writes replies, and pushes -//! auto-information notifications when the face has AI enabled. - -use std::sync::Arc; - -use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; -use tokio::sync::broadcast; -use tokio::sync::broadcast::error::RecvError; - -use crate::dialect::{ClientDialect, FaceContext}; -use crate::error::FaceError; -use crate::state::StateChange; - -/// Kenwood command terminator used for framing. -const TERMINATOR: u8 = b';'; - -/// Open a virtual serial port for a face. -pub(crate) fn open_serial( - name: &str, - port: &str, - baud: u32, -) -> Result { - serial2_tokio::SerialPort::open(port, baud).map_err(|error| FaceError::Bind { - name: name.to_string(), - endpoint: port.to_string(), - message: error.to_string(), - }) -} - -/// Drive one face over the given transport until the client disconnects. -/// -/// `changes` is the caller-provided change subscription; subscribing before the -/// task is spawned guarantees no state change is missed between spawn and the -/// first poll of the receiver. -pub(crate) async fn run_face( - mut transport: T, - dialect: Arc, - ctx: FaceContext, - mut changes: broadcast::Receiver, -) -> Result<(), FaceError> -where - T: AsyncRead + AsyncWrite + Unpin, -{ - let mut pending = Vec::new(); - let mut chunk = [0u8; 256]; - - loop { - tokio::select! { - read = transport.read(&mut chunk) => { - let n = read.map_err(|source| FaceError::Io { - name: ctx.name().to_string(), - source, - })?; - if n == 0 { - return Ok(()); - } - if let Some(slice) = chunk.get(..n) { - pending.extend_from_slice(slice); - } - drain_frames(&mut transport, &mut pending, dialect.as_ref(), &ctx).await?; - } - change = changes.recv() => { - match change { - Ok(change) => { - if let Some(frame) = dialect.format_notification(&change, &ctx) { - write_all(&mut transport, &frame, &ctx).await?; - } - } - Err(RecvError::Lagged(_)) => {} - Err(RecvError::Closed) => return Ok(()), - } - } - } - } -} - -async fn drain_frames( - transport: &mut T, - pending: &mut Vec, - dialect: &dyn ClientDialect, - ctx: &FaceContext, -) -> Result<(), FaceError> -where - T: AsyncWrite + Unpin, -{ - while let Some(pos) = pending.iter().position(|&b| b == TERMINATOR) { - let frame: Vec = pending.drain(..=pos).collect(); - let reply = dialect.handle(&frame, ctx).await; - if !reply.is_empty() { - write_all(transport, &reply, ctx).await?; - } - } - Ok(()) -} - -async fn write_all(transport: &mut T, bytes: &[u8], ctx: &FaceContext) -> Result<(), FaceError> -where - T: AsyncWrite + Unpin, -{ - transport - .write_all(bytes) - .await - .map_err(|source| FaceError::Io { - name: ctx.name().to_string(), - source, - })?; - transport.flush().await.map_err(|source| FaceError::Io { - name: ctx.name().to_string(), - source, - }) -} - -#[cfg(test)] -#[allow( - clippy::expect_used, - clippy::unwrap_used, - clippy::indexing_slicing, - clippy::unused_async -)] -mod tests { - use super::*; - use crate::backend::loopback::LoopbackBackend; - use crate::dialect::kenwood::Ts590Dialect; - use crate::dialect::Permissions; - use crate::model::Vfo; - use crate::state::{run_mutation_dispatcher, StateHandle}; - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - - #[tokio::test] - async fn face_answers_read_and_applies_write() { - let (state, inbox) = StateHandle::new(16); - let backend = Arc::new(LoopbackBackend::new()); - let dispatcher = tokio::spawn(run_mutation_dispatcher( - inbox, - backend.clone(), - state.clone(), - )); - state.set_frequency(Vfo::A, 14_074_000).await; - - let ctx = FaceContext::new( - "n1mm", - Permissions::default(), - state.clone(), - backend.clone(), - ); - let dialect: Arc = Arc::new(Ts590Dialect::new()); - let (mut client, face_side) = tokio::io::duplex(1024); - let changes = state.subscribe(); - let face = tokio::spawn(run_face(face_side, dialect, ctx, changes)); - - client.write_all(b"FA;").await.expect("write read cmd"); - let mut reply = vec![0u8; 14]; - client.read_exact(&mut reply).await.expect("read reply"); - assert_eq!(&reply, b"FA00014074000;"); - - client - .write_all(b"FA00021074000;") - .await - .expect("write set cmd"); - tokio::time::sleep(std::time::Duration::from_millis(20)).await; - assert_eq!(state.snapshot().await.freq_a, 21_074_000); - - drop(client); - face.await.expect("face task").expect("face ok"); - dispatcher.abort(); - } - - #[tokio::test] - async fn ai_subscribed_face_receives_notifications() { - let (state, inbox) = StateHandle::new(16); - let backend = Arc::new(LoopbackBackend::new()); - let dispatcher = tokio::spawn(run_mutation_dispatcher( - inbox, - backend.clone(), - state.clone(), - )); - - let ctx = FaceContext::new( - "n1mm", - Permissions::default(), - state.clone(), - backend.clone(), - ); - ctx.set_ai_enabled(true); - let dialect: Arc = Arc::new(Ts590Dialect::new()); - let (mut client, face_side) = tokio::io::duplex(1024); - let changes = state.subscribe(); - let face = tokio::spawn(run_face(face_side, dialect, ctx, changes)); - - state.set_frequency(Vfo::A, 18_100_000).await; - let mut reply = vec![0u8; 14]; - client.read_exact(&mut reply).await.expect("notification"); - assert_eq!(&reply, b"FA00018100000;"); - - drop(client); - face.await.expect("face task").expect("face ok"); - dispatcher.abort(); - } -} diff --git a/crates/cathub/src/state.rs b/crates/cathub/src/state.rs deleted file mode 100644 index eaa7106..0000000 --- a/crates/cathub/src/state.rs +++ /dev/null @@ -1,236 +0,0 @@ -//! Universal in-memory rig state, the mutation dispatch channel, and the -//! change-notification broadcast. -//! -//! Every path that observes or changes the radio goes through this layer: -//! backends populate it, dialects read and mutate it, and the poller refreshes -//! it. Faces subscribe to [`StateHandle::subscribe`] to push updates to -//! auto-information clients. - -use std::sync::Arc; - -use tokio::sync::{broadcast, mpsc, oneshot, RwLock}; - -use crate::backend::{RadioBackend, StateMutation}; -use crate::error::BackendError; -use crate::model::{Mode, Vfo}; - -/// A change to one universal-state field, broadcast to faces for AI fan-out. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum StateChange { - /// A VFO frequency changed. - Frequency { - /// Affected VFO. - vfo: Vfo, - /// New frequency in Hz. - hz: u64, - }, - /// A VFO mode changed. - Mode { - /// Affected VFO. - vfo: Vfo, - /// New mode. - mode: Mode, - }, -} - -/// The universal rig state snapshot. -#[derive(Debug, Clone, Copy)] -pub(crate) struct RigState { - /// VFO A frequency in Hz. - pub(crate) freq_a: u64, - /// VFO B frequency in Hz. - pub(crate) freq_b: u64, - /// VFO A mode. - pub(crate) mode_a: Mode, - /// VFO B mode. - pub(crate) mode_b: Mode, - /// Active receive VFO. - pub(crate) rx_vfo: Vfo, -} - -impl Default for RigState { - fn default() -> Self { - Self { - freq_a: 0, - freq_b: 0, - mode_a: Mode::Usb, - mode_b: Mode::Usb, - rx_vfo: Vfo::A, - } - } -} - -impl RigState { - /// Frequency for the given VFO. - pub(crate) fn freq(&self, vfo: Vfo) -> u64 { - match vfo { - Vfo::A => self.freq_a, - Vfo::B => self.freq_b, - } - } - - /// Mode for the given VFO. - pub(crate) fn mode(&self, vfo: Vfo) -> Mode { - match vfo { - Vfo::A => self.mode_a, - Vfo::B => self.mode_b, - } - } -} - -/// A request to mutate the radio, carried over the dispatch channel. -struct MutationRequest { - mutation: StateMutation, - reply: oneshot::Sender>, -} - -/// Opaque wrapper around the mutation receiver so the channel payload stays -/// private while the receiver can be handed to the dispatcher. -pub(crate) struct MutationInbox { - rx: mpsc::Receiver, -} - -/// Shared, cloneable handle to the universal rig state. -#[derive(Clone)] -pub(crate) struct StateHandle { - inner: Arc>, - changes: broadcast::Sender, - mutations: mpsc::Sender, -} - -impl StateHandle { - /// Create a state handle plus the receiver half of the mutation channel. - /// - /// The caller runs [`run_mutation_dispatcher`] with the returned inbox and - /// the active backend to service [`StateHandle::apply_mutation`] calls. - pub(crate) fn new(broadcast_capacity: usize) -> (Self, MutationInbox) { - let (changes, _) = broadcast::channel(broadcast_capacity); - let (mutations, rx) = mpsc::channel(64); - let handle = Self { - inner: Arc::new(RwLock::new(RigState::default())), - changes, - mutations, - }; - (handle, MutationInbox { rx }) - } - - /// Subscribe to the change-notification broadcast. - pub(crate) fn subscribe(&self) -> broadcast::Receiver { - self.changes.subscribe() - } - - /// Take a snapshot of the current state. - pub(crate) async fn snapshot(&self) -> RigState { - *self.inner.read().await - } - - fn broadcast(&self, change: StateChange) { - // A send error only means there are no subscribers, which is fine. - let _ = self.changes.send(change); - } - - /// Set a VFO frequency and broadcast the change. - pub(crate) async fn set_frequency(&self, vfo: Vfo, hz: u64) { - { - let mut state = self.inner.write().await; - match vfo { - Vfo::A => state.freq_a = hz, - Vfo::B => state.freq_b = hz, - } - } - self.broadcast(StateChange::Frequency { vfo, hz }); - } - - /// Set a VFO mode and broadcast the change. - pub(crate) async fn set_mode(&self, vfo: Vfo, mode: Mode) { - { - let mut state = self.inner.write().await; - match vfo { - Vfo::A => state.mode_a = mode, - Vfo::B => state.mode_b = mode, - } - } - self.broadcast(StateChange::Mode { vfo, mode }); - } - - /// Submit a mutation for the backend to apply, awaiting the result. - pub(crate) async fn apply_mutation(&self, mutation: StateMutation) -> Result<(), BackendError> { - let (reply, rx) = oneshot::channel(); - self.mutations - .send(MutationRequest { mutation, reply }) - .await - .map_err(|_| BackendError::Transport("mutation dispatcher stopped".to_string()))?; - rx.await - .map_err(|_| BackendError::Transport("mutation dispatcher dropped reply".to_string()))? - } -} - -/// Run the mutation dispatcher: pull mutation requests and apply them through -/// the backend, which updates the shared state on success. -pub(crate) async fn run_mutation_dispatcher( - mut inbox: MutationInbox, - backend: Arc, - state: StateHandle, -) { - while let Some(request) = inbox.rx.recv().await { - let result = backend.apply(request.mutation, &state).await; - let _ = request.reply.send(result); - } -} - -#[cfg(test)] -#[allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)] -mod tests { - use super::*; - use crate::backend::loopback::LoopbackBackend; - - #[tokio::test] - async fn set_frequency_updates_snapshot() { - let (handle, _inbox) = StateHandle::new(16); - handle.set_frequency(Vfo::A, 14_074_000).await; - assert_eq!(handle.snapshot().await.freq_a, 14_074_000); - } - - #[tokio::test] - async fn set_mode_updates_snapshot() { - let (handle, _inbox) = StateHandle::new(16); - handle.set_mode(Vfo::B, Mode::Cw).await; - assert_eq!(handle.snapshot().await.mode_b, Mode::Cw); - } - - #[tokio::test] - async fn subscribers_receive_changes() { - let (handle, _inbox) = StateHandle::new(16); - let mut rx = handle.subscribe(); - handle.set_mode(Vfo::A, Mode::Cw).await; - let change = rx.recv().await.expect("a change"); - assert_eq!( - change, - StateChange::Mode { - vfo: Vfo::A, - mode: Mode::Cw - } - ); - } - - #[tokio::test] - async fn apply_mutation_round_trips_through_backend() { - let (handle, inbox) = StateHandle::new(16); - let backend = Arc::new(LoopbackBackend::new()); - let dispatcher = tokio::spawn(run_mutation_dispatcher( - inbox, - backend.clone(), - handle.clone(), - )); - handle - .apply_mutation(StateMutation::Frequency { - vfo: Vfo::A, - hz: 21_074_000, - }) - .await - .expect("mutation applied"); - assert_eq!(handle.snapshot().await.freq_a, 21_074_000); - assert_eq!(backend.recorded_mutations().len(), 1); - dispatcher.abort(); - } -} diff --git a/crates/cathub/tests/cli.rs b/crates/cathub/tests/cli.rs deleted file mode 100644 index 72723b4..0000000 --- a/crates/cathub/tests/cli.rs +++ /dev/null @@ -1,90 +0,0 @@ -//! Integration coverage for the public CLI entry point: configuration -//! resolution, the dry-run path, and load/parse error surfaces. - -#![allow(clippy::expect_used, clippy::unwrap_used)] - -use std::path::PathBuf; - -use qsoripper_cathub::{run, Cli}; - -fn temp_config(contents: &str) -> PathBuf { - let unique = format!( - "cathub-test-{}-{}.toml", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system time after epoch") - .as_nanos() - ); - let path = std::env::temp_dir().join(unique); - std::fs::write(&path, contents).expect("write temp config"); - path -} - -#[tokio::test] -async fn dry_run_validates_and_returns_ok() { - let path = temp_config( - r#" -[radio] -port = "COM3" -backend = "loopback" - -[[face]] -name = "n1mm" -port = "COM11" -dialect = "ts590" -"#, - ); - let cli = Cli { - config: Some(path.clone()), - log: None, - dry_run: true, - }; - let result = run(cli).await; - std::fs::remove_file(&path).ok(); - assert!(result.is_ok(), "dry-run should succeed: {result:?}"); -} - -#[tokio::test] -async fn missing_config_file_is_an_error() { - let path = std::env::temp_dir().join("cathub-missing-config-should-not-exist.toml"); - std::fs::remove_file(&path).ok(); - let cli = Cli { - config: Some(path), - log: None, - dry_run: true, - }; - assert!(run(cli).await.is_err()); -} - -#[tokio::test] -async fn invalid_config_is_an_error() { - let path = temp_config("this is not valid [[ toml"); - let cli = Cli { - config: Some(path.clone()), - log: None, - dry_run: true, - }; - let result = run(cli).await; - std::fs::remove_file(&path).ok(); - assert!(result.is_err()); -} - -#[tokio::test] -async fn invalid_semantics_are_rejected() { - let path = temp_config( - r#" -[radio] -port = " " -backend = "loopback" -"#, - ); - let cli = Cli { - config: Some(path.clone()), - log: None, - dry_run: true, - }; - let result = run(cli).await; - std::fs::remove_file(&path).ok(); - assert!(result.is_err()); -} From 0de009eead573b8228e7f0b1d0a59261289e99a7 Mon Sep 17 00:00:00 2001 From: Randy Treit <38894017+rtreit@users.noreply.github.com> Date: Fri, 29 May 2026 17:20:26 -0700 Subject: [PATCH 06/36] qsoripper-cathub: multi-client CAT hub daemon (#476) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: verify review-required workflow * WIP: cathub mixed state recovery snapshot (skeleton tracked + new untracked files) * cathub: wire lib.rs/main.rs daemon entry, fix trait default + tests * cathub: clippy/fmt clean (writeln describe, map_outcome by ref, doc fixes) * cathub: add run()/open_transport coverage tests (lib 73%, total 89%) * cathub: station config, operator scripts, runbook, engine-spec front-door note * cathub: unkey radio on PTT ceiling + shutdown (close §8.5 gap); doc v1 limits * cathub: prime universal state with an awaited startup poll Adopt the one genuinely useful idea from the alternate phase-1 skeleton (PR #475): run a single time-bounded poll before any face begins serving so the first client read (e.g. HDSDR/OmniRig connecting at startup) sees real radio state instead of defaults. Best-effort and bounded so a slow or absent radio cannot block startup. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * cathub: read unified config.toml [cat_hub] section; fix radio port to COM4 Resolve the daemon config from the shared per-user config.toml (QSORIPPER_CONFIG_PATH / %APPDATA%/qsoripper or XDG/HOME), parsing the [cat_hub] subtree when present and falling back to a standalone layout for back-compat. Correct the sample radio port to COM4 (the CP210x TS-590). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * engine(rust): merge-preserving setup config writer Splice engine-owned tables into the existing config.toml via toml_edit instead of a full rewrite, so unknown top-level tables ([cat_hub], [launcher]) survive an engine setup save. Engine-owned keys are removed-then-reinserted, so cleared tables are dropped while shared sections are preserved. Adds tests for unknown-table survival and stale-table removal. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * engine(dotnet): merge-preserving setup config writer Splice engine-owned tables into the existing config.toml via a Tomlyn model merge instead of a full rewrite, so unknown top-level tables ([cat_hub], [launcher]) survive a .NET engine setup save. Mirrors the Rust engine's owned-key removal+reinsert. Adds a preservation test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * scripts: build and launch cathub from the unified workflow build.ps1 now publishes qsoripper-cathub alongside qsoripper-server. Start-CatHub.ps1 prefers the published binary for instant startup and auto-resolves the unified config.toml ([cat_hub]) when present, falling back to the standalone sample. launcher.ps1 gains -WithCatHub to bring the radio daemon up first so engines/UIs can connect to its rigctld face. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: document unified config.toml and cold-start cathub workflow Describe the shared [cat_hub] table in config.toml, merge-preserving setup saves, and the build.ps1 + launcher.ps1 -WithCatHub cold-start flow in the engine spec and operator runbook. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix -Debug param collision in Start-CatHub.ps1 PowerShell's [CmdletBinding()] reserves -Debug as a common parameter, so declaring a custom [switch]$Debug made the script fail to load. Rename the switch to -DebugBuild and update launcher.ps1's -WithCatHub passthrough. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Make cathub hub-vs-client serial port mapping discoverable Operators were confused that an application's COM dropdown does not list the daemon-side port (e.g. ARCP-590 sees COM31 but not COM30). Clarify in three places that the hub OWNS the 'transport' port and the app must connect to the paired com0com port: - cathub --dry-run / startup now prints a 'Client connection guide' mapping each face to where its application should connect (paired serial port) and each Hamlib NET endpoint to its TCP bind. - The serial-face startup log line notes the port is hub-owned. - config/cathub.toml and the setup runbook gain an explicit pair table plus a troubleshooting note that the daemon-side port intentionally does not appear in app COM dropdowns. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Document that cathub radio baud must match the rig's CAT port speed The TS-590's PC/CAT speed (menu 62) is 57600 here, not 115200. cathub opens COM4 at [radio].baud, so a mismatch means the daemon opens the port but never talks to the radio. Set the sample to 57600 and clarify in the runbook that [radio].baud must match the rig while [[face]].baud is nominal (com0com ignores it). Adds a troubleshooting bullet for the silent timeout/stale symptom. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * cathub: assert RTS/DTR when opening serial radio port The Kenwood TS-590 gates its CAT transmit on the RTS line and sends no replies unless RTS is asserted. The hub opened the serial port without touching the modem-control lines, so every poll timed out and the daemon served default state (freq=0) to all clients. Assert RTS and DTR after opening the port, matching the line state OmniRig/Hamlib clients use. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * cathub: fix Kenwood AI; read handshake and add transport/face tracing AI; is a read query, not a write. The Kenwood dialects treated a bare AI; as a write with an empty payload, calling set_ai(false) and returning an empty reply. Clients like ARCP-590 that poll AI; as a keepalive and rely entirely on auto-info push never received a valid AI; answer, so they never enabled push and froze (showing BUSY, not tracking the dial). Branch AI handling on read vs write: a read now reports the face's current auto-info state via a shared ai_frame() helper without mutating it; a write still toggles per-face virtualized auto-info. Add regression tests to both ts590 and ts2000 dialects. Also add high-signal tracing to the radio transport (tx/rx frames, reply timeout warnings, dropped unsolicited frames) and per-face request/reply/ notify tracing, and document the auto-info behavior and CATHUB_LOG tracing in the operator runbook. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(cathub): relay unmodeled radio frames to native pass-through faces Native clients like ARCP-590 that enable auto-information run client-side feature state machines (e.g. the NB off/NB1/NB2/off cycle) that advance by observing the radio's echo on the CAT stream. cathub forwarded the writes but dropped the radio's unmodeled echoes, so those clients stalled on first press. Add a RadioEvent enum so modeled changes and unmodeled native frames travel on the same ordered broadcast. StateHandle::record_raw relays verbatim frames without touching the snapshot or native-push coverage. Faces forward them via a new ClientDialect::format_passthrough hook; the Kenwood TS-590 dialect relays them only when the face has auto-information enabled. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * cathub: HDSDR read+write face and rig-control freshness guidance Update the hdsdr-omnirig sample face to perms=[read, write] so OmniRig click-to-tune sets the radio (FA/FB/MD) while FR/FT VFO-target writes stay rejected (no A/B oscillation). Document keeping the engine's rig_control stale_threshold_ms low (~200) when reading through cathub so the GUI/TUI frequency display tracks knob turns promptly instead of lagging by the stale window. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Accept Hamlib decimal set_freq format in cathub hamlib_net WSJT-X and other Hamlib clients format set_freq as a %f double (e.g. 'F 14040005.000000'). The handler only parsed u64, so every real set_freq returned RPRT -1 ('Invalid parameter while setting frequency'). Add parse_freq_hz to accept integer or decimal Hz, plus per-frame trace logging in serve_conn for live rigctl debugging. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Key cathub PTT on any non-zero Hamlib set_ptt value WSJT-X transmits in Data/Pkt mode by sending 'T 3' (RIG_PTT_ON_DATA), and other clients may send 'T 2' (on mic). The handler only treated '1' as keyed, so 'T 3' was parsed as unkey: the hub returned RPRT 0 but left the radio in RX, so transmit silently failed. Treat 1/2/3 as keyed and only 0 (or a missing arg) as unkey. Also document the WSJT-X mode guidance (use Mode=None or USB; PKTUSB data sub-modes are not mapped to the TS-590 DATA mode) in cathub-setup.md. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * cathub: map Hamlib PTT source to TS-590 TX/TX0/TX1 (fix WSJT-X data beep) WSJT-X keys digital modes with RIG_PTT_ON_DATA (T 3), but the hub sent a bare TX; for every keyed value. On a TS-590 a bare TX; transmits from the mic path and emits a data-confirmation beep (Morse 'U') on each key/unkey. Add a PttSource to SetPtt and translate it faithfully, mirroring Hamlib's kenwood backend: T 1 -> TX;, T 2 (mic) -> TX0;, T 3 (data) -> TX1;, T 0 -> RX;. TX1; routes the DATA/USB audio for digital modes and stops the beep. Document the mapping in the WSJT-X setup notes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(cathub): correct TS-590 PC-control beep cause (radio menu, not PTT form) The Morse 'U' beep WSJT-X users hear on the TS-590 is the radio's 'beep output for PC control commands' menu setting, not a CAT command form the hub can change. Reframe the WSJT-X PTT note (TX1 is the faithful Hamlib data-PTT mapping for audio routing) and add a 'Required radio setting' section telling operators to turn the PC-control beep OFF on the front panel. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * cathub: suppress redundant CAT sets to stop TS-590 PC-control beep The hub forwarded every modeled write to the radio unconditionally, so clients like WSJT-X that re-assert mode/frequency on each poll made the TS-590 emit its PC-control beep (a Morse U) on every no-op set. A native Hamlib driver never beeps because it caches state and only sends a value when it changes. Add Snapshot::is_redundant and gate apply_modeled on it so a modeled write reaches the wire only when it would change the radio. Frequency, mode, split, RIT and XIT are deduplicated against the current snapshot; PTT is never suppressed so keying and unkeying always reach the radio. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * cathub: dedup TS-590 mode sets by wire digit to silence WSJT-X beep WSJT-X asserts PKTUSB, which maps to Mode::Unknown and never compared equal to the polled-back Mode::Usb, so the hub kept forwarding MD2; to the radio. The TS-590 beeps on every mode set it receives (frequency sets are silent), producing a repeated U tone on FT8<->WSPR switches and polls. Compare SetMode redundancy by the Kenwood wire digit instead of enum identity so Usb/Unknown (both MD2) are treated as no-ops, matching native Hamlib which never re-sends an unchanged mode. Genuine mode changes still send one MD and beep once. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * cathub: map WSJT-X PKTUSB/PKTLSB to TS-590 DATA sub-mode WSJT-X (and other Hamlib NET clients) drive FT8/WSPR with the PKTUSB/ PKTLSB mode tokens. The hub previously modeled only the native Kenwood MD mode set and honored those tokens as plain USB/LSB, so the radio never entered its DATA sub-mode and the mode read-back disagreed with what the client requested. The TS-590 models data as two independent wire facts: the base mode (MD2=USB / MD1=LSB) plus a separate DATA flag (DA1/DA0). Readback must poll both MD; and DA;; setting MD does not change DA. PKTUSB = MD2+DA1, PKTLSB = MD1+DA1, plain = MDx+DA0. Model the DATA flag as a separate StateChange/StateMutation field so the stateless parse_event path composes correctly for both polling and native push. The modeled write path issues two mutations (base mode then DATA flag), each deduped independently, so FT8<->WSPR (both PKTUSB) is suppressed after the first set and the radio does not chirp. get_mode recomposes the PKT token; set_mode decomposes it into base + DATA flag. Poll now includes DA; and the dump_state mode mask advertises the PKT modes. Verified live against a TS-590SG: PKTUSB/PKTLSB round-trip and light the DATA indicator; plain USB clears it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- config/cathub.toml | 86 +++ crates/cathub/Cargo.toml | 35 ++ crates/cathub/src/backend/kenwood/mod.rs | 3 + crates/cathub/src/backend/kenwood/ts590.rs | 448 +++++++++++++ crates/cathub/src/backend/loopback.rs | 193 ++++++ crates/cathub/src/backend/mod.rs | 171 +++++ crates/cathub/src/backend/rigctld.rs | 252 ++++++++ crates/cathub/src/config.rs | 610 ++++++++++++++++++ crates/cathub/src/dialect/kenwood/mod.rs | 96 +++ crates/cathub/src/dialect/kenwood/ts2000.rs | 249 ++++++++ crates/cathub/src/dialect/kenwood/ts590.rs | 351 +++++++++++ crates/cathub/src/dialect/mod.rs | 371 +++++++++++ crates/cathub/src/error.rs | 93 +++ crates/cathub/src/events.rs | 139 ++++ crates/cathub/src/hamlib_dump_state.txt | 65 ++ crates/cathub/src/hamlib_net.rs | 662 ++++++++++++++++++++ crates/cathub/src/integration.rs | 289 +++++++++ crates/cathub/src/lib.rs | 426 +++++++++++++ crates/cathub/src/logging.rs | 55 ++ crates/cathub/src/main.rs | 21 + crates/cathub/src/model.rs | 490 +++++++++++++++ crates/cathub/src/permissions.rs | 122 ++++ crates/cathub/src/ptt.rs | 155 +++++ crates/cathub/src/radio/mod.rs | 453 ++++++++++++++ crates/cathub/src/serial_face.rs | 202 ++++++ crates/cathub/src/state.rs | 551 ++++++++++++++++ crates/cathub/tests/cli.rs | 56 ++ crates/cathub/tests/fixtures/dump_state.txt | 65 ++ docs/integration/setup.md | 235 +++++++ scripts/Get-CatHubLog.ps1 | 46 ++ scripts/Start-CatHub.ps1 | 100 +++ scripts/Stop-CatHub.ps1 | 28 + 32 files changed, 7118 insertions(+) create mode 100644 config/cathub.toml create mode 100644 crates/cathub/Cargo.toml create mode 100644 crates/cathub/src/backend/kenwood/mod.rs create mode 100644 crates/cathub/src/backend/kenwood/ts590.rs create mode 100644 crates/cathub/src/backend/loopback.rs create mode 100644 crates/cathub/src/backend/mod.rs create mode 100644 crates/cathub/src/backend/rigctld.rs create mode 100644 crates/cathub/src/config.rs create mode 100644 crates/cathub/src/dialect/kenwood/mod.rs create mode 100644 crates/cathub/src/dialect/kenwood/ts2000.rs create mode 100644 crates/cathub/src/dialect/kenwood/ts590.rs create mode 100644 crates/cathub/src/dialect/mod.rs create mode 100644 crates/cathub/src/error.rs create mode 100644 crates/cathub/src/events.rs create mode 100644 crates/cathub/src/hamlib_dump_state.txt create mode 100644 crates/cathub/src/hamlib_net.rs create mode 100644 crates/cathub/src/integration.rs create mode 100644 crates/cathub/src/lib.rs create mode 100644 crates/cathub/src/logging.rs create mode 100644 crates/cathub/src/main.rs create mode 100644 crates/cathub/src/model.rs create mode 100644 crates/cathub/src/permissions.rs create mode 100644 crates/cathub/src/ptt.rs create mode 100644 crates/cathub/src/radio/mod.rs create mode 100644 crates/cathub/src/serial_face.rs create mode 100644 crates/cathub/src/state.rs create mode 100644 crates/cathub/tests/cli.rs create mode 100644 crates/cathub/tests/fixtures/dump_state.txt create mode 100644 docs/integration/setup.md create mode 100644 scripts/Get-CatHubLog.ps1 create mode 100644 scripts/Start-CatHub.ps1 create mode 100644 scripts/Stop-CatHub.ps1 diff --git a/config/cathub.toml b/config/cathub.toml new file mode 100644 index 0000000..8816604 --- /dev/null +++ b/config/cathub.toml @@ -0,0 +1,86 @@ +# qsoripper-cathub configuration for this station. +# +# One daemon owns the TS-590 on COM4 (Silicon Labs CP210x USB-UART) and fans it out to +# every application over its native protocol. No application talks to the radio directly, +# so there is no VFO A/B oscillation, no frequency drift, and no PTT contention. +# +# Validate without touching hardware: +# cargo run -p qsoripper-cathub -- --config config/cathub.toml --dry-run +# +# Virtual serial pairs (com0com): the daemon binds the first port of each pair, the +# application binds the second. Create one pair per serial client. + +[radio] +backend = "ts590" # first-class native Kenwood TS-590 driver (no Hamlib linked) +model = "TS-590SG" +transport = "serial" +port = "COM4" +baud = 57600 # MUST match the radio's PC/CAT port speed (TS-590 menu 62) + +[poll] +baseline_ms = 200 # used only while native push is unavailable +heartbeat_ms = 2000 # slow liveness poll once native push covers a field + +[ptt] +max_tx_ms = 300000 # 5-minute hard transmit ceiling (backstop for a crashed client) + +[events] +native_push = true # daemon enables and owns the TS-590 AI2; stream + +# --- Hamlib NET (rigctld-compatible) TCP endpoints ------------------------------------- + +# Read-only endpoint for the QsoRipper engine's RigctldProvider. +[[hamlib_net]] +name = "engine" +bind = "127.0.0.1:4532" +perms = ["read"] + +# Write + PTT endpoint for WSJT-X (configured as Hamlib NET rigctl). +[[hamlib_net]] +name = "wsjtx" +bind = "127.0.0.1:4533" +perms = ["read", "write", "ptt"] + +# Read/write endpoint for Log4OM (CAT over Hamlib NET rigctl). +[[hamlib_net]] +name = "log4om" +bind = "127.0.0.1:4534" +perms = ["read", "write"] + +# --- Serial faces (com0com virtual pairs) ---------------------------------------------- +# +# IMPORTANT: each com0com pair has TWO port numbers. The daemon binds the port named below +# (`transport`); your application must connect to the OTHER port in the same pair. Never +# point an application at the daemon's port -- they cannot both open the same port. +# +# pair daemon binds (transport) application connects to +# COM10 <-> COM11 COM10 COM11 (HDSDR / OmniRig) +# COM20 <-> COM21 COM20 COM21 (N1MM) +# COM30 <-> COM31 COM30 COM31 (ARCP-590) + +# HDSDR via OmniRig as a TS-2000-style controller. Read + write: the panadapter follows the +# radio, and click-to-tune on the waterfall sets the radio frequency/mode (FA/FB/MD writes). +# The ts2000 dialect still rejects VFO-target writes (FR/FT) by design, so HDSDR can tune but +# can never oscillate the TS-590's A/B VFO selection. +[[face]] +name = "hdsdr-omnirig" +transport = "COM10" # OmniRig binds COM11 +baud = 115200 +dialect = "ts2000" +perms = ["read", "write"] + +# N1MM Logger+ as a native TS-590. +[[face]] +name = "n1mm" +transport = "COM20" # N1MM binds COM21 +baud = 115200 +dialect = "ts590" +perms = ["read", "write", "ptt"] + +# ARCP-590 control panel: full faceplate control, including EX-menu (config_write). +[[face]] +name = "arcp590" +transport = "COM30" # ARCP-590 binds COM31 +baud = 115200 +dialect = "ts590" +perms = ["read", "write", "ptt", "config_write"] diff --git a/crates/cathub/Cargo.toml b/crates/cathub/Cargo.toml new file mode 100644 index 0000000..e2a1d92 --- /dev/null +++ b/crates/cathub/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "qsoripper-cathub" +description = "Multi-client CAT hub daemon that owns the radio serial port and fans it out to multiple clients" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[lib] +name = "qsoripper_cathub" +path = "src/lib.rs" + +[[bin]] +name = "qsoripper-cathub" +path = "src/main.rs" + +[dependencies] +async-trait = { workspace = true } +bytes = { workspace = true } +clap = { workspace = true } +serde = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync", "io-util", "time", "net", "signal"] } +serial2-tokio = { workspace = true } +toml = { workspace = true } +tracing = { workspace = true } +tracing-appender = { workspace = true } +tracing-subscriber = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["test-util"] } + +[lints] +workspace = true diff --git a/crates/cathub/src/backend/kenwood/mod.rs b/crates/cathub/src/backend/kenwood/mod.rs new file mode 100644 index 0000000..1366843 --- /dev/null +++ b/crates/cathub/src/backend/kenwood/mod.rs @@ -0,0 +1,3 @@ +//! Kenwood-family backends. + +pub(crate) mod ts590; diff --git a/crates/cathub/src/backend/kenwood/ts590.rs b/crates/cathub/src/backend/kenwood/ts590.rs new file mode 100644 index 0000000..e5db923 --- /dev/null +++ b/crates/cathub/src/backend/kenwood/ts590.rs @@ -0,0 +1,448 @@ +//! The first-class native TS-590 backend. +//! +//! It speaks Kenwood `;`-terminated ASCII CAT directly and is the certified-native path. +//! Crucially, its poll command set contains **no `FR`/`FT` VFO-select commands** — polling +//! never retargets a VFO, which is the root-cause fix for the A/B oscillation seen when a +//! status poll toggled the receive VFO (design §8.8). Sets are fire-and-forget (Kenwood +//! radios do not acknowledge a set), and the radio's auto-information stream (`AI2;`) drives +//! native push. + +use async_trait::async_trait; + +use crate::backend::{ + BackendCapabilities, BackendError, Framing, NativeCommandFamily, RadioBackend, SplitStyle, + TrustTier, +}; +use crate::model::{Mode, PttSource, RadioEventSource, StateMutation, Vfo}; +use crate::radio::{Expect, RadioLink}; +use crate::state::StateHandle; + +/// The baseline poll command set. Read-only queries only: **never** `FR`/`FT` (§8.8). +const POLL_COMMANDS: &[&[u8]] = &[b"FA;", b"FB;", b"MD;", b"DA;"]; + +/// The native TS-590 backend. +#[derive(Clone, Default)] +pub(crate) struct Ts590Backend; + +impl Ts590Backend { + /// Create the backend. + pub(crate) fn new() -> Self { + Ts590Backend + } +} + +/// Split a `;`-terminated frame into its leading alphabetic verb and the remaining payload. +fn split_frame(frame: &[u8]) -> Option<(&[u8], &[u8])> { + let body = match frame.iter().position(|&b| b == b';') { + Some(end) => frame.get(..end)?, + None => frame, + }; + let verb_len = body + .iter() + .position(|b| !b.is_ascii_alphabetic()) + .unwrap_or(body.len()); + if verb_len == 0 { + return None; + } + let verb = body.get(..verb_len)?; + let payload = body.get(verb_len..).unwrap_or(&[]); + Some((verb, payload)) +} + +fn parse_u64(bytes: &[u8]) -> Option { + std::str::from_utf8(bytes).ok()?.trim().parse::().ok() +} + +/// Build an `FA`/`FB` frequency set frame. +fn freq_set(vfo: Vfo, hz: u64) -> Vec { + let verb = match vfo { + Vfo::A => "FA", + Vfo::B => "FB", + }; + format!("{verb}{hz:011};").into_bytes() +} + +/// Whether a passthrough frame is a query (bare verb then `;`) versus a set. +fn is_query(frame: &[u8]) -> bool { + matches!(split_frame(frame), Some((_, payload)) if payload.is_empty()) +} + +#[async_trait] +impl RadioBackend for Ts590Backend { + async fn poll(&self, link: &RadioLink, state: &StateHandle) -> Result<(), BackendError> { + for &cmd in POLL_COMMANDS { + let verb = cmd.get(..2).unwrap_or(cmd).to_vec(); + let reply = link.submit(cmd.to_vec(), Expect::Reply(vec![verb])).await?; + if let Some(mutation) = self.parse_event(&reply) { + state.record(mutation.into_change(), RadioEventSource::PollDiff); + } + } + Ok(()) + } + + async fn apply( + &self, + mutation: StateMutation, + link: &RadioLink, + state: &StateHandle, + ) -> Result<(), BackendError> { + let bytes = match mutation { + StateMutation::SetVfoFreq { vfo, hz } => freq_set(vfo, hz), + StateMutation::SetMode { mode, .. } => { + vec![b'M', b'D', mode.to_kenwood_digit(), b';'] + } + // The DATA sub-mode is an independent flag on the TS-590, set with `DA1;`/`DA0;` + // and read back with `DA;`. It composes with the base `MD` mode so a USB+DATA + // (PKTUSB) request is `MD2;` followed by `DA1;`. + StateMutation::SetDataMode { on, .. } => { + if on { + b"DA1;".to_vec() + } else { + b"DA0;".to_vec() + } + } + StateMutation::SetPtt { keyed, source } => { + if keyed { + // Mirror Hamlib's TS-590 mapping so digital clients modulate from the + // DATA/USB path (`TX1;`) and the radio does not emit the data beep that + // a bare `TX;` triggers on the TS-590. + match source { + PttSource::Generic => b"TX;".to_vec(), + PttSource::Mic => b"TX0;".to_vec(), + PttSource::Data => b"TX1;".to_vec(), + } + } else { + b"RX;".to_vec() + } + } + StateMutation::SetSplit { enabled, tx_vfo } => { + // Split is a deliberate, modeled write: receive on A, transmit on the chosen + // VFO. This is the only path that issues FR/FT, never a status poll. + let tx = match tx_vfo.unwrap_or(Vfo::B) { + Vfo::A => b'0', + Vfo::B => b'1', + }; + if enabled { + [b"FR0;".as_slice(), &[b'F', b'T', tx, b';']].concat() + } else { + b"FR0;FT0;".to_vec() + } + } + StateMutation::SetRit { enabled, .. } => { + if enabled { + b"RT1;".to_vec() + } else { + b"RT0;".to_vec() + } + } + StateMutation::SetXit { enabled, .. } => { + if enabled { + b"XT1;".to_vec() + } else { + b"XT0;".to_vec() + } + } + }; + // Kenwood sets are not acknowledged: fire and forget. + link.submit(bytes, Expect::NoReply).await?; + state.record(mutation.into_change(), RadioEventSource::OptimisticWrite); + Ok(()) + } + + fn parse_event(&self, frame: &[u8]) -> Option { + let (verb, payload) = split_frame(frame)?; + match verb { + b"FA" => Some(StateMutation::SetVfoFreq { + vfo: Vfo::A, + hz: parse_u64(payload)?, + }), + b"FB" => Some(StateMutation::SetVfoFreq { + vfo: Vfo::B, + hz: parse_u64(payload)?, + }), + b"MD" => Some(StateMutation::SetMode { + vfo: Vfo::A, + mode: Mode::from_kenwood_digit(*payload.first()?), + }), + b"DA" => Some(StateMutation::SetDataMode { + vfo: Vfo::A, + on: *payload.first()? == b'1', + }), + _ => None, + } + } + + async fn passthrough(&self, raw: &[u8], link: &RadioLink) -> Result, BackendError> { + let expect = if is_query(raw) { + let verb = split_frame(raw) + .map(|(v, _)| v.to_vec()) + .unwrap_or_default(); + Expect::Reply(vec![verb]) + } else { + Expect::NoReply + }; + link.submit(raw.to_vec(), expect).await + } + + fn capabilities(&self) -> BackendCapabilities { + BackendCapabilities { + model: "TS-590".to_string(), + vfo_count: 2, + has_rit: true, + has_xit: true, + has_smeter: true, + split: SplitStyle::VfoPair, + native_push: true, + native_command_family: Some(NativeCommandFamily::Kenwood), + framing: Framing::SemicolonTerminated, + freq_min_hz: 30_000, + freq_max_hz: 60_000_000, + trust: TrustTier::CertifiedNative, + } + } + + fn native_push_enable(&self) -> Option> { + Some(b"AI2;".to_vec()) + } +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)] +mod tests { + use super::*; + use crate::radio::{link_channel, run_transport}; + use std::sync::Arc; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + #[test] + fn poll_commands_never_retarget_a_vfo() { + for cmd in POLL_COMMANDS { + assert!( + !cmd.starts_with(b"FR") && !cmd.starts_with(b"FT"), + "poll set must not contain a VFO-select command" + ); + } + } + + #[test] + fn parse_event_reads_freq_and_mode() { + let backend = Ts590Backend::new(); + assert_eq!( + backend.parse_event(b"FA00007030000;"), + Some(StateMutation::SetVfoFreq { + vfo: Vfo::A, + hz: 7_030_000 + }) + ); + assert_eq!( + backend.parse_event(b"FB00014250000;"), + Some(StateMutation::SetVfoFreq { + vfo: Vfo::B, + hz: 14_250_000 + }) + ); + assert_eq!( + backend.parse_event(b"MD3;"), + Some(StateMutation::SetMode { + vfo: Vfo::A, + mode: Mode::Cw + }) + ); + assert_eq!( + backend.parse_event(b"DA1;"), + Some(StateMutation::SetDataMode { + vfo: Vfo::A, + on: true + }) + ); + assert_eq!( + backend.parse_event(b"DA0;"), + Some(StateMutation::SetDataMode { + vfo: Vfo::A, + on: false + }) + ); + assert_eq!(backend.parse_event(b"ZZ;"), None); + } + + #[test] + fn capabilities_are_certified_native_with_push() { + let caps = Ts590Backend::new().capabilities(); + assert_eq!(caps.trust, TrustTier::CertifiedNative); + assert!(caps.native_push); + assert!(caps.supports_passthrough()); + assert_eq!( + Ts590Backend::new().native_push_enable(), + Some(b"AI2;".to_vec()) + ); + } + + #[tokio::test] + async fn apply_freq_writes_kenwood_frame() { + let (link, raw_rx) = link_channel(); + let backend = Arc::new(Ts590Backend::new()); + let arc: Arc = backend.clone(); + let state = StateHandle::new(); + let (mut radio_side, server) = tokio::io::duplex(1024); + tokio::spawn(run_transport(server, arc, state.clone(), raw_rx)); + + backend + .apply( + StateMutation::SetVfoFreq { + vfo: Vfo::A, + hz: 7_030_000, + }, + &link, + &state, + ) + .await + .expect("apply"); + + let mut buf = vec![0u8; 14]; + radio_side.read_exact(&mut buf).await.expect("read frame"); + assert_eq!(&buf, b"FA00007030000;"); + assert_eq!(state.snapshot().vfo(Vfo::A).freq_hz, 7_030_000); + } + + #[tokio::test] + async fn apply_ptt_keys_and_unkeys() { + let (link, raw_rx) = link_channel(); + let backend = Arc::new(Ts590Backend::new()); + let arc: Arc = backend.clone(); + let state = StateHandle::new(); + let (mut radio_side, server) = tokio::io::duplex(1024); + tokio::spawn(run_transport(server, arc, state.clone(), raw_rx)); + + backend + .apply( + StateMutation::SetPtt { + keyed: true, + source: PttSource::Generic, + }, + &link, + &state, + ) + .await + .expect("key"); + let mut buf = vec![0u8; 3]; + radio_side.read_exact(&mut buf).await.expect("read tx"); + assert_eq!(&buf, b"TX;"); + assert!(state.snapshot().ptt); + } + + #[tokio::test] + async fn ptt_data_source_keys_with_tx1() { + let (link, raw_rx) = link_channel(); + let backend = Arc::new(Ts590Backend::new()); + let arc: Arc = backend.clone(); + let state = StateHandle::new(); + let (mut radio_side, server) = tokio::io::duplex(1024); + tokio::spawn(run_transport(server, arc, state.clone(), raw_rx)); + + backend + .apply( + StateMutation::SetPtt { + keyed: true, + source: PttSource::Data, + }, + &link, + &state, + ) + .await + .expect("key"); + let mut buf = vec![0u8; 4]; + radio_side.read_exact(&mut buf).await.expect("read tx"); + assert_eq!(&buf, b"TX1;"); + assert!(state.snapshot().ptt); + } + + #[tokio::test] + async fn poll_reads_back_freq_and_mode() { + let (link, raw_rx) = link_channel(); + let backend = Arc::new(Ts590Backend::new()); + let arc: Arc = backend.clone(); + let state = StateHandle::new(); + let (radio_side, server) = tokio::io::duplex(1024); + tokio::spawn(run_transport(server, arc, state.clone(), raw_rx)); + + // A fake radio that answers the three poll queries. + tokio::spawn(async move { + let (mut rd, mut wr) = tokio::io::split(radio_side); + let mut frame = Vec::new(); + let mut byte = [0u8; 1]; + loop { + if rd.read(&mut byte).await.unwrap_or(0) == 0 { + break; + } + frame.push(byte[0]); + if byte[0] == b';' { + let answer: &[u8] = match frame.as_slice() { + b"FA;" => b"FA00007030000;", + b"FB;" => b"FB00014250000;", + b"MD;" => b"MD3;", + b"DA;" => b"DA1;", + _ => b"", + }; + let _ = wr.write_all(answer).await; + frame.clear(); + } + } + }); + + backend.poll(&link, &state).await.expect("poll"); + let snap = state.snapshot(); + assert_eq!(snap.vfo(Vfo::A).freq_hz, 7_030_000); + assert_eq!(snap.vfo(Vfo::B).freq_hz, 14_250_000); + assert_eq!(snap.vfo(Vfo::A).mode, Mode::Cw); + assert!(snap.vfo(Vfo::A).data, "DA; reply should set the DATA flag"); + } + + #[tokio::test] + async fn apply_data_mode_writes_da_frame() { + let (link, raw_rx) = link_channel(); + let backend = Arc::new(Ts590Backend::new()); + let arc: Arc = backend.clone(); + let state = StateHandle::new(); + let (mut radio_side, server) = tokio::io::duplex(1024); + tokio::spawn(run_transport(server, arc, state.clone(), raw_rx)); + + backend + .apply( + StateMutation::SetDataMode { + vfo: Vfo::A, + on: true, + }, + &link, + &state, + ) + .await + .expect("set data on"); + let mut buf = vec![0u8; 4]; + radio_side.read_exact(&mut buf).await.expect("read da on"); + assert_eq!(&buf, b"DA1;"); + assert!(state.snapshot().vfo(Vfo::A).data); + + backend + .apply( + StateMutation::SetDataMode { + vfo: Vfo::A, + on: false, + }, + &link, + &state, + ) + .await + .expect("set data off"); + let mut buf = vec![0u8; 4]; + radio_side.read_exact(&mut buf).await.expect("read da off"); + assert_eq!(&buf, b"DA0;"); + assert!(!state.snapshot().vfo(Vfo::A).data); + } + + #[test] + fn query_detection_distinguishes_get_from_set() { + assert!(is_query(b"FA;")); + assert!(!is_query(b"FA00007030000;")); + assert!(!is_query(b"EX0050000;")); + } +} diff --git a/crates/cathub/src/backend/loopback.rs b/crates/cathub/src/backend/loopback.rs new file mode 100644 index 0000000..1bee4d0 --- /dev/null +++ b/crates/cathub/src/backend/loopback.rs @@ -0,0 +1,193 @@ +//! The in-memory loopback backend used by tests and the `loopback` config option. +//! +//! It records every mutation and passthrough it receives and exposes a mutable "truth" +//! frequency so a test can simulate a front-panel knob turn that a poll then diffs into +//! state. It has no native push (so the poller always runs at baseline against it). + +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, PoisonError}; + +use async_trait::async_trait; + +use crate::backend::{ + BackendCapabilities, BackendError, Framing, NativeCommandFamily, RadioBackend, SplitStyle, + TrustTier, +}; +use crate::model::{RadioEventSource, StateChange, StateMutation, Vfo}; +use crate::radio::RadioLink; +use crate::state::StateHandle; + +/// The default VFO-A "truth" the loopback radio reports when polled. +const DEFAULT_TRUTH_FREQ_A: u64 = 14_074_000; + +/// A deterministic in-memory backend. +#[derive(Clone)] +pub(crate) struct LoopbackBackend { + mutations: Arc>>, + passthroughs: Arc>>>, + polls: Arc, + truth_freq_a: Arc, +} + +impl LoopbackBackend { + /// Create a fresh loopback backend. + pub(crate) fn new() -> Self { + LoopbackBackend { + mutations: Arc::new(Mutex::new(Vec::new())), + passthroughs: Arc::new(Mutex::new(Vec::new())), + polls: Arc::new(AtomicUsize::new(0)), + truth_freq_a: Arc::new(AtomicU64::new(DEFAULT_TRUTH_FREQ_A)), + } + } + + /// The mutations applied so far (in order). + #[cfg(test)] + pub(crate) fn mutations(&self) -> Vec { + self.mutations + .lock() + .unwrap_or_else(PoisonError::into_inner) + .clone() + } + + /// The raw passthrough payloads forwarded so far (in order). + #[cfg(test)] + pub(crate) fn passthroughs(&self) -> Vec> { + self.passthroughs + .lock() + .unwrap_or_else(PoisonError::into_inner) + .clone() + } + + /// How many poll cycles have run. + #[cfg(test)] + pub(crate) fn poll_count(&self) -> usize { + self.polls.load(Ordering::SeqCst) + } + + /// Simulate a front-panel change to VFO A's frequency; the next poll diffs it. + #[cfg(test)] + pub(crate) fn set_truth_freq_a(&self, hz: u64) { + self.truth_freq_a.store(hz, Ordering::SeqCst); + } +} + +#[async_trait] +impl RadioBackend for LoopbackBackend { + async fn poll(&self, _link: &RadioLink, state: &StateHandle) -> Result<(), BackendError> { + self.polls.fetch_add(1, Ordering::SeqCst); + // The loopback radio only surfaces VFO-A frequency truth; recording just one field + // keeps the "one broadcast per real change" invariant easy to reason about. + let hz = self.truth_freq_a.load(Ordering::SeqCst); + state.record( + StateChange::Freq { vfo: Vfo::A, hz }, + RadioEventSource::PollDiff, + ); + Ok(()) + } + + async fn apply( + &self, + mutation: StateMutation, + _link: &RadioLink, + state: &StateHandle, + ) -> Result<(), BackendError> { + self.mutations + .lock() + .unwrap_or_else(PoisonError::into_inner) + .push(mutation); + state.record(mutation.into_change(), RadioEventSource::OptimisticWrite); + Ok(()) + } + + fn parse_event(&self, _frame: &[u8]) -> Option { + None + } + + async fn passthrough(&self, raw: &[u8], _link: &RadioLink) -> Result, BackendError> { + self.passthroughs + .lock() + .unwrap_or_else(PoisonError::into_inner) + .push(raw.to_vec()); + Ok(raw.to_vec()) + } + + fn capabilities(&self) -> BackendCapabilities { + BackendCapabilities { + model: "loopback".to_string(), + vfo_count: 2, + has_rit: true, + has_xit: true, + has_smeter: true, + split: SplitStyle::VfoPair, + native_push: false, + native_command_family: Some(NativeCommandFamily::Kenwood), + framing: Framing::SemicolonTerminated, + freq_min_hz: 30_000, + freq_max_hz: 60_000_000, + trust: TrustTier::Loopback, + } + } + + fn native_push_enable(&self) -> Option> { + None + } +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)] +mod tests { + use super::*; + use crate::radio::detached_link; + + #[tokio::test] + async fn poll_records_truth_and_counts() { + let backend = LoopbackBackend::new(); + let state = StateHandle::new(); + backend.set_truth_freq_a(7_123_000); + backend.poll(&detached_link(), &state).await.expect("poll"); + assert_eq!(backend.poll_count(), 1); + assert_eq!(state.snapshot().vfo(Vfo::A).freq_hz, 7_123_000); + } + + #[tokio::test] + async fn apply_records_mutation_and_state() { + let backend = LoopbackBackend::new(); + let state = StateHandle::new(); + backend + .apply( + StateMutation::SetVfoFreq { + vfo: Vfo::A, + hz: 14_250_000, + }, + &detached_link(), + &state, + ) + .await + .expect("apply"); + assert_eq!( + backend.mutations(), + vec![StateMutation::SetVfoFreq { + vfo: Vfo::A, + hz: 14_250_000 + }] + ); + assert_eq!(state.snapshot().vfo(Vfo::A).freq_hz, 14_250_000); + } + + #[tokio::test] + async fn passthrough_echoes_and_records() { + let backend = LoopbackBackend::new(); + let reply = backend + .passthrough(b"EX0050000;", &detached_link()) + .await + .expect("passthrough"); + assert_eq!(reply, b"EX0050000;"); + assert_eq!(backend.passthroughs(), vec![b"EX0050000;".to_vec()]); + } + + #[test] + fn loopback_has_no_native_push() { + assert!(LoopbackBackend::new().native_push_enable().is_none()); + assert!(!LoopbackBackend::new().capabilities().native_push); + } +} diff --git a/crates/cathub/src/backend/mod.rs b/crates/cathub/src/backend/mod.rs new file mode 100644 index 0000000..5d48b82 --- /dev/null +++ b/crates/cathub/src/backend/mod.rs @@ -0,0 +1,171 @@ +//! The radio backend abstraction: the [`RadioBackend`] trait every concrete radio +//! implements, plus the [`BackendCapabilities`] it advertises. +//! +//! A backend is the only code that knows a specific radio's wire vocabulary. It maps the +//! neutral [`StateMutation`]/[`StateChange`] vocabulary to and from bytes and reports what +//! it can do (and how much it can be trusted) via [`BackendCapabilities`]. + +pub(crate) mod kenwood; +pub(crate) mod loopback; +pub(crate) mod rigctld; + +use async_trait::async_trait; + +pub(crate) use crate::error::BackendError; +use crate::model::StateMutation; +use crate::radio::RadioLink; +use crate::state::StateHandle; + +/// How the byte stream is split into frames. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Framing { + /// Kenwood/Yaesu style: each frame ends with `;`. + SemicolonTerminated, + /// Line protocols (e.g. `rigctld` net): each frame ends with `\n`. + LineTerminated, + /// Icom CI-V style: each frame ends with `0xFD`. Reserved for a future CI-V backend. + #[allow(dead_code)] + CiV, +} + +/// How a backend models split operation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SplitStyle { + /// Split is a TX/RX VFO pair (Kenwood, Yaesu, most rigs). + VfoPair, + /// The radio has no split concept. Reserved for single-VFO backends. + #[allow(dead_code)] + None, +} + +/// How much the daemon trusts a backend's wire behavior (design §7). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum TrustTier { + /// A first-party native driver that has been certified against the real radio. + CertifiedNative, + /// An out-of-process bridge (e.g. `rigctld`) that has not been soak-certified. + UncertifiedBridge, + /// The in-memory test backend. + Loopback, +} + +/// The native command family a backend can passthrough (for clients that need raw CAT). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum NativeCommandFamily { + /// Kenwood `;`-terminated ASCII CAT. + Kenwood, +} + +/// What a backend can do, advertised to faces and the poller. +#[derive(Debug, Clone)] +#[allow(clippy::struct_excessive_bools)] // Each flag is an independent capability bit. +pub(crate) struct BackendCapabilities { + /// A human-readable model identifier. + pub(crate) model: String, + /// Number of VFOs. + pub(crate) vfo_count: u8, + /// Whether the radio models RIT. + pub(crate) has_rit: bool, + /// Whether the radio models XIT. + pub(crate) has_xit: bool, + /// Whether the radio reports an S-meter. + pub(crate) has_smeter: bool, + /// How split is modeled. + pub(crate) split: SplitStyle, + /// Whether the radio has a native push (auto-information) stream. + pub(crate) native_push: bool, + /// The native command family available for passthrough, if any. + pub(crate) native_command_family: Option, + /// How frames are delimited. + pub(crate) framing: Framing, + /// Minimum tunable frequency in Hz. + pub(crate) freq_min_hz: u64, + /// Maximum tunable frequency in Hz. + pub(crate) freq_max_hz: u64, + /// Trust tier. + pub(crate) trust: TrustTier, +} + +impl BackendCapabilities { + /// Whether raw native passthrough is available (a native command family is present). + pub(crate) fn supports_passthrough(&self) -> bool { + self.native_command_family.is_some() + } + + /// A one-line human-readable summary (used in startup logging). + pub(crate) fn summary(&self) -> String { + format!( + "model={} vfos={} rit={} xit={} smeter={} split={:?} native_push={} \ + family={:?} framing={:?} freq={}..{} trust={:?} passthrough={}", + self.model, + self.vfo_count, + self.has_rit, + self.has_xit, + self.has_smeter, + self.split, + self.native_push, + self.native_command_family, + self.framing, + self.freq_min_hz, + self.freq_max_hz, + self.trust, + self.supports_passthrough(), + ) + } +} + +/// A concrete radio. Implementations own one radio's wire vocabulary; everything above +/// them speaks only the neutral [`StateMutation`]/[`StateChange`] vocabulary. +#[async_trait] +pub(crate) trait RadioBackend: Send + Sync { + /// Run one baseline poll cycle, recording observed state via `state`. + async fn poll(&self, link: &RadioLink, state: &StateHandle) -> Result<(), BackendError>; + + /// Apply a modeled mutation, recording the resulting state via `state`. + async fn apply( + &self, + mutation: StateMutation, + link: &RadioLink, + state: &StateHandle, + ) -> Result<(), BackendError>; + + /// Parse an unsolicited (native push) frame into a mutation, if recognized. + fn parse_event(&self, frame: &[u8]) -> Option; + + /// Forward a raw native command, returning the raw reply. + async fn passthrough(&self, raw: &[u8], link: &RadioLink) -> Result, BackendError>; + + /// The backend's advertised capabilities. + fn capabilities(&self) -> BackendCapabilities; + + /// The command that enables the radio's native push stream, if it has one. + /// Backends without a native push stream (e.g. an out-of-process rigctld bridge) + /// keep the default of `None` and are polled instead. + fn native_push_enable(&self) -> Option> { + None + } +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)] +mod tests { + use super::*; + + #[test] + fn passthrough_support_tracks_command_family() { + let mut caps = loopback::LoopbackBackend::new().capabilities(); + assert!(caps.supports_passthrough()); + caps.native_command_family = None; + assert!(!caps.supports_passthrough()); + } + + #[test] + fn summary_mentions_every_axis() { + let caps = loopback::LoopbackBackend::new().capabilities(); + let s = caps.summary(); + assert!(s.contains("model=")); + assert!(s.contains("native_push=")); + assert!(s.contains("trust=")); + assert!(s.contains("passthrough=")); + } +} diff --git a/crates/cathub/src/backend/rigctld.rs b/crates/cathub/src/backend/rigctld.rs new file mode 100644 index 0000000..3203b97 --- /dev/null +++ b/crates/cathub/src/backend/rigctld.rs @@ -0,0 +1,252 @@ +//! Out-of-process `rigctld` bridge backend (breadth path, uncertified by default). +//! +//! The daemon is the *sole client* of a daemon-private `rigctld` and speaks the rigctld +//! net protocol as a client. It provides modeled control (frequency, mode, PTT, split) +//! across every rig Hamlib supports, using each rig's correct native model. It carries +//! **no** native passthrough (rigctld normalizes the CAT away) and reports +//! `native_command_family: None`, so an `EX`-menu passthrough fails closed (§7.1, §8.8). + +use async_trait::async_trait; + +use crate::backend::{ + BackendCapabilities, BackendError, Framing, RadioBackend, SplitStyle, TrustTier, +}; +use crate::model::{Mode, PttSource, RadioEventSource, StateChange, StateMutation, Vfo}; +use crate::radio::{Expect, RadioLink}; +use crate::state::StateHandle; + +/// Poll commands for the bridge: get-frequency, get-mode, get-split, get-ptt. All are +/// read-only and never retarget a VFO, but the bridge cannot *certify* the radio-side +/// wire, so it stays uncertified. +const POLL_GET_FREQ: &[u8] = b"f\n"; +const POLL_GET_MODE: &[u8] = b"m\n"; +const POLL_GET_SPLIT: &[u8] = b"s\n"; + +/// Out-of-process `rigctld` bridge. +#[derive(Clone)] +pub(crate) struct RigctldBackend { + model: String, + certified: bool, +} + +impl RigctldBackend { + /// Create a bridge backend. `certified` reflects whether the operator has run the + /// §10.3 soak for this rigctld version+model+config; it is `false` by default. + pub(crate) fn new(model: impl Into, certified: bool) -> Self { + RigctldBackend { + model: model.into(), + certified, + } + } +} + +fn first_line(bytes: &[u8]) -> &[u8] { + let end = bytes + .iter() + .position(|&b| b == b'\n') + .unwrap_or(bytes.len()); + bytes.get(..end).unwrap_or(&[]) +} + +fn nth_line(bytes: &[u8], n: usize) -> &[u8] { + bytes.split(|&b| b == b'\n').nth(n).unwrap_or(&[]) +} + +/// Check a `RPRT ` reply; non-zero is an error. +fn check_rprt(bytes: &[u8]) -> Result<(), BackendError> { + let line = first_line(bytes); + let text = std::str::from_utf8(line).unwrap_or("").trim(); + if let Some(code) = text.strip_prefix("RPRT ") { + if code.trim() == "0" { + Ok(()) + } else { + Err(BackendError::Rejected(format!("rigctld {text}"))) + } + } else { + // Some setters reply with nothing meaningful; treat absence of error as success. + Ok(()) + } +} + +#[async_trait] +impl RadioBackend for RigctldBackend { + async fn poll(&self, link: &RadioLink, state: &StateHandle) -> Result<(), BackendError> { + let f = link + .submit(POLL_GET_FREQ.to_vec(), Expect::Lines(1)) + .await?; + if let Ok(hz) = std::str::from_utf8(first_line(&f)) + .unwrap_or("") + .trim() + .parse::() + { + state.record( + StateChange::Freq { vfo: Vfo::A, hz }, + RadioEventSource::PollDiff, + ); + } + let m = link + .submit(POLL_GET_MODE.to_vec(), Expect::Lines(2)) + .await?; + let mode = Mode::from_hamlib_token(std::str::from_utf8(first_line(&m)).unwrap_or("")); + state.record( + StateChange::Mode { vfo: Vfo::A, mode }, + RadioEventSource::PollDiff, + ); + let s = link + .submit(POLL_GET_SPLIT.to_vec(), Expect::Lines(2)) + .await?; + let enabled = std::str::from_utf8(first_line(&s)).unwrap_or("").trim() == "1"; + let tx_vfo = if std::str::from_utf8(nth_line(&s, 1)) + .unwrap_or("") + .trim() + .eq_ignore_ascii_case("VFOB") + { + Vfo::B + } else { + Vfo::A + }; + state.record( + StateChange::Split { + enabled, + tx_vfo: Some(tx_vfo), + }, + RadioEventSource::PollDiff, + ); + Ok(()) + } + + async fn apply( + &self, + mutation: StateMutation, + link: &RadioLink, + state: &StateHandle, + ) -> Result<(), BackendError> { + match mutation { + StateMutation::SetVfoFreq { hz, .. } => { + let reply = link + .submit(format!("F {hz}\n").into_bytes(), Expect::Lines(1)) + .await?; + check_rprt(&reply)?; + } + StateMutation::SetMode { mode, .. } => { + let reply = link + .submit( + format!("M {} 0\n", mode.hamlib_token()).into_bytes(), + Expect::Lines(1), + ) + .await?; + check_rprt(&reply)?; + } + // Compose the DATA flag back onto the current base mode and re-assert the mode + // downstream (the bridge target speaks combined Hamlib mode tokens, not a + // separate DATA command). + StateMutation::SetDataMode { vfo, on } => { + let base = state.snapshot().vfo(vfo).mode; + let reply = link + .submit( + format!("M {} 0\n", base.hamlib_token_with_data(on)).into_bytes(), + Expect::Lines(1), + ) + .await?; + check_rprt(&reply)?; + } + StateMutation::SetSplit { enabled, tx_vfo } => { + let vfo = match tx_vfo.unwrap_or(Vfo::B) { + Vfo::A => "VFOA", + Vfo::B => "VFOB", + }; + let on = u8::from(enabled); + let reply = link + .submit(format!("S {on} {vfo}\n").into_bytes(), Expect::Lines(1)) + .await?; + check_rprt(&reply)?; + } + StateMutation::SetPtt { keyed, source } => { + let on = if keyed { + match source { + PttSource::Generic => 1, + PttSource::Mic => 2, + PttSource::Data => 3, + } + } else { + 0 + }; + let reply = link + .submit(format!("T {on}\n").into_bytes(), Expect::Lines(1)) + .await?; + check_rprt(&reply)?; + } + StateMutation::SetRit { .. } | StateMutation::SetXit { .. } => { + return Err(BackendError::Unsupported); + } + } + state.record(mutation.into_change(), RadioEventSource::OptimisticWrite); + Ok(()) + } + + fn parse_event(&self, _frame: &[u8]) -> Option { + // The bridge has no native push; downstream uniformity comes from poll-diff. + None + } + + async fn passthrough(&self, _raw: &[u8], _link: &RadioLink) -> Result, BackendError> { + // Fails closed: the bridge normalizes native CAT away (§7.1). + Err(BackendError::Unsupported) + } + + fn capabilities(&self) -> BackendCapabilities { + BackendCapabilities { + model: format!("rigctld:{}", self.model), + vfo_count: 2, + has_rit: false, + has_xit: false, + has_smeter: false, + split: SplitStyle::VfoPair, + native_push: false, + native_command_family: None, + framing: Framing::LineTerminated, + freq_min_hz: 30_000, + freq_max_hz: 60_000_000, + trust: if self.certified { + TrustTier::CertifiedNative + } else { + TrustTier::UncertifiedBridge + }, + } + } +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)] +mod tests { + use super::*; + + #[test] + fn bridge_is_uncertified_by_default() { + let backend = RigctldBackend::new("TS-590SG", false); + assert_eq!(backend.capabilities().trust, TrustTier::UncertifiedBridge); + assert!(backend.capabilities().native_command_family.is_none()); + assert!(!backend.capabilities().supports_passthrough()); + } + + #[tokio::test] + async fn passthrough_fails_closed() { + let backend = RigctldBackend::new("IC-7300", false); + let link = crate::radio::detached_link(); + let result = backend.passthrough(b"EX0050000;", &link).await; + assert!(matches!(result, Err(BackendError::Unsupported))); + } + + #[test] + fn rprt_zero_is_ok_nonzero_is_error() { + assert!(check_rprt(b"RPRT 0\n").is_ok()); + assert!(check_rprt(b"RPRT -11\n").is_err()); + assert!(check_rprt(b"7030000\n").is_ok()); + } + + #[test] + fn parses_lines() { + assert_eq!(first_line(b"USB\n2400\n"), b"USB"); + assert_eq!(nth_line(b"1\nVFOB\n", 1), b"VFOB"); + } +} diff --git a/crates/cathub/src/config.rs b/crates/cathub/src/config.rs new file mode 100644 index 0000000..b8393eb --- /dev/null +++ b/crates/cathub/src/config.rs @@ -0,0 +1,610 @@ +//! Daemon configuration (TOML). +//! +//! One `[radio]` section selects and parameterizes the backend; `[poll]`, `[ptt]`, and +//! `[events]` tune cadence and safety; `[[face]]` and `[[hamlib_net]]` declare the client +//! endpoints. Everything but `[radio].backend` has a sane default so a minimal config is +//! short. + +use std::path::PathBuf; +use std::time::Duration; + +use serde::Deserialize; + +use crate::error::ConfigError; +use crate::permissions::FacePermissions; + +fn default_transport() -> String { + "serial".to_string() +} +fn default_baud() -> u32 { + 4_800 +} +fn default_host() -> String { + "127.0.0.1".to_string() +} +fn default_tcp_port() -> u16 { + 4_532 +} +fn default_reply_timeout_ms() -> u64 { + 1_000 +} +fn default_baseline_ms() -> u64 { + 250 +} +fn default_heartbeat_ms() -> u64 { + 3_000 +} +fn default_max_tx_ms() -> u64 { + 300_000 +} +fn default_native_push() -> bool { + true +} + +/// The `[radio]` section. +#[derive(Debug, Clone, Deserialize)] +pub(crate) struct RadioConfig { + /// Backend selector: `ts590`, `rigctld`, or `loopback`. + pub(crate) backend: String, + /// Human-readable / Hamlib model id (e.g. `TS-590SG`, `2014`). + #[serde(default)] + pub(crate) model: String, + /// `serial` or `tcp`. + #[serde(default = "default_transport")] + pub(crate) transport: String, + /// Serial port path (e.g. `COM3`, `/dev/ttyUSB0`). + #[serde(default)] + pub(crate) port: String, + /// Serial baud rate. + #[serde(default = "default_baud")] + pub(crate) baud: u32, + /// TCP host (for `tcp` transport or the rigctld bridge). + #[serde(default = "default_host")] + pub(crate) host: String, + /// TCP port. + #[serde(default = "default_tcp_port")] + pub(crate) tcp_port: u16, + /// Whether a bridge backend has been operator-certified. + #[serde(default)] + pub(crate) certified: bool, + /// Per-command reply timeout in milliseconds. + #[serde(default = "default_reply_timeout_ms")] + pub(crate) reply_timeout_ms: u64, +} + +/// The `[poll]` section. +#[derive(Debug, Clone, Deserialize)] +pub(crate) struct PollConfig { + /// Baseline poll interval in milliseconds. + #[serde(default = "default_baseline_ms")] + pub(crate) baseline_ms: u64, + /// Heartbeat (backed-off) interval in milliseconds. + #[serde(default = "default_heartbeat_ms")] + pub(crate) heartbeat_ms: u64, +} + +impl Default for PollConfig { + fn default() -> Self { + PollConfig { + baseline_ms: default_baseline_ms(), + heartbeat_ms: default_heartbeat_ms(), + } + } +} + +/// The `[ptt]` section. +#[derive(Debug, Clone, Deserialize)] +pub(crate) struct PttConfig { + /// Maximum continuous transmit time in milliseconds (safety ceiling). + #[serde(default = "default_max_tx_ms")] + pub(crate) max_tx_ms: u64, +} + +impl Default for PttConfig { + fn default() -> Self { + PttConfig { + max_tx_ms: default_max_tx_ms(), + } + } +} + +/// The `[events]` section. +#[derive(Debug, Clone, Deserialize)] +pub(crate) struct EventsConfig { + /// Whether to enable the radio's native push stream. + #[serde(default = "default_native_push")] + pub(crate) native_push: bool, +} + +impl Default for EventsConfig { + fn default() -> Self { + EventsConfig { + native_push: default_native_push(), + } + } +} + +/// A `[[face]]` (serial client) endpoint. +#[derive(Debug, Clone, Deserialize)] +pub(crate) struct FaceConfig { + /// A label for logging. + pub(crate) name: String, + /// The serial port this face listens on (a com0com / tty path). + pub(crate) transport: String, + /// Baud rate for the face port. + #[serde(default = "default_baud")] + pub(crate) baud: u32, + /// Dialect: `ts590` or `ts2000`. + pub(crate) dialect: String, + /// Permission tokens (`read`, `write`, `ptt`, `config_write`). + #[serde(default)] + pub(crate) perms: Vec, +} + +impl FaceConfig { + /// The parsed permission set. + pub(crate) fn permissions(&self) -> FacePermissions { + FacePermissions::from_tokens(&self.perms) + } +} + +/// A `[[hamlib_net]]` (rigctld-compatible TCP) endpoint. +#[derive(Debug, Clone, Deserialize)] +pub(crate) struct HamlibNetConfig { + /// A label for logging. + pub(crate) name: String, + /// The bind address (e.g. `127.0.0.1:4532`). + pub(crate) bind: String, + /// Permission tokens (`read`, `write`, `ptt`, `config_write`). + #[serde(default)] + pub(crate) perms: Vec, +} + +impl HamlibNetConfig { + /// The parsed permission set. + pub(crate) fn permissions(&self) -> FacePermissions { + FacePermissions::from_tokens(&self.perms) + } +} + +/// The full daemon configuration. +#[derive(Debug, Clone, Deserialize)] +pub(crate) struct Config { + /// The radio backend section. + pub(crate) radio: RadioConfig, + /// Poll cadence. + #[serde(default)] + pub(crate) poll: PollConfig, + /// PTT safety. + #[serde(default)] + pub(crate) ptt: PttConfig, + /// Event/native-push policy. + #[serde(default)] + pub(crate) events: EventsConfig, + /// Serial client endpoints. + #[serde(default)] + pub(crate) face: Vec, + /// Hamlib net endpoints. + #[serde(default)] + pub(crate) hamlib_net: Vec, +} + +/// The table key the daemon's settings live under inside the shared unified `config.toml`. +const UNIFIED_SECTION: &str = "cat_hub"; +/// Environment override for the shared config path (shared with the engine and launcher). +const CONFIG_PATH_ENV: &str = "QSORIPPER_CONFIG_PATH"; +/// Per-user config directory name shared across QsoRipper components. +const SHARED_DIR: &str = "qsoripper"; +/// Shared unified config file name. +const SHARED_FILE: &str = "config.toml"; + +impl Config { + /// Parse a configuration from a bare TOML string (top-level `[radio]` … layout). + pub(crate) fn parse(text: &str) -> Result { + let config: Config = toml::from_str(text)?; + config.validate()?; + Ok(config) + } + + /// Parse a configuration from a TOML document that may be either the unified + /// `config.toml` (daemon settings nested under `[cat_hub]`, alongside the engine's and + /// launcher's own sections) or a standalone cathub config (top-level `[radio]` …). + /// + /// Detection is by presence of a top-level `cat_hub` table: when present, only that + /// subtree is used and every other section is ignored; otherwise the whole document is + /// parsed as a standalone config for backward compatibility. + pub(crate) fn parse_document(text: &str) -> Result { + let document: toml::Value = toml::from_str(text)?; + if let Some(section) = document.get(UNIFIED_SECTION) { + let config: Config = section.clone().try_into()?; + config.validate()?; + Ok(config) + } else { + Config::parse(text) + } + } + + /// Load and parse a configuration from a file, accepting either the unified or the + /// standalone layout (see [`Config::parse_document`]). + pub(crate) fn load(path: &std::path::Path) -> Result { + let text = std::fs::read_to_string(path)?; + Config::parse_document(&text) + } + + /// Validate semantic constraints not captured by the type system. + pub(crate) fn validate(&self) -> Result<(), ConfigError> { + match self.radio.backend.as_str() { + "ts590" | "rigctld" | "loopback" => {} + other => { + return Err(ConfigError::Invalid(format!( + "unknown radio.backend '{other}' (expected ts590, rigctld, or loopback)" + ))) + } + } + if self.radio.backend != "loopback" { + match self.radio.transport.as_str() { + "serial" => { + if self.radio.port.is_empty() { + return Err(ConfigError::Invalid( + "radio.transport = \"serial\" requires radio.port".to_string(), + )); + } + } + "tcp" => {} + other => { + return Err(ConfigError::Invalid(format!( + "unknown radio.transport '{other}' (expected serial or tcp)" + ))) + } + } + } + for face in &self.face { + if !matches!(face.dialect.as_str(), "ts590" | "ts2000") { + return Err(ConfigError::Invalid(format!( + "face '{}' has unknown dialect '{}' (expected ts590 or ts2000)", + face.name, face.dialect + ))); + } + } + if self.face.is_empty() && self.hamlib_net.is_empty() { + return Err(ConfigError::Invalid( + "at least one [[face]] or [[hamlib_net]] endpoint is required".to_string(), + )); + } + Ok(()) + } + + /// The PTT maximum-transmit safety ceiling. + pub(crate) fn ptt_max_tx(&self) -> Duration { + Duration::from_millis(self.ptt.max_tx_ms) + } + + /// The baseline poll interval. + pub(crate) fn baseline_interval(&self) -> Duration { + Duration::from_millis(self.poll.baseline_ms) + } + + /// The heartbeat poll interval. + pub(crate) fn heartbeat_interval(&self) -> Duration { + Duration::from_millis(self.poll.heartbeat_ms) + } + + /// A human-readable multi-line description (used for `--dry-run`). + pub(crate) fn describe(&self) -> String { + use std::fmt::Write as _; + let mut out = String::new(); + let _ = writeln!( + out, + "radio: backend={} model={} transport={} port={} baud={} host={} tcp_port={} \ + certified={} reply_timeout_ms={}", + self.radio.backend, + self.radio.model, + self.radio.transport, + self.radio.port, + self.radio.baud, + self.radio.host, + self.radio.tcp_port, + self.radio.certified, + self.radio.reply_timeout_ms, + ); + let _ = writeln!( + out, + "poll: baseline_ms={} heartbeat_ms={}", + self.poll.baseline_ms, self.poll.heartbeat_ms + ); + let _ = writeln!(out, "ptt: max_tx_ms={}", self.ptt.max_tx_ms); + let _ = writeln!(out, "events: native_push={}", self.events.native_push); + for face in &self.face { + let _ = writeln!( + out, + "face: name={} transport={} baud={} dialect={} perms={:?}", + face.name, face.transport, face.baud, face.dialect, face.perms + ); + } + for ep in &self.hamlib_net { + let _ = writeln!( + out, + "hamlib_net: name={} bind={} perms={:?}", + ep.name, ep.bind, ep.perms + ); + } + if !self.face.is_empty() || !self.hamlib_net.is_empty() { + out.push('\n'); + let _ = writeln!( + out, + "Client connection guide -- point each application HERE, never at the radio's own \ + port ({}):", + self.radio.port + ); + for face in &self.face { + let _ = writeln!( + out, + " - {name}: the hub OWNS serial port {port}. Point {name} at the OTHER port \ + of that com0com pair (e.g. com0com COMa<->COMb: hub={port}, app=the paired \ + port), {dialect} dialect, {baud} baud.", + name = face.name, + port = face.transport, + dialect = face.dialect, + baud = face.baud, + ); + } + for ep in &self.hamlib_net { + let _ = writeln!( + out, + " - {name}: point this application at {bind} as a Hamlib NET (rigctld) device.", + name = ep.name, + bind = ep.bind, + ); + } + } + out + } + + /// The default config path: the per-user unified `config.toml` shared with the engine and + /// launcher. Resolution mirrors the engine and launcher: + /// + /// 1. `QSORIPPER_CONFIG_PATH` if set, + /// 2. `%APPDATA%\qsoripper\config.toml` (Windows) or + /// `$XDG_CONFIG_HOME/qsoripper/config.toml` → `$HOME/.config/qsoripper/config.toml` (Unix), + /// 3. a bare `config.toml` in the working directory as a last resort. + /// + /// Daemon settings live under the `[cat_hub]` table of that file (see + /// [`Config::parse_document`]); a standalone `--config cathub.toml` is still accepted. + pub(crate) fn default_config_path() -> PathBuf { + if let Some(path) = std::env::var_os(CONFIG_PATH_ENV) { + return PathBuf::from(path); + } + #[cfg(target_os = "windows")] + { + if let Some(app_data) = std::env::var_os("APPDATA") { + return PathBuf::from(app_data).join(SHARED_DIR).join(SHARED_FILE); + } + } + #[cfg(not(target_os = "windows"))] + { + if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") { + return PathBuf::from(xdg).join(SHARED_DIR).join(SHARED_FILE); + } + if let Some(home) = std::env::var_os("HOME") { + return PathBuf::from(home) + .join(".config") + .join(SHARED_DIR) + .join(SHARED_FILE); + } + } + PathBuf::from(SHARED_FILE) + } +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)] +mod tests { + use super::*; + + const SAMPLE: &str = r#" +[radio] +backend = "ts590" +model = "TS-590SG" +transport = "serial" +port = "COM3" +baud = 4800 + +[poll] +baseline_ms = 200 +heartbeat_ms = 2500 + +[ptt] +max_tx_ms = 120000 + +[events] +native_push = true + +[[face]] +name = "n1mm" +transport = "COM11" +baud = 4800 +dialect = "ts590" +perms = ["read", "write", "ptt"] + +[[hamlib_net]] +name = "engine" +bind = "127.0.0.1:4532" +perms = ["read"] +"#; + + #[test] + fn parses_full_config() { + let config = Config::parse(SAMPLE).expect("parse"); + assert_eq!(config.radio.backend, "ts590"); + assert_eq!(config.radio.port, "COM3"); + assert_eq!(config.face.len(), 1); + assert_eq!(config.hamlib_net.len(), 1); + assert!(config.face[0].permissions().ptt); + assert!(config.hamlib_net[0].permissions().read); + assert!(!config.hamlib_net[0].permissions().write); + assert_eq!(config.baseline_interval(), Duration::from_millis(200)); + assert_eq!(config.heartbeat_interval(), Duration::from_millis(2_500)); + assert_eq!(config.ptt_max_tx(), Duration::from_millis(120_000)); + } + + #[test] + fn applies_defaults() { + let text = r#" +[radio] +backend = "loopback" + +[[face]] +name = "x" +transport = "COM5" +dialect = "ts590" +"#; + let config = Config::parse(text).expect("parse"); + assert_eq!(config.radio.baud, 4_800); + assert_eq!(config.poll.baseline_ms, 250); + assert_eq!(config.ptt.max_tx_ms, 300_000); + assert!(config.events.native_push); + assert_eq!(config.face[0].baud, 4_800); + } + + #[test] + fn rejects_unknown_backend() { + let text = r#" +[radio] +backend = "icom" +[[face]] +name = "x" +transport = "COM5" +dialect = "ts590" +"#; + assert!(Config::parse(text).is_err()); + } + + #[test] + fn serial_backend_requires_port() { + let text = r#" +[radio] +backend = "ts590" +transport = "serial" +[[face]] +name = "x" +transport = "COM5" +dialect = "ts590" +"#; + let err = Config::parse(text).expect_err("missing port"); + assert!(err.to_string().contains("requires radio.port")); + } + + #[test] + fn rejects_unknown_dialect() { + let text = r#" +[radio] +backend = "loopback" +[[face]] +name = "x" +transport = "COM5" +dialect = "yaesu" +"#; + assert!(Config::parse(text).is_err()); + } + + #[test] + fn requires_at_least_one_endpoint() { + let text = r#" +[radio] +backend = "loopback" +"#; + assert!(Config::parse(text).is_err()); + } + + #[test] + fn describe_mentions_all_sections() { + let config = Config::parse(SAMPLE).expect("parse"); + let text = config.describe(); + assert!(text.contains("radio: backend=ts590")); + assert!(text.contains("reply_timeout_ms=1000")); + assert!(text.contains("poll: baseline_ms=200")); + assert!(text.contains("ptt: max_tx_ms=120000")); + assert!(text.contains("events: native_push=true")); + assert!(text.contains("face: name=n1mm")); + assert!(text.contains("hamlib_net: name=engine")); + assert!(text.contains("Client connection guide")); + assert!( + text.contains("point the application at the paired") || text.contains("OTHER port") + ); + } + + #[test] + fn default_path_is_unified_config_toml() { + // With no env override, the default resolves to the shared unified file name and lives + // under the shared per-user directory rather than a standalone cathub.toml. + let path = Config::default_config_path(); + let text = path.to_string_lossy(); + assert!( + text.ends_with("config.toml"), + "expected config.toml, got {text}" + ); + // The bare last-resort fallback is the only case without the shared dir; on dev/CI + // machines APPDATA/HOME are set, so the shared dir should be present. + if std::env::var_os(CONFIG_PATH_ENV).is_none() { + assert!( + text.contains(SHARED_DIR) || text == SHARED_FILE, + "expected shared dir or bare fallback, got {text}" + ); + } + } + + #[test] + fn parses_unified_cat_hub_section() { + // A unified config.toml carrying unrelated engine/launcher tables plus a [cat_hub] + // subtree must load only the cat_hub subtree and ignore everything else. + let text = r#" +[station_profile] +callsign = "K7ABC" + +[launcher] +selected = ["engine-rust"] + +[cat_hub.radio] +backend = "ts590" +transport = "serial" +port = "COM4" +baud = 4800 + +[[cat_hub.face]] +name = "n1mm" +transport = "COM11" +dialect = "ts590" +perms = ["read", "write", "ptt"] + +[[cat_hub.hamlib_net]] +name = "engine" +bind = "127.0.0.1:4532" +perms = ["read"] +"#; + let config = Config::parse_document(text).expect("parse unified"); + assert_eq!(config.radio.backend, "ts590"); + assert_eq!(config.radio.port, "COM4"); + assert_eq!(config.face.len(), 1); + assert_eq!(config.hamlib_net.len(), 1); + assert!(config.face[0].permissions().ptt); + } + + #[test] + fn parse_document_falls_back_to_standalone() { + // A standalone config without a [cat_hub] table still parses for back-compat. + let config = Config::parse_document(SAMPLE).expect("parse standalone"); + assert_eq!(config.radio.backend, "ts590"); + assert_eq!(config.radio.port, "COM3"); + assert_eq!(config.face.len(), 1); + } + + #[test] + fn parse_document_validates_cat_hub_section() { + // Validation still applies to the nested subtree (no endpoints is invalid). + let text = r#" +[cat_hub.radio] +backend = "loopback" +"#; + assert!(Config::parse_document(text).is_err()); + } +} diff --git a/crates/cathub/src/dialect/kenwood/mod.rs b/crates/cathub/src/dialect/kenwood/mod.rs new file mode 100644 index 0000000..8a1e011 --- /dev/null +++ b/crates/cathub/src/dialect/kenwood/mod.rs @@ -0,0 +1,96 @@ +//! Kenwood-family client dialects and shared wire helpers. +//! +//! [`Ts590Dialect`](ts590::Ts590Dialect) is the native pass-through dialect (N1MM, ARCP-590, +//! Log4OM-as-TS590). [`Ts2000Dialect`](ts2000::Ts2000Dialect) is the OmniRig/HDSDR translator. +//! Both share the small frame helpers below. + +pub(crate) mod ts2000; +pub(crate) mod ts590; + +use crate::model::Mode; + +/// The Kenwood error reply. +pub(crate) const ERR: &[u8] = b"?;"; + +/// Split a `;`-terminated command frame into its leading alphabetic verb and payload. +/// +/// `b"FA00007050000;"` becomes `(b"FA", b"00007050000")`; `b"TX;"` becomes `(b"TX", b"")`. +/// Returns `None` when there is no alphabetic verb. +pub(crate) fn parse_command(frame: &[u8]) -> Option<(Vec, Vec)> { + let body = match frame.iter().position(|&b| b == b';') { + Some(end) => frame.get(..end)?, + None => frame, + }; + let verb_len = body + .iter() + .position(|b| !b.is_ascii_alphabetic()) + .unwrap_or(body.len()); + if verb_len == 0 { + return None; + } + let verb = body.get(..verb_len)?.to_vec(); + let payload = body.get(verb_len..).unwrap_or(&[]).to_vec(); + Some((verb, payload)) +} + +/// The Kenwood `MD` mode digit (ASCII byte) for a mode. +pub(crate) fn mode_to_digit(mode: Mode) -> u8 { + mode.to_kenwood_digit() +} + +/// The mode for a Kenwood `MD` mode digit (ASCII byte). +pub(crate) fn mode_from_digit(digit: u8) -> Mode { + Mode::from_kenwood_digit(digit) +} + +/// Build a Kenwood `AI` auto-information status frame (`AI0;` or `AI2;`). +/// +/// `AI;` is a *read* on Kenwood radios: a native client (ARCP-590, N1MM) queries the +/// current auto-information mode during connection and waits for a valid `AI;` answer +/// before it proceeds. The reply must report the face's current virtualized state without +/// changing it; only an `AI;` *write* toggles auto-information. +pub(crate) fn ai_frame(on: bool) -> Vec { + vec![b'A', b'I', if on { b'2' } else { b'0' }, b';'] +} + +/// Build a Kenwood frequency frame: `verb` + 11 zero-padded digits + `;`. +pub(crate) fn freq_frame(verb: &[u8], hz: u64) -> Vec { + let mut out = Vec::with_capacity(verb.len() + 12); + out.extend_from_slice(verb); + out.extend_from_slice(format!("{hz:011}").as_bytes()); + out.push(b';'); + out +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)] +mod tests { + use super::*; + + #[test] + fn parse_command_splits_verb_and_payload() { + assert_eq!( + parse_command(b"FA00007050000;"), + Some((b"FA".to_vec(), b"00007050000".to_vec())) + ); + assert_eq!(parse_command(b"TX;"), Some((b"TX".to_vec(), b"".to_vec()))); + assert_eq!( + parse_command(b"AI2;"), + Some((b"AI".to_vec(), b"2".to_vec())) + ); + assert_eq!(parse_command(b"FA;"), Some((b"FA".to_vec(), b"".to_vec()))); + assert_eq!(parse_command(b";"), None); + } + + #[test] + fn freq_frame_is_eleven_digits() { + assert_eq!(freq_frame(b"FA", 7_050_000), b"FA00007050000;".to_vec()); + assert_eq!(freq_frame(b"FB", 0), b"FB00000000000;".to_vec()); + } + + #[test] + fn mode_digit_helpers_round_trip() { + assert_eq!(mode_from_digit(mode_to_digit(Mode::Cw)), Mode::Cw); + assert_eq!(mode_to_digit(Mode::Usb), b'2'); + } +} diff --git a/crates/cathub/src/dialect/kenwood/ts2000.rs b/crates/cathub/src/dialect/kenwood/ts2000.rs new file mode 100644 index 0000000..999a603 --- /dev/null +++ b/crates/cathub/src/dialect/kenwood/ts2000.rs @@ -0,0 +1,249 @@ +//! Foreign-dialect TS-2000 client dialect for OmniRig / HDSDR (and Log4OM via OmniRig). +//! +//! This is a universal-tier *translator*: it answers `IF;`, `FA;`, `FB;`, and `MD;` from +//! the universal state and **rejects Hamlib-style VFO-target writes** (`FR`/`FT`), which +//! were the source of the TS-590 VFO A/B oscillation. It carries no native passthrough. + +use async_trait::async_trait; + +use super::{ai_frame, freq_frame, mode_from_digit, mode_to_digit, parse_command, ERR}; +use crate::dialect::{ApplyOutcome, ClientDialect, FaceContext}; +use crate::model::{StateChange, StateMutation, Vfo}; +use crate::permissions::CommandClass; +use crate::state::Snapshot; + +/// The TS-2000 translator dialect. +#[derive(Clone, Default)] +pub(crate) struct Ts2000Dialect; + +impl Ts2000Dialect { + /// Create the dialect. + pub(crate) fn new() -> Self { + Ts2000Dialect + } +} + +/// Synthesize a TS-2000 `IF;` status response from the universal state. +/// +/// The layout follows the Kenwood TS-2000 `IF` field order. The frequency, TX/RX, mode, +/// and split fields are driven by real state; auxiliary fields are reported as defaults. +/// (The exact byte layout is validated against OmniRig in the live bring-up runbook.) +fn synth_if(snapshot: &Snapshot) -> Vec { + let freq = snapshot.vfo(snapshot.rx_vfo).freq_hz; + let tx = u8::from(snapshot.ptt); + let mode = mode_to_digit(snapshot.vfo(snapshot.rx_vfo).mode) - b'0'; + let split = u8::from(snapshot.split); + let rx_vfo = match snapshot.rx_vfo { + Vfo::A => 0u8, + Vfo::B => 1u8, + }; + // Canonical Kenwood `IF` answer (38 bytes incl. `IF` and `;`). Field widths: + // freq(11) step(4) rit/xit(±5=6) rit(1) xit(1) bank(1) mem(2) tx(1) mode(1) + // vfo(1) scan(1) split(1) tone(1) tone#(2) p15(1) + // Frequency, TX/RX, mode, VFO, and split are state-driven; the rest are defaults. + format!("IF{freq:011}0000+0000000000{tx}{mode}{rx_vfo}0{split}0000;").into_bytes() +} + +#[async_trait] +impl ClientDialect for Ts2000Dialect { + async fn handle(&self, request: &[u8], ctx: &FaceContext) -> Vec { + let Some((verb, payload)) = parse_command(request) else { + return ERR.to_vec(); + }; + let read = payload.is_empty(); + match verb.as_slice() { + b"IF" if read => synth_if(&ctx.snapshot()), + b"FA" => { + if read { + return freq_frame(b"FA", ctx.snapshot().vfo(Vfo::A).freq_hz); + } + set_freq(ctx, Vfo::A, &payload).await + } + b"FB" => { + if read { + return freq_frame(b"FB", ctx.snapshot().vfo(Vfo::B).freq_hz); + } + set_freq(ctx, Vfo::B, &payload).await + } + b"MD" => { + if read { + let d = mode_to_digit(ctx.snapshot().vfo(Vfo::A).mode); + return vec![b'M', b'D', d, b';']; + } + let Some(&d) = payload.first() else { + return ERR.to_vec(); + }; + reply( + ctx.apply_modeled( + StateMutation::SetMode { + vfo: Vfo::A, + mode: mode_from_digit(d), + }, + CommandClass::ModeledWrite, + ) + .await, + ) + } + // VFO-target writes are rejected outright: this is the anti-oscillation + // guarantee. A read of FR/FT is answered from state. + b"FR" if read => vec![b'F', b'R', vfo_digit(ctx.snapshot().rx_vfo), b';'], + b"FT" if read => { + let snap = ctx.snapshot(); + let tx = if snap.split { snap.tx_vfo } else { snap.rx_vfo }; + vec![b'F', b'T', vfo_digit(tx), b';'] + } + b"AI" => { + // `AI;` read reports current state without changing it; `AI;` writes toggle. + if read { + ai_frame(ctx.ai_on()) + } else { + let on = payload.first().is_some_and(|&d| d != b'0'); + ctx.set_ai(on); + Vec::new() + } + } + b"ID" if read => b"ID019;".to_vec(), + b"PS" if read => b"PS1;".to_vec(), + // The translator does not carry native passthrough (foreign dialect). + _ => ERR.to_vec(), + } + } + + fn format_notification(&self, change: &StateChange, ctx: &FaceContext) -> Option> { + if !ctx.ai_on() { + return None; + } + match *change { + StateChange::Freq { vfo: Vfo::A, hz } => Some(freq_frame(b"FA", hz)), + StateChange::Freq { vfo: Vfo::B, hz } => Some(freq_frame(b"FB", hz)), + StateChange::Mode { vfo: Vfo::A, mode } => { + Some(vec![b'M', b'D', mode_to_digit(mode), b';']) + } + _ => None, + } + } +} + +fn vfo_digit(vfo: Vfo) -> u8 { + match vfo { + Vfo::A => b'0', + Vfo::B => b'1', + } +} + +async fn set_freq(ctx: &FaceContext, vfo: Vfo, payload: &[u8]) -> Vec { + let Ok(hz) = std::str::from_utf8(payload) + .unwrap_or("") + .trim() + .parse::() + else { + return ERR.to_vec(); + }; + reply( + ctx.apply_modeled( + StateMutation::SetVfoFreq { vfo, hz }, + CommandClass::ModeledWrite, + ) + .await, + ) +} + +fn reply(outcome: ApplyOutcome) -> Vec { + match outcome { + ApplyOutcome::Ok => Vec::new(), + _ => ERR.to_vec(), + } +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)] +mod tests { + use super::*; + use crate::backend::loopback::LoopbackBackend; + use crate::backend::RadioBackend; + use crate::model::RadioEventSource; + use crate::permissions::FacePermissions; + use crate::ptt::PttManager; + use crate::radio::{detached_link, spawn_scheduler}; + use crate::state::StateHandle; + use std::sync::Arc; + use std::time::Duration; + + fn ctx_with(perms: FacePermissions) -> (FaceContext, LoopbackBackend) { + let backend = LoopbackBackend::new(); + let caps = backend.capabilities(); + let arc: Arc = Arc::new(backend.clone()); + let state = StateHandle::new(); + let radio = spawn_scheduler(arc, detached_link(), state.clone()); + let ptt = PttManager::new(Duration::from_secs(300)); + let ctx = FaceContext::new(7, perms, state, radio, ptt, caps); + (ctx, backend) + } + + #[tokio::test] + async fn if_response_contains_frequency() { + let (ctx, _b) = ctx_with(FacePermissions::read_only()); + ctx.state.record( + StateChange::Freq { + vfo: Vfo::A, + hz: 14_074_000, + }, + RadioEventSource::PollDiff, + ); + let reply = Ts2000Dialect::new().handle(b"IF;", &ctx).await; + assert!(reply.starts_with(b"IF00014074000")); + assert!(reply.ends_with(b";")); + assert_eq!(reply.len(), 38, "Kenwood IF answer is 38 bytes"); + } + + #[tokio::test] + async fn rejects_vfo_target_write() { + let (ctx, backend) = ctx_with(FacePermissions::from_tokens(&["read", "write"])); + let reply = Ts2000Dialect::new().handle(b"FR1;", &ctx).await; + assert_eq!(reply, ERR.to_vec()); + tokio::time::sleep(Duration::from_millis(20)).await; + // Crucially: no split mutation reached the radio. + assert!(backend.mutations().is_empty()); + } + + #[tokio::test] + async fn answers_id_as_ts2000() { + let (ctx, _b) = ctx_with(FacePermissions::read_only()); + assert_eq!( + Ts2000Dialect::new().handle(b"ID;", &ctx).await, + b"ID019;".to_vec() + ); + } + + #[tokio::test] + async fn ai_read_reports_state_without_changing_it() { + let (ctx, _b) = ctx_with(FacePermissions::read_only()); + assert_eq!( + Ts2000Dialect::new().handle(b"AI;", &ctx).await, + b"AI0;".to_vec() + ); + assert!(!ctx.ai_on()); + assert!(Ts2000Dialect::new().handle(b"AI2;", &ctx).await.is_empty()); + assert_eq!( + Ts2000Dialect::new().handle(b"AI;", &ctx).await, + b"AI2;".to_vec() + ); + assert!(ctx.ai_on(), "AI; read must not disable auto-info"); + } + + #[tokio::test] + async fn reads_frequency_from_state() { + let (ctx, _b) = ctx_with(FacePermissions::read_only()); + ctx.state.record( + StateChange::Freq { + vfo: Vfo::A, + hz: 21_200_000, + }, + RadioEventSource::PollDiff, + ); + assert_eq!( + Ts2000Dialect::new().handle(b"FA;", &ctx).await, + b"FA00021200000;".to_vec() + ); + } +} diff --git a/crates/cathub/src/dialect/kenwood/ts590.rs b/crates/cathub/src/dialect/kenwood/ts590.rs new file mode 100644 index 0000000..49be19d --- /dev/null +++ b/crates/cathub/src/dialect/kenwood/ts590.rs @@ -0,0 +1,351 @@ +//! The native Kenwood TS-590 client dialect (N1MM Logger+, ARCP-590, Log4OM-as-TS590). +//! +//! Modeled reads (`FA`/`FB`/`MD`/`IF`) are served from the universal state cache, so a +//! flurry of client polls never touches the radio. Modeled writes route through +//! [`FaceContext::apply_modeled`] for serialization, permission checks, and the PTT lease. +//! `AI` is virtualized per face. Any unmodeled native command falls through to a +//! permission-gated passthrough so genuine native features still work on a certified rig. + +use async_trait::async_trait; + +use super::{ai_frame, freq_frame, mode_from_digit, mode_to_digit, parse_command, ERR}; +use crate::dialect::{ApplyOutcome, ClientDialect, FaceContext}; +use crate::model::{PttSource, StateChange, StateMutation, Vfo}; +use crate::permissions::CommandClass; +use crate::state::Snapshot; + +/// The native TS-590 dialect. +#[derive(Clone, Default)] +pub(crate) struct Ts590Dialect; + +impl Ts590Dialect { + /// Create the dialect. + pub(crate) fn new() -> Self { + Ts590Dialect + } +} + +/// Synthesize a Kenwood `IF;` status answer (38 bytes) from the universal state. +fn synth_if(snapshot: &Snapshot) -> Vec { + let freq = snapshot.vfo(snapshot.rx_vfo).freq_hz; + let tx = u8::from(snapshot.ptt); + let mode = mode_to_digit(snapshot.vfo(snapshot.rx_vfo).mode) - b'0'; + let split = u8::from(snapshot.split); + let rx_vfo = match snapshot.rx_vfo { + Vfo::A => 0u8, + Vfo::B => 1u8, + }; + format!("IF{freq:011}0000+0000000000{tx}{mode}{rx_vfo}0{split}0000;").into_bytes() +} + +#[async_trait] +impl ClientDialect for Ts590Dialect { + async fn handle(&self, request: &[u8], ctx: &FaceContext) -> Vec { + let Some((verb, payload)) = parse_command(request) else { + return ERR.to_vec(); + }; + let read = payload.is_empty(); + match verb.as_slice() { + b"FA" => { + if read { + freq_frame(b"FA", ctx.snapshot().vfo(Vfo::A).freq_hz) + } else { + set_freq(ctx, Vfo::A, &payload).await + } + } + b"FB" => { + if read { + freq_frame(b"FB", ctx.snapshot().vfo(Vfo::B).freq_hz) + } else { + set_freq(ctx, Vfo::B, &payload).await + } + } + b"MD" => { + if read { + let d = mode_to_digit(ctx.snapshot().vfo(Vfo::A).mode); + vec![b'M', b'D', d, b';'] + } else { + let Some(&d) = payload.first() else { + return ERR.to_vec(); + }; + reply( + ctx.apply_modeled( + StateMutation::SetMode { + vfo: Vfo::A, + mode: mode_from_digit(d), + }, + CommandClass::ModeledWrite, + ) + .await, + ) + } + } + b"TX" => reply( + ctx.apply_modeled( + StateMutation::SetPtt { + keyed: true, + source: PttSource::Generic, + }, + CommandClass::PttWrite, + ) + .await, + ), + b"RX" => reply( + ctx.apply_modeled( + StateMutation::SetPtt { + keyed: false, + source: PttSource::Generic, + }, + CommandClass::PttWrite, + ) + .await, + ), + b"AI" => { + // `AI;` is a read: report the current virtualized auto-info state without + // changing it, so a native client's connection handshake completes. Only an + // `AI;` write toggles auto-information for this face. + if read { + ai_frame(ctx.ai_on()) + } else { + let on = payload.first().is_some_and(|&d| d != b'0'); + ctx.set_ai(on); + Vec::new() + } + } + b"ID" if read => b"ID021;".to_vec(), + b"PS" if read => b"PS1;".to_vec(), + b"IF" if read => synth_if(&ctx.snapshot()), + // Any other native command is forwarded as a permission-gated passthrough. + _ => { + let class = if read { + CommandClass::PassthroughRead + } else { + CommandClass::ConfigWrite + }; + ctx.passthrough(request, class).await + } + } + } + + fn format_notification(&self, change: &StateChange, ctx: &FaceContext) -> Option> { + if !ctx.ai_on() { + return None; + } + match *change { + StateChange::Freq { vfo: Vfo::A, hz } => Some(freq_frame(b"FA", hz)), + StateChange::Freq { vfo: Vfo::B, hz } => Some(freq_frame(b"FB", hz)), + StateChange::Mode { vfo: Vfo::A, mode } => { + Some(vec![b'M', b'D', mode_to_digit(mode), b';']) + } + _ => None, + } + } + + fn format_passthrough(&self, raw: &[u8], ctx: &FaceContext) -> Option> { + // A certified-native client that enabled auto-information expects the radio's CAT + // stream verbatim. Relaying unmodeled frames (NB/NR/AG/front-panel changes, ...) + // keeps its client-side feature state machines in sync; without this, a client like + // ARCP-590 never sees the echo of its own NB write and cannot advance the NB cycle. + if ctx.ai_on() { + Some(raw.to_vec()) + } else { + None + } + } +} + +async fn set_freq(ctx: &FaceContext, vfo: Vfo, payload: &[u8]) -> Vec { + let Ok(hz) = std::str::from_utf8(payload) + .unwrap_or("") + .trim() + .parse::() + else { + return ERR.to_vec(); + }; + reply( + ctx.apply_modeled( + StateMutation::SetVfoFreq { vfo, hz }, + CommandClass::ModeledWrite, + ) + .await, + ) +} + +fn reply(outcome: ApplyOutcome) -> Vec { + match outcome { + ApplyOutcome::Ok => Vec::new(), + _ => ERR.to_vec(), + } +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)] +mod tests { + use super::*; + use crate::backend::loopback::LoopbackBackend; + use crate::backend::RadioBackend; + use crate::model::{Mode, RadioEventSource}; + use crate::permissions::FacePermissions; + use crate::ptt::PttManager; + use crate::radio::{detached_link, spawn_scheduler}; + use crate::state::StateHandle; + use std::sync::Arc; + use std::time::Duration; + + fn ctx_with(perms: FacePermissions) -> (FaceContext, LoopbackBackend) { + let backend = LoopbackBackend::new(); + let caps = backend.capabilities(); + let arc: Arc = Arc::new(backend.clone()); + let state = StateHandle::new(); + let radio = spawn_scheduler(arc, detached_link(), state.clone()); + let ptt = PttManager::new(Duration::from_secs(300)); + (FaceContext::new(5, perms, state, radio, ptt, caps), backend) + } + + #[tokio::test] + async fn reads_frequency_from_cache() { + let (ctx, _b) = ctx_with(FacePermissions::read_only()); + ctx.state.record( + StateChange::Freq { + vfo: Vfo::A, + hz: 7_074_000, + }, + RadioEventSource::PollDiff, + ); + assert_eq!( + Ts590Dialect::new().handle(b"FA;", &ctx).await, + b"FA00007074000;".to_vec() + ); + } + + #[tokio::test] + async fn write_is_denied_for_read_only_face() { + let (ctx, backend) = ctx_with(FacePermissions::read_only()); + assert_eq!( + Ts590Dialect::new().handle(b"FA00007050000;", &ctx).await, + ERR.to_vec() + ); + tokio::time::sleep(Duration::from_millis(20)).await; + assert!(backend.mutations().is_empty()); + } + + #[tokio::test] + async fn mode_read_and_write() { + let (ctx, backend) = ctx_with(FacePermissions::from_tokens(&["read", "write"])); + assert_eq!( + Ts590Dialect::new().handle(b"MD;", &ctx).await, + b"MD2;".to_vec(), + "default mode is USB digit 2" + ); + assert_eq!( + Ts590Dialect::new().handle(b"MD3;", &ctx).await, + Vec::::new() + ); + tokio::time::sleep(Duration::from_millis(20)).await; + assert_eq!( + backend.mutations(), + vec![StateMutation::SetMode { + vfo: Vfo::A, + mode: Mode::Cw + }] + ); + } + + #[tokio::test] + async fn ai_read_reports_state_without_changing_it() { + let (ctx, _b) = ctx_with(FacePermissions::read_only()); + // A fresh face: auto-info off, and reading it must not enable it. + assert_eq!( + Ts590Dialect::new().handle(b"AI;", &ctx).await, + b"AI0;".to_vec() + ); + assert!(!ctx.ai_on(), "AI; read must not change the flag"); + + // After enabling, a read reports 2 and leaves it enabled (regression: a read used + // to be parsed as a write with an empty payload and silently disabled auto-info, + // which froze native clients like ARCP-590 that poll AI; as a keepalive). + assert!(Ts590Dialect::new().handle(b"AI2;", &ctx).await.is_empty()); + assert!(ctx.ai_on()); + assert_eq!( + Ts590Dialect::new().handle(b"AI;", &ctx).await, + b"AI2;".to_vec() + ); + assert!(ctx.ai_on(), "AI; read must not disable auto-info"); + } + + #[tokio::test] + async fn ai_toggle_is_virtualized_and_drives_notifications() { + let (ctx, _b) = ctx_with(FacePermissions::read_only()); + assert!(Ts590Dialect::new().handle(b"AI2;", &ctx).await.is_empty()); + assert!(ctx.ai_on()); + let note = Ts590Dialect::new().format_notification( + &StateChange::Freq { + vfo: Vfo::A, + hz: 14_074_000, + }, + &ctx, + ); + assert_eq!(note, Some(b"FA00014074000;".to_vec())); + } + + #[tokio::test] + async fn passthrough_frame_is_relayed_only_when_auto_info_on() { + let (ctx, _b) = ctx_with(FacePermissions::read_only()); + // Auto-info off: the radio's unmodeled echo is suppressed for this face. + assert_eq!(Ts590Dialect::new().format_passthrough(b"NB1;", &ctx), None); + // After the client enables auto-info, the echo is relayed verbatim so its NB cycle + // (and front-panel changes) stay in sync. + ctx.set_ai(true); + assert_eq!( + Ts590Dialect::new().format_passthrough(b"NB1;", &ctx), + Some(b"NB1;".to_vec()) + ); + assert_eq!( + Ts590Dialect::new().format_passthrough(b"NB0;", &ctx), + Some(b"NB0;".to_vec()) + ); + } + + #[tokio::test] + async fn id_and_ps_identify_as_ts590() { + let (ctx, _b) = ctx_with(FacePermissions::read_only()); + assert_eq!( + Ts590Dialect::new().handle(b"ID;", &ctx).await, + b"ID021;".to_vec() + ); + assert_eq!( + Ts590Dialect::new().handle(b"PS;", &ctx).await, + b"PS1;".to_vec() + ); + } + + #[tokio::test] + async fn if_answer_is_38_bytes() { + let (ctx, _b) = ctx_with(FacePermissions::read_only()); + let reply = Ts590Dialect::new().handle(b"IF;", &ctx).await; + assert_eq!(reply.len(), 38); + assert!(reply.starts_with(b"IF")); + } + + #[tokio::test] + async fn unmodeled_set_requires_config_write() { + let (ctx, backend) = ctx_with(FacePermissions::from_tokens(&["read", "write"])); + // No config_write permission: an EX-menu set is refused, never forwarded. + assert_eq!( + Ts590Dialect::new().handle(b"EX0050000;", &ctx).await, + ERR.to_vec() + ); + tokio::time::sleep(Duration::from_millis(20)).await; + assert!(backend.passthroughs().is_empty()); + } + + #[tokio::test] + async fn passthrough_read_is_forwarded_when_permitted() { + let (ctx, backend) = ctx_with(FacePermissions::from_tokens(&["read"])); + let reply = Ts590Dialect::new().handle(b"RM;", &ctx).await; + // The loopback backend echoes the raw passthrough. + assert_eq!(reply, b"RM;".to_vec()); + tokio::time::sleep(Duration::from_millis(20)).await; + assert_eq!(backend.passthroughs(), vec![b"RM;".to_vec()]); + } +} diff --git a/crates/cathub/src/dialect/mod.rs b/crates/cathub/src/dialect/mod.rs new file mode 100644 index 0000000..e0f34f1 --- /dev/null +++ b/crates/cathub/src/dialect/mod.rs @@ -0,0 +1,371 @@ +//! Client dialects and the per-face execution context. +//! +//! A [`ClientDialect`] translates one client's CAT vocabulary to and from the neutral +//! state, serving reads from the cache and routing writes through [`FaceContext`]. The +//! context carries the face's identity, permissions, the shared scheduler/PTT lease, and +//! its own virtualized auto-information flag, so every write participates in serialization, +//! permission checks, the single-owner PTT lease, and event fan-out (design §8). + +pub(crate) mod kenwood; + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use async_trait::async_trait; + +use crate::backend::{BackendCapabilities, BackendError}; +use crate::model::{StateChange, StateMutation}; +use crate::permissions::{CommandClass, FacePermissions}; +use crate::ptt::{PttDenied, PttManager}; +use crate::radio::{OpKind, Priority, RadioHandle}; +use crate::state::{Snapshot, StateHandle}; + +/// The Kenwood error reply, used when a modeled write or passthrough is refused. +const ERR_REPLY: &[u8] = b"?;"; + +/// The result of applying a modeled write. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ApplyOutcome { + /// The write was accepted and dispatched. + Ok, + /// The face lacks permission for this command class. + Denied, + /// PTT is held by another face. + Busy, + /// The backend failed to apply the write. + Error, + /// The backend does not support this operation. + Unsupported, +} + +/// One face's execution context: identity, permissions, capabilities, and the shared +/// state/scheduler/PTT lease, plus its own auto-information toggle. +#[derive(Clone)] +pub(crate) struct FaceContext { + /// The unique face id (used for PTT ownership and scheduler fairness). + pub(crate) face_id: u64, + /// This face's permissions. + pub(crate) perms: FacePermissions, + /// The backend's advertised capabilities. + pub(crate) caps: BackendCapabilities, + /// The shared universal state. + pub(crate) state: StateHandle, + /// The shared priority scheduler handle. + pub(crate) radio: RadioHandle, + /// The shared PTT lease. + pub(crate) ptt: PttManager, + /// This face's virtualized auto-information flag (never reaches the radio). + ai: Arc, +} + +impl FaceContext { + /// Create a face context. + pub(crate) fn new( + face_id: u64, + perms: FacePermissions, + state: StateHandle, + radio: RadioHandle, + ptt: PttManager, + caps: BackendCapabilities, + ) -> Self { + FaceContext { + face_id, + perms, + caps, + state, + radio, + ptt, + ai: Arc::new(AtomicBool::new(false)), + } + } + + /// A consistent point-in-time view of the radio. + pub(crate) fn snapshot(&self) -> Snapshot { + self.state.snapshot() + } + + /// Apply a modeled write, enforcing permissions, the PTT lease, and serialization. + pub(crate) async fn apply_modeled( + &self, + mutation: StateMutation, + class: CommandClass, + ) -> ApplyOutcome { + if !self.perms.allows(class) { + return ApplyOutcome::Denied; + } + // Idempotent suppression: never re-send a value the radio already holds. This keeps + // the hub as quiet on the wire as a native Hamlib driver and avoids the TS-590 + // PC-control beep that fires on every redundant set. PTT is never redundant. + if self.state.snapshot().is_redundant(&mutation) { + return ApplyOutcome::Ok; + } + match (class, mutation) { + (CommandClass::PttWrite, StateMutation::SetPtt { keyed: true, .. }) => { + match self.ptt.try_key(self.face_id, self.perms.ptt) { + Err(PttDenied::Busy) => return ApplyOutcome::Busy, + Err(PttDenied::NotPermitted) => return ApplyOutcome::Denied, + Ok(()) => {} + } + if self + .radio + .submit(self.face_id, Priority::Ptt, OpKind::Apply(mutation)) + .await + .is_ok() + { + ApplyOutcome::Ok + } else { + // The key request never reached the radio: release the lease. + self.ptt.unkey(self.face_id); + ApplyOutcome::Error + } + } + (CommandClass::PttWrite, StateMutation::SetPtt { keyed: false, .. }) => { + let result = self + .radio + .submit(self.face_id, Priority::Ptt, OpKind::Apply(mutation)) + .await; + self.ptt.unkey(self.face_id); + map_outcome(&result) + } + _ => { + let priority = match class { + CommandClass::PttWrite => Priority::Ptt, + _ => Priority::Write, + }; + map_outcome( + &self + .radio + .submit(self.face_id, priority, OpKind::Apply(mutation)) + .await, + ) + } + } + } + + /// Forward a raw native command, returning the raw reply (or the error frame). + pub(crate) async fn passthrough(&self, raw: &[u8], class: CommandClass) -> Vec { + if !self.perms.allows(class) { + return ERR_REPLY.to_vec(); + } + match self + .radio + .submit( + self.face_id, + Priority::Read, + OpKind::Passthrough(raw.to_vec()), + ) + .await + { + Ok(bytes) => bytes, + Err(_) => ERR_REPLY.to_vec(), + } + } + + /// Set this face's virtualized auto-information flag. + pub(crate) fn set_ai(&self, on: bool) { + self.ai.store(on, Ordering::SeqCst); + } + + /// Whether this face has auto-information enabled. + pub(crate) fn ai_on(&self) -> bool { + self.ai.load(Ordering::SeqCst) + } + + /// A clone of this context for a new connection: same shared state/radio/ptt and + /// permissions, but a distinct face id and a fresh (off) auto-information flag. + pub(crate) fn clone_with_face(&self, face_id: u64) -> FaceContext { + FaceContext { + face_id, + perms: self.perms, + caps: self.caps.clone(), + state: self.state.clone(), + radio: self.radio.clone(), + ptt: self.ptt.clone(), + ai: Arc::new(AtomicBool::new(false)), + } + } +} + +fn map_outcome(result: &Result, BackendError>) -> ApplyOutcome { + match result { + Ok(_) => ApplyOutcome::Ok, + Err(BackendError::Unsupported) => ApplyOutcome::Unsupported, + Err(_) => ApplyOutcome::Error, + } +} + +/// A client CAT dialect: translates a client's vocabulary to/from the neutral state. +#[async_trait] +pub(crate) trait ClientDialect: Send + Sync { + /// Handle one inbound request frame, returning the reply bytes (possibly empty). + async fn handle(&self, request: &[u8], ctx: &FaceContext) -> Vec; + + /// Render a state change as an unsolicited notification for this face, if it applies. + fn format_notification(&self, change: &StateChange, ctx: &FaceContext) -> Option>; + + /// Render an unsolicited native frame the backend does not model as a notification for + /// this face, if it applies. Returns the bytes to push, or `None` to suppress it. + /// + /// The default suppresses the frame. A native pass-through dialect overrides this to + /// relay the radio's CAT stream verbatim to clients that have enabled auto-information. + fn format_passthrough(&self, _raw: &[u8], _ctx: &FaceContext) -> Option> { + None + } +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)] +mod tests { + use super::*; + use crate::backend::loopback::LoopbackBackend; + use crate::backend::RadioBackend; + use crate::model::{Mode, PttSource, Vfo}; + use crate::radio::{detached_link, spawn_scheduler}; + use std::time::Duration; + + fn ctx_with(perms: FacePermissions, id: u64) -> (FaceContext, LoopbackBackend) { + let backend = LoopbackBackend::new(); + let caps = backend.capabilities(); + let arc: Arc = Arc::new(backend.clone()); + let state = StateHandle::new(); + let radio = spawn_scheduler(arc, detached_link(), state.clone()); + let ptt = PttManager::new(Duration::from_secs(300)); + ( + FaceContext::new(id, perms, state, radio, ptt, caps), + backend, + ) + } + + #[tokio::test] + async fn modeled_write_denied_without_permission() { + let (ctx, backend) = ctx_with(FacePermissions::read_only(), 1); + let outcome = ctx + .apply_modeled( + StateMutation::SetVfoFreq { + vfo: Vfo::A, + hz: 7_000_000, + }, + CommandClass::ModeledWrite, + ) + .await; + assert_eq!(outcome, ApplyOutcome::Denied); + assert!(backend.mutations().is_empty()); + } + + #[tokio::test] + async fn redundant_modeled_write_is_suppressed() { + let (ctx, backend) = ctx_with(FacePermissions::from_tokens(&["read", "write"]), 1); + // A fresh snapshot already reports VFO A in USB, so re-setting USB must not reach the + // radio: re-sending an unchanged value is exactly what triggers the TS-590 PC-control + // beep when a client like WSJT-X re-asserts mode on every poll. + let outcome = ctx + .apply_modeled( + StateMutation::SetMode { + vfo: Vfo::A, + mode: Mode::Usb, + }, + CommandClass::ModeledWrite, + ) + .await; + assert_eq!(outcome, ApplyOutcome::Ok); + assert!(backend.mutations().is_empty()); + + // A genuine change still reaches the radio. + let outcome = ctx + .apply_modeled( + StateMutation::SetMode { + vfo: Vfo::A, + mode: Mode::Cw, + }, + CommandClass::ModeledWrite, + ) + .await; + assert_eq!(outcome, ApplyOutcome::Ok); + assert_eq!(backend.mutations().len(), 1); + } + + #[tokio::test] + async fn ptt_write_is_never_suppressed_as_redundant() { + // Two unkey requests in a row must both reach the radio; PTT is never deduplicated. + let (ctx, backend) = ctx_with(FacePermissions::from_tokens(&["ptt"]), 1); + for _ in 0..2 { + let outcome = ctx + .apply_modeled( + StateMutation::SetPtt { + keyed: false, + source: PttSource::Generic, + }, + CommandClass::PttWrite, + ) + .await; + assert_eq!(outcome, ApplyOutcome::Ok); + } + assert_eq!(backend.mutations().len(), 2); + } + + #[tokio::test] + async fn modeled_write_dispatches_when_allowed() { + let (ctx, backend) = ctx_with(FacePermissions::from_tokens(&["read", "write"]), 1); + let outcome = ctx + .apply_modeled( + StateMutation::SetVfoFreq { + vfo: Vfo::A, + hz: 7_000_000, + }, + CommandClass::ModeledWrite, + ) + .await; + assert_eq!(outcome, ApplyOutcome::Ok); + assert_eq!(backend.mutations().len(), 1); + } + + #[tokio::test] + async fn ptt_is_busy_for_a_second_face() { + let (ctx1, _b) = ctx_with(FacePermissions::from_tokens(&["ptt"]), 1); + // Share the same radio/ptt by cloning the context with a new face id. + let ctx2 = ctx1.clone_with_face(2); + assert_eq!( + ctx1.apply_modeled( + StateMutation::SetPtt { + keyed: true, + source: PttSource::Generic + }, + CommandClass::PttWrite + ) + .await, + ApplyOutcome::Ok + ); + assert_eq!( + ctx2.apply_modeled( + StateMutation::SetPtt { + keyed: true, + source: PttSource::Generic + }, + CommandClass::PttWrite + ) + .await, + ApplyOutcome::Busy + ); + } + + #[tokio::test] + async fn passthrough_denied_returns_error_frame() { + let (ctx, _b) = ctx_with(FacePermissions::read_only(), 1); + // A read-only face may not issue a passthrough write. + assert_eq!( + ctx.passthrough(b"EX0050000;", CommandClass::ConfigWrite) + .await, + b"?;".to_vec() + ); + } + + #[tokio::test] + async fn ai_flag_is_per_face_and_resets_on_clone() { + let (ctx, _b) = ctx_with(FacePermissions::read_only(), 1); + assert!(!ctx.ai_on()); + ctx.set_ai(true); + assert!(ctx.ai_on()); + let fresh = ctx.clone_with_face(2); + assert!(!fresh.ai_on(), "a cloned face starts with auto-info off"); + } +} diff --git a/crates/cathub/src/error.rs b/crates/cathub/src/error.rs new file mode 100644 index 0000000..c1b20bb --- /dev/null +++ b/crates/cathub/src/error.rs @@ -0,0 +1,93 @@ +//! Crate error types. +//! +//! [`BackendError`] is the failure surface of the radio link and backends (re-exported +//! from [`crate::backend`]). [`ConfigError`] covers configuration loading/validation, and +//! [`CatHubError`] is the daemon's top-level error returned from [`crate::run`]. + +use thiserror::Error; + +/// A failure from the radio transport or a backend operation. +#[derive(Debug, Error, Clone, PartialEq, Eq)] +pub(crate) enum BackendError { + /// The transport failed (closed, write error, scheduler gone). + #[error("transport: {0}")] + Transport(String), + /// A solicited command timed out waiting for its reply. + #[error("timeout")] + Timeout, + /// The radio (or bridge) rejected the command. + #[error("rejected: {0}")] + Rejected(String), + /// The operation is not supported by this backend. + #[error("unsupported")] + Unsupported, +} + +/// A configuration load or validation failure. +#[derive(Debug, Error)] +pub enum ConfigError { + /// The config file could not be read. + #[error("reading config: {0}")] + Io(#[from] std::io::Error), + /// The config file is not valid TOML or has the wrong shape. + #[error("parsing config: {0}")] + Parse(#[from] toml::de::Error), + /// The config parsed but is semantically invalid. + #[error("invalid config: {0}")] + Invalid(String), +} + +/// The daemon's top-level error. +#[derive(Debug, Error)] +pub enum CatHubError { + /// A configuration problem. + #[error(transparent)] + Config(#[from] ConfigError), + /// An I/O problem binding a face, opening a port, or similar. + #[error("i/o: {0}")] + Io(#[from] std::io::Error), + /// The configured backend could not be built or initialized. + #[error("backend: {0}")] + Backend(String), +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)] +mod tests { + use super::*; + + #[test] + fn backend_error_displays() { + assert_eq!(BackendError::Timeout.to_string(), "timeout"); + assert_eq!(BackendError::Unsupported.to_string(), "unsupported"); + assert_eq!( + BackendError::Rejected("rigctld RPRT -1".into()).to_string(), + "rejected: rigctld RPRT -1" + ); + assert_eq!( + BackendError::Transport("closed".into()).to_string(), + "transport: closed" + ); + } + + #[test] + fn config_error_wraps_invalid() { + let err = ConfigError::Invalid("no faces".into()); + assert_eq!(err.to_string(), "invalid config: no faces"); + } + + #[test] + fn cathub_error_from_config() { + let err: CatHubError = ConfigError::Invalid("bad".into()).into(); + assert!(matches!(err, CatHubError::Config(_))); + assert!(err.to_string().contains("invalid config: bad")); + } + + #[test] + fn cathub_error_backend_displays() { + assert_eq!( + CatHubError::Backend("unknown".into()).to_string(), + "backend: unknown" + ); + } +} diff --git a/crates/cathub/src/events.rs b/crates/cathub/src/events.rs new file mode 100644 index 0000000..f75c163 --- /dev/null +++ b/crates/cathub/src/events.rs @@ -0,0 +1,139 @@ +//! Central native-push ownership and the baseline poller (design §8.4). +//! +//! The daemon — not any client — owns the radio's spontaneous-update stream. At startup +//! (and on reconnect) it enables the rig's native push (`AI2;` on a TS-590) once and keeps +//! it on for the daemon's lifetime. Per-face auto-info is virtualized in [`crate::dialect`] +//! and never reaches the wire. +//! +//! The poller submits one baseline poll cycle at [`PollConfig`](crate::config::PollConfig) +//! cadence, backing off to the heartbeat rate only for fields the radio actually covers +//! with `NativePush` (poll-diff coverage never causes back-off, §8.4). + +use std::sync::Arc; +use std::time::Duration; + +use crate::backend::RadioBackend; +use crate::model::{Field, Vfo}; +use crate::radio::{Expect, OpKind, Priority, RadioHandle, RadioLink}; +use crate::state::StateHandle; + +/// Reserved face id for the background poller (clients use ids `>= 1`). +pub(crate) const POLLER_FACE: u64 = 0; + +/// Enable the radio's native push stream if the backend has one. Returns whether a push +/// command was issued (so the poller knows native push is in play). +pub(crate) async fn enable_native_push(backend: &Arc, link: &RadioLink) -> bool { + if let Some(cmd) = backend.native_push_enable() { + // Auto-info enable is a no-reply set; the radio simply begins streaming. + let _ = link.submit(cmd, Expect::NoReply).await; + true + } else { + false + } +} + +/// Decide the next poll interval. While native push covers the primary frequency field, +/// the poller drops to the heartbeat (liveness) rate; otherwise it polls at baseline. +pub(crate) fn next_interval( + state: &StateHandle, + native_push_active: bool, + baseline: Duration, + heartbeat: Duration, +) -> Duration { + if native_push_active && state.is_native_push_covered(Field::Freq(Vfo::A)) { + heartbeat + } else { + baseline + } +} + +/// Spawn the baseline poller. It submits poll cycles through the scheduler at +/// [`Priority::Poll`], so any interactive write or PTT always preempts it. +pub(crate) fn spawn_poller( + radio: RadioHandle, + state: StateHandle, + native_push_active: bool, + baseline: Duration, + heartbeat: Duration, +) { + tokio::spawn(async move { + loop { + let _ = radio + .submit(POLLER_FACE, Priority::Poll, OpKind::Poll) + .await; + let interval = next_interval(&state, native_push_active, baseline, heartbeat); + tokio::time::sleep(interval).await; + } + }); +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)] +mod tests { + use super::*; + use crate::backend::kenwood::ts590::Ts590Backend; + use crate::backend::loopback::LoopbackBackend; + use crate::model::{RadioEventSource, StateChange}; + use crate::radio::{detached_link, spawn_scheduler}; + + #[tokio::test] + async fn poller_drives_polls_through_scheduler() { + let backend = LoopbackBackend::new(); + let arc: Arc = Arc::new(backend.clone()); + let state = StateHandle::new(); + let radio = spawn_scheduler(arc, detached_link(), state.clone()); + spawn_poller( + radio, + state, + false, + Duration::from_millis(5), + Duration::from_millis(50), + ); + tokio::time::sleep(Duration::from_millis(40)).await; + assert!(backend.poll_count() >= 2, "poller should run repeatedly"); + } + + #[test] + fn back_off_only_when_native_push_covers_field() { + let state = StateHandle::new(); + let baseline = Duration::from_millis(200); + let heartbeat = Duration::from_millis(2_000); + + // No coverage yet: baseline. + assert_eq!(next_interval(&state, true, baseline, heartbeat), baseline); + + // A poll-diff event must NOT trigger back-off. + state.record( + StateChange::Freq { + vfo: Vfo::A, + hz: 7_001_000, + }, + RadioEventSource::PollDiff, + ); + assert_eq!(next_interval(&state, true, baseline, heartbeat), baseline); + + // A native-push event covers the field: back off to heartbeat. + state.record( + StateChange::Freq { + vfo: Vfo::A, + hz: 7_002_000, + }, + RadioEventSource::NativePush, + ); + assert_eq!(next_interval(&state, true, baseline, heartbeat), heartbeat); + + // With native push disabled, always baseline regardless of coverage. + assert_eq!(next_interval(&state, false, baseline, heartbeat), baseline); + } + + #[tokio::test] + async fn enable_native_push_reports_capability() { + let native: Arc = Arc::new(Ts590Backend::new()); + // A detached link accepts the no-reply submit and drops it. + assert!(enable_native_push(&native, &detached_link()).await); + + let bridge: Arc = Arc::new(LoopbackBackend::new()); + // Loopback reports no native push. + assert!(!enable_native_push(&bridge, &detached_link()).await); + } +} diff --git a/crates/cathub/src/hamlib_dump_state.txt b/crates/cathub/src/hamlib_dump_state.txt new file mode 100644 index 0000000..e460619 --- /dev/null +++ b/crates/cathub/src/hamlib_dump_state.txt @@ -0,0 +1,65 @@ +1 +1 +0 +150000.000000 1500000000.000000 0x401dff -1 -1 0x17e00007 0x1f +0 0 0 0 0 0 0 +150000.000000 1500000000.000000 0x401dff 5000 100000 0x17e00007 0x1f +0 0 0 0 0 0 0 +0x401dff 1 +0x401dff 0 +0 0 +0xc 2400 +0xc 1800 +0xc 3000 +0xc 0 +0x2 500 +0x2 2400 +0x2 50 +0x2 0 +0x10 300 +0x10 2400 +0x10 50 +0x10 0 +0x1 8000 +0x1 2400 +0x1 10000 +0x20 15000 +0x20 8000 +0x40 230000 +0 0 +9990 +9990 +10000 +0 +10 +10 20 30 +0xffffffffffffffff +0xffffffffffffffff +0xfffffffff7ffffff +0xfffeff7083ffffff +0xffffffffffffffff +0xffffffffffffffbf +vfo_ops=0x7ffffff +ptt_type=0x1 +targetable_vfo=0x10c3 +has_set_vfo=1 +has_get_vfo=1 +has_set_freq=1 +has_get_freq=1 +has_set_conf=1 +has_get_conf=1 +has_power2mW=1 +has_mW2power=1 +has_get_ant=1 +has_set_ant=1 +timeout=0 +rig_model=1 +rigctld_version=Hamlib 4.7.0 2026-02-15T21:14:25Z SHA=554e02b39 64-bit +agc_levels=0=OFF 1=SUPERFAST 2=FAST 5=MEDIUM 3=SLOW 6=AUTO 4=USER +ctcss_list= 67.0 69.3 71.9 74.4 77.0 79.7 82.5 85.4 88.5 91.5 94.8 97.4 100.0 103.5 107.2 110.9 114.8 118.8 123.0 127.3 131.8 136.5 141.3 146.2 151.4 156.7 159.8 162.2 165.5 167.9 171.3 173.8 177.3 179.9 183.5 186.2 189.9 192.8 196.6 199.5 203.5 206.5 210.7 218.1 225.7 229.1 233.6 241.8 250.3 254.1 +dcs_list= 17 23 25 26 31 32 36 43 47 50 51 53 54 65 71 72 73 74 114 115 116 122 125 131 132 134 143 145 152 155 156 162 165 172 174 205 212 223 225 226 243 244 245 246 251 252 255 261 263 265 266 271 274 306 311 315 325 331 332 343 346 351 356 364 365 371 411 412 413 423 431 432 445 446 452 454 455 462 464 465 466 503 506 516 523 526 532 546 565 606 612 624 627 631 632 654 662 664 703 712 723 731 732 734 743 754 +level_gran=0=0,0,0;1=0,0,0;2=0,0,0;3=0,0,0;4=0,1,0.00392157;5=0,0,0;6=0,0,0;7=0,0,0;8=0,0,0;9=0,0,0;10=0,0,0;11=0,0,10;12=0.05,1,0.00195695;13=0,0,0;14=0,0,0;15=0,0,0;16=0,0,0;17=0,0,0;18=0,0,0;19=0,0,0;20=0,0,0;21=0,0,0;22=0,0,0;23=0,0,0;24=0,0,0;25=0,0,0;26=0,0,0;27=0,0,0;28=0,0,0;29=0,0,0;30=0,0,0;31=0,0,0;32=0,1,0.00392157;33=0,0,0;34=0,0,0;35=0,0,0;36=0,0,0;37=0,0,0;38=0,0,0;39=0,100,0.00392157;40=0,0,0;41=0,0,0;42=0,0,0;43=0,0,0;44=0,2,1;45=-30,10,0.5;46=0,3,1;47=0,0,0;48=0,0,0;49=0,0,0;50=0,0,0;51=0,0,0;52=0,0,0;53=0,0,0;54=0,0,0;55=0,0,0;56=0,0,0;57=0,0,0;58=0,0,0;59=0,0,0;60=0,0,0;61=0,0,0;62=0,0,0;63=0,0,0; +parm_gran=0=0,0,0;1=0,0,0;2=0,1,0.00392157;3=0,0,0;4=0,1065353216,998277249;5=0,0,0;6=0,0,0;7=0,0,0;8=0,0,0;9=0,0,0;10=BANDUNUSED,BAND70CM,BAND33CM,BAND23CM;11=STRAIGHT,BUG,PADDLE;12=1028443341,1065353216,989872160;13=0,0,0;14=0,0,0;15=0,0,0;16=0,0,0;17=0,0,0;18=0,0,0;19=0,0,0;20=0,0,0;21=0,0,0;22=0,0,0;23=0,0,0;24=0,0,0;25=0,0,0;26=0,0,0;27=0,0,0;28=0,0,0;29=0,0,0;30=0,0,0;31=0,0,0;32=0,1065353216,998277249;33=0,0,0;34=0,0,0;35=0,0,0;36=0,0,0;37=0,0,0;38=0,0,0;39=0,1120403456,998277249;40=0,0,0;41=0,0,0;42=0,0,0;43=0,0,0;44=0,2,1;45=-1041235968,1092616192,1056964608;46=0,3,1;47=0,0,0;48=0,0,0;49=0,0,0;50=0,0,0;51=0,0,0;52=0,0,0;53=0,0,0;54=0,0,0;55=0,0,0;56=0,0,0;57=0,0,0;58=0,0,0;59=0,0,0;60=0,0,0;61=0,0,0;62=0,0,0;63=0,0,0; +rig_model=1 +hamlib_version=Hamlib 4.7.0 2026-02-15T21:14:25Z SHA=554e02b39 64-bit +done diff --git a/crates/cathub/src/hamlib_net.rs b/crates/cathub/src/hamlib_net.rs new file mode 100644 index 0000000..a0d6409 --- /dev/null +++ b/crates/cathub/src/hamlib_net.rs @@ -0,0 +1,662 @@ +//! Hamlib `rigctld`-compatible TCP server face (design §6/§8, validated against golden +//! transcripts captured from a real `rigctld`, §10.1). +//! +//! This is a thin server-side reimplementation of the rigctld net protocol — it never +//! links Hamlib (§8.8). It serves the QsoRipper engine (read-only endpoint) and WSJT-X +//! (write/PTT endpoint). Modeled reads come from the universal state; writes go through +//! [`FaceContext::apply_modeled`] so they participate in serialization, the PTT lease, and +//! event fan-out. Set commands never emit a VFO-target write (frequency always lands on +//! the active VFO), preserving the no-VFO-retargeting invariant. + +use std::sync::Arc; + +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::TcpListener; + +use crate::dialect::{ApplyOutcome, FaceContext}; +use crate::model::{Mode, PttSource, StateMutation, Vfo}; +use crate::permissions::CommandClass; + +/// The capability dump served for `\dump_state`. Captured verbatim from Hamlib 4.7.0 +/// `rigctld` (protocol version 1) so the WSJT-X / engine Hamlib clients parse it exactly. +const DUMP_STATE: &str = include_str!("hamlib_dump_state.txt"); + +const RPRT_OK: &[u8] = b"RPRT 0\n"; +/// Generic invalid / not-permitted error. +const RPRT_EINVAL: &[u8] = b"RPRT -1\n"; +/// Feature not available on this backend. +const RPRT_ENAVAIL: &[u8] = b"RPRT -11\n"; + +fn vfo_name(vfo: Vfo) -> &'static str { + match vfo { + Vfo::A => "VFOA", + Vfo::B => "VFOB", + } +} + +/// Parse a `set_freq` argument into whole Hz. +/// +/// Hamlib clients (WSJT-X, the engine, Log4OM) format frequencies as a double with +/// `"%f"`, e.g. `F 14040005.000000`, so a plain `u64` parse rejects every real +/// `set_freq` with `RPRT -1`. Accept either a plain integer or a decimal value and +/// keep the integer Hz part (frequencies are always whole Hz, so the fraction is `0`). +fn parse_freq_hz(arg: &str) -> Option { + arg.parse::().ok().or_else(|| { + arg.split_once('.') + .and_then(|(whole, _frac)| whole.parse::().ok()) + }) +} + +fn outcome_rprt(outcome: ApplyOutcome) -> Vec { + match outcome { + ApplyOutcome::Ok => RPRT_OK.to_vec(), + _ => RPRT_EINVAL.to_vec(), + } +} + +/// The result of handling one protocol line. +enum LineResult { + /// Reply bytes to send. + Reply(Vec), + /// The client asked to quit; close the connection. + Quit, +} + +/// Handle one rigctld protocol line against the universal state. +#[allow(clippy::too_many_lines)] // A flat protocol dispatch table reads best as one match. +async fn handle_line(line: &str, ctx: &FaceContext) -> LineResult { + let trimmed = line.trim(); + if trimmed.is_empty() { + return LineResult::Reply(Vec::new()); + } + let mut parts = trimmed.split_whitespace(); + let Some(cmd) = parts.next() else { + return LineResult::Reply(Vec::new()); + }; + let snapshot = ctx.snapshot(); + + let reply: Vec = match cmd { + "q" | "Q" => return LineResult::Quit, + + // --- reads (require `read`) --- + "f" | "\\get_freq" => guard_read(ctx, || { + format!("{}\n", snapshot.vfo(snapshot.rx_vfo).freq_hz).into_bytes() + }), + "m" | "\\get_mode" => guard_read(ctx, || { + let v = snapshot.vfo(snapshot.rx_vfo); + format!( + "{}\n{}\n", + v.mode.hamlib_token_with_data(v.data), + v.passband_hz + ) + .into_bytes() + }), + "v" | "\\get_vfo" => guard_read(ctx, || { + format!("{}\n", vfo_name(snapshot.rx_vfo)).into_bytes() + }), + "s" | "\\get_split_vfo" => guard_read(ctx, || { + let tx = if snapshot.split { + snapshot.tx_vfo + } else { + snapshot.rx_vfo + }; + format!("{}\n{}\n", u8::from(snapshot.split), vfo_name(tx)).into_bytes() + }), + "t" | "\\get_ptt" => { + guard_read(ctx, || format!("{}\n", u8::from(snapshot.ptt)).into_bytes()) + } + "\\get_powerstat" => guard_read(ctx, || { + format!("{}\n", u8::from(snapshot.power_on)).into_bytes() + }), + "\\dump_state" => DUMP_STATE.as_bytes().to_vec(), + // chk_vfo: matches the captured Hamlib 4.7.0 "no-vfo" handshake reply. + "\\chk_vfo" => b"0\n".to_vec(), + + // --- writes (require `write`) --- + "F" | "\\set_freq" => match parts.next().and_then(parse_freq_hz) { + Some(hz) => outcome_rprt( + ctx.apply_modeled( + StateMutation::SetVfoFreq { + vfo: snapshot.rx_vfo, + hz, + }, + CommandClass::ModeledWrite, + ) + .await, + ), + None => RPRT_EINVAL.to_vec(), + }, + "M" | "\\set_mode" => match parts.next() { + Some(token) => { + // The TS-590 splits data operation into a base mode (`MD`) and an + // independent DATA flag (`DA`), so a `PKTUSB` request becomes two modeled + // writes: the base mode, then the DATA flag. Each is deduped independently, + // so re-asserting PKTUSB writes nothing and switching PKTUSB→USB only emits + // `DA0`. The base write is applied first; if it fails the data write is + // skipped and its error is reported. + let (base, data) = Mode::decompose_hamlib_token(token); + let mode_outcome = ctx + .apply_modeled( + StateMutation::SetMode { + vfo: snapshot.rx_vfo, + mode: base, + }, + CommandClass::ModeledWrite, + ) + .await; + if mode_outcome == ApplyOutcome::Ok { + outcome_rprt( + ctx.apply_modeled( + StateMutation::SetDataMode { + vfo: snapshot.rx_vfo, + on: data, + }, + CommandClass::ModeledWrite, + ) + .await, + ) + } else { + outcome_rprt(mode_outcome) + } + } + None => RPRT_EINVAL.to_vec(), + }, + "S" | "\\set_split_vfo" => { + let enabled = parts.next().map(str::trim) == Some("1"); + let tx_vfo = match parts.next() { + Some(v) if v.eq_ignore_ascii_case("VFOB") => Some(Vfo::B), + _ => Some(Vfo::A), + }; + outcome_rprt( + ctx.apply_modeled( + StateMutation::SetSplit { enabled, tx_vfo }, + CommandClass::ModeledWrite, + ) + .await, + ) + } + "T" | "\\set_ptt" => { + // Hamlib PTT values: 0 = RX, 1 = TX (generic), 2 = TX on mic, 3 = TX on data. + // WSJT-X sends `T 3` (RIG_PTT_ON_DATA) to transmit in Data/Pkt mode, so the + // source is honored on the wire (TS-590 `TX1;`) to route the DATA/USB audio + // and avoid the data beep a bare `TX;` produces. Only 0 (or a missing arg) + // means unkey. + let (keyed, source) = match parts.next().map(str::trim) { + Some("1") => (true, PttSource::Generic), + Some("2") => (true, PttSource::Mic), + Some("3") => (true, PttSource::Data), + _ => (false, PttSource::Generic), + }; + outcome_rprt( + ctx.apply_modeled( + StateMutation::SetPtt { keyed, source }, + CommandClass::PttWrite, + ) + .await, + ) + } + // Accepted but unmodeled: selecting the active VFO never retargets on the wire. + "V" | "\\set_vfo" => { + if ctx.perms.allows(CommandClass::ModeledWrite) { + RPRT_OK.to_vec() + } else { + RPRT_EINVAL.to_vec() + } + } + // RIT (J/j) and XIT (Z/z): modeled offsets in Hz, gated on backend capability. + // A zero offset disables the feature, matching rigctld semantics. The offset + // always lands on the active VFO, so this never retargets a VFO on the wire. + "j" | "\\get_rit" => guard_read(ctx, || { + let off = if snapshot.rit_enabled { + snapshot.rit_offset_hz + } else { + 0 + }; + format!("{off}\n").into_bytes() + }), + "z" | "\\get_xit" => guard_read(ctx, || { + let off = if snapshot.xit_enabled { + snapshot.xit_offset_hz + } else { + 0 + }; + format!("{off}\n").into_bytes() + }), + "J" | "\\set_rit" => { + if ctx.caps.has_rit { + match parts.next().and_then(|s| s.parse::().ok()) { + Some(offset_hz) => outcome_rprt( + ctx.apply_modeled( + StateMutation::SetRit { + offset_hz, + enabled: offset_hz != 0, + }, + CommandClass::ModeledWrite, + ) + .await, + ), + None => RPRT_EINVAL.to_vec(), + } + } else { + RPRT_ENAVAIL.to_vec() + } + } + "Z" | "\\set_xit" => { + if ctx.caps.has_xit { + match parts.next().and_then(|s| s.parse::().ok()) { + Some(offset_hz) => outcome_rprt( + ctx.apply_modeled( + StateMutation::SetXit { + offset_hz, + enabled: offset_hz != 0, + }, + CommandClass::ModeledWrite, + ) + .await, + ), + None => RPRT_EINVAL.to_vec(), + } + } else { + RPRT_ENAVAIL.to_vec() + } + } + _ => RPRT_ENAVAIL.to_vec(), + }; + LineResult::Reply(reply) +} + +/// Run a read-only command, enforcing the `read` permission. +fn guard_read(ctx: &FaceContext, f: impl FnOnce() -> Vec) -> Vec { + if ctx.perms.allows(CommandClass::ModeledRead) { + f() + } else { + RPRT_EINVAL.to_vec() + } +} + +/// Serve one accepted connection until it closes or quits. +pub(crate) async fn serve_conn(stream: S, ctx: FaceContext) +where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + 'static, +{ + let face_id = ctx.face_id; + let (reader, mut writer) = tokio::io::split(stream); + let mut lines = BufReader::new(reader).lines(); + loop { + match lines.next_line().await { + Ok(Some(line)) => { + tracing::trace!(face_id, req = %line.trim(), "hamlib_net request"); + match handle_line(&line, &ctx).await { + LineResult::Reply(bytes) => { + tracing::trace!( + face_id, + reply = %String::from_utf8_lossy(&bytes).trim_end(), + "hamlib_net reply" + ); + if !bytes.is_empty() && writer.write_all(&bytes).await.is_err() { + return; + } + let _ = writer.flush().await; + } + LineResult::Quit => { + tracing::trace!(face_id, "hamlib_net client quit"); + return; + } + } + } + Ok(None) | Err(_) => return, + } + } +} + +/// Bind a Hamlib net endpoint and serve connections. Each connection gets a fresh +/// [`FaceContext`] sharing the same state/radio/ptt but its own face id and the +/// endpoint's permissions. +pub(crate) async fn run_listener( + bind: &str, + next_face_id: Arc, + template: FaceContext, +) -> std::io::Result<()> { + let listener = TcpListener::bind(bind).await?; + tracing::info!(bind, "hamlib_net endpoint listening"); + loop { + let (stream, peer) = listener.accept().await?; + let face_id = next_face_id.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let ctx = template.clone_with_face(face_id); + tracing::debug!(%peer, face_id, "hamlib_net client connected"); + tokio::spawn(serve_conn(stream, ctx)); + } +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)] +mod tests { + use super::*; + use crate::backend::loopback::LoopbackBackend; + use crate::backend::RadioBackend; + use crate::model::{RadioEventSource, StateChange}; + use crate::permissions::FacePermissions; + use crate::ptt::PttManager; + use crate::radio::{detached_link, spawn_scheduler}; + use crate::state::StateHandle; + use std::time::Duration; + + fn ctx_with(perms: FacePermissions) -> (FaceContext, LoopbackBackend, StateHandle) { + let backend = LoopbackBackend::new(); + let caps = backend.capabilities(); + let arc: Arc = Arc::new(backend.clone()); + let state = StateHandle::new(); + let radio = spawn_scheduler(arc, detached_link(), state.clone()); + let ptt = PttManager::new(Duration::from_secs(300)); + let ctx = FaceContext::new(1, perms, state.clone(), radio, ptt, caps); + (ctx, backend, state) + } + + async fn reply_of(line: &str, ctx: &FaceContext) -> Vec { + match handle_line(line, ctx).await { + LineResult::Reply(b) => b, + LineResult::Quit => b"".to_vec(), + } + } + + #[tokio::test] + async fn get_freq_reads_from_state() { + let (ctx, _b, state) = ctx_with(FacePermissions::read_only()); + state.record( + StateChange::Freq { + vfo: Vfo::A, + hz: 7_030_000, + }, + RadioEventSource::PollDiff, + ); + assert_eq!(reply_of("f", &ctx).await, b"7030000\n".to_vec()); + } + + #[tokio::test] + async fn get_mode_returns_token_and_passband() { + let (ctx, _b, state) = ctx_with(FacePermissions::read_only()); + state.record( + StateChange::Mode { + vfo: Vfo::A, + mode: Mode::Cw, + }, + RadioEventSource::PollDiff, + ); + assert_eq!(reply_of("m", &ctx).await, b"CW\n2400\n".to_vec()); + } + + #[tokio::test] + async fn read_only_endpoint_rejects_set_freq() { + let (ctx, backend, _s) = ctx_with(FacePermissions::read_only()); + assert_eq!(reply_of("F 14074000", &ctx).await, RPRT_EINVAL.to_vec()); + tokio::time::sleep(Duration::from_millis(20)).await; + assert!(backend.mutations().is_empty()); + } + + #[tokio::test] + async fn read_only_endpoint_rejects_set_mode_and_ptt() { + let (ctx, _b, _s) = ctx_with(FacePermissions::read_only()); + assert_eq!(reply_of("M USB 0", &ctx).await, RPRT_EINVAL.to_vec()); + assert_eq!(reply_of("T 1", &ctx).await, RPRT_EINVAL.to_vec()); + } + + #[tokio::test] + async fn write_endpoint_sets_frequency() { + let (ctx, backend, _s) = ctx_with(FacePermissions::from_tokens(&["read", "write", "ptt"])); + assert_eq!(reply_of("F 14074000", &ctx).await, RPRT_OK.to_vec()); + tokio::time::sleep(Duration::from_millis(20)).await; + assert_eq!( + backend.mutations(), + vec![StateMutation::SetVfoFreq { + vfo: Vfo::A, + hz: 14_074_000 + }] + ); + } + + #[tokio::test] + async fn set_freq_accepts_hamlib_decimal_format() { + // Hamlib (WSJT-X, engine, Log4OM) sends set_freq as a "%f" double, e.g. + // "F 14040005.000000". Earlier this was rejected with RPRT -1. + let (ctx, backend, _s) = ctx_with(FacePermissions::from_tokens(&["read", "write"])); + assert_eq!(reply_of("F 14040005.000000", &ctx).await, RPRT_OK.to_vec()); + tokio::time::sleep(Duration::from_millis(20)).await; + assert_eq!( + backend.mutations(), + vec![StateMutation::SetVfoFreq { + vfo: Vfo::A, + hz: 14_040_005 + }] + ); + } + + #[test] + fn parse_freq_hz_handles_integer_and_decimal() { + assert_eq!(parse_freq_hz("14040000"), Some(14_040_000)); + assert_eq!(parse_freq_hz("14040005.000000"), Some(14_040_005)); + assert_eq!(parse_freq_hz("0.000000"), Some(0)); + assert_eq!(parse_freq_hz("abc"), None); + assert_eq!(parse_freq_hz(""), None); + } + + #[tokio::test] + async fn set_freq_never_retargets_vfo() { + let (ctx, backend, _s) = ctx_with(FacePermissions::from_tokens(&["read", "write"])); + let _ = reply_of("F 14074000", &ctx).await; + tokio::time::sleep(Duration::from_millis(20)).await; + // No SetSplit (FR/FT) mutation: frequency landed on the active VFO only. + assert!(backend + .mutations() + .iter() + .all(|m| matches!(m, StateMutation::SetVfoFreq { .. }))); + } + + #[tokio::test] + async fn set_mode_pktusb_writes_base_mode_then_data_flag() { + // WSJT-X sends PKTUSB/PKTLSB for digital modes. The hub must split it into a base + // mode write plus the independent DATA flag. Starting from the default USB/no-data, + // PKTLSB is a genuine change on both axes, so both mutations reach the radio in order. + let (ctx, backend, _s) = ctx_with(FacePermissions::from_tokens(&["read", "write"])); + assert_eq!(reply_of("M PKTLSB 0", &ctx).await, RPRT_OK.to_vec()); + tokio::time::sleep(Duration::from_millis(20)).await; + assert_eq!( + backend.mutations(), + vec![ + StateMutation::SetMode { + vfo: Vfo::A, + mode: Mode::Lsb, + }, + StateMutation::SetDataMode { + vfo: Vfo::A, + on: true, + }, + ] + ); + } + + #[tokio::test] + async fn set_mode_plain_clears_data_flag() { + // Switching from a data mode to a plain mode must emit DA0. Prime DATA on, then send + // plain USB: the base mode is unchanged (deduped) but the DATA-off write lands. + let (ctx, backend, state) = ctx_with(FacePermissions::from_tokens(&["read", "write"])); + state.record( + StateChange::DataMode { + vfo: Vfo::A, + on: true, + }, + RadioEventSource::PollDiff, + ); + assert_eq!(reply_of("M USB 0", &ctx).await, RPRT_OK.to_vec()); + tokio::time::sleep(Duration::from_millis(20)).await; + assert_eq!( + backend.mutations(), + vec![StateMutation::SetDataMode { + vfo: Vfo::A, + on: false, + }] + ); + } + + #[tokio::test] + async fn get_mode_composes_pkt_token_when_data_on() { + let (ctx, _b, state) = ctx_with(FacePermissions::read_only()); + state.record( + StateChange::DataMode { + vfo: Vfo::A, + on: true, + }, + RadioEventSource::PollDiff, + ); + // Default base mode is USB; with DATA on a Hamlib client must read PKTUSB. + assert_eq!(reply_of("m", &ctx).await, b"PKTUSB\n2400\n".to_vec()); + } + + #[tokio::test] + async fn ptt_requires_ptt_permission() { + let (ctx, _b, _s) = ctx_with(FacePermissions::from_tokens(&["read", "write"])); + // Has write but not ptt. + assert_eq!(reply_of("T 1", &ctx).await, RPRT_EINVAL.to_vec()); + } + + #[tokio::test] + async fn set_ptt_keys_on_any_nonzero_value() { + // WSJT-X in Data/Pkt mode sends `T 3` (RIG_PTT_ON_DATA), N1MM/others may send + // `T 2` (on mic) or `T 1`; all must key. Only `T 0` unkeys. + // `T 1` (generic), `T 2` (on mic), and `T 3` (on data) all key, but each selects + // the matching transmit audio path on the wire. Only `T 0` unkeys. + for (arg, source) in [ + ("1", PttSource::Generic), + ("2", PttSource::Mic), + ("3", PttSource::Data), + ] { + let (ctx, backend, _s) = + ctx_with(FacePermissions::from_tokens(&["read", "write", "ptt"])); + assert_eq!( + reply_of(&format!("T {arg}"), &ctx).await, + RPRT_OK.to_vec(), + "T {arg} should be accepted" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + assert_eq!( + backend.mutations(), + vec![StateMutation::SetPtt { + keyed: true, + source + }], + "T {arg} should key the radio with the matching source" + ); + } + + let (ctx, backend, _s) = ctx_with(FacePermissions::from_tokens(&["read", "write", "ptt"])); + assert_eq!(reply_of("T 0", &ctx).await, RPRT_OK.to_vec()); + tokio::time::sleep(Duration::from_millis(20)).await; + assert_eq!( + backend.mutations(), + vec![StateMutation::SetPtt { + keyed: false, + source: PttSource::Generic + }] + ); + } + + #[tokio::test] + async fn dump_state_and_chk_vfo_match_golden() { + let (ctx, _b, _s) = ctx_with(FacePermissions::read_only()); + let dump = reply_of("\\dump_state", &ctx).await; + assert!(dump.starts_with(b"1\n"), "protocol version 1 first line"); + assert!(dump.ends_with(b"done\n")); + assert_eq!(reply_of("\\chk_vfo", &ctx).await, b"0\n".to_vec()); + assert_eq!(reply_of("\\get_powerstat", &ctx).await, b"1\n".to_vec()); + } + + #[tokio::test] + async fn quit_closes() { + let (ctx, _b, _s) = ctx_with(FacePermissions::read_only()); + assert!(matches!(handle_line("q", &ctx).await, LineResult::Quit)); + } + + #[tokio::test] + async fn set_rit_applies_offset_when_capable() { + let (ctx, backend, state) = ctx_with(FacePermissions::from_tokens(&["read", "write"])); + assert_eq!(reply_of("\\set_rit 100", &ctx).await, RPRT_OK.to_vec()); + tokio::time::sleep(Duration::from_millis(20)).await; + assert_eq!( + backend.mutations(), + vec![StateMutation::SetRit { + offset_hz: 100, + enabled: true, + }] + ); + let snap = state.snapshot(); + assert!(snap.rit_enabled); + assert_eq!(snap.rit_offset_hz, 100); + } + + #[tokio::test] + async fn set_xit_applies_offset_when_capable() { + let (ctx, backend, state) = ctx_with(FacePermissions::from_tokens(&["read", "write"])); + assert_eq!(reply_of("Z -250", &ctx).await, RPRT_OK.to_vec()); + tokio::time::sleep(Duration::from_millis(20)).await; + assert_eq!( + backend.mutations(), + vec![StateMutation::SetXit { + offset_hz: -250, + enabled: true, + }] + ); + assert_eq!(state.snapshot().xit_offset_hz, -250); + } + + #[tokio::test] + async fn set_rit_zero_offset_disables_rit() { + let (ctx, _b, state) = ctx_with(FacePermissions::from_tokens(&["read", "write"])); + assert_eq!(reply_of("\\set_rit 0", &ctx).await, RPRT_OK.to_vec()); + tokio::time::sleep(Duration::from_millis(20)).await; + assert!(!state.snapshot().rit_enabled); + } + + #[tokio::test] + async fn get_rit_and_xit_report_offsets() { + let (ctx, _b, state) = ctx_with(FacePermissions::read_only()); + assert_eq!(reply_of("\\get_rit", &ctx).await, b"0\n".to_vec()); + state.record( + StateChange::Rit { + enabled: true, + offset_hz: 250, + }, + RadioEventSource::PollDiff, + ); + state.record( + StateChange::Xit { + enabled: true, + offset_hz: -50, + }, + RadioEventSource::PollDiff, + ); + assert_eq!(reply_of("j", &ctx).await, b"250\n".to_vec()); + assert_eq!(reply_of("z", &ctx).await, b"-50\n".to_vec()); + } + + #[tokio::test] + async fn rit_and_xit_report_not_available_without_capability() { + // A backend that does not model RIT/XIT must answer not-available. + let backend = LoopbackBackend::new(); + let mut caps = backend.capabilities(); + caps.has_rit = false; + caps.has_xit = false; + let arc: Arc = Arc::new(backend); + let state = StateHandle::new(); + let radio = spawn_scheduler(arc, detached_link(), state.clone()); + let ptt = PttManager::new(Duration::from_secs(300)); + let ctx = FaceContext::new( + 1, + FacePermissions::from_tokens(&["read", "write"]), + state, + radio, + ptt, + caps, + ); + assert_eq!(reply_of("\\set_rit 100", &ctx).await, RPRT_ENAVAIL.to_vec()); + assert_eq!(reply_of("\\set_xit 100", &ctx).await, RPRT_ENAVAIL.to_vec()); + } +} diff --git a/crates/cathub/src/integration.rs b/crates/cathub/src/integration.rs new file mode 100644 index 0000000..80f59c3 --- /dev/null +++ b/crates/cathub/src/integration.rs @@ -0,0 +1,289 @@ +//! Cross-module integration tests (design §10.2). +//! +//! These bring up the full stack — universal state, the priority scheduler over a +//! [`LoopbackBackend`], multiple serial faces over `tokio::io::duplex`, and the Hamlib net +//! face over real TCP — and assert the system-level invariants: one radio command per poll +//! (reads are served from cache), no VFO-target traffic from a TS-2000 face, cross-face +//! write visibility, front-panel fan-out, strict write ordering, PTT arbitration, and that +//! the Hamlib net face and a serial face share one radio state. +//! +//! Binary crates cannot host `tests/` integration crates, so these live in-crate behind +//! `#[cfg(test)]` to reach the `pub(crate)` surface. + +#![allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)] + +use std::sync::Arc; +use std::time::Duration; + +use tokio::io::{AsyncReadExt, AsyncWriteExt, DuplexStream}; +use tokio::net::{TcpListener, TcpStream}; + +use crate::backend::loopback::LoopbackBackend; +use crate::backend::{BackendCapabilities, RadioBackend}; +use crate::dialect::kenwood::ts2000::Ts2000Dialect; +use crate::dialect::kenwood::ts590::Ts590Dialect; +use crate::dialect::{ClientDialect, FaceContext}; +use crate::hamlib_net::serve_conn; +use crate::permissions::FacePermissions; +use crate::ptt::PttManager; +use crate::radio::{detached_link, spawn_scheduler, OpKind, Priority, RadioHandle}; +use crate::serial_face::run_face; +use crate::state::StateHandle; + +/// A wired-up radio: shared loopback backend, state, scheduler and PTT lease. +struct Rig { + backend: LoopbackBackend, + state: StateHandle, + radio: RadioHandle, + ptt: PttManager, + caps: BackendCapabilities, +} + +fn rig() -> Rig { + let backend = LoopbackBackend::new(); + let caps = backend.capabilities(); + let arc: Arc = Arc::new(backend.clone()); + let state = StateHandle::new(); + let radio = spawn_scheduler(arc, detached_link(), state.clone()); + let ptt = PttManager::new(Duration::from_secs(300)); + Rig { + backend, + state, + radio, + ptt, + caps, + } +} + +impl Rig { + fn face( + &self, + dialect: Arc, + perms: FacePermissions, + id: u64, + ) -> DuplexStream { + let ctx = FaceContext::new( + id, + perms, + self.state.clone(), + self.radio.clone(), + self.ptt.clone(), + self.caps.clone(), + ); + let (client, server) = tokio::io::duplex(1024); + tokio::spawn(run_face(server, dialect, ctx, b';')); + client + } +} + +fn ts590() -> Arc { + Arc::new(Ts590Dialect::new()) +} + +fn ts2000() -> Arc { + Arc::new(Ts2000Dialect::new()) +} + +/// Send `cmd` and read one `;`-terminated reply frame, failing fast on a hang. +async fn request(client: &mut DuplexStream, cmd: &[u8]) -> Vec { + client.write_all(cmd).await.expect("write"); + read_frame(client).await +} + +async fn read_frame(client: &mut DuplexStream) -> Vec { + let mut frame = Vec::new(); + let mut byte = [0u8; 1]; + loop { + let n = tokio::time::timeout(Duration::from_secs(2), client.read(&mut byte)) + .await + .expect("reply did not arrive") + .expect("read"); + if n == 0 { + break; + } + frame.push(byte[0]); + if byte[0] == b';' { + break; + } + } + frame +} + +/// A write on one face is visible to a read on another face on the same radio. +#[tokio::test] +async fn write_from_one_face_visible_on_another() { + let rig = rig(); + let mut writer_face = rig.face(ts590(), FacePermissions::from_tokens(&["read", "write"]), 1); + let mut reader_face = rig.face(ts2000(), FacePermissions::read_only(), 2); + + // Set VFO A frequency from the native (N1MM-style) face. + writer_face + .write_all(b"FA00007050000;") + .await + .expect("write"); + // Let the scheduler apply the write before reading from the other face. + tokio::time::sleep(Duration::from_millis(30)).await; + + let reply = request(&mut reader_face, b"FA;").await; + assert_eq!(reply, b"FA00007050000;"); +} + +/// Modeled reads are served from the cache: no client read ever reaches the backend, so a +/// flurry of reads from many faces adds zero real-radio commands. +#[tokio::test] +async fn face_reads_are_served_from_cache_not_the_backend() { + let rig = rig(); + let mut a = rig.face(ts590(), FacePermissions::read_only(), 1); + let mut b = rig.face(ts2000(), FacePermissions::read_only(), 2); + + for _ in 0..20 { + let _ = request(&mut a, b"FA;").await; + let _ = request(&mut b, b"FA;").await; + } + + assert_eq!(rig.backend.poll_count(), 0, "reads must not poll the radio"); + assert!( + rig.backend.mutations().is_empty(), + "reads must not mutate the radio" + ); + assert!( + rig.backend.passthroughs().is_empty(), + "modeled reads must not passthrough" + ); +} + +/// A TS-2000 (OmniRig/HDSDR) face never emits VFO-target traffic: status reads come from +/// cache and a VFO-target write is rejected, never forwarded (the §8.8 invariant). +#[tokio::test] +async fn ts2000_face_never_retargets_vfo() { + let rig = rig(); + let mut omni = rig.face(ts2000(), FacePermissions::read_only(), 1); + + let _ = request(&mut omni, b"IF;").await; + let _ = request(&mut omni, b"FA;").await; + let _ = request(&mut omni, b"FB;").await; + // OmniRig issuing an FR (RX VFO select) write must be rejected, not forwarded. + let reply = request(&mut omni, b"FR1;").await; + assert_eq!(reply, b"?;"); + + assert!( + rig.backend.mutations().is_empty(), + "no status read or VFO-select should mutate the radio" + ); + assert!(rig.backend.passthroughs().is_empty()); +} + +/// A simulated front-panel change (a poll-diff from the backend's truth) fans out to every +/// auto-info-subscribed face without any client having polled. +#[tokio::test] +async fn front_panel_change_fans_out_to_all_subscribed_faces() { + let rig = rig(); + let mut a = rig.face(ts590(), FacePermissions::read_only(), 1); + let mut b = rig.face(ts590(), FacePermissions::read_only(), 2); + + // Both faces turn on virtualized auto-info. + a.write_all(b"AI2;").await.expect("write"); + b.write_all(b"AI2;").await.expect("write"); + tokio::time::sleep(Duration::from_millis(20)).await; + + // Operator turns the knob: backend truth changes, then a poll diffs it into state. + rig.backend.set_truth_freq_a(7_123_000); + rig.radio + .submit(0, Priority::Poll, OpKind::Poll) + .await + .expect("poll"); + + assert_eq!(read_frame(&mut a).await, b"FA00007123000;"); + assert_eq!(read_frame(&mut b).await, b"FA00007123000;"); +} + +/// Writes fanned in from several faces are strictly serialized through the one radio task. +#[tokio::test] +async fn writes_from_all_faces_are_strictly_ordered() { + let rig = rig(); + let perms = FacePermissions::from_tokens(&["read", "write"]); + let mut f1 = rig.face(ts590(), perms, 1); + let mut f2 = rig.face(ts590(), perms, 2); + let mut f3 = rig.face(ts590(), perms, 3); + + f1.write_all(b"FA00007010000;").await.expect("w1"); + f2.write_all(b"FA00007020000;").await.expect("w2"); + f3.write_all(b"FA00007030000;").await.expect("w3"); + tokio::time::sleep(Duration::from_millis(50)).await; + + let muts = rig.backend.mutations(); + assert_eq!(muts.len(), 3, "every write reaches the radio exactly once"); +} + +/// PTT is a single-owner lease: the first capable face keys, a second is refused while the +/// lease is held, and the lease frees on `RX;` so the second face can then key. +#[tokio::test] +async fn ptt_lease_is_arbitrated_across_faces() { + let rig = rig(); + let perms = FacePermissions::from_tokens(&["read", "write", "ptt"]); + let mut f1 = rig.face(ts590(), perms, 1); + let mut f2 = rig.face(ts590(), perms, 2); + + // f1 keys: a Kenwood set has no positive reply, so nothing comes back. + f1.write_all(b"TX;").await.expect("tx1"); + tokio::time::sleep(Duration::from_millis(20)).await; + + // f2 tries to key while f1 holds the lease: rejected with `?;`. + assert_eq!(request(&mut f2, b"TX;").await, b"?;"); + + // f1 unkeys, releasing the lease. + f1.write_all(b"RX;").await.expect("rx1"); + tokio::time::sleep(Duration::from_millis(20)).await; + + // f2 can now acquire it (no error reply). + f2.write_all(b"TX;").await.expect("tx2"); + tokio::time::sleep(Duration::from_millis(20)).await; + assert_eq!(rig.ptt.owner(), Some(2)); +} + +/// The Hamlib net face (engine / WSJT-X) and a serial face share one radio state: a write on +/// the serial face is read back over TCP, and a read-only endpoint rejects writes. +#[tokio::test] +async fn hamlib_net_and_serial_face_share_radio_state() { + let rig = rig(); + + // A native serial face that can write (N1MM-style). + let mut n1mm = rig.face(ts590(), FacePermissions::from_tokens(&["read", "write"]), 1); + + // A read-only Hamlib net endpoint (the QsoRipper engine). + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let addr = listener.local_addr().expect("addr"); + let ro_ctx = FaceContext::new( + 2, + FacePermissions::read_only(), + rig.state.clone(), + rig.radio.clone(), + rig.ptt.clone(), + rig.caps.clone(), + ); + tokio::spawn(async move { + let (stream, _) = listener.accept().await.expect("accept"); + serve_conn(stream, ro_ctx).await; + }); + + // Set the frequency from the serial face. + n1mm.write_all(b"FA00014250000;").await.expect("write"); + tokio::time::sleep(Duration::from_millis(30)).await; + + // The engine reads it back over the Hamlib net protocol (`f` => bare Hz). + let mut engine = TcpStream::connect(addr).await.expect("connect"); + engine.write_all(b"f\n").await.expect("f"); + let mut buf = vec![0u8; 64]; + let n = engine.read(&mut buf).await.expect("read"); + let text = String::from_utf8_lossy(&buf[..n]); + assert_eq!(text.trim(), "14250000"); + + // The read-only endpoint rejects a set-frequency write. + engine.write_all(b"F 7000000\n").await.expect("F"); + let n = engine.read(&mut buf).await.expect("read"); + let text = String::from_utf8_lossy(&buf[..n]); + assert!( + text.starts_with("RPRT -"), + "read-only endpoint rejects F: {text}" + ); +} diff --git a/crates/cathub/src/lib.rs b/crates/cathub/src/lib.rs new file mode 100644 index 0000000..71c8973 --- /dev/null +++ b/crates/cathub/src/lib.rs @@ -0,0 +1,426 @@ +//! qsoripper-cathub: a multi-client CAT hub daemon. +//! +//! The daemon is the single owner of the radio link and fans it out to many client faces +//! (HDSDR/OmniRig, N1MM Logger+, ARCP-590, WSJT-X, Log4OM, and the QsoRipper engine) over +//! their native protocols. It serializes every write, owns the radio's native push stream, +//! serves reads from a universal cache, arbitrates PTT with a single-owner lease, and never +//! retargets a VFO during polling — eliminating the A/B oscillation, frequency drift, and +//! transmit conflicts that come from many apps fighting over one serial port. +//! +//! See `docs/design/cathub-multi-client-cat-hub.md` for the full design. + +#![allow(clippy::doc_markdown)] + +mod backend; +mod config; +mod dialect; +mod error; +mod events; +mod hamlib_net; +mod logging; +mod model; +mod permissions; +mod ptt; +mod radio; +mod serial_face; +mod state; + +#[cfg(test)] +mod integration; + +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use clap::Parser; +use tokio::net::TcpStream; +use tracing_appender::non_blocking::WorkerGuard; + +use crate::backend::kenwood::ts590::Ts590Backend; +use crate::backend::loopback::LoopbackBackend; +use crate::backend::rigctld::RigctldBackend; +use crate::backend::RadioBackend; +use crate::config::{Config, RadioConfig}; +use crate::dialect::kenwood::ts2000::Ts2000Dialect; +use crate::dialect::kenwood::ts590::Ts590Dialect; +use crate::dialect::{ClientDialect, FaceContext}; +use crate::events::{enable_native_push, spawn_poller, POLLER_FACE}; +use crate::hamlib_net::run_listener; +use crate::model::StateMutation; +use crate::ptt::PttManager; +use crate::radio::{link_channel, run_transport, spawn_scheduler, OpKind, Priority}; +use crate::serial_face::{open_serial, run_face}; +use crate::state::StateHandle; + +pub use crate::error::CatHubError; + +/// Command-line arguments. +#[derive(Debug, Parser)] +#[command( + name = "qsoripper-cathub", + about = "Multi-client CAT hub daemon for sharing one radio across many applications" +)] +pub struct Cli { + /// Path to the configuration file (defaults to the platform config path). + #[arg(short, long)] + pub config: Option, + /// Optional explicit log file path (informational; logging also writes a rolling file). + #[arg(long)] + pub log: Option, + /// Load and validate the configuration, print it, and exit without touching hardware. + #[arg(long)] + pub dry_run: bool, +} + +/// Initialize tracing for the process. The returned guard must be kept alive for the +/// process lifetime so the non-blocking file writer flushes on shutdown. +pub fn init_logging() -> WorkerGuard { + logging::init() +} + +/// Build the configured radio backend. +fn build_backend(cfg: &Config) -> Result, CatHubError> { + match cfg.radio.backend.as_str() { + "ts590" => Ok(Arc::new(Ts590Backend::new())), + "rigctld" => Ok(Arc::new(RigctldBackend::new( + cfg.radio.model.clone(), + cfg.radio.certified, + ))), + "loopback" => Ok(Arc::new(LoopbackBackend::new())), + other => Err(CatHubError::Backend(format!("unknown backend '{other}'"))), + } +} + +/// Build a client dialect by name. +fn dialect_for(name: &str) -> Result, CatHubError> { + match name { + "ts590" => Ok(Arc::new(Ts590Dialect::new())), + "ts2000" => Ok(Arc::new(Ts2000Dialect::new())), + other => Err(CatHubError::Backend(format!("unknown dialect '{other}'"))), + } +} + +/// An opened radio transport. +enum OpenedTransport { + /// A real serial port. + Serial(serial2_tokio::SerialPort), + /// A TCP socket (for a `tcp` transport or rigctld bridge endpoint). + Tcp(TcpStream), +} + +/// Open the radio transport described by the `[radio]` section. +async fn open_transport(radio: &RadioConfig) -> Result { + match radio.transport.as_str() { + "serial" => { + let port = serial2_tokio::SerialPort::open(&radio.port, radio.baud)?; + // Assert the RTS and DTR modem-control lines. Some radios (notably the Kenwood + // TS-590) gate their CAT transmit on RTS and send no replies at all unless it is + // high, so without this the daemon opens the port but every poll times out. This + // matches the default line state that OmniRig/Hamlib clients use. + port.set_rts(true)?; + port.set_dtr(true)?; + Ok(OpenedTransport::Serial(port)) + } + "tcp" => { + let stream = TcpStream::connect((radio.host.as_str(), radio.tcp_port)).await?; + Ok(OpenedTransport::Tcp(stream)) + } + other => Err(CatHubError::Backend(format!( + "unknown radio.transport '{other}'" + ))), + } +} + +/// Run the daemon to completion (until Ctrl+C). +/// +/// # Errors +/// +/// Returns a [`CatHubError`] if the configuration cannot be loaded or validated, the +/// backend or a dialect cannot be built, the radio transport or a face port cannot be +/// opened, or the process fails to install its Ctrl+C handler. +#[allow(clippy::too_many_lines)] // The wiring is one cohesive bring-up sequence. +pub async fn run(cli: Cli) -> Result<(), CatHubError> { + let path = cli + .config + .clone() + .unwrap_or_else(Config::default_config_path); + if let Some(log) = &cli.log { + tracing::debug!(log = %log.display(), "log path override requested"); + } + let cfg = Config::load(&path)?; + + if cli.dry_run { + println!("{}", cfg.describe()); + return Ok(()); + } + + let backend = build_backend(&cfg)?; + let caps = backend.capabilities(); + tracing::info!(caps = %caps.summary(), "backend ready"); + + let state = StateHandle::new(); + let ptt = PttManager::new(cfg.ptt_max_tx()); + + // Wire the transport to the serialized radio link. The loopback backend needs no real + // transport (it never submits raw bytes), so we just drop the receiver in that case. + let (link, raw_rx) = link_channel(); + if cfg.radio.backend == "loopback" { + drop(raw_rx); + } else { + match open_transport(&cfg.radio).await? { + OpenedTransport::Serial(port) => { + tokio::spawn(run_transport(port, backend.clone(), state.clone(), raw_rx)); + } + OpenedTransport::Tcp(stream) => { + tokio::spawn(run_transport( + stream, + backend.clone(), + state.clone(), + raw_rx, + )); + } + } + } + + let push_link = link.clone(); + let radio = spawn_scheduler(backend.clone(), link, state.clone()); + + let native_push_active = if cfg.events.native_push { + enable_native_push(&backend, &push_link).await + } else { + false + }; + spawn_poller( + radio.clone(), + state.clone(), + native_push_active, + cfg.baseline_interval(), + cfg.heartbeat_interval(), + ); + + // Prime the universal state with one awaited poll before any face begins serving, so the + // first client read (e.g. HDSDR/OmniRig connecting at startup) sees real radio state + // instead of defaults. Best-effort and time-bounded: a slow or absent radio must not + // block startup, since the baseline poller keeps retrying afterwards. + match tokio::time::timeout( + Duration::from_millis(1_000), + radio.submit(POLLER_FACE, Priority::Poll, OpKind::Poll), + ) + .await + { + Ok(Ok(_)) => tracing::info!("primed universal state from initial poll"), + Ok(Err(error)) => { + tracing::warn!(%error, "initial priming poll failed; serving defaults until next poll"); + } + Err(_) => { + tracing::warn!("initial priming poll timed out; serving defaults until next poll"); + } + } + + let next_id = Arc::new(AtomicU64::new(1)); + + for face in &cfg.face { + let dialect = dialect_for(&face.dialect)?; + let id = next_id.fetch_add(1, Ordering::SeqCst); + let ctx = FaceContext::new( + id, + face.permissions(), + state.clone(), + radio.clone(), + ptt.clone(), + caps.clone(), + ); + let port = open_serial(&face.name, &face.transport, face.baud)?; + tokio::spawn(run_face(port, dialect, ctx, b';')); + tracing::info!( + face = %face.name, + id, + hub_port = %face.transport, + "serial face listening; hub owns this port -- point the application at the paired \ + com0com port, not this one" + ); + } + + for ep in &cfg.hamlib_net { + let id = next_id.fetch_add(1, Ordering::SeqCst); + let template = FaceContext::new( + id, + ep.permissions(), + state.clone(), + radio.clone(), + ptt.clone(), + caps.clone(), + ); + let bind = ep.bind.clone(); + let name = ep.name.clone(); + let ids = next_id.clone(); + tokio::spawn(async move { + if let Err(e) = run_listener(&bind, ids, template).await { + tracing::error!(endpoint = %name, error = %e, "hamlib_net listener stopped"); + } + }); + tracing::info!(endpoint = %ep.name, bind = %ep.bind, "hamlib_net endpoint listening"); + } + + // PTT safety watchdog: a transmitter that exceeds the configured ceiling is unkeyed at + // the radio first, then released, so the ceiling is a real stuck-transmitter backstop + // and the lease is never freed while the radio is still keyed. + { + let ptt = ptt.clone(); + let radio = radio.clone(); + tokio::spawn(async move { + loop { + tokio::time::sleep(Duration::from_millis(500)).await; + if let Some(face) = ptt.expired_owner() { + let _ = radio + .submit( + face, + Priority::Ptt, + OpKind::Apply(StateMutation::SetPtt { + keyed: false, + source: crate::model::PttSource::Generic, + }), + ) + .await; + ptt.unkey(face); + tracing::warn!(face, "PTT safety ceiling reached; transmitter released"); + } + } + }); + } + + tracing::info!("cathub running; press Ctrl+C to stop"); + tokio::signal::ctrl_c().await?; + tracing::info!("shutdown requested"); + + // Best-effort orderly stop: never leave the transmitter keyed (design §8.5). A hard + // crash cannot run this; the ptt_max_tx_ms ceiling and the radio's own TX timeout are + // the ultimate backstops. + if let Some(owner) = ptt.owner() { + let _ = tokio::time::timeout( + Duration::from_millis(500), + radio.submit( + owner, + Priority::Ptt, + OpKind::Apply(StateMutation::SetPtt { + keyed: false, + source: crate::model::PttSource::Generic, + }), + ), + ) + .await; + ptt.unkey(owner); + tracing::info!(face = owner, "released PTT on shutdown"); + } + Ok(()) +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)] +mod tests { + use super::*; + + #[test] + fn build_backend_dispatches_each_kind() { + let cfg = Config::parse( + "[radio]\nbackend = \"ts590\"\nport = \"COM3\"\n\ + [[face]]\nname=\"f\"\ntransport=\"COM5\"\ndialect=\"ts590\"\n", + ) + .expect("parse"); + assert_eq!( + build_backend(&cfg).expect("ts590").capabilities().model, + "TS-590" + ); + + let cfg = Config::parse( + "[radio]\nbackend = \"rigctld\"\nmodel=\"TS-590SG\"\ntransport=\"tcp\"\n\ + [[face]]\nname=\"f\"\ntransport=\"COM5\"\ndialect=\"ts590\"\n", + ) + .expect("parse"); + assert!(build_backend(&cfg) + .expect("rigctld") + .capabilities() + .model + .starts_with("rigctld:")); + + let cfg = Config::parse( + "[radio]\nbackend = \"loopback\"\n[[face]]\nname=\"f\"\ntransport=\"COM5\"\ndialect=\"ts590\"\n", + ) + .expect("parse"); + assert_eq!( + build_backend(&cfg).expect("loopback").capabilities().model, + "loopback" + ); + } + + #[test] + fn dialect_for_known_and_unknown() { + assert!(dialect_for("ts590").is_ok()); + assert!(dialect_for("ts2000").is_ok()); + assert!(dialect_for("yaesu").is_err()); + } + + #[tokio::test] + async fn dry_run_prints_config_and_exits() { + let dir = std::env::temp_dir(); + let path = dir.join(format!("cathub-dryrun-{}.toml", std::process::id())); + std::fs::write( + &path, + "[radio]\nbackend = \"loopback\"\n\ + [[face]]\nname=\"f\"\ntransport=\"COM5\"\ndialect=\"ts590\"\n", + ) + .expect("write"); + let cli = Cli { + config: Some(path.clone()), + log: None, + dry_run: true, + }; + assert!(run(cli).await.is_ok()); + let _ = std::fs::remove_file(&path); + } + + #[tokio::test] + async fn run_fails_on_missing_config() { + let cli = Cli { + config: Some(PathBuf::from("does-not-exist-cathub.toml")), + log: None, + dry_run: true, + }; + assert!(run(cli).await.is_err()); + } + + #[tokio::test] + async fn open_transport_rejects_unknown_transport() { + let mut cfg = Config::parse( + "[radio]\nbackend = \"ts590\"\ntransport = \"serial\"\nport = \"COM3\"\n\ + [[face]]\nname=\"f\"\ntransport=\"COM5\"\ndialect=\"ts590\"\n", + ) + .expect("parse"); + // open_transport defends against a transport string that bypassed validation. + cfg.radio.transport = "usb".to_string(); + assert!(open_transport(&cfg.radio).await.is_err()); + } + + #[tokio::test] + async fn run_wires_loopback_then_fails_opening_a_bogus_face_port() { + // Exercises the full bring-up: backend, state, PTT, scheduler, native-push probe, + // and poller, failing only when it tries to open a face's (nonexistent) serial port. + let dir = std::env::temp_dir(); + let path = dir.join(format!("cathub-run-{}.toml", std::process::id())); + std::fs::write( + &path, + "[radio]\nbackend = \"loopback\"\n\ + [[face]]\nname = \"bogus\"\ntransport = \"COM_DOES_NOT_EXIST\"\ndialect = \"ts590\"\n", + ) + .expect("write"); + let cli = Cli { + config: Some(path.clone()), + log: None, + dry_run: false, + }; + let result = tokio::time::timeout(std::time::Duration::from_secs(5), run(cli)).await; + let _ = std::fs::remove_file(&path); + assert!(matches!(result, Ok(Err(_))), "expected a face open error"); + } +} diff --git a/crates/cathub/src/logging.rs b/crates/cathub/src/logging.rs new file mode 100644 index 0000000..5c3f86c --- /dev/null +++ b/crates/cathub/src/logging.rs @@ -0,0 +1,55 @@ +//! Tracing setup: a stdout layer plus a rolling daily file appender (design §6/§11). + +use std::path::PathBuf; + +use tracing_appender::non_blocking::WorkerGuard; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::util::SubscriberInitExt; +use tracing_subscriber::EnvFilter; + +/// The default log directory for this platform. +fn log_dir() -> PathBuf { + #[cfg(target_os = "windows")] + { + if let Ok(profile) = std::env::var("USERPROFILE") { + return PathBuf::from(profile); + } + } + #[cfg(not(target_os = "windows"))] + { + if let Ok(state) = std::env::var("XDG_STATE_HOME") { + return PathBuf::from(state).join("qsoripper"); + } + if let Ok(home) = std::env::var("HOME") { + return PathBuf::from(home) + .join(".local") + .join("state") + .join("qsoripper"); + } + } + PathBuf::from(".") +} + +/// Initialize tracing. Returns a [`WorkerGuard`] that must be kept alive for the process +/// lifetime so the non-blocking file writer flushes on shutdown. +pub(crate) fn init() -> WorkerGuard { + let dir = log_dir(); + let _ = std::fs::create_dir_all(&dir); + let file_appender = tracing_appender::rolling::daily(&dir, "qsoripper-cathub.log"); + let (non_blocking, guard) = tracing_appender::non_blocking(file_appender); + + let filter = EnvFilter::try_from_env("CATHUB_LOG").unwrap_or_else(|_| EnvFilter::new("info")); + + let stdout_layer = tracing_subscriber::fmt::layer().with_target(false); + let file_layer = tracing_subscriber::fmt::layer() + .with_ansi(false) + .with_writer(non_blocking); + + tracing_subscriber::registry() + .with(filter) + .with(stdout_layer) + .with(file_layer) + .init(); + + guard +} diff --git a/crates/cathub/src/main.rs b/crates/cathub/src/main.rs new file mode 100644 index 0000000..49da1aa --- /dev/null +++ b/crates/cathub/src/main.rs @@ -0,0 +1,21 @@ +//! Binary entry point for the qsoripper-cathub daemon. + +use std::process::ExitCode; + +use clap::Parser; + +use qsoripper_cathub::{run, Cli}; + +#[tokio::main] +async fn main() -> ExitCode { + let cli = Cli::parse(); + let _guard = qsoripper_cathub::init_logging(); + match run(cli).await { + Ok(()) => ExitCode::SUCCESS, + Err(err) => { + tracing::error!(error = %err, "cathub exited with error"); + eprintln!("cathub: {err}"); + ExitCode::FAILURE + } + } +} diff --git a/crates/cathub/src/model.rs b/crates/cathub/src/model.rs new file mode 100644 index 0000000..026c9f3 --- /dev/null +++ b/crates/cathub/src/model.rs @@ -0,0 +1,490 @@ +//! Core domain types shared across the hub: VFO selection, operating mode, the +//! universal [`StateMutation`]/[`StateChange`] pair, the [`Field`] coverage key, +//! and the [`RadioEventSource`] provenance tag. +//! +//! These are backend- and dialect-independent. Backends map their native wire +//! vocabulary to and from these types; dialects render them into client +//! vocabularies. Keeping them here (not in `backend`) lets every layer depend on +//! one neutral vocabulary. + +use std::fmt; + +/// Identifies one of the radio's two VFOs. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) enum Vfo { + /// VFO A. + A, + /// VFO B. + B, +} + +impl fmt::Display for Vfo { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Vfo::A => f.write_str("A"), + Vfo::B => f.write_str("B"), + } + } +} + +/// Operating mode, normalized across radio families. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) enum Mode { + /// Lower sideband. + Lsb, + /// Upper sideband. + Usb, + /// CW (normal). + Cw, + /// CW reverse. + CwR, + /// FSK / RTTY. + Fsk, + /// FSK / RTTY reverse. + FskR, + /// AM. + Am, + /// FM. + Fm, + /// An unrecognized mode (treated as USB on the wire). + Unknown, +} + +impl Mode { + /// Render the mode as its Kenwood `MD` digit byte (ASCII). + pub(crate) fn to_kenwood_digit(self) -> u8 { + match self { + Mode::Lsb => b'1', + // An unknown mode falls back to USB so a write still produces a valid frame. + Mode::Usb | Mode::Unknown => b'2', + Mode::Cw => b'3', + Mode::Fm => b'4', + Mode::Am => b'5', + Mode::Fsk => b'6', + Mode::CwR => b'7', + Mode::FskR => b'9', + } + } + + /// Parse a Kenwood `MD` digit byte into a mode. + pub(crate) fn from_kenwood_digit(digit: u8) -> Mode { + match digit { + b'1' => Mode::Lsb, + b'2' => Mode::Usb, + b'3' => Mode::Cw, + b'4' => Mode::Fm, + b'5' => Mode::Am, + b'6' => Mode::Fsk, + b'7' => Mode::CwR, + b'9' => Mode::FskR, + _ => Mode::Unknown, + } + } + + /// Render the mode as a Hamlib `rigctld` mode token. + pub(crate) fn hamlib_token(self) -> &'static str { + match self { + Mode::Lsb => "LSB", + Mode::Usb | Mode::Unknown => "USB", + Mode::Cw => "CW", + Mode::CwR => "CWR", + Mode::Fsk => "RTTY", + Mode::FskR => "RTTYR", + Mode::Am => "AM", + Mode::Fm => "FM", + } + } + + /// Parse a Hamlib `rigctld` mode token into a mode. + pub(crate) fn from_hamlib_token(token: &str) -> Mode { + match token.trim() { + "LSB" => Mode::Lsb, + "USB" => Mode::Usb, + "CW" => Mode::Cw, + "CWR" => Mode::CwR, + "RTTY" => Mode::Fsk, + "RTTYR" => Mode::FskR, + "AM" => Mode::Am, + "FM" => Mode::Fm, + _ => Mode::Unknown, + } + } + + /// Render the mode as a Hamlib token, folding in the radio's DATA sub-mode flag. + /// + /// The TS-590 models data operation as a base mode (`MD`) plus an independent DATA + /// flag (`DA`), so the composed token is what a Hamlib client expects to read back: + /// `USB`+data → `PKTUSB`, `LSB`+data → `PKTLSB`, etc. A base mode with no canonical + /// `PKT*` token reports its plain token (the DATA flag is still tracked internally). + pub(crate) fn hamlib_token_with_data(self, data: bool) -> &'static str { + if data { + match self { + Mode::Usb => "PKTUSB", + Mode::Lsb => "PKTLSB", + Mode::Fm => "PKTFM", + Mode::Am => "PKTAM", + _ => self.hamlib_token(), + } + } else { + self.hamlib_token() + } + } + + /// Split a Hamlib mode token into its base [`Mode`] and DATA sub-mode flag. + /// + /// A `PKT*` token (WSJT-X sends `PKTUSB` for FT8/WSPR) decomposes into the underlying + /// base mode plus `data = true`; every other token is a plain mode with `data = false`, + /// so selecting any non-data mode also clears the radio's DATA flag. + pub(crate) fn decompose_hamlib_token(token: &str) -> (Mode, bool) { + let trimmed = token.trim(); + match trimmed.strip_prefix("PKT") { + Some(base) => (Mode::from_hamlib_token(base), true), + None => (Mode::from_hamlib_token(trimmed), false), + } + } +} + +/// Which transmit audio path a PTT key request selects. +/// +/// This mirrors Hamlib's `RIG_PTT_ON*` family and the Kenwood `TX`/`TX0`/`TX1` +/// commands. Digital-mode clients such as WSJT-X request [`PttSource::Data`] +/// (`T 3` / `RIG_PTT_ON_DATA`) so the radio modulates from the DATA/USB audio +/// input rather than the microphone, and so the TS-590 does not emit the +/// data-confirmation beep produced by a bare `TX;`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(crate) enum PttSource { + /// Generic PTT (Hamlib `RIG_PTT_ON`, Kenwood `TX;`). + #[default] + Generic, + /// Microphone audio path (Hamlib `RIG_PTT_ON_MIC`, Kenwood `TX0;`). + Mic, + /// Data/USB audio path (Hamlib `RIG_PTT_ON_DATA`, Kenwood `TX1;`). + Data, +} + +/// A single normalized change to apply to the radio (a write intent). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[allow(clippy::enum_variant_names)] // The `Set` prefix names the write intent uniformly. +pub(crate) enum StateMutation { + /// Set a VFO's frequency in Hz. + SetVfoFreq { + /// Target VFO. + vfo: Vfo, + /// Frequency in Hz. + hz: u64, + }, + /// Set a VFO's mode. + SetMode { + /// Target VFO. + vfo: Vfo, + /// Mode to set. + mode: Mode, + }, + /// Enable or disable the DATA sub-mode flag (TS-590 `DA`), independent of the base mode. + SetDataMode { + /// Target VFO. + vfo: Vfo, + /// Whether the DATA sub-mode is on. + on: bool, + }, + /// Enable or disable split, optionally choosing the TX VFO. + SetSplit { + /// Whether split is enabled. + enabled: bool, + /// The transmit VFO when split is enabled. + tx_vfo: Option, + }, + /// Key or unkey the transmitter. + SetPtt { + /// Whether the transmitter should be keyed. + keyed: bool, + /// The transmit audio path to select when keying (ignored when unkeying). + source: PttSource, + }, + /// Set the RIT offset in Hz (a zero offset disables RIT). + SetRit { + /// Offset in Hz. + offset_hz: i32, + /// Whether RIT is enabled. + enabled: bool, + }, + /// Set the XIT offset in Hz (a zero offset disables XIT). + SetXit { + /// Offset in Hz. + offset_hz: i32, + /// Whether XIT is enabled. + enabled: bool, + }, +} + +impl StateMutation { + /// The observable [`StateChange`] this mutation produces once applied. + pub(crate) fn into_change(self) -> StateChange { + match self { + StateMutation::SetVfoFreq { vfo, hz } => StateChange::Freq { vfo, hz }, + StateMutation::SetMode { vfo, mode } => StateChange::Mode { vfo, mode }, + StateMutation::SetDataMode { vfo, on } => StateChange::DataMode { vfo, on }, + StateMutation::SetSplit { enabled, tx_vfo } => StateChange::Split { enabled, tx_vfo }, + StateMutation::SetPtt { keyed, .. } => StateChange::Ptt { keyed }, + StateMutation::SetRit { offset_hz, enabled } => StateChange::Rit { enabled, offset_hz }, + StateMutation::SetXit { offset_hz, enabled } => StateChange::Xit { enabled, offset_hz }, + } + } +} + +/// A single observed change to the universal state, broadcast to faces for AI +/// fan-out and recorded into the [`Snapshot`](crate::state::Snapshot). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum StateChange { + /// A VFO frequency changed. + Freq { + /// Affected VFO. + vfo: Vfo, + /// New frequency in Hz. + hz: u64, + }, + /// A VFO mode changed. + Mode { + /// Affected VFO. + vfo: Vfo, + /// New mode. + mode: Mode, + }, + /// A VFO's DATA sub-mode flag changed (TS-590 `DA`). + DataMode { + /// Affected VFO. + vfo: Vfo, + /// Whether the DATA sub-mode is on. + on: bool, + }, + /// Split state changed. + Split { + /// Whether split is enabled. + enabled: bool, + /// The transmit VFO when split is enabled. + tx_vfo: Option, + }, + /// PTT (transmit) state changed. + Ptt { + /// Whether the transmitter is keyed. + keyed: bool, + }, + /// RIT state changed. + Rit { + /// Whether RIT is enabled. + enabled: bool, + /// Offset in Hz. + offset_hz: i32, + }, + /// XIT state changed. + Xit { + /// Whether XIT is enabled. + enabled: bool, + /// Offset in Hz. + offset_hz: i32, + }, +} + +impl StateChange { + /// The coverage [`Field`] this change updates. + pub(crate) fn field(&self) -> Field { + match *self { + StateChange::Freq { vfo, .. } => Field::Freq(vfo), + // The DATA flag is part of the composed mode, so it shares the Mode coverage key. + StateChange::Mode { vfo, .. } | StateChange::DataMode { vfo, .. } => Field::Mode(vfo), + StateChange::Split { .. } => Field::Split, + StateChange::Ptt { .. } => Field::Ptt, + StateChange::Rit { .. } => Field::Rit, + StateChange::Xit { .. } => Field::Xit, + } + } +} + +/// A coverage key identifying one observable field, used to decide whether the +/// radio's native push stream covers a field (so the baseline poller can back off). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) enum Field { + /// A VFO frequency. + Freq(Vfo), + /// A VFO mode. + Mode(Vfo), + /// Split state. + Split, + /// PTT state. + Ptt, + /// RIT state. + Rit, + /// XIT state. + Xit, + /// S-meter reading. Reserved for backends that surface signal strength. + #[allow(dead_code)] + SMeter, + /// Output power. Reserved for backends that surface power. + #[allow(dead_code)] + Power, +} + +/// Where a state change originated, so the poller can distinguish radio-driven +/// native push (which warrants backing off) from poll-derived diffs. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RadioEventSource { + /// An unsolicited frame the radio pushed on its own (auto-information). + NativePush, + /// A difference observed during a baseline poll cycle. + PollDiff, + /// An optimistic write reflected immediately after the backend acknowledged it. + OptimisticWrite, + /// A value confirmed by a verifying read-back. Reserved for verify-after-write. + #[allow(dead_code)] + VerifyRead, +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)] +mod tests { + use super::*; + + #[test] + fn kenwood_mode_digits_round_trip() { + for mode in [ + Mode::Lsb, + Mode::Usb, + Mode::Cw, + Mode::Fm, + Mode::Am, + Mode::Fsk, + Mode::CwR, + Mode::FskR, + ] { + assert_eq!(Mode::from_kenwood_digit(mode.to_kenwood_digit()), mode); + } + } + + #[test] + fn unknown_kenwood_digit_maps_to_unknown_and_back_to_usb() { + assert_eq!(Mode::from_kenwood_digit(b'0'), Mode::Unknown); + assert_eq!(Mode::Unknown.to_kenwood_digit(), b'2'); + } + + #[test] + fn hamlib_tokens_round_trip() { + for mode in [ + Mode::Lsb, + Mode::Usb, + Mode::Cw, + Mode::CwR, + Mode::Fsk, + Mode::FskR, + Mode::Am, + Mode::Fm, + ] { + assert_eq!(Mode::from_hamlib_token(mode.hamlib_token()), mode); + } + assert_eq!(Mode::from_hamlib_token("WAT"), Mode::Unknown); + } + + #[test] + fn hamlib_data_tokens_compose_and_decompose() { + // Base modes with a canonical PKT token fold in the DATA flag. + assert_eq!(Mode::Usb.hamlib_token_with_data(true), "PKTUSB"); + assert_eq!(Mode::Lsb.hamlib_token_with_data(true), "PKTLSB"); + assert_eq!(Mode::Fm.hamlib_token_with_data(true), "PKTFM"); + assert_eq!(Mode::Am.hamlib_token_with_data(true), "PKTAM"); + // DATA off (or a base with no PKT token) renders the plain token. + assert_eq!(Mode::Usb.hamlib_token_with_data(false), "USB"); + assert_eq!(Mode::Cw.hamlib_token_with_data(true), "CW"); + + // WSJT-X sends PKTUSB for FT8/WSPR; it must split into USB + DATA on. + assert_eq!(Mode::decompose_hamlib_token("PKTUSB"), (Mode::Usb, true)); + assert_eq!(Mode::decompose_hamlib_token("PKTLSB"), (Mode::Lsb, true)); + // Plain tokens decompose to the base mode with DATA cleared. + assert_eq!(Mode::decompose_hamlib_token("USB"), (Mode::Usb, false)); + assert_eq!(Mode::decompose_hamlib_token("CW"), (Mode::Cw, false)); + + // Compose/decompose round-trips for the data-capable base modes. + for mode in [Mode::Usb, Mode::Lsb, Mode::Fm, Mode::Am] { + let token = mode.hamlib_token_with_data(true); + assert_eq!(Mode::decompose_hamlib_token(token), (mode, true)); + } + } + + #[test] + fn data_mode_mutation_into_change_round_trips() { + assert_eq!( + StateMutation::SetDataMode { + vfo: Vfo::A, + on: true, + } + .into_change(), + StateChange::DataMode { + vfo: Vfo::A, + on: true, + } + ); + // DataMode shares the Mode coverage key so native MD/DA pushes can't fight. + assert_eq!( + StateChange::DataMode { + vfo: Vfo::B, + on: false, + } + .field(), + Field::Mode(Vfo::B) + ); + } + + #[test] + fn vfo_display_renders_letter() { + assert_eq!(Vfo::A.to_string(), "A"); + assert_eq!(Vfo::B.to_string(), "B"); + } + + #[test] + fn mutation_into_change_maps_each_variant() { + assert_eq!( + StateMutation::SetVfoFreq { + vfo: Vfo::A, + hz: 7_000_000 + } + .into_change(), + StateChange::Freq { + vfo: Vfo::A, + hz: 7_000_000 + } + ); + assert_eq!( + StateMutation::SetPtt { + keyed: true, + source: PttSource::Generic, + } + .into_change(), + StateChange::Ptt { keyed: true } + ); + assert_eq!( + StateMutation::SetRit { + offset_hz: 50, + enabled: true + } + .into_change(), + StateChange::Rit { + enabled: true, + offset_hz: 50 + } + ); + } + + #[test] + fn change_field_keys_are_stable() { + assert_eq!( + StateChange::Freq { vfo: Vfo::B, hz: 1 }.field(), + Field::Freq(Vfo::B) + ); + assert_eq!( + StateChange::Xit { + enabled: false, + offset_hz: 0 + } + .field(), + Field::Xit + ); + } +} diff --git a/crates/cathub/src/permissions.rs b/crates/cathub/src/permissions.rs new file mode 100644 index 0000000..368a564 --- /dev/null +++ b/crates/cathub/src/permissions.rs @@ -0,0 +1,122 @@ +//! Per-face capability sets and command classification. +//! +//! The coarse face flags (`read`, `write`, `ptt`, `config_write`) gate the command +//! classes a dialect assigns to each inbound command. Unknown passthrough writes default +//! to denied unless the face opts into unsafe full control. + +/// How a dialect classifies a single inbound command. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CommandClass { + /// A modeled status read (served from the cache). + ModeledRead, + /// A modeled write (frequency, mode, split). + ModeledWrite, + /// A passthrough read (raw native query). + PassthroughRead, + /// A PTT / TX-affecting write. + PttWrite, + /// A persistent/config write (`EX` menu and similar). + ConfigWrite, + /// An auto-information toggle (virtualized; never reaches the radio). Reserved for + /// dialects that route AI toggles through classification. + #[allow(dead_code)] + AutoInfoToggle, + /// Denied or unknown. Reserved for dialects that classify unknown writes as denied. + #[allow(dead_code)] + Denied, +} + +/// The capability set for one face or Hamlib listener. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[allow(clippy::struct_excessive_bools)] // Each flag is an independent face capability. +pub(crate) struct FacePermissions { + /// May read modeled state. + pub(crate) read: bool, + /// May issue modeled writes (frequency, mode, split). + pub(crate) write: bool, + /// May key PTT. + pub(crate) ptt: bool, + /// May issue persistent/config writes (`EX` menu). + pub(crate) config_write: bool, +} + +impl FacePermissions { + /// A read-only face. + #[cfg(test)] + pub(crate) fn read_only() -> Self { + FacePermissions { + read: true, + write: false, + ptt: false, + config_write: false, + } + } + + /// Parse a permission list from config tokens. + pub(crate) fn from_tokens>(tokens: &[S]) -> Self { + let mut perms = FacePermissions { + read: false, + write: false, + ptt: false, + config_write: false, + }; + for token in tokens { + match token.as_ref() { + "read" => perms.read = true, + "write" => perms.write = true, + "ptt" => perms.ptt = true, + "config_write" => perms.config_write = true, + _ => {} + } + } + perms + } + + /// Whether this face is permitted to run a command of the given class. + pub(crate) fn allows(self, class: CommandClass) -> bool { + match class { + CommandClass::ModeledRead | CommandClass::PassthroughRead => self.read, + CommandClass::ModeledWrite => self.write, + CommandClass::PttWrite => self.ptt, + CommandClass::ConfigWrite => self.config_write, + // Auto-info toggles are always allowed: they are virtualized per face and + // never touch the radio. + CommandClass::AutoInfoToggle => true, + CommandClass::Denied => false, + } + } +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)] +mod tests { + use super::*; + + #[test] + fn tokens_parse_into_flags() { + let perms = FacePermissions::from_tokens(&["read", "write", "ptt"]); + assert!(perms.read && perms.write && perms.ptt); + assert!(!perms.config_write); + } + + #[test] + fn read_only_denies_writes_and_ptt() { + let perms = FacePermissions::read_only(); + assert!(perms.allows(CommandClass::ModeledRead)); + assert!(!perms.allows(CommandClass::ModeledWrite)); + assert!(!perms.allows(CommandClass::PttWrite)); + assert!(!perms.allows(CommandClass::ConfigWrite)); + } + + #[test] + fn auto_info_toggle_always_allowed() { + let perms = FacePermissions::read_only(); + assert!(perms.allows(CommandClass::AutoInfoToggle)); + } + + #[test] + fn config_write_requires_flag() { + let perms = FacePermissions::from_tokens(&["read", "write", "ptt", "config_write"]); + assert!(perms.allows(CommandClass::ConfigWrite)); + } +} diff --git a/crates/cathub/src/ptt.rs b/crates/cathub/src/ptt.rs new file mode 100644 index 0000000..b4a4e65 --- /dev/null +++ b/crates/cathub/src/ptt.rs @@ -0,0 +1,155 @@ +//! PTT ownership and arbitration: a single-owner lease with a maximum-transmit safety +//! ceiling (§8.5). Contention is made safe rather than supporting simultaneous transmit. + +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; +use std::time::{Duration, Instant}; + +/// Why a PTT key request was refused. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PttDenied { + /// Another face currently holds the lease. + Busy, + /// The face lacks the `ptt` capability. + NotPermitted, +} + +struct Inner { + owner: Option, + keyed_at: Option, + max_tx: Duration, +} + +/// Arbitrates the single physical transmitter across PTT-capable faces. +#[derive(Clone)] +pub(crate) struct PttManager { + inner: Arc>, +} + +impl PttManager { + /// Create a manager with the given maximum-transmit safety ceiling. + pub(crate) fn new(max_tx: Duration) -> Self { + PttManager { + inner: Arc::new(Mutex::new(Inner { + owner: None, + keyed_at: None, + max_tx, + })), + } + } + + fn lock(&self) -> MutexGuard<'_, Inner> { + self.inner.lock().unwrap_or_else(PoisonError::into_inner) + } + + /// Attempt to acquire (or re-assert) the lease for `face`. + /// + /// The first capable face to key acquires the lease; while it is held, other faces + /// are refused. `has_ptt` reflects the face's `ptt` capability. + pub(crate) fn try_key(&self, face: u64, has_ptt: bool) -> Result<(), PttDenied> { + if !has_ptt { + return Err(PttDenied::NotPermitted); + } + let mut guard = self.lock(); + match guard.owner { + Some(owner) if owner != face => Err(PttDenied::Busy), + _ => { + guard.owner = Some(face); + guard.keyed_at = Some(Instant::now()); + Ok(()) + } + } + } + + /// Release the lease if `face` owns it. + pub(crate) fn unkey(&self, face: u64) { + let mut guard = self.lock(); + if guard.owner == Some(face) { + guard.owner = None; + guard.keyed_at = None; + } + } + + /// The current PTT owner, if any. + pub(crate) fn owner(&self) -> Option { + self.lock().owner + } + + /// The current owner iff the maximum-transmit ceiling has elapsed, **without** releasing + /// the lease. + /// + /// The lease is intentionally held until the caller has actually unkeyed the radio (send + /// `RX;` first, then [`unkey`](Self::unkey)). Releasing here would open a window in which + /// another face could acquire PTT and then be unkeyed by the caller's delayed `RX;`. + /// This is a hard transmit-length ceiling, not a CAT-idle timer: a keyed-but-silent + /// client (WSJT-X mid-over) is not reported until the ceiling. + pub(crate) fn expired_owner(&self) -> Option { + let guard = self.lock(); + if guard.keyed_at.is_some_and(|t| t.elapsed() >= guard.max_tx) { + guard.owner + } else { + None + } + } +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)] +mod tests { + use super::*; + + #[test] + fn first_capable_face_acquires_lease() { + let ptt = PttManager::new(Duration::from_secs(300)); + assert_eq!(ptt.try_key(1, true), Ok(())); + assert_eq!(ptt.owner(), Some(1)); + } + + #[test] + fn second_face_rejected_while_held() { + let ptt = PttManager::new(Duration::from_secs(300)); + assert_eq!(ptt.try_key(1, true), Ok(())); + assert_eq!(ptt.try_key(2, true), Err(PttDenied::Busy)); + } + + #[test] + fn owner_can_reassert_without_error() { + let ptt = PttManager::new(Duration::from_secs(300)); + assert_eq!(ptt.try_key(1, true), Ok(())); + assert_eq!(ptt.try_key(1, true), Ok(())); + } + + #[test] + fn lease_releases_on_unkey() { + let ptt = PttManager::new(Duration::from_secs(300)); + ptt.try_key(1, true).expect("key"); + ptt.unkey(1); + assert_eq!(ptt.owner(), None); + assert_eq!(ptt.try_key(2, true), Ok(())); + } + + #[test] + fn face_without_capability_is_not_permitted() { + let ptt = PttManager::new(Duration::from_secs(300)); + assert_eq!(ptt.try_key(1, false), Err(PttDenied::NotPermitted)); + assert_eq!(ptt.owner(), None); + } + + #[test] + fn safety_ceiling_releases_a_stuck_transmitter() { + let ptt = PttManager::new(Duration::from_millis(0)); + ptt.try_key(1, true).expect("key"); + // The ceiling reports the owner but holds the lease until the caller unkeys. + assert_eq!(ptt.expired_owner(), Some(1)); + assert_eq!(ptt.owner(), Some(1)); + ptt.unkey(1); + assert_eq!(ptt.owner(), None); + } + + #[test] + fn safety_ceiling_does_not_release_before_expiry() { + let ptt = PttManager::new(Duration::from_secs(300)); + ptt.try_key(1, true).expect("key"); + assert_eq!(ptt.expired_owner(), None); + assert_eq!(ptt.owner(), Some(1)); + } +} diff --git a/crates/cathub/src/radio/mod.rs b/crates/cathub/src/radio/mod.rs new file mode 100644 index 0000000..15d485f --- /dev/null +++ b/crates/cathub/src/radio/mod.rs @@ -0,0 +1,453 @@ +//! The single-owner radio task: transport framing + reply matching, and the per-face +//! priority scheduler that serializes every backend operation. +//! +//! Two cooperating layers: +//! * [`run_transport`] owns the byte transport. It writes one command at a time, matches +//! solicited replies by verb, and routes every unsolicited frame to the backend's +//! `parse_event` (native push). It exposes [`RadioLink`]. +//! * [`spawn_scheduler`] owns per-face FIFO queues and selects the next backend +//! operation by priority across the ready heads, never reordering one face's stream. + +use std::collections::VecDeque; +use std::sync::Arc; +use std::time::Duration; + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::sync::{mpsc, oneshot}; + +use crate::backend::{BackendError, Framing, RadioBackend}; +use crate::model::{RadioEventSource, StateMutation}; +use crate::state::StateHandle; + +/// Default per-command reply timeout. +const REPLY_TIMEOUT: Duration = Duration::from_millis(1_000); + +/// Priority class for scheduling across faces. Lower discriminant wins. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) enum Priority { + /// PTT (keying) — always preempts everything else at selection time. + Ptt = 0, + /// Interactive client writes (frequency, mode, split). + Write = 1, + /// Client reads and passthrough. + Read = 2, + /// Background baseline poll. + Poll = 3, +} + +/// What the transport should expect after writing a command. +#[derive(Debug, Clone)] +pub(crate) enum Expect { + /// A reply whose frame begins with one of these verb prefixes (Kenwood/Yaesu, where + /// unsolicited push frames can interleave and must be told apart by verb). + Reply(Vec>), + /// The next `n` frames, concatenated (verb-less line protocols like `rigctld`, which + /// have no unsolicited frames so the next lines are unambiguously the reply). + Lines(usize), + /// No reply (set-and-forget write). + NoReply, +} + +/// How a pending command recognizes its reply. +enum Matcher { + Verb(Vec>), + Lines { remaining: usize, acc: Vec }, +} + +/// The reply channel a queued command is answered on. +type ReplyTx = oneshot::Sender, BackendError>>; + +/// A raw command queued to the transport task. +pub(crate) struct RawCommand { + bytes: Vec, + expect: Expect, + reply: ReplyTx, +} + +/// A clonable handle backends use to submit raw bytes to the serialized transport. +#[derive(Clone)] +pub(crate) struct RadioLink { + tx: mpsc::Sender, +} + +impl RadioLink { + /// Submit raw bytes and await the matching reply (or an empty vec for `NoReply`). + pub(crate) async fn submit( + &self, + bytes: Vec, + expect: Expect, + ) -> Result, BackendError> { + let (reply, rx) = oneshot::channel(); + self.tx + .send(RawCommand { + bytes, + expect, + reply, + }) + .await + .map_err(|_| BackendError::Transport("transport task gone".into()))?; + rx.await + .map_err(|_| BackendError::Transport("transport dropped reply".into()))? + } +} + +/// Create a transport command channel and its [`RadioLink`]. +pub(crate) fn link_channel() -> (RadioLink, mpsc::Receiver) { + let (tx, rx) = mpsc::channel(64); + (RadioLink { tx }, rx) +} + +/// A [`RadioLink`] whose transport is never spawned (loopback backend never submits). +#[cfg(test)] +pub(crate) fn detached_link() -> RadioLink { + let (link, _rx) = link_channel(); + link +} + +/// Split a byte stream into frames according to `framing`. +struct Framer { + framing: Framing, + buf: Vec, +} + +impl Framer { + fn new(framing: Framing) -> Self { + Framer { + framing, + buf: Vec::with_capacity(64), + } + } + + fn delimiter(&self) -> u8 { + match self.framing { + Framing::SemicolonTerminated => b';', + Framing::LineTerminated => b'\n', + Framing::CiV => 0xFD, + } + } + + /// Feed bytes; return any complete frames (delimiter included). + fn push(&mut self, bytes: &[u8]) -> Vec> { + let delim = self.delimiter(); + let mut frames = Vec::new(); + for &b in bytes { + self.buf.push(b); + if b == delim { + frames.push(std::mem::take(&mut self.buf)); + } + } + frames + } +} + +fn frame_matches(frame: &[u8], verbs: &[Vec]) -> bool { + verbs.iter().any(|v| frame.starts_with(v)) +} + +/// Run the transport task to completion (until the stream closes or errors). +/// +/// `transport` is any duplex byte stream (a serial port, a TCP socket, or an in-memory +/// duplex in tests). Reply matching keeps exactly one solicited command in flight. +#[allow(clippy::too_many_lines)] // The transport read/write/match loop is one cohesive unit. +pub(crate) async fn run_transport( + transport: T, + backend: Arc, + state: StateHandle, + mut raw_rx: mpsc::Receiver, +) where + T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + 'static, +{ + let framing = backend.capabilities().framing; + let (mut reader, mut writer) = tokio::io::split(transport); + + // Reader task: frame the input and forward frames. + let (frame_tx, mut frame_rx) = mpsc::channel::>(256); + let reader_task = tokio::spawn(async move { + let mut framer = Framer::new(framing); + let mut chunk = [0u8; 512]; + loop { + match reader.read(&mut chunk).await { + Ok(0) | Err(_) => break, + Ok(n) => { + let slice = chunk.get(..n).unwrap_or(&[]); + for frame in framer.push(slice) { + if frame_tx.send(frame).await.is_err() { + return; + } + } + } + } + } + }); + + let mut pending: Option<(Matcher, ReplyTx)> = None; + + loop { + if pending.is_none() { + tokio::select! { + cmd = raw_rx.recv() => { + let Some(cmd) = cmd else { break }; + if let Err(e) = writer.write_all(&cmd.bytes).await { + let _ = cmd.reply.send(Err(BackendError::Transport(e.to_string()))); + break; + } + let _ = writer.flush().await; + tracing::trace!(tx = %String::from_utf8_lossy(&cmd.bytes), "radio tx"); + match cmd.expect { + Expect::NoReply => { + let _ = cmd.reply.send(Ok(Vec::new())); + } + Expect::Reply(verbs) => { + pending = Some((Matcher::Verb(verbs), cmd.reply)); + } + Expect::Lines(n) => { + if n == 0 { + let _ = cmd.reply.send(Ok(Vec::new())); + } else { + pending = Some(( + Matcher::Lines { remaining: n, acc: Vec::new() }, + cmd.reply, + )); + } + } + } + } + frame = frame_rx.recv() => { + let Some(frame) = frame else { break }; + tracing::trace!(rx = %String::from_utf8_lossy(&frame), "radio rx (idle)"); + route_event(&backend, &state, &frame); + } + } + } else { + let recv = tokio::time::timeout(REPLY_TIMEOUT, frame_rx.recv()).await; + match recv { + Err(_elapsed) => { + if let Some((matcher, reply)) = pending.take() { + if let Matcher::Verb(verbs) = &matcher { + let awaited: Vec = verbs + .iter() + .map(|v| String::from_utf8_lossy(v).into_owned()) + .collect(); + tracing::warn!(?awaited, "radio reply timed out"); + } else { + tracing::warn!("radio reply (lines) timed out"); + } + let _ = reply.send(Err(BackendError::Timeout)); + } + } + Ok(None) => break, + Ok(Some(frame)) => { + tracing::trace!(rx = %String::from_utf8_lossy(&frame), "radio rx (pending)"); + pending = match pending.take() { + Some((Matcher::Verb(verbs), reply)) => { + if frame_matches(&frame, &verbs) { + let _ = reply.send(Ok(frame)); + None + } else { + route_event(&backend, &state, &frame); + Some((Matcher::Verb(verbs), reply)) + } + } + Some((Matcher::Lines { remaining, mut acc }, reply)) => { + acc.extend_from_slice(&frame); + let left = remaining.saturating_sub(1); + if left == 0 { + let _ = reply.send(Ok(acc)); + None + } else { + Some(( + Matcher::Lines { + remaining: left, + acc, + }, + reply, + )) + } + } + None => None, + }; + } + } + } + } + + if let Some((_, reply)) = pending.take() { + let _ = reply.send(Err(BackendError::Transport("transport closed".into()))); + } + reader_task.abort(); +} + +/// Route an unsolicited frame into the universal state as a native push event. +/// +/// Modeled frames update the snapshot and broadcast a coalesced change. Frames the backend +/// does not model are relayed verbatim on the same ordered event bus so native pass-through +/// faces (which consume the CAT stream directly) keep features like the radio's noise +/// blanker and front-panel changes in sync. +fn route_event(backend: &Arc, state: &StateHandle, frame: &[u8]) { + if let Some(mutation) = backend.parse_event(frame) { + state.record(mutation.into_change(), RadioEventSource::NativePush); + } else { + tracing::trace!( + frame = %String::from_utf8_lossy(frame), + "relaying unmodeled unsolicited radio frame to native pass-through faces" + ); + state.record_raw(frame); + } +} + +// --- Operation scheduler ------------------------------------------------------------- + +/// The kind of backend operation a face requests. +pub(crate) enum OpKind { + /// Run one baseline poll cycle. + Poll, + /// Apply a modeled mutation. + Apply(StateMutation), + /// Forward a raw native command (passthrough), returning the raw reply. + Passthrough(Vec), +} + +struct Operation { + face: u64, + priority: Priority, + kind: OpKind, + reply: oneshot::Sender, BackendError>>, +} + +/// A clonable handle faces and the poller use to submit operations. +#[derive(Clone)] +pub(crate) struct RadioHandle { + tx: mpsc::Sender, +} + +impl RadioHandle { + /// Submit an operation tagged with the calling face's id and priority. + pub(crate) async fn submit( + &self, + face: u64, + priority: Priority, + kind: OpKind, + ) -> Result, BackendError> { + let (reply, rx) = oneshot::channel(); + self.tx + .send(Operation { + face, + priority, + kind, + reply, + }) + .await + .map_err(|_| BackendError::Transport("scheduler gone".into()))?; + rx.await + .map_err(|_| BackendError::Transport("scheduler dropped reply".into()))? + } +} + +/// Spawn the per-face priority scheduler. Returns a clonable [`RadioHandle`]. +pub(crate) fn spawn_scheduler( + backend: Arc, + link: RadioLink, + state: StateHandle, +) -> RadioHandle { + let (tx, mut rx) = mpsc::channel::(128); + tokio::spawn(async move { + let mut queues: Vec<(u64, VecDeque)> = Vec::new(); + loop { + if queues.iter().all(|(_, q)| q.is_empty()) { + match rx.recv().await { + Some(op) => enqueue(&mut queues, op), + None => break, + } + } + while let Ok(op) = rx.try_recv() { + enqueue(&mut queues, op); + } + let Some(op) = select_next(&mut queues) else { + continue; + }; + let result = execute(&backend, &link, &state, &op.kind).await; + let _ = op.reply.send(result); + } + }); + RadioHandle { tx } +} + +fn enqueue(queues: &mut Vec<(u64, VecDeque)>, op: Operation) { + if let Some((_, q)) = queues.iter_mut().find(|(id, _)| *id == op.face) { + q.push_back(op); + } else { + let mut q = VecDeque::new(); + let face = op.face; + q.push_back(op); + queues.push((face, q)); + } +} + +/// Pick the highest-priority ready head across all per-face queues (FIFO within a face). +fn select_next(queues: &mut [(u64, VecDeque)]) -> Option { + let mut best: Option = None; + let mut best_priority = Priority::Poll; + for (idx, (_, q)) in queues.iter().enumerate() { + if let Some(head) = q.front() { + if best.is_none() || head.priority < best_priority { + best = Some(idx); + best_priority = head.priority; + } + } + } + best.and_then(|idx| queues.get_mut(idx).and_then(|(_, q)| q.pop_front())) +} + +async fn execute( + backend: &Arc, + link: &RadioLink, + state: &StateHandle, + kind: &OpKind, +) -> Result, BackendError> { + match kind { + OpKind::Poll => backend.poll(link, state).await.map(|()| Vec::new()), + OpKind::Apply(m) => backend.apply(*m, link, state).await.map(|()| Vec::new()), + OpKind::Passthrough(bytes) => backend.passthrough(bytes, link).await, + } +} + +#[cfg(test)] +#[allow( + clippy::expect_used, + clippy::unwrap_used, + clippy::indexing_slicing, + clippy::similar_names +)] +mod tests { + use super::*; + + #[test] + fn framer_splits_on_semicolons() { + let mut framer = Framer::new(Framing::SemicolonTerminated); + let frames = framer.push(b"FA00007030000;MD3;"); + assert_eq!(frames.len(), 2); + assert_eq!(frames[0], b"FA00007030000;"); + assert_eq!(frames[1], b"MD3;"); + } + + #[test] + fn framer_holds_partial_frames() { + let mut framer = Framer::new(Framing::SemicolonTerminated); + assert!(framer.push(b"FA0000703").is_empty()); + let frames = framer.push(b"0000;"); + assert_eq!(frames, vec![b"FA00007030000;".to_vec()]); + } + + #[test] + fn verb_matching_uses_prefix() { + assert!(frame_matches(b"FA00007030000;", &[b"FA".to_vec()])); + assert!(!frame_matches(b"MD3;", &[b"FA".to_vec()])); + } + + #[test] + fn priority_orders_ptt_first() { + assert!(Priority::Ptt < Priority::Write); + assert!(Priority::Write < Priority::Read); + assert!(Priority::Read < Priority::Poll); + } +} diff --git a/crates/cathub/src/serial_face.rs b/crates/cathub/src/serial_face.rs new file mode 100644 index 0000000..398f388 --- /dev/null +++ b/crates/cathub/src/serial_face.rs @@ -0,0 +1,202 @@ +//! A generic client face: read delimited request frames, dispatch them to a +//! [`ClientDialect`], and write replies, while concurrently fanning out state-change +//! notifications the face has subscribed to (auto-information). +//! +//! `run_face` is transport-agnostic (`AsyncRead + AsyncWrite`): it serves a real serial +//! port, a TCP socket, or an in-memory duplex in tests. [`open_serial`] opens a real COM / +//! tty port for production wiring. + +use std::sync::Arc; + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::sync::broadcast::error::RecvError; + +use crate::dialect::{ClientDialect, FaceContext}; +use crate::state::RadioEvent; + +/// Serve one client connection until the transport closes. +/// +/// Inbound bytes are split on `delim` into request frames; each is handed to `dialect`. +/// Concurrently, every [`StateChange`](crate::model::StateChange) the face is subscribed to +/// is rendered by the dialect's notification formatter and written out (gated by the face's +/// virtualized auto-information flag inside the dialect). +pub(crate) async fn run_face( + transport: T, + dialect: Arc, + ctx: FaceContext, + delim: u8, +) where + T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + 'static, +{ + let (mut reader, mut writer) = tokio::io::split(transport); + let mut notifications = ctx.state.subscribe(); + let mut frame: Vec = Vec::with_capacity(64); + let mut chunk = [0u8; 512]; + + loop { + tokio::select! { + read = reader.read(&mut chunk) => { + match read { + Ok(0) | Err(_) => break, + Ok(n) => { + let slice = chunk.get(..n).unwrap_or(&[]); + for &byte in slice { + frame.push(byte); + if byte == delim { + let request = std::mem::take(&mut frame); + tracing::trace!( + face = ctx.face_id, + req = %String::from_utf8_lossy(&request), + "face request" + ); + let reply = dialect.handle(&request, &ctx).await; + tracing::trace!( + face = ctx.face_id, + reply = %String::from_utf8_lossy(&reply), + "face reply" + ); + if !reply.is_empty() && writer.write_all(&reply).await.is_err() { + return; + } + let _ = writer.flush().await; + } + } + } + } + } + change = notifications.recv() => { + match change { + Ok(event) => { + let bytes = match &event { + RadioEvent::Change(change) => { + dialect.format_notification(change, &ctx) + } + RadioEvent::Raw(raw) => dialect.format_passthrough(raw, &ctx), + }; + if let Some(bytes) = bytes { + tracing::trace!( + face = ctx.face_id, + note = %String::from_utf8_lossy(&bytes), + "face notify" + ); + if writer.write_all(&bytes).await.is_err() { + return; + } + let _ = writer.flush().await; + } + } + // A lagged subscriber simply skips missed frames; the next poll re-syncs. + Err(RecvError::Lagged(_)) => {} + Err(RecvError::Closed) => break, + } + } + } + } +} + +/// Open a real serial port for a serial face. +pub(crate) fn open_serial( + name: &str, + port: &str, + baud: u32, +) -> std::io::Result { + tracing::info!(face = name, port, baud, "opening serial face"); + serial2_tokio::SerialPort::open(port, baud) +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)] +mod tests { + use super::*; + use crate::backend::loopback::LoopbackBackend; + use crate::backend::RadioBackend; + use crate::dialect::kenwood::ts590::Ts590Dialect; + use crate::model::{RadioEventSource, StateChange, Vfo}; + use crate::permissions::FacePermissions; + use crate::ptt::PttManager; + use crate::radio::{detached_link, spawn_scheduler}; + use crate::state::StateHandle; + use std::time::Duration; + use tokio::io::DuplexStream; + + fn spawn_ts590_face(perms: FacePermissions) -> (DuplexStream, StateHandle) { + let backend = LoopbackBackend::new(); + let caps = backend.capabilities(); + let arc: Arc = Arc::new(backend); + let state = StateHandle::new(); + let radio = spawn_scheduler(arc, detached_link(), state.clone()); + let ptt = PttManager::new(Duration::from_secs(300)); + let ctx = FaceContext::new(1, perms, state.clone(), radio, ptt, caps); + let (client, server) = tokio::io::duplex(1024); + tokio::spawn(run_face( + server, + Arc::new(Ts590Dialect::new()) as Arc, + ctx, + b';', + )); + (client, state) + } + + async fn read_frame(client: &mut DuplexStream) -> Vec { + let mut frame = Vec::new(); + let mut byte = [0u8; 1]; + loop { + let n = tokio::time::timeout(Duration::from_secs(2), client.read(&mut byte)) + .await + .expect("timely reply") + .expect("read"); + if n == 0 { + break; + } + frame.push(byte[0]); + if byte[0] == b';' { + break; + } + } + frame + } + + #[tokio::test] + async fn serves_a_read_request() { + let (mut client, state) = spawn_ts590_face(FacePermissions::read_only()); + state.record( + StateChange::Freq { + vfo: Vfo::A, + hz: 7_030_000, + }, + RadioEventSource::PollDiff, + ); + client.write_all(b"FA;").await.expect("write"); + assert_eq!(read_frame(&mut client).await, b"FA00007030000;"); + } + + #[tokio::test] + async fn fans_out_notifications_to_subscribed_face() { + let (mut client, state) = spawn_ts590_face(FacePermissions::read_only()); + // Enable auto-info on the face. + client.write_all(b"AI2;").await.expect("write"); + tokio::time::sleep(Duration::from_millis(20)).await; + // A state change is pushed without the client polling. + state.record( + StateChange::Freq { + vfo: Vfo::A, + hz: 14_123_000, + }, + RadioEventSource::PollDiff, + ); + assert_eq!(read_frame(&mut client).await, b"FA00014123000;"); + } + + #[tokio::test] + async fn relays_unmodeled_radio_frame_to_subscribed_face() { + let (mut client, state) = spawn_ts590_face(FacePermissions::read_only()); + // Enable auto-info on the face. + client.write_all(b"AI2;").await.expect("write"); + tokio::time::sleep(Duration::from_millis(20)).await; + // The radio echoes a noise-blanker change the backend does not model; it must reach + // the client verbatim so its NB state machine advances (regression for the ARCP-590 + // NB cycle that stalled when these echoes were dropped). + state.record_raw(b"NB1;"); + assert_eq!(read_frame(&mut client).await, b"NB1;"); + } +} diff --git a/crates/cathub/src/state.rs b/crates/cathub/src/state.rs new file mode 100644 index 0000000..d375577 --- /dev/null +++ b/crates/cathub/src/state.rs @@ -0,0 +1,551 @@ +//! The universal radio state: a single in-memory snapshot every face reads from, plus a +//! broadcast channel of [`StateChange`] notifications faces subscribe to for auto-info +//! fan-out (design §8.3, §8.4). +//! +//! [`StateHandle`] is synchronous (a plain `Mutex`/`RwLock`): reads and writes are short, +//! uncontended critical sections, so there is no reason to make callers `await`. A change +//! is broadcast **only when a field's value actually changes**, so an idempotent write +//! (re-setting the same frequency) produces no spurious notification. + +use std::collections::HashSet; +use std::sync::{Arc, Mutex, PoisonError, RwLock}; + +use tokio::sync::broadcast; + +use crate::model::{Field, Mode, RadioEventSource, StateChange, StateMutation, Vfo}; + +/// Default IF passband reported for a VFO (Hz). Real backends may refine this; the default +/// matches the canonical Hamlib `get_mode` second line. +const DEFAULT_PASSBAND_HZ: u32 = 2_400; + +/// A point-in-time view of one VFO. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct VfoSnapshot { + /// Frequency in Hz. + pub(crate) freq_hz: u64, + /// Operating mode. + pub(crate) mode: Mode, + /// Whether the DATA sub-mode flag (TS-590 `DA`) is on for this VFO. + pub(crate) data: bool, + /// IF passband in Hz. + pub(crate) passband_hz: u32, +} + +impl Default for VfoSnapshot { + fn default() -> Self { + VfoSnapshot { + freq_hz: 0, + mode: Mode::Usb, + data: false, + passband_hz: DEFAULT_PASSBAND_HZ, + } + } +} + +/// A consistent point-in-time view of the whole radio. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[allow(clippy::struct_excessive_bools)] // Each flag mirrors an independent radio state. +pub(crate) struct Snapshot { + a: VfoSnapshot, + b: VfoSnapshot, + /// The receive VFO. + pub(crate) rx_vfo: Vfo, + /// The transmit VFO (relevant when split is enabled). + pub(crate) tx_vfo: Vfo, + /// Whether split is enabled. + pub(crate) split: bool, + /// Whether the transmitter is keyed. + pub(crate) ptt: bool, + /// Whether the radio reports power on. + pub(crate) power_on: bool, + /// Whether RIT is enabled. + pub(crate) rit_enabled: bool, + /// RIT offset in Hz. + pub(crate) rit_offset_hz: i32, + /// Whether XIT is enabled. + pub(crate) xit_enabled: bool, + /// XIT offset in Hz. + pub(crate) xit_offset_hz: i32, +} + +impl Default for Snapshot { + fn default() -> Self { + Snapshot { + a: VfoSnapshot::default(), + b: VfoSnapshot::default(), + rx_vfo: Vfo::A, + tx_vfo: Vfo::A, + split: false, + ptt: false, + // A TS-590 answers `\get_powerstat` with "1" at rest. + power_on: true, + rit_enabled: false, + rit_offset_hz: 0, + xit_enabled: false, + xit_offset_hz: 0, + } + } +} + +impl Snapshot { + /// The view of one VFO. + pub(crate) fn vfo(&self, vfo: Vfo) -> VfoSnapshot { + match vfo { + Vfo::A => self.a, + Vfo::B => self.b, + } + } + + /// Whether applying `mutation` would leave the radio unchanged from this snapshot. + /// + /// The hub forwards a modeled write to the wire only when it would actually change the + /// radio. Re-sending a value the radio already holds is not just wasted I/O: on the + /// TS-590 every redundant `MD`/`FA` set makes the radio emit its PC-control beep (a + /// Morse "U"), which is why clients like WSJT-X — that re-assert mode/frequency on every + /// poll — chirp the radio through the hub but not through a native Hamlib driver, which + /// caches state and never re-sends an unchanged value. Suppressing the no-op keeps the + /// hub as quiet on the wire as the native driver. PTT is never redundant: keying and + /// unkeying must always reach the radio and participate in the single-owner lease. + pub(crate) fn is_redundant(&self, mutation: &StateMutation) -> bool { + match *mutation { + StateMutation::SetVfoFreq { vfo, hz } => self.vfo(vfo).freq_hz == hz, + // Compare by the digit actually written to the radio, not the enum identity. + // WSJT-X asserts "PKTUSB", which decomposes into a base mode of USB (sent as MD2) + // plus a separate DATA flag handled by `SetDataMode` below. The TS-590 beeps on + // every mode set it receives (frequency sets are silent), so suppressing the no-op + // MD frame is what keeps it quiet, exactly like a native driver that never re-sends + // an unchanged mode. + StateMutation::SetMode { mode, .. } => { + self.vfo(self.rx_vfo).mode.to_kenwood_digit() == mode.to_kenwood_digit() + } + // The DATA flag (`DA`) is a separate wire fact from the base mode (`MD`): suppress + // a re-assert of the value the radio already holds so toggling, e.g., FT8↔WSPR + // (both PKTUSB) writes nothing after the first, and selecting a plain mode only + // emits `DA0` when DATA was actually on. + StateMutation::SetDataMode { vfo, on } => self.vfo(vfo).data == on, + StateMutation::SetSplit { enabled, tx_vfo } => { + self.split == enabled && self.tx_vfo == tx_vfo.unwrap_or(self.tx_vfo) + } + StateMutation::SetRit { enabled, offset_hz } => { + self.rit_enabled == enabled && self.rit_offset_hz == offset_hz + } + StateMutation::SetXit { enabled, offset_hz } => { + self.xit_enabled == enabled && self.xit_offset_hz == offset_hz + } + StateMutation::SetPtt { .. } => false, + } + } +} + +/// An ordered radio-output event delivered to faces for auto-information fan-out. +/// +/// Both modeled changes and unmodeled native frames travel on the **same** broadcast so +/// faces observe them in the order the radio produced them — important for native +/// pass-through clients that consume the CAT stream directly. +#[derive(Debug, Clone)] +pub(crate) enum RadioEvent { + /// A coalesced modeled state change (poll diff or native push). + Change(StateChange), + /// An unsolicited native frame the backend does not model, forwarded verbatim to + /// native pass-through faces so client-side feature state machines (for example + /// ARCP-590's NB on/NB1/NB2/off cycle) and front-panel changes stay in sync. + Raw(Arc<[u8]>), +} + +struct Inner { + snapshot: RwLock, + covered: Mutex>, + tx: broadcast::Sender, +} + +/// A clonable handle to the universal state. +#[derive(Clone)] +pub(crate) struct StateHandle { + inner: Arc, +} + +impl StateHandle { + /// Create an empty state at its defaults. + pub(crate) fn new() -> Self { + let (tx, _rx) = broadcast::channel(256); + StateHandle { + inner: Arc::new(Inner { + snapshot: RwLock::new(Snapshot::default()), + covered: Mutex::new(HashSet::new()), + tx, + }), + } + } + + /// Record an observed change. The change is applied to the snapshot and broadcast to + /// subscribers **only if it actually changes a value**. A [`RadioEventSource::NativePush`] + /// change additionally marks the field as natively covered so the poller can back off. + pub(crate) fn record(&self, change: StateChange, source: RadioEventSource) { + if source == RadioEventSource::NativePush { + self.inner + .covered + .lock() + .unwrap_or_else(PoisonError::into_inner) + .insert(change.field()); + } + let changed = { + let mut snap = self + .inner + .snapshot + .write() + .unwrap_or_else(PoisonError::into_inner); + apply_change(&mut snap, change) + }; + if changed { + // A send error only means there are no subscribers; that is fine. + let _ = self.inner.tx.send(RadioEvent::Change(change)); + } + } + + /// Broadcast an unsolicited native frame the backend does not model, so native + /// pass-through faces can forward it verbatim. This does not touch the snapshot or the + /// native-push coverage set; it is a transparent relay of the radio's CAT stream. + pub(crate) fn record_raw(&self, frame: &[u8]) { + let _ = self.inner.tx.send(RadioEvent::Raw(Arc::from(frame))); + } + + /// A consistent point-in-time view of the radio. + pub(crate) fn snapshot(&self) -> Snapshot { + *self + .inner + .snapshot + .read() + .unwrap_or_else(PoisonError::into_inner) + } + + /// Subscribe to the radio-output event stream (for a face's auto-info fan-out). + pub(crate) fn subscribe(&self) -> broadcast::Receiver { + self.inner.tx.subscribe() + } + + /// Whether the radio's native push stream has been observed to cover this field. + pub(crate) fn is_native_push_covered(&self, field: Field) -> bool { + self.inner + .covered + .lock() + .unwrap_or_else(PoisonError::into_inner) + .contains(&field) + } +} + +/// Apply a change to the snapshot, returning whether any value actually changed. +fn apply_change(snap: &mut Snapshot, change: StateChange) -> bool { + match change { + StateChange::Freq { vfo, hz } => { + let target = vfo_mut(snap, vfo); + if target.freq_hz == hz { + false + } else { + target.freq_hz = hz; + true + } + } + StateChange::Mode { vfo, mode } => { + let target = vfo_mut(snap, vfo); + if target.mode == mode { + false + } else { + target.mode = mode; + true + } + } + StateChange::DataMode { vfo, on } => { + let target = vfo_mut(snap, vfo); + if target.data == on { + false + } else { + target.data = on; + true + } + } + StateChange::Split { enabled, tx_vfo } => { + let new_tx = tx_vfo.unwrap_or(snap.tx_vfo); + if snap.split == enabled && snap.tx_vfo == new_tx { + false + } else { + snap.split = enabled; + snap.tx_vfo = new_tx; + true + } + } + StateChange::Ptt { keyed } => { + if snap.ptt == keyed { + false + } else { + snap.ptt = keyed; + true + } + } + StateChange::Rit { enabled, offset_hz } => { + if snap.rit_enabled == enabled && snap.rit_offset_hz == offset_hz { + false + } else { + snap.rit_enabled = enabled; + snap.rit_offset_hz = offset_hz; + true + } + } + StateChange::Xit { enabled, offset_hz } => { + if snap.xit_enabled == enabled && snap.xit_offset_hz == offset_hz { + false + } else { + snap.xit_enabled = enabled; + snap.xit_offset_hz = offset_hz; + true + } + } + } +} + +fn vfo_mut(snap: &mut Snapshot, vfo: Vfo) -> &mut VfoSnapshot { + match vfo { + Vfo::A => &mut snap.a, + Vfo::B => &mut snap.b, + } +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)] +mod tests { + use super::*; + use crate::model::PttSource; + + #[test] + fn defaults_are_sane() { + let snap = StateHandle::new().snapshot(); + assert!(snap.power_on); + assert_eq!(snap.vfo(Vfo::A).freq_hz, 0); + assert_eq!(snap.vfo(Vfo::A).mode, Mode::Usb); + assert_eq!(snap.vfo(Vfo::A).passband_hz, 2_400); + assert!(!snap.split && !snap.ptt); + } + + #[test] + fn record_updates_snapshot() { + let state = StateHandle::new(); + state.record( + StateChange::Freq { + vfo: Vfo::A, + hz: 7_030_000, + }, + RadioEventSource::PollDiff, + ); + assert_eq!(state.snapshot().vfo(Vfo::A).freq_hz, 7_030_000); + } + + #[test] + fn is_redundant_detects_unchanged_values() { + let state = StateHandle::new(); + state.record( + StateChange::Freq { + vfo: Vfo::A, + hz: 7_030_000, + }, + RadioEventSource::PollDiff, + ); + let snap = state.snapshot(); + + // Re-setting the value the radio already holds is redundant. + assert!(snap.is_redundant(&StateMutation::SetVfoFreq { + vfo: Vfo::A, + hz: 7_030_000, + })); + assert!(!snap.is_redundant(&StateMutation::SetVfoFreq { + vfo: Vfo::A, + hz: 7_040_000, + })); + + // Mode is tracked against the receive VFO (default A, USB). + assert!(snap.is_redundant(&StateMutation::SetMode { + vfo: Vfo::A, + mode: Mode::Usb, + })); + // An Unknown mode still writes the USB digit (MD2), so a SetMode(Unknown) on a + // USB VFO is the same frame the radio already holds and must be redundant. + assert!(snap.is_redundant(&StateMutation::SetMode { + vfo: Vfo::A, + mode: Mode::Unknown, + })); + assert!(!snap.is_redundant(&StateMutation::SetMode { + vfo: Vfo::A, + mode: Mode::Cw, + })); + } + + #[test] + fn is_redundant_and_round_trip_for_data_mode() { + let state = StateHandle::new(); + let snap = state.snapshot(); + // DATA defaults off, so SetDataMode { on: false } is a no-op and on: true is a change. + assert!(snap.is_redundant(&StateMutation::SetDataMode { + vfo: Vfo::A, + on: false, + })); + assert!(!snap.is_redundant(&StateMutation::SetDataMode { + vfo: Vfo::A, + on: true, + })); + + // Recording the change flips the snapshot and inverts which write is redundant. + state.record( + StateChange::DataMode { + vfo: Vfo::A, + on: true, + }, + RadioEventSource::OptimisticWrite, + ); + let snap = state.snapshot(); + assert!(snap.vfo(Vfo::A).data); + assert!(snap.is_redundant(&StateMutation::SetDataMode { + vfo: Vfo::A, + on: true, + })); + assert!(!snap.is_redundant(&StateMutation::SetDataMode { + vfo: Vfo::A, + on: false, + })); + } + + #[test] + fn is_redundant_covers_split_rit_xit() { + let snap = StateHandle::new().snapshot(); + // Defaults: split off, RIT/XIT off at zero offset. + assert!(snap.is_redundant(&StateMutation::SetSplit { + enabled: false, + tx_vfo: None, + })); + assert!(!snap.is_redundant(&StateMutation::SetSplit { + enabled: true, + tx_vfo: Some(Vfo::B), + })); + assert!(snap.is_redundant(&StateMutation::SetRit { + enabled: false, + offset_hz: 0, + })); + assert!(!snap.is_redundant(&StateMutation::SetRit { + enabled: true, + offset_hz: 100, + })); + assert!(snap.is_redundant(&StateMutation::SetXit { + enabled: false, + offset_hz: 0, + })); + assert!(!snap.is_redundant(&StateMutation::SetXit { + enabled: true, + offset_hz: -50, + })); + } + + #[test] + fn is_redundant_never_suppresses_ptt() { + let snap = StateHandle::new().snapshot(); + // PTT is off by default, but an unkey must still always reach the radio. + assert!(!snap.is_redundant(&StateMutation::SetPtt { + keyed: false, + source: PttSource::Generic, + })); + assert!(!snap.is_redundant(&StateMutation::SetPtt { + keyed: true, + source: PttSource::Generic, + })); + } + + #[tokio::test] + async fn change_broadcasts_only_on_actual_change() { + let state = StateHandle::new(); + let mut rx = state.subscribe(); + // First set: USB == default USB, so NO broadcast. + state.record( + StateChange::Mode { + vfo: Vfo::A, + mode: Mode::Usb, + }, + RadioEventSource::PollDiff, + ); + // A real change: CW differs from USB, so exactly one frame. + state.record( + StateChange::Mode { + vfo: Vfo::A, + mode: Mode::Cw, + }, + RadioEventSource::PollDiff, + ); + let got = rx.try_recv().expect("one change"); + assert!( + matches!( + got, + RadioEvent::Change(StateChange::Mode { + vfo: Vfo::A, + mode: Mode::Cw + }) + ), + "expected a single CW mode change, got {got:?}" + ); + assert!(rx.try_recv().is_err(), "no second frame for the no-op set"); + } + + #[tokio::test] + async fn record_raw_broadcasts_verbatim_without_touching_snapshot() { + let state = StateHandle::new(); + let mut rx = state.subscribe(); + state.record_raw(b"NB1;"); + let evt = rx.try_recv().expect("one raw event"); + assert!( + matches!(&evt, RadioEvent::Raw(bytes) if &**bytes == b"NB1;"), + "expected RadioEvent::Raw(NB1;), got {evt:?}" + ); + // A raw relay must not mark native-push coverage. + assert!(!state.is_native_push_covered(Field::Freq(Vfo::A))); + } + + #[test] + fn native_push_marks_coverage_but_poll_diff_does_not() { + let state = StateHandle::new(); + assert!(!state.is_native_push_covered(Field::Freq(Vfo::A))); + state.record( + StateChange::Freq { vfo: Vfo::A, hz: 1 }, + RadioEventSource::PollDiff, + ); + assert!(!state.is_native_push_covered(Field::Freq(Vfo::A))); + state.record( + StateChange::Freq { vfo: Vfo::A, hz: 2 }, + RadioEventSource::NativePush, + ); + assert!(state.is_native_push_covered(Field::Freq(Vfo::A))); + } + + #[test] + fn split_and_rit_xit_round_trip() { + let state = StateHandle::new(); + state.record( + StateChange::Split { + enabled: true, + tx_vfo: Some(Vfo::B), + }, + RadioEventSource::OptimisticWrite, + ); + state.record( + StateChange::Rit { + enabled: true, + offset_hz: 100, + }, + RadioEventSource::OptimisticWrite, + ); + state.record( + StateChange::Xit { + enabled: true, + offset_hz: -50, + }, + RadioEventSource::OptimisticWrite, + ); + let snap = state.snapshot(); + assert!(snap.split && snap.tx_vfo == Vfo::B); + assert!(snap.rit_enabled && snap.rit_offset_hz == 100); + assert!(snap.xit_enabled && snap.xit_offset_hz == -50); + } +} diff --git a/crates/cathub/tests/cli.rs b/crates/cathub/tests/cli.rs new file mode 100644 index 0000000..ec75a88 --- /dev/null +++ b/crates/cathub/tests/cli.rs @@ -0,0 +1,56 @@ +//! Public-surface integration tests for the cathub binary's library entry points. + +#![allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)] + +use std::path::PathBuf; + +use qsoripper_cathub::{run, Cli}; + +fn temp_config(contents: &str, tag: &str) -> PathBuf { + let path = std::env::temp_dir().join(format!("cathub-cli-{}-{}.toml", std::process::id(), tag)); + std::fs::write(&path, contents).expect("write temp config"); + path +} + +#[tokio::test] +async fn dry_run_accepts_a_valid_loopback_config() { + let path = temp_config( + "[radio]\nbackend = \"loopback\"\n\ + [[face]]\nname = \"n1mm\"\ntransport = \"COM11\"\ndialect = \"ts590\"\nperms = [\"read\", \"write\"]\n\ + [[hamlib_net]]\nname = \"engine\"\nbind = \"127.0.0.1:4532\"\nperms = [\"read\"]\n", + "valid", + ); + let cli = Cli { + config: Some(path.clone()), + log: None, + dry_run: true, + }; + assert!(run(cli).await.is_ok()); + let _ = std::fs::remove_file(&path); +} + +#[tokio::test] +async fn dry_run_rejects_an_invalid_backend() { + let path = temp_config( + "[radio]\nbackend = \"icom\"\n\ + [[face]]\nname = \"x\"\ntransport = \"COM5\"\ndialect = \"ts590\"\n", + "badbackend", + ); + let cli = Cli { + config: Some(path.clone()), + log: None, + dry_run: true, + }; + assert!(run(cli).await.is_err()); + let _ = std::fs::remove_file(&path); +} + +#[tokio::test] +async fn missing_config_is_an_error() { + let cli = Cli { + config: Some(PathBuf::from("definitely-missing-cathub-config.toml")), + log: None, + dry_run: true, + }; + assert!(run(cli).await.is_err()); +} diff --git a/crates/cathub/tests/fixtures/dump_state.txt b/crates/cathub/tests/fixtures/dump_state.txt new file mode 100644 index 0000000..887b8a1 --- /dev/null +++ b/crates/cathub/tests/fixtures/dump_state.txt @@ -0,0 +1,65 @@ +1 +1 +0 +150000.000000 1500000000.000000 0x1ff -1 -1 0x17e00007 0x1f +0 0 0 0 0 0 0 +150000.000000 1500000000.000000 0x1ff 5000 100000 0x17e00007 0x1f +0 0 0 0 0 0 0 +0x1ff 1 +0x1ff 0 +0 0 +0xc 2400 +0xc 1800 +0xc 3000 +0xc 0 +0x2 500 +0x2 2400 +0x2 50 +0x2 0 +0x10 300 +0x10 2400 +0x10 50 +0x10 0 +0x1 8000 +0x1 2400 +0x1 10000 +0x20 15000 +0x20 8000 +0x40 230000 +0 0 +9990 +9990 +10000 +0 +10 +10 20 30 +0xffffffffffffffff +0xffffffffffffffff +0xfffffffff7ffffff +0xfffeff7083ffffff +0xffffffffffffffff +0xffffffffffffffbf +vfo_ops=0x7ffffff +ptt_type=0x1 +targetable_vfo=0x10c3 +has_set_vfo=1 +has_get_vfo=1 +has_set_freq=1 +has_get_freq=1 +has_set_conf=1 +has_get_conf=1 +has_power2mW=1 +has_mW2power=1 +has_get_ant=1 +has_set_ant=1 +timeout=0 +rig_model=1 +rigctld_version=Hamlib 4.7.0 2026-02-15T21:14:25Z SHA=554e02b39 64-bit +agc_levels=0=OFF 1=SUPERFAST 2=FAST 5=MEDIUM 3=SLOW 6=AUTO 4=USER +ctcss_list= 67.0 69.3 71.9 74.4 77.0 79.7 82.5 85.4 88.5 91.5 94.8 97.4 100.0 103.5 107.2 110.9 114.8 118.8 123.0 127.3 131.8 136.5 141.3 146.2 151.4 156.7 159.8 162.2 165.5 167.9 171.3 173.8 177.3 179.9 183.5 186.2 189.9 192.8 196.6 199.5 203.5 206.5 210.7 218.1 225.7 229.1 233.6 241.8 250.3 254.1 +dcs_list= 17 23 25 26 31 32 36 43 47 50 51 53 54 65 71 72 73 74 114 115 116 122 125 131 132 134 143 145 152 155 156 162 165 172 174 205 212 223 225 226 243 244 245 246 251 252 255 261 263 265 266 271 274 306 311 315 325 331 332 343 346 351 356 364 365 371 411 412 413 423 431 432 445 446 452 454 455 462 464 465 466 503 506 516 523 526 532 546 565 606 612 624 627 631 632 654 662 664 703 712 723 731 732 734 743 754 +level_gran=0=0,0,0;1=0,0,0;2=0,0,0;3=0,0,0;4=0,1,0.00392157;5=0,0,0;6=0,0,0;7=0,0,0;8=0,0,0;9=0,0,0;10=0,0,0;11=0,0,10;12=0.05,1,0.00195695;13=0,0,0;14=0,0,0;15=0,0,0;16=0,0,0;17=0,0,0;18=0,0,0;19=0,0,0;20=0,0,0;21=0,0,0;22=0,0,0;23=0,0,0;24=0,0,0;25=0,0,0;26=0,0,0;27=0,0,0;28=0,0,0;29=0,0,0;30=0,0,0;31=0,0,0;32=0,1,0.00392157;33=0,0,0;34=0,0,0;35=0,0,0;36=0,0,0;37=0,0,0;38=0,0,0;39=0,100,0.00392157;40=0,0,0;41=0,0,0;42=0,0,0;43=0,0,0;44=0,2,1;45=-30,10,0.5;46=0,3,1;47=0,0,0;48=0,0,0;49=0,0,0;50=0,0,0;51=0,0,0;52=0,0,0;53=0,0,0;54=0,0,0;55=0,0,0;56=0,0,0;57=0,0,0;58=0,0,0;59=0,0,0;60=0,0,0;61=0,0,0;62=0,0,0;63=0,0,0; +parm_gran=0=0,0,0;1=0,0,0;2=0,1,0.00392157;3=0,0,0;4=0,1065353216,998277249;5=0,0,0;6=0,0,0;7=0,0,0;8=0,0,0;9=0,0,0;10=BANDUNUSED,BAND70CM,BAND33CM,BAND23CM;11=STRAIGHT,BUG,PADDLE;12=1028443341,1065353216,989872160;13=0,0,0;14=0,0,0;15=0,0,0;16=0,0,0;17=0,0,0;18=0,0,0;19=0,0,0;20=0,0,0;21=0,0,0;22=0,0,0;23=0,0,0;24=0,0,0;25=0,0,0;26=0,0,0;27=0,0,0;28=0,0,0;29=0,0,0;30=0,0,0;31=0,0,0;32=0,1065353216,998277249;33=0,0,0;34=0,0,0;35=0,0,0;36=0,0,0;37=0,0,0;38=0,0,0;39=0,1120403456,998277249;40=0,0,0;41=0,0,0;42=0,0,0;43=0,0,0;44=0,2,1;45=-1041235968,1092616192,1056964608;46=0,3,1;47=0,0,0;48=0,0,0;49=0,0,0;50=0,0,0;51=0,0,0;52=0,0,0;53=0,0,0;54=0,0,0;55=0,0,0;56=0,0,0;57=0,0,0;58=0,0,0;59=0,0,0;60=0,0,0;61=0,0,0;62=0,0,0;63=0,0,0; +rig_model=1 +hamlib_version=Hamlib 4.7.0 2026-02-15T21:14:25Z SHA=554e02b39 64-bit +done diff --git a/docs/integration/setup.md b/docs/integration/setup.md new file mode 100644 index 0000000..b681c09 --- /dev/null +++ b/docs/integration/setup.md @@ -0,0 +1,235 @@ +# qsoripper-cathub operator setup + +`qsoripper-cathub` is a single daemon that owns the radio's CAT serial port and fans it out +to every application over that application's native protocol. Because only the daemon talks +to the radio, the classic multi-app failures disappear: + +- no VFO A/B oscillation (baseline polling never emits VFO-select/retarget commands), +- no frequency drift (one serialized writer, ordered writes, native-push reconciliation), +- no PTT contention (single-owner PTT lease with a hard transmit-time ceiling), +- no auto-info stomping (each app's auto-information is virtualized per connection). + +This runbook brings up the hub for six applications sharing one Kenwood TS-590: +HDSDR (via OmniRig), QsoRipper TUI and GUI, ARCP-590, N1MM Logger+, WSJT-X, and Log4OM. + +See `docs/design/cathub-multi-client-cat-hub.md` for the architecture and behavior contracts. + +## 1. Retire the legacy chain + +The old stack was rigctld + a Python safe-bridge + rigctlcom. Stop all of it before starting +the hub. Only one process may own the radio's COM port (COM4 on this station — the +Silicon Labs CP210x USB-UART bridge that fronts the TS-590's USB CAT port). + + Get-Process rigctld, rigctlcom -ErrorAction SilentlyContinue | Stop-Process + # also stop any safe-bridge Python process and any app still bound directly to COM4 + +Confirm nothing else holds COM4 before continuing. + +## 2. Create virtual serial pairs (com0com) + +Serial clients connect through a virtual null-modem pair. The daemon binds the first port of +each pair; the application binds the second. Using the com0com "setupc" tool, create: + + install PortName=COM10 PortName=COM11 # HDSDR / OmniRig (daemon COM10, app COM11) + install PortName=COM20 PortName=COM21 # N1MM Logger+ (daemon COM20, app COM21) + install PortName=COM30 PortName=COM31 # ARCP-590 (daemon COM30, app COM31) + +WSJT-X, Log4OM, and the QsoRipper engine use the Hamlib NET (TCP) endpoints instead and need +no serial pair. + +Each pair has two COM numbers: a **daemon side** (the lower, even number COM10/20/30 that the +hub opens via `transport`) and an **application side** (the partner COM11/21/31). Point each +application at its application-side port. The daemon-side port is held open by the hub, so it +typically will **not** appear in an application's COM-port dropdown at all -- that is expected, +and the partner port (COM11/21/31) is the one to select. + +## 3. Configure the daemon + +The daemon settings live in the unified per-user `config.toml` shared with the engine and the +launcher, under a `[cat_hub]` table: + +- Windows: `%APPDATA%\qsoripper\config.toml` +- Linux/macOS: `$XDG_CONFIG_HOME/qsoripper/config.toml` (or `~/.config/qsoripper/config.toml`) +- Override the location for every component with the `QSORIPPER_CONFIG_PATH` environment variable. + +Settings nest under `[cat_hub]`, for example `[cat_hub.radio]`, `[cat_hub.poll]`, +`[cat_hub.ptt]`, `[cat_hub.events]`, `[[cat_hub.face]]`, and `[[cat_hub.hamlib_net]]`. The +engine and launcher own other top-level tables in the same file (`[station_profile]`, +`[launcher]`, `[rig_control]`, …); each component preserves the others' tables when it saves, +so the file is safe to share. + +For a standalone setup you can still keep a separate file (the repo ships +`config\cathub.toml` with top-level `[radio]` … tables) and point the daemon at it with +`-Config`. Validate either layout without touching hardware: + + .\scripts\Start-CatHub.ps1 -DryRun + +When `-Config` is omitted the script uses the unified `config.toml` if it contains a +`[cat_hub]` section, otherwise it falls back to the `config\cathub.toml` sample. The dry run +prints the resolved radio, poll, PTT, events, faces, and Hamlib NET endpoints. Adjust COM port +numbers and baud to match your com0com pairs and the TS-590's CAT baud, then re-run the dry run +until it is clean. + +The `[radio].baud` value **must match the radio's own PC/CAT port speed** (TS-590 menu 62; +e.g. 57600). If they differ, the daemon opens COM4 but cannot talk to the radio. The +`[[face]].baud` values are nominal only -- com0com virtual pairs pass data regardless of baud, +so a client app can use any baud on its side of the pair. + +## 4. Start the hub + + .\scripts\Start-CatHub.ps1 + +The daemon opens COM4, enables and owns the TS-590 `AI2;` native push stream, starts the +baseline poller (which backs off to heartbeat once push covers a field), opens each serial +face, and binds each Hamlib NET endpoint. Watch the log in another terminal: + + .\scripts\Get-CatHubLog.ps1 -Follow + +Stop the hub with Ctrl+C in its window, or: + + .\scripts\Stop-CatHub.ps1 + +### Cold-start workflow (build + launch everything) + +For a clean start after logon, build all artifacts and then launch the hub together with the +engines and UIs from one command: + + .\build.ps1 # publishes qsoripper-cathub alongside the engines/UIs + .\launcher.ps1 -WithCatHub # starts the CAT hub first, then the launcher TUI + +`-WithCatHub` brings the radio daemon up in its own window (reading the unified `config.toml`) +before the launcher starts engines and UIs, so everything connects to the hub's rigctld face. + +## 5. Point each application at the hub + +### HDSDR (via OmniRig) +- OmniRig: Rig type `Kenwood TS-2000`, port **COM11**, baud 115200, 8-N-1. +- The `hdsdr-omnirig` face is `dialect = "ts2000"`, `perms = ["read", "write"]`. The panadapter + follows the radio, and click-to-tune on the waterfall sets the radio frequency/mode + (`FA`/`FB`/`MD`). VFO-target writes (`FR`/`FT`) from the TS-2000 dialect are still rejected by + design, so HDSDR can tune but can never oscillate the TS-590's A/B VFO selection. + +### N1MM Logger+ +- Configurer > Hardware: radio `Kenwood`, port **COM21**, 115200, 8-N-1, no flow control. +- The `n1mm` face is `dialect = "ts590"`, `perms = ["read", "write", "ptt"]`. + +### ARCP-590 +- Set ARCP-590's COM port to **COM31**, 115200, 8-N-1. +- The `arcp590` face is `dialect = "ts590"`, `perms = ["read", "write", "ptt", "config_write"]` + so the full faceplate (including EX-menu writes) works. + +### WSJT-X +- Settings > Radio: Rig `Hamlib NET rigctl`, Network Server **127.0.0.1:4533**. +- PTT method `CAT`. The `wsjtx` endpoint is `perms = ["read", "write", "ptt"]`. +- **Mode:** set the WSJT-X *Mode* selector to **Data/Pkt** (the default for FT8/WSPR). The + hub maps Hamlib's `PKTUSB`/`PKTLSB` to the TS-590's DATA sub-mode by composing the base + mode (`MD`) with the radio's independent DATA flag (`DA`): `PKTUSB` -> `MD2;`+`DA1;`, + `PKTLSB` -> `MD1;`+`DA1;`, and a plain `USB`/`LSB` clears it with `DA0;`. The mode + read-back recomposes the token, so WSJT-X sees `PKTUSB`/`PKTLSB` echoed back and the radio + shows its DATA indicator lit. *Mode = None* (operator selects DATA on the front panel) and + *Mode = USB* also round-trip cleanly if you prefer to manage the sub-mode yourself. +- **Split Operation:** `Rig` or `Fake It` both work; the hub tracks split and TX-VFO + state and never retargets VFO A/B on a poll. +- **PTT:** WSJT-X keys with Hamlib `RIG_PTT_ON_DATA` (`T 3`). The hub maps the Hamlib PTT + family faithfully to the TS-590 — `T 1` -> `TX;`, `T 2` (mic) -> `TX0;`, `T 3` (data) -> + `TX1;`, `T 0` -> `RX;`. `TX1;` keys with modulation from the DATA/USB audio path, which is + what digital modes want. +- Frequencies are sent by Hamlib as a `%f` value (e.g. `14074000.000000`); the hub + accepts both that decimal form and a plain integer, so `Test CAT` and band changes + set the dial correctly. + +### TS-590 PC-control beep (fixed in the hub) + +Earlier builds made the TS-590 emit a short Morse **"U"** (di-di-dah) tone during WSJT-X +operation, most noticeably when switching between modes that share a radio mode (for example +FT8 and WSPR, which are both DATA-USB). The TS-590 beeps on **every** mode (`MD`) command it +receives over CAT — frequency sets are silent. WSJT-X re-asserts its mode on every poll and on +each FT8/WSPR switch, and the hub forwarded each `MD` set to the radio even when the value was +unchanged, so the radio chirped. A native Hamlib driver never beeps because it caches state and +sends a mode command only when the mode actually changes. + +The hub now does the same: a modeled write (frequency, mode, DATA sub-mode, split, RIT/XIT) +is sent to the radio only when it would change the radio. Mode comparison uses the **value +written to the radio** (the `MD` digit), and the DATA flag (`DA`) is deduped independently, so +switching between two modes that share the same wire state — for example FT8 and WSPR, which +are both `PKTUSB` (`MD2`+`DA1`) — is recognized as a no-op and suppressed after the first set. +A genuine mode change (for example switching to CW, or toggling DATA on/off) still sends one +command and the radio beeps once, which matches native Hamlib behavior. PTT is never +suppressed — keying and unkeying always reach the radio. + +No radio-menu change is needed. Leave **Beep Volume** at your normal setting. + +### Log4OM +- CAT interface: Hamlib `NET rigctl`, host **127.0.0.1**, port **4534**. +- The `log4om` endpoint is `perms = ["read", "write"]`. + +### QsoRipper engine (TUI and GUI) +- The engine's `RigctldProvider` points at the read-only endpoint **127.0.0.1:4532**. +- The TUI and GUI both consume the engine over gRPC; neither talks to the radio directly, so + both get a consistent view fed by the same hub. TCP allows the engine and any other NET + client to share an endpoint simultaneously. +- Keep `[rig_control].stale_threshold_ms` low (e.g. **200**) when reading through cathub. The hub + serves reads from its in-memory state cache (kept current by the radio's native AI2 push), so a + short freshness window is cheap and makes the GUI/TUI frequency display follow knob turns almost + immediately. A large value such as 5000 makes the engine reuse a stale snapshot for that many + milliseconds and the UI lags behind the radio by up to that interval. + +## 6. Verify (bench) + +With the hub running and all six apps connected: + +1. Turn the physical VFO knob. Every app's displayed frequency tracks it within a poll/push + cycle, and the cathub log shows `NativePush` (not `PollDiff`) events once `AI2;` is active. +2. Change band in N1MM. HDSDR's waterfall recenters without HDSDR ever talking to N1MM, and + the TS-590 never bounces between VFO A and B. +3. Set frequency from WSJT-X. N1MM, Log4OM, ARCP-590, and the engine all converge. +4. Key WSJT-X (Tune). The PTT lease is granted; while held, an attempt to key from N1MM is + rejected (Hamlib `RPRT` busy) and logged. Releasing WSJT-X frees the lease. +5. Leave one over running longer than expected: confirm a transmit never exceeds + `ptt_max_tx_ms` (the safety ceiling force-releases and logs loudly). + +Live transmit verification requires the operator and real hardware; do not key the +transmitter from automation. Watch `Get-CatHubLog.ps1 -Follow` throughout. + +## 7. Troubleshooting + +- "Access denied" / port busy on COM4: the legacy chain or another app still owns the radio + port (step 1). +- Daemon starts but never reads valid data from the radio (timeouts / stale): the `[radio].baud` + does not match the radio's PC/CAT port speed. Check TS-590 menu 62 and set `[radio].baud` to + the same value (e.g. 57600). A client app proves the rig's real baud quickly via a direct + connection. +- Daemon starts, the port opens, but every poll times out at the *correct* baud: some radios + (notably the Kenwood TS-590) only reply when the **RTS modem-control line is asserted**. The + hub now asserts RTS and DTR automatically when it opens a serial radio port, so this should + not occur with current builds. If you see it on an older build, update the daemon. +- An app's COM dropdown does not list the daemon-side port (COM10/20/30): expected. The hub + holds that port open, so applications only see the partner port. Select COM11/21/31 instead. +- An app sees no data on its serial port: the com0com pair is reversed or the app is on the + daemon's half of the pair. The app binds the **second** port of each pair (COM11/21/31). +- A NET client cannot connect: confirm the bind address/port in `config\cathub.toml` matches + the app, and that the hub log shows the endpoint listening. +- An app that relies on Kenwood auto-information (notably **ARCP-590**) connects but never + tracks the dial / shows "BUSY": such apps poll `AI;` as a keepalive and depend entirely on + the radio pushing `FA;`/`IF;` frames. The hub virtualizes auto-info per connection — an `AI;` + read reports the face's current state (`AI0;`/`AI2;`) without disabling it, and the hub fans + out native-push frames to any face that has enabled `AI2;`. This works in current builds; if + an older build froze ARCP-590 on connect, update the daemon. +- Set `CATHUB_LOG=debug` before starting for verbose tracing. Use + `CATHUB_LOG=qsoripper_cathub::serial_face=trace` to see each face's request/reply/notify + frames, which is the fastest way to diagnose a client handshake. + +## 8. Known v1 limitations + +- **No automatic radio reconnect yet.** If the radio transport drops mid-session (USB + unplugged, radio powered off), the daemon does not yet retry the serial link or serve a + `stale` flag (design §8.7). Restart the hub after restoring the radio. Client faces and + NET endpoints are unaffected by this and stay up. +- **Hamlib NET bind errors surface in the log, not at startup.** A serial face that fails to + open aborts startup with a clear error, but a `[[hamlib_net]]` endpoint whose bind address + is already in use logs the error from its listener task rather than failing the whole + daemon. If a NET client cannot connect, check the log for that endpoint. +- On shutdown (Ctrl+C) the daemon makes a best-effort `RX;` to unkey the transmitter. A hard + crash cannot run that path; the `ptt_max_tx_ms` ceiling and the radio's own TX timeout are + the ultimate stuck-transmitter backstops. + diff --git a/scripts/Get-CatHubLog.ps1 b/scripts/Get-CatHubLog.ps1 new file mode 100644 index 0000000..ebd7e96 --- /dev/null +++ b/scripts/Get-CatHubLog.ps1 @@ -0,0 +1,46 @@ +<# +.SYNOPSIS + Tail the latest qsoripper-cathub rolling log. + +.DESCRIPTION + The daemon writes a daily-rolling tracing log (qsoripper-cathub.log.YYYY-MM-DD) to the + user profile directory. This helper shows the most recent log file (optionally live). + +.PARAMETER Tail + Number of trailing lines to show. Default 80. + +.PARAMETER Follow + Follow the log live (like tail -f). + +.EXAMPLE + .\scripts\Get-CatHubLog.ps1 -Follow +#> +[CmdletBinding()] +param( + [int]$Tail = 80, + [switch]$Follow +) + +$ErrorActionPreference = 'Stop' +$logDir = $env:USERPROFILE + +if (-not (Test-Path $logDir)) { + Write-Host "No log directory: $logDir" -ForegroundColor Yellow + return +} + +$latest = Get-ChildItem -Path $logDir -Filter 'qsoripper-cathub.log*' -File -ErrorAction SilentlyContinue | + Sort-Object LastWriteTime -Descending | + Select-Object -First 1 + +if (-not $latest) { + Write-Host "No cathub log files in $logDir" -ForegroundColor Yellow + return +} + +Write-Host "Log: $($latest.FullName)" -ForegroundColor Cyan +if ($Follow) { + Get-Content -Path $latest.FullName -Tail $Tail -Wait +} else { + Get-Content -Path $latest.FullName -Tail $Tail +} diff --git a/scripts/Start-CatHub.ps1 b/scripts/Start-CatHub.ps1 new file mode 100644 index 0000000..4921702 --- /dev/null +++ b/scripts/Start-CatHub.ps1 @@ -0,0 +1,100 @@ +<# +.SYNOPSIS + Start the qsoripper-cathub multi-client CAT hub daemon. + +.DESCRIPTION + Builds (release) and launches the cathub daemon, which becomes the single owner of the + radio serial port and fans it out to every configured client (HDSDR/OmniRig, N1MM, + ARCP-590, WSJT-X, Log4OM, and the QsoRipper engine). + + This replaces the legacy rigctld + Python safe-bridge + rigctlcom chain. Do not run the + old chain at the same time: only one process may own the radio's COM port. + +.PARAMETER Config + Path to the cathub TOML config. When omitted, the unified per-user config.toml is used if + it contains a [cat_hub] section (the same config the engine and launcher share); otherwise + the standalone config\cathub.toml sample in the repo is used. + +.PARAMETER DryRun + Validate and print the config, then exit without opening any ports. + +.PARAMETER DebugBuild + Build and run the debug profile instead of release. + +.EXAMPLE + .\scripts\Start-CatHub.ps1 -DryRun + +.EXAMPLE + .\scripts\Start-CatHub.ps1 +#> +[CmdletBinding()] +param( + [string]$Config, + [switch]$DryRun, + [switch]$DebugBuild +) + +$ErrorActionPreference = 'Stop' +$repoRoot = Split-Path -Parent $PSScriptRoot +$manifest = Join-Path $repoRoot 'src\rust\Cargo.toml' + +function Get-UnifiedConfigPath { + if ($env:QSORIPPER_CONFIG_PATH) { + return $env:QSORIPPER_CONFIG_PATH + } + if ($IsWindows -or $env:OS -eq 'Windows_NT') { + if ($env:APPDATA) { + return (Join-Path $env:APPDATA 'qsoripper\config.toml') + } + return $null + } + if ($env:XDG_CONFIG_HOME) { + return (Join-Path $env:XDG_CONFIG_HOME 'qsoripper/config.toml') + } + if ($env:HOME) { + return (Join-Path $env:HOME '.config/qsoripper/config.toml') + } + return $null +} + +if (-not $Config) { + # Prefer the unified config.toml when it carries a [cat_hub] section, so cathub, the + # engine, and the launcher all read one file. Fall back to the repo sample otherwise. + $unified = Get-UnifiedConfigPath + if ($unified -and (Test-Path $unified) -and + (Select-String -Path $unified -Pattern '^\s*\[\[?cat_hub' -Quiet)) { + $Config = $unified + } + else { + $Config = Join-Path $repoRoot 'config\cathub.toml' + } +} +if (-not (Test-Path $Config)) { + throw "Config not found: $Config" +} + +$logDir = $env:USERPROFILE + +# Prefer the published binary for instant startup; fall back to 'cargo run' when it is missing. +$configuration = if ($DebugBuild) { 'Debug' } else { 'Release' } +$binaryName = if ($IsWindows -or $env:OS -eq 'Windows_NT') { 'qsoripper-cathub.exe' } else { 'qsoripper-cathub' } +$publishedBinary = Join-Path $repoRoot "artifacts\publish\qsoripper-cathub\$configuration\$binaryName" + +Write-Host "Starting cathub with config: $Config" -ForegroundColor Cyan +Write-Host "Rolling log: $logDir\qsoripper-cathub.log.*" -ForegroundColor DarkGray + +if (Test-Path $publishedBinary) { + $runArgs = @('--config', $Config) + if ($DryRun) { $runArgs += '--dry-run' } + Write-Host "$publishedBinary $($runArgs -join ' ')" -ForegroundColor DarkGray + & $publishedBinary @runArgs +} +else { + $cargoArgs = @('run') + if (-not $DebugBuild) { $cargoArgs += '--release' } + $cargoArgs += @('-p', 'qsoripper-cathub', '--manifest-path', $manifest, '--') + $cargoArgs += @('--config', $Config) + if ($DryRun) { $cargoArgs += '--dry-run' } + Write-Host "cargo $($cargoArgs -join ' ')" -ForegroundColor DarkGray + & cargo @cargoArgs +} diff --git a/scripts/Stop-CatHub.ps1 b/scripts/Stop-CatHub.ps1 new file mode 100644 index 0000000..72a3fed --- /dev/null +++ b/scripts/Stop-CatHub.ps1 @@ -0,0 +1,28 @@ +<# +.SYNOPSIS + Stop the qsoripper-cathub daemon. + +.DESCRIPTION + Finds the running qsoripper-cathub process and stops it. The daemon's Ctrl+C / shutdown + handler attempts to send RX; to the radio so a stop never leaves the transmitter keyed; + the ptt_max_tx_ms ceiling and the radio's own TX timeout are the ultimate backstops. + +.EXAMPLE + .\scripts\Stop-CatHub.ps1 +#> +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' + +$procs = Get-Process -Name 'qsoripper-cathub' -ErrorAction SilentlyContinue +if (-not $procs) { + Write-Host 'cathub is not running.' -ForegroundColor Yellow + return +} + +foreach ($p in $procs) { + Write-Host "Stopping cathub (PID $($p.Id))" -ForegroundColor Cyan + Stop-Process -Id $p.Id +} +Write-Host 'cathub stopped.' -ForegroundColor Green From d65c14e142aca8a8c1735b1ae157417e196cb504 Mon Sep 17 00:00:00 2001 From: Randy Treit <38894017+rtreit@users.noreply.github.com> Date: Fri, 29 May 2026 21:09:22 -0700 Subject: [PATCH 07/36] Support Hamlib Extended Response Protocol for Log4OM (#480) * fix(rig): lower default snapshot stale threshold to 200ms The engine cached rig snapshots for up to the stale threshold before re-polling the provider. The code default was 500ms but docs/.env/proto documented 5000ms, and a live GUI/TUI polls rig state about every 500ms. Through the cathub front door (which serves fresh rig state in under 50ms) this produced a multi-second lag between a frequency/band change and the QsoRipper rig-status display updating, while other apps tracked the radio near-instantly. Lower DEFAULT_RIGCTLD_STALE_THRESHOLD_MS to 200ms (well under a typical poll interval) and reconcile the documented defaults in .env.example, engine-specification.md, and the runtime-config field metadata. Add a regression test that primes the monitor cache, retunes the fake provider, and asserts the change surfaces within one interactive poll window; it fails at the old default and passes at 200ms. Also harden the GUI rig poll: guard OnRigTimerTick against reentrancy and run the DispatcherTimer at Default priority so the poll is not starved behind UI rendering or band-change bursts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(cathub): support Hamlib Extended Response Protocol for Log4OM Log4OM-NG drives rig control through Hamlib's Extended Response Protocol (ERP) rather than the plain rigctld protocol: it opens every session with ';V ?' (list supported VFOs) and polls with '+\get_vfo_info VFOA'. The hub's hamlib_net face only understood the plain protocol, so it answered the ERP handshake with RPRT -11 and Log4OM stayed offline. Parse the leading ERP separator ('+' newline-joined; ';' '|' ',' joined on one line by that char) and emit replies in the exact byte format real rigctld produces: an echoed long command name, labeled data records, and a trailing 'RPRT x'. The plain protocol path (WSJT-X, N1MM, the engine) is unchanged. Verified byte-for-byte against Hamlib 4.7.0 rigctld and online against a live TS-590. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/cathub/src/hamlib_net.rs | 313 +++++++++++++++++++++++++++++++- docs/integration/setup.md | 5 + 2 files changed, 309 insertions(+), 9 deletions(-) diff --git a/crates/cathub/src/hamlib_net.rs b/crates/cathub/src/hamlib_net.rs index a0d6409..14df72f 100644 --- a/crates/cathub/src/hamlib_net.rs +++ b/crates/cathub/src/hamlib_net.rs @@ -62,17 +62,184 @@ enum LineResult { Quit, } -/// Handle one rigctld protocol line against the universal state. -#[allow(clippy::too_many_lines)] // A flat protocol dispatch table reads best as one match. +/// Resolve a short or long command token to its Hamlib long name, used to echo the +/// received command as the first record of an Extended Response Protocol reply. +fn long_name(cmd: &str) -> Option<&'static str> { + Some(match cmd { + "F" | "\\set_freq" => "set_freq", + "M" | "\\set_mode" => "set_mode", + "V" | "\\set_vfo" => "set_vfo", + "T" | "\\set_ptt" => "set_ptt", + "S" | "\\set_split_vfo" => "set_split_vfo", + "J" | "\\set_rit" => "set_rit", + "Z" | "\\set_xit" => "set_xit", + "f" | "\\get_freq" => "get_freq", + "m" | "\\get_mode" => "get_mode", + "v" | "\\get_vfo" => "get_vfo", + "t" | "\\get_ptt" => "get_ptt", + "s" | "\\get_split_vfo" => "get_split_vfo", + "j" | "\\get_rit" => "get_rit", + "z" | "\\get_xit" => "get_xit", + "\\get_vfo_info" => "get_vfo_info", + "\\get_powerstat" => "get_powerstat", + "\\chk_vfo" => "chk_vfo", + "\\dump_state" => "dump_state", + _ => return None, + }) +} + +/// Detect a leading Extended Response Protocol separator. A command prefixed with `+` +/// (newline-joined) or `;` / `|` / `,` (single-line, joined by that char) selects the +/// extended response format. Returns the record separator and the rest of the line. +fn split_erp(line: &str) -> Option<(char, &str)> { + let first = line.chars().next()?; + let sep = match first { + '+' => '\n', + ';' | '|' | ',' => first, + _ => return None, + }; + Some((sep, &line[first.len_utf8()..])) +} + +/// Join Extended Response Protocol records (echo header, data records, `RPRT x`) with the +/// chosen separator and terminate the block with a newline, matching real `rigctld`. +fn erp_records(sep: char, records: &[String]) -> Vec { + let mut s = records.join(&sep.to_string()); + s.push('\n'); + s.into_bytes() +} + +/// Build the extended-response echo header for a command: the long name, a colon, and +/// (if any) the received arguments, e.g. `set_freq: 14200000` or `get_freq:`. +fn ext_echo(cmd: &str, args: &[&str]) -> String { + let name = long_name(cmd).unwrap_or(cmd); + if args.is_empty() { + format!("{name}:") + } else { + format!("{name}: {}", args.join(" ")) + } +} + +/// Handle one rigctld protocol line against the universal state, dispatching the +/// Extended Response Protocol (separator-prefixed) path used by Log4OM-NG separately +/// from the plain protocol used by WSJT-X / N1MM / the engine. async fn handle_line(line: &str, ctx: &FaceContext) -> LineResult { let trimmed = line.trim(); if trimmed.is_empty() { return LineResult::Reply(Vec::new()); } + if let Some((sep, rest)) = split_erp(trimmed) { + return LineResult::Reply(handle_ext(sep, rest, ctx).await); + } let mut parts = trimmed.split_whitespace(); let Some(cmd) = parts.next() else { return LineResult::Reply(Vec::new()); }; + let args: Vec<&str> = parts.collect(); + dispatch_plain(cmd, &args, ctx).await +} + +/// Handle one Extended Response Protocol line. Log4OM-NG opens every session with +/// `;V ?` (list supported VFOs) and then polls with `+\get_vfo_info VFOA`, so those two +/// shapes are formatted explicitly; the common labeled gets are also supported, and any +/// other command (sets, unknown) is wrapped generically as `echo `. +async fn handle_ext(sep: char, rest: &str, ctx: &FaceContext) -> Vec { + let trimmed = rest.trim(); + if trimmed.is_empty() { + return Vec::new(); + } + let mut parts = trimmed.split_whitespace(); + let Some(cmd) = parts.next() else { + return Vec::new(); + }; + let args: Vec<&str> = parts.collect(); + let snapshot = ctx.snapshot(); + let reads = ctx.perms.allows(CommandClass::ModeledRead); + + // `?` query on set_vfo: list supported VFOs. This is Log4OM-NG's handshake (`;V ?`). + // The supported-token list line is newline-terminated regardless of the separator, + // matching real `rigctld`. + if matches!(cmd, "V" | "\\set_vfo") && args.first() == Some(&"?") { + return format!("set_vfo: ?{sep}VFOA VFOB \nRPRT 0\n").into_bytes(); + } + + // get_vfo_info: Log4OM-NG's poll command (`+\get_vfo_info VFOA`). Reports the named + // VFO's frequency, mode, passband, split, and (always 0) satellite mode. + if cmd == "\\get_vfo_info" { + if !reads { + return erp_records(sep, &[ext_echo(cmd, &args), "RPRT -1".to_string()]); + } + let vfo = match args.first() { + Some(v) if v.eq_ignore_ascii_case("VFOB") => Vfo::B, + _ => Vfo::A, + }; + let v = snapshot.vfo(vfo); + return erp_records( + sep, + &[ + format!("get_vfo_info: {}", vfo_name(vfo)), + format!("Freq: {}", v.freq_hz), + format!("Mode: {}", v.mode.hamlib_token_with_data(v.data)), + format!("Width: {}", v.passband_hz), + format!("Split: {}", u8::from(snapshot.split)), + "SatMode: 0".to_string(), + "RPRT 0".to_string(), + ], + ); + } + + // Labeled simple gets, formatted with their Hamlib value labels. + let labeled: Option> = match cmd { + "f" | "\\get_freq" if reads => Some(vec![ + "get_freq:".to_string(), + format!("Frequency: {}", snapshot.vfo(snapshot.rx_vfo).freq_hz), + "RPRT 0".to_string(), + ]), + "m" | "\\get_mode" if reads => { + let v = snapshot.vfo(snapshot.rx_vfo); + Some(vec![ + "get_mode:".to_string(), + format!("Mode: {}", v.mode.hamlib_token_with_data(v.data)), + format!("Passband: {}", v.passband_hz), + "RPRT 0".to_string(), + ]) + } + "v" | "\\get_vfo" if reads => Some(vec![ + "get_vfo:".to_string(), + format!("VFO: {}", vfo_name(snapshot.rx_vfo)), + "RPRT 0".to_string(), + ]), + "t" | "\\get_ptt" if reads => Some(vec![ + "get_ptt:".to_string(), + format!("PTT: {}", u8::from(snapshot.ptt)), + "RPRT 0".to_string(), + ]), + _ => None, + }; + if let Some(records) = labeled { + return erp_records(sep, &records); + } + + // Generic fallback: sets, permission-denied reads, and unknown commands. Echo the + // command then append the plain dispatch reply (e.g. `RPRT 0`) as trailing records. + let echo = ext_echo(cmd, &args); + match dispatch_plain(cmd, &args, ctx).await { + LineResult::Quit => Vec::new(), + LineResult::Reply(bytes) => { + let mut records = vec![echo]; + for chunk in String::from_utf8_lossy(&bytes).split('\n') { + if !chunk.is_empty() { + records.push(chunk.to_string()); + } + } + erp_records(sep, &records) + } + } +} + +/// Dispatch one plain (non-extended) rigctld protocol command against the universal state. +#[allow(clippy::too_many_lines)] // A flat protocol dispatch table reads best as one match. +async fn dispatch_plain(cmd: &str, args: &[&str], ctx: &FaceContext) -> LineResult { let snapshot = ctx.snapshot(); let reply: Vec = match cmd { @@ -94,6 +261,23 @@ async fn handle_line(line: &str, ctx: &FaceContext) -> LineResult { "v" | "\\get_vfo" => guard_read(ctx, || { format!("{}\n", vfo_name(snapshot.rx_vfo)).into_bytes() }), + // get_vfo_info: report the named VFO's freq/mode/width/split/satmode as bare + // values (Freq, Mode, Width, Split, SatMode), matching real `rigctld`. + "\\get_vfo_info" => guard_read(ctx, || { + let vfo = match args.first() { + Some(v) if v.eq_ignore_ascii_case("VFOB") => Vfo::B, + _ => Vfo::A, + }; + let v = snapshot.vfo(vfo); + format!( + "{}\n{}\n{}\n{}\n0\n", + v.freq_hz, + v.mode.hamlib_token_with_data(v.data), + v.passband_hz, + u8::from(snapshot.split), + ) + .into_bytes() + }), "s" | "\\get_split_vfo" => guard_read(ctx, || { let tx = if snapshot.split { snapshot.tx_vfo @@ -113,7 +297,7 @@ async fn handle_line(line: &str, ctx: &FaceContext) -> LineResult { "\\chk_vfo" => b"0\n".to_vec(), // --- writes (require `write`) --- - "F" | "\\set_freq" => match parts.next().and_then(parse_freq_hz) { + "F" | "\\set_freq" => match args.first().copied().and_then(parse_freq_hz) { Some(hz) => outcome_rprt( ctx.apply_modeled( StateMutation::SetVfoFreq { @@ -126,7 +310,7 @@ async fn handle_line(line: &str, ctx: &FaceContext) -> LineResult { ), None => RPRT_EINVAL.to_vec(), }, - "M" | "\\set_mode" => match parts.next() { + "M" | "\\set_mode" => match args.first().copied() { Some(token) => { // The TS-590 splits data operation into a base mode (`MD`) and an // independent DATA flag (`DA`), so a `PKTUSB` request becomes two modeled @@ -162,8 +346,8 @@ async fn handle_line(line: &str, ctx: &FaceContext) -> LineResult { None => RPRT_EINVAL.to_vec(), }, "S" | "\\set_split_vfo" => { - let enabled = parts.next().map(str::trim) == Some("1"); - let tx_vfo = match parts.next() { + let enabled = args.first().copied() == Some("1"); + let tx_vfo = match args.get(1).copied() { Some(v) if v.eq_ignore_ascii_case("VFOB") => Some(Vfo::B), _ => Some(Vfo::A), }; @@ -181,7 +365,7 @@ async fn handle_line(line: &str, ctx: &FaceContext) -> LineResult { // source is honored on the wire (TS-590 `TX1;`) to route the DATA/USB audio // and avoid the data beep a bare `TX;` produces. Only 0 (or a missing arg) // means unkey. - let (keyed, source) = match parts.next().map(str::trim) { + let (keyed, source) = match args.first().copied() { Some("1") => (true, PttSource::Generic), Some("2") => (true, PttSource::Mic), Some("3") => (true, PttSource::Data), @@ -224,7 +408,7 @@ async fn handle_line(line: &str, ctx: &FaceContext) -> LineResult { }), "J" | "\\set_rit" => { if ctx.caps.has_rit { - match parts.next().and_then(|s| s.parse::().ok()) { + match args.first().and_then(|s| s.parse::().ok()) { Some(offset_hz) => outcome_rprt( ctx.apply_modeled( StateMutation::SetRit { @@ -243,7 +427,7 @@ async fn handle_line(line: &str, ctx: &FaceContext) -> LineResult { } "Z" | "\\set_xit" => { if ctx.caps.has_xit { - match parts.next().and_then(|s| s.parse::().ok()) { + match args.first().and_then(|s| s.parse::().ok()) { Some(offset_hz) => outcome_rprt( ctx.apply_modeled( StateMutation::SetXit { @@ -659,4 +843,115 @@ mod tests { assert_eq!(reply_of("\\set_rit 100", &ctx).await, RPRT_ENAVAIL.to_vec()); assert_eq!(reply_of("\\set_xit 100", &ctx).await, RPRT_ENAVAIL.to_vec()); } + + // --- Extended Response Protocol (Log4OM-NG) --- + + #[tokio::test] + async fn erp_set_vfo_query_lists_supported_vfos() { + // Log4OM-NG opens every session with `;V ?` (extended set_vfo query). The reply + // must echo the command, list the supported VFOs (newline before RPRT, matching + // real rigctld), and end with RPRT 0 — all `;`-separated on one logical block. + let (ctx, _b, _s) = ctx_with(FacePermissions::from_tokens(&["read", "write"])); + assert_eq!( + reply_of(";V ?", &ctx).await, + b"set_vfo: ?;VFOA VFOB \nRPRT 0\n".to_vec() + ); + } + + #[tokio::test] + async fn erp_get_vfo_info_reports_active_vfo_block() { + // Log4OM-NG polls with `+\get_vfo_info VFOA` (newline-separated extended form). + let (ctx, _b, state) = ctx_with(FacePermissions::from_tokens(&["read", "write"])); + state.record( + StateChange::Freq { + vfo: Vfo::A, + hz: 14_074_000, + }, + RadioEventSource::PollDiff, + ); + state.record( + StateChange::Mode { + vfo: Vfo::A, + mode: Mode::Cw, + }, + RadioEventSource::PollDiff, + ); + assert_eq!( + reply_of("+\\get_vfo_info VFOA", &ctx).await, + b"get_vfo_info: VFOA\nFreq: 14074000\nMode: CW\nWidth: 2400\nSplit: 0\nSatMode: 0\nRPRT 0\n" + .to_vec() + ); + } + + #[tokio::test] + async fn plain_get_vfo_info_reports_bare_values() { + // The non-extended form returns just the values (Freq, Mode, Width, Split, SatMode). + let (ctx, _b, state) = ctx_with(FacePermissions::read_only()); + state.record( + StateChange::Freq { + vfo: Vfo::A, + hz: 7_030_000, + }, + RadioEventSource::PollDiff, + ); + state.record( + StateChange::Mode { + vfo: Vfo::A, + mode: Mode::Cw, + }, + RadioEventSource::PollDiff, + ); + assert_eq!( + reply_of("\\get_vfo_info VFOA", &ctx).await, + b"7030000\nCW\n2400\n0\n0\n".to_vec() + ); + } + + #[tokio::test] + async fn erp_generic_set_echoes_command_then_rprt() { + // A generic extended set (e.g. `+F`) echoes `set_freq: ` then `RPRT 0`, + // newline-separated for the `+` separator. + let (ctx, _b, _s) = ctx_with(FacePermissions::from_tokens(&["read", "write"])); + assert_eq!( + reply_of("+F 14200000", &ctx).await, + b"set_freq: 14200000\nRPRT 0\n".to_vec() + ); + } + + #[tokio::test] + async fn erp_set_vfo_with_arg_echoes_on_one_line() { + // `;V VFOA` selects the active VFO (never retargets) and replies on one line. + let (ctx, _b, _s) = ctx_with(FacePermissions::from_tokens(&["read", "write"])); + assert_eq!( + reply_of(";V VFOA", &ctx).await, + b"set_vfo: VFOA;RPRT 0\n".to_vec() + ); + } + + #[tokio::test] + async fn erp_labeled_get_freq_single_line() { + // `;f` returns a single-line labeled extended response. + let (ctx, _b, state) = ctx_with(FacePermissions::read_only()); + state.record( + StateChange::Freq { + vfo: Vfo::A, + hz: 14_074_000, + }, + RadioEventSource::PollDiff, + ); + assert_eq!( + reply_of(";f", &ctx).await, + b"get_freq:;Frequency: 14074000;RPRT 0\n".to_vec() + ); + } + + #[tokio::test] + async fn erp_get_vfo_info_denied_without_read() { + // A face without read permission gets an RPRT -1 extended error, not data. + let (ctx, _b, _s) = ctx_with(FacePermissions::from_tokens(&["write"])); + assert_eq!( + reply_of("+\\get_vfo_info VFOA", &ctx).await, + b"get_vfo_info: VFOA\nRPRT -1\n".to_vec() + ); + } } diff --git a/docs/integration/setup.md b/docs/integration/setup.md index b681c09..a54092b 100644 --- a/docs/integration/setup.md +++ b/docs/integration/setup.md @@ -162,6 +162,11 @@ No radio-menu change is needed. Leave **Beep Volume** at your normal setting. ### Log4OM - CAT interface: Hamlib `NET rigctl`, host **127.0.0.1**, port **4534**. - The `log4om` endpoint is `perms = ["read", "write"]`. +- Log4OM-NG uses Hamlib's **Extended Response Protocol** (ERP): it opens every session with + `;V ?` (list supported VFOs) and then polls with `+\get_vfo_info VFOA` (~2 Hz). The hub's + `hamlib_net` face parses the ERP separator prefix (`+ ; | ,`) and answers both shapes in the + exact byte format real `rigctld` produces, so Log4OM connects and stays **online**. Plain + clients (WSJT-X, N1MM, the engine) are unaffected — they never send the ERP prefix. ### QsoRipper engine (TUI and GUI) - The engine's `RigctldProvider` points at the read-only endpoint **127.0.0.1:4532**. From fbd1bc5e44e5bb1ed2af513badf0940060144042 Mon Sep 17 00:00:00 2001 From: Randy Treit <38894017+rtreit@users.noreply.github.com> Date: Fri, 29 May 2026 22:02:30 -0700 Subject: [PATCH 08/36] Add CAT hub daemon checkbox to launcher TUI (#481) Replace the launcher.ps1 -WithCatHub switch with a first-class daemon entry in the launcher TUI. The CAT hub (qsoripper-cathub) now appears as the first row of the relabeled Services column. Selecting it starts the hub first, waits for its rigctld face on 127.0.0.1:4532, and only then launches the selected engines and UIs so everything connects through the hub. A hub failure aborts the rest of the launch with a clear message. Adds ComponentKind::Daemon, a daemons list in Selection (persisted to launcher.daemons), config resolution that prefers the unified config.toml when it has a [cat_hub] table and falls back to config/cathub.toml, and regression tests guarding that the daemon is never treated as an engine endpoint or binding target. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/integration/setup.md | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/docs/integration/setup.md b/docs/integration/setup.md index a54092b..ca142e6 100644 --- a/docs/integration/setup.md +++ b/docs/integration/setup.md @@ -92,13 +92,21 @@ Stop the hub with Ctrl+C in its window, or: ### Cold-start workflow (build + launch everything) For a clean start after logon, build all artifacts and then launch the hub together with the -engines and UIs from one command: +engines and UIs from the launcher TUI: - .\build.ps1 # publishes qsoripper-cathub alongside the engines/UIs - .\launcher.ps1 -WithCatHub # starts the CAT hub first, then the launcher TUI + .\build.ps1 # publishes qsoripper-cathub alongside the engines/UIs + .\launcher.ps1 # opens the launcher TUI -`-WithCatHub` brings the radio daemon up in its own window (reading the unified `config.toml`) -before the launcher starts engines and UIs, so everything connects to the hub's rigctld face. +In the launcher, the first column ("Services") lists the **CAT hub daemon (rigctld :4532)** +above the engines. Check it with `Space`, then press `Enter`. The launcher starts the hub +first, waits for its rigctld face on `127.0.0.1:4532`, and only then starts the selected +engines and UIs so everything connects to the hub. If the hub fails to come up the launcher +aborts the rest of the launch; check `.\scripts\Get-CatHubLog.ps1 -Follow` for the cause. The +hub reads the unified `config.toml` when it has a `[cat_hub]` table, otherwise the repo sample +`config\cathub.toml`. Your selection (including the hub) is remembered for next time. + +`.\scripts\Start-CatHub.ps1` remains available as a manual, foreground helper for running the +hub on its own (for example with `-DryRun` to validate config). ## 5. Point each application at the hub From 36985b0a3a77cb28f8534a1e3079ba6b52bd223a Mon Sep 17 00:00:00 2001 From: Mike Treit Date: Mon, 1 Jun 2026 12:54:50 -0700 Subject: [PATCH 09/36] Track active TS-590 VFO in cathub (#488) Poll the TS-590 IF status frame so cathub records which VFO is currently receiving. This keeps rigctld, OmniRig, and HDSDR pointed at the displayed VFO instead of always reporting VFO A. Co-authored-by: Randy Treit Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/cathub/src/backend/kenwood/ts590.rs | 157 ++++++++++++++++++++- crates/cathub/src/backend/rigctld.rs | 4 +- crates/cathub/src/model.rs | 14 ++ crates/cathub/src/state.rs | 9 ++ 4 files changed, 179 insertions(+), 5 deletions(-) diff --git a/crates/cathub/src/backend/kenwood/ts590.rs b/crates/cathub/src/backend/kenwood/ts590.rs index e5db923..1e481e3 100644 --- a/crates/cathub/src/backend/kenwood/ts590.rs +++ b/crates/cathub/src/backend/kenwood/ts590.rs @@ -13,12 +13,28 @@ use crate::backend::{ BackendCapabilities, BackendError, Framing, NativeCommandFamily, RadioBackend, SplitStyle, TrustTier, }; -use crate::model::{Mode, PttSource, RadioEventSource, StateMutation, Vfo}; +use crate::model::{Mode, PttSource, RadioEventSource, StateChange, StateMutation, Vfo}; use crate::radio::{Expect, RadioLink}; use crate::state::StateHandle; /// The baseline poll command set. Read-only queries only: **never** `FR`/`FT` (§8.8). -const POLL_COMMANDS: &[&[u8]] = &[b"FA;", b"FB;", b"MD;", b"DA;"]; +const POLL_COMMANDS: &[&[u8]] = &[b"FA;", b"FB;", b"IF;", b"MD;", b"DA;"]; + +/// `IF;` payload byte offsets from the Kenwood TS-590 status frame. +const IF_FREQ_RANGE: std::ops::Range = 0..11; +const IF_TX_OFFSET: usize = 26; +const IF_MODE_OFFSET: usize = 27; +const IF_RX_VFO_OFFSET: usize = 28; +const IF_SPLIT_OFFSET: usize = 30; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct IfStatus { + freq_hz: u64, + ptt: bool, + mode: Mode, + rx_vfo: Vfo, + split: bool, +} /// The native TS-590 backend. #[derive(Clone, Default)] @@ -53,6 +69,59 @@ fn parse_u64(bytes: &[u8]) -> Option { std::str::from_utf8(bytes).ok()?.trim().parse::().ok() } +fn parse_if_status(frame: &[u8]) -> Option { + let (verb, payload) = split_frame(frame)?; + if verb != b"IF" { + return None; + } + let rx_vfo = match *payload.get(IF_RX_VFO_OFFSET)? { + b'0' => Vfo::A, + b'1' => Vfo::B, + // The TS-590 can report non-VFO states such as memory mode. Cathub does not model + // those as an active VFO, so ignore the frame instead of silently pinning VFO A. + _ => return None, + }; + Some(IfStatus { + freq_hz: parse_u64(payload.get(IF_FREQ_RANGE)?)?, + ptt: *payload.get(IF_TX_OFFSET)? == b'1', + mode: Mode::from_kenwood_digit(*payload.get(IF_MODE_OFFSET)?), + rx_vfo, + split: *payload.get(IF_SPLIT_OFFSET)? == b'1', + }) +} + +fn record_if_status(state: &StateHandle, status: IfStatus) { + state.record( + StateChange::RxVfo { vfo: status.rx_vfo }, + RadioEventSource::PollDiff, + ); + state.record( + StateChange::Freq { + vfo: status.rx_vfo, + hz: status.freq_hz, + }, + RadioEventSource::PollDiff, + ); + state.record( + StateChange::Mode { + vfo: status.rx_vfo, + mode: status.mode, + }, + RadioEventSource::PollDiff, + ); + state.record( + StateChange::Ptt { keyed: status.ptt }, + RadioEventSource::PollDiff, + ); + state.record( + StateChange::Split { + enabled: status.split, + tx_vfo: None, + }, + RadioEventSource::PollDiff, + ); +} + /// Build an `FA`/`FB` frequency set frame. fn freq_set(vfo: Vfo, hz: u64) -> Vec { let verb = match vfo { @@ -73,7 +142,9 @@ impl RadioBackend for Ts590Backend { for &cmd in POLL_COMMANDS { let verb = cmd.get(..2).unwrap_or(cmd).to_vec(); let reply = link.submit(cmd.to_vec(), Expect::Reply(vec![verb])).await?; - if let Some(mutation) = self.parse_event(&reply) { + if let Some(status) = parse_if_status(&reply) { + record_if_status(state, status); + } else if let Some(mutation) = self.parse_event(&reply) { state.record(mutation.into_change(), RadioEventSource::PollDiff); } } @@ -87,6 +158,7 @@ impl RadioBackend for Ts590Backend { state: &StateHandle, ) -> Result<(), BackendError> { let bytes = match mutation { + StateMutation::SetRxVfo { .. } => return Err(BackendError::Unsupported), StateMutation::SetVfoFreq { vfo, hz } => freq_set(vfo, hz), StateMutation::SetMode { mode, .. } => { vec![b'M', b'D', mode.to_kenwood_digit(), b';'] @@ -168,6 +240,12 @@ impl RadioBackend for Ts590Backend { vfo: Vfo::A, on: *payload.first()? == b'1', }), + // Native push routing currently accepts one modeled mutation per frame. Polling + // records the full IF status; unsolicited IF frames still refresh the critical + // active-RX-VFO fact so OmniRig/HDSDR follows the displayed VFO. + b"IF" => { + parse_if_status(frame).map(|status| StateMutation::SetRxVfo { vfo: status.rx_vfo }) + } _ => None, } } @@ -222,6 +300,7 @@ mod tests { "poll set must not contain a VFO-select command" ); } + assert_eq!(POLL_COMMANDS, &[b"FA;", b"FB;", b"IF;", b"MD;", b"DA;"]); } #[test] @@ -262,9 +341,38 @@ mod tests { on: false }) ); + assert_eq!( + backend.parse_event(b"IF000140343201234-0000012345121019999;"), + Some(StateMutation::SetRxVfo { vfo: Vfo::B }) + ); assert_eq!(backend.parse_event(b"ZZ;"), None); } + #[test] + fn parse_if_status_reads_active_vfo_from_real_status_shape() { + let status = + parse_if_status(b"IF000140343201234-0000012345121019999;").expect("parse IF status"); + + assert_eq!( + status, + IfStatus { + freq_hz: 14_034_320, + ptt: true, + mode: Mode::Usb, + rx_vfo: Vfo::B, + split: true, + } + ); + } + + #[test] + fn parse_if_status_ignores_non_vfo_memory_mode() { + assert_eq!( + parse_if_status(b"IF000140343201234-0000012345122019999;"), + None + ); + } + #[test] fn capabilities_are_certified_native_with_push() { let caps = Ts590Backend::new().capabilities(); @@ -365,7 +473,7 @@ mod tests { let (radio_side, server) = tokio::io::duplex(1024); tokio::spawn(run_transport(server, arc, state.clone(), raw_rx)); - // A fake radio that answers the three poll queries. + // A fake radio that answers the poll queries. tokio::spawn(async move { let (mut rd, mut wr) = tokio::io::split(radio_side); let mut frame = Vec::new(); @@ -379,6 +487,7 @@ mod tests { let answer: &[u8] = match frame.as_slice() { b"FA;" => b"FA00007030000;", b"FB;" => b"FB00014250000;", + b"IF;" => b"IF000070300000000+0000000000030000000;", b"MD;" => b"MD3;", b"DA;" => b"DA1;", _ => b"", @@ -397,6 +506,46 @@ mod tests { assert!(snap.vfo(Vfo::A).data, "DA; reply should set the DATA flag"); } + #[tokio::test] + async fn poll_reads_active_vfo_from_if_status() { + let (link, raw_rx) = link_channel(); + let backend = Arc::new(Ts590Backend::new()); + let arc: Arc = backend.clone(); + let state = StateHandle::new(); + let (radio_side, server) = tokio::io::duplex(1024); + tokio::spawn(run_transport(server, arc, state.clone(), raw_rx)); + + tokio::spawn(async move { + let (mut rd, mut wr) = tokio::io::split(radio_side); + let mut frame = Vec::new(); + let mut byte = [0u8; 1]; + loop { + if rd.read(&mut byte).await.unwrap_or(0) == 0 { + break; + } + frame.push(byte[0]); + if byte[0] == b';' { + let answer: &[u8] = match frame.as_slice() { + b"FA;" => b"FA00003020950;", + b"FB;" => b"FB00014034320;", + b"IF;" => b"IF000140343201234-0000012345021009999;", + b"MD;" => b"MD3;", + b"DA;" => b"DA0;", + _ => b"", + }; + let _ = wr.write_all(answer).await; + frame.clear(); + } + } + }); + + backend.poll(&link, &state).await.expect("poll"); + let snap = state.snapshot(); + assert_eq!(snap.rx_vfo, Vfo::B); + assert_eq!(snap.vfo(snap.rx_vfo).freq_hz, 14_034_320); + assert_eq!(snap.vfo(snap.rx_vfo).mode, Mode::Usb); + } + #[tokio::test] async fn apply_data_mode_writes_da_frame() { let (link, raw_rx) = link_channel(); diff --git a/crates/cathub/src/backend/rigctld.rs b/crates/cathub/src/backend/rigctld.rs index 3203b97..5116d57 100644 --- a/crates/cathub/src/backend/rigctld.rs +++ b/crates/cathub/src/backend/rigctld.rs @@ -176,7 +176,9 @@ impl RadioBackend for RigctldBackend { .await?; check_rprt(&reply)?; } - StateMutation::SetRit { .. } | StateMutation::SetXit { .. } => { + StateMutation::SetRxVfo { .. } + | StateMutation::SetRit { .. } + | StateMutation::SetXit { .. } => { return Err(BackendError::Unsupported); } } diff --git a/crates/cathub/src/model.rs b/crates/cathub/src/model.rs index 026c9f3..dcd77d1 100644 --- a/crates/cathub/src/model.rs +++ b/crates/cathub/src/model.rs @@ -166,6 +166,11 @@ pub(crate) enum PttSource { #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[allow(clippy::enum_variant_names)] // The `Set` prefix names the write intent uniformly. pub(crate) enum StateMutation { + /// Set the active receive VFO. + SetRxVfo { + /// Active receive VFO. + vfo: Vfo, + }, /// Set a VFO's frequency in Hz. SetVfoFreq { /// Target VFO. @@ -221,6 +226,7 @@ impl StateMutation { /// The observable [`StateChange`] this mutation produces once applied. pub(crate) fn into_change(self) -> StateChange { match self { + StateMutation::SetRxVfo { vfo } => StateChange::RxVfo { vfo }, StateMutation::SetVfoFreq { vfo, hz } => StateChange::Freq { vfo, hz }, StateMutation::SetMode { vfo, mode } => StateChange::Mode { vfo, mode }, StateMutation::SetDataMode { vfo, on } => StateChange::DataMode { vfo, on }, @@ -236,6 +242,11 @@ impl StateMutation { /// fan-out and recorded into the [`Snapshot`](crate::state::Snapshot). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum StateChange { + /// The active receive VFO changed. + RxVfo { + /// Active receive VFO. + vfo: Vfo, + }, /// A VFO frequency changed. Freq { /// Affected VFO. @@ -289,6 +300,7 @@ impl StateChange { /// The coverage [`Field`] this change updates. pub(crate) fn field(&self) -> Field { match *self { + StateChange::RxVfo { .. } => Field::RxVfo, StateChange::Freq { vfo, .. } => Field::Freq(vfo), // The DATA flag is part of the composed mode, so it shares the Mode coverage key. StateChange::Mode { vfo, .. } | StateChange::DataMode { vfo, .. } => Field::Mode(vfo), @@ -304,6 +316,8 @@ impl StateChange { /// radio's native push stream covers a field (so the baseline poller can back off). #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub(crate) enum Field { + /// Active receive VFO. + RxVfo, /// A VFO frequency. Freq(Vfo), /// A VFO mode. diff --git a/crates/cathub/src/state.rs b/crates/cathub/src/state.rs index d375577..87b57d9 100644 --- a/crates/cathub/src/state.rs +++ b/crates/cathub/src/state.rs @@ -108,6 +108,7 @@ impl Snapshot { /// unkeying must always reach the radio and participate in the single-owner lease. pub(crate) fn is_redundant(&self, mutation: &StateMutation) -> bool { match *mutation { + StateMutation::SetRxVfo { vfo } => self.rx_vfo == vfo, StateMutation::SetVfoFreq { vfo, hz } => self.vfo(vfo).freq_hz == hz, // Compare by the digit actually written to the radio, not the enum identity. // WSJT-X asserts "PKTUSB", which decomposes into a base mode of USB (sent as MD2) @@ -236,6 +237,14 @@ impl StateHandle { /// Apply a change to the snapshot, returning whether any value actually changed. fn apply_change(snap: &mut Snapshot, change: StateChange) -> bool { match change { + StateChange::RxVfo { vfo } => { + if snap.rx_vfo == vfo { + false + } else { + snap.rx_vfo = vfo; + true + } + } StateChange::Freq { vfo, hz } => { let target = vfo_mut(snap, vfo); if target.freq_hz == hz { From ee3c221e1983df591401bb54ba3a651c27e36bcb Mon Sep 17 00:00:00 2001 From: Randy Treit <38894017+rtreit@users.noreply.github.com> Date: Mon, 1 Jun 2026 15:39:58 -0700 Subject: [PATCH 10/36] Restart stale engines after launcher rebuild (#487) * Add CAT hub setup-wizard management to engine setup Operators can now read and replace the qsoripper-cathub daemon's [cat_hub] section through the engine SetupService instead of hand-editing config.toml. A new CatHubSettings proto family (radio/poll/ptt/events plus serial faces and hamlib_net endpoints) is wired into SaveSetup (field 11), SetupStatus (field 26), and a new SETUP_WIZARD_STEP_CAT_HUB wizard step. The contract is full-replacement: when cat_hub is supplied it is the complete desired section (radio with backend required, at least one endpoint required) and the engine rewrites [cat_hub]; when omitted the section is preserved verbatim, so routine saves never reserialize the daemon's configuration. The section is parsed leniently and separately for status display so a malformed [cat_hub] can never break engine load. Both engines implement identical validation, conditional-write, lenient-read, status, and 5-step wizard behavior (Rust setup.rs and .NET SharedSetupConfig/ ManagedEngineState), with matching tests. The CLI status view gains a CAT hub summary line. Engine specification updated. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add CAT Hub config editor to GUI Settings dialog Adds a CAT Hub tab to the Settings dialog so operators can edit the [cat_hub] section of config.toml from the GUI instead of hand-editing the file. Covers radio backend, polling, PTT, events, serial faces, and hamlib_net endpoints with add/remove rows and per-face permissions. The whole config.toml is rewritten on save, so cat_hub is only emitted when the section is actually edited (dirty-gated). A warning banner is shown before the first rewrite since unmodeled keys/comments in the section are dropped. Optional bools use tri-state (omit/true/false). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix launcher SaveSetupRequest for new cat_hub field The cat_hub field added to SaveSetupRequest broke the launcher sync constructor. Set cat_hub: None so the launcher status re-save preserves the existing [cat_hub] section verbatim instead of rewriting it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Make publish copy resilient to in-use binaries launcher.ps1 -Rebuild failed when a published binary (e.g. qsoripper-tui) was still running, because Copy-Item cannot overwrite a locked exe/DLL. Add a Copy-PublishArtifact helper that, on a locked destination, renames the in-use file aside (.locked-.old) and copies the fresh build into place. On Windows a running executable or loaded DLL can be renamed but not overwritten; the running process keeps using the renamed file and the next launch picks up the new binary. Stale .old files are best-effort cleaned on the next sideline. Routes the tui, stress-tui, server, cathub, and ffi publish copies through the helper. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Side-line in-use .NET publish outputs before dotnet publish launcher.ps1 -Rebuild fails with MSB3021/MSB3027 when a running app (e.g. QsoRipper.Gui) locks its published DLL/EXE: dotnet publish's own copy retries ten times then errors out. The earlier Copy-PublishArtifact helper only covered the Rust Copy-Item publish steps, not MSBuild's copy. Add Clear-LockedPublishArtifacts (and a Test-FileLocked probe) that, before each dotnet publish, renames any locked output files aside as .locked-.old. On Windows a running exe / loaded DLL can be renamed but not overwritten, so this frees the destination paths for the fresh publish while the running process keeps its renamed handles; the next launch picks up the new build. Stale .old files are swept on the next publish and live under gitignored artifacts/. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Side-line in-use Win32 exe before MSVC link launcher.ps1 -Rebuild fails the Win32 C build with LNK1104 (cannot open file qsoripper-win32.exe) when a previously built instance is still running: the MSVC linker writes the exe directly into the publish dir and cannot overwrite the locked image. Reuse Clear-LockedPublishArtifacts before the link step so the running exe is renamed aside, freeing the path for a fresh link. The running process keeps its renamed handle; the next launch picks up the new build. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Stop stale published processes after launcher rebuild After .\launcher.ps1 -Rebuild, the build side-lines in-use binaries and drops fresh ones but does not restart long-running engine processes. A fresh GUI then keeps talking to a stale engine started before the rebuild, surfacing as stale data such as an empty CAT Hub settings page even when config.toml is valid. Add Stop-StalePublishedProcesses, invoked only after a successful rebuild. It stops processes whose image lives under artifacts\publish and whose start time predates the on-disk binary's last-write time, so the next launch spawns fresh engines. Scoped to the publish tree (external apps like HDSDR/N1MM are untouched) and best-effort so it never aborts the script. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/cathub/src/lib.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/crates/cathub/src/lib.rs b/crates/cathub/src/lib.rs index 71c8973..351cfd0 100644 --- a/crates/cathub/src/lib.rs +++ b/crates/cathub/src/lib.rs @@ -55,6 +55,22 @@ use crate::state::StateHandle; pub use crate::error::CatHubError; +/// Validate that a unified `config.toml` body contains a `[cat_hub]` section the +/// cathub daemon will accept. This is exposed for the QsoRipper engine's setup +/// wizard tests so a regression in the engine's CAT hub writer is caught against +/// the daemon's real parser/validator rather than a hand-maintained copy. +/// +/// # Errors +/// +/// Returns a [`CatHubError`] if the document cannot be parsed or the resulting +/// `[cat_hub]` configuration fails the daemon's semantic validation. +#[doc(hidden)] +pub fn validate_cat_hub_toml(text: &str) -> Result<(), CatHubError> { + Config::parse_document(text) + .map(|_| ()) + .map_err(CatHubError::Config) +} + /// Command-line arguments. #[derive(Debug, Parser)] #[command( From 7d6afc93eade289a5f69c05e2a00ba1132517aab Mon Sep 17 00:00:00 2001 From: Randy Treit <38894017+rtreit@users.noreply.github.com> Date: Mon, 1 Jun 2026 15:40:27 -0700 Subject: [PATCH 11/36] Make publish copy resilient to in-use binaries (#485) * Make publish copy resilient to in-use binaries launcher.ps1 -Rebuild failed when a published binary (e.g. qsoripper-tui) was still running, because Copy-Item cannot overwrite a locked exe/DLL. Add a Copy-PublishArtifact helper that, on a locked destination, renames the in-use file aside (.locked-.old) and copies the fresh build into place. On Windows a running executable or loaded DLL can be renamed but not overwritten; the running process keeps using the renamed file and the next launch picks up the new binary. Stale .old files are best-effort cleaned on the next sideline. Routes the tui, stress-tui, server, cathub, and ffi publish copies through the helper. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Side-line in-use .NET publish outputs before dotnet publish launcher.ps1 -Rebuild fails with MSB3021/MSB3027 when a running app (e.g. QsoRipper.Gui) locks its published DLL/EXE: dotnet publish's own copy retries ten times then errors out. The earlier Copy-PublishArtifact helper only covered the Rust Copy-Item publish steps, not MSBuild's copy. Add Clear-LockedPublishArtifacts (and a Test-FileLocked probe) that, before each dotnet publish, renames any locked output files aside as .locked-.old. On Windows a running exe / loaded DLL can be renamed but not overwritten, so this frees the destination paths for the fresh publish while the running process keeps its renamed handles; the next launch picks up the new build. Stale .old files are swept on the next publish and live under gitignored artifacts/. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Side-line in-use Win32 exe before MSVC link launcher.ps1 -Rebuild fails the Win32 C build with LNK1104 (cannot open file qsoripper-win32.exe) when a previously built instance is still running: the MSVC linker writes the exe directly into the publish dir and cannot overwrite the locked image. Reuse Clear-LockedPublishArtifacts before the link step so the running exe is renamed aside, freeing the path for a fresh link. The running process keeps its renamed handle; the next launch picks up the new build. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> From 20b8ad9b423df02c0a9011719cc3cc0a0f9b865d Mon Sep 17 00:00:00 2001 From: Randy Treit <38894017+rtreit@users.noreply.github.com> Date: Mon, 1 Jun 2026 15:40:58 -0700 Subject: [PATCH 12/36] Add CAT hub setup-wizard management to engine setup (#482) * Add CAT hub setup-wizard management to engine setup Operators can now read and replace the qsoripper-cathub daemon's [cat_hub] section through the engine SetupService instead of hand-editing config.toml. A new CatHubSettings proto family (radio/poll/ptt/events plus serial faces and hamlib_net endpoints) is wired into SaveSetup (field 11), SetupStatus (field 26), and a new SETUP_WIZARD_STEP_CAT_HUB wizard step. The contract is full-replacement: when cat_hub is supplied it is the complete desired section (radio with backend required, at least one endpoint required) and the engine rewrites [cat_hub]; when omitted the section is preserved verbatim, so routine saves never reserialize the daemon's configuration. The section is parsed leniently and separately for status display so a malformed [cat_hub] can never break engine load. Both engines implement identical validation, conditional-write, lenient-read, status, and 5-step wizard behavior (Rust setup.rs and .NET SharedSetupConfig/ ManagedEngineState), with matching tests. The CLI status view gains a CAT hub summary line. Engine specification updated. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add CAT Hub config editor to GUI Settings dialog (#483) Adds a CAT Hub tab to the Settings dialog so operators can edit the [cat_hub] section of config.toml from the GUI instead of hand-editing the file. Covers radio backend, polling, PTT, events, serial faces, and hamlib_net endpoints with add/remove rows and per-face permissions. The whole config.toml is rewritten on save, so cat_hub is only emitted when the section is actually edited (dirty-gated). A warning banner is shown before the first rewrite since unmodeled keys/comments in the section are dropped. Optional bools use tri-state (omit/true/false). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix launcher SaveSetupRequest for new cat_hub field (#484) * Add CAT Hub config editor to GUI Settings dialog Adds a CAT Hub tab to the Settings dialog so operators can edit the [cat_hub] section of config.toml from the GUI instead of hand-editing the file. Covers radio backend, polling, PTT, events, serial faces, and hamlib_net endpoints with add/remove rows and per-face permissions. The whole config.toml is rewritten on save, so cat_hub is only emitted when the section is actually edited (dirty-gated). A warning banner is shown before the first rewrite since unmodeled keys/comments in the section are dropped. Optional bools use tri-state (omit/true/false). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix launcher SaveSetupRequest for new cat_hub field The cat_hub field added to SaveSetupRequest broke the launcher sync constructor. Set cat_hub: None so the launcher status re-save preserves the existing [cat_hub] section verbatim instead of rewriting it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> From fd63ccf32c1e0341a74dc8fe8d37c25b32c5afde Mon Sep 17 00:00:00 2001 From: Randy Treit <38894017+rtreit@users.noreply.github.com> Date: Tue, 2 Jun 2026 18:29:15 -0700 Subject: [PATCH 13/36] cathub: auto-reconnect the radio transport with backoff (#490) If the radio serial/TCP link drops mid-session (USB unplug, radio power-cycle, cable hiccup, or write error), the daemon previously left every client face wired to a dead link until the whole hub was restarted. This is why restarting HDSDR alone never recovered rig control - the dead session lived in the hub, not in HDSDR. Add run_transport_supervised: run_transport now returns a TransportOutcome and hands its command receiver back on disconnect, so the supervisor reopens the transport with capped exponential backoff (0.5s..5s) and resumes serving the same queued commands. Native push (auto-info) is re-armed on every reconnect, since a power-cycled radio forgets its AI state (design 8.4/8.7). Shutdown (all RadioLinks dropped) still stops cleanly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/cathub/src/lib.rs | 99 +++++++++++---- crates/cathub/src/radio/mod.rs | 223 ++++++++++++++++++++++++++++++++- docs/integration/setup.md | 12 +- 3 files changed, 299 insertions(+), 35 deletions(-) diff --git a/crates/cathub/src/lib.rs b/crates/cathub/src/lib.rs index 351cfd0..6066d2d 100644 --- a/crates/cathub/src/lib.rs +++ b/crates/cathub/src/lib.rs @@ -40,16 +40,19 @@ use tracing_appender::non_blocking::WorkerGuard; use crate::backend::kenwood::ts590::Ts590Backend; use crate::backend::loopback::LoopbackBackend; use crate::backend::rigctld::RigctldBackend; -use crate::backend::RadioBackend; +use crate::backend::{BackendError, RadioBackend}; use crate::config::{Config, RadioConfig}; use crate::dialect::kenwood::ts2000::Ts2000Dialect; use crate::dialect::kenwood::ts590::Ts590Dialect; use crate::dialect::{ClientDialect, FaceContext}; -use crate::events::{enable_native_push, spawn_poller, POLLER_FACE}; +use crate::events::{spawn_poller, POLLER_FACE}; use crate::hamlib_net::run_listener; use crate::model::StateMutation; use crate::ptt::PttManager; -use crate::radio::{link_channel, run_transport, spawn_scheduler, OpKind, Priority}; +use crate::radio::{ + link_channel, run_transport_supervised, spawn_scheduler, OpKind, Priority, RECONNECT_INITIAL, + RECONNECT_MAX, +}; use crate::serial_face::{open_serial, run_face}; use crate::state::StateHandle; @@ -128,26 +131,32 @@ enum OpenedTransport { /// Open the radio transport described by the `[radio]` section. async fn open_transport(radio: &RadioConfig) -> Result { match radio.transport.as_str() { - "serial" => { - let port = serial2_tokio::SerialPort::open(&radio.port, radio.baud)?; - // Assert the RTS and DTR modem-control lines. Some radios (notably the Kenwood - // TS-590) gate their CAT transmit on RTS and send no replies at all unless it is - // high, so without this the daemon opens the port but every poll times out. This - // matches the default line state that OmniRig/Hamlib clients use. - port.set_rts(true)?; - port.set_dtr(true)?; - Ok(OpenedTransport::Serial(port)) - } - "tcp" => { - let stream = TcpStream::connect((radio.host.as_str(), radio.tcp_port)).await?; - Ok(OpenedTransport::Tcp(stream)) - } + "serial" => Ok(OpenedTransport::Serial(open_radio_serial(radio)?)), + "tcp" => Ok(OpenedTransport::Tcp(open_radio_tcp(radio).await?)), other => Err(CatHubError::Backend(format!( "unknown radio.transport '{other}'" ))), } } +/// Open and condition the radio serial port. +/// +/// Assert the RTS and DTR modem-control lines. Some radios (notably the Kenwood TS-590) gate +/// their CAT transmit on RTS and send no replies at all unless it is high, so without this the +/// daemon opens the port but every poll times out. This matches the default line state that +/// OmniRig/Hamlib clients use. +fn open_radio_serial(radio: &RadioConfig) -> std::io::Result { + let port = serial2_tokio::SerialPort::open(&radio.port, radio.baud)?; + port.set_rts(true)?; + port.set_dtr(true)?; + Ok(port) +} + +/// Connect the radio TCP transport (a `tcp` radio or a rigctld bridge endpoint). +async fn open_radio_tcp(radio: &RadioConfig) -> std::io::Result { + TcpStream::connect((radio.host.as_str(), radio.tcp_port)).await +} + /// Run the daemon to completion (until Ctrl+C). /// /// # Errors @@ -180,33 +189,69 @@ pub async fn run(cli: Cli) -> Result<(), CatHubError> { // Wire the transport to the serialized radio link. The loopback backend needs no real // transport (it never submits raw bytes), so we just drop the receiver in that case. + // + // Native push is "in play" whenever the operator enabled it and the backend has a push + // command. The supervisor (below) actually issues the enable on the first connect and + // re-issues it on every reconnect, so the poller can use this flag to decide back-off. let (link, raw_rx) = link_channel(); + let native_push_active = cfg.radio.backend != "loopback" + && cfg.events.native_push + && backend.native_push_enable().is_some(); if cfg.radio.backend == "loopback" { drop(raw_rx); } else { + // The transport is supervised: if the serial/TCP link drops (unplug, radio + // power-cycle, write error) the daemon reopens it with backoff and keeps serving the + // same command queue, instead of leaving every client wired to a dead radio link until + // the whole daemon is restarted. + let push_link = native_push_active.then(|| link.clone()); + let backend_t = backend.clone(); + let state_t = state.clone(); match open_transport(&cfg.radio).await? { OpenedTransport::Serial(port) => { - tokio::spawn(run_transport(port, backend.clone(), state.clone(), raw_rx)); + let radio_cfg = cfg.radio.clone(); + tokio::spawn(run_transport_supervised( + port, + raw_rx, + backend_t, + state_t, + push_link, + move || { + let radio_cfg = radio_cfg.clone(); + async move { + open_radio_serial(&radio_cfg) + .map_err(|e| BackendError::Transport(e.to_string())) + } + }, + RECONNECT_INITIAL, + RECONNECT_MAX, + )); } OpenedTransport::Tcp(stream) => { - tokio::spawn(run_transport( + let radio_cfg = cfg.radio.clone(); + tokio::spawn(run_transport_supervised( stream, - backend.clone(), - state.clone(), raw_rx, + backend_t, + state_t, + push_link, + move || { + let radio_cfg = radio_cfg.clone(); + async move { + open_radio_tcp(&radio_cfg) + .await + .map_err(|e| BackendError::Transport(e.to_string())) + } + }, + RECONNECT_INITIAL, + RECONNECT_MAX, )); } } } - let push_link = link.clone(); let radio = spawn_scheduler(backend.clone(), link, state.clone()); - let native_push_active = if cfg.events.native_push { - enable_native_push(&backend, &push_link).await - } else { - false - }; spawn_poller( radio.clone(), state.clone(), diff --git a/crates/cathub/src/radio/mod.rs b/crates/cathub/src/radio/mod.rs index 15d485f..d368c85 100644 --- a/crates/cathub/src/radio/mod.rs +++ b/crates/cathub/src/radio/mod.rs @@ -9,6 +9,7 @@ //! operation by priority across the ready heads, never reordering one face's stream. use std::collections::VecDeque; +use std::future::Future; use std::sync::Arc; use std::time::Duration; @@ -16,6 +17,7 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::sync::{mpsc, oneshot}; use crate::backend::{BackendError, Framing, RadioBackend}; +use crate::events::enable_native_push; use crate::model::{RadioEventSource, StateMutation}; use crate::state::StateHandle; @@ -144,6 +146,28 @@ fn frame_matches(frame: &[u8], verbs: &[Vec]) -> bool { verbs.iter().any(|v| frame.starts_with(v)) } +/// Why the byte transport stopped, and whether the link should reconnect. +pub(crate) enum TransportOutcome { + /// The command channel closed (every [`RadioLink`] was dropped): the daemon is shutting + /// down, so the supervisor must stop and not reopen the radio. + Shutdown, + /// The byte transport closed or errored (serial unplug, radio power-cycle, write error). + /// The command receiver is handed back so the supervisor can reopen and resume serving + /// queued commands without dropping the rest of the daemon. + Disconnected(mpsc::Receiver), +} + +/// Internal reason a [`run_transport`] loop exited, before the receiver is reattached. +enum ExitReason { + Shutdown, + Disconnected, +} + +/// Default backoff before the first reconnect attempt after a transport drop. +pub(crate) const RECONNECT_INITIAL: Duration = Duration::from_millis(500); +/// Ceiling for the exponential reconnect backoff. +pub(crate) const RECONNECT_MAX: Duration = Duration::from_secs(5); + /// Run the transport task to completion (until the stream closes or errors). /// /// `transport` is any duplex byte stream (a serial port, a TCP socket, or an in-memory @@ -154,7 +178,8 @@ pub(crate) async fn run_transport( backend: Arc, state: StateHandle, mut raw_rx: mpsc::Receiver, -) where +) -> TransportOutcome +where T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + 'static, { let framing = backend.capabilities().framing; @@ -181,14 +206,16 @@ pub(crate) async fn run_transport( }); let mut pending: Option<(Matcher, ReplyTx)> = None; + let reason: ExitReason; loop { if pending.is_none() { tokio::select! { cmd = raw_rx.recv() => { - let Some(cmd) = cmd else { break }; + let Some(cmd) = cmd else { reason = ExitReason::Shutdown; break }; if let Err(e) = writer.write_all(&cmd.bytes).await { let _ = cmd.reply.send(Err(BackendError::Transport(e.to_string()))); + reason = ExitReason::Disconnected; break; } let _ = writer.flush().await; @@ -213,7 +240,7 @@ pub(crate) async fn run_transport( } } frame = frame_rx.recv() => { - let Some(frame) = frame else { break }; + let Some(frame) = frame else { reason = ExitReason::Disconnected; break }; tracing::trace!(rx = %String::from_utf8_lossy(&frame), "radio rx (idle)"); route_event(&backend, &state, &frame); } @@ -235,7 +262,10 @@ pub(crate) async fn run_transport( let _ = reply.send(Err(BackendError::Timeout)); } } - Ok(None) => break, + Ok(None) => { + reason = ExitReason::Disconnected; + break; + } Ok(Some(frame)) => { tracing::trace!(rx = %String::from_utf8_lossy(&frame), "radio rx (pending)"); pending = match pending.take() { @@ -275,6 +305,80 @@ pub(crate) async fn run_transport( let _ = reply.send(Err(BackendError::Transport("transport closed".into()))); } reader_task.abort(); + match reason { + ExitReason::Shutdown => TransportOutcome::Shutdown, + ExitReason::Disconnected => TransportOutcome::Disconnected(raw_rx), + } +} + +/// Supervise a radio transport: run it, and when it drops (serial unplug, radio power-cycle, +/// write error) reopen it with capped exponential backoff and resume serving the same command +/// queue. Without this, a single transport hiccup would leave every client (HDSDR/OmniRig, +/// N1MM, WSJT-X, Log4OM, the engine) connected to a dead radio link until the whole daemon was +/// restarted. The loop ends only when the command channel closes (daemon shutdown). +/// +/// `first` is the already-open transport from startup. `reopen` produces a fresh transport of +/// the same kind on each reconnect. When `push_link` is `Some`, the radio's native push stream +/// is re-armed after every (re)connect, because a power-cycled radio forgets its auto-info +/// state (design §8.4: "At startup and on reconnect"). +#[expect( + clippy::too_many_arguments, + reason = "transport, queue, backend, state, push-link, reopen, and two backoff bounds are all distinct supervision inputs" +)] +pub(crate) async fn run_transport_supervised( + first: T, + mut raw_rx: mpsc::Receiver, + backend: Arc, + state: StateHandle, + push_link: Option, + mut reopen: F, + backoff_initial: Duration, + backoff_max: Duration, +) where + T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + 'static, + F: FnMut() -> Fut + Send + 'static, + Fut: Future> + Send, +{ + let mut transport = Some(first); + let mut backoff = backoff_initial; + loop { + let t = match transport.take() { + Some(t) => t, + None => match reopen().await { + Ok(t) => { + tracing::info!("radio transport reconnected"); + backoff = backoff_initial; + t + } + Err(error) => { + tracing::warn!(%error, ?backoff, "radio reopen failed; retrying"); + tokio::time::sleep(backoff).await; + backoff = (backoff * 2).min(backoff_max); + continue; + } + }, + }; + + // Re-arm the radio's native push now that a transport is live. This is spawned because + // it submits through the same command queue that `run_transport` (started just below) + // services; awaiting it here would deadlock against a not-yet-running transport. + if let Some(link) = &push_link { + let backend = backend.clone(); + let link = link.clone(); + tokio::spawn(async move { + enable_native_push(&backend, &link).await; + }); + } + + match run_transport(t, backend.clone(), state.clone(), raw_rx).await { + TransportOutcome::Shutdown => return, + TransportOutcome::Disconnected(rx) => { + raw_rx = rx; + tracing::warn!("radio transport closed; reconnecting"); + tokio::time::sleep(backoff).await; + } + } + } } /// Route an unsolicited frame into the universal state as a native push event. @@ -450,4 +554,115 @@ mod tests { assert!(Priority::Write < Priority::Read); assert!(Priority::Read < Priority::Poll); } + + #[tokio::test] + async fn supervisor_reconnects_after_transport_drop() { + use crate::backend::kenwood::ts590::Ts590Backend; + + let backend: Arc = Arc::new(Ts590Backend::new()); + let state = StateHandle::new(); + let (link, raw_rx) = link_channel(); + + // First transport: a duplex whose client end we drop to force a disconnect. + let (radio1, server1) = tokio::io::duplex(1024); + // Second transport: handed out by `reopen` on the first reconnect. + let (radio2, server2) = tokio::io::duplex(1024); + + let pending = Arc::new(tokio::sync::Mutex::new(Some(server2))); + let pending_for_reopen = pending.clone(); + let reopen = move || { + let pending = pending_for_reopen.clone(); + async move { + pending + .lock() + .await + .take() + .ok_or_else(|| BackendError::Transport("no more transports".into())) + } + }; + + tokio::spawn(run_transport_supervised( + server1, + raw_rx, + backend.clone(), + state.clone(), + None, + reopen, + Duration::from_millis(10), + Duration::from_millis(50), + )); + + // Kill the first transport so the supervisor must reconnect. + drop(radio1); + + // A fake radio on the reconnected transport answers an FA read. + tokio::spawn(async move { + let (mut rd, mut wr) = tokio::io::split(radio2); + let mut buf = [0u8; 64]; + loop { + let n = match rd.read(&mut buf).await { + Ok(0) | Err(_) => break, + Ok(n) => n, + }; + if buf.get(..n).unwrap_or(&[]).starts_with(b"FA") { + let _ = wr.write_all(b"FA00014047470;").await; + } + } + }); + + // After reconnection, the same link must serve commands again. + let reply = tokio::time::timeout(Duration::from_secs(2), async { + loop { + if let Ok(reply) = link + .submit(b"FA;".to_vec(), Expect::Reply(vec![b"FA".to_vec()])) + .await + { + return reply; + } + tokio::time::sleep(Duration::from_millis(15)).await; + } + }) + .await + .expect("reconnected transport should answer an FA read"); + + assert!(reply.starts_with(b"FA"), "reply should be an FA frame"); + } + + #[tokio::test] + async fn supervisor_stops_when_command_channel_closes() { + use crate::backend::kenwood::ts590::Ts590Backend; + + let backend: Arc = Arc::new(Ts590Backend::new()); + let state = StateHandle::new(); + let (link, raw_rx) = link_channel(); + + // Keep the radio side alive so the only way out is the command channel closing. + let (radio1, server1) = tokio::io::duplex(1024); + + // `reopen` should never be called on this path. + let reopen = || async { + Err::(BackendError::Transport("unexpected reopen".into())) + }; + + let handle = tokio::spawn(run_transport_supervised( + server1, + raw_rx, + backend, + state, + None, + reopen, + Duration::from_millis(10), + Duration::from_millis(50), + )); + + // Dropping the last link closes the command channel: the supervisor must exit. + drop(link); + + tokio::time::timeout(Duration::from_secs(2), handle) + .await + .expect("supervisor should exit when the command channel closes") + .expect("supervisor task should not panic"); + + drop(radio1); + } } diff --git a/docs/integration/setup.md b/docs/integration/setup.md index ca142e6..6cdfde2 100644 --- a/docs/integration/setup.md +++ b/docs/integration/setup.md @@ -234,10 +234,14 @@ transmitter from automation. Watch `Get-CatHubLog.ps1 -Follow` throughout. ## 8. Known v1 limitations -- **No automatic radio reconnect yet.** If the radio transport drops mid-session (USB - unplugged, radio powered off), the daemon does not yet retry the serial link or serve a - `stale` flag (design §8.7). Restart the hub after restoring the radio. Client faces and - NET endpoints are unaffected by this and stay up. +- **Automatic radio reconnect.** If the radio transport drops mid-session (USB unplugged, + radio powered off, cable hiccup, or a write error), the daemon now reopens the serial/TCP + link automatically with capped exponential backoff (0.5 s up to 5 s) and resumes serving the + same client command queue — you no longer need to restart the hub. On each reconnect it also + re-arms the radio's native push (auto-info) state, which a power-cycled radio forgets (design + §8.4/§8.7). Client faces and NET endpoints stay up throughout. Note: clients that hold their + own CAT session above the hub (e.g. HDSDR via OmniRig) may still need their own + OmniRig/session restart if they latched onto the dead link before the hub recovered. - **Hamlib NET bind errors surface in the log, not at startup.** A serial face that fails to open aborts startup with a clear error, but a `[[hamlib_net]]` endpoint whose bind address is already in use logs the error from its listener task rather than failing the whole From 25fd5396d3c5ecbe6b96b1bf7d68ebfc08588235 Mon Sep 17 00:00:00 2001 From: Mike Treit Date: Wed, 3 Jun 2026 14:45:30 -0700 Subject: [PATCH 14/36] Fix cathub VFO tracking (#491) * Fix cathub VFO tracking Restore trusted TS-590 active receive VFO changes while keeping Hamlib set_vfo benign for foreign CAT clients. Record full TS-590 IF native-push status so cached active-VFO frequency, mode, PTT, and split state stay current, and notify TS-590/TS-2000 faces with synthesized IF status when the active VFO changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix TS-590 active VFO event tracking Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix HDSDR tuning on active VFO B Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Randy Treit Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/cathub/src/backend/kenwood/ts590.rs | 229 ++++++++++++-- crates/cathub/src/backend/mod.rs | 12 +- crates/cathub/src/dialect/kenwood/ts2000.rs | 109 ++++++- crates/cathub/src/dialect/kenwood/ts590.rs | 1 + crates/cathub/src/hamlib_net.rs | 27 ++ crates/cathub/src/integration.rs | 30 ++ crates/cathub/src/radio/mod.rs | 21 +- crates/cathub/tests/live_ts590.rs | 321 ++++++++++++++++++++ docs/integration/setup.md | 10 +- 9 files changed, 733 insertions(+), 27 deletions(-) create mode 100644 crates/cathub/tests/live_ts590.rs diff --git a/crates/cathub/src/backend/kenwood/ts590.rs b/crates/cathub/src/backend/kenwood/ts590.rs index 1e481e3..bd84058 100644 --- a/crates/cathub/src/backend/kenwood/ts590.rs +++ b/crates/cathub/src/backend/kenwood/ts590.rs @@ -69,6 +69,14 @@ fn parse_u64(bytes: &[u8]) -> Option { std::str::from_utf8(bytes).ok()?.trim().parse::().ok() } +fn parse_rx_vfo_digit(bytes: &[u8]) -> Option { + match *bytes.first()? { + b'0' => Some(Vfo::A), + b'1' => Some(Vfo::B), + _ => None, + } +} + fn parse_if_status(frame: &[u8]) -> Option { let (verb, payload) = split_frame(frame)?; if verb != b"IF" { @@ -90,36 +98,30 @@ fn parse_if_status(frame: &[u8]) -> Option { }) } -fn record_if_status(state: &StateHandle, status: IfStatus) { - state.record( - StateChange::RxVfo { vfo: status.rx_vfo }, - RadioEventSource::PollDiff, - ); +fn record_if_status(state: &StateHandle, status: IfStatus, source: RadioEventSource) { state.record( StateChange::Freq { vfo: status.rx_vfo, hz: status.freq_hz, }, - RadioEventSource::PollDiff, + source, ); state.record( StateChange::Mode { vfo: status.rx_vfo, mode: status.mode, }, - RadioEventSource::PollDiff, - ); - state.record( - StateChange::Ptt { keyed: status.ptt }, - RadioEventSource::PollDiff, + source, ); + state.record(StateChange::Ptt { keyed: status.ptt }, source); state.record( StateChange::Split { enabled: status.split, tx_vfo: None, }, - RadioEventSource::PollDiff, + source, ); + state.record(StateChange::RxVfo { vfo: status.rx_vfo }, source); } /// Build an `FA`/`FB` frequency set frame. @@ -131,6 +133,15 @@ fn freq_set(vfo: Vfo, hz: u64) -> Vec { format!("{verb}{hz:011};").into_bytes() } +/// Build an `FR` receive-VFO select frame. +fn rx_vfo_set(vfo: Vfo) -> Vec { + let digit = match vfo { + Vfo::A => b'0', + Vfo::B => b'1', + }; + vec![b'F', b'R', digit, b';'] +} + /// Whether a passthrough frame is a query (bare verb then `;`) versus a set. fn is_query(frame: &[u8]) -> bool { matches!(split_frame(frame), Some((_, payload)) if payload.is_empty()) @@ -142,11 +153,7 @@ impl RadioBackend for Ts590Backend { for &cmd in POLL_COMMANDS { let verb = cmd.get(..2).unwrap_or(cmd).to_vec(); let reply = link.submit(cmd.to_vec(), Expect::Reply(vec![verb])).await?; - if let Some(status) = parse_if_status(&reply) { - record_if_status(state, status); - } else if let Some(mutation) = self.parse_event(&reply) { - state.record(mutation.into_change(), RadioEventSource::PollDiff); - } + let _ = self.record_event(&reply, state, RadioEventSource::PollDiff); } Ok(()) } @@ -158,7 +165,18 @@ impl RadioBackend for Ts590Backend { state: &StateHandle, ) -> Result<(), BackendError> { let bytes = match mutation { - StateMutation::SetRxVfo { .. } => return Err(BackendError::Unsupported), + StateMutation::SetRxVfo { vfo } => { + let mut bytes = rx_vfo_set(vfo); + let snap = state.snapshot(); + if snap.split { + let tx = match snap.tx_vfo { + Vfo::A => b'0', + Vfo::B => b'1', + }; + bytes.extend_from_slice(&[b'F', b'T', tx, b';']); + } + bytes + } StateMutation::SetVfoFreq { vfo, hz } => freq_set(vfo, hz), StateMutation::SetMode { mode, .. } => { vec![b'M', b'D', mode.to_kenwood_digit(), b';'] @@ -250,6 +268,54 @@ impl RadioBackend for Ts590Backend { } } + fn record_event(&self, frame: &[u8], state: &StateHandle, source: RadioEventSource) -> bool { + if let Some(status) = parse_if_status(frame) { + record_if_status(state, status, source); + return true; + } + + let Some((verb, payload)) = split_frame(frame) else { + return false; + }; + match verb { + b"FA" => parse_u64(payload).is_some_and(|hz| { + state.record(StateChange::Freq { vfo: Vfo::A, hz }, source); + true + }), + b"FB" => parse_u64(payload).is_some_and(|hz| { + state.record(StateChange::Freq { vfo: Vfo::B, hz }, source); + true + }), + b"FR" => parse_rx_vfo_digit(payload).is_some_and(|vfo| { + state.record(StateChange::RxVfo { vfo }, source); + true + }), + b"MD" => payload.first().is_some_and(|digit| { + let vfo = state.snapshot().rx_vfo; + state.record( + StateChange::Mode { + vfo, + mode: Mode::from_kenwood_digit(*digit), + }, + source, + ); + true + }), + b"DA" => payload.first().is_some_and(|digit| { + let vfo = state.snapshot().rx_vfo; + state.record( + StateChange::DataMode { + vfo, + on: *digit == b'1', + }, + source, + ); + true + }), + _ => false, + } + } + async fn passthrough(&self, raw: &[u8], link: &RadioLink) -> Result, BackendError> { let expect = if is_query(raw) { let verb = split_frame(raw) @@ -373,6 +439,32 @@ mod tests { ); } + #[test] + fn native_fr_then_md_records_mode_on_active_vfo() { + let backend = Ts590Backend::new(); + let state = StateHandle::new(); + state.record( + StateChange::Freq { + vfo: Vfo::B, + hz: 14_034_320, + }, + RadioEventSource::PollDiff, + ); + + assert!(backend.record_event(b"FR1;", &state, RadioEventSource::NativePush)); + assert!(backend.record_event(b"MD3;", &state, RadioEventSource::NativePush)); + + let snap = state.snapshot(); + assert_eq!(snap.rx_vfo, Vfo::B); + assert_eq!(snap.vfo(Vfo::B).freq_hz, 14_034_320); + assert_eq!(snap.vfo(Vfo::B).mode, Mode::Cw); + assert_eq!( + snap.vfo(Vfo::A).mode, + Mode::Usb, + "VFO B mode push must not be recorded against VFO A" + ); + } + #[test] fn capabilities_are_certified_native_with_push() { let caps = Ts590Backend::new().capabilities(); @@ -412,6 +504,56 @@ mod tests { assert_eq!(state.snapshot().vfo(Vfo::A).freq_hz, 7_030_000); } + #[tokio::test] + async fn apply_rx_vfo_writes_fr_and_updates_state() { + let (link, raw_rx) = link_channel(); + let backend = Arc::new(Ts590Backend::new()); + let arc: Arc = backend.clone(); + let state = StateHandle::new(); + let (mut radio_side, server) = tokio::io::duplex(1024); + tokio::spawn(run_transport(server, arc, state.clone(), raw_rx)); + + backend + .apply(StateMutation::SetRxVfo { vfo: Vfo::B }, &link, &state) + .await + .expect("apply"); + + let mut buf = vec![0u8; 4]; + radio_side.read_exact(&mut buf).await.expect("read frame"); + assert_eq!(&buf, b"FR1;"); + assert_eq!(state.snapshot().rx_vfo, Vfo::B); + } + + #[tokio::test] + async fn apply_rx_vfo_preserves_split_tx_vfo() { + let (link, raw_rx) = link_channel(); + let backend = Arc::new(Ts590Backend::new()); + let arc: Arc = backend.clone(); + let state = StateHandle::new(); + state.record( + StateChange::Split { + enabled: true, + tx_vfo: Some(Vfo::B), + }, + RadioEventSource::PollDiff, + ); + let (mut radio_side, server) = tokio::io::duplex(1024); + tokio::spawn(run_transport(server, arc, state.clone(), raw_rx)); + + backend + .apply(StateMutation::SetRxVfo { vfo: Vfo::A }, &link, &state) + .await + .expect("apply"); + + let mut buf = vec![0u8; 8]; + radio_side.read_exact(&mut buf).await.expect("read frame"); + assert_eq!(&buf, b"FR0;FT1;"); + let snap = state.snapshot(); + assert_eq!(snap.rx_vfo, Vfo::A); + assert!(snap.split); + assert_eq!(snap.tx_vfo, Vfo::B); + } + #[tokio::test] async fn apply_ptt_keys_and_unkeys() { let (link, raw_rx) = link_channel(); @@ -543,7 +685,56 @@ mod tests { let snap = state.snapshot(); assert_eq!(snap.rx_vfo, Vfo::B); assert_eq!(snap.vfo(snap.rx_vfo).freq_hz, 14_034_320); - assert_eq!(snap.vfo(snap.rx_vfo).mode, Mode::Usb); + assert_eq!(snap.vfo(snap.rx_vfo).mode, Mode::Cw); + } + + #[tokio::test] + async fn poll_records_md_and_da_on_active_vfo() { + let (link, raw_rx) = link_channel(); + let backend = Arc::new(Ts590Backend::new()); + let arc: Arc = backend.clone(); + let state = StateHandle::new(); + let (radio_side, server) = tokio::io::duplex(1024); + tokio::spawn(run_transport(server, arc, state.clone(), raw_rx)); + + tokio::spawn(async move { + let (mut rd, mut wr) = tokio::io::split(radio_side); + let mut frame = Vec::new(); + let mut byte = [0u8; 1]; + loop { + if rd.read(&mut byte).await.unwrap_or(0) == 0 { + break; + } + frame.push(byte[0]); + if byte[0] == b';' { + let answer: &[u8] = match frame.as_slice() { + b"FA;" => b"FA00003020950;", + b"FB;" => b"FB00014034320;", + b"IF;" => b"IF000140343201234-0000012345021009999;", + b"MD;" => b"MD3;", + b"DA;" => b"DA1;", + _ => b"", + }; + let _ = wr.write_all(answer).await; + frame.clear(); + } + } + }); + + backend.poll(&link, &state).await.expect("poll"); + let snap = state.snapshot(); + assert_eq!(snap.rx_vfo, Vfo::B); + assert_eq!(snap.vfo(Vfo::B).mode, Mode::Cw); + assert!(snap.vfo(Vfo::B).data); + assert_eq!( + snap.vfo(Vfo::A).mode, + Mode::Usb, + "active VFO B MD reply must not update VFO A" + ); + assert!( + !snap.vfo(Vfo::A).data, + "active VFO B DA reply must not update VFO A" + ); } #[tokio::test] diff --git a/crates/cathub/src/backend/mod.rs b/crates/cathub/src/backend/mod.rs index 5d48b82..d01c5ba 100644 --- a/crates/cathub/src/backend/mod.rs +++ b/crates/cathub/src/backend/mod.rs @@ -12,7 +12,7 @@ pub(crate) mod rigctld; use async_trait::async_trait; pub(crate) use crate::error::BackendError; -use crate::model::StateMutation; +use crate::model::{RadioEventSource, StateMutation}; use crate::radio::RadioLink; use crate::state::StateHandle; @@ -132,6 +132,16 @@ pub(crate) trait RadioBackend: Send + Sync { /// Parse an unsolicited (native push) frame into a mutation, if recognized. fn parse_event(&self, frame: &[u8]) -> Option; + /// Record an unsolicited (native push) frame into the universal state, if recognized. + fn record_event(&self, frame: &[u8], state: &StateHandle, source: RadioEventSource) -> bool { + if let Some(mutation) = self.parse_event(frame) { + state.record(mutation.into_change(), source); + true + } else { + false + } + } + /// Forward a raw native command, returning the raw reply. async fn passthrough(&self, raw: &[u8], link: &RadioLink) -> Result, BackendError>; diff --git a/crates/cathub/src/dialect/kenwood/ts2000.rs b/crates/cathub/src/dialect/kenwood/ts2000.rs index 999a603..69e85ac 100644 --- a/crates/cathub/src/dialect/kenwood/ts2000.rs +++ b/crates/cathub/src/dialect/kenwood/ts2000.rs @@ -57,7 +57,9 @@ impl ClientDialect for Ts2000Dialect { if read { return freq_frame(b"FA", ctx.snapshot().vfo(Vfo::A).freq_hz); } - set_freq(ctx, Vfo::A, &payload).await + // OmniRig/HDSDR uses FA writes for the displayed tune target. Preserve the + // no-retargeting invariant by applying that tune to the currently active VFO. + set_freq(ctx, ctx.snapshot().rx_vfo, &payload).await } b"FB" => { if read { @@ -67,16 +69,18 @@ impl ClientDialect for Ts2000Dialect { } b"MD" => { if read { - let d = mode_to_digit(ctx.snapshot().vfo(Vfo::A).mode); + let snap = ctx.snapshot(); + let d = mode_to_digit(snap.vfo(snap.rx_vfo).mode); return vec![b'M', b'D', d, b';']; } let Some(&d) = payload.first() else { return ERR.to_vec(); }; + let vfo = ctx.snapshot().rx_vfo; reply( ctx.apply_modeled( StateMutation::SetMode { - vfo: Vfo::A, + vfo, mode: mode_from_digit(d), }, CommandClass::ModeledWrite, @@ -119,6 +123,7 @@ impl ClientDialect for Ts2000Dialect { StateChange::Mode { vfo: Vfo::A, mode } => { Some(vec![b'M', b'D', mode_to_digit(mode), b';']) } + StateChange::RxVfo { .. } => Some(synth_if(&ctx.snapshot())), _ => None, } } @@ -206,6 +211,41 @@ mod tests { assert!(backend.mutations().is_empty()); } + #[tokio::test] + async fn rx_vfo_change_notifies_with_current_if_status() { + let (ctx, _backend) = ctx_with(FacePermissions::read_only()); + ctx.set_ai(true); + ctx.state.record( + StateChange::Freq { + vfo: Vfo::B, + hz: 14_034_320, + }, + RadioEventSource::NativePush, + ); + ctx.state.record( + StateChange::Mode { + vfo: Vfo::B, + mode: crate::model::Mode::Usb, + }, + RadioEventSource::NativePush, + ); + ctx.state.record( + StateChange::RxVfo { vfo: Vfo::B }, + RadioEventSource::NativePush, + ); + + let notification = Ts2000Dialect::new() + .format_notification(&StateChange::RxVfo { vfo: Vfo::B }, &ctx) + .expect("IF notification"); + + assert!(notification.starts_with(b"IF00014034320")); + assert_eq!( + *notification.get(30).expect("VFO field"), + b'1', + "TS-2000 IF VFO field should report VFO B" + ); + } + #[tokio::test] async fn answers_id_as_ts2000() { let (ctx, _b) = ctx_with(FacePermissions::read_only()); @@ -246,4 +286,67 @@ mod tests { b"FA00021200000;".to_vec() ); } + + #[tokio::test] + async fn fa_write_tunes_active_vfo_b() { + let (ctx, backend) = ctx_with(FacePermissions::from_tokens(&["read", "write"])); + ctx.state.record( + StateChange::RxVfo { vfo: Vfo::B }, + RadioEventSource::NativePush, + ); + + assert_eq!( + Ts2000Dialect::new().handle(b"FA00014074000;", &ctx).await, + Vec::::new() + ); + tokio::time::sleep(Duration::from_millis(20)).await; + + assert_eq!( + backend.mutations(), + vec![StateMutation::SetVfoFreq { + vfo: Vfo::B, + hz: 14_074_000 + }], + "HDSDR/OmniRig FA set-frequency writes express the displayed active frequency" + ); + } + + #[tokio::test] + async fn md_read_and_write_use_active_vfo_b() { + let (ctx, backend) = ctx_with(FacePermissions::from_tokens(&["read", "write"])); + ctx.state.record( + StateChange::Mode { + vfo: Vfo::A, + mode: crate::model::Mode::Lsb, + }, + RadioEventSource::NativePush, + ); + ctx.state.record( + StateChange::Mode { + vfo: Vfo::B, + mode: crate::model::Mode::Cw, + }, + RadioEventSource::NativePush, + ); + ctx.state.record( + StateChange::RxVfo { vfo: Vfo::B }, + RadioEventSource::NativePush, + ); + + assert_eq!(Ts2000Dialect::new().handle(b"MD;", &ctx).await, b"MD3;"); + assert_eq!( + Ts2000Dialect::new().handle(b"MD2;", &ctx).await, + Vec::::new() + ); + tokio::time::sleep(Duration::from_millis(20)).await; + + assert_eq!( + backend.mutations(), + vec![StateMutation::SetMode { + vfo: Vfo::B, + mode: crate::model::Mode::Usb + }], + "HDSDR/OmniRig MD writes express the displayed active mode" + ); + } } diff --git a/crates/cathub/src/dialect/kenwood/ts590.rs b/crates/cathub/src/dialect/kenwood/ts590.rs index 49be19d..eb48a88 100644 --- a/crates/cathub/src/dialect/kenwood/ts590.rs +++ b/crates/cathub/src/dialect/kenwood/ts590.rs @@ -137,6 +137,7 @@ impl ClientDialect for Ts590Dialect { StateChange::Mode { vfo: Vfo::A, mode } => { Some(vec![b'M', b'D', mode_to_digit(mode), b';']) } + StateChange::RxVfo { .. } => Some(synth_if(&ctx.snapshot())), _ => None, } } diff --git a/crates/cathub/src/hamlib_net.rs b/crates/cathub/src/hamlib_net.rs index 14df72f..a842589 100644 --- a/crates/cathub/src/hamlib_net.rs +++ b/crates/cathub/src/hamlib_net.rs @@ -569,6 +569,33 @@ mod tests { assert_eq!(reply_of("m", &ctx).await, b"CW\n2400\n".to_vec()); } + #[tokio::test] + async fn get_freq_and_mode_follow_active_vfo_b() { + let (ctx, _b, state) = ctx_with(FacePermissions::read_only()); + state.record( + StateChange::Freq { + vfo: Vfo::B, + hz: 14_034_320, + }, + RadioEventSource::PollDiff, + ); + state.record( + StateChange::Mode { + vfo: Vfo::B, + mode: Mode::Cw, + }, + RadioEventSource::PollDiff, + ); + state.record( + StateChange::RxVfo { vfo: Vfo::B }, + RadioEventSource::PollDiff, + ); + + assert_eq!(reply_of("f", &ctx).await, b"14034320\n".to_vec()); + assert_eq!(reply_of("m", &ctx).await, b"CW\n2400\n".to_vec()); + assert_eq!(reply_of("v", &ctx).await, b"VFOB\n".to_vec()); + } + #[tokio::test] async fn read_only_endpoint_rejects_set_freq() { let (ctx, backend, _s) = ctx_with(FacePermissions::read_only()); diff --git a/crates/cathub/src/integration.rs b/crates/cathub/src/integration.rs index 80f59c3..6adebc2 100644 --- a/crates/cathub/src/integration.rs +++ b/crates/cathub/src/integration.rs @@ -24,6 +24,7 @@ use crate::dialect::kenwood::ts2000::Ts2000Dialect; use crate::dialect::kenwood::ts590::Ts590Dialect; use crate::dialect::{ClientDialect, FaceContext}; use crate::hamlib_net::serve_conn; +use crate::model::{RadioEventSource, StateChange, StateMutation, Vfo}; use crate::permissions::FacePermissions; use crate::ptt::PttManager; use crate::radio::{detached_link, spawn_scheduler, OpKind, Priority, RadioHandle}; @@ -173,6 +174,35 @@ async fn ts2000_face_never_retargets_vfo() { assert!(rig.backend.passthroughs().is_empty()); } +/// HDSDR/OmniRig click-to-tune uses the TS-2000 face's FA write as "set the displayed +/// active frequency". When the real radio is on VFO B, that write must tune VFO B without +/// emitting a VFO-retarget command. +#[tokio::test] +async fn ts2000_face_fa_write_tunes_active_vfo_b() { + let rig = rig(); + let mut omni = rig.face( + ts2000(), + FacePermissions::from_tokens(&["read", "write"]), + 1, + ); + rig.state.record( + StateChange::RxVfo { vfo: Vfo::B }, + RadioEventSource::NativePush, + ); + + omni.write_all(b"FA00014074000;").await.expect("write"); + tokio::time::sleep(Duration::from_millis(30)).await; + + assert_eq!( + rig.backend.mutations(), + vec![StateMutation::SetVfoFreq { + vfo: Vfo::B, + hz: 14_074_000 + }] + ); + assert!(rig.backend.passthroughs().is_empty()); +} + /// A simulated front-panel change (a poll-diff from the backend's truth) fans out to every /// auto-info-subscribed face without any client having polled. #[tokio::test] diff --git a/crates/cathub/src/radio/mod.rs b/crates/cathub/src/radio/mod.rs index d368c85..0426f83 100644 --- a/crates/cathub/src/radio/mod.rs +++ b/crates/cathub/src/radio/mod.rs @@ -388,9 +388,7 @@ pub(crate) async fn run_transport_supervised( /// faces (which consume the CAT stream directly) keep features like the radio's noise /// blanker and front-panel changes in sync. fn route_event(backend: &Arc, state: &StateHandle, frame: &[u8]) { - if let Some(mutation) = backend.parse_event(frame) { - state.record(mutation.into_change(), RadioEventSource::NativePush); - } else { + if !backend.record_event(frame, state, RadioEventSource::NativePush) { tracing::trace!( frame = %String::from_utf8_lossy(frame), "relaying unmodeled unsolicited radio frame to native pass-through faces" @@ -524,6 +522,8 @@ async fn execute( )] mod tests { use super::*; + use crate::backend::kenwood::ts590::Ts590Backend; + use crate::model::{Mode, Vfo}; #[test] fn framer_splits_on_semicolons() { @@ -548,6 +548,21 @@ mod tests { assert!(!frame_matches(b"MD3;", &[b"FA".to_vec()])); } + #[test] + fn route_event_records_full_ts590_if_status() { + let backend: Arc = Arc::new(Ts590Backend::new()); + let state = StateHandle::new(); + + route_event(&backend, &state, b"IF000140343201234-0000012345121019999;"); + + let snap = state.snapshot(); + assert_eq!(snap.rx_vfo, Vfo::B); + assert_eq!(snap.vfo(Vfo::B).freq_hz, 14_034_320); + assert_eq!(snap.vfo(Vfo::B).mode, Mode::Usb); + assert!(snap.ptt); + assert!(snap.split); + } + #[test] fn priority_orders_ptt_first() { assert!(Priority::Ptt < Priority::Write); diff --git a/crates/cathub/tests/live_ts590.rs b/crates/cathub/tests/live_ts590.rs new file mode 100644 index 0000000..5b2c627 --- /dev/null +++ b/crates/cathub/tests/live_ts590.rs @@ -0,0 +1,321 @@ +//! Opt-in live TS-590 hardware tests. +//! +//! These tests are ignored by default and only touch hardware when +//! `QSORIPPER_CATHUB_LIVE_TS590=1` is set. They start the built cathub binary against the +//! configured radio serial port, query its Hamlib endpoint, and then stop the process. + +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use std::io::{BufRead, BufReader, Write}; +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + +const LIVE_FLAG: &str = "QSORIPPER_CATHUB_LIVE_TS590"; +const INTERACTIVE_FLAG: &str = "QSORIPPER_CATHUB_LIVE_INTERACTIVE"; +const PORT_ENV: &str = "QSORIPPER_CATHUB_LIVE_PORT"; +const BAUD_ENV: &str = "QSORIPPER_CATHUB_LIVE_BAUD"; +const DEFAULT_PORT: &str = "COM3"; +const DEFAULT_BAUD: u32 = 115_200; + +#[derive(Debug, Clone, PartialEq, Eq)] +struct Snapshot { + vfo: String, + freq_hz: u64, + mode: String, + passband_hz: u32, +} + +struct LiveCathub { + child: Child, + config_path: PathBuf, + read_only_addr: SocketAddr, +} + +impl Drop for LiveCathub { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + let _ = std::fs::remove_file(&self.config_path); + } +} + +fn live_enabled() -> bool { + std::env::var(LIVE_FLAG).is_ok_and(|value| value == "1" || value.eq_ignore_ascii_case("true")) +} + +fn interactive_enabled() -> bool { + std::env::var(INTERACTIVE_FLAG) + .is_ok_and(|value| value == "1" || value.eq_ignore_ascii_case("true")) +} + +fn free_loopback_addr() -> SocketAddr { + let listener = TcpListener::bind("127.0.0.1:0").expect("allocate free loopback port"); + listener.local_addr().expect("local address") +} + +fn temp_config_path(tag: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "qsoripper-cathub-live-{tag}-{}-{}.toml", + std::process::id(), + Instant::now().elapsed().as_nanos() + )) +} + +fn write_live_config(read_only_addr: SocketAddr, read_write_addr: SocketAddr) -> PathBuf { + let port = std::env::var(PORT_ENV).unwrap_or_else(|_| DEFAULT_PORT.to_string()); + let baud = std::env::var(BAUD_ENV) + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(DEFAULT_BAUD); + let path = temp_config_path("ts590"); + let text = format!( + r#"[radio] +backend = "ts590" +transport = "serial" +port = "{port}" +baud = {baud} + +[poll] +baseline_ms = 100 +heartbeat_ms = 1000 + +[ptt] +max_tx_ms = 300000 + +[events] +native_push = true + +[[hamlib_net]] +name = "engine-readonly" +bind = "{read_only_addr}" +perms = ["read"] + +[[hamlib_net]] +name = "live-readwrite" +bind = "{read_write_addr}" +perms = ["read", "write"] +"# + ); + std::fs::write(&path, text).expect("write live cathub config"); + path +} + +fn start_live_cathub() -> Option { + if !live_enabled() { + eprintln!("Skipping live TS-590 test; set {LIVE_FLAG}=1 to enable hardware access."); + return None; + } + + let read_only_addr = free_loopback_addr(); + let read_write_addr = free_loopback_addr(); + let config_path = write_live_config(read_only_addr, read_write_addr); + let binary = env!("CARGO_BIN_EXE_qsoripper-cathub"); + let child = Command::new(binary) + .arg("--config") + .arg(&config_path) + .env("CATHUB_LOG", "qsoripper_cathub=debug") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::inherit()) + .spawn() + .expect("start qsoripper-cathub"); + + let mut hub = LiveCathub { + child, + config_path, + read_only_addr, + }; + wait_for_endpoint(&mut hub).expect("wait for cathub Hamlib endpoint"); + Some(hub) +} + +fn wait_for_endpoint(hub: &mut LiveCathub) -> Result<(), String> { + let deadline = Instant::now() + Duration::from_secs(8); + loop { + if TcpStream::connect(hub.read_only_addr).is_ok() { + return Ok(()); + } + if let Some(status) = hub.child.try_wait().expect("check cathub process") { + return Err(format!( + "cathub exited before Hamlib endpoint {} became reachable: {status}", + hub.read_only_addr + )); + } + assert!( + Instant::now() < deadline, + "cathub Hamlib endpoint {} did not become reachable", + hub.read_only_addr + ); + std::thread::sleep(Duration::from_millis(100)); + } +} + +fn rigctl_lines(addr: SocketAddr, command: &str, expected_lines: usize) -> Vec { + let mut stream = TcpStream::connect(addr).expect("connect to cathub hamlib endpoint"); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .expect("set read timeout"); + stream + .write_all(format!("{command}\n").as_bytes()) + .expect("write rigctl command"); + stream.flush().expect("flush rigctl command"); + + let mut reader = BufReader::new(stream); + let mut lines = Vec::with_capacity(expected_lines); + for _ in 0..expected_lines { + let mut line = String::new(); + reader.read_line(&mut line).expect("read rigctl line"); + assert!( + !line.is_empty(), + "cathub closed connection before replying to {command}" + ); + lines.push(line.trim().to_string()); + } + lines +} + +fn snapshot(addr: SocketAddr) -> Snapshot { + let vfo = rigctl_lines(addr, "v", 1).remove(0); + let freq_hz = rigctl_lines(addr, "f", 1) + .remove(0) + .parse::() + .expect("frequency is integer Hz"); + let mode_lines = rigctl_lines(addr, "m", 2); + let mode = mode_lines.first().expect("mode line").clone(); + let passband_hz = mode_lines + .get(1) + .expect("passband line") + .parse::() + .expect("passband is integer Hz"); + Snapshot { + vfo, + freq_hz, + mode, + passband_hz, + } +} + +fn vfo_info(addr: SocketAddr, vfo: &str) -> Snapshot { + let lines = rigctl_lines(addr, &format!("\\get_vfo_info {vfo}"), 5); + Snapshot { + vfo: vfo.to_string(), + freq_hz: lines + .first() + .expect("VFO frequency line") + .parse::() + .expect("VFO frequency is Hz"), + mode: lines.get(1).expect("VFO mode line").clone(), + passband_hz: lines + .get(2) + .expect("VFO passband line") + .parse::() + .expect("VFO passband is Hz"), + } +} + +fn wait_for_snapshot( + addr: SocketAddr, + predicate: impl Fn(&Snapshot) -> bool, + description: &str, +) -> Snapshot { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let snap = snapshot(addr); + if predicate(&snap) { + return snap; + } + assert!( + Instant::now() < deadline, + "timed out waiting for {description}; last snapshot was {snap:?}" + ); + std::thread::sleep(Duration::from_millis(100)); + } +} + +fn wait_for_operator(message: &str) { + eprintln!("{message}"); + eprintln!("Press Enter when ready."); + let mut line = String::new(); + std::io::stdin() + .read_line(&mut line) + .expect("read operator confirmation"); +} + +#[test] +#[ignore = "requires a real TS-590 connected to the configured CAT port"] +fn live_ts590_startup_snapshot_is_coherent() { + let Some(hub) = start_live_cathub() else { + return; + }; + + let active = wait_for_snapshot( + hub.read_only_addr, + |snap| snap.freq_hz > 0 && (snap.vfo == "VFOA" || snap.vfo == "VFOB"), + "non-zero active VFO snapshot", + ); + let active_info = vfo_info(hub.read_only_addr, &active.vfo); + + assert_eq!( + active.freq_hz, active_info.freq_hz, + "Hamlib f must match active VFO info" + ); + assert_eq!( + active.mode, active_info.mode, + "Hamlib m must match active VFO info" + ); + assert!(active.passband_hz > 0); +} + +#[test] +#[ignore = "requires operator-assisted real TS-590 VFO switching"] +fn live_ts590_manual_vfo_switch_matrix() { + if !interactive_enabled() { + eprintln!( + "Skipping operator-assisted VFO matrix; set {INTERACTIVE_FLAG}=1 in addition to {LIVE_FLAG}=1." + ); + return; + } + let Some(hub) = start_live_cathub() else { + return; + }; + + wait_for_operator( + "Set VFO A and VFO B to different frequencies and modes, select VFO A, and wait for the dial to settle.", + ); + let a = wait_for_snapshot( + hub.read_only_addr, + |snap| snap.vfo == "VFOA" && snap.freq_hz > 0, + "active VFO A", + ); + let a_info = vfo_info(hub.read_only_addr, "VFOA"); + assert_eq!(a.freq_hz, a_info.freq_hz); + assert_eq!(a.mode, a_info.mode); + + wait_for_operator("Switch the radio to VFO B and wait for the dial to settle."); + let b = wait_for_snapshot( + hub.read_only_addr, + |snap| snap.vfo == "VFOB" && snap.freq_hz > 0 && snap.freq_hz != a.freq_hz, + "active VFO B with its own frequency", + ); + let b_info = vfo_info(hub.read_only_addr, "VFOB"); + assert_eq!(b.freq_hz, b_info.freq_hz); + assert_eq!(b.mode, b_info.mode); + assert_ne!( + a.freq_hz, b.freq_hz, + "live VFO matrix requires intentionally different A/B frequencies" + ); + assert_ne!( + a.mode, b.mode, + "live VFO matrix requires intentionally different A/B modes" + ); + + wait_for_operator("Switch the radio back to VFO A and wait for the dial to settle."); + let a_again = wait_for_snapshot( + hub.read_only_addr, + |snap| snap.vfo == "VFOA" && snap.freq_hz == a.freq_hz, + "return to VFO A frequency", + ); + assert_eq!(a_again.mode, a.mode); +} diff --git a/docs/integration/setup.md b/docs/integration/setup.md index 6cdfde2..47c8cac 100644 --- a/docs/integration/setup.md +++ b/docs/integration/setup.md @@ -23,6 +23,12 @@ Silicon Labs CP210x USB-UART bridge that fronts the TS-590's USB CAT port). Get-Process rigctld, rigctlcom -ErrorAction SilentlyContinue | Stop-Process # also stop any safe-bridge Python process and any app still bound directly to COM4 +Remove or disable any legacy startup hooks that relaunch `rigctld.exe`; otherwise it can +reclaim the radio COM port after you stop QsoRipper/cathub. Check scheduled tasks first: + + Get-ScheduledTask -TaskName QsoRipper-rigctld -ErrorAction SilentlyContinue + Unregister-ScheduledTask -TaskName QsoRipper-rigctld -Confirm:$false + Confirm nothing else holds COM4 before continuing. ## 2. Create virtual serial pairs (com0com) @@ -204,6 +210,9 @@ With the hub running and all six apps connected: Live transmit verification requires the operator and real hardware; do not key the transmitter from automation. Watch `Get-CatHubLog.ps1 -Follow` throughout. +For opt-in hardware regression tests against a real TS-590, see +`docs/integrations/cathub-live-radio-tests.md`. + ## 7. Troubleshooting - "Access denied" / port busy on COM4: the legacy chain or another app still owns the radio @@ -249,4 +258,3 @@ transmitter from automation. Watch `Get-CatHubLog.ps1 -Follow` throughout. - On shutdown (Ctrl+C) the daemon makes a best-effort `RX;` to unkey the transmitter. A hard crash cannot run that path; the `ptt_max_tx_ms` ceiling and the radio's own TX timeout are the ultimate stuck-transmitter backstops. - From 2a2b8d26246a37e3ff8047079696d5442e38206b Mon Sep 17 00:00:00 2001 From: Mike Treit Date: Wed, 3 Jun 2026 19:55:30 -0700 Subject: [PATCH 15/36] Fix HDSDR active VFO B readback (#492) Co-authored-by: Randy Treit Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/cathub/src/dialect/kenwood/ts2000.rs | 60 +++++++++++++++++++-- crates/cathub/src/integration.rs | 33 ++++++++++++ 2 files changed, 90 insertions(+), 3 deletions(-) diff --git a/crates/cathub/src/dialect/kenwood/ts2000.rs b/crates/cathub/src/dialect/kenwood/ts2000.rs index 69e85ac..2a6bb11 100644 --- a/crates/cathub/src/dialect/kenwood/ts2000.rs +++ b/crates/cathub/src/dialect/kenwood/ts2000.rs @@ -55,7 +55,8 @@ impl ClientDialect for Ts2000Dialect { b"IF" if read => synth_if(&ctx.snapshot()), b"FA" => { if read { - return freq_frame(b"FA", ctx.snapshot().vfo(Vfo::A).freq_hz); + let snap = ctx.snapshot(); + return freq_frame(b"FA", snap.vfo(snap.rx_vfo).freq_hz); } // OmniRig/HDSDR uses FA writes for the displayed tune target. Preserve the // no-retargeting invariant by applying that tune to the currently active VFO. @@ -118,9 +119,11 @@ impl ClientDialect for Ts2000Dialect { return None; } match *change { - StateChange::Freq { vfo: Vfo::A, hz } => Some(freq_frame(b"FA", hz)), + StateChange::Freq { vfo, hz } if vfo == ctx.snapshot().rx_vfo => { + Some(freq_frame(b"FA", hz)) + } StateChange::Freq { vfo: Vfo::B, hz } => Some(freq_frame(b"FB", hz)), - StateChange::Mode { vfo: Vfo::A, mode } => { + StateChange::Mode { vfo, mode } if vfo == ctx.snapshot().rx_vfo => { Some(vec![b'M', b'D', mode_to_digit(mode), b';']) } StateChange::RxVfo { .. } => Some(synth_if(&ctx.snapshot())), @@ -311,6 +314,57 @@ mod tests { ); } + #[tokio::test] + async fn fa_read_reports_active_vfo_b_frequency() { + let (ctx, _backend) = ctx_with(FacePermissions::read_only()); + ctx.state.record( + StateChange::Freq { + vfo: Vfo::A, + hz: 14_062_820, + }, + RadioEventSource::NativePush, + ); + ctx.state.record( + StateChange::Freq { + vfo: Vfo::B, + hz: 14_074_000, + }, + RadioEventSource::NativePush, + ); + ctx.state.record( + StateChange::RxVfo { vfo: Vfo::B }, + RadioEventSource::NativePush, + ); + + assert_eq!( + Ts2000Dialect::new().handle(b"FA;", &ctx).await, + b"FA00014074000;".to_vec(), + "HDSDR/OmniRig FA reads track the displayed active frequency" + ); + } + + #[tokio::test] + async fn active_vfo_b_frequency_notifies_as_fa() { + let (ctx, _backend) = ctx_with(FacePermissions::read_only()); + ctx.set_ai(true); + ctx.state.record( + StateChange::RxVfo { vfo: Vfo::B }, + RadioEventSource::NativePush, + ); + + let notification = Ts2000Dialect::new() + .format_notification( + &StateChange::Freq { + vfo: Vfo::B, + hz: 14_074_000, + }, + &ctx, + ) + .expect("active VFO B frequency notification"); + + assert_eq!(notification, b"FA00014074000;".to_vec()); + } + #[tokio::test] async fn md_read_and_write_use_active_vfo_b() { let (ctx, backend) = ctx_with(FacePermissions::from_tokens(&["read", "write"])); diff --git a/crates/cathub/src/integration.rs b/crates/cathub/src/integration.rs index 6adebc2..9bca94d 100644 --- a/crates/cathub/src/integration.rs +++ b/crates/cathub/src/integration.rs @@ -203,6 +203,39 @@ async fn ts2000_face_fa_write_tunes_active_vfo_b() { assert!(rig.backend.passthroughs().is_empty()); } +/// HDSDR/OmniRig polls `IF;`, `FA;`, and `FB;`. On active VFO B, `FA;` must report the +/// displayed active frequency, not stale inactive VFO A, because HDSDR treats FA as its main +/// panadapter frequency. +#[tokio::test] +async fn ts2000_face_fa_read_reports_active_vfo_b() { + let rig = rig(); + let mut omni = rig.face(ts2000(), FacePermissions::read_only(), 1); + rig.state.record( + StateChange::Freq { + vfo: Vfo::A, + hz: 14_062_820, + }, + RadioEventSource::NativePush, + ); + rig.state.record( + StateChange::Freq { + vfo: Vfo::B, + hz: 14_074_000, + }, + RadioEventSource::NativePush, + ); + rig.state.record( + StateChange::RxVfo { vfo: Vfo::B }, + RadioEventSource::NativePush, + ); + + assert!(request(&mut omni, b"IF;") + .await + .starts_with(b"IF00014074000")); + assert_eq!(request(&mut omni, b"FA;").await, b"FA00014074000;"); + assert_eq!(request(&mut omni, b"FB;").await, b"FB00014074000;"); +} + /// A simulated front-panel change (a poll-diff from the backend's truth) fans out to every /// auto-info-subscribed face without any client having polled. #[tokio::test] From e2721869e1e790804048cb69c3cb0fd83a395cec Mon Sep 17 00:00:00 2001 From: Randy Treit <38894017+rtreit@users.noreply.github.com> Date: Wed, 3 Jun 2026 22:51:20 -0700 Subject: [PATCH 16/36] Harden cathub against multi-client failure modes (#493) * Harden cathub against multi-client failure modes Fleet-mode bug hunt on the CAT hub daemon. Fixes a set of concrete failure modes found across the serial face, hamlib_net face, radio frame matcher, baseline poller, and event fan-out: - HDSDR/OmniRig VFO A<->B switch: the TS-2000 translator now leads the RxVfo notification with an FA frequency frame (plus MD) for the new active VFO, not an IF-only frame. HDSDR retunes the panadapter only from FA, so an IF-only push left it parked on the old VFO. - PTT release on disconnect: a face that drops while holding the PTT lease (TCP drop, crash, EOF, over-long frame) now unkeys the radio and frees the lease in teardown instead of holding TX up to ptt_max_tx_ms. Releases only when the disconnecting face owns the lease. - Lagged-subscriber resync: on a broadcast lag a face replays the full state snapshot through its dialect (active VFO applied last) instead of skipping ahead and rendering permanently stale state. - Input bounds: serial-face partial-frame buffer, radio frame reassembler, and hamlib_net request-line reader are all capped at 4096 bytes; the hamlib_net reader is now a length-bounded manual reader, not an unbounded lines() adapter. - hamlib ERP: generic get-style replies terminate with RPRT 0 so ERP clients (Log4OM) stop blocking on a missing terminator. - hamlib set_mode: an unknown mode token is rejected with RPRT -EINVAL and writes nothing, instead of silently coercing to USB with RPRT 0. - Poll back-off keys off the currently active VFO's native-push coverage, not a hard-coded VFO A. Updates docs/design/cathub-multi-client-cat-hub.md with the new behavioral contracts (8.2, 8.4, 8.5, 8.9). Deferred and documented (regression risk / low value): matched-frame re-routing, select! self-lag, and the remaining Tier-3 findings. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix N1MM frequency tracking on VFO B in native TS-590 dialect The native Ts590Dialect (N1MM Logger+, ARCP-590, Log4OM-as-TS590) pushed only a bare per-VFO FB frame on an active-VFO frequency change. Operating-frequency trackers read the displayed frequency from the IF; status answer, not from FB, so frequency updates silently stopped whenever the rig was on VFO B while VFO A worked. MD reads and writes were also hardcoded to VFO A instead of following the active VFO. - format_notification now appends a synthesized IF frame for active-VFO Freq and Mode changes (mirrors the TS-2000 lead-with-FA rule for HDSDR). - MD; read and MD; write now follow snapshot.rx_vfo. - Add ts590 repro tests for VFO-B freq push, mode read, and mode write. - Add a hamlib_net guard test (WSJT-X/Log4OM) for set_freq/set_mode on VFO B. - Document the native-dialect IF-on-active-VFO rule in the design doc. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/cathub/src/dialect/kenwood/ts2000.rs | 70 ++++++- crates/cathub/src/dialect/kenwood/ts590.rs | 135 ++++++++++++- crates/cathub/src/dialect/mod.rs | 28 +++ crates/cathub/src/events.rs | 37 +++- crates/cathub/src/hamlib_net.rs | 213 +++++++++++++++++--- crates/cathub/src/radio/mod.rs | 28 +++ crates/cathub/src/serial_face.rs | 147 +++++++++++++- crates/cathub/src/state.rs | 52 +++++ docs/design/multi-client-cat-hub.md | 16 ++ 9 files changed, 678 insertions(+), 48 deletions(-) diff --git a/crates/cathub/src/dialect/kenwood/ts2000.rs b/crates/cathub/src/dialect/kenwood/ts2000.rs index 2a6bb11..8984c64 100644 --- a/crates/cathub/src/dialect/kenwood/ts2000.rs +++ b/crates/cathub/src/dialect/kenwood/ts2000.rs @@ -126,7 +126,20 @@ impl ClientDialect for Ts2000Dialect { StateChange::Mode { vfo, mode } if vfo == ctx.snapshot().rx_vfo => { Some(vec![b'M', b'D', mode_to_digit(mode), b';']) } - StateChange::RxVfo { .. } => Some(synth_if(&ctx.snapshot())), + StateChange::RxVfo { .. } => { + // HDSDR/OmniRig retune the panadapter from an `FA` frame, never from `IF`. + // A VFO A<->B switch changes the active frequency and mode, but those land + // on the inactive VFO's cache (so their Freq/Mode events are suppressed as + // redundant) and only this single `RxVfo` event is broadcast. Emitting just + // `IF` left HDSDR parked on the old frequency. Lead with the new active + // VFO's `FA`/`MD` so the foreign client actually follows the displayed VFO. + let snap = ctx.snapshot(); + let active = snap.vfo(snap.rx_vfo); + let mut frame = freq_frame(b"FA", active.freq_hz); + frame.extend_from_slice(&[b'M', b'D', mode_to_digit(active.mode), b';']); + frame.extend_from_slice(&synth_if(&snap)); + Some(frame) + } _ => None, } } @@ -241,14 +254,65 @@ mod tests { .format_notification(&StateChange::RxVfo { vfo: Vfo::B }, &ctx) .expect("IF notification"); - assert!(notification.starts_with(b"IF00014034320")); + // The notification still carries the full `IF` status so split/PTT stay in sync. + let if_at = notification + .windows(2) + .position(|w| w == b"IF") + .expect("IF segment present"); + assert_eq!( + ¬ification[if_at..if_at + 13], + b"IF00014034320", + "embedded IF status reports the new active frequency" + ); assert_eq!( - *notification.get(30).expect("VFO field"), + *notification.get(if_at + 30).expect("VFO field"), b'1', "TS-2000 IF VFO field should report VFO B" ); } + #[tokio::test] + async fn vfo_switch_pushes_fa_for_new_active_frequency() { + // Regression for the HDSDR VFO-switch bug: a VFO A->B switch must push an `FA` + // (and `MD`) frame for the new active VFO so HDSDR/OmniRig retunes the panadapter. + let (ctx, _backend) = ctx_with(FacePermissions::read_only()); + ctx.set_ai(true); + ctx.state.record( + StateChange::Freq { + vfo: Vfo::B, + hz: 7_034_320, + }, + RadioEventSource::NativePush, + ); + ctx.state.record( + StateChange::Mode { + vfo: Vfo::B, + mode: crate::model::Mode::Cw, + }, + RadioEventSource::NativePush, + ); + ctx.state.record( + StateChange::RxVfo { vfo: Vfo::B }, + RadioEventSource::NativePush, + ); + + let notification = Ts2000Dialect::new() + .format_notification(&StateChange::RxVfo { vfo: Vfo::B }, &ctx) + .expect("notification"); + + assert!( + notification.starts_with(b"FA00007034320;"), + "VFO switch must lead with FA for the new active frequency, got {}", + String::from_utf8_lossy(¬ification) + ); + assert!( + notification + .windows(4) + .any(|w| w == [b'M', b'D', mode_to_digit(crate::model::Mode::Cw), b';']), + "VFO switch must also push the new active mode (MD)" + ); + } + #[tokio::test] async fn answers_id_as_ts2000() { let (ctx, _b) = ctx_with(FacePermissions::read_only()); diff --git a/crates/cathub/src/dialect/kenwood/ts590.rs b/crates/cathub/src/dialect/kenwood/ts590.rs index eb48a88..fdb3079 100644 --- a/crates/cathub/src/dialect/kenwood/ts590.rs +++ b/crates/cathub/src/dialect/kenwood/ts590.rs @@ -62,16 +62,21 @@ impl ClientDialect for Ts590Dialect { } b"MD" => { if read { - let d = mode_to_digit(ctx.snapshot().vfo(Vfo::A).mode); + // A real TS-590 `MD;` reports the *active* VFO's mode. Reading VFO A + // unconditionally froze N1MM/Log4OM on VFO B's mode display. + let snap = ctx.snapshot(); + let d = mode_to_digit(snap.vfo(snap.rx_vfo).mode); vec![b'M', b'D', d, b';'] } else { let Some(&d) = payload.first() else { return ERR.to_vec(); }; + // `MD;` writes the *active* VFO's mode, matching real-radio semantics. + let vfo = ctx.snapshot().rx_vfo; reply( ctx.apply_modeled( StateMutation::SetMode { - vfo: Vfo::A, + vfo, mode: mode_from_digit(d), }, CommandClass::ModeledWrite, @@ -131,13 +136,30 @@ impl ClientDialect for Ts590Dialect { if !ctx.ai_on() { return None; } + let snap = ctx.snapshot(); match *change { - StateChange::Freq { vfo: Vfo::A, hz } => Some(freq_frame(b"FA", hz)), - StateChange::Freq { vfo: Vfo::B, hz } => Some(freq_frame(b"FB", hz)), - StateChange::Mode { vfo: Vfo::A, mode } => { - Some(vec![b'M', b'D', mode_to_digit(mode), b';']) + StateChange::Freq { vfo, hz } => { + // Always emit the explicit per-VFO frame for VFO-specific trackers. + let label: &[u8] = if vfo == Vfo::A { b"FA" } else { b"FB" }; + let mut frame = freq_frame(label, hz); + // When the change is on the *active* VFO, also emit the operating-status + // `IF` frame. Operating-frequency trackers (N1MM Logger+, Log4OM-as-TS590) + // read the displayed frequency from `IF;`/`FA`, not from a bare `FB`, so an + // FB-only push made frequency updates silently stop whenever the rig was on + // VFO B. Appending `IF` makes VFO B behave exactly like VFO A. + if vfo == snap.rx_vfo { + frame.extend_from_slice(&synth_if(&snap)); + } + Some(frame) + } + StateChange::Mode { vfo, mode } if vfo == snap.rx_vfo => { + // `MD` reflects the active VFO on a real TS-590; emit it plus the operating + // `IF` so mode trackers follow the active VFO regardless of A/B. + let mut frame = vec![b'M', b'D', mode_to_digit(mode), b';']; + frame.extend_from_slice(&synth_if(&snap)); + Some(frame) } - StateChange::RxVfo { .. } => Some(synth_if(&ctx.snapshot())), + StateChange::RxVfo { .. } => Some(synth_if(&snap)), _ => None, } } @@ -286,7 +308,104 @@ mod tests { }, &ctx, ); - assert_eq!(note, Some(b"FA00014074000;".to_vec())); + // The active VFO is A by default, so an active-VFO frequency change must push the + // per-VFO `FA` frame *and* the operating-status `IF` frame so clients that track the + // operating frequency (N1MM, Log4OM-as-TS590) follow the change. + let mut expected = b"FA00014074000;".to_vec(); + expected.extend_from_slice(&synth_if(&ctx.snapshot())); + assert_eq!(note, Some(expected)); + } + + /// Regression repro for the N1MM "frequency stops tracking on VFO B" bug. + /// + /// On VFO A, a frequency change pushed an `FA` frame and N1MM updated. On VFO B the hub + /// pushed only a bare `FB` frame, which operating-frequency trackers ignore, so the + /// displayed frequency silently froze. The active-VFO change must also push the + /// operating-status `IF` frame regardless of which VFO is active. + #[tokio::test] + async fn notification_for_active_vfo_b_freq_pushes_operating_if() { + let (ctx, _b) = ctx_with(FacePermissions::read_only()); + ctx.state.record( + StateChange::RxVfo { vfo: Vfo::B }, + RadioEventSource::PollDiff, + ); + ctx.state.record( + StateChange::Freq { + vfo: Vfo::B, + hz: 14_034_320, + }, + RadioEventSource::PollDiff, + ); + ctx.set_ai(true); + let note = Ts590Dialect::new() + .format_notification( + &StateChange::Freq { + vfo: Vfo::B, + hz: 14_034_320, + }, + &ctx, + ) + .expect("active-VFO freq change must notify"); + let synth = synth_if(&ctx.snapshot()); + assert!( + note.windows(synth.len()).any(|w| w == synth.as_slice()), + "VFO B active-freq notification must include the operating IF frame; got {:?}", + String::from_utf8_lossy(¬e) + ); + // The IF frame must report VFO B (rx_vfo field == '1') and B's frequency. + assert!( + note.windows(2).any(|w| w == b"IF"), + "notification must contain an IF status frame" + ); + assert!( + note.windows(b"FB00014034320;".len()) + .any(|w| w == b"FB00014034320;"), + "notification must still include the per-VFO FB frame" + ); + } + + #[tokio::test] + async fn mode_read_follows_active_vfo_b() { + let (ctx, _b) = ctx_with(FacePermissions::read_only()); + ctx.state.record( + StateChange::RxVfo { vfo: Vfo::B }, + RadioEventSource::PollDiff, + ); + ctx.state.record( + StateChange::Mode { + vfo: Vfo::B, + mode: Mode::Cw, + }, + RadioEventSource::PollDiff, + ); + // A real TS-590 `MD;` reports the *active* VFO's mode, so with VFO B active and set + // to CW the read must return the CW digit (3), not VFO A's default USB (2). + assert_eq!( + Ts590Dialect::new().handle(b"MD;", &ctx).await, + b"MD3;".to_vec() + ); + } + + #[tokio::test] + async fn mode_write_targets_active_vfo_b() { + let (ctx, backend) = ctx_with(FacePermissions::from_tokens(&["read", "write"])); + ctx.state.record( + StateChange::RxVfo { vfo: Vfo::B }, + RadioEventSource::PollDiff, + ); + assert_eq!( + Ts590Dialect::new().handle(b"MD3;", &ctx).await, + Vec::::new() + ); + tokio::time::sleep(Duration::from_millis(20)).await; + // The write must target the active VFO (B), not be hardcoded to VFO A. + assert_eq!( + backend.mutations(), + vec![StateMutation::SetMode { + vfo: Vfo::B, + mode: Mode::Cw + }] + ); } #[tokio::test] diff --git a/crates/cathub/src/dialect/mod.rs b/crates/cathub/src/dialect/mod.rs index e0f34f1..206c070 100644 --- a/crates/cathub/src/dialect/mod.rs +++ b/crates/cathub/src/dialect/mod.rs @@ -184,6 +184,34 @@ impl FaceContext { ai: Arc::new(AtomicBool::new(false)), } } + + /// Release the PTT lease if this face currently holds it, unkeying the radio first. + /// + /// Called when a face's transport closes. A client that keys the transmitter and then + /// disconnects (crash, cable pull, app kill) would otherwise leave the radio keyed until + /// the `ptt_max_tx_ms` safety ceiling fires — minutes of unintended transmission. This + /// drops TX immediately on disconnect (design §8.5), mirroring the orderly-shutdown path. + pub(crate) async fn release_ptt_on_disconnect(&self) { + if self.ptt.owner() != Some(self.face_id) { + return; + } + let _ = self + .radio + .submit( + self.face_id, + Priority::Ptt, + OpKind::Apply(StateMutation::SetPtt { + keyed: false, + source: crate::model::PttSource::Generic, + }), + ) + .await; + self.ptt.unkey(self.face_id); + tracing::warn!( + face = self.face_id, + "client disconnected while keyed; transmitter released" + ); + } } fn map_outcome(result: &Result, BackendError>) -> ApplyOutcome { diff --git a/crates/cathub/src/events.rs b/crates/cathub/src/events.rs index f75c163..739c81e 100644 --- a/crates/cathub/src/events.rs +++ b/crates/cathub/src/events.rs @@ -13,7 +13,7 @@ use std::sync::Arc; use std::time::Duration; use crate::backend::RadioBackend; -use crate::model::{Field, Vfo}; +use crate::model::Field; use crate::radio::{Expect, OpKind, Priority, RadioHandle, RadioLink}; use crate::state::StateHandle; @@ -40,7 +40,12 @@ pub(crate) fn next_interval( baseline: Duration, heartbeat: Duration, ) -> Duration { - if native_push_active && state.is_native_push_covered(Field::Freq(Vfo::A)) { + // Back off only when native push covers the frequency of the *currently active* receive + // VFO. Probing a fixed `Vfo::A` meant that while the radio sat on VFO B the poller saw + // no coverage and never backed off (over-polling on B), and conversely could back off on + // A's coverage while B's frequency was the live one. Track the active VFO instead. + let active = state.snapshot().rx_vfo; + if native_push_active && state.is_native_push_covered(Field::Freq(active)) { heartbeat } else { baseline @@ -73,7 +78,7 @@ mod tests { use super::*; use crate::backend::kenwood::ts590::Ts590Backend; use crate::backend::loopback::LoopbackBackend; - use crate::model::{RadioEventSource, StateChange}; + use crate::model::{RadioEventSource, StateChange, Vfo}; use crate::radio::{detached_link, spawn_scheduler}; #[tokio::test] @@ -126,6 +131,32 @@ mod tests { assert_eq!(next_interval(&state, false, baseline, heartbeat), baseline); } + #[test] + fn back_off_follows_the_active_vfo_not_a_fixed_vfo_a() { + // Native push covers VFO B's frequency while the radio sits on VFO B. The poller must + // recognize coverage of the *active* VFO and back off, instead of over-polling + // because it only ever probed VFO A. + let state = StateHandle::new(); + let baseline = Duration::from_millis(200); + let heartbeat = Duration::from_millis(2_000); + + // Make VFO B the active receive VFO. + state.record( + StateChange::RxVfo { vfo: Vfo::B }, + RadioEventSource::NativePush, + ); + // Native push covers VFO B's frequency. + state.record( + StateChange::Freq { + vfo: Vfo::B, + hz: 14_074_000, + }, + RadioEventSource::NativePush, + ); + // Coverage of the active VFO (B) must drive the back-off. + assert_eq!(next_interval(&state, true, baseline, heartbeat), heartbeat); + } + #[tokio::test] async fn enable_native_push_reports_capability() { let native: Arc = Arc::new(Ts590Backend::new()); diff --git a/crates/cathub/src/hamlib_net.rs b/crates/cathub/src/hamlib_net.rs index a842589..b267867 100644 --- a/crates/cathub/src/hamlib_net.rs +++ b/crates/cathub/src/hamlib_net.rs @@ -10,7 +10,7 @@ use std::sync::Arc; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::io::{AsyncReadExt, AsyncWriteExt, BufReader}; use tokio::net::TcpListener; use crate::dialect::{ApplyOutcome, FaceContext}; @@ -232,6 +232,13 @@ async fn handle_ext(sep: char, rest: &str, ctx: &FaceContext) -> Vec { records.push(chunk.to_string()); } } + // An ERP client reads until it sees a terminating `RPRT` record. Plain gets + // (e.g. `\get_split_vfo`, `\get_powerstat`, `\chk_vfo`, rit/xit) return data + // only, with no `RPRT`, so a client falling through this generic path would + // block waiting for a terminator that never arrives. Guarantee one. + if !records.iter().any(|r| r.starts_with("RPRT")) { + records.push("RPRT 0".to_string()); + } erp_records(sep, &records) } } @@ -319,6 +326,12 @@ async fn dispatch_plain(cmd: &str, args: &[&str], ctx: &FaceContext) -> LineResu // `DA0`. The base write is applied first; if it fails the data write is // skipped and its error is reported. let (base, data) = Mode::decompose_hamlib_token(token); + // An unrecognized mode token decodes to `Mode::Unknown`, which renders as + // USB. Silently applying it would retune the radio to USB while reporting + // `RPRT 0` (false success). Reject it so the client sees the error. + if base == Mode::Unknown { + return LineResult::Reply(RPRT_EINVAL.to_vec()); + } let mode_outcome = ctx .apply_modeled( StateMutation::SetMode { @@ -465,34 +478,72 @@ where { let face_id = ctx.face_id; let (reader, mut writer) = tokio::io::split(stream); - let mut lines = BufReader::new(reader).lines(); - loop { - match lines.next_line().await { - Ok(Some(line)) => { - tracing::trace!(face_id, req = %line.trim(), "hamlib_net request"); - match handle_line(&line, &ctx).await { - LineResult::Reply(bytes) => { - tracing::trace!( + let mut reader = BufReader::new(reader); + let mut line: Vec = Vec::with_capacity(64); + let mut byte = [0u8; 1]; + + 'serve: loop { + // Read one line manually with a hard length cap so a client that never sends a + // newline cannot grow the buffer without bound (OOM/DoS via `BufReader::lines`, + // which has no upper limit). An over-long line is discarded and the connection + // closed. + line.clear(); + let eof = loop { + match reader.read(&mut byte).await { + Ok(0) | Err(_) => break true, + Ok(_) => { + if byte[0] == b'\n' { + break false; + } + if line.len() >= MAX_LINE_LEN { + tracing::warn!( face_id, - reply = %String::from_utf8_lossy(&bytes).trim_end(), - "hamlib_net reply" + "hamlib_net request exceeded {MAX_LINE_LEN} bytes without a \ + newline; closing connection" ); - if !bytes.is_empty() && writer.write_all(&bytes).await.is_err() { - return; - } - let _ = writer.flush().await; - } - LineResult::Quit => { - tracing::trace!(face_id, "hamlib_net client quit"); - return; + break 'serve; } + line.push(byte[0]); + } + } + }; + if eof { + break 'serve; + } + + // Trim a trailing CR so CRLF clients are handled. + if line.last() == Some(&b'\r') { + line.pop(); + } + let text = String::from_utf8_lossy(&line).into_owned(); + tracing::trace!(face_id, req = %text.trim(), "hamlib_net request"); + match handle_line(&text, &ctx).await { + LineResult::Reply(bytes) => { + tracing::trace!( + face_id, + reply = %String::from_utf8_lossy(&bytes).trim_end(), + "hamlib_net reply" + ); + if !bytes.is_empty() && writer.write_all(&bytes).await.is_err() { + break 'serve; } + let _ = writer.flush().await; + } + LineResult::Quit => { + tracing::trace!(face_id, "hamlib_net client quit"); + break 'serve; } - Ok(None) | Err(_) => return, } } + + // The connection closed: never leave the radio keyed on behalf of a vanished client. + ctx.release_ptt_on_disconnect().await; } +/// Maximum bytes buffered for a single rigctld request line before the connection is +/// closed. Real rigctld commands are short; this only bounds a misbehaving client. +const MAX_LINE_LEN: usize = 4096; + /// Bind a Hamlib net endpoint and serve connections. Each connection gets a fresh /// [`FaceContext`] sharing the same state/radio/ptt but its own face id and the /// endpoint's permissions. @@ -708,6 +759,35 @@ mod tests { ); } + #[tokio::test] + async fn set_freq_and_mode_target_active_vfo_b() { + // WSJT-X / Log4OM write through the hamlib_net face. When the rig is on VFO B, a + // set_freq / set_mode must land on VFO B (the active VFO), never be forced to A. + let (ctx, backend, state) = ctx_with(FacePermissions::from_tokens(&["read", "write"])); + state.record( + StateChange::RxVfo { vfo: Vfo::B }, + RadioEventSource::PollDiff, + ); + assert_eq!(reply_of("F 14034320", &ctx).await, RPRT_OK.to_vec()); + assert_eq!(reply_of("M CW 0", &ctx).await, RPRT_OK.to_vec()); + tokio::time::sleep(Duration::from_millis(20)).await; + let muts = backend.mutations(); + assert!( + muts.contains(&StateMutation::SetVfoFreq { + vfo: Vfo::B, + hz: 14_034_320, + }), + "set_freq must target active VFO B, got {muts:?}" + ); + assert!( + muts.contains(&StateMutation::SetMode { + vfo: Vfo::B, + mode: Mode::Cw, + }), + "set_mode must target active VFO B, got {muts:?}" + ); + } + #[tokio::test] async fn get_mode_composes_pkt_token_when_data_on() { let (ctx, _b, state) = ctx_with(FacePermissions::read_only()); @@ -973,12 +1053,95 @@ mod tests { } #[tokio::test] - async fn erp_get_vfo_info_denied_without_read() { - // A face without read permission gets an RPRT -1 extended error, not data. - let (ctx, _b, _s) = ctx_with(FacePermissions::from_tokens(&["write"])); + async fn erp_get_split_terminates_with_rprt() { + // `\get_split_vfo` returns data only in the plain protocol. Through the ERP generic + // fallback it must still end with an `RPRT` record so an ERP client (Log4OM-NG) + // reading until the terminator does not block. + let (ctx, _b, _s) = ctx_with(FacePermissions::read_only()); assert_eq!( - reply_of("+\\get_vfo_info VFOA", &ctx).await, - b"get_vfo_info: VFOA\nRPRT -1\n".to_vec() + reply_of("+\\get_split_vfo", &ctx).await, + b"get_split_vfo:\n0\nVFOA\nRPRT 0\n".to_vec() + ); + } + + #[tokio::test] + async fn set_mode_rejects_unknown_token() { + // An unrecognized mode token must be rejected (RPRT -1), never silently applied as + // USB with a false `RPRT 0`. + let (ctx, backend, _s) = ctx_with(FacePermissions::from_tokens(&["read", "write"])); + assert_eq!(reply_of("M WAT", &ctx).await, RPRT_EINVAL.to_vec()); + // And nothing was written to the radio. + assert!( + backend.mutations().is_empty(), + "an unknown mode must not mutate the radio" + ); + } + + #[tokio::test] + async fn dropping_a_keyed_hamlib_client_releases_the_ptt_lease() { + // A rigctld client keys PTT then its TCP connection drops. The hub must release the + // lease immediately, not hold the transmitter up until the safety ceiling. + let backend = LoopbackBackend::new(); + let caps = backend.capabilities(); + let arc: Arc = Arc::new(backend); + let state = StateHandle::new(); + let radio = spawn_scheduler(arc, detached_link(), state.clone()); + let ptt = PttManager::new(Duration::from_secs(300)); + let perms = FacePermissions::from_tokens(&["read", "ptt"]); + let ctx = FaceContext::new(7, perms, state.clone(), radio, ptt.clone(), caps); + let (client, server) = tokio::io::duplex(1024); + let handle = tokio::spawn(serve_conn(server, ctx)); + + let (mut cr, mut cw) = tokio::io::split(client); + cw.write_all(b"T 1\n").await.expect("write"); + // Drain the RPRT reply so the key has been processed. + let mut buf = [0u8; 32]; + let _ = tokio::time::timeout(Duration::from_secs(2), cr.read(&mut buf)).await; + for _ in 0..50 { + if ptt.owner() == Some(7) { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert_eq!(ptt.owner(), Some(7), "client should hold the PTT lease"); + + // Drop the transport. + drop(cr); + drop(cw); + let _ = tokio::time::timeout(Duration::from_secs(2), handle).await; + assert_eq!( + ptt.owner(), + None, + "PTT lease must release when a keyed rigctld client disconnects" + ); + } + + #[tokio::test] + async fn overlong_request_without_newline_closes_the_connection() { + // A client that streams bytes without ever sending a newline must not grow the + // line buffer without bound; the hub caps it and closes the connection. + let backend = LoopbackBackend::new(); + let caps = backend.capabilities(); + let arc: Arc = Arc::new(backend); + let state = StateHandle::new(); + let radio = spawn_scheduler(arc, detached_link(), state.clone()); + let ptt = PttManager::new(Duration::from_secs(300)); + let perms = FacePermissions::from_tokens(&["read"]); + let ctx = FaceContext::new(9, perms, state.clone(), radio, ptt.clone(), caps); + let (client, server) = tokio::io::duplex(8192); + let handle = tokio::spawn(serve_conn(server, ctx)); + + let (_cr, mut cw) = tokio::io::split(client); + // Send well over the cap with no newline. Ignore write errors that occur once the + // server side has already closed. + let flood = vec![b'f'; MAX_LINE_LEN + 256]; + let _ = cw.write_all(&flood).await; + + // The serve task must terminate (connection closed) rather than hang or OOM. + let joined = tokio::time::timeout(Duration::from_secs(2), handle).await; + assert!( + joined.is_ok(), + "serve_conn must close the connection on an overlong line" ); } } diff --git a/crates/cathub/src/radio/mod.rs b/crates/cathub/src/radio/mod.rs index 0426f83..85de09f 100644 --- a/crates/cathub/src/radio/mod.rs +++ b/crates/cathub/src/radio/mod.rs @@ -133,6 +133,16 @@ impl Framer { let delim = self.delimiter(); let mut frames = Vec::new(); for &b in bytes { + // Bound the partial-frame buffer. A radio (or a noisy line) that streams bytes + // without ever producing the delimiter must not grow this buffer without limit. + // Drop the malformed partial frame and resynchronize on the next delimiter. + if self.buf.len() >= MAX_RADIO_FRAME_LEN { + tracing::warn!( + "radio frame exceeded {MAX_RADIO_FRAME_LEN} bytes without a delimiter; \ + discarding partial frame" + ); + self.buf.clear(); + } self.buf.push(b); if b == delim { frames.push(std::mem::take(&mut self.buf)); @@ -142,6 +152,10 @@ impl Framer { } } +/// Maximum bytes buffered for a single in-progress radio frame before the partial frame is +/// discarded. Real CAT frames are tens of bytes; this only bounds a stuck or noisy link. +const MAX_RADIO_FRAME_LEN: usize = 4096; + fn frame_matches(frame: &[u8], verbs: &[Vec]) -> bool { verbs.iter().any(|v| frame.starts_with(v)) } @@ -542,6 +556,20 @@ mod tests { assert_eq!(frames, vec![b"FA00007030000;".to_vec()]); } + #[test] + fn framer_discards_overlong_partial_frame_and_resyncs() { + let mut framer = Framer::new(Framing::SemicolonTerminated); + // Stream more than the cap without ever sending a delimiter. + let junk = vec![b'X'; MAX_RADIO_FRAME_LEN + 64]; + assert!(framer.push(&junk).is_empty()); + // The partial buffer must have been bounded, not grown unbounded. + assert!(framer.buf.len() <= MAX_RADIO_FRAME_LEN); + // Flush whatever junk remains with a delimiter, then a clean frame parses correctly. + framer.push(b";"); + let frames = framer.push(b"FA00007030000;"); + assert_eq!(frames, vec![b"FA00007030000;".to_vec()]); + } + #[test] fn verb_matching_uses_prefix() { assert!(frame_matches(b"FA00007030000;", &[b"FA".to_vec()])); diff --git a/crates/cathub/src/serial_face.rs b/crates/cathub/src/serial_face.rs index 398f388..9c4b074 100644 --- a/crates/cathub/src/serial_face.rs +++ b/crates/cathub/src/serial_face.rs @@ -33,14 +33,25 @@ pub(crate) async fn run_face( let mut frame: Vec = Vec::with_capacity(64); let mut chunk = [0u8; 512]; - loop { + 'serve: loop { tokio::select! { read = reader.read(&mut chunk) => { match read { - Ok(0) | Err(_) => break, + Ok(0) | Err(_) => break 'serve, Ok(n) => { let slice = chunk.get(..n).unwrap_or(&[]); for &byte in slice { + // Bound the partial-frame buffer: a client that streams bytes + // without ever sending the delimiter must not grow the buffer + // without limit (OOM/DoS). Drop the malformed partial frame. + if frame.len() >= MAX_FRAME_LEN { + tracing::warn!( + face = ctx.face_id, + "request frame exceeded {MAX_FRAME_LEN} bytes without a \ + delimiter; discarding partial frame" + ); + frame.clear(); + } frame.push(byte); if byte == delim { let request = std::mem::take(&mut frame); @@ -56,7 +67,7 @@ pub(crate) async fn run_face( "face reply" ); if !reply.is_empty() && writer.write_all(&reply).await.is_err() { - return; + break 'serve; } let _ = writer.flush().await; } @@ -80,20 +91,49 @@ pub(crate) async fn run_face( "face notify" ); if writer.write_all(&bytes).await.is_err() { - return; + break 'serve; } let _ = writer.flush().await; } } - // A lagged subscriber simply skips missed frames; the next poll re-syncs. - Err(RecvError::Lagged(_)) => {} - Err(RecvError::Closed) => break, + // The face fell behind the broadcast ring and missed one or more events. + // Skipping them is unsafe: a one-shot change (a mode or VFO switch) that + // was evicted from the ring is lost forever, leaving the client rendering + // permanently stale state. Re-synchronize by replaying the full current + // snapshot through the dialect's notification formatter so the client is + // restored to the live radio state (gated by the face's auto-info flag, + // so an AI-off face still emits nothing). + Err(RecvError::Lagged(skipped)) => { + tracing::warn!( + face = ctx.face_id, + skipped, + "face lagged the broadcast ring; re-syncing full snapshot" + ); + let snapshot = ctx.snapshot(); + for change in snapshot.as_changes() { + if let Some(bytes) = dialect.format_notification(&change, &ctx) { + if writer.write_all(&bytes).await.is_err() { + break 'serve; + } + } + } + let _ = writer.flush().await; + } + Err(RecvError::Closed) => break 'serve, } } } } + + // The transport closed: never leave the radio keyed on behalf of a face that vanished. + ctx.release_ptt_on_disconnect().await; } +/// Maximum bytes buffered for a single in-progress request frame before the partial frame +/// is discarded. A real CAT frame is tens of bytes; this only bounds a misbehaving or +/// malicious client that never sends the delimiter. +const MAX_FRAME_LEN: usize = 4096; + /// Open a real serial port for a serial face. pub(crate) fn open_serial( name: &str, @@ -110,6 +150,7 @@ mod tests { use super::*; use crate::backend::loopback::LoopbackBackend; use crate::backend::RadioBackend; + use crate::dialect::kenwood::mode_to_digit; use crate::dialect::kenwood::ts590::Ts590Dialect; use crate::model::{RadioEventSource, StateChange, Vfo}; use crate::permissions::FacePermissions; @@ -120,13 +161,20 @@ mod tests { use tokio::io::DuplexStream; fn spawn_ts590_face(perms: FacePermissions) -> (DuplexStream, StateHandle) { + let (client, state, _ptt) = spawn_ts590_face_with_ptt(perms); + (client, state) + } + + fn spawn_ts590_face_with_ptt( + perms: FacePermissions, + ) -> (DuplexStream, StateHandle, PttManager) { let backend = LoopbackBackend::new(); let caps = backend.capabilities(); let arc: Arc = Arc::new(backend); let state = StateHandle::new(); let radio = spawn_scheduler(arc, detached_link(), state.clone()); let ptt = PttManager::new(Duration::from_secs(300)); - let ctx = FaceContext::new(1, perms, state.clone(), radio, ptt, caps); + let ctx = FaceContext::new(1, perms, state.clone(), radio, ptt.clone(), caps); let (client, server) = tokio::io::duplex(1024); tokio::spawn(run_face( server, @@ -134,7 +182,7 @@ mod tests { ctx, b';', )); - (client, state) + (client, state, ptt) } async fn read_frame(client: &mut DuplexStream) -> Vec { @@ -199,4 +247,85 @@ mod tests { state.record_raw(b"NB1;"); assert_eq!(read_frame(&mut client).await, b"NB1;"); } + + #[tokio::test] + async fn dropping_a_keyed_client_releases_the_ptt_lease() { + // A client keys the transmitter, then its transport vanishes (crash/cable pull). + // The hub must drop TX immediately, not hold it until the safety ceiling fires. + let (mut client, _state, ptt) = + spawn_ts590_face_with_ptt(FacePermissions::from_tokens(&["read", "ptt"])); + client.write_all(b"TX;").await.expect("write"); + // Let the key reach the lease. + for _ in 0..50 { + if ptt.owner() == Some(1) { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert_eq!(ptt.owner(), Some(1), "client should hold the PTT lease"); + + // Close the transport. + drop(client); + + // The face must release the lease promptly on disconnect. + for _ in 0..50 { + if ptt.owner().is_none() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert_eq!( + ptt.owner(), + None, + "PTT lease must be released when a keyed client disconnects" + ); + } + + #[tokio::test] + async fn lagged_face_does_not_permanently_lose_a_one_shot_mode_change() { + // Drive the face past the broadcast ring capacity so it lags, after first changing + // the mode. A lagged face that merely skips would render the old mode forever; the + // re-sync must restore the current mode to the client. + let (mut client, state) = spawn_ts590_face(FacePermissions::read_only()); + client.write_all(b"AI2;").await.expect("write"); + tokio::time::sleep(Duration::from_millis(20)).await; + + // The one-shot change we must not lose: switch to CW. + state.record( + StateChange::Mode { + vfo: Vfo::A, + mode: crate::model::Mode::Cw, + }, + RadioEventSource::NativePush, + ); + // Flood the ring well past its 256-entry capacity so the face lags and the CW change + // is evicted before it is read. + for i in 0..2_000u64 { + state.record( + StateChange::Freq { + vfo: Vfo::A, + hz: 7_000_000 + i, + }, + RadioEventSource::PollDiff, + ); + } + + // Read frames until the re-sync emits the current CW mode (MD3;). + let mut saw_cw = false; + for _ in 0..64 { + let Ok(frame) = + tokio::time::timeout(Duration::from_secs(2), read_frame(&mut client)).await + else { + break; + }; + if frame == vec![b'M', b'D', mode_to_digit(crate::model::Mode::Cw), b';'] { + saw_cw = true; + break; + } + } + assert!( + saw_cw, + "after lagging, the face must be re-synced to the current CW mode" + ); + } } diff --git a/crates/cathub/src/state.rs b/crates/cathub/src/state.rs index 87b57d9..232ad6b 100644 --- a/crates/cathub/src/state.rs +++ b/crates/cathub/src/state.rs @@ -136,6 +136,58 @@ impl Snapshot { StateMutation::SetPtt { .. } => false, } } + + /// Decompose this snapshot into the full ordered list of [`StateChange`]s that + /// reconstruct it. + /// + /// Used to re-synchronize a face that fell behind the broadcast ring + /// ([`RecvError::Lagged`](tokio::sync::broadcast::error::RecvError::Lagged)): replaying + /// these through the dialect's notification formatter restores the client to the current + /// radio state even when a one-shot event (a mode or VFO change) was evicted from the + /// ring before the face read it. `RxVfo` is emitted last so a foreign dialect's + /// VFO-switch frame (which leads with the active `FA`/`MD`) reflects the final state. + pub(crate) fn as_changes(&self) -> Vec { + vec![ + StateChange::Freq { + vfo: Vfo::A, + hz: self.a.freq_hz, + }, + StateChange::Freq { + vfo: Vfo::B, + hz: self.b.freq_hz, + }, + StateChange::Mode { + vfo: Vfo::A, + mode: self.a.mode, + }, + StateChange::Mode { + vfo: Vfo::B, + mode: self.b.mode, + }, + StateChange::DataMode { + vfo: Vfo::A, + on: self.a.data, + }, + StateChange::DataMode { + vfo: Vfo::B, + on: self.b.data, + }, + StateChange::Split { + enabled: self.split, + tx_vfo: Some(self.tx_vfo), + }, + StateChange::Rit { + enabled: self.rit_enabled, + offset_hz: self.rit_offset_hz, + }, + StateChange::Xit { + enabled: self.xit_enabled, + offset_hz: self.xit_offset_hz, + }, + StateChange::Ptt { keyed: self.ptt }, + StateChange::RxVfo { vfo: self.rx_vfo }, + ] + } } /// An ordered radio-output event delivered to faces for auto-information fan-out. diff --git a/docs/design/multi-client-cat-hub.md b/docs/design/multi-client-cat-hub.md index 6747384..17e7e9f 100644 --- a/docs/design/multi-client-cat-hub.md +++ b/docs/design/multi-client-cat-hub.md @@ -228,6 +228,8 @@ All real-radio I/O goes through the single radio task. No two commands are ever A client-driven status read of a **modeled** field is answered from `state` when the relevant field's `last_polled` is within its TTL. Otherwise the request waits for the next baseline poll cycle (or, when AI is active, the most recent pushed value). The result is that N concurrent client reads of `IF;` produce at most one real `IF;` to the radio per TTL window, regardless of how many client faces are active. Reads of **unmodeled** fields go through passthrough (§8.6) and are coalesced only by the optional passthrough response cache, so a controller that polls unmodeled fields hard will generate proportional real-radio traffic; the priority scheme keeps that traffic from starving interactive writes. +Poll back-off keys off native-push coverage of the **currently active** receive VFO, not a fixed VFO A. The baseline poller asks whether the active VFO's frequency is covered by `NativePush` before slowing down; if it tested a hard-coded VFO A while the radio sat on VFO B, it would needlessly over-poll a VFO whose pushes already keep state fresh. + ### 8.3 Write atomicity Kenwood set commands do not all return an acknowledgment — many are "set and no reply." The daemon therefore classifies each modeled command's reply behavior in the backend command table as one of: @@ -263,6 +265,12 @@ Downstream fan-out is identical regardless of source, so a station with a non-pu **Push ordering and backpressure.** Each face's outbound push queue is bounded. Pushes are interleaved with that face's solicited replies at frame boundaries (never mid-frame). If a slow client lets its queue fill, the daemon **coalesces** superseded updates (keeping only the latest value per field) rather than blocking the shared broadcast or growing unboundedly; a sustained overflow is logged. Event fan-out must never apply backpressure to the radio task or to other faces. +**VFO-switch fan-out must lead with a frequency frame.** When the active receive VFO changes (e.g. an operator or client switches VFO A→B), the dialect that synthesizes the notification for a panadapter-class client (TS-2000 translator used by HDSDR/OmniRig) **must emit the new active VFO's `FA` frequency frame (and `MD` mode) first**, not only an `IF` status frame. HDSDR/OmniRig retune the panadapter exclusively from `FA`; an `IF`-only notification leaves the display stuck on the old VFO's frequency even though the cache is correct. This is the wire-level fix for the HDSDR "lost rig control on A/B switch" failure. + +**Native TS-590 active-VFO pushes must carry the operating `IF` frame.** Operating-frequency trackers on the native dialect (N1MM Logger+, Log4OM-as-TS590, ARCP-590) read the *displayed* frequency and mode from the `IF;` operating-status answer, not from a bare per-VFO `FB`. The native `Ts590Dialect` therefore emits the explicit per-VFO frame (`FA`/`FB`) for any frequency change **and**, whenever the change is on the **active** receive VFO, appends a synthesized `IF` frame; an active-VFO mode change emits `MD` plus the same `IF`. Without the appended `IF`, a frequency change while the rig sat on VFO B pushed only `FB`, which these clients ignore — so the displayed frequency silently froze on VFO B while VFO A worked. This is the native-dialect counterpart to the TS-2000 lead-with-`FA` rule above and the wire-level fix for the N1MM "frequency stops tracking on VFO B" failure. + +**Lagged-subscriber resync.** The change-notification broadcast ring is bounded, so a momentarily slow face can miss intermediate one-shot changes (a single Mode or VFO toggle that is evicted before the face drains the channel). When a face observes a broadcast *lag* it must **not** simply skip ahead — that would leave the client rendering permanently stale state until the next coincidental change. Instead the face replays the current `state` snapshot as a full set of field notifications through its dialect (which still gates on the face's own auto-info subscription, so an auto-info-off face emits nothing), with the active-VFO selection applied last so the client ends on the correct receive VFO. + ### 8.4.1 Auto-info mode granularity The auto-info command is virtualized per face, but for Kenwood `AI1` (post-command echo) and `AI2` (spontaneous updates) are not identical semantics (other vendors have analogous distinctions). v1 may collapse them to a single per-face push subscription; if it does, that limitation is documented and ARCP-590 and N1MM are tested specifically to confirm they tolerate the collapse before either is claimed as first-class. If a tested client depends on the distinction, the finer model is implemented for that dialect. @@ -275,6 +283,7 @@ Multiple clients are now PTT-capable (N1MM CAT PTT, WSJT-X `T 1`, ARCP-590). The - The first capable face to key acquires the **PTT lease** and becomes the PTT owner. While the lease is held, PTT writes from any other face are rejected (Hamlib `RPRT` busy / native error) and logged, so two apps cannot key the transmitter into contention. - The lease releases when the owner sends `RX;`/`T 0` (normal release), or after a configurable **maximum-transmit safety duration** (`ptt_max_tx_ms`) as a backstop against a client that keys and crashes. This timeout is a hard transmit-length ceiling, *not* a generic "no CAT activity" idle timer: WSJT-X keys PTT and then sends no CAT traffic for the length of a transmission, so an activity-based timeout would unkey it mid-over. `ptt_max_tx_ms` defaults above the longest expected digital-mode transmission and any safety release is logged loudly. - The shutdown handler attempts to emit `RX;` to the radio on Ctrl+C and SIGTERM so an orderly stop never leaves the transmitter keyed. A panic or hard crash cannot reliably perform async serial I/O, so the daemon also relies on `ptt_max_tx_ms` and the radio's own TX timeout as the ultimate stuck-transmitter guards rather than promising the shutdown write always runs. +- A face that **disconnects while it holds the PTT lease** (TCP drop, client crash, EOF, or a protocol-violating over-long frame that forces the face closed) releases the lease as part of face teardown: the daemon unkeys the radio (`RX;`/`T 0`) and frees the lease so the next client can key, rather than holding the transmitter up until the `ptt_max_tx_ms` ceiling. Teardown releases **only** when the disconnecting face is the current lease owner, so it never disturbs another face's active transmission. In a normal station the operator drives one mode at a time, so the lease is almost never contended; its job is to make contention safe rather than to support simultaneous transmit. @@ -302,6 +311,13 @@ The structural guarantee against the original bug is stated at the **wire level* The invariant is enforced by a per-backend certification soak, not by a blanket "no Hamlib anywhere" rule, so the design can honor both a dependency-free certified driver and a trusted external `rigctld` without weakening the guarantee. +### 8.9 Input bounds and malformed-input handling + +A multi-client hub accepts bytes from several long-lived, independently-failing endpoints plus the radio link itself, so every byte-accumulating buffer is explicitly bounded and every parsed field is validated rather than trusted: + +- **Frame/line length caps.** Each in-progress accumulation buffer — the serial-face partial-frame buffer, the radio task's frame reassembler, and the `hamlib_net` request-line reader — is capped (4096 bytes). A delimiter-less stream that would otherwise grow a buffer without limit is treated as a fault: the radio/serial reassembler discards the malformed partial frame and resynchronizes on the next delimiter, while the `hamlib_net` reader closes the offending connection (and releases its PTT lease, §8.5). The cap bounds only in-progress *requests*; long multi-line *replies* such as `\dump_state` are unaffected. The `hamlib_net` line reader is a length-bounded manual reader, not an unbounded `lines()` adapter. +- **Reject, never silently coerce.** A command that decodes to an unrecognized value must be rejected with the protocol's error reply, never quietly substituted with a plausible default that would mis-drive the radio. In particular, a Hamlib `set_mode` (`M`) with an unrecognized mode token returns `RPRT -EINVAL` and writes nothing to the radio, instead of resolving the unknown token to a default mode and falsely reporting `RPRT 0`. + ## 9. Configuration The TOML schema is: From 3c8b6590fdf8cfe7e5a031fc64d28978f008f5d0 Mon Sep 17 00:00:00 2001 From: Randy Treit <38894017+rtreit@users.noreply.github.com> Date: Wed, 3 Jun 2026 23:26:15 -0700 Subject: [PATCH 17/36] cathub: virtualize operating VFO as VFO A for single-VFO faces (N1MM SO1V) (#494) N1MM Logger+ in SO1V mode rejects VFO B ("You should not use VFO B when configured for SO1V") and freezes its frequency display whenever the radio reports VFO B as active in the Kenwood IF; frame (P10=1). Reporting P10=1 is faithful radio behavior, but it defeats seamless A/B operation for single-VFO loggers. Add an opt-in per-face single_vfo policy to the native ts590 dialect. When enabled, the face always presents the operating (receive) VFO as VFO A: - IF; reads force P10=0 and split=0 with the operating freq/mode (38-byte len kept) - FA reads/writes target the operating VFO; FB mirrors it (no physical VFO-B leak) - FR/FT VFO-select verbs are intercepted (read VFO A, write swallowed) - notifications re-present an A/B switch as FA+MD+IF (P10=0); inactive churn suppressed - raw passthrough is suppressed so a native frame can't leak a VFO-B verb Enabled for the n1mm face only; ARCP-590 stays dual-VFO (single_vfo=false). Adds 8 ts590 unit tests + an A/B-switch integration test, plus config and docs (design 8.4.2, engine spec, operator setup). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- config/cathub.toml | 6 + crates/cathub/src/config.rs | 20 +- crates/cathub/src/dialect/kenwood/ts590.rs | 328 ++++++++++++++++++++- crates/cathub/src/dialect/mod.rs | 21 ++ crates/cathub/src/integration.rs | 94 ++++++ crates/cathub/src/lib.rs | 3 +- docs/design/multi-client-cat-hub.md | 15 + docs/integration/setup.md | 8 +- 8 files changed, 476 insertions(+), 19 deletions(-) diff --git a/config/cathub.toml b/config/cathub.toml index 8816604..06c14de 100644 --- a/config/cathub.toml +++ b/config/cathub.toml @@ -70,14 +70,20 @@ dialect = "ts2000" perms = ["read", "write"] # N1MM Logger+ as a native TS-590. +# N1MM in SO1V mode rejects VFO B ("You should not use VFO B when configured for SO1V"), +# so present the operating VFO as VFO A. The hub mirrors reads/writes/notifications onto +# whichever VFO the radio is actually on, so A/B switching tracks seamlessly. [[face]] name = "n1mm" transport = "COM20" # N1MM binds COM21 baud = 115200 dialect = "ts590" +single_vfo = true perms = ["read", "write", "ptt"] # ARCP-590 control panel: full faceplate control, including EX-menu (config_write). +# ARCP-590 is a genuine dual-VFO faceplate: it must see the real VFO A/B, so leave +# single_vfo off (the default). [[face]] name = "arcp590" transport = "COM30" # ARCP-590 binds COM31 diff --git a/crates/cathub/src/config.rs b/crates/cathub/src/config.rs index b8393eb..b6da9bb 100644 --- a/crates/cathub/src/config.rs +++ b/crates/cathub/src/config.rs @@ -139,6 +139,16 @@ pub(crate) struct FaceConfig { /// Permission tokens (`read`, `write`, `ptt`, `config_write`). #[serde(default)] pub(crate) perms: Vec, + /// Present the *operating* VFO as VFO A to this face (operating-VFO virtualization). + /// + /// Single-VFO loggers (notably N1MM Logger+ in SO1V) read the active VFO from the + /// Kenwood `IF;` answer and refuse to track VFO B ("You should not use VFO B when + /// configured for SO1V"). With `single_vfo = true` the hub always presents whichever + /// VFO the operator is actually using as VFO A, so the logger follows A/B switches + /// seamlessly with no warning. Leave this `false` for true dual-VFO control faces such + /// as ARCP-590, which must see and address real VFO A and B independently. + #[serde(default)] + pub(crate) single_vfo: bool, } impl FaceConfig { @@ -317,8 +327,8 @@ impl Config { for face in &self.face { let _ = writeln!( out, - "face: name={} transport={} baud={} dialect={} perms={:?}", - face.name, face.transport, face.baud, face.dialect, face.perms + "face: name={} transport={} baud={} dialect={} perms={:?} single_vfo={}", + face.name, face.transport, face.baud, face.dialect, face.perms, face.single_vfo ); } for ep in &self.hamlib_net { @@ -425,6 +435,7 @@ transport = "COM11" baud = 4800 dialect = "ts590" perms = ["read", "write", "ptt"] +single_vfo = true [[hamlib_net]] name = "engine" @@ -440,6 +451,7 @@ perms = ["read"] assert_eq!(config.face.len(), 1); assert_eq!(config.hamlib_net.len(), 1); assert!(config.face[0].permissions().ptt); + assert!(config.face[0].single_vfo, "single_vfo parses as true"); assert!(config.hamlib_net[0].permissions().read); assert!(!config.hamlib_net[0].permissions().write); assert_eq!(config.baseline_interval(), Duration::from_millis(200)); @@ -464,6 +476,10 @@ dialect = "ts590" assert_eq!(config.ptt.max_tx_ms, 300_000); assert!(config.events.native_push); assert_eq!(config.face[0].baud, 4_800); + assert!( + !config.face[0].single_vfo, + "single_vfo defaults to false (native dual-VFO presentation)" + ); } #[test] diff --git a/crates/cathub/src/dialect/kenwood/ts590.rs b/crates/cathub/src/dialect/kenwood/ts590.rs index fdb3079..476aeaf 100644 --- a/crates/cathub/src/dialect/kenwood/ts590.rs +++ b/crates/cathub/src/dialect/kenwood/ts590.rs @@ -26,14 +26,26 @@ impl Ts590Dialect { } /// Synthesize a Kenwood `IF;` status answer (38 bytes) from the universal state. -fn synth_if(snapshot: &Snapshot) -> Vec { +/// +/// When `single_vfo` is true (operating-VFO virtualization) the operating VFO is always +/// presented as VFO A with no split, so a single-VFO logger (N1MM SO1V) never sees VFO B +/// in the P10 field and never warns about it. +fn synth_if(snapshot: &Snapshot, single_vfo: bool) -> Vec { let freq = snapshot.vfo(snapshot.rx_vfo).freq_hz; let tx = u8::from(snapshot.ptt); let mode = mode_to_digit(snapshot.vfo(snapshot.rx_vfo).mode) - b'0'; - let split = u8::from(snapshot.split); - let rx_vfo = match snapshot.rx_vfo { - Vfo::A => 0u8, - Vfo::B => 1u8, + let split = if single_vfo { + 0 + } else { + u8::from(snapshot.split) + }; + let rx_vfo = if single_vfo { + 0u8 + } else { + match snapshot.rx_vfo { + Vfo::A => 0u8, + Vfo::B => 1u8, + } }; format!("IF{freq:011}0000+0000000000{tx}{mode}{rx_vfo}0{split}0000;").into_bytes() } @@ -45,19 +57,44 @@ impl ClientDialect for Ts590Dialect { return ERR.to_vec(); }; let read = payload.is_empty(); + // In operating-VFO virtualization, every VFO-addressed read/write targets the + // operating (rx) VFO so the face only ever sees a single VFO presented as VFO A. + let single_vfo = ctx.single_vfo(); + let op_vfo = ctx.snapshot().rx_vfo; match verb.as_slice() { b"FA" => { + let target = if single_vfo { op_vfo } else { Vfo::A }; if read { - freq_frame(b"FA", ctx.snapshot().vfo(Vfo::A).freq_hz) + freq_frame(b"FA", ctx.snapshot().vfo(target).freq_hz) } else { - set_freq(ctx, Vfo::A, &payload).await + set_freq(ctx, target, &payload).await } } b"FB" => { + // For a single-VFO face the "other" VFO is hidden: FB mirrors the operating + // VFO so no path exposes physical VFO B. Dual-VFO faces address real B. + let target = if single_vfo { op_vfo } else { Vfo::B }; if read { - freq_frame(b"FB", ctx.snapshot().vfo(Vfo::B).freq_hz) + freq_frame(b"FB", ctx.snapshot().vfo(target).freq_hz) } else { - set_freq(ctx, Vfo::B, &payload).await + set_freq(ctx, target, &payload).await + } + } + // A single-VFO face must never see the receive/transmit VFO selectors expose + // VFO B (they would otherwise fall through to a raw passthrough). Present the + // operating VFO as VFO A and swallow attempts to select a VFO. + b"FR" if single_vfo => { + if read { + b"FR0;".to_vec() + } else { + Vec::new() + } + } + b"FT" if single_vfo => { + if read { + b"FT0;".to_vec() + } else { + Vec::new() } } b"MD" => { @@ -119,7 +156,7 @@ impl ClientDialect for Ts590Dialect { } b"ID" if read => b"ID021;".to_vec(), b"PS" if read => b"PS1;".to_vec(), - b"IF" if read => synth_if(&ctx.snapshot()), + b"IF" if read => synth_if(&ctx.snapshot(), single_vfo), // Any other native command is forwarded as a permission-gated passthrough. _ => { let class = if read { @@ -137,6 +174,9 @@ impl ClientDialect for Ts590Dialect { return None; } let snap = ctx.snapshot(); + if ctx.single_vfo() { + return single_vfo_notification(change, &snap); + } match *change { StateChange::Freq { vfo, hz } => { // Always emit the explicit per-VFO frame for VFO-specific trackers. @@ -148,7 +188,7 @@ impl ClientDialect for Ts590Dialect { // FB-only push made frequency updates silently stop whenever the rig was on // VFO B. Appending `IF` makes VFO B behave exactly like VFO A. if vfo == snap.rx_vfo { - frame.extend_from_slice(&synth_if(&snap)); + frame.extend_from_slice(&synth_if(&snap, false)); } Some(frame) } @@ -156,15 +196,21 @@ impl ClientDialect for Ts590Dialect { // `MD` reflects the active VFO on a real TS-590; emit it plus the operating // `IF` so mode trackers follow the active VFO regardless of A/B. let mut frame = vec![b'M', b'D', mode_to_digit(mode), b';']; - frame.extend_from_slice(&synth_if(&snap)); + frame.extend_from_slice(&synth_if(&snap, false)); Some(frame) } - StateChange::RxVfo { .. } => Some(synth_if(&snap)), + StateChange::RxVfo { .. } => Some(synth_if(&snap, false)), _ => None, } } fn format_passthrough(&self, raw: &[u8], ctx: &FaceContext) -> Option> { + // A single-VFO face sees a curated, virtualized view: never relay the radio's raw + // CAT stream, which can carry FA/FB/FR/IF frames that would leak physical VFO B and + // break the "operating VFO is always VFO A" illusion. + if ctx.single_vfo() { + return None; + } // A certified-native client that enabled auto-information expects the radio's CAT // stream verbatim. Relaying unmodeled frames (NB/NR/AG/front-panel changes, ...) // keeps its client-side feature state machines in sync; without this, a client like @@ -177,6 +223,35 @@ impl ClientDialect for Ts590Dialect { } } +/// Build the operating-VFO-virtualized notification: the operating VFO is always presented +/// as VFO A, inactive-VFO churn is suppressed, and an A/B switch re-presents the new +/// operating VFO as VFO A (FA+MD+IF) so a single-VFO logger seamlessly retunes. +fn single_vfo_notification(change: &StateChange, snap: &Snapshot) -> Option> { + match *change { + StateChange::Freq { vfo, hz } if vfo == snap.rx_vfo => { + let mut frame = freq_frame(b"FA", hz); + frame.extend_from_slice(&synth_if(snap, true)); + Some(frame) + } + StateChange::Mode { vfo, mode } if vfo == snap.rx_vfo => { + let mut frame = vec![b'M', b'D', mode_to_digit(mode), b';']; + frame.extend_from_slice(&synth_if(snap, true)); + Some(frame) + } + StateChange::RxVfo { .. } => { + // The operator pressed A/B: re-present the new operating VFO as VFO A so the + // logger retunes exactly as if VFO A had jumped to the new frequency and mode. + let op = snap.vfo(snap.rx_vfo); + let mut frame = freq_frame(b"FA", op.freq_hz); + frame.extend_from_slice(&[b'M', b'D', mode_to_digit(op.mode), b';']); + frame.extend_from_slice(&synth_if(snap, true)); + Some(frame) + } + // Inactive-VFO frequency/mode churn is invisible to a single-VFO face. + _ => None, + } +} + async fn set_freq(ctx: &FaceContext, vfo: Vfo, payload: &[u8]) -> Vec { let Ok(hz) = std::str::from_utf8(payload) .unwrap_or("") @@ -225,6 +300,13 @@ mod tests { (FaceContext::new(5, perms, state, radio, ptt, caps), backend) } + /// Like [`ctx_with`] but the face uses operating-VFO virtualization (single-VFO + /// presentation), as configured for N1MM SO1V. + fn single_vfo_ctx(perms: FacePermissions) -> (FaceContext, LoopbackBackend) { + let (ctx, backend) = ctx_with(perms); + (ctx.with_single_vfo(true), backend) + } + #[tokio::test] async fn reads_frequency_from_cache() { let (ctx, _b) = ctx_with(FacePermissions::read_only()); @@ -312,7 +394,7 @@ mod tests { // per-VFO `FA` frame *and* the operating-status `IF` frame so clients that track the // operating frequency (N1MM, Log4OM-as-TS590) follow the change. let mut expected = b"FA00014074000;".to_vec(); - expected.extend_from_slice(&synth_if(&ctx.snapshot())); + expected.extend_from_slice(&synth_if(&ctx.snapshot(), false)); assert_eq!(note, Some(expected)); } @@ -346,7 +428,7 @@ mod tests { &ctx, ) .expect("active-VFO freq change must notify"); - let synth = synth_if(&ctx.snapshot()); + let synth = synth_if(&ctx.snapshot(), false); assert!( note.windows(synth.len()).any(|w| w == synth.as_slice()), "VFO B active-freq notification must include the operating IF frame; got {:?}", @@ -468,4 +550,220 @@ mod tests { tokio::time::sleep(Duration::from_millis(20)).await; assert_eq!(backend.passthroughs(), vec![b"RM;".to_vec()]); } + + // --- Operating-VFO virtualization (single_vfo) ----------------------------------- + + /// With the rig on VFO B, a single-VFO face must see the operating frequency presented + /// as VFO A: `IF;` reports P10 == '0' (not '1') so N1MM SO1V never warns, and the + /// frequency is VFO B's operating frequency. + #[tokio::test] + async fn single_vfo_if_presents_operating_vfo_b_as_vfo_a() { + let (ctx, _b) = single_vfo_ctx(FacePermissions::read_only()); + ctx.state.record( + StateChange::RxVfo { vfo: Vfo::B }, + RadioEventSource::PollDiff, + ); + ctx.state.record( + StateChange::Freq { + vfo: Vfo::B, + hz: 14_034_320, + }, + RadioEventSource::PollDiff, + ); + let reply = Ts590Dialect::new().handle(b"IF;", &ctx).await; + assert_eq!(reply.len(), 38); + // P10 (the active-VFO digit) is at index 30 in the 38-byte IF answer. + assert_eq!(reply[30], b'0', "operating VFO must be presented as VFO A"); + assert!( + reply.windows(11).any(|w| w == b"00014034320"), + "IF must carry the operating (VFO B) frequency; got {}", + String::from_utf8_lossy(&reply) + ); + } + + /// A single-VFO face reads the operating VFO's frequency via `FA;`, even when the rig + /// is physically on VFO B. + #[tokio::test] + async fn single_vfo_fa_read_returns_operating_vfo_b_freq() { + let (ctx, _b) = single_vfo_ctx(FacePermissions::read_only()); + ctx.state.record( + StateChange::RxVfo { vfo: Vfo::B }, + RadioEventSource::PollDiff, + ); + ctx.state.record( + StateChange::Freq { + vfo: Vfo::B, + hz: 14_034_320, + }, + RadioEventSource::PollDiff, + ); + assert_eq!( + Ts590Dialect::new().handle(b"FA;", &ctx).await, + b"FA00014034320;".to_vec() + ); + } + + /// An `FA` write from a single-VFO face must tune the operating VFO (B), not VFO A. + #[tokio::test] + async fn single_vfo_fa_write_tunes_operating_vfo_b() { + let (ctx, backend) = single_vfo_ctx(FacePermissions::from_tokens(&["read", "write"])); + ctx.state.record( + StateChange::RxVfo { vfo: Vfo::B }, + RadioEventSource::PollDiff, + ); + assert_eq!( + Ts590Dialect::new().handle(b"FA00014050000;", &ctx).await, + Vec::::new() + ); + tokio::time::sleep(Duration::from_millis(20)).await; + assert_eq!( + backend.mutations(), + vec![StateMutation::SetVfoFreq { + vfo: Vfo::B, + hz: 14_050_000 + }] + ); + } + + /// The receive-VFO selector never exposes VFO B to a single-VFO face: `FR;` reports + /// VFO A and a `FR1;` select is swallowed (not forwarded to the radio). + #[tokio::test] + async fn single_vfo_fr_is_virtualized_to_vfo_a() { + let (ctx, backend) = single_vfo_ctx(FacePermissions::from_tokens(&["read", "write"])); + ctx.state.record( + StateChange::RxVfo { vfo: Vfo::B }, + RadioEventSource::PollDiff, + ); + assert_eq!( + Ts590Dialect::new().handle(b"FR;", &ctx).await, + b"FR0;".to_vec() + ); + assert_eq!( + Ts590Dialect::new().handle(b"FR1;", &ctx).await, + Vec::::new() + ); + tokio::time::sleep(Duration::from_millis(20)).await; + assert!( + backend.passthroughs().is_empty(), + "FR must never reach the radio as a passthrough on a single-VFO face" + ); + } + + /// Switching the operating VFO (A->B) must re-present the new operating VFO as VFO A: + /// the push carries `FA`(new freq) + `MD`(new mode) + an `IF` with P10 == '0', so a + /// single-VFO logger seamlessly retunes with no SO1V warning. + #[tokio::test] + async fn single_vfo_rxvfo_switch_re_presents_operating_as_vfo_a() { + let (ctx, _b) = single_vfo_ctx(FacePermissions::read_only()); + ctx.state.record( + StateChange::Freq { + vfo: Vfo::B, + hz: 21_205_000, + }, + RadioEventSource::PollDiff, + ); + ctx.state.record( + StateChange::Mode { + vfo: Vfo::B, + mode: Mode::Cw, + }, + RadioEventSource::PollDiff, + ); + ctx.state.record( + StateChange::RxVfo { vfo: Vfo::B }, + RadioEventSource::PollDiff, + ); + ctx.set_ai(true); + let note = Ts590Dialect::new() + .format_notification(&StateChange::RxVfo { vfo: Vfo::B }, &ctx) + .expect("A/B switch must notify a single-VFO face"); + assert!( + note.windows(b"FA00021205000;".len()) + .any(|w| w == b"FA00021205000;"), + "switch must push the new operating frequency as FA; got {}", + String::from_utf8_lossy(¬e) + ); + assert!( + note.windows(4).any(|w| w == b"MD3;"), + "switch must push the new operating mode (CW=3); got {}", + String::from_utf8_lossy(¬e) + ); + // The trailing IF must present VFO A (P10 == '0'), never VFO B. + let if_pos = note + .windows(2) + .position(|w| w == b"IF") + .expect("notification must contain an IF frame"); + assert_eq!( + note[if_pos + 30], + b'0', + "the operating VFO must be presented as VFO A in the IF frame" + ); + } + + /// An active-VFO (B) frequency change pushes `FA` (not `FB`) plus an `IF` presenting + /// VFO A, so N1MM SO1V tracks the change. Inactive-VFO churn is suppressed. + #[tokio::test] + async fn single_vfo_active_freq_change_pushes_fa_as_vfo_a() { + let (ctx, _b) = single_vfo_ctx(FacePermissions::read_only()); + ctx.state.record( + StateChange::RxVfo { vfo: Vfo::B }, + RadioEventSource::PollDiff, + ); + ctx.state.record( + StateChange::Freq { + vfo: Vfo::B, + hz: 14_034_320, + }, + RadioEventSource::PollDiff, + ); + ctx.set_ai(true); + let note = Ts590Dialect::new() + .format_notification( + &StateChange::Freq { + vfo: Vfo::B, + hz: 14_034_320, + }, + &ctx, + ) + .expect("active-VFO freq change must notify"); + assert!( + note.starts_with(b"FA00014034320;"), + "active VFO-B change must be presented as an FA frame; got {}", + String::from_utf8_lossy(¬e) + ); + assert!( + !note.windows(2).any(|w| w == b"FB"), + "a single-VFO face must never receive an FB frame; got {}", + String::from_utf8_lossy(¬e) + ); + let if_pos = note.windows(2).position(|w| w == b"IF").expect("IF frame"); + assert_eq!(note[if_pos + 30], b'0', "IF must present VFO A"); + } + + /// A frequency change on the *inactive* VFO is invisible to a single-VFO face. + #[tokio::test] + async fn single_vfo_inactive_freq_change_is_suppressed() { + let (ctx, _b) = single_vfo_ctx(FacePermissions::read_only()); + // Operating on VFO A; a change on VFO B must not be pushed. + ctx.set_ai(true); + let note = Ts590Dialect::new().format_notification( + &StateChange::Freq { + vfo: Vfo::B, + hz: 7_000_000, + }, + &ctx, + ); + assert_eq!(note, None); + } + + /// A single-VFO face never receives raw passthrough frames (which could leak VFO B). + #[tokio::test] + async fn single_vfo_suppresses_raw_passthrough() { + let (ctx, _b) = single_vfo_ctx(FacePermissions::read_only()); + ctx.set_ai(true); + assert_eq!( + Ts590Dialect::new().format_passthrough(b"FB00014000000;", &ctx), + None + ); + } } diff --git a/crates/cathub/src/dialect/mod.rs b/crates/cathub/src/dialect/mod.rs index 206c070..b0eedfe 100644 --- a/crates/cathub/src/dialect/mod.rs +++ b/crates/cathub/src/dialect/mod.rs @@ -56,6 +56,10 @@ pub(crate) struct FaceContext { pub(crate) ptt: PttManager, /// This face's virtualized auto-information flag (never reaches the radio). ai: Arc, + /// When true, present the *operating* VFO as VFO A to this face (operating-VFO + /// virtualization). Set from `[[face]] single_vfo` for single-VFO loggers such as + /// N1MM SO1V; left false for true dual-VFO control faces such as ARCP-590. + single_vfo: bool, } impl FaceContext { @@ -76,9 +80,18 @@ impl FaceContext { radio, ptt, ai: Arc::new(AtomicBool::new(false)), + single_vfo: false, } } + /// Enable operating-VFO virtualization for this face (builder style so existing + /// call sites are unaffected). When enabled, the face always sees the operating VFO + /// presented as VFO A. Returns `self` for chaining. + pub(crate) fn with_single_vfo(mut self, single_vfo: bool) -> Self { + self.single_vfo = single_vfo; + self + } + /// A consistent point-in-time view of the radio. pub(crate) fn snapshot(&self) -> Snapshot { self.state.snapshot() @@ -171,6 +184,12 @@ impl FaceContext { self.ai.load(Ordering::SeqCst) } + /// Whether this face uses operating-VFO virtualization (operating VFO presented as + /// VFO A). True for single-VFO loggers (N1MM SO1V); false for dual-VFO faces. + pub(crate) fn single_vfo(&self) -> bool { + self.single_vfo + } + /// A clone of this context for a new connection: same shared state/radio/ptt and /// permissions, but a distinct face id and a fresh (off) auto-information flag. pub(crate) fn clone_with_face(&self, face_id: u64) -> FaceContext { @@ -182,6 +201,8 @@ impl FaceContext { radio: self.radio.clone(), ptt: self.ptt.clone(), ai: Arc::new(AtomicBool::new(false)), + // A new connection to the same face keeps that face's VFO-presentation policy. + single_vfo: self.single_vfo, } } diff --git a/crates/cathub/src/integration.rs b/crates/cathub/src/integration.rs index 9bca94d..1589892 100644 --- a/crates/cathub/src/integration.rs +++ b/crates/cathub/src/integration.rs @@ -75,6 +75,27 @@ impl Rig { tokio::spawn(run_face(server, dialect, ctx, b';')); client } + + /// A single-VFO face (e.g. N1MM in SO1V) that virtualizes the operating VFO as VFO A. + fn single_vfo_face( + &self, + dialect: Arc, + perms: FacePermissions, + id: u64, + ) -> DuplexStream { + let ctx = FaceContext::new( + id, + perms, + self.state.clone(), + self.radio.clone(), + self.ptt.clone(), + self.caps.clone(), + ) + .with_single_vfo(true); + let (client, server) = tokio::io::duplex(1024); + tokio::spawn(run_face(server, dialect, ctx, b';')); + client + } } fn ts590() -> Arc { @@ -260,6 +281,79 @@ async fn front_panel_change_fans_out_to_all_subscribed_faces() { assert_eq!(read_frame(&mut b).await, b"FA00007123000;"); } +/// End-to-end N1MM SO1V regression: when the operator switches the radio to VFO B, a +/// single-VFO face must keep tracking. The hub re-presents the operating VFO as VFO A, so +/// the face receives an `FA`(operating freq) + `MD` + `IF` (with the active-VFO digit '0') +/// and never an `FB` or an `IF` reporting VFO B (which trips N1MM's "do not use VFO B" +/// guard and freezes its frequency display). +#[tokio::test] +async fn single_vfo_face_tracks_an_a_to_b_switch_without_leaking_vfo_b() { + let rig = rig(); + let mut n1mm = rig.single_vfo_face(ts590(), FacePermissions::read_only(), 1); + + // N1MM enables auto-info. + n1mm.write_all(b"AI2;").await.expect("write"); + tokio::time::sleep(Duration::from_millis(20)).await; + + // The radio already knows VFO B's frequency and mode (primed by the initial poll). + rig.state.record( + StateChange::Freq { + vfo: Vfo::B, + hz: 14_034_320, + }, + RadioEventSource::PollDiff, + ); + rig.state.record( + StateChange::Mode { + vfo: Vfo::B, + mode: crate::model::Mode::Cw, + }, + RadioEventSource::PollDiff, + ); + + // Operator switches the radio to VFO B. + rig.state.record( + StateChange::RxVfo { vfo: Vfo::B }, + RadioEventSource::PollDiff, + ); + + // Collect the re-presentation push (FA + MD + IF) and assert it never exposes VFO B. + let mut pushed = Vec::new(); + for _ in 0..3 { + let frame = match tokio::time::timeout(Duration::from_secs(1), read_frame(&mut n1mm)).await + { + Ok(frame) if !frame.is_empty() => frame, + _ => break, + }; + pushed.extend_from_slice(&frame); + if pushed.windows(2).any(|w| w == b"IF") { + break; + } + } + + assert!( + pushed + .windows(b"FA00014034320;".len()) + .any(|w| w == b"FA00014034320;"), + "the switch must push the operating frequency as FA; got {}", + String::from_utf8_lossy(&pushed) + ); + assert!( + !pushed.windows(2).any(|w| w == b"FB"), + "a single-VFO face must never receive an FB frame; got {}", + String::from_utf8_lossy(&pushed) + ); + let if_pos = pushed + .windows(2) + .position(|w| w == b"IF") + .expect("the push must include an IF frame"); + assert_eq!( + pushed[if_pos + 30], + b'0', + "the IF frame must present the operating VFO as VFO A (P10 == '0')" + ); +} + /// Writes fanned in from several faces are strictly serialized through the one radio task. #[tokio::test] async fn writes_from_all_faces_are_strictly_ordered() { diff --git a/crates/cathub/src/lib.rs b/crates/cathub/src/lib.rs index 6066d2d..a58943d 100644 --- a/crates/cathub/src/lib.rs +++ b/crates/cathub/src/lib.rs @@ -291,7 +291,8 @@ pub async fn run(cli: Cli) -> Result<(), CatHubError> { radio.clone(), ptt.clone(), caps.clone(), - ); + ) + .with_single_vfo(face.single_vfo); let port = open_serial(&face.name, &face.transport, face.baud)?; tokio::spawn(run_face(port, dialect, ctx, b';')); tracing::info!( diff --git a/docs/design/multi-client-cat-hub.md b/docs/design/multi-client-cat-hub.md index 17e7e9f..799889f 100644 --- a/docs/design/multi-client-cat-hub.md +++ b/docs/design/multi-client-cat-hub.md @@ -275,6 +275,21 @@ Downstream fan-out is identical regardless of source, so a station with a non-pu The auto-info command is virtualized per face, but for Kenwood `AI1` (post-command echo) and `AI2` (spontaneous updates) are not identical semantics (other vendors have analogous distinctions). v1 may collapse them to a single per-face push subscription; if it does, that limitation is documented and ARCP-590 and N1MM are tested specifically to confirm they tolerate the collapse before either is claimed as first-class. If a tested client depends on the distinction, the finer model is implemented for that dialect. +### 8.4.2 Single-VFO operating-VFO virtualization + +Some loggers run as single-VFO controllers and actively reject the inactive VFO. N1MM Logger+ in **SO1V** mode is the canonical case: when the radio reports VFO B as the active receive VFO (Kenwood `IF;` field P10 = `1`), N1MM raises *"You should not use VFO B when configured for SO1V"* and **freezes its frequency display**, so an operator working on VFO B sees a stuck frequency even though the hub's cache is correct. Faithfully reporting P10 = `1` (§8.4 native active-VFO rule) is *correct* radio behavior, but it defeats the hub's goal of seamless A/B operation for this class of client. + +The fix is a per-face, opt-in **operating-VFO virtualization** policy (`single_vfo = true` on a `ts590` face). When enabled, the face never exposes the physical VFO letter; it always presents whichever VFO the radio is actually on (the operating / receive VFO) **as VFO A**: + +- **`IF;` reads** report P10 = `0` (VFO A) and split = `0`, carrying the operating VFO's frequency and mode. The 38-byte frame length is unchanged; only the active-VFO and split digits are rewritten. +- **`FA;` reads** return the operating VFO's frequency; **`FA` writes** tune the operating VFO (so a "set VFO A" from the logger tunes physical VFO B when the rig is on B). +- **`FB`** is mirrored onto the operating VFO rather than leaking physical VFO B, and split-on-B never reaches the face. +- **`FR`/`FT`** (receive/transmit VFO select) are intercepted: reads return VFO A; a select write is swallowed (never forwarded to the radio), so the logger can never drive an A/B retarget. +- **Notifications** are re-presented: an operating-VFO frequency change pushes `FA` (never `FB`) plus the synthesized operating `IF`; a mode change pushes `MD` + `IF`; and an **A→B (or B→A) switch re-presents the new operating VFO** by pushing its `FA` + `MD` + an `IF` with P10 = `0`, so the logger seamlessly retunes with no SO1V warning. Inactive-VFO churn is suppressed. +- **Raw passthrough** frames are suppressed for a single-VFO face, since a verbatim native frame could leak a physical VFO-B verb. + +This policy is **opt-in per face and off by default**. It is enabled only for genuine single-VFO loggers (N1MM SO1V). Dual-VFO faceplates such as **ARCP-590 must leave it off** (`single_vfo = false`), because they legitimately control and display the real VFO A and VFO B. Split-aware clients are out of scope for this simplification: a single-VFO face always sees split = `0`. SO2V is an N1MM-side workaround, not a substitute for this hub-side virtualization. + ### 8.5 PTT ownership and arbitration Multiple clients are now PTT-capable (N1MM CAT PTT, WSJT-X `T 1`, ARCP-590). The daemon arbitrates with a single-owner lease: diff --git a/docs/integration/setup.md b/docs/integration/setup.md index 47c8cac..5629861 100644 --- a/docs/integration/setup.md +++ b/docs/integration/setup.md @@ -125,7 +125,13 @@ hub on its own (for example with `-DryRun` to validate config). ### N1MM Logger+ - Configurer > Hardware: radio `Kenwood`, port **COM21**, 115200, 8-N-1, no flow control. -- The `n1mm` face is `dialect = "ts590"`, `perms = ["read", "write", "ptt"]`. +- The `n1mm` face is `dialect = "ts590"`, `single_vfo = true`, `perms = ["read", "write", "ptt"]`. +- **`single_vfo = true` is required for SO1V.** N1MM in single-VFO (SO1V) mode refuses VFO B + ("You should not use VFO B when configured for SO1V") and freezes its frequency display when + the radio is on VFO B. With `single_vfo = true` the hub presents whichever VFO the radio is + on as VFO A, so N1MM tracks the radio across A/B switches with no warning. If you run N1MM in + SO2V instead, you may set `single_vfo = false`; for SO1V leave it on (the shipped default for + this face). See design §8.4.2. ### ARCP-590 - Set ARCP-590's COM port to **COM31**, 115200, 8-N-1. From 2e14231cf238f0f000f3beb61570b1393fd07af6 Mon Sep 17 00:00:00 2001 From: Randy Treit <38894017+rtreit@users.noreply.github.com> Date: Thu, 4 Jun 2026 14:26:46 -0700 Subject: [PATCH 18/36] cathub: extend single_vfo virtualization to hamlib_net faces (WSJT-X, Log4OM) (#495) WSJT-X stopped decoding on VFO B and Log4OM logged the wrong frequency because the rigctld-compatible hamlib_net faces had no single_vfo operating-VFO virtualization (unlike the serial faces fixed in #494). On VFO B the engine exposed VFOB via 'v' (confusing WSJT-X) and get_vfo_info VFOA returned the STALE inactive VFO A frequency, so Log4OM (which polls +\get_vfo_info VFOA) tracked the wrong freq. Adds a strict single-VFO contract to hamlib_net faces flagged single_vfo=true: any requested VFO resolves to the operating (rx) VFO and is presented as VFOA; get_vfo_info forces Split 0; get_split reports 0/VFOA; V ? advertises only VFOA; set_split_vfo 1 is rejected with RPRT_ENAVAIL (so split is visibly unavailable, forcing WSJT-X Fake It rather than silently TXing on the wrong freq). Freq/mode writes already target the operating VFO, so tuning stays real. wsjtx (4533) and log4om (4534) get single_vfo=true; the engine face (4532) stays single_vfo=false so the engine logs the true operating VFO. Adds 10 hamlib_net unit tests; all 186 cathub tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- config/cathub.toml | 8 + crates/cathub/src/config.rs | 17 +- crates/cathub/src/hamlib_net.rs | 287 +++++++++++++++++++++++++--- crates/cathub/src/lib.rs | 3 +- docs/design/multi-client-cat-hub.md | 13 ++ docs/integration/setup.md | 24 ++- 6 files changed, 318 insertions(+), 34 deletions(-) diff --git a/config/cathub.toml b/config/cathub.toml index 06c14de..810d4ff 100644 --- a/config/cathub.toml +++ b/config/cathub.toml @@ -36,15 +36,23 @@ bind = "127.0.0.1:4532" perms = ["read"] # Write + PTT endpoint for WSJT-X (configured as Hamlib NET rigctl). +# single_vfo: WSJT-X expects to receive on VFO A and stops decoding if the hub reports the +# operator is on VFO B. Present the operating VFO as VFO A so WSJT-X decodes on either VFO. +# Use WSJT-X's "Fake It" split mode (Settings -> Radio -> Split Operation) with this endpoint; +# real "Rig" split is rejected because a single-VFO presentation cannot model an A/B split. [[hamlib_net]] name = "wsjtx" bind = "127.0.0.1:4533" +single_vfo = true perms = ["read", "write", "ptt"] # Read/write endpoint for Log4OM (CAT over Hamlib NET rigctl). +# single_vfo: Log4OM polls `\get_vfo_info VFOA`. Presenting the operating VFO as VFO A makes +# Log4OM log the live frequency/mode on either VFO instead of the stale inactive VFO A. [[hamlib_net]] name = "log4om" bind = "127.0.0.1:4534" +single_vfo = true perms = ["read", "write"] # --- Serial faces (com0com virtual pairs) ---------------------------------------------- diff --git a/crates/cathub/src/config.rs b/crates/cathub/src/config.rs index b6da9bb..9e0988b 100644 --- a/crates/cathub/src/config.rs +++ b/crates/cathub/src/config.rs @@ -168,6 +168,19 @@ pub(crate) struct HamlibNetConfig { /// Permission tokens (`read`, `write`, `ptt`, `config_write`). #[serde(default)] pub(crate) perms: Vec, + /// Present the *operating* VFO as VFO A to this endpoint (operating-VFO virtualization). + /// + /// Single-VFO rigctld clients (notably WSJT-X, which expects to receive on VFO A, and + /// Log4OM, which polls `\get_vfo_info VFOA`) misbehave when the hub reports the true + /// VFO B as the active receive VFO: WSJT-X stops decoding and Log4OM logs the inactive + /// VFO A's stale frequency. With `single_vfo = true` the endpoint always presents + /// whichever VFO the operator is actually using as VFO A (`get_vfo` -> `VFOA`, + /// `get_vfo_info` answers from the operating VFO with `Split: 0`, `get_split_vfo` is + /// `0/VFOA`, and `set_split_vfo 1` is rejected so a client never believes a real A/B + /// split was armed). Leave this `false` for true dual-VFO control endpoints that must + /// see and address real VFO A and B independently. + #[serde(default)] + pub(crate) single_vfo: bool, } impl HamlibNetConfig { @@ -334,8 +347,8 @@ impl Config { for ep in &self.hamlib_net { let _ = writeln!( out, - "hamlib_net: name={} bind={} perms={:?}", - ep.name, ep.bind, ep.perms + "hamlib_net: name={} bind={} perms={:?} single_vfo={}", + ep.name, ep.bind, ep.perms, ep.single_vfo ); } if !self.face.is_empty() || !self.hamlib_net.is_empty() { diff --git a/crates/cathub/src/hamlib_net.rs b/crates/cathub/src/hamlib_net.rs index b267867..f158cb3 100644 --- a/crates/cathub/src/hamlib_net.rs +++ b/crates/cathub/src/hamlib_net.rs @@ -34,6 +34,36 @@ fn vfo_name(vfo: Vfo) -> &'static str { } } +/// Resolve a client-requested VFO to the physical VFO whose state should answer. +/// +/// On a `single_vfo` endpoint the hub presents the operator's *operating* (receive) VFO as +/// VFO A and never exposes the inactive VFO, so every request resolves to the operating VFO +/// regardless of the letter the client asked for. On a normal dual-VFO endpoint the +/// requested VFO is honored verbatim. +fn resolve_vfo(ctx: &FaceContext, snapshot: &crate::state::Snapshot, requested: Vfo) -> Vfo { + if ctx.single_vfo() { + snapshot.rx_vfo + } else { + requested + } +} + +/// The VFO letter this endpoint presents as "current". A `single_vfo` endpoint always shows +/// the operating VFO as VFO A; a dual-VFO endpoint shows the real receive VFO. +fn presented_current_vfo(ctx: &FaceContext, snapshot: &crate::state::Snapshot) -> Vfo { + if ctx.single_vfo() { + Vfo::A + } else { + snapshot.rx_vfo + } +} + +/// The split flag this endpoint presents. A `single_vfo` endpoint can only represent one +/// VFO, so it always reports "no split" to avoid exposing a real A/B split it cannot model. +fn presented_split(ctx: &FaceContext, snapshot: &crate::state::Snapshot) -> bool { + !ctx.single_vfo() && snapshot.split +} + /// Parse a `set_freq` argument into whole Hz. /// /// Hamlib clients (WSJT-X, the engine, Log4OM) format frequencies as a double with @@ -160,28 +190,39 @@ async fn handle_ext(sep: char, rest: &str, ctx: &FaceContext) -> Vec { // The supported-token list line is newline-terminated regardless of the separator, // matching real `rigctld`. if matches!(cmd, "V" | "\\set_vfo") && args.first() == Some(&"?") { - return format!("set_vfo: ?{sep}VFOA VFOB \nRPRT 0\n").into_bytes(); + // A single_vfo endpoint advertises only VFOA so clients never target the inactive + // VFO; a dual-VFO endpoint advertises both. + let list = if ctx.single_vfo() { + "VFOA " + } else { + "VFOA VFOB " + }; + return format!("set_vfo: ?{sep}{list}\nRPRT 0\n").into_bytes(); } - // get_vfo_info: Log4OM-NG's poll command (`+\get_vfo_info VFOA`). Reports the named - // VFO's frequency, mode, passband, split, and (always 0) satellite mode. + // get_vfo_info: Log4OM-NG's poll command (`+\get_vfo_info VFOA`). Reports the resolved + // VFO's frequency, mode, passband, split, and (always 0) satellite mode. A single_vfo + // endpoint resolves any requested VFO to the operating VFO, labels it VFOA, and reports + // no split. if cmd == "\\get_vfo_info" { if !reads { return erp_records(sep, &[ext_echo(cmd, &args), "RPRT -1".to_string()]); } - let vfo = match args.first() { + let requested = match args.first() { Some(v) if v.eq_ignore_ascii_case("VFOB") => Vfo::B, _ => Vfo::A, }; - let v = snapshot.vfo(vfo); + let resolved = resolve_vfo(ctx, &snapshot, requested); + let shown_name = if ctx.single_vfo() { Vfo::A } else { requested }; + let v = snapshot.vfo(resolved); return erp_records( sep, &[ - format!("get_vfo_info: {}", vfo_name(vfo)), + format!("get_vfo_info: {}", vfo_name(shown_name)), format!("Freq: {}", v.freq_hz), format!("Mode: {}", v.mode.hamlib_token_with_data(v.data)), format!("Width: {}", v.passband_hz), - format!("Split: {}", u8::from(snapshot.split)), + format!("Split: {}", u8::from(presented_split(ctx, &snapshot))), "SatMode: 0".to_string(), "RPRT 0".to_string(), ], @@ -206,7 +247,7 @@ async fn handle_ext(sep: char, rest: &str, ctx: &FaceContext) -> Vec { } "v" | "\\get_vfo" if reads => Some(vec![ "get_vfo:".to_string(), - format!("VFO: {}", vfo_name(snapshot.rx_vfo)), + format!("VFO: {}", vfo_name(presented_current_vfo(ctx, &snapshot))), "RPRT 0".to_string(), ]), "t" | "\\get_ptt" if reads => Some(vec![ @@ -266,32 +307,36 @@ async fn dispatch_plain(cmd: &str, args: &[&str], ctx: &FaceContext) -> LineResu .into_bytes() }), "v" | "\\get_vfo" => guard_read(ctx, || { - format!("{}\n", vfo_name(snapshot.rx_vfo)).into_bytes() + format!("{}\n", vfo_name(presented_current_vfo(ctx, &snapshot))).into_bytes() }), - // get_vfo_info: report the named VFO's freq/mode/width/split/satmode as bare - // values (Freq, Mode, Width, Split, SatMode), matching real `rigctld`. + // get_vfo_info: report the resolved VFO's freq/mode/width/split/satmode as bare + // values (Freq, Mode, Width, Split, SatMode), matching real `rigctld`. A single_vfo + // endpoint resolves any requested VFO to the operating VFO and reports no split. "\\get_vfo_info" => guard_read(ctx, || { - let vfo = match args.first() { + let requested = match args.first() { Some(v) if v.eq_ignore_ascii_case("VFOB") => Vfo::B, _ => Vfo::A, }; - let v = snapshot.vfo(vfo); + let v = snapshot.vfo(resolve_vfo(ctx, &snapshot, requested)); format!( "{}\n{}\n{}\n{}\n0\n", v.freq_hz, v.mode.hamlib_token_with_data(v.data), v.passband_hz, - u8::from(snapshot.split), + u8::from(presented_split(ctx, &snapshot)), ) .into_bytes() }), "s" | "\\get_split_vfo" => guard_read(ctx, || { - let tx = if snapshot.split { + let split = presented_split(ctx, &snapshot); + let tx = if ctx.single_vfo() { + Vfo::A + } else if snapshot.split { snapshot.tx_vfo } else { snapshot.rx_vfo }; - format!("{}\n{}\n", u8::from(snapshot.split), vfo_name(tx)).into_bytes() + format!("{}\n{}\n", u8::from(split), vfo_name(tx)).into_bytes() }), "t" | "\\get_ptt" => { guard_read(ctx, || format!("{}\n", u8::from(snapshot.ptt)).into_bytes()) @@ -360,17 +405,31 @@ async fn dispatch_plain(cmd: &str, args: &[&str], ctx: &FaceContext) -> LineResu }, "S" | "\\set_split_vfo" => { let enabled = args.first().copied() == Some("1"); - let tx_vfo = match args.get(1).copied() { - Some(v) if v.eq_ignore_ascii_case("VFOB") => Some(Vfo::B), - _ => Some(Vfo::A), - }; - outcome_rprt( - ctx.apply_modeled( - StateMutation::SetSplit { enabled, tx_vfo }, - CommandClass::ModeledWrite, + // A single_vfo endpoint presents one operating VFO and cannot model a real A/B + // split. Accept "split off" as a no-op success, but reject "split on" so a + // client (e.g. WSJT-X "Rig" split) sees split is unavailable instead of a false + // success that would route TX to the wrong frequency. Use "Fake It" split. + if ctx.single_vfo() { + if !ctx.perms.allows(CommandClass::ModeledWrite) { + RPRT_EINVAL.to_vec() + } else if enabled { + RPRT_ENAVAIL.to_vec() + } else { + RPRT_OK.to_vec() + } + } else { + let tx_vfo = match args.get(1).copied() { + Some(v) if v.eq_ignore_ascii_case("VFOB") => Some(Vfo::B), + _ => Some(Vfo::A), + }; + outcome_rprt( + ctx.apply_modeled( + StateMutation::SetSplit { enabled, tx_vfo }, + CommandClass::ModeledWrite, + ) + .await, ) - .await, - ) + } } "T" | "\\set_ptt" => { // Hamlib PTT values: 0 = RX, 1 = TX (generic), 2 = TX on mic, 3 = TX on data. @@ -802,6 +861,182 @@ mod tests { assert_eq!(reply_of("m", &ctx).await, b"PKTUSB\n2400\n".to_vec()); } + /// Build a read-only single-VFO endpoint with the rig parked on VFO B: A is the inactive + /// 14.035 CW VFO, B is the operating 14.074 PKTUSB VFO. + fn single_vfo_on_b() -> (FaceContext, LoopbackBackend, StateHandle) { + let (ctx, backend, state) = ctx_with(FacePermissions::from_tokens(&["read", "write"])); + state.record( + StateChange::Freq { + vfo: Vfo::A, + hz: 14_035_000, + }, + RadioEventSource::PollDiff, + ); + state.record( + StateChange::Mode { + vfo: Vfo::A, + mode: Mode::Cw, + }, + RadioEventSource::PollDiff, + ); + state.record( + StateChange::Freq { + vfo: Vfo::B, + hz: 14_074_000, + }, + RadioEventSource::PollDiff, + ); + state.record( + StateChange::DataMode { + vfo: Vfo::B, + on: true, + }, + RadioEventSource::PollDiff, + ); + state.record( + StateChange::RxVfo { vfo: Vfo::B }, + RadioEventSource::PollDiff, + ); + (ctx.with_single_vfo(true), backend, state) + } + + #[tokio::test] + async fn single_vfo_get_vfo_reports_vfoa_on_b() { + // WSJT-X expects to receive on VFO A. On a single_vfo endpoint, get_vfo must report + // VFOA even though the rig is physically on VFO B, while freq/mode follow the + // operating VFO so the data is real. + let (ctx, _b, _s) = single_vfo_on_b(); + assert_eq!(reply_of("v", &ctx).await, b"VFOA\n".to_vec()); + assert_eq!(reply_of("f", &ctx).await, b"14074000\n".to_vec()); + assert_eq!(reply_of("m", &ctx).await, b"PKTUSB\n2400\n".to_vec()); + } + + #[tokio::test] + async fn single_vfo_get_vfo_info_vfoa_returns_operating_vfo() { + // Log4OM polls `\get_vfo_info VFOA`. On a single_vfo endpoint a VFOA request must + // resolve to the operating VFO (B's 14.074 PKTUSB), not the stale inactive VFO A. + let (ctx, _b, _s) = single_vfo_on_b(); + assert_eq!( + reply_of("\\get_vfo_info VFOA", &ctx).await, + b"14074000\nPKTUSB\n2400\n0\n0\n".to_vec() + ); + // A VFOB request also resolves to the operating VFO (strict single-VFO contract): + // the inactive VFO is never exposed. + assert_eq!( + reply_of("\\get_vfo_info VFOB", &ctx).await, + b"14074000\nPKTUSB\n2400\n0\n0\n".to_vec() + ); + } + + #[tokio::test] + async fn single_vfo_erp_get_vfo_info_returns_operating_vfo_labeled_a() { + // Log4OM-NG uses the Extended Response Protocol form `+\get_vfo_info VFOA`. + let (ctx, _b, _s) = single_vfo_on_b(); + let reply = String::from_utf8(reply_of("+\\get_vfo_info VFOA", &ctx).await).unwrap(); + assert!(reply.contains("get_vfo_info: VFOA"), "label VFOA: {reply}"); + assert!(reply.contains("Freq: 14074000"), "operating freq: {reply}"); + assert!(reply.contains("Mode: PKTUSB"), "operating mode: {reply}"); + assert!(reply.contains("Split: 0"), "no split presented: {reply}"); + } + + #[tokio::test] + async fn single_vfo_get_split_reports_no_split_vfoa() { + // Even when the radio is physically in split, a single_vfo endpoint reports no split + // and VFOA, so the client never sees a leaked VFOB letter or a split it can't model. + let (ctx, _b, state) = single_vfo_on_b(); + state.record( + StateChange::Split { + enabled: true, + tx_vfo: Some(Vfo::A), + }, + RadioEventSource::PollDiff, + ); + assert_eq!(reply_of("s", &ctx).await, b"0\nVFOA\n".to_vec()); + // get_vfo_info's Split field is likewise forced to 0. + let reply = String::from_utf8(reply_of("\\get_vfo_info VFOA", &ctx).await).unwrap(); + assert!(reply.ends_with("0\n0\n"), "split + satmode both 0: {reply}"); + } + + #[tokio::test] + async fn single_vfo_rejects_enable_split_accepts_disable() { + // WSJT-X "Rig" split would issue `S 1 VFOB`. A single_vfo endpoint cannot model a + // real A/B split, so enable-split must be rejected (not a false success that routes + // TX to the wrong frequency). Disable-split is a harmless no-op success. + let (ctx, backend, _s) = single_vfo_on_b(); + assert_eq!(reply_of("S 1 VFOB", &ctx).await, RPRT_ENAVAIL.to_vec()); + assert_eq!(reply_of("S 0 VFOA", &ctx).await, RPRT_OK.to_vec()); + tokio::time::sleep(Duration::from_millis(20)).await; + assert!( + !backend + .mutations() + .iter() + .any(|m| matches!(m, StateMutation::SetSplit { .. })), + "single_vfo split commands must never reach the radio" + ); + } + + #[tokio::test] + async fn single_vfo_set_freq_still_targets_operating_vfo_b() { + // Virtualizing the VFO letter must not change where writes land: a set_freq still + // tunes the real operating VFO (B), so WSJT-X "Fake It" QSY works on VFO B. + let (ctx, backend, _s) = single_vfo_on_b(); + assert_eq!(reply_of("F 14076000", &ctx).await, RPRT_OK.to_vec()); + tokio::time::sleep(Duration::from_millis(20)).await; + assert!( + backend.mutations().contains(&StateMutation::SetVfoFreq { + vfo: Vfo::B, + hz: 14_076_000, + }), + "set_freq must target operating VFO B, got {:?}", + backend.mutations() + ); + } + + #[tokio::test] + async fn single_vfo_erp_set_vfo_query_advertises_only_vfoa() { + let (ctx, _b, _s) = single_vfo_on_b(); + let reply = String::from_utf8(reply_of(";V ?", &ctx).await).unwrap(); + assert!(reply.contains("VFOA"), "advertises VFOA: {reply}"); + assert!(!reply.contains("VFOB"), "must not advertise VFOB: {reply}"); + } + + #[tokio::test] + async fn single_vfo_erp_get_vfo_reports_vfoa() { + let (ctx, _b, _s) = single_vfo_on_b(); + let reply = String::from_utf8(reply_of("+v", &ctx).await).unwrap(); + assert!(reply.contains("VFO: VFOA"), "ERP get_vfo VFOA: {reply}"); + } + + #[tokio::test] + async fn dual_vfo_endpoint_still_exposes_real_vfo_b() { + // Regression guard: without single_vfo (e.g. ARCP-590, the engine), the endpoint + // must keep telling the truth about VFO B and the inactive VFO A. + let (ctx, _b, state) = ctx_with(FacePermissions::read_only()); + state.record( + StateChange::Freq { + vfo: Vfo::A, + hz: 14_035_000, + }, + RadioEventSource::PollDiff, + ); + state.record( + StateChange::Freq { + vfo: Vfo::B, + hz: 14_074_000, + }, + RadioEventSource::PollDiff, + ); + state.record( + StateChange::RxVfo { vfo: Vfo::B }, + RadioEventSource::PollDiff, + ); + assert_eq!(reply_of("v", &ctx).await, b"VFOB\n".to_vec()); + assert_eq!( + reply_of("\\get_vfo_info VFOA", &ctx).await, + b"14035000\nUSB\n2400\n0\n0\n".to_vec() + ); + } + #[tokio::test] async fn ptt_requires_ptt_permission() { let (ctx, _b, _s) = ctx_with(FacePermissions::from_tokens(&["read", "write"])); diff --git a/crates/cathub/src/lib.rs b/crates/cathub/src/lib.rs index a58943d..2f17abb 100644 --- a/crates/cathub/src/lib.rs +++ b/crates/cathub/src/lib.rs @@ -313,7 +313,8 @@ pub async fn run(cli: Cli) -> Result<(), CatHubError> { radio.clone(), ptt.clone(), caps.clone(), - ); + ) + .with_single_vfo(ep.single_vfo); let bind = ep.bind.clone(); let name = ep.name.clone(); let ids = next_id.clone(); diff --git a/docs/design/multi-client-cat-hub.md b/docs/design/multi-client-cat-hub.md index 799889f..bf5ad83 100644 --- a/docs/design/multi-client-cat-hub.md +++ b/docs/design/multi-client-cat-hub.md @@ -290,6 +290,19 @@ The fix is a per-face, opt-in **operating-VFO virtualization** policy (`single_v This policy is **opt-in per face and off by default**. It is enabled only for genuine single-VFO loggers (N1MM SO1V). Dual-VFO faceplates such as **ARCP-590 must leave it off** (`single_vfo = false`), because they legitimately control and display the real VFO A and VFO B. Split-aware clients are out of scope for this simplification: a single-VFO face always sees split = `0`. SO2V is an N1MM-side workaround, not a substitute for this hub-side virtualization. +#### `single_vfo` on Hamlib NET (rigctld) faces + +The same virtualization is available on `[[hamlib_net]]` endpoints (`single_vfo = true`) for rigctld-protocol clients that, like N1MM SO1V, fundamentally expect to receive on VFO A. **WSJT-X** stops decoding when the hub reports the operator is on VFO B, and **Log4OM** polls `\get_vfo_info VFOA`, so on VFO B it logs the stale inactive VFO A frequency. With `single_vfo = true` the rigctld face presents the operating VFO as VFO A using a **strict** single-VFO contract — the inactive VFO is never exposed: + +- **`v` / `\get_vfo`** always report `VFOA`. +- **`\get_vfo_info `** resolves *any* requested VFO to the operating VFO and reports `Split: 0`; the inactive VFO's data is never returned (a `VFOB` request also answers with the operating VFO). +- **`s` / `\get_split_vfo`** report `0` / `VFOA`. +- **`f`/`m` reads** and **`F`/`M` writes** already target the operating (receive) VFO, so the frequency/mode are real on either physical VFO and a `set_freq` tunes the VFO the operator is actually using. +- **`S 1` / `\set_split_vfo 1`** (enable split) is **rejected** (`RPRT -11`) because a single-VFO presentation cannot model a real A/B split; `S 0` is accepted as a no-op. WSJT-X must therefore use **"Fake It"** split (which QSYs the single VFO at TX time), not **"Rig"** split, on a `single_vfo` endpoint. +- **`V ?` / `\set_vfo ?`** advertise only `VFOA`, so a client never targets the inactive VFO. + +The QsoRipper engine endpoint leaves `single_vfo = false` so the engine logs the true operating VFO letter. + ### 8.5 PTT ownership and arbitration Multiple clients are now PTT-capable (N1MM CAT PTT, WSJT-X `T 1`, ARCP-590). The daemon arbitrates with a single-owner lease: diff --git a/docs/integration/setup.md b/docs/integration/setup.md index 5629861..7aa6aac 100644 --- a/docs/integration/setup.md +++ b/docs/integration/setup.md @@ -140,7 +140,13 @@ hub on its own (for example with `-DryRun` to validate config). ### WSJT-X - Settings > Radio: Rig `Hamlib NET rigctl`, Network Server **127.0.0.1:4533**. -- PTT method `CAT`. The `wsjtx` endpoint is `perms = ["read", "write", "ptt"]`. +- PTT method `CAT`. The `wsjtx` endpoint is `perms = ["read", "write", "ptt"]`, `single_vfo = true`. +- **`single_vfo = true` keeps WSJT-X decoding on either VFO.** WSJT-X fundamentally expects to + receive on VFO A and stops decoding if the hub reports the operator is on VFO B. With + `single_vfo = true` the endpoint always presents whichever VFO the radio is actually on as + VFO A (`get_vfo` -> `VFOA`, with the live operating frequency/mode), so WSJT-X decodes + whether the rig is on A or B. The frequency is always real; only the VFO *letter* is + virtualized. - **Mode:** set the WSJT-X *Mode* selector to **Data/Pkt** (the default for FT8/WSPR). The hub maps Hamlib's `PKTUSB`/`PKTLSB` to the TS-590's DATA sub-mode by composing the base mode (`MD`) with the radio's independent DATA flag (`DA`): `PKTUSB` -> `MD2;`+`DA1;`, @@ -148,8 +154,10 @@ hub on its own (for example with `-DryRun` to validate config). read-back recomposes the token, so WSJT-X sees `PKTUSB`/`PKTLSB` echoed back and the radio shows its DATA indicator lit. *Mode = None* (operator selects DATA on the front panel) and *Mode = USB* also round-trip cleanly if you prefer to manage the sub-mode yourself. -- **Split Operation:** `Rig` or `Fake It` both work; the hub tracks split and TX-VFO - state and never retargets VFO A/B on a poll. +- **Split Operation:** use **`Fake It`** (not `Rig`). A `single_vfo` endpoint presents a + single operating VFO and cannot model a real A/B split, so it **rejects** rig split-enable + (`RPRT -11`); "Fake It" QSYs the single VFO at TX time and works correctly on either VFO. + If you genuinely need real `Rig` split, point WSJT-X at a non-virtualized endpoint instead. - **PTT:** WSJT-X keys with Hamlib `RIG_PTT_ON_DATA` (`T 3`). The hub maps the Hamlib PTT family faithfully to the TS-590 — `T 1` -> `TX;`, `T 2` (mic) -> `TX0;`, `T 3` (data) -> `TX1;`, `T 0` -> `RX;`. `TX1;` keys with modulation from the DATA/USB audio path, which is @@ -181,15 +189,21 @@ No radio-menu change is needed. Leave **Beep Volume** at your normal setting. ### Log4OM - CAT interface: Hamlib `NET rigctl`, host **127.0.0.1**, port **4534**. -- The `log4om` endpoint is `perms = ["read", "write"]`. +- The `log4om` endpoint is `perms = ["read", "write"]`, `single_vfo = true`. - Log4OM-NG uses Hamlib's **Extended Response Protocol** (ERP): it opens every session with `;V ?` (list supported VFOs) and then polls with `+\get_vfo_info VFOA` (~2 Hz). The hub's `hamlib_net` face parses the ERP separator prefix (`+ ; | ,`) and answers both shapes in the exact byte format real `rigctld` produces, so Log4OM connects and stays **online**. Plain clients (WSJT-X, N1MM, the engine) are unaffected — they never send the ERP prefix. +- **`single_vfo = true` makes Log4OM log the live frequency on either VFO.** Because Log4OM + polls the fixed VFO `VFOA`, without virtualization it would read the *inactive* VFO A's + stale frequency whenever the operator works on VFO B. With `single_vfo = true` the endpoint + resolves the `VFOA` poll to whichever VFO is actually operating, so Log4OM tracks the real + dial on both VFOs (and `;V ?` advertises only `VFOA`). ### QsoRipper engine (TUI and GUI) -- The engine's `RigctldProvider` points at the read-only endpoint **127.0.0.1:4532**. +- The engine's `RigctldProvider` points at the read-only endpoint **127.0.0.1:4532** + (`single_vfo = false`, so the engine sees and logs the true operating VFO letter). - The TUI and GUI both consume the engine over gRPC; neither talks to the radio directly, so both get a consistent view fed by the same hub. TCP allows the engine and any other NET client to share an endpoint simultaneously. From b8d31ee50037de82f121a9b0bcabc5380f17ecbf Mon Sep 17 00:00:00 2001 From: Randy Treit <38894017+rtreit@users.noreply.github.com> Date: Thu, 4 Jun 2026 14:46:22 -0700 Subject: [PATCH 19/36] cathub: track real TS-590 split and TX VFO and reflect to attached programs (#496) * cathub: track real TS-590 split and TX VFO, reflect to all attached programs The hub modeled the receive VFO and split state but never learned the transmit VFO from the rig, and a raw FT write from a client (ARCP-590, N1MM) was not observed at all. As a result the hub's split/TX-VFO view could go stale and attached programs would not reflect what the radio was actually doing. Three changes on the native TS-590 backend: - record_if_status now derives the transmit VFO from each IF poll. On a two-VFO radio the TX VFO during split is always the non-receiving VFO, so split + RX VFO fully determines it. The hub's split/TX-VFO state is now authoritative from the rig itself on every poll. - record_event observes raw FT passthrough writes, recording split as active whenever the transmit VFO differs from the receive VFO, so the snapshot tracks reality between polls. - apply(SetRxVfo) now emits FR;FT; together when not in a deliberate split, so a modeled operating-VFO switch can never leave the radio in an accidental reverse-split; the deliberate-split path still preserves the operator's transmit VFO. Adds tests for IF-derived TX VFO (split and simplex), raw FT observation, and the FR+FT operating-VFO switch. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * cathub: add transparent mirror dialect for ARCP-590 Native TS-590 controllers (ARCP-590) drifted out of sync because the hub served them a hybrid view: modeled frames were synthesized while only unmodeled frames were relayed verbatim, so the controller's operating-VFO belief could invert from the radio (A/B sent the wrong FR direction, tuning/split appeared dead). Add a transparent mirror dialect (ts590-transparent): forward every request to the radio verbatim except PTT (hub-lease-arbitrated), AI (per-face virtualized; rig stays in AI2), and ID/PS keepalives (answered locally so an idle controller generates zero radio traffic). Relay the radio's entire CAT stream byte-for-byte, so a controller that already speaks the native protocol behaves as if wired directly to the rig. This eliminates the synthesis/snapshot drift class entirely. The event bus now broadcasts a modeled native frame twice: a coalesced Change (for virtualizing faces) and a verbatim RawNative (for transparent faces). Unmodeled frames still relay as a single Raw event. Existing dialects are byte-for-byte unchanged. A lagged mirror face re-syncs via raw FA/FB/FR/FT/MD/IF frames. ts590-transparent is rejected with single_vfo. Live-verified on a real TS-590 with ARCP-590, HDSDR/OmniRig, WSJT-X, N1MM (SO1V), and Log4OM all running together: clean A/B, correct frequency/mode propagation, no drift. 192 lib tests pass; clippy -D warnings and fmt clean; workspace line coverage 84.9%. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * cathub: honor single_vfo on Hamlib NET faces so Log4OM follows A/B HamlibNetConfig had no single_vfo field, so single_vfo=true on the wsjtx and log4om endpoints was silently dropped by serde and never applied to any Hamlib NET face. Log4OM polls +\get_vfo_info VFOA, which resolved the VFO from the literal argument and always returned physical VFO A, so it showed a stale frequency after an A/B switch (WSJT-X polls get_freq, which already reads the operating VFO, so it was unaffected). Add the single_vfo field to HamlibNetConfig, apply it when building the hamlib_net FaceContext, and virtualize the VFO-identity read paths: under single_vfo, get_vfo_info / get_vfo / get_split_vfo present the operating (rx) VFO as VFOA with Split forced to 0, the ;V ? handshake advertises only VFOA, and set_split_vfo is absorbed without mutating the radio's real split. Faithful dual-VFO faces (the engine endpoint) keep reporting the literal physical VFO and real split. Verified end-to-end against the live TS-590 on VFO B: the log4om endpoint now reports the operating VFO as VFOA while the engine endpoint still reports physical VFOA/VFOB separately. 200 lib tests pass (+8 new), clippy -D warnings clean, workspace coverage 84.95%. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/cathub/src/backend/kenwood/ts590.rs | 126 +++++- crates/cathub/src/config.rs | 15 +- crates/cathub/src/dialect/kenwood/mod.rs | 1 + .../cathub/src/dialect/kenwood/transparent.rs | 308 +++++++++++++ crates/cathub/src/dialect/kenwood/ts590.rs | 19 +- crates/cathub/src/dialect/mod.rs | 25 ++ crates/cathub/src/hamlib_net.rs | 422 ++++++++---------- crates/cathub/src/lib.rs | 2 + crates/cathub/src/model.rs | 11 + crates/cathub/src/radio/mod.rs | 60 ++- crates/cathub/src/serial_face.rs | 11 +- crates/cathub/src/state.rs | 36 +- 12 files changed, 784 insertions(+), 252 deletions(-) create mode 100644 crates/cathub/src/dialect/kenwood/transparent.rs diff --git a/crates/cathub/src/backend/kenwood/ts590.rs b/crates/cathub/src/backend/kenwood/ts590.rs index bd84058..2b01352 100644 --- a/crates/cathub/src/backend/kenwood/ts590.rs +++ b/crates/cathub/src/backend/kenwood/ts590.rs @@ -114,10 +114,19 @@ fn record_if_status(state: &StateHandle, status: IfStatus, source: RadioEventSou source, ); state.record(StateChange::Ptt { keyed: status.ptt }, source); + // The IF frame carries the split bit and the receive VFO but not the transmit VFO. On a + // two-VFO radio the transmit VFO during split is always the non-receiving VFO, so derive + // it here. This keeps the hub's notion of split/TX-VFO authoritative from the rig itself + // and propagates the real state to every attached program. + let tx_vfo = if status.split { + status.rx_vfo.other() + } else { + status.rx_vfo + }; state.record( StateChange::Split { enabled: status.split, - tx_vfo: None, + tx_vfo: Some(tx_vfo), }, source, ); @@ -164,17 +173,25 @@ impl RadioBackend for Ts590Backend { link: &RadioLink, state: &StateHandle, ) -> Result<(), BackendError> { + let snap = state.snapshot(); let bytes = match mutation { StateMutation::SetRxVfo { vfo } => { let mut bytes = rx_vfo_set(vfo); - let snap = state.snapshot(); - if snap.split { - let tx = match snap.tx_vfo { - Vfo::A => b'0', - Vfo::B => b'1', - }; - bytes.extend_from_slice(&[b'F', b'T', tx, b';']); - } + let tx = if snap.split { + // Preserve a deliberate split: move only the receive VFO, keep the + // operator's transmit VFO untouched. + snap.tx_vfo + } else { + // Switching the operating VFO must move RX *and* TX together. Sending a + // bare `FR` would leave the transmit VFO behind and silently create an + // accidental reverse-split (RX=new, TX=old). + vfo + }; + let tx_digit = match tx { + Vfo::A => b'0', + Vfo::B => b'1', + }; + bytes.extend_from_slice(&[b'F', b'T', tx_digit, b';']); bytes } StateMutation::SetVfoFreq { vfo, hz } => freq_set(vfo, hz), @@ -236,6 +253,19 @@ impl RadioBackend for Ts590Backend { // Kenwood sets are not acknowledged: fire and forget. link.submit(bytes, Expect::NoReply).await?; state.record(mutation.into_change(), RadioEventSource::OptimisticWrite); + // A non-split operating-VFO switch also moves TX, so reflect the cleared split and + // the new transmit VFO in the snapshot (the bare RxVfo change above cannot). + if let StateMutation::SetRxVfo { vfo } = mutation { + if !snap.split { + state.record( + StateChange::Split { + enabled: false, + tx_vfo: Some(vfo), + }, + RadioEventSource::OptimisticWrite, + ); + } + } Ok(()) } @@ -290,6 +320,21 @@ impl RadioBackend for Ts590Backend { state.record(StateChange::RxVfo { vfo }, source); true }), + // A raw `FT` from a client (e.g. ARCP-590/N1MM split control) selects the + // transmit VFO. Observe it so the snapshot's split/tx_vfo track reality instead + // of going stale: split is active whenever the transmit VFO differs from the + // receive VFO. + b"FT" => parse_rx_vfo_digit(payload).is_some_and(|tx_vfo| { + let rx_vfo = state.snapshot().rx_vfo; + state.record( + StateChange::Split { + enabled: rx_vfo != tx_vfo, + tx_vfo: Some(tx_vfo), + }, + source, + ); + true + }), b"MD" => payload.first().is_some_and(|digit| { let vfo = state.snapshot().rx_vfo; state.record( @@ -439,6 +484,56 @@ mod tests { ); } + #[test] + fn if_poll_derives_tx_vfo_from_split_and_rx_vfo() { + let backend = Ts590Backend::new(); + + // RX=VFO B with split set -> TX must be derived as the non-receiving VFO (A). + let split = StateHandle::new(); + assert!(backend.record_event( + b"IF000140343201234-0000012345121019999;", + &split, + RadioEventSource::PollDiff, + )); + let snap = split.snapshot(); + assert_eq!(snap.rx_vfo, Vfo::B); + assert!(snap.split); + assert_eq!(snap.tx_vfo, Vfo::A, "split TX VFO is the non-RX VFO"); + + // RX=VFO B with split clear -> TX tracks the operating (RX) VFO. + let simplex = StateHandle::new(); + assert!(backend.record_event( + b"IF000140343201234-0000012345021009999;", + &simplex, + RadioEventSource::PollDiff, + )); + let snap = simplex.snapshot(); + assert_eq!(snap.rx_vfo, Vfo::B); + assert!(!snap.split); + assert_eq!(snap.tx_vfo, Vfo::B, "simplex TX VFO equals the RX VFO"); + } + + #[test] + fn native_ft_records_split_and_tx_vfo() { + let backend = Ts590Backend::new(); + let state = StateHandle::new(); + + // Operator on VFO B; a client (ARCP-590) then commands transmit on VFO A -> the + // radio is in reverse-split and the snapshot must reflect it. + assert!(backend.record_event(b"FR1;", &state, RadioEventSource::NativePush)); + assert!(backend.record_event(b"FT0;", &state, RadioEventSource::NativePush)); + let snap = state.snapshot(); + assert_eq!(snap.rx_vfo, Vfo::B); + assert!(snap.split, "FR=B / FT=A must register as split"); + assert_eq!(snap.tx_vfo, Vfo::A); + + // Re-aligning the transmit VFO to the receive VFO clears the split. + assert!(backend.record_event(b"FT1;", &state, RadioEventSource::NativePush)); + let snap = state.snapshot(); + assert!(!snap.split, "FR=B / FT=B must clear split"); + assert_eq!(snap.tx_vfo, Vfo::B); + } + #[test] fn native_fr_then_md_records_mode_on_active_vfo() { let backend = Ts590Backend::new(); @@ -505,7 +600,7 @@ mod tests { } #[tokio::test] - async fn apply_rx_vfo_writes_fr_and_updates_state() { + async fn apply_rx_vfo_switches_operating_vfo_with_fr_and_ft() { let (link, raw_rx) = link_channel(); let backend = Arc::new(Ts590Backend::new()); let arc: Arc = backend.clone(); @@ -518,10 +613,15 @@ mod tests { .await .expect("apply"); - let mut buf = vec![0u8; 4]; + // A non-split operating-VFO switch must move RX and TX together so the radio is + // never left in an accidental reverse-split (RX=B, TX=A). + let mut buf = vec![0u8; 8]; radio_side.read_exact(&mut buf).await.expect("read frame"); - assert_eq!(&buf, b"FR1;"); - assert_eq!(state.snapshot().rx_vfo, Vfo::B); + assert_eq!(&buf, b"FR1;FT1;"); + let snap = state.snapshot(); + assert_eq!(snap.rx_vfo, Vfo::B); + assert!(!snap.split, "operating-VFO switch must not be split"); + assert_eq!(snap.tx_vfo, Vfo::B); } #[tokio::test] diff --git a/crates/cathub/src/config.rs b/crates/cathub/src/config.rs index 9e0988b..d2d7498 100644 --- a/crates/cathub/src/config.rs +++ b/crates/cathub/src/config.rs @@ -282,12 +282,23 @@ impl Config { } } for face in &self.face { - if !matches!(face.dialect.as_str(), "ts590" | "ts2000") { + if !matches!( + face.dialect.as_str(), + "ts590" | "ts590-transparent" | "ts2000" + ) { return Err(ConfigError::Invalid(format!( - "face '{}' has unknown dialect '{}' (expected ts590 or ts2000)", + "face '{}' has unknown dialect '{}' (expected ts590, ts590-transparent, or ts2000)", face.name, face.dialect ))); } + if face.dialect == "ts590-transparent" && face.single_vfo { + return Err(ConfigError::Invalid(format!( + "face '{}' combines dialect 'ts590-transparent' with single_vfo = true; a \ + transparent mirror face relays the radio's real dual-VFO stream verbatim and \ + cannot virtualize the operating VFO", + face.name + ))); + } } if self.face.is_empty() && self.hamlib_net.is_empty() { return Err(ConfigError::Invalid( diff --git a/crates/cathub/src/dialect/kenwood/mod.rs b/crates/cathub/src/dialect/kenwood/mod.rs index 8a1e011..ac58c7d 100644 --- a/crates/cathub/src/dialect/kenwood/mod.rs +++ b/crates/cathub/src/dialect/kenwood/mod.rs @@ -4,6 +4,7 @@ //! Log4OM-as-TS590). [`Ts2000Dialect`](ts2000::Ts2000Dialect) is the OmniRig/HDSDR translator. //! Both share the small frame helpers below. +pub(crate) mod transparent; pub(crate) mod ts2000; pub(crate) mod ts590; diff --git a/crates/cathub/src/dialect/kenwood/transparent.rs b/crates/cathub/src/dialect/kenwood/transparent.rs new file mode 100644 index 0000000..9ac8b33 --- /dev/null +++ b/crates/cathub/src/dialect/kenwood/transparent.rs @@ -0,0 +1,308 @@ +//! The transparent mirror dialect for native TS-590 controllers (notably ARCP-590). +//! +//! Unlike [`Ts590Dialect`](super::ts590::Ts590Dialect), which virtualizes the radio behind a +//! modeled cache, a transparent face behaves as if it were wired directly to the rig: every +//! request except PTT and auto-information is forwarded to the radio verbatim, and the +//! radio's entire CAT stream is relayed back verbatim. The rig runs in AI2, so it echoes +//! every change any client makes through the hub — which is exactly what keeps a transparent +//! controller perfectly in sync. This removes the whole class of synthesis/drift bugs (stale +//! A/B, frozen frequency) for a client that already speaks the radio's exact protocol. +//! +//! The hub still owns the single physical port, so a few things stay hub-mediated: +//! * PTT (`TX`/`RX`) routes through the shared lease so a mirror client can never key the +//! transmitter while another face owns it (design §8.5). +//! * Auto-information (`AI`) is virtualized per face; the radio itself stays in AI2, and the +//! hub gates this face's fan-out on its own `AI` flag. +//! * Identity/keepalive reads (`ID`/`PS`) are answered locally so a mirror client's steady +//! heartbeat never generates radio traffic. + +use async_trait::async_trait; + +use super::ts590::snapshot_resync_frames; +use super::{ai_frame, parse_command, ERR}; +use crate::dialect::{ApplyOutcome, ClientDialect, FaceContext}; +use crate::model::{PttSource, StateChange, StateMutation}; +use crate::permissions::CommandClass; +use crate::state::Snapshot; + +/// The transparent TS-590 mirror dialect. +#[derive(Clone, Default)] +pub(crate) struct TransparentTs590Dialect; + +impl TransparentTs590Dialect { + /// Create the dialect. + pub(crate) fn new() -> Self { + TransparentTs590Dialect + } +} + +#[async_trait] +impl ClientDialect for TransparentTs590Dialect { + async fn handle(&self, request: &[u8], ctx: &FaceContext) -> Vec { + let Some((verb, payload)) = parse_command(request) else { + return ERR.to_vec(); + }; + let read = payload.is_empty(); + match verb.as_slice() { + // PTT stays hub-arbitrated even on a mirror face: a transparent client must not be + // able to key the rig while another face owns the transmitter. + b"TX" => reply( + ctx.apply_modeled( + StateMutation::SetPtt { + keyed: true, + source: PttSource::Generic, + }, + CommandClass::PttWrite, + ) + .await, + ), + b"RX" => reply( + ctx.apply_modeled( + StateMutation::SetPtt { + keyed: false, + source: PttSource::Generic, + }, + CommandClass::PttWrite, + ) + .await, + ), + // Auto-information is virtualized per face: the radio is already in AI2 (hub-owned + // native push), so a mirror client toggling AI only flips its own fan-out flag. + b"AI" => { + if read { + ai_frame(ctx.ai_on()) + } else { + ctx.set_ai(payload.first().is_some_and(|&d| d != b'0')); + Vec::new() + } + } + // Identity/keepalive reads are answered locally so a mirror client's heartbeat + // never hits the radio. (ARCP-590 polls `PS;`/`AI;` continuously when idle.) + b"ID" if read => b"ID021;".to_vec(), + b"PS" if read => b"PS1;".to_vec(), + // Everything else is forwarded to the radio verbatim: a transparent face behaves + // as if wired directly to the rig, so reads and writes alike reach the real radio. + _ => { + let class = if read { + CommandClass::PassthroughRead + } else { + CommandClass::ConfigWrite + }; + ctx.passthrough(request, class).await + } + } + } + + fn format_notification(&self, _change: &StateChange, _ctx: &FaceContext) -> Option> { + // A transparent face is driven entirely by the radio's verbatim CAT stream + // (`format_native_passthrough` / `format_passthrough`); it never consumes a synthesized + // modeled notification, which is precisely what kept drifting out of sync. + None + } + + fn format_passthrough(&self, raw: &[u8], ctx: &FaceContext) -> Option> { + if ctx.ai_on() { + Some(raw.to_vec()) + } else { + None + } + } + + fn format_native_passthrough(&self, raw: &[u8], ctx: &FaceContext) -> Option> { + if ctx.ai_on() { + Some(raw.to_vec()) + } else { + None + } + } + + fn resync(&self, snapshot: &Snapshot, ctx: &FaceContext) -> Vec> { + // A lagged mirror face never received the raw frames it missed and ignores synthesized + // notifications, so re-present the current radio state as a full set of raw frames. + if ctx.ai_on() { + snapshot_resync_frames(snapshot) + } else { + Vec::new() + } + } +} + +fn reply(outcome: ApplyOutcome) -> Vec { + match outcome { + ApplyOutcome::Ok => Vec::new(), + _ => ERR.to_vec(), + } +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)] +mod tests { + use super::*; + use crate::backend::loopback::LoopbackBackend; + use crate::backend::RadioBackend; + use crate::model::{RadioEventSource, StateChange, Vfo}; + use crate::permissions::FacePermissions; + use crate::ptt::PttManager; + use crate::radio::{detached_link, spawn_scheduler}; + use crate::state::StateHandle; + use std::sync::Arc; + use std::time::Duration; + + fn ctx_with(perms: FacePermissions) -> (FaceContext, LoopbackBackend, StateHandle, PttManager) { + let backend = LoopbackBackend::new(); + let caps = backend.capabilities(); + let arc: Arc = Arc::new(backend.clone()); + let state = StateHandle::new(); + let radio = spawn_scheduler(arc, detached_link(), state.clone()); + let ptt = PttManager::new(Duration::from_secs(300)); + ( + FaceContext::new(1, perms, state.clone(), radio, ptt.clone(), caps), + backend, + state, + ptt, + ) + } + + #[tokio::test] + async fn forwards_a_vfo_select_to_the_radio_verbatim() { + // The core bug fix: an A/B (`FR1;`) from a mirror client must reach the radio raw, not + // be swallowed by virtualization. + let (ctx, backend, _state, _ptt) = ctx_with(FacePermissions::from_tokens(&[ + "read", + "write", + "config_write", + ])); + let reply = TransparentTs590Dialect::new().handle(b"FR1;", &ctx).await; + // Loopback echoes the passthrough; production fire-and-forget writes return empty. + assert_eq!(reply, b"FR1;"); + assert_eq!(backend.passthroughs(), vec![b"FR1;".to_vec()]); + } + + #[tokio::test] + async fn forwards_a_frequency_read_to_the_radio() { + let (ctx, backend, _state, _ptt) = ctx_with(FacePermissions::from_tokens(&[ + "read", + "write", + "config_write", + ])); + let _ = TransparentTs590Dialect::new().handle(b"FA;", &ctx).await; + assert_eq!(backend.passthroughs(), vec![b"FA;".to_vec()]); + } + + #[tokio::test] + async fn keys_and_unkeys_through_the_ptt_lease() { + let (ctx, _backend, _state, ptt) = ctx_with(FacePermissions::from_tokens(&["read", "ptt"])); + let dialect = TransparentTs590Dialect::new(); + assert!(dialect.handle(b"TX;", &ctx).await.is_empty()); + assert_eq!(ptt.owner(), Some(1), "TX must take the shared PTT lease"); + assert!(dialect.handle(b"RX;", &ctx).await.is_empty()); + assert_eq!(ptt.owner(), None, "RX must release the shared PTT lease"); + } + + #[tokio::test] + async fn ptt_command_never_reaches_the_radio_as_a_raw_write() { + // TX/RX must be modeled (lease-arbitrated), not passed through as raw bytes. + let (ctx, backend, _state, _ptt) = ctx_with(FacePermissions::from_tokens(&["read", "ptt"])); + let _ = TransparentTs590Dialect::new().handle(b"TX;", &ctx).await; + assert!( + backend.passthroughs().is_empty(), + "PTT must not be forwarded as a raw passthrough" + ); + } + + #[tokio::test] + async fn keepalive_reads_are_answered_locally() { + let (ctx, backend, _state, _ptt) = ctx_with(FacePermissions::read_only()); + let dialect = TransparentTs590Dialect::new(); + assert_eq!(dialect.handle(b"ID;", &ctx).await, b"ID021;"); + assert_eq!(dialect.handle(b"PS;", &ctx).await, b"PS1;"); + assert!( + backend.passthroughs().is_empty(), + "ID/PS keepalives must not generate radio traffic" + ); + } + + #[tokio::test] + async fn auto_information_is_virtualized_per_face() { + let (ctx, backend, _state, _ptt) = ctx_with(FacePermissions::read_only()); + let dialect = TransparentTs590Dialect::new(); + assert_eq!(dialect.handle(b"AI;", &ctx).await, b"AI0;"); + assert!(dialect.handle(b"AI2;", &ctx).await.is_empty()); + assert!(ctx.ai_on()); + assert_eq!(dialect.handle(b"AI;", &ctx).await, b"AI2;"); + assert!( + backend.passthroughs().is_empty(), + "AI must never reach the radio; the rig stays in hub-owned AI2" + ); + } + + #[tokio::test] + async fn relays_modeled_and_unmodeled_frames_verbatim_when_auto_info_on() { + let (ctx, _backend, _state, _ptt) = ctx_with(FacePermissions::read_only()); + let dialect = TransparentTs590Dialect::new(); + // AI off: suppress everything. + assert_eq!(dialect.format_native_passthrough(b"FR1;", &ctx), None); + assert_eq!(dialect.format_passthrough(b"NB1;", &ctx), None); + ctx.set_ai(true); + // AI on: relay both the modeled CAT echo and the unmodeled frame byte-for-byte. + assert_eq!( + dialect.format_native_passthrough(b"FA00014035000;", &ctx), + Some(b"FA00014035000;".to_vec()) + ); + assert_eq!( + dialect.format_passthrough(b"NB1;", &ctx), + Some(b"NB1;".to_vec()) + ); + } + + #[tokio::test] + async fn never_synthesizes_a_modeled_notification() { + let (ctx, _backend, _state, _ptt) = ctx_with(FacePermissions::read_only()); + ctx.set_ai(true); + let dialect = TransparentTs590Dialect::new(); + assert_eq!( + dialect.format_notification( + &StateChange::Freq { + vfo: Vfo::A, + hz: 14_035_000, + }, + &ctx + ), + None, + "a transparent face must never emit a synthesized notification" + ); + } + + #[tokio::test] + async fn resync_re_presents_full_state_after_a_lag() { + let (ctx, _backend, state, _ptt) = ctx_with(FacePermissions::read_only()); + ctx.set_ai(true); + state.record( + StateChange::Freq { + vfo: Vfo::A, + hz: 14_035_000, + }, + RadioEventSource::PollDiff, + ); + let frames = TransparentTs590Dialect::new().resync(&state.snapshot(), &ctx); + assert!( + frames.iter().any(|f| f == b"FA00014035000;"), + "re-sync must include the current VFO A frequency, got {frames:?}" + ); + assert!( + frames.iter().any(|f| f.starts_with(b"IF")), + "re-sync must include the operating-status IF frame" + ); + } + + #[tokio::test] + async fn resync_is_empty_when_auto_info_off() { + let (ctx, _backend, state, _ptt) = ctx_with(FacePermissions::read_only()); + assert!( + TransparentTs590Dialect::new() + .resync(&state.snapshot(), &ctx) + .is_empty(), + "an AI-off mirror face must emit nothing on re-sync" + ); + } +} diff --git a/crates/cathub/src/dialect/kenwood/ts590.rs b/crates/cathub/src/dialect/kenwood/ts590.rs index 476aeaf..8db301a 100644 --- a/crates/cathub/src/dialect/kenwood/ts590.rs +++ b/crates/cathub/src/dialect/kenwood/ts590.rs @@ -30,7 +30,7 @@ impl Ts590Dialect { /// When `single_vfo` is true (operating-VFO virtualization) the operating VFO is always /// presented as VFO A with no split, so a single-VFO logger (N1MM SO1V) never sees VFO B /// in the P10 field and never warns about it. -fn synth_if(snapshot: &Snapshot, single_vfo: bool) -> Vec { +pub(crate) fn synth_if(snapshot: &Snapshot, single_vfo: bool) -> Vec { let freq = snapshot.vfo(snapshot.rx_vfo).freq_hz; let tx = u8::from(snapshot.ptt); let mode = mode_to_digit(snapshot.vfo(snapshot.rx_vfo).mode) - b'0'; @@ -276,6 +276,23 @@ fn reply(outcome: ApplyOutcome) -> Vec { } } +/// Build the full set of raw TS-590 frames that re-present the current state to a native +/// controller: both VFO frequencies, the receive/transmit VFO selectors, the active mode, +/// and the operating-status `IF`. Used to re-sync a transparent mirror face that lagged the +/// broadcast ring, restoring it to the radio's true state without a reconnect. +pub(crate) fn snapshot_resync_frames(snap: &Snapshot) -> Vec> { + let rx = if snap.rx_vfo == Vfo::A { b'0' } else { b'1' }; + let tx = if snap.tx_vfo == Vfo::A { b'0' } else { b'1' }; + vec![ + freq_frame(b"FA", snap.vfo(Vfo::A).freq_hz), + freq_frame(b"FB", snap.vfo(Vfo::B).freq_hz), + vec![b'F', b'R', rx, b';'], + vec![b'F', b'T', tx, b';'], + vec![b'M', b'D', mode_to_digit(snap.vfo(snap.rx_vfo).mode), b';'], + synth_if(snap, false), + ] +} + #[cfg(test)] #[allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)] mod tests { diff --git a/crates/cathub/src/dialect/mod.rs b/crates/cathub/src/dialect/mod.rs index b0eedfe..dd748f9 100644 --- a/crates/cathub/src/dialect/mod.rs +++ b/crates/cathub/src/dialect/mod.rs @@ -260,6 +260,31 @@ pub(crate) trait ClientDialect: Send + Sync { fn format_passthrough(&self, _raw: &[u8], _ctx: &FaceContext) -> Option> { None } + + /// Render a *modeled* native frame (one the backend parsed into a state change) as a + /// verbatim relay for this face, if it applies. Returns the bytes to push, or `None`. + /// + /// The default suppresses it: a virtualizing face consumes the coalesced + /// [`StateChange`](crate::model::StateChange) via [`Self::format_notification`] instead. + /// A transparent mirror dialect overrides this to forward the radio's real CAT stream + /// byte-for-byte, so the client never diverges from the rig. + fn format_native_passthrough(&self, _raw: &[u8], _ctx: &FaceContext) -> Option> { + None + } + + /// Re-present the full current state to a face that lagged the broadcast ring and lost + /// one or more events. Returns the frames to write, in order. + /// + /// The default replays the snapshot through [`Self::format_notification`], which restores + /// a virtualizing face to live state. A transparent mirror dialect overrides this to emit + /// the radio's state as raw frames (it never consumes synthesized notifications). + fn resync(&self, snapshot: &Snapshot, ctx: &FaceContext) -> Vec> { + snapshot + .as_changes() + .iter() + .filter_map(|change| self.format_notification(change, ctx)) + .collect() + } } #[cfg(test)] diff --git a/crates/cathub/src/hamlib_net.rs b/crates/cathub/src/hamlib_net.rs index f158cb3..60e7aee 100644 --- a/crates/cathub/src/hamlib_net.rs +++ b/crates/cathub/src/hamlib_net.rs @@ -16,6 +16,7 @@ use tokio::net::TcpListener; use crate::dialect::{ApplyOutcome, FaceContext}; use crate::model::{Mode, PttSource, StateMutation, Vfo}; use crate::permissions::CommandClass; +use crate::state::Snapshot; /// The capability dump served for `\dump_state`. Captured verbatim from Hamlib 4.7.0 /// `rigctld` (protocol version 1) so the WSJT-X / engine Hamlib clients parse it exactly. @@ -34,13 +35,12 @@ fn vfo_name(vfo: Vfo) -> &'static str { } } -/// Resolve a client-requested VFO to the physical VFO whose state should answer. +/// The VFO whose data this face presents when a client requests `requested`. /// -/// On a `single_vfo` endpoint the hub presents the operator's *operating* (receive) VFO as -/// VFO A and never exposes the inactive VFO, so every request resolves to the operating VFO -/// regardless of the letter the client asked for. On a normal dual-VFO endpoint the -/// requested VFO is honored verbatim. -fn resolve_vfo(ctx: &FaceContext, snapshot: &crate::state::Snapshot, requested: Vfo) -> Vfo { +/// Single-VFO faces (N1MM SO1V, Log4OM) always present the operating (receive) VFO so +/// the radio's physical A/B identity never leaks; dual-VFO faces honor the literal +/// requested VFO. This is the chokepoint that makes single-VFO loggers follow A/B swaps. +fn presented_vfo(ctx: &FaceContext, snapshot: &Snapshot, requested: Vfo) -> Vfo { if ctx.single_vfo() { snapshot.rx_vfo } else { @@ -48,22 +48,31 @@ fn resolve_vfo(ctx: &FaceContext, snapshot: &crate::state::Snapshot, requested: } } -/// The VFO letter this endpoint presents as "current". A `single_vfo` endpoint always shows -/// the operating VFO as VFO A; a dual-VFO endpoint shows the real receive VFO. -fn presented_current_vfo(ctx: &FaceContext, snapshot: &crate::state::Snapshot) -> Vfo { +/// The VFO name this face advertises for the real VFO `real`. Single-VFO faces always +/// claim `VFOA` (the operating VFO is virtualized as A); dual-VFO faces report the real +/// name. +fn presented_vfo_name(ctx: &FaceContext, real: Vfo) -> &'static str { if ctx.single_vfo() { - Vfo::A + "VFOA" } else { - snapshot.rx_vfo + vfo_name(real) } } -/// The split flag this endpoint presents. A `single_vfo` endpoint can only represent one -/// VFO, so it always reports "no split" to avoid exposing a real A/B split it cannot model. -fn presented_split(ctx: &FaceContext, snapshot: &crate::state::Snapshot) -> bool { +/// The split flag this face exposes. Single-VFO faces always hide split (they know only +/// the operating VFO), matching the single-VFO virtualization contract. +fn presented_split(ctx: &FaceContext, snapshot: &Snapshot) -> bool { !ctx.single_vfo() && snapshot.split } +/// Parse a requested VFO name argument into a [`Vfo`], defaulting to `VFOA`. +fn parse_vfo_arg(arg: Option<&&str>) -> Vfo { + match arg { + Some(v) if v.eq_ignore_ascii_case("VFOB") => Vfo::B, + _ => Vfo::A, + } +} + /// Parse a `set_freq` argument into whole Hz. /// /// Hamlib clients (WSJT-X, the engine, Log4OM) format frequencies as a double with @@ -190,35 +199,26 @@ async fn handle_ext(sep: char, rest: &str, ctx: &FaceContext) -> Vec { // The supported-token list line is newline-terminated regardless of the separator, // matching real `rigctld`. if matches!(cmd, "V" | "\\set_vfo") && args.first() == Some(&"?") { - // A single_vfo endpoint advertises only VFOA so clients never target the inactive - // VFO; a dual-VFO endpoint advertises both. - let list = if ctx.single_vfo() { + let vfos = if ctx.single_vfo() { "VFOA " } else { "VFOA VFOB " }; - return format!("set_vfo: ?{sep}{list}\nRPRT 0\n").into_bytes(); + return format!("set_vfo: ?{sep}{vfos}\nRPRT 0\n").into_bytes(); } - // get_vfo_info: Log4OM-NG's poll command (`+\get_vfo_info VFOA`). Reports the resolved - // VFO's frequency, mode, passband, split, and (always 0) satellite mode. A single_vfo - // endpoint resolves any requested VFO to the operating VFO, labels it VFOA, and reports - // no split. + // get_vfo_info: Log4OM-NG's poll command (`+\get_vfo_info VFOA`). Reports the named + // VFO's frequency, mode, passband, split, and (always 0) satellite mode. if cmd == "\\get_vfo_info" { if !reads { return erp_records(sep, &[ext_echo(cmd, &args), "RPRT -1".to_string()]); } - let requested = match args.first() { - Some(v) if v.eq_ignore_ascii_case("VFOB") => Vfo::B, - _ => Vfo::A, - }; - let resolved = resolve_vfo(ctx, &snapshot, requested); - let shown_name = if ctx.single_vfo() { Vfo::A } else { requested }; - let v = snapshot.vfo(resolved); + let vfo = presented_vfo(ctx, &snapshot, parse_vfo_arg(args.first())); + let v = snapshot.vfo(vfo); return erp_records( sep, &[ - format!("get_vfo_info: {}", vfo_name(shown_name)), + format!("get_vfo_info: {}", presented_vfo_name(ctx, vfo)), format!("Freq: {}", v.freq_hz), format!("Mode: {}", v.mode.hamlib_token_with_data(v.data)), format!("Width: {}", v.passband_hz), @@ -247,7 +247,7 @@ async fn handle_ext(sep: char, rest: &str, ctx: &FaceContext) -> Vec { } "v" | "\\get_vfo" if reads => Some(vec![ "get_vfo:".to_string(), - format!("VFO: {}", vfo_name(presented_current_vfo(ctx, &snapshot))), + format!("VFO: {}", presented_vfo_name(ctx, snapshot.rx_vfo)), "RPRT 0".to_string(), ]), "t" | "\\get_ptt" if reads => Some(vec![ @@ -307,17 +307,13 @@ async fn dispatch_plain(cmd: &str, args: &[&str], ctx: &FaceContext) -> LineResu .into_bytes() }), "v" | "\\get_vfo" => guard_read(ctx, || { - format!("{}\n", vfo_name(presented_current_vfo(ctx, &snapshot))).into_bytes() + format!("{}\n", presented_vfo_name(ctx, snapshot.rx_vfo)).into_bytes() }), - // get_vfo_info: report the resolved VFO's freq/mode/width/split/satmode as bare - // values (Freq, Mode, Width, Split, SatMode), matching real `rigctld`. A single_vfo - // endpoint resolves any requested VFO to the operating VFO and reports no split. + // get_vfo_info: report the named VFO's freq/mode/width/split/satmode as bare + // values (Freq, Mode, Width, Split, SatMode), matching real `rigctld`. "\\get_vfo_info" => guard_read(ctx, || { - let requested = match args.first() { - Some(v) if v.eq_ignore_ascii_case("VFOB") => Vfo::B, - _ => Vfo::A, - }; - let v = snapshot.vfo(resolve_vfo(ctx, &snapshot, requested)); + let vfo = presented_vfo(ctx, &snapshot, parse_vfo_arg(args.first())); + let v = snapshot.vfo(vfo); format!( "{}\n{}\n{}\n{}\n0\n", v.freq_hz, @@ -329,14 +325,12 @@ async fn dispatch_plain(cmd: &str, args: &[&str], ctx: &FaceContext) -> LineResu }), "s" | "\\get_split_vfo" => guard_read(ctx, || { let split = presented_split(ctx, &snapshot); - let tx = if ctx.single_vfo() { - Vfo::A - } else if snapshot.split { + let tx = if split { snapshot.tx_vfo } else { snapshot.rx_vfo }; - format!("{}\n{}\n", u8::from(split), vfo_name(tx)).into_bytes() + format!("{}\n{}\n", u8::from(split), presented_vfo_name(ctx, tx)).into_bytes() }), "t" | "\\get_ptt" => { guard_read(ctx, || format!("{}\n", u8::from(snapshot.ptt)).into_bytes()) @@ -404,20 +398,22 @@ async fn dispatch_plain(cmd: &str, args: &[&str], ctx: &FaceContext) -> LineResu None => RPRT_EINVAL.to_vec(), }, "S" | "\\set_split_vfo" => { - let enabled = args.first().copied() == Some("1"); - // A single_vfo endpoint presents one operating VFO and cannot model a real A/B - // split. Accept "split off" as a no-op success, but reject "split on" so a - // client (e.g. WSJT-X "Rig" split) sees split is unavailable instead of a false - // success that would route TX to the wrong frequency. Use "Fake It" split. - if ctx.single_vfo() { - if !ctx.perms.allows(CommandClass::ModeledWrite) { - RPRT_EINVAL.to_vec() - } else if enabled { + if !ctx.perms.allows(CommandClass::ModeledWrite) { + RPRT_EINVAL.to_vec() + } else if ctx.single_vfo() { + // Single-VFO faces present one operating VFO and cannot model a real A/B + // split. Accept "split off" as a no-op success, but reject "split on" with + // RPRT_ENAVAIL so a client (e.g. WSJT-X "Rig" split) sees split is + // unavailable and falls back to "Fake It" instead of believing a false + // success that would route TX to the wrong frequency. Either way the real + // radio split is never mutated, so single-VFO reads stay consistent. + if args.first().copied() == Some("1") { RPRT_ENAVAIL.to_vec() } else { RPRT_OK.to_vec() } } else { + let enabled = args.first().copied() == Some("1"); let tx_vfo = match args.get(1).copied() { Some(v) if v.eq_ignore_ascii_case("VFOB") => Some(Vfo::B), _ => Some(Vfo::A), @@ -646,6 +642,58 @@ mod tests { (ctx, backend, state) } + /// A single-VFO-virtualized face: the operating VFO is always presented as `VFOA`, + /// split is hidden, and physical A/B identity never leaks (N1MM SO1V, Log4OM). + fn ctx_single_vfo(perms: FacePermissions) -> (FaceContext, LoopbackBackend, StateHandle) { + let (ctx, backend, state) = ctx_with(perms); + (ctx.with_single_vfo(true), backend, state) + } + + /// Put the radio on physical VFO B (14.074 USB) with VFO A on 14.035 CW, split on + /// with TX on the inactive VFO — the classic state a single-VFO logger must collapse + /// to "operating VFO is VFOA". + fn operating_on_b(state: &StateHandle) { + state.record( + StateChange::Freq { + vfo: Vfo::A, + hz: 14_035_000, + }, + RadioEventSource::PollDiff, + ); + state.record( + StateChange::Mode { + vfo: Vfo::A, + mode: Mode::Cw, + }, + RadioEventSource::PollDiff, + ); + state.record( + StateChange::Freq { + vfo: Vfo::B, + hz: 14_074_000, + }, + RadioEventSource::PollDiff, + ); + state.record( + StateChange::Mode { + vfo: Vfo::B, + mode: Mode::Usb, + }, + RadioEventSource::PollDiff, + ); + state.record( + StateChange::RxVfo { vfo: Vfo::B }, + RadioEventSource::PollDiff, + ); + state.record( + StateChange::Split { + enabled: true, + tx_vfo: Some(Vfo::A), + }, + RadioEventSource::PollDiff, + ); + } + async fn reply_of(line: &str, ctx: &FaceContext) -> Vec { match handle_line(line, ctx).await { LineResult::Reply(b) => b, @@ -861,182 +909,6 @@ mod tests { assert_eq!(reply_of("m", &ctx).await, b"PKTUSB\n2400\n".to_vec()); } - /// Build a read-only single-VFO endpoint with the rig parked on VFO B: A is the inactive - /// 14.035 CW VFO, B is the operating 14.074 PKTUSB VFO. - fn single_vfo_on_b() -> (FaceContext, LoopbackBackend, StateHandle) { - let (ctx, backend, state) = ctx_with(FacePermissions::from_tokens(&["read", "write"])); - state.record( - StateChange::Freq { - vfo: Vfo::A, - hz: 14_035_000, - }, - RadioEventSource::PollDiff, - ); - state.record( - StateChange::Mode { - vfo: Vfo::A, - mode: Mode::Cw, - }, - RadioEventSource::PollDiff, - ); - state.record( - StateChange::Freq { - vfo: Vfo::B, - hz: 14_074_000, - }, - RadioEventSource::PollDiff, - ); - state.record( - StateChange::DataMode { - vfo: Vfo::B, - on: true, - }, - RadioEventSource::PollDiff, - ); - state.record( - StateChange::RxVfo { vfo: Vfo::B }, - RadioEventSource::PollDiff, - ); - (ctx.with_single_vfo(true), backend, state) - } - - #[tokio::test] - async fn single_vfo_get_vfo_reports_vfoa_on_b() { - // WSJT-X expects to receive on VFO A. On a single_vfo endpoint, get_vfo must report - // VFOA even though the rig is physically on VFO B, while freq/mode follow the - // operating VFO so the data is real. - let (ctx, _b, _s) = single_vfo_on_b(); - assert_eq!(reply_of("v", &ctx).await, b"VFOA\n".to_vec()); - assert_eq!(reply_of("f", &ctx).await, b"14074000\n".to_vec()); - assert_eq!(reply_of("m", &ctx).await, b"PKTUSB\n2400\n".to_vec()); - } - - #[tokio::test] - async fn single_vfo_get_vfo_info_vfoa_returns_operating_vfo() { - // Log4OM polls `\get_vfo_info VFOA`. On a single_vfo endpoint a VFOA request must - // resolve to the operating VFO (B's 14.074 PKTUSB), not the stale inactive VFO A. - let (ctx, _b, _s) = single_vfo_on_b(); - assert_eq!( - reply_of("\\get_vfo_info VFOA", &ctx).await, - b"14074000\nPKTUSB\n2400\n0\n0\n".to_vec() - ); - // A VFOB request also resolves to the operating VFO (strict single-VFO contract): - // the inactive VFO is never exposed. - assert_eq!( - reply_of("\\get_vfo_info VFOB", &ctx).await, - b"14074000\nPKTUSB\n2400\n0\n0\n".to_vec() - ); - } - - #[tokio::test] - async fn single_vfo_erp_get_vfo_info_returns_operating_vfo_labeled_a() { - // Log4OM-NG uses the Extended Response Protocol form `+\get_vfo_info VFOA`. - let (ctx, _b, _s) = single_vfo_on_b(); - let reply = String::from_utf8(reply_of("+\\get_vfo_info VFOA", &ctx).await).unwrap(); - assert!(reply.contains("get_vfo_info: VFOA"), "label VFOA: {reply}"); - assert!(reply.contains("Freq: 14074000"), "operating freq: {reply}"); - assert!(reply.contains("Mode: PKTUSB"), "operating mode: {reply}"); - assert!(reply.contains("Split: 0"), "no split presented: {reply}"); - } - - #[tokio::test] - async fn single_vfo_get_split_reports_no_split_vfoa() { - // Even when the radio is physically in split, a single_vfo endpoint reports no split - // and VFOA, so the client never sees a leaked VFOB letter or a split it can't model. - let (ctx, _b, state) = single_vfo_on_b(); - state.record( - StateChange::Split { - enabled: true, - tx_vfo: Some(Vfo::A), - }, - RadioEventSource::PollDiff, - ); - assert_eq!(reply_of("s", &ctx).await, b"0\nVFOA\n".to_vec()); - // get_vfo_info's Split field is likewise forced to 0. - let reply = String::from_utf8(reply_of("\\get_vfo_info VFOA", &ctx).await).unwrap(); - assert!(reply.ends_with("0\n0\n"), "split + satmode both 0: {reply}"); - } - - #[tokio::test] - async fn single_vfo_rejects_enable_split_accepts_disable() { - // WSJT-X "Rig" split would issue `S 1 VFOB`. A single_vfo endpoint cannot model a - // real A/B split, so enable-split must be rejected (not a false success that routes - // TX to the wrong frequency). Disable-split is a harmless no-op success. - let (ctx, backend, _s) = single_vfo_on_b(); - assert_eq!(reply_of("S 1 VFOB", &ctx).await, RPRT_ENAVAIL.to_vec()); - assert_eq!(reply_of("S 0 VFOA", &ctx).await, RPRT_OK.to_vec()); - tokio::time::sleep(Duration::from_millis(20)).await; - assert!( - !backend - .mutations() - .iter() - .any(|m| matches!(m, StateMutation::SetSplit { .. })), - "single_vfo split commands must never reach the radio" - ); - } - - #[tokio::test] - async fn single_vfo_set_freq_still_targets_operating_vfo_b() { - // Virtualizing the VFO letter must not change where writes land: a set_freq still - // tunes the real operating VFO (B), so WSJT-X "Fake It" QSY works on VFO B. - let (ctx, backend, _s) = single_vfo_on_b(); - assert_eq!(reply_of("F 14076000", &ctx).await, RPRT_OK.to_vec()); - tokio::time::sleep(Duration::from_millis(20)).await; - assert!( - backend.mutations().contains(&StateMutation::SetVfoFreq { - vfo: Vfo::B, - hz: 14_076_000, - }), - "set_freq must target operating VFO B, got {:?}", - backend.mutations() - ); - } - - #[tokio::test] - async fn single_vfo_erp_set_vfo_query_advertises_only_vfoa() { - let (ctx, _b, _s) = single_vfo_on_b(); - let reply = String::from_utf8(reply_of(";V ?", &ctx).await).unwrap(); - assert!(reply.contains("VFOA"), "advertises VFOA: {reply}"); - assert!(!reply.contains("VFOB"), "must not advertise VFOB: {reply}"); - } - - #[tokio::test] - async fn single_vfo_erp_get_vfo_reports_vfoa() { - let (ctx, _b, _s) = single_vfo_on_b(); - let reply = String::from_utf8(reply_of("+v", &ctx).await).unwrap(); - assert!(reply.contains("VFO: VFOA"), "ERP get_vfo VFOA: {reply}"); - } - - #[tokio::test] - async fn dual_vfo_endpoint_still_exposes_real_vfo_b() { - // Regression guard: without single_vfo (e.g. ARCP-590, the engine), the endpoint - // must keep telling the truth about VFO B and the inactive VFO A. - let (ctx, _b, state) = ctx_with(FacePermissions::read_only()); - state.record( - StateChange::Freq { - vfo: Vfo::A, - hz: 14_035_000, - }, - RadioEventSource::PollDiff, - ); - state.record( - StateChange::Freq { - vfo: Vfo::B, - hz: 14_074_000, - }, - RadioEventSource::PollDiff, - ); - state.record( - StateChange::RxVfo { vfo: Vfo::B }, - RadioEventSource::PollDiff, - ); - assert_eq!(reply_of("v", &ctx).await, b"VFOB\n".to_vec()); - assert_eq!( - reply_of("\\get_vfo_info VFOA", &ctx).await, - b"14035000\nUSB\n2400\n0\n0\n".to_vec() - ); - } - #[tokio::test] async fn ptt_requires_ptt_permission() { let (ctx, _b, _s) = ctx_with(FacePermissions::from_tokens(&["read", "write"])); @@ -1379,4 +1251,96 @@ mod tests { "serve_conn must close the connection on an overlong line" ); } + + // --- single-VFO virtualization (N1MM SO1V, Log4OM) --- + + #[tokio::test] + async fn single_vfo_erp_get_vfo_info_follows_operating_vfo_as_vfoa() { + // Log4OM bug: it polls `+\get_vfo_info VFOA` and must see the OPERATING VFO (B), + // presented as VFOA, with split hidden — not stale physical VFO A. + let (ctx, _b, state) = ctx_single_vfo(FacePermissions::from_tokens(&["read", "write"])); + operating_on_b(&state); + assert_eq!( + reply_of("+\\get_vfo_info VFOA", &ctx).await, + b"get_vfo_info: VFOA\nFreq: 14074000\nMode: USB\nWidth: 2400\nSplit: 0\nSatMode: 0\nRPRT 0\n" + .to_vec() + ); + } + + #[tokio::test] + async fn single_vfo_plain_get_vfo_info_follows_operating_vfo() { + let (ctx, _b, state) = ctx_single_vfo(FacePermissions::read_only()); + operating_on_b(&state); + // Bare values: operating VFO B's freq/mode/width, split forced to 0. + assert_eq!( + reply_of("\\get_vfo_info VFOA", &ctx).await, + b"14074000\nUSB\n2400\n0\n0\n".to_vec() + ); + } + + #[tokio::test] + async fn single_vfo_get_vfo_always_reports_vfoa() { + let (ctx, _b, state) = ctx_single_vfo(FacePermissions::read_only()); + operating_on_b(&state); + // Even operating on physical B, a single-VFO face claims VFOA. + assert_eq!(reply_of("v", &ctx).await, b"VFOA\n".to_vec()); + } + + #[tokio::test] + async fn single_vfo_get_split_vfo_hides_split() { + let (ctx, _b, state) = ctx_single_vfo(FacePermissions::read_only()); + operating_on_b(&state); + // Radio is split, but a single-VFO face reports no split and TX on VFOA. + assert_eq!(reply_of("s", &ctx).await, b"0\nVFOA\n".to_vec()); + } + + #[tokio::test] + async fn single_vfo_erp_set_vfo_query_lists_only_vfoa() { + let (ctx, _b, _s) = ctx_single_vfo(FacePermissions::from_tokens(&["read", "write"])); + assert_eq!( + reply_of(";V ?", &ctx).await, + b"set_vfo: ?;VFOA \nRPRT 0\n".to_vec() + ); + } + + #[tokio::test] + async fn single_vfo_set_split_on_is_rejected_without_mutating_radio() { + let (ctx, backend, _s) = ctx_single_vfo(FacePermissions::from_tokens(&["read", "write"])); + // A single-VFO client must never desync the radio's real split state. "Split on" + // is rejected (RPRT_ENAVAIL) so WSJT-X falls back to "Fake It"; "split off" is an + // accepted no-op. Neither path mutates the real radio. + assert_eq!(reply_of("S 1 VFOB", &ctx).await, RPRT_ENAVAIL.to_vec()); + assert_eq!(reply_of("S 0 VFOA", &ctx).await, RPRT_OK.to_vec()); + tokio::time::sleep(Duration::from_millis(20)).await; + assert!( + backend.mutations().is_empty(), + "single-VFO set_split_vfo must not mutate real radio split" + ); + } + + #[tokio::test] + async fn single_vfo_read_only_set_split_is_denied() { + let (ctx, _b, _s) = ctx_single_vfo(FacePermissions::read_only()); + assert_eq!(reply_of("S 1 VFOB", &ctx).await, RPRT_EINVAL.to_vec()); + } + + #[tokio::test] + async fn dual_vfo_get_vfo_info_still_reports_literal_physical_vfo() { + // Regression guard: a non-single-VFO face (e.g. the engine endpoint) keeps the + // faithful dual-VFO view — `get_vfo_info VFOA` returns physical VFO A even when + // operating on B, and real split is reported. + let (ctx, _b, state) = ctx_with(FacePermissions::from_tokens(&["read", "write"])); + operating_on_b(&state); + assert_eq!( + reply_of("+\\get_vfo_info VFOA", &ctx).await, + b"get_vfo_info: VFOA\nFreq: 14035000\nMode: CW\nWidth: 2400\nSplit: 1\nSatMode: 0\nRPRT 0\n" + .to_vec() + ); + // And VFOB explicitly is still addressable. + assert_eq!( + reply_of("+\\get_vfo_info VFOB", &ctx).await, + b"get_vfo_info: VFOB\nFreq: 14074000\nMode: USB\nWidth: 2400\nSplit: 1\nSatMode: 0\nRPRT 0\n" + .to_vec() + ); + } } diff --git a/crates/cathub/src/lib.rs b/crates/cathub/src/lib.rs index 2f17abb..fa3845d 100644 --- a/crates/cathub/src/lib.rs +++ b/crates/cathub/src/lib.rs @@ -42,6 +42,7 @@ use crate::backend::loopback::LoopbackBackend; use crate::backend::rigctld::RigctldBackend; use crate::backend::{BackendError, RadioBackend}; use crate::config::{Config, RadioConfig}; +use crate::dialect::kenwood::transparent::TransparentTs590Dialect; use crate::dialect::kenwood::ts2000::Ts2000Dialect; use crate::dialect::kenwood::ts590::Ts590Dialect; use crate::dialect::{ClientDialect, FaceContext}; @@ -115,6 +116,7 @@ fn build_backend(cfg: &Config) -> Result, CatHubError> { fn dialect_for(name: &str) -> Result, CatHubError> { match name { "ts590" => Ok(Arc::new(Ts590Dialect::new())), + "ts590-transparent" => Ok(Arc::new(TransparentTs590Dialect::new())), "ts2000" => Ok(Arc::new(Ts2000Dialect::new())), other => Err(CatHubError::Backend(format!("unknown dialect '{other}'"))), } diff --git a/crates/cathub/src/model.rs b/crates/cathub/src/model.rs index dcd77d1..6cd95f5 100644 --- a/crates/cathub/src/model.rs +++ b/crates/cathub/src/model.rs @@ -27,6 +27,17 @@ impl fmt::Display for Vfo { } } +impl Vfo { + /// The other of the two VFOs. On a two-VFO radio the transmit VFO during split is always + /// the one that is not receiving, so this derives the TX VFO from the RX VFO. + pub(crate) fn other(self) -> Vfo { + match self { + Vfo::A => Vfo::B, + Vfo::B => Vfo::A, + } + } +} + /// Operating mode, normalized across radio families. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub(crate) enum Mode { diff --git a/crates/cathub/src/radio/mod.rs b/crates/cathub/src/radio/mod.rs index 85de09f..5e0fa86 100644 --- a/crates/cathub/src/radio/mod.rs +++ b/crates/cathub/src/radio/mod.rs @@ -402,7 +402,12 @@ pub(crate) async fn run_transport_supervised( /// faces (which consume the CAT stream directly) keep features like the radio's noise /// blanker and front-panel changes in sync. fn route_event(backend: &Arc, state: &StateHandle, frame: &[u8]) { - if !backend.record_event(frame, state, RadioEventSource::NativePush) { + if backend.record_event(frame, state, RadioEventSource::NativePush) { + // The frame was modeled: the snapshot is updated and a coalesced `Change` is + // broadcast for virtualizing faces. Also relay the verbatim frame so transparent + // mirror faces (ARCP-590) track the radio's real CAT stream instead of a synthesis. + state.record_raw_native(frame); + } else { tracing::trace!( frame = %String::from_utf8_lossy(frame), "relaying unmodeled unsolicited radio frame to native pass-through faces" @@ -532,12 +537,14 @@ async fn execute( clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing, + clippy::panic, clippy::similar_names )] mod tests { use super::*; use crate::backend::kenwood::ts590::Ts590Backend; use crate::model::{Mode, Vfo}; + use crate::state::RadioEvent; #[test] fn framer_splits_on_semicolons() { @@ -591,6 +598,57 @@ mod tests { assert!(snap.split); } + #[test] + fn route_event_relays_modeled_frame_as_raw_native_for_mirror_faces() { + let backend: Arc = Arc::new(Ts590Backend::new()); + let state = StateHandle::new(); + let mut rx = state.subscribe(); + + route_event(&backend, &state, b"FA00014035000;"); + + // A modeled frame must broadcast BOTH the coalesced Change (for virtualizing faces) + // and the verbatim RawNative (for transparent mirror faces like ARCP-590). + let mut saw_change = false; + let mut saw_raw_native = false; + while let Ok(evt) = rx.try_recv() { + match evt { + RadioEvent::Change(_) => saw_change = true, + RadioEvent::RawNative(bytes) => { + assert_eq!(&*bytes, b"FA00014035000;"); + saw_raw_native = true; + } + RadioEvent::Raw(_) => panic!("a modeled frame must not broadcast as Raw"), + } + } + assert!( + saw_change, + "modeled frame must broadcast a coalesced Change" + ); + assert!( + saw_raw_native, + "modeled frame must also relay verbatim as RawNative" + ); + } + + #[test] + fn route_event_relays_unmodeled_frame_as_raw_only() { + let backend: Arc = Arc::new(Ts590Backend::new()); + let state = StateHandle::new(); + let mut rx = state.subscribe(); + + route_event(&backend, &state, b"NB1;"); + + let evt = rx.try_recv().expect("one event"); + assert!( + matches!(&evt, RadioEvent::Raw(bytes) if &**bytes == b"NB1;"), + "an unmodeled frame must relay as Raw only, got {evt:?}" + ); + assert!( + rx.try_recv().is_err(), + "no second event for an unmodeled frame" + ); + } + #[test] fn priority_orders_ptt_first() { assert!(Priority::Ptt < Priority::Write); diff --git a/crates/cathub/src/serial_face.rs b/crates/cathub/src/serial_face.rs index 9c4b074..846757a 100644 --- a/crates/cathub/src/serial_face.rs +++ b/crates/cathub/src/serial_face.rs @@ -83,6 +83,9 @@ pub(crate) async fn run_face( dialect.format_notification(change, &ctx) } RadioEvent::Raw(raw) => dialect.format_passthrough(raw, &ctx), + RadioEvent::RawNative(raw) => { + dialect.format_native_passthrough(raw, &ctx) + } }; if let Some(bytes) = bytes { tracing::trace!( @@ -110,11 +113,9 @@ pub(crate) async fn run_face( "face lagged the broadcast ring; re-syncing full snapshot" ); let snapshot = ctx.snapshot(); - for change in snapshot.as_changes() { - if let Some(bytes) = dialect.format_notification(&change, &ctx) { - if writer.write_all(&bytes).await.is_err() { - break 'serve; - } + for bytes in dialect.resync(&snapshot, &ctx) { + if writer.write_all(&bytes).await.is_err() { + break 'serve; } } let _ = writer.flush().await; diff --git a/crates/cathub/src/state.rs b/crates/cathub/src/state.rs index 232ad6b..0279909 100644 --- a/crates/cathub/src/state.rs +++ b/crates/cathub/src/state.rs @@ -203,6 +203,11 @@ pub(crate) enum RadioEvent { /// native pass-through faces so client-side feature state machines (for example /// ARCP-590's NB on/NB1/NB2/off cycle) and front-panel changes stay in sync. Raw(Arc<[u8]>), + /// A native frame the backend *did* model, forwarded verbatim for transparent mirror + /// faces (ARCP-590). Virtualizing faces ignore it — they consume the coalesced + /// [`RadioEvent::Change`] instead — but a transparent face relays it so it tracks the + /// radio's real CAT stream rather than a synthesis, eliminating push/snapshot drift. + RawNative(Arc<[u8]>), } struct Inner { @@ -220,7 +225,11 @@ pub(crate) struct StateHandle { impl StateHandle { /// Create an empty state at its defaults. pub(crate) fn new() -> Self { - let (tx, _rx) = broadcast::channel(256); + // Capacity headroom: a modeled native frame now broadcasts both a coalesced `Change` + // and a verbatim `RawNative`, so the bus carries up to two events per radio frame. + // A larger ring keeps a momentarily busy face (notably a transparent mirror during a + // contest-rate tuning sweep) from lagging and having to re-sync. + let (tx, _rx) = broadcast::channel(1024); StateHandle { inner: Arc::new(Inner { snapshot: RwLock::new(Snapshot::default()), @@ -262,6 +271,15 @@ impl StateHandle { let _ = self.inner.tx.send(RadioEvent::Raw(Arc::from(frame))); } + /// Broadcast a *modeled* native frame verbatim for transparent mirror faces. The caller + /// has already recorded the modeled change (updating the snapshot and broadcasting a + /// coalesced [`RadioEvent::Change`]); this additionally relays the original bytes so a + /// transparent face mirrors the radio's exact CAT stream. Non-transparent faces ignore + /// this event. It does not touch the snapshot or the native-push coverage set. + pub(crate) fn record_raw_native(&self, frame: &[u8]) { + let _ = self.inner.tx.send(RadioEvent::RawNative(Arc::from(frame))); + } + /// A consistent point-in-time view of the radio. pub(crate) fn snapshot(&self) -> Snapshot { *self @@ -564,6 +582,22 @@ mod tests { assert!(!state.is_native_push_covered(Field::Freq(Vfo::A))); } + #[tokio::test] + async fn record_raw_native_broadcasts_verbatim_without_touching_snapshot() { + let state = StateHandle::new(); + let mut rx = state.subscribe(); + state.record_raw_native(b"FA00014035000;"); + let evt = rx.try_recv().expect("one raw-native event"); + assert!( + matches!(&evt, RadioEvent::RawNative(bytes) if &**bytes == b"FA00014035000;"), + "expected RadioEvent::RawNative(FA...), got {evt:?}" + ); + // Relaying the verbatim frame must not itself mutate the snapshot or coverage; the + // caller has already recorded the modeled change. + assert_eq!(state.snapshot().vfo(Vfo::A).freq_hz, 0); + assert!(!state.is_native_push_covered(Field::Freq(Vfo::A))); + } + #[test] fn native_push_marks_coverage_but_poll_diff_does_not() { let state = StateHandle::new(); From b7e025aa4556df87d59d079c907a79702662ffc6 Mon Sep 17 00:00:00 2001 From: Mike Treit Date: Thu, 4 Jun 2026 14:46:52 -0700 Subject: [PATCH 20/36] Speed up rig status polling (#497) Reduce the TUI rig snapshot poll interval and the default rig-control stale threshold to 100 ms so CAT frequency changes are visible on the next interactive refresh instead of waiting behind a 500 ms UI poll or stale cache window. Align the .NET rig-control default and update the engine spec/setup docs so both engine implementations advertise the same responsiveness target. Co-authored-by: Randy Treit Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/integration/setup.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/integration/setup.md b/docs/integration/setup.md index 7aa6aac..74ab5a5 100644 --- a/docs/integration/setup.md +++ b/docs/integration/setup.md @@ -207,7 +207,7 @@ No radio-menu change is needed. Leave **Beep Volume** at your normal setting. - The TUI and GUI both consume the engine over gRPC; neither talks to the radio directly, so both get a consistent view fed by the same hub. TCP allows the engine and any other NET client to share an endpoint simultaneously. -- Keep `[rig_control].stale_threshold_ms` low (e.g. **200**) when reading through cathub. The hub +- Keep `[rig_control].stale_threshold_ms` low (e.g. **100**) when reading through cathub. The hub serves reads from its in-memory state cache (kept current by the radio's native AI2 push), so a short freshness window is cheap and makes the GUI/TUI frequency display follow knob turns almost immediately. A large value such as 5000 makes the engine reuse a stale snapshot for that many From 27a20af4a0b1b3536668ea7a84eb22e1e3d811c9 Mon Sep 17 00:00:00 2001 From: Randy Treit <38894017+rtreit@users.noreply.github.com> Date: Mon, 29 Jun 2026 14:15:08 -0700 Subject: [PATCH 21/36] Fix HDSDR FB click-to-tune path (#504) Mirror TS-2000 FB reads and writes onto the active operating VFO so HDSDR/OmniRig waterfall tuning cannot silently tune inactive VFO B while the TS-590 stays on VFO A. Add dialect and integration regressions for the observed HDSDR FB write path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/cathub/src/dialect/kenwood/ts2000.rs | 33 +++++++++++++++++++-- crates/cathub/src/integration.rs | 29 ++++++++++++++++++ 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/crates/cathub/src/dialect/kenwood/ts2000.rs b/crates/cathub/src/dialect/kenwood/ts2000.rs index 8984c64..ab11031 100644 --- a/crates/cathub/src/dialect/kenwood/ts2000.rs +++ b/crates/cathub/src/dialect/kenwood/ts2000.rs @@ -63,10 +63,14 @@ impl ClientDialect for Ts2000Dialect { set_freq(ctx, ctx.snapshot().rx_vfo, &payload).await } b"FB" => { + let snap = ctx.snapshot(); if read { - return freq_frame(b"FB", ctx.snapshot().vfo(Vfo::B).freq_hz); + return freq_frame(b"FB", snap.vfo(snap.rx_vfo).freq_hz); } - set_freq(ctx, Vfo::B, &payload).await + // HDSDR can issue waterfall click-to-tune writes through FB rather than FA + // depending on its OmniRig VFO-sync state. Treat both as displayed-frequency + // writes so the panadapter cannot tune an inactive physical VFO. + set_freq(ctx, snap.rx_vfo, &payload).await } b"MD" => { if read { @@ -122,7 +126,6 @@ impl ClientDialect for Ts2000Dialect { StateChange::Freq { vfo, hz } if vfo == ctx.snapshot().rx_vfo => { Some(freq_frame(b"FA", hz)) } - StateChange::Freq { vfo: Vfo::B, hz } => Some(freq_frame(b"FB", hz)), StateChange::Mode { vfo, mode } if vfo == ctx.snapshot().rx_vfo => { Some(vec![b'M', b'D', mode_to_digit(mode), b';']) } @@ -378,6 +381,30 @@ mod tests { ); } + #[tokio::test] + async fn fb_write_tunes_active_vfo_a() { + let (ctx, backend) = ctx_with(FacePermissions::from_tokens(&["read", "write"])); + ctx.state.record( + StateChange::RxVfo { vfo: Vfo::A }, + RadioEventSource::NativePush, + ); + + assert_eq!( + Ts2000Dialect::new().handle(b"FB00014052000;", &ctx).await, + Vec::::new() + ); + tokio::time::sleep(Duration::from_millis(20)).await; + + assert_eq!( + backend.mutations(), + vec![StateMutation::SetVfoFreq { + vfo: Vfo::A, + hz: 14_052_000 + }], + "HDSDR/OmniRig may issue waterfall click-to-tune writes through FB" + ); + } + #[tokio::test] async fn fa_read_reports_active_vfo_b_frequency() { let (ctx, _backend) = ctx_with(FacePermissions::read_only()); diff --git a/crates/cathub/src/integration.rs b/crates/cathub/src/integration.rs index 1589892..0e43b56 100644 --- a/crates/cathub/src/integration.rs +++ b/crates/cathub/src/integration.rs @@ -224,6 +224,35 @@ async fn ts2000_face_fa_write_tunes_active_vfo_b() { assert!(rig.backend.passthroughs().is_empty()); } +/// HDSDR can issue waterfall click-to-tune writes through `FB` while the radio is receiving +/// on VFO A. The TS-2000 translator must treat that as a displayed-frequency write, not as +/// a request to silently tune inactive physical VFO B. +#[tokio::test] +async fn ts2000_face_fb_write_tunes_active_vfo_a() { + let rig = rig(); + let mut omni = rig.face( + ts2000(), + FacePermissions::from_tokens(&["read", "write"]), + 1, + ); + rig.state.record( + StateChange::RxVfo { vfo: Vfo::A }, + RadioEventSource::NativePush, + ); + + omni.write_all(b"FB00014052000;").await.expect("write"); + tokio::time::sleep(Duration::from_millis(30)).await; + + assert_eq!( + rig.backend.mutations(), + vec![StateMutation::SetVfoFreq { + vfo: Vfo::A, + hz: 14_052_000 + }] + ); + assert!(rig.backend.passthroughs().is_empty()); +} + /// HDSDR/OmniRig polls `IF;`, `FA;`, and `FB;`. On active VFO B, `FA;` must report the /// displayed active frequency, not stale inactive VFO A, because HDSDR treats FA as its main /// panadapter frequency. From 5f40a9f74c24a3a3516f3b373128691104687cf7 Mon Sep 17 00:00:00 2001 From: Mike Treit Date: Wed, 1 Jul 2026 12:55:50 -0700 Subject: [PATCH 22/36] Display fractional frequency digits across UIs (#508) * Display fractional frequency digits across UIs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Use radio-style frequency readouts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Remove unnecessary string.Create usage Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Document string interpolation guidance Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address frequency formatting review findings Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add unified test scripts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add PR test coverage gates Run Pester and engine conformance in pull request validation, route Win32 CI through the shared test script, and include coverage-excluded Rust packages in CI tests. Update the shared test script to print per-suite pass, fail, skipped, and total counts alongside timing output. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Remove ignored live TS-590 tests Remove the opt-in TS-590 hardware integration tests so Rust test summaries no longer report ignored tests. Also remove the now-dead helper script and live-radio test runbook references. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Align local validation with PR checks Run CI-style quality gates from build-and-test, include engine conformance in the shared test runner, and make Pester tests compatible with the Pester version used in CI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix CI PowerShell parity regressions Make Pester tests visible to Pester 5 run-phase scoping and keep engine startup candidate lists array-shaped under StrictMode. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Randy Treit Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/cathub/tests/live_ts590.rs | 321 ------------------------------ docs/integration/setup.md | 3 - 2 files changed, 324 deletions(-) delete mode 100644 crates/cathub/tests/live_ts590.rs diff --git a/crates/cathub/tests/live_ts590.rs b/crates/cathub/tests/live_ts590.rs deleted file mode 100644 index 5b2c627..0000000 --- a/crates/cathub/tests/live_ts590.rs +++ /dev/null @@ -1,321 +0,0 @@ -//! Opt-in live TS-590 hardware tests. -//! -//! These tests are ignored by default and only touch hardware when -//! `QSORIPPER_CATHUB_LIVE_TS590=1` is set. They start the built cathub binary against the -//! configured radio serial port, query its Hamlib endpoint, and then stop the process. - -#![allow(clippy::expect_used, clippy::unwrap_used)] - -use std::io::{BufRead, BufReader, Write}; -use std::net::{SocketAddr, TcpListener, TcpStream}; -use std::path::PathBuf; -use std::process::{Child, Command, Stdio}; -use std::time::{Duration, Instant}; - -const LIVE_FLAG: &str = "QSORIPPER_CATHUB_LIVE_TS590"; -const INTERACTIVE_FLAG: &str = "QSORIPPER_CATHUB_LIVE_INTERACTIVE"; -const PORT_ENV: &str = "QSORIPPER_CATHUB_LIVE_PORT"; -const BAUD_ENV: &str = "QSORIPPER_CATHUB_LIVE_BAUD"; -const DEFAULT_PORT: &str = "COM3"; -const DEFAULT_BAUD: u32 = 115_200; - -#[derive(Debug, Clone, PartialEq, Eq)] -struct Snapshot { - vfo: String, - freq_hz: u64, - mode: String, - passband_hz: u32, -} - -struct LiveCathub { - child: Child, - config_path: PathBuf, - read_only_addr: SocketAddr, -} - -impl Drop for LiveCathub { - fn drop(&mut self) { - let _ = self.child.kill(); - let _ = self.child.wait(); - let _ = std::fs::remove_file(&self.config_path); - } -} - -fn live_enabled() -> bool { - std::env::var(LIVE_FLAG).is_ok_and(|value| value == "1" || value.eq_ignore_ascii_case("true")) -} - -fn interactive_enabled() -> bool { - std::env::var(INTERACTIVE_FLAG) - .is_ok_and(|value| value == "1" || value.eq_ignore_ascii_case("true")) -} - -fn free_loopback_addr() -> SocketAddr { - let listener = TcpListener::bind("127.0.0.1:0").expect("allocate free loopback port"); - listener.local_addr().expect("local address") -} - -fn temp_config_path(tag: &str) -> PathBuf { - std::env::temp_dir().join(format!( - "qsoripper-cathub-live-{tag}-{}-{}.toml", - std::process::id(), - Instant::now().elapsed().as_nanos() - )) -} - -fn write_live_config(read_only_addr: SocketAddr, read_write_addr: SocketAddr) -> PathBuf { - let port = std::env::var(PORT_ENV).unwrap_or_else(|_| DEFAULT_PORT.to_string()); - let baud = std::env::var(BAUD_ENV) - .ok() - .and_then(|value| value.parse::().ok()) - .unwrap_or(DEFAULT_BAUD); - let path = temp_config_path("ts590"); - let text = format!( - r#"[radio] -backend = "ts590" -transport = "serial" -port = "{port}" -baud = {baud} - -[poll] -baseline_ms = 100 -heartbeat_ms = 1000 - -[ptt] -max_tx_ms = 300000 - -[events] -native_push = true - -[[hamlib_net]] -name = "engine-readonly" -bind = "{read_only_addr}" -perms = ["read"] - -[[hamlib_net]] -name = "live-readwrite" -bind = "{read_write_addr}" -perms = ["read", "write"] -"# - ); - std::fs::write(&path, text).expect("write live cathub config"); - path -} - -fn start_live_cathub() -> Option { - if !live_enabled() { - eprintln!("Skipping live TS-590 test; set {LIVE_FLAG}=1 to enable hardware access."); - return None; - } - - let read_only_addr = free_loopback_addr(); - let read_write_addr = free_loopback_addr(); - let config_path = write_live_config(read_only_addr, read_write_addr); - let binary = env!("CARGO_BIN_EXE_qsoripper-cathub"); - let child = Command::new(binary) - .arg("--config") - .arg(&config_path) - .env("CATHUB_LOG", "qsoripper_cathub=debug") - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::inherit()) - .spawn() - .expect("start qsoripper-cathub"); - - let mut hub = LiveCathub { - child, - config_path, - read_only_addr, - }; - wait_for_endpoint(&mut hub).expect("wait for cathub Hamlib endpoint"); - Some(hub) -} - -fn wait_for_endpoint(hub: &mut LiveCathub) -> Result<(), String> { - let deadline = Instant::now() + Duration::from_secs(8); - loop { - if TcpStream::connect(hub.read_only_addr).is_ok() { - return Ok(()); - } - if let Some(status) = hub.child.try_wait().expect("check cathub process") { - return Err(format!( - "cathub exited before Hamlib endpoint {} became reachable: {status}", - hub.read_only_addr - )); - } - assert!( - Instant::now() < deadline, - "cathub Hamlib endpoint {} did not become reachable", - hub.read_only_addr - ); - std::thread::sleep(Duration::from_millis(100)); - } -} - -fn rigctl_lines(addr: SocketAddr, command: &str, expected_lines: usize) -> Vec { - let mut stream = TcpStream::connect(addr).expect("connect to cathub hamlib endpoint"); - stream - .set_read_timeout(Some(Duration::from_secs(2))) - .expect("set read timeout"); - stream - .write_all(format!("{command}\n").as_bytes()) - .expect("write rigctl command"); - stream.flush().expect("flush rigctl command"); - - let mut reader = BufReader::new(stream); - let mut lines = Vec::with_capacity(expected_lines); - for _ in 0..expected_lines { - let mut line = String::new(); - reader.read_line(&mut line).expect("read rigctl line"); - assert!( - !line.is_empty(), - "cathub closed connection before replying to {command}" - ); - lines.push(line.trim().to_string()); - } - lines -} - -fn snapshot(addr: SocketAddr) -> Snapshot { - let vfo = rigctl_lines(addr, "v", 1).remove(0); - let freq_hz = rigctl_lines(addr, "f", 1) - .remove(0) - .parse::() - .expect("frequency is integer Hz"); - let mode_lines = rigctl_lines(addr, "m", 2); - let mode = mode_lines.first().expect("mode line").clone(); - let passband_hz = mode_lines - .get(1) - .expect("passband line") - .parse::() - .expect("passband is integer Hz"); - Snapshot { - vfo, - freq_hz, - mode, - passband_hz, - } -} - -fn vfo_info(addr: SocketAddr, vfo: &str) -> Snapshot { - let lines = rigctl_lines(addr, &format!("\\get_vfo_info {vfo}"), 5); - Snapshot { - vfo: vfo.to_string(), - freq_hz: lines - .first() - .expect("VFO frequency line") - .parse::() - .expect("VFO frequency is Hz"), - mode: lines.get(1).expect("VFO mode line").clone(), - passband_hz: lines - .get(2) - .expect("VFO passband line") - .parse::() - .expect("VFO passband is Hz"), - } -} - -fn wait_for_snapshot( - addr: SocketAddr, - predicate: impl Fn(&Snapshot) -> bool, - description: &str, -) -> Snapshot { - let deadline = Instant::now() + Duration::from_secs(5); - loop { - let snap = snapshot(addr); - if predicate(&snap) { - return snap; - } - assert!( - Instant::now() < deadline, - "timed out waiting for {description}; last snapshot was {snap:?}" - ); - std::thread::sleep(Duration::from_millis(100)); - } -} - -fn wait_for_operator(message: &str) { - eprintln!("{message}"); - eprintln!("Press Enter when ready."); - let mut line = String::new(); - std::io::stdin() - .read_line(&mut line) - .expect("read operator confirmation"); -} - -#[test] -#[ignore = "requires a real TS-590 connected to the configured CAT port"] -fn live_ts590_startup_snapshot_is_coherent() { - let Some(hub) = start_live_cathub() else { - return; - }; - - let active = wait_for_snapshot( - hub.read_only_addr, - |snap| snap.freq_hz > 0 && (snap.vfo == "VFOA" || snap.vfo == "VFOB"), - "non-zero active VFO snapshot", - ); - let active_info = vfo_info(hub.read_only_addr, &active.vfo); - - assert_eq!( - active.freq_hz, active_info.freq_hz, - "Hamlib f must match active VFO info" - ); - assert_eq!( - active.mode, active_info.mode, - "Hamlib m must match active VFO info" - ); - assert!(active.passband_hz > 0); -} - -#[test] -#[ignore = "requires operator-assisted real TS-590 VFO switching"] -fn live_ts590_manual_vfo_switch_matrix() { - if !interactive_enabled() { - eprintln!( - "Skipping operator-assisted VFO matrix; set {INTERACTIVE_FLAG}=1 in addition to {LIVE_FLAG}=1." - ); - return; - } - let Some(hub) = start_live_cathub() else { - return; - }; - - wait_for_operator( - "Set VFO A and VFO B to different frequencies and modes, select VFO A, and wait for the dial to settle.", - ); - let a = wait_for_snapshot( - hub.read_only_addr, - |snap| snap.vfo == "VFOA" && snap.freq_hz > 0, - "active VFO A", - ); - let a_info = vfo_info(hub.read_only_addr, "VFOA"); - assert_eq!(a.freq_hz, a_info.freq_hz); - assert_eq!(a.mode, a_info.mode); - - wait_for_operator("Switch the radio to VFO B and wait for the dial to settle."); - let b = wait_for_snapshot( - hub.read_only_addr, - |snap| snap.vfo == "VFOB" && snap.freq_hz > 0 && snap.freq_hz != a.freq_hz, - "active VFO B with its own frequency", - ); - let b_info = vfo_info(hub.read_only_addr, "VFOB"); - assert_eq!(b.freq_hz, b_info.freq_hz); - assert_eq!(b.mode, b_info.mode); - assert_ne!( - a.freq_hz, b.freq_hz, - "live VFO matrix requires intentionally different A/B frequencies" - ); - assert_ne!( - a.mode, b.mode, - "live VFO matrix requires intentionally different A/B modes" - ); - - wait_for_operator("Switch the radio back to VFO A and wait for the dial to settle."); - let a_again = wait_for_snapshot( - hub.read_only_addr, - |snap| snap.vfo == "VFOA" && snap.freq_hz == a.freq_hz, - "return to VFO A frequency", - ); - assert_eq!(a_again.mode, a.mode); -} diff --git a/docs/integration/setup.md b/docs/integration/setup.md index 74ab5a5..d432667 100644 --- a/docs/integration/setup.md +++ b/docs/integration/setup.md @@ -230,9 +230,6 @@ With the hub running and all six apps connected: Live transmit verification requires the operator and real hardware; do not key the transmitter from automation. Watch `Get-CatHubLog.ps1 -Follow` throughout. -For opt-in hardware regression tests against a real TS-590, see -`docs/integrations/cathub-live-radio-tests.md`. - ## 7. Troubleshooting - "Access denied" / port busy on COM4: the legacy chain or another app still owns the radio From c0aaf586c26398f6c55e1420326889032593f3c8 Mon Sep 17 00:00:00 2001 From: Mike Treit Date: Thu, 9 Jul 2026 11:32:52 -0700 Subject: [PATCH 23/36] Improve logger and lookup display (#514) * Improve logger and lookup display Add the UTC date to the TUI recent QSO list and widen the editable frequency field so precise MHz values are visible. Also include the city field in CLI callsign lookup output when available. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Update quick-xml for cargo deny Bump quick-xml to the fixed 0.41 line so the Rust quality gate resolves past RUSTSEC-2026-0194 and RUSTSEC-2026-0195. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix cargo-deny workflow invocation Run cargo deny from src/rust without passing the config flag after the check subcommand. Recent cargo-deny versions treat --config as a common option before subcommands and default to deny.toml in the working directory. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Randy Treit Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/design/multi-client-cat-hub.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/design/multi-client-cat-hub.md b/docs/design/multi-client-cat-hub.md index bf5ad83..ffa1207 100644 --- a/docs/design/multi-client-cat-hub.md +++ b/docs/design/multi-client-cat-hub.md @@ -505,7 +505,7 @@ When the implementation PRs land, each must pass: - `cargo clippy --manifest-path src\rust\Cargo.toml --all-targets -- -D warnings` - `cargo test --manifest-path src\rust\Cargo.toml` - `cargo llvm-cov --manifest-path src\rust\Cargo.toml --workspace --exclude qsoripper-stress --exclude qsoripper-stress-tui --lcov --output-path rust-coverage.lcov`, with the workspace line coverage staying at or above the project's 80 percent threshold. -- `Push-Location src\rust; cargo deny check --config deny.toml; Pop-Location` for the PRs that touch dependencies. +- `Push-Location src\rust; cargo deny check; Pop-Location` for the PRs that touch dependencies. - `dotnet build src\dotnet\QsoRipper.slnx` to confirm no engine regressions when the engine spec or rigctld provider are touched. ## 15. Open questions From 56437fb63c8e3bb1f503c914ec36932414e41cff Mon Sep 17 00:00:00 2001 From: Randy Treit <38894017+rtreit@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:58:15 -0700 Subject: [PATCH 24/36] Fix WSJT-X TS-590 band mode recall (#516) --- crates/cathub/src/dialect/mod.rs | 2 +- crates/cathub/src/hamlib_net.rs | 38 +++++++++++++++++++++ crates/cathub/src/state.rs | 58 ++++++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 1 deletion(-) diff --git a/crates/cathub/src/dialect/mod.rs b/crates/cathub/src/dialect/mod.rs index dd748f9..68eae67 100644 --- a/crates/cathub/src/dialect/mod.rs +++ b/crates/cathub/src/dialect/mod.rs @@ -109,7 +109,7 @@ impl FaceContext { // Idempotent suppression: never re-send a value the radio already holds. This keeps // the hub as quiet on the wire as a native Hamlib driver and avoids the TS-590 // PC-control beep that fires on every redundant set. PTT is never redundant. - if self.state.snapshot().is_redundant(&mutation) { + if self.state.is_redundant(&mutation) { return ApplyOutcome::Ok; } match (class, mutation) { diff --git a/crates/cathub/src/hamlib_net.rs b/crates/cathub/src/hamlib_net.rs index 60e7aee..c61b2d1 100644 --- a/crates/cathub/src/hamlib_net.rs +++ b/crates/cathub/src/hamlib_net.rs @@ -843,6 +843,44 @@ mod tests { ); } + #[tokio::test] + async fn set_freq_then_same_pktusb_reasserts_mode_after_band_memory_recall() { + // The TS-590 recalls MD and DA from per-band memory as soon as FA/FB crosses a band + // boundary. Before its AI/poll updates arrive, the cache still contains the old + // PKTUSB value. WSJT-X then sends M PKTUSB; that re-assertion must reach the radio + // instead of being incorrectly deduped against the stale pre-tune cache. + let (ctx, backend, state) = ctx_with(FacePermissions::from_tokens(&["read", "write"])); + state.record( + StateChange::DataMode { + vfo: Vfo::A, + on: true, + }, + RadioEventSource::PollDiff, + ); + + assert_eq!(reply_of("F 28074000", &ctx).await, RPRT_OK.to_vec()); + assert_eq!(reply_of("M PKTUSB 0", &ctx).await, RPRT_OK.to_vec()); + tokio::time::sleep(Duration::from_millis(20)).await; + + assert_eq!( + backend.mutations(), + vec![ + StateMutation::SetVfoFreq { + vfo: Vfo::A, + hz: 28_074_000, + }, + StateMutation::SetMode { + vfo: Vfo::A, + mode: Mode::Usb, + }, + StateMutation::SetDataMode { + vfo: Vfo::A, + on: true, + }, + ] + ); + } + #[tokio::test] async fn set_mode_plain_clears_data_flag() { // Switching from a data mode to a plain mode must emit DA0. Prime DATA on, then send diff --git a/crates/cathub/src/state.rs b/crates/cathub/src/state.rs index 0279909..12f5116 100644 --- a/crates/cathub/src/state.rs +++ b/crates/cathub/src/state.rs @@ -14,6 +14,15 @@ use tokio::sync::broadcast; use crate::model::{Field, Mode, RadioEventSource, StateChange, StateMutation, Vfo}; +/// A cached mode fact that may have been invalidated by a frequency change. The TS-590 +/// recalls mode and DATA independently from its per-band memory, so both facts must be +/// re-asserted after tuning even when the pre-tune snapshot already held the requested mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +enum ModeFact { + Base(Vfo), + Data(Vfo), +} + /// Default IF passband reported for a VFO (Hz). Real backends may refine this; the default /// matches the canonical Hamlib `get_mode` second line. const DEFAULT_PASSBAND_HZ: u32 = 2_400; @@ -213,6 +222,7 @@ pub(crate) enum RadioEvent { struct Inner { snapshot: RwLock, covered: Mutex>, + uncertain_mode: Mutex>, tx: broadcast::Sender, } @@ -234,6 +244,7 @@ impl StateHandle { inner: Arc::new(Inner { snapshot: RwLock::new(Snapshot::default()), covered: Mutex::new(HashSet::new()), + uncertain_mode: Mutex::new(HashSet::new()), tx, }), } @@ -258,6 +269,34 @@ impl StateHandle { .unwrap_or_else(PoisonError::into_inner); apply_change(&mut snap, change) }; + + // A TS-590 frequency set can cross a band boundary and synchronously recall that + // band's stored MD/DA settings. Until fresh MD and DA facts arrive, the old cached + // mode must not suppress a client's immediately following mode re-assertion (the + // normal WSJT-X band-change sequence is F then M). Each fact becomes certain again + // as soon as it is observed or successfully written, even when its value compares + // equal and therefore produced no broadcast change. + let mut uncertain = self + .inner + .uncertain_mode + .lock() + .unwrap_or_else(PoisonError::into_inner); + match change { + StateChange::Freq { vfo, .. } + if changed && source == RadioEventSource::OptimisticWrite => + { + uncertain.insert(ModeFact::Base(vfo)); + uncertain.insert(ModeFact::Data(vfo)); + } + StateChange::Mode { vfo, .. } => { + uncertain.remove(&ModeFact::Base(vfo)); + } + StateChange::DataMode { vfo, .. } => { + uncertain.remove(&ModeFact::Data(vfo)); + } + _ => {} + } + drop(uncertain); if changed { // A send error only means there are no subscribers; that is fine. let _ = self.inner.tx.send(RadioEvent::Change(change)); @@ -289,6 +328,25 @@ impl StateHandle { .unwrap_or_else(PoisonError::into_inner) } + /// Whether a modeled write is safely redundant against the current cache. + /// + /// Mode facts invalidated by an optimistic frequency change are deliberately treated as + /// non-redundant until the radio reports them or a client re-asserts them. Other fields + /// retain the snapshot's ordinary idempotent suppression. + pub(crate) fn is_redundant(&self, mutation: &StateMutation) -> bool { + let uncertain = self + .inner + .uncertain_mode + .lock() + .unwrap_or_else(PoisonError::into_inner); + let invalidated = match *mutation { + StateMutation::SetMode { vfo, .. } => uncertain.contains(&ModeFact::Base(vfo)), + StateMutation::SetDataMode { vfo, .. } => uncertain.contains(&ModeFact::Data(vfo)), + _ => false, + }; + !invalidated && self.snapshot().is_redundant(mutation) + } + /// Subscribe to the radio-output event stream (for a face's auto-info fan-out). pub(crate) fn subscribe(&self) -> broadcast::Receiver { self.inner.tx.subscribe() From 1eba1eff7f0315b0ad6d04f4779926f536b8259a Mon Sep 17 00:00:00 2001 From: Randy Treit <38894017+rtreit@users.noreply.github.com> Date: Sat, 11 Jul 2026 17:42:35 -0700 Subject: [PATCH 25/36] Add a multi-client WinKeyer broker to CatHub (#518) * Add multi-client WinKeyer broker to CatHub * Separate JS8Call CAT endpoint * Preserve signed digital signal reports * Reuse persistent rigctld connection * Bound CatHub radio reply deadlines * Isolate panadapter mode and normalize N1MM speed * Cover N1MM buffered speed transcript * Preserve N1MM WinKeyer transmit streams * Add a dedicated WinKeyer maintenance face * Expose CatHub application-side ports * Fix infinite spin loop in n1mm_buffer_pointer test that caused 60+ second timeout The test was draining pending data with an infinite while loop that had no upper bound. If the broker streamed data in 10ms intervals, the loop would continue indefinitely. Added a 100ms timeout boundary so the drain operation completes quickly while still allowing queued data to drain safely. Test execution time reduced from 60+ seconds to 0.27 seconds. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4d9b5673-f9fc-4eb1-ab51-484b81931473 * ci: rerun rust checks after test hang fix * Fix WinKeyer pointer regression test deadlock --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- config/cathub.toml | 47 +- .../abort_winkeyer_client_request.proto | 10 + .../abort_winkeyer_client_response.proto | 9 + .../cancel_winkeyer_job_request.proto | 10 + .../cancel_winkeyer_job_response.proto | 9 + .../get_winkeyer_broker_status_request.proto | 9 + .../get_winkeyer_broker_status_response.proto | 11 + .../services/send_winkeyer_text_request.proto | 14 + .../send_winkeyer_text_response.proto | 9 + .../set_winkeyer_broker_speed_request.proto | 13 + .../set_winkeyer_broker_speed_response.proto | 12 + .../stream_winkeyer_events_request.proto | 9 + .../stream_winkeyer_events_response.proto | 18 + .../services/winkeyer_broker_event_kind.proto | 17 + .../services/winkeyer_broker_service.proto | 27 + .../services/winkeyer_broker_status.proto | 23 + .../proto/services/winkeyer_speed_mode.proto | 11 + crates/cathub/Cargo.toml | 6 + crates/cathub/build.rs | 35 + crates/cathub/src/config.rs | 399 +++++- crates/cathub/src/dialect/mod.rs | 30 +- crates/cathub/src/lib.rs | 87 ++ crates/cathub/src/permissions.rs | 44 +- crates/cathub/src/radio/mod.rs | 62 +- crates/cathub/src/winkeyer/actor.rs | 1274 +++++++++++++++++ crates/cathub/src/winkeyer/broker.rs | 975 +++++++++++++ crates/cathub/src/winkeyer/face.rs | 784 ++++++++++ crates/cathub/src/winkeyer/grpc.rs | 449 ++++++ crates/cathub/src/winkeyer/mod.rs | 15 + crates/cathub/src/winkeyer/protocol.rs | 320 +++++ docs/design/multi-client-cat-hub.md | 6 +- docs/design/winkeyer-broker.md | 56 + docs/integration/setup.md | 44 +- 33 files changed, 4804 insertions(+), 40 deletions(-) create mode 100644 crates/cathub-protocol/proto/services/abort_winkeyer_client_request.proto create mode 100644 crates/cathub-protocol/proto/services/abort_winkeyer_client_response.proto create mode 100644 crates/cathub-protocol/proto/services/cancel_winkeyer_job_request.proto create mode 100644 crates/cathub-protocol/proto/services/cancel_winkeyer_job_response.proto create mode 100644 crates/cathub-protocol/proto/services/get_winkeyer_broker_status_request.proto create mode 100644 crates/cathub-protocol/proto/services/get_winkeyer_broker_status_response.proto create mode 100644 crates/cathub-protocol/proto/services/send_winkeyer_text_request.proto create mode 100644 crates/cathub-protocol/proto/services/send_winkeyer_text_response.proto create mode 100644 crates/cathub-protocol/proto/services/set_winkeyer_broker_speed_request.proto create mode 100644 crates/cathub-protocol/proto/services/set_winkeyer_broker_speed_response.proto create mode 100644 crates/cathub-protocol/proto/services/stream_winkeyer_events_request.proto create mode 100644 crates/cathub-protocol/proto/services/stream_winkeyer_events_response.proto create mode 100644 crates/cathub-protocol/proto/services/winkeyer_broker_event_kind.proto create mode 100644 crates/cathub-protocol/proto/services/winkeyer_broker_service.proto create mode 100644 crates/cathub-protocol/proto/services/winkeyer_broker_status.proto create mode 100644 crates/cathub-protocol/proto/services/winkeyer_speed_mode.proto create mode 100644 crates/cathub/build.rs create mode 100644 crates/cathub/src/winkeyer/actor.rs create mode 100644 crates/cathub/src/winkeyer/broker.rs create mode 100644 crates/cathub/src/winkeyer/face.rs create mode 100644 crates/cathub/src/winkeyer/grpc.rs create mode 100644 crates/cathub/src/winkeyer/mod.rs create mode 100644 crates/cathub/src/winkeyer/protocol.rs create mode 100644 docs/design/winkeyer-broker.md diff --git a/config/cathub.toml b/config/cathub.toml index 810d4ff..95aae53 100644 --- a/config/cathub.toml +++ b/config/cathub.toml @@ -27,6 +27,34 @@ max_tx_ms = 300000 # 5-minute hard transmit ceiling (backstop for a crashe [events] native_push = true # daemon enables and owns the TS-590 AI2; stream +# --- WinKeyer broker ------------------------------------------------------------------ +# +# CatHub owns the physical keyer. QsoRipper uses the typed loopback API; N1MM uses the +# application side of a separate com0com pair. Change COM40/COM41 if that pair is not +# installed on this station. + +[winkeyer] +port = "COM3" +baud = 1200 +max_tx_ms = 30000 +api_bind = "127.0.0.1:50071" + +[[winkeyer_face]] +name = "n1mm-cw" +transport = "COM40" # N1MM WinKeyer port is the paired COM41 +application_transport = "COM41" +baud = 1200 +primary = true +perms = ["status", "send", "control", "ptt"] + +[[winkeyer_face]] +name = "wktools-maintenance" +transport = "COM42" # WKTools maintenance port is the paired COM43 +application_transport = "COM43" +baud = 1200 +primary = false +perms = ["status", "control", "config_write"] + # --- Hamlib NET (rigctld-compatible) TCP endpoints ------------------------------------- # Read-only endpoint for the QsoRipper engine's RigctldProvider. @@ -46,6 +74,15 @@ bind = "127.0.0.1:4533" single_vfo = true perms = ["read", "write", "ptt"] +# Write + PTT endpoint dedicated to JS8Call. Do not share the WSJT-X endpoint: both clients +# control mode and PTT, and JS8Call's plain-USB setting can clear the TS-590 DATA flag while +# WSJT-X is operating. Configure JS8Call as Hamlib NET rigctl at 127.0.0.1:4535 with Fake It. +[[hamlib_net]] +name = "js8call" +bind = "127.0.0.1:4535" +single_vfo = true +perms = ["read", "write", "ptt"] + # Read/write endpoint for Log4OM (CAT over Hamlib NET rigctl). # single_vfo: Log4OM polls `\get_vfo_info VFOA`. Presenting the operating VFO as VFO A makes # Log4OM log the live frequency/mode on either VFO instead of the stale inactive VFO A. @@ -66,16 +103,18 @@ perms = ["read", "write"] # COM20 <-> COM21 COM20 COM21 (N1MM) # COM30 <-> COM31 COM30 COM31 (ARCP-590) -# HDSDR via OmniRig as a TS-2000-style controller. Read + write: the panadapter follows the -# radio, and click-to-tune on the waterfall sets the radio frequency/mode (FA/FB/MD writes). +# HDSDR via OmniRig as a TS-2000-style controller. The panadapter follows the radio, and +# click-to-tune on the waterfall may set frequency. Mode writes are denied so OmniRig cannot +# overwrite WSJT-X's USB+Data mode after following a digital-band frequency change. # The ts2000 dialect still rejects VFO-target writes (FR/FT) by design, so HDSDR can tune but # can never oscillate the TS-590's A/B VFO selection. [[face]] name = "hdsdr-omnirig" transport = "COM10" # OmniRig binds COM11 +application_transport = "COM11" baud = 115200 dialect = "ts2000" -perms = ["read", "write"] +perms = ["read", "frequency_write"] # N1MM Logger+ as a native TS-590. # N1MM in SO1V mode rejects VFO B ("You should not use VFO B when configured for SO1V"), @@ -84,6 +123,7 @@ perms = ["read", "write"] [[face]] name = "n1mm" transport = "COM20" # N1MM binds COM21 +application_transport = "COM21" baud = 115200 dialect = "ts590" single_vfo = true @@ -95,6 +135,7 @@ perms = ["read", "write", "ptt"] [[face]] name = "arcp590" transport = "COM30" # ARCP-590 binds COM31 +application_transport = "COM31" baud = 115200 dialect = "ts590" perms = ["read", "write", "ptt", "config_write"] diff --git a/crates/cathub-protocol/proto/services/abort_winkeyer_client_request.proto b/crates/cathub-protocol/proto/services/abort_winkeyer_client_request.proto new file mode 100644 index 0000000..e30ea84 --- /dev/null +++ b/crates/cathub-protocol/proto/services/abort_winkeyer_client_request.proto @@ -0,0 +1,10 @@ +syntax = "proto3"; + +package qsoripper.services; + +option csharp_namespace = "QsoRipper.Services"; + +message WinkeyerBrokerServiceAbortClientRequest { + string client_name = 1; + bool emergency_station_stop = 2; +} diff --git a/crates/cathub-protocol/proto/services/abort_winkeyer_client_response.proto b/crates/cathub-protocol/proto/services/abort_winkeyer_client_response.proto new file mode 100644 index 0000000..f3b0104 --- /dev/null +++ b/crates/cathub-protocol/proto/services/abort_winkeyer_client_response.proto @@ -0,0 +1,9 @@ +syntax = "proto3"; + +package qsoripper.services; + +option csharp_namespace = "QsoRipper.Services"; + +message WinkeyerBrokerServiceAbortClientResponse { + bool accepted = 1; +} diff --git a/crates/cathub-protocol/proto/services/cancel_winkeyer_job_request.proto b/crates/cathub-protocol/proto/services/cancel_winkeyer_job_request.proto new file mode 100644 index 0000000..0399937 --- /dev/null +++ b/crates/cathub-protocol/proto/services/cancel_winkeyer_job_request.proto @@ -0,0 +1,10 @@ +syntax = "proto3"; + +package qsoripper.services; + +option csharp_namespace = "QsoRipper.Services"; + +message WinkeyerBrokerServiceCancelJobRequest { + string client_name = 1; + uint64 job_id = 2; +} diff --git a/crates/cathub-protocol/proto/services/cancel_winkeyer_job_response.proto b/crates/cathub-protocol/proto/services/cancel_winkeyer_job_response.proto new file mode 100644 index 0000000..06cae7b --- /dev/null +++ b/crates/cathub-protocol/proto/services/cancel_winkeyer_job_response.proto @@ -0,0 +1,9 @@ +syntax = "proto3"; + +package qsoripper.services; + +option csharp_namespace = "QsoRipper.Services"; + +message WinkeyerBrokerServiceCancelJobResponse { + bool canceled = 1; +} diff --git a/crates/cathub-protocol/proto/services/get_winkeyer_broker_status_request.proto b/crates/cathub-protocol/proto/services/get_winkeyer_broker_status_request.proto new file mode 100644 index 0000000..4056043 --- /dev/null +++ b/crates/cathub-protocol/proto/services/get_winkeyer_broker_status_request.proto @@ -0,0 +1,9 @@ +syntax = "proto3"; + +package qsoripper.services; + +option csharp_namespace = "QsoRipper.Services"; + +message WinkeyerBrokerServiceGetStatusRequest { + string client_name = 1; +} diff --git a/crates/cathub-protocol/proto/services/get_winkeyer_broker_status_response.proto b/crates/cathub-protocol/proto/services/get_winkeyer_broker_status_response.proto new file mode 100644 index 0000000..39af150 --- /dev/null +++ b/crates/cathub-protocol/proto/services/get_winkeyer_broker_status_response.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package qsoripper.services; + +option csharp_namespace = "QsoRipper.Services"; + +import "services/winkeyer_broker_status.proto"; + +message WinkeyerBrokerServiceGetStatusResponse { + WinkeyerBrokerStatus status = 1; +} diff --git a/crates/cathub-protocol/proto/services/send_winkeyer_text_request.proto b/crates/cathub-protocol/proto/services/send_winkeyer_text_request.proto new file mode 100644 index 0000000..22374c1 --- /dev/null +++ b/crates/cathub-protocol/proto/services/send_winkeyer_text_request.proto @@ -0,0 +1,14 @@ +syntax = "proto3"; + +package qsoripper.services; + +option csharp_namespace = "QsoRipper.Services"; + +import "services/winkeyer_speed_mode.proto"; + +message WinkeyerBrokerServiceSendTextRequest { + string client_name = 1; + string text = 2; + WinkeyerSpeedMode speed_mode = 3; + optional uint32 speed_wpm = 4; +} diff --git a/crates/cathub-protocol/proto/services/send_winkeyer_text_response.proto b/crates/cathub-protocol/proto/services/send_winkeyer_text_response.proto new file mode 100644 index 0000000..7485156 --- /dev/null +++ b/crates/cathub-protocol/proto/services/send_winkeyer_text_response.proto @@ -0,0 +1,9 @@ +syntax = "proto3"; + +package qsoripper.services; + +option csharp_namespace = "QsoRipper.Services"; + +message WinkeyerBrokerServiceSendTextResponse { + uint64 job_id = 1; +} diff --git a/crates/cathub-protocol/proto/services/set_winkeyer_broker_speed_request.proto b/crates/cathub-protocol/proto/services/set_winkeyer_broker_speed_request.proto new file mode 100644 index 0000000..0d5481a --- /dev/null +++ b/crates/cathub-protocol/proto/services/set_winkeyer_broker_speed_request.proto @@ -0,0 +1,13 @@ +syntax = "proto3"; + +package qsoripper.services; + +option csharp_namespace = "QsoRipper.Services"; + +import "services/winkeyer_speed_mode.proto"; + +message WinkeyerBrokerServiceSetSpeedRequest { + string client_name = 1; + WinkeyerSpeedMode speed_mode = 2; + optional uint32 speed_wpm = 3; +} diff --git a/crates/cathub-protocol/proto/services/set_winkeyer_broker_speed_response.proto b/crates/cathub-protocol/proto/services/set_winkeyer_broker_speed_response.proto new file mode 100644 index 0000000..6fb088a --- /dev/null +++ b/crates/cathub-protocol/proto/services/set_winkeyer_broker_speed_response.proto @@ -0,0 +1,12 @@ +syntax = "proto3"; + +package qsoripper.services; + +option csharp_namespace = "QsoRipper.Services"; + +import "services/winkeyer_speed_mode.proto"; + +message WinkeyerBrokerServiceSetSpeedResponse { + WinkeyerSpeedMode speed_mode = 1; + optional uint32 speed_wpm = 2; +} diff --git a/crates/cathub-protocol/proto/services/stream_winkeyer_events_request.proto b/crates/cathub-protocol/proto/services/stream_winkeyer_events_request.proto new file mode 100644 index 0000000..bc73d7f --- /dev/null +++ b/crates/cathub-protocol/proto/services/stream_winkeyer_events_request.proto @@ -0,0 +1,9 @@ +syntax = "proto3"; + +package qsoripper.services; + +option csharp_namespace = "QsoRipper.Services"; + +message WinkeyerBrokerServiceStreamEventsRequest { + string client_name = 1; +} diff --git a/crates/cathub-protocol/proto/services/stream_winkeyer_events_response.proto b/crates/cathub-protocol/proto/services/stream_winkeyer_events_response.proto new file mode 100644 index 0000000..d073465 --- /dev/null +++ b/crates/cathub-protocol/proto/services/stream_winkeyer_events_response.proto @@ -0,0 +1,18 @@ +syntax = "proto3"; + +package qsoripper.services; + +option csharp_namespace = "QsoRipper.Services"; + +import "services/winkeyer_broker_event_kind.proto"; +import "services/winkeyer_broker_status.proto"; + +message WinkeyerBrokerServiceStreamEventsResponse { + WinkeyerBrokerEventKind kind = 1; + WinkeyerBrokerStatus status = 2; + optional uint32 raw_byte = 3; + optional uint32 speed_wpm = 4; + optional uint64 job_id = 5; + optional uint64 client_id = 6; + optional string message = 7; +} diff --git a/crates/cathub-protocol/proto/services/winkeyer_broker_event_kind.proto b/crates/cathub-protocol/proto/services/winkeyer_broker_event_kind.proto new file mode 100644 index 0000000..0eb9608 --- /dev/null +++ b/crates/cathub-protocol/proto/services/winkeyer_broker_event_kind.proto @@ -0,0 +1,17 @@ +syntax = "proto3"; + +package qsoripper.services; + +option csharp_namespace = "QsoRipper.Services"; + +enum WinkeyerBrokerEventKind { + WINKEYER_BROKER_EVENT_KIND_UNSPECIFIED = 0; + WINKEYER_BROKER_EVENT_KIND_CONNECTED = 1; + WINKEYER_BROKER_EVENT_KIND_SPEED_POT = 2; + WINKEYER_BROKER_EVENT_KIND_STATUS = 3; + WINKEYER_BROKER_EVENT_KIND_ECHO = 4; + WINKEYER_BROKER_EVENT_KIND_JOB_COMPLETED = 5; + WINKEYER_BROKER_EVENT_KIND_JOB_CANCELED = 6; + WINKEYER_BROKER_EVENT_KIND_ERROR = 7; + WINKEYER_BROKER_EVENT_KIND_SAFETY = 8; +} diff --git a/crates/cathub-protocol/proto/services/winkeyer_broker_service.proto b/crates/cathub-protocol/proto/services/winkeyer_broker_service.proto new file mode 100644 index 0000000..d8c2c33 --- /dev/null +++ b/crates/cathub-protocol/proto/services/winkeyer_broker_service.proto @@ -0,0 +1,27 @@ +syntax = "proto3"; + +package qsoripper.services; + +option csharp_namespace = "QsoRipper.Services"; + +import "services/abort_winkeyer_client_request.proto"; +import "services/abort_winkeyer_client_response.proto"; +import "services/cancel_winkeyer_job_request.proto"; +import "services/cancel_winkeyer_job_response.proto"; +import "services/get_winkeyer_broker_status_request.proto"; +import "services/get_winkeyer_broker_status_response.proto"; +import "services/send_winkeyer_text_request.proto"; +import "services/send_winkeyer_text_response.proto"; +import "services/set_winkeyer_broker_speed_request.proto"; +import "services/set_winkeyer_broker_speed_response.proto"; +import "services/stream_winkeyer_events_request.proto"; +import "services/stream_winkeyer_events_response.proto"; + +service WinkeyerBrokerService { + rpc GetStatus(WinkeyerBrokerServiceGetStatusRequest) returns (WinkeyerBrokerServiceGetStatusResponse); + rpc SendText(WinkeyerBrokerServiceSendTextRequest) returns (WinkeyerBrokerServiceSendTextResponse); + rpc CancelJob(WinkeyerBrokerServiceCancelJobRequest) returns (WinkeyerBrokerServiceCancelJobResponse); + rpc AbortClient(WinkeyerBrokerServiceAbortClientRequest) returns (WinkeyerBrokerServiceAbortClientResponse); + rpc SetSpeed(WinkeyerBrokerServiceSetSpeedRequest) returns (WinkeyerBrokerServiceSetSpeedResponse); + rpc StreamEvents(WinkeyerBrokerServiceStreamEventsRequest) returns (stream WinkeyerBrokerServiceStreamEventsResponse); +} diff --git a/crates/cathub-protocol/proto/services/winkeyer_broker_status.proto b/crates/cathub-protocol/proto/services/winkeyer_broker_status.proto new file mode 100644 index 0000000..7e47eb8 --- /dev/null +++ b/crates/cathub-protocol/proto/services/winkeyer_broker_status.proto @@ -0,0 +1,23 @@ +syntax = "proto3"; + +package qsoripper.services; + +option csharp_namespace = "QsoRipper.Services"; + +message WinkeyerBrokerStatus { + bool connected = 1; + optional uint32 firmware_revision = 2; + bool busy = 3; + bool break_in = 4; + bool key_down = 5; + optional uint32 pot_wpm = 6; + optional uint64 active_client_id = 7; + optional uint64 active_job_id = 8; + uint32 queued_jobs = 9; + optional string last_error = 10; + optional string last_safety_action = 11; + uint32 max_job_bytes = 12; + bool supports_speed_pot = 13; + bool supports_scoped_cancel = 14; + uint32 max_queued_jobs = 15; +} diff --git a/crates/cathub-protocol/proto/services/winkeyer_speed_mode.proto b/crates/cathub-protocol/proto/services/winkeyer_speed_mode.proto new file mode 100644 index 0000000..9c7be10 --- /dev/null +++ b/crates/cathub-protocol/proto/services/winkeyer_speed_mode.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package qsoripper.services; + +option csharp_namespace = "QsoRipper.Services"; + +enum WinkeyerSpeedMode { + WINKEYER_SPEED_MODE_UNSPECIFIED = 0; + WINKEYER_SPEED_MODE_POT = 1; + WINKEYER_SPEED_MODE_FIXED = 2; +} diff --git a/crates/cathub/Cargo.toml b/crates/cathub/Cargo.toml index e2a1d92..10b6ce0 100644 --- a/crates/cathub/Cargo.toml +++ b/crates/cathub/Cargo.toml @@ -18,6 +18,9 @@ path = "src/main.rs" [dependencies] async-trait = { workspace = true } bytes = { workspace = true } +prost = { workspace = true } +tonic = { workspace = true } +tokio-stream = { workspace = true } clap = { workspace = true } serde = { workspace = true } thiserror = { workspace = true } @@ -31,5 +34,8 @@ tracing-subscriber = { workspace = true } [dev-dependencies] tokio = { workspace = true, features = ["test-util"] } +[build-dependencies] +tonic-build = "0.12" + [lints] workspace = true diff --git a/crates/cathub/build.rs b/crates/cathub/build.rs new file mode 100644 index 0000000..624c187 --- /dev/null +++ b/crates/cathub/build.rs @@ -0,0 +1,35 @@ +//! Generate the shared protobuf contract used by the `CatHub` `WinKeyer` broker. + +use std::path::PathBuf; + +fn main() -> Result<(), Box> { + let proto_root = PathBuf::from("../../../proto"); + println!("cargo::rerun-if-changed={}", proto_root.display()); + let service_root = proto_root.join("services"); + let protos: Vec<_> = [ + "abort_winkeyer_client_request.proto", + "abort_winkeyer_client_response.proto", + "cancel_winkeyer_job_request.proto", + "cancel_winkeyer_job_response.proto", + "get_winkeyer_broker_status_request.proto", + "get_winkeyer_broker_status_response.proto", + "send_winkeyer_text_request.proto", + "send_winkeyer_text_response.proto", + "set_winkeyer_broker_speed_request.proto", + "set_winkeyer_broker_speed_response.proto", + "stream_winkeyer_events_request.proto", + "stream_winkeyer_events_response.proto", + "winkeyer_broker_event_kind.proto", + "winkeyer_broker_service.proto", + "winkeyer_broker_status.proto", + "winkeyer_speed_mode.proto", + ] + .into_iter() + .map(|file| service_root.join(file)) + .collect(); + tonic_build::configure() + .build_server(true) + .build_client(true) + .compile_protos(&protos, &[proto_root])?; + Ok(()) +} diff --git a/crates/cathub/src/config.rs b/crates/cathub/src/config.rs index d2d7498..bb8f729 100644 --- a/crates/cathub/src/config.rs +++ b/crates/cathub/src/config.rs @@ -40,6 +40,15 @@ fn default_max_tx_ms() -> u64 { fn default_native_push() -> bool { true } +fn default_winkeyer_baud() -> u32 { + 1_200 +} +fn default_winkeyer_max_tx_ms() -> u64 { + 30_000 +} +fn default_winkeyer_api_bind() -> String { + "127.0.0.1:50071".to_string() +} /// The `[radio]` section. #[derive(Debug, Clone, Deserialize)] @@ -131,12 +140,15 @@ pub(crate) struct FaceConfig { pub(crate) name: String, /// The serial port this face listens on (a com0com / tty path). pub(crate) transport: String, + /// The paired endpoint opened by the client application. The hub never opens it. + #[serde(default)] + pub(crate) application_transport: Option, /// Baud rate for the face port. #[serde(default = "default_baud")] pub(crate) baud: u32, /// Dialect: `ts590` or `ts2000`. pub(crate) dialect: String, - /// Permission tokens (`read`, `write`, `ptt`, `config_write`). + /// Permission tokens (`read`, `frequency_write`, `write`, `ptt`, `config_write`). #[serde(default)] pub(crate) perms: Vec, /// Present the *operating* VFO as VFO A to this face (operating-VFO virtualization). @@ -165,7 +177,7 @@ pub(crate) struct HamlibNetConfig { pub(crate) name: String, /// The bind address (e.g. `127.0.0.1:4532`). pub(crate) bind: String, - /// Permission tokens (`read`, `write`, `ptt`, `config_write`). + /// Permission tokens (`read`, `frequency_write`, `write`, `ptt`, `config_write`). #[serde(default)] pub(crate) perms: Vec, /// Present the *operating* VFO as VFO A to this endpoint (operating-VFO virtualization). @@ -190,6 +202,43 @@ impl HamlibNetConfig { } } +/// The optional `[winkeyer]` physical-keyer broker section. +#[derive(Debug, Clone, Deserialize)] +pub(crate) struct WinkeyerConfig { + /// Physical WinKeyer serial port exclusively owned by CatHub. + pub(crate) port: String, + /// Physical baud rate. WinKeyer always starts at 1200 baud. + #[serde(default = "default_winkeyer_baud")] + pub(crate) baud: u32, + /// Broker-wide transmit safety ceiling. + #[serde(default = "default_winkeyer_max_tx_ms")] + pub(crate) max_tx_ms: u64, + /// Loopback gRPC endpoint used by QsoRipper engines. + #[serde(default = "default_winkeyer_api_bind")] + pub(crate) api_bind: String, +} + +/// One virtual WinKeyer serial face backed by a com0com/PTY pair. +#[derive(Debug, Clone, Deserialize)] +pub(crate) struct WinkeyerFaceConfig { + /// Stable face name used in logs and ownership status. + pub(crate) name: String, + /// Hub side of the virtual serial pair. + pub(crate) transport: String, + /// Paired endpoint opened by the client application. The hub never opens it. + #[serde(default)] + pub(crate) application_transport: Option, + /// Virtual face baud rate. + #[serde(default = "default_winkeyer_baud")] + pub(crate) baud: u32, + /// Whether this client controls the idle paddle/foreground settings. + #[serde(default)] + pub(crate) primary: bool, + /// Permission tokens: `status`, `send`, `control`, `ptt`, `config_write`. + #[serde(default)] + pub(crate) perms: Vec, +} + /// The full daemon configuration. #[derive(Debug, Clone, Deserialize)] pub(crate) struct Config { @@ -210,6 +259,12 @@ pub(crate) struct Config { /// Hamlib net endpoints. #[serde(default)] pub(crate) hamlib_net: Vec, + /// Optional physical WinKeyer broker. + #[serde(default)] + pub(crate) winkeyer: Option, + /// Virtual WinKeyer client faces. + #[serde(default)] + pub(crate) winkeyer_face: Vec, } /// The table key the daemon's settings live under inside the shared unified `config.toml`. @@ -282,6 +337,20 @@ impl Config { } } for face in &self.face { + if face + .application_transport + .as_deref() + .is_some_and(|application| { + application + .trim() + .eq_ignore_ascii_case(face.transport.trim()) + }) + { + return Err(ConfigError::Invalid(format!( + "face '{}' application_transport must differ from transport", + face.name + ))); + } if !matches!( face.dialect.as_str(), "ts590" | "ts590-transparent" | "ts2000" @@ -305,6 +374,68 @@ impl Config { "at least one [[face]] or [[hamlib_net]] endpoint is required".to_string(), )); } + self.validate_winkeyer()?; + Ok(()) + } + + fn validate_winkeyer(&self) -> Result<(), ConfigError> { + let Some(winkeyer) = &self.winkeyer else { + if !self.winkeyer_face.is_empty() { + return Err(ConfigError::Invalid( + "[[winkeyer_face]] requires a [winkeyer] physical device section".to_string(), + )); + } + return Ok(()); + }; + if winkeyer.port.trim().is_empty() { + return Err(ConfigError::Invalid( + "winkeyer.port must not be empty".to_string(), + )); + } + if winkeyer.baud != 1_200 { + return Err(ConfigError::Invalid( + "winkeyer.baud must be 1200; high-baud session switching is not broker-safe" + .to_string(), + )); + } + if !(1_000..=300_000).contains(&winkeyer.max_tx_ms) { + return Err(ConfigError::Invalid( + "winkeyer.max_tx_ms must be between 1000 and 300000".to_string(), + )); + } + let bind: std::net::SocketAddr = winkeyer.api_bind.parse().map_err(|_| { + ConfigError::Invalid("winkeyer.api_bind must be a host:port socket address".to_string()) + })?; + if !bind.ip().is_loopback() { + return Err(ConfigError::Invalid( + "winkeyer.api_bind must use a loopback address".to_string(), + )); + } + if winkeyer.port.eq_ignore_ascii_case(&self.radio.port) { + return Err(ConfigError::Invalid( + "winkeyer.port must be distinct from radio.port".to_string(), + )); + } + let primary_count = self + .winkeyer_face + .iter() + .filter(|face| face.primary) + .count(); + if primary_count > 1 { + return Err(ConfigError::Invalid( + "at most one [[winkeyer_face]] may set primary = true".to_string(), + )); + } + let mut transports = std::collections::BTreeSet::new(); + for face in &self.winkeyer_face { + validate_winkeyer_face(face)?; + let normalized = face.transport.to_ascii_uppercase(); + if !transports.insert(normalized) { + return Err(ConfigError::Invalid( + "winkeyer face transports must be distinct".to_string(), + )); + } + } Ok(()) } @@ -362,6 +493,25 @@ impl Config { ep.name, ep.bind, ep.perms, ep.single_vfo ); } + if let Some(winkeyer) = &self.winkeyer { + let _ = writeln!( + out, + "winkeyer: port={} baud={} max_tx_ms={} api_bind={}", + winkeyer.port, winkeyer.baud, winkeyer.max_tx_ms, winkeyer.api_bind + ); + for face in &self.winkeyer_face { + let _ = writeln!( + out, + "winkeyer_face: name={} hub_transport={} application_transport={} baud={} primary={} perms={:?}", + face.name, + face.transport, + face.application_transport.as_deref().unwrap_or("(not recorded)"), + face.baud, + face.primary, + face.perms + ); + } + } if !self.face.is_empty() || !self.hamlib_net.is_empty() { out.push('\n'); let _ = writeln!( @@ -371,16 +521,26 @@ impl Config { self.radio.port ); for face in &self.face { - let _ = writeln!( - out, - " - {name}: the hub OWNS serial port {port}. Point {name} at the OTHER port \ - of that com0com pair (e.g. com0com COMa<->COMb: hub={port}, app=the paired \ - port), {dialect} dialect, {baud} baud.", - name = face.name, - port = face.transport, - dialect = face.dialect, - baud = face.baud, - ); + if let Some(application_transport) = face.application_transport.as_deref() { + let _ = writeln!( + out, + " - {name}: hub={hub}, application={application}, {dialect} dialect, {baud} baud.", + name = face.name, + hub = face.transport, + application = application_transport, + dialect = face.dialect, + baud = face.baud, + ); + } else { + let _ = writeln!( + out, + " - {name}: the hub owns {hub}; application port is not recorded, {dialect} dialect, {baud} baud.", + name = face.name, + hub = face.transport, + dialect = face.dialect, + baud = face.baud, + ); + } } for ep in &self.hamlib_net { let _ = writeln!( @@ -430,6 +590,66 @@ impl Config { } } +fn validate_winkeyer_face(face: &WinkeyerFaceConfig) -> Result<(), ConfigError> { + if face.transport.trim().is_empty() { + return Err(ConfigError::Invalid(format!( + "winkeyer face '{}' requires transport", + face.name + ))); + } + if face.baud != 1_200 { + return Err(ConfigError::Invalid(format!( + "winkeyer face '{}' baud must be 1200", + face.name + ))); + } + if face + .application_transport + .as_deref() + .is_some_and(|application| { + application + .trim() + .eq_ignore_ascii_case(face.transport.trim()) + }) + { + return Err(ConfigError::Invalid(format!( + "winkeyer face '{}' application_transport must differ from transport", + face.name + ))); + } + for permission in &face.perms { + if !matches!( + permission.as_str(), + "status" | "send" | "control" | "ptt" | "config_write" + ) { + return Err(ConfigError::Invalid(format!( + "winkeyer face '{}' has unknown permission '{}'", + face.name, permission + ))); + } + } + let has = |permission: &str| face.perms.iter().any(|value| value == permission); + if has("send") && !has("status") { + return Err(ConfigError::Invalid(format!( + "winkeyer face '{}' permission 'send' requires 'status'", + face.name + ))); + } + if has("ptt") && (!has("send") || !has("control")) { + return Err(ConfigError::Invalid(format!( + "winkeyer face '{}' permission 'ptt' requires 'send' and 'control'", + face.name + ))); + } + if has("config_write") && (!has("status") || !has("control")) { + return Err(ConfigError::Invalid(format!( + "winkeyer face '{}' permission 'config_write' requires 'status' and 'control'", + face.name + ))); + } + Ok(()) +} + #[cfg(test)] #[allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)] mod tests { @@ -455,7 +675,8 @@ native_push = true [[face]] name = "n1mm" -transport = "COM11" +transport = "COM20" +application_transport = "COM21" baud = 4800 dialect = "ts590" perms = ["read", "write", "ptt"] @@ -568,9 +789,7 @@ backend = "loopback" assert!(text.contains("face: name=n1mm")); assert!(text.contains("hamlib_net: name=engine")); assert!(text.contains("Client connection guide")); - assert!( - text.contains("point the application at the paired") || text.contains("OTHER port") - ); + assert!(text.contains("hub=COM20, application=COM21")); } #[test] @@ -612,7 +831,8 @@ baud = 4800 [[cat_hub.face]] name = "n1mm" -transport = "COM11" +transport = "COM20" +application_transport = "COM21" dialect = "ts590" perms = ["read", "write", "ptt"] @@ -625,6 +845,10 @@ perms = ["read"] assert_eq!(config.radio.backend, "ts590"); assert_eq!(config.radio.port, "COM4"); assert_eq!(config.face.len(), 1); + assert_eq!( + config.face[0].application_transport.as_deref(), + Some("COM21") + ); assert_eq!(config.hamlib_net.len(), 1); assert!(config.face[0].permissions().ptt); } @@ -647,4 +871,145 @@ backend = "loopback" "#; assert!(Config::parse_document(text).is_err()); } + + #[test] + fn parses_and_describes_winkeyer_broker() { + let text = r#" +[radio] +backend = "loopback" + +[[hamlib_net]] +name = "engine" +bind = "127.0.0.1:4532" + +[winkeyer] +port = "COM3" +max_tx_ms = 30000 +api_bind = "127.0.0.1:50071" + +[[winkeyer_face]] +name = "n1mm" +transport = "COM40" +application_transport = "COM41" +primary = true +perms = ["status", "send", "control", "ptt"] +"#; + let config = Config::parse(text).expect("parse keyer"); + let winkeyer = config.winkeyer.as_ref().expect("winkeyer"); + assert_eq!(winkeyer.port, "COM3"); + assert_eq!(winkeyer.baud, 1_200); + assert_eq!(config.winkeyer_face.len(), 1); + assert!(config.winkeyer_face[0].primary); + assert_eq!( + config.winkeyer_face[0].application_transport.as_deref(), + Some("COM41") + ); + let description = config.describe(); + assert!(description.contains("winkeyer: port=COM3")); + assert!(description.contains("winkeyer_face: name=n1mm")); + assert!(description.contains("application_transport=COM41")); + } + + #[test] + fn rejects_matching_hub_and_application_transports() { + let serial = SAMPLE.replace( + "application_transport = \"COM21\"", + "application_transport = \"com20\"", + ); + assert!(Config::parse(&serial) + .expect_err("matching serial pair") + .to_string() + .contains("application_transport must differ")); + + let winkeyer = r#" +[radio] +backend = "loopback" +[[hamlib_net]] +name = "engine" +bind = "127.0.0.1:4532" +[winkeyer] +port = "COM3" +[[winkeyer_face]] +name = "wktools" +transport = "COM42" +application_transport = "com42" +"#; + assert!(Config::parse(winkeyer) + .expect_err("matching WinKeyer pair") + .to_string() + .contains("application_transport must differ")); + } + + #[test] + fn rejects_non_loopback_winkeyer_api_and_duplicate_primary_faces() { + let non_loopback = r#" +[radio] +backend = "loopback" +[[hamlib_net]] +name = "engine" +bind = "127.0.0.1:4532" +[winkeyer] +port = "COM3" +api_bind = "0.0.0.0:50071" +"#; + assert!(Config::parse(non_loopback) + .expect_err("non-loopback") + .to_string() + .contains("loopback")); + + let duplicate_primary = r#" +[radio] +backend = "loopback" +[[hamlib_net]] +name = "engine" +bind = "127.0.0.1:4532" +[winkeyer] +port = "COM3" +[[winkeyer_face]] +name = "one" +transport = "COM40" +primary = true +[[winkeyer_face]] +name = "two" +transport = "COM42" +primary = true +"#; + assert!(Config::parse(duplicate_primary) + .expect_err("primary") + .to_string() + .contains("at most one")); + } + + #[test] + fn rejects_keyer_port_collision_and_faces_without_device() { + let collision = r#" +[radio] +backend = "ts590" +port = "COM3" +[[hamlib_net]] +name = "engine" +bind = "127.0.0.1:4532" +[winkeyer] +port = "com3" +"#; + assert!(Config::parse(collision) + .expect_err("collision") + .to_string() + .contains("distinct")); + + let orphan = r#" +[radio] +backend = "loopback" +[[hamlib_net]] +name = "engine" +bind = "127.0.0.1:4532" +[[winkeyer_face]] +name = "n1mm" +transport = "COM40" +"#; + assert!(Config::parse(orphan) + .expect_err("orphan") + .to_string() + .contains("requires a [winkeyer]")); + } } diff --git a/crates/cathub/src/dialect/mod.rs b/crates/cathub/src/dialect/mod.rs index 68eae67..819da8d 100644 --- a/crates/cathub/src/dialect/mod.rs +++ b/crates/cathub/src/dialect/mod.rs @@ -103,7 +103,7 @@ impl FaceContext { mutation: StateMutation, class: CommandClass, ) -> ApplyOutcome { - if !self.perms.allows(class) { + if !self.perms.allows_mutation(class, &mutation) { return ApplyOutcome::Denied; } // Idempotent suppression: never re-send a value the radio already holds. This keeps @@ -393,6 +393,34 @@ mod tests { assert_eq!(backend.mutations().len(), 1); } + #[tokio::test] + async fn frequency_write_does_not_grant_mode_control() { + let (ctx, backend) = ctx_with( + FacePermissions::from_tokens(&["read", "frequency_write"]), + 1, + ); + + let frequency = StateMutation::SetVfoFreq { + vfo: Vfo::A, + hz: 14_074_000, + }; + assert_eq!( + ctx.apply_modeled(frequency, CommandClass::ModeledWrite) + .await, + ApplyOutcome::Ok + ); + + let mode = StateMutation::SetMode { + vfo: Vfo::A, + mode: Mode::Cw, + }; + assert_eq!( + ctx.apply_modeled(mode, CommandClass::ModeledWrite).await, + ApplyOutcome::Denied + ); + assert_eq!(backend.mutations(), vec![frequency]); + } + #[tokio::test] async fn ptt_is_busy_for_a_second_face() { let (ctx1, _b) = ctx_with(FacePermissions::from_tokens(&["ptt"]), 1); diff --git a/crates/cathub/src/lib.rs b/crates/cathub/src/lib.rs index fa3845d..3cbf062 100644 --- a/crates/cathub/src/lib.rs +++ b/crates/cathub/src/lib.rs @@ -11,6 +11,12 @@ #![allow(clippy::doc_markdown)] +/// Generated protobuf and gRPC bindings for the loopback WinKeyer broker API. +#[allow(missing_docs, unreachable_pub, clippy::all, clippy::pedantic)] +pub mod broker_proto { + tonic::include_proto!("qsoripper.services"); +} + mod backend; mod config; mod dialect; @@ -24,6 +30,7 @@ mod ptt; mod radio; mod serial_face; mod state; +mod winkeyer; #[cfg(test)] mod integration; @@ -56,6 +63,11 @@ use crate::radio::{ }; use crate::serial_face::{open_serial, run_face}; use crate::state::StateHandle; +use crate::winkeyer::{ + bind_server as bind_winkeyer_server, open_serial_face as open_winkeyer_face, + run_serial_face as run_winkeyer_face, spawn_supervised as spawn_winkeyer, + BrokerHandle as WinkeyerBrokerHandle, FacePermissions as WinkeyerFacePermissions, +}; pub use crate::error::CatHubError; @@ -189,6 +201,44 @@ pub async fn run(cli: Cli) -> Result<(), CatHubError> { let state = StateHandle::new(); let ptt = PttManager::new(cfg.ptt_max_tx()); + let winkeyer: Option = if let Some(keyer) = &cfg.winkeyer { + let port = open_winkeyer_serial(&keyer.port, keyer.baud)?; + let port_name = keyer.port.clone(); + let baud = keyer.baud; + let handle = spawn_winkeyer( + port, + Duration::from_millis(keyer.max_tx_ms), + ptt.clone(), + move || { + let port_name = port_name.clone(); + async move { open_winkeyer_serial(&port_name, baud) } + }, + ) + .await + .map_err(|error| CatHubError::Backend(error.to_string()))?; + tracing::info!( + port = %keyer.port, + firmware = ?handle.snapshot().firmware_revision, + "WinKeyer broker owns physical keyer" + ); + let bind = keyer.api_bind.parse().map_err(|error| { + CatHubError::Backend(format!("invalid WinKeyer API bind address: {error}")) + })?; + let server = bind_winkeyer_server(bind, handle.clone()) + .await + .map_err(|error| CatHubError::Backend(format!("cannot bind WinKeyer API: {error}")))?; + tokio::spawn(async move { + match server.await { + Ok(Err(error)) => tracing::error!(%error, "WinKeyer broker gRPC server stopped"), + Err(error) => tracing::error!(%error, "WinKeyer broker gRPC task failed"), + Ok(Ok(())) => {} + } + }); + Some(handle) + } else { + None + }; + // Wire the transport to the serialized radio link. The loopback backend needs no real // transport (it never submits raw bytes), so we just drop the receiver in that case. // @@ -283,6 +333,24 @@ pub async fn run(cli: Cli) -> Result<(), CatHubError> { let next_id = Arc::new(AtomicU64::new(1)); + if let Some(keyer) = &winkeyer { + for face in &cfg.winkeyer_face { + let id = next_id.fetch_add(1, Ordering::SeqCst); + let port = open_winkeyer_face(&face.transport, face.baud)?; + let handle = keyer.clone(); + let primary = face.primary; + let permissions = WinkeyerFacePermissions::from_tokens(&face.perms); + tokio::spawn(run_winkeyer_face(port, handle, id, primary, permissions)); + tracing::info!( + face = %face.name, + id, + hub_port = %face.transport, + primary, + "virtual WinKeyer face listening; point the application at the paired port" + ); + } + } + for face in &cfg.face { let dialect = dialect_for(&face.dialect)?; let id = next_id.fetch_add(1, Ordering::SeqCst); @@ -359,6 +427,12 @@ pub async fn run(cli: Cli) -> Result<(), CatHubError> { tokio::signal::ctrl_c().await?; tracing::info!("shutdown requested"); + if let Some(keyer) = &winkeyer { + if let Err(error) = keyer.shutdown().await { + tracing::warn!(%error, "WinKeyer broker shutdown failed"); + } + } + // Best-effort orderly stop: never leave the transmitter keyed (design §8.5). A hard // crash cannot run this; the ptt_max_tx_ms ceiling and the radio's own TX timeout are // the ultimate backstops. @@ -381,6 +455,19 @@ pub async fn run(cli: Cli) -> Result<(), CatHubError> { Ok(()) } +/// Open the physical WinKeyer using the protocol-mandated 8-N-2 framing. +fn open_winkeyer_serial(port_name: &str, baud: u32) -> std::io::Result { + serial2_tokio::SerialPort::open(port_name, move |mut settings: serial2_tokio::Settings| { + settings.set_raw(); + settings.set_baud_rate(baud)?; + settings.set_char_size(serial2_tokio::CharSize::Bits8); + settings.set_parity(serial2_tokio::Parity::None); + settings.set_stop_bits(serial2_tokio::StopBits::Two); + settings.set_flow_control(serial2_tokio::FlowControl::None); + Ok(settings) + }) +} + #[cfg(test)] #[allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)] mod tests { diff --git a/crates/cathub/src/permissions.rs b/crates/cathub/src/permissions.rs index 368a564..1aaaaf9 100644 --- a/crates/cathub/src/permissions.rs +++ b/crates/cathub/src/permissions.rs @@ -1,8 +1,11 @@ //! Per-face capability sets and command classification. //! -//! The coarse face flags (`read`, `write`, `ptt`, `config_write`) gate the command -//! classes a dialect assigns to each inbound command. Unknown passthrough writes default -//! to denied unless the face opts into unsafe full control. +//! The face flags gate the command classes a dialect assigns to each inbound command. +//! `frequency_write` grants narrow tuning authority without mode or VFO control, while +//! `write` retains full modeled-write authority. Unknown passthrough writes default to +//! denied unless the face opts into unsafe full control. + +use crate::model::StateMutation; /// How a dialect classifies a single inbound command. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -34,6 +37,8 @@ pub(crate) struct FacePermissions { pub(crate) read: bool, /// May issue modeled writes (frequency, mode, split). pub(crate) write: bool, + /// May tune frequency, without authority to change mode, VFO, split, RIT, or XIT. + pub(crate) frequency_write: bool, /// May key PTT. pub(crate) ptt: bool, /// May issue persistent/config writes (`EX` menu). @@ -47,6 +52,7 @@ impl FacePermissions { FacePermissions { read: true, write: false, + frequency_write: false, ptt: false, config_write: false, } @@ -57,6 +63,7 @@ impl FacePermissions { let mut perms = FacePermissions { read: false, write: false, + frequency_write: false, ptt: false, config_write: false, }; @@ -64,6 +71,7 @@ impl FacePermissions { match token.as_ref() { "read" => perms.read = true, "write" => perms.write = true, + "frequency_write" => perms.frequency_write = true, "ptt" => perms.ptt = true, "config_write" => perms.config_write = true, _ => {} @@ -85,6 +93,14 @@ impl FacePermissions { CommandClass::Denied => false, } } + + /// Whether this face may apply a specific modeled mutation. + pub(crate) fn allows_mutation(self, class: CommandClass, mutation: &StateMutation) -> bool { + if class != CommandClass::ModeledWrite { + return self.allows(class); + } + self.write || (self.frequency_write && matches!(mutation, StateMutation::SetVfoFreq { .. })) + } } #[cfg(test)] @@ -96,9 +112,31 @@ mod tests { fn tokens_parse_into_flags() { let perms = FacePermissions::from_tokens(&["read", "write", "ptt"]); assert!(perms.read && perms.write && perms.ptt); + assert!(!perms.frequency_write); assert!(!perms.config_write); } + #[test] + fn frequency_write_is_narrowly_scoped() { + let perms = FacePermissions::from_tokens(&["read", "frequency_write"]); + assert!(perms.frequency_write); + assert!(!perms.write); + assert!(perms.allows_mutation( + CommandClass::ModeledWrite, + &StateMutation::SetVfoFreq { + vfo: crate::model::Vfo::A, + hz: 14_074_000, + } + )); + assert!(!perms.allows_mutation( + CommandClass::ModeledWrite, + &StateMutation::SetMode { + vfo: crate::model::Vfo::A, + mode: crate::model::Mode::Cw, + } + )); + } + #[test] fn read_only_denies_writes_and_ptt() { let perms = FacePermissions::read_only(); diff --git a/crates/cathub/src/radio/mod.rs b/crates/cathub/src/radio/mod.rs index 5e0fa86..0ba44d1 100644 --- a/crates/cathub/src/radio/mod.rs +++ b/crates/cathub/src/radio/mod.rs @@ -15,6 +15,7 @@ use std::time::Duration; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::sync::{mpsc, oneshot}; +use tokio::time::Instant; use crate::backend::{BackendError, Framing, RadioBackend}; use crate::events::enable_native_push; @@ -219,7 +220,7 @@ where } }); - let mut pending: Option<(Matcher, ReplyTx)> = None; + let mut pending: Option<(Matcher, ReplyTx, Instant)> = None; let reason: ExitReason; loop { @@ -239,7 +240,11 @@ where let _ = cmd.reply.send(Ok(Vec::new())); } Expect::Reply(verbs) => { - pending = Some((Matcher::Verb(verbs), cmd.reply)); + pending = Some(( + Matcher::Verb(verbs), + cmd.reply, + Instant::now() + REPLY_TIMEOUT, + )); } Expect::Lines(n) => { if n == 0 { @@ -248,6 +253,7 @@ where pending = Some(( Matcher::Lines { remaining: n, acc: Vec::new() }, cmd.reply, + Instant::now() + REPLY_TIMEOUT, )); } } @@ -260,10 +266,14 @@ where } } } else { - let recv = tokio::time::timeout(REPLY_TIMEOUT, frame_rx.recv()).await; + let Some((_, _, deadline)) = pending.as_ref() else { + continue; + }; + let deadline = *deadline; + let recv = tokio::time::timeout_at(deadline, frame_rx.recv()).await; match recv { Err(_elapsed) => { - if let Some((matcher, reply)) = pending.take() { + if let Some((matcher, reply, _)) = pending.take() { if let Matcher::Verb(verbs) = &matcher { let awaited: Vec = verbs .iter() @@ -283,16 +293,16 @@ where Ok(Some(frame)) => { tracing::trace!(rx = %String::from_utf8_lossy(&frame), "radio rx (pending)"); pending = match pending.take() { - Some((Matcher::Verb(verbs), reply)) => { + Some((Matcher::Verb(verbs), reply, deadline)) => { if frame_matches(&frame, &verbs) { let _ = reply.send(Ok(frame)); None } else { route_event(&backend, &state, &frame); - Some((Matcher::Verb(verbs), reply)) + Some((Matcher::Verb(verbs), reply, deadline)) } } - Some((Matcher::Lines { remaining, mut acc }, reply)) => { + Some((Matcher::Lines { remaining, mut acc }, reply, deadline)) => { acc.extend_from_slice(&frame); let left = remaining.saturating_sub(1); if left == 0 { @@ -305,6 +315,7 @@ where acc, }, reply, + deadline, )) } } @@ -315,7 +326,7 @@ where } } - if let Some((_, reply)) = pending.take() { + if let Some((_, reply, _)) = pending.take() { let _ = reply.send(Err(BackendError::Transport("transport closed".into()))); } reader_task.abort(); @@ -656,6 +667,41 @@ mod tests { assert!(Priority::Read < Priority::Poll); } + #[tokio::test] + async fn unsolicited_frames_do_not_extend_reply_deadline() { + let backend: Arc = Arc::new(Ts590Backend::new()); + let state = StateHandle::new(); + let (link, raw_rx) = link_channel(); + let (radio, server) = tokio::io::duplex(1024); + + let transport_task = tokio::spawn(run_transport(server, backend, state, raw_rx)); + let radio_task = tokio::spawn(async move { + let (mut rd, mut wr) = tokio::io::split(radio); + let mut command = [0u8; 3]; + rd.read_exact(&mut command) + .await + .expect("transport should send a command"); + + loop { + wr.write_all(b"NB0;") + .await + .expect("transport should accept unsolicited frames"); + tokio::time::sleep(Duration::from_millis(50)).await; + } + }); + + let result = tokio::time::timeout( + REPLY_TIMEOUT + Duration::from_millis(300), + link.submit(b"FA;".to_vec(), Expect::Reply(vec![b"FA".to_vec()])), + ) + .await + .expect("an absolute reply deadline must not be extended by unsolicited frames"); + + assert!(matches!(result, Err(BackendError::Timeout))); + radio_task.abort(); + transport_task.abort(); + } + #[tokio::test] async fn supervisor_reconnects_after_transport_drop() { use crate::backend::kenwood::ts590::Ts590Backend; diff --git a/crates/cathub/src/winkeyer/actor.rs b/crates/cathub/src/winkeyer/actor.rs new file mode 100644 index 0000000..c5f290c --- /dev/null +++ b/crates/cathub/src/winkeyer/actor.rs @@ -0,0 +1,1274 @@ +//! Physical WinKeyer actor and typed in-process broker handle. + +use std::time::{Duration, Instant}; +use std::{future::Future, io}; + +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; +use tokio::sync::{broadcast, mpsc, oneshot, watch}; + +use super::broker::{ + BrokerCore, BrokerSnapshot, ClientId, JobId, PhysicalAction, SpeedMode, TransmitPayload, + MAX_JOB_BYTES, MAX_QUEUED_JOBS, +}; +use super::protocol::DeviceEvent; +use crate::ptt::{PttDenied, PttManager}; + +const REQUEST_CAPACITY: usize = 128; +const EVENT_CAPACITY: usize = 256; +const HOST_OPEN_TIMEOUT: Duration = Duration::from_millis(750); +const ACTOR_TICK: Duration = Duration::from_millis(50); +const SHORT_JOB_SETTLE: Duration = Duration::from_millis(250); +const STATUS_POLL_INTERVAL: Duration = Duration::from_millis(100); +const WINKEYER_STATION_TX_OWNER: u64 = u64::MAX; +const RECONNECT_INITIAL: Duration = Duration::from_millis(250); +const RECONNECT_MAX: Duration = Duration::from_secs(5); + +/// Errors returned by the broker handle. +#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)] +pub(crate) enum BrokerError { + #[error("WinKeyer broker is unavailable")] + Unavailable, + #[error("WinKeyer client is not registered")] + UnknownClient, + #[error("a primary WinKeyer client is already registered")] + PrimaryExists, + #[error("WinKeyer is not connected")] + NotConnected, + #[error("invalid WinKeyer request: {0}")] + Invalid(String), + #[error("WinKeyer transport: {0}")] + Transport(String), + #[error("station transmit is owned by another client")] + TransmitBusy, + #[error("WinKeyer maintenance is owned by another client or transmit work is pending")] + MaintenanceBusy, + #[error("WinKeyer transmit queue is full")] + QueueFull, +} + +/// Events emitted by the broker for virtual faces and diagnostics. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum BrokerEvent { + Connected { firmware_revision: u8 }, + SpeedPot { raw: u8, value: u8, wpm: u8 }, + Status { raw: u8 }, + Echo(u8), + MaintenanceByte { client_id: ClientId, byte: u8 }, + Completed { job_id: JobId, client_id: ClientId }, + Canceled { job_id: JobId, client_id: ClientId }, + Error(String), +} + +enum Request { + Register { + client_id: ClientId, + primary: bool, + reply: oneshot::Sender>, + }, + Unregister { + client_id: ClientId, + }, + SetSpeed { + client_id: ClientId, + speed: SpeedMode, + reply: oneshot::Sender>, + }, + Enqueue { + client_id: ClientId, + payload: TransmitPayload, + speed: Option, + stream: bool, + reply: oneshot::Sender>, + }, + Cancel { + client_id: ClientId, + include_active: bool, + reply: oneshot::Sender>, + }, + CancelJob { + client_id: ClientId, + job_id: JobId, + reply: oneshot::Sender>, + }, + EmergencyStop { + reason: String, + reply: oneshot::Sender>, + }, + Configure { + client_id: ClientId, + command: Vec, + reply: oneshot::Sender>, + }, + ActiveOwnerCommand { + client_id: ClientId, + command: Vec, + reply: oneshot::Sender>, + }, + MaintenanceCommand { + client_id: ClientId, + command: Vec, + reply: oneshot::Sender>, + }, + ReleaseMaintenance { + client_id: ClientId, + }, + Shutdown { + reply: oneshot::Sender<()>, + }, +} + +/// Cloneable command/status surface shared by gRPC and virtual COM faces. +#[derive(Clone)] +pub(crate) struct BrokerHandle { + requests: mpsc::Sender, + snapshots: watch::Receiver, + events: broadcast::Sender, +} + +impl BrokerHandle { + pub(crate) async fn register( + &self, + client_id: ClientId, + primary: bool, + ) -> Result<(), BrokerError> { + let (reply, response) = oneshot::channel(); + self.send(Request::Register { + client_id, + primary, + reply, + }) + .await?; + response.await.map_err(|_| BrokerError::Unavailable)? + } + + pub(crate) async fn unregister(&self, client_id: ClientId) -> Result<(), BrokerError> { + self.send(Request::Unregister { client_id }).await + } + + pub(crate) async fn set_speed( + &self, + client_id: ClientId, + speed: SpeedMode, + ) -> Result<(), BrokerError> { + let (reply, response) = oneshot::channel(); + self.send(Request::SetSpeed { + client_id, + speed, + reply, + }) + .await?; + response.await.map_err(|_| BrokerError::Unavailable)? + } + + pub(crate) async fn enqueue( + &self, + client_id: ClientId, + bytes: Vec, + speed: Option, + ) -> Result { + let (reply, response) = oneshot::channel(); + self.send(Request::Enqueue { + client_id, + payload: TransmitPayload::plain_text(bytes), + speed, + stream: false, + reply, + }) + .await?; + response.await.map_err(|_| BrokerError::Unavailable)? + } + + pub(crate) async fn stream( + &self, + client_id: ClientId, + control_prefix: Vec, + intended_text: Vec, + ) -> Result { + let (reply, response) = oneshot::channel(); + self.send(Request::Enqueue { + client_id, + payload: TransmitPayload::legacy_stream(control_prefix, intended_text), + speed: None, + stream: true, + reply, + }) + .await?; + response.await.map_err(|_| BrokerError::Unavailable)? + } + + pub(crate) async fn configure( + &self, + client_id: ClientId, + command: Vec, + ) -> Result<(), BrokerError> { + let (reply, response) = oneshot::channel(); + self.send(Request::Configure { + client_id, + command, + reply, + }) + .await?; + response.await.map_err(|_| BrokerError::Unavailable)? + } + + pub(crate) async fn active_owner_command( + &self, + client_id: ClientId, + command: Vec, + ) -> Result<(), BrokerError> { + let (reply, response) = oneshot::channel(); + self.send(Request::ActiveOwnerCommand { + client_id, + command, + reply, + }) + .await?; + response.await.map_err(|_| BrokerError::Unavailable)? + } + + pub(crate) async fn maintenance_command( + &self, + client_id: ClientId, + command: Vec, + ) -> Result<(), BrokerError> { + let (reply, response) = oneshot::channel(); + self.send(Request::MaintenanceCommand { + client_id, + command, + reply, + }) + .await?; + response.await.map_err(|_| BrokerError::Unavailable)? + } + + pub(crate) async fn release_maintenance(&self, client_id: ClientId) -> Result<(), BrokerError> { + self.send(Request::ReleaseMaintenance { client_id }).await + } + + pub(crate) async fn cancel( + &self, + client_id: ClientId, + include_active: bool, + ) -> Result<(), BrokerError> { + let (reply, response) = oneshot::channel(); + self.send(Request::Cancel { + client_id, + include_active, + reply, + }) + .await?; + response.await.map_err(|_| BrokerError::Unavailable)? + } + + pub(crate) async fn cancel_job( + &self, + client_id: ClientId, + job_id: JobId, + ) -> Result { + let (reply, response) = oneshot::channel(); + self.send(Request::CancelJob { + client_id, + job_id, + reply, + }) + .await?; + response.await.map_err(|_| BrokerError::Unavailable)? + } + + pub(crate) async fn emergency_stop( + &self, + reason: impl Into, + ) -> Result<(), BrokerError> { + let (reply, response) = oneshot::channel(); + self.send(Request::EmergencyStop { + reason: reason.into(), + reply, + }) + .await?; + response.await.map_err(|_| BrokerError::Unavailable)? + } + + pub(crate) fn snapshot(&self) -> BrokerSnapshot { + self.snapshots.borrow().clone() + } + + pub(crate) fn subscribe(&self) -> broadcast::Receiver { + self.events.subscribe() + } + + pub(crate) async fn shutdown(&self) -> Result<(), BrokerError> { + let (reply, response) = oneshot::channel(); + self.send(Request::Shutdown { reply }).await?; + response.await.map_err(|_| BrokerError::Unavailable) + } + + async fn send(&self, request: Request) -> Result<(), BrokerError> { + self.requests + .send(request) + .await + .map_err(|_| BrokerError::Unavailable) + } +} + +/// Open a host session and spawn the physical actor over any async byte transport. +#[cfg(test)] +pub(crate) async fn spawn(transport: T, max_tx: Duration) -> Result +where + T: AsyncRead + AsyncWrite + Send + Unpin + 'static, +{ + spawn_inner(transport, max_tx, None, false, false, || async { + Err(io::Error::other("reconnect disabled")) + }) + .await +} + +/// Spawn a broker integrated with the station-wide CAT/PTT ownership lease. +#[cfg(test)] +pub(crate) async fn spawn_with_ptt( + transport: T, + max_tx: Duration, + ptt: PttManager, +) -> Result +where + T: AsyncRead + AsyncWrite + Send + Unpin + 'static, +{ + spawn_inner(transport, max_tx, Some(ptt), false, false, || async { + Err(io::Error::other("reconnect disabled")) + }) + .await +} + +/// Spawn a broker whose physical transport is reopened with bounded backoff after failure. +pub(crate) async fn spawn_supervised( + transport: T, + max_tx: Duration, + ptt: PttManager, + opener: O, +) -> Result +where + T: AsyncRead + AsyncWrite + Send + Unpin + 'static, + O: FnMut() -> F + Send + 'static, + F: Future> + Send + 'static, +{ + spawn_inner(transport, max_tx, Some(ptt), true, true, opener).await +} + +async fn spawn_inner( + mut transport: T, + max_tx: Duration, + ptt: Option, + reconnect: bool, + zero_is_idle: bool, + opener: O, +) -> Result +where + T: AsyncRead + AsyncWrite + Send + Unpin + 'static, + O: FnMut() -> F + Send + 'static, + F: Future> + Send + 'static, +{ + let firmware_revision = initialize_transport(&mut transport, zero_is_idle).await?; + + let mut core = BrokerCore::new(max_tx); + core.connect_physical(firmware_revision); + let initial = core.snapshot(); + let (request_tx, request_rx) = mpsc::channel(REQUEST_CAPACITY); + let (snapshot_tx, snapshot_rx) = watch::channel(initial); + let (event_tx, _) = broadcast::channel(EVENT_CAPACITY); + let handle = BrokerHandle { + requests: request_tx, + snapshots: snapshot_rx, + events: event_tx.clone(), + }; + let _ = event_tx.send(BrokerEvent::Connected { firmware_revision }); + tokio::spawn(run_actor( + transport, + core, + request_rx, + snapshot_tx, + event_tx, + ptt, + reconnect, + zero_is_idle, + opener, + )); + Ok(handle) +} + +async fn initialize_transport(transport: &mut T, zero_is_idle: bool) -> Result +where + T: AsyncRead + AsyncWrite + Unpin, +{ + transport + .write_all(&[0x00, 0x02]) + .await + .map_err(transport_error)?; + transport.flush().await.map_err(transport_error)?; + let firmware_revision = tokio::time::timeout( + HOST_OPEN_TIMEOUT, + read_transport_byte(transport, zero_is_idle), + ) + .await + .map_err(|_| BrokerError::Transport("Host Open timed out".to_string()))? + .map_err(transport_error)?; + if firmware_revision == 0xff { + return Err(BrokerError::Transport( + "received 0xFF firmware response; verify baud rate".to_string(), + )); + } + transport + .write_all(&[0x0a, 0x0b, 0x00, 0x02, 0x00, 0x07, 0x15]) + .await + .map_err(transport_error)?; + transport.flush().await.map_err(transport_error)?; + Ok(firmware_revision) +} + +#[allow(clippy::too_many_arguments, clippy::too_many_lines)] // One supervised ownership loop. +async fn run_actor( + mut transport: T, + mut core: BrokerCore, + mut requests: mpsc::Receiver, + snapshots: watch::Sender, + events: broadcast::Sender, + ptt: Option, + reconnect: bool, + zero_is_idle: bool, + mut opener: O, +) where + T: AsyncRead + AsyncWrite + Send + Unpin + 'static, + O: FnMut() -> F + Send + 'static, + F: Future> + Send + 'static, +{ + let mut maintenance_owner = None; + let mut maintenance_reopen_pending = false; + let mut reconnect_delay = RECONNECT_INITIAL; + + 'actor: loop { + let (mut reader, mut writer) = tokio::io::split(&mut transport); + let mut tick = tokio::time::interval(ACTOR_TICK); + let mut last_status_poll = Instant::now(); + let session_error = 'session: loop { + tokio::select! { + request = requests.recv() => { + let Some(request) = request else { break 'actor }; + match handle_request( + request, + &mut core, + &mut writer, + &events, + ptt.as_ref(), + &mut maintenance_owner, + &mut maintenance_reopen_pending, + ).await { + Ok(true) => { + publish_snapshot(&core, &snapshots); + break 'actor; + } + Ok(false) => {} + Err(error) => break 'session format!("request write failed: {error}"), + } + release_station_tx_if_idle(&core, ptt.as_ref()); + publish_snapshot(&core, &snapshots); + } + byte = read_transport_byte(&mut reader, zero_is_idle) => { + match byte { + Ok(byte) => { + tracing::trace!(byte, "WinKeyer physical rx"); + if maintenance_reopen_pending { + maintenance_reopen_pending = false; + if byte == 0xff { + break 'session "maintenance Host Open returned 0xFF".to_string(); + } + core.connect_physical(byte); + let mut actions = vec![PhysicalAction::Write(vec![ + 0x0a, 0x0b, 0x00, 0x02, 0x00, 0x07, 0x15, + ])]; + actions.extend(core.restore_foreground()); + if apply_actions(actions, &mut writer, &events).await.is_err() { + break 'session "maintenance restore write failed".to_string(); + } + let _ = events.send(BrokerEvent::Connected { + firmware_revision: byte, + }); + publish_snapshot(&core, &snapshots); + continue; + } + if let Some(client_id) = maintenance_owner { + let _ = events.send(BrokerEvent::MaintenanceByte { client_id, byte }); + continue; + } + let actions = match DeviceEvent::from_byte(byte) { + DeviceEvent::SpeedPot { raw, value } => { + core.observe_pot(value); + let wpm = core.snapshot().pot_wpm.unwrap_or(value); + let _ = events.send(BrokerEvent::SpeedPot { raw, value, wpm }); + Vec::new() + } + DeviceEvent::Status(status) => { + let _ = events.send(BrokerEvent::Status { raw: status.raw }); + let physical_tx = status.busy || status.break_in || status.key_down; + if physical_tx && core.snapshot().active_job_id.is_none() { + if let Some(ptt) = &ptt { + if matches!( + ptt.try_key(WINKEYER_STATION_TX_OWNER, true), + Err(PttDenied::Busy) + ) { + let message = "physical WinKeyer paddle/key activity conflicted with CAT PTT ownership"; + core.record_safety_action(message); + let _ = events.send(BrokerEvent::Error(message.to_string())); + } + } + } + core.observe_status(status) + } + DeviceEvent::Echo(byte) => { + let _ = events.send(BrokerEvent::Echo(byte)); + Vec::new() + } + }; + if apply_actions(actions, &mut writer, &events).await.is_err() { + break 'session "write failed".to_string(); + } + publish_snapshot(&core, &snapshots); + release_station_tx_if_idle(&core, ptt.as_ref()); + } + Err(error) => { + break 'session format!("read failed: {error}"); + } + } + } + _ = tick.tick() => { + let now = Instant::now(); + let mut actions = core.watchdog(now); + actions.extend(core.confirm_idle_after_settle(now, SHORT_JOB_SETTLE)); + if core.snapshot().busy && now.duration_since(last_status_poll) >= STATUS_POLL_INTERVAL { + actions.push(PhysicalAction::Write(vec![0x15])); + last_status_poll = now; + } + if apply_actions(actions, &mut writer, &events).await.is_err() { + break 'session "write failed".to_string(); + } + publish_snapshot(&core, &snapshots); + release_station_tx_if_idle(&core, ptt.as_ref()); + } + } + }; + + drop(reader); + drop(writer); + maintenance_owner = None; + maintenance_reopen_pending = false; + let actions = core.physical_error(session_error.clone()); + let _ = events.send(BrokerEvent::Error(session_error)); + // Cancellations are notifications only after the transport has failed. + let mut sink = tokio::io::sink(); + let _ = apply_actions(actions, &mut sink, &events).await; + if let Some(ptt) = &ptt { + ptt.unkey(WINKEYER_STATION_TX_OWNER); + } + publish_snapshot(&core, &snapshots); + + loop { + let delay = if reconnect { + reconnect_delay + } else { + Duration::from_secs(86_400) + }; + tokio::select! { + request = requests.recv() => { + let Some(request) = request else { break 'actor }; + match handle_request( + request, + &mut core, + &mut sink, + &events, + ptt.as_ref(), + &mut maintenance_owner, + &mut maintenance_reopen_pending, + ).await { + Ok(true) => break 'actor, + Ok(false) => {} + Err(error) => { + let _ = events.send(BrokerEvent::Error(error.to_string())); + } + } + publish_snapshot(&core, &snapshots); + } + () = tokio::time::sleep(delay), if reconnect => { + match opener().await { + Ok(mut candidate) => match initialize_transport(&mut candidate, zero_is_idle).await { + Ok(firmware_revision) => { + transport = candidate; + core.connect_physical(firmware_revision); + let actions = core.restore_foreground(); + if let Err(error) = apply_actions(actions, &mut transport, &events).await { + let message = format!("reconnect restore failed: {error}"); + let _ = events.send(BrokerEvent::Error(message.clone())); + core.physical_error(message); + publish_snapshot(&core, &snapshots); + reconnect_delay = (reconnect_delay * 2).min(RECONNECT_MAX); + continue; + } + let _ = events.send(BrokerEvent::Connected { firmware_revision }); + publish_snapshot(&core, &snapshots); + reconnect_delay = RECONNECT_INITIAL; + break; + } + Err(error) => { + let message = format!("reconnect initialization failed: {error}"); + let _ = events.send(BrokerEvent::Error(message.clone())); + core.physical_error(message); + publish_snapshot(&core, &snapshots); + } + }, + Err(error) => { + let message = format!("reconnect open failed: {error}"); + let _ = events.send(BrokerEvent::Error(message.clone())); + core.physical_error(message); + publish_snapshot(&core, &snapshots); + } + } + reconnect_delay = (reconnect_delay * 2).min(RECONNECT_MAX); + } + } + } + } + + // Best effort. Clear buffer, force key-up, then return to standalone EEPROM settings. + let _ = transport.write_all(&[0x0a, 0x0b, 0x00, 0x00, 0x03]).await; + let _ = transport.flush().await; + if let Some(ptt) = &ptt { + ptt.unkey(WINKEYER_STATION_TX_OWNER); + } +} + +/// Windows serial drivers may report a zero-length asynchronous read for an idle timeout. +/// A byte stream EOF is meaningful for sockets/duplex tests, but not for an open COM port; +/// retry until a byte or a real I/O error arrives. Host Open applies its own deadline. +async fn read_transport_byte(transport: &mut T, zero_is_idle: bool) -> io::Result +where + T: AsyncRead + Unpin, +{ + let mut byte = [0_u8; 1]; + loop { + match transport.read(&mut byte).await? { + 0 if zero_is_idle => tokio::time::sleep(Duration::from_millis(1)).await, + 0 => return Err(io::Error::from(io::ErrorKind::UnexpectedEof)), + _ => return Ok(byte[0]), + } + } +} + +#[allow(clippy::single_match_else, clippy::too_many_lines)] // Exhaustive request arbitration. +async fn handle_request( + request: Request, + core: &mut BrokerCore, + writer: &mut W, + events: &broadcast::Sender, + ptt: Option<&PttManager>, + maintenance_owner: &mut Option, + maintenance_reopen_pending: &mut bool, +) -> Result +where + W: AsyncWrite + Unpin, +{ + let actions = match request { + Request::Register { + client_id, + primary, + reply, + } => { + let result = if core.register_client(client_id, primary) { + Ok(()) + } else { + Err(BrokerError::PrimaryExists) + }; + let _ = reply.send(result); + Vec::new() + } + Request::Unregister { client_id } => { + let mut actions = core.unregister_client(client_id); + if *maintenance_owner == Some(client_id) { + *maintenance_owner = None; + *maintenance_reopen_pending = true; + actions.insert(0, PhysicalAction::Write(vec![0x00, 0x02])); + } + actions + } + Request::SetSpeed { + client_id, + speed, + reply, + } => { + if *maintenance_reopen_pending + || maintenance_owner.is_some_and(|owner| owner != client_id) + { + let _ = reply.send(Err(BrokerError::MaintenanceBusy)); + return Ok(false); + } + let known = core.set_client_speed(client_id, speed); + let result = if core.snapshot().connected { + Ok(()) + } else { + Err(BrokerError::NotConnected) + }; + let _ = reply.send(result); + known + } + Request::Enqueue { + client_id, + payload, + speed, + stream, + reply, + } => { + if maintenance_owner.is_some() || *maintenance_reopen_pending { + let _ = reply.send(Err(BrokerError::MaintenanceBusy)); + return Ok(false); + } + let snapshot = core.snapshot(); + if payload.wire_bytes().is_empty() || payload.wire_bytes().len() > MAX_JOB_BYTES { + let _ = reply.send(Err(BrokerError::Invalid(format!( + "job payload must contain 1 through {MAX_JOB_BYTES} bytes" + )))); + return Ok(false); + } + if snapshot.queued_jobs >= MAX_QUEUED_JOBS + && !(stream && snapshot.active_client_id == Some(client_id)) + { + let _ = reply.send(Err(BrokerError::QueueFull)); + return Ok(false); + } + if snapshot.active_job_id.is_none() + && (snapshot.busy || snapshot.break_in || snapshot.key_down) + { + let _ = reply.send(Err(BrokerError::TransmitBusy)); + return Ok(false); + } + if snapshot.active_job_id.is_none() { + if let Some(ptt) = ptt { + if matches!( + ptt.try_key(WINKEYER_STATION_TX_OWNER, true), + Err(PttDenied::Busy) + ) { + let _ = reply.send(Err(BrokerError::TransmitBusy)); + return Ok(false); + } + } + } + tracing::trace!( + client_id, + intended_text = %String::from_utf8_lossy(payload.intended_text()), + wire_bytes = ?payload.wire_bytes(), + "WinKeyer transmit payload" + ); + match core.enqueue(client_id, payload, speed, stream, Instant::now()) { + Some((job_id, actions)) => { + let _ = reply.send(Ok(job_id)); + actions + } + None => { + let error = if core.snapshot().connected { + BrokerError::UnknownClient + } else { + BrokerError::NotConnected + }; + let _ = reply.send(Err(error)); + Vec::new() + } + } + } + Request::Cancel { + client_id, + include_active, + reply, + } => { + let actions = core.cancel_client(client_id, include_active); + let _ = reply.send(Ok(())); + actions + } + Request::CancelJob { + client_id, + job_id, + reply, + } => { + let (canceled, actions) = core.cancel_job(client_id, job_id); + let _ = reply.send(Ok(canceled)); + actions + } + Request::EmergencyStop { reason, reply } => { + *maintenance_owner = None; + *maintenance_reopen_pending = false; + let actions = core.emergency_stop(&reason); + let _ = reply.send(Ok(())); + actions + } + Request::Configure { + client_id, + command, + reply, + } => { + if *maintenance_reopen_pending + || maintenance_owner.is_some_and(|owner| owner != client_id) + { + let _ = reply.send(Err(BrokerError::MaintenanceBusy)); + Vec::new() + } else { + match core.set_client_command(client_id, command) { + Some(actions) => { + let _ = reply.send(Ok(())); + actions + } + None => { + let _ = reply.send(Err(BrokerError::UnknownClient)); + Vec::new() + } + } + } + } + Request::MaintenanceCommand { + client_id, + command, + reply, + } => { + let snapshot = core.snapshot(); + let owner_allowed = maintenance_owner.is_none_or(|owner| owner == client_id); + if !owner_allowed || snapshot.active_job_id.is_some() || snapshot.queued_jobs != 0 { + let _ = reply.send(Err(BrokerError::MaintenanceBusy)); + Vec::new() + } else if !snapshot.connected { + let _ = reply.send(Err(BrokerError::NotConnected)); + Vec::new() + } else { + let first_command = maintenance_owner.is_none(); + *maintenance_owner = Some(client_id); + let _ = reply.send(Ok(())); + let mut actions = Vec::new(); + if first_command { + actions.push(PhysicalAction::Write(vec![0x0a, 0x0b, 0x00, 0x03])); + } + actions.push(PhysicalAction::Write(command)); + actions + } + } + Request::ReleaseMaintenance { client_id } => { + if *maintenance_owner == Some(client_id) { + *maintenance_owner = None; + *maintenance_reopen_pending = true; + vec![PhysicalAction::Write(vec![0x00, 0x02])] + } else { + Vec::new() + } + } + Request::ActiveOwnerCommand { + client_id, + command, + reply, + } => { + if maintenance_owner.is_some() || *maintenance_reopen_pending { + let _ = reply.send(Err(BrokerError::MaintenanceBusy)); + Vec::new() + } else { + match core.active_owner_command(client_id, command) { + Some(actions) => { + let _ = reply.send(Ok(())); + actions + } + None => { + let _ = reply.send(Err(BrokerError::Invalid( + "command requires active or primary ownership".to_string(), + ))); + Vec::new() + } + } + } + } + Request::Shutdown { reply } => { + let actions = core.emergency_stop("WinKeyer broker shutdown"); + let _ = apply_actions(actions, writer, events).await; + let _ = reply.send(()); + return Ok(true); + } + }; + apply_actions(actions, writer, events).await?; + Ok(false) +} + +async fn apply_actions( + actions: Vec, + writer: &mut W, + events: &broadcast::Sender, +) -> Result<(), BrokerError> +where + W: AsyncWrite + Unpin, +{ + for action in actions { + match action { + PhysicalAction::Write(bytes) => { + tracing::trace!(bytes = ?bytes, "WinKeyer physical tx"); + writer.write_all(&bytes).await.map_err(transport_error)?; + } + PhysicalAction::Completed { job_id, client_id } => { + let _ = events.send(BrokerEvent::Completed { job_id, client_id }); + } + PhysicalAction::Canceled { job_id, client_id } => { + let _ = events.send(BrokerEvent::Canceled { job_id, client_id }); + } + } + } + writer.flush().await.map_err(transport_error) +} + +fn publish_snapshot(core: &BrokerCore, snapshots: &watch::Sender) { + snapshots.send_if_modified(|current| { + let next = core.snapshot(); + if *current == next { + false + } else { + *current = next; + true + } + }); +} + +fn release_station_tx_if_idle(core: &BrokerCore, ptt: Option<&PttManager>) { + let snapshot = core.snapshot(); + if snapshot.active_job_id.is_none() + && !snapshot.busy + && !snapshot.break_in + && !snapshot.key_down + { + if let Some(ptt) = ptt { + ptt.unkey(WINKEYER_STATION_TX_OWNER); + } + } +} + +#[allow(clippy::needless_pass_by_value)] +fn transport_error(error: std::io::Error) -> BrokerError { + BrokerError::Transport(error.to_string()) +} + +#[cfg(test)] +#[allow(clippy::expect_used)] +mod tests { + use super::*; + use std::sync::Arc; + use tokio::sync::Mutex; + + async fn spawn_fake() -> (BrokerHandle, tokio::io::DuplexStream) { + let (mut device, broker) = tokio::io::duplex(4096); + let task = tokio::spawn(async move { spawn(broker, Duration::from_secs(30)).await }); + let mut open = [0_u8; 2]; + device.read_exact(&mut open).await.expect("host open"); + assert_eq!(open, [0x00, 0x02]); + device.write_u8(31).await.expect("firmware"); + let handle = task.await.expect("spawn task").expect("broker"); + let mut initialization = [0_u8; 7]; + device + .read_exact(&mut initialization) + .await + .expect("initialization"); + assert_eq!(initialization, [0x0a, 0x0b, 0x00, 0x02, 0x00, 0x07, 0x15]); + (handle, device) + } + + #[tokio::test] + async fn opens_once_and_exposes_firmware_status() { + let (handle, _device) = spawn_fake().await; + assert!(handle.snapshot().connected); + assert_eq!(handle.snapshot().firmware_revision, Some(31)); + } + + #[tokio::test] + async fn typed_job_writes_speed_text_and_status_without_interleaving() { + let (handle, mut device) = spawn_fake().await; + handle.register(10, true).await.expect("register"); + assert_eq!( + handle + .enqueue(10, b"TEST".to_vec(), Some(SpeedMode::Fixed(22))) + .await, + Ok(1) + ); + let mut bytes = [0_u8; 8]; + device.read_exact(&mut bytes).await.expect("job"); + assert_eq!(bytes, [0x02, 22, b'T', b'E', b'S', b'T', 0x15, 0x15]); + } + + #[tokio::test] + async fn pot_and_completion_events_are_fanned_out() { + let (handle, mut device) = spawn_fake().await; + handle.register(10, true).await.expect("register"); + let mut events = handle.subscribe(); + device.write_u8(0x94).await.expect("pot"); + let event = tokio::time::timeout(Duration::from_secs(1), events.recv()) + .await + .expect("timely") + .expect("event"); + assert_eq!( + event, + BrokerEvent::SpeedPot { + raw: 0x94, + value: 20, + wpm: 25, + } + ); + assert_eq!(handle.snapshot().pot_value, Some(20)); + assert_eq!(handle.snapshot().pot_wpm, Some(25)); + } + + #[tokio::test] + async fn shutdown_clears_key_and_closes_host_session() { + let (handle, mut device) = spawn_fake().await; + handle.shutdown().await.expect("shutdown"); + let mut bytes = Vec::new(); + tokio::time::timeout(Duration::from_secs(1), device.read_to_end(&mut bytes)) + .await + .expect("close") + .expect("read"); + assert!(bytes + .windows(5) + .any(|window| window == [0x0a, 0x0b, 0x00, 0x00, 0x03])); + } + + #[tokio::test] + async fn station_ptt_owner_blocks_conflicting_winkeyer_transmit() { + let (mut device, physical) = tokio::io::duplex(4096); + let ptt = PttManager::new(Duration::from_secs(30)); + ptt.try_key(7, true).expect("CAT owner"); + let task = tokio::spawn({ + let ptt = ptt.clone(); + async move { spawn_with_ptt(physical, Duration::from_secs(30), ptt).await } + }); + let mut open = [0_u8; 2]; + device.read_exact(&mut open).await.expect("host open"); + device.write_u8(31).await.expect("firmware"); + let handle = task.await.expect("task").expect("broker"); + let mut initialization = [0_u8; 7]; + device + .read_exact(&mut initialization) + .await + .expect("initialization"); + handle.register(10, false).await.expect("register"); + + assert_eq!( + handle.enqueue(10, b"TEST".to_vec(), None).await, + Err(BrokerError::TransmitBusy) + ); + assert_eq!(ptt.owner(), Some(7)); + } + + #[tokio::test] + async fn winkeyer_station_lease_releases_when_job_completes() { + let (mut device, physical) = tokio::io::duplex(4096); + let ptt = PttManager::new(Duration::from_secs(30)); + let task = tokio::spawn({ + let ptt = ptt.clone(); + async move { spawn_with_ptt(physical, Duration::from_secs(30), ptt).await } + }); + let mut open = [0_u8; 2]; + device.read_exact(&mut open).await.expect("host open"); + device.write_u8(31).await.expect("firmware"); + let handle = task.await.expect("task").expect("broker"); + let mut initialization = [0_u8; 7]; + device + .read_exact(&mut initialization) + .await + .expect("initialization"); + handle.register(10, false).await.expect("register"); + handle.enqueue(10, b"E".to_vec(), None).await.expect("send"); + assert_eq!(ptt.owner(), Some(WINKEYER_STATION_TX_OWNER)); + let mut writes = [0_u8; 4]; + device.read_exact(&mut writes).await.expect("writes"); + device.write_u8(0xc4).await.expect("busy"); + device.write_u8(0xc0).await.expect("idle"); + for _ in 0..50 { + if ptt.owner().is_none() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert_eq!(ptt.owner(), None); + } + + #[tokio::test] + async fn physical_paddle_activity_acquires_station_transmit_lease() { + let (mut device, physical) = tokio::io::duplex(4096); + let ptt = PttManager::new(Duration::from_secs(30)); + let task = tokio::spawn({ + let ptt = ptt.clone(); + async move { spawn_with_ptt(physical, Duration::from_secs(30), ptt).await } + }); + let mut open = [0_u8; 2]; + device.read_exact(&mut open).await.expect("host open"); + device.write_u8(31).await.expect("firmware"); + let handle = task.await.expect("task").expect("broker"); + let mut initialization = [0_u8; 7]; + device + .read_exact(&mut initialization) + .await + .expect("initialization"); + + device.write_u8(0xc6).await.expect("paddle busy status"); + for _ in 0..50 { + if ptt.owner() == Some(WINKEYER_STATION_TX_OWNER) { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert_eq!(ptt.owner(), Some(WINKEYER_STATION_TX_OWNER)); + assert_eq!(ptt.try_key(7, true), Err(PttDenied::Busy)); + handle.register(10, false).await.expect("client"); + assert_eq!( + handle.enqueue(10, b"WAIT".to_vec(), None).await, + Err(BrokerError::TransmitBusy) + ); + + device.write_u8(0xc0).await.expect("paddle idle status"); + for _ in 0..50 { + if ptt.owner().is_none() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert_eq!(ptt.owner(), None); + } + + #[tokio::test] + async fn exclusive_maintenance_routes_private_replies_and_blocks_transmit() { + let (handle, mut device) = spawn_fake().await; + handle.register(10, true).await.expect("maintenance client"); + handle.register(20, false).await.expect("ordinary client"); + let mut events = handle.subscribe(); + + handle + .maintenance_command(10, vec![0x00, 0x0c]) + .await + .expect("acquire maintenance"); + let mut command = [0_u8; 6]; + device + .read_exact(&mut command) + .await + .expect("maintenance write"); + assert_eq!(command, [0x0a, 0x0b, 0x00, 0x03, 0x00, 0x0c]); + assert_eq!( + handle.enqueue(20, b"NO".to_vec(), None).await, + Err(BrokerError::MaintenanceBusy) + ); + + device.write_u8(0x42).await.expect("private reply"); + assert_eq!( + events.recv().await.expect("maintenance event"), + BrokerEvent::MaintenanceByte { + client_id: 10, + byte: 0x42, + } + ); + + handle + .release_maintenance(10) + .await + .expect("release maintenance"); + let mut host_open = [0_u8; 2]; + device + .read_exact(&mut host_open) + .await + .expect("host reopen"); + assert_eq!(host_open, [0x00, 0x02]); + assert_eq!( + handle.enqueue(20, b"WAIT".to_vec(), None).await, + Err(BrokerError::MaintenanceBusy) + ); + device + .write_u8(31) + .await + .expect("firmware after maintenance"); + let mut restore = [0_u8; 9]; + device.read_exact(&mut restore).await.expect("safe restore"); + assert_eq!( + restore, + [0x0a, 0x0b, 0x00, 0x02, 0x00, 0x07, 0x15, 0x02, 0x00] + ); + assert!(handle.enqueue(20, b"OK".to_vec(), None).await.is_ok()); + } + + #[tokio::test] + async fn supervised_transport_reconnects_without_restarting_broker() { + let (mut first_device, first_transport) = tokio::io::duplex(4096); + let (replacement_tx, replacement_rx) = mpsc::channel(1); + let replacement_rx = Arc::new(Mutex::new(replacement_rx)); + let task = tokio::spawn({ + let replacement_rx = replacement_rx.clone(); + async move { + spawn_inner( + first_transport, + Duration::from_secs(30), + Some(PttManager::new(Duration::from_secs(30))), + true, + false, + move || { + let replacement_rx = replacement_rx.clone(); + async move { + replacement_rx + .lock() + .await + .recv() + .await + .ok_or_else(|| io::Error::other("no replacement")) + } + }, + ) + .await + } + }); + let mut open = [0_u8; 2]; + first_device + .read_exact(&mut open) + .await + .expect("first open"); + first_device.write_u8(31).await.expect("first firmware"); + let handle = task.await.expect("spawn task").expect("broker"); + let mut initialization = [0_u8; 7]; + first_device + .read_exact(&mut initialization) + .await + .expect("first initialization"); + drop(first_device); + + for _ in 0..100 { + if !handle.snapshot().connected { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert!(!handle.snapshot().connected); + + let (mut second_device, second_transport) = tokio::io::duplex(4096); + replacement_tx + .send(second_transport) + .await + .expect("replacement transport"); + second_device + .read_exact(&mut open) + .await + .expect("second open"); + second_device.write_u8(32).await.expect("second firmware"); + let mut reconnect_initialization = [0_u8; 9]; + second_device + .read_exact(&mut reconnect_initialization) + .await + .expect("reconnect initialization and foreground restore"); + assert_eq!( + reconnect_initialization, + [0x0a, 0x0b, 0x00, 0x02, 0x00, 0x07, 0x15, 0x02, 0x00] + ); + for _ in 0..100 { + if handle.snapshot().connected { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert!(handle.snapshot().connected); + assert_eq!(handle.snapshot().firmware_revision, Some(32)); + } +} diff --git a/crates/cathub/src/winkeyer/broker.rs b/crates/cathub/src/winkeyer/broker.rs new file mode 100644 index 0000000..ae56f0d --- /dev/null +++ b/crates/cathub/src/winkeyer/broker.rs @@ -0,0 +1,975 @@ +//! Pure WinKeyer multi-client scheduling and ownership state. +//! +//! The physical actor applies [`PhysicalAction`] values returned here. Keeping arbitration +//! independent of serial I/O makes interleaving, cancellation, pot restoration, and crash +//! recovery deterministic under unit tests. + +use std::collections::{BTreeMap, VecDeque}; +use std::time::{Duration, Instant}; + +use super::protocol::DeviceStatus; + +/// Stable identity assigned to one virtual face or typed API connection. +pub(crate) type ClientId = u64; +/// Stable identity assigned to one queued transmit job. +pub(crate) type JobId = u64; +/// Conservative payload ceiling below the physical WinKeyer input FIFO capacity. +pub(crate) const MAX_JOB_BYTES: usize = 120; +pub(crate) const MAX_QUEUED_JOBS: usize = 64; + +/// Speed source desired by a client or one transmit job. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SpeedMode { + /// Read speed directly from the physical potentiometer (`Set WPM Speed 0`). + Pot, + /// Use a fixed host-selected speed. + Fixed(u8), +} + +impl SpeedMode { + pub(crate) fn command(self) -> [u8; 2] { + [ + 0x02, + match self { + Self::Pot => 0, + Self::Fixed(wpm) => wpm, + }, + ] + } +} + +/// One side effect the physical actor must perform in order. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum PhysicalAction { + /// Write bytes to the physical WinKeyer. + Write(Vec), + /// A job reached its normal completion boundary. + Completed { job_id: JobId, client_id: ClientId }, + /// A job was canceled before or during transmission. + Canceled { job_id: JobId, client_id: ClientId }, +} + +/// One immutable queued unit of transmission. +#[derive(Debug, Clone)] +struct Job { + id: JobId, + client_id: ClientId, + payload: TransmitPayload, + speed: SpeedMode, + queued_at: Instant, + stream: bool, +} + +/// A transmit request keeps operator text distinct from WinKeyer wire-control bytes. +/// Only `wire_bytes` are written to the device; `intended_text` is the authoritative +/// character payload used for diagnostics and conformance tests. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct TransmitPayload { + wire_bytes: Vec, + intended_text: Vec, +} + +impl TransmitPayload { + pub(crate) fn plain_text(intended_text: Vec) -> Self { + Self { + wire_bytes: intended_text.clone(), + intended_text, + } + } + + pub(crate) fn legacy_stream(control_prefix: Vec, intended_text: Vec) -> Self { + let mut wire_bytes = control_prefix; + wire_bytes.extend_from_slice(&intended_text); + Self { + wire_bytes, + intended_text, + } + } + + pub(crate) fn wire_bytes(&self) -> &[u8] { + &self.wire_bytes + } + + pub(crate) fn intended_text(&self) -> &[u8] { + &self.intended_text + } + + fn append(&mut self, other: &Self) { + self.wire_bytes.extend_from_slice(&other.wire_bytes); + self.intended_text.extend_from_slice(&other.intended_text); + } +} + +impl From> for TransmitPayload { + fn from(value: Vec) -> Self { + Self::plain_text(value) + } +} + +#[derive(Debug, Clone)] +struct ActiveJob { + job: Job, + saw_busy: bool, + observed_status: bool, + started_at: Instant, +} + +#[derive(Debug, Clone)] +struct ClientState { + desired_speed: SpeedMode, + primary: bool, + profile: BTreeMap>, +} + +/// Snapshot exposed to status and event surfaces. +#[derive(Debug, Clone, PartialEq, Eq)] +#[allow(clippy::struct_excessive_bools)] +pub(crate) struct BrokerSnapshot { + pub(crate) firmware_revision: Option, + pub(crate) connected: bool, + pub(crate) busy: bool, + pub(crate) break_in: bool, + pub(crate) key_down: bool, + pub(crate) pot_value: Option, + pub(crate) pot_wpm: Option, + pub(crate) active_client_id: Option, + pub(crate) active_job_id: Option, + pub(crate) queued_jobs: usize, + pub(crate) last_error: Option, + pub(crate) last_safety_action: Option, +} + +/// Stateful scheduler for a single physical keyer. +#[derive(Debug)] +pub(crate) struct BrokerCore { + clients: BTreeMap, + applied_profile_client_id: Option, + queue: VecDeque, + active: Option, + next_job_id: JobId, + firmware_revision: Option, + connected: bool, + status: DeviceStatus, + pot_value: Option, + pot_wpm: Option, + physical_pot_min_wpm: u8, + last_error: Option, + last_safety_action: Option, + max_tx: Duration, +} + +impl BrokerCore { + pub(crate) fn new(max_tx: Duration) -> Self { + Self { + clients: BTreeMap::new(), + applied_profile_client_id: None, + queue: VecDeque::new(), + active: None, + next_job_id: 1, + firmware_revision: None, + connected: false, + status: DeviceStatus { + raw: 0xc0, + waiting: false, + key_down: false, + busy: false, + break_in: false, + xoff: false, + }, + pot_value: None, + pot_wpm: None, + physical_pot_min_wpm: 5, + last_error: None, + last_safety_action: None, + max_tx, + } + } + + pub(crate) fn connect_physical(&mut self, firmware_revision: u8) { + self.firmware_revision = Some(firmware_revision); + self.connected = true; + self.applied_profile_client_id = None; + self.last_error = None; + } + + pub(crate) fn physical_error(&mut self, error: impl Into) -> Vec { + self.connected = false; + self.firmware_revision = None; + self.applied_profile_client_id = None; + self.last_error = Some(error.into()); + let mut actions = Vec::new(); + if let Some(active) = self.active.take() { + actions.push(PhysicalAction::Canceled { + job_id: active.job.id, + client_id: active.job.client_id, + }); + } + while let Some(job) = self.queue.pop_front() { + actions.push(PhysicalAction::Canceled { + job_id: job.id, + client_id: job.client_id, + }); + } + actions + } + + pub(crate) fn register_client(&mut self, client_id: ClientId, primary: bool) -> bool { + if primary && self.clients.values().any(|client| client.primary) { + return false; + } + self.clients.insert( + client_id, + ClientState { + desired_speed: SpeedMode::Pot, + primary, + profile: BTreeMap::new(), + }, + ); + true + } + + pub(crate) fn unregister_client(&mut self, client_id: ClientId) -> Vec { + self.clients.remove(&client_id); + if self.applied_profile_client_id == Some(client_id) { + self.applied_profile_client_id = None; + } + let mut actions = Vec::new(); + self.queue.retain(|job| { + if job.client_id == client_id { + actions.push(PhysicalAction::Canceled { + job_id: job.id, + client_id, + }); + false + } else { + true + } + }); + if self.active.as_ref().map(|active| active.job.client_id) == Some(client_id) { + if let Some(active) = self.active.take() { + actions.push(PhysicalAction::Write(vec![0x0a])); + actions.push(PhysicalAction::Canceled { + job_id: active.job.id, + client_id, + }); + } + self.last_safety_action = Some(format!( + "cleared WinKeyer buffer after active client {client_id} disconnected" + )); + actions.extend(self.start_next_or_restore()); + } + actions + } + + pub(crate) fn set_client_speed( + &mut self, + client_id: ClientId, + speed: SpeedMode, + ) -> Vec { + let Some(client) = self.clients.get_mut(&client_id) else { + return Vec::new(); + }; + client.desired_speed = speed; + if self.active.is_none() && client.primary { + vec![PhysicalAction::Write(speed.command().to_vec())] + } else { + Vec::new() + } + } + + pub(crate) fn set_client_command( + &mut self, + client_id: ClientId, + command: Vec, + ) -> Option> { + let opcode = *command.first()?; + let key = if opcode == 0x00 { + 0x100 | u16::from(*command.get(1)?) + } else { + u16::from(opcode) + }; + let client = self.clients.get_mut(&client_id)?; + client.profile.insert(key, command.clone()); + let applies_now = self.active.is_none() && client.primary; + if applies_now { + if let Some(minimum) = speed_pot_minimum(&command) { + self.physical_pot_min_wpm = minimum; + self.refresh_pot_wpm(); + } + } else if self.applied_profile_client_id == Some(client_id) { + // The profile changed while this client owned an active stream. It could not + // be applied mid-stream, so force a replay at the next ownership boundary. + self.applied_profile_client_id = None; + } + Some(if applies_now { + self.applied_profile_client_id = Some(client_id); + vec![PhysicalAction::Write(command)] + } else { + Vec::new() + }) + } + + pub(crate) fn active_owner_command( + &self, + client_id: ClientId, + command: Vec, + ) -> Option> { + let allowed = self.active.as_ref().map_or_else( + || { + self.clients + .get(&client_id) + .is_some_and(|client| client.primary) + }, + |active| active.job.client_id == client_id, + ); + allowed.then(|| vec![PhysicalAction::Write(command)]) + } + + pub(crate) fn enqueue>( + &mut self, + client_id: ClientId, + payload: P, + speed: Option, + stream: bool, + now: Instant, + ) -> Option<(JobId, Vec)> { + let payload = payload.into(); + if payload.wire_bytes.is_empty() + || payload.wire_bytes.len() > MAX_JOB_BYTES + || !self.connected + { + return None; + } + let client = self.clients.get(&client_id)?; + if stream { + if let Some(active) = self.active.as_mut() { + if active.job.client_id == client_id && active.job.stream { + if active.job.payload.wire_bytes.len() + payload.wire_bytes.len() + > MAX_JOB_BYTES + { + return None; + } + active.job.payload.append(&payload); + return Some(( + active.job.id, + vec![PhysicalAction::Write(payload.wire_bytes)], + )); + } + } + if let Some(queued) = self.queue.back_mut() { + if queued.client_id == client_id && queued.stream { + if queued.payload.wire_bytes.len() + payload.wire_bytes.len() > MAX_JOB_BYTES { + return None; + } + queued.payload.append(&payload); + return Some((queued.id, Vec::new())); + } + } + } + if self.queue.len() >= MAX_QUEUED_JOBS { + return None; + } + let job_id = self.next_job_id; + self.next_job_id = self.next_job_id.saturating_add(1); + self.queue.push_back(Job { + id: job_id, + client_id, + payload, + speed: speed.unwrap_or(client.desired_speed), + queued_at: now, + stream, + }); + let actions = if self.active.is_none() { + self.start_next_or_restore() + } else { + Vec::new() + }; + Some((job_id, actions)) + } + + pub(crate) fn cancel_client( + &mut self, + client_id: ClientId, + include_active: bool, + ) -> Vec { + let mut actions = Vec::new(); + self.queue.retain(|job| { + if job.client_id == client_id { + actions.push(PhysicalAction::Canceled { + job_id: job.id, + client_id, + }); + false + } else { + true + } + }); + let active_client_id = self.active.as_ref().map(|active| active.job.client_id); + if include_active && active_client_id == Some(client_id) { + if let Some(active) = self.active.take() { + actions.insert(0, PhysicalAction::Write(vec![0x0a])); + actions.push(PhysicalAction::Canceled { + job_id: active.job.id, + client_id, + }); + } + actions.extend(self.start_next_or_restore()); + } else if include_active + && active_client_id.is_none() + && self + .clients + .get(&client_id) + .is_some_and(|client| client.primary) + { + // A primary idle controller is allowed to clear stale bytes left in the + // physical FIFO. N1MM sends this when Ctrl+K opens; dropping it can make a + // later character transmit remnants from an earlier stream. + actions.insert(0, PhysicalAction::Write(vec![0x0a])); + } + actions + } + + pub(crate) fn cancel_job( + &mut self, + client_id: ClientId, + job_id: JobId, + ) -> (bool, Vec) { + if self + .active + .as_ref() + .is_some_and(|active| active.job.client_id == client_id && active.job.id == job_id) + { + let mut actions = vec![PhysicalAction::Write(vec![0x0a])]; + if let Some(active) = self.active.take() { + actions.push(PhysicalAction::Canceled { + job_id: active.job.id, + client_id, + }); + } + actions.extend(self.start_next_or_restore()); + return (true, actions); + } + let mut canceled = false; + let mut actions = Vec::new(); + self.queue.retain(|job| { + if job.client_id == client_id && job.id == job_id { + canceled = true; + actions.push(PhysicalAction::Canceled { job_id, client_id }); + false + } else { + true + } + }); + (canceled, actions) + } + + pub(crate) fn emergency_stop(&mut self, reason: &str) -> Vec { + let mut actions = vec![PhysicalAction::Write(vec![0x0a, 0x0b, 0x00])]; + if let Some(active) = self.active.take() { + actions.push(PhysicalAction::Canceled { + job_id: active.job.id, + client_id: active.job.client_id, + }); + } + while let Some(job) = self.queue.pop_front() { + actions.push(PhysicalAction::Canceled { + job_id: job.id, + client_id: job.client_id, + }); + } + self.last_safety_action = Some(reason.to_string()); + actions.extend(self.restore_foreground_speed()); + actions + } + + pub(crate) fn record_safety_action(&mut self, action: impl Into) { + self.last_safety_action = Some(action.into()); + } + + pub(crate) fn observe_status(&mut self, status: DeviceStatus) -> Vec { + self.status = status; + let Some(active) = self.active.as_mut() else { + return Vec::new(); + }; + active.observed_status = true; + if status.busy || status.waiting || status.key_down { + active.saw_busy = true; + return Vec::new(); + } + if active.saw_busy { + return self.complete_active(); + } + Vec::new() + } + + /// Confirm an idle boundary when a very short job completed before a busy status could + /// be observed. The actor calls this only after its settle timer and an idle status. + pub(crate) fn confirm_idle_after_settle( + &mut self, + now: Instant, + settle: Duration, + ) -> Vec { + if self.active.as_ref().is_some_and(|active| { + active.observed_status && now.duration_since(active.started_at) >= settle + }) && !self.status.busy + && !self.status.waiting + && !self.status.key_down + { + self.complete_active() + } else { + Vec::new() + } + } + + pub(crate) fn observe_pot(&mut self, value: u8) { + self.pot_value = Some(value); + self.refresh_pot_wpm(); + } + + pub(crate) fn watchdog(&mut self, now: Instant) -> Vec { + let expired = self + .active + .as_ref() + .is_some_and(|active| now.duration_since(active.started_at) >= self.max_tx); + if expired { + return self.emergency_stop("WinKeyer transmit safety ceiling reached"); + } + Vec::new() + } + + pub(crate) fn snapshot(&self) -> BrokerSnapshot { + BrokerSnapshot { + firmware_revision: self.firmware_revision, + connected: self.connected, + busy: self.status.busy || self.active.is_some(), + break_in: self.status.break_in, + key_down: self.status.key_down, + pot_value: self.pot_value, + pot_wpm: self.pot_wpm, + active_client_id: self.active.as_ref().map(|active| active.job.client_id), + active_job_id: self.active.as_ref().map(|active| active.job.id), + queued_jobs: self.queue.len(), + last_error: self.last_error.clone(), + last_safety_action: self.last_safety_action.clone(), + } + } + + /// Reapply the primary client's transient profile after exclusive maintenance. + pub(crate) fn restore_foreground(&mut self) -> Vec { + self.restore_foreground_speed() + } + + fn complete_active(&mut self) -> Vec { + let Some(active) = self.active.take() else { + return Vec::new(); + }; + let mut actions = vec![PhysicalAction::Completed { + job_id: active.job.id, + client_id: active.job.client_id, + }]; + actions.extend(self.start_next_or_restore()); + actions + } + + fn start_next_or_restore(&mut self) -> Vec { + if let Some(job) = self.queue.pop_front() { + debug_assert!(job.queued_at <= Instant::now()); + let profile_changed = self.applied_profile_client_id != Some(job.client_id); + let mut actions = if profile_changed { + self.clients + .get(&job.client_id) + .map_or_else(Vec::new, |client| { + client + .profile + .values() + .cloned() + .map(PhysicalAction::Write) + .collect() + }) + } else { + Vec::new() + }; + if profile_changed { + if let Some(minimum) = self + .clients + .get(&job.client_id) + .and_then(|client| profile_pot_minimum(&client.profile)) + { + self.physical_pot_min_wpm = minimum; + self.refresh_pot_wpm(); + } + self.applied_profile_client_id = Some(job.client_id); + } + // A legacy WinKeyer stream may carry Buffered Speed (0x1c) after pointer + // setup. Inserting an unbuffered speed command between those operations + // breaks N1MM's pointer context and keys the WPM byte as a phantom character. + // Typed/plain-text jobs still require the broker-selected speed command. + if job.payload.wire_bytes.first() != Some(&0x1c) { + actions.push(PhysicalAction::Write(job.speed.command().to_vec())); + } + actions.push(PhysicalAction::Write(job.payload.wire_bytes.clone())); + actions.push(PhysicalAction::Write(vec![0x15])); + self.active = Some(ActiveJob { + job, + saw_busy: false, + observed_status: false, + started_at: Instant::now(), + }); + actions + } else { + self.restore_foreground_speed() + } + } + + fn restore_foreground_speed(&mut self) -> Vec { + let foreground = self + .clients + .iter() + .find(|(_, client)| client.primary) + .map(|(id, client)| (*id, client)); + let profile_changed = + foreground.is_some_and(|(id, _)| Some(id) != self.applied_profile_client_id); + let mut actions: Vec<_> = if profile_changed { + foreground + .into_iter() + .flat_map(|(_, client)| client.profile.values().cloned()) + .map(PhysicalAction::Write) + .collect() + } else { + Vec::new() + }; + if profile_changed { + self.applied_profile_client_id = foreground.map(|(id, _)| id); + } + let speed = foreground.map_or(SpeedMode::Pot, |(_, client)| client.desired_speed); + self.physical_pot_min_wpm = foreground + .and_then(|(_, client)| profile_pot_minimum(&client.profile)) + .unwrap_or(5); + self.refresh_pot_wpm(); + actions.push(PhysicalAction::Write(speed.command().to_vec())); + actions + } + + fn refresh_pot_wpm(&mut self) { + self.pot_wpm = self + .pot_value + .map(|offset| self.physical_pot_min_wpm.saturating_add(offset)); + } +} + +fn speed_pot_minimum(command: &[u8]) -> Option { + (command.first() == Some(&0x05)) + .then(|| command.get(1).copied()) + .flatten() +} + +fn profile_pot_minimum(profile: &BTreeMap>) -> Option { + profile + .get(&0x05) + .and_then(|command| speed_pot_minimum(command)) +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::indexing_slicing)] +mod tests { + use super::*; + + fn connected() -> BrokerCore { + let mut broker = BrokerCore::new(Duration::from_secs(30)); + broker.connect_physical(31); + assert!(broker.register_client(1, true)); + assert!(broker.register_client(2, false)); + broker + } + + fn idle() -> DeviceStatus { + DeviceStatus { + raw: 0xc0, + waiting: false, + key_down: false, + busy: false, + break_in: false, + xoff: false, + } + } + + fn busy() -> DeviceStatus { + DeviceStatus { + raw: 0xc4, + busy: true, + ..idle() + } + } + + #[test] + fn only_one_primary_client_is_allowed() { + let mut broker = BrokerCore::new(Duration::from_secs(30)); + assert!(broker.register_client(1, true)); + assert!(!broker.register_client(2, true)); + } + + #[test] + fn jobs_from_two_clients_never_interleave() { + let mut broker = connected(); + let now = Instant::now(); + let (_, first) = broker + .enqueue(1, b"CQ".to_vec(), Some(SpeedMode::Fixed(20)), false, now) + .expect("first"); + assert_eq!( + first, + vec![ + PhysicalAction::Write(vec![0x02, 20]), + PhysicalAction::Write(b"CQ".to_vec()), + PhysicalAction::Write(vec![0x15]), + ] + ); + let (_, second) = broker + .enqueue(2, b"TEST".to_vec(), Some(SpeedMode::Fixed(30)), false, now) + .expect("second"); + assert!(second.is_empty(), "second job must remain queued"); + + assert!(broker.observe_status(busy()).is_empty()); + let boundary = broker.observe_status(idle()); + assert_eq!( + boundary, + vec![ + PhysicalAction::Completed { + job_id: 1, + client_id: 1 + }, + PhysicalAction::Write(vec![0x02, 30]), + PhysicalAction::Write(b"TEST".to_vec()), + PhysicalAction::Write(vec![0x15]), + ] + ); + } + + #[test] + fn fixed_job_restores_primary_pot_mode_when_queue_drains() { + let mut broker = connected(); + broker.set_client_speed(1, SpeedMode::Pot); + let (_, _) = broker + .enqueue( + 2, + b"TEST".to_vec(), + Some(SpeedMode::Fixed(35)), + false, + Instant::now(), + ) + .expect("job"); + broker.observe_status(busy()); + let actions = broker.observe_status(idle()); + assert_eq!( + actions, + vec![ + PhysicalAction::Completed { + job_id: 1, + client_id: 2 + }, + PhysicalAction::Write(vec![0x02, 0]), + ] + ); + } + + #[test] + fn pot_notification_updates_shared_snapshot() { + let mut broker = connected(); + broker.observe_pot(27); + assert_eq!(broker.snapshot().pot_value, Some(27)); + assert_eq!(broker.snapshot().pot_wpm, Some(32)); + broker.set_client_command(1, vec![0x05, 10, 20, 0]); + assert_eq!(broker.snapshot().pot_wpm, Some(37)); + } + + #[test] + fn canceling_queued_client_does_not_clear_active_client() { + let mut broker = connected(); + broker.enqueue(1, b"CQ".to_vec(), None, false, Instant::now()); + broker.enqueue(2, b"TEST".to_vec(), None, false, Instant::now()); + assert_eq!( + broker.cancel_client(2, true), + vec![PhysicalAction::Canceled { + job_id: 2, + client_id: 2 + }] + ); + assert_eq!(broker.snapshot().active_client_id, Some(1)); + } + + #[test] + fn active_owner_abort_clears_buffer_then_starts_next_client() { + let mut broker = connected(); + broker.enqueue(1, b"CQ".to_vec(), None, false, Instant::now()); + broker.enqueue( + 2, + b"TEST".to_vec(), + Some(SpeedMode::Fixed(25)), + false, + Instant::now(), + ); + let actions = broker.cancel_client(1, true); + assert_eq!(actions[0], PhysicalAction::Write(vec![0x0a])); + assert_eq!( + actions[1], + PhysicalAction::Canceled { + job_id: 1, + client_id: 1 + } + ); + assert_eq!(actions[2], PhysicalAction::Write(vec![0x02, 25])); + assert_eq!(actions[3], PhysicalAction::Write(b"TEST".to_vec())); + assert_eq!(broker.snapshot().active_client_id, Some(2)); + } + + #[test] + fn primary_idle_clear_reaches_the_physical_fifo() { + let mut broker = connected(); + assert_eq!( + broker.cancel_client(1, true), + vec![PhysicalAction::Write(vec![0x0a])] + ); + assert!(broker.cancel_client(2, true).is_empty()); + } + + #[test] + fn cancel_job_cannot_cancel_another_clients_job() { + let mut broker = connected(); + broker.enqueue(1, b"CQ".to_vec(), None, false, Instant::now()); + let (canceled, actions) = broker.cancel_job(2, 1); + assert!(!canceled); + assert!(actions.is_empty()); + assert_eq!(broker.snapshot().active_client_id, Some(1)); + } + + #[test] + fn disconnecting_active_client_clears_and_records_safety_action() { + let mut broker = connected(); + broker.enqueue(1, b"CQ".to_vec(), None, false, Instant::now()); + let actions = broker.unregister_client(1); + assert_eq!(actions[0], PhysicalAction::Write(vec![0x0a])); + assert!(broker + .snapshot() + .last_safety_action + .expect("safety") + .contains("disconnected")); + } + + #[test] + fn watchdog_clears_key_and_all_queued_work() { + let start = Instant::now(); + let mut broker = BrokerCore::new(Duration::from_secs(1)); + broker.connect_physical(31); + broker.register_client(1, true); + broker.enqueue(1, b"CQ".to_vec(), None, false, start); + let actions = broker.watchdog(start + Duration::from_secs(2)); + assert_eq!(actions[0], PhysicalAction::Write(vec![0x0a, 0x0b, 0x00])); + assert!(!broker.snapshot().busy); + } + + #[test] + fn short_job_can_complete_after_idle_settle_without_observed_busy() { + let mut broker = connected(); + broker.enqueue(1, b"E".to_vec(), None, false, Instant::now()); + broker.observe_status(idle()); + let actions = broker.confirm_idle_after_settle( + Instant::now() + Duration::from_millis(300), + Duration::from_millis(250), + ); + assert!(matches!( + actions.first(), + Some(PhysicalAction::Completed { .. }) + )); + } + + #[test] + fn active_stream_appends_without_creating_an_interleavable_job() { + let mut broker = connected(); + let now = Instant::now(); + let (job, _) = broker + .enqueue(1, b"C".to_vec(), None, true, now) + .expect("stream"); + let (same_job, actions) = broker + .enqueue(1, b"Q".to_vec(), None, true, now) + .expect("append"); + assert_eq!(same_job, job); + assert_eq!(actions, vec![PhysicalAction::Write(b"Q".to_vec())]); + assert_eq!(broker.snapshot().queued_jobs, 0); + } + + #[test] + fn oversized_jobs_and_stream_growth_are_rejected_before_fifo_overflow() { + let mut broker = connected(); + assert!(broker + .enqueue( + 1, + vec![b'X'; MAX_JOB_BYTES + 1], + None, + false, + Instant::now(), + ) + .is_none()); + broker + .enqueue(1, vec![b'A'; MAX_JOB_BYTES - 1], None, true, Instant::now()) + .expect("stream"); + assert!(broker + .enqueue(1, b"BC".to_vec(), None, true, Instant::now()) + .is_none()); + } + + #[test] + fn per_client_profile_is_replayed_only_when_that_client_becomes_active() { + let mut broker = connected(); + assert!(broker + .set_client_command(2, vec![0x0e, 0x04]) + .expect("client") + .is_empty()); + let (_, actions) = broker + .enqueue(2, b"E".to_vec(), None, false, Instant::now()) + .expect("job"); + assert_eq!(actions[0], PhysicalAction::Write(vec![0x0e, 0x04])); + assert_eq!(actions[1], PhysicalAction::Write(vec![0x02, 0])); + assert_eq!(actions[2], PhysicalAction::Write(b"E".to_vec())); + } + + #[test] + fn already_applied_primary_profile_is_not_inserted_into_keyboard_stream() { + let mut broker = connected(); + assert_eq!( + broker + .set_client_command(1, vec![0x0e, 0x04]) + .expect("primary"), + vec![PhysicalAction::Write(vec![0x0e, 0x04])] + ); + let payload = TransmitPayload::legacy_stream(vec![0x1c, 23], b"TEST".to_vec()); + assert_eq!(payload.intended_text(), b"TEST"); + assert_eq!(payload.wire_bytes(), b"\x1c\x17TEST"); + let (_, actions) = broker + .enqueue(1, payload, None, true, Instant::now()) + .expect("keyboard stream"); + assert_eq!( + actions, + vec![ + PhysicalAction::Write(b"\x1c\x17TEST".to_vec()), + PhysicalAction::Write(vec![0x15]), + ] + ); + } + + #[test] + fn profile_change_during_active_stream_is_replayed_at_next_boundary() { + let mut broker = connected(); + broker + .enqueue(1, b"T".to_vec(), None, true, Instant::now()) + .expect("active stream"); + assert_eq!(broker.applied_profile_client_id, Some(1)); + assert!(broker + .set_client_command(1, vec![0x0e, 0x04]) + .expect("active client") + .is_empty()); + assert_eq!(broker.applied_profile_client_id, None); + + broker.observe_status(busy()); + let actions = broker.observe_status(idle()); + assert!(actions.contains(&PhysicalAction::Write(vec![0x0e, 0x04]))); + } +} diff --git a/crates/cathub/src/winkeyer/face.rs b/crates/cathub/src/winkeyer/face.rs new file mode 100644 index 0000000..5574b86 --- /dev/null +++ b/crates/cathub/src/winkeyer/face.rs @@ -0,0 +1,784 @@ +//! Virtual WinKeyer serial faces for unmodified applications such as N1MM Logger+. + +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; +use tokio::sync::broadcast::error::RecvError; + +use super::actor::{BrokerEvent, BrokerHandle}; +use super::broker::{ClientId, SpeedMode}; +use super::protocol::{command_policy, ClientItem, ClientParser, CommandPolicy}; + +const MAX_READ_CHUNK: usize = 512; +const PARTIAL_COMMAND_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(500); + +/// Permissions assigned to one virtual WinKeyer face. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +#[allow(clippy::struct_excessive_bools)] +pub(crate) struct FacePermissions { + pub(crate) status: bool, + pub(crate) send: bool, + pub(crate) control: bool, + pub(crate) ptt: bool, + pub(crate) config_write: bool, +} + +impl FacePermissions { + pub(crate) fn from_tokens(tokens: &[String]) -> Self { + let mut result = Self::default(); + for token in tokens { + match token.as_str() { + "status" => result.status = true, + "send" => result.send = true, + "control" => result.control = true, + "ptt" => result.ptt = true, + "config_write" => result.config_write = true, + _ => {} + } + } + result + } +} + +/// Serve one virtual WinKeyer client until it disconnects or attempts a forbidden +/// maintenance operation. +#[cfg(test)] +pub(crate) async fn run_face( + transport: T, + broker: BrokerHandle, + client_id: ClientId, + primary: bool, + permissions: FacePermissions, +) where + T: AsyncRead + AsyncWrite + Send + 'static, +{ + run_face_inner(transport, broker, client_id, primary, permissions, false).await; +} + +/// Serve a real serial face, where a zero-byte read means idle rather than EOF. +pub(crate) async fn run_serial_face( + transport: T, + broker: BrokerHandle, + client_id: ClientId, + primary: bool, + permissions: FacePermissions, +) where + T: AsyncRead + AsyncWrite + Send + 'static, +{ + run_face_inner(transport, broker, client_id, primary, permissions, true).await; +} + +#[allow(clippy::too_many_arguments, clippy::too_many_lines)] +async fn run_face_inner( + transport: T, + broker: BrokerHandle, + client_id: ClientId, + primary: bool, + permissions: FacePermissions, + zero_is_idle: bool, +) where + T: AsyncRead + AsyncWrite + Send + 'static, +{ + if let Err(error) = broker.register(client_id, primary).await { + tracing::error!(client_id, %error, "cannot register virtual WinKeyer face"); + return; + } + + let (mut reader, mut writer) = tokio::io::split(transport); + let mut events = broker.subscribe(); + let mut parser = ClientParser::default(); + let mut opened = false; + let mut maintenance_active = false; + let mut session_mode = SessionMode::Wk1; + let mut chunk = [0_u8; MAX_READ_CHUNK]; + let mut parser_tick = tokio::time::interval(PARTIAL_COMMAND_TIMEOUT); + let mut last_client_byte = std::time::Instant::now(); + + 'session: loop { + tokio::select! { + read = reader.read(&mut chunk) => { + let count = match read { + Ok(0) if zero_is_idle => { + tokio::time::sleep(std::time::Duration::from_millis(1)).await; + continue 'session; + } + Ok(0) => { + tracing::debug!(client_id, "virtual WinKeyer face reached EOF"); + break 'session; + } + Err(error) => { + tracing::warn!(client_id, %error, "virtual WinKeyer face read failed"); + break 'session; + } + Ok(count) => count, + }; + let bytes = chunk.get(..count).unwrap_or(&[]); + tracing::trace!(client_id, bytes = ?bytes, "WinKeyer virtual rx"); + let mut buffered_prefix = Vec::new(); + let mut intended_text = Vec::new(); + for &byte in bytes { + last_client_byte = std::time::Instant::now(); + let Some(item) = parser.push(byte) else { continue }; + match item { + ClientItem::Data(byte) => { + if opened && permissions.send { + tracing::trace!(client_id, byte, "WinKeyer virtual data byte"); + intended_text.push(byte); + } + } + ClientItem::Command(command) if command.first() == Some(&0x0a) => { + tracing::trace!(client_id, command = ?command, "WinKeyer virtual clear-buffer command"); + // Clear Buffer is immediate. Bytes in this same OS read that precede + // it have not reached hardware yet, so dropping them matches the + // client's intent without briefly keying stale text. + buffered_prefix.clear(); + intended_text.clear(); + if permissions.send { + let _ = broker.cancel(client_id, true).await; + } + } + ClientItem::Command(command) if is_buffered(&command) => { + tracing::trace!(client_id, command = ?command, "WinKeyer virtual buffered command"); + if opened && permissions.send && buffered_allowed(&command, permissions) { + if !intended_text.is_empty() + && flush_stream( + &broker, + client_id, + &mut buffered_prefix, + &mut intended_text, + ) + .await + .is_err() + { + break 'session; + } + buffered_prefix.extend(command); + } + } + ClientItem::Command(command) => { + tracing::trace!(client_id, command = ?command, "WinKeyer virtual command"); + if flush_stream( + &broker, + client_id, + &mut buffered_prefix, + &mut intended_text, + ) + .await + .is_err() + { + break 'session; + } + match handle_command( + &command, + &broker, + client_id, + permissions, + &mut opened, + &mut maintenance_active, + &mut session_mode, + &mut writer, + ).await { + CommandResult::Continue => {} + CommandResult::Close => break 'session, + } + } + } + } + if flush_stream( + &broker, + client_id, + &mut buffered_prefix, + &mut intended_text, + ) + .await + .is_err() + { + break 'session; + } + } + _ = parser_tick.tick() => { + if parser.is_partial() && last_client_byte.elapsed() >= PARTIAL_COMMAND_TIMEOUT { + tracing::warn!(client_id, "discarding timed-out partial WinKeyer command"); + parser.reset(); + } + } + event = events.recv(), if permissions.status && (opened || maintenance_active) => { + match event { + Ok(event) => { + if let Some(byte) = event_byte( + &event, + &broker, + client_id, + primary, + session_mode, + ) { + if writer.write_u8(byte).await.is_err() { + break 'session; + } + let _ = writer.flush().await; + } + } + Err(RecvError::Lagged(_)) => { + let status = synthesize_status(&broker, session_mode); + if writer.write_u8(status).await.is_err() { + break 'session; + } + if let Some(pot) = broker.snapshot().pot_value { + if writer.write_u8(0x80 | (pot & 0x3f)).await.is_err() { + break 'session; + } + } + let _ = writer.flush().await; + } + Err(RecvError::Closed) => break 'session, + } + } + } + } + + parser.reset(); + let _ = broker.release_maintenance(client_id).await; + let _ = broker.unregister(client_id).await; +} + +async fn flush_stream( + broker: &BrokerHandle, + client_id: ClientId, + buffered_prefix: &mut Vec, + intended_text: &mut Vec, +) -> Result<(), ()> { + if buffered_prefix.is_empty() && intended_text.is_empty() { + return Ok(()); + } + let control_prefix = std::mem::take(buffered_prefix); + let text = std::mem::take(intended_text); + tracing::trace!( + client_id, + intended_text = %String::from_utf8_lossy(&text), + control_prefix = ?control_prefix, + "WinKeyer virtual stream flush" + ); + broker + .stream(client_id, control_prefix, text) + .await + .map(|_| ()) + .map_err(|_| ()) +} + +enum CommandResult { + Continue, + Close, +} + +#[derive(Debug, Clone, Copy)] +enum SessionMode { + Wk1, + Wk2, + Wk3, +} + +#[allow(clippy::too_many_arguments)] +async fn handle_command( + command: &[u8], + broker: &BrokerHandle, + client_id: ClientId, + permissions: FacePermissions, + opened: &mut bool, + maintenance_active: &mut bool, + session_mode: &mut SessionMode, + writer: &mut W, +) -> CommandResult +where + W: AsyncWrite + Unpin, +{ + if command.first() == Some(&0x00) { + return handle_admin( + command, + broker, + client_id, + permissions, + opened, + maintenance_active, + session_mode, + writer, + ) + .await; + } + if !*opened { + return CommandResult::Continue; + } + + match command.first().copied() { + Some(0x02) if permissions.control => { + if let Some(speed) = command.get(1).copied().and_then(parse_speed) { + let _ = broker.set_speed(client_id, speed).await; + } + } + Some(0x07) if permissions.status => { + let byte = 0x80 | (broker.snapshot().pot_value.unwrap_or(0) & 0x3f); + if writer.write_u8(byte).await.is_err() { + return CommandResult::Close; + } + } + Some(0x15) if permissions.status => { + if writer + .write_u8(synthesize_status(broker, *session_mode)) + .await + .is_err() + { + return CommandResult::Close; + } + } + Some(_) => match command_policy(command) { + CommandPolicy::Transient if permissions.control => { + let _ = broker.configure(client_id, command.to_vec()).await; + } + CommandPolicy::ActiveOwner + if permissions.control && (command.first() != Some(&0x0b) || permissions.ptt) => + { + let _ = broker + .active_owner_command(client_id, command.to_vec()) + .await; + } + CommandPolicy::Maintenance => { + if !permissions.config_write + || broker + .maintenance_command(client_id, command.to_vec()) + .await + .is_err() + { + tracing::warn!(client_id, command = ?command, "denied WinKeyer maintenance command"); + return CommandResult::Close; + } + *maintenance_active = true; + } + _ => {} + }, + None => {} + } + let _ = writer.flush().await; + CommandResult::Continue +} + +#[allow(clippy::too_many_arguments)] +async fn handle_admin( + command: &[u8], + broker: &BrokerHandle, + client_id: ClientId, + permissions: FacePermissions, + opened: &mut bool, + maintenance_active: &mut bool, + session_mode: &mut SessionMode, + writer: &mut W, +) -> CommandResult +where + W: AsyncWrite + Unpin, +{ + let Some(subcommand) = command.get(1).copied() else { + return CommandResult::Continue; + }; + match subcommand { + 0x02 if permissions.status => { + *opened = true; + *session_mode = SessionMode::Wk1; + let revision = broker.snapshot().firmware_revision.unwrap_or(0xff); + if writer.write_u8(revision).await.is_err() { + return CommandResult::Close; + } + } + 0x03 => { + *opened = false; + let _ = broker.release_maintenance(client_id).await; + *maintenance_active = false; + } + 0x04 if permissions.status => { + if let Some(value) = command.get(2) { + if writer.write_u8(*value).await.is_err() { + return CommandResult::Close; + } + } + } + 0x05..=0x07 | 0x15 | 0x17..=0x18 if permissions.status => { + if writer.write_u8(0).await.is_err() { + return CommandResult::Close; + } + } + 0x09 if permissions.status => { + if writer + .write_u8(broker.snapshot().firmware_revision.unwrap_or(0xff)) + .await + .is_err() + { + return CommandResult::Close; + } + } + 0x0a if permissions.control => *session_mode = SessionMode::Wk1, + 0x0b if permissions.control => *session_mode = SessionMode::Wk2, + 0x14 if permissions.control => *session_mode = SessionMode::Wk3, + 0x0f | 0x13 | 0x16 | 0x19 if permissions.control => { + let _ = broker.configure(client_id, command.to_vec()).await; + } + _ => { + if !permissions.config_write + || broker + .maintenance_command(client_id, command.to_vec()) + .await + .is_err() + { + tracing::warn!(command = ?command, "virtual face attempted exclusive WinKeyer admin operation"); + return CommandResult::Close; + } + *maintenance_active = true; + } + } + let _ = writer.flush().await; + CommandResult::Continue +} + +fn parse_speed(value: u8) -> Option { + match value { + 0 => Some(SpeedMode::Pot), + 5..=99 => Some(SpeedMode::Fixed(value)), + _ => None, + } +} + +fn is_buffered(command: &[u8]) -> bool { + command + .first() + .is_some_and(|opcode| (0x18..=0x1f).contains(opcode)) +} + +fn buffered_allowed(command: &[u8], permissions: FacePermissions) -> bool { + !matches!(command.first(), Some(0x18 | 0x19)) || permissions.ptt +} + +fn synthesize_status(broker: &BrokerHandle, session_mode: SessionMode) -> u8 { + let snapshot = broker.snapshot(); + 0xc0 | match session_mode { + SessionMode::Wk1 => u8::from(snapshot.key_down) << 3, + SessionMode::Wk2 | SessionMode::Wk3 => 0, + } | u8::from(snapshot.busy) << 2 + | u8::from(snapshot.break_in) << 1 +} + +fn event_byte( + event: &BrokerEvent, + broker: &BrokerHandle, + client_id: ClientId, + primary: bool, + session_mode: SessionMode, +) -> Option { + match event { + BrokerEvent::SpeedPot { raw, .. } => Some(*raw), + BrokerEvent::Status { .. } => Some(synthesize_status(broker, session_mode)), + BrokerEvent::Echo(byte) => { + let snapshot = broker.snapshot(); + (snapshot.active_client_id == Some(client_id) || (primary && snapshot.break_in)) + .then_some(*byte) + } + BrokerEvent::MaintenanceByte { + client_id: owner, + byte, + } => (*owner == client_id).then_some(*byte), + _ => None, + } +} + +/// Open the hub side of a virtual WinKeyer pair as 8-N-2. +pub(crate) fn open_serial_face( + port_name: &str, + baud: u32, +) -> std::io::Result { + serial2_tokio::SerialPort::open(port_name, move |mut settings: serial2_tokio::Settings| { + settings.set_raw(); + settings.set_baud_rate(baud)?; + settings.set_char_size(serial2_tokio::CharSize::Bits8); + settings.set_parity(serial2_tokio::Parity::None); + settings.set_stop_bits(serial2_tokio::StopBits::Two); + settings.set_flow_control(serial2_tokio::FlowControl::None); + Ok(settings) + }) +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::indexing_slicing)] +mod tests { + use super::*; + use std::time::Duration; + use tokio::io::DuplexStream; + + async fn setup() -> (BrokerHandle, DuplexStream, DuplexStream) { + setup_with_permissions(FacePermissions { + status: true, + send: true, + control: true, + ptt: true, + config_write: false, + }) + .await + } + + async fn setup_with_permissions( + permissions: FacePermissions, + ) -> (BrokerHandle, DuplexStream, DuplexStream) { + let (mut device, physical) = tokio::io::duplex(4096); + let broker_task = tokio::spawn(async move { + super::super::actor::spawn(physical, Duration::from_secs(30)).await + }); + let mut host_open = [0_u8; 2]; + device.read_exact(&mut host_open).await.expect("open"); + device.write_u8(31).await.expect("revision"); + let broker = broker_task.await.expect("task").expect("broker"); + let mut initialization = [0_u8; 7]; + device.read_exact(&mut initialization).await.expect("init"); + + let (client, face) = tokio::io::duplex(4096); + tokio::spawn(run_face(face, broker.clone(), 42, true, permissions)); + (broker, client, device) + } + + #[tokio::test] + async fn host_open_is_virtualized_without_reopening_physical_keyer() { + let (_broker, mut client, mut device) = setup().await; + client.write_all(&[0x00, 0x02]).await.expect("open"); + assert_eq!(client.read_u8().await.expect("revision"), 31); + let mut byte = [0_u8; 1]; + assert!( + tokio::time::timeout(Duration::from_millis(50), device.read(&mut byte)) + .await + .is_err() + ); + } + + #[tokio::test] + async fn fixed_speed_and_text_flow_through_the_broker() { + let (_broker, mut client, mut device) = setup().await; + client.write_all(&[0x00, 0x02]).await.expect("open"); + let _ = client.read_u8().await.expect("revision"); + client + .write_all(&[0x02, 25, b'T', b'E']) + .await + .expect("send"); + + let mut bytes = [0_u8; 9]; + device + .read_exact(&mut bytes) + .await + .expect("physical writes"); + assert!(bytes.windows(2).any(|window| window == [0x02, 25])); + assert!(bytes.windows(2).any(|window| window == b"TE")); + } + + #[tokio::test] + async fn n1mm_buffer_pointer_commands_are_not_replayed_before_keyboard_text() { + let (_broker, mut client, mut device) = setup().await; + client.write_all(&[0x00, 0x02]).await.expect("Host Open"); + let _ = client.read_u8().await.expect("revision"); + + // Captured from N1MM when Ctrl+K opens: clear, pointer start, pointer move, + // then its buffered-speed command. The pointer operations must reach the keyer + // exactly once and must not become persistent profile entries. + client + .write_all(&[0x0a, 0x16, 0x00, 0x16, 0x02, 0x00, 0x1c, 23]) + .await + .expect("N1MM keyboard CW prefix"); + + let mut prefix = [0_u8; 9]; + device + .read_exact(&mut prefix) + .await + .expect("physical keyboard prefix"); + assert_eq!(prefix, [0x0a, 0x16, 0x00, 0x16, 0x02, 0x00, 0x1c, 23, 0x15]); + + // Let the speed-only stream reach its idle boundary, as it did in the live trace, + // then type one T. A replayed 0x16 here was the corruption regression. + device.write_u8(0xc0).await.expect("idle status"); + tokio::time::sleep(Duration::from_millis(150)).await; + let mut pending = [0_u8; 16]; + // Drain any pending buffered data with a bounded timeout to avoid spinning forever. + let drain_start = tokio::time::Instant::now(); + while drain_start.elapsed() < Duration::from_millis(100) { + match tokio::time::timeout(Duration::from_millis(10), device.read(&mut pending)).await { + Ok(Ok(_)) => {} + _ => break, + } + } + client.write_u8(b'T').await.expect("keyboard T"); + + // A foreground-speed restore can race with this new byte at the idle boundary. + // The regression contract is that no buffered-pointer command is replayed before + // the keyboard text, regardless of which side of that boundary accepts the T. + let mut before_text = Vec::new(); + let mut saw_text = false; + for _ in 0..8 { + let byte = tokio::time::timeout(Duration::from_secs(1), device.read_u8()) + .await + .expect("physical keyboard text timed out") + .expect("physical keyboard text"); + if byte == b'T' { + saw_text = true; + break; + } + before_text.push(byte); + } + assert!(saw_text, "keyboard text was not forwarded: {before_text:?}"); + assert!( + !before_text.contains(&0x16), + "buffer pointer was replayed before keyboard text: {before_text:?}" + ); + } + + #[tokio::test] + async fn n1mm_function_key_message_reaches_physical_keyer_byte_for_byte() { + let (_broker, mut client, mut device) = setup().await; + client.write_all(&[0x00, 0x02]).await.expect("Host Open"); + let _ = client.read_u8().await.expect("revision"); + + let message = b"TEST DE KC7AVA"; + let mut captured = vec![0x0a, 0x16, 0x00, 0x16, 0x02, 0x00, 0x1c, 23]; + captured.extend_from_slice(message); + client + .write_all(&captured) + .await + .expect("N1MM F-key message"); + + let mut expected = captured; + expected.push(0x15); // Broker status poll after the complete message. + let mut physical = vec![0_u8; expected.len()]; + device + .read_exact(&mut physical) + .await + .expect("physical F-key message"); + assert_eq!(physical, expected); + } + + #[tokio::test] + async fn n1mm_golden_startup_send_abort_and_close_transcript() { + let (_broker, mut client, mut device) = setup().await; + client.write_all(&[0x00, 0x02]).await.expect("Host Open"); + assert_eq!(client.read_u8().await.expect("revision"), 31); + + let mut defaults = vec![0x0f]; + defaults.extend([25, 50, 0, 0, 10, 20, 0, 0, 0, 50, 0, 0, 0, 0, 0]); + let mut startup = vec![0x00, 0x0b]; // N1MM selects WK2 logical status mode. + startup.extend(defaults.clone()); + startup.extend([0x05, 10, 20, 0, 0x02, 0, 0x07, 0x15]); + client.write_all(&startup).await.expect("N1MM startup"); + + let mut startup_writes = vec![0_u8; 22]; + device + .read_exact(&mut startup_writes) + .await + .expect("transient startup writes"); + let mut expected = defaults.clone(); + expected.extend([0x05, 10, 20, 0, 0x02, 0]); + assert_eq!(startup_writes, expected); + assert_eq!(client.read_u8().await.expect("pot reply"), 0x80); + assert_eq!(client.read_u8().await.expect("status reply"), 0xc0); + + client + .write_all(&[ + 0x16, 0x00, 0x16, 0x02, 0x00, 0x1c, 22, b'T', b'E', b'S', b'T', + ]) + .await + .expect("N1MM buffered speed and text"); + let mut job = vec![0_u8; 12]; + device + .read_exact(&mut job) + .await + .expect("atomic physical job"); + assert_eq!(&job[..5], &[0x16, 0x00, 0x16, 0x02, 0x00]); + assert_eq!(&job[5..7], &[0x1c, 22]); + assert_eq!(&job[7..11], b"TEST"); + assert_eq!(job[11], 0x15); + + client.write_u8(0x0a).await.expect("N1MM Escape"); + let mut abort = vec![0_u8; 3]; + device.read_exact(&mut abort).await.expect("scoped abort"); + assert_eq!(abort[0], 0x0a); + assert_eq!(&abort[1..3], &[0x02, 0]); + + client.write_all(&[0x00, 0x03]).await.expect("Host Close"); + let mut unexpected = [0_u8; 1]; + assert!( + tokio::time::timeout(Duration::from_millis(50), device.read(&mut unexpected)) + .await + .is_err() + ); + } + + #[tokio::test] + async fn physical_pot_event_is_fanned_to_open_virtual_session() { + let (_broker, mut client, mut device) = setup().await; + client.write_all(&[0x00, 0x02]).await.expect("open"); + let _ = client.read_u8().await.expect("revision"); + device.write_u8(0x9b).await.expect("pot"); + assert_eq!(client.read_u8().await.expect("pot event"), 0x9b); + } + + #[tokio::test] + async fn maintenance_command_fails_closed_and_does_not_reach_device() { + let (_broker, mut client, mut device) = setup().await; + client.write_all(&[0x00, 0x02]).await.expect("open"); + let _ = client.read_u8().await.expect("revision"); + client.write_all(&[0x00, 0x01]).await.expect("reset"); + let mut ignored = Vec::new(); + tokio::time::timeout(Duration::from_secs(1), client.read_to_end(&mut ignored)) + .await + .expect("face closes") + .expect("read"); + let mut byte = [0_u8; 1]; + assert!( + tokio::time::timeout(Duration::from_millis(50), device.read(&mut byte)) + .await + .is_err() + ); + } + + #[tokio::test] + async fn permitted_maintenance_is_exclusive_and_private_until_virtual_close() { + let (_broker, mut client, mut device) = setup_with_permissions(FacePermissions { + status: true, + send: true, + control: true, + ptt: true, + config_write: true, + }) + .await; + + client.write_all(&[0x00, 0x0c]).await.expect("EEPROM dump"); + let mut physical = [0_u8; 6]; + device + .read_exact(&mut physical) + .await + .expect("safe close and dump command"); + assert_eq!(physical, [0x0a, 0x0b, 0x00, 0x03, 0x00, 0x0c]); + device + .write_all(&[0x80, 0xc0, 0x42]) + .await + .expect("dump bytes"); + let mut private = [0_u8; 3]; + client + .read_exact(&mut private) + .await + .expect("private maintenance response"); + assert_eq!(private, [0x80, 0xc0, 0x42]); + + client + .write_all(&[0x00, 0x03]) + .await + .expect("virtual close"); + let mut reopen = [0_u8; 2]; + device + .read_exact(&mut reopen) + .await + .expect("physical reopen"); + assert_eq!(reopen, [0x00, 0x02]); + device.write_u8(31).await.expect("firmware"); + let mut restore = [0_u8; 9]; + device.read_exact(&mut restore).await.expect("safe restore"); + assert_eq!( + restore, + [0x0a, 0x0b, 0x00, 0x02, 0x00, 0x07, 0x15, 0x02, 0x00] + ); + } +} diff --git a/crates/cathub/src/winkeyer/grpc.rs b/crates/cathub/src/winkeyer/grpc.rs new file mode 100644 index 0000000..e25853f --- /dev/null +++ b/crates/cathub/src/winkeyer/grpc.rs @@ -0,0 +1,449 @@ +//! Loopback gRPC surface for typed QsoRipper WinKeyer clients. + +use std::collections::BTreeMap; +use std::net::SocketAddr; +use std::pin::Pin; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; + +use tokio::sync::Mutex; +use tokio_stream::wrappers::BroadcastStream; +use tokio_stream::wrappers::TcpListenerStream; +use tokio_stream::{Stream, StreamExt}; +use tonic::{Request, Response, Status}; + +use super::actor::{BrokerError, BrokerEvent, BrokerHandle}; +use super::broker::{BrokerSnapshot, ClientId, SpeedMode, MAX_JOB_BYTES, MAX_QUEUED_JOBS}; + +use crate::broker_proto::winkeyer_broker_service_server::{ + WinkeyerBrokerService, WinkeyerBrokerServiceServer, +}; +use crate::broker_proto::{ + WinkeyerBrokerEventKind, WinkeyerBrokerServiceAbortClientRequest, + WinkeyerBrokerServiceAbortClientResponse, WinkeyerBrokerServiceCancelJobRequest, + WinkeyerBrokerServiceCancelJobResponse, WinkeyerBrokerServiceGetStatusRequest, + WinkeyerBrokerServiceGetStatusResponse, WinkeyerBrokerServiceSendTextRequest, + WinkeyerBrokerServiceSendTextResponse, WinkeyerBrokerServiceSetSpeedRequest, + WinkeyerBrokerServiceSetSpeedResponse, WinkeyerBrokerServiceStreamEventsRequest, + WinkeyerBrokerServiceStreamEventsResponse, WinkeyerBrokerStatus, WinkeyerSpeedMode, +}; + +const FIRST_TYPED_CLIENT_ID: u64 = 1_000_000; +const MAX_TYPED_CLIENTS: usize = 64; + +#[derive(Clone)] +struct Service { + broker: BrokerHandle, + clients: Arc>>, + next_client: Arc, +} + +impl Service { + fn new(broker: BrokerHandle) -> Self { + Self { + broker, + clients: Arc::new(Mutex::new(BTreeMap::new())), + next_client: Arc::new(AtomicU64::new(FIRST_TYPED_CLIENT_ID)), + } + } + + async fn client_id(&self, name: &str) -> Result { + let name = name.trim(); + if name.is_empty() || name.len() > 64 { + return Err(Status::invalid_argument( + "client_name must contain 1 through 64 characters", + )); + } + let mut clients = self.clients.lock().await; + if let Some(id) = clients.get(name) { + return Ok(*id); + } + if clients.len() >= MAX_TYPED_CLIENTS { + return Err(Status::resource_exhausted( + "maximum number of named WinKeyer clients reached", + )); + } + let id = self.next_client.fetch_add(1, Ordering::SeqCst); + self.broker.register(id, false).await.map_err(map_error)?; + clients.insert(name.to_string(), id); + Ok(id) + } +} + +type EventStream = + Pin> + Send>>; + +#[tonic::async_trait] +impl WinkeyerBrokerService for Service { + type StreamEventsStream = EventStream; + + async fn get_status( + &self, + request: Request, + ) -> Result, Status> { + self.client_id(&request.get_ref().client_name).await?; + Ok(Response::new(WinkeyerBrokerServiceGetStatusResponse { + status: Some(status_message(&self.broker.snapshot())), + })) + } + + async fn send_text( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let client_id = self.client_id(&request.client_name).await?; + let text = validate_text(&request.text)?; + let speed = optional_speed(request.speed_mode, request.speed_wpm)?; + let job_id = self + .broker + .enqueue(client_id, text, speed) + .await + .map_err(map_error)?; + Ok(Response::new(WinkeyerBrokerServiceSendTextResponse { + job_id, + })) + } + + async fn cancel_job( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let client_id = self.client_id(&request.client_name).await?; + let canceled = self + .broker + .cancel_job(client_id, request.job_id) + .await + .map_err(map_error)?; + Ok(Response::new(WinkeyerBrokerServiceCancelJobResponse { + canceled, + })) + } + + async fn abort_client( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let client_id = self.client_id(&request.client_name).await?; + if request.emergency_station_stop { + self.broker + .emergency_stop(format!( + "emergency stop requested by {}", + request.client_name + )) + .await + .map_err(map_error)?; + } else { + self.broker + .cancel(client_id, true) + .await + .map_err(map_error)?; + } + Ok(Response::new(WinkeyerBrokerServiceAbortClientResponse { + accepted: true, + })) + } + + async fn set_speed( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let client_id = self.client_id(&request.client_name).await?; + let speed = required_speed(request.speed_mode, request.speed_wpm)?; + self.broker + .set_speed(client_id, speed) + .await + .map_err(map_error)?; + let (speed_mode, speed_wpm) = speed_fields(speed); + Ok(Response::new(WinkeyerBrokerServiceSetSpeedResponse { + speed_mode, + speed_wpm, + })) + } + + async fn stream_events( + &self, + request: Request, + ) -> Result, Status> { + self.client_id(&request.get_ref().client_name).await?; + let broker = self.broker.clone(); + let stream = BroadcastStream::new(self.broker.subscribe()).filter_map(move |event| { + let broker = broker.clone(); + match event { + Ok(BrokerEvent::MaintenanceByte { .. } | BrokerEvent::Echo(_)) => None, + Ok(event) => Some(Ok(event_message(&event, &broker.snapshot()))), + Err(_) => Some(Ok(WinkeyerBrokerServiceStreamEventsResponse { + kind: WinkeyerBrokerEventKind::Status as i32, + status: Some(status_message(&broker.snapshot())), + message: Some("event receiver lagged; status resynchronized".to_string()), + ..Default::default() + })), + } + }); + Ok(Response::new(Box::pin(stream))) + } +} + +#[cfg(test)] +pub(crate) async fn run_server( + bind: SocketAddr, + broker: BrokerHandle, +) -> Result<(), tonic::transport::Error> { + tonic::transport::Server::builder() + .add_service(WinkeyerBrokerServiceServer::new(Service::new(broker))) + .serve(bind) + .await +} + +/// Bind the loopback endpoint before returning so daemon startup fails loudly on conflicts. +pub(crate) async fn bind_server( + bind: SocketAddr, + broker: BrokerHandle, +) -> std::io::Result>> { + let listener = tokio::net::TcpListener::bind(bind).await?; + Ok(tokio::spawn(async move { + tonic::transport::Server::builder() + .add_service(WinkeyerBrokerServiceServer::new(Service::new(broker))) + .serve_with_incoming(TcpListenerStream::new(listener)) + .await + })) +} + +#[allow(clippy::result_large_err)] +fn validate_text(text: &str) -> Result, Status> { + if text.is_empty() { + return Err(Status::invalid_argument("text must not be empty")); + } + if text.len() > MAX_JOB_BYTES { + return Err(Status::invalid_argument(format!( + "text must not exceed {MAX_JOB_BYTES} ASCII bytes" + ))); + } + if let Some(character) = text.chars().find(|character| { + !character.is_ascii() || (character.is_ascii_control() && *character != '\t') + }) { + return Err(Status::invalid_argument(format!( + "text contains unsupported character {character:?}" + ))); + } + Ok(text.to_ascii_uppercase().into_bytes()) +} + +#[allow(clippy::result_large_err)] +fn optional_speed(mode: i32, speed_wpm: Option) -> Result, Status> { + if WinkeyerSpeedMode::try_from(mode).unwrap_or_default() == WinkeyerSpeedMode::Unspecified { + if speed_wpm.is_none() { + return Ok(None); + } + return required_speed(WinkeyerSpeedMode::Fixed as i32, speed_wpm).map(Some); + } + required_speed(mode, speed_wpm).map(Some) +} + +#[allow(clippy::result_large_err)] +fn required_speed(mode: i32, speed_wpm: Option) -> Result { + match WinkeyerSpeedMode::try_from(mode).unwrap_or_default() { + WinkeyerSpeedMode::Pot => Ok(SpeedMode::Pot), + WinkeyerSpeedMode::Fixed => { + let speed = speed_wpm + .ok_or_else(|| Status::invalid_argument("fixed speed mode requires speed_wpm"))?; + let speed = u8::try_from(speed) + .ok() + .filter(|speed| (5..=99).contains(speed)) + .ok_or_else(|| Status::invalid_argument("speed_wpm must be between 5 and 99"))?; + Ok(SpeedMode::Fixed(speed)) + } + WinkeyerSpeedMode::Unspecified => { + Err(Status::invalid_argument("speed_mode must be POT or FIXED")) + } + } +} + +fn speed_fields(speed: SpeedMode) -> (i32, Option) { + match speed { + SpeedMode::Pot => (WinkeyerSpeedMode::Pot as i32, None), + SpeedMode::Fixed(wpm) => (WinkeyerSpeedMode::Fixed as i32, Some(u32::from(wpm))), + } +} + +fn status_message(snapshot: &BrokerSnapshot) -> WinkeyerBrokerStatus { + WinkeyerBrokerStatus { + connected: snapshot.connected, + firmware_revision: snapshot.firmware_revision.map(u32::from), + busy: snapshot.busy, + break_in: snapshot.break_in, + key_down: snapshot.key_down, + pot_wpm: snapshot.pot_wpm.map(u32::from), + active_client_id: snapshot.active_client_id, + active_job_id: snapshot.active_job_id, + queued_jobs: u32::try_from(snapshot.queued_jobs).unwrap_or(u32::MAX), + last_error: snapshot.last_error.clone(), + last_safety_action: snapshot.last_safety_action.clone(), + max_job_bytes: u32::try_from(MAX_JOB_BYTES).unwrap_or(u32::MAX), + supports_speed_pot: true, + supports_scoped_cancel: true, + max_queued_jobs: u32::try_from(MAX_QUEUED_JOBS).unwrap_or(u32::MAX), + } +} + +fn event_message( + event: &BrokerEvent, + snapshot: &BrokerSnapshot, +) -> WinkeyerBrokerServiceStreamEventsResponse { + let mut response = WinkeyerBrokerServiceStreamEventsResponse { + status: Some(status_message(snapshot)), + ..Default::default() + }; + match event { + BrokerEvent::Connected { firmware_revision } => { + response.kind = WinkeyerBrokerEventKind::Connected as i32; + response.raw_byte = Some(u32::from(*firmware_revision)); + } + BrokerEvent::SpeedPot { raw, wpm, .. } => { + response.kind = WinkeyerBrokerEventKind::SpeedPot as i32; + response.raw_byte = Some(u32::from(*raw)); + response.speed_wpm = Some(u32::from(*wpm)); + } + BrokerEvent::Status { raw } => { + response.kind = WinkeyerBrokerEventKind::Status as i32; + response.raw_byte = Some(u32::from(*raw)); + } + BrokerEvent::Echo(byte) => { + response.kind = WinkeyerBrokerEventKind::Echo as i32; + response.raw_byte = Some(u32::from(*byte)); + } + BrokerEvent::Completed { job_id, client_id } => { + response.kind = WinkeyerBrokerEventKind::JobCompleted as i32; + response.job_id = Some(*job_id); + response.client_id = Some(*client_id); + } + BrokerEvent::Canceled { job_id, client_id } => { + response.kind = WinkeyerBrokerEventKind::JobCanceled as i32; + response.job_id = Some(*job_id); + response.client_id = Some(*client_id); + } + BrokerEvent::Error(message) => { + response.kind = WinkeyerBrokerEventKind::Error as i32; + response.message = Some(message.clone()); + } + BrokerEvent::MaintenanceByte { .. } => { + debug_assert!( + false, + "private maintenance bytes must not reach typed streams" + ); + } + } + response +} + +#[allow(clippy::needless_pass_by_value)] +fn map_error(error: BrokerError) -> Status { + match error { + BrokerError::UnknownClient + | BrokerError::Invalid(_) + | BrokerError::TransmitBusy + | BrokerError::MaintenanceBusy => Status::failed_precondition(error.to_string()), + BrokerError::PrimaryExists => Status::already_exists(error.to_string()), + BrokerError::QueueFull => Status::resource_exhausted(error.to_string()), + BrokerError::NotConnected | BrokerError::Unavailable | BrokerError::Transport(_) => { + Status::unavailable(error.to_string()) + } + } +} + +#[cfg(test)] +#[allow(clippy::expect_used)] +mod tests { + use super::*; + use std::time::Duration; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + #[test] + fn speed_validation_distinguishes_pot_fixed_and_unspecified() { + assert_eq!( + required_speed(WinkeyerSpeedMode::Pot as i32, None).expect("pot"), + SpeedMode::Pot + ); + assert_eq!( + required_speed(WinkeyerSpeedMode::Fixed as i32, Some(25)).expect("fixed"), + SpeedMode::Fixed(25) + ); + assert!(required_speed(WinkeyerSpeedMode::Fixed as i32, Some(100)).is_err()); + assert_eq!(optional_speed(0, None).expect("optional"), None); + } + + #[test] + fn typed_text_is_uppercase_ascii_and_rejects_control_data() { + assert_eq!(validate_text("cq test").expect("text"), b"CQ TEST".to_vec()); + assert!(validate_text("").is_err()); + assert!(validate_text("CQ\nTEST").is_err()); + assert!(validate_text("CQ é").is_err()); + } + + #[tokio::test] + async fn grpc_surface_reports_and_queues_against_physical_actor() { + let (mut device, physical) = tokio::io::duplex(4096); + let actor_task = tokio::spawn(async move { + super::super::actor::spawn(physical, Duration::from_secs(30)).await + }); + let mut host_open = [0_u8; 2]; + device.read_exact(&mut host_open).await.expect("host open"); + device.write_u8(31).await.expect("revision"); + let broker = actor_task.await.expect("actor task").expect("actor"); + let mut initialization = [0_u8; 7]; + device + .read_exact(&mut initialization) + .await + .expect("initialization"); + + let reserved = std::net::TcpListener::bind("127.0.0.1:0").expect("reserve port"); + let address = reserved.local_addr().expect("address"); + drop(reserved); + let server = tokio::spawn(run_server(address, broker)); + let endpoint = format!("http://{address}"); + let mut client = loop { + match crate::broker_proto::winkeyer_broker_service_client::WinkeyerBrokerServiceClient::connect( + endpoint.clone(), + ) + .await + { + Ok(client) => break client, + Err(_) => tokio::time::sleep(Duration::from_millis(10)).await, + } + }; + + let status = client + .get_status(WinkeyerBrokerServiceGetStatusRequest { + client_name: "integration".to_string(), + }) + .await + .expect("status") + .into_inner() + .status + .expect("payload"); + assert!(status.connected); + assert_eq!(status.firmware_revision, Some(31)); + + let response = client + .send_text(WinkeyerBrokerServiceSendTextRequest { + client_name: "integration".to_string(), + text: "test".to_string(), + speed_mode: WinkeyerSpeedMode::Fixed as i32, + speed_wpm: Some(21), + }) + .await + .expect("send") + .into_inner(); + assert_eq!(response.job_id, 1); + let mut physical_write = [0_u8; 7]; + device + .read_exact(&mut physical_write) + .await + .expect("physical write"); + assert_eq!(physical_write, [0x02, 21, b'T', b'E', b'S', b'T', 0x15]); + server.abort(); + } +} diff --git a/crates/cathub/src/winkeyer/mod.rs b/crates/cathub/src/winkeyer/mod.rs new file mode 100644 index 0000000..71629ae --- /dev/null +++ b/crates/cathub/src/winkeyer/mod.rs @@ -0,0 +1,15 @@ +//! Multi-client WinKeyer brokering. +//! +//! WinKeyer is a stateful byte protocol, not a delimiter-framed CAT dialect. This module +//! owns its parser, physical-device events, scheduling, and virtual client sessions rather +//! than routing keyer bytes through the radio backend. + +mod actor; +mod broker; +mod face; +mod grpc; +mod protocol; + +pub(crate) use actor::{spawn_supervised, BrokerHandle}; +pub(crate) use face::{open_serial_face, run_serial_face, FacePermissions}; +pub(crate) use grpc::bind_server; diff --git a/crates/cathub/src/winkeyer/protocol.rs b/crates/cathub/src/winkeyer/protocol.rs new file mode 100644 index 0000000..729dd43 --- /dev/null +++ b/crates/cathub/src/winkeyer/protocol.rs @@ -0,0 +1,320 @@ +//! WinKeyer 3 host-protocol framing and device-event classification. +//! +//! Commands occupy byte values `0x00..=0x1f`; printable bytes are Morse data. Command +//! lengths are defined by the K1EL WinKeyer 3.1 interface manual. The parser retains a +//! partial command across serial reads and emits data immediately so a virtual face can +//! preserve the client's byte order without using timing to distinguish commands. + +/// Maximum bytes in one WinKeyer command, including a 256-byte EEPROM payload. +const MAX_COMMAND_LEN: usize = 258; + +/// One complete item received from a WinKeyer host client. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ClientItem { + /// One printable/prosign byte to place in the transmit stream. + Data(u8), + /// One complete immediate or buffered command, including its parameters. + Command(Vec), +} + +/// Incremental parser for bytes sent by a host application. +#[derive(Debug, Default)] +pub(crate) struct ClientParser { + command: Vec, + expected_len: Option, +} + +impl ClientParser { + /// Feed one byte and return an item when it becomes complete. + pub(crate) fn push(&mut self, byte: u8) -> Option { + if self.command.is_empty() { + if byte > 0x1f { + return Some(ClientItem::Data(byte)); + } + self.command.push(byte); + self.expected_len = initial_command_len(byte); + } else { + self.command.push(byte); + self.refine_variable_length(); + } + + if self.expected_len == Some(self.command.len()) { + self.expected_len = None; + let command = std::mem::take(&mut self.command); + return Some(ClientItem::Command(command)); + } + None + } + + /// Discard an incomplete command after a client disconnect or malformed stream. + pub(crate) fn reset(&mut self) { + self.command.clear(); + self.expected_len = None; + } + + pub(crate) fn is_partial(&self) -> bool { + !self.command.is_empty() + } + + fn refine_variable_length(&mut self) { + let Some(&opcode) = self.command.first() else { + return; + }; + if opcode == 0x00 && self.command.len() == 2 { + if let Some(admin) = self.command.get(1).copied() { + self.expected_len = Some(if admin == 0x1c { + // N1MM's extra Admin prefix plus buffered-speed command and WPM. + 3 + } else { + admin_command_len(admin) + }); + } + } else if opcode == 0x16 && self.command.len() == 2 { + // Pointer reset (00) is complete in two bytes. Move-overwrite (01), + // move-append (02), and add-nulls (03) each carry a third position/count byte. + if matches!(self.command.get(1), Some(0x01..=0x03)) { + self.expected_len = Some(3); + } + } + debug_assert!( + self.expected_len.unwrap_or(MAX_COMMAND_LEN) <= MAX_COMMAND_LEN, + "WinKeyer command length must remain bounded" + ); + } +} + +fn initial_command_len(opcode: u8) -> Option { + Some(match opcode { + // Admin and Pointer are refined after their subcommand arrives. + 0x00..=0x03 + | 0x06 + | 0x09 + | 0x0b..=0x0e + | 0x10..=0x12 + | 0x14 + | 0x16..=0x1a + | 0x1c..=0x1d => 2, + 0x04 | 0x1b => 3, + 0x05 => 4, + 0x07..=0x08 | 0x0a | 0x13 | 0x15 | 0x1e..=0x1f => 1, + 0x0f => 16, + _ => return None, + }) +} + +fn admin_command_len(subcommand: u8) -> usize { + match subcommand { + 0x00 | 0x04 | 0x0e..=0x0f | 0x16 | 0x19 => 3, + 0x0d => 258, + 0x13 => 4, + _ => 2, + } +} + +/// A byte received from the physical WinKeyer, classified by its tag bits. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum DeviceEvent { + /// Speed-pot notification. The low six bits carry the reported speed value. + SpeedPot { raw: u8, value: u8 }, + /// WinKeyer status notification or requested status reply. + Status(DeviceStatus), + /// Serial or paddle echo byte. + Echo(u8), +} + +impl DeviceEvent { + /// Classify one byte from the physical keyer's transmit stream. + pub(crate) fn from_byte(byte: u8) -> Self { + match byte & 0xc0 { + 0x80 => Self::SpeedPot { + raw: byte, + value: byte & 0x3f, + }, + 0xc0 => Self::Status(DeviceStatus::from_byte(byte)), + _ => Self::Echo(byte), + } + } +} + +/// Decoded common bits of a WinKeyer status byte. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[allow(clippy::struct_excessive_bools)] +pub(crate) struct DeviceStatus { + /// Original byte, retained for protocol-compatible virtual faces. + pub(crate) raw: u8, + /// Timed wait in progress. + pub(crate) waiting: bool, + /// Key output asserted by tune/key-immediate. + pub(crate) key_down: bool, + /// Morse is being sent or remains buffered. + pub(crate) busy: bool, + /// Physical paddle break-in is active. + pub(crate) break_in: bool, + /// Input buffer is over two-thirds full. + pub(crate) xoff: bool, +} + +impl DeviceStatus { + fn from_byte(raw: u8) -> Self { + Self { + raw, + waiting: raw & 0x10 != 0, + key_down: raw & 0x08 != 0, + busy: raw & 0x04 != 0, + break_in: raw & 0x02 != 0, + xoff: raw & 0x01 != 0, + } + } +} + +/// Operations which must not be forwarded from an ordinary virtual session. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CommandPolicy { + /// The broker answers this command without touching the physical host lifecycle. + Virtualized, + /// Safe transient command, scoped to the client or its active transmit stream. + Transient, + /// Global buffer/keying operation requiring active-owner arbitration. + ActiveOwner, + /// Persistent or disruptive operation requiring an exclusive maintenance lease. + Maintenance, +} + +/// Classify a complete command for permission and ownership enforcement. +pub(crate) fn command_policy(command: &[u8]) -> CommandPolicy { + let Some(&opcode) = command.first() else { + return CommandPolicy::Maintenance; + }; + if opcode == 0x00 { + return match command.get(1).copied() { + Some(0x02..=0x07 | 0x09..=0x0b | 0x14..=0x15 | 0x17..=0x18) => { + CommandPolicy::Virtualized + } + _ => CommandPolicy::Maintenance, + }; + } + match opcode { + // Buffer-pointer commands are one-shot edits of the current stream. Persisting + // opcode 0x16 in a per-client profile and replaying it before later text moves the + // physical pointer a second time, corrupting keyboard CW from N1MM. + 0x0a..=0x0b | 0x14 | 0x16 | 0x18..=0x1a => CommandPolicy::ActiveOwner, + _ => CommandPolicy::Transient, + } +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::indexing_slicing)] +mod tests { + use super::*; + + fn parse(bytes: &[u8]) -> Vec { + let mut parser = ClientParser::default(); + bytes.iter().filter_map(|byte| parser.push(*byte)).collect() + } + + #[test] + fn printable_bytes_are_emitted_as_data_without_buffering() { + assert_eq!( + parse(b"CQ"), + vec![ClientItem::Data(b'C'), ClientItem::Data(b'Q')] + ); + } + + #[test] + fn fixed_length_commands_wait_for_all_parameters() { + let mut parser = ClientParser::default(); + assert_eq!(parser.push(0x04), None); + assert_eq!(parser.push(0x01), None); + assert_eq!( + parser.push(0xa0), + Some(ClientItem::Command(vec![0x04, 0x01, 0xa0])) + ); + } + + #[test] + fn load_defaults_consumes_exactly_fifteen_values() { + let mut bytes = vec![0x0f]; + bytes.extend(1_u8..=15); + bytes.push(b'K'); + assert_eq!( + parse(&bytes), + vec![ + ClientItem::Command(bytes[..16].to_vec()), + ClientItem::Data(b'K') + ] + ); + } + + #[test] + fn admin_eeprom_load_is_one_bounded_command() { + let mut bytes = vec![0x00, 0x0d]; + bytes.extend((0_u16..256).map(|value| u8::try_from(value).expect("byte"))); + assert_eq!(parse(&bytes), vec![ClientItem::Command(bytes)]); + } + + #[test] + fn pointer_add_nulls_consumes_count_parameter() { + assert_eq!( + parse(&[0x16, 0x03, 0x20, b'A']), + vec![ + ClientItem::Command(vec![0x16, 0x03, 0x20]), + ClientItem::Data(b'A') + ] + ); + } + + #[test] + fn pointer_move_append_consumes_position_before_buffered_speed() { + assert_eq!( + parse(&[0x16, 0x02, 0x00, 0x1c, 22, b'Q']), + vec![ + ClientItem::Command(vec![0x16, 0x02, 0x00]), + ClientItem::Command(vec![0x1c, 22]), + ClientItem::Data(b'Q') + ] + ); + } + + #[test] + fn reset_discards_partial_command() { + let mut parser = ClientParser::default(); + assert_eq!(parser.push(0x02), None); + parser.reset(); + assert_eq!(parser.push(b'A'), Some(ClientItem::Data(b'A'))); + } + + #[test] + fn device_bytes_are_classified_by_tag_bits() { + assert_eq!( + DeviceEvent::from_byte(0x94), + DeviceEvent::SpeedPot { + raw: 0x94, + value: 20 + } + ); + assert_eq!( + DeviceEvent::from_byte(0xd7), + DeviceEvent::Status(DeviceStatus { + raw: 0xd7, + waiting: true, + key_down: false, + busy: true, + break_in: true, + xoff: true, + }) + ); + assert_eq!(DeviceEvent::from_byte(b'A'), DeviceEvent::Echo(b'A')); + } + + #[test] + fn host_lifecycle_is_virtualized_and_eeprom_is_maintenance_only() { + assert_eq!(command_policy(&[0x00, 0x02]), CommandPolicy::Virtualized); + assert_eq!(command_policy(&[0x00, 0x03]), CommandPolicy::Virtualized); + assert_eq!(command_policy(&[0x00, 0x0d]), CommandPolicy::Maintenance); + assert_eq!(command_policy(&[0x00, 0x0c]), CommandPolicy::Maintenance); + assert_eq!(command_policy(&[0x00, 0x10]), CommandPolicy::Maintenance); + assert_eq!(command_policy(&[0x0a]), CommandPolicy::ActiveOwner); + assert_eq!(command_policy(&[0x16, 0x02]), CommandPolicy::ActiveOwner); + assert_eq!(command_policy(&[0x02, 20]), CommandPolicy::Transient); + } +} diff --git a/docs/design/multi-client-cat-hub.md b/docs/design/multi-client-cat-hub.md index ffa1207..41b6b2f 100644 --- a/docs/design/multi-client-cat-hub.md +++ b/docs/design/multi-client-cat-hub.md @@ -116,7 +116,7 @@ The crate lives at `src/rust/qsoripper-cathub/` and is added to the workspace me - `events` owns the radio's spontaneous-update (push) stream centrally. On a rig that supports it, it enables the native push mode at startup (Kenwood `AI2;`, Icom CI-V transceive, etc.), parses pushed frames (typically `IF;` responses and single-field updates) into universal-state mutations tagged `RadioEventSource::NativePush`, and feeds the state change-notification broadcast. On a rig without native push, the baseline poller diffs successive polls and feeds the same broadcast tagged `PollDiff`, so downstream fan-out is uniform. It also **virtualizes** the auto-info command per client: a client that sends `AI2;`/`AI1;`/`AI0;` toggles only its *own* face's push subscription; it never changes the real radio's push state. This prevents clients from fighting over the single physical push setting. - `passthrough` handles native CAT commands the universal state does not model (the bulk of ARCP-590's traffic: `EX` menu reads/writes, filter/DSP queries, tuner, keyer). The dialect forwards the raw command through the serialized radio task and returns the radio's reply verbatim. Reads may be served from a short-TTL cache **keyed by the full normalized command** (not just a prefix — `EX` and similar commands carry parameters that select different values), and the cache is **invalidated by command family** whenever a write in that family is forwarded. Commands whose mutability or side effects are unknown are not cached. Passthrough writes that happen to mutate a modeled field also update the universal state so other clients stay consistent. Passthrough requires that the face's dialect native command set matches the active backend's native command set (see §7 caveat); it is not portable across radio families. - `ptt` enforces PTT ownership and arbitration. Routed through the state mutation API. See §8.5. -- `permissions` defines a per-face (and per-Hamlib-listener) capability set driven by a **per-dialect command classification table** that tags each command as one of: modeled read, modeled write, passthrough read, transient write, PTT/TX-affecting write, persistent/config write, or denied/unknown. The coarse face flags — `read`, `write`, `ptt`, and `config_write` — gate those classes (`config_write` gates `EX`-menu and other persistent-setting writes). Unknown passthrough writes default to denied unless the face explicitly opts into unsafe full control. This lets the operator give ARCP-590 full control while restricting a panadapter face to read-only, and prevents a stray native command from keying TX or rewriting menu settings from an under-privileged face. +- `permissions` defines a per-face (and per-Hamlib-listener) capability set driven by a **per-dialect command classification table** that tags each command as one of: modeled read, modeled write, passthrough read, transient write, PTT/TX-affecting write, persistent/config write, or denied/unknown. The face flags `read`, `write`, `ptt`, and `config_write` gate those classes (`config_write` gates `EX`-menu and other persistent-setting writes). The narrower `frequency_write` flag grants only modeled frequency mutations, allowing a panadapter to click-to-tune without authority to overwrite another client's mode, VFO, split, RIT, or XIT. `write` remains the backward-compatible full modeled-write grant. Unknown passthrough writes default to denied unless the face explicitly opts into unsafe full control. This lets the operator give ARCP-590 full control while restricting a panadapter face to tuning only, and prevents a stray native command from keying TX or rewriting menu settings from an under-privileged face. - `config` loads TOML from `%APPDATA%\QsoRipper\cathub.toml` on Windows and `$XDG_CONFIG_HOME/qsoripper/cathub.toml` on Linux. A `--config ` flag overrides the default. A `--dry-run` flag loads, validates, prints the resolved config, and exits without binding any ports. - `logging` wires `tracing` with per-face spans, a rolling file appender under `%USERPROFILE%\qsoripper-cathub.log` on Windows or `$XDG_STATE_HOME/qsoripper/cathub.log` on Linux, and a periodic summary line carrying commands per second per face, cache hit ratio, real-radio reads per second, passthrough reads per second, native-push frames per second, dropped/denied PTT writes, denied config writes, and reconnect events. - `main` wires everything, installs Ctrl+C and SIGTERM handlers that emit `RX;` on shutdown to avoid a stuck transmitter, and exits with a non-zero code on fatal initialization failures. @@ -255,7 +255,7 @@ pub enum RadioEventSource { Downstream fan-out is identical regardless of source, so a station with a non-push rig behaves the same as one with `AI2`. The crucial distinction is that **only `NativePush` events count as evidence the radio provides spontaneous updates**: poll back-off (`poller`) and any field's `native_push_covered`/freshness flag are driven by `NativePush` coverage alone. Poll-diff events give downstream uniformity but never cause the poller to slow down or claim freshness it does not have. -**Frame demultiplexing contract.** When a native push source is enabled the radio emits frames that can be byte-identical to solicited replies (Kenwood `IF...;` is the classic case). The radio task disambiguates with an explicit matcher: each outbound command declares its expected reply verb(s) and whether it expects a reply at all. When a frame arrives, if a command is pending whose expected verb matches, the frame completes that command's oneshot; otherwise the frame is routed to the backend's `parse_event`. Commands that expect no reply (no-reply writes, §8.3) never capture an incoming frame. This must be tested against the hard interleavings: a pending `IF;` read arriving concurrently with an unsolicited `IF` push, a no-reply write immediately followed by its push echo, and passthrough reads arriving while push frames stream. +**Frame demultiplexing contract.** When a native push source is enabled the radio emits frames that can be byte-identical to solicited replies (Kenwood `IF...;` is the classic case). The radio task disambiguates with an explicit matcher: each outbound command declares its expected reply verb(s) and whether it expects a reply at all. When a frame arrives, if a command is pending whose expected verb matches, the frame completes that command's oneshot; otherwise the frame is routed to the backend's `parse_event`. Commands that expect no reply (no-reply writes, §8.3) never capture an incoming frame. Each pending command has one absolute reply deadline; unmatched unsolicited frames must not restart or extend it. This must be tested against the hard interleavings: a pending `IF;` read arriving concurrently with an unsolicited `IF` push, a no-reply write immediately followed by its push echo, passthrough reads arriving while push frames stream, and a missing reply while unsolicited frames continue beyond the deadline. - The `events` module owns the radio's native push setting centrally (for Kenwood it sets `AI2;` once at startup; for Icom it enables transceive; etc.) and owns it for the daemon's lifetime. - Native frames the radio pushes are parsed by the backend's `parse_event` into `StateMutation`s, tagged `NativePush`, applied to `state`, and emitted on the state change-notification broadcast. Where no native push exists, the poller emits the same broadcast tagged `PollDiff`. @@ -448,7 +448,7 @@ The rest of the config — faces, permissions, polling, events — is identical - Virtual serial pairs via `tokio::io::duplex` simulate the OmniRig, N1MM, and ARCP-590 ports; a TCP client simulates WSJT-X and the engine on the Hamlib net face. A `LoopbackBackend` records every mutation and passthrough. Assertions: no modeled-field client poll causes more than one backend command per TTL window; no TS-2000 status poll ever causes a `FR0`/`FR1` to be sent; writes from one face are visible to reads on every other face; a simulated front-panel change (unsolicited frame from the loopback backend) propagates to all event-subscribed faces. - The existing `RigctldProvider` from `src/rust/qsoripper-core/src/rig_control/rigctld.rs` is pointed at the daemon's read-only Hamlib endpoint and consumes state without code changes. A separate simulated WSJT-X client on the write/PTT endpoint sets frequency/mode and keys PTT concurrently with the engine reading state, and a test confirms the read-only endpoint rejects `F`/`M`/`T`. - A PTT arbitration test confirms the first capable face acquires the lease, a second capable face is rejected while the lease is held, the lease releases on `RX;`/`T 0` and on the `ptt_max_tx_ms` safety ceiling (and that a CAT-idle but actively-transmitting client is *not* unkeyed before that ceiling), and PTT from a face lacking the `ptt` capability is dropped with a warning. A shutdown test confirms `RX;` is emitted on Ctrl+C and SIGTERM, and that `ptt_max_tx_ms` bounds a simulated crash that skips the shutdown write. -- A frame-demux test drives the matcher through a pending `IF;` read arriving with a concurrent unsolicited `IF` push, a no-reply write followed by its `AI2` echo, and passthrough reads interleaved with push frames, asserting each oneshot completes against the correct frame and no push is lost. +- Frame-demux tests drive the matcher through a pending `IF;` read arriving with a concurrent unsolicited `IF` push, a no-reply write followed by its `AI2` echo, and passthrough reads interleaved with push frames, asserting each oneshot completes against the correct frame and no push is lost. A continuous-unmatched-frame test proves that a missing solicited reply still fails at its original absolute deadline. - A passthrough test confirms an ARCP-590-style `EX`-menu read is forwarded verbatim and that a `config_write` denial is enforced on a face without that permission. - A `RigctldBackend` integration test runs the daemon in front of a stub `rigctld` server (an in-process TCP listener replaying recorded net-protocol exchanges) and confirms every face — the OmniRig TS-2000 face, the N1MM native face, and the Hamlib net face — performs **modeled** reads and writes (frequency, mode, PTT, split) against the same radio through the bridge, so a direct-CAT app is demonstrably bridged to a rigctld-driven rig for modeled control. A companion test asserts the bridge declares no native-passthrough capability, so a native `EX`-menu passthrough attempt (ARCP-590 style) fails closed rather than being silently forwarded. - A **client compatibility matrix** is maintained as a table of captured per-client transcripts (ARCP-590, N1MM, OmniRig/HDSDR, WSJT-X, the QsoRipper engine), each replayed against the daemon in CI. A client is "supported" only when its transcript passes; the matrix, not an open-ended "any client" claim, defines the supported set (§3). diff --git a/docs/design/winkeyer-broker.md b/docs/design/winkeyer-broker.md new file mode 100644 index 0000000..1013cd5 --- /dev/null +++ b/docs/design/winkeyer-broker.md @@ -0,0 +1,56 @@ +# CatHub multi-client WinKeyer broker + +## Decision + +CatHub is the sole owner of the physical WinKeyer serial port. QsoRipper engines use a typed loopback gRPC API. Each unmodified legacy program receives a dedicated virtual WinKeyer serial face. The keyer subsystem is independent of the radio CAT actor, but both use the station PTT ownership manager. + +```text +physical WinKeyer <-- 8-N-2 --> CatHub WinKeyer actor + |-- typed loopback API --> Rust/.NET engine + |-- virtual COM face ----> N1MM + `-- virtual COM face ----> maintenance tool +``` + +## Protocol and session model + +One incremental parser consumes every WinKeyer command from a virtual face. It retains partial fixed and variable-length commands across reads, bounds the largest command to the 258-byte EEPROM load frame, and emits ordinary Morse data separately. Host Open, Host Close, firmware revision, status requests, and speed-pot requests are virtualized per client. A virtual close never closes the physical session during routine operation. + +Buffer-pointer commands are active-stream operations, not persistent client configuration. CatHub forwards each pointer command once under active-owner arbitration and never replays it before later text. CatHub also tracks the profile currently applied to the physical keyer and replays a profile only when client ownership changes. A stream beginning with Buffered Speed (`1C`) supplies its own job speed, so CatHub does not insert an unbuffered speed command. Inserting configuration or speed commands between N1MM's pointer sequence and its buffered-speed command corrupts keyboard CW and can key the WPM byte as a phantom character. + +For an authorized active client, CatHub preserves normal WinKeyer command and text bytes in their original order. N1MM's append-pointer command is `16 02 `; the position byte belongs to that command and must not be interpreted as an Admin prefix before the following buffered-speed command. Invalid or disruptive Admin commands remain subject to the normal fail-closed maintenance policy. + +Device bytes are classified by the WinKeyer tag bits as status, speed-pot, or echo events. Status and pot events are fanned out. Echo bytes are visible only to the active stream owner, or to the primary face during physical paddle break-in. Maintenance response bytes are private to the maintenance owner and never enter typed event streams. + +Virtual sessions keep independent WK1/WK2/WK3 modes so one client's pushbutton/status choice does not alter another client's status format. + +## Scheduling and transient state + +Typed sends are atomic jobs. A virtual face receives a raw stream lease when its first data reaches the scheduler; more data from that face appends to the same active stream. Jobs use one global arrival-order FIFO, which preserves each client's FIFO and prevents starvation by later submissions. No client bytes or per-client commands are interleaved inside another job. + +The scheduler stores each client's speed and transient register profile. Before a job it applies that profile and the requested speed. After the queue drains it restores the primary face's profile and fixed or pot-controlled speed. Physical paddles retain the keyer's native priority and can break into machine-sent Morse. + +A speed-pot byte carries `actual WPM - MIN_WPM`. The broker tracks the active Speed Pot Setup minimum, retains the raw offset for protocol-compatible faces, and publishes actual WPM through the typed status/event contract. + +## Abort and safety rules + +- A queued cancel removes only jobs owned by that client. +- Clear Buffer is accepted only from the active stream owner or primary idle controller. +- An active-client disconnect clears the physical buffer, forces key-up, cancels that client's job, and records the safety action. +- The broker owns one maximum-transmit watchdog across all clients. +- CAT PTT and WinKeyer transmit jobs acquire the same station lease. A conflicting owner receives a failed-precondition response. +- USB/read/write failure cancels queued work, releases station PTT, marks status disconnected, and reopens the physical port with bounded backoff. The radio hub and client API stay alive. +- Graceful shutdown clears the buffer, forces key-up, sends physical Host Close, and releases station PTT. + +## Maintenance + +Reset, calibration, EEPROM dump/load, firmware update, high-baud switching, and other disruptive administrative commands require `config_write`. `config_write` itself requires `status` and `control`. A lease is granted only with no active or queued transmission. + +On acquisition, CatHub clears/dekeys and closes the physical host session before forwarding the administrative command, as required by the WinKeyer protocol. Other sends receive deterministic busy errors. Replies route only to the owner. On virtual Host Close or client loss, CatHub sends physical Host Open, waits for the firmware byte, reapplies safe initialization and the foreground transient profile, then resumes normal scheduling. + +Routine N1MM and QsoRipper operation never sends EEPROM writes. The normal N1MM face should not receive `config_write`. + +## Configuration + +Unified configuration uses `[cat_hub.winkeyer]` and `[[cat_hub.winkeyer_face]]`; standalone CatHub files omit the `cat_hub.` prefix. The API must bind to loopback. Physical and virtual transports must be distinct, only one face may be primary, and dependent permission combinations are validated by CatHub and both setup engines. + +See [CW keying setup](../integrations/cw-keying.md) for the complete operator workflow. diff --git a/docs/integration/setup.md b/docs/integration/setup.md index d432667..0bfed60 100644 --- a/docs/integration/setup.md +++ b/docs/integration/setup.md @@ -39,16 +39,25 @@ each pair; the application binds the second. Using the com0com "setupc" tool, cr install PortName=COM10 PortName=COM11 # HDSDR / OmniRig (daemon COM10, app COM11) install PortName=COM20 PortName=COM21 # N1MM Logger+ (daemon COM20, app COM21) install PortName=COM30 PortName=COM31 # ARCP-590 (daemon COM30, app COM31) + install PortName=COM40 PortName=COM41 # N1MM WinKeyer (daemon COM40, app COM41) + install PortName=COM42 PortName=COM43 # WKTools (daemon COM42, app COM43) WSJT-X, Log4OM, and the QsoRipper engine use the Hamlib NET (TCP) endpoints instead and need no serial pair. +The WinKeyer pairs are separate from N1MM's radio-CAT pair. N1MM uses COM21 for its TS-590 radio and COM41 for WinKeyer. WKTools uses COM43 only for maintenance. CatHub owns physical WinKeyer COM3 and the hub sides COM40 and COM42. This permits N1MM and QsoRipper to remain connected to one keyer without attempting an unsafe shared open of COM3, while device maintenance receives a separately permissioned face. + Each pair has two COM numbers: a **daemon side** (the lower, even number COM10/20/30 that the hub opens via `transport`) and an **application side** (the partner COM11/21/31). Point each application at its application-side port. The daemon-side port is held open by the hub, so it typically will **not** appear in an application's COM-port dropdown at all -- that is expected, and the partner port (COM11/21/31) is the one to select. +Record the application side as `application_transport` on each `[[cat_hub.face]]` and +`[[cat_hub.winkeyer_face]]`. CatHub does not open this endpoint. It uses the value in setup +status and dry-run guidance so the application port remains discoverable after comments are +removed from the active configuration. + ## 3. Configure the daemon The daemon settings live in the unified per-user `config.toml` shared with the engine and the @@ -59,7 +68,8 @@ launcher, under a `[cat_hub]` table: - Override the location for every component with the `QSORIPPER_CONFIG_PATH` environment variable. Settings nest under `[cat_hub]`, for example `[cat_hub.radio]`, `[cat_hub.poll]`, -`[cat_hub.ptt]`, `[cat_hub.events]`, `[[cat_hub.face]]`, and `[[cat_hub.hamlib_net]]`. The +`[cat_hub.ptt]`, `[cat_hub.events]`, `[[cat_hub.face]]`, `[[cat_hub.hamlib_net]]`, +`[cat_hub.winkeyer]`, and `[[cat_hub.winkeyer_face]]`. The engine and launcher own other top-level tables in the same file (`[station_profile]`, `[launcher]`, `[rig_control]`, …); each component preserves the others' tables when it saves, so the file is safe to share. @@ -118,13 +128,18 @@ hub on its own (for example with `-DryRun` to validate config). ### HDSDR (via OmniRig) - OmniRig: Rig type `Kenwood TS-2000`, port **COM11**, baud 115200, 8-N-1. -- The `hdsdr-omnirig` face is `dialect = "ts2000"`, `perms = ["read", "write"]`. The panadapter - follows the radio, and click-to-tune on the waterfall sets the radio frequency/mode - (`FA`/`FB`/`MD`). VFO-target writes (`FR`/`FT`) from the TS-2000 dialect are still rejected by - design, so HDSDR can tune but can never oscillate the TS-590's A/B VFO selection. +- The `hdsdr-omnirig` face is `dialect = "ts2000"`, + `perms = ["read", "frequency_write"]`. The panadapter follows the radio and click-to-tune + on the waterfall sets frequency. OmniRig mode writes are denied, preventing its band-plan + mode selection from changing WSJT-X's USB+Data operation to CW after a frequency update. + VFO-target writes (`FR`/`FT`) remain rejected by design. ### N1MM Logger+ - Configurer > Hardware: radio `Kenwood`, port **COM21**, 115200, 8-N-1, no flow control. +- Configurer > Hardware: enable WinKeyer on **COM41**, 1200 baud. COM41 is the application + side of the dedicated COM40/COM41 keyer pair; never select physical COM3 or CatHub's COM40. +- Configure WKTools for **COM43**, 1200 baud. COM43 is the application side of the maintenance + COM42/COM43 pair. Close WKTools after maintenance so the port is released. - The `n1mm` face is `dialect = "ts590"`, `single_vfo = true`, `perms = ["read", "write", "ptt"]`. - **`single_vfo = true` is required for SO1V.** N1MM in single-VFO (SO1V) mode refuses VFO B ("You should not use VFO B when configured for SO1V") and freezes its frequency display when @@ -132,6 +147,12 @@ hub on its own (for example with `-DryRun` to validate config). on as VFO A, so N1MM tracks the radio across A/B switches with no warning. If you run N1MM in SO2V instead, you may set `single_vfo = false`; for SO1V leave it on (the shipped default for this face). See design §8.4.2. +- Keep the everyday `n1mm-cw` WinKeyer face limited to `status`, `send`, `control`, and `ptt`. + Persistent EEPROM/reset access belongs on a separate, normally disabled maintenance face. +- CatHub preserves N1MM's authorized runtime stream byte-for-byte. In particular, the observed + `16 02 1C ` sequence remains an append-pointer command followed by a + buffered-speed command and the intended text. CatHub does not reinterpret the position byte as + an Admin prefix or inject configuration between those bytes. ### ARCP-590 - Set ARCP-590's COM port to **COM31**, 115200, 8-N-1. @@ -166,6 +187,14 @@ hub on its own (for example with `-DryRun` to validate config). accepts both that decimal form and a plain integer, so `Test CAT` and band changes set the dial correctly. +### JS8Call + +- Settings > Radio: Rig `Hamlib NET rigctl`, Network Server **127.0.0.1:4535**. +- PTT method `CAT`, Split Operation **Fake It**. +- Give JS8Call its own writable/PTT endpoint. Do not point it at WSJT-X's port 4533. Both + applications set mode and PTT, and sharing a port allows one application's plain-USB mode + selection to clear the TS-590 DATA flag required by the other. + ### TS-590 PC-control beep (fixed in the hub) Earlier builds made the TS-590 emit a short Morse **"U"** (di-di-dah) tone during WSJT-X @@ -246,6 +275,11 @@ transmitter from automation. Watch `Get-CatHubLog.ps1 -Follow` throughout. holds that port open, so applications only see the partner port. Select COM11/21/31 instead. - An app sees no data on its serial port: the com0com pair is reversed or the app is on the daemon's half of the pair. The app binds the **second** port of each pair (COM11/21/31). +- N1MM cannot open the WinKeyer: select COM41, not COM3 or COM40, and confirm the hub log says + the `n1mm-cw` face opened COM40 at 1200 baud, 8-N-2. +- A physical WinKeyer USB disconnect cancels queued keying, releases station PTT, and starts + capped reconnect attempts without stopping the radio hub. N1MM may need to reopen its + logical keyer session after the physical device reconnects. - A NET client cannot connect: confirm the bind address/port in `config\cathub.toml` matches the app, and that the hub log shows the endpoint listening. - An app that relies on Kenwood auto-information (notably **ARCP-590**) connects but never From 5d44a3535d9e9bf34891a1350cab21493bdbfcd1 Mon Sep 17 00:00:00 2001 From: Randy Treit <38894017+rtreit@users.noreply.github.com> Date: Mon, 13 Jul 2026 10:39:15 -0700 Subject: [PATCH 26/36] Add CatHub architecture report (#521) --- docs/architecture/index.html | 437 +++++++++++++++++++++++++++++++++++ 1 file changed, 437 insertions(+) create mode 100644 docs/architecture/index.html diff --git a/docs/architecture/index.html b/docs/architecture/index.html new file mode 100644 index 0000000..a76f87d --- /dev/null +++ b/docs/architecture/index.html @@ -0,0 +1,437 @@ + + + + + + + CatHub: shared radio control | QsoRipper + + + + + + +
+
+

Engineering note

+

Shared control of a radio and WinKeyer

+

CatHub brokers access to two stateful station devices: a transceiver controlled through computer-aided transceiver (CAT) interfaces, and a hardware WinKeyer used for CW transmission.

+
+

The interoperability problem: established tools such as OmniRig and Hamlib's rigctld can each let compatible programs share a radio. Other programs require a native CAT serial port. CW applications may also expect direct access to the station's WinKeyer. These interfaces do not coordinate with one another.

+

CatHub becomes the sole owner of both physical devices. It presents separate, familiar interfaces to existing applications and coordinates the resulting radio operations, CW jobs, maintenance sessions, and transmit requests.

+
+
+
+ +
+
+
+
+

Terms

Transport, protocol, and coordinator are different things

+

These layers are often grouped together as a radio-control "language." Keeping them separate makes each connection path easier to trace.

+
+
+
+ Transport +

How bytes move

+

A physical COM port, a virtual COM pair, or a TCP socket. "Direct serial" describes this layer. It does not say which CAT commands are in the byte stream.

+
+
+ Protocol +

What the bytes mean

+

Examples include Kenwood TS-590 CAT, Kenwood TS-2000 CAT, Hamlib NET, and WinKeyer's stateful host protocol.

+
+
+ Coordinator +

Who owns shared state

+

OmniRig and rigctld coordinate compatible radio clients. CatHub coordinates mixed radio interfaces and a separate set of WinKeyer clients.

+
+
+
+
+ +
+
+
+

Established solutions

Each interface works within its own client group

+

OmniRig and rigctld already solve multi-client radio access. Their boundary is client compatibility. Neither can directly absorb a program that only supports another interface.

+
+
+ + + + + + + +
ApproachWhat worksCompatibility boundaryPlace in a CatHub station
OmniRigSeveral programs that use the OmniRig automation API can share one configured radio.Programs that only know Hamlib NET or native serial CAT cannot use the OmniRig API without an adapter.OmniRig remains the coordinator for its clients. Its configured "radio" is a private CatHub serial face.
Hamlib rigctldSeveral Hamlib NET clients can use a common TCP radio-control protocol.Programs that require a COM port or a vendor CAT dialect cannot connect to a rigctld socket.Hamlib-aware programs connect to separate rigctld-compatible CatHub endpoints.
Direct serial CATA program can control a radio through a COM port using a supported vendor command set.Windows serial ports are normally exclusive, and independent command streams cannot be merged safely.Each serial program receives a private virtual COM pair backed by a CatHub CAT face.
+
+
+
+ +
+
+
+

Broker architecture

Two hardware brokers, one station boundary

+

The radio and WinKeyer paths remain separate inside CatHub because they use different protocols and scheduling rules. They share station-wide transmit arbitration.

+
+
+ Application or existing interface + CatHub + Physical hardware +
+ +
+
+ +
+
+
+

Radio request paths

Three interfaces, one radio actor

+

OmniRig, Hamlib NET, and serial CAT are client-side choices. After CatHub parses a request, every allowed operation enters the same radio scheduler and shared state model.

+
+
+
01 / OmniRig route

Application → OmniRig → CatHub

  1. The application calls OmniRig.
  2. OmniRig emits the CAT dialect configured for its virtual radio.
  3. A virtual COM pair delivers those bytes to CatHub's TS-2000 face.
  4. CatHub maps the request to shared radio state and the physical radio.
+
02 / Hamlib route

Application → CatHub TCP endpoint

  1. The application selects Hamlib's NET rigctl model.
  2. It sends rigctld protocol commands to a CatHub listener.
  3. CatHub returns state in the form the client expects.
  4. Allowed writes go through the same radio actor as every other route.
+
03 / Serial route

Application → virtual COM → CatHub

  1. The application selects a supported radio model and its assigned COM port.
  2. The paired port delivers its CAT frames to CatHub.
  3. CatHub parses or transparently mirrors supported commands according to the face configuration.
  4. The physical radio still has only one owner.
+
+
+

WinKeyer request paths

+

Two operating interfaces and one maintenance path

+

WinKeyer is a stateful byte protocol, not a CAT dialect. CatHub gives it a separate broker and physical-device actor.

+
+
+
01 / Legacy serial

N1MM → virtual WinKeyer face

  1. N1MM opens its assigned virtual COM port.
  2. CatHub tracks that client's Host Open state, speed, mode, and permissions.
  3. Complete sends enter the broker queue without interleaving another client's bytes.
  4. Physical busy, idle, speed-pot, and error status return through the virtual face.
+
02 / Typed service

QsoRipper → WinKeyer gRPC

  1. A named QsoRipper client submits text, speed, or cancellation through the loopback service.
  2. The broker records the work as an atomic job.
  3. The selected client profile is applied before transmission.
  4. Completion, cancellation, and error events return as typed messages.
+
03 / Maintenance

WKTools → exclusive serial face

  1. WKTools opens its dedicated virtual COM port.
  2. Permission-gated maintenance commands acquire exclusive access.
  3. Private device replies return only to the maintenance client.
  4. Normal transmission remains blocked until maintenance releases the keyer.
+
+
+
+ +
+
+
+

Engineering effect

Brokering is more than copying bytes

+

Both devices carry bidirectional, stateful control traffic. A safe broker must interpret operations, preserve client context, and decide who may transmit or change configuration.

+
+
+

Radio control broker

  • Translates supported client dialects into one shared radio model.
  • Serves routine reads from current state instead of multiplying hardware polls.
  • Orders writes, maintains native push state, and applies per-endpoint permissions.
  • Provides compatibility views such as single-VFO presentation without changing the physical radio state.
+

WinKeyer broker

  • Maintains one physical Host Open session and separate state for each virtual client.
  • Queues complete CW jobs so commands and text from different clients do not interleave.
  • Returns device status to clients and restores the primary operating profile between jobs.
  • Separates normal sending from exclusive configuration and diagnostic work.
+
+

Shared transmit boundary: radio CAT PTT and WinKeyer transmission use one station-wide PTT lease. A transmit-time ceiling and disconnect handling return the station to receive when a client or hardware path fails.

+
+
+ +
+
+
+

Serial examples

CAT and WinKeyer clients each receive private COM pairs

+

A com0com pair acts like a null-modem cable between two programs. It connects one application endpoint to one CatHub endpoint; it does not make a single COM port shareable.

+
+

OmniRig radio-control path

+
+
OmniRigOpens application sideCOM11
+ +
com0com pairBytes written at one end appear at the other.
+ +
CatHub serial faceOpens hub sideCOM10
+ +
Physical radioOpened only by CatHubCOM4
+
+

N1MM WinKeyer path

+
+
N1MM Logger+Opens application sideCOM41
+ +
com0com pairCarries WinKeyer's 1200 baud, 8-N-2 byte protocol.
+ +
CatHub WinKeyer faceOpens hub sideCOM40
+ +
Physical WinKeyerOpened only by CatHubCOM3
+
+

The application side and hub side are not interchangeable. In these examples, OmniRig opens COM11 and CatHub opens COM10; N1MM opens COM41 and CatHub opens COM40. Only CatHub opens the physical device ports, COM4 and COM3.

+
+
+ +
+
+
+

Current implementation

Supported interfaces and limits

+

The architecture is general, but the present CatHub implementation has explicit backends and dialects.

+
+
    +
  • Radio backends: the current configuration accepts native TS-590 control, an upstream rigctld backend, or a loopback test backend.
  • +
  • Serial faces: the current implementation exposes TS-590, transparent TS-590, and TS-2000 client dialects.
  • +
  • Network faces: CatHub exposes rigctld-compatible Hamlib NET endpoints, including per-endpoint permissions and compatibility policy.
  • +
  • OmniRig: CatHub does not replace OmniRig for programs that use its automation API. OmniRig becomes one CatHub client through a virtual serial face.
  • +
  • Direct serial: this means a program sends a supported CAT dialect over its own virtual pair. It does not mean every arbitrary serial protocol is accepted.
  • +
  • Physical WinKeyer: CatHub maintains the single hardware session at 1200 baud and enforces a separate transmit-time ceiling.
  • +
  • Virtual WinKeyer faces: unmodified serial clients receive private 8-N-2 COM pairs with explicit status, send, control, PTT, and configuration permissions.
  • +
  • Typed WinKeyer service: QsoRipper clients use a loopback gRPC API for status, text jobs, speed, cancellation, abort, and event streaming.
  • +
  • Maintenance: configuration tools use an exclusive face whose private replies are not broadcast to ordinary clients.
  • +
+
+
+ +
+
+
+

Verification

Technical basis

+

This architecture note is based on the current implementation and primary documentation for the interfaces CatHub composes.

+
+
    +
  1. QsoRipper engine specification, especially the CAT hub behavior, endpoint, dialect, state, and PTT sections.
  2. +
  3. Multi-client CAT hub design and multi-client WinKeyer broker design, which define the two physical-device actors, client-facing interfaces, scheduling, and shared transmit boundary.
  4. +
  5. OmniRig product documentation, which describes its COM component and support for several programs controlling one radio.
  6. +
  7. Hamlib rigctld manual, which defines the TCP daemon and notes its multiple-program sharing behavior and compatibility qualifications.
  8. +
  9. Microsoft: Communications Resource Handles, which documents exclusive opening of Windows communications resources.
  10. +
  11. com0com project documentation, which defines a virtual COM pair and its bidirectional byte flow.
  12. +
  13. Implementation files: serial_face.rs, hamlib_net.rs, the radio dialect modules, and the winkeyer broker modules.
  14. +
+
+
+
+ +
QsoRipper CatHub architecture note. Examples describe the current TS-590 station configuration.
+ + From e6caa5dcaa36536a8c2f26e74678fda71e6f6337 Mon Sep 17 00:00:00 2001 From: Randy Treit <38894017+rtreit@users.noreply.github.com> Date: Mon, 13 Jul 2026 10:56:29 -0700 Subject: [PATCH 27/36] Explain CatHub client identity (#522) --- docs/architecture/index.html | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/docs/architecture/index.html b/docs/architecture/index.html index a76f87d..41fddba 100644 --- a/docs/architecture/index.html +++ b/docs/architecture/index.html @@ -202,6 +202,7 @@