feat(aetherd): 2.3 — complete SliceModel conversion (full canonical slice-status decode behind the seam)#4068
Conversation
…lice-status decode behind the seam) The largest of the five mixed-model splits: ~70 Flex slice-status fields move out of SliceModel into FlexBackend, with a full vendor-neutral rename of the seam contract. Behavior-neutral (verified field-by-field + programmatic key-symmetry + adversarial review). - FlexBackend::decodeSliceStatus(sliceId, kvs): owns ALL the SmartSDR wire knowledge — key names (RF_frequency, filter_lo/hi, lock, audio_level, …), "1"→bool, esc "1"/"on", comma-split + trim antenna/mode lists (rx_ant_list precedence), fm_tone_mode/repeater_offset_dir lowercase — and emits the normalized, canonically-named, typed change map via IRadioBackend::sliceChanged. - SliceModel::applyStatus → applyChanges(const QVariantMap&): reads only canonical keys; keeps every piece of model business logic unchanged — filter-polarity normalization, the audio_mute / speex_nr_level / rtty_mark override re-pushes, diversity change-detection, fmToneValue formatting, per-field emit gates. - RadioModel::handleSliceStatus routes both 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 moved from SliceModel into the backend decode. - 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). Verification: backend inserts 69 canonical keys, model reads 69 — set-diff empty both ways (no silent field death). 66/66 tests green; boundary --strict green; full app builds clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Reviewed the full diff plus the PR-head sources (not just the truncated diff). This is a clean, genuinely behavior-neutral refactor — nice work, @ten9876. The mechanical wire→canonical split is executed carefully and the model's business logic is preserved intact.
What I verified holds
- Key symmetry is real. Cross-checked every canonical key
decodeSliceStatusinserts against every keyapplyChangesreads — the two sets match exactly, includinginUse(emitted present-only, consumed only in theaudio_mute-absent resync branch). No silent field death in either direction. - The subtle business logic survived the move verbatim: the
rtty_mark/speex_nr_level"default-recall → re-push user override" workarounds, theplay3-state (disabled/1/0), the filter one-edge-present "keep current for the absent edge" fallback,rx_ant_listprecedence overant_list, and thefmToneValue'f',1formatting. - Signal wiring is sound.
sliceChangedis declared once onIRadioBackend, emitted once, connected once. SinceFlexBackendis constructed on and never moved off RadioModel's thread, the AutoConnection resolves to a DirectConnection — so the append path'sdecodeSliceStatus()→sliceChanged→applyChanges()runs synchronously beforeemit sliceAdded(s), preserving the populate-before-notify ordering. The handler'sif (SliceModel* s = slice(id))guard also makes a spurious emit harmless. - The
if (m_flexBackend)gate is not a regression —m_flexBackendis set unconditionally in the ctor and only nulled during teardown (fail-closed), matching the established #4065 idiom. - CI is green (7/7), and
aetherd_slice_decode_testis a realQSignalSpy-based pin, including a "no wire key leaks through" assertion.
Minor
-
Stale comment at
RadioModel.cpp:5841:return; // applyStatus already called below; skip second call.applyStatusno longer exists, and the decode now happens on the line just above the return (not below). Worth updating to referencedecodeSliceStatusfor the next reader. -
Merge conflict with
main(mergeable_state: dirty). The 2.3 series has advancedRadioModel.cpp/SliceModel.cppunder you (#4065), so this needs a rebase before it can land — agit merge-treeshows conflicts in the touched files. Not a code issue, just a heads-up.
Neither blocks the approach. Thanks for the thorough behavior-neutrality write-up and the field-by-field discipline — it made verification straightforward.
🤖 aethersdr-agent · cost: $6.3732 · model: claude-opus-4-8
2d3b9a1 to
d45eac2
Compare
There was a problem hiding this comment.
Reviewed the full decode↔model seam, not just the summary. This is a faithful, behavior-neutral split — nice work, and thanks for the meticulous field-by-field bookkeeping.
What I verified independently:
- Key coverage & naming. Every wire key the old
applyStatusconsumed is emitted byFlexBackend::decodeSliceStatus, and every canonical name lines up with the correspondingapplyChangesread (walked both halves of the diff, incl. the DSP toggle/level block, AGC, squelch/rit/xit, DAX, RTTY, FM, and step). No name drift in either direction. - Emit-gate fidelity. The subtle part, and it holds: unconditional emits stay unconditional (nb/nr/anf/qsk/lock/agc/antenna), change-gated stay gated (apf, all
*Level, dax, rtty, digl/digu), and the combined blocks (squelch/squelchLevel,rit,xit) keep their either-present gate. Theaudio_mute-absent →inUseresync else-branch and the 3-stateplay(disabled/1/other, carried raw) are both preserved verbatim. - Business logic stays model-side as claimed: mode-dependent filter-polarity normalization, the
speex_nr_level/rtty_markdefault override re-pushes (still viasendCommand), the diversity change-detection block, andfmToneValueformatting. - Routing & ordering.
if (m_flexBackend)matches the established seam pattern (panState/meterStatus already guard this way); it's non-null outside teardown, so the guard replacing the old unconditionalapplyStatusisn't a regression. The append path appends tom_slicesbeforedecodeSliceStatus, and since FlexBackend lives on the main thread thesliceChangedAutoConnectionresolves toDirectConnection→ the slice is populated synchronously beforesliceAdded. Ordering claim confirmed.
All 7 CI checks are green (build/macos/windows/CodeQL/a11y/dependency-direction), and the new aetherd_slice_decode_test pins the wire→canonical mapping.
Non-blocking:
- GitHub reports this as
mergeable_state: dirty— it'll need a rebase onmainnow that #4065/#4066 have landed. - Same transitional note as #4063 applies: routing through
m_flexBackendmeans a hypothetical non-Flex backend would silently drop slice status until slice decode is fully backend-owned. Fine while Flex is the only backend; just flagging it stays on the 2.x burndown.
No changes requested from me — LGTM pending the rebase.
🤖 aethersdr-agent · cost: $5.7600 · 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: clean, faithful, well-tested — no functional regression. The largest 2.3 touchpoint: ~70 Flex slice-status keys move out of SliceModel::applyStatus into FlexBackend::decodeSliceStatus, which translates wire→canonical typed keys and emits sliceChanged; SliceModel::applyChanges now consumes only vendor-neutral keys. Two independent key audits confirmed every wire key round-trips 1:1 — no dropped/misnamed/orphan key. The == "1"→bool semantics stay backend-side (not .toBool() on raw strings), esc keeps 1/on, and the filter one-edge default, fm_tone_value formatting, play/step_list raw carry, plus all preserved business logic (rtty_mark re-push, speex_nr override, filter-polarity normalization, audio override re-pushes, audio_mute in_use resync) are intact. The rename layer genuinely buys vendor-neutrality — applyChanges contains no Flex wire string. Good coverage from the new decode test + converted model tests.
Five findings inline, all low/medium — none blocking. Noted-not-flagged: the rx_ant_list branch is a guessed spelling (FlexLib Slice.cs only has ant_list) but pre-existing, not PR-introduced; the wire→map→apply round-trip roughly doubles map probes + a QVariantMap alloc per status, mitigated by present-only (most updates are 1–3 keys); m_flexBackend teardown gate is intended fail-closed.
| const auto integer = [&](const char* wire, const char* canon) { | ||
| if (kvs.contains(QLatin1String(wire))) | ||
| c.insert(QLatin1String(canon), kvs.value(QLatin1String(wire)).toInt()); | ||
| }; | ||
| const auto real = [&](const char* wire, const char* canon) { | ||
| if (kvs.contains(QLatin1String(wire))) | ||
| c.insert(QLatin1String(canon), kvs.value(QLatin1String(wire)).toDouble()); | ||
| }; |
There was a problem hiding this comment.
[fail-closed — the stakes are higher here] Boundary numeric parses are unguarded.
integer()/real() use bare .toInt()/.toDouble(), so a malformed present field → 0/0.0, silently applied. Behavior-preserving (old applyStatus was equally unguarded), but this is now the Flex slice boundary decoder, and unlike the pan/meter cases the fields are tuning-relevant: a garbled RF_frequency retunes the slice to 0 Hz, and filter edges/offsets follow. FlexLib fails closed on exactly these (Slice.cs uses TryParseDouble/int.TryParse + continue, keeping the prior value). Same call I flagged on #4065 (decodePanRange/RfGain) and #4066 (meter low/hi/num) — closing it in the two carriers fixes every numeric field at once:
| const auto integer = [&](const char* wire, const char* canon) { | |
| if (kvs.contains(QLatin1String(wire))) | |
| c.insert(QLatin1String(canon), kvs.value(QLatin1String(wire)).toInt()); | |
| }; | |
| const auto real = [&](const char* wire, const char* canon) { | |
| if (kvs.contains(QLatin1String(wire))) | |
| c.insert(QLatin1String(canon), kvs.value(QLatin1String(wire)).toDouble()); | |
| }; | |
| const auto integer = [&](const char* wire, const char* canon) { | |
| if (kvs.contains(QLatin1String(wire))) { | |
| bool ok = false; | |
| const int v = kvs.value(QLatin1String(wire)).toInt(&ok); | |
| if (ok) c.insert(QLatin1String(canon), v); | |
| } | |
| }; | |
| const auto real = [&](const char* wire, const char* canon) { | |
| if (kvs.contains(QLatin1String(wire))) { | |
| bool ok = false; | |
| const double v = kvs.value(QLatin1String(wire)).toDouble(&ok); | |
| if (ok) c.insert(QLatin1String(canon), v); | |
| } | |
| }; |
Low real-world probability (radio emits well-formed), reachable over SmartLink/WAN.
| // key names, "1"→bool, comma-split lists, lowercase normalization — so | ||
| // SliceModel::applyChanges speaks only vendor-neutral keys. Present-only: | ||
| // each canonical key rides only when its wire key was reported. | ||
| QVariantMap c; |
There was a problem hiding this comment.
[altitude / simplification — the one strategic item] Untyped 70-field QVariantMap is the #4066 fragility at 10×.
The canonical schema now lives in two hand-maintained string tables — the str/integer/real/boolean calls here and the ~70 c.contains(QStringLiteral(...)) reads in applyChanges — with the compiler blind to drift between them. A typo ("filterlow" on emit vs "filterLow" on read) compiles, passes the emit-only decode test, and silently drops that field forever (stale UI, no error). All 70 currently match, but this is exactly the smell reviewers raised on #4066's MeterDef, now far more load-bearing.
A typed SliceDelta { std::optional<double> frequency; std::optional<int> filterLow; … } single-sources the schema and makes the seam compiler-checked — and keeps the vendor-neutrality win (the rename layer is good; it's the payload type that doesn't survive at this field count). The generic sliceChanged(int, QVariantMap) signal cardinality is fine — slice state is one cohesive object. Recommend for this PR or a fast-follow: the natural moment to fix the compounding cost of the series is before a 5th touchpoint copies the pattern.
| if (kvs.contains(QLatin1String(wire))) | ||
| c.insert(QLatin1String(canon), kvs.value(QLatin1String(wire)) == QLatin1String("1")); | ||
| }; | ||
| const auto splitList = [](const QString& raw) { |
There was a problem hiding this comment.
[cleanup — low] Duplicated split/carry helpers.
splitList is a verbatim copy of the splitAntennaList this PR deletes from SliceModel.cpp, and the str/integer/real/boolean present-only carriers re-implement the carry pattern already in decodePanState/decodePanExtensions. A shared typed carry-helper set would centralize the present-only contract and the ok-guard from the numeric-parse comment above in one place. (Minor: the inline mode_list split doesn't trim while splitList does — pre-existing asymmetry, old code didn't trim mode_list either, so not a regression.)
| // it to the addressed slice. Driven synchronously from handleSliceStatus's | ||
| // decodeSliceStatus() calls (main-thread DirectConnection), so a slice just | ||
| // appended to m_slices is populated before the sliceAdded UI notify. | ||
| connect(m_backend.get(), &IRadioBackend::sliceChanged, this, |
There was a problem hiding this comment.
[latent — low] connect() is AutoConnection, but the comment two lines up says "DirectConnection".
Synchronous today only because FlexBackend shares RadioModel's thread. But FlexBackend already moves its sub-objects (m_connection/m_panStream) to worker threads — if it's ever itself moved to a worker thread, this AutoConnection silently degrades to QueuedConnection: emit sliceChanged returns before applyChanges runs, so the "slice populated before sliceAdded UI notify" guarantee (just below) breaks and the UI briefly sees a slice with default frequency/mode. No compile error, no test failure (the decode test never exercises the connect). Either pin Qt::DirectConnection explicitly to make the invariant load-bearing, or soften the comment to match the actual AutoConnection.
| @@ -5853,7 +5868,9 @@ void RadioModel::handleSliceStatus(int id, | |||
| return; // applyStatus already called below; skip second call | |||
There was a problem hiding this comment.
[doc nit — trivial] Stale comment. applyStatus already called below; skip second call — SliceModel::applyStatus no longer exists after this PR; the call this return guards against is now m_flexBackend->decodeSliceStatus(id, kvs) on the fall-through. Reword to avoid pointing a future reader at a removed method.
|
Took a red-team pass at the behavior-neutrality claim — decoded-vs-original
One thing that's out of scope for this PR but worth a mental note for the FlexBackend lifecycle: both routed call sites are now Nothing blocking from me. (It's showing conflicting against main — presumably just the sibling 2.3 touchpoints landing around it.) |
…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>
|
Thanks for the detailed split. I walked the merge ref against current Blocking
Please make the numeric carriers fail closed with Non-blocking The 69-field
There are also stale Local checks I ran
All passed locally. GitHub CI is now green, but the fail-closed parse issue should be fixed before merge. |
Review response — pushed 052c465Thanks @ten9876 (and @aethersdr-agent for the two clean passes). All findings resolved, including the strategic one: #2 — typed payload (done). The slice payload is now a compiler-checked #1 — parse guards (done). #4 — AutoConnection comment (done). Reworded to describe the actual #5 — stale comment (done). "applyStatus already called below" → now references #3 — duplicated carry helpers: deferred to a dedicated cleanup that unifies the carry/split helpers across all the 67/67 tests green, boundary |
There was a problem hiding this comment.
Reviewed the head commit (052c465), which already folds in your two self-flagged items — the typed SliceDelta payload and the ok-guarded numeric parses. This is a clean, careful split. Thanks for the thoroughness, @ten9876.
Verification
Seam correctness — the typed struct closes the real risk. Adopting SliceDelta (instead of a 70-key QVariantMap) means a decode/apply name drift is now a compile error rather than a silently-dropped field. That was the one structural concern worth blocking on, and it's resolved. SliceDelta.h living in core/backends/ with only Qt deps keeps IRadioBackend free of model dependencies — and the Engine/UI dependency-direction CI check passed, confirming SliceModel.h including the backend header doesn't invert the layering.
Behavior-neutrality — spot-checked the tricky spots, all preserved:
- Per-field emit gating (unconditional-when-present vs change-gated) matches old code field-by-field — e.g.
rxant/agc_mode/fm_*still emit unconditionally on presence;apf/dax/rtty_markstill change-gated. - The
audio_mute-absent →in_use==1resync branch:d.inUse.value_or(false)is exactly equivalent to the oldkvs.value("in_use") == "1"(engaged-and-true iff present-and-"1"). - Filter one-edge-present fallback, the
speex_nr_level/rtty_markoverride re-pushes, diversity change-detection block, andfm_tone_value→ 1-decimal formatting all stay model-side, unchanged. esc"1"/"on"→bool andrx_ant_listprecedence overant_listcorrectly moved wire-side.
The ok-guard is a deliberate (good) behavior change, not neutral — a malformed present RF_frequency now keeps the prior value instead of retuning to 0 Hz. Correctly matches FlexLib's TryParse+continue, and your comment documents it. Reachable over SmartLink, so worth having. 👍
Routing/ordering: m_flexBackend is the same object as m_backend (the transitional alias set at construction), so the emit sliceChanged reaches the DirectConnection handler synchronously — the freshly-appended slice is populated before the sliceAdded notify, as claimed. The if (m_flexBackend) guard matches the merged pan/meter precedent (#4065/#4066). Confirmed no stale applyStatus callers remain for SliceModel.
New aetherd_slice_decode_test is substantive (real QSignalSpy on the signal, qRegisterMetaType present) and pins the wire→typed mapping.
Minor (non-blocking)
- Your own comment-3 still partly stands:
splitListin the decode is a verbatim copy of the deletedsplitAntennaList, andoStr/oInt/oReal/oBoolre-implement the present-only carry already indecodePanState/decodePanExtensions. A shared typed carry-helper set (folding in the ok-guard) would centralize the present-only contract across all three decoders. Fine as a fast-follow — no need to expand this PR. - Pre-existing asymmetry you already noted: inline
mode_listsplit doesn't trim whilesplitListdoes. Matches old behavior, so not a regression.
Nothing here blocks. Waiting on build/macos/windows/CodeQL (still in progress) to go green. Nice work on the largest of the five splits.
🤖 aethersdr-agent · cost: $3.4552 · model: claude-opus-4-8
…stale applyStatus comments Closes rfoust's remaining #4068 items (their review raced the 052c465 push that already landed the integer/real guards, typed SliceDelta, AutoConnection comment, and the RadioModel stale comment). - SliceModel::applyChanges: the per-token step_list parse now fails closed — a malformed step token is skipped, not admitted to the tuning-step list as a bogus 0-Hz step. (Was the one remaining bare toInt() on the slice boundary.) - Reword the last stale applyStatus() comment references in SliceModel.{h,cpp} to applyChanges(). - Test: slice_model_letter_test pins the malformed-step-token drop. 67/67 tests green; boundary --strict green; full app builds clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Re: @rfoust — all items closed (06a4c12)Thanks for the thorough merge-ref review. Your pass landed ~1 min after the Blocking — parse guards: resolved in Your additional Typed AutoConnection comment: reworded (not pinning Stale 67/67 tests green, boundary |
…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>
…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 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
SliceModelintoFlexBackend, 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 viaIRadioBackend::sliceChanged: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 (withrx_ant_listprecedence overant_list);fm_tone_mode/repeater_offset_dirlowercase.SliceModel::applyStatus→applyChanges(const QVariantMap&)reads only canonical keys; every piece of model business logic is unchanged — the mode-dependent filter-polarity flip, theaudio_mute/speex_nr_level/rtty_markoverride re-pushes, the diversity change-detection block,fmToneValueformatting, and each field's exact emit gate (unconditional-when-present vs change-gated).RadioModel::handleSliceStatusroutes bothapplyStatuscall sites throughdecodeSliceStatus; a ctor-wiredsliceChangedhandler applies toslice(id).FlexBackendis main-thread → synchronousDirectConnection, so a freshly-appended slice is populated before thesliceAddedUI notify (ordering preserved).splitAntennaListrelocated fromSliceModelinto the backend decode.Behavior-neutral — verified three ways
float↔doublegain fields,"1"→bool + theesc/play/in_usespecial cases, present-only + combined-block "keep current when absent", all side-effects, per-field emit conditionality, RadioModel routing + synchronous ordering) — no regressions.slice_model_letter_test+antenna_alias_testconverted to canonicalapplyChanges; newaetherd_slice_decode_testpins the wire→canonical mapping (renames,"1"/"on"→bool, list split,rx_ant_listprecedence, lowercase, rawplay/step_list, present-only).Gates
--strictgreen, full app builds clean.Notes
client_handleslice 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