Skip to content

feat(gridtracker-udp-reporting): WSJT-X UDP broadcaster + inbound listener + Settings tab#70

Merged
frank001 merged 15 commits into
mainfrom
feat/gridtracker-udp-reporting
Jul 12, 2026
Merged

feat(gridtracker-udp-reporting): WSJT-X UDP broadcaster + inbound listener + Settings tab#70
frank001 merged 15 commits into
mainfrom
feat/gridtracker-udp-reporting

Conversation

@frank001

Copy link
Copy Markdown
Owner

Summary

Implements the gridtracker-udp-reporting OpenSpec change end-to-end (41/41 tasks):

  • Config schema (FR-052): externalReporting block on AppConfigenabled, targets[] (name/host/port/enabled), honourInboundCommands. Fully inert defaults; missing key deserialises safely; out-of-range target ports (1–65535) rejected on POST /api/v1/config with no partial persistence.
  • WSJT-X UDP protocol (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.
  • Outbound broadcaster + inbound listener (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 a QsoLoggedNotifyingAdifWriter decorator covering every ADIF-write call site; Close on shutdown. Inbound: Halt Tx always honoured, Reply/Free Text gated by honourInboundCommands, Close logged-only, unsupported types discarded at Debug.
  • External reply routing (QsoAnswererService.TryEngageExternal, IExternalReplyTarget): lets an inbound Reply engage a specific decoded CQ, reusing existing guards, unaffected by tx.autoAnswer. All 5 specs/qso-answerer/spec.md scenarios covered.
  • Settings UI (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).
  • Docs: REQUIREMENTS.md FR-052..FR-055 + Integrations row + revision history; VERSION bumped to 0.35; traceability gate (G3) closed.

Two real bugs found and fixed during testing (see commit 6b916f6): a _cts.Token-in-closure race causing an intermittent NullReferenceException on concurrent start/stop, and a missing null-guard for ExternalReportingConfig (same STJ deserialisation quirk already guarded elsewhere in the codebase) that crashed Reconcile on 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.md phase or gate; v1.0's gate remains a confirmed two-way QSO via CAT+TX (tracked separately by the in-flight cat-tx-ptt change).

Test plan

  • dotnet test across all 9 test projects — 1090/1090 passing, 0 failed, 0 skipped
  • openspec validate --strict --all53/53 passed
  • tools/TraceabilityCheck run locally — PASS, all requirements mapped
  • Before/after screenshots of the Settings tab bar (dev-tasks/screenshots/gridtracker-*.png)
  • Live Playwright verification of the Settings tab against a real running daemon (Save/reload round-trip)
  • Real GridTracker2/WSJT-X wire capture verification — not performed, no install available (see Known risk above)

🤖 Generated with Claude Code

frank001 and others added 11 commits July 12, 2026 00:42
…(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>
@frank001

Copy link
Copy Markdown
Owner Author

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 SO_REUSEADDR on the bind (Part A) plus routing the primary target's outbound sends through that same shared socket (Part B), so a peer's reply-to-sender-port semantics actually reach us. Two new regression tests. Empirically found while writing them: Windows delivers shared-port UDP unicast to only the first-bound socket (no SO_REUSEPORT-style fan-out) — a real platform limitation, documented in design.md Decision 7, that true simultaneous two-listener coexistence would need multicast to fully guarantee (already an unimplemented Open Question). The fix and its tests prove what's actually fixed: the bind no longer fails/stays permanently null, and the primary target's sends genuinely originate from the shared port.

Absolute exclusion of synthetic/unknown-region traffic — implemented as a hard-coded, non-configurable filter inside ExternalReportingService itself (not exposed as any Settings-page control), independent of the operator-toggleable DecodeNoiseSuppressionConfig. Tier 1 (outbound Decode) checks DecodeResult.Region directly. Tier 2 (outbound Status/QSOLogged) adds an optional ICallsignRegionStore and a new IsSuppressedCallsign helper that fails closed (unlike most optional-dependency null-checks elsewhere in this codebase) — a lookup miss or missing store is treated as suppressed, not open. Six new tests, all run with both DecodeNoiseSuppressionConfig suppression settings off to prove the exclusion doesn't depend on them.

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), openspec validate --strict --all 53/53, dotnet build zero warnings across every src/ project, tools/TraceabilityCheck still PASS. All nine acceptance criteria (AC-1 through AC-9) in the dev-task are met.

Docs amended in place on this same branch (no new OpenSpec proposal, per the dev-task's own instruction): specs/external-reporting/spec.md, design.md (new Decisions 6 & 7), tasks.md (new §11), REQUIREMENTS.md FR-053 + folded into the existing 1.31 revision-history row.

🤖 Generated with Claude Code

frank001 and others added 4 commits July 12, 2026 13:04
… 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>
@frank001 frank001 merged commit 0d8f9c7 into main Jul 12, 2026
14 checks passed
@frank001 frank001 deleted the feat/gridtracker-udp-reporting branch July 12, 2026 11:42
frank001 added a commit that referenced this pull request Jul 12, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant