Skip to content

Synchronize TGXL startup state before presence - #5339

Open
w5jwp wants to merge 3 commits into
aethersdr:mainfrom
w5jwp:fix/tgxl-startup-status
Open

Synchronize TGXL startup state before presence#5339
w5jwp wants to merge 3 commits into
aethersdr:mainfrom
w5jwp:fix/tgxl-startup-status

Conversation

@w5jwp

@w5jwp w5jwp commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #5338.

Carry the TGXL handle in TunerDelta so identity, operate/bypass state, and IP from the initial radio status are applied as one model snapshot. TunerModel now publishes first presence only after those fields are current.

Route both external-TGXL discovery paths through that atomic delta and extend the existing socket-free tuner model and backend decode tests. This prevents first-presence observers from reading default standby values while preserving direct presence without a radio handle, meter routing, and removal paths.

Constitution principle honored

Principle XI — Fixes Are Demonstrated. The model test observes state from presenceChanged(true); it fails when atomic handle application is removed and passes after restoration.

Test plan

  • Modified aethercore sources compile while building the focused targets
  • Focused tests pass (ctest --test-dir build -R '^(tuner_model_test|aetherd_tuner_decode_test)$' --output-on-failure --no-tests=error)
  • Registration check passes (python3 tools/check_test_registration.py --strict)
  • Engine-boundary check reports no blockers (python3 tools/check_engine_boundary.py --strict)
  • Regression assertion mutation-checked by removing atomic handle application and observing failure
  • Direct-presence/no-radio-handle behavior and post-presence edge-signal ordering covered
  • Real TGXL hardware verification; this issue was identified by code audit and the invariant is tested at the socket-free model boundary

Checklist

  • Commit is signed (docs/COMMIT-SIGNING.md)
  • No new AppSettings calls
  • Code is clean-room and follows the existing normalized backend-delta architecture
  • No meter UI changes
  • Reproduction and behavior are documented in the linked issue and this PR; CHANGELOG.md is unchanged
  • No security-sensitive changes
@w5jwp
w5jwp requested a review from a team as a code owner August 30, 2026 18:31
@w5jwp

w5jwp commented Aug 30, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the issue-triage recommendations in signed commit 6f82e4b8:

  • RadioModel explicitly routes the first real TGXL handle when the model currently carries 0x00000000, allowing the atomic TunerDelta to replace the placeholder even if the follow-up status omits model.
  • tuner_model_test now covers placeholder-to-real-handle replacement without a duplicate presence edge.
  • A direct-connected tuner with no radio handle is covered; applying a handle-less state delta preserves direct presence and the empty radio handle.
  • aetherd_tuner_decode_test covers carrying the placeholder followed by the assigned real handle.
  • m_meterModel.setTgxlHandle(...) remains in the existing RadioModel identity path unchanged, preserving VITA meter routing.

I did not add removed to TunerDelta: the existing setHandle({}) removal paths remain intact, and expanding removal ownership was presented as an optional maintainer choice rather than necessary to fix the initialization ordering.

Focused tests, registration validation, and the strict engine-boundary check pass locally.

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

Issue fit

Partially. #5338 asks for three things: carry tuner identity in TunerDelta, apply the complete initial snapshot inside applyChanges(), and publish presence only after all fields are current. All three are delivered, and the payoff is real rather than theoretical — MainWindow_Wiring.cpp:6022 reads tgxlIp() from inside the presenceChanged(true) handler to auto-connect the direct port-9010 socket, so a presence edge that fires before ip lands genuinely mattered. tuner_model_test's ipAtPresence assertion pins exactly that.

