feat(gui): QRZ callsign lookup — CW decoder contact card, lookup dialog, 7-day cache#3990
Conversation
…og, 7-day cache — Principle V. A framework for pulling station contact info from QRZ.com and popping a themed contact card wherever AetherSDR identifies a station: Core (src/core/): - QrzClient — QRZ XML API client: session-key login (agent=AetherSDR), key reuse, transparent one-shot re-login + retry on session expiry, per-call retry guard so a flapping session can never loop. - CallsignInfo — provider-neutral contact struct (name/location/grid/ class/photo/QSL flags) with JSON round-trip for the disk cache. - CallsignLookupService — app-wide facade: lazy on-disk cache (7-day TTL, 2×TTL prune, 1000-entry cap, debounced atomic-ish save), station photo download + cache, in-flight coalescing so a busy net never asks QRZ twice, stale-entry fallback when offline, keychain-backed credentials (password never touches the settings file). - CwCallsignSpotter — rolling-window watcher for "DE <call> <call>" in decoded CW; requires the doubled call as a decode-confidence gate, holds end-of-window matches for a 3 s settle so end-of-transmission IDs still spot, per-call re-emit suppression. - QrzLookupSettings — enabled/username as one AppSettings["QrzLookup"] nested blob (Principle V); password in the OS keychain. GUI (src/gui/): - CallsignCard — reusable themed card (accent bar / photo / details), Compact for the CW decode panel, Large for the lookup dialog; the same visual will serve the SSB voice-callsign decoder. Callsign is keyboard-activatable and opens the station's QRZ page. - CW decode panel hosts a Compact card beside the decoded text; wired via MainWindow_Callsign.cpp through routeCwDecoderOutput() so the spotter follows the active-slice applet and clears across reroutes. - View → Callsign Lookup… (Ctrl+Shift+L) — PersistentDialog with uppercase-normalized input, cache-age status line, force-refresh. - Radio Setup → QRZ tab — enable toggle, username, keychain password, async Test Login, cache count + Clear Cache. Automation bridge: - New `qrz` verb group: status / cached <call> / lookup <call> / spottext <text> (feeds the real spotter → card path; with a seeded cache this proves the end-to-end screen-pop with no QRZ account). - invoke setCurrentText/setCurrentIndex now drive QTabBar, reaching deferred setup-dialog tabs (previously unreachable from the bridge). - docs/automation-bridge.md updated for both. Tests: tests/qrz_callsign_test.cpp — 36 checks over the callsign regex, spotter stream behavior (chunked arrival, settle timer, garble/dedup rejection), CallsignInfo JSON round-trip, and the 7-day TTL rule. Proven live via the bridge against the FLEX-8400M (RX only): CW-mode spottext → card pop with cached data + photo; lookup dialog render; QRZ tab render; enable-toggle round-trip through the settings blob. 💻 Generated with Claude Code (claude-fable-5 7/1/26) with architecture by @jensenpat
…ith 5 s timeout — Principle IX. Two upgrades to the QRZ contact card, both proven live via the bridge: Distance & bearing: - CallsignLookupService stamps great-circle distance + initial bearing onto every emitted CallsignInfo (transient — never cached, since the operator's position changes). Station position preference: exact QRZ lat/lon → grid-square center → DXCC country center (marked "~"). - Own position: radio GPS fix → radio grid locator (same preference as the PSK Reporter map) → zero-config fallback: the radio callsign's own QRZ record / cache entry supplies the operator's grid when the radio has no GPS. GPS always overrides the record-derived position. - Card meta row renders "2,514 mi @ 067°" (locale-aware mi/km, PSK-map bearing format, "~" prefix for country-center estimates). Country fallback (cty.dat prefix data): - CtyDatParser now captures each entity's lat/lon from cty.dat (stored west-positive in the file; flipped east-positive) and grows an entityForCallsign() convenience. DxccColorProvider exposes its parser so cty.dat is parsed exactly once app-wide. - When QRZ can't answer — network/provider failure, no credentials, or no reply within 5 s (kPrefixFallbackMs) — the service emits a country-level stand-in card: country, continent, CQ zone, approximate distance/bearing from the entity center, with a "prefix" provenance chip. Deliberately never cached; the full QRZ card replaces it whenever the real answer lands. Surfaced only when honest data exists (Principle IX): the CW screen-pop gate is now canResolve() = credentials || cached || prefix-resolvable. - Lookup dialog explains the stand-in in its status line. Bridge: `qrz status` reports hasOwnLocation (docs updated). Tests (all pass): cty.dat sample parse with longitude flip, prefix → entity resolution, CM97→FN31 distance/bearing sanity. Live proof (FLEX-8400M, RX only, no QRZ credentials on this box): own position adopted from the radio callsign's cached record (hasOwnLocation:true); cached W1AW card shows "FN31pr · Hartford · 2,514 mi @ 067°"; uncached G4ABC pops the England prefix card "EU · CQ 14 · ~5,231 mi @ 033°". 💻 Generated with Claude Code (claude-fable-5 7/1/26) with architecture by @jensenpat
Added: distance & bearing + country fallback (commit ff58ded)Two follow-on upgrades, per operator direction: Distance & bearing on every cardGreat-circle distance + initial bearing from the operator's position, on both card variants (locale-aware mi/km). Station position: exact QRZ lat/lon → grid center → DXCC country center ( Cached W1AW, exact grid distance: Country fallback when QRZ can't answer (5 s timeout)On network/provider failure, missing credentials, or no QRZ reply within 5 s, the card falls back to cty.dat prefix data — country, continent, CQ zone, approximate distance/bearing — with a Uncached G4ABC with no credentials — instant England prefix card: Proof
💻 Generated with Claude Code (claude-fable-5 7/1/26) with architecture by @jensenpat |
There was a problem hiding this comment.
Nicely built feature — thanks @jensenpat. The architecture is clean and the AetherSDR conventions are all respected: config is one nested-JSON blob under AppSettings["QrzLookup"] (Principle V), the password goes to the OS keychain rather than the settings file, callsigns are regex-validated at the boundary before any request, network lookups coalesce in-flight and serve stale-on-failure, the photo fetch pins NoLessSafeRedirectPolicy, and the spotter/service split keeps the SSB path in mind. QObject ownership, QPointer guards, and deleteLater() on replies are all handled correctly, and CI is green across the board. Tests are thorough.
A few things worth addressing:
1. Set Qt::PlainText on the contact-card data labels (defensive, matches existing convention)
CallsignCard's data labels — m_nameLabel, m_locationLabel, m_metaLabel, and the message in showError() — display text sourced straight from the QRZ record (<name_fmt>, <nickname>, <addr2>, <county>, etc.), which are operator-editable free-text fields. QLabel defaults to Qt::AutoText, so a value that trips Qt::mightBeRichText() renders as HTML from a network source — an integrity/appearance issue at minimum, and a possible beacon vector via markup like <img src=…>.
This codebase already guards exactly this case for remote directory data — src/gui/KiwiSdrApplet.cpp calls setTextFormat(Qt::PlainText) on every label that shows server-provided text (lines 250, 258, 279, 300, 309, 317). Recommend the same on the card's data labels. The callsign label itself is regex-validated so it's lower risk, but the name/location/meta/error labels carry unvalidated provider text.
2. Confirm QRZ-on-by-default is intended
QrzLookupSettings::enabled() returns true when the key is absent, so the CW spotter is live for existing users immediately on upgrade. In practice this only pops a local cty.dat country-level card until credentials are configured (no network without setup, since onCwCallsignSpotted gates on canResolve), so it's benign — just flagging that a new subsystem defaulting on is a product choice worth a conscious nod.
3. (Optional) Cap photo download size
fetchPhoto()'s reply handler does reply->readAll() into memory with a 20 s transfer timeout but no byte ceiling. QRZ image URLs are provider-controlled so risk is low, but a bounded read (or a Content-Length sanity check) would harden it against an oversized/hostile image.
None of these block the feature — #1 is the one I'd want in before merge to stay consistent with the KiwiSDR label handling. Solid contribution.
🤖 aethersdr-agent · cost: $7.9345 · model: claude-opus-4-8
There was a problem hiding this comment.
Nicely built feature — thanks for the thorough PR, @jensenpat. I read through the whole subsystem and it lands cleanly on AetherSDR conventions: password in the OS keychain (never the settings file), config as one nested JSON blob under AppSettings["QrzLookup"] (Principle V), a provider-neutral CallsignInfo with JSON round-trip, request coalescing + stale-serve in the service facade, and a genuinely reusable themed CallsignCard with solid a11y (keyboard-activatable callsign link, accessible names). The doubled-call decode-confidence gate and the settle-timer for end-of-transmission IDs are a clever, well-commented design, and the qrz spottext bridge verb making the end-to-end pop provable with no radio/account is excellent. All CI green.
A few small things, none blocking:
1. Photo download has no size cap (CallsignLookupService::fetchPhoto). The reply body is written straight to disk with only a 20 s transfer timeout — no maximum byte limit. The image URL comes from the QRZ <image> field (third-party-controlled), so this is an untrusted-input boundary. A hostile/misconfigured URL could stream an arbitrarily large file into the cache dir. Consider rejecting on an implausible Content-Length, or capping bytes written (a few MB is plenty for a station photo).
2. Shape-validation claim is slightly broader than the code. The notes say "callsigns are regex-validated at the boundary before any network request (Principle VII)." That holds for the spotter path (regex-gated) and the lookup dialog (isLikelyCallsign before lookup), but CallsignLookupService::lookup() and QrzClient::lookup() only check normalized(call).isEmpty() — the automation-bridge qrz lookup <call> path reaches the network with any uppercased/trimmed string. It's URL-encoded via QUrlQuery, so not injectable, but gating lookup() on Callsigns::isLikelyCallsign() would match the stated contract and avoid pointless QRZ round-trips on garble.
3. (Confirm, not a bug) QrzLookupSettings::enabled() defaults to "True", so on a fresh install with no credentials the CW screen-pop is active and will auto-pop country-level cty.dat cards. That's harmless (offline, no network without creds) and probably intended — just flagging the default-on behavior in case it wasn't deliberate.
Everything else — QRZ session-key relogin/retry guard, cache TTL + prune/cap, prefix fallback, own-location precedence (GPS → own QRZ grid), theme-switch handling — looks correct and well-reasoned. Nice work.
🤖 aethersdr-agent · cost: $8.6846 · model: claude-opus-4-8
ten9876
left a comment
There was a problem hiding this comment.
Review: request changes
Well-architected feature (password correctly in the OS keychain, HTTPS API, single-flight queue, cty.dat fallback) — but a multi-agent + adversarial-verify pass turned up several confirmed correctness and security bugs. Each inline comment carries a fix (suggestion or sketch).
Blocking:
- Portable/prefixed callsigns break permanently — cache/in-flight keyed on the queried form but removed/cached on QRZ's returned base call → dead lookups + permanent cache miss.
- Tabbing through the QRZ password field before the async keychain read completes silently deletes the stored password (data loss, trivially hit).
<lat>without<lon>→ bogus(lat, 0)→ wrong distance/bearing + corrupts the adopted home QTH.- Unbounded profile-photo fetch — no size cap + GUI-thread decode (decompression-bomb/OOM/freeze), and http/cross-host redirects allowed (SSRF-lite).
qrz lookupbridge verb = unbounded authenticated QRZ network trigger (no shape gate / rate limit).
Should fix: 6. no negative caching → re-queries QRZ every 120 s for not-found calls; 7. async-keychain race drops the first CW spot after startup.
Maintainer decision: 8. the feature ships ON for every existing user (enabled() defaults True) — recommend default-off / gate on credentials.
Quality: 9. contact-card a11y (interactive QLabel role + live-update announce); 10. a robustness bundle (retry-set leak, photo TTL pruning, atomic cache write, GUI-thread I/O, unbounded spotter map, per-char regex rebuild).
The architecture is sound — these are edge/security/robustness gaps. I can push fixes for the safe confirmed set (1–5, retry-leak) if you'd like.
… guard, geo/photo/bridge hardening (aethersdr#3990) Addresses the confirmed correctness + security findings from review. - Portable/prefixed callsigns: thread the queried call through QrzClient::lookupSucceeded(call, info) and key the service's in-flight/cache state on it, not on QRZ's returned base info.call — the mismatch leaked the in-flight entry (permanently dead lookup) and missed the cache for any W1AW/P / DL/KI6BCJ query. - Credential loss: the Radio Setup QRZ password field's editingFinished no longer calls savePassword("") (which deletes the keychain entry) before the async keychain read has completed — guarded on a shared loaded flag. - Geo: QRZ parseXml requires BOTH <lat> and <lon>; a valid lat with a missing lon no longer yields a bogus (lat, 0) that skews distance/bearing and could corrupt the adopted home QTH. - Photo fetch: https-only (no http/cross-host SSRF-lite), an 8 MB download cap (abort on downloadProgress), and a 4096-px decode-dimension cap in CallsignCard (decompression-bomb / GUI-thread-freeze defense). - Bridge: the `qrz lookup` verb now shape-gates on isLikelyCallsign so an agent can't drive unbounded authenticated QRZ queries over arbitrary tokens. - QrzClient: clear m_retriedAfterRelogin on the network-error and login-fail paths so a leaked retry flag can't suppress the one-shot re-login recovery. Deferred to the author/maintainer (see review): negative caching, the async-keychain first-spot deferral, the enabled()-defaults-on product call, the card a11y role/announce, and the remaining robustness items (photo TTL pruning, atomic cache write, GUI-thread cache I/O, unbounded spotter map, per-char regex). Both the app and qrz_callsign_test build/pass. Fixes Are Demonstrated (Principle XI). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Pushed fixes for the confirmed correctness + security findings (commit `e4cc7186`). App + `qrz_callsign_test` build/pass locally. Fixed:
Deferred (your call / larger changes), left as review notes: 6 negative caching, 7 async-keychain first-spot deferral, 8 the `enabled()` defaults-ON product decision (maintainer), 9 card a11y role/announce, and the rest of the #10 robustness bundle (photo TTL pruning, atomic cache write, GUI-thread cache I/O, unbounded spotter map, per-char regex). Happy to take any of these in a follow-up. |
ten9876
left a comment
There was a problem hiding this comment.
Approving after the review-follow-up fixes (portable-call keying, credential-delete guard, geo/photo/bridge hardening, retry-leak); app + qrz_callsign_test build/pass locally, all threads resolved. Per maintainer direction, shipping with QrzLookup enabled-by-default as-is (finding #8 noted for a possible follow-up). Set to auto-merge on CI.
## What was broken PR #3990 added QRZ callsign lookup, but the login path was broken end-to-end — verified by testing against a real QRZ.com account. ### 1. Every QRZ XML response failed to parse (`src/core/QrzClient.cpp`) `QrzClient::parseXml()` walks `QXmlStreamReader` tokens, special-casing `<Session>` and `<Callsign>` start tags. Every other start tag falls through to `r.readElementText(QXmlStreamReader::SkipChildElements)`. The problem: every QRZ response is wrapped in a root `<QRZDatabase>` element. That root tag is the *first* `StartElement` the loop sees, and it doesn't match `"Session"` or `"Callsign"` — so the loop calls `readElementText(SkipChildElements)` on the **root itself**. Per Qt's docs that reads "until the corresponding `EndElement`", i.e. it consumes and discards the entire rest of the document (including the nested `<Session><Key>…</Key></Session>` block) before the loop ever gets a chance to inspect it. Net effect: `sessionKey`/`sessionError`/`info` come back empty for *every* login and lookup response, valid credentials or not, so every login attempt reported `"QRZ login rejected (no session key)"`. Fix: skip start tags encountered before entering a recognized block instead of feeding them to `readElementText`, so the root wrapper is passed over rather than consumed. Note: `tests/qrz_callsign_test.cpp` (added in #3990) covers callsign regex/spotter logic but never exercises `parseXml()` against a realistic root-wrapped response, which is how this shipped. ### 2. Windows source builds silently compiled out credential persistence, and even with it enabled, the app couldn't find the DLL Found while confirming fix #1 on a real Windows 11 build: - The README's "Windows 11" build steps never run `scripts/setup/setup-qtkeychain.ps1`, so `find_package(Qt6Keychain)` fails silently (`REQUIRE_KEYCHAIN` defaults `OFF`), `HAVE_KEYCHAIN` never gets defined, and the QRZ password is never saved — `CallsignLookupService::savePassword()`/`loadPasswordFromKeychain()` become no-ops. The Setup dialog's "Test Login" button still worked because it bypasses the keychain and tests raw typed credentials directly, which masked the gap: real lookups (`View → Callsign Lookup`) always got `AuthFailed` (empty password) and silently fell back to offline `cty.dat` prefix data, appearing as "QRZ.com unavailable". - After staging qtkeychain and confirming `Qt6Keychain found` in the CMake configure log, running straight from the build directory still failed with "qt6keychain.dll was not found" — `windeployqt` (which the Windows installer CI job manually feeds the DLL through, see `.github/workflows/windows-installer.yml`) isn't part of a plain local build, so nothing put the DLL next to the exe. Fix: add the `setup-qtkeychain.ps1` step to the README's Windows instructions, and add a `WIN32`-guarded `POST_BUILD` step in `CMakeLists.txt` that copies `qt6keychain.dll` next to the built exe when present, so a local dev build gets working persistence without a manual copy. ## Test plan - [x] Built on Windows 11 (MSVC 2022, Qt 6.8.3) with qtkeychain staged via `setup-qtkeychain.ps1` - [x] Radio Setup → QRZ tab → Test Login succeeds with a real QRZ.com account (previously always failed with "no session key") - [x] Password persists across closing/reopening the Setup dialog (previously lost every time — confirmed root cause was the missing keychain DLL/build step, not app logic) - [x] View → Callsign Lookup → enter callsign → Lookup returns real QRZ.com data (previously always fell back to "QRZ.com unavailable" prefix-only data) 🤖 Generated with [Claude Code](https://claude.com/claude-code)
…l regression tests — Principle VIII. Two gaps surfaced by live testing on the keychain-less test-Mac build: 1. Session-password fallback. In a build without QtKeychain (or when the keychain read fails — locked keychain, ACL denial on an unsigned test binary), a password typed in Radio Setup → QRZ was never handed to QrzClient at all: savePassword() only warned, so lookups fell back to prefix cards even seconds after the operator entered valid credentials. CallsignLookupService now keeps a memory-only m_sessionPassword and applies it whenever the keychain can't supply one — persistence and usability are separate concerns. Never written to disk; a restart still requires re-entry when no keychain exists. 2. Parser regression tests. The aethersdr#4043 root-element bug (readElementText on <QRZDatabase> swallowed the whole response; login broken end-to-end since aethersdr#3990) shipped because parseXml had zero test coverage. ParsedResponse/parseXml are now public-for-tests, and qrz_callsign_test pins the three live response shapes: login (Session/Key), lookup (Callsign + Session), and session-timeout (Error, no Key). Verified the tests catch the original bug: against the pre-aethersdr#4043 parser they fail 6/6; with the aethersdr#4043 guard, all pass. Stacked on aethersdr#4043 (requires its parser guard for the new tests to pass). 💻 Generated with Claude Code (claude-fable-5 7/1/26) with architecture by @jensenpat
…l regression tests (stacked on #4043) (#4044) **Stacked on #4043 — merge that first.** This branch contains #4043's commits plus one of mine; after #4043 merges, the diff reduces to my commit alone. ## What live testing on a keychain-less build surfaced Deploying a test build whose worktree had never run `scripts/setup/setup-qtkeychain.sh` (so `find_package(Qt6Keychain QUIET)` silently compiled persistence out) exposed two gaps: ### 1. Typed password unusable without a keychain In a keychain-less build — or when the keychain read fails (locked keychain, ACL denial on an unsigned test binary) — a password typed in **Radio Setup → QRZ** was never handed to `QrzClient` at all: `savePassword()` only logged a warning. Lookups fell back to country prefix cards seconds after the operator entered valid credentials, which reads as "lookup is broken". `CallsignLookupService` now keeps a **memory-only session password** and applies it whenever the keychain can't supply one. Persistence and usability are separate concerns: without a keychain the password still works for the whole session and simply has to be re-entered after a restart (the QRZ tab's Test Login already worked, since it uses the field values directly). Never written to disk. ### 2. Zero test coverage let the #4043 bug ship The `<QRZDatabase>` root-element bug (login broken end-to-end since #3990) shipped because `parseXml` had no tests. `ParsedResponse`/`parseXml` are now public-for-tests with a comment saying why, and `qrz_callsign_test` pins the three live response shapes: - login success (`Session`/`Key`) - lookup success (`Callsign` + `Session`) - session timeout (`Error`, no `Key`) **Verified the tests catch the original bug** (Principle VIII/XI): against the pre-#4043 parser they fail 6/6; with #4043's guard, all 53 checks pass. ## Testing - `qrz_callsign_test`: ALL PASS with #4043; 6 failures without it (regression coverage proven both ways). - Root-caused from pulled test-Mac logs: `WRN aether.qrz: QRZ password persistence unavailable: built without QtKeychain` → prefix-fallback-only lookups; this PR + a properly staged qtkeychain build fix both symptoms. 💻 Generated with Claude Code (claude-fable-5 7/1/26) with architecture by @jensenpat


What this adds
A framework for pulling station contact information from QRZ.com and popping a themed contact card wherever AetherSDR identifies a station — starting with the CW decoder, designed so the upcoming AI SSB voice-callsign decoder pops the exact same card.
Working CW: automatic screen-pop
When the CW decoder prints a station identifying itself —
DE KI6BCJ KI6BCJ— a compact contact card pops beside the decoded text with callsign, license-class chip, name, location, grid/county, and the station photo. The doubled call is the decode-confidence gate: one garbled character breaks the repeat and correctly suppresses the spot. An ID sent at the very end of a transmission (… 73 DE K1AB K1ABthen silence) still spots via a 3 s settle timer.View → Callsign Lookup… (Ctrl+Shift+L)
Manual lookups get a
PersistentDialogwith the large card variant, cache-age status line, and a force-refresh button.Radio Setup → QRZ tab
Enable toggle, username, password (OS keychain via QtKeychain — never in the settings file), async Test Login, cache count + Clear Cache.
Architecture
core/QrzClientagent=AetherSDR<ver>), key reuse, transparent one-shot re-login + retry on expiry (per-call retry guard — no login loops)core/CallsignInfocore/CallsignLookupServicecore/CwCallsignSpotterDE <call> <call>watcher; per-call re-emit suppression (120 s)core/QrzLookupSettingsAppSettings["QrzLookup"]nested blob (Principle V)gui/CallsignCardgui/MainWindow_Callsign.cppAgent automation bridge
New
qrzverb group (docs updated):status/cached <call>/lookup <call>/spottext <text>—spottextfeeds the real spotter → service → card path, so the end-to-end screen-pop is provable with no radio and no QRZ account (seeded cache). Bonus:invoke setCurrentText/setCurrentIndexnow drive QTabBar, making deferred setup-dialog tabs reachable from the bridge for the first time.Testing
tests/qrz_callsign_test.cpp— 36/36 PASS: callsign regex, spotter stream behavior (char-by-char arrival, settle timer, garbled-repeat rejection, dedup, cross-station splice guard), JSON round-trip, 7-day TTL rule.qrz spottext "CQ CQ CQ DE KI6BCJ KI6BCJ K"→ card popped with cached data + photo (screenshots above are those captures); lookup dialog driven via menu action +callsignLookupInput/callsignLookupGo; QRZ tab via the new tab verb; enable-toggle round-trip verified throughqrz status.theme_manager_test, which fails identically onmainon this machine (it loads the operator's live user settings — pre-existing, unrelated).Notes for review
QXmlStreamReader; callsigns are regex-validated at the boundary before any network request (Principle VII).💻 Generated with Claude Code (claude-fable-5 7/1/26) with architecture by @jensenpat