Skip to content

iOS: bound a WSJT-X reply's requested audio frequency to the TX passband#583

Merged
patrickrb merged 1 commit into
devfrom
optio/task-45727737-3287-46be-9037-9b13f9c2c87e
Jul 15, 2026
Merged

iOS: bound a WSJT-X reply's requested audio frequency to the TX passband#583
patrickrb merged 1 commit into
devfrom
optio/task-45727737-3287-46be-9037-9b13f9c2c87e

Conversation

@patrickrb

Copy link
Copy Markdown
Owner

Summary

An inbound WSJT-X Reply request — sent by companion apps (GridTracker, JTAlert, N1MM) when the Accept UDP requests setting is on — carries a df field: the audio-offset (Hz) the companion wants us to answer on. The iOS LiveEngine.onReply handler adopted any deltaFreq > 0 straight into appState.waterfall.txFreqHz with no range check:

if deltaFreq > 0 { appState.waterfall.txFreqHz = Float(deltaFreq) }

The decoder's display band runs to 3500 Hz (FT8Decoder/WaterfallAxis.displayMaxHz), so double-clicking a routine 3000–3500 Hz spot in a companion sends a df above the SSB TX passband; a stray or garbled datagram can send anything up to UInt32.max.

Root cause

WaterfallAxis already documents (on clampedFraction, lines 38-45) that txFreqHz "can be set externally without any range check — a WSJT-X UDP reply carries a raw deltaFreq" and defensively clamps the TX marker to the band edge. But the actual TX tone was still set to the raw out-of-band value. Net effect: the reply keys up outside the passband (attenuated / effectively off-air) while the red TX marker points to the band edge — marker and tone disagree.

Both sibling ports already guard this exact untrusted value:

The iOS port dropped that guard. This is the same cross-port "one port omitted a safeguard the others have" class as #519 / #554 / #559 / #561 / #573 / #576.

Fix

Add a pure, Foundation-only, Kit-testable helper mirroring the desktop guard, using iOS's own passband constants (the same range tunedTxHz clamps tap-to-tune to):

public static func boundedReplyTxHz(_ hz: Float) -> Float? {
    return (minTxHz...maxTxHz).contains(hz) ? hz : nil   // [200, 3000]
}

onReply now adopts the requested tone only when it's in-band; a df of 0 (unspecified — the app's own click-to-answer sends 0) or out-of-band keeps the operator's current offset.

  • In-band replies behave exactly as before.
  • No DSP/decoder/protocol change; happy path is byte-identical.
  • Float(deltaFreq) for a UInt32 never traps (unlike an integer cast), so a garbage huge df is simply rejected.

Testing

  • WaterfallAxisTests gains testBoundedReplyTxHzHonorsInBandRequests and testBoundedReplyTxHzDropsUnspecifiedAndOutOfBand; 4 assertions fail against the old unbounded behavior, pass with the fix (verified by reverting only the helper).
  • Full FT8AFKit suite: 131/131 green via swift test on Linux.
  • The onReply wiring is in the Xcode-only app target (ios/FT8AF/**, imports Network/AVFoundation), which is not Linux-buildable; the extracted helper and its tests are, and were run.

Risk

Low. Input-validation-only on untrusted UDP data; the sole behavioral change is that an out-of-band/garbage reply df is ignored (current offset kept) instead of keying TX off-air. In-band replies and the desktop-UI click-to-answer path are unchanged.

🤖 Generated with Claude Code

An inbound WSJT-X Reply (companion apps like GridTracker/JTAlert/N1MM, when
"Accept UDP requests" is on) carries a `df` = the audio Hz it wants us to
answer on. `LiveEngine`'s `onReply` handler adopted any `deltaFreq > 0`
straight into `txFreqHz` with no bound. The decoder's band runs to 3500 Hz,
so double-clicking a routine 3000-3500 Hz spot sends a df above the SSB TX
passband; a stray/garbled datagram can send anything up to UInt32.max.

The result is a reply keyed up outside the passband (attenuated / effectively
off-air) while the TX marker is separately pinned to the band edge by
`WaterfallAxis.clampedFraction` -- so the marker and the actual tone disagree.
The desktop (`reply_tx_audio_hz`, #564) and Android (`replyTxFrequencyHz`,
#563) ports already bound this untrusted df; the iOS port dropped that guard.

Root cause fix: add pure `WaterfallAxis.boundedReplyTxHz` (Foundation-only,
Kit-testable) that honors a df only inside [minTxHz, maxTxHz] = [200, 3000] --
the same range `tunedTxHz` clamps tap-to-tune to -- and returns nil (keep the
operator's current offset) for 0/unspecified or out-of-band requests. In-band
replies behave exactly as before; the app's own click-to-answer (df=0) is
unchanged.

Tests: WaterfallAxisTests gains in-band-honored and dropped-out-of-band cases
(4 fail against the old unbounded behavior). Full FT8AFKit suite 131/131 via
swift test on Linux. The onReply wiring lives in the Xcode-only app target
(not Linux-buildable); the extracted helper + its tests are.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented Jul 13, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 29.40%. Comparing base (647b12e) to head (6dcaad0).

Additional details and impacted files

Impacted file tree graph

@@             Coverage Diff              @@
##                dev     #583      +/-   ##
============================================
+ Coverage     29.36%   29.40%   +0.04%     
  Complexity      197      197              
============================================
  Files           179      179              
  Lines         24068    24083      +15     
  Branches       3263     3263              
============================================
+ Hits           7067     7082      +15     
  Misses        16780    16780              
  Partials        221      221              
Flag Coverage Δ
ios 92.99% <100.00%> (+0.04%) ⬆️
native 9.93% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
ios/FT8AFKit/Sources/FT8Audio/WaterfallAxis.swift 100.00% <100.00%> (ø)
...AFKit/Tests/FT8AudioTests/WaterfallAxisTests.swift 100.00% <100.00%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR hardens the iOS WSJT-X UDP Reply handling by validating the companion-app requested audio offset (df / deltaFreq) before adopting it as the TX tone, ensuring replies never key up outside the SSB transmit passband and keeping the TX marker/tone consistent.

Changes:

  • Add WaterfallAxis.boundedReplyTxHz(_:) to accept reply-requested TX offsets only when they fall within minTxHz...maxTxHz (otherwise keep the current offset).
  • Update LiveEngine.onReply to use the new helper instead of accepting any deltaFreq > 0.
  • Add unit tests covering in-band acceptance and 0/out-of-band/garbage-value rejection.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.

File Description
ios/FT8AFKit/Sources/FT8Audio/WaterfallAxis.swift Adds a passband-bounding helper for untrusted WSJT-X Reply df values.
ios/FT8AFKit/Tests/FT8AudioTests/WaterfallAxisTests.swift Adds test coverage asserting in-band values are honored and 0/out-of-band values are ignored.
ios/FT8AF/FT8AF/Engine/LiveEngine.swift Wires reply handling to adopt df only when it’s within the TX passband.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@patrickrb
patrickrb merged commit e1dd634 into dev Jul 15, 2026
18 checks passed
@patrickrb
patrickrb deleted the optio/task-45727737-3287-46be-9037-9b13f9c2c87e branch July 15, 2026 13:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants