feat(aetherd): 2.3 template (complete) — decode + encode + extension facets on PanadapterModel/SliceModel#4063
Conversation
…exBackend — Principle I. The first converted touchpoint of RFC step 2.3, proving the model-split pattern on the smallest genuine slice before expanding the blast radius. The universal panadapter display fields (center/bandwidth) now flow: wire → RadioModel status choke point (handlePanadapterStatus, so both live and deferred/replayed status convert) → FlexBackend::decodePanCenterBandwidth → IRadioBackend::panCenterBandwidthChanged (normalized typed signal) → PanadapterModel::setCenterBandwidth → GPU render. - IRadioBackend: new panCenterBandwidthChanged(panId, centerMhz, bandwidthMhz). - FlexBackend: decodePanCenterBandwidth() parses the Flex "display pan" kvs and emits the normalized signal (only when the fields are present; negative = "unchanged" for a field the radio omitted). - PanadapterModel: new setCenterBandwidth() normalized setter; center/bandwidth removed from applyPanStatus (the 13 Flex-specific pan fields stay there until they convert). - RadioModel: transitional concrete FlexBackend* alias drives the decode from its status choke point; the typed signal is routed to the addressed pan and the legacy panadapterInfoChanged is preserved. Behavior-neutral. Verified: clean build; 64/64 tests; boundary --strict green; LIVE panadapter GPU grab on a FLEX-8600 renders the correct frequency axis (7.14–7.28 MHz, center 7.22) — i.e. center/bandwidth flowed correctly through the new path. This is the template. The remaining universal pan fields (min/max dBm, waterfall) and the other four mixed models follow the same mold. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
NF0T
left a comment
There was a problem hiding this comment.
Human-directed independent review (Claude / @NF0T)
Tier: All 7 changed files (IRadioBackend.h, FlexBackend.{cpp,h}, PanadapterModel.{cpp,h}, RadioModel.{cpp,h}) are plain src/ paths — Tier 3, default CODEOWNERS rule.
CI at review time: build, check-macos, and both static-analysis jobs green. check-windows/CodeQL were still pending — worth a final glance before merge.
Verified independently (not just re-asserting the PR body)
- Principle I: confirmed against FlexLib source (
Panadapter.cs:918,951) —"center"/"bandwidth"are the exact wire status keys, both MHz doubles, matching this PR's decode exactly. - Principle II: the new path only reads wire kvs and never writes back to the radio — one-way reconciliation preserved.
- Choke-point driving:
decodePanCenterBandwidthis called fromhandlePanadapterStatusitself (not a live-only observer), so deferred/replayed status is genuinely covered as claimed. - Pan-resolution consistency: the new ctor-wired lambda's
m_panadapters.value(panId) → activePanadapter()fallback exactly matches the pre-existing fallback used elsewhere in the same function — no divergence between old and new pan resolution. - Thread safety: the decode call happens synchronously from already-main-thread status processing, so the emitted signal resolves to a direct connection regardless of
FlexBackend's own thread affinity. - Touchpoint manifest: correctly left
unconvertedformodels/PanadapterModel.hsince this is a partial field-level conversion, consistent with the documented incremental methodology inaetherd-iradiobackend-design.md. - Default member values (
m_centerMhz{14.1}/m_bandwidthMhz{0.2}) never collide with the-1.0"unchanged" sentinel.
Findings
1. (Moderate, non-blocking, pre-existing but undisclosed) The "single source of truth" claim is incomplete. PanadapterModel::applyWaterfallStatus() (PanadapterModel.cpp, right after the line_duration handling) still independently parses "center"/"bandwidth" straight out of kvs and writes m_centerMhz/emits infoChanged, entirely bypassing the new FlexBackend::decodePanCenterBandwidth() path. This code isn't touched by this diff, so it's not a regression — but it means center/bandwidth aren't actually single-sourced from the backend yet, and this second ingestion surface isn't mentioned anywhere in the PR body's field inventory (understandably, since it isn't even in applyPanStatus). Cross-checked against FlexLib's Waterfall.cs: "center" is genuinely live there (line 799); "bandwidth" is an explicit FlexLib no-op (line 1116, grouped purely to suppress a debug warning) — so this duplicate may be tracking a field FlexLib itself doesn't treat as meaningful in waterfall scope. Recommend disclosing this as a known gap or folding it into scope before calling this "the template" for the rest of 2.3.
2. (Minor, non-blocking) No test coverage for the new decode/normalize chain. FlexBackend::decodePanCenterBandwidth(), PanadapterModel::setCenterBandwidth(), and the new RadioModel wiring have zero unit tests in this diff, despite existing precedent for testing kvs-driven PanadapterModel setters directly (tests/panadapter_model_rx_antenna_test.cpp). Given this PR is explicitly the reusable template for the rest of RFC 2.3, shipping it without a test sets a weak precedent for the many follow-on conversions.
3. (Nit) ~RadioModel() doesn't null m_flexBackend. RadioModel.cpp nulls m_connection/m_panStream after m_backend.reset() as a defensive pattern against dangling non-owning pointers, but the new m_flexBackend alias isn't included. Harmless today (nothing reads it afterward), but inconsistent with the file's own convention.
Verdict
No functional regression confirmed — the core claim (byte-identical behavior + live GPU verification) holds up independently, and the design is sound for the slice it claims to cover. Posting as COMMENT rather than APPROVE/REQUEST_CHANGES pending your call on whether finding 1 or 2 should hold this before merge.
…— Principle I & VI. Extends #4063 so the model-split template proves all THREE facets before the blast radius expands, not just decode: FACET 2 — universal ENCODE through a backend verb, TX-safety preserved: - SliceModel::setMode now expresses intent (modeChangeRequested) instead of building "slice set N mode=…"; RadioModel routes it to FlexBackend::setSliceMode. - The slice verbs route through a dedicated guarded slice sink (setSliceCommandSink → RadioModel::sendSliceCommand), so the TX-inhibit interlock stays ABOVE the seam (RFC §6). Verified LSB↔USB round-trip on a live FLEX-8600. FACET 3 — Flex-specific field → namespaced extension: - New IRadioBackend::extensionStatus(ns, kind, fields) — the status counterpart to invokeExtension, the vendor-namespaced UP channel. - WNB (wideband noise blanker) decode moves out of PanadapterModel::applyPanStatus into FlexBackend::decodePanExtensions → extensionStatus("flex","panWnb") → PanadapterModel::applyWnbExtension. Verified: WNB button tracks toggles precisely (the state flows the new path). Both driven from RadioModel's status choke point (live + deferred), same as the decode facet. Behavior-neutral. Verified: clean build; 64/64 tests; boundary --strict green; on a live FLEX-8600 — slice-0 mode change round-trips through the backend verb, WNB toggle tracks through the extension path, panadapter GPU grab intact. Template now complete; the remaining pan fields + the other four models follow this exact mold. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Template completed — all three facets now proven (836a8a6)Per the "prove the template before expanding the blast radius" plan, this PR now covers all three facets a full 2.3 model-split needs, each verified on a live FLEX-8600:
All three are driven from Verified: clean build, 64/64 tests, boundary |
NF0T
left a comment
There was a problem hiding this comment.
Human-directed independent review, update (Claude / @NF0T) — covers commit 836a8a6
Follow-up to the earlier review of 7897704 (decode facet only). This pass covers the newly pushed encode + extension facets. Local build verified clean on this commit; CI: build/check-macos/static-analysis green, check-windows/CodeQL pending at review time.
Verified against FlexLib (Principle I)
wnb/wnb_level/wnb_updatingare the correct wire keys (Panadapter.cs:1226,1244,1262)."slice set N mode=X"is the correct wire format (Slice.cs:295) — unchanged, correctly preserved through the move.sendSliceCommand(nullptr, cmd)correctly falls back tothis->slice(txCommand.sliceId)for ID-based resolution — no null-deref, no dangling risk, behaviorally equivalent to passing a real pointer.extensionStatusdispatch guard and pan-resolution fallback match the established pattern from the decode facet — no divergence.
Findings
1. (Moderate — regression, independently confirmed against FlexLib) Dropped input validation on wnb_level. Old PanadapterModel::applyPanStatus parsed wnb_level via kvs["wnb_level"].toInt(&ok) and only applied it if (ok && ...) — mirroring FlexLib's own validation exactly (Panadapter.cs:1244: uint.TryParse + range check, "Invalid value" + skip on failure). The new FlexBackend::decodePanExtensions() uses kvs.value("wnb_level").toInt() with no ok check — a malformed/non-numeric wnb_level on the wire now silently becomes 0 and gets applied to the model (spurious wnbChanged/wnbStateChanged) instead of being rejected as before. Low-probability trigger, but a real Principle VII (Untrusted Input Validated At The Boundary) regression that both the prior AetherSDR code and FlexLib itself guarded against. Recommend restoring the ok-checked parse before merge.
2. (Minor — overstated claim, not a functional bug) "TX-inhibit interlock stays above the seam" doesn't actually change behavior for mode changes. TransmitInhibitPolicy::parseSliceTxCommand only recognizes commands containing a literal tx= kv. A "slice set N mode=X" command was already routing through the same guarded sendSliceCommand() before this PR (via SliceModel::commandReady), and the guard was already a no-op for mode changes both before and after. This commit is a pure architecture/plumbing change — the commit message overstates what the "guarded slice sink" accomplishes here specifically.
3. (Minor — quietly expanded scope, harmless) setSliceFrequency/setSliceFilter rerouted but unreachable. The diff also switches FlexBackend::setSliceFrequency()/setSliceFilter() to the new sendSlice() path, but neither has any caller yet (confirmed via git grep pinned to 836a8a6 — the only caller of any of the three is the new setSliceMode call). Dead code today, harmless, but not mentioned in the PR's facet description.
4. (Nit — documentation loss) The removed WNB block's comment — "FlexLib v4.2.18 exposes wnb_updating on display pan status while the radio normalizes the SCU-level WNB threshold; keep it distinct from the per-pan WNB enable flag" — is real, non-obvious wire-quirk knowledge that wasn't carried into decodePanExtensions/applyWnbExtension.
5. (Minor — recurring gap) Still zero test coverage. decodePanExtensions, applyWnbExtension, modeChangeRequested, and setSliceMode/sendSlice have no unit tests (confirmed via git grep pinned to 836a8a6), despite precedent (tests/slice_model_letter_test.cpp, tests/panadapter_model_rx_antenna_test.cpp). Same gap flagged in the original decode-facet review, now repeated across two more facets in what's explicitly meant to be the reusable template for the rest of RFC 2.3.
Assessment
No live-behavior-breaking bug found. Finding 1 is the one with real teeth — a narrow but genuine input-validation regression relative to both prior AetherSDR code and the FlexLib reference it was modeled on. Findings 2-5 are framing/completeness/hygiene, not correctness. Across both commits reviewed so far, the pattern is consistent: sound architecture, correct on the happy path, but validation rigor and test coverage slipping at the edges — worth tightening before this template gets copied three more times for the rest of 2.3.
There was a problem hiding this comment.
Thanks @ten9876 — this is a clean, well-documented realization of the 2.3 template. I traced the whole path (wire → handlePanadapterStatus → decodePanCenterBandwidth/decodePanExtensions → normalized signals → setCenterBandwidth/applyWnbExtension) plus the slice encode seam, and the decode side is genuinely behavior-neutral. A few observations, none blocking. CI is green across all 7 checks (build, check-windows, check-macos, CodeQL, Engine/UI dependency direction).
Correctness — verified, no bugs found
- The
sendSliceCommand(nullptr, cmd)sink is safe:parseSliceTxCommandclassifiesslice tune/set mode/filtas non-TX-enabling, so the guard is inert for these verbs and never dereferences the null slice. When a real TX-enable command does route here later, the guard resolves the target from the command string (this->slice(txCommand.sliceId)) exactly as intended. - The ctor-wired pan lookup (
m_panadapters.value(panId)→activePanadapter()fallback) mirrorshandlePanadapterStatus's existing resolution, so the addressed-vs-active behavior is preserved. The legacypanadapterInfoChangedis emitted exactly once (the old inline block is replaced by the handler), so no double-emit. SliceModel::setMode→modeChangeRequested→setSliceModeproduces a byte-identicalslice set N mode=…string to the old directsendCommand, and reaches the samesendSliceCommand. Behavior-neutral.
1. The body undersells the scope (worth a note for future reviewers/bisects). The description frames this as "the smallest genuine slice — two universal panadapter fields (center/bandwidth)," but the diff also lands (a) the full slice encode path — setSliceCommandSink/sendSlice/m_sliceSink, SliceModel::modeChangeRequested, and the setSliceFrequency/Mode/Filter rewiring to the guarded sink — and (b) the WNB extension facet end-to-end. The title ("decode + encode + extension facets") is accurate; it's just the "Why this scope" section that reads narrower than reality. No change needed to the code, but if a TX-inhibited-slice-mode regression ever surfaces, it traces to this PR, not a later one — good to have that on record.
2. Minor: wnb_level parse robustness regression. The old applyPanStatus guarded with toInt(&ok) and only applied the value when ok. The new FlexBackend::decodePanExtensions uses a bare .toInt(), so a malformed wnb_level=<non-numeric> now writes 0 (clamped) into the model instead of being ignored. Real Flex firmware won't send that, so severity is low, but preserving the ok-check when inserting the key keeps parity:
if (kvs.contains(QStringLiteral("wnb_level"))) {
bool ok = false;
const int lvl = kvs.value(QStringLiteral("wnb_level")).toInt(&ok);
if (ok) wnb.insert(QStringLiteral("wnb_level"), lvl);
}3. Latent (fine today): modeChangeRequested is only serviced when m_flexBackend is set. With Flex as the only backend this is correct, but once a non-Flex backend exists, setMode becomes a silent no-op on the wire while still emitting modeChanged. A // TODO(2.x): route via IRadioBackend once encode is backend-owned at the connect site would flag it for whoever generalizes the seam.
Nice work proving the template on the live RX plane. Points 2 and 3 are optional polish — I'd take this as-is.
🤖 aethersdr-agent · cost: $5.7085 · model: claude-opus-4-8
ten9876
left a comment
There was a problem hiding this comment.
Code review — high-effort recall pass (8 finder angles → verify)
Verdict: no confirmed functional regression. Ship-shape as a behavior-neutral seam step, modulo one narrow decode delta. Two independent finders (decode-half + encode/lifetime-half) plus a manual line-by-line pass agree the 2.3 template is behavior-preserving: the mode-change wire string is byte-for-byte identical, synchronous, and connected for every slice (incl. the stale-slice reclaim path); the -1.0/absent-field sentinels are correct; extension routing targets the right pan; and the emit-order change has no coupled consumer.
One real delta and two template-hardening items follow inline. I've kept them separate from @NF0T's earlier review, which already covers:
- Waterfall dual-parse of
center/bandwidth— pre-existing (applyWaterfallStatusstill parses independently); this PR neither introduces nor fixes it. Not a regression; worth a follow-up to converge both ingests on the newsetCenterBandwidthpath. - No test coverage for the new decode/encode facets — agreed; the decode facets (
decodePanCenterBandwidth,decodePanExtensions) are pure and cheap to unit-test and would have caught thewnb_leveldelta below.
Confirmed-safe, no action needed: m_sliceSink→m_sink fallback is dead-but-harmless in the wired RadioModel; setSliceFrequency/setSliceFilter are dead template code today (still on the old guarded sendCommand path via SliceModel) — when they convert, wire them like modeChangeRequested and mind the null-slice inhibit gap noted below.
| if (kvs.contains(QStringLiteral("wnb_level"))) { | ||
| wnb.insert(QStringLiteral("wnb_level"), | ||
| kvs.value(QStringLiteral("wnb_level")).toInt()); | ||
| } |
There was a problem hiding this comment.
[behavior delta — the one real regression] Lost the wnb_level numeric guard.
The old inline path did int v = kvs["wnb_level"].toInt(&ok) and only applied it under if (ok && ...), so a malformed / non-numeric wnb_level was ignored. Here .toInt() (no ok) returns 0 on failure, which is inserted, flows through extensionStatus → PanadapterModel::applyWnbExtension (writes m_wnbLevel = 0 and emits wnbChanged), snapping the WNB-level UI to 0 instead of leaving it untouched.
Low real-world probability (FlexLib emits an integer here), but it contradicts the "decode is behavior-neutral" invariant this step is trying to hold. Restore the guard:
| if (kvs.contains(QStringLiteral("wnb_level"))) { | |
| wnb.insert(QStringLiteral("wnb_level"), | |
| kvs.value(QStringLiteral("wnb_level")).toInt()); | |
| } | |
| if (kvs.contains(QStringLiteral("wnb_level"))) { | |
| bool ok = false; | |
| const int level = kvs.value(QStringLiteral("wnb_level")).toInt(&ok); | |
| if (ok) { | |
| wnb.insert(QStringLiteral("wnb_level"), level); | |
| } | |
| } |
| flex->setModelProvider([this]{ return m_model; }); | ||
| m_connection = flex->connection(); // non-owning; the backend owns it | ||
| m_panStream = flex->panStream(); // non-owning; the backend owns it | ||
| m_flexBackend = flex.get(); // transitional alias (2.3) |
There was a problem hiding this comment.
[latent UAF / template hygiene] m_flexBackend alias is never nulled on teardown.
~RadioModel deliberately disconnect(...)s m_connection/m_panStream before m_backend.reset() (the #502 ASAN fix), but it does not null this alias or tear down the slice→modeChangeRequested / backend→handler lambdas. After reset(), m_flexBackend dangles, and every if (m_flexBackend) guard (here, in handleSliceStatus's mode lambda, and in handlePanadapterStatus's decode calls) passes on the dangling pointer — false safety.
Not reachable in normal teardown (event loop isn't spinning past reset()), so PLAUSIBLE not CONFIRMED — but note the asymmetry: handlePanadapterStatus is also reachable via the WAN statusReceived connection, which the dtor never disconnects; only the non-spinning-loop timing saves it. As 2.3 wires more decode off these choke points this becomes the reachable-UAF template. One-liner, right after m_backend.reset() in ~RadioModel:
m_backend.reset();
m_flexBackend = nullptr; // alias now dangles; drop it so guards fail closed| flex->setCommandSink([this](const QString& cmd){ sendCommand(cmd); }); | ||
| // Slice verbs route through the TX-inhibit-guarded slice sink (§6), so | ||
| // moving slice encode behind the seam keeps TX safety above it. | ||
| flex->setSliceCommandSink([this](const QString& cmd){ |
There was a problem hiding this comment.
[latent, not reachable in this PR] Null-slice TX-inhibit bypass on the slice sink.
Routing slice verbs through sendSliceCommand(nullptr, cmd) is correct today — the guard re-resolves the slice from the command text, and no verb currently on this path emits tx=1 (setSliceMode/Frequency/Filter are all non-transmitting), so parseSliceTxCommand(...).txEnabled is false and the inhibit block is skipped entirely. Verified null-safe.
The latent gap: if a tx-enabling verb is ever added to the sendSlice path, transmitInhibitMessageForSlice(null) returns empty for a since-removed slice → guard skipped → tx=1 sent, where the old per-slice-pointer path still held the real SliceModel*. Before any transmitting verb rides this sink, have it carry/re-resolve the slice or reject tx-enabling commands on the null path. Flagging so it's on record with the template, not asking for a change here.
…d, alias teardown, decode tests Resolves the review findings on #4063. No behavior change to the live path beyond restoring the one dropped guard. - Restore the wnb_level numeric guard in FlexBackend::decodePanExtensions (the one real regression, flagged by NF0T/ten9876/aethersdr-agent): the move to a bare .toInt() would silently apply a malformed wnb_level as 0 and snap the WNB-level UI. Back to toInt(&ok) + if(ok) — parity with the old applyPanStatus path and FlexLib's own uint.TryParse guard (Panadapter.cs:1244). Principle VII. - Null m_flexBackend in ~RadioModel after m_backend.reset() so the decode guards fail closed instead of dereferencing the dangling transitional alias — handlePanadapterStatus is also reachable via the WAN statusReceived connection, so this is the reachable-UAF template as 2.3 wires more decode onto the choke points. - Restore the wnb_updating wire-quirk comment lost in the extension move. - TODO(2.x) at the modeChangeRequested connect site: it no-ops on the wire for a non-Flex backend today (m_flexBackend null) while still emitting modeChanged. Fine while Flex is the only backend. - Add aetherd_pan_decode_test covering all three facets (decode / extension / model sinks) incl. the malformed-wnb_level guard case that would have caught the regression — closing the test-coverage gap all three reviewers flagged on the template PR. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review response — pushed 478a31fThanks @NF0T @ten9876 @aethersdr-agent — thorough passes. Consensus was clear (behavior-neutral seam step, one real delta), and I've resolved every finding rather than deferring: Fixed in code
Test coverage (flagged by all three)
Disclosed in the PR body (deferred, not regressions)
On record, no change (not reachable here)
|
There was a problem hiding this comment.
Reviewed the full diff against main. This is a clean, well-scoped increment — the three-facet template (decode / encode / extension) is coherent, the choke-point driving is correct, and the test coverage pins the behavior that matters. Thanks for the thorough write-up and the self red-team, @ten9876.
The three self-review findings are all resolved in the current head
For the record, since these were flagged as open concerns — the final commit (478a31f0, "address 2.3 template red-team findings") already fixes them, so they're now no-ops against the current diff:
wnb_levelguard — restored.FlexBackend::decodePanExtensions(FlexBackend.cpp:224) doestoInt(&ok)and only inserts underif (ok), andaetherd_pan_decode_testpins the malformed-input drop. Confirmed the "decode is behavior-neutral" invariant holds. ✅m_flexBackendalias teardown —~RadioModelnow nulls it immediately afterm_backend.reset()(RadioModel.cpp:690), so theif (m_flexBackend)guards fail closed. No event processing runs betweenreset()and the null in the dtor, so the WAN-statusReceived-reachable window is closed. ✅- Null-slice TX bypass — accurately characterized as latent/not-reachable. Verified:
parseSliceTxCommand("slice set N mode=…").txEnabledis false, so the inhibit block insendSliceCommandis skipped entirely and thenullptrslice is never dereferenced (RadioModel.cpp:941). Correct to flag-for-record rather than change here.
Verification I ran
- Pan resolution is consistent. The ctor-wired
panCenterBandwidthChanged/extensionStatushandlers resolve the target pan viam_panadapters.value(panId)→activePanadapter()fallback — byte-identical tohandlePanadapterStatus's own lookup, so decode + WNB land on the same model as the old inline path. Behavior-neutral. ✅ - Encode path parity. Old
setModealready routed throughsendSliceCommand(viacommandReady); the only delta iss→nullptr, and the guard re-resolves bysliceIdfrom the command text. Wire string unchanged. ✅ - Dependency-direction CI check passed despite the transitional concrete
FlexBackend*inRadioModel— good, the alias is documented as going away when status ingress becomes backend-owned. - Scope is tight: all 11 files are on the stated touchpoint, test is registered in CMake, no stray files.
Minor (non-blocking) — defense-in-depth for the model sink
PanadapterModel::applyWnbExtension (PanadapterModel.cpp:97) does fields.value("wnb_level").toInt() with no ok check, relying entirely on FlexBackend having guarded upstream. That's fine for the single caller today, and the std::clamp(…, 0, 100) bounds the worst case, but as 2.3 adds more producers on the extensionStatus channel this becomes an implicit contract worth making explicit — either re-guard here or add a comment noting the sink trusts pre-validated input. Not a change I'd block on.
Overall: LGTM as a template. The narrow-but-end-to-end scoping is the right call for de-risking the rest of 2.3, and the "known gaps" disclosure (waterfall dual-parse, remaining Flex pan fields) is accurate against the tree. Nice work.
(CI still in progress at review time — build/macos/windows/analyze pending; dependency-direction and accessibility checks green.)
🤖 aethersdr-agent · cost: $2.3279 · model: claude-opus-4-8
NF0T
left a comment
There was a problem hiding this comment.
Human-directed independent review, final pass (Claude / @NF0T) — 478a31f
Pulled the branch locally, built it (clean), and independently re-verified rather than just trusting the write-up:
wnb_levelguard fix — confirmed in the diff: back totoInt(&ok)+if (ok), matching FlexLib's ownuint.TryParse/skip-on-failure behavior.m_flexBackendteardown fix — confirmed: nulled immediately afterm_backend.reset()in~RadioModel().- New test
aetherd_pan_decode_test— built and ran it myself (all checks passed), and read the source: it specifically pins the malformed-wnb_level-must-be-dropped-not-zeroed regression, plus decode-sentinel and model-sink coverage for all three facets. - PR body — now honestly discloses the waterfall dual-parse gap and reframes the TX-inhibit claim accurately (a plumbing move for now, not a live behavior change).
Every finding raised across our two reviews, the bot's two passes, and @ten9876's self red-team is either fixed in code or disclosed as a deliberate, pre-existing, or deferred item. One optional nit remains on record (defense-in-depth ok-check in PanadapterModel::applyWnbExtension, not blocking — the single caller already validates upstream).
We're good to go from our side. Given how load-bearing this template is for the rest of RFC 2.3, leaving the merge call to @ten9876 rather than approving ourselves.
…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>
…atus decode behind the seam) (#4065) Completes the **PanadapterModel** touchpoint of aetherd RFC 2.3 in a single pass — `applyPanStatus` and `applyWaterfallStatus` are **removed**; the model holds zero wire decode. Every Flex status field now decodes in `FlexBackend` and drives the model via normalized signals wired in `RadioModel`'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`. - Model gains normalized setters: `setRfGain` / `setRxAntenna` / `setAntList` / `setRange` / `setWaterfallLineDuration`. **Flex extension channel** (namespaced `extensionStatus`): - `"panWnb"` (from #4063) + new `"panState"` bundle: `wide`, loop A/B, `fps`, preamp, DAX-IQ channel, MultiFlex `client_handle`, waterfall stream-id → `PanadapterModel::applyStateExtension`. **Convergences** (closing dual-parse gaps): - RadioModel's duplicate `ant_list` parse folds onto `panAntennaListChanged`. - Waterfall `center`/`bandwidth` + `line_duration` decode through the same backend path as pan status (single-sourced; the `center`/`bandwidth` gap was disclosed on #4063). ## Behavior-neutral — verified Adversarially reviewed field-by-field (dropped guards, emit-timing, sentinels, ordering, pan-null path). Preserved verbatim: - `fps` ok-guard + "reported-always / changed-on-change" quirk; `line_duration` ok-guard; loopA/loopB mutual-exclusion. - #3977 `client_handle` fail-closed re-stamp (`parsed != 0 && parsed != current`) — the re-stamp moves from first→last field within `handlePanadapterStatus`, but the only ownership readers (`noteForeignPanWriteIfAny`, pan-reclaim) run at the **top** of status dispatch before this handler, so the ordering #3977 relies on is untouched. Pinned by a test. - `-1.0` (center/bw) and `NaN` (dBm, signed) "unchanged" sentinels; the `setDbmRange` GPU side-effect + legacy `panadapterInfoChanged`/`panadapterLevelChanged` emits. - FlexBackend is main-thread, so every `decodePan*` → signal → setter chain is a synchronous `DirectConnection` completing before the `y_pixels`/`configurePan` bookkeeping — status ordering is preserved. - **One deliberate exception:** the old code emitted `panadapterLevelChanged` even on the pan==null path (synthesized `-130`/`-20` defaults). That signal now has **no live `connect()`** (spectrum consumer removed; per-pan `levelChanged` used instead), so the no-pan emit is intentionally dropped rather than resurrected as dead code. ##⚠️ Also fixes a latent regression from #4063 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 of `applyPanStatus`, 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 on `main`). Swapped to `setCenterBandwidth`, 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_test` extended: rfgain/antenna decode, `panState` bundle, #3977 ownership fail-closed, `line_duration` malformed-guard, NaN/`-1` sentinels. - `panadapter_model_rx_antenna_test` uses the new setters. - **65/65 tests green**, engine-boundary `--strict` green, full app builds clean. - Design doc pan row + classification decision (§6.5) updated. ## 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](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
RFC step 2.3 (mixed-model split), proving the full three-facet template — decode, encode, extension — on the panadapter/slice touchpoints before expanding the blast radius. Scoped narrow, but end-to-end on all three facets so the mold is complete for the rest of 2.3.
The three facets (the template the rest of 2.3 follows)
1. DECODE — a universal field flows into the backend and out as a normalized typed signal:
decodePanCenterBandwidth()emits only when present (negative = "unchanged" for an omitted field); center/bandwidth removed fromapplyPanStatus.2. ENCODE — a command intent routes through a backend verb, TX-safety kept above the seam (RFC §6):
sendCommand. The guard is inert forslice set mode=(a non-tx=verb), so this is a plumbing move, not a behavior change — it establishes where the guard lives for the transmitting verbs that convert later.setSliceFrequency/setSliceFilterare rewired to the same guarded sink but have no caller yet — dead template code today, exercised when those verbs convert.3. EXTENSION — a Flex-specific field decodes to a namespaced channel, not the core profile:
panCenterBandwidthChanged(panId, center, bw)+extensionStatus(ns, kind, fields).FlexBackend*alias drives decode from the choke point and servicesmodeChangeRequested; legacypanadapterInfoChangedpreserved. Behavior-neutral.Why this scope
The status plane is a ~921-line monolith entangled with pan lifecycle + deferred-status buffering — no clean tiny increment, but a clean narrow one. Proving all three facets end-to-end validates the whole approach (typed signal, choke-point driving, normalized setter, guarded encode seam, namespaced extension, deferred-path coverage) at minimal risk. If a TX-inhibited-slice-mode regression ever surfaces in a bisect, it traces here — the encode + extension paths land in this PR, not a later one.
Known gaps (disclosed, deferred to the 2.3 burndown — not regressions)
PanadapterModel::applyWaterfallStatusstill independently parsescenter/bandwidth, so they aren't single-sourced from the backend yet. Pre-existing, untouched by this diff. (FlexLib treats waterfallbandwidthas an explicit no-op,Waterfall.cs:1116.) Converges ontosetCenterBandwidthin a follow-up.client_handle,loopa/loopb, min/max dBm, …) stay inapplyPanStatusuntil they convert.Verification
Clean build; 65/65 tests (adds
aetherd_pan_decode_testcovering all three facets incl. the malformed-wnb_levelguard); boundary--strictgreen; live panadapter GPU grab on a FLEX-8600 renders the correct frequency axis — direct visual proof center/bandwidth flowed through the new backend path (a broken path would mis-scale the X axis). Full automation-bridge battery: 10/10 (6 core + 4 facet), session log clean (0 crash/assert markers).Next (after this template is reviewed/merged)
Same mold for the remaining universal pan fields (min/max dBm, waterfall id), then MeterModel → SliceModel → TransmitModel → RadioModel.
🤖 Generated with Claude Code