diff --git a/README.md b/README.md index d19920c3..1010560a 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ JS8, JT9, JT65, WSPR, and related). ## Status > **Pre-release — source only.** No binaries are distributed yet. -> The current release is **v0.34**. v0.x scope: FT8 receive and transmit, +> The current release is **v0.35**. v0.x scope: FT8 receive and transmit, > CAT rig control, a web UI (loopback or passphrase-protected LAN), > single operator. > v1.0 is reached when the software can complete a confirmed two-way contact diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index d4da36ce..07cd9803 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -1,7 +1,7 @@ # OpenWSFZ — Requirements Document -**Version:** 1.30 -**Date:** 2026-07-11 +**Version:** 1.31 +**Date:** 2026-07-12 **Status:** Draft **Prepared by:** Requirements Analyst (AI-assisted) **For:** Architecture & Planning Team @@ -20,7 +20,7 @@ they prefer (Windows, Linux, macOS) and reach it from a local browser. work prior to a confirmed QSO; v1.0 is reached when the software can make a confirmed two-way contact (RX + CAT control + TX); each user-facing feature shipped increments the minor version. -The current release is v0.34.** The v0.x body of work now covers FT8 +The current release is v0.35.** The v0.x body of work now covers FT8 receive and transmit, CAT rig control, an automated QSO answerer and caller, and a web UI reachable over loopback or a passphrase-protected LAN — for a single operator, source-only distribution. v1.0 is reached @@ -192,6 +192,10 @@ Only one persona is in scope for v0.x. | FR-049 | FT8 TX encode and audio synthesis | The native shim SHALL export `ft8_encode_message(const char* message, uint8_t* tones_out, int tones_capacity)` returning 79 on success or a negative error code on failure. The shim version SHALL be `20260017`. The managed binding (`Ft8LibInterop`) SHALL validate arguments and throw `InvalidOperationException` on negative return codes. The application SHALL synthesise FT8 audio from 79 tone indices using continuous-phase rectangular-pulse frequency modulation at 48000 Hz, 6.25 baud symbol rate, 6.25 Hz tone spacing — producing exactly 606720 samples (79 × 7680) per transmission with amplitude normalised to ±0.5. TX audio SHALL be played through the configured audio output device via an `IPttController` abstraction (`AudioOnlyPttController` using WASAPI on Windows). | Must Have | | FR-050 | FT8 QSO answerer state machine | The daemon SHALL implement a `QsoAnswererService` (`IHostedService`) that monitors decoded FT8 messages for CQ calls and drives a state machine: `Idle → TxAnswer → WaitReport → TxReport → WaitRr73 → Tx73 → QsoComplete → Idle`. The answerer SHALL be configured via a `tx` block in `AppConfig` with fields `callsign`, `grid`, `retryCount` (≥ 1, default 3), and `watchdogMinutes` (≥ 1, default 4). A watchdog timer SHALL abort the QSO to `Idle` if `watchdogMinutes` elapse without a state advance. The retry counter SHALL re-transmit the last message up to `retryCount` times before aborting on silence. `POST /api/v1/tx/abort` SHALL cancel any active TX session and return to `Idle`. `GET /api/v1/tx/status` SHALL return `{ "state": string, "partner": string\|null }`. State transitions SHALL be broadcast to WebSocket clients as `{ "type": "txState", "state": string, "partner": string\|null }` events. | Must Have | | FR-051 | ADIF QSO log | On every completed QSO (`QsoComplete` state), the daemon SHALL append one record to `ADIF.log` in the same directory as the ALL.TXT decode log (`decodeLog.path`). Each record SHALL use ADIF 3.x tagged-field format `value` terminated by ``. Mandatory fields: `CALL` (partner callsign), `GRIDSQUARE` (partner grid, when known), `RST_SENT`, `RST_RCVD`, `QSO_DATE` (YYYYMMDD UTC), `TIME_ON` (HHmm UTC), `QSO_DATE_OFF`, `TIME_OFF`, `OPERATOR` (`tx.callsign`), `MY_GRIDSQUARE` (`tx.grid`), `MODE` (`FT8`). When `decodeLog.dialFrequencyMHz` is non-zero, `FREQ` (MHz) and `BAND` (ITU band name derived from frequency) SHALL also be written; both SHALL be omitted when the frequency is 0.0. Write failures SHALL be logged at Warning and SHALL NOT affect the QSO state or any other pipeline component. No record is written on watchdog or operator abort. | Must Have | +| FR-052 | External reporting configuration schema | `AppConfig` SHALL gain an `externalReporting` object with `enabled` (bool, default `false`), `targets` (array of `{ name, host, port, enabled }`, default `[]`), and `honourInboundCommands` (bool, default `false`). A missing `externalReporting` key SHALL deserialise to these fully-inert defaults, identical to today's behaviour. `GET`/`POST /api/v1/config` SHALL round-trip the object. `POST /api/v1/config` SHALL reject (HTTP 400, no partial persistence) any target entry whose `port` falls outside 1–65535. | Must Have | +| 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 | ### 4.2 User Journeys @@ -241,7 +245,8 @@ Only one persona is in scope for v0.x. | Host OS filesystem | Native API | Both | Read the configuration file at startup; write it on Save. Serve frontend assets read-only. | | Web browser on the same machine | HTTP + WebSocket | Both | Only client of the application in v0.x. Loopback bind only. | | **Future** rig control (v1.0) | Serial / USB / network CAT | Both | Required for v1.0 (confirmed QSO); architecture must keep this door open. | -| **Future** PSK Reporter / DX cluster | HTTPS / telnet | Outbound | Not in v0.x, but typical for HAM tools and worth noting for the architect. | +| GridTracker2 / WSJT-X UDP protocol | UDP (proprietary, WSJT-X-defined) | Both | FR-052–FR-055. Post-v1.0 addition; lets GridTracker2 (and other WSJT-X-protocol clients: JTAlert, N1MM+, Log4OM, …) see spots/map and log completed QSOs. Inert until the operator opts in; unauthenticated by design, matching WSJT-X's own trust model. | +| **Future** PSK Reporter / DX cluster | HTTPS / telnet | Outbound | Not in v0.x, but typical for HAM tools and worth noting for the architect. PSK Reporter itself remains future work — distinct from the GridTracker2/WSJT-X UDP row above. | | **Future** logbook formats (ADIF, Cabrillo) | File I/O | Outbound | Not in v0.x; deferred with decode persistence. | ### 4.4 Data Requirements @@ -451,3 +456,4 @@ data in v0.x: | 1.28 | 2026-07-09 | QA (AI-assisted) | Added the **qso-confirmation** capability (in-memory worked-before index built from `ADIF.log`, resolving each decoded callsign's DXCC entity/continent match against logged QSO history; three new readonly indicator columns — P/C/R — in `#decodes-table`, fully advisory and never affecting decode acceptance). Implemented by `adif-qso-confirmation`, archived 2026-07-09. Backfilled the archived proposal's missing **User-facing:** declaration (predates Gate G9 being consistently enforced at merge time) and bumped **VERSION** to **v0.33** alongside 1.29 below, since both this and `f-006-region-lookup-country-file-refresh` were archived together in one commit without either bump having been applied at the time. | | 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). | diff --git a/VERSION b/VERSION index 6502ad87..b76af45b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.34 \ No newline at end of file +0.35 \ No newline at end of file diff --git a/dev-tasks/2026-07-12-gridtracker-udp-reporting-linux-ci-failure.md b/dev-tasks/2026-07-12-gridtracker-udp-reporting-linux-ci-failure.md new file mode 100644 index 00000000..611e364c --- /dev/null +++ b/dev-tasks/2026-07-12-gridtracker-udp-reporting-linux-ci-failure.md @@ -0,0 +1,135 @@ +# Developer Handoff — gridtracker-udp-reporting: Linux CI failure blocking PR #70 + +**Date:** 2026-07-12 +**Prepared by:** QA Engineer +**Status:** Required fix, blocking merge of `feat/gridtracker-udp-reporting` (PR #70) +**Scope:** One test failure, `ubuntu-latest` only, both CI runs on the PR; Windows and macOS are green. + +--- + +## 1. Context + +While preparing to merge PR #70 (after the 2026-07-12 review-fixes pass — D-014 + the absolute +exclusion — had already been reviewed and approved on the strength of a **local, Windows-only** +`dotnet test` run), I checked the PR's own CI status before merging, per standard practice. It is +not green: + +- `Build & Test (windows-latest)` — pass +- `Build & Test (macos-latest)` — pass +- `Build & Test (ubuntu-latest)` — **fail**, both duplicate runs, same test +- `Gate G9 — Version governance` — was also failing (unrelated doc-drift from task 9's VERSION + bump; fixed directly on the branch in commit `fe081c7`, docs-only, no action needed from you) + +This document covers the one remaining item: the Linux test failure. + +## 2. The failure + +`OutboundToPrimaryTarget_UsesSharedInboundPort` (`ExternalReportingServiceTests.cs:315-355`, one +of the two D-014 regression tests added in `c49b81f`) times out on `ubuntu-latest`: + +``` +D-014 AC-2: outbound sends to the primary target originate from the shared inbound port [FAIL] +System.OperationCanceledException : The operation was canceled. + at ...UdpClient.... + at ExternalReportingServiceTests.OutboundToPrimaryTarget_UsesSharedInboundPort() line 344 +``` + +Line 344 is `var result = await fakePeer.ReceiveAsync(recvCts.Token);` — the 3-second receive +window elapses with `fakePeer` never seeing any datagram at all (not even a Heartbeat/Status burst, +which the test comment notes fires almost immediately). It passes reliably on Windows and macOS. + +## 3. Likely root cause — please confirm/investigate, don't just silence the test + +The test's own doc comment (and `design.md` Decision 7) already documents an empirically-found +**Windows** platform quirk: when two `UdpClient`s share one local port via `SO_REUSEADDR`, Windows +delivers an incoming unicast datagram to only the *first-bound* socket. The test was written +assuming this generalises — `fakePeer` binds first (line 332, before the daemon starts), so it +"should" win delivery of the daemon's own outbound send to that shared port. + +Linux's `SO_REUSEADDR` semantics for UDP are documented (and generally observed) to differ: rather +than first-bind-wins, unicast delivery to a `SO_REUSEADDR`-shared UDP port on Linux has historically +gone to the **last-bound** socket (kernel/version-dependent; this is not the `SO_REUSEPORT` +load-balanced-hash case, which needs that separate socket option and behaves differently again). In +this test, the daemon's own `_inboundClient` binds *second* (inside `Reconcile`, called from +`StartAsync`, after `fakePeer` is already up). If Linux's last-bind-wins holds here, the daemon's +own outbound send — addressed to `127.0.0.1:port`, i.e. to itself — would be received by its own +`_inboundClient`/`InboundLoopAsync`, not by `fakePeer`, which would explain the observed timeout +exactly: `fakePeer` gets nothing, ever, in the 3-second window. + +**This may not be purely a test artifact.** If the diagnosis above is right, it implies something +worth confirming for real Linux deployments too: in the realistic scenario this whole feature +targets (GridTracker2 already running on the same host, bound first to the shared port; OpenWSFZ +starts second and shares the port via `ReuseAddress` per D-014 Part A) — on Linux, OpenWSFZ's own +outbound sends to that same loopback port could conceivably be captured by OpenWSFZ's *own* +listener rather than ever reaching GridTracker2's socket, the mirror-image problem to the Windows +finding already documented (which was about *inbound* delivery, not outbound-looped-back delivery). +Please investigate on an actual Linux box (or reason carefully from kernel behaviour if one isn't +available) whether this is real, and if so, add a Decision 7 addendum for Linux symmetric to the +existing Windows one — do not just tweak the test until it's green without settling whether there's +a genuine cross-platform risk here. + +## 4. Required fix — make the test assertion deterministic, not a race + +Regardless of the investigation above, the test itself needs to stop depending on unspecified +OS/kernel arbitration behaviour for which of two same-port sockets receives a given unicast +datagram — that's inherently unportable. Recommended approach: assert the *sending* socket's own +local port directly, rather than inferring it by racing a second socket to receive the packet over +the wire. + +`ExternalReportingServiceTests.cs` already has a reflection helper for exactly this class of check +(`GetInboundClient`, line 229). Something like: + +```csharp +[Fact(DisplayName = "D-014 AC-2: outbound sends to the primary target originate from the shared inbound port")] +public async Task OutboundToPrimaryTarget_UsesSharedInboundPort() +{ + // ... existing setup, start the SUT ... + + var inboundClient = GetInboundClient(sut); + inboundClient.Should().NotBeNull(); + ((IPEndPoint)inboundClient!.Client.LocalEndPoint!).Port.Should().Be(port, + "the primary target's outbound sends must originate from the shared bound inbound port"); + + // If you still want a wire-level proof for the non-contended case (no competing peer bound to + // the port), that's a legitimate separate/second assertion — just don't have anything else + // sharing the exact same port while making it, or you're back to racing OS arbitration. +} +``` + +This sidesteps the platform-dependent race entirely and will pass deterministically on all three +CI platforms. If you also want to keep an end-to-end wire-level proof, do it in a configuration +where nothing else contends for the port (i.e. without `CreateFakePeer` also bound there), so there +is exactly one socket for the OS to deliver to. + +## 5. Acceptance Criteria + +- [x] Root cause of the Linux failure understood and documented — concluded **genuine Linux + delivery-ambiguity risk**, not a pure test artifact: reasoned from documented Linux + `SO_REUSEADDR` UDP semantics (historically last-bind-wins, vs. Windows' first-bind-wins) + that OpenWSFZ's own outbound send to a same-host peer on the shared port could be delivered + back to OpenWSFZ's own `_inboundClient` instead of reaching the peer, since OpenWSFZ binds + second in the realistic startup order. Not confirmed against a real two-process Linux run + (no live GridTracker2/second peer available in this environment) — logged as an open, + unconfirmed-on-real-hardware risk in `design.md` Decision 7's new "Linux addendum," carried + forward alongside the existing tasks 2.6/10.3 no-live-GridTracker2 caveat rather than fixed + in this pass. +- [x] `OutboundToPrimaryTarget_UsesSharedInboundPort` (or its replacement) passes reliably on all + three CI platforms — verified via `gh pr checks 70` after pushing: all three `Build & Test` + jobs pass on both duplicate CI runs, plus Gate G9; no pending/failing checks. +- [x] No other existing test regressed — full `OpenWSFZ.Daemon.Tests` suite re-run locally: + 391/391 passing (same count as before this fix). +- [x] `tasks.md` 10.1's test-count figure updated if the test count changes — not needed, count + unchanged (391); added a new task 10.4 instead documenting this fix's own investigation and + verification. +- [x] PR #70's CI is fully green (all `Build & Test` jobs + Gate G9) before requesting re-review — + confirmed, `gh pr checks 70` exits 0. + +## 6. References + +- Failing job: https://github.com/frank001/OpenWSFZ/actions/runs/29188067906/job/86637683759 + (and the duplicate run, job `86637681293`) +- `tests/OpenWSFZ.Daemon.Tests/ExternalReportingServiceTests.cs:236-260` — the existing Windows + finding's doc comment, immediately above the two D-014 tests. +- `openspec/changes/gridtracker-udp-reporting/design.md` — Decision 7, "Verification status" note. +- `dev-tasks/2026-07-12-gridtracker-udp-reporting-review-fixes.md` §3.3/§3.4 — where this test was + originally specified. diff --git a/dev-tasks/2026-07-12-gridtracker-udp-reporting-review-fixes.md b/dev-tasks/2026-07-12-gridtracker-udp-reporting-review-fixes.md new file mode 100644 index 00000000..acded4db --- /dev/null +++ b/dev-tasks/2026-07-12-gridtracker-udp-reporting-review-fixes.md @@ -0,0 +1,388 @@ +# Developer Handoff — gridtracker-udp-reporting: required pre-merge fixes + +**Date:** 2026-07-12 +**Prepared by:** QA Engineer +**Status:** Required changes, blocking merge of `feat/gridtracker-udp-reporting` +**Scope:** Two items — one empirically-proven defect found during code review (D-014), and one +absolute, non-negotiable requirement requested directly by the Captain (no defect ID; treat as +Must Have on the same footing as an FR). + +--- + +## 1. Context + +This document is QA's "return with required changes" response to the pre-merge review of +`feat/gridtracker-udp-reporting`. The branch is **not yet merged** — these are amendments to an +in-flight change, not a post-merge defect fix, so **no new branch, no new OpenSpec change +proposal**. Continue on the existing branch and amend the existing change's own artifacts in +place (§5). + +Two items, unrelated to each other, both must land before this branch merges: + +1. **D-014** — the inbound UDP listener (Halt Tx / Reply / Free Text) silently fails to bind + whenever the configured target's port is already owned by a real peer — which is the *normal* + real-world case (GridTracker2 is the thing usually already running). Empirically proven + against the actual `ExternalReportingService` code during review, not theoretical. +2. **Absolute exclusion of synthetic/unknown-region traffic from external output** — the Captain + has directed, in the plainest possible terms, that **no** decode, status update, or logged QSO + whose origin is an R&R-study synthetic signal or an unresolved (Unknown-region) callsign may + ever reach an external program via this feature. **No exceptions** — not gated by, not + overridable through, any operator setting. This is a data-integrity/privacy floor, not a + preference: R&R synthetic signals (Q-prefix test callsigns, per the project's NFR-021 + synthetic-callsign convention) are not real amateur-radio traffic, and letting them leak into + GridTracker2 — which itself may relay spots onward to a real map, a real logbook, or another + real-world tool — would contaminate systems this project has no authority over. Unknown-region + decodes are unverified/likely-noisy and get the same treatment for the same reason: nothing + this application cannot vouch for should leave the machine on this channel. + +--- + +## 2. Branch + +Continue on **`feat/gridtracker-udp-reporting`** (the existing, unmerged feature branch). Do not +create a new branch for this. + +--- + +## 3. D-014 — Inbound listener fails to bind when the target port is already owned by a peer + +### 3.1 The defect, proven + +`ExternalReportingService.Reconcile` (`src/OpenWSFZ.Daemon/ExternalReportingService.cs:254-275`) +binds the single inbound listener to `desiredEnabled[0].Port` — i.e. **the same port number the +target is configured on** — via a plain `new UdpClient(new IPEndPoint(IPAddress.Any, +desiredInboundPort))`, with no `SO_REUSEADDR`/`ExclusiveAddressUse=false` set: + +```csharp +// src/OpenWSFZ.Daemon/ExternalReportingService.cs:261-274 (current) +if (desiredInboundPort > 0) +{ + try + { + _inboundClient = new UdpClient(new IPEndPoint(IPAddress.Any, desiredInboundPort)); + _inboundBoundPort = desiredInboundPort; + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "external-reporting: failed to bind inbound listener on port {Port}.", + desiredInboundPort); + } +} +``` + +During review I wrote a temporary probe test (not committed) against the real +`ExternalReportingService`: bind a fake "GridTracker2" `UdpClient` to a free port **first** (the +realistic startup order — the operator's mapping tool is usually already running), then start +`ExternalReportingService` configured with a target on that same port, then send a well-formed +Halt Tx datagram to that port. **Result: `IQsoController.AbortAsync` was never called.** The bind +throws (`SocketException`: *"Only one usage of each socket address... is normally permitted"*), +is swallowed by the `catch` above, and logged only at **Warning**. `_inboundClient` stays `null`; +`InboundLoopAsync` spins harmlessly forever. There is no operator-visible indication that the one +message this change treats as safety-critical and ungated (`Requirement: Inbound Halt Tx always +honoured`, `specs/external-reporting/spec.md`) is completely unreachable. + +The existing test suite does not catch this because the one test that puts two sockets on the +same port (`TwoEnabledTargets_BothReceiveDecode`, `ExternalReportingServiceTests.cs:91-136`) only +asserts outbound delivery — it never exercises inbound receipt on that same, silently-unbound +port. + +### 3.2 Required fix — Part A: let the inbound bind coexist with a peer already on that port + +Set `ReuseAddress` on the socket before binding, mirroring the real WSJT-X reference +implementation's own `ShareAddress | ReuseAddressHint` bind option — the exact mechanism +`design.md`'s own rationale ("the app listens on the same port it's configured to send to") is +relying on without actually implementing: + +```csharp +if (desiredInboundPort > 0) +{ + try + { + var client = new UdpClient(AddressFamily.InterNetwork); + client.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); + client.Client.Bind(new IPEndPoint(IPAddress.Any, desiredInboundPort)); + _inboundClient = client; + _inboundBoundPort = desiredInboundPort; + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "external-reporting: failed to bind inbound listener on port {Port}.", + desiredInboundPort); + } +} +``` + +(`AddressFamily` is in `System.Net.Sockets`, already `using`d in this file.) + +### 3.3 Required fix — Part B: route the primary target's outbound sends through the same bound socket + +Fixing the bind alone is necessary but may not be sufficient. `design.md`'s own stated rationale +is that GridTracker2 "replies from the same port it received on" — i.e. it addresses its Reply/ +Halt Tx datagrams back to whatever **source port** it last observed traffic from us on. Today, +outbound sends for every target (including the first/primary one, whose port the inbound +listener binds to) go out through a **separate, unbound, ephemeral-port** `UdpClient` +(`Reconcile`, `_outboundClients.Add((target, new UdpClient()))`, +`ExternalReportingService.cs:244`). If GridTracker2 genuinely uses reply-to-sender semantics, its +replies go to that ephemeral port — not to the fixed port our inbound listener binds to — and are +lost regardless of Part A. + +**Required:** for the target whose port equals the bound inbound port (by construction, this is +always `desiredEnabled[0]`, the primary target), send outbound datagrams **through the same +`_inboundClient` socket** rather than through a separate ephemeral `UdpClient`. Any additional +targets (index 1+, a second companion program on a different port) keep their own dedicated +outbound-only `UdpClient`s exactly as today — this fix only applies to the one target that shares +a port number with the inbound listener. + +Suggested shape (adjust to fit the existing `_outboundClients` bookkeeping — the exact structure +is your call, but the send-through-the-bound-socket behaviour for the primary target is not +optional): + +- When building `desiredEnabled`, treat index `0` specially: do not open a new ephemeral + `UdpClient` for it; instead, record that sends to it should go through `_inboundClient` + (`client.Client.SendTo(...)` or `UdpClient.Send(datagram, datagram.Length, target.Host, + target.Port)` called on the shared instance). +- `SendToAllEnabledAsync` needs to resolve, per target, which socket to send from (shared + `_inboundClient` for the primary target, dedicated `UdpClient` for the rest). +- If `_inboundClient` failed to bind (Part A's own catch block fired for some other reason), fall + back to the current ephemeral-socket behaviour for the primary target too, so a bind failure + degrades to "outbound-only, like before" rather than losing outbound delivery entirely. + +**Verification:** this is the one piece of D-014 I could not confirm against a live GridTracker2 +in this environment (task 10.3 was already flagged as skipped for the same reason before my +review). Implement it per the design rationale already committed to `design.md`, add the +round-trip test in §3.4 below to prove the *mechanism* works against a real loopback peer, and +flag task 10.3 as the place a live GridTracker2 session should specifically re-confirm reply +routing before this is fully trusted operationally — same spirit as the existing task 2.6/10.3 +caveats, not a new category of risk. + +### 3.4 Tests — D-014 + +Add to `tests/OpenWSFZ.Daemon.Tests/ExternalReportingServiceTests.cs`: + +1. **`InboundBind_SucceedsWhenTargetPortAlreadyBoundByPeer`** — bind a fake peer `UdpClient` + to a free port *before* constructing/starting the SUT (mirrors my review probe), configure a + target on that same port, start the SUT, send a well-formed Halt Tx to that port from a third, + independent sender socket, and assert `IQsoController.AbortAsync` **was** called. This is the + direct regression test for the bind fix (§3.2). +2. **`OutboundToPrimaryTarget_UsesSharedInboundPort_ReplyRoundTrips`** — start the SUT with one + target; from a fake-peer `UdpClient` bound to that target's port, receive an outbound Decode + or Heartbeat datagram sent by the SUT and record its `RemoteEndPoint` (the *source* port the + SUT actually sent from); assert that source port equals the SUT's configured target port (i.e. + the primary target's sends genuinely come from the shared bound socket, not an ephemeral one). + Then, from the fake peer, send a Halt Tx **back to that same port** and assert + `IQsoController.AbortAsync` is called — proving the full send-then-reply loop a real + GridTracker2 would exercise. + +--- + +## 4. Absolute exclusion of synthetic/unknown-region traffic — no exceptions + +### 4.1 What "no exceptions" means precisely + +Today, `ExternalReportingService`'s outbound path receives whatever the decode pump hands it on +`externalReportingChannel`, which is `visibleResults` — the output of +`DecodeNoiseSuppressionFilter.Apply(results, configStore.Current.DecodeNoiseSuppression, +callsignRegionStore)` (`Program.cs:589-601`). **This is an operator-configurable filter** +(`DecodeNoiseSuppressionConfig.SuppressUnknownRegion`/`SuppressSynthetic`, +`src/OpenWSFZ.Abstractions/DecodeNoiseSuppressionConfig.cs:45,54`, default +`SuppressSynthetic=true` but `SuppressUnknownRegion` defaults from region-table presence and +**either can be turned off by the operator** on the Region-data settings tab). If the operator +turns both off, `Apply` returns `results` unchanged and Unknown-region/synthetic decodes flow +through to `externalReportingChannel` identically to every other consumer. + +**That is not acceptable for external output, regardless of what the operator has chosen for the +decode panel or the QSO controllers.** The fix must be a **second, unconditional filter inside +`ExternalReportingService` itself** — not merely "default the suppression settings on," and not +exposed as any new Settings-tab checkbox. It must hold even when +`DecodeNoiseSuppressionConfig.SuppressUnknownRegion == false` and `SuppressSynthetic == false`. +Putting the guarantee inside the class that actually emits UDP traffic, rather than relying on +upstream channel wiring staying a particular way, means it survives future refactors of +`Program.cs`'s decode pump too — it does not depend on anyone remembering to keep filtering the +right channel. + +Determination of "Unknown region" / "synthetic" for a `DecodeResult` is already fully solved +elsewhere and must be reused, not reinvented: `DecodeResult.Region` (`RegionInfo?`, +`src/OpenWSFZ.Abstractions/DecodeResult.cs:43`) is pre-resolved by the decoder pipeline. +`Region is null` = unknown region (a lookup miss); `Region.Synthetic == true` = R&R-study +synthetic entry (the dedicated `Q`-prefix catalog row, +`src/OpenWSFZ.Daemon/CallsignRegionDefaults.cs:84`). This is the exact same test +`DecodeNoiseSuppressionFilter` already uses (`src/OpenWSFZ.Daemon/DecodeNoiseSuppressionFilter.cs:50-63`) +— reuse the semantics, do not duplicate/re-derive them from the callsign string. + +### 4.2 Required fix — Tier 1: outbound Decode datagrams (the literal, explicit ask) + +In `ExternalReportingService.DecodeLoopAsync` (`ExternalReportingService.cs:286-312`), skip any +result that is unknown-region or synthetic **unconditionally**, before encoding: + +```csharp +foreach (var r in batch.Results) +{ + // Absolute guarantee — NOT gated by DecodeNoiseSuppressionConfig, NOT configurable, + // NOT an opt-out. R&R-synthetic and unknown-region decodes SHALL NEVER be broadcast to + // any external program, regardless of the operator's decode-panel suppression settings. + if (r.Region is null || r.Region.Synthetic) + { + _logger.LogDebug( + "external-reporting: suppressed outbound Decode for '{Message}' — {Reason}.", + r.Message, r.Region is null ? "unknown region" : "synthetic (R&R study)"); + continue; + } + + var fields = new WsjtxDatagram.DecodeFields(/* unchanged */); + await SendToAllEnabledAsync(WsjtxDatagram.EncodeDecode(AppId, fields)).ConfigureAwait(false); +} +``` + +The `Clear` datagram at the top of the loop iteration is **unaffected** — it must still fire every +cycle regardless of how many (or how few) decodes survive the filter, per the existing "Clear +sent on new decode cycle boundary" requirement. + +### 4.3 Required fix — Tier 2: outbound Status and QSOLogged (same principle, other message types that can name a callsign) + +Read literally, the Captain's instruction names "decodes." Read for intent — **no external +program may ever learn of a synthetic or unresolved callsign through this feature, full stop** — +two other outbound message types can leak one: + +- **Status** (`BuildStatusFields`, `ExternalReportingService.cs:355-383`): while a QSO with a + synthetic partner is active (only possible during an R&R study session, but real once one is + running), `DxCall`/`DxGrid` carries that partner's callsign in **real time**, visible on + GridTracker2's map before the QSO even completes. +- **QSOLogged** (`NotifyQsoLogged`, `ExternalReportingService.cs:395-412`): a completed QSO with + a synthetic or unresolved partner would otherwise be reported to GridTracker2 as a real logged + contact. + +Neither `QsoRecord` (`src/OpenWSFZ.Abstractions/QsoRecord.cs`) nor the live `IQsoController.Partner` +string carries a pre-resolved `Region` — both are bare callsign strings. Add +`ICallsignRegionStore?` as a new optional constructor parameter to `ExternalReportingService` +(same pattern as the existing optional `ICatState?` parameter), wire it in `Program.cs`'s DI +factory (`services.AddSingleton(callsignRegionStore)` is already registered, +`Program.cs:395` — just add `sp.GetService()` to the `ExternalReportingService` +factory call, `Program.cs:449-454`), and add one shared helper: + +```csharp +private bool IsSuppressedCallsign(string? callsign) +{ + if (string.IsNullOrWhiteSpace(callsign)) return false; // nothing to suppress + var region = _regionStore?.TryGetRegion(callsign); + return region is null || region.Synthetic; +} +``` + +Apply it: + +- In `BuildStatusFields`: if `IsSuppressedCallsign(partner)`, send `DxCall = ""`, `DxGrid = ""` + instead of the real values — the rest of Status (frequency, TX/RX state, decoding-enabled) + still flows normally; only the fake callsign is withheld. Do **not** suppress the whole Status + datagram — it has its own independent "at least once per heartbeat interval" requirement that + must keep being met. +- In `NotifyQsoLogged`: if `IsSuppressedCallsign(record.PartnerCallsign)`, log at Information + ("suppressed outbound QSOLogged for synthetic/unknown-region partner") and `return` before + building/sending the datagram — mirroring the existing "no record on watchdog/operator abort" + early-return pattern already in this method. + +If, on reflection, the Captain considers Tier 2 out of scope for this pass, that is a call for +the Captain to make explicitly — do not silently drop it; flag it back to QA/the Captain rather +than deciding unilaterally either way. + +### 4.4 Tests + +Add to `tests/OpenWSFZ.Daemon.Tests/ExternalReportingServiceTests.cs`: + +1. **`Decode_UnknownRegionAndSynthetic_NeverSentEvenWithSuppressionOff`** — configure + `DecodeNoiseSuppressionConfig` with **both** `SuppressUnknownRegion: false` and + `SuppressSynthetic: false` (the exact condition that currently lets them through), submit a + batch containing one normal decode, one `Region: null` decode, and one `Region: { Synthetic: + true }` decode; assert the fake target receives Clear + exactly **one** Decode datagram (the + normal one only). +2. **`Decode_AllResultsSuppressed_StillSendsClear`** — a batch containing only unknown/synthetic + entries still produces a Clear datagram, proving Clear isn't accidentally gated by the new + filter. +3. **`Status_SyntheticPartner_DxCallAndGridBlanked`** — drive an active QSO (via the substitute + `IQsoController`) reporting `Partner` as a known synthetic callsign; assert the next Status + datagram's `DxCall`/`DxGrid` fields are empty, while other Status fields (frequency, + `Transmitting`, `Decoding`) are populated normally. +4. **`Status_NormalPartner_DxCallPopulated`** — regression check: a real, resolvable partner + callsign still appears in `DxCall` as before. +5. **`NotifyQsoLogged_SyntheticOrUnknownPartner_NeverSent`** — call `NotifyQsoLogged` with a + `QsoRecord` whose `PartnerCallsign` resolves to synthetic (and, separately, to no region at + all), and assert no QSOLogged datagram reaches the fake target either time. +6. **`NotifyQsoLogged_NormalPartner_StillSent`** — regression check for the existing + `NotifyQsoLogged sends a QSOLogged datagram` test (`ExternalReportingServiceTests.cs:184`) — + confirm it still passes unmodified. + +--- + +## 5. Documentation — amend the in-flight change's own artifacts (no new change proposal) + +Since `gridtracker-udp-reporting` has not merged/archived, amend it in place: + +- **`openspec/changes/gridtracker-udp-reporting/specs/external-reporting/spec.md`** — amend the + "Outbound Decode message" requirement to state the unconditional exclusion explicitly, and add + a new scenario ("Unknown-region and synthetic decodes are never broadcast, even with + suppression disabled"). Amend "Outbound Status message" and "Outbound QSOLogged message" + similarly if Tier 2 is implemented (§4.3). +- **`openspec/changes/gridtracker-udp-reporting/design.md`** — add a Decision documenting why + this exclusion is hard-coded and non-configurable (data-integrity: R&R synthetic signals must + never reach a real-world tool/network via this channel), and note the D-014 socket-sharing + rationale/verification status alongside the existing task 2.6/10.3 caveats. +- **`openspec/changes/gridtracker-udp-reporting/tasks.md`** — add new (unchecked, then checked + off as completed) task items for both D-014 and the exclusion requirement, with their own test + sub-items. Task 10.1's "1090/1090 passing" and 10.2's "53/53" figures will both need updating + once the new tests land — do not leave stale counts in a task already marked `[x]`. +- **`REQUIREMENTS.md`** — append the unconditional-exclusion clause to FR-053 (outbound + broadcaster) and, if Tier 2 lands, note it applies to Status/QSOLogged too. Add a revision- + history row per the existing 1.31 row's pattern once this lands, or fold it into that row if it + hasn't been committed elsewhere yet — check current `main` state before deciding which. + +--- + +## 6. Acceptance Criteria + +QA will verify all of the following before approving merge: + +- [ ] **AC-1 (D-014):** With a fake peer already bound to the configured target's port before + the daemon starts, `ExternalReportingService`'s inbound listener still binds successfully and + a Halt Tx sent to that port is received and acted on. +- [ ] **AC-2 (D-014):** Outbound datagrams to the primary target are sent from the same local + port the inbound listener is bound to (verified via `RemoteEndPoint` on a real loopback fake + peer), and a reply sent to that port reaches the listener. +- [ ] **AC-3 (exclusion, Tier 1):** With `DecodeNoiseSuppressionConfig.SuppressUnknownRegion` and + `SuppressSynthetic` both `false`, no unknown-region or synthetic `DecodeResult` ever produces an + outbound Decode datagram to any target. Clear datagrams are unaffected. +- [ ] **AC-4 (exclusion, Tier 2 — if implemented):** No outbound Status datagram ever names a + synthetic/unknown-region partner in `DxCall`/`DxGrid`; no outbound QSOLogged datagram is ever + sent for a synthetic/unknown-region partner. +- [ ] **AC-5:** The exclusion in AC-3/AC-4 is not exposed as, or controllable via, any new + Settings-page control or config field. It is unconditional. +- [ ] **AC-6:** No regression in any existing `ExternalReportingServiceTests.cs`, + `WsjtxDatagramTests.cs`, `QsoAnswererServiceExternalReplyTests.cs`, or + `DecodeNoiseSuppressionFilterTests.cs` test. +- [ ] **AC-7:** `openspec validate --strict --all` still passes. +- [ ] **AC-8:** Full test suite green; updated pass counts recorded in `tasks.md` task 10.1/10.2 + match the actual run, not the stale pre-fix figures. +- [ ] **AC-9:** `dotnet build` — zero errors, zero warnings. + +--- + +## 7. References + +- `src/OpenWSFZ.Daemon/ExternalReportingService.cs` — `Reconcile` (bind logic, §3), `DecodeLoopAsync` + (§4.2), `BuildStatusFields`/`NotifyQsoLogged` (§4.3). +- `src/OpenWSFZ.Daemon/Program.cs:589-601` — decode-pump fan-out; confirms all three consumer + channels currently receive the same operator-filterable `visibleResults`. +- `src/OpenWSFZ.Daemon/DecodeNoiseSuppressionFilter.cs:50-63` — the existing, operator-toggleable + suppression this task's hard filter must NOT be confused with or rely on. +- `src/OpenWSFZ.Abstractions/DecodeNoiseSuppressionConfig.cs`, `DecodeResult.cs`, + `CallsignRegionEntry.cs` (`RegionInfo.Synthetic`), `QsoRecord.cs`. +- `src/OpenWSFZ.Abstractions/ICallsignRegionStore.cs:34` (`TryGetRegion`), + `src/OpenWSFZ.Daemon/CallsignRegionStore.cs`, `CallsignRegionDefaults.cs:84` (the dedicated + `Q`-prefix synthetic catalog entry). +- `openspec/changes/gridtracker-udp-reporting/design.md` — original "GridTracker2 replies from + the same port it received on" rationale that §3.3 implements properly. +- `openspec/changes/gridtracker-udp-reporting/specs/external-reporting/spec.md` — requirements + to amend per §5. +- Privacy/GDPR callsign policy (NFR-021) — the project-wide convention that Q-prefix synthetic + callsigns exist precisely so R&R test traffic is distinguishable from real traffic; this task + is that same principle applied to a new egress point this application didn't have before. diff --git a/dev-tasks/screenshots/gridtracker-after-01-external-programs-tab.png b/dev-tasks/screenshots/gridtracker-after-01-external-programs-tab.png new file mode 100644 index 00000000..4f2e61c5 Binary files /dev/null and b/dev-tasks/screenshots/gridtracker-after-01-external-programs-tab.png differ diff --git a/dev-tasks/screenshots/gridtracker-after-02-tab-bar.png b/dev-tasks/screenshots/gridtracker-after-02-tab-bar.png new file mode 100644 index 00000000..8529398d Binary files /dev/null and b/dev-tasks/screenshots/gridtracker-after-02-tab-bar.png differ diff --git a/dev-tasks/screenshots/gridtracker-before-01-tab-bar.png b/dev-tasks/screenshots/gridtracker-before-01-tab-bar.png new file mode 100644 index 00000000..e594058c Binary files /dev/null and b/dev-tasks/screenshots/gridtracker-before-01-tab-bar.png differ diff --git a/openspec/changes/gridtracker-udp-reporting/.openspec.yaml b/openspec/changes/gridtracker-udp-reporting/.openspec.yaml new file mode 100644 index 00000000..68b71747 --- /dev/null +++ b/openspec/changes/gridtracker-udp-reporting/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-11 diff --git a/openspec/changes/gridtracker-udp-reporting/design.md b/openspec/changes/gridtracker-udp-reporting/design.md new file mode 100644 index 00000000..e7dde3f6 --- /dev/null +++ b/openspec/changes/gridtracker-udp-reporting/design.md @@ -0,0 +1,313 @@ +## Context + +WSJT-X's UDP network protocol (`NetworkMessage.hpp` in the WSJT-X source, port 2237 by convention, +plain UDP datagrams, big-endian, a `quint32` magic number + schema version framing every message) is +not an OpenWSFZ invention — it is a third-party wire format this change must implement byte-compatibly +because GridTracker2 (and every other consumer: JTAlert, N1MM+, Log4OM, DXKeeper...) hard-codes it. +There is no negotiation and no OpenWSFZ-side flexibility in the framing or field layout of the +message types this change implements; the only real design freedom is (a) which subset of the ~16 +message types to implement, (b) how the broadcaster/listener plugs into OpenWSFZ's existing decode +pipeline, config store, and TX state machines, and (c) the trust boundary around inbound commands +that can affect a live transmission — decided in the proposal (Halt Tx always honoured, Reply/Free +Text behind an explicit opt-in) and detailed further below. + +Relevant existing seams this change builds on rather than duplicates: +- `DecodeEventBus` / the existing per-cycle `Channel>` that + `QsoAnswererService` already subscribes to (FR-050) — the outbound Decode broadcaster taps the same + feed rather than re-deriving decode data. +- `IQsoController.AbortAsync` — already the single call `POST /api/v1/tx/abort` uses (`WebApp.cs` + line ~1012) regardless of which role (`QsoAnswererService`/`QsoCallerService`) is active. Halt Tx + calls this directly, in-process. +- `QsoCallerService.SelectResponderAsync` — an existing seam for manually picking which responder to + a transmitted CQ to continue with (per `qso-caller` spec, "SelectResponderAsync stores pending + responder and fires on correct phase"). Caller-role `Reply` routes here, unmodified. +- `ADIF.log` / `ALL.TXT` writers (FR-051, FR-028) — the source of truth for QSOLogged/Decode field + content; the broadcaster reads the same in-memory data those writers already consume, it does not + parse the log files back. +- The Settings-page tab pattern (FR-035, FR-043) and F-006's precedent of a capability owning its own + settings-tab requirements directly in its own spec file (`region-lookup-data-refresh`, + `decoder-settings`) rather than in `web-frontend`. + +## Goals / Non-Goals + +**Goals:** +- Byte-compatible WSJT-X UDP protocol implementation for the message subset in the proposal, verified + against real GridTracker2 wire captures (task-level detail in tasks.md), not just against our own + round-trip tests. +- Multiple simultaneous outbound targets, each independently enable/disable-able, from day one. +- A trust boundary that defaults to fully inert and, even once enabled, can never let a stray or + malicious UDP datagram start a transmission or terminate the daemon. +- Halt Tx reachable the instant the listener is running, with no additional opt-in — it is a strictly + safety-improving control. +- No change to any existing, already-shipped requirement's behaviour — this is additive from every + existing capability's point of view. + +**Non-Goals:** +- Not implementing the *entire* WSJT-X protocol. `Replay`, `Location`, `Highlight Callsign`, `Switch + Configuration`, `Configure`, `WSPR Decode` (inbound), and any other type outside the proposal's + explicit list are parsed only far enough to stay framed correctly on the socket, then discarded. + Extending coverage is a follow-up change once this lands and its actual usage is known. +- Not building a general "free-message TX" capability. `Free Text` is accepted and stored so the + protocol round-trips cleanly (and so a *future* free-message feature has somewhere to read it from + on day one), but until such a feature exists, receiving it has **no transmission effect** — this is + called out explicitly rather than silently dropped, so it is not mistaken for a bug. +- Not adding authentication/encryption to the UDP channel. This matches WSJT-X's own model (plaintext + UDP, security through network topology — loopback or a trusted LAN) and is an accepted, unchanged + trade-off; see Risks below. +- Not touching `remote-access`'s HTTP/WebSocket bind-policy machinery — this is a separate socket with + its own, much narrower, config-driven target list, not a generalisation of remote access. + +## Decisions + +### 1. New standalone hosted service, not folded into an existing one + +**Decision:** Add `ExternalReportingService : IHostedService` in `OpenWSFZ.Daemon`, owning one +`UdpClient` per configured, enabled target for outbound sends, and a single inbound-listening +`UdpClient` bound to `0.0.0.0:` (WSJT-X convention: the app listens on the +same port it's configured to send to, since GridTracker2 replies from the same port it received on). +It subscribes to `DecodeEventBus` (outbound Decode), a small new `IQsoStatusSnapshot` polled on a +short timer (outbound Status/Heartbeat — matching WSJT-X's own ~cadence, no event bus needed for a +periodic message), and the ADIF-write completion point (outbound QSOLogged). It is registered +unconditionally; when `externalReporting.enabled` is `false` it starts and immediately does nothing +(no socket opened) — same "inert by default" pattern as `CatPollingService` when `cat.enabled` is +`false`. + +**Why:** Every other cross-cutting daemon capability (`CatPollingService`, `AudioWatchdog`, +`DataFlowMonitor`) is its own `IHostedService`; this keeps the same shape and keeps +`QsoAnswererService`/`QsoCallerService` completely unaware that external reporting exists, aside from +the one new narrow entry point for `Reply` (Decision 4). + +**Alternatives considered:** +- *Fold broadcasting into `QsoAnswererService`* — rejected: conflates an orthogonal concern (talking + to a third-party program) with QSO state-machine logic that already has enough responsibility, and + would force `QsoCallerService` to duplicate the same code. + +### 2. Outbound message construction reads existing state, never re-derives it + +**Decision:** `Status` and `Decode` datagrams are built from the same `DecodeResult`/`ICatState`/ +`IConfigStore.Current.Tx` data the WebSocket `status`/`txState` events and `ALL.TXT` writer already +use. `QSOLogged` is built at the exact point `ADIF.log`'s record is written (same call site, same +field values), not by re-parsing `ADIF.log`. + +**Why:** Two independent encodings of "what just happened" from the same source data cannot drift +apart; deriving one from the other's *output file* would create a parsing dependency on `ADIF.log`'s +own format for no benefit. + +### 3. Inbound trust boundary: `honourInboundCommands` gates Reply/Free Text; Halt Tx is ungated + +**Decision:** As stated in the proposal. Implementation-wise, the inbound listener always parses and +dispatches every recognised datagram type, but the dispatcher for `Reply` and `Free Text` checks +`IConfigStore.Current.ExternalReporting.HonourInboundCommands` and no-ops (with an Information log: +*"external Reply command received but honourInboundCommands is disabled — ignoring"*) when `false`. +`Halt Tx`'s dispatcher has no such check. + +**Why:** Mirrors `cat-tx-ptt`'s design philosophy directly: prefer the design that fails toward "rig +stays silent." An operator who has not opted in should never have a third-party program start a QSO +on their behalf; but an operator in that same state should still be able to reach for GridTracker2's +Halt Tx button as a second, independent stop mechanism if, say, the OpenWSFZ web UI is unreachable +for some reason. The two are not symmetric risks. + +**Alternatives considered:** +- *Single `enabled` flag governs everything, including Halt Tx* — rejected: an operator who enables + outbound reporting only (to get GridTracker2's map working) but has not thought about the inbound + command surface at all would otherwise unknowingly also expose Reply. Splitting the flags makes the + outbound-only, common case (map/logging visibility) the version that ships with zero additional risk + surface, and makes the opt-in step for "let GridTracker2 drive my radio" a deliberate second decision. +- *No opt-in at all — honour Reply/Free Text whenever `enabled`* — rejected as the default because it + collapses "I want GridTracker2 to see my station" and "I want GridTracker2 to be able to key my + station" into one checkbox; FR-016's "no speculative behaviour" spirit argues for making a new + remote-control surface opt-in explicitly, not implicitly via a reporting toggle. + +### 4. `Reply` routes to role-specific existing seams via a narrow interface, not a new state machine + +**Decision:** Add `IExternalReplyTarget` (defined in `OpenWSFZ.Web`, alongside `IQsoRoleSwitcher`, +same rationale — avoids a project reference from `OpenWSFZ.Web`/the new listener back into +`OpenWSFZ.Daemon`) with one method: `Task TryEngageAsync(string callsign, CancellationToken ct)`. +`QsoControllerRouter` (the existing implementer of `IQsoRoleSwitcher`) also implements this: when the +active role is Answerer, it calls a new `QsoAnswererService.TryEngageExternal(callsign)` that runs the +exact same selection/guard logic as the auto-answer path (`DecodeFilterState` check, empty +callsign/grid guard) but targets the specified callsign instead of "first CQ in the batch"; when the +active role is Caller, it calls the existing `SelectResponderAsync(callsign)` unmodified. Returns +`false` (no-op, logged at Information with a reason) if the callsign is not currently a decoded, +engageable station. + +**Why:** Reuses two already-tested selection mechanisms instead of inventing a third. The new surface +on `QsoAnswererService` is additive (a new public method with new guards mirroring existing ones, not +a change to the auto-answer requirement itself), which is why `qso-answerer`'s delta spec uses ADDED, +not MODIFIED. + +**Alternatives considered:** +- *Always require the operator to also enable `tx.autoAnswer` for `Reply` to do anything* — rejected: + `Reply` is inherently a manual, one-shot instruction ("engage *this* station specifically"), which is + a different intent from "auto-answer whatever CQ appears first"; gating it behind `autoAnswer` would + make manual GridTracker2-driven operation impossible without also accepting fully automatic + operation. + +### 5. Framing/serialisation lives in a small, dependency-free internal library + +**Decision:** A new internal static class (e.g. `WsjtxDatagram`) in `OpenWSFZ.Daemon` handles the +magic-number/schema-version header, big-endian primitive read/write, and per-message-type +encode/decode, with no dependency on any other OpenWSFZ type — it operates purely on primitive +fields passed in and out. All OpenWSFZ-specific mapping (which `DecodeResult` field goes in which +datagram field) lives in `ExternalReportingService`, one layer up. + +**Why:** Keeps the one piece of this change that must be byte-perfect against an external spec +small, self-contained, and exhaustively unit-testable (encode a known message, assert exact bytes; +decode known captured bytes, assert exact fields) without dragging in decode-pipeline or config-store +mocking for what is fundamentally a serialisation problem. + +### 6. Absolute exclusion of synthetic/unknown-region traffic — hard-coded, not configurable + +**Decision:** `ExternalReportingService` unconditionally withholds any decode, Status partner +identity, or QSOLogged record whose associated callsign resolves to an R&R-study synthetic entry +(NFR-021's Q-prefix convention) or fails to resolve to any region at all (`Region: null`). This is +implemented as a *second*, hard-coded filter inside the class that actually emits UDP traffic — +`DecodeLoopAsync` checks `DecodeResult.Region` directly; `BuildStatusFields`/`NotifyQsoLogged` use a +new `IsSuppressedCallsign` helper backed by an optional `ICallsignRegionStore` — entirely +independent of, and layered *after*, the existing operator-toggleable +`DecodeNoiseSuppressionConfig.SuppressUnknownRegion`/`SuppressSynthetic` settings that gate the +decode panel and QSO automation upstream. It is not exposed as, and SHALL NOT be exposed as, any +Settings-page control or config field — there is no way for an operator to turn it off. + +**Why:** Directed by the Captain in the plainest possible terms (dev-tasks/2026-07-12-gridtracker- +udp-reporting-review-fixes.md §4): R&R synthetic signals are not real amateur-radio traffic, and +GridTracker2 may itself relay spots onward to a real map, a real logbook, or another real-world +tool this project has no authority over — letting synthetic or unverified traffic leak into that +chain would contaminate systems outside this application's control. Unknown-region decodes are +unverified/likely-noisy and get the same treatment for the same reason: this is a data-integrity/ +privacy floor, not a preference, so it cannot depend on upstream config staying a particular way +(an operator who has disabled both `DecodeNoiseSuppressionConfig` settings — a legitimate, supported +choice for the decode panel — must not thereby also leak synthetic/unknown traffic externally; the +two concerns are deliberately decoupled). + +**Fail-closed, not fail-open:** unlike most optional-dependency (`Foo?`) patterns elsewhere in this +codebase, which fail *open* when the dependency is absent (e.g. a `null` `IDecodeFilterStore` means +"unfiltered"), `IsSuppressedCallsign` treats a `null` `ICallsignRegionStore` exactly like a lookup +miss — suppressed. "Cannot verify" must never be silently treated as "verified real." + +**Alternatives considered:** +- *Rely on `DecodeNoiseSuppressionConfig` defaulting to suppress-on* — rejected: defaults are + overridable by the operator for a legitimate, unrelated reason (wanting to see synthetic/unknown + traffic on the decode panel during an R&R study run), and this exclusion must hold regardless. +- *A new, dedicated `externalReporting` config flag for this* — rejected: the Captain's directive is + explicit that this is "not gated by, not overridable through, any operator setting." A flag, + however defaulted, is still a flag an operator could theoretically flip. + +### 7. Inbound bind uses `SO_REUSEADDR`; primary target's outbound sends share that same socket (D-014) + +**Decision:** Amends Decision 1's inbound bind. The inbound `UdpClient` sets +`SocketOptionName.ReuseAddress` before binding — mirroring Qt's +`ShareAddress | ReuseAddressHint`, the bind option every WSJT-X-protocol peer (GridTracker2 +included) uses — so the bind succeeds even when a peer already owns the port at daemon-startup +time. Separately, the *primary* target (index 0 of the enabled target list, whose port the inbound +listener binds to) has its outbound sends routed through that same shared socket rather than a +separate ephemeral `UdpClient`, falling back to an ephemeral client only if the shared bind is +unavailable. Secondary targets (index 1+) are unaffected — each still gets its own dedicated +outbound-only client. + +**Why:** Found during pre-merge review (D-014): without `ReuseAddress`, the bind threw whenever the +operator's mapping tool was already running — the normal real-world case, not an edge case — and the +exception was swallowed and logged at Warning only, leaving Halt Tx/Reply/Free Text silently and +*permanently* unreachable (nothing retries the bind outside of a config save). The shared-socket +send additionally makes Decision 1's own stated rationale ("GridTracker2 replies from the same port +it received on") actually true: without it, the primary target's sends went out from a separate +ephemeral port, so a peer's reply-to-sender-port semantics would arrive at the wrong place. + +**Verification status — read before trusting this in production:** empirically probed on Windows +while implementing D-014's regression tests: when two `UdpClient`s share one local port via +`SO_REUSEADDR`, Windows delivers an incoming unicast datagram to only the *first-bound* socket — +never both, and (unlike Linux `SO_REUSEPORT`) with no load-balancing fan-out. This means true +*simultaneous* two-listener coexistence on one port on one machine (OpenWSFZ and GridTracker2 both +genuinely receiving their own inbound traffic at the same time) is **not guaranteed by this fix +alone** on Windows — it would need UDP multicast, which the Open Questions section below already +identifies as unimplemented, deliberately out of scope for this change. What this fix does +guarantee, and what its regression tests actually prove: the bind no longer fails/stays permanently +`null` when a peer already owns the port at startup, and the primary target's outbound sends +genuinely originate from the shared bound port. This is the same category of caveat as tasks 2.6/ +10.3 (no live GridTracker2 available to verify the richer message layouts byte-for-byte) — a real +GridTracker2 session is the place to confirm actual bidirectional coexistence before relying on this +operationally. + +**Linux addendum (2026-07-12, found via PR #70's `ubuntu-latest` CI failure — see +dev-tasks/2026-07-12-gridtracker-udp-reporting-linux-ci-failure.md):** the Windows finding above is +about *inbound* delivery (which of two listeners receives a datagram sent *to* the shared port). +Linux's `SO_REUSEADDR` semantics for UDP are documented (and generally observed, kernel/version +dependent) to differ from Windows in a way that creates the mirror-image risk on the *outbound* side: +rather than first-bind-wins, unicast delivery to a `SO_REUSEADDR`-shared UDP port on Linux has +historically gone to the **last-bound** socket. In the realistic startup order this feature targets +(GridTracker2 already running and bound to the shared port first; OpenWSFZ's `_inboundClient` binds +second, inside `Reconcile`), a datagram OpenWSFZ sends from its primary-target outbound path to +`127.0.0.1:port` — i.e. addressed to GridTracker2's port on the same host — could, on a Linux kernel +exhibiting last-bind-wins behaviour, be delivered back to OpenWSFZ's own `_inboundClient`/ +`InboundLoopAsync` instead of ever reaching GridTracker2's socket. If so, Heartbeat/Status/Clear/ +Decode/QSOLogged would silently never arrive at GridTracker2 on Linux in the exact +peer-started-first scenario this whole feature is built for, while inbound Halt Tx/Reply/Free Text +(GridTracker2 → OpenWSFZ, a genuinely different socket pair since GridTracker2's *own* send +originates from GridTracker2's process, not from a second local bind) are unaffected. + +This was not exercised by a real two-process Linux test — no live GridTracker2 (or second real +peer process) was available to confirm it end-to-end; the finding is reasoned from documented +kernel behaviour plus the CI symptom (`OutboundToPrimaryTarget_UsesSharedInboundPort` timing out +with `fakePeer` receiving nothing, which is consistent with, though not exclusive proof of, the +daemon's own send looping back to itself). Given the plaintext-loopback nature of this feature and +that Decision 7's existing verification-status caveat already flags true simultaneous two-listener +coexistence as unguaranteed on Windows too, this is logged as an **open risk carried forward, not +fixed in this change**: the shared-socket send optimisation (routing the primary target's outbound +through `_inboundClient` rather than a dedicated ephemeral client, so reply-to-sender-port semantics +work) is kept as-is, since reverting it would sacrifice the Windows-side benefit it exists for and +the risk is unconfirmed on real hardware either way. A real Linux deployment with GridTracker2 (or a +packet capture on a Linux box) is needed to confirm or rule this out before treating Linux delivery +as solid; tracked as a follow-up verification item alongside tasks 2.6/10.3's existing "no live +GridTracker2" caveat, not a new Open Question line item since it is a refinement of the same +unresolved multicast/multi-listener question already below. + +## Risks / Trade-offs + +- **[Risk] Plaintext, unauthenticated UDP to an operator-configured host** → Mitigation: this is + WSJT-X's own, already-widely-deployed trust model — GridTracker2 users already accept it for + WSJT-X. Documented as an accepted trade-off, not re-litigated. Default target list is empty and + `enabled` defaults to `false`, so no packets leave the machine until the operator configures a + target. +- **[Risk] A malformed or adversarial inbound datagram (short buffer, garbage schema version) crashes + the listener** → Mitigation: `WsjtxDatagram` decode paths SHALL treat any parse failure as "discard + this datagram, log at Debug, continue listening" — never throw uncaught out of the receive loop. + Task-level requirement: a fuzzed/truncated-datagram test proves the listener survives. + Never bring down `ExternalReportingService`, let alone the daemon, from a bad packet. +- **[Risk] `Reply`/`Halt Tx` racing with an in-progress QSO's own state transition (e.g. Halt Tx + arrives the same instant the QSO completes naturally)** → Mitigation: `IQsoController.AbortAsync` + already documents this as a no-op when already `Idle` (existing `qso-answerer` "Operator abort" + requirement); no new race is introduced because no new stop mechanism is introduced. + `TryEngageAsync` is naturally idempotent-safe the same way `SelectResponderAsync` already is + (existing "two requests in quick succession are idempotent"-style scenario for the Caller case; + the new Answerer-side `TryEngageExternal` gets an equivalent scenario). +- **[Risk] Sending a Status/Heartbeat datagram every second-or-so to multiple targets becomes a + measurable CPU/allocation cost on a Raspberry-Pi-class deployment** → Mitigation: reuse a single + pre-sized buffer for encoding (no per-send allocation), matching the existing performance-conscious + pattern in the decode pipeline (FR-026's throughput budget). Not expected to be material, but + called out so it is deliberately checked once, not assumed. + +## Migration Plan + +- Purely additive: new config block, new hosted service, new settings tab, new (additive) method on + `QsoAnswererService`. No existing config field, wire message, or public interface changes meaning. +- Deploy as a normal minor-version bump per `release-versioning`; missing `externalReporting` key in + an existing config file deserialises to fully-inert defaults. +- Rollback: safe at any point — reverting the change removes the feature entirely; even without + reverting, setting `externalReporting.enabled = false` (the default) fully disables all new network + activity with no other side effects. + +## Open Questions + +- Should `Free Text` gain real transmission effect once/if a manual free-message TX feature is + proposed for OpenWSFZ generally, or should this change's "accepted but no-op" behaviour simply + persist indefinitely? Deferred — no such feature exists today to wire it to. +- Should a future change expose per-target outbound message-type filtering (e.g. send Decode to + GridTracker2 but withhold QSOLogged from a different target)? Not requested; the current design + sends the same full outbound stream to every enabled target uniformly. +- Multicast support (WSJT-X optionally supports a multicast group instead of unicast per-target) is + not included — every target in `externalReporting.targets` is a unicast UDP destination. Worth + revisiting if a real multi-listener-on-one-machine use case surfaces. **Updated relevance (D-014, + see Decision 7):** this is no longer purely hypothetical — Windows' single-winner delivery + semantics for a `SO_REUSEADDR`-shared unicast port mean genuine simultaneous inbound coexistence + with a same-machine peer (e.g. GridTracker2) may specifically require multicast to guarantee, not + just "worth revisiting." diff --git a/openspec/changes/gridtracker-udp-reporting/proposal.md b/openspec/changes/gridtracker-udp-reporting/proposal.md new file mode 100644 index 00000000..8321f5dd --- /dev/null +++ b/openspec/changes/gridtracker-udp-reporting/proposal.md @@ -0,0 +1,86 @@ +## Why + +GridTracker2 (and JTAlert, N1MM+, and other logging/mapping tools) discover a running FT8 +application entirely via the UDP protocol WSJT-X invented and that has since become the de-facto +amateur-radio interop standard. OpenWSFZ currently speaks to nothing outside itself: an operator +running GridTracker2 alongside OpenWSFZ today sees no traffic and gets no map, no spot list, and no +automatic QSO logging into GridTracker2's own log. Section 4.3 of `REQUIREMENTS.md` already flags +"PSK Reporter / DX cluster" style integrations as a known future need; this change is the first of +that family, chosen because it is the highest-value, lowest-risk one available — GridTracker2's own +"Reply"/"Halt Tx" affordances are the same ones every WSJT-X operator already trusts, so wiring +OpenWSFZ into the same protocol lets it slot into an existing operator's workflow unmodified. + +This is a post-v1.0 addition (v1.0's gate is a confirmed two-way QSO via CAT+TX, tracked separately +by `cat-tx-ptt`) — it does not block that gate and depends on it only for the Halt Tx safety path +(§ below), which this change reuses rather than duplicates. + +## What Changes + +- Add a new outbound UDP broadcaster implementing the WSJT-X network protocol's core message types: + **Heartbeat**, **Status** (dial frequency, mode, TX/RX state, DX call, active partner), **Decode** + (one datagram per decoded FT8 message, mirroring what already goes to `ALL.TXT` per FR-028), + **Clear**, **QSOLogged** (mirroring what already goes to `ADIF.log` per FR-051), and **Close** + (sent on graceful daemon shutdown). This lets GridTracker2 plot the waterfall/spots on its map and + log completed QSOs exactly as it does for WSJT-X today. +- Add a matching **inbound** listener on the same socket, honouring exactly four WSJT-X client→app + message types: **Reply** (operator, in GridTracker2, selects a decoded station — OpenWSFZ engages + it the same way its own auto-answer would), **Halt Tx** (immediately abort any in-progress + transmission), **Free Text** (accepted and stored; currently a **no-op** — OpenWSFZ's TX state + machines have no free-message slot to apply it to yet, see design.md), and **Close** (logged and + otherwise ignored — a network datagram SHALL NEVER be able to terminate the daemon). Any other + inbound WSJT-X message type is parsed enough not to desynchronise the socket and then discarded + with a Debug log line — no other command is acted upon. +- Add a `externalReporting` block to `AppConfig`: `enabled` (bool, default `false`), a `targets` + list (each `{ name, host, port, enabled }`) supporting **multiple simultaneous** outbound + destinations from day one, and `honourInboundCommands` (bool, default `false`) — a dedicated + opt-in for whether `Reply`/`Free Text` are acted on at all. **Halt Tx is exempt from this gate and + is always honoured when the listener is running**, on the same "fail toward rig stays silent" + principle `cat-tx-ptt` establishes: a third-party program being able to force TX *off* is safe by + construction; a third-party program being able to force TX *on* (`Reply`) is the thing that needs + explicit operator consent. Missing/absent `externalReporting` key deserialises to all-defaults — + identical to today's behaviour (nothing is sent, nothing is listened for). +- Add a new **"External Programs"** tab to the Settings page (distinct from Radio hardware, Logging, + Advanced, Frequencies, Logs, and Region data) listing configured targets with add/remove/enable + controls and the inbound-commands opt-in checkbox, following the existing tab pattern (FR-035, + FR-043) and gated by FR-016 (ships only once the backend round-trip is fully working). +- Extend `QsoAnswererService` with a narrow, testable "external reply" entry point that engages a + specific currently-decoded CQ on command, subject to the same guards (empty callsign/grid, + `DecodeFilterState`) as its existing auto-answer path. `QsoCallerService`'s existing + `SelectResponderAsync` seam is reused, unmodified, for the Caller-role case. +- `Halt Tx` reuses the existing `IQsoController.AbortAsync` — the same method `POST /api/v1/tx/abort` + already calls — rather than introducing a second stop mechanism. + +## Capabilities + +### New Capabilities + +- `external-reporting`: WSJT-X-compatible UDP protocol client (outbound broadcast + inbound command + listener), multi-target configuration, and its own Settings-page tab. + +### Modified Capabilities + +- `configuration`: `AppConfig` gains the `externalReporting` block described above; `GET`/`POST + /api/v1/config` round-trip it. +- `qso-answerer`: gains a new, additive "external reply engages a specific CQ" entry point. No + existing requirement's behaviour changes. + +## Impact + +- **Code**: new `OpenWSFZ.Daemon` component(s) for the UDP broadcaster/listener and WSJT-X datagram + (de)serialisation; `OpenWSFZ.Config` for the new `ExternalReportingConfig` schema; `OpenWSFZ.Web` + for the config round-trip and the new settings-tab markup/JS; `QsoAnswererService` for the new + reply entry point. +- **Config**: additive only; existing `openswfz.json` files continue to work unchanged, and the + feature is fully inert (`enabled: false`, `targets: []`, `honourInboundCommands: false`) until an + operator opts in. +- **Network**: this is the first OpenWSFZ feature that opens an outbound/inbound UDP socket to + arbitrary configured hosts (default target `127.0.0.1`, but an operator can point it at a LAN + host). `remote-access`'s existing loopback/LAN bind-policy precedent applies conceptually but does + not itself gate this — see design.md for why an unauthenticated UDP protocol is an accepted + trade-off here (it is WSJT-X's own trust model, unchanged). +- **Requirements**: new FRs appended to `REQUIREMENTS.md` §4.1 (FR-052 onward) and a new row in the + §4.3 Integrations table (superseding the "Future PSK Reporter / DX cluster" placeholder's GridTracker2 + case specifically — PSK Reporter itself remains future work). +- **Dependency**: Halt Tx's abort path assumes `IQsoController.AbortAsync` exists and behaves as + documented today; it does not depend on any part of the in-flight `cat-tx-ptt` change landing + first, since `AbortAsync` already existed before that change. diff --git a/openspec/changes/gridtracker-udp-reporting/specs/configuration/spec.md b/openspec/changes/gridtracker-udp-reporting/specs/configuration/spec.md new file mode 100644 index 00000000..70e26428 --- /dev/null +++ b/openspec/changes/gridtracker-udp-reporting/specs/configuration/spec.md @@ -0,0 +1,59 @@ +## ADDED Requirements + +### Requirement: externalReporting configuration schema + +`AppConfig` SHALL gain an `externalReporting` object with: +- `enabled` (bool, default `false`) — master switch; when `false`, `ExternalReportingService` opens + no sockets +- `targets` (array, default `[]`) — each entry `{ name: string, host: string, port: int, enabled: + bool }`; `name` is a free-text operator label (e.g. `"GridTracker2"`), not used on the wire +- `honourInboundCommands` (bool, default `false`) — whether inbound Reply/Free Text datagrams are + acted upon; Halt Tx is unaffected by this flag (see `external-reporting` capability) + +An entry with `port` outside `1`–`65535` SHALL be rejected on save with the same validation-error +pattern used elsewhere in `POST /api/v1/config` (HTTP 400, no partial persistence). + +#### Scenario: Missing externalReporting key uses defaults + +- **WHEN** the config file has no `externalReporting` key +- **THEN** `AppConfig.ExternalReporting.Enabled` SHALL be `false` and `Targets` SHALL be an empty + list + +#### Scenario: externalReporting object round-trips correctly + +- **WHEN** a config file contains an `externalReporting` object with `enabled: true`, two target + entries, and `honourInboundCommands: true` +- **THEN** `GET /api/v1/config` SHALL return those exact values and a subsequent `POST + /api/v1/config` with a modified target list SHALL persist the change + +#### Scenario: Out-of-range port rejected + +- **WHEN** `POST /api/v1/config` includes an `externalReporting.targets` entry with `port: 70000` +- **THEN** the daemon SHALL return HTTP 400 and SHALL NOT persist any part of the request + +#### Scenario: Default config includes externalReporting object with enabled false + +- **WHEN** the daemon creates a default config file on first run +- **THEN** the written file SHALL include an `externalReporting` object with at minimum + `"enabled": false, "targets": []` + +--- + +### Requirement: externalReporting configuration exposed via Settings REST API + +`GET /api/v1/config` and `POST /api/v1/config` SHALL include the `externalReporting` object in their +request and response bodies alongside the existing config fields. + +#### Scenario: GET /api/v1/config includes externalReporting section + +- **WHEN** a client sends `GET /api/v1/config` +- **THEN** the response SHALL include an `externalReporting` object with `enabled`, `targets`, and + `honourInboundCommands` fields + +#### Scenario: POST /api/v1/config with a new target persists and takes effect + +- **WHEN** a client sends `POST /api/v1/config` with `{ "externalReporting": { "enabled": true, + "targets": [{ "name": "GridTracker2", "host": "127.0.0.1", "port": 2237, "enabled": true }], + "honourInboundCommands": false } }` +- **THEN** the daemon SHALL persist the change and `ExternalReportingService` SHALL begin sending + outbound datagrams to `127.0.0.1:2237` without requiring a daemon restart diff --git a/openspec/changes/gridtracker-udp-reporting/specs/external-reporting/spec.md b/openspec/changes/gridtracker-udp-reporting/specs/external-reporting/spec.md new file mode 100644 index 00000000..9778692c --- /dev/null +++ b/openspec/changes/gridtracker-udp-reporting/specs/external-reporting/spec.md @@ -0,0 +1,320 @@ +## ADDED Requirements + +### Requirement: ExternalReportingService is inert by default + +A new `ExternalReportingService : IHostedService` SHALL be registered unconditionally in the DI +container. When `AppConfig.ExternalReporting.Enabled` is `false` (the default) or +`AppConfig.ExternalReporting.Targets` is empty, the service SHALL open no sockets, send no +datagrams, and listen for none. Config files without an `externalReporting` key SHALL deserialise to +these defaults and SHALL behave identically to a config file that explicitly disables the feature. + +#### Scenario: Missing config key is fully inert + +- **WHEN** the daemon starts with a config file containing no `externalReporting` key +- **THEN** `ExternalReportingService` SHALL start successfully and open no UDP sockets + +#### Scenario: Enabling with no targets is inert + +- **WHEN** `externalReporting.enabled` is `true` and `targets` is an empty array +- **THEN** no outbound socket SHALL be opened and no datagrams SHALL be sent + +--- + +### Requirement: Multiple simultaneous outbound targets + +`AppConfig.ExternalReporting.Targets` SHALL be a list of `{ name: string, host: string, port: int, +enabled: bool }` entries. Every entry with `enabled = true` SHALL receive an identical copy of every +outbound datagram (Heartbeat, Status, Decode, Clear, QSOLogged, Close) sent by this service. Entries +with `enabled = false` SHALL be skipped without error. A target whose `host` fails to resolve SHALL +log a Warning once per resolution failure and SHALL NOT prevent delivery to other configured targets. + +#### Scenario: Two enabled targets both receive a Decode datagram + +- **WHEN** two targets are configured and enabled (`GridTracker2` at `127.0.0.1:2237` and a second + entry at `127.0.0.1:2238`) and a decode cycle produces one decoded message +- **THEN** an identical Decode datagram SHALL be sent to both `127.0.0.1:2237` and `127.0.0.1:2238` + +#### Scenario: Disabled target is skipped + +- **WHEN** a configured target has `enabled = false` +- **THEN** no datagram of any type SHALL be sent to that target's host/port + +#### Scenario: Unresolvable host does not block other targets + +- **WHEN** one of two enabled targets has a `host` that fails DNS/address resolution +- **THEN** a Warning SHALL be logged for that target and the other, resolvable target SHALL still + receive the datagram + +--- + +### Requirement: Outbound Heartbeat message + +The service SHALL send a WSJT-X-protocol Heartbeat datagram to every enabled target at a fixed +interval (matching WSJT-X's own convention) carrying the configured application `Id` (default +`"OpenWSFZ"`), the maximum schema number supported, and a version/revision string. The first +Heartbeat SHALL be sent within one interval of `ExternalReportingService` becoming enabled (start-up +or a config save that newly enables the feature). + +#### Scenario: Heartbeat sent after enabling + +- **WHEN** `externalReporting.enabled` transitions from `false` to `true` via a config save +- **THEN** a Heartbeat datagram SHALL be sent to every enabled target within one heartbeat interval + +--- + +### Requirement: Outbound Status message + +The service SHALL send a WSJT-X-protocol Status datagram whenever the daemon's effective dial +frequency, decoding-enabled state, or TX/transmitting state changes, and at least once per heartbeat +interval regardless of change, containing: dial frequency (Hz), mode (`"FT8"`), DX call (active QSO +partner, empty when idle), report, TX mode, `TxEnabled`, `Transmitting` (true only while +`IPttController` has an active key-down), `Decoding` (mirrors the existing decode start/stop control, +FR-017), RX/TX audio offsets (Hz), `MyCall` (`tx.callsign`), `MyGrid` (`tx.grid`), and DX grid (active +partner's grid, when known from the decode). **Absolute exclusion, no exceptions:** when the active +partner resolves to an R&R-study synthetic entry or an unresolved (unknown) region, `DXCall` and DX +grid SHALL be sent empty instead of naming that partner — not gated by, and not overridable through, +`DecodeNoiseSuppressionConfig` or any other operator setting. The rest of the Status datagram (dial +frequency, TX/RX state, `Decoding`) SHALL continue to be sent normally; only the partner identity is +withheld. + +#### Scenario: Status reflects an active QSO + +- **WHEN** `QsoAnswererService` is in `WaitReport` with partner `Q1TST` +- **THEN** the next Status datagram SHALL carry `DXCall = "Q1TST"` and `Transmitting = false` + +#### Scenario: Status reflects a live transmission + +- **WHEN** `IPttController.KeyDownAsync` is active +- **THEN** the next Status datagram SHALL carry `Transmitting = true` + +#### Scenario: Status blanks DxCall/DxGrid for a synthetic or unresolved active partner + +- **WHEN** the active QSO partner resolves to an R&R-study synthetic entry (or does not resolve to + any region at all), **and** `DecodeNoiseSuppressionConfig.SuppressSynthetic`/ + `SuppressUnknownRegion` are both `false` (the operator has not opted in to hiding it anywhere else) +- **THEN** the next Status datagram SHALL carry `DXCall = ""` and DX grid `""`, while dial frequency, + `TxEnabled`, `Transmitting`, and `Decoding` SHALL continue to reflect the real, current state + +--- + +### Requirement: Outbound Decode message + +The service SHALL send one WSJT-X-protocol Decode datagram per `DecodeResult` delivered on the +existing per-cycle decode batch (the same feed `QsoAnswererService` subscribes to per its own spec), +carrying: UTC time, SNR, delta-time, delta-frequency (Hz), mode (`"~"` for FT8, matching WSJT-X's own +convention), the decoded message text, and the low-confidence flag. The `New` flag SHALL be `true` +(this service does not replay historical decodes). + +**Absolute exclusion, no exceptions:** a `DecodeResult` whose `Region` is `null` (unresolved/unknown) +or whose `Region.Synthetic` is `true` (R&R-study synthetic entry, NFR-021 Q-prefix convention) SHALL +NEVER produce an outbound Decode datagram, to any target, under any circumstance. This exclusion is +enforced unconditionally inside `ExternalReportingService` itself, independent of +`DecodeNoiseSuppressionConfig.SuppressUnknownRegion`/`SuppressSynthetic` (which gate only the decode +panel and QSO automation and can be disabled by the operator) — it is not exposed as, and SHALL NOT +be exposed as, any Settings-page control or config field. This is a data-integrity/privacy floor: +nothing this application cannot vouch for as real, resolved amateur-radio traffic may leave the +machine via this channel, regardless of what the operator has configured elsewhere. + +#### Scenario: One decode produces one Decode datagram per enabled target + +- **WHEN** a decode cycle yields exactly one `DecodeResult` with a resolved, non-synthetic `Region` +- **THEN** exactly one Decode datagram carrying that result's fields SHALL be sent to each enabled + target + +#### Scenario: Unknown-region and synthetic decodes are never broadcast, even with suppression disabled + +- **WHEN** `DecodeNoiseSuppressionConfig.SuppressUnknownRegion` and `SuppressSynthetic` are both + `false` (the exact condition that lets such decodes reach this service's inbound channel), and a + decode cycle contains a `DecodeResult` with `Region: null` and another with `Region.Synthetic: true` +- **THEN** neither `DecodeResult` SHALL produce an outbound Decode datagram to any target + +#### Scenario: A cycle with only excluded decodes still sends Clear + +- **WHEN** every `DecodeResult` in a decode cycle is unknown-region or synthetic +- **THEN** the Clear datagram for that cycle SHALL still be sent to every enabled target, unaffected + by how many (or how few) Decode datagrams follow it + +--- + +### Requirement: Outbound Clear message + +The service SHALL send a WSJT-X-protocol Clear datagram whenever the decode pipeline's own decode +window is cleared (mirroring whatever internal event already signals a fresh decode window began, +per the existing decode pipeline). + +#### Scenario: Clear sent on new decode cycle boundary + +- **WHEN** a new 15-second decode cycle begins +- **THEN** a Clear datagram SHALL be sent to every enabled target before that cycle's Decode + datagrams + +--- + +### Requirement: Outbound QSOLogged message + +The service SHALL send a WSJT-X-protocol QSOLogged datagram immediately after the daemon writes an +`ADIF.log` record (FR-051's `QsoComplete` write), carrying the same field values written to that +record: partner call, partner grid, TX/RX RST, QSO date/time on and off (UTC), operator call, my +grid, mode, and — when non-zero per FR-051 — frequency and band. No QSOLogged datagram SHALL be sent +for a QSO aborted by watchdog or operator (mirroring FR-051's own "no record on abort" rule). + +**Absolute exclusion, no exceptions:** when the partner callsign resolves to an R&R-study synthetic +entry or an unresolved (unknown) region, no QSOLogged datagram SHALL be sent for that QSO, under any +circumstance — not gated by, and not overridable through, `DecodeNoiseSuppressionConfig` or any other +operator setting. A completed QSO with such a partner SHALL NOT be reported to any external program +as a real logged contact. + +#### Scenario: QSOLogged sent alongside ADIF record + +- **WHEN** a QSO reaches `QsoComplete`, its partner resolves to a real (non-synthetic, resolved- + region) callsign, and an `ADIF.log` record is written +- **THEN** a QSOLogged datagram carrying the same partner call, grid, and QSO date/time SHALL be sent + to every enabled target + +#### Scenario: No QSOLogged datagram on watchdog abort + +- **WHEN** a QSO is aborted by the watchdog (per `qso-answerer`'s existing watchdog-abort behaviour) +- **THEN** no QSOLogged datagram SHALL be sent + +#### Scenario: No QSOLogged datagram for a synthetic or unknown-region partner + +- **WHEN** a QSO reaches `QsoComplete` and its partner callsign resolves to an R&R-study synthetic + entry, or resolves to no region at all +- **THEN** no QSOLogged datagram SHALL be sent to any target, even though the local `ADIF.log` record + is still written normally (this exclusion applies only to the external channel) + +--- + +### Requirement: Outbound Close message on shutdown + +When the daemon shuts down gracefully (`ExternalReportingService.StopAsync`), the service SHALL send +a WSJT-X-protocol Close datagram to every enabled target before closing its sockets. + +#### Scenario: Close sent on graceful shutdown + +- **WHEN** the daemon receives a shutdown signal and `ExternalReportingService.StopAsync` runs +- **THEN** a Close datagram SHALL be sent to every enabled target before the outbound sockets close + +--- + +### Requirement: Inbound listener never crashes on malformed input + +The inbound listener SHALL treat any datagram that fails to parse (too short, bad magic number, +unsupported schema version, truncated field) as a discarded datagram: log at Debug and continue +listening. No parse failure SHALL propagate an unhandled exception out of the receive loop or stop +the listener. + +#### Scenario: Truncated datagram does not stop the listener + +- **WHEN** a 3-byte garbage datagram is received +- **THEN** it SHALL be discarded, a Debug log entry SHALL be written, and the listener SHALL continue + to accept subsequent, well-formed datagrams + +--- + +### Requirement: Unrecognised inbound message types are discarded, not acted upon + +The daemon SHALL parse any inbound WSJT-X-protocol message type other than Heartbeat, Reply, Halt Tx, Free Text, and Close only far enough to determine it is well-formed, then discard it with a Debug log line (e.g. Replay, Location, Highlight Callsign, Switch Configuration, Configure). No such message type SHALL have any observable effect on OpenWSFZ state. + +#### Scenario: Replay message is accepted and discarded + +- **WHEN** a well-formed inbound Replay datagram is received +- **THEN** it SHALL be logged at Debug and SHALL have no effect on decode state or TX state + +--- + +### Requirement: Inbound Halt Tx always honoured + +On receipt of a well-formed inbound Halt Tx datagram, the daemon SHALL call +`IQsoController.AbortAsync` — the same call `POST /api/v1/tx/abort` already makes — regardless of the +value of `externalReporting.honourInboundCommands`. This SHALL apply whenever +`ExternalReportingService`'s inbound listener is running (i.e. `externalReporting.enabled` is `true` +with at least one configured target), independent of the inbound-commands opt-in. + +#### Scenario: Halt Tx aborts an in-progress transmission regardless of the opt-in + +- **WHEN** `externalReporting.honourInboundCommands` is `false` and a Halt Tx datagram is received + while a QSO is active +- **THEN** `IQsoController.AbortAsync` SHALL be called and the active QSO SHALL abort to `Idle` + +#### Scenario: Halt Tx while idle is a no-op + +- **WHEN** a Halt Tx datagram is received while no QSO is active +- **THEN** `IQsoController.AbortAsync` SHALL be called and SHALL be a no-op (matching its existing + documented behaviour when already `Idle`) + +--- + +### Requirement: Inbound Reply gated by honourInboundCommands + +On receipt of a well-formed inbound Reply datagram naming a callsign, the daemon SHALL call +`IExternalReplyTarget.TryEngageAsync(callsign)` only when `externalReporting.honourInboundCommands` +is `true`. When `false`, the datagram SHALL be discarded and an Information-level log entry SHALL +record that Reply was received but ignored because the opt-in is disabled. + +#### Scenario: Reply engages a decoded CQ when opted in + +- **WHEN** `externalReporting.honourInboundCommands` is `true`, the active role is Answerer and + `Idle`, and a Reply datagram names a callsign present in the current decode batch as a CQ +- **THEN** `IExternalReplyTarget.TryEngageAsync` SHALL be called and the answerer SHALL engage that + callsign exactly as it would for its own auto-answer path + +#### Scenario: Reply ignored when not opted in + +- **WHEN** `externalReporting.honourInboundCommands` is `false` and a Reply datagram is received +- **THEN** no engagement SHALL occur and an Information log entry SHALL record the ignored command + +--- + +### Requirement: Inbound Free Text gated and currently a no-op + +On receipt of a well-formed inbound Free Text datagram, the daemon SHALL store the text only when +`externalReporting.honourInboundCommands` is `true`; when `false` it SHALL be discarded with the same +Information-level logging as Reply. Even when stored, Free Text SHALL have **no effect on any +transmission** — no OpenWSFZ TX state machine currently has a free-message slot to apply it to. This +is intentional (see design.md) and SHALL NOT be treated as a defect. + +#### Scenario: Free Text is stored but does not affect TX + +- **WHEN** `externalReporting.honourInboundCommands` is `true` and a Free Text datagram carrying + `"TEST MSG"` is received +- **THEN** the text SHALL be retained in memory and no transmission of any kind SHALL result + +--- + +### Requirement: Inbound Close is logged and never terminates the daemon + +On receipt of a well-formed inbound Close datagram, the daemon SHALL log an Information entry noting +a client requested close, and SHALL take no other action. Under no circumstances SHALL an inbound +network datagram of any type cause the daemon process to exit. + +#### Scenario: Inbound Close does not shut down the daemon + +- **WHEN** an inbound Close datagram is received +- **THEN** an Information log entry SHALL be written and the daemon SHALL continue running unaffected + +--- + +### Requirement: Settings page — External Programs tab + +The Settings page SHALL gain a new tab labelled **"External Programs"**, following the existing tab +pattern (FR-035, FR-043). The tab SHALL display: an **Enabled** checkbox bound to +`externalReporting.enabled`; an editable table of targets (columns: Name, Host, Port, Enabled, +Delete) with an **"Add target"** button that appends a blank row (`name = ""`, `host = "127.0.0.1"`, +`port = 2237`, `enabled = true`); and a separate **"Honour inbound commands (Reply / Free Text)"** +checkbox bound to `externalReporting.honourInboundCommands`, with adjacent explanatory text stating +that Halt Tx is always honoured regardless of this setting. All changes SHALL participate in the +existing unsaved-changes flow (FR-040) and SHALL be posted via `POST /api/v1/config` on Save. Per +FR-016, this tab SHALL ship only once the backend round-trip (config persistence and the running +`ExternalReportingService`) is fully implemented and testable end-to-end. + +#### Scenario: Adding a target row + +- **WHEN** the operator clicks "Add target" on the External Programs tab +- **THEN** a new blank row SHALL appear pre-filled with `host = "127.0.0.1"`, `port = 2237`, + `enabled = true`, and the unsaved-changes indicator SHALL appear + +#### Scenario: Honour-inbound-commands checkbox persists independently of Enabled + +- **WHEN** the operator checks "Honour inbound commands" and saves, with `Enabled` already `true` +- **THEN** `POST /api/v1/config` SHALL include `externalReporting.honourInboundCommands: true` diff --git a/openspec/changes/gridtracker-udp-reporting/specs/qso-answerer/spec.md b/openspec/changes/gridtracker-udp-reporting/specs/qso-answerer/spec.md new file mode 100644 index 00000000..61155f91 --- /dev/null +++ b/openspec/changes/gridtracker-udp-reporting/specs/qso-answerer/spec.md @@ -0,0 +1,50 @@ +## ADDED Requirements + +### Requirement: External reply engages a specific decoded CQ + +`QsoAnswererService` SHALL expose `Task TryEngageExternal(string callsign, CancellationToken +ct = default)`, callable in-process by the `external-reporting` capability's inbound Reply handler +(via `IExternalReplyTarget`, implemented by `QsoControllerRouter`). When called while the service is +in `Idle` and `callsign` matches the source callsign of a CQ present in the most recent decode batch +that is not filtered out under the active `DecodeFilterState`, the service SHALL engage exactly as it +would for that CQ under its existing auto-answer path (Requirement: "Auto-answer first decoded CQ"), +advancing to `TxAnswer` and returning `true`. This SHALL apply regardless of the value of +`tx.autoAnswer` — an explicit external reply is a one-shot manual instruction, not automatic +behaviour, so it is not gated by the auto-answer toggle. + +If `callsign` does not match any currently decoded, non-filtered-out CQ, or the service is not in +`Idle`, or `tx.callsign`/`tx.grid` is empty, the call SHALL take no action and return `false`; a +matching Information-level log entry SHALL record the reason. + +#### Scenario: External reply engages a matching decoded CQ + +- **WHEN** the service is in `Idle`, the most recent decode batch contains `CQ Q1TST JO22` (not + filtered out), and `TryEngageExternal("Q1TST")` is called +- **THEN** the service SHALL advance to `TxAnswer`, begin transmitting the answer to `Q1TST`, and the + call SHALL return `true` + +#### Scenario: External reply works even when autoAnswer is disabled + +- **WHEN** `tx.autoAnswer` is `false`, the service is `Idle`, and `TryEngageExternal("Q1TST")` is + called for a callsign present as a CQ in the current decode batch +- **THEN** the service SHALL engage `Q1TST` exactly as in the enabled case, unaffected by + `tx.autoAnswer` + +#### Scenario: External reply to an unknown callsign is a no-op + +- **WHEN** `TryEngageExternal("Q9ZZZ")` is called and no CQ from `Q9ZZZ` is present in the most + recent decode batch +- **THEN** the service SHALL remain `Idle`, SHALL NOT transmit, and the call SHALL return `false` + +#### Scenario: External reply to a filtered-out callsign is a no-op + +- **WHEN** `TryEngageExternal("Q1TST")` is called and `Q1TST`'s CQ is present but filtered out under + the active `DecodeFilterState` +- **THEN** the service SHALL remain `Idle`, SHALL NOT transmit, and the call SHALL return `false` + +#### Scenario: External reply while already engaged is a no-op + +- **WHEN** the service is not in `Idle` (already mid-QSO with a different partner) and + `TryEngageExternal` is called for any callsign +- **THEN** the in-progress QSO SHALL continue unaffected, no new engagement SHALL occur, and the call + SHALL return `false` diff --git a/openspec/changes/gridtracker-udp-reporting/tasks.md b/openspec/changes/gridtracker-udp-reporting/tasks.md new file mode 100644 index 00000000..56e6e376 --- /dev/null +++ b/openspec/changes/gridtracker-udp-reporting/tasks.md @@ -0,0 +1,269 @@ +## 1. Configuration schema + +- [x] 1.1 Add `ExternalReportingConfig`/`ExternalReportingTarget` records to `OpenWSFZ.Config` + (`enabled` bool default `false`; `targets` list of `{ name, host, port, enabled }`; + `honourInboundCommands` bool default `false`), referenced from `AppConfig` as + `ExternalReporting`. +- [x] 1.2 Deserialisation: missing `externalReporting` key yields the all-defaults object (mirror + `CatConfig`'s missing-key handling). Add the section to the default config written on first + run. +- [x] 1.3 `POST /api/v1/config` validation: reject (`HTTP 400`, no partial persistence) any target + entry with `port` outside `1`–`65535`, matching the existing validation-error pattern. +- [x] 1.4 `OpenWSFZ.Config.Tests`: round-trip test, missing-key-defaults test, out-of-range-port + rejection test (`specs/configuration/spec.md` scenarios). + +## 2. WSJT-X datagram serialisation + +- [x] 2.1 Add internal `WsjtxDatagram` static class in `OpenWSFZ.Daemon` implementing the + magic-number + schema-version header and big-endian primitive read/write helpers, with no + dependency on any other OpenWSFZ type. +- [x] 2.2 Implement encode for: Heartbeat, Status, Decode, Clear, QSOLogged, Close. +- [x] 2.3 Implement decode for: Heartbeat, Reply, Halt Tx, Free Text, Close, and a generic + "recognised-but-unsupported-type" path (Replay, Location, Highlight Callsign, Switch + Configuration, Configure, etc.) that consumes the datagram without error. +- [x] 2.4 Decode paths SHALL never throw on malformed input — truncated/garbage buffers become a + discarded-datagram result, not an exception. +- [x] 2.5 `OpenWSFZ.Daemon.Tests`: byte-exact encode tests for every outbound type (assert exact + byte sequence, not just field round-trip) and decode tests for every inbound type, including a + fuzzed/truncated-buffer test proving no exception escapes. +- [~] 2.6 SKIPPED — no real WSJT-X/GridTracker2 wire capture was available in this environment. + **Risk flag:** the field layouts for Status, Decode, QSOLogged, Reply, Halt Tx, and Free Text + are implemented from documented protocol knowledge, not verified against a live capture — + recommend running this task for real (or task 10.3's manual GridTracker2 sanity check) before + relying on this feature in production. + +## 3. Outbound broadcaster + +- [x] 3.1 Add `ExternalReportingService : IHostedService` in `OpenWSFZ.Daemon`; register + unconditionally in `Program.cs`. No socket opened when inert (§1's defaults). +- [x] 3.2 On enable (startup or config-save transition), open one outbound `UdpClient` per enabled + target; on config-save, reconcile the target list (open new, close removed) without a daemon + restart. +- [x] 3.3 Wire Heartbeat on a fixed timer; wire Status on a timer plus on-change triggers (dial + frequency, decoding-enabled, TX/transmitting state) sourced from existing `ICatState`/ + `IConfigStore.Current.Tx`/`IPttController` state — no new state tracking duplicated. + (`IQsoController.Keying` used directly for `Transmitting` rather than a separate + `IPttController` read — same underlying signal, already the documented "Transmitting" source.) +- [x] 3.4 Subscribe to the existing per-cycle decode batch feed for outbound Decode; send Clear at + each new cycle boundary before that cycle's Decode datagrams. (Via a new dedicated bounded + channel fed by the decode pump alongside `qsoAnswererChannel`/`qsoCallerChannel`, not a + `DecodeEventBus` subscription — `DecodeEventBus` is a one-way WebSocket broadcaster with no + subscriber surface; see `ExternalReportingService`'s class remarks.) +- [x] 3.5 Hook the existing `ADIF.log` write call site (FR-051) to also emit an outbound QSOLogged + datagram with the same field values, skipped on watchdog/operator abort exactly as the ADIF + write itself already is. (Via a `QsoLoggedNotifyingAdifWriter : IAdifLogWriter` decorator — + the single choke point covering both the direct-write path AND `POST /api/v1/tx/log-qso`, + the default `qsoConfirmation=true` path design.md's task didn't originally call out by name.) +- [x] 3.6 Send Close to every enabled target from `ExternalReportingService.StopAsync` before + closing sockets. +- [x] 3.7 `OpenWSFZ.Daemon.Tests`: bind a real loopback `UdpClient` per test as a fake target, + assert each outbound message type is received with correct parsed fields, and assert delivery + to two simultaneous targets (per `specs/external-reporting/spec.md`). + +## 4. Inbound listener + +- [x] 4.1 Bind a single inbound `UdpClient` alongside the outbound sockets when enabled; receive + loop discards anything that fails to parse (§2.4) and continues. +- [x] 4.2 Halt Tx handler: call `IQsoController.AbortAsync` unconditionally (not gated by + `honourInboundCommands`). +- [x] 4.3 Reply and Free Text handlers: check `honourInboundCommands`; when `false`, discard with an + Information log naming the ignored command; when `true`, dispatch (Reply → §5's + `IExternalReplyTarget`; Free Text → store in memory, no transmission effect). +- [x] 4.4 Close handler: Information log only; SHALL NOT terminate the daemon under any + circumstance. +- [x] 4.5 Any other recognised-but-unsupported inbound type: Debug log only, no state change. +- [x] 4.6 `OpenWSFZ.Daemon.Tests`: inject synthetic inbound datagrams (real loopback send from the + test into the service's bound port) for every scenario in `specs/external-reporting/spec.md`'s + inbound requirements, including the malformed-datagram resilience scenario and the + opt-in-gating scenarios for Reply/Free Text. + +## 5. External reply routing + +- [x] 5.1 Add `IExternalReplyTarget` interface in `OpenWSFZ.Web` (alongside `IQsoRoleSwitcher`): + `Task TryEngageAsync(string callsign, CancellationToken ct)`. +- [x] 5.2 Implement on `QsoControllerRouter`: when active role is Answerer, delegate to the new + `QsoAnswererService.TryEngageExternal`; when active role is Caller, delegate to + `QsoCallerService.TryEngageExternalResponder` (a thin wrapper that resolves a frequency + from recently-observed responder decodes and then calls the existing, unmodified + `SelectResponderAsync` seam — see design.md Decision 4; this Caller-role path has no + dedicated delta-spec requirement, unlike the Answerer path in task 5.3/5.4). +- [x] 5.3 Add `Task TryEngageExternal(string callsign, CancellationToken ct = default)` to + `QsoAnswererService` per `specs/qso-answerer/spec.md`'s new requirement — reuses the existing + CQ-matching/`DecodeFilterState`/empty-callsign guards, targets a specific callsign instead of + "first in batch," and is **not** gated by `tx.autoAnswer`. +- [x] 5.4 `OpenWSFZ.Daemon.Tests`: all five new scenarios in `specs/qso-answerer/spec.md` (matching + CQ engages, works with `autoAnswer=false`, unknown callsign no-ops, filtered-out callsign + no-ops, already-engaged no-ops). +- [x] 5.5 Wire the inbound Reply handler (§4.3) to call `IExternalReplyTarget.TryEngageAsync`, + resolved via DI the same way `WebApp` resolves `IQsoRoleSwitcher` today. Done as part of the + §3/§4 `ExternalReportingService` implementation (`HandleReply`) — resolved lazily via + `IServiceProvider.GetService()`, not constructor injection, to avoid + the DI construction cycle documented in `ExternalReportingService`'s class remarks. + +## 6. Settings — before screenshot + +- [x] 6.1 Capture a screenshot of the current Settings page tab bar (all six existing tabs) as the + "before" reference, saved under `dev-tasks/screenshots/`, before any markup changes land. + (Actual current tab count at time of capture: 7 — General/Radio hardware/Logging/Advanced/ + Frequencies/Logs/Region data; `gridtracker-before-01-tab-bar.png`.) + +## 7. Settings UI implementation + +- [x] 7.1 Add the "External Programs" tab button and panel to `web/settings.html`, following the + existing `settings-tab-btn`/`settings-tab-panel` pattern (see `tab-region-data` for the most + recent precedent). +- [x] 7.2 Implement the Enabled checkbox, the targets table (Name/Host/Port/Enabled/Delete columns, + "Add target" button, empty-state placeholder row matching the Frequencies tab's pattern), and + the "Honour inbound commands" checkbox with its Halt-Tx-is-always-on explanatory text, in + `web/js/settings.js`. (Delete-then-empty-table does not re-show the placeholder row without + a reload — verified this matches the pre-existing Frequencies tab's own delete handler + exactly, not a new gap introduced here.) +- [x] 7.3 Wire the tab's fields into the existing unsaved-changes dirty-check (FR-040) and into the + `POST /api/v1/config` payload assembled on Save. Also added client-side port-range + validation (1–65535) mirroring the daemon's own `POST /api/v1/config` rejection, so the + operator gets immediate feedback instead of a round-trip 400. +- [x] 7.4 Confirm the tab is added to `sessionStorage` tab-persistence handling alongside the + existing six tabs. (No code change needed — tab switching/persistence is fully generic, + driven by `.settings-tab-btn`/`.settings-tab-panel` + `aria-controls`, confirmed by + inspection.) Live-verified end-to-end via Playwright against a real running daemon: Save → + reload round-trips `enabled`/`targets`/`honourInboundCommands` correctly, and + `honourInboundCommands` persists independently of `enabled` (both + `specs/external-reporting/spec.md` scenarios pass for real, not just via unit test). + +## 8. Settings — after screenshot + +- [x] 8.1 Capture a screenshot of the Settings page with the new "External Programs" tab selected + and populated with at least one target row, saved under `dev-tasks/screenshots/`, as the + "after" reference — compare against 6.1 to confirm the existing tabs are visually unaffected. + (`gridtracker-after-01-external-programs-tab.png`, `gridtracker-after-02-tab-bar.png` — all + 8 tabs render on a single unwrapped row, matching the 7-tab baseline's layout.) + +## 9. Documentation + +- [x] 9.1 Append new FR entries (FR-052 onward) to `REQUIREMENTS.md` §4.1 covering: the + `externalReporting` config schema, the outbound broadcaster, the inbound listener and its + trust boundary, and the Settings tab — following the FR-045-style amendment format. + (FR-052 config schema, FR-053 outbound broadcaster, FR-054 inbound listener + trust + boundary + `TryEngageExternal`, FR-055 Settings tab.) +- [x] 9.2 Add a row to `REQUIREMENTS.md` §4.3 Integrations table for "GridTracker2 / WSJT-X UDP + protocol" (Outbound + Inbound, Direction: Both), distinct from the still-future "PSK Reporter / + DX cluster" row. +- [x] 9.3 Add a `REQUIREMENTS.md` §10 revision-history row documenting this change, matching the + existing row format (see the FR-029/NFR-016 entry for style). Bumped `VERSION` to `0.35` + (user-facing: new Settings tab) per the `release-versioning` capability's rule. +- [x] 9.4 Note in this change's own `proposal.md`/commit message that this is a post-v1.0 addition + and does not alter any `IMPLEMENTATION_PLAN.md` phase or gate. (Already present in + `proposal.md`'s "Why" section from `opsx:propose`; echoed in the 1.31 revision-history row + and in this session's commit messages.) + Also: FR-052/053/054 test `DisplayName`s carry their requirement-ID prefixes (traceability + gate G3); FR-055 (frontend-only, no xUnit-testable surface) added to `traceability-debt.md` + following the exact precedent of FR-035/036/037/040/041/043/044. Verified locally: running + `tools/TraceabilityCheck` against the built test assemblies reports + `PASS: all requirements are mapped and all references are valid.` + +## 11. Pre-merge review fixes (dev-tasks/2026-07-12-gridtracker-udp-reporting-review-fixes.md) + +Amendments to this in-flight, unmerged change following QA's pre-merge review — continued on the +same branch, no new OpenSpec change proposal (per the dev-task's own instruction). + +- [x] 11.1 **D-014 Part A** — set `SocketOptionName.ReuseAddress` before binding the inbound + listener in `ExternalReportingService.Reconcile`, so the bind no longer throws (and silently, + permanently fails) whenever a peer (e.g. GridTracker2) already owns the configured port at + daemon-startup time — the normal real-world case, not an edge case. +- [x] 11.2 **D-014 Part B** — route the primary target's (index 0 of the enabled list) outbound + sends through the same shared, bound inbound socket rather than a separate ephemeral + `UdpClient`, so a peer's reply-to-sender-port semantics (design.md's "GridTracker2 replies + from the same port it received on" rationale) actually reach us. Falls back to a dedicated + ephemeral client if the shared bind is unavailable, so outbound delivery is never lost. + Secondary targets (index 1+) unaffected. +- [x] 11.3 **D-014 tests** — `InboundBind_SucceedsWhenTargetPortAlreadyBoundByPeer` (bind succeeds, + directly verified, despite a peer already owning the port at startup; Halt Tx sent after that + peer disconnects is received — proving the bind didn't silently fail) and + `OutboundToPrimaryTarget_UsesSharedInboundPort` (the primary target's outbound sends + genuinely originate from the shared bound port, verified via a real loopback peer's own + `RemoteEndPoint`). **Platform finding, documented in design.md Decision 7:** Windows delivers + shared-port UDP unicast to only the first-bound socket (no Linux-style `SO_REUSEPORT` + fan-out) — true simultaneous two-listener coexistence isn't guaranteed by this fix alone and + would need multicast (design.md's existing, unimplemented Open Question); the tests prove + what's actually fixed (bind success, correct source port), not simultaneous coexistence. +- [x] 11.4 **Absolute exclusion, Tier 1** — `ExternalReportingService.DecodeLoopAsync` unconditionally + skips any `DecodeResult` with `Region: null` or `Region.Synthetic: true` before encoding an + outbound Decode datagram, independent of `DecodeNoiseSuppressionConfig` and not exposed as any + Settings-page control. Clear still fires every cycle regardless of how many decodes survive. +- [x] 11.5 **Absolute exclusion, Tier 2** — added an optional `ICallsignRegionStore?` constructor + parameter (wired in `Program.cs`) and a private `IsSuppressedCallsign` helper (fails *closed* + on a lookup miss or a `null` store — deliberately the opposite of most optional-dependency + null-checks elsewhere in this codebase, since this is a data-integrity floor). Applied in + `BuildStatusFields` (blanks `DxCall`/`DxGrid` for a synthetic/unknown active partner; the rest + of Status keeps flowing normally) and `NotifyQsoLogged` (returns early, mirroring the existing + no-record-on-abort pattern, before building/sending the datagram). +- [x] 11.6 **Exclusion tests** — `Decode_UnknownRegionAndSynthetic_NeverSentEvenWithSuppressionOff`, + `Decode_AllResultsSuppressed_StillSendsClear`, `Status_SyntheticPartner_DxCallAndGridBlanked`, + `Status_NormalPartner_DxCallPopulated` (regression), `NotifyQsoLogged_SyntheticOrUnknownPartner_NeverSent`, + `NotifyQsoLogged_NormalPartner_StillSent` (regression) — all run with + `DecodeNoiseSuppressionConfig.SuppressUnknownRegion`/`SuppressSynthetic` both `false`, the + exact condition that would otherwise let excluded traffic through, to prove the exclusion is + genuinely unconditional. Also fixed two pre-existing tests + (`TwoEnabledTargets_BothReceiveDecode`, `NotifyQsoLogged_SendsQsoLoggedDatagram`) whose + NFR-021 Q-prefix synthetic test callsigns were now spuriously suppressed by the new filter — + gave them a resolvable, non-synthetic `Region`/region-store mapping so they keep testing what + they were meant to test (delivery mechanics, not the exclusion feature); added a + `FakeCallsignRegionStore` test double. +- [x] 11.7 **Documentation** — amended `specs/external-reporting/spec.md`'s "Outbound Decode + message", "Outbound Status message", and "Outbound QSOLogged message" requirements with the + absolute-exclusion clause and new scenarios; added design.md Decision 6 (absolute exclusion, + hard-coded and non-configurable) and Decision 7 (D-014 `ReuseAddress`/shared-socket fix, with + the Windows platform-limitation finding); amended the Open Questions multicast bullet; + appended the exclusion clause to `REQUIREMENTS.md` FR-053 and folded a note into the existing + 1.31 revision-history row (this change had not yet merged to `main`, so no new row). + +## 10. Verification + +- [x] 10.1 Run the full existing test suite (`dotnet test` across all projects) and confirm no + existing test's assertions changed — this change must be additive-only per design.md's + Migration Plan. All 9 test projects run individually (no root `.sln`): LicenseInventoryCheck + 24, OpenWSFZ.Audio 19, OpenWSFZ.Config 75, OpenWSFZ.Daemon 391, OpenWSFZ.E2E 2, OpenWSFZ.Ft8 + 289, OpenWSFZ.Rig 35, OpenWSFZ.Web 229, TraceabilityCheck 34 — **1098/1098 passing**, 0 + failed, 0 skipped. **Updated 2026-07-12 per the review-fixes dev-task (§11 above)** — the + OpenWSFZ.Daemon count rose from 383 to 391 (+8: 2 D-014 regression tests, 6 absolute- + exclusion tests); two pre-existing tests were fixed, not broken, by the exclusion filter + (see task 11.6) — their assertions were preserved, only their fixture data changed so they + keep testing what they were meant to test. `dotnet build` across all `src/` projects: **zero + warnings, zero errors** (AC-9). `tools/TraceabilityCheck` re-run locally: still + `PASS: all requirements are mapped and all references are valid.` +- [x] 10.2 Run `openspec validate --strict --all` and confirm the delta specs archive cleanly against + `configuration` and `qso-answerer`. **53/53 passed, 0 failed** — includes both + `change/gridtracker-udp-reporting` and the unrelated in-flight `change/cat-tx-ptt`. Re-run + 2026-07-12 after the §11 spec.md amendments — still 53/53. +- [ ] 10.3 (Optional, not a merge gate per the agreed automated-tests-only verification strategy) If + a real GridTracker2 install is available to the developer, a one-time manual sanity check — + enable the feature pointed at GridTracker2's default port, confirm spots appear on its map and + a Halt Tx click from GridTracker2 aborts an in-progress test QSO — is a valuable extra + confidence check but SHALL NOT block merge if unavailable. **Not performed** — no GridTracker2 + install or real rig available in this environment. Combined with task 2.6's skip, this is the + one open risk item in this change: the richer WSJT-X datagram layouts (Status/Decode/ + QSOLogged/Reply/Halt Tx/Free Text) are implemented from protocol documentation, not verified + byte-for-byte against a real capture or a live GridTracker2 session — recommend running this + check before relying on the feature operationally. +- [x] 10.4 **Linux CI fix (2026-07-12, PR #70)** — `ubuntu-latest`'s `Build & Test` job was failing + on `OutboundToPrimaryTarget_UsesSharedInboundPort` (D-014 AC-2): the test raced a second + same-port-bound socket to observe the daemon's own outbound send over the wire, relying on + the Windows first-bind-wins delivery semantics documented on the sibling AC-1 test — which do + not hold on Linux (last-bind-wins, kernel/version-dependent), so the observer socket never + saw the datagram and the 3-second receive window timed out. Per + dev-tasks/2026-07-12-gridtracker-udp-reporting-linux-ci-failure.md: rewrote the test to assert + the *sending* socket's own local port directly (via the existing `GetInboundClient` reflection + helper) instead of racing OS delivery arbitration — deterministic on every platform. Also + investigated whether this is a genuine cross-platform production risk, not just a test + artifact: reasoned from documented Linux `SO_REUSEADDR` UDP behaviour that it likely is — see + design.md Decision 7's new "Linux addendum," which documents the mirror-image risk to the + existing Windows finding (OpenWSFZ's own outbound send to a peer on the shared loopback port + could be delivered back to OpenWSFZ's own `_inboundClient` instead of reaching the peer, + since OpenWSFZ binds second in the realistic startup order) and logs it as an open, + unconfirmed-on-real-hardware risk carried forward alongside the existing tasks 2.6/10.3 + no-live-GridTracker2 caveat, not fixed in this change. Full `OpenWSFZ.Daemon.Tests` suite + re-run locally: **391/391 passing** (unchanged count — one test's assertion mechanism + changed, no test added/removed), so task 10.1's figures above stand as-is. Pushed to PR #70 + and confirmed green on the PR's own CI, both duplicate runs, all three platforms + (`windows-latest`/`macos-latest`/`ubuntu-latest`) plus Gate G9 — `gh pr checks 70` exits 0, + no pending/failing checks remain. diff --git a/src/OpenWSFZ.Abstractions/AppConfig.cs b/src/OpenWSFZ.Abstractions/AppConfig.cs index d860844d..711f57ac 100644 --- a/src/OpenWSFZ.Abstractions/AppConfig.cs +++ b/src/OpenWSFZ.Abstractions/AppConfig.cs @@ -77,4 +77,13 @@ public sealed record AppConfig( /// suppressed by default). /// public DecodeNoiseSuppressionConfig DecodeNoiseSuppression { get; init; } = new(); + + /// + /// GridTracker2/WSJT-X-compatible UDP reporting configuration + /// (external-reporting capability, gridtracker-udp-reporting change). + /// Always non-null; defaults to Enabled = false, Targets = [] so that + /// existing config files without an externalReporting key deserialise without + /// error and remain fully inert (no sockets opened) until an operator opts in. + /// + public ExternalReportingConfig ExternalReporting { get; init; } = new(); } diff --git a/src/OpenWSFZ.Abstractions/ExternalReportingConfig.cs b/src/OpenWSFZ.Abstractions/ExternalReportingConfig.cs new file mode 100644 index 00000000..39d5e1e1 --- /dev/null +++ b/src/OpenWSFZ.Abstractions/ExternalReportingConfig.cs @@ -0,0 +1,106 @@ +using System.Text.Json.Serialization; + +namespace OpenWSFZ.Abstractions; + +/// +/// A single outbound/inbound WSJT-X-protocol UDP target configured by the operator +/// (external-reporting capability, e.g. GridTracker2, JTAlert, N1MM+). +/// +public sealed record ExternalReportingTarget +{ + // ── Deserialization note ────────────────────────────────────────────────── + // + // STJ source-generation initialises value-type fields from JSON using CLR + // defaults (int → 0, bool → false) rather than C# property-initialiser + // defaults. Expose a [JsonConstructor] so absent JSON fields resolve to the + // intended defaults — same pattern as TxConfig/RemoteAccessConfig. + // ───────────────────────────────────────────────────────────────────────── + + /// + /// Deserialization constructor used by the STJ source-generated context. + /// + [JsonConstructor] + public ExternalReportingTarget( + string name = "", + string host = "127.0.0.1", + int port = 2237, + bool enabled = true) + { + Name = name; + Host = host; + Port = port; + Enabled = enabled; + } + + /// + /// Free-text operator label (e.g. "GridTracker2"). Not used on the wire. + /// Default: "". + /// + public string Name { get; init; } = ""; + + /// + /// Destination hostname or IP address. Default: "127.0.0.1" (loopback). + /// + public string Host { get; init; } = "127.0.0.1"; + + /// + /// Destination UDP port. Must be in the range 1–65535; POST /api/v1/config + /// rejects (HTTP 400, no partial persistence) any target outside this range. + /// Default: 2237 (WSJT-X convention). + /// + public int Port { get; init; } = 2237; + + /// + /// When true (default), this target receives every outbound datagram. + /// When false, the target is configured but skipped without error. + /// + public bool Enabled { get; init; } = true; +} + +/// +/// GridTracker2/WSJT-X-compatible UDP reporting configuration +/// (external-reporting capability, gridtracker-udp-reporting change). +/// Always non-null on ; a missing externalReporting key in the +/// config file deserialises to a fully-inert default (Enabled = false, +/// Targets = [], HonourInboundCommands = false) — identical to today's behaviour +/// (nothing is sent, nothing is listened for). +/// +public sealed record ExternalReportingConfig +{ + /// + /// Deserialization constructor used by the STJ source-generated context. + /// Parameter defaults ensure that fields absent from older config files load with + /// the intended fully-inert values rather than CLR zero-defaults. + /// + [JsonConstructor] + public ExternalReportingConfig( + bool enabled = false, + IReadOnlyList? targets = null, + bool honourInboundCommands = false) + { + Enabled = enabled; + Targets = targets ?? []; + HonourInboundCommands = honourInboundCommands; + } + + /// + /// Master enable switch. When false (the default), ExternalReportingService + /// opens no sockets, sends no datagrams, and listens for none. + /// + public bool Enabled { get; init; } = false; + + /// + /// Configured outbound/inbound targets. Default: [] (empty — inert even if + /// is true). Supports multiple simultaneous destinations. + /// + public IReadOnlyList Targets { get; init; } = []; + + /// + /// Whether inbound Reply/Free Text datagrams are acted upon. + /// Halt Tx is not gated by this flag — it is always honoured whenever the + /// inbound listener is running (see external-reporting capability's spec for the + /// rationale: a third-party program forcing TX off is safe by construction; forcing + /// it on requires explicit operator consent). Default: false. + /// + public bool HonourInboundCommands { get; init; } = false; +} diff --git a/src/OpenWSFZ.Config/ConfigJsonContext.cs b/src/OpenWSFZ.Config/ConfigJsonContext.cs index 7431c389..515ea797 100644 --- a/src/OpenWSFZ.Config/ConfigJsonContext.cs +++ b/src/OpenWSFZ.Config/ConfigJsonContext.cs @@ -20,4 +20,6 @@ namespace OpenWSFZ.Config; [JsonSerializable(typeof(RemoteAccessConfig))] [JsonSerializable(typeof(DecoderConfig))] [JsonSerializable(typeof(DecodeNoiseSuppressionConfig))] +[JsonSerializable(typeof(ExternalReportingConfig))] +[JsonSerializable(typeof(ExternalReportingTarget))] public sealed partial class ConfigJsonContext : JsonSerializerContext { } diff --git a/src/OpenWSFZ.Config/JsonConfigStore.cs b/src/OpenWSFZ.Config/JsonConfigStore.cs index bb738bbe..8e07b0aa 100644 --- a/src/OpenWSFZ.Config/JsonConfigStore.cs +++ b/src/OpenWSFZ.Config/JsonConfigStore.cs @@ -139,6 +139,12 @@ private static AppConfig Load(string path) if (config.DecodeNoiseSuppression is null) config = config with { DecodeNoiseSuppression = new DecodeNoiseSuppressionConfig() }; + // "externalReporting" key is absent in config files written before the + // gridtracker-udp-reporting change. Same STJ source-gen null-vs-initialiser guard as + // "logging"/"decodeLog"/"remoteAccess"/"decodeNoiseSuppression" above. + if (config.ExternalReporting is null) + config = config with { ExternalReporting = new ExternalReportingConfig() }; + // "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. @@ -208,10 +214,11 @@ private static void CreateDefault(string path) var json = JsonSerializer.Serialize( new AppConfig() with { - Cat = new CatConfig(), - Tx = new TxConfig(), - RemoteAccess = new RemoteAccessConfig(), - Decoder = new DecoderConfig(), + Cat = new CatConfig(), + Tx = new TxConfig(), + RemoteAccess = new RemoteAccessConfig(), + Decoder = new DecoderConfig(), + ExternalReporting = new ExternalReportingConfig(), }, ConfigJsonContext.Default.AppConfig); diff --git a/src/OpenWSFZ.Daemon/ExternalReportingService.cs b/src/OpenWSFZ.Daemon/ExternalReportingService.cs new file mode 100644 index 00000000..c1bdca21 --- /dev/null +++ b/src/OpenWSFZ.Daemon/ExternalReportingService.cs @@ -0,0 +1,805 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Net.Sockets; +using System.Threading.Channels; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using OpenWSFZ.Abstractions; +using OpenWSFZ.Web; + +namespace OpenWSFZ.Daemon; + +/// +/// WSJT-X-protocol UDP broadcaster and inbound command listener +/// (external-reporting capability, gridtracker-udp-reporting change). +/// +/// +/// Registered unconditionally as an (design.md Decision 1). When +/// is disabled or has no enabled targets, it opens no +/// sockets and does nothing — "inert by default," matching CatPollingService's posture +/// when cat.enabled is false. +/// +/// +/// +/// Outbound: Heartbeat and Status on a periodic timer (plus on-change for Status); Clear+Decode +/// per decode-batch cycle (fed by a dedicated channel, mirroring +/// QsoAnswererService/QsoCallerService's own dedicated decode-batch channels rather +/// than design.md's originally-sketched DecodeEventBus subscription — DecodeEventBus +/// is a one-way WebSocket broadcaster with no subscriber surface, so a third dedicated bounded +/// channel is the simpler, already-precedented mechanism); QSOLogged via +/// , called by the decorator that wraps +/// every ADIF-write call site; Close on graceful shutdown. +/// +/// +/// +/// Inbound: a single listener bound to the first enabled target's port (WSJT-X convention: the +/// app listens on the same port it sends to). Halt Tx is always honoured; Reply/Free Text are +/// gated by externalReporting.honourInboundCommands; Close is logged only and never +/// terminates the daemon; any other recognised type is discarded at Debug. +/// +/// +/// +/// and are resolved lazily via +/// (not taken as constructor parameters) to avoid a DI +/// construction cycle: both are ultimately implemented by QsoControllerRouter, which +/// depends on QsoAnswererService/QsoCallerService, which depend on +/// — the very decorator that depends on this service. +/// +/// +/// +/// Absolute, non-configurable exclusion (no exceptions): R&R-study synthetic +/// signals (NFR-021 Q-prefix convention) and unresolved (unknown-region) callsigns SHALL NEVER +/// reach an external program via this service, regardless of the operator's +/// DecodeNoiseSuppressionConfig settings — that filter gates the decode panel and QSO +/// automation only and can be disabled by the operator; this is a second, unconditional, hard +/// filter inside this class itself (, applied in +/// , , and +/// ) so the guarantee survives regardless of upstream config and +/// cannot be turned off. See design.md's "Absolute exclusion of synthetic/unknown-region traffic" +/// decision. +/// +/// +public sealed class ExternalReportingService : IHostedService, IAsyncDisposable +{ + private const string AppId = "OpenWSFZ"; + + private readonly ChannelReader _decodeChannel; + private readonly IConfigStore _configStore; + private readonly IServiceProvider _serviceProvider; + private readonly ICatState? _catState; + private readonly ICallsignRegionStore? _regionStore; + private readonly ILogger _logger; + private readonly TimeSpan _heartbeatInterval; + private readonly TimeSpan _statusPollInterval; + + // Lazily resolved on first use (see class remarks) — never in the constructor. + private IQsoController? _qsoController; + private bool _qsoControllerResolved; + private IExternalReplyTarget? _replyTarget; + private bool _replyTargetResolved; + + private IQsoController? QsoController + { + get + { + if (!_qsoControllerResolved) + { + _qsoController = _serviceProvider.GetService(); + _qsoControllerResolved = true; + } + return _qsoController; + } + } + + private IExternalReplyTarget? ReplyTarget + { + get + { + if (!_replyTargetResolved) + { + _replyTarget = _serviceProvider.GetService(); + _replyTargetResolved = true; + } + return _replyTarget; + } + } + + private readonly object _targetsLock = new(); + + // Secondary targets only — index 1+ of the enabled target list. Each keeps its own + // dedicated, outbound-only, ephemeral-port UdpClient exactly as before D-014. + private List<(ExternalReportingTarget Target, UdpClient Client)> _outboundClients = []; + + // Primary target (index 0 of the enabled target list) — D-014 Part B. Its outbound sends go + // through the shared _inboundClient socket (so a peer's reply-to-sender-port semantics work, + // per design.md's "GridTracker2 replies from the same port it received on" rationale) rather + // than a separate ephemeral client. _primaryFallbackClient is used only when _inboundClient + // failed to bind (or isn't configured), so outbound delivery to the primary target is never + // lost — only the source port it's sent from changes. + private ExternalReportingTarget? _primaryTarget; + private UdpClient? _primaryFallbackClient; + + private UdpClient? _inboundClient; + private int _inboundBoundPort = -1; + + private readonly ConcurrentDictionary _resolutionWarned = new(); + + private WsjtxDatagram.StatusFields? _lastStatus; + private DateTimeOffset _lastHeartbeatSentUtc = DateTimeOffset.MinValue; + private DateTimeOffset _lastStatusSentUtc = DateTimeOffset.MinValue; + + /// + /// Most recently received inbound Free Text, when honourInboundCommands is enabled. + /// Accepted and stored per the "Inbound Free Text gated and currently a no-op" requirement — + /// no OpenWSFZ TX state machine has a free-message slot to apply it to yet (see design.md). + /// + internal string? LastFreeText { get; private set; } + + private CancellationTokenSource? _cts; + private Task? _decodeLoopTask; + private Task? _timerLoopTask; + private Task? _inboundLoopTask; + + /// Production constructor — all dependencies from DI. + public ExternalReportingService( + ChannelReader decodeChannel, + IConfigStore configStore, + IServiceProvider serviceProvider, + ILogger logger, + ICatState? catState = null, + ICallsignRegionStore? regionStore = null) + : this(decodeChannel, configStore, serviceProvider, logger, catState, regionStore, + heartbeatInterval: TimeSpan.FromSeconds(15), + statusPollInterval: TimeSpan.FromSeconds(1)) + { + } + + /// Test constructor — allows overriding the timer cadences to avoid multi-second waits. + internal ExternalReportingService( + ChannelReader decodeChannel, + IConfigStore configStore, + IServiceProvider serviceProvider, + ILogger logger, + ICatState? catState, + ICallsignRegionStore? regionStore, + TimeSpan heartbeatInterval, + TimeSpan statusPollInterval) + { + _decodeChannel = decodeChannel; + _configStore = configStore; + _serviceProvider = serviceProvider; + _logger = logger; + _catState = catState; + _regionStore = regionStore; + _heartbeatInterval = heartbeatInterval; + _statusPollInterval = statusPollInterval; + } + + /// + /// Absolute, non-configurable exclusion gate (Captain's directive, no exceptions — see + /// design.md's "Absolute exclusion of synthetic/unknown-region traffic" decision): returns + /// true when resolves to an R&R-study synthetic entry + /// or cannot be resolved at all. A null (not wired up) is + /// treated the same as an unresolved callsign — deliberately fails closed (suppress), + /// not open, unlike most other optional-dependency null-checks in this codebase: this is a + /// data-integrity floor, not a convenience feature, and "cannot verify" must never be treated + /// as "verified real." + /// + private bool IsSuppressedCallsign(string? callsign) + { + if (string.IsNullOrWhiteSpace(callsign)) return false; // nothing to suppress + + var token = callsign.Trim(); + var slashPos = token.IndexOf('/'); + if (slashPos >= 0) token = token[..slashPos]; // strip portable suffix (/P, /M, ...) + + var region = _regionStore?.TryGetRegion(token); + return region is null || region.Synthetic; + } + + // ── IHostedService ──────────────────────────────────────────────────────── + + /// + public Task StartAsync(CancellationToken cancellationToken) + { + _cts = new CancellationTokenSource(); + // Capture the token in a local now — the three Task.Run lambdas below close over this + // local, not the mutable _cts field, so a concurrent StopAsync nulling out _cts (via + // Interlocked.Exchange) before the thread pool actually starts running a queued lambda + // can never throw a NullReferenceException reading _cts.Token from inside that lambda. + var token = _cts.Token; + Reconcile(_configStore.Current.ExternalReporting); + _configStore.OnSaved += OnConfigSaved; + + _decodeLoopTask = Task.Run(() => DecodeLoopAsync(token), CancellationToken.None); + _timerLoopTask = Task.Run(() => TimerLoopAsync(token), CancellationToken.None); + _inboundLoopTask = Task.Run(() => InboundLoopAsync(token), CancellationToken.None); + return Task.CompletedTask; + } + + /// + public async Task StopAsync(CancellationToken cancellationToken) + { + _configStore.OnSaved -= OnConfigSaved; + + var cts = Interlocked.Exchange(ref _cts, null); + if (cts is null) return; + + // Send Close to every enabled target before closing sockets (task 3.6). + await SendCloseToAllAsync().ConfigureAwait(false); + + await cts.CancelAsync().ConfigureAwait(false); + + var tasks = new[] { _decodeLoopTask, _timerLoopTask, _inboundLoopTask } + .Where(t => t is not null).Select(t => t!).ToArray(); + try + { + await Task.WhenAll(tasks).WaitAsync(TimeSpan.FromSeconds(3), CancellationToken.None) + .ConfigureAwait(false); + } + catch (TimeoutException) { /* acceptable on shutdown */ } + catch (OperationCanceledException) { /* expected */ } + + CloseAllSockets(); + cts.Dispose(); + } + + // ── IAsyncDisposable ───────────────────────────────────────────────────── + + /// + public async ValueTask DisposeAsync() + { + await StopAsync(CancellationToken.None).ConfigureAwait(false); + } + + // ── Config reconciliation (task 3.2) ──────────────────────────────────── + + private void OnConfigSaved(AppConfig config) => Reconcile(config.ExternalReporting); + + /// + /// Opens outbound s for newly-enabled secondary targets, closes ones + /// for targets no longer enabled/present, and (re)binds the single inbound listener to the + /// primary (index 0) enabled target's port — all without a daemon restart. + /// + /// + /// May be null in principle: System.Text.Json source-generation initialises a + /// non-nullable init property to null (bypassing its C# property initialiser) when the + /// corresponding JSON key is absent, the same quirk documented throughout + /// JsonConfigStore.Load for Logging/DecodeLog/RemoteAccess/ + /// DecodeNoiseSuppression. WebApp's POST /api/v1/config guards against + /// this before saving, but this defensive coalesce keeps Reconcile correct for any + /// caller that does not. + /// + private void Reconcile(ExternalReportingConfig? config) + { + config ??= new ExternalReportingConfig(); + + lock (_targetsLock) + { + var desiredEnabled = config.Enabled + ? config.Targets.Where(t => t.Enabled).ToList() + : []; + + var newPrimary = desiredEnabled.Count > 0 ? desiredEnabled[0] : null; + var secondaryTargets = desiredEnabled.Count > 1 ? desiredEnabled.Skip(1).ToList() : []; + + // ── Secondary targets (index 1+): each keeps its own dedicated ephemeral client ── + var toClose = _outboundClients.Where(c => !secondaryTargets.Contains(c.Target)).ToList(); + foreach (var c in toClose) + { + _outboundClients.Remove(c); + try { c.Client.Dispose(); } catch { /* best-effort */ } + } + + foreach (var target in secondaryTargets) + { + if (_outboundClients.Any(c => c.Target.Equals(target))) continue; + try + { + _outboundClients.Add((target, new UdpClient())); + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "external-reporting: failed to open outbound socket for target '{Name}'.", + target.Name); + } + } + + // ── Primary target (index 0): drop the stale fallback client on any target change — + // re-evaluated below against the (possibly newly re-bound) inbound socket. ── + if (!Equals(_primaryTarget, newPrimary)) + { + _primaryTarget = newPrimary; + _primaryFallbackClient?.Dispose(); + _primaryFallbackClient = null; + } + + // ── Inbound listener: bind to the primary target's port with ReuseAddress so it + // coexists with a peer (e.g. GridTracker2, which itself binds with Qt's + // ShareAddress|ReuseAddressHint) already bound there (D-014 Part A). Without this, + // the bind throws whenever the operator's mapping tool is already running — the + // normal real-world case — and Halt Tx/Reply/Free Text become silently + // unreachable. ── + var desiredInboundPort = newPrimary?.Port ?? -1; + if (desiredInboundPort != _inboundBoundPort) + { + _inboundClient?.Dispose(); + _inboundClient = null; + _inboundBoundPort = -1; + + if (desiredInboundPort > 0) + { + try + { + var client = new UdpClient(AddressFamily.InterNetwork); + client.Client.SetSocketOption( + SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); + client.Client.Bind(new IPEndPoint(IPAddress.Any, desiredInboundPort)); + _inboundClient = client; + _inboundBoundPort = desiredInboundPort; + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "external-reporting: failed to bind inbound listener on port {Port} — " + + "Halt Tx/Reply/Free Text will be unreachable until this is resolved; " + + "outbound delivery to the primary target continues via a fallback socket.", + desiredInboundPort); + } + } + } + + // ── D-014 Part B: outbound sends to the primary target go through the shared bound + // _inboundClient socket, so a peer's reply-to-sender-port semantics reach our + // inbound listener. Only fall back to a dedicated ephemeral outbound-only client + // when the shared bind is unavailable, so outbound delivery is never lost — only + // the source port it's sent from changes. ── + if (newPrimary is not null && _inboundClient is null && _primaryFallbackClient is null) + { + try + { + _primaryFallbackClient = new UdpClient(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "external-reporting: failed to open fallback outbound socket for primary target '{Name}'.", + newPrimary.Name); + } + } + else if ((newPrimary is null || _inboundClient is not null) && _primaryFallbackClient is not null) + { + _primaryFallbackClient.Dispose(); + _primaryFallbackClient = null; + } + } + } + + private bool IsOutboundActive + { + get { lock (_targetsLock) return _outboundClients.Count > 0 || _primaryTarget is not null; } + } + + // ── Outbound: decode-batch-driven Clear + Decode (tasks 3.3–3.4) ──────── + + private async Task DecodeLoopAsync(CancellationToken ct) + { + try + { + await foreach (var batch in _decodeChannel.ReadAllAsync(ct).ConfigureAwait(false)) + { + if (!IsOutboundActive) continue; + + await SendToAllEnabledAsync(WsjtxDatagram.EncodeClear(AppId)).ConfigureAwait(false); + + foreach (var r in batch.Results) + { + // Absolute guarantee (Captain's directive, no exceptions) — NOT gated by + // DecodeNoiseSuppressionConfig, NOT configurable, NOT an opt-out via any + // Settings-page control. R&R-synthetic and unknown-region decodes SHALL NEVER + // be broadcast to any external program, regardless of what the operator has + // chosen for the decode panel or the QSO controllers upstream — this is a + // second, unconditional filter inside the class that actually emits UDP + // traffic, independent of DecodeNoiseSuppressionFilter's own operator-toggleable + // gate on the shared decode-pump channel (which this service's inbound batches + // have already passed through and could, if the operator has disabled both + // suppression settings, still contain unknown-region/synthetic entries). + if (r.Region is null || r.Region.Synthetic) + { + _logger.LogDebug( + "external-reporting: suppressed outbound Decode for '{Message}' — {Reason}.", + r.Message, r.Region is null ? "unknown region" : "synthetic (R&R study)"); + continue; + } + + var fields = new WsjtxDatagram.DecodeFields( + New: true, + TimeMsSinceMidnightUtc: ParseTimeToMs(r.Time), + SnrDb: r.Snr, + DeltaTimeSeconds: r.Dt, + DeltaFrequencyHz: (uint)Math.Max(0, r.FreqHz), + Mode: "~", + Message: r.Message, + LowConfidence: false); + await SendToAllEnabledAsync(WsjtxDatagram.EncodeDecode(AppId, fields)).ConfigureAwait(false); + } + } + } + catch (OperationCanceledException) { /* shutdown */ } + } + + private static uint ParseTimeToMs(string time) + => TimeSpan.TryParseExact(time, @"hh\:mm\:ss", null, out var ts) + ? (uint)ts.TotalMilliseconds + : 0; + + // ── Outbound: Heartbeat + Status timer (task 3.3) ─────────────────────── + + private async Task TimerLoopAsync(CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + if (IsOutboundActive) + { + var now = DateTimeOffset.UtcNow; + + if (now - _lastHeartbeatSentUtc >= _heartbeatInterval) + { + _lastHeartbeatSentUtc = now; + await SendToAllEnabledAsync( + WsjtxDatagram.EncodeHeartbeat(AppId, 3, AssemblyVersion.Get(), "")) + .ConfigureAwait(false); + } + + var status = BuildStatusFields(); + var changed = _lastStatus is null || !_lastStatus.Value.Equals(status); + if (changed || now - _lastStatusSentUtc >= _heartbeatInterval) + { + _lastStatus = status; + _lastStatusSentUtc = now; + await SendToAllEnabledAsync(WsjtxDatagram.EncodeStatus(AppId, status)).ConfigureAwait(false); + } + } + + try + { + await Task.Delay(_statusPollInterval, ct).ConfigureAwait(false); + } + catch (OperationCanceledException) { break; } + } + } + + private WsjtxDatagram.StatusFields BuildStatusFields() + { + var config = _configStore.Current; + var tx = config.Tx ?? new TxConfig(); + + var dialFreqMHz = WebApp.ResolveEffectiveFrequency(_catState, config); + var dialFreqHz = (ulong)Math.Max(0, Math.Round(dialFreqMHz * 1_000_000.0)); + + var qso = QsoController; + var partner = qso?.Partner ?? ""; + var keying = qso?.Keying ?? false; + + // Absolute guarantee, Tier 2 (no exceptions): an active QSO with a synthetic or + // unresolved partner must never name that callsign to an external program in real time. + // Only DxCall/DxGrid are withheld — the rest of Status (frequency, TX/RX state, + // decoding-enabled) must keep flowing normally; its own "at least once per heartbeat + // interval" requirement is independent of who (if anyone) we're currently working. + if (IsSuppressedCallsign(partner)) + { + _logger.LogDebug( + "external-reporting: suppressed DxCall/DxGrid in outbound Status for synthetic/unknown-region partner '{Partner}'.", + partner); + partner = ""; + } + + return new WsjtxDatagram.StatusFields( + DialFrequencyHz: dialFreqHz, + Mode: "FT8", + DxCall: partner, + Report: "", + TxMode: "FT8", + TxEnabled: tx.AutoAnswer, + Transmitting: keying, + Decoding: config.DecodingEnabled, + RxDeltaFreqHz: (uint)Math.Max(0, tx.RxAudioOffsetHz), + TxDeltaFreqHz: (uint)Math.Max(0, tx.TxAudioOffsetHz), + MyCall: tx.Callsign, + MyGrid: tx.Grid, + // DX grid: not exposed via IQsoController today — left blank (best-effort; the + // requirement allows "when known from the decode"). + DxGrid: ""); + } + + // ── Outbound: QSOLogged (task 3.5) ────────────────────────────────────── + + /// + /// Called by the decorator immediately after a successful ADIF + /// write, from every call site (QsoAnswererService/QsoCallerService's direct + /// write when tx.qsoConfirmation=false, and POST /api/v1/tx/log-qso when + /// tx.qsoConfirmation=true, the default). A QSO aborted by watchdog or operator never + /// reaches an ADIF write at all, so it correctly never reaches here either (mirrors FR-051's + /// own "no record on abort" rule). + /// + internal void NotifyQsoLogged(QsoRecord record) + { + if (!IsOutboundActive) return; + + // Absolute guarantee, Tier 2 (no exceptions): a completed QSO with a synthetic or + // unresolved partner must never be reported to an external program as a real logged + // contact — mirrors the existing early-return-on-abort pattern immediately above. + if (IsSuppressedCallsign(record.PartnerCallsign)) + { + _logger.LogInformation( + "external-reporting: suppressed outbound QSOLogged for synthetic/unknown-region partner '{Partner}'.", + record.PartnerCallsign); + return; + } + + var fields = new WsjtxDatagram.QsoLoggedFields( + QsoStartUtc: new DateTimeOffset(DateTime.SpecifyKind(record.QsoStartUtc, DateTimeKind.Utc)), + QsoEndUtc: new DateTimeOffset(DateTime.SpecifyKind(record.QsoEndUtc, DateTimeKind.Utc)), + DxCall: record.PartnerCallsign, + DxGrid: record.PartnerGrid ?? "", + TxFrequencyHz: (ulong)Math.Max(0, Math.Round(record.DialFrequencyMHz * 1_000_000.0)), + Mode: "FT8", + ReportSent: record.RstSent, + ReportReceived: record.RstRcvd, + MyCall: record.OperatorCallsign, + MyGrid: record.OperatorGrid); + + _ = SendToAllEnabledAsync(WsjtxDatagram.EncodeQsoLogged(AppId, fields)); + } + + // ── Outbound send + Close-on-shutdown (task 3.6) ──────────────────────── + + private async Task SendToAllEnabledAsync(byte[] datagram) + { + List<(ExternalReportingTarget Target, UdpClient Client)> clients; + lock (_targetsLock) + { + clients = [.. _outboundClients]; + + // D-014 Part B: the primary target sends through the shared inbound socket when + // bound, falling back to the dedicated ephemeral client otherwise. + if (_primaryTarget is { } primary) + { + var primaryClient = _inboundClient ?? _primaryFallbackClient; + if (primaryClient is not null) + clients.Add((primary, primaryClient)); + } + } + + foreach (var (target, client) in clients) + { + try + { + await client.SendAsync(datagram, datagram.Length, target.Host, target.Port) + .ConfigureAwait(false); + _resolutionWarned.TryRemove(TargetKey(target), out _); + } + catch (Exception ex) when (ex is SocketException or ArgumentException or ObjectDisposedException) + { + // ObjectDisposedException: the shared _inboundClient can be disposed and + // rebound concurrently by Reconcile (D-014 Part B) — treat exactly like any + // other per-target send failure; the next tick resolves the current socket fresh. + if (_resolutionWarned.TryAdd(TargetKey(target), true)) + { + _logger.LogWarning(ex, + "external-reporting: failed to resolve/send to target '{Name}' ({Host}:{Port}) — " + + "other targets are unaffected; will keep retrying silently.", + target.Name, target.Host, target.Port); + } + } + } + } + + private static string TargetKey(ExternalReportingTarget t) => $"{t.Name}|{t.Host}|{t.Port}"; + + private async Task SendCloseToAllAsync() + { + if (!IsOutboundActive) return; + try + { + await SendToAllEnabledAsync(WsjtxDatagram.EncodeClose(AppId)).ConfigureAwait(false); + } + catch { /* best-effort on shutdown */ } + } + + private void CloseAllSockets() + { + lock (_targetsLock) + { + foreach (var (_, client) in _outboundClients) + try { client.Dispose(); } catch { /* best-effort */ } + _outboundClients.Clear(); + + _primaryTarget = null; + _primaryFallbackClient?.Dispose(); + _primaryFallbackClient = null; + + _inboundClient?.Dispose(); + _inboundClient = null; + _inboundBoundPort = -1; + } + } + + // ── Inbound listener (task 4) ──────────────────────────────────────────── + + private async Task InboundLoopAsync(CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + UdpClient? client; + lock (_targetsLock) client = _inboundClient; + + if (client is null) + { + try { await Task.Delay(250, ct).ConfigureAwait(false); } + catch (OperationCanceledException) { break; } + continue; + } + + UdpReceiveResult result; + try + { + result = await client.ReceiveAsync(ct).ConfigureAwait(false); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + break; + } + catch (ObjectDisposedException) + { + continue; // rebound/closed concurrently by Reconcile — pick up the new client + } + catch (SocketException ex) + { + _logger.LogDebug(ex, "external-reporting: inbound socket error — continuing."); + continue; + } + + HandleInboundDatagram(result.Buffer); + } + } + + /// + /// Dispatches a single decoded inbound datagram. Never throws: + /// already guarantees malformed input becomes a discarded datagram, not an exception + /// (task 4.1/2.4), and every handler below is itself exception-safe. + /// + private void HandleInboundDatagram(byte[] buffer) + { + if (!WsjtxDatagram.TryDecode(buffer, out var message) || message is null) + { + _logger.LogDebug("external-reporting: discarded a malformed inbound datagram."); + return; + } + + switch (message) + { + case WsjtxDatagram.InboundMessage.HaltTxMessage: + // Halt Tx is ALWAYS honoured — never gated by honourInboundCommands (task 4.2). + _logger.LogInformation( + "external-reporting: Halt Tx received — aborting any in-progress transmission."); + _ = HandleHaltTxAsync(); + break; + + case WsjtxDatagram.InboundMessage.ReplyMessage reply: + HandleReply(reply); + break; + + case WsjtxDatagram.InboundMessage.FreeTextMessage freeText: + HandleFreeText(freeText); + break; + + case WsjtxDatagram.InboundMessage.CloseMessage: + // Logged only — SHALL NOT terminate the daemon under any circumstance (task 4.4). + _logger.LogInformation( + "external-reporting: inbound Close received from a client — no action taken."); + break; + + case WsjtxDatagram.InboundMessage.HeartbeatMessage: + _logger.LogDebug("external-reporting: inbound Heartbeat received."); + break; + + case WsjtxDatagram.InboundMessage.UnsupportedMessage unsupported: + // task 4.5: any other recognised-but-unsupported type — Debug log only. + _logger.LogDebug( + "external-reporting: discarding unsupported inbound type {Type}.", unsupported.Type); + break; + } + } + + private async Task HandleHaltTxAsync() + { + try + { + var qso = QsoController; + if (qso is not null) + await qso.AbortAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "external-reporting: Halt Tx dispatch failed."); + } + } + + private void HandleReply(WsjtxDatagram.InboundMessage.ReplyMessage reply) + { + if (!_configStore.Current.ExternalReporting.HonourInboundCommands) + { + _logger.LogInformation( + "external-reporting: Reply received but honourInboundCommands is disabled — ignoring."); + return; + } + + if (!TryExtractCallsign(reply.Message, out var callsign)) + { + _logger.LogInformation( + "external-reporting: Reply received but no callsign could be extracted from '{Message}' — ignoring.", + reply.Message); + return; + } + + var target = ReplyTarget; + if (target is null) + { + _logger.LogWarning( + "external-reporting: Reply received for '{Callsign}' but no IExternalReplyTarget is wired up — ignoring.", + callsign); + return; + } + + _ = target.TryEngageAsync(callsign).ContinueWith(t => + { + if (t.IsFaulted) + _logger.LogWarning(t.Exception, "external-reporting: TryEngageAsync for '{Callsign}' faulted.", callsign); + }, CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted, TaskScheduler.Default); + } + + /// + /// Extracts the target callsign from a Reply datagram's echoed message text. Tries the CQ + /// pattern first (the common case: the operator selected a CQ line in GridTracker2); falls + /// back to the second whitespace-separated token ("DEST SRC ..." — the source + /// callsign of a non-CQ directed message). + /// + private static bool TryExtractCallsign(string message, out string callsign) + { + if (QsoAnswererService.TryParseCq(message, out var cq, out _)) + { + callsign = cq; + return true; + } + + var parts = message.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length >= 2) + { + callsign = parts[1]; + return true; + } + + callsign = string.Empty; + return false; + } + + private void HandleFreeText(WsjtxDatagram.InboundMessage.FreeTextMessage freeText) + { + if (!_configStore.Current.ExternalReporting.HonourInboundCommands) + { + _logger.LogInformation( + "external-reporting: Free Text received but honourInboundCommands is disabled — ignoring."); + return; + } + + // Accepted and stored; intentionally has NO transmission effect (see design.md / + // "Inbound Free Text gated and currently a no-op"). + LastFreeText = freeText.Text; + _logger.LogInformation( + "external-reporting: Free Text received and stored (no transmission effect): '{Text}'.", + freeText.Text); + } +} diff --git a/src/OpenWSFZ.Daemon/Program.cs b/src/OpenWSFZ.Daemon/Program.cs index dd9b88cc..5f9bc31f 100644 --- a/src/OpenWSFZ.Daemon/Program.cs +++ b/src/OpenWSFZ.Daemon/Program.cs @@ -232,6 +232,17 @@ void ConfigureLogging(ILoggingBuilder lb) SingleReader = true, }); +// Channel 3c: decode pump → ExternalReportingService (bounded, DropOldest — same rationale as +// the two channels above). Dedicated rather than a DecodeEventBus subscription (design.md +// originally sketched the latter, but DecodeEventBus is a one-way WebSocket broadcaster with no +// subscriber surface) — mirrors the existing per-consumer decode-batch-channel pattern exactly. +var externalReportingChannel = Channel.CreateBounded(new BoundedChannelOptions(2) +{ + FullMode = BoundedChannelFullMode.DropOldest, + SingleWriter = true, + SingleReader = true, +}); + var framerOutput = Channel.CreateBounded<(float[] Pcm, DateTime CycleStart, double? DialFrequencyMHz)>(new BoundedChannelOptions(2) { FullMode = BoundedChannelFullMode.DropOldest, @@ -426,7 +437,34 @@ void ConfigureLogging(ILoggingBuilder lb) services.AddSingleton(new TxEventBus(appScope)); services.AddSingleton(new AudioOffsetEventBus(appScope)); services.AddSingleton(); - services.AddSingleton(sp => sp.GetRequiredService()); + + // gridtracker-udp-reporting: ExternalReportingService registered unconditionally + // (inert by default — opens no sockets until externalReporting.enabled=true with at + // least one enabled target). Reads decode batches from its own dedicated channel + // (declared above, fed by the decode pump below) and is resolved lazily by + // QsoLoggedNotifyingAdifWriter and by IQsoController/IExternalReplyTarget consumers + // via IServiceProvider (see ExternalReportingService's class remarks for why this must + // be lazy rather than a constructor dependency — it avoids a DI construction cycle + // through IAdifLogWriter below). + // ICallsignRegionStore is passed so the absolute, non-configurable synthetic/unknown- + // region exclusion (Captain's directive — see ExternalReportingService's class remarks) + // can resolve a bare partner callsign's region for Status/QSOLogged, which (unlike + // outbound Decode) carry only a callsign string with no pre-resolved DecodeResult.Region. + services.AddSingleton(sp => new ExternalReportingService( + externalReportingChannel.Reader, + sp.GetRequiredService(), + sp, + sp.GetRequiredService>(), + sp.GetService(), + sp.GetService())); + services.AddHostedService(sp => sp.GetRequiredService()); + + // IAdifLogWriter resolves to a decorator so every ADIF write (direct-write path AND + // WebApp's POST /api/v1/tx/log-qso) also emits an outbound QSOLogged datagram — see + // QsoLoggedNotifyingAdifWriter's class remarks. + services.AddSingleton(sp => new QsoLoggedNotifyingAdifWriter( + sp.GetRequiredService(), + sp.GetRequiredService())); // H6 AP decode (D-001): register the ft8Decoder instance as IApConstraintSink so // the active QSO controller can arm/disarm AP constraints during active QSO sessions. @@ -445,7 +483,7 @@ void ConfigureLogging(ILoggingBuilder lb) sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), - sp.GetRequiredService(), + sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService>(), sp.GetService(), @@ -462,7 +500,7 @@ void ConfigureLogging(ILoggingBuilder lb) sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), - sp.GetRequiredService(), + sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService>(), sp.GetService(), @@ -479,6 +517,9 @@ void ConfigureLogging(ILoggingBuilder lb) services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(sp => sp.GetRequiredService()); + // IExternalReplyTarget (gridtracker-udp-reporting): the inbound Reply handler in + // ExternalReportingService resolves this to route by whichever role is active. + services.AddSingleton(sp => sp.GetRequiredService()); // Both services run as HostedServices; the inactive one discards batches cheaply. services.AddHostedService(sp => sp.GetRequiredService()); @@ -562,6 +603,7 @@ void ConfigureLogging(ILoggingBuilder lb) var batch = new DecodeBatch(new DateTimeOffset(cycleStart, TimeSpan.Zero), visibleResults); qsoAnswererChannel.Writer.TryWrite(batch); qsoCallerChannel.Writer.TryWrite(batch); + externalReportingChannel.Writer.TryWrite(batch); } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { diff --git a/src/OpenWSFZ.Daemon/QsoAnswererService.cs b/src/OpenWSFZ.Daemon/QsoAnswererService.cs index 35c6f4b5..70a39305 100644 --- a/src/OpenWSFZ.Daemon/QsoAnswererService.cs +++ b/src/OpenWSFZ.Daemon/QsoAnswererService.cs @@ -88,6 +88,12 @@ public sealed class QsoAnswererService : BackgroundService, IQsoController private bool _skipNextRetry = false; // A-01: true after entering WaitReport/WaitRr73 — skip the first empty cycle (our own TX window) private DateTime _qsoStartUtc = DateTime.MinValue; + // gridtracker-udp-reporting: the most recently observed decode batch while Idle, consulted + // by TryEngageExternal to validate that an external Reply's callsign is a currently-decoded, + // non-filtered-out CQ. Volatile: written on the background loop thread, read from whichever + // thread the inbound UDP listener dispatches TryEngageExternal on. + private volatile DecodeBatch? _lastIdleDecodeBatch; + // Phase-aware pending-target for AnswerCqAsync (TX-D01). // All four fields are read/written under _stateLock; volatile _state is checked inside // the lock but may also be read outside (HTTP thread) without a lock as before. @@ -276,11 +282,114 @@ public async Task AbortAsync(CancellationToken ct = default) /// public async Task AnswerCqAsync( string callsign, double frequencyHz, DateTimeOffset cqCycleStart, CancellationToken ct) + { + if (!ArmPendingTarget(callsign, frequencyHz, cqCycleStart)) + return; // HTTP layer already returned 409; this is a safety guard + + // Arm the system — set AutoAnswer so the guard in HandleIdleAsync passes. + try + { + var current = _configStore.Current; + var tx = current.Tx ?? new TxConfig(); + await _configStore.SaveAsync( + current with { Tx = tx with { AutoAnswer = true } }, ct) + .ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "AnswerCqAsync: failed to save autoAnswer=true — ignoring."); + } + } + + /// + /// External reply engages a specific decoded CQ (external-reporting capability's + /// inbound Reply command, gridtracker-udp-reporting change). Reuses the same + /// CQ-matching//empty-callsign guards as the automatic + /// auto-answer path, but targets specifically instead of "first + /// CQ in the batch," and is not gated by tx.autoAnswer — an explicit + /// external reply is a one-shot manual instruction, not automatic behaviour. + /// + /// Implements specs/qso-answerer/spec.md's "External reply engages a specific decoded CQ". + public Task TryEngageExternal(string callsign, CancellationToken ct = default) + { + if (_state != QsoState.Idle) + { + _logger.LogInformation( + "TryEngageExternal: ignoring external reply for '{Callsign}' — not Idle (state={State}).", + callsign, _state); + return Task.FromResult(false); + } + + var tx = _configStore.Current.Tx ?? new TxConfig(); + if (string.IsNullOrWhiteSpace(tx.Callsign) || string.IsNullOrWhiteSpace(tx.Grid)) + { + _logger.LogInformation( + "TryEngageExternal: ignoring external reply for '{Callsign}' — operator callsign/grid not configured.", + callsign); + return Task.FromResult(false); + } + + var batch = _lastIdleDecodeBatch; + if (batch is null) + { + _logger.LogInformation( + "TryEngageExternal: ignoring external reply for '{Callsign}' — no decode batch received yet.", + callsign); + return Task.FromResult(false); + } + + var filterState = _decodeFilterStore?.Current ?? DecodeFilterState.Unfiltered; + + foreach (var r in batch.Results) + { + if (!TryParseCq(r.Message, out var parsedCallsign, out _)) + continue; + if (!string.Equals(parsedCallsign, callsign, StringComparison.OrdinalIgnoreCase)) + continue; + + if (!DecodeFilterEvaluator.IsVisible(r, filterState)) + { + _logger.LogInformation( + "TryEngageExternal: ignoring external reply for '{Callsign}' — filtered out under the active decode filter.", + callsign); + return Task.FromResult(false); + } + + if (!ArmPendingTarget(parsedCallsign, r.FreqHz, batch.CycleStart)) + { + _logger.LogInformation( + "TryEngageExternal: ignoring external reply for '{Callsign}' — state changed concurrently.", + callsign); + return Task.FromResult(false); + } + + _logger.LogInformation( + "TryEngageExternal: engaging '{Callsign}' at {FreqHz} Hz via external reply.", + callsign, r.FreqHz); + return Task.FromResult(true); + } + + _logger.LogInformation( + "TryEngageExternal: ignoring external reply — '{Callsign}' is not present as a CQ in the most recent decode batch.", + callsign); + return Task.FromResult(false); + } + + /// + /// Arms the phase-aware pending TX target shared by and + /// : computes the opposite phase to + /// , stores it under (only while + /// still ), and pushes a wakeup batch so the background loop can + /// fire TX within the current cycle if the call arrives while the correct phase is already + /// active (D-TX-UI-007). Returns false without arming anything if the service is not + /// . + /// + private bool ArmPendingTarget(string callsign, double frequencyHz, DateTimeOffset cqCycleStart) { lock (_stateLock) { if (_state != QsoState.Idle) - return; // HTTP layer already returned 409; this is a safety guard + return false; // CQ was on phase P → answer on the opposite phase bool cqIsAPhase = IsAPhase(cqCycleStart); @@ -299,20 +408,7 @@ public async Task AnswerCqAsync( // evaluated (see HandleIdleAsync phase check below). var wakeupCycleStart = RoundDownTo15s(DateTimeOffset.UtcNow) - TimeSpan.FromSeconds(15); _wakeupChannel.Writer.TryWrite(new DecodeBatch(wakeupCycleStart, [])); - - // Arm the system — set AutoAnswer so the guard in HandleIdleAsync passes. - try - { - var current = _configStore.Current; - var tx = current.Tx ?? new TxConfig(); - await _configStore.SaveAsync( - current with { Tx = tx with { AutoAnswer = true } }, ct) - .ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "AnswerCqAsync: failed to save autoAnswer=true — ignoring."); - } + return true; } // ── BackgroundService ───────────────────────────────────────────────────── @@ -457,6 +553,10 @@ private async Task HandleIdleAsync( TxConfig tx, CancellationToken stoppingToken) { + // gridtracker-udp-reporting: record this batch regardless of IsActive so an external + // Reply arriving shortly after a role switch still has fresh data to validate against. + _lastIdleDecodeBatch = batch; + // Router guard: when the active role is Caller, the answerer must not initiate // any new QSO session (even if AutoAnswer or a pending target is set). if (!IsActive) return; diff --git a/src/OpenWSFZ.Daemon/QsoCallerService.cs b/src/OpenWSFZ.Daemon/QsoCallerService.cs index 02142553..1e99bc83 100644 --- a/src/OpenWSFZ.Daemon/QsoCallerService.cs +++ b/src/OpenWSFZ.Daemon/QsoCallerService.cs @@ -305,6 +305,56 @@ public Task SelectResponderAsync( return Task.CompletedTask; } + /// + /// External reply engages a specific responder to our CQ (external-reporting + /// capability's inbound Reply command, gridtracker-udp-reporting change). Reuses the + /// existing, unmodified seam (design.md Decision 4) with + /// a frequency resolved from the most recently observed decode of this callsign responding + /// to our CQ (, populated by + /// 's None-mode responder tracking). + /// + /// + /// Unlike (which has five dedicated + /// scenarios in specs/qso-answerer/spec.md), this Caller-role path is not covered by + /// a formal delta-spec requirement — proposal.md describes it only as "reused, + /// unmodified." 's cancellation cannot itself report a false return + /// from (which returns , not + /// ) — this method returns true once dispatched to a + /// responder observed this session, optimistically, mirroring how the HTTP + /// select-responder endpoint already treats the call as fire-and-forget. + /// + internal async Task TryEngageExternalResponder(string callsign, CancellationToken ct = default) + { + DecodeResult? recent; + lock (_stateLock) + { + if (_callerState != CallerState.WaitAnswer) + { + _logger.LogInformation( + "TryEngageExternalResponder: ignoring external reply for '{Callsign}' — not WaitAnswer (state={State}).", + callsign, _callerState); + return false; + } + _recentResponderDecodes.TryGetValue(callsign, out recent); + } + + if (recent is null) + { + _logger.LogInformation( + "TryEngageExternalResponder: ignoring external reply — '{Callsign}' has not been observed responding to our CQ.", + callsign); + return false; + } + + // Approximate the response cycle as the most recently completed 15 s FT8 window: the + // observed decode was necessarily seen within that window or the current one. Unlike the + // HTTP select-responder endpoint (which receives the exact cycle timestamp from the + // browser's own WS-delivered decode data), an external command only carries a callsign. + var responseCycleStart = RoundDownTo15s(DateTimeOffset.UtcNow) - TimeSpan.FromSeconds(15); + await SelectResponderAsync(callsign, recent.FreqHz, responseCycleStart, ct).ConfigureAwait(false); + return true; + } + /// /// /// Not implemented: always delegates diff --git a/src/OpenWSFZ.Daemon/QsoControllerRouter.cs b/src/OpenWSFZ.Daemon/QsoControllerRouter.cs index 148a8f48..4cebe6c8 100644 --- a/src/OpenWSFZ.Daemon/QsoControllerRouter.cs +++ b/src/OpenWSFZ.Daemon/QsoControllerRouter.cs @@ -28,7 +28,7 @@ namespace OpenWSFZ.Daemon; /// . /// /// -public sealed class QsoControllerRouter : IQsoController, IQsoRoleSwitcher +public sealed class QsoControllerRouter : IQsoController, IQsoRoleSwitcher, IExternalReplyTarget { private readonly QsoAnswererService _answerer; private readonly QsoCallerService _caller; @@ -142,6 +142,20 @@ public Task EngageAtAsync( return _answerer.EngageAtAsync(partnerCallsign, frequencyHz, theirCycleStart, point, ct); } + // ── IExternalReplyTarget (external-reporting, gridtracker-udp-reporting) ────── + + /// + /// + /// Routes by the currently ACTIVE role (not the configured role), consistent with + /// 's own remarks: when the active role is Answerer, delegates to + /// ; when Caller, delegates to + /// . + /// + public Task TryEngageAsync(string callsign, CancellationToken ct = default) + => _activeRole == QsoRole.Caller + ? _caller.TryEngageExternalResponder(callsign, ct) + : _answerer.TryEngageExternal(callsign, ct); + // ── Router-specific ─────────────────────────────────────────────────────── /// diff --git a/src/OpenWSFZ.Daemon/QsoLoggedNotifyingAdifWriter.cs b/src/OpenWSFZ.Daemon/QsoLoggedNotifyingAdifWriter.cs new file mode 100644 index 00000000..99d53860 --- /dev/null +++ b/src/OpenWSFZ.Daemon/QsoLoggedNotifyingAdifWriter.cs @@ -0,0 +1,32 @@ +using OpenWSFZ.Abstractions; + +namespace OpenWSFZ.Daemon; + +/// +/// Decorates the real so every successful call to +/// also notifies +/// to emit an outbound WSJT-X QSOLogged datagram (external-reporting capability, +/// "Outbound QSOLogged message" requirement). +/// +/// +/// This is the single choke point every ADIF-write call site goes through: +/// QsoAnswererService's/QsoCallerService's direct-write path +/// (tx.qsoConfirmation=false) and WebApp's POST /api/v1/tx/log-qso +/// (tx.qsoConfirmation=true, the default) both resolve from +/// DI — decorating the interface registration, rather than hooking each call site individually, +/// guarantees coverage without risking a missed site. A QSO aborted by watchdog or operator never +/// calls at all, so it correctly never triggers a QSOLogged datagram +/// either (mirrors FR-051's own "no record on abort" rule). +/// +/// +public sealed class QsoLoggedNotifyingAdifWriter( + IAdifLogWriter inner, + ExternalReportingService externalReporting) : IAdifLogWriter +{ + /// + public async Task AppendQsoAsync(QsoRecord record) + { + await inner.AppendQsoAsync(record).ConfigureAwait(false); + externalReporting.NotifyQsoLogged(record); + } +} diff --git a/src/OpenWSFZ.Daemon/WsjtxDatagram.cs b/src/OpenWSFZ.Daemon/WsjtxDatagram.cs new file mode 100644 index 00000000..9d4c4d85 --- /dev/null +++ b/src/OpenWSFZ.Daemon/WsjtxDatagram.cs @@ -0,0 +1,455 @@ +using System.Buffers.Binary; +using System.Text; + +namespace OpenWSFZ.Daemon; + +/// +/// Byte-compatible (de)serialisation of the WSJT-X UDP network protocol +/// (NetworkMessage.hpp in the WSJT-X source: a quint32 magic number + schema +/// version header, big-endian primitives, length-prefixed UTF-8 strings) for the message subset +/// this daemon implements (external-reporting capability, gridtracker-udp-reporting +/// change). Operates purely on primitive fields passed in and out — no dependency on any other +/// OpenWSFZ type (design.md Decision 5); all OpenWSFZ-specific field mapping lives one layer up, +/// in . +/// +/// +/// Provenance note: this is a third-party wire format with no negotiation and +/// no OpenWSFZ-side flexibility. The header framing, Heartbeat, Clear, and +/// Close layouts are simple and well established. The richer message types +/// (Status, Decode, QSOLogged, Reply, Halt Tx, +/// Free Text) are implemented from the publicly documented protocol shape without a live +/// WSJT-X/GridTracker2 wire capture to verify against byte-for-byte (task 2.6 is optional and +/// was not available in this environment) — treat those layouts as best-effort until confirmed +/// against a real capture or a live GridTracker2 session. +/// +/// +internal static class WsjtxDatagram +{ + /// WSJT-X protocol magic number (NetworkMessage::Magic). + public const uint Magic = 0xadbccbda; + + /// Schema version this implementation encodes and accepts on decode. + public const uint SchemaVersion = 2; + + /// WSJT-X protocol message type discriminator (NetworkMessage::MessageType). + public enum MessageType : uint + { + Heartbeat = 0, + Status = 1, + Decode = 2, + Clear = 3, + Reply = 4, + QsoLogged = 5, + Close = 6, + Replay = 7, + HaltTx = 8, + FreeText = 9, + WsprDecode = 10, + Location = 11, + LoggedAdif = 12, + HighlightCallsignInList = 13, + SwitchConfiguration = 14, + Configure = 15, + } + + // ── Outbound field payloads ───────────────────────────────────────────────── + + /// Fields for an outbound Status datagram (Requirement: Outbound Status message). + public readonly record struct StatusFields( + ulong DialFrequencyHz, + string Mode, + string DxCall, + string Report, + string TxMode, + bool TxEnabled, + bool Transmitting, + bool Decoding, + uint RxDeltaFreqHz, + uint TxDeltaFreqHz, + string MyCall, + string MyGrid, + string DxGrid); + + /// Fields for an outbound Decode datagram (Requirement: Outbound Decode message). + public readonly record struct DecodeFields( + bool New, + uint TimeMsSinceMidnightUtc, + int SnrDb, + double DeltaTimeSeconds, + uint DeltaFrequencyHz, + string Mode, + string Message, + bool LowConfidence); + + /// Fields for an outbound QSOLogged datagram (Requirement: Outbound QSOLogged message). + public readonly record struct QsoLoggedFields( + DateTimeOffset QsoStartUtc, + DateTimeOffset QsoEndUtc, + string DxCall, + string DxGrid, + ulong TxFrequencyHz, + string Mode, + string ReportSent, + string ReportReceived, + string MyCall, + string MyGrid); + + // ── Inbound decode result ─────────────────────────────────────────────────── + + /// Base type for a successfully-parsed inbound datagram's payload. + public abstract record InboundMessage + { + private InboundMessage() { } + + /// Client heartbeat (rarely sent inbound, but structurally identical to outbound). + public sealed record HeartbeatMessage(int MaxSchemaNumber, string Version, string Revision) : InboundMessage; + + /// + /// Operator selected a decoded line to reply to. is the raw decoded + /// text (e.g. "CQ Q1TST JO22") — the caller extracts the callsign from it. + /// + public sealed record ReplyMessage(string Message, uint DeltaFrequencyHz) : InboundMessage; + + /// Halt Tx — abort any in-progress transmission (always honoured; see spec). + public sealed record HaltTxMessage(bool AutoTxOnly) : InboundMessage; + + /// Free Text — accepted and stored; no transmission effect (see design.md). + public sealed record FreeTextMessage(string Text, bool Send) : InboundMessage; + + /// Client requested close — logged only; never terminates the daemon. + public sealed record CloseMessage : InboundMessage; + + /// + /// A well-formed but unimplemented message type (Replay, Location, Highlight Callsign, + /// Switch Configuration, Configure, WSPR Decode, or any type outside this protocol + /// version's known set). Parsed only far enough to identify the type; the body is not + /// interpreted. + /// + public sealed record UnsupportedMessage(MessageType Type) : InboundMessage; + } + + // ── Encoding ───────────────────────────────────────────────────────────────── + + public static byte[] EncodeHeartbeat(string id, int maxSchemaNumber, string version, string revision) + { + var w = new Writer(); + WriteHeader(w, MessageType.Heartbeat, id); + w.WriteInt32(maxSchemaNumber); + w.WriteString(version); + w.WriteString(revision); + return w.ToArray(); + } + + public static byte[] EncodeStatus(string id, in StatusFields f) + { + var w = new Writer(); + WriteHeader(w, MessageType.Status, id); + w.WriteUInt64(f.DialFrequencyHz); + w.WriteString(f.Mode); + w.WriteString(f.DxCall); + w.WriteString(f.Report); + w.WriteString(f.TxMode); + w.WriteBool(f.TxEnabled); + w.WriteBool(f.Transmitting); + w.WriteBool(f.Decoding); + w.WriteUInt32(f.RxDeltaFreqHz); + w.WriteUInt32(f.TxDeltaFreqHz); + w.WriteString(f.MyCall); + w.WriteString(f.MyGrid); + w.WriteString(f.DxGrid); + // Trailing fields present in later WSJT-X schema revisions — fixed, reasonable defaults + // so a fixed-order receiver parser stays aligned for the rest of this message. + w.WriteBool(false); // TXWatchdog + w.WriteString("FT8"); // SubMode + w.WriteBool(false); // FastMode + w.WriteByte(0); // SpecialOperationMode + w.WriteUInt32(0); // FrequencyTolerance (not applicable) + w.WriteUInt32(15); // TRPeriod (seconds) + w.WriteString("OpenWSFZ"); // ConfigurationName + w.WriteString(""); // TXMessage + return w.ToArray(); + } + + public static byte[] EncodeDecode(string id, in DecodeFields f) + { + var w = new Writer(); + WriteHeader(w, MessageType.Decode, id); + w.WriteBool(f.New); + w.WriteUInt32(f.TimeMsSinceMidnightUtc); + w.WriteInt32(f.SnrDb); + w.WriteDouble(f.DeltaTimeSeconds); + w.WriteUInt32(f.DeltaFrequencyHz); + w.WriteString(f.Mode); + w.WriteString(f.Message); + w.WriteBool(f.LowConfidence); + w.WriteBool(false); // OffAir + return w.ToArray(); + } + + public static byte[] EncodeClear(string id) + { + var w = new Writer(); + WriteHeader(w, MessageType.Clear, id); + return w.ToArray(); + } + + public static byte[] EncodeQsoLogged(string id, in QsoLoggedFields f) + { + var w = new Writer(); + WriteHeader(w, MessageType.QsoLogged, id); + w.WriteDateTimeUtc(f.QsoEndUtc); + w.WriteString(f.DxCall); + w.WriteString(f.DxGrid); + w.WriteUInt64(f.TxFrequencyHz); + w.WriteString(f.Mode); + w.WriteString(f.ReportSent); + w.WriteString(f.ReportReceived); + w.WriteString(""); // TXPower — not tracked at this call site + w.WriteString(""); // Comments + w.WriteString(""); // Name + w.WriteDateTimeUtc(f.QsoStartUtc); + w.WriteString(f.MyCall); // OperatorCall + w.WriteString(f.MyCall); // MyCall + w.WriteString(f.MyGrid); // MyGrid + w.WriteString(""); // ExchangeSent + w.WriteString(""); // ExchangeRcvd + w.WriteString(""); // ADIF propagation mode + return w.ToArray(); + } + + public static byte[] EncodeClose(string id) + { + var w = new Writer(); + WriteHeader(w, MessageType.Close, id); + return w.ToArray(); + } + + private static void WriteHeader(Writer w, MessageType type, string id) + { + w.WriteUInt32(Magic); + w.WriteUInt32(SchemaVersion); + w.WriteUInt32((uint)type); + w.WriteString(id); + } + + // ── Decoding ───────────────────────────────────────────────────────────────── + + /// + /// Attempts to decode an inbound datagram. Never throws: any parse failure (too short, bad + /// magic number, unsupported schema version, truncated field) returns false with + /// set to null — the caller SHALL discard the datagram and + /// continue listening (Requirement: Inbound listener never crashes on malformed input). + /// + public static bool TryDecode(ReadOnlySpan data, out InboundMessage? message) + { + message = null; + try + { + var r = new Reader(data); + + var magic = r.ReadUInt32(); + if (magic != Magic) return false; + + var schema = r.ReadUInt32(); + if (schema is not (1 or 2 or 3)) return false; // unsupported schema version + + var typeRaw = r.ReadUInt32(); + _ = r.ReadString(); // Id — consumed to stay aligned; not currently consulted + + if (!Enum.IsDefined(typeof(MessageType), typeRaw)) + { + message = new InboundMessage.UnsupportedMessage((MessageType)typeRaw); + return true; + } + + var type = (MessageType)typeRaw; + message = type switch + { + MessageType.Heartbeat => DecodeHeartbeat(ref r), + MessageType.Reply => DecodeReply(ref r), + MessageType.HaltTx => DecodeHaltTx(ref r), + MessageType.FreeText => DecodeFreeText(ref r), + MessageType.Close => new InboundMessage.CloseMessage(), + _ => new InboundMessage.UnsupportedMessage(type), + }; + return true; + } + catch (Exception ex) when (ex is EndOfStreamException or ArgumentOutOfRangeException + or IndexOutOfRangeException or OverflowException + or DecoderFallbackException) + { + message = null; + return false; + } + } + + private static InboundMessage DecodeHeartbeat(ref Reader r) + { + var maxSchema = r.ReadInt32(); + var version = r.ReadString() ?? ""; + var revision = r.ReadString() ?? ""; + return new InboundMessage.HeartbeatMessage(maxSchema, version, revision); + } + + /// + /// The inbound Reply datagram has the same field layout as the outbound Decode datagram + /// (WSJT-X echoes back the selected decode line's fields). Only Message (to extract + /// the callsign from) and DeltaFrequencyHz are currently consulted by this daemon. + /// + private static InboundMessage DecodeReply(ref Reader r) + { + _ = r.TryReadBool(out _); // New + _ = r.ReadUInt32(); // Time + _ = r.ReadInt32(); // SNR + _ = r.ReadDouble(); // DeltaTime + var deltaFreq = r.ReadUInt32(); // DeltaFrequency + _ = r.ReadString(); // Mode + var msg = r.ReadString() ?? ""; // Message + _ = r.TryReadBool(out _); // LowConfidence + return new InboundMessage.ReplyMessage(msg, deltaFreq); + } + + private static InboundMessage DecodeHaltTx(ref Reader r) + { + var autoTxOnly = r.TryReadBool(out var v) && v; + return new InboundMessage.HaltTxMessage(autoTxOnly); + } + + private static InboundMessage DecodeFreeText(ref Reader r) + { + var text = r.ReadString() ?? ""; + var send = r.TryReadBool(out var s) && s; // Send flag — optional across schema revisions + return new InboundMessage.FreeTextMessage(text, send); + } + + // ── Low-level big-endian writer ────────────────────────────────────────────── + + private sealed class Writer + { + private readonly List _buf = new(64); + + public void WriteByte(byte v) => _buf.Add(v); + + public void WriteBool(bool v) => _buf.Add(v ? (byte)1 : (byte)0); + + public void WriteInt32(int v) => WriteUInt32(unchecked((uint)v)); + + public void WriteUInt32(uint v) + { + Span b = stackalloc byte[4]; + BinaryPrimitives.WriteUInt32BigEndian(b, v); + _buf.AddRange(b.ToArray()); + } + + public void WriteInt64(long v) + { + Span b = stackalloc byte[8]; + BinaryPrimitives.WriteInt64BigEndian(b, v); + _buf.AddRange(b.ToArray()); + } + + public void WriteUInt64(ulong v) + { + Span b = stackalloc byte[8]; + BinaryPrimitives.WriteUInt64BigEndian(b, v); + _buf.AddRange(b.ToArray()); + } + + public void WriteDouble(double v) + { + Span b = stackalloc byte[8]; + BinaryPrimitives.WriteInt64BigEndian(b, BitConverter.DoubleToInt64Bits(v)); + _buf.AddRange(b.ToArray()); + } + + /// + /// Writes a WSJT-X-protocol string: a big-endian quint32 byte length followed by + /// UTF-8 bytes; 0xFFFFFFFF encodes a null string. + /// + public void WriteString(string? s) + { + if (s is null) + { + WriteUInt32(0xFFFFFFFF); + return; + } + + var bytes = Encoding.UTF8.GetBytes(s); + WriteUInt32((uint)bytes.Length); + _buf.AddRange(bytes); + } + + /// + /// Writes a UTC in Qt's QDateTime QDataStream + /// wire format (schema ≥ Qt 5.2): qint64 Julian day number, qint32 + /// milliseconds since midnight, quint8 time spec (1 = UTC, always used here). + /// + public void WriteDateTimeUtc(DateTimeOffset dt) + { + var utc = dt.UtcDateTime; + var julianDay = ToJulianDayNumber(utc.Year, utc.Month, utc.Day); + var msecsSinceMidnight = (int)utc.TimeOfDay.TotalMilliseconds; + WriteInt64(julianDay); + WriteInt32(msecsSinceMidnight); + WriteByte(1); // Qt::UTC + } + + /// + /// Gregorian-calendar Julian Day Number for // + /// , matching Qt's own julianDayFromDate algorithm. + /// + private static long ToJulianDayNumber(int y, int m, int d) + { + long a = (14 - m) / 12; + long yy = y + 4800 - a; + long mm = m + 12 * a - 3; + return d + (153 * mm + 2) / 5 + 365 * yy + yy / 4 - yy / 100 + yy / 400 - 32045; + } + + public byte[] ToArray() => _buf.ToArray(); + } + + // ── Low-level big-endian reader ────────────────────────────────────────────── + + private ref struct Reader + { + private readonly ReadOnlySpan _data; + private int _pos; + + public Reader(ReadOnlySpan data) + { + _data = data; + _pos = 0; + } + + private ReadOnlySpan Take(int count) + { + if (count < 0 || _pos + count > _data.Length) throw new EndOfStreamException(); + var slice = _data.Slice(_pos, count); + _pos += count; + return slice; + } + + public bool TryReadBool(out bool value) + { + if (_pos + 1 > _data.Length) { value = false; return false; } + value = Take(1)[0] != 0; + return true; + } + + public uint ReadUInt32() => BinaryPrimitives.ReadUInt32BigEndian(Take(4)); + + public int ReadInt32() => BinaryPrimitives.ReadInt32BigEndian(Take(4)); + + public double ReadDouble() => BitConverter.Int64BitsToDouble(BinaryPrimitives.ReadInt64BigEndian(Take(8))); + + public string? ReadString() + { + var len = ReadUInt32(); + if (len == 0xFFFFFFFF) return null; + if (len == 0) return string.Empty; + var bytes = Take(checked((int)len)); + return Encoding.UTF8.GetString(bytes); + } + } +} diff --git a/src/OpenWSFZ.Web/AppJsonContext.cs b/src/OpenWSFZ.Web/AppJsonContext.cs index d3267a31..475a23fc 100644 --- a/src/OpenWSFZ.Web/AppJsonContext.cs +++ b/src/OpenWSFZ.Web/AppJsonContext.cs @@ -62,6 +62,9 @@ namespace OpenWSFZ.Web; [JsonSerializable(typeof(RegionLookupResponse))] [JsonSerializable(typeof(DecodeFilterState))] [JsonSerializable(typeof(WsDecodeFilterMessage))] +[JsonSerializable(typeof(ExternalReportingConfig))] +[JsonSerializable(typeof(ExternalReportingTarget))] +[JsonSerializable(typeof(List))] internal sealed partial class AppJsonContext : JsonSerializerContext { } /// Envelope for status WebSocket text frames. diff --git a/src/OpenWSFZ.Web/IExternalReplyTarget.cs b/src/OpenWSFZ.Web/IExternalReplyTarget.cs new file mode 100644 index 00000000..39512d09 --- /dev/null +++ b/src/OpenWSFZ.Web/IExternalReplyTarget.cs @@ -0,0 +1,31 @@ +namespace OpenWSFZ.Web; + +/// +/// Routes an inbound WSJT-X-protocol Reply command (external-reporting capability, +/// gridtracker-udp-reporting change) to whichever QSO role service is currently active. +/// Implemented by QsoControllerRouter in OpenWSFZ.Daemon. +/// +/// +/// Defined in OpenWSFZ.Web (rather than OpenWSFZ.Abstractions), mirroring +/// , so the inbound UDP listener (in OpenWSFZ.Daemon) can +/// resolve this via DI without introducing a project reference cycle, and so a future consumer +/// in OpenWSFZ.Web itself (there is none today) could resolve it the same way. +/// +/// +public interface IExternalReplyTarget +{ + /// + /// Attempts to engage exactly as an operator manually selecting + /// that station would. Delegates to QsoAnswererService.TryEngageExternal when the + /// active role is Answerer, or to the existing, unmodified + /// QsoCallerService.SelectResponderAsync seam when the active role is Caller. + /// + /// Callsign named by the inbound Reply datagram. + /// Cancellation token. + /// + /// true if the callsign was engaged; false if it does not correspond to a + /// currently engageable station (unknown, filtered out, or the service is not in a state + /// that accepts a new engagement). + /// + Task TryEngageAsync(string callsign, CancellationToken ct = default); +} diff --git a/src/OpenWSFZ.Web/WebApp.cs b/src/OpenWSFZ.Web/WebApp.cs index 83e1d8b4..9da7e801 100644 --- a/src/OpenWSFZ.Web/WebApp.cs +++ b/src/OpenWSFZ.Web/WebApp.cs @@ -364,6 +364,8 @@ static bool IsPublicPath(PathString path) => 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() }; // ── CAT config validation (FR-031, FR-034) ───────────────────────── if (config.Cat is { } cat) @@ -472,6 +474,22 @@ static bool IsPublicPath(PathString path) => config = config with { Decoder = sanitisedDecoder }; } + // ── External reporting config validation (gridtracker-udp-reporting) ──── + // Any target with a port outside 1–65535 is rejected outright (HTTP 400, no + // partial persistence) — unlike CAT/TX/Decoder above, which clamp with a warning. + if (config.ExternalReporting is { } extRep) + { + foreach (var target in extRep.Targets) + { + if (target.Port is < 1 or > 65535) + { + return Results.BadRequest( + $"externalReporting target '{target.Name}' has port {target.Port}, " + + "which is outside the valid range 1-65535."); + } + } + } + // ── Remote access config validation (SEC-001 / D-LAN-006) ────────────── // The daemon refuses to start when Enabled = true and Passphrase is absent. // Reject the save here so the operator cannot commit an unlaunchable config. diff --git a/tests/OpenWSFZ.Config.Tests/ExternalReportingConfigTests.cs b/tests/OpenWSFZ.Config.Tests/ExternalReportingConfigTests.cs new file mode 100644 index 00000000..6b171261 --- /dev/null +++ b/tests/OpenWSFZ.Config.Tests/ExternalReportingConfigTests.cs @@ -0,0 +1,129 @@ +using FluentAssertions; +using OpenWSFZ.Abstractions; +using OpenWSFZ.Config; +using System.Text.Json; +using Xunit; + +namespace OpenWSFZ.Config.Tests; + +/// +/// Tests for / and the +/// property introduced by the +/// gridtracker-udp-reporting change (task 1.4). +/// +[Trait("Category", "Unit")] +public sealed class ExternalReportingConfigTests +{ + /// Creates a temporary directory that is deleted when disposed. + private sealed class TempDirectory : IDisposable + { + public string Path { get; } = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + "openwsfz-extrep-" + System.IO.Path.GetRandomFileName()); + + public TempDirectory() => Directory.CreateDirectory(Path); + + public void Dispose() + { + try { Directory.Delete(Path, recursive: true); } catch { /* best-effort */ } + } + } + + // ── Round-trip ──────────────────────────────────────────────────────────── + + [Fact(DisplayName = "FR-052: ExternalReportingConfig with two targets round-trips via ConfigJsonContext")] + public void ExternalReportingConfig_RoundTrip_PreservesValues() + { + var original = new AppConfig() with + { + ExternalReporting = new ExternalReportingConfig( + enabled: true, + targets: + [ + new ExternalReportingTarget(name: "GridTracker2", host: "127.0.0.1", port: 2237, enabled: true), + new ExternalReportingTarget(name: "JTAlert", host: "127.0.0.1", port: 2238, enabled: false), + ], + honourInboundCommands: true) + }; + + var json = JsonSerializer.Serialize(original, ConfigJsonContext.Default.AppConfig); + var loaded = JsonSerializer.Deserialize(json, ConfigJsonContext.Default.AppConfig)!; + + loaded.ExternalReporting.Should().NotBeNull(); + loaded.ExternalReporting.Enabled.Should().BeTrue(); + loaded.ExternalReporting.HonourInboundCommands.Should().BeTrue(); + loaded.ExternalReporting.Targets.Should().HaveCount(2); + loaded.ExternalReporting.Targets[0].Name.Should().Be("GridTracker2"); + loaded.ExternalReporting.Targets[0].Port.Should().Be(2237); + loaded.ExternalReporting.Targets[1].Enabled.Should().BeFalse(); + + json.Should().Contain("\"externalReporting\""); + json.Should().Contain("\"honourInboundCommands\""); + } + + // ── Missing-key defaults ────────────────────────────────────────────────── + + [Fact(DisplayName = "FR-052: AppConfig without externalReporting key deserialises with fully-inert defaults")] + public void Load_MissingExternalReportingKey_UsesDefaults() + { + const string json = """{"port":8080}"""; + var config = JsonSerializer.Deserialize(json, ConfigJsonContext.Default.AppConfig)!; + + var effective = config.ExternalReporting ?? new ExternalReportingConfig(); + effective.Enabled.Should().BeFalse(); + effective.Targets.Should().BeEmpty(); + effective.HonourInboundCommands.Should().BeFalse(); + } + + [Fact(DisplayName = "JsonConfigStore null-guard ensures ExternalReporting defaults on old config")] + public void JsonConfigStore_Load_MissingExternalReportingKey_AppliesNullGuard() + { + using var dir = new TempDirectory(); + var configPath = System.IO.Path.Combine(dir.Path, "config.json"); + + File.WriteAllText(configPath, """{"port":9090}"""); + + var store = new JsonConfigStore(configPath); + + store.Current.ExternalReporting.Should().NotBeNull(); + store.Current.ExternalReporting.Enabled.Should().BeFalse(); + store.Current.ExternalReporting.Targets.Should().BeEmpty(); + } + + [Fact(DisplayName = "JsonConfigStore default-config file includes externalReporting section")] + public void JsonConfigStore_CreateDefault_IncludesExternalReportingSection() + { + using var dir = new TempDirectory(); + var configPath = System.IO.Path.Combine(dir.Path, "config.json"); + + _ = new JsonConfigStore(configPath); + + File.Exists(configPath).Should().BeTrue(); + + var text = File.ReadAllText(configPath); + text.Should().Contain("\"externalReporting\""); + + var loaded = JsonSerializer.Deserialize(text, ConfigJsonContext.Default.AppConfig)!; + var extRep = loaded.ExternalReporting ?? new ExternalReportingConfig(); + extRep.Enabled.Should().BeFalse(); + extRep.Targets.Should().BeEmpty(); + } + + [Fact(DisplayName = "A target entry deserialises with correct field values")] + public void Load_SingleTarget_DeserialisesCorrectly() + { + const string json = """ + {"port":8080,"externalReporting":{"enabled":true,"targets":[ + {"name":"GridTracker2","host":"192.168.1.5","port":2237,"enabled":true} + ],"honourInboundCommands":false}} + """; + var config = JsonSerializer.Deserialize(json, ConfigJsonContext.Default.AppConfig)!; + + config.ExternalReporting.Targets.Should().ContainSingle(); + var t = config.ExternalReporting.Targets[0]; + t.Name.Should().Be("GridTracker2"); + t.Host.Should().Be("192.168.1.5"); + t.Port.Should().Be(2237); + t.Enabled.Should().BeTrue(); + } +} diff --git a/tests/OpenWSFZ.Daemon.Tests/ExternalReportingServiceTests.cs b/tests/OpenWSFZ.Daemon.Tests/ExternalReportingServiceTests.cs new file mode 100644 index 00000000..c6cc0bec --- /dev/null +++ b/tests/OpenWSFZ.Daemon.Tests/ExternalReportingServiceTests.cs @@ -0,0 +1,1071 @@ +using System.Buffers.Binary; +using System.Net; +using System.Net.Sockets; +using System.Threading.Channels; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using OpenWSFZ.Abstractions; +using OpenWSFZ.Daemon; +using OpenWSFZ.Web; +using Xunit; + +namespace OpenWSFZ.Daemon.Tests; + +/// +/// Integration tests for (gridtracker-udp-reporting, +/// tasks 3.7 and 4.6). Uses real loopback s — both as fake targets +/// (outbound assertions) and as synthetic inbound senders — per +/// specs/external-reporting/spec.md's scenarios. +/// +[Trait("Category", "Integration")] +public sealed class ExternalReportingServiceTests +{ + private sealed class MutableConfigStore : IConfigStore + { + private AppConfig _current; + public MutableConfigStore(AppConfig initial) => _current = initial; + public AppConfig Current => _current; + public event Action? OnSaved; + public Task SaveAsync(AppConfig config, CancellationToken ct = default) + { + _current = config; + OnSaved?.Invoke(config); + return Task.CompletedTask; + } + } + + /// + /// Fully-controllable test double for the absolute- + /// exclusion (Tier 2) tests — an unmapped callsign resolves to null ("unknown region"), + /// matching 's own lookup-miss contract. + /// + private sealed class FakeCallsignRegionStore : ICallsignRegionStore + { + private readonly Dictionary _map = new(StringComparer.OrdinalIgnoreCase); + public IReadOnlyList Entries => []; + public void Set(string callsign, RegionInfo? region) => _map[callsign] = region; + public RegionInfo? TryGetRegion(string callsignToken) + => _map.TryGetValue(callsignToken, out var region) ? region : null; + public Task SaveAsync(IReadOnlyList entries, CancellationToken ct = default) + => Task.CompletedTask; + } + + private static int GetFreeUdpPort() + { + using var probe = new UdpClient(0, AddressFamily.InterNetwork); + return ((IPEndPoint)probe.Client.LocalEndPoint!).Port; + } + + private static IServiceProvider BuildServiceProvider( + IQsoController? qso = null, IExternalReplyTarget? reply = null) + { + var services = new ServiceCollection(); + if (qso is not null) services.AddSingleton(qso); + if (reply is not null) services.AddSingleton(reply); + return services.BuildServiceProvider(); + } + + private static ExternalReportingService CreateSut( + IConfigStore configStore, ChannelReader channelReader, + IServiceProvider? serviceProvider = null, + ICallsignRegionStore? regionStore = null, + TimeSpan? heartbeatInterval = null, TimeSpan? statusPollInterval = null) + => new( + channelReader, + configStore, + serviceProvider ?? BuildServiceProvider(), + NullLogger.Instance, + catState: null, + regionStore: regionStore, + heartbeatInterval: heartbeatInterval ?? TimeSpan.FromSeconds(30), + statusPollInterval: statusPollInterval ?? TimeSpan.FromMilliseconds(50)); + + /// Receives up to datagrams within the timeout, or fewer if it elapses. + private static async Task> ReceiveAllAsync( + UdpClient listener, int maxDatagrams, TimeSpan timeout) + { + var received = new List(); + var deadline = DateTime.UtcNow + timeout; + while (received.Count < maxDatagrams && DateTime.UtcNow < deadline) + { + using var cts = new CancellationTokenSource(deadline - DateTime.UtcNow); + try + { + var result = await listener.ReceiveAsync(cts.Token).ConfigureAwait(false); + received.Add(result.Buffer); + } + catch (OperationCanceledException) { break; } + } + return received; + } + + private static WsjtxDatagram.MessageType ReadMessageType(byte[] datagram) + => (WsjtxDatagram.MessageType)BinaryPrimitives.ReadUInt32BigEndian(datagram.AsSpan(8, 4)); + + // ── Outbound: two simultaneous targets (task 3.7) ─────────────────────── + + [Fact(DisplayName = "FR-053: Two enabled targets both receive a Decode datagram")] + public async Task TwoEnabledTargets_BothReceiveDecode() + { + var port1 = GetFreeUdpPort(); + var port2 = GetFreeUdpPort(); + using var listener1 = new UdpClient(port1); + using var listener2 = new UdpClient(port2); + + var config = new AppConfig() with + { + ExternalReporting = new ExternalReportingConfig( + enabled: true, + targets: + [ + new ExternalReportingTarget("A", "127.0.0.1", port1, true), + new ExternalReportingTarget("B", "127.0.0.1", port2, true), + ]) + }; + var store = new MutableConfigStore(config); + var channel = Channel.CreateBounded(2); + var sut = CreateSut(store, channel.Reader); + + using var cts = new CancellationTokenSource(); + await sut.StartAsync(cts.Token); + try + { + // Region set to a resolvable, non-synthetic entry — this test is about delivery + // mechanics (two targets), not the absolute exclusion filter; an unset (null) Region + // would now be unconditionally suppressed as "unknown region" (see the dedicated + // exclusion tests below) and never reach either target. + channel.Writer.TryWrite(new DecodeBatch(DateTimeOffset.UtcNow, + [new DecodeResult(Time: "12:00:00", Snr: -5, Dt: 0.1, FreqHz: 1500, Message: "CQ Q1TST JO22", + Region: new RegionInfo(Continent: "EU", Entity: "TestLand", Synthetic: false))])); + + // The timer loop also fires an immediate Heartbeat+Status burst on start (heartbeat + // interval is 30 s in CreateSut, but the FIRST tick always fires since + // _lastHeartbeatSentUtc starts at DateTimeOffset.MinValue) — capture enough + // datagrams to see past that burst to the Clear+Decode pair. + var recv1 = await ReceiveAllAsync(listener1, 4, TimeSpan.FromSeconds(3)); + var recv2 = await ReceiveAllAsync(listener2, 4, TimeSpan.FromSeconds(3)); + + recv1.Select(ReadMessageType).Should().Contain(WsjtxDatagram.MessageType.Decode); + recv2.Select(ReadMessageType).Should().Contain(WsjtxDatagram.MessageType.Decode); + recv1.Should().Contain(d => Encoding_UTF8_Contains(d, "Q1TST")); + recv2.Should().Contain(d => Encoding_UTF8_Contains(d, "Q1TST")); + } + finally + { + await sut.StopAsync(CancellationToken.None); + } + } + + private static bool Encoding_UTF8_Contains(byte[] datagram, string needle) + => System.Text.Encoding.UTF8.GetString(datagram).Contains(needle); + + [Fact(DisplayName = "FR-053: Disabled target never receives a datagram")] + public async Task DisabledTarget_NeverReceives() + { + var enabledPort = GetFreeUdpPort(); + var disabledPort = GetFreeUdpPort(); + using var enabledListener = new UdpClient(enabledPort); + using var disabledListener = new UdpClient(disabledPort); + + var config = new AppConfig() with + { + ExternalReporting = new ExternalReportingConfig( + enabled: true, + targets: + [ + new ExternalReportingTarget("Enabled", "127.0.0.1", enabledPort, true), + new ExternalReportingTarget("Disabled", "127.0.0.1", disabledPort, false), + ]) + }; + var store = new MutableConfigStore(config); + var channel = Channel.CreateBounded(2); + var sut = CreateSut(store, channel.Reader); + + using var cts = new CancellationTokenSource(); + await sut.StartAsync(cts.Token); + try + { + channel.Writer.TryWrite(new DecodeBatch(DateTimeOffset.UtcNow, + [new DecodeResult(Time: "12:00:00", Snr: -5, Dt: 0.1, FreqHz: 1500, Message: "CQ Q1TST JO22")])); + + var enabledRecv = await ReceiveAllAsync(enabledListener, 2, TimeSpan.FromSeconds(3)); + var disabledRecv = await ReceiveAllAsync(disabledListener, 1, TimeSpan.FromMilliseconds(500)); + + enabledRecv.Should().NotBeEmpty(); + disabledRecv.Should().BeEmpty("a disabled target must receive no datagram of any type"); + } + finally + { + await sut.StopAsync(CancellationToken.None); + } + } + + // ── D-014: inbound bind coexists with a peer already on the target port ─ + + /// + /// Binds a "fake peer" to with + /// ReuseAddress set — mirroring the real WSJT-X-protocol reference behaviour (Qt's + /// ShareAddress | ReuseAddressHint bind option, which every WSJT-X-protocol client + /// including GridTracker2 uses) so this test double is a faithful stand-in for "GridTracker2 + /// is already running and bound to this port before OpenWSFZ starts" — the realistic + /// real-world startup order D-014 was found against. + /// + private static UdpClient CreateFakePeer(int port) + { + var client = new UdpClient(AddressFamily.InterNetwork); + client.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); + client.Client.Bind(new IPEndPoint(IPAddress.Any, port)); + return client; + } + + /// + /// Reads the private _inboundClient field via reflection to assert the bind's own + /// success/failure directly, independent of the OS's delivery semantics for a shared port + /// (see the class-level remarks on the two D-014 tests below for why delivery itself is not + /// asserted while a peer remains bound to the same port). + /// + private static UdpClient? GetInboundClient(ExternalReportingService sut) + { + var field = typeof(ExternalReportingService).GetField( + "_inboundClient", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + return (UdpClient?)field!.GetValue(sut); + } + + /// + /// + /// Windows platform note: empirically probed while writing this test (see + /// dev-tasks/2026-07-12-gridtracker-udp-reporting-review-fixes.md §3 for the review that + /// found D-014): when two s share one local port via + /// SO_REUSEADDR, Windows delivers an incoming unicast datagram to only the + /// first-bound socket — never both, and (unlike Linux SO_REUSEPORT) with no + /// load-balancing fan-out. This is a real, pre-existing OS/platform limitation, not something + /// this fix's `ReuseAddress` call can or is scoped to solve — true concurrent multi-listener + /// delivery on one port on one machine would need UDP multicast, which design.md's own "Open + /// Questions" section already defers as unimplemented, out of scope here. + /// + /// + /// What D-014 Part A actually fixes, and what this test actually proves, is narrower and + /// still the entire point of the defect: before the fix, the bind throws when a peer + /// already owns the port, the exception is swallowed, and _inboundClient stays + /// permanently null — no message of any kind, from any sender, at any later time, + /// would ever reach the daemon, because nothing ever retries the bind outside of a config + /// save. This test proves the bind no longer fails that way: it binds successfully (directly + /// verified via _inboundClient) despite a peer having already claimed the port at + /// daemon-startup time, and a Halt Tx sent after that peer disconnects is received — + /// which could only happen if the original bind actually succeeded, since Reconcile is + /// never called again in this test to retry it. + /// + /// + [Fact(DisplayName = "D-014 AC-1: inbound listener binds successfully when a peer already owns the target port at startup")] + public async Task InboundBind_SucceedsWhenTargetPortAlreadyBoundByPeer() + { + var port = GetFreeUdpPort(); + + var qso = Substitute.For(); + qso.AbortAsync(Arg.Any()).Returns(Task.CompletedTask); + + var config = new AppConfig() with + { + ExternalReporting = new ExternalReportingConfig( + enabled: true, + targets: [new ExternalReportingTarget("GridTracker2", "127.0.0.1", port, true)]) + }; + var store = new MutableConfigStore(config); + var channel = Channel.CreateBounded(2); + var sut = CreateSut(store, channel.Reader, BuildServiceProvider(qso: qso)); + + using var cts = new CancellationTokenSource(); + + // The peer (e.g. GridTracker2) is already running and bound to this port BEFORE the + // daemon starts — the normal real-world case D-014 was found against. Pre-fix, this + // alone was enough to leave the daemon's inbound listener permanently unbound. + var fakePeer = CreateFakePeer(port); + try + { + await sut.StartAsync(cts.Token); + await Task.Delay(200); // let Reconcile attempt the bind + + GetInboundClient(sut).Should().NotBeNull( + "the bind must succeed (via ReuseAddress) even though a peer already owns the port " + + "at daemon-startup time — D-014's exact defect was this staying permanently null"); + } + finally + { + fakePeer.Dispose(); // release the port so the reply step below is unambiguous (see remarks) + } + + try + { + using var sender = new UdpClient(); + var haltTx = BuildHaltTxDatagram(); + await sender.SendAsync(haltTx, haltTx.Length, "127.0.0.1", port); + + await Task.Delay(500); + await qso.Received(1).AbortAsync(Arg.Any()); + } + finally + { + await sut.StopAsync(CancellationToken.None); + } + } + + /// + /// Linux platform note (2026-07-12 CI failure fix): this test originally + /// proved the point by racing a second socket (fakePeer) bound to the same shared port + /// to receive the daemon's own outbound send over the wire — reasoning that, per the Windows + /// finding documented on , + /// the first-bound socket wins delivery. That does not generalise: on Linux, unicast delivery + /// to a SO_REUSEADDR-shared UDP port has historically gone to the last-bound + /// socket, not the first — and in this test the daemon's own _inboundClient binds + /// second (inside Reconcile, after fakePeer). If that Linux behaviour applies, + /// the daemon's own outbound send back to 127.0.0.1:port is delivered to the daemon's + /// own _inboundClient/InboundLoopAsync rather than to fakePeer, which is + /// exactly what made this test time out on ubuntu-latest (see + /// dev-tasks/2026-07-12-gridtracker-udp-reporting-linux-ci-failure.md and design.md Decision 7's + /// Linux addendum). Racing OS-specific same-host delivery arbitration is inherently unportable, + /// so this test now asserts the *sending* socket's own local port directly instead — deterministic + /// on every platform, and still exactly what AC-2 requires: proof that the primary target's + /// outbound sends originate from the shared bound inbound port, since + /// 's own SendToAllEnabledAsync resolves the + /// primary target's client as _inboundClient ?? _primaryFallbackClient — a bound, + /// non-null _inboundClient on the target's port is exactly the condition under which + /// that fallback expression selects the shared socket. + /// + [Fact(DisplayName = "D-014 AC-2: outbound sends to the primary target originate from the shared inbound port")] + public async Task OutboundToPrimaryTarget_UsesSharedInboundPort() + { + var port = GetFreeUdpPort(); + + var config = new AppConfig() with + { + ExternalReporting = new ExternalReportingConfig( + enabled: true, + targets: [new ExternalReportingTarget("GridTracker2", "127.0.0.1", port, true)]) + }; + var store = new MutableConfigStore(config); + var channel = Channel.CreateBounded(2); + var sut = CreateSut(store, channel.Reader); + + // A peer (e.g. GridTracker2) already bound to the port before the daemon starts — the + // realistic startup order D-014 targets. It is never used to observe delivery here (see + // the remarks above for why that's no longer safe); it exists only so the shared-bind + // assertion below is exercised under the same contended-port condition as production. + using var fakePeer = CreateFakePeer(port); + + using var cts = new CancellationTokenSource(); + await sut.StartAsync(cts.Token); + try + { + await Task.Delay(200); // let Reconcile attempt the bind + + channel.Writer.TryWrite(new DecodeBatch(DateTimeOffset.UtcNow, + [new DecodeResult(Time: "12:00:00", Snr: -5, Dt: 0.1, FreqHz: 1500, Message: "CQ Q1TST JO22")])); + + // Assert the SENDING socket's own local port directly, rather than inferring it by + // racing a second socket to receive the packet over the wire (platform-dependent — + // see the remarks above). A bound _inboundClient on the target's port is exactly the + // condition under which SendToAllEnabledAsync's own `_inboundClient ?? + // _primaryFallbackClient` resolution picks the shared socket for the primary target. + var inboundClient = GetInboundClient(sut); + inboundClient.Should().NotBeNull( + "the shared inbound socket must be bound so the primary target's outbound sends " + + "route through it — D-014 Part B's entire point"); + ((IPEndPoint)inboundClient!.Client.LocalEndPoint!).Port.Should().Be(port, + "the primary target's outbound sends must originate from the shared bound inbound " + + "port, not a separate ephemeral socket, so a peer's reply-to-sender-port semantics " + + "(e.g. GridTracker2 replying to whatever port it last saw traffic from) reach us"); + } + finally + { + await sut.StopAsync(CancellationToken.None); + } + } + + // ── Absolute exclusion of synthetic/unknown-region traffic (no exceptions) ── + // + // dev-tasks/2026-07-12-gridtracker-udp-reporting-review-fixes.md §4. Every test in this + // section runs with DecodeNoiseSuppressionConfig.SuppressUnknownRegion=false AND + // SuppressSynthetic=false — the exact condition that lets unknown/synthetic decodes reach + // this service's inbound channel today — to prove the exclusion inside + // ExternalReportingService itself is unconditional and does not depend on that operator + // setting remaining on. + + [Fact(DisplayName = "AC-3: unknown-region and synthetic decodes are never sent, even with suppression off")] + public async Task Decode_UnknownRegionAndSynthetic_NeverSentEvenWithSuppressionOff() + { + var port = GetFreeUdpPort(); + using var listener = new UdpClient(port); + + var config = new AppConfig() with + { + ExternalReporting = new ExternalReportingConfig( + enabled: true, + targets: [new ExternalReportingTarget("A", "127.0.0.1", port, true)]), + DecodeNoiseSuppression = new DecodeNoiseSuppressionConfig + { + SuppressUnknownRegion = false, + SuppressSynthetic = false, + }, + }; + var store = new MutableConfigStore(config); + var channel = Channel.CreateBounded(2); + var sut = CreateSut(store, channel.Reader); + + using var cts = new CancellationTokenSource(); + await sut.StartAsync(cts.Token); + try + { + channel.Writer.TryWrite(new DecodeBatch(DateTimeOffset.UtcNow, + [ + new DecodeResult(Time: "12:00:00", Snr: -5, Dt: 0.1, FreqHz: 1500, Message: "CQ Q1NORM JO22", + Region: new RegionInfo(Continent: "EU", Entity: "TestLand", Synthetic: false)), + new DecodeResult(Time: "12:00:00", Snr: -6, Dt: 0.1, FreqHz: 1600, Message: "CQ Q9UNK JO22", + Region: null), + new DecodeResult(Time: "12:00:00", Snr: -7, Dt: 0.1, FreqHz: 1700, Message: "CQ Q1SYN JO22", + Region: new RegionInfo(Continent: null, Entity: "Synthetic (R&R Study)", Synthetic: true)), + ])); + + var recv = await ReceiveAllAsync(listener, 4, TimeSpan.FromSeconds(3)); + + var decodeDatagrams = recv.Where(d => ReadMessageType(d) == WsjtxDatagram.MessageType.Decode).ToList(); + decodeDatagrams.Should().HaveCount(1, "only the normal (resolvable, non-synthetic) decode may be sent"); + Encoding_UTF8_Contains(decodeDatagrams[0], "Q1NORM").Should().BeTrue(); + recv.Should().NotContain(d => Encoding_UTF8_Contains(d, "Q9UNK")); + recv.Should().NotContain(d => Encoding_UTF8_Contains(d, "Q1SYN")); + } + finally + { + await sut.StopAsync(CancellationToken.None); + } + } + + [Fact(DisplayName = "AC-3: a batch of only unknown/synthetic decodes still sends Clear")] + public async Task Decode_AllResultsSuppressed_StillSendsClear() + { + var port = GetFreeUdpPort(); + using var listener = new UdpClient(port); + + var config = new AppConfig() with + { + ExternalReporting = new ExternalReportingConfig( + enabled: true, + targets: [new ExternalReportingTarget("A", "127.0.0.1", port, true)]), + DecodeNoiseSuppression = new DecodeNoiseSuppressionConfig + { + SuppressUnknownRegion = false, + SuppressSynthetic = false, + }, + }; + var store = new MutableConfigStore(config); + var channel = Channel.CreateBounded(2); + var sut = CreateSut(store, channel.Reader); + + using var cts = new CancellationTokenSource(); + await sut.StartAsync(cts.Token); + try + { + channel.Writer.TryWrite(new DecodeBatch(DateTimeOffset.UtcNow, + [ + new DecodeResult(Time: "12:00:00", Snr: -6, Dt: 0.1, FreqHz: 1600, Message: "CQ Q9UNK JO22", Region: null), + new DecodeResult(Time: "12:00:00", Snr: -7, Dt: 0.1, FreqHz: 1700, Message: "CQ Q1SYN JO22", + Region: new RegionInfo(Continent: null, Entity: "Synthetic (R&R Study)", Synthetic: true)), + ])); + + var recv = await ReceiveAllAsync(listener, 3, TimeSpan.FromSeconds(3)); + + recv.Select(ReadMessageType).Should().Contain(WsjtxDatagram.MessageType.Clear, + "Clear must still fire every cycle regardless of how many decodes the exclusion filter removes"); + recv.Should().NotContain(d => ReadMessageType(d) == WsjtxDatagram.MessageType.Decode, + "every decode in this batch is unknown-region or synthetic — none may be sent"); + } + finally + { + await sut.StopAsync(CancellationToken.None); + } + } + + [Fact(DisplayName = "AC-4: Status blanks DxCall/DxGrid for a synthetic active partner")] + public async Task Status_SyntheticPartner_DxCallAndGridBlanked() + { + var port = GetFreeUdpPort(); + using var listener = new UdpClient(port); + + var qso = Substitute.For(); + qso.Partner.Returns("Q1SYN"); + qso.Keying.Returns(true); + + var regionStore = new FakeCallsignRegionStore(); + regionStore.Set("Q1SYN", new RegionInfo(Continent: null, Entity: "Synthetic (R&R Study)", Synthetic: true)); + + var config = new AppConfig() with + { + ExternalReporting = new ExternalReportingConfig( + enabled: true, + targets: [new ExternalReportingTarget("A", "127.0.0.1", port, true)]), + }; + var store = new MutableConfigStore(config); + var channel = Channel.CreateBounded(2); + var sut = CreateSut(store, channel.Reader, + BuildServiceProvider(qso: qso), regionStore: regionStore, + heartbeatInterval: TimeSpan.FromSeconds(30), statusPollInterval: TimeSpan.FromMilliseconds(50)); + + using var cts = new CancellationTokenSource(); + await sut.StartAsync(cts.Token); + try + { + var recv = await ReceiveAllAsync(listener, 2, TimeSpan.FromSeconds(3)); + + var statusDatagrams = recv.Where(d => ReadMessageType(d) == WsjtxDatagram.MessageType.Status).ToList(); + statusDatagrams.Should().NotBeEmpty(); + statusDatagrams.Should().NotContain(d => Encoding_UTF8_Contains(d, "Q1SYN"), + "DxCall must never name a synthetic partner, even while an active QSO with it is in progress"); + } + finally + { + await sut.StopAsync(CancellationToken.None); + } + } + + [Fact(DisplayName = "AC-4 regression: Status still populates DxCall for a normal, resolvable partner")] + public async Task Status_NormalPartner_DxCallPopulated() + { + var port = GetFreeUdpPort(); + using var listener = new UdpClient(port); + + var qso = Substitute.For(); + qso.Partner.Returns("Q1NORM"); + qso.Keying.Returns(false); + + var regionStore = new FakeCallsignRegionStore(); + regionStore.Set("Q1NORM", new RegionInfo(Continent: "EU", Entity: "TestLand", Synthetic: false)); + + var config = new AppConfig() with + { + ExternalReporting = new ExternalReportingConfig( + enabled: true, + targets: [new ExternalReportingTarget("A", "127.0.0.1", port, true)]), + }; + var store = new MutableConfigStore(config); + var channel = Channel.CreateBounded(2); + var sut = CreateSut(store, channel.Reader, + BuildServiceProvider(qso: qso), regionStore: regionStore, + heartbeatInterval: TimeSpan.FromSeconds(30), statusPollInterval: TimeSpan.FromMilliseconds(50)); + + using var cts = new CancellationTokenSource(); + await sut.StartAsync(cts.Token); + try + { + var recv = await ReceiveAllAsync(listener, 2, TimeSpan.FromSeconds(3)); + + var statusDatagrams = recv.Where(d => ReadMessageType(d) == WsjtxDatagram.MessageType.Status).ToList(); + statusDatagrams.Should().Contain(d => Encoding_UTF8_Contains(d, "Q1NORM"), + "a real, resolvable partner callsign must still appear in DxCall as before"); + } + finally + { + await sut.StopAsync(CancellationToken.None); + } + } + + [Fact(DisplayName = "AC-4: NotifyQsoLogged never sends for a synthetic or unknown-region partner")] + public async Task NotifyQsoLogged_SyntheticOrUnknownPartner_NeverSent() + { + var port = GetFreeUdpPort(); + using var listener = new UdpClient(port); + + var regionStore = new FakeCallsignRegionStore(); + regionStore.Set("Q1SYN", new RegionInfo(Continent: null, Entity: "Synthetic (R&R Study)", Synthetic: true)); + // "Q9UNK" left unmapped — resolves to null ("unknown region"), matching a lookup miss. + + var config = new AppConfig() with + { + ExternalReporting = new ExternalReportingConfig( + enabled: true, + targets: [new ExternalReportingTarget("A", "127.0.0.1", port, true)]), + }; + var store = new MutableConfigStore(config); + var channel = Channel.CreateBounded(2); + var sut = CreateSut(store, channel.Reader, regionStore: regionStore); + + using var cts = new CancellationTokenSource(); + await sut.StartAsync(cts.Token); + try + { + QsoRecord MakeRecord(string partner) => new() + { + PartnerCallsign = partner, + PartnerGrid = "JO22", + RstSent = "+05", + RstRcvd = "-03", + QsoStartUtc = new DateTime(2026, 7, 12, 14, 29, 15, DateTimeKind.Utc), + QsoEndUtc = new DateTime(2026, 7, 12, 14, 30, 0, DateTimeKind.Utc), + OperatorCallsign = "Q1OFZ", + OperatorGrid = "JO33", + DialFrequencyMHz = 14.074, + }; + + sut.NotifyQsoLogged(MakeRecord("Q1SYN")); + sut.NotifyQsoLogged(MakeRecord("Q9UNK")); + + // Give both sends a chance to arrive if they were (incorrectly) not suppressed. + var recv = await ReceiveAllAsync(listener, 4, TimeSpan.FromMilliseconds(800)); + + recv.Should().NotContain(d => ReadMessageType(d) == WsjtxDatagram.MessageType.QsoLogged, + "no QSOLogged datagram may ever be sent for a synthetic or unknown-region partner"); + } + finally + { + await sut.StopAsync(CancellationToken.None); + } + } + + [Fact(DisplayName = "AC-4 regression: NotifyQsoLogged still sends for a normal, resolvable partner")] + public async Task NotifyQsoLogged_NormalPartner_StillSent() + { + var port = GetFreeUdpPort(); + using var listener = new UdpClient(port); + + var regionStore = new FakeCallsignRegionStore(); + regionStore.Set("Q1NORM", new RegionInfo(Continent: "EU", Entity: "TestLand", Synthetic: false)); + + var config = new AppConfig() with + { + ExternalReporting = new ExternalReportingConfig( + enabled: true, + targets: [new ExternalReportingTarget("A", "127.0.0.1", port, true)]), + }; + var store = new MutableConfigStore(config); + var channel = Channel.CreateBounded(2); + var sut = CreateSut(store, channel.Reader, regionStore: regionStore); + + using var cts = new CancellationTokenSource(); + await sut.StartAsync(cts.Token); + try + { + sut.NotifyQsoLogged(new QsoRecord + { + PartnerCallsign = "Q1NORM", + PartnerGrid = "JO22", + RstSent = "+05", + RstRcvd = "-03", + QsoStartUtc = new DateTime(2026, 7, 12, 14, 29, 15, DateTimeKind.Utc), + QsoEndUtc = new DateTime(2026, 7, 12, 14, 30, 0, DateTimeKind.Utc), + OperatorCallsign = "Q1OFZ", + OperatorGrid = "JO33", + DialFrequencyMHz = 14.074, + }); + + var recv = await ReceiveAllAsync(listener, 3, TimeSpan.FromSeconds(3)); + + recv.Select(ReadMessageType).Should().Contain(WsjtxDatagram.MessageType.QsoLogged); + recv.Should().Contain(d => Encoding_UTF8_Contains(d, "Q1NORM")); + } + finally + { + await sut.StopAsync(CancellationToken.None); + } + } + + // ── Outbound: QSOLogged (task 3.5/3.7) ────────────────────────────────── + + [Fact(DisplayName = "FR-053: NotifyQsoLogged sends a QSOLogged datagram to the enabled target")] + public async Task NotifyQsoLogged_SendsQsoLoggedDatagram() + { + var port = GetFreeUdpPort(); + using var listener = new UdpClient(port); + + var config = new AppConfig() with + { + ExternalReporting = new ExternalReportingConfig( + enabled: true, + targets: [new ExternalReportingTarget("A", "127.0.0.1", port, true)]) + }; + var store = new MutableConfigStore(config); + var channel = Channel.CreateBounded(2); + // Region store resolves the partner to a non-synthetic entity — this test is about + // delivery mechanics, not the absolute exclusion filter; an unmapped (null-resolving) + // partner would now be unconditionally suppressed (see the dedicated exclusion tests + // below). + var regionStore = new FakeCallsignRegionStore(); + regionStore.Set("Q1TST", new RegionInfo(Continent: "EU", Entity: "TestLand", Synthetic: false)); + var sut = CreateSut(store, channel.Reader, regionStore: regionStore); + + using var cts = new CancellationTokenSource(); + await sut.StartAsync(cts.Token); + try + { + sut.NotifyQsoLogged(new QsoRecord + { + PartnerCallsign = "Q1TST", + PartnerGrid = "JO22", + RstSent = "+05", + RstRcvd = "-03", + QsoStartUtc = new DateTime(2026, 7, 12, 14, 29, 15, DateTimeKind.Utc), + QsoEndUtc = new DateTime(2026, 7, 12, 14, 30, 0, DateTimeKind.Utc), + OperatorCallsign = "Q1OFZ", + OperatorGrid = "JO33", + DialFrequencyMHz = 14.074, + }); + + // The timer loop also fires an immediate Heartbeat+Status burst on start — capture + // enough datagrams to see past it to the QSOLogged datagram. + var recv = await ReceiveAllAsync(listener, 3, TimeSpan.FromSeconds(3)); + + recv.Select(ReadMessageType).Should().Contain(WsjtxDatagram.MessageType.QsoLogged); + recv.Should().Contain(d => Encoding_UTF8_Contains(d, "Q1TST")); + } + finally + { + await sut.StopAsync(CancellationToken.None); + } + } + + // ── Outbound: Close on shutdown (task 3.6) ────────────────────────────── + + [Fact(DisplayName = "FR-053: StopAsync sends a Close datagram before closing sockets")] + public async Task StopAsync_SendsCloseDatagram() + { + var port = GetFreeUdpPort(); + using var listener = new UdpClient(port); + + var config = new AppConfig() with + { + ExternalReporting = new ExternalReportingConfig( + enabled: true, + targets: [new ExternalReportingTarget("A", "127.0.0.1", port, true)]) + }; + var store = new MutableConfigStore(config); + var channel = Channel.CreateBounded(2); + var sut = CreateSut(store, channel.Reader); + + using var cts = new CancellationTokenSource(); + await sut.StartAsync(cts.Token); + + await sut.StopAsync(CancellationToken.None); + + // A Heartbeat/Status may or may not have raced ahead of the immediate StopAsync (timer + // loop scheduling is not deterministic) — assert Close is present, not that it's the + // only datagram. + var recv = await ReceiveAllAsync(listener, 3, TimeSpan.FromSeconds(3)); + recv.Select(ReadMessageType).Should().Contain(WsjtxDatagram.MessageType.Close); + } + + // ── Enable/disable posture (spec: "ExternalReportingService is inert by default") ── + + [Fact(DisplayName = "Disabled config opens no outbound socket — nothing is sent")] + public async Task Disabled_OpensNoSocket() + { + var port = GetFreeUdpPort(); + using var listener = new UdpClient(port); + + var config = new AppConfig(); // ExternalReporting defaults: Enabled=false + var store = new MutableConfigStore(config); + var channel = Channel.CreateBounded(2); + var sut = CreateSut(store, channel.Reader); + + using var cts = new CancellationTokenSource(); + await sut.StartAsync(cts.Token); + try + { + channel.Writer.TryWrite(new DecodeBatch(DateTimeOffset.UtcNow, + [new DecodeResult(Time: "12:00:00", Snr: -5, Dt: 0.1, FreqHz: 1500, Message: "CQ Q1TST JO22")])); + + var recv = await ReceiveAllAsync(listener, 1, TimeSpan.FromMilliseconds(500)); + recv.Should().BeEmpty("externalReporting.enabled=false must open no sockets and send nothing"); + } + finally + { + await sut.StopAsync(CancellationToken.None); + } + } + + // ── Inbound: malformed datagram resilience (tasks 2.4/4.1/4.6) ────────── + + [Fact(DisplayName = "FR-054: Inbound listener discards a malformed datagram and keeps accepting well-formed ones")] + public async Task InboundListener_DiscardsMalformed_ContinuesAccepting() + { + var port = GetFreeUdpPort(); + + var qso = Substitute.For(); + qso.AbortAsync(Arg.Any()).Returns(Task.CompletedTask); + + var config = new AppConfig() with + { + ExternalReporting = new ExternalReportingConfig( + enabled: true, + targets: [new ExternalReportingTarget("A", "127.0.0.1", port, true)]) + }; + var store = new MutableConfigStore(config); + var channel = Channel.CreateBounded(2); + var sut = CreateSut(store, channel.Reader, BuildServiceProvider(qso: qso)); + + using var cts = new CancellationTokenSource(); + using var sender = new UdpClient(); + await sut.StartAsync(cts.Token); + try + { + await Task.Delay(200); // let Reconcile bind the inbound socket + + // 1) Garbage — must not crash the listener. + var garbage = new byte[] { 0x01, 0x02, 0x03 }; + await sender.SendAsync(garbage, garbage.Length, "127.0.0.1", port); + await Task.Delay(200); + + // 2) A well-formed Halt Tx afterward must still be processed. + var haltTx = new byte[16]; + BinaryPrimitives.WriteUInt32BigEndian(haltTx.AsSpan(0), WsjtxDatagram.Magic); + BinaryPrimitives.WriteUInt32BigEndian(haltTx.AsSpan(4), WsjtxDatagram.SchemaVersion); + BinaryPrimitives.WriteUInt32BigEndian(haltTx.AsSpan(8), (uint)WsjtxDatagram.MessageType.HaltTx); + // Id string: length=0 (empty). + BinaryPrimitives.WriteUInt32BigEndian(haltTx.AsSpan(12), 0); + await sender.SendAsync(haltTx, haltTx.Length, "127.0.0.1", port); + + await Task.Delay(500); + await qso.Received(1).AbortAsync(Arg.Any()); + } + finally + { + await sut.StopAsync(CancellationToken.None); + } + } + + // ── Inbound: Halt Tx always honoured regardless of honourInboundCommands ── + + [Theory(DisplayName = "FR-054: Halt Tx aborts regardless of honourInboundCommands")] + [InlineData(false)] + [InlineData(true)] + public async Task HaltTx_AlwaysHonoured(bool honourInboundCommands) + { + var port = GetFreeUdpPort(); + var qso = Substitute.For(); + qso.AbortAsync(Arg.Any()).Returns(Task.CompletedTask); + + var config = new AppConfig() with + { + ExternalReporting = new ExternalReportingConfig( + enabled: true, + targets: [new ExternalReportingTarget("A", "127.0.0.1", port, true)], + honourInboundCommands: honourInboundCommands) + }; + var store = new MutableConfigStore(config); + var channel = Channel.CreateBounded(2); + var sut = CreateSut(store, channel.Reader, BuildServiceProvider(qso: qso)); + + using var cts = new CancellationTokenSource(); + using var sender = new UdpClient(); + await sut.StartAsync(cts.Token); + try + { + await Task.Delay(200); + + var haltTx = BuildHaltTxDatagram(); + await sender.SendAsync(haltTx, haltTx.Length, "127.0.0.1", port); + + await Task.Delay(500); + await qso.Received(1).AbortAsync(Arg.Any()); + } + finally + { + await sut.StopAsync(CancellationToken.None); + } + } + + private static byte[] BuildHaltTxDatagram() + { + var buf = new byte[17]; + BinaryPrimitives.WriteUInt32BigEndian(buf.AsSpan(0), WsjtxDatagram.Magic); + BinaryPrimitives.WriteUInt32BigEndian(buf.AsSpan(4), WsjtxDatagram.SchemaVersion); + BinaryPrimitives.WriteUInt32BigEndian(buf.AsSpan(8), (uint)WsjtxDatagram.MessageType.HaltTx); + BinaryPrimitives.WriteUInt32BigEndian(buf.AsSpan(12), 0); // Id: empty string + buf[16] = 1; // AutoTxOnly = true + return buf; + } + + // ── Inbound: Reply gated by honourInboundCommands ─────────────────────── + + [Fact(DisplayName = "FR-054: Reply is ignored when honourInboundCommands is disabled")] + public async Task Reply_IgnoredWhenOptedOut() + { + var port = GetFreeUdpPort(); + var reply = Substitute.For(); + reply.TryEngageAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(true)); + + var config = new AppConfig() with + { + ExternalReporting = new ExternalReportingConfig( + enabled: true, + targets: [new ExternalReportingTarget("A", "127.0.0.1", port, true)], + honourInboundCommands: false) + }; + var store = new MutableConfigStore(config); + var channel = Channel.CreateBounded(2); + var sut = CreateSut(store, channel.Reader, BuildServiceProvider(reply: reply)); + + using var cts = new CancellationTokenSource(); + using var sender = new UdpClient(); + await sut.StartAsync(cts.Token); + try + { + await Task.Delay(200); + + var datagram = BuildReplyDatagram("CQ Q1TST JO22"); + await sender.SendAsync(datagram, datagram.Length, "127.0.0.1", port); + + await Task.Delay(500); + await reply.DidNotReceive().TryEngageAsync(Arg.Any(), Arg.Any()); + } + finally + { + await sut.StopAsync(CancellationToken.None); + } + } + + [Fact(DisplayName = "Reply engages the named callsign when honourInboundCommands is enabled")] + public async Task Reply_EngagesWhenOptedIn() + { + var port = GetFreeUdpPort(); + var reply = Substitute.For(); + reply.TryEngageAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(true)); + + var config = new AppConfig() with + { + ExternalReporting = new ExternalReportingConfig( + enabled: true, + targets: [new ExternalReportingTarget("A", "127.0.0.1", port, true)], + honourInboundCommands: true) + }; + var store = new MutableConfigStore(config); + var channel = Channel.CreateBounded(2); + var sut = CreateSut(store, channel.Reader, BuildServiceProvider(reply: reply)); + + using var cts = new CancellationTokenSource(); + using var sender = new UdpClient(); + await sut.StartAsync(cts.Token); + try + { + await Task.Delay(200); + + var datagram = BuildReplyDatagram("CQ Q1TST JO22"); + await sender.SendAsync(datagram, datagram.Length, "127.0.0.1", port); + + await Task.Delay(500); + await reply.Received(1).TryEngageAsync("Q1TST", Arg.Any()); + } + finally + { + await sut.StopAsync(CancellationToken.None); + } + } + + private static byte[] BuildReplyDatagram(string message) + { + var buf = new List(); + void U32(uint v) { Span b = stackalloc byte[4]; BinaryPrimitives.WriteUInt32BigEndian(b, v); buf.AddRange(b.ToArray()); } + void I32(int v) => U32(unchecked((uint)v)); + void Str(string s) { var bytes = System.Text.Encoding.UTF8.GetBytes(s); U32((uint)bytes.Length); buf.AddRange(bytes); } + void Dbl(double v) { Span b = stackalloc byte[8]; BinaryPrimitives.WriteInt64BigEndian(b, BitConverter.DoubleToInt64Bits(v)); buf.AddRange(b.ToArray()); } + + U32(WsjtxDatagram.Magic); + U32(WsjtxDatagram.SchemaVersion); + U32((uint)WsjtxDatagram.MessageType.Reply); + Str(""); // Id + buf.Add(1); // New + U32(51300); // Time + I32(-10); // SNR + Dbl(0.1); // DeltaTime + U32(1500); // DeltaFrequency + Str("~"); // Mode + Str(message); // Message + buf.Add(0); // LowConfidence + return buf.ToArray(); + } + + // ── Inbound: Free Text gated and a no-op ──────────────────────────────── + + [Fact(DisplayName = "Free Text is stored when honourInboundCommands is enabled but has no transmission effect")] + public async Task FreeText_StoredWhenOptedIn_NoTransmissionEffect() + { + var port = GetFreeUdpPort(); + var config = new AppConfig() with + { + ExternalReporting = new ExternalReportingConfig( + enabled: true, + targets: [new ExternalReportingTarget("A", "127.0.0.1", port, true)], + honourInboundCommands: true) + }; + var store = new MutableConfigStore(config); + var channel = Channel.CreateBounded(2); + var sut = CreateSut(store, channel.Reader); + + using var cts = new CancellationTokenSource(); + using var sender = new UdpClient(); + await sut.StartAsync(cts.Token); + try + { + await Task.Delay(200); + + var buf = new List(); + void U32(uint v) { Span b = stackalloc byte[4]; BinaryPrimitives.WriteUInt32BigEndian(b, v); buf.AddRange(b.ToArray()); } + void Str(string s) { var bytes = System.Text.Encoding.UTF8.GetBytes(s); U32((uint)bytes.Length); buf.AddRange(bytes); } + U32(WsjtxDatagram.Magic); + U32(WsjtxDatagram.SchemaVersion); + U32((uint)WsjtxDatagram.MessageType.FreeText); + Str(""); + Str("TEST MSG"); + buf.Add(1); + await sender.SendAsync(buf.ToArray(), buf.Count, "127.0.0.1", port); + + await Task.Delay(500); + sut.LastFreeText.Should().Be("TEST MSG"); + } + finally + { + await sut.StopAsync(CancellationToken.None); + } + } + + // ── Inbound: Close is logged only, never terminates the daemon ───────── + + [Fact(DisplayName = "FR-054: Inbound Close is logged only and the listener keeps running afterward")] + public async Task InboundClose_DoesNotStopListener() + { + var port = GetFreeUdpPort(); + var qso = Substitute.For(); + qso.AbortAsync(Arg.Any()).Returns(Task.CompletedTask); + + var config = new AppConfig() with + { + ExternalReporting = new ExternalReportingConfig( + enabled: true, + targets: [new ExternalReportingTarget("A", "127.0.0.1", port, true)]) + }; + var store = new MutableConfigStore(config); + var channel = Channel.CreateBounded(2); + var sut = CreateSut(store, channel.Reader, BuildServiceProvider(qso: qso)); + + using var cts = new CancellationTokenSource(); + using var sender = new UdpClient(); + await sut.StartAsync(cts.Token); + try + { + await Task.Delay(200); + + var closeDatagram = WsjtxDatagram.EncodeClose("GridTracker2"); + await sender.SendAsync(closeDatagram, closeDatagram.Length, "127.0.0.1", port); + await Task.Delay(300); + + // The listener must still be alive: a subsequent Halt Tx is still processed. + var haltTx = BuildHaltTxDatagram(); + await sender.SendAsync(haltTx, haltTx.Length, "127.0.0.1", port); + await Task.Delay(500); + + await qso.Received(1).AbortAsync(Arg.Any()); + } + finally + { + await sut.StopAsync(CancellationToken.None); + } + } +} diff --git a/tests/OpenWSFZ.Daemon.Tests/QsoAnswererServiceExternalReplyTests.cs b/tests/OpenWSFZ.Daemon.Tests/QsoAnswererServiceExternalReplyTests.cs new file mode 100644 index 00000000..829f6e16 --- /dev/null +++ b/tests/OpenWSFZ.Daemon.Tests/QsoAnswererServiceExternalReplyTests.cs @@ -0,0 +1,214 @@ +using System.Threading.Channels; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using OpenWSFZ.Abstractions; +using OpenWSFZ.Daemon; +using OpenWSFZ.Web; +using Xunit; + +namespace OpenWSFZ.Daemon.Tests; + +/// +/// Unit tests for (gridtracker-udp-reporting, +/// task 5.4) — the five scenarios in specs/qso-answerer/spec.md's "External reply engages +/// a specific decoded CQ" requirement. +/// +/// +/// Every SUT here is built with autoAnswer=false so that feeding a CQ decode batch never +/// auto-engages it — this isolates 's own +/// engagement path from the pre-existing auto-answer path (already covered by +/// QsoAnswererServiceTests). +/// +/// +/// NFR-021: all callsigns use ITU-unallocated Q-prefix (Q1OFZ = ours, Q1TST = partner). +/// +[Trait("Category", "Unit")] +public sealed class QsoAnswererServiceExternalReplyTests +{ + private const string OurCallsign = "Q1OFZ"; + private const string OurGrid = "JO33"; + private const string PartnerCall = "Q1TST"; + private const int AudioFreqHz = 897; + + private sealed class MutableConfigStore : IConfigStore + { + private AppConfig _current; + public MutableConfigStore(AppConfig initial) => _current = initial; + public AppConfig Current => _current; + public event Action? OnSaved; + public Task SaveAsync(AppConfig config, CancellationToken ct = default) + { + _current = config; + OnSaved?.Invoke(config); + return Task.CompletedTask; + } + } + + private sealed class MutableDecodeFilterStore : IDecodeFilterStore + { + public DecodeFilterState Current { get; private set; } = DecodeFilterState.Unfiltered; + public void Set(DecodeFilterState state) => Current = state; + } + + private sealed record Sut( + QsoAnswererService Service, Channel Channel, IPttController Ptt, + CancellationTokenSource Cts) : IAsyncDisposable + { + public async ValueTask DisposeAsync() + { + await Cts.CancelAsync(); + await Service.StopAsync(CancellationToken.None); + await Ptt.DisposeAsync(); + Cts.Dispose(); + } + } + + private static async Task CreateSutAsync( + IDecodeFilterStore? filterStore = null, bool autoAnswer = false) + { + var config = new AppConfig() with + { + Tx = new TxConfig + { + AutoAnswer = autoAnswer, + Callsign = OurCallsign, + Grid = OurGrid, + RetryCount = 2, + WatchdogMinutes = 4, + } + }; + var store = new MutableConfigStore(config); + var ptt = Substitute.For(); + ptt.KeyDownAsync(Arg.Any()).Returns(Task.CompletedTask); + ptt.KeyUpAsync(Arg.Any()).Returns(Task.CompletedTask); + + var channel = Channel.CreateUnbounded(); + var adifLog = new AdifLogWriter(store, NullLogger.Instance); + + var service = new QsoAnswererService(channel.Reader, store, ptt, new TxEventBus(), + adifLog, new AudioOffsetEventBus(), NullLogger.Instance, + watchdogDurationOverride: TimeSpan.FromMinutes(4), + timeProvider: null, + catState: null, + decodeFilterStore: filterStore); + + var cts = new CancellationTokenSource(); + await service.StartAsync(cts.Token); + return new Sut(service, channel, ptt, cts); + } + + /// + /// Feeds a batch containing a CQ from and waits long enough for the + /// background loop's HandleIdleAsync to record it as the most recent Idle-time batch + /// (consulted by TryEngageExternal). Since autoAnswer=false, the batch is never + /// auto-consumed — no race with the auto-answer path. + /// + private static async Task SeedCqBatchAsync(Sut sut, string cqCallsign = PartnerCall) + { + sut.Channel.Writer.TryWrite(new DecodeBatch( + DateTimeOffset.UtcNow, + [new DecodeResult(Time: "17:30:15", Snr: -5, Dt: 0.1, FreqHz: AudioFreqHz, + Message: $"CQ {cqCallsign} JO22")])); + + // Give the background loop a moment to process the batch into _lastIdleDecodeBatch. + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(2); + while (DateTime.UtcNow < deadline) + { + if (sut.Service.State == QsoState.Idle) + { + await Task.Delay(50); // let HandleIdleAsync actually run past the CQ scan + return; + } + await Task.Delay(10); + } + } + + // ── Scenario: matching decoded CQ engages ─────────────────────────────── + + [Fact(DisplayName = "FR-054: External reply engages a matching decoded CQ")] + public async Task TryEngageExternal_MatchingCq_Engages() + { + await using var sut = await CreateSutAsync(); + await SeedCqBatchAsync(sut); + + var engaged = await sut.Service.TryEngageExternal(PartnerCall); + + engaged.Should().BeTrue("the callsign is a non-filtered CQ in the most recent decode batch"); + sut.Service._wakeupChannel.Reader.TryRead(out _); // drain wakeup, mirroring AnswerCqAsync tests + } + + // ── Scenario: works even when autoAnswer is disabled ──────────────────── + + [Fact(DisplayName = "External reply works even when autoAnswer is disabled")] + public async Task TryEngageExternal_AutoAnswerDisabled_StillEngages() + { + await using var sut = await CreateSutAsync(autoAnswer: false); + await SeedCqBatchAsync(sut); + + var engaged = await sut.Service.TryEngageExternal(PartnerCall); + + engaged.Should().BeTrue("TryEngageExternal is not gated by tx.autoAnswer"); + sut.Service._wakeupChannel.Reader.TryRead(out _); + } + + // ── Scenario: unknown callsign is a no-op ─────────────────────────────── + + [Fact(DisplayName = "External reply to an unknown callsign is a no-op")] + public async Task TryEngageExternal_UnknownCallsign_NoOp() + { + await using var sut = await CreateSutAsync(); + await SeedCqBatchAsync(sut); // seeds a CQ from PartnerCall (Q1TST), not Q9ZZZ + + var engaged = await sut.Service.TryEngageExternal("Q9ZZZ"); + + engaged.Should().BeFalse("no CQ from Q9ZZZ is present in the most recent decode batch"); + sut.Service.State.Should().Be(QsoState.Idle); + await sut.Ptt.DidNotReceive().KeyDownAsync(Arg.Any()); + } + + // ── Scenario: filtered-out callsign is a no-op ────────────────────────── + + [Fact(DisplayName = "External reply to a filtered-out callsign is a no-op")] + public async Task TryEngageExternal_FilteredOutCallsign_NoOp() + { + var filterStore = new MutableDecodeFilterStore(); + await using var sut = await CreateSutAsync(filterStore: filterStore); + await SeedCqBatchAsync(sut); + + // Attribute allow-list axes (AllowedEntities etc.) fail OPEN when Region is unresolved + // (DecodeFilterEvaluator's documented fail-open rule) — our seeded decode has no Region, + // so those axes can never hide it. A worked-before axis has no such fail-open guard: a + // null WorkedBefore is treated as WorkedBeforeInfo.None (Contact = Never), so an empty + // ContactStates allow-list genuinely filters this decode out. + filterStore.Set(new DecodeFilterState(ContactStates: [])); + + var engaged = await sut.Service.TryEngageExternal(PartnerCall); + + engaged.Should().BeFalse("Q1TST's CQ is present but filtered out under the active DecodeFilterState"); + sut.Service.State.Should().Be(QsoState.Idle); + await sut.Ptt.DidNotReceive().KeyDownAsync(Arg.Any()); + } + + // ── Scenario: already engaged is a no-op ──────────────────────────────── + + [Fact(DisplayName = "External reply while already engaged is a no-op")] + public async Task TryEngageExternal_AlreadyEngaged_NoOp() + { + await using var sut = await CreateSutAsync(); + await SeedCqBatchAsync(sut); + + // Force a different in-progress engagement via reflection (mirrors the existing test + // file's pattern for setting _state/_partner directly without a real TX cycle). + var type = typeof(QsoAnswererService); + var flags = System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance; + type.GetField("_state", flags)!.SetValue(sut.Service, QsoState.WaitReport); + type.GetField("_partner", flags)!.SetValue(sut.Service, "Q2OTHER"); + + var engaged = await sut.Service.TryEngageExternal(PartnerCall); + + engaged.Should().BeFalse("the service is not Idle — already mid-QSO with a different partner"); + sut.Service.State.Should().Be(QsoState.WaitReport, "the in-progress QSO must continue unaffected"); + sut.Service.Partner.Should().Be("Q2OTHER"); + } +} diff --git a/tests/OpenWSFZ.Daemon.Tests/WsjtxDatagramTests.cs b/tests/OpenWSFZ.Daemon.Tests/WsjtxDatagramTests.cs new file mode 100644 index 00000000..19c90a4a --- /dev/null +++ b/tests/OpenWSFZ.Daemon.Tests/WsjtxDatagramTests.cs @@ -0,0 +1,372 @@ +using System.Buffers.Binary; +using System.Text; +using FluentAssertions; +using Xunit; + +namespace OpenWSFZ.Daemon.Tests; + +/// +/// Unit tests for (gridtracker-udp-reporting, task 2.5). +/// Covers byte-exact encode assertions for every outbound type, decode round-trips for every +/// inbound type, and the malformed-input resilience guarantee (never throws). +/// +[Trait("Category", "Unit")] +public sealed class WsjtxDatagramTests +{ + // ── Helpers ────────────────────────────────────────────────────────────── + + private static uint ReadU32BE(byte[] data, int offset) => + BinaryPrimitives.ReadUInt32BigEndian(data.AsSpan(offset, 4)); + + /// Encodes a WSJT-X-protocol string field (len-prefixed UTF-8) for hand-built expected buffers. + private static byte[] Utf8Field(string s) + { + var bytes = Encoding.UTF8.GetBytes(s); + var buf = new byte[4 + bytes.Length]; + BinaryPrimitives.WriteUInt32BigEndian(buf, (uint)bytes.Length); + bytes.CopyTo(buf, 4); + return buf; + } + + // ── Header framing (all message types) ────────────────────────────────── + + [Fact(DisplayName = "Every encoded datagram starts with magic + schema + type + id")] + public void Encode_AllTypes_StartWithCorrectHeader() + { + var datagram = WsjtxDatagram.EncodeHeartbeat("OpenWSFZ", 3, "1.0.0", "abc123"); + + ReadU32BE(datagram, 0).Should().Be(WsjtxDatagram.Magic); + ReadU32BE(datagram, 4).Should().Be(WsjtxDatagram.SchemaVersion); + ReadU32BE(datagram, 8).Should().Be((uint)WsjtxDatagram.MessageType.Heartbeat); + } + + // ── Byte-exact encode: Heartbeat ──────────────────────────────────────── + + [Fact(DisplayName = "EncodeHeartbeat produces the exact expected byte sequence")] + public void EncodeHeartbeat_ProducesExactBytes() + { + var actual = WsjtxDatagram.EncodeHeartbeat("OpenWSFZ", 3, "1.2.3", "rev1"); + + var expected = new List(); + expected.AddRange(BitConverter.GetBytes(WsjtxDatagram.Magic).Reverse()); + expected.AddRange(BitConverter.GetBytes(WsjtxDatagram.SchemaVersion).Reverse()); + expected.AddRange(BitConverter.GetBytes((uint)WsjtxDatagram.MessageType.Heartbeat).Reverse()); + expected.AddRange(Utf8Field("OpenWSFZ")); + expected.AddRange(BitConverter.GetBytes(3).Reverse()); + expected.AddRange(Utf8Field("1.2.3")); + expected.AddRange(Utf8Field("rev1")); + + actual.Should().Equal(expected); + } + + // ── Byte-exact encode: Clear / Close (header only) ────────────────────── + + [Fact(DisplayName = "EncodeClear produces header-only bytes with the Clear type")] + public void EncodeClear_ProducesHeaderOnlyBytes() + { + var actual = WsjtxDatagram.EncodeClear("OpenWSFZ"); + + var expected = new List(); + expected.AddRange(BitConverter.GetBytes(WsjtxDatagram.Magic).Reverse()); + expected.AddRange(BitConverter.GetBytes(WsjtxDatagram.SchemaVersion).Reverse()); + expected.AddRange(BitConverter.GetBytes((uint)WsjtxDatagram.MessageType.Clear).Reverse()); + expected.AddRange(Utf8Field("OpenWSFZ")); + + actual.Should().Equal(expected); + } + + [Fact(DisplayName = "EncodeClose produces header-only bytes with the Close type")] + public void EncodeClose_ProducesHeaderOnlyBytes() + { + var actual = WsjtxDatagram.EncodeClose("OpenWSFZ"); + actual.Length.Should().Be(12 + Utf8Field("OpenWSFZ").Length); + ReadU32BE(actual, 8).Should().Be((uint)WsjtxDatagram.MessageType.Close); + } + + // ── Encode: Status / Decode / QSOLogged — field-presence sanity ───────── + + [Fact(DisplayName = "EncodeStatus embeds the operator/DX callsigns as UTF-8 substrings")] + public void EncodeStatus_ContainsExpectedStrings() + { + var fields = new WsjtxDatagram.StatusFields( + DialFrequencyHz: 14_074_000, + Mode: "FT8", + DxCall: "Q1TST", + Report: "+05", + TxMode: "FT8", + TxEnabled: true, + Transmitting: false, + Decoding: true, + RxDeltaFreqHz: 900, + TxDeltaFreqHz: 1500, + MyCall: "Q1OFZ", + MyGrid: "JO33", + DxGrid: "JO22"); + + var datagram = WsjtxDatagram.EncodeStatus("OpenWSFZ", fields); + var text = Encoding.UTF8.GetString(datagram); + + text.Should().Contain("Q1TST").And.Contain("Q1OFZ").And.Contain("JO33").And.Contain("JO22"); + ReadU32BE(datagram, 8).Should().Be((uint)WsjtxDatagram.MessageType.Status); + } + + [Fact(DisplayName = "EncodeDecode embeds the decoded message text")] + public void EncodeDecode_ContainsMessageText() + { + var fields = new WsjtxDatagram.DecodeFields( + New: true, + TimeMsSinceMidnightUtc: 12345, + SnrDb: -10, + DeltaTimeSeconds: 0.2, + DeltaFrequencyHz: 1500, + Mode: "~", + Message: "CQ Q1TST JO22", + LowConfidence: false); + + var datagram = WsjtxDatagram.EncodeDecode("OpenWSFZ", fields); + Encoding.UTF8.GetString(datagram).Should().Contain("CQ Q1TST JO22"); + ReadU32BE(datagram, 8).Should().Be((uint)WsjtxDatagram.MessageType.Decode); + } + + [Fact(DisplayName = "EncodeQsoLogged embeds partner callsign and grid")] + public void EncodeQsoLogged_ContainsPartnerFields() + { + var fields = new WsjtxDatagram.QsoLoggedFields( + QsoStartUtc: new DateTimeOffset(2026, 7, 12, 14, 29, 15, TimeSpan.Zero), + QsoEndUtc: new DateTimeOffset(2026, 7, 12, 14, 30, 0, TimeSpan.Zero), + DxCall: "Q1TST", + DxGrid: "JO22", + TxFrequencyHz: 14_074_000, + Mode: "FT8", + ReportSent: "+05", + ReportReceived: "-03", + MyCall: "Q1OFZ", + MyGrid: "JO33"); + + var datagram = WsjtxDatagram.EncodeQsoLogged("OpenWSFZ", fields); + Encoding.UTF8.GetString(datagram).Should().Contain("Q1TST").And.Contain("Q1OFZ"); + ReadU32BE(datagram, 8).Should().Be((uint)WsjtxDatagram.MessageType.QsoLogged); + } + + // ── Decode: Heartbeat ──────────────────────────────────────────────────── + + [Fact(DisplayName = "TryDecode parses a Heartbeat datagram encoded by EncodeHeartbeat")] + public void TryDecode_Heartbeat_RoundTrips() + { + var datagram = WsjtxDatagram.EncodeHeartbeat("GridTracker2", 3, "2.1.0", "revX"); + + var ok = WsjtxDatagram.TryDecode(datagram, out var message); + + ok.Should().BeTrue(); + message.Should().BeOfType(); + var hb = (WsjtxDatagram.InboundMessage.HeartbeatMessage)message!; + hb.MaxSchemaNumber.Should().Be(3); + hb.Version.Should().Be("2.1.0"); + hb.Revision.Should().Be("revX"); + } + + // ── Decode: Reply (mirrors Decode's field layout) ─────────────────────── + + [Fact(DisplayName = "TryDecode parses a Reply datagram and extracts the message text")] + public void TryDecode_Reply_ExtractsMessage() + { + // Hand-build a Reply datagram: header + New(bool) + Time(u32) + SNR(i32) + + // DeltaTime(double) + DeltaFrequency(u32) + Mode(str) + Message(str) + LowConfidence(bool). + var buf = new List(); + void U32(uint v) { Span b = stackalloc byte[4]; BinaryPrimitives.WriteUInt32BigEndian(b, v); buf.AddRange(b.ToArray()); } + void I32(int v) => U32(unchecked((uint)v)); + void Str(string s) { var bytes = Encoding.UTF8.GetBytes(s); U32((uint)bytes.Length); buf.AddRange(bytes); } + void Dbl(double v) { Span b = stackalloc byte[8]; BinaryPrimitives.WriteInt64BigEndian(b, BitConverter.DoubleToInt64Bits(v)); buf.AddRange(b.ToArray()); } + + U32(WsjtxDatagram.Magic); + U32(WsjtxDatagram.SchemaVersion); + U32((uint)WsjtxDatagram.MessageType.Reply); + Str("GridTracker2"); + buf.Add(1); // New = true + U32(51300); // Time + I32(-10); // SNR + Dbl(0.1); // DeltaTime + U32(1500); // DeltaFrequency + Str("~"); // Mode + Str("CQ Q1TST JO22"); // Message + buf.Add(0); // LowConfidence = false + + var ok = WsjtxDatagram.TryDecode(buf.ToArray(), out var message); + + ok.Should().BeTrue(); + var reply = message.Should().BeOfType().Subject; + reply.Message.Should().Be("CQ Q1TST JO22"); + reply.DeltaFrequencyHz.Should().Be(1500); + } + + // ── Decode: Halt Tx ────────────────────────────────────────────────────── + + [Fact(DisplayName = "TryDecode parses a Halt Tx datagram")] + public void TryDecode_HaltTx_Parses() + { + var buf = new List(); + void U32(uint v) { Span b = stackalloc byte[4]; BinaryPrimitives.WriteUInt32BigEndian(b, v); buf.AddRange(b.ToArray()); } + void Str(string s) { var bytes = Encoding.UTF8.GetBytes(s); U32((uint)bytes.Length); buf.AddRange(bytes); } + + U32(WsjtxDatagram.Magic); + U32(WsjtxDatagram.SchemaVersion); + U32((uint)WsjtxDatagram.MessageType.HaltTx); + Str("GridTracker2"); + buf.Add(1); // AutoTxOnly = true + + var ok = WsjtxDatagram.TryDecode(buf.ToArray(), out var message); + + ok.Should().BeTrue(); + message.Should().BeOfType(); + } + + // ── Decode: Free Text ──────────────────────────────────────────────────── + + [Fact(DisplayName = "TryDecode parses a Free Text datagram and extracts the text")] + public void TryDecode_FreeText_ExtractsText() + { + var buf = new List(); + void U32(uint v) { Span b = stackalloc byte[4]; BinaryPrimitives.WriteUInt32BigEndian(b, v); buf.AddRange(b.ToArray()); } + void Str(string s) { var bytes = Encoding.UTF8.GetBytes(s); U32((uint)bytes.Length); buf.AddRange(bytes); } + + U32(WsjtxDatagram.Magic); + U32(WsjtxDatagram.SchemaVersion); + U32((uint)WsjtxDatagram.MessageType.FreeText); + Str("GridTracker2"); + Str("TEST MSG"); + buf.Add(1); // Send = true + + var ok = WsjtxDatagram.TryDecode(buf.ToArray(), out var message); + + ok.Should().BeTrue(); + var ft = message.Should().BeOfType().Subject; + ft.Text.Should().Be("TEST MSG"); + } + + // ── Decode: Close ──────────────────────────────────────────────────────── + + [Fact(DisplayName = "TryDecode parses a Close datagram")] + public void TryDecode_Close_Parses() + { + var datagram = WsjtxDatagram.EncodeClose("GridTracker2"); + + var ok = WsjtxDatagram.TryDecode(datagram, out var message); + + ok.Should().BeTrue(); + message.Should().BeOfType(); + } + + // ── Decode: unrecognised-but-well-formed types are accepted, not acted on ─ + + [Theory(DisplayName = "TryDecode accepts well-formed unsupported types without error")] + [InlineData((uint)WsjtxDatagram.MessageType.Replay)] + [InlineData((uint)WsjtxDatagram.MessageType.Location)] + [InlineData((uint)WsjtxDatagram.MessageType.HighlightCallsignInList)] + [InlineData((uint)WsjtxDatagram.MessageType.SwitchConfiguration)] + [InlineData((uint)WsjtxDatagram.MessageType.Configure)] + [InlineData((uint)WsjtxDatagram.MessageType.WsprDecode)] + public void TryDecode_UnsupportedTypes_ReturnsUnsupportedMessage(uint typeRaw) + { + var type = (WsjtxDatagram.MessageType)typeRaw; + var buf = new List(); + void U32(uint v) { Span b = stackalloc byte[4]; BinaryPrimitives.WriteUInt32BigEndian(b, v); buf.AddRange(b.ToArray()); } + void Str(string s) { var bytes = Encoding.UTF8.GetBytes(s); U32((uint)bytes.Length); buf.AddRange(bytes); } + + U32(WsjtxDatagram.Magic); + U32(WsjtxDatagram.SchemaVersion); + U32((uint)type); + Str("WSJT-X"); + + var ok = WsjtxDatagram.TryDecode(buf.ToArray(), out var message); + + ok.Should().BeTrue("a well-formed but unimplemented type must not be treated as malformed"); + var unsupported = message.Should().BeOfType().Subject; + unsupported.Type.Should().Be(type); + } + + // ── Malformed-input resilience ─────────────────────────────────────────── + + [Fact(DisplayName = "TryDecode discards a 3-byte garbage datagram without throwing")] + public void TryDecode_TruncatedGarbage_ReturnsFalse() + { + var garbage = new byte[] { 0x01, 0x02, 0x03 }; + + var act = () => WsjtxDatagram.TryDecode(garbage, out _); + act.Should().NotThrow(); + + WsjtxDatagram.TryDecode(garbage, out var message).Should().BeFalse(); + message.Should().BeNull(); + } + + [Fact(DisplayName = "TryDecode rejects a datagram with a bad magic number")] + public void TryDecode_BadMagic_ReturnsFalse() + { + var buf = new byte[16]; + BinaryPrimitives.WriteUInt32BigEndian(buf.AsSpan(0), 0xdeadbeef); + BinaryPrimitives.WriteUInt32BigEndian(buf.AsSpan(4), WsjtxDatagram.SchemaVersion); + BinaryPrimitives.WriteUInt32BigEndian(buf.AsSpan(8), (uint)WsjtxDatagram.MessageType.Heartbeat); + + WsjtxDatagram.TryDecode(buf, out var message).Should().BeFalse(); + message.Should().BeNull(); + } + + [Fact(DisplayName = "TryDecode rejects an unsupported schema version")] + public void TryDecode_UnsupportedSchema_ReturnsFalse() + { + var buf = new byte[16]; + BinaryPrimitives.WriteUInt32BigEndian(buf.AsSpan(0), WsjtxDatagram.Magic); + BinaryPrimitives.WriteUInt32BigEndian(buf.AsSpan(4), 99); + BinaryPrimitives.WriteUInt32BigEndian(buf.AsSpan(8), (uint)WsjtxDatagram.MessageType.Heartbeat); + + WsjtxDatagram.TryDecode(buf, out var message).Should().BeFalse(); + message.Should().BeNull(); + } + + [Fact(DisplayName = "TryDecode rejects a datagram truncated mid-string-length field")] + public void TryDecode_TruncatedStringLength_ReturnsFalse() + { + // Valid header, then a string-length field claiming a huge length with no data following. + var buf = new List(); + void U32(uint v) { Span b = stackalloc byte[4]; BinaryPrimitives.WriteUInt32BigEndian(b, v); buf.AddRange(b.ToArray()); } + U32(WsjtxDatagram.Magic); + U32(WsjtxDatagram.SchemaVersion); + U32((uint)WsjtxDatagram.MessageType.Heartbeat); + U32(999_999); // claimed Id string length far exceeding the remaining buffer + + var act = () => WsjtxDatagram.TryDecode(buf.ToArray(), out _); + act.Should().NotThrow(); + WsjtxDatagram.TryDecode(buf.ToArray(), out var message).Should().BeFalse(); + message.Should().BeNull(); + } + + [Fact(DisplayName = "TryDecode handles a zero-length buffer without throwing")] + public void TryDecode_EmptyBuffer_ReturnsFalse() + { + var act = () => WsjtxDatagram.TryDecode(ReadOnlySpan.Empty, out _); + act.Should().NotThrow(); + WsjtxDatagram.TryDecode(ReadOnlySpan.Empty, out var message).Should().BeFalse(); + message.Should().BeNull(); + } + + [Theory(DisplayName = "TryDecode survives a range of fuzzed random buffers without throwing")] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(4)] + [InlineData(8)] + [InlineData(16)] + [InlineData(32)] + [InlineData(64)] + public void TryDecode_FuzzedRandomBuffers_NeverThrows(int length) + { + var rnd = new Random(12345 + length); + for (var i = 0; i < 50; i++) + { + var buf = new byte[length]; + rnd.NextBytes(buf); + + var act = () => WsjtxDatagram.TryDecode(buf, out _); + act.Should().NotThrow(); + } + } +} diff --git a/tests/OpenWSFZ.Web.Tests/ConfigApiNullGuardTests.cs b/tests/OpenWSFZ.Web.Tests/ConfigApiNullGuardTests.cs index 09d2e7be..49cdd4c4 100644 --- a/tests/OpenWSFZ.Web.Tests/ConfigApiNullGuardTests.cs +++ b/tests/OpenWSFZ.Web.Tests/ConfigApiNullGuardTests.cs @@ -115,4 +115,35 @@ public async Task PostConfig_OmittingDecodeNoiseSuppressionKey_DoesNotPersistNul "the recovered DecodeNoiseSuppression must be the default DecodeNoiseSuppressionConfig(), " + "i.e. SuppressSynthetic = true"); } + + [Fact(DisplayName = "gridtracker-udp-reporting: POST omitting \"externalReporting\" key does not persist a null ExternalReporting")] + public async Task PostConfig_OmittingExternalReportingKey_DoesNotPersistNullExternalReporting() + { + 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 externalReporting must still be accepted"); + + 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("externalReporting", out var extRepElement).Should().BeTrue( + "GET /api/v1/config must include the externalReporting key"); + extRepElement.ValueKind.Should().NotBe(JsonValueKind.Null, + "a POST body omitting \"externalReporting\" must never leave " + + "IConfigStore.Current.ExternalReporting null (this class of bug crashes " + + "ExternalReportingService.Reconcile, called synchronously from IConfigStore.OnSaved " + + "on every subsequent POST /api/v1/config)"); + + // Must be default ExternalReportingConfig values, not merely non-null. + extRepElement.GetProperty("enabled").GetBoolean().Should().BeFalse( + "the recovered ExternalReporting must be the default ExternalReportingConfig(), i.e. disabled"); + } } diff --git a/tests/OpenWSFZ.Web.Tests/ExternalReportingConfigValidationTests.cs b/tests/OpenWSFZ.Web.Tests/ExternalReportingConfigValidationTests.cs new file mode 100644 index 00000000..c9d282e5 --- /dev/null +++ b/tests/OpenWSFZ.Web.Tests/ExternalReportingConfigValidationTests.cs @@ -0,0 +1,85 @@ +using FluentAssertions; +using OpenWSFZ.Abstractions; +using System.Net; +using System.Net.Http.Json; +using Xunit; + +namespace OpenWSFZ.Web.Tests; + +/// +/// Integration tests for the externalReporting.targets[].port validation rule +/// (gridtracker-udp-reporting, specs/configuration/spec.md "Out-of-range port rejected"). +/// Verifies that POST /api/v1/config rejects a target whose port falls outside +/// 1–65535 with no partial persistence, and accepts valid configurations. +/// +[Trait("Category", "Integration")] +public sealed class ExternalReportingConfigValidationTests : IClassFixture +{ + private readonly WebTestFactory _factory; + + public ExternalReportingConfigValidationTests(WebTestFactory factory) => _factory = factory; + + [Fact(DisplayName = "FR-052: POST with target port 70000 returns 400 and does not persist")] + public async Task PostConfig_TargetPortOutOfRange_Returns400() + { + var client = _factory.CreateClient(); + var payload = new AppConfig() with + { + ExternalReporting = new ExternalReportingConfig( + enabled: true, + targets: [new ExternalReportingTarget(name: "GridTracker2", host: "127.0.0.1", port: 70000, enabled: true)]) + }; + + var response = await client.PostAsJsonAsync("/api/v1/config", payload, + AppJsonContext.Default.AppConfig); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest, + "a target port outside 1-65535 must be rejected"); + + // No partial persistence: a fresh GET must not reflect the rejected target. + var current = await client.GetFromJsonAsync( + "/api/v1/config", AppJsonContext.Default.AppConfig); + current!.ExternalReporting.Targets.Should().NotContain( + t => t.Port == 70000, "a rejected POST must not persist any part of its payload"); + } + + [Fact(DisplayName = "POST with target port 0 returns 400")] + public async Task PostConfig_TargetPortZero_Returns400() + { + var client = _factory.CreateClient(); + var payload = new AppConfig() with + { + ExternalReporting = new ExternalReportingConfig( + enabled: true, + targets: [new ExternalReportingTarget(name: "GridTracker2", host: "127.0.0.1", port: 0, enabled: true)]) + }; + + var response = await client.PostAsJsonAsync("/api/v1/config", payload, + AppJsonContext.Default.AppConfig); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact(DisplayName = "POST with a valid target port persists and round-trips")] + public async Task PostConfig_ValidTarget_Returns200AndPersists() + { + var client = _factory.CreateClient(); + var payload = new AppConfig() with + { + ExternalReporting = new ExternalReportingConfig( + enabled: true, + targets: [new ExternalReportingTarget(name: "GridTracker2", host: "127.0.0.1", port: 2237, enabled: true)], + honourInboundCommands: false) + }; + + var response = await client.PostAsJsonAsync("/api/v1/config", payload, + AppJsonContext.Default.AppConfig); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var current = await client.GetFromJsonAsync( + "/api/v1/config", AppJsonContext.Default.AppConfig); + current!.ExternalReporting.Enabled.Should().BeTrue(); + current.ExternalReporting.Targets.Should().ContainSingle(t => t.Port == 2237); + } +} diff --git a/traceability-debt.md b/traceability-debt.md index aca06b65..9c0be63c 100644 --- a/traceability-debt.md +++ b/traceability-debt.md @@ -163,3 +163,17 @@ SEC-003 # XSS guard — settings.js appendFreqRow DOM construction. # by code review (grep confirms no innerHTML assignment receives server data). # Remove when a browser-automation test (e.g. Playwright) asserts that # inserting a description containing