From 09e7df8074bace2b030b243d65c1dac1a5637484 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Mon, 8 Jun 2026 14:27:56 -0500 Subject: [PATCH 1/2] Add FT8 DXpedition "Hound" mode (work a Fox) Implements the Hound side of FT8 DXpedition (Fox/Hound) mode: call a DXpedition "Fox" high in the 1000-4000 Hz band, auto-QSY down to where the Fox answers, reply with the report, and log on RR73. The Fox combo message ("CALL RR73; CALL2 rpt", i3=0/n3=1) already decodes and surfaces to Java on this build (verified on-device against a WSJT-X ft8code reference frame), so no native/decoder work is needed. The Hound only ever transmits standard i3=1 messages (grid-call + R+rpt), which already encode, so there are no DSP changes. - Ft8Message: fix cosmetic double-sign in the i3=0/n3=1 combo formatter. - GeneralVariables: houndMode + houndFoxCall flags. - FT8TransmitSignal: startHound() + handleHoundCycle(), a dedicated Hound QSO handler gated behind houndMode (standard sequencer untouched). Locks TX to the odd slot, reuses getFunctionCommand orders 1 (grid) and 3 (R+rpt), auto-QSYs to the Fox frequency on invite, logs on RR73. - MainViewModel: startHoundMode()/stopHoundMode() (disables Hunt, which is mutually exclusive). - TxStrip: new "DX" chip; FT8USApp: HoundSetupSheet (Fox call + call freq). Verified on-device: builds, installs, launches without crash; the DX chip opens the setup sheet; Start enters Hound mode and transmits the grid-call in the odd slot each cycle with Hunt auto-disabled. Full QSO sequencing (invite -> QSY -> reply, RR73 -> log) reuses proven primitives but awaits on-air validation against a live Fox. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../java/com/bg7yoz/ft8cn/Ft8Message.java | 6 +- .../com/bg7yoz/ft8cn/GeneralVariables.java | 8 ++ .../java/com/bg7yoz/ft8cn/MainViewModel.java | 29 +++++ .../ft8cn/ft8transmit/FT8TransmitSignal.java | 102 ++++++++++++++++++ .../kotlin/radio/ks3ckc/ft8us/FT8USApp.kt | 42 +++++++- .../ft8us/ui/components/HoundSetupSheet.kt | 78 ++++++++++++++ .../ks3ckc/ft8us/ui/components/TxStrip.kt | 26 +++++ 7 files changed, 288 insertions(+), 3 deletions(-) create mode 100644 ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/ui/components/HoundSetupSheet.kt diff --git a/ft8cn/app/src/main/java/com/bg7yoz/ft8cn/Ft8Message.java b/ft8cn/app/src/main/java/com/bg7yoz/ft8cn/Ft8Message.java index 0f02e4fc..7c76b6c3 100644 --- a/ft8cn/app/src/main/java/com/bg7yoz/ft8cn/Ft8Message.java +++ b/ft8cn/app/src/main/java/com/bg7yoz/ft8cn/Ft8Message.java @@ -250,13 +250,15 @@ public String getMessageText() { } if (i3 == 0 && (n3 == 1)) {//this is DXpedition - + // Fox combo: " RR73; ". The report + // is already signed, so format its magnitude after the sign char — + // otherwise a negative report renders a double minus ("--18"). return String.format("%s RR73; %s %s %s%d" ,callsignTo ,dx_call_to2 ,hashList.getCallsign(new long[]{callFromHash10}) ,report > 0 ? "+" : "-" - ,report + ,Math.abs(report) ); } diff --git a/ft8cn/app/src/main/java/com/bg7yoz/ft8cn/GeneralVariables.java b/ft8cn/app/src/main/java/com/bg7yoz/ft8cn/GeneralVariables.java index 6f191477..b06b3b44 100644 --- a/ft8cn/app/src/main/java/com/bg7yoz/ft8cn/GeneralVariables.java +++ b/ft8cn/app/src/main/java/com/bg7yoz/ft8cn/GeneralVariables.java @@ -485,6 +485,14 @@ public static String getMyMaidenheadGrid() { return myMaidenheadGrid; } + // ===== FT8 DXpedition "Hound" mode ===== + // When true, the TX engine runs the Hound QSO variant (call Fox high at + // 1000-4000 Hz, auto-QSY down to where Fox calls us, reply R+rpt, log on + // RR73) instead of the standard auto-sequencer. Mutually exclusive with the + // Hunt auto-answer-CQ mode. houndFoxCall is the Fox's base callsign. + public static boolean houndMode = false; + public static String houndFoxCall = ""; + public static float getBaseFrequency() { return baseFrequency; } diff --git a/ft8cn/app/src/main/java/com/bg7yoz/ft8cn/MainViewModel.java b/ft8cn/app/src/main/java/com/bg7yoz/ft8cn/MainViewModel.java index 8be64e7d..a8de85f0 100644 --- a/ft8cn/app/src/main/java/com/bg7yoz/ft8cn/MainViewModel.java +++ b/ft8cn/app/src/main/java/com/bg7yoz/ft8cn/MainViewModel.java @@ -742,6 +742,35 @@ public void callStation(Ft8Message message) { ft8TransmitSignal.transmitNow(); } + // ===== FT8 DXpedition Hound mode ===== + + /** + * Enter DXpedition Hound mode and start calling the Fox. Mutually exclusive + * with Hunt (auto-answer-CQ), which is disabled here. The rig should already + * be tuned to the Fox's published dial frequency. + * + * @param foxCall the Fox's base callsign (the DXpedition) + * @param callFreqHz initial Hound TX audio frequency, 1000-4000 Hz + */ + public void startHoundMode(String foxCall, float callFreqHz) { + if (foxCall == null || foxCall.trim().length() < 3) return; + if (GeneralVariables.myCallsign == null + || GeneralVariables.myCallsign.length() < 3) return; + GeneralVariables.houndMode = true; + GeneralVariables.houndFoxCall = foxCall.trim().toUpperCase(); + GeneralVariables.autoFollowCQ = false;// Hound and Hunt are mutually exclusive + GeneralVariables.resetLaunchSupervision(); + ft8TransmitSignal.startHound(GeneralVariables.houndFoxCall, callFreqHz); + } + + /** Leave Hound mode and return to the normal idle/CQ state. */ + public void stopHoundMode() { + GeneralVariables.houndMode = false; + GeneralVariables.houndFoxCall = ""; + ft8TransmitSignal.setActivated(false); + ft8TransmitSignal.resetToCQ(); + } + /** * Get the followed callsign list from the database diff --git a/ft8cn/app/src/main/java/com/bg7yoz/ft8cn/ft8transmit/FT8TransmitSignal.java b/ft8cn/app/src/main/java/com/bg7yoz/ft8cn/ft8transmit/FT8TransmitSignal.java index e3718831..4ca34153 100644 --- a/ft8cn/app/src/main/java/com/bg7yoz/ft8cn/ft8transmit/FT8TransmitSignal.java +++ b/ft8cn/app/src/main/java/com/bg7yoz/ft8cn/ft8transmit/FT8TransmitSignal.java @@ -1065,6 +1065,10 @@ public void parseMessageToFunction(ArrayList msgList) { } ArrayList messages = new ArrayList<>(msgList);// prevent thread conflicts + if (GeneralVariables.houndMode) {// DXpedition Hound: dedicated QSO handler + handleHoundCycle(messages); + return; + } int newOrder = checkFunctionOrdFromMessages(messages);// check reply message sequence from the other party; -1 means not received // Per-cycle spine of the QSO trace: current state, what (if anything) the @@ -1207,6 +1211,104 @@ public void parseMessageToFunction(ArrayList msgList) { } + // ==================== FT8 DXpedition Hound ==================== + + /** + * Begin DXpedition Hound operation: call the Fox high (1000-4000 Hz) and let + * {@link #handleHoundCycle} auto-QSY and sequence the QSO. The caller sets + * {@code GeneralVariables.houndMode = true} first. + * + * @param foxCall the Fox's base callsign + * @param callFreqHz initial Hound TX audio frequency (1000-4000 Hz) + */ + public void startHound(String foxCall, float callFreqHz) { + int i3 = GenerateFT8.checkI3ByCallsign(GeneralVariables.myCallsign); + // Fox transmits in the even/1st slot (sequential 0); setTransmit derives + // our slot as (fox.sequential + 1) % 2 = 1 (odd), which is where Hounds + // are required to transmit. + resetTargetReport(); + setTransmit(new TransmitCallsign(i3, 0, foxCall, callFreqHz, 0, 0), 1, ""); + setBaseFrequency(callFreqHz);// call Fox at the chosen high frequency + setActivated(true); + GeneralVariables.fileLog("HOUND: start, fox=" + foxCall + + " callFreq=" + Math.round(callFreqHz) + "Hz slot=" + sequential); + } + + /** + * DXpedition Hound per-cycle handler. Reacts to the Fox's reply to us: + *
    + *
  • RR73 to me (combo where I'm the acknowledged call, or an explicit + * standard RR73 from Fox) -> log + complete.
  • + *
  • Report/invite to me (combo where I'm the invited second call, or a + * standard "<me> <fox> -rpt") -> QSY to the frequency Fox + * called me on and reply "<fox> <me> R-rpt".
  • + *
  • Otherwise keep calling at the current order (grid-call or R+rpt).
  • + *
+ */ + private void handleHoundCycle(ArrayList messages) { + if (toCallsign == null || !toCallsign.haveTargetCallsign()) return; + String fox = toCallsign.callsign; + + for (int i = messages.size() - 1; i >= 0; i--) { + Ft8Message msg = messages.get(i); + if (msg.getSequence() == sequential) continue;// our own slot + if (msg.band != GeneralVariables.band) continue; + + boolean combo = (msg.i3 == 0 && msg.n3 == 1);// Fox DXpedition combo + boolean toMe = GeneralVariables.checkIsMyCallsign(msg.getCallsignTo()); + + // 1) RR73 to me => QSO complete & logged. Combo: I'm the acknowledged + // (first) call. Standard: an explicit RR73 addressed to me by Fox. + if ((combo && toMe) + || (msg.i3 == 1 && toMe + && checkCallsignIsCallTo(msg.getCallsignFrom(), fox) + && GeneralVariables.checkFun4(msg.extraInfo))) { + GeneralVariables.fileLog("HOUND: RR73 from " + fox + " -> complete"); + houndComplete(); + return; + } + + // 2) Fox reported / invited me => reply R+rpt at Fox's frequency. + // Combo: I'm the invited second call (dx_call_to2), report = msg.report. + // Standard: " -rpt" or " R-rpt". + String invited = msg.dx_call_to2 == null ? "" + : msg.dx_call_to2.replace("<", "").replace(">", ""); + boolean invitedByCombo = combo && GeneralVariables.checkIsMyCallsign(invited); + boolean reportStd = msg.i3 == 1 && toMe + && checkCallsignIsCallTo(msg.getCallsignFrom(), fox) + && (GeneralVariables.checkFun2(msg.extraInfo) + || GeneralVariables.checkFun3(msg.extraInfo)); + if (invitedByCombo || reportStd) { + int rpt = invitedByCombo ? msg.report : getReportFromExtraInfo(msg.extraInfo); + if (rpt != -100) { + receivedReport = rpt; + receiveTargetReport = rpt; + } + toCallsign.snr = msg.snr;// Fox's SNR as I hear it -> goes in my R+rpt + setBaseFrequency(msg.freq_hz);// auto-QSY to where Fox called me + functionOrder = 3;// " R-rpt" + generateFun(); + setCurrentFunctionOrder(functionOrder); + mutableFunctionOrder.postValue(functionOrder); + GeneralVariables.fileLog(String.format( + "HOUND: report from %s rpt=%d -> QSY %.0fHz, send R%s", + fox, rpt, msg.freq_hz, toCallsign.getSnr())); + return; + } + } + // Nothing relevant this cycle: keep calling at the current order. + } + + /** Hound QSO complete: log it, fire the celebration, and stop transmitting. */ + private void houndComplete() { + if (toCallsign == null) return; + doComplete();// saves the QSL record + posts mutableQsoCompletedAt + setActivated(false);// worked the Fox; stop calling + GeneralVariables.fileLog("HOUND: QSO logged with " + toCallsign.callsign); + } + + // ==================== End FT8 DXpedition Hound ==================== + /** * Check watch list for active CQ messages that are not my current target callsign. * diff --git a/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/FT8USApp.kt b/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/FT8USApp.kt index 2835a424..841b6c9a 100644 --- a/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/FT8USApp.kt +++ b/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/FT8USApp.kt @@ -38,6 +38,7 @@ import radio.ks3ckc.ft8us.theme.BgApp import radio.ks3ckc.ft8us.ui.components.ActiveQsoPanel import radio.ks3ckc.ft8us.ui.components.FT8USTab import radio.ks3ckc.ft8us.ui.components.FrequencyPickerSheet +import radio.ks3ckc.ft8us.ui.components.HoundSetupSheet import radio.ks3ckc.ft8us.ui.components.formatMhz import radio.ks3ckc.ft8us.ui.components.QsoCelebration import radio.ks3ckc.ft8us.ui.components.SlotTimerBar @@ -77,6 +78,11 @@ fun FT8USApp(mainViewModel: MainViewModel) { // editable in Settings, which provides the persisted default at startup). var huntEnabled by remember { mutableStateOf(GeneralVariables.autoFollowCQ) } + // DXpedition Hound mode. Mirrors GeneralVariables.houndMode; the setup sheet + // collects the Fox call + call frequency before starting. + var dxEnabled by remember { mutableStateOf(GeneralVariables.houndMode) } + var showHoundSetup by remember { mutableStateOf(false) } + // Frequency picker sheet state var showFrequencyPicker by rememberSaveable { mutableStateOf(false) } @@ -212,6 +218,7 @@ fun FT8USApp(mainViewModel: MainViewModel) { frequencyLabel = frequencyLabel, txSlot = txSlot, huntEnabled = huntEnabled, + dxEnabled = dxEnabled, expanded = qsoPanelExpanded, onCallCQ = { if (GeneralVariables.myCallsign.isNullOrEmpty()) { @@ -223,7 +230,22 @@ fun FT8USApp(mainViewModel: MainViewModel) { } }, onStop = { - mainViewModel.ft8TransmitSignal.setActivated(false) + // In Hound mode the STOP button leaves Hound entirely; + // otherwise it just deactivates the normal sequencer. + if (GeneralVariables.houndMode) { + mainViewModel.stopHoundMode() + dxEnabled = false + } else { + mainViewModel.ft8TransmitSignal.setActivated(false) + } + }, + onToggleDx = { + if (dxEnabled || GeneralVariables.houndMode) { + mainViewModel.stopHoundMode() + dxEnabled = false + } else { + showHoundSetup = true + } }, onToggleSlot = { val current = mainViewModel.ft8TransmitSignal.sequential @@ -274,5 +296,23 @@ fun FT8USApp(mainViewModel: MainViewModel) { showFrequencyPicker = false }, ) + + // DXpedition Hound setup — collects the Fox call + call frequency, then + // starts calling (disabling Hunt, which is mutually exclusive). + HoundSetupSheet( + visible = showHoundSetup, + initialFoxCall = GeneralVariables.houndFoxCall, + onDismiss = { showHoundSetup = false }, + onStart = { foxCall, callFreqHz -> + if (GeneralVariables.myCallsign.isNullOrEmpty()) { + Toast.makeText(context, context.getString(R.string.app_set_callsign_first), Toast.LENGTH_SHORT).show() + } else { + mainViewModel.startHoundMode(foxCall, callFreqHz) + dxEnabled = true + huntEnabled = false + showHoundSetup = false + } + }, + ) } } diff --git a/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/ui/components/HoundSetupSheet.kt b/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/ui/components/HoundSetupSheet.kt new file mode 100644 index 00000000..cc786114 --- /dev/null +++ b/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/ui/components/HoundSetupSheet.kt @@ -0,0 +1,78 @@ +package radio.ks3ckc.ft8us.ui.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.foundation.text.KeyboardOptions + +/** + * Setup dialog for FT8 DXpedition "Hound" mode. The user tunes the rig to the + * Fox's published dial frequency (via the normal frequency picker), then enters + * the Fox's base callsign here and an initial call frequency in the 1000-4000 Hz + * Hound band. On Start, the app begins calling the Fox and auto-QSYs down when + * answered. + */ +@Composable +fun HoundSetupSheet( + visible: Boolean, + initialFoxCall: String, + onDismiss: () -> Unit, + onStart: (foxCall: String, callFreqHz: Float) -> Unit, +) { + if (!visible) return + + var foxCall by remember { mutableStateOf(initialFoxCall) } + var callFreq by remember { mutableStateOf("2500") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Work a DXpedition (Hound)") }, + text = { + Column { + Text( + "Tune the rig to the Fox's published dial frequency first. " + + "You call high (1000–4000 Hz); the app auto-QSYs you " + + "down to where the Fox answers and replies with your report.", + ) + Spacer(Modifier.height(12.dp)) + OutlinedTextField( + value = foxCall, + onValueChange = { foxCall = it.uppercase().trim() }, + label = { Text("Fox callsign") }, + singleLine = true, + ) + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = callFreq, + onValueChange = { v -> callFreq = v.filter { it.isDigit() }.take(4) }, + label = { Text("Call frequency (Hz, 1000–4000)") }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + ) + } + }, + confirmButton = { + TextButton( + onClick = { + val f = (callFreq.toFloatOrNull() ?: 2500f).coerceIn(1000f, 4000f) + if (foxCall.trim().length >= 3) onStart(foxCall.trim(), f) + }, + ) { Text("Start") } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + }, + ) +} diff --git a/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/ui/components/TxStrip.kt b/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/ui/components/TxStrip.kt index 577e0525..5d79f287 100644 --- a/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/ui/components/TxStrip.kt +++ b/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/ui/components/TxStrip.kt @@ -39,11 +39,13 @@ fun TxStrip( frequencyLabel: String, txSlot: Int, huntEnabled: Boolean, + dxEnabled: Boolean = false, expanded: Boolean = false, onCallCQ: () -> Unit, onStop: () -> Unit, onToggleSlot: () -> Unit, onToggleHunt: () -> Unit, + onToggleDx: () -> Unit = {}, onOpenFrequencyPicker: () -> Unit, onToggleExpand: () -> Unit = {}, modifier: Modifier = Modifier, @@ -142,6 +144,30 @@ fun TxStrip( ) } + // DX (DXpedition Hound) toggle pill. On = working a Fox/DXpedition + // (call high, auto-QSY when answered). Mutually exclusive with HUNT/CQ. + val dxBg = if (dxEnabled) Accent.copy(alpha = 0.18f) else BgSurface3 + val dxColor = if (dxEnabled) Accent else TextMuted + Box( + modifier = Modifier + .clip(RoundedCornerShape(6.dp)) + .background(dxBg) + .clickable { onToggleDx() } + .padding(horizontal = 8.dp, vertical = 4.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = "DX", + color = dxColor, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = GeistMonoFamily, + letterSpacing = 0.02.sp, + maxLines = 1, + softWrap = false, + ) + } + // HUNT (auto-answer CQ) toggle pill. On = proactively call stations // calling CQ; off = run CQ and only work stations that answer us. val huntBg = if (huntEnabled) Signal.copy(alpha = 0.18f) else BgSurface3 From 4d9f941bb5e65f58e8d4711463b1bcef0ce0f72e Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Mon, 8 Jun 2026 14:56:39 -0500 Subject: [PATCH 2/2] Hound mode: address PR #162 review (Fox hash attribution, +0 report) - startHound(): seed the Fox callsign's 22/12/10-bit hashes so DXpedition combo decodes resolve the Fox for display and can be attributed to it. - handleHoundCycle(): only act on a combo whose 10-bit Fox hash matches our Fox; skip a combo whose hash resolves to a different known Fox. Implemented as a negative filter (proceed when the hash is unknown) so compound-call Foxes -- whose combo hashes the full call while the operator works the base -- still complete. - Ft8Message: a zero combo report now renders "+0" (>= 0), not "-0". - Add Ft8MessageTest coverage for the combo formatter (negative and zero). Copilot's report-value comment is intentionally not changed: per the WSJT-X DXpedition guide the Hound's R+rpt carries its own measurement of the Fox (toCallsign.snr = msg.snr), not the report the Fox sent -- which is correct. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../java/com/bg7yoz/ft8cn/Ft8Message.java | 5 ++-- .../ft8cn/ft8transmit/FT8TransmitSignal.java | 29 +++++++++++++++++++ .../java/com/bg7yoz/ft8cn/Ft8MessageTest.java | 28 ++++++++++++++++++ 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/ft8cn/app/src/main/java/com/bg7yoz/ft8cn/Ft8Message.java b/ft8cn/app/src/main/java/com/bg7yoz/ft8cn/Ft8Message.java index 7c76b6c3..548748a9 100644 --- a/ft8cn/app/src/main/java/com/bg7yoz/ft8cn/Ft8Message.java +++ b/ft8cn/app/src/main/java/com/bg7yoz/ft8cn/Ft8Message.java @@ -252,12 +252,13 @@ public String getMessageText() { if (i3 == 0 && (n3 == 1)) {//this is DXpedition // Fox combo: " RR73; ". The report // is already signed, so format its magnitude after the sign char — - // otherwise a negative report renders a double minus ("--18"). + // otherwise a negative report renders a double minus ("--18"). A zero + // report formats as "+0" (>= 0), matching WSJT-X, not "-0". return String.format("%s RR73; %s %s %s%d" ,callsignTo ,dx_call_to2 ,hashList.getCallsign(new long[]{callFromHash10}) - ,report > 0 ? "+" : "-" + ,report >= 0 ? "+" : "-" ,Math.abs(report) ); } diff --git a/ft8cn/app/src/main/java/com/bg7yoz/ft8cn/ft8transmit/FT8TransmitSignal.java b/ft8cn/app/src/main/java/com/bg7yoz/ft8cn/ft8transmit/FT8TransmitSignal.java index 4ca34153..f885f9fa 100644 --- a/ft8cn/app/src/main/java/com/bg7yoz/ft8cn/ft8transmit/FT8TransmitSignal.java +++ b/ft8cn/app/src/main/java/com/bg7yoz/ft8cn/ft8transmit/FT8TransmitSignal.java @@ -24,6 +24,7 @@ import com.bg7yoz.ft8cn.FT8Common; import com.bg7yoz.ft8cn.Ft8Message; +import com.bg7yoz.ft8cn.ft8signal.FT8Package; import com.bg7yoz.ft8cn.GeneralVariables; import com.bg7yoz.ft8cn.R; import com.bg7yoz.ft8cn.connector.ConnectMode; @@ -1223,6 +1224,14 @@ public void parseMessageToFunction(ArrayList msgList) { */ public void startHound(String foxCall, float callFreqHz) { int i3 = GenerateFT8.checkI3ByCallsign(GeneralVariables.myCallsign); + // Seed the Fox callsign's hashes. DXpedition combo messages carry the Fox + // only as a 10-bit hash, so without this the combo renders "<...>" in the + // UI and handleHoundCycle can't tell whose combo it is. (No-op for a + // compound Fox whose combo hashes its full call while the user enters the + // base call — handleHoundCycle degrades gracefully in that case.) + Ft8Message.hashList.addHash(FT8Package.getHash22(foxCall), foxCall); + Ft8Message.hashList.addHash(FT8Package.getHash12(foxCall), foxCall); + Ft8Message.hashList.addHash(FT8Package.getHash10(foxCall), foxCall); // Fox transmits in the even/1st slot (sequential 0); setTransmit derives // our slot as (fox.sequential + 1) % 2 = 1 (odd), which is where Hounds // are required to transmit. @@ -1257,6 +1266,11 @@ private void handleHoundCycle(ArrayList messages) { boolean combo = (msg.i3 == 0 && msg.n3 == 1);// Fox DXpedition combo boolean toMe = GeneralVariables.checkIsMyCallsign(msg.getCallsignTo()); + // A combo names the Fox only by a 10-bit hash. If that hash resolves + // to a known callsign that isn't our Fox, it belongs to a different + // Fox's pileup — skip it so we don't QSY/log against the wrong station. + if (combo && !comboFromOurFox(msg, fox)) continue; + // 1) RR73 to me => QSO complete & logged. Combo: I'm the acknowledged // (first) call. Standard: an explicit RR73 addressed to me by Fox. if ((combo && toMe) @@ -1307,6 +1321,21 @@ private void houndComplete() { GeneralVariables.fileLog("HOUND: QSO logged with " + toCallsign.callsign); } + /** + * Whether a DXpedition combo can be attributed to our Fox. The combo names + * the Fox only by a 10-bit hash; if that hash resolves to a known callsign + * that doesn't match our Fox (base or compound), it's a different Fox's combo. + * Returns true when the hash is unknown/unresolved, so we never reject a valid + * QSO just because the Fox's (possibly compound) call hasn't been hashed yet. + */ + private boolean comboFromOurFox(Ft8Message msg, String fox) { + String resolved = Ft8Message.hashList + .getCallsign(new long[]{msg.callFromHash10}) + .replace("<", "").replace(">", ""); + if (resolved.isEmpty() || resolved.equals("...")) return true;// unknown -> allow + return resolved.equals(fox) || resolved.contains(fox) || fox.contains(resolved); + } + // ==================== End FT8 DXpedition Hound ==================== /** diff --git a/ft8cn/app/src/test/java/com/bg7yoz/ft8cn/Ft8MessageTest.java b/ft8cn/app/src/test/java/com/bg7yoz/ft8cn/Ft8MessageTest.java index bf4dcfdb..95c4149d 100644 --- a/ft8cn/app/src/test/java/com/bg7yoz/ft8cn/Ft8MessageTest.java +++ b/ft8cn/app/src/test/java/com/bg7yoz/ft8cn/Ft8MessageTest.java @@ -185,6 +185,34 @@ public void getMessageText_euVhf_zeroPadsSerial() { assertThat(msg.getMessageText()).isEqualTo(" R 570007 JO22DB"); } + @Test + public void getMessageText_dxpeditionCombo_negativeReportSingleSign() { + // i3=0, n3=1 DXpedition combo: " RR73; ". + // An unseeded Fox hash resolves to "<...>"; a negative report must render a + // single minus (regression: the magnitude is formatted after the sign char). + Ft8Message msg = new Ft8Message(FT8Common.FT8_MODE); + msg.i3 = 0; + msg.n3 = 1; + msg.callsignTo = "K1ABC"; + msg.dx_call_to2 = "W9XYZ"; + msg.callFromHash10 = 0; + msg.report = -18; + assertThat(msg.getMessageText()).isEqualTo("K1ABC RR73; W9XYZ <...> -18"); + } + + @Test + public void getMessageText_dxpeditionCombo_zeroReportIsPlusZero() { + // A zero report formats as "+0" (>= 0), matching WSJT-X, not "-0". + Ft8Message msg = new Ft8Message(FT8Common.FT8_MODE); + msg.i3 = 0; + msg.n3 = 1; + msg.callsignTo = "K1ABC"; + msg.dx_call_to2 = "W9XYZ"; + msg.callFromHash10 = 0; + msg.report = 0; + assertThat(msg.getMessageText()).isEqualTo("K1ABC RR73; W9XYZ <...> +0"); + } + // ---- getCallsignFrom / getCallsignTo ------------------------------------ @Test