From 0aa456261734a338b1eda7ca83ff8b101e3e4a5a Mon Sep 17 00:00:00 2001 From: Randy Treit Date: Wed, 15 Jul 2026 14:15:46 -0700 Subject: [PATCH 1/2] Remove QsoRipper seams from CatHub --- .github/workflows/release.yml | 14 +- CONTRIBUTING.md | 3 +- README.md | 120 ++-- config/cathub.toml | 10 +- crates/cathub-protocol/src/lib.rs | 5 +- crates/cathub/src/config.rs | 25 +- crates/cathub/src/hamlib_net.rs | 4 +- crates/cathub/src/integration.rs | 4 +- crates/cathub/src/lib.rs | 9 +- crates/cathub/src/winkeyer/grpc.rs | 2 +- docs/architecture/independent-product.md | 26 - docs/architecture/index.html | 19 +- .../architecture/release-and-compatibility.md | 58 ++ docs/design/multi-client-cat-hub.md | 611 ++++-------------- docs/design/winkeyer-broker.md | 17 +- docs/integration/setup.md | 461 ++++++------- 16 files changed, 537 insertions(+), 851 deletions(-) delete mode 100644 docs/architecture/independent-product.md create mode 100644 docs/architecture/release-and-compatibility.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3d1644d..5d6a615 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,7 +21,7 @@ jobs: binary: cathub runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7.0.0 - uses: dtolnay/rust-toolchain@stable - run: cargo build --release -p cathub - name: Assemble release archive @@ -43,7 +43,7 @@ jobs: "$hash $([System.IO.Path]::GetFileName($archive))" | Set-Content -LiteralPath "$archive.sha256" -NoNewline Remove-Item -LiteralPath "artifacts/release/$name" -Recurse - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7.0.1 with: name: cathub-${{ matrix.rid }} path: artifacts/release/* @@ -51,9 +51,9 @@ jobs: protocols: runs-on: windows-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7.0.0 - uses: dtolnay/rust-toolchain@stable - - uses: actions/setup-dotnet@v4 + - uses: actions/setup-dotnet@v5.4.0 with: dotnet-version: 10.0.x - run: cargo package -p cathub-protocol @@ -66,7 +66,7 @@ jobs: $hash = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() "$hash $($_.Name)" | Set-Content -LiteralPath "$($_.FullName).sha256" -NoNewline } - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7.0.1 with: name: cathub-protocols path: artifacts/release/* @@ -75,11 +75,11 @@ jobs: needs: [daemon, protocols] runs-on: ubuntu-latest steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v8.0.1 with: path: release merge-multiple: true - - uses: softprops/action-gh-release@v2 + - uses: softprops/action-gh-release@v3.0.2 with: generate_release_notes: true files: release/* diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f5cf37f..e5ca9e6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,7 +10,8 @@ Run the complete local gate before opening a pull request: Changes to files under `crates\cathub-protocol\proto` are public wire-contract changes. Keep protobuf 1-1-1 structure, use unique request and response envelopes, run `buf lint`, -and document compatibility. Do not rename the 0.1 `qsoripper.services` package in place. +and document compatibility. The 0.1 `qsoripper.services` wire-package identifier is a +compatibility boundary. Do not rename it in place or interpret it as a source dependency. Hardware transmission tests must be attended. Automated tests must not key a physical transmitter. diff --git a/README.md b/README.md index dee074a..b973394 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,11 @@ # CatHub -CatHub lets programs that use different radio CAT, Hamlib NET, virtual serial, and -WinKeyer interfaces safely share one transceiver and keyer. +CatHub lets radio applications that use CAT, Hamlib NET, virtual serial, and WinKeyer +interfaces safely share one transceiver and keyer. -CatHub is a standalone station service. It does not require QsoRipper, and QsoRipper is -only one optional client. The daemon owns each physical device, serializes mutations, -serves compatible client endpoints, arbitrates PTT, and leaves the station unkeyed after -shutdown or a failed client session. +The daemon is the single owner of each physical device. It serializes mutations, serves +separately permissioned client endpoints, arbitrates PTT, and leaves the station unkeyed +after shutdown or a failed client session. ## Current support @@ -16,16 +15,39 @@ shutdown or a failed client session. - TS-590 and TS-2000 compatible virtual serial endpoints - Single-VFO presentation for applications that cannot model the active VFO correctly - Multi-client WinKeyer broker with typed gRPC and virtual serial endpoints -- QsoRipper unified configuration compatibility +- Standalone and embedded `[cat_hub]` configuration layouts + +## Install + +No public CatHub release has been tagged yet. Until the first release is published, build +the daemon from this repository with Rust 1.88 or newer: + +```powershell +git clone https://github.com/treitforge/cathub.git +Set-Location cathub +cargo build --release -p cathub +``` + +The executable is `target\release\cathub.exe` on Windows and +`target/release/cathub` on Linux. Copy it to a directory on `PATH`, or run it by its full +path. + +Tagged releases will provide Windows and Linux archives with adjacent SHA-256 checksum +files on the [GitHub Releases page](https://github.com/treitforge/cathub/releases). After +the daemon crate is published to crates.io, users with a Rust toolchain may instead run +`cargo install cathub --version `. A daemon operator does not need either +protocol-development package described below. ## Build and test +The full repository gate requires PowerShell 7, a current stable Rust toolchain, the .NET +10 SDK, and Buf: + ```powershell -.\build.ps1 .\build.ps1 check ``` -The direct Rust commands are: +The principal checks can also be run directly: ```powershell cargo build --workspace @@ -36,63 +58,89 @@ buf lint dotnet build CatHub.slnx ``` +The Rust protocol crate vendors `protoc`, so `cargo build` does not require a separate +Protocol Buffers compiler installation. + ## Configuration -CatHub's standalone default configuration is: +CatHub's default configuration path is: - Windows: `%APPDATA%\cathub\cathub.toml` - Linux: `$XDG_CONFIG_HOME/cathub/cathub.toml`, or `~/.config/cathub/cathub.toml` -Set `CATHUB_CONFIG_PATH` or pass `--config` to use another file. The canonical example is +Set `CATHUB_CONFIG_PATH` or pass `--config` to use another file. The complete example is [config/cathub.toml](config/cathub.toml). -CatHub accepts both a standalone document with top-level `[radio]`, `[winkeyer]`, and -endpoint tables, and a QsoRipper unified document with the same content nested under -`[cat_hub]`. +A standalone document uses top-level `[radio]`, `[winkeyer]`, and endpoint tables. CatHub +also accepts those settings beneath `[cat_hub]` in a larger managed document. Use +`--section cat_hub` when a launcher must require the embedded layout. Validate and inspect configuration without opening hardware: ```powershell -cargo run -p cathub -- config validate --config config\cathub.toml -cargo run -p cathub -- config print-effective --config config\cathub.toml -cargo run -p cathub -- config print-effective --format json --config config\cathub.toml +cathub config validate --config config\cathub.toml +cathub config print-effective --config config\cathub.toml +cathub config print-effective --format json --config config\cathub.toml ``` -Extract an existing QsoRipper `[cat_hub]` section without changing the source file: +Extract an embedded `[cat_hub]` section without changing the source file: ```powershell -cargo run -p cathub -- config migrate ` - --from "$env:APPDATA\qsoripper\config.toml" ` +cathub config migrate ` + --from C:\station\managed-config.toml ` --output "$env:APPDATA\cathub\cathub.toml" ``` Migration refuses to overwrite an existing destination unless `--force` is supplied. -Source removal is never automatic. The optional `--remove-source-section` switch creates a -`.bak` copy before changing the unified file. - -For a managed unified file, pass `--section cat_hub` to require that section explicitly. -All effective CatHub settings require a daemon restart in the 0.1 release. Unknown keys -are rejected by CatHub validation. Migration never rewrites unrelated unified sections. +Source removal is never automatic. `--remove-source-section` creates a `.bak` copy before +changing the managed document. ## Run +The repository helper builds the daemon when necessary, copies the sample configuration to +the platform default on first use, and starts CatHub: + ```powershell .\scripts\Start-CatHub.ps1 ``` -See [the setup guide](docs/integration/setup.md), -[the multi-client CAT design](docs/design/multi-client-cat-hub.md), and -[the WinKeyer broker design](docs/design/winkeyer-broker.md). +For an installed binary: + +```powershell +cathub config validate +cathub +``` + +## Client interfaces + +- Hamlib-aware clients connect to a configured `[[hamlib_net]]` TCP listener. +- Serial CAT clients connect to the application side of a dedicated virtual serial pair. +- Legacy WinKeyer clients connect to their own virtual serial pair. +- Typed WinKeyer clients connect to the loopback gRPC address in `[winkeyer].api_bind`. + +Each endpoint has its own permissions. Applications never open the physical radio or keyer +port directly. + +## Protocol packages + +The `cathub` crate builds and installs the daemon. `cathub-protocol` is the Rust +client/server contract crate. `CatHub.Protocol` is the .NET client package project. The +protocol packages are for applications that use the typed WinKeyer API and are not required +to run the daemon. -## Protocol compatibility +The release workflow packages both protocol artifacts into a GitHub release. Publishing the +daemon and protocol crates to crates.io, and the .NET contract to NuGet, are separate +release-authority operations. None of those registry packages exists before publication +succeeds. See +[release and compatibility](docs/architecture/release-and-compatibility.md). -The first standalone release preserves the historical `qsoripper.services` WinKeyer broker -wire package. This lets existing QsoRipper clients connect without a flag day. CatHub owns -the contract from this release forward through the `cathub-protocol` Rust crate and the -`CatHub.Protocol` .NET package project. +## Documentation -See [the independent-product decision](docs/architecture/independent-product.md) for the -ownership, compatibility, and release policy. +- [Architecture report](docs/architecture/index.html) +- [Radio hub design](docs/design/multi-client-cat-hub.md) +- [WinKeyer broker design](docs/design/winkeyer-broker.md) +- [Operator setup](docs/integration/setup.md) +- [Release and compatibility](docs/architecture/release-and-compatibility.md) ## License diff --git a/config/cathub.toml b/config/cathub.toml index fc4bb0c..33f5760 100644 --- a/config/cathub.toml +++ b/config/cathub.toml @@ -29,9 +29,9 @@ 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. +# CatHub owns the physical keyer. Typed clients use the loopback API; legacy clients use +# the application side of a dedicated virtual serial pair. Change COM40/COM41 if that pair +# is not installed on this station. [winkeyer] port = "COM3" @@ -57,9 +57,9 @@ perms = ["status", "control", "config_write"] # --- Hamlib NET (rigctld-compatible) TCP endpoints ------------------------------------- -# Read-only endpoint for the QsoRipper engine's RigctldProvider. +# Read-only endpoint for logger and monitor clients. [[hamlib_net]] -name = "engine" +name = "logger-readonly" bind = "127.0.0.1:4532" perms = ["read"] diff --git a/crates/cathub-protocol/src/lib.rs b/crates/cathub-protocol/src/lib.rs index b2520b8..4107409 100644 --- a/crates/cathub-protocol/src/lib.rs +++ b/crates/cathub-protocol/src/lib.rs @@ -1,7 +1,8 @@ //! Generated client and server types for CatHub's public protocol. //! -//! The first standalone release intentionally preserves the historical -//! `qsoripper.services` wire package so existing clients remain compatible. +//! Version 0.1 retains the legacy `qsoripper.services` wire-package identifier so +//! pre-release clients remain compatible. The identifier does not imply a runtime +//! or source dependency on another product. // Generated prost/tonic code is not authored against this workspace's pedantic lint profile. #![allow(clippy::all, clippy::pedantic)] diff --git a/crates/cathub/src/config.rs b/crates/cathub/src/config.rs index 844b540..b364f57 100644 --- a/crates/cathub/src/config.rs +++ b/crates/cathub/src/config.rs @@ -213,7 +213,7 @@ pub(crate) struct WinkeyerConfig { /// 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. + /// Loopback gRPC endpoint used by typed WinKeyer clients. #[serde(default = "default_winkeyer_api_bind")] pub(crate) api_bind: String, } @@ -271,8 +271,6 @@ pub(crate) struct Config { const UNIFIED_SECTION: &str = "cat_hub"; /// Environment override for CatHub's standalone configuration path. const CONFIG_PATH_ENV: &str = "CATHUB_CONFIG_PATH"; -/// Legacy QsoRipper override retained for a compatibility transition. -const LEGACY_CONFIG_PATH_ENV: &str = "QSORIPPER_CONFIG_PATH"; /// Per-user standalone configuration directory. const CONFIG_DIR: &str = "cathub"; /// Standalone configuration file name. @@ -286,9 +284,9 @@ impl Config { 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]` ...). + /// Parse a configuration from a TOML document that may be either a managed + /// document (daemon settings nested under `[cat_hub]`) or a standalone CatHub + /// configuration (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 @@ -600,10 +598,9 @@ impl Config { /// The default standalone CatHub configuration path. /// /// 1. `CATHUB_CONFIG_PATH` if set, - /// 2. the legacy `QSORIPPER_CONFIG_PATH` only when it names an existing file, - /// 3. `%APPDATA%\cathub\cathub.toml` (Windows) or + /// 2. `%APPDATA%\cathub\cathub.toml` (Windows) or /// `$XDG_CONFIG_HOME/cathub/cathub.toml` -> `$HOME/.config/cathub/cathub.toml` (Unix), - /// 4. a bare `cathub.toml` in the working directory as a last resort. + /// 3. a bare `cathub.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. @@ -611,12 +608,6 @@ impl Config { if let Some(path) = std::env::var_os(CONFIG_PATH_ENV) { return PathBuf::from(path); } - if let Some(path) = std::env::var_os(LEGACY_CONFIG_PATH_ENV) { - let path = PathBuf::from(path); - if path.is_file() { - return path; - } - } #[cfg(target_os = "windows")] { if let Some(app_data) = std::env::var_os("APPDATA") { @@ -924,9 +915,7 @@ backend = "loopback" fn default_path_is_standalone_cathub_toml() { let path = Config::default_config_path(); let text = path.to_string_lossy(); - if std::env::var_os(CONFIG_PATH_ENV).is_none() - && std::env::var_os(LEGACY_CONFIG_PATH_ENV).is_none() - { + if std::env::var_os(CONFIG_PATH_ENV).is_none() { assert!( text.ends_with(CONFIG_FILE), "expected {CONFIG_FILE}, got {text}" diff --git a/crates/cathub/src/hamlib_net.rs b/crates/cathub/src/hamlib_net.rs index 95f7ce1..0efd0dc 100644 --- a/crates/cathub/src/hamlib_net.rs +++ b/crates/cathub/src/hamlib_net.rs @@ -2,8 +2,8 @@ //! 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 +//! links Hamlib (§8.8). It serves read-only and write/PTT client endpoints. Modeled reads +//! come from the universal state; writes go through //! [`ClientSessionContext::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. diff --git a/crates/cathub/src/integration.rs b/crates/cathub/src/integration.rs index f052501..282bf0c 100644 --- a/crates/cathub/src/integration.rs +++ b/crates/cathub/src/integration.rs @@ -431,7 +431,7 @@ async fn ptt_lease_is_arbitrated_across_sessions() { assert_eq!(rig.ptt.owner(), Some(2)); } -/// The Hamlib net endpoint (engine / WSJT-X) and a serial endpoint share one radio state: a write on +/// A Hamlib NET endpoint and a serial endpoint share one radio state: a write on /// the serial endpoint is read back over TCP, and a read-only endpoint rejects writes. #[tokio::test] async fn hamlib_net_and_serial_endpoint_share_radio_state() { @@ -444,7 +444,7 @@ async fn hamlib_net_and_serial_endpoint_share_radio_state() { 1, ); - // A read-only Hamlib net endpoint (the QsoRipper engine). + // A read-only Hamlib NET endpoint. let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); let addr = listener.local_addr().expect("addr"); let ro_ctx = ClientSessionContext::new( diff --git a/crates/cathub/src/lib.rs b/crates/cathub/src/lib.rs index 036d302..2f441b3 100644 --- a/crates/cathub/src/lib.rs +++ b/crates/cathub/src/lib.rs @@ -1,7 +1,7 @@ //! CatHub: a multi-client CAT and WinKeyer hub daemon. //! //! The daemon is the single owner of the radio link and fans it out to many client endpoints -//! (HDSDR/OmniRig, N1MM Logger+, ARCP-590, WSJT-X, Log4OM, and the QsoRipper engine) over +//! (loggers, digital-mode software, SDR software, and manufacturer control tools) 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 @@ -73,9 +73,8 @@ use crate::winkeyer::{ 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. +/// CatHub daemon will accept. This is exposed so managed-configuration clients can +/// test their writers against the daemon's real parser and validator. /// /// # Errors /// @@ -141,7 +140,7 @@ pub enum ConfigCommand { }, /// Extract `[cat_hub]` from a unified file into a standalone file. Migrate { - /// Unified QsoRipper configuration containing `[cat_hub]`. + /// Managed configuration containing `[cat_hub]`. #[arg(long)] from: PathBuf, /// Destination standalone CatHub TOML file. diff --git a/crates/cathub/src/winkeyer/grpc.rs b/crates/cathub/src/winkeyer/grpc.rs index e25853f..89756ac 100644 --- a/crates/cathub/src/winkeyer/grpc.rs +++ b/crates/cathub/src/winkeyer/grpc.rs @@ -1,4 +1,4 @@ -//! Loopback gRPC surface for typed QsoRipper WinKeyer clients. +//! Loopback gRPC surface for typed WinKeyer clients. use std::collections::BTreeMap; use std::net::SocketAddr; diff --git a/docs/architecture/independent-product.md b/docs/architecture/independent-product.md deleted file mode 100644 index 3346c36..0000000 --- a/docs/architecture/independent-product.md +++ /dev/null @@ -1,26 +0,0 @@ -# CatHub as an independent product - -Status: accepted for the 0.1 release. - -CatHub is independently versioned station infrastructure. It owns radio CAT access, -Hamlib NET and virtual serial endpoints, PTT arbitration, physical WinKeyer access, -its configuration schema, and its public broker protocol. It does not depend on a -logger, QSO storage, ADIF, QRZ, a station profile, or a specific user interface. - -QsoRipper is one optional client. It consumes Hamlib NET and the versioned WinKeyer -broker protocol. QsoRipper may launch an installed or explicitly bundled CatHub, but -normal logging does not require CatHub to be installed or running. - -The 0.1 protocol preserves the historical `qsoripper.services` wire package so deployed -clients remain compatible. CatHub owns that contract from this release onward. A future -CatHub-namespaced protocol will be introduced as a separate version, not as an in-place -wire rename. - -CatHub's standalone configuration is `cathub.toml`. A QsoRipper unified file containing -`[cat_hub]` remains a supported managed compatibility layout. `--section cat_hub` makes -that selection explicit, while automatic detection remains available during migration. - -Tagged releases produce Windows and Linux archives, SHA-256 checksum files, the -`cathub-protocol` Rust crate archive, and the `CatHub.Protocol` NuGet package. Publishing -to package registries is a separate release-authority step. Publish `cathub-protocol` -before publishing the `cathub` crate because the daemon depends on that version. diff --git a/docs/architecture/index.html b/docs/architecture/index.html index 37a1433..6a69ecf 100644 --- a/docs/architecture/index.html +++ b/docs/architecture/index.html @@ -454,7 +454,7 @@

2. Problem and scop OmniRigPrograms using the OmniRig automation API.Native serial CAT and Hamlib NET clients cannot use that API directly.Remains a coordinator for its clients and connects to a private CatHub serial endpoint. Hamlib rigctldPrograms using the rigctld-compatible TCP protocol.Programs requiring a COM port or vendor CAT dialect cannot connect to its socket.Hamlib-aware programs connect to dedicated CatHub network endpoints. Direct serial CATOne program speaking a supported vendor command set.Serial ports are normally exclusive, and independent command streams cannot be safely combined.Each program receives a private virtual COM pair backed by a CatHub serial endpoint. - Direct WinKeyer accessOne host session using the stateful WinKeyer byte protocol.Client state, buffered Morse, status bytes, and maintenance replies cannot be interleaved safely.Legacy programs use private virtual endpoints; QsoRipper uses a typed loopback API. + Direct WinKeyer accessOne host session using the stateful WinKeyer byte protocol.Client state, buffered Morse, status bytes, and maintenance replies cannot be interleaved safely.Legacy programs use private virtual endpoints; native clients use a typed loopback API. Table 1. Existing coordinators solve compatibility within a client group, not across the complete station. @@ -500,8 +500,8 @@

4. System context
-
Radio applicationsLoggers, digital modes, SDR software, manufacturer control, and QsoRipper.
-
CW applicationsLegacy WinKeyer clients, maintenance tools, and QsoRipper CW clients.
+
Radio applicationsLoggers, digital modes, SDR software, and manufacturer control.
+
CW applicationsLegacy WinKeyer clients, maintenance tools, and typed API clients.
CatHubEndpoint adapters, shared state, radio broker, WinKeyer broker, permissions, scheduling, and station PTT ownership.
@@ -535,7 +535,7 @@

5. Architecture ov
OmniRigConfigured as a TS-2000 on its own virtual COM port.Example: COM11
-
WSJT-X, JS8Call, Log4OM, QsoRipperUse Hamlib's NET rigctl model.
+
WSJT-X, JS8Call, Log4OM, other loggersUse Hamlib's NET rigctl model.
Hamlib NET endpointsEach policy group connects to a separate TCP listener.Example: 127.0.0.1:4532-4535
@@ -554,7 +554,7 @@

5. Architecture ov
Virtual WinKeyer endpointUnmodified WinKeyer byte protocol over a private pair.Example: COM41 ⇄ COM40
-
QsoRipper CW clientsSubmit named or expanded CW work.
+
Typed CW clientsSubmit named or expanded CW work.
Typed WinKeyer gRPC serviceNamed clients submit atomic jobs and receive status.Example: 127.0.0.1:50071
@@ -609,13 +609,13 @@

7. Interfaces an HDSDR through OmniRigCOM11COM10TS-2000 CATRead, frequency write N1MM radio controlCOM21COM20TS-590 CAT, single-VFO viewRead, write, PTT ARCP-590COM31COM30TS-590 CAT with passthroughRead, write, PTT, configuration write - QsoRipper engine127.0.0.1:4532TCP listener :4532Hamlib NETRead + Read-only logger127.0.0.1:4532TCP listener :4532Hamlib NETRead WSJT-X127.0.0.1:4533TCP listener :4533Hamlib NET, single-VFO viewRead, write, PTT Log4OM127.0.0.1:4534TCP listener :4534Hamlib NET, single-VFO viewRead, write JS8Call127.0.0.1:4535TCP listener :4535Hamlib NET, single-VFO viewRead, write, PTT N1MM CWCOM41COM40WinKeyer host protocol, 1200 baud 8-N-2Status, send, control, PTT WKToolsCOM43COM42WinKeyer maintenanceStatus, control, configuration write - QsoRipper CW client127.0.0.1:50071Loopback gRPC serviceTyped WinKeyer APINamed jobs, speed, status, cancel, abort + Typed CW client127.0.0.1:50071Loopback gRPC serviceTyped WinKeyer APINamed jobs, speed, status, cancel, abort Table 3. Example client-to-CatHub connection map. Every serial client has a private virtual pair. @@ -749,7 +749,7 @@

9. Safety and failur

10. Configuration and deployment

-

Operators configure physical devices first, then create one endpoint for each application or authority group. The examples below use standalone CatHub section names. In the unified QsoRipper configuration, the same sections are nested under cat_hub.

+

Operators configure physical devices first, then create one endpoint for each application or authority group. The examples below use standalone CatHub section names. Managed TOML documents may nest the same sections under cat_hub.

10.1 Physical hardware and safety limits

@@ -871,9 +871,8 @@

Glossary

References

    -
  1. QsoRipper engine specification, especially the CAT hub endpoint, dialect, state, WinKeyer, and PTT contracts.
  2. Multi-client CAT hub design and multi-client WinKeyer broker design.
  3. -
  4. CatHub operator setup and CW keying setup.
  5. +
  6. CatHub operator setup and release and compatibility policy.
  7. OmniRig product documentation.
  8. Hamlib rigctld manual.
  9. Microsoft documentation for communications resource handles.
  10. diff --git a/docs/architecture/release-and-compatibility.md b/docs/architecture/release-and-compatibility.md new file mode 100644 index 0000000..4d2ff92 --- /dev/null +++ b/docs/architecture/release-and-compatibility.md @@ -0,0 +1,58 @@ +# Release and compatibility + +CatHub versions the daemon, configuration schema, and typed WinKeyer protocol together. +The initial compatibility line is `0.1.x`. + +## Release artifacts + +Pushing a `v*` tag runs `.github/workflows/release.yml` and creates a GitHub release with: + +- Windows x86-64 daemon archive and SHA-256 checksum +- Linux x86-64 daemon archive and SHA-256 checksum +- `cathub-protocol` Rust crate archive and SHA-256 checksum +- `CatHub.Protocol` NuGet package and SHA-256 checksum + +GitHub release assets and registry packages are separate distribution channels. Creating a +tag does not publish to crates.io or NuGet. + +## Registry publication order + +Registry publication requires the release owner's credentials and must occur only after the +tagged release passes CI: + +```powershell +cargo publish -p cathub-protocol +cargo search cathub-protocol --limit 1 +cargo publish -p cathub +dotnet nuget push artifacts\release\CatHub.Protocol..nupkg ` + --source https://api.nuget.org/v3/index.json ` + --api-key $env:NUGET_API_KEY +``` + +Do not print either registry token. Verify package ownership, the expected version, and the +tag before publishing. The daemon crate depends on the same version of `cathub-protocol`. +Publish the protocol crate first, wait until the crates.io index exposes that version, and +only then publish `cathub`. + +The current workflow does not perform registry publication. A release owner must run these +commands or add separately authorized trusted-publishing jobs. + +## Wire compatibility + +The 0.1 typed WinKeyer API retains the legacy `qsoripper.services` protobuf package string +so existing pre-release clients can connect. That string is a wire identifier, not a source +or runtime dependency. It must not be renamed in place within the 0.1 line. + +A future wire namespace must be introduced as a new protocol version with an explicit +migration window. Request and response envelopes remain unique per RPC, and protobuf +1-1-1 remains the default file layout. + +## Configuration compatibility + +The authoritative standalone layout is `cathub.toml` with top-level CatHub tables. The +parser also accepts the same tables under a top-level `[cat_hub]` section in a managed TOML +document. Other top-level tables in that document are ignored. + +`--section cat_hub` requires the managed layout explicitly. `config migrate` extracts that +section to a standalone file and never removes it from the source unless +`--remove-source-section` is requested. diff --git a/docs/design/multi-client-cat-hub.md b/docs/design/multi-client-cat-hub.md index 0e3b07b..8da00d2 100644 --- a/docs/design/multi-client-cat-hub.md +++ b/docs/design/multi-client-cat-hub.md @@ -1,536 +1,205 @@ -# Design: `cathub` multi-client CAT hub +# Multi-client radio hub design -> **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 (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. +## Purpose -## 1. Problem +CatHub allows applications with incompatible radio-control interfaces to share one +transceiver without sharing the physical serial port. The daemon is the only radio owner. +Every application connects to a dedicated CatHub endpoint with an explicit dialect and +permission set. -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: +The design addresses four recurring station failures: -- 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 control-panel 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). +- competing programs retargeting VFO A and VFO B during polling +- repeated or reordered frequency and mode writes +- several processes attempting to open one serial device +- conflicting PTT requests leaving transmit state ambiguous -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). +## Scope -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: +The current runtime manages one radio and one optional physical WinKeyer per process. It +supports: -- `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 serial client (N1MM, ARCP-590, on its own virtual COM port) requires the bridge to multiplex serial endpoints, 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. +- a native Kenwood TS-590 radio backend +- an external `rigctld` radio backend for broader rig support +- TS-590, transparent TS-590, and TS-2000 serial client dialects +- Hamlib NET client endpoints +- virtual and typed WinKeyer client endpoints +- one shared PTT ownership manager -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. +Multi-radio orchestration, remote authorization, and unrestricted public network listeners +are outside the 0.1 scope. -## 2. Goals +## System boundary -- 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 daemon is rig-agnostic by construction.** Only the `RadioBackend` is rig-specific; the state cache, polling, event fan-out, endpoints, 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 endpoint. -- The Hamlib net endpoint 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 endpoint; 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 endpoint 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 control-panel 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. +```text +radio applications + |-- private virtual serial pairs ------| + `-- dedicated Hamlib NET listeners ----|--> CatHub --> radio or private rigctld -## 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 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. - -## 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, 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 (`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 endpoints. Each client endpoint 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- | -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 | - +-----------------+ -``` - -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 endpoint; 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/cathub/` and is added to the workspace members in `Cargo.toml`. Internal modules: - -- `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-session FIFO ordering** and prioritizes only between the ready heads of the per-session queues: a client session's commands always reach the radio in submission order, while across sessions PTT outranks interactive writes, which outrank reads/passthrough, which outrank the background baseline poll. A read issued by a session after that session'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 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 endpoint 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 endpoints 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_endpoint` is the generic virtual-COM listener. Each configured endpoint binds one COM/PTY path and routes its byte stream into a configured dialect. Endpoints run concurrently as independent tasks. Two endpoints serving the same dialect against the same backend are supported and expected (one for HDSDR, one for another panadapter app, or N1MM and ARCP-590 both on the native TS-590 dialect). Each opened endpoint creates a client session with its own **outbound queue** so the daemon can push unsolicited frames (AI updates) independently of other sessions. -- `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), `i` (get split TX frequency), `x` (get split TX mode), `l RFPOWER` (get configured relative transmitter power), `2`/`\power2mW` (convert relative power to milliwatts), `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. - The native TS-590 backend polls the radio's `PC;` configured-power query and normalizes it with the TS-590's mode-sensitive maximum. The bridge backend probes downstream `rigctld` for `i`, `x`, `l RFPOWER`, and `power2mW`; unsupported optional probes return `RPRT -11` without failing the required frequency/mode poll. This lets the QsoRipper engine log true split RX/TX frequencies and configured TX power when the active backend exposes them. - 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. -- `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 session'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 endpoint'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-endpoint (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 endpoint 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 endpoint explicitly opts into unsafe full control. This lets the operator give ARCP-590 full control while restricting a panadapter endpoint to tuning only, and prevents a stray native command from keying TX or rewriting menu settings from an under-privileged endpoint. -- `config` loads TOML from `%APPDATA%\cathub\cathub.toml` on Windows and `$XDG_CONFIG_HOME/cathub/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-endpoint spans, a rolling file appender under `%LOCALAPPDATA%\cathub\logs\cathub.log` on Windows or `$XDG_STATE_HOME/cathub/cathub.log` on Linux, and a periodic summary line carrying commands per second per endpoint, 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 - -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>; - /// 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; -} +CW applications + |-- private virtual serial pairs ------| + `-- loopback typed gRPC ---------------|--> CatHub --> physical WinKeyer ``` -`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 endpoint returns `RPRT -11` for XIT commands against that backend without ever touching the wire. - -`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` (above) is the rig-specific seam; nothing else in the daemon is. - -The `ClientDialect` trait is the client-side abstraction: - -```rust -#[async_trait] -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: &ClientSessionContext) -> Vec; - /// 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: &ClientSessionContext) -> Option>; -} -``` - -`ClientSessionContext` carries the endpoint's permission set, its per-endpoint 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 endpoint 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 daemon owns physical transports and client-facing listeners. Applications own only the +opposite side of a virtual pair or a TCP connection. Each serial client receives its own +pair because a virtual COM port is still exclusive at either endpoint. -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. +## Radio state model -### 7.1.1 Which backend is best, and why the trait is the real abstraction +CatHub maintains a universal radio snapshot containing operating frequency, mode, VFO, +split state, RIT/XIT, PTT, and configured transmit power when the backend can provide it. -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: +All modeled writes pass through one scheduler. Reads are served from the snapshot when the +field is fresh. The native TS-590 backend enables the radio's auto-information stream and +uses a low-rate heartbeat once push events cover a field. Polling never sends VFO-select or +VFO-retarget commands. -- **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 control-panel 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 control-panel 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.2 v1 scope - -- 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). -- Endpoints: native serial endpoints for N1MM and ARCP-590, a TS-2000 serial endpoint for OmniRig, and the Hamlib net endpoint for WSJT-X and the engine. - -### 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_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; 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 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 - -### 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. Each client session 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 one session. A session's read therefore always observes that session's earlier writes, while an operator's keying and tuning can still preempt another client's bulk reads. Each command carries a oneshot reply channel. - -### 8.2 Coalesced polling - -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 endpoints 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: - -- **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 endpoint 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 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. 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: - -```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 -} -``` +Client sessions receive state-change notifications translated into their configured +dialect. A session's auto-information preference is virtual and does not enable or disable +the radio's physical push stream. -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. +Commands outside the modeled state can use the supported passthrough path. Passthrough is +serialized with modeled work, invalidates affected cached fields, and remains subject to +endpoint permissions. -**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. +## Backends -- 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 client session subscribes to the broadcast. For each change, the endpoint's configured dialect decides via `format_notification` whether and how to push it to that session. A native TS-590 client with 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 session'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. +### Native TS-590 -**Push ordering and backpressure.** Each session's outbound push queue is bounded. Pushes are interleaved with that session'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 sessions. +The native backend speaks the radio protocol directly and is the first-class path for +TS-590 stations. It owns `AI2;`, normalizes VFO and split state, deduplicates modeled writes, +and reads configured power from `PC;`. -**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. +### External rigctld -**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. +The bridge backend is the sole client of a private downstream `rigctld`. Client applications +still connect to CatHub, not to that private process. Required state probes drive the common +snapshot. Optional split and power probes may return Hamlib's not-supported result without +failing baseline operation. -**Lagged-subscriber resync.** The change-notification broadcast ring is bounded, so a momentarily slow session can miss intermediate one-shot changes (a single Mode or VFO toggle that is evicted before the session drains the channel). When a session observes broadcast *lag* it must **not** simply skip ahead - that would leave the client rendering permanently stale state until the next coincidental change. Instead the session replays the current `state` snapshot through its configured dialect (which still gates on that session's auto-info subscription), with the active-VFO selection applied last so the client ends on the correct receive VFO. +### Loopback -### 8.4.1 Auto-info mode granularity +The loopback backend supports deterministic tests without radio hardware. -The auto-info command is virtualized per session, 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-session 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. +## Client endpoints -### 8.4.2 Single-VFO operating-VFO virtualization +### Hamlib NET -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. +Each `[[hamlib_net]]` entry creates a TCP listener with its own permissions. Separate +listeners should be used for different authority groups, such as read-only monitors and +digital-mode programs allowed to write and key PTT. -The fix is a per-endpoint, opt-in **operating-VFO virtualization** policy (`single_vfo = true` on a `ts590` endpoint). When enabled, the endpoint never exposes the physical VFO letter; it always presents whichever VFO the radio is actually on (the operating / receive VFO) **as VFO A**: +The endpoint implements the tested subset of the `rigctld` network protocol, including +ordinary and extended-response forms used by supported clients. Compatibility is defined by +captured transcripts and tests, not by a claim to implement every Hamlib command. -- **`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 endpoint. -- **`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 endpoint, since a verbatim native frame could leak a physical VFO-B verb. +### Serial CAT -This policy is **opt-in per endpoint and off by default**. It is enabled only for genuine single-VFO loggers (N1MM SO1V). Dual-VFO control panels 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 endpoint always sees split = `0`. SO2V is an N1MM-side workaround, not a substitute for this hub-side virtualization. +Each `[[serial_endpoint]]` binds the daemon side of a virtual serial pair. The application +opens `application_transport`, the other side of that pair. A configured dialect parses the +client's command stream and translates modeled operations into the shared scheduler. -#### `single_vfo` on Hamlib NET (rigctld) endpoints +The transparent TS-590 dialect relays the real dual-VFO stream and therefore cannot use +single-VFO presentation. Modeled TS-590 and TS-2000 endpoints may enable `single_vfo` when a +client cannot operate correctly while VFO B is active. -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 endpoint presents the operating VFO as VFO A using a **strict** single-VFO contract - the inactive VFO is never exposed: +### Single-VFO presentation -- **`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. +`single_vfo = true` presents the current operating VFO as VFO A without changing the radio. +Reads, writes, and notifications still target the real operating VFO. Real A/B split cannot +be represented through this view, so split-enable requests are rejected. -The QsoRipper engine endpoint leaves `single_vfo = false` so the engine logs the true operating VFO letter. +## Permissions -### 8.5 PTT ownership and arbitration +Permissions are fixed per endpoint: -Multiple clients are now PTT-capable (N1MM CAT PTT, WSJT-X `T 1`, ARCP-590). The daemon arbitrates with a single-owner lease: +| Permission | Allows | +|---|---| +| `read` | State queries and non-mutating capability queries | +| `frequency_write` | Frequency changes without general mode or configuration writes | +| `write` | Modeled operating-state changes | +| `ptt` | Transmit and receive requests through the PTT lease | +| `config_write` | Persistent or maintenance-oriented device changes | -- A client session must inherit the `ptt` capability from its endpoint to key at all. Sessions without it have their PTT writes dropped with a logged warning. -- The first capable session to key acquires the **PTT lease** and becomes the PTT owner. While the lease is held, PTT writes from any other session 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 session that **disconnects while it holds the PTT lease** (TCP drop, client crash, EOF, or a protocol-violating over-long frame) releases the lease during session 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 session is the current lease owner, so it never disturbs another session's active transmission. +Unknown commands and unauthorized commands fail closed. A TCP listener is a policy group, +not a user identity boundary, so clients with different authority requirements need +different listeners. -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. +## PTT ownership and safety -### 8.6 Passthrough for rich native clients +CAT PTT and WinKeyer transmit jobs use one station-wide ownership manager. Only one session +may own transmit authority at a time. A conflicting request fails deterministically. -ARCP-590 (and any future control-panel-class controller) issues many commands the universal state does not model. The contract: +The safety rules are: -- 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 endpoint permissions (§6 `permissions`): an endpoint 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. +- every transmit lease has a configured maximum duration +- disconnecting the active owner forces receive and releases the lease +- backend or keyer failure releases transmit ownership +- graceful shutdown clears queued keying, forces key-up, and returns the radio to receive +- maintenance cannot begin while transmission is active or queued -### 8.7 Graceful recovery +Automated tests use loopback transports and must not key physical hardware. -If the radio transport disappears (USB unplugged, radio powered off), the daemon keeps the client endpoints 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. +## Configuration -### 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). - -- The daemon **never links Hamlib** in any first-class path. The Hamlib net endpoint 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 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-endpoint 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: +Standalone configuration uses top-level tables: ```toml [radio] -model = "kenwood-ts590" +backend = "ts590" transport = "serial" -port = "COM3" -baud = 115200 - -[poll] -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 +port = "COM4" +baud = 57600 -[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]] -name = "engine" +name = "logger-readonly" 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"] +perms = ["read"] [[serial_endpoint]] -name = "omnirig" -port = "COM10" -baud = 115200 -dialect = "kenwood-ts2000" -permissions = ["read"] # panadapter follows; does not drive the radio - -[[serial_endpoint]] -name = "n1mm" -port = "COM20" -baud = 115200 -dialect = "kenwood-ts590" -permissions = ["read", "write", "ptt"] - -[[serial_endpoint]] -name = "arcp590" -port = "COM30" -baud = 115200 -dialect = "kenwood-ts590" -permissions = ["read", "write", "ptt", "config_write"] # full control-panel control -``` - -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 `[[serial_endpoint]]` 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: - -```toml -[radio] -model = "icom-ic7300" -transport = "serial" -port = "COM3" -baud = 115200 -ci_v_address = 0x94 +name = "contest-logger" +transport = "COM20" +application_transport = "COM21" +dialect = "ts590" +single_vfo = true +perms = ["read", "write", "ptt"] ``` -Endpoints, polling, events, and the Hamlib net endpoint 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 endpoint and the TS-2000/CI-V dialects for modeled state, and a native Kenwood control-panel 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 endpoints, 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 - endpoints, 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 endpoint whether the backend is native or the `rigctld` bridge. The bridge does *not* carry native control-panel passthrough, so an `EX`-menu controller like ARCP-590 still requires a native same-family backend (§7.1). - -## 10. Validation strategy - -### 10.1 Unit tests - -- `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. -- `events` parsing of recorded spontaneous frames (Kenwood unsolicited `IF;`, and an Icom CI-V transceive sample) into mutations tagged `NativePush`, and per-endpoint 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 endpoints. -- `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_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 endpoint. 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 endpoint are visible to reads on every other endpoint; a simulated front-panel change (unsolicited frame from the loopback backend) propagates to all event-subscribed endpoints. -- 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 session acquires the lease, a second capable session 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 session 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. -- 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 an endpoint 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 endpoint - the OmniRig TS-2000 endpoint, the N1MM native endpoint, and the Hamlib net endpoint - 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 - -- **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 control-panel 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. -- 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 endpoints 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 with the priority inbox, the `LoopbackBackend`, the universal state with change-notification broadcast, the `Ts590Backend`, and the `Ts590Dialect` with passthrough. The N1MM endpoint works end-to-end against a real TS-590. The OmniRig endpoint, ARCP-590 endpoint, event fan-out, and the Hamlib net endpoint are not yet wired. - -Phase 2 adds the `Ts2000Dialect` and the OmniRig serial endpoint, plus the ARCP-590 native serial endpoint 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 endpoint. 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 control-panel 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. 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 `events` module (central ownership of the radio's native push stream - Kenwood `AI2;`, CI-V transceive, etc. - spontaneous-frame parsing tagged by `RadioEventSource`, per-endpoint 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. - -## 12. Risks - -- **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 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 endpoints 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 +The same tables may be embedded beneath `[cat_hub]` in a managed TOML document. CatHub owns +schema validation in both layouts. See [operator setup](../integration/setup.md) and the +complete [sample configuration](../../config/cathub.toml). -This design is consistent with the project's architectural principles: +## Runtime lifecycle -- **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. -- **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, endpoints, 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. +Startup proceeds in fail-closed order: -## 14. Validation gates for the implementation PRs +1. Load and validate configuration. +2. Open the radio backend and establish initial state. +3. Open the optional physical WinKeyer. +4. Bind every configured client endpoint. +5. Start polling, push reconciliation, and client session tasks. -When the implementation PRs land, each must pass: +A required bind or configuration failure stops startup. Runtime transport failures use +bounded reconnect backoff while listeners and diagnostic state remain available when safe. -- `cargo fmt --manifest-path Cargo.toml --all -- --check` -- `cargo clippy --manifest-path Cargo.toml --all-targets -- -D warnings` -- `cargo test --manifest-path Cargo.toml` -- `cargo llvm-cov --manifest-path 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; 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. +## Verification -## 15. Open questions +The repository gate covers formatting, Clippy, unit and integration tests, protobuf lint, +.NET protocol generation, and dependency policy. Protocol compatibility is verified with +fixtures and transcript-oriented tests for supported commands. -- 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 endpoint for browser clients. Out of scope for v1; trivial to add later as another `ClientDialect`-shaped seam. -- Whether the per-endpoint 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. +Hardware acceptance remains an attended activity. Validate one client at a time, confirm +read-only rejection and PTT timeout behavior, then add concurrent clients while watching the +CatHub log. -## 16. References +## Implementation map -- Existing VFO B writeup: . -- 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: . -- 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). +- `crates/cathub/src/radio`: scheduler and physical radio link +- `crates/cathub/src/state.rs`: shared snapshot +- `crates/cathub/src/backend`: radio backends +- `crates/cathub/src/dialect`: serial client dialects +- `crates/cathub/src/hamlib_net.rs`: Hamlib NET listeners +- `crates/cathub/src/serial_endpoint.rs`: virtual serial sessions +- `crates/cathub/src/ptt.rs`: station PTT ownership +- `crates/cathub/src/winkeyer`: WinKeyer actor, broker, endpoints, and typed API diff --git a/docs/design/winkeyer-broker.md b/docs/design/winkeyer-broker.md index a04c2fb..38605f8 100644 --- a/docs/design/winkeyer-broker.md +++ b/docs/design/winkeyer-broker.md @@ -2,11 +2,14 @@ ## 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 endpoint. The keyer subsystem is independent of the radio CAT actor, but both use the station PTT ownership manager. +CatHub is the sole owner of the physical WinKeyer serial port. Native clients use a typed +loopback gRPC API. Each unmodified legacy program receives a dedicated virtual WinKeyer +serial endpoint. 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 + |-- typed loopback API --> native client |-- virtual COM endpoint ----> N1MM `-- virtual COM endpoint ----> maintenance tool ``` @@ -47,10 +50,14 @@ Reset, calibration, EEPROM dump/load, firmware update, high-baud switching, and 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 endpoint should not receive `config_write`. +Routine keying never requires EEPROM writes. Normal operating endpoints should not receive +`config_write`. ## Configuration -Unified configuration uses `[cat_hub.winkeyer]` and `[[cat_hub.winkeyer_endpoint]]`; standalone CatHub files omit the `cat_hub.` prefix. The API must bind to loopback. Physical and virtual transports must be distinct, only one endpoint may be primary, and dependent permission combinations are validated by CatHub and both setup engines. +Standalone configuration uses `[winkeyer]` and `[[winkeyer_endpoint]]`. Managed documents +place the same tables beneath `[cat_hub]`. The API must bind to loopback. Physical and virtual +transports must be distinct, only one endpoint may be primary, and CatHub validates dependent +permission combinations. -See [CW keying setup](../integrations/cw-keying.md) for the complete operator workflow. +See [operator setup](../integration/setup.md) for the complete workflow. diff --git a/docs/integration/setup.md b/docs/integration/setup.md index 2ba1f02..ea463d9 100644 --- a/docs/integration/setup.md +++ b/docs/integration/setup.md @@ -1,305 +1,246 @@ -# cathub operator setup +# CatHub operator setup -`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: +This guide configures one CatHub process to own a radio and optional WinKeyer while several +applications connect through dedicated endpoints. The examples use Windows, a Kenwood +TS-590, com0com virtual serial pairs, and common amateur-radio applications. Substitute the +ports and clients used by your station. -- 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). +## 1. Install CatHub -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. +Download the matching platform archive from the +[GitHub Releases page](https://github.com/treitforge/cathub/releases), verify the adjacent +SHA-256 checksum, extract it, and place `cathub` or `cathub.exe` on `PATH`. -See `docs/design/multi-client-cat-hub.md` for the architecture and behavior contracts. +If the daemon crate has been published for that version and Rust is already installed, the +equivalent installation is: -## 1. Retire the legacy chain +```powershell +cargo install cathub --version +``` -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). +No public release has been tagged yet. Until the first release exists, build from source: - Get-Process rigctld, rigctlcom -ErrorAction SilentlyContinue | Stop-Process - # also stop any safe-bridge Python process and any app still bound directly to COM4 +```powershell +git clone https://github.com/treitforge/cathub.git +Set-Location cathub +cargo build --release -p cathub +``` -Remove or disable any legacy startup hooks that relaunch `rigctld.exe`; otherwise it can -reclaim the radio COM port after you stop CatHub. Check scheduled tasks first: +The installed daemon does not require the `cathub-protocol` Rust crate, the +`CatHub.Protocol` NuGet package, the .NET SDK, Buf, or a separate `protoc` installation. +Those tools and packages are for source builds or client development. - Get-ScheduledTask -TaskName QsoRipper-rigctld -ErrorAction SilentlyContinue - Unregister-ScheduledTask -TaskName QsoRipper-rigctld -Confirm:$false +Confirm the executable: -Confirm nothing else holds COM4 before continuing. +```powershell +cathub --version +cathub --help +``` -## 2. Create virtual serial pairs (com0com) +## 2. Inventory physical devices -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: +Record: - 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) +- the radio serial port and its configured CAT baud rate +- the physical WinKeyer port, if present +- every application that needs radio state, radio writes, PTT, or keying +- whether each application supports Hamlib NET, a vendor serial dialect, or WinKeyer serial -WSJT-X, Log4OM, and the QsoRipper engine use the Hamlib NET (TCP) endpoints instead and need -no serial pair. +Only CatHub may open the physical radio and keyer ports. Stop any `rigctld`, serial bridge, +logger, digital-mode program, or manufacturer utility that currently owns either device. +Remove startup tasks that would relaunch an old bridge. -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 endpoint. +Do not proceed until the physical ports are free. -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. Create virtual serial pairs -Record the application side as `application_transport` on each `[[cat_hub.serial_endpoint]]` and -`[[cat_hub.winkeyer_endpoint]]`. 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. +Applications that require a COM port need one dedicated null-modem pair each. CatHub opens +one side and the application opens the other. Hamlib NET and typed gRPC clients use TCP and +do not need a pair. -## 3. Configure the daemon +Example com0com pairs: -CatHub's authoritative settings live in its own `cathub.toml` file: +```text +COM10 <-> COM11 SDR software through OmniRig +COM20 <-> COM21 contest logger radio CAT +COM30 <-> COM31 manufacturer control panel +COM40 <-> COM41 contest logger WinKeyer +COM42 <-> COM43 WinKeyer maintenance tool +``` + +With com0com's `setupc` utility: + +```text +install PortName=COM10 PortName=COM11 +install PortName=COM20 PortName=COM21 +install PortName=COM30 PortName=COM31 +install PortName=COM40 PortName=COM41 +install PortName=COM42 PortName=COM43 +``` + +The lower, even port in this example is CatHub's `transport`. The other port is +`application_transport`. Never point an application at CatHub's side of the pair. + +On Linux, use stable PTY or virtual-serial endpoints managed by the host. Ensure the CatHub +service account can open the radio, keyer, and hub-side paths. + +## 4. Create cathub.toml + +Copy the repository's [sample configuration](../../config/cathub.toml) to the platform +default: - Windows: `%APPDATA%\cathub\cathub.toml` -- Linux/macOS: `$XDG_CONFIG_HOME/cathub/cathub.toml` (or `~/.config/cathub/cathub.toml`) -- Override the location with `CATHUB_CONFIG_PATH` or `--config`. +- Linux: `$XDG_CONFIG_HOME/cathub/cathub.toml`, or `~/.config/cathub/cathub.toml` + +Set `CATHUB_CONFIG_PATH` or pass `--config` to use another location. The standalone file uses top-level `[radio]`, `[poll]`, `[ptt]`, `[events]`, `[[serial_endpoint]]`, `[[hamlib_net]]`, `[winkeyer]`, and `[[winkeyer_endpoint]]` tables. -The repository ships a complete example at `config\cathub.toml`. Validate it without -touching hardware: +Delete unused example endpoints and replace every port with the station's actual values. - cargo run -p cathub -- config validate --config config\cathub.toml - cargo run -p cathub -- config print-effective --config config\cathub.toml +The radio baud must match the radio's menu setting. Virtual serial endpoint baud values +describe the client-facing protocol and do not replace the physical radio baud. -CatHub also accepts an existing QsoRipper unified file with settings nested under `[cat_hub]`. -Managed launchers should pass `--section cat_hub` to require that layout explicitly. -To move that section into CatHub's standalone file without changing the source: +CatHub also accepts the same tables beneath `[cat_hub]` in a larger managed TOML document. +Require that layout with `--section cat_hub`. To extract it into a standalone file: - cargo run -p cathub -- config migrate ` - --from "$env:APPDATA\qsoripper\config.toml" ` - --output "$env:APPDATA\cathub\cathub.toml" +```powershell +cathub config migrate ` + --from C:\station\managed-config.toml ` + --output "$env:APPDATA\cathub\cathub.toml" +``` -Add `--remove-source-section` only when you are ready to stop using managed compatibility -mode. CatHub creates a `.bak` copy before changing the source file. +## 5. Validate without hardware -To move in the other direction, copy the standalone tables beneath a new `[cat_hub]` -namespace in QsoRipper's unified file, validate with `cathub --section cat_hub config -validate --config `, and only then point the managed launcher at that file. Preserve -the standalone file until the managed launch has been verified. +Validation and effective-config printing do not open the radio or keyer: -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 -`[[serial_endpoint]].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. +```powershell +cathub config validate +cathub config print-effective +cathub config print-effective --format json +``` -## 4. Start the hub +When running from a source checkout with the sample file: - .\scripts\Start-CatHub.ps1 +```powershell +cargo run -p cathub -- config validate --config config\cathub.toml +cargo run -p cathub -- config print-effective --config config\cathub.toml +``` -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 -endpoint, and binds each Hamlib NET endpoint. Watch the log in another terminal: +Correct every validation error before starting the daemon. - .\scripts\Get-CatHubLog.ps1 -Follow +## 6. Start and inspect CatHub -Stop the hub with Ctrl+C in its window, or: +Start an installed daemon: - .\scripts\Stop-CatHub.ps1 +```powershell +cathub +``` -Build and test CatHub independently with `.\build.ps1` and `.\test.ps1`. QsoRipper's launcher -can also manage an installed CatHub binary through `CATHUB_EXECUTABLE`, `PATH`, or its optional -bundled-artifact location. Managed mode may continue to pass QsoRipper's unified file while it -contains a valid `[cat_hub]` table. +From a source checkout, the helper builds and starts it: -## 5. Point each application at the hub +```powershell +.\scripts\Start-CatHub.ps1 +``` -### HDSDR (via OmniRig) -- OmniRig: Rig type `Kenwood TS-2000`, port **COM11**, baud 115200, 8-N-1. -- The `hdsdr-omnirig` endpoint 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. +The rolling log is under `%LOCALAPPDATA%\cathub\logs` on Windows and +`$XDG_STATE_HOME/cathub` or `~/.local/state/cathub` on Linux. From a source checkout: -### 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` endpoint 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 endpoint). See design §8.4.2. -- Keep the everyday `n1mm-cw` WinKeyer endpoint limited to `status`, `send`, `control`, and `ptt`. - Persistent EEPROM/reset access belongs on a separate, normally disabled maintenance endpoint. -- 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. +```powershell +.\scripts\Get-CatHubLog.ps1 -Follow +``` -### ARCP-590 -- Set ARCP-590's COM port to **COM31**, 115200, 8-N-1. -- The `arcp590` endpoint is `dialect = "ts590"`, `perms = ["read", "write", "ptt", "config_write"]` - so the full control-panel (including EX-menu writes) works. +Stop with Ctrl+C. The repository stop helper requests confirmation before terminating a +background CatHub process: + +```powershell +.\scripts\Stop-CatHub.ps1 +``` + +## 7. Connect applications + +Add clients one at a time. Confirm read behavior before enabling writes or PTT. + +### Read-only Hamlib NET client + +Point any logger or monitor that supports Hamlib NET rigctl at the configured read-only +listener, such as `127.0.0.1:4532`. It should receive frequency, mode, VFO, split, and power +data when the backend exposes them. Set commands must fail. ### 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"]`, `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;`, - `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:** 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 - 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. - -### 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 -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. + +- Rig: `Hamlib NET rigctl` +- Network Server: the dedicated write/PTT listener, such as `127.0.0.1:4533` +- PTT method: `CAT` +- Split Operation: `Fake It` when the endpoint uses `single_vfo = true` +- Mode: `Data/Pkt` for normal digital operation + +Give every simultaneously running digital-mode program its own listener. Separate listeners +prevent one program's mode or PTT policy from affecting another. ### Log4OM -- CAT interface: Hamlib `NET rigctl`, host **127.0.0.1**, port **4534**. -- 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` endpoint 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** - (`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. -- The read-only endpoint exposes split TX frequency/mode and configured transmitter power when - the radio backend supplies them. During split operation the engine logs the transmit side as - `FREQ`/`BAND`, the receive side as `FREQ_RX`/`BAND_RX`, and configured power as `TX_PWR`. - Unsupported optional values remain blank and do not make rig control appear disconnected. -- 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 - 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). -- N1MM cannot open the WinKeyer: select COM41, not COM3 or COM40, and confirm the hub log says - the `n1mm-cw` endpoint 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 - 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 endpoint's current state (`AI0;`/`AI2;`) without disabling it, and the hub fans - out native-push frames to any endpoint 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=cathub::serial_endpoint=trace` to see each endpoint's request/reply/notify - frames, which is the fastest way to diagnose a client handshake. - -## 8. Known v1 limitations - -- **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 endpoints 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 endpoint 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. + +Configure Hamlib NET rigctl with the dedicated Log4OM address, such as +`127.0.0.1:4534`. The sample enables `single_vfo` so Log4OM's VFO A polling follows the real +operating VFO. + +### N1MM Logger+ + +- Radio: `Kenwood`, application port `COM21`, 115200 baud, 8-N-1 +- WinKeyer: application port `COM41`, 1200 baud + +The sample radio endpoint uses TS-590 dialect, read/write/PTT permissions, and single-VFO +presentation for SO1V operation. The normal WinKeyer endpoint has status, send, control, and +PTT permissions but no `config_write`. + +### HDSDR through OmniRig + +Configure OmniRig as Kenwood TS-2000 on application port `COM11`. The sample grants read and +frequency-write only, allowing click-to-tune while preventing mode and PTT changes. + +### ARCP-590 + +Configure application port `COM31`. The sample grants full TS-590 read, write, PTT, and +configuration-write access because the manufacturer control panel needs commands outside the +modeled operating state. + +### WinKeyer maintenance + +Configure the maintenance utility on application port `COM43`. Keep maintenance permissions +on a separate endpoint that is normally unused. Close the utility when maintenance ends so +it releases the virtual port and lease. + +### Typed WinKeyer client + +Use the loopback gRPC address in `[winkeyer].api_bind`, normally +`http://127.0.0.1:50071`. Supply a stable `client_name` so cancellation and telemetry remain +scoped to that client. + +## 8. Safety acceptance + +Perform transmit tests while attended and into a suitable load: + +1. Verify read-only endpoints reject frequency, mode, and PTT writes. +2. Key and unkey from one authorized CAT client. +3. Confirm a second client cannot acquire PTT while the first owns it. +4. Disconnect the active owner and confirm the station returns to receive. +5. Send a short typed or virtual WinKeyer job and verify completion. +6. Test scoped cancel, active-owner disconnect, and the transmit watchdog. +7. Confirm graceful shutdown leaves both radio and keyer unkeyed. + +## Troubleshooting + +| Symptom | Check | +|---|---| +| Physical port is busy | Stop every direct radio/keyer client and old bridge. CatHub must be the only physical owner. | +| Radio opens but does not answer | Match `[radio].baud` to the radio menu and verify the physical port. | +| Serial client cannot open a port | The client must open `application_transport`, not CatHub's `transport`. | +| Hamlib NET client cannot connect | Confirm the listener address, firewall policy, and that CatHub completed startup. | +| Writes return not supported | Check endpoint permissions and whether the command is modeled for that dialect. | +| Digital mode stops on VFO B | Enable `single_vfo` for that client and use fake split. | +| PTT is busy | Find the current lease owner in the log and confirm the previous client unkeyed. | +| Typed keyer API is unavailable | Verify `[winkeyer].api_bind` is loopback and not already in use. | +| Maintenance is rejected | Stop active and queued sends, then connect through the `config_write` endpoint. | + +Set `CATHUB_LOG=debug` for verbose tracing. Narrow it to a module, such as +`CATHUB_LOG=cathub::serial_endpoint=trace`, when diagnosing one interface. From 6924cf7deba0ee4195205e1c7d8dbff8cdbdb439 Mon Sep 17 00:00:00 2001 From: Randy Treit Date: Wed, 15 Jul 2026 14:47:35 -0700 Subject: [PATCH 2/2] Automate ordered registry publication --- .github/workflows/publish-registries.yml | 196 ++++++++++++++++++ .../architecture/release-and-compatibility.md | 25 ++- 2 files changed, 208 insertions(+), 13 deletions(-) create mode 100644 .github/workflows/publish-registries.yml diff --git a/.github/workflows/publish-registries.yml b/.github/workflows/publish-registries.yml new file mode 100644 index 0000000..2c716df --- /dev/null +++ b/.github/workflows/publish-registries.yml @@ -0,0 +1,196 @@ +name: Publish registries + +on: + workflow_dispatch: + inputs: + release_tag: + description: Verified GitHub release tag to publish + required: true + default: v0.1.0 + type: string + +permissions: + contents: read + +concurrency: + group: publish-registries-${{ inputs.release_tag }} + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-latest + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + RELEASE_TAG: ${{ inputs.release_tag }} + steps: + - uses: actions/checkout@v7.0.0 + with: + ref: ${{ inputs.release_tag }} + - uses: dtolnay/rust-toolchain@stable + - uses: actions/setup-dotnet@v5.4.0 + with: + dotnet-version: 10.0.x + + - name: Validate release input and credentials + shell: pwsh + run: | + if ($env:RELEASE_TAG -notmatch '^v(?\d+\.\d+\.\d+)$') { + throw "release_tag must have the form vMAJOR.MINOR.PATCH" + } + "VERSION=$($Matches.version)" | Add-Content -LiteralPath $env:GITHUB_ENV + if ([string]::IsNullOrWhiteSpace($env:CARGO_REGISTRY_TOKEN)) { + throw "The CARGO_REGISTRY_TOKEN repository secret is not configured" + } + if ([string]::IsNullOrWhiteSpace($env:NUGET_API_KEY)) { + throw "The NUGET_API_KEY repository secret is not configured" + } + + - name: Verify GitHub release and checksums + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + $release = gh release view $env:RELEASE_TAG ` + --repo $env:GITHUB_REPOSITORY ` + --json tagName,isDraft,isPrerelease | ConvertFrom-Json + if ($release.tagName -ne $env:RELEASE_TAG -or $release.isDraft -or $release.isPrerelease) { + throw "The requested tag is not a published stable GitHub release" + } + + New-Item -ItemType Directory -Path release -Force | Out-Null + gh release download $env:RELEASE_TAG ` + --repo $env:GITHUB_REPOSITORY ` + --dir release + + $requiredAssets = @( + "cathub-$($env:RELEASE_TAG)-windows-x86_64.zip", + "cathub-$($env:RELEASE_TAG)-linux-x86_64.tar.gz", + "cathub-protocol-$($env:VERSION).crate", + "CatHub.Protocol.$($env:VERSION).nupkg" + ) + foreach ($asset in $requiredAssets) { + if (-not (Test-Path -LiteralPath "release/$asset" -PathType Leaf)) { + throw "Required release asset is missing: $asset" + } + if (-not (Test-Path -LiteralPath "release/$asset.sha256" -PathType Leaf)) { + throw "Required checksum is missing: $asset.sha256" + } + } + + Get-ChildItem -LiteralPath release -Filter *.sha256 | ForEach-Object { + $line = (Get-Content -LiteralPath $_.FullName -Raw).Trim() + if ($line -notmatch '^(?[a-f0-9]{64}) (?.+)$') { + throw "Invalid checksum format: $($_.Name)" + } + $assetPath = Join-Path $_.DirectoryName $Matches.file + $actual = (Get-FileHash -LiteralPath $assetPath -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $Matches.hash) { + throw "Checksum mismatch: $($Matches.file)" + } + } + + - name: Verify source versions + shell: pwsh + run: | + $metadata = cargo metadata --no-deps --format-version 1 | ConvertFrom-Json + $rustPackages = $metadata.packages | + Where-Object { $_.name -in @('cathub', 'cathub-protocol') } + foreach ($package in $rustPackages) { + if ($package.version -ne $env:VERSION) { + throw "$($package.name) version $($package.version) does not match $($env:VERSION)" + } + } + + [xml]$project = Get-Content -LiteralPath src/dotnet/CatHub.Protocol/CatHub.Protocol.csproj + $nugetVersion = [string]$project.Project.PropertyGroup.Version + if ($nugetVersion -ne $env:VERSION) { + throw "CatHub.Protocol version $nugetVersion does not match $($env:VERSION)" + } + + - name: Publish cathub-protocol + shell: pwsh + run: | + $uri = "https://crates.io/api/v1/crates/cathub-protocol/$($env:VERSION)" + try { + Invoke-RestMethod -Uri $uri -Headers @{ 'User-Agent' = 'CatHub release workflow' } | Out-Null + Write-Host "cathub-protocol $($env:VERSION) is already published" + } catch { + if ($_.Exception.Response.StatusCode -ne 404) { + throw + } + cargo publish --locked -p cathub-protocol + if ($LASTEXITCODE -ne 0) { + throw "cargo publish failed for cathub-protocol" + } + } + + - name: Wait for cathub-protocol indexing + shell: pwsh + run: | + $indexed = $false + foreach ($attempt in 1..60) { + cargo info "cathub-protocol@$($env:VERSION)" *> $null + if ($LASTEXITCODE -eq 0) { + $indexed = $true + break + } + Write-Host "Waiting for crates.io indexing ($attempt/60)" + Start-Sleep -Seconds 10 + } + if (-not $indexed) { + throw "cathub-protocol $($env:VERSION) was not indexed within 10 minutes" + } + + - name: Publish cathub + shell: pwsh + run: | + $uri = "https://crates.io/api/v1/crates/cathub/$($env:VERSION)" + try { + Invoke-RestMethod -Uri $uri -Headers @{ 'User-Agent' = 'CatHub release workflow' } | Out-Null + Write-Host "cathub $($env:VERSION) is already published" + } catch { + if ($_.Exception.Response.StatusCode -ne 404) { + throw + } + cargo publish --locked -p cathub + if ($LASTEXITCODE -ne 0) { + throw "cargo publish failed for cathub" + } + } + + - name: Publish CatHub.Protocol + shell: pwsh + run: | + dotnet nuget push "release/CatHub.Protocol.$($env:VERSION).nupkg" ` + --source https://api.nuget.org/v3/index.json ` + --api-key $env:NUGET_API_KEY ` + --skip-duplicate + if ($LASTEXITCODE -ne 0) { + throw "dotnet nuget push failed for CatHub.Protocol" + } + + - name: Verify registry packages + shell: pwsh + run: | + $crates = @('cathub-protocol', 'cathub') + foreach ($crate in $crates) { + $uri = "https://crates.io/api/v1/crates/$crate/$($env:VERSION)" + Invoke-RestMethod -Uri $uri -Headers @{ 'User-Agent' = 'CatHub release workflow' } | Out-Null + } + + $nugetUri = "https://api.nuget.org/v3-flatcontainer/cathub.protocol/$($env:VERSION)/cathub.protocol.$($env:VERSION).nupkg" + $indexed = $false + foreach ($attempt in 1..60) { + try { + Invoke-WebRequest -Method Head -Uri $nugetUri | Out-Null + $indexed = $true + break + } catch { + Write-Host "Waiting for NuGet indexing ($attempt/60)" + Start-Sleep -Seconds 10 + } + } + if (-not $indexed) { + throw "CatHub.Protocol $($env:VERSION) was not indexed within 10 minutes" + } diff --git a/docs/architecture/release-and-compatibility.md b/docs/architecture/release-and-compatibility.md index 4d2ff92..e27ac4f 100644 --- a/docs/architecture/release-and-compatibility.md +++ b/docs/architecture/release-and-compatibility.md @@ -18,24 +18,23 @@ tag does not publish to crates.io or NuGet. ## Registry publication order Registry publication requires the release owner's credentials and must occur only after the -tagged release passes CI: +tagged release passes CI. Configure the `CARGO_REGISTRY_TOKEN` and `NUGET_API_KEY` Actions +repository secrets, then dispatch the trusted publication workflow: ```powershell -cargo publish -p cathub-protocol -cargo search cathub-protocol --limit 1 -cargo publish -p cathub -dotnet nuget push artifacts\release\CatHub.Protocol..nupkg ` - --source https://api.nuget.org/v3/index.json ` - --api-key $env:NUGET_API_KEY +gh workflow run publish-registries.yml -f release_tag=v0.1.0 ``` -Do not print either registry token. Verify package ownership, the expected version, and the -tag before publishing. The daemon crate depends on the same version of `cathub-protocol`. -Publish the protocol crate first, wait until the crates.io index exposes that version, and -only then publish `cathub`. +The workflow rejects a draft or prerelease, verifies every release asset checksum, and checks +that the tagged Rust and NuGet versions match. It publishes `cathub-protocol`, waits until +Cargo can resolve that exact version from the crates.io index, publishes `cathub`, and then +publishes `CatHub.Protocol`. Finally, it verifies both crates through the crates.io API and +waits for the NuGet package to become available. Existing versions are detected so a retry +can safely continue after a partial registry failure. -The current workflow does not perform registry publication. A release owner must run these -commands or add separately authorized trusted-publishing jobs. +Do not print either registry token. Verify package ownership, the expected version, and the +tag before dispatching the workflow. The daemon crate depends on the same version of +`cathub-protocol`. ## Wire compatibility