Skip to content

Extract CatHub into standalone project#2

Merged
rtreit merged 37 commits into
mainfrom
u/rtreit/ExtractCatHub
Jul 15, 2026
Merged

Extract CatHub into standalone project#2
rtreit merged 37 commits into
mainfrom
u/rtreit/ExtractCatHub

Conversation

@rtreit

@rtreit rtreit commented Jul 15, 2026

Copy link
Copy Markdown
Member

CatHub is independently useful station infrastructure and does not require QsoRipper logging functionality. This change imports the original CatHub source history and turns the initialized repository into a standalone Rust workspace with its own daemon, configuration ownership, protocol packages, documentation, tests, CI, and tagged-release automation.

Standalone configuration now defaults to CatHub-owned paths and supports validation, effective TOML or JSON output, explicit managed-section selection, and reversible migration from a QsoRipper [cat_hub] section. The first protocol release preserves the deployed qsoripper.services wire package while transferring ownership to CatHub.

The release workflow builds Windows and Linux archives, emits SHA-256 checksums, and packages the Rust and .NET protocol clients. Hardware transmission testing remains an attended release step.

Validated with .\build.ps1 check, a release-mode daemon build, cargo package for cathub-protocol, NuGet packaging, Buf lint, strict Clippy, and 272 Rust tests.

Closes #1.

Cross-repository QsoRipper integration remains tracked by treitforge/qsoripper#540.

Treit and others added 30 commits May 28, 2026 20:02
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) <mtreit@ntdev.microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…okering (#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>
…#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>
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) <mtreit@ntdev.microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* 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<n>; 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>
* 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>
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>
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 <rtreit@gmail.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* 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-<timestamp>.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-<ts>.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>
* 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-<timestamp>.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-<ts>.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>
* 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>
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>
* 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 <rtreit@gmail.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Randy Treit <rtreit@gmail.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* 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<n>; 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>
…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>
… 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>
…ograms (#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>
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 <rtreit@gmail.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
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>
* 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 <rtreit@gmail.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* 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 <rtreit@gmail.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* 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>
@rtreit
rtreit marked this pull request as ready for review July 15, 2026 20:47
@rtreit
rtreit merged commit baf31f2 into main Jul 15, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bootstrap standalone CatHub 0.1 product

2 participants