From 1d138cdb6635b39fb2b9465cb3250943077293cc Mon Sep 17 00:00:00 2001 From: quadra Date: Tue, 20 Oct 2026 04:46:41 +0000 Subject: [PATCH 01/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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); }