refactor(aetherd): #4070 — typed MeterDef payload + shared FlexKvCarry decode helpers#4075
Conversation
…y decode helpers Closes the seam-consistency follow-up from the 2.3 series (#4070). 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 can carry 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 (ten9876's #4066/#4068/#4071 cleanup): - 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 — a future tweak (base-0 parse, whitespace trim) can't be applied to one copy and skipped in the others. 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. - qRegisterMetaType<MeterDef>() added alongside Slice/TransmitDelta at startup. - aetherd_meter_decode_test asserts on the typed MeterDef (incl. the malformed-drop guard → default). 69/69 tests green; boundary --strict green; full app builds clean. Refs #3849. Concludes the RFC 2.3 mixed-model split seam cleanup. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ten9876
left a comment
There was a problem hiding this comment.
Code review — high-effort recall pass (8 finder angles → verify)
Verdict: clean, behavior-preserving refactor that lands three earlier review recommendations. meterDefined becomes a typed MeterDef signal (no more stringly-keyed QVariantMap round-trip), the per-decoder carrier lambdas/helpers (oStr/oInt/…, tBool/tInt/…) collapse into one shared FlexKvCarry.h, MeterDef moves to the core layer, and the metatypes are registered at startup. Independent audits confirmed byte-for-byte carrier parity (every guard/clamp/base preserved), the typed MeterDef build is present-or-default identical to the old path, all meterDefined consumers migrated, the MeterDef move resolves cleanly (models→core layering already established), no double-metatype/ODR, and engine-boundary passes. This resolves the #4066 (typed MeterDef), #4071-#3 (shared helpers), and #4071-#2 (qRegisterMetaType at startup) findings.
Four findings inline, all low — quality/test/doc, no runtime bug.
One accuracy note (not inline — the code comment at FlexBackend.cpp:16 correctly scopes the helpers to "slice/transmit/meter"): the PR title/description says the helpers unify pan/slice/meter/transmit, but decodePanState still uses its own carry lambda and did not converge (it carries raw strings into a QVariantMap keyed by the wire key — a map-target shape flexkv doesn't offer). Either add a map-target flexkv::carryRaw and route pan through it, or scope the claim to slice/meter/transmit so the unification boundary is honest — pan's present-only guard remains a 4th independent copy.
| CHECK(f.contains(QStringLiteral("high"))); // valid field still carried | ||
| CHECK(d.name == QStringLiteral("LEVEL")); | ||
| CHECK(qFuzzyCompare(d.high, 20.0)); // valid field carried | ||
| CHECK(d.low == 0.0); // malformed low dropped → default 0.0 |
There was a problem hiding this comment.
[test-coverage — low] This no longer verifies the ok-guard it names.
The old test asserted !f.contains("low") / !f.contains("sourceIndex") — a guard-dropped field was literally absent from the QVariantMap, so removing the if (ok) would flip contains() to true and fail the test. The rewrite asserts d.low == 0.0 and d.sourceIndex == 0, but MeterDef's defaults are exactly 0.0/0, and an unguarded "junk".toDouble() / "nope".toInt() also yields 0.0/0 — so the assertion holds whether or not the guard exists. This is the only test covering the meter guard (grep-confirmed), so it's now unpinned: a future deletion of the if (ok) from carryRealInto/carryIntInto ships green. The comment "malformed low dropped → default 0.0" is self-defeating — it asserts the exact value both branches produce.
Root cause: for a plain struct field the fallback (default) equals the parse-failure value, so no plain-field assertion can distinguish guarded from unguarded. To actually pin the contract you'd test at a call site whose default differs from 0 (i.e. the std::optional carriers, where nullopt ≠ 0) — see the companion comment on FlexBackend.cpp about the guard being inert here.
| // Build the typed MeterDef directly (#4070). Present-only: a field the | ||
| // wire didn't report keeps its MeterDef default. Numeric fields are | ||
| // ok-guarded — a malformed present value is dropped, not applied as | ||
| // 0/0.0 (a bad low/hi would otherwise collapse a meter's scale). |
There was a problem hiding this comment.
[doc-accuracy — low] The guard is inert for meter; this comment overstates it.
"a bad low/hi would otherwise collapse a meter's scale" is true for the std::optional slice/transmit carriers (a dropped malformed value leaves the field nullopt, so the model keeps its previous value), but not here: a fresh MeterDef def; is built each iteration with low/high defaulting to 0.0, so a malformed low=junk yields 0.0 with the guard and "junk".toDouble()==0.0 without it — the same collapse either way. The carryRealInto/carryIntInto guard gives real fail-closed protection only at the optional<T> call sites, not at the plain-field MeterDef sites.
Since meter status is a full definition and defineMeter full-replaces, the plain carryInto/unconditional-assign would be equally correct here — consider either dropping the guarded variant's pretense for meter or reworiding the comment to "defensive/consistency only" so a maintainer doesn't trust a protection that isn't there.
| } | ||
| } | ||
|
|
||
| // ---- carriers into plain struct fields (meter's MeterDef) — same present-only |
There was a problem hiding this comment.
[cleanup — low] The shared header ships the very duplication its rationale warns against.
Two parallel families with identical present-only + ok-guard bodies, distinguished only by target type: carryStr/carryInt/carryReal/carryBool write std::optional<T>&; carryInto/carryIntInto/carryRealInto write plain T&. The header's own comment says "a future tweak (base-0 parse, whitespace trim) can't be applied to one copy and silently skipped in the others" — yet carryInt and carryIntInto are two copies of the int parse (and carryIntInto already carries a base param carryInt lacks), and likewise for real. Overloading a single carry(kvs, wire, target) on std::optional<T>& vs T& (or one template dispatching to a small assign) collapses both families and makes every call site read uniformly — the Into suffix doesn't signal "plain vs optional" to a reader anyway.
| } | ||
| return out; | ||
| }; | ||
| // Thin forwarders to the shared flexkv carriers — the present-only + ok-guard |
There was a problem hiding this comment.
[cleanup — low] Three call conventions for one helper family in one TU.
Slice keeps these forwarding lambdas (oStr → flexkv::carryStr, …), transmit calls carryBool unqualified via the file-scope using namespace flexkv, and meter qualifies flexkv::carryInto. Transmit already proved the forwarders unnecessary — dropping them (call flexkv::carryStr(kvs, "pan", d.panId) directly) or keeping one convention everywhere would make the contract legible and mean a future carrier-signature change threads through one layer, not two. Relatedly, the local splitList lambda at the slice site is redundant: the using-directive already makes flexkv::splitList callable unqualified, so the lambda is a no-op wrapper that only obscures whether it adds behavior.
There was a problem hiding this comment.
Clean, behavior-neutral refactor — I traced the typed MeterDef path end to end and it holds up: RadioModel is the sole meterDefined consumer so the signal-signature change is safe, defineMeter(const MeterDef&) matches, the qRegisterMetaType<MeterDef>() + Q_DECLARE_METATYPE pair is correct for the queued backend connection, and carryIntInto(..., /*base=*/0) preserves the old base-0 num parse. All seven files are within the stated scope. The shared-carrier consolidation is a genuine improvement and the #4070 "deliberately NOT done" decisions are well reasoned.
I've independently verified the four items you flagged in your own self-review — all four are valid; recap by severity:
Would like fixed before merge
tests/aetherd_meter_decode_test.cpp— the malformed-drop test no longer pins the guard it names; a future deletion ofif (ok)from the meter path ships green. This is the only meter-guard coverage, and the PR body claims "the three decode tests pin the exact wire→output mapping" — this one now doesn't. Pick a resolution (see inline).
Polish (non-blocking)
FlexBackend.cpp:394— the "collapse a meter's scale" comment overstates a guard that is inert at the plain-fieldMeterDefsites.FlexBackend.cpp:428— redundantsplitListforwarder lambda + three different call conventions for one helper family in one TU.FlexKvCarry.h— the two parallel carrier families are the duplication the header's own rationale warns against; foldable via overload/template. Design opinion, fine to defer.
Nice work threading the seam cleanup to a compiler-checked payload.
🤖 aethersdr-agent · cost: $5.0689 · model: claude-opus-4-8
| CHECK(d.low == 0.0); // malformed low dropped → default 0.0 | ||
| CHECK(d.sourceIndex == 0); // malformed num dropped → default 0 |
There was a problem hiding this comment.
These two assertions no longer distinguish a guarded from an unguarded parse. MeterDef defaults low/sourceIndex to 0.0/0, and an unguarded "junk".toDouble() / "nope".toInt(&ok,0) also yields 0.0/0 — so both branches produce the same value and the test stays green if carryRealInto/carryIntInto lose their if (ok). The // malformed low dropped → default 0.0 comment is self-defeating: it asserts the exact value both paths produce.
For a plain struct field the parse-failure value equals the default, so no meter-site assertion can pin the fail-closed contract. Two honest options:
- Move the guard test to an
optional<T>call site (slice/transmit), where a dropped malformed value staysnullopt≠0— that's where the guard actually protects, per your FlexBackend.cpp:394 note. - Accept the guard is inert here and reword this to a plain "malformed value → field keeps default" check (drop the "dropped"/guard framing), and let (1) carry the real contract.
| // Build the typed MeterDef directly (#4070). Present-only: a field the | ||
| // wire didn't report keeps its MeterDef default. Numeric fields are | ||
| // ok-guarded — a malformed present value is dropped, not applied as | ||
| // 0/0.0 (a bad low/hi would otherwise collapse a meter's scale). |
There was a problem hiding this comment.
Confirmed: this comment is accurate for the optional<T> slice/transmit carriers (a dropped value stays nullopt, model keeps its previous value) but not here — a fresh MeterDef def; defaults low/high to 0.0, so a malformed low=junk collapses to 0.0 with the guard and "junk".toDouble()==0.0 without it. Since defineMeter full-replaces, an unconditional assign would be equally correct at these plain-field sites. Either reword to "defensive/consistency only" so a maintainer doesn't trust protection that isn't there, or drop the guarded variant for the meter path.
| const auto oInt = [&](const char* w, std::optional<int>& f) { flexkv::carryInt(kvs, w, f); }; | ||
| const auto oReal = [&](const char* w, std::optional<double>& f) { flexkv::carryReal(kvs, w, f); }; | ||
| const auto oBool = [&](const char* w, std::optional<bool>& f) { flexkv::carryBool(kvs, w, f); }; | ||
| const auto splitList = [](const QString& raw) { return flexkv::splitList(raw); }; |
There was a problem hiding this comment.
The file-scope using namespace flexkv; already makes splitList callable unqualified, so this forwarder lambda is a no-op wrapper — calling flexkv::splitList (or just splitList) directly is equivalent. More broadly, this TU now uses three conventions for one helper family: slice wraps in forwarding lambdas (oStr/oInt/…), transmit calls carryBool unqualified via the using, and meter qualifies flexkv::carryInto. Transmit already proved the forwarders unnecessary; settling on one convention would keep a future carrier-signature change threading through one layer, not two.
| } | ||
| } | ||
|
|
||
| // ---- carriers into plain struct fields (meter's MeterDef) — same present-only |
There was a problem hiding this comment.
Non-blocking design note: carryInt/carryIntInto (and carryReal/carryRealInto) are two copies of the same parse distinguished only by optional<T>& vs T& — the exact duplication this header's rationale warns against, and carryIntInto already carries a base param carryInt lacks. Overloading a single carry(kvs, wire, target) on the two target types (or one template dispatching to a small assign) would collapse both families; the Into suffix doesn't signal "plain vs optional" to a reader anyway. Fine to defer, but worth a note before a 5th touchpoint copies it.
…verged, honest meter-guard docs Resolves ten9876's #4075 findings. - Consolidate the two carrier families into a single overloaded carry() dispatched on target type (std::optional<T>&, plain T&, QVariantMap&), with the guarded numeric parse single-sourced in parseInt/parseReal — so the int/real parse is no longer two copies (the very duplication the header warned against). carryClamp + splitList unchanged. - Converge pan: decodePanState now routes through the new QVariantMap-target carry() instead of its own carry lambda — so the shared helpers genuinely cover pan/slice/meter/transmit (the PR's original claim), removing the 4th copy. - One call convention: dropped slice's forwarder lambdas and its redundant splitList lambda; every decoder now calls carry()/splitList() directly (in scope via the file-scope `using namespace flexkv`). - Honesty: reworded the meter decode comment + test to state the ok-guard is defensive/consistency-only for a plain MeterDef field (default 0/0.0 == unguarded malformed-parse result, full-replace semantics); the guard's real fail-closed effect is pinned at the std::optional carry() sites, where the slice/transmit decode tests already assert malformed → disengaged. Behavior-neutral: 69/69 tests green (all four decode tests + pan pass), boundary --strict green, full app builds clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review response — pushed 7b070d1Thanks @ten9876 — all four inline findings + the pan-claim accuracy note resolved: Carrier duplication (FlexKvCarry.h:60) — the two families collapse into a single overloaded Three call conventions (FlexBackend.cpp:422) — unified. Dropped slice's forwarder lambdas and its redundant Pan didn't converge (accuracy note) — fixed for real rather than by scoping the claim: Meter guard inert + self-defeating test (FlexBackend.cpp:394 / test:84) — you're right that for a plain 69/69 tests green, boundary |
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
MeterDefmoves frommodels/MeterModel.htocore/backends/MeterDef.h(pure data), soIRadioBackendcarries it directly:meterDefined(const MeterDef&)instead of the stringly-keyedQVariantMap.FlexBackend::decodeMeterStatusbuilds theMeterDef;RadioModelhands it straight todefineMeter()— the ~15-line key-string reconstruction is gone, and a field-name typo is now a compile error.MeterModel.hre-includes the core header so its existing users are unaffected.Shared decode carriers
New
core/backends/flex/FlexKvCarry.hsingle-sources the present-only + fail-closed parse contract (carryStr/Bool/Int/Clamp/Realfor optional delta fields;carryInto/IntInto/RealIntofor 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.cppnet-shrinks ~90 lines.Deliberately NOT done (documented decisions)
panState/panWnbstay on the genericextensionStatus(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/SliceDeltakept flat (not per-plane sub-structs — ten9876's feat(aetherd): 2.3 — complete TransmitModel conversion (typed TransmitDelta, last mixed model) #4071 option): the flat structs are verified/tested, and theassign()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 alongsideSlice/TransmitDelta.aetherd_meter_decode_testasserts on the typedMeterDef(incl. the malformed-drop guard → default).--strictgreen, full app builds clean.Refs #3849. Concludes the RFC 2.3 mixed-model split seam cleanup — the
IRadioBackendseam now carries pan/meter/slice/transmit with compiler-checked typed payloads and one shared decode-helper set.🤖 Generated with Claude Code