Repository navigation
Add QRP Labs QMX via the hamlib backend - #925
laniol8926 wants to merge 55 commits into
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## dev #925 +/- ##
============================================
+ Coverage 41.70% 41.92% +0.21%
Complexity 252 252
============================================
Files 280 280
Lines 34002 34119 +117
Branches 4076 4091 +15
============================================
+ Hits 14180 14303 +123
+ Misses 19531 19504 -27
- Partials 291 312 +21
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
|
@patrickrb — flagging this directly in case it gets buried under the Codecov bot comment. This change is small (one line in Would you be able to either:
No rush — happy to wait on whichever's easier on your end. |
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf
9e6a274 to
1d138cd
Compare
|
Tested this on real QMX hardware (arm64 debug build from this branch, sideloaded). CAT control works — confirms the fix itself: But TX audio write fails — separate, pre-existing bug, not introduced by this PR's diff: Also saw the whole USB connection to the QMX fully detach and reattach twice during the session, independent of the TX attempt: I initially suspected old-phone/cable flakiness (this is a 2015 Moto G 3rd Gen), but ruling that out: both the cable and this exact phone have already done reliable, sustained live QMX work — same cable is the one in daily use with a different always-on QMX setup, and this same phone has already done live FT8 decoding over USB with a different native Android FT8 app with no equivalent dropouts. So this looks like something specific to FT8AF's direct-libusb audio path or its interaction with the CAT connection on this device, not the hardware. Given CAT itself works end to end, I think this PR's change is sound — the TX-audio-write and USB-detach issues look like a separate, pre-existing bug surfaced by QMX now being reachable at all. Happy to pull more of |
|
One more real-hardware finding: changing bands in the app did not actually retune the radio. Confirmed by direct observation (watching the QMX itself), not just the log — the first band change (to 7074000/40m) did take effect, but a later change to 14074000/20m did not; the radio stayed on 40m. I can't fully root-cause this from `debug.log` alone, and want to flag why precisely: `HamlibRig.setFreqToRig()` submits `HamlibNative.nativeSetFreq()` to an async worker and only logs a failure via `Log.w` (logcat) if `rc != 0` — that line never reaches `fileLog()`/`debug.log`. I don't have `adb`/logcat access in this environment, so I can't see whether the native call actually returned a nonzero `rc` for that second band change or silently no-op'd for some other reason. The `debug.log` lines around it: |
|
Found the precise mechanism behind "transmitting but no audio reaches the radio" (and probably the earlier "good audio level but no waterfall/decodes" too): after a USB detach/reattach, the serial/CAT side reconnects correctly, but the USB audio side silently falls back to the phone's own mic/speaker and never reacquires the QMX. Sequence from `debug.log`: So the earlier "libusb native write FAILED (rc=-1 IO)" report and this one may be two different faces of the same underlying USB-audio-device-tracking issue (one still attempting and failing the libusb write, this one having given up on USB audio entirely and not noticing) — or two separate bugs. Either way, the common thread across all three things reported on this PR is: the USB audio side of the connection is noticeably less resilient to reattach/reconnect events than the CAT/serial side, and it fails silently rather than surfacing to the user or retrying. |
|
Follow-up on the band-retune issue above, with a harder confirmation: watching the QMX's own display directly (not just the app or the log), the radio's actual frequency stayed at 7074 (40m) when the app was commanded to switch to the 20m FT8 frequency (14074). This matches the `debug.log` sequence from that attempt: |
|
Ruling out the hardware definitively: the same QMX and same USB cable work completely normally under WSJT-X (running directly on the machine that was hosting the phone's MTP connection during this testing) — CAT, frequency control, and audio all fine there. So the three issues reported above (TX audio silently falling back to the phone's own speaker after a USB reattach, the radio not always retuning on a band change, and the earlier intermittent full USB detaches) are software-side in FT8AF, not the radio or cable. |
|
One more hardware variable ruled out: all of the phone-side testing above was repeated across two different USB-OTG cables/adapters between the phone and the QMX, with the same symptoms both times. Combined with the WSJT-X result above, that's the radio, the Quadra-side cable, and now both OTG cables all cleared — this is squarely software in FT8AF. |
|
Last piece of hardware-elimination: installed a different native Android FT8 app (danleetw/FT8TW) on a different, older Samsung Galaxy phone against the same QMX, and it's getting real decodes with no issues. So this isn't specific to the Moto G 3rd Gen used in the testing above either — two different phones, two different apps, same radio: only FT8AF shows the symptoms reported in this thread. That's about as clean a "software, not hardware" signal as this setup can produce. |
|
Correction/update to everything above — new evidence changes the picture significantly, and I want to flag that clearly rather than let the "it's FT8AF software, not hardware" framing in my earlier comments stand as the final word. Summary of a cleaner round of testing:
Two independently-written apps, same split, same phones, same radio. That's a strong signal this is an Android 15 platform-level change, not a bug in either app's code. The specific clue: on Android 15, plugging in the QMX only shows the standard "which app do you want to open this with" chooser — no separate USB-device-access permission dialog appears, for either app. On Android 13, the explicit permission prompt does show. CAT/serial still works on Android 15 (likely covered by whatever implicit grant comes from the app-chooser/intent-filter match), but USB Audio Class access needs its own explicit I don't have logcat access in my environment to confirm the exact permission-API-level mechanism, but given it reproduces identically in two unrelated codebases, I'd treat this as an Android-version compatibility issue to track (and maybe worth a GitHub issue of its own, separate from this PR) rather than something to chase inside FT8AF's own USB-audio code. Apologies for the earlier, more confident "it's definitely FT8AF" framing — should have held that more loosely given how early those comments were in the testing. |
|
One more refinement, and this is good news: tried a third app on the same Android 15 Moto G — QFT8, which was written specifically for the QMX — and it gets real decodes, no issue. So this isn't an unconditional Android 15 platform block after all. It's narrower than that: whatever FT8AF and danleetw/FT8TW are both doing (or not doing) to request/claim the USB Audio Class interface doesn't work on Android 15, but QFT8's approach does. That means there's a working reference implementation to compare against — this is a fixable Android-15 compatibility bug in the USB-audio permission/claim flow, not something to just wait out. I don't have QFT8's source to compare against, but if anyone has access to it (or wants to sniff what permission calls it makes vs. what |
|
Closing summary of the real-hardware testing on this PR, pulling the thread above together:
Conclusion: this isn't something to wait out — it's a known-fixable gap. FT8AF (and FT8TW, separately) would benefit from updating USB-audio permission/claim handling to work on current Android versions, ideally informed by comparing against how QFT8 does it. Worth its own tracking issue separate from this PR, since it's pre-existing and affects every USB-audio rig, not just the QMX. Happy to help narrow it down further if useful — thanks for bearing with the long comment thread while this got untangled. |
|
I love this, thank you! |
Follow-up: two concrete leads on the issues above (code-level, not just symptoms)Dug into 1. TX audio format isn't actually enforced — likely explains weak/no drive on the QMX
I can confirm this matters on real hardware: on the QMX, both WSJT-X and QFT8 (a third-party app with adjustable audio routing similar to WSJT-X) require switching explicitly to mono/left-channel to get adequate TX drive — duplicating to both channels is not just non-ideal, it's insufficient to fully drive the transmitter. That's consistent across two independent, unrelated codebases, so it looks like a real characteristic of the QMX's USB audio input stage (e.g. only the left channel is actually summed into the modulator), not an app-specific quirk. Given that, defaulting 2. Likely root cause of the Android-15-only audio reconnect failure
No code anywhere distinguishes Android 13 vs 15. A fixed delay tuned against 13's re-enumeration timing could simply be too short on 15's USB host stack, especially for a composite CDC-ACM+UAC device like the QMX — which would exactly match what we saw: CAT/serial reliably reconnects (its own retry path works), but USB audio claim loses the race and silently falls back with no error surfaced. If this is reproducible on other Android 15 devices, a retry-with-backoff on |
Follow-up: CAT and audio use genuinely different USB mechanisms — this likely explains why one survives Android 15 reattach and the other doesn'tLooked into whether this needs an Android-specific build of libusb or hamlib. Short answer: no — both are already correctly built for Android. But the two paths are architecturally very different, and that difference lines up with the symptom. CAT (hamlib) never touches libusb at all. It's configured Audio does use libusb, the standard unrooted-Android way: Checked Likely sharper reason audio specifically breaks: On "QFT8 sees it as a valid radio, the others don't" — one speculative but testable explanation: Android has a native way to handle USB Audio Class devices that doesn't require any of this — treating the QMX as a normal system audio route via |
Update: narrowed this down further with more hardware testing — correcting/refining the earlier framingMore testing on the Moto G (Android 15) ruled out two things I'd floated as possible fixes, and landed on a cleaner explanation. Ruled out — rig profile isn't the variable. Tested both "Qrp Labs QMX (Hamlib)" (this PR's new entry) and the existing "Qrp Labs QDX" (Kenwood TS-590 emulation) against the same QMX on the same phone. CAT works fine either way (with QDX needing a manual mode switch from the default USB/SSB to Digital, as expected). Audio fails identically with both — this isn't a Hamlib-backend-specific problem, it's purely in the audio layer, independent of which CAT backend is driving the radio. Ruled out — it's not about which in-app audio-device option is selected. Tried all three: "Default," the AudioManager-routed "USB-Audio — QMX Transceiver (USB audio)," and the raw-libusb "QMX Transceiver (USB Direct)." All three fail on this phone — no waterfall/decodes on RX in any of them; on USB Direct specifically, TX keys the rig but drive is insufficient regardless of trying LEFT or RIGHT channel individually. The actual answer, confirmed independent of any app: with the QMX plugged into this Moto G and nothing running, Android's own Settings > Sound shows zero device options for it. The kernel/ALSA layer never registers this composite device as a sound card on this Android 15 build at all. That's the real reason "Default" and the AudioManager-routed "USB audio" option can't work here — there's no That leaves "USB Direct" (raw libusb, bypassing the OS's sound-card registration entirely) as the only path that can possibly work on this phone — and QFT8 getting real decodes on this same phone/radio combination confirms that path is achievable, since QFT8 can't be relying on OS-level sound-card registration either if the OS never creates one. So FT8AF's "USB Direct" implementation has a real, fixable gap (not a platform dead end): RX is getting nothing at all, and TX keys but is underdriven on either single channel. Net effect: walking back the "Android 15 permission-timing regression" framing from earlier in this thread in favor of this — the fix has to happen somewhere in |
Pulled the phone's debug.log directly — found the exact, 100%-reproducible TX failure pointPulled TX fails identically every single time, with no variation — three separate TX attempts logged ( Kernel driver detach succeeds every time, the device opens and activates cleanly at 48kHz every time — and then the very first native write fails immediately, on a device that was just successfully opened seconds earlier. This isn't a race after a reattach event; it fails on fresh state every time. That means a retry/backoff around the write (my earlier suggestion) wouldn't help — it would just hit the same wall again. Found a likely reason in the native code. Java_com_k1af_ft8af_wave_UsbAudioNative_nativeWrite(
JNIEnv* env,
jclass /*clazz*/,
jint fd,
jint /*interfaceNumber*/,
jint /*altSetting*/,
jint endpointAddress,
...The function wraps the already-open fd ( Might be worth having the native side explicitly re-assert the alt-setting ( One correction to earlier comments in this thread: in this same log, RX audio input never actually switched to the QMX at all — every |
Correction: the test phone's model was misidentified in earlier commentsSeveral comments above (the initial hardware-elimination round) identify the Android 15 test phone as a "Moto G 3rd Gen (XT1541)." That's wrong, and worth fixing on the record since it's an easily-checkable claim: a 2015-era Moto G 3rd Gen couldn't run Android 15 at all. Just connected the same phone to a Windows laptop with real Doesn't change anything about the technical findings in this thread (CAT, TX audio, the libusb I/O error, the Android-13-vs-15 behavior) — just correcting the device name in case anyone goes looking it up. |
Real
|
Real-hardware testing on PR patrickrb#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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf
…hange Real-hardware testing on PR patrickrb#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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf
… MD6; The Q41; fix (e046e0a, 3789712) 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf
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 patrickrb#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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf
The previous fix (47c8fc4) 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf
Trims the audioFormat field comment to the why in a few lines (per Copilot review feedback on PR patrickrb#925, where this fix originated, carried over here since it's the same underlying change). Extracts 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), and adds MicRecorderRestartDecisionTest covering both scenarios: restart after a transient failure left isRunning false without stopRecord(), and no restart after stopRecord() itself cleared shouldBeRunning. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf
|
Addressed findings 6-10 (head
CI fix + findings 2-5 from your previous review are at |
persisted AudioDeviceInfo id goes stale Confirmed on real hardware: power-cycling a QMX alone re-enumerated its USB audio interface with a new AudioDeviceInfo id, reverting the Settings input-device picker (and the actual MicRecorder/FT8TransmitSignal routing) to "Default" even though the same physical device was still attached -- AudioDeviceInfo's numeric id is not guaranteed stable across a USB reconnect, but its product name is, and the app already persists the device's VID:PID whenever it's picked (just never used it as a fallback). Added UsbAudioDevice.resolveDeviceIndex: exact id match first, else match by the product name of the live USB device whose VID:PID equals what was persisted. Wired into MicRecorder.findAudioDeviceById (input), FT8TransmitSignal.findAudioDeviceById (output), and AudioDeviceSpinnerAdapter.getPositionByDeviceId (the Settings picker's own re-selection on screen open) -- all three previously only matched by exact id, so a power-cycle silently fell back to Default in all of them. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf
persisted for framework picks, and exact name match fails on the HAL's
decorated name
Blocker 1: picking a framework AudioManager USB device (e.g. "USB-Audio -
QMX Transceiver") always persisted VID:PID 0:0 -- getUsbAudioDeviceInfo()
only returns non-null for the separate USB-direct list, so the picker's
`else if (deviceId != -1)` branch unconditionally zeroed it, leaving
resolveDeviceIndex's fallback with nothing to fall back to. Fixed by
looking up the raw UsbDevice by product-name match
(AudioDeviceSpinnerAdapter.getUsbFrameworkDeviceProductName +
UsbAudioDevice.matchByProductName) and persisting ITS VID:PID instead, in
both RadioAudioSettings.kt (Compose) and ConfigFragment.java (legacy).
Blocker 2: even with VID:PID persisted correctly, UsbDevice.getProductName()
("QMX Transceiver", confirmed via dumpsys usb) never equals the
AudioDeviceInfo list's name for the same card ("USB-Audio - QMX
Transceiver", confirmed via dumpsys audio) -- Android's generic USB-Audio
HAL decorates the raw descriptor name. resolveDeviceIndex's match (and the
new matchByProductName) now use case-insensitive containment instead of
equality.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf
Both ImportSharedLogs (VIEW-intent import) and LogFileImport (web-logger HTTP upload) capped a single import at 50 MB purely as a defensive OOM guard, not a real technical limit -- an 87 MB real-world lifetime ADIF log (an active contester's) was rejected by it. Raised both to 200 MB, comfortably covering that case with headroom, while still bounding the worst case for a hostile/pathological upload. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf
…itself Real on-phone testing (Motorola Edge 2022, ~256MB app heap) found that readFully()'s ByteArrayOutputStream-doubling growth means REJECTING a stream over the 200 MB cap already needs close to 200-256MB of heap -- LogFileImportTest.readFully_throwsWhenExceedingCap got OutOfMemoryError instead of the expected IOException. 100 MB keeps the rejection path's own peak memory safely under that, while still covering the 87 MB real log that prompted the original 50 MB -> 200 MB change. This is a stopgap, not a confirmed fix: actually parsing an accepted import this large still needs several times the raw file size under the current whole-file-in-memory approach (buffer, decoded string, per-record and per-field split copies, and the full parsed-record list all alive at once), so whether a 100 MB accepted import itself succeeds on a 256MB-heap device is not yet confirmed. A streaming rewrite is the real fix. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf
whole file Real on-phone testing found that even a modest size cap (100-200MB) on the previous whole-file-then-split approach could make either the rejection path itself OOM, or leave an accepted import's actual multi-copy parse (buffer, decoded string, per-record/per-field split copies, the full parsed-record list, all alive at once) unconfirmed to fit a 256MB-class heap -- an 87MB real ADIF log is exactly the case that prompted this. Adds AdifStreamReader: reads the source in small chunks via an InputStreamReader (so a multi-byte UTF-8 character split across a chunk boundary still decodes correctly), and yields one raw record at a time by scanning only the unconsumed tail for the next <eoh>/<eor> tag (case-insensitive). Peak memory is bounded by roughly one record's worth of text, not the whole file, so there is no longer any need for a size cap on this path at all. ImportSharedLogs.doImport now takes a StreamOpener (re-opens the source, e.g. a content:// URI, rather than a single already-open InputStream) and streams it twice: once to count records for the existing progress-total contract, once to parse and insert each record as it's read, instead of collecting a full ArrayList<HashMap> up front. parseLogRecords' per-record field parsing is extracted unchanged into parseSingleRecord, shared by both the whole-body (still used by existing tests) and streaming paths, so behavior is identical either way. Updated the three callers (MainActivity, ComposeMainActivity, LogbookScreen) to pass a reopening lambda instead of a single InputStream. Scope: this covers ImportSharedLogs (the VIEW-intent/share import path, what the original report hit). LogFileImport (the built-in web-logger's HTTP upload path) keeps its whole-file approach and 100MB safety cap for now as a separate follow-up, rather than doubling this change's size. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf
multiByteUtf8CharacterSplitAcrossChunkBoundary_decodesCorrectly declared <comment:4> for "Café", but ADIF's LEN counts UTF-8 bytes (AdifFormat's own javadoc), and 'é' is 2 bytes -- so the correct declared length is 5. The parser (extractFieldValue -> AdifFormat.sliceByUtf8Length) was already correct and matches the app's own exporter; only the test fixture's declared length (and its comment, which wrongly implied char-length semantics) was wrong. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf
|
Real-world finding worth flagging for finding 8 (rigaddress.txt append-only ordering): while testing PR #950 (ADIF import) separately, the user's phone had this PR's build installed, got swapped to a build based on plain `dev` (no QMX entry) to test the import fix, then back again. In between, with the saved rig selection still pointing at the QMX's list index, the `dev`-based build resolved that same index to a different rig ("Yaesu FT-891 (Hamlib)", one line earlier in `dev`'s shorter `rigaddress.txt`) and sent its CAT commands to the actually-attached QMX — 64x literal `f` + newline, with zero replies. That apparently put the QMX into its serial setup-terminal mode, cleared by a power-cycle. No data was corrupted and nothing crashed, but it's a concrete demonstration of the risk you flagged in finding 8: moving between app versions with different `rigaddress.txt` lengths, with a HAMLIB rig already selected, can silently resolve to the wrong backend and send garbage CAT to whatever's attached — not just a reordering/merge-conflict risk. Not something I'm proposing to fix in this PR (the append-only convention already does what it can at the file level), just flagging the sharper edge case for awareness, especially since QMX's vendor commands have shown they can put the radio into unexpected states. |
Real-world testing of an 87 MB ADIF import (PR patrickrb#950) found two related bugs, both surfaced by the same 73 malformed RST_SENT/RST_RCVD values ("59+30", "R-07", "-10db", "-20 dB", "--5", ...) that other loggers write: 1. QSLRecord's RST_RCVD/RST_SENT parsing used a strict Integer.parseInt, so any of those shapes threw, silently dropped the report (falling back to the "no report" sentinel), and dumped a full stack trace to logcat for every one (73 traces in this case). Added AdifFormat.parseReportLeniently: recovers the leading signed integer from each of the real-world shapes above, falling back to flagging invalid (with a one-line log, no stack trace) only when no digits can be found at all. 2. Even when isInvalid *was* set (by this or the existing FREQ parsing path), DatabaseOpr.doInsertQSLData never looked at it -- it hard-coded isInvalid=false on every successful insert/update, only ever reporting true for the unrelated missing-callsign case. So the import UI told the user "no invalid records" while 73 QSOs had silently lost their RST. Now passes record.isInvalid through to both doAfterInsert call sites (new insert and update-existing). The record is still inserted either way (the one bad field already fell back to a sentinel) -- this only fixes what gets reported, plus the RST recovery itself. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf
still count as added/updated
Real-world code review on this same PR found that passing record.isInvalid
as the isInvalid parameter made both AfterInsertQSLData consumers
(AdifImportTally's exclusive `when`, LogHttpServer's `if (isInvalid) {
...; return; }`) exclude a record that WAS written (just with one field
dropped, e.g. an unparseable RST) from "added"/"updated" entirely --
exactly the same under-reporting bug this PR set out to fix, just moved:
now the summary said "73 invalid" implying those 73 were never imported,
when they actually were.
AfterInsertQSLData gets a third parameter, hasWarning, independent of
isInvalid (never written at all, e.g. no callsign) and isNewQSL (new vs.
update): a record flagged by QSLRecord.isInvalid is now reported as
isInvalid=false (it WAS written), isNewQSL as normal, hasWarning=true.
Both consumers tally it alongside added/updated rather than instead of:
AdifImportTally gets a separate `warnings` count (surfaced in the import
dialog only when non-zero), and LogHttpServer/ImportTaskList get a
parallel warningCount/HTML row.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf
|
Real-hardware finding from unrelated testing today: with the QMX stuck unresponsive (a separate build's stray serial writes left it in a non-CAT mode, not this PR's bug), `HamlibRig` was creating a brand-new native bridge (loopback socket + pump thread) on every single CAT call that ran while the handle was unset -- 354 of them in ~17 minutes via the app's ~3s CAT-liveness watchdog. Each cycle was already being cleaned up correctly (confirmed: no leak, `releaseHandle()` runs on every failure), but churning native resources that often for an indefinitely unresponsive radio is wasteful. Added a 15s backoff after a failed open (`ensureOpen()` just returns false immediately until it elapses, reset on the next successful open), pushed as `b30f7db6`. New tests for the backoff decision plus a regression test against the exact scenario (two CAT calls landing moments apart after a failure, only the first should touch the native layer). |
… backoff Code review on the backoff fix (b30f7db) 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf
…, 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf
volume-curve cut and enhancement effects Real-hardware finding (deferred since the first night of the QMX PR's work -- "rig keys/transmits, but audio is low"): a QRP Labs QMX still showed low drive even with TX audio correctly on the left channel. Logcat showed why: FT8AF's TX AudioTrack used USAGE_MEDIA/CONTENT_TYPE_MUSIC, which resolves to AUDIO_STREAM_MUSIC -- and on this device the audio policy manager applies a fixed -6 dB cut to that stream regardless of the app's own volume index (confirmed happening at max index, 15/15), on top of an OEM "Dolby Listener" enhancement effect inserted into the output's mix chain. Moves TX to USAGE_VOICE_COMMUNICATION/CONTENT_TYPE_SPEECH (the usage "raw audio to a USB modem" apps commonly use instead of USAGE_MEDIA, specifically to land on a different volume-source/output than media audio), which escapes the -6 dB curve for certain and plausibly the Dolby chain too if it causes routing to a different mixer thread. Also builds the AudioTrack via Builder instead of the legacy constructor so PERFORMANCE_MODE_LOW_LATENCY can be requested (a secondary, independent attempt to dodge the enhancement chain -- NOT expected to touch the volume curve itself, which applies regardless of mixer path) and so a dedicated generated session id can be used instead of the literal 0 the legacy constructor used, which implicitly joins the output's global session-0 mix -- the exact session real hardware showed carrying the Dolby effect. Logs the actual granted performance mode either way, since a denied low-latency request otherwise fails completely silently. Cross-rig: affects every rig's TX audio, not just the QMX -- its own PR, same reasoning as the earlier RX-capture split. Needs real-hardware confirmation before merge: does drive actually improve, and does the existing Bluetooth-SCO output-routing workaround (issue patrickrb#759, applyDefaultOutputRoutingOverride) still behave correctly -- it forces setPreferredDevice() regardless of usage so is not expected to be affected, but hasn't been re-verified against a live SCO link. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf
USAGE_VOICE_COMMUNICATION was confirmed via real dumpsys audio output to resolve to AUDIO_STREAM_VOICE_CALL, sitting at its minimum volume index (1 of 8) on the QMX's USB output -- far worse than the -6 dB music-stream cut it was meant to escape, and with no easy way for the operator to raise it outside an active call. Caught before an on-air test confirmed it the hard way. Keeps the dedicated session id + PERFORMANCE_MODE_LOW_LATENCY request (aimed at the Dolby/session-0 effect specifically) but leaves the -6 dB volume-curve cut itself unsolved, pending real per-device volume-curve data for any future candidate usage. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf
…TION) Pulls in 26cc47a, which reverts the USAGE_VOICE_COMMUNICATION change merged earlier (a4929d0) after real-device testing confirmed it made TX drive worse, not better. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf
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 patrickrb#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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf
|
TX underdrive fully root-caused and fixed, confirmed on real hardware (2026-10-23). This chased a false lead across several PRs/comments (AudioAttributes usage, volume curves, Dolby effects, channel layout) before the actual cause turned up: on this phone's USB-audio HAL, opening TX's Fix: Confirmed on real hardware: before the fix, every single TX logged Known follow-up, not blocking: |
Summary
rigaddress.txt) had no entry for the QMX at all, so it never showed up as a selectable rig in Settings.MD2(SSB-USB voice mode) rather than DATA mode + the TX-rise-threshold tuning the QMX specifically needs to transmit — the same bugdanleetw/FT8TW's native app had to hand-patch around (MD6;Q41;).qrplabs_qmx_caps/RIG_MODEL_QRPLABS_QMX), exposed through the existingHamlibRigbridge — 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).ICOM IC-7300MK2), perRigAddressAssetTest's documented append-only invariant — the selected rig is persisted as a raw list index, so inserting it mid-file would silently reassign every existing user's selected rig on upgrade.Status (updated after real-hardware testing — see PR comment thread for the full narrative)
Confirmed working on real QMX hardware (Android 15, Motorola Edge 2022; CAT also confirmed earlier on Android 13):
MD6;) through a frequency change.Bugs found and fixed along the way (commit-by-commit detail is in the PR thread):
PKTUSB→ invalidMDD;) produced no CAT effect on the QMX; fixed with a QMX-specific override (RTTY→MD6;) that no longer applies to other hamlib rigs (e.g. FT-891 keepsPKTUSB).rig_open()returned; fixed by splitting create/open so the handle is live before the handshake runs.releaseHandle()on the rig's own worker thread, guarded by a read/write lock against the serial feed path.MicRecorder: requestingENCODING_PCM_FLOATfrom a USB input silently produces unusable samples on this phone's MediaTek audio HAL. Fixed by capturing 16-bit PCM and converting to float in software. (This fix isn't QMX-specific — see "Known gaps" below.)Q90;) is now sent on connect, matching a fix indanleetw/FT8TWfor the same symptom on this radio family.Known gaps, still open:
MicRecorderlifecycle fixes) touches every rig's audio capture, not just the QMX's. It's been smoke-tested on the QMX but not against other hardware (IC-7300/705, CM108/Digirig, Bluetooth SCO) — per review feedback, that fix should probably ship as its own separate PR rather than ride in with the QMX catalog entry.aapt2is x86_64-only there); all real builds/on-device testing were done via a Windows machine with Android Studio, connected over Claude Code's Remote Control feature.Test plan
🤖 Generated with Claude Code
https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf