diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java b/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java index 3e57ea8a..e11e8bb8 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java @@ -1262,11 +1262,34 @@ private void broadcastWsjtxQso(QSLRecord r) { svc.sendQsoLogged(q, adif); } + /** + * The audio TX offset (Hz) to adopt for an inbound WSJT-X Reply's requested {@code df}, + * or -1 to keep the current frequency. Bounds the untrusted UDP value to the same + * audio passband the tuning UI permits: {@code [100, spectrumWidthHz - 100]}, matching + * {@code WaterfallView}'s click-to-tune clamp of {@code freq_hz} to + * {@code [100, spectrumWidth - 100]}. A df below 100, above {@code spectrumWidthHz - 100}, + * non-positive, or absent is ignored — a malformed/garbled datagram, or one asking for a + * tone the user could never dial in by hand, must not push the TX marker outside the + * visible/tunable range. + */ + static int replyTxFrequencyHz(int deltaFreq, int spectrumWidthHz) { + int max = spectrumWidthHz - 100; + return (deltaFreq >= 100 && deltaFreq <= max) ? deltaFreq : -1; + } + /** Inbound Reply: call the referenced station, preferring the real decode. */ - private void handleWsjtxReply(String call, String grid, int snr) { + private void handleWsjtxReply(String call, String grid, int snr, int deltaFreq) { if (ft8TransmitSignal == null || call == null || call.isEmpty()) { return; } + // Honor the audio frequency the companion asked us to answer on (WSJT-X's `df`), + // so we key up on the requested tone rather than the current TX offset — matching + // the iOS port. getBaseFrequency() is the shared RX/TX audio offset + // FT8TransmitSignal reads when it generates the waveform. + int txHz = replyTxFrequencyHz(deltaFreq, GeneralVariables.getSpectrumWidth()); + if (txHz > 0) { + GeneralVariables.setBaseFrequency(txHz); + } Ft8Message target = findRecentDecode(call); if (target == null) { target = new Ft8Message("", call, grid == null ? "" : grid); diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/wsjtx/WsjtxCodec.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/wsjtx/WsjtxCodec.kt index 3e614a11..58c02fac 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/wsjtx/WsjtxCodec.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/wsjtx/WsjtxCodec.kt @@ -225,7 +225,14 @@ object WsjtxCodec { // --- inbound ------------------------------------------------------------ sealed class Inbound { - data class Reply(val call: String, val grid: String, val snr: Int) : Inbound() + /** + * A Reply request. [deltaFreq] is the audio frequency (Hz) the companion asked us + * to answer on — the caller should honor it so we key up on the requested tone + * rather than whatever the current TX frequency happens to be (mirrors the iOS + * port). It is the WSJT-X message's `df` field; 0 means "unspecified". + */ + data class Reply(val call: String, val grid: String, val snr: Int, val deltaFreq: Int) : + Inbound() object Replay : Inbound() data class HaltTx(val autoOnly: Boolean) : Inbound() data class FreeText(val text: String, val send: Boolean) : Inbound() @@ -278,11 +285,11 @@ object WsjtxCodec { r.i32() ?: return null // time val snr = r.i32() ?: return null r.f64() ?: return null // dt - r.i32() ?: return null // df + val deltaFreq = r.i32() ?: return null // df: the requested audio freq (Hz) r.string() ?: return null // mode val message = r.string() ?: return null val target = parseReplyTarget(message) ?: return null - Inbound.Reply(target.first, target.second, snr) + Inbound.Reply(target.first, target.second, snr, deltaFreq) } T_REPLAY -> Inbound.Replay // Reject a truncated packet rather than defaulting the trailing bool: a malformed diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/wsjtx/WsjtxUdpService.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/wsjtx/WsjtxUdpService.kt index 13183af9..69729d55 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/wsjtx/WsjtxUdpService.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/wsjtx/WsjtxUdpService.kt @@ -60,7 +60,7 @@ object WsjtxUdpService { // Java-friendly SAM interfaces so the (Java) ViewModel can wire these with // plain lambdas. fun interface StatusProvider { fun get(): WsjtxCodec.Status? } - fun interface ReplyHandler { fun onReply(call: String, grid: String, snr: Int) } + fun interface ReplyHandler { fun onReply(call: String, grid: String, snr: Int, deltaFreq: Int) } fun interface HaltHandler { fun onHalt(autoOnly: Boolean) } fun interface FreeTextHandler { fun onFreeText(text: String, send: Boolean) } fun interface ReplayHandler { fun onReplay() } @@ -210,7 +210,7 @@ object WsjtxUdpService { private fun dispatch(buf: ByteArray) { when (val msg = WsjtxCodec.parseInbound(buf)) { is WsjtxCodec.Inbound.Reply -> - mainHandler.post { onReply?.onReply(msg.call, msg.grid, msg.snr) } + mainHandler.post { onReply?.onReply(msg.call, msg.grid, msg.snr, msg.deltaFreq) } is WsjtxCodec.Inbound.HaltTx -> mainHandler.post { onHaltTx?.onHalt(msg.autoOnly) } is WsjtxCodec.Inbound.FreeText -> diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/ReplyTxFrequencyTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/ReplyTxFrequencyTest.java new file mode 100644 index 00000000..bf5f1c7c --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/ReplyTxFrequencyTest.java @@ -0,0 +1,66 @@ +package com.k1af.ft8af; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; + +/** + * Unit tests for {@link MainViewModel#replyTxFrequencyHz} — the bound applied to the + * (untrusted) audio frequency a WSJT-X companion asks us to answer on via an inbound + * Reply request's {@code df} field. A plausible df is honored so we key up on the + * requested tone; anything outside the same passband the tuning UI permits + * ({@code [100, spectrumWidth - 100]}, matching {@code WaterfallView}'s click-to-tune + * clamp) is ignored, so a malformed/garbled UDP datagram — or one asking for a tone the + * user could never dial in by hand — can never push the TX marker off the visible band. + */ +public class ReplyTxFrequencyTest { + + // A representative in-range spectrum width (the settings default). getSpectrumWidth() + // is always clamped to [MIN_SPECTRUM_WIDTH_HZ, MAX_SPECTRUM_WIDTH_HZ] = [2500, 5000]. + private static final int WIDTH = 3500; + private static final int MIN = 100; // lower tuning bound + private static final int MAX = WIDTH - 100; // upper tuning bound (3400) + + @Test + public void midBandFrequency_isHonored() { + assertThat(MainViewModel.replyTxFrequencyHz(1500, WIDTH)).isEqualTo(1500); + } + + @Test + public void lowestValidFrequency_isHonored() { + // The UI's minimum tunable offset is 100 Hz. + assertThat(MainViewModel.replyTxFrequencyHz(MIN, WIDTH)).isEqualTo(MIN); + } + + @Test + public void highestValidFrequency_isHonored() { + // The UI's maximum tunable offset is spectrumWidth - 100. + assertThat(MainViewModel.replyTxFrequencyHz(MAX, WIDTH)).isEqualTo(MAX); + } + + @Test + public void belowMin_keepsCurrent() { + // Below the 100 Hz floor the tuning UI enforces — the user could never dial this in. + assertThat(MainViewModel.replyTxFrequencyHz(99, WIDTH)).isEqualTo(-1); + assertThat(MainViewModel.replyTxFrequencyHz(1, WIDTH)).isEqualTo(-1); + } + + @Test + public void zeroFrequency_keepsCurrent() { + // df == 0 means "unspecified" in the WSJT-X message. + assertThat(MainViewModel.replyTxFrequencyHz(0, WIDTH)).isEqualTo(-1); + } + + @Test + public void negativeFrequency_keepsCurrent() { + // A hostile/garbled df decoded as a negative int must be ignored. + assertThat(MainViewModel.replyTxFrequencyHz(-200, WIDTH)).isEqualTo(-1); + } + + @Test + public void aboveMax_keepsCurrent() { + // Beyond spectrumWidth - 100 — outside the visible/tunable range; ignore it. + assertThat(MainViewModel.replyTxFrequencyHz(MAX + 1, WIDTH)).isEqualTo(-1); + assertThat(MainViewModel.replyTxFrequencyHz(50_000, WIDTH)).isEqualTo(-1); + } +} diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/wsjtx/WsjtxCodecTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/wsjtx/WsjtxCodecTest.kt index 4287860b..2af8acd9 100644 --- a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/wsjtx/WsjtxCodecTest.kt +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/wsjtx/WsjtxCodecTest.kt @@ -190,12 +190,12 @@ class WsjtxCodecTest { val out = ByteArray(bb.position()); bb.flip(); bb.get(out); return out } - private fun makeReply(message: String, snr: Int): ByteArray { + private fun makeReply(message: String, snr: Int, df: Int = 1500): ByteArray { val bb = beginInbound(WsjtxCodec.T_REPLY) bb.putInt(47_000_000) // time bb.putInt(snr) bb.putDouble(0.1) // dt - bb.putInt(1500) // df + bb.putInt(df) // df putStr(bb, "FT8") putStr(bb, message) bb.put(0) // low confidence @@ -206,21 +206,29 @@ class WsjtxCodecTest { @Test fun parseReplyPlainCq() { assertThat(WsjtxCodec.parseInbound(makeReply("CQ K1ABC FN42", -3))) - .isEqualTo(WsjtxCodec.Inbound.Reply("K1ABC", "FN42", -3)) + .isEqualTo(WsjtxCodec.Inbound.Reply("K1ABC", "FN42", -3, 1500)) } @Test fun parseReplyCqWithDirective() { assertThat(WsjtxCodec.parseInbound(makeReply("CQ DX K1ABC FN42", -3))) - .isEqualTo(WsjtxCodec.Inbound.Reply("K1ABC", "FN42", -3)) + .isEqualTo(WsjtxCodec.Inbound.Reply("K1ABC", "FN42", -3, 1500)) assertThat(WsjtxCodec.parseInbound(makeReply("CQ POTA W1AW/4 EM70", 5))) - .isEqualTo(WsjtxCodec.Inbound.Reply("W1AW/4", "EM70", 5)) + .isEqualTo(WsjtxCodec.Inbound.Reply("W1AW/4", "EM70", 5, 1500)) } @Test fun parseReplyNonCqCallsSender() { assertThat(WsjtxCodec.parseInbound(makeReply("K0XYZ K1ABC -12", -12))) - .isEqualTo(WsjtxCodec.Inbound.Reply("K1ABC", "", -12)) + .isEqualTo(WsjtxCodec.Inbound.Reply("K1ABC", "", -12, 1500)) + } + + @Test + fun parseReplyCapturesRequestedDeltaFreq() { + // The `df` field carries the audio frequency the companion asked us to answer on; + // the reply must surface it (not discard it) so the caller can key up on that tone. + assertThat(WsjtxCodec.parseInbound(makeReply("CQ K1ABC FN42", -3, df = 2100))) + .isEqualTo(WsjtxCodec.Inbound.Reply("K1ABC", "FN42", -3, 2100)) } @Test