What the issue does not ask for is the third change in the diff: the replacingPlaceholder widening of the amplifier routing predicate at RadioModel.cpp:9985-9988. That is a separate defect class (#4203's model-less-status edge), it is the one hunk with no test, and I believe it is a regression — blocker 1.

Scope

File What it changes Claimed? Verdict
src/core/backends/TunerDelta.h adds handle field yes — issue's "carry tuner identity in TunerDelta" In scope
src/core/backends/flex/FlexBackend.cpp populates d.handle in decodeTunerStatus yes In scope
src/models/TunerModel.cpp applies d.handle; presence edge computed across the whole delta yes — this is the fix In scope
src/models/RadioModel.cppatu branch (9911-9917) drops the pre-decode setHandle, folds handle extraction into the decode call yes — issue names the atu entry point explicitly In scope
src/models/RadioModel.cppamplifier branch, replacingPlaceholder (9985-9988) widens which statuses route to TunerModel no#5338 says nothing about routing, only about ordering Out of scope, and a regression — see blocker 1
tests/aetherd_tuner_decode_test.cpp handle param + placeholder-carry case yes In scope
tests/tuner_model_test.cpp presence-observer, placeholder→real, direct-presence cases yes In scope

No CHANGELOG.md entry — correct. No AppSettings calls, no new public surface, no deleted guards other than the two setHandle calls the issue asked to remove (setHandle itself survives and is still used by the three removal/reset paths at RadioModel.cpp:6941/9966/9979, so it is not dead). No UI defaults or styling touched, so nothing to classify under the preference check.

Blockers

1. replacingPlaceholder routes PGXL statuses into TunerModel and starves AmpModel — inline at src/models/RadioModel.cpp:9985.

The new third disjunct is unconditioned on model or handle. Once m_tunerModel.handle() == "0x00000000", every amplifier <handle> ... status takes the tuner branch, and because the AmpModel decode is the else of that branch, the amp decode is skipped entirely. Concretely, a model=PowerGeniusXL status arriving while the TGXL is still holding the placeholder:

  • decodeTunerStatus(pgxlHandle, kvs) sets FlexBackend::m_tunerHandle = pgxlHandle (FlexBackend.cpp:977-978), so tuner.operate / tuner.bypass / tuner.autotune then encode tgxl … handle=<PGXL>. decodeAmplifierStatus at FlexBackend.cpp:950 carries an explicit guard against precisely the mirror image of this ("Defense in depth (#4203) … refuse to cache a known-tuner handle so a later amp.operate can never mis-target the TGXL") — this change opens the direction that guard does not cover, and tgxl autotune is a transmit-keying command.
  • d.handle and d.model overwrite TunerModel's identity with the PGXL's, so TunerModel::modelName() becomes PowerGeniusXL.
  • m_meterModel.setTgxlHandle(pgxlHandle) fires, since handle != "0x00000000" && handle != m_tunerModel.handle().
  • AmpModel never sees the status, so PGXL presence never latches — no amp applet, no auto-connect at MainWindow_Wiring.cpp:6068.

On main this cannot happen: a PGXL status fails both model == "TunerGeniusXL" and handle == m_tunerModel.handle() and falls correctly to decodeAmplifierStatus. The placeholder being stored in the model is not new — the pre-existing else if (m_tunerModel.handle().isEmpty()) branch stored it too — so the window this opens is a live state on any station running a TGXL, and it stays open indefinitely if a real handle never arrives.

The case the disjunct is presumably aimed at is a model-less TGXL status carrying the real handle while the model still holds the placeholder. Two narrower shapes that don't swallow the amp:

  • gate on the absence of an attributing model — || (replacingPlaceholder && model.isEmpty()); or, better,
  • stop adopting 0x00000000 as identity at all, so handle == m_tunerModel.handle() never matches the placeholder and the disjunct is unnecessary. decodeTunerStatus's own comment already assumes this ("RadioModel passes the handle it already extracted+sanitized (never the 0x00000000 placeholder)").

Either way this hunk wants its own test, and arguably its own PR against #4203 rather than riding on the ordering fix.

Nits

  • The one risky hunk is the one with no coverage. Both new tests drive TunerDelta / decodeTunerStatus directly; neither exercises RadioModel::onStatusReceived, so nothing in the suite would catch blocker 1. A socket-free case feeding a model=PowerGeniusXL amplifier status while the tuner holds the placeholder, and asserting AmpModel presence, would pin it.
  • Edge signals now precede presence. antennaAChanged / tuningChanged are emitted mid-loop in applyChanges, i.e. before the new presenceChanged. On main setHandle published presence first. A first delta carrying tuning=1 now emits tuningChanged(true) for a tuner the rest of the app does not yet consider present. TunerApplet.cpp:282 only restyles, so I don't think this bites today — but it inverts the ordering the fix is otherwise establishing, and moving the presence emit above the field block would cost nothing.
  • Stale comment. FlexBackend.cpp:974-976 still claims RadioModel never passes the placeholder; RadioModel.cpp:9996 now passes raw handle unconditionally. The claim was already loose on main; this makes it plainly wrong.

What I tried to break

  • "Presence only after all fields are current." Held for the fields the delta carries — wasPresent/nowPresent bracket the entire field block and presenceChanged precedes stateChanged. The one seam is the edge signals above.
  • Direct-connection presence with no radio handle. isPresent() is !m_handle.isEmpty() || m_directPresence, and decodeTunerStatus leaves d.handle disengaged on an empty handle, so a handle-less delta cannot clear direct presence. The third new test case pins this; I could not construct a delta that spuriously re-emits the edge.
  • The atu branch rewrite. Walked it against the deleted code for: no regex match (both skip), match with model handle already set (both keep the existing handle), direct-presence-but-no-handle (both adopt the radio's own ATU handle — unchanged behavior, not a new defect). I could not find a divergence.
  • Meter-handle sequencing. m_meterModel.setTgxlHandle now reads m_tunerModel.handle() before the decode updates it rather than after setHandle. Traced placeholder→real: 0x00000000 first status sets meter handle 0 via the isEmpty() branch, real handle then satisfies the first branch. Correct under both direct and queued tunerChanged delivery.
  • Removal paths. setHandle({}) at 6941/9966/9979 still clears presence; the new d.handle cannot resurrect it, since a removal returns before decodeTunerStatus.
  • The tests against the unfixed code. They cannot compile pre-TunerDelta::handle, so I checked them against a mutated fix instead: deleting the presenceChanged emit fails presence.count() == 1, and deleting the d.handle apply fails the placeholder→real case. They are not self-referential.

CI is green on 6f82e4b (build, check-macos, check-windows, Static checks), but ci.yml -R-filters ctest, so green does not mean tuner_model_test ran — it establishes compile/link only. Everything above is reasoned from the head checkout at /tmp/aetherclaude/pr-5339; I have no build or hardware here, so the PGXL misrouting is a code-path argument, not an observed failure. The quickest disproof would be a station with both a PGXL and a TGXL where the TGXL's first amplifier status carries 0x00000000 — if that placeholder frame never actually occurs on real firmware, blocker 1 collapses (and so does the motivation for the disjunct).

Recommendation

Request changes, narrowly. The ordering fix that #5338 asks for is well-built and well-tested — it should land. The replacingPlaceholder disjunct is a different fix for a different defect, it has no test, and as written it lets a PGXL status capture the tuner's identity and encode handle while hiding the amp. Drop it from this PR and the rest is ready; if the model-less-status edge is worth fixing, it deserves its own issue against #4203 with a routing test, ideally by refusing to adopt 0x00000000 as identity rather than by widening the predicate. Thanks for the clear issue write-up and for being upfront that this one came from audit rather than a user report — that framing made the review much easier.


🤖 aethersdr-agent · cost: $6.2469 · model: claude-opus-5

Comment thread src/models/RadioModel.cpp Outdated
Comment on lines +9985 to +9988
const bool replacingPlaceholder =
m_tunerModel.handle() == QLatin1String("0x00000000");
if (model == "TunerGeniusXL" || handle == m_tunerModel.handle()
|| replacingPlaceholder) {

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.

Blocker. This third disjunct is unconditioned on model and on handle, so while the tuner holds the 0x00000000 placeholder, every amplifier status takes the tuner branch — and since the AmpModel decode is the else of this branch, PGXL statuses are swallowed entirely.

For a model=PowerGeniusXL status arriving in that window:

  • decodeTunerStatus caches m_tunerHandle = <PGXL handle> (FlexBackend.cpp:977), so tuner.autotune then encodes tgxl autotune handle=<PGXL>. FlexBackend.cpp:950 carries an explicit FlexBackend: guard amp-handle cache against a mis-routed tuner handle #4203 guard against exactly the mirror of this; this opens the direction it doesn't cover.
  • the new d.handle/d.model overwrite TunerModel's identity with the amp's.
  • AmpModel presence never latches — no PGXL applet, no auto-connect.

On main a PGXL status fails both existing disjuncts and routes correctly. Storing the placeholder is pre-existing (the isEmpty() branch below already did it), so this window is live on any TGXL station, and permanent if a real handle never arrives.

If the target is the model-less-TGXL-status edge, gate on that rather than on placeholder state alone:

Suggested change
const bool replacingPlaceholder =
m_tunerModel.handle() == QLatin1String("0x00000000");
if (model == "TunerGeniusXL" || handle == m_tunerModel.handle()
|| replacingPlaceholder) {
const bool replacingPlaceholder =
m_tunerModel.handle() == QLatin1String("0x00000000")
&& model.isEmpty(); // never capture an attributed (PGXL) status
if (model == "TunerGeniusXL" || handle == m_tunerModel.handle()
|| replacingPlaceholder) {

Cleaner still would be to stop adopting 0x00000000 as identity at all — decodeTunerStatus's own comment already assumes RadioModel never passes it — which makes this disjunct unnecessary. Either way it needs a routing test; nothing in the suite exercises onStatusReceived.

Comment thread src/models/RadioModel.cpp
m_meterModel.setTgxlHandle(handle.toUInt(nullptr, 0));
}
if (m_flexBackend) m_flexBackend->decodeTunerStatus(m_tunerModel.handle(), kvs); // #4092/#4198
if (m_flexBackend) m_flexBackend->decodeTunerStatus(handle, kvs); // #4092/#4198

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.

Now passes the raw handle, which can be the 0x00000000 placeholder. That contradicts FlexBackend.cpp:974-976 ("RadioModel passes the handle it already extracted+sanitized (never the 0x00000000 placeholder)") — the claim was already loose on main, but this makes it plainly wrong. Worth either updating the comment or filtering the placeholder here.

Comment thread src/models/TunerModel.cpp
const bool wasPresent = isPresent();
bool changed = false;

if (d.handle && m_handle != *d.handle) { m_handle = *d.handle; changed = true; }

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.

Nit, non-blocking: antennaAChanged and tuningChanged are emitted below inside the field block, so they now fire before the new presenceChanged. On main, setHandle published presence first. A first delta carrying tuning=1 therefore emits tuningChanged(true) for a tuner the rest of the app doesn't yet consider present. TunerApplet.cpp:282 only restyles so I don't think it bites today, but it inverts the ordering this fix is otherwise establishing — hoisting the presence emit above the field block would settle it.

Comment thread tests/tuner_model_test.cpp Outdated
CHECK(t.relayC1() == 5 && st.count() == 0);
}

// ---- placeholder identity is replaced without a second presence edge ----

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.

These three cases are solid and not self-referential — I mutation-checked them by reading: dropping the presenceChanged emit fails presence.count() == 1, and dropping the d.handle apply fails the placeholder→real case.

The gap is that all of them drive TunerDelta directly, so none reaches RadioModel::onStatusReceived — the replacingPlaceholder hunk, which is the riskiest change in the diff, has no coverage at all. A case feeding a model=PowerGeniusXL amplifier status while the tuner holds the placeholder, asserting AmpModel still latches presence, would pin the blocker above.

@w5jwp

w5jwp commented Aug 30, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the review blocker and remaining nits in signed commit d2eebb9d:

  • Removed the replacingPlaceholder routing disjunct. A PGXL status can no longer enter the tuner branch merely because the TGXL currently holds 0x00000000.
  • Removed the out-of-scope placeholder-transition tests and claims added with that routing change.
  • Corrected the stale FlexBackend::decodeTunerStatus() comment: a placeholder may be carried in the delta for parity, but it is not cached for outgoing tuner commands.
  • Deferred antennaAChanged and tuningChanged until after first presence while preserving their established antenna-before-tuning order.
  • Extended tuner_model_test to verify both edge signals are deferred at the first-presence callback. Moving them ahead of presence makes the test fail.

The atomic initial-snapshot fix, direct-presence/no-radio-handle coverage, existing meter-handle assignment, and existing removal paths remain intact.

Focused tests, registration validation, and the strict engine-boundary check pass locally.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

1 participant