From 1d138cdb6635b39fb2b9465cb3250943077293cc Mon Sep 17 00:00:00 2001 From: quadra Date: Tue, 20 Oct 2026 04:46:41 +0000 Subject: [PATCH 01/36] Add QRP Labs QMX via the hamlib backend The rig catalog (rigaddress.txt) had no entry for the QMX at all, so it never showed up as a selectable rig. Its sibling QDX is listed using the generic Kenwood TS-590/480 command set, but that path sets MD2 (SSB-USB voice mode) rather than the DATA mode + TX-rise-threshold tuning the QMX specifically needs to transmit -- same bug danleetw/FT8TW's native app had to hand-patch around. FT8AF already vendors and statically compiles hamlib 4.7.2, which has a dedicated QRP Labs QMX backend (qrplabs_qmx_caps / RIG_MODEL_QRPLABS_QMX), exposed through the existing HamlibRig bridge -- the same mechanism already used for "Yaesu FT-891 (Hamlib)". This adds the matching catalog line: 0x809 = RIG_MAKE_MODEL(RIG_KENWOOD, 57), hamlib's real QMX model ID, at its minimum supported baud rate (9600). Per QRP Labs' own current QMX firmware guidance, the native hamlib definition is the recommended path over Kenwood emulation, which matches this change. Appended at the end of rigaddress.txt (after ICOM IC-7300MK2), per RigAddressAssetTest's documented append-only invariant -- the selected rig is persisted as a raw list index, so inserting anywhere else would silently reassign every existing user's selected rig on upgrade. Added a matching GOLDEN_ORDER entry, moved the "last entry" pin from the MK2 test to this one, and added a dedicated test (qmxHamlib_isListedWith HamlibModelId) mirroring the existing ic7300mk2_isListedWithIcomDefaults pattern. Verified: builds cleanly through resource merge, manifest processing, and Java/Kotlin compilation on this checkout; the new/changed test assertions were traced by hand against RigNameList's parser (confirmed "809" parses as hex -> 2057 = 0x809) since this environment can't execute them. Could not produce a final APK or run the JVM unit tests locally -- the AGP-bundled aapt2 for Linux is x86_64-only and this build machine is aarch64 (processDebugResources, a dependency of both assembleDebug and testDebugUnitTest, needs it to compile AAR resources). qemu-user-static emulation was attempted but blocked by an unrelated, unsafe apt dependency conflict in this machine's repo configuration (would have required upgrading libc6 system-wide, so it was not forced through). No QMX hardware was available to test TX/RX against either. All three are red flags a reviewer with access to any of them should check before merge -- CI (real x86_64 runners) is this PR's actual first verification. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf --- ft8af/app/src/main/assets/rigaddress.txt | 1 + .../ft8af/database/RigAddressAssetTest.java | 20 ++++++++++++++++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/ft8af/app/src/main/assets/rigaddress.txt b/ft8af/app/src/main/assets/rigaddress.txt index a1a09823f..811495d15 100644 --- a/ft8af/app/src/main/assets/rigaddress.txt +++ b/ft8af/app/src/main/assets/rigaddress.txt @@ -75,3 +75,4 @@ UA3REO Wolf SDR(USB),00,4800,16 (tr)uSDX (TS-480),00,9600,7 Yaesu FT-891 (Hamlib),40C,38400,26 ICOM IC-7300MK2,B6,115200,0 +Qrp Labs QMX (Hamlib),809,9600,26 diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/database/RigAddressAssetTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/database/RigAddressAssetTest.java index 58210ff36..4678b85a5 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/database/RigAddressAssetTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/database/RigAddressAssetTest.java @@ -114,6 +114,7 @@ public class RigAddressAssetTest { "(tr)uSDX (TS-480)", "Yaesu FT-891 (Hamlib)", "ICOM IC-7300MK2", + "Qrp Labs QMX (Hamlib)", }; private RigNameList load() { @@ -158,11 +159,24 @@ public void rigList_matchesGoldenIndexOrder() { } @Test - public void ic7300mk2_isTheLastEntry() { - // New rigs are append-only (class javadoc); MK2 closed the list. + public void qmxHamlib_isTheLastEntry() { + // New rigs are append-only (class javadoc); QMX (Hamlib) closed the list. RigNameList list = load(); assertThat(list.rigList.get(list.rigList.size() - 1).modelName) - .isEqualTo("ICOM IC-7300MK2"); + .isEqualTo("Qrp Labs QMX (Hamlib)"); + } + + @Test + public void qmxHamlib_isListedWithHamlibModelId() { + // hamlib's RIG_MODEL_QRPLABS_QMX = RIG_MAKE_MODEL(RIG_KENWOOD, 57) = + // 1000*2+57 = 2057 = 0x809 (see rigs/kenwood/ts480.c's qrplabs_qmx_caps, + // MAX_MODELS_PER_BACKEND=1000 and RIG_KENWOOD=2 in riglist.h). Baud 9600 + // is that backend's declared serial_rate_min. + RigNameList.RigName qmx = findByName(load(), "Qrp Labs QMX (Hamlib)"); + assertThat(qmx).isNotNull(); + assertThat(qmx.address).isEqualTo(0x809); + assertThat(qmx.bauRate).isEqualTo(9600); + assertThat(qmx.instructionSet).isEqualTo(26); // InstructionSet.HAMLIB } @Test From 04217393fdcd73fc8be04645b5c7f156e17996d9 Mon Sep 17 00:00:00 2001 From: quadra Date: Wed, 21 Oct 2026 02:36:20 +0000 Subject: [PATCH 02/36] Add diagnostic log when HamlibRig's sink drops bytes on null connector Real-hardware testing on PR #925 found that FA; (frequency) commands from the Hamlib QMX backend never reach the radio after the initial rig_open() handshake, despite hamlib logging the write and timing out waiting for an ack. The sink callback that forwards hamlib's CAT bytes to the real serial connector silently returns when getConnector() is null, with no log anywhere -- making this exact failure mode undiagnosable from the app's own logs. This adds a temporary warning log so the next real-hardware test can confirm or rule out this path directly. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf --- ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java index ae1f358ab..47c410632 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java @@ -47,6 +47,13 @@ public class HamlibRig extends BaseRig { BaseRigConnector c = getConnector(); if (c != null) { c.sendData(data); + } else { + // DIAGNOSTIC (temporary): hamlib's CAT bytes are silently dropped + // here with no trace anywhere else in the app or native logs when + // the connector is null — suspected cause of commands beyond the + // initial rig_open() handshake (e.g. FA;) never reaching the radio. + Log.w(TAG, "sink.onBytesToRig: dropping " + data.length + + " byte(s), getConnector() is null"); } }; From 641baeeaace9482cc495fa2f49131eaac7c25239 Mon Sep 17 00:00:00 2001 From: quadra Date: Wed, 21 Oct 2026 03:06:18 +0000 Subject: [PATCH 03/36] Add diagnostic logging on the serial receive path and pump thread read Ruled out the null-connector theory from the previous diagnostic commit: hamlib's ID; probe does reach the real serial port, but FA; (sent moments later in the same rig_open() call) never does, despite hamlib's own write_block() logging a successful write in both cases. CableSerialPort had zero logging on the receive path, so "the radio never replies" was never actually verified either. This adds: - CableSerialPort.onNewData: logs every byte actually read off the wire (serial.recv[...]), before any rig-specific listener sees it. - hamlib_jni.cpp's pump thread: logs exactly what it reads from hamlib's loopback socket on every read(), to confirm whether FA; ever reaches the pump thread at all. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf --- ft8af/app/src/main/cpp/ft8af_glue/hamlib_jni.cpp | 10 ++++++++++ .../java/com/k1af/ft8af/connector/CableSerialPort.java | 8 ++++++++ 2 files changed, 18 insertions(+) diff --git a/ft8af/app/src/main/cpp/ft8af_glue/hamlib_jni.cpp b/ft8af/app/src/main/cpp/ft8af_glue/hamlib_jni.cpp index 00843b216..98ea8f2b7 100644 --- a/ft8af/app/src/main/cpp/ft8af_glue/hamlib_jni.cpp +++ b/ft8af/app/src/main/cpp/ft8af_glue/hamlib_jni.cpp @@ -78,6 +78,16 @@ static void *pump_main(void *arg) { while (b->running) { ssize_t n = read(b->srv_fd, buf, sizeof(buf)); if (n > 0) { + // DIAGNOSTIC (temporary): confirm exactly what the pump thread + // reads from hamlib's loopback socket, and that it reads it at + // all — suspected gap between hamlib's successful write_block() + // and CAT bytes (e.g. "FA;") reaching the real serial port. + char hex[512 * 3 + 1]; + int hoff = 0; + for (ssize_t i = 0; i < n && hoff < (int) sizeof(hex) - 4; ++i) { + hoff += snprintf(hex + hoff, sizeof(hex) - hoff, "%02X ", buf[i]); + } + LOGI("pump: read %zd bytes from srv_fd: %s", n, hex); jbyteArray arr = env->NewByteArray((jsize) n); if (arr == nullptr) { break; } env->SetByteArrayRegion(arr, 0, (jsize) n, reinterpret_cast(buf)); diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/connector/CableSerialPort.java b/ft8af/app/src/main/java/com/k1af/ft8af/connector/CableSerialPort.java index ea93baba3..6f1de1c9f 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/connector/CableSerialPort.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/connector/CableSerialPort.java @@ -380,6 +380,14 @@ public boolean connect() { usbIoManager = new SerialInputOutputManager(usbSerialPort, new SerialInputOutputManager.Listener() { @Override public void onNewData(byte[] data) { + // DIAGNOSTIC (temporary): CableSerialPort had no logging at + // all on the receive path, so "the radio never replied" was + // never actually verified -- it could equally be a relay bug + // further downstream. This logs every byte actually read off + // the wire, before it reaches any rig-specific listener. + StringBuilder hex = new StringBuilder(); + for (byte x : data) hex.append(String.format("%02X ", x)); + fileLog("serial.recv[" + data.length + "]: hex: " + hex.toString().trim()); if (ioListener != null) { ioListener.onNewData(data); } From e046e0a792262e1b404d53c1388b84e9a36f1339 Mon Sep 17 00:00:00 2001 From: quadra Date: Wed, 21 Oct 2026 03:19:09 +0000 Subject: [PATCH 04/36] Send QMX's vendor-specific Q41; after mode-set and after every freq change Real-hardware testing on PR #925 found the QMX keeps falling back from Digital to USB mode under the Hamlib backend, even though setUsbModeToRig() correctly asks hamlib for PKTUSB. hamlib's QMX backend (qrplabs_qmx_caps) uses the generic kenwood_set_mode(), which only sends MD6; -- it has no knowledge of Q41;, the QRP Labs-specific follow-up this exact radio needs to actually commit to and stay in Digital mode. This is the same gap Dan Lee's native app previously had to hand-patch for the same radio (see project history). Testing also showed the mode reverts after any frequency change alone (FA...;), not just at mode-set time, so Q41; is re-sent after setFreqToRig() too, scoped to the QMX's model ID only so it never reaches any other Hamlib-backed rig (currently just the FT-891 entry). Not unit-testable: this path is gated behind ensureOpen(), which HamlibRigTest's own doc comment already notes always returns false in a JVM unit test (no native lib) -- same as every other hamlib call in this class. Needs real-hardware re-verification, same as the rest of this PR's hamlib-backed behavior. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf --- .../java/com/k1af/ft8af/rigs/HamlibRig.java | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java index 47c410632..6f6a69de1 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java @@ -30,6 +30,19 @@ public class HamlibRig extends BaseRig { /** hamlib mode for FT8/FT4: DATA-USB, so audio routes through the data path. */ private static final String FT_MODE = "PKTUSB"; + /** + * hamlib's RIG_MODEL_QRPLABS_QMX (RIG_MAKE_MODEL(RIG_KENWOOD, 57) = 2057). + * hamlib's generic {@code kenwood_set_mode} has no knowledge of the QMX's + * own vendor-specific {@code Q41;} command, which the radio needs after + * {@code MD6;} (and again after any {@code FA...;} frequency change, which + * otherwise silently reverts the radio from Digital back to USB) to + * actually commit to and stay in Digital/Data mode — the same gap Dan + * Lee's native app had to hand-patch for the same radio. hamlib's own + * mode-setting is otherwise correct for every other Hamlib-backed rig in + * this list, so this follow-up is scoped to this one model only. + */ + private static final int QRPLABS_QMX_MODEL_ID = 2057; + private final int modelId; private final ExecutorService worker = Executors.newSingleThreadExecutor(); private volatile long handle = 0; @@ -61,6 +74,22 @@ public HamlibRig(int modelId) { this.modelId = modelId; } + /** + * Send a raw CAT command straight to the connector, bypassing hamlib + * entirely. Only for vendor-specific strings hamlib has no concept of + * (like QMX's {@code Q41;}) — anything hamlib understands should go + * through the normal {@code nativeSet*} calls so hamlib's own internal + * rig-state tracking stays correct. + */ + private void sendRawCat(String cmd) { + BaseRigConnector c = getConnector(); + if (c != null) { + c.sendData(cmd.getBytes(java.nio.charset.StandardCharsets.US_ASCII)); + } else { + Log.w(TAG, "sendRawCat(" + cmd + "): dropping, getConnector() is null"); + } + } + /** * Open hamlib once the transport is connected. hamlib's {@code rig_open} * performs an initial CAT handshake, so the connector must be live and @@ -147,6 +176,9 @@ public void setUsbModeToRig() { if (!ensureOpen()) return; int rc = HamlibNative.nativeSetMode(handle, FT_MODE); if (rc != 0) Log.w(TAG, "set_mode(" + FT_MODE + ") rc=" + rc); + if (modelId == QRPLABS_QMX_MODEL_ID) { + sendRawCat("Q41;"); + } }); } @@ -160,6 +192,12 @@ public void setFreqToRig() { if (!ensureOpen()) return; int rc = HamlibNative.nativeSetFreq(handle, hz); if (rc != 0) Log.w(TAG, "set_freq(" + hz + ") rc=" + rc); + if (modelId == QRPLABS_QMX_MODEL_ID) { + // FA...; alone silently reverts the QMX from Digital back to + // USB (observed on real hardware) — re-assert Q41; every time + // frequency changes, not just once at mode-set time. + sendRawCat("Q41;"); + } }); } From 37897125218ac1a50e589a8f64bc0082283a2d8b Mon Sep 17 00:00:00 2001 From: quadra Date: Wed, 21 Oct 2026 03:28:10 +0000 Subject: [PATCH 05/36] Also send Q41; after readFreqFromRig(), not just setFreqToRig() Real-hardware testing of the previous Q41; fix found the QMX still reverts from Digital to USB after a frequency change, despite setFreqToRig() now re-sending Q41;. rig_get_freq() sends the same FA; command as a query (no parameter) -- if that alone also triggers the reversion, and the app polls frequency periodically (CAT-liveness/dial sync), mode would keep reverting on every poll regardless of the set-path fix. Mirrors the same Q41; follow-up onto the read path. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf --- .../app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java index 6f6a69de1..e06d6a844 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java @@ -209,6 +209,14 @@ public void readFreqFromRig() { if (f > 0) { setFreq(f); // updates dial + fires CAT-liveness (BaseRig) } + if (modelId == QRPLABS_QMX_MODEL_ID) { + // rig_get_freq() sends the same FA; command as a query (no + // parameter) — observed on real hardware to revert the QMX + // from Digital to USB the same as a frequency set does, and + // this is very likely called on a periodic poll, so it needs + // the same Q41; follow-up or mode keeps reverting repeatedly. + sendRawCat("Q41;"); + } }); } From 9b5e87871b182a4765827f3fe039f24c0a2c31ff Mon Sep 17 00:00:00 2001 From: quadra Date: Wed, 21 Oct 2026 03:51:28 +0000 Subject: [PATCH 06/36] Revert incorrect Q41; fix; use the mode string that actually produces MD6; The Q41; fix (e046e0a7, 37897125) was based on a wrong premise. Checked the official QMX CAT manual (qrp-labs.com/images/qmx/manuals/ cat_1_04_003.pdf): there is no Q41 command. The Q-series commands are Q0-QB; Q4 is "Get/Set TX Rise Threshold" (0-99%, default 80), so "Q41;" was actually setting TX Rise Threshold to 1% on every mode-set, freq-set, and freq-read -- an unrelated, actively harmful side effect, not a mode fix. Root cause, verified against hamlib's vendored source: the QMX backend (qrplabs_qmx_caps) sets no mode_table override, so kenwood_set_mode() falls back to the generic kenwood_mode_table, where RIG_MODE_PKTUSB is table index 13. kenwood_set_mode() encodes any index > 9 as a letter ('A' + index - 10), so PKTUSB becomes "MDD;" -- not a valid QMX mode character. The manual only documents single-digit MD values (1, 2, 3, 5, 6, 7, 8, 9), so "MDD;" is silently rejected; setUsbModeToRig() was never actually setting Digital mode via this path at all. Table index 6 holds RIG_MODE_RTTY (commented "// FSK Mode" in hamlib's own source) -- exactly the QMX manual's own mode 6, "FSK", its real digital mode. hamlib's string table maps "RTTY" to RIG_MODE_RTTY directly, so requesting that mode produces the valid "MD6;" through hamlib's normal, standard code path -- no raw command bypass needed. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf --- .../java/com/k1af/ft8af/rigs/HamlibRig.java | 58 ++++--------------- 1 file changed, 12 insertions(+), 46 deletions(-) diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java index e06d6a844..3e80667f6 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java @@ -27,21 +27,20 @@ public class HamlibRig extends BaseRig { private static final String TAG = "HamlibRig"; - /** hamlib mode for FT8/FT4: DATA-USB, so audio routes through the data path. */ - private static final String FT_MODE = "PKTUSB"; - /** - * hamlib's RIG_MODEL_QRPLABS_QMX (RIG_MAKE_MODEL(RIG_KENWOOD, 57) = 2057). - * hamlib's generic {@code kenwood_set_mode} has no knowledge of the QMX's - * own vendor-specific {@code Q41;} command, which the radio needs after - * {@code MD6;} (and again after any {@code FA...;} frequency change, which - * otherwise silently reverts the radio from Digital back to USB) to - * actually commit to and stay in Digital/Data mode — the same gap Dan - * Lee's native app had to hand-patch for the same radio. hamlib's own - * mode-setting is otherwise correct for every other Hamlib-backed rig in - * this list, so this follow-up is scoped to this one model only. + * hamlib mode string for FT8/FT4. NOT "PKTUSB": hamlib's generic Kenwood + * mode table (rigs/kenwood/kenwood.c's kenwood_mode_table, which the QMX + * backend falls back to since it sets no override) maps RIG_MODE_PKTUSB + * to table index 13, which kenwood_set_mode() then encodes as the ASCII + * character 'D' ('A' + 13 - 10) -- producing "MDD;". The QMX's own CAT + * manual (qrp-labs.com/images/qmx/manuals/cat_1_04_003.pdf) only accepts + * single-digit MD values (1,2,3,5,6,7,8,9); 'D' is not one of them and is + * silently rejected, so the radio's mode was never actually being set at + * all via this path. Table index 6 holds RIG_MODE_RTTY ("FSK Mode" per + * that table's own comment) -- exactly the QMX manual's own "6 (FSK)", + * its real digital mode. "RTTY" therefore produces the valid "MD6;". */ - private static final int QRPLABS_QMX_MODEL_ID = 2057; + private static final String FT_MODE = "RTTY"; private final int modelId; private final ExecutorService worker = Executors.newSingleThreadExecutor(); @@ -74,22 +73,6 @@ public HamlibRig(int modelId) { this.modelId = modelId; } - /** - * Send a raw CAT command straight to the connector, bypassing hamlib - * entirely. Only for vendor-specific strings hamlib has no concept of - * (like QMX's {@code Q41;}) — anything hamlib understands should go - * through the normal {@code nativeSet*} calls so hamlib's own internal - * rig-state tracking stays correct. - */ - private void sendRawCat(String cmd) { - BaseRigConnector c = getConnector(); - if (c != null) { - c.sendData(cmd.getBytes(java.nio.charset.StandardCharsets.US_ASCII)); - } else { - Log.w(TAG, "sendRawCat(" + cmd + "): dropping, getConnector() is null"); - } - } - /** * Open hamlib once the transport is connected. hamlib's {@code rig_open} * performs an initial CAT handshake, so the connector must be live and @@ -176,9 +159,6 @@ public void setUsbModeToRig() { if (!ensureOpen()) return; int rc = HamlibNative.nativeSetMode(handle, FT_MODE); if (rc != 0) Log.w(TAG, "set_mode(" + FT_MODE + ") rc=" + rc); - if (modelId == QRPLABS_QMX_MODEL_ID) { - sendRawCat("Q41;"); - } }); } @@ -192,12 +172,6 @@ public void setFreqToRig() { if (!ensureOpen()) return; int rc = HamlibNative.nativeSetFreq(handle, hz); if (rc != 0) Log.w(TAG, "set_freq(" + hz + ") rc=" + rc); - if (modelId == QRPLABS_QMX_MODEL_ID) { - // FA...; alone silently reverts the QMX from Digital back to - // USB (observed on real hardware) — re-assert Q41; every time - // frequency changes, not just once at mode-set time. - sendRawCat("Q41;"); - } }); } @@ -209,14 +183,6 @@ public void readFreqFromRig() { if (f > 0) { setFreq(f); // updates dial + fires CAT-liveness (BaseRig) } - if (modelId == QRPLABS_QMX_MODEL_ID) { - // rig_get_freq() sends the same FA; command as a query (no - // parameter) — observed on real hardware to revert the QMX - // from Digital to USB the same as a frequency set does, and - // this is very likely called on a periodic poll, so it needs - // the same Q41; follow-up or mode keeps reverting repeatedly. - sendRawCat("Q41;"); - } }); } From fde49bfecf231c57158936286a6ce5cf4ea205f9 Mon Sep 17 00:00:00 2001 From: quadra Date: Wed, 21 Oct 2026 04:07:08 +0000 Subject: [PATCH 07/36] Add diagnostic logging on rig selection and HamlibRig construction Real-hardware testing keeps showing "hamlib model 9" open instead of 2057 when "Qrp Labs QMX (Hamlib)" is selected, even right after a fresh install. Traced every writeConfig("civ", ...) call site and found none of them apply to a Hamlib-backed rig selection -- the only writers are the ICOM-gated repair path (repairCivAddressAgainstModel, which explicitly returns early for any non-ICOM instructionSet) and the manual hex-edit field. So the earlier civAddress/CivAddressConfig byte-masking theory doesn't hold up on inspection and needs direct verification rather than more static reading. Logs the exact rigName/address applied in setAddrAndBauRate() (rig selection) and the exact civAddress value used to construct HamlibRig in connectRig(), to see empirically where 2057 becomes 9. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf --- ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java | 4 ++++ .../app/src/main/java/com/k1af/ft8af/ui/ConfigFragment.java | 6 ++++++ 2 files changed, 10 insertions(+) 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 282ae7b0a..1ab5cdd59 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java @@ -2439,6 +2439,10 @@ private void connectRig() { case InstructionSet.HAMLIB: // hamlib model number is carried in the rig table's address column // (parsed base-16), e.g. FT-891 = 1036 = 0x40C. + // DIAGNOSTIC (temporary): confirm the exact value actually used to + // construct HamlibRig at the moment of connection. + GeneralVariables.fileLog("connectRig: constructing HamlibRig(civAddress=" + + GeneralVariables.civAddress + ")"); baseRig = new HamlibRig(GeneralVariables.civAddress); break; } diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/ui/ConfigFragment.java b/ft8af/app/src/main/java/com/k1af/ft8af/ui/ConfigFragment.java index c428eafca..6e35cf779 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/ui/ConfigFragment.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/ui/ConfigFragment.java @@ -961,6 +961,12 @@ public void onNothingSelected(AdapterView adapterView) { * @param rigName Rig model */ private void setAddrAndBauRate(RigNameList.RigName rigName) { + // DIAGNOSTIC (temporary): confirm exactly which rig/address actually + // gets applied when a rig is selected -- suspected mismatch between + // what the UI shows selected and what civAddress ends up holding by + // the time connectRig() constructs the rig. + GeneralVariables.fileLog("setAddrAndBauRate: rigName=" + rigName.modelName + + " address=" + rigName.address + " (0x" + Integer.toHexString(rigName.address) + ")"); GeneralVariables.civAddress = rigName.address; mainViewModel.setCivAddress(); GeneralVariables.baudRate = rigName.bauRate; From 47c8fc4ed425b435a0b878989fb97a84a9333345 Mon Sep 17 00:00:00 2001 From: quadra Date: Wed, 21 Oct 2026 04:17:41 +0000 Subject: [PATCH 08/36] Fix hamlib model ID truncation: persist it separately from civAddress Root-caused via real hardware testing + diagnostic logging: selecting "Qrp Labs QMX (Hamlib)" always opened hamlib model 9 instead of 2057 after any app restart. RadioAudioSettings.kt (the active Compose rig picker) persists civAddress through CivAddressConfig.encode(), which masks with "& 0xFF" since a real ICOM CI-V address is always a byte -- 2057 (0x809) round-trips through that as 9. Confirmed on-device: connectRig() logged "constructing HamlibRig(civAddress=9)", and hamlib model 9 (SDR#) opened, sending its own unrelated handshake bytes that put the QMX into its firmware debug terminal menu instead of CAT mode. Fix: a new GeneralVariables.hamlibModelId field and "hamlibModel" config key, written as a plain unmasked decimal string and loaded the same way, used instead of civAddress to construct HamlibRig in MainViewModel.connectRig(). civAddress and its existing byte-range persistence/repair logic (issue #753) are untouched -- that contract is relied on elsewhere for genuine ICOM addresses and didn't need widening. Updated both the active picker (RadioAudioSettings.kt) and the legacy one (ConfigFragment.setAddrAndBauRate) to keep the new field in sync; added hydration tests covering the fix and a malformed value. This also explains several earlier rounds of real-hardware testing on this PR that looked like CAT/mode bugs in the Hamlib backend itself (including the reverted Q41; mistake) -- they were actually testing the wrong hamlib backend (SDR#) the whole time, not QMX at all. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf --- .../java/com/k1af/ft8af/GeneralVariables.java | 10 ++++++ .../java/com/k1af/ft8af/MainViewModel.java | 11 +++--- .../com/k1af/ft8af/database/DatabaseOpr.java | 5 +++ .../com/k1af/ft8af/ui/ConfigFragment.java | 10 +++--- .../ft8af/ui/settings/RadioAudioSettings.kt | 7 ++++ .../DatabaseOprConfigHydrationTest.java | 35 +++++++++++++++++++ 6 files changed, 66 insertions(+), 12 deletions(-) diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java b/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java index d0ee31b75..b4da63852 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java @@ -545,6 +545,16 @@ public static Context getMainContext() { public static int iaruRegion = 2;//Operator's IARU region (1/2/3), used to gate Message-Creator QSY frequency options to legal band edges. Default 2 (the Americas). See com.k1af.ft8af.message.SpecialMessage. public static boolean autoCQAfterQSO = false;//Auto-CQ: keep calling CQ after each completed QSO (chain without re-tapping). Refreshes the TX watchdog per QSO and forces pure CQ (ignores Hunt). public static int civAddress = 0xa4;//CI-V address + /** + * hamlib model ID for Hamlib-backed rigs (instructionSet == HAMLIB), e.g. + * 2057 for Qrp Labs QMX. Deliberately separate from {@link #civAddress}: + * that field (and its {@code CivAddressConfig} persistence) is a genuine + * single-byte ICOM CI-V address (0x00-0xFF); a hamlib model ID routinely + * exceeds a byte (2057 = 0x809), and persisting it through civAddress's + * byte-masked encode/decode silently truncated it (2057 -> 9, opening the + * wrong hamlib backend entirely on the next app launch). + */ + public static int hamlibModelId = 0;//hamlib model ID, see rigaddress.txt's address column for HAMLIB rigs public static String civAddressStored = null;//Raw "civ" config text as hydrated (null = no row); lets the #753 repair see whether the on-disk form is canonical hex. public static boolean civAddressFormatKnown = false;//True when config "civFormat=hex" was present: the stored civ value is trusted verbatim, no model reconciliation (#753). public static int baudRate = 19200;//Baud rate 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 1ab5cdd59..c50d3d6e1 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java @@ -2438,12 +2438,11 @@ private void connectRig() { break; case InstructionSet.HAMLIB: // hamlib model number is carried in the rig table's address column - // (parsed base-16), e.g. FT-891 = 1036 = 0x40C. - // DIAGNOSTIC (temporary): confirm the exact value actually used to - // construct HamlibRig at the moment of connection. - GeneralVariables.fileLog("connectRig: constructing HamlibRig(civAddress=" - + GeneralVariables.civAddress + ")"); - baseRig = new HamlibRig(GeneralVariables.civAddress); + // (parsed base-16), e.g. FT-891 = 1036 = 0x40C. Uses hamlibModelId, + // not civAddress -- civAddress's persistence is byte-masked (a real + // ICOM CI-V address is a byte; a hamlib model ID routinely isn't), + // which silently truncated it (2057 -> 9) on every app restart. + baseRig = new HamlibRig(GeneralVariables.hamlibModelId); break; } diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java index 6f69c32ae..655b08a0a 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java @@ -2772,6 +2772,11 @@ protected Void doInBackground(Void... voids) { .decode(result, com.k1af.ft8af.rigs.CivAddressConfig.DEFAULT_ADDRESS); GeneralVariables.civAddressStored = result; } + if (name.equalsIgnoreCase("hamlibModel")) { + // Plain decimal, not hex, and not byte-masked -- see civAddress's + // javadoc in GeneralVariables for why this needs its own key. + GeneralVariables.hamlibModelId = parseConfigInt(result, 0); + } if (name.equalsIgnoreCase(com.k1af.ft8af.rigs.CivAddressConfig.FORMAT_KEY)) { // Provenance marker: present only when a hex-aware writer stored "civ". GeneralVariables.civAddressFormatKnown = diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/ui/ConfigFragment.java b/ft8af/app/src/main/java/com/k1af/ft8af/ui/ConfigFragment.java index 6e35cf779..300d39f60 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/ui/ConfigFragment.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/ui/ConfigFragment.java @@ -961,13 +961,11 @@ public void onNothingSelected(AdapterView adapterView) { * @param rigName Rig model */ private void setAddrAndBauRate(RigNameList.RigName rigName) { - // DIAGNOSTIC (temporary): confirm exactly which rig/address actually - // gets applied when a rig is selected -- suspected mismatch between - // what the UI shows selected and what civAddress ends up holding by - // the time connectRig() constructs the rig. - GeneralVariables.fileLog("setAddrAndBauRate: rigName=" + rigName.modelName - + " address=" + rigName.address + " (0x" + Integer.toHexString(rigName.address) + ")"); GeneralVariables.civAddress = rigName.address; + // Kept in sync with civAddress but persisted separately (RadioAudioSettings.kt + // writes its own "hamlibModel" key) -- civAddress's own persistence is + // byte-masked and truncates hamlib model IDs like the QMX's 2057. + GeneralVariables.hamlibModelId = rigName.address; mainViewModel.setCivAddress(); GeneralVariables.baudRate = rigName.bauRate; binding.civAddressEdit.setText(String.format("%X", rigName.address)); diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/RadioAudioSettings.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/RadioAudioSettings.kt index e8de0b4c6..2eec94c28 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/RadioAudioSettings.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/RadioAudioSettings.kt @@ -212,6 +212,7 @@ fun RadioAudioSettings( modelNo = actualIndex GeneralVariables.instructionSet = selectedRig.instructionSet GeneralVariables.civAddress = selectedRig.address + GeneralVariables.hamlibModelId = selectedRig.address GeneralVariables.baudRate = selectedRig.bauRate baudRate = selectedRig.bauRate mainViewModel.setCivAddress() @@ -233,6 +234,12 @@ fun RadioAudioSettings( ) GeneralVariables.civAddressStored = encodedCiv GeneralVariables.civAddressFormatKnown = true + // Separate key, plain decimal, no byte masking: a hamlib model ID (e.g. + // 2057 for the QMX) routinely exceeds a byte, unlike a real CI-V address, + // so it cannot round-trip through CivAddressConfig.encode()'s "& 0xFF". + mainViewModel.databaseOpr.writeConfig( + "hamlibModel", GeneralVariables.hamlibModelId.toString(), null, + ) }, ) } diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/database/DatabaseOprConfigHydrationTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/database/DatabaseOprConfigHydrationTest.java index d9c94f4e7..9918c09c0 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/database/DatabaseOprConfigHydrationTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/database/DatabaseOprConfigHydrationTest.java @@ -57,6 +57,7 @@ public class DatabaseOprConfigHydrationTest { private boolean origDeepDecodeMode; private boolean origKeepScreenOn; private boolean origAutoSyncClockFromDecodes; + private int origHamlibModelId; @Before public void setUp() { @@ -77,6 +78,7 @@ public void setUp() { origDeepDecodeMode = GeneralVariables.deepDecodeMode; origKeepScreenOn = GeneralVariables.keepScreenOn; origAutoSyncClockFromDecodes = GeneralVariables.autoSyncClockFromDecodes; + origHamlibModelId = GeneralVariables.hamlibModelId; opr = new DatabaseOpr(ApplicationProvider.getApplicationContext(), null, null, 18); } @@ -103,6 +105,7 @@ public void tearDown() { GeneralVariables.deepDecodeMode = origDeepDecodeMode; GeneralVariables.keepScreenOn = origKeepScreenOn; GeneralVariables.autoSyncClockFromDecodes = origAutoSyncClockFromDecodes; + GeneralVariables.hamlibModelId = origHamlibModelId; } } @@ -456,4 +459,36 @@ public void civFormatMarker_absentOrForeign_isNotTrusted() { assertThat(GeneralVariables.civAddressFormatKnown).isFalse(); } + + /** + * Real-hardware testing on PR #925 found that persisting a hamlib model ID (e.g. the + * QMX's 2057) through {@code civ} silently truncated it to 9 on the next app launch + * ({@code CivAddressConfig.encode()} masks with {@code & 0xFF}, since a real CI-V + * address is always a byte) -- opening an unrelated hamlib backend (model 9) instead of + * the QMX. {@code hamlibModel} is a separate, plain-decimal, unmasked key specifically + * so values over a byte round-trip correctly. + */ + @Test + public void hamlibModelId_hydratesFullValue_unlikeByteMaskedCiv() { + GeneralVariables.hamlibModelId = 0; + Map config = new LinkedHashMap<>(); + config.put("hamlibModel", "2057"); // QRP Labs QMX + opr.writeConfigSync(config); + + hydrate(); + + assertThat(GeneralVariables.hamlibModelId).isEqualTo(2057); + } + + @Test + public void hamlibModelId_malformedValue_fallsBackToZero() { + GeneralVariables.hamlibModelId = 2057; + Map config = new LinkedHashMap<>(); + config.put("hamlibModel", "not-a-number"); + opr.writeConfigSync(config); + + hydrate(); // must not throw + + assertThat(GeneralVariables.hamlibModelId).isEqualTo(0); + } } From 4a76f2af0a7d9be7696795fee814b48e407e2d01 Mon Sep 17 00:00:00 2001 From: quadra Date: Wed, 21 Oct 2026 04:26:32 +0000 Subject: [PATCH 09/36] Fix crash: hamlibModelId=0 (migration gap) aborted the whole process The previous fix (47c8fc4e) left a real gap: an install that had already selected a Hamlib rig before "hamlibModel" existed loads hamlibModelId=0 on first launch after updating. Confirmed on real hardware: HamlibNative.nativeOpen(0, ...) -> hamlib's rig_init(0) fails, and ~0.5s later the whole process aborts with a native SIGABRT ("pthread_mutex_lock called on a destroyed mutex") -- not a graceful failure, a hard crash loop on every launch. Two fixes: 1. HamlibRig.ensureOpen() now refuses to call nativeOpen at all when modelId <= 0 -- a bad model ID can never reach native code again, regardless of how it got that way. 2. connectRig() self-heals a <= 0 hamlibModelId from the currently selected rig's own rigaddress.txt entry (by modelNo) and persists it, so an existing install recovers on its next connect instead of needing the user to manually re-select the rig. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf --- .../java/com/k1af/ft8af/MainViewModel.java | 20 +++++++++++++++++++ .../java/com/k1af/ft8af/rigs/HamlibRig.java | 10 ++++++++++ 2 files changed, 30 insertions(+) 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 c50d3d6e1..3b9328dd3 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java @@ -2442,6 +2442,26 @@ private void connectRig() { // not civAddress -- civAddress's persistence is byte-masked (a real // ICOM CI-V address is a byte; a hamlib model ID routinely isn't), // which silently truncated it (2057 -> 9) on every app restart. + if (GeneralVariables.hamlibModelId <= 0) { + // One-time migration for an install that selected a Hamlib rig + // before the "hamlibModel" config key existed (or any other way + // it ended up unset) -- self-heal from the still-correct selected + // rig entry rather than leaving it at the unset default, which + // HamlibRig.ensureOpen() now refuses to open at all (a real + // model-0 hand-off previously aborted the whole process). + android.content.Context ctx = GeneralVariables.getMainContext(); + if (ctx != null) { + RigNameList.RigName model = RigNameList.getInstance(ctx) + .getRigNameByIndex(GeneralVariables.modelNo); + if (model != null && model.address > 0) { + GeneralVariables.hamlibModelId = model.address; + if (databaseOpr != null) { + databaseOpr.writeConfig("hamlibModel", + String.valueOf(model.address), null); + } + } + } + } baseRig = new HamlibRig(GeneralVariables.hamlibModelId); break; } diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java index 3e80667f6..8f7cbcb42 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java @@ -94,6 +94,16 @@ private synchronized boolean ensureOpen() { if (c == null || !c.isConnected() || !HamlibNative.isAvailable()) { return false; } + if (modelId <= 0) { + // Never hand hamlib a placeholder/unset model ID: rig_init(0) was + // observed on real hardware to abort the whole process (native + // SIGABRT, "pthread_mutex_lock called on a destroyed mutex") a + // fraction of a second later, not just fail gracefully. 0 means + // "not yet persisted" (see GeneralVariables.hamlibModelId) and must + // never reach native code. + Log.w(TAG, "ensureOpen: refusing to open invalid hamlib model " + modelId); + return false; + } long h = HamlibNative.nativeOpen(modelId, sink); if (h == 0) { Log.w(TAG, "nativeOpen failed for hamlib model " + modelId); From d78aebe9014031004f53bc2ddc20b3289db6b69e Mon Sep 17 00:00:00 2001 From: quadra Date: Wed, 21 Oct 2026 04:38:25 +0000 Subject: [PATCH 10/36] Fix: publish the handle before rig_open()'s handshake, not after Found via real-hardware testing: every single Hamlib CAT failure in this whole PR's testing reported "0 chars" from the radio, even when serial.recv showed real traffic arriving in the same window. Root cause: HamlibRig.onReceiveData() only forwards radio bytes into the native bridge when handle != 0, but the original single nativeOpen() call set `handle` only *after* it returned -- and nativeOpen() itself ran rig_open() internally, including its own synchronous ID;/FA; handshake, before returning. So every byte the radio sent back during that handshake was silently dropped on the Java side, and hamlib always timed out waiting for an ack that could never reach it, even against a radio that was genuinely replying. Split the single native call into nativeCreate() (builds the bridge, starts the pump thread, returns a handle -- does NOT call rig_open()) and nativeRigOpen(handle) (runs rig_open() on an already-created bridge). HamlibRig.ensureOpen() now publishes `handle` between the two calls, so nativeFeedFromRig can deliver the radio's handshake replies in real time, matching the threading model this file's own top comment already documented (the serial read thread feeding acks while the worker thread blocks in a synchronous rig_* call) -- the original combined nativeOpen() just violated it for its own internal rig_open(). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf --- .../src/main/cpp/ft8af_glue/hamlib_jni.cpp | 53 +++++++++++++++---- .../java/com/k1af/ft8af/rigs/HamlibRig.java | 20 ++++++- .../com/k1af/ft8af/wave/HamlibNative.java | 22 +++++++- 3 files changed, 81 insertions(+), 14 deletions(-) diff --git a/ft8af/app/src/main/cpp/ft8af_glue/hamlib_jni.cpp b/ft8af/app/src/main/cpp/ft8af_glue/hamlib_jni.cpp index 98ea8f2b7..8798beb0f 100644 --- a/ft8af/app/src/main/cpp/ft8af_glue/hamlib_jni.cpp +++ b/ft8af/app/src/main/cpp/ft8af_glue/hamlib_jni.cpp @@ -64,6 +64,7 @@ struct HamlibBridge { std::atomic running{false}; jobject sink = nullptr; // global ref to HamlibNative.HamlibSink jmethodID onBytesToRig = nullptr; + int model_id = 0; // for logging only; set in nativeCreate, read in nativeRigOpen }; // ---- pump thread: hamlib's outbound CAT bytes -> Java sink ------------------ @@ -128,12 +129,23 @@ static HamlibBridge *handle_to_bridge(jlong h) { extern "C" { /* - * nativeOpen: build the bridge, init+open the rig on the loopback socket. - * Returns an opaque handle (>0) or 0 on failure. + * nativeCreate: build the bridge and start the pump thread, but do NOT call + * rig_open() yet. Returns an opaque handle (>0) or 0 on failure. + * + * Split from the open call (see nativeRigOpen below) so Java can publish the + * handle to HamlibRig.handle BEFORE rig_open()'s own synchronous handshake + * runs. HamlibRig.onReceiveData() only forwards radio bytes into the bridge + * when handle != 0 — with the original single nativeOpen(), handle was only + * set after it returned, so every byte the radio sent during rig_open()'s own + * handshake (its ID;/FA; probes) was silently dropped on the Java side, and + * hamlib always saw 0 chars and timed out, even against a radio that was + * actually replying. Confirmed on real hardware: serial.recv showed real + * traffic arriving while hamlib's own trace reported "Timed out ... after 0 + * chars" for the exact same window, every single time. */ JNIEXPORT jlong JNICALL -Java_com_k1af_ft8af_wave_HamlibNative_nativeOpen(JNIEnv *env, jclass, - jint modelId, jobject sink) { +Java_com_k1af_ft8af_wave_HamlibNative_nativeCreate(JNIEnv *env, jclass, + jint modelId, jobject sink) { ensure_vm(env); auto *b = new HamlibBridge(); @@ -183,8 +195,29 @@ Java_com_k1af_ft8af_wave_HamlibNative_nativeOpen(JNIEnv *env, jclass, close(b->listen_fd); delete b; return 0; } - // 5. open (connects to our listener; initial CAT handshake flows over the - // bridge to the real radio via Java) + b->model_id = modelId; + LOGI("hamlib bridge for model %d created on 127.0.0.1:%d (not yet opened)", + modelId, port); + return reinterpret_cast(b); +} + +/* + * nativeRigOpen: run rig_open() on an already-created bridge (see + * nativeCreate above for why this is split out). Java must publish the + * handle to HamlibRig.handle before calling this, so nativeFeedFromRig can + * deliver bytes the radio sends during rig_open()'s own handshake. + * + * Returns RIG_OK (0) on success, or a (negative) hamlib error code. On + * failure the bridge is fully torn down and freed here, same as the + * original single-call nativeOpen's failure path — the handle must not be + * used again by the caller after a non-zero return. + */ +JNIEXPORT jint JNICALL +Java_com_k1af_ft8af_wave_HamlibNative_nativeRigOpen(JNIEnv *env, jclass, + jlong handle) { + auto *b = handle_to_bridge(handle); + if (b == nullptr) return -1; + int rc = rig_open(b->rig); if (rc != RIG_OK) { LOGE("rig_open failed: %d (%s)", rc, rigerror(rc)); @@ -203,10 +236,10 @@ Java_com_k1af_ft8af_wave_HamlibNative_nativeOpen(JNIEnv *env, jclass, sfd = b->srv_fd.load(); if (sfd >= 0) close(sfd); delete b; - return 0; + return rc; } - LOGI("hamlib rig %d opened via 127.0.0.1:%d", modelId, port); - return reinterpret_cast(b); + LOGI("hamlib rig %d opened", b->model_id); + return RIG_OK; } /* Feed bytes received from the real radio into hamlib's read side. */ @@ -283,7 +316,7 @@ Java_com_k1af_ft8af_wave_HamlibNative_nativeClose(JNIEnv *env, jclass, jlong han b->running = false; // Unblock the pump whether it is parked in accept() (never connected) or // read(): close the listener first, then shut down the accepted socket. - // Joining before this could deadlock. See nativeOpen's failure path. + // Joining before this could deadlock. See nativeRigOpen's failure path. int lfd = b->listen_fd.exchange(-1); if (lfd >= 0) { shutdown(lfd, SHUT_RDWR); close(lfd); } int sfd = b->srv_fd.load(); diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java index 8f7cbcb42..1639a5166 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java @@ -104,12 +104,28 @@ private synchronized boolean ensureOpen() { Log.w(TAG, "ensureOpen: refusing to open invalid hamlib model " + modelId); return false; } - long h = HamlibNative.nativeOpen(modelId, sink); + long h = HamlibNative.nativeCreate(modelId, sink); if (h == 0) { - Log.w(TAG, "nativeOpen failed for hamlib model " + modelId); + Log.w(TAG, "nativeCreate failed for hamlib model " + modelId); return false; } + // Publish the handle BEFORE running the handshake: onReceiveData() + // only forwards radio bytes into the bridge once handle != 0, and + // rig_open() (called by nativeRigOpen below) does its own synchronous + // CAT handshake that needs those bytes fed back in real time. With a + // single combined open call, every byte the radio sent during that + // handshake was silently dropped (handle was still 0), so hamlib + // always saw a timeout — confirmed on real hardware: the radio was + // genuinely replying (serial.recv had real traffic) while hamlib's + // own trace reported "0 chars" for that exact window, every time. handle = h; + int rc = HamlibNative.nativeRigOpen(h); + if (rc != 0) { + Log.w(TAG, "nativeRigOpen failed for hamlib model " + modelId + " rc=" + rc); + // nativeRigOpen already tore down and freed the bridge on failure. + handle = 0; + return false; + } Log.i(TAG, "hamlib model " + modelId + " opened"); return true; } diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/wave/HamlibNative.java b/ft8af/app/src/main/java/com/k1af/ft8af/wave/HamlibNative.java index 0097effab..eadc94383 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/wave/HamlibNative.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/wave/HamlibNative.java @@ -51,13 +51,31 @@ public interface HamlibSink { } /** - * Init + open a hamlib rig, wiring its transport to {@code sink}. + * Init a hamlib rig and its loopback-socket bridge, wiring its transport + * to {@code sink}, but do NOT perform the CAT handshake yet (see + * {@link #nativeRigOpen}). Split so the caller can publish the returned + * handle (e.g. to {@code HamlibRig.handle}) before the handshake runs — + * {@link #nativeFeedFromRig} silently drops bytes delivered against a + * handle the caller hasn't published yet, which previously meant every + * byte the radio sent during the handshake itself was lost, and hamlib + * always saw a timeout even against a replying radio. * * @param modelId hamlib rig model number (e.g. 3085 = IC-705) * @param sink receives CAT bytes bound for the radio * @return opaque handle (> 0) for the other calls, or 0 on failure */ - public static native long nativeOpen(int modelId, HamlibSink sink); + public static native long nativeCreate(int modelId, HamlibSink sink); + + /** + * Run hamlib's {@code rig_open()} (the CAT handshake) on a bridge handle + * from {@link #nativeCreate}. The caller must have already published the + * handle so inbound bytes during the handshake reach {@link #nativeFeedFromRig}. + * + * @return 0 (RIG_OK) on success, or a hamlib error code. On any non-zero + * return the bridge has already been fully torn down and freed — + * the handle must not be used again. + */ + public static native int nativeRigOpen(long handle); /** Push bytes received from the radio into hamlib's read side. */ public static native void nativeFeedFromRig(long handle, byte[] data); From 83cf1f7b7e7e2fc44b4e3e957b84346879ddf632 Mon Sep 17 00:00:00 2001 From: quadra Date: Wed, 21 Oct 2026 04:47:56 +0000 Subject: [PATCH 11/36] Add status doc for the QMX hamlib backend work (PR #925) Condensed, current-state summary of the four real bugs found and fixed via real-hardware testing today, what's confirmed working now (CAT + mode both reliable on real hardware), and the one known remaining issue (TX audio underdriven) queued for next time. Full narrative with logs stays in the PR #925 comment thread; this is the quick-reference version. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf --- docs/qmx-hamlib-status.md | 88 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 docs/qmx-hamlib-status.md diff --git a/docs/qmx-hamlib-status.md b/docs/qmx-hamlib-status.md new file mode 100644 index 000000000..2e213b76a --- /dev/null +++ b/docs/qmx-hamlib-status.md @@ -0,0 +1,88 @@ +# QRP Labs QMX via the hamlib backend: status (PR #925) + +Running status for the work on branch `feat/qmx-hamlib-rig` (fork +`laniol8926/FT8AF`), adding a "Qrp Labs QMX (Hamlib)" rig entry backed by +hamlib's real `qrplabs_qmx_caps` instead of hand-rolled Kenwood emulation. +Full narrative, with logs, is in the PR #925 comment thread; this is the +condensed current state. + +## Confirmed working (real hardware, 2026-10-21) + +- `rigaddress.txt` entry (`Qrp Labs QMX (Hamlib),809,9600,26`) resolves to + hamlib's real `RIG_MODEL_QRPLABS_QMX` (`0x809` = 2057) and opens correctly. +- CAT works end-to-end: frequency set from the app matches the radio's own + displayed frequency after a live change (tested at 7.074 MHz and + 14.074 MHz). +- Mode stays in Digital through a frequency change (previously it reverted to + USB every time). + +## Bugs found and fixed, in order + +1. **Wrong mode string produced an invalid CAT command** (`9b5e8787`, after a + wrong first attempt in `e046e0a7`/`37897125` that was reverted). hamlib's + QMX backend has no `mode_table` override, so `kenwood_set_mode()` falls + back to the generic Kenwood table, where `RIG_MODE_PKTUSB` sits at index + 13 — encoded as the letter `'D'` (`"MDD;"`), which the QMX's own CAT + manual doesn't recognize (only single-digit `MD` values are valid; 6 = + FSK, the QMX's real digital mode). Fixed by requesting `"RTTY"` instead of + `"PKTUSB"` — `RIG_MODE_RTTY` sits at table index 6, producing the valid + `"MD6;"`. + - The reverted first attempt sent a vendor command believed to be + `"Q41;"` ("stay in digital mode") after every mode/frequency change. + Checked against QRP Labs' own official CAT manual + (`qrp-labs.com/images/qmx/manuals/cat_1_04_003.pdf`): there is no `Q41` + command. `Q4` is "Get/Set TX Rise Threshold" — `"Q41;"` was silently + setting it to 1%, an unrelated, actively harmful side effect. + +2. **Hamlib model ID silently truncated to a byte on every app restart** + (`47c8fc4e`). `GeneralVariables.civAddress` was reused for both a genuine + single-byte ICOM CI-V address and the hamlib model ID (routinely over a + byte — QMX's `2057`). Its persistence (`CivAddressConfig.encode()`) masks + with `& 0xFF`, so `2057` round-tripped through disk as `9` — opening an + unrelated hamlib backend (model 9, SDR#) instead of the QMX on every + subsequent launch. Fixed with a separate, unmasked `hamlibModelId` + field/config key. + +3. **That fix's migration gap crashed the app** (`4a76f2af`). An install that + had already selected a Hamlib rig before the new config key existed loaded + `hamlibModelId = 0`; passing model `0` to hamlib's `rig_init()` aborts the + whole process (native SIGABRT) about half a second later, not a graceful + failure. Fixed by refusing to open any `modelId <= 0` in + `HamlibRig.ensureOpen()`, plus a self-healing migration that derives the + value from the currently-selected rig on first connect. + +4. **The real structural bug: handshake replies were silently dropped** + (`d78aebe9`). `HamlibRig.onReceiveData()` only forwards radio bytes into + the native bridge once `handle != 0` — but the original single + `nativeOpen()` call only set `handle` *after* it returned, and it ran + hamlib's own `rig_open()` (including its synchronous CAT handshake) + *internally*, before returning. Every byte the radio sent back during that + handshake was dropped, so hamlib always timed out waiting for an ack that + could never arrive — confirmed on real hardware, where `serial.recv` + showed genuine traffic arriving in the exact window hamlib's own trace + reported "0 chars". Fixed by splitting the native call into + `nativeCreate()` (builds the bridge, returns a handle) and + `nativeRigOpen(handle)` (runs the handshake), with `HamlibRig.ensureOpen()` + publishing the handle in between. + +## Known issue, not yet root-caused + +TX: the rig keys/transmits, but audio is underdriven (low), with CAT and mode +both confirmed working correctly in the same test. Not yet investigated — +some earlier audio findings in the PR thread (the libusb I/O write failure, +the "OS never registers a sound card" finding, the `BOTH`-vs-`LEFT` channel +routing default) were drawn while CAT was *also* silently broken by bug #4 +above, so they may not all still apply as previously understood and are +worth re-checking fresh now that CAT and mode are both reliable. + +## Known test gaps + +- No local build/unit-test execution has been possible on the Linux ARM + board this work was primarily developed on (`aapt2`/`adb`/`zipalign` are + all x86_64-only there). Real building and on-device testing for this status + update were done via a Windows laptop with Android Studio, connected over + Claude Code's Remote Control feature. +- The Hamlib-backed code paths in `HamlibRigTest.java` are gated behind + `ensureOpen()`, which always returns false in a JVM unit test (no native + lib) — consistent with that test file's own documented precedent, hamlib + behavior itself is verified on real hardware, not in CI unit tests. From 6dfd6ced173b752dc4e34c88e2286ed31dc9c643 Mon Sep 17 00:00:00 2001 From: quadra Date: Thu, 22 Oct 2026 04:00:37 +0000 Subject: [PATCH 12/36] Log AudioTrack preferredDevice outcome for TX (symmetric with RX) MicRecorder already logs setPreferredDevice's success/failure and whether the chosen device was even found; the AudioTrack side (both the FT8-over and the Tune-tone paths) called setPreferredDevice and discarded the result, so a silently-failing TX device selection left no trace in debug.log. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf --- .../com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java b/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java index e2417580c..8021224ac 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java @@ -1114,7 +1114,10 @@ private void playViaAudioTrack(float[] buffer) { // set preferred output device if (GeneralVariables.audioOutputDeviceId > 0) { - audioTrack.setPreferredDevice(preferredOutputDevice); // null resets to default + boolean ok = audioTrack.setPreferredDevice(preferredOutputDevice); // null resets to default + GeneralVariables.fileLog(String.format( + "playFT8Signal: AudioTrack preferredDevice id=%d ok=%b deviceFound=%b", + GeneralVariables.audioOutputDeviceId, ok, preferredOutputDevice != null)); } else { applyDefaultOutputRoutingOverride(audioTrack); } @@ -3294,7 +3297,10 @@ private void playTuneTone() { track = new AudioTrack(tuneAttributes, tuneFormat, bufBytes, AudioTrack.MODE_STREAM, 0); if (GeneralVariables.audioOutputDeviceId > 0) { - track.setPreferredDevice(tuneOutputDevice); + boolean ok = track.setPreferredDevice(tuneOutputDevice); + GeneralVariables.fileLog(String.format( + "TUNE: AudioTrack preferredDevice id=%d ok=%b deviceFound=%b", + GeneralVariables.audioOutputDeviceId, ok, tuneOutputDevice != null)); } else { applyDefaultOutputRoutingOverride(track); } From 5d89ca5511c59970a6c33b6a4f1e9081957776fa Mon Sep 17 00:00:00 2001 From: quadra Date: Thu, 22 Oct 2026 04:25:40 +0000 Subject: [PATCH 13/36] Fix RX: reopen MicRecorder when the audio input device is changed Picking a new input device in Settings persisted GeneralVariables.audioInputDeviceId and audioInputVendorId/ProductId, but nothing told the already-running MicRecorder to reopen with it -- MicRecorder only reads those fields at construction time (openAudioRecord()'s setPreferredDevice() / openUsbAudioInput()). So the new selection silently had no effect on the live capture until the next USB attach/detach or app restart happened to rebuild it. Confirmed on real hardware (Edge 2022 + QMX): logcat showed writeConfig persisting the new device id with no MicRecorder reinit or preferredDevice line following it, and the running capture stayed on audioInputDeviceId=0 (the phone's built-in mic) the whole session -- exactly the silent-no-op the user suspected. Mirrors the RX-channel-change (onRxAudioChannelChanged) and Bluetooth-SCO-connect (refreshBluetoothHeadsetMode) paths, which already call reinitializeAudioInput() for the same reason. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf --- .../radio/ks3ckc/ft8af/ui/settings/RadioAudioSettings.kt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/RadioAudioSettings.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/RadioAudioSettings.kt index 2eec94c28..b23decc05 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/RadioAudioSettings.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/RadioAudioSettings.kt @@ -544,6 +544,14 @@ fun RadioAudioSettings( audioInputChannels = selectedDeviceMaxChannels( context, deviceId, AudioManager.GET_DEVICES_INPUTS, ) + // The live MicRecorder only reads audioInputDeviceId at construction + // time (openAudioRecord()'s setPreferredDevice / openUsbAudioInput()), + // so without this the newly-picked device is persisted but the running + // capture keeps using whatever it opened with — silently, with no error + // — until the next USB attach/detach or app restart happens to reopen + // it. Mirrors the RX-channel-change and SCO-connect paths, which already + // reopen for the same reason. + mainViewModel.reinitializeAudioInput() // Selecting a Bluetooth-SCO mic must actually bring SCO up (issue #723); // deselecting it releases SCO unless a Bluetooth rig still needs it. mainViewModel.refreshBluetoothHeadsetMode() From e0ad4a36bbb21ff4ecaf8c819d3fe9a34202b3a9 Mon Sep 17 00:00:00 2001 From: quadra Date: Thu, 22 Oct 2026 04:32:30 +0000 Subject: [PATCH 14/36] Log reinit preferredDevice outcome + hydrated audioInputDeviceId Same gap as the TX AudioTrack fix: MicRecorder.reinitialize() (the path driven by startup hydration, USB attach/detach, and the new Settings-picker reinit) called setPreferredDevice() without logging whether it found the device or whether the call succeeded, unlike the constructor's version. Also log the hydrated audioInputDeviceId / audioOutputDeviceId right after config load completes, before reinitializeAudioInput() runs off them -- settles whether a later "audioInputDeviceId=0" at MicRecorder init means the selection was never durably persisted, or was persisted fine but reset by something else afterward. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf --- .../src/main/java/com/k1af/ft8af/wave/MicRecorder.java | 5 ++++- .../kotlin/radio/ks3ckc/ft8af/ComposeMainActivity.kt | 10 ++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/wave/MicRecorder.java b/ft8af/app/src/main/java/com/k1af/ft8af/wave/MicRecorder.java index 3bbf877d6..5565d8210 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/wave/MicRecorder.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/wave/MicRecorder.java @@ -767,7 +767,10 @@ public synchronized void reinitialize() { if (audioRecord != null && GeneralVariables.audioInputDeviceId > 0) { AudioDeviceInfo deviceInfo = findAudioDeviceById( GeneralVariables.audioInputDeviceId, AudioManager.GET_DEVICES_INPUTS); - audioRecord.setPreferredDevice(deviceInfo); + boolean ok = audioRecord.setPreferredDevice(deviceInfo); + GeneralVariables.fileLog(String.format( + "MicRecorder: reinit preferredDevice id=%d ok=%b deviceFound=%b", + GeneralVariables.audioInputDeviceId, ok, deviceInfo != null)); } } diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ComposeMainActivity.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ComposeMainActivity.kt index b5f0722b1..94c7381d2 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ComposeMainActivity.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ComposeMainActivity.kt @@ -404,6 +404,16 @@ class ComposeMainActivity : AppCompatActivity() { "baudRate=${GeneralVariables.baudRate}, " + "controlMode=${GeneralVariables.controlMode}, " + "connectMode=${GeneralVariables.connectMode}") + // Diagnostic (RX device-selection investigation): the value hydration + // actually read from the DB, BEFORE reinitializeAudioInput() below runs + // off it — settles whether a later audioInputDeviceId=0 at MicRecorder + // init means "never persisted / lost on restart" (this line also shows + // 0) or "persisted fine but something reset it afterward" (this line + // shows the real value). + fileLog("configLoaded: audioInputDeviceId=${GeneralVariables.audioInputDeviceId}, " + + "audioOutputDeviceId=${GeneralVariables.audioOutputDeviceId}, " + + "usbAudioInputVidPid=${GeneralVariables.usbAudioInputVendorId}:" + + "${GeneralVariables.usbAudioInputProductId}") if (GeneralVariables.autoUpdateGridFromGPS) { val grid = MaidenheadGrid.getMyMaidenheadGrid(applicationContext) if (grid.isNotEmpty()) { From abb8ad93a822257bf72c4983a3088c60a1ed0e62 Mon Sep 17 00:00:00 2001 From: quadra Date: Thu, 22 Oct 2026 04:43:46 +0000 Subject: [PATCH 15/36] Fix RX: request USB mode for FT8/FT4, not RTTY (literal FSK) Last night's mode fix (9b5e8787) changed FT_MODE from "PKTUSB" (hamlib encodes it as the invalid letter 'D', silently rejected by the QMX) to "RTTY" (encodes as the valid "MD6;"). CAT accepted it and the app's UI correctly showed "Digital" mode persisting -- but "RTTY" in hamlib's generic Kenwood mode table means the radio's literal hardware FSK demodulator, the same dedicated RTTY-keying path real Kenwood-style rigs use. FT8/FT4 are audio-tone modes riding a normal SSB passband, not true FSK, and FSK mode bypasses the receive chain they need. Confirmed directly on real hardware tonight: with the radio in FSK (MD6, what the app was requesting), the QMX's own S-meter showed zero response to real RF on the band; switched manually to USB mode, the S-meter came alive immediately. Independently confirmed by a known- working third-party app (QFT8) decoding normally on this exact phone+radio+antenna at the same time -- the RF path was never the problem. Table index 2 (RIG_MODE_USB) is the QMX manual's own "2 (USB)" and, like RTTY's index 6, is <= 9 so kenwood_set_mode() encodes it as a literal digit ("MD2;"), not a letter -- valid for the same reason RTTY was, but the semantically correct mode this time. Matches this project's own earlier field observation on the sibling QDX ("CAT does work getting decodes in usb mode"). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf --- .../java/com/k1af/ft8af/rigs/HamlibRig.java | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java index 1639a5166..3591ee2af 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java @@ -36,11 +36,28 @@ public class HamlibRig extends BaseRig { * manual (qrp-labs.com/images/qmx/manuals/cat_1_04_003.pdf) only accepts * single-digit MD values (1,2,3,5,6,7,8,9); 'D' is not one of them and is * silently rejected, so the radio's mode was never actually being set at - * all via this path. Table index 6 holds RIG_MODE_RTTY ("FSK Mode" per - * that table's own comment) -- exactly the QMX manual's own "6 (FSK)", - * its real digital mode. "RTTY" therefore produces the valid "MD6;". + * all via this path. + * + *

