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> + Send, +{ + let mut transport = Some(first); + let mut backoff = backoff_initial; + loop { + let t = match transport.take() { + Some(t) => t, + None => match reopen().await { + Ok(t) => { + tracing::info!("radio transport reconnected"); + backoff = backoff_initial; + t + } + Err(error) => { + tracing::warn!(%error, ?backoff, "radio reopen failed; retrying"); + tokio::time::sleep(backoff).await; + backoff = (backoff * 2).min(backoff_max); + continue; + } + }, + }; + + // Re-arm the radio's native push now that a transport is live. This is spawned because + // it submits through the same command queue that `run_transport` (started just below) + // services; awaiting it here would deadlock against a not-yet-running transport. + if let Some(link) = &push_link { + let backend = backend.clone(); + let link = link.clone(); + tokio::spawn(async move { + enable_native_push(&backend, &link).await; + }); + } + + match run_transport(t, backend.clone(), state.clone(), raw_rx).await { + TransportOutcome::Shutdown => return, + TransportOutcome::Disconnected(rx) => { + raw_rx = rx; + tracing::warn!("radio transport closed; reconnecting"); + tokio::time::sleep(backoff).await; + } + } + } +} + +/// Route an unsolicited frame into the universal state as a native push event. +/// +/// Modeled frames update the snapshot and broadcast a coalesced change. Frames the backend +/// does not model are relayed verbatim on the same ordered event bus so native pass-through +/// endpoints (which consume the CAT stream directly) keep features like the radio's noise +/// blanker and front-panel changes in sync. +fn route_event(backend: &Arc, state: &StateHandle, frame: &[u8]) { + if backend.record_event(frame, state, RadioEventSource::NativePush) { + // The frame was modeled: the snapshot is updated and a coalesced `Change` is + // broadcast for virtualizing endpoints. Also relay the verbatim frame so transparent + // mirror endpoints (ARCP-590) track the radio's real CAT stream instead of a synthesis. + state.record_raw_native(frame); + } else { + tracing::trace!( + frame = %String::from_utf8_lossy(frame), + "relaying unmodeled unsolicited radio frame to native pass-through endpoints" + ); + state.record_raw(frame); + } +} + +// --- Operation scheduler ------------------------------------------------------------- + +/// The kind of backend operation a client session requests. +pub(crate) enum OpKind { + /// Run one baseline poll cycle. + Poll, + /// Apply a modeled mutation. + Apply(StateMutation), + /// Forward a raw native command (passthrough), returning the raw reply. + Passthrough(Vec), +} + +struct Operation { + session_id: u64, + priority: Priority, + kind: OpKind, + reply: oneshot::Sender, BackendError>>, +} + +/// A clonable handle client sessions and the poller use to submit operations. +#[derive(Clone)] +pub(crate) struct RadioHandle { + tx: mpsc::Sender, +} + +impl RadioHandle { + /// Submit an operation tagged with the calling session's id and priority. + pub(crate) async fn submit( + &self, + session_id: u64, + priority: Priority, + kind: OpKind, + ) -> Result, BackendError> { + let (reply, rx) = oneshot::channel(); + self.tx + .send(Operation { + session_id, + priority, + kind, + reply, + }) + .await + .map_err(|_| BackendError::Transport("scheduler gone".into()))?; + rx.await + .map_err(|_| BackendError::Transport("scheduler dropped reply".into()))? + } +} + +/// Spawn the per-session priority scheduler. Returns a clonable [`RadioHandle`]. +pub(crate) fn spawn_scheduler( + backend: Arc, + link: RadioLink, + state: StateHandle, +) -> RadioHandle { + let (tx, mut rx) = mpsc::channel::(128); + tokio::spawn(async move { + let mut queues: Vec<(u64, VecDeque)> = Vec::new(); + loop { + if queues.iter().all(|(_, q)| q.is_empty()) { + match rx.recv().await { + Some(op) => enqueue(&mut queues, op), + None => break, + } + } + while let Ok(op) = rx.try_recv() { + enqueue(&mut queues, op); + } + let Some(op) = select_next(&mut queues) else { + continue; + }; + let result = execute(&backend, &link, &state, &op.kind).await; + let _ = op.reply.send(result); + } + }); + RadioHandle { tx } +} + +fn enqueue(queues: &mut Vec<(u64, VecDeque)>, op: Operation) { + if let Some((_, q)) = queues.iter_mut().find(|(id, _)| *id == op.session_id) { + q.push_back(op); + } else { + let mut q = VecDeque::new(); + let session_id = op.session_id; + q.push_back(op); + queues.push((session_id, q)); + } +} + +/// Pick the highest-priority ready head across all per-session queues (FIFO within a session). +fn select_next(queues: &mut [(u64, VecDeque)]) -> Option { + let mut best: Option = None; + let mut best_priority = Priority::Poll; + for (idx, (_, q)) in queues.iter().enumerate() { + if let Some(head) = q.front() { + if best.is_none() || head.priority < best_priority { + best = Some(idx); + best_priority = head.priority; + } + } + } + best.and_then(|idx| queues.get_mut(idx).and_then(|(_, q)| q.pop_front())) +} + +async fn execute( + backend: &Arc, + link: &RadioLink, + state: &StateHandle, + kind: &OpKind, +) -> Result, BackendError> { + match kind { + OpKind::Poll => backend.poll(link, state).await.map(|()| Vec::new()), + OpKind::Apply(m) => backend.apply(*m, link, state).await.map(|()| Vec::new()), + OpKind::Passthrough(bytes) => backend.passthrough(bytes, link).await, + } +} + +#[cfg(test)] +#[allow( + clippy::expect_used, + clippy::unwrap_used, + clippy::indexing_slicing, + clippy::panic, + clippy::similar_names +)] +mod tests { + use super::*; + use crate::backend::kenwood::ts590::Ts590Backend; + use crate::backend::rigctld::RigctldBackend; + use crate::model::{Mode, Vfo}; + use crate::state::RadioEvent; + + #[test] + fn framer_splits_on_semicolons() { + let mut framer = Framer::new(Framing::SemicolonTerminated); + let frames = framer.push(b"FA00007030000;MD3;"); + assert_eq!(frames.len(), 2); + assert_eq!(frames[0], b"FA00007030000;"); + assert_eq!(frames[1], b"MD3;"); + } + + #[test] + fn framer_holds_partial_frames() { + let mut framer = Framer::new(Framing::SemicolonTerminated); + assert!(framer.push(b"FA0000703").is_empty()); + let frames = framer.push(b"0000;"); + assert_eq!(frames, vec![b"FA00007030000;".to_vec()]); + } + + #[test] + fn framer_discards_overlong_partial_frame_and_resyncs() { + let mut framer = Framer::new(Framing::SemicolonTerminated); + // Stream more than the cap without ever sending a delimiter. + let junk = vec![b'X'; MAX_RADIO_FRAME_LEN + 64]; + assert!(framer.push(&junk).is_empty()); + // The partial buffer must have been bounded, not grown unbounded. + assert!(framer.buf.len() <= MAX_RADIO_FRAME_LEN); + // Flush whatever junk remains with a delimiter, then a clean frame parses correctly. + framer.push(b";"); + let frames = framer.push(b"FA00007030000;"); + assert_eq!(frames, vec![b"FA00007030000;".to_vec()]); + } + + #[test] + fn verb_matching_uses_prefix() { + assert!(frame_matches(b"FA00007030000;", &[b"FA".to_vec()])); + assert!(!frame_matches(b"MD3;", &[b"FA".to_vec()])); + } + + #[test] + fn route_event_records_full_ts590_if_status() { + let backend: Arc = Arc::new(Ts590Backend::new()); + let state = StateHandle::new(); + + route_event(&backend, &state, b"IF000140343201234-0000012345121019999;"); + + let snap = state.snapshot(); + assert_eq!(snap.rx_vfo, Vfo::B); + assert_eq!(snap.vfo(Vfo::B).freq_hz, 14_034_320); + assert_eq!(snap.vfo(Vfo::B).mode, Mode::Usb); + assert!(snap.ptt); + assert!(snap.split); + } + + #[test] + fn route_event_relays_modeled_frame_as_raw_native_for_mirror_endpoints() { + let backend: Arc = Arc::new(Ts590Backend::new()); + let state = StateHandle::new(); + let mut rx = state.subscribe(); + + route_event(&backend, &state, b"FA00014035000;"); + + // A modeled frame must broadcast BOTH the coalesced Change (for virtualizing endpoints) + // and the verbatim RawNative (for transparent mirror endpoints like ARCP-590). + let mut saw_change = false; + let mut saw_raw_native = false; + while let Ok(evt) = rx.try_recv() { + match evt { + RadioEvent::Change(_) => saw_change = true, + RadioEvent::RawNative(bytes) => { + assert_eq!(&*bytes, b"FA00014035000;"); + saw_raw_native = true; + } + RadioEvent::Raw(_) => panic!("a modeled frame must not broadcast as Raw"), + } + } + assert!( + saw_change, + "modeled frame must broadcast a coalesced Change" + ); + assert!( + saw_raw_native, + "modeled frame must also relay verbatim as RawNative" + ); + } + + #[test] + fn route_event_relays_unmodeled_frame_as_raw_only() { + let backend: Arc = Arc::new(Ts590Backend::new()); + let state = StateHandle::new(); + let mut rx = state.subscribe(); + + route_event(&backend, &state, b"NB1;"); + + let evt = rx.try_recv().expect("one event"); + assert!( + matches!(&evt, RadioEvent::Raw(bytes) if &**bytes == b"NB1;"), + "an unmodeled frame must relay as Raw only, got {evt:?}" + ); + assert!( + rx.try_recv().is_err(), + "no second event for an unmodeled frame" + ); + } + + #[test] + fn priority_orders_ptt_first() { + assert!(Priority::Ptt < Priority::Write); + assert!(Priority::Write < Priority::Read); + assert!(Priority::Read < Priority::Poll); + } + + #[tokio::test] + async fn unsolicited_frames_do_not_extend_reply_deadline() { + let backend: Arc = Arc::new(Ts590Backend::new()); + let state = StateHandle::new(); + let (link, raw_rx) = link_channel(); + let (radio, server) = tokio::io::duplex(1024); + + let transport_task = tokio::spawn(run_transport(server, backend, state, raw_rx)); + let radio_task = tokio::spawn(async move { + let (mut rd, mut wr) = tokio::io::split(radio); + let mut command = [0u8; 3]; + rd.read_exact(&mut command) + .await + .expect("transport should send a command"); + + loop { + wr.write_all(b"NB0;") + .await + .expect("transport should accept unsolicited frames"); + tokio::time::sleep(Duration::from_millis(50)).await; + } + }); + + let result = tokio::time::timeout( + REPLY_TIMEOUT + Duration::from_millis(300), + link.submit(b"FA;".to_vec(), Expect::Reply(vec![b"FA".to_vec()])), + ) + .await + .expect("an absolute reply deadline must not be extended by unsolicited frames"); + + assert!(matches!(result, Err(BackendError::Timeout))); + radio_task.abort(); + transport_task.abort(); + } + + #[tokio::test] + async fn line_matcher_completes_multi_line_request_on_rprt_error() { + let backend: Arc = Arc::new(RigctldBackend::new("test", false)); + let state = StateHandle::new(); + let (link, raw_rx) = link_channel(); + let (radio, server) = tokio::io::duplex(128); + let transport_task = tokio::spawn(run_transport(server, backend, state, raw_rx)); + + let radio_task = tokio::spawn(async move { + let (mut rd, mut wr) = tokio::io::split(radio); + let mut command = [0u8; 2]; + rd.read_exact(&mut command).await.expect("read command"); + assert_eq!(&command, b"x\n"); + wr.write_all(b"RPRT -11\n").await.expect("write error"); + }); + + let reply = tokio::time::timeout( + Duration::from_millis(200), + link.submit(b"x\n".to_vec(), Expect::Lines(2)), + ) + .await + .expect("RPRT should complete before the normal reply timeout") + .expect("transport reply"); + assert_eq!(reply, b"RPRT -11\n"); + + radio_task.await.expect("fake radio"); + transport_task.abort(); + } + + #[tokio::test] + async fn supervisor_reconnects_after_transport_drop() { + use crate::backend::kenwood::ts590::Ts590Backend; + + let backend: Arc = Arc::new(Ts590Backend::new()); + let state = StateHandle::new(); + let (link, raw_rx) = link_channel(); + + // First transport: a duplex whose client end we drop to force a disconnect. + let (radio1, server1) = tokio::io::duplex(1024); + // Second transport: handed out by `reopen` on the first reconnect. + let (radio2, server2) = tokio::io::duplex(1024); + + let pending = Arc::new(tokio::sync::Mutex::new(Some(server2))); + let pending_for_reopen = pending.clone(); + let reopen = move || { + let pending = pending_for_reopen.clone(); + async move { + pending + .lock() + .await + .take() + .ok_or_else(|| BackendError::Transport("no more transports".into())) + } + }; + + tokio::spawn(run_transport_supervised( + server1, + raw_rx, + backend.clone(), + state.clone(), + None, + reopen, + Duration::from_millis(10), + Duration::from_millis(50), + )); + + // Kill the first transport so the supervisor must reconnect. + drop(radio1); + + // A fake radio on the reconnected transport answers an FA read. + tokio::spawn(async move { + let (mut rd, mut wr) = tokio::io::split(radio2); + let mut buf = [0u8; 64]; + loop { + let n = match rd.read(&mut buf).await { + Ok(0) | Err(_) => break, + Ok(n) => n, + }; + if buf.get(..n).unwrap_or(&[]).starts_with(b"FA") { + let _ = wr.write_all(b"FA00014047470;").await; + } + } + }); + + // After reconnection, the same link must serve commands again. + let reply = tokio::time::timeout(Duration::from_secs(2), async { + loop { + if let Ok(reply) = link + .submit(b"FA;".to_vec(), Expect::Reply(vec![b"FA".to_vec()])) + .await + { + return reply; + } + tokio::time::sleep(Duration::from_millis(15)).await; + } + }) + .await + .expect("reconnected transport should answer an FA read"); + + assert!(reply.starts_with(b"FA"), "reply should be an FA frame"); + } + + #[tokio::test] + async fn supervisor_stops_when_command_channel_closes() { + use crate::backend::kenwood::ts590::Ts590Backend; + + let backend: Arc = Arc::new(Ts590Backend::new()); + let state = StateHandle::new(); + let (link, raw_rx) = link_channel(); + + // Keep the radio side alive so the only way out is the command channel closing. + let (radio1, server1) = tokio::io::duplex(1024); + + // `reopen` should never be called on this path. + let reopen = || async { + Err::(BackendError::Transport("unexpected reopen".into())) + }; + + let handle = tokio::spawn(run_transport_supervised( + server1, + raw_rx, + backend, + state, + None, + reopen, + Duration::from_millis(10), + Duration::from_millis(50), + )); + + // Dropping the last link closes the command channel: the supervisor must exit. + drop(link); + + tokio::time::timeout(Duration::from_secs(2), handle) + .await + .expect("supervisor should exit when the command channel closes") + .expect("supervisor task should not panic"); + + drop(radio1); + } +} diff --git a/crates/cathub/src/serial_endpoint.rs b/crates/cathub/src/serial_endpoint.rs new file mode 100644 index 0000000..b12f8d9 --- /dev/null +++ b/crates/cathub/src/serial_endpoint.rs @@ -0,0 +1,332 @@ +//! A generic client endpoint: read delimited request frames, dispatch them to a +//! [`ClientDialect`], and write replies, while concurrently fanning out state-change +//! notifications the client session has subscribed to (auto-information). +//! +//! `run_endpoint_session` is transport-agnostic (`AsyncRead + AsyncWrite`): it serves a real serial +//! port, a TCP socket, or an in-memory duplex in tests. [`open_serial`] opens a real COM / +//! tty port for production wiring. + +use std::sync::Arc; + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::sync::broadcast::error::RecvError; + +use crate::dialect::{ClientDialect, ClientSessionContext}; +use crate::state::RadioEvent; + +/// Serve one client connection until the transport closes. +/// +/// Inbound bytes are split on `delim` into request frames; each is handed to `dialect`. +/// Concurrently, every [`StateChange`](crate::model::StateChange) the session is subscribed to +/// is rendered by the dialect's notification formatter and written out (gated by the session's +/// virtualized auto-information flag inside the dialect). +pub(crate) async fn run_endpoint_session( + transport: T, + dialect: Arc, + ctx: ClientSessionContext, + delim: u8, +) where + T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + 'static, +{ + let (mut reader, mut writer) = tokio::io::split(transport); + let mut notifications = ctx.state.subscribe(); + let mut frame: Vec = Vec::with_capacity(64); + let mut chunk = [0u8; 512]; + + 'serve: loop { + tokio::select! { + read = reader.read(&mut chunk) => { + match read { + Ok(0) | Err(_) => break 'serve, + Ok(n) => { + let slice = chunk.get(..n).unwrap_or(&[]); + for &byte in slice { + // Bound the partial-frame buffer: a client that streams bytes + // without ever sending the delimiter must not grow the buffer + // without limit (OOM/DoS). Drop the malformed partial frame. + if frame.len() >= MAX_FRAME_LEN { + tracing::warn!( + session_id = ctx.session_id, + "request frame exceeded {MAX_FRAME_LEN} bytes without a \ + delimiter; discarding partial frame" + ); + frame.clear(); + } + frame.push(byte); + if byte == delim { + let request = std::mem::take(&mut frame); + tracing::trace!( + session_id = ctx.session_id, + req = %String::from_utf8_lossy(&request), + "client session request" + ); + let reply = dialect.handle(&request, &ctx).await; + tracing::trace!( + session_id = ctx.session_id, + reply = %String::from_utf8_lossy(&reply), + "client session reply" + ); + if !reply.is_empty() && writer.write_all(&reply).await.is_err() { + break 'serve; + } + let _ = writer.flush().await; + } + } + } + } + } + change = notifications.recv() => { + match change { + Ok(event) => { + let bytes = match &event { + RadioEvent::Change(change) => { + dialect.format_notification(change, &ctx) + } + RadioEvent::Raw(raw) => dialect.format_passthrough(raw, &ctx), + RadioEvent::RawNative(raw) => { + dialect.format_native_passthrough(raw, &ctx) + } + }; + if let Some(bytes) = bytes { + tracing::trace!( + session_id = ctx.session_id, + note = %String::from_utf8_lossy(&bytes), + "client session notification" + ); + if writer.write_all(&bytes).await.is_err() { + break 'serve; + } + let _ = writer.flush().await; + } + } + // The session fell behind the broadcast ring and missed one or more events. + // Skipping them is unsafe: a one-shot change (a mode or VFO switch) that + // was evicted from the ring is lost forever, leaving the client rendering + // permanently stale state. Re-synchronize by replaying the full current + // snapshot through the dialect's notification formatter so the client is + // restored to the live radio state (gated by the session's auto-info flag, + // so an AI-off session still emits nothing). + Err(RecvError::Lagged(skipped)) => { + tracing::warn!( + session_id = ctx.session_id, + skipped, + "client session lagged the broadcast ring; re-syncing full snapshot" + ); + let snapshot = ctx.snapshot(); + for bytes in dialect.resync(&snapshot, &ctx) { + if writer.write_all(&bytes).await.is_err() { + break 'serve; + } + } + let _ = writer.flush().await; + } + Err(RecvError::Closed) => break 'serve, + } + } + } + } + + // The transport closed: never leave the radio keyed on behalf of a session that vanished. + ctx.release_ptt_on_disconnect().await; +} + +/// Maximum bytes buffered for a single in-progress request frame before the partial frame +/// is discarded. A real CAT frame is tens of bytes; this only bounds a misbehaving or +/// malicious client that never sends the delimiter. +const MAX_FRAME_LEN: usize = 4096; + +/// Open a real serial port for a serial endpoint. +pub(crate) fn open_serial( + name: &str, + port: &str, + baud: u32, +) -> std::io::Result { + tracing::info!(endpoint = name, port, baud, "opening serial endpoint"); + serial2_tokio::SerialPort::open(port, baud) +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)] +mod tests { + use super::*; + use crate::backend::loopback::LoopbackBackend; + use crate::backend::RadioBackend; + use crate::dialect::kenwood::mode_to_digit; + use crate::dialect::kenwood::ts590::Ts590Dialect; + 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::time::Duration; + use tokio::io::DuplexStream; + + fn spawn_ts590_session(perms: EndpointPermissions) -> (DuplexStream, StateHandle) { + let (client, state, _ptt) = spawn_ts590_session_with_ptt(perms); + (client, state) + } + + fn spawn_ts590_session_with_ptt( + perms: EndpointPermissions, + ) -> (DuplexStream, StateHandle, PttManager) { + let backend = LoopbackBackend::new(); + let caps = backend.capabilities(); + let arc: Arc = Arc::new(backend); + let state = StateHandle::new(); + let radio = spawn_scheduler(arc, detached_link(), state.clone()); + let ptt = PttManager::new(Duration::from_secs(300)); + let ctx = ClientSessionContext::new(1, perms, state.clone(), radio, ptt.clone(), caps); + let (client, server) = tokio::io::duplex(1024); + tokio::spawn(run_endpoint_session( + server, + Arc::new(Ts590Dialect::new()) as Arc, + ctx, + b';', + )); + (client, state, ptt) + } + + async fn read_frame(client: &mut DuplexStream) -> Vec { + let mut frame = Vec::new(); + let mut byte = [0u8; 1]; + loop { + let n = tokio::time::timeout(Duration::from_secs(2), client.read(&mut byte)) + .await + .expect("timely reply") + .expect("read"); + if n == 0 { + break; + } + frame.push(byte[0]); + if byte[0] == b';' { + break; + } + } + frame + } + + #[tokio::test] + async fn serves_a_read_request() { + let (mut client, state) = spawn_ts590_session(EndpointPermissions::read_only()); + state.record( + StateChange::Freq { + vfo: Vfo::A, + hz: 7_030_000, + }, + RadioEventSource::PollDiff, + ); + client.write_all(b"FA;").await.expect("write"); + assert_eq!(read_frame(&mut client).await, b"FA00007030000;"); + } + + #[tokio::test] + async fn fans_out_notifications_to_subscribed_session() { + let (mut client, state) = spawn_ts590_session(EndpointPermissions::read_only()); + // Enable auto-info on the endpoint. + client.write_all(b"AI2;").await.expect("write"); + tokio::time::sleep(Duration::from_millis(20)).await; + // A state change is pushed without the client polling. + state.record( + StateChange::Freq { + vfo: Vfo::A, + hz: 14_123_000, + }, + RadioEventSource::PollDiff, + ); + assert_eq!(read_frame(&mut client).await, b"FA00014123000;"); + } + + #[tokio::test] + async fn relays_unmodeled_radio_frame_to_subscribed_session() { + let (mut client, state) = spawn_ts590_session(EndpointPermissions::read_only()); + // Enable auto-info on the endpoint. + client.write_all(b"AI2;").await.expect("write"); + tokio::time::sleep(Duration::from_millis(20)).await; + // The radio echoes a noise-blanker change the backend does not model; it must reach + // the client verbatim so its NB state machine advances (regression for the ARCP-590 + // NB cycle that stalled when these echoes were dropped). + state.record_raw(b"NB1;"); + assert_eq!(read_frame(&mut client).await, b"NB1;"); + } + + #[tokio::test] + async fn dropping_a_keyed_client_releases_the_ptt_lease() { + // A client keys the transmitter, then its transport vanishes (crash/cable pull). + // The hub must drop TX immediately, not hold it until the safety ceiling fires. + let (mut client, _state, ptt) = + spawn_ts590_session_with_ptt(EndpointPermissions::from_tokens(&["read", "ptt"])); + client.write_all(b"TX;").await.expect("write"); + // Let the key reach the lease. + for _ in 0..50 { + if ptt.owner() == Some(1) { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert_eq!(ptt.owner(), Some(1), "client should hold the PTT lease"); + + // Close the transport. + drop(client); + + // The endpoint must release the lease promptly on disconnect. + for _ in 0..50 { + if ptt.owner().is_none() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert_eq!( + ptt.owner(), + None, + "PTT lease must be released when a keyed client disconnects" + ); + } + + #[tokio::test] + async fn lagged_session_does_not_permanently_lose_a_one_shot_mode_change() { + // Drive the endpoint past the broadcast ring capacity so it lags, after first changing + // the mode. A lagged endpoint that merely skips would render the old mode forever; the + // re-sync must restore the current mode to the client. + let (mut client, state) = spawn_ts590_session(EndpointPermissions::read_only()); + client.write_all(b"AI2;").await.expect("write"); + tokio::time::sleep(Duration::from_millis(20)).await; + + // The one-shot change we must not lose: switch to CW. + state.record( + StateChange::Mode { + vfo: Vfo::A, + mode: crate::model::Mode::Cw, + }, + RadioEventSource::NativePush, + ); + // Flood the ring well past its 256-entry capacity so the endpoint lags and the CW change + // is evicted before it is read. + for i in 0..2_000u64 { + state.record( + StateChange::Freq { + vfo: Vfo::A, + hz: 7_000_000 + i, + }, + RadioEventSource::PollDiff, + ); + } + + // Read frames until the re-sync emits the current CW mode (MD3;). + let mut saw_cw = false; + for _ in 0..64 { + let Ok(frame) = + tokio::time::timeout(Duration::from_secs(2), read_frame(&mut client)).await + else { + break; + }; + if frame == vec![b'M', b'D', mode_to_digit(crate::model::Mode::Cw), b';'] { + saw_cw = true; + break; + } + } + assert!( + saw_cw, + "after lagging, the endpoint must be re-synced to the current CW mode" + ); + } +} diff --git a/crates/cathub/src/state.rs b/crates/cathub/src/state.rs new file mode 100644 index 0000000..5a12f0e --- /dev/null +++ b/crates/cathub/src/state.rs @@ -0,0 +1,719 @@ +//! The universal radio state: a single in-memory snapshot every endpoint reads from, plus a +//! broadcast channel of [`StateChange`] notifications endpoints subscribe to for auto-info +//! fan-out (design §8.3, §8.4). +//! +//! [`StateHandle`] is synchronous (a plain `Mutex`/`RwLock`): reads and writes are short, +//! uncontended critical sections, so there is no reason to make callers `await`. A change +//! is broadcast **only when a field's value actually changes**, so an idempotent write +//! (re-setting the same frequency) produces no spurious notification. + +use std::collections::HashSet; +use std::sync::{Arc, Mutex, PoisonError, RwLock}; + +use tokio::sync::broadcast; + +use crate::model::{Field, Mode, RadioEventSource, StateChange, StateMutation, TxPower, Vfo}; + +/// A cached mode fact that may have been invalidated by a frequency change. The TS-590 +/// recalls mode and DATA independently from its per-band memory, so both facts must be +/// re-asserted after tuning even when the pre-tune snapshot already held the requested mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +enum ModeFact { + Base(Vfo), + Data(Vfo), +} + +/// Default IF passband reported for a VFO (Hz). Real backends may refine this; the default +/// matches the canonical Hamlib `get_mode` second line. +const DEFAULT_PASSBAND_HZ: u32 = 2_400; + +/// A point-in-time view of one VFO. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct VfoSnapshot { + /// Frequency in Hz. + pub(crate) freq_hz: u64, + /// Operating mode. + pub(crate) mode: Mode, + /// Whether the backend has observed this VFO's base mode. + pub(crate) mode_known: bool, + /// Whether the DATA sub-mode flag (TS-590 `DA`) is on for this VFO. + pub(crate) data: bool, + /// IF passband in Hz. + pub(crate) passband_hz: u32, +} + +impl Default for VfoSnapshot { + fn default() -> Self { + VfoSnapshot { + freq_hz: 0, + mode: Mode::Usb, + mode_known: false, + data: false, + passband_hz: DEFAULT_PASSBAND_HZ, + } + } +} + +/// A consistent point-in-time view of the whole radio. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[allow(clippy::struct_excessive_bools)] // Each flag mirrors an independent radio state. +pub(crate) struct Snapshot { + a: VfoSnapshot, + b: VfoSnapshot, + /// The receive VFO. + pub(crate) rx_vfo: Vfo, + /// The transmit VFO (relevant when split is enabled). + pub(crate) tx_vfo: Vfo, + /// Whether split is enabled. + pub(crate) split: bool, + /// Whether the transmitter is keyed. + pub(crate) ptt: bool, + /// Whether the radio reports power on. + pub(crate) power_on: bool, + /// Configured transmitter output power, when the backend can expose it. + pub(crate) tx_power: Option, + /// Whether RIT is enabled. + pub(crate) rit_enabled: bool, + /// RIT offset in Hz. + pub(crate) rit_offset_hz: i32, + /// Whether XIT is enabled. + pub(crate) xit_enabled: bool, + /// XIT offset in Hz. + pub(crate) xit_offset_hz: i32, +} + +impl Default for Snapshot { + fn default() -> Self { + Snapshot { + a: VfoSnapshot::default(), + b: VfoSnapshot::default(), + rx_vfo: Vfo::A, + tx_vfo: Vfo::A, + split: false, + ptt: false, + // A TS-590 answers `\get_powerstat` with "1" at rest. + power_on: true, + tx_power: None, + rit_enabled: false, + rit_offset_hz: 0, + xit_enabled: false, + xit_offset_hz: 0, + } + } +} + +impl Snapshot { + /// The view of one VFO. + pub(crate) fn vfo(&self, vfo: Vfo) -> VfoSnapshot { + match vfo { + Vfo::A => self.a, + Vfo::B => self.b, + } + } + + /// Whether applying `mutation` would leave the radio unchanged from this snapshot. + /// + /// The hub forwards a modeled write to the wire only when it would actually change the + /// radio. Re-sending a value the radio already holds is not just wasted I/O: on the + /// TS-590 every redundant `MD`/`FA` set makes the radio emit its PC-control beep (a + /// Morse "U"), which is why clients like WSJT-X - that re-assert mode/frequency on every + /// poll - chirp the radio through the hub but not through a native Hamlib driver, which + /// caches state and never re-sends an unchanged value. Suppressing the no-op keeps the + /// hub as quiet on the wire as the native driver. PTT is never redundant: keying and + /// unkeying must always reach the radio and participate in the single-owner lease. + pub(crate) fn is_redundant(&self, mutation: &StateMutation) -> bool { + match *mutation { + StateMutation::SetRxVfo { vfo } => self.rx_vfo == vfo, + StateMutation::SetVfoFreq { vfo, hz } => self.vfo(vfo).freq_hz == hz, + // Compare by the digit actually written to the radio, not the enum identity. + // WSJT-X asserts "PKTUSB", which decomposes into a base mode of USB (sent as MD2) + // plus a separate DATA flag handled by `SetDataMode` below. The TS-590 beeps on + // every mode set it receives (frequency sets are silent), so suppressing the no-op + // MD frame is what keeps it quiet, exactly like a native driver that never re-sends + // an unchanged mode. + StateMutation::SetMode { mode, .. } => { + self.vfo(self.rx_vfo).mode.to_kenwood_digit() == mode.to_kenwood_digit() + } + // The DATA flag (`DA`) is a separate wire fact from the base mode (`MD`): suppress + // a re-assert of the value the radio already holds so toggling, e.g., FT8↔WSPR + // (both PKTUSB) writes nothing after the first, and selecting a plain mode only + // emits `DA0` when DATA was actually on. + StateMutation::SetDataMode { vfo, on } => self.vfo(vfo).data == on, + StateMutation::SetSplit { enabled, tx_vfo } => { + self.split == enabled && self.tx_vfo == tx_vfo.unwrap_or(self.tx_vfo) + } + StateMutation::SetRit { enabled, offset_hz } => { + self.rit_enabled == enabled && self.rit_offset_hz == offset_hz + } + StateMutation::SetXit { enabled, offset_hz } => { + self.xit_enabled == enabled && self.xit_offset_hz == offset_hz + } + StateMutation::SetPtt { .. } => false, + } + } + + /// Decompose this snapshot into the full ordered list of [`StateChange`]s that + /// reconstruct it. + /// + /// Used to re-synchronize a client session that fell behind the broadcast ring + /// ([`RecvError::Lagged`](tokio::sync::broadcast::error::RecvError::Lagged)): replaying + /// these through the dialect's notification formatter restores the client to the current + /// radio state even when a one-shot event (a mode or VFO change) was evicted from the + /// ring before the endpoint read it. `RxVfo` is emitted last so a foreign dialect's + /// VFO-switch frame (which leads with the active `FA`/`MD`) reflects the final state. + pub(crate) fn as_changes(&self) -> Vec { + vec![ + StateChange::Freq { + vfo: Vfo::A, + hz: self.a.freq_hz, + }, + StateChange::Freq { + vfo: Vfo::B, + hz: self.b.freq_hz, + }, + StateChange::Mode { + vfo: Vfo::A, + mode: self.a.mode, + }, + StateChange::Mode { + vfo: Vfo::B, + mode: self.b.mode, + }, + StateChange::DataMode { + vfo: Vfo::A, + on: self.a.data, + }, + StateChange::DataMode { + vfo: Vfo::B, + on: self.b.data, + }, + StateChange::Split { + enabled: self.split, + tx_vfo: Some(self.tx_vfo), + }, + StateChange::Rit { + enabled: self.rit_enabled, + offset_hz: self.rit_offset_hz, + }, + StateChange::Xit { + enabled: self.xit_enabled, + offset_hz: self.xit_offset_hz, + }, + StateChange::Ptt { keyed: self.ptt }, + StateChange::TxPower { + power: self.tx_power, + }, + StateChange::RxVfo { vfo: self.rx_vfo }, + ] + } +} + +/// An ordered radio-output event delivered to endpoints for auto-information fan-out. +/// +/// Both modeled changes and unmodeled native frames travel on the **same** broadcast so +/// endpoints observe them in the order the radio produced them - important for native +/// pass-through clients that consume the CAT stream directly. +#[derive(Debug, Clone)] +pub(crate) enum RadioEvent { + /// A coalesced modeled state change (poll diff or native push). + Change(StateChange), + /// An unsolicited native frame the backend does not model, forwarded verbatim to + /// native pass-through endpoints so client-side feature state machines (for example + /// ARCP-590's NB on/NB1/NB2/off cycle) and front-panel changes stay in sync. + Raw(Arc<[u8]>), + /// A native frame the backend *did* model, forwarded verbatim for transparent mirror + /// endpoints (ARCP-590). Virtualizing endpoints ignore it - they consume the coalesced + /// [`RadioEvent::Change`] instead - but a transparent endpoint relays it so it tracks the + /// radio's real CAT stream rather than a synthesis, eliminating push/snapshot drift. + RawNative(Arc<[u8]>), +} + +struct Inner { + snapshot: RwLock, + covered: Mutex>, + uncertain_mode: Mutex>, + tx: broadcast::Sender, +} + +/// A clonable handle to the universal state. +#[derive(Clone)] +pub(crate) struct StateHandle { + inner: Arc, +} + +impl StateHandle { + /// Create an empty state at its defaults. + pub(crate) fn new() -> Self { + // Capacity headroom: a modeled native frame now broadcasts both a coalesced `Change` + // and a verbatim `RawNative`, so the bus carries up to two events per radio frame. + // A larger ring keeps a momentarily busy endpoint (notably a transparent mirror during a + // contest-rate tuning sweep) from lagging and having to re-sync. + let (tx, _rx) = broadcast::channel(1024); + StateHandle { + inner: Arc::new(Inner { + snapshot: RwLock::new(Snapshot::default()), + covered: Mutex::new(HashSet::new()), + uncertain_mode: Mutex::new(HashSet::new()), + tx, + }), + } + } + + /// Record an observed change. The change is applied to the snapshot and broadcast to + /// subscribers **only if it actually changes a value**. A [`RadioEventSource::NativePush`] + /// change additionally marks the field as natively covered so the poller can back off. + pub(crate) fn record(&self, change: StateChange, source: RadioEventSource) { + if source == RadioEventSource::NativePush { + self.inner + .covered + .lock() + .unwrap_or_else(PoisonError::into_inner) + .insert(change.field()); + } + let changed = { + let mut snap = self + .inner + .snapshot + .write() + .unwrap_or_else(PoisonError::into_inner); + apply_change(&mut snap, change) + }; + + // A TS-590 frequency set can cross a band boundary and synchronously recall that + // band's stored MD/DA settings. Until fresh MD and DA facts arrive, the old cached + // mode must not suppress a client's immediately following mode re-assertion (the + // normal WSJT-X band-change sequence is F then M). Each fact becomes certain again + // as soon as it is observed or successfully written, even when its value compares + // equal and therefore produced no broadcast change. + let mut uncertain = self + .inner + .uncertain_mode + .lock() + .unwrap_or_else(PoisonError::into_inner); + match change { + StateChange::Freq { vfo, .. } + if changed && source == RadioEventSource::OptimisticWrite => + { + uncertain.insert(ModeFact::Base(vfo)); + uncertain.insert(ModeFact::Data(vfo)); + } + StateChange::Mode { vfo, .. } => { + uncertain.remove(&ModeFact::Base(vfo)); + } + StateChange::DataMode { vfo, .. } => { + uncertain.remove(&ModeFact::Data(vfo)); + } + _ => {} + } + drop(uncertain); + if changed { + // A send error only means there are no subscribers; that is fine. + let _ = self.inner.tx.send(RadioEvent::Change(change)); + } + } + + /// Broadcast an unsolicited native frame the backend does not model, so native + /// pass-through endpoints can forward it verbatim. This does not touch the snapshot or the + /// native-push coverage set; it is a transparent relay of the radio's CAT stream. + pub(crate) fn record_raw(&self, frame: &[u8]) { + let _ = self.inner.tx.send(RadioEvent::Raw(Arc::from(frame))); + } + + /// Broadcast a *modeled* native frame verbatim for transparent mirror endpoints. The caller + /// has already recorded the modeled change (updating the snapshot and broadcasting a + /// coalesced [`RadioEvent::Change`]); this additionally relays the original bytes so a + /// transparent endpoint mirrors the radio's exact CAT stream. Non-transparent endpoints ignore + /// this event. It does not touch the snapshot or the native-push coverage set. + pub(crate) fn record_raw_native(&self, frame: &[u8]) { + let _ = self.inner.tx.send(RadioEvent::RawNative(Arc::from(frame))); + } + + /// A consistent point-in-time view of the radio. + pub(crate) fn snapshot(&self) -> Snapshot { + *self + .inner + .snapshot + .read() + .unwrap_or_else(PoisonError::into_inner) + } + + /// Whether a modeled write is safely redundant against the current cache. + /// + /// Mode facts invalidated by an optimistic frequency change are deliberately treated as + /// non-redundant until the radio reports them or a client re-asserts them. Other fields + /// retain the snapshot's ordinary idempotent suppression. + pub(crate) fn is_redundant(&self, mutation: &StateMutation) -> bool { + let uncertain = self + .inner + .uncertain_mode + .lock() + .unwrap_or_else(PoisonError::into_inner); + let invalidated = match *mutation { + StateMutation::SetMode { vfo, .. } => uncertain.contains(&ModeFact::Base(vfo)), + StateMutation::SetDataMode { vfo, .. } => uncertain.contains(&ModeFact::Data(vfo)), + _ => false, + }; + !invalidated && self.snapshot().is_redundant(mutation) + } + + /// Subscribe to the radio-output event stream for a client session's auto-info fan-out. + pub(crate) fn subscribe(&self) -> broadcast::Receiver { + self.inner.tx.subscribe() + } + + /// Whether the radio's native push stream has been observed to cover this field. + pub(crate) fn is_native_push_covered(&self, field: Field) -> bool { + self.inner + .covered + .lock() + .unwrap_or_else(PoisonError::into_inner) + .contains(&field) + } +} + +/// Apply a change to the snapshot, returning whether any value actually changed. +fn apply_change(snap: &mut Snapshot, change: StateChange) -> bool { + match change { + StateChange::RxVfo { vfo } => { + if snap.rx_vfo == vfo { + false + } else { + snap.rx_vfo = vfo; + true + } + } + StateChange::Freq { vfo, hz } => { + let target = vfo_mut(snap, vfo); + if target.freq_hz == hz { + false + } else { + target.freq_hz = hz; + true + } + } + StateChange::Mode { vfo, mode } => { + let target = vfo_mut(snap, vfo); + let changed = target.mode != mode; + target.mode = mode; + target.mode_known = true; + changed + } + StateChange::DataMode { vfo, on } => { + let target = vfo_mut(snap, vfo); + if target.data == on { + false + } else { + target.data = on; + true + } + } + StateChange::Split { enabled, tx_vfo } => { + let new_tx = tx_vfo.unwrap_or(snap.tx_vfo); + if snap.split == enabled && snap.tx_vfo == new_tx { + false + } else { + snap.split = enabled; + snap.tx_vfo = new_tx; + true + } + } + StateChange::Ptt { keyed } => { + if snap.ptt == keyed { + false + } else { + snap.ptt = keyed; + true + } + } + StateChange::Rit { enabled, offset_hz } => { + if snap.rit_enabled == enabled && snap.rit_offset_hz == offset_hz { + false + } else { + snap.rit_enabled = enabled; + snap.rit_offset_hz = offset_hz; + true + } + } + StateChange::Xit { enabled, offset_hz } => { + if snap.xit_enabled == enabled && snap.xit_offset_hz == offset_hz { + false + } else { + snap.xit_enabled = enabled; + snap.xit_offset_hz = offset_hz; + true + } + } + StateChange::TxPower { power } => { + if snap.tx_power == power { + false + } else { + snap.tx_power = power; + true + } + } + } +} + +fn vfo_mut(snap: &mut Snapshot, vfo: Vfo) -> &mut VfoSnapshot { + match vfo { + Vfo::A => &mut snap.a, + Vfo::B => &mut snap.b, + } +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)] +mod tests { + use super::*; + use crate::model::PttSource; + + #[test] + fn defaults_are_sane() { + let snap = StateHandle::new().snapshot(); + assert!(snap.power_on); + assert_eq!(snap.vfo(Vfo::A).freq_hz, 0); + assert_eq!(snap.vfo(Vfo::A).mode, Mode::Usb); + assert_eq!(snap.vfo(Vfo::A).passband_hz, 2_400); + assert!(!snap.split && !snap.ptt); + } + + #[test] + fn record_updates_snapshot() { + let state = StateHandle::new(); + state.record( + StateChange::Freq { + vfo: Vfo::A, + hz: 7_030_000, + }, + RadioEventSource::PollDiff, + ); + assert_eq!(state.snapshot().vfo(Vfo::A).freq_hz, 7_030_000); + } + + #[test] + fn is_redundant_detects_unchanged_values() { + let state = StateHandle::new(); + state.record( + StateChange::Freq { + vfo: Vfo::A, + hz: 7_030_000, + }, + RadioEventSource::PollDiff, + ); + let snap = state.snapshot(); + + // Re-setting the value the radio already holds is redundant. + assert!(snap.is_redundant(&StateMutation::SetVfoFreq { + vfo: Vfo::A, + hz: 7_030_000, + })); + assert!(!snap.is_redundant(&StateMutation::SetVfoFreq { + vfo: Vfo::A, + hz: 7_040_000, + })); + + // Mode is tracked against the receive VFO (default A, USB). + assert!(snap.is_redundant(&StateMutation::SetMode { + vfo: Vfo::A, + mode: Mode::Usb, + })); + // An Unknown mode still writes the USB digit (MD2), so a SetMode(Unknown) on a + // USB VFO is the same frame the radio already holds and must be redundant. + assert!(snap.is_redundant(&StateMutation::SetMode { + vfo: Vfo::A, + mode: Mode::Unknown, + })); + assert!(!snap.is_redundant(&StateMutation::SetMode { + vfo: Vfo::A, + mode: Mode::Cw, + })); + } + + #[test] + fn is_redundant_and_round_trip_for_data_mode() { + let state = StateHandle::new(); + let snap = state.snapshot(); + // DATA defaults off, so SetDataMode { on: false } is a no-op and on: true is a change. + assert!(snap.is_redundant(&StateMutation::SetDataMode { + vfo: Vfo::A, + on: false, + })); + assert!(!snap.is_redundant(&StateMutation::SetDataMode { + vfo: Vfo::A, + on: true, + })); + + // Recording the change flips the snapshot and inverts which write is redundant. + state.record( + StateChange::DataMode { + vfo: Vfo::A, + on: true, + }, + RadioEventSource::OptimisticWrite, + ); + let snap = state.snapshot(); + assert!(snap.vfo(Vfo::A).data); + assert!(snap.is_redundant(&StateMutation::SetDataMode { + vfo: Vfo::A, + on: true, + })); + assert!(!snap.is_redundant(&StateMutation::SetDataMode { + vfo: Vfo::A, + on: false, + })); + } + + #[test] + fn is_redundant_covers_split_rit_xit() { + let snap = StateHandle::new().snapshot(); + // Defaults: split off, RIT/XIT off at zero offset. + assert!(snap.is_redundant(&StateMutation::SetSplit { + enabled: false, + tx_vfo: None, + })); + assert!(!snap.is_redundant(&StateMutation::SetSplit { + enabled: true, + tx_vfo: Some(Vfo::B), + })); + assert!(snap.is_redundant(&StateMutation::SetRit { + enabled: false, + offset_hz: 0, + })); + assert!(!snap.is_redundant(&StateMutation::SetRit { + enabled: true, + offset_hz: 100, + })); + assert!(snap.is_redundant(&StateMutation::SetXit { + enabled: false, + offset_hz: 0, + })); + assert!(!snap.is_redundant(&StateMutation::SetXit { + enabled: true, + offset_hz: -50, + })); + } + + #[test] + fn is_redundant_never_suppresses_ptt() { + let snap = StateHandle::new().snapshot(); + // PTT is off by default, but an unkey must still always reach the radio. + assert!(!snap.is_redundant(&StateMutation::SetPtt { + keyed: false, + source: PttSource::Generic, + })); + assert!(!snap.is_redundant(&StateMutation::SetPtt { + keyed: true, + source: PttSource::Generic, + })); + } + + #[tokio::test] + async fn change_broadcasts_only_on_actual_change() { + let state = StateHandle::new(); + let mut rx = state.subscribe(); + // First set: USB == default USB, so NO broadcast. + state.record( + StateChange::Mode { + vfo: Vfo::A, + mode: Mode::Usb, + }, + RadioEventSource::PollDiff, + ); + // A real change: CW differs from USB, so exactly one frame. + state.record( + StateChange::Mode { + vfo: Vfo::A, + mode: Mode::Cw, + }, + RadioEventSource::PollDiff, + ); + let got = rx.try_recv().expect("one change"); + assert!( + matches!( + got, + RadioEvent::Change(StateChange::Mode { + vfo: Vfo::A, + mode: Mode::Cw + }) + ), + "expected a single CW mode change, got {got:?}" + ); + assert!(rx.try_recv().is_err(), "no second frame for the no-op set"); + } + + #[tokio::test] + async fn record_raw_broadcasts_verbatim_without_touching_snapshot() { + let state = StateHandle::new(); + let mut rx = state.subscribe(); + state.record_raw(b"NB1;"); + let evt = rx.try_recv().expect("one raw event"); + assert!( + matches!(&evt, RadioEvent::Raw(bytes) if &**bytes == b"NB1;"), + "expected RadioEvent::Raw(NB1;), got {evt:?}" + ); + // A raw relay must not mark native-push coverage. + assert!(!state.is_native_push_covered(Field::Freq(Vfo::A))); + } + + #[tokio::test] + async fn record_raw_native_broadcasts_verbatim_without_touching_snapshot() { + let state = StateHandle::new(); + let mut rx = state.subscribe(); + state.record_raw_native(b"FA00014035000;"); + let evt = rx.try_recv().expect("one raw-native event"); + assert!( + matches!(&evt, RadioEvent::RawNative(bytes) if &**bytes == b"FA00014035000;"), + "expected RadioEvent::RawNative(FA...), got {evt:?}" + ); + // Relaying the verbatim frame must not itself mutate the snapshot or coverage; the + // caller has already recorded the modeled change. + assert_eq!(state.snapshot().vfo(Vfo::A).freq_hz, 0); + assert!(!state.is_native_push_covered(Field::Freq(Vfo::A))); + } + + #[test] + fn native_push_marks_coverage_but_poll_diff_does_not() { + let state = StateHandle::new(); + assert!(!state.is_native_push_covered(Field::Freq(Vfo::A))); + state.record( + StateChange::Freq { vfo: Vfo::A, hz: 1 }, + RadioEventSource::PollDiff, + ); + assert!(!state.is_native_push_covered(Field::Freq(Vfo::A))); + state.record( + StateChange::Freq { vfo: Vfo::A, hz: 2 }, + RadioEventSource::NativePush, + ); + assert!(state.is_native_push_covered(Field::Freq(Vfo::A))); + } + + #[test] + fn split_and_rit_xit_round_trip() { + let state = StateHandle::new(); + state.record( + StateChange::Split { + enabled: true, + tx_vfo: Some(Vfo::B), + }, + RadioEventSource::OptimisticWrite, + ); + state.record( + StateChange::Rit { + enabled: true, + offset_hz: 100, + }, + RadioEventSource::OptimisticWrite, + ); + state.record( + StateChange::Xit { + enabled: true, + offset_hz: -50, + }, + RadioEventSource::OptimisticWrite, + ); + let snap = state.snapshot(); + assert!(snap.split && snap.tx_vfo == Vfo::B); + assert!(snap.rit_enabled && snap.rit_offset_hz == 100); + assert!(snap.xit_enabled && snap.xit_offset_hz == -50); + } +} diff --git a/crates/cathub/src/winkeyer/actor.rs b/crates/cathub/src/winkeyer/actor.rs new file mode 100644 index 0000000..ed2ca6f --- /dev/null +++ b/crates/cathub/src/winkeyer/actor.rs @@ -0,0 +1,1274 @@ +//! Physical WinKeyer actor and typed in-process broker handle. + +use std::time::{Duration, Instant}; +use std::{future::Future, io}; + +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; +use tokio::sync::{broadcast, mpsc, oneshot, watch}; + +use super::broker::{ + BrokerCore, BrokerSnapshot, ClientId, JobId, PhysicalAction, SpeedMode, TransmitPayload, + MAX_JOB_BYTES, MAX_QUEUED_JOBS, +}; +use super::protocol::DeviceEvent; +use crate::ptt::{PttDenied, PttManager}; + +const REQUEST_CAPACITY: usize = 128; +const EVENT_CAPACITY: usize = 256; +const HOST_OPEN_TIMEOUT: Duration = Duration::from_millis(750); +const ACTOR_TICK: Duration = Duration::from_millis(50); +const SHORT_JOB_SETTLE: Duration = Duration::from_millis(250); +const STATUS_POLL_INTERVAL: Duration = Duration::from_millis(100); +const WINKEYER_STATION_TX_OWNER: u64 = u64::MAX; +const RECONNECT_INITIAL: Duration = Duration::from_millis(250); +const RECONNECT_MAX: Duration = Duration::from_secs(5); + +/// Errors returned by the broker handle. +#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)] +pub(crate) enum BrokerError { + #[error("WinKeyer broker is unavailable")] + Unavailable, + #[error("WinKeyer client is not registered")] + UnknownClient, + #[error("a primary WinKeyer client is already registered")] + PrimaryExists, + #[error("WinKeyer is not connected")] + NotConnected, + #[error("invalid WinKeyer request: {0}")] + Invalid(String), + #[error("WinKeyer transport: {0}")] + Transport(String), + #[error("station transmit is owned by another client")] + TransmitBusy, + #[error("WinKeyer maintenance is owned by another client or transmit work is pending")] + MaintenanceBusy, + #[error("WinKeyer transmit queue is full")] + QueueFull, +} + +/// Events emitted by the broker for virtual endpoints and diagnostics. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum BrokerEvent { + Connected { firmware_revision: u8 }, + SpeedPot { raw: u8, value: u8, wpm: u8 }, + Status { raw: u8 }, + Echo(u8), + MaintenanceByte { client_id: ClientId, byte: u8 }, + Completed { job_id: JobId, client_id: ClientId }, + Canceled { job_id: JobId, client_id: ClientId }, + Error(String), +} + +enum Request { + Register { + client_id: ClientId, + primary: bool, + reply: oneshot::Sender>, + }, + Unregister { + client_id: ClientId, + }, + SetSpeed { + client_id: ClientId, + speed: SpeedMode, + reply: oneshot::Sender>, + }, + Enqueue { + client_id: ClientId, + payload: TransmitPayload, + speed: Option, + stream: bool, + reply: oneshot::Sender>, + }, + Cancel { + client_id: ClientId, + include_active: bool, + reply: oneshot::Sender>, + }, + CancelJob { + client_id: ClientId, + job_id: JobId, + reply: oneshot::Sender>, + }, + EmergencyStop { + reason: String, + reply: oneshot::Sender>, + }, + Configure { + client_id: ClientId, + command: Vec, + reply: oneshot::Sender>, + }, + ActiveOwnerCommand { + client_id: ClientId, + command: Vec, + reply: oneshot::Sender>, + }, + MaintenanceCommand { + client_id: ClientId, + command: Vec, + reply: oneshot::Sender>, + }, + ReleaseMaintenance { + client_id: ClientId, + }, + Shutdown { + reply: oneshot::Sender<()>, + }, +} + +/// Cloneable command/status surface shared by gRPC and virtual COM endpoints. +#[derive(Clone)] +pub(crate) struct BrokerHandle { + requests: mpsc::Sender, + snapshots: watch::Receiver, + events: broadcast::Sender, +} + +impl BrokerHandle { + pub(crate) async fn register( + &self, + client_id: ClientId, + primary: bool, + ) -> Result<(), BrokerError> { + let (reply, response) = oneshot::channel(); + self.send(Request::Register { + client_id, + primary, + reply, + }) + .await?; + response.await.map_err(|_| BrokerError::Unavailable)? + } + + pub(crate) async fn unregister(&self, client_id: ClientId) -> Result<(), BrokerError> { + self.send(Request::Unregister { client_id }).await + } + + pub(crate) async fn set_speed( + &self, + client_id: ClientId, + speed: SpeedMode, + ) -> Result<(), BrokerError> { + let (reply, response) = oneshot::channel(); + self.send(Request::SetSpeed { + client_id, + speed, + reply, + }) + .await?; + response.await.map_err(|_| BrokerError::Unavailable)? + } + + pub(crate) async fn enqueue( + &self, + client_id: ClientId, + bytes: Vec, + speed: Option, + ) -> Result { + let (reply, response) = oneshot::channel(); + self.send(Request::Enqueue { + client_id, + payload: TransmitPayload::plain_text(bytes), + speed, + stream: false, + reply, + }) + .await?; + response.await.map_err(|_| BrokerError::Unavailable)? + } + + pub(crate) async fn stream( + &self, + client_id: ClientId, + control_prefix: Vec, + intended_text: Vec, + ) -> Result { + let (reply, response) = oneshot::channel(); + self.send(Request::Enqueue { + client_id, + payload: TransmitPayload::legacy_stream(control_prefix, intended_text), + speed: None, + stream: true, + reply, + }) + .await?; + response.await.map_err(|_| BrokerError::Unavailable)? + } + + pub(crate) async fn configure( + &self, + client_id: ClientId, + command: Vec, + ) -> Result<(), BrokerError> { + let (reply, response) = oneshot::channel(); + self.send(Request::Configure { + client_id, + command, + reply, + }) + .await?; + response.await.map_err(|_| BrokerError::Unavailable)? + } + + pub(crate) async fn active_owner_command( + &self, + client_id: ClientId, + command: Vec, + ) -> Result<(), BrokerError> { + let (reply, response) = oneshot::channel(); + self.send(Request::ActiveOwnerCommand { + client_id, + command, + reply, + }) + .await?; + response.await.map_err(|_| BrokerError::Unavailable)? + } + + pub(crate) async fn maintenance_command( + &self, + client_id: ClientId, + command: Vec, + ) -> Result<(), BrokerError> { + let (reply, response) = oneshot::channel(); + self.send(Request::MaintenanceCommand { + client_id, + command, + reply, + }) + .await?; + response.await.map_err(|_| BrokerError::Unavailable)? + } + + pub(crate) async fn release_maintenance(&self, client_id: ClientId) -> Result<(), BrokerError> { + self.send(Request::ReleaseMaintenance { client_id }).await + } + + pub(crate) async fn cancel( + &self, + client_id: ClientId, + include_active: bool, + ) -> Result<(), BrokerError> { + let (reply, response) = oneshot::channel(); + self.send(Request::Cancel { + client_id, + include_active, + reply, + }) + .await?; + response.await.map_err(|_| BrokerError::Unavailable)? + } + + pub(crate) async fn cancel_job( + &self, + client_id: ClientId, + job_id: JobId, + ) -> Result { + let (reply, response) = oneshot::channel(); + self.send(Request::CancelJob { + client_id, + job_id, + reply, + }) + .await?; + response.await.map_err(|_| BrokerError::Unavailable)? + } + + pub(crate) async fn emergency_stop( + &self, + reason: impl Into, + ) -> Result<(), BrokerError> { + let (reply, response) = oneshot::channel(); + self.send(Request::EmergencyStop { + reason: reason.into(), + reply, + }) + .await?; + response.await.map_err(|_| BrokerError::Unavailable)? + } + + pub(crate) fn snapshot(&self) -> BrokerSnapshot { + self.snapshots.borrow().clone() + } + + pub(crate) fn subscribe(&self) -> broadcast::Receiver { + self.events.subscribe() + } + + pub(crate) async fn shutdown(&self) -> Result<(), BrokerError> { + let (reply, response) = oneshot::channel(); + self.send(Request::Shutdown { reply }).await?; + response.await.map_err(|_| BrokerError::Unavailable) + } + + async fn send(&self, request: Request) -> Result<(), BrokerError> { + self.requests + .send(request) + .await + .map_err(|_| BrokerError::Unavailable) + } +} + +/// Open a host session and spawn the physical actor over any async byte transport. +#[cfg(test)] +pub(crate) async fn spawn(transport: T, max_tx: Duration) -> Result +where + T: AsyncRead + AsyncWrite + Send + Unpin + 'static, +{ + spawn_inner(transport, max_tx, None, false, false, || async { + Err(io::Error::other("reconnect disabled")) + }) + .await +} + +/// Spawn a broker integrated with the station-wide CAT/PTT ownership lease. +#[cfg(test)] +pub(crate) async fn spawn_with_ptt( + transport: T, + max_tx: Duration, + ptt: PttManager, +) -> Result +where + T: AsyncRead + AsyncWrite + Send + Unpin + 'static, +{ + spawn_inner(transport, max_tx, Some(ptt), false, false, || async { + Err(io::Error::other("reconnect disabled")) + }) + .await +} + +/// Spawn a broker whose physical transport is reopened with bounded backoff after failure. +pub(crate) async fn spawn_supervised( + transport: T, + max_tx: Duration, + ptt: PttManager, + opener: O, +) -> Result +where + T: AsyncRead + AsyncWrite + Send + Unpin + 'static, + O: FnMut() -> F + Send + 'static, + F: Future> + Send + 'static, +{ + spawn_inner(transport, max_tx, Some(ptt), true, true, opener).await +} + +async fn spawn_inner( + mut transport: T, + max_tx: Duration, + ptt: Option, + reconnect: bool, + zero_is_idle: bool, + opener: O, +) -> Result +where + T: AsyncRead + AsyncWrite + Send + Unpin + 'static, + O: FnMut() -> F + Send + 'static, + F: Future> + Send + 'static, +{ + let firmware_revision = initialize_transport(&mut transport, zero_is_idle).await?; + + let mut core = BrokerCore::new(max_tx); + core.connect_physical(firmware_revision); + let initial = core.snapshot(); + let (request_tx, request_rx) = mpsc::channel(REQUEST_CAPACITY); + let (snapshot_tx, snapshot_rx) = watch::channel(initial); + let (event_tx, _) = broadcast::channel(EVENT_CAPACITY); + let handle = BrokerHandle { + requests: request_tx, + snapshots: snapshot_rx, + events: event_tx.clone(), + }; + let _ = event_tx.send(BrokerEvent::Connected { firmware_revision }); + tokio::spawn(run_actor( + transport, + core, + request_rx, + snapshot_tx, + event_tx, + ptt, + reconnect, + zero_is_idle, + opener, + )); + Ok(handle) +} + +async fn initialize_transport(transport: &mut T, zero_is_idle: bool) -> Result +where + T: AsyncRead + AsyncWrite + Unpin, +{ + transport + .write_all(&[0x00, 0x02]) + .await + .map_err(transport_error)?; + transport.flush().await.map_err(transport_error)?; + let firmware_revision = tokio::time::timeout( + HOST_OPEN_TIMEOUT, + read_transport_byte(transport, zero_is_idle), + ) + .await + .map_err(|_| BrokerError::Transport("Host Open timed out".to_string()))? + .map_err(transport_error)?; + if firmware_revision == 0xff { + return Err(BrokerError::Transport( + "received 0xFF firmware response; verify baud rate".to_string(), + )); + } + transport + .write_all(&[0x0a, 0x0b, 0x00, 0x02, 0x00, 0x07, 0x15]) + .await + .map_err(transport_error)?; + transport.flush().await.map_err(transport_error)?; + Ok(firmware_revision) +} + +#[allow(clippy::too_many_arguments, clippy::too_many_lines)] // One supervised ownership loop. +async fn run_actor( + mut transport: T, + mut core: BrokerCore, + mut requests: mpsc::Receiver, + snapshots: watch::Sender, + events: broadcast::Sender, + ptt: Option, + reconnect: bool, + zero_is_idle: bool, + mut opener: O, +) where + T: AsyncRead + AsyncWrite + Send + Unpin + 'static, + O: FnMut() -> F + Send + 'static, + F: Future> + Send + 'static, +{ + let mut maintenance_owner = None; + let mut maintenance_reopen_pending = false; + let mut reconnect_delay = RECONNECT_INITIAL; + + 'actor: loop { + let (mut reader, mut writer) = tokio::io::split(&mut transport); + let mut tick = tokio::time::interval(ACTOR_TICK); + let mut last_status_poll = Instant::now(); + let session_error = 'session: loop { + tokio::select! { + request = requests.recv() => { + let Some(request) = request else { break 'actor }; + match handle_request( + request, + &mut core, + &mut writer, + &events, + ptt.as_ref(), + &mut maintenance_owner, + &mut maintenance_reopen_pending, + ).await { + Ok(true) => { + publish_snapshot(&core, &snapshots); + break 'actor; + } + Ok(false) => {} + Err(error) => break 'session format!("request write failed: {error}"), + } + release_station_tx_if_idle(&core, ptt.as_ref()); + publish_snapshot(&core, &snapshots); + } + byte = read_transport_byte(&mut reader, zero_is_idle) => { + match byte { + Ok(byte) => { + tracing::trace!(byte, "WinKeyer physical rx"); + if maintenance_reopen_pending { + maintenance_reopen_pending = false; + if byte == 0xff { + break 'session "maintenance Host Open returned 0xFF".to_string(); + } + core.connect_physical(byte); + let mut actions = vec![PhysicalAction::Write(vec![ + 0x0a, 0x0b, 0x00, 0x02, 0x00, 0x07, 0x15, + ])]; + actions.extend(core.restore_foreground()); + if apply_actions(actions, &mut writer, &events).await.is_err() { + break 'session "maintenance restore write failed".to_string(); + } + let _ = events.send(BrokerEvent::Connected { + firmware_revision: byte, + }); + publish_snapshot(&core, &snapshots); + continue; + } + if let Some(client_id) = maintenance_owner { + let _ = events.send(BrokerEvent::MaintenanceByte { client_id, byte }); + continue; + } + let actions = match DeviceEvent::from_byte(byte) { + DeviceEvent::SpeedPot { raw, value } => { + core.observe_pot(value); + let wpm = core.snapshot().pot_wpm.unwrap_or(value); + let _ = events.send(BrokerEvent::SpeedPot { raw, value, wpm }); + Vec::new() + } + DeviceEvent::Status(status) => { + let _ = events.send(BrokerEvent::Status { raw: status.raw }); + let physical_tx = status.busy || status.break_in || status.key_down; + if physical_tx && core.snapshot().active_job_id.is_none() { + if let Some(ptt) = &ptt { + if matches!( + ptt.try_key(WINKEYER_STATION_TX_OWNER, true), + Err(PttDenied::Busy) + ) { + let message = "physical WinKeyer paddle/key activity conflicted with CAT PTT ownership"; + core.record_safety_action(message); + let _ = events.send(BrokerEvent::Error(message.to_string())); + } + } + } + core.observe_status(status) + } + DeviceEvent::Echo(byte) => { + let _ = events.send(BrokerEvent::Echo(byte)); + Vec::new() + } + }; + if apply_actions(actions, &mut writer, &events).await.is_err() { + break 'session "write failed".to_string(); + } + publish_snapshot(&core, &snapshots); + release_station_tx_if_idle(&core, ptt.as_ref()); + } + Err(error) => { + break 'session format!("read failed: {error}"); + } + } + } + _ = tick.tick() => { + let now = Instant::now(); + let mut actions = core.watchdog(now); + actions.extend(core.confirm_idle_after_settle(now, SHORT_JOB_SETTLE)); + if core.snapshot().busy && now.duration_since(last_status_poll) >= STATUS_POLL_INTERVAL { + actions.push(PhysicalAction::Write(vec![0x15])); + last_status_poll = now; + } + if apply_actions(actions, &mut writer, &events).await.is_err() { + break 'session "write failed".to_string(); + } + publish_snapshot(&core, &snapshots); + release_station_tx_if_idle(&core, ptt.as_ref()); + } + } + }; + + drop(reader); + drop(writer); + maintenance_owner = None; + maintenance_reopen_pending = false; + let actions = core.physical_error(session_error.clone()); + let _ = events.send(BrokerEvent::Error(session_error)); + // Cancellations are notifications only after the transport has failed. + let mut sink = tokio::io::sink(); + let _ = apply_actions(actions, &mut sink, &events).await; + if let Some(ptt) = &ptt { + ptt.unkey(WINKEYER_STATION_TX_OWNER); + } + publish_snapshot(&core, &snapshots); + + loop { + let delay = if reconnect { + reconnect_delay + } else { + Duration::from_secs(86_400) + }; + tokio::select! { + request = requests.recv() => { + let Some(request) = request else { break 'actor }; + match handle_request( + request, + &mut core, + &mut sink, + &events, + ptt.as_ref(), + &mut maintenance_owner, + &mut maintenance_reopen_pending, + ).await { + Ok(true) => break 'actor, + Ok(false) => {} + Err(error) => { + let _ = events.send(BrokerEvent::Error(error.to_string())); + } + } + publish_snapshot(&core, &snapshots); + } + () = tokio::time::sleep(delay), if reconnect => { + match opener().await { + Ok(mut candidate) => match initialize_transport(&mut candidate, zero_is_idle).await { + Ok(firmware_revision) => { + transport = candidate; + core.connect_physical(firmware_revision); + let actions = core.restore_foreground(); + if let Err(error) = apply_actions(actions, &mut transport, &events).await { + let message = format!("reconnect restore failed: {error}"); + let _ = events.send(BrokerEvent::Error(message.clone())); + core.physical_error(message); + publish_snapshot(&core, &snapshots); + reconnect_delay = (reconnect_delay * 2).min(RECONNECT_MAX); + continue; + } + let _ = events.send(BrokerEvent::Connected { firmware_revision }); + publish_snapshot(&core, &snapshots); + reconnect_delay = RECONNECT_INITIAL; + break; + } + Err(error) => { + let message = format!("reconnect initialization failed: {error}"); + let _ = events.send(BrokerEvent::Error(message.clone())); + core.physical_error(message); + publish_snapshot(&core, &snapshots); + } + }, + Err(error) => { + let message = format!("reconnect open failed: {error}"); + let _ = events.send(BrokerEvent::Error(message.clone())); + core.physical_error(message); + publish_snapshot(&core, &snapshots); + } + } + reconnect_delay = (reconnect_delay * 2).min(RECONNECT_MAX); + } + } + } + } + + // Best effort. Clear buffer, force key-up, then return to standalone EEPROM settings. + let _ = transport.write_all(&[0x0a, 0x0b, 0x00, 0x00, 0x03]).await; + let _ = transport.flush().await; + if let Some(ptt) = &ptt { + ptt.unkey(WINKEYER_STATION_TX_OWNER); + } +} + +/// Windows serial drivers may report a zero-length asynchronous read for an idle timeout. +/// A byte stream EOF is meaningful for sockets/duplex tests, but not for an open COM port; +/// retry until a byte or a real I/O error arrives. Host Open applies its own deadline. +async fn read_transport_byte(transport: &mut T, zero_is_idle: bool) -> io::Result +where + T: AsyncRead + Unpin, +{ + let mut byte = [0_u8; 1]; + loop { + match transport.read(&mut byte).await? { + 0 if zero_is_idle => tokio::time::sleep(Duration::from_millis(1)).await, + 0 => return Err(io::Error::from(io::ErrorKind::UnexpectedEof)), + _ => return Ok(byte[0]), + } + } +} + +#[allow(clippy::single_match_else, clippy::too_many_lines)] // Exhaustive request arbitration. +async fn handle_request( + request: Request, + core: &mut BrokerCore, + writer: &mut W, + events: &broadcast::Sender, + ptt: Option<&PttManager>, + maintenance_owner: &mut Option, + maintenance_reopen_pending: &mut bool, +) -> Result +where + W: AsyncWrite + Unpin, +{ + let actions = match request { + Request::Register { + client_id, + primary, + reply, + } => { + let result = if core.register_client(client_id, primary) { + Ok(()) + } else { + Err(BrokerError::PrimaryExists) + }; + let _ = reply.send(result); + Vec::new() + } + Request::Unregister { client_id } => { + let mut actions = core.unregister_client(client_id); + if *maintenance_owner == Some(client_id) { + *maintenance_owner = None; + *maintenance_reopen_pending = true; + actions.insert(0, PhysicalAction::Write(vec![0x00, 0x02])); + } + actions + } + Request::SetSpeed { + client_id, + speed, + reply, + } => { + if *maintenance_reopen_pending + || maintenance_owner.is_some_and(|owner| owner != client_id) + { + let _ = reply.send(Err(BrokerError::MaintenanceBusy)); + return Ok(false); + } + let known = core.set_client_speed(client_id, speed); + let result = if core.snapshot().connected { + Ok(()) + } else { + Err(BrokerError::NotConnected) + }; + let _ = reply.send(result); + known + } + Request::Enqueue { + client_id, + payload, + speed, + stream, + reply, + } => { + if maintenance_owner.is_some() || *maintenance_reopen_pending { + let _ = reply.send(Err(BrokerError::MaintenanceBusy)); + return Ok(false); + } + let snapshot = core.snapshot(); + if payload.wire_bytes().is_empty() || payload.wire_bytes().len() > MAX_JOB_BYTES { + let _ = reply.send(Err(BrokerError::Invalid(format!( + "job payload must contain 1 through {MAX_JOB_BYTES} bytes" + )))); + return Ok(false); + } + if snapshot.queued_jobs >= MAX_QUEUED_JOBS + && !(stream && snapshot.active_client_id == Some(client_id)) + { + let _ = reply.send(Err(BrokerError::QueueFull)); + return Ok(false); + } + if snapshot.active_job_id.is_none() + && (snapshot.busy || snapshot.break_in || snapshot.key_down) + { + let _ = reply.send(Err(BrokerError::TransmitBusy)); + return Ok(false); + } + if snapshot.active_job_id.is_none() { + if let Some(ptt) = ptt { + if matches!( + ptt.try_key(WINKEYER_STATION_TX_OWNER, true), + Err(PttDenied::Busy) + ) { + let _ = reply.send(Err(BrokerError::TransmitBusy)); + return Ok(false); + } + } + } + tracing::trace!( + client_id, + intended_text = %String::from_utf8_lossy(payload.intended_text()), + wire_bytes = ?payload.wire_bytes(), + "WinKeyer transmit payload" + ); + match core.enqueue(client_id, payload, speed, stream, Instant::now()) { + Some((job_id, actions)) => { + let _ = reply.send(Ok(job_id)); + actions + } + None => { + let error = if core.snapshot().connected { + BrokerError::UnknownClient + } else { + BrokerError::NotConnected + }; + let _ = reply.send(Err(error)); + Vec::new() + } + } + } + Request::Cancel { + client_id, + include_active, + reply, + } => { + let actions = core.cancel_client(client_id, include_active); + let _ = reply.send(Ok(())); + actions + } + Request::CancelJob { + client_id, + job_id, + reply, + } => { + let (canceled, actions) = core.cancel_job(client_id, job_id); + let _ = reply.send(Ok(canceled)); + actions + } + Request::EmergencyStop { reason, reply } => { + *maintenance_owner = None; + *maintenance_reopen_pending = false; + let actions = core.emergency_stop(&reason); + let _ = reply.send(Ok(())); + actions + } + Request::Configure { + client_id, + command, + reply, + } => { + if *maintenance_reopen_pending + || maintenance_owner.is_some_and(|owner| owner != client_id) + { + let _ = reply.send(Err(BrokerError::MaintenanceBusy)); + Vec::new() + } else { + match core.set_client_command(client_id, command) { + Some(actions) => { + let _ = reply.send(Ok(())); + actions + } + None => { + let _ = reply.send(Err(BrokerError::UnknownClient)); + Vec::new() + } + } + } + } + Request::MaintenanceCommand { + client_id, + command, + reply, + } => { + let snapshot = core.snapshot(); + let owner_allowed = maintenance_owner.is_none_or(|owner| owner == client_id); + if !owner_allowed || snapshot.active_job_id.is_some() || snapshot.queued_jobs != 0 { + let _ = reply.send(Err(BrokerError::MaintenanceBusy)); + Vec::new() + } else if !snapshot.connected { + let _ = reply.send(Err(BrokerError::NotConnected)); + Vec::new() + } else { + let first_command = maintenance_owner.is_none(); + *maintenance_owner = Some(client_id); + let _ = reply.send(Ok(())); + let mut actions = Vec::new(); + if first_command { + actions.push(PhysicalAction::Write(vec![0x0a, 0x0b, 0x00, 0x03])); + } + actions.push(PhysicalAction::Write(command)); + actions + } + } + Request::ReleaseMaintenance { client_id } => { + if *maintenance_owner == Some(client_id) { + *maintenance_owner = None; + *maintenance_reopen_pending = true; + vec![PhysicalAction::Write(vec![0x00, 0x02])] + } else { + Vec::new() + } + } + Request::ActiveOwnerCommand { + client_id, + command, + reply, + } => { + if maintenance_owner.is_some() || *maintenance_reopen_pending { + let _ = reply.send(Err(BrokerError::MaintenanceBusy)); + Vec::new() + } else { + match core.active_owner_command(client_id, command) { + Some(actions) => { + let _ = reply.send(Ok(())); + actions + } + None => { + let _ = reply.send(Err(BrokerError::Invalid( + "command requires active or primary ownership".to_string(), + ))); + Vec::new() + } + } + } + } + Request::Shutdown { reply } => { + let actions = core.emergency_stop("WinKeyer broker shutdown"); + let _ = apply_actions(actions, writer, events).await; + let _ = reply.send(()); + return Ok(true); + } + }; + apply_actions(actions, writer, events).await?; + Ok(false) +} + +async fn apply_actions( + actions: Vec, + writer: &mut W, + events: &broadcast::Sender, +) -> Result<(), BrokerError> +where + W: AsyncWrite + Unpin, +{ + for action in actions { + match action { + PhysicalAction::Write(bytes) => { + tracing::trace!(bytes = ?bytes, "WinKeyer physical tx"); + writer.write_all(&bytes).await.map_err(transport_error)?; + } + PhysicalAction::Completed { job_id, client_id } => { + let _ = events.send(BrokerEvent::Completed { job_id, client_id }); + } + PhysicalAction::Canceled { job_id, client_id } => { + let _ = events.send(BrokerEvent::Canceled { job_id, client_id }); + } + } + } + writer.flush().await.map_err(transport_error) +} + +fn publish_snapshot(core: &BrokerCore, snapshots: &watch::Sender) { + snapshots.send_if_modified(|current| { + let next = core.snapshot(); + if *current == next { + false + } else { + *current = next; + true + } + }); +} + +fn release_station_tx_if_idle(core: &BrokerCore, ptt: Option<&PttManager>) { + let snapshot = core.snapshot(); + if snapshot.active_job_id.is_none() + && !snapshot.busy + && !snapshot.break_in + && !snapshot.key_down + { + if let Some(ptt) = ptt { + ptt.unkey(WINKEYER_STATION_TX_OWNER); + } + } +} + +#[allow(clippy::needless_pass_by_value)] +fn transport_error(error: std::io::Error) -> BrokerError { + BrokerError::Transport(error.to_string()) +} + +#[cfg(test)] +#[allow(clippy::expect_used)] +mod tests { + use super::*; + use std::sync::Arc; + use tokio::sync::Mutex; + + async fn spawn_fake() -> (BrokerHandle, tokio::io::DuplexStream) { + let (mut device, broker) = tokio::io::duplex(4096); + let task = tokio::spawn(async move { spawn(broker, Duration::from_secs(30)).await }); + let mut open = [0_u8; 2]; + device.read_exact(&mut open).await.expect("host open"); + assert_eq!(open, [0x00, 0x02]); + device.write_u8(31).await.expect("firmware"); + let handle = task.await.expect("spawn task").expect("broker"); + let mut initialization = [0_u8; 7]; + device + .read_exact(&mut initialization) + .await + .expect("initialization"); + assert_eq!(initialization, [0x0a, 0x0b, 0x00, 0x02, 0x00, 0x07, 0x15]); + (handle, device) + } + + #[tokio::test] + async fn opens_once_and_exposes_firmware_status() { + let (handle, _device) = spawn_fake().await; + assert!(handle.snapshot().connected); + assert_eq!(handle.snapshot().firmware_revision, Some(31)); + } + + #[tokio::test] + async fn typed_job_writes_speed_text_and_status_without_interleaving() { + let (handle, mut device) = spawn_fake().await; + handle.register(10, true).await.expect("register"); + assert_eq!( + handle + .enqueue(10, b"TEST".to_vec(), Some(SpeedMode::Fixed(22))) + .await, + Ok(1) + ); + let mut bytes = [0_u8; 8]; + device.read_exact(&mut bytes).await.expect("job"); + assert_eq!(bytes, [0x02, 22, b'T', b'E', b'S', b'T', 0x15, 0x15]); + } + + #[tokio::test] + async fn pot_and_completion_events_are_fanned_out() { + let (handle, mut device) = spawn_fake().await; + handle.register(10, true).await.expect("register"); + let mut events = handle.subscribe(); + device.write_u8(0x94).await.expect("pot"); + let event = tokio::time::timeout(Duration::from_secs(1), events.recv()) + .await + .expect("timely") + .expect("event"); + assert_eq!( + event, + BrokerEvent::SpeedPot { + raw: 0x94, + value: 20, + wpm: 25, + } + ); + assert_eq!(handle.snapshot().pot_value, Some(20)); + assert_eq!(handle.snapshot().pot_wpm, Some(25)); + } + + #[tokio::test] + async fn shutdown_clears_key_and_closes_host_session() { + let (handle, mut device) = spawn_fake().await; + handle.shutdown().await.expect("shutdown"); + let mut bytes = Vec::new(); + tokio::time::timeout(Duration::from_secs(1), device.read_to_end(&mut bytes)) + .await + .expect("close") + .expect("read"); + assert!(bytes + .windows(5) + .any(|window| window == [0x0a, 0x0b, 0x00, 0x00, 0x03])); + } + + #[tokio::test] + async fn station_ptt_owner_blocks_conflicting_winkeyer_transmit() { + let (mut device, physical) = tokio::io::duplex(4096); + let ptt = PttManager::new(Duration::from_secs(30)); + ptt.try_key(7, true).expect("CAT owner"); + let task = tokio::spawn({ + let ptt = ptt.clone(); + async move { spawn_with_ptt(physical, Duration::from_secs(30), ptt).await } + }); + let mut open = [0_u8; 2]; + device.read_exact(&mut open).await.expect("host open"); + device.write_u8(31).await.expect("firmware"); + let handle = task.await.expect("task").expect("broker"); + let mut initialization = [0_u8; 7]; + device + .read_exact(&mut initialization) + .await + .expect("initialization"); + handle.register(10, false).await.expect("register"); + + assert_eq!( + handle.enqueue(10, b"TEST".to_vec(), None).await, + Err(BrokerError::TransmitBusy) + ); + assert_eq!(ptt.owner(), Some(7)); + } + + #[tokio::test] + async fn winkeyer_station_lease_releases_when_job_completes() { + let (mut device, physical) = tokio::io::duplex(4096); + let ptt = PttManager::new(Duration::from_secs(30)); + let task = tokio::spawn({ + let ptt = ptt.clone(); + async move { spawn_with_ptt(physical, Duration::from_secs(30), ptt).await } + }); + let mut open = [0_u8; 2]; + device.read_exact(&mut open).await.expect("host open"); + device.write_u8(31).await.expect("firmware"); + let handle = task.await.expect("task").expect("broker"); + let mut initialization = [0_u8; 7]; + device + .read_exact(&mut initialization) + .await + .expect("initialization"); + handle.register(10, false).await.expect("register"); + handle.enqueue(10, b"E".to_vec(), None).await.expect("send"); + assert_eq!(ptt.owner(), Some(WINKEYER_STATION_TX_OWNER)); + let mut writes = [0_u8; 4]; + device.read_exact(&mut writes).await.expect("writes"); + device.write_u8(0xc4).await.expect("busy"); + device.write_u8(0xc0).await.expect("idle"); + for _ in 0..50 { + if ptt.owner().is_none() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert_eq!(ptt.owner(), None); + } + + #[tokio::test] + async fn physical_paddle_activity_acquires_station_transmit_lease() { + let (mut device, physical) = tokio::io::duplex(4096); + let ptt = PttManager::new(Duration::from_secs(30)); + let task = tokio::spawn({ + let ptt = ptt.clone(); + async move { spawn_with_ptt(physical, Duration::from_secs(30), ptt).await } + }); + let mut open = [0_u8; 2]; + device.read_exact(&mut open).await.expect("host open"); + device.write_u8(31).await.expect("firmware"); + let handle = task.await.expect("task").expect("broker"); + let mut initialization = [0_u8; 7]; + device + .read_exact(&mut initialization) + .await + .expect("initialization"); + + device.write_u8(0xc6).await.expect("paddle busy status"); + for _ in 0..50 { + if ptt.owner() == Some(WINKEYER_STATION_TX_OWNER) { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert_eq!(ptt.owner(), Some(WINKEYER_STATION_TX_OWNER)); + assert_eq!(ptt.try_key(7, true), Err(PttDenied::Busy)); + handle.register(10, false).await.expect("client"); + assert_eq!( + handle.enqueue(10, b"WAIT".to_vec(), None).await, + Err(BrokerError::TransmitBusy) + ); + + device.write_u8(0xc0).await.expect("paddle idle status"); + for _ in 0..50 { + if ptt.owner().is_none() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert_eq!(ptt.owner(), None); + } + + #[tokio::test] + async fn exclusive_maintenance_routes_private_replies_and_blocks_transmit() { + let (handle, mut device) = spawn_fake().await; + handle.register(10, true).await.expect("maintenance client"); + handle.register(20, false).await.expect("ordinary client"); + let mut events = handle.subscribe(); + + handle + .maintenance_command(10, vec![0x00, 0x0c]) + .await + .expect("acquire maintenance"); + let mut command = [0_u8; 6]; + device + .read_exact(&mut command) + .await + .expect("maintenance write"); + assert_eq!(command, [0x0a, 0x0b, 0x00, 0x03, 0x00, 0x0c]); + assert_eq!( + handle.enqueue(20, b"NO".to_vec(), None).await, + Err(BrokerError::MaintenanceBusy) + ); + + device.write_u8(0x42).await.expect("private reply"); + assert_eq!( + events.recv().await.expect("maintenance event"), + BrokerEvent::MaintenanceByte { + client_id: 10, + byte: 0x42, + } + ); + + handle + .release_maintenance(10) + .await + .expect("release maintenance"); + let mut host_open = [0_u8; 2]; + device + .read_exact(&mut host_open) + .await + .expect("host reopen"); + assert_eq!(host_open, [0x00, 0x02]); + assert_eq!( + handle.enqueue(20, b"WAIT".to_vec(), None).await, + Err(BrokerError::MaintenanceBusy) + ); + device + .write_u8(31) + .await + .expect("firmware after maintenance"); + let mut restore = [0_u8; 9]; + device.read_exact(&mut restore).await.expect("safe restore"); + assert_eq!( + restore, + [0x0a, 0x0b, 0x00, 0x02, 0x00, 0x07, 0x15, 0x02, 0x00] + ); + assert!(handle.enqueue(20, b"OK".to_vec(), None).await.is_ok()); + } + + #[tokio::test] + async fn supervised_transport_reconnects_without_restarting_broker() { + let (mut first_device, first_transport) = tokio::io::duplex(4096); + let (replacement_tx, replacement_rx) = mpsc::channel(1); + let replacement_rx = Arc::new(Mutex::new(replacement_rx)); + let task = tokio::spawn({ + let replacement_rx = replacement_rx.clone(); + async move { + spawn_inner( + first_transport, + Duration::from_secs(30), + Some(PttManager::new(Duration::from_secs(30))), + true, + false, + move || { + let replacement_rx = replacement_rx.clone(); + async move { + replacement_rx + .lock() + .await + .recv() + .await + .ok_or_else(|| io::Error::other("no replacement")) + } + }, + ) + .await + } + }); + let mut open = [0_u8; 2]; + first_device + .read_exact(&mut open) + .await + .expect("first open"); + first_device.write_u8(31).await.expect("first firmware"); + let handle = task.await.expect("spawn task").expect("broker"); + let mut initialization = [0_u8; 7]; + first_device + .read_exact(&mut initialization) + .await + .expect("first initialization"); + drop(first_device); + + for _ in 0..100 { + if !handle.snapshot().connected { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert!(!handle.snapshot().connected); + + let (mut second_device, second_transport) = tokio::io::duplex(4096); + replacement_tx + .send(second_transport) + .await + .expect("replacement transport"); + second_device + .read_exact(&mut open) + .await + .expect("second open"); + second_device.write_u8(32).await.expect("second firmware"); + let mut reconnect_initialization = [0_u8; 9]; + second_device + .read_exact(&mut reconnect_initialization) + .await + .expect("reconnect initialization and foreground restore"); + assert_eq!( + reconnect_initialization, + [0x0a, 0x0b, 0x00, 0x02, 0x00, 0x07, 0x15, 0x02, 0x00] + ); + for _ in 0..100 { + if handle.snapshot().connected { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert!(handle.snapshot().connected); + assert_eq!(handle.snapshot().firmware_revision, Some(32)); + } +} diff --git a/crates/cathub/src/winkeyer/broker.rs b/crates/cathub/src/winkeyer/broker.rs new file mode 100644 index 0000000..d08879b --- /dev/null +++ b/crates/cathub/src/winkeyer/broker.rs @@ -0,0 +1,975 @@ +//! Pure WinKeyer multi-client scheduling and ownership state. +//! +//! The physical actor applies [`PhysicalAction`] values returned here. Keeping arbitration +//! independent of serial I/O makes interleaving, cancellation, pot restoration, and crash +//! recovery deterministic under unit tests. + +use std::collections::{BTreeMap, VecDeque}; +use std::time::{Duration, Instant}; + +use super::protocol::DeviceStatus; + +/// Stable identity assigned to one virtual endpoint or typed API connection. +pub(crate) type ClientId = u64; +/// Stable identity assigned to one queued transmit job. +pub(crate) type JobId = u64; +/// Conservative payload ceiling below the physical WinKeyer input FIFO capacity. +pub(crate) const MAX_JOB_BYTES: usize = 120; +pub(crate) const MAX_QUEUED_JOBS: usize = 64; + +/// Speed source desired by a client or one transmit job. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SpeedMode { + /// Read speed directly from the physical potentiometer (`Set WPM Speed 0`). + Pot, + /// Use a fixed host-selected speed. + Fixed(u8), +} + +impl SpeedMode { + pub(crate) fn command(self) -> [u8; 2] { + [ + 0x02, + match self { + Self::Pot => 0, + Self::Fixed(wpm) => wpm, + }, + ] + } +} + +/// One side effect the physical actor must perform in order. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum PhysicalAction { + /// Write bytes to the physical WinKeyer. + Write(Vec), + /// A job reached its normal completion boundary. + Completed { job_id: JobId, client_id: ClientId }, + /// A job was canceled before or during transmission. + Canceled { job_id: JobId, client_id: ClientId }, +} + +/// One immutable queued unit of transmission. +#[derive(Debug, Clone)] +struct Job { + id: JobId, + client_id: ClientId, + payload: TransmitPayload, + speed: SpeedMode, + queued_at: Instant, + stream: bool, +} + +/// A transmit request keeps operator text distinct from WinKeyer wire-control bytes. +/// Only `wire_bytes` are written to the device; `intended_text` is the authoritative +/// character payload used for diagnostics and conformance tests. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct TransmitPayload { + wire_bytes: Vec, + intended_text: Vec, +} + +impl TransmitPayload { + pub(crate) fn plain_text(intended_text: Vec) -> Self { + Self { + wire_bytes: intended_text.clone(), + intended_text, + } + } + + pub(crate) fn legacy_stream(control_prefix: Vec, intended_text: Vec) -> Self { + let mut wire_bytes = control_prefix; + wire_bytes.extend_from_slice(&intended_text); + Self { + wire_bytes, + intended_text, + } + } + + pub(crate) fn wire_bytes(&self) -> &[u8] { + &self.wire_bytes + } + + pub(crate) fn intended_text(&self) -> &[u8] { + &self.intended_text + } + + fn append(&mut self, other: &Self) { + self.wire_bytes.extend_from_slice(&other.wire_bytes); + self.intended_text.extend_from_slice(&other.intended_text); + } +} + +impl From> for TransmitPayload { + fn from(value: Vec) -> Self { + Self::plain_text(value) + } +} + +#[derive(Debug, Clone)] +struct ActiveJob { + job: Job, + saw_busy: bool, + observed_status: bool, + started_at: Instant, +} + +#[derive(Debug, Clone)] +struct ClientState { + desired_speed: SpeedMode, + primary: bool, + profile: BTreeMap>, +} + +/// Snapshot exposed to status and event surfaces. +#[derive(Debug, Clone, PartialEq, Eq)] +#[allow(clippy::struct_excessive_bools)] +pub(crate) struct BrokerSnapshot { + pub(crate) firmware_revision: Option, + pub(crate) connected: bool, + pub(crate) busy: bool, + pub(crate) break_in: bool, + pub(crate) key_down: bool, + pub(crate) pot_value: Option, + pub(crate) pot_wpm: Option, + pub(crate) active_client_id: Option, + pub(crate) active_job_id: Option, + pub(crate) queued_jobs: usize, + pub(crate) last_error: Option, + pub(crate) last_safety_action: Option, +} + +/// Stateful scheduler for a single physical keyer. +#[derive(Debug)] +pub(crate) struct BrokerCore { + clients: BTreeMap, + applied_profile_client_id: Option, + queue: VecDeque, + active: Option, + next_job_id: JobId, + firmware_revision: Option, + connected: bool, + status: DeviceStatus, + pot_value: Option, + pot_wpm: Option, + physical_pot_min_wpm: u8, + last_error: Option, + last_safety_action: Option, + max_tx: Duration, +} + +impl BrokerCore { + pub(crate) fn new(max_tx: Duration) -> Self { + Self { + clients: BTreeMap::new(), + applied_profile_client_id: None, + queue: VecDeque::new(), + active: None, + next_job_id: 1, + firmware_revision: None, + connected: false, + status: DeviceStatus { + raw: 0xc0, + waiting: false, + key_down: false, + busy: false, + break_in: false, + xoff: false, + }, + pot_value: None, + pot_wpm: None, + physical_pot_min_wpm: 5, + last_error: None, + last_safety_action: None, + max_tx, + } + } + + pub(crate) fn connect_physical(&mut self, firmware_revision: u8) { + self.firmware_revision = Some(firmware_revision); + self.connected = true; + self.applied_profile_client_id = None; + self.last_error = None; + } + + pub(crate) fn physical_error(&mut self, error: impl Into) -> Vec { + self.connected = false; + self.firmware_revision = None; + self.applied_profile_client_id = None; + self.last_error = Some(error.into()); + let mut actions = Vec::new(); + if let Some(active) = self.active.take() { + actions.push(PhysicalAction::Canceled { + job_id: active.job.id, + client_id: active.job.client_id, + }); + } + while let Some(job) = self.queue.pop_front() { + actions.push(PhysicalAction::Canceled { + job_id: job.id, + client_id: job.client_id, + }); + } + actions + } + + pub(crate) fn register_client(&mut self, client_id: ClientId, primary: bool) -> bool { + if primary && self.clients.values().any(|client| client.primary) { + return false; + } + self.clients.insert( + client_id, + ClientState { + desired_speed: SpeedMode::Pot, + primary, + profile: BTreeMap::new(), + }, + ); + true + } + + pub(crate) fn unregister_client(&mut self, client_id: ClientId) -> Vec { + self.clients.remove(&client_id); + if self.applied_profile_client_id == Some(client_id) { + self.applied_profile_client_id = None; + } + let mut actions = Vec::new(); + self.queue.retain(|job| { + if job.client_id == client_id { + actions.push(PhysicalAction::Canceled { + job_id: job.id, + client_id, + }); + false + } else { + true + } + }); + if self.active.as_ref().map(|active| active.job.client_id) == Some(client_id) { + if let Some(active) = self.active.take() { + actions.push(PhysicalAction::Write(vec![0x0a])); + actions.push(PhysicalAction::Canceled { + job_id: active.job.id, + client_id, + }); + } + self.last_safety_action = Some(format!( + "cleared WinKeyer buffer after active client {client_id} disconnected" + )); + actions.extend(self.start_next_or_restore()); + } + actions + } + + pub(crate) fn set_client_speed( + &mut self, + client_id: ClientId, + speed: SpeedMode, + ) -> Vec { + let Some(client) = self.clients.get_mut(&client_id) else { + return Vec::new(); + }; + client.desired_speed = speed; + if self.active.is_none() && client.primary { + vec![PhysicalAction::Write(speed.command().to_vec())] + } else { + Vec::new() + } + } + + pub(crate) fn set_client_command( + &mut self, + client_id: ClientId, + command: Vec, + ) -> Option> { + let opcode = *command.first()?; + let key = if opcode == 0x00 { + 0x100 | u16::from(*command.get(1)?) + } else { + u16::from(opcode) + }; + let client = self.clients.get_mut(&client_id)?; + client.profile.insert(key, command.clone()); + let applies_now = self.active.is_none() && client.primary; + if applies_now { + if let Some(minimum) = speed_pot_minimum(&command) { + self.physical_pot_min_wpm = minimum; + self.refresh_pot_wpm(); + } + } else if self.applied_profile_client_id == Some(client_id) { + // The profile changed while this client owned an active stream. It could not + // be applied mid-stream, so force a replay at the next ownership boundary. + self.applied_profile_client_id = None; + } + Some(if applies_now { + self.applied_profile_client_id = Some(client_id); + vec![PhysicalAction::Write(command)] + } else { + Vec::new() + }) + } + + pub(crate) fn active_owner_command( + &self, + client_id: ClientId, + command: Vec, + ) -> Option> { + let allowed = self.active.as_ref().map_or_else( + || { + self.clients + .get(&client_id) + .is_some_and(|client| client.primary) + }, + |active| active.job.client_id == client_id, + ); + allowed.then(|| vec![PhysicalAction::Write(command)]) + } + + pub(crate) fn enqueue>( + &mut self, + client_id: ClientId, + payload: P, + speed: Option, + stream: bool, + now: Instant, + ) -> Option<(JobId, Vec)> { + let payload = payload.into(); + if payload.wire_bytes.is_empty() + || payload.wire_bytes.len() > MAX_JOB_BYTES + || !self.connected + { + return None; + } + let client = self.clients.get(&client_id)?; + if stream { + if let Some(active) = self.active.as_mut() { + if active.job.client_id == client_id && active.job.stream { + if active.job.payload.wire_bytes.len() + payload.wire_bytes.len() + > MAX_JOB_BYTES + { + return None; + } + active.job.payload.append(&payload); + return Some(( + active.job.id, + vec![PhysicalAction::Write(payload.wire_bytes)], + )); + } + } + if let Some(queued) = self.queue.back_mut() { + if queued.client_id == client_id && queued.stream { + if queued.payload.wire_bytes.len() + payload.wire_bytes.len() > MAX_JOB_BYTES { + return None; + } + queued.payload.append(&payload); + return Some((queued.id, Vec::new())); + } + } + } + if self.queue.len() >= MAX_QUEUED_JOBS { + return None; + } + let job_id = self.next_job_id; + self.next_job_id = self.next_job_id.saturating_add(1); + self.queue.push_back(Job { + id: job_id, + client_id, + payload, + speed: speed.unwrap_or(client.desired_speed), + queued_at: now, + stream, + }); + let actions = if self.active.is_none() { + self.start_next_or_restore() + } else { + Vec::new() + }; + Some((job_id, actions)) + } + + pub(crate) fn cancel_client( + &mut self, + client_id: ClientId, + include_active: bool, + ) -> Vec { + let mut actions = Vec::new(); + self.queue.retain(|job| { + if job.client_id == client_id { + actions.push(PhysicalAction::Canceled { + job_id: job.id, + client_id, + }); + false + } else { + true + } + }); + let active_client_id = self.active.as_ref().map(|active| active.job.client_id); + if include_active && active_client_id == Some(client_id) { + if let Some(active) = self.active.take() { + actions.insert(0, PhysicalAction::Write(vec![0x0a])); + actions.push(PhysicalAction::Canceled { + job_id: active.job.id, + client_id, + }); + } + actions.extend(self.start_next_or_restore()); + } else if include_active + && active_client_id.is_none() + && self + .clients + .get(&client_id) + .is_some_and(|client| client.primary) + { + // A primary idle controller is allowed to clear stale bytes left in the + // physical FIFO. N1MM sends this when Ctrl+K opens; dropping it can make a + // later character transmit remnants from an earlier stream. + actions.insert(0, PhysicalAction::Write(vec![0x0a])); + } + actions + } + + pub(crate) fn cancel_job( + &mut self, + client_id: ClientId, + job_id: JobId, + ) -> (bool, Vec) { + if self + .active + .as_ref() + .is_some_and(|active| active.job.client_id == client_id && active.job.id == job_id) + { + let mut actions = vec![PhysicalAction::Write(vec![0x0a])]; + if let Some(active) = self.active.take() { + actions.push(PhysicalAction::Canceled { + job_id: active.job.id, + client_id, + }); + } + actions.extend(self.start_next_or_restore()); + return (true, actions); + } + let mut canceled = false; + let mut actions = Vec::new(); + self.queue.retain(|job| { + if job.client_id == client_id && job.id == job_id { + canceled = true; + actions.push(PhysicalAction::Canceled { job_id, client_id }); + false + } else { + true + } + }); + (canceled, actions) + } + + pub(crate) fn emergency_stop(&mut self, reason: &str) -> Vec { + let mut actions = vec![PhysicalAction::Write(vec![0x0a, 0x0b, 0x00])]; + if let Some(active) = self.active.take() { + actions.push(PhysicalAction::Canceled { + job_id: active.job.id, + client_id: active.job.client_id, + }); + } + while let Some(job) = self.queue.pop_front() { + actions.push(PhysicalAction::Canceled { + job_id: job.id, + client_id: job.client_id, + }); + } + self.last_safety_action = Some(reason.to_string()); + actions.extend(self.restore_foreground_speed()); + actions + } + + pub(crate) fn record_safety_action(&mut self, action: impl Into) { + self.last_safety_action = Some(action.into()); + } + + pub(crate) fn observe_status(&mut self, status: DeviceStatus) -> Vec { + self.status = status; + let Some(active) = self.active.as_mut() else { + return Vec::new(); + }; + active.observed_status = true; + if status.busy || status.waiting || status.key_down { + active.saw_busy = true; + return Vec::new(); + } + if active.saw_busy { + return self.complete_active(); + } + Vec::new() + } + + /// Confirm an idle boundary when a very short job completed before a busy status could + /// be observed. The actor calls this only after its settle timer and an idle status. + pub(crate) fn confirm_idle_after_settle( + &mut self, + now: Instant, + settle: Duration, + ) -> Vec { + if self.active.as_ref().is_some_and(|active| { + active.observed_status && now.duration_since(active.started_at) >= settle + }) && !self.status.busy + && !self.status.waiting + && !self.status.key_down + { + self.complete_active() + } else { + Vec::new() + } + } + + pub(crate) fn observe_pot(&mut self, value: u8) { + self.pot_value = Some(value); + self.refresh_pot_wpm(); + } + + pub(crate) fn watchdog(&mut self, now: Instant) -> Vec { + let expired = self + .active + .as_ref() + .is_some_and(|active| now.duration_since(active.started_at) >= self.max_tx); + if expired { + return self.emergency_stop("WinKeyer transmit safety ceiling reached"); + } + Vec::new() + } + + pub(crate) fn snapshot(&self) -> BrokerSnapshot { + BrokerSnapshot { + firmware_revision: self.firmware_revision, + connected: self.connected, + busy: self.status.busy || self.active.is_some(), + break_in: self.status.break_in, + key_down: self.status.key_down, + pot_value: self.pot_value, + pot_wpm: self.pot_wpm, + active_client_id: self.active.as_ref().map(|active| active.job.client_id), + active_job_id: self.active.as_ref().map(|active| active.job.id), + queued_jobs: self.queue.len(), + last_error: self.last_error.clone(), + last_safety_action: self.last_safety_action.clone(), + } + } + + /// Reapply the primary client's transient profile after exclusive maintenance. + pub(crate) fn restore_foreground(&mut self) -> Vec { + self.restore_foreground_speed() + } + + fn complete_active(&mut self) -> Vec { + let Some(active) = self.active.take() else { + return Vec::new(); + }; + let mut actions = vec![PhysicalAction::Completed { + job_id: active.job.id, + client_id: active.job.client_id, + }]; + actions.extend(self.start_next_or_restore()); + actions + } + + fn start_next_or_restore(&mut self) -> Vec { + if let Some(job) = self.queue.pop_front() { + debug_assert!(job.queued_at <= Instant::now()); + let profile_changed = self.applied_profile_client_id != Some(job.client_id); + let mut actions = if profile_changed { + self.clients + .get(&job.client_id) + .map_or_else(Vec::new, |client| { + client + .profile + .values() + .cloned() + .map(PhysicalAction::Write) + .collect() + }) + } else { + Vec::new() + }; + if profile_changed { + if let Some(minimum) = self + .clients + .get(&job.client_id) + .and_then(|client| profile_pot_minimum(&client.profile)) + { + self.physical_pot_min_wpm = minimum; + self.refresh_pot_wpm(); + } + self.applied_profile_client_id = Some(job.client_id); + } + // A legacy WinKeyer stream may carry Buffered Speed (0x1c) after pointer + // setup. Inserting an unbuffered speed command between those operations + // breaks N1MM's pointer context and keys the WPM byte as a phantom character. + // Typed/plain-text jobs still require the broker-selected speed command. + if job.payload.wire_bytes.first() != Some(&0x1c) { + actions.push(PhysicalAction::Write(job.speed.command().to_vec())); + } + actions.push(PhysicalAction::Write(job.payload.wire_bytes.clone())); + actions.push(PhysicalAction::Write(vec![0x15])); + self.active = Some(ActiveJob { + job, + saw_busy: false, + observed_status: false, + started_at: Instant::now(), + }); + actions + } else { + self.restore_foreground_speed() + } + } + + fn restore_foreground_speed(&mut self) -> Vec { + let foreground = self + .clients + .iter() + .find(|(_, client)| client.primary) + .map(|(id, client)| (*id, client)); + let profile_changed = + foreground.is_some_and(|(id, _)| Some(id) != self.applied_profile_client_id); + let mut actions: Vec<_> = if profile_changed { + foreground + .into_iter() + .flat_map(|(_, client)| client.profile.values().cloned()) + .map(PhysicalAction::Write) + .collect() + } else { + Vec::new() + }; + if profile_changed { + self.applied_profile_client_id = foreground.map(|(id, _)| id); + } + let speed = foreground.map_or(SpeedMode::Pot, |(_, client)| client.desired_speed); + self.physical_pot_min_wpm = foreground + .and_then(|(_, client)| profile_pot_minimum(&client.profile)) + .unwrap_or(5); + self.refresh_pot_wpm(); + actions.push(PhysicalAction::Write(speed.command().to_vec())); + actions + } + + fn refresh_pot_wpm(&mut self) { + self.pot_wpm = self + .pot_value + .map(|offset| self.physical_pot_min_wpm.saturating_add(offset)); + } +} + +fn speed_pot_minimum(command: &[u8]) -> Option { + (command.first() == Some(&0x05)) + .then(|| command.get(1).copied()) + .flatten() +} + +fn profile_pot_minimum(profile: &BTreeMap>) -> Option { + profile + .get(&0x05) + .and_then(|command| speed_pot_minimum(command)) +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::indexing_slicing)] +mod tests { + use super::*; + + fn connected() -> BrokerCore { + let mut broker = BrokerCore::new(Duration::from_secs(30)); + broker.connect_physical(31); + assert!(broker.register_client(1, true)); + assert!(broker.register_client(2, false)); + broker + } + + fn idle() -> DeviceStatus { + DeviceStatus { + raw: 0xc0, + waiting: false, + key_down: false, + busy: false, + break_in: false, + xoff: false, + } + } + + fn busy() -> DeviceStatus { + DeviceStatus { + raw: 0xc4, + busy: true, + ..idle() + } + } + + #[test] + fn only_one_primary_client_is_allowed() { + let mut broker = BrokerCore::new(Duration::from_secs(30)); + assert!(broker.register_client(1, true)); + assert!(!broker.register_client(2, true)); + } + + #[test] + fn jobs_from_two_clients_never_interleave() { + let mut broker = connected(); + let now = Instant::now(); + let (_, first) = broker + .enqueue(1, b"CQ".to_vec(), Some(SpeedMode::Fixed(20)), false, now) + .expect("first"); + assert_eq!( + first, + vec![ + PhysicalAction::Write(vec![0x02, 20]), + PhysicalAction::Write(b"CQ".to_vec()), + PhysicalAction::Write(vec![0x15]), + ] + ); + let (_, second) = broker + .enqueue(2, b"TEST".to_vec(), Some(SpeedMode::Fixed(30)), false, now) + .expect("second"); + assert!(second.is_empty(), "second job must remain queued"); + + assert!(broker.observe_status(busy()).is_empty()); + let boundary = broker.observe_status(idle()); + assert_eq!( + boundary, + vec![ + PhysicalAction::Completed { + job_id: 1, + client_id: 1 + }, + PhysicalAction::Write(vec![0x02, 30]), + PhysicalAction::Write(b"TEST".to_vec()), + PhysicalAction::Write(vec![0x15]), + ] + ); + } + + #[test] + fn fixed_job_restores_primary_pot_mode_when_queue_drains() { + let mut broker = connected(); + broker.set_client_speed(1, SpeedMode::Pot); + let (_, _) = broker + .enqueue( + 2, + b"TEST".to_vec(), + Some(SpeedMode::Fixed(35)), + false, + Instant::now(), + ) + .expect("job"); + broker.observe_status(busy()); + let actions = broker.observe_status(idle()); + assert_eq!( + actions, + vec![ + PhysicalAction::Completed { + job_id: 1, + client_id: 2 + }, + PhysicalAction::Write(vec![0x02, 0]), + ] + ); + } + + #[test] + fn pot_notification_updates_shared_snapshot() { + let mut broker = connected(); + broker.observe_pot(27); + assert_eq!(broker.snapshot().pot_value, Some(27)); + assert_eq!(broker.snapshot().pot_wpm, Some(32)); + broker.set_client_command(1, vec![0x05, 10, 20, 0]); + assert_eq!(broker.snapshot().pot_wpm, Some(37)); + } + + #[test] + fn canceling_queued_client_does_not_clear_active_client() { + let mut broker = connected(); + broker.enqueue(1, b"CQ".to_vec(), None, false, Instant::now()); + broker.enqueue(2, b"TEST".to_vec(), None, false, Instant::now()); + assert_eq!( + broker.cancel_client(2, true), + vec![PhysicalAction::Canceled { + job_id: 2, + client_id: 2 + }] + ); + assert_eq!(broker.snapshot().active_client_id, Some(1)); + } + + #[test] + fn active_owner_abort_clears_buffer_then_starts_next_client() { + let mut broker = connected(); + broker.enqueue(1, b"CQ".to_vec(), None, false, Instant::now()); + broker.enqueue( + 2, + b"TEST".to_vec(), + Some(SpeedMode::Fixed(25)), + false, + Instant::now(), + ); + let actions = broker.cancel_client(1, true); + assert_eq!(actions[0], PhysicalAction::Write(vec![0x0a])); + assert_eq!( + actions[1], + PhysicalAction::Canceled { + job_id: 1, + client_id: 1 + } + ); + assert_eq!(actions[2], PhysicalAction::Write(vec![0x02, 25])); + assert_eq!(actions[3], PhysicalAction::Write(b"TEST".to_vec())); + assert_eq!(broker.snapshot().active_client_id, Some(2)); + } + + #[test] + fn primary_idle_clear_reaches_the_physical_fifo() { + let mut broker = connected(); + assert_eq!( + broker.cancel_client(1, true), + vec![PhysicalAction::Write(vec![0x0a])] + ); + assert!(broker.cancel_client(2, true).is_empty()); + } + + #[test] + fn cancel_job_cannot_cancel_another_clients_job() { + let mut broker = connected(); + broker.enqueue(1, b"CQ".to_vec(), None, false, Instant::now()); + let (canceled, actions) = broker.cancel_job(2, 1); + assert!(!canceled); + assert!(actions.is_empty()); + assert_eq!(broker.snapshot().active_client_id, Some(1)); + } + + #[test] + fn disconnecting_active_client_clears_and_records_safety_action() { + let mut broker = connected(); + broker.enqueue(1, b"CQ".to_vec(), None, false, Instant::now()); + let actions = broker.unregister_client(1); + assert_eq!(actions[0], PhysicalAction::Write(vec![0x0a])); + assert!(broker + .snapshot() + .last_safety_action + .expect("safety") + .contains("disconnected")); + } + + #[test] + fn watchdog_clears_key_and_all_queued_work() { + let start = Instant::now(); + let mut broker = BrokerCore::new(Duration::from_secs(1)); + broker.connect_physical(31); + broker.register_client(1, true); + broker.enqueue(1, b"CQ".to_vec(), None, false, start); + let actions = broker.watchdog(start + Duration::from_secs(2)); + assert_eq!(actions[0], PhysicalAction::Write(vec![0x0a, 0x0b, 0x00])); + assert!(!broker.snapshot().busy); + } + + #[test] + fn short_job_can_complete_after_idle_settle_without_observed_busy() { + let mut broker = connected(); + broker.enqueue(1, b"E".to_vec(), None, false, Instant::now()); + broker.observe_status(idle()); + let actions = broker.confirm_idle_after_settle( + Instant::now() + Duration::from_millis(300), + Duration::from_millis(250), + ); + assert!(matches!( + actions.first(), + Some(PhysicalAction::Completed { .. }) + )); + } + + #[test] + fn active_stream_appends_without_creating_an_interleavable_job() { + let mut broker = connected(); + let now = Instant::now(); + let (job, _) = broker + .enqueue(1, b"C".to_vec(), None, true, now) + .expect("stream"); + let (same_job, actions) = broker + .enqueue(1, b"Q".to_vec(), None, true, now) + .expect("append"); + assert_eq!(same_job, job); + assert_eq!(actions, vec![PhysicalAction::Write(b"Q".to_vec())]); + assert_eq!(broker.snapshot().queued_jobs, 0); + } + + #[test] + fn oversized_jobs_and_stream_growth_are_rejected_before_fifo_overflow() { + let mut broker = connected(); + assert!(broker + .enqueue( + 1, + vec![b'X'; MAX_JOB_BYTES + 1], + None, + false, + Instant::now(), + ) + .is_none()); + broker + .enqueue(1, vec![b'A'; MAX_JOB_BYTES - 1], None, true, Instant::now()) + .expect("stream"); + assert!(broker + .enqueue(1, b"BC".to_vec(), None, true, Instant::now()) + .is_none()); + } + + #[test] + fn per_client_profile_is_replayed_only_when_that_client_becomes_active() { + let mut broker = connected(); + assert!(broker + .set_client_command(2, vec![0x0e, 0x04]) + .expect("client") + .is_empty()); + let (_, actions) = broker + .enqueue(2, b"E".to_vec(), None, false, Instant::now()) + .expect("job"); + assert_eq!(actions[0], PhysicalAction::Write(vec![0x0e, 0x04])); + assert_eq!(actions[1], PhysicalAction::Write(vec![0x02, 0])); + assert_eq!(actions[2], PhysicalAction::Write(b"E".to_vec())); + } + + #[test] + fn already_applied_primary_profile_is_not_inserted_into_keyboard_stream() { + let mut broker = connected(); + assert_eq!( + broker + .set_client_command(1, vec![0x0e, 0x04]) + .expect("primary"), + vec![PhysicalAction::Write(vec![0x0e, 0x04])] + ); + let payload = TransmitPayload::legacy_stream(vec![0x1c, 23], b"TEST".to_vec()); + assert_eq!(payload.intended_text(), b"TEST"); + assert_eq!(payload.wire_bytes(), b"\x1c\x17TEST"); + let (_, actions) = broker + .enqueue(1, payload, None, true, Instant::now()) + .expect("keyboard stream"); + assert_eq!( + actions, + vec![ + PhysicalAction::Write(b"\x1c\x17TEST".to_vec()), + PhysicalAction::Write(vec![0x15]), + ] + ); + } + + #[test] + fn profile_change_during_active_stream_is_replayed_at_next_boundary() { + let mut broker = connected(); + broker + .enqueue(1, b"T".to_vec(), None, true, Instant::now()) + .expect("active stream"); + assert_eq!(broker.applied_profile_client_id, Some(1)); + assert!(broker + .set_client_command(1, vec![0x0e, 0x04]) + .expect("active client") + .is_empty()); + assert_eq!(broker.applied_profile_client_id, None); + + broker.observe_status(busy()); + let actions = broker.observe_status(idle()); + assert!(actions.contains(&PhysicalAction::Write(vec![0x0e, 0x04]))); + } +} diff --git a/crates/cathub/src/winkeyer/endpoint.rs b/crates/cathub/src/winkeyer/endpoint.rs new file mode 100644 index 0000000..b85f049 --- /dev/null +++ b/crates/cathub/src/winkeyer/endpoint.rs @@ -0,0 +1,790 @@ +//! Virtual WinKeyer serial endpoints for unmodified applications such as N1MM Logger+. + +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; +use tokio::sync::broadcast::error::RecvError; + +use super::actor::{BrokerEvent, BrokerHandle}; +use super::broker::{ClientId, SpeedMode}; +use super::protocol::{command_policy, ClientItem, ClientParser, CommandPolicy}; + +const MAX_READ_CHUNK: usize = 512; +const PARTIAL_COMMAND_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(500); + +/// Permissions assigned to one virtual WinKeyer endpoint. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +#[allow(clippy::struct_excessive_bools)] +pub(crate) struct EndpointPermissions { + pub(crate) status: bool, + pub(crate) send: bool, + pub(crate) control: bool, + pub(crate) ptt: bool, + pub(crate) config_write: bool, +} + +impl EndpointPermissions { + pub(crate) fn from_tokens(tokens: &[String]) -> Self { + let mut result = Self::default(); + for token in tokens { + match token.as_str() { + "status" => result.status = true, + "send" => result.send = true, + "control" => result.control = true, + "ptt" => result.ptt = true, + "config_write" => result.config_write = true, + _ => {} + } + } + result + } +} + +/// Serve one virtual WinKeyer client until it disconnects or attempts a forbidden +/// maintenance operation. +#[cfg(test)] +pub(crate) async fn run_endpoint_session( + transport: T, + broker: BrokerHandle, + client_id: ClientId, + primary: bool, + permissions: EndpointPermissions, +) where + T: AsyncRead + AsyncWrite + Send + 'static, +{ + run_endpoint_session_inner(transport, broker, client_id, primary, permissions, false).await; +} + +/// Serve a real serial endpoint, where a zero-byte read means idle rather than EOF. +pub(crate) async fn run_serial_endpoint( + transport: T, + broker: BrokerHandle, + client_id: ClientId, + primary: bool, + permissions: EndpointPermissions, +) where + T: AsyncRead + AsyncWrite + Send + 'static, +{ + run_endpoint_session_inner(transport, broker, client_id, primary, permissions, true).await; +} + +#[allow(clippy::too_many_arguments, clippy::too_many_lines)] +async fn run_endpoint_session_inner( + transport: T, + broker: BrokerHandle, + client_id: ClientId, + primary: bool, + permissions: EndpointPermissions, + zero_is_idle: bool, +) where + T: AsyncRead + AsyncWrite + Send + 'static, +{ + if let Err(error) = broker.register(client_id, primary).await { + tracing::error!(client_id, %error, "cannot register virtual WinKeyer endpoint"); + return; + } + + let (mut reader, mut writer) = tokio::io::split(transport); + let mut events = broker.subscribe(); + let mut parser = ClientParser::default(); + let mut opened = false; + let mut maintenance_active = false; + let mut session_mode = SessionMode::Wk1; + let mut chunk = [0_u8; MAX_READ_CHUNK]; + let mut parser_tick = tokio::time::interval(PARTIAL_COMMAND_TIMEOUT); + let mut last_client_byte = std::time::Instant::now(); + + 'session: loop { + tokio::select! { + read = reader.read(&mut chunk) => { + let count = match read { + Ok(0) if zero_is_idle => { + tokio::time::sleep(std::time::Duration::from_millis(1)).await; + continue 'session; + } + Ok(0) => { + tracing::debug!(client_id, "virtual WinKeyer endpoint reached EOF"); + break 'session; + } + Err(error) => { + tracing::warn!(client_id, %error, "virtual WinKeyer endpoint read failed"); + break 'session; + } + Ok(count) => count, + }; + let bytes = chunk.get(..count).unwrap_or(&[]); + tracing::trace!(client_id, bytes = ?bytes, "WinKeyer virtual rx"); + let mut buffered_prefix = Vec::new(); + let mut intended_text = Vec::new(); + for &byte in bytes { + last_client_byte = std::time::Instant::now(); + let Some(item) = parser.push(byte) else { continue }; + match item { + ClientItem::Data(byte) => { + if opened && permissions.send { + tracing::trace!(client_id, byte, "WinKeyer virtual data byte"); + intended_text.push(byte); + } + } + ClientItem::Command(command) if command.first() == Some(&0x0a) => { + tracing::trace!(client_id, command = ?command, "WinKeyer virtual clear-buffer command"); + // Clear Buffer is immediate. Bytes in this same OS read that precede + // it have not reached hardware yet, so dropping them matches the + // client's intent without briefly keying stale text. + buffered_prefix.clear(); + intended_text.clear(); + if permissions.send { + let _ = broker.cancel(client_id, true).await; + } + } + ClientItem::Command(command) if is_buffered(&command) => { + tracing::trace!(client_id, command = ?command, "WinKeyer virtual buffered command"); + if opened && permissions.send && buffered_allowed(&command, permissions) { + if !intended_text.is_empty() + && flush_stream( + &broker, + client_id, + &mut buffered_prefix, + &mut intended_text, + ) + .await + .is_err() + { + break 'session; + } + buffered_prefix.extend(command); + } + } + ClientItem::Command(command) => { + tracing::trace!(client_id, command = ?command, "WinKeyer virtual command"); + if flush_stream( + &broker, + client_id, + &mut buffered_prefix, + &mut intended_text, + ) + .await + .is_err() + { + break 'session; + } + match handle_command( + &command, + &broker, + client_id, + permissions, + &mut opened, + &mut maintenance_active, + &mut session_mode, + &mut writer, + ).await { + CommandResult::Continue => {} + CommandResult::Close => break 'session, + } + } + } + } + if flush_stream( + &broker, + client_id, + &mut buffered_prefix, + &mut intended_text, + ) + .await + .is_err() + { + break 'session; + } + } + _ = parser_tick.tick() => { + if parser.is_partial() && last_client_byte.elapsed() >= PARTIAL_COMMAND_TIMEOUT { + tracing::warn!(client_id, "discarding timed-out partial WinKeyer command"); + parser.reset(); + } + } + event = events.recv(), if permissions.status && (opened || maintenance_active) => { + match event { + Ok(event) => { + if let Some(byte) = event_byte( + &event, + &broker, + client_id, + primary, + session_mode, + ) { + if writer.write_u8(byte).await.is_err() { + break 'session; + } + let _ = writer.flush().await; + } + } + Err(RecvError::Lagged(_)) => { + let status = synthesize_status(&broker, session_mode); + if writer.write_u8(status).await.is_err() { + break 'session; + } + if let Some(pot) = broker.snapshot().pot_value { + if writer.write_u8(0x80 | (pot & 0x3f)).await.is_err() { + break 'session; + } + } + let _ = writer.flush().await; + } + Err(RecvError::Closed) => break 'session, + } + } + } + } + + parser.reset(); + let _ = broker.release_maintenance(client_id).await; + let _ = broker.unregister(client_id).await; +} + +async fn flush_stream( + broker: &BrokerHandle, + client_id: ClientId, + buffered_prefix: &mut Vec, + intended_text: &mut Vec, +) -> Result<(), ()> { + if buffered_prefix.is_empty() && intended_text.is_empty() { + return Ok(()); + } + let control_prefix = std::mem::take(buffered_prefix); + let text = std::mem::take(intended_text); + tracing::trace!( + client_id, + intended_text = %String::from_utf8_lossy(&text), + control_prefix = ?control_prefix, + "WinKeyer virtual stream flush" + ); + broker + .stream(client_id, control_prefix, text) + .await + .map(|_| ()) + .map_err(|_| ()) +} + +enum CommandResult { + Continue, + Close, +} + +#[derive(Debug, Clone, Copy)] +enum SessionMode { + Wk1, + Wk2, + Wk3, +} + +#[allow(clippy::too_many_arguments)] +async fn handle_command( + command: &[u8], + broker: &BrokerHandle, + client_id: ClientId, + permissions: EndpointPermissions, + opened: &mut bool, + maintenance_active: &mut bool, + session_mode: &mut SessionMode, + writer: &mut W, +) -> CommandResult +where + W: AsyncWrite + Unpin, +{ + if command.first() == Some(&0x00) { + return handle_admin( + command, + broker, + client_id, + permissions, + opened, + maintenance_active, + session_mode, + writer, + ) + .await; + } + if !*opened { + return CommandResult::Continue; + } + + match command.first().copied() { + Some(0x02) if permissions.control => { + if let Some(speed) = command.get(1).copied().and_then(parse_speed) { + let _ = broker.set_speed(client_id, speed).await; + } + } + Some(0x07) if permissions.status => { + let byte = 0x80 | (broker.snapshot().pot_value.unwrap_or(0) & 0x3f); + if writer.write_u8(byte).await.is_err() { + return CommandResult::Close; + } + } + Some(0x15) if permissions.status => { + if writer + .write_u8(synthesize_status(broker, *session_mode)) + .await + .is_err() + { + return CommandResult::Close; + } + } + Some(_) => match command_policy(command) { + CommandPolicy::Transient if permissions.control => { + let _ = broker.configure(client_id, command.to_vec()).await; + } + CommandPolicy::ActiveOwner + if permissions.control && (command.first() != Some(&0x0b) || permissions.ptt) => + { + let _ = broker + .active_owner_command(client_id, command.to_vec()) + .await; + } + CommandPolicy::Maintenance => { + if !permissions.config_write + || broker + .maintenance_command(client_id, command.to_vec()) + .await + .is_err() + { + tracing::warn!(client_id, command = ?command, "denied WinKeyer maintenance command"); + return CommandResult::Close; + } + *maintenance_active = true; + } + _ => {} + }, + None => {} + } + let _ = writer.flush().await; + CommandResult::Continue +} + +#[allow(clippy::too_many_arguments)] +async fn handle_admin( + command: &[u8], + broker: &BrokerHandle, + client_id: ClientId, + permissions: EndpointPermissions, + opened: &mut bool, + maintenance_active: &mut bool, + session_mode: &mut SessionMode, + writer: &mut W, +) -> CommandResult +where + W: AsyncWrite + Unpin, +{ + let Some(subcommand) = command.get(1).copied() else { + return CommandResult::Continue; + }; + match subcommand { + 0x02 if permissions.status => { + *opened = true; + *session_mode = SessionMode::Wk1; + let revision = broker.snapshot().firmware_revision.unwrap_or(0xff); + if writer.write_u8(revision).await.is_err() { + return CommandResult::Close; + } + } + 0x03 => { + *opened = false; + let _ = broker.release_maintenance(client_id).await; + *maintenance_active = false; + } + 0x04 if permissions.status => { + if let Some(value) = command.get(2) { + if writer.write_u8(*value).await.is_err() { + return CommandResult::Close; + } + } + } + 0x05..=0x07 | 0x15 | 0x17..=0x18 if permissions.status => { + if writer.write_u8(0).await.is_err() { + return CommandResult::Close; + } + } + 0x09 if permissions.status => { + if writer + .write_u8(broker.snapshot().firmware_revision.unwrap_or(0xff)) + .await + .is_err() + { + return CommandResult::Close; + } + } + 0x0a if permissions.control => *session_mode = SessionMode::Wk1, + 0x0b if permissions.control => *session_mode = SessionMode::Wk2, + 0x14 if permissions.control => *session_mode = SessionMode::Wk3, + 0x0f | 0x13 | 0x16 | 0x19 if permissions.control => { + let _ = broker.configure(client_id, command.to_vec()).await; + } + _ => { + if !permissions.config_write + || broker + .maintenance_command(client_id, command.to_vec()) + .await + .is_err() + { + tracing::warn!(command = ?command, "virtual endpoint attempted exclusive WinKeyer admin operation"); + return CommandResult::Close; + } + *maintenance_active = true; + } + } + let _ = writer.flush().await; + CommandResult::Continue +} + +fn parse_speed(value: u8) -> Option { + match value { + 0 => Some(SpeedMode::Pot), + 5..=99 => Some(SpeedMode::Fixed(value)), + _ => None, + } +} + +fn is_buffered(command: &[u8]) -> bool { + command + .first() + .is_some_and(|opcode| (0x18..=0x1f).contains(opcode)) +} + +fn buffered_allowed(command: &[u8], permissions: EndpointPermissions) -> bool { + !matches!(command.first(), Some(0x18 | 0x19)) || permissions.ptt +} + +fn synthesize_status(broker: &BrokerHandle, session_mode: SessionMode) -> u8 { + let snapshot = broker.snapshot(); + 0xc0 | match session_mode { + SessionMode::Wk1 => u8::from(snapshot.key_down) << 3, + SessionMode::Wk2 | SessionMode::Wk3 => 0, + } | u8::from(snapshot.busy) << 2 + | u8::from(snapshot.break_in) << 1 +} + +fn event_byte( + event: &BrokerEvent, + broker: &BrokerHandle, + client_id: ClientId, + primary: bool, + session_mode: SessionMode, +) -> Option { + match event { + BrokerEvent::SpeedPot { raw, .. } => Some(*raw), + BrokerEvent::Status { .. } => Some(synthesize_status(broker, session_mode)), + BrokerEvent::Echo(byte) => { + let snapshot = broker.snapshot(); + (snapshot.active_client_id == Some(client_id) || (primary && snapshot.break_in)) + .then_some(*byte) + } + BrokerEvent::MaintenanceByte { + client_id: owner, + byte, + } => (*owner == client_id).then_some(*byte), + _ => None, + } +} + +/// Open the hub side of a virtual WinKeyer pair as 8-N-2. +pub(crate) fn open_serial_endpoint( + port_name: &str, + baud: u32, +) -> std::io::Result { + serial2_tokio::SerialPort::open(port_name, move |mut settings: serial2_tokio::Settings| { + settings.set_raw(); + settings.set_baud_rate(baud)?; + settings.set_char_size(serial2_tokio::CharSize::Bits8); + settings.set_parity(serial2_tokio::Parity::None); + settings.set_stop_bits(serial2_tokio::StopBits::Two); + settings.set_flow_control(serial2_tokio::FlowControl::None); + Ok(settings) + }) +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::indexing_slicing)] +mod tests { + use super::*; + use std::time::Duration; + use tokio::io::DuplexStream; + + async fn setup() -> (BrokerHandle, DuplexStream, DuplexStream) { + setup_with_permissions(EndpointPermissions { + status: true, + send: true, + control: true, + ptt: true, + config_write: false, + }) + .await + } + + async fn setup_with_permissions( + permissions: EndpointPermissions, + ) -> (BrokerHandle, DuplexStream, DuplexStream) { + let (mut device, physical) = tokio::io::duplex(4096); + let broker_task = tokio::spawn(async move { + super::super::actor::spawn(physical, Duration::from_secs(30)).await + }); + let mut host_open = [0_u8; 2]; + device.read_exact(&mut host_open).await.expect("open"); + device.write_u8(31).await.expect("revision"); + let broker = broker_task.await.expect("task").expect("broker"); + let mut initialization = [0_u8; 7]; + device.read_exact(&mut initialization).await.expect("init"); + + let (client, server) = tokio::io::duplex(4096); + tokio::spawn(run_endpoint_session( + server, + broker.clone(), + 42, + true, + permissions, + )); + (broker, client, device) + } + + #[tokio::test] + async fn host_open_is_virtualized_without_reopening_physical_keyer() { + let (_broker, mut client, mut device) = setup().await; + client.write_all(&[0x00, 0x02]).await.expect("open"); + assert_eq!(client.read_u8().await.expect("revision"), 31); + let mut byte = [0_u8; 1]; + assert!( + tokio::time::timeout(Duration::from_millis(50), device.read(&mut byte)) + .await + .is_err() + ); + } + + #[tokio::test] + async fn fixed_speed_and_text_flow_through_the_broker() { + let (_broker, mut client, mut device) = setup().await; + client.write_all(&[0x00, 0x02]).await.expect("open"); + let _ = client.read_u8().await.expect("revision"); + client + .write_all(&[0x02, 25, b'T', b'E']) + .await + .expect("send"); + + let mut bytes = [0_u8; 9]; + device + .read_exact(&mut bytes) + .await + .expect("physical writes"); + assert!(bytes.windows(2).any(|window| window == [0x02, 25])); + assert!(bytes.windows(2).any(|window| window == b"TE")); + } + + #[tokio::test] + async fn n1mm_buffer_pointer_commands_are_not_replayed_before_keyboard_text() { + let (_broker, mut client, mut device) = setup().await; + client.write_all(&[0x00, 0x02]).await.expect("Host Open"); + let _ = client.read_u8().await.expect("revision"); + + // Captured from N1MM when Ctrl+K opens: clear, pointer start, pointer move, + // then its buffered-speed command. The pointer operations must reach the keyer + // exactly once and must not become persistent profile entries. + client + .write_all(&[0x0a, 0x16, 0x00, 0x16, 0x02, 0x00, 0x1c, 23]) + .await + .expect("N1MM keyboard CW prefix"); + + let mut prefix = [0_u8; 9]; + device + .read_exact(&mut prefix) + .await + .expect("physical keyboard prefix"); + assert_eq!(prefix, [0x0a, 0x16, 0x00, 0x16, 0x02, 0x00, 0x1c, 23, 0x15]); + + // Let the speed-only stream reach its idle boundary, as it did in the live trace, + // then type one T. A replayed 0x16 here was the corruption regression. + device.write_u8(0xc0).await.expect("idle status"); + tokio::time::sleep(Duration::from_millis(150)).await; + let mut pending = [0_u8; 16]; + // Drain any pending buffered data with a bounded timeout to avoid spinning forever. + let drain_start = tokio::time::Instant::now(); + while drain_start.elapsed() < Duration::from_millis(100) { + match tokio::time::timeout(Duration::from_millis(10), device.read(&mut pending)).await { + Ok(Ok(_)) => {} + _ => break, + } + } + client.write_u8(b'T').await.expect("keyboard T"); + + // A foreground-speed restore can race with this new byte at the idle boundary. + // The regression contract is that no buffered-pointer command is replayed before + // the keyboard text, regardless of which side of that boundary accepts the T. + let mut before_text = Vec::new(); + let mut saw_text = false; + for _ in 0..8 { + let byte = tokio::time::timeout(Duration::from_secs(1), device.read_u8()) + .await + .expect("physical keyboard text timed out") + .expect("physical keyboard text"); + if byte == b'T' { + saw_text = true; + break; + } + before_text.push(byte); + } + assert!(saw_text, "keyboard text was not forwarded: {before_text:?}"); + assert!( + !before_text.contains(&0x16), + "buffer pointer was replayed before keyboard text: {before_text:?}" + ); + } + + #[tokio::test] + async fn n1mm_function_key_message_reaches_physical_keyer_byte_for_byte() { + let (_broker, mut client, mut device) = setup().await; + client.write_all(&[0x00, 0x02]).await.expect("Host Open"); + let _ = client.read_u8().await.expect("revision"); + + let message = b"TEST DE KC7AVA"; + let mut captured = vec![0x0a, 0x16, 0x00, 0x16, 0x02, 0x00, 0x1c, 23]; + captured.extend_from_slice(message); + client + .write_all(&captured) + .await + .expect("N1MM F-key message"); + + let mut expected = captured; + expected.push(0x15); // Broker status poll after the complete message. + let mut physical = vec![0_u8; expected.len()]; + device + .read_exact(&mut physical) + .await + .expect("physical F-key message"); + assert_eq!(physical, expected); + } + + #[tokio::test] + async fn n1mm_golden_startup_send_abort_and_close_transcript() { + let (_broker, mut client, mut device) = setup().await; + client.write_all(&[0x00, 0x02]).await.expect("Host Open"); + assert_eq!(client.read_u8().await.expect("revision"), 31); + + let mut defaults = vec![0x0f]; + defaults.extend([25, 50, 0, 0, 10, 20, 0, 0, 0, 50, 0, 0, 0, 0, 0]); + let mut startup = vec![0x00, 0x0b]; // N1MM selects WK2 logical status mode. + startup.extend(defaults.clone()); + startup.extend([0x05, 10, 20, 0, 0x02, 0, 0x07, 0x15]); + client.write_all(&startup).await.expect("N1MM startup"); + + let mut startup_writes = vec![0_u8; 22]; + device + .read_exact(&mut startup_writes) + .await + .expect("transient startup writes"); + let mut expected = defaults.clone(); + expected.extend([0x05, 10, 20, 0, 0x02, 0]); + assert_eq!(startup_writes, expected); + assert_eq!(client.read_u8().await.expect("pot reply"), 0x80); + assert_eq!(client.read_u8().await.expect("status reply"), 0xc0); + + client + .write_all(&[ + 0x16, 0x00, 0x16, 0x02, 0x00, 0x1c, 22, b'T', b'E', b'S', b'T', + ]) + .await + .expect("N1MM buffered speed and text"); + let mut job = vec![0_u8; 12]; + device + .read_exact(&mut job) + .await + .expect("atomic physical job"); + assert_eq!(&job[..5], &[0x16, 0x00, 0x16, 0x02, 0x00]); + assert_eq!(&job[5..7], &[0x1c, 22]); + assert_eq!(&job[7..11], b"TEST"); + assert_eq!(job[11], 0x15); + + client.write_u8(0x0a).await.expect("N1MM Escape"); + let mut abort = vec![0_u8; 3]; + device.read_exact(&mut abort).await.expect("scoped abort"); + assert_eq!(abort[0], 0x0a); + assert_eq!(&abort[1..3], &[0x02, 0]); + + client.write_all(&[0x00, 0x03]).await.expect("Host Close"); + let mut unexpected = [0_u8; 1]; + assert!( + tokio::time::timeout(Duration::from_millis(50), device.read(&mut unexpected)) + .await + .is_err() + ); + } + + #[tokio::test] + async fn physical_pot_event_is_fanned_to_open_virtual_session() { + let (_broker, mut client, mut device) = setup().await; + client.write_all(&[0x00, 0x02]).await.expect("open"); + let _ = client.read_u8().await.expect("revision"); + device.write_u8(0x9b).await.expect("pot"); + assert_eq!(client.read_u8().await.expect("pot event"), 0x9b); + } + + #[tokio::test] + async fn maintenance_command_fails_closed_and_does_not_reach_device() { + let (_broker, mut client, mut device) = setup().await; + client.write_all(&[0x00, 0x02]).await.expect("open"); + let _ = client.read_u8().await.expect("revision"); + client.write_all(&[0x00, 0x01]).await.expect("reset"); + let mut ignored = Vec::new(); + tokio::time::timeout(Duration::from_secs(1), client.read_to_end(&mut ignored)) + .await + .expect("session closes") + .expect("read"); + let mut byte = [0_u8; 1]; + assert!( + tokio::time::timeout(Duration::from_millis(50), device.read(&mut byte)) + .await + .is_err() + ); + } + + #[tokio::test] + async fn permitted_maintenance_is_exclusive_and_private_until_virtual_close() { + let (_broker, mut client, mut device) = setup_with_permissions(EndpointPermissions { + status: true, + send: true, + control: true, + ptt: true, + config_write: true, + }) + .await; + + client.write_all(&[0x00, 0x0c]).await.expect("EEPROM dump"); + let mut physical = [0_u8; 6]; + device + .read_exact(&mut physical) + .await + .expect("safe close and dump command"); + assert_eq!(physical, [0x0a, 0x0b, 0x00, 0x03, 0x00, 0x0c]); + device + .write_all(&[0x80, 0xc0, 0x42]) + .await + .expect("dump bytes"); + let mut private = [0_u8; 3]; + client + .read_exact(&mut private) + .await + .expect("private maintenance response"); + assert_eq!(private, [0x80, 0xc0, 0x42]); + + client + .write_all(&[0x00, 0x03]) + .await + .expect("virtual close"); + let mut reopen = [0_u8; 2]; + device + .read_exact(&mut reopen) + .await + .expect("physical reopen"); + assert_eq!(reopen, [0x00, 0x02]); + device.write_u8(31).await.expect("firmware"); + let mut restore = [0_u8; 9]; + device.read_exact(&mut restore).await.expect("safe restore"); + assert_eq!( + restore, + [0x0a, 0x0b, 0x00, 0x02, 0x00, 0x07, 0x15, 0x02, 0x00] + ); + } +} diff --git a/crates/cathub/src/winkeyer/grpc.rs b/crates/cathub/src/winkeyer/grpc.rs new file mode 100644 index 0000000..e25853f --- /dev/null +++ b/crates/cathub/src/winkeyer/grpc.rs @@ -0,0 +1,449 @@ +//! Loopback gRPC surface for typed QsoRipper WinKeyer clients. + +use std::collections::BTreeMap; +use std::net::SocketAddr; +use std::pin::Pin; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; + +use tokio::sync::Mutex; +use tokio_stream::wrappers::BroadcastStream; +use tokio_stream::wrappers::TcpListenerStream; +use tokio_stream::{Stream, StreamExt}; +use tonic::{Request, Response, Status}; + +use super::actor::{BrokerError, BrokerEvent, BrokerHandle}; +use super::broker::{BrokerSnapshot, ClientId, SpeedMode, MAX_JOB_BYTES, MAX_QUEUED_JOBS}; + +use crate::broker_proto::winkeyer_broker_service_server::{ + WinkeyerBrokerService, WinkeyerBrokerServiceServer, +}; +use crate::broker_proto::{ + WinkeyerBrokerEventKind, WinkeyerBrokerServiceAbortClientRequest, + WinkeyerBrokerServiceAbortClientResponse, WinkeyerBrokerServiceCancelJobRequest, + WinkeyerBrokerServiceCancelJobResponse, WinkeyerBrokerServiceGetStatusRequest, + WinkeyerBrokerServiceGetStatusResponse, WinkeyerBrokerServiceSendTextRequest, + WinkeyerBrokerServiceSendTextResponse, WinkeyerBrokerServiceSetSpeedRequest, + WinkeyerBrokerServiceSetSpeedResponse, WinkeyerBrokerServiceStreamEventsRequest, + WinkeyerBrokerServiceStreamEventsResponse, WinkeyerBrokerStatus, WinkeyerSpeedMode, +}; + +const FIRST_TYPED_CLIENT_ID: u64 = 1_000_000; +const MAX_TYPED_CLIENTS: usize = 64; + +#[derive(Clone)] +struct Service { + broker: BrokerHandle, + clients: Arc>>, + next_client: Arc, +} + +impl Service { + fn new(broker: BrokerHandle) -> Self { + Self { + broker, + clients: Arc::new(Mutex::new(BTreeMap::new())), + next_client: Arc::new(AtomicU64::new(FIRST_TYPED_CLIENT_ID)), + } + } + + async fn client_id(&self, name: &str) -> Result { + let name = name.trim(); + if name.is_empty() || name.len() > 64 { + return Err(Status::invalid_argument( + "client_name must contain 1 through 64 characters", + )); + } + let mut clients = self.clients.lock().await; + if let Some(id) = clients.get(name) { + return Ok(*id); + } + if clients.len() >= MAX_TYPED_CLIENTS { + return Err(Status::resource_exhausted( + "maximum number of named WinKeyer clients reached", + )); + } + let id = self.next_client.fetch_add(1, Ordering::SeqCst); + self.broker.register(id, false).await.map_err(map_error)?; + clients.insert(name.to_string(), id); + Ok(id) + } +} + +type EventStream = + Pin> + Send>>; + +#[tonic::async_trait] +impl WinkeyerBrokerService for Service { + type StreamEventsStream = EventStream; + + async fn get_status( + &self, + request: Request, + ) -> Result, Status> { + self.client_id(&request.get_ref().client_name).await?; + Ok(Response::new(WinkeyerBrokerServiceGetStatusResponse { + status: Some(status_message(&self.broker.snapshot())), + })) + } + + async fn send_text( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let client_id = self.client_id(&request.client_name).await?; + let text = validate_text(&request.text)?; + let speed = optional_speed(request.speed_mode, request.speed_wpm)?; + let job_id = self + .broker + .enqueue(client_id, text, speed) + .await + .map_err(map_error)?; + Ok(Response::new(WinkeyerBrokerServiceSendTextResponse { + job_id, + })) + } + + async fn cancel_job( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let client_id = self.client_id(&request.client_name).await?; + let canceled = self + .broker + .cancel_job(client_id, request.job_id) + .await + .map_err(map_error)?; + Ok(Response::new(WinkeyerBrokerServiceCancelJobResponse { + canceled, + })) + } + + async fn abort_client( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let client_id = self.client_id(&request.client_name).await?; + if request.emergency_station_stop { + self.broker + .emergency_stop(format!( + "emergency stop requested by {}", + request.client_name + )) + .await + .map_err(map_error)?; + } else { + self.broker + .cancel(client_id, true) + .await + .map_err(map_error)?; + } + Ok(Response::new(WinkeyerBrokerServiceAbortClientResponse { + accepted: true, + })) + } + + async fn set_speed( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let client_id = self.client_id(&request.client_name).await?; + let speed = required_speed(request.speed_mode, request.speed_wpm)?; + self.broker + .set_speed(client_id, speed) + .await + .map_err(map_error)?; + let (speed_mode, speed_wpm) = speed_fields(speed); + Ok(Response::new(WinkeyerBrokerServiceSetSpeedResponse { + speed_mode, + speed_wpm, + })) + } + + async fn stream_events( + &self, + request: Request, + ) -> Result, Status> { + self.client_id(&request.get_ref().client_name).await?; + let broker = self.broker.clone(); + let stream = BroadcastStream::new(self.broker.subscribe()).filter_map(move |event| { + let broker = broker.clone(); + match event { + Ok(BrokerEvent::MaintenanceByte { .. } | BrokerEvent::Echo(_)) => None, + Ok(event) => Some(Ok(event_message(&event, &broker.snapshot()))), + Err(_) => Some(Ok(WinkeyerBrokerServiceStreamEventsResponse { + kind: WinkeyerBrokerEventKind::Status as i32, + status: Some(status_message(&broker.snapshot())), + message: Some("event receiver lagged; status resynchronized".to_string()), + ..Default::default() + })), + } + }); + Ok(Response::new(Box::pin(stream))) + } +} + +#[cfg(test)] +pub(crate) async fn run_server( + bind: SocketAddr, + broker: BrokerHandle, +) -> Result<(), tonic::transport::Error> { + tonic::transport::Server::builder() + .add_service(WinkeyerBrokerServiceServer::new(Service::new(broker))) + .serve(bind) + .await +} + +/// Bind the loopback endpoint before returning so daemon startup fails loudly on conflicts. +pub(crate) async fn bind_server( + bind: SocketAddr, + broker: BrokerHandle, +) -> std::io::Result>> { + let listener = tokio::net::TcpListener::bind(bind).await?; + Ok(tokio::spawn(async move { + tonic::transport::Server::builder() + .add_service(WinkeyerBrokerServiceServer::new(Service::new(broker))) + .serve_with_incoming(TcpListenerStream::new(listener)) + .await + })) +} + +#[allow(clippy::result_large_err)] +fn validate_text(text: &str) -> Result, Status> { + if text.is_empty() { + return Err(Status::invalid_argument("text must not be empty")); + } + if text.len() > MAX_JOB_BYTES { + return Err(Status::invalid_argument(format!( + "text must not exceed {MAX_JOB_BYTES} ASCII bytes" + ))); + } + if let Some(character) = text.chars().find(|character| { + !character.is_ascii() || (character.is_ascii_control() && *character != '\t') + }) { + return Err(Status::invalid_argument(format!( + "text contains unsupported character {character:?}" + ))); + } + Ok(text.to_ascii_uppercase().into_bytes()) +} + +#[allow(clippy::result_large_err)] +fn optional_speed(mode: i32, speed_wpm: Option) -> Result, Status> { + if WinkeyerSpeedMode::try_from(mode).unwrap_or_default() == WinkeyerSpeedMode::Unspecified { + if speed_wpm.is_none() { + return Ok(None); + } + return required_speed(WinkeyerSpeedMode::Fixed as i32, speed_wpm).map(Some); + } + required_speed(mode, speed_wpm).map(Some) +} + +#[allow(clippy::result_large_err)] +fn required_speed(mode: i32, speed_wpm: Option) -> Result { + match WinkeyerSpeedMode::try_from(mode).unwrap_or_default() { + WinkeyerSpeedMode::Pot => Ok(SpeedMode::Pot), + WinkeyerSpeedMode::Fixed => { + let speed = speed_wpm + .ok_or_else(|| Status::invalid_argument("fixed speed mode requires speed_wpm"))?; + let speed = u8::try_from(speed) + .ok() + .filter(|speed| (5..=99).contains(speed)) + .ok_or_else(|| Status::invalid_argument("speed_wpm must be between 5 and 99"))?; + Ok(SpeedMode::Fixed(speed)) + } + WinkeyerSpeedMode::Unspecified => { + Err(Status::invalid_argument("speed_mode must be POT or FIXED")) + } + } +} + +fn speed_fields(speed: SpeedMode) -> (i32, Option) { + match speed { + SpeedMode::Pot => (WinkeyerSpeedMode::Pot as i32, None), + SpeedMode::Fixed(wpm) => (WinkeyerSpeedMode::Fixed as i32, Some(u32::from(wpm))), + } +} + +fn status_message(snapshot: &BrokerSnapshot) -> WinkeyerBrokerStatus { + WinkeyerBrokerStatus { + connected: snapshot.connected, + firmware_revision: snapshot.firmware_revision.map(u32::from), + busy: snapshot.busy, + break_in: snapshot.break_in, + key_down: snapshot.key_down, + pot_wpm: snapshot.pot_wpm.map(u32::from), + active_client_id: snapshot.active_client_id, + active_job_id: snapshot.active_job_id, + queued_jobs: u32::try_from(snapshot.queued_jobs).unwrap_or(u32::MAX), + last_error: snapshot.last_error.clone(), + last_safety_action: snapshot.last_safety_action.clone(), + max_job_bytes: u32::try_from(MAX_JOB_BYTES).unwrap_or(u32::MAX), + supports_speed_pot: true, + supports_scoped_cancel: true, + max_queued_jobs: u32::try_from(MAX_QUEUED_JOBS).unwrap_or(u32::MAX), + } +} + +fn event_message( + event: &BrokerEvent, + snapshot: &BrokerSnapshot, +) -> WinkeyerBrokerServiceStreamEventsResponse { + let mut response = WinkeyerBrokerServiceStreamEventsResponse { + status: Some(status_message(snapshot)), + ..Default::default() + }; + match event { + BrokerEvent::Connected { firmware_revision } => { + response.kind = WinkeyerBrokerEventKind::Connected as i32; + response.raw_byte = Some(u32::from(*firmware_revision)); + } + BrokerEvent::SpeedPot { raw, wpm, .. } => { + response.kind = WinkeyerBrokerEventKind::SpeedPot as i32; + response.raw_byte = Some(u32::from(*raw)); + response.speed_wpm = Some(u32::from(*wpm)); + } + BrokerEvent::Status { raw } => { + response.kind = WinkeyerBrokerEventKind::Status as i32; + response.raw_byte = Some(u32::from(*raw)); + } + BrokerEvent::Echo(byte) => { + response.kind = WinkeyerBrokerEventKind::Echo as i32; + response.raw_byte = Some(u32::from(*byte)); + } + BrokerEvent::Completed { job_id, client_id } => { + response.kind = WinkeyerBrokerEventKind::JobCompleted as i32; + response.job_id = Some(*job_id); + response.client_id = Some(*client_id); + } + BrokerEvent::Canceled { job_id, client_id } => { + response.kind = WinkeyerBrokerEventKind::JobCanceled as i32; + response.job_id = Some(*job_id); + response.client_id = Some(*client_id); + } + BrokerEvent::Error(message) => { + response.kind = WinkeyerBrokerEventKind::Error as i32; + response.message = Some(message.clone()); + } + BrokerEvent::MaintenanceByte { .. } => { + debug_assert!( + false, + "private maintenance bytes must not reach typed streams" + ); + } + } + response +} + +#[allow(clippy::needless_pass_by_value)] +fn map_error(error: BrokerError) -> Status { + match error { + BrokerError::UnknownClient + | BrokerError::Invalid(_) + | BrokerError::TransmitBusy + | BrokerError::MaintenanceBusy => Status::failed_precondition(error.to_string()), + BrokerError::PrimaryExists => Status::already_exists(error.to_string()), + BrokerError::QueueFull => Status::resource_exhausted(error.to_string()), + BrokerError::NotConnected | BrokerError::Unavailable | BrokerError::Transport(_) => { + Status::unavailable(error.to_string()) + } + } +} + +#[cfg(test)] +#[allow(clippy::expect_used)] +mod tests { + use super::*; + use std::time::Duration; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + #[test] + fn speed_validation_distinguishes_pot_fixed_and_unspecified() { + assert_eq!( + required_speed(WinkeyerSpeedMode::Pot as i32, None).expect("pot"), + SpeedMode::Pot + ); + assert_eq!( + required_speed(WinkeyerSpeedMode::Fixed as i32, Some(25)).expect("fixed"), + SpeedMode::Fixed(25) + ); + assert!(required_speed(WinkeyerSpeedMode::Fixed as i32, Some(100)).is_err()); + assert_eq!(optional_speed(0, None).expect("optional"), None); + } + + #[test] + fn typed_text_is_uppercase_ascii_and_rejects_control_data() { + assert_eq!(validate_text("cq test").expect("text"), b"CQ TEST".to_vec()); + assert!(validate_text("").is_err()); + assert!(validate_text("CQ\nTEST").is_err()); + assert!(validate_text("CQ é").is_err()); + } + + #[tokio::test] + async fn grpc_surface_reports_and_queues_against_physical_actor() { + let (mut device, physical) = tokio::io::duplex(4096); + let actor_task = tokio::spawn(async move { + super::super::actor::spawn(physical, Duration::from_secs(30)).await + }); + let mut host_open = [0_u8; 2]; + device.read_exact(&mut host_open).await.expect("host open"); + device.write_u8(31).await.expect("revision"); + let broker = actor_task.await.expect("actor task").expect("actor"); + let mut initialization = [0_u8; 7]; + device + .read_exact(&mut initialization) + .await + .expect("initialization"); + + let reserved = std::net::TcpListener::bind("127.0.0.1:0").expect("reserve port"); + let address = reserved.local_addr().expect("address"); + drop(reserved); + let server = tokio::spawn(run_server(address, broker)); + let endpoint = format!("http://{address}"); + let mut client = loop { + match crate::broker_proto::winkeyer_broker_service_client::WinkeyerBrokerServiceClient::connect( + endpoint.clone(), + ) + .await + { + Ok(client) => break client, + Err(_) => tokio::time::sleep(Duration::from_millis(10)).await, + } + }; + + let status = client + .get_status(WinkeyerBrokerServiceGetStatusRequest { + client_name: "integration".to_string(), + }) + .await + .expect("status") + .into_inner() + .status + .expect("payload"); + assert!(status.connected); + assert_eq!(status.firmware_revision, Some(31)); + + let response = client + .send_text(WinkeyerBrokerServiceSendTextRequest { + client_name: "integration".to_string(), + text: "test".to_string(), + speed_mode: WinkeyerSpeedMode::Fixed as i32, + speed_wpm: Some(21), + }) + .await + .expect("send") + .into_inner(); + assert_eq!(response.job_id, 1); + let mut physical_write = [0_u8; 7]; + device + .read_exact(&mut physical_write) + .await + .expect("physical write"); + assert_eq!(physical_write, [0x02, 21, b'T', b'E', b'S', b'T', 0x15]); + server.abort(); + } +} diff --git a/crates/cathub/src/winkeyer/mod.rs b/crates/cathub/src/winkeyer/mod.rs new file mode 100644 index 0000000..e3a8e55 --- /dev/null +++ b/crates/cathub/src/winkeyer/mod.rs @@ -0,0 +1,15 @@ +//! Multi-client WinKeyer brokering. +//! +//! WinKeyer is a stateful byte protocol, not a delimiter-framed CAT dialect. This module +//! owns its parser, physical-device events, scheduling, and virtual client sessions rather +//! than routing keyer bytes through the radio backend. + +mod actor; +mod broker; +mod endpoint; +mod grpc; +mod protocol; + +pub(crate) use actor::{spawn_supervised, BrokerHandle}; +pub(crate) use endpoint::{open_serial_endpoint, run_serial_endpoint, EndpointPermissions}; +pub(crate) use grpc::bind_server; diff --git a/crates/cathub/src/winkeyer/protocol.rs b/crates/cathub/src/winkeyer/protocol.rs new file mode 100644 index 0000000..474bcde --- /dev/null +++ b/crates/cathub/src/winkeyer/protocol.rs @@ -0,0 +1,320 @@ +//! WinKeyer 3 host-protocol framing and device-event classification. +//! +//! Commands occupy byte values `0x00..=0x1f`; printable bytes are Morse data. Command +//! lengths are defined by the K1EL WinKeyer 3.1 interface manual. The parser retains a +//! partial command across serial reads and emits data immediately so a virtual endpoint can +//! preserve the client's byte order without using timing to distinguish commands. + +/// Maximum bytes in one WinKeyer command, including a 256-byte EEPROM payload. +const MAX_COMMAND_LEN: usize = 258; + +/// One complete item received from a WinKeyer host client. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ClientItem { + /// One printable/prosign byte to place in the transmit stream. + Data(u8), + /// One complete immediate or buffered command, including its parameters. + Command(Vec), +} + +/// Incremental parser for bytes sent by a host application. +#[derive(Debug, Default)] +pub(crate) struct ClientParser { + command: Vec, + expected_len: Option, +} + +impl ClientParser { + /// Feed one byte and return an item when it becomes complete. + pub(crate) fn push(&mut self, byte: u8) -> Option { + if self.command.is_empty() { + if byte > 0x1f { + return Some(ClientItem::Data(byte)); + } + self.command.push(byte); + self.expected_len = initial_command_len(byte); + } else { + self.command.push(byte); + self.refine_variable_length(); + } + + if self.expected_len == Some(self.command.len()) { + self.expected_len = None; + let command = std::mem::take(&mut self.command); + return Some(ClientItem::Command(command)); + } + None + } + + /// Discard an incomplete command after a client disconnect or malformed stream. + pub(crate) fn reset(&mut self) { + self.command.clear(); + self.expected_len = None; + } + + pub(crate) fn is_partial(&self) -> bool { + !self.command.is_empty() + } + + fn refine_variable_length(&mut self) { + let Some(&opcode) = self.command.first() else { + return; + }; + if opcode == 0x00 && self.command.len() == 2 { + if let Some(admin) = self.command.get(1).copied() { + self.expected_len = Some(if admin == 0x1c { + // N1MM's extra Admin prefix plus buffered-speed command and WPM. + 3 + } else { + admin_command_len(admin) + }); + } + } else if opcode == 0x16 && self.command.len() == 2 { + // Pointer reset (00) is complete in two bytes. Move-overwrite (01), + // move-append (02), and add-nulls (03) each carry a third position/count byte. + if matches!(self.command.get(1), Some(0x01..=0x03)) { + self.expected_len = Some(3); + } + } + debug_assert!( + self.expected_len.unwrap_or(MAX_COMMAND_LEN) <= MAX_COMMAND_LEN, + "WinKeyer command length must remain bounded" + ); + } +} + +fn initial_command_len(opcode: u8) -> Option { + Some(match opcode { + // Admin and Pointer are refined after their subcommand arrives. + 0x00..=0x03 + | 0x06 + | 0x09 + | 0x0b..=0x0e + | 0x10..=0x12 + | 0x14 + | 0x16..=0x1a + | 0x1c..=0x1d => 2, + 0x04 | 0x1b => 3, + 0x05 => 4, + 0x07..=0x08 | 0x0a | 0x13 | 0x15 | 0x1e..=0x1f => 1, + 0x0f => 16, + _ => return None, + }) +} + +fn admin_command_len(subcommand: u8) -> usize { + match subcommand { + 0x00 | 0x04 | 0x0e..=0x0f | 0x16 | 0x19 => 3, + 0x0d => 258, + 0x13 => 4, + _ => 2, + } +} + +/// A byte received from the physical WinKeyer, classified by its tag bits. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum DeviceEvent { + /// Speed-pot notification. The low six bits carry the reported speed value. + SpeedPot { raw: u8, value: u8 }, + /// WinKeyer status notification or requested status reply. + Status(DeviceStatus), + /// Serial or paddle echo byte. + Echo(u8), +} + +impl DeviceEvent { + /// Classify one byte from the physical keyer's transmit stream. + pub(crate) fn from_byte(byte: u8) -> Self { + match byte & 0xc0 { + 0x80 => Self::SpeedPot { + raw: byte, + value: byte & 0x3f, + }, + 0xc0 => Self::Status(DeviceStatus::from_byte(byte)), + _ => Self::Echo(byte), + } + } +} + +/// Decoded common bits of a WinKeyer status byte. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[allow(clippy::struct_excessive_bools)] +pub(crate) struct DeviceStatus { + /// Original byte, retained for protocol-compatible virtual endpoints. + pub(crate) raw: u8, + /// Timed wait in progress. + pub(crate) waiting: bool, + /// Key output asserted by tune/key-immediate. + pub(crate) key_down: bool, + /// Morse is being sent or remains buffered. + pub(crate) busy: bool, + /// Physical paddle break-in is active. + pub(crate) break_in: bool, + /// Input buffer is over two-thirds full. + pub(crate) xoff: bool, +} + +impl DeviceStatus { + fn from_byte(raw: u8) -> Self { + Self { + raw, + waiting: raw & 0x10 != 0, + key_down: raw & 0x08 != 0, + busy: raw & 0x04 != 0, + break_in: raw & 0x02 != 0, + xoff: raw & 0x01 != 0, + } + } +} + +/// Operations which must not be forwarded from an ordinary virtual session. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CommandPolicy { + /// The broker answers this command without touching the physical host lifecycle. + Virtualized, + /// Safe transient command, scoped to the client or its active transmit stream. + Transient, + /// Global buffer/keying operation requiring active-owner arbitration. + ActiveOwner, + /// Persistent or disruptive operation requiring an exclusive maintenance lease. + Maintenance, +} + +/// Classify a complete command for permission and ownership enforcement. +pub(crate) fn command_policy(command: &[u8]) -> CommandPolicy { + let Some(&opcode) = command.first() else { + return CommandPolicy::Maintenance; + }; + if opcode == 0x00 { + return match command.get(1).copied() { + Some(0x02..=0x07 | 0x09..=0x0b | 0x14..=0x15 | 0x17..=0x18) => { + CommandPolicy::Virtualized + } + _ => CommandPolicy::Maintenance, + }; + } + match opcode { + // Buffer-pointer commands are one-shot edits of the current stream. Persisting + // opcode 0x16 in a per-client profile and replaying it before later text moves the + // physical pointer a second time, corrupting keyboard CW from N1MM. + 0x0a..=0x0b | 0x14 | 0x16 | 0x18..=0x1a => CommandPolicy::ActiveOwner, + _ => CommandPolicy::Transient, + } +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::indexing_slicing)] +mod tests { + use super::*; + + fn parse(bytes: &[u8]) -> Vec { + let mut parser = ClientParser::default(); + bytes.iter().filter_map(|byte| parser.push(*byte)).collect() + } + + #[test] + fn printable_bytes_are_emitted_as_data_without_buffering() { + assert_eq!( + parse(b"CQ"), + vec![ClientItem::Data(b'C'), ClientItem::Data(b'Q')] + ); + } + + #[test] + fn fixed_length_commands_wait_for_all_parameters() { + let mut parser = ClientParser::default(); + assert_eq!(parser.push(0x04), None); + assert_eq!(parser.push(0x01), None); + assert_eq!( + parser.push(0xa0), + Some(ClientItem::Command(vec![0x04, 0x01, 0xa0])) + ); + } + + #[test] + fn load_defaults_consumes_exactly_fifteen_values() { + let mut bytes = vec![0x0f]; + bytes.extend(1_u8..=15); + bytes.push(b'K'); + assert_eq!( + parse(&bytes), + vec![ + ClientItem::Command(bytes[..16].to_vec()), + ClientItem::Data(b'K') + ] + ); + } + + #[test] + fn admin_eeprom_load_is_one_bounded_command() { + let mut bytes = vec![0x00, 0x0d]; + bytes.extend((0_u16..256).map(|value| u8::try_from(value).expect("byte"))); + assert_eq!(parse(&bytes), vec![ClientItem::Command(bytes)]); + } + + #[test] + fn pointer_add_nulls_consumes_count_parameter() { + assert_eq!( + parse(&[0x16, 0x03, 0x20, b'A']), + vec![ + ClientItem::Command(vec![0x16, 0x03, 0x20]), + ClientItem::Data(b'A') + ] + ); + } + + #[test] + fn pointer_move_append_consumes_position_before_buffered_speed() { + assert_eq!( + parse(&[0x16, 0x02, 0x00, 0x1c, 22, b'Q']), + vec![ + ClientItem::Command(vec![0x16, 0x02, 0x00]), + ClientItem::Command(vec![0x1c, 22]), + ClientItem::Data(b'Q') + ] + ); + } + + #[test] + fn reset_discards_partial_command() { + let mut parser = ClientParser::default(); + assert_eq!(parser.push(0x02), None); + parser.reset(); + assert_eq!(parser.push(b'A'), Some(ClientItem::Data(b'A'))); + } + + #[test] + fn device_bytes_are_classified_by_tag_bits() { + assert_eq!( + DeviceEvent::from_byte(0x94), + DeviceEvent::SpeedPot { + raw: 0x94, + value: 20 + } + ); + assert_eq!( + DeviceEvent::from_byte(0xd7), + DeviceEvent::Status(DeviceStatus { + raw: 0xd7, + waiting: true, + key_down: false, + busy: true, + break_in: true, + xoff: true, + }) + ); + assert_eq!(DeviceEvent::from_byte(b'A'), DeviceEvent::Echo(b'A')); + } + + #[test] + fn host_lifecycle_is_virtualized_and_eeprom_is_maintenance_only() { + assert_eq!(command_policy(&[0x00, 0x02]), CommandPolicy::Virtualized); + assert_eq!(command_policy(&[0x00, 0x03]), CommandPolicy::Virtualized); + assert_eq!(command_policy(&[0x00, 0x0d]), CommandPolicy::Maintenance); + assert_eq!(command_policy(&[0x00, 0x0c]), CommandPolicy::Maintenance); + assert_eq!(command_policy(&[0x00, 0x10]), CommandPolicy::Maintenance); + assert_eq!(command_policy(&[0x0a]), CommandPolicy::ActiveOwner); + assert_eq!(command_policy(&[0x16, 0x02]), CommandPolicy::ActiveOwner); + assert_eq!(command_policy(&[0x02, 20]), CommandPolicy::Transient); + } +} diff --git a/crates/cathub/tests/cli.rs b/crates/cathub/tests/cli.rs new file mode 100644 index 0000000..625bc3e --- /dev/null +++ b/crates/cathub/tests/cli.rs @@ -0,0 +1,62 @@ +//! Public-surface integration tests for the cathub binary's library entry points. + +#![allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)] + +use std::path::PathBuf; + +use cathub::{run, Cli}; + +fn temp_config(contents: &str, tag: &str) -> PathBuf { + let path = std::env::temp_dir().join(format!("cathub-cli-{}-{}.toml", std::process::id(), tag)); + std::fs::write(&path, contents).expect("write temp config"); + path +} + +#[tokio::test] +async fn dry_run_accepts_a_valid_loopback_config() { + let path = temp_config( + "[radio]\nbackend = \"loopback\"\n\ + [[serial_endpoint]]\nname = \"n1mm\"\ntransport = \"COM11\"\ndialect = \"ts590\"\nperms = [\"read\", \"write\"]\n\ + [[hamlib_net]]\nname = \"engine\"\nbind = \"127.0.0.1:4532\"\nperms = [\"read\"]\n", + "valid", + ); + let cli = Cli { + config: Some(path.clone()), + 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 dry_run_rejects_an_invalid_backend() { + let path = temp_config( + "[radio]\nbackend = \"icom\"\n\ + [[serial_endpoint]]\nname = \"x\"\ntransport = \"COM5\"\ndialect = \"ts590\"\n", + "badbackend", + ); + let cli = Cli { + config: Some(path.clone()), + section: None, + log: None, + dry_run: true, + command: None, + }; + assert!(run(cli).await.is_err()); + let _ = std::fs::remove_file(&path); +} + +#[tokio::test] +async fn missing_config_is_an_error() { + let cli = Cli { + config: Some(PathBuf::from("definitely-missing-cathub-config.toml")), + section: None, + log: None, + dry_run: true, + command: None, + }; + assert!(run(cli).await.is_err()); +} diff --git a/crates/cathub/tests/fixtures/dump_state.txt b/crates/cathub/tests/fixtures/dump_state.txt new file mode 100644 index 0000000..887b8a1 --- /dev/null +++ b/crates/cathub/tests/fixtures/dump_state.txt @@ -0,0 +1,65 @@ +1 +1 +0 +150000.000000 1500000000.000000 0x1ff -1 -1 0x17e00007 0x1f +0 0 0 0 0 0 0 +150000.000000 1500000000.000000 0x1ff 5000 100000 0x17e00007 0x1f +0 0 0 0 0 0 0 +0x1ff 1 +0x1ff 0 +0 0 +0xc 2400 +0xc 1800 +0xc 3000 +0xc 0 +0x2 500 +0x2 2400 +0x2 50 +0x2 0 +0x10 300 +0x10 2400 +0x10 50 +0x10 0 +0x1 8000 +0x1 2400 +0x1 10000 +0x20 15000 +0x20 8000 +0x40 230000 +0 0 +9990 +9990 +10000 +0 +10 +10 20 30 +0xffffffffffffffff +0xffffffffffffffff +0xfffffffff7ffffff +0xfffeff7083ffffff +0xffffffffffffffff +0xffffffffffffffbf +vfo_ops=0x7ffffff +ptt_type=0x1 +targetable_vfo=0x10c3 +has_set_vfo=1 +has_get_vfo=1 +has_set_freq=1 +has_get_freq=1 +has_set_conf=1 +has_get_conf=1 +has_power2mW=1 +has_mW2power=1 +has_get_ant=1 +has_set_ant=1 +timeout=0 +rig_model=1 +rigctld_version=Hamlib 4.7.0 2026-02-15T21:14:25Z SHA=554e02b39 64-bit +agc_levels=0=OFF 1=SUPERFAST 2=FAST 5=MEDIUM 3=SLOW 6=AUTO 4=USER +ctcss_list= 67.0 69.3 71.9 74.4 77.0 79.7 82.5 85.4 88.5 91.5 94.8 97.4 100.0 103.5 107.2 110.9 114.8 118.8 123.0 127.3 131.8 136.5 141.3 146.2 151.4 156.7 159.8 162.2 165.5 167.9 171.3 173.8 177.3 179.9 183.5 186.2 189.9 192.8 196.6 199.5 203.5 206.5 210.7 218.1 225.7 229.1 233.6 241.8 250.3 254.1 +dcs_list= 17 23 25 26 31 32 36 43 47 50 51 53 54 65 71 72 73 74 114 115 116 122 125 131 132 134 143 145 152 155 156 162 165 172 174 205 212 223 225 226 243 244 245 246 251 252 255 261 263 265 266 271 274 306 311 315 325 331 332 343 346 351 356 364 365 371 411 412 413 423 431 432 445 446 452 454 455 462 464 465 466 503 506 516 523 526 532 546 565 606 612 624 627 631 632 654 662 664 703 712 723 731 732 734 743 754 +level_gran=0=0,0,0;1=0,0,0;2=0,0,0;3=0,0,0;4=0,1,0.00392157;5=0,0,0;6=0,0,0;7=0,0,0;8=0,0,0;9=0,0,0;10=0,0,0;11=0,0,10;12=0.05,1,0.00195695;13=0,0,0;14=0,0,0;15=0,0,0;16=0,0,0;17=0,0,0;18=0,0,0;19=0,0,0;20=0,0,0;21=0,0,0;22=0,0,0;23=0,0,0;24=0,0,0;25=0,0,0;26=0,0,0;27=0,0,0;28=0,0,0;29=0,0,0;30=0,0,0;31=0,0,0;32=0,1,0.00392157;33=0,0,0;34=0,0,0;35=0,0,0;36=0,0,0;37=0,0,0;38=0,0,0;39=0,100,0.00392157;40=0,0,0;41=0,0,0;42=0,0,0;43=0,0,0;44=0,2,1;45=-30,10,0.5;46=0,3,1;47=0,0,0;48=0,0,0;49=0,0,0;50=0,0,0;51=0,0,0;52=0,0,0;53=0,0,0;54=0,0,0;55=0,0,0;56=0,0,0;57=0,0,0;58=0,0,0;59=0,0,0;60=0,0,0;61=0,0,0;62=0,0,0;63=0,0,0; +parm_gran=0=0,0,0;1=0,0,0;2=0,1,0.00392157;3=0,0,0;4=0,1065353216,998277249;5=0,0,0;6=0,0,0;7=0,0,0;8=0,0,0;9=0,0,0;10=BANDUNUSED,BAND70CM,BAND33CM,BAND23CM;11=STRAIGHT,BUG,PADDLE;12=1028443341,1065353216,989872160;13=0,0,0;14=0,0,0;15=0,0,0;16=0,0,0;17=0,0,0;18=0,0,0;19=0,0,0;20=0,0,0;21=0,0,0;22=0,0,0;23=0,0,0;24=0,0,0;25=0,0,0;26=0,0,0;27=0,0,0;28=0,0,0;29=0,0,0;30=0,0,0;31=0,0,0;32=0,1065353216,998277249;33=0,0,0;34=0,0,0;35=0,0,0;36=0,0,0;37=0,0,0;38=0,0,0;39=0,1120403456,998277249;40=0,0,0;41=0,0,0;42=0,0,0;43=0,0,0;44=0,2,1;45=-1041235968,1092616192,1056964608;46=0,3,1;47=0,0,0;48=0,0,0;49=0,0,0;50=0,0,0;51=0,0,0;52=0,0,0;53=0,0,0;54=0,0,0;55=0,0,0;56=0,0,0;57=0,0,0;58=0,0,0;59=0,0,0;60=0,0,0;61=0,0,0;62=0,0,0;63=0,0,0; +rig_model=1 +hamlib_version=Hamlib 4.7.0 2026-02-15T21:14:25Z SHA=554e02b39 64-bit +done diff --git a/docs/architecture/independent-product.md b/docs/architecture/independent-product.md new file mode 100644 index 0000000..3346c36 --- /dev/null +++ b/docs/architecture/independent-product.md @@ -0,0 +1,26 @@ +# CatHub as an independent product + +Status: accepted for the 0.1 release. + +CatHub is independently versioned station infrastructure. It owns radio CAT access, +Hamlib NET and virtual serial endpoints, PTT arbitration, physical WinKeyer access, +its configuration schema, and its public broker protocol. It does not depend on a +logger, QSO storage, ADIF, QRZ, a station profile, or a specific user interface. + +QsoRipper is one optional client. It consumes Hamlib NET and the versioned WinKeyer +broker protocol. QsoRipper may launch an installed or explicitly bundled CatHub, but +normal logging does not require CatHub to be installed or running. + +The 0.1 protocol preserves the historical `qsoripper.services` wire package so deployed +clients remain compatible. CatHub owns that contract from this release onward. A future +CatHub-namespaced protocol will be introduced as a separate version, not as an in-place +wire rename. + +CatHub's standalone configuration is `cathub.toml`. A QsoRipper unified file containing +`[cat_hub]` remains a supported managed compatibility layout. `--section cat_hub` makes +that selection explicit, while automatic detection remains available during migration. + +Tagged releases produce Windows and Linux archives, SHA-256 checksum files, the +`cathub-protocol` Rust crate archive, and the `CatHub.Protocol` NuGet package. Publishing +to package registries is a separate release-authority step. Publish `cathub-protocol` +before publishing the `cathub` crate because the daemon depends on that version. diff --git a/docs/architecture/index.html b/docs/architecture/index.html new file mode 100644 index 0000000..37a1433 --- /dev/null +++ b/docs/architecture/index.html @@ -0,0 +1,889 @@ + + + + + + + CatHub Architecture: Shared Control of a Radio and WinKeyer + + + + +
+
+ CatHub / architecture + Software architecture note +
+
+ +
+ + +
+
+

CatHub engineering documentation

+

CatHub Architecture: Shared Control of a Radio and WinKeyer

+

CatHub allows applications using OmniRig, Hamlib NET, native CAT serial interfaces, virtual WinKeyer interfaces, and typed APIs to safely share one physical station.

+
+
Document type
Software architecture note
+
Scope
CatHub radio control, CW keying, client endpoints, and station-wide transmit safety
+
Examples
Illustrative Windows station using a Kenwood TS-590, a WinKeyer, and com0com virtual pairs
+
+
+ +
+

1. Executive summary

+

A station may run several programs that expect exclusive access to the same radio or keyer. Those programs also speak different protocols and have different authority. CatHub places one coordination boundary between the software and the physical devices.

+

CatHub is the sole owner of the radio control link and physical WinKeyer. Client applications connect to private serial or loopback network endpoints that match the protocol they already understand. Inside the daemon, separate radio and WinKeyer brokers serialize work, maintain shared state, apply endpoint permissions, and use one station-wide push-to-talk (PTT) lease.

+

The design lets existing applications continue to use OmniRig, Hamlib NET, vendor CAT commands, or the WinKeyer host protocol. It does not make the physical serial ports shareable. It replaces direct hardware access with explicit, managed endpoints.

+
+ +
+

2. Problem and scope

+

A modern amateur radio station often combines a panadapter, logger, digital-mode program, manufacturer control application, and logging engine. The transceiver and WinKeyer each expose one stateful hardware connection, while every application behaves as though it owns that connection.

+

OmniRig coordinates programs that use its automation API. Hamlib's rigctld coordinates programs that use Hamlib NET. A native CAT program still requires a serial port, and legacy CW software may require a physical-looking WinKeyer port. These coordinators do not share station-wide state or transmit ownership with one another.

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

CatHub's scope begins where separate interface-specific coordinators stop: shared hardware ownership, state, scheduling, permissions, and transmit safety.

+
+ +
+

3. Goals and non-goals

+
+
+

Goals

+
    +
  • Keep one authoritative owner for each physical hardware connection.
  • +
  • Support simultaneous serial CAT, Hamlib NET, OmniRig, WinKeyer serial, and typed API clients.
  • +
  • Normalize modeled radio operations into shared state and propagate changes from any client or the radio front panel.
  • +
  • Preserve per-session command order while prioritizing PTT and interactive writes over background reads.
  • +
  • Apply explicit read, write, PTT, control, and configuration permissions at each endpoint.
  • +
  • Return the station to receive when a client disconnects or a transmit safety limit expires.
  • +
  • Keep radio-specific behavior behind backend capabilities and client-specific behavior behind endpoint dialects.
  • +
+
+
+

Non-goals

+
    +
  • Replacing OmniRig, Hamlib, loggers, digital-mode applications, or manufacturer control software.
  • +
  • Implementing every Hamlib operation or every vendor CAT command in the universal state model.
  • +
  • Automatically detecting a client program or dialect from arbitrary command traffic.
  • +
  • Allowing several applications to open one physical or virtual serial endpoint.
  • +
  • Supporting simultaneous transmit owners.
  • +
  • Providing remote, cross-host station access in the current deployment model.
  • +
  • Claiming support for radio families or WinKeyer behaviors that have not been implemented and tested.
  • +
+
+
+
+ +
+

4. System context

+

From an operator's perspective, CatHub is a local station service. Applications connect to CatHub instead of opening the physical device ports. CatHub then coordinates all radio-control and CW-keying traffic.

+ +
+ +
Figure 1. CatHub system context.All software interfaces terminate at CatHub. Only CatHub opens the physical station devices.
+
+
+ +
+

5. Architecture overview

+

The detailed topology separates the protocol expected by each application from the backend used to control the hardware. Radio-control clients converge on one radio broker. CW clients converge on a separate WinKeyer broker. Both brokers use the same station transmit boundary.

+ +
+
+ Application or existing interface + CatHub component + Physical hardware +
+ +
Figure 2. Detailed CatHub topology.Client-specific adapters remain at the edge. Shared state and scheduling sit inside the brokers, while the physical devices remain single-owner resources.
+
+

The major architectural boundary is hardware ownership: applications may differ in transport and protocol, but none bypasses the broker that owns the corresponding device.

+
+ +
+

6. Components and responsibilities

+

CatHub separates volatile hardware and client protocols from the shared station model. This keeps new radio backends and client dialects at the edges rather than spreading device-specific rules through the scheduler.

+
+ + + + + + + + + + + + + + + + +
ComponentPrimary responsibilityImportant boundary
Endpoint adaptersAccept serial, Hamlib NET, virtual WinKeyer, or typed gRPC clients; create client sessions; frame and parse requests.An endpoint fixes the protocol, permissions, and compatibility policy before a client connects.
Client dialectsTranslate supported CAT commands into shared operations and format protocol-specific replies or events.Dialects use shared state and capabilities, not a concrete radio backend.
Radio brokerSerialize radio operations, preserve per-session FIFO order, prioritize interactive work, and reconnect the hardware transport.Only this actor submits work to the active radio backend.
Radio backendImplement modeled operations and native passthrough for a physical radio or an upstream rigctld process.Radio-specific behavior is isolated behind backend capabilities.
Shared radio stateStore modeled VFO, frequency, mode, split, RIT/XIT, meter, power, and PTT state with freshness metadata.All modeled mutations and downstream change notifications pass through this state.
Poller and event fan-outCoalesce baseline polling, consume native push events, update shared state, and notify interested endpoints.Native push reduces polling per covered field; it does not disable polling for uncovered fields.
WinKeyer brokerVirtualize client sessions, preserve client profiles, schedule atomic CW jobs, route status, and isolate maintenance.Client bytes and commands are never interleaved inside another client's active job.
Hardware actorsOwn the physical radio or WinKeyer transport and recover it with bounded backoff after failure.Client endpoints remain available while a physical device reconnects.
PTT leaseGrant one station-wide transmit owner across CAT PTT and WinKeyer activity.A conflicting owner is rejected rather than allowed to contend.
Transmit watchdogsEnforce radio PTT and WinKeyer maximum-transmit durations and force a safe return to receive.The timeout is a hard transmit ceiling, not a generic client-idle timeout.
ConfigurationDefine hardware, endpoints, transports, dialects, permissions, compatibility views, polling, and safety limits.Invalid or unsafe combinations fail validation before hardware is opened.
Table 2. CatHub component responsibilities and architectural boundaries.
+
+
+ +
+

7. Interfaces and connection topology

+

The following map uses one illustrative Windows station. Port numbers are examples, not required defaults. A serial application opens the application side of its own virtual pair. CatHub opens the other side and the physical hardware ports.

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

Virtual serial pairs

+

A com0com pair on Windows, or a PTY pair on Linux, behaves like a null-modem cable between two processes. Bytes written at one endpoint appear at the other. The pair does not make either endpoint shareable; CatHub and the application must open opposite sides.

+

In a serial endpoint configuration, transport is the port CatHub opens. application_transport records the paired port the operator assigns to the application. The latter is operator guidance and setup metadata; CatHub does not open it.

+ +
+ +
Figure 3. Example serial connection paths.The application-side, CatHub-side, and physical hardware ports are distinct. Reversing the two sides of a virtual pair causes an exclusive-open conflict.
+
+ +
+ Operator warning +

Never configure an application to open CatHub's transport port or a physical hardware port. The application must open its assigned application_transport port.

+
+
+ +
+

8. Runtime behavior

+

CatHub handles radio commands and CW jobs through separate schedulers because the protocols have different state and ordering rules. The following scenarios show the important runtime paths.

+ +

8.1 CAT command and state propagation

+

A client command is parsed under the policy of its configured endpoint. Modeled operations update the shared state through the serialized radio path. Replies and spontaneous changes are then formatted for each client dialect.

+
+ +
Figure 4. CAT request and state-update sequence.Per-session FIFO ordering is preserved. Across ready session heads, PTT and interactive writes take priority over routine reads and baseline polling.
+
+

The result is one coherent station view. A front-panel change, an N1MM band change, or a WSJT-X frequency write updates the same model and can be reported to every compatible client without multiplying physical-radio polls.

+ +

8.2 Atomic CW scheduling

+

The WinKeyer protocol mixes client configuration, status, buffered commands, and Morse data. CatHub therefore schedules complete typed jobs and leased raw streams rather than forwarding bytes from several clients at once.

+
+ +
Figure 5. Atomic CW scheduling sequence.No other client's bytes or profile commands are inserted inside an active job. Physical paddle break-in retains the keyer's native priority.
+
+

The result is deterministic CW. Typed jobs remain atomic, a virtual serial client keeps its own protocol state, and maintenance replies remain private to the maintenance owner.

+ +

8.3 PTT arbitration and recovery

+

Radio CAT PTT and WinKeyer transmission compete for one station resource. The first authorized client to transmit acquires a lease. Other clients receive a busy or failed-precondition response until the owner releases it.

+
+ +
Figure 6. Station-wide PTT lease lifecycle.The lease prevents simultaneous transmit owners and provides a single place to enforce fail-safe return to receive.
+
+
+ +
+

9. Safety and failure behavior

+

Failure handling is part of the architecture rather than an application convention. CatHub keeps safety decisions in the brokers that own the physical devices.

+
+ + + + + + + + + + + + + + + +
ConditionCatHub behaviorOperational result
Permission deniedReject the request using the client protocol's error form and log the endpoint and operation.Read-only or restricted clients cannot tune, key, or change persistent settings.
PTT already leasedReject the conflicting PTT or CW request as busy.Only one client controls station transmit at a time.
Transmit ceiling reachedForce receive or key-up, release the lease, and record the safety action.A crashed or wedged client cannot hold continuous transmit indefinitely.
PTT owner disconnectsUnkey immediately during session teardown and release only that owner's lease.The transmitter returns to receive without waiting for the maximum-duration ceiling.
CW job cancelledRemove the requesting client's queued jobs. An authorized active abort clears the physical buffer and forces key-up.Cancellation is scoped and does not remove another client's queued work.
Radio transport failsKeep client endpoints open, mark shared state stale, reject mutations, and retry the backend with backoff.Clients can distinguish cached status from live control; service resumes after reconnect.
WinKeyer transport failsCancel queued work, release station PTT, mark status disconnected, and reopen the port with bounded backoff.No queued Morse resumes unexpectedly after a hardware interruption.
WinKeyer maintenance beginsRequire no active or queued transmission, clear and dekey, close the normal host session, and grant exclusive access.Disruptive administrative commands cannot interleave with normal sending.
Graceful shutdownAttempt radio RX, clear the keyer buffer, force key-up, close the physical keyer host session, and release PTT.An orderly service stop leaves hardware in a safe receive state.
Malformed or oversized inputReject invalid values, bound in-progress frames, and close or resynchronize the offending session as appropriate.Bad client input cannot grow buffers without limit or silently coerce unsafe commands.
Table 4. Safety and failure contracts.
+
+
+ Implementation detail +

The transmit watchdog is a maximum continuous-transmit duration, not an inactivity timer. Digital-mode applications may key PTT and send no further CAT traffic until the over completes.

+
+
+ +
+

10. Configuration and deployment

+

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

+ +

10.1 Physical hardware and safety limits

+
+
TOML - example physical device configuration
+
# Physical ports are opened only by CatHub.
+[radio]
+backend = "ts590"
+model = "TS-590SG"
+transport = "serial"
+port = "COM4"
+baud = 57600
+
+[poll]
+baseline_ms = 200
+heartbeat_ms = 2000
+
+[ptt]
+max_tx_ms = 300000
+
+[events]
+native_push = true
+
+[winkeyer]
+port = "COM3"
+baud = 1200
+max_tx_ms = 30000
+api_bind = "127.0.0.1:50071"
+
+ +

10.2 Application endpoints

+
+
TOML - example serial, network, and WinKeyer endpoints
+
# CatHub opens COM10. OmniRig opens the paired COM11.
+[[serial_endpoint]]
+name = "hdsdr-omnirig"
+transport = "COM10"
+application_transport = "COM11"
+baud = 115200
+dialect = "ts2000"
+perms = ["read", "frequency_write"]
+
+# A separate loopback listener expresses WSJT-X authority.
+[[hamlib_net]]
+name = "wsjtx"
+bind = "127.0.0.1:4533"
+single_vfo = true
+perms = ["read", "write", "ptt"]
+
+# CatHub opens COM40. N1MM opens the paired COM41.
+[[winkeyer_endpoint]]
+name = "n1mm-cw"
+transport = "COM40"
+application_transport = "COM41"
+baud = 1200
+primary = true
+perms = ["status", "send", "control", "ptt"]
+
+ +

10.3 Operator deployment sequence

+
    +
  1. Create one virtual serial pair for each serial CAT or legacy WinKeyer application.
  2. +
  3. Configure the physical radio and WinKeyer ports. No client application should reference these ports.
  4. +
  5. Configure CatHub's side of every virtual pair as transport and record the application side as application_transport.
  6. +
  7. Create separate loopback listeners when network clients require different permissions or compatibility views.
  8. +
  9. Validate the configuration with CatHub's dry-run mode before opening hardware.
  10. +
  11. Start CatHub, then point each application at its assigned application-side COM port or loopback TCP endpoint.
  12. +
  13. Bench-test read, tune, mode, PTT, CW, cancellation, disconnect, and watchdog behavior before on-air use.
  14. +
+
+ +
+

11. Current implementation and limitations

+

The preceding sections describe the architecture. This section identifies what the current CatHub implementation supports and where the general design remains intentionally bounded.

+
+ + + + + + + + + + + + + +
AreaCurrent implementationLimitation or qualification
Radio backendsNative Kenwood TS-590, upstream rigctld, and loopback test backends.The native TS-590 backend is the first certified reference. Other radio families require additional backends or a validated rigctld pairing.
Serial CAT dialectsTS-590, transparent TS-590, and TS-2000 client dialects.Direct serial means a supported dialect over a private pair, not arbitrary serial protocol acceptance.
Hamlib NETModeled read/write operations, PTT, split and VFO queries, power queries, capability handshake, and raw CAT escape operations used by tested clients.CatHub does not reimplement every Hamlib feature. Compatibility is defined by tested client transcripts.
Compatibility viewsOptional single-VFO presentation for serial and Hamlib NET endpoints.A single-VFO endpoint rejects real split enablement; WSJT-X must use Fake It rather than Rig split.
Native passthroughSame-family rich TS-590 commands can pass through under command classification and permissions.An upstream rigctld backend provides modeled control, not transparent vendor-command fidelity.
WinKeyerPhysical 1200 baud host session, virtual serial endpoints, typed gRPC jobs and events, profiles, status fan-out, cancellation, abort, and exclusive maintenance.Only implemented and validated WinKeyer protocol behavior should be assumed; maintenance authority is deliberately restricted.
Network exposureHamlib NET and typed APIs bind to loopback in the supported deployment model.Cross-host remote operation, authentication, and network security are outside the current scope.
Station scaleOne configured radio and one optional physical WinKeyer per CatHub instance.Multi-radio orchestration is not part of the current runtime.
Table 5. Current implementation status and architectural limits.
+
+
+ +
+

12. Design decisions and tradeoffs

+
+
CatHub is the sole hardware owner.
Exclusive ownership makes ordering, recovery, and safety enforceable in one process. The tradeoff is that every application must be redirected to a CatHub endpoint and CatHub becomes required station infrastructure.
+
Each serial client receives a private virtual pair.
A private pair provides unambiguous protocol, permissions, client-session cleanup, and outbound event routing. It consumes more virtual port names than attempting to share one pair, but avoids an unsafe and generally unsupported multi-open arrangement.
+
Radio and WinKeyer use separate brokers.
CAT is request/state oriented, while WinKeyer is a stateful byte protocol with buffered Morse, profiles, and private maintenance replies. Separate schedulers keep those ordering rules explicit. They still share the station PTT lease because both can cause transmission.
+
CatHub maintains shared radio state instead of only forwarding bytes.
Shared state coalesces polling, fans out front-panel and client changes, translates between dialects, and supports consistent reads. The cost is a modeled state surface with freshness and invalidation rules, plus passthrough for rich commands outside that model.
+
Endpoints are configured explicitly.
Explicit dialects and permissions make behavior predictable and fail closed. The tradeoff is operational configuration: connecting an application to the wrong endpoint does not trigger automatic dialect detection, and the client receives only that endpoint's authority.
+
Compatibility views are endpoint policy.
A single-VFO view lets applications with simplified assumptions operate against either physical VFO without changing radio state. The view intentionally hides capabilities, such as real split, that it cannot represent honestly.
+
+
+ +
+

13. Glossary and references

+

Glossary

+
+
CAT
Computer-aided transceiver control: commands and responses used to read or change radio state.
+
Dialect
A specific command vocabulary and reply format presented to a client, such as Kenwood TS-590 or TS-2000 CAT.
+
Transport
The mechanism that carries bytes, such as a physical serial port, virtual COM endpoint, PTY, or TCP socket.
+
Endpoint
A configured CatHub listener or serial path with a fixed protocol, permissions, and compatibility policy.
+
Virtual COM pair
Two linked virtual serial endpoints. Each is opened by one process; bytes written to either side arrive at the other.
+
Client session
One live connection to an endpoint, with its own internal ID, ordering, PTT ownership, queues, and disconnect cleanup.
+
Hamlib NET
The rigctld-compatible TCP protocol used by Hamlib-aware applications to control a radio.
+
OmniRig
A Windows automation component that allows compatible applications to share configured radio instances.
+
WinKeyer
A hardware CW keyer with a stateful host serial protocol, buffered Morse sending, status, speed, and configuration commands.
+
PTT lease
CatHub's single-owner grant allowing one client session to place the station in transmit.
+
+ +

References

+
    +
  1. QsoRipper engine specification, especially the CAT hub endpoint, dialect, state, WinKeyer, and PTT contracts.
  2. +
  3. Multi-client CAT hub design and multi-client WinKeyer broker design.
  4. +
  5. CatHub operator setup and CW keying setup.
  6. +
  7. OmniRig product documentation.
  8. +
  9. Hamlib rigctld manual.
  10. +
  11. Microsoft documentation for communications resource handles.
  12. +
  13. com0com project documentation.
  14. +
  15. Implementation: serial_endpoint.rs, hamlib_net.rs, the radio dialect modules, and the winkeyer modules.
  16. +
+
+
+
+ +
+ + diff --git a/docs/design/multi-client-cat-hub.md b/docs/design/multi-client-cat-hub.md new file mode 100644 index 0000000..0e3b07b --- /dev/null +++ b/docs/design/multi-client-cat-hub.md @@ -0,0 +1,536 @@ +# Design: `cathub` multi-client CAT hub + +> **Status:** Proposed. This document is the source of truth for implementing the hub. Once implementation lands, the contract sections graduate into `docs/architecture/` and this file becomes a historical record. +> +> **Audience:** An implementer (human or AI agent) with access to this repository, the Hamlib `rigctld` net protocol reference, the Kenwood TS-590 CAT command reference (including the `AI` auto-information command), the Kenwood ARCP-590 / ARHP-590 documentation, the WSJT-X / Hamlib rig-control documentation, the Icom CI-V reference, and the com0com virtual serial port documentation. + +## 1. Problem + +A modern ham radio station runs several pieces of software against one transceiver at the same time. A typical TS-590 station wants any combination of: + +- A panadapter or SDR receiver such as HDSDR with click-to-tune on the waterfall (typically through OmniRig speaking the TS-2000 profile). +- A contest logger such as N1MM Logger+ driving the rig directly for band changes, mode switches, and CAT PTT (native TS-590 CAT). +- The manufacturer's own control software, **Kenwood ARCP-590**, which presents a full software control-panel for the radio: VFOs, modes, DSP and filter settings, the antenna tuner, meters, the CW/voice keyer, and the entire `EX` menu. ARCP-590 speaks the **native TS-590 CAT command set** and exercises a far larger command surface, and polls far more aggressively, than a logger does. +- A digital-mode application such as **WSJT-X**, which controls the rig through **Hamlib** - either Hamlib's built-in TS-590S/SG backend on a serial port or, more cleanly for a shared station, Hamlib's **NET rigctl** transport pointed at a `rigctld`-compatible TCP endpoint. WSJT-X sets frequency, mode, and CAT PTT during normal operation. +- The QsoRipper engine reading rig state for QSO enrichment (today via its `rigctld` provider). + +Each of these wants its own CAT link to the radio. The radio exposes a single physical (or USB virtual) serial port. Only one host process can own that port at a time. The result is a forced choice between contest logging, panadapter tuning, manufacturer control, digital modes, and engine awareness. Worse, the clients have **incompatible expectations**: ARCP-590 and WSJT-X both want to *write* to the radio and both want PTT; ARCP-590 and any AI-aware client expect the radio to *push* unsolicited status updates; OmniRig speaks a different dialect (TS-2000) than the radio actually is (TS-590). + +The existing workaround uses Hamlib's `rigctld` to multiplex the radio over TCP and a Python "safe bridge" to translate OmniRig's serial CAT into raw Hamlib calls. The full report is at . That stack solves a real bug (the TS-590 oscillating between VFO A and VFO B because Hamlib's TS-2000 backend retargets VFOs on every status poll), but it is fragile in several ways: + +- `rigctlcom` and the Python bridge each occupy a process and a com0com endpoint, and both must agree about which one owns the radio. +- The fix depends on never letting Hamlib invoke its VFO-targeting APIs on the TS-590. Any future Hamlib backend change can reintroduce the oscillation. +- Adding a second serial client (N1MM, ARCP-590, on its own virtual COM port) requires the bridge to multiplex serial endpoints, which the current Python script does not do. +- There is no story for the radio's native auto-information push (`AI2;`), so every client polls independently and the real-radio command rate scales with the number of clients. + +This design replaces the legacy multiplexing chain (the Python safe bridge and a client-facing `rigctlcom`) with one Rust daemon that is the single owner of the radio link and fans it out to as many clients as the operator needs, regardless of whether a given client speaks native TS-590 CAT, a foreign Kenwood dialect (TS-2000), or the Hamlib net protocol. "Single owner of the radio link" has two forms (§7): in native mode the daemon owns the serial port directly; in bridge mode a private, daemon-owned `rigctld` owns the port and the daemon is its sole client. + +## 2. Goals + +- One daemon is the single owner of the radio link, and all client traffic is serialized through it. The link is owned either directly (a native backend holds the serial port) or as the sole client of a private `rigctld` the daemon owns (bridge mode). No other process may share that link. +- Multiple client apps run simultaneously against one radio with full read and write parity. This explicitly includes a heavy native controller (ARCP-590), one or more loggers (N1MM), a foreign-dialect panadapter client (OmniRig/HDSDR), a Hamlib-net digital-mode client (WSJT-X), and the QsoRipper engine, all at once. +- State changes from any client - or from the radio's front panel - propagate to all the other clients through a shared in-memory cache, so HDSDR's waterfall follows N1MM's band changes, ARCP-590 reflects a knob turn made on the physical radio, and the engine's view stays current. +- **The daemon is rig-agnostic by construction.** Only the `RadioBackend` is rig-specific; the state cache, polling, event fan-out, endpoints, dialects, and PTT arbitration know nothing about any particular transceiver. All hub logic is **capability-driven** (`BackendCapabilities`), never branched on rig model. Adding a transceiver is adding a backend, in one of the trust tiers below, with no changes to the rest of the daemon. +- **The radio path never emits VFO-retargeting commands during polling.** This is the wire-level invariant that makes the original VFO A/B oscillation structurally impossible (see §8.8). It is stated in terms of bytes on the wire, not which library is linked, so it holds for hand-written, descriptor-driven, and (once certified) Hamlib-bridged backends alike. +- The daemon is client-agnostic by construction. Adding a new client app means picking an existing CAT dialect, or writing one new `ClientDialect` implementation, or pointing the client at the Hamlib net endpoint. +- The Hamlib net endpoint supports the **read and write** subset that modeled rig-control clients use (frequency, mode, PTT, split/VFO, plus the `\dump_state`/`\chk_vfo` handshake), validated per client against captured transcripts. This is the recommended **rig-agnostic universal tier**: it works against any backend because it is implemented entirely against the universal state and capabilities. +- Radios that can push spontaneous status updates (Kenwood `AI2;`, Icom CI-V transceive, Yaesu auto-information, Flex streaming) have that stream owned centrally by the daemon and fanned out to the clients that want it, so one real-radio event stream feeds every event-aware client without each client polling. Radios without push get the same downstream fan-out via poll-diff synthesis (see §8.4). +- Native rich controllers such as ARCP-590 work without the daemon having to model every CAT command. Commands the universal state does not model are forwarded transparently to the radio through the same serialized path (passthrough). Passthrough is a family-scoped power feature (a native dialect over a same-family backend); the rig-agnostic path for everything else is the universal tier above. +- The QsoRipper engine continues to consume rig state without code change by speaking the Hamlib net protocol to a built-in server in the daemon. +- **The daemon is a universal bridge between the Hamlib world and the direct-CAT world.** Software that speaks Hamlib/`rigctld` (WSJT-X, the QsoRipper engine, most modern apps) connects to the Hamlib net endpoint; software that expects to own a COM port and speak the rig's native CAT directly (ARCP-590, OmniRig/HDSDR, N1MM) connects to a virtual serial endpoint speaking its expected dialect. Neither population has to change or learn about the other, and both drive the same physical radio simultaneously. Making these "just work" together is the central goal. +- **The radio backend may also be a trusted out-of-process `rigctld`.** `rigctld` is mature and, run against a rig's correct native model, drives many transceivers robustly. The daemon can therefore use a `RigctldBackend` that talks the `rigctld` net protocol *as a client* to a separate `rigctld` process (the daemon never links Hamlib; see §7.1, §8.8). This is the fast path to **breadth** - universal *modeled* control (frequency, mode, PTT, split, RIT/XIT) across the hundreds of rigs Hamlib supports - while the daemon supplies the multiplexing, caching, event fan-out, dialect translation, and PTT arbitration that bare `rigctld` does not. It is explicitly a **modeled-control bridge, not a transparent native-CAT pipe**: because `rigctld` normalizes the radio's CAT, rich native passthrough (an ARCP-590 `EX`-menu control-panel session) is *not* available through it. Full native fidelity - passthrough, native push, the no-VFO-retargeting guarantee by construction, and lowest latency - comes from a native backend (§7.1). For the TS-590, the native backend is the recommended default; `rigctld` is the breadth/bring-up path and a robustness-proven alternative. + +## 3. Non-goals + +- Replacing OmniRig, HDSDR, N1MM, ARCP-590, WSJT-X, or any other client app. The daemon brokers them; it does not reimplement them. +- Reimplementing Hamlib in full. The daemon implements only the subset of the `rigctld` net protocol that real clients are shown (by captured transcript) to use. The universal tier targets **modeled rig-control clients**, not literally every Hamlib feature; the supported set is a tested compatibility matrix (§10), not an open-ended "any client" promise. +- Modeling the full vendor CAT command set in the universal state. The state models the hot, cross-client fields (frequency, mode, split, RIT/XIT, S-meter, power, PTT). Everything else (the `EX` menu, filter/DSP detail, keyer memories, tuner state) flows through family-scoped passthrough. +- Shipping the data-driven descriptor backend interpreter or an **in-process** libhamlib (FFI) backend in v1. Both are deliberate roadmap items (§7.3): the descriptor language is deferred until at least two hand-written backends exist to validate its shape, and linking libhamlib is deferred behind a non-default feature flag because of native-dependency packaging cost. The **out-of-process** `RigctldBackend` (TCP client to a separate `rigctld` process, no linking) is *not* deferred - it ships in v1 as the broad-compatibility backend (§7.1, §7.2) so the daemon is immediately useful against any rigctld-supported rig. The hand-written, Hamlib-free `Ts590Backend` remains the certified first-class reference so the trust story and the core invariants are proven on at least one rig the project owns end to end. +- Supporting every transceiver in v1. v1 ships the Kenwood TS-590 as the first reference rig. The architecture is rig-agnostic; the Icom, Yaesu, and FlexRadio backends are designed for but not shipped in v1. +- Remote operation across hosts. The daemon binds loopback only. Cross-host operation is a future extension (ARHP-590-style remote heads are explicitly out of scope for v1). +- A GUI control panel. v1 ships configuration via TOML and observability via structured logs. + +## 4. Current architecture, for reference + +``` +HDSDR + | + v +OmniRig (TS-2000 profile, COM11, 115200 baud, 500 ms poll) + | + v +com0com pair (COM11 <-> COM10) + | + v +safe_ts590_omnirig_bridge.py on COM10 + | + v +rigctld (TCP 127.0.0.1:4532) + | + v +TS-590 on COM3 (single owner) +``` + +N1MM, ARCP-590, and WSJT-X cannot insert themselves anywhere in this chain without taking COM3 away from `rigctld`. The QsoRipper engine consumes state by speaking the Hamlib net protocol to `rigctld`. + +## 5. Proposed architecture + +The daemon (`cathub`) is one Rust binary. It is the single owner of the radio link - directly via a serial port (native backend) or as the sole client of a private `rigctld` (bridge backend, §7). It exposes several client endpoints. Each client endpoint is either a virtual COM port (for client apps that expect serial CAT) or a TCP server (for clients that speak the Hamlib net protocol). + +``` + +-----------------+ +HDSDR --> OmniRig (TS-2000) --> com0com --> COM_OMNIRIG_SIDE ---->| | + | | +N1MM (native TS-590) ------------> com0com --> COM_N1MM_SIDE ---->| | + | qsoripper- | +ARCP-590 (native TS-590) --------> com0com --> COM_ARCP_SIDE ---->| cathub |--> COM3 --> TS-590 + | (Rust) | +WSJT-X -- Hamlib NET rigctl ------> 127.0.0.1:4533 (rw) -------->| | + | shared state | +QsoRipper engine -- Hamlib net ---> 127.0.0.1:4532 (ro) -------->| cache + AI | + | fan-out | + +-----------------+ +``` + +Each serial client gets its own virtual serial pair on the operator's machine. OmniRig, N1MM, and ARCP-590 each bind one end of a dedicated pair; the daemon binds the other end. WSJT-X and the QsoRipper engine connect over the loopback Hamlib net endpoint; because they have different privilege needs (WSJT-X writes and keys PTT, the engine only reads) and a single TCP port cannot express per-client permissions, the daemon exposes two loopback Hamlib listeners - a read-only one for the engine and a write/PTT one for WSJT-X (each accepts multiple connections). Neither client app needs any reconfiguration beyond pointing at its assigned virtual COM port or Hamlib endpoint. + +On Windows the virtual serial pairs are com0com pairs. On Linux the equivalent is a PTY pair (for example via `socat -d -d pty,raw,echo=0 pty,raw,echo=0` or `tty0tty`); the daemon binds one node and the client binds the other. The daemon treats both identically - it opens a serial-style endpoint by path and does not depend on the pairing mechanism. + +## 6. Component layout + +The crate lives at `src/rust/cathub/` and is added to the workspace members in `Cargo.toml`. Internal modules: + +- `radio` owns the radio transport. v1 supports a serial port (native backend) and a TCP connection to a private `rigctld` (bridge backend); v2 adds TCP for FlexRadio. The module exposes an async `submit(cmd, priority) -> reply` API. All access is serialized through one tokio task. Scheduling preserves **per-session FIFO ordering** and prioritizes only between the ready heads of the per-session queues: a client session's commands always reach the radio in submission order, while across sessions PTT outranks interactive writes, which outrank reads/passthrough, which outrank the background baseline poll. A read issued by a session after that session's own write is therefore guaranteed to observe its own write. This avoids reordering a single client's request stream (which serial CAT clients assume) while still keeping an operator's keying and tuning ahead of another client's bulk reads. Framing is configurable per backend: semicolon-terminated for Kenwood and Yaesu, `0xFD`-terminated for Icom CI-V. The radio task also demultiplexes **spontaneous** frames (see `events` below) from solicited replies via an explicit frame matcher (§8.4). Reconnect on disconnect is automatic and idempotent. +- `backend` defines the `RadioBackend` trait. Implementations live in `backend/kenwood/ts590.rs` and `backend/loopback.rs` for v1, with `backend/icom/ci_v.rs`, `backend/yaesu/ft991a.rs`, and `backend/flex/smartsdr.rs` as v2 modules. The trait is the only seam between the radio and everything else. +- `state` is the universal in-memory snapshot. It must be rich enough for split- and VFO-aware clients (Hamlib `v`/`V`/`s`/`S`, WSJT-X split, N1MM, ARCP-590), so v1 models, per VFO where applicable: per-VFO frequency (A and B), per-VFO mode and passband, the active RX VFO, the active TX VFO, split enabled plus split TX VFO/frequency, RIT and XIT enabled plus offsets, S-meter, power, and PTT owner. Each field carries `last_polled`, `last_set`, and a `native_push_covered` flag (see `poller`/§8.4). Mutations from any path go through this layer. Backends populate it. Dialects read and mutate it. The Hamlib net endpoint reads and mutates it. The `events` module updates it from spontaneous radio frames (Kenwood `AI2;`, CI-V transceive, etc.). None of those touch the others directly. The state layer also exposes a **change-notification broadcast** (a `tokio::sync::broadcast` channel) so endpoints can push updates to event-aware clients. +- `poller` is a single background task that drives a low-rate baseline poll through the active backend into `state`. The baseline cadence is 200 ms by default. Push back-off is **per field, not blanket**: only fields whose `native_push_covered` flag is set (those the radio actually reports via its native push stream - Kenwood `AI2;`, typically frequency, mode, split) degrade to a slow liveness/heartbeat poll while native push is active. Fields the radio does *not* push spontaneously (S-meter, power, tuner/DSP detail) keep their own polling cadence regardless. Crucially, only `RadioEventSource::NativePush` coverage sets this flag - poll-diff synthesized events (§8.4) never do - so a backend without native push (including the `RigctldBackend`) keeps polling at the baseline rate. The cache TTL per field controls when client-driven reads piggyback on the next baseline cycle versus serve directly from cache. +- `dialect` defines the `ClientDialect` trait. Implementations: `dialect/kenwood/ts590.rs` for N1MM and ARCP-590 (native pass-through with state caching and unmodeled-command passthrough), `dialect/kenwood/ts2000.rs` for OmniRig (translator). Dialects only touch the universal state and the radio passthrough channel; they never call a specific backend directly. This is what guarantees any dialect serves any backend. A dialect can both answer a request *and* emit asynchronous frames to its client (see event fan-out, §8.4). +- `serial_endpoint` is the generic virtual-COM listener. Each configured endpoint binds one COM/PTY path and routes its byte stream into a configured dialect. Endpoints run concurrently as independent tasks. Two endpoints serving the same dialect against the same backend are supported and expected (one for HDSDR, one for another panadapter app, or N1MM and ARCP-590 both on the native TS-590 dialect). Each opened endpoint creates a client session with its own **outbound queue** so the daemon can push unsolicited frames (AI updates) independently of other sessions. +- `hamlib_net` is the minimal `rigctld`-compatible TCP server. It binds `127.0.0.1:4532` by default and accepts multiple simultaneous connections (WSJT-X and the engine at once). It supports the **read and write** subset that real Hamlib clients use: + - reads: `f` (get freq), `m` (get mode), `v` (get vfo), `s` (get split), `i` (get split TX frequency), `x` (get split TX mode), `l RFPOWER` (get configured relative transmitter power), `2`/`\power2mW` (convert relative power to milliwatts), `t` (get ptt), and `\get_powerstat`; + - writes: `F` (set freq), `M` (set mode), `V` (set vfo), `S` (set split), `T` (set ptt); + - protocol handshake: `\dump_state` and `\chk_vfo`, which WSJT-X and other Hamlib clients issue on connect to learn rig capabilities; + - raw `w`/`W` (write/read CAT) passthrough for escape hatches. + Every command is implemented against the universal state and the backend capability table, so it works for any backend without changes. Response formats - the `\dump_state` capability dump, `\chk_vfo`, the `M ` shape, split replies, and the exact `RPRT ` lines - must match what real `rigctld` emits closely enough for unforgiving clients like WSJT-X; the dialect is validated against golden transcripts captured from a real `rigctld` (§10.1). Unsupported operations return the specific `RPRT` code rigctld uses (for example `RPRT -11`, "not available") rather than guessing. + The native TS-590 backend polls the radio's `PC;` configured-power query and normalizes it with the TS-590's mode-sensitive maximum. The bridge backend probes downstream `rigctld` for `i`, `x`, `l RFPOWER`, and `power2mW`; unsupported optional probes return `RPRT -11` without failing the required frequency/mode poll. This lets the QsoRipper engine log true split RX/TX frequencies and configured TX power when the active backend exposes them. + Because a single TCP endpoint cannot express per-client permissions and any local process could connect, write and PTT are **disabled by default** and the daemon supports more than one listener: a read-only endpoint for the QsoRipper engine and a separate write/PTT-enabled endpoint for WSJT-X. Each listener carries its own permission set (see `permissions`). The existing `RigctldProvider` in `src/rust/qsoripper-core/src/rig_control/rigctld.rs` connects to the read-only endpoint with no code change; WSJT-X connects to the write/PTT endpoint as a standard Hamlib NET rigctl rig. +- `events` owns the radio's spontaneous-update (push) stream centrally. On a rig that supports it, it enables the native push mode at startup (Kenwood `AI2;`, Icom CI-V transceive, etc.), parses pushed frames (typically `IF;` responses and single-field updates) into universal-state mutations tagged `RadioEventSource::NativePush`, and feeds the state change-notification broadcast. On a rig without native push, the baseline poller diffs successive polls and feeds the same broadcast tagged `PollDiff`, so downstream fan-out is uniform. It also **virtualizes** the auto-info command per client: a client that sends `AI2;`/`AI1;`/`AI0;` toggles only its own session's push subscription; it never changes the real radio's push state. This prevents clients from fighting over the single physical push setting. +- `passthrough` handles native CAT commands the universal state does not model (the bulk of ARCP-590's traffic: `EX` menu reads/writes, filter/DSP queries, tuner, keyer). The dialect forwards the raw command through the serialized radio task and returns the radio's reply verbatim. Reads may be served from a short-TTL cache **keyed by the full normalized command** (not just a prefix - `EX` and similar commands carry parameters that select different values), and the cache is **invalidated by command family** whenever a write in that family is forwarded. Commands whose mutability or side effects are unknown are not cached. Passthrough writes that happen to mutate a modeled field also update the universal state so other clients stay consistent. Passthrough requires that the endpoint's dialect native command set matches the active backend's native command set (see §7 caveat); it is not portable across radio families. +- `ptt` enforces PTT ownership and arbitration. Routed through the state mutation API. See §8.5. +- `permissions` defines a per-endpoint (and per-Hamlib-listener) capability set driven by a **per-dialect command classification table** that tags each command as one of: modeled read, modeled write, passthrough read, transient write, PTT/TX-affecting write, persistent/config write, or denied/unknown. The endpoint flags `read`, `write`, `ptt`, and `config_write` gate those classes (`config_write` gates `EX`-menu and other persistent-setting writes). The narrower `frequency_write` flag grants only modeled frequency mutations, allowing a panadapter to click-to-tune without authority to overwrite another client's mode, VFO, split, RIT, or XIT. `write` remains the backward-compatible full modeled-write grant. Unknown passthrough writes default to denied unless the endpoint explicitly opts into unsafe full control. This lets the operator give ARCP-590 full control while restricting a panadapter endpoint to tuning only, and prevents a stray native command from keying TX or rewriting menu settings from an under-privileged endpoint. +- `config` loads TOML from `%APPDATA%\cathub\cathub.toml` on Windows and `$XDG_CONFIG_HOME/cathub/cathub.toml` on Linux. A `--config ` flag overrides the default. A `--dry-run` flag loads, validates, prints the resolved config, and exits without binding any ports. +- `logging` wires `tracing` with per-endpoint spans, a rolling file appender under `%LOCALAPPDATA%\cathub\logs\cathub.log` on Windows or `$XDG_STATE_HOME/cathub/cathub.log` on Linux, and a periodic summary line carrying commands per second per endpoint, cache hit ratio, real-radio reads per second, passthrough reads per second, native-push frames per second, dropped/denied PTT writes, denied config writes, and reconnect events. +- `main` wires everything, installs Ctrl+C and SIGTERM handlers that emit `RX;` on shutdown to avoid a stuck transmitter, and exits with a non-zero code on fatal initialization failures. + +## 7. Multi-radio model + +The `RadioBackend` trait is the radio-side abstraction. Conceptual shape: + +```rust +#[async_trait] +pub trait RadioBackend: Send + Sync { + async fn poll(&self, state: &StateHandle) -> Result<(), BackendError>; + async fn apply(&self, mutation: StateMutation, state: &StateHandle) -> Result<(), BackendError>; + /// Parse a spontaneous frame the radio pushed (Kenwood AI, CI-V transceive, Yaesu + /// auto-info, Flex stream) into a state mutation, if recognized. Backends without a + /// native push source return None and rely on poll-diff synthesis (§8.4). + fn parse_event(&self, frame: &[u8]) -> Option; + /// Forward an opaque native command and return the raw reply (family-scoped passthrough). + async fn passthrough(&self, raw: &[u8]) -> Result, BackendError>; + fn capabilities(&self) -> BackendCapabilities; +} +``` + +`BackendCapabilities` is the single source of rig-specific truth the rest of the daemon consults: VFO count and sub-receiver presence, supported modes and split style, RIT/XIT and meter availability, frequency ranges, native-push support and its per-field coverage, native command family (for passthrough compatibility), and the trust tier (§7.1). No hub module branches on rig model; it branches on capabilities. The capability table is also what `\dump_state` reports to Hamlib clients. A backend with no XIT reports `xit: false`, and the Hamlib net endpoint returns `RPRT -11` for XIT commands against that backend without ever touching the wire. + +`StateMutation` is a universal enum: + +```rust +pub enum StateMutation { + SetVfoFreq { vfo: Vfo, hz: u64 }, + SetMode { vfo: Vfo, mode: Mode }, + SetSplit { enabled: bool, tx_vfo: Option }, + SetRit { offset_hz: i32, enabled: bool }, + SetXit { offset_hz: i32, enabled: bool }, + SetPtt { keyed: bool }, +} +``` + +`BackendCapabilities` (above) is the rig-specific seam; nothing else in the daemon is. + +The `ClientDialect` trait is the client-side abstraction: + +```rust +#[async_trait] +pub trait ClientDialect: Send + Sync { + /// Handle one inbound request: read state, emit a mutation, or passthrough; return reply bytes. + async fn handle(&self, request: &[u8], ctx: &ClientSessionContext) -> Vec; + /// Format a state-change notification as an unsolicited frame for this client (event fan-out), + /// or return None if this dialect/client does not want pushes for this change. + fn format_notification(&self, change: &StateChange, ctx: &ClientSessionContext) -> Option>; +} +``` + +`ClientSessionContext` carries the endpoint's permission set, its per-endpoint event subscription state, and handles to the universal state and the radio passthrough channel. There are two tiers of client compatibility, and the distinction is what makes the daemon rig-agnostic: + +- **Universal tier (rig-agnostic).** The Hamlib net endpoint and any dialect's *modeled* commands are served entirely from the universal state and capabilities. They work against any backend. A TS-2000 modeled read served by an Icom backend answers exactly as well as one served by a Kenwood backend. This is the recommended path for the broad population of clients. +- **Native-dialect tier (family-scoped).** Raw passthrough forwards radio-family-specific bytes, so a native TS-590 dialect (N1MM, ARCP-590) only fully works over a Kenwood backend. Passthrough **fails closed**: pairing a native dialect with a non-matching backend family must fail config validation, or run in modeled-only mode with passthrough disabled. The daemon never promises ARCP-590 against a non-Kenwood radio. + +### 7.1 Backend strategies and trust tiers + +A backend is any implementation of `RadioBackend`. There are four ways to produce one, all interchangeable from the daemon's point of view, each declaring a **trust tier** in its capabilities: + +- **First-class native backend** (e.g. `Ts590Backend`): hand-written Rust, no Hamlib dependency, wire-trace certified to honor the no-VFO-retargeting-on-poll invariant (§8.8), and covered by transcript tests. This is the bug-proof reference for rigs the project supports directly. Command metadata (framing, per-field get/set templates, reply behavior, mode/band value maps, push command and parse rules, side-effect class) is held in **tables** rather than scattered through code, so it is straightforward to later serialize into a descriptor. +- **Out-of-process `rigctld` backend** (`RigctldBackend`, first-class for breadth, v1): the daemon connects to a separately launched, daemon-private `rigctld` over TCP and speaks the `rigctld` net protocol **as a client**. The daemon never links Hamlib, so this carries no native-linking packaging cost and cannot reintroduce in-process Hamlib state bugs. It gives immediate **modeled-control** breadth across every rig Hamlib supports, using each rig's **correct native Hamlib model** (notably *not* the TS-2000 model on a TS-590). It is the recommended way to bring up a rig that has no native backend yet, and a robustness-proven alternative on rigs that do. Two deliberate limits: (1) it is a *modeled-control* bridge - `rigctld` normalizes the native CAT away, so family-scoped native passthrough (ARCP-590 `EX` menu) is **not** available through it, and it should report `native_command_family: None` / no raw-passthrough capability; (2) it is **uncertified by default** for the no-VFO-retargeting invariant, because the daemon does not control `rigctld`'s radio-side traffic. A specific `rigctld` version + model + configuration is promoted to *certified* only after the §10.3 soak observes the **`rigctld`-to-radio wire** (not just daemon-to-`rigctld`) and finds no VFO-retargeting. Until then the pairing runs behind an explicit opt-in and does not advertise the guarantee in `\dump_state` trust metadata. +- **Descriptor backend** (roadmap, §7.3): one generic interpreter driven by a declarative descriptor (TOML/RON) carrying the same table data, so adding a same-family rig is data plus tests rather than code. This is the long-term path to native fidelity (passthrough, push, wire-level control) *without* per-rig code, where `rigctld` gives breadth without native fidelity. There is strong prior art for the format: OmniRig's per-rig `.ini` files describe each transceiver's CAT command set and status-polling/parse masks declaratively (see the RigIni format reference in §16), and Hamlib encodes equivalent per-rig knowledge in its backends. The descriptor language should be researched against OmniRig's `.ini` model as a starting point - while deliberately *not* copying its polling behavior, because OmniRig's own status polling (like Hamlib's TS-2000 backend) is a source of the VFO-switching issue this design exists to prevent. A descriptor is only "supported" when its test suite passes, including a certification that its poll command list emits no VFO-target traffic. Deferred until at least two native backends exist to prove the descriptor language must express enough (reply matching, event demux, no-reply/verify semantics, side-effect classification, passthrough invalidation, VFO semantics, per-field poll/push coverage). +- **In-process libhamlib (FFI) backend** (roadmap, §7.3, non-default `hamlib-ffi` feature): links libhamlib directly. Functionally similar reach to the out-of-process bridge but with native packaging cost (DLL/SO discovery, ABI drift, cross-build, licensing) and the in-process state-bug surface the out-of-process bridge avoids, so it is deferred and, when built, CI-gated on both Windows and Linux. First-class native backends remain Hamlib-free regardless. + +The out-of-process `rigctld` backend and the first-class native backend together cover the two realities: a hand-certified, dependency-free, full-fidelity driver for the primary rig, and a trusted breadth bridge to everything else Hamlib already drives well. The hub's durable value - single-owner serialization, coalesced polling, event fan-out, PTT arbitration, and dialect translation - is identical regardless of which strategy produced the backend. + +### 7.1.1 Which backend is best, and why the trait is the real abstraction + +The deliberate answer is *no single backend implementation is best for every rig* - which is exactly why the abstraction lives in the `RadioBackend` trait and `BackendCapabilities`, not in any one strategy. Backends trade along two axes: + +- **Fidelity** - exact bytes on the wire (so the no-VFO-retargeting invariant holds *by construction*), native push (AI2 / CI-V transceive) as a true event stream, transparent native-CAT passthrough for rich control-panel apps (ARCP-590's `EX` menu, keyer, tuner), and the lowest latency (no extra process or TCP hop). +- **Breadth and effort** - how many rigs are covered for how much engineering. + +A **hand-written native backend maximizes fidelity** and is therefore the **recommended default for the TS-590 and any rig the project supports richly**. It is the only strategy that delivers ARCP-590 control-panel passthrough, native AI2 push, the invariant by construction, and minimal latency - i.e. the fast, efficient TS-590 control this design targets. Its cost is per-rig engineering. + +The **`rigctld` bridge maximizes breadth** at near-zero per-rig cost by reusing Hamlib's mature drivers, but it is modeled-control-only and cannot guarantee the invariant or carry native passthrough (above). It is the best choice for bringing a *new* rig up quickly and as a robustness-proven alternative, not as the TS-590 default. + +The **descriptor backend** is the intended way to eventually get native fidelity *and* breadth together - declarative per-rig data over one interpreter - which is why it, not the `rigctld` bridge, is the long-term scaling target. The `rigctld` bridge remains valuable as the zero-effort fallback for the long tail of rigs no one has described yet. + +So the recommended composition is: native backend for the TS-590 (and each rig we invest in), `rigctld` bridge for immediate breadth and as a trusted alternative, descriptor backend as the scaling endgame, and in-process FFI only as a last resort. Picking the right strategy per rig is a capability/config choice; the rest of the daemon never changes. + +### 7.2 v1 scope + +- Backends: `Ts590Backend` (first-class native, the recommended default for the reference rig), `RigctldBackend` (out-of-process bridge for modeled control of any other rigctld-supported rig), and `LoopbackBackend`. The Kenwood code is table-driven so `Ts2000Backend`, `Ts480Backend`, `Ts890Backend`, and friends drop in by replacing the table; those tables are the future descriptor data. +- Dialects: `Ts590Dialect` (native pass-through with state caching, event fan-out, and family-scoped passthrough - serves both N1MM and ARCP-590) and `Ts2000Dialect` (for OmniRig, translator that answers `IF;`, `FA;`, `FB;` from the universal state and rejects Hamlib-style VFO-target writes). +- Endpoints: native serial endpoints for N1MM and ARCP-590, a TS-2000 serial endpoint for OmniRig, and the Hamlib net endpoint for WSJT-X and the engine. + +### 7.3 Roadmap + +- `IcomCiVBackend` for IC-7300, IC-7610, and IC-705. Different framing (`0xFE 0xFE` preamble, sub-address byte, `0xFD` end-of-message) but the same universal state. Icom transceive mode is the CI-V analogue of Kenwood AI and feeds the same `parse_event` path. +- `YaesuFt991aBackend` and similar Yaesu models. Kenwood-like ASCII with Yaesu-specific commands. +- `FlexSmartSdrBackend` over the native FlexRadio TCP API. No serial port involved on the radio side; its status stream feeds `parse_event`. +- The **descriptor backend** interpreter, once the second native backend has clarified the descriptor language. +- The **in-process libhamlib (FFI) backend** (non-default `hamlib-ffi` feature), only if a deployment cannot run a separate `rigctld` process and the out-of-process `RigctldBackend` is therefore unavailable. +- `IcomCiVDialect` for client apps that prefer to speak CI-V. + +Each native/descriptor entry is one new backend (or descriptor file). None of them changes any existing module; the hub stays rig-agnostic. + +## 8. Behavior contracts + +### 8.1 Serialization and ordering + +All real-radio I/O goes through the single radio task. No two commands are ever in flight to the transceiver. Each client session has its own FIFO queue; the radio task selects the next command by priority **across the ready heads** of those queues - PTT (highest) > interactive client writes (frequency/mode/split) > client reads/passthrough > baseline poll (lowest) - and never reorders commands within one session. A session's read therefore always observes that session's earlier writes, while an operator's keying and tuning can still preempt another client's bulk reads. Each command carries a oneshot reply channel. + +### 8.2 Coalesced polling + +A client-driven status read of a **modeled** field is answered from `state` when the relevant field's `last_polled` is within its TTL. Otherwise the request waits for the next baseline poll cycle (or, when AI is active, the most recent pushed value). The result is that N concurrent client reads of `IF;` produce at most one real `IF;` to the radio per TTL window, regardless of how many client endpoints are active. Reads of **unmodeled** fields go through passthrough (§8.6) and are coalesced only by the optional passthrough response cache, so a controller that polls unmodeled fields hard will generate proportional real-radio traffic; the priority scheme keeps that traffic from starving interactive writes. + +Poll back-off keys off native-push coverage of the **currently active** receive VFO, not a fixed VFO A. The baseline poller asks whether the active VFO's frequency is covered by `NativePush` before slowing down; if it tested a hard-coded VFO A while the radio sat on VFO B, it would needlessly over-poll a VFO whose pushes already keep state fresh. + +### 8.3 Write atomicity + +Kenwood set commands do not all return an acknowledgment - many are "set and no reply." The daemon therefore classifies each modeled command's reply behavior in the backend command table as one of: + +- **no-reply write:** state is updated optimistically after the serial write succeeds; correctness is reconciled by the next native-push echo (for `native_push_covered` fields) or a verify-read for fields that are not push-covered; +- **write with reply:** the daemon waits for and parses the reply before updating state and acking the client; +- **read:** the daemon waits for the matching solicited reply (frame matcher, §8.4). + +For "write with reply" commands, subsequent reads from any endpoint see the new value immediately after the ack. For "no-reply" commands the new value is visible after the optimistic update, then confirmed (and corrected if the radio rejected it) by the echo/verify path. Either way, HDSDR's waterfall follows N1MM's band change without HDSDR ever talking to N1MM directly. The atomicity guarantee is "ordered and eventually reconciled," not "blocked on an ack the radio never sends." + +### 8.4 Event (spontaneous update) fan-out + +This is the mechanism that lets a knob turn on the physical radio, a band change in N1MM, a frequency set from WSJT-X, and a VFO change in ARCP-590 all stay mutually consistent at low cost. It is **rig-agnostic**: the Kenwood `AI2;` auto-information stream is one concrete event source; Icom CI-V transceive, Yaesu auto-information, and the Flex status stream are others; and rigs with no push at all are handled by poll-diff synthesis. All of them normalize to one internal stream of state mutations, each tagged with a source: + +```rust +pub enum RadioEventSource { + NativePush, // AI2 / CI-V transceive / Yaesu auto-info / Flex stream + PollDiff, // synthesized by the poller diffing successive polls + OptimisticWrite, // a client write we applied before reconciliation + VerifyRead, // a confirming read after a no-reply write +} +``` + +Downstream fan-out is identical regardless of source, so a station with a non-push rig behaves the same as one with `AI2`. The crucial distinction is that **only `NativePush` events count as evidence the radio provides spontaneous updates**: poll back-off (`poller`) and any field's `native_push_covered`/freshness flag are driven by `NativePush` coverage alone. Poll-diff events give downstream uniformity but never cause the poller to slow down or claim freshness it does not have. + +**Frame demultiplexing contract.** When a native push source is enabled the radio emits frames that can be byte-identical to solicited replies (Kenwood `IF...;` is the classic case). The radio task disambiguates with an explicit matcher: each outbound command declares its expected reply verb(s) and whether it expects a reply at all. When a frame arrives, if a command is pending whose expected verb matches, the frame completes that command's oneshot; otherwise the frame is routed to the backend's `parse_event`. Commands that expect no reply (no-reply writes, §8.3) never capture an incoming frame. Each pending command has one absolute reply deadline; unmatched unsolicited frames must not restart or extend it. This must be tested against the hard interleavings: a pending `IF;` read arriving concurrently with an unsolicited `IF` push, a no-reply write immediately followed by its push echo, passthrough reads arriving while push frames stream, and a missing reply while unsolicited frames continue beyond the deadline. + +- The `events` module owns the radio's native push setting centrally (for Kenwood it sets `AI2;` once at startup; for Icom it enables transceive; etc.) and owns it for the daemon's lifetime. +- Native frames the radio pushes are parsed by the backend's `parse_event` into `StateMutation`s, tagged `NativePush`, applied to `state`, and emitted on the state change-notification broadcast. Where no native push exists, the poller emits the same broadcast tagged `PollDiff`. +- Each client session subscribes to the broadcast. For each change, the endpoint's configured dialect decides via `format_notification` whether and how to push it to that session. A native TS-590 client with virtual auto-info enabled receives a synthesized `IF;`/field frame; a TS-2000 client receives the TS-2000-equivalent frame; a client with auto-info disabled receives nothing and continues to poll. +- The auto-info command from any client (Kenwood `AI`, etc.) is **virtualized**: it toggles only that session's subscription, never the real radio's push state. This removes the classic multi-client failure where one app disables auto-info and silences the stream another app depends on. +- Because the daemon, not each client, owns the real push stream, one physical event feed serves every event-aware client and the baseline poll can back off (for `NativePush`-covered fields only), cutting real-radio traffic. + +**Push ordering and backpressure.** Each session's outbound push queue is bounded. Pushes are interleaved with that session's solicited replies at frame boundaries (never mid-frame). If a slow client lets its queue fill, the daemon **coalesces** superseded updates (keeping only the latest value per field) rather than blocking the shared broadcast or growing unboundedly; a sustained overflow is logged. Event fan-out must never apply backpressure to the radio task or to other sessions. + +**VFO-switch fan-out must lead with a frequency frame.** When the active receive VFO changes (e.g. an operator or client switches VFO A→B), the dialect that synthesizes the notification for a panadapter-class client (TS-2000 translator used by HDSDR/OmniRig) **must emit the new active VFO's `FA` frequency frame (and `MD` mode) first**, not only an `IF` status frame. HDSDR/OmniRig retune the panadapter exclusively from `FA`; an `IF`-only notification leaves the display stuck on the old VFO's frequency even though the cache is correct. This is the wire-level fix for the HDSDR "lost rig control on A/B switch" failure. + +**Native TS-590 active-VFO pushes must carry the operating `IF` frame.** Operating-frequency trackers on the native dialect (N1MM Logger+, Log4OM-as-TS590, ARCP-590) read the *displayed* frequency and mode from the `IF;` operating-status answer, not from a bare per-VFO `FB`. The native `Ts590Dialect` therefore emits the explicit per-VFO frame (`FA`/`FB`) for any frequency change **and**, whenever the change is on the **active** receive VFO, appends a synthesized `IF` frame; an active-VFO mode change emits `MD` plus the same `IF`. Without the appended `IF`, a frequency change while the rig sat on VFO B pushed only `FB`, which these clients ignore - so the displayed frequency silently froze on VFO B while VFO A worked. This is the native-dialect counterpart to the TS-2000 lead-with-`FA` rule above and the wire-level fix for the N1MM "frequency stops tracking on VFO B" failure. + +**Lagged-subscriber resync.** The change-notification broadcast ring is bounded, so a momentarily slow session can miss intermediate one-shot changes (a single Mode or VFO toggle that is evicted before the session drains the channel). When a session observes broadcast *lag* it must **not** simply skip ahead - that would leave the client rendering permanently stale state until the next coincidental change. Instead the session replays the current `state` snapshot through its configured dialect (which still gates on that session's auto-info subscription), with the active-VFO selection applied last so the client ends on the correct receive VFO. + +### 8.4.1 Auto-info mode granularity + +The auto-info command is virtualized per session, but for Kenwood `AI1` (post-command echo) and `AI2` (spontaneous updates) are not identical semantics (other vendors have analogous distinctions). v1 may collapse them to a single per-session push subscription; if it does, that limitation is documented and ARCP-590 and N1MM are tested specifically to confirm they tolerate the collapse before either is claimed as first-class. If a tested client depends on the distinction, the finer model is implemented for that dialect. + +### 8.4.2 Single-VFO operating-VFO virtualization + +Some loggers run as single-VFO controllers and actively reject the inactive VFO. N1MM Logger+ in **SO1V** mode is the canonical case: when the radio reports VFO B as the active receive VFO (Kenwood `IF;` field P10 = `1`), N1MM raises *"You should not use VFO B when configured for SO1V"* and **freezes its frequency display**, so an operator working on VFO B sees a stuck frequency even though the hub's cache is correct. Faithfully reporting P10 = `1` (§8.4 native active-VFO rule) is *correct* radio behavior, but it defeats the hub's goal of seamless A/B operation for this class of client. + +The fix is a per-endpoint, opt-in **operating-VFO virtualization** policy (`single_vfo = true` on a `ts590` endpoint). When enabled, the endpoint never exposes the physical VFO letter; it always presents whichever VFO the radio is actually on (the operating / receive VFO) **as VFO A**: + +- **`IF;` reads** report P10 = `0` (VFO A) and split = `0`, carrying the operating VFO's frequency and mode. The 38-byte frame length is unchanged; only the active-VFO and split digits are rewritten. +- **`FA;` reads** return the operating VFO's frequency; **`FA` writes** tune the operating VFO (so a "set VFO A" from the logger tunes physical VFO B when the rig is on B). +- **`FB`** is mirrored onto the operating VFO rather than leaking physical VFO B, and split-on-B never reaches the endpoint. +- **`FR`/`FT`** (receive/transmit VFO select) are intercepted: reads return VFO A; a select write is swallowed (never forwarded to the radio), so the logger can never drive an A/B retarget. +- **Notifications** are re-presented: an operating-VFO frequency change pushes `FA` (never `FB`) plus the synthesized operating `IF`; a mode change pushes `MD` + `IF`; and an **A→B (or B→A) switch re-presents the new operating VFO** by pushing its `FA` + `MD` + an `IF` with P10 = `0`, so the logger seamlessly retunes with no SO1V warning. Inactive-VFO churn is suppressed. +- **Raw passthrough** frames are suppressed for a single-VFO endpoint, since a verbatim native frame could leak a physical VFO-B verb. + +This policy is **opt-in per endpoint and off by default**. It is enabled only for genuine single-VFO loggers (N1MM SO1V). Dual-VFO control panels such as **ARCP-590 must leave it off** (`single_vfo = false`), because they legitimately control and display the real VFO A and VFO B. Split-aware clients are out of scope for this simplification: a single-VFO endpoint always sees split = `0`. SO2V is an N1MM-side workaround, not a substitute for this hub-side virtualization. + +#### `single_vfo` on Hamlib NET (rigctld) endpoints + +The same virtualization is available on `[[hamlib_net]]` endpoints (`single_vfo = true`) for rigctld-protocol clients that, like N1MM SO1V, fundamentally expect to receive on VFO A. **WSJT-X** stops decoding when the hub reports the operator is on VFO B, and **Log4OM** polls `\get_vfo_info VFOA`, so on VFO B it logs the stale inactive VFO A frequency. With `single_vfo = true` the rigctld endpoint presents the operating VFO as VFO A using a **strict** single-VFO contract - the inactive VFO is never exposed: + +- **`v` / `\get_vfo`** always report `VFOA`. +- **`\get_vfo_info `** resolves *any* requested VFO to the operating VFO and reports `Split: 0`; the inactive VFO's data is never returned (a `VFOB` request also answers with the operating VFO). +- **`s` / `\get_split_vfo`** report `0` / `VFOA`. +- **`f`/`m` reads** and **`F`/`M` writes** already target the operating (receive) VFO, so the frequency/mode are real on either physical VFO and a `set_freq` tunes the VFO the operator is actually using. +- **`S 1` / `\set_split_vfo 1`** (enable split) is **rejected** (`RPRT -11`) because a single-VFO presentation cannot model a real A/B split; `S 0` is accepted as a no-op. WSJT-X must therefore use **"Fake It"** split (which QSYs the single VFO at TX time), not **"Rig"** split, on a `single_vfo` endpoint. +- **`V ?` / `\set_vfo ?`** advertise only `VFOA`, so a client never targets the inactive VFO. + +The QsoRipper engine endpoint leaves `single_vfo = false` so the engine logs the true operating VFO letter. + +### 8.5 PTT ownership and arbitration + +Multiple clients are now PTT-capable (N1MM CAT PTT, WSJT-X `T 1`, ARCP-590). The daemon arbitrates with a single-owner lease: + +- A client session must inherit the `ptt` capability from its endpoint to key at all. Sessions without it have their PTT writes dropped with a logged warning. +- The first capable session to key acquires the **PTT lease** and becomes the PTT owner. While the lease is held, PTT writes from any other session are rejected (Hamlib `RPRT` busy / native error) and logged, so two apps cannot key the transmitter into contention. +- The lease releases when the owner sends `RX;`/`T 0` (normal release), or after a configurable **maximum-transmit safety duration** (`ptt_max_tx_ms`) as a backstop against a client that keys and crashes. This timeout is a hard transmit-length ceiling, *not* a generic "no CAT activity" idle timer: WSJT-X keys PTT and then sends no CAT traffic for the length of a transmission, so an activity-based timeout would unkey it mid-over. `ptt_max_tx_ms` defaults above the longest expected digital-mode transmission and any safety release is logged loudly. +- The shutdown handler attempts to emit `RX;` to the radio on Ctrl+C and SIGTERM so an orderly stop never leaves the transmitter keyed. A panic or hard crash cannot reliably perform async serial I/O, so the daemon also relies on `ptt_max_tx_ms` and the radio's own TX timeout as the ultimate stuck-transmitter guards rather than promising the shutdown write always runs. +- A session that **disconnects while it holds the PTT lease** (TCP drop, client crash, EOF, or a protocol-violating over-long frame) releases the lease during session teardown: the daemon unkeys the radio (`RX;`/`T 0`) and frees the lease so the next client can key, rather than holding the transmitter up until the `ptt_max_tx_ms` ceiling. Teardown releases **only** when the disconnecting session is the current lease owner, so it never disturbs another session's active transmission. + +In a normal station the operator drives one mode at a time, so the lease is almost never contended; its job is to make contention safe rather than to support simultaneous transmit. + +### 8.6 Passthrough for rich native clients + +ARCP-590 (and any future control-panel-class controller) issues many commands the universal state does not model. The contract: + +- A command whose normalized form maps to a modeled field is handled through the state path (cached read or atomic write) so it participates in coalescing, event fan-out, and cross-client consistency. +- Any other command is forwarded verbatim through the serialized radio task and its raw reply is returned to the client unchanged. Reads may be served from a short-TTL cache keyed by the **full normalized command** (parameters included) and invalidated **by command family** when a write in that family is forwarded; commands with unknown side effects are not cached. +- Passthrough is gated by the command classification table and endpoint permissions (§6 `permissions`): an endpoint without `config_write` cannot push `EX`-menu or other persistent-setting writes, and unknown passthrough writes are denied by default; such attempts are logged. +- Passthrough never bypasses serialization. It is just another priority-classified entry in the radio task's inbox. + +### 8.7 Graceful recovery + +If the radio transport disappears (USB unplugged, radio powered off), the daemon keeps the client endpoints open and answers modeled status reads from `state` with a `stale` flag. State mutations and passthrough writes from clients return a non-fatal error. The radio task retries the transport with backoff. On reconnect, the daemon re-asserts the native push setting (Kenwood `AI2;`, Icom transceive, etc.), the baseline poller resumes, and the stale flag clears. + +### 8.8 The no-VFO-retargeting invariant + +The structural guarantee against the original bug is stated at the **wire level**, not in terms of which library is linked: *baseline polling and modeled reads must never emit radio-side VFO-selection or VFO-retargeting commands; such commands are sent only for an explicit user/client VFO or split mutation.* This is what makes the TS-590 VFO A/B oscillation impossible, and it is the certification target for every backend (§10.3). + +- The daemon **never links Hamlib** in any first-class path. The Hamlib net endpoint is a thin server-side reimplementation of the wire protocol, not Hamlib code, and the out-of-process `RigctldBackend` is a TCP *client* of a separate `rigctld` process - neither links libhamlib. +- The first-class native `Ts590Backend` satisfies the invariant by construction: it issues only TS-590 native commands and never the TS-2000-style VFO-target writes that triggered the oscillation. Its poll command list is asserted in tests to contain no VFO-target verbs. +- The out-of-process `RigctldBackend` is **uncertified by default**. It satisfies the invariant only when `rigctld` runs against the rig's **correct native model** (e.g. the TS-590 model, never the TS-2000 model), and even then the daemon cannot prove it because it does not control `rigctld`'s radio-side traffic. A specific `rigctld` **version + model + configuration** is promoted to *certified* only after the §10.3 soak observes the **`rigctld`-to-radio wire** (a serial-line capture, not just the daemon-to-`rigctld` TCP traffic) and finds no VFO-retargeting under the full client compatibility matrix. Until certified, the pairing requires explicit operator opt-in and does not advertise the no-VFO guarantee in logs or `\dump_state` trust metadata. The bridge also reports no native push (it relies on poll-diff events, §8.4) unless a tested net-protocol push path is added. +- The optional in-process libhamlib (FFI) backend carries the same per-rig certification requirement and the additional in-process state-bug surface, which is exactly why the out-of-process bridge is preferred and FFI is deferred (§7.1). + +The invariant is enforced by a per-backend certification soak, not by a blanket "no Hamlib anywhere" rule, so the design can honor both a dependency-free certified driver and a trusted external `rigctld` without weakening the guarantee. + +### 8.9 Input bounds and malformed-input handling + +A multi-client hub accepts bytes from several long-lived, independently-failing endpoints plus the radio link itself, so every byte-accumulating buffer is explicitly bounded and every parsed field is validated rather than trusted: + +- **Frame/line length caps.** Each in-progress accumulation buffer - the serial-endpoint partial-frame buffer, the radio task's frame reassembler, and the `hamlib_net` request-line reader - is capped (4096 bytes). A delimiter-less stream that would otherwise grow a buffer without limit is treated as a fault: the radio/serial reassembler discards the malformed partial frame and resynchronizes on the next delimiter, while the `hamlib_net` reader closes the offending connection (and releases its PTT lease, §8.5). The cap bounds only in-progress *requests*; long multi-line *replies* such as `\dump_state` are unaffected. The `hamlib_net` line reader is a length-bounded manual reader, not an unbounded `lines()` adapter. +- **Reject, never silently coerce.** A command that decodes to an unrecognized value must be rejected with the protocol's error reply, never quietly substituted with a plausible default that would mis-drive the radio. In particular, a Hamlib `set_mode` (`M`) with an unrecognized mode token returns `RPRT -EINVAL` and writes nothing to the radio, instead of resolving the unknown token to a default mode and falsely reporting `RPRT 0`. + +## 9. Configuration + +The TOML schema is: + +```toml +[radio] +model = "kenwood-ts590" +transport = "serial" +port = "COM3" +baud = 115200 + +[poll] +baseline_ms = 200 # active when native push is unavailable +heartbeat_ms = 2000 # slow liveness poll while native push covers a field +freq_ttl_ms = 50 +mode_ttl_ms = 200 + +[events] +native_push = true # daemon enables the rig's push stream (AI2 on TS-590) and owns it + +# Read-only Hamlib endpoint for the QsoRipper engine. +[[hamlib_net]] +name = "engine" +bind = "127.0.0.1:4532" +permissions = ["read"] + +# Write/PTT Hamlib endpoint for WSJT-X. +[[hamlib_net]] +name = "wsjtx" +bind = "127.0.0.1:4533" +permissions = ["read", "write", "ptt"] + +[[serial_endpoint]] +name = "omnirig" +port = "COM10" +baud = 115200 +dialect = "kenwood-ts2000" +permissions = ["read"] # panadapter follows; does not drive the radio + +[[serial_endpoint]] +name = "n1mm" +port = "COM20" +baud = 115200 +dialect = "kenwood-ts590" +permissions = ["read", "write", "ptt"] + +[[serial_endpoint]] +name = "arcp590" +port = "COM30" +baud = 115200 +dialect = "kenwood-ts590" +permissions = ["read", "write", "ptt", "config_write"] # full control-panel control +``` + +The operator creates one virtual serial pair per serial client. OmniRig binds `COM11` (the daemon binds `COM10`), N1MM binds `COM21` (daemon `COM20`), ARCP-590 binds `COM31` (daemon `COM30`). Each `[[serial_endpoint]]` may also specify serial line parameters (`parity`, `stop_bits`, `data_bits`) and control-line behavior (`dtr`, `rts`); these default to 8-N-1 but are configurable because some clients assert DTR/RTS or expect specific framing and the virtual pair must match on both ends. WSJT-X is configured as a Hamlib **NET rigctl** rig pointed at the write/PTT Hamlib endpoint. The QsoRipper engine continues to use its existing `RigctldProvider` configuration, pointed at the read-only Hamlib endpoint (TCP supports both clients simultaneously). + +A v2 Icom configuration is structurally identical: + +```toml +[radio] +model = "icom-ic7300" +transport = "serial" +port = "COM3" +baud = 115200 +ci_v_address = 0x94 +``` + +Endpoints, polling, events, and the Hamlib net endpoint are unchanged for modeled commands. Note that a native TS-590 dialect's passthrough does *not* carry over to an Icom backend (§7): an Icom station would drive the radio through the Hamlib net endpoint and the TS-2000/CI-V dialects for modeled state, and a native Kenwood control-panel app like ARCP-590 is simply not applicable to a non-Kenwood radio. The universal state model, event fan-out, and modeled reads/writes remain backend-independent. + +To drive a rig through a trusted out-of-process `rigctld` instead of a native backend, the operator points the radio at the bridge. The daemon owns the endpoints, cache, event fan-out, and arbitration; a **daemon-private** `rigctld` owns the physical port. That `rigctld` must accept the daemon as its **sole client** - no other app may connect to it directly, or it would bypass the daemon's serialization, PTT lease, and cache. The Hamlib model `rigctld` is launched with is the per-rig safety knob (§8.8): + +```toml +[radio] +backend = "rigctld" # out-of-process bridge; the daemon never links Hamlib +transport = "tcp" +host = "127.0.0.1" +port = 4532 # the daemon-private rigctld (no other client may connect) +# rigctld itself is started against the rig's correct native model, e.g. +# rigctld -m 2045 -r COM3 -s 115200 (TS-590SG native model) +# uncertified by default; promoted per rigctld version+model+config via the §10.3 soak. +``` + +The rest of the config - endpoints, permissions, polling, events - is identical for the **modeled** control surface, which is the point: a direct-CAT app (OmniRig, N1MM) gets the same frequency/mode/PTT/split behavior over its virtual COM endpoint whether the backend is native or the `rigctld` bridge. The bridge does *not* carry native control-panel passthrough, so an `EX`-menu controller like ARCP-590 still requires a native same-family backend (§7.1). + +## 10. Validation strategy + +### 10.1 Unit tests + +- `state` cache TTL, mutation, staleness, concurrent reader behavior, and change-notification broadcast delivery. +- `dialect/kenwood/ts590.rs` and `dialect/kenwood/ts2000.rs` round-trips against recorded TS-590 transcripts, including ARCP-590 `EX`-menu passthrough transcripts. +- `events` parsing of recorded spontaneous frames (Kenwood unsolicited `IF;`, and an Icom CI-V transceive sample) into mutations tagged `NativePush`, and per-endpoint event virtualization (a client `AI0;` must not change the real radio's auto-info state). A poll-diff test confirms synthesized events are tagged `PollDiff` and never trigger poller back-off or set `native_push_covered`. +- `permissions` enforcement: write/ptt/config_write denials for under-privileged endpoints. +- `hamlib_net` read and write command coverage validated against **golden transcripts captured from a real `rigctld`** (NET rigctl), including the `\dump_state` capability dump, `\chk_vfo`, `F`, `M `, `T`, `S`, `V`, simple vs extended response mode, and the exact `RPRT ` lines for unsupported operations. Per-endpoint permission enforcement (read-only endpoint rejects `F`/`M`/`T`). +- `backend/kenwood/ts590.rs` command table and `parse_event`/`passthrough` coverage against recorded byte sequences. A static assertion confirms the backend's poll command list contains no VFO-target verbs (the no-VFO-retargeting invariant, §8.8). +- `backend/rigctld.rs` (out-of-process bridge) protocol-client coverage against recorded `rigctld` net-protocol transcripts: read/set frequency, mode, PTT, split/VFO, and the `RPRT` error mapping into `BackendError`. A test confirms the bridge surfaces an uncertified rig+model pairing in its reported trust tier. +- `backend/loopback.rs` exposes a deterministic implementation used by every integration test. + +### 10.2 Integration tests + +- Virtual serial pairs via `tokio::io::duplex` simulate the OmniRig, N1MM, and ARCP-590 ports; a TCP client simulates WSJT-X and the engine on the Hamlib net endpoint. A `LoopbackBackend` records every mutation and passthrough. Assertions: no modeled-field client poll causes more than one backend command per TTL window; no TS-2000 status poll ever causes a `FR0`/`FR1` to be sent; writes from one endpoint are visible to reads on every other endpoint; a simulated front-panel change (unsolicited frame from the loopback backend) propagates to all event-subscribed endpoints. +- The existing `RigctldProvider` from `src/rust/qsoripper-core/src/rig_control/rigctld.rs` is pointed at the daemon's read-only Hamlib endpoint and consumes state without code changes. A separate simulated WSJT-X client on the write/PTT endpoint sets frequency/mode and keys PTT concurrently with the engine reading state, and a test confirms the read-only endpoint rejects `F`/`M`/`T`. +- A PTT arbitration test confirms the first capable session acquires the lease, a second capable session is rejected while the lease is held, the lease releases on `RX;`/`T 0` and on the `ptt_max_tx_ms` safety ceiling (and that a CAT-idle but actively-transmitting client is *not* unkeyed before that ceiling), and PTT from a session lacking the `ptt` capability is dropped with a warning. A shutdown test confirms `RX;` is emitted on Ctrl+C and SIGTERM, and that `ptt_max_tx_ms` bounds a simulated crash that skips the shutdown write. +- Frame-demux tests drive the matcher through a pending `IF;` read arriving with a concurrent unsolicited `IF` push, a no-reply write followed by its `AI2` echo, and passthrough reads interleaved with push frames, asserting each oneshot completes against the correct frame and no push is lost. A continuous-unmatched-frame test proves that a missing solicited reply still fails at its original absolute deadline. +- A passthrough test confirms an ARCP-590-style `EX`-menu read is forwarded verbatim and that a `config_write` denial is enforced on an endpoint without that permission. +- A `RigctldBackend` integration test runs the daemon in front of a stub `rigctld` server (an in-process TCP listener replaying recorded net-protocol exchanges) and confirms every endpoint - the OmniRig TS-2000 endpoint, the N1MM native endpoint, and the Hamlib net endpoint - performs **modeled** reads and writes (frequency, mode, PTT, split) against the same radio through the bridge, so a direct-CAT app is demonstrably bridged to a rigctld-driven rig for modeled control. A companion test asserts the bridge declares no native-passthrough capability, so a native `EX`-menu passthrough attempt (ARCP-590 style) fails closed rather than being silently forwarded. +- A **client compatibility matrix** is maintained as a table of captured per-client transcripts (ARCP-590, N1MM, OmniRig/HDSDR, WSJT-X, the QsoRipper engine), each replayed against the daemon in CI. A client is "supported" only when its transcript passes; the matrix, not an open-ended "any client" claim, defines the supported set (§3). + +### 10.3 Live bench + +- **Per-backend VFO-retargeting certification soak (§8.8).** For each backend a station will use, certify the no-VFO-retargeting invariant against the **radio-side wire** (a serial-line capture: the daemon's own port for the native backend, the `rigctld`-to-radio port for the bridge - never just the daemon-to-`rigctld` TCP hop). Reproduce the VFO B scenario from the existing report *and* replay the full client compatibility matrix (ARCP-590, N1MM, OmniRig, WSJT-X, engine) so retargeting triggered by modeled reads, split/VFO queries, or `\chk_vfo` paths is caught, not only the idle baseline poll. The capture must show zero `FR0`/`FR1` (VFO-target) traffic and no front-panel flicker across a 30-minute soak. A specific backend + (for the bridge) `rigctld` version + model + configuration that fails is not marked certified, regardless of trust tier. This soak is the gate that promotes a `rigctld`-driven rig from "uncertified bridge" to "certified." +- Run N1MM, HDSDR (via OmniRig), ARCP-590, WSJT-X, and the QsoRipper engine simultaneously. Exercise band changes, mode changes, RIT, and CAT PTT from N1MM; full control-panel control and `EX`-menu access from ARCP-590; frequency/mode/PTT from WSJT-X. Turn the VFO knob on the physical radio. Confirm every client reflects the change, that `dotnet run --project src\dotnet\QsoRipper.Cli -- status` reports the same state, and that only one transmitter key is ever active. +- Event fan-out: with the native push stream (Kenwood `AI2;`) owned by the daemon, confirm a physical-knob frequency change reaches every event-subscribed client without any client having polled, and that only `native_push_covered` fields back off to the heartbeat rate while meter/power keep their own cadence. Repeat against the `RigctldBackend` to confirm poll-diff synthesized events fan out identically and that no field is marked `native_push_covered` (the bridge keeps polling at the baseline rate). +- Serial line behavior: confirm ARCP-590, N1MM, and OmniRig open their virtual ports with the configured parity/stop/data bits and DTR/RTS handling, and that a mismatch surfaces a clear error rather than silent garbled CAT. +- Stress test: poll at 50 ms from all endpoints simultaneously while ARCP-590 streams `EX`-menu reads. Modeled-field real-radio command rate should stay near the baseline poll rate, not the sum of client poll rates; interactive writes (PTT, frequency set) must stay responsive (priority scheduling) even under the passthrough load. + +## 11. Rollout + +The crate lands in phases so each phase is independently testable and useful. + +Phase 1 brings up the skeleton, the radio task with the priority inbox, the `LoopbackBackend`, the universal state with change-notification broadcast, the `Ts590Backend`, and the `Ts590Dialect` with passthrough. The N1MM endpoint works end-to-end against a real TS-590. The OmniRig endpoint, ARCP-590 endpoint, event fan-out, and the Hamlib net endpoint are not yet wired. + +Phase 2 adds the `Ts2000Dialect` and the OmniRig serial endpoint, plus the ARCP-590 native serial endpoint and the `permissions` model. It also lands the **out-of-process `RigctldBackend`** as a first-class breadth backend: the daemon connects as the sole client of a daemon-private `rigctld` and presents that radio's **modeled** control surface (frequency, mode, PTT, split, RIT/XIT) to every endpoint. This lets the daemon bring up *modeled* control of non-Kenwood and not-yet-natively-supported rigs immediately, and lets the operator migrate off the legacy Python safe bridge and `rigctlcom` while keeping the `rigctld` they trust. Native control-panel passthrough (ARCP-590 `EX` menu) is **not** carried over the bridge and still requires the native `Ts590Backend` (§7.1). Exactly one process owns the physical port: either `rigctld` (with the daemon as its sole client) or the daemon's native `Ts590Backend` directly - never both, and no third app connects to that `rigctld`. On a TS-590 the native backend is the recommended default; the bridge is the path for other rigs and a robustness-proven alternative. The legacy bridge and `rigctlcom` are retired from the operator's startup scripts. + +Phase 3 adds the Hamlib net protocol server with full read/write support, `\dump_state`, and `\chk_vfo`. The QsoRipper engine config is repointed at the daemon, and WSJT-X is connected as a Hamlib NET rigctl rig. The *legacy* multiplexing chain (the Python safe bridge plus a client-facing `rigctld`/`rigctlcom`) is removed; `rigctld` now appears only where it belongs, as the optional radio-side backend behind `RigctldBackend` for rigs without a native backend. + +Phase 4 adds the `events` module (central ownership of the radio's native push stream - Kenwood `AI2;`, CI-V transceive, etc. - spontaneous-frame parsing tagged by `RadioEventSource`, per-endpoint push virtualization, fan-out, and `NativePush`-only poller back-off), PTT lease arbitration, structured metrics, and the `--dry-run` mode. + +Phase 5 finalizes documentation, but per the project's engine-spec-currency rule the spec and operator docs are updated **in the same PR as the behavior that changes them**, not deferred wholesale to the end - for example, the PR that repoints the engine at the daemon (Phase 3) updates the relevant spec note in that same PR. Phase 5 then consolidates: it updates `docs/architecture/engine-specification.md` to describe the daemon as the recommended rig-control front door on shared-radio stations, with `rigctld` retained as a supported alternative for single-client setups. It clarifies that the cathub daemon is **station infrastructure that lives below the engine's `rigctld` provider**, not part of the gRPC engine contract, so the engine remains client-agnostic and the §3.4 `RigControlService` / §5.3 rigctld integration sections are unchanged on the engine side. Adds an operator setup guide under `docs/integrations/cathub-setup.md` covering virtual serial pair creation (com0com on Windows, PTY pairs on Linux), OmniRig, N1MM, ARCP-590, and WSJT-X configuration, startup order, AI behavior, and a troubleshooting checklist. Adds `Start-CatHub`, `Stop-CatHub`, and `Get-CatHubLog` helpers to `scripts/profile-helpers.ps1` mirroring the existing `Start-Rigctld` and `Start-RigBridge` style. + +## 12. Risks + +- **com0com / PTY driver quirks.** Port-open failures must surface clear, actionable errors. The operator guide documents driver installation and the expected pair naming on both Windows and Linux. +- **TS-2000 and TS-590 command set drift.** A few TS-2000 commands have no clean TS-590 equivalent. The dialect layer rejects unsupported commands with a logged reply rather than guessing. The universal state plus baseline polling keeps status reads honest. +- **ARCP-590 command surface beyond the modeled state.** Passthrough covers it, but a command that mutates a modeled field through an unmodeled prefix could desync the cache. The backend's prefix map for state-mutating commands must be tested against recorded ARCP-590 transcripts, and event fan-out provides a correction path because the radio echoes the real change. +- **Spontaneous-event parsing and the push-ownership contract.** If a client manages to change the real radio's auto-info state (Kenwood `AI`), other clients lose their push feed. The virtualization in `events` must be the only path that ever writes the auto-info command to the radio; a test enforces that a client `AI0;` does not reach the wire. +- **PTT contention and stuck transmitter.** The lease makes contention safe; orderly Ctrl+C and SIGTERM paths emit `RX;`; and because a panic/crash cannot reliably do async serial I/O, `ptt_max_tx_ms` plus the radio's own TX timeout are the ultimate stuck-transmitter guards. Integration tests exercise the arbitration, the safety ceiling, and the shutdown paths. +- **Latency regressions versus the Python bridge under heavy passthrough.** The integration suite includes an end-to-end timing assertion. The budget is sub-five-millisecond added latency per modeled CAT round-trip on loopback, with interactive writes prioritized ahead of passthrough reads so ARCP-590 polling cannot inflate PTT/tuning latency. +- **Single radio, multiple writers stomping each other.** Serialization through the single radio task is structural, not advisory. An integration test fans writes in from all endpoints and confirms the radio sees a strict ordering. +- **Out-of-process `rigctld` dependency.** The `RigctldBackend` adds a second process and a TCP hop, and inherits whatever model `rigctld` is launched with. Mitigations: the daemon supervises/launches `rigctld` and surfaces its exit clearly; an uncertified rig+model pairing is flagged in logs and trust metadata until the §10.3 soak passes; and a TS-590 station always has the native `Ts590Backend` as the dependency-free, certified alternative. + +## 13. Alignment with QsoRipper architecture + +This design is consistent with the project's architectural principles: + +- **Stable core, volatile edges.** The cathub daemon is an *edge* component (station infrastructure). The QsoRipper engine's stable core is untouched: it keeps consuming rig state through its existing `RigctldProvider` and the `RigControlService` gRPC contract. Hardware, dialect, and vendor-app quirks are isolated inside the daemon. +- **Normalize at the edge.** Vendor CAT dialects (TS-590 native, TS-2000, CI-V) and vendor apps (ARCP-590, OmniRig, WSJT-X) are normalized into one universal state model at the boundary, exactly as the engine normalizes QRZ and ADIF into project-owned proto/domain types. +- **Rig-agnostic by construction (stable core, volatile edges, restated for hardware).** Rigs are edges, not core. Only the `RadioBackend` is rig-specific; the state cache, polling, event fan-out, endpoints, dialects, and arbitration are capability-driven and never branch on rig model. A native certified driver, a trusted out-of-process `rigctld`, and a future descriptor file are interchangeable behind one trait - so the daemon bridges the Hamlib world and the direct-CAT world for *many* rigs, not one, without the core ever learning a model name. +- **Performance and low latency.** Priority scheduling, coalesced polling, native-push-driven poll back-off, and a passthrough cache target the project's "everything should feel instant" goal for the interactive control path. +- **Engine specification currency.** Per project rules, Phase 5 updates `docs/architecture/engine-specification.md` in the same change that lands the behavioral shift, documenting the daemon as the recommended rig-control front door while keeping the engine's gRPC contract stable. +- **Cross-platform.** The daemon uses path-based serial endpoints and `std::path` semantics, supports com0com (Windows) and PTY pairs (Linux), and avoids hardcoded separators, satisfying the repository's Windows-and-Linux requirement even though the primary station is Windows. + +## 14. Validation gates for the implementation PRs + +When the implementation PRs land, each must pass: + +- `cargo fmt --manifest-path Cargo.toml --all -- --check` +- `cargo clippy --manifest-path Cargo.toml --all-targets -- -D warnings` +- `cargo test --manifest-path Cargo.toml` +- `cargo llvm-cov --manifest-path Cargo.toml --workspace --exclude qsoripper-stress --exclude qsoripper-stress-tui --lcov --output-path rust-coverage.lcov`, with the workspace line coverage staying at or above the project's 80 percent threshold. +- `Push-Location src\rust; cargo deny check; Pop-Location` for the PRs that touch dependencies. +- `dotnet build src\dotnet\QsoRipper.slnx` to confirm no engine regressions when the engine spec or rigctld provider are touched. + +## 15. Open questions + +- Whether to ship a small CLI subcommand for daemonless one-shot CAT calls (`cathub send IF;`) for troubleshooting. Easy to add; out of scope for v1. +- Whether to expose a JSON over WebSocket endpoint for browser clients. Out of scope for v1; trivial to add later as another `ClientDialect`-shaped seam. +- Whether the per-endpoint AI virtualization should support `AI1;` (post-command echo) semantics distinctly from `AI2;` (spontaneous), or collapse both to a single push subscription. v1 may collapse them; loggers that depend on the `AI1;` distinction would need the finer model. +- Whether to add an audio routing component in a future iteration so the same daemon can multiplex radio audio for digital modes (WSJT-X, fldigi). Explicitly out of scope and likely belongs in a separate daemon if pursued. +- Whether to support ARHP-590-style remote heads (cathub over the network) once loopback operation is proven. Out of scope for v1. +- When to introduce the data-driven **descriptor backend** interpreter. The trigger is the second hand-written native backend (Icom), which clarifies what the descriptor language must express; OmniRig's `.ini` RigIni format and Hamlib's per-rig backends are the prior art to study (§7.1, §16). +- Whether an **in-process libhamlib (FFI) backend** is ever worth the native packaging cost, or whether the out-of-process `RigctldBackend` covers every realistic deployment. Current lean: keep FFI deferred behind a non-default feature unless a deployment genuinely cannot run a separate `rigctld`. +- How the daemon should **supervise `rigctld`** when using `RigctldBackend`: launch and own its lifecycle, or attach to an operator-managed instance. v1 leans toward attach-or-launch configurable, with clear surfacing of `rigctld` exit. + +## 16. References + +- Existing VFO B writeup: . +- Hamlib `rigctld` net protocol and command set (`F`/`M`/`T` sets, `\dump_state`, `\chk_vfo`): and . +- Kenwood TS-590 CAT command reference, including the `AI` auto-information command and the `IF` status response (Kenwood publishes the official PDF on the TS-590S and TS-590SG product pages). +- Kenwood ARCP-590 Radio Control Program and ARHP-590 Host Program (Kenwood publishes both on the TS-590S/SG support pages). +- WSJT-X user guide, rig control via Hamlib / Hamlib NET rigctl: . +- Icom CI-V reference (Icom publishes per-model CI-V command tables in each transceiver's PDF reference manual). +- com0com virtual serial port driver: . +- N1MM Logger+ rig configuration: . +- OmniRig RigIni descriptor format (declarative per-rig CAT command + status-parse `.ini` files), studied as prior art for the future descriptor backend: . +- OmniRig2 (MIT-licensed open-source fork), referenced for descriptor-format inspiration only - not its polling/VFO behavior: . +- Existing QsoRipper rig control consumer: `src/rust/qsoripper-core/src/rig_control/rigctld.rs`. +- QsoRipper engine specification, rig control sections: `docs/architecture/engine-specification.md` (§3.4 RigControlService, §5.3 Rig Control). diff --git a/docs/design/winkeyer-broker.md b/docs/design/winkeyer-broker.md new file mode 100644 index 0000000..a04c2fb --- /dev/null +++ b/docs/design/winkeyer-broker.md @@ -0,0 +1,56 @@ +# CatHub multi-client WinKeyer broker + +## Decision + +CatHub is the sole owner of the physical WinKeyer serial port. QsoRipper engines use a typed loopback gRPC API. Each unmodified legacy program receives a dedicated virtual WinKeyer serial endpoint. The keyer subsystem is independent of the radio CAT actor, but both use the station PTT ownership manager. + +```text +physical WinKeyer <-- 8-N-2 --> CatHub WinKeyer actor + |-- typed loopback API --> Rust/.NET engine + |-- virtual COM endpoint ----> N1MM + `-- virtual COM endpoint ----> maintenance tool +``` + +## Protocol and session model + +One incremental parser consumes every WinKeyer command from a virtual endpoint. It retains partial fixed and variable-length commands across reads, bounds the largest command to the 258-byte EEPROM load frame, and emits ordinary Morse data separately. Host Open, Host Close, firmware revision, status requests, and speed-pot requests are virtualized per client. A virtual close never closes the physical session during routine operation. + +Buffer-pointer commands are active-stream operations, not persistent client configuration. CatHub forwards each pointer command once under active-owner arbitration and never replays it before later text. CatHub also tracks the profile currently applied to the physical keyer and replays a profile only when client ownership changes. A stream beginning with Buffered Speed (`1C`) supplies its own job speed, so CatHub does not insert an unbuffered speed command. Inserting configuration or speed commands between N1MM's pointer sequence and its buffered-speed command corrupts keyboard CW and can key the WPM byte as a phantom character. + +For an authorized active client, CatHub preserves normal WinKeyer command and text bytes in their original order. N1MM's append-pointer command is `16 02 `; the position byte belongs to that command and must not be interpreted as an Admin prefix before the following buffered-speed command. Invalid or disruptive Admin commands remain subject to the normal fail-closed maintenance policy. + +Device bytes are classified by the WinKeyer tag bits as status, speed-pot, or echo events. Status and pot events are fanned out. Echo bytes are visible only to the active stream owner, or to the primary endpoint during physical paddle break-in. Maintenance response bytes are private to the maintenance owner and never enter typed event streams. + +Virtual sessions keep independent WK1/WK2/WK3 modes so one client's pushbutton/status choice does not alter another client's status format. + +## Scheduling and transient state + +Typed sends are atomic jobs. A virtual client session receives a raw stream lease when its first data reaches the scheduler; more data from that session appends to the same active stream. Jobs use one global arrival-order FIFO, which preserves each client's FIFO and prevents starvation by later submissions. No client bytes or per-client commands are interleaved inside another job. + +The scheduler stores each client's speed and transient register profile. Before a job it applies that profile and the requested speed. After the queue drains it restores the primary endpoint's profile and fixed or pot-controlled speed. Physical paddles retain the keyer's native priority and can break into machine-sent Morse. + +A speed-pot byte carries `actual WPM - MIN_WPM`. The broker tracks the active Speed Pot Setup minimum, retains the raw offset for protocol-compatible endpoints, and publishes actual WPM through the typed status/event contract. + +## Abort and safety rules + +- A queued cancel removes only jobs owned by that client. +- Clear Buffer is accepted only from the active stream owner or primary idle controller. +- An active-client disconnect clears the physical buffer, forces key-up, cancels that client's job, and records the safety action. +- The broker owns one maximum-transmit watchdog across all clients. +- CAT PTT and WinKeyer transmit jobs acquire the same station lease. A conflicting owner receives a failed-precondition response. +- USB/read/write failure cancels queued work, releases station PTT, marks status disconnected, and reopens the physical port with bounded backoff. The radio hub and client API stay alive. +- Graceful shutdown clears the buffer, forces key-up, sends physical Host Close, and releases station PTT. + +## Maintenance + +Reset, calibration, EEPROM dump/load, firmware update, high-baud switching, and other disruptive administrative commands require `config_write`. `config_write` itself requires `status` and `control`. A lease is granted only with no active or queued transmission. + +On acquisition, CatHub clears/dekeys and closes the physical host session before forwarding the administrative command, as required by the WinKeyer protocol. Other sends receive deterministic busy errors. Replies route only to the owner. On virtual Host Close or client loss, CatHub sends physical Host Open, waits for the firmware byte, reapplies safe initialization and the foreground transient profile, then resumes normal scheduling. + +Routine N1MM and QsoRipper operation never sends EEPROM writes. The normal N1MM endpoint should not receive `config_write`. + +## Configuration + +Unified configuration uses `[cat_hub.winkeyer]` and `[[cat_hub.winkeyer_endpoint]]`; standalone CatHub files omit the `cat_hub.` prefix. The API must bind to loopback. Physical and virtual transports must be distinct, only one endpoint may be primary, and dependent permission combinations are validated by CatHub and both setup engines. + +See [CW keying setup](../integrations/cw-keying.md) for the complete operator workflow. diff --git a/docs/integration/setup.md b/docs/integration/setup.md new file mode 100644 index 0000000..2ba1f02 --- /dev/null +++ b/docs/integration/setup.md @@ -0,0 +1,305 @@ +# cathub operator setup + +`cathub` is a single daemon that owns the radio's CAT serial port and fans it out +to every application over that application's native protocol. Because only the daemon talks +to the radio, the classic multi-app failures disappear: + +- no VFO A/B oscillation (baseline polling never emits VFO-select/retarget commands), +- no frequency drift (one serialized writer, ordered writes, native-push reconciliation), +- no PTT contention (single-owner PTT lease with a hard transmit-time ceiling), +- no auto-info stomping (each app's auto-information is virtualized per connection). + +This runbook brings up the hub for six applications sharing one Kenwood TS-590: +HDSDR (via OmniRig), QsoRipper TUI and GUI, ARCP-590, N1MM Logger+, WSJT-X, and Log4OM. + +See `docs/design/multi-client-cat-hub.md` for the architecture and behavior contracts. + +## 1. Retire the legacy chain + +The old stack was rigctld + a Python safe-bridge + rigctlcom. Stop all of it before starting +the hub. Only one process may own the radio's COM port (COM4 on this station - the +Silicon Labs CP210x USB-UART bridge that fronts the TS-590's USB CAT port). + + Get-Process rigctld, rigctlcom -ErrorAction SilentlyContinue | Stop-Process + # also stop any safe-bridge Python process and any app still bound directly to COM4 + +Remove or disable any legacy startup hooks that relaunch `rigctld.exe`; otherwise it can +reclaim the radio COM port after you stop CatHub. Check scheduled tasks first: + + Get-ScheduledTask -TaskName QsoRipper-rigctld -ErrorAction SilentlyContinue + Unregister-ScheduledTask -TaskName QsoRipper-rigctld -Confirm:$false + +Confirm nothing else holds COM4 before continuing. + +## 2. Create virtual serial pairs (com0com) + +Serial clients connect through a virtual null-modem pair. The daemon binds the first port of +each pair; the application binds the second. Using the com0com "setupc" tool, create: + + install PortName=COM10 PortName=COM11 # HDSDR / OmniRig (daemon COM10, app COM11) + install PortName=COM20 PortName=COM21 # N1MM Logger+ (daemon COM20, app COM21) + install PortName=COM30 PortName=COM31 # ARCP-590 (daemon COM30, app COM31) + install PortName=COM40 PortName=COM41 # N1MM WinKeyer (daemon COM40, app COM41) + install PortName=COM42 PortName=COM43 # WKTools (daemon COM42, app COM43) + +WSJT-X, Log4OM, and the QsoRipper engine use the Hamlib NET (TCP) endpoints instead and need +no serial pair. + +The WinKeyer pairs are separate from N1MM's radio-CAT pair. N1MM uses COM21 for its TS-590 radio and COM41 for WinKeyer. WKTools uses COM43 only for maintenance. CatHub owns physical WinKeyer COM3 and the hub sides COM40 and COM42. This permits N1MM and QsoRipper to remain connected to one keyer without attempting an unsafe shared open of COM3, while device maintenance receives a separately permissioned endpoint. + +Each pair has two COM numbers: a **daemon side** (the lower, even number COM10/20/30 that the +hub opens via `transport`) and an **application side** (the partner COM11/21/31). Point each +application at its application-side port. The daemon-side port is held open by the hub, so it +typically will **not** appear in an application's COM-port dropdown at all -- that is expected, +and the partner port (COM11/21/31) is the one to select. + +Record the application side as `application_transport` on each `[[cat_hub.serial_endpoint]]` and +`[[cat_hub.winkeyer_endpoint]]`. CatHub does not open this endpoint. It uses the value in setup +status and dry-run guidance so the application port remains discoverable after comments are +removed from the active configuration. + +## 3. Configure the daemon + +CatHub's authoritative settings live in its own `cathub.toml` file: + +- Windows: `%APPDATA%\cathub\cathub.toml` +- Linux/macOS: `$XDG_CONFIG_HOME/cathub/cathub.toml` (or `~/.config/cathub/cathub.toml`) +- Override the location with `CATHUB_CONFIG_PATH` or `--config`. + +The standalone file uses top-level `[radio]`, `[poll]`, `[ptt]`, `[events]`, +`[[serial_endpoint]]`, `[[hamlib_net]]`, `[winkeyer]`, and `[[winkeyer_endpoint]]` tables. +The repository ships a complete example at `config\cathub.toml`. Validate it without +touching hardware: + + cargo run -p cathub -- config validate --config config\cathub.toml + cargo run -p cathub -- config print-effective --config config\cathub.toml + +CatHub also accepts an existing QsoRipper unified file with settings nested under `[cat_hub]`. +Managed launchers should pass `--section cat_hub` to require that layout explicitly. +To move that section into CatHub's standalone file without changing the source: + + cargo run -p cathub -- config migrate ` + --from "$env:APPDATA\qsoripper\config.toml" ` + --output "$env:APPDATA\cathub\cathub.toml" + +Add `--remove-source-section` only when you are ready to stop using managed compatibility +mode. CatHub creates a `.bak` copy before changing the source file. + +To move in the other direction, copy the standalone tables beneath a new `[cat_hub]` +namespace in QsoRipper's unified file, validate with `cathub --section cat_hub config +validate --config `, and only then point the managed launcher at that file. Preserve +the standalone file until the managed launch has been verified. + +The `[radio].baud` value **must match the radio's own PC/CAT port speed** (TS-590 menu 62; +e.g. 57600). If they differ, the daemon opens COM4 but cannot talk to the radio. The +`[[serial_endpoint]].baud` values are nominal only -- com0com virtual pairs pass data regardless of baud, +so a client app can use any baud on its side of the pair. + +## 4. Start the hub + + .\scripts\Start-CatHub.ps1 + +The daemon opens COM4, enables and owns the TS-590 `AI2;` native push stream, starts the +baseline poller (which backs off to heartbeat once push covers a field), opens each serial +endpoint, and binds each Hamlib NET endpoint. Watch the log in another terminal: + + .\scripts\Get-CatHubLog.ps1 -Follow + +Stop the hub with Ctrl+C in its window, or: + + .\scripts\Stop-CatHub.ps1 + +Build and test CatHub independently with `.\build.ps1` and `.\test.ps1`. QsoRipper's launcher +can also manage an installed CatHub binary through `CATHUB_EXECUTABLE`, `PATH`, or its optional +bundled-artifact location. Managed mode may continue to pass QsoRipper's unified file while it +contains a valid `[cat_hub]` table. + +## 5. Point each application at the hub + +### HDSDR (via OmniRig) +- OmniRig: Rig type `Kenwood TS-2000`, port **COM11**, baud 115200, 8-N-1. +- The `hdsdr-omnirig` endpoint is `dialect = "ts2000"`, + `perms = ["read", "frequency_write"]`. The panadapter follows the radio and click-to-tune + on the waterfall sets frequency. OmniRig mode writes are denied, preventing its band-plan + mode selection from changing WSJT-X's USB+Data operation to CW after a frequency update. + VFO-target writes (`FR`/`FT`) remain rejected by design. + +### N1MM Logger+ +- Configurer > Hardware: radio `Kenwood`, port **COM21**, 115200, 8-N-1, no flow control. +- Configurer > Hardware: enable WinKeyer on **COM41**, 1200 baud. COM41 is the application + side of the dedicated COM40/COM41 keyer pair; never select physical COM3 or CatHub's COM40. +- Configure WKTools for **COM43**, 1200 baud. COM43 is the application side of the maintenance + COM42/COM43 pair. Close WKTools after maintenance so the port is released. +- The `n1mm` endpoint is `dialect = "ts590"`, `single_vfo = true`, `perms = ["read", "write", "ptt"]`. +- **`single_vfo = true` is required for SO1V.** N1MM in single-VFO (SO1V) mode refuses VFO B + ("You should not use VFO B when configured for SO1V") and freezes its frequency display when + the radio is on VFO B. With `single_vfo = true` the hub presents whichever VFO the radio is + on as VFO A, so N1MM tracks the radio across A/B switches with no warning. If you run N1MM in + SO2V instead, you may set `single_vfo = false`; for SO1V leave it on (the shipped default for + this endpoint). See design §8.4.2. +- Keep the everyday `n1mm-cw` WinKeyer endpoint limited to `status`, `send`, `control`, and `ptt`. + Persistent EEPROM/reset access belongs on a separate, normally disabled maintenance endpoint. +- CatHub preserves N1MM's authorized runtime stream byte-for-byte. In particular, the observed + `16 02 1C ` sequence remains an append-pointer command followed by a + buffered-speed command and the intended text. CatHub does not reinterpret the position byte as + an Admin prefix or inject configuration between those bytes. + +### ARCP-590 +- Set ARCP-590's COM port to **COM31**, 115200, 8-N-1. +- The `arcp590` endpoint is `dialect = "ts590"`, `perms = ["read", "write", "ptt", "config_write"]` + so the full control-panel (including EX-menu writes) works. + +### WSJT-X +- Settings > Radio: Rig `Hamlib NET rigctl`, Network Server **127.0.0.1:4533**. +- PTT method `CAT`. The `wsjtx` endpoint is `perms = ["read", "write", "ptt"]`, `single_vfo = true`. +- **`single_vfo = true` keeps WSJT-X decoding on either VFO.** WSJT-X fundamentally expects to + receive on VFO A and stops decoding if the hub reports the operator is on VFO B. With + `single_vfo = true` the endpoint always presents whichever VFO the radio is actually on as + VFO A (`get_vfo` -> `VFOA`, with the live operating frequency/mode), so WSJT-X decodes + whether the rig is on A or B. The frequency is always real; only the VFO *letter* is + virtualized. +- **Mode:** set the WSJT-X *Mode* selector to **Data/Pkt** (the default for FT8/WSPR). The + hub maps Hamlib's `PKTUSB`/`PKTLSB` to the TS-590's DATA sub-mode by composing the base + mode (`MD`) with the radio's independent DATA flag (`DA`): `PKTUSB` -> `MD2;`+`DA1;`, + `PKTLSB` -> `MD1;`+`DA1;`, and a plain `USB`/`LSB` clears it with `DA0;`. The mode + read-back recomposes the token, so WSJT-X sees `PKTUSB`/`PKTLSB` echoed back and the radio + shows its DATA indicator lit. *Mode = None* (operator selects DATA on the front panel) and + *Mode = USB* also round-trip cleanly if you prefer to manage the sub-mode yourself. +- **Split Operation:** use **`Fake It`** (not `Rig`). A `single_vfo` endpoint presents a + single operating VFO and cannot model a real A/B split, so it **rejects** rig split-enable + (`RPRT -11`); "Fake It" QSYs the single VFO at TX time and works correctly on either VFO. + If you genuinely need real `Rig` split, point WSJT-X at a non-virtualized endpoint instead. +- **PTT:** WSJT-X keys with Hamlib `RIG_PTT_ON_DATA` (`T 3`). The hub maps the Hamlib PTT + family faithfully to the TS-590 - `T 1` -> `TX;`, `T 2` (mic) -> `TX0;`, `T 3` (data) -> + `TX1;`, `T 0` -> `RX;`. `TX1;` keys with modulation from the DATA/USB audio path, which is + what digital modes want. +- Frequencies are sent by Hamlib as a `%f` value (e.g. `14074000.000000`); the hub + accepts both that decimal form and a plain integer, so `Test CAT` and band changes + set the dial correctly. + +### JS8Call + +- Settings > Radio: Rig `Hamlib NET rigctl`, Network Server **127.0.0.1:4535**. +- PTT method `CAT`, Split Operation **Fake It**. +- Give JS8Call its own writable/PTT endpoint. Do not point it at WSJT-X's port 4533. Both + applications set mode and PTT, and sharing a port allows one application's plain-USB mode + selection to clear the TS-590 DATA flag required by the other. + +### TS-590 PC-control beep (fixed in the hub) + +Earlier builds made the TS-590 emit a short Morse **"U"** (di-di-dah) tone during WSJT-X +operation, most noticeably when switching between modes that share a radio mode (for example +FT8 and WSPR, which are both DATA-USB). The TS-590 beeps on **every** mode (`MD`) command it +receives over CAT - frequency sets are silent. WSJT-X re-asserts its mode on every poll and on +each FT8/WSPR switch, and the hub forwarded each `MD` set to the radio even when the value was +unchanged, so the radio chirped. A native Hamlib driver never beeps because it caches state and +sends a mode command only when the mode actually changes. + +The hub now does the same: a modeled write (frequency, mode, DATA sub-mode, split, RIT/XIT) +is sent to the radio only when it would change the radio. Mode comparison uses the **value +written to the radio** (the `MD` digit), and the DATA flag (`DA`) is deduped independently, so +switching between two modes that share the same wire state - for example FT8 and WSPR, which +are both `PKTUSB` (`MD2`+`DA1`) - is recognized as a no-op and suppressed after the first set. +A genuine mode change (for example switching to CW, or toggling DATA on/off) still sends one +command and the radio beeps once, which matches native Hamlib behavior. PTT is never +suppressed - keying and unkeying always reach the radio. + +No radio-menu change is needed. Leave **Beep Volume** at your normal setting. + +### Log4OM +- CAT interface: Hamlib `NET rigctl`, host **127.0.0.1**, port **4534**. +- The `log4om` endpoint is `perms = ["read", "write"]`, `single_vfo = true`. +- Log4OM-NG uses Hamlib's **Extended Response Protocol** (ERP): it opens every session with + `;V ?` (list supported VFOs) and then polls with `+\get_vfo_info VFOA` (~2 Hz). The hub's + `hamlib_net` endpoint parses the ERP separator prefix (`+ ; | ,`) and answers both shapes in the + exact byte format real `rigctld` produces, so Log4OM connects and stays **online**. Plain + clients (WSJT-X, N1MM, the engine) are unaffected - they never send the ERP prefix. +- **`single_vfo = true` makes Log4OM log the live frequency on either VFO.** Because Log4OM + polls the fixed VFO `VFOA`, without virtualization it would read the *inactive* VFO A's + stale frequency whenever the operator works on VFO B. With `single_vfo = true` the endpoint + resolves the `VFOA` poll to whichever VFO is actually operating, so Log4OM tracks the real + dial on both VFOs (and `;V ?` advertises only `VFOA`). + +### QsoRipper engine (TUI and GUI) +- The engine's `RigctldProvider` points at the read-only endpoint **127.0.0.1:4532** + (`single_vfo = false`, so the engine sees and logs the true operating VFO letter). +- The TUI and GUI both consume the engine over gRPC; neither talks to the radio directly, so + both get a consistent view fed by the same hub. TCP allows the engine and any other NET + client to share an endpoint simultaneously. +- The read-only endpoint exposes split TX frequency/mode and configured transmitter power when + the radio backend supplies them. During split operation the engine logs the transmit side as + `FREQ`/`BAND`, the receive side as `FREQ_RX`/`BAND_RX`, and configured power as `TX_PWR`. + Unsupported optional values remain blank and do not make rig control appear disconnected. +- Keep `[rig_control].stale_threshold_ms` low (e.g. **100**) when reading through cathub. The hub + serves reads from its in-memory state cache (kept current by the radio's native AI2 push), so a + short freshness window is cheap and makes the GUI/TUI frequency display follow knob turns almost + immediately. A large value such as 5000 makes the engine reuse a stale snapshot for that many + milliseconds and the UI lags behind the radio by up to that interval. + +## 6. Verify (bench) + +With the hub running and all six apps connected: + +1. Turn the physical VFO knob. Every app's displayed frequency tracks it within a poll/push + cycle, and the cathub log shows `NativePush` (not `PollDiff`) events once `AI2;` is active. +2. Change band in N1MM. HDSDR's waterfall recenters without HDSDR ever talking to N1MM, and + the TS-590 never bounces between VFO A and B. +3. Set frequency from WSJT-X. N1MM, Log4OM, ARCP-590, and the engine all converge. +4. Key WSJT-X (Tune). The PTT lease is granted; while held, an attempt to key from N1MM is + rejected (Hamlib `RPRT` busy) and logged. Releasing WSJT-X frees the lease. +5. Leave one over running longer than expected: confirm a transmit never exceeds + `ptt_max_tx_ms` (the safety ceiling force-releases and logs loudly). + +Live transmit verification requires the operator and real hardware; do not key the +transmitter from automation. Watch `Get-CatHubLog.ps1 -Follow` throughout. + +## 7. Troubleshooting + +- "Access denied" / port busy on COM4: the legacy chain or another app still owns the radio + port (step 1). +- Daemon starts but never reads valid data from the radio (timeouts / stale): the `[radio].baud` + does not match the radio's PC/CAT port speed. Check TS-590 menu 62 and set `[radio].baud` to + the same value (e.g. 57600). A client app proves the rig's real baud quickly via a direct + connection. +- Daemon starts, the port opens, but every poll times out at the *correct* baud: some radios + (notably the Kenwood TS-590) only reply when the **RTS modem-control line is asserted**. The + hub now asserts RTS and DTR automatically when it opens a serial radio port, so this should + not occur with current builds. If you see it on an older build, update the daemon. +- An app's COM dropdown does not list the daemon-side port (COM10/20/30): expected. The hub + holds that port open, so applications only see the partner port. Select COM11/21/31 instead. +- An app sees no data on its serial port: the com0com pair is reversed or the app is on the + daemon's half of the pair. The app binds the **second** port of each pair (COM11/21/31). +- N1MM cannot open the WinKeyer: select COM41, not COM3 or COM40, and confirm the hub log says + the `n1mm-cw` endpoint opened COM40 at 1200 baud, 8-N-2. +- A physical WinKeyer USB disconnect cancels queued keying, releases station PTT, and starts + capped reconnect attempts without stopping the radio hub. N1MM may need to reopen its + logical keyer session after the physical device reconnects. +- A NET client cannot connect: confirm the bind address/port in `config\cathub.toml` matches + the app, and that the hub log shows the endpoint listening. +- An app that relies on Kenwood auto-information (notably **ARCP-590**) connects but never + tracks the dial / shows "BUSY": such apps poll `AI;` as a keepalive and depend entirely on + the radio pushing `FA;`/`IF;` frames. The hub virtualizes auto-info per connection - an `AI;` + read reports the endpoint's current state (`AI0;`/`AI2;`) without disabling it, and the hub fans + out native-push frames to any endpoint that has enabled `AI2;`. This works in current builds; if + an older build froze ARCP-590 on connect, update the daemon. +- Set `CATHUB_LOG=debug` before starting for verbose tracing. Use + `CATHUB_LOG=cathub::serial_endpoint=trace` to see each endpoint's request/reply/notify + frames, which is the fastest way to diagnose a client handshake. + +## 8. Known v1 limitations + +- **Automatic radio reconnect.** If the radio transport drops mid-session (USB unplugged, + radio powered off, cable hiccup, or a write error), the daemon now reopens the serial/TCP + link automatically with capped exponential backoff (0.5 s up to 5 s) and resumes serving the + same client command queue - you no longer need to restart the hub. On each reconnect it also + re-arms the radio's native push (auto-info) state, which a power-cycled radio forgets (design + §8.4/§8.7). Client endpoints and NET endpoints stay up throughout. Note: clients that hold their + own CAT session above the hub (e.g. HDSDR via OmniRig) may still need their own + OmniRig/session restart if they latched onto the dead link before the hub recovered. +- **Hamlib NET bind errors surface in the log, not at startup.** A serial endpoint that fails to + open aborts startup with a clear error, but a `[[hamlib_net]]` endpoint whose bind address + is already in use logs the error from its listener task rather than failing the whole + daemon. If a NET client cannot connect, check the log for that endpoint. +- On shutdown (Ctrl+C) the daemon makes a best-effort `RX;` to unkey the transmitter. A hard + crash cannot run that path; the `ptt_max_tx_ms` ceiling and the radio's own TX timeout are + the ultimate stuck-transmitter backstops. diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..625651c --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,4 @@ +edition = "2021" +max_width = 100 +use_field_init_shorthand = true +use_try_shorthand = true diff --git a/scripts/Get-CatHubLog.ps1 b/scripts/Get-CatHubLog.ps1 new file mode 100644 index 0000000..1508d7b --- /dev/null +++ b/scripts/Get-CatHubLog.ps1 @@ -0,0 +1,39 @@ +<# +.SYNOPSIS + Show the latest standalone CatHub rolling log. +#> +[CmdletBinding()] +param( + [int]$Tail = 80, + [switch]$Follow +) + +$ErrorActionPreference = 'Stop' +if ($env:CATHUB_LOG_DIR) { + $logDir = $env:CATHUB_LOG_DIR +} +elseif ($IsWindows -or $env:OS -eq 'Windows_NT') { + $logDir = Join-Path $env:LOCALAPPDATA 'cathub\logs' +} +elseif ($env:XDG_STATE_HOME) { + $logDir = Join-Path $env:XDG_STATE_HOME 'cathub' +} +else { + $logDir = Join-Path $env:HOME '.local/state/cathub' +} + +$latest = Get-ChildItem -LiteralPath $logDir -Filter 'cathub.log*' -File -ErrorAction SilentlyContinue | + Sort-Object LastWriteTime -Descending | + Select-Object -First 1 + +if (-not $latest) { + Write-Host "No CatHub logs found in $logDir." + return +} + +if ($Follow) { + Get-Content -LiteralPath $latest.FullName -Tail $Tail -Wait +} +else { + Get-Content -LiteralPath $latest.FullName -Tail $Tail +} diff --git a/scripts/Start-CatHub.ps1 b/scripts/Start-CatHub.ps1 new file mode 100644 index 0000000..5dedcf7 --- /dev/null +++ b/scripts/Start-CatHub.ps1 @@ -0,0 +1,49 @@ +<# +.SYNOPSIS + Build and start the standalone CatHub daemon. +#> +[CmdletBinding()] +param( + [string]$Config, + [switch]$DryRun, + [switch]$DebugBuild +) + +$ErrorActionPreference = 'Stop' +$repoRoot = Split-Path -Parent $PSScriptRoot + +if (-not $Config) { + if ($env:CATHUB_CONFIG_PATH) { + $Config = $env:CATHUB_CONFIG_PATH + } + elseif ($IsWindows -or $env:OS -eq 'Windows_NT') { + $Config = Join-Path $env:APPDATA 'cathub\cathub.toml' + } + elseif ($env:XDG_CONFIG_HOME) { + $Config = Join-Path $env:XDG_CONFIG_HOME 'cathub/cathub.toml' + } + else { + $Config = Join-Path $env:HOME '.config/cathub/cathub.toml' + } +} + +if (-not (Test-Path -LiteralPath $Config)) { + $sample = Join-Path $repoRoot 'config\cathub.toml' + Write-Warning "Config not found at $Config. Using the repository sample." + $Config = $sample +} + +$profile = if ($DebugBuild) { 'debug' } else { 'release' } +$binaryName = if ($IsWindows -or $env:OS -eq 'Windows_NT') { 'cathub.exe' } else { 'cathub' } +$binary = Join-Path $repoRoot "target\$profile\$binaryName" + +if (-not (Test-Path -LiteralPath $binary)) { + $buildArgs = @('build', '--workspace') + if (-not $DebugBuild) { $buildArgs += '--release' } + & cargo @buildArgs --manifest-path (Join-Path $repoRoot 'Cargo.toml') + if ($LASTEXITCODE -ne 0) { throw "CatHub build failed with exit code $LASTEXITCODE" } +} + +$runArgs = @('--config', $Config) +if ($DryRun) { $runArgs += '--dry-run' } +& $binary @runArgs diff --git a/scripts/Stop-CatHub.ps1 b/scripts/Stop-CatHub.ps1 new file mode 100644 index 0000000..0ca875c --- /dev/null +++ b/scripts/Stop-CatHub.ps1 @@ -0,0 +1,19 @@ +<# +.SYNOPSIS + Stop standalone CatHub processes after confirmation. +#> +[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')] +param() + +$ErrorActionPreference = 'Stop' +$processes = Get-Process -Name 'cathub' -ErrorAction SilentlyContinue +if (-not $processes) { + Write-Host 'CatHub is not running.' + return +} + +foreach ($process in $processes) { + if ($PSCmdlet.ShouldProcess("CatHub PID $($process.Id)", 'Stop process')) { + Stop-Process -Id $process.Id + } +} diff --git a/src/dotnet/CatHub.Protocol/CatHub.Protocol.csproj b/src/dotnet/CatHub.Protocol/CatHub.Protocol.csproj new file mode 100644 index 0000000..fea34a6 --- /dev/null +++ b/src/dotnet/CatHub.Protocol/CatHub.Protocol.csproj @@ -0,0 +1,27 @@ + + + net8.0 + enable + enable + CatHub.Protocol + 0.1.0 + Versioned gRPC client contracts for CatHub. + README.md + MIT + https://github.com/treitforge/cathub + false + + + + + + + + + + + + + diff --git a/test.ps1 b/test.ps1 new file mode 100644 index 0000000..fa64e42 --- /dev/null +++ b/test.ps1 @@ -0,0 +1,5 @@ +[CmdletBinding()] +param() + +& (Join-Path $PSScriptRoot 'build.ps1') test +exit $LASTEXITCODE