From d3dc5f8bec53ee38e03aae39462ebd10712b1740 Mon Sep 17 00:00:00 2001 From: Frank0x01 Date: Sun, 12 Jul 2026 00:42:16 +0200 Subject: [PATCH 01/15] feat(gridtracker-udp-reporting): add externalReporting config schema (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 --- .../gridtracker-udp-reporting/.openspec.yaml | 2 + .../gridtracker-udp-reporting/design.md | 204 +++++++++++++ .../gridtracker-udp-reporting/proposal.md | 86 ++++++ .../specs/configuration/spec.md | 59 ++++ .../specs/external-reporting/spec.md | 270 ++++++++++++++++++ .../specs/qso-answerer/spec.md | 50 ++++ .../gridtracker-udp-reporting/tasks.md | 139 +++++++++ src/OpenWSFZ.Abstractions/AppConfig.cs | 9 + .../ExternalReportingConfig.cs | 106 +++++++ src/OpenWSFZ.Config/ConfigJsonContext.cs | 2 + src/OpenWSFZ.Config/JsonConfigStore.cs | 15 +- src/OpenWSFZ.Web/AppJsonContext.cs | 3 + src/OpenWSFZ.Web/WebApp.cs | 16 ++ .../ExternalReportingConfigTests.cs | 129 +++++++++ .../ExternalReportingConfigValidationTests.cs | 85 ++++++ 15 files changed, 1171 insertions(+), 4 deletions(-) create mode 100644 openspec/changes/gridtracker-udp-reporting/.openspec.yaml create mode 100644 openspec/changes/gridtracker-udp-reporting/design.md create mode 100644 openspec/changes/gridtracker-udp-reporting/proposal.md create mode 100644 openspec/changes/gridtracker-udp-reporting/specs/configuration/spec.md create mode 100644 openspec/changes/gridtracker-udp-reporting/specs/external-reporting/spec.md create mode 100644 openspec/changes/gridtracker-udp-reporting/specs/qso-answerer/spec.md create mode 100644 openspec/changes/gridtracker-udp-reporting/tasks.md create mode 100644 src/OpenWSFZ.Abstractions/ExternalReportingConfig.cs create mode 100644 tests/OpenWSFZ.Config.Tests/ExternalReportingConfigTests.cs create mode 100644 tests/OpenWSFZ.Web.Tests/ExternalReportingConfigValidationTests.cs diff --git a/openspec/changes/gridtracker-udp-reporting/.openspec.yaml b/openspec/changes/gridtracker-udp-reporting/.openspec.yaml new file mode 100644 index 0000000..68b7174 --- /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 0000000..7812b77 --- /dev/null +++ b/openspec/changes/gridtracker-udp-reporting/design.md @@ -0,0 +1,204 @@ +## 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. + +## 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. diff --git a/openspec/changes/gridtracker-udp-reporting/proposal.md b/openspec/changes/gridtracker-udp-reporting/proposal.md new file mode 100644 index 0000000..8321f5d --- /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 0000000..70e2642 --- /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 0000000..0ffddeb --- /dev/null +++ b/openspec/changes/gridtracker-udp-reporting/specs/external-reporting/spec.md @@ -0,0 +1,270 @@ +## 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). + +#### 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` + +--- + +### 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). + +#### Scenario: One decode produces one Decode datagram per enabled target + +- **WHEN** a decode cycle yields exactly one `DecodeResult` +- **THEN** exactly one Decode datagram carrying that result's fields SHALL be sent to each enabled + target + +--- + +### 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). + +#### Scenario: QSOLogged sent alongside ADIF record + +- **WHEN** a QSO reaches `QsoComplete` 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 + +--- + +### 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 0000000..61155f9 --- /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 0000000..cd9f8fa --- /dev/null +++ b/openspec/changes/gridtracker-udp-reporting/tasks.md @@ -0,0 +1,139 @@ +## 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 + +- [ ] 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. +- [ ] 2.2 Implement encode for: Heartbeat, Status, Decode, Clear, QSOLogged, Close. +- [ ] 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. +- [ ] 2.4 Decode paths SHALL never throw on malformed input — truncated/garbage buffers become a + discarded-datagram result, not an exception. +- [ ] 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 If a real WSJT-X or GridTracker2 wire capture is available (e.g. via Wireshark on a test + machine), add at least one captured-bytes fixture and assert our decoder parses it correctly — + this is the closest this change gets to an external reference oracle without a live-verify + script. + +## 3. Outbound broadcaster + +- [ ] 3.1 Add `ExternalReportingService : IHostedService` in `OpenWSFZ.Daemon`; register + unconditionally in `Program.cs`. No socket opened when inert (§1's defaults). +- [ ] 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. +- [ ] 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. +- [ ] 3.4 Subscribe to the existing per-cycle decode batch feed (same one `QsoAnswererService` + consumes) for outbound Decode; send Clear at each new cycle boundary before that cycle's + Decode datagrams. +- [ ] 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. +- [ ] 3.6 Send Close to every enabled target from `ExternalReportingService.StopAsync` before + closing sockets. +- [ ] 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 + +- [ ] 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. +- [ ] 4.2 Halt Tx handler: call `IQsoController.AbortAsync` unconditionally (not gated by + `honourInboundCommands`). +- [ ] 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). +- [ ] 4.4 Close handler: Information log only; SHALL NOT terminate the daemon under any + circumstance. +- [ ] 4.5 Any other recognised-but-unsupported inbound type: Debug log only, no state change. +- [ ] 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 + +- [ ] 5.1 Add `IExternalReplyTarget` interface in `OpenWSFZ.Web` (alongside `IQsoRoleSwitcher`): + `Task TryEngageAsync(string callsign, CancellationToken ct)`. +- [ ] 5.2 Implement on `QsoControllerRouter`: when active role is Answerer, delegate to the new + `QsoAnswererService.TryEngageExternal`; when active role is Caller, delegate to the existing, + unmodified `QsoCallerService.SelectResponderAsync`. +- [ ] 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`. +- [ ] 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). +- [ ] 5.5 Wire the inbound Reply handler (§4.3) to call `IExternalReplyTarget.TryEngageAsync`, + resolved via DI the same way `WebApp` resolves `IQsoRoleSwitcher` today. + +## 6. Settings — before screenshot + +- [ ] 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. + +## 7. Settings UI implementation + +- [ ] 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). +- [ ] 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`. +- [ ] 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. +- [ ] 7.4 Confirm the tab is added to `sessionStorage` tab-persistence handling alongside the + existing six tabs. + +## 8. Settings — after screenshot + +- [ ] 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. + +## 9. Documentation + +- [ ] 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. +- [ ] 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. +- [ ] 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). +- [ ] 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. + +## 10. Verification + +- [ ] 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. +- [ ] 10.2 Run `openspec validate --strict --all` and confirm the delta specs archive cleanly against + `configuration` and `qso-answerer`. +- [ ] 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. diff --git a/src/OpenWSFZ.Abstractions/AppConfig.cs b/src/OpenWSFZ.Abstractions/AppConfig.cs index d860844..711f57a 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 0000000..39d5e1e --- /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 7431c38..515ea79 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 bb738bb..8e07b0a 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.Web/AppJsonContext.cs b/src/OpenWSFZ.Web/AppJsonContext.cs index d3267a3..475a23f 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/WebApp.cs b/src/OpenWSFZ.Web/WebApp.cs index 83e1d8b..5cbf289 100644 --- a/src/OpenWSFZ.Web/WebApp.cs +++ b/src/OpenWSFZ.Web/WebApp.cs @@ -472,6 +472,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 0000000..da1752f --- /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 = "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 = "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.Web.Tests/ExternalReportingConfigValidationTests.cs b/tests/OpenWSFZ.Web.Tests/ExternalReportingConfigValidationTests.cs new file mode 100644 index 0000000..a5338d3 --- /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 = "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); + } +} From 496e1fe2095cd23d027aa2a3cbd534516bbcd252 Mon Sep 17 00:00:00 2001 From: Frank0x01 Date: Sun, 12 Jul 2026 00:48:10 +0200 Subject: [PATCH 02/15] feat(gridtracker-udp-reporting): implement WSJT-X UDP datagram (de)serialisation (task 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../gridtracker-udp-reporting/tasks.md | 19 +- src/OpenWSFZ.Daemon/WsjtxDatagram.cs | 455 ++++++++++++++++++ .../WsjtxDatagramTests.cs | 372 ++++++++++++++ 3 files changed, 837 insertions(+), 9 deletions(-) create mode 100644 src/OpenWSFZ.Daemon/WsjtxDatagram.cs create mode 100644 tests/OpenWSFZ.Daemon.Tests/WsjtxDatagramTests.cs diff --git a/openspec/changes/gridtracker-udp-reporting/tasks.md b/openspec/changes/gridtracker-udp-reporting/tasks.md index cd9f8fa..f5687e7 100644 --- a/openspec/changes/gridtracker-udp-reporting/tasks.md +++ b/openspec/changes/gridtracker-udp-reporting/tasks.md @@ -14,22 +14,23 @@ ## 2. WSJT-X datagram serialisation -- [ ] 2.1 Add internal `WsjtxDatagram` static class in `OpenWSFZ.Daemon` implementing the +- [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. -- [ ] 2.2 Implement encode for: Heartbeat, Status, Decode, Clear, QSOLogged, Close. -- [ ] 2.3 Implement decode for: Heartbeat, Reply, Halt Tx, Free Text, Close, and a generic +- [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. -- [ ] 2.4 Decode paths SHALL never throw on malformed input — truncated/garbage buffers become a +- [x] 2.4 Decode paths SHALL never throw on malformed input — truncated/garbage buffers become a discarded-datagram result, not an exception. -- [ ] 2.5 `OpenWSFZ.Daemon.Tests`: byte-exact encode tests for every outbound type (assert exact +- [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 If a real WSJT-X or GridTracker2 wire capture is available (e.g. via Wireshark on a test - machine), add at least one captured-bytes fixture and assert our decoder parses it correctly — - this is the closest this change gets to an external reference oracle without a live-verify - script. +- [~] 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 diff --git a/src/OpenWSFZ.Daemon/WsjtxDatagram.cs b/src/OpenWSFZ.Daemon/WsjtxDatagram.cs new file mode 100644 index 0000000..9d4c4d8 --- /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/tests/OpenWSFZ.Daemon.Tests/WsjtxDatagramTests.cs b/tests/OpenWSFZ.Daemon.Tests/WsjtxDatagramTests.cs new file mode 100644 index 0000000..19c90a4 --- /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(); + } + } +} From 67df59ff88a71f026f691ef43f96a7f711cdb6a7 Mon Sep 17 00:00:00 2001 From: Frank0x01 Date: Sun, 12 Jul 2026 00:54:43 +0200 Subject: [PATCH 03/15] feat(gridtracker-udp-reporting): add external reply routing (task 5) 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 --- .../gridtracker-udp-reporting/tasks.md | 19 +- src/OpenWSFZ.Daemon/Program.cs | 3 + src/OpenWSFZ.Daemon/QsoAnswererService.cs | 130 +++++++++-- src/OpenWSFZ.Daemon/QsoCallerService.cs | 50 ++++ src/OpenWSFZ.Daemon/QsoControllerRouter.cs | 16 +- src/OpenWSFZ.Web/IExternalReplyTarget.cs | 31 +++ .../QsoAnswererServiceExternalReplyTests.cs | 214 ++++++++++++++++++ 7 files changed, 440 insertions(+), 23 deletions(-) create mode 100644 src/OpenWSFZ.Web/IExternalReplyTarget.cs create mode 100644 tests/OpenWSFZ.Daemon.Tests/QsoAnswererServiceExternalReplyTests.cs diff --git a/openspec/changes/gridtracker-udp-reporting/tasks.md b/openspec/changes/gridtracker-udp-reporting/tasks.md index f5687e7..845ee2c 100644 --- a/openspec/changes/gridtracker-udp-reporting/tasks.md +++ b/openspec/changes/gridtracker-udp-reporting/tasks.md @@ -73,20 +73,25 @@ ## 5. External reply routing -- [ ] 5.1 Add `IExternalReplyTarget` interface in `OpenWSFZ.Web` (alongside `IQsoRoleSwitcher`): +- [x] 5.1 Add `IExternalReplyTarget` interface in `OpenWSFZ.Web` (alongside `IQsoRoleSwitcher`): `Task TryEngageAsync(string callsign, CancellationToken ct)`. -- [ ] 5.2 Implement on `QsoControllerRouter`: when active role is Answerer, delegate to the new - `QsoAnswererService.TryEngageExternal`; when active role is Caller, delegate to the existing, - unmodified `QsoCallerService.SelectResponderAsync`. -- [ ] 5.3 Add `Task TryEngageExternal(string callsign, CancellationToken ct = default)` to +- [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`. -- [ ] 5.4 `OpenWSFZ.Daemon.Tests`: all five new scenarios in `specs/qso-answerer/spec.md` (matching +- [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). - [ ] 5.5 Wire the inbound Reply handler (§4.3) to call `IExternalReplyTarget.TryEngageAsync`, - resolved via DI the same way `WebApp` resolves `IQsoRoleSwitcher` today. + resolved via DI the same way `WebApp` resolves `IQsoRoleSwitcher` today. (Deferred to the + §3/§4 `ExternalReportingService` implementation, which consumes `IExternalReplyTarget` + directly via constructor injection.) ## 6. Settings — before screenshot diff --git a/src/OpenWSFZ.Daemon/Program.cs b/src/OpenWSFZ.Daemon/Program.cs index dd9b88c..29cd73e 100644 --- a/src/OpenWSFZ.Daemon/Program.cs +++ b/src/OpenWSFZ.Daemon/Program.cs @@ -479,6 +479,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()); diff --git a/src/OpenWSFZ.Daemon/QsoAnswererService.cs b/src/OpenWSFZ.Daemon/QsoAnswererService.cs index 35c6f4b..70a3930 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 0214255..1e99bc8 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 148a8f4..4cebe6c 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.Web/IExternalReplyTarget.cs b/src/OpenWSFZ.Web/IExternalReplyTarget.cs new file mode 100644 index 0000000..39512d0 --- /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/tests/OpenWSFZ.Daemon.Tests/QsoAnswererServiceExternalReplyTests.cs b/tests/OpenWSFZ.Daemon.Tests/QsoAnswererServiceExternalReplyTests.cs new file mode 100644 index 0000000..aa4165f --- /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 = "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"); + } +} From 6b916f67e10e619fe800209f9e7cc1a43a33e09c Mon Sep 17 00:00:00 2001 From: Frank0x01 Date: Sun, 12 Jul 2026 01:17:04 +0200 Subject: [PATCH 04/15] feat(gridtracker-udp-reporting): implement outbound broadcaster + inbound listener (tasks 3-4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../gridtracker-udp-reporting/tasks.md | 38 +- .../ExternalReportingService.cs | 643 ++++++++++++++++++ src/OpenWSFZ.Daemon/Program.cs | 40 +- .../QsoLoggedNotifyingAdifWriter.cs | 32 + src/OpenWSFZ.Web/WebApp.cs | 2 + .../ExternalReportingServiceTests.cs | 576 ++++++++++++++++ .../ConfigApiNullGuardTests.cs | 31 + 7 files changed, 1343 insertions(+), 19 deletions(-) create mode 100644 src/OpenWSFZ.Daemon/ExternalReportingService.cs create mode 100644 src/OpenWSFZ.Daemon/QsoLoggedNotifyingAdifWriter.cs create mode 100644 tests/OpenWSFZ.Daemon.Tests/ExternalReportingServiceTests.cs diff --git a/openspec/changes/gridtracker-udp-reporting/tasks.md b/openspec/changes/gridtracker-udp-reporting/tasks.md index 845ee2c..e0e692b 100644 --- a/openspec/changes/gridtracker-udp-reporting/tasks.md +++ b/openspec/changes/gridtracker-udp-reporting/tasks.md @@ -34,39 +34,45 @@ ## 3. Outbound broadcaster -- [ ] 3.1 Add `ExternalReportingService : IHostedService` in `OpenWSFZ.Daemon`; register +- [x] 3.1 Add `ExternalReportingService : IHostedService` in `OpenWSFZ.Daemon`; register unconditionally in `Program.cs`. No socket opened when inert (§1's defaults). -- [ ] 3.2 On enable (startup or config-save transition), open one outbound `UdpClient` per enabled +- [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. -- [ ] 3.3 Wire Heartbeat on a fixed timer; wire Status on a timer plus on-change triggers (dial +- [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. -- [ ] 3.4 Subscribe to the existing per-cycle decode batch feed (same one `QsoAnswererService` - consumes) for outbound Decode; send Clear at each new cycle boundary before that cycle's - Decode datagrams. -- [ ] 3.5 Hook the existing `ADIF.log` write call site (FR-051) to also emit an outbound QSOLogged + (`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. -- [ ] 3.6 Send Close to every enabled target from `ExternalReportingService.StopAsync` before + 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. -- [ ] 3.7 `OpenWSFZ.Daemon.Tests`: bind a real loopback `UdpClient` per test as a fake target, +- [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 -- [ ] 4.1 Bind a single inbound `UdpClient` alongside the outbound sockets when enabled; receive +- [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. -- [ ] 4.2 Halt Tx handler: call `IQsoController.AbortAsync` unconditionally (not gated by +- [x] 4.2 Halt Tx handler: call `IQsoController.AbortAsync` unconditionally (not gated by `honourInboundCommands`). -- [ ] 4.3 Reply and Free Text handlers: check `honourInboundCommands`; when `false`, discard with an +- [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). -- [ ] 4.4 Close handler: Information log only; SHALL NOT terminate the daemon under any +- [x] 4.4 Close handler: Information log only; SHALL NOT terminate the daemon under any circumstance. -- [ ] 4.5 Any other recognised-but-unsupported inbound type: Debug log only, no state change. -- [ ] 4.6 `OpenWSFZ.Daemon.Tests`: inject synthetic inbound datagrams (real loopback send from the +- [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. diff --git a/src/OpenWSFZ.Daemon/ExternalReportingService.cs b/src/OpenWSFZ.Daemon/ExternalReportingService.cs new file mode 100644 index 0000000..b63da06 --- /dev/null +++ b/src/OpenWSFZ.Daemon/ExternalReportingService.cs @@ -0,0 +1,643 @@ +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. +/// +/// +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 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(); + private List<(ExternalReportingTarget Target, UdpClient Client)> _outboundClients = []; + 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) + : this(decodeChannel, configStore, serviceProvider, logger, catState, + 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, + TimeSpan heartbeatInterval, + TimeSpan statusPollInterval) + { + _decodeChannel = decodeChannel; + _configStore = configStore; + _serviceProvider = serviceProvider; + _logger = logger; + _catState = catState; + _heartbeatInterval = heartbeatInterval; + _statusPollInterval = statusPollInterval; + } + + // ── 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 targets, closes ones for + /// targets no longer enabled/present, and (re)binds the single inbound listener to the + /// first 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 toClose = _outboundClients.Where(c => !desiredEnabled.Contains(c.Target)).ToList(); + foreach (var c in toClose) + { + _outboundClients.Remove(c); + try { c.Client.Dispose(); } catch { /* best-effort */ } + } + + foreach (var target in desiredEnabled) + { + 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); + } + } + + var desiredInboundPort = desiredEnabled.Count > 0 ? desiredEnabled[0].Port : -1; + if (desiredInboundPort != _inboundBoundPort) + { + _inboundClient?.Dispose(); + _inboundClient = null; + _inboundBoundPort = -1; + + 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); + } + } + } + } + } + + private bool IsOutboundActive + { + get { lock (_targetsLock) return _outboundClients.Count > 0; } + } + + // ── 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) + { + 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; + + 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; + + 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]; + + 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) + { + 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(); + + _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 29cd73e..e9a4b4b 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,29 @@ 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). + services.AddSingleton(sp => new ExternalReportingService( + externalReportingChannel.Reader, + sp.GetRequiredService(), + sp, + sp.GetRequiredService>(), + 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 +478,7 @@ void ConfigureLogging(ILoggingBuilder lb) sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), - sp.GetRequiredService(), + sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService>(), sp.GetService(), @@ -462,7 +495,7 @@ void ConfigureLogging(ILoggingBuilder lb) sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), - sp.GetRequiredService(), + sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService>(), sp.GetService(), @@ -565,6 +598,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/QsoLoggedNotifyingAdifWriter.cs b/src/OpenWSFZ.Daemon/QsoLoggedNotifyingAdifWriter.cs new file mode 100644 index 0000000..99d5386 --- /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.Web/WebApp.cs b/src/OpenWSFZ.Web/WebApp.cs index 5cbf289..9da7e80 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) diff --git a/tests/OpenWSFZ.Daemon.Tests/ExternalReportingServiceTests.cs b/tests/OpenWSFZ.Daemon.Tests/ExternalReportingServiceTests.cs new file mode 100644 index 0000000..0b5011a --- /dev/null +++ b/tests/OpenWSFZ.Daemon.Tests/ExternalReportingServiceTests.cs @@ -0,0 +1,576 @@ +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; + } + } + + 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, + TimeSpan? heartbeatInterval = null, TimeSpan? statusPollInterval = null) + => new( + channelReader, + configStore, + serviceProvider ?? BuildServiceProvider(), + NullLogger.Instance, + catState: null, + 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 = "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 + { + channel.Writer.TryWrite(new DecodeBatch(DateTimeOffset.UtcNow, + [new DecodeResult(Time: "12:00:00", Snr: -5, Dt: 0.1, FreqHz: 1500, Message: "CQ Q1TST JO22")])); + + // 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 = "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); + } + } + + // ── Outbound: QSOLogged (task 3.5/3.7) ────────────────────────────────── + + [Fact(DisplayName = "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); + var sut = CreateSut(store, channel.Reader); + + 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 = "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 = "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 = "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 = "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 = "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.Web.Tests/ConfigApiNullGuardTests.cs b/tests/OpenWSFZ.Web.Tests/ConfigApiNullGuardTests.cs index 09d2e7b..49cdd4c 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"); + } } From aeff9eda33d6df89e950f8d27f2df15eccf7e201 Mon Sep 17 00:00:00 2001 From: Frank0x01 Date: Sun, 12 Jul 2026 01:29:05 +0200 Subject: [PATCH 05/15] feat(gridtracker-udp-reporting): add External Programs settings tab (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 --- ...tracker-after-01-external-programs-tab.png | Bin 0 -> 46129 bytes .../gridtracker-after-02-tab-bar.png | Bin 0 -> 7230 bytes .../gridtracker-before-01-tab-bar.png | Bin 0 -> 6711 bytes .../gridtracker-udp-reporting/tasks.md | 31 ++-- web/js/settings.js | 146 ++++++++++++++++++ web/settings.html | 60 +++++++ 6 files changed, 228 insertions(+), 9 deletions(-) create mode 100644 dev-tasks/screenshots/gridtracker-after-01-external-programs-tab.png create mode 100644 dev-tasks/screenshots/gridtracker-after-02-tab-bar.png create mode 100644 dev-tasks/screenshots/gridtracker-before-01-tab-bar.png 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 0000000000000000000000000000000000000000..4f2e61c5c07312b0f2b57f87c55b411eba51b607 GIT binary patch literal 46129 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
    U{B(9rq2;o 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..8529398d282cdca13c107323848ee1b03eb2fb48 GIT binary patch literal 7230 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e594058c1699c6b88b43d61cc02819f50d69db80 GIT binary patch literal 6711 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 literal 0 HcmV?d00001 diff --git a/openspec/changes/gridtracker-udp-reporting/tasks.md b/openspec/changes/gridtracker-udp-reporting/tasks.md index e0e692b..a022532 100644 --- a/openspec/changes/gridtracker-udp-reporting/tasks.md +++ b/openspec/changes/gridtracker-udp-reporting/tasks.md @@ -101,28 +101,41 @@ ## 6. Settings — before screenshot -- [ ] 6.1 Capture a screenshot of the current Settings page tab bar (all six existing tabs) as the +- [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 -- [ ] 7.1 Add the "External Programs" tab button and panel to `web/settings.html`, following the +- [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). -- [ ] 7.2 Implement the Enabled checkbox, the targets table (Name/Host/Port/Enabled/Delete columns, +- [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`. -- [ ] 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. -- [ ] 7.4 Confirm the tab is added to `sessionStorage` tab-persistence handling alongside the - existing six tabs. + `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 -- [ ] 8.1 Capture a screenshot of the Settings page with the new "External Programs" tab selected +- [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 diff --git a/web/js/settings.js b/web/js/settings.js index 345fbb7..94c7a1d 100644 --- a/web/js/settings.js +++ b/web/js/settings.js @@ -92,6 +92,12 @@ const regionDataLookupResult = /** @type {HTMLElement} */ (document.getEl const suppressUnknownRegion = /** @type {HTMLInputElement} */ (document.getElementById('suppress-unknown-region')); const suppressSynthetic = /** @type {HTMLInputElement} */ (document.getElementById('suppress-synthetic')); +// External Programs tab controls (gridtracker-udp-reporting). +const extRepEnabled = /** @type {HTMLInputElement} */ (document.getElementById('ext-rep-enabled')); +const extRepTbody = /** @type {HTMLTableSectionElement} */ (document.getElementById('ext-rep-tbody')); +const addExtRepTargetBtn = /** @type {HTMLButtonElement} */ (document.getElementById('add-ext-rep-target-btn')); +const extRepHonourInbound = /** @type {HTMLInputElement} */ (document.getElementById('ext-rep-honour-inbound')); + // Advanced Decoder Settings controls (decoder-settings-page) const decoderK = /** @type {HTMLInputElement} */ (document.getElementById('decoder-k')); const decoderCorr = /** @type {HTMLInputElement} */ (document.getElementById('decoder-corr')); @@ -328,6 +334,12 @@ function snapshotForm() { suppressUnknownRegion: _suppressUnknownRegionRaw, suppressSynthetic: suppressSynthetic?.checked ?? true, }, + // gridtracker-udp-reporting: include the External Programs tab in dirty-state comparison. + externalReporting: { + enabled: extRepEnabled?.checked ?? false, + targets: collectExtRepTargets(), + honourInboundCommands: extRepHonourInbound?.checked ?? false, + }, }); } @@ -484,6 +496,112 @@ addFreqBtn.addEventListener('click', () => { syncDirtyUI(); // FR-040: adding a row marks the form dirty }); +// ── External Programs tab (gridtracker-udp-reporting) ─────────────────── + +/** + * Read the current external-reporting target table rows as an array of target objects. + * @returns {Array<{name: string, host: string, port: number, enabled: boolean}>} + */ +function collectExtRepTargets() { + if (!extRepTbody) return []; + const rows = /** @type {NodeListOf} */ ( + extRepTbody.querySelectorAll('tr[data-ext-rep-row]') + ); + return Array.from(rows).map(row => ({ + name: /** @type {HTMLInputElement} */ (row.querySelector('.ext-rep-name')).value.trim(), + host: /** @type {HTMLInputElement} */ (row.querySelector('.ext-rep-host')).value.trim() || '127.0.0.1', + port: parseInt(/** @type {HTMLInputElement} */ (row.querySelector('.ext-rep-port')).value, 10) || 0, + enabled: /** @type {HTMLInputElement} */ (row.querySelector('.ext-rep-target-enabled')).checked, + })); +} + +/** + * Build and append a single external-reporting target table row. + * @param {string} name + * @param {string} host + * @param {number} port + * @param {boolean} enabled + */ +function appendExtRepRow(name, host, port, enabled) { + const tr = document.createElement('tr'); + tr.setAttribute('data-ext-rep-row', ''); + + // SEC-003: build the row via DOM APIs so no server-derived value is ever assigned to innerHTML. + + /** @param {string} type @param {string} cls @param {string} val */ + function makeInputCell(type, cls, val) { + const td = tr.insertCell(); + const inp = document.createElement('input'); + inp.type = type; + inp.className = cls; + inp.value = val; + if (type === 'number') { + inp.step = '1'; + inp.min = '1'; + inp.max = '65535'; + } + td.appendChild(inp); + return inp; + } + + makeInputCell('text', 'ext-rep-name', name); + makeInputCell('text', 'ext-rep-host', host); + makeInputCell('number', 'ext-rep-port', String(port)); + + const tdEnabled = tr.insertCell(); + const enabledCb = document.createElement('input'); + enabledCb.type = 'checkbox'; + enabledCb.className = 'ext-rep-target-enabled'; + enabledCb.checked = enabled; + tdEnabled.appendChild(enabledCb); + + const tdDel = tr.insertCell(); + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'freq-delete-btn'; + btn.setAttribute('aria-label', 'Delete row'); + btn.textContent = '✕'; + btn.addEventListener('click', () => { + tr.remove(); + syncDirtyUI(); // FR-040: deleting a row marks the form dirty + }); + tdDel.appendChild(btn); + + extRepTbody.appendChild(tr); +} + +/** + * Render the full external-reporting target table from an array of entries. + * Clears any existing rows (including the placeholder). + * @param {Array<{name: string, host: string, port: number, enabled: boolean}>} entries + */ +function renderExtRepTable(entries) { + if (!extRepTbody) return; + extRepTbody.innerHTML = ''; + + if (entries.length === 0) { + const tr = document.createElement('tr'); + tr.innerHTML = 'No targets configured — click Add target to begin'; + extRepTbody.appendChild(tr); + return; + } + + for (const t of entries) { + appendExtRepRow(t.name ?? '', t.host ?? '127.0.0.1', t.port ?? 2237, t.enabled ?? true); + } +} + +if (addExtRepTargetBtn) { + addExtRepTargetBtn.addEventListener('click', () => { + // Remove the placeholder row if present. + const placeholder = extRepTbody.querySelector('.freq-placeholder'); + if (placeholder) placeholder.closest('tr').remove(); + + appendExtRepRow('', '127.0.0.1', 2237, true); + syncDirtyUI(); // FR-040: adding a row marks the form dirty + }); +} + // ── Load config and devices ─────────────────────────────────────────────── document.addEventListener('DOMContentLoaded', async () => { @@ -673,6 +791,12 @@ document.addEventListener('DOMContentLoaded', async () => { if (suppressSynthetic) suppressSynthetic.checked = noise.suppressSynthetic ?? true; _suppressUnknownRegionRaw = noise.suppressUnknownRegion ?? null; + // Pre-fill External Programs tab controls (gridtracker-udp-reporting). + const extRep = config.externalReporting ?? {}; + if (extRepEnabled) extRepEnabled.checked = extRep.enabled ?? false; + if (extRepHonourInbound) extRepHonourInbound.checked = extRep.honourInboundCommands ?? false; + renderExtRepTable(Array.isArray(extRep.targets) ? extRep.targets : []); + // Capture the clean baseline after all fields are fully populated (FR-040). _cleanSnapshot = snapshotForm(); @@ -867,6 +991,20 @@ saveBtn.addEventListener('click', async () => { return; } + // gridtracker-udp-reporting: mirror the daemon's own port-range validation client-side so + // the operator gets immediate feedback instead of a round-trip 400. + const extRepTargetsForValidation = collectExtRepTargets(); + const badExtRepTarget = extRepTargetsForValidation.find(t => t.port < 1 || t.port > 65535); + if (badExtRepTarget) { + showFeedback( + `External Programs target '${badExtRepTarget.name || badExtRepTarget.host}' has an invalid port ` + + `(${badExtRepTarget.port}) — must be between 1 and 65535.`, + 'error' + ); + saveBtn.disabled = false; + return; + } + // p16: collect CAT config — carry forward opaque server-managed fields (FR-039). const cat = { ...catOpaqueFields, // ← carry forward server-managed fields @@ -947,6 +1085,13 @@ saveBtn.addEventListener('click', async () => { suppressSynthetic: suppressSynthetic?.checked ?? true, }; + // Collect External Programs config (gridtracker-udp-reporting). + const externalReporting = { + enabled: extRepEnabled?.checked ?? false, + targets: extRepTargetsForValidation, + honourInboundCommands: extRepHonourInbound?.checked ?? false, + }; + // POST config and frequencies in parallel (FR-043 / FR-007). await Promise.all([ postConfig({ @@ -964,6 +1109,7 @@ saveBtn.addEventListener('click', async () => { remoteAccess, decoder, decodeNoiseSuppression, + externalReporting, }), postFrequencies(freqEntries), ]); diff --git a/web/settings.html b/web/settings.html index 481e92f..05e0f62 100644 --- a/web/settings.html +++ b/web/settings.html @@ -54,6 +54,10 @@

    Settings

    role="tab" aria-selected="false" aria-controls="tab-region-data" id="tab-btn-region-data"> Region data + @@ -649,6 +653,62 @@

    Settings

    + +
    + +

    + Speaks the WSJT-X UDP network protocol so third-party programs (GridTracker2, JTAlert, + N1MM+, Log4OM, …) can see this station's spots and log completed QSOs exactly as they do + for WSJT-X today. Fully inert until enabled below — no network traffic leaves this + machine otherwise. +

    + +
    + +
    + + + + + + + + + + + + + + +
    NameHostPortEnabledDelete
    + +
    + +
    + +
    + Inbound commands + +
    + +

    + When checked, a third-party program can select a decoded station for this daemon to + engage (Reply) or send a free-text message (currently stored only — no transmission + effect yet). Halt Tx is always honoured regardless of this setting — + a third-party program can always stop an in-progress transmission as a safety + backstop, but starting one requires this explicit opt-in. +

    +
    +
    + +
    +