diff --git a/ios/FT8AF/FT8AF/Engine/LiveEngine.swift b/ios/FT8AF/FT8AF/Engine/LiveEngine.swift index 32e6a382..a2321ae3 100644 --- a/ios/FT8AF/FT8AF/Engine/LiveEngine.swift +++ b/ios/FT8AF/FT8AF/Engine/LiveEngine.swift @@ -438,8 +438,13 @@ final class LiveEngine { udp.onReply = { [weak self] call, grid, snr, deltaFreq in guard let self, let appState = self.appState else { return } // Honor the requested audio frequency: answer on the tone the companion asked - // for, not whatever the current TX frequency happens to be. - if deltaFreq > 0 { appState.waterfall.txFreqHz = Float(deltaFreq) } + // for, not whatever the current TX frequency happens to be. `deltaFreq` is a + // raw value off an untrusted UDP socket, so only adopt it when it lands inside + // the transmittable passband; an unspecified (0) or out-of-band request keeps + // the current offset (mirrors the desktop/Android reply-df guards). + if let txHz = WaterfallAxis.boundedReplyTxHz(Float(deltaFreq)) { + appState.waterfall.txFreqHz = txHz + } let msg = DecodeMessage( utcTime: Self.utcTimeString(from: Int64(Date().timeIntervalSince1970 * 1000)), callFrom: call, callTo: "", snr: Int(snr), diff --git a/ios/FT8AFKit/Sources/FT8Audio/WaterfallAxis.swift b/ios/FT8AFKit/Sources/FT8Audio/WaterfallAxis.swift index 0b792dc9..b4a73fef 100644 --- a/ios/FT8AFKit/Sources/FT8Audio/WaterfallAxis.swift +++ b/ios/FT8AFKit/Sources/FT8Audio/WaterfallAxis.swift @@ -57,4 +57,18 @@ public enum WaterfallAxis { public static func tunedTxHz(forFraction fraction: Float) -> Float { return min(max(hz(forFraction: fraction), minTxHz), maxTxHz) } + + /// The audio offset (Hz) to answer a WSJT-X `Reply` on, given the companion's + /// requested `deltaFreq`, or `nil` to keep the current TX offset. The `df` + /// arrives on an untrusted UDP socket, so a value of 0 (unspecified — the + /// app's own click-to-answer sends 0) or one outside the transmittable + /// passband `minTxHz ... maxTxHz` must NOT be adopted: keying up outside the + /// SSB passband sends the reply attenuated/off-air, and (because the TX + /// marker is separately pinned to the band edge by `clampedFraction`) leaves + /// the marker pointing where the tone isn't. Mirrors the desktop + /// `reply_tx_audio_hz` and Android `replyTxFrequencyHz` guards, which the iOS + /// port previously omitted (it honored any `deltaFreq > 0` unbounded). + public static func boundedReplyTxHz(_ hz: Float) -> Float? { + return (minTxHz...maxTxHz).contains(hz) ? hz : nil + } } diff --git a/ios/FT8AFKit/Tests/FT8AudioTests/WaterfallAxisTests.swift b/ios/FT8AFKit/Tests/FT8AudioTests/WaterfallAxisTests.swift index 05d0560c..855112cc 100644 --- a/ios/FT8AFKit/Tests/FT8AudioTests/WaterfallAxisTests.swift +++ b/ios/FT8AFKit/Tests/FT8AudioTests/WaterfallAxisTests.swift @@ -77,4 +77,26 @@ final class WaterfallAxisTests: XCTestCase { let fraction = WaterfallAxis.fraction(forHz: 2000) XCTAssertEqual(WaterfallAxis.tunedTxHz(forFraction: fraction), 2000, accuracy: 1e-3) } + + /// A WSJT-X UDP reply's requested `deltaFreq` is honored only when it lands + /// inside the transmittable passband — mirrors the desktop/Android guards the + /// iOS reply path previously omitted (it adopted any deltaFreq > 0). + func testBoundedReplyTxHzHonorsInBandRequests() { + XCTAssertEqual(WaterfallAxis.boundedReplyTxHz(200), 200) // lower edge, inclusive + XCTAssertEqual(WaterfallAxis.boundedReplyTxHz(1500), 1500) + XCTAssertEqual(WaterfallAxis.boundedReplyTxHz(3000), 3000) // upper edge, inclusive + XCTAssertEqual(WaterfallAxis.boundedReplyTxHz(WaterfallAxis.minTxHz), WaterfallAxis.minTxHz) + XCTAssertEqual(WaterfallAxis.boundedReplyTxHz(WaterfallAxis.maxTxHz), WaterfallAxis.maxTxHz) + } + + /// Unspecified (0) or out-of-band requests are dropped (nil) so the caller + /// keeps the operator's current offset rather than keying up off-air. + func testBoundedReplyTxHzDropsUnspecifiedAndOutOfBand() { + XCTAssertNil(WaterfallAxis.boundedReplyTxHz(0)) // the app's own click-to-answer sends 0 + XCTAssertNil(WaterfallAxis.boundedReplyTxHz(199)) // just below the passband + XCTAssertNil(WaterfallAxis.boundedReplyTxHz(3001)) // just above the passband + XCTAssertNil(WaterfallAxis.boundedReplyTxHz(3400)) // in the display band but not transmittable + // A garbage UInt32 df (huge, would overdrive far outside the passband). + XCTAssertNil(WaterfallAxis.boundedReplyTxHz(Float(UInt32.max))) + } }