feat(aetherd): 2.3 — complete TransmitModel conversion (typed TransmitDelta, last mixed model)#4071
Conversation
…tDelta, all 5 status planes behind the seam) The last of the five mixed-model splits. All five Flex transmit-family status decoders move out of TransmitModel into FlexBackend, behind a typed compiler-checked TransmitDelta payload. Behavior-neutral (adversarially reviewed field-by-field + 57/57 set-read symmetry). - 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, and the sampler INTERNAL-prepend — emitting IRadioBackend::transmitChanged(TransmitDelta). - TransmitModel: applyTransmitStatus/Interlock/Atu/Apd/ApdSampler → one merged applyChanges(const TransmitDelta&). Every emit group preserved exactly (stateChanged / tuneChanged / micStateChanged / phoneStateChanged / txFilterCutoffChanged / inline maxPowerLevelChanged+txSliceModeChanged / atuStateChanged / apdStateChanged / apdEqualizerResetReceived / apdSamplerChanged), plus the model-side business logic: compander→both companderOn/dexpOn pairs, the ATU enum parse, and the per-antenna sampler map + selected-fallback. - Fail-closed: malformed present numerics drop instead of applying 0/0.0 (freq/max_power_level/interlock were previously unguarded) — consistent with the slice/pan standard. No well-formed input changes behavior. - RadioModel: the 6 transmit-family call sites route through the decode methods; a ctor-wired transmitChanged handler drives the model (synchronous, main-thread). - Also folds in the deferred #4066 meter parse-guards (num/low/hi ok-guarded) + a malformed-value meter test. - Tests: transmit_model_test + transmit_model_apd_test converted to typed deltas; new aetherd_transmit_decode_test pins the wire→delta mapping (clamps, ok-guard, compander/dexp aliasing, ATU raw, APD bare-flag, sampler INTERNAL-prepend). Registered transmit_model_apd_test (was built but never run). 68/68 tests green; boundary --strict green; full app builds clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
805ce35 to
e673f9f
Compare
rfoust
left a comment
There was a problem hiding this comment.
Reviewed PR #4071 with a red-team pass focused on the TransmitModel seam conversion, typed TransmitDelta coverage, meter parse guards, engine boundary, and merge-ref build behavior.
Blocking findings: none found.
Non-blocking notes: none from this pass.
Local validation:
- Built the fetched PR merge ref successfully.
- Ran focused tests:
aetherd_transmit_decode_test,transmit_model_test,transmit_model_apd_test, andaetherd_meter_decode_test. - Ran
tools/check_engine_boundary.py --strict; only tracked legacy warnings. git diff --checkwas clean.- Spot-checked FlexLib v4.2.18.41174 under
~/Documents/source/FlexLib_API_v4.2.18.41174/FlexLibfor the moved transmit/ATU/APD/interlock parsing behavior.
Limit: no live radio hardware validation.
Approved from this review pass. CI was still partially pending when checked, so wait for the remaining required jobs before merge.
ten9876
left a comment
There was a problem hiding this comment.
Code review — high-effort recall pass (8 finder angles → verify)
Verdict: clean, faithful, well-tested — no functional regression, and it closes out the series' open feedback. The 5 Flex transmit-family status planes (transmit/interlock/ATU/APD/APD-sampler) move into FlexBackend::decode*Status, populating a typed TransmitDelta (std::optional, present-only, Q_DECLARE_METATYPE) → one transmitChanged signal → TransmitModel::applyChanges. Independent audits confirmed all ~60 wire keys round-trip 1:1 to correctly-named fields; the compander/dexp aliasing exactly reproduces the old 4-block logic; every clamp bound matches; per-plane emit-gating is correct (interlock sets no changed → no stateChanged; the nested atuChanged/apdChanged/samplerChanged scopes don't leak); no wrong-field transposition across 60 assignments; sampler INTERNAL-prepend + selected-fallback and the equalizer_reset bare-flag preserved.
This PR resolves the recurring feedback from the whole 2.3 series — worth calling out:
- Fail-closed complete —
tInt/tClamp/tRealall ok-guard, including the inlinenoise_gate_levelalias; no bare.toInt()/.toDouble()survives in the transmit family. Lands the #4065/#4066/#4068 call, and the #4066 meter guard is folded in here (num/low/hi) with a dedicated new test. - Typed struct adopted — resolves the untyped-
QVariantMapsmell from #4066/#4068; matches theSliceDeltaalready in place. - CI gain —
transmit_model_apd_testwas built but never run (noadd_test); now registered. - ATU-as-raw-string is the layering-correct boundary (the
ATUStatusenum is models-layer; core can't own the parse without inverting the dependency), and engine-boundary is clean.
Four findings inline, all low/medium — none blocking; design/cleanup, not line fixes.
| // The ATU status is carried as its raw wire token (`atuStatusRaw`); the model | ||
| // owns the ATUStatus enum + parse. The APD sampler is per-TX-antenna: a single | ||
| // delta carries one antenna's (txAnt, available, selected) triple. | ||
| struct TransmitDelta { |
There was a problem hiding this comment.
[altitude / cleanup — the one judgment call] Five planes collapsed onto one 60-field struct + one 130-line applyChanges.
Unlike the single-plane SliceDelta/MeterDef precedent, this one struct spans 5 semantically-distinct planes with different emit semantics — interlock is plain no-emit state, while core/mic/cw/ATU/APD each drive their own grouped signal. Nothing structurally binds a field to its plane: decodeInterlockStatus could populate d.rfPower, or a new core field added to the interlock decoder would silently apply through the shared applyChanges with no compile error. Correctness rests on landing the right local flag (changed/micChanged/phoneChanged/atuChanged/…) in the right if-block of a 130-line function.
Sub-structs matching the 5 decoders — TransmitDelta { core; mic; cw; interlock; atu; apd; sampler; } — would make the plane boundary compiler-enforced and let applyChanges split into cohesive per-plane helpers, each with a single-plane blast radius like the old 5 methods. Behavior is correct and well-tested today, so this is a maintainability decision for you — but since this is the last mixed model and sets the final shape of the seam, it's the natural moment to decide. (Two finder angles converged here.)
| // into a typed TransmitDelta; RadioModel drives the TransmitModel. Driven | ||
| // synchronously from the matching decode*Status() calls in the status | ||
| // handlers (main-thread AutoConnection → DirectConnection). | ||
| connect(m_backend.get(), &IRadioBackend::transmitChanged, this, |
There was a problem hiding this comment.
[latent — low] Typed deltas aren't qRegisterMetaType'd in production startup.
TransmitDelta (and SliceDelta) are registered only in the test main(), not at app startup. Correct today — decode*Status is invoked synchronously on RadioModel's thread, so this AutoConnection resolves to DirectConnection (no runtime metatype needed), and the comment above honestly says so. But if FlexBackend is ever moved onto one of its existing worker threads (the #4068 slice concern), the connection becomes Queued and Qt logs "Cannot queue arguments of type TransmitDelta" and silently drops the emit — transmit state stops updating, no crash. Cheap insurance that makes the seam thread-safe by construction: qRegisterMetaType<TransmitDelta>() + qRegisterMetaType<SliceDelta>() once at startup.
| // normalization the old TransmitModel decoders did inline. | ||
| namespace { | ||
| // present-only, ok-guarded carriers over a Flex kv-set. | ||
| inline void tBool(const QMap<QString, QString>& kvs, const char* wire, |
There was a problem hiding this comment.
[cleanup — low] Carrier helpers are the 3rd/4th re-roll of the same pattern.
tBool/tInt/tClamp/tReal duplicate decodeSliceStatus's oStr/oInt/oReal/oBool lambdas and the pan carry — and the meter decoder in this very PR hand-inlines the same ok-guard a 4th time. The transmit shape here (free functions, no capture, ok-guarded, plus the tClamp variant) is the right target: a small file-local FlexKvCarry.h (tStr/tInt/tClamp/tReal/tBool/tList) shared by the pan/slice/meter/transmit decoders would put the present-only + parse-guard semantics in one place, so a future tweak (base-0 parse, whitespace trim) can't be applied to one copy and silently skipped in the others.
| if (kvs.contains("compander")) { | ||
| bool v = kvs["compander"] == "1"; | ||
| // ── Core transmit ── | ||
| if (d.rfPower && m_rfPower != *d.rfPower) { m_rfPower = *d.rfPower; changed = true; } |
There was a problem hiding this comment.
[cleanup — low] applyChanges assignment boilerplate.
~50 repetitions of if (d.x && m_x != *d.x) { m_x = *d.x; flag = true; }. A tiny helper — template<class T> bool assign(const std::optional<T>& s, T& d){ if (s && d != *s){ d = *s; return true; } return false; } — collapses each to micChanged |= assign(d.micLevel, m_micLevel);, and each inline-emit field to if (assign(d.maxPowerLevel, m_maxPowerLevel)) { changed = true; emit maxPowerLevelChanged(m_maxPowerLevel); }. Roughly halves the function and names the emit-flag once per call site, removing the dominant defect mode in a 130-line wall (a wrong flag on a pasted line — e.g. micChanged where phoneChanged was meant, so a phone-panel control never repaints). The compander/dexp alias, ATU parse, and sampler logic stay bespoke.
… assign() helper in applyChanges - qRegisterMetaType<SliceDelta>() + <TransmitDelta>() in RadioModel's ctor so the typed seam signals survive a queued connection. Correct today (synchronous same-thread DirectConnection), but makes the seam thread-safe by construction if a backend is ever moved to a worker thread — Qt would otherwise silently drop the emit with "Cannot queue arguments of type …". (#4071 review, low.) - Collapse applyChanges's ~50 field-apply lines behind a small assign() helper (writes present-and-different, returns changed) so the emit-flag is named once per call site — removing the wrong-flag-on-a-pasted-line defect mode ten9876 flagged. compander/dexp aliasing, the ATU enum parse, and the APD sampler map stay bespoke. Behavior-identical: 69/69 tests green (transmit_model_test / transmit_model_apd_test / aetherd_transmit_decode_test pin the emit groups), boundary --strict green. Deferred to #4070 (typed-payload + shared-helper cleanup): the shared FlexKvCarry helper set (spans pan/slice/meter/transmit decoders) and the per-plane sub-struct question for TransmitDelta — both are seam-shape decisions best made holistically across all deltas in that PR, not partially here. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review response — pushed 01c633fThanks @rfoust (approval + FlexLib spot-check) and @ten9876. All four of @ten9876's findings dispositioned: Fixed now
Deferred to #4070 (the "typed seam payloads + shared decode helpers" follow-up — the right home since both are seam-shape decisions best made across all deltas at once, not partially here):
69/69 tests green, boundary |
There was a problem hiding this comment.
Review — TransmitModel 2.3 conversion
Thanks @ten9876 — this is the strongest of the five mixed-model splits, and it closes out the set cleanly. I checked out the branch and verified the refactor field-by-field against main. It holds up: behavior-neutral, well-tested, all 7 CI checks green.
What I verified
- Emit-group parity. Walked every field in the merged
applyChangesagainst the old fiveapply*Statusmethods. All groups map exactly:met_in_rx→stateChanged(not phone),compander/compander_levelset both mic+phone pairs, interlock stays emit-less,lo/hi→phoneStateChanged+txFilterCutoffChanged,max_power_level/tx_slice_modekeep their inline emits. Thedexp/noise_gate_levelalias precedence (companderwins, alias only when absent) is preserved — now folded intod.companderin the decode, applied through the same two-member/two-flag block. - Single-plane isolation. Each
decode*Statuspopulates only its plane's optionals, so an interlock/ATU/APD-only delta no-ops the other blocks and leaveschangedfalse — no spuriousstateChanged. This is what makes the shared struct safe at runtime today. - Old methods fully removed, no stale callers anywhere in
src/ortests/. m_flexBackendguard matches the established seam pattern from #4065/#4066/#4068 (set at ctor, nulled during teardown), so the routing change is consistent.- Fail-closed on malformed numerics (
freq,max_power_level, interlock ints) is intentional and consistent with the pan/slice/meter boundary — no well-formed input changes behavior. - Folded-in #4066 meter parse-guards (
num/low/hiok-guarded) are correct, and registeringtransmit_model_apd_testcloses a real pre-existing gap.
On your four inline notes
- qRegisterMetaType (RadioModel.cpp:561) and the
assignboilerplate helper — both are already implemented in this PR (ctor registersSliceDelta+TransmitDelta;assign<T>collapses the ~50 apply lines and names each emit-flag once). So those two notes read as resolved-in-branch rather than open. Good calls, already landed. - 60-field struct across 5 planes (TransmitDelta.h:22) — agreed this is the one real judgment call. Nothing structurally binds a field to its decoder, so a future core field mistakenly populated by
decodeInterlockStatuswould apply through the shared path with no compile error. It's a maintainability tradeoff, not a bug today (each decoder is disciplined and well-tested). Since this sets the final shape of the seam, sub-structs ({core; mic; cw; interlock; atu; apd; sampler;}) matching the decoders would be the moment to do it — but I'd treat it as an optional follow-up, not a merge blocker. - Carrier helper duplication (
tBool/tInt/tClamp/tReal) — valid low-priority DRY point; a sharedFlexKvCarry.hacross pan/slice/meter/transmit would prevent a future parse-semantics tweak landing in only one copy. Also fine as a follow-up.
Clean, careful work with genuinely useful self-review. LGTM from a correctness standpoint — the two structural items are the maintainers' call on whether to fold in now or defer.
🤖 aethersdr-agent · cost: $7.8142 · model: claude-opus-4-8
…y decode helpers (#4075) Closes #4070 — the seam-consistency follow-up from the RFC 2.3 mixed-model series. Behavior-neutral (the three decode tests pin the exact wire→output mapping). ## Typed meter payload `MeterDef` moves from `models/MeterModel.h` to **`core/backends/MeterDef.h`** (pure data), so `IRadioBackend` carries it directly: **`meterDefined(const MeterDef&)`** instead of the stringly-keyed `QVariantMap`. `FlexBackend::decodeMeterStatus` builds the `MeterDef`; `RadioModel` hands it straight to `defineMeter()` — the ~15-line key-string reconstruction is gone, and a field-name typo is now a **compile error**. `MeterModel.h` re-includes the core header so its existing users are unaffected. ## Shared decode carriers New **`core/backends/flex/FlexKvCarry.h`** single-sources the present-only + fail-closed parse contract (`carryStr/Bool/Int/Clamp/Real` for optional delta fields; `carryInto/IntInto/RealInto` for plain struct fields; `splitList`). The slice (`oStr/oInt/…`), transmit (`tBool/tInt/…`), and meter (inline guards) decoders now **all route through it** — resolving the "3rd/4th re-roll of the same guard" that ten9876 flagged across #4066/#4068/#4071. A future tweak (base-0 parse, whitespace trim) can no longer be applied to one copy and silently skipped in the others. `FlexBackend.cpp` net-shrinks ~90 lines. ## Deliberately NOT done (documented decisions) - **`panState`/`panWnb` stay on the generic `extensionStatus(ns, kind, QVariantMap)` channel** — that channel exists precisely to keep Flex-specific status *off* the vendor-neutral typed interface; typing them would invert that design. - **`TransmitDelta`/`SliceDelta` kept flat** (not per-plane sub-structs — ten9876's #4071 option): the flat structs are verified/tested, and the `assign()` helper + these shared carriers already address the maintainability concern; a sub-struct rewrite of two just-merged deltas isn't worth the churn. Revisitable if a 6th touchpoint ever copies the pattern. ## Gates - `qRegisterMetaType<MeterDef>()` added at startup alongside `Slice`/`TransmitDelta`. - `aetherd_meter_decode_test` asserts on the typed `MeterDef` (incl. the malformed-drop guard → default). - **69/69 tests green**, engine-boundary `--strict` green, full app builds clean. Refs #3849. Concludes the RFC 2.3 mixed-model split seam cleanup — the `IRadioBackend` seam now carries pan/meter/slice/transmit with compiler-checked typed payloads and one shared decode-helper set. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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
TransmitModelintoFlexBackend, behind a typed, compiler-checkedTransmitDeltapayload (the pattern established for slices in #4068). Behavior-neutral.What moved
FlexBackend::decodeTransmitStatus/decodeInterlockStatus/decodeAtuStatus/decodeApdStatus/decodeApdSamplerStatusown 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 (companderwins;dexp/noise_gate_levelalias only when the compander key is absent), and the samplerINTERNAL-prepend. Each emitsIRadioBackend::transmitChanged(const TransmitDelta&).TransmitModel: the fiveapply*Statusmethods → one mergedapplyChanges(const TransmitDelta&). Every emit group is preserved exactly —stateChanged/tuneChanged/micStateChanged/phoneStateChanged/txFilterCutoffChanged/ inlinemaxPowerLevelChanged+txSliceModeChanged/atuStateChanged/apdStateChanged/apdEqualizerResetReceived/apdSamplerChanged— along with the model-side business logic:compander→ bothcompanderOn/dexpOn(+ mic/phone flags), the ATU enum parse, and the stateful per-antenna APD-sampler map + selected-not-in-available→INTERNALfallback.RadioModel: the 6 transmit-family call sites route through the decode methods; a ctor-wiredtransmitChangedhandler drives the model (synchronous, main-thread).Behavior-neutral — verified
met_in_rx→changednot 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 theoptional<bool>presence-vs-value footgun — no regressions.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 garbledmax_power_levelwould have emittedmaxPowerLevelChanged(0)). No well-formed input changes behavior. Consistent with the slice/pan standard.Also in this PR
num/low/hiok-guarded — a live fail-closed gap onmain) + a malformed-value meter test.transmit_model_apd_test— it was built but never run (pre-existing CMake omission).Tests / gates
transmit_model_test+transmit_model_apd_testconverted to typed deltas; newaetherd_transmit_decode_testpins the wire→delta mapping (clamps, ok-guard-drop, compander/dexp aliasing, ATU raw token, APD bare-flag, samplerINTERNAL-prepend + empty-tx_antno-emit).--strictgreen, 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