Skip to content

feat(aetherd): 2.3 — complete TransmitModel conversion (typed TransmitDelta, last mixed model)#4071

Merged
ten9876 merged 2 commits into
mainfrom
aetherd-step2.3-transmit
Jul 6, 2026
Merged

feat(aetherd): 2.3 — complete TransmitModel conversion (typed TransmitDelta, last mixed model)#4071
ten9876 merged 2 commits into
mainfrom
aetherd-step2.3-transmit

Conversation

@ten9876

@ten9876 ten9876 commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

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_rxchanged 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.0freq, 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

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

@ten9876 ten9876 requested review from a team as code owners July 6, 2026 04:12
…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 ten9876 force-pushed the aetherd-step2.3-transmit branch from 805ce35 to e673f9f Compare July 6, 2026 04:21
@rfoust rfoust self-assigned this Jul 6, 2026
rfoust
rfoust previously approved these changes 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 #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, and aetherd_meter_decode_test.
  • Ran tools/check_engine_boundary.py --strict; only tracked legacy warnings.
  • git diff --check was clean.
  • Spot-checked FlexLib v4.2.18.41174 under ~/Documents/source/FlexLib_API_v4.2.18.41174/FlexLib for 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 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, 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 completetInt/tClamp/tReal all ok-guard, including the inline noise_gate_level alias; 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-QVariantMap smell from #4066/#4068; matches the SliceDelta already in place.
  • CI gaintransmit_model_apd_test was built but never run (no add_test); now registered.
  • ATU-as-raw-string is the layering-correct boundary (the ATUStatus enum 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 {

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.

[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.)

Comment thread src/models/RadioModel.cpp
// 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,

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.

[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,

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.

[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.

Comment thread src/models/TransmitModel.cpp Outdated
if (kvs.contains("compander")) {
bool v = kvs["compander"] == "1";
// ── Core transmit ──
if (d.rfPower && m_rfPower != *d.rfPower) { m_rfPower = *d.rfPower; changed = true; }

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.

[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>
@ten9876

ten9876 commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Review response — pushed 01c633f

Thanks @rfoust (approval + FlexLib spot-check) and @ten9876. All four of @ten9876's findings dispositioned:

Fixed now

  • Metatype registration (latent, low): qRegisterMetaType<SliceDelta>() + <TransmitDelta>() in RadioModel's ctor. Correct today (synchronous same-thread → DirectConnection), but now the seam is thread-safe by construction — no silent "Cannot queue arguments of type …" drop if a backend ever moves to a worker thread.
  • applyChanges boilerplate: collapsed the ~50 field-apply lines behind a small assign() helper that names the emit-flag exactly once per call site — removing the wrong-flag-on-a-pasted-line defect mode you flagged. compander/dexp aliasing, the ATU enum parse, and the APD sampler map stay bespoke. Behavior-identical (69/69 green; transmit_model_test/transmit_model_apd_test/aetherd_transmit_decode_test pin the emit groups).

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 --strict green, full app builds clean.

@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.

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 applyChanges against the old five apply*Status methods. All groups map exactly: met_in_rxstateChanged (not phone), compander/compander_level set both mic+phone pairs, interlock stays emit-less, lo/hiphoneStateChanged+txFilterCutoffChanged, max_power_level/tx_slice_mode keep their inline emits. The dexp/noise_gate_level alias precedence (compander wins, alias only when absent) is preserved — now folded into d.compander in the decode, applied through the same two-member/two-flag block.
  • Single-plane isolation. Each decode*Status populates only its plane's optionals, so an interlock/ATU/APD-only delta no-ops the other blocks and leaves changed false — no spurious stateChanged. This is what makes the shared struct safe at runtime today.
  • Old methods fully removed, no stale callers anywhere in src/ or tests/.
  • m_flexBackend guard 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/hi ok-guarded) are correct, and registering transmit_model_apd_test closes a real pre-existing gap.

On your four inline notes

  • qRegisterMetaType (RadioModel.cpp:561) and the assign boilerplate helper — both are already implemented in this PR (ctor registers SliceDelta+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 decodeInterlockStatus would 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 shared FlexKvCarry.h across 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

@ten9876 ten9876 merged commit e9329f3 into main Jul 6, 2026
7 checks passed
@ten9876 ten9876 deleted the aetherd-step2.3-transmit branch July 6, 2026 12:52
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.

2 participants