feat(gridtracker-udp-reporting): WSJT-X UDP broadcaster + inbound listener + Settings tab#70
Conversation
…(task 1) Add ExternalReportingConfig/ExternalReportingTarget records, wire into AppConfig with fully-inert defaults, register with both JSON source-gen contexts, add the JsonConfigStore null-guard + default-config section, and reject out-of-range target ports (1-65535) on POST /api/v1/config with no partial persistence. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…rialisation (task 2) Add WsjtxDatagram: magic/schema header framing, big-endian primitives, length-prefixed UTF-8 strings, encode for Heartbeat/Status/Decode/Clear/QSOLogged/Close, decode for Heartbeat/Reply/HaltTx/FreeText/Close plus a generic well-formed-but-unsupported path, and a never-throws guarantee on malformed input (verified by fuzzed-buffer tests). No live WSJT-X/GridTracker2 wire capture was available to verify the richer message layouts byte-for-byte (task 2.6 skipped, flagged as a risk in tasks.md) — recommend a real capture or GridTracker2 sanity check (task 10.3) before production use. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Add IExternalReplyTarget (OpenWSFZ.Web) implemented by QsoControllerRouter, routing by active role: Answerer delegates to the new QsoAnswererService.TryEngageExternal (reuses the CQ-matching/DecodeFilterState/empty-callsign guards, targets a specific callsign, not gated by tx.autoAnswer); Caller delegates to a thin TryEngageExternalResponder wrapper around the existing, unmodified SelectResponderAsync seam. Refactor AnswerCqAsync's phase-arming logic into a shared ArmPendingTarget helper reused by both call paths. Cover all five qso-answerer/spec.md scenarios. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ound listener (tasks 3-4) Add ExternalReportingService: registered unconditionally, inert by default; config-save reconciliation opens/closes per-target outbound UdpClients and rebinds the single inbound listener to the first enabled target's port without a daemon restart; Heartbeat/Status on a timer plus on-change; Clear+Decode from a new dedicated decode-batch channel; QSOLogged via a QsoLoggedNotifyingAdifWriter decorator covering every ADIF-write call site (including the default qsoConfirmation=true POST /api/v1/tx/log-qso path); Close sent on graceful shutdown. Inbound: Halt Tx always honoured, Reply/Free Text gated by honourInboundCommands, Close logged only, unsupported types discarded at Debug. IQsoController/IExternalReplyTarget are resolved lazily via IServiceProvider rather than constructor injection to avoid a DI construction cycle through IAdifLogWriter. Fixes found during testing: - StartAsync captured _cts.Token inside the Task.Run lambdas instead of a local, causing a NullReferenceException race when StopAsync ran concurrently with startup. - Reconcile threw NRE on a null ExternalReportingConfig (the same STJ deserialisation null-vs-initialiser quirk already guarded for Logging/DecodeLog/RemoteAccess/ DecodeNoiseSuppression) — added the missing WebApp.cs POST /api/v1/config guard plus a defensive coalesce in Reconcile itself, with a regression test. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…tasks 6-8) Add the "External Programs" tab (Enabled checkbox, targets table with Name/Host/Port/Enabled/Delete columns + Add target, Honour-inbound-commands checkbox with Halt-Tx-is-always-on explanatory text), following the existing settings-tab-btn/ settings-tab-panel and Frequencies-tab table patterns exactly. Wired into the existing dirty-check and POST /api/v1/config payload; client-side port-range validation mirrors the daemon's own rejection. Tab switching/sessionStorage persistence needed no code change (fully generic). Live-verified end-to-end against a real running daemon via Playwright: tab renders correctly with all 8 tabs on one row, Save/reload round-trips enabled/targets/ honourInboundCommands correctly, honourInboundCommands persists independently of enabled, out-of-range port is rejected client-side, delete-row works (its lack of placeholder re-insertion on empty matches the pre-existing Frequencies tab exactly, not a new gap). Before/after screenshots per HK-005 ordering. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…aceability (task 9) Append FR-052 (externalReporting config schema), FR-053 (outbound broadcaster), FR-054 (inbound listener + trust boundary + QsoAnswererService.TryEngageExternal), and FR-055 (External Programs settings tab) to REQUIREMENTS.md §4.1; add a §4.3 Integrations row for GridTracker2/WSJT-X UDP protocol, distinct from the still-future PSK Reporter/DX cluster row; add revision-history row 1.31 noting this is a post-v1.0 addition that does not alter any IMPLEMENTATION_PLAN.md phase or gate. Bump VERSION to 0.35 (user-facing: new Settings tab) per release-versioning. Close the traceability gate (G3): FR-052/053/054 test DisplayNames now carry their requirement-ID prefixes; FR-055 (frontend-only) added to traceability-debt.md following the exact precedent of FR-035/036/037/040/041/043/044. Verified locally by running tools/TraceabilityCheck against the built test assemblies: PASS, all requirements mapped. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Full test suite green (1090/1090 across all 9 test projects, no root .sln so run individually), openspec validate --strict --all clean (53/53). Task 10.3 (manual GridTracker2 sanity check) not performed — no install/rig available in this environment; flagged alongside task 2.6 as the one open risk item before relying on this feature operationally. All 41 tasks in gridtracker-udp-reporting/tasks.md are now complete. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Task 5.5 (wire inbound Reply handler to IExternalReplyTarget.TryEngageAsync) was actually completed as part of the task 3/4 ExternalReportingService commit (HandleReply) but its checkbox was left unchecked with a stale "deferred" note. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…s with a peer already on the port Part A: set SO_REUSEADDR before binding the inbound listener (Reconcile), mirroring the Qt ShareAddress|ReuseAddressHint bind option every WSJT-X-protocol peer (GridTracker2 included) uses — without it, the bind threw whenever the operator's mapping tool was already running (the normal case), the exception was swallowed and logged at Warning only, and Halt Tx/Reply/Free Text became silently and permanently unreachable. Part B: outbound sends to the primary (index 0) target now go through the same bound _inboundClient 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") 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+) are unaffected — each keeps its own dedicated outbound-only client as before. Added two regression tests. Empirically confirmed while writing them that Windows delivers shared-port UDP unicast to only the first-bound socket (no Linux-style SO_REUSEPORT fan-out) — a real, pre-existing platform limitation that true concurrent multi-listener delivery on one port would need multicast to fully solve (already an unimplemented design.md Open Question, unchanged). The tests prove what's actually fixed: the bind no longer fails/stays permanently null when a peer already owns the port at startup, and the primary target's sends genuinely originate from the shared bound port — not simultaneous coexistence delivery, which this fix was never scoped to guarantee on this platform. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…wn-region traffic Captain's directive, no exceptions: R&R-study synthetic signals and unresolved (unknown-region) callsigns must never reach an external program via this feature, regardless of the operator's DecodeNoiseSuppressionConfig settings (which gate the decode panel/QSO automation only and can be turned off). Tier 1 (outbound Decode): DecodeLoopAsync unconditionally skips any result with Region == null or Region.Synthetic before encoding, reusing DecodeResult.Region's already-resolved semantics (the same test DecodeNoiseSuppressionFilter uses) rather than re-deriving from the callsign string. Clear still fires every cycle regardless. Tier 2 (outbound Status/QSOLogged): added an optional ICallsignRegionStore constructor parameter (wired in Program.cs) and a private IsSuppressedCallsign helper that resolves a bare callsign (stripping any portable suffix) and fails CLOSED — a null store or a lookup miss is treated as suppressed, not open, since this is a data-integrity floor, not a convenience feature. BuildStatusFields blanks DxCall/DxGrid for a synthetic/unknown active partner (rest of Status keeps flowing normally); NotifyQsoLogged returns early for such a partner, mirroring the existing no-record-on-abort pattern. Not exposed as, or controllable via, any Settings-page control or config field. Fixed two pre-existing tests whose synthetic Q-prefix NFR-021 test callsigns (with no Region set) were now spuriously suppressed by the new filter — gave them a resolvable, non-synthetic Region so they keep testing what they were meant to test (delivery mechanics), and added a FakeCallsignRegionStore test double plus 6 new tests covering both tiers per the dev-task's acceptance criteria. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… for D-014 + exclusion (task 11.7) Amended in place on this still-unmerged change (no new OpenSpec proposal, per the review-fixes dev-task's own instruction): - specs/external-reporting/spec.md: added the absolute-exclusion clause to the Outbound Decode/Status/QSOLogged requirements, plus new scenarios for each. - design.md: Decision 6 (absolute exclusion is hard-coded, not configurable, and why) and Decision 7 (D-014's ReuseAddress/shared-socket fix, including the empirically- found Windows platform limitation on shared-port unicast delivery); updated the multicast Open Question to reflect that finding. - tasks.md: new §11 documenting all six review-fix tasks (checked off), and updated 10.1/10.2's test-count/build/traceability figures to the actual final run (was 1090/1090 + 53/53 pre-fix; now 1098/1098 + 53/53, zero build warnings, traceability still clean) so no already-checked task carries stale numbers. - REQUIREMENTS.md: FR-053 now documents the absolute exclusion; folded a same-day amendment note into the existing 1.31 revision-history row rather than adding a new row, since this change had not yet merged to main. Verified before this commit: full test suite 1098/1098, openspec validate --strict --all 53/53, dotnet build zero warnings/errors across every src/ project, tools/TraceabilityCheck PASS — all nine dev-task acceptance criteria (AC-1..AC-9) met. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Review fixes landed (dev-tasks/2026-07-12-gridtracker-udp-reporting-review-fixes.md)Both required items from QA's pre-merge review are implemented, tested, and documented on this branch: D-014 — the inbound listener's bind silently failed whenever a peer (e.g. GridTracker2) already owned the configured port at daemon startup — the normal case, not an edge case. Fixed with Absolute exclusion of synthetic/unknown-region traffic — implemented as a hard-coded, non-configurable filter inside Verification: 1098/1098 tests green (up from 1090; +8 new, 2 pre-existing fixed not broken — their NFR-021 Q-prefix synthetic test callsigns were now legitimately caught by the new filter, so they were given a resolvable, non-synthetic region to keep testing delivery mechanics rather than the new exclusion feature), Docs amended in place on this same branch (no new OpenSpec proposal, per the dev-task's own instruction): 🤖 Generated with Claude Code |
… VERSION=0.35 Gate G9 (version governance) failed on PR #70: task 9's commit (7cc8ae5) bumped VERSION 0.34 -> 0.35 but never updated the "current release" anchor sentence in README.md or REQUIREMENTS.md, leaving both citing the prior version. Docs-only, mechanical sync; no behavioural change. Verified locally: tools/check_version_docs.py now reports OK for both documents. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Checked PR #70's own CI before merging (not just my local Windows-only re-run) and found ubuntu-latest failing on OutboundToPrimaryTarget_UsesSharedInboundPort — a timeout, while windows-latest/macos-latest are green. Likely cause: the test relies on unspecified OS arbitration for which of two SO_REUSEADDR-shared UDP sockets receives a given unicast datagram, and Linux's last-bind-wins behaviour (vs the already-documented Windows first-bind-wins) means the daemon's own _inboundClient (bound second) may be swallowing its own self-addressed loopback send before the test's fakePeer observer ever sees it. Filed as a developer handoff rather than patching it myself, since it touches test logic tied to networking behaviour of the actual service and may indicate a genuine Linux-specific finding (symmetric to the existing Windows one in design.md Decision 7), not just a flaky assertion. Not merging PR #70 until this is resolved and CI is green on all three platforms. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
OutboundToPrimaryTarget_UsesSharedInboundPort raced a second socket bound to the shared port to observe the daemon's own outbound send over the wire, assuming Windows' first-bind-wins UDP delivery semantics generalise. They don't: Linux has historically favoured the last-bound socket for a SO_REUSEADDR-shared UDP port, so the daemon's own _inboundClient (which binds second) could swallow the packet before the test's observer ever saw it, timing out on ubuntu-latest. Rewrite the test to assert the sending socket's own local port directly via the existing GetInboundClient reflection helper, sidestepping OS delivery arbitration entirely. Also document the Linux finding as a genuine, unconfirmed-on-real-hardware production risk in design.md Decision 7 (mirror image of the existing Windows finding: OpenWSFZ's own outbound send to a same-host peer could loop back to itself instead of reaching the peer) rather than dismissing it as a test-only artifact. Fixes the ubuntu-latest CI failure blocking PR #70. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…the Linux fix Marks the dev-task's acceptance criteria complete and updates tasks.md 10.4 now that PR #70's CI is confirmed fully green (all three Build & Test platforms + Gate G9, both duplicate runs) after the Linux-safe test fix. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Merges the three additive delta specs (external-reporting — new capability; configuration and qso-answerer — one/two new requirements each) into their main specs, then archives the change now that PR #70 has merged to main. All three deltas were ADDED-only, no conflicts. openspec validate --strict --all: 53/53. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Summary
Implements the
gridtracker-udp-reportingOpenSpec change end-to-end (41/41 tasks):FR-052):externalReportingblock onAppConfig—enabled,targets[](name/host/port/enabled),honourInboundCommands. Fully inert defaults; missing key deserialises safely; out-of-range target ports (1–65535) rejected onPOST /api/v1/configwith no partial persistence.WsjtxDatagram): magic/schema header framing, big-endian primitives, length-prefixed UTF-8 strings, encode for Heartbeat/Status/Decode/Clear/QSOLogged/Close, decode for Heartbeat/Reply/HaltTx/FreeText/Close + generic unsupported-type passthrough, never throws on malformed input.FR-053/FR-054,ExternalReportingService): registered unconditionally, inert until enabled; config-save reconciliation opens/closes per-target sockets and rebinds the inbound listener without a restart; Heartbeat/Status on a timer plus on-change; Clear+Decode per decode cycle; QSOLogged via aQsoLoggedNotifyingAdifWriterdecorator covering every ADIF-write call site; Close on shutdown. Inbound: Halt Tx always honoured, Reply/Free Text gated byhonourInboundCommands, Close logged-only, unsupported types discarded at Debug.QsoAnswererService.TryEngageExternal,IExternalReplyTarget): lets an inbound Reply engage a specific decoded CQ, reusing existing guards, unaffected bytx.autoAnswer. All 5specs/qso-answerer/spec.mdscenarios covered.FR-055, "External Programs" tab): Enabled checkbox, targets table (Add/Delete), Honour-inbound-commands checkbox with Halt-Tx-always-on explanatory text. Live-verified end-to-end via Playwright against a real running daemon (Save/reload round-trip, independent persistence, client-side port validation).REQUIREMENTS.mdFR-052..FR-055 + Integrations row + revision history;VERSIONbumped to0.35; traceability gate (G3) closed.Two real bugs found and fixed during testing (see commit
6b916f6): a_cts.Token-in-closure race causing an intermittentNullReferenceExceptionon concurrent start/stop, and a missing null-guard forExternalReportingConfig(same STJ deserialisation quirk already guarded elsewhere in the codebase) that crashedReconcileon some config saves.Known risk (flagged, not a merge blocker per design.md): the richer WSJT-X message layouts (Status/Decode/QSOLogged/Reply/HaltTx/FreeText) are implemented from protocol documentation, not verified byte-for-byte against a real WSJT-X/GridTracker2 capture (tasks 2.6/10.3) — no such install was available in this environment. Recommend a real capture or live GridTracker2 sanity check before relying on this operationally.
This is a post-v1.0 addition — does not alter any
IMPLEMENTATION_PLAN.mdphase or gate; v1.0's gate remains a confirmed two-way QSO via CAT+TX (tracked separately by the in-flightcat-tx-pttchange).Test plan
dotnet testacross all 9 test projects — 1090/1090 passing, 0 failed, 0 skippedopenspec validate --strict --all— 53/53 passedtools/TraceabilityCheckrun locally — PASS, all requirements mappeddev-tasks/screenshots/gridtracker-*.png)🤖 Generated with Claude Code