Skip to content

feat(aetherd): 2.3 — complete MeterModel conversion (meter-status decode behind the seam)#4066

Merged
ten9876 merged 1 commit into
mainfrom
aetherd-step2.3-meter
Jul 6, 2026
Merged

feat(aetherd): 2.3 — complete MeterModel conversion (meter-status decode behind the seam)#4066
ten9876 merged 1 commit into
mainfrom
aetherd-step2.3-meter

Conversation

@ten9876

@ten9876 ten9876 commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

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

…ode behind the seam)

Moves the SmartSDR meter-status wire decode out of RadioModel into FlexBackend,
completing the MeterModel touchpoint. MeterModel already held only core state
(the MeterDef catalog + smoothed values the UI renders) — the Flex decode was
RadioModel::handleMeterStatus — so this is the last Flex meter wire-decode to
move behind the seam. Behavior-neutral.

- IRadioBackend: meterDefined(int index, QVariantMap fields) + meterRemoved(int).
- FlexBackend::decodeMeterStatus parses the "N.key=value#…" definition body and
  the "N removed" line (verbatim from the old handleMeterStatus, incl. the
  num base-0 parse and present-only field extraction), emitting the normalized
  signals. QMap grouping preserves ascending-index emit order.
- RadioModel: handleMeterStatus is now a thin forwarder to decodeMeterStatus
  (driven from the status choke point → live + deferred/replayed covered); the
  ctor-wired handlers reconstruct MeterDef present-only and drive defineMeter/
  removeMeter exactly as before (defineMeter's SLC/LEVEL catalog caching + the
  m_manifestSliceContext side-effect run identically). FlexBackend is main-
  thread, so the emit→apply chain is a synchronous DirectConnection.
- Meter *values* remain on the VITA-49 data plane (step 5), untouched.
- Test: aetherd_meter_decode_test pins definition/removal/multi-meter/malformed.

66/66 tests green; boundary --strict green; full app builds clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@ten9876 ten9876 requested review from a team as code owners July 6, 2026 01:23

@ten9876 ten9876 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review — high-effort recall pass (8 finder angles → verify)

Verdict: clean, faithful move-refactor — no functional regression. The meter-status wire decode relocates verbatim from RadioModel::handleMeterStatus into FlexBackend::decodeMeterStatus, emitting meterDefined/meterRemoved → ctor handlers → MeterModel. Independent finders confirmed every field mapping, guard, grouping, and ordering is preserved byte-for-byte; the QVariant round-trip is type-lossless; the connect signatures match; there's no bypassed second parse path; and the new decode test pins it. Smallest and cleanest of the 2.3 touchpoints.

