feat(aetherd): 2.3 — complete PanadapterModel conversion (all Flex status decode behind the seam)#4065
Conversation
…ual-parse convergence Second universal panadapter field converted through the RFC 2.3 template, plus closing the waterfall dual-parse gap disclosed on #4063. Behavior-neutral. DECODE (min/max dBm — the Y-axis display geometry pairing center/bandwidth's X): - IRadioBackend: new panRangeChanged(panId, minDbm, maxDbm). dBm is signed, so an omitted bound is carried as NaN ("unchanged"), not a negative sentinel — the generalization the -1-based center/bandwidth sentinel couldn't express. - FlexBackend::decodePanRange parses min_dbm/max_dbm, emits only when present. - PanadapterModel::setRange(minDbm, maxDbm): NaN-guarded normalized setter, emits levelChanged on actual change, returns whether it changed. min/max removed from applyPanStatus (and its now-dead levelChanged local). - RadioModel drives it from handlePanadapterStatus (the choke point — live + deferred/replayed), and the ctor-wired handler preserves both side-effects the old inline block owned: panStream setDbmRange (only when the range actually changed, so no redundant GPU-scale reset) and legacy panadapterLevelChanged. CONVERGENCE (waterfall center/bandwidth): - applyWaterfallStatus no longer independently parses center/bandwidth; the waterfall-status choke point now routes them through the same FlexBackend::decodePanCenterBandwidth as pan status, so both ingestion surfaces are single-sourced onto setCenterBandwidth (the #4063 gap). TESTS: - aetherd_pan_decode_test extended: decodePanRange present/absent/both-negative (NaN sentinel), and setRange NaN-unchanged + changed-return semantics. 65/65 tests green; boundary --strict green; full app builds clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…decode behind the seam Finishes the PanadapterModel touchpoint in one pass: applyPanStatus and applyWaterfallStatus are REMOVED — the model holds zero wire decode. Every remaining field moved to FlexBackend, driving the model via normalized signals wired in RadioModel's ctor. Behavior-neutral (adversarially reviewed field-by- field); FlexBackend is main-thread so the emit→apply chain is synchronous and status ordering is preserved. UNIVERSAL typed signals (rfgain + antenna promoted per the 2026-07-05 classification — cross-radio concepts, so a second backend emits them and the existing UI just works): - panRfGainChanged, panRxAntennaChanged, panAntennaListChanged, panWaterfallLineDurationChanged (+ center/bandwidth, min/max dBm from prior). - Model gains setRfGain/setRxAntenna/setAntList/setWaterfallLineDuration. FLEX EXTENSION channel (namespaced extensionStatus): - "panState" bundle: wide, loop A/B, fps, preamp, DAX-IQ channel, MultiFlex client_handle (#3977 fail-closed re-stamp preserved verbatim), waterfall stream-id — applied via PanadapterModel::applyStateExtension. "panWnb" stays. - fps ok-guard + reported-always quirk, line_duration ok-guard, loopA/loopB mutual-exclusion all preserved. CONVERGENCES: - RadioModel's duplicate ant_list parse now folds onto panAntennaListChanged. - Waterfall center/bandwidth + line_duration decode via the same backend path. GUI (restores a latent #4063 regression): six call sites used applyPanStatus({{"center",…}}) as an optimistic pre-echo model nudge. #4063 moved center/bandwidth decode out of applyPanStatus, silently turning those into NO-OPS (zoom/tune/pan-follow waited a radio round-trip to recenter). Swapped to setCenterBandwidth — the optimistic repaint works again. TESTS: aetherd_pan_decode_test extended (rfgain/antenna/panState/#3977 ownership/line_duration guard); panadapter_model_rx_antenna_test uses the new setters. 65/65 green; boundary --strict green; full app builds clean. Design doc pan row + classification decision updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ten9876
left a comment
There was a problem hiding this comment.
Code review — high-effort recall pass (8 finder angles → verify)
Verdict: clean, well-tested migration — no confirmed functional regression that reaches a live consumer. This finishes moving all 13 display pan keys + 3 waterfall keys out of the now-deleted applyPanStatus/applyWaterfallStatus into FlexBackend decoders behind 5 typed signals + a panState extension bundle. A key-by-key audit confirms every deleted key is re-homed with identical guards (loop mutual-exclusion, client_handle !=0 && changed, fps/line_duration ok-guards), identical side-effects (setDbmRange change-gated, PerfTelemetry, antListChanged), and it correctly single-sources the waterfall center/bandwidth onto decodePanCenterBandwidth — closing the #4063 dual-parse gap. Nice to see real decode tests (NaN sentinel + client_handle fail-closed). The #4063 dangling-m_flexBackend finding is fixed here (~RadioModel now nulls the alias, citing the review).
Four findings inline. Only the two parse-guard items are worth acting on — cheap, and they bring the new boundary decoders in line with their own siblings and FlexLib's fail-closed semantics. #3/#4 are latent (no live consumer / not synchronously reachable).
Cleanup, non-blocking: the m_panadapters.value(panId); if(!pan) pan=activePanadapter(); idiom is now duplicated ~7× — a one-line private resolvePan(panId) helper would single-source the addressing policy (commented inline). The per-status shape (6 decode passes + up to 5 typed-signal dispatches, each re-resolving the pan; three coexisting "unchanged" conventions -1.0/NaN/present-only) is largely inherent to the RFC's approved per-field-signal design — noted as a future consolidation, not a blocker.
| const double nan = std::numeric_limits<double>::quiet_NaN(); | ||
| const double minDbm = kvs.contains(QStringLiteral("min_dbm")) | ||
| ? kvs.value(QStringLiteral("min_dbm")).toDouble() : nan; | ||
| const double maxDbm = kvs.contains(QStringLiteral("max_dbm")) | ||
| ? kvs.value(QStringLiteral("max_dbm")).toDouble() : nan; |
There was a problem hiding this comment.
[fail-closed gap — low severity] min_dbm/max_dbm parsed with unguarded toDouble().
A malformed/empty present field parses to 0.0 (not NaN), and setRange() only skips NaN — so 0 is applied as a real level, drives setDbmRange(…, 0, …), and collapses the vertical scale. Not a regression (the old applyPanStatus used the same unguarded toFloat()), but decode now sits on the Constitution Principle VII validation boundary, and the sibling decodeWaterfallLineDuration in this same PR — plus the post-#4063 WNB decoder — both guard with toInt(&ok). Close the asymmetry (real-world probability is low; FlexLib always emits valid dBm, and its own min_dbm/max_dbm cases use TryParseDouble + continue):
| const double nan = std::numeric_limits<double>::quiet_NaN(); | |
| const double minDbm = kvs.contains(QStringLiteral("min_dbm")) | |
| ? kvs.value(QStringLiteral("min_dbm")).toDouble() : nan; | |
| const double maxDbm = kvs.contains(QStringLiteral("max_dbm")) | |
| ? kvs.value(QStringLiteral("max_dbm")).toDouble() : nan; | |
| const double nan = std::numeric_limits<double>::quiet_NaN(); | |
| // Guard the numeric parse: a malformed *present* field must be ignored | |
| // (carry NaN = "unchanged"), not applied as 0.0 dBm — setRange() only skips | |
| // NaN. Matches decodeWaterfallLineDuration's ok-guard + FlexLib fail-closed. | |
| const auto dbm = [nan](const QString& s) { | |
| bool ok = false; | |
| const double v = s.toDouble(&ok); | |
| return ok ? v : nan; | |
| }; | |
| const double minDbm = kvs.contains(QStringLiteral("min_dbm")) | |
| ? dbm(kvs.value(QStringLiteral("min_dbm"))) : nan; | |
| const double maxDbm = kvs.contains(QStringLiteral("max_dbm")) | |
| ? dbm(kvs.value(QStringLiteral("max_dbm"))) : nan; |
| if (!kvs.contains(QStringLiteral("rfgain"))) { | ||
| return; | ||
| } | ||
| emit panRfGainChanged(panId, kvs.value(QStringLiteral("rfgain")).toInt()); |
There was a problem hiding this comment.
[fail-closed gap — low severity] rfgain parsed with unguarded toInt().
Same class as the dBm decoder above: a malformed rfgain parses to 0 and is emitted as a real gain → setRfGain(0) overwrites the displayed value. Lower severity (0 is a plausible gain, so no scale collapse), but FlexLib's rfgain case uses int.TryParse + continue, and decodeWaterfallLineDuration already models the guard. One-line fix:
| emit panRfGainChanged(panId, kvs.value(QStringLiteral("rfgain")).toInt()); | |
| bool ok = false; | |
| const int gain = kvs.value(QStringLiteral("rfgain")).toInt(&ok); | |
| if (ok) { | |
| emit panRfGainChanged(panId, gain); | |
| } |
| [this](const QString& panId, double minDbm, double maxDbm) { | ||
| auto* pan = m_panadapters.value(panId, nullptr); | ||
| if (!pan) pan = activePanadapter(); | ||
| if (!pan) return; |
There was a problem hiding this comment.
[latent — removed behavior] if (!pan) return; drops the old no-pan panadapterLevelChanged emit.
The old inline block emitted panadapterLevelChanged unconditionally whenever min_dbm|max_dbm was present — including the pan==null branch, where it synthesized kvs.value("min_dbm","-130") / "-20" defaults (an explicitly-coded null path). This early-return sits before the emit below, so the comment two lines up ("preserves … the legacy panadapterLevelChanged signal") holds only for the non-null case.
Latent, not a UI regression today — grep finds no live connect() to RadioModel::panadapterLevelChanged (the spectrum consumer was removed; per-pan levelChanged is used instead). Flagging so it's a deliberate choice: either restore the no-pan emit for contract symmetry, or narrow the comment's "preserved" claim to the pan-resolved case.
| // center/bandwidth converge onto the SAME decodePanCenterBandwidth as | ||
| // pan status (single-sourced, the #4063 gap), and line_duration → the | ||
| // universal panWaterfallLineDurationChanged. applyWaterfallStatus is | ||
| // gone — PanadapterModel no longer decodes the wire. |
There was a problem hiding this comment.
[very low — latent UAF window] first m_flexBackend deref on the live status path; m_wanConn not disconnected in the dtor.
This adds the first m_flexBackend dereference on the live onStatusReceived observer. ~RadioModel destroys the backend at m_backend.reset() and nulls the alias two lines later, but m_wanConn->statusReceived is never disconnected in the dtor (only m_connection/m_panStream are). A waterfall status delivered in the reset→null window would pass if (ownerPan && m_flexBackend) on a freed backend.
Not synchronously reachable (the reset() teardown blocks on thread quit/wait(), which doesn't pump the main-thread event loop, so a queued WAN slot can't fire mid-reset). Cheap hardening: QObject::disconnect(m_wanConn, nullptr, this, nullptr) alongside the existing dtor disconnects, or null m_flexBackend before m_backend.reset().
| // aetherd RFC 2.3 extension template: Flex-specific pan fields (WNB) ride | ||
| // the namespaced extensionStatus channel; RadioModel routes them to the | ||
| // addressed PanadapterModel. Other namespaces/kinds are ignored here. | ||
| // aetherd RFC 2.3: min/max dBm — the second universal pan field. The backend |
There was a problem hiding this comment.
[cleanup — non-blocking] duplicated pan-resolution idiom.
auto* pan = m_panadapters.value(panId, nullptr); if (!pan) pan = activePanadapter(); now appears in all 5 new signal lambdas plus the extensionStatus handler and handlePanadapterStatus (~7 sites). A one-line private helper — PanadapterModel* resolvePan(const QString& panId) const { auto* p = panadapter(panId); return p ? p : activePanadapter(); } — collapses each to if (auto* pan = resolvePan(panId)) pan->setX(...); and single-sources the "fall back to active" policy, so a future change (e.g. don't fall back for MultiFlex-owned pans) can't be applied to 6 of 7 sites by accident. Not blocking this PR.
There was a problem hiding this comment.
Thanks @ten9876 — this is a clean, faithful completion of the PanadapterModel conversion. I traced every removed field through the new backend decode → normalized signal → setter chain and the semantics hold up: the >= 0.0 / NaN "unchanged" sentinels, the setDbmRange gating on setRange()'s changed-return, the fps "reported-always / changed-on-change" quirk, loop A/B mutual exclusion, and the #3977 client_handle re-stamp are all preserved verbatim. All decode references to applyPanStatus/applyWaterfallStatus are gone (only comments remain), and CI is green across build/macOS/Windows/CodeQL/engine-boundary. Nice work.
A few notes:
1. The two guard gaps you flagged are real — worth closing (low severity).
Confirmed both against the diff:
decodePanRange:kvs.value("min_dbm").toDouble()/max_dbmare unguarded. A present-but-malformed field parses to0.0(not NaN), sosetRange()— which only skips NaN — applies0as a real level and drivessetDbmRange(…, 0, …), collapsing the vertical scale.decodePanRfGain:kvs.value("rfgain").toInt()unguarded →setRfGain(0)overwrites the displayed gain.
You're right that neither is a regression (old applyPanStatus used the same unguarded toFloat()/toInt()), but the asymmetry is now stark within a single file: decodeWaterfallLineDuration and decodePanExtensions' wnb_level both guard with toInt(&ok) and the WNB one explicitly cites Principle VII as the reason. Since this decode is now the formal validation boundary, I'd apply your two suggested &ok guards for consistency. min_dbm is the higher-value fix (scale collapse vs. a plausible 0 gain).
2. Latent-regression fix on the GUI center nudges — verified, good catch.
The six applyPanStatus({{"center",…}}) → setCenterBandwidth(…, -1.0) swaps are correct. After #4063 moved center/bandwidth decode out of applyPanStatus, those calls were genuinely no-ops, and setCenterBandwidth's >= 0.0 gate means the -1.0 bandwidth sentinel leaves bandwidth untouched — exactly the center-only nudge those call sites intend.
3. Minor: stale comment. RadioModel.cpp:4261 (in noteForeignPanWriteIfAny) still reads "Note applyPanStatus re-stamps the pan when the radio reassigns it…". The mechanism is preserved, but the re-stamp now lives in applyStateExtension (the panState extension), so the comment names a method that no longer exists. Worth a one-line touch-up while you're here since it documents the #3977 ordering invariant.
None of these block; (1) and (3) would be nice to fold in before merge.
🤖 aethersdr-agent · cost: $6.9750 · model: claude-opus-4-8
There was a problem hiding this comment.
Reviewed the full diff, traced the removed applyPanStatus/applyWaterfallStatus decode against the new backend decoders field-by-field, and read the tests. This is a clean, well-structured completion of the PanadapterModel conversion — the model now genuinely holds zero wire decode, the per-field semantics (fps ok-guard + always-report quirk, loop A/B mutual exclusion, preamp no-emit, -1/NaN sentinels, #3977 client_handle fail-closed re-stamp) are preserved verbatim, and aetherd_pan_decode_test pins each one including the zero-handle fail-closed case. All 7 checks green. Nice work, @ten9876.
A few notes, none blocking:
1. The two self-flagged unguarded parses are worth fixing (Copilot findings — both valid).
decodePanRange's min_dbm/max_dbm .toDouble() and decodePanRfGain's rfgain .toInt() have no ok-guard, while the sibling decodeWaterfallLineDuration in this same PR guards with toInt(&ok). You're right that neither is a regression (old applyPanStatus was equally unguarded), but now that decode sits squarely on the Principle VII validation boundary and FlexLib itself fail-closes (TryParseDouble/int.TryParse + continue), the asymmetry is the thing to close. The min_dbm case has the higher practical impact: a malformed present field parses to 0.0 (not NaN), setRange() only skips NaN, so 0 is applied as a real level and setDbmRange(…, 0, …) collapses the vertical scale — a more visible failure than a bad rfgain clamping to 0. The dbm lambda suggestion you posted is the right shape; I'd take both.
2. Minor unmentioned deviation in the pan-null path for panadapterLevelChanged.
The old inline block emitted panadapterLevelChanged even when no pan resolved (falling back to the wire value or -130/-20 defaults). The new ctor-wired panRangeChanged handler does if (!pan) return; before the emit, so with zero panadapters present the level signal no longer fires. This is a very low-severity edge case (only reachable when both the addressed and active pan are null), but it's a behavioral difference the "pan-null path preserved" claim in the description doesn't cover — worth a one-line acknowledgement or restoring the fallback emit if any global scale listener depends on it.
3. The #4063 regression fix is a legitimate, well-reasoned catch. I confirmed on main that applyPanStatus no longer decodes center/bandwidth, so the six GUI applyPanStatus({{"center",…}}) nudges were indeed silent no-ops since #4063 — swapping to setCenterBandwidth (with -1.0 correctly skipped by the >= 0.0 guard for the center-only callers) restores the optimistic repaint. Worth calling out that routing the waterfall center/bandwidth path through decodePanCenterBandwidth now also emits panadapterInfoChanged, which the old model-only infoChanged path did not — harmless (arguably more correct), just an extra emit to be aware of.
Overall this is in good shape; only #1 is worth acting on before merge, and it's the fix you already drafted.
🤖 aethersdr-agent · cost: $2.6618 · model: claude-opus-4-8
…ng, resolvePan helper
Resolves ten9876's review of the PanadapterModel conversion.
- Guard the min_dbm/max_dbm parse in decodePanRange: a malformed *present*
field now carries NaN ("unchanged") instead of 0.0, which setRange would
apply as a real level and collapse the vertical scale via setDbmRange.
Brings it in line with the sibling decodeWaterfallLineDuration/WNB ok-guards
and FlexLib's TryParseDouble+continue (Principle VII boundary).
- Guard the rfgain parse in decodePanRfGain likewise (malformed → dropped, not
setRfGain(0)).
- ~RadioModel: null m_flexBackend BEFORE m_backend.reset(), and disconnect
m_wanConn (the WAN statusReceived path now derefs m_flexBackend via the
waterfall handler) — closes the reset→null UAF window on the WAN status path.
Not synchronously reachable today, but this PR added the first live-path deref.
- Add resolvePan(panId) — the "addressed pan, else active" policy was duplicated
across all 6 backend-signal lambdas + the extensionStatus handler; single-
sourced so a future MultiFlex addressing change can't miss a site.
- Narrow the panadapterLevelChanged comment: the old pan==null synthesized-
default emit is intentionally dropped (the signal has no live connect()); the
claim now scopes to the pan-resolved case.
- Tests: malformed min_dbm (→NaN) and malformed rfgain (→dropped) guard cases.
65/65 green; boundary --strict green; full app builds clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review response — pushed 30b3e65Thanks @ten9876 — sharp pass, especially catching that this PR adds the first live-path Parse guards (fail-closed asymmetry)
Dtor UAF window —
65/65 green, boundary |
rfoust
left a comment
There was a problem hiding this comment.
Reviewed against the current upstream/main merge result.
No blocking findings from my pass. The earlier review items appear addressed in the current code: dBm/rfgain parse guards are in place, WAN teardown hardening is present, resolvePan() is added, and the dropped no-pan panadapterLevelChanged behavior is documented as intentional.
One non-blocking cleanup nit: a couple of comments still reference the removed applyPanStatus() path near the ownership/decode code. Runtime behavior looks correct, but updating those comments would make the new 2.3 seam easier to trace later.
Local validation passed:
git diff --check upstream/main...upstream/pr/4065cmake --build build --target aetherd_pan_decode_test panadapter_model_rx_antenna_test -j8ctest --test-dir build -R '^(aetherd_pan_decode_test|panadapter_model_rx_antenna_test)$' --output-on-failurepython3 tools/check_engine_boundary.py --strictpython3 tools/check_a11y.pyon the touched GUI filescmake --build build --target AetherSDR -j8
NF0T
left a comment
There was a problem hiding this comment.
Independent red-team pass (second review round, 30b3e65c)
Traced this against actual source (not just the diff) rather than taking the prior passes' claims at face value — three things held up under independent verification, one small gap confirmed, and two process items nobody's raised yet.
What I independently verified (not just re-asserted)
- #3977 ownership-ordering. Grepped
noteForeignPanWriteIfAny's call site (RadioModel.cpp:3662) — it fires in the raw per-line status dispatch, before any object-specific routing reacheshandlePanadapterStatus. Relocating theclient_handlere-stamp to the end of the decode chain (insidedecodePanState/applyStateExtension) doesn't cross that boundary. Ordering invariant holds. - "Synchronous DirectConnection" claim.
FlexBackend's ctor doesmoveToThread()onm_panStream/m_connection, which initially read as a contradiction — but those are FlexBackend's owned wire objects; theFlexBackendQObject itself stays on the main thread and is what emitspanRangeChanged/panRfGainChanged/etc. Chain is synchronous as claimed. - Commit signing. All three commits verify
true/validvia the API.
Confirmed gap (low severity, already self-disclosed — independently reproduced)
panRangeChanged's ctor handler does if (!pan) return; before emitting panadapterLevelChanged, where the old inline code emitted it unconditionally (wire value or -130/-20 defaults) even with zero panadapters resolved. I grepped src/ for panadapterLevelChanged — one emit site, one declaration; the two other hits (MainWindow_Session.cpp, MainWindow_Wiring.cpp) are stale comments, not connect()s. So this is confirmed dead-signal territory today, not a live regression — but "pan-null path preserved" in the description should read "preserved except the now-provably-dead no-pan case."
CI: green claims are stale, not false
Both prior review passes state "all 7 checks green" / "CI is green across build/macOS/Windows/CodeQL/engine-boundary" — posted 00:22–00:23. The fix commit 30b3e65c wasn't pushed until 00:27:16. As of this pass, on the actual current HEAD:
| Check | State |
|---|---|
| build, check-macos, engine-boundary, a11y-static | ✅ pass |
| check-windows | 🟡 in progress |
| analyze (cpp) / CodeQL | 🟡 in progress |
Not a red flag on the code — just: nobody has actually watched CI go green on 30b3e65c yet. Please confirm both before merge.
Process items — need resolution before this can be approved, not just noted for later
- No issue reference. Body has no
Fixes #/Closes #;closingIssuesReferencesis empty. If this is tracked informally against #3849 + the touchpoints doc rather than a dedicated issue, that's fine, but CONTRIBUTING.md's one-issue-per-PR rule expects an explicit reference — worth adding even if retroactive. - Self-approval deadlock. This diff touches
src/gui/MainWindow.cpp, which CODEOWNERS gates Tier 1 to@aethersdr/maintainers— currently just@ten9876per the file's own header comment. The author is@ten9876. GitHub hard-blocks self-approval on every tier, so as filed there is currently no one who can satisfy this PR's Tier-1 gate. This holds regardless of the hunk being a 3-line mechanicalapplyPanStatus→setCenterBandwidthswap — CODEOWNERS matches on path, not diff size. Options: add a second maintainer to the team, use an admin merge override, or split theMainWindow.cppline out of this PR so it lands Tier-3-only.
Separate from this PR, worth a maintainer look
CONTRIBUTING.md's "Reviews and merging" table lists src/core/RadioModel.{h,cpp}, src/core/AudioEngine.{h,cpp}, and src/core/PanadapterStream.{h,cpp} as Tier-1 maintainer-only. The actual .github/CODEOWNERS doesn't gate any of them (only MainWindow.{h,cpp} is Tier 1), and the documented path is stale besides — RadioModel lives under src/models/, not src/core/. This PR happens to touch RadioModel.cpp's destructor/teardown ordering (exactly the class of change burned before in #502/#4063) but only requires Tier-3 sign-off under the enforced gate. Not blocking this PR, but the docs/CODEOWNERS mismatch should get reconciled.
Net: the PanadapterModel conversion itself is sound and the two guard fixes from the last round are exactly right. Nice catch on the dtor UAF window, too — that's a real one, and the fix (null m_flexBackend before reset, disconnect m_wanConn) is the correct shape. Once check-windows/CodeQL finish clean on 30b3e65c and the Tier-1 gate has a path to clear, this is ready.
Review response — round 2Thanks @rfoust (approval + local validation), @aethersdr-agent, @NF0T. Confirmed resolved on
Acknowledged, no code change:
Process (@NF0T):
The remaining stale "old applyPanStatus" provenance comments (@rfoust nit) will fold into the next model PR rather than spend a CI cycle alone. |
There was a problem hiding this comment.
Reviewed the full diff against main (checked out the branch to trace the removed decoders). This is a clean, careful conversion — the field-by-field behavior preservation is real, not just claimed. All 7 checks green, 65/65 tests. Nice work, @ten9876.
Verification notes:
Field coverage is complete. Every field the old applyPanStatus/applyWaterfallStatus decoded (client_handle, min/max_dbm, rfgain, pre, wide, loopa/b, fps, ant_list, rxant, waterfall, daxiq_channel, center/bandwidth) maps to exactly one new decoder, with the per-field semantics preserved verbatim — including the fps reported-vs-changed split, the loop A/B mutual exclusion, the pre no-emit, and the line_duration ok-guard. No decode responsibility was dropped.
The two Copilot fail-closed findings are already fixed in this diff. decodePanRange's dbm lambda and decodePanRfGain both carry the toDouble/toInt(&ok) guard now, so a malformed present field carries NaN / is skipped rather than collapsing the scale or overwriting gain with 0. Confirmed valid concerns, correctly resolved — good catch closing the asymmetry with decodeWaterfallLineDuration.
setCenterBandwidth(freq, -1.0) GUI swaps are behavior-preserving — setCenterBandwidth guards each arg on >= 0.0, so the -1.0 bandwidth sentinel is a clean "center only", matching the old single-key applyPanStatus({{"center",…}}). And these genuinely restore the optimistic pre-echo nudge that #4063 silently turned into no-ops — well spotted.
Destructor hardening is a good defensive catch. Moving m_flexBackend = nullptr ahead of m_backend.reset() and severing m_wanConn closes a real UAF window now that WAN-delivered status flows through decodePan* → m_flexBackend.
Two small, non-blocking items:
-
Intentional-but-new legacy emit on the waterfall path. The old
applyWaterfallStatussynced center/bandwidth via the per-paninfoChangedonly. Routing the waterfall path through the shareddecodePanCenterBandwidthnow also fires RadioModel's legacypanadapterInfoChanged(the ctor handler emits it). This looks like the deliberate single-sourcing of the #4063 gap and is benign (same authoritative values MainWindow already consumes), but it is a new signal emission on that path — worth a one-line confirmation it's intended rather than incidental. -
Stale comment at
src/gui/MainWindow_DigitalModes.cpp:1167still says "applyPanStatus updates the local model immediately" though the code below now callssetCenterBandwidth. Trivial, fix if you touch the file.
Nothing here blocks. The extension-channel split and the resolvePan single-sourcing read well and set up the remaining 2.3 mixed-model conversions cleanly.
🤖 aethersdr-agent · cost: $3.2805 · model: claude-opus-4-8
…ode behind the seam) (#4066) Completes the **MeterModel** touchpoint of aetherd RFC 2.3 — the SmartSDR meter-status wire decode moves out of `RadioModel` into `FlexBackend`, behind the seam. Behavior-neutral. MeterModel was already the cleanest of the five mixed models: it holds only core state (the `MeterDef` catalog + the smoothed values the UI renders via `MeterSmoother`), and the Flex-specific decode lived in `RadioModel::handleMeterStatus`. This moves that last piece behind the seam. ## What moved - **IRadioBackend**: `meterDefined(int index, QVariantMap fields)` + `meterRemoved(int index)` — the normalized meter-definition catalog signals. (Meter *values* stream on the VITA-49 data plane, step 5 — untouched here.) - **FlexBackend::decodeMeterStatus**: parses the `"N.key=value#…"` definition body and the `"N removed"` line — **verbatim** from the old `handleMeterStatus`, including the `num` base-0 parse, the `#`/`.`/`=` tokenizer, and present-only field extraction. `QMap` grouping preserves ascending-index emit order. - **RadioModel**: `handleMeterStatus` is now a thin forwarder to `decodeMeterStatus` (driven from the status choke point, so live + deferred/replayed meter status both convert). The ctor-wired `meterDefined`/`meterRemoved` handlers reconstruct `MeterDef` **present-only** and call `defineMeter`/`removeMeter` exactly as before — so `defineMeter`'s SLC/LEVEL catalog caching and the `m_manifestSliceContext` side-effect run identically. ## Behavior-neutral — verified - Field-by-field, the reconstructed `MeterDef` is byte-identical to the old inline parse (`src`→source, `num`→sourceIndex base-0, `nam`→name, `unit`, `low`/`hi`→double, `desc`→description; all present-only). - `defineMeter` does a full replace (`m_defs[index] = def`) — the present-only reconstruction preserves exactly what the old code passed it. - Emit order: `QMap<int,…>` iterates ascending index in both the backend group loop and (previously) the RadioModel loop, and each `meterDefined` emit is a synchronous `DirectConnection` (FlexBackend is main-thread) that runs `defineMeter` inline — so definition order and the choke-point ordering are preserved. - Dual-parse hunt: no other meter-status parser exists in the tree. ## Tests / gates - `aetherd_meter_decode_test`: full definition, `"N removed"`, multiple meters in one body (grouped), malformed index token skipped. - **66/66 tests green**, engine-boundary `--strict` green, full app builds clean. Refs #3849 (aetherd RFC tracking) — part of the step-2.3 touchpoint burndown (`docs/architecture/aetherd-touchpoints.md`). ## 2.3 progress PanadapterModel ✅ (#4065) · **MeterModel (this PR)** · SliceModel → TransmitModel + RadioModel residual next. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ok-guarded numeric parses Resolves ten9876's #4068 findings. - TYPED PAYLOAD (#2, strategic): the slice-status payload becomes a compiler- checked SliceDelta struct (std::optional per field) instead of the stringly- keyed QVariantMap. A field-name typo between FlexBackend::decodeSliceStatus and SliceModel::applyChanges is now a compile error, not a silently-dropped field. IRadioBackend::sliceChanged(int, const SliceDelta&); SliceDelta lives in src/core/backends/ so the interface stays free of model deps. The vendor-neutral rename win is unchanged — applyChanges still reads only canonical fields. Verified: 69 delta fields set in the decode, 69 read in the model, set-diff empty both ways (now also enforced by the compiler). - PARSE GUARDS (#1): the decode's numeric parses are ok-guarded — a malformed present field is dropped, not applied as 0/0.0. Higher-stakes here than pan/meter: a garbled RF_frequency would otherwise retune the slice to 0 Hz. Matches FlexLib's TryParse+continue and the standard set on #4065/#4066. - DOC FIXES: the sliceChanged connect comment now accurately describes the AutoConnection + same-thread assumption (not a hard "DirectConnection", which would be wrong if a backend ever moves to a worker thread) (#4); the stale "applyStatus already called below" comment reworded to reference decodeSliceStatus (#5). - Tests: aetherd_slice_decode_test now asserts on the typed delta (incl. a malformed-RF_frequency-dropped case); slice_model_letter_test / antenna_alias_test build SliceDelta directly. 67/67 tests green; boundary --strict green; full app builds clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…lice-status decode behind the seam) (#4068) Completes the **SliceModel** touchpoint of aetherd RFC 2.3 — the largest of the five mixed-model splits. ~70 fields of Flex slice-status wire decode move out of `SliceModel` into `FlexBackend`, with a **full vendor-neutral rename** of the seam contract (per the 2026-07-05 scope decision). Behavior-neutral. ## What moved - **`FlexBackend::decodeSliceStatus(sliceId, kvs)`** owns ALL the SmartSDR wire knowledge and emits the normalized, canonically-named, typed change map via `IRadioBackend::sliceChanged`: - key renames (`RF_frequency`→`frequency`, `filter_lo`/`filter_hi`→`filterLow`/`filterHigh`, `lock`→`locked`, `audio_level`→`audioGain`, `tx`→`txSlice`, `dax`→`daxChannel`, `index_letter`→`letter`, …) - `"1"`→bool; `esc` `"1"`/`"on"`→bool; comma-split + trim for antenna/mode lists (with `rx_ant_list` precedence over `ant_list`); `fm_tone_mode`/`repeater_offset_dir` lowercase. - **`SliceModel::applyStatus` → `applyChanges(const QVariantMap&)`** reads only canonical keys; **every** piece of model business logic is unchanged — the mode-dependent filter-polarity flip, the `audio_mute` / `speex_nr_level` / `rtty_mark` override re-pushes, the diversity change-detection block, `fmToneValue` formatting, and each field's exact emit gate (unconditional-when-present vs change-gated). - **`RadioModel::handleSliceStatus`** routes both `applyStatus` call sites through `decodeSliceStatus`; a ctor-wired `sliceChanged` handler applies to `slice(id)`. `FlexBackend` is main-thread → synchronous `DirectConnection`, so a freshly-appended slice is populated before the `sliceAdded` UI notify (ordering preserved). - `splitAntennaList` relocated from `SliceModel` into the backend decode. ## Behavior-neutral — verified three ways 1. **Programmatic key symmetry**: the backend inserts **69 canonical keys**, the model reads **69** — set-difference is **empty in both directions**, so there is no name mismatch (silent field death) and no key the model waits forever for. 2. **Adversarial field-by-field review** across 8 dimensions (name consistency, no dropped field, type correctness incl. the `float`↔`double` gain fields, `"1"`→bool + the `esc`/`play`/`in_use` special cases, present-only + combined-block "keep current when absent", all side-effects, per-field emit conditionality, RadioModel routing + synchronous ordering) — **no regressions**. 3. **Tests**: `slice_model_letter_test` + `antenna_alias_test` converted to canonical `applyChanges`; new **`aetherd_slice_decode_test`** pins the wire→canonical mapping (renames, `"1"`/`"on"`→bool, list split, `rx_ant_list` precedence, lowercase, raw `play`/`step_list`, present-only). ## Gates - **66/66 tests green**, engine-boundary `--strict` green, full app builds clean. ## Notes - The seam-contract keys are now fully canonical (vendor-neutral) — an HL2 or other backend emits the same map for the fields it supports; unknown/absent keys are simply not applied. - `client_handle` slice ownership + slice lifecycle remain RadioModel-level (the doc's SliceModel "extension" bucket), unchanged by this PR. Refs #3849 (aetherd RFC tracking) — part of the step-2.3 touchpoint burndown. ## 2.3 progress PanadapterModel ✅ (#4065) · MeterModel 🟡 (#4066) · **SliceModel (this PR)** · TransmitModel + RadioModel residual next. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tDelta, last mixed model) (#4071) Completes the **TransmitModel** touchpoint — the **last of the five mixed-model splits** in aetherd RFC 2.3. All five Flex transmit-family status decoders move out of `TransmitModel` into `FlexBackend`, behind a typed, compiler-checked `TransmitDelta` payload (the pattern established for slices in #4068). Behavior-neutral. ## What moved - **`FlexBackend::decodeTransmitStatus` / `decodeInterlockStatus` / `decodeAtuStatus` / `decodeApdStatus` / `decodeApdSamplerStatus`** own all the SmartSDR wire knowledge: key names, `"1"`→bool, **ok-guarded + clamped** numeric parses (per-field ranges), uppercase, list split, the **compander/dexp alias precedence** (`compander` wins; `dexp`/`noise_gate_level` alias only when the compander key is absent), and the sampler `INTERNAL`-prepend. Each emits `IRadioBackend::transmitChanged(const TransmitDelta&)`. - **`TransmitModel`**: the five `apply*Status` methods → one merged **`applyChanges(const TransmitDelta&)`**. Every emit group is preserved exactly — `stateChanged` / `tuneChanged` / `micStateChanged` / `phoneStateChanged` / `txFilterCutoffChanged` / inline `maxPowerLevelChanged`+`txSliceModeChanged` / `atuStateChanged` / `apdStateChanged` / `apdEqualizerResetReceived` / `apdSamplerChanged` — along with the model-side business logic: `compander` → both `companderOn`/`dexpOn` (+ mic/phone flags), the **ATU enum parse**, and the **stateful per-antenna APD-sampler map** + selected-not-in-available→`INTERNAL` fallback. - **`RadioModel`**: the 6 transmit-family call sites route through the decode methods; a ctor-wired `transmitChanged` handler drives the model (synchronous, main-thread). ## Behavior-neutral — verified - **Adversarial field-by-field review** across the 5-method merge's central risks — emit-group mapping (every field lands in its original group; `met_in_rx`→`changed` not phone; compander sets both mic+phone; interlock emits nothing), cross-contamination (single-plane deltas isolate each group's emits), clamp ranges (all match), ATU/sampler logic + idempotency, routing, and the `optional<bool>` presence-vs-value footgun — **no regressions**. - **57 delta fields set in the decode = 57 read in the model**, empty set-diff both ways (now compiler-enforced by the typed struct). ## Fail-closed (intentional, not a regression) Malformed *present* numerics now drop instead of applying `0`/`0.0` — `freq`, `max_power_level`, and the interlock ints were previously unguarded (a garbled `max_power_level` would have emitted `maxPowerLevelChanged(0)`). No well-formed input changes behavior. Consistent with the slice/pan standard. ## Also in this PR - **Folds in the deferred #4066 meter parse-guards** (`num`/`low`/`hi` ok-guarded — a live fail-closed gap on `main`) + a malformed-value meter test. - **Registered `transmit_model_apd_test`** — it was built but never run (pre-existing CMake omission). ## Tests / gates - `transmit_model_test` + `transmit_model_apd_test` converted to typed deltas; new **`aetherd_transmit_decode_test`** pins the wire→delta mapping (clamps, ok-guard-drop, compander/dexp aliasing, ATU raw token, APD bare-flag, sampler `INTERNAL`-prepend + empty-`tx_ant` no-emit). - **68/68 tests green**, engine-boundary `--strict` green, full app builds clean. ## Scope note — "RadioModel residual" This finishes the five mixed models. RadioModel's own remaining *direct* Flex decoders are the radio-**global** status handlers (`handleRadioStatus`/`handleGpsStatus`/`handleMemoryStatus`/`handleProfileStatus`) — not a sub-model, and a distinct smaller concern. Kept out of this PR (already the largest) as a separate follow-up rather than bloating it. Refs #3849 (aetherd RFC tracking). Related: #4065, #4066, #4068, #4070 (typed-payload parity for pan/meter). ## 2.3 scoreboard Panadapter ✅ (#4065) · Meter ✅ (#4066) · Slice 🟡 (#4068) · **Transmit (this PR)** — the five mixed models complete. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes the PanadapterModel touchpoint of aetherd RFC 2.3 in a single pass —
applyPanStatusandapplyWaterfallStatusare removed; the model holds zero wire decode. Every Flex status field now decodes inFlexBackendand drives the model via normalized signals wired inRadioModel's ctor. This is the first fully converted mixed model (the template PR #4063 proved the mold on two fields; this finishes the model).What moved behind the seam
Universal typed signals (rfgain + antenna promoted to universal per the 2026-07-05 classification — cross-radio concepts, so a second backend emits them and the existing UI works with zero vendor-specific handling):
panCenterBandwidthChanged,panRangeChanged(min/max dBm),panRfGainChanged,panRxAntennaChanged,panAntennaListChanged,panWaterfallLineDurationChanged.setRfGain/setRxAntenna/setAntList/setRange/setWaterfallLineDuration.Flex extension channel (namespaced
extensionStatus):"panWnb"(from feat(aetherd): 2.3 template (complete) — decode + encode + extension facets on PanadapterModel/SliceModel #4063) + new"panState"bundle:wide, loop A/B,fps, preamp, DAX-IQ channel, MultiFlexclient_handle, waterfall stream-id →PanadapterModel::applyStateExtension.Convergences (closing dual-parse gaps):
ant_listparse folds ontopanAntennaListChanged.center/bandwidth+line_durationdecode through the same backend path as pan status (single-sourced; thecenter/bandwidthgap was disclosed on feat(aetherd): 2.3 template (complete) — decode + encode + extension facets on PanadapterModel/SliceModel #4063).Behavior-neutral — verified
Adversarially reviewed field-by-field (dropped guards, emit-timing, sentinels, ordering, pan-null path). Preserved verbatim:
fpsok-guard + "reported-always / changed-on-change" quirk;line_durationok-guard; loopA/loopB mutual-exclusion.client_handlefail-closed re-stamp (parsed != 0 && parsed != current) — the re-stamp moves from first→last field withinhandlePanadapterStatus, but the only ownership readers (noteForeignPanWriteIfAny, pan-reclaim) run at the top of status dispatch before this handler, so the ordering Stale MultiFlex session keeps auto-floor-adjusting a reclaimed pan; foreign dBm-range echoes bypass smoothing and bounce the trace (#3951 root cause) #3977 relies on is untouched. Pinned by a test.-1.0(center/bw) andNaN(dBm, signed) "unchanged" sentinels; thesetDbmRangeGPU side-effect + legacypanadapterInfoChanged/panadapterLevelChangedemits.decodePan*→ signal → setter chain is a synchronousDirectConnectioncompleting before they_pixels/configurePanbookkeeping — status ordering is preserved.panadapterLevelChangedeven on the pan==null path (synthesized-130/-20defaults). That signal now has no liveconnect()(spectrum consumer removed; per-panlevelChangedused instead), so the no-pan emit is intentionally dropped rather than resurrected as dead code.Six GUI call sites used
applyPanStatus({{"center",…}})as an optimistic pre-echo model nudge (zoom, tune, pan-follow, ATU pre-tune). #4063 moved center/bandwidth decode out ofapplyPanStatus, silently turning those calls into no-ops — so since #4063 merged, those workflows have waited a full radio round-trip to recenter (visible flicker/lag onmain). Swapped tosetCenterBandwidth, restoring the immediate repaint. This slipped #4063's review because the GUI callers weren't in that diff's scope.Tests / gates
aetherd_pan_decode_testextended: rfgain/antenna decode,panStatebundle, Stale MultiFlex session keeps auto-floor-adjusting a reclaimed pan; foreign dBm-range echoes bypass smoothing and bounce the trace (#3951 root cause) #3977 ownership fail-closed,line_durationmalformed-guard, NaN/-1sentinels.panadapter_model_rx_antenna_testuses the new setters.--strictgreen, full app builds clean.Next in 2.3
One complete PR per remaining mixed model: MeterModel → SliceModel → TransmitModel + RadioModel residual.
Refs #3849 (aetherd RFC tracking) — part of the step-2.3 touchpoint burndown (
docs/architecture/aetherd-touchpoints.md).🤖 Generated with Claude Code