From c51cef67a9a5e0236455b7d70baf4bf487a0c2b8 Mon Sep 17 00:00:00 2001 From: Frank0x01 Date: Sun, 12 Jul 2026 14:15:34 +0200 Subject: [PATCH 1/4] docs(cat-tx-ptt): fix stale FR number in tasks.md Task 1.1 reserved FR-052, but gridtracker-udp-reporting (merged same day) already claimed FR-052-FR-055 in REQUIREMENTS.md. Next free number is FR-056 as of QA's pre-implementation audit. Co-Authored-By: Claude Sonnet 5 --- openspec/changes/cat-tx-ptt/tasks.md | 118 +++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 openspec/changes/cat-tx-ptt/tasks.md diff --git a/openspec/changes/cat-tx-ptt/tasks.md b/openspec/changes/cat-tx-ptt/tasks.md new file mode 100644 index 0000000..2dbe06b --- /dev/null +++ b/openspec/changes/cat-tx-ptt/tasks.md @@ -0,0 +1,118 @@ +## 1. Requirements & Documentation + +- [ ] 1.1 Add **FR-056** (PTT-over-CAT and serial RTS/DTR keying) to `REQUIREMENTS.md`, amending the FR-045/`IRadioConnection` "no PTT" note; add the corresponding version-history row (FR-052–FR-055 were claimed by `gridtracker-udp-reporting`, merged 2026-07-12 — FR-056 is the next free number as of this correction) +- [ ] 1.2 Update `IRadioConnection`'s XML doc comment (currently states "No mode-set, PTT, or other rig-altering commands are defined here") to reflect the amendment + +## 2. Abstractions + +- [ ] 2.1 Add `Task SetPttAsync(bool transmitting, CancellationToken cancellationToken = default)` to `IRadioConnection` in `OpenWSFZ.Abstractions` +- [ ] 2.2 Add `ICatPttGate` interface to `OpenWSFZ.Abstractions` with a single member `Task SetPttAsync(bool transmitting, CancellationToken cancellationToken = default)` +- [ ] 2.3 Add `PttConfig` record to `OpenWSFZ.Abstractions` (`Method`, `SerialPort`, `SerialLine`, `LeadTimeMs`, `TailTimeMs`, `WatchdogTimeoutMs`, defaults per design.md Decision 6) +- [ ] 2.4 Add `Ptt` property of type `PttConfig` (defaulting to a disabled/VOX instance) to `AppConfig`, as a sibling of `Cat`, not nested inside it +- [ ] 2.5 Verify round-trip: existing config files without a `ptt` key deserialise without error and `Ptt.Method` is `"AudioVox"` + +## 3. Serial Port RTS/DTR Support + +- [ ] 3.1 Add `bool RtsEnable { get; set; }` and `bool DtrEnable { get; set; }` to `ISerialPort` in `OpenWSFZ.Rig.Internal` +- [ ] 3.2 Implement both properties as pass-throughs in `SerialPortWrapper` (mirroring `System.IO.Ports.SerialPort.RtsEnable`/`DtrEnable`) +- [ ] 3.3 Add a fake `ISerialPort` test double exposing settable/observable `RtsEnable`/`DtrEnable` state (extending or alongside whatever fake already backs the existing `SerialCatConnection` unit tests) + +## 4. SerialCatConnection — PTT commands + +- [ ] 4.1 Implement `SetPttAsync(true)` — writes `TX;\r` to the serial port, no read-back +- [ ] 4.2 Implement `SetPttAsync(false)` — writes `RX;\r` to the serial port, no read-back +- [ ] 4.3 Update the class's XML doc comment to document the new PTT commands alongside the existing frequency commands + +## 5. RigctldConnection — PTT commands + +- [ ] 5.1 Implement `SetPttAsync(true)` — sends `\set_ptt 1\n`, reads and validates the `RPRT 0` acknowledgement, throws `InvalidOperationException` (with raw response) on any other reply +- [ ] 5.2 Implement `SetPttAsync(false)` — sends `\set_ptt 0\n`, same acknowledgement handling +- [ ] 5.3 Update the class's XML doc comment to document the new PTT commands + +## 6. CatPollingService — wire serialization + +- [ ] 6.1 Add a private `SemaphoreSlim(1,1)` gate to `CatPollingService` guarding every call the poll loop makes to the shared `IRadioConnection` +- [ ] 6.2 Implement `CatPollingService`'s `ICatPttGate.SetPttAsync` by acquiring the same gate before calling `IRadioConnection.SetPttAsync` +- [ ] 6.3 `ICatPttGate.SetPttAsync` throws `InvalidOperationException` when `AppConfig.Cat.Enabled` is `false` or no connection has ever been established +- [ ] 6.4 Register `CatPollingService` as `ICatPttGate` in `Program.cs` DI wiring (alongside its existing `ICatTuner`/`ICatController` registrations) +- [ ] 6.5 Audit `OpenWSFZ.Daemon` and `OpenWSFZ.Rig` for any other holder of the shared `IRadioConnection` reference and remove/refactor it so `CatPollingService` remains the sole holder + +## 7. Shared WASAPI playback extraction + +- [ ] 7.1 Extract the WASAPI device-open/play/stop/dispose logic from `AudioOnlyPttController` into an internal `WasapiTxPlayer` helper (`WASAPI_SUPPORTED`-gated) exposing an async play-to-completion method and a stop method +- [ ] 7.2 Refactor `AudioOnlyPttController` to use `WasapiTxPlayer` internally +- [ ] 7.3 Run the full existing `AudioOnlyPttController` unit test suite unmodified against the refactored implementation — zero assertion changes permitted; any failure blocks proceeding to section 8 + +## 8. CatPttController + +- [ ] 8.1 Implement `CatPttController : IPttController` in `OpenWSFZ.Daemon`, constructed with `ICatPttGate`, `IConfigStore`, and a logger +- [ ] 8.2 Implement `LoadAudio` (same contract as `AudioOnlyPttController`) +- [ ] 8.3 Implement `KeyDownAsync`: `ICatPttGate.SetPttAsync(true)` → wait `LeadTimeMs` → play via `WasapiTxPlayer` → await completion +- [ ] 8.4 Implement `KeyUpAsync`: stop playback → wait `TailTimeMs` → `ICatPttGate.SetPttAsync(false)` +- [ ] 8.5 Wrap the key-down/play/key-up sequence in a watchdog per section 10, and in try/finally so any exception still releases PTT +- [ ] 8.6 Implement `DisposeAsync` — force PTT release if asserted, release any audio device handle + +## 9. SerialRtsDtrPttController + +- [ ] 9.1 Implement `SerialRtsDtrPttController : IPttController` in `OpenWSFZ.Daemon`, opening its own `ISerialPort` from `AppConfig.Ptt.SerialPort`, independent of any `CatPollingService`/`ICatPttGate` instance +- [ ] 9.2 Implement `LoadAudio` (same contract) +- [ ] 9.3 Implement `KeyDownAsync`: assert the configured line (`RtsEnable`/`DtrEnable` per `AppConfig.Ptt.SerialLine`) → wait `LeadTimeMs` → play via `WasapiTxPlayer` → await completion +- [ ] 9.4 Implement `KeyUpAsync`: stop playback → wait `TailTimeMs` → de-assert the configured line +- [ ] 9.5 Port-open failure in `KeyDownAsync` throws rather than silently skipping PTT assertion +- [ ] 9.6 Wrap the sequence in a watchdog per section 10, and in try/finally so any exception still de-asserts the line +- [ ] 9.7 Implement `DisposeAsync` — force line de-assertion if asserted, close and dispose the serial port + +## 10. Failsafe watchdog + +- [ ] 10.1 Implement a small shared watchdog helper (timer + forced-release callback) usable by both `CatPttController` and `SerialRtsDtrPttController`, parameterised by `WatchdogTimeoutMs` +- [ ] 10.2 Watchdog logs at Error (including elapsed hold duration) and forces release, bypassing `TailTimeMs`, when it fires +- [ ] 10.3 Watchdog is cancelled the instant `KeyUpAsync` begins its release step + +## 11. DI Wiring + +- [ ] 11.1 In `Program.cs`, replace the current `#if WASAPI_SUPPORTED` / `#else` two-way `IPttController` registration with a three-way switch on `configStore.Current.Ptt.Method` (falling back to `AudioOnlyPttController`/`NullPttController` per existing platform gating when the method is unrecognised or when `WASAPI_SUPPORTED` is undefined) +- [ ] 11.2 Register `CatPollingService` as `ICatPttGate` (see 6.4) +- [ ] 11.3 Verify `QsoAnswererService`/`QsoCallerService` construction is unaffected (they already resolve `IPttController` by interface, not concrete type) + +## 12. Tests + +- [ ] 12.1 `SerialCatConnection` PTT unit tests: `SetPttAsync(true)` writes `TX;\r`, `SetPttAsync(false)` writes `RX;\r` — prefix `CatTx-Ptt:` +- [ ] 12.2 `RigctldConnection` PTT unit tests: `SetPttAsync(true/false)` sends the right command and validates the RPRT ack, non-`RPRT 0` ack throws — prefix `CatTx-Ptt:` +- [ ] 12.3 `CatPollingService` gate unit tests (mock `IRadioConnection`): concurrent poll + PTT calls never overlap on the mock (assert via a re-entrancy guard in the mock), `ICatPttGate.SetPttAsync` throws when CAT disabled — prefix `CatTx-Ptt:` +- [ ] 12.4 `PttConfig`/`AppConfig` schema tests: missing `ptt` key defaults correctly, unknown `Method`/`SerialLine` fall back with a logged Warning — prefix `CatTx-Ptt:` +- [ ] 12.5 `ISerialPort`/`SerialPortWrapper` RTS/DTR unit tests using the fake serial port — prefix `CatTx-Ptt:` +- [ ] 12.6 `CatPttController` unit tests (mock `ICatPttGate` + injected playback override, same seam pattern as `AudioOnlyPttController`'s test constructor): key order (PTT before audio, audio-stop before PTT release), lead/tail timing honoured, PTT released on playback exception, `DisposeAsync` releases if asserted — prefix `CatTx-Ptt:` +- [ ] 12.7 `SerialRtsDtrPttController` unit tests (fake `ISerialPort` + injected playback override): same key-order/timing/exception/dispose coverage as 12.6, plus Rts-vs-Dtr line selection and port-open-failure-throws — prefix `CatTx-Ptt:` +- [ ] 12.8 Watchdog unit tests: forces release and logs Error when `KeyUpAsync` never arrives; does not fire when release happens in time; fires even when playback itself hangs — prefix `CatTx-Ptt:` +- [ ] 12.9 DI wiring test: each `Ptt.Method` value resolves the expected concrete `IPttController` — prefix `CatTx-Ptt:` + +## 13. Documentation + +- [ ] 13.1 Author `hardware-acceptance.md` in this change directory, modelled on `openspec/changes/archive/2026-06-03-p16-cat-control/hardware-acceptance.md`, covering CAT-command PTT, serial RTS/DTR PTT, the failsafe watchdog, and a confirmed two-way QSO (see section 14) +- [ ] 13.2 Update `docs/cat-control-operator-guide.md` with the new `ptt` config block and a short explanation of when to choose each method + +## 14. Acceptance Gate — CAT-command PTT (manual, hardware required) + +- [ ] 14.1 Set `ptt.method = "CatCommand"`; confirm the rig keys (TX LED / RF output) within `leadTimeMs` of a transmission starting and unkeys within `tailTimeMs` of it ending +- [ ] 14.2 Confirm the CAT status badge and frequency polling continue to update correctly during and immediately after a TX cycle (proves Decision 1's serialization works under real load, not just in mocks) +- [ ] 14.3 Force a watchdog trip (e.g. inject a stalled playback build or a very small `watchdogTimeoutMs`) and confirm the rig unkeys automatically and an Error is logged +- [ ] 14.4 Confirm no unexpected mode-set, frequency-set, or other rig-altering command appears in the log or on the rig display during the session + +## 15. Acceptance Gate — Serial RTS/DTR PTT (manual, hardware required) + +- [ ] 15.1 Set `ptt.method = "SerialRtsDtr"` with `ptt.serialPort` on a different port than any CAT connection in use; confirm the rig keys/unkeys correctly via the RTS line +- [ ] 15.2 Repeat 15.1 with `ptt.serialLine = "Dtr"` on hardware wired for DTR PTT (or confirm the line-selection logic behaves correctly if only one line is available to test) +- [ ] 15.3 Confirm PTT keys/unkeys correctly with `cat.enabled = false` (proves independence from any CAT connection) +- [ ] 15.4 Force a watchdog trip and confirm the line de-asserts automatically + +## 16. Acceptance Gate — Confirmed two-way QSO (release gate R3) + +- [ ] 16.1 With either PTT method configured and `QsoAnswererService` or `QsoCallerService` active, complete one full, genuine over-the-air FT8 QSO with another station +- [ ] 16.2 Confirm the completed QSO is written correctly to `ADIF.log` +- [ ] 16.3 Document the confirmed QSO (date, band, partner call — Q-prefix or otherwise per NFR-021) in `hardware-acceptance.md` as the R3 evidence artefact + +## 17. Housekeeping + +- [ ] 17.1 Commit all changes with `feat(cat-tx-ptt): key the transmitter via CAT command or serial RTS/DTR` +- [ ] 17.2 Push and confirm CI green (all quality gates, including G9 version governance — this is user-facing, VERSION bump required) +- [ ] 17.3 Open PR to `main`; request QA gate review From 7786597160428e66b8712f02db6fe15dd7240ddd Mon Sep 17 00:00:00 2001 From: Frank0x01 Date: Sun, 12 Jul 2026 20:11:12 +0200 Subject: [PATCH 2/4] feat(cat-tx-ptt): key the transmitter via CAT command or serial RTS/DTR Adds two new PTT methods (ptt.method = CatCommand | SerialRtsDtr) alongside the existing audio-only VOX keying, plus a shared failsafe watchdog and a Settings-page PTT configuration UI (FR-056/FR-057). Includes a critical caller-side fix found during hardware acceptance: QsoCallerService/QsoAnswererService never called KeyUpAsync after a normal transmission, so every real TX cycle relied on the 20s watchdog to release PTT instead of releasing promptly after tailTimeMs, breaking FT8 slot timing on every transmission (dev-tasks/2026-07-12-cat-tx-ptt-missing-keyup-after-transmit.md). Verified against real hardware: two complete, ADIF-logged QSOs with zero watchdog-forced releases (openspec/changes/cat-tx-ptt/hardware-acceptance.md). Gate 16 (R3, confirmed two-way QSO) is satisfied. Gate 14 (CAT-command PTT) and Gates 15.2-15.4 (DTR line, CAT-disabled independence, forced watchdog trip) remain outstanding manual hardware gates - tracked in tasks.md section 18.6, not blocking this merge but blocking archival of the change. Co-Authored-By: Claude Sonnet 5 --- REQUIREMENTS.md | 6 +- ...cat-tx-ptt-missing-keyup-after-transmit.md | 222 +++++++++++ ...-07-12-cat-tx-ptt-null-ptt-config-guard.md | 244 ++++++++++++ .../2026-07-12-cat-tx-ptt-settings-ui.md | 137 +++++++ ...026-07-12-cat-tx-ptt-settings-ui-after.png | Bin 0 -> 71633 bytes ...26-07-12-cat-tx-ptt-settings-ui-before.png | Bin 0 -> 48028 bytes docs/cat-control-operator-guide.md | 118 +++++- openspec/changes/cat-tx-ptt/.openspec.yaml | 2 + openspec/changes/cat-tx-ptt/design.md | 161 ++++++++ .../changes/cat-tx-ptt/hardware-acceptance.md | 341 +++++++++++++++++ openspec/changes/cat-tx-ptt/proposal.md | 37 ++ .../cat-tx-ptt/specs/cat-control/spec.md | 199 ++++++++++ .../changes/cat-tx-ptt/specs/ft8-tx/spec.md | 140 +++++++ openspec/changes/cat-tx-ptt/tasks.md | 257 +++++++++---- src/OpenWSFZ.Abstractions/AppConfig.cs | 9 + src/OpenWSFZ.Abstractions/ICatPttGate.cs | 29 ++ src/OpenWSFZ.Abstractions/IPttController.cs | 32 +- src/OpenWSFZ.Abstractions/IRadioConnection.cs | 27 +- src/OpenWSFZ.Abstractions/PttConfig.cs | 62 +++ src/OpenWSFZ.Config/ConfigJsonContext.cs | 1 + src/OpenWSFZ.Config/JsonConfigStore.cs | 26 ++ src/OpenWSFZ.Daemon/AudioOnlyPttController.cs | 165 +------- src/OpenWSFZ.Daemon/Cat/CatPollingService.cs | 58 ++- src/OpenWSFZ.Daemon/CatPttController.cs | 259 +++++++++++++ src/OpenWSFZ.Daemon/Program.cs | 30 +- src/OpenWSFZ.Daemon/PttControllerSelector.cs | 45 +++ src/OpenWSFZ.Daemon/PttWatchdog.cs | 118 ++++++ src/OpenWSFZ.Daemon/QsoAnswererService.cs | 18 + src/OpenWSFZ.Daemon/QsoCallerService.cs | 18 + .../SerialRtsDtrPttController.cs | 345 +++++++++++++++++ src/OpenWSFZ.Daemon/WasapiTxPlayer.cs | 207 ++++++++++ src/OpenWSFZ.Rig/Internal/ISerialPort.cs | 14 + .../Internal/SerialPortWrapper.cs | 12 + src/OpenWSFZ.Rig/OpenWSFZ.Rig.csproj | 9 + src/OpenWSFZ.Rig/RigctldConnection.cs | 26 +- src/OpenWSFZ.Rig/SerialCatConnection.cs | 25 ++ src/OpenWSFZ.Web/AppJsonContext.cs | 17 + src/OpenWSFZ.Web/WebApp.cs | 71 ++++ tests/OpenWSFZ.Config.Tests/PttConfigTests.cs | 157 ++++++++ .../CatPollingServiceTests.cs | 118 ++++++ .../CatPttControllerTests.cs | 294 +++++++++++++++ tests/OpenWSFZ.Daemon.Tests/FakeSerialPort.cs | 52 +++ .../FakeSerialPortTests.cs | 104 +++++ .../OpenWSFZ.Daemon.Tests.csproj | 14 + .../PttControllerSelectorTests.cs | 99 +++++ .../OpenWSFZ.Daemon.Tests/PttWatchdogTests.cs | 134 +++++++ .../QsoAnswererServiceTests.cs | 61 +++ .../QsoCallerServiceTests.cs | 140 ++++++- .../SerialRtsDtrPttControllerTests.cs | 354 ++++++++++++++++++ .../RigctldConnectionTests.cs | 40 ++ .../SerialCatConnectionTests.cs | 35 ++ .../ConfigApiNullGuardTests.cs | 121 ++++++ .../PttTestEndpointTests.cs | 206 ++++++++++ web/css/app.css | 38 ++ web/js/api.js | 40 ++ web/js/settings.js | 173 ++++++++- web/settings.html | 85 +++++ 57 files changed, 5506 insertions(+), 246 deletions(-) create mode 100644 dev-tasks/2026-07-12-cat-tx-ptt-missing-keyup-after-transmit.md create mode 100644 dev-tasks/2026-07-12-cat-tx-ptt-null-ptt-config-guard.md create mode 100644 dev-tasks/2026-07-12-cat-tx-ptt-settings-ui.md create mode 100644 dev-tasks/screenshots/2026-07-12-cat-tx-ptt-settings-ui-after.png create mode 100644 dev-tasks/screenshots/2026-07-12-cat-tx-ptt-settings-ui-before.png create mode 100644 openspec/changes/cat-tx-ptt/.openspec.yaml create mode 100644 openspec/changes/cat-tx-ptt/design.md create mode 100644 openspec/changes/cat-tx-ptt/hardware-acceptance.md create mode 100644 openspec/changes/cat-tx-ptt/proposal.md create mode 100644 openspec/changes/cat-tx-ptt/specs/cat-control/spec.md create mode 100644 openspec/changes/cat-tx-ptt/specs/ft8-tx/spec.md create mode 100644 src/OpenWSFZ.Abstractions/ICatPttGate.cs create mode 100644 src/OpenWSFZ.Abstractions/PttConfig.cs create mode 100644 src/OpenWSFZ.Daemon/CatPttController.cs create mode 100644 src/OpenWSFZ.Daemon/PttControllerSelector.cs create mode 100644 src/OpenWSFZ.Daemon/PttWatchdog.cs create mode 100644 src/OpenWSFZ.Daemon/SerialRtsDtrPttController.cs create mode 100644 src/OpenWSFZ.Daemon/WasapiTxPlayer.cs create mode 100644 tests/OpenWSFZ.Config.Tests/PttConfigTests.cs create mode 100644 tests/OpenWSFZ.Daemon.Tests/CatPttControllerTests.cs create mode 100644 tests/OpenWSFZ.Daemon.Tests/FakeSerialPort.cs create mode 100644 tests/OpenWSFZ.Daemon.Tests/FakeSerialPortTests.cs create mode 100644 tests/OpenWSFZ.Daemon.Tests/PttControllerSelectorTests.cs create mode 100644 tests/OpenWSFZ.Daemon.Tests/PttWatchdogTests.cs create mode 100644 tests/OpenWSFZ.Daemon.Tests/SerialRtsDtrPttControllerTests.cs create mode 100644 tests/OpenWSFZ.Web.Tests/PttTestEndpointTests.cs diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 07cd980..0e27545 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -1,6 +1,6 @@ # OpenWSFZ — Requirements Document -**Version:** 1.31 +**Version:** 1.33 **Date:** 2026-07-12 **Status:** Draft **Prepared by:** Requirements Analyst (AI-assisted) @@ -196,6 +196,8 @@ Only one persona is in scope for v0.x. | FR-053 | GridTracker2 / WSJT-X UDP outbound broadcaster | A new `ExternalReportingService` (`IHostedService`, registered unconditionally, inert until `externalReporting.enabled` is `true` with at least one enabled target) SHALL implement the WSJT-X UDP network protocol's outbound message subset: **Heartbeat** (fixed interval), **Status** (dial frequency, mode, TX/RX state, DX call/grid, audio offsets — on-change plus at least once per heartbeat interval), **Decode** (one per decoded FT8 message, mirroring `ALL.TXT`), **Clear** (once per decode-cycle boundary before that cycle's Decode datagrams), **QSOLogged** (mirroring `ADIF.log`, skipped on watchdog/operator abort exactly as the ADIF write itself is), and **Close** (on graceful daemon shutdown). Every enabled target SHALL receive an identical copy of each datagram; a disabled target SHALL receive none; an unresolvable target host SHALL log a Warning once and SHALL NOT block delivery to other targets. Config-save reconciliation (open new targets, close removed ones) SHALL take effect without a daemon restart. **Absolute exclusion, no exceptions:** a decode, an active-QSO partner named in a Status datagram, or a completed QSO reported via QSOLogged whose associated callsign resolves to an R&R-study synthetic entry (NFR-021 Q-prefix convention) or an unresolved (unknown) region SHALL NEVER be sent to any external target, under any circumstance. This exclusion is enforced unconditionally inside `ExternalReportingService` itself, independent of `DecodeNoiseSuppressionConfig`'s operator-toggleable suppression settings (which gate the decode panel/QSO automation only), and SHALL NOT be exposed as any Settings-page control or config field. | Must Have | | FR-054 | GridTracker2 / WSJT-X UDP inbound listener and trust boundary | The same service SHALL bind a single inbound listener (when enabled) to the first enabled target's port (WSJT-X convention: the app listens on the port it sends to) and honour exactly four inbound message types. **Halt Tx** SHALL always call `IQsoController.AbortAsync` — never gated by `honourInboundCommands`, since a third-party program forcing TX *off* is safety-improving by construction. **Reply** (operator selects a decoded station) and **Free Text** (accepted and stored; intentionally has no transmission effect — no OpenWSFZ TX state machine has a free-message slot yet) SHALL be acted on only when `honourInboundCommands` is `true`; when `false` they SHALL be discarded with an Information-level log naming the ignored command. **Close** SHALL be logged at Information and SHALL NOT terminate the daemon under any circumstance. Any other recognised message type (Replay, Location, Highlight Callsign, Switch Configuration, Configure, WSPR Decode, …) SHALL be parsed only far enough to identify it, then discarded at Debug with no observable effect. A datagram that fails to parse (too short, bad magic number, unsupported schema version, truncated field) SHALL be discarded and logged at Debug — no parse failure SHALL propagate an unhandled exception out of the receive loop. `QsoAnswererService` SHALL additionally expose `Task TryEngageExternal(string callsign, CancellationToken ct)`, engaging a named, currently-decoded, non-filtered-out CQ exactly as its existing auto-answer path would, unaffected by `tx.autoAnswer` (an explicit external reply is a one-shot manual instruction, not automatic behaviour). | Must Have | | FR-055 | External Programs settings tab | The Settings page SHALL gain an **"External Programs"** tab (subject to FR-016 — ships only once the backend round-trip is fully implemented and testable end-to-end) displaying: an **Enabled** checkbox; an editable target table (columns: Name, Host, Port, Enabled, Delete) with an **"Add target"** button appending a blank row (`host = "127.0.0.1"`, `port = 2237`, `enabled = true`); and a **"Honour inbound commands (Reply / Free Text)"** checkbox with adjacent text stating that Halt Tx is always honoured regardless of this setting. All changes SHALL participate in the existing FR-040 unsaved-changes flow and SHALL be posted via `POST /api/v1/config` on Save, with client-side port-range validation mirroring the server-side FR-052 rejection rule. | Must Have | +| FR-056 | PTT-over-CAT and serial RTS/DTR keying | `IRadioConnection` SHALL gain `Task SetPttAsync(bool transmitting, CancellationToken cancellationToken = default)`, amending the FR-045 restriction (previously read-only apart from frequency-set) to also permit PTT-set. `SerialCatConnection` SHALL implement it with the Kenwood/Yaesu-family commands `TX;` (key) / `RX;` (unkey), the same dialect family as its existing `FA;`/`FA;` frequency commands; `RigctldConnection` SHALL implement it with `\set_ptt 1\n` / `\set_ptt 0\n`, reading and validating the `RPRT 0` acknowledgement exactly as `SetDialFrequencyMhzAsync` already does for `\set_freq`. `CatPollingService` SHALL serialise all access to the shared `IRadioConnection` instance — poll-loop reads, `SetDialFrequencyMhzAsync` tune commands, and `SetPttAsync` PTT commands — through a single mutual-exclusion gate, and SHALL expose PTT keying to consumers outside `OpenWSFZ.Daemon.Cat` only via a narrow `ICatPttGate` interface; no other component SHALL hold a direct reference to the shared connection. `AppConfig` SHALL gain a top-level `Ptt` field (`PttConfig`, sibling to `Cat`) with `Method` (`"AudioVox"` \| `"CatCommand"` \| `"SerialRtsDtr"`, default `"AudioVox"`), `SerialPort`, `SerialLine` (`"Rts"` \| `"Dtr"`, default `"Rts"`), `LeadTimeMs` (default 50), `TailTimeMs` (default 50), and `WatchdogTimeoutMs` (default 20000). The daemon SHALL register exactly one `IPttController` at startup per `Ptt.Method`: `AudioOnlyPttController` (unchanged, existing VOX-only behaviour — default), a new `CatPttController` (asserts PTT via `ICatPttGate` before audio, releases after audio + tail time), or a new `SerialRtsDtrPttController` (asserts/de-asserts a raw RTS or DTR line on its own independently-configured serial port, with no dependency on any CAT connection). `ISerialPort`/`SerialPortWrapper` SHALL gain `RtsEnable`/`DtrEnable` properties to support the latter. Both new PTT-asserting controllers SHALL run a hard failsafe watchdog (`WatchdogTimeoutMs`) that forces PTT release and logs at Error if `KeyUpAsync` has not completed in time, in addition to `try`/`finally` and `DisposeAsync` release guarantees. A missing `ptt` config key SHALL deserialise to all defaults, preserving today's VOX-only behaviour exactly; an unrecognised `Method`/`SerialLine` value SHALL log a Warning and fall back to `AudioVox`/`Rts`. No mode-set or other rig-altering command beyond frequency-set (FR-045) and PTT-set is introduced. | Must Have | +| FR-057 | Settings-page PTT configuration UI and hardware Test button | The Settings → Radio hardware tab SHALL split the existing "CAT rig connection" fieldset's container into two side-by-side fieldsets on wide viewports (stacking on narrow ones, following this page's existing intrinsic flex-wrap layout pattern) — "CAT rig connection" (unchanged) and a new "PTT Config" fieldset. The new fieldset SHALL expose `ptt.method` (`AudioVox` \| `CatCommand` \| `SerialRtsDtr`), `ptt.serialPort`/`ptt.serialLine` (shown only when `method = SerialRtsDtr`, reusing the existing `GET /api/v1/serial/ports` endpoint and the `input-with-action`/"↺ Refresh" pattern), `ptt.leadTimeMs`, `ptt.tailTimeMs`, and `ptt.watchdogTimeoutMs` (hidden when `method = AudioVox`, since there is nothing to configure). `web/js/settings.js`'s `POST /api/v1/config` save payload SHALL include a `ptt` key built from these controls — the first time the Settings page has ever sent one (superseding the "Settings page never sends `ptt`" behaviour from design.md Decision 6, whose null-guard fallback-to-`Current.Ptt` behaviour remains as defense-in-depth for any caller that still omits the key). The fieldset SHALL also expose a **Test** button and a three-state result badge (Pass/Error/idle, mirroring `.cat-status-badge`'s existing visual pattern) that `POST`s to a new `POST /api/v1/ptt/test` endpoint. The endpoint SHALL resolve the currently-running `IPttController`/`IQsoController` singletons, reject with HTTP 409 when `IQsoController.Keying` is `true` ("a QSO is currently transmitting") or when the running `Ptt.Method` is `AudioVox` (nothing to test), and otherwise perform a brief (~200–300 ms) **silent** software PTT pulse (`LoadAudio` with a zero-amplitude buffer, then `KeyDownAsync` immediately followed by `KeyUpAsync`), returning `{ "result": "pass" }` on success or `{ "result": "error", "message": "" }` (HTTP 200) on any exception — Test confirms only that the assert/release commands were accepted, never that the rig physically keyed, and the UI SHALL state this plainly. The Test button SHALL be disabled/hidden when the last-loaded, live `Ptt.Method` is `AudioVox`, with a hint that a Save plus daemon restart is required before Test reflects a newly-selected method (`ptt.method` is read once at DI-registration time, not hot-reloaded). `CatPttController` and `SerialRtsDtrPttController` SHALL each serialise their entire `KeyDownAsync`→`KeyUpAsync` critical section behind a private mutual-exclusion gate, so a Test click racing an in-progress real transmission queues behind it rather than interleaving with it — a second, independent caller of the shared `IPttController` singleton must never be able to reset the real transmission's watchdog or de-assert its PTT mid-transmission. | Must Have | ### 4.2 User Journeys @@ -457,3 +459,5 @@ data in v0.x: | 1.29 | 2026-07-09 | QA (AI-assisted) | Added the **region-lookup-data-refresh** capability (operator-triggered "refresh region data" backend operation sourcing DXCC entity/continent/CQ-zone/ITU-zone data live from country-files.com's XML country-file release, converting and overwriting the runtime `callsign-regions.json`; new "Region data" settings-page tab with status summary, refresh button, and a diagnostic callsign lookup tool). Implemented by `f-006-region-lookup-country-file-refresh`, archived 2026-07-09 (GitHub issue #40). Backfilled the archived proposal's missing **User-facing:** declaration alongside 1.28 above — see that row for the shared VERSION-bump rationale. | | 1.30 | 2026-07-11 | QA (AI-assisted) | Added the **decode-noise-suppression** capability (two persisted, independent Region-data-tab settings — "Suppress Unknown region/DXCC decodes" and "Suppress R&R Synthetic decodes" — gating the decode-panel broadcast and `QsoAnswererService`/`QsoCallerService` eligibility upstream of the existing ephemeral column filter; `ALL.TXT` always unaffected; the Unknown control is always interactive, defaulting from region-data presence until the operator makes an explicit choice, which then persists regardless of subsequent refreshes). Implemented by `decode-noise-suppression`, archived 2026-07-11 (PR #68). Bumped **VERSION** to **v0.34**; also corrected this header's **Version**/**Date** fields, which had lagged at 1.27/2026-07-05 since row 1.27 was added despite the changelog having since reached 1.29. | | 1.31 | 2026-07-12 | QA (AI-assisted) | Added **FR-052** (external reporting configuration schema — `externalReporting` block on `AppConfig`, fully inert by default), **FR-053** (GridTracker2/WSJT-X UDP outbound broadcaster — `ExternalReportingService`, Heartbeat/Status/Decode/Clear/QSOLogged/Close), **FR-054** (inbound listener and trust boundary — Halt Tx always honoured, Reply/Free Text gated by `honourInboundCommands`, Close never terminates the daemon, malformed input never crashes the listener, `QsoAnswererService.TryEngageExternal`), and **FR-055** (External Programs settings tab). Added a §4.3 Integrations row for "GridTracker2 / WSJT-X UDP protocol", distinct from the still-future "PSK Reporter / DX cluster" row. Implemented by `gridtracker-udp-reporting` — a **post-v1.0 addition**: v1.0's gate remains a confirmed two-way QSO via CAT+TX (tracked separately by the in-flight `cat-tx-ptt` change); this change does not alter any `IMPLEMENTATION_PLAN.md` phase or gate, and depends on `cat-tx-ptt` only conceptually (Halt Tx reuses the pre-existing `IQsoController.AbortAsync`, which predates that change). Bumped **VERSION** to **v0.35** (user-facing: new Settings tab). **Amended same day, pre-merge, per QA's review** (dev-tasks/2026-07-12-gridtracker-udp-reporting-review-fixes.md — folded into this row rather than a new one, since `gridtracker-udp-reporting` had not yet merged to `main`): fixed D-014 (the inbound listener's bind silently failed whenever a peer, e.g. GridTracker2, already owned the configured port — the normal case, not an edge case; fixed via `SO_REUSEADDR` plus routing the primary target's outbound sends through the same shared socket) and added FR-053's now-documented absolute, non-configurable exclusion of R&R-synthetic/unknown-region traffic from all external output (Captain's directive — see FR-053's own text above for the exact scope). | +| 1.32 | 2026-07-12 | DEVELOPER (AI-assisted) | Added **FR-056** (PTT-over-CAT and serial RTS/DTR keying — `IRadioConnection.SetPttAsync`, `SerialCatConnection`'s `TX;`/`RX;`, `RigctldConnection`'s `\set_ptt 1\n`/`\set_ptt 0\n` with RPRT ack validation, `CatPollingService`'s wire-serialisation gate and `ICatPttGate` seam, the new `Ptt` config block on `AppConfig`, the new `CatPttController`/`SerialRtsDtrPttController` `IPttController` implementations, `ISerialPort` RTS/DTR support, and the mechanism-agnostic failsafe watchdog). This is the last item standing between v0.x and the v1.0 gate (a confirmed two-way FT8 contact: RX + CAT control + TX) — see `IMPLEMENTATION_PLAN.md` release gate R3. Implemented by `cat-tx-ptt`. | +| 1.33 | 2026-07-12 | DEVELOPER (AI-assisted) | Added **FR-057** (Settings-page PTT configuration UI and hardware Test button — split "CAT rig connection" into two side-by-side fieldsets with a new "PTT Config" fieldset exposing `ptt.method`/`serialPort`/`serialLine`/`leadTimeMs`/`tailTimeMs`/`watchdogTimeoutMs`, a new `POST /api/v1/ptt/test` silent software-pulse endpoint gated by a 409 while a real QSO is keying or the running method is `AudioVox`, and a `.ptt-test-badge` Pass/Error/idle result indicator). Raised directly by the Captain during hardware acceptance (gates 14–15) after discovering there was no way to see or change `ptt.method` without hand-editing `config.json` and restarting — this amends design.md Decision 6's original "no new UI" scope (`cat-tx-ptt` change, tasks.md section 17). Also fixed a safety-critical gap surfaced while scoping this UI: `CatPttController`/`SerialRtsDtrPttController` had no internal call-serialisation, so a second caller (the Test button) could race a real in-progress transmission and de-assert its PTT mid-transmission — both controllers now hold a private `SemaphoreSlim(1,1)` across their entire `KeyDownAsync`→`KeyUpAsync` critical section. Implemented by `cat-tx-ptt` (dev-tasks/2026-07-12-cat-tx-ptt-settings-ui.md). | diff --git a/dev-tasks/2026-07-12-cat-tx-ptt-missing-keyup-after-transmit.md b/dev-tasks/2026-07-12-cat-tx-ptt-missing-keyup-after-transmit.md new file mode 100644 index 0000000..b905815 --- /dev/null +++ b/dev-tasks/2026-07-12-cat-tx-ptt-missing-keyup-after-transmit.md @@ -0,0 +1,222 @@ +# DEV TASK — `QsoCallerService`/`QsoAnswererService` never call `KeyUpAsync` after a normal transmission; every real TX cycle relies on the 20 s watchdog to release PTT + +**Date:** 2026-07-12 +**OpenSpec change:** `cat-tx-ptt` (implementation review) — no spec text changes needed; this is a +code defect in the implementation (the *callers* of `IPttController`, not the controllers +themselves), found during hardware acceptance (Gate 16, the confirmed two-way QSO / release gate R3). +**Branch:** `feat/cat-tx-ptt`. +**Status:** New. Found during a real, live hardware-acceptance attempt: the Captain reported the rig +was heard and answered by a real station (HB9HYO) but the QSO could not be completed, and pointed QA +at `logs/openswfz-20260712T152156Z.log` and `ALL.TXT`. +**Found by:** QA, diagnosing the log against the `cat-tx-ptt` implementation. +**Severity:** **Critical — merge-blocking.** This is not a rare edge case; it fires on **every +single transmission** made with either new PTT method (`CatCommand` or `SerialRtsDtr`). It defeats +the entire purpose of this change: the transmitter is not reliably keyed *and unkeyed* under real +operation. + +--- + +## Evidence + +`logs/openswfz-20260712T152156Z.log`, the QSO with HB9HYO (times are local, +02:00): + +``` +17:28:45.377 [INF] SerialRtsDtrPttController: KeyDown — PTT asserted ("Rts"). +17:28:45.371 [DBG] QsoAnswererService: state → "TxReport" (partner: HB9HYO). +17:28:58.129 [DBG] QsoAnswererService: state → "WaitRr73" (partner: HB9HYO). +17:29:05.392 [ERR] SerialRtsDtrPttController: watchdog fired after 20014 ms — forcing PTT release. +``` + +The state machine advances to `WaitRr73` at 17:28:58 — i.e. it believes the transmission is +complete and it is time to listen — but **no `KeyUp — PTT released` line exists anywhere between +the `KeyDown` and the watchdog firing.** The rig's PTT line stayed physically asserted for a further +**~7.3 seconds** after `QsoAnswererService` had already moved on to listening for a reply, until the +20-second failsafe watchdog forced it off. This pattern repeats identically for every transmission in +this session (5 separate watchdog-forced releases logged in a 4-minute window: 17:27:05, 17:27:35, +17:29:05, 17:29:35, 17:30:05, 17:30:35), for both `QsoCallerService` (CQ calls) and +`QsoAnswererService` (the HB9HYO exchange). Even the one cycle in this log that *wasn't* forced by +the watchdog (17:22:45–17:23:02) still released PTT ~17.6 s after key-down — roughly 5 s later than +the expected ~12.7 s (12 640 ms audio + `tailTimeMs`) — meaning normal release is *also* not prompt +even when it does eventually happen. `ALL.TXT` confirms HB9HYO answered PD2FZ/P's CQ at 15:28:00 UTC +and nothing further from HB9HYO was ever decoded — consistent with the daemon's own receiver still +being held in TX for several extra seconds into what should have been its next listen window. + +## Root cause + +`IPttController.KeyDownAsync` asserts PTT and blocks until the loaded audio buffer has finished +playing; `KeyUpAsync` is a **separate call** the caller must make afterward to actually release PTT +(wait `TailTimeMs`, then de-assert the line/send the unkey command). This is correct and +intentional for `CatPttController`/`SerialRtsDtrPttController` — see their own doc comments +("`KeyDownAsync` sequence: assert PTT → wait `LeadTimeMs` → play audio → await completion. +`KeyUpAsync` sequence: stop playback → wait `TailTimeMs` → release PTT.") — but it is a **behaviour +change** from the previous `AudioOnlyPttController`-only world, where forgetting to call `KeyUpAsync` +after a normal transmission was harmless: `AudioOnlyPttController.KeyUpAsync` just stops WASAPI +playback that has, by construction, already finished, so skipping it had no observable effect (VOX +drops out on its own the instant audio stops). Nobody updated the *callers* for the new contract. + +Confirmed by inspection — `KeyUpAsync` is called at exactly four places in the whole daemon, **all +of them abort paths**, none of them the normal-completion path: + +``` +src/OpenWSFZ.Daemon/QsoAnswererService.cs:274 (SafeAbortToIdleAsync-equivalent, plain abort) +src/OpenWSFZ.Daemon/QsoAnswererService.cs:1288 (graceful-stop abort) +src/OpenWSFZ.Daemon/QsoCallerService.cs:234 (SafeAbortToIdleAsync, plain abort) +src/OpenWSFZ.Daemon/QsoCallerService.cs:1038 (SafeAbortToIdleAsync, graceful-stop) +``` + +The actual transmit helper in both services brackets only `KeyDownAsync`, never follows it with +`KeyUpAsync`: + +```csharp +// QsoCallerService.cs:930-960 (TransmitAsync) — QsoAnswererService.cs:~1160-1199 is structurally identical +_keying = true; +PublishKeyingTransition(); +try +{ + await _pttController.KeyDownAsync(linked.Token).ConfigureAwait(false); +} +finally +{ + _keying = false; + PublishKeyingTransition(); +} +linked.Token.ThrowIfCancellationRequested(); +_logger.LogDebug("QsoCallerService: TX complete for \"{Message}\".", message); +// <-- caller returns here; KeyUpAsync is never called. Every subsequent state +// transition (WaitAnswer/WaitRr73/etc.) proceeds with PTT still asserted. +``` + +The only reason this has *ever* worked at all is `PttWatchdog`'s 20-second failsafe — which exists +specifically to catch bugs, not to be the normal release mechanism for every transmission. This also +means `IPttController.cs`'s own interface doc comment is stale: it still describes the contract in +purely `AudioOnlyPttController` terms ("`KeyDownAsync` starts WASAPI audio playback; `KeyUpAsync` +stops it") and never states the now-critical rule that every `KeyDownAsync` **must** be followed by a +`KeyUpAsync` in the caller's normal-completion path, not only its abort path — worth fixing alongside +the code, since this is exactly the kind of contract gap that let the bug go unnoticed through code +review. + +## Why this is worse than "the rig keys a bit long" + +1. **It breaks FT8 timing outright.** FT8 depends on precise 15-second-aligned TX/RX slot boundaries. + Holding PTT asserted ~7 extra seconds past the intended tail time keeps the rig transmitting (and + the daemon's own receiver blind) well into what should already be the next receive window — this + is almost certainly why HB9HYO's continuation of the exchange, if sent, was never decoded, and + directly explains "someone did respond, but I was not able to finish the QSO." +2. **Every single transmission relies on the watchdog.** This is not an occasional/rare trigger — the + watchdog fired on effectively every TX cycle in this session. A mechanism explicitly designed as a + last-resort failsafe ("this should never fire during correct operation" — `PttWatchdog`'s own + design rationale) is now the *primary* release path, which also means a genuine watchdog + regression would be much harder to notice separately, since it fires constantly regardless. +3. **CAT-command PTT (`CatPttController`) has the identical gap** and is equally broken, even though + this particular log happened to be running `SerialRtsDtr` — this is not a serial-specific bug. + +## Recommended fix + +In both `QsoCallerService.TransmitAsync` (`QsoCallerService.cs:930-960`) and +`QsoAnswererService`'s equivalent (`QsoAnswererService.cs:~1160-1199`), call +`_pttController.KeyUpAsync(...)` immediately after `KeyDownAsync` completes — in the **same** +`finally` block already wrapping the keying-state broadcast, so PTT is released exactly once +regardless of whether `KeyDownAsync` returned normally, threw, or was cancelled: + +```csharp +_keying = true; +PublishKeyingTransition(); +try +{ + await _pttController.KeyDownAsync(linked.Token).ConfigureAwait(false); +} +finally +{ + // KeyUpAsync must run here, not only from the abort path (SafeAbortToIdleAsync) — + // that path is for operator/watchdog-initiated abort of an in-progress *session*, this + // is the ordinary, successful end of a single transmission. Use CancellationToken.None so + // release still happens even if `ct`/`linked.Token` is already cancelled — mirrors both + // controllers' own KeyUpAsync bodies, which already tolerate being called when nothing is + // asserted (a safe no-op) so this is safe to call unconditionally. + try + { + await _pttController.KeyUpAsync(CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "QsoCallerService: KeyUpAsync threw after TransmitAsync — ignoring."); + } + _keying = false; + PublishKeyingTransition(); +} +linked.Token.ThrowIfCancellationRequested(); +``` + +Order the two `finally`-block actions so `KeyUpAsync` runs **before** `_keying = false` / +`PublishKeyingTransition()` — the keying broadcast should reflect "still keyed" for the brief window +while `KeyUpAsync`'s own `TailTimeMs` wait is in progress, not flip to "not keying" prematurely while +PTT is still physically asserted. + +**Do not** attempt to fix this by shortening the watchdog timeout instead — that would only reduce +the *duration* of the stuck-key window per cycle, not eliminate the underlying missing call, and +would leave every transmission still relying on a failsafe as its normal path. + +**Also fix:** `src/OpenWSFZ.Abstractions/IPttController.cs`'s doc comment — update it to state +plainly that a caller **must** call `KeyUpAsync` after every `KeyDownAsync` in the normal-completion +path, not only on abort/cancellation; the current wording ("`KeyDownAsync` starts WASAPI audio +playback; `KeyUpAsync` stops it") predates `CatPttController`/`SerialRtsDtrPttController` and reads +as if `KeyUpAsync` is optional cleanup rather than a mandatory pairing. + +## Tests required + +- A `QsoCallerServiceTests.cs`/`QsoAnswererServiceTests.cs` case (both files already exist and use a + test-double `IPttController` recording call order, per the existing `TestPttController`-style + pattern already used elsewhere — e.g. `tests/OpenWSFZ.Web.Tests/PttTestEndpointTests.cs`) asserting + that a normal, successful transmission cycle calls `KeyDownAsync` **immediately followed by** + `KeyUpAsync`, with no intervening state transition — the exact regression this task fixes. +- A case confirming `KeyUpAsync` runs even when the transmission is cancelled mid-`KeyDownAsync` + (linked-token cancellation), not only on full success. +- Re-run `PttWatchdogTests.cs`, `CatPttControllerTests.cs`, `SerialRtsDtrPttControllerTests.cs` + unmodified — this fix is entirely on the caller side; no controller-level assertion should change. + +## Verification + +1. `dotnet build` / `dotnet test` — expect unchanged pass counts plus the new caller-side tests, all + green. +2. Manual, hardware-required (this is exactly what Gate 16 needs to re-attempt): with + `ptt.method = "SerialRtsDtr"` (or `"CatCommand"`) and a real QSO in progress, confirm + `SerialRtsDtrPttController: KeyUp — PTT released` (or the CAT equivalent) now appears within + roughly `tailTimeMs` of each transmission's audio ending — **not** the 20-second watchdog line. + Confirm the watchdog does **not** fire during normal operation across a full multi-exchange QSO. +3. Repeat the actual failed scenario: attempt a real two-way QSO end-to-end and confirm it completes + (RR73 both ways) now that the rig returns to receive promptly after each of the daemon's own + transmissions. +4. `openspec validate --strict --all` — expect unchanged pass count (no spec text is changing). + +## QA re-review + +QA will re-run the manual reproduction in "Verification" step 2/3 directly (watching the rig and the +log together, not just trusting the new unit tests), confirm the new caller-side tests assert call +*order*, not just call *presence*, confirm `IPttController.cs`'s doc comment is corrected, and confirm +the full suite plus `openspec validate --strict --all` are green before sign-off. **Hardware +acceptance Gates 14–16 (`openspec/changes/cat-tx-ptt/tasks.md`) must be re-attempted from scratch +after this fix lands — none of the keying observed before this fix should be considered valid +evidence for those gates**, including any prior informal confirmation that "the radio responds" — +the rig keying at all (Gates 14/15) is necessary but not sufficient; it must also *unkey* promptly +enough to hold FT8 timing for Gate 16 to mean anything. + +## References + +- `logs/openswfz-20260712T152156Z.log` — the session this was found in; five separate + watchdog-forced releases in the first ~4 minutes of real automated operation, all traceable to this + gap. +- `ALL.TXT` — HB9HYO's answer to PD2FZ/P's CQ (line 294, `260712_152800`), with nothing further from + HB9HYO decoded afterward — consistent with the daemon's own receiver being unavailable slightly + too long after each of its own transmissions. +- `src/OpenWSFZ.Daemon/QsoCallerService.cs:930-960` (`TransmitAsync` — missing `KeyUpAsync` call) and + `:234`, `:1038` (the only two `KeyUpAsync` call sites, both abort-only). +- `src/OpenWSFZ.Daemon/QsoAnswererService.cs:~1160-1199` (transmit helper — identical gap) and `:274`, + `:1288` (the only two `KeyUpAsync` call sites, both abort-only). +- `src/OpenWSFZ.Daemon/SerialRtsDtrPttController.cs`, `src/OpenWSFZ.Daemon/CatPttController.cs` — both + correctly implemented per their own documented `KeyDownAsync`/`KeyUpAsync` contract; **not** where + the bug is. Do not modify either controller to "fix" this. +- `src/OpenWSFZ.Daemon/PttWatchdog.cs` — the failsafe that has been masking this defect by + eventually releasing PTT every time, ~7+ seconds late. +- `src/OpenWSFZ.Abstractions/IPttController.cs` — stale doc comment to correct alongside the fix. +- `openspec/changes/cat-tx-ptt/hardware-acceptance.md` — Gates 14–16, to be re-attempted after this + fix lands. diff --git a/dev-tasks/2026-07-12-cat-tx-ptt-null-ptt-config-guard.md b/dev-tasks/2026-07-12-cat-tx-ptt-null-ptt-config-guard.md new file mode 100644 index 0000000..3b677a7 --- /dev/null +++ b/dev-tasks/2026-07-12-cat-tx-ptt-null-ptt-config-guard.md @@ -0,0 +1,244 @@ +# DEV TASK — `POST /api/v1/config` can persist a null `Ptt`, silently disabling CAT-command/serial RTS-DTR PTT (and risking a stuck-key NRE) + +**Date:** 2026-07-12 +**OpenSpec change:** `cat-tx-ptt` (implementation review) — no spec text changes needed; this is +a code defect in the implementation, not a requirements gap. The relevant requirement is already +correctly specified in `openspec/changes/cat-tx-ptt/specs/ft8-tx/spec.md` under "PTT method +configuration" / scenario "Default configuration preserves existing VOX behaviour" (`AppConfig.Ptt` +must always resolve to a usable, non-null `PttConfig`) — the bug below causes that invariant to be +violated at runtime, at the exact layer the spec doesn't reach (the live HTTP config-save path). +**Branch:** `feat/cat-tx-ptt`. +**Status:** New. Found during hardware acceptance (Gate 14/15): Captain reported "the radio never +enables TX" after configuring CAT-command PTT. Root-caused by QA via log inspection + static code +reading, not yet reproduced with an automated test (see "Tests required" below — this doc specifies +that test rather than including it, so the developer's fix and the regression test land together). +**Found by:** QA, diagnosing `logs/openswfz-20260712T131050Z.log` against the `cat-tx-ptt` review +already performed the same day. +**Severity:** High. This is not merely "a setting doesn't stick" — see "Why this is worse than it +looks" below for the stuck-PTT NullReferenceException path. + +--- + +## Evidence + +The Captain's live `C:\Users\Frank\AppData\Roaming\OpenWSFZ\config.json` currently contains: + +```json +"ptt": null, +``` + +The session log (`logs/openswfz-20260712T131050Z.log`) shows every TX cycle logging the exact, +literal message `AudioOnlyPttController.KeyDownAsync` emits: + +``` +TX KeyDown — starting playback on device '...' (606720 samples). +``` + +`CatPttController: KeyDown — PTT asserted (CAT).` and `SerialRtsDtrPttController: KeyDown — PTT +asserted (...).` — the messages the CAT-command and serial RTS/DTR controllers would log — never +appear anywhere in the file. Whatever `ptt.method` the Captain intended for this hardware-acceptance +run, the daemon ran on plain VOX (`AudioOnlyPttController`) throughout, which is consistent with a +`ptt.method` that silently reverted to `"AudioVox"` before or during the session. + +The log also contains exactly one `POST /api/v1/config` (15:11:59.920, from the Settings page, +loaded at 15:11:39), which is the only way this session touched persisted config after startup. + +## Root cause + +This is the same class of bug as **D-010** and its two prior recurrences (`decodeLog`, `logging`, +`decodeNoiseSuppression`, `externalReporting` — see +`tests/OpenWSFZ.Web.Tests/ConfigApiNullGuardTests.cs`), now recurring for the new `Ptt` section this +change introduces. + +1. **`web/js/settings.js`'s save handler never sends a `ptt` key.** The `postConfig({...})` payload + (built starting ~line 1097) lists `audioDeviceId, ..., cat, tx, remoteAccess, decoder, + decodeNoiseSuppression, externalReporting` — every other config section, but not `ptt`. This is + correct and intentional: `cat-tx-ptt` deliberately ships with no Settings-page UI for PTT + configuration (design.md Decision 6, matching FR-016 — no speculative UI). The consequence is + that **every single Settings-page save omits the `ptt` key from its request body**, by design. + +2. **`WebApp.cs`'s `POST /api/v1/config` handler has no guard for `Ptt`.** It deserializes the + request body directly into a whole new `AppConfig` (`request.ReadFromJsonAsync(...)`, + `WebApp.cs:339`). System.Text.Json's source-generated deserializer sets a non-nullable `init` + property to `null` when its JSON key is absent, rather than falling back to the property's `= + new()` initialiser — the exact quirk this same handler already guards against for four other + sections, immediately after the read (`WebApp.cs:361-368`): + + ```csharp + if (config.Logging is null) + config = config with { Logging = new LoggingConfig() }; + if (config.DecodeLog is null) + config = config with { DecodeLog = new DecodeLogConfig() }; + if (config.DecodeNoiseSuppression is null) + config = config with { DecodeNoiseSuppression = new DecodeNoiseSuppressionConfig() }; + if (config.ExternalReporting is null) + config = config with { ExternalReporting = new ExternalReportingConfig() }; + ``` + + **`Ptt` is missing from this list.** `PttConfig` has the identical shape to all four guarded + types (`AppConfig.Ptt` is a non-nullable `{ get; init; } = new()` property — see + `src/OpenWSFZ.Abstractions/AppConfig.cs:54`), so it is subject to the identical quirk — and, + unlike the other four, it is *guaranteed* to be absent from every request the Settings page + sends, because there is no field for it in the JS payload at all. + +3. **`JsonConfigStore.SaveAsync` performs no validation of its own** (`JsonConfigStore.cs:43-90`); + it serializes and persists whatever `AppConfig` it is handed and immediately updates `_current` + in memory (`JsonConfigStore.cs:75`). So the null `Ptt` sails straight through to both the file on + disk and the live in-memory config for the remainder of the process. + +`JsonConfigStore.Load()` (`JsonConfigStore.cs:153-154`) *does* correctly guard `Ptt` — the developer +clearly knew about this quirk and handled it for the file-read-at-startup path (task 2.5's own round- +trip verification covers exactly this). What's missing is the parallel guard on the live-HTTP-write +path, which is a different code path entirely and isn't exercised by `PttConfigTests.cs` or +`JsonConfigStoreTests.cs` (both correctly test the layer they're testing — this gap is specifically +in `WebApp.cs`, which has its own dedicated null-guard test file for exactly this reason). + +## Why this is worse than it looks + +**Case A — daemon started with `ptt.method = "AudioVox"` (the default, or after a previous null +reset):** the very next Settings-page save does nothing new, because `AudioOnlyPttController` never +reads `AppConfig.Ptt` at all. The operator sees no error. If they'd separately hand-edited +`config.json` to `"CatCommand"`/`"SerialRtsDtr"` expecting it to take effect on save/reload (as the +operator guide currently — incorrectly, see "Also update the docs" below — implies is possible), that +edit is silently discarded the moment any Settings save happens, and the *next restart* resolves back +to `AudioVox` with no warning logged anywhere. This is almost certainly what the Captain hit. + +**Case B — a `CatCommand`/`SerialRtsDtr` controller is already the active DI-registered singleton** +(daemon was started fresh with the correct `ptt.method` on disk, and a Settings save happens +afterward, mid-session, for any reason): the next `KeyDownAsync` call does + +```csharp +// CatPttController.KeyDownAsync (SerialRtsDtrPttController is structurally identical) +var ptt = _configStore.Current.Ptt; // now null +... +await _pttGate.SetPttAsync(true, ct).ConfigureAwait(false); // PTT physically asserted — rig keys +_pttAsserted = true; +_watchdog.Arm(ptt.WatchdogTimeoutMs, ForceReleaseAsync); // NullReferenceException here +``` + +The NRE fires **after** PTT has been physically asserted and **after** `_pttAsserted = true`, but +**before** the watchdog is armed and **before** the exception-safe `try` block (the one wrapping the +lead-time wait and playback, whose `catch` calls `KeyUpAsync` to release PTT) has even begun. None of +this change's carefully-built release guarantees apply to an exception thrown here — this is exactly +the stuck-transmitter failure mode `cat-tx-ptt`'s entire watchdog/failsafe design exists to prevent, +triggered by an unrelated, ordinary Settings save (e.g. tweaking the CAT serial port, which is +precisely the kind of adjustment an operator makes while setting up hardware acceptance testing). + +Not reproduced against real hardware in this pass — flagging as a static-analysis-confirmed risk +requiring the same fix as Case A, verified by the same test. + +## Recommended fix + +Two small, mechanical additions, matching the established pattern exactly (do not invent a new +pattern — this is the fourth time this exact bug shape has occurred; use the same fix shape as +D-010/`decode-noise-suppression`/`gridtracker-udp-reporting` did): + +1. **`src/OpenWSFZ.Web/WebApp.cs`**, alongside the four existing guards (~`WebApp.cs:368`): + + ```csharp + if (config.Ptt is null) + config = config with { Ptt = new PttConfig() }; + ``` + +2. **`src/OpenWSFZ.Config/JsonConfigStore.cs`'s `SaveAsync`** — belt-and-braces. `SaveAsync` is the + one true chokepoint all persistence goes through (not just this one HTTP handler — e.g. any + future caller that builds an `AppConfig` from a partial source), and it currently guards nothing. + Add the identical guard at the top of `SaveAsync`, before the write: + + ```csharp + if (config.Ptt is null) + config = config with { Ptt = new PttConfig() }; + ``` + + (Optional but recommended while touching this method: consider whether `Logging`/`DecodeLog`/ + `DecodeNoiseSuppression`/`ExternalReporting` should receive the same defense-in-depth treatment + here rather than relying solely on the `WebApp.cs`-side guard — out of scope for this task, note + only, do not fix unrelated sections as a drive-by.) + +3. **Do not** attempt to fix this by adding null-checks inside `CatPttController`/ + `SerialRtsDtrPttController`/`PttControllerSelector` instead — that would treat the symptom (the + NRE) without preventing the underlying bad state from being written to disk and read back in a + context that lacks `JsonConfigStore.Load()`'s own guard. Fix it at the same layer the existing + `Logging`/`DecodeLog`/`DecodeNoiseSuppression`/`ExternalReporting` guards already fix it at. + +## Also update the docs + +`docs/cat-control-operator-guide.md`'s new PTT section currently says: + +> There is currently no Settings-page UI for `ptt` — edit the config file directly (same location +> as every other setting) and restart the daemon (**or save/reload**) to pick up changes. + +The "or save/reload" half is actively misleading given `Ptt.Method` is only ever read once, at +`Program.cs`'s DI-registration time (~`Program.cs:436`) — it is not hot-reloaded on config save the +way, say, `CatConfig` is. Please correct this line once the guard fix above is in, to state plainly +that a full daemon restart is required for a `ptt` change to take effect, and that (post-fix) a +Settings-page save no longer discards a manually-edited `ptt` section. + +## Tests required + +- Extend `tests/OpenWSFZ.Web.Tests/ConfigApiNullGuardTests.cs` with a fifth case, following the + existing four exactly (raw JSON via `StringContent`, **not** `new AppConfig() with {...}`, since + the latter never reproduces the quirk — see the file's own class-level doc comment): POST a body + omitting `"ptt"`, then GET `/api/v1/config` back and assert the section round-trips as a non-null + object with the documented defaults (`method == "AudioVox"`, `serialLine == "Rts"`, `leadTimeMs == + 50`, `tailTimeMs == 50`, `watchdogTimeoutMs == 20000`) — not merely non-null. +- No change needed to `PttConfigTests.cs`, `JsonConfigStoreTests.cs`, `CatPttControllerTests.cs`, or + `SerialRtsDtrPttControllerTests.cs` — all four already correctly cover their respective layers + (schema defaults, the file-load path guard, and each controller's own behaviour given a + well-formed, non-null `PttConfig`). This gap is specifically in the HTTP POST path, which has its + own dedicated test file for exactly this reason. +- Optional, if practical: a `CatPttControllerTests.cs`/`SerialRtsDtrPttControllerTests.cs` case + constructing the controller with a mock `IConfigStore` whose `Current.Ptt` is `null`, asserting + `KeyDownAsync` fails in a PTT-released state rather than leaving `_pttAsserted == true` — this + would directly cover Case B above at the controller layer as well as the config layer. Not + required for this task's sign-off if time-constrained; the `WebApp.cs`/`JsonConfigStore.cs` guard + fix already prevents `Ptt` from ever reaching either controller as `null` in the first place, which + is the correct place to fix it (see "Do not" above) — this would be pure defense-in-depth. + +## Verification + +1. `dotnet build -c Release` / `dotnet test -c Release --no-build` — expect unchanged pass counts + plus the one new test, all green (442 → 443 in `OpenWSFZ.Daemon.Tests` is unaffected since this + test lives in `OpenWSFZ.Web.Tests`; that suite's count should go up by exactly one). +2. Manually repeat the Captain's scenario: set `ptt.method = "CatCommand"` in `config.json`, start + the daemon, confirm `CatPttController: KeyDown — PTT asserted (CAT).` appears on the first TX + cycle. Then perform any unrelated Settings-page save (e.g. toggle `showCycleCountdown`) and + confirm the *next* TX cycle still logs the `CatPttController` message, not `AudioOnlyPttController`'s + `TX KeyDown — starting playback...` — and confirm `config.json`'s `ptt.method` still reads + `"CatCommand"` after the save. +3. `openspec validate --strict --all` — expect unchanged pass count (no spec text is being changed + by this fix). + +## References + +- `src/OpenWSFZ.Web/WebApp.cs:331-368` (`POST /api/v1/config` handler — existing `Logging`/ + `DecodeLog`/`DecodeNoiseSuppression`/`ExternalReporting` guards to mirror; fix belongs at line + ~368). +- `src/OpenWSFZ.Config/JsonConfigStore.cs:43-90` (`SaveAsync` — no guard today; add one) and + `JsonConfigStore.cs:148-154` (the equivalent guard already correctly applied to the file-load + path — same pattern, different layer). +- `src/OpenWSFZ.Daemon/CatPttController.cs:113-145` (`KeyDownAsync` — shows the exact NRE-after- + assert ordering described in Case B) and `src/OpenWSFZ.Daemon/SerialRtsDtrPttController.cs:135-172` + (structurally identical risk). +- `src/OpenWSFZ.Abstractions/AppConfig.cs:54` (`Ptt` property — non-nullable, `= new()` initialiser, + same shape as the four already-guarded sections). +- `tests/OpenWSFZ.Web.Tests/ConfigApiNullGuardTests.cs` (existing D-010-class regression fixture; + extend this file rather than creating a new one). +- `docs/cat-control-operator-guide.md`, "PTT (Transmit Keying) Configuration" section (the + "restart the daemon (or save/reload)" line to correct). +- `openspec/changes/cat-tx-ptt/specs/ft8-tx/spec.md`, requirement "PTT method configuration", + scenario "Default configuration preserves existing VOX behaviour" — the invariant this bug + violates at runtime (a missing/reset `ptt` section must always resolve to a well-formed + `PttConfig`, not `null`). +- `logs/openswfz-20260712T131050Z.log` — the session in which this was caught; every TX cycle logs + `AudioOnlyPttController`'s message, never `CatPttController`'s or `SerialRtsDtrPttController`'s. + +## QA re-review + +QA will re-run the manual reproduction in "Verification" step 2 directly against the fix (not just +trust that tests pass), check the new test's assertions rather than "count went up by one," confirm +the operator-guide correction lands, and confirm the full suite (`dotnet test`, +`openspec validate --strict --all`) is still green before sign-off. Hardware acceptance (Gates +14–16 in `openspec/changes/cat-tx-ptt/tasks.md`) should not be re-attempted by the Captain until this +fix has merged — there is no point re-testing real hardware against a config layer known to discard +the setting under test. diff --git a/dev-tasks/2026-07-12-cat-tx-ptt-settings-ui.md b/dev-tasks/2026-07-12-cat-tx-ptt-settings-ui.md new file mode 100644 index 0000000..16bbb6e --- /dev/null +++ b/dev-tasks/2026-07-12-cat-tx-ptt-settings-ui.md @@ -0,0 +1,137 @@ +# DEV TASK — Add a Settings-page UI for PTT configuration, with a safe hardware Test button + +**Date:** 2026-07-12 +**OpenSpec change:** `cat-tx-ptt` — amends design.md Decision 6 ("no new UI") and adds tasks.md +section 17. Both already edited in this branch; this document is the developer handoff, not a +duplicate spec — **read `openspec/changes/cat-tx-ptt/design.md`'s 2026-07-12 amendment to Decision 6 +first**, it contains the full safety analysis for the semaphore requirement below, and +`openspec/changes/cat-tx-ptt/tasks.md` section 17 is the authoritative, line-by-line task list. +**Branch:** `feat/cat-tx-ptt`. +**Status:** New. Raised directly by the Captain after the null-`ptt`-guard fix +(`dev-tasks/2026-07-12-cat-tx-ptt-null-ptt-config-guard.md`) landed and hardware acceptance still +couldn't proceed smoothly — there is currently no way to see or change `ptt.method` without +hand-editing `config.json` and fully restarting, which is itself part of why the null-guard defect +went unnoticed for a full session. +**Found by:** Captain, via a hand-annotated screenshot of the Settings → Radio hardware tab +(reproduced below in words since the image isn't reproducible here): split the existing "CAT rig +connection" box into two side-by-side boxes, the new one labelled "PTT Config", with a Test button +and a Pass/Error visual result. +**Severity:** High — blocks efficient continuation of hardware acceptance (gates 14–16), and the +underlying safety finding below (concurrent PTT callers) is a real hazard independent of this UI. + +--- + +## What was decided, and how + +Two points here key on live hardware, so I put them to the Captain directly rather than assuming: + +1. **What does "Test" verify?** → **Software-only pulse.** Per `IRadioConnection.SetPttAsync`'s own + doc comment (design.md Decision 2), no implementation ever reads back PTT state — there is no way + to prove the rig physically keyed. Test asserts PTT briefly (~200–300 ms, silent — no audible + tone), then releases. Pass = the command was accepted without throwing (a real CAT ACK, or a real + RTS/DTR line toggle). The UI must say plainly that this confirms the *command*, not that the rig + *visibly keyed* — the operator still has to watch the rig themselves. +2. **Confirmation dialog before firing Test?** → **No.** A single click is enough, consistent with + every other action already on this page (Save, Retry, Refresh) — none of which prompt "are you + sure?" — and the pulse is brief and watchdog-protected regardless. + +## A safety-critical finding surfaced while scoping this — fix required, not optional + +Reading `CatPttController.cs` to figure out how Test should actually invoke it surfaced something +that has nothing to do with the UI itself: **`CatPttController`/`SerialRtsDtrPttController` have no +internal call-serialisation.** `KeyDownAsync`/`KeyUpAsync` were written assuming exactly one caller — +the active `QsoAnswererService`/`QsoCallerService` — ever calls them. A Test click is a second, +independent caller of the same DI singleton `IPttController`. + +Without a guard, firing Test while a real QSO is mid-transmission would run a second +`KeyDownAsync`/`KeyUpAsync` sequence concurrently against the *same* controller instance: the shared +watchdog gets silently re-armed (resetting a real transmission's failsafe countdown to a fresh +timer), and — worse — the Test's own short `KeyUpAsync` sets `_pttAsserted = false` and de-asserts +PTT, **physically unkeying a real, in-progress over-the-air transmission**. That is precisely the +kind of failure this change's own stated design principle ("prefer the design that fails toward 'rig +stays silent' over the one that fails toward 'rig stays keyed'") exists to prevent — this feature, if +built without the guard below, would introduce a fresh way to *stop* a legitimate transmission mid- +flight from an unrelated browser tab. + +**Required, two layers (see tasks.md 17.2/17.3):** +1. `POST /api/v1/ptt/test` checks `IQsoController.Keying` and rejects with 409 if a real QSO is + currently keying. +2. `CatPttController` and `SerialRtsDtrPttController` each gain a private `SemaphoreSlim(1,1)` + around their entire `KeyDownAsync`→`KeyUpAsync` critical section, so a request that races past + check (1) queues behind the real transmission instead of interleaving with it. This is small and + mirrors design.md Decision 1's own wire-serialisation gate one layer up — do this **before** any + UI work (tasks.md 17.2 is explicitly ordered first for this reason). + +Existing `CatPttControllerTests.cs`/`SerialRtsDtrPttControllerTests.cs` suites must pass **unmodified** +after adding the semaphore (same "zero assertion changes" discipline task 7.3 already established for +the `WasapiTxPlayer` extraction) — plus one new test per controller proving two concurrent +`KeyDownAsync` callers serialise rather than interleave. + +## Scope (full detail in tasks.md section 17 — summarised here) + +- **Layout:** split `#cat-settings` into two side-by-side fieldsets ("CAT rig connection" unchanged, + new "PTT Config") on wide viewports, stacking on narrow ones — reuse whatever breakpoint + `app.css` already uses elsewhere in this page rather than inventing a new one. +- **New fields:** `ptt.method` (AudioVox/CatCommand/SerialRtsDtr), `serialPort`/`serialLine` (shown + only for SerialRtsDtr — reuse the existing generic `/api/v1/serial/ports` endpoint and the + `cat-serial-port`/`cat-serial-refresh` `input-with-action` pattern verbatim), `leadTimeMs`, + `tailTimeMs`, `watchdogTimeoutMs` (hidden for AudioVox — nothing to configure). +- **Test button + badge:** mirrors `.cat-status-badge`'s existing three-state visual pattern + (`.cat-connected`/`.cat-error`/`.cat-disabled` → new `.ptt-test-pass`/`.ptt-test-error`/ + `.ptt-test-idle`, same CSS custom properties). Disabled/hidden when the **live**, already-running + method is AudioVox (there's nothing to test), with a hint that a Save + restart is required before + Test reflects a newly-selected method — `ptt.method` is read once at DI-registration time, not + hot-reloaded (already corrected in `docs/cat-control-operator-guide.md`). +- **`settings.js`'s save payload now includes a `ptt` key** for the first time — this is the + deliberate, intended end of the "Settings page never sends `ptt`" behaviour design.md Decision 6 + originally described. The null-guard fix's fallback-to-`Current.Ptt` behaviour + (`WebApp.cs`/`JsonConfigStore.cs`, merged same day) is unaffected and remains correct + defense-in-depth for any caller that still omits the key. +- **Backend:** new `POST /api/v1/ptt/test` per the safety section above. +- **Docs:** `docs/cat-control-operator-guide.md`'s "no Settings-page UI for `ptt`" line is now false + once this ships — update it, and document Test's exact semantics (software pulse, not a physical + confirmation). +- **REQUIREMENTS.md:** add **FR-057** (next free number after FR-056) for this UI, following the + existing FR-056 entry's format, plus a version-history row. + +## Verification + +1. `dotnet build -c Release` / `dotnet test -c Release --no-build` — all green, plus the new + concurrent-KeyDownAsync serialisation tests and the new `POST /api/v1/ptt/test` coverage. +2. Manual: with `ptt.method = "CatCommand"` and CAT connected, load Settings → Radio hardware, click + Test, confirm a Pass badge and `CatPttController: KeyDown — PTT asserted (CAT).` / + `KeyUp — PTT released (CAT).` in the log within the ~200–300 ms window, and confirm the rig's own + TX indicator pulses briefly. Then start a real automated QSO (or simulate one via the existing TX + test flow) and click Test *while it is transmitting* — confirm the endpoint returns 409 and the + real transmission's audio/PTT is completely undisturbed (this is the regression test for the + safety finding above; do not skip it). +3. `openspec validate --strict --all` — expect unchanged pass count (design.md/tasks.md are not + spec-graded content; no spec text changes). +4. Before/after screenshots of the Radio hardware tab, before-first per the project's standing + screenshot-ordering rule. + +## QA re-review + +QA will re-run the manual reproduction in "Verification" step 2 directly (including the +concurrent-Test-during-a-real-QSO case — this is not optional given what triggered the requirement), +inspect the semaphore addition in both controllers line-by-line rather than trusting "tests pass", +confirm the operator-guide and REQUIREMENTS.md updates land, and confirm the full suite plus +`openspec validate --strict --all` are green before sign-off. + +## References + +- `openspec/changes/cat-tx-ptt/design.md` — Decision 6's 2026-07-12 amendment (authoritative safety + analysis for the semaphore requirement). +- `openspec/changes/cat-tx-ptt/tasks.md` — section 17 (authoritative, itemised task list). +- `src/OpenWSFZ.Daemon/CatPttController.cs`, `src/OpenWSFZ.Daemon/SerialRtsDtrPttController.cs` — + no existing internal call-serialisation; add the `SemaphoreSlim(1,1)` here. +- `src/OpenWSFZ.Abstractions/IRadioConnection.cs:52-62` (`SetPttAsync` — no read-back capability, + hence Test can only confirm the command, never the physical rig state). +- `web/settings.html:153-229` (`#cat-settings` fieldset — markup pattern to mirror for the new + fieldset), `web/js/settings.js` (CAT element refs/handlers — pattern to mirror for PTT), + `web/css/app.css:848-882` (`input-with-action` and `.cat-status-badge` — patterns to mirror). +- `src/OpenWSFZ.Web/WebApp.cs:809` (`IQsoController` resolution pattern already used by every + `/api/v1/tx/*` endpoint — reuse for the 409-while-keying guard), `WebApp.cs:951-` (`/api/v1/cat/retry` + — endpoint shape to mirror for `/api/v1/ptt/test`). +- `docs/cat-control-operator-guide.md`, "PTT (Transmit Keying) Configuration" section (the + "no Settings-page UI" line to correct). diff --git a/dev-tasks/screenshots/2026-07-12-cat-tx-ptt-settings-ui-after.png b/dev-tasks/screenshots/2026-07-12-cat-tx-ptt-settings-ui-after.png new file mode 100644 index 0000000000000000000000000000000000000000..39f20359f2773ba7c1d9bb5ef4b67abcdc027ed1 GIT binary patch literal 71633 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

8MRyLUb&t;Eg3ye&)aG_I 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 NB(M57=g|w_{|Dnm3_1V+ literal 0 HcmV?d00001 diff --git a/dev-tasks/screenshots/2026-07-12-cat-tx-ptt-settings-ui-before.png b/dev-tasks/screenshots/2026-07-12-cat-tx-ptt-settings-ui-before.png new file mode 100644 index 0000000000000000000000000000000000000000..60b0e065cf51396d920e9572947b4aaf5b565cfe GIT binary patch literal 48028 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

icvy>2K0cF!a7P3F3%;7aNA6_Vj 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, `RigctldConnection` (TCP to `rigctld`, `\get_freq\n`/`\set_freq \n`), and `CatPollingService` — a single `IHostedService` that owns one `IRadioConnection` instance and polls it on a timer, publishing `ICatState`. +- **FT8 TX** (`ft8-tx` spec): `Ft8AudioSynthesiser` produces the GFSK waveform; `IPttController` is the seam between "have audio" and "make noise happen on a device"; `AudioOnlyPttController` is the only implementation, and it conflates two ideas that are actually separate — *asserting PTT* and *playing audio* — because for VOX they happen to be the same action (start audio → rig's VOX detects it → rig keys itself). +- **QSO automation** (`qso-answerer`, `qso-caller`): both resolve `IPttController` from DI and call `LoadAudio`/`KeyDownAsync`/`KeyUpAsync` at the right points in their state machines. Neither needs to change for this proposal — the whole point of the `IPttController` seam (per its own doc comment) is that new keying mechanisms plug in underneath it without touching the state machines. + +This change makes OpenWSFZ able to key a real transmitter under its own control for the first time. That is a materially different risk profile from anything shipped so far — CAT frequency control is read-mostly and reversible; PTT keying, done wrong, means a transmitter stuck on the air. Every decision below is made with that asymmetry in mind: prefer the design that fails toward "rig stays silent" over the one that fails toward "rig stays keyed." + +## Goals / Non-Goals + +**Goals:** +- Let an operator select, per-config, how PTT is asserted: existing VOX (`AudioOnlyPttController`, unchanged), a new CAT-command controller, or a new serial RTS/DTR controller. +- Guarantee CAT frequency polling and CAT PTT commands never interleave on the wire. +- Guarantee PTT is always released — on normal completion, on any exception, on cancellation, on `DisposeAsync`, on daemon shutdown, and on a hard watchdog ceiling if nothing else fires. +- Keep today's behaviour as the zero-config default; nothing breaks for an operator who never touches the new config fields. +- Produce a real hardware-acceptance gate, because none of the above can be proven by CI against a real radio. + +**Non-Goals:** +- No rig-specific dialect beyond the Kenwood/Yaesu-family command set `SerialCatConnection` already speaks. Rigs needing a different PTT dialect over serial CAT are out of scope for this change (they can use RTS/DTR instead, or a future change can add another `IRadioConnection` implementation). +- No mode-set, split, or any other rig-altering command. This change adds exactly one new rig-altering capability: PTT. +- No new UI. The existing CAT status badge is unchanged; no "TX keyed" indicator is added (see Decision 6). + **Amended 2026-07-12 — see Decision 6's own amendment note below**: a Settings-page UI for + `ptt` configuration (not a "TX keyed" indicator, which remains out of scope) is now in scope, + raised by the Captain during hardware acceptance after discovering there is no way to change + or verify `ptt.method` without hand-editing `config.json`. +- No change to `QsoAnswererService`/`QsoCallerService` state machines — they already consume `IPttController` correctly; this change only adds implementations underneath. + +## Decisions + +### 1. Wire serialization: `CatPollingService` becomes the single writer of the shared `IRadioConnection` + +**Decision:** `CatPollingService` already owns the one `IRadioConnection` instance used for polling. It gains a private `SemaphoreSlim(1,1)` gate and a new internal method (exposed to the CAT-command PTT controller via a narrow interface, e.g. `ICatPttGate` with `Task SetPttAsync(bool, CancellationToken)`) that acquires the same gate the poll loop uses before touching the connection. Nothing outside `CatPollingService` ever calls `IRadioConnection.SetPttAsync` (or `GetDialFrequencyMhzAsync`/`SetDialFrequencyMhzAsync`) directly — the CAT-command `IPttController` implementation depends only on `ICatPttGate`, mirroring how `ICatTuner`/`ICatController` are already the narrow public seams `CatPollingService` exposes for its other capabilities. + +**Why:** `SerialCatConnection` and `RigctldConnection` are not thread-safe with respect to each other's calls — they share one `SerialPort`/`TcpClient` and assume request/response calls do not overlap. The poll loop runs on a timer independent of QSO state; a PTT key-down can happen at any point in that cycle. Without serialization, a poll's `FA;` write could be immediately followed by a PTT `TX;` write before the poll's response is read, and the two responses could arrive interleaved — at best a spurious CAT error, at worst a misread response that causes a wrong action. + +**Alternatives considered:** +- *Separate `IRadioConnection` instances for polling vs. PTT* — rejected: most CAT interfaces are a single serial port or a single `rigctld` TCP session; opening two connections to the same port either fails outright (serial, exclusive access) or requires `rigctld` to arbitrate (which it does, but then we've just moved the interleaving risk into `rigctld`'s own request queue, and we've lost the ability to guarantee ordering between "stop polling frequency" and "assert PTT" that a bad rig might care about). +- *Lock-free, "last writer wins" with retries* — rejected: retrying a `TX;` that the rig may have half-processed is exactly the kind of ambiguity this change must not introduce. +- *Pause polling entirely for the duration of a QSO's TX phases* — considered but not needed: the semaphore already forces polling to simply wait its turn (a poll blocked for ~100 ms behind a PTT command is invisible to the operator; the reverse — PTT waiting behind a slow poll — is bounded by the existing 500 ms serial/TCP read timeout). + +### 2. `IRadioConnection.SetPttAsync` — same dialect family as the existing frequency commands + +**Decision:** Add `Task SetPttAsync(bool transmitting, CancellationToken cancellationToken = default)` to `IRadioConnection`. `SerialCatConnection` sends `TX;\r` to key and `RX;\r` to unkey — the same Kenwood/Yaesu-family dialect its `FA;`/`FA;` frequency commands already use, so no new rig-family assumption is introduced. `RigctldConnection` sends `\set_ptt 1\n` / `\set_ptt 0\n` and consumes the `RPRT` acknowledgement exactly as `SetDialFrequencyMhzAsync` already does for `\set_freq`. Like the frequency-set method, `SetPttAsync` is fire-and-forget on the wire (write, read+validate the ack where the protocol provides one, return) — it does not poll back to confirm the rig actually keyed, because `IRadioConnection` has no read-PTT-state query and adding one is out of scope. + +**Why:** Consistency with the existing, already-shipped dialect keeps `SerialCatConnection` a single coherent implementation rather than a grab-bag of per-command rig assumptions. Reusing the ack-consumption pattern from `SetDialFrequencyMhzAsync` (rather than inventing a new one) keeps `RigctldConnection` internally consistent and avoids the exact receive-buffer-misalignment class of bug that `SetDialFrequencyMhzAsync`'s own history (see its doc comments referencing "F-006 Root A") already had to fix once. + +**Alternatives considered:** +- *Read back and confirm PTT state after every set* — rejected for v1: `rigctld` does expose `\get_ptt`, but serial Kenwood/Yaesu dialect PTT read-back is inconsistent across rig families, and confirming here would still race with the watchdog described in Decision 4. Left as a documented Open Question / future hardening. + +### 3. Two new `IPttController` implementations, sharing an extracted WASAPI helper; `AudioOnlyPttController` untouched in behaviour + +**Decision:** Extract the WASAPI device-open/play/stop/release logic currently inline in `AudioOnlyPttController` into an internal, `WASAPI_SUPPORTED`-gated helper (e.g. `internal sealed class WasapiTxPlayer`) exposing `PlayAsync(float[] samples, string? deviceId, CancellationToken)` and `Stop()`. `AudioOnlyPttController` is refactored to call this helper but its public behaviour, timing, and requirements are unchanged — every existing scenario in the `ft8-tx` spec for `AudioOnlyPttController` continues to hold verbatim. + +Two new sealed classes are added: +- `CatPttController : IPttController` — `KeyDownAsync` calls `ICatPttGate.SetPttAsync(true)`, waits `PttLeadTimeMs`, then plays the loaded audio via `WasapiTxPlayer` and awaits completion; `KeyUpAsync` stops any in-progress playback, waits `PttTailTimeMs`, then calls `ICatPttGate.SetPttAsync(false)`. +- `SerialRtsDtrPttController : IPttController` — same sequencing, but asserts/de-asserts a raw serial control line (via the new `ISerialPort.SetRts`/`SetDtr`, see Decision 5) on its own independently-configured `ISerialPort` instead of going through `ICatPttGate`. + +Both new controllers wrap the watchdog described in Decision 4. + +**Why:** For VOX, "start audio" and "assert PTT" are the same physical action by construction — that is why `AudioOnlyPttController` never needed to separate them. CAT-command and RTS/DTR PTT are physically asserted *before* any audio should appear (real rigs need tens of milliseconds for the PA to come up cleanly) and released *after* audio ends (to avoid clipping the tail of the last symbol) — this is exactly the "lead time / tail time" pattern used throughout amateur radio interfacing, and it cannot be expressed by the existing single-action `AudioOnlyPttController`. Extracting the WASAPI helper avoids a second ~150-line copy of device-open/play/stop/dispose logic (and a second copy of its finally/dispose bug surface) in each new controller. + +**Alternatives considered:** +- *One `IPttController` implementation with a strategy enum inside it* — rejected: `IPttController`'s contract (`LoadAudio`/`KeyDownAsync`/`KeyUpAsync`/`DisposeAsync`) is already the strategy seam; a single class branching internally on a config enum just reinvents DI-time selection with an `if` statement, and makes each mechanism harder to unit-test in isolation (the existing `SerialCatConnection`/`RigctldConnection`/`AudioOnlyPttController` test suites all test one mechanism per class — this stays consistent with that). + +### 4. Failsafe watchdog: a single, mechanism-agnostic guard + +**Decision:** Both `CatPttController` and `SerialRtsDtrPttController` start a `CancellationTokenSource` timer (default `PttWatchdogTimeoutMs = 20000`, comfortably above one FT8 transmission's 12 640 ms) the instant PTT is asserted. If `KeyUpAsync` has not completed by the time the timer fires, the watchdog forces PTT release (bypassing tail-time) and logs at Error. The watchdog is cancelled the instant `KeyUpAsync` begins. PTT release is additionally guaranteed via `try/finally` around the entire key-down→play→key-up sequence (mirroring `AudioOnlyPttController`'s existing `_playerLock` finally pattern) and via `DisposeAsync`, so an exception anywhere in the sequence — including inside `WasapiTxPlayer` — still de-asserts PTT. + +**Why:** This is the one part of the change where "silently do nothing" is worse than "do something loud." A hung WASAPI call, a cancelled task that doesn't unwind cleanly, or an unhandled exception must not leave a real transmitter keyed indefinitely. Twenty seconds is short enough to bound real-world harm (no single FT8 exchange step runs anywhere near that long) and long enough to never fire during correct operation, so it should never surprise an operator in normal use — only during a genuine bug, where it is exactly the last line of defence this change exists to provide. + +### 5. `ISerialPort` gains RTS/DTR control; RTS/DTR PTT is wired independently of CAT + +**Decision:** `ISerialPort` gains `bool RtsEnable { get; set; }` and `bool DtrEnable { get; set; }` (mirroring `System.IO.Ports.SerialPort`'s own properties 1:1, so `SerialPortWrapper` is a one-line pass-through each — same pattern as its existing `IsOpen`/`ReadTimeout`). `SerialRtsDtrPttController` opens its **own** `ISerialPort` instance, constructed from its own config (`Ptt.SerialPort`, independent of `cat.serialPort`), and does not share a connection with `CatPollingService` at all. + +**Why:** In real operator setups, CAT (if used) and RTS/DTR PTT are frequently on different physical interfaces entirely — e.g. CAT over a USB-CI-V cable direct to the rig, PTT via a separate USB-serial adapter wired to a soundcard interface's RTS pin, or PTT-only with no CAT link at all (an operator who wants software PTT but has no CAT-capable rig, or prefers not to enable frequency polling). Forcing RTS/DTR PTT to reuse `cat.serialPort` would make that arrangement impossible to configure and would also drag RTS/DTR PTT into Decision 1's serialization gate for no reason — it is a different transport with no shared state to protect. + +### 6. Configuration: additive fields on `CatConfig`, default preserves today's behaviour exactly; no new UI + +**Decision:** Add a `PttConfig` record (new file, referenced from `AppConfig`, not nested inside `CatConfig` — PTT method is orthogonal to whether CAT is even enabled: an operator can run `SerialRtsDtr` PTT with `cat.enabled = false`): + +```csharp +public sealed record PttConfig +{ + public string Method { get; init; } = "AudioVox"; // AudioVox | CatCommand | SerialRtsDtr + public string SerialPort { get; init; } = ; + public string SerialLine { get; init; } = "Rts"; // Rts | Dtr — only used when Method == SerialRtsDtr + public int LeadTimeMs { get; init; } = 50; + public int TailTimeMs { get; init; } = 50; + public int WatchdogTimeoutMs { get; init; } = 20000; +} +``` + +Unknown/missing `ptt` key deserialises to all defaults (`Method = "AudioVox"`), which is byte-for-byte today's behaviour — `Program.cs`'s DI registration switches on `configStore.Current.Ptt.Method` at startup the same way it already switches on `#if WASAPI_SUPPORTED` today, registering exactly one `IPttController`. An invalid/unrecognised `Method` value logs a Warning and falls back to `AudioVox`, matching the existing `CatConfig.RigModel` unknown-value handling (FR-034). + +No new UI ships with this change. The existing CAT status badge (`ICatState.Status`) already tells the operator whether the CAT link itself is up; per FR-016, a "PTT currently asserted" indicator would need its own fully-working backend-to-UI round trip designed, reviewed, and tested, which is out of scope here — it can be proposed as a small follow-up once the underlying keying mechanisms exist and have been hardware-verified. + +**Alternatives considered:** +- *Nest `Ptt` inside `CatConfig`* — rejected per above: couples PTT method selection to CAT being the owning capability, which is false for `SerialRtsDtr` and even for `AudioVox` (today's default has never depended on `CatConfig` at all). +- *Reuse `cat.serialPort`/`cat.baudRate` for `SerialRtsDtr`* — rejected per Decision 5. + +**Amendment (2026-07-12) — "no new UI" reversed for `ptt` *configuration* only:** During hardware +acceptance (gates 14–15), the Captain discovered there is no way to change or verify `ptt.method` +without hand-editing `config.json` and restarting — which is itself how a null-`ptt`-guard defect +(`dev-tasks/2026-07-12-cat-tx-ptt-null-ptt-config-guard.md`) went undiagnosed for a full session: an +operator has no visibility into what `ptt` section is actually persisted. A Settings-page UI is now +in scope, added as tasks.md section 17. This does **not** reverse the rest of Decision 6 — no +"TX keyed" / watchdog-trip indicator is added; the scope is strictly the same fields already +described in this Decision's `PttConfig` record, exposed as editable form controls in a new +"PTT Config" fieldset alongside "CAT rig connection" (split side-by-side per the Captain's layout +sketch), plus one new capability with its own safety analysis below: a **Test** button. + +**Test-button decision:** Per `IRadioConnection.SetPttAsync`'s own doc comment (Decision 2), no +implementation ever reads back PTT state — there is no way to confirm a rig physically keyed. +Given that hard limitation, Test performs a brief (~200–300 ms), **silent** software pulse — assert +PTT → tiny silence buffer → release — against the currently-**running** `IPttController` singleton +(i.e. whatever `Ptt.Method` the daemon was last started with; unsaved, un-restarted form edits are +not reflected, consistent with `Ptt.Method` already being read once at DI-registration time). Pass +means the assert/release commands completed without throwing (a real CAT ACK or a real RTS/DTR line +toggle happened); it explicitly does **not** mean the rig visibly keyed — the operator must still +watch the rig. No confirmation dialog gates the click (matches every other action already on this +page — Save, Retry, Refresh — none of which prompt "are you sure?"). + +**Safety-critical finding, must be fixed as part of this scope, not deferred:** `CatPttController`/ +`SerialRtsDtrPttController` have **no internal call-serialisation** — `KeyDownAsync`/`KeyUpAsync` +assume exactly one caller (the active `QsoAnswererService`/`QsoCallerService`) ever calls them. +A Test click is a second, independent caller of the same DI singleton. Without a guard, a Test +firing while a real QSO is mid-transmission would call the *same* `KeyDownAsync`/`KeyUpAsync` +sequence concurrently: the shared `_watchdog` would be re-armed (silently resetting a real +transmission's failsafe countdown) and, worse, the Test's own short `KeyUpAsync` would set +`_pttAsserted = false` and de-assert PTT — **physically unkeying a real, in-progress over-the-air +transmission**, which is exactly the failure mode this entire change's stated design principle +("prefer the design that fails toward 'rig stays silent' over the one that fails toward 'rig stays +keyed'") exists to prevent, just inverted (fails toward "rig stops mid-transmission" instead). Two +layers of defence, both required: +1. `WebApp.cs`'s test endpoint checks `IQsoController.Keying` and rejects with 409 if a real QSO is + currently keying — fast, clear feedback for the common case. +2. `CatPttController` and `SerialRtsDtrPttController` each gain a private `SemaphoreSlim(1,1)` + acquired for the full `KeyDownAsync`→`KeyUpAsync` critical section, so even a request that races + past check (1) queues behind the in-flight real transmission instead of interleaving with it. + This mirrors Decision 1's own wire-serialisation gate, applied one layer up, and permanently + closes this hazard for this caller and any future one — not just the Settings-page Test button. + +## Risks / Trade-offs + +- **[Risk] A rig's serial CAT dialect accepts `TX;`/`RX;` but interprets them differently than expected (e.g. `TX;` toggles rather than sets)** → Mitigation: this is precisely why the hardware-acceptance gate (see tasks.md) requires an operator to visually confirm actual rig behaviour on real hardware before this change is considered done; it cannot be fully de-risked by unit tests against a fake `ISerialPort`. +- **[Risk] Watchdog fires during a legitimately slow transmission (e.g. a saturated USB-audio path delays WASAPI playback)** → Mitigation: `WatchdogTimeoutMs` is configurable; the default 20 s carries ~7 s of margin over the fixed 12.64 s FT8 transmission length, and the hardware-acceptance plan includes a step that deliberately validates the watchdog fires only when expected. +- **[Risk] Extracting `WasapiTxPlayer` out of `AudioOnlyPttController` regresses its existing, already-shipped behaviour** → Mitigation: the existing `AudioOnlyPttController` unit test suite is the regression guard; tasks.md requires it to pass unmodified (assertions unchanged) against the refactored implementation before any new controller is added. +- **[Risk] `CatPollingService` becoming the gatekeeper for PTT commands makes it a more critical, harder-to-test component** → Trade-off accepted: the alternative (letting `CatPttController` open its own competing `IRadioConnection`) reintroduces the interleaving risk Decision 1 exists to remove. `ICatPttGate` is a narrow, single-method seam that keeps `CatPttController`'s own unit tests trivial (mock `ICatPttGate`, assert it's called in the right order with the right value). + +## Migration Plan + +- Purely additive: no existing config field changes meaning, no existing `IRadioConnection`/`IPttController` consumer call site changes (both interfaces gain members but no existing method loses parameters or changes behaviour). +- Deploy as a normal minor-version bump per `release-versioning`; no data migration, no config migration script needed (missing `ptt` key = default = today's behaviour). +- Rollback: reverting the change is safe at any point before an operator opts into `CatCommand`/`SerialRtsDtr` in their config, since `AudioVox` remains the default and is functionally identical to pre-change behaviour. + +## Open Questions + +- Should a future change add PTT read-back confirmation (`rigctld`'s `\get_ptt`) as an optional post-key-down sanity check? Deferred — see Decision 2's alternatives. +- Should the watchdog-fired event be surfaced to the operator via a WebSocket event (distinct from a log line) so a stuck-PTT recovery is visible in the UI without reading logs? Deferred with the rest of Decision 6's "no new UI" scope — worth revisiting once hardware acceptance has run at least once and it's known how often (if ever) the watchdog actually fires in practice. diff --git a/openspec/changes/cat-tx-ptt/hardware-acceptance.md b/openspec/changes/cat-tx-ptt/hardware-acceptance.md new file mode 100644 index 0000000..81a8f96 --- /dev/null +++ b/openspec/changes/cat-tx-ptt/hardware-acceptance.md @@ -0,0 +1,341 @@ +# Hardware Acceptance Gates — cat-tx-ptt + +**Gates:** 14 (CAT-command PTT), 15 (Serial RTS/DTR PTT), 16 (Confirmed two-way QSO — release gate R3) +**Tasks:** 14.1–14.4, 15.1–15.4, 16.1–16.3 +**Required hardware:** a CAT-capable rig (for Gate 14 and, optionally, as the CAT link under test in Gate 15/16), and a serial interface with RTS or DTR wired to the rig's PTT input (for Gate 15). CI cannot key a real radio, so none of these steps have an automated substitute — they must be run and ticked by a human before this change is archived. + +This document is the "what to look for" companion to `tasks.md` sections 14–16. Tick the checkbox in `tasks.md` as each step below is completed. + +**Safety first:** before starting either gate, disconnect the antenna or attach a dummy load, and tell anyone nearby that the rig may key unexpectedly during testing. This change is explicitly the first in the project able to key a transmitter under software control — treat every step as if a mistake could put unintended RF on the air, because it could. + +> **2026-07-12 — Gates 14–16 must be re-attempted from scratch.** The first live attempt at Gate 16 +> (session `logs/openswfz-20260712T152156Z.log`, HB9HYO) found that `QsoCallerService`/ +> `QsoAnswererService` never called `KeyUpAsync` after a normal transmission — every TX cycle held PTT +> asserted for ~7+ seconds past the intended tail time, relying entirely on `PttWatchdog`'s 20 s +> failsafe to ever release it, which broke FT8 slot timing and is why that QSO could not be completed. +> Fixed in `tasks.md` section 18 (`dev-tasks/2026-07-12-cat-tx-ptt-missing-keyup-after-transmit.md`). +> **None of the keying observed before this fix — including any informal "the radio responds" +> confirmation — is valid evidence for Gates 14/15/16.** The rig keying at all is necessary but not +> sufficient; it must also unkey promptly for Gate 16 to mean anything. + +> **2026-07-12 (same day, after the fix) — Gate 15.1 and Gate 16 re-attempted and passed; +> Gate 14 and the rest of Gate 15 remain outstanding.** Two full, genuine over-the-air FT8 QSOs +> were completed with `ptt.method = "SerialRtsDtr"` running the fixed build (`tasks.md` section 18): +> session evidence below. In both sessions, every `KeyDown — PTT asserted` line was followed by a +> `KeyUp — PTT released` line roughly 12.8 s later (matching the encoded audio length + `tailTimeMs`), +> and **zero** `watchdog fired`/`forcing PTT release` lines occurred in either session log — direct +> confirmation the rig no longer relies on the 20 s failsafe to unkey. This is the evidence for +> Gate 16 (16.1–16.3, below). +> +> **Not covered by this re-attempt — still outstanding:** +> - **Gate 14 (CAT-command PTT) was not exercised at all.** No `CatPttController` log line appears +> in any session from this day. The dev-task's root-cause analysis states the missing-`KeyUpAsync` +> bug affects `CatPttController` identically, and the fix is applied identically to both +> controllers' shared callers, so there is every reason to expect Gate 14 will pass — but that is +> an expectation, not evidence. Gate 14 must still be run for real against a CAT-connected rig +> before this change is archived. +> - **Gate 15.2 (DTR line)** — only `Rts` was exercised; no `SerialLine = "Dtr"` session exists yet. +> - **Gate 15.3 (CAT-disabled independence)** — CAT was connected (`SerialCat`) throughout both +> sessions below; `cat.enabled = false` was never tested alongside `SerialRtsDtr` PTT. +> - **Gate 15.4 / 14.3 (deliberately forcing the watchdog, post-fix)** — the watchdog firing +> observed pre-fix (`logs/openswfz-20260712T152156Z.log`, 6 forced releases) is evidence the +> *original bug* existed, not that the failsafe itself still correctly force-releases when +> genuinely needed after this change. `PttWatchdogTests.cs` covers this at the unit level +> (re-run unmodified per task 18.5) but a deliberate hardware trip has not been repeated since +> the fix landed. + +--- + +## Prerequisites — do this once before any gate + +### 1. Build and locate the daemon + +```powershell +cd D:\Projects\claude\OpenWSFZ +dotnet build -c Release +cd src\OpenWSFZ.Daemon\bin\Release\net10.0 +.\OpenWSFZ.Daemon.exe +``` + +Watch the log in a second terminal: +```powershell +Get-Content .\logs\*.log -Wait +``` + +### 2. Verify the rig is ready + +- Power on the rig, tune to a quiet FT8 frequency (e.g. 7.074 MHz) +- Antenna disconnected or dummy load attached (see Safety note above) +- Confirm no other application (WSJT-X, `rigctld`, etc.) holds the port you're about to configure + +### 3. Open the Settings page + +`http://127.0.0.1:/settings.html` — port is printed in the daemon's startup log. + +--- + +## Gate 14 — CAT-command PTT + +### 14.1 — Configure and arm a test transmission + +In Settings, set: +- `ptt.method` → `CatCommand` +- (ensure CAT itself is configured and `Connected` — Gate 14 requires the CAT link to be up) +- Save + +Trigger a single transmission (e.g. arm the QSO caller/answerer, or use whatever manual TX-test affordance exists at implementation time). + +**What to look for on the rig:** +- The TX indicator (LED / meter) comes on within roughly `leadTimeMs` (default 50 ms) of the daemon logging that it has asserted PTT — this will look instantaneous to the eye but should not visibly lag by more than a couple of hundred milliseconds +- The rig unkeys within roughly `tailTimeMs` (default 50 ms) of audio playback ending + +**What to look for in the log:** +``` +CatPttController: KeyDown — PTT asserted (CAT). +CatPttController: KeyUp — PTT released (CAT). +``` + +**Fail criteria:** the rig never keys, keys but never unkeys, or keys noticeably later/earlier than the audio. + +**✅ Mark 14.1 complete once a clean key-down/key-up cycle is observed on the rig.** + +--- + +### 14.2 — Verify CAT polling is unaffected + +While transmissions are happening (repeat 14.1 a few times, or let an automated QSO run for a few cycles), watch the main-page status bar. + +**What to look for:** +- The CAT status badge stays `Connected` throughout +- The displayed dial frequency keeps updating at the normal poll cadence — it should not freeze, lag, or show stale data during or immediately after a TX cycle + +**Fail criteria:** the badge drops to `Error`, or the frequency display visibly stalls around TX events. Either would indicate the wire-serialization gate (design.md Decision 1) isn't working under real timing. + +**✅ Mark 14.2 complete once polling is confirmed unaffected across several TX cycles.** + +--- + +### 14.3 — Force the watchdog and confirm automatic unkey + +Temporarily set `ptt.watchdogTimeoutMs` to a small value (e.g. `500`) and arrange for a transmission where playback cannot complete before that (a short pre-encoded buffer, or a deliberately induced delay, whatever the implementation's test seam supports) — the intent is a key-down that would otherwise remain asserted well past a real 12.64 s transmission. + +**What to look for on the rig:** it unkeys on its own, without any `KeyUpAsync` ever being called normally. + +**What to look for in the log:** +``` +CatPttController: watchdog fired after ms — forcing PTT release. +``` +logged at Error. + +Restore `watchdogTimeoutMs` to its normal value afterward. + +**✅ Mark 14.3 complete once the watchdog is confirmed to unkey the rig on its own.** + +--- + +### 14.4 — Confirm no other rig-altering command appears + +Review the full log for the Gate 14 session. + +**What must be absent:** any mode-set command, or any frequency-set the operator did not explicitly request via the tuning UI. + +**What to verify on the rig:** mode unchanged; frequency only changed when the operator changed it. + +**✅ Mark 14.4 complete once confirmed.** + +--- + +## Gate 15 — Serial RTS/DTR PTT + +### 15.1 — Configure and test the RTS line + +In Settings, set: +- `ptt.method` → `SerialRtsDtr` +- `ptt.serialPort` → the port wired to your PTT interface (this SHOULD be a different port than any CAT connection you have configured, to genuinely exercise the independence claimed in design.md Decision 5) +- `ptt.serialLine` → `Rts` +- Save, then trigger a transmission as in 14.1 + +**What to look for on the rig:** TX indicator comes on/off in step with playback, same timing expectations as 14.1. + +**✅ Mark 15.1 complete once a clean key-down/key-up cycle is observed via RTS.** + +**Evidence (2026-07-12, post-fix, `ptt.serialLine = "Rts"`):** two complete FT8 QSOs, both driving +real key/unkey cycles with no watchdog involvement. `KeyDown`/`KeyUp` timestamps below are local +(+02:00); every cycle is a distinct transmission (CQ, report, retries, RR73/73) within the same QSO: + +| Session (local log file, gitignored) | KeyDown | KeyUp | Δ | +|---|---|---|---| +| `openswfz-20260712T162611Z.log` | 18:27:30.761 | 18:27:43.586 | 12.83 s | +| " | 18:28:00.668 | 18:28:13.483 | 12.82 s | +| " | 18:28:30.649 | 18:28:43.455 | 12.81 s | +| " | 18:29:00.641 | 18:29:13.467 | 12.83 s | +| " | 18:30:00.746 | 18:30:13.564 | 12.82 s | +| " | 18:30:30.787 | 18:30:43.595 | 12.81 s | +| " | 18:31:30.770 | 18:31:43.579 | 12.81 s | +| `openswfz-20260712T164315Z.log` | 18:43:45.733 | 18:43:58.550 | 12.82 s | +| " | 18:44:15.590 | 18:44:28.408 | 12.82 s | +| " | 18:44:45.730 | 18:44:58.544 | 12.81 s | +| " | 18:45:15.771 | 18:45:28.599 | 12.83 s | + +Eleven TX cycles across two real QSOs, every one releasing PTT ~12.8 s after key-down (consistent +with 12.64 s of encoded audio + `tailTimeMs`) — not the pre-fix pattern of ~20 s watchdog-forced +releases. `grep -c "watchdog fired" ` → `0`. + +**Port distinctness (the other half of 15.1's claim) is not independently confirmed from the logs** +— the session logs prove the RTS line keyed/unkeyed correctly but do not record the actual COM port +names in use, so whether `ptt.serialPort` was genuinely a different physical port than the CAT +connection's port is not verifiable from this evidence alone. Operator to confirm before ticking +15.1 in full. + +--- + +### 15.2 — Test the DTR line (if your interface supports it) + +Repeat 15.1 with `ptt.serialLine` → `Dtr`. If your interface hardware only supports one of the two lines, note that in this file and mark this step complete based on confirming the *other* line correctly does nothing when unselected (i.e. no unintended keying) rather than skipping it outright. + +**✅ Mark 15.2 complete once DTR behaviour is confirmed (or the hardware limitation is documented).** + +--- + +### 15.3 — Verify independence from CAT + +Set `cat.enabled` → `false`, keep `ptt.method` → `SerialRtsDtr`. Trigger a transmission. + +**What to look for:** PTT still keys/unkeys correctly with no CAT connection open at all. + +**Fail criteria:** any error or failure to key that only occurs because CAT is disabled — that would mean the two are not actually independent as designed. + +**✅ Mark 15.3 complete once confirmed.** + +--- + +### 15.4 — Force the watchdog (RTS/DTR) + +Same procedure as 14.3, but with `ptt.method = "SerialRtsDtr"`. Confirm the line de-asserts automatically and the same Error-level log line (naming the serial controller) appears. + +**✅ Mark 15.4 complete once confirmed.** + +--- + +## Gate 16 — Confirmed two-way QSO (release gate R3) + +This is the actual v1.0 milestone: a genuine, complete, over-the-air FT8 QSO conducted with either PTT method configured (whichever the operator intends to use day-to-day) and `QsoAnswererService` or `QsoCallerService` driving it end-to-end. + +### 16.1 — Complete one real QSO + +Restore the antenna connection (Safety note above assumed a dummy load for Gates 14–15; this gate requires actual RF). Run a normal operating session until one full QSO completes (RR73 exchanged both ways, or the local station's equivalent completion state). + +**✅ 16.1 — complete.** Two full, genuine over-the-air FT8 QSOs completed 2026-07-12, both with +`ptt.method = "SerialRtsDtr"`, RR73/73 exchanged both ways in each. See 16.3 for the evidence record. + +### 16.2 — Verify the ADIF record + +Open `ADIF.log` and confirm one new record was appended for this QSO, with the fields required by the `qso-answerer`/`adif-log` specs populated correctly (call, grid if known, RST sent/received, date/time on/off, frequency/band). + +**✅ 16.2 — complete, with one pre-existing gap noted (not caused by this change).** Both QSOs +appended a well-formed `ADIF.log` record: `CALL`, `RST_SENT`/`RST_RCVD`, `QSO_DATE`/`TIME_ON`/ +`QSO_DATE_OFF`/`TIME_OFF`, `OPERATOR`, `MY_GRIDSQUARE`, `MODE`, `FREQ`/`BAND` all populated +correctly. **`GRIDSQUARE` (partner grid) is absent from both records**, even though the partner's +grid was visible in the decode stream for both contacts. Traced to source, not a regression from +this dev-task: +- QSO 2 was driven start-to-finish by `QsoCallerService`, which has an existing, explicitly + documented gap: `QsoCallerService.cs:833` — `PartnerGrid = null, // caller does not capture + partner's grid in WaitRr73`. +- QSO 1 completed via `QsoAnswererService`'s mid-exchange jump-in path + (`ExecuteJumpInAsync`, `QsoAnswererService.cs:872`), which is likewise explicitly documented as + not having the partner's grid available: `_partnerGrid = null; // not available in mid-exchange + jump-in`. The same jump-in resets `_qsoStartUtc` (`:875`), which is also why QSO 1's `TIME_ON` + equals `TIME_OFF` — the operator manually aborted via the web UI mid-QSO and a second jump-in + re-engaged 13 s before completion, so the recorded "start" time is that late re-engagement, not + the original CQ answer four minutes earlier. + +Neither gap was introduced by the `KeyUpAsync` fix — both predate it and are pre-existing scope +limitations of the grid-capture logic in each service. Worth a follow-up dev-task, but not +merge-blocking for `cat-tx-ptt` and not evidence against Gate 16 (a QSO with an incomplete +`GRIDSQUARE` field is still a genuinely confirmed, correctly-logged QSO for R3's purposes). + +### 16.3 — Document the evidence + +Below, record: date (UTC), band/frequency, PTT method used, and partner callsign (Q-prefix synthetic call per NFR-021 unless the contact is with a public, consenting station — see the project's privacy/GDPR callsign policy before writing a real call here). + +**✅ 16.3 — complete.** Both contacts were with real, non-consenting third-party stations, so per +NFR-021 / the project's privacy-GDPR callsign policy their callsigns are **not** recorded in this +VCS-tracked file — `ADIF.log`, `ALL.TXT`, and `logs/*.log` are all gitignored specifically for this +reason and hold the full, unredacted record locally for anyone who needs to verify against this +write-up. + +``` +QSO 1 +Date (UTC): 2026-07-12 +Band / frequency: 40 m / 7.074 MHz +PTT method: SerialRtsDtr +Partner callsign: [real callsign — withheld per NFR-021; see local ADIF.log, TIME_ON 163130Z] +QSO complete (log): openswfz-20260712T162611Z.log, 18:31:43.580 local +ADIF written: openswfz-20260712T162611Z.log, 18:32:40.141 local +External confirmation: not confirmed via QRZ/LoTW as of this writing + +QSO 2 +Date (UTC): 2026-07-12 +Band / frequency: 40 m / 7.074 MHz +PTT method: SerialRtsDtr +Partner callsign: [real callsign — withheld per NFR-021; see local ADIF.log, TIME_ON 164345Z] +QSO complete (log): openswfz-20260712T164315Z.log, 18:45:28.599 local +ADIF written: openswfz-20260712T164315Z.log, 18:45:38.050 local +External confirmation: confirmed on QRZ logbook (operator-supplied screenshot, not committed to VCS) +``` + +--- + +## After completing all three gates + +### Tick the tasks + +Open `openspec/changes/cat-tx-ptt/tasks.md` and change every `- [ ]` in sections 14, 15, and 16 to `- [x]`. + +**Status as of 2026-07-12:** 16.1, 16.2, and 16.3 are ticked — the evidence above satisfies them. +15.1 is **not yet ticked**, pending the operator confirming the port-distinctness half of its claim +(see the note under 15.1's evidence above). 14.1–14.4, 15.2, 15.3, and 15.4 remain unticked and +genuinely untested on hardware — do not tick any of these until they are actually run. + +### Commit + +Once **all** of sections 14, 15, and 16 are genuinely ticked (not just 16, as of 2026-07-12): + +```powershell +git add openspec/changes/cat-tx-ptt/tasks.md openspec/changes/cat-tx-ptt/hardware-acceptance.md +git commit -m "chore(cat-tx-ptt): mark hardware acceptance gates 14-16 complete" +``` + +Until then, commit the partial evidence honestly, e.g.: + +```powershell +git add openspec/changes/cat-tx-ptt/tasks.md openspec/changes/cat-tx-ptt/hardware-acceptance.md +git commit -m "chore(cat-tx-ptt): Gate 16 (R3) confirmed post-fix; Gate 14 and rest of Gate 15 still outstanding" +``` + +### Archive + +Do **not** archive until Gate 14 and Gates 15.2–15.4 are also run — archiving with outstanding +hardware gates would let this change ship without ever having proven CAT-command PTT works at all. +Once every gate is genuinely ticked, return to QA and ask to archive the change: + +> "archive the change" + +--- + +## Quick Reference — Config Keys + +```json +{ + "ptt": { + "method": "AudioVox", + "serialPort": "COM7", + "serialLine": "Rts", + "leadTimeMs": 50, + "tailTimeMs": 50, + "watchdogTimeoutMs": 20000 + } +} +``` + +`method` is one of `AudioVox` (default, unchanged VOX-style behaviour), `CatCommand` (Gate 14), or `SerialRtsDtr` (Gate 15). Config file location: beside the executable, same as every other setting in this project. diff --git a/openspec/changes/cat-tx-ptt/proposal.md b/openspec/changes/cat-tx-ptt/proposal.md new file mode 100644 index 0000000..55f5230 --- /dev/null +++ b/openspec/changes/cat-tx-ptt/proposal.md @@ -0,0 +1,37 @@ +**User-facing:** yes + +## Why + +v1.0 is defined (`REQUIREMENTS.md`, `IMPLEMENTATION_PLAN.md`) as the point where OpenWSFZ can make a confirmed two-way FT8 contact: RX + CAT control + TX. RX and CAT (frequency read/set) and TX (audio synthesis) all exist, but nothing in the software can actually key the transmitter — the only `IPttController` today (`AudioOnlyPttController`) plays TX audio out a sound device and relies entirely on the rig's own VOX to key up. `IRadioConnection` explicitly documents that no PTT command exists yet. Closing this gap is the last item standing between the current release and the v1.0 gate. + +## What Changes + +- Extend `IRadioConnection` with a `SetPttAsync(bool transmitting, CancellationToken)` member (amending the existing "no PTT" restriction, same pattern as the earlier `SetDialFrequencyMhzAsync` amendment). `SerialCatConnection` implements it with the Kenwood/Yaesu-family `TX;`/`RX;` commands (same dialect as its existing `FA;` frequency commands); `RigctldConnection` implements it with `\set_ptt 1\n` / `\set_ptt 0\n`, consuming the `RPRT` acknowledgement the same way `SetDialFrequencyMhzAsync` already does. +- Introduce a serialization mechanism inside `CatPollingService` so frequency polling and CAT PTT commands can never interleave bytes on the same serial port / TCP socket — the single highest-risk part of this change. +- Add two new `IPttController` implementations alongside the existing `AudioOnlyPttController` (which is kept, unmodified in behaviour, as the default): a CAT-command controller that sequences `SetPttAsync` around the existing WASAPI TX-audio playback, and a serial RTS/DTR line-toggle controller that keys PTT via a raw control line independent of any CAT link. The WASAPI playback logic currently embedded in `AudioOnlyPttController` is extracted into a shared internal helper so it is not duplicated. +- Add an operator-selectable PTT method to configuration (`AudioVox` | `CatCommand` | `SerialRtsDtr`), defaulting to `AudioVox` so existing configurations are unaffected. `SerialRtsDtr` gets its own independent serial port + line (RTS or DTR) configuration, since in practice that wiring is frequently on a different port than the CAT link. +- Add a hard watchdog ceiling and guaranteed-release semantics (exception paths, `DisposeAsync`, daemon shutdown) so a stuck key-down can never leave the rig transmitting. +- Extend `ISerialPort` / `SerialPortWrapper` with RTS/DTR line control (currently absent) to support the new controller and its unit tests. +- Add a `FR-0##` requirement (and `REQUIREMENTS.md` version-history row) documenting the new capability, following the FR-045 amendment style. +- Ship a hardware-acceptance.md manual test plan (no CI substitute exists for keying a real radio) covering both new PTT mechanisms, the failsafe watchdog, and a genuine confirmed two-way QSO, tying into `IMPLEMENTATION_PLAN.md` release gate R3. + +No new UI is introduced by this change — the existing CAT status badge (already driven by `ICatState.Status`) is sufficient; per FR-016 no speculative "PTT active" indicator is added without one being explicitly requested. + +## Capabilities + +### New Capabilities + +_(none — this change extends two existing capabilities rather than introducing a new one)_ + +### Modified Capabilities + +- `cat-control`: `IRadioConnection` gains `SetPttAsync`; `SerialCatConnection` and `RigctldConnection` implement it; `CatPollingService` gains a serialization mechanism guaranteeing polling and PTT commands never interleave on the wire. +- `ft8-tx`: the `IPttController` abstraction gains two new implementations (CAT-command and serial RTS/DTR), a shared WASAPI-playback helper, a configurable PTT method selector, lead/tail timing, and a failsafe watchdog. `AudioOnlyPttController`'s existing behaviour and requirements are unchanged. + +## Impact + +- **Code**: `OpenWSFZ.Abstractions` (`IRadioConnection`, `CatConfig` or a new `PttConfig`), `OpenWSFZ.Rig` (`SerialCatConnection`, `RigctldConnection`, `ISerialPort`, `SerialPortWrapper`), `OpenWSFZ.Daemon` (`CatPollingService`, `AudioOnlyPttController` refactor, two new `IPttController` implementations, `Program.cs` DI wiring). +- **Config**: additive fields only; existing `openswfz.json` files continue to work unchanged (default PTT method preserves today's VOX-only behaviour). +- **Hardware**: requires a CAT-capable rig and/or a serial interface with RTS/DTR wired to PTT to validate; a manual hardware-acceptance gate is added, matching the precedent set by `p16-cat-control`. +- **Requirements**: one new FR added to `REQUIREMENTS.md`. +- **Safety**: this is the first change in the project that can key a real transmitter under software control — failsafe/watchdog behaviour is treated as a hard requirement, not a nice-to-have. diff --git a/openspec/changes/cat-tx-ptt/specs/cat-control/spec.md b/openspec/changes/cat-tx-ptt/specs/cat-control/spec.md new file mode 100644 index 0000000..9743d0e --- /dev/null +++ b/openspec/changes/cat-tx-ptt/specs/cat-control/spec.md @@ -0,0 +1,199 @@ +## MODIFIED Requirements + +### Requirement: IRadioConnection abstraction + +`OpenWSFZ.Abstractions` SHALL define a public interface `IRadioConnection` with the following members: + +```csharp +Task ConnectAsync(CancellationToken cancellationToken = default); +Task DisconnectAsync(CancellationToken cancellationToken = default); +Task GetDialFrequencyMhzAsync(CancellationToken cancellationToken = default); +Task SetDialFrequencyMhzAsync(double frequencyMHz, CancellationToken cancellationToken = default); +Task SetPttAsync(bool transmitting, CancellationToken cancellationToken = default); +bool IsConnected { get; } +``` + +The interface SHALL be the only type that `CatPollingService` and any consumer outside `OpenWSFZ.Rig` depend upon. + +`SetDialFrequencyMhzAsync` and `SetPttAsync` are both fire-and-forget commands: each sends its instruction to the rig and returns without reading back confirmation that the rig accepted it. The next `GetDialFrequencyMhzAsync` poll will reflect a frequency change if the rig accepted it; `IRadioConnection` does not define any PTT-state query. + +The earlier p16 restriction prohibiting frequency-set commands was amended by the frequency-management change to permit frequency-set. That restriction is **hereby further amended**: `SetPttAsync` is now a permitted operation on this interface. No mode-set or other rig-altering commands beyond frequency-set and PTT are introduced by this change. + +#### Scenario: IRadioConnection is defined in OpenWSFZ.Abstractions + +- **WHEN** `OpenWSFZ.Abstractions` is compiled +- **THEN** the assembly SHALL export a public interface named `IRadioConnection` with the six members listed above + +#### Scenario: SetDialFrequencyMhzAsync is a member of IRadioConnection + +- **WHEN** the interface is reflected at runtime +- **THEN** `SetDialFrequencyMhzAsync` SHALL be present with signature `Task SetDialFrequencyMhzAsync(double, CancellationToken)` + +#### Scenario: SetPttAsync is a member of IRadioConnection + +- **WHEN** the interface is reflected at runtime +- **THEN** `SetPttAsync` SHALL be present with signature `Task SetPttAsync(bool, CancellationToken)` + +--- + +### Requirement: SerialCatConnection implements IRadioConnection + +`OpenWSFZ.Rig` SHALL provide a class `SerialCatConnection` that implements `IRadioConnection` using `System.IO.Ports.SerialPort` and the serial CAT command set. The class SHALL be constructable with a port name and baud rate. All serial I/O SHALL use a 500 ms `ReadTimeout`. + +`SerialCatConnection` SHALL support three CAT operations: + +- **Frequency query**: `GetDialFrequencyMhzAsync` sends `FA;` and parses the VFO-A frequency from the rig's response. +- **Frequency set**: `SetDialFrequencyMhzAsync` sends `FA;` with the Hz value zero-padded to the rig's native digit width (self-calibrated from the first successful query response). No mode-set commands SHALL be sent by this class. +- **PTT set**: `SetPttAsync` sends `TX;` to key the transmitter (`transmitting = true`) or `RX;` to unkey it (`transmitting = false`) — the same Kenwood/Yaesu command dialect family used by the existing `FA;`/`FA;` frequency commands. No read-back confirmation is performed. + +The rig's native digit width SHALL be discovered automatically: on the first successful `GetDialFrequencyMhzAsync` call, the class SHALL record the digit count of the FA response and use that same width for all subsequent `SetDialFrequencyMhzAsync` calls. Until the first successful query, `SetDialFrequencyMhzAsync` SHALL fall back to an 11-digit format. + +#### Scenario: ConnectAsync opens the serial port + +- **WHEN** `ConnectAsync` is called with a valid port name and baud rate +- **THEN** the serial port SHALL be opened and `IsConnected` SHALL return `true` + +#### Scenario: ConnectAsync throws when port is unavailable + +- **WHEN** `ConnectAsync` is called with a port name that does not exist or is in use +- **THEN** the method SHALL throw an exception (propagating `SerialPort.Open()` failure) and `IsConnected` SHALL remain `false` + +#### Scenario: GetDialFrequencyMhzAsync sends FA; and parses response + +- **WHEN** `GetDialFrequencyMhzAsync` is called on a connected instance and the rig responds with a valid `FA;` frame +- **THEN** the implementation SHALL write `FA;` to the serial port, read the response up to the `;` delimiter, and return the VFO-A frequency in MHz. The response Hz field SHALL be accepted with 8 to 11 digits inclusive (e.g. `FA014074000;` → `14.074`, `FA00014074000;` → `14.074`). + +#### Scenario: GetDialFrequencyMhzAsync calibrates digit width on first success + +- **WHEN** `GetDialFrequencyMhzAsync` succeeds for the first time on a given connection +- **THEN** the class SHALL record the digit count from the FA response and use that count as the zero-pad width for all subsequent `SetDialFrequencyMhzAsync` calls on that connection + +#### Scenario: GetDialFrequencyMhzAsync throws on malformed response + +- **WHEN** the serial port returns a response that does not start with `FA`, or whose digit count (characters between the `FA` prefix and the `;` delimiter) is outside the range 8–11 +- **THEN** `GetDialFrequencyMhzAsync` SHALL throw an `InvalidOperationException` with a message that includes the raw response for diagnostics + +#### Scenario: GetDialFrequencyMhzAsync throws on read timeout + +- **WHEN** no response arrives within 500 ms of the `FA;` command being written +- **THEN** `GetDialFrequencyMhzAsync` SHALL throw a `TimeoutException` + +#### Scenario: SetDialFrequencyMhzAsync sends FA set command using calibrated width + +- **WHEN** `SetDialFrequencyMhzAsync` is called after at least one successful `GetDialFrequencyMhzAsync` has recorded the rig's digit width +- **THEN** the implementation SHALL write `FA;` to the serial port with the Hz value rounded to the nearest integer and zero-padded to the previously recorded digit width (e.g. for a 9-digit rig: `14.074 MHz → FA014074000;`) + +#### Scenario: SetDialFrequencyMhzAsync uses 11-digit fallback before first poll + +- **WHEN** `SetDialFrequencyMhzAsync` is called before any successful `GetDialFrequencyMhzAsync` has completed on the current connection +- **THEN** the implementation SHALL format the FA set command using an 11-digit zero-padded Hz value (e.g. `FA00014074000;`) + +#### Scenario: SetPttAsync sends TX; to key the transmitter + +- **WHEN** `SetPttAsync(true)` is called on a connected instance +- **THEN** the implementation SHALL write `TX;` to the serial port and return without waiting for a response + +#### Scenario: SetPttAsync sends RX; to unkey the transmitter + +- **WHEN** `SetPttAsync(false)` is called on a connected instance +- **THEN** the implementation SHALL write `RX;` to the serial port and return without waiting for a response + +#### Scenario: DisconnectAsync closes the serial port + +- **WHEN** `DisconnectAsync` is called +- **THEN** the serial port SHALL be closed and `IsConnected` SHALL return `false` + +#### Scenario: Dispose closes the serial port + +- **WHEN** a `SerialCatConnection` instance is disposed while the port is open +- **THEN** the serial port SHALL be closed + +### Requirement: RigctldConnection implements IRadioConnection + +`OpenWSFZ.Rig` SHALL provide a class `RigctldConnection` that implements `IRadioConnection` using a `TcpClient` connected to a running `rigctld` daemon. The class SHALL be constructable with a hostname and port (defaults `"127.0.0.1"` and `4532`). All network I/O SHALL use a 500 ms receive timeout. + +Commands sent by `RigctldConnection`: +- `\get_freq\n` (frequency **query**, sent by `GetDialFrequencyMhzAsync`) +- `\set_freq \n` (frequency **set**, sent by `SetDialFrequencyMhzAsync`) +- `\set_ptt 1\n` / `\set_ptt 0\n` (PTT **set**, sent by `SetPttAsync`) + +No mode-set or other rig-altering commands beyond frequency-set and PTT-set SHALL be sent by this class. + +#### Scenario: ConnectAsync opens a TCP connection to rigctld + +- **WHEN** `ConnectAsync` is called and `rigctld` is listening on the configured host and port +- **THEN** the TCP connection SHALL be established and `IsConnected` SHALL return `true` + +#### Scenario: ConnectAsync throws when rigctld is not reachable + +- **WHEN** `ConnectAsync` is called and no process is listening on the configured host and port +- **THEN** the method SHALL throw a `SocketException` and `IsConnected` SHALL remain `false` + +#### Scenario: GetDialFrequencyMhzAsync sends get_freq and parses response + +- **WHEN** `GetDialFrequencyMhzAsync` is called on a connected instance +- **THEN** the implementation SHALL send `\get_freq\n` to the rigctld socket, read the response line, and return the frequency in MHz by dividing the Hz integer value by 1 000 000 (e.g. `14074000` → `14.074`) + +#### Scenario: GetDialFrequencyMhzAsync throws on rigctld error response + +- **WHEN** rigctld returns a response beginning with `RPRT` or a value that cannot be parsed as a non-negative integer +- **THEN** `GetDialFrequencyMhzAsync` SHALL throw an `InvalidOperationException` with the raw response included in the message + +#### Scenario: GetDialFrequencyMhzAsync throws on receive timeout + +- **WHEN** no response arrives within 500 ms of the command being sent +- **THEN** `GetDialFrequencyMhzAsync` SHALL throw a `TimeoutException` + +#### Scenario: SetDialFrequencyMhzAsync sends set_freq command + +- **WHEN** `SetDialFrequencyMhzAsync` is called with a frequency in MHz (e.g., `14.074`) +- **THEN** the implementation SHALL send `\set_freq \n` to the rigctld socket, where Hz is the frequency rounded to the nearest integer (e.g., `\set_freq 14074000\n`) +- **AND** the method SHALL return after the write completes without waiting for a confirmation response + +#### Scenario: SetPttAsync sends set_ptt 1 and consumes the acknowledgement to key + +- **WHEN** `SetPttAsync(true)` is called on a connected instance +- **THEN** the implementation SHALL send `\set_ptt 1\n` to the rigctld socket and read and validate the `RPRT 0` acknowledgement, throwing `InvalidOperationException` (including the raw response) if the acknowledgement is any other value + +#### Scenario: SetPttAsync sends set_ptt 0 and consumes the acknowledgement to unkey + +- **WHEN** `SetPttAsync(false)` is called on a connected instance +- **THEN** the implementation SHALL send `\set_ptt 0\n` to the rigctld socket and read and validate the `RPRT 0` acknowledgement, throwing `InvalidOperationException` (including the raw response) if the acknowledgement is any other value + +#### Scenario: DisconnectAsync closes the TCP connection + +- **WHEN** `DisconnectAsync` is called +- **THEN** the TCP connection SHALL be closed and `IsConnected` SHALL return `false` + +#### Scenario: Dispose closes the TCP connection + +- **WHEN** a `RigctldConnection` instance is disposed while connected +- **THEN** the TCP connection SHALL be closed + +## ADDED Requirements + +### Requirement: CatPollingService serializes all IRadioConnection wire access + +`CatPollingService` SHALL be the sole owner of the shared `IRadioConnection` instance used for CAT. All access to that instance — the poll loop's `GetDialFrequencyMhzAsync` calls, the tuning endpoint's `SetDialFrequencyMhzAsync` calls, and any `SetPttAsync` call originating from a CAT-command PTT controller — SHALL be serialized through a single mutual-exclusion gate owned by `CatPollingService`, so that no two calls are ever in flight on the connection at the same time. + +`CatPollingService` SHALL expose PTT keying to consumers outside `OpenWSFZ.Daemon.Cat` only through a narrow interface (`ICatPttGate`) with a single member `Task SetPttAsync(bool transmitting, CancellationToken cancellationToken = default)`. No component other than `CatPollingService` SHALL hold a direct reference to the shared `IRadioConnection` instance. + +#### Scenario: A PTT command waits for an in-flight poll to complete + +- **WHEN** `ICatPttGate.SetPttAsync` is called while the poll loop is in the middle of a `GetDialFrequencyMhzAsync` call on the shared connection +- **THEN** the PTT command SHALL wait until the in-flight poll call completes before writing to the connection + +#### Scenario: A poll waits for an in-flight PTT command to complete + +- **WHEN** the poll loop's timer fires while a `SetPttAsync` call is in flight on the shared connection +- **THEN** the poll SHALL wait until the in-flight PTT command completes before writing to the connection + +#### Scenario: ICatPttGate is unavailable when CAT is disabled + +- **WHEN** `AppConfig.Cat.Enabled` is `false` +- **THEN** any call to `ICatPttGate.SetPttAsync` SHALL throw `InvalidOperationException` rather than attempting to open a connection + +#### Scenario: No component outside CatPollingService references the shared IRadioConnection directly + +- **WHEN** the `OpenWSFZ.Daemon` and `OpenWSFZ.Rig` assemblies are inspected for consumers of `IRadioConnection` +- **THEN** only `CatPollingService` SHALL hold a direct reference to the shared instance; all other consumers (including any CAT-command `IPttController` implementation) SHALL depend only on `ICatPttGate`, `ICatTuner`, `ICatController`, or `ICatState` diff --git a/openspec/changes/cat-tx-ptt/specs/ft8-tx/spec.md b/openspec/changes/cat-tx-ptt/specs/ft8-tx/spec.md new file mode 100644 index 0000000..5cd6844 --- /dev/null +++ b/openspec/changes/cat-tx-ptt/specs/ft8-tx/spec.md @@ -0,0 +1,140 @@ +## ADDED Requirements + +### Requirement: PTT method configuration + +`OpenWSFZ.Abstractions` SHALL define a `PttConfig` record, referenced from `AppConfig` as a top-level `Ptt` field (sibling to `Cat`, not nested inside it), with the following fields and defaults: + +```csharp +public string Method { get; init; } = "AudioVox"; // AudioVox | CatCommand | SerialRtsDtr +public string SerialPort { get; init; } = ; +public string SerialLine { get; init; } = "Rts"; // Rts | Dtr +public int LeadTimeMs { get; init; } = 50; +public int TailTimeMs { get; init; } = 50; +public int WatchdogTimeoutMs { get; init; } = 20000; +``` + +A missing or partial `ptt` key in the configuration file SHALL deserialise with all fields at their defaults. An unrecognised `Method` or `SerialLine` value SHALL be logged at Warning and treated as `"AudioVox"` / `"Rts"` respectively, rather than throwing. + +The daemon SHALL register exactly one `IPttController` implementation in the DI container, selected at startup from `AppConfig.Ptt.Method`: +- `"AudioVox"` → `AudioOnlyPttController` (unchanged from the existing implementation) +- `"CatCommand"` → `CatPttController` +- `"SerialRtsDtr"` → `SerialRtsDtrPttController` + +#### Scenario: Default configuration preserves existing VOX behaviour + +- **WHEN** a configuration file has no `ptt` key at all +- **THEN** `AppConfig.Ptt.Method` SHALL be `"AudioVox"` and the daemon SHALL register `AudioOnlyPttController` exactly as it does today + +#### Scenario: CatCommand method registers CatPttController + +- **WHEN** `AppConfig.Ptt.Method` is `"CatCommand"` at daemon startup +- **THEN** the DI container SHALL register `CatPttController` as the singleton `IPttController` + +#### Scenario: SerialRtsDtr method registers SerialRtsDtrPttController + +- **WHEN** `AppConfig.Ptt.Method` is `"SerialRtsDtr"` at daemon startup +- **THEN** the DI container SHALL register `SerialRtsDtrPttController` as the singleton `IPttController` + +#### Scenario: Unrecognised method falls back to AudioVox + +- **WHEN** `AppConfig.Ptt.Method` is a value other than `"AudioVox"`, `"CatCommand"`, or `"SerialRtsDtr"` +- **THEN** the daemon SHALL log a Warning naming the invalid value and register `AudioOnlyPttController` as if `Method` were `"AudioVox"` + +--- + +### Requirement: CatPttController keys PTT over the CAT link + +`OpenWSFZ.Daemon` SHALL provide a class `CatPttController : IPttController` that asserts PTT via `ICatPttGate.SetPttAsync` (see `cat-control`) and plays the loaded TX audio via the same WASAPI playback mechanism as `AudioOnlyPttController`, sequenced as follows: + +`KeyDownAsync`: +1. Call `ICatPttGate.SetPttAsync(true)`. +2. Wait `AppConfig.Ptt.LeadTimeMs`. +3. Begin WASAPI playback of the loaded audio buffer and await its completion (or cancellation). + +`KeyUpAsync`: +1. Stop any in-progress playback and release the audio device. +2. Wait `AppConfig.Ptt.TailTimeMs`. +3. Call `ICatPttGate.SetPttAsync(false)`. + +PTT SHALL be released (via `ICatPttGate.SetPttAsync(false)`) on any exception raised during steps 1–3 of `KeyDownAsync`, on cancellation, and on `DisposeAsync`, even if playback has not started or has not completed. + +#### Scenario: KeyDownAsync asserts PTT before audio starts + +- **WHEN** `LoadAudio` has been called with a valid buffer and `KeyDownAsync` is called +- **THEN** `ICatPttGate.SetPttAsync(true)` SHALL be called, and WASAPI playback SHALL NOT begin until at least `LeadTimeMs` has elapsed after that call returns + +#### Scenario: KeyUpAsync releases PTT after audio and tail time + +- **WHEN** `KeyUpAsync` is called while audio is playing +- **THEN** playback SHALL stop, at least `TailTimeMs` SHALL elapse, and only then SHALL `ICatPttGate.SetPttAsync(false)` be called + +#### Scenario: PTT is released when playback throws + +- **WHEN** WASAPI playback throws an exception after PTT has been asserted +- **THEN** `CatPttController` SHALL still call `ICatPttGate.SetPttAsync(false)` before the exception propagates to the caller + +#### Scenario: KeyDownAsync without LoadAudio throws InvalidOperationException + +- **WHEN** `KeyDownAsync` is called before `LoadAudio` has been called +- **THEN** the method SHALL throw `InvalidOperationException` without calling `ICatPttGate.SetPttAsync` + +#### Scenario: DisposeAsync releases PTT if asserted + +- **WHEN** `DisposeAsync` is called while PTT is asserted +- **THEN** `ICatPttGate.SetPttAsync(false)` SHALL be called before disposal completes + +--- + +### Requirement: SerialRtsDtrPttController keys PTT via a serial control line + +`OpenWSFZ.Daemon` SHALL provide a class `SerialRtsDtrPttController : IPttController` that asserts PTT by setting a serial port's RTS or DTR line (per `AppConfig.Ptt.SerialLine`) high, and plays the loaded TX audio via the same WASAPI playback mechanism as `AudioOnlyPttController`, sequenced identically to `CatPttController`'s `KeyDownAsync`/`KeyUpAsync` steps but asserting/de-asserting the configured serial line in place of `ICatPttGate.SetPttAsync`. + +`SerialRtsDtrPttController` SHALL open its own serial port (`AppConfig.Ptt.SerialPort`) independently of any CAT connection; it SHALL NOT share a connection, port handle, or synchronisation gate with `CatPollingService` or `ICatPttGate`. + +#### Scenario: KeyDownAsync asserts the configured line before audio starts + +- **WHEN** `AppConfig.Ptt.SerialLine` is `"Rts"` and `KeyDownAsync` is called +- **THEN** the serial port's RTS line SHALL be set high, and WASAPI playback SHALL NOT begin until at least `LeadTimeMs` has elapsed after that call returns + +#### Scenario: KeyUpAsync de-asserts the configured line after audio and tail time + +- **WHEN** `KeyUpAsync` is called while audio is playing +- **THEN** playback SHALL stop, at least `TailTimeMs` SHALL elapse, and only then SHALL the configured serial line be set low + +#### Scenario: DTR line is used when configured + +- **WHEN** `AppConfig.Ptt.SerialLine` is `"Dtr"` +- **THEN** `KeyDownAsync`/`KeyUpAsync` SHALL assert/de-assert the DTR line instead of RTS + +#### Scenario: Independent of the CAT connection + +- **WHEN** `AppConfig.Cat.Enabled` is `false` and `AppConfig.Ptt.Method` is `"SerialRtsDtr"` +- **THEN** `SerialRtsDtrPttController` SHALL still be able to open its configured serial port and key PTT, independent of CAT being disabled + +#### Scenario: Port open failure surfaces as an exception, not a silent no-op + +- **WHEN** `AppConfig.Ptt.SerialPort` names a port that does not exist or is already in use +- **THEN** `KeyDownAsync` SHALL throw rather than silently proceeding to play audio with no PTT asserted + +--- + +### Requirement: PTT failsafe watchdog + +Any `IPttController` implementation that asserts a physical PTT signal (`CatPttController`, `SerialRtsDtrPttController`) SHALL start a watchdog timer, set to `AppConfig.Ptt.WatchdogTimeoutMs`, the instant PTT is asserted in `KeyDownAsync`. If `KeyUpAsync` has not completed the PTT-release step before the watchdog elapses, the implementation SHALL force PTT release immediately (bypassing `TailTimeMs`) and SHALL log at Error level. The watchdog SHALL be cancelled as soon as `KeyUpAsync` begins executing its release step. + +This requirement does not apply to `AudioOnlyPttController`, which asserts no independent physical PTT signal (VOX keying is entirely the rig's own responsibility). + +#### Scenario: Watchdog forces release when key-up never arrives + +- **WHEN** PTT has been asserted for longer than `WatchdogTimeoutMs` without `KeyUpAsync` being called +- **THEN** the controller SHALL force PTT release and log an Error containing the elapsed hold duration + +#### Scenario: Watchdog does not fire during normal operation + +- **WHEN** `KeyUpAsync` completes its release step before `WatchdogTimeoutMs` has elapsed +- **THEN** the watchdog timer SHALL be cancelled and SHALL NOT force a second release + +#### Scenario: Watchdog fires even if playback hangs + +- **WHEN** WASAPI playback does not return control within `WatchdogTimeoutMs` of `KeyDownAsync` asserting PTT +- **THEN** PTT SHALL still be forcibly released by the watchdog, independent of whether playback ever completes diff --git a/openspec/changes/cat-tx-ptt/tasks.md b/openspec/changes/cat-tx-ptt/tasks.md index 2dbe06b..48bb5d6 100644 --- a/openspec/changes/cat-tx-ptt/tasks.md +++ b/openspec/changes/cat-tx-ptt/tasks.md @@ -1,95 +1,99 @@ ## 1. Requirements & Documentation -- [ ] 1.1 Add **FR-056** (PTT-over-CAT and serial RTS/DTR keying) to `REQUIREMENTS.md`, amending the FR-045/`IRadioConnection` "no PTT" note; add the corresponding version-history row (FR-052–FR-055 were claimed by `gridtracker-udp-reporting`, merged 2026-07-12 — FR-056 is the next free number as of this correction) -- [ ] 1.2 Update `IRadioConnection`'s XML doc comment (currently states "No mode-set, PTT, or other rig-altering commands are defined here") to reflect the amendment +- [x] 1.1 Add **FR-056** (PTT-over-CAT and serial RTS/DTR keying) to `REQUIREMENTS.md`, amending the FR-045/`IRadioConnection` "no PTT" note; add the corresponding version-history row (FR-052–FR-055 were claimed by `gridtracker-udp-reporting`, merged 2026-07-12 — FR-056 is the next free number as of this correction) +- [x] 1.2 Update `IRadioConnection`'s XML doc comment (currently states "No mode-set, PTT, or other rig-altering commands are defined here") to reflect the amendment ## 2. Abstractions -- [ ] 2.1 Add `Task SetPttAsync(bool transmitting, CancellationToken cancellationToken = default)` to `IRadioConnection` in `OpenWSFZ.Abstractions` -- [ ] 2.2 Add `ICatPttGate` interface to `OpenWSFZ.Abstractions` with a single member `Task SetPttAsync(bool transmitting, CancellationToken cancellationToken = default)` -- [ ] 2.3 Add `PttConfig` record to `OpenWSFZ.Abstractions` (`Method`, `SerialPort`, `SerialLine`, `LeadTimeMs`, `TailTimeMs`, `WatchdogTimeoutMs`, defaults per design.md Decision 6) -- [ ] 2.4 Add `Ptt` property of type `PttConfig` (defaulting to a disabled/VOX instance) to `AppConfig`, as a sibling of `Cat`, not nested inside it -- [ ] 2.5 Verify round-trip: existing config files without a `ptt` key deserialise without error and `Ptt.Method` is `"AudioVox"` +- [x] 2.1 Add `Task SetPttAsync(bool transmitting, CancellationToken cancellationToken = default)` to `IRadioConnection` in `OpenWSFZ.Abstractions` +- [x] 2.2 Add `ICatPttGate` interface to `OpenWSFZ.Abstractions` with a single member `Task SetPttAsync(bool transmitting, CancellationToken cancellationToken = default)` +- [x] 2.3 Add `PttConfig` record to `OpenWSFZ.Abstractions` (`Method`, `SerialPort`, `SerialLine`, `LeadTimeMs`, `TailTimeMs`, `WatchdogTimeoutMs`, defaults per design.md Decision 6) +- [x] 2.4 Add `Ptt` property of type `PttConfig` (defaulting to a disabled/VOX instance) to `AppConfig`, as a sibling of `Cat`, not nested inside it +- [x] 2.5 Verify round-trip: existing config files without a `ptt` key deserialise without error and `Ptt.Method` is `"AudioVox"` ## 3. Serial Port RTS/DTR Support -- [ ] 3.1 Add `bool RtsEnable { get; set; }` and `bool DtrEnable { get; set; }` to `ISerialPort` in `OpenWSFZ.Rig.Internal` -- [ ] 3.2 Implement both properties as pass-throughs in `SerialPortWrapper` (mirroring `System.IO.Ports.SerialPort.RtsEnable`/`DtrEnable`) -- [ ] 3.3 Add a fake `ISerialPort` test double exposing settable/observable `RtsEnable`/`DtrEnable` state (extending or alongside whatever fake already backs the existing `SerialCatConnection` unit tests) +- [x] 3.1 Add `bool RtsEnable { get; set; }` and `bool DtrEnable { get; set; }` to `ISerialPort` in `OpenWSFZ.Rig.Internal` +- [x] 3.2 Implement both properties as pass-throughs in `SerialPortWrapper` (mirroring `System.IO.Ports.SerialPort.RtsEnable`/`DtrEnable`) +- [x] 3.3 Add a fake `ISerialPort` test double exposing settable/observable `RtsEnable`/`DtrEnable` state (extending or alongside whatever fake already backs the existing `SerialCatConnection` unit tests) ## 4. SerialCatConnection — PTT commands -- [ ] 4.1 Implement `SetPttAsync(true)` — writes `TX;\r` to the serial port, no read-back -- [ ] 4.2 Implement `SetPttAsync(false)` — writes `RX;\r` to the serial port, no read-back -- [ ] 4.3 Update the class's XML doc comment to document the new PTT commands alongside the existing frequency commands +- [x] 4.1 Implement `SetPttAsync(true)` — writes `TX;` to the serial port, no read-back (matches spec.md's literal scenario and the existing no-`\r` `SetDialFrequencyMhzAsync` set convention — see note below) +- [x] 4.2 Implement `SetPttAsync(false)` — writes `RX;` to the serial port, no read-back +- [x] 4.3 Update the class's XML doc comment to document the new PTT commands alongside the existing frequency commands + + > Note: this task's original wording said `TX;\r`/`RX;\r`, but `specs/cat-control/spec.md`'s scenario text (the authoritative contract) says exactly `TX;`/`RX;` with no `\r` — matching the existing no-`\r` convention already used by `SetDialFrequencyMhzAsync`'s `FA;` (the `\r` is only used by the `FA;` read/probe command). Implemented per spec.md. ## 5. RigctldConnection — PTT commands -- [ ] 5.1 Implement `SetPttAsync(true)` — sends `\set_ptt 1\n`, reads and validates the `RPRT 0` acknowledgement, throws `InvalidOperationException` (with raw response) on any other reply -- [ ] 5.2 Implement `SetPttAsync(false)` — sends `\set_ptt 0\n`, same acknowledgement handling -- [ ] 5.3 Update the class's XML doc comment to document the new PTT commands +- [x] 5.1 Implement `SetPttAsync(true)` — sends `\set_ptt 1\n`, reads and validates the `RPRT 0` acknowledgement, throws `InvalidOperationException` (with raw response) on any other reply +- [x] 5.2 Implement `SetPttAsync(false)` — sends `\set_ptt 0\n`, same acknowledgement handling +- [x] 5.3 Update the class's XML doc comment to document the new PTT commands ## 6. CatPollingService — wire serialization -- [ ] 6.1 Add a private `SemaphoreSlim(1,1)` gate to `CatPollingService` guarding every call the poll loop makes to the shared `IRadioConnection` -- [ ] 6.2 Implement `CatPollingService`'s `ICatPttGate.SetPttAsync` by acquiring the same gate before calling `IRadioConnection.SetPttAsync` -- [ ] 6.3 `ICatPttGate.SetPttAsync` throws `InvalidOperationException` when `AppConfig.Cat.Enabled` is `false` or no connection has ever been established -- [ ] 6.4 Register `CatPollingService` as `ICatPttGate` in `Program.cs` DI wiring (alongside its existing `ICatTuner`/`ICatController` registrations) -- [ ] 6.5 Audit `OpenWSFZ.Daemon` and `OpenWSFZ.Rig` for any other holder of the shared `IRadioConnection` reference and remove/refactor it so `CatPollingService` remains the sole holder +- [x] 6.1 Add a private `SemaphoreSlim(1,1)` gate to `CatPollingService` guarding every call the poll loop makes to the shared `IRadioConnection` (already existed as `_connectionLock`, added by the earlier frequency-management change for the same poll-vs-tune serialization purpose — reused rather than duplicated, per design.md Decision 1) +- [x] 6.2 Implement `CatPollingService`'s `ICatPttGate.SetPttAsync` by acquiring the same gate before calling `IRadioConnection.SetPttAsync` +- [x] 6.3 `ICatPttGate.SetPttAsync` throws `InvalidOperationException` when `AppConfig.Cat.Enabled` is `false` or no connection has ever been established +- [x] 6.4 Register `CatPollingService` as `ICatPttGate` in `Program.cs` DI wiring (alongside its existing `ICatTuner`/`ICatController` registrations) +- [x] 6.5 Audit `OpenWSFZ.Daemon` and `OpenWSFZ.Rig` for any other holder of the shared `IRadioConnection` reference and remove/refactor it so `CatPollingService` remains the sole holder — audited (grep for `IRadioConnection` across both assemblies): only `CatPollingService.cs` (owner), `SerialCatConnection.cs`/`RigctldConnection.cs` (implementers), and `RigModelFactory.cs` (factory, returns an unconnected instance to `CatPollingService` only) reference it; no other consumer exists, so no refactor was needed ## 7. Shared WASAPI playback extraction -- [ ] 7.1 Extract the WASAPI device-open/play/stop/dispose logic from `AudioOnlyPttController` into an internal `WasapiTxPlayer` helper (`WASAPI_SUPPORTED`-gated) exposing an async play-to-completion method and a stop method -- [ ] 7.2 Refactor `AudioOnlyPttController` to use `WasapiTxPlayer` internally -- [ ] 7.3 Run the full existing `AudioOnlyPttController` unit test suite unmodified against the refactored implementation — zero assertion changes permitted; any failure blocks proceeding to section 8 +- [x] 7.1 Extract the WASAPI device-open/play/stop/dispose logic from `AudioOnlyPttController` into an internal `WasapiTxPlayer` helper (`WASAPI_SUPPORTED`-gated) exposing an async play-to-completion method and a stop method +- [x] 7.2 Refactor `AudioOnlyPttController` to use `WasapiTxPlayer` internally +- [x] 7.3 Run the full existing `AudioOnlyPttController` unit test suite unmodified against the refactored implementation — zero assertion changes permitted; any failure blocks proceeding to section 8 + + > **Defect found and fixed while establishing the pre-refactor baseline:** `tests/OpenWSFZ.Daemon.Tests/OpenWSFZ.Daemon.Tests.csproj` never defined the `WASAPI_SUPPORTED` conditional-compilation symbol that `AudioOnlyPttControllerTests.cs` (and its host class) require — the whole `#if WASAPI_SUPPORTED` test file silently compiled to 0 tests, on every platform including Windows CI, since it was authored. Added the same OS-conditional `DefineConstants` block `OpenWSFZ.Daemon.csproj` already has. With the suite actually running for the first time, `DisposeAsync_CalledTwice_SecondCallIsNoOp` genuinely failed (`ObjectDisposedException` from a disposed `SemaphoreSlim`) — a real, pre-existing, previously-invisible bug, not a regression from this change. Fixed via the same `Interlocked.Exchange` double-dispose guard `CatPollingService` already uses (see `WasapiTxPlayer.DisposeAsync`). Suite now runs 7/7 green against the refactored implementation with zero assertion changes. ## 8. CatPttController -- [ ] 8.1 Implement `CatPttController : IPttController` in `OpenWSFZ.Daemon`, constructed with `ICatPttGate`, `IConfigStore`, and a logger -- [ ] 8.2 Implement `LoadAudio` (same contract as `AudioOnlyPttController`) -- [ ] 8.3 Implement `KeyDownAsync`: `ICatPttGate.SetPttAsync(true)` → wait `LeadTimeMs` → play via `WasapiTxPlayer` → await completion -- [ ] 8.4 Implement `KeyUpAsync`: stop playback → wait `TailTimeMs` → `ICatPttGate.SetPttAsync(false)` -- [ ] 8.5 Wrap the key-down/play/key-up sequence in a watchdog per section 10, and in try/finally so any exception still releases PTT -- [ ] 8.6 Implement `DisposeAsync` — force PTT release if asserted, release any audio device handle +- [x] 8.1 Implement `CatPttController : IPttController` in `OpenWSFZ.Daemon`, constructed with `ICatPttGate`, `IConfigStore`, and a logger +- [x] 8.2 Implement `LoadAudio` (same contract as `AudioOnlyPttController`) +- [x] 8.3 Implement `KeyDownAsync`: `ICatPttGate.SetPttAsync(true)` → wait `LeadTimeMs` → play via `WasapiTxPlayer` → await completion +- [x] 8.4 Implement `KeyUpAsync`: stop playback → wait `TailTimeMs` → `ICatPttGate.SetPttAsync(false)` +- [x] 8.5 Wrap the key-down/play/key-up sequence in a watchdog per section 10, and in try/finally so any exception still releases PTT (implemented as try/catch-release-rethrow around the lead-wait+play steps, since a plain finally would incorrectly release on the normal-completion path too — the invariant "any exception releases PTT" holds either way) +- [x] 8.6 Implement `DisposeAsync` — force PTT release if asserted, release any audio device handle ## 9. SerialRtsDtrPttController -- [ ] 9.1 Implement `SerialRtsDtrPttController : IPttController` in `OpenWSFZ.Daemon`, opening its own `ISerialPort` from `AppConfig.Ptt.SerialPort`, independent of any `CatPollingService`/`ICatPttGate` instance -- [ ] 9.2 Implement `LoadAudio` (same contract) -- [ ] 9.3 Implement `KeyDownAsync`: assert the configured line (`RtsEnable`/`DtrEnable` per `AppConfig.Ptt.SerialLine`) → wait `LeadTimeMs` → play via `WasapiTxPlayer` → await completion -- [ ] 9.4 Implement `KeyUpAsync`: stop playback → wait `TailTimeMs` → de-assert the configured line -- [ ] 9.5 Port-open failure in `KeyDownAsync` throws rather than silently skipping PTT assertion -- [ ] 9.6 Wrap the sequence in a watchdog per section 10, and in try/finally so any exception still de-asserts the line -- [ ] 9.7 Implement `DisposeAsync` — force line de-assertion if asserted, close and dispose the serial port +- [x] 9.1 Implement `SerialRtsDtrPttController : IPttController` in `OpenWSFZ.Daemon`, opening its own `ISerialPort` from `AppConfig.Ptt.SerialPort`, independent of any `CatPollingService`/`ICatPttGate` instance (opened lazily on first `KeyDownAsync`, kept open for the controller's lifetime; baud rate is a hardcoded conventional default since RTS/DTR-only keying exchanges no data and `PttConfig` has no `baudRate` field) +- [x] 9.2 Implement `LoadAudio` (same contract) +- [x] 9.3 Implement `KeyDownAsync`: assert the configured line (`RtsEnable`/`DtrEnable` per `AppConfig.Ptt.SerialLine`) → wait `LeadTimeMs` → play via `WasapiTxPlayer` → await completion +- [x] 9.4 Implement `KeyUpAsync`: stop playback → wait `TailTimeMs` → de-assert the configured line (the exact line that was asserted at key-down time, remembered separately, so a mid-transmission config change can never de-assert the wrong line and leave the real one stuck high) +- [x] 9.5 Port-open failure in `KeyDownAsync` throws rather than silently skipping PTT assertion +- [x] 9.6 Wrap the sequence in a watchdog per section 10, and in try/finally so any exception still de-asserts the line +- [x] 9.7 Implement `DisposeAsync` — force line de-assertion if asserted, close and dispose the serial port ## 10. Failsafe watchdog -- [ ] 10.1 Implement a small shared watchdog helper (timer + forced-release callback) usable by both `CatPttController` and `SerialRtsDtrPttController`, parameterised by `WatchdogTimeoutMs` -- [ ] 10.2 Watchdog logs at Error (including elapsed hold duration) and forces release, bypassing `TailTimeMs`, when it fires -- [ ] 10.3 Watchdog is cancelled the instant `KeyUpAsync` begins its release step +- [x] 10.1 Implement a small shared watchdog helper (timer + forced-release callback) usable by both `CatPttController` and `SerialRtsDtrPttController`, parameterised by `WatchdogTimeoutMs` (`PttWatchdog`, built ahead of sections 8/9 since both depend on it; deliberately has no WASAPI dependency so it is unit-testable standalone) +- [x] 10.2 Watchdog logs at Error (including elapsed hold duration) and forces release, bypassing `TailTimeMs`, when it fires +- [x] 10.3 Watchdog is cancelled the instant `KeyUpAsync` begins its release step (`Disarm()`, called first thing in both controllers' `KeyUpAsync`) ## 11. DI Wiring -- [ ] 11.1 In `Program.cs`, replace the current `#if WASAPI_SUPPORTED` / `#else` two-way `IPttController` registration with a three-way switch on `configStore.Current.Ptt.Method` (falling back to `AudioOnlyPttController`/`NullPttController` per existing platform gating when the method is unrecognised or when `WASAPI_SUPPORTED` is undefined) -- [ ] 11.2 Register `CatPollingService` as `ICatPttGate` (see 6.4) -- [ ] 11.3 Verify `QsoAnswererService`/`QsoCallerService` construction is unaffected (they already resolve `IPttController` by interface, not concrete type) +- [x] 11.1 In `Program.cs`, replace the current `#if WASAPI_SUPPORTED` / `#else` two-way `IPttController` registration with a three-way switch on `configStore.Current.Ptt.Method` (falling back to `AudioOnlyPttController`/`NullPttController` per existing platform gating when the method is unrecognised or when `WASAPI_SUPPORTED` is undefined) +- [x] 11.2 Register `CatPollingService` as `ICatPttGate` (see 6.4 — already done there) +- [x] 11.3 Verify `QsoAnswererService`/`QsoCallerService` construction is unaffected (they already resolve `IPttController` by interface, not concrete type) — confirmed by inspection, no changes needed; `dotnet build` of `OpenWSFZ.Daemon` succeeds with 0 warnings/errors ## 12. Tests -- [ ] 12.1 `SerialCatConnection` PTT unit tests: `SetPttAsync(true)` writes `TX;\r`, `SetPttAsync(false)` writes `RX;\r` — prefix `CatTx-Ptt:` -- [ ] 12.2 `RigctldConnection` PTT unit tests: `SetPttAsync(true/false)` sends the right command and validates the RPRT ack, non-`RPRT 0` ack throws — prefix `CatTx-Ptt:` -- [ ] 12.3 `CatPollingService` gate unit tests (mock `IRadioConnection`): concurrent poll + PTT calls never overlap on the mock (assert via a re-entrancy guard in the mock), `ICatPttGate.SetPttAsync` throws when CAT disabled — prefix `CatTx-Ptt:` -- [ ] 12.4 `PttConfig`/`AppConfig` schema tests: missing `ptt` key defaults correctly, unknown `Method`/`SerialLine` fall back with a logged Warning — prefix `CatTx-Ptt:` -- [ ] 12.5 `ISerialPort`/`SerialPortWrapper` RTS/DTR unit tests using the fake serial port — prefix `CatTx-Ptt:` -- [ ] 12.6 `CatPttController` unit tests (mock `ICatPttGate` + injected playback override, same seam pattern as `AudioOnlyPttController`'s test constructor): key order (PTT before audio, audio-stop before PTT release), lead/tail timing honoured, PTT released on playback exception, `DisposeAsync` releases if asserted — prefix `CatTx-Ptt:` -- [ ] 12.7 `SerialRtsDtrPttController` unit tests (fake `ISerialPort` + injected playback override): same key-order/timing/exception/dispose coverage as 12.6, plus Rts-vs-Dtr line selection and port-open-failure-throws — prefix `CatTx-Ptt:` -- [ ] 12.8 Watchdog unit tests: forces release and logs Error when `KeyUpAsync` never arrives; does not fire when release happens in time; fires even when playback itself hangs — prefix `CatTx-Ptt:` -- [ ] 12.9 DI wiring test: each `Ptt.Method` value resolves the expected concrete `IPttController` — prefix `CatTx-Ptt:` +- [x] 12.1 `SerialCatConnection` PTT unit tests: `SetPttAsync(true)` writes `TX;`, `SetPttAsync(false)` writes `RX;` (no `\r` — see the section 4 implementation note) — prefix `CatTx-Ptt:`. 41/41 `OpenWSFZ.Rig.Tests` green. +- [x] 12.2 `RigctldConnection` PTT unit tests: `SetPttAsync(true/false)` sends the right command and validates the RPRT ack, non-`RPRT 0` ack throws — prefix `CatTx-Ptt:` +- [x] 12.3 `CatPollingService` gate unit tests (mock `IRadioConnection`): concurrent poll + PTT calls never overlap on the mock (assert via a re-entrancy guard in the mock), `ICatPttGate.SetPttAsync` throws when CAT disabled or no connection established, dispatches once connected — prefix `CatTx-Ptt:`. 10/10 `CatPollingServiceTests` green. +- [x] 12.4 `PttConfig`/`AppConfig` schema tests: missing `ptt` key defaults correctly (`PttConfigTests.cs`, task 2.5) — prefix `CatTx-Ptt:` used where FR-number-style prefixes aren't already established. Unknown-`Method`/`SerialLine`-fallback-with-Warning coverage lives where that logic actually runs: `Method` in `PttControllerSelectorTests` (12.9, since selection is DI-time behaviour, not a config-schema concern) and `SerialLine` in `SerialRtsDtrPttControllerTests` (12.7, since only the controller knows which physical line was requested). +- [x] 12.5 `ISerialPort`/`SerialPortWrapper` RTS/DTR unit tests using the fake serial port — `FakeSerialPortTests.cs` (8/8 green), validating the double's independent, observable Rts/Dtr state that `SerialRtsDtrPttControllerTests` builds on. `SerialPortWrapper` itself wraps real hardware and has no dedicated test, consistent with the project's existing pattern (no test file exists for it today either) — prefix `CatTx-Ptt:` +- [x] 12.6 `CatPttController` unit tests (mock `ICatPttGate` + injected playback override, same seam pattern as `AudioOnlyPttController`'s test constructor): key order (PTT before audio), lead/tail timing honoured, PTT released before an exception from playback propagates, `DisposeAsync` releases if asserted, watchdog force-release integration — prefix `CatTx-Ptt:`. 10/10 green. +- [x] 12.7 `SerialRtsDtrPttController` unit tests (fake `ISerialPort` + injected playback override): same key-order/timing/exception/dispose coverage as 12.6, plus Rts-vs-Dtr line selection, unrecognised-`SerialLine`-falls-back-to-Rts-with-Warning, port-open-failure-throws, and CAT-independence — prefix `CatTx-Ptt:`. 13/13 green. +- [x] 12.8 Watchdog unit tests (`PttWatchdogTests.cs`, no WASAPI/Windows dependency — pure timer logic): forces release and logs Error when `KeyUpAsync` never arrives; does not fire when `Disarm()` happens in time; fires even when the guarded operation hangs; re-arming replaces the pending timer — prefix `CatTx-Ptt:`. 4/4 green. +- [x] 12.9 DI wiring test: each `Ptt.Method` value resolves the expected concrete `IPttController` — extracted the selection logic from `Program.cs`'s inline switch into a new pure, directly-testable `PttControllerSelector.Resolve(method, logger)` (returns `PttControllerKind`) so this doesn't require spinning up the whole daemon host via `WebApplicationFactory`; `PttControllerSelectorTests.cs`, 5/5 green — prefix `CatTx-Ptt:` ## 13. Documentation -- [ ] 13.1 Author `hardware-acceptance.md` in this change directory, modelled on `openspec/changes/archive/2026-06-03-p16-cat-control/hardware-acceptance.md`, covering CAT-command PTT, serial RTS/DTR PTT, the failsafe watchdog, and a confirmed two-way QSO (see section 14) -- [ ] 13.2 Update `docs/cat-control-operator-guide.md` with the new `ptt` config block and a short explanation of when to choose each method +- [x] 13.1 Author `hardware-acceptance.md` in this change directory, modelled on `openspec/changes/archive/2026-06-03-p16-cat-control/hardware-acceptance.md`, covering CAT-command PTT, serial RTS/DTR PTT, the failsafe watchdog, and a confirmed two-way QSO (see section 14) — already drafted during proposal generation; verified against the final implementation (log line text, config field names/defaults) with no discrepancies found, so no edits were needed +- [x] 13.2 Update `docs/cat-control-operator-guide.md` with the new `ptt` config block and a short explanation of when to choose each method — added a "PTT (Transmit Keying) Configuration" section (method comparison table, field reference, JSON examples, watchdog safety note), corrected the now-stale "never sends frequency-set... or PTT commands" claim, and updated "Known Limitations" ## 14. Acceptance Gate — CAT-command PTT (manual, hardware required) @@ -107,12 +111,139 @@ ## 16. Acceptance Gate — Confirmed two-way QSO (release gate R3) -- [ ] 16.1 With either PTT method configured and `QsoAnswererService` or `QsoCallerService` active, complete one full, genuine over-the-air FT8 QSO with another station -- [ ] 16.2 Confirm the completed QSO is written correctly to `ADIF.log` -- [ ] 16.3 Document the confirmed QSO (date, band, partner call — Q-prefix or otherwise per NFR-021) in `hardware-acceptance.md` as the R3 evidence artefact - -## 17. Housekeeping - -- [ ] 17.1 Commit all changes with `feat(cat-tx-ptt): key the transmitter via CAT command or serial RTS/DTR` -- [ ] 17.2 Push and confirm CI green (all quality gates, including G9 version governance — this is user-facing, VERSION bump required) -- [ ] 17.3 Open PR to `main`; request QA gate review +- [x] 16.1 With either PTT method configured and `QsoAnswererService` or `QsoCallerService` active, complete one full, genuine over-the-air FT8 QSO with another station — two completed 2026-07-12 with `SerialRtsDtr`; see `hardware-acceptance.md` §16.1/16.3 +- [x] 16.2 Confirm the completed QSO is written correctly to `ADIF.log` — confirmed for both QSOs; one pre-existing, non-blocking `GRIDSQUARE`/`TIME_ON` gap noted and traced (not caused by this change) — see `hardware-acceptance.md` §16.2 +- [x] 16.3 Document the confirmed QSO (date, band, partner call — Q-prefix or otherwise per NFR-021) in `hardware-acceptance.md` as the R3 evidence artefact — done, real callsigns withheld per NFR-021 (see file) + +## 17. Settings UI for PTT Configuration + +Added 2026-07-12 (design.md's Decision 6 amendment) — the Captain hit a wall during hardware +acceptance: no way to see or change `ptt.method` without hand-editing `config.json`, which is also +how the null-`ptt` guard defect went undiagnosed for a full session. See design.md's amendment note +for the full safety analysis behind the semaphore requirement in 17.5/17.6 — do not skip it. + +- [x] 17.1 Add **FR-057** (Settings-page PTT configuration UI) to `REQUIREMENTS.md`, following the + FR-056 entry's format; add the corresponding version-history row +- [x] 17.2 **Safety fix first, before any UI work**: add a private `SemaphoreSlim(1,1)` to + `CatPttController` and `SerialRtsDtrPttController`, acquired for the entire `KeyDownAsync` → + `KeyUpAsync` critical section of each (design.md amendment note, safety-critical finding). Add a + test to each controller's existing suite proving two concurrent `KeyDownAsync` callers serialise + (the second's PTT-assert does not begin until the first's `KeyUpAsync` completes) rather than + interleave. Run the full existing `CatPttControllerTests.cs`/`SerialRtsDtrPttControllerTests.cs` + suites unmodified afterward — zero assertion changes permitted, matching the discipline task 7.3 + already established for the `WasapiTxPlayer` extraction +- [x] 17.3 `WebApp.cs`: add `POST /api/v1/ptt/test`. Resolve the currently-running `IPttController` + and `IQsoController` from DI (same pattern as the existing `/api/v1/tx/*` endpoints). Reject with + 409 Conflict (clear message: "a QSO is currently transmitting") if `IQsoController.Keying` is + `true`. Reject with 409 if the running `Ptt.Method` is `"AudioVox"` (nothing to test — OpenWSFZ + never asserts PTT itself in that mode; message should say so, not just "cannot test"). Otherwise: + call `LoadAudio` with a short (~200–300 ms) buffer of silence (zero-amplitude samples — a real + audible test tone was explicitly rejected, see design.md), then `KeyDownAsync` immediately + followed by `KeyUpAsync`. Return `{ "result": "pass" }` on success; on any exception, return + `{ "result": "error", "message": "" }` with HTTP 200 (this is an expected, + handleable outcome, not a server error) — do not let the exception propagate as a 500 +- [x] 17.4 `web/css/app.css`: add a layout rule so `#cat-settings` and the new PTT fieldset (17.5) + sit side-by-side on wide viewports and stack vertically on narrow ones — reuse whatever breakpoint + the rest of `settings.html` already uses for responsive stacking (check existing media queries in + `app.css` before inventing a new breakpoint value). Add `.ptt-test-badge` with three modifier + classes — `.ptt-test-pass` (reuse `--color-success`, same visual treatment as `.cat-connected`), + `.ptt-test-error` (reuse `--color-danger`, same as `.cat-error`), `.ptt-test-idle` (default/empty + state, shown before Test has been clicked) — mirroring `.cat-status-badge`'s existing structure + exactly rather than inventing a new badge pattern +- [x] 17.5 `web/settings.html`: split the current single `#cat-settings` fieldset's visual container + so "CAT rig connection" (existing markup, unchanged) and a new "PTT Config" fieldset sit side by + side per the Captain's layout sketch. New fieldset fields: `ptt-method` (select: AudioVox / + CatCommand / SerialRtsDtr), `ptt-serial-port` + `ptt-serial-line` (shown only when method = + SerialRtsDtr — reuse the existing generic `/api/v1/serial/ports` list and the `input-with-action` + + "↺ Refresh" pattern `cat-serial-port`/`cat-serial-refresh` already establish), `ptt-lead-time-ms`, + `ptt-tail-time-ms`, `ptt-watchdog-timeout-ms` (shown for CatCommand and SerialRtsDtr, hidden for + AudioVox — nothing to configure), and a `ptt-test-btn` ("Test") + `ptt-test-badge` result badge + pair using the `input-with-action` layout. Disable/hide the Test button when the *live* (last + GET-loaded, not the currently-selected-but-unsaved) method is AudioVox, with a hint explaining a + Save + daemon restart is required before Test reflects a newly-selected CAT/serial method (`ptt` + is not hot-reloaded — see the already-corrected operator-guide.md note) +- [x] 17.6 `web/js/settings.js`: element refs for all new controls (17.5); show/hide handler on + `ptt-method`'s `change` event mirroring `cat-rig-model`'s existing SerialCat/RigCtld show/hide + logic exactly; `load()` populates the new fields from `GET /api/v1/config`'s `ptt` object; the + `postConfig({...})` payload (currently omits `ptt` entirely per the now-superseded part of + design.md Decision 6) now includes a `ptt` key built from the form; `ptt-test-btn` click handler + POSTs to `/api/v1/ptt/test`, shows a transient "Testing…" badge state, then renders + `.ptt-test-pass`/`.ptt-test-error` with the result (and the error message, if any) on response +- [x] 17.7 Web/UI test coverage (this project's established pattern for `settings.js`/`settings.html` + — check for existing Playwright/JS test scaffolding before deciding the mechanism; if none exists, + a `WebApplicationFactory`-based integration test against `POST /api/v1/ptt/test` covering the + 409-while-keying case, the 409-on-AudioVox case, and the pass/error response shape is the minimum + bar, matching `ConfigApiNullGuardTests.cs`'s style) +- [x] 17.8 Update `docs/cat-control-operator-guide.md`'s new PTT section (added by task 13.2) to + state a Settings-page UI now exists, remove/replace the now-stale "no Settings-page UI for `ptt`" + line, and add a short paragraph on the Test button's exact semantics (software-only pulse, does + not confirm physical keying, requires Save + restart to reflect a changed method) +- [x] 17.9 Before/after screenshots of the Radio hardware tab (per this project's standing + before/after screenshot ordering rule — capture "before" against the current single-fieldset + layout *first*, then implement, then capture "after") + +## 18. Defect fix — `KeyUpAsync` never called after a normal transmission + +Added 2026-07-12 (dev-task `dev-tasks/2026-07-12-cat-tx-ptt-missing-keyup-after-transmit.md`) — +found during the first real hardware-acceptance attempt (Gate 16): `QsoCallerService.TransmitAsync` +and `QsoAnswererService`'s equivalent transmit helper called `KeyDownAsync` but never followed it +with `KeyUpAsync` on the normal-completion path — only the four abort paths called it. Every real TX +cycle therefore relied entirely on `PttWatchdog`'s 20 s failsafe to ever release PTT, holding the rig +keyed (and the daemon's own receiver blind) ~7+ seconds into the next FT8 RX slot on every single +transmission — critical, merge-blocking, and the direct cause of the failed HB9HYO QSO logged in +`logs/openswfz-20260712T152156Z.log`. No spec text changes required — this is a caller-side +implementation defect, not a behaviour change to `qso-caller`/`qso-answerer`. + +- [x] 18.1 `QsoCallerService.TransmitAsync` (`:930-990`): call `_pttController.KeyUpAsync(CancellationToken.None)` + inside the existing `finally` block, immediately after `KeyDownAsync`, before `_keying = false` / + `PublishKeyingTransition()` — release happens on every exit path (normal return, exception, or + cancellation), and the keying broadcast still reflects "still keyed" for the brief `TailTimeMs` + window while release is in progress +- [x] 18.2 `QsoAnswererService`'s equivalent transmit helper (`:1166-1225`): identical fix, same + ordering, same `CancellationToken.None` rationale (release must still happen even when the token + that was passed to `KeyDownAsync` is already cancelled — both controllers' `KeyUpAsync` bodies + already tolerate being called when nothing is asserted, so this is safe unconditionally) +- [x] 18.3 `IPttController.cs`: rewrote the stale doc comment (previously described the contract in + purely `AudioOnlyPttController`/WASAPI terms) to state plainly that every `KeyDownAsync` call MUST + be paired with a `KeyUpAsync` call in the caller's normal-completion path, not only on abort, and to + explain why the two controllers this change added do not share `AudioOnlyPttController`'s + "skipping `KeyUpAsync` is harmless" property +- [x] 18.4 Regression tests, both call order and cancellation-path coverage: + - `QsoCallerServiceTests.cs`: `TransmitAsync_NormalCompletion_KeyUpImmediatelyFollowsKeyDown_NoInterveningStateTransition` + (asserts `KeyUpAsync` immediately follows `KeyDownAsync` in a hand-rolled cross-substitute call-order + recorder — NSubstitute's `Received.InOrder` does not reliably interleave calls across two different + substitute instances, confirmed empirically — and that the `WaitAnswer` state broadcast comes after + both) and `TransmitAsync_CancelledMidKeyDown_StillCallsKeyUpAsync` (a `KeyDownAsync` that only + completes on cancellation still reaches the `finally` block and calls `KeyUpAsync` exactly once, + isolated from `AbortAsync`'s own separate cleanup call by cancelling the token `ExecuteAsync` was + started with directly rather than going through the abort API) + - `QsoAnswererServiceTests.cs`: the same two cases, adapted to the shared-fixture (`IAsyncLifetime`) + pattern already used throughout that file + - Updated the pre-existing `GracefulStopAsync_WhileTransmittingCq_DoesNotInterruptThenReturnsToIdle` + assertion from `Received(1).KeyUpAsync` to `Received(2)` — one call now comes from + `TransmitAsync`'s own finally block (the fix), a second from `SafeAbortToIdleAsync`'s pre-existing + unconditional cleanup call; both are individually safe no-ops on a controller with nothing + asserted, so two calls is the correct total, not a regression + - `PttWatchdogTests.cs`, `CatPttControllerTests.cs`, `SerialRtsDtrPttControllerTests.cs` re-run + unmodified, zero assertion changes — this fix is entirely on the `QsoCallerService`/ + `QsoAnswererService` caller side +- [x] 18.5 Full suite green: `OpenWSFZ.Daemon.Tests` 448/448 (run twice standalone to rule out an + unrelated pre-existing timing flake in `PendingTarget_LateStart_IsDeferred_ThenFiresNextCycle`, + which fails only under full-solution parallel load and passes in isolation — not touched by this + fix); full solution `dotnet test` otherwise green. `openspec validate --strict --all`: 54/54, + unchanged +- [ ] 18.6 **Hardware acceptance Gates 14–16 (section 14/15/16 above) must be re-attempted from + scratch** — none of the keying observed before this fix is valid evidence for those gates, Gate 16 + specifically (the HB9HYO session was the discovery evidence for this defect, not a pass). + **Partially re-attempted 2026-07-12:** Gate 16 (16.1–16.3) now ticked with two real, completed, + post-fix QSOs as evidence; Gate 15.1's key/unkey claim is evidenced too (port-distinctness half + still needs operator confirmation). **Still fully outstanding:** Gate 14 (CAT-command PTT — not + exercised at all on this day) and Gates 15.2–15.4 (DTR line, CAT-disabled independence, forced + watchdog trip post-fix). See `hardware-acceptance.md`'s 2026-07-12 evidence note for the full + breakdown. This item stays unchecked until Gate 14 and the rest of Gate 15 are actually run. + +## 19. Housekeeping + +- [ ] 19.1 Commit all changes with `feat(cat-tx-ptt): key the transmitter via CAT command or serial RTS/DTR` +- [ ] 19.2 Push and confirm CI green (all quality gates, including G9 version governance — this is user-facing, VERSION bump required) +- [ ] 19.3 Open PR to `main`; request QA gate review diff --git a/src/OpenWSFZ.Abstractions/AppConfig.cs b/src/OpenWSFZ.Abstractions/AppConfig.cs index 711f57a..965209f 100644 --- a/src/OpenWSFZ.Abstractions/AppConfig.cs +++ b/src/OpenWSFZ.Abstractions/AppConfig.cs @@ -44,6 +44,15 @@ public sealed record AppConfig( /// public CatConfig? Cat { get; init; } = null; + ///

+ /// PTT (push-to-talk) keying configuration (FR-056). Always non-null; defaults to + /// Method = "AudioVox", which is byte-for-byte today's pre-cat-tx-ptt + /// behaviour. A sibling of , not nested inside it — PTT method + /// selection is orthogonal to whether CAT is even enabled (an operator can run + /// SerialRtsDtr PTT with cat.enabled = false). + /// + public PttConfig Ptt { get; init; } = new(); + /// /// TX / QSO answerer configuration (FR-046). /// Defaults to null so that existing config files without a tx key diff --git a/src/OpenWSFZ.Abstractions/ICatPttGate.cs b/src/OpenWSFZ.Abstractions/ICatPttGate.cs new file mode 100644 index 0000000..a2897b9 --- /dev/null +++ b/src/OpenWSFZ.Abstractions/ICatPttGate.cs @@ -0,0 +1,29 @@ +namespace OpenWSFZ.Abstractions; + +/// +/// Narrow PTT-keying seam exposed by the CAT subsystem (FR-056). +/// Implemented by CatPollingService, which is the sole holder of the shared +/// instance used for CAT; this interface lets a +/// CAT-command IPttController implementation assert/de-assert PTT without +/// ever touching directly, so every call is +/// automatically serialised against the CAT poll loop's own frequency reads +/// (design.md Decision 1 of the cat-tx-ptt change). +/// +/// +/// Mirrors / — the other narrow +/// public seams CatPollingService exposes for its other capabilities. +/// +/// +public interface ICatPttGate +{ + /// + /// Commands the active CAT connection to key ( = + /// true) or unkey ( = false) the + /// transmitter, waiting for any in-flight poll to complete first. + /// + /// + /// Thrown when CAT is disabled (AppConfig.Cat.Enabled == false) or no + /// connection has ever been established. + /// + Task SetPttAsync(bool transmitting, CancellationToken cancellationToken = default); +} diff --git a/src/OpenWSFZ.Abstractions/IPttController.cs b/src/OpenWSFZ.Abstractions/IPttController.cs index 1e541a3..488736f 100644 --- a/src/OpenWSFZ.Abstractions/IPttController.cs +++ b/src/OpenWSFZ.Abstractions/IPttController.cs @@ -6,11 +6,27 @@ namespace OpenWSFZ.Abstractions; /// /// v1 implementation (AudioOnlyPttController) drives PTT via audio output only: /// starts WASAPI audio playback; -/// stops it. Future implementations can add serial-port, CAT, or VOX keying without +/// stops it. For that implementation, forgetting to call after a +/// normal transmission is harmless because playback has, by construction, already finished. +/// The CAT/serial implementations (CatPttController, SerialRtsDtrPttController) +/// do not share that property: asserts PTT and returns once +/// playback completes, but the transmitter line stays physically asserted until a +/// separate call de-asserts it (after waiting +/// TailTimeMs). Future implementations can add further keying mechanisms without /// changing the state machine. /// /// /// +/// Contract: every call to MUST be followed by exactly one +/// call to in the caller's normal-completion path — not only +/// on abort/cancellation. Skipping it is not "harmless cleanup you can defer"; on CAT/serial +/// implementations it leaves the rig transmitting until PttWatchdog's 20-second +/// failsafe eventually forces a release, which is far too late to hold FT8 slot timing. See +/// dev-task 2026-07-12-cat-tx-ptt-missing-keyup-after-transmit.md for the incident this +/// note was added to prevent from recurring. +/// +/// +/// /// The pre-synthesised TX audio buffer is supplied via a separate /// LoadAudio(float[] samples) method on the concrete implementation before /// is called. This separates audio preparation (done at @@ -32,7 +48,11 @@ public interface IPttController : IAsyncDisposable /// /// Begins transmission. For AudioOnlyPttController, starts WASAPI playback - /// of the pre-loaded audio buffer. + /// of the pre-loaded audio buffer. For the CAT/serial implementations, asserts the PTT + /// line/command and plays the pre-loaded audio, returning once playback completes — the + /// PTT line/command remains asserted after this method returns; only + /// releases it. Callers MUST call after + /// every call to this method on the normal-completion path, not only on abort. /// /// Cancellation token; cancellation stops the transmission. /// @@ -41,8 +61,12 @@ public interface IPttController : IAsyncDisposable Task KeyDownAsync(CancellationToken ct = default); /// - /// Ends transmission and releases the audio device handle (or equivalent resource). - /// Safe to call when no transmission is in progress — treated as a no-op. + /// Ends transmission and releases the audio device handle (or equivalent resource); for + /// the CAT/serial implementations, also de-asserts the PTT line/command after waiting + /// TailTimeMs. Safe to call when no transmission is in progress — treated as a + /// no-op. Callers MUST call this exactly once after every call + /// in their normal-completion path — do not rely on PttWatchdog's failsafe timeout + /// as the ordinary release mechanism. /// /// Cancellation token. Task KeyUpAsync(CancellationToken ct = default); diff --git a/src/OpenWSFZ.Abstractions/IRadioConnection.cs b/src/OpenWSFZ.Abstractions/IRadioConnection.cs index e8a4a41..176036a 100644 --- a/src/OpenWSFZ.Abstractions/IRadioConnection.cs +++ b/src/OpenWSFZ.Abstractions/IRadioConnection.cs @@ -1,14 +1,23 @@ namespace OpenWSFZ.Abstractions; /// -/// Abstraction over a rig CAT connection (FR-031, FR-032, FR-045). +/// Abstraction over a rig CAT connection (FR-031, FR-032, FR-045, FR-056). /// Implementations are in OpenWSFZ.Rig; consumers outside that assembly /// depend only on this interface so the protocol details are hidden. /// /// -/// The p16 read-only restriction is hereby amended for frequency only (FR-045): +/// The p16 read-only restriction was first amended for frequency (FR-045): /// sends a frequency-set command to the rig. -/// No mode-set, PTT, or other rig-altering commands are defined here. +/// It is hereby further amended for PTT (FR-056): +/// sends a PTT-set command to key or unkey the transmitter. No mode-set or other +/// rig-altering command beyond frequency-set and PTT-set is defined here. +/// +/// +/// +/// All access to a given instance MUST be serialised +/// by its owner (see CatPollingService's wire-serialisation gate, FR-056) — +/// the implementations in OpenWSFZ.Rig assume request/response calls never +/// overlap on the underlying transport. /// /// public interface IRadioConnection @@ -40,6 +49,18 @@ public interface IRadioConnection /// Cancellation token. Task SetDialFrequencyMhzAsync(double frequencyMHz, CancellationToken cancellationToken = default); + /// + /// Commands the rig to key ( = true) or unkey + /// ( = false) the transmitter (FR-056). + /// This is a fire-and-forget set: the method sends the command — and, where the + /// underlying protocol provides one, reads and validates its acknowledgement — but + /// does not poll back to confirm the rig actually changed PTT state. + /// defines no PTT-state query. + /// + /// true to key PTT; false to unkey it. + /// Cancellation token. + Task SetPttAsync(bool transmitting, CancellationToken cancellationToken = default); + /// /// true after a successful and before /// is called. diff --git a/src/OpenWSFZ.Abstractions/PttConfig.cs b/src/OpenWSFZ.Abstractions/PttConfig.cs new file mode 100644 index 0000000..b04cfc2 --- /dev/null +++ b/src/OpenWSFZ.Abstractions/PttConfig.cs @@ -0,0 +1,62 @@ +namespace OpenWSFZ.Abstractions; + +/// +/// PTT (push-to-talk) keying configuration (FR-056). +/// All fields have defaults so a partial or absent ptt JSON key loads without +/// error, and the all-defaults instance is byte-for-byte today's pre-cat-tx-ptt +/// behaviour: audio-only VOX keying, nothing else touched. +/// +public sealed record PttConfig +{ + /// + /// Selects which IPttController implementation is registered at daemon startup. + /// Recognised values: "AudioVox" (default — existing VOX-only behaviour, + /// unchanged), "CatCommand" (keys PTT via the CAT link), "SerialRtsDtr" + /// (keys PTT via a raw RTS/DTR serial control line, independent of any CAT connection). + /// Unknown values log a Warning and fall back to "AudioVox". + /// + public string Method { get; init; } = "AudioVox"; + + /// + /// Serial port used for SerialRtsDtr PTT keying. Independent of + /// — in practice RTS/DTR PTT wiring is + /// frequently on a different physical interface than the CAT link. + /// Platform default: COM7 on Windows, /dev/ttyUSB1 on Linux, + /// /dev/cu.usbserial-ptt on macOS. + /// Used only when is "SerialRtsDtr". + /// + public string SerialPort { get; init; } = + OperatingSystem.IsWindows() ? "COM7" : + OperatingSystem.IsMacOS() ? "/dev/cu.usbserial-ptt" : + "/dev/ttyUSB1"; + + /// + /// Which serial control line asserts PTT. Recognised values: "Rts" (default), + /// "Dtr". Unrecognised values log a Warning and fall back to "Rts". + /// Used only when is "SerialRtsDtr". + /// + public string SerialLine { get; init; } = "Rts"; + + /// + /// Milliseconds to wait after asserting PTT before TX audio playback begins. + /// Gives the rig's PA time to come up cleanly before audio appears. Default: 50. + /// Used only by CatPttController/SerialRtsDtrPttController. + /// + public int LeadTimeMs { get; init; } = 50; + + /// + /// Milliseconds to wait after TX audio playback ends before PTT is released. + /// Avoids clipping the tail of the last symbol. Default: 50. + /// Used only by CatPttController/SerialRtsDtrPttController. + /// + public int TailTimeMs { get; init; } = 50; + + /// + /// Hard failsafe ceiling, in milliseconds, on how long PTT may remain asserted + /// without KeyUpAsync completing its release step. If exceeded, PTT is + /// force-released (bypassing ) and an Error is logged. + /// Default: 20000 (comfortably above one FT8 transmission's 12 640 ms). + /// Used only by CatPttController/SerialRtsDtrPttController. + /// + public int WatchdogTimeoutMs { get; init; } = 20000; +} diff --git a/src/OpenWSFZ.Config/ConfigJsonContext.cs b/src/OpenWSFZ.Config/ConfigJsonContext.cs index 515ea79..c3fe96f 100644 --- a/src/OpenWSFZ.Config/ConfigJsonContext.cs +++ b/src/OpenWSFZ.Config/ConfigJsonContext.cs @@ -16,6 +16,7 @@ namespace OpenWSFZ.Config; [JsonSerializable(typeof(LoggingConfig))] [JsonSerializable(typeof(DecodeLogConfig))] [JsonSerializable(typeof(CatConfig))] +[JsonSerializable(typeof(PttConfig))] [JsonSerializable(typeof(TxConfig))] [JsonSerializable(typeof(RemoteAccessConfig))] [JsonSerializable(typeof(DecoderConfig))] diff --git a/src/OpenWSFZ.Config/JsonConfigStore.cs b/src/OpenWSFZ.Config/JsonConfigStore.cs index 8e07b0a..bc88720 100644 --- a/src/OpenWSFZ.Config/JsonConfigStore.cs +++ b/src/OpenWSFZ.Config/JsonConfigStore.cs @@ -42,6 +42,23 @@ public JsonConfigStore(string path, ILogger? logger = null) /// public async Task SaveAsync(AppConfig config, CancellationToken ct = default) { + // Belt-and-braces guard: SaveAsync is the one true chokepoint all persistence + // goes through, not just the POST /api/v1/config handler. Mirrors the same + // STJ source-gen null-vs-initialiser guard applied at load time (see Load() + // below) and at the HTTP write path (WebApp.cs) so that any caller building a + // partial AppConfig can never persist (or hand back via Current) a null Ptt. + // + // Falls back to the already-persisted _current.Ptt rather than a hardcoded + // new PttConfig(): web/js/settings.js never sends a "ptt" key at all (no + // Settings-page UI exists for it), so a hardcoded default would silently + // revert an operator's manually-configured ptt.method (e.g. "CatCommand") + // back to "AudioVox" on every ordinary, unrelated Settings-page save — the + // exact stuck-on-VOX symptom this guard exists to prevent. Only falls back + // further to new PttConfig() if _current.Ptt is itself somehow null, which + // should not happen given Load()'s own guard below. + if (config.Ptt is null) + config = config with { Ptt = _current.Ptt ?? new PttConfig() }; + var dir = Path.GetDirectoryName(_path) ?? throw new InvalidOperationException($"Cannot determine directory for '{_path}'."); @@ -145,6 +162,14 @@ private static AppConfig Load(string path) if (config.ExternalReporting is null) config = config with { ExternalReporting = new ExternalReportingConfig() }; + // "ptt" key is absent in config files written before the cat-tx-ptt change. + // Same STJ source-gen null-vs-initialiser guard as + // "logging"/"decodeLog"/"remoteAccess"/"decodeNoiseSuppression"/"externalReporting" + // above. Defaulting to new PttConfig() (Method = "AudioVox") preserves today's + // VOX-only behaviour exactly (FR-056). + if (config.Ptt is null) + config = config with { Ptt = new PttConfig() }; + // "cat" key is intentionally nullable: absent in config files written before p16. // Null is the correct default (CAT disabled); no guard needed — consumers use // (config.Cat ?? new CatConfig()) to get a non-null value. @@ -215,6 +240,7 @@ private static void CreateDefault(string path) new AppConfig() with { Cat = new CatConfig(), + Ptt = new PttConfig(), Tx = new TxConfig(), RemoteAccess = new RemoteAccessConfig(), Decoder = new DecoderConfig(), diff --git a/src/OpenWSFZ.Daemon/AudioOnlyPttController.cs b/src/OpenWSFZ.Daemon/AudioOnlyPttController.cs index a5f83a1..e01acf5 100644 --- a/src/OpenWSFZ.Daemon/AudioOnlyPttController.cs +++ b/src/OpenWSFZ.Daemon/AudioOnlyPttController.cs @@ -1,8 +1,6 @@ #if WASAPI_SUPPORTED using System.Runtime.Versioning; using Microsoft.Extensions.Logging; -using NAudio.CoreAudioApi; -using NAudio.Wave; using OpenWSFZ.Abstractions; using OpenWSFZ.Audio; @@ -39,9 +37,11 @@ public sealed class AudioOnlyPttController : IPttController // The pre-loaded audio buffer. Null until LoadAudio is called. private float[]? _audioSamples; - // The active WasapiOut instance (non-null only while transmission is in progress). - private WasapiOut? _activePlayer; - private readonly SemaphoreSlim _playerLock = new(1, 1); + // Shared WASAPI device-open/play/stop/dispose helper (cat-tx-ptt, design.md + // Decision 3). Only ever touched via the real (non-override) KeyDownAsync path; + // unit tests always supply _playerOverride, so this instance's WasapiOut is never + // opened during tests — KeyUpAsync/DisposeAsync remain graceful no-ops for them. + private readonly WasapiTxPlayer _player; // ── Constructors ───────────────────────────────────────────────────────── @@ -53,6 +53,7 @@ public AudioOnlyPttController( _configStore = configStore; _logger = logger; _playerOverride = null; + _player = new WasapiTxPlayer(logger); } /// @@ -68,6 +69,7 @@ internal AudioOnlyPttController( _configStore = configStore; _logger = logger; _playerOverride = playerOverride; + _player = new WasapiTxPlayer(logger); } // ── Public API ──────────────────────────────────────────────────────────── @@ -114,7 +116,7 @@ public async Task KeyDownAsync(CancellationToken ct = default) return; } - await PlayWasapiAsync(samples, deviceId, ct).ConfigureAwait(false); + await _player.PlayAsync(samples, deviceId, ct).ConfigureAwait(false); _logger.LogInformation("TX KeyDown — playback completed."); } @@ -126,157 +128,10 @@ public async Task KeyDownAsync(CancellationToken ct = default) public async Task KeyUpAsync(CancellationToken ct = default) { _logger.LogInformation("TX KeyUp — stopping playback."); - - await _playerLock.WaitAsync(ct).ConfigureAwait(false); - try - { - StopAndReleasePlayer(); - } - finally - { - _playerLock.Release(); - } + await _player.StopAsync(ct).ConfigureAwait(false); } /// - public async ValueTask DisposeAsync() - { - await _playerLock.WaitAsync().ConfigureAwait(false); - try - { - StopAndReleasePlayer(); - } - finally - { - _playerLock.Release(); - _playerLock.Dispose(); - } - } - - // ── Helpers ─────────────────────────────────────────────────────────────── - - private async Task PlayWasapiAsync(float[] samples, string? deviceId, CancellationToken ct) - { - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - - await _playerLock.WaitAsync(ct).ConfigureAwait(false); - try - { - // Obtain the output device. - using var enumerator = new MMDeviceEnumerator(); - MMDevice device; - if (!string.IsNullOrEmpty(deviceId)) - { - device = enumerator.GetDevice(deviceId); - _logger.LogDebug("TX: opened output device '{DeviceId}'.", deviceId); - } - else - { - device = enumerator.GetDefaultAudioEndpoint(DataFlow.Render, Role.Multimedia); - _logger.LogDebug("TX: using default output device."); - } - - var waveFormat = WaveFormat.CreateIeeeFloatWaveFormat(48_000, channels: 1); - var provider = new FloatArraySampleProvider(samples, waveFormat); - - _activePlayer = new WasapiOut(device, AudioClientShareMode.Shared, - useEventSync: true, latency: 200); - - _activePlayer.PlaybackStopped += (_, e) => - { - if (e.Exception is not null) - tcs.TrySetException(e.Exception); - else - tcs.TrySetResult(); - }; - - _activePlayer.Init(provider); - _activePlayer.Play(); - } - finally - { - _playerLock.Release(); - } - - // Wait outside the lock so KeyUpAsync can acquire it to stop playback. - // Register callback is synchronous — await is not available, so KeyUpAsync is - // fire-and-forget. Observe the task to prevent an unhandled exception on the - // finaliser thread; StopAndReleasePlayer already catches and logs internally so - // a fault here indicates a genuinely unexpected failure. - using var reg = ct.Register(() => - { - KeyUpAsync(CancellationToken.None).ContinueWith( - t => _logger.LogWarning(t.Exception, "KeyUpAsync threw during cancellation — ignoring."), - CancellationToken.None, - TaskContinuationOptions.OnlyOnFaulted, - TaskScheduler.Default); - tcs.TrySetCanceled(); - }); - - try - { - await tcs.Task.ConfigureAwait(false); - } - catch (TaskCanceledException) - { - // Cancellation is expected; caller will handle it. - throw new OperationCanceledException(ct); - } - } - - private void StopAndReleasePlayer() - { - if (_activePlayer is null) return; - - try - { - _activePlayer.Stop(); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "WasapiOut.Stop() threw during TX release — ignoring."); - } - - try - { - _activePlayer.Dispose(); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "WasapiOut.Dispose() threw during TX release — ignoring."); - } - - _activePlayer = null; - } - - // ── Inner helper: ISampleProvider over float[] ──────────────────────────── - - /// - /// Thin wrapper over a pre-filled float[] buffer. - /// Used to feed the synthesised TX audio to WasapiOut.Init. - /// - private sealed class FloatArraySampleProvider : ISampleProvider - { - private readonly float[] _samples; - private int _position; - - public FloatArraySampleProvider(float[] samples, WaveFormat waveFormat) - { - _samples = samples; - WaveFormat = waveFormat; - } - - public WaveFormat WaveFormat { get; } - - public int Read(float[] buffer, int offset, int count) - { - int available = _samples.Length - _position; - int toCopy = Math.Min(available, count); - if (toCopy <= 0) return 0; - Buffer.BlockCopy(_samples, _position * sizeof(float), buffer, offset * sizeof(float), toCopy * sizeof(float)); - _position += toCopy; - return toCopy; - } - } + public async ValueTask DisposeAsync() => await _player.DisposeAsync().ConfigureAwait(false); } #endif diff --git a/src/OpenWSFZ.Daemon/Cat/CatPollingService.cs b/src/OpenWSFZ.Daemon/Cat/CatPollingService.cs index f1c7224..fe73a56 100644 --- a/src/OpenWSFZ.Daemon/Cat/CatPollingService.cs +++ b/src/OpenWSFZ.Daemon/Cat/CatPollingService.cs @@ -31,8 +31,18 @@ namespace OpenWSFZ.Daemon.Cat; /// sends a frequency-set command on the currently active connection and updates /// optimistically. /// +/// +/// +/// Also implements (FR-056): +/// sends a PTT-set command on the currently active connection, serialised through the +/// same gate the poll loop and +/// already use, so poll reads, tune commands, and PTT commands can never interleave bytes +/// on the wire (design.md Decision 1 of the cat-tx-ptt change). This is the only +/// component outside CatPollingService permitted to key PTT via CAT — no other +/// component holds a direct reference to the shared . +/// /// -public class CatPollingService : IHostedService, IAsyncDisposable, ICatTuner, ICatController +public class CatPollingService : IHostedService, IAsyncDisposable, ICatTuner, ICatController, ICatPttGate { private static readonly TimeSpan RetryDelay = TimeSpan.FromSeconds(2); @@ -227,6 +237,52 @@ await conn.SetDialFrequencyMhzAsync(frequencyMHz, cancellationToken) } } + // ── ICatPttGate ─────────────────────────────────────────────────────────── + + /// + /// + /// Acquires the same gate the poll loop and + /// use, so a PTT command can never interleave + /// bytes on the wire with an in-flight poll read or tune command (FR-056, + /// design.md Decision 1). Throws before ever touching the lock when CAT is + /// disabled or no connection has ever been established, so a misconfigured + /// CAT-command PTT controller fails fast rather than silently doing nothing. + /// + public async Task SetPttAsync(bool transmitting, CancellationToken cancellationToken = default) + { + if (!(_configStore.Current.Cat?.Enabled ?? false)) + throw new InvalidOperationException( + "Cannot key PTT via CAT — CAT is disabled (AppConfig.Cat.Enabled = false)."); + + var conn = _activeConnection + ?? throw new InvalidOperationException( + "No active rig connection — CAT is not yet connected."); + + await _connectionLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + // Re-check after lock acquisition: _activeConnection may have been + // nulled by the poll loop's error handler between the fast pre-check + // above and here (TOCTOU guard) — same pattern as SetDialFrequencyAsync. + conn = _activeConnection + ?? throw new InvalidOperationException( + "No active rig connection — CAT is not yet connected."); + + _logger.LogInformation( + "CAT: dispatching PTT command — transmitting={Transmitting} via {ConnType}.", + transmitting, conn.GetType().Name); + + await conn.SetPttAsync(transmitting, cancellationToken).ConfigureAwait(false); + + _logger.LogInformation( + "CAT: PTT command sent — transmitting={Transmitting}.", transmitting); + } + finally + { + _connectionLock.Release(); + } + } + // ── ICatController ─────────────────────────────────────────────────────── /// diff --git a/src/OpenWSFZ.Daemon/CatPttController.cs b/src/OpenWSFZ.Daemon/CatPttController.cs new file mode 100644 index 0000000..a429bcc --- /dev/null +++ b/src/OpenWSFZ.Daemon/CatPttController.cs @@ -0,0 +1,259 @@ +#if WASAPI_SUPPORTED +using System.Runtime.Versioning; +using Microsoft.Extensions.Logging; +using OpenWSFZ.Abstractions; + +namespace OpenWSFZ.Daemon; + +/// +/// CAT-command implementation of (FR-056). +/// +/// +/// PTT is asserted/de-asserted via (implemented by +/// CatPollingService, guaranteeing every command is serialised against the CAT +/// poll loop — see design.md Decision 1 of the cat-tx-ptt change). TX audio is +/// played via the same shared helper +/// uses. +/// +/// +/// +/// KeyDownAsync sequence: assert PTT → wait LeadTimeMs → play audio → +/// await completion. KeyUpAsync sequence: stop playback → wait TailTimeMs +/// → release PTT. A guards the entire asserted period — +/// see its own remarks for the failsafe contract. PTT is guaranteed released on any +/// exception during KeyDownAsync, on cancellation, on a watchdog trip, and on +/// . +/// +/// +/// Registered as a singleton in DI by Program.cs when +/// AppConfig.Ptt.Method == "CatCommand". +/// +[SupportedOSPlatform("windows")] +public sealed class CatPttController : IPttController +{ + private readonly ICatPttGate _pttGate; + private readonly IConfigStore _configStore; + private readonly ILogger _logger; + private readonly WasapiTxPlayer _player; + private readonly PttWatchdog _watchdog; + + // Internal seam — replaced in tests with a delegate that does not open WASAPI. + // Production constructor leaves this null, triggering the real WASAPI path. + // Mirrors AudioOnlyPttController's own test seam exactly. + private readonly Func? _playerOverride; + + private float[]? _audioSamples; + + // True from the instant ICatPttGate.SetPttAsync(true) returns until PTT has been + // released (normally, by exception, by watchdog, or by DisposeAsync). Guards against + // a double release racing between KeyUpAsync and a watchdog trip. + private volatile bool _pttAsserted; + + // ── Call-serialisation (design.md Decision 6 amendment, task 17.2) ───────── + // + // KeyDownAsync/KeyUpAsync were originally written assuming exactly one caller (the + // active QsoAnswererService/QsoCallerService) ever calls them. The Settings-page Test + // button (POST /api/v1/ptt/test) is a second, independent caller of this same DI + // singleton. Without a guard here, a Test click racing a real in-progress transmission + // could re-arm the shared watchdog and — worse — its own short KeyUpAsync could + // de-assert PTT mid-transmission, physically unkeying a real over-the-air transmission. + // _txLock is held for the ENTIRE KeyDownAsync → KeyUpAsync critical section (not just + // KeyDownAsync itself), so a second caller's KeyDownAsync cannot begin asserting PTT + // until the first caller's KeyUpAsync has fully completed. Released exactly once per + // successful acquire by whichever of KeyUpAsync / ForceReleaseAsync / DisposeAsync (or + // KeyDownAsync's own catch block, for a pre-assert failure) actually performs the + // release — see ReleaseTxLockOnce. + private readonly SemaphoreSlim _txLock = new(1, 1); + private volatile bool _txLockHeld; + + // ── Constructors ───────────────────────────────────────────────────────── + + /// Production constructor — uses the real CAT gate and real WASAPI output. + public CatPttController( + ICatPttGate pttGate, + IConfigStore configStore, + ILogger logger) + { + _pttGate = pttGate; + _configStore = configStore; + _logger = logger; + _playerOverride = null; + _player = new WasapiTxPlayer(logger); + _watchdog = new PttWatchdog(logger, nameof(CatPttController)); + } + + /// + /// Internal test constructor — injects a delegate in place of the real WASAPI path, + /// mirroring 's own test seam. + /// is typically a mock so tests can assert call order/values without a real CAT link. + /// + internal CatPttController( + ICatPttGate pttGate, + IConfigStore configStore, + ILogger logger, + Func playerOverride) + { + _pttGate = pttGate; + _configStore = configStore; + _logger = logger; + _playerOverride = playerOverride; + _player = new WasapiTxPlayer(logger); + _watchdog = new PttWatchdog(logger, nameof(CatPttController)); + } + + // ── Public API ──────────────────────────────────────────────────────────── + + /// + /// Sets the TX audio buffer that will be played on the next call. + /// Must be called before . + /// + /// + /// Mono float32 PCM at 48 000 Hz, amplitude in [−0.5, +0.5]. + /// + public void LoadAudio(float[] samples) + { + ArgumentNullException.ThrowIfNull(samples); + _audioSamples = samples; + _logger.LogDebug("TX audio loaded: {Samples} samples ({DurationMs:F0} ms).", + samples.Length, + samples.Length * 1000.0 / 48_000); + } + + /// + /// Begins transmission: asserts PTT via , waits + /// LeadTimeMs, then plays the pre-loaded audio buffer and awaits completion. + /// + /// + /// Thrown when has not been called before this method. + /// PTT is NOT asserted in this case. + /// + public async Task KeyDownAsync(CancellationToken ct = default) + { + var samples = _audioSamples + ?? throw new InvalidOperationException( + "LoadAudio must be called before KeyDownAsync. " + + "Call LoadAudio with the synthesised TX audio buffer first."); + + var ptt = _configStore.Current.Ptt; + var deviceId = _configStore.Current.AudioOutputDeviceId; + + // task 17.2: acquire the call-serialisation lock before touching PTT at all, so a + // second concurrent caller blocks here until this cycle's KeyUpAsync completes. + await _txLock.WaitAsync(ct).ConfigureAwait(false); + _txLockHeld = true; + + try + { + await _pttGate.SetPttAsync(true, ct).ConfigureAwait(false); + _pttAsserted = true; + _watchdog.Arm(ptt.WatchdogTimeoutMs, ForceReleaseAsync); + _logger.LogInformation("CatPttController: KeyDown — PTT asserted (CAT)."); + + try + { + if (ptt.LeadTimeMs > 0) + await Task.Delay(ptt.LeadTimeMs, ct).ConfigureAwait(false); + + if (_playerOverride is not null) + await _playerOverride(samples, deviceId, ct).ConfigureAwait(false); + else + await _player.PlayAsync(samples, deviceId, ct).ConfigureAwait(false); + } + catch + { + // Any failure during the lead-time wait or playback (including cancellation) + // must still release PTT before the exception reaches the caller. KeyUpAsync + // releases _txLock itself (via ReleaseTxLockOnce) as part of that release. + await KeyUpAsync(CancellationToken.None).ConfigureAwait(false); + throw; + } + } + catch + { + // Either SetPttAsync(true) itself failed (PTT was never asserted, so KeyUpAsync + // above never ran and never released the lock), or the inner catch already ran + // KeyUpAsync, which already released it — ReleaseTxLockOnce is idempotent either + // way (task 17.2). + ReleaseTxLockOnce(); + throw; + } + } + + /// + /// Ends transmission: stops any in-progress playback, waits TailTimeMs, then + /// releases PTT via . Safe to call when PTT is not asserted + /// — treated as a no-op beyond stopping playback and disarming the watchdog. + /// + public async Task KeyUpAsync(CancellationToken ct = default) + { + _watchdog.Disarm(); + + if (_playerOverride is null) + await _player.StopAsync(ct).ConfigureAwait(false); + + if (!_pttAsserted) return; + + var ptt = _configStore.Current.Ptt; + if (ptt.TailTimeMs > 0) + await Task.Delay(ptt.TailTimeMs, CancellationToken.None).ConfigureAwait(false); + + await _pttGate.SetPttAsync(false, CancellationToken.None).ConfigureAwait(false); + _pttAsserted = false; + _logger.LogInformation("CatPttController: KeyUp — PTT released (CAT)."); + ReleaseTxLockOnce(); + } + + /// + /// Watchdog-forced release (design.md Decision 4): bypasses TailTimeMs entirely. + /// + private async Task ForceReleaseAsync() + { + if (!_pttAsserted) return; + _pttAsserted = false; // set first so a racing KeyUpAsync becomes a no-op + + if (_playerOverride is null) + await _player.StopAsync(CancellationToken.None).ConfigureAwait(false); + + await _pttGate.SetPttAsync(false, CancellationToken.None).ConfigureAwait(false); + ReleaseTxLockOnce(); + } + + /// + /// Forces PTT release if still asserted and releases the audio device handle. + /// + public async ValueTask DisposeAsync() + { + _watchdog.Dispose(); + + if (_pttAsserted) + { + _pttAsserted = false; + try + { + await _pttGate.SetPttAsync(false, CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "CatPttController: PTT release during DisposeAsync threw — ignoring."); + } + ReleaseTxLockOnce(); + } + + await _player.DisposeAsync().ConfigureAwait(false); + } + + /// + /// Releases at most once per acquire (task 17.2). Guards against + /// double-release when KeyUpAsync, a watchdog-forced , and + /// can each be the one that actually performs the release for + /// a given KeyDownAsync→KeyUpAsync cycle. + /// + private void ReleaseTxLockOnce() + { + if (!_txLockHeld) return; + _txLockHeld = false; + _txLock.Release(); + } +} +#endif diff --git a/src/OpenWSFZ.Daemon/Program.cs b/src/OpenWSFZ.Daemon/Program.cs index 5f9bc31..bff8775 100644 --- a/src/OpenWSFZ.Daemon/Program.cs +++ b/src/OpenWSFZ.Daemon/Program.cs @@ -415,14 +415,36 @@ void ConfigureLogging(ILoggingBuilder lb) services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(sp => sp.GetRequiredService()); + // ICatPttGate (task 6.4, FR-056): the narrow seam CatPttController depends on so + // PTT commands are always serialised against the poll loop — see CatPollingService's + // class remarks and design.md Decision 1 of the cat-tx-ptt change. + services.AddSingleton(sp => sp.GetRequiredService()); services.AddHostedService(sp => sp.GetRequiredService()); - // PTT controller (task 4.5): AudioOnlyPttController on Windows; NullPttController elsewhere. - // CA1416: suppressed — code is only compiled when WASAPI_SUPPORTED is defined, - // which is set exclusively on Windows targets (see .csproj). + // PTT controller (task 4.5, extended by cat-tx-ptt task 11.1, FR-056): a three-way + // switch on AppConfig.Ptt.Method selects exactly one IPttController implementation + // at startup, falling back to AudioOnlyPttController/NullPttController per the + // existing WASAPI_SUPPORTED platform gating when WASAPI is unavailable or the + // configured Method is unrecognised (design.md Decision 6 — matches the existing + // CatConfig.RigModel unknown-value handling, FR-034). A missing ptt config key + // defaults to Method = "AudioVox", preserving today's behaviour exactly. + // CA1416: suppressed — the CatCommand/SerialRtsDtr branches are only compiled when + // WASAPI_SUPPORTED is defined, which is set exclusively on Windows targets + // (see .csproj) — same rationale as the pre-existing AudioVox branch. #pragma warning disable CA1416 #if WASAPI_SUPPORTED - services.AddSingleton(); + switch (PttControllerSelector.Resolve(configStore.Current.Ptt.Method, startupLogger)) + { + case PttControllerKind.CatCommand: + services.AddSingleton(); + break; + case PttControllerKind.SerialRtsDtr: + services.AddSingleton(); + break; + default: + services.AddSingleton(); + break; + } #else services.AddSingleton(); #endif diff --git a/src/OpenWSFZ.Daemon/PttControllerSelector.cs b/src/OpenWSFZ.Daemon/PttControllerSelector.cs new file mode 100644 index 0000000..7099967 --- /dev/null +++ b/src/OpenWSFZ.Daemon/PttControllerSelector.cs @@ -0,0 +1,45 @@ +using Microsoft.Extensions.Logging; + +namespace OpenWSFZ.Daemon; + +/// Identifies which IPttController implementation to register (FR-056). +internal enum PttControllerKind +{ + AudioVox, + CatCommand, + SerialRtsDtr, +} + +/// +/// Pure mapping from AppConfig.Ptt.Method to a +/// (FR-056, design.md Decision 6), extracted from Program.cs's DI wiring so the +/// selection logic — including the unrecognised-value fallback and its Warning log — is +/// directly unit-testable (task 12.9) without spinning up the whole daemon host. +/// +internal static class PttControllerSelector +{ + /// + /// Resolves to a . + /// Recognised values: "AudioVox", "CatCommand", "SerialRtsDtr". + /// Any other value logs a Warning naming the invalid value (matching the existing + /// CatConfig.RigModel unknown-value handling, FR-034) and falls back to + /// . + /// + public static PttControllerKind Resolve(string method, ILogger logger) + { + switch (method) + { + case "CatCommand": + return PttControllerKind.CatCommand; + case "SerialRtsDtr": + return PttControllerKind.SerialRtsDtr; + case "AudioVox": + return PttControllerKind.AudioVox; + default: + logger.LogWarning( + "ptt.method '{Method}' is not recognised (expected AudioVox, CatCommand, " + + "or SerialRtsDtr) — falling back to AudioVox.", method); + return PttControllerKind.AudioVox; + } + } +} diff --git a/src/OpenWSFZ.Daemon/PttWatchdog.cs b/src/OpenWSFZ.Daemon/PttWatchdog.cs new file mode 100644 index 0000000..88e1319 --- /dev/null +++ b/src/OpenWSFZ.Daemon/PttWatchdog.cs @@ -0,0 +1,118 @@ +using Microsoft.Extensions.Logging; + +namespace OpenWSFZ.Daemon; + +/// +/// Mechanism-agnostic PTT failsafe watchdog (FR-056, design.md Decision 4), shared by +/// CatPttController and SerialRtsDtrPttController — the two +/// IPttController implementations that assert a real physical PTT signal. +/// +/// +/// starts a timer the instant PTT is asserted. If +/// has not been called before watchdogTimeoutMs elapses, the watchdog invokes the +/// supplied forced-release callback and logs at Error (including the elapsed hold +/// duration) — the last line of defence against a stuck key-down leaving a real +/// transmitter keyed indefinitely. Does not apply to AudioOnlyPttController, which +/// asserts no independent physical PTT signal (VOX keying is the rig's own responsibility). +/// +/// +/// +/// Deliberately has no dependency on WASAPI or any PTT-assertion mechanism — it is a +/// plain timer + callback so it can be unit-tested (task 12.8) without requiring +/// WASAPI_SUPPORTED or Windows. +/// +/// +internal sealed class PttWatchdog : IDisposable +{ + private readonly ILogger _logger; + private readonly string _controllerName; + private readonly object _gate = new(); + + private Timer? _timer; + private DateTime _armedAtUtc; + private Func? _forceReleaseAsync; + + /// Logger used for the Error-level watchdog-fired message. + /// + /// Name of the owning controller (e.g. "CatPttController"), included in the + /// watchdog-fired log line so hardware-acceptance testing can identify which + /// mechanism tripped. + /// + public PttWatchdog(ILogger logger, string controllerName) + { + _logger = logger; + _controllerName = controllerName; + } + + /// + /// Arms the watchdog. Call the instant PTT is asserted in KeyDownAsync. + /// Re-arming an already-armed watchdog replaces the pending timer. + /// + /// Milliseconds before the watchdog fires. + /// + /// Invoked if the watchdog fires — SHALL force PTT release, bypassing any configured + /// tail time. + /// + public void Arm(int watchdogTimeoutMs, Func forceReleaseAsync) + { + lock (_gate) + { + _forceReleaseAsync = forceReleaseAsync; + _armedAtUtc = DateTime.UtcNow; + _timer?.Dispose(); + _timer = new Timer(OnFired, null, watchdogTimeoutMs, Timeout.Infinite); + } + } + + /// + /// Disarms the watchdog. Call the instant KeyUpAsync begins its release step, + /// so a race between a normal, in-time release and the watchdog firing cannot occur. + /// Safe to call when not armed. + /// + public void Disarm() + { + lock (_gate) + { + _timer?.Dispose(); + _timer = null; + _forceReleaseAsync = null; + } + } + + private void OnFired(object? state) + { + Func? callback; + TimeSpan elapsed; + + lock (_gate) + { + // Disarmed between the timer firing and this callback running — no-op. + if (_timer is null) return; + + callback = _forceReleaseAsync; + elapsed = DateTime.UtcNow - _armedAtUtc; + + _timer.Dispose(); + _timer = null; + _forceReleaseAsync = null; + } + + if (callback is null) return; + + _logger.LogError( + "{Controller}: watchdog fired after {ElapsedMs} ms — forcing PTT release.", + _controllerName, (int)elapsed.TotalMilliseconds); + + // Fire-and-forget: OnFired runs on a ThreadPool timer thread with no async path + // back to a caller. Observe the task so a fault in the forced-release path itself + // is logged rather than becoming an unobserved task exception. + _ = callback().ContinueWith( + t => _logger.LogError(t.Exception, + "{Controller}: forced watchdog release threw.", _controllerName), + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted, + TaskScheduler.Default); + } + + public void Dispose() => Disarm(); +} diff --git a/src/OpenWSFZ.Daemon/QsoAnswererService.cs b/src/OpenWSFZ.Daemon/QsoAnswererService.cs index 70a3930..813650e 100644 --- a/src/OpenWSFZ.Daemon/QsoAnswererService.cs +++ b/src/OpenWSFZ.Daemon/QsoAnswererService.cs @@ -1188,6 +1188,24 @@ private async Task TransmitAsync(string message, int freqHz, CancellationToken s } finally { + // dev-task 2026-07-12-cat-tx-ptt-missing-keyup-after-transmit.md: KeyUpAsync + // MUST run here, not only from the abort path (SafeAbortToIdleAsync) — that path + // is for operator/watchdog-initiated abort of an in-progress *session*, this is + // the ordinary, successful (or cancelled) end of a single transmission. Every + // KeyDownAsync must be paired with a KeyUpAsync in the normal-completion path or + // PTT relies on the 20 s PttWatchdog failsafe to ever release, which breaks FT8 + // slot timing on every single transmission. Use CancellationToken.None so release + // still happens even if linked.Token is already cancelled — both controllers' + // KeyUpAsync bodies already tolerate being called when nothing is asserted (a + // safe no-op). + try + { + await _pttController.KeyUpAsync(CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "QsoAnswererService: KeyUpAsync threw after TransmitAsync — ignoring."); + } _keying = false; PublishKeyingTransition(); } diff --git a/src/OpenWSFZ.Daemon/QsoCallerService.cs b/src/OpenWSFZ.Daemon/QsoCallerService.cs index 1e99bc8..8efb9c9 100644 --- a/src/OpenWSFZ.Daemon/QsoCallerService.cs +++ b/src/OpenWSFZ.Daemon/QsoCallerService.cs @@ -951,6 +951,24 @@ private async Task TransmitAsync(string message, int freqHz, CancellationToken s } finally { + // dev-task 2026-07-12-cat-tx-ptt-missing-keyup-after-transmit.md: KeyUpAsync + // MUST run here, not only from the abort path (AbortAsync/SafeAbortToIdleAsync) + // — that path is for operator/watchdog-initiated abort of an in-progress + // *session*, this is the ordinary, successful (or cancelled) end of a single + // transmission. Every KeyDownAsync must be paired with a KeyUpAsync in the + // normal-completion path or PTT relies on the 20 s PttWatchdog failsafe to ever + // release, which breaks FT8 slot timing on every single transmission. Use + // CancellationToken.None so release still happens even if linked.Token is + // already cancelled — both controllers' KeyUpAsync bodies already tolerate being + // called when nothing is asserted (a safe no-op). + try + { + await _pttController.KeyUpAsync(CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "QsoCallerService: KeyUpAsync threw after TransmitAsync — ignoring."); + } _keying = false; PublishKeyingTransition(); } diff --git a/src/OpenWSFZ.Daemon/SerialRtsDtrPttController.cs b/src/OpenWSFZ.Daemon/SerialRtsDtrPttController.cs new file mode 100644 index 0000000..93f5d34 --- /dev/null +++ b/src/OpenWSFZ.Daemon/SerialRtsDtrPttController.cs @@ -0,0 +1,345 @@ +#if WASAPI_SUPPORTED +using System.Runtime.Versioning; +using Microsoft.Extensions.Logging; +using OpenWSFZ.Abstractions; +using OpenWSFZ.Rig.Internal; + +namespace OpenWSFZ.Daemon; + +/// +/// Serial RTS/DTR implementation of (FR-056). +/// +/// +/// PTT is asserted/de-asserted by toggling a raw RTS or DTR control line (per +/// AppConfig.Ptt.SerialLine) on its own, independently-configured serial port +/// (AppConfig.Ptt.SerialPort) — entirely independent of any CAT connection or +/// (design.md Decision 5 of the cat-tx-ptt change). TX +/// audio is played via the same shared helper +/// uses. +/// +/// +/// +/// Sequencing mirrors exactly: KeyDownAsync +/// asserts the line → waits LeadTimeMs → plays audio → awaits completion. +/// KeyUpAsync stops playback → waits TailTimeMs → de-asserts the line. +/// A guards the entire asserted period. +/// +/// +/// +/// The serial port is opened lazily on first use and kept open for the controller's +/// lifetime; a port-open failure propagates as an exception from KeyDownAsync +/// rather than silently skipping PTT assertion (task 9.5). Changing +/// AppConfig.Ptt.SerialPort while the daemon is running does not reopen an +/// already-open port — unlike CAT's serial port, this is not hot-reloaded. +/// +/// +/// Registered as a singleton in DI by Program.cs when +/// AppConfig.Ptt.Method == "SerialRtsDtr". +/// +[SupportedOSPlatform("windows")] +public sealed class SerialRtsDtrPttController : IPttController +{ + // Baud rate is irrelevant for RTS/DTR-only PTT keying — no data is ever exchanged + // on the TX/RX pins, only the RTS/DTR control lines are toggled. Kept at a + // conventional default purely because System.IO.Ports.SerialPort's constructor + // requires one; PttConfig deliberately has no baudRate field for this reason. + private const int DefaultBaudRate = 9600; + + private enum SerialLine { Rts, Dtr } + + private readonly IConfigStore _configStore; + private readonly ILogger _logger; + private readonly ISerialPort _port; + private readonly SemaphoreSlim _portLock = new(1, 1); + private readonly WasapiTxPlayer _player; + private readonly PttWatchdog _watchdog; + + // Internal seam — replaced in tests with a delegate that does not open WASAPI. + // Production constructor leaves this null, triggering the real WASAPI path. + private readonly Func? _playerOverride; + + private float[]? _audioSamples; + private volatile bool _pttAsserted; + + // The line actually asserted by the most recent KeyDownAsync, remembered so + // KeyUpAsync/ForceReleaseAsync de-assert the SAME line even if AppConfig.Ptt.SerialLine + // changes mid-transmission — de-asserting the wrong line would leave the originally- + // asserted one stuck high, which is exactly the failure mode this change exists to prevent. + private SerialLine? _assertedLine; + + // ── Call-serialisation (design.md Decision 6 amendment, task 17.2) ───────── + // See CatPttController's identical field for the full rationale: a second concurrent + // caller of this DI singleton (e.g. the Settings-page Test button) must not be able to + // interleave with an in-flight real transmission. _txLock is held for the ENTIRE + // KeyDownAsync → KeyUpAsync critical section, released exactly once per acquire by + // whichever of KeyUpAsync / ForceReleaseAsync / DisposeAsync (or KeyDownAsync's own + // catch block, for a pre-assert failure) actually performs the release. Distinct from + // _portLock above, which only guards the lazy port-open step. + private readonly SemaphoreSlim _txLock = new(1, 1); + private volatile bool _txLockHeld; + + // ── Constructors ───────────────────────────────────────────────────────── + + /// Production constructor — opens a real serial port from config. + public SerialRtsDtrPttController( + IConfigStore configStore, + ILogger logger) + : this( + configStore, + logger, + new SerialPortWrapper(configStore.Current.Ptt.SerialPort, DefaultBaudRate), + playerOverride: null) + { + } + + /// + /// Internal test constructor — injects a fake and, optionally, + /// a delegate in place of the real WASAPI path (mirroring + /// 's/'s own seams). + /// + internal SerialRtsDtrPttController( + IConfigStore configStore, + ILogger logger, + ISerialPort serialPort, + Func? playerOverride = null) + { + _configStore = configStore; + _logger = logger; + _port = serialPort; + _playerOverride = playerOverride; + _player = new WasapiTxPlayer(logger); + _watchdog = new PttWatchdog(logger, nameof(SerialRtsDtrPttController)); + } + + // ── Public API ──────────────────────────────────────────────────────────── + + /// + /// Sets the TX audio buffer that will be played on the next call. + /// Must be called before . + /// + /// + /// Mono float32 PCM at 48 000 Hz, amplitude in [−0.5, +0.5]. + /// + public void LoadAudio(float[] samples) + { + ArgumentNullException.ThrowIfNull(samples); + _audioSamples = samples; + _logger.LogDebug("TX audio loaded: {Samples} samples ({DurationMs:F0} ms).", + samples.Length, + samples.Length * 1000.0 / 48_000); + } + + /// + /// Begins transmission: opens the configured serial port if not already open, + /// asserts the configured RTS/DTR line, waits LeadTimeMs, then plays the + /// pre-loaded audio buffer and awaits completion. + /// + /// + /// Thrown when has not been called before this method. + /// PTT is NOT asserted in this case. + /// + /// + /// Propagates any exception from opening the serial port (task 9.5) — a configured + /// port that does not exist or is already in use SHALL NOT silently skip PTT + /// assertion and proceed to play audio. + /// + public async Task KeyDownAsync(CancellationToken ct = default) + { + var samples = _audioSamples + ?? throw new InvalidOperationException( + "LoadAudio must be called before KeyDownAsync. " + + "Call LoadAudio with the synthesised TX audio buffer first."); + + var ptt = _configStore.Current.Ptt; + var deviceId = _configStore.Current.AudioOutputDeviceId; + + // task 17.2: acquire the call-serialisation lock before touching the line at all, + // so a second concurrent caller blocks here until this cycle's KeyUpAsync completes. + await _txLock.WaitAsync(ct).ConfigureAwait(false); + _txLockHeld = true; + + try + { + await EnsurePortOpenAsync(ct).ConfigureAwait(false); // throws on open failure + + var line = ResolveLine(ptt.SerialLine); + SetLine(line, asserted: true); + _assertedLine = line; + _pttAsserted = true; + _watchdog.Arm(ptt.WatchdogTimeoutMs, ForceReleaseAsync); + _logger.LogInformation( + "SerialRtsDtrPttController: KeyDown — PTT asserted ({Line}).", line); + + try + { + if (ptt.LeadTimeMs > 0) + await Task.Delay(ptt.LeadTimeMs, ct).ConfigureAwait(false); + + if (_playerOverride is not null) + await _playerOverride(samples, deviceId, ct).ConfigureAwait(false); + else + await _player.PlayAsync(samples, deviceId, ct).ConfigureAwait(false); + } + catch + { + // Any failure during the lead-time wait or playback (including cancellation) + // must still de-assert the line before the exception reaches the caller. + // KeyUpAsync releases _txLock itself (via ReleaseTxLockOnce) as part of that. + await KeyUpAsync(CancellationToken.None).ConfigureAwait(false); + throw; + } + } + catch + { + // Either the port failed to open or SetLine/assert never happened (PTT never + // asserted, so KeyUpAsync above never ran and never released the lock), or the + // inner catch already ran KeyUpAsync, which already released it — + // ReleaseTxLockOnce is idempotent either way (task 17.2). + ReleaseTxLockOnce(); + throw; + } + } + + /// + /// Ends transmission: stops any in-progress playback, waits TailTimeMs, then + /// de-asserts the line that was asserted by the most recent . + /// Safe to call when PTT is not asserted — treated as a no-op beyond stopping + /// playback and disarming the watchdog. + /// + public async Task KeyUpAsync(CancellationToken ct = default) + { + _watchdog.Disarm(); + + if (_playerOverride is null) + await _player.StopAsync(ct).ConfigureAwait(false); + + if (!_pttAsserted) return; + + var ptt = _configStore.Current.Ptt; + if (ptt.TailTimeMs > 0) + await Task.Delay(ptt.TailTimeMs, CancellationToken.None).ConfigureAwait(false); + + if (_assertedLine is { } line) + SetLine(line, asserted: false); + + _pttAsserted = false; + _logger.LogInformation( + "SerialRtsDtrPttController: KeyUp — PTT released ({Line}).", _assertedLine); + _assertedLine = null; + ReleaseTxLockOnce(); + } + + /// + /// Watchdog-forced release (design.md Decision 4): bypasses TailTimeMs entirely. + /// + private async Task ForceReleaseAsync() + { + if (!_pttAsserted) return; + _pttAsserted = false; // set first so a racing KeyUpAsync becomes a no-op + + if (_playerOverride is null) + await _player.StopAsync(CancellationToken.None).ConfigureAwait(false); + + if (_assertedLine is { } line) + SetLine(line, asserted: false); + _assertedLine = null; + ReleaseTxLockOnce(); + } + + /// + /// Forces line de-assertion if still asserted, releases the audio device handle, + /// and closes/disposes the serial port. + /// + public async ValueTask DisposeAsync() + { + _watchdog.Dispose(); + + if (_pttAsserted && _assertedLine is { } line) + { + try + { + SetLine(line, asserted: false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "SerialRtsDtrPttController: line de-assertion during DisposeAsync threw — ignoring."); + } + ReleaseTxLockOnce(); + } + _pttAsserted = false; + _assertedLine = null; + + await _player.DisposeAsync().ConfigureAwait(false); + + try + { + if (_port.IsOpen) _port.Close(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "SerialRtsDtrPttController: serial port close during DisposeAsync threw — ignoring."); + } + try + { + _port.Dispose(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "SerialRtsDtrPttController: serial port dispose during DisposeAsync threw — ignoring."); + } + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + /// + /// Releases at most once per acquire (task 17.2). Guards against + /// double-release when KeyUpAsync, a watchdog-forced , and + /// can each be the one that actually performs the release for + /// a given KeyDownAsync→KeyUpAsync cycle. + /// + private void ReleaseTxLockOnce() + { + if (!_txLockHeld) return; + _txLockHeld = false; + _txLock.Release(); + } + + private async Task EnsurePortOpenAsync(CancellationToken ct) + { + if (_port.IsOpen) return; + + await _portLock.WaitAsync(ct).ConfigureAwait(false); + try + { + if (!_port.IsOpen) + _port.Open(); // throws on failure — propagates directly (task 9.5) + } + finally + { + _portLock.Release(); + } + } + + private SerialLine ResolveLine(string configured) + { + if (string.Equals(configured, "Dtr", StringComparison.OrdinalIgnoreCase)) return SerialLine.Dtr; + if (string.Equals(configured, "Rts", StringComparison.OrdinalIgnoreCase)) return SerialLine.Rts; + + _logger.LogWarning( + "SerialRtsDtrPttController: ptt.serialLine '{SerialLine}' is not recognised " + + "(expected Rts or Dtr) — falling back to Rts.", configured); + return SerialLine.Rts; + } + + private void SetLine(SerialLine line, bool asserted) + { + if (line == SerialLine.Dtr) + _port.DtrEnable = asserted; + else + _port.RtsEnable = asserted; + } +} +#endif diff --git a/src/OpenWSFZ.Daemon/WasapiTxPlayer.cs b/src/OpenWSFZ.Daemon/WasapiTxPlayer.cs new file mode 100644 index 0000000..3aa6ff1 --- /dev/null +++ b/src/OpenWSFZ.Daemon/WasapiTxPlayer.cs @@ -0,0 +1,207 @@ +#if WASAPI_SUPPORTED +using System.Runtime.Versioning; +using Microsoft.Extensions.Logging; +using NAudio.CoreAudioApi; +using NAudio.Wave; + +namespace OpenWSFZ.Daemon; + +/// +/// Shared WASAPI device-open/play/stop/dispose helper (cat-tx-ptt, design.md Decision 3), +/// extracted from so CatPttController and +/// SerialRtsDtrPttController can play TX audio without each duplicating this +/// ~150-line surface — and its finally/dispose bug surface — a second and third time. +/// +/// +/// 's public behaviour, timing, and requirements are +/// unchanged by this extraction: every existing scenario in the ft8-tx spec for +/// AudioOnlyPttController continues to hold verbatim. +/// +/// +[SupportedOSPlatform("windows")] +internal sealed class WasapiTxPlayer : IAsyncDisposable +{ + private readonly ILogger _logger; + private readonly SemaphoreSlim _playerLock = new(1, 1); + private WasapiOut? _activePlayer; + + // Atomically taken by DisposeAsync so a second call sees a non-zero value and + // exits immediately, rather than re-entering a disposed _playerLock and throwing + // ObjectDisposedException — same double-dispose guard pattern as CatPollingService + // (see its own DisposeAsync remarks). Fixes a genuine pre-existing defect (task 7.3): + // AudioOnlyPttControllerTests.DisposeAsync_CalledTwice_SecondCallIsNoOp had never + // actually executed before this change (the test project was missing the + // WASAPI_SUPPORTED conditional-compilation symbol its host class requires — fixed + // alongside this) and would have failed the moment it finally ran. + private int _disposed; + + public WasapiTxPlayer(ILogger logger) => _logger = logger; + + /// + /// Opens the given (or default) render device and plays , + /// returning when playback completes. Throws + /// (carrying ) if is cancelled before + /// playback finishes, having first stopped and released the device. + /// + public async Task PlayAsync(float[] samples, string? deviceId, CancellationToken ct) + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await _playerLock.WaitAsync(ct).ConfigureAwait(false); + try + { + // Obtain the output device. + using var enumerator = new MMDeviceEnumerator(); + MMDevice device; + if (!string.IsNullOrEmpty(deviceId)) + { + device = enumerator.GetDevice(deviceId); + _logger.LogDebug("TX: opened output device '{DeviceId}'.", deviceId); + } + else + { + device = enumerator.GetDefaultAudioEndpoint(DataFlow.Render, Role.Multimedia); + _logger.LogDebug("TX: using default output device."); + } + + var waveFormat = WaveFormat.CreateIeeeFloatWaveFormat(48_000, channels: 1); + var provider = new FloatArraySampleProvider(samples, waveFormat); + + _activePlayer = new WasapiOut(device, AudioClientShareMode.Shared, + useEventSync: true, latency: 200); + + _activePlayer.PlaybackStopped += (_, e) => + { + if (e.Exception is not null) + tcs.TrySetException(e.Exception); + else + tcs.TrySetResult(); + }; + + _activePlayer.Init(provider); + _activePlayer.Play(); + } + finally + { + _playerLock.Release(); + } + + // Wait outside the lock so StopAsync can acquire it to stop playback. + // Register callback is synchronous — await is not available, so the stop here is + // fire-and-forget. Observe the task to prevent an unhandled exception on the + // finaliser thread; StopAndReleasePlayerCore already catches and logs internally + // so a fault here indicates a genuinely unexpected failure. + using var reg = ct.Register(() => + { + StopAsync(CancellationToken.None).ContinueWith( + t => _logger.LogWarning(t.Exception, "StopAsync threw during cancellation — ignoring."), + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted, + TaskScheduler.Default); + tcs.TrySetCanceled(); + }); + + try + { + await tcs.Task.ConfigureAwait(false); + } + catch (TaskCanceledException) + { + // Cancellation is expected; caller will handle it. + throw new OperationCanceledException(ct); + } + } + + /// + /// Stops any in-progress playback and releases the device handle. Safe to call when + /// no transmission is in progress — treated as a no-op. + /// + public async Task StopAsync(CancellationToken ct = default) + { + await _playerLock.WaitAsync(ct).ConfigureAwait(false); + try + { + StopAndReleasePlayerCore(); + } + finally + { + _playerLock.Release(); + } + } + + /// + /// Stops any in-progress playback, releases the device handle, and disposes the + /// internal synchronisation primitive. Idempotent — a second call is a no-op. + /// + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) return; // already disposed + + await _playerLock.WaitAsync().ConfigureAwait(false); + try + { + StopAndReleasePlayerCore(); + } + finally + { + _playerLock.Release(); + _playerLock.Dispose(); + } + } + + private void StopAndReleasePlayerCore() + { + if (_activePlayer is null) return; + + try + { + _activePlayer.Stop(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "WasapiOut.Stop() threw during TX release — ignoring."); + } + + try + { + _activePlayer.Dispose(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "WasapiOut.Dispose() threw during TX release — ignoring."); + } + + _activePlayer = null; + } + + // ── Inner helper: ISampleProvider over float[] ──────────────────────────── + + /// + /// Thin wrapper over a pre-filled float[] buffer. + /// Used to feed the synthesised TX audio to WasapiOut.Init. + /// + private sealed class FloatArraySampleProvider : ISampleProvider + { + private readonly float[] _samples; + private int _position; + + public FloatArraySampleProvider(float[] samples, WaveFormat waveFormat) + { + _samples = samples; + WaveFormat = waveFormat; + } + + public WaveFormat WaveFormat { get; } + + public int Read(float[] buffer, int offset, int count) + { + int available = _samples.Length - _position; + int toCopy = Math.Min(available, count); + if (toCopy <= 0) return 0; + Buffer.BlockCopy(_samples, _position * sizeof(float), buffer, offset * sizeof(float), toCopy * sizeof(float)); + _position += toCopy; + return toCopy; + } + } +} +#endif diff --git a/src/OpenWSFZ.Rig/Internal/ISerialPort.cs b/src/OpenWSFZ.Rig/Internal/ISerialPort.cs index 047576c..f2b1d61 100644 --- a/src/OpenWSFZ.Rig/Internal/ISerialPort.cs +++ b/src/OpenWSFZ.Rig/Internal/ISerialPort.cs @@ -9,6 +9,20 @@ internal interface ISerialPort : IDisposable bool IsOpen { get; } int ReadTimeout { get; set; } + /// + /// Controls the RTS (Request To Send) control line (FR-056). Mirrors + /// 1:1. Used by + /// SerialRtsDtrPttController to key PTT via a raw serial control line. + /// + bool RtsEnable { get; set; } + + /// + /// Controls the DTR (Data Terminal Ready) control line (FR-056). Mirrors + /// 1:1. Used by + /// SerialRtsDtrPttController to key PTT via a raw serial control line. + /// + bool DtrEnable { get; set; } + void Open(); void Write(string text); string ReadTo(string value); diff --git a/src/OpenWSFZ.Rig/Internal/SerialPortWrapper.cs b/src/OpenWSFZ.Rig/Internal/SerialPortWrapper.cs index 47b75a9..2462324 100644 --- a/src/OpenWSFZ.Rig/Internal/SerialPortWrapper.cs +++ b/src/OpenWSFZ.Rig/Internal/SerialPortWrapper.cs @@ -27,6 +27,18 @@ public int ReadTimeout set => _port.ReadTimeout = value; } + public bool RtsEnable + { + get => _port.RtsEnable; + set => _port.RtsEnable = value; + } + + public bool DtrEnable + { + get => _port.DtrEnable; + set => _port.DtrEnable = value; + } + public void Open() => _port.Open(); public void Write(string text) => _port.Write(text); public string ReadTo(string value) => _port.ReadTo(value); diff --git a/src/OpenWSFZ.Rig/OpenWSFZ.Rig.csproj b/src/OpenWSFZ.Rig/OpenWSFZ.Rig.csproj index 5780372..9edcfbb 100644 --- a/src/OpenWSFZ.Rig/OpenWSFZ.Rig.csproj +++ b/src/OpenWSFZ.Rig/OpenWSFZ.Rig.csproj @@ -19,6 +19,15 @@ + + + diff --git a/src/OpenWSFZ.Rig/RigctldConnection.cs b/src/OpenWSFZ.Rig/RigctldConnection.cs index b7976e0..69a080b 100644 --- a/src/OpenWSFZ.Rig/RigctldConnection.cs +++ b/src/OpenWSFZ.Rig/RigctldConnection.cs @@ -20,7 +20,9 @@ namespace OpenWSFZ.Rig; /// reads one decimal-integer Hz line. sends /// \set_freq <Hz>\n and reads the RPRT 0 acknowledgement that /// rigctld sends for every command; an RPRT -N reply throws -/// . +/// . sends +/// \set_ptt 1\n / \set_ptt 0\n (FR-056) and consumes the RPRT +/// acknowledgement the same way. /// /// public sealed class RigctldConnection : IRadioConnection, IDisposable @@ -127,6 +129,28 @@ public async Task SetDialFrequencyMhzAsync( $@"rigctld returned error for \set_freq {hz}: '{ack}'"); } + /// + /// Sends \set_ptt 1\n to key the transmitter ( = + /// true) or \set_ptt 0\n to unkey it ( = + /// false) (FR-056), then reads and validates the RPRT 0 acknowledgement + /// exactly as does for \set_freq — this + /// keeps the receive buffer aligned for the next + /// call (same rationale as F-006 Root A). + /// + /// + /// rigctld returned a non-zero RPRT code (e.g. RPRT -1). + /// + public async Task SetPttAsync(bool transmitting, CancellationToken cancellationToken = default) + { + var command = $@"\set_ptt {(transmitting ? 1 : 0)}" + "\n"; + await _tcp.SendAsync(command, cancellationToken).ConfigureAwait(false); + + var ack = (await _tcp.ReceiveLineAsync(cancellationToken).ConfigureAwait(false)).Trim(); + if (!ack.Equals("RPRT 0", StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException( + $@"rigctld returned error for \set_ptt {(transmitting ? 1 : 0)}: '{ack}'"); + } + /// Closes the TCP connection. public Task DisconnectAsync(CancellationToken cancellationToken = default) { diff --git a/src/OpenWSFZ.Rig/SerialCatConnection.cs b/src/OpenWSFZ.Rig/SerialCatConnection.cs index 288d77b..3f5973e 100644 --- a/src/OpenWSFZ.Rig/SerialCatConnection.cs +++ b/src/OpenWSFZ.Rig/SerialCatConnection.cs @@ -33,6 +33,14 @@ namespace OpenWSFZ.Rig; /// until the first poll completes) (FR-045). No read-back is performed; any rig /// response is cleared by the next GetDialFrequencyMhzAsync call. /// +/// +/// +/// PTT set (): sends TX; to key the transmitter or +/// RX; to unkey it — the same Kenwood/Yaesu-family dialect and no-\r +/// fire-and-forget convention as (FR-056). +/// No read-back is performed; any rig response is cleared by the next +/// GetDialFrequencyMhzAsync call, exactly as for a frequency set. +/// /// public sealed class SerialCatConnection : IRadioConnection, IDisposable { @@ -40,6 +48,8 @@ public sealed class SerialCatConnection : IRadioConnection, IDisposable private const int DefaultFreqWidth = 11; private const string CatCommand = "FA;\r"; private const string ResponseDelim = ";"; + private const string PttOnCommand = "TX;"; + private const string PttOffCommand = "RX;"; private readonly ISerialPort _port; private readonly ILogger? _logger; @@ -251,6 +261,21 @@ public Task SetDialFrequencyMhzAsync( return Task.CompletedTask; } + /// + /// Sends TX; to key the transmitter ( = true) + /// or RX; to unkey it ( = false) (FR-056) — + /// the same Kenwood/Yaesu-family dialect, and the same no-\r fire-and-forget + /// convention, as . No read-back is performed; + /// any rig response is cleared by the next call. + /// + public Task SetPttAsync(bool transmitting, CancellationToken cancellationToken = default) + { + var command = transmitting ? PttOnCommand : PttOffCommand; + _logger?.LogDebug("Serial CAT PTT: writing '{Command}'", command); + _port.Write(command); + return Task.CompletedTask; + } + /// Closes the serial port. public Task DisconnectAsync(CancellationToken cancellationToken = default) { diff --git a/src/OpenWSFZ.Web/AppJsonContext.cs b/src/OpenWSFZ.Web/AppJsonContext.cs index 475a23f..dfe4ffe 100644 --- a/src/OpenWSFZ.Web/AppJsonContext.cs +++ b/src/OpenWSFZ.Web/AppJsonContext.cs @@ -65,6 +65,7 @@ namespace OpenWSFZ.Web; [JsonSerializable(typeof(ExternalReportingConfig))] [JsonSerializable(typeof(ExternalReportingTarget))] [JsonSerializable(typeof(List))] +[JsonSerializable(typeof(PttTestResponse))] internal sealed partial class AppJsonContext : JsonSerializerContext { } /// Envelope for status WebSocket text frames. @@ -329,6 +330,22 @@ public sealed record RegionLookupResponse( /// internal sealed record WsDecodeFilterMessage(string Type, DecodeFilterState Payload); +/// +/// Response body for POST /api/v1/ptt/test (cat-tx-ptt, task 17.3, FR-057). +/// Result is "pass" when the assert/release commands were accepted without +/// throwing (a real CAT ACK or a real RTS/DTR line toggle happened) — this confirms only +/// that the command was accepted, never that the rig physically keyed, since +/// defines no read-back. +/// Result is "error" with a non-null Message when the pulse itself threw +/// (e.g. a CAT write failure, a serial port error) — always returned with HTTP 200, since this +/// is an expected, handleable outcome, not a server error. The 409-Conflict cases (a real QSO +/// is currently keying; the running method is AudioVox) are surfaced as HTTP 409 +/// ProblemDetails responses instead, not through this record. +/// Wire format: {"result":"pass","message":null} or +/// {"result":"error","message":"port in use"}. +/// +public sealed record PttTestResponse(string Result, string? Message = null); + internal sealed record WsQsoReviewMessage( string Type, string Callsign, diff --git a/src/OpenWSFZ.Web/WebApp.cs b/src/OpenWSFZ.Web/WebApp.cs index 9da7e80..9f1e21a 100644 --- a/src/OpenWSFZ.Web/WebApp.cs +++ b/src/OpenWSFZ.Web/WebApp.cs @@ -366,6 +366,20 @@ static bool IsPublicPath(PathString path) => config = config with { DecodeNoiseSuppression = new DecodeNoiseSuppressionConfig() }; if (config.ExternalReporting is null) config = config with { ExternalReporting = new ExternalReportingConfig() }; + // Ptt gets a different fallback than the four guards above: web/js/settings.js + // never sends a "ptt" key at all (there is deliberately no Settings-page UI for + // it — design.md Decision 6), so EVERY Settings-page save hits this branch, not + // just a hypothetical missing-key edge case. Defaulting to a fresh new PttConfig() + // here would silently revert an operator's manually-edited ptt.method (e.g. + // "CatCommand") back to "AudioVox" on the very next unrelated save (toggling + // showCycleCountdown, etc.) — reproducing the exact stuck-on-VOX symptom this fix + // exists to prevent. Falling back to the already-persisted store.Current.Ptt + // instead makes an omitted "ptt" key a true no-op: whatever was on disk before + // this save stays there. store.Current.Ptt is itself never null by this point + // (JsonConfigStore.Load() and SaveAsync both guard it), so the ?? new PttConfig() + // is pure defense-in-depth, not the expected path. + if (config.Ptt is null) + config = config with { Ptt = store.Current.Ptt ?? new PttConfig() }; // ── CAT config validation (FR-031, FR-034) ───────────────────────── if (config.Cat is { } cat) @@ -794,6 +808,12 @@ static bool IsPublicPath(PathString path) => // Capture IQsoController (may be null in tests or when the TX subsystem is not wired). var qsoController = app.Services.GetService(); + // Capture IPttController (cat-tx-ptt, task 17.3) — the currently-running singleton + // resolved by Program.cs's Ptt.Method switch at DI-registration time. May be null in + // test fixtures that don't wire the TX subsystem; POST /api/v1/ptt/test treats that + // the same as "nothing to test" (503). + var pttController = app.Services.GetService(); + // Capture IQsoRoleSwitcher for runtime role switching (Call CQ, task 11.5). // Null in tests that register a plain IQsoController stub rather than the full router. var qsoRoleSwitcher = app.Services.GetService(); @@ -947,6 +967,57 @@ static bool IsPublicPath(PathString path) => return Results.NoContent(); }); + // ── PTT test endpoint (cat-tx-ptt, task 17.3, FR-057) ───────────────── + // + // Fires a brief (~250 ms), silent (zero-amplitude) software PTT pulse against the + // currently-running IPttController singleton: assert → tiny silence buffer → release. + // Pass means the assert/release commands were accepted without throwing — it does NOT + // mean the rig visibly keyed (IRadioConnection.SetPttAsync defines no read-back; see + // its own doc comment). The operator must still watch the rig. + // + // Safety-critical (design.md Decision 6 amendment): this is a second, independent + // caller of the same DI singleton QsoAnswererService/QsoCallerService already drive. + // Layer 1 here rejects with 409 while a real QSO is keying; layer 2 (CatPttController/ + // SerialRtsDtrPttController's own SemaphoreSlim, task 17.2) closes the remaining race + // for any request that gets past this check just as a real transmission begins. + const int PttTestSampleRate = 48_000; + const int PttTestDurationMs = 250; + var pttTestSilence = new float[PttTestSampleRate * PttTestDurationMs / 1000]; + + app.MapPost("/api/v1/ptt/test", async (IConfigStore store, CancellationToken ct) => + { + if (qsoController?.Keying == true) + return Results.Problem( + statusCode: StatusCodes.Status409Conflict, + detail: "a QSO is currently transmitting"); + + var method = store.Current.Ptt?.Method ?? "AudioVox"; + if (string.Equals(method, "AudioVox", StringComparison.OrdinalIgnoreCase)) + return Results.Problem( + statusCode: StatusCodes.Status409Conflict, + detail: "PTT method is AudioVox — OpenWSFZ never asserts PTT itself in this " + + "mode (the rig's own VOX keys from audio), so there is nothing to test."); + + if (pttController is null) + return Results.Problem( + statusCode: StatusCodes.Status503ServiceUnavailable, + detail: "PTT controller is not available."); + + try + { + pttController.LoadAudio(pttTestSilence); + await pttController.KeyDownAsync(ct).ConfigureAwait(false); + await pttController.KeyUpAsync(ct).ConfigureAwait(false); + return TypedResults.Ok(new PttTestResponse("pass")); + } + catch (Exception ex) + { + // Expected, handleable outcome (a real CAT/serial failure) — not a server + // error, so HTTP 200 with an error payload rather than a 500 (task 17.3). + return TypedResults.Ok(new PttTestResponse("error", ex.Message)); + } + }); + // ── Audio offset endpoint ───────────────────────────────────────────── app.MapPost("/api/v1/audio-offset", async ( diff --git a/tests/OpenWSFZ.Config.Tests/PttConfigTests.cs b/tests/OpenWSFZ.Config.Tests/PttConfigTests.cs new file mode 100644 index 0000000..b5229de --- /dev/null +++ b/tests/OpenWSFZ.Config.Tests/PttConfigTests.cs @@ -0,0 +1,157 @@ +using FluentAssertions; +using OpenWSFZ.Abstractions; +using OpenWSFZ.Config; +using System.Text.Json; +using Xunit; + +namespace OpenWSFZ.Config.Tests; + +/// +/// Tests for configuration schema (FR-056, task 2.5). +/// Verifies defaults, round-trip fidelity, the JSON key name contract, and that +/// existing config files without a ptt key deserialise without error to +/// today's VOX-only behaviour. +/// +[Trait("Category", "Unit")] +public sealed class PttConfigTests +{ + private sealed class TempDirectory : IDisposable + { + public string Path { get; } = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + "openwsfz-pttcfg-" + System.IO.Path.GetRandomFileName()); + + public TempDirectory() => Directory.CreateDirectory(Path); + + public void Dispose() + { + try { Directory.Delete(Path, recursive: true); } catch { /* best-effort */ } + } + } + + // ── Scenario: Missing ptt key uses defaults (task 2.5) ──────────────────── + + [Fact(DisplayName = "FR-056: AppConfig without ptt key deserialises without error and Ptt.Method is AudioVox")] + public void Load_MissingPttKey_UsesDefaults() + { + const string json = """{"port":8080}"""; + var config = JsonSerializer.Deserialize(json, ConfigJsonContext.Default.AppConfig)!; + + // Direct STJ deserialisation may return null for a non-nullable init property when + // its key is absent (same source-gen quirk documented for RemoteAccess/DecodeLog/etc. + // above) — consumers use (config.Ptt ?? new PttConfig()); JsonConfigStore's own + // null-guard (see below) covers the load path used by the running daemon. + var effective = config.Ptt ?? new PttConfig(); + effective.Method.Should().Be("AudioVox"); + } + + [Fact(DisplayName = "FR-056: JsonConfigStore null-guard ensures Ptt.Method=AudioVox on old config")] + public void JsonConfigStore_Load_MissingPttKey_AppliesNullGuard() + { + using var dir = new TempDirectory(); + var configPath = System.IO.Path.Combine(dir.Path, "config.json"); + + // Write a pre-cat-tx-ptt config file (no ptt key). + File.WriteAllText(configPath, """{"port":9090}"""); + + var store = new JsonConfigStore(configPath); + + store.Current.Ptt.Should().NotBeNull( + "JsonConfigStore null-guard must produce a non-null Ptt for old config files"); + store.Current.Ptt.Method.Should().Be("AudioVox", + "missing ptt key must default to Method=AudioVox — today's VOX-only behaviour"); + } + + // ── Scenario: ptt round-trips correctly ─────────────────────────────────── + + [Fact(DisplayName = "FR-056: PttConfig round-trips all six fields through JSON serialisation")] + public void RoundTrip_AllFields_PreservesValues() + { + var original = new AppConfig() with + { + Ptt = new PttConfig + { + Method = "CatCommand", + SerialPort = "COM12", + SerialLine = "Dtr", + LeadTimeMs = 75, + TailTimeMs = 120, + WatchdogTimeoutMs = 15000, + } + }; + + var json = JsonSerializer.Serialize(original, ConfigJsonContext.Default.AppConfig); + var loaded = JsonSerializer.Deserialize(json, ConfigJsonContext.Default.AppConfig)!; + + loaded.Ptt.Should().NotBeNull(); + loaded.Ptt.Method.Should().Be("CatCommand"); + loaded.Ptt.SerialPort.Should().Be("COM12"); + loaded.Ptt.SerialLine.Should().Be("Dtr"); + loaded.Ptt.LeadTimeMs.Should().Be(75); + loaded.Ptt.TailTimeMs.Should().Be(120); + loaded.Ptt.WatchdogTimeoutMs.Should().Be(15000); + } + + // ── Scenario: JSON key names use camelCase ───────────────────────────────── + + [Fact(DisplayName = "FR-056: PttConfig serialises with camelCase JSON property names")] + public void Serialise_PttConfig_UsesCamelCase() + { + var config = new AppConfig() with { Ptt = new PttConfig() }; + var json = JsonSerializer.Serialize(config, ConfigJsonContext.Default.AppConfig); + + json.Should().Contain("\"ptt\""); + json.Should().Contain("\"method\""); + json.Should().Contain("\"serialPort\""); + json.Should().Contain("\"serialLine\""); + json.Should().Contain("\"leadTimeMs\""); + json.Should().Contain("\"tailTimeMs\""); + json.Should().Contain("\"watchdogTimeoutMs\""); + } + + // ── Scenario: Default values ────────────────────────────────────────────── + + [Fact(DisplayName = "FR-056: PttConfig defaults: method=AudioVox, serialLine=Rts, leadTimeMs=50, tailTimeMs=50, watchdogTimeoutMs=20000")] + public void Defaults_AreCorrect() + { + var ptt = new PttConfig(); + ptt.Method.Should().Be("AudioVox"); + ptt.SerialLine.Should().Be("Rts"); + ptt.LeadTimeMs.Should().Be(50); + ptt.TailTimeMs.Should().Be(50); + ptt.WatchdogTimeoutMs.Should().Be(20000); + ptt.SerialPort.Should().NotBeNullOrEmpty(); + } + + // ── Scenario: Partial ptt object loads without error ───────────────────── + + [Fact(DisplayName = "FR-056: Partial ptt JSON (only method present) loads without error")] + public void Load_PartialPttJson_LoadsWithoutError() + { + const string json = """{"ptt":{"method":"SerialRtsDtr"}}"""; + AppConfig? config = null; + var act = () => config = JsonSerializer.Deserialize(json, ConfigJsonContext.Default.AppConfig); + + act.Should().NotThrow("a partial ptt object must deserialise without error"); + config!.Ptt.Should().NotBeNull(); + config.Ptt.Method.Should().Be("SerialRtsDtr", "the explicitly-provided method field must be correct"); + } + + [Fact(DisplayName = "FR-056: JsonConfigStore default-config file includes ptt section")] + public void JsonConfigStore_CreateDefault_IncludesPttSection() + { + using var dir = new TempDirectory(); + var configPath = System.IO.Path.Combine(dir.Path, "config.json"); + + _ = new JsonConfigStore(configPath); + + File.Exists(configPath).Should().BeTrue("a default config file must be written"); + + var text = File.ReadAllText(configPath); + text.Should().Contain("\"ptt\"", "default config must include ptt key"); + + var loaded = JsonSerializer.Deserialize(text, ConfigJsonContext.Default.AppConfig)!; + var ptt = loaded.Ptt ?? new PttConfig(); + ptt.Method.Should().Be("AudioVox", "default ptt.method must be AudioVox"); + } +} diff --git a/tests/OpenWSFZ.Daemon.Tests/CatPollingServiceTests.cs b/tests/OpenWSFZ.Daemon.Tests/CatPollingServiceTests.cs index 952038a..4c24983 100644 --- a/tests/OpenWSFZ.Daemon.Tests/CatPollingServiceTests.cs +++ b/tests/OpenWSFZ.Daemon.Tests/CatPollingServiceTests.cs @@ -231,6 +231,124 @@ public async Task TriggerRetry_WhenSuspended_AttemptsReconnect() await svc.StopAsync(CancellationToken.None); } + // ── ICatPttGate (FR-056, task 12.3) ─────────────────────────────────────── + + [Fact(DisplayName = "CatTx-Ptt: ICatPttGate.SetPttAsync throws InvalidOperationException when CAT is disabled")] + public async Task SetPttAsync_CatDisabled_Throws() + { + var (svc, _, _) = MakeService(new CatConfig { Enabled = false }); + ICatPttGate gate = svc; + + var act = () => gate.SetPttAsync(true); + + await act.Should().ThrowAsync() + .WithMessage("*disabled*"); + } + + [Fact(DisplayName = "CatTx-Ptt: ICatPttGate.SetPttAsync throws InvalidOperationException when no connection has ever been established")] + public async Task SetPttAsync_NoActiveConnection_Throws() + { + // Enabled but the poll loop was never started — _activeConnection stays null. + var (svc, _, _) = MakeService(new CatConfig { Enabled = true }); + ICatPttGate gate = svc; + + var act = () => gate.SetPttAsync(true); + + await act.Should().ThrowAsync() + .WithMessage("*not yet connected*"); + } + + [Fact(DisplayName = "CatTx-Ptt: ICatPttGate.SetPttAsync dispatches to the active IRadioConnection once connected")] + public async Task SetPttAsync_Connected_CallsConnectionSetPtt() + { + var cat = new CatConfig { Enabled = true, RigModel = "SerialCat" }; + var store = new StubConfigStore(new AppConfig() with { Cat = cat }); + + var connection = Substitute.For(); + connection.IsConnected.Returns(true); + connection.GetDialFrequencyMhzAsync(Arg.Any()).Returns(14.074); + + var state = new CatState(); + var bus = new CatEventBus(Guid.NewGuid()); + var svc = new TestableCatPollingService( + state, store, bus, + NullLogger.Instance, + NullLoggerFactory.Instance, + connection); + ICatPttGate gate = svc; + + await svc.StartAsync(CancellationToken.None); + + var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(1000); + while (DateTime.UtcNow < deadline && state.Status != CatConnectionStatus.Connected) + await Task.Delay(20); + + await gate.SetPttAsync(true); + + await svc.StopAsync(CancellationToken.None); + + await connection.Received(1).SetPttAsync(true, Arg.Any()); + } + + [Fact(DisplayName = "CatTx-Ptt: poll loop reads and SetPttAsync commands never overlap on the shared connection")] + public async Task SetPttAsync_ConcurrentWithPoll_NeverInterleaves() + { + // A re-entrancy guard on the mock connection: any overlapping call (poll vs. PTT, + // or PTT vs. PTT) increments past 1 and flags a violation — proving the + // _connectionLock gate genuinely serialises every call, not just by coincidence + // of timing (design.md Decision 1). + var cat = new CatConfig { Enabled = true, RigModel = "SerialCat", PollIntervalSeconds = 1 }; + var store = new StubConfigStore(new AppConfig() with { Cat = cat }); + + var reentrancyGuard = 0; + var violation = false; + + var connection = Substitute.For(); + connection.IsConnected.Returns(true); + connection.GetDialFrequencyMhzAsync(Arg.Any()) + .Returns(async _ => + { + if (Interlocked.Increment(ref reentrancyGuard) != 1) violation = true; + await Task.Delay(50); + Interlocked.Decrement(ref reentrancyGuard); + return 14.074; + }); + connection.SetPttAsync(Arg.Any(), Arg.Any()) + .Returns(async _ => + { + if (Interlocked.Increment(ref reentrancyGuard) != 1) violation = true; + await Task.Delay(50); + Interlocked.Decrement(ref reentrancyGuard); + }); + + var state = new CatState(); + var bus = new CatEventBus(Guid.NewGuid()); + var svc = new TestableCatPollingService( + state, store, bus, + NullLogger.Instance, + NullLoggerFactory.Instance, + connection); + ICatPttGate gate = svc; + + await svc.StartAsync(CancellationToken.None); + + var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(1000); + while (DateTime.UtcNow < deadline && state.Status != CatConnectionStatus.Connected) + await Task.Delay(20); + + // Fire several concurrent PTT calls while the poll loop is also ticking + // (PollIntervalSeconds = 1, so a poll is likely mid-flight during this window). + var pttTasks = Enumerable.Range(0, 5).Select(_ => gate.SetPttAsync(true)).ToArray(); + await Task.WhenAll(pttTasks); + + await Task.Delay(300); // let another poll cycle also have a chance to overlap + + await svc.StopAsync(CancellationToken.None); + + violation.Should().BeFalse( + "the connection lock must prevent any overlap between poll reads and PTT commands"); + } + // ── Stub IConfigStore ───────────────────────────────────────────────────── private sealed class StubConfigStore : IConfigStore diff --git a/tests/OpenWSFZ.Daemon.Tests/CatPttControllerTests.cs b/tests/OpenWSFZ.Daemon.Tests/CatPttControllerTests.cs new file mode 100644 index 0000000..42d9e41 --- /dev/null +++ b/tests/OpenWSFZ.Daemon.Tests/CatPttControllerTests.cs @@ -0,0 +1,294 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using OpenWSFZ.Abstractions; +using Xunit; + +namespace OpenWSFZ.Daemon.Tests; + +/// +/// Unit tests for (FR-056, task 12.6). +/// A mocked and an injected playback-override delegate are +/// used throughout, mirroring 's own test seam, so +/// no real CAT link or WASAPI hardware is required. +/// +#if WASAPI_SUPPORTED +[System.Runtime.Versioning.SupportedOSPlatform("windows")] +public sealed class CatPttControllerTests +{ + private static readonly float[] SomeSamples = new float[606_720]; // 79 × 7680 at 48 kHz + + // ── LoadAudio + KeyDownAsync ────────────────────────────────────────────── + + [Fact(DisplayName = "CatTx-Ptt: KeyDownAsync without LoadAudio throws and does not assert PTT")] + public async Task KeyDownAsync_WithoutLoadAudio_ThrowsAndDoesNotAssertPtt() + { + await using var sut = MakeSut(out var gate); + + var act = async () => await sut.KeyDownAsync(); + + await act.Should().ThrowExactlyAsync() + .WithMessage("*LoadAudio*"); + await gate.DidNotReceive().SetPttAsync(Arg.Any(), Arg.Any()); + } + + [Fact(DisplayName = "CatTx-Ptt: KeyDownAsync asserts PTT before audio playback starts")] + public async Task KeyDownAsync_AssertsPttBeforeAudioStarts() + { + var order = new List(); + + await using var sut = MakeSut(out var gate, playerOverride: (_, _, _) => + { + order.Add("audio-start"); + return Task.CompletedTask; + }); + gate.SetPttAsync(true, Arg.Any()) + .Returns(_ => { order.Add("ptt-on"); return Task.CompletedTask; }); + + sut.LoadAudio(SomeSamples); + await sut.KeyDownAsync(); + + order.Should().Equal("ptt-on", "audio-start"); + } + + [Fact(DisplayName = "CatTx-Ptt: KeyDownAsync honours LeadTimeMs before audio playback starts")] + public async Task KeyDownAsync_HonoursLeadTimeMs() + { + var sw = System.Diagnostics.Stopwatch.StartNew(); + TimeSpan? audioStartedAt = null; + + await using var sut = MakeSut(out _, playerOverride: (_, _, _) => + { + audioStartedAt = sw.Elapsed; + return Task.CompletedTask; + }, leadTimeMs: 80); + + sut.LoadAudio(SomeSamples); + await sut.KeyDownAsync(); + + audioStartedAt.Should().NotBeNull(); + audioStartedAt!.Value.TotalMilliseconds.Should().BeGreaterThanOrEqualTo( + 75, "playback must not begin until at least LeadTimeMs has elapsed after PTT is asserted"); + } + + [Fact(DisplayName = "CatTx-Ptt: KeyDownAsync propagates player delegate exception")] + public async Task KeyDownAsync_PlayerThrows_PropagatesException() + { + await using var sut = MakeSut(out _, (_, _, _) => + Task.FromException(new InvalidOperationException("device busy"))); + + sut.LoadAudio(SomeSamples); + + var act = async () => await sut.KeyDownAsync(); + await act.Should().ThrowExactlyAsync() + .WithMessage("device busy"); + } + + [Fact(DisplayName = "CatTx-Ptt: PTT is released before the exception propagates when playback throws")] + public async Task KeyDownAsync_PlayerThrows_StillReleasesPttFirst() + { + await using var sut = MakeSut(out var gate, (_, _, _) => + Task.FromException(new InvalidOperationException("device busy"))); + + sut.LoadAudio(SomeSamples); + + var act = async () => await sut.KeyDownAsync(); + await act.Should().ThrowExactlyAsync(); + + await gate.Received(1).SetPttAsync(true, Arg.Any()); + await gate.Received(1).SetPttAsync(false, Arg.Any()); + } + + // ── KeyUpAsync ──────────────────────────────────────────────────────────── + + [Fact(DisplayName = "CatTx-Ptt: KeyUpAsync when not asserted completes without releasing PTT")] + public async Task KeyUpAsync_WhenNotAsserted_CompletesGracefully() + { + await using var sut = MakeSut(out var gate); + + var act = async () => await sut.KeyUpAsync(); + await act.Should().NotThrowAsync(); + + await gate.DidNotReceive().SetPttAsync(Arg.Any(), Arg.Any()); + } + + [Fact(DisplayName = "CatTx-Ptt: KeyUpAsync releases PTT only after TailTimeMs has elapsed")] + public async Task KeyUpAsync_HonoursTailTimeMsBeforeReleasingPtt() + { + TimeSpan? releasedAt = null; + var sw = System.Diagnostics.Stopwatch.StartNew(); + + await using var sut = MakeSut(out var gate, (_, _, _) => Task.CompletedTask, tailTimeMs: 80); + gate.SetPttAsync(false, Arg.Any()) + .Returns(_ => { releasedAt = sw.Elapsed; return Task.CompletedTask; }); + + sut.LoadAudio(SomeSamples); + await sut.KeyDownAsync(); + + sw.Restart(); + await sut.KeyUpAsync(); + + releasedAt.Should().NotBeNull(); + releasedAt!.Value.TotalMilliseconds.Should().BeGreaterThanOrEqualTo( + 75, "PTT must not be released until at least TailTimeMs has elapsed after playback stops"); + } + + // ── DisposeAsync ────────────────────────────────────────────────────────── + + [Fact(DisplayName = "CatTx-Ptt: DisposeAsync releases PTT if still asserted")] + public async Task DisposeAsync_WhilePttAsserted_ReleasesPtt() + { + var sut = MakeSut(out var gate, (_, _, ct) => Task.Delay(Timeout.Infinite, ct)); + // KeyDownAsync will hang on the infinite delay — fire it without awaiting so we + // can dispose while PTT is still asserted but playback is mid-flight. + sut.LoadAudio(SomeSamples); + using var cts = new CancellationTokenSource(); + var keyDownTask = sut.KeyDownAsync(cts.Token); + + // Give KeyDownAsync time to assert PTT and reach the (infinitely) blocked player. + await Task.Delay(50); + + await sut.DisposeAsync(); + + await gate.Received(1).SetPttAsync(false, Arg.Any()); + + cts.Cancel(); + try { await keyDownTask; } catch { /* expected — cancelled/disposed mid-flight */ } + } + + [Fact(DisplayName = "CatTx-Ptt: DisposeAsync when PTT not asserted does not call SetPttAsync")] + public async Task DisposeAsync_WhenNotAsserted_DoesNotReleasePtt() + { + var sut = MakeSut(out var gate); + + await sut.DisposeAsync(); + + await gate.DidNotReceive().SetPttAsync(Arg.Any(), Arg.Any()); + } + + [Fact(DisplayName = "CatTx-Ptt: DisposeAsync is idempotent — second call does not throw")] + public async Task DisposeAsync_CalledTwice_SecondCallIsNoOp() + { + var sut = MakeSut(out _); + + await sut.DisposeAsync(); + + var act = async () => await sut.DisposeAsync(); + await act.Should().NotThrowAsync(); + } + + // ── Watchdog integration ───────────────────────────────────────────────── + + [Fact(DisplayName = "CatTx-Ptt: watchdog force-releases PTT when KeyUpAsync is never called")] + public async Task Watchdog_ForcesRelease_WhenKeyUpNeverCalled() + { + await using var sut = MakeSut(out var gate, + (_, _, ct) => Task.Delay(Timeout.Infinite, ct), watchdogTimeoutMs: 50); + + sut.LoadAudio(SomeSamples); + // Fire-and-forget: the player hangs forever, so KeyDownAsync never returns on its + // own — the watchdog must force a release regardless. + _ = sut.KeyDownAsync(); + + // Poll for the forced release rather than a single fixed delay, to keep this + // robust under slow CI runners. + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(2); + while (DateTime.UtcNow < deadline && + gate.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(ICatPttGate.SetPttAsync)) < 2) + { + await Task.Delay(20); + } + + await gate.Received(1).SetPttAsync(true, Arg.Any()); + await gate.Received(1).SetPttAsync(false, Arg.Any()); + } + + // ── Call-serialisation (task 17.2) ─────────────────────────────────────── + + [Fact(DisplayName = "CatTx-Ptt: two concurrent KeyDownAsync callers serialise rather than interleave")] + public async Task KeyDownAsync_TwoConcurrentCallers_Serialise() + { + var order = new List(); + var callCount = 0; + var firstPlaybackReleaseGate = new TaskCompletionSource(); + + await using var sut = MakeSut(out var gate, playerOverride: async (_, _, _) => + { + var n = Interlocked.Increment(ref callCount); + lock (order) order.Add($"audio-start-{n}"); + if (n == 1) + await firstPlaybackReleaseGate.Task; // held open until the test releases it + }); + + sut.LoadAudio(SomeSamples); + + // Caller #1 — simulates the real, active QsoAnswererService/QsoCallerService + // transmission — begins and stays "mid-transmission" (audio still playing) until + // the test explicitly lets it finish below. + var firstKeyDown = sut.KeyDownAsync(); + + // Give caller #1 time to acquire the lock, assert PTT, and enter the player. + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(2); + while (DateTime.UtcNow < deadline && callCount < 1) + await Task.Delay(10); + callCount.Should().Be(1, "caller #1 must have started its playback by now"); + + // Caller #2 — simulates a Settings-page Test click racing the real transmission — + // starts concurrently while #1 is still mid-transmission. + var secondKeyDown = Task.Run(() => sut.KeyDownAsync()); + + // Give caller #2 a chance to (incorrectly) race ahead if the lock were missing. + await Task.Delay(100); + callCount.Should().Be(1, + "caller #2 must remain blocked behind caller #1's still-open KeyUpAsync — it must " + + "not be able to assert PTT again (and, worse, later de-assert it) while caller #1's " + + "real transmission is still in flight"); + await gate.Received(1).SetPttAsync(true, Arg.Any()); + + // Complete caller #1's cycle exactly as the real state machine would: let its + // playback finish, then call its matching KeyUpAsync. + firstPlaybackReleaseGate.SetResult(); + await firstKeyDown; + await sut.KeyUpAsync(); + + // Caller #2 must now be able to proceed and assert PTT for its own cycle. + await secondKeyDown.WaitAsync(TimeSpan.FromSeconds(5)); + + callCount.Should().Be(2); + await gate.Received(2).SetPttAsync(true, Arg.Any()); + order.Should().Equal(["audio-start-1", "audio-start-2"], + "caller #2's playback must not start until caller #1's KeyUpAsync has completed"); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private static CatPttController MakeSut( + out ICatPttGate gate, + Func? playerOverride = null, + int leadTimeMs = 0, + int tailTimeMs = 0, + int watchdogTimeoutMs = 20_000) + { + var mockGate = Substitute.For(); + gate = mockGate; + + var store = Substitute.For(); + store.Current.Returns(new AppConfig() with + { + Ptt = new PttConfig + { + Method = "CatCommand", + LeadTimeMs = leadTimeMs, + TailTimeMs = tailTimeMs, + WatchdogTimeoutMs = watchdogTimeoutMs, + } + }); + + var logger = NullLogger.Instance; + + return playerOverride is not null + ? new CatPttController(mockGate, store, logger, playerOverride) + : new CatPttController(mockGate, store, logger, (_, _, _) => Task.CompletedTask); + } +} +#endif diff --git a/tests/OpenWSFZ.Daemon.Tests/FakeSerialPort.cs b/tests/OpenWSFZ.Daemon.Tests/FakeSerialPort.cs new file mode 100644 index 0000000..3007980 --- /dev/null +++ b/tests/OpenWSFZ.Daemon.Tests/FakeSerialPort.cs @@ -0,0 +1,52 @@ +using OpenWSFZ.Rig.Internal; + +namespace OpenWSFZ.Daemon.Tests; + +/// +/// Hand-written test double (task 3.3, FR-056) exposing +/// settable/observable / state, used by +/// SerialRtsDtrPttController's unit tests (task 12.7). Lives alongside +/// OpenWSFZ.Rig.Tests's NSubstitute-based fakes that already back +/// SerialCatConnectionTests — a hand-rolled double is used here (rather than +/// Substitute.For<ISerialPort>()) because failure +/// injection and write-history inspection read more directly this way for a controller +/// whose whole job is toggling two boolean control lines in a precise order. +/// +internal sealed class FakeSerialPort : ISerialPort +{ + private readonly List _written = new(); + + public bool IsOpen { get; private set; } + public int ReadTimeout { get; set; } + public bool RtsEnable { get; set; } + public bool DtrEnable { get; set; } + + /// When set, throws this instead of succeeding. + public Exception? ThrowOnOpen { get; set; } + + /// All strings passed to , in order. + public IReadOnlyList Written => _written; + + public int CloseCallCount { get; private set; } + public int DisposeCallCount { get; private set; } + + public void Open() + { + if (ThrowOnOpen is not null) throw ThrowOnOpen; + IsOpen = true; + } + + public void Write(string text) => _written.Add(text); + + public string ReadTo(string value) => string.Empty; + + public void DiscardInBuffer() { /* no-op — RTS/DTR PTT never reads from the port */ } + + public void Close() + { + CloseCallCount++; + IsOpen = false; + } + + public void Dispose() => DisposeCallCount++; +} diff --git a/tests/OpenWSFZ.Daemon.Tests/FakeSerialPortTests.cs b/tests/OpenWSFZ.Daemon.Tests/FakeSerialPortTests.cs new file mode 100644 index 0000000..2f91a87 --- /dev/null +++ b/tests/OpenWSFZ.Daemon.Tests/FakeSerialPortTests.cs @@ -0,0 +1,104 @@ +using FluentAssertions; +using OpenWSFZ.Rig.Internal; +using Xunit; + +namespace OpenWSFZ.Daemon.Tests; + +/// +/// Unit tests for the RTS/DTR contract (FR-056, task 12.5), +/// exercised via — the hand-written test double +/// SerialRtsDtrPttController's own unit tests (task 12.7) build on. Verifies the +/// double faithfully models independent, observable RTS/DTR line state before other +/// tests trust it. +/// +[Trait("Category", "Unit")] +public sealed class FakeSerialPortTests +{ + [Fact(DisplayName = "CatTx-Ptt: RtsEnable and DtrEnable both default to false")] + public void Defaults_BothLinesFalse() + { + var port = new FakeSerialPort(); + + port.RtsEnable.Should().BeFalse(); + port.DtrEnable.Should().BeFalse(); + } + + [Fact(DisplayName = "CatTx-Ptt: RtsEnable is independently settable and observable")] + public void RtsEnable_SetTrue_ObservedTrue_DtrUnaffected() + { + var port = new FakeSerialPort(); + + port.RtsEnable = true; + + port.RtsEnable.Should().BeTrue(); + port.DtrEnable.Should().BeFalse("setting RTS must not affect the independent DTR line"); + } + + [Fact(DisplayName = "CatTx-Ptt: DtrEnable is independently settable and observable")] + public void DtrEnable_SetTrue_ObservedTrue_RtsUnaffected() + { + var port = new FakeSerialPort(); + + port.DtrEnable = true; + + port.DtrEnable.Should().BeTrue(); + port.RtsEnable.Should().BeFalse("setting DTR must not affect the independent RTS line"); + } + + [Fact(DisplayName = "CatTx-Ptt: RtsEnable/DtrEnable can be de-asserted after being asserted")] + public void Lines_CanBeToggledBackToFalse() + { + var port = new FakeSerialPort { RtsEnable = true, DtrEnable = true }; + + port.RtsEnable = false; + port.DtrEnable = false; + + port.RtsEnable.Should().BeFalse(); + port.DtrEnable.Should().BeFalse(); + } + + [Fact(DisplayName = "CatTx-Ptt: Open() sets IsOpen true; Close() sets IsOpen false and increments CloseCallCount")] + public void OpenClose_TracksLifecycle() + { + var port = new FakeSerialPort(); + + port.Open(); + port.IsOpen.Should().BeTrue(); + + port.Close(); + port.IsOpen.Should().BeFalse(); + port.CloseCallCount.Should().Be(1); + } + + [Fact(DisplayName = "CatTx-Ptt: Open() throws the configured ThrowOnOpen exception and IsOpen remains false")] + public void Open_ThrowOnOpenConfigured_Throws() + { + var port = new FakeSerialPort { ThrowOnOpen = new UnauthorizedAccessException("port in use") }; + + var act = () => port.Open(); + + act.Should().Throw().WithMessage("port in use"); + port.IsOpen.Should().BeFalse(); + } + + [Fact(DisplayName = "CatTx-Ptt: Write() records every write in order")] + public void Write_RecordsHistoryInOrder() + { + var port = new FakeSerialPort(); + + port.Write("first"); + port.Write("second"); + + port.Written.Should().Equal("first", "second"); + } + + [Fact(DisplayName = "CatTx-Ptt: Dispose() increments DisposeCallCount")] + public void Dispose_IncrementsCallCount() + { + var port = new FakeSerialPort(); + + port.Dispose(); + + port.DisposeCallCount.Should().Be(1); + } +} diff --git a/tests/OpenWSFZ.Daemon.Tests/OpenWSFZ.Daemon.Tests.csproj b/tests/OpenWSFZ.Daemon.Tests/OpenWSFZ.Daemon.Tests.csproj index b0d60f4..0d2b4bc 100644 --- a/tests/OpenWSFZ.Daemon.Tests/OpenWSFZ.Daemon.Tests.csproj +++ b/tests/OpenWSFZ.Daemon.Tests/OpenWSFZ.Daemon.Tests.csproj @@ -22,4 +22,18 @@ + + + + $(DefineConstants);WASAPI_SUPPORTED + diff --git a/tests/OpenWSFZ.Daemon.Tests/PttControllerSelectorTests.cs b/tests/OpenWSFZ.Daemon.Tests/PttControllerSelectorTests.cs new file mode 100644 index 0000000..7011277 --- /dev/null +++ b/tests/OpenWSFZ.Daemon.Tests/PttControllerSelectorTests.cs @@ -0,0 +1,99 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace OpenWSFZ.Daemon.Tests; + +/// +/// Unit tests for (FR-056, task 12.9) — the pure +/// mapping Program.cs uses to pick which concrete IPttController to +/// register for each AppConfig.Ptt.Method value. +/// +[Trait("Category", "Unit")] +public sealed class PttControllerSelectorTests +{ + // xUnit test methods must be public, but PttControllerKind is internal — an enum-typed + // [Theory] parameter would trip CS0051 (inconsistent accessibility). Three explicit + // [Fact]s instead; the enum is only ever used inside each method body, never in a + // public signature. + + [Fact(DisplayName = "CatTx-Ptt: Ptt.Method \"AudioVox\" resolves to PttControllerKind.AudioVox")] + public void Resolve_AudioVox_ReturnsAudioVox() + { + var logger = new CapturingLogger(); + + var kind = PttControllerSelector.Resolve("AudioVox", logger); + + kind.Should().Be(PttControllerKind.AudioVox); + logger.Entries.Should().BeEmpty("a recognised method must not log anything"); + } + + [Fact(DisplayName = "CatTx-Ptt: Ptt.Method \"CatCommand\" resolves to PttControllerKind.CatCommand")] + public void Resolve_CatCommand_ReturnsCatCommand() + { + var logger = new CapturingLogger(); + + var kind = PttControllerSelector.Resolve("CatCommand", logger); + + kind.Should().Be(PttControllerKind.CatCommand); + logger.Entries.Should().BeEmpty("a recognised method must not log anything"); + } + + [Fact(DisplayName = "CatTx-Ptt: Ptt.Method \"SerialRtsDtr\" resolves to PttControllerKind.SerialRtsDtr")] + public void Resolve_SerialRtsDtr_ReturnsSerialRtsDtr() + { + var logger = new CapturingLogger(); + + var kind = PttControllerSelector.Resolve("SerialRtsDtr", logger); + + kind.Should().Be(PttControllerKind.SerialRtsDtr); + logger.Entries.Should().BeEmpty("a recognised method must not log anything"); + } + + [Fact(DisplayName = "CatTx-Ptt: unrecognised Ptt.Method falls back to AudioVox and logs a Warning naming the invalid value")] + public void Resolve_UnrecognisedMethod_FallsBackToAudioVoxAndLogsWarning() + { + var logger = new CapturingLogger(); + + var kind = PttControllerSelector.Resolve("SomeBogusMethod", logger); + + kind.Should().Be(PttControllerKind.AudioVox); + logger.Entries.Should().Contain(e => + e.Level == LogLevel.Warning && e.Message.Contains("SomeBogusMethod"), + "the fallback must be logged at Warning naming the invalid value"); + } + + [Fact(DisplayName = "CatTx-Ptt: missing/default Ptt.Method (\"AudioVox\") resolves to AudioVox — preserves today's behaviour")] + public void Resolve_DefaultMethod_ResolvesToAudioVox() + { + var logger = new CapturingLogger(); + var defaultMethod = new OpenWSFZ.Abstractions.PttConfig().Method; + + var kind = PttControllerSelector.Resolve(defaultMethod, logger); + + kind.Should().Be(PttControllerKind.AudioVox); + } + + // ── Test logger ─────────────────────────────────────────────────────────── + + private sealed class CapturingLogger : ILogger + { + public List<(LogLevel Level, string Message)> Entries { get; } = new(); + + public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance; + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, EventId eventId, TState state, Exception? exception, + Func formatter) + { + lock (Entries) Entries.Add((logLevel, formatter(state, exception))); + } + + private sealed class NullScope : IDisposable + { + public static readonly NullScope Instance = new(); + public void Dispose() { } + } + } +} diff --git a/tests/OpenWSFZ.Daemon.Tests/PttWatchdogTests.cs b/tests/OpenWSFZ.Daemon.Tests/PttWatchdogTests.cs new file mode 100644 index 0000000..f9958ac --- /dev/null +++ b/tests/OpenWSFZ.Daemon.Tests/PttWatchdogTests.cs @@ -0,0 +1,134 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace OpenWSFZ.Daemon.Tests; + +/// +/// Unit tests for (FR-056, design.md Decision 4, task 12.8). +/// Deliberately has no dependency on WASAPI or a real PTT-assertion mechanism — a plain +/// timer + callback, so these run without WASAPI_SUPPORTED or Windows. +/// +[Trait("Category", "Unit")] +public sealed class PttWatchdogTests +{ + [Fact(DisplayName = "CatTx-Ptt: watchdog forces release and logs Error when KeyUpAsync never arrives")] + public async Task Fires_WhenNeverDisarmed_InvokesCallbackAndLogsError() + { + var logger = new CapturingLogger(); + var watchdog = new PttWatchdog(logger, "TestController"); + var released = new TaskCompletionSource(); + + watchdog.Arm(50, () => + { + released.TrySetResult(); + return Task.CompletedTask; + }); + + var completed = await Task.WhenAny(released.Task, Task.Delay(2000)); + completed.Should().Be(released.Task, "the watchdog must force a release when never disarmed"); + + // Give the log call (which happens just before invoking the callback) a moment + // to land — same thread, so this is a formality rather than a real race. + await Task.Delay(20); + logger.Entries.Should().Contain(e => + e.Level == LogLevel.Error && e.Message.Contains("watchdog fired"), + "the watchdog must log at Error including 'watchdog fired'"); + logger.Entries.Should().Contain(e => e.Message.Contains("TestController"), + "the log line must name the owning controller"); + } + + [Fact(DisplayName = "CatTx-Ptt: watchdog does not fire when Disarm is called before the timeout elapses")] + public async Task Disarm_BeforeTimeout_CallbackNeverInvoked() + { + var logger = new CapturingLogger(); + var watchdog = new PttWatchdog(logger, "TestController"); + var fired = false; + + watchdog.Arm(200, () => + { + fired = true; + return Task.CompletedTask; + }); + + await Task.Delay(30); + watchdog.Disarm(); + + // Wait well past the original timeout to prove it never fires late. + await Task.Delay(300); + + fired.Should().BeFalse("Disarm before the timeout must prevent the forced-release callback"); + logger.Entries.Should().NotContain(e => e.Level == LogLevel.Error); + } + + [Fact(DisplayName = "CatTx-Ptt: watchdog fires even when the forced-release callback itself represents a hung operation")] + public async Task Fires_EvenWhenGuardedOperationHangs() + { + // The watchdog has no knowledge of what KeyDownAsync/playback is doing — it fires + // purely on elapsed time since Arm(), independent of whether the thing it is + // guarding (e.g. a hung WASAPI playback) ever completes. + var logger = new CapturingLogger(); + var watchdog = new PttWatchdog(logger, "TestController"); + var callbackRan = new TaskCompletionSource(); + + watchdog.Arm(50, () => + { + callbackRan.TrySetResult(); + return Task.CompletedTask; + }); + + // Never call Disarm() — simulates KeyDownAsync/playback hanging indefinitely. + var completed = await Task.WhenAny(callbackRan.Task, Task.Delay(2000)); + completed.Should().Be(callbackRan.Task, + "the watchdog must fire on elapsed time alone, regardless of what it is guarding"); + } + + [Fact(DisplayName = "CatTx-Ptt: re-arming an already-armed watchdog replaces the pending timer")] + public async Task Arm_WhileAlreadyArmed_ReplacesPendingTimer() + { + var logger = new CapturingLogger(); + var watchdog = new PttWatchdog(logger, "TestController"); + var fireCount = 0; + + watchdog.Arm(500, () => { Interlocked.Increment(ref fireCount); return Task.CompletedTask; }); + await Task.Delay(20); + + // Re-arm with a much shorter timeout — only the second timer should ever fire. + var secondFired = new TaskCompletionSource(); + watchdog.Arm(30, () => + { + Interlocked.Increment(ref fireCount); + secondFired.TrySetResult(); + return Task.CompletedTask; + }); + + var completed = await Task.WhenAny(secondFired.Task, Task.Delay(2000)); + completed.Should().Be(secondFired.Task); + + await Task.Delay(600); // past the original (replaced) 500 ms timer too + fireCount.Should().Be(1, "only the most recent Arm() call should ever fire"); + } + + // ── Test logger ─────────────────────────────────────────────────────────── + + private sealed class CapturingLogger : ILogger + { + public List<(LogLevel Level, string Message)> Entries { get; } = new(); + + public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance; + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, EventId eventId, TState state, Exception? exception, + Func formatter) + { + lock (Entries) Entries.Add((logLevel, formatter(state, exception))); + } + + private sealed class NullScope : IDisposable + { + public static readonly NullScope Instance = new(); + public void Dispose() { } + } + } +} diff --git a/tests/OpenWSFZ.Daemon.Tests/QsoAnswererServiceTests.cs b/tests/OpenWSFZ.Daemon.Tests/QsoAnswererServiceTests.cs index 7746ad5..2490fba 100644 --- a/tests/OpenWSFZ.Daemon.Tests/QsoAnswererServiceTests.cs +++ b/tests/OpenWSFZ.Daemon.Tests/QsoAnswererServiceTests.cs @@ -318,6 +318,67 @@ public async Task Idle_NonCqMessage_StaysIdle() await _ptt.DidNotReceive().KeyDownAsync(Arg.Any()); } + // ── dev-task 2026-07-12-cat-tx-ptt-missing-keyup-after-transmit.md ───────── + + [Fact(DisplayName = "dev-task 2026-07-12: normal transmission calls KeyDownAsync immediately followed by KeyUpAsync, with no intervening state transition")] + public async Task TransmitAsync_NormalCompletion_KeyUpImmediatelyFollowsKeyDown() + { + // Regression test: before this fix, TransmitAsync's normal-completion path never + // called KeyUpAsync at all — every real TX cycle relied entirely on PttWatchdog's + // 20 s failsafe to ever release PTT, which broke FT8 slot timing on every single + // transmission (see dev-tasks/2026-07-12-cat-tx-ptt-missing-keyup-after-transmit.md). + Send(Make($"CQ {PartnerCall} {PartnerGrid}")); + + await WaitForStateAsync(_sut!, QsoState.WaitReport); + _sut!.Partner.Should().Be(PartnerCall); + + // KeyDownAsync immediately followed by KeyUpAsync — no other call intervenes — and + // each was called exactly once, proving PTT is released inside TransmitAsync's own + // normal-completion path rather than "eventually, from the watchdog". + Received.InOrder(() => + { + _ptt.KeyDownAsync(Arg.Any()); + _ptt.KeyUpAsync(Arg.Any()); + }); + await _ptt.Received(1).KeyDownAsync(Arg.Any()); + await _ptt.Received(1).KeyUpAsync(Arg.Any()); + _sut!.Keying.Should().BeFalse("KeyUpAsync's own finally clears keying once TransmitAsync returns"); + } + + [Fact(DisplayName = "dev-task 2026-07-12: KeyUpAsync still runs when the transmission is cancelled mid-KeyDownAsync")] + public async Task TransmitAsync_CancelledMidKeyDown_StillCallsKeyUpAsync() + { + // Override the shared fixture's default no-op KeyDownAsync: it now hangs until its + // token is cancelled, so this test proves the finally block (and its KeyUpAsync call) + // is reached via the cancellation path, not the normal-return path. + _ptt.KeyDownAsync(Arg.Any()) + .Returns(callInfo => Task.Delay(Timeout.Infinite, callInfo.Arg())); + + Send(Make($"CQ {PartnerCall} {PartnerGrid}")); + await WaitForStateAsync(_sut!, QsoState.TxAnswer); + _sut!.Keying.Should().BeTrue(); + + await _ptt.DidNotReceive().KeyUpAsync(Arg.Any()); + + // Cancel the token ExecuteAsync was started with — linked into TransmitAsync's + // `linked` CTS alongside `_txCts`, so it interrupts the in-flight KeyDownAsync + // directly (deliberately not going through AbortAsync, which has its own, separate + // KeyUpAsync cleanup call — this test isolates TransmitAsync's own finally block). + await _stopCts!.CancelAsync(); + + // Poll rather than a fixed delay — the finally block runs asynchronously once the + // cancellation propagates through Task.Delay. + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5); + while (DateTime.UtcNow < deadline + && _ptt.ReceivedCalls().Count(c => c.GetMethodInfo().Name == "KeyUpAsync") == 0) + { + await Task.Delay(10); + } + + await _ptt.Received(1).KeyUpAsync(Arg.Any()); + _sut!.Keying.Should().BeFalse("the finally block clears keying even on the cancellation path"); + } + // ── Task 6.6: WaitReport ────────────────────────────────────────────────── [Fact(DisplayName = "6.6: Signal report from partner → TxReport → WaitRr73")] diff --git a/tests/OpenWSFZ.Daemon.Tests/QsoCallerServiceTests.cs b/tests/OpenWSFZ.Daemon.Tests/QsoCallerServiceTests.cs index 5f25f5a..2d38859 100644 --- a/tests/OpenWSFZ.Daemon.Tests/QsoCallerServiceTests.cs +++ b/tests/OpenWSFZ.Daemon.Tests/QsoCallerServiceTests.cs @@ -212,6 +212,139 @@ public async Task TransmitAsync_BracketsKeyDownAsync_WithKeyingTrueThenFalse() await ptt.DisposeAsync(); } + // ── dev-task 2026-07-12-cat-tx-ptt-missing-keyup-after-transmit.md ───────── + + [Fact(DisplayName = "dev-task 2026-07-12: normal transmission calls KeyDownAsync immediately followed by KeyUpAsync, with no intervening state transition")] + public async Task TransmitAsync_NormalCompletion_KeyUpImmediatelyFollowsKeyDown_NoInterveningStateTransition() + { + // Regression test: before this fix, TransmitAsync's normal-completion path never + // called KeyUpAsync at all — every real TX cycle relied entirely on PttWatchdog's + // 20 s failsafe to ever release PTT, which broke FT8 slot timing on every single + // transmission (see dev-tasks/2026-07-12-cat-tx-ptt-missing-keyup-after-transmit.md). + var tx = new TxConfig + { + AutoAnswer = true, + Callsign = OurCallsign, + Grid = OurGrid, + RetryCount = 3, + WatchdogMinutes = 4, + }; + var (sut, eventBus, _, ptt, channel, stopCts) = BuildIsolatedSut(tx, watchdogDuration: TimeSpan.FromSeconds(30)); + + // Manually record the interleaved call order across both substitutes. NSubstitute's + // Received.InOrder does not reliably interleave calls made on two different substitute + // instances (verified empirically — it throws CallSequenceNotFoundException even for a + // provably valid subsequence), so this hand-rolled recorder is the robust way to assert + // strict ordering across ptt and eventBus here. + var callOrder = new List(); + ptt.KeyDownAsync(Arg.Any()) + .Returns(_ => { callOrder.Add("KeyDownAsync"); return Task.CompletedTask; }); + ptt.KeyUpAsync(Arg.Any()) + .Returns(_ => { callOrder.Add("KeyUpAsync"); return Task.CompletedTask; }); + eventBus.When(e => e.Publish( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any())) + .Do(ci => callOrder.Add($"Publish:{ci.ArgAt(0)}:keying={ci.ArgAt(5)}")); + + await sut.StartAsync(stopCts.Token); + + // Send any batch — triggers CQ transmission from Idle. + Send(channel, Make("CQ Q2NOISE JO00")); + await WaitForStateAsync(sut, QsoState.WaitReport, timeout: TimeSpan.FromSeconds(5)); // WaitAnswer proxy + + // KeyDownAsync must be immediately followed by KeyUpAsync in the recorded call order — + // nothing else intervenes — proving PTT is released inside TransmitAsync's own + // normal-completion path, not merely "eventually, from the watchdog". + var keyDownIndex = callOrder.IndexOf("KeyDownAsync"); + keyDownIndex.Should().BeGreaterThanOrEqualTo(0, "KeyDownAsync must have been called"); + callOrder.Skip(keyDownIndex).Take(2).Should().Equal( + ["KeyDownAsync", "KeyUpAsync"], + "KeyUpAsync must run immediately after KeyDownAsync, with no intervening state transition"); + + // ...and the WaitAnswer state broadcast (TransmitAsync's caller resuming the state + // machine) must come strictly after both. + var waitAnswerIndex = callOrder.FindIndex(c => c.StartsWith("Publish:WaitAnswer", StringComparison.Ordinal)); + waitAnswerIndex.Should().BeGreaterThan(keyDownIndex + 1, + "the WaitAnswer transition must not be published until after KeyUpAsync has run"); + + // Exactly one KeyDownAsync, exactly one KeyUpAsync — no extra calls sneaking in from + // an abort path, since none was taken. + await ptt.Received(1).KeyDownAsync(Arg.Any()); + await ptt.Received(1).KeyUpAsync(Arg.Any()); + + await stopCts.CancelAsync(); + await sut.StopAsync(CancellationToken.None); + await ptt.DisposeAsync(); + } + + [Fact(DisplayName = "dev-task 2026-07-12: KeyUpAsync still runs when the transmission is cancelled mid-KeyDownAsync")] + public async Task TransmitAsync_CancelledMidKeyDown_StillCallsKeyUpAsync() + { + var tx = new TxConfig + { + AutoAnswer = true, + Callsign = OurCallsign, + Grid = OurGrid, + RetryCount = 3, + WatchdogMinutes = 4, + }; + + // KeyDownAsync never completes on its own — it only ends when its token is cancelled, + // so this test proves the finally block (and its KeyUpAsync call) is reached via the + // cancellation path, not the normal-return path. + var ptt = Substitute.For(); + ptt.KeyDownAsync(Arg.Any()) + .Returns(callInfo => Task.Delay(Timeout.Infinite, callInfo.Arg())); + ptt.KeyUpAsync(Arg.Any()).Returns(Task.CompletedTask); + + var store = Substitute.For(); + store.Current.Returns(new AppConfig() with { Tx = tx }); + store.SaveAsync(Arg.Any(), Arg.Any()).Returns(Task.CompletedTask); + + var eventBus = Substitute.For(); + var channel = Channel.CreateUnbounded(); + var stopCts = new CancellationTokenSource(); + + var sut = new QsoCallerService( + channel.Reader, store, ptt, eventBus, + new AdifLogWriter(store, NullLogger.Instance), new AudioOffsetEventBus(), + NullLogger.Instance, + watchdogDurationOverride: TimeSpan.FromSeconds(30)); + + await sut.StartAsync(stopCts.Token); + + // Trigger CQ — the service enters TxCq (proxy: TxAnswer) and blocks inside KeyDownAsync. + Send(channel, Make("CQ Q2NOISE JO00")); + await WaitForStateAsync(sut, QsoState.TxAnswer, timeout: TimeSpan.FromSeconds(5)); + sut.Keying.Should().BeTrue(); + + await ptt.DidNotReceive().KeyUpAsync(Arg.Any()); + + // Cancel the token ExecuteAsync was started with — this is the token linked into + // TransmitAsync's `linked` CTS alongside `_txCts`, so it interrupts the in-flight + // KeyDownAsync directly (deliberately not going through AbortAsync, which has its own, + // separate KeyUpAsync cleanup call — this test isolates TransmitAsync's own finally + // block). Because stoppingToken.IsCancellationRequested is now true, ExecuteAsync's + // `catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)` + // branch simply breaks the loop rather than routing through SafeAbortToIdleAsync, so + // any KeyUpAsync call observed here can only have come from TransmitAsync's finally. + await stopCts.CancelAsync(); + + // Poll rather than a fixed delay — the finally block runs asynchronously once the + // cancellation propagates through Task.Delay. + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5); + while (DateTime.UtcNow < deadline && ptt.ReceivedCalls().Count(c => c.GetMethodInfo().Name == "KeyUpAsync") == 0) + { + await Task.Delay(10); + } + + await ptt.Received(1).KeyUpAsync(Arg.Any()); + sut.Keying.Should().BeFalse("the finally block clears keying even on the cancellation path"); + + await sut.StopAsync(CancellationToken.None); + await ptt.DisposeAsync(); + } + // ── 5.3: WaitAnswer First mode auto-engages ─────────────────────────────── [Fact(DisplayName = "5.3: WaitAnswer First mode — batch with response auto-advances to TxReport")] @@ -1326,7 +1459,12 @@ public async Task GracefulStopAsync_WhileTransmittingCq_DoesNotInterruptThenRetu await WaitForStateAsync(sut, QsoState.Idle, timeout: TimeSpan.FromSeconds(5)); await ptt.Received(1).KeyDownAsync(Arg.Any()); // exactly one TX — no retransmission - await ptt.Received(1).KeyUpAsync(Arg.Any()); // SafeAbortToIdleAsync's cleanup call + // dev-task 2026-07-12-cat-tx-ptt-missing-keyup-after-transmit.md: TransmitAsync's own + // finally block now calls KeyUpAsync once on every normal-completion TX (the fix under + // test), and SafeAbortToIdleAsync's unconditional cleanup call fires a second time when + // the state machine reaches Idle — both are individually safe no-ops on a controller + // with nothing asserted, so two calls is the correct, expected total here. + await ptt.Received(2).KeyUpAsync(Arg.Any()); await stopCts.CancelAsync(); await sut.StopAsync(CancellationToken.None); diff --git a/tests/OpenWSFZ.Daemon.Tests/SerialRtsDtrPttControllerTests.cs b/tests/OpenWSFZ.Daemon.Tests/SerialRtsDtrPttControllerTests.cs new file mode 100644 index 0000000..018f859 --- /dev/null +++ b/tests/OpenWSFZ.Daemon.Tests/SerialRtsDtrPttControllerTests.cs @@ -0,0 +1,354 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using OpenWSFZ.Abstractions; +using Xunit; + +namespace OpenWSFZ.Daemon.Tests; + +/// +/// Unit tests for (FR-056, task 12.7). A +/// and an injected playback-override delegate are used +/// throughout, mirroring 's/'s +/// own test seams, so no real hardware is required. +/// +#if WASAPI_SUPPORTED +[System.Runtime.Versioning.SupportedOSPlatform("windows")] +public sealed class SerialRtsDtrPttControllerTests +{ + private static readonly float[] SomeSamples = new float[606_720]; // 79 × 7680 at 48 kHz + + // ── LoadAudio + KeyDownAsync ────────────────────────────────────────────── + + [Fact(DisplayName = "CatTx-Ptt: KeyDownAsync without LoadAudio throws and does not assert the line")] + public async Task KeyDownAsync_WithoutLoadAudio_ThrowsAndDoesNotAssertLine() + { + await using var sut = MakeSut(out var port); + + var act = async () => await sut.KeyDownAsync(); + + await act.Should().ThrowExactlyAsync() + .WithMessage("*LoadAudio*"); + port.RtsEnable.Should().BeFalse(); + port.DtrEnable.Should().BeFalse(); + } + + [Fact(DisplayName = "CatTx-Ptt: KeyDownAsync asserts the RTS line before audio playback starts (default line)")] + public async Task KeyDownAsync_AssertsRtsBeforeAudioStarts() + { + var order = new List(); + var fakePort = new FakeSerialPort(); + var store = MakeConfigStore(); + var logger = NullLogger.Instance; + + // Constructed directly (not via MakeSut) so the playback-override closure can + // reference fakePort — an out-parameter from MakeSut cannot be read inside a + // lambda argument passed within that same call (CS0165). + await using var sut = new SerialRtsDtrPttController(store, logger, fakePort, (_, _, _) => + { + order.Add($"audio-start(rts={fakePort.RtsEnable})"); + return Task.CompletedTask; + }); + + sut.LoadAudio(SomeSamples); + await sut.KeyDownAsync(); + + fakePort.RtsEnable.Should().BeTrue(); + fakePort.DtrEnable.Should().BeFalse(); + order.Should().ContainSingle().Which.Should().Be("audio-start(rts=True)", + "RTS must already be asserted by the time playback starts"); + } + + [Fact(DisplayName = "CatTx-Ptt: DTR line is asserted instead of RTS when configured")] + public async Task KeyDownAsync_SerialLineDtr_AssertsDtrNotRts() + { + await using var sut = MakeSut(out var port, serialLine: "Dtr"); + + sut.LoadAudio(SomeSamples); + await sut.KeyDownAsync(); + + port.DtrEnable.Should().BeTrue(); + port.RtsEnable.Should().BeFalse(); + } + + [Fact(DisplayName = "CatTx-Ptt: unrecognised serialLine falls back to RTS with a logged Warning")] + public async Task KeyDownAsync_UnrecognisedSerialLine_FallsBackToRts() + { + var logger = new CapturingLogger(); + await using var sut = MakeSut(out var port, serialLine: "Bogus", logger: logger); + + sut.LoadAudio(SomeSamples); + await sut.KeyDownAsync(); + + port.RtsEnable.Should().BeTrue("an unrecognised serialLine value must fall back to Rts"); + port.DtrEnable.Should().BeFalse(); + logger.Entries.Should().Contain(e => + e.Level == Microsoft.Extensions.Logging.LogLevel.Warning && + e.Message.Contains("Bogus"), + "the fallback must be logged at Warning naming the invalid value"); + } + + [Fact(DisplayName = "CatTx-Ptt: KeyDownAsync honours LeadTimeMs before audio playback starts")] + public async Task KeyDownAsync_HonoursLeadTimeMs() + { + var sw = System.Diagnostics.Stopwatch.StartNew(); + TimeSpan? audioStartedAt = null; + + await using var sut = MakeSut(out _, playerOverride: (_, _, _) => + { + audioStartedAt = sw.Elapsed; + return Task.CompletedTask; + }, leadTimeMs: 80); + + sut.LoadAudio(SomeSamples); + await sut.KeyDownAsync(); + + audioStartedAt.Should().NotBeNull(); + audioStartedAt!.Value.TotalMilliseconds.Should().BeGreaterThanOrEqualTo( + 75, "playback must not begin until at least LeadTimeMs has elapsed after the line is asserted"); + } + + [Fact(DisplayName = "CatTx-Ptt: port-open failure propagates from KeyDownAsync rather than silently skipping PTT assertion")] + public async Task KeyDownAsync_PortOpenFails_Throws() + { + var fakePort = new FakeSerialPort { ThrowOnOpen = new UnauthorizedAccessException("port in use") }; + var store = MakeConfigStore(); + var logger = NullLogger.Instance; + var sut = new SerialRtsDtrPttController(store, logger, fakePort, (_, _, _) => Task.CompletedTask); + + sut.LoadAudio(SomeSamples); + + var act = async () => await sut.KeyDownAsync(); + await act.Should().ThrowAsync(); + + fakePort.RtsEnable.Should().BeFalse("PTT must never be asserted when the port failed to open"); + await sut.DisposeAsync(); + } + + [Fact(DisplayName = "CatTx-Ptt: PTT line is de-asserted before an exception from playback propagates")] + public async Task KeyDownAsync_PlayerThrows_StillDeassertsLineFirst() + { + await using var sut = MakeSut(out var port, (_, _, _) => + Task.FromException(new InvalidOperationException("device busy"))); + + sut.LoadAudio(SomeSamples); + + var act = async () => await sut.KeyDownAsync(); + await act.Should().ThrowExactlyAsync(); + + port.RtsEnable.Should().BeFalse("the line must be de-asserted even though playback threw"); + } + + // ── KeyUpAsync ──────────────────────────────────────────────────────────── + + [Fact(DisplayName = "CatTx-Ptt: KeyUpAsync when not asserted completes without touching the line")] + public async Task KeyUpAsync_WhenNotAsserted_CompletesGracefully() + { + await using var sut = MakeSut(out var port); + + var act = async () => await sut.KeyUpAsync(); + await act.Should().NotThrowAsync(); + + port.RtsEnable.Should().BeFalse(); + } + + [Fact(DisplayName = "CatTx-Ptt: KeyUpAsync de-asserts the line only after TailTimeMs has elapsed")] + public async Task KeyUpAsync_HonoursTailTimeMsBeforeDeasserting() + { + await using var sut = MakeSut(out var port, (_, _, _) => Task.CompletedTask, tailTimeMs: 80); + + sut.LoadAudio(SomeSamples); + await sut.KeyDownAsync(); + port.RtsEnable.Should().BeTrue(); + + var sw = System.Diagnostics.Stopwatch.StartNew(); + await sut.KeyUpAsync(); + sw.Stop(); + + port.RtsEnable.Should().BeFalse(); + sw.Elapsed.TotalMilliseconds.Should().BeGreaterThanOrEqualTo( + 75, "the line must not be de-asserted until at least TailTimeMs has elapsed"); + } + + // ── DisposeAsync ────────────────────────────────────────────────────────── + + [Fact(DisplayName = "CatTx-Ptt: DisposeAsync de-asserts the line if still asserted, and closes the port")] + public async Task DisposeAsync_WhileAsserted_DeassertsAndClosesPort() + { + var fakePort = new FakeSerialPort(); + var store = MakeConfigStore(leadTimeMs: 0, tailTimeMs: 0); + var logger = NullLogger.Instance; + var sut = new SerialRtsDtrPttController( + store, logger, fakePort, (_, _, ct) => Task.Delay(Timeout.Infinite, ct)); + + sut.LoadAudio(SomeSamples); + using var cts = new CancellationTokenSource(); + var keyDownTask = sut.KeyDownAsync(cts.Token); + + await Task.Delay(50); // let KeyDownAsync assert the line and reach the hung player + fakePort.RtsEnable.Should().BeTrue(); + + await sut.DisposeAsync(); + + fakePort.RtsEnable.Should().BeFalse("DisposeAsync must force-release an asserted line"); + fakePort.CloseCallCount.Should().Be(1); + fakePort.DisposeCallCount.Should().Be(1); + + cts.Cancel(); + try { await keyDownTask; } catch { /* expected — cancelled/disposed mid-flight */ } + } + + [Fact(DisplayName = "CatTx-Ptt: DisposeAsync when not asserted still closes the port without touching the line")] + public async Task DisposeAsync_WhenNotAsserted_ClosesPortOnly() + { + var fakePort = new FakeSerialPort(); + var store = MakeConfigStore(); + var logger = NullLogger.Instance; + var sut = new SerialRtsDtrPttController(store, logger, fakePort, (_, _, _) => Task.CompletedTask); + + await sut.DisposeAsync(); + + fakePort.RtsEnable.Should().BeFalse(); + fakePort.DisposeCallCount.Should().Be(1); + } + + // ── Independence from CAT ──────────────────────────────────────────────── + + [Fact(DisplayName = "CatTx-Ptt: keys/unkeys correctly with no CAT dependency of any kind")] + public async Task KeyDownAndUp_NoCatDependency_WorksStandalone() + { + // SerialRtsDtrPttController's constructor takes no ICatPttGate/CatPollingService + // reference at all — this test simply proves the full cycle works using only its + // own IConfigStore + ISerialPort, with no CAT-related type anywhere in scope. + await using var sut = MakeSut(out var port, (_, _, _) => Task.CompletedTask); + + sut.LoadAudio(SomeSamples); + await sut.KeyDownAsync(); + port.RtsEnable.Should().BeTrue(); + + await sut.KeyUpAsync(); + port.RtsEnable.Should().BeFalse(); + } + + // ── Call-serialisation (task 17.2) ─────────────────────────────────────── + + [Fact(DisplayName = "CatTx-Ptt: two concurrent KeyDownAsync callers serialise rather than interleave")] + public async Task KeyDownAsync_TwoConcurrentCallers_Serialise() + { + var order = new List(); + var callCount = 0; + var firstPlaybackReleaseGate = new TaskCompletionSource(); + + var fakePort = new FakeSerialPort(); + var store = MakeConfigStore(); + var logger = NullLogger.Instance; + + await using var sut = new SerialRtsDtrPttController(store, logger, fakePort, async (_, _, _) => + { + var n = Interlocked.Increment(ref callCount); + lock (order) order.Add($"audio-start-{n}(rts={fakePort.RtsEnable})"); + if (n == 1) + await firstPlaybackReleaseGate.Task; // held open until the test releases it + }); + + sut.LoadAudio(SomeSamples); + + // Caller #1 — simulates the real, active QsoAnswererService/QsoCallerService + // transmission — begins and stays "mid-transmission" until released below. + var firstKeyDown = sut.KeyDownAsync(); + + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(2); + while (DateTime.UtcNow < deadline && callCount < 1) + await Task.Delay(10); + callCount.Should().Be(1, "caller #1 must have started its playback by now"); + fakePort.RtsEnable.Should().BeTrue("caller #1 must have asserted PTT by now"); + + // Caller #2 — simulates a Settings-page Test click racing the real transmission. + var secondKeyDown = Task.Run(() => sut.KeyDownAsync()); + + // Give caller #2 a chance to (incorrectly) race ahead if the lock were missing. + await Task.Delay(100); + callCount.Should().Be(1, + "caller #2 must remain blocked behind caller #1's still-open KeyUpAsync — it must " + + "not be able to assert (and, worse, later de-assert) the line while caller #1's real " + + "transmission is still in flight"); + + // Complete caller #1's cycle exactly as the real state machine would. + firstPlaybackReleaseGate.SetResult(); + await firstKeyDown; + await sut.KeyUpAsync(); + + // Caller #2 must now be able to proceed and assert the line for its own cycle. + await secondKeyDown.WaitAsync(TimeSpan.FromSeconds(5)); + + callCount.Should().Be(2); + order.Should().Equal(["audio-start-1(rts=True)", "audio-start-2(rts=True)"], + "caller #2's playback must not start until caller #1's KeyUpAsync has completed"); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private static IConfigStore MakeConfigStore( + string serialLine = "Rts", int leadTimeMs = 0, int tailTimeMs = 0, int watchdogTimeoutMs = 20_000) + { + var store = Substitute.For(); + store.Current.Returns(new AppConfig() with + { + Ptt = new PttConfig + { + Method = "SerialRtsDtr", + SerialLine = serialLine, + LeadTimeMs = leadTimeMs, + TailTimeMs = tailTimeMs, + WatchdogTimeoutMs = watchdogTimeoutMs, + } + }); + return store; + } + + private static SerialRtsDtrPttController MakeSut( + out FakeSerialPort port, + Func? playerOverride = null, + string serialLine = "Rts", + int leadTimeMs = 0, + int tailTimeMs = 0, + int watchdogTimeoutMs = 20_000, + CapturingLogger? logger = null) + { + var fakePort = new FakeSerialPort(); + port = fakePort; + + var store = MakeConfigStore(serialLine, leadTimeMs, tailTimeMs, watchdogTimeoutMs); + Microsoft.Extensions.Logging.ILogger log = logger is not null + ? logger + : NullLogger.Instance; + + return new SerialRtsDtrPttController( + store, log, fakePort, playerOverride ?? ((_, _, _) => Task.CompletedTask)); + } + + // ── Test logger (shared shape with PttWatchdogTests, kept local to avoid a + // cross-file dependency on an internal test type) ──────────────────── + + private sealed class CapturingLogger : Microsoft.Extensions.Logging.ILogger + { + public List<(Microsoft.Extensions.Logging.LogLevel Level, string Message)> Entries { get; } = new(); + + public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance; + public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) => true; + + public void Log( + Microsoft.Extensions.Logging.LogLevel logLevel, Microsoft.Extensions.Logging.EventId eventId, + TState state, Exception? exception, Func formatter) + { + lock (Entries) Entries.Add((logLevel, formatter(state, exception))); + } + + private sealed class NullScope : IDisposable + { + public static readonly NullScope Instance = new(); + public void Dispose() { } + } + } +} +#endif diff --git a/tests/OpenWSFZ.Rig.Tests/RigctldConnectionTests.cs b/tests/OpenWSFZ.Rig.Tests/RigctldConnectionTests.cs index a571f04..3aa12b1 100644 --- a/tests/OpenWSFZ.Rig.Tests/RigctldConnectionTests.cs +++ b/tests/OpenWSFZ.Rig.Tests/RigctldConnectionTests.cs @@ -172,4 +172,44 @@ public async Task SetDialFrequencyMhzAsync_ThrowsOnNonZeroRprt() await act.Should().ThrowAsync() .WithMessage("*RPRT -1*"); } + + // ── SetPttAsync (FR-056, task 12.2) ─────────────────────────────────────── + + [Fact(DisplayName = "CatTx-Ptt: SetPttAsync(true) sends set_ptt 1 and reads the RPRT 0 acknowledgement")] + public async Task SetPttAsync_True_SendsCommandAndReadsAck() + { + var tcp = Substitute.For(); + tcp.ReceiveLineAsync(Arg.Any()).Returns("RPRT 0"); + var sut = new RigctldConnection(Host, Port, tcp); + + await sut.SetPttAsync(true); + + await tcp.Received(1).SendAsync(@"\set_ptt 1" + "\n", Arg.Any()); + await tcp.Received(1).ReceiveLineAsync(Arg.Any()); + } + + [Fact(DisplayName = "CatTx-Ptt: SetPttAsync(false) sends set_ptt 0 and reads the RPRT 0 acknowledgement")] + public async Task SetPttAsync_False_SendsCommandAndReadsAck() + { + var tcp = Substitute.For(); + tcp.ReceiveLineAsync(Arg.Any()).Returns("RPRT 0"); + var sut = new RigctldConnection(Host, Port, tcp); + + await sut.SetPttAsync(false); + + await tcp.Received(1).SendAsync(@"\set_ptt 0" + "\n", Arg.Any()); + } + + [Fact(DisplayName = "CatTx-Ptt: SetPttAsync throws InvalidOperationException on non-zero RPRT code")] + public async Task SetPttAsync_ThrowsOnNonZeroRprt() + { + var tcp = Substitute.For(); + tcp.ReceiveLineAsync(Arg.Any()).Returns("RPRT -1"); + var sut = new RigctldConnection(Host, Port, tcp); + + var act = () => sut.SetPttAsync(true); + + await act.Should().ThrowAsync() + .WithMessage("*RPRT -1*"); + } } diff --git a/tests/OpenWSFZ.Rig.Tests/SerialCatConnectionTests.cs b/tests/OpenWSFZ.Rig.Tests/SerialCatConnectionTests.cs index 8255930..837c14c 100644 --- a/tests/OpenWSFZ.Rig.Tests/SerialCatConnectionTests.cs +++ b/tests/OpenWSFZ.Rig.Tests/SerialCatConnectionTests.cs @@ -326,4 +326,39 @@ public async Task SetDialFrequencyMhzAsync_DoesNotReadBack() // ReadTo must never be called — the method is fire-and-forget. port.DidNotReceive().ReadTo(Arg.Any()); } + + // ── SetPttAsync (FR-056, task 12.1) ─────────────────────────────────────── + + [Fact(DisplayName = "CatTx-Ptt: SetPttAsync(true) writes TX; to the serial port")] + public async Task SetPttAsync_True_WritesTxCommand() + { + var port = Substitute.For(); + var sut = new SerialCatConnection(port); + + await sut.SetPttAsync(true); + + port.Received(1).Write("TX;"); + } + + [Fact(DisplayName = "CatTx-Ptt: SetPttAsync(false) writes RX; to the serial port")] + public async Task SetPttAsync_False_WritesRxCommand() + { + var port = Substitute.For(); + var sut = new SerialCatConnection(port); + + await sut.SetPttAsync(false); + + port.Received(1).Write("RX;"); + } + + [Fact(DisplayName = "CatTx-Ptt: SetPttAsync does not read back a confirmation")] + public async Task SetPttAsync_DoesNotReadBack() + { + var port = Substitute.For(); + var sut = new SerialCatConnection(port); + + await sut.SetPttAsync(true); + + port.DidNotReceive().ReadTo(Arg.Any()); + } } diff --git a/tests/OpenWSFZ.Web.Tests/ConfigApiNullGuardTests.cs b/tests/OpenWSFZ.Web.Tests/ConfigApiNullGuardTests.cs index 49cdd4c..fd84414 100644 --- a/tests/OpenWSFZ.Web.Tests/ConfigApiNullGuardTests.cs +++ b/tests/OpenWSFZ.Web.Tests/ConfigApiNullGuardTests.cs @@ -146,4 +146,125 @@ public async Task PostConfig_OmittingExternalReportingKey_DoesNotPersistNullExte extRepElement.GetProperty("enabled").GetBoolean().Should().BeFalse( "the recovered ExternalReporting must be the default ExternalReportingConfig(), i.e. disabled"); } + + [Fact(DisplayName = "cat-tx-ptt AC-1: POST omitting \"ptt\" key does not persist a null Ptt")] + public async Task PostConfig_OmittingPttKey_DoesNotPersistNullPtt() + { + var client = _factory.CreateClient(); + var content = new StringContent( + """{ "audioDeviceId": "test-device" }""", + Encoding.UTF8, "application/json"); + + var postResp = await client.PostAsync("/api/v1/config", content); + postResp.StatusCode.Should().Be(HttpStatusCode.OK, + "a request body omitting ptt must still be accepted — the Settings page never sends " + + "a \"ptt\" key at all (design.md Decision 6: no speculative UI), so every ordinary " + + "Settings-page save must not be rejected"); + + var getResp = await client.GetAsync("/api/v1/config"); + getResp.StatusCode.Should().Be(HttpStatusCode.OK); + + var json = await getResp.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + + doc.RootElement.TryGetProperty("ptt", out var pttElement).Should().BeTrue( + "GET /api/v1/config must include the ptt key"); + pttElement.ValueKind.Should().NotBe(JsonValueKind.Null, + "a POST body omitting \"ptt\" must never leave IConfigStore.Current.Ptt null " + + "(silently reverts CAT-command/serial RTS-DTR PTT to VOX with no warning, and risks a " + + "stuck-key NullReferenceException in CatPttController/SerialRtsDtrPttController.KeyDownAsync " + + "if a CAT/serial controller is already the active singleton)"); + + // Freshly-initialised store: Current.Ptt is still the untouched default at this + // point, so falling back to it (rather than a hardcoded new PttConfig()) produces + // the same observable defaults as before. See the AC-2 test below for the case + // that actually distinguishes the two fallbacks — a previously-persisted non-default + // Ptt surviving an unrelated save. + pttElement.GetProperty("method").GetString().Should().Be("AudioVox"); + pttElement.GetProperty("serialLine").GetString().Should().Be("Rts"); + pttElement.GetProperty("leadTimeMs").GetInt32().Should().Be(50); + pttElement.GetProperty("tailTimeMs").GetInt32().Should().Be(50); + pttElement.GetProperty("watchdogTimeoutMs").GetInt32().Should().Be(20000); + } + + [Fact(DisplayName = + "cat-tx-ptt AC-2: an unrelated Settings-page save does not revert a previously-persisted " + + "non-default ptt.method back to AudioVox")] + public async Task PostConfig_UnrelatedSave_PreservesPreviouslyPersistedPtt() + { + // This is the actual hardware-acceptance symptom (dev-tasks/2026-07-12-cat-tx-ptt-null- + // ptt-config-guard.md, "Case A"): a null-guard that falls back to a hardcoded + // `new PttConfig()` stops the crash but does NOT stop every ordinary Settings-page + // save — even one that has nothing to do with PTT — from silently discarding an + // operator's manually-configured "CatCommand"/"SerialRtsDtr" ptt.method, because + // web/js/settings.js never sends a "ptt" key in the first place. The fix must fall + // back to the already-persisted IConfigStore.Current.Ptt instead, so an omitted + // "ptt" key is a true no-op rather than an implicit reset to defaults. + var client = _factory.CreateClient(); + + try + { + // Seed a non-default, previously-persisted Ptt section (simulates an operator + // having configured CAT-command PTT, whether via a prior POST that did include + // "ptt" or a hand-edited config.json picked up at daemon startup). + var seedContent = new StringContent( + """ + { + "audioDeviceId": "test-device", + "ptt": { + "method": "CatCommand", + "serialPort": "COM9", + "serialLine": "Dtr", + "leadTimeMs": 75, + "tailTimeMs": 75, + "watchdogTimeoutMs": 15000 + } + } + """, + Encoding.UTF8, "application/json"); + (await client.PostAsync("/api/v1/config", seedContent)).StatusCode + .Should().Be(HttpStatusCode.OK); + + // Sanity-check the seed actually took. + var seededJson = await (await client.GetAsync("/api/v1/config")).Content.ReadAsStringAsync(); + using (var seededDoc = JsonDocument.Parse(seededJson)) + { + seededDoc.RootElement.GetProperty("ptt").GetProperty("method").GetString() + .Should().Be("CatCommand", "the seed POST must have taken effect before testing preservation"); + } + + // Now perform an ordinary, unrelated Settings-page save — a real save always + // omits "ptt" entirely, so this reproduces exactly what web/js/settings.js sends. + var unrelatedSaveContent = new StringContent( + """{ "audioDeviceId": "test-device", "showCycleCountdown": true }""", + Encoding.UTF8, "application/json"); + (await client.PostAsync("/api/v1/config", unrelatedSaveContent)).StatusCode + .Should().Be(HttpStatusCode.OK); + + var afterJson = await (await client.GetAsync("/api/v1/config")).Content.ReadAsStringAsync(); + using var afterDoc = JsonDocument.Parse(afterJson); + var pttAfter = afterDoc.RootElement.GetProperty("ptt"); + + pttAfter.GetProperty("method").GetString().Should().Be("CatCommand", + "an unrelated Settings-page save must not silently revert ptt.method to AudioVox " + + "— this is the exact hardware-acceptance symptom (\"the radio never enables TX\") " + + "this test guards against"); + pttAfter.GetProperty("serialPort").GetString().Should().Be("COM9"); + pttAfter.GetProperty("serialLine").GetString().Should().Be("Dtr"); + pttAfter.GetProperty("leadTimeMs").GetInt32().Should().Be(75); + pttAfter.GetProperty("tailTimeMs").GetInt32().Should().Be(75); + pttAfter.GetProperty("watchdogTimeoutMs").GetInt32().Should().Be(15000); + } + finally + { + // Restore Current.Ptt to the default before returning control to the shared + // WebTestFactory (IClassFixture — one instance for this whole test class), so + // this test's seeded state can never leak into AC-1 or any other test in this + // class regardless of xUnit's execution order. + var resetContent = new StringContent( + """{ "audioDeviceId": "test-device", "ptt": { "method": "AudioVox" } }""", + Encoding.UTF8, "application/json"); + await client.PostAsync("/api/v1/config", resetContent); + } + } } diff --git a/tests/OpenWSFZ.Web.Tests/PttTestEndpointTests.cs b/tests/OpenWSFZ.Web.Tests/PttTestEndpointTests.cs new file mode 100644 index 0000000..80afa5f --- /dev/null +++ b/tests/OpenWSFZ.Web.Tests/PttTestEndpointTests.cs @@ -0,0 +1,206 @@ +using FluentAssertions; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.Extensions.DependencyInjection; +using OpenWSFZ.Abstractions; +using System.Net; +using System.Text.Json; +using Xunit; + +namespace OpenWSFZ.Web.Tests; + +// ── Test doubles for POST /api/v1/ptt/test (cat-tx-ptt, task 17.7, FR-057) ──── + +/// +/// Controllable stub with a settable , +/// so tests can exercise the endpoint's 409-while-a-real-QSO-is-transmitting guard +/// without needing a real QsoAnswererService/QsoCallerService. +/// +internal sealed class TestKeyingQsoController : IQsoController +{ + public QsoState State { get; set; } = QsoState.Idle; + public string? Partner { get; set; } + public QsoRole Role { get; set; } = QsoRole.Answerer; + public bool Keying { get; set; } + + public Task AbortAsync(CancellationToken ct = default) => Task.CompletedTask; + + public Task GracefulStopAsync(CancellationToken ct = default) => Task.CompletedTask; + + public Task AnswerCqAsync( + string callsign, double frequencyHz, DateTimeOffset cqCycleStart, CancellationToken ct) + => Task.CompletedTask; + + public Task SelectResponderAsync( + string callsign, double frequencyHz, DateTimeOffset responseCycleStart, CancellationToken ct) + => Task.CompletedTask; + + public Task EngageAtAsync( + string partnerCallsign, double frequencyHz, DateTimeOffset theirCycleStart, + EngagePoint point, CancellationToken ct) + => Task.CompletedTask; +} + +/// +/// Controllable stub that records the call sequence and can +/// be made to throw from /, so tests can +/// exercise the endpoint's pass/error response shape without any real WASAPI, CAT, or +/// serial hardware. +/// +internal sealed class TestPttController : IPttController +{ + public List Calls { get; } = new(); + public Exception? ThrowOnKeyDown { get; set; } + public Exception? ThrowOnKeyUp { get; set; } + + public void LoadAudio(float[] samples) => Calls.Add("LoadAudio"); + + public Task KeyDownAsync(CancellationToken ct = default) + { + Calls.Add("KeyDownAsync"); + if (ThrowOnKeyDown is not null) throw ThrowOnKeyDown; + return Task.CompletedTask; + } + + public Task KeyUpAsync(CancellationToken ct = default) + { + Calls.Add("KeyUpAsync"); + if (ThrowOnKeyUp is not null) throw ThrowOnKeyUp; + return Task.CompletedTask; + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; +} + +/// +/// Fixture that wires a and +/// into a live Kestrel instance (mirrors TxAnswerCqFixture's pattern) so +/// POST /api/v1/ptt/test tests can control both the "is a real QSO keying" state and +/// the PTT controller's success/failure behaviour without touching real hardware. +/// +public sealed class PttTestFixture : IAsyncLifetime +{ + internal readonly TestConfigStore ConfigStore = new(); + internal readonly TestKeyingQsoController QsoController = new(); + internal readonly TestPttController PttController = new(); + + private Microsoft.AspNetCore.Builder.WebApplication? _app; + public HttpClient Client { get; private set; } = null!; + + public async Task InitializeAsync() + { + _app = WebApp.Create( + port: 0, + configStore: ConfigStore, + configureServices: services => + services + .AddSingleton(QsoController) + .AddSingleton(PttController)); + + await _app.StartAsync(); + + var addr = _app.Services + .GetRequiredService() + .Features.Get()! + .Addresses.First(); + + Client = new HttpClient { BaseAddress = new Uri($"http://127.0.0.1:{new Uri(addr).Port}") }; + } + + public async Task DisposeAsync() + { + Client.Dispose(); + if (_app is not null) + { + await _app.StopAsync(); + await _app.DisposeAsync(); + } + } +} + +/// +/// Integration tests for POST /api/v1/ptt/test (cat-tx-ptt, task 17.7, FR-057) — +/// the 409-while-keying case, the 409-on-AudioVox case, and the pass/error response shape, +/// matching 's style. +/// +[Trait("Category", "Integration")] +public sealed class PttTestEndpointTests : IClassFixture +{ + private readonly PttTestFixture _fixture; + private readonly HttpClient _client; + + public PttTestEndpointTests(PttTestFixture fixture) + { + _fixture = fixture; + _client = fixture.Client; + + // Each test starts from a clean slate — IClassFixture shares one instance across + // all tests in this class (xUnit convention used throughout this test project). + _fixture.QsoController.Keying = false; + _fixture.PttController.Calls.Clear(); + _fixture.PttController.ThrowOnKeyDown = null; + _fixture.PttController.ThrowOnKeyUp = null; + } + + [Fact(DisplayName = "cat-tx-ptt 17.7: POST /api/v1/ptt/test returns 409 when the running method is AudioVox")] + public async Task PostPttTest_AudioVoxMethod_Returns409() + { + await _fixture.ConfigStore.SaveAsync(new AppConfig { Ptt = new PttConfig { Method = "AudioVox" } }); + + var response = await _client.PostAsync("/api/v1/ptt/test", content: null); + + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + _fixture.PttController.Calls.Should().BeEmpty( + "AudioVox has nothing to test — the controller must never be touched"); + } + + [Fact(DisplayName = "cat-tx-ptt 17.7: POST /api/v1/ptt/test returns 409 while a real QSO is keying")] + public async Task PostPttTest_WhileKeying_Returns409() + { + await _fixture.ConfigStore.SaveAsync(new AppConfig { Ptt = new PttConfig { Method = "CatCommand" } }); + _fixture.QsoController.Keying = true; + + var response = await _client.PostAsync("/api/v1/ptt/test", content: null); + + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + var body = await response.Content.ReadAsStringAsync(); + body.Should().Contain("transmitting", + "the 409 response must explain why — a real QSO is currently transmitting"); + _fixture.PttController.Calls.Should().BeEmpty( + "a Test click racing a real transmission must never touch the shared IPttController " + + "singleton — this is the regression guard for the safety finding behind task 17.2"); + } + + [Fact(DisplayName = "cat-tx-ptt 17.7: POST /api/v1/ptt/test returns pass and pulses PTT when the method is testable")] + public async Task PostPttTest_TestableMethod_ReturnsPassAndPulsesPtt() + { + await _fixture.ConfigStore.SaveAsync(new AppConfig { Ptt = new PttConfig { Method = "CatCommand" } }); + + var response = await _client.PostAsync("/api/v1/ptt/test", content: null); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var body = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(body); + doc.RootElement.GetProperty("result").GetString().Should().Be("pass"); + + _fixture.PttController.Calls.Should().Equal(["LoadAudio", "KeyDownAsync", "KeyUpAsync"], + "the endpoint must load a (silent) buffer, assert, then release — in that order"); + } + + [Fact(DisplayName = "cat-tx-ptt 17.7: POST /api/v1/ptt/test returns error (HTTP 200) when the pulse throws")] + public async Task PostPttTest_ControllerThrows_ReturnsErrorWithHttp200() + { + await _fixture.ConfigStore.SaveAsync(new AppConfig { Ptt = new PttConfig { Method = "SerialRtsDtr" } }); + _fixture.PttController.ThrowOnKeyDown = new InvalidOperationException("port in use"); + + var response = await _client.PostAsync("/api/v1/ptt/test", content: null); + + response.StatusCode.Should().Be(HttpStatusCode.OK, + "a real CAT/serial failure during the pulse is an expected, handleable outcome — " + + "not a server error, so it must not surface as HTTP 500"); + var body = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(body); + doc.RootElement.GetProperty("result").GetString().Should().Be("error"); + doc.RootElement.GetProperty("message").GetString().Should().Be("port in use"); + } +} diff --git a/web/css/app.css b/web/css/app.css index 9561e36..2046e27 100644 --- a/web/css/app.css +++ b/web/css/app.css @@ -880,6 +880,44 @@ label { .cat-error { background: var(--color-danger); border-color: var(--color-danger); color: #fff; } .cat-disabled { /* inherits the transparent default above */ } +/* ── CAT rig connection / PTT Config side-by-side layout (cat-tx-ptt, task 17.4) ── + Follows this page's existing intrinsic flex-wrap pattern (see .settings-tabs + above) rather than a fixed @media breakpoint — settings.html has none today, so + both fieldsets simply wrap onto their own row once the container is too narrow + to hold both at their minimum width. */ +.hardware-fieldset-row { + display: flex; + flex-wrap: wrap; + gap: 1.25rem; + align-items: flex-start; +} +.hardware-fieldset-row > fieldset { + flex: 1 1 380px; + min-width: 0; /* allow children (selects, inputs) to shrink below their intrinsic width */ +} + +/* ── PTT Config Test badge (cat-tx-ptt, task 17.4, FR-057) ─────────────────── + #ptt-settings gets no bespoke border/padding rule, matching #cat-settings — + neither fieldset has one; the browser's default fieldset chrome plus + .hardware-fieldset-row's gap keeps both boxes visually identical. */ +.ptt-test-badge { + display: inline-block; + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 0.15rem 0.6rem; + border-radius: var(--radius); + cursor: default; + border: 1px solid var(--color-border); + color: var(--color-muted); + background: transparent; +} + +.ptt-test-pass { background: var(--color-success); border-color: var(--color-success); color: #0d1117; } +.ptt-test-error { background: var(--color-danger); border-color: var(--color-danger); color: #fff; } +.ptt-test-idle { /* inherits the transparent default above */ } + /* ── Frequencies table (FR-043) ─────────────────────────────────────────── */ .freq-table { width: 100%; diff --git a/web/js/api.js b/web/js/api.js index 11837be..9299721 100644 --- a/web/js/api.js +++ b/web/js/api.js @@ -182,6 +182,46 @@ export async function postCatRetry() { // 204 No Content — no body to parse. } +/** + * POST /api/v1/ptt/test (cat-tx-ptt, task 17.3/17.6, FR-057) + * Fires a brief, silent PTT pulse against the currently-running IPttController. + * Unlike the generic fetchJson-based helpers above, this reads the JSON body on a 409 + * response too (rather than discarding it and throwing a generic "HTTP 409" error) so the + * caller can show the operator the actual reason ("a QSO is currently transmitting" / + * "PTT method is AudioVox…") rather than a bare status code. + * @returns {Promise<{result: 'pass'|'error', message: string|null}>} + */ +export async function postPttTest() { + const key = getApiKey(); + const res = await fetch('/api/v1/ptt/test', { + method: 'POST', + headers: key ? { 'X-Api-Key': key } : {}, + }); + + if (res.status === 401) { + sessionStorage.removeItem(API_KEY_SESSION_KEY); + window.location.href = '/login.html'; + return new Promise(() => {}); + } + + if (res.status === 409) { + let message = 'PTT test unavailable.'; + try { + const problem = await res.json(); + message = problem?.detail || problem?.title || message; + } catch { + // Malformed/absent ProblemDetails body — fall back to the generic message above. + } + return { result: 'error', message }; + } + + if (!res.ok) { + throw new Error(`HTTP ${res.status} ${res.statusText} — /api/v1/ptt/test`); + } + + return res.json(); +} + /** * GET /api/v1/tx/status * @returns {Promise<{state: string, partner: string|null, autoAnswerEnabled: boolean, keying: boolean}>} diff --git a/web/js/settings.js b/web/js/settings.js index 94c7a1d..92a6e8e 100644 --- a/web/js/settings.js +++ b/web/js/settings.js @@ -7,7 +7,7 @@ * @module settings */ -import { getConfig, getDevices, getOutputDevices, postConfig, getStatus, getSerialPorts, getFrequencies, postFrequencies, postCatRetry, getApiKey, getLogsTail, getRegionDataStatus, postRegionDataRefresh, getRegionDataLookup } from './api.js'; +import { getConfig, getDevices, getOutputDevices, postConfig, getStatus, getSerialPorts, getFrequencies, postFrequencies, postCatRetry, postPttTest, getApiKey, getLogsTail, getRegionDataStatus, postRegionDataRefresh, getRegionDataLookup } from './api.js'; import { resolveUnknownCheckboxDisplay } from './decodeNoiseSuppression.js'; const deviceSelect = /** @type {HTMLSelectElement} */ (document.getElementById('device-select')); @@ -41,6 +41,19 @@ const catRigctldFields = /** @type {HTMLElement} */ (document.getElement const catStatusValue = /** @type {HTMLElement} */ (document.getElementById('cat-status-value')); const catRetryBtn = /** @type {HTMLButtonElement} */ (document.getElementById('cat-retry-btn')); +// PTT Config controls (cat-tx-ptt, FR-057). +const pttMethod = /** @type {HTMLSelectElement} */ (document.getElementById('ptt-method')); +const pttSerialFields = /** @type {HTMLElement} */ (document.getElementById('ptt-serial-fields')); +const pttSerialPort = /** @type {HTMLSelectElement} */ (document.getElementById('ptt-serial-port')); +const pttSerialRefreshBtn = /** @type {HTMLButtonElement} */ (document.getElementById('ptt-serial-refresh')); +const pttSerialLine = /** @type {HTMLSelectElement} */ (document.getElementById('ptt-serial-line')); +const pttTimingFields = /** @type {HTMLElement} */ (document.getElementById('ptt-timing-fields')); +const pttLeadTimeMs = /** @type {HTMLInputElement} */ (document.getElementById('ptt-lead-time-ms')); +const pttTailTimeMs = /** @type {HTMLInputElement} */ (document.getElementById('ptt-tail-time-ms')); +const pttWatchdogTimeoutMs = /** @type {HTMLInputElement} */ (document.getElementById('ptt-watchdog-timeout-ms')); +const pttTestBtn = /** @type {HTMLButtonElement} */ (document.getElementById('ptt-test-btn')); +const pttTestBadge = /** @type {HTMLElement} */ (document.getElementById('ptt-test-badge')); + // General tab controls (callsign, grid, watchdog, retry moved from TX fieldset) const txCallsign = /** @type {HTMLInputElement} */ (document.getElementById('general-callsign')); const txGrid = /** @type {HTMLInputElement} */ (document.getElementById('general-grid')); @@ -157,17 +170,24 @@ if (savedTab && document.getElementById(savedTab)) { let portsLoaded = false; -async function loadSerialPorts() { +/** + * Populates a serial-port +
CAT rig connection @@ -228,6 +229,90 @@

Settings

+ +
+ PTT Config + +
+ + +

+ Changing this requires Save + restarting the application before it takes effect — + ptt.method is read once at startup, not hot-reloaded. +

+
+ + +
+
+ +
+ + +
+

+ Independent of the CAT serial port above — RTS/DTR PTT wiring is frequently on a + different physical interface. +

+
+ +
+ + +
+
+ + +
+
+ + +

Delay after asserting PTT before TX audio playback begins.

+
+ +
+ + +

Delay after TX audio ends before PTT is released.

+
+ +
+ + +

+ Hard failsafe ceiling — PTT is force-released if not released normally within this + time. Default 20000 ms comfortably exceeds one FT8 transmission (12 640 ms). +

+
+
+ +
+ +
+ + Not tested +
+

+ Fires a brief, silent PTT pulse against the currently-running PTT method. Pass means + the assert/release commands were accepted — it does not confirm the + rig visibly keyed. Watch the rig yourself to verify. Unavailable while a real QSO is + transmitting, or when the live method is AudioVox (nothing to test). +

+
+ +
+
+
Advanced Decoder Settings From 585ff940ba8b107fef3eadd9606e232088573cf8 Mon Sep 17 00:00:00 2001 From: Frank0x01 Date: Sun, 12 Jul 2026 20:21:30 +0200 Subject: [PATCH 3/4] fix(cat-tx-ptt): poll for Keying instead of a single racy check in new tests PR #71's CI failed on ubuntu-latest only (Windows/macOS green): the two new "cancelled mid-KeyDownAsync" regression tests asserted sut.Keying immediately after WaitForStateAsync returned. SetStateAndNotify(TxCq/TxAnswer) runs before TransmitAsync sets _keying = true, so the two writes have no atomicity between them - a narrow but genuine race, reliably exposed by CI's Linux scheduling, not present in the production KeyUpAsync fix itself (_keying is already volatile; this was a test-timing assumption, not a visibility bug). Fix is test-only: poll Keying to the expected value with the same 10ms-loop pattern WaitForStateAsync already uses for State, in both QsoCallerServiceTests and QsoAnswererServiceTests. Co-Authored-By: Claude Sonnet 5 --- .../QsoAnswererServiceTests.cs | 23 ++++++++++++++++++- .../QsoCallerServiceTests.cs | 23 ++++++++++++++++++- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/tests/OpenWSFZ.Daemon.Tests/QsoAnswererServiceTests.cs b/tests/OpenWSFZ.Daemon.Tests/QsoAnswererServiceTests.cs index 2490fba..2ce3eb2 100644 --- a/tests/OpenWSFZ.Daemon.Tests/QsoAnswererServiceTests.cs +++ b/tests/OpenWSFZ.Daemon.Tests/QsoAnswererServiceTests.cs @@ -119,6 +119,27 @@ private static async Task WaitForStateAsync( $"Expected state {expected} but was {svc!.State} after {(timeout ?? TimeSpan.FromSeconds(3)).TotalSeconds:F1} s."); } + /// + /// Polls until it reaches + /// or times out. State and Keying are two independent fields set by two + /// separate lines of production code (SetStateAndNotify runs before + /// TransmitAsync's _keying = true) with no atomicity between them — a single + /// immediate check right after returns is a genuine race + /// (observed failing on CI's Linux runner, not locally) rather than a fixed-order guarantee. + /// Poll here the same way already polls State. + /// + private static async Task WaitForKeyingAsync( + QsoAnswererService svc, bool expected, TimeSpan? timeout = null) + { + var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(5)); + while (DateTime.UtcNow < deadline) + { + if (svc.Keying == expected) return; + await Task.Delay(10); + } + svc.Keying.Should().Be(expected, $"keying should reach {expected} within timeout"); + } + // ── Task 6.2: initial state ─────────────────────────────────────────────── [Fact(DisplayName = "FR-050: QsoAnswererService starts in Idle state with null partner")] @@ -356,7 +377,7 @@ public async Task TransmitAsync_CancelledMidKeyDown_StillCallsKeyUpAsync() Send(Make($"CQ {PartnerCall} {PartnerGrid}")); await WaitForStateAsync(_sut!, QsoState.TxAnswer); - _sut!.Keying.Should().BeTrue(); + await WaitForKeyingAsync(_sut!, expected: true, timeout: TimeSpan.FromSeconds(5)); await _ptt.DidNotReceive().KeyUpAsync(Arg.Any()); diff --git a/tests/OpenWSFZ.Daemon.Tests/QsoCallerServiceTests.cs b/tests/OpenWSFZ.Daemon.Tests/QsoCallerServiceTests.cs index 2d38859..6d2f140 100644 --- a/tests/OpenWSFZ.Daemon.Tests/QsoCallerServiceTests.cs +++ b/tests/OpenWSFZ.Daemon.Tests/QsoCallerServiceTests.cs @@ -112,6 +112,27 @@ private static async Task WaitForStateAsync( svc.State.Should().Be(expected, $"state should reach {expected} within timeout"); } + /// + /// Polls until it reaches + /// or times out. State and Keying are two independent fields set by two + /// separate lines of production code (SetStateAndNotify runs before + /// TransmitAsync's _keying = true) with no atomicity between them — a single + /// immediate check right after returns is a genuine race + /// (observed failing on CI's Linux runner, not locally) rather than a fixed-order guarantee. + /// Poll here the same way already polls State. + /// + private static async Task WaitForKeyingAsync( + QsoCallerService svc, bool expected, TimeSpan? timeout = null) + { + var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(5)); + while (DateTime.UtcNow < deadline) + { + if (svc.Keying == expected) return; + await Task.Delay(10); + } + svc.Keying.Should().Be(expected, $"keying should reach {expected} within timeout"); + } + private static DecodeResult Make(string msg, int freqHz = AudioFreqHz) => new(Time: "12:00:00", Snr: -5, Dt: 0.1, FreqHz: freqHz, Message: msg); @@ -316,7 +337,7 @@ public async Task TransmitAsync_CancelledMidKeyDown_StillCallsKeyUpAsync() // Trigger CQ — the service enters TxCq (proxy: TxAnswer) and blocks inside KeyDownAsync. Send(channel, Make("CQ Q2NOISE JO00")); await WaitForStateAsync(sut, QsoState.TxAnswer, timeout: TimeSpan.FromSeconds(5)); - sut.Keying.Should().BeTrue(); + await WaitForKeyingAsync(sut, expected: true, timeout: TimeSpan.FromSeconds(5)); await ptt.DidNotReceive().KeyUpAsync(Arg.Any()); From 09c8afa102b3459e5411e46febe4cf718b072ab2 Mon Sep 17 00:00:00 2001 From: Frank0x01 Date: Sun, 12 Jul 2026 20:49:46 +0200 Subject: [PATCH 4/4] fix(cat-tx-ptt): tag PttTestEndpointTests DisplayNames with FR-057 for Gate G3 CI's Gate G3 (traceability check) failed after the Keying-race fix cleared the ubuntu-latest leg: FR-057 (Settings-page PTT configuration UI, added to REQUIREMENTS.md by this branch) had no test mapped to it. PttTestEndpointTests already covered it in substance but its DisplayNames only carried the "cat-tx-ptt 17.7" free-text prefix, not an "FR-057:" requirement-ID prefix the traceability tool's regex recognises (TestAssemblyScanner.LeadingIds requires the ID immediately before the colon, e.g. "FR-057: cat-tx-ptt 17.7, ..."). Verified locally with the exact command CI runs (dotnet run --project tools/TraceabilityCheck ... --report traceability.md): now reports "PASS: all requirements are mapped and all references are valid." Co-Authored-By: Claude Sonnet 5 --- tests/OpenWSFZ.Web.Tests/PttTestEndpointTests.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/OpenWSFZ.Web.Tests/PttTestEndpointTests.cs b/tests/OpenWSFZ.Web.Tests/PttTestEndpointTests.cs index 80afa5f..7eea91a 100644 --- a/tests/OpenWSFZ.Web.Tests/PttTestEndpointTests.cs +++ b/tests/OpenWSFZ.Web.Tests/PttTestEndpointTests.cs @@ -142,7 +142,7 @@ public PttTestEndpointTests(PttTestFixture fixture) _fixture.PttController.ThrowOnKeyUp = null; } - [Fact(DisplayName = "cat-tx-ptt 17.7: POST /api/v1/ptt/test returns 409 when the running method is AudioVox")] + [Fact(DisplayName = "FR-057: cat-tx-ptt 17.7, POST /api/v1/ptt/test returns 409 when the running method is AudioVox")] public async Task PostPttTest_AudioVoxMethod_Returns409() { await _fixture.ConfigStore.SaveAsync(new AppConfig { Ptt = new PttConfig { Method = "AudioVox" } }); @@ -154,7 +154,7 @@ public async Task PostPttTest_AudioVoxMethod_Returns409() "AudioVox has nothing to test — the controller must never be touched"); } - [Fact(DisplayName = "cat-tx-ptt 17.7: POST /api/v1/ptt/test returns 409 while a real QSO is keying")] + [Fact(DisplayName = "FR-057: cat-tx-ptt 17.7, POST /api/v1/ptt/test returns 409 while a real QSO is keying")] public async Task PostPttTest_WhileKeying_Returns409() { await _fixture.ConfigStore.SaveAsync(new AppConfig { Ptt = new PttConfig { Method = "CatCommand" } }); @@ -171,7 +171,7 @@ public async Task PostPttTest_WhileKeying_Returns409() "singleton — this is the regression guard for the safety finding behind task 17.2"); } - [Fact(DisplayName = "cat-tx-ptt 17.7: POST /api/v1/ptt/test returns pass and pulses PTT when the method is testable")] + [Fact(DisplayName = "FR-057: cat-tx-ptt 17.7, POST /api/v1/ptt/test returns pass and pulses PTT when the method is testable")] public async Task PostPttTest_TestableMethod_ReturnsPassAndPulsesPtt() { await _fixture.ConfigStore.SaveAsync(new AppConfig { Ptt = new PttConfig { Method = "CatCommand" } }); @@ -187,7 +187,7 @@ public async Task PostPttTest_TestableMethod_ReturnsPassAndPulsesPtt() "the endpoint must load a (silent) buffer, assert, then release — in that order"); } - [Fact(DisplayName = "cat-tx-ptt 17.7: POST /api/v1/ptt/test returns error (HTTP 200) when the pulse throws")] + [Fact(DisplayName = "FR-057: cat-tx-ptt 17.7, POST /api/v1/ptt/test returns error (HTTP 200) when the pulse throws")] public async Task PostPttTest_ControllerThrows_ReturnsErrorWithHttp200() { await _fixture.ConfigStore.SaveAsync(new AppConfig { Ptt = new PttConfig { Method = "SerialRtsDtr" } });