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
25 changes: 24 additions & 1 deletion ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
13 changes: 10 additions & 3 deletions ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/wsjtx/WsjtxCodec.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
Expand Down Expand Up @@ -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 ->
Expand Down
66 changes: 66 additions & 0 deletions ft8af/app/src/test/java/com/k1af/ft8af/ReplyTxFrequencyTest.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading