Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions ios/FT8AF/FT8AF/Engine/LiveEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
14 changes: 14 additions & 0 deletions ios/FT8AFKit/Sources/FT8Audio/WaterfallAxis.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
22 changes: 22 additions & 0 deletions ios/FT8AFKit/Tests/FT8AudioTests/WaterfallAxisTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
}
}
Loading