Two things noted but NOT flagged: the m_flexBackend teardown gate is intended fail-closed (and here the dtor disconnects m_connection/m_panStream/m_wanConn before nulling the alias — cleaner than the #4065 waterfall path); MeterDef full-replace on partial status is pre-existing (Flex sends full definitions).

Three findings inline, all low/medium — none blocking.

Comment thread src/core/backends/flex/FlexBackend.cpp
Comment thread src/core/backends/flex/FlexBackend.cpp
Comment thread src/core/backends/flex/FlexBackend.cpp
@rfoust rfoust self-assigned this Jul 6, 2026

@rfoust rfoust left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed PR #4066 at the merge ref. No blocking findings.

The meter-status decode move from RadioModel::handleMeterStatus() into FlexBackend::decodeMeterStatus() looks behavior-equivalent: definitions/removals still flow back through the ctor-wired meterDefined / meterRemoved handlers into MeterModel, and meter values remain on the VITA-49 data path.

Validated locally:

  • git diff --check
  • CMake configure
  • aetherd_meter_decode_test
  • adjacent aetherd_pan_decode_test
  • direct meter_model_test
  • tools/check_engine_boundary.py --strict
  • full AetherSDR target build

@aethersdr-agent aethersdr-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the full diff against the pre-move handleMeterStatus. The move is behavior-neutral as claimed, and the mechanics line up with the sibling PanadapterModel touchpoint (#4065). Thanks @ten9876 — this is clean.

Verified

  • Field-by-field parity with the old inline parse: src→source, num→sourceIndex (base-0 toInt(nullptr,0)), nam→name, unit, low/hi→double, desc→description, all present-only; index set from it.key() on emit → def.index = index on receive. The removal branch is equivalent (!words.isEmpty() == the old words.size() >= 1; contains("removed") preserved verbatim, including its pre-existing quirk of matching "removed" anywhere in the body — unchanged, so not a regression).
  • Single choke point, no dual parse: handleMeterStatus has exactly one caller (processStatusbody.mid(6)), and it's the sole meter parser in the tree.
  • Ordering preserved: meterDefined is same-thread (AutoConnection ⇒ DirectConnection), so defineMeter runs inline in ascending-index QMap order before decodeMeterStatus returns — definition/choke ordering is intact.
  • m_flexBackend guard mirrors the sibling handlers (handlePanStatus, slice mode) and is the same teardown-null-safety pattern already in use — consistent, not a new behavior gate.
  • CMake test wiring mirrors aetherd_pan_decode_test exactly.

On the two Copilot notes:

  1. Untyped QVariantMap vs a typed meterDefined(int, const MeterDef&) — I'd lean toward keeping the map here, and I don't think the panState-vs-typed distinction is the deciding factor. The seam's established convention is a stringly-keyed QVariantMap (sliceChanged(int, QVariantMap), panState), and IRadioBackend.h deliberately pulls in no model headers — MeterDef lives in models/MeterModel.h. A typed signal would couple the backend-agnostic interface down to a model struct, inverting the layering this RFC is establishing. So the untyped hop is consistent, not an oversight.

    That said, the actionable nugget in that comment is real: the receive half is untested. The new test pins only the emit side; a key-string typo (sourceIndex on emit vs a mismatched key on receive) would silently drop a field to its default with no build error and no test failure. Cheap to close with a small round-trip assertion (decode → RadioModel reconstruction → MeterDef fields), or at minimum a comment pinning the two key lists as a matched pair. Non-blocking, but worth it given the two-places coupling.

  2. Unguarded num/low/hi parses — accurate that they're unguarded, but this is verbatim-preserved from the old code and behavior-neutral. The Principle VII "you're at the boundary now, align with the guarded siblings / FlexLib's TryParse+continue" argument is reasonable, but it's the same optional call as on #4065's decodePanRange — author's discretion, not a blocker for a behavior-preserving move.

Nothing blocking here. The one thing I'd genuinely encourage is a receive-side test to lock the key contract; the rest is fine to merge as-is.


🤖 aethersdr-agent · cost: $5.2247 · model: claude-opus-4-8

@NF0T NF0T left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Independent red-team pass

Traced this against the actual source (not just the diff) rather than re-asserting the sibling PR's reasoning. Smaller, cleaner move than #4065 — one real finding, plus a couple of things worth confirming rather than assuming.

What checked out

  • Field-by-field verbatim. Compared old handleMeterStatus to the new decodeMeterStatus + ctor-handler split line by line: source/name/unit/description are straight string carries, the meter-index grouping key keeps its ok-guard on both sides, and QMap's ascending-key iteration (confirmed Qt semantics, not assumed) preserves definition emit order exactly as before.
  • Dtor safety already inherited correctly. This branch was cut from main after #4065 merged, so ~RadioModel() already has the m_flexBackend-nulled-before-m_backend.reset() + m_wanConn-disconnect ordering, and handleMeterStatus's new if (m_flexBackend) guard sits on the safe side of it. (I initially flagged a cross-PR sequencing risk here against an earlier commit of this review — retracted after re-checking against the actual squash-merge commit rather than #4065's pre-squash branch tip; no action needed, just flagging that I checked rather than assumed.)
  • CI is now fully green — all 7 checks, including check-windows and CodeQL, which were still in flight at PR-open time.

One finding: parse-guard asymmetry with the sibling touchpoint

decodeMeterStatus's sourceIndex (num field) and low/high (hi) fields parse via unguarded .toInt(nullptr, 0) / .toDouble() — a malformed present value silently becomes 0 / 0.0 rather than being dropped. This isn't a new regression (the old inline handleMeterStatus had the identical unguarded calls), but it's the same shape of gap #4065's fix commit closed for min_dbm/rfgain on the reasoning that "this decode is now the formal validation boundary" (Principle VII) — same author, same migration series, ~35 minutes earlier. A malformed low/hi here has the same failure mode as the min_dbm bug just fixed: a meter's displayed scale silently collapsing to 0 instead of the field being dropped as unchanged/undefined. Suggest carrying the same ok-guard pattern here for consistency with the standard this series has now set for itself — cheap, and it's the one path in this PR that's actually a wire-format validation boundary.

CODEOWNERS gate — not blocking on content, but not clearable by us or @rfoust's approval alone

This diff touches CMakeLists.txt (new test target registration), which CODEOWNERS gates Tier 2 to @aethersdr/infrastructure (@ten9876, @jensenpat). @rfoust's approval satisfies the Tier-3 default for the rest of the diff, but doesn't cover that gate, and @ten9876 can't self-approve — hence reviewDecision sitting at REVIEW_REQUIRED despite an approval on file. Same shape as #4065's Tier-1 situation, one tier down, so it has an actual path this time: an approval from @jensenpat clears it cleanly, or a maintainer merge-override (the path #4065 took) works if the CMakeLists.txt addition is judged trivial enough.


Net: this is the cleanest of the 2.3 touchpoints so far — verbatim relocation, correct dtor inheritance, tests that actually pin the behavior. The one thing worth fixing before merge is the low/hi/num guard, purely for consistency with the bar this series already set for itself.

@NF0T NF0T self-assigned this Jul 6, 2026
@ten9876 ten9876 merged commit cbcaa2c into main Jul 6, 2026
7 checks passed
@ten9876 ten9876 deleted the aetherd-step2.3-meter branch July 6, 2026 02:37
ten9876 added a commit that referenced this pull request Jul 6, 2026
…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>
ten9876 added a commit that referenced this pull request Jul 6, 2026
…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>
ten9876 added a commit that referenced this pull request Jul 6, 2026
…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>
ten9876 added a commit that referenced this pull request Jul 6, 2026
…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>
ten9876 added a commit that referenced this pull request Jul 6, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants