diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..2ee9ade
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1 @@
+crates/cathub/src/hamlib_dump_state.txt text eol=lf
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..8b61052
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,36 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+jobs:
+ rust:
+ strategy:
+ matrix:
+ os: [windows-latest, ubuntu-latest]
+ runs-on: ${{ matrix.os }}
+ steps:
+ - uses: actions/checkout@v7.0.0
+ - uses: dtolnay/rust-toolchain@stable
+ with:
+ components: rustfmt, clippy
+ - uses: bufbuild/buf-action@v1.4.0
+ with:
+ version: 1.50.0
+ github_token: ${{ github.token }}
+ setup_only: true
+ - run: cargo fmt --all -- --check
+ - run: cargo clippy --workspace --all-targets -- -D warnings
+ - run: cargo test --workspace
+ - run: buf lint
+
+ dotnet-protocol:
+ runs-on: windows-latest
+ steps:
+ - uses: actions/checkout@v7.0.0
+ - uses: actions/setup-dotnet@v5.4.0
+ with:
+ dotnet-version: 10.0.x
+ - run: dotnet build CatHub.slnx -c Release
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..3d1644d
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,85 @@
+name: Release
+
+on:
+ push:
+ tags:
+ - "v*"
+
+permissions:
+ contents: write
+
+jobs:
+ daemon:
+ strategy:
+ matrix:
+ include:
+ - os: windows-latest
+ rid: windows-x86_64
+ binary: cathub.exe
+ - os: ubuntu-latest
+ rid: linux-x86_64
+ binary: cathub
+ runs-on: ${{ matrix.os }}
+ steps:
+ - uses: actions/checkout@v4
+ - uses: dtolnay/rust-toolchain@stable
+ - run: cargo build --release -p cathub
+ - name: Assemble release archive
+ shell: pwsh
+ run: |
+ $name = "cathub-${{ github.ref_name }}-${{ matrix.rid }}"
+ New-Item -ItemType Directory -Path "artifacts/release/$name" -Force | Out-Null
+ Copy-Item "target/release/${{ matrix.binary }}" "artifacts/release/$name/"
+ Copy-Item README.md, LICENSE "artifacts/release/$name/"
+ Copy-Item config "artifacts/release/$name/config" -Recurse
+ if ('${{ runner.os }}' -eq 'Windows') {
+ Compress-Archive -Path "artifacts/release/$name" -DestinationPath "artifacts/release/$name.zip"
+ $archive = "artifacts/release/$name.zip"
+ } else {
+ & tar -C artifacts/release -czf "artifacts/release/$name.tar.gz" $name
+ $archive = "artifacts/release/$name.tar.gz"
+ }
+ $hash = (Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash.ToLowerInvariant()
+ "$hash $([System.IO.Path]::GetFileName($archive))" |
+ Set-Content -LiteralPath "$archive.sha256" -NoNewline
+ Remove-Item -LiteralPath "artifacts/release/$name" -Recurse
+ - uses: actions/upload-artifact@v4
+ with:
+ name: cathub-${{ matrix.rid }}
+ path: artifacts/release/*
+
+ protocols:
+ runs-on: windows-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: dtolnay/rust-toolchain@stable
+ - uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 10.0.x
+ - run: cargo package -p cathub-protocol
+ - run: dotnet pack src\dotnet\CatHub.Protocol\CatHub.Protocol.csproj -c Release -o artifacts\release
+ - name: Collect protocol packages and checksums
+ shell: pwsh
+ run: |
+ Copy-Item target\package\cathub-protocol-*.crate artifacts\release\
+ Get-ChildItem artifacts\release -File | ForEach-Object {
+ $hash = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant()
+ "$hash $($_.Name)" | Set-Content -LiteralPath "$($_.FullName).sha256" -NoNewline
+ }
+ - uses: actions/upload-artifact@v4
+ with:
+ name: cathub-protocols
+ path: artifacts/release/*
+
+ publish:
+ needs: [daemon, protocols]
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/download-artifact@v4
+ with:
+ path: release
+ merge-multiple: true
+ - uses: softprops/action-gh-release@v2
+ with:
+ generate_release_notes: true
+ files: release/*
diff --git a/.gitignore b/.gitignore
index ad67955..0d175d5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,10 @@
# will have compiled files and executables
debug
target
+artifacts
+.vs
+**/bin
+**/obj
# These are backup files generated by rustfmt
**/*.rs.bk
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..f5cf37f
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,16 @@
+# Contributing
+
+Use a current stable Rust toolchain, the .NET 10 SDK, Buf, and PowerShell 7.
+
+Run the complete local gate before opening a pull request:
+
+```powershell
+.\build.ps1 check
+```
+
+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.
+
+Hardware transmission tests must be attended. Automated tests must not key a physical
+transmitter.
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..97dcac2
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,1588 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "anstream"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
+
+[[package]]
+name = "anstyle-parse"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
+dependencies = [
+ "anstyle",
+ "once_cell_polyfill",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.103"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
+
+[[package]]
+name = "async-stream"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476"
+dependencies = [
+ "async-stream-impl",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-stream-impl"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "async-trait"
+version = "0.1.89"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "axum"
+version = "0.7.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
+dependencies = [
+ "async-trait",
+ "axum-core",
+ "bytes",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "itoa",
+ "matchit",
+ "memchr",
+ "mime",
+ "percent-encoding",
+ "pin-project-lite",
+ "rustversion",
+ "serde",
+ "sync_wrapper",
+ "tower 0.5.3",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
+name = "axum-core"
+version = "0.4.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199"
+dependencies = [
+ "async-trait",
+ "bytes",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "mime",
+ "pin-project-lite",
+ "rustversion",
+ "sync_wrapper",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "bitflags"
+version = "2.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
+
+[[package]]
+name = "bytes"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
+
+[[package]]
+name = "cathub"
+version = "0.1.0"
+dependencies = [
+ "async-trait",
+ "bytes",
+ "cathub-protocol",
+ "clap",
+ "serde",
+ "serde_json",
+ "serial2-tokio",
+ "thiserror",
+ "tokio",
+ "tokio-stream",
+ "toml",
+ "toml_edit",
+ "tonic",
+ "tracing",
+ "tracing-appender",
+ "tracing-subscriber",
+]
+
+[[package]]
+name = "cathub-protocol"
+version = "0.1.0"
+dependencies = [
+ "prost",
+ "protoc-bin-vendored",
+ "tonic",
+ "tonic-build",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "clap"
+version = "4.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "clap_lex"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
+
+[[package]]
+name = "colorchoice"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
+
+[[package]]
+name = "crossbeam-channel"
+version = "0.5.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
+
+[[package]]
+name = "deranged"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
+
+[[package]]
+name = "either"
+version = "1.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
+
+[[package]]
+name = "fixedbitset"
+version = "0.5.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
+
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
+[[package]]
+name = "futures-channel"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
+
+[[package]]
+name = "futures-sink"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
+
+[[package]]
+name = "futures-task"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
+
+[[package]]
+name = "futures-util"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+]
+
+[[package]]
+name = "h2"
+version = "0.4.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "fnv",
+ "futures-core",
+ "futures-sink",
+ "http",
+ "indexmap 2.14.0",
+ "slab",
+ "tokio",
+ "tokio-util",
+ "tracing",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "http"
+version = "1.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
+dependencies = [
+ "bytes",
+ "itoa",
+]
+
+[[package]]
+name = "http-body"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
+dependencies = [
+ "bytes",
+ "http",
+]
+
+[[package]]
+name = "http-body-util"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "httparse"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+
+[[package]]
+name = "httpdate"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
+
+[[package]]
+name = "hyper"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "h2",
+ "http",
+ "http-body",
+ "httparse",
+ "httpdate",
+ "itoa",
+ "pin-project-lite",
+ "smallvec",
+ "tokio",
+ "want",
+]
+
+[[package]]
+name = "hyper-timeout"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0"
+dependencies = [
+ "hyper",
+ "hyper-util",
+ "pin-project-lite",
+ "tokio",
+ "tower-service",
+]
+
+[[package]]
+name = "hyper-util"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
+dependencies = [
+ "bytes",
+ "futures-channel",
+ "futures-util",
+ "http",
+ "http-body",
+ "hyper",
+ "libc",
+ "pin-project-lite",
+ "socket2 0.6.5",
+ "tokio",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "indexmap"
+version = "1.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
+dependencies = [
+ "autocfg",
+ "hashbrown 0.12.3",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.17.1",
+]
+
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+
+[[package]]
+name = "itertools"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
+dependencies = [
+ "either",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "lazy_static"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+
+[[package]]
+name = "libc"
+version = "0.2.186"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "log"
+version = "0.4.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
+
+[[package]]
+name = "matchers"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
+dependencies = [
+ "regex-automata",
+]
+
+[[package]]
+name = "matchit"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "mime"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+
+[[package]]
+name = "mio"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
+dependencies = [
+ "libc",
+ "wasi",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "multimap"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084"
+
+[[package]]
+name = "nu-ansi-term"
+version = "0.50.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "num-conv"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "once_cell_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "petgraph"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772"
+dependencies = [
+ "fixedbitset",
+ "indexmap 2.14.0",
+]
+
+[[package]]
+name = "pin-project"
+version = "1.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
+dependencies = [
+ "pin-project-internal",
+]
+
+[[package]]
+name = "pin-project-internal"
+version = "1.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "powerfmt"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
+[[package]]
+name = "prettyplease"
+version = "0.2.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
+dependencies = [
+ "proc-macro2",
+ "syn",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "prost"
+version = "0.13.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5"
+dependencies = [
+ "bytes",
+ "prost-derive",
+]
+
+[[package]]
+name = "prost-build"
+version = "0.13.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf"
+dependencies = [
+ "heck",
+ "itertools",
+ "log",
+ "multimap",
+ "once_cell",
+ "petgraph",
+ "prettyplease",
+ "prost",
+ "prost-types",
+ "regex",
+ "syn",
+ "tempfile",
+]
+
+[[package]]
+name = "prost-derive"
+version = "0.13.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
+dependencies = [
+ "anyhow",
+ "itertools",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "prost-types"
+version = "0.13.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16"
+dependencies = [
+ "prost",
+]
+
+[[package]]
+name = "protoc-bin-vendored"
+version = "3.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1c381df33c98266b5f08186583660090a4ffa0889e76c7e9a5e175f645a67fa"
+dependencies = [
+ "protoc-bin-vendored-linux-aarch_64",
+ "protoc-bin-vendored-linux-ppcle_64",
+ "protoc-bin-vendored-linux-s390_64",
+ "protoc-bin-vendored-linux-x86_32",
+ "protoc-bin-vendored-linux-x86_64",
+ "protoc-bin-vendored-macos-aarch_64",
+ "protoc-bin-vendored-macos-x86_64",
+ "protoc-bin-vendored-win32",
+]
+
+[[package]]
+name = "protoc-bin-vendored-linux-aarch_64"
+version = "3.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c350df4d49b5b9e3ca79f7e646fde2377b199e13cfa87320308397e1f37e1a4c"
+
+[[package]]
+name = "protoc-bin-vendored-linux-ppcle_64"
+version = "3.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a55a63e6c7244f19b5c6393f025017eb5d793fd5467823a099740a7a4222440c"
+
+[[package]]
+name = "protoc-bin-vendored-linux-s390_64"
+version = "3.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1dba5565db4288e935d5330a07c264a4ee8e4a5b4a4e6f4e83fad824cc32f3b0"
+
+[[package]]
+name = "protoc-bin-vendored-linux-x86_32"
+version = "3.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8854774b24ee28b7868cd71dccaae8e02a2365e67a4a87a6cd11ee6cdbdf9cf5"
+
+[[package]]
+name = "protoc-bin-vendored-linux-x86_64"
+version = "3.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b38b07546580df720fa464ce124c4b03630a6fb83e05c336fea2a241df7e5d78"
+
+[[package]]
+name = "protoc-bin-vendored-macos-aarch_64"
+version = "3.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "89278a9926ce312e51f1d999fee8825d324d603213344a9a706daa009f1d8092"
+
+[[package]]
+name = "protoc-bin-vendored-macos-x86_64"
+version = "3.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "81745feda7ccfb9471d7a4de888f0652e806d5795b61480605d4943176299756"
+
+[[package]]
+name = "protoc-bin-vendored-win32"
+version = "3.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3"
+
+[[package]]
+name = "quote"
+version = "1.0.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "rand"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a"
+dependencies = [
+ "libc",
+ "rand_chacha",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+dependencies = [
+ "ppv-lite86",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom 0.2.17",
+]
+
+[[package]]
+name = "regex"
+version = "1.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
+
+[[package]]
+name = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.150"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "serde_spanned"
+version = "0.6.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "serial2"
+version = "0.2.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9eb6ea5562eeaed6936b8b54e086aa0f88b9e5b1bef45beb038e2519fa1185b1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "serial2-tokio"
+version = "0.1.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "241cf29fa78f0d2437fd66ccbe7eca453fd3e3466b51368e3faf3887e9132824"
+dependencies = [
+ "libc",
+ "serial2",
+ "tokio",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "sharded-slab"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
+dependencies = [
+ "lazy_static",
+]
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "smallvec"
+version = "1.15.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
+
+[[package]]
+name = "socket2"
+version = "0.5.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
+dependencies = [
+ "libc",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "socket2"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "symlink"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a"
+
+[[package]]
+name = "syn"
+version = "2.0.119"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "sync_wrapper"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
+
+[[package]]
+name = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom 0.4.3",
+ "once_cell",
+ "rustix",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "thread_local"
+version = "1.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "time"
+version = "0.3.53"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50"
+dependencies = [
+ "deranged",
+ "num-conv",
+ "powerfmt",
+ "serde_core",
+ "time-core",
+ "time-macros",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
+
+[[package]]
+name = "time-macros"
+version = "0.2.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f"
+dependencies = [
+ "num-conv",
+ "time-core",
+]
+
+[[package]]
+name = "tokio"
+version = "1.52.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "pin-project-lite",
+ "signal-hook-registry",
+ "socket2 0.6.5",
+ "tokio-macros",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tokio-stream"
+version = "0.1.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
+dependencies = [
+ "futures-core",
+ "pin-project-lite",
+ "tokio",
+ "tokio-util",
+]
+
+[[package]]
+name = "tokio-util"
+version = "0.7.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "toml"
+version = "0.8.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
+dependencies = [
+ "serde",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_edit",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.22.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
+dependencies = [
+ "indexmap 2.14.0",
+ "serde",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_write",
+ "winnow",
+]
+
+[[package]]
+name = "toml_write"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
+
+[[package]]
+name = "tonic"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52"
+dependencies = [
+ "async-stream",
+ "async-trait",
+ "axum",
+ "base64",
+ "bytes",
+ "h2",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-timeout",
+ "hyper-util",
+ "percent-encoding",
+ "pin-project",
+ "prost",
+ "socket2 0.5.10",
+ "tokio",
+ "tokio-stream",
+ "tower 0.4.13",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "tonic-build"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9557ce109ea773b399c9b9e5dca39294110b74f1f342cb347a80d1fce8c26a11"
+dependencies = [
+ "prettyplease",
+ "proc-macro2",
+ "prost-build",
+ "prost-types",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tower"
+version = "0.4.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
+dependencies = [
+ "futures-core",
+ "futures-util",
+ "indexmap 1.9.3",
+ "pin-project",
+ "pin-project-lite",
+ "rand",
+ "slab",
+ "tokio",
+ "tokio-util",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "tower"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
+dependencies = [
+ "futures-core",
+ "futures-util",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
+name = "tower-layer"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
+
+[[package]]
+name = "tower-service"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-appender"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c"
+dependencies = [
+ "crossbeam-channel",
+ "symlink",
+ "thiserror",
+ "time",
+ "tracing-subscriber",
+]
+
+[[package]]
+name = "tracing-attributes"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+ "valuable",
+]
+
+[[package]]
+name = "tracing-log"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
+dependencies = [
+ "log",
+ "once_cell",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-subscriber"
+version = "0.3.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
+dependencies = [
+ "matchers",
+ "nu-ansi-term",
+ "once_cell",
+ "regex-automata",
+ "sharded-slab",
+ "smallvec",
+ "thread_local",
+ "tracing",
+ "tracing-core",
+ "tracing-log",
+]
+
+[[package]]
+name = "try-lock"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "valuable"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
+
+[[package]]
+name = "want"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
+dependencies = [
+ "try-lock",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "winnow"
+version = "0.7.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "zerocopy"
+version = "0.8.54"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.54"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..1cf6a35
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,55 @@
+[workspace]
+resolver = "2"
+members = [
+ "crates/cathub",
+ "crates/cathub-protocol",
+]
+
+[workspace.package]
+version = "0.1.0"
+edition = "2021"
+rust-version = "1.88"
+license = "MIT"
+repository = "https://github.com/treitforge/cathub"
+
+[workspace.dependencies]
+async-trait = "0.1"
+bytes = "1"
+clap = { version = "4", features = ["derive"] }
+prost = "0.13"
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+serial2-tokio = "0.1"
+thiserror = "2"
+tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync"] }
+tokio-stream = { version = "0.1", features = ["net", "sync"] }
+toml = "0.8"
+toml_edit = "0.22"
+tonic = "0.12"
+tracing = "0.1"
+tracing-appender = "0.2"
+tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+
+[workspace.lints.clippy]
+all = { level = "deny", priority = -1 }
+pedantic = { level = "warn", priority = -1 }
+cast_possible_truncation = "deny"
+cast_sign_loss = "deny"
+cast_precision_loss = "warn"
+cast_lossless = "warn"
+unwrap_used = "warn"
+expect_used = "warn"
+panic = "warn"
+indexing_slicing = "warn"
+inefficient_to_string = "warn"
+large_futures = "warn"
+large_stack_arrays = "warn"
+needless_pass_by_value = "warn"
+cloned_instead_of_copied = "warn"
+manual_string_new = "warn"
+implicit_clone = "warn"
+
+[workspace.lints.rust]
+unsafe_code = "warn"
+missing_docs = "warn"
+unreachable_pub = "warn"
diff --git a/CatHub.slnx b/CatHub.slnx
new file mode 100644
index 0000000..6c0ec22
--- /dev/null
+++ b/CatHub.slnx
@@ -0,0 +1,3 @@
+
+
+
diff --git a/README.md b/README.md
index 017341d..dee074a 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,99 @@
-# cathub
-Support programs using different rig control/CAT systems to talk to the same transceiver.
+# CatHub
+
+CatHub lets programs that use different radio 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.
+
+## Current support
+
+- First-class Kenwood TS-590 backend
+- Broad rig support through an external `rigctld` backend
+- Hamlib NET endpoints with per-endpoint permissions
+- 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
+
+## Build and test
+
+```powershell
+.\build.ps1
+.\build.ps1 check
+```
+
+The direct Rust commands are:
+
+```powershell
+cargo build --workspace
+cargo test --workspace
+cargo fmt --all -- --check
+cargo clippy --workspace --all-targets -- -D warnings
+buf lint
+dotnet build CatHub.slnx
+```
+
+## Configuration
+
+CatHub's standalone default configuration 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
+[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]`.
+
+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
+```
+
+Extract an existing QsoRipper `[cat_hub]` section without changing the source file:
+
+```powershell
+cargo run -p cathub -- config migrate `
+ --from "$env:APPDATA\qsoripper\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.
+
+## Run
+
+```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).
+
+## Protocol compatibility
+
+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.
+
+See [the independent-product decision](docs/architecture/independent-product.md) for the
+ownership, compatibility, and release policy.
+
+## License
+
+MIT. See [LICENSE](LICENSE).
diff --git a/buf.yaml b/buf.yaml
new file mode 100644
index 0000000..f983667
--- /dev/null
+++ b/buf.yaml
@@ -0,0 +1,14 @@
+version: v2
+modules:
+ - path: crates/cathub-protocol/proto
+ name: buf.build/treitforge/cathub
+lint:
+ use:
+ - STANDARD
+ except:
+ - ENUM_ZERO_VALUE_SUFFIX
+ - PACKAGE_DIRECTORY_MATCH
+ - PACKAGE_VERSION_SUFFIX
+breaking:
+ use:
+ - FILE
diff --git a/build.ps1 b/build.ps1
new file mode 100644
index 0000000..0baa144
--- /dev/null
+++ b/build.ps1
@@ -0,0 +1,58 @@
+[CmdletBinding()]
+param(
+ [ValidateSet('build', 'check', 'test', 'proto', 'pack')]
+ [string]$Action = 'build',
+ [ValidateSet('Debug', 'Release')]
+ [string]$Configuration = 'Release'
+)
+
+$ErrorActionPreference = 'Stop'
+$root = $PSScriptRoot
+$cargoProfile = if ($Configuration -eq 'Release') { '--release' } else { $null }
+
+function Invoke-Cargo {
+ param([string[]]$CargoArguments)
+ & cargo @CargoArguments
+ if ($LASTEXITCODE -ne 0) { throw "cargo failed with exit code $LASTEXITCODE" }
+}
+
+function Invoke-DotNet {
+ param([string[]]$DotNetArguments)
+ & dotnet @DotNetArguments
+ if ($LASTEXITCODE -ne 0) { throw "dotnet failed with exit code $LASTEXITCODE" }
+}
+
+Push-Location $root
+try {
+ switch ($Action) {
+ 'build' {
+ $cargoArgs = @('build', '--workspace')
+ if ($cargoProfile) { $cargoArgs += $cargoProfile }
+ Invoke-Cargo -CargoArguments $cargoArgs
+ Invoke-DotNet -DotNetArguments @('build', 'CatHub.slnx', '-c', $Configuration)
+ }
+ 'test' {
+ Invoke-Cargo -CargoArguments @('test', '--workspace')
+ Invoke-DotNet -DotNetArguments @('test', 'CatHub.slnx', '-c', $Configuration)
+ }
+ 'proto' {
+ & buf lint
+ if ($LASTEXITCODE -ne 0) { throw "buf lint failed with exit code $LASTEXITCODE" }
+ }
+ 'pack' {
+ Invoke-Cargo -CargoArguments @('build', '--workspace', '--release')
+ Invoke-DotNet -DotNetArguments @('pack', 'src\dotnet\CatHub.Protocol\CatHub.Protocol.csproj', '-c', 'Release', '-o', 'artifacts\packages')
+ }
+ 'check' {
+ Invoke-Cargo -CargoArguments @('fmt', '--all', '--', '--check')
+ Invoke-Cargo -CargoArguments @('clippy', '--workspace', '--all-targets', '--', '-D', 'warnings')
+ Invoke-Cargo -CargoArguments @('test', '--workspace')
+ & buf lint
+ if ($LASTEXITCODE -ne 0) { throw "buf lint failed with exit code $LASTEXITCODE" }
+ Invoke-DotNet -DotNetArguments @('build', 'CatHub.slnx', '-c', $Configuration)
+ }
+ }
+}
+finally {
+ Pop-Location
+}
diff --git a/config/cathub.toml b/config/cathub.toml
new file mode 100644
index 0000000..fc4bb0c
--- /dev/null
+++ b/config/cathub.toml
@@ -0,0 +1,141 @@
+# cathub configuration for this station.
+#
+# One daemon owns the TS-590 on COM4 (Silicon Labs CP210x USB-UART) and fans it out to
+# every application over its native protocol. No application talks to the radio directly,
+# so there is no VFO A/B oscillation, no frequency drift, and no PTT contention.
+#
+# Validate without touching hardware:
+# cargo run -p cathub -- --config config/cathub.toml --dry-run
+#
+# Virtual serial pairs (com0com): the daemon binds the first port of each pair, the
+# application binds the second. Create one pair per serial client.
+
+[radio]
+backend = "ts590" # first-class native Kenwood TS-590 driver (no Hamlib linked)
+model = "TS-590SG"
+transport = "serial"
+port = "COM4"
+baud = 57600 # MUST match the radio's PC/CAT port speed (TS-590 menu 62)
+
+[poll]
+baseline_ms = 200 # used only while native push is unavailable
+heartbeat_ms = 2000 # slow liveness poll once native push covers a field
+
+[ptt]
+max_tx_ms = 300000 # 5-minute hard transmit ceiling (backstop for a crashed client)
+
+[events]
+native_push = true # daemon enables and owns the TS-590 AI2; stream
+
+# --- WinKeyer broker ------------------------------------------------------------------
+#
+# CatHub owns the physical keyer. QsoRipper uses the typed loopback API; N1MM uses the
+# application side of a separate com0com pair. Change COM40/COM41 if that pair is not
+# installed on this station.
+
+[winkeyer]
+port = "COM3"
+baud = 1200
+max_tx_ms = 30000
+api_bind = "127.0.0.1:50071"
+
+[[winkeyer_endpoint]]
+name = "n1mm-cw"
+transport = "COM40" # N1MM WinKeyer port is the paired COM41
+application_transport = "COM41"
+baud = 1200
+primary = true
+perms = ["status", "send", "control", "ptt"]
+
+[[winkeyer_endpoint]]
+name = "wktools-maintenance"
+transport = "COM42" # WKTools maintenance port is the paired COM43
+application_transport = "COM43"
+baud = 1200
+primary = false
+perms = ["status", "control", "config_write"]
+
+# --- Hamlib NET (rigctld-compatible) TCP endpoints -------------------------------------
+
+# Read-only endpoint for the QsoRipper engine's RigctldProvider.
+[[hamlib_net]]
+name = "engine"
+bind = "127.0.0.1:4532"
+perms = ["read"]
+
+# Write + PTT endpoint for WSJT-X (configured as Hamlib NET rigctl).
+# single_vfo: WSJT-X expects to receive on VFO A and stops decoding if the hub reports the
+# operator is on VFO B. Present the operating VFO as VFO A so WSJT-X decodes on either VFO.
+# Use WSJT-X's "Fake It" split mode (Settings -> Radio -> Split Operation) with this endpoint;
+# real "Rig" split is rejected because a single-VFO presentation cannot model an A/B split.
+[[hamlib_net]]
+name = "wsjtx"
+bind = "127.0.0.1:4533"
+single_vfo = true
+perms = ["read", "write", "ptt"]
+
+# Write + PTT endpoint dedicated to JS8Call. Do not share the WSJT-X endpoint: both clients
+# control mode and PTT, and JS8Call's plain-USB setting can clear the TS-590 DATA flag while
+# WSJT-X is operating. Configure JS8Call as Hamlib NET rigctl at 127.0.0.1:4535 with Fake It.
+[[hamlib_net]]
+name = "js8call"
+bind = "127.0.0.1:4535"
+single_vfo = true
+perms = ["read", "write", "ptt"]
+
+# Read/write endpoint for Log4OM (CAT over Hamlib NET rigctl).
+# single_vfo: Log4OM polls `\get_vfo_info VFOA`. Presenting the operating VFO as VFO A makes
+# Log4OM log the live frequency/mode on either VFO instead of the stale inactive VFO A.
+[[hamlib_net]]
+name = "log4om"
+bind = "127.0.0.1:4534"
+single_vfo = true
+perms = ["read", "write"]
+
+# --- Serial endpoints (com0com virtual pairs) ----------------------------------------------
+#
+# IMPORTANT: each com0com pair has TWO port numbers. The daemon binds the port named below
+# (`transport`); your application must connect to the OTHER port in the same pair. Never
+# point an application at the daemon's port -- they cannot both open the same port.
+#
+# pair daemon binds (transport) application connects to
+# COM10 <-> COM11 COM10 COM11 (HDSDR / OmniRig)
+# COM20 <-> COM21 COM20 COM21 (N1MM)
+# COM30 <-> COM31 COM30 COM31 (ARCP-590)
+
+# HDSDR via OmniRig as a TS-2000-style controller. The panadapter follows the radio, and
+# click-to-tune on the waterfall may set frequency. Mode writes are denied so OmniRig cannot
+# overwrite WSJT-X's USB+Data mode after following a digital-band frequency change.
+# The ts2000 dialect still rejects VFO-target writes (FR/FT) by design, so HDSDR can tune but
+# can never oscillate the TS-590's A/B VFO selection.
+[[serial_endpoint]]
+name = "hdsdr-omnirig"
+transport = "COM10" # OmniRig binds COM11
+application_transport = "COM11"
+baud = 115200
+dialect = "ts2000"
+perms = ["read", "frequency_write"]
+
+# N1MM Logger+ as a native TS-590.
+# N1MM in SO1V mode rejects VFO B ("You should not use VFO B when configured for SO1V"),
+# so present the operating VFO as VFO A. The hub mirrors reads/writes/notifications onto
+# whichever VFO the radio is actually on, so A/B switching tracks seamlessly.
+[[serial_endpoint]]
+name = "n1mm"
+transport = "COM20" # N1MM binds COM21
+application_transport = "COM21"
+baud = 115200
+dialect = "ts590"
+single_vfo = true
+perms = ["read", "write", "ptt"]
+
+# ARCP-590 control panel: full control-panel control, including EX-menu (config_write).
+# ARCP-590 is a genuine dual-VFO control-panel: it must see the real VFO A/B, so leave
+# single_vfo off (the default).
+[[serial_endpoint]]
+name = "arcp590"
+transport = "COM30" # ARCP-590 binds COM31
+application_transport = "COM31"
+baud = 115200
+dialect = "ts590"
+perms = ["read", "write", "ptt", "config_write"]
diff --git a/crates/cathub-protocol/Cargo.toml b/crates/cathub-protocol/Cargo.toml
new file mode 100644
index 0000000..bbf50de
--- /dev/null
+++ b/crates/cathub-protocol/Cargo.toml
@@ -0,0 +1,19 @@
+[package]
+name = "cathub-protocol"
+description = "Versioned gRPC contracts for CatHub clients"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+repository.workspace = true
+
+[dependencies]
+prost = { workspace = true }
+tonic = { workspace = true }
+
+[build-dependencies]
+protoc-bin-vendored = "3.2.0"
+tonic-build = "0.12"
+
+[lints]
+workspace = true
diff --git a/crates/cathub-protocol/build.rs b/crates/cathub-protocol/build.rs
new file mode 100644
index 0000000..bae8cf4
--- /dev/null
+++ b/crates/cathub-protocol/build.rs
@@ -0,0 +1,39 @@
+//! Generate `CatHub`'s public `WinKeyer` broker protocol.
+
+use std::path::PathBuf;
+
+fn main() -> Result<(), Box> {
+ let compiler_path = protoc_bin_vendored::protoc_bin_path()?;
+ std::env::set_var("PROTOC", compiler_path);
+
+ let proto_root = PathBuf::from("proto");
+ println!("cargo::rerun-if-changed={}", proto_root.display());
+ let service_root = proto_root.join("services");
+ let protos: Vec<_> = [
+ "abort_winkeyer_client_request.proto",
+ "abort_winkeyer_client_response.proto",
+ "cancel_winkeyer_job_request.proto",
+ "cancel_winkeyer_job_response.proto",
+ "get_winkeyer_broker_status_request.proto",
+ "get_winkeyer_broker_status_response.proto",
+ "send_winkeyer_text_request.proto",
+ "send_winkeyer_text_response.proto",
+ "set_winkeyer_broker_speed_request.proto",
+ "set_winkeyer_broker_speed_response.proto",
+ "stream_winkeyer_events_request.proto",
+ "stream_winkeyer_events_response.proto",
+ "winkeyer_broker_event_kind.proto",
+ "winkeyer_broker_service.proto",
+ "winkeyer_broker_status.proto",
+ "winkeyer_speed_mode.proto",
+ ]
+ .into_iter()
+ .map(|file| service_root.join(file))
+ .collect();
+
+ tonic_build::configure()
+ .build_server(true)
+ .build_client(true)
+ .compile_protos(&protos, &[proto_root])?;
+ Ok(())
+}
diff --git a/crates/cathub-protocol/proto/services/abort_winkeyer_client_request.proto b/crates/cathub-protocol/proto/services/abort_winkeyer_client_request.proto
new file mode 100644
index 0000000..e30ea84
--- /dev/null
+++ b/crates/cathub-protocol/proto/services/abort_winkeyer_client_request.proto
@@ -0,0 +1,10 @@
+syntax = "proto3";
+
+package qsoripper.services;
+
+option csharp_namespace = "QsoRipper.Services";
+
+message WinkeyerBrokerServiceAbortClientRequest {
+ string client_name = 1;
+ bool emergency_station_stop = 2;
+}
diff --git a/crates/cathub-protocol/proto/services/abort_winkeyer_client_response.proto b/crates/cathub-protocol/proto/services/abort_winkeyer_client_response.proto
new file mode 100644
index 0000000..f3b0104
--- /dev/null
+++ b/crates/cathub-protocol/proto/services/abort_winkeyer_client_response.proto
@@ -0,0 +1,9 @@
+syntax = "proto3";
+
+package qsoripper.services;
+
+option csharp_namespace = "QsoRipper.Services";
+
+message WinkeyerBrokerServiceAbortClientResponse {
+ bool accepted = 1;
+}
diff --git a/crates/cathub-protocol/proto/services/cancel_winkeyer_job_request.proto b/crates/cathub-protocol/proto/services/cancel_winkeyer_job_request.proto
new file mode 100644
index 0000000..0399937
--- /dev/null
+++ b/crates/cathub-protocol/proto/services/cancel_winkeyer_job_request.proto
@@ -0,0 +1,10 @@
+syntax = "proto3";
+
+package qsoripper.services;
+
+option csharp_namespace = "QsoRipper.Services";
+
+message WinkeyerBrokerServiceCancelJobRequest {
+ string client_name = 1;
+ uint64 job_id = 2;
+}
diff --git a/crates/cathub-protocol/proto/services/cancel_winkeyer_job_response.proto b/crates/cathub-protocol/proto/services/cancel_winkeyer_job_response.proto
new file mode 100644
index 0000000..06cae7b
--- /dev/null
+++ b/crates/cathub-protocol/proto/services/cancel_winkeyer_job_response.proto
@@ -0,0 +1,9 @@
+syntax = "proto3";
+
+package qsoripper.services;
+
+option csharp_namespace = "QsoRipper.Services";
+
+message WinkeyerBrokerServiceCancelJobResponse {
+ bool canceled = 1;
+}
diff --git a/crates/cathub-protocol/proto/services/get_winkeyer_broker_status_request.proto b/crates/cathub-protocol/proto/services/get_winkeyer_broker_status_request.proto
new file mode 100644
index 0000000..4056043
--- /dev/null
+++ b/crates/cathub-protocol/proto/services/get_winkeyer_broker_status_request.proto
@@ -0,0 +1,9 @@
+syntax = "proto3";
+
+package qsoripper.services;
+
+option csharp_namespace = "QsoRipper.Services";
+
+message WinkeyerBrokerServiceGetStatusRequest {
+ string client_name = 1;
+}
diff --git a/crates/cathub-protocol/proto/services/get_winkeyer_broker_status_response.proto b/crates/cathub-protocol/proto/services/get_winkeyer_broker_status_response.proto
new file mode 100644
index 0000000..39af150
--- /dev/null
+++ b/crates/cathub-protocol/proto/services/get_winkeyer_broker_status_response.proto
@@ -0,0 +1,11 @@
+syntax = "proto3";
+
+package qsoripper.services;
+
+option csharp_namespace = "QsoRipper.Services";
+
+import "services/winkeyer_broker_status.proto";
+
+message WinkeyerBrokerServiceGetStatusResponse {
+ WinkeyerBrokerStatus status = 1;
+}
diff --git a/crates/cathub-protocol/proto/services/send_winkeyer_text_request.proto b/crates/cathub-protocol/proto/services/send_winkeyer_text_request.proto
new file mode 100644
index 0000000..22374c1
--- /dev/null
+++ b/crates/cathub-protocol/proto/services/send_winkeyer_text_request.proto
@@ -0,0 +1,14 @@
+syntax = "proto3";
+
+package qsoripper.services;
+
+option csharp_namespace = "QsoRipper.Services";
+
+import "services/winkeyer_speed_mode.proto";
+
+message WinkeyerBrokerServiceSendTextRequest {
+ string client_name = 1;
+ string text = 2;
+ WinkeyerSpeedMode speed_mode = 3;
+ optional uint32 speed_wpm = 4;
+}
diff --git a/crates/cathub-protocol/proto/services/send_winkeyer_text_response.proto b/crates/cathub-protocol/proto/services/send_winkeyer_text_response.proto
new file mode 100644
index 0000000..7485156
--- /dev/null
+++ b/crates/cathub-protocol/proto/services/send_winkeyer_text_response.proto
@@ -0,0 +1,9 @@
+syntax = "proto3";
+
+package qsoripper.services;
+
+option csharp_namespace = "QsoRipper.Services";
+
+message WinkeyerBrokerServiceSendTextResponse {
+ uint64 job_id = 1;
+}
diff --git a/crates/cathub-protocol/proto/services/set_winkeyer_broker_speed_request.proto b/crates/cathub-protocol/proto/services/set_winkeyer_broker_speed_request.proto
new file mode 100644
index 0000000..0d5481a
--- /dev/null
+++ b/crates/cathub-protocol/proto/services/set_winkeyer_broker_speed_request.proto
@@ -0,0 +1,13 @@
+syntax = "proto3";
+
+package qsoripper.services;
+
+option csharp_namespace = "QsoRipper.Services";
+
+import "services/winkeyer_speed_mode.proto";
+
+message WinkeyerBrokerServiceSetSpeedRequest {
+ string client_name = 1;
+ WinkeyerSpeedMode speed_mode = 2;
+ optional uint32 speed_wpm = 3;
+}
diff --git a/crates/cathub-protocol/proto/services/set_winkeyer_broker_speed_response.proto b/crates/cathub-protocol/proto/services/set_winkeyer_broker_speed_response.proto
new file mode 100644
index 0000000..6fb088a
--- /dev/null
+++ b/crates/cathub-protocol/proto/services/set_winkeyer_broker_speed_response.proto
@@ -0,0 +1,12 @@
+syntax = "proto3";
+
+package qsoripper.services;
+
+option csharp_namespace = "QsoRipper.Services";
+
+import "services/winkeyer_speed_mode.proto";
+
+message WinkeyerBrokerServiceSetSpeedResponse {
+ WinkeyerSpeedMode speed_mode = 1;
+ optional uint32 speed_wpm = 2;
+}
diff --git a/crates/cathub-protocol/proto/services/stream_winkeyer_events_request.proto b/crates/cathub-protocol/proto/services/stream_winkeyer_events_request.proto
new file mode 100644
index 0000000..bc73d7f
--- /dev/null
+++ b/crates/cathub-protocol/proto/services/stream_winkeyer_events_request.proto
@@ -0,0 +1,9 @@
+syntax = "proto3";
+
+package qsoripper.services;
+
+option csharp_namespace = "QsoRipper.Services";
+
+message WinkeyerBrokerServiceStreamEventsRequest {
+ string client_name = 1;
+}
diff --git a/crates/cathub-protocol/proto/services/stream_winkeyer_events_response.proto b/crates/cathub-protocol/proto/services/stream_winkeyer_events_response.proto
new file mode 100644
index 0000000..d073465
--- /dev/null
+++ b/crates/cathub-protocol/proto/services/stream_winkeyer_events_response.proto
@@ -0,0 +1,18 @@
+syntax = "proto3";
+
+package qsoripper.services;
+
+option csharp_namespace = "QsoRipper.Services";
+
+import "services/winkeyer_broker_event_kind.proto";
+import "services/winkeyer_broker_status.proto";
+
+message WinkeyerBrokerServiceStreamEventsResponse {
+ WinkeyerBrokerEventKind kind = 1;
+ WinkeyerBrokerStatus status = 2;
+ optional uint32 raw_byte = 3;
+ optional uint32 speed_wpm = 4;
+ optional uint64 job_id = 5;
+ optional uint64 client_id = 6;
+ optional string message = 7;
+}
diff --git a/crates/cathub-protocol/proto/services/winkeyer_broker_event_kind.proto b/crates/cathub-protocol/proto/services/winkeyer_broker_event_kind.proto
new file mode 100644
index 0000000..0eb9608
--- /dev/null
+++ b/crates/cathub-protocol/proto/services/winkeyer_broker_event_kind.proto
@@ -0,0 +1,17 @@
+syntax = "proto3";
+
+package qsoripper.services;
+
+option csharp_namespace = "QsoRipper.Services";
+
+enum WinkeyerBrokerEventKind {
+ WINKEYER_BROKER_EVENT_KIND_UNSPECIFIED = 0;
+ WINKEYER_BROKER_EVENT_KIND_CONNECTED = 1;
+ WINKEYER_BROKER_EVENT_KIND_SPEED_POT = 2;
+ WINKEYER_BROKER_EVENT_KIND_STATUS = 3;
+ WINKEYER_BROKER_EVENT_KIND_ECHO = 4;
+ WINKEYER_BROKER_EVENT_KIND_JOB_COMPLETED = 5;
+ WINKEYER_BROKER_EVENT_KIND_JOB_CANCELED = 6;
+ WINKEYER_BROKER_EVENT_KIND_ERROR = 7;
+ WINKEYER_BROKER_EVENT_KIND_SAFETY = 8;
+}
diff --git a/crates/cathub-protocol/proto/services/winkeyer_broker_service.proto b/crates/cathub-protocol/proto/services/winkeyer_broker_service.proto
new file mode 100644
index 0000000..d8c2c33
--- /dev/null
+++ b/crates/cathub-protocol/proto/services/winkeyer_broker_service.proto
@@ -0,0 +1,27 @@
+syntax = "proto3";
+
+package qsoripper.services;
+
+option csharp_namespace = "QsoRipper.Services";
+
+import "services/abort_winkeyer_client_request.proto";
+import "services/abort_winkeyer_client_response.proto";
+import "services/cancel_winkeyer_job_request.proto";
+import "services/cancel_winkeyer_job_response.proto";
+import "services/get_winkeyer_broker_status_request.proto";
+import "services/get_winkeyer_broker_status_response.proto";
+import "services/send_winkeyer_text_request.proto";
+import "services/send_winkeyer_text_response.proto";
+import "services/set_winkeyer_broker_speed_request.proto";
+import "services/set_winkeyer_broker_speed_response.proto";
+import "services/stream_winkeyer_events_request.proto";
+import "services/stream_winkeyer_events_response.proto";
+
+service WinkeyerBrokerService {
+ rpc GetStatus(WinkeyerBrokerServiceGetStatusRequest) returns (WinkeyerBrokerServiceGetStatusResponse);
+ rpc SendText(WinkeyerBrokerServiceSendTextRequest) returns (WinkeyerBrokerServiceSendTextResponse);
+ rpc CancelJob(WinkeyerBrokerServiceCancelJobRequest) returns (WinkeyerBrokerServiceCancelJobResponse);
+ rpc AbortClient(WinkeyerBrokerServiceAbortClientRequest) returns (WinkeyerBrokerServiceAbortClientResponse);
+ rpc SetSpeed(WinkeyerBrokerServiceSetSpeedRequest) returns (WinkeyerBrokerServiceSetSpeedResponse);
+ rpc StreamEvents(WinkeyerBrokerServiceStreamEventsRequest) returns (stream WinkeyerBrokerServiceStreamEventsResponse);
+}
diff --git a/crates/cathub-protocol/proto/services/winkeyer_broker_status.proto b/crates/cathub-protocol/proto/services/winkeyer_broker_status.proto
new file mode 100644
index 0000000..7e47eb8
--- /dev/null
+++ b/crates/cathub-protocol/proto/services/winkeyer_broker_status.proto
@@ -0,0 +1,23 @@
+syntax = "proto3";
+
+package qsoripper.services;
+
+option csharp_namespace = "QsoRipper.Services";
+
+message WinkeyerBrokerStatus {
+ bool connected = 1;
+ optional uint32 firmware_revision = 2;
+ bool busy = 3;
+ bool break_in = 4;
+ bool key_down = 5;
+ optional uint32 pot_wpm = 6;
+ optional uint64 active_client_id = 7;
+ optional uint64 active_job_id = 8;
+ uint32 queued_jobs = 9;
+ optional string last_error = 10;
+ optional string last_safety_action = 11;
+ uint32 max_job_bytes = 12;
+ bool supports_speed_pot = 13;
+ bool supports_scoped_cancel = 14;
+ uint32 max_queued_jobs = 15;
+}
diff --git a/crates/cathub-protocol/proto/services/winkeyer_speed_mode.proto b/crates/cathub-protocol/proto/services/winkeyer_speed_mode.proto
new file mode 100644
index 0000000..9c7be10
--- /dev/null
+++ b/crates/cathub-protocol/proto/services/winkeyer_speed_mode.proto
@@ -0,0 +1,11 @@
+syntax = "proto3";
+
+package qsoripper.services;
+
+option csharp_namespace = "QsoRipper.Services";
+
+enum WinkeyerSpeedMode {
+ WINKEYER_SPEED_MODE_UNSPECIFIED = 0;
+ WINKEYER_SPEED_MODE_POT = 1;
+ WINKEYER_SPEED_MODE_FIXED = 2;
+}
diff --git a/crates/cathub-protocol/src/lib.rs b/crates/cathub-protocol/src/lib.rs
new file mode 100644
index 0000000..b2520b8
--- /dev/null
+++ b/crates/cathub-protocol/src/lib.rs
@@ -0,0 +1,11 @@
+//! 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.
+
+// Generated prost/tonic code is not authored against this workspace's pedantic lint profile.
+#![allow(clippy::all, clippy::pedantic)]
+// Generated prost/tonic items do not carry Rust documentation from the proto comments.
+#![allow(missing_docs)]
+
+tonic::include_proto!("qsoripper.services");
diff --git a/crates/cathub/Cargo.toml b/crates/cathub/Cargo.toml
new file mode 100644
index 0000000..36224f7
--- /dev/null
+++ b/crates/cathub/Cargo.toml
@@ -0,0 +1,40 @@
+[package]
+name = "cathub"
+description = "Multi-client CAT and WinKeyer hub daemon"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+repository.workspace = true
+
+[lib]
+name = "cathub"
+path = "src/lib.rs"
+
+[[bin]]
+name = "cathub"
+path = "src/main.rs"
+
+[dependencies]
+async-trait = { workspace = true }
+bytes = { workspace = true }
+cathub-protocol = { path = "../cathub-protocol", version = "0.1.0" }
+clap = { workspace = true }
+serde = { workspace = true }
+serde_json = { workspace = true }
+serial2-tokio = { workspace = true }
+thiserror = { workspace = true }
+tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync", "io-util", "time", "net", "signal"] }
+tokio-stream = { workspace = true }
+toml = { workspace = true }
+toml_edit = { workspace = true }
+tonic = { workspace = true }
+tracing = { workspace = true }
+tracing-appender = { workspace = true }
+tracing-subscriber = { workspace = true }
+
+[dev-dependencies]
+tokio = { workspace = true, features = ["test-util"] }
+
+[lints]
+workspace = true
diff --git a/crates/cathub/src/backend/kenwood/mod.rs b/crates/cathub/src/backend/kenwood/mod.rs
new file mode 100644
index 0000000..1366843
--- /dev/null
+++ b/crates/cathub/src/backend/kenwood/mod.rs
@@ -0,0 +1,3 @@
+//! Kenwood-family backends.
+
+pub(crate) mod ts590;
diff --git a/crates/cathub/src/backend/kenwood/ts590.rs b/crates/cathub/src/backend/kenwood/ts590.rs
new file mode 100644
index 0000000..cfe6e7e
--- /dev/null
+++ b/crates/cathub/src/backend/kenwood/ts590.rs
@@ -0,0 +1,939 @@
+//! The first-class native TS-590 backend.
+//!
+//! It speaks Kenwood `;`-terminated ASCII CAT directly and is the certified-native path.
+//! Crucially, its poll command set contains **no `FR`/`FT` VFO-select commands** - polling
+//! never retargets a VFO, which is the root-cause fix for the A/B oscillation seen when a
+//! status poll toggled the receive VFO (design §8.8). Sets are fire-and-forget (Kenwood
+//! radios do not acknowledge a set), and the radio's auto-information stream (`AI2;`) drives
+//! native push.
+
+use async_trait::async_trait;
+
+use crate::backend::{
+ BackendCapabilities, BackendError, Framing, NativeCommandFamily, RadioBackend, SplitStyle,
+ TrustTier,
+};
+use crate::model::{Mode, PttSource, RadioEventSource, StateChange, StateMutation, TxPower, Vfo};
+use crate::radio::{Expect, RadioLink};
+use crate::state::StateHandle;
+
+/// The baseline poll command set. Read-only queries only: **never** `FR`/`FT` (§8.8).
+const POLL_COMMANDS: &[&[u8]] = &[b"FA;", b"FB;", b"IF;", b"MD;", b"DA;", b"PC;"];
+
+/// `IF;` payload byte offsets from the Kenwood TS-590 status frame.
+const IF_FREQ_RANGE: std::ops::Range = 0..11;
+const IF_TX_OFFSET: usize = 26;
+const IF_MODE_OFFSET: usize = 27;
+const IF_RX_VFO_OFFSET: usize = 28;
+const IF_SPLIT_OFFSET: usize = 30;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+struct IfStatus {
+ freq_hz: u64,
+ ptt: bool,
+ mode: Mode,
+ rx_vfo: Vfo,
+ split: bool,
+}
+
+/// The native TS-590 backend.
+#[derive(Clone, Default)]
+pub(crate) struct Ts590Backend;
+
+impl Ts590Backend {
+ /// Create the backend.
+ pub(crate) fn new() -> Self {
+ Ts590Backend
+ }
+}
+
+/// Split a `;`-terminated frame into its leading alphabetic verb and the remaining payload.
+fn split_frame(frame: &[u8]) -> Option<(&[u8], &[u8])> {
+ let body = match frame.iter().position(|&b| b == b';') {
+ Some(end) => frame.get(..end)?,
+ None => frame,
+ };
+ let verb_len = body
+ .iter()
+ .position(|b| !b.is_ascii_alphabetic())
+ .unwrap_or(body.len());
+ if verb_len == 0 {
+ return None;
+ }
+ let verb = body.get(..verb_len)?;
+ let payload = body.get(verb_len..).unwrap_or(&[]);
+ Some((verb, payload))
+}
+
+fn parse_u64(bytes: &[u8]) -> Option {
+ std::str::from_utf8(bytes).ok()?.trim().parse::().ok()
+}
+
+fn parse_rx_vfo_digit(bytes: &[u8]) -> Option {
+ match *bytes.first()? {
+ b'0' => Some(Vfo::A),
+ b'1' => Some(Vfo::B),
+ _ => None,
+ }
+}
+
+/// The TS-590 limits configured carrier power to 25 W in AM and 100 W in other modes.
+/// This is the same mode-sensitive maximum Hamlib uses to normalize `PC` into `RFPOWER`.
+fn max_power_watts(mode: Mode) -> u32 {
+ if mode == Mode::Am {
+ 25
+ } else {
+ 100
+ }
+}
+
+fn parse_if_status(frame: &[u8]) -> Option {
+ let (verb, payload) = split_frame(frame)?;
+ if verb != b"IF" {
+ return None;
+ }
+ let rx_vfo = match *payload.get(IF_RX_VFO_OFFSET)? {
+ b'0' => Vfo::A,
+ b'1' => Vfo::B,
+ // The TS-590 can report non-VFO states such as memory mode. Cathub does not model
+ // those as an active VFO, so ignore the frame instead of silently pinning VFO A.
+ _ => return None,
+ };
+ Some(IfStatus {
+ freq_hz: parse_u64(payload.get(IF_FREQ_RANGE)?)?,
+ ptt: *payload.get(IF_TX_OFFSET)? == b'1',
+ mode: Mode::from_kenwood_digit(*payload.get(IF_MODE_OFFSET)?),
+ rx_vfo,
+ split: *payload.get(IF_SPLIT_OFFSET)? == b'1',
+ })
+}
+
+fn record_if_status(state: &StateHandle, status: IfStatus, source: RadioEventSource) {
+ state.record(
+ StateChange::Freq {
+ vfo: status.rx_vfo,
+ hz: status.freq_hz,
+ },
+ source,
+ );
+ state.record(
+ StateChange::Mode {
+ vfo: status.rx_vfo,
+ mode: status.mode,
+ },
+ source,
+ );
+ state.record(StateChange::Ptt { keyed: status.ptt }, source);
+ // The IF frame carries the split bit and the receive VFO but not the transmit VFO. On a
+ // two-VFO radio the transmit VFO during split is always the non-receiving VFO, so derive
+ // it here. This keeps the hub's notion of split/TX-VFO authoritative from the rig itself
+ // and propagates the real state to every attached program.
+ let tx_vfo = if status.split {
+ status.rx_vfo.other()
+ } else {
+ status.rx_vfo
+ };
+ state.record(
+ StateChange::Split {
+ enabled: status.split,
+ tx_vfo: Some(tx_vfo),
+ },
+ source,
+ );
+ state.record(StateChange::RxVfo { vfo: status.rx_vfo }, source);
+}
+
+/// Build an `FA`/`FB` frequency set frame.
+fn freq_set(vfo: Vfo, hz: u64) -> Vec {
+ let verb = match vfo {
+ Vfo::A => "FA",
+ Vfo::B => "FB",
+ };
+ format!("{verb}{hz:011};").into_bytes()
+}
+
+/// Build an `FR` receive-VFO select frame.
+fn rx_vfo_set(vfo: Vfo) -> Vec {
+ let digit = match vfo {
+ Vfo::A => b'0',
+ Vfo::B => b'1',
+ };
+ vec![b'F', b'R', digit, b';']
+}
+
+/// Whether a passthrough frame is a query (bare verb then `;`) versus a set.
+fn is_query(frame: &[u8]) -> bool {
+ matches!(split_frame(frame), Some((_, payload)) if payload.is_empty())
+}
+
+#[async_trait]
+impl RadioBackend for Ts590Backend {
+ async fn poll(&self, link: &RadioLink, state: &StateHandle) -> Result<(), BackendError> {
+ for &cmd in POLL_COMMANDS {
+ let verb = cmd.get(..2).unwrap_or(cmd).to_vec();
+ let reply = link.submit(cmd.to_vec(), Expect::Reply(vec![verb])).await?;
+ let _ = self.record_event(&reply, state, RadioEventSource::PollDiff);
+ }
+ Ok(())
+ }
+
+ async fn apply(
+ &self,
+ mutation: StateMutation,
+ link: &RadioLink,
+ state: &StateHandle,
+ ) -> Result<(), BackendError> {
+ let snap = state.snapshot();
+ let bytes = match mutation {
+ StateMutation::SetRxVfo { vfo } => {
+ let mut bytes = rx_vfo_set(vfo);
+ let tx = if snap.split {
+ // Preserve a deliberate split: move only the receive VFO, keep the
+ // operator's transmit VFO untouched.
+ snap.tx_vfo
+ } else {
+ // Switching the operating VFO must move RX *and* TX together. Sending a
+ // bare `FR` would leave the transmit VFO behind and silently create an
+ // accidental reverse-split (RX=new, TX=old).
+ vfo
+ };
+ let tx_digit = match tx {
+ Vfo::A => b'0',
+ Vfo::B => b'1',
+ };
+ bytes.extend_from_slice(&[b'F', b'T', tx_digit, b';']);
+ bytes
+ }
+ StateMutation::SetVfoFreq { vfo, hz } => freq_set(vfo, hz),
+ StateMutation::SetMode { mode, .. } => {
+ vec![b'M', b'D', mode.to_kenwood_digit(), b';']
+ }
+ // The DATA sub-mode is an independent flag on the TS-590, set with `DA1;`/`DA0;`
+ // and read back with `DA;`. It composes with the base `MD` mode so a USB+DATA
+ // (PKTUSB) request is `MD2;` followed by `DA1;`.
+ StateMutation::SetDataMode { on, .. } => {
+ if on {
+ b"DA1;".to_vec()
+ } else {
+ b"DA0;".to_vec()
+ }
+ }
+ StateMutation::SetPtt { keyed, source } => {
+ if keyed {
+ // Mirror Hamlib's TS-590 mapping so digital clients modulate from the
+ // DATA/USB path (`TX1;`) and the radio does not emit the data beep that
+ // a bare `TX;` triggers on the TS-590.
+ match source {
+ PttSource::Generic => b"TX;".to_vec(),
+ PttSource::Mic => b"TX0;".to_vec(),
+ PttSource::Data => b"TX1;".to_vec(),
+ }
+ } else {
+ b"RX;".to_vec()
+ }
+ }
+ StateMutation::SetSplit { enabled, tx_vfo } => {
+ // Split is a deliberate, modeled write: receive on A, transmit on the chosen
+ // VFO. This is the only path that issues FR/FT, never a status poll.
+ let tx = match tx_vfo.unwrap_or(Vfo::B) {
+ Vfo::A => b'0',
+ Vfo::B => b'1',
+ };
+ if enabled {
+ [b"FR0;".as_slice(), &[b'F', b'T', tx, b';']].concat()
+ } else {
+ b"FR0;FT0;".to_vec()
+ }
+ }
+ StateMutation::SetRit { enabled, .. } => {
+ if enabled {
+ b"RT1;".to_vec()
+ } else {
+ b"RT0;".to_vec()
+ }
+ }
+ StateMutation::SetXit { enabled, .. } => {
+ if enabled {
+ b"XT1;".to_vec()
+ } else {
+ b"XT0;".to_vec()
+ }
+ }
+ };
+ // Kenwood sets are not acknowledged: fire and forget.
+ link.submit(bytes, Expect::NoReply).await?;
+ state.record(mutation.into_change(), RadioEventSource::OptimisticWrite);
+ // A non-split operating-VFO switch also moves TX, so reflect the cleared split and
+ // the new transmit VFO in the snapshot (the bare RxVfo change above cannot).
+ if let StateMutation::SetRxVfo { vfo } = mutation {
+ if !snap.split {
+ state.record(
+ StateChange::Split {
+ enabled: false,
+ tx_vfo: Some(vfo),
+ },
+ RadioEventSource::OptimisticWrite,
+ );
+ }
+ }
+ Ok(())
+ }
+
+ fn parse_event(&self, frame: &[u8]) -> Option {
+ let (verb, payload) = split_frame(frame)?;
+ match verb {
+ b"FA" => Some(StateMutation::SetVfoFreq {
+ vfo: Vfo::A,
+ hz: parse_u64(payload)?,
+ }),
+ b"FB" => Some(StateMutation::SetVfoFreq {
+ vfo: Vfo::B,
+ hz: parse_u64(payload)?,
+ }),
+ b"MD" => Some(StateMutation::SetMode {
+ vfo: Vfo::A,
+ mode: Mode::from_kenwood_digit(*payload.first()?),
+ }),
+ b"DA" => Some(StateMutation::SetDataMode {
+ vfo: Vfo::A,
+ on: *payload.first()? == b'1',
+ }),
+ // Native push routing currently accepts one modeled mutation per frame. Polling
+ // records the full IF status; unsolicited IF frames still refresh the critical
+ // active-RX-VFO fact so OmniRig/HDSDR follows the displayed VFO.
+ b"IF" => {
+ parse_if_status(frame).map(|status| StateMutation::SetRxVfo { vfo: status.rx_vfo })
+ }
+ _ => None,
+ }
+ }
+
+ fn record_event(&self, frame: &[u8], state: &StateHandle, source: RadioEventSource) -> bool {
+ if let Some(status) = parse_if_status(frame) {
+ record_if_status(state, status, source);
+ return true;
+ }
+
+ let Some((verb, payload)) = split_frame(frame) else {
+ return false;
+ };
+ match verb {
+ b"FA" => parse_u64(payload).is_some_and(|hz| {
+ state.record(StateChange::Freq { vfo: Vfo::A, hz }, source);
+ true
+ }),
+ b"FB" => parse_u64(payload).is_some_and(|hz| {
+ state.record(StateChange::Freq { vfo: Vfo::B, hz }, source);
+ true
+ }),
+ b"FR" => parse_rx_vfo_digit(payload).is_some_and(|vfo| {
+ state.record(StateChange::RxVfo { vfo }, source);
+ true
+ }),
+ // A raw `FT` from a client (e.g. ARCP-590/N1MM split control) selects the
+ // transmit VFO. Observe it so the snapshot's split/tx_vfo track reality instead
+ // of going stale: split is active whenever the transmit VFO differs from the
+ // receive VFO.
+ b"FT" => parse_rx_vfo_digit(payload).is_some_and(|tx_vfo| {
+ let rx_vfo = state.snapshot().rx_vfo;
+ state.record(
+ StateChange::Split {
+ enabled: rx_vfo != tx_vfo,
+ tx_vfo: Some(tx_vfo),
+ },
+ source,
+ );
+ true
+ }),
+ b"MD" => payload.first().is_some_and(|digit| {
+ let vfo = state.snapshot().rx_vfo;
+ state.record(
+ StateChange::Mode {
+ vfo,
+ mode: Mode::from_kenwood_digit(*digit),
+ },
+ source,
+ );
+ true
+ }),
+ b"DA" => payload.first().is_some_and(|digit| {
+ let vfo = state.snapshot().rx_vfo;
+ state.record(
+ StateChange::DataMode {
+ vfo,
+ on: *digit == b'1',
+ },
+ source,
+ );
+ true
+ }),
+ b"PC" => parse_u64(payload)
+ .and_then(|watts| u32::try_from(watts).ok())
+ .is_some_and(|watts| {
+ let snapshot = state.snapshot();
+ let tx_vfo = if snapshot.split {
+ snapshot.tx_vfo
+ } else {
+ snapshot.rx_vfo
+ };
+ let max_watts = max_power_watts(snapshot.vfo(tx_vfo).mode);
+ state.record(
+ StateChange::TxPower {
+ power: Some(TxPower::from_watts(watts, max_watts)),
+ },
+ source,
+ );
+ true
+ }),
+ _ => false,
+ }
+ }
+
+ async fn passthrough(&self, raw: &[u8], link: &RadioLink) -> Result, BackendError> {
+ let expect = if is_query(raw) {
+ let verb = split_frame(raw)
+ .map(|(v, _)| v.to_vec())
+ .unwrap_or_default();
+ Expect::Reply(vec![verb])
+ } else {
+ Expect::NoReply
+ };
+ link.submit(raw.to_vec(), expect).await
+ }
+
+ fn capabilities(&self) -> BackendCapabilities {
+ BackendCapabilities {
+ model: "TS-590".to_string(),
+ vfo_count: 2,
+ has_rit: true,
+ has_xit: true,
+ has_smeter: true,
+ split: SplitStyle::VfoPair,
+ native_push: true,
+ native_command_family: Some(NativeCommandFamily::Kenwood),
+ framing: Framing::SemicolonTerminated,
+ freq_min_hz: 30_000,
+ freq_max_hz: 60_000_000,
+ trust: TrustTier::CertifiedNative,
+ }
+ }
+
+ fn native_push_enable(&self) -> Option> {
+ Some(b"AI2;".to_vec())
+ }
+}
+
+#[cfg(test)]
+#[allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)]
+mod tests {
+ use super::*;
+ use crate::radio::{link_channel, run_transport};
+ use std::sync::Arc;
+ use tokio::io::{AsyncReadExt, AsyncWriteExt};
+
+ #[test]
+ fn poll_commands_never_retarget_a_vfo() {
+ for cmd in POLL_COMMANDS {
+ assert!(
+ !cmd.starts_with(b"FR") && !cmd.starts_with(b"FT"),
+ "poll set must not contain a VFO-select command"
+ );
+ }
+ assert_eq!(
+ POLL_COMMANDS,
+ &[b"FA;", b"FB;", b"IF;", b"MD;", b"DA;", b"PC;"]
+ );
+ }
+
+ #[test]
+ fn parse_event_reads_freq_and_mode() {
+ let backend = Ts590Backend::new();
+ assert_eq!(
+ backend.parse_event(b"FA00007030000;"),
+ Some(StateMutation::SetVfoFreq {
+ vfo: Vfo::A,
+ hz: 7_030_000
+ })
+ );
+ assert_eq!(
+ backend.parse_event(b"FB00014250000;"),
+ Some(StateMutation::SetVfoFreq {
+ vfo: Vfo::B,
+ hz: 14_250_000
+ })
+ );
+ assert_eq!(
+ backend.parse_event(b"MD3;"),
+ Some(StateMutation::SetMode {
+ vfo: Vfo::A,
+ mode: Mode::Cw
+ })
+ );
+ assert_eq!(
+ backend.parse_event(b"DA1;"),
+ Some(StateMutation::SetDataMode {
+ vfo: Vfo::A,
+ on: true
+ })
+ );
+ assert_eq!(
+ backend.parse_event(b"DA0;"),
+ Some(StateMutation::SetDataMode {
+ vfo: Vfo::A,
+ on: false
+ })
+ );
+ assert_eq!(
+ backend.parse_event(b"IF000140343201234-0000012345121019999;"),
+ Some(StateMutation::SetRxVfo { vfo: Vfo::B })
+ );
+ assert_eq!(backend.parse_event(b"ZZ;"), None);
+ }
+
+ #[test]
+ fn parse_if_status_reads_active_vfo_from_real_status_shape() {
+ let status =
+ parse_if_status(b"IF000140343201234-0000012345121019999;").expect("parse IF status");
+
+ assert_eq!(
+ status,
+ IfStatus {
+ freq_hz: 14_034_320,
+ ptt: true,
+ mode: Mode::Usb,
+ rx_vfo: Vfo::B,
+ split: true,
+ }
+ );
+ }
+
+ #[test]
+ fn parse_if_status_ignores_non_vfo_memory_mode() {
+ assert_eq!(
+ parse_if_status(b"IF000140343201234-0000012345122019999;"),
+ None
+ );
+ }
+
+ #[test]
+ fn if_poll_derives_tx_vfo_from_split_and_rx_vfo() {
+ let backend = Ts590Backend::new();
+
+ // RX=VFO B with split set -> TX must be derived as the non-receiving VFO (A).
+ let split = StateHandle::new();
+ assert!(backend.record_event(
+ b"IF000140343201234-0000012345121019999;",
+ &split,
+ RadioEventSource::PollDiff,
+ ));
+ let snap = split.snapshot();
+ assert_eq!(snap.rx_vfo, Vfo::B);
+ assert!(snap.split);
+ assert_eq!(snap.tx_vfo, Vfo::A, "split TX VFO is the non-RX VFO");
+
+ // RX=VFO B with split clear -> TX tracks the operating (RX) VFO.
+ let simplex = StateHandle::new();
+ assert!(backend.record_event(
+ b"IF000140343201234-0000012345021009999;",
+ &simplex,
+ RadioEventSource::PollDiff,
+ ));
+ let snap = simplex.snapshot();
+ assert_eq!(snap.rx_vfo, Vfo::B);
+ assert!(!snap.split);
+ assert_eq!(snap.tx_vfo, Vfo::B, "simplex TX VFO equals the RX VFO");
+ }
+
+ #[test]
+ fn native_ft_records_split_and_tx_vfo() {
+ let backend = Ts590Backend::new();
+ let state = StateHandle::new();
+
+ // Operator on VFO B; a client (ARCP-590) then commands transmit on VFO A -> the
+ // radio is in reverse-split and the snapshot must reflect it.
+ assert!(backend.record_event(b"FR1;", &state, RadioEventSource::NativePush));
+ assert!(backend.record_event(b"FT0;", &state, RadioEventSource::NativePush));
+ let snap = state.snapshot();
+ assert_eq!(snap.rx_vfo, Vfo::B);
+ assert!(snap.split, "FR=B / FT=A must register as split");
+ assert_eq!(snap.tx_vfo, Vfo::A);
+
+ // Re-aligning the transmit VFO to the receive VFO clears the split.
+ assert!(backend.record_event(b"FT1;", &state, RadioEventSource::NativePush));
+ let snap = state.snapshot();
+ assert!(!snap.split, "FR=B / FT=B must clear split");
+ assert_eq!(snap.tx_vfo, Vfo::B);
+ }
+
+ #[test]
+ fn pc_power_uses_the_transmit_vfo_mode_maximum() {
+ let backend = Ts590Backend::new();
+ let state = StateHandle::new();
+ state.record(
+ StateChange::Mode {
+ vfo: Vfo::A,
+ mode: Mode::Am,
+ },
+ RadioEventSource::PollDiff,
+ );
+
+ assert!(backend.record_event(b"PC025;", &state, RadioEventSource::PollDiff,));
+ assert_eq!(state.snapshot().tx_power, Some(TxPower::from_watts(25, 25)));
+ }
+
+ #[test]
+ fn native_fr_then_md_records_mode_on_active_vfo() {
+ let backend = Ts590Backend::new();
+ let state = StateHandle::new();
+ state.record(
+ StateChange::Freq {
+ vfo: Vfo::B,
+ hz: 14_034_320,
+ },
+ RadioEventSource::PollDiff,
+ );
+
+ assert!(backend.record_event(b"FR1;", &state, RadioEventSource::NativePush));
+ assert!(backend.record_event(b"MD3;", &state, RadioEventSource::NativePush));
+
+ let snap = state.snapshot();
+ assert_eq!(snap.rx_vfo, Vfo::B);
+ assert_eq!(snap.vfo(Vfo::B).freq_hz, 14_034_320);
+ assert_eq!(snap.vfo(Vfo::B).mode, Mode::Cw);
+ assert_eq!(
+ snap.vfo(Vfo::A).mode,
+ Mode::Usb,
+ "VFO B mode push must not be recorded against VFO A"
+ );
+ }
+
+ #[test]
+ fn capabilities_are_certified_native_with_push() {
+ let caps = Ts590Backend::new().capabilities();
+ assert_eq!(caps.trust, TrustTier::CertifiedNative);
+ assert!(caps.native_push);
+ assert!(caps.supports_passthrough());
+ assert_eq!(
+ Ts590Backend::new().native_push_enable(),
+ Some(b"AI2;".to_vec())
+ );
+ }
+
+ #[tokio::test]
+ async fn apply_freq_writes_kenwood_frame() {
+ let (link, raw_rx) = link_channel();
+ let backend = Arc::new(Ts590Backend::new());
+ let arc: Arc = backend.clone();
+ let state = StateHandle::new();
+ let (mut radio_side, server) = tokio::io::duplex(1024);
+ tokio::spawn(run_transport(server, arc, state.clone(), raw_rx));
+
+ backend
+ .apply(
+ StateMutation::SetVfoFreq {
+ vfo: Vfo::A,
+ hz: 7_030_000,
+ },
+ &link,
+ &state,
+ )
+ .await
+ .expect("apply");
+
+ let mut buf = vec![0u8; 14];
+ radio_side.read_exact(&mut buf).await.expect("read frame");
+ assert_eq!(&buf, b"FA00007030000;");
+ assert_eq!(state.snapshot().vfo(Vfo::A).freq_hz, 7_030_000);
+ }
+
+ #[tokio::test]
+ async fn apply_rx_vfo_switches_operating_vfo_with_fr_and_ft() {
+ let (link, raw_rx) = link_channel();
+ let backend = Arc::new(Ts590Backend::new());
+ let arc: Arc = backend.clone();
+ let state = StateHandle::new();
+ let (mut radio_side, server) = tokio::io::duplex(1024);
+ tokio::spawn(run_transport(server, arc, state.clone(), raw_rx));
+
+ backend
+ .apply(StateMutation::SetRxVfo { vfo: Vfo::B }, &link, &state)
+ .await
+ .expect("apply");
+
+ // A non-split operating-VFO switch must move RX and TX together so the radio is
+ // never left in an accidental reverse-split (RX=B, TX=A).
+ let mut buf = vec![0u8; 8];
+ radio_side.read_exact(&mut buf).await.expect("read frame");
+ assert_eq!(&buf, b"FR1;FT1;");
+ let snap = state.snapshot();
+ assert_eq!(snap.rx_vfo, Vfo::B);
+ assert!(!snap.split, "operating-VFO switch must not be split");
+ assert_eq!(snap.tx_vfo, Vfo::B);
+ }
+
+ #[tokio::test]
+ async fn apply_rx_vfo_preserves_split_tx_vfo() {
+ let (link, raw_rx) = link_channel();
+ let backend = Arc::new(Ts590Backend::new());
+ let arc: Arc = backend.clone();
+ let state = StateHandle::new();
+ state.record(
+ StateChange::Split {
+ enabled: true,
+ tx_vfo: Some(Vfo::B),
+ },
+ RadioEventSource::PollDiff,
+ );
+ let (mut radio_side, server) = tokio::io::duplex(1024);
+ tokio::spawn(run_transport(server, arc, state.clone(), raw_rx));
+
+ backend
+ .apply(StateMutation::SetRxVfo { vfo: Vfo::A }, &link, &state)
+ .await
+ .expect("apply");
+
+ let mut buf = vec![0u8; 8];
+ radio_side.read_exact(&mut buf).await.expect("read frame");
+ assert_eq!(&buf, b"FR0;FT1;");
+ let snap = state.snapshot();
+ assert_eq!(snap.rx_vfo, Vfo::A);
+ assert!(snap.split);
+ assert_eq!(snap.tx_vfo, Vfo::B);
+ }
+
+ #[tokio::test]
+ async fn apply_ptt_keys_and_unkeys() {
+ let (link, raw_rx) = link_channel();
+ let backend = Arc::new(Ts590Backend::new());
+ let arc: Arc = backend.clone();
+ let state = StateHandle::new();
+ let (mut radio_side, server) = tokio::io::duplex(1024);
+ tokio::spawn(run_transport(server, arc, state.clone(), raw_rx));
+
+ backend
+ .apply(
+ StateMutation::SetPtt {
+ keyed: true,
+ source: PttSource::Generic,
+ },
+ &link,
+ &state,
+ )
+ .await
+ .expect("key");
+ let mut buf = vec![0u8; 3];
+ radio_side.read_exact(&mut buf).await.expect("read tx");
+ assert_eq!(&buf, b"TX;");
+ assert!(state.snapshot().ptt);
+ }
+
+ #[tokio::test]
+ async fn ptt_data_source_keys_with_tx1() {
+ let (link, raw_rx) = link_channel();
+ let backend = Arc::new(Ts590Backend::new());
+ let arc: Arc = backend.clone();
+ let state = StateHandle::new();
+ let (mut radio_side, server) = tokio::io::duplex(1024);
+ tokio::spawn(run_transport(server, arc, state.clone(), raw_rx));
+
+ backend
+ .apply(
+ StateMutation::SetPtt {
+ keyed: true,
+ source: PttSource::Data,
+ },
+ &link,
+ &state,
+ )
+ .await
+ .expect("key");
+ let mut buf = vec![0u8; 4];
+ radio_side.read_exact(&mut buf).await.expect("read tx");
+ assert_eq!(&buf, b"TX1;");
+ assert!(state.snapshot().ptt);
+ }
+
+ #[tokio::test]
+ async fn poll_reads_back_freq_and_mode() {
+ let (link, raw_rx) = link_channel();
+ let backend = Arc::new(Ts590Backend::new());
+ let arc: Arc = backend.clone();
+ let state = StateHandle::new();
+ let (radio_side, server) = tokio::io::duplex(1024);
+ tokio::spawn(run_transport(server, arc, state.clone(), raw_rx));
+
+ // A fake radio that answers the poll queries.
+ tokio::spawn(async move {
+ let (mut rd, mut wr) = tokio::io::split(radio_side);
+ let mut frame = Vec::new();
+ let mut byte = [0u8; 1];
+ loop {
+ if rd.read(&mut byte).await.unwrap_or(0) == 0 {
+ break;
+ }
+ frame.push(byte[0]);
+ if byte[0] == b';' {
+ let answer: &[u8] = match frame.as_slice() {
+ b"FA;" => b"FA00007030000;",
+ b"FB;" => b"FB00014250000;",
+ b"IF;" => b"IF000070300000000+0000000000030000000;",
+ b"MD;" => b"MD3;",
+ b"DA;" => b"DA1;",
+ b"PC;" => b"PC050;",
+ _ => b"",
+ };
+ let _ = wr.write_all(answer).await;
+ frame.clear();
+ }
+ }
+ });
+
+ backend.poll(&link, &state).await.expect("poll");
+ let snap = state.snapshot();
+ assert_eq!(snap.vfo(Vfo::A).freq_hz, 7_030_000);
+ assert_eq!(snap.vfo(Vfo::B).freq_hz, 14_250_000);
+ assert_eq!(snap.vfo(Vfo::A).mode, Mode::Cw);
+ assert!(snap.vfo(Vfo::A).data, "DA; reply should set the DATA flag");
+ assert_eq!(snap.tx_power, Some(TxPower::from_watts(50, 100)));
+ }
+
+ #[tokio::test]
+ async fn poll_reads_active_vfo_from_if_status() {
+ let (link, raw_rx) = link_channel();
+ let backend = Arc::new(Ts590Backend::new());
+ let arc: Arc = backend.clone();
+ let state = StateHandle::new();
+ let (radio_side, server) = tokio::io::duplex(1024);
+ tokio::spawn(run_transport(server, arc, state.clone(), raw_rx));
+
+ tokio::spawn(async move {
+ let (mut rd, mut wr) = tokio::io::split(radio_side);
+ let mut frame = Vec::new();
+ let mut byte = [0u8; 1];
+ loop {
+ if rd.read(&mut byte).await.unwrap_or(0) == 0 {
+ break;
+ }
+ frame.push(byte[0]);
+ if byte[0] == b';' {
+ let answer: &[u8] = match frame.as_slice() {
+ b"FA;" => b"FA00003020950;",
+ b"FB;" => b"FB00014034320;",
+ b"IF;" => b"IF000140343201234-0000012345021009999;",
+ b"MD;" => b"MD3;",
+ b"DA;" => b"DA0;",
+ b"PC;" => b"PC100;",
+ _ => b"",
+ };
+ let _ = wr.write_all(answer).await;
+ frame.clear();
+ }
+ }
+ });
+
+ backend.poll(&link, &state).await.expect("poll");
+ let snap = state.snapshot();
+ assert_eq!(snap.rx_vfo, Vfo::B);
+ assert_eq!(snap.vfo(snap.rx_vfo).freq_hz, 14_034_320);
+ assert_eq!(snap.vfo(snap.rx_vfo).mode, Mode::Cw);
+ }
+
+ #[tokio::test]
+ async fn poll_records_md_and_da_on_active_vfo() {
+ let (link, raw_rx) = link_channel();
+ let backend = Arc::new(Ts590Backend::new());
+ let arc: Arc = backend.clone();
+ let state = StateHandle::new();
+ let (radio_side, server) = tokio::io::duplex(1024);
+ tokio::spawn(run_transport(server, arc, state.clone(), raw_rx));
+
+ tokio::spawn(async move {
+ let (mut rd, mut wr) = tokio::io::split(radio_side);
+ let mut frame = Vec::new();
+ let mut byte = [0u8; 1];
+ loop {
+ if rd.read(&mut byte).await.unwrap_or(0) == 0 {
+ break;
+ }
+ frame.push(byte[0]);
+ if byte[0] == b';' {
+ let answer: &[u8] = match frame.as_slice() {
+ b"FA;" => b"FA00003020950;",
+ b"FB;" => b"FB00014034320;",
+ b"IF;" => b"IF000140343201234-0000012345021009999;",
+ b"MD;" => b"MD3;",
+ b"DA;" => b"DA1;",
+ b"PC;" => b"PC100;",
+ _ => b"",
+ };
+ let _ = wr.write_all(answer).await;
+ frame.clear();
+ }
+ }
+ });
+
+ backend.poll(&link, &state).await.expect("poll");
+ let snap = state.snapshot();
+ assert_eq!(snap.rx_vfo, Vfo::B);
+ assert_eq!(snap.vfo(Vfo::B).mode, Mode::Cw);
+ assert!(snap.vfo(Vfo::B).data);
+ assert_eq!(
+ snap.vfo(Vfo::A).mode,
+ Mode::Usb,
+ "active VFO B MD reply must not update VFO A"
+ );
+ assert!(
+ !snap.vfo(Vfo::A).data,
+ "active VFO B DA reply must not update VFO A"
+ );
+ }
+
+ #[tokio::test]
+ async fn apply_data_mode_writes_da_frame() {
+ let (link, raw_rx) = link_channel();
+ let backend = Arc::new(Ts590Backend::new());
+ let arc: Arc = backend.clone();
+ let state = StateHandle::new();
+ let (mut radio_side, server) = tokio::io::duplex(1024);
+ tokio::spawn(run_transport(server, arc, state.clone(), raw_rx));
+
+ backend
+ .apply(
+ StateMutation::SetDataMode {
+ vfo: Vfo::A,
+ on: true,
+ },
+ &link,
+ &state,
+ )
+ .await
+ .expect("set data on");
+ let mut buf = vec![0u8; 4];
+ radio_side.read_exact(&mut buf).await.expect("read da on");
+ assert_eq!(&buf, b"DA1;");
+ assert!(state.snapshot().vfo(Vfo::A).data);
+
+ backend
+ .apply(
+ StateMutation::SetDataMode {
+ vfo: Vfo::A,
+ on: false,
+ },
+ &link,
+ &state,
+ )
+ .await
+ .expect("set data off");
+ let mut buf = vec![0u8; 4];
+ radio_side.read_exact(&mut buf).await.expect("read da off");
+ assert_eq!(&buf, b"DA0;");
+ assert!(!state.snapshot().vfo(Vfo::A).data);
+ }
+
+ #[test]
+ fn query_detection_distinguishes_get_from_set() {
+ assert!(is_query(b"FA;"));
+ assert!(!is_query(b"FA00007030000;"));
+ assert!(!is_query(b"EX0050000;"));
+ }
+}
diff --git a/crates/cathub/src/backend/loopback.rs b/crates/cathub/src/backend/loopback.rs
new file mode 100644
index 0000000..1bee4d0
--- /dev/null
+++ b/crates/cathub/src/backend/loopback.rs
@@ -0,0 +1,193 @@
+//! The in-memory loopback backend used by tests and the `loopback` config option.
+//!
+//! It records every mutation and passthrough it receives and exposes a mutable "truth"
+//! frequency so a test can simulate a front-panel knob turn that a poll then diffs into
+//! state. It has no native push (so the poller always runs at baseline against it).
+
+use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
+use std::sync::{Arc, Mutex, PoisonError};
+
+use async_trait::async_trait;
+
+use crate::backend::{
+ BackendCapabilities, BackendError, Framing, NativeCommandFamily, RadioBackend, SplitStyle,
+ TrustTier,
+};
+use crate::model::{RadioEventSource, StateChange, StateMutation, Vfo};
+use crate::radio::RadioLink;
+use crate::state::StateHandle;
+
+/// The default VFO-A "truth" the loopback radio reports when polled.
+const DEFAULT_TRUTH_FREQ_A: u64 = 14_074_000;
+
+/// A deterministic in-memory backend.
+#[derive(Clone)]
+pub(crate) struct LoopbackBackend {
+ mutations: Arc>>,
+ passthroughs: Arc>>>,
+ polls: Arc,
+ truth_freq_a: Arc,
+}
+
+impl LoopbackBackend {
+ /// Create a fresh loopback backend.
+ pub(crate) fn new() -> Self {
+ LoopbackBackend {
+ mutations: Arc::new(Mutex::new(Vec::new())),
+ passthroughs: Arc::new(Mutex::new(Vec::new())),
+ polls: Arc::new(AtomicUsize::new(0)),
+ truth_freq_a: Arc::new(AtomicU64::new(DEFAULT_TRUTH_FREQ_A)),
+ }
+ }
+
+ /// The mutations applied so far (in order).
+ #[cfg(test)]
+ pub(crate) fn mutations(&self) -> Vec {
+ self.mutations
+ .lock()
+ .unwrap_or_else(PoisonError::into_inner)
+ .clone()
+ }
+
+ /// The raw passthrough payloads forwarded so far (in order).
+ #[cfg(test)]
+ pub(crate) fn passthroughs(&self) -> Vec> {
+ self.passthroughs
+ .lock()
+ .unwrap_or_else(PoisonError::into_inner)
+ .clone()
+ }
+
+ /// How many poll cycles have run.
+ #[cfg(test)]
+ pub(crate) fn poll_count(&self) -> usize {
+ self.polls.load(Ordering::SeqCst)
+ }
+
+ /// Simulate a front-panel change to VFO A's frequency; the next poll diffs it.
+ #[cfg(test)]
+ pub(crate) fn set_truth_freq_a(&self, hz: u64) {
+ self.truth_freq_a.store(hz, Ordering::SeqCst);
+ }
+}
+
+#[async_trait]
+impl RadioBackend for LoopbackBackend {
+ async fn poll(&self, _link: &RadioLink, state: &StateHandle) -> Result<(), BackendError> {
+ self.polls.fetch_add(1, Ordering::SeqCst);
+ // The loopback radio only surfaces VFO-A frequency truth; recording just one field
+ // keeps the "one broadcast per real change" invariant easy to reason about.
+ let hz = self.truth_freq_a.load(Ordering::SeqCst);
+ state.record(
+ StateChange::Freq { vfo: Vfo::A, hz },
+ RadioEventSource::PollDiff,
+ );
+ Ok(())
+ }
+
+ async fn apply(
+ &self,
+ mutation: StateMutation,
+ _link: &RadioLink,
+ state: &StateHandle,
+ ) -> Result<(), BackendError> {
+ self.mutations
+ .lock()
+ .unwrap_or_else(PoisonError::into_inner)
+ .push(mutation);
+ state.record(mutation.into_change(), RadioEventSource::OptimisticWrite);
+ Ok(())
+ }
+
+ fn parse_event(&self, _frame: &[u8]) -> Option {
+ None
+ }
+
+ async fn passthrough(&self, raw: &[u8], _link: &RadioLink) -> Result, BackendError> {
+ self.passthroughs
+ .lock()
+ .unwrap_or_else(PoisonError::into_inner)
+ .push(raw.to_vec());
+ Ok(raw.to_vec())
+ }
+
+ fn capabilities(&self) -> BackendCapabilities {
+ BackendCapabilities {
+ model: "loopback".to_string(),
+ vfo_count: 2,
+ has_rit: true,
+ has_xit: true,
+ has_smeter: true,
+ split: SplitStyle::VfoPair,
+ native_push: false,
+ native_command_family: Some(NativeCommandFamily::Kenwood),
+ framing: Framing::SemicolonTerminated,
+ freq_min_hz: 30_000,
+ freq_max_hz: 60_000_000,
+ trust: TrustTier::Loopback,
+ }
+ }
+
+ fn native_push_enable(&self) -> Option> {
+ None
+ }
+}
+
+#[cfg(test)]
+#[allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)]
+mod tests {
+ use super::*;
+ use crate::radio::detached_link;
+
+ #[tokio::test]
+ async fn poll_records_truth_and_counts() {
+ let backend = LoopbackBackend::new();
+ let state = StateHandle::new();
+ backend.set_truth_freq_a(7_123_000);
+ backend.poll(&detached_link(), &state).await.expect("poll");
+ assert_eq!(backend.poll_count(), 1);
+ assert_eq!(state.snapshot().vfo(Vfo::A).freq_hz, 7_123_000);
+ }
+
+ #[tokio::test]
+ async fn apply_records_mutation_and_state() {
+ let backend = LoopbackBackend::new();
+ let state = StateHandle::new();
+ backend
+ .apply(
+ StateMutation::SetVfoFreq {
+ vfo: Vfo::A,
+ hz: 14_250_000,
+ },
+ &detached_link(),
+ &state,
+ )
+ .await
+ .expect("apply");
+ assert_eq!(
+ backend.mutations(),
+ vec![StateMutation::SetVfoFreq {
+ vfo: Vfo::A,
+ hz: 14_250_000
+ }]
+ );
+ assert_eq!(state.snapshot().vfo(Vfo::A).freq_hz, 14_250_000);
+ }
+
+ #[tokio::test]
+ async fn passthrough_echoes_and_records() {
+ let backend = LoopbackBackend::new();
+ let reply = backend
+ .passthrough(b"EX0050000;", &detached_link())
+ .await
+ .expect("passthrough");
+ assert_eq!(reply, b"EX0050000;");
+ assert_eq!(backend.passthroughs(), vec![b"EX0050000;".to_vec()]);
+ }
+
+ #[test]
+ fn loopback_has_no_native_push() {
+ assert!(LoopbackBackend::new().native_push_enable().is_none());
+ assert!(!LoopbackBackend::new().capabilities().native_push);
+ }
+}
diff --git a/crates/cathub/src/backend/mod.rs b/crates/cathub/src/backend/mod.rs
new file mode 100644
index 0000000..b75252b
--- /dev/null
+++ b/crates/cathub/src/backend/mod.rs
@@ -0,0 +1,181 @@
+//! The radio backend abstraction: the [`RadioBackend`] trait every concrete radio
+//! implements, plus the [`BackendCapabilities`] it advertises.
+//!
+//! A backend is the only code that knows a specific radio's wire vocabulary. It maps the
+//! neutral [`StateMutation`]/[`StateChange`] vocabulary to and from bytes and reports what
+//! it can do (and how much it can be trusted) via [`BackendCapabilities`].
+
+pub(crate) mod kenwood;
+pub(crate) mod loopback;
+pub(crate) mod rigctld;
+
+use async_trait::async_trait;
+
+pub(crate) use crate::error::BackendError;
+use crate::model::{RadioEventSource, StateMutation};
+use crate::radio::RadioLink;
+use crate::state::StateHandle;
+
+/// How the byte stream is split into frames.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum Framing {
+ /// Kenwood/Yaesu style: each frame ends with `;`.
+ SemicolonTerminated,
+ /// Line protocols (e.g. `rigctld` net): each frame ends with `\n`.
+ LineTerminated,
+ /// Icom CI-V style: each frame ends with `0xFD`. Reserved for a future CI-V backend.
+ #[allow(dead_code)]
+ CiV,
+}
+
+/// How a backend models split operation.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum SplitStyle {
+ /// Split is a TX/RX VFO pair (Kenwood, Yaesu, most rigs).
+ VfoPair,
+ /// The radio has no split concept. Reserved for single-VFO backends.
+ #[allow(dead_code)]
+ None,
+}
+
+/// How much the daemon trusts a backend's wire behavior (design §7).
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum TrustTier {
+ /// A first-party native driver that has been certified against the real radio.
+ CertifiedNative,
+ /// An out-of-process bridge (e.g. `rigctld`) that has not been soak-certified.
+ UncertifiedBridge,
+ /// The in-memory test backend.
+ Loopback,
+}
+
+/// The native command family a backend can passthrough (for clients that need raw CAT).
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum NativeCommandFamily {
+ /// Kenwood `;`-terminated ASCII CAT.
+ Kenwood,
+}
+
+/// What a backend can do, advertised to endpoints and the poller.
+#[derive(Debug, Clone)]
+#[allow(clippy::struct_excessive_bools)] // Each flag is an independent capability bit.
+pub(crate) struct BackendCapabilities {
+ /// A human-readable model identifier.
+ pub(crate) model: String,
+ /// Number of VFOs.
+ pub(crate) vfo_count: u8,
+ /// Whether the radio models RIT.
+ pub(crate) has_rit: bool,
+ /// Whether the radio models XIT.
+ pub(crate) has_xit: bool,
+ /// Whether the radio reports an S-meter.
+ pub(crate) has_smeter: bool,
+ /// How split is modeled.
+ pub(crate) split: SplitStyle,
+ /// Whether the radio has a native push (auto-information) stream.
+ pub(crate) native_push: bool,
+ /// The native command family available for passthrough, if any.
+ pub(crate) native_command_family: Option,
+ /// How frames are delimited.
+ pub(crate) framing: Framing,
+ /// Minimum tunable frequency in Hz.
+ pub(crate) freq_min_hz: u64,
+ /// Maximum tunable frequency in Hz.
+ pub(crate) freq_max_hz: u64,
+ /// Trust tier.
+ pub(crate) trust: TrustTier,
+}
+
+impl BackendCapabilities {
+ /// Whether raw native passthrough is available (a native command family is present).
+ pub(crate) fn supports_passthrough(&self) -> bool {
+ self.native_command_family.is_some()
+ }
+
+ /// A one-line human-readable summary (used in startup logging).
+ pub(crate) fn summary(&self) -> String {
+ format!(
+ "model={} vfos={} rit={} xit={} smeter={} split={:?} native_push={} \
+ family={:?} framing={:?} freq={}..{} trust={:?} passthrough={}",
+ self.model,
+ self.vfo_count,
+ self.has_rit,
+ self.has_xit,
+ self.has_smeter,
+ self.split,
+ self.native_push,
+ self.native_command_family,
+ self.framing,
+ self.freq_min_hz,
+ self.freq_max_hz,
+ self.trust,
+ self.supports_passthrough(),
+ )
+ }
+}
+
+/// A concrete radio. Implementations own one radio's wire vocabulary; everything above
+/// them speaks only the neutral [`StateMutation`]/[`StateChange`] vocabulary.
+#[async_trait]
+pub(crate) trait RadioBackend: Send + Sync {
+ /// Run one baseline poll cycle, recording observed state via `state`.
+ async fn poll(&self, link: &RadioLink, state: &StateHandle) -> Result<(), BackendError>;
+
+ /// Apply a modeled mutation, recording the resulting state via `state`.
+ async fn apply(
+ &self,
+ mutation: StateMutation,
+ link: &RadioLink,
+ state: &StateHandle,
+ ) -> Result<(), BackendError>;
+
+ /// Parse an unsolicited (native push) frame into a mutation, if recognized.
+ fn parse_event(&self, frame: &[u8]) -> Option;
+
+ /// Record an unsolicited (native push) frame into the universal state, if recognized.
+ fn record_event(&self, frame: &[u8], state: &StateHandle, source: RadioEventSource) -> bool {
+ if let Some(mutation) = self.parse_event(frame) {
+ state.record(mutation.into_change(), source);
+ true
+ } else {
+ false
+ }
+ }
+
+ /// Forward a raw native command, returning the raw reply.
+ async fn passthrough(&self, raw: &[u8], link: &RadioLink) -> Result, BackendError>;
+
+ /// The backend's advertised capabilities.
+ fn capabilities(&self) -> BackendCapabilities;
+
+ /// The command that enables the radio's native push stream, if it has one.
+ /// Backends without a native push stream (e.g. an out-of-process rigctld bridge)
+ /// keep the default of `None` and are polled instead.
+ fn native_push_enable(&self) -> Option> {
+ None
+ }
+}
+
+#[cfg(test)]
+#[allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn passthrough_support_tracks_command_family() {
+ let mut caps = loopback::LoopbackBackend::new().capabilities();
+ assert!(caps.supports_passthrough());
+ caps.native_command_family = None;
+ assert!(!caps.supports_passthrough());
+ }
+
+ #[test]
+ fn summary_mentions_every_axis() {
+ let caps = loopback::LoopbackBackend::new().capabilities();
+ let s = caps.summary();
+ assert!(s.contains("model="));
+ assert!(s.contains("native_push="));
+ assert!(s.contains("trust="));
+ assert!(s.contains("passthrough="));
+ }
+}
diff --git a/crates/cathub/src/backend/rigctld.rs b/crates/cathub/src/backend/rigctld.rs
new file mode 100644
index 0000000..969a3d6
--- /dev/null
+++ b/crates/cathub/src/backend/rigctld.rs
@@ -0,0 +1,447 @@
+//! Out-of-process `rigctld` bridge backend (breadth path, uncertified by default).
+//!
+//! The daemon is the *sole client* of a daemon-private `rigctld` and speaks the rigctld
+//! net protocol as a client. It provides modeled control (frequency, mode, PTT, split,
+//! and configured transmitter power)
+//! across every rig Hamlib supports, using each rig's correct native model. It carries
+//! **no** native passthrough (rigctld normalizes the CAT away) and reports
+//! `native_command_family: None`, so an `EX`-menu passthrough fails closed (§7.1, §8.8).
+
+use async_trait::async_trait;
+
+use crate::backend::{
+ BackendCapabilities, BackendError, Framing, RadioBackend, SplitStyle, TrustTier,
+};
+use crate::model::{Mode, PttSource, RadioEventSource, StateChange, StateMutation, TxPower, Vfo};
+use crate::radio::{Expect, RadioLink};
+use crate::state::StateHandle;
+
+/// Poll commands for the bridge: get-frequency, get-mode, get-split, get-ptt. All are
+/// read-only and never retarget a VFO, but the bridge cannot *certify* the radio-side
+/// wire, so it stays uncertified.
+const POLL_GET_FREQ: &[u8] = b"f\n";
+const POLL_GET_MODE: &[u8] = b"m\n";
+const POLL_GET_SPLIT: &[u8] = b"s\n";
+const POLL_GET_SPLIT_FREQ: &[u8] = b"i\n";
+const POLL_GET_SPLIT_MODE: &[u8] = b"x\n";
+const POLL_GET_RFPOWER: &[u8] = b"l RFPOWER\n";
+
+/// Out-of-process `rigctld` bridge.
+#[derive(Clone)]
+pub(crate) struct RigctldBackend {
+ model: String,
+ certified: bool,
+}
+
+impl RigctldBackend {
+ /// Create a bridge backend. `certified` reflects whether the operator has run the
+ /// §10.3 soak for this rigctld version+model+config; it is `false` by default.
+ pub(crate) fn new(model: impl Into, certified: bool) -> Self {
+ RigctldBackend {
+ model: model.into(),
+ certified,
+ }
+ }
+}
+
+fn first_line(bytes: &[u8]) -> &[u8] {
+ let end = bytes
+ .iter()
+ .position(|&b| b == b'\n')
+ .unwrap_or(bytes.len());
+ bytes.get(..end).unwrap_or(&[])
+}
+
+fn nth_line(bytes: &[u8], n: usize) -> &[u8] {
+ bytes.split(|&b| b == b'\n').nth(n).unwrap_or(&[])
+}
+
+/// Check a `RPRT ` reply; non-zero is an error.
+fn check_rprt(bytes: &[u8]) -> Result<(), BackendError> {
+ let line = first_line(bytes);
+ let text = std::str::from_utf8(line).unwrap_or("").trim();
+ if let Some(code) = text.strip_prefix("RPRT ") {
+ if code.trim() == "0" {
+ Ok(())
+ } else {
+ Err(BackendError::Rejected(format!("rigctld {text}")))
+ }
+ } else {
+ // Some setters reply with nothing meaningful; treat absence of error as success.
+ Ok(())
+ }
+}
+
+fn is_rprt_error(bytes: &[u8]) -> bool {
+ std::str::from_utf8(first_line(bytes))
+ .unwrap_or("")
+ .trim()
+ .starts_with("RPRT ")
+}
+
+async fn poll_split_details(
+ link: &RadioLink,
+ state: &StateHandle,
+ tx_vfo: Vfo,
+) -> Result<(), BackendError> {
+ let split_frequency = link
+ .submit(POLL_GET_SPLIT_FREQ.to_vec(), Expect::Lines(1))
+ .await?;
+ if !is_rprt_error(&split_frequency) {
+ if let Ok(hz) = std::str::from_utf8(first_line(&split_frequency))
+ .unwrap_or("")
+ .trim()
+ .parse::()
+ {
+ state.record(
+ StateChange::Freq { vfo: tx_vfo, hz },
+ RadioEventSource::PollDiff,
+ );
+ }
+ }
+
+ let split_mode = link
+ .submit(POLL_GET_SPLIT_MODE.to_vec(), Expect::Lines(2))
+ .await?;
+ if !is_rprt_error(&split_mode) {
+ let (mode, data) = Mode::decompose_hamlib_token(
+ std::str::from_utf8(first_line(&split_mode)).unwrap_or(""),
+ );
+ if mode != Mode::Unknown {
+ state.record(
+ StateChange::Mode { vfo: tx_vfo, mode },
+ RadioEventSource::PollDiff,
+ );
+ state.record(
+ StateChange::DataMode {
+ vfo: tx_vfo,
+ on: data,
+ },
+ RadioEventSource::PollDiff,
+ );
+ }
+ }
+ Ok(())
+}
+
+async fn poll_tx_power(link: &RadioLink, state: &StateHandle) -> Result<(), BackendError> {
+ let relative_reply = link
+ .submit(POLL_GET_RFPOWER.to_vec(), Expect::Lines(1))
+ .await?;
+ let relative_text = std::str::from_utf8(first_line(&relative_reply))
+ .unwrap_or("")
+ .trim();
+ let Some(relative_millionths) = TxPower::parse_relative_millionths(relative_text) else {
+ state.record(
+ StateChange::TxPower { power: None },
+ RadioEventSource::PollDiff,
+ );
+ return Ok(());
+ };
+
+ let snapshot = state.snapshot();
+ let effective_tx_vfo = if snapshot.split {
+ snapshot.tx_vfo
+ } else {
+ snapshot.rx_vfo
+ };
+ let tx = snapshot.vfo(effective_tx_vfo);
+ let conversion = format!(
+ "2 {relative_text} {} {}\n",
+ tx.freq_hz,
+ tx.mode.hamlib_token_with_data(tx.data)
+ );
+ let milliwatts_reply = link
+ .submit(conversion.into_bytes(), Expect::Lines(1))
+ .await?;
+ let power = if is_rprt_error(&milliwatts_reply) {
+ None
+ } else {
+ std::str::from_utf8(first_line(&milliwatts_reply))
+ .unwrap_or("")
+ .trim()
+ .parse::()
+ .ok()
+ .and_then(|milliwatts| {
+ TxPower::from_relative_millionths(relative_millionths, milliwatts)
+ })
+ };
+ state.record(StateChange::TxPower { power }, RadioEventSource::PollDiff);
+ Ok(())
+}
+
+#[async_trait]
+impl RadioBackend for RigctldBackend {
+ async fn poll(&self, link: &RadioLink, state: &StateHandle) -> Result<(), BackendError> {
+ let f = link
+ .submit(POLL_GET_FREQ.to_vec(), Expect::Lines(1))
+ .await?;
+ if let Ok(hz) = std::str::from_utf8(first_line(&f))
+ .unwrap_or("")
+ .trim()
+ .parse::()
+ {
+ state.record(
+ StateChange::Freq { vfo: Vfo::A, hz },
+ RadioEventSource::PollDiff,
+ );
+ }
+ let m = link
+ .submit(POLL_GET_MODE.to_vec(), Expect::Lines(2))
+ .await?;
+ let mode = Mode::from_hamlib_token(std::str::from_utf8(first_line(&m)).unwrap_or(""));
+ state.record(
+ StateChange::Mode { vfo: Vfo::A, mode },
+ RadioEventSource::PollDiff,
+ );
+ let s = link
+ .submit(POLL_GET_SPLIT.to_vec(), Expect::Lines(2))
+ .await?;
+ let enabled = std::str::from_utf8(first_line(&s)).unwrap_or("").trim() == "1";
+ let tx_vfo = if std::str::from_utf8(nth_line(&s, 1))
+ .unwrap_or("")
+ .trim()
+ .eq_ignore_ascii_case("VFOB")
+ {
+ Vfo::B
+ } else {
+ Vfo::A
+ };
+ state.record(
+ StateChange::Split {
+ enabled,
+ tx_vfo: Some(tx_vfo),
+ },
+ RadioEventSource::PollDiff,
+ );
+ if enabled {
+ poll_split_details(link, state, tx_vfo).await?;
+ }
+ poll_tx_power(link, state).await?;
+ Ok(())
+ }
+
+ async fn apply(
+ &self,
+ mutation: StateMutation,
+ link: &RadioLink,
+ state: &StateHandle,
+ ) -> Result<(), BackendError> {
+ match mutation {
+ StateMutation::SetVfoFreq { hz, .. } => {
+ let reply = link
+ .submit(format!("F {hz}\n").into_bytes(), Expect::Lines(1))
+ .await?;
+ check_rprt(&reply)?;
+ }
+ StateMutation::SetMode { mode, .. } => {
+ let reply = link
+ .submit(
+ format!("M {} 0\n", mode.hamlib_token()).into_bytes(),
+ Expect::Lines(1),
+ )
+ .await?;
+ check_rprt(&reply)?;
+ }
+ // Compose the DATA flag back onto the current base mode and re-assert the mode
+ // downstream (the bridge target speaks combined Hamlib mode tokens, not a
+ // separate DATA command).
+ StateMutation::SetDataMode { vfo, on } => {
+ let base = state.snapshot().vfo(vfo).mode;
+ let reply = link
+ .submit(
+ format!("M {} 0\n", base.hamlib_token_with_data(on)).into_bytes(),
+ Expect::Lines(1),
+ )
+ .await?;
+ check_rprt(&reply)?;
+ }
+ StateMutation::SetSplit { enabled, tx_vfo } => {
+ let vfo = match tx_vfo.unwrap_or(Vfo::B) {
+ Vfo::A => "VFOA",
+ Vfo::B => "VFOB",
+ };
+ let on = u8::from(enabled);
+ let reply = link
+ .submit(format!("S {on} {vfo}\n").into_bytes(), Expect::Lines(1))
+ .await?;
+ check_rprt(&reply)?;
+ }
+ StateMutation::SetPtt { keyed, source } => {
+ let on = if keyed {
+ match source {
+ PttSource::Generic => 1,
+ PttSource::Mic => 2,
+ PttSource::Data => 3,
+ }
+ } else {
+ 0
+ };
+ let reply = link
+ .submit(format!("T {on}\n").into_bytes(), Expect::Lines(1))
+ .await?;
+ check_rprt(&reply)?;
+ }
+ StateMutation::SetRxVfo { .. }
+ | StateMutation::SetRit { .. }
+ | StateMutation::SetXit { .. } => {
+ return Err(BackendError::Unsupported);
+ }
+ }
+ state.record(mutation.into_change(), RadioEventSource::OptimisticWrite);
+ Ok(())
+ }
+
+ fn parse_event(&self, _frame: &[u8]) -> Option {
+ // The bridge has no native push; downstream uniformity comes from poll-diff.
+ None
+ }
+
+ async fn passthrough(&self, _raw: &[u8], _link: &RadioLink) -> Result, BackendError> {
+ // Fails closed: the bridge normalizes native CAT away (§7.1).
+ Err(BackendError::Unsupported)
+ }
+
+ fn capabilities(&self) -> BackendCapabilities {
+ BackendCapabilities {
+ model: format!("rigctld:{}", self.model),
+ vfo_count: 2,
+ has_rit: false,
+ has_xit: false,
+ has_smeter: false,
+ split: SplitStyle::VfoPair,
+ native_push: false,
+ native_command_family: None,
+ framing: Framing::LineTerminated,
+ freq_min_hz: 30_000,
+ freq_max_hz: 60_000_000,
+ trust: if self.certified {
+ TrustTier::CertifiedNative
+ } else {
+ TrustTier::UncertifiedBridge
+ },
+ }
+ }
+}
+
+#[cfg(test)]
+#[allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)]
+mod tests {
+ use super::*;
+ use crate::radio::{link_channel, run_transport};
+ use std::sync::Arc;
+ use tokio::io::{AsyncReadExt, AsyncWriteExt};
+
+ #[test]
+ fn bridge_is_uncertified_by_default() {
+ let backend = RigctldBackend::new("TS-590SG", false);
+ assert_eq!(backend.capabilities().trust, TrustTier::UncertifiedBridge);
+ assert!(backend.capabilities().native_command_family.is_none());
+ assert!(!backend.capabilities().supports_passthrough());
+ }
+
+ #[tokio::test]
+ async fn passthrough_fails_closed() {
+ let backend = RigctldBackend::new("IC-7300", false);
+ let link = crate::radio::detached_link();
+ let result = backend.passthrough(b"EX0050000;", &link).await;
+ assert!(matches!(result, Err(BackendError::Unsupported)));
+ }
+
+ #[test]
+ fn rprt_zero_is_ok_nonzero_is_error() {
+ assert!(check_rprt(b"RPRT 0\n").is_ok());
+ assert!(check_rprt(b"RPRT -11\n").is_err());
+ assert!(check_rprt(b"7030000\n").is_ok());
+ }
+
+ #[test]
+ fn parses_lines() {
+ assert_eq!(first_line(b"USB\n2400\n"), b"USB");
+ assert_eq!(nth_line(b"1\nVFOB\n", 1), b"VFOB");
+ }
+
+ #[tokio::test]
+ async fn poll_records_split_transmit_state_and_power() {
+ let (link, raw_rx) = link_channel();
+ let backend = Arc::new(RigctldBackend::new("test", false));
+ let arc: Arc = backend.clone();
+ let state = StateHandle::new();
+ let (radio_side, server) = tokio::io::duplex(1024);
+ tokio::spawn(run_transport(server, arc, state.clone(), raw_rx));
+
+ tokio::spawn(async move {
+ let (mut rd, mut wr) = tokio::io::split(radio_side);
+ let mut request = Vec::new();
+ let mut byte = [0u8; 1];
+ loop {
+ if rd.read(&mut byte).await.unwrap_or(0) == 0 {
+ break;
+ }
+ request.push(byte[0]);
+ if byte[0] == b'\n' {
+ let answer: &[u8] = match request.as_slice() {
+ b"f\n" => b"14074000\n",
+ b"m\n" => b"USB\n2400\n",
+ b"s\n" => b"1\nVFOB\n",
+ b"i\n" => b"14076000\n",
+ b"x\n" => b"CW\n500\n",
+ b"l RFPOWER\n" => b"0.5\n",
+ b"2 0.5 14076000 CW\n" => b"50000\n",
+ _ => b"RPRT -11\n",
+ };
+ wr.write_all(answer).await.expect("write fake reply");
+ request.clear();
+ }
+ }
+ });
+
+ backend.poll(&link, &state).await.expect("poll");
+
+ let snapshot = state.snapshot();
+ assert!(snapshot.split);
+ assert_eq!(snapshot.tx_vfo, Vfo::B);
+ assert_eq!(snapshot.vfo(Vfo::A).freq_hz, 14_074_000);
+ assert_eq!(snapshot.vfo(Vfo::B).freq_hz, 14_076_000);
+ assert_eq!(snapshot.vfo(Vfo::B).mode, Mode::Cw);
+ assert_eq!(snapshot.tx_power, Some(TxPower::from_watts(50, 100)));
+ }
+
+ #[tokio::test]
+ async fn poll_tolerates_unsupported_optional_power() {
+ let (link, raw_rx) = link_channel();
+ let backend = Arc::new(RigctldBackend::new("test", false));
+ let arc: Arc = backend.clone();
+ let state = StateHandle::new();
+ let (radio_side, server) = tokio::io::duplex(1024);
+ tokio::spawn(run_transport(server, arc, state.clone(), raw_rx));
+
+ tokio::spawn(async move {
+ let (mut rd, mut wr) = tokio::io::split(radio_side);
+ let mut request = Vec::new();
+ let mut byte = [0u8; 1];
+ loop {
+ if rd.read(&mut byte).await.unwrap_or(0) == 0 {
+ break;
+ }
+ request.push(byte[0]);
+ if byte[0] == b'\n' {
+ let answer: &[u8] = match request.as_slice() {
+ b"f\n" => b"14074000\n",
+ b"m\n" => b"USB\n2400\n",
+ b"s\n" => b"0\nVFOA\n",
+ _ => {
+ assert_eq!(request, b"l RFPOWER\n");
+ b"RPRT -11\n"
+ }
+ };
+ wr.write_all(answer).await.expect("write fake reply");
+ request.clear();
+ }
+ }
+ });
+
+ backend.poll(&link, &state).await.expect("poll");
+ assert_eq!(state.snapshot().tx_power, None);
+ }
+}
diff --git a/crates/cathub/src/config.rs b/crates/cathub/src/config.rs
new file mode 100644
index 0000000..844b540
--- /dev/null
+++ b/crates/cathub/src/config.rs
@@ -0,0 +1,1194 @@
+//! Daemon configuration (TOML).
+//!
+//! One `[radio]` section selects and parameterizes the backend; `[poll]`, `[ptt]`, and
+//! `[events]` tune cadence and safety; `[[serial_endpoint]]` and `[[hamlib_net]]` declare the client
+//! endpoints. Everything but `[radio].backend` has a sane default so a minimal config is
+//! short.
+
+use std::path::PathBuf;
+use std::time::Duration;
+
+use serde::{Deserialize, Serialize};
+
+use crate::error::ConfigError;
+use crate::permissions::EndpointPermissions;
+
+fn default_transport() -> String {
+ "serial".to_string()
+}
+fn default_baud() -> u32 {
+ 4_800
+}
+fn default_host() -> String {
+ "127.0.0.1".to_string()
+}
+fn default_tcp_port() -> u16 {
+ 4_532
+}
+fn default_reply_timeout_ms() -> u64 {
+ 1_000
+}
+fn default_baseline_ms() -> u64 {
+ 250
+}
+fn default_heartbeat_ms() -> u64 {
+ 3_000
+}
+fn default_max_tx_ms() -> u64 {
+ 300_000
+}
+fn default_native_push() -> bool {
+ true
+}
+fn default_winkeyer_baud() -> u32 {
+ 1_200
+}
+fn default_winkeyer_max_tx_ms() -> u64 {
+ 30_000
+}
+fn default_winkeyer_api_bind() -> String {
+ "127.0.0.1:50071".to_string()
+}
+
+/// The `[radio]` section.
+#[derive(Debug, Clone, Deserialize, Serialize)]
+pub(crate) struct RadioConfig {
+ /// Backend selector: `ts590`, `rigctld`, or `loopback`.
+ pub(crate) backend: String,
+ /// Human-readable / Hamlib model id (e.g. `TS-590SG`, `2014`).
+ #[serde(default)]
+ pub(crate) model: String,
+ /// `serial` or `tcp`.
+ #[serde(default = "default_transport")]
+ pub(crate) transport: String,
+ /// Serial port path (e.g. `COM3`, `/dev/ttyUSB0`).
+ #[serde(default)]
+ pub(crate) port: String,
+ /// Serial baud rate.
+ #[serde(default = "default_baud")]
+ pub(crate) baud: u32,
+ /// TCP host (for `tcp` transport or the rigctld bridge).
+ #[serde(default = "default_host")]
+ pub(crate) host: String,
+ /// TCP port.
+ #[serde(default = "default_tcp_port")]
+ pub(crate) tcp_port: u16,
+ /// Whether a bridge backend has been operator-certified.
+ #[serde(default)]
+ pub(crate) certified: bool,
+ /// Per-command reply timeout in milliseconds.
+ #[serde(default = "default_reply_timeout_ms")]
+ pub(crate) reply_timeout_ms: u64,
+}
+
+/// The `[poll]` section.
+#[derive(Debug, Clone, Deserialize, Serialize)]
+pub(crate) struct PollConfig {
+ /// Baseline poll interval in milliseconds.
+ #[serde(default = "default_baseline_ms")]
+ pub(crate) baseline_ms: u64,
+ /// Heartbeat (backed-off) interval in milliseconds.
+ #[serde(default = "default_heartbeat_ms")]
+ pub(crate) heartbeat_ms: u64,
+}
+
+impl Default for PollConfig {
+ fn default() -> Self {
+ PollConfig {
+ baseline_ms: default_baseline_ms(),
+ heartbeat_ms: default_heartbeat_ms(),
+ }
+ }
+}
+
+/// The `[ptt]` section.
+#[derive(Debug, Clone, Deserialize, Serialize)]
+pub(crate) struct PttConfig {
+ /// Maximum continuous transmit time in milliseconds (safety ceiling).
+ #[serde(default = "default_max_tx_ms")]
+ pub(crate) max_tx_ms: u64,
+}
+
+impl Default for PttConfig {
+ fn default() -> Self {
+ PttConfig {
+ max_tx_ms: default_max_tx_ms(),
+ }
+ }
+}
+
+/// The `[events]` section.
+#[derive(Debug, Clone, Deserialize, Serialize)]
+pub(crate) struct EventsConfig {
+ /// Whether to enable the radio's native push stream.
+ #[serde(default = "default_native_push")]
+ pub(crate) native_push: bool,
+}
+
+impl Default for EventsConfig {
+ fn default() -> Self {
+ EventsConfig {
+ native_push: default_native_push(),
+ }
+ }
+}
+
+/// A `[[serial_endpoint]]` (serial client) endpoint.
+#[derive(Debug, Clone, Deserialize, Serialize)]
+pub(crate) struct SerialEndpointConfig {
+ /// A label for logging.
+ pub(crate) name: String,
+ /// The serial port this endpoint listens on (a com0com / tty path).
+ pub(crate) transport: String,
+ /// The paired endpoint opened by the client application. The hub never opens it.
+ #[serde(default)]
+ pub(crate) application_transport: Option,
+ /// Baud rate for the endpoint port.
+ #[serde(default = "default_baud")]
+ pub(crate) baud: u32,
+ /// Dialect: `ts590` or `ts2000`.
+ pub(crate) dialect: String,
+ /// Permission tokens (`read`, `frequency_write`, `write`, `ptt`, `config_write`).
+ #[serde(default)]
+ pub(crate) perms: Vec,
+ /// Present the *operating* VFO as VFO A to this endpoint (operating-VFO virtualization).
+ ///
+ /// Single-VFO loggers (notably N1MM Logger+ in SO1V) read the active VFO from the
+ /// Kenwood `IF;` answer and refuse to track VFO B ("You should not use VFO B when
+ /// configured for SO1V"). With `single_vfo = true` the hub always presents whichever
+ /// VFO the operator is actually using as VFO A, so the logger follows A/B switches
+ /// seamlessly with no warning. Leave this `false` for true dual-VFO control endpoints such
+ /// as ARCP-590, which must see and address real VFO A and B independently.
+ #[serde(default)]
+ pub(crate) single_vfo: bool,
+}
+
+impl SerialEndpointConfig {
+ /// The parsed permission set.
+ pub(crate) fn permissions(&self) -> EndpointPermissions {
+ EndpointPermissions::from_tokens(&self.perms)
+ }
+}
+
+/// A `[[hamlib_net]]` (rigctld-compatible TCP) endpoint.
+#[derive(Debug, Clone, Deserialize, Serialize)]
+pub(crate) struct HamlibNetConfig {
+ /// A label for logging.
+ pub(crate) name: String,
+ /// The bind address (e.g. `127.0.0.1:4532`).
+ pub(crate) bind: String,
+ /// Permission tokens (`read`, `frequency_write`, `write`, `ptt`, `config_write`).
+ #[serde(default)]
+ pub(crate) perms: Vec,
+ /// Present the *operating* VFO as VFO A to this endpoint (operating-VFO virtualization).
+ ///
+ /// Single-VFO rigctld clients (notably WSJT-X, which expects to receive on VFO A, and
+ /// Log4OM, which polls `\get_vfo_info VFOA`) misbehave when the hub reports the true
+ /// VFO B as the active receive VFO: WSJT-X stops decoding and Log4OM logs the inactive
+ /// VFO A's stale frequency. With `single_vfo = true` the endpoint always presents
+ /// whichever VFO the operator is actually using as VFO A (`get_vfo` -> `VFOA`,
+ /// `get_vfo_info` answers from the operating VFO with `Split: 0`, `get_split_vfo` is
+ /// `0/VFOA`, and `set_split_vfo 1` is rejected so a client never believes a real A/B
+ /// split was armed). Leave this `false` for true dual-VFO control endpoints that must
+ /// see and address real VFO A and B independently.
+ #[serde(default)]
+ pub(crate) single_vfo: bool,
+}
+
+impl HamlibNetConfig {
+ /// The parsed permission set.
+ pub(crate) fn permissions(&self) -> EndpointPermissions {
+ EndpointPermissions::from_tokens(&self.perms)
+ }
+}
+
+/// The optional `[winkeyer]` physical-keyer broker section.
+#[derive(Debug, Clone, Deserialize, Serialize)]
+pub(crate) struct WinkeyerConfig {
+ /// Physical WinKeyer serial port exclusively owned by CatHub.
+ pub(crate) port: String,
+ /// Physical baud rate. WinKeyer always starts at 1200 baud.
+ #[serde(default = "default_winkeyer_baud")]
+ pub(crate) baud: u32,
+ /// Broker-wide transmit safety ceiling.
+ #[serde(default = "default_winkeyer_max_tx_ms")]
+ pub(crate) max_tx_ms: u64,
+ /// Loopback gRPC endpoint used by QsoRipper engines.
+ #[serde(default = "default_winkeyer_api_bind")]
+ pub(crate) api_bind: String,
+}
+
+/// One virtual WinKeyer serial endpoint backed by a com0com/PTY pair.
+#[derive(Debug, Clone, Deserialize, Serialize)]
+pub(crate) struct WinkeyerEndpointConfig {
+ /// Stable endpoint name used in logs and ownership status.
+ pub(crate) name: String,
+ /// Hub side of the virtual serial pair.
+ pub(crate) transport: String,
+ /// Paired endpoint opened by the client application. The hub never opens it.
+ #[serde(default)]
+ pub(crate) application_transport: Option,
+ /// Virtual endpoint baud rate.
+ #[serde(default = "default_winkeyer_baud")]
+ pub(crate) baud: u32,
+ /// Whether this client controls the idle paddle/foreground settings.
+ #[serde(default)]
+ pub(crate) primary: bool,
+ /// Permission tokens: `status`, `send`, `control`, `ptt`, `config_write`.
+ #[serde(default)]
+ pub(crate) perms: Vec,
+}
+
+/// The full daemon configuration.
+#[derive(Debug, Clone, Deserialize, Serialize)]
+pub(crate) struct Config {
+ /// The radio backend section.
+ pub(crate) radio: RadioConfig,
+ /// Poll cadence.
+ #[serde(default)]
+ pub(crate) poll: PollConfig,
+ /// PTT safety.
+ #[serde(default)]
+ pub(crate) ptt: PttConfig,
+ /// Event/native-push policy.
+ #[serde(default)]
+ pub(crate) events: EventsConfig,
+ /// Serial client endpoints.
+ #[serde(default)]
+ pub(crate) serial_endpoint: Vec,
+ /// Hamlib net endpoints.
+ #[serde(default)]
+ pub(crate) hamlib_net: Vec,
+ /// Optional physical WinKeyer broker.
+ #[serde(default)]
+ pub(crate) winkeyer: Option,
+ /// Virtual WinKeyer client endpoints.
+ #[serde(default)]
+ pub(crate) winkeyer_endpoint: Vec,
+}
+
+/// The table key the daemon's settings live under inside the shared unified `config.toml`.
+const UNIFIED_SECTION: &str = "cat_hub";
+/// Environment override for 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.
+const CONFIG_FILE: &str = "cathub.toml";
+
+impl Config {
+ /// Parse a configuration from a bare TOML string (top-level `[radio]` ... layout).
+ pub(crate) fn parse(text: &str) -> Result {
+ let config: Config = toml::from_str(text)?;
+ config.validate()?;
+ Ok(config)
+ }
+
+ /// Parse a configuration from a TOML document that may be either the unified
+ /// `config.toml` (daemon settings nested under `[cat_hub]`, alongside the engine's and
+ /// launcher's own sections) or a standalone cathub config (top-level `[radio]` ...).
+ ///
+ /// Detection is by presence of a top-level `cat_hub` table: when present, only that
+ /// subtree is used and every other section is ignored; otherwise the whole document is
+ /// parsed as a standalone config for backward compatibility.
+ pub(crate) fn parse_document(text: &str) -> Result {
+ let document: toml::Value = toml::from_str(text)?;
+ if let Some(section) = document.get(UNIFIED_SECTION) {
+ let config: Config = section.clone().try_into()?;
+ config.validate()?;
+ Ok(config)
+ } else {
+ Config::parse(text)
+ }
+ }
+
+ /// Parse a specifically selected top-level configuration section.
+ pub(crate) fn parse_section(text: &str, section_name: &str) -> Result {
+ if section_name != UNIFIED_SECTION {
+ return Err(ConfigError::Invalid(format!(
+ "unsupported configuration section '{section_name}' (expected '{UNIFIED_SECTION}')"
+ )));
+ }
+ let document: toml::Value = toml::from_str(text)?;
+ let section = document.get(section_name).ok_or_else(|| {
+ ConfigError::Invalid(format!(
+ "configuration does not contain a top-level [{section_name}] section"
+ ))
+ })?;
+ let config: Config = section.clone().try_into()?;
+ config.validate()?;
+ Ok(config)
+ }
+
+ /// Load a configuration using an optional explicit top-level section selector.
+ pub(crate) fn load_selected(
+ path: &std::path::Path,
+ section_name: Option<&str>,
+ ) -> Result {
+ let text = std::fs::read_to_string(path)?;
+ section_name.map_or_else(
+ || Config::parse_document(&text),
+ |section| Config::parse_section(&text, section),
+ )
+ }
+
+ /// Serialize the validated effective configuration using the standalone layout.
+ pub(crate) fn to_standalone_toml(&self) -> Result {
+ Ok(toml::to_string_pretty(self)?)
+ }
+
+ /// Validate semantic constraints not captured by the type system.
+ pub(crate) fn validate(&self) -> Result<(), ConfigError> {
+ match self.radio.backend.as_str() {
+ "ts590" | "rigctld" | "loopback" => {}
+ other => {
+ return Err(ConfigError::Invalid(format!(
+ "unknown radio.backend '{other}' (expected ts590, rigctld, or loopback)"
+ )))
+ }
+ }
+ if self.radio.backend != "loopback" {
+ match self.radio.transport.as_str() {
+ "serial" => {
+ if self.radio.port.is_empty() {
+ return Err(ConfigError::Invalid(
+ "radio.transport = \"serial\" requires radio.port".to_string(),
+ ));
+ }
+ }
+ "tcp" => {}
+ other => {
+ return Err(ConfigError::Invalid(format!(
+ "unknown radio.transport '{other}' (expected serial or tcp)"
+ )))
+ }
+ }
+ }
+ for endpoint in &self.serial_endpoint {
+ if endpoint
+ .application_transport
+ .as_deref()
+ .is_some_and(|application| {
+ application
+ .trim()
+ .eq_ignore_ascii_case(endpoint.transport.trim())
+ })
+ {
+ return Err(ConfigError::Invalid(format!(
+ "endpoint '{}' application_transport must differ from transport",
+ endpoint.name
+ )));
+ }
+ if !matches!(
+ endpoint.dialect.as_str(),
+ "ts590" | "ts590-transparent" | "ts2000"
+ ) {
+ return Err(ConfigError::Invalid(format!(
+ "endpoint '{}' has unknown dialect '{}' (expected ts590, ts590-transparent, or ts2000)",
+ endpoint.name, endpoint.dialect
+ )));
+ }
+ if endpoint.dialect == "ts590-transparent" && endpoint.single_vfo {
+ return Err(ConfigError::Invalid(format!(
+ "endpoint '{}' combines dialect 'ts590-transparent' with single_vfo = true; a \
+ transparent mirror endpoint relays the radio's real dual-VFO stream verbatim and \
+ cannot virtualize the operating VFO",
+ endpoint.name
+ )));
+ }
+ }
+ if self.serial_endpoint.is_empty() && self.hamlib_net.is_empty() {
+ return Err(ConfigError::Invalid(
+ "at least one [[serial_endpoint]] or [[hamlib_net]] endpoint is required"
+ .to_string(),
+ ));
+ }
+ self.validate_winkeyer()?;
+ Ok(())
+ }
+
+ fn validate_winkeyer(&self) -> Result<(), ConfigError> {
+ let Some(winkeyer) = &self.winkeyer else {
+ if !self.winkeyer_endpoint.is_empty() {
+ return Err(ConfigError::Invalid(
+ "[[winkeyer_endpoint]] requires a [winkeyer] physical device section"
+ .to_string(),
+ ));
+ }
+ return Ok(());
+ };
+ if winkeyer.port.trim().is_empty() {
+ return Err(ConfigError::Invalid(
+ "winkeyer.port must not be empty".to_string(),
+ ));
+ }
+ if winkeyer.baud != 1_200 {
+ return Err(ConfigError::Invalid(
+ "winkeyer.baud must be 1200; high-baud session switching is not broker-safe"
+ .to_string(),
+ ));
+ }
+ if !(1_000..=300_000).contains(&winkeyer.max_tx_ms) {
+ return Err(ConfigError::Invalid(
+ "winkeyer.max_tx_ms must be between 1000 and 300000".to_string(),
+ ));
+ }
+ let bind: std::net::SocketAddr = winkeyer.api_bind.parse().map_err(|_| {
+ ConfigError::Invalid("winkeyer.api_bind must be a host:port socket address".to_string())
+ })?;
+ if !bind.ip().is_loopback() {
+ return Err(ConfigError::Invalid(
+ "winkeyer.api_bind must use a loopback address".to_string(),
+ ));
+ }
+ if winkeyer.port.eq_ignore_ascii_case(&self.radio.port) {
+ return Err(ConfigError::Invalid(
+ "winkeyer.port must be distinct from radio.port".to_string(),
+ ));
+ }
+ let primary_count = self
+ .winkeyer_endpoint
+ .iter()
+ .filter(|endpoint| endpoint.primary)
+ .count();
+ if primary_count > 1 {
+ return Err(ConfigError::Invalid(
+ "at most one [[winkeyer_endpoint]] may set primary = true".to_string(),
+ ));
+ }
+ let mut transports = std::collections::BTreeSet::new();
+ for endpoint in &self.winkeyer_endpoint {
+ validate_winkeyer_endpoint(endpoint)?;
+ let normalized = endpoint.transport.to_ascii_uppercase();
+ if !transports.insert(normalized) {
+ return Err(ConfigError::Invalid(
+ "winkeyer endpoint transports must be distinct".to_string(),
+ ));
+ }
+ }
+ Ok(())
+ }
+
+ /// The PTT maximum-transmit safety ceiling.
+ pub(crate) fn ptt_max_tx(&self) -> Duration {
+ Duration::from_millis(self.ptt.max_tx_ms)
+ }
+
+ /// The baseline poll interval.
+ pub(crate) fn baseline_interval(&self) -> Duration {
+ Duration::from_millis(self.poll.baseline_ms)
+ }
+
+ /// The heartbeat poll interval.
+ pub(crate) fn heartbeat_interval(&self) -> Duration {
+ Duration::from_millis(self.poll.heartbeat_ms)
+ }
+
+ /// A human-readable multi-line description (used for `--dry-run`).
+ pub(crate) fn describe(&self) -> String {
+ use std::fmt::Write as _;
+ let mut out = String::new();
+ let _ = writeln!(
+ out,
+ "radio: backend={} model={} transport={} port={} baud={} host={} tcp_port={} \
+ certified={} reply_timeout_ms={}",
+ self.radio.backend,
+ self.radio.model,
+ self.radio.transport,
+ self.radio.port,
+ self.radio.baud,
+ self.radio.host,
+ self.radio.tcp_port,
+ self.radio.certified,
+ self.radio.reply_timeout_ms,
+ );
+ let _ = writeln!(
+ out,
+ "poll: baseline_ms={} heartbeat_ms={}",
+ self.poll.baseline_ms, self.poll.heartbeat_ms
+ );
+ let _ = writeln!(out, "ptt: max_tx_ms={}", self.ptt.max_tx_ms);
+ let _ = writeln!(out, "events: native_push={}", self.events.native_push);
+ for endpoint in &self.serial_endpoint {
+ let _ = writeln!(
+ out,
+ "serial_endpoint: name={} transport={} baud={} dialect={} perms={:?} single_vfo={}",
+ endpoint.name,
+ endpoint.transport,
+ endpoint.baud,
+ endpoint.dialect,
+ endpoint.perms,
+ endpoint.single_vfo
+ );
+ }
+ for ep in &self.hamlib_net {
+ let _ = writeln!(
+ out,
+ "hamlib_net: name={} bind={} perms={:?} single_vfo={}",
+ ep.name, ep.bind, ep.perms, ep.single_vfo
+ );
+ }
+ self.append_winkeyer_description(&mut out);
+ if !self.serial_endpoint.is_empty() || !self.hamlib_net.is_empty() {
+ out.push('\n');
+ let _ = writeln!(
+ out,
+ "Client connection guide -- point each application HERE, never at the radio's own \
+ port ({}):",
+ self.radio.port
+ );
+ for endpoint in &self.serial_endpoint {
+ if let Some(application_transport) = endpoint.application_transport.as_deref() {
+ let _ = writeln!(
+ out,
+ " - {name}: hub={hub}, application={application}, {dialect} dialect, {baud} baud.",
+ name = endpoint.name,
+ hub = endpoint.transport,
+ application = application_transport,
+ dialect = endpoint.dialect,
+ baud = endpoint.baud,
+ );
+ } else {
+ let _ = writeln!(
+ out,
+ " - {name}: the hub owns {hub}; application port is not recorded, {dialect} dialect, {baud} baud.",
+ name = endpoint.name,
+ hub = endpoint.transport,
+ dialect = endpoint.dialect,
+ baud = endpoint.baud,
+ );
+ }
+ }
+ for ep in &self.hamlib_net {
+ let _ = writeln!(
+ out,
+ " - {name}: point this application at {bind} as a Hamlib NET (rigctld) device.",
+ name = ep.name,
+ bind = ep.bind,
+ );
+ }
+ }
+ out
+ }
+
+ fn append_winkeyer_description(&self, out: &mut String) {
+ use std::fmt::Write as _;
+ let Some(winkeyer) = &self.winkeyer else {
+ return;
+ };
+ let _ = writeln!(
+ out,
+ "winkeyer: port={} baud={} max_tx_ms={} api_bind={}",
+ winkeyer.port, winkeyer.baud, winkeyer.max_tx_ms, winkeyer.api_bind
+ );
+ for endpoint in &self.winkeyer_endpoint {
+ let _ = writeln!(
+ out,
+ "winkeyer_endpoint: name={} hub_transport={} application_transport={} baud={} primary={} perms={:?}",
+ endpoint.name,
+ endpoint.transport,
+ endpoint.application_transport.as_deref().unwrap_or("(not recorded)"),
+ endpoint.baud,
+ endpoint.primary,
+ endpoint.perms
+ );
+ }
+ }
+
+ /// 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
+ /// `$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.
+ ///
+ /// Daemon settings live under the `[cat_hub]` table of that file (see
+ /// [`Config::parse_document`]); a standalone `--config cathub.toml` is still accepted.
+ pub(crate) fn default_config_path() -> PathBuf {
+ if let Some(path) = std::env::var_os(CONFIG_PATH_ENV) {
+ return PathBuf::from(path);
+ }
+ 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") {
+ return PathBuf::from(app_data).join(CONFIG_DIR).join(CONFIG_FILE);
+ }
+ }
+ #[cfg(not(target_os = "windows"))]
+ {
+ if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") {
+ return PathBuf::from(xdg).join(CONFIG_DIR).join(CONFIG_FILE);
+ }
+ if let Some(home) = std::env::var_os("HOME") {
+ return PathBuf::from(home)
+ .join(".config")
+ .join(CONFIG_DIR)
+ .join(CONFIG_FILE);
+ }
+ }
+ PathBuf::from(CONFIG_FILE)
+ }
+}
+
+/// Extract a unified `[cat_hub]` section into a standalone CatHub file.
+pub(crate) fn migrate_to_standalone(
+ source: &std::path::Path,
+ destination: &std::path::Path,
+ force: bool,
+ remove_source_section: bool,
+) -> Result<(), ConfigError> {
+ let source_text = std::fs::read_to_string(source)?;
+ let config = Config::parse_document(&source_text)?;
+ let document: toml::Value = toml::from_str(&source_text)?;
+ if document.get(UNIFIED_SECTION).is_none() {
+ return Err(ConfigError::Invalid(
+ "migration source does not contain a top-level [cat_hub] section".to_string(),
+ ));
+ }
+ if destination.exists() && !force {
+ return Err(ConfigError::Invalid(format!(
+ "migration destination already exists: {}",
+ destination.display()
+ )));
+ }
+ if remove_source_section && backup_path(source).exists() {
+ return Err(ConfigError::Invalid(format!(
+ "source backup already exists: {}",
+ backup_path(source).display()
+ )));
+ }
+
+ let standalone = config.to_standalone_toml()?;
+ atomic_write(destination, standalone.as_bytes(), force)?;
+
+ if remove_source_section {
+ let mut editable = source_text
+ .parse::()
+ .map_err(|error| ConfigError::Invalid(format!("editing migration source: {error}")))?;
+ editable.remove(UNIFIED_SECTION);
+ let backup = backup_path(source);
+ std::fs::copy(source, &backup)?;
+ atomic_write(source, editable.to_string().as_bytes(), true)?;
+ }
+ Ok(())
+}
+
+fn atomic_write(path: &std::path::Path, contents: &[u8], replace: bool) -> Result<(), ConfigError> {
+ if let Some(parent) = path.parent() {
+ std::fs::create_dir_all(parent)?;
+ }
+ let temporary = path.with_extension(format!(
+ "{}.tmp",
+ path.extension()
+ .and_then(|value| value.to_str())
+ .unwrap_or("toml")
+ ));
+ std::fs::write(&temporary, contents)?;
+ if replace && path.exists() {
+ let backup = backup_path(path);
+ if !backup.exists() {
+ std::fs::copy(path, backup)?;
+ }
+ std::fs::remove_file(path)?;
+ }
+ match std::fs::rename(&temporary, path) {
+ Ok(()) => Ok(()),
+ Err(error) => {
+ let _ = std::fs::remove_file(&temporary);
+ Err(ConfigError::Io(error))
+ }
+ }
+}
+
+fn backup_path(path: &std::path::Path) -> PathBuf {
+ let mut value = path.as_os_str().to_os_string();
+ value.push(".bak");
+ PathBuf::from(value)
+}
+
+fn validate_winkeyer_endpoint(endpoint: &WinkeyerEndpointConfig) -> Result<(), ConfigError> {
+ if endpoint.transport.trim().is_empty() {
+ return Err(ConfigError::Invalid(format!(
+ "winkeyer endpoint '{}' requires transport",
+ endpoint.name
+ )));
+ }
+ if endpoint.baud != 1_200 {
+ return Err(ConfigError::Invalid(format!(
+ "winkeyer endpoint '{}' baud must be 1200",
+ endpoint.name
+ )));
+ }
+ if endpoint
+ .application_transport
+ .as_deref()
+ .is_some_and(|application| {
+ application
+ .trim()
+ .eq_ignore_ascii_case(endpoint.transport.trim())
+ })
+ {
+ return Err(ConfigError::Invalid(format!(
+ "winkeyer endpoint '{}' application_transport must differ from transport",
+ endpoint.name
+ )));
+ }
+ for permission in &endpoint.perms {
+ if !matches!(
+ permission.as_str(),
+ "status" | "send" | "control" | "ptt" | "config_write"
+ ) {
+ return Err(ConfigError::Invalid(format!(
+ "winkeyer endpoint '{}' has unknown permission '{}'",
+ endpoint.name, permission
+ )));
+ }
+ }
+ let has = |permission: &str| endpoint.perms.iter().any(|value| value == permission);
+ if has("send") && !has("status") {
+ return Err(ConfigError::Invalid(format!(
+ "winkeyer endpoint '{}' permission 'send' requires 'status'",
+ endpoint.name
+ )));
+ }
+ if has("ptt") && (!has("send") || !has("control")) {
+ return Err(ConfigError::Invalid(format!(
+ "winkeyer endpoint '{}' permission 'ptt' requires 'send' and 'control'",
+ endpoint.name
+ )));
+ }
+ if has("config_write") && (!has("status") || !has("control")) {
+ return Err(ConfigError::Invalid(format!(
+ "winkeyer endpoint '{}' permission 'config_write' requires 'status' and 'control'",
+ endpoint.name
+ )));
+ }
+ Ok(())
+}
+
+#[cfg(test)]
+#[allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)]
+mod tests {
+ use super::*;
+
+ const SAMPLE: &str = r#"
+[radio]
+backend = "ts590"
+model = "TS-590SG"
+transport = "serial"
+port = "COM3"
+baud = 4800
+
+[poll]
+baseline_ms = 200
+heartbeat_ms = 2500
+
+[ptt]
+max_tx_ms = 120000
+
+[events]
+native_push = true
+
+[[serial_endpoint]]
+name = "n1mm"
+transport = "COM20"
+application_transport = "COM21"
+baud = 4800
+dialect = "ts590"
+perms = ["read", "write", "ptt"]
+single_vfo = true
+
+[[hamlib_net]]
+name = "engine"
+bind = "127.0.0.1:4532"
+perms = ["read"]
+"#;
+
+ #[test]
+ fn parses_full_config() {
+ let config = Config::parse(SAMPLE).expect("parse");
+ assert_eq!(config.radio.backend, "ts590");
+ assert_eq!(config.radio.port, "COM3");
+ assert_eq!(config.serial_endpoint.len(), 1);
+ assert_eq!(config.hamlib_net.len(), 1);
+ assert!(config.serial_endpoint[0].permissions().ptt);
+ assert!(
+ config.serial_endpoint[0].single_vfo,
+ "single_vfo parses as true"
+ );
+ assert!(config.hamlib_net[0].permissions().read);
+ assert!(!config.hamlib_net[0].permissions().write);
+ assert_eq!(config.baseline_interval(), Duration::from_millis(200));
+ assert_eq!(config.heartbeat_interval(), Duration::from_millis(2_500));
+ assert_eq!(config.ptt_max_tx(), Duration::from_secs(120));
+ }
+
+ #[test]
+ fn applies_defaults() {
+ let text = r#"
+[radio]
+backend = "loopback"
+
+[[serial_endpoint]]
+name = "x"
+transport = "COM5"
+dialect = "ts590"
+"#;
+ let config = Config::parse(text).expect("parse");
+ assert_eq!(config.radio.baud, 4_800);
+ assert_eq!(config.poll.baseline_ms, 250);
+ assert_eq!(config.ptt.max_tx_ms, 300_000);
+ assert!(config.events.native_push);
+ assert_eq!(config.serial_endpoint[0].baud, 4_800);
+ assert!(
+ !config.serial_endpoint[0].single_vfo,
+ "single_vfo defaults to false (native dual-VFO presentation)"
+ );
+ }
+
+ #[test]
+ fn rejects_unknown_backend() {
+ let text = r#"
+[radio]
+backend = "icom"
+[[serial_endpoint]]
+name = "x"
+transport = "COM5"
+dialect = "ts590"
+"#;
+ assert!(Config::parse(text).is_err());
+ }
+
+ #[test]
+ fn serial_backend_requires_port() {
+ let text = r#"
+[radio]
+backend = "ts590"
+transport = "serial"
+[[serial_endpoint]]
+name = "x"
+transport = "COM5"
+dialect = "ts590"
+"#;
+ let err = Config::parse(text).expect_err("missing port");
+ assert!(err.to_string().contains("requires radio.port"));
+ }
+
+ #[test]
+ fn rejects_unknown_dialect() {
+ let text = r#"
+[radio]
+backend = "loopback"
+[[serial_endpoint]]
+name = "x"
+transport = "COM5"
+dialect = "yaesu"
+"#;
+ assert!(Config::parse(text).is_err());
+ }
+
+ #[test]
+ fn requires_at_least_one_endpoint() {
+ let text = r#"
+[radio]
+backend = "loopback"
+"#;
+ assert!(Config::parse(text).is_err());
+ }
+
+ #[test]
+ fn describe_mentions_all_sections() {
+ let config = Config::parse(SAMPLE).expect("parse");
+ let text = config.describe();
+ assert!(text.contains("radio: backend=ts590"));
+ assert!(text.contains("reply_timeout_ms=1000"));
+ assert!(text.contains("poll: baseline_ms=200"));
+ assert!(text.contains("ptt: max_tx_ms=120000"));
+ assert!(text.contains("events: native_push=true"));
+ assert!(text.contains("endpoint: name=n1mm"));
+ assert!(text.contains("hamlib_net: name=engine"));
+ assert!(text.contains("Client connection guide"));
+ assert!(text.contains("hub=COM20, application=COM21"));
+ }
+
+ #[test]
+ 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()
+ {
+ assert!(
+ text.ends_with(CONFIG_FILE),
+ "expected {CONFIG_FILE}, got {text}"
+ );
+ assert!(
+ text.contains(CONFIG_DIR) || text == CONFIG_FILE,
+ "expected CatHub config dir or bare fallback, got {text}"
+ );
+ }
+ }
+
+ #[test]
+ fn migration_extracts_unified_section_without_modifying_source() {
+ let root = std::env::temp_dir().join(format!("cathub-migrate-{}", std::process::id()));
+ let source = root.join("config.toml");
+ let destination = root.join("cathub.toml");
+ let _ = std::fs::create_dir_all(&root);
+ let original = "[launcher]\nselected = [\"rust-engine\"]\n\n[cat_hub.radio]\nbackend = \"loopback\"\n\n[[cat_hub.hamlib_net]]\nname = \"engine\"\nbind = \"127.0.0.1:4532\"\nperms = [\"read\"]\n";
+ std::fs::write(&source, original).expect("write source");
+
+ migrate_to_standalone(&source, &destination, false, false).expect("migrate");
+
+ assert_eq!(
+ std::fs::read_to_string(&source).expect("read source"),
+ original
+ );
+ let migrated = std::fs::read_to_string(&destination).expect("read destination");
+ let config = Config::parse(&migrated).expect("parse migrated");
+ assert_eq!(config.radio.backend, "loopback");
+ assert_eq!(config.hamlib_net.len(), 1);
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn migration_refuses_to_overwrite_destination() {
+ let root =
+ std::env::temp_dir().join(format!("cathub-migrate-existing-{}", std::process::id()));
+ let source = root.join("config.toml");
+ let destination = root.join("cathub.toml");
+ let _ = std::fs::create_dir_all(&root);
+ std::fs::write(
+ &source,
+ "[cat_hub.radio]\nbackend = \"loopback\"\n[[cat_hub.hamlib_net]]\nname = \"engine\"\nbind = \"127.0.0.1:4532\"\n",
+ )
+ .expect("write source");
+ std::fs::write(&destination, "keep").expect("write destination");
+
+ assert!(migrate_to_standalone(&source, &destination, false, false).is_err());
+ assert_eq!(
+ std::fs::read_to_string(&destination).expect("read destination"),
+ "keep"
+ );
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn parses_unified_cat_hub_section() {
+ // A unified config.toml carrying unrelated engine/launcher tables plus a [cat_hub]
+ // subtree must load only the cat_hub subtree and ignore everything else.
+ let text = r#"
+[station_profile]
+callsign = "K7ABC"
+
+[launcher]
+selected = ["engine-rust"]
+
+[cat_hub.radio]
+backend = "ts590"
+transport = "serial"
+port = "COM4"
+baud = 4800
+
+[[cat_hub.serial_endpoint]]
+name = "n1mm"
+transport = "COM20"
+application_transport = "COM21"
+dialect = "ts590"
+perms = ["read", "write", "ptt"]
+
+[[cat_hub.hamlib_net]]
+name = "engine"
+bind = "127.0.0.1:4532"
+perms = ["read"]
+"#;
+ let config = Config::parse_document(text).expect("parse unified");
+ assert_eq!(config.radio.backend, "ts590");
+ assert_eq!(config.radio.port, "COM4");
+ assert_eq!(config.serial_endpoint.len(), 1);
+ assert_eq!(
+ config.serial_endpoint[0].application_transport.as_deref(),
+ Some("COM21")
+ );
+ assert_eq!(config.hamlib_net.len(), 1);
+ assert!(config.serial_endpoint[0].permissions().ptt);
+ }
+
+ #[test]
+ fn parse_document_falls_back_to_standalone() {
+ // A standalone config without a [cat_hub] table still parses for back-compat.
+ let config = Config::parse_document(SAMPLE).expect("parse standalone");
+ assert_eq!(config.radio.backend, "ts590");
+ assert_eq!(config.radio.port, "COM3");
+ assert_eq!(config.serial_endpoint.len(), 1);
+ }
+
+ #[test]
+ fn explicit_section_requires_and_parses_cat_hub() {
+ let embedded = "[cat_hub.radio]\nbackend = \"loopback\"\n\
+ [[cat_hub.hamlib_net]]\nname = \"engine\"\nbind = \"127.0.0.1:4532\"\n";
+ assert!(Config::parse_section(embedded, "cat_hub").is_ok());
+ assert!(Config::parse_section(SAMPLE, "cat_hub").is_err());
+ assert!(Config::parse_section(embedded, "other").is_err());
+ }
+
+ #[test]
+ fn parse_document_validates_cat_hub_section() {
+ // Validation still applies to the nested subtree (no endpoints is invalid).
+ let text = r#"
+[cat_hub.radio]
+backend = "loopback"
+"#;
+ assert!(Config::parse_document(text).is_err());
+ }
+
+ #[test]
+ fn parses_and_describes_winkeyer_broker() {
+ let text = r#"
+[radio]
+backend = "loopback"
+
+[[hamlib_net]]
+name = "engine"
+bind = "127.0.0.1:4532"
+
+[winkeyer]
+port = "COM3"
+max_tx_ms = 30000
+api_bind = "127.0.0.1:50071"
+
+[[winkeyer_endpoint]]
+name = "n1mm"
+transport = "COM40"
+application_transport = "COM41"
+primary = true
+perms = ["status", "send", "control", "ptt"]
+"#;
+ let config = Config::parse(text).expect("parse keyer");
+ let winkeyer = config.winkeyer.as_ref().expect("winkeyer");
+ assert_eq!(winkeyer.port, "COM3");
+ assert_eq!(winkeyer.baud, 1_200);
+ assert_eq!(config.winkeyer_endpoint.len(), 1);
+ assert!(config.winkeyer_endpoint[0].primary);
+ assert_eq!(
+ config.winkeyer_endpoint[0].application_transport.as_deref(),
+ Some("COM41")
+ );
+ let description = config.describe();
+ assert!(description.contains("winkeyer: port=COM3"));
+ assert!(description.contains("winkeyer_endpoint: name=n1mm"));
+ assert!(description.contains("application_transport=COM41"));
+ }
+
+ #[test]
+ fn rejects_matching_hub_and_application_transports() {
+ let serial = SAMPLE.replace(
+ "application_transport = \"COM21\"",
+ "application_transport = \"com20\"",
+ );
+ assert!(Config::parse(&serial)
+ .expect_err("matching serial pair")
+ .to_string()
+ .contains("application_transport must differ"));
+
+ let winkeyer = r#"
+[radio]
+backend = "loopback"
+[[hamlib_net]]
+name = "engine"
+bind = "127.0.0.1:4532"
+[winkeyer]
+port = "COM3"
+[[winkeyer_endpoint]]
+name = "wktools"
+transport = "COM42"
+application_transport = "com42"
+"#;
+ assert!(Config::parse(winkeyer)
+ .expect_err("matching WinKeyer pair")
+ .to_string()
+ .contains("application_transport must differ"));
+ }
+
+ #[test]
+ fn rejects_non_loopback_winkeyer_api_and_duplicate_primary_endpoints() {
+ let non_loopback = r#"
+[radio]
+backend = "loopback"
+[[hamlib_net]]
+name = "engine"
+bind = "127.0.0.1:4532"
+[winkeyer]
+port = "COM3"
+api_bind = "0.0.0.0:50071"
+"#;
+ assert!(Config::parse(non_loopback)
+ .expect_err("non-loopback")
+ .to_string()
+ .contains("loopback"));
+
+ let duplicate_primary = r#"
+[radio]
+backend = "loopback"
+[[hamlib_net]]
+name = "engine"
+bind = "127.0.0.1:4532"
+[winkeyer]
+port = "COM3"
+[[winkeyer_endpoint]]
+name = "one"
+transport = "COM40"
+primary = true
+[[winkeyer_endpoint]]
+name = "two"
+transport = "COM42"
+primary = true
+"#;
+ assert!(Config::parse(duplicate_primary)
+ .expect_err("primary")
+ .to_string()
+ .contains("at most one"));
+ }
+
+ #[test]
+ fn rejects_keyer_port_collision_and_endpoints_without_device() {
+ let collision = r#"
+[radio]
+backend = "ts590"
+port = "COM3"
+[[hamlib_net]]
+name = "engine"
+bind = "127.0.0.1:4532"
+[winkeyer]
+port = "com3"
+"#;
+ assert!(Config::parse(collision)
+ .expect_err("collision")
+ .to_string()
+ .contains("distinct"));
+
+ let orphan = r#"
+[radio]
+backend = "loopback"
+[[hamlib_net]]
+name = "engine"
+bind = "127.0.0.1:4532"
+[[winkeyer_endpoint]]
+name = "n1mm"
+transport = "COM40"
+"#;
+ assert!(Config::parse(orphan)
+ .expect_err("orphan")
+ .to_string()
+ .contains("requires a [winkeyer]"));
+ }
+}
diff --git a/crates/cathub/src/dialect/kenwood/mod.rs b/crates/cathub/src/dialect/kenwood/mod.rs
new file mode 100644
index 0000000..aaec049
--- /dev/null
+++ b/crates/cathub/src/dialect/kenwood/mod.rs
@@ -0,0 +1,97 @@
+//! Kenwood-family client dialects and shared wire helpers.
+//!
+//! [`Ts590Dialect`](ts590::Ts590Dialect) is the native pass-through dialect (N1MM, ARCP-590,
+//! Log4OM-as-TS590). [`Ts2000Dialect`](ts2000::Ts2000Dialect) is the OmniRig/HDSDR translator.
+//! Both share the small frame helpers below.
+
+pub(crate) mod transparent;
+pub(crate) mod ts2000;
+pub(crate) mod ts590;
+
+use crate::model::Mode;
+
+/// The Kenwood error reply.
+pub(crate) const ERR: &[u8] = b"?;";
+
+/// Split a `;`-terminated command frame into its leading alphabetic verb and payload.
+///
+/// `b"FA00007050000;"` becomes `(b"FA", b"00007050000")`; `b"TX;"` becomes `(b"TX", b"")`.
+/// Returns `None` when there is no alphabetic verb.
+pub(crate) fn parse_command(frame: &[u8]) -> Option<(Vec, Vec)> {
+ let body = match frame.iter().position(|&b| b == b';') {
+ Some(end) => frame.get(..end)?,
+ None => frame,
+ };
+ let verb_len = body
+ .iter()
+ .position(|b| !b.is_ascii_alphabetic())
+ .unwrap_or(body.len());
+ if verb_len == 0 {
+ return None;
+ }
+ let verb = body.get(..verb_len)?.to_vec();
+ let payload = body.get(verb_len..).unwrap_or(&[]).to_vec();
+ Some((verb, payload))
+}
+
+/// The Kenwood `MD` mode digit (ASCII byte) for a mode.
+pub(crate) fn mode_to_digit(mode: Mode) -> u8 {
+ mode.to_kenwood_digit()
+}
+
+/// The mode for a Kenwood `MD` mode digit (ASCII byte).
+pub(crate) fn mode_from_digit(digit: u8) -> Mode {
+ Mode::from_kenwood_digit(digit)
+}
+
+/// Build a Kenwood `AI` auto-information status frame (`AI0;` or `AI2;`).
+///
+/// `AI;` is a *read* on Kenwood radios: a native client (ARCP-590, N1MM) queries the
+/// current auto-information mode during connection and waits for a valid `AI;` answer
+/// before it proceeds. The reply must report the endpoint's current virtualized state without
+/// changing it; only an `AI;` *write* toggles auto-information.
+pub(crate) fn ai_frame(on: bool) -> Vec {
+ vec![b'A', b'I', if on { b'2' } else { b'0' }, b';']
+}
+
+/// Build a Kenwood frequency frame: `verb` + 11 zero-padded digits + `;`.
+pub(crate) fn freq_frame(verb: &[u8], hz: u64) -> Vec {
+ let mut out = Vec::with_capacity(verb.len() + 12);
+ out.extend_from_slice(verb);
+ out.extend_from_slice(format!("{hz:011}").as_bytes());
+ out.push(b';');
+ out
+}
+
+#[cfg(test)]
+#[allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parse_command_splits_verb_and_payload() {
+ assert_eq!(
+ parse_command(b"FA00007050000;"),
+ Some((b"FA".to_vec(), b"00007050000".to_vec()))
+ );
+ assert_eq!(parse_command(b"TX;"), Some((b"TX".to_vec(), b"".to_vec())));
+ assert_eq!(
+ parse_command(b"AI2;"),
+ Some((b"AI".to_vec(), b"2".to_vec()))
+ );
+ assert_eq!(parse_command(b"FA;"), Some((b"FA".to_vec(), b"".to_vec())));
+ assert_eq!(parse_command(b";"), None);
+ }
+
+ #[test]
+ fn freq_frame_is_eleven_digits() {
+ assert_eq!(freq_frame(b"FA", 7_050_000), b"FA00007050000;".to_vec());
+ assert_eq!(freq_frame(b"FB", 0), b"FB00000000000;".to_vec());
+ }
+
+ #[test]
+ fn mode_digit_helpers_round_trip() {
+ assert_eq!(mode_from_digit(mode_to_digit(Mode::Cw)), Mode::Cw);
+ assert_eq!(mode_to_digit(Mode::Usb), b'2');
+ }
+}
diff --git a/crates/cathub/src/dialect/kenwood/transparent.rs b/crates/cathub/src/dialect/kenwood/transparent.rs
new file mode 100644
index 0000000..460dc88
--- /dev/null
+++ b/crates/cathub/src/dialect/kenwood/transparent.rs
@@ -0,0 +1,321 @@
+//! The transparent mirror dialect for native TS-590 controllers (notably ARCP-590).
+//!
+//! Unlike [`Ts590Dialect`](super::ts590::Ts590Dialect), which virtualizes the radio behind a
+//! modeled cache, a transparent endpoint behaves as if it were wired directly to the rig: every
+//! request except PTT and auto-information is forwarded to the radio verbatim, and the
+//! radio's entire CAT stream is relayed back verbatim. The rig runs in AI2, so it echoes
+//! every change any client makes through the hub - which is exactly what keeps a transparent
+//! controller perfectly in sync. This removes the whole class of synthesis/drift bugs (stale
+//! A/B, frozen frequency) for a client that already speaks the radio's exact protocol.
+//!
+//! The hub still owns the single physical port, so a few things stay hub-mediated:
+//! * PTT (`TX`/`RX`) routes through the shared lease so a mirror client can never key the
+//! transmitter while another endpoint owns it (design §8.5).
+//! * Auto-information (`AI`) is virtualized per endpoint; the radio itself stays in AI2, and the
+//! hub gates this endpoint's fan-out on its own `AI` flag.
+//! * Identity/keepalive reads (`ID`/`PS`) are answered locally so a mirror client's steady
+//! heartbeat never generates radio traffic.
+
+use async_trait::async_trait;
+
+use super::ts590::snapshot_resync_frames;
+use super::{ai_frame, parse_command, ERR};
+use crate::dialect::{ApplyOutcome, ClientDialect, ClientSessionContext};
+use crate::model::{PttSource, StateChange, StateMutation};
+use crate::permissions::CommandClass;
+use crate::state::Snapshot;
+
+/// The transparent TS-590 mirror dialect.
+#[derive(Clone, Default)]
+pub(crate) struct TransparentTs590Dialect;
+
+impl TransparentTs590Dialect {
+ /// Create the dialect.
+ pub(crate) fn new() -> Self {
+ TransparentTs590Dialect
+ }
+}
+
+#[async_trait]
+impl ClientDialect for TransparentTs590Dialect {
+ async fn handle(&self, request: &[u8], ctx: &ClientSessionContext) -> Vec {
+ let Some((verb, payload)) = parse_command(request) else {
+ return ERR.to_vec();
+ };
+ let read = payload.is_empty();
+ match verb.as_slice() {
+ // PTT stays hub-arbitrated even on a mirror endpoint: a transparent client must not be
+ // able to key the rig while another endpoint owns the transmitter.
+ b"TX" => reply(
+ ctx.apply_modeled(
+ StateMutation::SetPtt {
+ keyed: true,
+ source: PttSource::Generic,
+ },
+ CommandClass::PttWrite,
+ )
+ .await,
+ ),
+ b"RX" => reply(
+ ctx.apply_modeled(
+ StateMutation::SetPtt {
+ keyed: false,
+ source: PttSource::Generic,
+ },
+ CommandClass::PttWrite,
+ )
+ .await,
+ ),
+ // Auto-information is virtualized per endpoint: the radio is already in AI2 (hub-owned
+ // native push), so a mirror client toggling AI only flips its own fan-out flag.
+ b"AI" => {
+ if read {
+ ai_frame(ctx.ai_on())
+ } else {
+ ctx.set_ai(payload.first().is_some_and(|&d| d != b'0'));
+ Vec::new()
+ }
+ }
+ // Identity/keepalive reads are answered locally so a mirror client's heartbeat
+ // never hits the radio. (ARCP-590 polls `PS;`/`AI;` continuously when idle.)
+ b"ID" if read => b"ID021;".to_vec(),
+ b"PS" if read => b"PS1;".to_vec(),
+ // Everything else is forwarded to the radio verbatim: a transparent endpoint behaves
+ // as if wired directly to the rig, so reads and writes alike reach the real radio.
+ _ => {
+ let class = if read {
+ CommandClass::PassthroughRead
+ } else {
+ CommandClass::ConfigWrite
+ };
+ ctx.passthrough(request, class).await
+ }
+ }
+ }
+
+ fn format_notification(
+ &self,
+ _change: &StateChange,
+ _ctx: &ClientSessionContext,
+ ) -> Option> {
+ // A transparent endpoint is driven entirely by the radio's verbatim CAT stream
+ // (`format_native_passthrough` / `format_passthrough`); it never consumes a synthesized
+ // modeled notification, which is precisely what kept drifting out of sync.
+ None
+ }
+
+ fn format_passthrough(&self, raw: &[u8], ctx: &ClientSessionContext) -> Option> {
+ if ctx.ai_on() {
+ Some(raw.to_vec())
+ } else {
+ None
+ }
+ }
+
+ fn format_native_passthrough(&self, raw: &[u8], ctx: &ClientSessionContext) -> Option> {
+ if ctx.ai_on() {
+ Some(raw.to_vec())
+ } else {
+ None
+ }
+ }
+
+ fn resync(&self, snapshot: &Snapshot, ctx: &ClientSessionContext) -> Vec> {
+ // A lagged mirror endpoint never received the raw frames it missed and ignores synthesized
+ // notifications, so re-present the current radio state as a full set of raw frames.
+ if ctx.ai_on() {
+ snapshot_resync_frames(snapshot)
+ } else {
+ Vec::new()
+ }
+ }
+}
+
+fn reply(outcome: ApplyOutcome) -> Vec {
+ match outcome {
+ ApplyOutcome::Ok => Vec::new(),
+ _ => ERR.to_vec(),
+ }
+}
+
+#[cfg(test)]
+#[allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)]
+mod tests {
+ use super::*;
+ use crate::backend::loopback::LoopbackBackend;
+ use crate::backend::RadioBackend;
+ use crate::model::{RadioEventSource, StateChange, Vfo};
+ use crate::permissions::EndpointPermissions;
+ use crate::ptt::PttManager;
+ use crate::radio::{detached_link, spawn_scheduler};
+ use crate::state::StateHandle;
+ use std::sync::Arc;
+ use std::time::Duration;
+
+ fn ctx_with(
+ perms: EndpointPermissions,
+ ) -> (
+ ClientSessionContext,
+ LoopbackBackend,
+ StateHandle,
+ PttManager,
+ ) {
+ let backend = LoopbackBackend::new();
+ let caps = backend.capabilities();
+ let arc: Arc = Arc::new(backend.clone());
+ let state = StateHandle::new();
+ let radio = spawn_scheduler(arc, detached_link(), state.clone());
+ let ptt = PttManager::new(Duration::from_secs(300));
+ (
+ ClientSessionContext::new(1, perms, state.clone(), radio, ptt.clone(), caps),
+ backend,
+ state,
+ ptt,
+ )
+ }
+
+ #[tokio::test]
+ async fn forwards_a_vfo_select_to_the_radio_verbatim() {
+ // The core bug fix: an A/B (`FR1;`) from a mirror client must reach the radio raw, not
+ // be swallowed by virtualization.
+ let (ctx, backend, _state, _ptt) = ctx_with(EndpointPermissions::from_tokens(&[
+ "read",
+ "write",
+ "config_write",
+ ]));
+ let reply = TransparentTs590Dialect::new().handle(b"FR1;", &ctx).await;
+ // Loopback echoes the passthrough; production fire-and-forget writes return empty.
+ assert_eq!(reply, b"FR1;");
+ assert_eq!(backend.passthroughs(), vec![b"FR1;".to_vec()]);
+ }
+
+ #[tokio::test]
+ async fn forwards_a_frequency_read_to_the_radio() {
+ let (ctx, backend, _state, _ptt) = ctx_with(EndpointPermissions::from_tokens(&[
+ "read",
+ "write",
+ "config_write",
+ ]));
+ let _ = TransparentTs590Dialect::new().handle(b"FA;", &ctx).await;
+ assert_eq!(backend.passthroughs(), vec![b"FA;".to_vec()]);
+ }
+
+ #[tokio::test]
+ async fn keys_and_unkeys_through_the_ptt_lease() {
+ let (ctx, _backend, _state, ptt) =
+ ctx_with(EndpointPermissions::from_tokens(&["read", "ptt"]));
+ let dialect = TransparentTs590Dialect::new();
+ assert!(dialect.handle(b"TX;", &ctx).await.is_empty());
+ assert_eq!(ptt.owner(), Some(1), "TX must take the shared PTT lease");
+ assert!(dialect.handle(b"RX;", &ctx).await.is_empty());
+ assert_eq!(ptt.owner(), None, "RX must release the shared PTT lease");
+ }
+
+ #[tokio::test]
+ async fn ptt_command_never_reaches_the_radio_as_a_raw_write() {
+ // TX/RX must be modeled (lease-arbitrated), not passed through as raw bytes.
+ let (ctx, backend, _state, _ptt) =
+ ctx_with(EndpointPermissions::from_tokens(&["read", "ptt"]));
+ let _ = TransparentTs590Dialect::new().handle(b"TX;", &ctx).await;
+ assert!(
+ backend.passthroughs().is_empty(),
+ "PTT must not be forwarded as a raw passthrough"
+ );
+ }
+
+ #[tokio::test]
+ async fn keepalive_reads_are_answered_locally() {
+ let (ctx, backend, _state, _ptt) = ctx_with(EndpointPermissions::read_only());
+ let dialect = TransparentTs590Dialect::new();
+ assert_eq!(dialect.handle(b"ID;", &ctx).await, b"ID021;");
+ assert_eq!(dialect.handle(b"PS;", &ctx).await, b"PS1;");
+ assert!(
+ backend.passthroughs().is_empty(),
+ "ID/PS keepalives must not generate radio traffic"
+ );
+ }
+
+ #[tokio::test]
+ async fn auto_information_is_virtualized_per_session() {
+ let (ctx, backend, _state, _ptt) = ctx_with(EndpointPermissions::read_only());
+ let dialect = TransparentTs590Dialect::new();
+ assert_eq!(dialect.handle(b"AI;", &ctx).await, b"AI0;");
+ assert!(dialect.handle(b"AI2;", &ctx).await.is_empty());
+ assert!(ctx.ai_on());
+ assert_eq!(dialect.handle(b"AI;", &ctx).await, b"AI2;");
+ assert!(
+ backend.passthroughs().is_empty(),
+ "AI must never reach the radio; the rig stays in hub-owned AI2"
+ );
+ }
+
+ #[tokio::test]
+ async fn relays_modeled_and_unmodeled_frames_verbatim_when_auto_info_on() {
+ let (ctx, _backend, _state, _ptt) = ctx_with(EndpointPermissions::read_only());
+ let dialect = TransparentTs590Dialect::new();
+ // AI off: suppress everything.
+ assert_eq!(dialect.format_native_passthrough(b"FR1;", &ctx), None);
+ assert_eq!(dialect.format_passthrough(b"NB1;", &ctx), None);
+ ctx.set_ai(true);
+ // AI on: relay both the modeled CAT echo and the unmodeled frame byte-for-byte.
+ assert_eq!(
+ dialect.format_native_passthrough(b"FA00014035000;", &ctx),
+ Some(b"FA00014035000;".to_vec())
+ );
+ assert_eq!(
+ dialect.format_passthrough(b"NB1;", &ctx),
+ Some(b"NB1;".to_vec())
+ );
+ }
+
+ #[tokio::test]
+ async fn never_synthesizes_a_modeled_notification() {
+ let (ctx, _backend, _state, _ptt) = ctx_with(EndpointPermissions::read_only());
+ ctx.set_ai(true);
+ let dialect = TransparentTs590Dialect::new();
+ assert_eq!(
+ dialect.format_notification(
+ &StateChange::Freq {
+ vfo: Vfo::A,
+ hz: 14_035_000,
+ },
+ &ctx
+ ),
+ None,
+ "a transparent endpoint must never emit a synthesized notification"
+ );
+ }
+
+ #[tokio::test]
+ async fn resync_re_presents_full_state_after_a_lag() {
+ let (ctx, _backend, state, _ptt) = ctx_with(EndpointPermissions::read_only());
+ ctx.set_ai(true);
+ state.record(
+ StateChange::Freq {
+ vfo: Vfo::A,
+ hz: 14_035_000,
+ },
+ RadioEventSource::PollDiff,
+ );
+ let frames = TransparentTs590Dialect::new().resync(&state.snapshot(), &ctx);
+ assert!(
+ frames.iter().any(|f| f == b"FA00014035000;"),
+ "re-sync must include the current VFO A frequency, got {frames:?}"
+ );
+ assert!(
+ frames.iter().any(|f| f.starts_with(b"IF")),
+ "re-sync must include the operating-status IF frame"
+ );
+ }
+
+ #[tokio::test]
+ async fn resync_is_empty_when_auto_info_off() {
+ let (ctx, _backend, state, _ptt) = ctx_with(EndpointPermissions::read_only());
+ assert!(
+ TransparentTs590Dialect::new()
+ .resync(&state.snapshot(), &ctx)
+ .is_empty(),
+ "an AI-off mirror endpoint must emit nothing on re-sync"
+ );
+ }
+}
diff --git a/crates/cathub/src/dialect/kenwood/ts2000.rs b/crates/cathub/src/dialect/kenwood/ts2000.rs
new file mode 100644
index 0000000..4da5ad5
--- /dev/null
+++ b/crates/cathub/src/dialect/kenwood/ts2000.rs
@@ -0,0 +1,501 @@
+//! Foreign-dialect TS-2000 client dialect for OmniRig / HDSDR (and Log4OM via OmniRig).
+//!
+//! This is a universal-tier *translator*: it answers `IF;`, `FA;`, `FB;`, and `MD;` from
+//! the universal state and **rejects Hamlib-style VFO-target writes** (`FR`/`FT`), which
+//! were the source of the TS-590 VFO A/B oscillation. It carries no native passthrough.
+
+use async_trait::async_trait;
+
+use super::{ai_frame, freq_frame, mode_from_digit, mode_to_digit, parse_command, ERR};
+use crate::dialect::{ApplyOutcome, ClientDialect, ClientSessionContext};
+use crate::model::{StateChange, StateMutation, Vfo};
+use crate::permissions::CommandClass;
+use crate::state::Snapshot;
+
+/// The TS-2000 translator dialect.
+#[derive(Clone, Default)]
+pub(crate) struct Ts2000Dialect;
+
+impl Ts2000Dialect {
+ /// Create the dialect.
+ pub(crate) fn new() -> Self {
+ Ts2000Dialect
+ }
+}
+
+/// Synthesize a TS-2000 `IF;` status response from the universal state.
+///
+/// The layout follows the Kenwood TS-2000 `IF` field order. The frequency, TX/RX, mode,
+/// and split fields are driven by real state; auxiliary fields are reported as defaults.
+/// (The exact byte layout is validated against OmniRig in the live bring-up runbook.)
+fn synth_if(snapshot: &Snapshot) -> Vec {
+ let freq = snapshot.vfo(snapshot.rx_vfo).freq_hz;
+ let tx = u8::from(snapshot.ptt);
+ let mode = mode_to_digit(snapshot.vfo(snapshot.rx_vfo).mode) - b'0';
+ let split = u8::from(snapshot.split);
+ let rx_vfo = match snapshot.rx_vfo {
+ Vfo::A => 0u8,
+ Vfo::B => 1u8,
+ };
+ // Canonical Kenwood `IF` answer (38 bytes incl. `IF` and `;`). Field widths:
+ // freq(11) step(4) rit/xit(±5=6) rit(1) xit(1) bank(1) mem(2) tx(1) mode(1)
+ // vfo(1) scan(1) split(1) tone(1) tone#(2) p15(1)
+ // Frequency, TX/RX, mode, VFO, and split are state-driven; the rest are defaults.
+ format!("IF{freq:011}0000+0000000000{tx}{mode}{rx_vfo}0{split}0000;").into_bytes()
+}
+
+#[async_trait]
+impl ClientDialect for Ts2000Dialect {
+ async fn handle(&self, request: &[u8], ctx: &ClientSessionContext) -> Vec {
+ let Some((verb, payload)) = parse_command(request) else {
+ return ERR.to_vec();
+ };
+ let read = payload.is_empty();
+ match verb.as_slice() {
+ b"IF" if read => synth_if(&ctx.snapshot()),
+ b"FA" => {
+ if read {
+ let snap = ctx.snapshot();
+ return freq_frame(b"FA", snap.vfo(snap.rx_vfo).freq_hz);
+ }
+ // OmniRig/HDSDR uses FA writes for the displayed tune target. Preserve the
+ // no-retargeting invariant by applying that tune to the currently active VFO.
+ set_freq(ctx, ctx.snapshot().rx_vfo, &payload).await
+ }
+ b"FB" => {
+ let snap = ctx.snapshot();
+ if read {
+ return freq_frame(b"FB", snap.vfo(snap.rx_vfo).freq_hz);
+ }
+ // HDSDR can issue waterfall click-to-tune writes through FB rather than FA
+ // depending on its OmniRig VFO-sync state. Treat both as displayed-frequency
+ // writes so the panadapter cannot tune an inactive physical VFO.
+ set_freq(ctx, snap.rx_vfo, &payload).await
+ }
+ b"MD" => {
+ if read {
+ let snap = ctx.snapshot();
+ let d = mode_to_digit(snap.vfo(snap.rx_vfo).mode);
+ return vec![b'M', b'D', d, b';'];
+ }
+ let Some(&d) = payload.first() else {
+ return ERR.to_vec();
+ };
+ let vfo = ctx.snapshot().rx_vfo;
+ reply(
+ ctx.apply_modeled(
+ StateMutation::SetMode {
+ vfo,
+ mode: mode_from_digit(d),
+ },
+ CommandClass::ModeledWrite,
+ )
+ .await,
+ )
+ }
+ // VFO-target writes are rejected outright: this is the anti-oscillation
+ // guarantee. A read of FR/FT is answered from state.
+ b"FR" if read => vec![b'F', b'R', vfo_digit(ctx.snapshot().rx_vfo), b';'],
+ b"FT" if read => {
+ let snap = ctx.snapshot();
+ let tx = if snap.split { snap.tx_vfo } else { snap.rx_vfo };
+ vec![b'F', b'T', vfo_digit(tx), b';']
+ }
+ b"AI" => {
+ // `AI;` read reports current state without changing it; `AI;` writes toggle.
+ if read {
+ ai_frame(ctx.ai_on())
+ } else {
+ let on = payload.first().is_some_and(|&d| d != b'0');
+ ctx.set_ai(on);
+ Vec::new()
+ }
+ }
+ b"ID" if read => b"ID019;".to_vec(),
+ b"PS" if read => b"PS1;".to_vec(),
+ // The translator does not carry native passthrough (foreign dialect).
+ _ => ERR.to_vec(),
+ }
+ }
+
+ fn format_notification(
+ &self,
+ change: &StateChange,
+ ctx: &ClientSessionContext,
+ ) -> Option> {
+ if !ctx.ai_on() {
+ return None;
+ }
+ match *change {
+ StateChange::Freq { vfo, hz } if vfo == ctx.snapshot().rx_vfo => {
+ Some(freq_frame(b"FA", hz))
+ }
+ StateChange::Mode { vfo, mode } if vfo == ctx.snapshot().rx_vfo => {
+ Some(vec![b'M', b'D', mode_to_digit(mode), b';'])
+ }
+ StateChange::RxVfo { .. } => {
+ // HDSDR/OmniRig retune the panadapter from an `FA` frame, never from `IF`.
+ // A VFO A<->B switch changes the active frequency and mode, but those land
+ // on the inactive VFO's cache (so their Freq/Mode events are suppressed as
+ // redundant) and only this single `RxVfo` event is broadcast. Emitting just
+ // `IF` left HDSDR parked on the old frequency. Lead with the new active
+ // VFO's `FA`/`MD` so the foreign client actually follows the displayed VFO.
+ let snap = ctx.snapshot();
+ let active = snap.vfo(snap.rx_vfo);
+ let mut frame = freq_frame(b"FA", active.freq_hz);
+ frame.extend_from_slice(&[b'M', b'D', mode_to_digit(active.mode), b';']);
+ frame.extend_from_slice(&synth_if(&snap));
+ Some(frame)
+ }
+ _ => None,
+ }
+ }
+}
+
+fn vfo_digit(vfo: Vfo) -> u8 {
+ match vfo {
+ Vfo::A => b'0',
+ Vfo::B => b'1',
+ }
+}
+
+async fn set_freq(ctx: &ClientSessionContext, vfo: Vfo, payload: &[u8]) -> Vec {
+ let Ok(hz) = std::str::from_utf8(payload)
+ .unwrap_or("")
+ .trim()
+ .parse::()
+ else {
+ return ERR.to_vec();
+ };
+ reply(
+ ctx.apply_modeled(
+ StateMutation::SetVfoFreq { vfo, hz },
+ CommandClass::ModeledWrite,
+ )
+ .await,
+ )
+}
+
+fn reply(outcome: ApplyOutcome) -> Vec {
+ match outcome {
+ ApplyOutcome::Ok => Vec::new(),
+ _ => ERR.to_vec(),
+ }
+}
+
+#[cfg(test)]
+#[allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)]
+mod tests {
+ use super::*;
+ use crate::backend::loopback::LoopbackBackend;
+ use crate::backend::RadioBackend;
+ use crate::model::RadioEventSource;
+ use crate::permissions::EndpointPermissions;
+ use crate::ptt::PttManager;
+ use crate::radio::{detached_link, spawn_scheduler};
+ use crate::state::StateHandle;
+ use std::sync::Arc;
+ use std::time::Duration;
+
+ fn ctx_with(perms: EndpointPermissions) -> (ClientSessionContext, LoopbackBackend) {
+ let backend = LoopbackBackend::new();
+ let caps = backend.capabilities();
+ let arc: Arc = Arc::new(backend.clone());
+ let state = StateHandle::new();
+ let radio = spawn_scheduler(arc, detached_link(), state.clone());
+ let ptt = PttManager::new(Duration::from_secs(300));
+ let ctx = ClientSessionContext::new(7, perms, state, radio, ptt, caps);
+ (ctx, backend)
+ }
+
+ #[tokio::test]
+ async fn if_response_contains_frequency() {
+ let (ctx, _b) = ctx_with(EndpointPermissions::read_only());
+ ctx.state.record(
+ StateChange::Freq {
+ vfo: Vfo::A,
+ hz: 14_074_000,
+ },
+ RadioEventSource::PollDiff,
+ );
+ let reply = Ts2000Dialect::new().handle(b"IF;", &ctx).await;
+ assert!(reply.starts_with(b"IF00014074000"));
+ assert!(reply.ends_with(b";"));
+ assert_eq!(reply.len(), 38, "Kenwood IF answer is 38 bytes");
+ }
+
+ #[tokio::test]
+ async fn rejects_vfo_target_write() {
+ let (ctx, backend) = ctx_with(EndpointPermissions::from_tokens(&["read", "write"]));
+ let reply = Ts2000Dialect::new().handle(b"FR1;", &ctx).await;
+ assert_eq!(reply, ERR.to_vec());
+ tokio::time::sleep(Duration::from_millis(20)).await;
+ // Crucially: no split mutation reached the radio.
+ assert!(backend.mutations().is_empty());
+ }
+
+ #[tokio::test]
+ async fn rx_vfo_change_notifies_with_current_if_status() {
+ let (ctx, _backend) = ctx_with(EndpointPermissions::read_only());
+ ctx.set_ai(true);
+ ctx.state.record(
+ StateChange::Freq {
+ vfo: Vfo::B,
+ hz: 14_034_320,
+ },
+ RadioEventSource::NativePush,
+ );
+ ctx.state.record(
+ StateChange::Mode {
+ vfo: Vfo::B,
+ mode: crate::model::Mode::Usb,
+ },
+ RadioEventSource::NativePush,
+ );
+ ctx.state.record(
+ StateChange::RxVfo { vfo: Vfo::B },
+ RadioEventSource::NativePush,
+ );
+
+ let notification = Ts2000Dialect::new()
+ .format_notification(&StateChange::RxVfo { vfo: Vfo::B }, &ctx)
+ .expect("IF notification");
+
+ // The notification still carries the full `IF` status so split/PTT stay in sync.
+ let if_at = notification
+ .windows(2)
+ .position(|w| w == b"IF")
+ .expect("IF segment present");
+ assert_eq!(
+ ¬ification[if_at..if_at + 13],
+ b"IF00014034320",
+ "embedded IF status reports the new active frequency"
+ );
+ assert_eq!(
+ *notification.get(if_at + 30).expect("VFO field"),
+ b'1',
+ "TS-2000 IF VFO field should report VFO B"
+ );
+ }
+
+ #[tokio::test]
+ async fn vfo_switch_pushes_fa_for_new_active_frequency() {
+ // Regression for the HDSDR VFO-switch bug: a VFO A->B switch must push an `FA`
+ // (and `MD`) frame for the new active VFO so HDSDR/OmniRig retunes the panadapter.
+ let (ctx, _backend) = ctx_with(EndpointPermissions::read_only());
+ ctx.set_ai(true);
+ ctx.state.record(
+ StateChange::Freq {
+ vfo: Vfo::B,
+ hz: 7_034_320,
+ },
+ RadioEventSource::NativePush,
+ );
+ ctx.state.record(
+ StateChange::Mode {
+ vfo: Vfo::B,
+ mode: crate::model::Mode::Cw,
+ },
+ RadioEventSource::NativePush,
+ );
+ ctx.state.record(
+ StateChange::RxVfo { vfo: Vfo::B },
+ RadioEventSource::NativePush,
+ );
+
+ let notification = Ts2000Dialect::new()
+ .format_notification(&StateChange::RxVfo { vfo: Vfo::B }, &ctx)
+ .expect("notification");
+
+ assert!(
+ notification.starts_with(b"FA00007034320;"),
+ "VFO switch must lead with FA for the new active frequency, got {}",
+ String::from_utf8_lossy(¬ification)
+ );
+ assert!(
+ notification
+ .windows(4)
+ .any(|w| w == [b'M', b'D', mode_to_digit(crate::model::Mode::Cw), b';']),
+ "VFO switch must also push the new active mode (MD)"
+ );
+ }
+
+ #[tokio::test]
+ async fn answers_id_as_ts2000() {
+ let (ctx, _b) = ctx_with(EndpointPermissions::read_only());
+ assert_eq!(
+ Ts2000Dialect::new().handle(b"ID;", &ctx).await,
+ b"ID019;".to_vec()
+ );
+ }
+
+ #[tokio::test]
+ async fn ai_read_reports_state_without_changing_it() {
+ let (ctx, _b) = ctx_with(EndpointPermissions::read_only());
+ assert_eq!(
+ Ts2000Dialect::new().handle(b"AI;", &ctx).await,
+ b"AI0;".to_vec()
+ );
+ assert!(!ctx.ai_on());
+ assert!(Ts2000Dialect::new().handle(b"AI2;", &ctx).await.is_empty());
+ assert_eq!(
+ Ts2000Dialect::new().handle(b"AI;", &ctx).await,
+ b"AI2;".to_vec()
+ );
+ assert!(ctx.ai_on(), "AI; read must not disable auto-info");
+ }
+
+ #[tokio::test]
+ async fn reads_frequency_from_state() {
+ let (ctx, _b) = ctx_with(EndpointPermissions::read_only());
+ ctx.state.record(
+ StateChange::Freq {
+ vfo: Vfo::A,
+ hz: 21_200_000,
+ },
+ RadioEventSource::PollDiff,
+ );
+ assert_eq!(
+ Ts2000Dialect::new().handle(b"FA;", &ctx).await,
+ b"FA00021200000;".to_vec()
+ );
+ }
+
+ #[tokio::test]
+ async fn fa_write_tunes_active_vfo_b() {
+ let (ctx, backend) = ctx_with(EndpointPermissions::from_tokens(&["read", "write"]));
+ ctx.state.record(
+ StateChange::RxVfo { vfo: Vfo::B },
+ RadioEventSource::NativePush,
+ );
+
+ assert_eq!(
+ Ts2000Dialect::new().handle(b"FA00014074000;", &ctx).await,
+ Vec::::new()
+ );
+ tokio::time::sleep(Duration::from_millis(20)).await;
+
+ assert_eq!(
+ backend.mutations(),
+ vec![StateMutation::SetVfoFreq {
+ vfo: Vfo::B,
+ hz: 14_074_000
+ }],
+ "HDSDR/OmniRig FA set-frequency writes express the displayed active frequency"
+ );
+ }
+
+ #[tokio::test]
+ async fn fb_write_tunes_active_vfo_a() {
+ let (ctx, backend) = ctx_with(EndpointPermissions::from_tokens(&["read", "write"]));
+ ctx.state.record(
+ StateChange::RxVfo { vfo: Vfo::A },
+ RadioEventSource::NativePush,
+ );
+
+ assert_eq!(
+ Ts2000Dialect::new().handle(b"FB00014052000;", &ctx).await,
+ Vec::::new()
+ );
+ tokio::time::sleep(Duration::from_millis(20)).await;
+
+ assert_eq!(
+ backend.mutations(),
+ vec![StateMutation::SetVfoFreq {
+ vfo: Vfo::A,
+ hz: 14_052_000
+ }],
+ "HDSDR/OmniRig may issue waterfall click-to-tune writes through FB"
+ );
+ }
+
+ #[tokio::test]
+ async fn fa_read_reports_active_vfo_b_frequency() {
+ let (ctx, _backend) = ctx_with(EndpointPermissions::read_only());
+ ctx.state.record(
+ StateChange::Freq {
+ vfo: Vfo::A,
+ hz: 14_062_820,
+ },
+ RadioEventSource::NativePush,
+ );
+ ctx.state.record(
+ StateChange::Freq {
+ vfo: Vfo::B,
+ hz: 14_074_000,
+ },
+ RadioEventSource::NativePush,
+ );
+ ctx.state.record(
+ StateChange::RxVfo { vfo: Vfo::B },
+ RadioEventSource::NativePush,
+ );
+
+ assert_eq!(
+ Ts2000Dialect::new().handle(b"FA;", &ctx).await,
+ b"FA00014074000;".to_vec(),
+ "HDSDR/OmniRig FA reads track the displayed active frequency"
+ );
+ }
+
+ #[tokio::test]
+ async fn active_vfo_b_frequency_notifies_as_fa() {
+ let (ctx, _backend) = ctx_with(EndpointPermissions::read_only());
+ ctx.set_ai(true);
+ ctx.state.record(
+ StateChange::RxVfo { vfo: Vfo::B },
+ RadioEventSource::NativePush,
+ );
+
+ let notification = Ts2000Dialect::new()
+ .format_notification(
+ &StateChange::Freq {
+ vfo: Vfo::B,
+ hz: 14_074_000,
+ },
+ &ctx,
+ )
+ .expect("active VFO B frequency notification");
+
+ assert_eq!(notification, b"FA00014074000;".to_vec());
+ }
+
+ #[tokio::test]
+ async fn md_read_and_write_use_active_vfo_b() {
+ let (ctx, backend) = ctx_with(EndpointPermissions::from_tokens(&["read", "write"]));
+ ctx.state.record(
+ StateChange::Mode {
+ vfo: Vfo::A,
+ mode: crate::model::Mode::Lsb,
+ },
+ RadioEventSource::NativePush,
+ );
+ ctx.state.record(
+ StateChange::Mode {
+ vfo: Vfo::B,
+ mode: crate::model::Mode::Cw,
+ },
+ RadioEventSource::NativePush,
+ );
+ ctx.state.record(
+ StateChange::RxVfo { vfo: Vfo::B },
+ RadioEventSource::NativePush,
+ );
+
+ assert_eq!(Ts2000Dialect::new().handle(b"MD;", &ctx).await, b"MD3;");
+ assert_eq!(
+ Ts2000Dialect::new().handle(b"MD2;", &ctx).await,
+ Vec::::new()
+ );
+ tokio::time::sleep(Duration::from_millis(20)).await;
+
+ assert_eq!(
+ backend.mutations(),
+ vec![StateMutation::SetMode {
+ vfo: Vfo::B,
+ mode: crate::model::Mode::Usb
+ }],
+ "HDSDR/OmniRig MD writes express the displayed active mode"
+ );
+ }
+}
diff --git a/crates/cathub/src/dialect/kenwood/ts590.rs b/crates/cathub/src/dialect/kenwood/ts590.rs
new file mode 100644
index 0000000..6c4976c
--- /dev/null
+++ b/crates/cathub/src/dialect/kenwood/ts590.rs
@@ -0,0 +1,793 @@
+//! The native Kenwood TS-590 client dialect (N1MM Logger+, ARCP-590, Log4OM-as-TS590).
+//!
+//! Modeled reads (`FA`/`FB`/`MD`/`IF`) are served from the universal state cache, so a
+//! flurry of client polls never touches the radio. Modeled writes route through
+//! [`ClientSessionContext::apply_modeled`] for serialization, permission checks, and the PTT lease.
+//! `AI` is virtualized per endpoint. Any unmodeled native command falls through to a
+//! permission-gated passthrough so genuine native features still work on a certified rig.
+
+use async_trait::async_trait;
+
+use super::{ai_frame, freq_frame, mode_from_digit, mode_to_digit, parse_command, ERR};
+use crate::dialect::{ApplyOutcome, ClientDialect, ClientSessionContext};
+use crate::model::{PttSource, StateChange, StateMutation, Vfo};
+use crate::permissions::CommandClass;
+use crate::state::Snapshot;
+
+/// The native TS-590 dialect.
+#[derive(Clone, Default)]
+pub(crate) struct Ts590Dialect;
+
+impl Ts590Dialect {
+ /// Create the dialect.
+ pub(crate) fn new() -> Self {
+ Ts590Dialect
+ }
+}
+
+/// Synthesize a Kenwood `IF;` status answer (38 bytes) from the universal state.
+///
+/// When `single_vfo` is true (operating-VFO virtualization) the operating VFO is always
+/// presented as VFO A with no split, so a single-VFO logger (N1MM SO1V) never sees VFO B
+/// in the P10 field and never warns about it.
+pub(crate) fn synth_if(snapshot: &Snapshot, single_vfo: bool) -> Vec {
+ let freq = snapshot.vfo(snapshot.rx_vfo).freq_hz;
+ let tx = u8::from(snapshot.ptt);
+ let mode = mode_to_digit(snapshot.vfo(snapshot.rx_vfo).mode) - b'0';
+ let split = if single_vfo {
+ 0
+ } else {
+ u8::from(snapshot.split)
+ };
+ let rx_vfo = if single_vfo {
+ 0u8
+ } else {
+ match snapshot.rx_vfo {
+ Vfo::A => 0u8,
+ Vfo::B => 1u8,
+ }
+ };
+ format!("IF{freq:011}0000+0000000000{tx}{mode}{rx_vfo}0{split}0000;").into_bytes()
+}
+
+#[async_trait]
+impl ClientDialect for Ts590Dialect {
+ async fn handle(&self, request: &[u8], ctx: &ClientSessionContext) -> Vec {
+ let Some((verb, payload)) = parse_command(request) else {
+ return ERR.to_vec();
+ };
+ let read = payload.is_empty();
+ // In operating-VFO virtualization, every VFO-addressed read/write targets the
+ // operating (rx) VFO so the endpoint only ever sees a single VFO presented as VFO A.
+ let single_vfo = ctx.single_vfo();
+ let op_vfo = ctx.snapshot().rx_vfo;
+ match verb.as_slice() {
+ b"FA" => {
+ let target = if single_vfo { op_vfo } else { Vfo::A };
+ if read {
+ freq_frame(b"FA", ctx.snapshot().vfo(target).freq_hz)
+ } else {
+ set_freq(ctx, target, &payload).await
+ }
+ }
+ b"FB" => {
+ // For a single-VFO endpoint the "other" VFO is hidden: FB mirrors the operating
+ // VFO so no path exposes physical VFO B. Dual-VFO endpoints address real B.
+ let target = if single_vfo { op_vfo } else { Vfo::B };
+ if read {
+ freq_frame(b"FB", ctx.snapshot().vfo(target).freq_hz)
+ } else {
+ set_freq(ctx, target, &payload).await
+ }
+ }
+ // A single-VFO endpoint must never see the receive/transmit VFO selectors expose
+ // VFO B (they would otherwise fall through to a raw passthrough). Present the
+ // operating VFO as VFO A and swallow attempts to select a VFO.
+ b"FR" if single_vfo => {
+ if read {
+ b"FR0;".to_vec()
+ } else {
+ Vec::new()
+ }
+ }
+ b"FT" if single_vfo => {
+ if read {
+ b"FT0;".to_vec()
+ } else {
+ Vec::new()
+ }
+ }
+ b"MD" => {
+ if read {
+ // A real TS-590 `MD;` reports the *active* VFO's mode. Reading VFO A
+ // unconditionally froze N1MM/Log4OM on VFO B's mode display.
+ let snap = ctx.snapshot();
+ let d = mode_to_digit(snap.vfo(snap.rx_vfo).mode);
+ vec![b'M', b'D', d, b';']
+ } else {
+ let Some(&d) = payload.first() else {
+ return ERR.to_vec();
+ };
+ // `MD;` writes the *active* VFO's mode, matching real-radio semantics.
+ let vfo = ctx.snapshot().rx_vfo;
+ reply(
+ ctx.apply_modeled(
+ StateMutation::SetMode {
+ vfo,
+ mode: mode_from_digit(d),
+ },
+ CommandClass::ModeledWrite,
+ )
+ .await,
+ )
+ }
+ }
+ b"TX" => reply(
+ ctx.apply_modeled(
+ StateMutation::SetPtt {
+ keyed: true,
+ source: PttSource::Generic,
+ },
+ CommandClass::PttWrite,
+ )
+ .await,
+ ),
+ b"RX" => reply(
+ ctx.apply_modeled(
+ StateMutation::SetPtt {
+ keyed: false,
+ source: PttSource::Generic,
+ },
+ CommandClass::PttWrite,
+ )
+ .await,
+ ),
+ b"AI" => {
+ // `AI;` is a read: report the current virtualized auto-info state without
+ // changing it, so a native client's connection handshake completes. Only an
+ // `AI;` write toggles auto-information for this endpoint.
+ if read {
+ ai_frame(ctx.ai_on())
+ } else {
+ let on = payload.first().is_some_and(|&d| d != b'0');
+ ctx.set_ai(on);
+ Vec::new()
+ }
+ }
+ b"ID" if read => b"ID021;".to_vec(),
+ b"PS" if read => b"PS1;".to_vec(),
+ b"IF" if read => synth_if(&ctx.snapshot(), single_vfo),
+ // Any other native command is forwarded as a permission-gated passthrough.
+ _ => {
+ let class = if read {
+ CommandClass::PassthroughRead
+ } else {
+ CommandClass::ConfigWrite
+ };
+ ctx.passthrough(request, class).await
+ }
+ }
+ }
+
+ fn format_notification(
+ &self,
+ change: &StateChange,
+ ctx: &ClientSessionContext,
+ ) -> Option> {
+ if !ctx.ai_on() {
+ return None;
+ }
+ let snap = ctx.snapshot();
+ if ctx.single_vfo() {
+ return single_vfo_notification(change, &snap);
+ }
+ match *change {
+ StateChange::Freq { vfo, hz } => {
+ // Always emit the explicit per-VFO frame for VFO-specific trackers.
+ let label: &[u8] = if vfo == Vfo::A { b"FA" } else { b"FB" };
+ let mut frame = freq_frame(label, hz);
+ // When the change is on the *active* VFO, also emit the operating-status
+ // `IF` frame. Operating-frequency trackers (N1MM Logger+, Log4OM-as-TS590)
+ // read the displayed frequency from `IF;`/`FA`, not from a bare `FB`, so an
+ // FB-only push made frequency updates silently stop whenever the rig was on
+ // VFO B. Appending `IF` makes VFO B behave exactly like VFO A.
+ if vfo == snap.rx_vfo {
+ frame.extend_from_slice(&synth_if(&snap, false));
+ }
+ Some(frame)
+ }
+ StateChange::Mode { vfo, mode } if vfo == snap.rx_vfo => {
+ // `MD` reflects the active VFO on a real TS-590; emit it plus the operating
+ // `IF` so mode trackers follow the active VFO regardless of A/B.
+ let mut frame = vec![b'M', b'D', mode_to_digit(mode), b';'];
+ frame.extend_from_slice(&synth_if(&snap, false));
+ Some(frame)
+ }
+ StateChange::RxVfo { .. } => Some(synth_if(&snap, false)),
+ _ => None,
+ }
+ }
+
+ fn format_passthrough(&self, raw: &[u8], ctx: &ClientSessionContext) -> Option> {
+ // A single-VFO endpoint sees a curated, virtualized view: never relay the radio's raw
+ // CAT stream, which can carry FA/FB/FR/IF frames that would leak physical VFO B and
+ // break the "operating VFO is always VFO A" illusion.
+ if ctx.single_vfo() {
+ return None;
+ }
+ // A certified-native client that enabled auto-information expects the radio's CAT
+ // stream verbatim. Relaying unmodeled frames (NB/NR/AG/front-panel changes, ...)
+ // keeps its client-side feature state machines in sync; without this, a client like
+ // ARCP-590 never sees the echo of its own NB write and cannot advance the NB cycle.
+ if ctx.ai_on() {
+ Some(raw.to_vec())
+ } else {
+ None
+ }
+ }
+}
+
+/// Build the operating-VFO-virtualized notification: the operating VFO is always presented
+/// as VFO A, inactive-VFO churn is suppressed, and an A/B switch re-presents the new
+/// operating VFO as VFO A (FA+MD+IF) so a single-VFO logger seamlessly retunes.
+fn single_vfo_notification(change: &StateChange, snap: &Snapshot) -> Option> {
+ match *change {
+ StateChange::Freq { vfo, hz } if vfo == snap.rx_vfo => {
+ let mut frame = freq_frame(b"FA", hz);
+ frame.extend_from_slice(&synth_if(snap, true));
+ Some(frame)
+ }
+ StateChange::Mode { vfo, mode } if vfo == snap.rx_vfo => {
+ let mut frame = vec![b'M', b'D', mode_to_digit(mode), b';'];
+ frame.extend_from_slice(&synth_if(snap, true));
+ Some(frame)
+ }
+ StateChange::RxVfo { .. } => {
+ // The operator pressed A/B: re-present the new operating VFO as VFO A so the
+ // logger retunes exactly as if VFO A had jumped to the new frequency and mode.
+ let op = snap.vfo(snap.rx_vfo);
+ let mut frame = freq_frame(b"FA", op.freq_hz);
+ frame.extend_from_slice(&[b'M', b'D', mode_to_digit(op.mode), b';']);
+ frame.extend_from_slice(&synth_if(snap, true));
+ Some(frame)
+ }
+ // Inactive-VFO frequency/mode churn is invisible to a single-VFO endpoint.
+ _ => None,
+ }
+}
+
+async fn set_freq(ctx: &ClientSessionContext, vfo: Vfo, payload: &[u8]) -> Vec {
+ let Ok(hz) = std::str::from_utf8(payload)
+ .unwrap_or("")
+ .trim()
+ .parse::()
+ else {
+ return ERR.to_vec();
+ };
+ reply(
+ ctx.apply_modeled(
+ StateMutation::SetVfoFreq { vfo, hz },
+ CommandClass::ModeledWrite,
+ )
+ .await,
+ )
+}
+
+fn reply(outcome: ApplyOutcome) -> Vec {
+ match outcome {
+ ApplyOutcome::Ok => Vec::new(),
+ _ => ERR.to_vec(),
+ }
+}
+
+/// Build the full set of raw TS-590 frames that re-present the current state to a native
+/// controller: both VFO frequencies, the receive/transmit VFO selectors, the active mode,
+/// and the operating-status `IF`. Used to re-sync a transparent mirror endpoint that lagged the
+/// broadcast ring, restoring it to the radio's true state without a reconnect.
+pub(crate) fn snapshot_resync_frames(snap: &Snapshot) -> Vec> {
+ let rx = if snap.rx_vfo == Vfo::A { b'0' } else { b'1' };
+ let tx = if snap.tx_vfo == Vfo::A { b'0' } else { b'1' };
+ vec![
+ freq_frame(b"FA", snap.vfo(Vfo::A).freq_hz),
+ freq_frame(b"FB", snap.vfo(Vfo::B).freq_hz),
+ vec![b'F', b'R', rx, b';'],
+ vec![b'F', b'T', tx, b';'],
+ vec![b'M', b'D', mode_to_digit(snap.vfo(snap.rx_vfo).mode), b';'],
+ synth_if(snap, false),
+ ]
+}
+
+#[cfg(test)]
+#[allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)]
+mod tests {
+ use super::*;
+ use crate::backend::loopback::LoopbackBackend;
+ use crate::backend::RadioBackend;
+ use crate::model::{Mode, RadioEventSource};
+ use crate::permissions::EndpointPermissions;
+ use crate::ptt::PttManager;
+ use crate::radio::{detached_link, spawn_scheduler};
+ use crate::state::StateHandle;
+ use std::sync::Arc;
+ use std::time::Duration;
+
+ fn ctx_with(perms: EndpointPermissions) -> (ClientSessionContext, LoopbackBackend) {
+ let backend = LoopbackBackend::new();
+ let caps = backend.capabilities();
+ let arc: Arc = Arc::new(backend.clone());
+ let state = StateHandle::new();
+ let radio = spawn_scheduler(arc, detached_link(), state.clone());
+ let ptt = PttManager::new(Duration::from_secs(300));
+ (
+ ClientSessionContext::new(5, perms, state, radio, ptt, caps),
+ backend,
+ )
+ }
+
+ /// Like [`ctx_with`] but the endpoint uses operating-VFO virtualization (single-VFO
+ /// presentation), as configured for N1MM SO1V.
+ fn single_vfo_ctx(perms: EndpointPermissions) -> (ClientSessionContext, LoopbackBackend) {
+ let (ctx, backend) = ctx_with(perms);
+ (ctx.with_single_vfo(true), backend)
+ }
+
+ #[tokio::test]
+ async fn reads_frequency_from_cache() {
+ let (ctx, _b) = ctx_with(EndpointPermissions::read_only());
+ ctx.state.record(
+ StateChange::Freq {
+ vfo: Vfo::A,
+ hz: 7_074_000,
+ },
+ RadioEventSource::PollDiff,
+ );
+ assert_eq!(
+ Ts590Dialect::new().handle(b"FA;", &ctx).await,
+ b"FA00007074000;".to_vec()
+ );
+ }
+
+ #[tokio::test]
+ async fn write_is_denied_for_read_only_endpoint() {
+ let (ctx, backend) = ctx_with(EndpointPermissions::read_only());
+ assert_eq!(
+ Ts590Dialect::new().handle(b"FA00007050000;", &ctx).await,
+ ERR.to_vec()
+ );
+ tokio::time::sleep(Duration::from_millis(20)).await;
+ assert!(backend.mutations().is_empty());
+ }
+
+ #[tokio::test]
+ async fn mode_read_and_write() {
+ let (ctx, backend) = ctx_with(EndpointPermissions::from_tokens(&["read", "write"]));
+ assert_eq!(
+ Ts590Dialect::new().handle(b"MD;", &ctx).await,
+ b"MD2;".to_vec(),
+ "default mode is USB digit 2"
+ );
+ assert_eq!(
+ Ts590Dialect::new().handle(b"MD3;", &ctx).await,
+ Vec::::new()
+ );
+ tokio::time::sleep(Duration::from_millis(20)).await;
+ assert_eq!(
+ backend.mutations(),
+ vec![StateMutation::SetMode {
+ vfo: Vfo::A,
+ mode: Mode::Cw
+ }]
+ );
+ }
+
+ #[tokio::test]
+ async fn ai_read_reports_state_without_changing_it() {
+ let (ctx, _b) = ctx_with(EndpointPermissions::read_only());
+ // A fresh endpoint: auto-info off, and reading it must not enable it.
+ assert_eq!(
+ Ts590Dialect::new().handle(b"AI;", &ctx).await,
+ b"AI0;".to_vec()
+ );
+ assert!(!ctx.ai_on(), "AI; read must not change the flag");
+
+ // After enabling, a read reports 2 and leaves it enabled (regression: a read used
+ // to be parsed as a write with an empty payload and silently disabled auto-info,
+ // which froze native clients like ARCP-590 that poll AI; as a keepalive).
+ assert!(Ts590Dialect::new().handle(b"AI2;", &ctx).await.is_empty());
+ assert!(ctx.ai_on());
+ assert_eq!(
+ Ts590Dialect::new().handle(b"AI;", &ctx).await,
+ b"AI2;".to_vec()
+ );
+ assert!(ctx.ai_on(), "AI; read must not disable auto-info");
+ }
+
+ #[tokio::test]
+ async fn ai_toggle_is_virtualized_and_drives_notifications() {
+ let (ctx, _b) = ctx_with(EndpointPermissions::read_only());
+ assert!(Ts590Dialect::new().handle(b"AI2;", &ctx).await.is_empty());
+ assert!(ctx.ai_on());
+ let note = Ts590Dialect::new().format_notification(
+ &StateChange::Freq {
+ vfo: Vfo::A,
+ hz: 14_074_000,
+ },
+ &ctx,
+ );
+ // The active VFO is A by default, so an active-VFO frequency change must push the
+ // per-VFO `FA` frame *and* the operating-status `IF` frame so clients that track the
+ // operating frequency (N1MM, Log4OM-as-TS590) follow the change.
+ let mut expected = b"FA00014074000;".to_vec();
+ expected.extend_from_slice(&synth_if(&ctx.snapshot(), false));
+ assert_eq!(note, Some(expected));
+ }
+
+ /// Regression repro for the N1MM "frequency stops tracking on VFO B" bug.
+ ///
+ /// On VFO A, a frequency change pushed an `FA` frame and N1MM updated. On VFO B the hub
+ /// pushed only a bare `FB` frame, which operating-frequency trackers ignore, so the
+ /// displayed frequency silently froze. The active-VFO change must also push the
+ /// operating-status `IF` frame regardless of which VFO is active.
+ #[tokio::test]
+ async fn notification_for_active_vfo_b_freq_pushes_operating_if() {
+ let (ctx, _b) = ctx_with(EndpointPermissions::read_only());
+ ctx.state.record(
+ StateChange::RxVfo { vfo: Vfo::B },
+ RadioEventSource::PollDiff,
+ );
+ ctx.state.record(
+ StateChange::Freq {
+ vfo: Vfo::B,
+ hz: 14_034_320,
+ },
+ RadioEventSource::PollDiff,
+ );
+ ctx.set_ai(true);
+ let note = Ts590Dialect::new()
+ .format_notification(
+ &StateChange::Freq {
+ vfo: Vfo::B,
+ hz: 14_034_320,
+ },
+ &ctx,
+ )
+ .expect("active-VFO freq change must notify");
+ let synth = synth_if(&ctx.snapshot(), false);
+ assert!(
+ note.windows(synth.len()).any(|w| w == synth.as_slice()),
+ "VFO B active-freq notification must include the operating IF frame; got {:?}",
+ String::from_utf8_lossy(¬e)
+ );
+ // The IF frame must report VFO B (rx_vfo field == '1') and B's frequency.
+ assert!(
+ note.windows(2).any(|w| w == b"IF"),
+ "notification must contain an IF status frame"
+ );
+ assert!(
+ note.windows(b"FB00014034320;".len())
+ .any(|w| w == b"FB00014034320;"),
+ "notification must still include the per-VFO FB frame"
+ );
+ }
+
+ #[tokio::test]
+ async fn mode_read_follows_active_vfo_b() {
+ let (ctx, _b) = ctx_with(EndpointPermissions::read_only());
+ ctx.state.record(
+ StateChange::RxVfo { vfo: Vfo::B },
+ RadioEventSource::PollDiff,
+ );
+ ctx.state.record(
+ StateChange::Mode {
+ vfo: Vfo::B,
+ mode: Mode::Cw,
+ },
+ RadioEventSource::PollDiff,
+ );
+ // A real TS-590 `MD;` reports the *active* VFO's mode, so with VFO B active and set
+ // to CW the read must return the CW digit (3), not VFO A's default USB (2).
+ assert_eq!(
+ Ts590Dialect::new().handle(b"MD;", &ctx).await,
+ b"MD3;".to_vec()
+ );
+ }
+
+ #[tokio::test]
+ async fn mode_write_targets_active_vfo_b() {
+ let (ctx, backend) = ctx_with(EndpointPermissions::from_tokens(&["read", "write"]));
+ ctx.state.record(
+ StateChange::RxVfo { vfo: Vfo::B },
+ RadioEventSource::PollDiff,
+ );
+ assert_eq!(
+ Ts590Dialect::new().handle(b"MD3;", &ctx).await,
+ Vec::::new()
+ );
+ tokio::time::sleep(Duration::from_millis(20)).await;
+ // The write must target the active VFO (B), not be hardcoded to VFO A.
+ assert_eq!(
+ backend.mutations(),
+ vec![StateMutation::SetMode {
+ vfo: Vfo::B,
+ mode: Mode::Cw
+ }]
+ );
+ }
+
+ #[tokio::test]
+ async fn passthrough_frame_is_relayed_only_when_auto_info_on() {
+ let (ctx, _b) = ctx_with(EndpointPermissions::read_only());
+ // Auto-info off: the radio's unmodeled echo is suppressed for this endpoint.
+ assert_eq!(Ts590Dialect::new().format_passthrough(b"NB1;", &ctx), None);
+ // After the client enables auto-info, the echo is relayed verbatim so its NB cycle
+ // (and front-panel changes) stay in sync.
+ ctx.set_ai(true);
+ assert_eq!(
+ Ts590Dialect::new().format_passthrough(b"NB1;", &ctx),
+ Some(b"NB1;".to_vec())
+ );
+ assert_eq!(
+ Ts590Dialect::new().format_passthrough(b"NB0;", &ctx),
+ Some(b"NB0;".to_vec())
+ );
+ }
+
+ #[tokio::test]
+ async fn id_and_ps_identify_as_ts590() {
+ let (ctx, _b) = ctx_with(EndpointPermissions::read_only());
+ assert_eq!(
+ Ts590Dialect::new().handle(b"ID;", &ctx).await,
+ b"ID021;".to_vec()
+ );
+ assert_eq!(
+ Ts590Dialect::new().handle(b"PS;", &ctx).await,
+ b"PS1;".to_vec()
+ );
+ }
+
+ #[tokio::test]
+ async fn if_answer_is_38_bytes() {
+ let (ctx, _b) = ctx_with(EndpointPermissions::read_only());
+ let reply = Ts590Dialect::new().handle(b"IF;", &ctx).await;
+ assert_eq!(reply.len(), 38);
+ assert!(reply.starts_with(b"IF"));
+ }
+
+ #[tokio::test]
+ async fn unmodeled_set_requires_config_write() {
+ let (ctx, backend) = ctx_with(EndpointPermissions::from_tokens(&["read", "write"]));
+ // No config_write permission: an EX-menu set is refused, never forwarded.
+ assert_eq!(
+ Ts590Dialect::new().handle(b"EX0050000;", &ctx).await,
+ ERR.to_vec()
+ );
+ tokio::time::sleep(Duration::from_millis(20)).await;
+ assert!(backend.passthroughs().is_empty());
+ }
+
+ #[tokio::test]
+ async fn passthrough_read_is_forwarded_when_permitted() {
+ let (ctx, backend) = ctx_with(EndpointPermissions::from_tokens(&["read"]));
+ let reply = Ts590Dialect::new().handle(b"RM;", &ctx).await;
+ // The loopback backend echoes the raw passthrough.
+ assert_eq!(reply, b"RM;".to_vec());
+ tokio::time::sleep(Duration::from_millis(20)).await;
+ assert_eq!(backend.passthroughs(), vec![b"RM;".to_vec()]);
+ }
+
+ // --- Operating-VFO virtualization (single_vfo) -----------------------------------
+
+ /// With the rig on VFO B, a single-VFO endpoint must see the operating frequency presented
+ /// as VFO A: `IF;` reports P10 == '0' (not '1') so N1MM SO1V never warns, and the
+ /// frequency is VFO B's operating frequency.
+ #[tokio::test]
+ async fn single_vfo_if_presents_operating_vfo_b_as_vfo_a() {
+ let (ctx, _b) = single_vfo_ctx(EndpointPermissions::read_only());
+ ctx.state.record(
+ StateChange::RxVfo { vfo: Vfo::B },
+ RadioEventSource::PollDiff,
+ );
+ ctx.state.record(
+ StateChange::Freq {
+ vfo: Vfo::B,
+ hz: 14_034_320,
+ },
+ RadioEventSource::PollDiff,
+ );
+ let reply = Ts590Dialect::new().handle(b"IF;", &ctx).await;
+ assert_eq!(reply.len(), 38);
+ // P10 (the active-VFO digit) is at index 30 in the 38-byte IF answer.
+ assert_eq!(reply[30], b'0', "operating VFO must be presented as VFO A");
+ assert!(
+ reply.windows(11).any(|w| w == b"00014034320"),
+ "IF must carry the operating (VFO B) frequency; got {}",
+ String::from_utf8_lossy(&reply)
+ );
+ }
+
+ /// A single-VFO endpoint reads the operating VFO's frequency via `FA;`, even when the rig
+ /// is physically on VFO B.
+ #[tokio::test]
+ async fn single_vfo_fa_read_returns_operating_vfo_b_freq() {
+ let (ctx, _b) = single_vfo_ctx(EndpointPermissions::read_only());
+ ctx.state.record(
+ StateChange::RxVfo { vfo: Vfo::B },
+ RadioEventSource::PollDiff,
+ );
+ ctx.state.record(
+ StateChange::Freq {
+ vfo: Vfo::B,
+ hz: 14_034_320,
+ },
+ RadioEventSource::PollDiff,
+ );
+ assert_eq!(
+ Ts590Dialect::new().handle(b"FA;", &ctx).await,
+ b"FA00014034320;".to_vec()
+ );
+ }
+
+ /// An `FA` write from a single-VFO endpoint must tune the operating VFO (B), not VFO A.
+ #[tokio::test]
+ async fn single_vfo_fa_write_tunes_operating_vfo_b() {
+ let (ctx, backend) = single_vfo_ctx(EndpointPermissions::from_tokens(&["read", "write"]));
+ ctx.state.record(
+ StateChange::RxVfo { vfo: Vfo::B },
+ RadioEventSource::PollDiff,
+ );
+ assert_eq!(
+ Ts590Dialect::new().handle(b"FA00014050000;", &ctx).await,
+ Vec::::new()
+ );
+ tokio::time::sleep(Duration::from_millis(20)).await;
+ assert_eq!(
+ backend.mutations(),
+ vec![StateMutation::SetVfoFreq {
+ vfo: Vfo::B,
+ hz: 14_050_000
+ }]
+ );
+ }
+
+ /// The receive-VFO selector never exposes VFO B to a single-VFO endpoint: `FR;` reports
+ /// VFO A and a `FR1;` select is swallowed (not forwarded to the radio).
+ #[tokio::test]
+ async fn single_vfo_fr_is_virtualized_to_vfo_a() {
+ let (ctx, backend) = single_vfo_ctx(EndpointPermissions::from_tokens(&["read", "write"]));
+ ctx.state.record(
+ StateChange::RxVfo { vfo: Vfo::B },
+ RadioEventSource::PollDiff,
+ );
+ assert_eq!(
+ Ts590Dialect::new().handle(b"FR;", &ctx).await,
+ b"FR0;".to_vec()
+ );
+ assert_eq!(
+ Ts590Dialect::new().handle(b"FR1;", &ctx).await,
+ Vec::::new()
+ );
+ tokio::time::sleep(Duration::from_millis(20)).await;
+ assert!(
+ backend.passthroughs().is_empty(),
+ "FR must never reach the radio as a passthrough on a single-VFO endpoint"
+ );
+ }
+
+ /// Switching the operating VFO (A->B) must re-present the new operating VFO as VFO A:
+ /// the push carries `FA`(new freq) + `MD`(new mode) + an `IF` with P10 == '0', so a
+ /// single-VFO logger seamlessly retunes with no SO1V warning.
+ #[tokio::test]
+ async fn single_vfo_rxvfo_switch_re_presents_operating_as_vfo_a() {
+ let (ctx, _b) = single_vfo_ctx(EndpointPermissions::read_only());
+ ctx.state.record(
+ StateChange::Freq {
+ vfo: Vfo::B,
+ hz: 21_205_000,
+ },
+ RadioEventSource::PollDiff,
+ );
+ ctx.state.record(
+ StateChange::Mode {
+ vfo: Vfo::B,
+ mode: Mode::Cw,
+ },
+ RadioEventSource::PollDiff,
+ );
+ ctx.state.record(
+ StateChange::RxVfo { vfo: Vfo::B },
+ RadioEventSource::PollDiff,
+ );
+ ctx.set_ai(true);
+ let note = Ts590Dialect::new()
+ .format_notification(&StateChange::RxVfo { vfo: Vfo::B }, &ctx)
+ .expect("A/B switch must notify a single-VFO endpoint");
+ assert!(
+ note.windows(b"FA00021205000;".len())
+ .any(|w| w == b"FA00021205000;"),
+ "switch must push the new operating frequency as FA; got {}",
+ String::from_utf8_lossy(¬e)
+ );
+ assert!(
+ note.windows(4).any(|w| w == b"MD3;"),
+ "switch must push the new operating mode (CW=3); got {}",
+ String::from_utf8_lossy(¬e)
+ );
+ // The trailing IF must present VFO A (P10 == '0'), never VFO B.
+ let if_pos = note
+ .windows(2)
+ .position(|w| w == b"IF")
+ .expect("notification must contain an IF frame");
+ assert_eq!(
+ note[if_pos + 30],
+ b'0',
+ "the operating VFO must be presented as VFO A in the IF frame"
+ );
+ }
+
+ /// An active-VFO (B) frequency change pushes `FA` (not `FB`) plus an `IF` presenting
+ /// VFO A, so N1MM SO1V tracks the change. Inactive-VFO churn is suppressed.
+ #[tokio::test]
+ async fn single_vfo_active_freq_change_pushes_fa_as_vfo_a() {
+ let (ctx, _b) = single_vfo_ctx(EndpointPermissions::read_only());
+ ctx.state.record(
+ StateChange::RxVfo { vfo: Vfo::B },
+ RadioEventSource::PollDiff,
+ );
+ ctx.state.record(
+ StateChange::Freq {
+ vfo: Vfo::B,
+ hz: 14_034_320,
+ },
+ RadioEventSource::PollDiff,
+ );
+ ctx.set_ai(true);
+ let note = Ts590Dialect::new()
+ .format_notification(
+ &StateChange::Freq {
+ vfo: Vfo::B,
+ hz: 14_034_320,
+ },
+ &ctx,
+ )
+ .expect("active-VFO freq change must notify");
+ assert!(
+ note.starts_with(b"FA00014034320;"),
+ "active VFO-B change must be presented as an FA frame; got {}",
+ String::from_utf8_lossy(¬e)
+ );
+ assert!(
+ !note.windows(2).any(|w| w == b"FB"),
+ "a single-VFO endpoint must never receive an FB frame; got {}",
+ String::from_utf8_lossy(¬e)
+ );
+ let if_pos = note.windows(2).position(|w| w == b"IF").expect("IF frame");
+ assert_eq!(note[if_pos + 30], b'0', "IF must present VFO A");
+ }
+
+ /// A frequency change on the *inactive* VFO is invisible to a single-VFO endpoint.
+ #[tokio::test]
+ async fn single_vfo_inactive_freq_change_is_suppressed() {
+ let (ctx, _b) = single_vfo_ctx(EndpointPermissions::read_only());
+ // Operating on VFO A; a change on VFO B must not be pushed.
+ ctx.set_ai(true);
+ let note = Ts590Dialect::new().format_notification(
+ &StateChange::Freq {
+ vfo: Vfo::B,
+ hz: 7_000_000,
+ },
+ &ctx,
+ );
+ assert_eq!(note, None);
+ }
+
+ /// A single-VFO endpoint never receives raw passthrough frames (which could leak VFO B).
+ #[tokio::test]
+ async fn single_vfo_suppresses_raw_passthrough() {
+ let (ctx, _b) = single_vfo_ctx(EndpointPermissions::read_only());
+ ctx.set_ai(true);
+ assert_eq!(
+ Ts590Dialect::new().format_passthrough(b"FB00014000000;", &ctx),
+ None
+ );
+ }
+}
diff --git a/crates/cathub/src/dialect/mod.rs b/crates/cathub/src/dialect/mod.rs
new file mode 100644
index 0000000..7b2a48e
--- /dev/null
+++ b/crates/cathub/src/dialect/mod.rs
@@ -0,0 +1,484 @@
+//! Client dialects and the per-session execution context.
+//!
+//! A [`ClientDialect`] translates one client's CAT vocabulary to and from the neutral
+//! state, serving reads from the cache and routing writes through [`ClientSessionContext`]. The
+//! context carries the session's identity, inherited endpoint permissions, the shared scheduler/PTT lease, and
+//! its own virtualized auto-information flag, so every write participates in serialization,
+//! permission checks, the single-owner PTT lease, and event fan-out (design §8).
+
+pub(crate) mod kenwood;
+
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::sync::Arc;
+
+use async_trait::async_trait;
+
+use crate::backend::{BackendCapabilities, BackendError};
+use crate::model::{StateChange, StateMutation};
+use crate::permissions::{CommandClass, EndpointPermissions};
+use crate::ptt::{PttDenied, PttManager};
+use crate::radio::{OpKind, Priority, RadioHandle};
+use crate::state::{Snapshot, StateHandle};
+
+/// The Kenwood error reply, used when a modeled write or passthrough is refused.
+const ERR_REPLY: &[u8] = b"?;";
+
+/// The result of applying a modeled write.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum ApplyOutcome {
+ /// The write was accepted and dispatched.
+ Ok,
+ /// The client session lacks permission for this command class.
+ Denied,
+ /// PTT is held by another client session.
+ Busy,
+ /// The backend failed to apply the write.
+ Error,
+ /// The backend does not support this operation.
+ Unsupported,
+}
+
+/// One client session's execution context: identity, permissions, capabilities, and the shared
+/// state/scheduler/PTT lease, plus its own auto-information toggle.
+#[derive(Clone)]
+pub(crate) struct ClientSessionContext {
+ /// The unique session id (used for PTT ownership and scheduler fairness).
+ pub(crate) session_id: u64,
+ /// Permissions inherited from this session's configured endpoint.
+ pub(crate) perms: EndpointPermissions,
+ /// The backend's advertised capabilities.
+ pub(crate) caps: BackendCapabilities,
+ /// The shared universal state.
+ pub(crate) state: StateHandle,
+ /// The shared priority scheduler handle.
+ pub(crate) radio: RadioHandle,
+ /// The shared PTT lease.
+ pub(crate) ptt: PttManager,
+ /// This session's virtualized auto-information flag (never reaches the radio).
+ ai: Arc,
+ /// When true, present the *operating* VFO as VFO A to this endpoint (operating-VFO
+ /// virtualization). Set from `[[serial_endpoint]] single_vfo` for single-VFO loggers such as
+ /// N1MM SO1V; left false for true dual-VFO control endpoints such as ARCP-590.
+ single_vfo: bool,
+}
+
+impl ClientSessionContext {
+ /// Create a client session context.
+ pub(crate) fn new(
+ session_id: u64,
+ perms: EndpointPermissions,
+ state: StateHandle,
+ radio: RadioHandle,
+ ptt: PttManager,
+ caps: BackendCapabilities,
+ ) -> Self {
+ ClientSessionContext {
+ session_id,
+ perms,
+ caps,
+ state,
+ radio,
+ ptt,
+ ai: Arc::new(AtomicBool::new(false)),
+ single_vfo: false,
+ }
+ }
+
+ /// Enable operating-VFO virtualization for this session (builder style so existing
+ /// call sites are unaffected). When enabled, the client always sees the operating VFO
+ /// presented as VFO A. Returns `self` for chaining.
+ pub(crate) fn with_single_vfo(mut self, single_vfo: bool) -> Self {
+ self.single_vfo = single_vfo;
+ self
+ }
+
+ /// A consistent point-in-time view of the radio.
+ pub(crate) fn snapshot(&self) -> Snapshot {
+ self.state.snapshot()
+ }
+
+ /// Apply a modeled write, enforcing permissions, the PTT lease, and serialization.
+ pub(crate) async fn apply_modeled(
+ &self,
+ mutation: StateMutation,
+ class: CommandClass,
+ ) -> ApplyOutcome {
+ if !self.perms.allows_mutation(class, &mutation) {
+ return ApplyOutcome::Denied;
+ }
+ // Idempotent suppression: never re-send a value the radio already holds. This keeps
+ // the hub as quiet on the wire as a native Hamlib driver and avoids the TS-590
+ // PC-control beep that fires on every redundant set. PTT is never redundant.
+ if self.state.is_redundant(&mutation) {
+ return ApplyOutcome::Ok;
+ }
+ match (class, mutation) {
+ (CommandClass::PttWrite, StateMutation::SetPtt { keyed: true, .. }) => {
+ match self.ptt.try_key(self.session_id, self.perms.ptt) {
+ Err(PttDenied::Busy) => return ApplyOutcome::Busy,
+ Err(PttDenied::NotPermitted) => return ApplyOutcome::Denied,
+ Ok(()) => {}
+ }
+ if self
+ .radio
+ .submit(self.session_id, Priority::Ptt, OpKind::Apply(mutation))
+ .await
+ .is_ok()
+ {
+ ApplyOutcome::Ok
+ } else {
+ // The key request never reached the radio: release the lease.
+ self.ptt.unkey(self.session_id);
+ ApplyOutcome::Error
+ }
+ }
+ (CommandClass::PttWrite, StateMutation::SetPtt { keyed: false, .. }) => {
+ let result = self
+ .radio
+ .submit(self.session_id, Priority::Ptt, OpKind::Apply(mutation))
+ .await;
+ self.ptt.unkey(self.session_id);
+ map_outcome(&result)
+ }
+ _ => {
+ let priority = match class {
+ CommandClass::PttWrite => Priority::Ptt,
+ _ => Priority::Write,
+ };
+ map_outcome(
+ &self
+ .radio
+ .submit(self.session_id, priority, OpKind::Apply(mutation))
+ .await,
+ )
+ }
+ }
+ }
+
+ /// Forward a raw native command, returning the raw reply (or the error frame).
+ pub(crate) async fn passthrough(&self, raw: &[u8], class: CommandClass) -> Vec {
+ if !self.perms.allows(class) {
+ return ERR_REPLY.to_vec();
+ }
+ match self
+ .radio
+ .submit(
+ self.session_id,
+ Priority::Read,
+ OpKind::Passthrough(raw.to_vec()),
+ )
+ .await
+ {
+ Ok(bytes) => bytes,
+ Err(_) => ERR_REPLY.to_vec(),
+ }
+ }
+
+ /// Set this session's virtualized auto-information flag.
+ pub(crate) fn set_ai(&self, on: bool) {
+ self.ai.store(on, Ordering::SeqCst);
+ }
+
+ /// Whether this session has auto-information enabled.
+ pub(crate) fn ai_on(&self) -> bool {
+ self.ai.load(Ordering::SeqCst)
+ }
+
+ /// Whether this session uses operating-VFO virtualization (operating VFO presented as
+ /// VFO A). True for single-VFO loggers (N1MM SO1V); false for dual-VFO endpoints.
+ pub(crate) fn single_vfo(&self) -> bool {
+ self.single_vfo
+ }
+
+ /// A clone of this context for a new connection: same shared state/radio/ptt and
+ /// permissions, but a distinct session id and a fresh (off) auto-information flag.
+ pub(crate) fn clone_for_session(&self, session_id: u64) -> ClientSessionContext {
+ ClientSessionContext {
+ session_id,
+ perms: self.perms,
+ caps: self.caps.clone(),
+ state: self.state.clone(),
+ radio: self.radio.clone(),
+ ptt: self.ptt.clone(),
+ ai: Arc::new(AtomicBool::new(false)),
+ // A new connection to the same endpoint keeps that endpoint's VFO-presentation policy.
+ single_vfo: self.single_vfo,
+ }
+ }
+
+ /// Release the PTT lease if this session currently holds it, unkeying the radio first.
+ ///
+ /// Called when a client session's transport closes. A client that keys the transmitter and then
+ /// disconnects (crash, cable pull, app kill) would otherwise leave the radio keyed until
+ /// the `ptt_max_tx_ms` safety ceiling fires - minutes of unintended transmission. This
+ /// drops TX immediately on disconnect (design §8.5), mirroring the orderly-shutdown path.
+ pub(crate) async fn release_ptt_on_disconnect(&self) {
+ if self.ptt.owner() != Some(self.session_id) {
+ return;
+ }
+ let _ = self
+ .radio
+ .submit(
+ self.session_id,
+ Priority::Ptt,
+ OpKind::Apply(StateMutation::SetPtt {
+ keyed: false,
+ source: crate::model::PttSource::Generic,
+ }),
+ )
+ .await;
+ self.ptt.unkey(self.session_id);
+ tracing::warn!(
+ session_id = self.session_id,
+ "client disconnected while keyed; transmitter released"
+ );
+ }
+}
+
+fn map_outcome(result: &Result, BackendError>) -> ApplyOutcome {
+ match result {
+ Ok(_) => ApplyOutcome::Ok,
+ Err(BackendError::Unsupported) => ApplyOutcome::Unsupported,
+ Err(_) => ApplyOutcome::Error,
+ }
+}
+
+/// A client CAT dialect: translates a client's vocabulary to/from the neutral state.
+#[async_trait]
+pub(crate) trait ClientDialect: Send + Sync {
+ /// Handle one inbound request frame, returning the reply bytes (possibly empty).
+ async fn handle(&self, request: &[u8], ctx: &ClientSessionContext) -> Vec;
+
+ /// Render a state change as an unsolicited notification for this endpoint, if it applies.
+ fn format_notification(
+ &self,
+ change: &StateChange,
+ ctx: &ClientSessionContext,
+ ) -> Option>;
+
+ /// Render an unsolicited native frame the backend does not model as a notification for
+ /// this endpoint, if it applies. Returns the bytes to push, or `None` to suppress it.
+ ///
+ /// The default suppresses the frame. A native pass-through dialect overrides this to
+ /// relay the radio's CAT stream verbatim to clients that have enabled auto-information.
+ fn format_passthrough(&self, _raw: &[u8], _ctx: &ClientSessionContext) -> Option> {
+ None
+ }
+
+ /// Render a *modeled* native frame (one the backend parsed into a state change) as a
+ /// verbatim relay for this endpoint, if it applies. Returns the bytes to push, or `None`.
+ ///
+ /// The default suppresses it: a virtualizing endpoint consumes the coalesced
+ /// [`StateChange`](crate::model::StateChange) via [`Self::format_notification`] instead.
+ /// A transparent mirror dialect overrides this to forward the radio's real CAT stream
+ /// byte-for-byte, so the client never diverges from the rig.
+ fn format_native_passthrough(
+ &self,
+ _raw: &[u8],
+ _ctx: &ClientSessionContext,
+ ) -> Option> {
+ None
+ }
+
+ /// Re-present the full current state to a session that lagged the broadcast ring and lost
+ /// one or more events. Returns the frames to write, in order.
+ ///
+ /// The default replays the snapshot through [`Self::format_notification`], which restores
+ /// a virtualizing endpoint to live state. A transparent mirror dialect overrides this to emit
+ /// the radio's state as raw frames (it never consumes synthesized notifications).
+ fn resync(&self, snapshot: &Snapshot, ctx: &ClientSessionContext) -> Vec> {
+ snapshot
+ .as_changes()
+ .iter()
+ .filter_map(|change| self.format_notification(change, ctx))
+ .collect()
+ }
+}
+
+#[cfg(test)]
+#[allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)]
+mod tests {
+ use super::*;
+ use crate::backend::loopback::LoopbackBackend;
+ use crate::backend::RadioBackend;
+ use crate::model::{Mode, PttSource, Vfo};
+ use crate::radio::{detached_link, spawn_scheduler};
+ use std::time::Duration;
+
+ fn ctx_with(perms: EndpointPermissions, id: u64) -> (ClientSessionContext, LoopbackBackend) {
+ let backend = LoopbackBackend::new();
+ let caps = backend.capabilities();
+ let arc: Arc = Arc::new(backend.clone());
+ let state = StateHandle::new();
+ let radio = spawn_scheduler(arc, detached_link(), state.clone());
+ let ptt = PttManager::new(Duration::from_secs(300));
+ (
+ ClientSessionContext::new(id, perms, state, radio, ptt, caps),
+ backend,
+ )
+ }
+
+ #[tokio::test]
+ async fn modeled_write_denied_without_permission() {
+ let (ctx, backend) = ctx_with(EndpointPermissions::read_only(), 1);
+ let outcome = ctx
+ .apply_modeled(
+ StateMutation::SetVfoFreq {
+ vfo: Vfo::A,
+ hz: 7_000_000,
+ },
+ CommandClass::ModeledWrite,
+ )
+ .await;
+ assert_eq!(outcome, ApplyOutcome::Denied);
+ assert!(backend.mutations().is_empty());
+ }
+
+ #[tokio::test]
+ async fn redundant_modeled_write_is_suppressed() {
+ let (ctx, backend) = ctx_with(EndpointPermissions::from_tokens(&["read", "write"]), 1);
+ // A fresh snapshot already reports VFO A in USB, so re-setting USB must not reach the
+ // radio: re-sending an unchanged value is exactly what triggers the TS-590 PC-control
+ // beep when a client like WSJT-X re-asserts mode on every poll.
+ let outcome = ctx
+ .apply_modeled(
+ StateMutation::SetMode {
+ vfo: Vfo::A,
+ mode: Mode::Usb,
+ },
+ CommandClass::ModeledWrite,
+ )
+ .await;
+ assert_eq!(outcome, ApplyOutcome::Ok);
+ assert!(backend.mutations().is_empty());
+
+ // A genuine change still reaches the radio.
+ let outcome = ctx
+ .apply_modeled(
+ StateMutation::SetMode {
+ vfo: Vfo::A,
+ mode: Mode::Cw,
+ },
+ CommandClass::ModeledWrite,
+ )
+ .await;
+ assert_eq!(outcome, ApplyOutcome::Ok);
+ assert_eq!(backend.mutations().len(), 1);
+ }
+
+ #[tokio::test]
+ async fn ptt_write_is_never_suppressed_as_redundant() {
+ // Two unkey requests in a row must both reach the radio; PTT is never deduplicated.
+ let (ctx, backend) = ctx_with(EndpointPermissions::from_tokens(&["ptt"]), 1);
+ for _ in 0..2 {
+ let outcome = ctx
+ .apply_modeled(
+ StateMutation::SetPtt {
+ keyed: false,
+ source: PttSource::Generic,
+ },
+ CommandClass::PttWrite,
+ )
+ .await;
+ assert_eq!(outcome, ApplyOutcome::Ok);
+ }
+ assert_eq!(backend.mutations().len(), 2);
+ }
+
+ #[tokio::test]
+ async fn modeled_write_dispatches_when_allowed() {
+ let (ctx, backend) = ctx_with(EndpointPermissions::from_tokens(&["read", "write"]), 1);
+ let outcome = ctx
+ .apply_modeled(
+ StateMutation::SetVfoFreq {
+ vfo: Vfo::A,
+ hz: 7_000_000,
+ },
+ CommandClass::ModeledWrite,
+ )
+ .await;
+ assert_eq!(outcome, ApplyOutcome::Ok);
+ assert_eq!(backend.mutations().len(), 1);
+ }
+
+ #[tokio::test]
+ async fn frequency_write_does_not_grant_mode_control() {
+ let (ctx, backend) = ctx_with(
+ EndpointPermissions::from_tokens(&["read", "frequency_write"]),
+ 1,
+ );
+
+ let frequency = StateMutation::SetVfoFreq {
+ vfo: Vfo::A,
+ hz: 14_074_000,
+ };
+ assert_eq!(
+ ctx.apply_modeled(frequency, CommandClass::ModeledWrite)
+ .await,
+ ApplyOutcome::Ok
+ );
+
+ let mode = StateMutation::SetMode {
+ vfo: Vfo::A,
+ mode: Mode::Cw,
+ };
+ assert_eq!(
+ ctx.apply_modeled(mode, CommandClass::ModeledWrite).await,
+ ApplyOutcome::Denied
+ );
+ assert_eq!(backend.mutations(), vec![frequency]);
+ }
+
+ #[tokio::test]
+ async fn ptt_is_busy_for_a_second_session() {
+ let (ctx1, _b) = ctx_with(EndpointPermissions::from_tokens(&["ptt"]), 1);
+ // Share the same radio/ptt by cloning the context with a new session id.
+ let ctx2 = ctx1.clone_for_session(2);
+ assert_eq!(
+ ctx1.apply_modeled(
+ StateMutation::SetPtt {
+ keyed: true,
+ source: PttSource::Generic
+ },
+ CommandClass::PttWrite
+ )
+ .await,
+ ApplyOutcome::Ok
+ );
+ assert_eq!(
+ ctx2.apply_modeled(
+ StateMutation::SetPtt {
+ keyed: true,
+ source: PttSource::Generic
+ },
+ CommandClass::PttWrite
+ )
+ .await,
+ ApplyOutcome::Busy
+ );
+ }
+
+ #[tokio::test]
+ async fn passthrough_denied_returns_error_frame() {
+ let (ctx, _b) = ctx_with(EndpointPermissions::read_only(), 1);
+ // A read-only endpoint may not issue a passthrough write.
+ assert_eq!(
+ ctx.passthrough(b"EX0050000;", CommandClass::ConfigWrite)
+ .await,
+ b"?;".to_vec()
+ );
+ }
+
+ #[tokio::test]
+ async fn ai_flag_is_per_session_and_resets_on_clone() {
+ let (ctx, _b) = ctx_with(EndpointPermissions::read_only(), 1);
+ assert!(!ctx.ai_on());
+ ctx.set_ai(true);
+ assert!(ctx.ai_on());
+ let fresh = ctx.clone_for_session(2);
+ assert!(
+ !fresh.ai_on(),
+ "a cloned endpoint starts with auto-info off"
+ );
+ }
+}
diff --git a/crates/cathub/src/error.rs b/crates/cathub/src/error.rs
new file mode 100644
index 0000000..67300e7
--- /dev/null
+++ b/crates/cathub/src/error.rs
@@ -0,0 +1,96 @@
+//! Crate error types.
+//!
+//! [`BackendError`] is the failure surface of the radio link and backends (re-exported
+//! from [`crate::backend`]). [`ConfigError`] covers configuration loading/validation, and
+//! [`CatHubError`] is the daemon's top-level error returned from [`crate::run`].
+
+use thiserror::Error;
+
+/// A failure from the radio transport or a backend operation.
+#[derive(Debug, Error, Clone, PartialEq, Eq)]
+pub(crate) enum BackendError {
+ /// The transport failed (closed, write error, scheduler gone).
+ #[error("transport: {0}")]
+ Transport(String),
+ /// A solicited command timed out waiting for its reply.
+ #[error("timeout")]
+ Timeout,
+ /// The radio (or bridge) rejected the command.
+ #[error("rejected: {0}")]
+ Rejected(String),
+ /// The operation is not supported by this backend.
+ #[error("unsupported")]
+ Unsupported,
+}
+
+/// A configuration load or validation failure.
+#[derive(Debug, Error)]
+pub enum ConfigError {
+ /// The config file could not be read.
+ #[error("reading config: {0}")]
+ Io(#[from] std::io::Error),
+ /// The config file is not valid TOML or has the wrong shape.
+ #[error("parsing config: {0}")]
+ Parse(#[from] toml::de::Error),
+ /// The effective configuration could not be serialized.
+ #[error("serializing config: {0}")]
+ Serialize(#[from] toml::ser::Error),
+ /// The config parsed but is semantically invalid.
+ #[error("invalid config: {0}")]
+ Invalid(String),
+}
+
+/// The daemon's top-level error.
+#[derive(Debug, Error)]
+pub enum CatHubError {
+ /// A configuration problem.
+ #[error(transparent)]
+ Config(#[from] ConfigError),
+ /// An I/O problem binding an endpoint, opening a port, or similar.
+ #[error("i/o: {0}")]
+ Io(#[from] std::io::Error),
+ /// The configured backend could not be built or initialized.
+ #[error("backend: {0}")]
+ Backend(String),
+}
+
+#[cfg(test)]
+#[allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn backend_error_displays() {
+ assert_eq!(BackendError::Timeout.to_string(), "timeout");
+ assert_eq!(BackendError::Unsupported.to_string(), "unsupported");
+ assert_eq!(
+ BackendError::Rejected("rigctld RPRT -1".into()).to_string(),
+ "rejected: rigctld RPRT -1"
+ );
+ assert_eq!(
+ BackendError::Transport("closed".into()).to_string(),
+ "transport: closed"
+ );
+ }
+
+ #[test]
+ fn config_error_wraps_invalid() {
+ let err = ConfigError::Invalid("no endpoints".into());
+ assert_eq!(err.to_string(), "invalid config: no endpoints");
+ }
+
+ #[test]
+ fn cathub_error_from_config() {
+ let err: CatHubError = ConfigError::Invalid("bad".into()).into();
+ assert!(matches!(err, CatHubError::Config(_)));
+ assert!(err.to_string().contains("invalid config: bad"));
+ }
+
+ #[test]
+ fn cathub_error_backend_displays() {
+ assert_eq!(
+ CatHubError::Backend("unknown".into()).to_string(),
+ "backend: unknown"
+ );
+ }
+}
diff --git a/crates/cathub/src/events.rs b/crates/cathub/src/events.rs
new file mode 100644
index 0000000..4891b6e
--- /dev/null
+++ b/crates/cathub/src/events.rs
@@ -0,0 +1,170 @@
+//! Central native-push ownership and the baseline poller (design §8.4).
+//!
+//! The daemon - not any client - owns the radio's spontaneous-update stream. At startup
+//! (and on reconnect) it enables the rig's native push (`AI2;` on a TS-590) once and keeps
+//! it on for the daemon's lifetime. Per-endpoint auto-info is virtualized in [`crate::dialect`]
+//! and never reaches the wire.
+//!
+//! The poller submits one baseline poll cycle at [`PollConfig`](crate::config::PollConfig)
+//! cadence, backing off to the heartbeat rate only for fields the radio actually covers
+//! with `NativePush` (poll-diff coverage never causes back-off, §8.4).
+
+use std::sync::Arc;
+use std::time::Duration;
+
+use crate::backend::RadioBackend;
+use crate::model::Field;
+use crate::radio::{Expect, OpKind, Priority, RadioHandle, RadioLink};
+use crate::state::StateHandle;
+
+/// Reserved session id for the background poller (client sessions use ids `>= 1`).
+pub(crate) const POLLER_SESSION: u64 = 0;
+
+/// Enable the radio's native push stream if the backend has one. Returns whether a push
+/// command was issued (so the poller knows native push is in play).
+pub(crate) async fn enable_native_push(backend: &Arc, link: &RadioLink) -> bool {
+ if let Some(cmd) = backend.native_push_enable() {
+ // Auto-info enable is a no-reply set; the radio simply begins streaming.
+ let _ = link.submit(cmd, Expect::NoReply).await;
+ true
+ } else {
+ false
+ }
+}
+
+/// Decide the next poll interval. While native push covers the primary frequency field,
+/// the poller drops to the heartbeat (liveness) rate; otherwise it polls at baseline.
+pub(crate) fn next_interval(
+ state: &StateHandle,
+ native_push_active: bool,
+ baseline: Duration,
+ heartbeat: Duration,
+) -> Duration {
+ // Back off only when native push covers the frequency of the *currently active* receive
+ // VFO. Probing a fixed `Vfo::A` meant that while the radio sat on VFO B the poller saw
+ // no coverage and never backed off (over-polling on B), and conversely could back off on
+ // A's coverage while B's frequency was the live one. Track the active VFO instead.
+ let active = state.snapshot().rx_vfo;
+ if native_push_active && state.is_native_push_covered(Field::Freq(active)) {
+ heartbeat
+ } else {
+ baseline
+ }
+}
+
+/// Spawn the baseline poller. It submits poll cycles through the scheduler at
+/// [`Priority::Poll`], so any interactive write or PTT always preempts it.
+pub(crate) fn spawn_poller(
+ radio: RadioHandle,
+ state: StateHandle,
+ native_push_active: bool,
+ baseline: Duration,
+ heartbeat: Duration,
+) {
+ tokio::spawn(async move {
+ loop {
+ let _ = radio
+ .submit(POLLER_SESSION, Priority::Poll, OpKind::Poll)
+ .await;
+ let interval = next_interval(&state, native_push_active, baseline, heartbeat);
+ tokio::time::sleep(interval).await;
+ }
+ });
+}
+
+#[cfg(test)]
+#[allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)]
+mod tests {
+ use super::*;
+ use crate::backend::kenwood::ts590::Ts590Backend;
+ use crate::backend::loopback::LoopbackBackend;
+ use crate::model::{RadioEventSource, StateChange, Vfo};
+ use crate::radio::{detached_link, spawn_scheduler};
+
+ #[tokio::test]
+ async fn poller_drives_polls_through_scheduler() {
+ let backend = LoopbackBackend::new();
+ let arc: Arc = Arc::new(backend.clone());
+ let state = StateHandle::new();
+ let radio = spawn_scheduler(arc, detached_link(), state.clone());
+ spawn_poller(
+ radio,
+ state,
+ false,
+ Duration::from_millis(5),
+ Duration::from_millis(50),
+ );
+ tokio::time::sleep(Duration::from_millis(40)).await;
+ assert!(backend.poll_count() >= 2, "poller should run repeatedly");
+ }
+
+ #[test]
+ fn back_off_only_when_native_push_covers_field() {
+ let state = StateHandle::new();
+ let baseline = Duration::from_millis(200);
+ let heartbeat = Duration::from_secs(2);
+
+ // No coverage yet: baseline.
+ assert_eq!(next_interval(&state, true, baseline, heartbeat), baseline);
+
+ // A poll-diff event must NOT trigger back-off.
+ state.record(
+ StateChange::Freq {
+ vfo: Vfo::A,
+ hz: 7_001_000,
+ },
+ RadioEventSource::PollDiff,
+ );
+ assert_eq!(next_interval(&state, true, baseline, heartbeat), baseline);
+
+ // A native-push event covers the field: back off to heartbeat.
+ state.record(
+ StateChange::Freq {
+ vfo: Vfo::A,
+ hz: 7_002_000,
+ },
+ RadioEventSource::NativePush,
+ );
+ assert_eq!(next_interval(&state, true, baseline, heartbeat), heartbeat);
+
+ // With native push disabled, always baseline regardless of coverage.
+ assert_eq!(next_interval(&state, false, baseline, heartbeat), baseline);
+ }
+
+ #[test]
+ fn back_off_follows_the_active_vfo_not_a_fixed_vfo_a() {
+ // Native push covers VFO B's frequency while the radio sits on VFO B. The poller must
+ // recognize coverage of the *active* VFO and back off, instead of over-polling
+ // because it only ever probed VFO A.
+ let state = StateHandle::new();
+ let baseline = Duration::from_millis(200);
+ let heartbeat = Duration::from_secs(2);
+
+ // Make VFO B the active receive VFO.
+ state.record(
+ StateChange::RxVfo { vfo: Vfo::B },
+ RadioEventSource::NativePush,
+ );
+ // Native push covers VFO B's frequency.
+ state.record(
+ StateChange::Freq {
+ vfo: Vfo::B,
+ hz: 14_074_000,
+ },
+ RadioEventSource::NativePush,
+ );
+ // Coverage of the active VFO (B) must drive the back-off.
+ assert_eq!(next_interval(&state, true, baseline, heartbeat), heartbeat);
+ }
+
+ #[tokio::test]
+ async fn enable_native_push_reports_capability() {
+ let native: Arc = Arc::new(Ts590Backend::new());
+ // A detached link accepts the no-reply submit and drops it.
+ assert!(enable_native_push(&native, &detached_link()).await);
+
+ let bridge: Arc = Arc::new(LoopbackBackend::new());
+ // Loopback reports no native push.
+ assert!(!enable_native_push(&bridge, &detached_link()).await);
+ }
+}
diff --git a/crates/cathub/src/hamlib_dump_state.txt b/crates/cathub/src/hamlib_dump_state.txt
new file mode 100644
index 0000000..e460619
--- /dev/null
+++ b/crates/cathub/src/hamlib_dump_state.txt
@@ -0,0 +1,65 @@
+1
+1
+0
+150000.000000 1500000000.000000 0x401dff -1 -1 0x17e00007 0x1f
+0 0 0 0 0 0 0
+150000.000000 1500000000.000000 0x401dff 5000 100000 0x17e00007 0x1f
+0 0 0 0 0 0 0
+0x401dff 1
+0x401dff 0
+0 0
+0xc 2400
+0xc 1800
+0xc 3000
+0xc 0
+0x2 500
+0x2 2400
+0x2 50
+0x2 0
+0x10 300
+0x10 2400
+0x10 50
+0x10 0
+0x1 8000
+0x1 2400
+0x1 10000
+0x20 15000
+0x20 8000
+0x40 230000
+0 0
+9990
+9990
+10000
+0
+10
+10 20 30
+0xffffffffffffffff
+0xffffffffffffffff
+0xfffffffff7ffffff
+0xfffeff7083ffffff
+0xffffffffffffffff
+0xffffffffffffffbf
+vfo_ops=0x7ffffff
+ptt_type=0x1
+targetable_vfo=0x10c3
+has_set_vfo=1
+has_get_vfo=1
+has_set_freq=1
+has_get_freq=1
+has_set_conf=1
+has_get_conf=1
+has_power2mW=1
+has_mW2power=1
+has_get_ant=1
+has_set_ant=1
+timeout=0
+rig_model=1
+rigctld_version=Hamlib 4.7.0 2026-02-15T21:14:25Z SHA=554e02b39 64-bit
+agc_levels=0=OFF 1=SUPERFAST 2=FAST 5=MEDIUM 3=SLOW 6=AUTO 4=USER
+ctcss_list= 67.0 69.3 71.9 74.4 77.0 79.7 82.5 85.4 88.5 91.5 94.8 97.4 100.0 103.5 107.2 110.9 114.8 118.8 123.0 127.3 131.8 136.5 141.3 146.2 151.4 156.7 159.8 162.2 165.5 167.9 171.3 173.8 177.3 179.9 183.5 186.2 189.9 192.8 196.6 199.5 203.5 206.5 210.7 218.1 225.7 229.1 233.6 241.8 250.3 254.1
+dcs_list= 17 23 25 26 31 32 36 43 47 50 51 53 54 65 71 72 73 74 114 115 116 122 125 131 132 134 143 145 152 155 156 162 165 172 174 205 212 223 225 226 243 244 245 246 251 252 255 261 263 265 266 271 274 306 311 315 325 331 332 343 346 351 356 364 365 371 411 412 413 423 431 432 445 446 452 454 455 462 464 465 466 503 506 516 523 526 532 546 565 606 612 624 627 631 632 654 662 664 703 712 723 731 732 734 743 754
+level_gran=0=0,0,0;1=0,0,0;2=0,0,0;3=0,0,0;4=0,1,0.00392157;5=0,0,0;6=0,0,0;7=0,0,0;8=0,0,0;9=0,0,0;10=0,0,0;11=0,0,10;12=0.05,1,0.00195695;13=0,0,0;14=0,0,0;15=0,0,0;16=0,0,0;17=0,0,0;18=0,0,0;19=0,0,0;20=0,0,0;21=0,0,0;22=0,0,0;23=0,0,0;24=0,0,0;25=0,0,0;26=0,0,0;27=0,0,0;28=0,0,0;29=0,0,0;30=0,0,0;31=0,0,0;32=0,1,0.00392157;33=0,0,0;34=0,0,0;35=0,0,0;36=0,0,0;37=0,0,0;38=0,0,0;39=0,100,0.00392157;40=0,0,0;41=0,0,0;42=0,0,0;43=0,0,0;44=0,2,1;45=-30,10,0.5;46=0,3,1;47=0,0,0;48=0,0,0;49=0,0,0;50=0,0,0;51=0,0,0;52=0,0,0;53=0,0,0;54=0,0,0;55=0,0,0;56=0,0,0;57=0,0,0;58=0,0,0;59=0,0,0;60=0,0,0;61=0,0,0;62=0,0,0;63=0,0,0;
+parm_gran=0=0,0,0;1=0,0,0;2=0,1,0.00392157;3=0,0,0;4=0,1065353216,998277249;5=0,0,0;6=0,0,0;7=0,0,0;8=0,0,0;9=0,0,0;10=BANDUNUSED,BAND70CM,BAND33CM,BAND23CM;11=STRAIGHT,BUG,PADDLE;12=1028443341,1065353216,989872160;13=0,0,0;14=0,0,0;15=0,0,0;16=0,0,0;17=0,0,0;18=0,0,0;19=0,0,0;20=0,0,0;21=0,0,0;22=0,0,0;23=0,0,0;24=0,0,0;25=0,0,0;26=0,0,0;27=0,0,0;28=0,0,0;29=0,0,0;30=0,0,0;31=0,0,0;32=0,1065353216,998277249;33=0,0,0;34=0,0,0;35=0,0,0;36=0,0,0;37=0,0,0;38=0,0,0;39=0,1120403456,998277249;40=0,0,0;41=0,0,0;42=0,0,0;43=0,0,0;44=0,2,1;45=-1041235968,1092616192,1056964608;46=0,3,1;47=0,0,0;48=0,0,0;49=0,0,0;50=0,0,0;51=0,0,0;52=0,0,0;53=0,0,0;54=0,0,0;55=0,0,0;56=0,0,0;57=0,0,0;58=0,0,0;59=0,0,0;60=0,0,0;61=0,0,0;62=0,0,0;63=0,0,0;
+rig_model=1
+hamlib_version=Hamlib 4.7.0 2026-02-15T21:14:25Z SHA=554e02b39 64-bit
+done
diff --git a/crates/cathub/src/hamlib_net.rs b/crates/cathub/src/hamlib_net.rs
new file mode 100644
index 0000000..95f7ce1
--- /dev/null
+++ b/crates/cathub/src/hamlib_net.rs
@@ -0,0 +1,1519 @@
+//! Hamlib `rigctld`-compatible TCP server endpoint (design §6/§8, validated against golden
+//! transcripts captured from a real `rigctld`, §10.1).
+//!
+//! This is a thin server-side reimplementation of the rigctld net protocol - it never
+//! links Hamlib (§8.8). It serves the QsoRipper engine (read-only endpoint) and WSJT-X
+//! (write/PTT endpoint). Modeled reads come from the universal state; writes go through
+//! [`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.
+
+use std::sync::Arc;
+
+use tokio::io::{AsyncReadExt, AsyncWriteExt, BufReader};
+use tokio::net::TcpListener;
+
+use crate::dialect::{ApplyOutcome, ClientSessionContext};
+use crate::model::{Mode, PttSource, StateMutation, TxPower, Vfo};
+use crate::permissions::CommandClass;
+use crate::state::Snapshot;
+
+/// The capability dump served for `\dump_state`. Captured verbatim from Hamlib 4.7.0
+/// `rigctld` (protocol version 1) so the WSJT-X / engine Hamlib clients parse it exactly.
+const DUMP_STATE: &str = include_str!("hamlib_dump_state.txt");
+
+const RPRT_OK: &[u8] = b"RPRT 0\n";
+/// Generic invalid / not-permitted error.
+const RPRT_EINVAL: &[u8] = b"RPRT -1\n";
+/// Feature not available on this backend.
+const RPRT_ENAVAIL: &[u8] = b"RPRT -11\n";
+
+fn vfo_name(vfo: Vfo) -> &'static str {
+ match vfo {
+ Vfo::A => "VFOA",
+ Vfo::B => "VFOB",
+ }
+}
+
+/// The VFO whose data this endpoint presents when a client requests `requested`.
+///
+/// Single-VFO endpoints (N1MM SO1V, Log4OM) always present the operating (receive) VFO so
+/// the radio's physical A/B identity never leaks; dual-VFO endpoints honor the literal
+/// requested VFO. This is the chokepoint that makes single-VFO loggers follow A/B swaps.
+fn presented_vfo(ctx: &ClientSessionContext, snapshot: &Snapshot, requested: Vfo) -> Vfo {
+ if ctx.single_vfo() {
+ snapshot.rx_vfo
+ } else {
+ requested
+ }
+}
+
+/// The VFO name this endpoint advertises for the real VFO `real`. Single-VFO endpoints always
+/// claim `VFOA` (the operating VFO is virtualized as A); dual-VFO endpoints report the real
+/// name.
+fn presented_vfo_name(ctx: &ClientSessionContext, real: Vfo) -> &'static str {
+ if ctx.single_vfo() {
+ "VFOA"
+ } else {
+ vfo_name(real)
+ }
+}
+
+/// The split flag this endpoint exposes. Single-VFO endpoints always hide split (they know only
+/// the operating VFO), matching the single-VFO virtualization contract.
+fn presented_split(ctx: &ClientSessionContext, snapshot: &Snapshot) -> bool {
+ !ctx.single_vfo() && snapshot.split
+}
+
+/// Parse a requested VFO name argument into a [`Vfo`], defaulting to `VFOA`.
+fn parse_vfo_arg(arg: Option<&&str>) -> Vfo {
+ match arg {
+ Some(v) if v.eq_ignore_ascii_case("VFOB") => Vfo::B,
+ _ => Vfo::A,
+ }
+}
+
+/// Parse a `set_freq` argument into whole Hz.
+///
+/// Hamlib clients (WSJT-X, the engine, Log4OM) format frequencies as a double with
+/// `"%f"`, e.g. `F 14040005.000000`, so a plain `u64` parse rejects every real
+/// `set_freq` with `RPRT -1`. Accept either a plain integer or a decimal value and
+/// keep the integer Hz part (frequencies are always whole Hz, so the fraction is `0`).
+fn parse_freq_hz(arg: &str) -> Option {
+ arg.parse::().ok().or_else(|| {
+ arg.split_once('.')
+ .and_then(|(whole, _frac)| whole.parse::().ok())
+ })
+}
+
+fn outcome_rprt(outcome: ApplyOutcome) -> Vec {
+ match outcome {
+ ApplyOutcome::Ok => RPRT_OK.to_vec(),
+ _ => RPRT_EINVAL.to_vec(),
+ }
+}
+
+/// The result of handling one protocol line.
+enum LineResult {
+ /// Reply bytes to send.
+ Reply(Vec),
+ /// The client asked to quit; close the connection.
+ Quit,
+}
+
+/// Resolve a short or long command token to its Hamlib long name, used to echo the
+/// received command as the first record of an Extended Response Protocol reply.
+fn long_name(cmd: &str) -> Option<&'static str> {
+ Some(match cmd {
+ "F" | "\\set_freq" => "set_freq",
+ "M" | "\\set_mode" => "set_mode",
+ "V" | "\\set_vfo" => "set_vfo",
+ "T" | "\\set_ptt" => "set_ptt",
+ "S" | "\\set_split_vfo" => "set_split_vfo",
+ "J" | "\\set_rit" => "set_rit",
+ "Z" | "\\set_xit" => "set_xit",
+ "f" | "\\get_freq" => "get_freq",
+ "m" | "\\get_mode" => "get_mode",
+ "v" | "\\get_vfo" => "get_vfo",
+ "t" | "\\get_ptt" => "get_ptt",
+ "s" | "\\get_split_vfo" => "get_split_vfo",
+ "i" | "\\get_split_freq" => "get_split_freq",
+ "x" | "\\get_split_mode" => "get_split_mode",
+ "l" | "\\get_level" => "get_level",
+ "2" | "\\power2mW" => "power2mW",
+ "j" | "\\get_rit" => "get_rit",
+ "z" | "\\get_xit" => "get_xit",
+ "\\get_vfo_info" => "get_vfo_info",
+ "\\get_powerstat" => "get_powerstat",
+ "\\chk_vfo" => "chk_vfo",
+ "\\dump_state" => "dump_state",
+ _ => return None,
+ })
+}
+
+/// Detect a leading Extended Response Protocol separator. A command prefixed with `+`
+/// (newline-joined) or `;` / `|` / `,` (single-line, joined by that char) selects the
+/// extended response format. Returns the record separator and the rest of the line.
+fn split_erp(line: &str) -> Option<(char, &str)> {
+ let first = line.chars().next()?;
+ let sep = match first {
+ '+' => '\n',
+ ';' | '|' | ',' => first,
+ _ => return None,
+ };
+ Some((sep, &line[first.len_utf8()..]))
+}
+
+/// Join Extended Response Protocol records (echo header, data records, `RPRT x`) with the
+/// chosen separator and terminate the block with a newline, matching real `rigctld`.
+fn erp_records(sep: char, records: &[String]) -> Vec {
+ let mut s = records.join(&sep.to_string());
+ s.push('\n');
+ s.into_bytes()
+}
+
+/// Build the extended-response echo header for a command: the long name, a colon, and
+/// (if any) the received arguments, e.g. `set_freq: 14200000` or `get_freq:`.
+fn ext_echo(cmd: &str, args: &[&str]) -> String {
+ let name = long_name(cmd).unwrap_or(cmd);
+ if args.is_empty() {
+ format!("{name}:")
+ } else {
+ format!("{name}: {}", args.join(" "))
+ }
+}
+
+/// Handle one rigctld protocol line against the universal state, dispatching the
+/// Extended Response Protocol (separator-prefixed) path used by Log4OM-NG separately
+/// from the plain protocol used by WSJT-X / N1MM / the engine.
+async fn handle_line(line: &str, ctx: &ClientSessionContext) -> LineResult {
+ let trimmed = line.trim();
+ if trimmed.is_empty() {
+ return LineResult::Reply(Vec::new());
+ }
+ if let Some((sep, rest)) = split_erp(trimmed) {
+ return LineResult::Reply(handle_ext(sep, rest, ctx).await);
+ }
+ let mut parts = trimmed.split_whitespace();
+ let Some(cmd) = parts.next() else {
+ return LineResult::Reply(Vec::new());
+ };
+ let args: Vec<&str> = parts.collect();
+ dispatch_plain(cmd, &args, ctx).await
+}
+
+/// Handle one Extended Response Protocol line. Log4OM-NG opens every session with
+/// `;V ?` (list supported VFOs) and then polls with `+\get_vfo_info VFOA`, so those two
+/// shapes are formatted explicitly; the common labeled gets are also supported, and any
+/// other command (sets, unknown) is wrapped generically as `echo `.
+async fn handle_ext(sep: char, rest: &str, ctx: &ClientSessionContext) -> Vec {
+ let trimmed = rest.trim();
+ if trimmed.is_empty() {
+ return Vec::new();
+ }
+ let mut parts = trimmed.split_whitespace();
+ let Some(cmd) = parts.next() else {
+ return Vec::new();
+ };
+ let args: Vec<&str> = parts.collect();
+ let snapshot = ctx.snapshot();
+ let reads = ctx.perms.allows(CommandClass::ModeledRead);
+
+ // `?` query on set_vfo: list supported VFOs. This is Log4OM-NG's handshake (`;V ?`).
+ // The supported-token list line is newline-terminated regardless of the separator,
+ // matching real `rigctld`.
+ if matches!(cmd, "V" | "\\set_vfo") && args.first() == Some(&"?") {
+ let vfos = if ctx.single_vfo() {
+ "VFOA "
+ } else {
+ "VFOA VFOB "
+ };
+ return format!("set_vfo: ?{sep}{vfos}\nRPRT 0\n").into_bytes();
+ }
+
+ // get_vfo_info: Log4OM-NG's poll command (`+\get_vfo_info VFOA`). Reports the named
+ // VFO's frequency, mode, passband, split, and (always 0) satellite mode.
+ if cmd == "\\get_vfo_info" {
+ if !reads {
+ return erp_records(sep, &[ext_echo(cmd, &args), "RPRT -1".to_string()]);
+ }
+ let vfo = presented_vfo(ctx, &snapshot, parse_vfo_arg(args.first()));
+ let v = snapshot.vfo(vfo);
+ return erp_records(
+ sep,
+ &[
+ format!("get_vfo_info: {}", presented_vfo_name(ctx, vfo)),
+ format!("Freq: {}", v.freq_hz),
+ format!("Mode: {}", v.mode.hamlib_token_with_data(v.data)),
+ format!("Width: {}", v.passband_hz),
+ format!("Split: {}", u8::from(presented_split(ctx, &snapshot))),
+ "SatMode: 0".to_string(),
+ "RPRT 0".to_string(),
+ ],
+ );
+ }
+
+ // Labeled simple gets, formatted with their Hamlib value labels.
+ let labeled: Option> = match cmd {
+ "f" | "\\get_freq" if reads => Some(vec![
+ "get_freq:".to_string(),
+ format!("Frequency: {}", snapshot.vfo(snapshot.rx_vfo).freq_hz),
+ "RPRT 0".to_string(),
+ ]),
+ "m" | "\\get_mode" if reads => {
+ let v = snapshot.vfo(snapshot.rx_vfo);
+ Some(vec![
+ "get_mode:".to_string(),
+ format!("Mode: {}", v.mode.hamlib_token_with_data(v.data)),
+ format!("Passband: {}", v.passband_hz),
+ "RPRT 0".to_string(),
+ ])
+ }
+ "v" | "\\get_vfo" if reads => Some(vec![
+ "get_vfo:".to_string(),
+ format!("VFO: {}", presented_vfo_name(ctx, snapshot.rx_vfo)),
+ "RPRT 0".to_string(),
+ ]),
+ "t" | "\\get_ptt" if reads => Some(vec![
+ "get_ptt:".to_string(),
+ format!("PTT: {}", u8::from(snapshot.ptt)),
+ "RPRT 0".to_string(),
+ ]),
+ _ => None,
+ };
+ if let Some(records) = labeled {
+ return erp_records(sep, &records);
+ }
+
+ // Generic fallback: sets, permission-denied reads, and unknown commands. Echo the
+ // command then append the plain dispatch reply (e.g. `RPRT 0`) as trailing records.
+ let echo = ext_echo(cmd, &args);
+ match dispatch_plain(cmd, &args, ctx).await {
+ LineResult::Quit => Vec::new(),
+ LineResult::Reply(bytes) => {
+ let mut records = vec![echo];
+ for chunk in String::from_utf8_lossy(&bytes).split('\n') {
+ if !chunk.is_empty() {
+ records.push(chunk.to_string());
+ }
+ }
+ // An ERP client reads until it sees a terminating `RPRT` record. Plain gets
+ // (e.g. `\get_split_vfo`, `\get_powerstat`, `\chk_vfo`, rit/xit) return data
+ // only, with no `RPRT`, so a client falling through this generic path would
+ // block waiting for a terminator that never arrives. Guarantee one.
+ if !records.iter().any(|r| r.starts_with("RPRT")) {
+ records.push("RPRT 0".to_string());
+ }
+ erp_records(sep, &records)
+ }
+ }
+}
+
+/// Dispatch one plain (non-extended) rigctld protocol command against the universal state.
+#[allow(clippy::too_many_lines)] // A flat protocol dispatch table reads best as one match.
+async fn dispatch_plain(cmd: &str, args: &[&str], ctx: &ClientSessionContext) -> LineResult {
+ let snapshot = ctx.snapshot();
+
+ let reply: Vec = match cmd {
+ "q" | "Q" => return LineResult::Quit,
+
+ // --- reads (require `read`) ---
+ "f" | "\\get_freq" => guard_read(ctx, || {
+ format!("{}\n", snapshot.vfo(snapshot.rx_vfo).freq_hz).into_bytes()
+ }),
+ "m" | "\\get_mode" => guard_read(ctx, || {
+ let v = snapshot.vfo(snapshot.rx_vfo);
+ format!(
+ "{}\n{}\n",
+ v.mode.hamlib_token_with_data(v.data),
+ v.passband_hz
+ )
+ .into_bytes()
+ }),
+ "v" | "\\get_vfo" => guard_read(ctx, || {
+ format!("{}\n", presented_vfo_name(ctx, snapshot.rx_vfo)).into_bytes()
+ }),
+ // get_vfo_info: report the named VFO's freq/mode/width/split/satmode as bare
+ // values (Freq, Mode, Width, Split, SatMode), matching real `rigctld`.
+ "\\get_vfo_info" => guard_read(ctx, || {
+ let vfo = presented_vfo(ctx, &snapshot, parse_vfo_arg(args.first()));
+ let v = snapshot.vfo(vfo);
+ format!(
+ "{}\n{}\n{}\n{}\n0\n",
+ v.freq_hz,
+ v.mode.hamlib_token_with_data(v.data),
+ v.passband_hz,
+ u8::from(presented_split(ctx, &snapshot)),
+ )
+ .into_bytes()
+ }),
+ "s" | "\\get_split_vfo" => guard_read(ctx, || {
+ let split = presented_split(ctx, &snapshot);
+ let tx = if split {
+ snapshot.tx_vfo
+ } else {
+ snapshot.rx_vfo
+ };
+ format!("{}\n{}\n", u8::from(split), presented_vfo_name(ctx, tx)).into_bytes()
+ }),
+ "i" | "\\get_split_freq" => guard_read(ctx, || {
+ if ctx.single_vfo() {
+ RPRT_ENAVAIL.to_vec()
+ } else {
+ let hz = snapshot.vfo(snapshot.tx_vfo).freq_hz;
+ if hz == 0 {
+ RPRT_ENAVAIL.to_vec()
+ } else {
+ format!("{hz}\n").into_bytes()
+ }
+ }
+ }),
+ "x" | "\\get_split_mode" => guard_read(ctx, || {
+ if ctx.single_vfo() {
+ RPRT_ENAVAIL.to_vec()
+ } else {
+ let v = snapshot.vfo(snapshot.tx_vfo);
+ if v.mode_known {
+ format!(
+ "{}\n{}\n",
+ v.mode.hamlib_token_with_data(v.data),
+ v.passband_hz
+ )
+ .into_bytes()
+ } else {
+ RPRT_ENAVAIL.to_vec()
+ }
+ }
+ }),
+ "l" | "\\get_level" => guard_read(ctx, || {
+ if args
+ .first()
+ .is_some_and(|level| level.eq_ignore_ascii_case("RFPOWER"))
+ {
+ snapshot.tx_power.map_or_else(
+ || RPRT_ENAVAIL.to_vec(),
+ |power| format!("{}\n", power.relative_decimal()).into_bytes(),
+ )
+ } else {
+ RPRT_ENAVAIL.to_vec()
+ }
+ }),
+ "2" | "\\power2mW" => guard_read(ctx, || {
+ let relative = args
+ .first()
+ .and_then(|value| TxPower::parse_relative_millionths(value));
+ match (snapshot.tx_power, relative, args.get(1), args.get(2)) {
+ (Some(power), Some(relative), Some(_frequency), Some(_mode)) => {
+ power.milliwatts_for_relative(relative).map_or_else(
+ || RPRT_ENAVAIL.to_vec(),
+ |milliwatts| format!("{milliwatts}\n").into_bytes(),
+ )
+ }
+ _ => RPRT_ENAVAIL.to_vec(),
+ }
+ }),
+ "t" | "\\get_ptt" => {
+ guard_read(ctx, || format!("{}\n", u8::from(snapshot.ptt)).into_bytes())
+ }
+ "\\get_powerstat" => guard_read(ctx, || {
+ format!("{}\n", u8::from(snapshot.power_on)).into_bytes()
+ }),
+ "\\dump_state" => DUMP_STATE.as_bytes().to_vec(),
+ // chk_vfo: matches the captured Hamlib 4.7.0 "no-vfo" handshake reply.
+ "\\chk_vfo" => b"0\n".to_vec(),
+
+ // --- writes (require `write`) ---
+ "F" | "\\set_freq" => match args.first().copied().and_then(parse_freq_hz) {
+ Some(hz) => outcome_rprt(
+ ctx.apply_modeled(
+ StateMutation::SetVfoFreq {
+ vfo: snapshot.rx_vfo,
+ hz,
+ },
+ CommandClass::ModeledWrite,
+ )
+ .await,
+ ),
+ None => RPRT_EINVAL.to_vec(),
+ },
+ "M" | "\\set_mode" => match args.first().copied() {
+ Some(token) => {
+ // The TS-590 splits data operation into a base mode (`MD`) and an
+ // independent DATA flag (`DA`), so a `PKTUSB` request becomes two modeled
+ // writes: the base mode, then the DATA flag. Each is deduped independently,
+ // so re-asserting PKTUSB writes nothing and switching PKTUSB→USB only emits
+ // `DA0`. The base write is applied first; if it fails the data write is
+ // skipped and its error is reported.
+ let (base, data) = Mode::decompose_hamlib_token(token);
+ // An unrecognized mode token decodes to `Mode::Unknown`, which renders as
+ // USB. Silently applying it would retune the radio to USB while reporting
+ // `RPRT 0` (false success). Reject it so the client sees the error.
+ if base == Mode::Unknown {
+ return LineResult::Reply(RPRT_EINVAL.to_vec());
+ }
+ let mode_outcome = ctx
+ .apply_modeled(
+ StateMutation::SetMode {
+ vfo: snapshot.rx_vfo,
+ mode: base,
+ },
+ CommandClass::ModeledWrite,
+ )
+ .await;
+ if mode_outcome == ApplyOutcome::Ok {
+ outcome_rprt(
+ ctx.apply_modeled(
+ StateMutation::SetDataMode {
+ vfo: snapshot.rx_vfo,
+ on: data,
+ },
+ CommandClass::ModeledWrite,
+ )
+ .await,
+ )
+ } else {
+ outcome_rprt(mode_outcome)
+ }
+ }
+ None => RPRT_EINVAL.to_vec(),
+ },
+ "S" | "\\set_split_vfo" => {
+ if !ctx.perms.allows(CommandClass::ModeledWrite) {
+ RPRT_EINVAL.to_vec()
+ } else if ctx.single_vfo() {
+ // Single-VFO endpoints present one operating VFO and cannot model a real A/B
+ // split. Accept "split off" as a no-op success, but reject "split on" with
+ // RPRT_ENAVAIL so a client (e.g. WSJT-X "Rig" split) sees split is
+ // unavailable and falls back to "Fake It" instead of believing a false
+ // success that would route TX to the wrong frequency. Either way the real
+ // radio split is never mutated, so single-VFO reads stay consistent.
+ if args.first().copied() == Some("1") {
+ RPRT_ENAVAIL.to_vec()
+ } else {
+ RPRT_OK.to_vec()
+ }
+ } else {
+ let enabled = args.first().copied() == Some("1");
+ let tx_vfo = match args.get(1).copied() {
+ Some(v) if v.eq_ignore_ascii_case("VFOB") => Some(Vfo::B),
+ _ => Some(Vfo::A),
+ };
+ outcome_rprt(
+ ctx.apply_modeled(
+ StateMutation::SetSplit { enabled, tx_vfo },
+ CommandClass::ModeledWrite,
+ )
+ .await,
+ )
+ }
+ }
+ "T" | "\\set_ptt" => {
+ // Hamlib PTT values: 0 = RX, 1 = TX (generic), 2 = TX on mic, 3 = TX on data.
+ // WSJT-X sends `T 3` (RIG_PTT_ON_DATA) to transmit in Data/Pkt mode, so the
+ // source is honored on the wire (TS-590 `TX1;`) to route the DATA/USB audio
+ // and avoid the data beep a bare `TX;` produces. Only 0 (or a missing arg)
+ // means unkey.
+ let (keyed, source) = match args.first().copied() {
+ Some("1") => (true, PttSource::Generic),
+ Some("2") => (true, PttSource::Mic),
+ Some("3") => (true, PttSource::Data),
+ _ => (false, PttSource::Generic),
+ };
+ outcome_rprt(
+ ctx.apply_modeled(
+ StateMutation::SetPtt { keyed, source },
+ CommandClass::PttWrite,
+ )
+ .await,
+ )
+ }
+ // Accepted but unmodeled: selecting the active VFO never retargets on the wire.
+ "V" | "\\set_vfo" => {
+ if ctx.perms.allows(CommandClass::ModeledWrite) {
+ RPRT_OK.to_vec()
+ } else {
+ RPRT_EINVAL.to_vec()
+ }
+ }
+ // RIT (J/j) and XIT (Z/z): modeled offsets in Hz, gated on backend capability.
+ // A zero offset disables the feature, matching rigctld semantics. The offset
+ // always lands on the active VFO, so this never retargets a VFO on the wire.
+ "j" | "\\get_rit" => guard_read(ctx, || {
+ let off = if snapshot.rit_enabled {
+ snapshot.rit_offset_hz
+ } else {
+ 0
+ };
+ format!("{off}\n").into_bytes()
+ }),
+ "z" | "\\get_xit" => guard_read(ctx, || {
+ let off = if snapshot.xit_enabled {
+ snapshot.xit_offset_hz
+ } else {
+ 0
+ };
+ format!("{off}\n").into_bytes()
+ }),
+ "J" | "\\set_rit" => {
+ if ctx.caps.has_rit {
+ match args.first().and_then(|s| s.parse::().ok()) {
+ Some(offset_hz) => outcome_rprt(
+ ctx.apply_modeled(
+ StateMutation::SetRit {
+ offset_hz,
+ enabled: offset_hz != 0,
+ },
+ CommandClass::ModeledWrite,
+ )
+ .await,
+ ),
+ None => RPRT_EINVAL.to_vec(),
+ }
+ } else {
+ RPRT_ENAVAIL.to_vec()
+ }
+ }
+ "Z" | "\\set_xit" => {
+ if ctx.caps.has_xit {
+ match args.first().and_then(|s| s.parse::().ok()) {
+ Some(offset_hz) => outcome_rprt(
+ ctx.apply_modeled(
+ StateMutation::SetXit {
+ offset_hz,
+ enabled: offset_hz != 0,
+ },
+ CommandClass::ModeledWrite,
+ )
+ .await,
+ ),
+ None => RPRT_EINVAL.to_vec(),
+ }
+ } else {
+ RPRT_ENAVAIL.to_vec()
+ }
+ }
+ _ => RPRT_ENAVAIL.to_vec(),
+ };
+ LineResult::Reply(reply)
+}
+
+/// Run a read-only command, enforcing the `read` permission.
+fn guard_read(ctx: &ClientSessionContext, f: impl FnOnce() -> Vec) -> Vec {
+ if ctx.perms.allows(CommandClass::ModeledRead) {
+ f()
+ } else {
+ RPRT_EINVAL.to_vec()
+ }
+}
+
+/// Serve one accepted connection until it closes or quits.
+pub(crate) async fn serve_conn(stream: S, ctx: ClientSessionContext)
+where
+ S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + 'static,
+{
+ let session_id = ctx.session_id;
+ let (reader, mut writer) = tokio::io::split(stream);
+ let mut reader = BufReader::new(reader);
+ let mut line: Vec = Vec::with_capacity(64);
+ let mut byte = [0u8; 1];
+
+ 'serve: loop {
+ // Read one line manually with a hard length cap so a client that never sends a
+ // newline cannot grow the buffer without bound (OOM/DoS via `BufReader::lines`,
+ // which has no upper limit). An over-long line is discarded and the connection
+ // closed.
+ line.clear();
+ let eof = loop {
+ match reader.read(&mut byte).await {
+ Ok(0) | Err(_) => break true,
+ Ok(_) => {
+ if byte[0] == b'\n' {
+ break false;
+ }
+ if line.len() >= MAX_LINE_LEN {
+ tracing::warn!(
+ session_id,
+ "hamlib_net request exceeded {MAX_LINE_LEN} bytes without a \
+ newline; closing connection"
+ );
+ break 'serve;
+ }
+ line.push(byte[0]);
+ }
+ }
+ };
+ if eof {
+ break 'serve;
+ }
+
+ // Trim a trailing CR so CRLF clients are handled.
+ if line.last() == Some(&b'\r') {
+ line.pop();
+ }
+ let text = String::from_utf8_lossy(&line).into_owned();
+ tracing::trace!(session_id, req = %text.trim(), "hamlib_net request");
+ match handle_line(&text, &ctx).await {
+ LineResult::Reply(bytes) => {
+ tracing::trace!(
+ session_id,
+ reply = %String::from_utf8_lossy(&bytes).trim_end(),
+ "hamlib_net reply"
+ );
+ if !bytes.is_empty() && writer.write_all(&bytes).await.is_err() {
+ break 'serve;
+ }
+ let _ = writer.flush().await;
+ }
+ LineResult::Quit => {
+ tracing::trace!(session_id, "hamlib_net client quit");
+ break 'serve;
+ }
+ }
+ }
+
+ // The connection closed: never leave the radio keyed on behalf of a vanished client.
+ ctx.release_ptt_on_disconnect().await;
+}
+
+/// Maximum bytes buffered for a single rigctld request line before the connection is
+/// closed. Real rigctld commands are short; this only bounds a misbehaving client.
+const MAX_LINE_LEN: usize = 4096;
+
+/// Bind a Hamlib net endpoint and serve connections. Each connection gets a fresh
+/// [`ClientSessionContext`] sharing the same state/radio/ptt but its own session id and the
+/// endpoint's permissions.
+pub(crate) async fn run_listener(
+ bind: &str,
+ next_session_id: Arc,
+ template: ClientSessionContext,
+) -> std::io::Result<()> {
+ let listener = TcpListener::bind(bind).await?;
+ tracing::info!(bind, "hamlib_net endpoint listening");
+ loop {
+ let (stream, peer) = listener.accept().await?;
+ let session_id = next_session_id.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
+ let ctx = template.clone_for_session(session_id);
+ tracing::debug!(%peer, session_id, "hamlib_net client connected");
+ tokio::spawn(serve_conn(stream, ctx));
+ }
+}
+
+#[cfg(test)]
+#[allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)]
+mod tests {
+ use super::*;
+ use crate::backend::loopback::LoopbackBackend;
+ use crate::backend::RadioBackend;
+ use crate::model::{RadioEventSource, StateChange, TxPower};
+ use crate::permissions::EndpointPermissions;
+ use crate::ptt::PttManager;
+ use crate::radio::{detached_link, spawn_scheduler};
+ use crate::state::StateHandle;
+ use std::time::Duration;
+
+ fn ctx_with(
+ perms: EndpointPermissions,
+ ) -> (ClientSessionContext, LoopbackBackend, StateHandle) {
+ let backend = LoopbackBackend::new();
+ let caps = backend.capabilities();
+ let arc: Arc = Arc::new(backend.clone());
+ let state = StateHandle::new();
+ let radio = spawn_scheduler(arc, detached_link(), state.clone());
+ let ptt = PttManager::new(Duration::from_secs(300));
+ let ctx = ClientSessionContext::new(1, perms, state.clone(), radio, ptt, caps);
+ (ctx, backend, state)
+ }
+
+ /// A single-VFO-virtualized endpoint: the operating VFO is always presented as `VFOA`,
+ /// split is hidden, and physical A/B identity never leaks (N1MM SO1V, Log4OM).
+ fn ctx_single_vfo(
+ perms: EndpointPermissions,
+ ) -> (ClientSessionContext, LoopbackBackend, StateHandle) {
+ let (ctx, backend, state) = ctx_with(perms);
+ (ctx.with_single_vfo(true), backend, state)
+ }
+
+ /// Put the radio on physical VFO B (14.074 USB) with VFO A on 14.035 CW, split on
+ /// with TX on the inactive VFO - the classic state a single-VFO logger must collapse
+ /// to "operating VFO is VFOA".
+ fn operating_on_b(state: &StateHandle) {
+ state.record(
+ StateChange::Freq {
+ vfo: Vfo::A,
+ hz: 14_035_000,
+ },
+ RadioEventSource::PollDiff,
+ );
+ state.record(
+ StateChange::Mode {
+ vfo: Vfo::A,
+ mode: Mode::Cw,
+ },
+ RadioEventSource::PollDiff,
+ );
+ state.record(
+ StateChange::Freq {
+ vfo: Vfo::B,
+ hz: 14_074_000,
+ },
+ RadioEventSource::PollDiff,
+ );
+ state.record(
+ StateChange::Mode {
+ vfo: Vfo::B,
+ mode: Mode::Usb,
+ },
+ RadioEventSource::PollDiff,
+ );
+ state.record(
+ StateChange::RxVfo { vfo: Vfo::B },
+ RadioEventSource::PollDiff,
+ );
+ state.record(
+ StateChange::Split {
+ enabled: true,
+ tx_vfo: Some(Vfo::A),
+ },
+ RadioEventSource::PollDiff,
+ );
+ }
+
+ async fn reply_of(line: &str, ctx: &ClientSessionContext) -> Vec {
+ match handle_line(line, ctx).await {
+ LineResult::Reply(b) => b,
+ LineResult::Quit => b"".to_vec(),
+ }
+ }
+
+ #[tokio::test]
+ async fn get_freq_reads_from_state() {
+ let (ctx, _b, state) = ctx_with(EndpointPermissions::read_only());
+ state.record(
+ StateChange::Freq {
+ vfo: Vfo::A,
+ hz: 7_030_000,
+ },
+ RadioEventSource::PollDiff,
+ );
+ assert_eq!(reply_of("f", &ctx).await, b"7030000\n".to_vec());
+ }
+
+ #[tokio::test]
+ async fn get_mode_returns_token_and_passband() {
+ let (ctx, _b, state) = ctx_with(EndpointPermissions::read_only());
+ state.record(
+ StateChange::Mode {
+ vfo: Vfo::A,
+ mode: Mode::Cw,
+ },
+ RadioEventSource::PollDiff,
+ );
+ assert_eq!(reply_of("m", &ctx).await, b"CW\n2400\n".to_vec());
+ }
+
+ #[tokio::test]
+ async fn get_freq_and_mode_follow_active_vfo_b() {
+ let (ctx, _b, state) = ctx_with(EndpointPermissions::read_only());
+ state.record(
+ StateChange::Freq {
+ vfo: Vfo::B,
+ hz: 14_034_320,
+ },
+ RadioEventSource::PollDiff,
+ );
+ state.record(
+ StateChange::Mode {
+ vfo: Vfo::B,
+ mode: Mode::Cw,
+ },
+ RadioEventSource::PollDiff,
+ );
+ state.record(
+ StateChange::RxVfo { vfo: Vfo::B },
+ RadioEventSource::PollDiff,
+ );
+
+ assert_eq!(reply_of("f", &ctx).await, b"14034320\n".to_vec());
+ assert_eq!(reply_of("m", &ctx).await, b"CW\n2400\n".to_vec());
+ assert_eq!(reply_of("v", &ctx).await, b"VFOB\n".to_vec());
+ }
+
+ #[tokio::test]
+ async fn get_split_freq_and_mode_read_the_transmit_vfo() {
+ let (ctx, _b, state) = ctx_with(EndpointPermissions::read_only());
+ operating_on_b(&state);
+
+ assert_eq!(reply_of("i", &ctx).await, b"14035000\n".to_vec());
+ assert_eq!(reply_of("x", &ctx).await, b"CW\n2400\n".to_vec());
+ }
+
+ #[tokio::test]
+ async fn get_rfpower_and_power2mw_use_cached_transmit_power() {
+ let (ctx, _b, state) = ctx_with(EndpointPermissions::read_only());
+ state.record(
+ StateChange::TxPower {
+ power: Some(TxPower::from_watts(50, 100)),
+ },
+ RadioEventSource::PollDiff,
+ );
+
+ assert_eq!(reply_of("l RFPOWER", &ctx).await, b"0.5\n".to_vec());
+ assert_eq!(
+ reply_of("2 0.5 14074000 USB", &ctx).await,
+ b"50000\n".to_vec()
+ );
+ }
+
+ #[tokio::test]
+ async fn rfpower_reads_report_unavailable_without_cached_power() {
+ let (ctx, _b, _state) = ctx_with(EndpointPermissions::read_only());
+
+ assert_eq!(reply_of("l RFPOWER", &ctx).await, RPRT_ENAVAIL.to_vec());
+ assert_eq!(
+ reply_of("2 0.5 14074000 USB", &ctx).await,
+ RPRT_ENAVAIL.to_vec()
+ );
+ }
+
+ #[tokio::test]
+ async fn split_mode_is_unavailable_until_the_transmit_mode_is_observed() {
+ let (ctx, _b, state) = ctx_with(EndpointPermissions::read_only());
+ state.record(
+ StateChange::Freq {
+ vfo: Vfo::B,
+ hz: 14_076_000,
+ },
+ RadioEventSource::PollDiff,
+ );
+ state.record(
+ StateChange::Split {
+ enabled: true,
+ tx_vfo: Some(Vfo::B),
+ },
+ RadioEventSource::PollDiff,
+ );
+
+ assert_eq!(reply_of("i", &ctx).await, b"14076000\n".to_vec());
+ assert_eq!(reply_of("x", &ctx).await, RPRT_ENAVAIL.to_vec());
+ }
+
+ #[tokio::test]
+ async fn read_only_endpoint_rejects_set_freq() {
+ let (ctx, backend, _s) = ctx_with(EndpointPermissions::read_only());
+ assert_eq!(reply_of("F 14074000", &ctx).await, RPRT_EINVAL.to_vec());
+ tokio::time::sleep(Duration::from_millis(20)).await;
+ assert!(backend.mutations().is_empty());
+ }
+
+ #[tokio::test]
+ async fn read_only_endpoint_rejects_set_mode_and_ptt() {
+ let (ctx, _b, _s) = ctx_with(EndpointPermissions::read_only());
+ assert_eq!(reply_of("M USB 0", &ctx).await, RPRT_EINVAL.to_vec());
+ assert_eq!(reply_of("T 1", &ctx).await, RPRT_EINVAL.to_vec());
+ }
+
+ #[tokio::test]
+ async fn write_endpoint_sets_frequency() {
+ let (ctx, backend, _s) =
+ ctx_with(EndpointPermissions::from_tokens(&["read", "write", "ptt"]));
+ assert_eq!(reply_of("F 14074000", &ctx).await, RPRT_OK.to_vec());
+ tokio::time::sleep(Duration::from_millis(20)).await;
+ assert_eq!(
+ backend.mutations(),
+ vec![StateMutation::SetVfoFreq {
+ vfo: Vfo::A,
+ hz: 14_074_000
+ }]
+ );
+ }
+
+ #[tokio::test]
+ async fn set_freq_accepts_hamlib_decimal_format() {
+ // Hamlib (WSJT-X, engine, Log4OM) sends set_freq as a "%f" double, e.g.
+ // "F 14040005.000000". Earlier this was rejected with RPRT -1.
+ let (ctx, backend, _s) = ctx_with(EndpointPermissions::from_tokens(&["read", "write"]));
+ assert_eq!(reply_of("F 14040005.000000", &ctx).await, RPRT_OK.to_vec());
+ tokio::time::sleep(Duration::from_millis(20)).await;
+ assert_eq!(
+ backend.mutations(),
+ vec![StateMutation::SetVfoFreq {
+ vfo: Vfo::A,
+ hz: 14_040_005
+ }]
+ );
+ }
+
+ #[test]
+ fn parse_freq_hz_handles_integer_and_decimal() {
+ assert_eq!(parse_freq_hz("14040000"), Some(14_040_000));
+ assert_eq!(parse_freq_hz("14040005.000000"), Some(14_040_005));
+ assert_eq!(parse_freq_hz("0.000000"), Some(0));
+ assert_eq!(parse_freq_hz("abc"), None);
+ assert_eq!(parse_freq_hz(""), None);
+ }
+
+ #[tokio::test]
+ async fn set_freq_never_retargets_vfo() {
+ let (ctx, backend, _s) = ctx_with(EndpointPermissions::from_tokens(&["read", "write"]));
+ let _ = reply_of("F 14074000", &ctx).await;
+ tokio::time::sleep(Duration::from_millis(20)).await;
+ // No SetSplit (FR/FT) mutation: frequency landed on the active VFO only.
+ assert!(backend
+ .mutations()
+ .iter()
+ .all(|m| matches!(m, StateMutation::SetVfoFreq { .. })));
+ }
+
+ #[tokio::test]
+ async fn set_mode_pktusb_writes_base_mode_then_data_flag() {
+ // WSJT-X sends PKTUSB/PKTLSB for digital modes. The hub must split it into a base
+ // mode write plus the independent DATA flag. Starting from the default USB/no-data,
+ // PKTLSB is a genuine change on both axes, so both mutations reach the radio in order.
+ let (ctx, backend, _s) = ctx_with(EndpointPermissions::from_tokens(&["read", "write"]));
+ assert_eq!(reply_of("M PKTLSB 0", &ctx).await, RPRT_OK.to_vec());
+ tokio::time::sleep(Duration::from_millis(20)).await;
+ assert_eq!(
+ backend.mutations(),
+ vec![
+ StateMutation::SetMode {
+ vfo: Vfo::A,
+ mode: Mode::Lsb,
+ },
+ StateMutation::SetDataMode {
+ vfo: Vfo::A,
+ on: true,
+ },
+ ]
+ );
+ }
+
+ #[tokio::test]
+ async fn set_freq_then_same_pktusb_reasserts_mode_after_band_memory_recall() {
+ // The TS-590 recalls MD and DA from per-band memory as soon as FA/FB crosses a band
+ // boundary. Before its AI/poll updates arrive, the cache still contains the old
+ // PKTUSB value. WSJT-X then sends M PKTUSB; that re-assertion must reach the radio
+ // instead of being incorrectly deduped against the stale pre-tune cache.
+ let (ctx, backend, state) = ctx_with(EndpointPermissions::from_tokens(&["read", "write"]));
+ state.record(
+ StateChange::DataMode {
+ vfo: Vfo::A,
+ on: true,
+ },
+ RadioEventSource::PollDiff,
+ );
+
+ assert_eq!(reply_of("F 28074000", &ctx).await, RPRT_OK.to_vec());
+ assert_eq!(reply_of("M PKTUSB 0", &ctx).await, RPRT_OK.to_vec());
+ tokio::time::sleep(Duration::from_millis(20)).await;
+
+ assert_eq!(
+ backend.mutations(),
+ vec![
+ StateMutation::SetVfoFreq {
+ vfo: Vfo::A,
+ hz: 28_074_000,
+ },
+ StateMutation::SetMode {
+ vfo: Vfo::A,
+ mode: Mode::Usb,
+ },
+ StateMutation::SetDataMode {
+ vfo: Vfo::A,
+ on: true,
+ },
+ ]
+ );
+ }
+
+ #[tokio::test]
+ async fn set_mode_plain_clears_data_flag() {
+ // Switching from a data mode to a plain mode must emit DA0. Prime DATA on, then send
+ // plain USB: the base mode is unchanged (deduped) but the DATA-off write lands.
+ let (ctx, backend, state) = ctx_with(EndpointPermissions::from_tokens(&["read", "write"]));
+ state.record(
+ StateChange::DataMode {
+ vfo: Vfo::A,
+ on: true,
+ },
+ RadioEventSource::PollDiff,
+ );
+ assert_eq!(reply_of("M USB 0", &ctx).await, RPRT_OK.to_vec());
+ tokio::time::sleep(Duration::from_millis(20)).await;
+ assert_eq!(
+ backend.mutations(),
+ vec![StateMutation::SetDataMode {
+ vfo: Vfo::A,
+ on: false,
+ }]
+ );
+ }
+
+ #[tokio::test]
+ async fn set_freq_and_mode_target_active_vfo_b() {
+ // WSJT-X / Log4OM write through the hamlib_net endpoint. When the rig is on VFO B, a
+ // set_freq / set_mode must land on VFO B (the active VFO), never be forced to A.
+ let (ctx, backend, state) = ctx_with(EndpointPermissions::from_tokens(&["read", "write"]));
+ state.record(
+ StateChange::RxVfo { vfo: Vfo::B },
+ RadioEventSource::PollDiff,
+ );
+ assert_eq!(reply_of("F 14034320", &ctx).await, RPRT_OK.to_vec());
+ assert_eq!(reply_of("M CW 0", &ctx).await, RPRT_OK.to_vec());
+ tokio::time::sleep(Duration::from_millis(20)).await;
+ let muts = backend.mutations();
+ assert!(
+ muts.contains(&StateMutation::SetVfoFreq {
+ vfo: Vfo::B,
+ hz: 14_034_320,
+ }),
+ "set_freq must target active VFO B, got {muts:?}"
+ );
+ assert!(
+ muts.contains(&StateMutation::SetMode {
+ vfo: Vfo::B,
+ mode: Mode::Cw,
+ }),
+ "set_mode must target active VFO B, got {muts:?}"
+ );
+ }
+
+ #[tokio::test]
+ async fn get_mode_composes_pkt_token_when_data_on() {
+ let (ctx, _b, state) = ctx_with(EndpointPermissions::read_only());
+ state.record(
+ StateChange::DataMode {
+ vfo: Vfo::A,
+ on: true,
+ },
+ RadioEventSource::PollDiff,
+ );
+ // Default base mode is USB; with DATA on a Hamlib client must read PKTUSB.
+ assert_eq!(reply_of("m", &ctx).await, b"PKTUSB\n2400\n".to_vec());
+ }
+
+ #[tokio::test]
+ async fn ptt_requires_ptt_permission() {
+ let (ctx, _b, _s) = ctx_with(EndpointPermissions::from_tokens(&["read", "write"]));
+ // Has write but not ptt.
+ assert_eq!(reply_of("T 1", &ctx).await, RPRT_EINVAL.to_vec());
+ }
+
+ #[tokio::test]
+ async fn set_ptt_keys_on_any_nonzero_value() {
+ // WSJT-X in Data/Pkt mode sends `T 3` (RIG_PTT_ON_DATA), N1MM/others may send
+ // `T 2` (on mic) or `T 1`; all must key. Only `T 0` unkeys.
+ // `T 1` (generic), `T 2` (on mic), and `T 3` (on data) all key, but each selects
+ // the matching transmit audio path on the wire. Only `T 0` unkeys.
+ for (arg, source) in [
+ ("1", PttSource::Generic),
+ ("2", PttSource::Mic),
+ ("3", PttSource::Data),
+ ] {
+ let (ctx, backend, _s) =
+ ctx_with(EndpointPermissions::from_tokens(&["read", "write", "ptt"]));
+ assert_eq!(
+ reply_of(&format!("T {arg}"), &ctx).await,
+ RPRT_OK.to_vec(),
+ "T {arg} should be accepted"
+ );
+ tokio::time::sleep(Duration::from_millis(20)).await;
+ assert_eq!(
+ backend.mutations(),
+ vec![StateMutation::SetPtt {
+ keyed: true,
+ source
+ }],
+ "T {arg} should key the radio with the matching source"
+ );
+ }
+
+ let (ctx, backend, _s) =
+ ctx_with(EndpointPermissions::from_tokens(&["read", "write", "ptt"]));
+ assert_eq!(reply_of("T 0", &ctx).await, RPRT_OK.to_vec());
+ tokio::time::sleep(Duration::from_millis(20)).await;
+ assert_eq!(
+ backend.mutations(),
+ vec![StateMutation::SetPtt {
+ keyed: false,
+ source: PttSource::Generic
+ }]
+ );
+ }
+
+ #[tokio::test]
+ async fn dump_state_and_chk_vfo_match_golden() {
+ let (ctx, _b, _s) = ctx_with(EndpointPermissions::read_only());
+ let dump = reply_of("\\dump_state", &ctx).await;
+ assert!(dump.starts_with(b"1\n"), "protocol version 1 first line");
+ assert!(dump.ends_with(b"done\n"));
+ assert_eq!(reply_of("\\chk_vfo", &ctx).await, b"0\n".to_vec());
+ assert_eq!(reply_of("\\get_powerstat", &ctx).await, b"1\n".to_vec());
+ }
+
+ #[tokio::test]
+ async fn quit_closes() {
+ let (ctx, _b, _s) = ctx_with(EndpointPermissions::read_only());
+ assert!(matches!(handle_line("q", &ctx).await, LineResult::Quit));
+ }
+
+ #[tokio::test]
+ async fn set_rit_applies_offset_when_capable() {
+ let (ctx, backend, state) = ctx_with(EndpointPermissions::from_tokens(&["read", "write"]));
+ assert_eq!(reply_of("\\set_rit 100", &ctx).await, RPRT_OK.to_vec());
+ tokio::time::sleep(Duration::from_millis(20)).await;
+ assert_eq!(
+ backend.mutations(),
+ vec![StateMutation::SetRit {
+ offset_hz: 100,
+ enabled: true,
+ }]
+ );
+ let snap = state.snapshot();
+ assert!(snap.rit_enabled);
+ assert_eq!(snap.rit_offset_hz, 100);
+ }
+
+ #[tokio::test]
+ async fn set_xit_applies_offset_when_capable() {
+ let (ctx, backend, state) = ctx_with(EndpointPermissions::from_tokens(&["read", "write"]));
+ assert_eq!(reply_of("Z -250", &ctx).await, RPRT_OK.to_vec());
+ tokio::time::sleep(Duration::from_millis(20)).await;
+ assert_eq!(
+ backend.mutations(),
+ vec![StateMutation::SetXit {
+ offset_hz: -250,
+ enabled: true,
+ }]
+ );
+ assert_eq!(state.snapshot().xit_offset_hz, -250);
+ }
+
+ #[tokio::test]
+ async fn set_rit_zero_offset_disables_rit() {
+ let (ctx, _b, state) = ctx_with(EndpointPermissions::from_tokens(&["read", "write"]));
+ assert_eq!(reply_of("\\set_rit 0", &ctx).await, RPRT_OK.to_vec());
+ tokio::time::sleep(Duration::from_millis(20)).await;
+ assert!(!state.snapshot().rit_enabled);
+ }
+
+ #[tokio::test]
+ async fn get_rit_and_xit_report_offsets() {
+ let (ctx, _b, state) = ctx_with(EndpointPermissions::read_only());
+ assert_eq!(reply_of("\\get_rit", &ctx).await, b"0\n".to_vec());
+ state.record(
+ StateChange::Rit {
+ enabled: true,
+ offset_hz: 250,
+ },
+ RadioEventSource::PollDiff,
+ );
+ state.record(
+ StateChange::Xit {
+ enabled: true,
+ offset_hz: -50,
+ },
+ RadioEventSource::PollDiff,
+ );
+ assert_eq!(reply_of("j", &ctx).await, b"250\n".to_vec());
+ assert_eq!(reply_of("z", &ctx).await, b"-50\n".to_vec());
+ }
+
+ #[tokio::test]
+ async fn rit_and_xit_report_not_available_without_capability() {
+ // A backend that does not model RIT/XIT must answer not-available.
+ let backend = LoopbackBackend::new();
+ let mut caps = backend.capabilities();
+ caps.has_rit = false;
+ caps.has_xit = false;
+ let arc: Arc = Arc::new(backend);
+ let state = StateHandle::new();
+ let radio = spawn_scheduler(arc, detached_link(), state.clone());
+ let ptt = PttManager::new(Duration::from_secs(300));
+ let ctx = ClientSessionContext::new(
+ 1,
+ EndpointPermissions::from_tokens(&["read", "write"]),
+ state,
+ radio,
+ ptt,
+ caps,
+ );
+ assert_eq!(reply_of("\\set_rit 100", &ctx).await, RPRT_ENAVAIL.to_vec());
+ assert_eq!(reply_of("\\set_xit 100", &ctx).await, RPRT_ENAVAIL.to_vec());
+ }
+
+ // --- Extended Response Protocol (Log4OM-NG) ---
+
+ #[tokio::test]
+ async fn erp_set_vfo_query_lists_supported_vfos() {
+ // Log4OM-NG opens every session with `;V ?` (extended set_vfo query). The reply
+ // must echo the command, list the supported VFOs (newline before RPRT, matching
+ // real rigctld), and end with RPRT 0 - all `;`-separated on one logical block.
+ let (ctx, _b, _s) = ctx_with(EndpointPermissions::from_tokens(&["read", "write"]));
+ assert_eq!(
+ reply_of(";V ?", &ctx).await,
+ b"set_vfo: ?;VFOA VFOB \nRPRT 0\n".to_vec()
+ );
+ }
+
+ #[tokio::test]
+ async fn erp_get_vfo_info_reports_active_vfo_block() {
+ // Log4OM-NG polls with `+\get_vfo_info VFOA` (newline-separated extended form).
+ let (ctx, _b, state) = ctx_with(EndpointPermissions::from_tokens(&["read", "write"]));
+ state.record(
+ StateChange::Freq {
+ vfo: Vfo::A,
+ hz: 14_074_000,
+ },
+ RadioEventSource::PollDiff,
+ );
+ state.record(
+ StateChange::Mode {
+ vfo: Vfo::A,
+ mode: Mode::Cw,
+ },
+ RadioEventSource::PollDiff,
+ );
+ assert_eq!(
+ reply_of("+\\get_vfo_info VFOA", &ctx).await,
+ b"get_vfo_info: VFOA\nFreq: 14074000\nMode: CW\nWidth: 2400\nSplit: 0\nSatMode: 0\nRPRT 0\n"
+ .to_vec()
+ );
+ }
+
+ #[tokio::test]
+ async fn plain_get_vfo_info_reports_bare_values() {
+ // The non-extended form returns just the values (Freq, Mode, Width, Split, SatMode).
+ let (ctx, _b, state) = ctx_with(EndpointPermissions::read_only());
+ state.record(
+ StateChange::Freq {
+ vfo: Vfo::A,
+ hz: 7_030_000,
+ },
+ RadioEventSource::PollDiff,
+ );
+ state.record(
+ StateChange::Mode {
+ vfo: Vfo::A,
+ mode: Mode::Cw,
+ },
+ RadioEventSource::PollDiff,
+ );
+ assert_eq!(
+ reply_of("\\get_vfo_info VFOA", &ctx).await,
+ b"7030000\nCW\n2400\n0\n0\n".to_vec()
+ );
+ }
+
+ #[tokio::test]
+ async fn erp_generic_set_echoes_command_then_rprt() {
+ // A generic extended set (e.g. `+F`) echoes `set_freq: ` then `RPRT 0`,
+ // newline-separated for the `+` separator.
+ let (ctx, _b, _s) = ctx_with(EndpointPermissions::from_tokens(&["read", "write"]));
+ assert_eq!(
+ reply_of("+F 14200000", &ctx).await,
+ b"set_freq: 14200000\nRPRT 0\n".to_vec()
+ );
+ }
+
+ #[tokio::test]
+ async fn erp_set_vfo_with_arg_echoes_on_one_line() {
+ // `;V VFOA` selects the active VFO (never retargets) and replies on one line.
+ let (ctx, _b, _s) = ctx_with(EndpointPermissions::from_tokens(&["read", "write"]));
+ assert_eq!(
+ reply_of(";V VFOA", &ctx).await,
+ b"set_vfo: VFOA;RPRT 0\n".to_vec()
+ );
+ }
+
+ #[tokio::test]
+ async fn erp_labeled_get_freq_single_line() {
+ // `;f` returns a single-line labeled extended response.
+ let (ctx, _b, state) = ctx_with(EndpointPermissions::read_only());
+ state.record(
+ StateChange::Freq {
+ vfo: Vfo::A,
+ hz: 14_074_000,
+ },
+ RadioEventSource::PollDiff,
+ );
+ assert_eq!(
+ reply_of(";f", &ctx).await,
+ b"get_freq:;Frequency: 14074000;RPRT 0\n".to_vec()
+ );
+ }
+
+ #[tokio::test]
+ async fn erp_get_split_terminates_with_rprt() {
+ // `\get_split_vfo` returns data only in the plain protocol. Through the ERP generic
+ // fallback it must still end with an `RPRT` record so an ERP client (Log4OM-NG)
+ // reading until the terminator does not block.
+ let (ctx, _b, _s) = ctx_with(EndpointPermissions::read_only());
+ assert_eq!(
+ reply_of("+\\get_split_vfo", &ctx).await,
+ b"get_split_vfo:\n0\nVFOA\nRPRT 0\n".to_vec()
+ );
+ }
+
+ #[tokio::test]
+ async fn set_mode_rejects_unknown_token() {
+ // An unrecognized mode token must be rejected (RPRT -1), never silently applied as
+ // USB with a false `RPRT 0`.
+ let (ctx, backend, _s) = ctx_with(EndpointPermissions::from_tokens(&["read", "write"]));
+ assert_eq!(reply_of("M WAT", &ctx).await, RPRT_EINVAL.to_vec());
+ // And nothing was written to the radio.
+ assert!(
+ backend.mutations().is_empty(),
+ "an unknown mode must not mutate the radio"
+ );
+ }
+
+ #[tokio::test]
+ async fn dropping_a_keyed_hamlib_client_releases_the_ptt_lease() {
+ // A rigctld client keys PTT then its TCP connection drops. The hub must release the
+ // lease immediately, not hold the transmitter up until the safety ceiling.
+ let backend = LoopbackBackend::new();
+ let caps = backend.capabilities();
+ let arc: Arc = Arc::new(backend);
+ let state = StateHandle::new();
+ let radio = spawn_scheduler(arc, detached_link(), state.clone());
+ let ptt = PttManager::new(Duration::from_secs(300));
+ let perms = EndpointPermissions::from_tokens(&["read", "ptt"]);
+ let ctx = ClientSessionContext::new(7, perms, state.clone(), radio, ptt.clone(), caps);
+ let (client, server) = tokio::io::duplex(1024);
+ let handle = tokio::spawn(serve_conn(server, ctx));
+
+ let (mut cr, mut cw) = tokio::io::split(client);
+ cw.write_all(b"T 1\n").await.expect("write");
+ // Drain the RPRT reply so the key has been processed.
+ let mut buf = [0u8; 32];
+ let _ = tokio::time::timeout(Duration::from_secs(2), cr.read(&mut buf)).await;
+ for _ in 0..50 {
+ if ptt.owner() == Some(7) {
+ break;
+ }
+ tokio::time::sleep(Duration::from_millis(10)).await;
+ }
+ assert_eq!(ptt.owner(), Some(7), "client should hold the PTT lease");
+
+ // Drop the transport.
+ drop(cr);
+ drop(cw);
+ let _ = tokio::time::timeout(Duration::from_secs(2), handle).await;
+ assert_eq!(
+ ptt.owner(),
+ None,
+ "PTT lease must release when a keyed rigctld client disconnects"
+ );
+ }
+
+ #[tokio::test]
+ async fn overlong_request_without_newline_closes_the_connection() {
+ // A client that streams bytes without ever sending a newline must not grow the
+ // line buffer without bound; the hub caps it and closes the connection.
+ let backend = LoopbackBackend::new();
+ let caps = backend.capabilities();
+ let arc: Arc = Arc::new(backend);
+ let state = StateHandle::new();
+ let radio = spawn_scheduler(arc, detached_link(), state.clone());
+ let ptt = PttManager::new(Duration::from_secs(300));
+ let perms = EndpointPermissions::from_tokens(&["read"]);
+ let ctx = ClientSessionContext::new(9, perms, state.clone(), radio, ptt.clone(), caps);
+ let (client, server) = tokio::io::duplex(8192);
+ let handle = tokio::spawn(serve_conn(server, ctx));
+
+ let (_cr, mut cw) = tokio::io::split(client);
+ // Send well over the cap with no newline. Ignore write errors that occur once the
+ // server side has already closed.
+ let flood = vec![b'f'; MAX_LINE_LEN + 256];
+ let _ = cw.write_all(&flood).await;
+
+ // The serve task must terminate (connection closed) rather than hang or OOM.
+ let joined = tokio::time::timeout(Duration::from_secs(2), handle).await;
+ assert!(
+ joined.is_ok(),
+ "serve_conn must close the connection on an overlong line"
+ );
+ }
+
+ // --- single-VFO virtualization (N1MM SO1V, Log4OM) ---
+
+ #[tokio::test]
+ async fn single_vfo_erp_get_vfo_info_follows_operating_vfo_as_vfoa() {
+ // Log4OM bug: it polls `+\get_vfo_info VFOA` and must see the OPERATING VFO (B),
+ // presented as VFOA, with split hidden - not stale physical VFO A.
+ let (ctx, _b, state) = ctx_single_vfo(EndpointPermissions::from_tokens(&["read", "write"]));
+ operating_on_b(&state);
+ assert_eq!(
+ reply_of("+\\get_vfo_info VFOA", &ctx).await,
+ b"get_vfo_info: VFOA\nFreq: 14074000\nMode: USB\nWidth: 2400\nSplit: 0\nSatMode: 0\nRPRT 0\n"
+ .to_vec()
+ );
+ }
+
+ #[tokio::test]
+ async fn single_vfo_plain_get_vfo_info_follows_operating_vfo() {
+ let (ctx, _b, state) = ctx_single_vfo(EndpointPermissions::read_only());
+ operating_on_b(&state);
+ // Bare values: operating VFO B's freq/mode/width, split forced to 0.
+ assert_eq!(
+ reply_of("\\get_vfo_info VFOA", &ctx).await,
+ b"14074000\nUSB\n2400\n0\n0\n".to_vec()
+ );
+ }
+
+ #[tokio::test]
+ async fn single_vfo_get_vfo_always_reports_vfoa() {
+ let (ctx, _b, state) = ctx_single_vfo(EndpointPermissions::read_only());
+ operating_on_b(&state);
+ // Even operating on physical B, a single-VFO endpoint claims VFOA.
+ assert_eq!(reply_of("v", &ctx).await, b"VFOA\n".to_vec());
+ }
+
+ #[tokio::test]
+ async fn single_vfo_get_split_vfo_hides_split() {
+ let (ctx, _b, state) = ctx_single_vfo(EndpointPermissions::read_only());
+ operating_on_b(&state);
+ // Radio is split, but a single-VFO endpoint reports no split and TX on VFOA.
+ assert_eq!(reply_of("s", &ctx).await, b"0\nVFOA\n".to_vec());
+ }
+
+ #[tokio::test]
+ async fn single_vfo_split_freq_and_mode_are_unavailable() {
+ let (ctx, _b, state) = ctx_single_vfo(EndpointPermissions::read_only());
+ operating_on_b(&state);
+
+ assert_eq!(reply_of("i", &ctx).await, RPRT_ENAVAIL.to_vec());
+ assert_eq!(reply_of("x", &ctx).await, RPRT_ENAVAIL.to_vec());
+ }
+
+ #[tokio::test]
+ async fn single_vfo_erp_set_vfo_query_lists_only_vfoa() {
+ let (ctx, _b, _s) = ctx_single_vfo(EndpointPermissions::from_tokens(&["read", "write"]));
+ assert_eq!(
+ reply_of(";V ?", &ctx).await,
+ b"set_vfo: ?;VFOA \nRPRT 0\n".to_vec()
+ );
+ }
+
+ #[tokio::test]
+ async fn single_vfo_set_split_on_is_rejected_without_mutating_radio() {
+ let (ctx, backend, _s) =
+ ctx_single_vfo(EndpointPermissions::from_tokens(&["read", "write"]));
+ // A single-VFO client must never desync the radio's real split state. "Split on"
+ // is rejected (RPRT_ENAVAIL) so WSJT-X falls back to "Fake It"; "split off" is an
+ // accepted no-op. Neither path mutates the real radio.
+ assert_eq!(reply_of("S 1 VFOB", &ctx).await, RPRT_ENAVAIL.to_vec());
+ assert_eq!(reply_of("S 0 VFOA", &ctx).await, RPRT_OK.to_vec());
+ tokio::time::sleep(Duration::from_millis(20)).await;
+ assert!(
+ backend.mutations().is_empty(),
+ "single-VFO set_split_vfo must not mutate real radio split"
+ );
+ }
+
+ #[tokio::test]
+ async fn single_vfo_read_only_set_split_is_denied() {
+ let (ctx, _b, _s) = ctx_single_vfo(EndpointPermissions::read_only());
+ assert_eq!(reply_of("S 1 VFOB", &ctx).await, RPRT_EINVAL.to_vec());
+ }
+
+ #[tokio::test]
+ async fn dual_vfo_get_vfo_info_still_reports_literal_physical_vfo() {
+ // Regression guard: a non-single-VFO endpoint (e.g. the engine endpoint) keeps the
+ // faithful dual-VFO view - `get_vfo_info VFOA` returns physical VFO A even when
+ // operating on B, and real split is reported.
+ let (ctx, _b, state) = ctx_with(EndpointPermissions::from_tokens(&["read", "write"]));
+ operating_on_b(&state);
+ assert_eq!(
+ reply_of("+\\get_vfo_info VFOA", &ctx).await,
+ b"get_vfo_info: VFOA\nFreq: 14035000\nMode: CW\nWidth: 2400\nSplit: 1\nSatMode: 0\nRPRT 0\n"
+ .to_vec()
+ );
+ // And VFOB explicitly is still addressable.
+ assert_eq!(
+ reply_of("+\\get_vfo_info VFOB", &ctx).await,
+ b"get_vfo_info: VFOB\nFreq: 14074000\nMode: USB\nWidth: 2400\nSplit: 1\nSatMode: 0\nRPRT 0\n"
+ .to_vec()
+ );
+ }
+}
diff --git a/crates/cathub/src/integration.rs b/crates/cathub/src/integration.rs
new file mode 100644
index 0000000..f052501
--- /dev/null
+++ b/crates/cathub/src/integration.rs
@@ -0,0 +1,483 @@
+//! Cross-module integration tests (design §10.2).
+//!
+//! These bring up the full stack - universal state, the priority scheduler over a
+//! [`LoopbackBackend`], multiple serial endpoints over `tokio::io::duplex`, and the Hamlib net
+//! endpoint over real TCP - and assert the system-level invariants: one radio command per poll
+//! (reads are served from cache), no VFO-target traffic from a TS-2000 endpoint, cross-endpoint
+//! write visibility, front-panel fan-out, strict write ordering, PTT arbitration, and that
+//! the Hamlib net endpoint and a serial endpoint share one radio state.
+//!
+//! Binary crates cannot host `tests/` integration crates, so these live in-crate behind
+//! `#[cfg(test)]` to reach the `pub(crate)` surface.
+
+#![allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)]
+
+use std::sync::Arc;
+use std::time::Duration;
+
+use tokio::io::{AsyncReadExt, AsyncWriteExt, DuplexStream};
+use tokio::net::{TcpListener, TcpStream};
+
+use crate::backend::loopback::LoopbackBackend;
+use crate::backend::{BackendCapabilities, RadioBackend};
+use crate::dialect::kenwood::ts2000::Ts2000Dialect;
+use crate::dialect::kenwood::ts590::Ts590Dialect;
+use crate::dialect::{ClientDialect, ClientSessionContext};
+use crate::hamlib_net::serve_conn;
+use crate::model::{RadioEventSource, StateChange, StateMutation, Vfo};
+use crate::permissions::EndpointPermissions;
+use crate::ptt::PttManager;
+use crate::radio::{detached_link, spawn_scheduler, OpKind, Priority, RadioHandle};
+use crate::serial_endpoint::run_endpoint_session;
+use crate::state::StateHandle;
+
+/// A wired-up radio: shared loopback backend, state, scheduler and PTT lease.
+struct Rig {
+ backend: LoopbackBackend,
+ state: StateHandle,
+ radio: RadioHandle,
+ ptt: PttManager,
+ caps: BackendCapabilities,
+}
+
+fn rig() -> Rig {
+ let backend = LoopbackBackend::new();
+ let caps = backend.capabilities();
+ let arc: Arc = Arc::new(backend.clone());
+ let state = StateHandle::new();
+ let radio = spawn_scheduler(arc, detached_link(), state.clone());
+ let ptt = PttManager::new(Duration::from_secs(300));
+ Rig {
+ backend,
+ state,
+ radio,
+ ptt,
+ caps,
+ }
+}
+
+impl Rig {
+ fn session(
+ &self,
+ dialect: Arc,
+ perms: EndpointPermissions,
+ id: u64,
+ ) -> DuplexStream {
+ let ctx = ClientSessionContext::new(
+ id,
+ perms,
+ self.state.clone(),
+ self.radio.clone(),
+ self.ptt.clone(),
+ self.caps.clone(),
+ );
+ let (client, server) = tokio::io::duplex(1024);
+ tokio::spawn(run_endpoint_session(server, dialect, ctx, b';'));
+ client
+ }
+
+ /// A session on a single-VFO endpoint (e.g. N1MM in SO1V) that presents the operating VFO as VFO A.
+ fn single_vfo_session(
+ &self,
+ dialect: Arc,
+ perms: EndpointPermissions,
+ id: u64,
+ ) -> DuplexStream {
+ let ctx = ClientSessionContext::new(
+ id,
+ perms,
+ self.state.clone(),
+ self.radio.clone(),
+ self.ptt.clone(),
+ self.caps.clone(),
+ )
+ .with_single_vfo(true);
+ let (client, server) = tokio::io::duplex(1024);
+ tokio::spawn(run_endpoint_session(server, dialect, ctx, b';'));
+ client
+ }
+}
+
+fn ts590() -> Arc {
+ Arc::new(Ts590Dialect::new())
+}
+
+fn ts2000() -> Arc {
+ Arc::new(Ts2000Dialect::new())
+}
+
+/// Send `cmd` and read one `;`-terminated reply frame, failing fast on a hang.
+async fn request(client: &mut DuplexStream, cmd: &[u8]) -> Vec {
+ client.write_all(cmd).await.expect("write");
+ read_frame(client).await
+}
+
+async fn read_frame(client: &mut DuplexStream) -> Vec {
+ let mut frame = Vec::new();
+ let mut byte = [0u8; 1];
+ loop {
+ let n = tokio::time::timeout(Duration::from_secs(2), client.read(&mut byte))
+ .await
+ .expect("reply did not arrive")
+ .expect("read");
+ if n == 0 {
+ break;
+ }
+ frame.push(byte[0]);
+ if byte[0] == b';' {
+ break;
+ }
+ }
+ frame
+}
+
+/// A write on one endpoint is visible to a read on another endpoint on the same radio.
+#[tokio::test]
+async fn write_from_one_endpoint_visible_on_another() {
+ let rig = rig();
+ let mut writer_session = rig.session(
+ ts590(),
+ EndpointPermissions::from_tokens(&["read", "write"]),
+ 1,
+ );
+ let mut reader_session = rig.session(ts2000(), EndpointPermissions::read_only(), 2);
+
+ // Set VFO A frequency from the native (N1MM-style) endpoint.
+ writer_session
+ .write_all(b"FA00007050000;")
+ .await
+ .expect("write");
+ // Let the scheduler apply the write before reading from the other endpoint.
+ tokio::time::sleep(Duration::from_millis(30)).await;
+
+ let reply = request(&mut reader_session, b"FA;").await;
+ assert_eq!(reply, b"FA00007050000;");
+}
+
+/// Modeled reads are served from the cache: no client read ever reaches the backend, so a
+/// flurry of reads from many endpoints adds zero real-radio commands.
+#[tokio::test]
+async fn session_reads_are_served_from_cache_not_the_backend() {
+ let rig = rig();
+ let mut a = rig.session(ts590(), EndpointPermissions::read_only(), 1);
+ let mut b = rig.session(ts2000(), EndpointPermissions::read_only(), 2);
+
+ for _ in 0..20 {
+ let _ = request(&mut a, b"FA;").await;
+ let _ = request(&mut b, b"FA;").await;
+ }
+
+ assert_eq!(rig.backend.poll_count(), 0, "reads must not poll the radio");
+ assert!(
+ rig.backend.mutations().is_empty(),
+ "reads must not mutate the radio"
+ );
+ assert!(
+ rig.backend.passthroughs().is_empty(),
+ "modeled reads must not passthrough"
+ );
+}
+
+/// A TS-2000 (OmniRig/HDSDR) endpoint never emits VFO-target traffic: status reads come from
+/// cache and a VFO-target write is rejected, never forwarded (the §8.8 invariant).
+#[tokio::test]
+async fn ts2000_endpoint_never_retargets_vfo() {
+ let rig = rig();
+ let mut omni = rig.session(ts2000(), EndpointPermissions::read_only(), 1);
+
+ let _ = request(&mut omni, b"IF;").await;
+ let _ = request(&mut omni, b"FA;").await;
+ let _ = request(&mut omni, b"FB;").await;
+ // OmniRig issuing an FR (RX VFO select) write must be rejected, not forwarded.
+ let reply = request(&mut omni, b"FR1;").await;
+ assert_eq!(reply, b"?;");
+
+ assert!(
+ rig.backend.mutations().is_empty(),
+ "no status read or VFO-select should mutate the radio"
+ );
+ assert!(rig.backend.passthroughs().is_empty());
+}
+
+/// HDSDR/OmniRig click-to-tune uses the TS-2000 endpoint's FA write as "set the displayed
+/// active frequency". When the real radio is on VFO B, that write must tune VFO B without
+/// emitting a VFO-retarget command.
+#[tokio::test]
+async fn ts2000_endpoint_fa_write_tunes_active_vfo_b() {
+ let rig = rig();
+ let mut omni = rig.session(
+ ts2000(),
+ EndpointPermissions::from_tokens(&["read", "write"]),
+ 1,
+ );
+ rig.state.record(
+ StateChange::RxVfo { vfo: Vfo::B },
+ RadioEventSource::NativePush,
+ );
+
+ omni.write_all(b"FA00014074000;").await.expect("write");
+ tokio::time::sleep(Duration::from_millis(30)).await;
+
+ assert_eq!(
+ rig.backend.mutations(),
+ vec![StateMutation::SetVfoFreq {
+ vfo: Vfo::B,
+ hz: 14_074_000
+ }]
+ );
+ assert!(rig.backend.passthroughs().is_empty());
+}
+
+/// HDSDR can issue waterfall click-to-tune writes through `FB` while the radio is receiving
+/// on VFO A. The TS-2000 translator must treat that as a displayed-frequency write, not as
+/// a request to silently tune inactive physical VFO B.
+#[tokio::test]
+async fn ts2000_endpoint_fb_write_tunes_active_vfo_a() {
+ let rig = rig();
+ let mut omni = rig.session(
+ ts2000(),
+ EndpointPermissions::from_tokens(&["read", "write"]),
+ 1,
+ );
+ rig.state.record(
+ StateChange::RxVfo { vfo: Vfo::A },
+ RadioEventSource::NativePush,
+ );
+
+ omni.write_all(b"FB00014052000;").await.expect("write");
+ tokio::time::sleep(Duration::from_millis(30)).await;
+
+ assert_eq!(
+ rig.backend.mutations(),
+ vec![StateMutation::SetVfoFreq {
+ vfo: Vfo::A,
+ hz: 14_052_000
+ }]
+ );
+ assert!(rig.backend.passthroughs().is_empty());
+}
+
+/// HDSDR/OmniRig polls `IF;`, `FA;`, and `FB;`. On active VFO B, `FA;` must report the
+/// displayed active frequency, not stale inactive VFO A, because HDSDR treats FA as its main
+/// panadapter frequency.
+#[tokio::test]
+async fn ts2000_endpoint_fa_read_reports_active_vfo_b() {
+ let rig = rig();
+ let mut omni = rig.session(ts2000(), EndpointPermissions::read_only(), 1);
+ rig.state.record(
+ StateChange::Freq {
+ vfo: Vfo::A,
+ hz: 14_062_820,
+ },
+ RadioEventSource::NativePush,
+ );
+ rig.state.record(
+ StateChange::Freq {
+ vfo: Vfo::B,
+ hz: 14_074_000,
+ },
+ RadioEventSource::NativePush,
+ );
+ rig.state.record(
+ StateChange::RxVfo { vfo: Vfo::B },
+ RadioEventSource::NativePush,
+ );
+
+ assert!(request(&mut omni, b"IF;")
+ .await
+ .starts_with(b"IF00014074000"));
+ assert_eq!(request(&mut omni, b"FA;").await, b"FA00014074000;");
+ assert_eq!(request(&mut omni, b"FB;").await, b"FB00014074000;");
+}
+
+/// A simulated front-panel change (a poll-diff from the backend's truth) fans out to every
+/// auto-info-subscribed endpoint without any client having polled.
+#[tokio::test]
+async fn front_panel_change_fans_out_to_all_subscribed_sessions() {
+ let rig = rig();
+ let mut a = rig.session(ts590(), EndpointPermissions::read_only(), 1);
+ let mut b = rig.session(ts590(), EndpointPermissions::read_only(), 2);
+
+ // Both sessions turn on virtualized auto-info.
+ a.write_all(b"AI2;").await.expect("write");
+ b.write_all(b"AI2;").await.expect("write");
+ tokio::time::sleep(Duration::from_millis(20)).await;
+
+ // Operator turns the knob: backend truth changes, then a poll diffs it into state.
+ rig.backend.set_truth_freq_a(7_123_000);
+ rig.radio
+ .submit(0, Priority::Poll, OpKind::Poll)
+ .await
+ .expect("poll");
+
+ assert_eq!(read_frame(&mut a).await, b"FA00007123000;");
+ assert_eq!(read_frame(&mut b).await, b"FA00007123000;");
+}
+
+/// End-to-end N1MM SO1V regression: when the operator switches the radio to VFO B, a
+/// single-VFO endpoint must keep tracking. The hub re-presents the operating VFO as VFO A, so
+/// the endpoint receives an `FA`(operating freq) + `MD` + `IF` (with the active-VFO digit '0')
+/// and never an `FB` or an `IF` reporting VFO B (which trips N1MM's "do not use VFO B"
+/// guard and freezes its frequency display).
+#[tokio::test]
+async fn single_vfo_session_tracks_an_a_to_b_switch_without_leaking_vfo_b() {
+ let rig = rig();
+ let mut n1mm = rig.single_vfo_session(ts590(), EndpointPermissions::read_only(), 1);
+
+ // N1MM enables auto-info.
+ n1mm.write_all(b"AI2;").await.expect("write");
+ tokio::time::sleep(Duration::from_millis(20)).await;
+
+ // The radio already knows VFO B's frequency and mode (primed by the initial poll).
+ rig.state.record(
+ StateChange::Freq {
+ vfo: Vfo::B,
+ hz: 14_034_320,
+ },
+ RadioEventSource::PollDiff,
+ );
+ rig.state.record(
+ StateChange::Mode {
+ vfo: Vfo::B,
+ mode: crate::model::Mode::Cw,
+ },
+ RadioEventSource::PollDiff,
+ );
+
+ // Operator switches the radio to VFO B.
+ rig.state.record(
+ StateChange::RxVfo { vfo: Vfo::B },
+ RadioEventSource::PollDiff,
+ );
+
+ // Collect the re-presentation push (FA + MD + IF) and assert it never exposes VFO B.
+ let mut pushed = Vec::new();
+ for _ in 0..3 {
+ let frame = match tokio::time::timeout(Duration::from_secs(1), read_frame(&mut n1mm)).await
+ {
+ Ok(frame) if !frame.is_empty() => frame,
+ _ => break,
+ };
+ pushed.extend_from_slice(&frame);
+ if pushed.windows(2).any(|w| w == b"IF") {
+ break;
+ }
+ }
+
+ assert!(
+ pushed
+ .windows(b"FA00014034320;".len())
+ .any(|w| w == b"FA00014034320;"),
+ "the switch must push the operating frequency as FA; got {}",
+ String::from_utf8_lossy(&pushed)
+ );
+ assert!(
+ !pushed.windows(2).any(|w| w == b"FB"),
+ "a single-VFO endpoint must never receive an FB frame; got {}",
+ String::from_utf8_lossy(&pushed)
+ );
+ let if_pos = pushed
+ .windows(2)
+ .position(|w| w == b"IF")
+ .expect("the push must include an IF frame");
+ assert_eq!(
+ pushed[if_pos + 30],
+ b'0',
+ "the IF frame must present the operating VFO as VFO A (P10 == '0')"
+ );
+}
+
+/// Writes fanned in from several client sessions are strictly serialized through the one radio task.
+#[tokio::test]
+async fn writes_from_all_sessions_are_strictly_ordered() {
+ let rig = rig();
+ let perms = EndpointPermissions::from_tokens(&["read", "write"]);
+ let mut f1 = rig.session(ts590(), perms, 1);
+ let mut f2 = rig.session(ts590(), perms, 2);
+ let mut f3 = rig.session(ts590(), perms, 3);
+
+ f1.write_all(b"FA00007010000;").await.expect("w1");
+ f2.write_all(b"FA00007020000;").await.expect("w2");
+ f3.write_all(b"FA00007030000;").await.expect("w3");
+ tokio::time::sleep(Duration::from_millis(50)).await;
+
+ let muts = rig.backend.mutations();
+ assert_eq!(muts.len(), 3, "every write reaches the radio exactly once");
+}
+
+/// PTT is a single-owner lease: the first capable endpoint keys, a second is refused while the
+/// lease is held, and the lease frees on `RX;` so the second endpoint can then key.
+#[tokio::test]
+async fn ptt_lease_is_arbitrated_across_sessions() {
+ let rig = rig();
+ let perms = EndpointPermissions::from_tokens(&["read", "write", "ptt"]);
+ let mut f1 = rig.session(ts590(), perms, 1);
+ let mut f2 = rig.session(ts590(), perms, 2);
+
+ // f1 keys: a Kenwood set has no positive reply, so nothing comes back.
+ f1.write_all(b"TX;").await.expect("tx1");
+ tokio::time::sleep(Duration::from_millis(20)).await;
+
+ // f2 tries to key while f1 holds the lease: rejected with `?;`.
+ assert_eq!(request(&mut f2, b"TX;").await, b"?;");
+
+ // f1 unkeys, releasing the lease.
+ f1.write_all(b"RX;").await.expect("rx1");
+ tokio::time::sleep(Duration::from_millis(20)).await;
+
+ // f2 can now acquire it (no error reply).
+ f2.write_all(b"TX;").await.expect("tx2");
+ tokio::time::sleep(Duration::from_millis(20)).await;
+ assert_eq!(rig.ptt.owner(), Some(2));
+}
+
+/// The Hamlib net endpoint (engine / WSJT-X) 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() {
+ let rig = rig();
+
+ // A native serial endpoint that can write (N1MM-style).
+ let mut n1mm = rig.session(
+ ts590(),
+ EndpointPermissions::from_tokens(&["read", "write"]),
+ 1,
+ );
+
+ // A read-only Hamlib net endpoint (the QsoRipper engine).
+ let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
+ let addr = listener.local_addr().expect("addr");
+ let ro_ctx = ClientSessionContext::new(
+ 2,
+ EndpointPermissions::read_only(),
+ rig.state.clone(),
+ rig.radio.clone(),
+ rig.ptt.clone(),
+ rig.caps.clone(),
+ );
+ tokio::spawn(async move {
+ let (stream, _) = listener.accept().await.expect("accept");
+ serve_conn(stream, ro_ctx).await;
+ });
+
+ // Set the frequency from the serial endpoint.
+ n1mm.write_all(b"FA00014250000;").await.expect("write");
+ tokio::time::sleep(Duration::from_millis(30)).await;
+
+ // The engine reads it back over the Hamlib net protocol (`f` => bare Hz).
+ let mut engine = TcpStream::connect(addr).await.expect("connect");
+ engine.write_all(b"f\n").await.expect("f");
+ let mut buf = vec![0u8; 64];
+ let n = engine.read(&mut buf).await.expect("read");
+ let text = String::from_utf8_lossy(&buf[..n]);
+ assert_eq!(text.trim(), "14250000");
+
+ // The read-only endpoint rejects a set-frequency write.
+ engine.write_all(b"F 7000000\n").await.expect("F");
+ let n = engine.read(&mut buf).await.expect("read");
+ let text = String::from_utf8_lossy(&buf[..n]);
+ assert!(
+ text.starts_with("RPRT -"),
+ "read-only endpoint rejects F: {text}"
+ );
+}
diff --git a/crates/cathub/src/lib.rs b/crates/cathub/src/lib.rs
new file mode 100644
index 0000000..036d302
--- /dev/null
+++ b/crates/cathub/src/lib.rs
@@ -0,0 +1,721 @@
+//! 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
+//! their native protocols. It serializes every write, owns the radio's native push stream,
+//! serves reads from a universal cache, arbitrates PTT with a single-owner lease, and never
+//! retargets a VFO during polling - eliminating the A/B oscillation, frequency drift, and
+//! transmit conflicts that come from many apps fighting over one serial port.
+//!
+//! See `docs/design/multi-client-cat-hub.md` for the full design.
+
+#![allow(clippy::doc_markdown)]
+
+/// Generated protobuf and gRPC bindings for the loopback WinKeyer broker API.
+#[allow(missing_docs, unreachable_pub, clippy::all, clippy::pedantic)]
+/// Public WinKeyer broker protocol types.
+pub mod broker_proto {
+ pub use cathub_protocol::*;
+}
+
+mod backend;
+mod config;
+mod dialect;
+mod error;
+mod events;
+mod hamlib_net;
+mod logging;
+mod model;
+mod permissions;
+mod ptt;
+mod radio;
+mod serial_endpoint;
+mod state;
+mod winkeyer;
+
+#[cfg(test)]
+mod integration;
+
+use std::path::PathBuf;
+use std::sync::atomic::{AtomicU64, Ordering};
+use std::sync::Arc;
+use std::time::Duration;
+
+use clap::{Parser, Subcommand, ValueEnum};
+use tokio::net::TcpStream;
+use tracing_appender::non_blocking::WorkerGuard;
+
+use crate::backend::kenwood::ts590::Ts590Backend;
+use crate::backend::loopback::LoopbackBackend;
+use crate::backend::rigctld::RigctldBackend;
+use crate::backend::{BackendError, RadioBackend};
+use crate::config::{migrate_to_standalone, Config, RadioConfig};
+use crate::dialect::kenwood::transparent::TransparentTs590Dialect;
+use crate::dialect::kenwood::ts2000::Ts2000Dialect;
+use crate::dialect::kenwood::ts590::Ts590Dialect;
+use crate::dialect::{ClientDialect, ClientSessionContext};
+use crate::events::{spawn_poller, POLLER_SESSION};
+use crate::hamlib_net::run_listener;
+use crate::model::StateMutation;
+use crate::ptt::PttManager;
+use crate::radio::{
+ link_channel, run_transport_supervised, spawn_scheduler, OpKind, Priority, RECONNECT_INITIAL,
+ RECONNECT_MAX,
+};
+use crate::serial_endpoint::{open_serial, run_endpoint_session};
+use crate::state::StateHandle;
+use crate::winkeyer::{
+ bind_server as bind_winkeyer_server, open_serial_endpoint as open_winkeyer_endpoint,
+ run_serial_endpoint as run_winkeyer_endpoint, spawn_supervised as spawn_winkeyer,
+ BrokerHandle as WinkeyerBrokerHandle, EndpointPermissions as WinkeyerEndpointPermissions,
+};
+
+pub use crate::error::CatHubError;
+
+/// Validate that a unified `config.toml` body contains a `[cat_hub]` section the
+/// cathub daemon will accept. This is exposed for the QsoRipper engine's setup
+/// wizard tests so a regression in the engine's CAT hub writer is caught against
+/// the daemon's real parser/validator rather than a hand-maintained copy.
+///
+/// # Errors
+///
+/// Returns a [`CatHubError`] if the document cannot be parsed or the resulting
+/// `[cat_hub]` configuration fails the daemon's semantic validation.
+#[doc(hidden)]
+pub fn validate_cat_hub_toml(text: &str) -> Result<(), CatHubError> {
+ Config::parse_document(text)
+ .map(|_| ())
+ .map_err(CatHubError::Config)
+}
+
+/// Command-line arguments.
+#[derive(Debug, Parser)]
+#[command(
+ name = "cathub",
+ version,
+ about = "Share one radio and WinKeyer safely across multiple applications"
+)]
+pub struct Cli {
+ /// Path to the configuration file (defaults to the platform config path).
+ #[arg(short, long, global = true)]
+ pub config: Option,
+ /// Explicit top-level section to read from a managed configuration file.
+ #[arg(long, global = true)]
+ pub section: Option,
+ /// Optional explicit log file path (informational; logging also writes a rolling file).
+ #[arg(long)]
+ pub log: Option,
+ /// Load and validate the configuration, print it, and exit without touching hardware.
+ #[arg(long)]
+ pub dry_run: bool,
+ /// Standalone configuration operations.
+ #[command(subcommand)]
+ pub command: Option,
+}
+
+/// Top-level CatHub commands.
+#[derive(Debug, Subcommand)]
+pub enum Command {
+ /// Inspect, validate, or migrate CatHub configuration.
+ Config {
+ /// Configuration operation to perform.
+ #[command(subcommand)]
+ command: ConfigCommand,
+ },
+}
+
+/// CatHub configuration commands.
+#[derive(Debug, Subcommand)]
+pub enum ConfigCommand {
+ /// Validate a standalone or embedded `[cat_hub]` configuration.
+ Validate {
+ /// Select text or machine-readable JSON output.
+ #[arg(long, value_enum, default_value_t = OutputFormat::Text)]
+ format: OutputFormat,
+ },
+ /// Print the validated effective configuration in standalone form.
+ PrintEffective {
+ /// Select TOML text or machine-readable JSON output.
+ #[arg(long, value_enum, default_value_t = OutputFormat::Text)]
+ format: OutputFormat,
+ },
+ /// Extract `[cat_hub]` from a unified file into a standalone file.
+ Migrate {
+ /// Unified QsoRipper configuration containing `[cat_hub]`.
+ #[arg(long)]
+ from: PathBuf,
+ /// Destination standalone CatHub TOML file.
+ #[arg(long)]
+ output: PathBuf,
+ /// Replace an existing destination after preserving a `.bak` copy.
+ #[arg(long)]
+ force: bool,
+ /// Remove `[cat_hub]` from the source after creating a `.bak` copy.
+ #[arg(long)]
+ remove_source_section: bool,
+ },
+}
+
+/// Output encoding for configuration diagnostics.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
+pub enum OutputFormat {
+ /// Human-readable text or TOML.
+ Text,
+ /// Machine-readable JSON.
+ Json,
+}
+
+/// Initialize tracing for the process. The returned guard must be kept alive for the
+/// process lifetime so the non-blocking file writer flushes on shutdown.
+pub fn init_logging() -> WorkerGuard {
+ logging::init()
+}
+
+/// Build the configured radio backend.
+fn build_backend(cfg: &Config) -> Result, CatHubError> {
+ match cfg.radio.backend.as_str() {
+ "ts590" => Ok(Arc::new(Ts590Backend::new())),
+ "rigctld" => Ok(Arc::new(RigctldBackend::new(
+ cfg.radio.model.clone(),
+ cfg.radio.certified,
+ ))),
+ "loopback" => Ok(Arc::new(LoopbackBackend::new())),
+ other => Err(CatHubError::Backend(format!("unknown backend '{other}'"))),
+ }
+}
+
+/// Build a client dialect by name.
+fn dialect_for(name: &str) -> Result, CatHubError> {
+ match name {
+ "ts590" => Ok(Arc::new(Ts590Dialect::new())),
+ "ts590-transparent" => Ok(Arc::new(TransparentTs590Dialect::new())),
+ "ts2000" => Ok(Arc::new(Ts2000Dialect::new())),
+ other => Err(CatHubError::Backend(format!("unknown dialect '{other}'"))),
+ }
+}
+
+/// An opened radio transport.
+enum OpenedTransport {
+ /// A real serial port.
+ Serial(serial2_tokio::SerialPort),
+ /// A TCP socket (for a `tcp` transport or rigctld bridge endpoint).
+ Tcp(TcpStream),
+}
+
+/// Open the radio transport described by the `[radio]` section.
+async fn open_transport(radio: &RadioConfig) -> Result {
+ match radio.transport.as_str() {
+ "serial" => Ok(OpenedTransport::Serial(open_radio_serial(radio)?)),
+ "tcp" => Ok(OpenedTransport::Tcp(open_radio_tcp(radio).await?)),
+ other => Err(CatHubError::Backend(format!(
+ "unknown radio.transport '{other}'"
+ ))),
+ }
+}
+
+/// Open and condition the radio serial port.
+///
+/// Assert the RTS and DTR modem-control lines. Some radios (notably the Kenwood TS-590) gate
+/// their CAT transmit on RTS and send no replies at all unless it is high, so without this the
+/// daemon opens the port but every poll times out. This matches the default line state that
+/// OmniRig/Hamlib clients use.
+fn open_radio_serial(radio: &RadioConfig) -> std::io::Result {
+ let port = serial2_tokio::SerialPort::open(&radio.port, radio.baud)?;
+ port.set_rts(true)?;
+ port.set_dtr(true)?;
+ Ok(port)
+}
+
+/// Connect the radio TCP transport (a `tcp` radio or a rigctld bridge endpoint).
+async fn open_radio_tcp(radio: &RadioConfig) -> std::io::Result {
+ TcpStream::connect((radio.host.as_str(), radio.tcp_port)).await
+}
+
+/// Run the daemon to completion (until Ctrl+C).
+///
+/// # Errors
+///
+/// Returns a [`CatHubError`] if the configuration cannot be loaded or validated, the
+/// backend or a dialect cannot be built, the radio transport or an endpoint port cannot be
+/// opened, or the process fails to install its Ctrl+C handler.
+#[allow(clippy::too_many_lines)] // The wiring is one cohesive bring-up sequence.
+pub async fn run(cli: Cli) -> Result<(), CatHubError> {
+ if let Some(command) = cli.command {
+ return run_command(command, cli.config, cli.section.as_deref())
+ .map_err(CatHubError::Config);
+ }
+ let path = cli
+ .config
+ .clone()
+ .unwrap_or_else(Config::default_config_path);
+ if let Some(log) = &cli.log {
+ tracing::debug!(log = %log.display(), "log path override requested");
+ }
+ let cfg = Config::load_selected(&path, cli.section.as_deref())?;
+
+ if cli.dry_run {
+ println!("{}", cfg.describe());
+ return Ok(());
+ }
+
+ let backend = build_backend(&cfg)?;
+ let caps = backend.capabilities();
+ tracing::info!(caps = %caps.summary(), "backend ready");
+
+ let state = StateHandle::new();
+ let ptt = PttManager::new(cfg.ptt_max_tx());
+
+ let winkeyer: Option = if let Some(keyer) = &cfg.winkeyer {
+ let port = open_winkeyer_serial(&keyer.port, keyer.baud)?;
+ let port_name = keyer.port.clone();
+ let baud = keyer.baud;
+ let handle = spawn_winkeyer(
+ port,
+ Duration::from_millis(keyer.max_tx_ms),
+ ptt.clone(),
+ move || {
+ let port_name = port_name.clone();
+ async move { open_winkeyer_serial(&port_name, baud) }
+ },
+ )
+ .await
+ .map_err(|error| CatHubError::Backend(error.to_string()))?;
+ tracing::info!(
+ port = %keyer.port,
+ firmware = ?handle.snapshot().firmware_revision,
+ "WinKeyer broker owns physical keyer"
+ );
+ let bind = keyer.api_bind.parse().map_err(|error| {
+ CatHubError::Backend(format!("invalid WinKeyer API bind address: {error}"))
+ })?;
+ let server = bind_winkeyer_server(bind, handle.clone())
+ .await
+ .map_err(|error| CatHubError::Backend(format!("cannot bind WinKeyer API: {error}")))?;
+ tokio::spawn(async move {
+ match server.await {
+ Ok(Err(error)) => tracing::error!(%error, "WinKeyer broker gRPC server stopped"),
+ Err(error) => tracing::error!(%error, "WinKeyer broker gRPC task failed"),
+ Ok(Ok(())) => {}
+ }
+ });
+ Some(handle)
+ } else {
+ None
+ };
+
+ // Wire the transport to the serialized radio link. The loopback backend needs no real
+ // transport (it never submits raw bytes), so we just drop the receiver in that case.
+ //
+ // Native push is "in play" whenever the operator enabled it and the backend has a push
+ // command. The supervisor (below) actually issues the enable on the first connect and
+ // re-issues it on every reconnect, so the poller can use this flag to decide back-off.
+ let (link, raw_rx) = link_channel();
+ let native_push_active = cfg.radio.backend != "loopback"
+ && cfg.events.native_push
+ && backend.native_push_enable().is_some();
+ if cfg.radio.backend == "loopback" {
+ drop(raw_rx);
+ } else {
+ // The transport is supervised: if the serial/TCP link drops (unplug, radio
+ // power-cycle, write error) the daemon reopens it with backoff and keeps serving the
+ // same command queue, instead of leaving every client wired to a dead radio link until
+ // the whole daemon is restarted.
+ let push_link = native_push_active.then(|| link.clone());
+ let backend_t = backend.clone();
+ let state_t = state.clone();
+ match open_transport(&cfg.radio).await? {
+ OpenedTransport::Serial(port) => {
+ let radio_cfg = cfg.radio.clone();
+ tokio::spawn(run_transport_supervised(
+ port,
+ raw_rx,
+ backend_t,
+ state_t,
+ push_link,
+ move || {
+ let radio_cfg = radio_cfg.clone();
+ async move {
+ open_radio_serial(&radio_cfg)
+ .map_err(|e| BackendError::Transport(e.to_string()))
+ }
+ },
+ RECONNECT_INITIAL,
+ RECONNECT_MAX,
+ ));
+ }
+ OpenedTransport::Tcp(stream) => {
+ let radio_cfg = cfg.radio.clone();
+ tokio::spawn(run_transport_supervised(
+ stream,
+ raw_rx,
+ backend_t,
+ state_t,
+ push_link,
+ move || {
+ let radio_cfg = radio_cfg.clone();
+ async move {
+ open_radio_tcp(&radio_cfg)
+ .await
+ .map_err(|e| BackendError::Transport(e.to_string()))
+ }
+ },
+ RECONNECT_INITIAL,
+ RECONNECT_MAX,
+ ));
+ }
+ }
+ }
+
+ let radio = spawn_scheduler(backend.clone(), link, state.clone());
+
+ spawn_poller(
+ radio.clone(),
+ state.clone(),
+ native_push_active,
+ cfg.baseline_interval(),
+ cfg.heartbeat_interval(),
+ );
+
+ // Prime the universal state with one awaited poll before any endpoint begins serving, so the
+ // first client read (e.g. HDSDR/OmniRig connecting at startup) sees real radio state
+ // instead of defaults. Best-effort and time-bounded: a slow or absent radio must not
+ // block startup, since the baseline poller keeps retrying afterwards.
+ match tokio::time::timeout(
+ Duration::from_secs(1),
+ radio.submit(POLLER_SESSION, Priority::Poll, OpKind::Poll),
+ )
+ .await
+ {
+ Ok(Ok(_)) => tracing::info!("primed universal state from initial poll"),
+ Ok(Err(error)) => {
+ tracing::warn!(%error, "initial priming poll failed; serving defaults until next poll");
+ }
+ Err(_) => {
+ tracing::warn!("initial priming poll timed out; serving defaults until next poll");
+ }
+ }
+
+ let next_id = Arc::new(AtomicU64::new(1));
+
+ if let Some(keyer) = &winkeyer {
+ for endpoint in &cfg.winkeyer_endpoint {
+ let id = next_id.fetch_add(1, Ordering::SeqCst);
+ let port = open_winkeyer_endpoint(&endpoint.transport, endpoint.baud)?;
+ let handle = keyer.clone();
+ let primary = endpoint.primary;
+ let permissions = WinkeyerEndpointPermissions::from_tokens(&endpoint.perms);
+ tokio::spawn(run_winkeyer_endpoint(
+ port,
+ handle,
+ id,
+ primary,
+ permissions,
+ ));
+ tracing::info!(
+ endpoint = %endpoint.name,
+ id,
+ hub_port = %endpoint.transport,
+ primary,
+ "virtual WinKeyer endpoint listening; point the application at the paired port"
+ );
+ }
+ }
+
+ for endpoint in &cfg.serial_endpoint {
+ let dialect = dialect_for(&endpoint.dialect)?;
+ let id = next_id.fetch_add(1, Ordering::SeqCst);
+ let ctx = ClientSessionContext::new(
+ id,
+ endpoint.permissions(),
+ state.clone(),
+ radio.clone(),
+ ptt.clone(),
+ caps.clone(),
+ )
+ .with_single_vfo(endpoint.single_vfo);
+ let port = open_serial(&endpoint.name, &endpoint.transport, endpoint.baud)?;
+ tokio::spawn(run_endpoint_session(port, dialect, ctx, b';'));
+ tracing::info!(
+ endpoint = %endpoint.name,
+ id,
+ hub_port = %endpoint.transport,
+ "serial endpoint listening; hub owns this port -- point the application at the paired \
+ com0com port, not this one"
+ );
+ }
+
+ for ep in &cfg.hamlib_net {
+ let id = next_id.fetch_add(1, Ordering::SeqCst);
+ let template = ClientSessionContext::new(
+ id,
+ ep.permissions(),
+ state.clone(),
+ radio.clone(),
+ ptt.clone(),
+ caps.clone(),
+ )
+ .with_single_vfo(ep.single_vfo);
+ let bind = ep.bind.clone();
+ let name = ep.name.clone();
+ let ids = next_id.clone();
+ tokio::spawn(async move {
+ if let Err(e) = run_listener(&bind, ids, template).await {
+ tracing::error!(endpoint = %name, error = %e, "hamlib_net listener stopped");
+ }
+ });
+ tracing::info!(endpoint = %ep.name, bind = %ep.bind, "hamlib_net endpoint listening");
+ }
+
+ // PTT safety watchdog: a transmitter that exceeds the configured ceiling is unkeyed at
+ // the radio first, then released, so the ceiling is a real stuck-transmitter backstop
+ // and the lease is never freed while the radio is still keyed.
+ {
+ let ptt = ptt.clone();
+ let radio = radio.clone();
+ tokio::spawn(async move {
+ loop {
+ tokio::time::sleep(Duration::from_millis(500)).await;
+ if let Some(endpoint) = ptt.expired_owner() {
+ let _ = radio
+ .submit(
+ endpoint,
+ Priority::Ptt,
+ OpKind::Apply(StateMutation::SetPtt {
+ keyed: false,
+ source: crate::model::PttSource::Generic,
+ }),
+ )
+ .await;
+ ptt.unkey(endpoint);
+ tracing::warn!(endpoint, "PTT safety ceiling reached; transmitter released");
+ }
+ }
+ });
+ }
+
+ tracing::info!("cathub running; press Ctrl+C to stop");
+ tokio::signal::ctrl_c().await?;
+ tracing::info!("shutdown requested");
+
+ if let Some(keyer) = &winkeyer {
+ if let Err(error) = keyer.shutdown().await {
+ tracing::warn!(%error, "WinKeyer broker shutdown failed");
+ }
+ }
+
+ // Best-effort orderly stop: never leave the transmitter keyed (design §8.5). A hard
+ // crash cannot run this; the ptt_max_tx_ms ceiling and the radio's own TX timeout are
+ // the ultimate backstops.
+ if let Some(owner) = ptt.owner() {
+ let _ = tokio::time::timeout(
+ Duration::from_millis(500),
+ radio.submit(
+ owner,
+ Priority::Ptt,
+ OpKind::Apply(StateMutation::SetPtt {
+ keyed: false,
+ source: crate::model::PttSource::Generic,
+ }),
+ ),
+ )
+ .await;
+ ptt.unkey(owner);
+ tracing::info!(session_id = owner, "released PTT on shutdown");
+ }
+ Ok(())
+}
+
+fn run_command(
+ command: Command,
+ config_path: Option,
+ section: Option<&str>,
+) -> Result<(), error::ConfigError> {
+ match command {
+ Command::Config { command } => match command {
+ ConfigCommand::Validate { format } => {
+ let path = config_path.unwrap_or_else(Config::default_config_path);
+ match Config::load_selected(&path, section) {
+ Ok(_) => {
+ match format {
+ OutputFormat::Text => println!("valid: {}", path.display()),
+ OutputFormat::Json => {
+ println!("{}", serde_json::json!({ "valid": true, "path": path }));
+ }
+ }
+ Ok(())
+ }
+ Err(error) => {
+ if format == OutputFormat::Json {
+ println!(
+ "{}",
+ serde_json::json!({
+ "valid": false,
+ "path": path,
+ "error": error.to_string()
+ })
+ );
+ }
+ Err(error)
+ }
+ }
+ }
+ ConfigCommand::PrintEffective { format } => {
+ let path = config_path.unwrap_or_else(Config::default_config_path);
+ let config = Config::load_selected(&path, section)?;
+ match format {
+ OutputFormat::Text => print!("{}", config.to_standalone_toml()?),
+ OutputFormat::Json => println!(
+ "{}",
+ serde_json::to_string_pretty(&config).map_err(|error| {
+ error::ConfigError::Invalid(format!(
+ "serializing configuration as JSON: {error}"
+ ))
+ })?
+ ),
+ }
+ Ok(())
+ }
+ ConfigCommand::Migrate {
+ from,
+ output,
+ force,
+ remove_source_section,
+ } => {
+ migrate_to_standalone(&from, &output, force, remove_source_section)?;
+ println!("migrated: {} -> {}", from.display(), output.display());
+ Ok(())
+ }
+ },
+ }
+}
+
+/// Open the physical WinKeyer using the protocol-mandated 8-N-2 framing.
+fn open_winkeyer_serial(port_name: &str, baud: u32) -> std::io::Result {
+ serial2_tokio::SerialPort::open(port_name, move |mut settings: serial2_tokio::Settings| {
+ settings.set_raw();
+ settings.set_baud_rate(baud)?;
+ settings.set_char_size(serial2_tokio::CharSize::Bits8);
+ settings.set_parity(serial2_tokio::Parity::None);
+ settings.set_stop_bits(serial2_tokio::StopBits::Two);
+ settings.set_flow_control(serial2_tokio::FlowControl::None);
+ Ok(settings)
+ })
+}
+
+#[cfg(test)]
+#[allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn build_backend_dispatches_each_kind() {
+ let cfg = Config::parse(
+ "[radio]\nbackend = \"ts590\"\nport = \"COM3\"\n\
+ [[serial_endpoint]]\nname=\"f\"\ntransport=\"COM5\"\ndialect=\"ts590\"\n",
+ )
+ .expect("parse");
+ assert_eq!(
+ build_backend(&cfg).expect("ts590").capabilities().model,
+ "TS-590"
+ );
+
+ let cfg = Config::parse(
+ "[radio]\nbackend = \"rigctld\"\nmodel=\"TS-590SG\"\ntransport=\"tcp\"\n\
+ [[serial_endpoint]]\nname=\"f\"\ntransport=\"COM5\"\ndialect=\"ts590\"\n",
+ )
+ .expect("parse");
+ assert!(build_backend(&cfg)
+ .expect("rigctld")
+ .capabilities()
+ .model
+ .starts_with("rigctld:"));
+
+ let cfg = Config::parse(
+ "[radio]\nbackend = \"loopback\"\n[[serial_endpoint]]\nname=\"f\"\ntransport=\"COM5\"\ndialect=\"ts590\"\n",
+ )
+ .expect("parse");
+ assert_eq!(
+ build_backend(&cfg).expect("loopback").capabilities().model,
+ "loopback"
+ );
+ }
+
+ #[test]
+ fn dialect_for_known_and_unknown() {
+ assert!(dialect_for("ts590").is_ok());
+ assert!(dialect_for("ts2000").is_ok());
+ assert!(dialect_for("yaesu").is_err());
+ }
+
+ #[tokio::test]
+ async fn dry_run_prints_config_and_exits() {
+ let dir = std::env::temp_dir();
+ let path = dir.join(format!("cathub-dryrun-{}.toml", std::process::id()));
+ std::fs::write(
+ &path,
+ "[radio]\nbackend = \"loopback\"\n\
+ [[serial_endpoint]]\nname=\"f\"\ntransport=\"COM5\"\ndialect=\"ts590\"\n",
+ )
+ .expect("write");
+ let cli = Cli {
+ config: Some(path.clone()),
+ section: None,
+ log: None,
+ dry_run: true,
+ command: None,
+ };
+ assert!(run(cli).await.is_ok());
+ let _ = std::fs::remove_file(&path);
+ }
+
+ #[tokio::test]
+ async fn run_fails_on_missing_config() {
+ let cli = Cli {
+ config: Some(PathBuf::from("does-not-exist-cathub.toml")),
+ section: None,
+ log: None,
+ dry_run: true,
+ command: None,
+ };
+ assert!(run(cli).await.is_err());
+ }
+
+ #[tokio::test]
+ async fn open_transport_rejects_unknown_transport() {
+ let mut cfg = Config::parse(
+ "[radio]\nbackend = \"ts590\"\ntransport = \"serial\"\nport = \"COM3\"\n\
+ [[serial_endpoint]]\nname=\"f\"\ntransport=\"COM5\"\ndialect=\"ts590\"\n",
+ )
+ .expect("parse");
+ // open_transport defends against a transport string that bypassed validation.
+ cfg.radio.transport = "usb".to_string();
+ assert!(open_transport(&cfg.radio).await.is_err());
+ }
+
+ #[tokio::test]
+ async fn run_wires_loopback_then_fails_opening_a_bogus_endpoint_port() {
+ // Exercises the full bring-up: backend, state, PTT, scheduler, native-push probe,
+ // and poller, failing only when it tries to open an endpoint's nonexistent serial port.
+ let dir = std::env::temp_dir();
+ let path = dir.join(format!("cathub-run-{}.toml", std::process::id()));
+ std::fs::write(
+ &path,
+ "[radio]\nbackend = \"loopback\"\n\
+ [[serial_endpoint]]\nname = \"bogus\"\ntransport = \"COM_DOES_NOT_EXIST\"\ndialect = \"ts590\"\n",
+ )
+ .expect("write");
+ let cli = Cli {
+ config: Some(path.clone()),
+ section: None,
+ log: None,
+ dry_run: false,
+ command: None,
+ };
+ let result = tokio::time::timeout(std::time::Duration::from_secs(5), run(cli)).await;
+ let _ = std::fs::remove_file(&path);
+ assert!(
+ matches!(result, Ok(Err(_))),
+ "expected an endpoint open error"
+ );
+ }
+}
diff --git a/crates/cathub/src/logging.rs b/crates/cathub/src/logging.rs
new file mode 100644
index 0000000..5cc9d01
--- /dev/null
+++ b/crates/cathub/src/logging.rs
@@ -0,0 +1,61 @@
+//! Tracing setup: a stdout layer plus a rolling daily file appender (design §6/§11).
+
+use std::path::PathBuf;
+
+use tracing_appender::non_blocking::WorkerGuard;
+use tracing_subscriber::layer::SubscriberExt;
+use tracing_subscriber::util::SubscriberInitExt;
+use tracing_subscriber::EnvFilter;
+
+/// The default log directory for this platform.
+fn log_dir() -> PathBuf {
+ if let Some(path) = std::env::var_os("CATHUB_LOG_DIR") {
+ return PathBuf::from(path);
+ }
+ #[cfg(target_os = "windows")]
+ {
+ if let Some(local_app_data) = std::env::var_os("LOCALAPPDATA") {
+ return PathBuf::from(local_app_data).join("cathub").join("logs");
+ }
+ if let Some(app_data) = std::env::var_os("APPDATA") {
+ return PathBuf::from(app_data).join("cathub").join("logs");
+ }
+ }
+ #[cfg(not(target_os = "windows"))]
+ {
+ if let Ok(state) = std::env::var("XDG_STATE_HOME") {
+ return PathBuf::from(state).join("cathub");
+ }
+ if let Ok(home) = std::env::var("HOME") {
+ return PathBuf::from(home)
+ .join(".local")
+ .join("state")
+ .join("cathub");
+ }
+ }
+ PathBuf::from(".")
+}
+
+/// Initialize tracing. Returns a [`WorkerGuard`] that must be kept alive for the process
+/// lifetime so the non-blocking file writer flushes on shutdown.
+pub(crate) fn init() -> WorkerGuard {
+ let dir = log_dir();
+ let _ = std::fs::create_dir_all(&dir);
+ let file_appender = tracing_appender::rolling::daily(&dir, "cathub.log");
+ let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
+
+ let filter = EnvFilter::try_from_env("CATHUB_LOG").unwrap_or_else(|_| EnvFilter::new("info"));
+
+ let stdout_layer = tracing_subscriber::fmt::layer().with_target(false);
+ let file_layer = tracing_subscriber::fmt::layer()
+ .with_ansi(false)
+ .with_writer(non_blocking);
+
+ tracing_subscriber::registry()
+ .with(filter)
+ .with(stdout_layer)
+ .with(file_layer)
+ .init();
+
+ guard
+}
diff --git a/crates/cathub/src/main.rs b/crates/cathub/src/main.rs
new file mode 100644
index 0000000..7c0cc0a
--- /dev/null
+++ b/crates/cathub/src/main.rs
@@ -0,0 +1,21 @@
+//! Binary entry point for the `CatHub` daemon.
+
+use std::process::ExitCode;
+
+use clap::Parser;
+
+use cathub::{run, Cli};
+
+#[tokio::main]
+async fn main() -> ExitCode {
+ let cli = Cli::parse();
+ let _guard = cathub::init_logging();
+ match run(cli).await {
+ Ok(()) => ExitCode::SUCCESS,
+ Err(err) => {
+ tracing::error!(error = %err, "cathub exited with error");
+ eprintln!("cathub: {err}");
+ ExitCode::FAILURE
+ }
+ }
+}
diff --git a/crates/cathub/src/model.rs b/crates/cathub/src/model.rs
new file mode 100644
index 0000000..db6f49b
--- /dev/null
+++ b/crates/cathub/src/model.rs
@@ -0,0 +1,665 @@
+//! Core domain types shared across the hub: VFO selection, operating mode, the
+//! universal [`StateMutation`]/[`StateChange`] pair, the [`Field`] coverage key,
+//! and the [`RadioEventSource`] provenance tag.
+//!
+//! These are backend- and dialect-independent. Backends map their native wire
+//! vocabulary to and from these types; dialects render them into client
+//! vocabularies. Keeping them here (not in `backend`) lets every layer depend on
+//! one neutral vocabulary.
+
+use std::fmt;
+
+/// Identifies one of the radio's two VFOs.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub(crate) enum Vfo {
+ /// VFO A.
+ A,
+ /// VFO B.
+ B,
+}
+
+impl fmt::Display for Vfo {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Vfo::A => f.write_str("A"),
+ Vfo::B => f.write_str("B"),
+ }
+ }
+}
+
+impl Vfo {
+ /// The other of the two VFOs. On a two-VFO radio the transmit VFO during split is always
+ /// the one that is not receiving, so this derives the TX VFO from the RX VFO.
+ pub(crate) fn other(self) -> Vfo {
+ match self {
+ Vfo::A => Vfo::B,
+ Vfo::B => Vfo::A,
+ }
+ }
+}
+
+/// Configured transmitter output power, normalized for Hamlib clients without using
+/// floating-point values in the universal state.
+///
+/// Hamlib exposes `RFPOWER` as a relative value from 0 through 1, then converts that value
+/// to milliwatts with `power2mW`. Keeping both observations preserves the exact milliwatt
+/// value reported by a downstream rigctld while still allowing CatHub to serve both calls.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) struct TxPower {
+ relative_millionths: u32,
+ milliwatts: u32,
+ max_milliwatts: Option,
+}
+
+impl TxPower {
+ /// The fixed-point denominator used for Hamlib's relative 0 through 1 power value.
+ pub(crate) const RELATIVE_SCALE: u32 = 1_000_000;
+
+ /// Parse Hamlib's relative 0 through 1 decimal into fixed-point millionths.
+ pub(crate) fn parse_relative_millionths(value: &str) -> Option {
+ let trimmed = value.trim();
+ let (whole, fraction) = trimmed.split_once('.').unwrap_or((trimmed, ""));
+ match whole {
+ "0" => {
+ if fraction.len() > 6 || !fraction.bytes().all(|b| b.is_ascii_digit()) {
+ return None;
+ }
+ let mut padded = fraction.to_string();
+ padded.extend(std::iter::repeat_n('0', 6 - fraction.len()));
+ padded.parse::().ok()
+ }
+ "1" if fraction.bytes().all(|b| b == b'0') => Some(Self::RELATIVE_SCALE),
+ _ => None,
+ }
+ }
+
+ /// Build a power observation for a radio with a known current and maximum wattage.
+ pub(crate) fn from_watts(watts: u32, max_watts: u32) -> Self {
+ Self::from_milliwatts(watts.saturating_mul(1_000), max_watts.saturating_mul(1_000))
+ }
+
+ /// Build a power observation for a radio with a known current and maximum milliwatt
+ /// value.
+ pub(crate) fn from_milliwatts(milliwatts: u32, max_milliwatts: u32) -> Self {
+ let relative_millionths = if max_milliwatts == 0 {
+ 0
+ } else {
+ let scaled = u64::from(milliwatts).saturating_mul(u64::from(Self::RELATIVE_SCALE))
+ / u64::from(max_milliwatts);
+ u32::try_from(scaled.min(u64::from(Self::RELATIVE_SCALE)))
+ .unwrap_or(Self::RELATIVE_SCALE)
+ };
+ TxPower {
+ relative_millionths,
+ milliwatts,
+ max_milliwatts: Some(max_milliwatts),
+ }
+ }
+
+ /// Build an observation returned by a downstream rigctld. The maximum is inferred so
+ /// CatHub can service later `power2mW` requests at other relative levels.
+ pub(crate) fn from_relative_millionths(
+ relative_millionths: u32,
+ milliwatts: u32,
+ ) -> Option {
+ if relative_millionths > Self::RELATIVE_SCALE {
+ return None;
+ }
+ let max_milliwatts = if relative_millionths == 0 {
+ None
+ } else {
+ let numerator = u64::from(milliwatts)
+ .saturating_mul(u64::from(Self::RELATIVE_SCALE))
+ .saturating_add(u64::from(relative_millionths / 2));
+ u32::try_from(numerator / u64::from(relative_millionths)).ok()
+ };
+ Some(TxPower {
+ relative_millionths,
+ milliwatts,
+ max_milliwatts,
+ })
+ }
+
+ /// Render the cached relative value as a compact decimal accepted by Hamlib clients.
+ pub(crate) fn relative_decimal(self) -> String {
+ if self.relative_millionths == Self::RELATIVE_SCALE {
+ return "1".to_string();
+ }
+ if self.relative_millionths == 0 {
+ return "0".to_string();
+ }
+ let fraction = format!("{:06}", self.relative_millionths);
+ format!("0.{}", fraction.trim_end_matches('0'))
+ }
+
+ /// Convert a fixed-point relative Hamlib power level to milliwatts.
+ pub(crate) fn milliwatts_for_relative(self, relative_millionths: u32) -> Option {
+ if relative_millionths > Self::RELATIVE_SCALE {
+ return None;
+ }
+ if relative_millionths == self.relative_millionths {
+ return Some(self.milliwatts);
+ }
+ if relative_millionths == 0 {
+ return Some(0);
+ }
+ let max_milliwatts = self.max_milliwatts?;
+ let numerator = u64::from(max_milliwatts)
+ .saturating_mul(u64::from(relative_millionths))
+ .saturating_add(u64::from(Self::RELATIVE_SCALE / 2));
+ u32::try_from(numerator / u64::from(Self::RELATIVE_SCALE)).ok()
+ }
+}
+
+/// Operating mode, normalized across radio families.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub(crate) enum Mode {
+ /// Lower sideband.
+ Lsb,
+ /// Upper sideband.
+ Usb,
+ /// CW (normal).
+ Cw,
+ /// CW reverse.
+ CwR,
+ /// FSK / RTTY.
+ Fsk,
+ /// FSK / RTTY reverse.
+ FskR,
+ /// AM.
+ Am,
+ /// FM.
+ Fm,
+ /// An unrecognized mode (treated as USB on the wire).
+ Unknown,
+}
+
+impl Mode {
+ /// Render the mode as its Kenwood `MD` digit byte (ASCII).
+ pub(crate) fn to_kenwood_digit(self) -> u8 {
+ match self {
+ Mode::Lsb => b'1',
+ // An unknown mode falls back to USB so a write still produces a valid frame.
+ Mode::Usb | Mode::Unknown => b'2',
+ Mode::Cw => b'3',
+ Mode::Fm => b'4',
+ Mode::Am => b'5',
+ Mode::Fsk => b'6',
+ Mode::CwR => b'7',
+ Mode::FskR => b'9',
+ }
+ }
+
+ /// Parse a Kenwood `MD` digit byte into a mode.
+ pub(crate) fn from_kenwood_digit(digit: u8) -> Mode {
+ match digit {
+ b'1' => Mode::Lsb,
+ b'2' => Mode::Usb,
+ b'3' => Mode::Cw,
+ b'4' => Mode::Fm,
+ b'5' => Mode::Am,
+ b'6' => Mode::Fsk,
+ b'7' => Mode::CwR,
+ b'9' => Mode::FskR,
+ _ => Mode::Unknown,
+ }
+ }
+
+ /// Render the mode as a Hamlib `rigctld` mode token.
+ pub(crate) fn hamlib_token(self) -> &'static str {
+ match self {
+ Mode::Lsb => "LSB",
+ Mode::Usb | Mode::Unknown => "USB",
+ Mode::Cw => "CW",
+ Mode::CwR => "CWR",
+ Mode::Fsk => "RTTY",
+ Mode::FskR => "RTTYR",
+ Mode::Am => "AM",
+ Mode::Fm => "FM",
+ }
+ }
+
+ /// Parse a Hamlib `rigctld` mode token into a mode.
+ pub(crate) fn from_hamlib_token(token: &str) -> Mode {
+ match token.trim() {
+ "LSB" => Mode::Lsb,
+ "USB" => Mode::Usb,
+ "CW" => Mode::Cw,
+ "CWR" => Mode::CwR,
+ "RTTY" => Mode::Fsk,
+ "RTTYR" => Mode::FskR,
+ "AM" => Mode::Am,
+ "FM" => Mode::Fm,
+ _ => Mode::Unknown,
+ }
+ }
+
+ /// Render the mode as a Hamlib token, folding in the radio's DATA sub-mode flag.
+ ///
+ /// The TS-590 models data operation as a base mode (`MD`) plus an independent DATA
+ /// flag (`DA`), so the composed token is what a Hamlib client expects to read back:
+ /// `USB`+data → `PKTUSB`, `LSB`+data → `PKTLSB`, etc. A base mode with no canonical
+ /// `PKT*` token reports its plain token (the DATA flag is still tracked internally).
+ pub(crate) fn hamlib_token_with_data(self, data: bool) -> &'static str {
+ if data {
+ match self {
+ Mode::Usb => "PKTUSB",
+ Mode::Lsb => "PKTLSB",
+ Mode::Fm => "PKTFM",
+ Mode::Am => "PKTAM",
+ _ => self.hamlib_token(),
+ }
+ } else {
+ self.hamlib_token()
+ }
+ }
+
+ /// Split a Hamlib mode token into its base [`Mode`] and DATA sub-mode flag.
+ ///
+ /// A `PKT*` token (WSJT-X sends `PKTUSB` for FT8/WSPR) decomposes into the underlying
+ /// base mode plus `data = true`; every other token is a plain mode with `data = false`,
+ /// so selecting any non-data mode also clears the radio's DATA flag.
+ pub(crate) fn decompose_hamlib_token(token: &str) -> (Mode, bool) {
+ let trimmed = token.trim();
+ match trimmed.strip_prefix("PKT") {
+ Some(base) => (Mode::from_hamlib_token(base), true),
+ None => (Mode::from_hamlib_token(trimmed), false),
+ }
+ }
+}
+
+/// Which transmit audio path a PTT key request selects.
+///
+/// This mirrors Hamlib's `RIG_PTT_ON*` family and the Kenwood `TX`/`TX0`/`TX1`
+/// commands. Digital-mode clients such as WSJT-X request [`PttSource::Data`]
+/// (`T 3` / `RIG_PTT_ON_DATA`) so the radio modulates from the DATA/USB audio
+/// input rather than the microphone, and so the TS-590 does not emit the
+/// data-confirmation beep produced by a bare `TX;`.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub(crate) enum PttSource {
+ /// Generic PTT (Hamlib `RIG_PTT_ON`, Kenwood `TX;`).
+ #[default]
+ Generic,
+ /// Microphone audio path (Hamlib `RIG_PTT_ON_MIC`, Kenwood `TX0;`).
+ Mic,
+ /// Data/USB audio path (Hamlib `RIG_PTT_ON_DATA`, Kenwood `TX1;`).
+ Data,
+}
+
+/// A single normalized change to apply to the radio (a write intent).
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+#[allow(clippy::enum_variant_names)] // The `Set` prefix names the write intent uniformly.
+pub(crate) enum StateMutation {
+ /// Set the active receive VFO.
+ SetRxVfo {
+ /// Active receive VFO.
+ vfo: Vfo,
+ },
+ /// Set a VFO's frequency in Hz.
+ SetVfoFreq {
+ /// Target VFO.
+ vfo: Vfo,
+ /// Frequency in Hz.
+ hz: u64,
+ },
+ /// Set a VFO's mode.
+ SetMode {
+ /// Target VFO.
+ vfo: Vfo,
+ /// Mode to set.
+ mode: Mode,
+ },
+ /// Enable or disable the DATA sub-mode flag (TS-590 `DA`), independent of the base mode.
+ SetDataMode {
+ /// Target VFO.
+ vfo: Vfo,
+ /// Whether the DATA sub-mode is on.
+ on: bool,
+ },
+ /// Enable or disable split, optionally choosing the TX VFO.
+ SetSplit {
+ /// Whether split is enabled.
+ enabled: bool,
+ /// The transmit VFO when split is enabled.
+ tx_vfo: Option,
+ },
+ /// Key or unkey the transmitter.
+ SetPtt {
+ /// Whether the transmitter should be keyed.
+ keyed: bool,
+ /// The transmit audio path to select when keying (ignored when unkeying).
+ source: PttSource,
+ },
+ /// Set the RIT offset in Hz (a zero offset disables RIT).
+ SetRit {
+ /// Offset in Hz.
+ offset_hz: i32,
+ /// Whether RIT is enabled.
+ enabled: bool,
+ },
+ /// Set the XIT offset in Hz (a zero offset disables XIT).
+ SetXit {
+ /// Offset in Hz.
+ offset_hz: i32,
+ /// Whether XIT is enabled.
+ enabled: bool,
+ },
+}
+
+impl StateMutation {
+ /// The observable [`StateChange`] this mutation produces once applied.
+ pub(crate) fn into_change(self) -> StateChange {
+ match self {
+ StateMutation::SetRxVfo { vfo } => StateChange::RxVfo { vfo },
+ StateMutation::SetVfoFreq { vfo, hz } => StateChange::Freq { vfo, hz },
+ StateMutation::SetMode { vfo, mode } => StateChange::Mode { vfo, mode },
+ StateMutation::SetDataMode { vfo, on } => StateChange::DataMode { vfo, on },
+ StateMutation::SetSplit { enabled, tx_vfo } => StateChange::Split { enabled, tx_vfo },
+ StateMutation::SetPtt { keyed, .. } => StateChange::Ptt { keyed },
+ StateMutation::SetRit { offset_hz, enabled } => StateChange::Rit { enabled, offset_hz },
+ StateMutation::SetXit { offset_hz, enabled } => StateChange::Xit { enabled, offset_hz },
+ }
+ }
+}
+
+/// A single observed change to the universal state, broadcast to endpoints for AI
+/// fan-out and recorded into the [`Snapshot`](crate::state::Snapshot).
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum StateChange {
+ /// The active receive VFO changed.
+ RxVfo {
+ /// Active receive VFO.
+ vfo: Vfo,
+ },
+ /// A VFO frequency changed.
+ Freq {
+ /// Affected VFO.
+ vfo: Vfo,
+ /// New frequency in Hz.
+ hz: u64,
+ },
+ /// A VFO mode changed.
+ Mode {
+ /// Affected VFO.
+ vfo: Vfo,
+ /// New mode.
+ mode: Mode,
+ },
+ /// A VFO's DATA sub-mode flag changed (TS-590 `DA`).
+ DataMode {
+ /// Affected VFO.
+ vfo: Vfo,
+ /// Whether the DATA sub-mode is on.
+ on: bool,
+ },
+ /// Split state changed.
+ Split {
+ /// Whether split is enabled.
+ enabled: bool,
+ /// The transmit VFO when split is enabled.
+ tx_vfo: Option,
+ },
+ /// PTT (transmit) state changed.
+ Ptt {
+ /// Whether the transmitter is keyed.
+ keyed: bool,
+ },
+ /// RIT state changed.
+ Rit {
+ /// Whether RIT is enabled.
+ enabled: bool,
+ /// Offset in Hz.
+ offset_hz: i32,
+ },
+ /// XIT state changed.
+ Xit {
+ /// Whether XIT is enabled.
+ enabled: bool,
+ /// Offset in Hz.
+ offset_hz: i32,
+ },
+ /// The configured transmitter output power changed or became unavailable.
+ TxPower {
+ /// The latest normalized observation, or `None` when the backend cannot expose it.
+ power: Option,
+ },
+}
+
+impl StateChange {
+ /// The coverage [`Field`] this change updates.
+ pub(crate) fn field(&self) -> Field {
+ match *self {
+ StateChange::RxVfo { .. } => Field::RxVfo,
+ StateChange::Freq { vfo, .. } => Field::Freq(vfo),
+ // The DATA flag is part of the composed mode, so it shares the Mode coverage key.
+ StateChange::Mode { vfo, .. } | StateChange::DataMode { vfo, .. } => Field::Mode(vfo),
+ StateChange::Split { .. } => Field::Split,
+ StateChange::Ptt { .. } => Field::Ptt,
+ StateChange::Rit { .. } => Field::Rit,
+ StateChange::Xit { .. } => Field::Xit,
+ StateChange::TxPower { .. } => Field::Power,
+ }
+ }
+}
+
+/// A coverage key identifying one observable field, used to decide whether the
+/// radio's native push stream covers a field (so the baseline poller can back off).
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub(crate) enum Field {
+ /// Active receive VFO.
+ RxVfo,
+ /// A VFO frequency.
+ Freq(Vfo),
+ /// A VFO mode.
+ Mode(Vfo),
+ /// Split state.
+ Split,
+ /// PTT state.
+ Ptt,
+ /// RIT state.
+ Rit,
+ /// XIT state.
+ Xit,
+ /// S-meter reading. Reserved for backends that surface signal strength.
+ #[allow(dead_code)]
+ SMeter,
+ /// Configured transmitter output power.
+ Power,
+}
+
+/// Where a state change originated, so the poller can distinguish radio-driven
+/// native push (which warrants backing off) from poll-derived diffs.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum RadioEventSource {
+ /// An unsolicited frame the radio pushed on its own (auto-information).
+ NativePush,
+ /// A difference observed during a baseline poll cycle.
+ PollDiff,
+ /// An optimistic write reflected immediately after the backend acknowledged it.
+ OptimisticWrite,
+ /// A value confirmed by a verifying read-back. Reserved for verify-after-write.
+ #[allow(dead_code)]
+ VerifyRead,
+}
+
+#[cfg(test)]
+#[allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn kenwood_mode_digits_round_trip() {
+ for mode in [
+ Mode::Lsb,
+ Mode::Usb,
+ Mode::Cw,
+ Mode::Fm,
+ Mode::Am,
+ Mode::Fsk,
+ Mode::CwR,
+ Mode::FskR,
+ ] {
+ assert_eq!(Mode::from_kenwood_digit(mode.to_kenwood_digit()), mode);
+ }
+ }
+
+ #[test]
+ fn unknown_kenwood_digit_maps_to_unknown_and_back_to_usb() {
+ assert_eq!(Mode::from_kenwood_digit(b'0'), Mode::Unknown);
+ assert_eq!(Mode::Unknown.to_kenwood_digit(), b'2');
+ }
+
+ #[test]
+ fn hamlib_tokens_round_trip() {
+ for mode in [
+ Mode::Lsb,
+ Mode::Usb,
+ Mode::Cw,
+ Mode::CwR,
+ Mode::Fsk,
+ Mode::FskR,
+ Mode::Am,
+ Mode::Fm,
+ ] {
+ assert_eq!(Mode::from_hamlib_token(mode.hamlib_token()), mode);
+ }
+ assert_eq!(Mode::from_hamlib_token("WAT"), Mode::Unknown);
+ }
+
+ #[test]
+ fn hamlib_data_tokens_compose_and_decompose() {
+ // Base modes with a canonical PKT token fold in the DATA flag.
+ assert_eq!(Mode::Usb.hamlib_token_with_data(true), "PKTUSB");
+ assert_eq!(Mode::Lsb.hamlib_token_with_data(true), "PKTLSB");
+ assert_eq!(Mode::Fm.hamlib_token_with_data(true), "PKTFM");
+ assert_eq!(Mode::Am.hamlib_token_with_data(true), "PKTAM");
+ // DATA off (or a base with no PKT token) renders the plain token.
+ assert_eq!(Mode::Usb.hamlib_token_with_data(false), "USB");
+ assert_eq!(Mode::Cw.hamlib_token_with_data(true), "CW");
+
+ // WSJT-X sends PKTUSB for FT8/WSPR; it must split into USB + DATA on.
+ assert_eq!(Mode::decompose_hamlib_token("PKTUSB"), (Mode::Usb, true));
+ assert_eq!(Mode::decompose_hamlib_token("PKTLSB"), (Mode::Lsb, true));
+ // Plain tokens decompose to the base mode with DATA cleared.
+ assert_eq!(Mode::decompose_hamlib_token("USB"), (Mode::Usb, false));
+ assert_eq!(Mode::decompose_hamlib_token("CW"), (Mode::Cw, false));
+
+ // Compose/decompose round-trips for the data-capable base modes.
+ for mode in [Mode::Usb, Mode::Lsb, Mode::Fm, Mode::Am] {
+ let token = mode.hamlib_token_with_data(true);
+ assert_eq!(Mode::decompose_hamlib_token(token), (mode, true));
+ }
+ }
+
+ #[test]
+ fn tx_power_parses_and_formats_hamlib_relative_values() {
+ assert_eq!(TxPower::parse_relative_millionths("0"), Some(0));
+ assert_eq!(TxPower::parse_relative_millionths("0.5"), Some(500_000));
+ assert_eq!(
+ TxPower::parse_relative_millionths("0.501250"),
+ Some(501_250)
+ );
+ assert_eq!(
+ TxPower::parse_relative_millionths("1.000000"),
+ Some(TxPower::RELATIVE_SCALE)
+ );
+ assert_eq!(TxPower::parse_relative_millionths("1.1"), None);
+ assert_eq!(TxPower::parse_relative_millionths("-0.5"), None);
+
+ assert_eq!(TxPower::from_watts(50, 100).relative_decimal(), "0.5");
+ assert_eq!(TxPower::from_watts(25, 25).relative_decimal(), "1");
+ }
+
+ #[test]
+ fn tx_power_converts_relative_values_to_milliwatts() {
+ let native = TxPower::from_watts(50, 100);
+ assert_eq!(native.milliwatts_for_relative(500_000), Some(50_000));
+ assert_eq!(native.milliwatts_for_relative(250_000), Some(25_000));
+
+ let bridged = TxPower::from_relative_millionths(500_000, 50_000)
+ .expect("valid downstream power observation");
+ assert_eq!(bridged.milliwatts_for_relative(500_000), Some(50_000));
+ assert_eq!(bridged.milliwatts_for_relative(1_000_000), Some(100_000));
+ assert_eq!(bridged.milliwatts_for_relative(1_000_001), None);
+ }
+
+ #[test]
+ fn data_mode_mutation_into_change_round_trips() {
+ assert_eq!(
+ StateMutation::SetDataMode {
+ vfo: Vfo::A,
+ on: true,
+ }
+ .into_change(),
+ StateChange::DataMode {
+ vfo: Vfo::A,
+ on: true,
+ }
+ );
+ // DataMode shares the Mode coverage key so native MD/DA pushes can't fight.
+ assert_eq!(
+ StateChange::DataMode {
+ vfo: Vfo::B,
+ on: false,
+ }
+ .field(),
+ Field::Mode(Vfo::B)
+ );
+ }
+
+ #[test]
+ fn vfo_display_renders_letter() {
+ assert_eq!(Vfo::A.to_string(), "A");
+ assert_eq!(Vfo::B.to_string(), "B");
+ }
+
+ #[test]
+ fn mutation_into_change_maps_each_variant() {
+ assert_eq!(
+ StateMutation::SetVfoFreq {
+ vfo: Vfo::A,
+ hz: 7_000_000
+ }
+ .into_change(),
+ StateChange::Freq {
+ vfo: Vfo::A,
+ hz: 7_000_000
+ }
+ );
+ assert_eq!(
+ StateMutation::SetPtt {
+ keyed: true,
+ source: PttSource::Generic,
+ }
+ .into_change(),
+ StateChange::Ptt { keyed: true }
+ );
+ assert_eq!(
+ StateMutation::SetRit {
+ offset_hz: 50,
+ enabled: true
+ }
+ .into_change(),
+ StateChange::Rit {
+ enabled: true,
+ offset_hz: 50
+ }
+ );
+ }
+
+ #[test]
+ fn change_field_keys_are_stable() {
+ assert_eq!(
+ StateChange::Freq { vfo: Vfo::B, hz: 1 }.field(),
+ Field::Freq(Vfo::B)
+ );
+ assert_eq!(
+ StateChange::Xit {
+ enabled: false,
+ offset_hz: 0
+ }
+ .field(),
+ Field::Xit
+ );
+ }
+}
diff --git a/crates/cathub/src/permissions.rs b/crates/cathub/src/permissions.rs
new file mode 100644
index 0000000..52c4b34
--- /dev/null
+++ b/crates/cathub/src/permissions.rs
@@ -0,0 +1,160 @@
+//! Per-endpoint capability sets and command classification.
+//!
+//! The endpoint flags gate the command classes a dialect assigns to each inbound command.
+//! `frequency_write` grants narrow tuning authority without mode or VFO control, while
+//! `write` retains full modeled-write authority. Unknown passthrough writes default to
+//! denied unless the endpoint opts into unsafe full control.
+
+use crate::model::StateMutation;
+
+/// How a dialect classifies a single inbound command.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum CommandClass {
+ /// A modeled status read (served from the cache).
+ ModeledRead,
+ /// A modeled write (frequency, mode, split).
+ ModeledWrite,
+ /// A passthrough read (raw native query).
+ PassthroughRead,
+ /// A PTT / TX-affecting write.
+ PttWrite,
+ /// A persistent/config write (`EX` menu and similar).
+ ConfigWrite,
+ /// An auto-information toggle (virtualized; never reaches the radio). Reserved for
+ /// dialects that route AI toggles through classification.
+ #[allow(dead_code)]
+ AutoInfoToggle,
+ /// Denied or unknown. Reserved for dialects that classify unknown writes as denied.
+ #[allow(dead_code)]
+ Denied,
+}
+
+/// The capability set for one endpoint or Hamlib listener.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+#[allow(clippy::struct_excessive_bools)] // Each flag is an independent endpoint capability.
+pub(crate) struct EndpointPermissions {
+ /// May read modeled state.
+ pub(crate) read: bool,
+ /// May issue modeled writes (frequency, mode, split).
+ pub(crate) write: bool,
+ /// May tune frequency, without authority to change mode, VFO, split, RIT, or XIT.
+ pub(crate) frequency_write: bool,
+ /// May key PTT.
+ pub(crate) ptt: bool,
+ /// May issue persistent/config writes (`EX` menu).
+ pub(crate) config_write: bool,
+}
+
+impl EndpointPermissions {
+ /// A read-only endpoint.
+ #[cfg(test)]
+ pub(crate) fn read_only() -> Self {
+ EndpointPermissions {
+ read: true,
+ write: false,
+ frequency_write: false,
+ ptt: false,
+ config_write: false,
+ }
+ }
+
+ /// Parse a permission list from config tokens.
+ pub(crate) fn from_tokens>(tokens: &[S]) -> Self {
+ let mut perms = EndpointPermissions {
+ read: false,
+ write: false,
+ frequency_write: false,
+ ptt: false,
+ config_write: false,
+ };
+ for token in tokens {
+ match token.as_ref() {
+ "read" => perms.read = true,
+ "write" => perms.write = true,
+ "frequency_write" => perms.frequency_write = true,
+ "ptt" => perms.ptt = true,
+ "config_write" => perms.config_write = true,
+ _ => {}
+ }
+ }
+ perms
+ }
+
+ /// Whether this endpoint is permitted to run a command of the given class.
+ pub(crate) fn allows(self, class: CommandClass) -> bool {
+ match class {
+ CommandClass::ModeledRead | CommandClass::PassthroughRead => self.read,
+ CommandClass::ModeledWrite => self.write,
+ CommandClass::PttWrite => self.ptt,
+ CommandClass::ConfigWrite => self.config_write,
+ // Auto-info toggles are always allowed: they are virtualized per endpoint and
+ // never touch the radio.
+ CommandClass::AutoInfoToggle => true,
+ CommandClass::Denied => false,
+ }
+ }
+
+ /// Whether this endpoint may apply a specific modeled mutation.
+ pub(crate) fn allows_mutation(self, class: CommandClass, mutation: &StateMutation) -> bool {
+ if class != CommandClass::ModeledWrite {
+ return self.allows(class);
+ }
+ self.write || (self.frequency_write && matches!(mutation, StateMutation::SetVfoFreq { .. }))
+ }
+}
+
+#[cfg(test)]
+#[allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn tokens_parse_into_flags() {
+ let perms = EndpointPermissions::from_tokens(&["read", "write", "ptt"]);
+ assert!(perms.read && perms.write && perms.ptt);
+ assert!(!perms.frequency_write);
+ assert!(!perms.config_write);
+ }
+
+ #[test]
+ fn frequency_write_is_narrowly_scoped() {
+ let perms = EndpointPermissions::from_tokens(&["read", "frequency_write"]);
+ assert!(perms.frequency_write);
+ assert!(!perms.write);
+ assert!(perms.allows_mutation(
+ CommandClass::ModeledWrite,
+ &StateMutation::SetVfoFreq {
+ vfo: crate::model::Vfo::A,
+ hz: 14_074_000,
+ }
+ ));
+ assert!(!perms.allows_mutation(
+ CommandClass::ModeledWrite,
+ &StateMutation::SetMode {
+ vfo: crate::model::Vfo::A,
+ mode: crate::model::Mode::Cw,
+ }
+ ));
+ }
+
+ #[test]
+ fn read_only_denies_writes_and_ptt() {
+ let perms = EndpointPermissions::read_only();
+ assert!(perms.allows(CommandClass::ModeledRead));
+ assert!(!perms.allows(CommandClass::ModeledWrite));
+ assert!(!perms.allows(CommandClass::PttWrite));
+ assert!(!perms.allows(CommandClass::ConfigWrite));
+ }
+
+ #[test]
+ fn auto_info_toggle_always_allowed() {
+ let perms = EndpointPermissions::read_only();
+ assert!(perms.allows(CommandClass::AutoInfoToggle));
+ }
+
+ #[test]
+ fn config_write_requires_flag() {
+ let perms = EndpointPermissions::from_tokens(&["read", "write", "ptt", "config_write"]);
+ assert!(perms.allows(CommandClass::ConfigWrite));
+ }
+}
diff --git a/crates/cathub/src/ptt.rs b/crates/cathub/src/ptt.rs
new file mode 100644
index 0000000..3686145
--- /dev/null
+++ b/crates/cathub/src/ptt.rs
@@ -0,0 +1,155 @@
+//! PTT ownership and arbitration: a single-owner lease with a maximum-transmit safety
+//! ceiling (§8.5). Contention is made safe rather than supporting simultaneous transmit.
+
+use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
+use std::time::{Duration, Instant};
+
+/// Why a PTT key request was refused.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum PttDenied {
+ /// Another client session currently holds the lease.
+ Busy,
+ /// The client session lacks the `ptt` capability.
+ NotPermitted,
+}
+
+struct Inner {
+ owner: Option,
+ keyed_at: Option,
+ max_tx: Duration,
+}
+
+/// Arbitrates the single physical transmitter across PTT-capable client sessions.
+#[derive(Clone)]
+pub(crate) struct PttManager {
+ inner: Arc>,
+}
+
+impl PttManager {
+ /// Create a manager with the given maximum-transmit safety ceiling.
+ pub(crate) fn new(max_tx: Duration) -> Self {
+ PttManager {
+ inner: Arc::new(Mutex::new(Inner {
+ owner: None,
+ keyed_at: None,
+ max_tx,
+ })),
+ }
+ }
+
+ fn lock(&self) -> MutexGuard<'_, Inner> {
+ self.inner.lock().unwrap_or_else(PoisonError::into_inner)
+ }
+
+ /// Attempt to acquire (or re-assert) the lease for `session_id`.
+ ///
+ /// The first capable session to key acquires the lease; while it is held, other sessions
+ /// are refused. `has_ptt` reflects the configured endpoint's `ptt` capability.
+ pub(crate) fn try_key(&self, session_id: u64, has_ptt: bool) -> Result<(), PttDenied> {
+ if !has_ptt {
+ return Err(PttDenied::NotPermitted);
+ }
+ let mut guard = self.lock();
+ match guard.owner {
+ Some(owner) if owner != session_id => Err(PttDenied::Busy),
+ _ => {
+ guard.owner = Some(session_id);
+ guard.keyed_at = Some(Instant::now());
+ Ok(())
+ }
+ }
+ }
+
+ /// Release the lease if `session_id` owns it.
+ pub(crate) fn unkey(&self, session_id: u64) {
+ let mut guard = self.lock();
+ if guard.owner == Some(session_id) {
+ guard.owner = None;
+ guard.keyed_at = None;
+ }
+ }
+
+ /// The current PTT owner, if any.
+ pub(crate) fn owner(&self) -> Option {
+ self.lock().owner
+ }
+
+ /// The current owner iff the maximum-transmit ceiling has elapsed, **without** releasing
+ /// the lease.
+ ///
+ /// The lease is intentionally held until the caller has actually unkeyed the radio (send
+ /// `RX;` first, then [`unkey`](Self::unkey)). Releasing here would open a window in which
+ /// another session could acquire PTT and then be unkeyed by the caller's delayed `RX;`.
+ /// This is a hard transmit-length ceiling, not a CAT-idle timer: a keyed-but-silent
+ /// client (WSJT-X mid-over) is not reported until the ceiling.
+ pub(crate) fn expired_owner(&self) -> Option {
+ let guard = self.lock();
+ if guard.keyed_at.is_some_and(|t| t.elapsed() >= guard.max_tx) {
+ guard.owner
+ } else {
+ None
+ }
+ }
+}
+
+#[cfg(test)]
+#[allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn first_capable_session_acquires_lease() {
+ let ptt = PttManager::new(Duration::from_secs(300));
+ assert_eq!(ptt.try_key(1, true), Ok(()));
+ assert_eq!(ptt.owner(), Some(1));
+ }
+
+ #[test]
+ fn second_session_rejected_while_held() {
+ let ptt = PttManager::new(Duration::from_secs(300));
+ assert_eq!(ptt.try_key(1, true), Ok(()));
+ assert_eq!(ptt.try_key(2, true), Err(PttDenied::Busy));
+ }
+
+ #[test]
+ fn owner_can_reassert_without_error() {
+ let ptt = PttManager::new(Duration::from_secs(300));
+ assert_eq!(ptt.try_key(1, true), Ok(()));
+ assert_eq!(ptt.try_key(1, true), Ok(()));
+ }
+
+ #[test]
+ fn lease_releases_on_unkey() {
+ let ptt = PttManager::new(Duration::from_secs(300));
+ ptt.try_key(1, true).expect("key");
+ ptt.unkey(1);
+ assert_eq!(ptt.owner(), None);
+ assert_eq!(ptt.try_key(2, true), Ok(()));
+ }
+
+ #[test]
+ fn session_without_capability_is_not_permitted() {
+ let ptt = PttManager::new(Duration::from_secs(300));
+ assert_eq!(ptt.try_key(1, false), Err(PttDenied::NotPermitted));
+ assert_eq!(ptt.owner(), None);
+ }
+
+ #[test]
+ fn safety_ceiling_releases_a_stuck_transmitter() {
+ let ptt = PttManager::new(Duration::from_millis(0));
+ ptt.try_key(1, true).expect("key");
+ // The ceiling reports the owner but holds the lease until the caller unkeys.
+ assert_eq!(ptt.expired_owner(), Some(1));
+ assert_eq!(ptt.owner(), Some(1));
+ ptt.unkey(1);
+ assert_eq!(ptt.owner(), None);
+ }
+
+ #[test]
+ fn safety_ceiling_does_not_release_before_expiry() {
+ let ptt = PttManager::new(Duration::from_secs(300));
+ ptt.try_key(1, true).expect("key");
+ assert_eq!(ptt.expired_owner(), None);
+ assert_eq!(ptt.owner(), Some(1));
+ }
+}
diff --git a/crates/cathub/src/radio/mod.rs b/crates/cathub/src/radio/mod.rs
new file mode 100644
index 0000000..e73d8e7
--- /dev/null
+++ b/crates/cathub/src/radio/mod.rs
@@ -0,0 +1,849 @@
+//! The single-owner radio task: transport framing + reply matching, and the per-endpoint
+//! priority scheduler that serializes every backend operation.
+//!
+//! Two cooperating layers:
+//! * [`run_transport`] owns the byte transport. It writes one command at a time, matches
+//! solicited replies by verb, and routes every unsolicited frame to the backend's
+//! `parse_event` (native push). It exposes [`RadioLink`].
+//! * [`spawn_scheduler`] owns per-session FIFO queues and selects the next backend
+//! operation by priority across the ready heads, never reordering one endpoint's stream.
+
+use std::collections::VecDeque;
+use std::future::Future;
+use std::sync::Arc;
+use std::time::Duration;
+
+use tokio::io::{AsyncReadExt, AsyncWriteExt};
+use tokio::sync::{mpsc, oneshot};
+use tokio::time::Instant;
+
+use crate::backend::{BackendError, Framing, RadioBackend};
+use crate::events::enable_native_push;
+use crate::model::{RadioEventSource, StateMutation};
+use crate::state::StateHandle;
+
+/// Default per-command reply timeout.
+const REPLY_TIMEOUT: Duration = Duration::from_secs(1);
+
+/// Priority class for scheduling across endpoints. Lower discriminant wins.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
+pub(crate) enum Priority {
+ /// PTT (keying) - always preempts everything else at selection time.
+ Ptt = 0,
+ /// Interactive client writes (frequency, mode, split).
+ Write = 1,
+ /// Client reads and passthrough.
+ Read = 2,
+ /// Background baseline poll.
+ Poll = 3,
+}
+
+/// What the transport should expect after writing a command.
+#[derive(Debug, Clone)]
+pub(crate) enum Expect {
+ /// A reply whose frame begins with one of these verb prefixes (Kenwood/Yaesu, where
+ /// unsolicited push frames can interleave and must be told apart by verb).
+ Reply(Vec>),
+ /// The next `n` frames, concatenated (verb-less line protocols like `rigctld`, which
+ /// have no unsolicited frames so the next lines are unambiguously the reply).
+ Lines(usize),
+ /// No reply (set-and-forget write).
+ NoReply,
+}
+
+/// How a pending command recognizes its reply.
+enum Matcher {
+ Verb(Vec>),
+ Lines { remaining: usize, acc: Vec },
+}
+
+/// The reply channel a queued command is answered on.
+type ReplyTx = oneshot::Sender, BackendError>>;
+
+/// A raw command queued to the transport task.
+pub(crate) struct RawCommand {
+ bytes: Vec,
+ expect: Expect,
+ reply: ReplyTx,
+}
+
+/// A clonable handle backends use to submit raw bytes to the serialized transport.
+#[derive(Clone)]
+pub(crate) struct RadioLink {
+ tx: mpsc::Sender,
+}
+
+impl RadioLink {
+ /// Submit raw bytes and await the matching reply (or an empty vec for `NoReply`).
+ pub(crate) async fn submit(
+ &self,
+ bytes: Vec,
+ expect: Expect,
+ ) -> Result, BackendError> {
+ let (reply, rx) = oneshot::channel();
+ self.tx
+ .send(RawCommand {
+ bytes,
+ expect,
+ reply,
+ })
+ .await
+ .map_err(|_| BackendError::Transport("transport task gone".into()))?;
+ rx.await
+ .map_err(|_| BackendError::Transport("transport dropped reply".into()))?
+ }
+}
+
+/// Create a transport command channel and its [`RadioLink`].
+pub(crate) fn link_channel() -> (RadioLink, mpsc::Receiver) {
+ let (tx, rx) = mpsc::channel(64);
+ (RadioLink { tx }, rx)
+}
+
+/// A [`RadioLink`] whose transport is never spawned (loopback backend never submits).
+#[cfg(test)]
+pub(crate) fn detached_link() -> RadioLink {
+ let (link, _rx) = link_channel();
+ link
+}
+
+/// Split a byte stream into frames according to `framing`.
+struct Framer {
+ framing: Framing,
+ buf: Vec,
+}
+
+impl Framer {
+ fn new(framing: Framing) -> Self {
+ Framer {
+ framing,
+ buf: Vec::with_capacity(64),
+ }
+ }
+
+ fn delimiter(&self) -> u8 {
+ match self.framing {
+ Framing::SemicolonTerminated => b';',
+ Framing::LineTerminated => b'\n',
+ Framing::CiV => 0xFD,
+ }
+ }
+
+ /// Feed bytes; return any complete frames (delimiter included).
+ fn push(&mut self, bytes: &[u8]) -> Vec> {
+ let delim = self.delimiter();
+ let mut frames = Vec::new();
+ for &b in bytes {
+ // Bound the partial-frame buffer. A radio (or a noisy line) that streams bytes
+ // without ever producing the delimiter must not grow this buffer without limit.
+ // Drop the malformed partial frame and resynchronize on the next delimiter.
+ if self.buf.len() >= MAX_RADIO_FRAME_LEN {
+ tracing::warn!(
+ "radio frame exceeded {MAX_RADIO_FRAME_LEN} bytes without a delimiter; \
+ discarding partial frame"
+ );
+ self.buf.clear();
+ }
+ self.buf.push(b);
+ if b == delim {
+ frames.push(std::mem::take(&mut self.buf));
+ }
+ }
+ frames
+ }
+}
+
+/// Maximum bytes buffered for a single in-progress radio frame before the partial frame is
+/// discarded. Real CAT frames are tens of bytes; this only bounds a stuck or noisy link.
+const MAX_RADIO_FRAME_LEN: usize = 4096;
+
+fn frame_matches(frame: &[u8], verbs: &[Vec]) -> bool {
+ verbs.iter().any(|v| frame.starts_with(v))
+}
+
+/// Why the byte transport stopped, and whether the link should reconnect.
+pub(crate) enum TransportOutcome {
+ /// The command channel closed (every [`RadioLink`] was dropped): the daemon is shutting
+ /// down, so the supervisor must stop and not reopen the radio.
+ Shutdown,
+ /// The byte transport closed or errored (serial unplug, radio power-cycle, write error).
+ /// The command receiver is handed back so the supervisor can reopen and resume serving
+ /// queued commands without dropping the rest of the daemon.
+ Disconnected(mpsc::Receiver),
+}
+
+/// Internal reason a [`run_transport`] loop exited, before the receiver is reattached.
+enum ExitReason {
+ Shutdown,
+ Disconnected,
+}
+
+/// Default backoff before the first reconnect attempt after a transport drop.
+pub(crate) const RECONNECT_INITIAL: Duration = Duration::from_millis(500);
+/// Ceiling for the exponential reconnect backoff.
+pub(crate) const RECONNECT_MAX: Duration = Duration::from_secs(5);
+
+/// Run the transport task to completion (until the stream closes or errors).
+///
+/// `transport` is any duplex byte stream (a serial port, a TCP socket, or an in-memory
+/// duplex in tests). Reply matching keeps exactly one solicited command in flight.
+#[allow(clippy::too_many_lines)] // The transport read/write/match loop is one cohesive unit.
+pub(crate) async fn run_transport(
+ transport: T,
+ backend: Arc,
+ state: StateHandle,
+ mut raw_rx: mpsc::Receiver,
+) -> TransportOutcome
+where
+ T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + 'static,
+{
+ let framing = backend.capabilities().framing;
+ let (mut reader, mut writer) = tokio::io::split(transport);
+
+ // Reader task: frame the input and forward frames.
+ let (frame_tx, mut frame_rx) = mpsc::channel::>(256);
+ let reader_task = tokio::spawn(async move {
+ let mut framer = Framer::new(framing);
+ let mut chunk = [0u8; 512];
+ loop {
+ match reader.read(&mut chunk).await {
+ Ok(0) | Err(_) => break,
+ Ok(n) => {
+ let slice = chunk.get(..n).unwrap_or(&[]);
+ for frame in framer.push(slice) {
+ if frame_tx.send(frame).await.is_err() {
+ return;
+ }
+ }
+ }
+ }
+ }
+ });
+
+ let mut pending: Option<(Matcher, ReplyTx, Instant)> = None;
+ let reason: ExitReason;
+
+ loop {
+ if pending.is_none() {
+ tokio::select! {
+ cmd = raw_rx.recv() => {
+ let Some(cmd) = cmd else { reason = ExitReason::Shutdown; break };
+ if let Err(e) = writer.write_all(&cmd.bytes).await {
+ let _ = cmd.reply.send(Err(BackendError::Transport(e.to_string())));
+ reason = ExitReason::Disconnected;
+ break;
+ }
+ let _ = writer.flush().await;
+ tracing::trace!(tx = %String::from_utf8_lossy(&cmd.bytes), "radio tx");
+ match cmd.expect {
+ Expect::NoReply => {
+ let _ = cmd.reply.send(Ok(Vec::new()));
+ }
+ Expect::Reply(verbs) => {
+ pending = Some((
+ Matcher::Verb(verbs),
+ cmd.reply,
+ Instant::now() + REPLY_TIMEOUT,
+ ));
+ }
+ Expect::Lines(n) => {
+ if n == 0 {
+ let _ = cmd.reply.send(Ok(Vec::new()));
+ } else {
+ pending = Some((
+ Matcher::Lines { remaining: n, acc: Vec::new() },
+ cmd.reply,
+ Instant::now() + REPLY_TIMEOUT,
+ ));
+ }
+ }
+ }
+ }
+ frame = frame_rx.recv() => {
+ let Some(frame) = frame else { reason = ExitReason::Disconnected; break };
+ tracing::trace!(rx = %String::from_utf8_lossy(&frame), "radio rx (idle)");
+ route_event(&backend, &state, &frame);
+ }
+ }
+ } else {
+ let Some((_, _, deadline)) = pending.as_ref() else {
+ continue;
+ };
+ let deadline = *deadline;
+ let recv = tokio::time::timeout_at(deadline, frame_rx.recv()).await;
+ match recv {
+ Err(_elapsed) => {
+ if let Some((matcher, reply, _)) = pending.take() {
+ if let Matcher::Verb(verbs) = &matcher {
+ let awaited: Vec = verbs
+ .iter()
+ .map(|v| String::from_utf8_lossy(v).into_owned())
+ .collect();
+ tracing::warn!(?awaited, "radio reply timed out");
+ } else {
+ tracing::warn!("radio reply (lines) timed out");
+ }
+ let _ = reply.send(Err(BackendError::Timeout));
+ }
+ }
+ Ok(None) => {
+ reason = ExitReason::Disconnected;
+ break;
+ }
+ Ok(Some(frame)) => {
+ tracing::trace!(rx = %String::from_utf8_lossy(&frame), "radio rx (pending)");
+ pending = match pending.take() {
+ Some((Matcher::Verb(verbs), reply, deadline)) => {
+ if frame_matches(&frame, &verbs) {
+ let _ = reply.send(Ok(frame));
+ None
+ } else {
+ route_event(&backend, &state, &frame);
+ Some((Matcher::Verb(verbs), reply, deadline))
+ }
+ }
+ Some((Matcher::Lines { remaining, mut acc }, reply, deadline)) => {
+ acc.extend_from_slice(&frame);
+ let left = remaining.saturating_sub(1);
+ // A rigctld error is a complete one-line response even when the
+ // successful command shape has multiple lines (`m`, `s`, `x`).
+ // Complete immediately so optional capability probes do not wait
+ // for a line that will never arrive.
+ if left == 0 || frame.starts_with(b"RPRT ") {
+ let _ = reply.send(Ok(acc));
+ None
+ } else {
+ Some((
+ Matcher::Lines {
+ remaining: left,
+ acc,
+ },
+ reply,
+ deadline,
+ ))
+ }
+ }
+ None => None,
+ };
+ }
+ }
+ }
+ }
+
+ if let Some((_, reply, _)) = pending.take() {
+ let _ = reply.send(Err(BackendError::Transport("transport closed".into())));
+ }
+ reader_task.abort();
+ match reason {
+ ExitReason::Shutdown => TransportOutcome::Shutdown,
+ ExitReason::Disconnected => TransportOutcome::Disconnected(raw_rx),
+ }
+}
+
+/// Supervise a radio transport: run it, and when it drops (serial unplug, radio power-cycle,
+/// write error) reopen it with capped exponential backoff and resume serving the same command
+/// queue. Without this, a single transport hiccup would leave every client (HDSDR/OmniRig,
+/// N1MM, WSJT-X, Log4OM, the engine) connected to a dead radio link until the whole daemon was
+/// restarted. The loop ends only when the command channel closes (daemon shutdown).
+///
+/// `first` is the already-open transport from startup. `reopen` produces a fresh transport of
+/// the same kind on each reconnect. When `push_link` is `Some`, the radio's native push stream
+/// is re-armed after every (re)connect, because a power-cycled radio forgets its auto-info
+/// state (design §8.4: "At startup and on reconnect").
+#[expect(
+ clippy::too_many_arguments,
+ reason = "transport, queue, backend, state, push-link, reopen, and two backoff bounds are all distinct supervision inputs"
+)]
+pub(crate) async fn run_transport_supervised(
+ first: T,
+ mut raw_rx: mpsc::Receiver,
+ backend: Arc,
+ state: StateHandle,
+ push_link: Option,
+ mut reopen: F,
+ backoff_initial: Duration,
+ backoff_max: Duration,
+) where
+ T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + 'static,
+ F: FnMut() -> Fut + Send + 'static,
+ Fut: Future