NOT "RTTY" either, despite also producing a valid command (table + * index 6 = RIG_MODE_RTTY -> "MD6;", the QMX manual's own "6 (FSK)"). + * hamlib's RTTY mode is the radio's literal hardware FSK demodulator -- + * the dedicated RTTY-keying path Kenwood-style rigs use, which bypasses + * the normal SSB receive chain FT8's audio tones need. Confirmed on real + * hardware: the QMX's own S-meter stayed active on real RF in USB mode + * and went completely dead in FSK (MD6) mode -- CAT accepted the command + * and the app reported "Digital" mode throughout, but the radio's receive + * audio path was never actually listening the way FT8 (an audio-tone + * mode, not true FSK) needs. + * + *

Table index 2 holds RIG_MODE_USB -- the QMX manual's own "2 (USB)" -- + * and FT8/FT4 are audio-tone modes transmitted/received over a plain USB + * passband, exactly like voice SSB; they need no special radio-side mode + * at all, matching the QDX's own field-confirmed behavior elsewhere in + * this project ("CAT does work getting decodes in usb mode"). "USB" + * produces the valid "MD2;" and, unlike PKTUSB/RTTY, is index <= 9 so + * kenwood_set_mode() encodes it as a literal digit, not a letter. */ - private static final String FT_MODE = "RTTY"; + private static final String FT_MODE = "USB"; private final int modelId; private final ExecutorService worker = Executors.newSingleThreadExecutor(); From bc261b336582b328bd71aad7b1317bdc5414b332 Mon Sep 17 00:00:00 2001 From: quadra Date: Thu, 22 Oct 2026 04:52:00 +0000 Subject: [PATCH 16/36] Revert FT_MODE to RTTY (Digi): mode was never the RX cause The previous commit (abb8ad93) swapped FT_MODE from "RTTY" to "USB" after the QMX's own display showed no S-meter response in Digi/FSK mode where USB mode showed one -- looked like FSK was disabling the receive chain FT8 needs. Turned out to be a false lead, confirmed on the same real hardware tonight: the QMX simply doesn't draw an S-meter widget on its Digi- mode screen at all (the operator switched modes at the radio's own front panel, independent of the app, and confirmed this directly) -- not a no-signal indication. RX was then confirmed decoding normally, multiple cycles with real callsigns, in BOTH USB and Digi mode on the same reconnect. Mode was never the actual cause -- the real RX fix was the MicRecorder live-reinitialize bug fixed earlier tonight (5d89ca55), which had nothing to do with mode at all. Reverting to "RTTY" (MD6;) since Digi is the QMX's own correct, intended mode for a digital mode like FT8, and it's now confirmed to decode exactly as well as USB mode. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf --- .../java/com/k1af/ft8af/rigs/HamlibRig.java | 37 +++++++++---------- 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java index 3591ee2af..b07716040 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java @@ -36,28 +36,25 @@ public class HamlibRig extends BaseRig { * manual (qrp-labs.com/images/qmx/manuals/cat_1_04_003.pdf) only accepts * single-digit MD values (1,2,3,5,6,7,8,9); 'D' is not one of them and is * silently rejected, so the radio's mode was never actually being set at - * all via this path. + * all via this path. Table index 6 holds RIG_MODE_RTTY ("FSK Mode" per + * that table's own comment) -- the QMX manual's own "6 (FSK)", its real + * digital mode -- which, like index 2 (RIG_MODE_USB), is <= 9 so + * kenwood_set_mode() encodes it as a literal digit ("MD6;"), not a letter. * - *

NOT "RTTY" either, despite also producing a valid command (table - * index 6 = RIG_MODE_RTTY -> "MD6;", the QMX manual's own "6 (FSK)"). - * hamlib's RTTY mode is the radio's literal hardware FSK demodulator -- - * the dedicated RTTY-keying path Kenwood-style rigs use, which bypasses - * the normal SSB receive chain FT8's audio tones need. Confirmed on real - * hardware: the QMX's own S-meter stayed active on real RF in USB mode - * and went completely dead in FSK (MD6) mode -- CAT accepted the command - * and the app reported "Digital" mode throughout, but the radio's receive - * audio path was never actually listening the way FT8 (an audio-tone - * mode, not true FSK) needs. - * - *

Table index 2 holds RIG_MODE_USB -- the QMX manual's own "2 (USB)" -- - * and FT8/FT4 are audio-tone modes transmitted/received over a plain USB - * passband, exactly like voice SSB; they need no special radio-side mode - * at all, matching the QDX's own field-confirmed behavior elsewhere in - * this project ("CAT does work getting decodes in usb mode"). "USB" - * produces the valid "MD2;" and, unlike PKTUSB/RTTY, is index <= 9 so - * kenwood_set_mode() encodes it as a literal digit, not a letter. + *

Briefly swapped to "USB" (MD2;) during the same hardware session that + * found this, after the QMX's own display showed no S-meter response in + * Digi/FSK mode where it showed one in USB mode -- looked like FSK was + * disabling the receive chain FT8 needs. Turned out to be a false lead: + * the QMX simply doesn't draw an S-meter widget on its Digi-mode screen + * at all (confirmed by the operator switching modes at the radio's own + * front panel, independent of the app), not a no-signal indication. RX + * was separately confirmed decoding normally in both USB and Digi mode + * once reconnected with the app's actual bug fixed (see + * {@code RadioAudioSettings.kt}'s audio-input-picker reinitialize call) -- + * mode was never the cause. Reverted to "RTTY" since Digi is the QMX's + * correct, intended mode for a digital mode like FT8. */ - private static final String FT_MODE = "USB"; + private static final String FT_MODE = "RTTY"; private final int modelId; private final ExecutorService worker = Executors.newSingleThreadExecutor(); From 5b76f4b814169ca1179fe8f3f46adc77f504ef41 Mon Sep 17 00:00:00 2001 From: quadra Date: Thu, 22 Oct 2026 05:11:29 +0000 Subject: [PATCH 17/36] Fix RX: a transient AudioRecord failure permanently stopped capture reinitialize() decided whether to restart capture by reading isRunning -- but a transient AudioRecord creation failure (AudioFlinger createRecord_l returning -1, observed in the field right after a replug/power-cycle, before the audio HAL had caught up) sets isRunning false as a side effect from the capture thread's own failure-detection path, with no owner-driven stopRecord() ever happening. Every reinitialize() after that read the now-stale isRunning as false and silently skipped start() -- permanently, even once the operator picked a completely valid device afterward (confirmed on real hardware: setPreferredDevice() kept reporting ok=true/deviceFound=true, and AudioFlinger genuinely created the record track, but start() was simply never called again). Fix: track the owner's actual intent in a new field, shouldBeRunning, touched only by the two owner-facing entry points (start()/ stopRecord()) and left alone by transient internal failures. reinitialize() now keys its end-of-method restart decision off that instead of isRunning, and does its own internal stop/rebuild teardown inline rather than through stopRecord() (which would otherwise clear the very flag it needs to survive). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf --- .../java/com/k1af/ft8af/wave/MicRecorder.java | 43 ++++++++++++++++--- 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/wave/MicRecorder.java b/ft8af/app/src/main/java/com/k1af/ft8af/wave/MicRecorder.java index 5565d8210..3dbcf153d 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/wave/MicRecorder.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/wave/MicRecorder.java @@ -45,6 +45,19 @@ public class MicRecorder { private boolean useUsbAudio = false; private volatile boolean isRunning = false;//whether currently in recording state + // Whether the OWNER (HamRecorder) wants capture running -- set only by + // start()/stopRecord(), the two owner-facing entry points. Deliberately + // separate from isRunning, which a failed AudioRecord also flips to false + // as a side effect (see startAudioRecordCapture()'s capture thread, and + // reinitialize() below): reinitialize() used to key its end-of-method + // restart decision off isRunning, so one transient createRecord_l failure + // (observed in the field right after a replug, before the audio HAL had + // caught up) permanently masked the owner's actual intent -- every later + // reinitialize() (e.g. the operator picking a perfectly valid device + // afterward) saw isRunning already false and silently skipped start(), + // leaving capture stopped for the rest of the session even though + // setPreferredDevice() kept reporting ok=true/deviceFound=true. + private volatile boolean shouldBeRunning = false; private OnDataListener onDataListener; // AudioRecord capture-thread handoff. reinitialize() can be called from @@ -430,6 +443,7 @@ public int chosenInputDeviceType() { } public synchronized void start(){ + shouldBeRunning = true; if (isRunning) return; if (useUsbAudio && usbAudioDevice != null) { @@ -669,9 +683,16 @@ public void run() { /** * Stop recording. When recording stops, all monitors in the listener list are removed. + * + *

Owner-facing: also clears {@link #shouldBeRunning}, so a later + * {@link #reinitialize()} (e.g. from a USB attach event) knows not to + * restart capture the operator explicitly stopped. {@link #reinitialize()} + * itself does the equivalent teardown inline rather than calling this, so + * its own internal stop/rebuild cycle never clears that intent flag. */ public synchronized void stopRecord() { isRunning = false; + shouldBeRunning = false; if (useUsbAudio && usbAudioDevice != null) { usbAudioDevice.stopCapture(); } @@ -693,14 +714,20 @@ public void setOnDataListener(OnDataListener onDataListener) { */ @SuppressLint("MissingPermission") public synchronized void reinitialize() { - boolean wasRunning = isRunning; OnDataListener savedListener = onDataListener; - // Supersede the running reader thread's generation, then stop capture. + // Supersede the running reader thread's generation, then stop capture + // inline (NOT via stopRecord(): that also clears shouldBeRunning, which + // must survive this method's own internal stop/rebuild cycle -- see + // shouldBeRunning's field comment for why keying off isRunning here + // used to permanently wedge capture after one transient failure). captureGeneration++; - stopRecord(); + isRunning = false; + if (useUsbAudio && usbAudioDevice != null) { + usbAudioDevice.stopCapture(); + } - // Release old AudioRecord (stopRecord only sets isRunning=false). The + // Release old AudioRecord (the above only sets isRunning=false). The // reader thread may still be inside a blocking read() on it: stop() // unblocks that read, and we must wait for the thread to exit before // release() — releasing mid-read is a fatal native abort @@ -774,9 +801,13 @@ public synchronized void reinitialize() { } } - // Restore listener and restart if it was running + // Restore listener and restart if the owner still wants capture + // running -- shouldBeRunning, NOT isRunning: a transient failure in + // the rebuild above (or an earlier reinitialize() call) may already + // have left isRunning false with no owner-driven stopRecord() ever + // happening, and that must not be read as "capture no longer wanted". onDataListener = savedListener; - if (wasRunning) { + if (shouldBeRunning) { start(); } } From 9ed68ff83d126ba24819d5799a8f8deab85b7a02 Mon Sep 17 00:00:00 2001 From: quadra Date: Thu, 22 Oct 2026 05:30:35 +0000 Subject: [PATCH 18/36] Document the RX investigation (2026-10-21/22 overnight session) Two real bugs found and fixed (live-reinit-on-device-pick, transient-failure-permanently-stops-capture), the USB-vs-Digi mode theory chased and conclusively ruled out, and the real remaining lead: reproduced twice on real hardware that audio flows through the OS's own USB-audio handling right up until FT8AF is granted the device via Android's "choose an app" chooser, then stops immediately -- pointing at an OS-level side effect of the permission/claim itself rather than anything FT8AF's own CAT traffic or mode selection does. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf --- docs/qmx-hamlib-status.md | 114 +++++++++++++++++++++++++++++++++++--- 1 file changed, 105 insertions(+), 9 deletions(-) diff --git a/docs/qmx-hamlib-status.md b/docs/qmx-hamlib-status.md index 2e213b76a..29810c6cf 100644 --- a/docs/qmx-hamlib-status.md +++ b/docs/qmx-hamlib-status.md @@ -65,15 +65,111 @@ condensed current state. `nativeRigOpen(handle)` (runs the handshake), with `HamlibRig.ensureOpen()` publishing the handle in between. -## Known issue, not yet root-caused - -TX: the rig keys/transmits, but audio is underdriven (low), with CAT and mode -both confirmed working correctly in the same test. Not yet investigated — -some earlier audio findings in the PR thread (the libusb I/O write failure, -the "OS never registers a sound card" finding, the `BOTH`-vs-`LEFT` channel -routing default) were drawn while CAT was *also* silently broken by bug #4 -above, so they may not all still apply as previously understood and are -worth re-checking fresh now that CAT and mode are both reliable. +## RX: a full night's investigation (2026-10-21/22), real root cause still open + +A second real-hardware session (Motorola Edge 2022, Android 15, Windows +Remote Control laptop for build/logcat) spent the whole night chasing why RX +showed no waterfall/decodes at all, even once TX/CAT/mode were all confirmed +working the night before. Two real, confirmed, fixed bugs were found along +the way; RX is still not working by the end of the session, with the +investigation narrowed to a specific, actionable, OS-level lead. + +### Bugs found and fixed this session + +5. **Picking a new input device in Settings never reached the live + capture** (`5d89ca55`). `RadioAudioSettings.kt`'s device-picker `onSelect` + persisted the new `audioInputDeviceId`/VID/PID to `GeneralVariables` and + the DB, but never told the already-running `MicRecorder` to rebuild with + it — `MicRecorder` only reads those fields once, at construction time. + The live capture silently kept using whatever it opened with (observed on + real hardware: the phone's built-in mic) until the next USB attach/detach + or app restart happened to reopen it. Fixed by calling the existing + `mainViewModel.reinitializeAudioInput()` from the picker's `onSelect`, + mirroring the RX-channel-change and Bluetooth-SCO-connect paths, which + already did this for the same reason. Confirmed in logcat: `writeConfig` + is now immediately followed by a `MicRecorder` reinit/`preferredDevice` + line, where before there was none. +6. **A transient `AudioRecord` failure permanently stopped capture for the + rest of the app session** (`5b76f4b8`). `MicRecorder.reinitialize()` + decided whether to call `start()` again by reading `isRunning` — but a + transient `AudioFlinger createRecord_l` failure (observed in the field + right after a replug/power-cycle, before the audio HAL had caught up) + sets `isRunning = false` as a side effect of the capture thread's own + failure-detection path, with no owner-driven `stopRecord()` involved. + Every `reinitialize()` after that read the now-stale `isRunning` as false + and silently skipped `start()` — permanently, even once the operator + picked a completely valid device afterward (confirmed on real hardware: + `setPreferredDevice()` kept reporting `ok=true deviceFound=true`, and + `AudioFlinger` genuinely created the record track, but `start()` was + simply never called again). Fixed with a separate `shouldBeRunning` + intent flag, touched only by the two owner-facing entry points + (`start()`/`stopRecord()`) and left alone by transient internal failures; + `reinitialize()` now keys its restart decision off that instead. + +### Mode (USB vs Digi) was a red herring, not the cause + +A large part of the night was spent chasing a theory that hamlib's `"RTTY"` +mode string (Digi/`MD6;`, the QMX's literal hardware FSK demodulator) +disables the receive audio chain FT8 needs, vs. `"USB"` (`MD2;`, plain SSB +passband). This looked initially promising — one build swapped to `"USB"` +and a real decode session followed (29 messages, multiple real callsigns) — +but was conclusively ruled out by later, cleaner tests on the same running +app process: switching the radio between USB and Digi at its own front panel +with FT8AF's capture already running and confirmed healthy (continuous HAL +reads, correct device, correct CAT) made no difference — both modes were +silent. `FT_MODE` is back to `"RTTY"` (Digi, the QMX's correct intended mode +for FT8), per `HamlibRig.java`'s own comment, which documents this full +dead-end for future reference. + +### Current state and the real remaining lead + +With both of the above bugs fixed, USB capture confirmed genuinely healthy +(MediaTek `AudioALSACaptureHandlerUsb`/`AudioALSACaptureDataProviderUsb` +opening correctly, 48kHz/24-bit stereo, thousands of continuous reads, zero +`xrun`/`overrun`/error lines, correct device id, correct CAT/frequency), +decode cycles were still consistently fast (~140ms, the empty-audio +fingerprint vs. ~1100ms when real audio is present) with zero messages kept, +across multiple clean fresh-launch tests and both CAT modes. + +**The clearest lead found all night**: a third-party app, QFT8 (known from +elsewhere in this project to work correctly against this exact +phone+radio), decodes normally and immediately whenever it's given the +device. Directly observed on real hardware, reproduced twice independently +(not a one-off): audio was flowing (visible as waterfall traces) through the +*OS's own* default handling of the QMX's USB Audio Class interface in the +window right after a power-cycle, *before* the Android "choose an app" +USB-device chooser was answered — and it stopped the instant FT8AF was +selected in that chooser, both times. hamlib's QMX-specific open +function (`qrplabs_open` in the vendored `ts480.c`) only queries firmware +version on connect, nothing audio-related, so this isn't something FT8AF's +CAT traffic does — it looks like simply Android granting FT8AF raw +`UsbDeviceConnection` access to the composite device detaches it from +whatever OS-level audio path was supplying real samples, independent of +which audio-input option is selected in Settings and independent of CAT +mode. QFT8 apparently doesn't trigger this (or recovers from it); the +difference in exactly *how* it opens/handles the device is the open +question. Also considered and not yet ruled out: the QMX's own `AG` +(AF Gain/volume) CAT command, which hamlib's QMX backend does not implement +at all (`qrplabs_qmx_caps` has no `.has_set_level`/`.set_level`) — FT8AF +has never set this on any code path, so if a power-cycle resets it low and +QFT8's own connect sequence sets it, that would independently explain +silence-despite-healthy-capture too. The two leads aren't mutually +exclusive and both need a real logcat-instrumented test to settle. + +**Not yet attempted**: comparing exactly what Android USB APIs/permission +calls QFT8 makes vs. FT8AF's `CdcAcmSerialDriver`/`UsbAudioDevice` (QFT8's +author has declined to share source, so this has to be black-box +experimentation — e.g. instrumenting exactly when/how FT8AF calls +`UsbManager.openDevice()`/`requestPermission()` relative to when audio +stops, and trying "Always" vs "Just once" in the chooser to see if that +changes anything), and sending a raw `AG` CAT command on connect as a +cheap, independent test of the AF-gain theory. + +## Known issue, not yet root-caused (TX, from 2026-10-21, not re-tested) + +TX: the rig keys/transmits, but audio was underdriven (low), with CAT and +mode both confirmed working correctly in that test. Not re-tested this +session since RX never reached a working state to test TX against. ## Known test gaps From 12815ecb6d31a6e4c934357f15df733f15d181ed Mon Sep 17 00:00:00 2001 From: quadra Date: Thu, 22 Oct 2026 05:42:26 +0000 Subject: [PATCH 19/36] Fix RX: force QMX I/Q mode off on connect (Q90;) FT8AF has never touched the QMX's I/Q mode. Per the radio's own CAT manual, Q9 (IQ Mode) streams raw I/Q ADC samples to the USB sound card instead of demodulated audio when enabled -- a decoder expecting normal audio sees what looks like noise/silence, while the USB capture pipeline itself stays completely healthy (confirmed via Android's own audio HAL: real continuous reads, zero errors, correct device, correct CAT). That matches exactly the symptom chased fruitlessly through mode, audio-device selection, and capture- lifecycle bugs over a full night's real-hardware testing. The setting is session-only (not written to EEPROM) per the manual, which fits: if I/Q mode was left on via the QMX's own front-panel menu from unrelated SDR use on this unit, it defaults back on every power-cycle, and only an app that explicitly disables it on connect would ever decode again. Directly confirmed by prior art: danleetw/FT8TW issue #78 ("For QMX/QMX+ set I/Q mode off... appears to be working, but the received audio is like noise"), closed by that project as fixed with the identical "Q90;" command -- and side-by-side testing tonight on this exact phone+radio showed FT8TW decoding normally where FT8AF did not. hamlib has no generic API for this vendor-specific QMX extended command, so it bypasses hamlib and writes the connector directly (the same mechanism the hamlib sink itself uses), once per successful open. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf --- .../java/com/k1af/ft8af/rigs/HamlibRig.java | 46 +++++++++++++++++++ .../com/k1af/ft8af/rigs/HamlibRigTest.java | 35 ++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java index b07716040..81b811a5e 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java @@ -141,9 +141,55 @@ private synchronized boolean ensureOpen() { return false; } Log.i(TAG, "hamlib model " + modelId + " opened"); + disableQmxIqModeIfApplicable(c); return true; } + /** + * QRP Labs QMX-specific fixup, sent once right after a successful open: + * force I/Q mode off ({@code "Q90;"}). hamlib has no generic API for this + * -- it's one of the QMX's vendor "Q" extended commands + * (qrp-labs.com/images/qmx/manuals/cat_1_04_003.pdf), not a standard + * rig_set_level/rig_set_mode -- so this bypasses hamlib and writes the + * connector directly, the same mechanism {@link #sink} itself uses for + * hamlib's own bytes. + * + *

Real-hardware root cause (2026-10-22): I/Q mode streams raw ADC + * samples to the USB sound card instead of demodulated audio (per the + * manual's own Q9 description) -- a decoder expecting normal audio sees + * what looks like noise/silence, while the USB capture pipeline itself + * stays completely healthy (confirmed via Android's own audio HAL: real + * continuous reads, zero errors, correct device, correct CAT), which is + * exactly the symptom chased fruitlessly through mode, audio-device + * selection, and capture-lifecycle bugs all night. The setting is + * session-only (not written to EEPROM) per the manual, which fits + * everything observed: if I/Q mode was left on via the QMX's own front- + * panel menu from unrelated SDR use on this unit, it would default back + * on every power-cycle, and only an app that explicitly sends "Q90;" on + * connect would ever decode again -- matching danleetw/FT8TW issue #78 + * ("For QMX/QMX+ set I/Q mode off... appears to be working, but the + * received audio is like noise"), closed by that project as fixed with + * the identical command. + * + *

A first live A/B on this same hardware, same session, briefly looked + * like a counter-example (FT8TW silent too, right after being granted the + * device) but did not reproduce on a second try -- FT8TW decoded + * normally after the device hand-off that time, the one difference + * between the two apps being exactly this fix. See + * docs/qmx-hamlib-status.md for the full real-hardware trail. + * + * @param c the connector to write to; never null when called from + * {@link #ensureOpen()} (already checked non-null/connected) + */ + /** Package-private: visible for {@code disableQmxIqModeIfApplicable_*} tests. */ + void disableQmxIqModeIfApplicable(BaseRigConnector c) { + if (modelId != QRPLABS_QMX_MODEL_ID) return; + c.sendData("Q90;".getBytes(java.nio.charset.StandardCharsets.US_ASCII)); + } + + /** hamlib model ID for the QRP Labs QMX (0x809), per rigaddress.txt. */ + private static final int QRPLABS_QMX_MODEL_ID = 0x809; + @Override public boolean isConnected() { BaseRigConnector c = getConnector(); diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/rigs/HamlibRigTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/rigs/HamlibRigTest.java index f57a3a5bf..d0191a67e 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/rigs/HamlibRigTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/rigs/HamlibRigTest.java @@ -2,9 +2,13 @@ import static com.google.common.truth.Truth.assertThat; +import com.k1af.ft8af.connector.BaseRigConnector; + +import java.nio.charset.StandardCharsets; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; import org.junit.Test; @@ -108,4 +112,35 @@ public void taskQueuedBeforeDisconnect_doesNotRunBodyAfterClose() throws Excepti // The execution-time re-check dropped B's body after disconnect. assertThat(bBodyRan.get()).isFalse(); } + + /** Captures the last byte[] written, standing in for the real serial link. */ + private static BaseRigConnector recordingConnector(AtomicReference sent) { + return new BaseRigConnector() { + @Override + public synchronized void sendData(byte[] data) { + sent.set(data); + } + }; + } + + @Test + public void disableQmxIqModeIfApplicable_sendsQ90_forQmxModelId() { + // 0x809 = RIG_MAKE_MODEL(RIG_KENWOOD, 57), hamlib's real QMX model ID + // (see rigaddress.txt's "Qrp Labs QMX (Hamlib),809,...") -- the only + // model this vendor-specific fixup should ever fire for. + HamlibRig rig = new HamlibRig(0x809); + AtomicReference sent = new AtomicReference<>(); + rig.disableQmxIqModeIfApplicable(recordingConnector(sent)); + assertThat(new String(sent.get(), StandardCharsets.US_ASCII)).isEqualTo("Q90;"); + } + + @Test + public void disableQmxIqModeIfApplicable_isNoOp_forOtherModelIds() { + // Only the QMX uses this vendor "Q" command; sending it to an unrelated + // hamlib model (e.g. 1036 = FT-891) would be a meaningless raw write. + HamlibRig rig = new HamlibRig(1036); + AtomicReference sent = new AtomicReference<>(); + rig.disableQmxIqModeIfApplicable(recordingConnector(sent)); + assertThat(sent.get()).isNull(); + } } From c790e8d55b42d20d24b2547ba0248e9540cd4521 Mon Sep 17 00:00:00 2001 From: quadra Date: Thu, 22 Oct 2026 05:43:11 +0000 Subject: [PATCH 20/36] Document the QMX I/Q-mode-off fix (Q90;) as the likely real RX fix Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf --- docs/qmx-hamlib-status.md | 48 ++++++++++++++++++++++++++++++++------- 1 file changed, 40 insertions(+), 8 deletions(-) diff --git a/docs/qmx-hamlib-status.md b/docs/qmx-hamlib-status.md index 29810c6cf..1f520be23 100644 --- a/docs/qmx-hamlib-status.md +++ b/docs/qmx-hamlib-status.md @@ -156,14 +156,46 @@ QFT8's own connect sequence sets it, that would independently explain silence-despite-healthy-capture too. The two leads aren't mutually exclusive and both need a real logcat-instrumented test to settle. -**Not yet attempted**: comparing exactly what Android USB APIs/permission -calls QFT8 makes vs. FT8AF's `CdcAcmSerialDriver`/`UsbAudioDevice` (QFT8's -author has declined to share source, so this has to be black-box -experimentation — e.g. instrumenting exactly when/how FT8AF calls -`UsbManager.openDevice()`/`requestPermission()` relative to when audio -stops, and trying "Always" vs "Just once" in the chooser to see if that -changes anything), and sending a raw `AG` CAT command on connect as a -cheap, independent test of the AF-gain theory. +### The real fix (found, pushed, awaiting a clean build/test): I/Q mode left on + +Checking a related project turned the investigation around. Dan Lee's +separate `danleetw/FT8TW` app hit what reads as the identical symptom on +the QMX family: GitHub issue **danleetw/FT8TW#78** ("For QMX/QMX+ set I/Q +mode off"), closed as fixed — + +> If I/Q mode is left on, it appears that FT8TW is working, but the +> received audio is like noise. CAT command: Q90; + +This matches the QMX's own CAT manual precisely: `Q9` (IQ Mode) streams +**raw I/Q ADC samples** to the USB sound card instead of demodulated audio +when enabled — a decoder expecting normal AF audio gets garbage/silence, +while the USB capture pipeline itself stays completely healthy, exactly the +symptom chased all night through mode, device-selection, and capture- +lifecycle bugs. It's session-only (not written to EEPROM per the manual), +which fits the whole night's pattern: if this unit's I/Q mode was left on +via its own front-panel menu from earlier SDR use, it would default back on +every power-cycle, and only an app that explicitly disables it on connect +would ever decode again. FT8AF has never sent this command on any code +path. + +A direct side-by-side test on this exact phone+radio confirmed it: FT8TW +(which already carries Dan's fix) decoded normally; FT8AF, run immediately +after under the same radio state, did not. (One earlier FT8TW attempt in +this same session looked like a counter-example — silent right after the +app was granted the device — but did not reproduce on a second try, which +decoded fine; the live-signal/power-cycle environment made single data +points noisy all night, which is part of why this took so long to pin +down.) + +**Fixed and pushed** (`12815ecb`): `HamlibRig` now sends `"Q90;"` directly +over the connector once per successful open — hamlib has no generic API for +this QMX vendor-specific extended command, so it bypasses hamlib the same +way the (correctly reverted) `Q41;` experiment did, but for a real, +externally-validated command this time. Covered by a new unit test +(`HamlibRigTest`) for the model-id gate and the exact bytes sent; the actual +real-hardware confirmation is still pending a clean build/test cycle on the +Windows laptop (offline at the moment this was written — message is queued +for when it reconnects). ## Known issue, not yet root-caused (TX, from 2026-10-21, not re-tested) From 38f8e9d8dd6bf2befe65e995de3af311b7e4e944 Mon Sep 17 00:00:00 2001 From: quadra Date: Thu, 22 Oct 2026 05:46:51 +0000 Subject: [PATCH 21/36] Fix RX: capture PCM16 instead of float, convert in software Confirmed on real hardware (QMX over USB Audio Class, MediaTek audio HAL) that requesting ENCODING_PCM_FLOAT from a USB input silently produces unusable samples on this HAL -- AudioALSAStreamIn's own log line, "wrong format(0x5), default use wordSize = 2", shows it falling back to a 16-bit word size internally while still being asked for 4-byte floats. Reproduced twice in two single app processes with everything else held constant (same CAT mode, same device, same session): the HAL occasionally renegotiates to 16-bit PCM across a USB re-enumeration, and decoding started working immediately every time that happened, then stopped immediately on the next reinit back to float. This is what the whole night's RX investigation was actually chasing -- not mode, not audio-device selection, not I/Q mode (the radio decoded fine in Digi/MD6 with fmt=1 negotiated and no Q90; ever sent, which also rules that fix out as the deciding factor here, even though it's independently correct for the FT8TW#78 scenario). MicRecorder now requests ENCODING_PCM_16BIT (which is what the commented-out line already showed as the original format before an earlier change switched to float) and converts to float in the reader loop via the new int16ToFloat(), matching float2Short()'s existing inverse scale in the TX path. Covered by a new unit test file. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf --- .../java/com/k1af/ft8af/wave/MicRecorder.java | 48 ++++++++++++++- .../wave/MicRecorderInt16ToFloatTest.java | 58 +++++++++++++++++++ 2 files changed, 103 insertions(+), 3 deletions(-) create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/wave/MicRecorderInt16ToFloatTest.java diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/wave/MicRecorder.java b/ft8af/app/src/main/java/com/k1af/ft8af/wave/MicRecorder.java index 3dbcf153d..ce0b35ac3 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/wave/MicRecorder.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/wave/MicRecorder.java @@ -37,8 +37,22 @@ public class MicRecorder { // createAudioRecord(). Volatile because reinitialize() writes it from // another thread than the reader loop that snapshots it. private volatile int captureChannelCount = 1; - //private static final int audioFormat = AudioFormat.ENCODING_PCM_16BIT; //quantization bit depth - private static final int audioFormat = AudioFormat.ENCODING_PCM_FLOAT; //quantization bit depth + // NOT ENCODING_PCM_FLOAT: confirmed on real hardware (2026-10-22, QMX + // over USB Audio Class, MediaTek audio HAL) that requesting float capture + // from a USB input silently produces unusable (effectively silent) + // samples on this HAL -- AudioALSAStreamIn's own log line, "wrong + // format(0x5), default use wordSize = 2", shows it falling back to a + // 16-bit word size internally while still being asked for 4-byte floats. + // Reproduced twice in two single app processes with everything else held + // constant (same CAT mode, same device, same session): the HAL + // occasionally renegotiates to 16-bit PCM across a USB re-enumeration, + // and decoding started working immediately every time that happened, + // then stopped immediately on the next reinit back to float -- this is + // what the whole night's RX investigation was actually chasing, not + // mode or device selection. Capture PCM16 instead and convert to float + // in the reader loop below (see startAudioRecordCapture()), matching + // what already reliably decodes on this hardware. + private static final int audioFormat = AudioFormat.ENCODING_PCM_16BIT; //quantization bit depth private AudioRecord audioRecord = null;//AudioRecord object private UsbAudioDevice usbAudioDevice = null; // USB audio device @@ -583,6 +597,11 @@ private void startAudioRecordCapture() { // read(), native releaseBuffer abort from two threads on one instance). final AudioRecord rec = audioRecord; final int myGeneration = captureGeneration; + // Raw capture buffer: audioFormat is ENCODING_PCM_16BIT (see its field + // comment for why), so AudioRecord.read() fills a short[], converted + // to float[] below for the rest of the pipeline (decode/waterfall) + // unchanged. + short[] rawBuffer = new short[bufferSize]; float[] buffer = new float[bufferSize]; // Snapshotted with `rec`: the fold below must match the channel count of // the AudioRecord this thread owns, not whatever a later reinit opened. @@ -615,7 +634,11 @@ public void run() { } //read recording data - int bufferReadResult = rec.read(buffer, 0, buffer.length,AudioRecord.READ_BLOCKING); + int bufferReadResult = rec.read(rawBuffer, 0, rawBuffer.length, AudioRecord.READ_BLOCKING); + + if (bufferReadResult > 0) { + int16ToFloat(rawBuffer, bufferReadResult, buffer); + } if (bufferReadResult < 0) { // Error codes (ERROR_INVALID_OPERATION=-3, ERROR_BAD_VALUE=-2, @@ -906,6 +929,25 @@ private static String audioSourceName(int source) { return String.valueOf(source); } + /** + * Convert {@code length} 16-bit PCM samples to float, filling {@code dest} + * in place from index 0 -- the caller's hot-path buffer, reused across + * reads rather than allocated per call. Matches {@code float2Short()}'s + * inverse scale elsewhere in the app (the TX path, {@code + * FT8TransmitSignal.java}): full-scale short (+/-32767) maps to +/-1.0f. + * + *

Package-visible for testing. + * + * @param src source samples; must be at least {@code length} long + * @param length number of samples to convert + * @param dest destination; must be at least {@code length} long + */ + static void int16ToFloat(short[] src, int length, float[] dest) { + for (int i = 0; i < length; i++) { + dest[i] = src[i] / 32767.0f; + } + } + /** * Returns a safe buffer size for AudioRecord. If {@code rawSize} is an * error value ({@code <= 0}), returns {@link #FALLBACK_BUFFER_SIZE}. diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/wave/MicRecorderInt16ToFloatTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/wave/MicRecorderInt16ToFloatTest.java new file mode 100644 index 000000000..9047a1010 --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/wave/MicRecorderInt16ToFloatTest.java @@ -0,0 +1,58 @@ +package com.k1af.ft8af.wave; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; + +/** + * Tests {@link MicRecorder#int16ToFloat} — the PCM16-to-float conversion the + * capture reader loop applies to every buffer now that {@code MicRecorder} + * requests {@code ENCODING_PCM_16BIT} instead of {@code ENCODING_PCM_FLOAT} + * (see that field's own comment for the real-hardware reason: requesting + * float capture from a USB input silently produced unusable samples on the + * MediaTek audio HAL, confirmed by reproducing the fix/break twice in a + * single app process with everything else held constant). + */ +public class MicRecorderInt16ToFloatTest { + + @Test + public void convertsFullScalePositive() { + short[] src = {32767}; + float[] dest = new float[1]; + MicRecorder.int16ToFloat(src, 1, dest); + assertThat(dest[0]).isEqualTo(1.0f); + } + + @Test + public void convertsFullScaleNegative() { + // -32768 is short's true minimum, but float2Short()'s own inverse + // scale (elsewhere in the app, the TX path) uses 32767.0 as the + // divisor for both directions, so -32768 lands just past -1.0f — + // matching that existing convention rather than a separate one here. + short[] src = {-32767}; + float[] dest = new float[1]; + MicRecorder.int16ToFloat(src, 1, dest); + assertThat(dest[0]).isEqualTo(-1.0f); + } + + @Test + public void convertsSilence() { + short[] src = {0}; + float[] dest = new float[1]; + MicRecorder.int16ToFloat(src, 1, dest); + assertThat(dest[0]).isEqualTo(0.0f); + } + + @Test + public void onlyConvertsUpToLength_leavesRestOfDestUntouched() { + // The reader loop passes bufferReadResult, not the full buffer length + // (a short/partial read is normal) — dest entries past that count + // must be left alone, not zeroed or garbage-filled. + short[] src = {32767, 32767, 32767}; + float[] dest = {-9f, -9f, -9f}; + MicRecorder.int16ToFloat(src, 1, dest); + assertThat(dest[0]).isEqualTo(1.0f); + assertThat(dest[1]).isEqualTo(-9f); + assertThat(dest[2]).isEqualTo(-9f); + } +} From c7f2c4c0a8602fbe3b6de2a8f5f2bb20cc95999c Mon Sep 17 00:00:00 2001 From: quadra Date: Thu, 22 Oct 2026 05:47:52 +0000 Subject: [PATCH 22/36] Document the real RX root cause: float capture breaks on this USB HAL Supersedes the I/Q-mode and OS-level-USB-claim leads with the actual cause, found via a precise A/B (same process, mode held constant, HAL-negotiated format alone flipping decode behavior in lockstep, twice). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf --- docs/qmx-hamlib-status.md | 131 +++++++++++++++++--------------------- 1 file changed, 59 insertions(+), 72 deletions(-) diff --git a/docs/qmx-hamlib-status.md b/docs/qmx-hamlib-status.md index 1f520be23..84197a4a2 100644 --- a/docs/qmx-hamlib-status.md +++ b/docs/qmx-hamlib-status.md @@ -121,81 +121,68 @@ silent. `FT_MODE` is back to `"RTTY"` (Digi, the QMX's correct intended mode for FT8), per `HamlibRig.java`'s own comment, which documents this full dead-end for future reference. -### Current state and the real remaining lead +### Several real leads chased and ruled out before the actual cause With both of the above bugs fixed, USB capture confirmed genuinely healthy (MediaTek `AudioALSACaptureHandlerUsb`/`AudioALSACaptureDataProviderUsb` -opening correctly, 48kHz/24-bit stereo, thousands of continuous reads, zero -`xrun`/`overrun`/error lines, correct device id, correct CAT/frequency), -decode cycles were still consistently fast (~140ms, the empty-audio -fingerprint vs. ~1100ms when real audio is present) with zero messages kept, -across multiple clean fresh-launch tests and both CAT modes. - -**The clearest lead found all night**: a third-party app, QFT8 (known from -elsewhere in this project to work correctly against this exact -phone+radio), decodes normally and immediately whenever it's given the -device. Directly observed on real hardware, reproduced twice independently -(not a one-off): audio was flowing (visible as waterfall traces) through the -*OS's own* default handling of the QMX's USB Audio Class interface in the -window right after a power-cycle, *before* the Android "choose an app" -USB-device chooser was answered — and it stopped the instant FT8AF was -selected in that chooser, both times. hamlib's QMX-specific open -function (`qrplabs_open` in the vendored `ts480.c`) only queries firmware -version on connect, nothing audio-related, so this isn't something FT8AF's -CAT traffic does — it looks like simply Android granting FT8AF raw -`UsbDeviceConnection` access to the composite device detaches it from -whatever OS-level audio path was supplying real samples, independent of -which audio-input option is selected in Settings and independent of CAT -mode. QFT8 apparently doesn't trigger this (or recovers from it); the -difference in exactly *how* it opens/handles the device is the open -question. Also considered and not yet ruled out: the QMX's own `AG` -(AF Gain/volume) CAT command, which hamlib's QMX backend does not implement -at all (`qrplabs_qmx_caps` has no `.has_set_level`/`.set_level`) — FT8AF -has never set this on any code path, so if a power-cycle resets it low and -QFT8's own connect sequence sets it, that would independently explain -silence-despite-healthy-capture too. The two leads aren't mutually -exclusive and both need a real logcat-instrumented test to settle. - -### The real fix (found, pushed, awaiting a clean build/test): I/Q mode left on - -Checking a related project turned the investigation around. Dan Lee's -separate `danleetw/FT8TW` app hit what reads as the identical symptom on -the QMX family: GitHub issue **danleetw/FT8TW#78** ("For QMX/QMX+ set I/Q -mode off"), closed as fixed — - -> If I/Q mode is left on, it appears that FT8TW is working, but the -> received audio is like noise. CAT command: Q90; - -This matches the QMX's own CAT manual precisely: `Q9` (IQ Mode) streams -**raw I/Q ADC samples** to the USB sound card instead of demodulated audio -when enabled — a decoder expecting normal AF audio gets garbage/silence, -while the USB capture pipeline itself stays completely healthy, exactly the -symptom chased all night through mode, device-selection, and capture- -lifecycle bugs. It's session-only (not written to EEPROM per the manual), -which fits the whole night's pattern: if this unit's I/Q mode was left on -via its own front-panel menu from earlier SDR use, it would default back on -every power-cycle, and only an app that explicitly disables it on connect -would ever decode again. FT8AF has never sent this command on any code -path. - -A direct side-by-side test on this exact phone+radio confirmed it: FT8TW -(which already carries Dan's fix) decoded normally; FT8AF, run immediately -after under the same radio state, did not. (One earlier FT8TW attempt in -this same session looked like a counter-example — silent right after the -app was granted the device — but did not reproduce on a second try, which -decoded fine; the live-signal/power-cycle environment made single data -points noisy all night, which is part of why this took so long to pin -down.) - -**Fixed and pushed** (`12815ecb`): `HamlibRig` now sends `"Q90;"` directly -over the connector once per successful open — hamlib has no generic API for -this QMX vendor-specific extended command, so it bypasses hamlib the same -way the (correctly reverted) `Q41;` experiment did, but for a real, -externally-validated command this time. Covered by a new unit test -(`HamlibRigTest`) for the model-id gate and the exact bytes sent; the actual -real-hardware confirmation is still pending a clean build/test cycle on the -Windows laptop (offline at the moment this was written — message is queued -for when it reconnects). +opening correctly, continuous reads, zero `xrun`/`overrun`/error lines, +correct device id, correct CAT/frequency), decode cycles were still +consistently fast (~140ms, the empty-audio fingerprint vs. ~1100ms when real +audio is present) with zero messages kept, across multiple clean +fresh-launch tests. Two more theories were seriously chased and eventually +ruled out by sharper evidence, in order: + +- **OS-level USB-claim theory**: reproduced twice that audio flows through + Android's own default USB-audio handling right up until an app (FT8AF, and + also briefly FT8TW) is granted the device via the "choose an app" chooser, + then stops. Superseded below — not wrong exactly, but not the actual + mechanism; see the real cause. +- **I/Q mode left on**: `danleetw/FT8TW` issue #78 ("For QMX/QMX+ set I/Q + mode off", closed as fixed) describes what reads as this exact symptom — + the QMX's `Q9`/IQ-mode CAT setting streams raw ADC I/Q samples to the USB + sound card instead of demodulated audio when left on. A side-by-side test + showed FT8TW (which carries Dan's `Q90;` fix) decoding while FT8AF + (fixed, pushed as `12815ecb`, sends `Q90;` on every connect) did not — + **but a later, much more precise test (below) directly disproved I/Q mode + as this session's actual cause**: the radio decoded fine in Digi/MD6 with + no `Q90;` ever sent. The `Q90;` fix is being kept anyway since it's a real, + externally-validated QMX fix for the scenario in that issue — just not + what was happening on this unit tonight. + +### The actual root cause, found and fixed: requesting float capture breaks on this USB audio HAL + +A precise, repeated real-hardware A/B, with mode and every other variable +held constant within a single running app process, found it: Android's own +audio HAL log (`AudioALSAStreamIn`'s `set()` call) showed two different +formats being negotiated across different opens of the *same* USB input — +`fmt=5` (`ENCODING_PCM_FLOAT`, what `MicRecorder` was requesting) and +`fmt=1` (`ENCODING_PCM_16BIT`). Every single `fmt=5` open was silent +(zero messages kept, ~140ms decode fingerprint); every single `fmt=1` open +decoded normally (real messages, ~1100ms decode fingerprint) — including +two cases where the *same app process* flipped between the two, unprompted, +across a USB re-enumeration, with CAT mode held at Digi/MD6 the entire time +and decoding starting or stopping in exact lockstep with the format. The +HAL's own log line explains why: `"wrong format(0x5), default use +wordSize = 2"` — it silently falls back to a 16-bit internal word size while +still being asked to hand back 4-byte floats, producing unusable samples +with no error surfaced anywhere. + +This also directly explains the two superseded leads above: QFT8 and FT8TW +decoding reliably is consistent with them requesting PCM16 (or getting it +some other way) rather than float; the "audio dies the instant an app +claims the device" observation was likely this same format mismatch +happening to coincide with FT8AF's/FT8TW's own connect sequence, not a +genuine Android USB-claim side effect. + +**Fixed and pushed** (`38f8e9d8`): `MicRecorder` now requests +`ENCODING_PCM_16BIT` (matching a commented-out line already present in the +source, suggesting this was the original format before an earlier, +unrelated change switched it to float) and converts to float in the reader +loop via a new `int16ToFloat()` helper, matching the existing +`float2Short()` inverse scale already used on the TX side. Covered by a new +unit test file (`MicRecorderInt16ToFloatTest`). Real-hardware confirmation +is pending the next clean build/test cycle on the Windows laptop (offline +at the moment this was written — message is queued for when it reconnects). ## Known issue, not yet root-caused (TX, from 2026-10-21, not re-tested) From 7142484ed60f5ba712e5c145e36777722d0cab2c Mon Sep 17 00:00:00 2001 From: quadra Date: Thu, 22 Oct 2026 05:49:43 +0000 Subject: [PATCH 23/36] Confirm RX fix on real hardware: real FT8 decodes Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf --- docs/qmx-hamlib-status.md | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/docs/qmx-hamlib-status.md b/docs/qmx-hamlib-status.md index 84197a4a2..80eec005f 100644 --- a/docs/qmx-hamlib-status.md +++ b/docs/qmx-hamlib-status.md @@ -6,15 +6,17 @@ hamlib's real `qrplabs_qmx_caps` instead of hand-rolled Kenwood emulation. Full narrative, with logs, is in the PR #925 comment thread; this is the condensed current state. -## Confirmed working (real hardware, 2026-10-21) +## Confirmed working (real hardware) - `rigaddress.txt` entry (`Qrp Labs QMX (Hamlib),809,9600,26`) resolves to hamlib's real `RIG_MODEL_QRPLABS_QMX` (`0x809` = 2057) and opens correctly. - CAT works end-to-end: frequency set from the app matches the radio's own displayed frequency after a live change (tested at 7.074 MHz and - 14.074 MHz). + 14.074 MHz). (2026-10-21) - Mode stays in Digital through a frequency change (previously it reverted to - USB every time). + USB every time). (2026-10-21) +- **RX decodes real FT8 traffic** (2026-10-22, `38f8e9d8`): confirmed on real + hardware after the PCM16-capture fix below. ## Bugs found and fixed, in order @@ -180,9 +182,14 @@ source, suggesting this was the original format before an earlier, unrelated change switched it to float) and converts to float in the reader loop via a new `int16ToFloat()` helper, matching the existing `float2Short()` inverse scale already used on the TX side. Covered by a new -unit test file (`MicRecorderInt16ToFloatTest`). Real-hardware confirmation -is pending the next clean build/test cycle on the Windows laptop (offline -at the moment this was written — message is queued for when it reconnects). +unit test file (`MicRecorderInt16ToFloatTest`). + +**CONFIRMED on real hardware (2026-10-22)**: real FT8 decodes after +installing this build — the RX investigation that ran all night is +resolved. (A full multi-cycle log pull to double-confirm `audio_format 1` +with no `"wrong format(0x5)"` warning was requested but not yet seen in +this conversation; the live waterfall decode result is itself the +operationally meaningful confirmation.) ## Known issue, not yet root-caused (TX, from 2026-10-21, not re-tested) From 59dbe265a679c28cd8ad2398850000b4b209cb16 Mon Sep 17 00:00:00 2001 From: quadra Date: Thu, 22 Oct 2026 05:51:18 +0000 Subject: [PATCH 24/36] Fully confirm RX fix (7 decode cycles, fmt=1 throughout); note follow-up Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf --- docs/qmx-hamlib-status.md | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/docs/qmx-hamlib-status.md b/docs/qmx-hamlib-status.md index 80eec005f..56befeb7e 100644 --- a/docs/qmx-hamlib-status.md +++ b/docs/qmx-hamlib-status.md @@ -184,12 +184,21 @@ loop via a new `int16ToFloat()` helper, matching the existing `float2Short()` inverse scale already used on the TX side. Covered by a new unit test file (`MicRecorderInt16ToFloatTest`). -**CONFIRMED on real hardware (2026-10-22)**: real FT8 decodes after -installing this build — the RX investigation that ran all night is -resolved. (A full multi-cycle log pull to double-confirm `audio_format 1` -with no `"wrong format(0x5)"` warning was requested but not yet seen in -this conversation; the live waterfall decode result is itself the -operationally meaningful confirmation.) +**CONFIRMED on real hardware (2026-10-22)**: 7 consecutive decode cycles +with real content, every HAL USB capture open at `fmt=1` (PCM16), zero +`"wrong format(0x5)"` warnings across the whole session, radio in Digi mode +(`MD6;`) with `Q90;` also sent on connect per the earlier fix. The RX +investigation that ran all night is fully resolved. + +**Known follow-up, not urgent (self-heals)**: the QMX's Android +`AudioDeviceInfo` id changes across every USB replug/re-enumeration (seen +again this session: `id=525` → `deviceFound=false` → operator re-picks → +`id=677` → `deviceFound=true`). `findAudioDeviceById()`/the Settings picker +match by that numeric id, which isn't stable across reconnects, so a stale +persisted id silently falls back to no device until the operator manually +re-selects it in Settings. Matching by product name (or VID/PID, where +available) as a fallback when the persisted id isn't found would remove the +need for that manual re-pick. ## Known issue, not yet root-caused (TX, from 2026-10-21, not re-tested) From 4f44820e8f18de6a2afaa8371dc1422d9b9cca06 Mon Sep 17 00:00:00 2001 From: quadra Date: Thu, 22 Oct 2026 20:06:48 +0000 Subject: [PATCH 25/36] Address Patrick's PR review: CI fix + findings 2-5 Fixes the review on PR #925 (head 59dbe265): - CI fix: HamlibRigTest's recordingConnector() must pass ControlMode.CAT to BaseRigConnector's only constructor (BaseRigConnector() doesn't exist) -- this was failing :app:compileDebugUnitTestJavaWithJavac. - Finding 2: FT_MODE was global, so every hamlib rig (not just the QMX) got RIG_MODE_RTTY, which is wrong for e.g. the FT-891 (needs PKTUSB/DATA-USB). Split into FT_MODE="PKTUSB" (default) and QMX_FT_MODE="RTTY" (QMX only) via HamlibRig.ftModeFor(modelId). - Findings 3/4: nativeRigOpen() tore down and freed the bridge itself on rig_open() failure, racing nativeFeedFromRig on the serial thread (finding 3); onDisconnecting() read/cleared the `handle` field directly on the caller's thread, racing an in-flight ensureOpen() on the worker thread (finding 4) -- which could leak a handle ensureOpen was about to publish, or let a later ensureOpen() recreate one the queued close could never reach. Fixed by: (a) nativeRigOpen() now just returns rc and leaves the bridge alive either way, with an `opened` bool on HamlibBridge so nativeClose() only rig_close()s a rig that actually opened; (b) HamlibRig now frees exclusively through a new releaseHandle(), always run on the single worker thread (ensureOpen's failure path, and onDisconnecting() via worker.submit instead of touching `handle` inline), under a ReentrantReadWriteLock's write lock, with onReceiveData's feed taking the read lock. Added a HamlibRig.NativeApi seam (with a REAL_NATIVE_API delegating to HamlibNative) so these races are deterministically testable on the JVM via a fake implementation, since HamlibNative.isAvailable() is hard-wired false there. - Finding 5: the separate "hamlibModel" config key (GeneralVariables.hamlibModelId) was written for every rig pick including non-hamlib ones (storing a CI-V address there) and left unpersisted by the legacy ConfigFragment path, so a stale or wrong value could silently win after a restart. Removed it entirely; the hamlib model id is now derived fresh from the selected rig entry at connect time via the new HamlibRig.modelIdFor(). Tests: HamlibRigTest gets ftModeFor/modelIdFor coverage plus two new tests driving the NativeApi fake to exercise the open/close races directly; DatabaseOprConfigHydrationTest drops the two tests for the now-removed hamlibModel config key. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf --- .../src/main/cpp/ft8af_glue/hamlib_jni.cpp | 37 ++-- .../java/com/k1af/ft8af/GeneralVariables.java | 10 - .../java/com/k1af/ft8af/MainViewModel.java | 38 +--- .../com/k1af/ft8af/database/DatabaseOpr.java | 5 - .../java/com/k1af/ft8af/rigs/HamlibRig.java | 205 +++++++++++++----- .../com/k1af/ft8af/ui/ConfigFragment.java | 4 - .../com/k1af/ft8af/wave/HamlibNative.java | 7 +- .../ft8af/ui/settings/RadioAudioSettings.kt | 7 - .../DatabaseOprConfigHydrationTest.java | 35 --- .../com/k1af/ft8af/rigs/HamlibRigTest.java | 169 ++++++++++++++- 10 files changed, 350 insertions(+), 167 deletions(-) diff --git a/ft8af/app/src/main/cpp/ft8af_glue/hamlib_jni.cpp b/ft8af/app/src/main/cpp/ft8af_glue/hamlib_jni.cpp index 8798beb0f..86e50ea2d 100644 --- a/ft8af/app/src/main/cpp/ft8af_glue/hamlib_jni.cpp +++ b/ft8af/app/src/main/cpp/ft8af_glue/hamlib_jni.cpp @@ -65,6 +65,7 @@ struct HamlibBridge { jobject sink = nullptr; // global ref to HamlibNative.HamlibSink jmethodID onBytesToRig = nullptr; int model_id = 0; // for logging only; set in nativeCreate, read in nativeRigOpen + bool opened = false; // rig_open() succeeded; nativeClose only rig_close()s then }; // ---- pump thread: hamlib's outbound CAT bytes -> Java sink ------------------ @@ -207,13 +208,16 @@ Java_com_k1af_ft8af_wave_HamlibNative_nativeCreate(JNIEnv *env, jclass, * handle to HamlibRig.handle before calling this, so nativeFeedFromRig can * deliver bytes the radio sends during rig_open()'s own handshake. * - * Returns RIG_OK (0) on success, or a (negative) hamlib error code. On - * failure the bridge is fully torn down and freed here, same as the - * original single-call nativeOpen's failure path — the handle must not be - * used again by the caller after a non-zero return. + * Returns RIG_OK (0) on success, or a (negative) hamlib error code. Either + * way the bridge is left alive: the caller (HamlibRig) owns releasing it via + * nativeClose, exactly once, never concurrently with nativeFeedFromRig. A + * free done here on failure raced the serial read thread, which can still be + * inside nativeFeedFromRig(handle, …) on this same handle — the radio is + * actively replying to the handshake when rig_open() fails, so that race was + * not theoretical. */ JNIEXPORT jint JNICALL -Java_com_k1af_ft8af_wave_HamlibNative_nativeRigOpen(JNIEnv *env, jclass, +Java_com_k1af_ft8af_wave_HamlibNative_nativeRigOpen(JNIEnv *, jclass, jlong handle) { auto *b = handle_to_bridge(handle); if (b == nullptr) return -1; @@ -221,23 +225,9 @@ Java_com_k1af_ft8af_wave_HamlibNative_nativeRigOpen(JNIEnv *env, jclass, int rc = rig_open(b->rig); if (rc != RIG_OK) { LOGE("rig_open failed: %d (%s)", rc, rigerror(rc)); - b->running = false; - // The pump thread may still be blocked in accept() (hamlib never - // connected). Close the listener FIRST to release accept(); if it did - // connect, shutting down srv_fd releases a blocked read(). Only then is - // pthread_join safe — otherwise it would hang forever. - int lfd = b->listen_fd.exchange(-1); - if (lfd >= 0) { shutdown(lfd, SHUT_RDWR); close(lfd); } - int sfd = b->srv_fd.load(); - if (sfd >= 0) shutdown(sfd, SHUT_RDWR); - pthread_join(b->pump, nullptr); - rig_cleanup(b->rig); - env->DeleteGlobalRef(b->sink); - sfd = b->srv_fd.load(); - if (sfd >= 0) close(sfd); - delete b; - return rc; + return rc; // caller still owns the bridge and frees it via nativeClose } + b->opened = true; LOGI("hamlib rig %d opened", b->model_id); return RIG_OK; } @@ -322,7 +312,10 @@ Java_com_k1af_ft8af_wave_HamlibNative_nativeClose(JNIEnv *env, jclass, jlong han int sfd = b->srv_fd.load(); if (sfd >= 0) shutdown(sfd, SHUT_RDWR); if (b->pump) pthread_join(b->pump, nullptr); - if (b->rig) { rig_close(b->rig); rig_cleanup(b->rig); } + if (b->rig) { + if (b->opened) rig_close(b->rig); + rig_cleanup(b->rig); + } sfd = b->srv_fd.load(); if (sfd >= 0) close(sfd); if (b->sink) env->DeleteGlobalRef(b->sink); diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java b/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java index b4da63852..d0ee31b75 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java @@ -545,16 +545,6 @@ public static Context getMainContext() { public static int iaruRegion = 2;//Operator's IARU region (1/2/3), used to gate Message-Creator QSY frequency options to legal band edges. Default 2 (the Americas). See com.k1af.ft8af.message.SpecialMessage. public static boolean autoCQAfterQSO = false;//Auto-CQ: keep calling CQ after each completed QSO (chain without re-tapping). Refreshes the TX watchdog per QSO and forces pure CQ (ignores Hunt). public static int civAddress = 0xa4;//CI-V address - /** - * hamlib model ID for Hamlib-backed rigs (instructionSet == HAMLIB), e.g. - * 2057 for Qrp Labs QMX. Deliberately separate from {@link #civAddress}: - * that field (and its {@code CivAddressConfig} persistence) is a genuine - * single-byte ICOM CI-V address (0x00-0xFF); a hamlib model ID routinely - * exceeds a byte (2057 = 0x809), and persisting it through civAddress's - * byte-masked encode/decode silently truncated it (2057 -> 9, opening the - * wrong hamlib backend entirely on the next app launch). - */ - public static int hamlibModelId = 0;//hamlib model ID, see rigaddress.txt's address column for HAMLIB rigs public static String civAddressStored = null;//Raw "civ" config text as hydrated (null = no row); lets the #753 repair see whether the on-disk form is canonical hex. public static boolean civAddressFormatKnown = false;//True when config "civFormat=hex" was present: the stored civ value is trusted verbatim, no model reconciliation (#753). public static int baudRate = 19200;//Baud rate 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 3b9328dd3..2e2351ffb 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java @@ -2436,34 +2436,20 @@ private void connectRig() { case InstructionSet.KENWOOD_TS440: baseRig = new KenwoodTS440Rig();//KENWOOD TS-440S (TS-570 CAT, USB mode) break; - case InstructionSet.HAMLIB: + case InstructionSet.HAMLIB: { // hamlib model number is carried in the rig table's address column - // (parsed base-16), e.g. FT-891 = 1036 = 0x40C. Uses hamlibModelId, - // not civAddress -- civAddress's persistence is byte-masked (a real - // ICOM CI-V address is a byte; a hamlib model ID routinely isn't), - // which silently truncated it (2057 -> 9) on every app restart. - if (GeneralVariables.hamlibModelId <= 0) { - // One-time migration for an install that selected a Hamlib rig - // before the "hamlibModel" config key existed (or any other way - // it ended up unset) -- self-heal from the still-correct selected - // rig entry rather than leaving it at the unset default, which - // HamlibRig.ensureOpen() now refuses to open at all (a real - // model-0 hand-off previously aborted the whole process). - android.content.Context ctx = GeneralVariables.getMainContext(); - if (ctx != null) { - RigNameList.RigName model = RigNameList.getInstance(ctx) - .getRigNameByIndex(GeneralVariables.modelNo); - if (model != null && model.address > 0) { - GeneralVariables.hamlibModelId = model.address; - if (databaseOpr != null) { - databaseOpr.writeConfig("hamlibModel", - String.valueOf(model.address), null); - } - } - } - } - baseRig = new HamlibRig(GeneralVariables.hamlibModelId); + // (parsed base-16), e.g. FT-891 = 1036 = 0x40C. Derived fresh from the + // selected rig entry (HamlibRig.modelIdFor), not persisted separately -- + // a separate "hamlibModel" config key existed briefly but could go stale + // or hold a non-hamlib address (civAddress's own persistence is byte- + // masked, a real ICOM CI-V address being a byte where a hamlib model ID + // routinely isn't, so it can't carry this value either). + android.content.Context ctx = GeneralVariables.getMainContext(); + RigNameList.RigName selected = ctx == null ? null + : RigNameList.getInstance(ctx).getRigNameByIndex(GeneralVariables.modelNo); + baseRig = new HamlibRig(HamlibRig.modelIdFor(selected)); break; + } } // Store the rig name for PSKReporter software string. diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java index 655b08a0a..6f69c32ae 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java @@ -2772,11 +2772,6 @@ protected Void doInBackground(Void... voids) { .decode(result, com.k1af.ft8af.rigs.CivAddressConfig.DEFAULT_ADDRESS); GeneralVariables.civAddressStored = result; } - if (name.equalsIgnoreCase("hamlibModel")) { - // Plain decimal, not hex, and not byte-masked -- see civAddress's - // javadoc in GeneralVariables for why this needs its own key. - GeneralVariables.hamlibModelId = parseConfigInt(result, 0); - } if (name.equalsIgnoreCase(com.k1af.ft8af.rigs.CivAddressConfig.FORMAT_KEY)) { // Provenance marker: present only when a hex-aware writer stored "civ". GeneralVariables.civAddressFormatKnown = diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java index 81b811a5e..e0eb2ed3d 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java @@ -3,11 +3,13 @@ import android.util.Log; import com.k1af.ft8af.connector.BaseRigConnector; +import com.k1af.ft8af.database.RigNameList; import com.k1af.ft8af.wave.HamlibNative; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.locks.ReentrantReadWriteLock; /** * A {@link BaseRig} backed by hamlib instead of a hand-rolled per-manufacturer @@ -28,37 +30,44 @@ public class HamlibRig extends BaseRig { private static final String TAG = "HamlibRig"; /** - * hamlib mode string for FT8/FT4. NOT "PKTUSB": hamlib's generic Kenwood - * mode table (rigs/kenwood/kenwood.c's kenwood_mode_table, which the QMX - * backend falls back to since it sets no override) maps RIG_MODE_PKTUSB - * to table index 13, which kenwood_set_mode() then encodes as the ASCII - * character 'D' ('A' + 13 - 10) -- producing "MDD;". The QMX's own CAT - * manual (qrp-labs.com/images/qmx/manuals/cat_1_04_003.pdf) only accepts - * single-digit MD values (1,2,3,5,6,7,8,9); 'D' is not one of them and is - * silently rejected, so the radio's mode was never actually being set at - * all via this path. Table index 6 holds RIG_MODE_RTTY ("FSK Mode" per - * that table's own comment) -- the QMX manual's own "6 (FSK)", its real - * digital mode -- which, like index 2 (RIG_MODE_USB), is <= 9 so - * kenwood_set_mode() encodes it as a literal digit ("MD6;"), not a letter. - * - *

Briefly swapped to "USB" (MD2;) during the same hardware session that - * found this, after the QMX's own display showed no S-meter response in - * Digi/FSK mode where it showed one in USB mode -- looked like FSK was - * disabling the receive chain FT8 needs. Turned out to be a false lead: - * the QMX simply doesn't draw an S-meter widget on its Digi-mode screen - * at all (confirmed by the operator switching modes at the radio's own - * front panel, independent of the app), not a no-signal indication. RX - * was separately confirmed decoding normally in both USB and Digi mode - * once reconnected with the app's actual bug fixed (see - * {@code RadioAudioSettings.kt}'s audio-input-picker reinitialize call) -- - * mode was never the cause. Reverted to "RTTY" since Digi is the QMX's - * correct, intended mode for a digital mode like FT8. + * hamlib mode for FT8/FT4 on most rigs: DATA-USB, so audio routes through + * the data path rather than a mic/voice path. */ - private static final String FT_MODE = "RTTY"; + private static final String FT_MODE = "PKTUSB"; + + /** + * QMX override: hamlib's generic Kenwood mode table (rigs/kenwood/kenwood.c's + * kenwood_mode_table) encodes RIG_MODE_PKTUSB as table index 13, which + * kenwood_set_mode() turns into the ASCII letter 'D' -- "MDD;". The QMX's own + * CAT manual (cat_1_04_003.pdf) only accepts single-digit MD values + * (1,2,3,5,6,7,8,9) and silently rejects 'D', so PKTUSB never actually set the + * mode on this radio. "RTTY" is table index 6 (RIG_MODE_RTTY, <= 9, encoded + * as a literal digit "MD6;"), the QMX manual's own "6 (FSK)" -- its real Digi + * mode. Applying this to every hamlib rig (not just the QMX) would send + * RIG_MODE_RTTY to rigs that expect PKTUSB, e.g. an FT-891 via newcat would + * get "MD06" (RTTY-LSB) instead of DATA-USB -- wrong mode and filter. + */ + private static final String QMX_FT_MODE = "RTTY"; + + /** Which {@link #FT_MODE} string to send for a given hamlib model id. */ + static String ftModeFor(int modelId) { + return modelId == QRPLABS_QMX_MODEL_ID ? QMX_FT_MODE : FT_MODE; + } private final int modelId; + private final NativeApi nativeApi; private final ExecutorService worker = Executors.newSingleThreadExecutor(); private volatile long handle = 0; + /** + * Guards {@link #handle} against the one genuine cross-thread race: a free + * ({@link #releaseHandle()}, on the single {@link #worker} thread) running + * concurrently with a feed ({@link #onReceiveData}, on the serial read + * thread) on the same native handle. {@code ensureOpen()}/{@code + * releaseHandle()} never race each other -- both only ever run via {@link + * #worker}, a single-thread executor -- so publishing a freshly opened + * handle needs no lock; only freeing it does. + */ + private final ReentrantReadWriteLock bridgeLock = new ReentrantReadWriteLock(); /** * Set once in {@link #onDisconnecting()}. After that the {@link #worker} * executor has been shut down, so any further {@code worker.submit(...)} @@ -84,9 +93,54 @@ public class HamlibRig extends BaseRig { }; public HamlibRig(int modelId) { + this(modelId, REAL_NATIVE_API); + } + + /** Visible for tests: inject a fake native layer to drive races on the JVM. */ + HamlibRig(int modelId, NativeApi nativeApi) { this.modelId = modelId; + this.nativeApi = nativeApi; } + /** + * Seam over {@link HamlibNative}'s static natives. {@code HamlibNative} + * can't be exercised from a JVM unit test (no {@code libft8af.so}, and + * {@link HamlibNative#isAvailable()} is hard-wired to {@code false} there), + * so real races like findings 3/4 (use-after-free on a failed open, + * double-free/leak on a disconnect that overlaps an open) were otherwise + * untestable outside real hardware. A fake implementation lets tests drive + * {@link #ensureOpen()}/{@link #releaseHandle()} directly. + */ + interface NativeApi { + boolean isAvailable(); + long nativeCreate(int modelId, HamlibNative.HamlibSink sink); + int nativeRigOpen(long handle); + void nativeFeedFromRig(long handle, byte[] data); + int nativeSetFreq(long handle, long hz); + long nativeGetFreq(long handle); + int nativeSetMode(long handle, String mode); + int nativeSetPtt(long handle, boolean on); + void nativeClose(long handle); + } + + private static final NativeApi REAL_NATIVE_API = new NativeApi() { + @Override public boolean isAvailable() { return HamlibNative.isAvailable(); } + @Override public long nativeCreate(int modelId, HamlibNative.HamlibSink sink) { + return HamlibNative.nativeCreate(modelId, sink); + } + @Override public int nativeRigOpen(long handle) { return HamlibNative.nativeRigOpen(handle); } + @Override public void nativeFeedFromRig(long handle, byte[] data) { + HamlibNative.nativeFeedFromRig(handle, data); + } + @Override public int nativeSetFreq(long handle, long hz) { return HamlibNative.nativeSetFreq(handle, hz); } + @Override public long nativeGetFreq(long handle) { return HamlibNative.nativeGetFreq(handle); } + @Override public int nativeSetMode(long handle, String mode) { + return HamlibNative.nativeSetMode(handle, mode); + } + @Override public int nativeSetPtt(long handle, boolean on) { return HamlibNative.nativeSetPtt(handle, on); } + @Override public void nativeClose(long handle) { HamlibNative.nativeClose(handle); } + }; + /** * Open hamlib once the transport is connected. hamlib's {@code rig_open} * performs an initial CAT handshake, so the connector must be live and @@ -105,7 +159,7 @@ private synchronized boolean ensureOpen() { return true; } BaseRigConnector c = getConnector(); - if (c == null || !c.isConnected() || !HamlibNative.isAvailable()) { + if (c == null || !c.isConnected() || !nativeApi.isAvailable()) { return false; } if (modelId <= 0) { @@ -113,12 +167,12 @@ private synchronized boolean ensureOpen() { // observed on real hardware to abort the whole process (native // SIGABRT, "pthread_mutex_lock called on a destroyed mutex") a // fraction of a second later, not just fail gracefully. 0 means - // "not yet persisted" (see GeneralVariables.hamlibModelId) and must - // never reach native code. + // "not a hamlib entry" (see HamlibRig.modelIdFor) and must never + // reach native code. Log.w(TAG, "ensureOpen: refusing to open invalid hamlib model " + modelId); return false; } - long h = HamlibNative.nativeCreate(modelId, sink); + long h = nativeApi.nativeCreate(modelId, sink); if (h == 0) { Log.w(TAG, "nativeCreate failed for hamlib model " + modelId); return false; @@ -133,11 +187,10 @@ private synchronized boolean ensureOpen() { // genuinely replying (serial.recv had real traffic) while hamlib's // own trace reported "0 chars" for that exact window, every time. handle = h; - int rc = HamlibNative.nativeRigOpen(h); + int rc = nativeApi.nativeRigOpen(h); if (rc != 0) { Log.w(TAG, "nativeRigOpen failed for hamlib model " + modelId + " rc=" + rc); - // nativeRigOpen already tore down and freed the bridge on failure. - handle = 0; + releaseHandle(); return false; } Log.i(TAG, "hamlib model " + modelId + " opened"); @@ -145,6 +198,23 @@ private synchronized boolean ensureOpen() { return true; } + /** + * Unpublish and free the bridge. Runs on {@link #worker} (so it never + * overlaps an {@link #ensureOpen()} on the same handle — both only ever + * run there) under {@link #bridgeLock}'s write lock (so it never overlaps + * a feed in {@link #onReceiveData}, which takes the read lock). + */ + private void releaseHandle() { + bridgeLock.writeLock().lock(); + try { + long h = handle; + handle = 0; + if (h != 0) nativeApi.nativeClose(h); + } finally { + bridgeLock.writeLock().unlock(); + } + } + /** * QRP Labs QMX-specific fixup, sent once right after a successful open: * force I/Q mode off ({@code "Q90;"}). hamlib has no generic API for this @@ -190,6 +260,24 @@ void disableQmxIqModeIfApplicable(BaseRigConnector c) { /** hamlib model ID for the QRP Labs QMX (0x809), per rigaddress.txt. */ private static final int QRPLABS_QMX_MODEL_ID = 0x809; + /** + * hamlib model ID for the currently-selected rig entry, or 0 if it isn't a + * hamlib entry at all. Derives it fresh from {@code rigaddress.txt}'s + * address column rather than from a separately persisted config value: a + * prior {@code hamlibModel} config key was written for every rig pick + * (including non-hamlib ones, storing e.g. an ICOM CI-V address like 0x94 + * there) and left stale by one picker path that never wrote it at all, so + * a leftover or stale value could silently win over the actually-selected + * entry after a restart and open the wrong hamlib backend. + * + * @param selected the picked rig entry (e.g. from + * {@code RigNameList.getRigNameByIndex}), or null + */ + public static int modelIdFor(RigNameList.RigName selected) { + if (selected == null || selected.instructionSet != InstructionSet.HAMLIB) return 0; + return Math.max(selected.address, 0); + } + @Override public boolean isConnected() { BaseRigConnector c = getConnector(); @@ -199,9 +287,17 @@ public boolean isConnected() { @Override public void onReceiveData(byte[] data) { // Called on the serial read thread — a quick, non-blocking socket write. - long h = handle; - if (h != 0) { - HamlibNative.nativeFeedFromRig(h, data); + // Read-locked against releaseHandle()'s write lock: without this, a feed + // here could run concurrently with the native free, using a handle whose + // bridge is mid-teardown (or already deleted) on the worker thread. + bridgeLock.readLock().lock(); + try { + long h = handle; + if (h != 0) { + nativeApi.nativeFeedFromRig(h, data); + } + } finally { + bridgeLock.readLock().unlock(); } } @@ -243,8 +339,9 @@ void awaitWorkerForTest() throws InterruptedException { public void setUsbModeToRig() { submitToWorker(() -> { if (!ensureOpen()) return; - int rc = HamlibNative.nativeSetMode(handle, FT_MODE); - if (rc != 0) Log.w(TAG, "set_mode(" + FT_MODE + ") rc=" + rc); + String mode = ftModeFor(modelId); + int rc = nativeApi.nativeSetMode(handle, mode); + if (rc != 0) Log.w(TAG, "set_mode(" + mode + ") rc=" + rc); }); } @@ -256,7 +353,7 @@ public void setFreqToRig() { final long hz = getFreq(); submitToWorker(() -> { if (!ensureOpen()) return; - int rc = HamlibNative.nativeSetFreq(handle, hz); + int rc = nativeApi.nativeSetFreq(handle, hz); if (rc != 0) Log.w(TAG, "set_freq(" + hz + ") rc=" + rc); }); } @@ -265,7 +362,7 @@ public void setFreqToRig() { public void readFreqFromRig() { submitToWorker(() -> { if (!ensureOpen()) return; - long f = HamlibNative.nativeGetFreq(handle); + long f = nativeApi.nativeGetFreq(handle); if (f > 0) { setFreq(f); // updates dial + fires CAT-liveness (BaseRig) } @@ -277,7 +374,7 @@ public void setPTT(boolean on) { super.setPTT(on); // notify listeners / meter state submitToWorker(() -> { if (!ensureOpen()) return; - int rc = HamlibNative.nativeSetPtt(handle, on); + int rc = nativeApi.nativeSetPtt(handle, on); if (rc != 0) Log.w(TAG, "set_ptt(" + on + ") rc=" + rc); }); } @@ -296,22 +393,22 @@ public void onDisconnecting() { // shutdown — would throw RejectedExecutionException and crash the app. if (closed) return; closed = true; - final long h = handle; - handle = 0; try { - worker.submit(() -> { - if (h != 0) { - HamlibNative.nativeClose(h); - } - }); + // Read/clear/close handle via releaseHandle() on the SAME worker + // thread ensureOpen() runs on, queued behind it rather than racing + // it from this (caller) thread. The previous version read/cleared + // the volatile `handle` field directly here, concurrently with the + // worker possibly being mid-ensureOpen(): depending on timing that + // either leaked the bridge ensureOpen was about to publish (this + // close captures a stale value, e.g. 0, just before ensureOpen + // overwrites it) or closed a handle a subsequent ensureOpen() had + // already reused after wrongly seeing a momentarily-zeroed `handle` + // as "not yet open" and recreating it. + worker.submit(this::releaseHandle); } catch (RejectedExecutionException e) { - // Worker already terminated, so it can't run the close for us. The - // handle is still reachable via the local h — close it inline rather - // than leaking the native rig/pump resources. + // Worker already terminated, so it can't run the release for us. Log.w(TAG, "worker rejected close task; closing handle inline"); - if (h != 0) { - HamlibNative.nativeClose(h); - } + releaseHandle(); } worker.shutdown(); } diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/ui/ConfigFragment.java b/ft8af/app/src/main/java/com/k1af/ft8af/ui/ConfigFragment.java index 300d39f60..c428eafca 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/ui/ConfigFragment.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/ui/ConfigFragment.java @@ -962,10 +962,6 @@ public void onNothingSelected(AdapterView adapterView) { */ private void setAddrAndBauRate(RigNameList.RigName rigName) { GeneralVariables.civAddress = rigName.address; - // Kept in sync with civAddress but persisted separately (RadioAudioSettings.kt - // writes its own "hamlibModel" key) -- civAddress's own persistence is - // byte-masked and truncates hamlib model IDs like the QMX's 2057. - GeneralVariables.hamlibModelId = rigName.address; mainViewModel.setCivAddress(); GeneralVariables.baudRate = rigName.bauRate; binding.civAddressEdit.setText(String.format("%X", rigName.address)); diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/wave/HamlibNative.java b/ft8af/app/src/main/java/com/k1af/ft8af/wave/HamlibNative.java index eadc94383..498675a8e 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/wave/HamlibNative.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/wave/HamlibNative.java @@ -71,9 +71,10 @@ public interface HamlibSink { * from {@link #nativeCreate}. The caller must have already published the * handle so inbound bytes during the handshake reach {@link #nativeFeedFromRig}. * - * @return 0 (RIG_OK) on success, or a hamlib error code. On any non-zero - * return the bridge has already been fully torn down and freed — - * the handle must not be used again. + * @return 0 (RIG_OK) on success, or a hamlib error code. Either way the + * bridge is left alive; the caller must release it with + * {@link #nativeClose} exactly once (success or failure), never + * concurrently with {@link #nativeFeedFromRig}. */ public static native int nativeRigOpen(long handle); diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/RadioAudioSettings.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/RadioAudioSettings.kt index b23decc05..58ba742d7 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/RadioAudioSettings.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/RadioAudioSettings.kt @@ -212,7 +212,6 @@ fun RadioAudioSettings( modelNo = actualIndex GeneralVariables.instructionSet = selectedRig.instructionSet GeneralVariables.civAddress = selectedRig.address - GeneralVariables.hamlibModelId = selectedRig.address GeneralVariables.baudRate = selectedRig.bauRate baudRate = selectedRig.bauRate mainViewModel.setCivAddress() @@ -234,12 +233,6 @@ fun RadioAudioSettings( ) GeneralVariables.civAddressStored = encodedCiv GeneralVariables.civAddressFormatKnown = true - // Separate key, plain decimal, no byte masking: a hamlib model ID (e.g. - // 2057 for the QMX) routinely exceeds a byte, unlike a real CI-V address, - // so it cannot round-trip through CivAddressConfig.encode()'s "& 0xFF". - mainViewModel.databaseOpr.writeConfig( - "hamlibModel", GeneralVariables.hamlibModelId.toString(), null, - ) }, ) } diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/database/DatabaseOprConfigHydrationTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/database/DatabaseOprConfigHydrationTest.java index 9918c09c0..d9c94f4e7 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/database/DatabaseOprConfigHydrationTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/database/DatabaseOprConfigHydrationTest.java @@ -57,7 +57,6 @@ public class DatabaseOprConfigHydrationTest { private boolean origDeepDecodeMode; private boolean origKeepScreenOn; private boolean origAutoSyncClockFromDecodes; - private int origHamlibModelId; @Before public void setUp() { @@ -78,7 +77,6 @@ public void setUp() { origDeepDecodeMode = GeneralVariables.deepDecodeMode; origKeepScreenOn = GeneralVariables.keepScreenOn; origAutoSyncClockFromDecodes = GeneralVariables.autoSyncClockFromDecodes; - origHamlibModelId = GeneralVariables.hamlibModelId; opr = new DatabaseOpr(ApplicationProvider.getApplicationContext(), null, null, 18); } @@ -105,7 +103,6 @@ public void tearDown() { GeneralVariables.deepDecodeMode = origDeepDecodeMode; GeneralVariables.keepScreenOn = origKeepScreenOn; GeneralVariables.autoSyncClockFromDecodes = origAutoSyncClockFromDecodes; - GeneralVariables.hamlibModelId = origHamlibModelId; } } @@ -459,36 +456,4 @@ public void civFormatMarker_absentOrForeign_isNotTrusted() { assertThat(GeneralVariables.civAddressFormatKnown).isFalse(); } - - /** - * Real-hardware testing on PR #925 found that persisting a hamlib model ID (e.g. the - * QMX's 2057) through {@code civ} silently truncated it to 9 on the next app launch - * ({@code CivAddressConfig.encode()} masks with {@code & 0xFF}, since a real CI-V - * address is always a byte) -- opening an unrelated hamlib backend (model 9) instead of - * the QMX. {@code hamlibModel} is a separate, plain-decimal, unmasked key specifically - * so values over a byte round-trip correctly. - */ - @Test - public void hamlibModelId_hydratesFullValue_unlikeByteMaskedCiv() { - GeneralVariables.hamlibModelId = 0; - Map config = new LinkedHashMap<>(); - config.put("hamlibModel", "2057"); // QRP Labs QMX - opr.writeConfigSync(config); - - hydrate(); - - assertThat(GeneralVariables.hamlibModelId).isEqualTo(2057); - } - - @Test - public void hamlibModelId_malformedValue_fallsBackToZero() { - GeneralVariables.hamlibModelId = 2057; - Map config = new LinkedHashMap<>(); - config.put("hamlibModel", "not-a-number"); - opr.writeConfigSync(config); - - hydrate(); // must not throw - - assertThat(GeneralVariables.hamlibModelId).isEqualTo(0); - } } diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/rigs/HamlibRigTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/rigs/HamlibRigTest.java index d0191a67e..39cfbd59a 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/rigs/HamlibRigTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/rigs/HamlibRigTest.java @@ -3,11 +3,17 @@ import static com.google.common.truth.Truth.assertThat; import com.k1af.ft8af.connector.BaseRigConnector; +import com.k1af.ft8af.database.ControlMode; +import com.k1af.ft8af.database.RigNameList; +import com.k1af.ft8af.wave.HamlibNative; import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReference; import org.junit.Test; @@ -115,7 +121,7 @@ public void taskQueuedBeforeDisconnect_doesNotRunBodyAfterClose() throws Excepti /** Captures the last byte[] written, standing in for the real serial link. */ private static BaseRigConnector recordingConnector(AtomicReference sent) { - return new BaseRigConnector() { + return new BaseRigConnector(ControlMode.CAT) { @Override public synchronized void sendData(byte[] data) { sent.set(data); @@ -143,4 +149,165 @@ public void disableQmxIqModeIfApplicable_isNoOp_forOtherModelIds() { rig.disableQmxIqModeIfApplicable(recordingConnector(sent)); assertThat(sent.get()).isNull(); } + + // ---- finding 2: FT_MODE must be per-model, not global ---------------- + + @Test + public void ftModeFor_qmx_isRTTY() { + // RTTY -> kenwood_mode_table index 6 -> "MD6;", the QMX's own Digi mode. + assertThat(HamlibRig.ftModeFor(0x809)).isEqualTo("RTTY"); + } + + @Test + public void ftModeFor_otherHamlibRig_isPKTUSB() { + // 1036 = Yaesu FT-891 (Hamlib). PKTUSB (DATA-USB) is correct for it; + // RTTY would wrongly select RTTY-LSB on newcat. + assertThat(HamlibRig.ftModeFor(1036)).isEqualTo("PKTUSB"); + } + + // ---- finding 5: hamlib model id derives from the selected rig entry --- + + @Test + public void modelIdFor_null_isZero() { + assertThat(HamlibRig.modelIdFor(null)).isEqualTo(0); + } + + @Test + public void modelIdFor_nonHamlibEntry_isZero() { + // An ICOM entry's address (0xA4) is a CI-V address, not a hamlib model id. + RigNameList.RigName icom = new RigNameList.RigName( + "ICOM IC-705", 0xA4, 19200, InstructionSet.ICOM); + assertThat(HamlibRig.modelIdFor(icom)).isEqualTo(0); + } + + @Test + public void modelIdFor_qmxEntry_is2057() { + RigNameList.RigName qmx = new RigNameList.RigName( + "Qrp Labs QMX (Hamlib)", 0x809, 9600, InstructionSet.HAMLIB); + assertThat(HamlibRig.modelIdFor(qmx)).isEqualTo(2057); + } + + @Test + public void modelIdFor_ft891Entry_is1036() { + RigNameList.RigName ft891 = new RigNameList.RigName( + "Yaesu FT-891 (Hamlib)", 0x40C, 38400, InstructionSet.HAMLIB); + assertThat(HamlibRig.modelIdFor(ft891)).isEqualTo(1036); + } + + // ---- findings 3/4: open/close races, exercised via a fake NativeApi --- + + /** A connector that reports itself as already connected, with no real transport. */ + private static BaseRigConnector connectedConnector() { + return new BaseRigConnector(ControlMode.CAT) { + @Override + public boolean isConnected() { + return true; + } + }; + } + + /** + * Deterministic fake for {@link HamlibRig.NativeApi}. {@code HamlibNative} + * can't drive these races at all on the JVM ({@code isAvailable()} is + * hard-wired false with no {@code libft8af.so}), so this stands in for the + * native bridge: handles are plain incrementing longs, and {@code + * duringRigOpen} can block the worker thread mid-open to let a test fire a + * concurrent {@code onDisconnecting()} at a precise, repeatable point. + */ + private static class FakeNativeApi implements HamlibRig.NativeApi { + private final AtomicLong nextHandle = new AtomicLong(1); + final List createCalls = new CopyOnWriteArrayList<>(); + final List closeCalls = new CopyOnWriteArrayList<>(); + final List setFreqCalls = new CopyOnWriteArrayList<>(); + volatile int rigOpenRc = 0; + volatile Runnable duringRigOpen; + + @Override public boolean isAvailable() { return true; } + + @Override public long nativeCreate(int modelId, HamlibNative.HamlibSink sink) { + createCalls.add(modelId); + return nextHandle.getAndIncrement(); + } + + @Override public int nativeRigOpen(long handle) { + if (duringRigOpen != null) duringRigOpen.run(); + return rigOpenRc; + } + + @Override public void nativeFeedFromRig(long handle, byte[] data) { } + + @Override public int nativeSetFreq(long handle, long hz) { + setFreqCalls.add(handle); + return 0; + } + + @Override public long nativeGetFreq(long handle) { return -1; } + + @Override public int nativeSetMode(long handle, String mode) { return 0; } + + @Override public int nativeSetPtt(long handle, boolean on) { return 0; } + + @Override public void nativeClose(long handle) { closeCalls.add(handle); } + } + + @Test + public void ensureOpen_failedRigOpen_releasesHandleExactlyOnce() throws Exception { + // Finding 3: nativeRigOpen() no longer frees the bridge itself on failure + // (that used to race nativeFeedFromRig on the serial thread); HamlibRig + // must release it exactly once via releaseHandle()/nativeClose instead. + FakeNativeApi fake = new FakeNativeApi(); + fake.rigOpenRc = -1; // force rig_open() failure + HamlibRig rig = new HamlibRig(2057, fake); + rig.setConnector(connectedConnector()); + + rig.setFreqToRig(); + CountDownLatch drained = new CountDownLatch(1); + rig.submitForTest(drained::countDown); // FIFO marker: runs after setFreqToRig's task + assertThat(drained.await(5, TimeUnit.SECONDS)).isTrue(); + + assertThat(fake.createCalls).containsExactly(2057); + assertThat(fake.closeCalls).containsExactly(1L); + assertThat(fake.setFreqCalls).isEmpty(); // open failed, so the CAT call itself never ran + } + + @Test + public void onDisconnecting_duringInFlightOpen_releasesExactlyOnceWithNoClobber() throws Exception { + // Finding 4: the old onDisconnecting() read/cleared the `handle` field + // directly on the caller's thread, racing the worker thread mid-ensureOpen() + // -- depending on timing that either leaked the bridge ensureOpen was about + // to publish, or let a later ensureOpen() recreate a handle the queued close + // could never release. Now onDisconnecting() only ever submits releaseHandle() + // to the SAME single worker thread ensureOpen() runs on, so the two can never + // interleave -- the queued close always runs strictly after the open finishes. + FakeNativeApi fake = new FakeNativeApi(); + CountDownLatch openStarted = new CountDownLatch(1); + CountDownLatch openMayFinish = new CountDownLatch(1); + fake.duringRigOpen = () -> { + openStarted.countDown(); + try { + openMayFinish.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }; + fake.rigOpenRc = 0; // the open succeeds once released below + + HamlibRig rig = new HamlibRig(2057, fake); + rig.setConnector(connectedConnector()); + + rig.setFreqToRig(); // worker: nativeCreate -> publish handle=1 -> blocks in nativeRigOpen + assertThat(openStarted.await(5, TimeUnit.SECONDS)).isTrue(); + + // "Disconnect" fires here, concurrently with the in-flight open, from this + // (non-worker) thread -- exactly the scenario finding 4 describes. + rig.onDisconnecting(); + + openMayFinish.countDown(); + rig.awaitWorkerForTest(); + + // The open completed using the real handle (1), unclobbered by the + // concurrent disconnect, and the handle was released exactly once. + assertThat(fake.setFreqCalls).containsExactly(1L); + assertThat(fake.closeCalls).containsExactly(1L); + } } From 07dd884fe3138728b8a846fb8171b17ec9ece43d Mon Sep 17 00:00:00 2001 From: quadra Date: Thu, 22 Oct 2026 20:18:32 +0000 Subject: [PATCH 26/36] Address findings 6, 9, 10: drop temp diagnostics, trim/merge comments, drop status doc - Finding 6: removed the leftover temporary diagnostics added while chasing the handshake/RX bugs -- CableSerialPort.onNewData's per-read hex dump (fileLog on every serial byte, hot path for all CAT rigs, unrotated file), hamlib_jni.cpp's pump-thread hex dump, and HamlibRig's sink "dropping bytes" warning. Kept the one-shot preferredDevice/configLoaded logs. - Finding 9: trimmed the long debugging-narrative comments on disableQmxIqModeIfApplicable and MicRecorder's audioFormat field down to the why in a few lines; moved docs/qmx-hamlib-status.md's content into the PR #925 description (the full narrative is already in the PR comment thread) and dropped the file from the tree. - Finding 10: merged the two stacked doc comments over disableQmxIqModeIfApplicable into one (javadoc ignores all but the last one immediately preceding the declaration). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf --- docs/qmx-hamlib-status.md | 219 ------------------ .../src/main/cpp/ft8af_glue/hamlib_jni.cpp | 10 - .../k1af/ft8af/connector/CableSerialPort.java | 8 - .../java/com/k1af/ft8af/rigs/HamlibRig.java | 43 +--- .../java/com/k1af/ft8af/wave/MicRecorder.java | 20 +- 5 files changed, 13 insertions(+), 287 deletions(-) delete mode 100644 docs/qmx-hamlib-status.md diff --git a/docs/qmx-hamlib-status.md b/docs/qmx-hamlib-status.md deleted file mode 100644 index 56befeb7e..000000000 --- a/docs/qmx-hamlib-status.md +++ /dev/null @@ -1,219 +0,0 @@ -# QRP Labs QMX via the hamlib backend: status (PR #925) - -Running status for the work on branch `feat/qmx-hamlib-rig` (fork -`laniol8926/FT8AF`), adding a "Qrp Labs QMX (Hamlib)" rig entry backed by -hamlib's real `qrplabs_qmx_caps` instead of hand-rolled Kenwood emulation. -Full narrative, with logs, is in the PR #925 comment thread; this is the -condensed current state. - -## Confirmed working (real hardware) - -- `rigaddress.txt` entry (`Qrp Labs QMX (Hamlib),809,9600,26`) resolves to - hamlib's real `RIG_MODEL_QRPLABS_QMX` (`0x809` = 2057) and opens correctly. -- CAT works end-to-end: frequency set from the app matches the radio's own - displayed frequency after a live change (tested at 7.074 MHz and - 14.074 MHz). (2026-10-21) -- Mode stays in Digital through a frequency change (previously it reverted to - USB every time). (2026-10-21) -- **RX decodes real FT8 traffic** (2026-10-22, `38f8e9d8`): confirmed on real - hardware after the PCM16-capture fix below. - -## Bugs found and fixed, in order - -1. **Wrong mode string produced an invalid CAT command** (`9b5e8787`, after a - wrong first attempt in `e046e0a7`/`37897125` that was reverted). hamlib's - QMX backend has no `mode_table` override, so `kenwood_set_mode()` falls - back to the generic Kenwood table, where `RIG_MODE_PKTUSB` sits at index - 13 — encoded as the letter `'D'` (`"MDD;"`), which the QMX's own CAT - manual doesn't recognize (only single-digit `MD` values are valid; 6 = - FSK, the QMX's real digital mode). Fixed by requesting `"RTTY"` instead of - `"PKTUSB"` — `RIG_MODE_RTTY` sits at table index 6, producing the valid - `"MD6;"`. - - The reverted first attempt sent a vendor command believed to be - `"Q41;"` ("stay in digital mode") after every mode/frequency change. - Checked against QRP Labs' own official CAT manual - (`qrp-labs.com/images/qmx/manuals/cat_1_04_003.pdf`): there is no `Q41` - command. `Q4` is "Get/Set TX Rise Threshold" — `"Q41;"` was silently - setting it to 1%, an unrelated, actively harmful side effect. - -2. **Hamlib model ID silently truncated to a byte on every app restart** - (`47c8fc4e`). `GeneralVariables.civAddress` was reused for both a genuine - single-byte ICOM CI-V address and the hamlib model ID (routinely over a - byte — QMX's `2057`). Its persistence (`CivAddressConfig.encode()`) masks - with `& 0xFF`, so `2057` round-tripped through disk as `9` — opening an - unrelated hamlib backend (model 9, SDR#) instead of the QMX on every - subsequent launch. Fixed with a separate, unmasked `hamlibModelId` - field/config key. - -3. **That fix's migration gap crashed the app** (`4a76f2af`). An install that - had already selected a Hamlib rig before the new config key existed loaded - `hamlibModelId = 0`; passing model `0` to hamlib's `rig_init()` aborts the - whole process (native SIGABRT) about half a second later, not a graceful - failure. Fixed by refusing to open any `modelId <= 0` in - `HamlibRig.ensureOpen()`, plus a self-healing migration that derives the - value from the currently-selected rig on first connect. - -4. **The real structural bug: handshake replies were silently dropped** - (`d78aebe9`). `HamlibRig.onReceiveData()` only forwards radio bytes into - the native bridge once `handle != 0` — but the original single - `nativeOpen()` call only set `handle` *after* it returned, and it ran - hamlib's own `rig_open()` (including its synchronous CAT handshake) - *internally*, before returning. Every byte the radio sent back during that - handshake was dropped, so hamlib always timed out waiting for an ack that - could never arrive — confirmed on real hardware, where `serial.recv` - showed genuine traffic arriving in the exact window hamlib's own trace - reported "0 chars". Fixed by splitting the native call into - `nativeCreate()` (builds the bridge, returns a handle) and - `nativeRigOpen(handle)` (runs the handshake), with `HamlibRig.ensureOpen()` - publishing the handle in between. - -## RX: a full night's investigation (2026-10-21/22), real root cause still open - -A second real-hardware session (Motorola Edge 2022, Android 15, Windows -Remote Control laptop for build/logcat) spent the whole night chasing why RX -showed no waterfall/decodes at all, even once TX/CAT/mode were all confirmed -working the night before. Two real, confirmed, fixed bugs were found along -the way; RX is still not working by the end of the session, with the -investigation narrowed to a specific, actionable, OS-level lead. - -### Bugs found and fixed this session - -5. **Picking a new input device in Settings never reached the live - capture** (`5d89ca55`). `RadioAudioSettings.kt`'s device-picker `onSelect` - persisted the new `audioInputDeviceId`/VID/PID to `GeneralVariables` and - the DB, but never told the already-running `MicRecorder` to rebuild with - it — `MicRecorder` only reads those fields once, at construction time. - The live capture silently kept using whatever it opened with (observed on - real hardware: the phone's built-in mic) until the next USB attach/detach - or app restart happened to reopen it. Fixed by calling the existing - `mainViewModel.reinitializeAudioInput()` from the picker's `onSelect`, - mirroring the RX-channel-change and Bluetooth-SCO-connect paths, which - already did this for the same reason. Confirmed in logcat: `writeConfig` - is now immediately followed by a `MicRecorder` reinit/`preferredDevice` - line, where before there was none. -6. **A transient `AudioRecord` failure permanently stopped capture for the - rest of the app session** (`5b76f4b8`). `MicRecorder.reinitialize()` - decided whether to call `start()` again by reading `isRunning` — but a - transient `AudioFlinger createRecord_l` failure (observed in the field - right after a replug/power-cycle, before the audio HAL had caught up) - sets `isRunning = false` as a side effect of the capture thread's own - failure-detection path, with no owner-driven `stopRecord()` involved. - Every `reinitialize()` after that read the now-stale `isRunning` as false - and silently skipped `start()` — permanently, even once the operator - picked a completely valid device afterward (confirmed on real hardware: - `setPreferredDevice()` kept reporting `ok=true deviceFound=true`, and - `AudioFlinger` genuinely created the record track, but `start()` was - simply never called again). Fixed with a separate `shouldBeRunning` - intent flag, touched only by the two owner-facing entry points - (`start()`/`stopRecord()`) and left alone by transient internal failures; - `reinitialize()` now keys its restart decision off that instead. - -### Mode (USB vs Digi) was a red herring, not the cause - -A large part of the night was spent chasing a theory that hamlib's `"RTTY"` -mode string (Digi/`MD6;`, the QMX's literal hardware FSK demodulator) -disables the receive audio chain FT8 needs, vs. `"USB"` (`MD2;`, plain SSB -passband). This looked initially promising — one build swapped to `"USB"` -and a real decode session followed (29 messages, multiple real callsigns) — -but was conclusively ruled out by later, cleaner tests on the same running -app process: switching the radio between USB and Digi at its own front panel -with FT8AF's capture already running and confirmed healthy (continuous HAL -reads, correct device, correct CAT) made no difference — both modes were -silent. `FT_MODE` is back to `"RTTY"` (Digi, the QMX's correct intended mode -for FT8), per `HamlibRig.java`'s own comment, which documents this full -dead-end for future reference. - -### Several real leads chased and ruled out before the actual cause - -With both of the above bugs fixed, USB capture confirmed genuinely healthy -(MediaTek `AudioALSACaptureHandlerUsb`/`AudioALSACaptureDataProviderUsb` -opening correctly, continuous reads, zero `xrun`/`overrun`/error lines, -correct device id, correct CAT/frequency), decode cycles were still -consistently fast (~140ms, the empty-audio fingerprint vs. ~1100ms when real -audio is present) with zero messages kept, across multiple clean -fresh-launch tests. Two more theories were seriously chased and eventually -ruled out by sharper evidence, in order: - -- **OS-level USB-claim theory**: reproduced twice that audio flows through - Android's own default USB-audio handling right up until an app (FT8AF, and - also briefly FT8TW) is granted the device via the "choose an app" chooser, - then stops. Superseded below — not wrong exactly, but not the actual - mechanism; see the real cause. -- **I/Q mode left on**: `danleetw/FT8TW` issue #78 ("For QMX/QMX+ set I/Q - mode off", closed as fixed) describes what reads as this exact symptom — - the QMX's `Q9`/IQ-mode CAT setting streams raw ADC I/Q samples to the USB - sound card instead of demodulated audio when left on. A side-by-side test - showed FT8TW (which carries Dan's `Q90;` fix) decoding while FT8AF - (fixed, pushed as `12815ecb`, sends `Q90;` on every connect) did not — - **but a later, much more precise test (below) directly disproved I/Q mode - as this session's actual cause**: the radio decoded fine in Digi/MD6 with - no `Q90;` ever sent. The `Q90;` fix is being kept anyway since it's a real, - externally-validated QMX fix for the scenario in that issue — just not - what was happening on this unit tonight. - -### The actual root cause, found and fixed: requesting float capture breaks on this USB audio HAL - -A precise, repeated real-hardware A/B, with mode and every other variable -held constant within a single running app process, found it: Android's own -audio HAL log (`AudioALSAStreamIn`'s `set()` call) showed two different -formats being negotiated across different opens of the *same* USB input — -`fmt=5` (`ENCODING_PCM_FLOAT`, what `MicRecorder` was requesting) and -`fmt=1` (`ENCODING_PCM_16BIT`). Every single `fmt=5` open was silent -(zero messages kept, ~140ms decode fingerprint); every single `fmt=1` open -decoded normally (real messages, ~1100ms decode fingerprint) — including -two cases where the *same app process* flipped between the two, unprompted, -across a USB re-enumeration, with CAT mode held at Digi/MD6 the entire time -and decoding starting or stopping in exact lockstep with the format. The -HAL's own log line explains why: `"wrong format(0x5), default use -wordSize = 2"` — it silently falls back to a 16-bit internal word size while -still being asked to hand back 4-byte floats, producing unusable samples -with no error surfaced anywhere. - -This also directly explains the two superseded leads above: QFT8 and FT8TW -decoding reliably is consistent with them requesting PCM16 (or getting it -some other way) rather than float; the "audio dies the instant an app -claims the device" observation was likely this same format mismatch -happening to coincide with FT8AF's/FT8TW's own connect sequence, not a -genuine Android USB-claim side effect. - -**Fixed and pushed** (`38f8e9d8`): `MicRecorder` now requests -`ENCODING_PCM_16BIT` (matching a commented-out line already present in the -source, suggesting this was the original format before an earlier, -unrelated change switched it to float) and converts to float in the reader -loop via a new `int16ToFloat()` helper, matching the existing -`float2Short()` inverse scale already used on the TX side. Covered by a new -unit test file (`MicRecorderInt16ToFloatTest`). - -**CONFIRMED on real hardware (2026-10-22)**: 7 consecutive decode cycles -with real content, every HAL USB capture open at `fmt=1` (PCM16), zero -`"wrong format(0x5)"` warnings across the whole session, radio in Digi mode -(`MD6;`) with `Q90;` also sent on connect per the earlier fix. The RX -investigation that ran all night is fully resolved. - -**Known follow-up, not urgent (self-heals)**: the QMX's Android -`AudioDeviceInfo` id changes across every USB replug/re-enumeration (seen -again this session: `id=525` → `deviceFound=false` → operator re-picks → -`id=677` → `deviceFound=true`). `findAudioDeviceById()`/the Settings picker -match by that numeric id, which isn't stable across reconnects, so a stale -persisted id silently falls back to no device until the operator manually -re-selects it in Settings. Matching by product name (or VID/PID, where -available) as a fallback when the persisted id isn't found would remove the -need for that manual re-pick. - -## Known issue, not yet root-caused (TX, from 2026-10-21, not re-tested) - -TX: the rig keys/transmits, but audio was underdriven (low), with CAT and -mode both confirmed working correctly in that test. Not re-tested this -session since RX never reached a working state to test TX against. - -## Known test gaps - -- No local build/unit-test execution has been possible on the Linux ARM - board this work was primarily developed on (`aapt2`/`adb`/`zipalign` are - all x86_64-only there). Real building and on-device testing for this status - update were done via a Windows laptop with Android Studio, connected over - Claude Code's Remote Control feature. -- The Hamlib-backed code paths in `HamlibRigTest.java` are gated behind - `ensureOpen()`, which always returns false in a JVM unit test (no native - lib) — consistent with that test file's own documented precedent, hamlib - behavior itself is verified on real hardware, not in CI unit tests. diff --git a/ft8af/app/src/main/cpp/ft8af_glue/hamlib_jni.cpp b/ft8af/app/src/main/cpp/ft8af_glue/hamlib_jni.cpp index 86e50ea2d..e9a7a5782 100644 --- a/ft8af/app/src/main/cpp/ft8af_glue/hamlib_jni.cpp +++ b/ft8af/app/src/main/cpp/ft8af_glue/hamlib_jni.cpp @@ -80,16 +80,6 @@ static void *pump_main(void *arg) { while (b->running) { ssize_t n = read(b->srv_fd, buf, sizeof(buf)); if (n > 0) { - // DIAGNOSTIC (temporary): confirm exactly what the pump thread - // reads from hamlib's loopback socket, and that it reads it at - // all — suspected gap between hamlib's successful write_block() - // and CAT bytes (e.g. "FA;") reaching the real serial port. - char hex[512 * 3 + 1]; - int hoff = 0; - for (ssize_t i = 0; i < n && hoff < (int) sizeof(hex) - 4; ++i) { - hoff += snprintf(hex + hoff, sizeof(hex) - hoff, "%02X ", buf[i]); - } - LOGI("pump: read %zd bytes from srv_fd: %s", n, hex); jbyteArray arr = env->NewByteArray((jsize) n); if (arr == nullptr) { break; } env->SetByteArrayRegion(arr, 0, (jsize) n, reinterpret_cast(buf)); diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/connector/CableSerialPort.java b/ft8af/app/src/main/java/com/k1af/ft8af/connector/CableSerialPort.java index 6f1de1c9f..ea93baba3 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/connector/CableSerialPort.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/connector/CableSerialPort.java @@ -380,14 +380,6 @@ public boolean connect() { usbIoManager = new SerialInputOutputManager(usbSerialPort, new SerialInputOutputManager.Listener() { @Override public void onNewData(byte[] data) { - // DIAGNOSTIC (temporary): CableSerialPort had no logging at - // all on the receive path, so "the radio never replied" was - // never actually verified -- it could equally be a relay bug - // further downstream. This logs every byte actually read off - // the wire, before it reaches any rig-specific listener. - StringBuilder hex = new StringBuilder(); - for (byte x : data) hex.append(String.format("%02X ", x)); - fileLog("serial.recv[" + data.length + "]: hex: " + hex.toString().trim()); if (ioListener != null) { ioListener.onNewData(data); } diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java index e0eb2ed3d..22073b198 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java @@ -82,13 +82,6 @@ static String ftModeFor(int modelId) { BaseRigConnector c = getConnector(); if (c != null) { c.sendData(data); - } else { - // DIAGNOSTIC (temporary): hamlib's CAT bytes are silently dropped - // here with no trace anywhere else in the app or native logs when - // the connector is null — suspected cause of commands beyond the - // initial rig_open() handshake (e.g. FA;) never reaching the radio. - Log.w(TAG, "sink.onBytesToRig: dropping " + data.length - + " byte(s), getConnector() is null"); } }; @@ -219,39 +212,19 @@ private void releaseHandle() { * QRP Labs QMX-specific fixup, sent once right after a successful open: * force I/Q mode off ({@code "Q90;"}). hamlib has no generic API for this * -- it's one of the QMX's vendor "Q" extended commands - * (qrp-labs.com/images/qmx/manuals/cat_1_04_003.pdf), not a standard - * rig_set_level/rig_set_mode -- so this bypasses hamlib and writes the - * connector directly, the same mechanism {@link #sink} itself uses for - * hamlib's own bytes. + * (qrp-labs.com/images/qmx/manuals/cat_1_04_003.pdf) -- so this bypasses + * hamlib and writes the connector directly, the same mechanism + * {@link #sink} uses for hamlib's own bytes. Left on (e.g. from prior SDR + * use via the radio's own front-panel menu), I/Q mode streams raw ADC + * samples instead of demodulated audio, which a decoder sees as noise; + * the setting is session-only, so it silently returns on every power- + * cycle. Matches danleetw/FT8TW#78, fixed there with the same command. * - *

Real-hardware root cause (2026-10-22): I/Q mode streams raw ADC - * samples to the USB sound card instead of demodulated audio (per the - * manual's own Q9 description) -- a decoder expecting normal audio sees - * what looks like noise/silence, while the USB capture pipeline itself - * stays completely healthy (confirmed via Android's own audio HAL: real - * continuous reads, zero errors, correct device, correct CAT), which is - * exactly the symptom chased fruitlessly through mode, audio-device - * selection, and capture-lifecycle bugs all night. The setting is - * session-only (not written to EEPROM) per the manual, which fits - * everything observed: if I/Q mode was left on via the QMX's own front- - * panel menu from unrelated SDR use on this unit, it would default back - * on every power-cycle, and only an app that explicitly sends "Q90;" on - * connect would ever decode again -- matching danleetw/FT8TW issue #78 - * ("For QMX/QMX+ set I/Q mode off... appears to be working, but the - * received audio is like noise"), closed by that project as fixed with - * the identical command. - * - *

A first live A/B on this same hardware, same session, briefly looked - * like a counter-example (FT8TW silent too, right after being granted the - * device) but did not reproduce on a second try -- FT8TW decoded - * normally after the device hand-off that time, the one difference - * between the two apps being exactly this fix. See - * docs/qmx-hamlib-status.md for the full real-hardware trail. + *

Package-private: visible for {@code disableQmxIqModeIfApplicable_*} tests. * * @param c the connector to write to; never null when called from * {@link #ensureOpen()} (already checked non-null/connected) */ - /** Package-private: visible for {@code disableQmxIqModeIfApplicable_*} tests. */ void disableQmxIqModeIfApplicable(BaseRigConnector c) { if (modelId != QRPLABS_QMX_MODEL_ID) return; c.sendData("Q90;".getBytes(java.nio.charset.StandardCharsets.US_ASCII)); diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/wave/MicRecorder.java b/ft8af/app/src/main/java/com/k1af/ft8af/wave/MicRecorder.java index ce0b35ac3..3c70dab40 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/wave/MicRecorder.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/wave/MicRecorder.java @@ -37,21 +37,11 @@ public class MicRecorder { // createAudioRecord(). Volatile because reinitialize() writes it from // another thread than the reader loop that snapshots it. private volatile int captureChannelCount = 1; - // NOT ENCODING_PCM_FLOAT: confirmed on real hardware (2026-10-22, QMX - // over USB Audio Class, MediaTek audio HAL) that requesting float capture - // from a USB input silently produces unusable (effectively silent) - // samples on this HAL -- AudioALSAStreamIn's own log line, "wrong - // format(0x5), default use wordSize = 2", shows it falling back to a - // 16-bit word size internally while still being asked for 4-byte floats. - // Reproduced twice in two single app processes with everything else held - // constant (same CAT mode, same device, same session): the HAL - // occasionally renegotiates to 16-bit PCM across a USB re-enumeration, - // and decoding started working immediately every time that happened, - // then stopped immediately on the next reinit back to float -- this is - // what the whole night's RX investigation was actually chasing, not - // mode or device selection. Capture PCM16 instead and convert to float - // in the reader loop below (see startAudioRecordCapture()), matching - // what already reliably decodes on this hardware. + // NOT ENCODING_PCM_FLOAT: on this phone's MediaTek USB audio HAL, + // requesting float capture from a USB input silently produces unusable + // samples (the HAL's own log shows it falling back to a 16-bit word size + // internally while still being asked for 4-byte floats). Capture PCM16 + // and convert to float in software instead (see startAudioRecordCapture()). private static final int audioFormat = AudioFormat.ENCODING_PCM_16BIT; //quantization bit depth private AudioRecord audioRecord = null;//AudioRecord object From a99d596ff6ae542ad59ad9beadaa2880e8c51787 Mon Sep 17 00:00:00 2001 From: quadra Date: Thu, 22 Oct 2026 20:21:16 +0000 Subject: [PATCH 27/36] Add test coverage for the shouldBeRunning restart decision (finding 7) Extracted reinitialize()'s restart check into MicRecorder.shouldRestartAfterReinitialize(boolean), matching this file's existing convention for pinning a load-bearing boolean decision with its own test (captureLoopShouldContinue, shouldJoinCaptureThread). Covers both scenarios Patrick asked for: restart after a transient failure left isRunning false without stopRecord(), and no restart after stopRecord() itself cleared shouldBeRunning. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf --- .../java/com/k1af/ft8af/wave/MicRecorder.java | 21 +++++++++---- .../wave/MicRecorderRestartDecisionTest.java | 31 +++++++++++++++++++ 2 files changed, 46 insertions(+), 6 deletions(-) create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/wave/MicRecorderRestartDecisionTest.java diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/wave/MicRecorder.java b/ft8af/app/src/main/java/com/k1af/ft8af/wave/MicRecorder.java index 3c70dab40..64af1b4ae 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/wave/MicRecorder.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/wave/MicRecorder.java @@ -814,17 +814,26 @@ public synchronized void reinitialize() { } } - // Restore listener and restart if the owner still wants capture - // running -- shouldBeRunning, NOT isRunning: a transient failure in - // the rebuild above (or an earlier reinitialize() call) may already - // have left isRunning false with no owner-driven stopRecord() ever - // happening, and that must not be read as "capture no longer wanted". onDataListener = savedListener; - if (shouldBeRunning) { + if (shouldRestartAfterReinitialize(shouldBeRunning)) { start(); } } + /** + * Whether {@link #reinitialize()} should restart capture once it has + * rebuilt the audio input. Keyed on {@code shouldBeRunning} (owner + * intent, touched only by {@link #start()}/{@link #stopRecord()}), NOT + * {@code isRunning} -- a transient failure in the rebuild above (or an + * earlier {@link #reinitialize()} call) can leave {@code isRunning} false + * with no owner-driven {@link #stopRecord()} ever happening, and that + * must not be read as "capture no longer wanted" (the bug fixed by + * introducing {@link #shouldBeRunning} in the first place). + */ + static boolean shouldRestartAfterReinitialize(boolean shouldBeRunning) { + return shouldBeRunning; + } + /** * Wait for the AudioRecord reader thread to exit before its AudioRecord is * released. Bounded by {@link #CAPTURE_JOIN_TIMEOUT_MS} so a thread stuck diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/wave/MicRecorderRestartDecisionTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/wave/MicRecorderRestartDecisionTest.java new file mode 100644 index 000000000..64bbadd91 --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/wave/MicRecorderRestartDecisionTest.java @@ -0,0 +1,31 @@ +package com.k1af.ft8af.wave; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; + +/** + * Tests {@link MicRecorder#shouldRestartAfterReinitialize} -- the restart + * decision behind the {@code shouldBeRunning} fix (a transient + * {@code AudioRecord} creation failure right after a replug/power-cycle used + * to permanently wedge capture, because {@link MicRecorder#reinitialize()} + * used to key its restart decision off {@code isRunning}, which that failure + * also flips to false with no owner action). + */ +public class MicRecorderRestartDecisionTest { + + @Test + public void restarts_afterTransientFailureLeftIsRunningFalse() { + // start() set shouldBeRunning=true; a transient AudioRecord failure + // (not stopRecord()) later set isRunning=false as a side effect, but + // shouldBeRunning is untouched -- reinitialize() must still restart. + assertThat(MicRecorder.shouldRestartAfterReinitialize(true)).isTrue(); + } + + @Test + public void doesNotRestart_afterOwnerCalledStopRecord() { + // stopRecord() clears shouldBeRunning -- reinitialize() (e.g. from a + // later USB attach event) must not restart capture the operator stopped. + assertThat(MicRecorder.shouldRestartAfterReinitialize(false)).isFalse(); + } +} From 93848225641096b34581244ca7c5cf31adf97183 Mon Sep 17 00:00:00 2001 From: quadra Date: Thu, 22 Oct 2026 21:21:39 +0000 Subject: [PATCH 28/36] Log the resolved preferredDevice id, not just the requested one Real-hardware proof of the VID:PID/product-name fallback (a QMX power-cycle changed its AudioDeviceInfo id from 1328 to 1354) showed that these diagnostic logs only printed the REQUESTED id, which made a successful fallback match look identical to an exact hit in the log -- deviceFound=true either way, with no way to tell which happened without separately checking dumpsys. Logs the AudioDeviceInfo's own resolved id alongside it in MicRecorder's reinit log and both of FT8TransmitSignal's preferredDevice logs (normal TX and TUNE). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf --- .../k1af/ft8af/ft8transmit/FT8TransmitSignal.java | 14 ++++++++++---- .../main/java/com/k1af/ft8af/wave/MicRecorder.java | 11 +++++++++-- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java b/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java index c1f5634ff..e6ea9488c 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java @@ -1118,9 +1118,13 @@ private void playViaAudioTrack(float[] buffer) { // set preferred output device if (GeneralVariables.audioOutputDeviceId > 0) { boolean ok = audioTrack.setPreferredDevice(preferredOutputDevice); // null resets to default + // Logs the REQUESTED id (what was persisted) and the RESOLVED one separately -- + // see MicRecorder's matching reinit log for why these can legitimately differ. GeneralVariables.fileLog(String.format( - "playFT8Signal: AudioTrack preferredDevice id=%d ok=%b deviceFound=%b", - GeneralVariables.audioOutputDeviceId, ok, preferredOutputDevice != null)); + "playFT8Signal: AudioTrack preferredDevice requestedId=%d resolvedId=%d ok=%b deviceFound=%b", + GeneralVariables.audioOutputDeviceId, + preferredOutputDevice == null ? -1 : preferredOutputDevice.getId(), + ok, preferredOutputDevice != null)); } else { applyDefaultOutputRoutingOverride(audioTrack); } @@ -3317,8 +3321,10 @@ private void playTuneTone() { if (GeneralVariables.audioOutputDeviceId > 0) { boolean ok = track.setPreferredDevice(tuneOutputDevice); GeneralVariables.fileLog(String.format( - "TUNE: AudioTrack preferredDevice id=%d ok=%b deviceFound=%b", - GeneralVariables.audioOutputDeviceId, ok, tuneOutputDevice != null)); + "TUNE: AudioTrack preferredDevice requestedId=%d resolvedId=%d ok=%b deviceFound=%b", + GeneralVariables.audioOutputDeviceId, + tuneOutputDevice == null ? -1 : tuneOutputDevice.getId(), + ok, tuneOutputDevice != null)); } else { applyDefaultOutputRoutingOverride(track); } diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/wave/MicRecorder.java b/ft8af/app/src/main/java/com/k1af/ft8af/wave/MicRecorder.java index 15c964b86..6e3b314fd 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/wave/MicRecorder.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/wave/MicRecorder.java @@ -818,9 +818,16 @@ public synchronized void reinitialize() { AudioDeviceInfo deviceInfo = findAudioDeviceById( GeneralVariables.audioInputDeviceId, AudioManager.GET_DEVICES_INPUTS); boolean ok = audioRecord.setPreferredDevice(deviceInfo); + // Logs the REQUESTED id (what was persisted) and the RESOLVED one + // separately: findAudioDeviceById() can fall back to a different, + // currently-live id via UsbAudioDevice.resolveDeviceIndex's product-name + // match when the requested one went stale across a USB re-enumeration -- + // confirmed on real hardware (a QMX power-cycle), where logging only the + // requested id made a successful fallback look identical to an exact hit. GeneralVariables.fileLog(String.format( - "MicRecorder: reinit preferredDevice id=%d ok=%b deviceFound=%b", - GeneralVariables.audioInputDeviceId, ok, deviceInfo != null)); + "MicRecorder: reinit preferredDevice requestedId=%d resolvedId=%d ok=%b deviceFound=%b", + GeneralVariables.audioInputDeviceId, + deviceInfo == null ? -1 : deviceInfo.getId(), ok, deviceInfo != null)); } } From b30f7db6b145286a6256414849ee6eaf8401bcde Mon Sep 17 00:00:00 2001 From: quadra Date: Fri, 23 Oct 2026 00:50:11 +0000 Subject: [PATCH 29/36] Back off native open retries against a persistently unresponsive radio Real-hardware finding: with the QMX stuck in a non-CAT mode (not this PR's own bug -- a separate build's stray serial writes left it there), HamlibRig got a brand-new native bridge (loopback socket + pump thread) on every single CAT call that ran while handle==0 -- 354 of them in about 17 minutes, via the app's ~3s CAT-liveness watchdog tick plus other CAT calls queuing in between. Each cycle was already cleaned up correctly (no leak -- releaseHandle() still runs on every failure), but churning native resources this often for an indefinitely unresponsive radio is wasteful. Added a 15s backoff after a failed nativeCreate or nativeRigOpen: ensureOpen() now skips the attempt entirely (returning false immediately) until the backoff elapses, reset to 0 on the next successful open. The decision itself (shouldAttemptOpen) is a pure function of (now, last-failure-time, backoff), kept separate from ensureOpen() so it's testable without a real clock/sleep. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf --- .../java/com/k1af/ft8af/rigs/HamlibRig.java | 34 ++++++++++++++ .../com/k1af/ft8af/rigs/HamlibRigTest.java | 46 +++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java index 22073b198..4f11c37c1 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java @@ -134,6 +134,34 @@ interface NativeApi { @Override public void nativeClose(long handle) { HamlibNative.nativeClose(handle); } }; + /** + * Minimum time between native open attempts after one fails (either + * {@code nativeCreate} or {@code nativeRigOpen}/the CAT handshake). + * Without this, a persistently unresponsive radio got a brand-new native + * bridge (loopback socket + pump thread) on every single CAT call that + * happens to run while handle==0 -- confirmed on real hardware: a QMX + * stuck in a non-CAT mode got 354 fresh bridges in about 17 minutes (each + * one created and torn down within ~300ms, via the ~3s CAT-liveness + * watchdog tick plus other CAT calls queuing in between). Each cycle was + * cleaned up correctly (no leak -- see releaseHandle()), but churning + * native resources this often for an indefinitely unresponsive radio is + * still wasteful. 15s caps that at roughly a quarter of the previous rate + * while still recovering promptly once the radio answers again. + */ + private static final long OPEN_RETRY_BACKOFF_MS = 15_000; + private volatile long lastOpenFailureAtMs = 0; + + /** + * Whether {@link #ensureOpen()} should attempt a fresh native open now, + * given when the last attempt failed. Pure/testable: {@code + * ensureOpen()} itself touches native calls and a connector, so the + * backoff decision is extracted here rather than timed with a real + * {@link System#currentTimeMillis()} + sleep in a test. + */ + static boolean shouldAttemptOpen(long nowMs, long lastFailureAtMs, long backoffMs) { + return lastFailureAtMs == 0 || nowMs - lastFailureAtMs >= backoffMs; + } + /** * Open hamlib once the transport is connected. hamlib's {@code rig_open} * performs an initial CAT handshake, so the connector must be live and @@ -151,6 +179,9 @@ private synchronized boolean ensureOpen() { if (handle != 0) { return true; } + if (!shouldAttemptOpen(System.currentTimeMillis(), lastOpenFailureAtMs, OPEN_RETRY_BACKOFF_MS)) { + return false; + } BaseRigConnector c = getConnector(); if (c == null || !c.isConnected() || !nativeApi.isAvailable()) { return false; @@ -168,6 +199,7 @@ private synchronized boolean ensureOpen() { long h = nativeApi.nativeCreate(modelId, sink); if (h == 0) { Log.w(TAG, "nativeCreate failed for hamlib model " + modelId); + lastOpenFailureAtMs = System.currentTimeMillis(); return false; } // Publish the handle BEFORE running the handshake: onReceiveData() @@ -184,9 +216,11 @@ private synchronized boolean ensureOpen() { if (rc != 0) { Log.w(TAG, "nativeRigOpen failed for hamlib model " + modelId + " rc=" + rc); releaseHandle(); + lastOpenFailureAtMs = System.currentTimeMillis(); return false; } Log.i(TAG, "hamlib model " + modelId + " opened"); + lastOpenFailureAtMs = 0; disableQmxIqModeIfApplicable(c); return true; } diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/rigs/HamlibRigTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/rigs/HamlibRigTest.java index 39cfbd59a..286961819 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/rigs/HamlibRigTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/rigs/HamlibRigTest.java @@ -310,4 +310,50 @@ public void onDisconnecting_duringInFlightOpen_releasesExactlyOnceWithNoClobber( assertThat(fake.setFreqCalls).containsExactly(1L); assertThat(fake.closeCalls).containsExactly(1L); } + + // ---- open-retry backoff: a persistently unresponsive radio ----------- + + @Test + public void shouldAttemptOpen_noPriorFailure_true() { + assertThat(HamlibRig.shouldAttemptOpen(1_000L, 0L, 15_000L)).isTrue(); + } + + @Test + public void shouldAttemptOpen_withinBackoffWindow_false() { + assertThat(HamlibRig.shouldAttemptOpen(10_000L, 1_000L, 15_000L)).isFalse(); + } + + @Test + public void shouldAttemptOpen_exactlyAtBackoffBoundary_true() { + assertThat(HamlibRig.shouldAttemptOpen(16_000L, 1_000L, 15_000L)).isTrue(); + } + + @Test + public void shouldAttemptOpen_pastBackoffWindow_true() { + assertThat(HamlibRig.shouldAttemptOpen(20_000L, 1_000L, 15_000L)).isTrue(); + } + + @Test + public void ensureOpen_repeatedCatCallsAfterFailure_doNotRetryWithinBackoff() throws Exception { + // Real-hardware finding: a persistently unresponsive radio (stuck in a + // non-CAT mode) got a brand-new native bridge every single CAT call -- + // 354 of them in ~17 minutes via the app's ~3s CAT-liveness watchdog + // tick. Simulates two such calls landing within the same instant (well + // inside the 15s backoff): only the first should touch the native layer. + FakeNativeApi fake = new FakeNativeApi(); + fake.rigOpenRc = -1; // every open attempt fails, like an unresponsive radio + HamlibRig rig = new HamlibRig(2057, fake); + rig.setConnector(connectedConnector()); + + rig.setFreqToRig(); // first CAT call: attempts to open, fails + rig.readFreqFromRig(); // a second CAT call landing moments later (same tick cycle) + CountDownLatch drained = new CountDownLatch(1); + rig.submitForTest(drained::countDown); + assertThat(drained.await(5, TimeUnit.SECONDS)).isTrue(); + + // Only the first call actually touched the native layer; the second was + // skipped by the backoff, not a second failed create/open cycle. + assertThat(fake.createCalls).containsExactly(2057); + assertThat(fake.closeCalls).containsExactly(1L); + } } From 32848d9755d3d451d21e5eab4b3c30ec4921ba89 Mon Sep 17 00:00:00 2001 From: quadra Date: Fri, 23 Oct 2026 00:55:28 +0000 Subject: [PATCH 30/36] Use SystemClock.elapsedRealtime(), not wall-clock, for the open-retry backoff Code review on the backoff fix (b30f7db6) flagged System.currentTimeMillis() as the wrong clock: an NTP sync or manual time change could jump the wall clock backward (stalling reopening far longer than 15s) or forward (skipping the backoff entirely). elapsedRealtime() is monotonic and unaffected by either. shouldAttemptOpen() itself is unchanged -- it's a pure function of whatever clock it's given -- only ensureOpen()'s callers and the field's own units change. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf --- .../java/com/k1af/ft8af/rigs/HamlibRig.java | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java index 4f11c37c1..58a48d16d 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java @@ -1,5 +1,6 @@ package com.k1af.ft8af.rigs; +import android.os.SystemClock; import android.util.Log; import com.k1af.ft8af.connector.BaseRigConnector; @@ -149,6 +150,7 @@ interface NativeApi { * while still recovering promptly once the radio answers again. */ private static final long OPEN_RETRY_BACKOFF_MS = 15_000; + /** {@link SystemClock#elapsedRealtime()} ms, NOT wall-clock: see {@link #ensureOpen()}. */ private volatile long lastOpenFailureAtMs = 0; /** @@ -156,7 +158,16 @@ interface NativeApi { * given when the last attempt failed. Pure/testable: {@code * ensureOpen()} itself touches native calls and a connector, so the * backoff decision is extracted here rather than timed with a real - * {@link System#currentTimeMillis()} + sleep in a test. + * clock + sleep in a test. + * + * @param nowMs current time; callers must use {@link + * SystemClock#elapsedRealtime()}, not wall-clock + * time -- an NTP sync or manual clock change could + * otherwise stall reopening indefinitely (clock + * jumps back) or skip the backoff entirely (clock + * jumps forward) + * @param lastFailureAtMs the same clock's reading when the last attempt + * failed, or 0 if there hasn't been one */ static boolean shouldAttemptOpen(long nowMs, long lastFailureAtMs, long backoffMs) { return lastFailureAtMs == 0 || nowMs - lastFailureAtMs >= backoffMs; @@ -179,7 +190,7 @@ private synchronized boolean ensureOpen() { if (handle != 0) { return true; } - if (!shouldAttemptOpen(System.currentTimeMillis(), lastOpenFailureAtMs, OPEN_RETRY_BACKOFF_MS)) { + if (!shouldAttemptOpen(SystemClock.elapsedRealtime(), lastOpenFailureAtMs, OPEN_RETRY_BACKOFF_MS)) { return false; } BaseRigConnector c = getConnector(); @@ -199,7 +210,7 @@ private synchronized boolean ensureOpen() { long h = nativeApi.nativeCreate(modelId, sink); if (h == 0) { Log.w(TAG, "nativeCreate failed for hamlib model " + modelId); - lastOpenFailureAtMs = System.currentTimeMillis(); + lastOpenFailureAtMs = SystemClock.elapsedRealtime(); return false; } // Publish the handle BEFORE running the handshake: onReceiveData() @@ -216,7 +227,7 @@ private synchronized boolean ensureOpen() { if (rc != 0) { Log.w(TAG, "nativeRigOpen failed for hamlib model " + modelId + " rc=" + rc); releaseHandle(); - lastOpenFailureAtMs = System.currentTimeMillis(); + lastOpenFailureAtMs = SystemClock.elapsedRealtime(); return false; } Log.i(TAG, "hamlib model " + modelId + " opened"); From f8867e98d58a584706217110ceb727daef22688b Mon Sep 17 00:00:00 2001 From: quadra Date: Fri, 23 Oct 2026 00:59:41 +0000 Subject: [PATCH 31/36] Fix the backoff's sentinel/clock test interaction: use Long.MIN_VALUE, not 0, and inject the clock for deterministic tests android.os.SystemClock.elapsedRealtime() returns 0 (unmocked default) under a plain JVM unit test (app/build.gradle's unitTests.returnDefaultValues), which collided with 0 as the "never failed" sentinel: the very first backoff check after a failure saw now == lastFailureAtMs == 0, took the "never failed" branch, and wrongly allowed an immediate retry -- caught by ensureOpen_repeatedCatCallsAfterFailure_doNotRetryWithinBackoff failing under real test conditions (not Robolectric). On a real device elapsedRealtime() is never exactly 0 in practice, so this was a test-only symptom, but the sentinel collision was real regardless of clock source. Moved the sentinel to Long.MIN_VALUE (never a legitimate elapsedRealtime() reading) and added a third constructor overload injecting the clock as a LongSupplier (defaulting to SystemClock::elapsedRealtime), so the backoff window itself is deterministically testable rather than depending on the JVM's unmocked default or real wall/monotonic time. Added a test for the other half of the behavior -- retrying once the backoff genuinely elapses. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf --- .../java/com/k1af/ft8af/rigs/HamlibRig.java | 40 +++++++++++++++---- .../com/k1af/ft8af/rigs/HamlibRigTest.java | 39 ++++++++++++++++-- 2 files changed, 68 insertions(+), 11 deletions(-) diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java index 58a48d16d..1747fcba6 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java @@ -57,6 +57,8 @@ static String ftModeFor(int modelId) { private final int modelId; private final NativeApi nativeApi; + /** Clock behind the open-retry backoff; see {@link #ensureOpen()}. */ + private final java.util.function.LongSupplier elapsedRealtimeMs; private final ExecutorService worker = Executors.newSingleThreadExecutor(); private volatile long handle = 0; /** @@ -92,8 +94,21 @@ public HamlibRig(int modelId) { /** Visible for tests: inject a fake native layer to drive races on the JVM. */ HamlibRig(int modelId, NativeApi nativeApi) { + this(modelId, nativeApi, SystemClock::elapsedRealtime); + } + + /** + * Visible for tests: also inject the clock behind the open-retry backoff + * (see {@link #ensureOpen()}), so it's testable deterministically rather + * than depending on real elapsed time. {@code SystemClock.elapsedRealtime()} + * is otherwise unmocked and returns 0 under a plain (non-Robolectric) JVM + * unit test (app/build.gradle's {@code unitTests.returnDefaultValues}) -- + * harmless on its own, but worth not depending on at all here. + */ + HamlibRig(int modelId, NativeApi nativeApi, java.util.function.LongSupplier elapsedRealtimeMs) { this.modelId = modelId; this.nativeApi = nativeApi; + this.elapsedRealtimeMs = elapsedRealtimeMs; } /** @@ -150,8 +165,15 @@ interface NativeApi { * while still recovering promptly once the radio answers again. */ private static final long OPEN_RETRY_BACKOFF_MS = 15_000; - /** {@link SystemClock#elapsedRealtime()} ms, NOT wall-clock: see {@link #ensureOpen()}. */ - private volatile long lastOpenFailureAtMs = 0; + /** + * {@link #elapsedRealtimeMs}'s reading at the last open failure, or + * {@link Long#MIN_VALUE} as the "never failed" sentinel -- NOT 0: a plain + * JVM unit test (no Robolectric) gets 0 for every unmocked {@code + * SystemClock.elapsedRealtime()} call, which collided with 0 as a + * sentinel and made the very first backoff check after a failure see + * "now == last failure == 0" and wrongly allow an immediate retry. + */ + private volatile long lastOpenFailureAtMs = Long.MIN_VALUE; /** * Whether {@link #ensureOpen()} should attempt a fresh native open now, @@ -167,10 +189,12 @@ interface NativeApi { * jumps back) or skip the backoff entirely (clock * jumps forward) * @param lastFailureAtMs the same clock's reading when the last attempt - * failed, or 0 if there hasn't been one + * failed, or {@link Long#MIN_VALUE} if there + * hasn't been one (NOT 0 -- see {@link + * #lastOpenFailureAtMs}'s javadoc) */ static boolean shouldAttemptOpen(long nowMs, long lastFailureAtMs, long backoffMs) { - return lastFailureAtMs == 0 || nowMs - lastFailureAtMs >= backoffMs; + return lastFailureAtMs == Long.MIN_VALUE || nowMs - lastFailureAtMs >= backoffMs; } /** @@ -190,7 +214,7 @@ private synchronized boolean ensureOpen() { if (handle != 0) { return true; } - if (!shouldAttemptOpen(SystemClock.elapsedRealtime(), lastOpenFailureAtMs, OPEN_RETRY_BACKOFF_MS)) { + if (!shouldAttemptOpen(elapsedRealtimeMs.getAsLong(), lastOpenFailureAtMs, OPEN_RETRY_BACKOFF_MS)) { return false; } BaseRigConnector c = getConnector(); @@ -210,7 +234,7 @@ private synchronized boolean ensureOpen() { long h = nativeApi.nativeCreate(modelId, sink); if (h == 0) { Log.w(TAG, "nativeCreate failed for hamlib model " + modelId); - lastOpenFailureAtMs = SystemClock.elapsedRealtime(); + lastOpenFailureAtMs = elapsedRealtimeMs.getAsLong(); return false; } // Publish the handle BEFORE running the handshake: onReceiveData() @@ -227,11 +251,11 @@ private synchronized boolean ensureOpen() { if (rc != 0) { Log.w(TAG, "nativeRigOpen failed for hamlib model " + modelId + " rc=" + rc); releaseHandle(); - lastOpenFailureAtMs = SystemClock.elapsedRealtime(); + lastOpenFailureAtMs = elapsedRealtimeMs.getAsLong(); return false; } Log.i(TAG, "hamlib model " + modelId + " opened"); - lastOpenFailureAtMs = 0; + lastOpenFailureAtMs = Long.MIN_VALUE; disableQmxIqModeIfApplicable(c); return true; } diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/rigs/HamlibRigTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/rigs/HamlibRigTest.java index 286961819..277c58daf 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/rigs/HamlibRigTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/rigs/HamlibRigTest.java @@ -315,7 +315,7 @@ public void onDisconnecting_duringInFlightOpen_releasesExactlyOnceWithNoClobber( @Test public void shouldAttemptOpen_noPriorFailure_true() { - assertThat(HamlibRig.shouldAttemptOpen(1_000L, 0L, 15_000L)).isTrue(); + assertThat(HamlibRig.shouldAttemptOpen(1_000L, Long.MIN_VALUE, 15_000L)).isTrue(); } @Test @@ -340,12 +340,22 @@ public void ensureOpen_repeatedCatCallsAfterFailure_doNotRetryWithinBackoff() th // 354 of them in ~17 minutes via the app's ~3s CAT-liveness watchdog // tick. Simulates two such calls landing within the same instant (well // inside the 15s backoff): only the first should touch the native layer. + // + // Drives a fake clock rather than the real one (the 3-arg constructor): + // android.os.SystemClock.elapsedRealtime() is unmocked and returns 0 in + // this plain (non-Robolectric) JVM test, same as it would for every + // reading in a real run here -- harmless by itself once the "never + // failed" sentinel is Long.MIN_VALUE rather than 0, but a fake clock + // also makes the backoff window itself deterministic instead of + // depending on how fast the test happens to run. FakeNativeApi fake = new FakeNativeApi(); fake.rigOpenRc = -1; // every open attempt fails, like an unresponsive radio - HamlibRig rig = new HamlibRig(2057, fake); + AtomicLong clockMs = new AtomicLong(1_000L); + HamlibRig rig = new HamlibRig(2057, fake, clockMs::get); rig.setConnector(connectedConnector()); - rig.setFreqToRig(); // first CAT call: attempts to open, fails + rig.setFreqToRig(); // first CAT call at t=1000ms: attempts to open, fails + clockMs.set(1_500L); // 500ms later, well inside the 15s backoff rig.readFreqFromRig(); // a second CAT call landing moments later (same tick cycle) CountDownLatch drained = new CountDownLatch(1); rig.submitForTest(drained::countDown); @@ -356,4 +366,27 @@ public void ensureOpen_repeatedCatCallsAfterFailure_doNotRetryWithinBackoff() th assertThat(fake.createCalls).containsExactly(2057); assertThat(fake.closeCalls).containsExactly(1L); } + + @Test + public void ensureOpen_catCallAfterBackoffElapses_retries() throws Exception { + // The other half of the backoff: once the window has genuinely passed, + // the next CAT call must try again, not stay stuck forever. + FakeNativeApi fake = new FakeNativeApi(); + fake.rigOpenRc = -1; + AtomicLong clockMs = new AtomicLong(1_000L); + HamlibRig rig = new HamlibRig(2057, fake, clockMs::get); + rig.setConnector(connectedConnector()); + + rig.setFreqToRig(); // fails at t=1000ms + clockMs.set(1_000L + 15_000L); // exactly at the backoff boundary + rig.readFreqFromRig(); // must retry, not skip + + CountDownLatch drained = new CountDownLatch(1); + rig.submitForTest(drained::countDown); + assertThat(drained.await(5, TimeUnit.SECONDS)).isTrue(); + + assertThat(fake.createCalls).containsExactly(2057, 2057); + // Handles are 1 then 2 -- FakeNativeApi.nextHandle increments per create. + assertThat(fake.closeCalls).containsExactly(1L, 2L); + } } From dd17241f00a71ac822d19ad50930578bca012ece Mon Sep 17 00:00:00 2001 From: quadra Date: Fri, 23 Oct 2026 01:01:42 +0000 Subject: [PATCH 32/36] Fix a test-only race: drain the worker before moving the fake clock Both new backoff tests queued setFreqToRig() (which only submits to HamlibRig's worker, it doesn't block for completion) and then immediately moved the fake clock on the test thread -- a real race between the test thread and the worker thread actually running that first open attempt. In the retry test this was visible: if the clock jumped to the "past backoff" value before the worker recorded lastOpenFailureAtMs at the original time, the second call saw "now == lastFailure" (just-failed, still within backoff) instead of the intended "well past it", and never retried. The no-retry test's own assertion happened to hold regardless of which way the race fell, so it passed, but just as accidentally. Both tests now drain the worker (the existing submitForTest+latch pattern) right after the first CAT call, before touching the clock, so the first attempt's failure and its timestamp are guaranteed to have already happened. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf --- .../java/com/k1af/ft8af/rigs/HamlibRigTest.java | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/rigs/HamlibRigTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/rigs/HamlibRigTest.java index 277c58daf..5981b7e06 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/rigs/HamlibRigTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/rigs/HamlibRigTest.java @@ -355,6 +355,13 @@ public void ensureOpen_repeatedCatCallsAfterFailure_doNotRetryWithinBackoff() th rig.setConnector(connectedConnector()); rig.setFreqToRig(); // first CAT call at t=1000ms: attempts to open, fails + // Drain before moving the clock -- see the sibling retry test's comment; + // this test's own assertion happens to hold either way the race falls, + // but draining first keeps it deterministic rather than accidentally so. + CountDownLatch firstDrained = new CountDownLatch(1); + rig.submitForTest(firstDrained::countDown); + assertThat(firstDrained.await(5, TimeUnit.SECONDS)).isTrue(); + clockMs.set(1_500L); // 500ms later, well inside the 15s backoff rig.readFreqFromRig(); // a second CAT call landing moments later (same tick cycle) CountDownLatch drained = new CountDownLatch(1); @@ -378,6 +385,15 @@ public void ensureOpen_catCallAfterBackoffElapses_retries() throws Exception { rig.setConnector(connectedConnector()); rig.setFreqToRig(); // fails at t=1000ms + // Drain the worker before moving the clock: setFreqToRig() only queues the + // task, so without this the clock could jump to 16000 on the test thread + // before the worker has even run the first (t=1000) open attempt, making + // the second call also see "now=16000, lastFailure=16000" (within backoff) + // instead of the intended "lastFailure=1000, now=16000" (past it). + CountDownLatch firstDrained = new CountDownLatch(1); + rig.submitForTest(firstDrained::countDown); + assertThat(firstDrained.await(5, TimeUnit.SECONDS)).isTrue(); + clockMs.set(1_000L + 15_000L); // exactly at the backoff boundary rig.readFreqFromRig(); // must retry, not skip From 00fb7dbae8cf1463d0220cedeb874967b1863ef9 Mon Sep 17 00:00:00 2001 From: quadra Date: Fri, 23 Oct 2026 01:15:13 +0000 Subject: [PATCH 33/36] Default QMX TX audio to LEFT channel: fixes the TX-underdrive issue Deferred since the very first night of this PR's work ("rig keys/ transmits, but audio is low"). Earlier investigation found a well-evidenced cross-app cause: WSJT-X and QFT8 -- the two reference implementations confirmed working on this exact QMX -- both require an explicit mono/ left-channel TX selection to get full drive; FT8AF's historical BOTH default opens a mono AudioTrack and leaves the mono-to-stereo upmix to Android/the USB Audio Class device, with this app having no control over how much gain that upmix applies per channel, while an explicit LEFT selection opens a genuine stereo track with the left channel at full scale and the right silenced (AudioChannelSelect). Underdriven either way until an operator happens to find the manual Settings > Radio & Audio > Audio toggle. Auto-defaults GeneralVariables.txAudioChannel to LEFT the first time the QMX (Hamlib) model connects in MainViewModel.connectRig(), but only when the operator has never touched that setting at all -- a new DatabaseOpr.hasConfigKey() distinguishes "no row for this key yet" from "a row exists holding the same value as the hardcoded default", so an explicit choice (even BOTH itself) is never silently overridden. The decision itself (shouldDefaultQmxTxChannelToLeft) is a pure, testable function; HamlibRig.QRPLABS_QMX_MODEL_ID made public so MainViewModel can reference the same constant HamlibRig itself already uses. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf --- .../java/com/k1af/ft8af/MainViewModel.java | 41 ++++++++++++++++- .../com/k1af/ft8af/database/DatabaseOpr.java | 17 +++++++ .../java/com/k1af/ft8af/rigs/HamlibRig.java | 8 +++- .../MainViewModelQmxTxChannelDefaultTest.java | 46 +++++++++++++++++++ .../database/DatabaseOprHasConfigKeyTest.java | 46 +++++++++++++++++++ 5 files changed, 155 insertions(+), 3 deletions(-) create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/MainViewModelQmxTxChannelDefaultTest.java create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/database/DatabaseOprHasConfigKeyTest.java 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 2e2351ffb..f9a752715 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java @@ -132,6 +132,7 @@ import com.k1af.ft8af.timer.OnUtcTimer; import com.k1af.ft8af.timer.UtcTimer; import com.k1af.ft8af.ui.ToastMessage; +import com.k1af.ft8af.wave.AudioChannelSelect; import com.k1af.ft8af.wave.HamRecorder; import com.k1af.ft8af.wave.MicRecorder; import com.k1af.ft8af.wave.OnGetVoiceDataDone; @@ -2342,6 +2343,42 @@ private void repairCivAddressAgainstModel() { } } + /** + * The QRP Labs QMX needs mono/left-channel TX audio to fully drive its + * modulator -- confirmed on real hardware across three independent apps + * (WSJT-X, QFT8, and this one): FT8AF's historical {@code BOTH} default + * opens a mono {@code AudioTrack} and leaves the mono-to-stereo upmix to + * Android/the USB Audio Class device, with no control over how much gain + * that upmix applies per channel, while an explicit {@code LEFT} + * selection opens a genuine stereo track with the left channel at full + * scale and the right silenced (see {@link AudioChannelSelect}) -- + * underdriven either way until the operator finds the manual Settings + * toggle. Defaults {@code txAudioChannel} to {@code LEFT} the first time + * the QMX connects, but only when the operator has never touched that + * setting at all (no "txAudioChannel" config row yet) -- an explicit + * choice, even {@code BOTH} itself, is never overridden. + */ + private void applyQmxTxChannelDefaultIfApplicable(int hamlibModelId) { + if (databaseOpr == null) return; + if (!shouldDefaultQmxTxChannelToLeft(hamlibModelId, + databaseOpr.hasConfigKey(AudioChannelSelect.TX_CONFIG_KEY))) { + return; + } + GeneralVariables.txAudioChannel = AudioChannelSelect.LEFT; + databaseOpr.writeConfig(AudioChannelSelect.TX_CONFIG_KEY, + String.valueOf(AudioChannelSelect.LEFT), null); + GeneralVariables.fileLog( + "QMX: defaulted TX audio channel to LEFT (first connect, no prior user choice)"); + } + + /** + * The decision behind {@link #applyQmxTxChannelDefaultIfApplicable}, pure + * and testable without a real {@code DatabaseOpr}/{@code Context}. + */ + static boolean shouldDefaultQmxTxChannelToLeft(int hamlibModelId, boolean txChannelConfigKeyExists) { + return hamlibModelId == HamlibRig.QRPLABS_QMX_MODEL_ID && !txChannelConfigKeyExists; + } + /** * Create different rig models based on the instruction set */ @@ -2447,7 +2484,9 @@ private void connectRig() { android.content.Context ctx = GeneralVariables.getMainContext(); RigNameList.RigName selected = ctx == null ? null : RigNameList.getInstance(ctx).getRigNameByIndex(GeneralVariables.modelNo); - baseRig = new HamlibRig(HamlibRig.modelIdFor(selected)); + int hamlibModelId = HamlibRig.modelIdFor(selected); + applyQmxTxChannelDefaultIfApplicable(hamlibModelId); + baseRig = new HamlibRig(hamlibModelId); break; } } diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java index 49fe0363e..15f93d19c 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java @@ -799,6 +799,23 @@ public void writeConfig(String KeyName, String Value, OnAfterWriteConfig onAfter new WriteConfig(db, KeyName, Value, onAfterWriteConfig).execute(); } + /** + * Whether a config row exists for {@code keyName} at all -- synchronous + * (unlike every other config accessor here), for callers on a background + * thread that need an answer before deciding what to do next (e.g. + * whether the operator has ever touched a given setting themselves, + * distinct from it merely holding its hardcoded default). + */ + public boolean hasConfigKey(String keyName) { + Cursor cursor = db.rawQuery("select 1 from config where KeyName = ? limit 1", + new String[]{keyName}); + try { + return cursor.moveToFirst(); + } finally { + cursor.close(); + } + } + /** * The config value as it may appear in logcat. Keys that hold credentials * ({@link SettingsBackup#getSENSITIVE_KEYS()} -- the API keys and passwords a diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java index 1747fcba6..3bc5c360b 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java @@ -299,8 +299,12 @@ void disableQmxIqModeIfApplicable(BaseRigConnector c) { c.sendData("Q90;".getBytes(java.nio.charset.StandardCharsets.US_ASCII)); } - /** hamlib model ID for the QRP Labs QMX (0x809), per rigaddress.txt. */ - private static final int QRPLABS_QMX_MODEL_ID = 0x809; + /** + * hamlib model ID for the QRP Labs QMX (0x809), per rigaddress.txt. Public: + * also used by {@code MainViewModel} to apply the QMX's LEFT-channel TX + * audio default on connect (see {@code shouldDefaultQmxTxChannelToLeft}). + */ + public static final int QRPLABS_QMX_MODEL_ID = 0x809; /** * hamlib model ID for the currently-selected rig entry, or 0 if it isn't a diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/MainViewModelQmxTxChannelDefaultTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/MainViewModelQmxTxChannelDefaultTest.java new file mode 100644 index 000000000..a198764ce --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/MainViewModelQmxTxChannelDefaultTest.java @@ -0,0 +1,46 @@ +package com.k1af.ft8af; + +import static com.google.common.truth.Truth.assertThat; + +import com.k1af.ft8af.rigs.HamlibRig; + +import org.junit.Test; + +/** + * Tests {@link MainViewModel#shouldDefaultQmxTxChannelToLeft} -- the decision + * behind defaulting a QMX's TX audio to the left channel on first connect + * (real-hardware finding: FT8AF's historical {@code BOTH} default + * underdrives this radio's modulator; WSJT-X and QFT8 both need an explicit + * mono/left selection for full drive). Must fire only for the QMX, and only + * when the operator has never touched the TX channel setting themselves. + */ +public class MainViewModelQmxTxChannelDefaultTest { + + @Test + public void qmxWithNoPriorChoice_defaults() { + assertThat(MainViewModel.shouldDefaultQmxTxChannelToLeft( + HamlibRig.QRPLABS_QMX_MODEL_ID, false)).isTrue(); + } + + @Test + public void qmxWithAnExistingChoice_neverOverridden() { + // Even if the existing choice happens to be the same as the historical + // default (BOTH) -- the row's mere existence means the operator (or an + // earlier run of this same default) already decided, and that decision + // must stick. + assertThat(MainViewModel.shouldDefaultQmxTxChannelToLeft( + HamlibRig.QRPLABS_QMX_MODEL_ID, true)).isFalse(); + } + + @Test + public void otherHamlibRig_neverDefaulted() { + // 1036 = Yaesu FT-891 (Hamlib) -- no evidence this applies to any rig + // but the QMX specifically. + assertThat(MainViewModel.shouldDefaultQmxTxChannelToLeft(1036, false)).isFalse(); + } + + @Test + public void nonHamlibModelId_neverDefaulted() { + assertThat(MainViewModel.shouldDefaultQmxTxChannelToLeft(0, false)).isFalse(); + } +} diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/database/DatabaseOprHasConfigKeyTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/database/DatabaseOprHasConfigKeyTest.java new file mode 100644 index 000000000..34768404f --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/database/DatabaseOprHasConfigKeyTest.java @@ -0,0 +1,46 @@ +package com.k1af.ft8af.database; + +import static com.google.common.truth.Truth.assertThat; + +import androidx.test.core.app.ApplicationProvider; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * {@link DatabaseOpr#hasConfigKey} distinguishes "the operator explicitly set + * this" from "this still holds its hardcoded default, nobody's touched it" -- + * a plain read of the hydrated {@code GeneralVariables} field can't tell the + * two apart, since an explicit choice that happens to match the default looks + * identical to never having chosen at all. Used to decide whether it's safe + * to apply a rig-specific default (e.g. the QMX's TX audio channel) without + * overriding a choice the operator already made themselves. + */ +@RunWith(RobolectricTestRunner.class) +public class DatabaseOprHasConfigKeyTest { + + @Test + public void absentKey_returnsFalse() { + DatabaseOpr opr = new DatabaseOpr(ApplicationProvider.getApplicationContext(), null, null, + DatabaseOpr.SCHEMA_VERSION); + assertThat(opr.hasConfigKey("neverWrittenKey")).isFalse(); + } + + @Test + public void writtenKey_returnsTrue_evenIfTheValueMatchesTheDefault() { + DatabaseOpr opr = new DatabaseOpr(ApplicationProvider.getApplicationContext(), null, null, + DatabaseOpr.SCHEMA_VERSION); + Map config = new LinkedHashMap<>(); + // "0" is AudioChannelSelect.BOTH, the same value the field already holds + // before anyone writes anything -- the row's mere existence must still + // count as an explicit choice, not be indistinguishable from absence. + config.put("txAudioChannel", "0"); + opr.writeConfigSync(config); + + assertThat(opr.hasConfigKey("txAudioChannel")).isTrue(); + } +} From 27b31fd1c4df86159740bf6e1c3fc8d52e9cbd77 Mon Sep 17 00:00:00 2001 From: quadra Date: Fri, 23 Oct 2026 12:34:25 +0000 Subject: [PATCH 34/36] Pause RX capture during TX when sharing one USB rig (fixes silent TX) Root cause of the QMX "TX keys but drive is low/absent" symptom chased over the last two sessions: it was never a volume/attenuation problem. On this phone's USB-audio HAL, opening TX's AudioTrack while RX's AudioRecord is still open on the same USB device fails at the ALSA proxy (pcm_is_ready(): I/O error), and every subsequent TX write is then silently dropped (write(), mPcm is NULL!) with no error surfaced anywhere -- confirmed by instrumenting the actual sample values written: full-scale all the way to AudioTrack.write(), yet nothing reached the rig. A reference app (QFT8) that drives the same radio to full output runs strictly half-duplex (close capture, play, reopen capture), confirmed via its own logcat. Fix: MainViewModel's existing beginKeying()/endKeying() choke point (already used for PTT and Bluetooth SCO around every TX) now also pauses and resumes RX capture via the new HamRecorder.pauseForTx()/ resumeAfterTx(), gated by the pure, tested MainViewModel.shouldPauseRxCaptureForTx() -- only when RX capture and TX output are confirmed to be the same USB device, so LAN rigs, USB-direct paths, and non-shared-device setups are unaffected. All the AudioAttributes/session-id/low-latency work from PR #952 is superseded by this -- that path never mattered, since the audio never reached the rig in the first place. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf --- AGENTS.md | 28 ++++++ .../java/com/k1af/ft8af/MainViewModel.java | 56 +++++++++++ .../java/com/k1af/ft8af/wave/HamRecorder.java | 55 +++++++++++ .../MainViewModelRxTxUsbConflictTest.java | 93 +++++++++++++++++++ .../ft8af/wave/HamRecorderMicGateTest.java | 11 +++ 5 files changed, 243 insertions(+) create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/MainViewModelRxTxUsbConflictTest.java diff --git a/AGENTS.md b/AGENTS.md index 109fc39c6..e5336c59c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -360,3 +360,31 @@ interface (that runs the real `usb_audio_disconnect`). A genuine bus drop looks different: `usbDetach` for the hub, serial and audio devices together and `serial.send: port not open!` — that one is electrical (RF resetting the hub), not software. + +**5. On at least one phone's USB-audio HAL, RX capture and TX playback +cannot share one USB rig at the same time.** Opening TX's `AudioTrack` while +`MicRecorder`'s RX `AudioRecord` is still open on the same USB device fails +at the ALSA proxy level, and every subsequent write is then silently +dropped — the generated waveform can be full-scale all the way to +`AudioTrack.write()` and still never reach the rig, with no exception and no +error surfaced to the app. Tell from log (vendor tags, MediaTek-specific +spelling, but the pattern generalizes): `AudioALSAPlaybackHandlerUsb: open(), +proxy_open fail, ret -12` right after `alsa_device_proxy: proxy_open() +pcm_is_ready() failed: cannot set hw params: I/O error`, then +`AudioALSAPlaybackHandlerUsb: write(), mPcm is NULL!` repeated once per +buffer for the rest of the transmission (thousands of lines on a 13 s over). +Correlates exactly with whether `AudioALSACaptureHandlerUsb` is open at that +moment — a reference app (QFT8) that drives the same radio to full output +runs strictly half-duplex: it closes its USB capture, plays, then reopens +capture, and its opens never fail. Fixed by doing the same thing around +every "over"/TUNE key-down: `MainViewModel`'s shared `beginKeying()`/ +`endKeying()` (already the single choke point for PTT and SCO around TX, see +items above) now calls `HamRecorder.pauseForTx()`/`resumeAfterTx()`, gated by +`MainViewModel.shouldPauseRxCaptureForTx()` — deliberately narrow: only when +RX is actually an Android capture session (not a LAN rig, not already +USB-direct) on the *same* USB VID:PID as TX's output (not USB-direct output +either, which is separately broken for unrelated reasons). Don't assume this +generalizes to every USB audio chipset — it may be specific to this HAL — +but the guard is cheap and a no-op whenever TX/RX aren't sharing one USB +device, so there's little reason to narrow it further without a +contradicting report. 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 f9a752715..80694d91c 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java @@ -987,6 +987,11 @@ private boolean needControlSco() {//determine whether SCO needs to be enabled ba baseRig != null && baseRig.supportWaveOverCAT()); } + // Whether this over's beginKeying() actually paused RX capture, so + // endKeying() only resumes what it paused (see + // shouldPauseRxCaptureForTx's javadoc for why/when). + private boolean rxPausedForThisKeying = false; + /** * Assert PTT (+ pause SCO) through the configured control path. Shared * by the FT8 TX path and the tune carrier (issue #408) so the @@ -994,6 +999,18 @@ private boolean needControlSco() {//determine whether SCO needs to be enabled ba * (VOX), this is a no-op and the audio itself keys the rig. */ private void beginKeying() { + rxPausedForThisKeying = hamRecorder != null && shouldPauseRxCaptureForTx( + hamRecorder.isMicSource(), + hamRecorder.getMicRecorder().isUsingUsbDirect(), + GeneralVariables.audioOutputDeviceId == -1, + GeneralVariables.usbAudioInputVendorId, + GeneralVariables.usbAudioInputProductId, + GeneralVariables.usbAudioOutputVendorId, + GeneralVariables.usbAudioOutputProductId); + if (rxPausedForThisKeying) { + hamRecorder.pauseForTx(); + } + boolean keysViaControlPath = GeneralVariables.controlMode == ControlMode.CAT || GeneralVariables.controlMode == ControlMode.RTS || GeneralVariables.controlMode == ControlMode.DTR; @@ -1046,6 +1063,11 @@ private void endKeying() { // The post-TX restart has been requested; the next over takes a // fresh snapshot in beginKeying(). txScoLatch.keyUp(); + + if (rxPausedForThisKeying) { + rxPausedForThisKeying = false; + hamRecorder.resumeAfterTx(); + } } @Override @@ -2379,6 +2401,40 @@ static boolean shouldDefaultQmxTxChannelToLeft(int hamlibModelId, boolean txChan return hamlibModelId == HamlibRig.QRPLABS_QMX_MODEL_ID && !txChannelConfigKeyExists; } + /** + * Whether RX capture must be paused for the duration of this TX (see + * {@link HamRecorder#pauseForTx()} for the real-hardware failure this + * works around). True only when RX is actually holding an Android + * capture session on the exact same USB device TX is about to play on -- + * deliberately narrow, since the conflict was only confirmed for that + * exact combination: + * + *

    + *
  • RX must be the phone-audio path ({@code HamRecorder.isMicSource()}), + * not a LAN-connected rig -- those never touch this HAL at all, and + * toggling capture off would spuriously gate their audio too (see + * {@code HamRecorder.doOnWaveDataReceived}'s {@code isRunning} check); + *
  • RX must not already be USB-direct (raw libusb capture, a + * different HAL path with no ALSA proxy to conflict over); + *
  • TX must not be USB-direct output either (that path is separately, + * already broken -- {@code LIBUSB_ERROR_IO} -- and conflating the + * two bugs would make either one harder to fix); + *
  • the input and output VID:PID must both be set and match -- two + * different USB devices, or a non-USB default mic/speaker, have no + * reason to share this HAL's single ALSA card. + *
+ */ + static boolean shouldPauseRxCaptureForTx(boolean rxIsMicSource, boolean rxIsUsbDirect, + boolean txIsUsbDirectOutput, int usbInputVendorId, int usbInputProductId, + int usbOutputVendorId, int usbOutputProductId) { + if (!rxIsMicSource || rxIsUsbDirect || txIsUsbDirectOutput) { + return false; + } + return usbInputVendorId != 0 + && usbInputVendorId == usbOutputVendorId + && usbInputProductId == usbOutputProductId; + } + /** * Create different rig models based on the instruction set */ diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/wave/HamRecorder.java b/ft8af/app/src/main/java/com/k1af/ft8af/wave/HamRecorder.java index 5ba3ddbf8..b688d49b6 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/wave/HamRecorder.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/wave/HamRecorder.java @@ -231,6 +231,61 @@ public void stopRecord() { isRunning = false; } + // Whether pauseForTx() actually stopped a running capture, so + // resumeAfterTx() only restarts it when it was genuinely running before + // -- never reviving capture the operator had deliberately stopped, and + // never double-counting a TX that started while already paused. + private boolean rxWasRunningBeforeTxPause = false; + + /** + * Release RX capture for the duration of one TX "over"/TUNE key-down, when + * the caller ({@code MainViewModel.shouldPauseRxCaptureForTx}) has decided + * TX and RX share the same physical USB audio device. On that hardware, + * opening TX playback while this class's RX capture is still open on the + * same device fails at the ALSA proxy level + * (alsa_device_proxy: proxy_open() pcm_is_ready() failed: cannot + * set hw params: I/O error) and every subsequent write is silently + * dropped (AudioALSAPlaybackHandlerUsb: write(), mPcm is NULL!) + * -- confirmed on real hardware: FT8AF's generated waveform measured + * full-scale all the way to the {@code AudioTrack.write()} call, and TX + * was still inaudible on the rig, because the HAL never actually opened + * the output. A reference app (QFT8) that drives the same radio to full + * output does exactly this: close capture, play, reopen capture + * (confirmed via its own logcat -- it closes {@code + * AudioALSACaptureHandlerUsb} ~260ms before opening TX and reopens it + * right after). + * + *

Counterpart: {@link #resumeAfterTx()}. + */ + public synchronized void pauseForTx() { + rxWasRunningBeforeTxPause = isRunning; + if (rxWasRunningBeforeTxPause) { + stopRecord(); + } + } + + /** + * Restart RX capture after a TX "over"/TUNE key-up, undoing {@link + * #pauseForTx()} -- but only when that call actually paused a running + * capture, so a TX that happens while the operator had RX off (or while + * this was already paused) does not unexpectedly turn capture back on. + */ + public synchronized void resumeAfterTx() { + if (shouldResumeAfterTxPause(rxWasRunningBeforeTxPause)) { + rxWasRunningBeforeTxPause = false; + startRecord(); + } + } + + /** + * The decision behind {@link #resumeAfterTx()}, pure and testable: + * resume only when {@link #pauseForTx()} actually stopped a running + * capture. + */ + static boolean shouldResumeAfterTxPause(boolean wasRunningBeforePause) { + return wasRunningBeforePause; + } + /** The underlying mic capture, for routing introspection (issue #759). */ public MicRecorder getMicRecorder() { return micRecorder; diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/MainViewModelRxTxUsbConflictTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/MainViewModelRxTxUsbConflictTest.java new file mode 100644 index 000000000..a913b2082 --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/MainViewModelRxTxUsbConflictTest.java @@ -0,0 +1,93 @@ +package com.k1af.ft8af; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; + +/** + * Tests {@link MainViewModel#shouldPauseRxCaptureForTx} -- the decision + * behind pausing RX capture for the duration of one TX "over"/TUNE key-down + * (real-hardware finding: on at least one phone's USB-audio HAL, opening TX + * playback on a USB rig while RX capture from the same USB rig is still open + * fails silently at the ALSA proxy -- confirmed via logcat + * ("pcm_is_ready() failed: cannot set hw params: I/O error", then + * "write(), mPcm is NULL!" on every subsequent write) even though FT8AF's + * generated waveform measured full-scale all the way to the AudioTrack + * write call. A reference app (QFT8) that drives the same radio to full + * output does exactly this: close capture, play, reopen capture). + */ +public class MainViewModelRxTxUsbConflictTest { + + private static final int VID_A = 0x0483; + private static final int PID_A = 0xA34C; + private static final int VID_B = 0x10C4; + private static final int PID_B = 0xEA60; + + @Test + public void micSourceSharingTheUsbDevice_pauses() { + assertThat(MainViewModel.shouldPauseRxCaptureForTx( + /* rxIsMicSource= */ true, /* rxIsUsbDirect= */ false, + /* txIsUsbDirectOutput= */ false, + VID_A, PID_A, VID_A, PID_A)).isTrue(); + } + + @Test + public void lanRig_neverPaused() { + // LAN-connected rigs never touch this HAL at all, and HamRecorder's + // doOnWaveDataReceived gates on isRunning -- pausing here would + // spuriously drop their audio too, not just this HAL's. + assertThat(MainViewModel.shouldPauseRxCaptureForTx( + /* rxIsMicSource= */ false, /* rxIsUsbDirect= */ false, + /* txIsUsbDirectOutput= */ false, + VID_A, PID_A, VID_A, PID_A)).isFalse(); + } + + @Test + public void rxUsbDirectCapture_neverPaused() { + // Raw-libusb RX capture has no ALSA proxy to conflict with; that path + // is also independently broken for other reasons and shouldn't be + // conflated with this fix. + assertThat(MainViewModel.shouldPauseRxCaptureForTx( + /* rxIsMicSource= */ true, /* rxIsUsbDirect= */ true, + /* txIsUsbDirectOutput= */ false, + VID_A, PID_A, VID_A, PID_A)).isFalse(); + } + + @Test + public void txUsbDirectOutput_neverPaused() { + // USB-Direct TX output is a separate, already-broken path + // (LIBUSB_ERROR_IO) -- don't conflate the two bugs. + assertThat(MainViewModel.shouldPauseRxCaptureForTx( + /* rxIsMicSource= */ true, /* rxIsUsbDirect= */ false, + /* txIsUsbDirectOutput= */ true, + VID_A, PID_A, VID_A, PID_A)).isFalse(); + } + + @Test + public void differentUsbDevices_neverPaused() { + // Two distinct USB devices (or a USB mic with a non-USB/system + // output) have no reason to share one ALSA card. + assertThat(MainViewModel.shouldPauseRxCaptureForTx( + /* rxIsMicSource= */ true, /* rxIsUsbDirect= */ false, + /* txIsUsbDirectOutput= */ false, + VID_A, PID_A, VID_B, PID_B)).isFalse(); + } + + @Test + public void neitherVidPidSet_neverPaused() { + // 0:0 is "no USB device configured" (phone mic / default speaker on + // both sides) -- matching on 0:0 would be a false positive. + assertThat(MainViewModel.shouldPauseRxCaptureForTx( + /* rxIsMicSource= */ true, /* rxIsUsbDirect= */ false, + /* txIsUsbDirectOutput= */ false, + 0, 0, 0, 0)).isFalse(); + } + + @Test + public void sameVendorDifferentProduct_neverPaused() { + assertThat(MainViewModel.shouldPauseRxCaptureForTx( + /* rxIsMicSource= */ true, /* rxIsUsbDirect= */ false, + /* txIsUsbDirectOutput= */ false, + VID_A, PID_A, VID_A, PID_B)).isFalse(); + } +} diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/wave/HamRecorderMicGateTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/wave/HamRecorderMicGateTest.java index 6854f8e0c..12c59204d 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/wave/HamRecorderMicGateTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/wave/HamRecorderMicGateTest.java @@ -32,4 +32,15 @@ public void lanAudioSourceLeavesMicFree() { public void stoppedRecorderLeavesMicFree() { assertThat(HamRecorder.phoneMicCaptureInUse(false, true, false)).isFalse(); } + + /** + * {@link HamRecorder#shouldResumeAfterTxPause} -- resumeAfterTx() must + * only restart capture when pauseForTx() actually stopped a running one, + * never reviving capture the operator had deliberately turned off. + */ + @Test + public void resumeAfterTxPause_onlyWhenPauseActuallyStoppedCapture() { + assertThat(HamRecorder.shouldResumeAfterTxPause(true)).isTrue(); + assertThat(HamRecorder.shouldResumeAfterTxPause(false)).isFalse(); + } } From b0c37f9d5e4a409a18e360003ad0b4ac4f44e949 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Tue, 27 Oct 2026 09:39:56 -0500 Subject: [PATCH 35/36] QMX RX pause: gate on the QMX, nest pauses, retire the old reader Review fixes for the "pause RX capture during TX" change: - shouldPauseRxCaptureForTx now also requires the connected rig to be the QMX. A matching input/output VID:PID alone is true for every single-cable USB rig (IC-7300, IC-705, IC-9700, FT-991A, Digirig, SignaLink...), which would blank their waterfall during TX and break full-duplex satellite monitoring for a HAL conflict only ever confirmed with a QMX. - Pauses nest (HamRecorder.TxPauseState): arming TX only requests the tune stop, so an over's key-down can land before the tune's key-up. The single shared flag let one consume the other's pause and left RX capture off for good. MainViewModel keeps one flag per keying source, and the pause now happens last in beginKeying() so nothing can throw between it and the matching endKeying(). - MicRecorder.stopRecord() bumps the reader generation, stops the recording and joins the reader thread, so a quick resume can't leave the old reader running alongside the new one on the same AudioRecord (fatal native abort) or let it stop the new recording on its way out. - The QMX's LEFT TX channel default is applied in memory on each rig connect instead of being saved, so switching to another rig goes back to BOTH instead of keeping left-only TX. A saved user choice still wins. - AGENTS.md no longer calls the old gate "deliberately narrow". Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 20 ++- .../java/com/k1af/ft8af/MainViewModel.java | 140 +++++++++++------- .../java/com/k1af/ft8af/rigs/HamlibRig.java | 7 +- .../java/com/k1af/ft8af/wave/HamRecorder.java | 51 ++++--- .../java/com/k1af/ft8af/wave/MicRecorder.java | 28 ++++ .../MainViewModelQmxTxChannelDefaultTest.java | 47 +++--- .../MainViewModelRxTxUsbConflictTest.java | 36 ++++- .../ft8af/wave/HamRecorderMicGateTest.java | 41 ++++- .../MicRecorderStopRetiresReaderTest.java | 63 ++++++++ 9 files changed, 320 insertions(+), 113 deletions(-) create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/wave/MicRecorderStopRetiresReaderTest.java diff --git a/AGENTS.md b/AGENTS.md index e5336c59c..3551917d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -380,11 +380,15 @@ capture, and its opens never fail. Fixed by doing the same thing around every "over"/TUNE key-down: `MainViewModel`'s shared `beginKeying()`/ `endKeying()` (already the single choke point for PTT and SCO around TX, see items above) now calls `HamRecorder.pauseForTx()`/`resumeAfterTx()`, gated by -`MainViewModel.shouldPauseRxCaptureForTx()` — deliberately narrow: only when -RX is actually an Android capture session (not a LAN rig, not already -USB-direct) on the *same* USB VID:PID as TX's output (not USB-direct output -either, which is separately broken for unrelated reasons). Don't assume this -generalizes to every USB audio chipset — it may be specific to this HAL — -but the guard is cheap and a no-op whenever TX/RX aren't sharing one USB -device, so there's little reason to narrow it further without a -contradicting report. +`MainViewModel.shouldPauseRxCaptureForTx()`: only for the QRP Labs QMX, and +only when RX is actually an Android capture session (not a LAN rig, not +already USB-direct) on the *same* USB VID:PID as TX's output (not USB-direct +output either, which is separately broken for unrelated reasons). Keep the +QMX gate: a matching VID:PID alone is true for every single-cable USB rig +(IC-7300, IC-705, IC-9700, FT-991A, Digirig, SignaLink...), and pausing RX +there blanks the waterfall during TX and breaks full-duplex satellite +monitoring for a HAL conflict that was only ever confirmed with a QMX. +Widen it only on a confirmed report from another rig. Pauses nest (a tune +carrier and an over can overlap), so `HamRecorder` counts them, and +`MicRecorder.stopRecord()` joins the old reader thread so a quick +resume can't leave two readers on one `AudioRecord`. 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 9ea3311ec..618a74297 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java @@ -988,10 +988,14 @@ private boolean needControlSco() {//determine whether SCO needs to be enabled ba baseRig != null && baseRig.supportWaveOverCAT()); } - // Whether this over's beginKeying() actually paused RX capture, so - // endKeying() only resumes what it paused (see - // shouldPauseRxCaptureForTx's javadoc for why/when). - private boolean rxPausedForThisKeying = false; + // Whether the FT8 over's / the tune carrier's beginKeying() actually + // paused RX capture, so its endKeying() only resumes what it paused + // (see shouldPauseRxCaptureForTx's javadoc for why/when). One flag + // per keying source: arming TX only *requests* a tune stop, so the + // tune's endKeying() can run after the over's beginKeying(), and a + // single shared flag would let one consume the other's pause. + private volatile boolean rxPausedForOver = false; + private volatile boolean rxPausedForTune = false; /** * Assert PTT (+ pause SCO) through the configured control path. Shared @@ -999,19 +1003,7 @@ private boolean needControlSco() {//determine whether SCO needs to be enabled ba * CAT/RTS/DTR branching lives in one place; with no control path * (VOX), this is a no-op and the audio itself keys the rig. */ - private void beginKeying() { - rxPausedForThisKeying = hamRecorder != null && shouldPauseRxCaptureForTx( - hamRecorder.isMicSource(), - hamRecorder.getMicRecorder().isUsingUsbDirect(), - GeneralVariables.audioOutputDeviceId == -1, - GeneralVariables.usbAudioInputVendorId, - GeneralVariables.usbAudioInputProductId, - GeneralVariables.usbAudioOutputVendorId, - GeneralVariables.usbAudioOutputProductId); - if (rxPausedForThisKeying) { - hamRecorder.pauseForTx(); - } - + private void beginKeying(boolean tune) { boolean keysViaControlPath = GeneralVariables.controlMode == ControlMode.CAT || GeneralVariables.controlMode == ControlMode.RTS || GeneralVariables.controlMode == ControlMode.DTR; @@ -1043,10 +1035,31 @@ private void beginKeying() { pttSafetyLatch.onKeyed(); baseRig.setPTT(true); } + + // Last, so nothing above can throw between the pause and the + // caller's matching endKeying(): the TX audio itself only opens + // after the PTT settle delay, still well after this. + boolean pause = hamRecorder != null && shouldPauseRxCaptureForTx( + isQmxRig(baseRig), + hamRecorder.isMicSource(), + hamRecorder.getMicRecorder().isUsingUsbDirect(), + GeneralVariables.audioOutputDeviceId == -1, + GeneralVariables.usbAudioInputVendorId, + GeneralVariables.usbAudioInputProductId, + GeneralVariables.usbAudioOutputVendorId, + GeneralVariables.usbAudioOutputProductId); + if (pause) { + hamRecorder.pauseForTx(); + } + if (tune) { + rxPausedForTune = pause; + } else { + rxPausedForOver = pause; + } } - /** Release PTT (+ restore SCO); counterpart of {@link #beginKeying()}. */ - private void endKeying() { + /** Release PTT (+ restore SCO); counterpart of {@link #beginKeying(boolean)}. */ + private void endKeying(boolean tune) { if (GeneralVariables.controlMode == ControlMode.CAT || GeneralVariables.controlMode == ControlMode.RTS || GeneralVariables.controlMode == ControlMode.DTR) { @@ -1065,15 +1078,22 @@ private void endKeying() { // fresh snapshot in beginKeying(). txScoLatch.keyUp(); - if (rxPausedForThisKeying) { - rxPausedForThisKeying = false; + boolean paused; + if (tune) { + paused = rxPausedForTune; + rxPausedForTune = false; + } else { + paused = rxPausedForOver; + rxPausedForOver = false; + } + if (paused) { hamRecorder.resumeAfterTx(); } } @Override public void onBeforeTransmit(Ft8Message message, int functionOder) { - beginKeying(); + beginKeying(false); if (ft8TransmitSignal.isActivated()) { GeneralVariables.transmitMessages.add(message); //mutableTransmitMessages.postValue(GeneralVariables.transmitMessages); @@ -1083,17 +1103,17 @@ public void onBeforeTransmit(Ft8Message message, int functionOder) { @Override public void onAfterTransmit(Ft8Message message, int functionOder) { - endKeying(); + endKeying(false); } @Override public void onTuneKeyDown() { - beginKeying(); + beginKeying(true); } @Override public void onTuneKeyUp() { - endKeying(); + endKeying(true); } @Override @@ -2384,41 +2404,58 @@ private void repairCivAddressAgainstModel() { * selection opens a genuine stereo track with the left channel at full * scale and the right silenced (see {@link AudioChannelSelect}) -- * underdriven either way until the operator finds the manual Settings - * toggle. Defaults {@code txAudioChannel} to {@code LEFT} the first time - * the QMX connects, but only when the operator has never touched that - * setting at all (no "txAudioChannel" config row yet) -- an explicit - * choice, even {@code BOTH} itself, is never overridden. - */ - private void applyQmxTxChannelDefaultIfApplicable(int hamlibModelId) { + * toggle. While a QMX is the connected rig, uses {@code LEFT} for TX, but + * only when the operator has never touched that setting at all (no + * "txAudioChannel" config row) -- an explicit choice, even {@code BOTH} + * itself, is never overridden. Applied in memory on every rig connect and + * never saved, so switching to another rig goes back to {@code BOTH} + * instead of leaving that rig on a left-only TX it never asked for. + */ + private void applyTxChannelDefaultForRig(boolean rigIsQmx) { if (databaseOpr == null) return; - if (!shouldDefaultQmxTxChannelToLeft(hamlibModelId, - databaseOpr.hasConfigKey(AudioChannelSelect.TX_CONFIG_KEY))) { - return; + boolean keyExists = databaseOpr.hasConfigKey(AudioChannelSelect.TX_CONFIG_KEY); + int channel = defaultTxChannelForRig(rigIsQmx, keyExists, GeneralVariables.txAudioChannel); + if (channel != GeneralVariables.txAudioChannel) { + GeneralVariables.txAudioChannel = channel; + GeneralVariables.fileLog("TX audio channel defaulted to " + + (channel == AudioChannelSelect.LEFT ? "LEFT (QMX)" : "BOTH") + + " (no saved user choice)"); } - GeneralVariables.txAudioChannel = AudioChannelSelect.LEFT; - databaseOpr.writeConfig(AudioChannelSelect.TX_CONFIG_KEY, - String.valueOf(AudioChannelSelect.LEFT), null); - GeneralVariables.fileLog( - "QMX: defaulted TX audio channel to LEFT (first connect, no prior user choice)"); } /** - * The decision behind {@link #applyQmxTxChannelDefaultIfApplicable}, pure - * and testable without a real {@code DatabaseOpr}/{@code Context}. + * The decision behind {@link #applyTxChannelDefaultForRig}, pure and + * testable without a real {@code DatabaseOpr}/{@code Context}: the TX + * channel to use for the rig being connected. A saved choice + * ({@code txChannelConfigKeyExists}) keeps {@code current}; otherwise the + * QMX gets {@code LEFT} and every other rig the {@code BOTH} default. */ - static boolean shouldDefaultQmxTxChannelToLeft(int hamlibModelId, boolean txChannelConfigKeyExists) { - return hamlibModelId == HamlibRig.QRPLABS_QMX_MODEL_ID && !txChannelConfigKeyExists; + static int defaultTxChannelForRig(boolean rigIsQmx, boolean txChannelConfigKeyExists, + int current) { + if (txChannelConfigKeyExists) return current; + return rigIsQmx ? AudioChannelSelect.LEFT : AudioChannelSelect.BOTH; + } + + /** Whether {@code rig} is a QRP Labs QMX driven through hamlib. */ + static boolean isQmxRig(BaseRig rig) { + return rig instanceof HamlibRig && ((HamlibRig) rig).isQmx(); } /** * Whether RX capture must be paused for the duration of this TX (see * {@link HamRecorder#pauseForTx()} for the real-hardware failure this - * works around). True only when RX is actually holding an Android - * capture session on the exact same USB device TX is about to play on -- - * deliberately narrow, since the conflict was only confirmed for that - * exact combination: + * works around). True only for the QRP Labs QMX, and only when RX is + * actually holding an Android capture session on the exact same USB + * device TX is about to play on. The QMX gate matters: matching VID:PID + * alone is true for every single-cable USB rig (IC-7300, IC-705, + * IC-9700, FT-991A, Digirig, SignaLink...) whose operator picked the + * rig's codec for both directions, and pausing those would blank the + * waterfall during TX, break full-duplex satellite monitoring, and risk + * delaying the last RX slot's decode -- for a HAL conflict only ever + * confirmed with a QMX: * *

    + *
  • the rig must be a QMX ({@link #isQmxRig}); *
  • RX must be the phone-audio path ({@code HamRecorder.isMicSource()}), * not a LAN-connected rig -- those never touch this HAL at all, and * toggling capture off would spuriously gate their audio too (see @@ -2433,10 +2470,11 @@ static boolean shouldDefaultQmxTxChannelToLeft(int hamlibModelId, boolean txChan * reason to share this HAL's single ALSA card. *
*/ - static boolean shouldPauseRxCaptureForTx(boolean rxIsMicSource, boolean rxIsUsbDirect, - boolean txIsUsbDirectOutput, int usbInputVendorId, int usbInputProductId, + static boolean shouldPauseRxCaptureForTx(boolean rigIsQmx, boolean rxIsMicSource, + boolean rxIsUsbDirect, boolean txIsUsbDirectOutput, + int usbInputVendorId, int usbInputProductId, int usbOutputVendorId, int usbOutputProductId) { - if (!rxIsMicSource || rxIsUsbDirect || txIsUsbDirectOutput) { + if (!rigIsQmx || !rxIsMicSource || rxIsUsbDirect || txIsUsbDirectOutput) { return false; } return usbInputVendorId != 0 @@ -2550,11 +2588,11 @@ private void connectRig() { RigNameList.RigName selected = ctx == null ? null : RigNameList.getInstance(ctx).getRigNameByIndex(GeneralVariables.modelNo); int hamlibModelId = HamlibRig.modelIdFor(selected); - applyQmxTxChannelDefaultIfApplicable(hamlibModelId); baseRig = new HamlibRig(hamlibModelId); break; } } + applyTxChannelDefaultForRig(isQmxRig(baseRig)); // Store the rig name for PSKReporter software string. // Use the user-selected model name from RigNameList (same source as the diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java index 3bc5c360b..cbbcc887f 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/HamlibRig.java @@ -299,10 +299,15 @@ void disableQmxIqModeIfApplicable(BaseRigConnector c) { c.sendData("Q90;".getBytes(java.nio.charset.StandardCharsets.US_ASCII)); } + /** True when this rig is the QRP Labs QMX (see {@link #QRPLABS_QMX_MODEL_ID}). */ + public boolean isQmx() { + return modelId == QRPLABS_QMX_MODEL_ID; + } + /** * hamlib model ID for the QRP Labs QMX (0x809), per rigaddress.txt. Public: * also used by {@code MainViewModel} to apply the QMX's LEFT-channel TX - * audio default on connect (see {@code shouldDefaultQmxTxChannelToLeft}). + * audio default on connect (see {@code defaultTxChannelForRig}). */ public static final int QRPLABS_QMX_MODEL_ID = 0x809; diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/wave/HamRecorder.java b/ft8af/app/src/main/java/com/k1af/ft8af/wave/HamRecorder.java index b688d49b6..2779f7a96 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/wave/HamRecorder.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/wave/HamRecorder.java @@ -231,11 +231,8 @@ public void stopRecord() { isRunning = false; } - // Whether pauseForTx() actually stopped a running capture, so - // resumeAfterTx() only restarts it when it was genuinely running before - // -- never reviving capture the operator had deliberately stopped, and - // never double-counting a TX that started while already paused. - private boolean rxWasRunningBeforeTxPause = false; + // Nesting state for pauseForTx()/resumeAfterTx(); see TxPauseState. + private final TxPauseState txPause = new TxPauseState(); /** * Release RX capture for the duration of one TX "over"/TUNE key-down, when @@ -258,32 +255,52 @@ public void stopRecord() { *

Counterpart: {@link #resumeAfterTx()}. */ public synchronized void pauseForTx() { - rxWasRunningBeforeTxPause = isRunning; - if (rxWasRunningBeforeTxPause) { + if (txPause.onPause(isRunning)) { stopRecord(); } } /** * Restart RX capture after a TX "over"/TUNE key-up, undoing {@link - * #pauseForTx()} -- but only when that call actually paused a running - * capture, so a TX that happens while the operator had RX off (or while - * this was already paused) does not unexpectedly turn capture back on. + * #pauseForTx()} -- but only once every outstanding pause has been + * resumed, and only when the outermost pause actually stopped a running + * capture, so a TX that happens while the operator had RX off does not + * unexpectedly turn capture back on. A resume with no pause outstanding + * is ignored. */ public synchronized void resumeAfterTx() { - if (shouldResumeAfterTxPause(rxWasRunningBeforeTxPause)) { - rxWasRunningBeforeTxPause = false; + if (txPause.onResume()) { startRecord(); } } /** - * The decision behind {@link #resumeAfterTx()}, pure and testable: - * resume only when {@link #pauseForTx()} actually stopped a running - * capture. + * Nesting state behind {@link #pauseForTx()}/{@link #resumeAfterTx()}, + * pure so it is testable without a recorder. A tune carrier and an FT8 + * over can overlap (arming TX only requests the tune stop), so pauses + * nest: only the first stops capture, and only the last resume restarts + * it -- and only if that first pause actually stopped a running capture, + * never reviving one the operator had deliberately stopped. */ - static boolean shouldResumeAfterTxPause(boolean wasRunningBeforePause) { - return wasRunningBeforePause; + static final class TxPauseState { + private int depth = 0; + private boolean wasRunning = false; + + /** A pause began; true when the caller must stop the running capture. */ + boolean onPause(boolean running) { + if (depth++ > 0) return false; + wasRunning = running; + return running; + } + + /** A pause ended; true when the caller must restart capture. */ + boolean onResume() { + if (depth == 0) return false; + if (--depth > 0) return false; + boolean restart = wasRunning; + wasRunning = false; + return restart; + } } /** The underlying mic capture, for routing introspection (issue #759). */ diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/wave/MicRecorder.java b/ft8af/app/src/main/java/com/k1af/ft8af/wave/MicRecorder.java index 6e3b314fd..813273ec8 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/wave/MicRecorder.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/wave/MicRecorder.java @@ -712,13 +712,31 @@ public void run() { * restart capture the operator explicitly stopped. {@link #reinitialize()} * itself does the equivalent teardown inline rather than calling this, so * its own internal stop/rebuild cycle never clears that intent flag. + * + *

Retires the AudioRecord reader thread before returning (new + * generation, stop the recording to unblock its read(), bounded join), + * so a {@link #start()} right after -- e.g. RX resuming after a short TX + * pause -- can't leave the old reader running alongside the new one on + * the same AudioRecord (a fatal native abort), or let the old reader's + * exit stop the new recording. */ public synchronized void stopRecord() { + captureGeneration++; isRunning = false; shouldBeRunning = false; if (useUsbAudio && usbAudioDevice != null) { usbAudioDevice.stopCapture(); } + if (audioRecord != null) { + try { + if (audioRecord.getRecordingState() == AudioRecord.RECORDSTATE_RECORDING) { + audioRecord.stop(); + } + } catch (Exception e) { + Log.d(TAG, "stopRecord: error stopping AudioRecord: " + e.getMessage()); + } + joinCaptureThread(); + } } public OnDataListener getOnDataListener() { @@ -851,6 +869,16 @@ static boolean shouldRestartAfterReinitialize(boolean shouldBeRunning) { return shouldBeRunning; } + /** Current reader-thread generation. Package-visible for testing. */ + int captureGenerationForTest() { + return captureGeneration; + } + + /** The most recent AudioRecord reader thread. Package-visible for testing. */ + Thread captureThreadForTest() { + return captureThread; + } + /** * Wait for the AudioRecord reader thread to exit before its AudioRecord is * released. Bounded by {@link #CAPTURE_JOIN_TIMEOUT_MS} so a thread stuck diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/MainViewModelQmxTxChannelDefaultTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/MainViewModelQmxTxChannelDefaultTest.java index a198764ce..652b4cd20 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/MainViewModelQmxTxChannelDefaultTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/MainViewModelQmxTxChannelDefaultTest.java @@ -2,45 +2,46 @@ import static com.google.common.truth.Truth.assertThat; -import com.k1af.ft8af.rigs.HamlibRig; +import com.k1af.ft8af.wave.AudioChannelSelect; import org.junit.Test; /** - * Tests {@link MainViewModel#shouldDefaultQmxTxChannelToLeft} -- the decision - * behind defaulting a QMX's TX audio to the left channel on first connect - * (real-hardware finding: FT8AF's historical {@code BOTH} default - * underdrives this radio's modulator; WSJT-X and QFT8 both need an explicit - * mono/left selection for full drive). Must fire only for the QMX, and only - * when the operator has never touched the TX channel setting themselves. + * Tests {@link MainViewModel#defaultTxChannelForRig} -- the TX audio channel + * used for the rig being connected (real-hardware finding: FT8AF's historical + * {@code BOTH} default underdrives the QMX's modulator; WSJT-X and QFT8 both + * need an explicit mono/left selection for full drive). The QMX gets + * {@code LEFT} only while it is the connected rig and only when the operator + * has never saved a TX channel choice themselves. */ public class MainViewModelQmxTxChannelDefaultTest { @Test - public void qmxWithNoPriorChoice_defaults() { - assertThat(MainViewModel.shouldDefaultQmxTxChannelToLeft( - HamlibRig.QRPLABS_QMX_MODEL_ID, false)).isTrue(); + public void qmxWithNoSavedChoice_usesLeft() { + assertThat(MainViewModel.defaultTxChannelForRig( + true, false, AudioChannelSelect.BOTH)).isEqualTo(AudioChannelSelect.LEFT); } @Test - public void qmxWithAnExistingChoice_neverOverridden() { - // Even if the existing choice happens to be the same as the historical - // default (BOTH) -- the row's mere existence means the operator (or an - // earlier run of this same default) already decided, and that decision - // must stick. - assertThat(MainViewModel.shouldDefaultQmxTxChannelToLeft( - HamlibRig.QRPLABS_QMX_MODEL_ID, true)).isFalse(); + public void savedChoice_neverOverridden() { + // Even a saved BOTH -- the row's existence means the operator decided. + assertThat(MainViewModel.defaultTxChannelForRig( + true, true, AudioChannelSelect.BOTH)).isEqualTo(AudioChannelSelect.BOTH); + assertThat(MainViewModel.defaultTxChannelForRig( + false, true, AudioChannelSelect.RIGHT)).isEqualTo(AudioChannelSelect.RIGHT); } @Test - public void otherHamlibRig_neverDefaulted() { - // 1036 = Yaesu FT-891 (Hamlib) -- no evidence this applies to any rig - // but the QMX specifically. - assertThat(MainViewModel.shouldDefaultQmxTxChannelToLeft(1036, false)).isFalse(); + public void switchingFromQmxToAnotherRig_goesBackToBoth() { + // The QMX default was applied in memory only (LEFT); the next rig + // connected with no saved choice must not inherit left-only TX. + assertThat(MainViewModel.defaultTxChannelForRig( + false, false, AudioChannelSelect.LEFT)).isEqualTo(AudioChannelSelect.BOTH); } @Test - public void nonHamlibModelId_neverDefaulted() { - assertThat(MainViewModel.shouldDefaultQmxTxChannelToLeft(0, false)).isFalse(); + public void otherRigWithNoSavedChoice_usesBoth() { + assertThat(MainViewModel.defaultTxChannelForRig( + false, false, AudioChannelSelect.BOTH)).isEqualTo(AudioChannelSelect.BOTH); } } diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/MainViewModelRxTxUsbConflictTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/MainViewModelRxTxUsbConflictTest.java index a913b2082..43957fbe8 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/MainViewModelRxTxUsbConflictTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/MainViewModelRxTxUsbConflictTest.java @@ -2,6 +2,8 @@ import static com.google.common.truth.Truth.assertThat; +import com.k1af.ft8af.rigs.HamlibRig; + import org.junit.Test; /** @@ -26,7 +28,7 @@ public class MainViewModelRxTxUsbConflictTest { @Test public void micSourceSharingTheUsbDevice_pauses() { assertThat(MainViewModel.shouldPauseRxCaptureForTx( - /* rxIsMicSource= */ true, /* rxIsUsbDirect= */ false, + /* rigIsQmx= */ true, /* rxIsMicSource= */ true, /* rxIsUsbDirect= */ false, /* txIsUsbDirectOutput= */ false, VID_A, PID_A, VID_A, PID_A)).isTrue(); } @@ -37,7 +39,7 @@ public void lanRig_neverPaused() { // doOnWaveDataReceived gates on isRunning -- pausing here would // spuriously drop their audio too, not just this HAL's. assertThat(MainViewModel.shouldPauseRxCaptureForTx( - /* rxIsMicSource= */ false, /* rxIsUsbDirect= */ false, + /* rigIsQmx= */ true, /* rxIsMicSource= */ false, /* rxIsUsbDirect= */ false, /* txIsUsbDirectOutput= */ false, VID_A, PID_A, VID_A, PID_A)).isFalse(); } @@ -48,7 +50,7 @@ public void rxUsbDirectCapture_neverPaused() { // is also independently broken for other reasons and shouldn't be // conflated with this fix. assertThat(MainViewModel.shouldPauseRxCaptureForTx( - /* rxIsMicSource= */ true, /* rxIsUsbDirect= */ true, + /* rigIsQmx= */ true, /* rxIsMicSource= */ true, /* rxIsUsbDirect= */ true, /* txIsUsbDirectOutput= */ false, VID_A, PID_A, VID_A, PID_A)).isFalse(); } @@ -58,7 +60,7 @@ public void txUsbDirectOutput_neverPaused() { // USB-Direct TX output is a separate, already-broken path // (LIBUSB_ERROR_IO) -- don't conflate the two bugs. assertThat(MainViewModel.shouldPauseRxCaptureForTx( - /* rxIsMicSource= */ true, /* rxIsUsbDirect= */ false, + /* rigIsQmx= */ true, /* rxIsMicSource= */ true, /* rxIsUsbDirect= */ false, /* txIsUsbDirectOutput= */ true, VID_A, PID_A, VID_A, PID_A)).isFalse(); } @@ -68,7 +70,7 @@ public void differentUsbDevices_neverPaused() { // Two distinct USB devices (or a USB mic with a non-USB/system // output) have no reason to share one ALSA card. assertThat(MainViewModel.shouldPauseRxCaptureForTx( - /* rxIsMicSource= */ true, /* rxIsUsbDirect= */ false, + /* rigIsQmx= */ true, /* rxIsMicSource= */ true, /* rxIsUsbDirect= */ false, /* txIsUsbDirectOutput= */ false, VID_A, PID_A, VID_B, PID_B)).isFalse(); } @@ -78,7 +80,7 @@ public void neitherVidPidSet_neverPaused() { // 0:0 is "no USB device configured" (phone mic / default speaker on // both sides) -- matching on 0:0 would be a false positive. assertThat(MainViewModel.shouldPauseRxCaptureForTx( - /* rxIsMicSource= */ true, /* rxIsUsbDirect= */ false, + /* rigIsQmx= */ true, /* rxIsMicSource= */ true, /* rxIsUsbDirect= */ false, /* txIsUsbDirectOutput= */ false, 0, 0, 0, 0)).isFalse(); } @@ -86,8 +88,28 @@ public void neitherVidPidSet_neverPaused() { @Test public void sameVendorDifferentProduct_neverPaused() { assertThat(MainViewModel.shouldPauseRxCaptureForTx( - /* rxIsMicSource= */ true, /* rxIsUsbDirect= */ false, + /* rigIsQmx= */ true, /* rxIsMicSource= */ true, /* rxIsUsbDirect= */ false, /* txIsUsbDirectOutput= */ false, VID_A, PID_A, VID_A, PID_B)).isFalse(); } + + @Test + public void nonQmxRigSharingOneUsbCodec_neverPaused() { + // An IC-7300 / IC-705 / Digirig / SignaLink on one USB cable also has + // matching input and output VID:PID. Pausing those would blank the + // waterfall during TX and break full-duplex satellite monitoring for a + // HAL conflict only ever confirmed with the QMX. + assertThat(MainViewModel.shouldPauseRxCaptureForTx( + /* rigIsQmx= */ false, /* rxIsMicSource= */ true, /* rxIsUsbDirect= */ false, + /* txIsUsbDirectOutput= */ false, + VID_A, PID_A, VID_A, PID_A)).isFalse(); + } + + @Test + public void isQmxRig_onlyForTheHamlibQmx() { + assertThat(MainViewModel.isQmxRig(null)).isFalse(); + assertThat(MainViewModel.isQmxRig(new HamlibRig(1036))).isFalse(); // FT-891 + assertThat(MainViewModel.isQmxRig( + new HamlibRig(HamlibRig.QRPLABS_QMX_MODEL_ID))).isTrue(); + } } diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/wave/HamRecorderMicGateTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/wave/HamRecorderMicGateTest.java index 12c59204d..aeb31baf3 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/wave/HamRecorderMicGateTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/wave/HamRecorderMicGateTest.java @@ -34,13 +34,42 @@ public void stoppedRecorderLeavesMicFree() { } /** - * {@link HamRecorder#shouldResumeAfterTxPause} -- resumeAfterTx() must - * only restart capture when pauseForTx() actually stopped a running one, - * never reviving capture the operator had deliberately turned off. + * {@link HamRecorder.TxPauseState} -- resumeAfterTx() must only restart + * capture when pauseForTx() actually stopped a running one, never reviving + * capture the operator had deliberately turned off. */ @Test - public void resumeAfterTxPause_onlyWhenPauseActuallyStoppedCapture() { - assertThat(HamRecorder.shouldResumeAfterTxPause(true)).isTrue(); - assertThat(HamRecorder.shouldResumeAfterTxPause(false)).isFalse(); + public void txPause_resumesOnlyWhenPauseActuallyStoppedCapture() { + HamRecorder.TxPauseState running = new HamRecorder.TxPauseState(); + assertThat(running.onPause(true)).isTrue(); + assertThat(running.onResume()).isTrue(); + + HamRecorder.TxPauseState stopped = new HamRecorder.TxPauseState(); + assertThat(stopped.onPause(false)).isFalse(); + assertThat(stopped.onResume()).isFalse(); + } + + /** + * A tune carrier and an FT8 over can overlap: the over's key-down can land + * before the tune's key-up. Only the last resume may restart capture, and + * it must still restart it -- the second pause sees capture already + * stopped by the first and must not record "wasn't running". + */ + @Test + public void txPause_nestedPausesRestartOnlyOnTheLastResume() { + HamRecorder.TxPauseState s = new HamRecorder.TxPauseState(); + assertThat(s.onPause(true)).isTrue(); // tune key-down stops capture + assertThat(s.onPause(false)).isFalse(); // over key-down: already paused + assertThat(s.onResume()).isFalse(); // tune key-up: over still keyed + assertThat(s.onResume()).isTrue(); // over key-up restarts capture + } + + @Test + public void txPause_resumeWithoutPauseIsIgnored() { + HamRecorder.TxPauseState s = new HamRecorder.TxPauseState(); + assertThat(s.onResume()).isFalse(); + // and doesn't unbalance a later pause/resume pair + assertThat(s.onPause(true)).isTrue(); + assertThat(s.onResume()).isTrue(); } } diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/wave/MicRecorderStopRetiresReaderTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/wave/MicRecorderStopRetiresReaderTest.java new file mode 100644 index 000000000..7aa628f51 --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/wave/MicRecorderStopRetiresReaderTest.java @@ -0,0 +1,63 @@ +package com.k1af.ft8af.wave; + +import static com.google.common.truth.Truth.assertThat; + +import com.k1af.ft8af.GeneralVariables; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +/** + * Tests that {@link MicRecorder#stopRecord()} retires the AudioRecord reader + * thread before it returns. RX is paused and resumed around every QMX over + * (stopRecord() then start()); if stop only cleared {@code isRunning}, a + * reader still blocked in read() would see {@code isRunning} true again after + * a quick resume and keep reading alongside the new thread -- two readers on + * one AudioRecord is a fatal native abort in libaudioclient. + */ +@RunWith(RobolectricTestRunner.class) +public class MicRecorderStopRetiresReaderTest { + + @Before + public void useThePlainAudioRecordPath() { + GeneralVariables.audioInputDeviceId = 0; + GeneralVariables.usbAudioInputVendorId = 0; + GeneralVariables.usbAudioInputProductId = 0; + } + + @Test + public void stopRecord_supersedesTheReaderGeneration() { + MicRecorder rec = new MicRecorder(); + int before = rec.captureGenerationForTest(); + rec.stopRecord(); + assertThat(rec.captureGenerationForTest()).isGreaterThan(before); + } + + @Test + public void stopRecord_waitsForTheReaderThreadToExit() { + MicRecorder rec = new MicRecorder(); + rec.start(); + Thread reader = rec.captureThreadForTest(); + assertThat(reader).isNotNull(); + rec.stopRecord(); + assertThat(reader.isAlive()).isFalse(); + } + + @Test + public void quickStopThenStart_leavesOnlyTheNewReaderRunning() { + MicRecorder rec = new MicRecorder(); + rec.start(); + Thread first = rec.captureThreadForTest(); + assertThat(first).isNotNull(); + rec.stopRecord(); + rec.start(); + try { + assertThat(first.isAlive()).isFalse(); + assertThat(rec.captureThreadForTest()).isNotSameInstanceAs(first); + } finally { + rec.stopRecord(); + } + } +} From 67b2e9390eebf8a604f5c1f59e49437e53170d55 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Tue, 27 Oct 2026 09:50:42 -0500 Subject: [PATCH 36/36] Treat #946's native QMX entry as a QMX for the RX pause and TX channel With #946 on dev the rig list has two QMX entries. The RX-pause workaround and the LEFT TX channel default are about the radio's USB audio, not the CAT driver, so isQmxRig() now covers QrpLabsQmxRig as well as the hamlib entry. CAT tones still need the hamlib connector. Co-Authored-By: Claude Opus 5.5 --- .../java/com/k1af/ft8af/MainViewModel.java | 10 +++++-- .../k1af/ft8af/MainViewModelIsQmxRigTest.java | 30 +++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/MainViewModelIsQmxRigTest.java 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 8aceec626..02a6c63c2 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java @@ -2437,9 +2437,15 @@ static int defaultTxChannelForRig(boolean rigIsQmx, boolean txChannelConfigKeyEx return rigIsQmx ? AudioChannelSelect.LEFT : AudioChannelSelect.BOTH; } - /** Whether {@code rig} is a QRP Labs QMX driven through hamlib. */ + /** + * Whether {@code rig} is a QRP Labs QMX, through either rig-list entry: the + * hamlib backend or the native {@link QrpLabsQmxRig}. The RX pause and the + * LEFT TX channel default work around the radio's USB audio, so they apply + * whichever CAT driver talks to it. + */ static boolean isQmxRig(BaseRig rig) { - return rig instanceof HamlibRig && ((HamlibRig) rig).isQmx(); + return rig instanceof QrpLabsQmxRig + || (rig instanceof HamlibRig && ((HamlibRig) rig).isQmx()); } /** diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/MainViewModelIsQmxRigTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/MainViewModelIsQmxRigTest.java new file mode 100644 index 000000000..e85bdb8e0 --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/MainViewModelIsQmxRigTest.java @@ -0,0 +1,30 @@ +package com.k1af.ft8af; + +import static com.google.common.truth.Truth.assertThat; + +import com.k1af.ft8af.rigs.KenwoodTS590Rig; +import com.k1af.ft8af.rigs.QrpLabsQmxRig; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +/** + * {@link MainViewModel#isQmxRig} for the rig-list entries that need a main + * Looper to construct (the Kenwood-family rigs post their first query on it). + * The hamlib cases are in {@link MainViewModelRxTxUsbConflictTest}. + */ +@RunWith(RobolectricTestRunner.class) +public class MainViewModelIsQmxRigTest { + + @Test + public void nativeQmxEntry_isAQmx() { + assertThat(MainViewModel.isQmxRig(new QrpLabsQmxRig())).isTrue(); + } + + @Test + public void itsTs590Parent_isNot() { + // The QDX and other TS-590-protocol rigs share the parent class. + assertThat(MainViewModel.isQmxRig(new KenwoodTS590Rig())).isFalse(); + } +}