Skip to content

feat(anan): remember where the operator left the radio. Principle III. - #6129

Merged
jensenpat merged 1 commit into
aethersdr:mainfrom
tropo1234:anan-session-state
Oct 7, 2026
Merged

jensenpat merged 1 commit into
aethersdr:mainfrom
tropo1234:anan-session-state

Conversation

@tropo1234

@tropo1234 tropo1234 commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

What this fixes

An ANAN-G2 reports no frequency, mode, tuning step or output level of its own, so every connect started from defaults. The client now remembers each one per radio:

Before After
Frequency and mode every session started at 10 MHz where you left it; 14.175 MHz USB on a first connect
Tuning step 100 Hz once the first slice arrived where you left it
Radio's own output level (PC Audio off) reset to 50 every connect where you left it, including 0
Panadapter dBm range −50 dBm floor every launch where you left it, through #6140's store

How

Each value rides a capability the backend declares. None of it is a family check.

  • Tuning (ClientSettingsDomain::Tuning, already used by HL2 and RTL). AnanBackend restores frequency and mode before connect. It captures them once per settled tune (applyTuneToRadioAndPan()) and on each mode change. A stored value is refused, not clamped: a mode modeFromString() cannot map, or a frequency at or above the DDS Nyquist (tuningMinHz/MaxHz are not reported, so there is no band edge to clamp to). The two fields are judged separately, so a bad mode does not drop a good frequency.
  • Tuning step. It rides the same domain as RestoredRadioState::tuningStepHz, but only where no command plane carries slice set step=. RadioModel follows its CW capture/restore. It restores the step at connect, seeds it into each new slice before sliceAdded, and captures any slice's step change. A radio swap clears it first, so radio A's step never reaches radio B. Steps outside 1 Hz–1 MHz are refused. The flat TuningStepSize key stays as the widget preference it was, and is no longer read back into a slice.
  • Radio output level: a new ClientSettingsDomain::ReceiveOutputLevel (bit 9) plus RestoredRadioState::receiveOutputLevelPct. The sentinel is −1, so a speaker silenced on purpose comes back as 0. With nothing stored, the backend keeps its default of 50. The level is seeded into both the backend and RadioModel::lineoutGain(), which is what the slider shows with PC Audio off. Two G2s on one profile keep separate levels. OperatingState schema goes 2 → 3.
  • dBm range: ANAN declares PanAmplitudeModel::clientPersistsDbmRange. fix(gui): FFT FPS and the dBm range survive a restart. Principle VIII. #6140's accessors, accept-set and restore do the rest, and this PR adds no dBm code of its own.

Master volume

RadioModel::activeOutputVolumePercent() is now the one rule for the level the operator hears. With PC Audio on it is MasterVolume. With it off it is the radio's line-out gain, on every family, because that is what applyMasterVolume() drives. The volume hotkeys, FlexControl, HID, the wheel, MIDI and TCI all read it. MainWindow and TCI keep no copy of the rule.

applyMasterVolume() now writes MasterVolume only with PC Audio on. A level set for the radio's speaker therefore no longer comes back as the PC level.

Behaviour changes outside ANAN

  • HL2 and legacy RTL now remember the tuning step per radio. Icom, settings-active RTL and the demo get nothing, as on main.
  • Every family, PC Audio off: the volume nudges step from the line-out level. Moving the slider no longer rewrites MasterVolume. TCI's volume reports the line-out level.
  • Flex: the step_up/step_down shortcuts now send slice set N step=, as the STEP buttons already did, and update the RX applet and the toast.
  • The PC-Audio toggle re-applies the level on a radio that declares ReceiveOutputLevel (ANAN today). It no longer compares the family string, and the slider now shows the level for the new destination.

Testing

On a6299adb, rebased onto 2e3d7ae7 (#6140); merge-tree clean against 30882701.

  • Serial ctest: 657 of 659 pass, with the one deliberate skip. pgxl_panel_test fails here on clean main too. The new radio_model_client_state_test failed on that run because the test never opened its settings store (AppSettings::load()); it was fixed before the push and passes.

  • Static checks: all 17 Python gates from static-checks.yml pass with --strict. Command plane is 369, at baseline. Capability booleans are 70, at the frozen 70. The touchpoint manifest is up to date and unchanged by this PR. No colour lines were added.

  • 12 mutations, 12 killed, each run against a target that was green beforehand:

    # Mutation Goes red
    M1 ANAN stops declaring ReceiveOutputLevel anan_backend_test
    M2 a level of 0 is not stored radio_state_memory_test
    M3 an out-of-range level is clamped instead of refused radio_state_memory_test
    M4 radio B keeps radio A's level anan_backend_test
    M5 setLineoutGain() stops announcing the change anan_backend_test
    M6 the capability gate goes back on the volume rule radio_model_client_state_test
    M7 the restored level is not seeded into the model radio_model_client_state_test
    M8 the restored step is not seeded into new slices radio_model_client_state_test
    M9 a step change is not captured radio_model_client_state_test
    M10 the step-reset on a radio swap is removed radio_model_client_state_test
    M11 the step is not loaded radio_state_memory_test
    M12 ANAN declares no dBm-range owner client_display_pan_settings_test
  • ANAN-G2, quit and relaunch: frequency, mode, a 2.5 kHz step, the radio output level with PC Audio off, and the dBm range (the first run of fix(gui): FFT FPS and the dBm range survive a restart. Principle VIII. #6140's restore in the app) all came back. A different DDC0 rate picked in the connect dialog applied. With PC Audio off and the slider at 30, Master Volume Up went 35, then 40.

Limits

  • Not mutation-run: the three predicates that live in headers, i.e. fix(gui): FFT FPS and the dBm range survive a restart. Principle VIII. #6140's clientPersistsDbmRange() declaration term (the synthetic case below is its only control now) and the two new isEmpty() terms. Each mutation would rebuild most of aethercore twice. The test rows read those fields directly.
  • A downgrade is read-only: after this build writes schema 3, an older build still reads the known fields. It will not overwrite the document (RadioStateMemory's newer-schema rule). That applies to HL2 and RTL operating state as well as ANAN.
  • No HL2 or RTL bench for the step. Their path is the same RadioModel code the new test drives with an ANAN backend.

Not in this PR

🤖 Generated with Claude Code

@tropo1234
tropo1234 requested a review from a team as a code owner October 3, 2026 19:41
@aethersdr-agent

Copy link
Copy Markdown
Contributor

Thanks for this one, @tropo1234 — six real settings-persistence bugs with a mutation per fix and a quit-and-relaunch on actual G2 hardware is more rigour than most PRs get, and the note that two of them were only visible across a process restart is a genuinely useful observation about the limits of the suite.

Only one check is red, and it's a small bookkeeping step rather than anything wrong with the code. (check-windows, check-macos and build were still in progress when I looked, so this is the only known failure.)

What failed

Static checks → aetherd touchpoint manifest (run 37148817719):

::error::aetherd touchpoints require a valid semantic tag: core/AudioOutputVolumePolicy.h

I reproduced it at da7e189c:

$ python tools/gen_touchpoint_manifest.py --check
::error::aetherd touchpoints require a valid semantic tag: core/AudioOutputVolumePolicy.h

The new src/core/AudioOutputVolumePolicy.h is included by five GUI translation units —

src/gui/MainWindow.cpp
src/gui/MainWindow_Wiring.cpp
src/gui/MainWindow_Controllers.cpp
src/gui/MainWindow_Shortcuts.cpp
src/gui/TitleBar.cpp

— so tools/gen_touchpoint_manifest.py counts it as a new gui→engine touchpoint (233 → 234). Every live touchpoint needs an entry in docs/architecture/aetherd-touchpoint-tags.json, and this one has none. git show --stat da7e189c -- docs/architecture/ is empty, so neither the tags file nor the manifest was updated.

How to fix it

Note the order — --check validates tags before it compares the manifest (gen_touchpoint_manifest.py:207-216), so regenerating alone won't clear it:

  1. Add the tag entry to docs/architecture/aetherd-touchpoint-tags.json, by hand. The file uses one-space indentation; json.dump(..., indent=2) will reformat all ~2900 lines (I did that by accident). Done right, git diff --stat on that file should be about seven lines:

     "core/AudioOutputVolumePolicy.h": {
      "tag": "<see below>",
      "note": "Which stored level the master slider moves, and the radio-side default; client-side AppSettings policy, no radio state.",
      "split": "",
      "confidence": "high"
     },
  2. Regenerate, then verify with the CI form:

    python tools/gen_touchpoint_manifest.py
    python tools/gen_touchpoint_manifest.py --check   # → "manifest up to date"
    
  3. Commit both aetherd-touchpoint-tags.json and docs/architecture/aetherd-touchpoints.md.

With a tag added I ran exactly that locally and --check went green; the only manifest change is the totals line and the one new row (core/AppSettings.h's includer count stays at 104, since the new file is a core header, not a GUI one). Worth knowing: the plain generator form writes an untagged row and prints success, which is probably why "eleven ratchets green" didn't catch this — --check is the form that gates.

The tag itself is your call, not a mechanical one

TAG_RE (line 50) accepts universal, ui-support, vendor(x), mixed(x), peripheral(x) — any of them clears CI, so please don't just take my placeholder. The nearest precedents are both ui-support:

  • core/AppSettings.h — "Client-side XML settings store … app plumbing, not radio state"
  • core/AudioOutputRouter.h — "Registry fanning the user-selected QAudioDevice to local playback sinks … no radio state"

which fits the header well. The thing that makes it a judgment call is separatesRadioOutputLevel()'s family.compare(QLatin1String("anan")) — family knowledge inside a core/ header, which could argue for mixed(anan) instead. Your header comment already makes the case that the condition is "who owns the level" rather than "which vendor", so you may well have the better read; worth one line in the PR body either way.

One thing for the PR body

docs/aetherd-agents-md-staging.md:111 treats a new gui→engine touchpoint as a maintainer decision (RFC §2) rather than something to add in passing. I don't think the header can go anywhere else — src/core/TciProtocol.cpp and src/core/DeviceDiagnostics.cpp include it too, so it can't live under gui/, and the single-spelling-of-the-test argument in your comment is a good one. But it's worth saying so explicitly in the description and noting the 233 → 234 bump, the way #6008 did for its peripheral validation header, so the maintainer isn't inferring the decision from a manifest diff.

No Copilot or reviewer comments on the PR yet, and nothing here is CI infrastructure — the check is behaving correctly, it just hasn't been fed the new header's tag.


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

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

1. Issue fit

There is no linked issue — no Fixes/Closes #NNNN in the body — so I reviewed against the PR's own stated intent. GOVERNANCE.md wants an RFC for architectural changes and explicitly does not require an issue for a bug fix with a clear root cause; this is a feat( PR bundling five client-persistence fixes plus a new core/ header and a new gui→engine touchpoint, which sits closer to the RFC side of that line. The author pre-flags the touchpoint as a maintainer call, which is the right instinct — see §5 below.

Against its own spec the PR delivers all six rows of its table. The five restore paths are real, the validation is refuse-not-clamp as claimed, and the "restore before the push" ordering in wirePanDisplayStatus() is genuinely placed where the comment says. But two of them are implemented against a family string rather than a declared capability, and canon has a sentence about exactly that. Details in Blockers.

2. Scope

File / group What it changes Claimed? Verdict
core/AudioOutputVolumePolicy.h (new) PC-vs-radio volume key/default selectors + family predicate Yes, incl. self-flagged touchpoint New gui→engine touchpoint + new flat settings key → maintainer decision; see Blockers 1–2
core/ClientDisplaySettings.h FFT averaging + dBm range accessors, shared readInt/writeInt/loadForWrite/store Yes In scope
core/DeviceDiagnostics.cpp Two snapshots gain radio_output_volume_pct Implied In scope (see nit)
core/TciProtocol.cpp Init burst + volume GET read the active sink Implied In scope
backends/anan/* Tuning + SpanRate domains, accept-lists, first-connect defaults Yes In scope; the 10 MHz → 14.175 MHz default change is disclosed → maintainer decision
gui/MainWindow*.cpp/.h Volume key routing, connect-time lineout push, applyOperatorTuningStep() extraction Yes In scope
gui/MainWindow_Wiring.cpp Step seeding on onSliceAdded, display restore, dBm/avg saves Yes In scope
gui/SpectrumOverlayMenu.*, gui/TitleBar.cpp Signal-blocked setters, PC-key read at construction Yes In scope
gui/MainWindow_Session.cpp:1763, MainWindow_Wiring.cpp:3015 Blank line only No Nit — unrelated whitespace churn
tests/*, tests.cmake, docs/architecture/* New targets + manifest rows Yes In scope; registration verified

Everything else in the diff is explained by the PR's stated intent. No CHANGELOG.md entry was added — correct. No socket tests added or removed; anan_backend_test's new --restore-check arm re-executes the test binary as a child process to prove disk persistence, which is a process boundary, not a socket.

3. Blockers

1. separatesRadioOutputLevel() is a family check deciding persistence, where canon requires a declared capability. (inline: AudioOutputVolumePolicy.h:52)

docs/agents/settings.md:143-145, under Settings Authority Policy:

The deciding test is whether THIS radio can save and restore the value — a declared capability, not a family assumption

return family.compare(QLatin1String("anan"), Qt::CaseInsensitive) == 0; is a family assumption, in a core/ header, selecting which key a setting persists under. The header comment argues the condition is "who owns the level" rather than "which vendor" — I agree that is the right question, and that is exactly why it should be a declared answer. This same PR gets it right three lines of diff away: clientSettingsDomains |= Tuning | SpanRate in AnanBackend::capabilities(). The dBm-range and averaging restores also gate correctly on radioOwnsDbmScale / backendPanAveraging. Only the volume split reaches for the string.

Fix: a RadioCapabilities field — clientOwnsReceiveOutputLevel, or an std::optional<ReceiveOutputLevelControl> record if you want the default value to travel with it. Note for the ratchet: an std::optional<…Control> record needs no FROZEN_BOOL_COUNT ruling, while a bare bool grows the count. The pre-existing family compare in MainWindow.cpp's PC-Audio toggle handler doesn't license a new one in core/.

2. RadioSpeakerVolume is a flat, app-global key holding per-radio state. (inline: AudioOutputVolumePolicy.h:33)

Same section of docs/agents/settings.md:150-154:

On a radio that persists nothing and declares so (HL2 today): the client IS the radio's memory — for exactly the declared domains, persisted ONLY through RadioStateMemory's OperatingState document. Never in flat AppSettings keys, never via ad-hoc code paths.

That is precisely the case the PR body makes for this value ("this client originates that audio and the radio has no output-level register"). MasterVolume is legitimately flat because the PC sink is app-global; RadioSpeakerVolume is not — it is one radio's output level. Concrete consequence, from the code rather than a bench run: two ANAN G2s on one profile share a single output level, and the first one connected writes the other's. Every other value in this PR is scoped per radio through RadioSettingsScope / RadioStateMemory, which makes this one the odd member of its own change.

Fix: carry it in the OperatingState document under its own declared domain, the way RfGain already rides in state.extension. That also dissolves Blocker 1 — the domain flag is the declared capability.

4. Nits (non-blocking)

  • The weighted-average save and restore use different gates (inline, MainWindow_Wiring.cpp:4564, with a suggestion). Save fires whenever requestLocalPanWeightedAverage() returns true, which is any non-Flex backend (RadioModel.cpp:6102 → shapesDisplayRatesLocally() → m_flexBackend == nullptr). Restore gates on backendPanAveraging.has_value(), which is ANAN and HL2 only. On Icom, RTL-SDR and the demo the flag is written to the per-radio document and never read back — a dead write. The sibling saveFftAverage() eight lines up already uses the capability gate.
  • radio_output_volume_pct is emitted unconditionally (DeviceDiagnostics.cpp:331, :430) but reads a key nothing writes on Flex, so every Flex support bundle will report 0 — which reads as a muted radio to whoever is triaging it. Gate it on the same predicate, or omit the field when it doesn't apply.
  • A zoom alone never arms the SpanRate capture. The body calls the live span "the whole point", and beginRateChange() (AnanBackend.cpp:1080) is the function a zoom moves it through — but the three emit operatingStateChanged() sites are setSliceMode, the attenuation setter, and applyTuneToRadioAndPan. A zoom followed by a clean quit is still captured by the disconnect flush, so the bench run would not show this; a zoom followed by a kill or crash loses the span. One line in beginRateChange().
  • RadioSetupDialog.cpp:4759 is a second writer to the same radio output level (m_model->setLineoutGain(v)) and knows nothing about RadioSpeakerVolume. After this change, a level set there is silently replaced at the next connect by the stored value — default 0. Not a new loss (it previously reverted to the backend's 100), but the new destination is silent, and the two surfaces now disagree about who owns the number. Folding the level into the capability/domain from Blockers 1–2 would give both surfaces one writer.
  • With PC Audio on — the shipped default — the master slider cannot reach the radio output at all (applyMasterVolume() is strictly either/or), while the connect handler now pushes it to 0. Radio Setup is the only route. Worth a sentence in the body so the first G2 operator who files "my radio speaker went silent after updating" gets the answer from the release note.
  • dbmRange() validates ordering in double but returns float (ClientDisplaySettings.h:132-137) — two ordered doubles within float epsilon pass lo < hi and collapse to min == max, the empty scale the comment at :130 exists to prevent. AetherSDR's own writer compares floats so it cannot produce such a pair; only a hand-edited document can. Correctness note, not a security issue. (Surfaced by security-audit (CodeGuard) and confirmed against the file.)
  • The dBm-range and averaging persistence is not ANAN-only. Both gate on capabilities (radioOwnsDbmScale, backendPanAveraging), so HL2, RTL-SDR and the demo get them too. That is the correct shape per Principle III — but the body frames the whole change as a G2 fix, and the bench evidence is G2-only. Worth stating which other families this lands on.
  • Two whitespace-only hunks (MainWindow_Session.cpp:1763, MainWindow_Wiring.cpp:3015).

Needs maintainer decision

  • New gui→engine touchpoint (core/AudioOutputVolumePolicy.h, 5 GUI includers, manifest 235 → 236). Self-flagged by the author with both alternatives spelled out. Resolving Blockers 1–2 likely removes the header entirely, so this may be moot.
  • First-connect default 10 MHz → 14.175 MHz (inline, AnanBackend.h:120). Disclosed in both the body and the commit message, and the WWV-as-diagnostic-default argument is a reasonable one — but it is a user-visible default change with no issue citing the old default as a defect, which per GOVERNANCE.md is your call rather than the contributor's.

5. What I tried to break (and could not)

Findings below are reasoned from the head checkout at /tmp/aetherclaude/pr-6129; I have no build and ran nothing. The ${PR_DIFF} strings in the task's diff where & belongs are a harness artifact, not source corruption — the real file is fine.

  • "A rejected document leaks the previous radio's dial onto the next radio." No: RadioModel.cpp:660 calls applyRestoredState() unconditionally, with the comment saying why, and AnanBackend::applyRestoredState() assigns all three sentinels unconditionally. Radio A's dial cannot survive a swap to radio B.
  • "The restore is overwritten by the reclaimed-pan block 15 lines later." No: PanadapterModel initialises m_average{-1} and m_weightedAverageKnown{false}, so pan->average() >= 0 is false on a fresh pan, and on a reclaimed one the live value legitimately wins.
  • "The restore seeds the widget but not the pan stream" — the drag handler calls both sw->setDbmRange() and setStreamDbmRange(), the restore only the first. Harmless: m_panStream is assigned only from flex->panStream() (RadioModel.cpp:832), and the restore is gated on !radioOwnsDbmScale, so on every family that reaches it the stream is null.
  • "TCI volume GET and SET now read and write different keys." No: the SET branch sets m_pendingMasterVolume → TciServer:1559 masterVolumeRequested → MainWindow applyMasterVolume(), which writes through the same volumeKey(). GET and SET stay symmetric, including the dB round-trip echo.
  • "family() goes empty on disconnect, so a slider nudge while disconnected writes the radio level into the PC key." No: m_family is assigned at connect and at backend injection and is never cleared, so the key stays correct across a disconnect.
  • Missing transitive includes — std::isfinite, std::find, std::numeric_limits are all covered: <cmath>, <algorithm> and <limits> are present in both AnanBackend.cpp and the test. No Linux-only compile break of the usual shape.
  • The restore tests are not self-confirming. isRestorableFrequencyHz is pinned at 0.0, negative, exactly Nyquist, 200 MHz, NaN and infinity, and isKnownModeString has a negative case; emptying either guard turns those red. audio_output_volume_policy_test does assert separatesRadioOutputLevel("anan") true and ("flex") false, so the "disable the output-level split" mutation claim is credible.
  • Where I could not test: the "found on the bench, not by the suite" claims — the dBm restore-ordering race and the step-seeding overwrite — are the author's; I confirmed the code now sits where the comments say, not that the bench run happened. The MainWindow call sites have no test target at all, which is why Blockers 1–2 matter more than they otherwise would: nothing would catch a regression there. CI is green on all 5 checks at bb459020, but per ci.yml that is an -R-filtered subset, not the 621-test suite the body cites.

6. Recommendation

Request changes — on Blockers 1 and 2 only, and they are one fix. Moving the radio output level into a declared capability + the OperatingState document resolves both canon citations, dissolves the two-G2 collision, gives RadioSetupDialog and the master slider a single writer, and very likely removes the new touchpoint that the author asked you to rule on. Everything else in this PR is solid work: the refuse-don't-clamp validation is exactly what Principle VII asks for, the restore-before-push ordering is correctly placed and correctly explained, and the limits section is honest about what the bench did and did not prove.

Next step for the contributor: add the capability (an std::optional<…Control> record keeps the ratchet flat), route the level through RestoredRadioState, then re-run the nine mutations — the "disable the output-level split" one will need rewriting against the new gate. The nits can ride along in the same push.

Thanks for the unusually clear write-up — flagging the touchpoint yourself rather than burying it, and separating "measured" from "reasoned", made this much faster to review.


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

Comment thread src/core/AudioOutputVolumePolicy.h Outdated
Comment thread src/core/AudioOutputVolumePolicy.h Outdated
Comment thread src/gui/MainWindow_Wiring.cpp Outdated
Comment thread src/core/DeviceDiagnostics.cpp Outdated
Comment thread src/core/backends/anan/AnanBackend.h
ten9876
ten9876 previously requested changes Oct 4, 2026

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

Review of 04762258

Issue fit. No linked issue, so I reviewed this against the PR's own intent: ANAN-G2 state the client has to remember because the radio can't. The capability-shaped part is well built. Tuning, SpanRate and the new ReceiveOutputLevel domain all go through RadioStateMemory's OperatingState document. That resolves both blockers from the earlier automated review on bb459020 (AudioOutputVolumePolicy.h and the flat RadioSpeakerVolume key are both gone). The refuse-don't-clamp validation holds up. Four things stop it merging: a registered test fails at this head, Static checks is red, the volume rework breaks hotkey/controller volume with PC Audio off on every radio that doesn't declare the new domain (reproduced below), and the PR body describes an earlier version of the code.

Scope

File / group What it changes Claimed? Verdict
backends/anan/AnanBackend.{h,cpp} Tuning + SpanRate + ReceiveOutputLevel domains, accept-lists, capture emits, first-connect default Yes In scope. The 10 MHz → 14.175 MHz USB default needs a maintainer decision (below)
RadioCapabilities.h, RestoredRadioState.h, RadioStateMemory.{h,cpp} New ReceiveOutputLevel = 1u << 9 domain, schema 2 → 3 No. The body still describes a flat key plus a family predicate In scope for the code, but undisclosed. See Blocker 4
models/RadioModel.cpp Seeds m_lineoutGain from the restored level No In scope, no test (nit)
gui/MainWindow*.cpp/.h, TitleBar.cpp, core/TciProtocol.cpp activeOutputVolumePercent(); MasterVolume written only with PC Audio on Partly Regression, Blocker 3
gui/MainWindow.cpp → MainWindow_Controllers.cpp Step handler extracted to applyOperatorTuningStep(), moved to another TU Yes Trips the command-plane ratchet, Blocker 2
gui/MainWindow_Shortcuts.cpp step_up/step_down now push to the slice, persist and toast Yes Also lands on Flex (now sends slice set N step=). Probably a fix, but the body frames everything as ANAN-only (nit)
gui/MainWindow_Wiring.cpp Step seeding on onSliceAdded; dBm-range restore/save Yes In scope. The dBm range also lands on Icom (radioOwnsDbmScale=false), and step seeding on every radio with no command plane. Both undisclosed (nit). Step seeding needs a maintainer decision (below)
core/ClientDisplaySettings.h + test dbmRange/saveDbmRange, loadForWrite/store Yes In scope
gui/SpectrumOverlayMenu.{h,cpp} setFftAverage() / setFftWeightedAverage() Implied by the "FFT averaging" row Dead code. Nothing calls either (nit)
core/DeviceDiagnostics.cpp:325-334, 427-432 Comment plus reformat, no behaviour change No Churn (nit)
docs/architecture/aetherd-touchpoint* Note text for ClientDisplaySettings.h Yes Fine. --check reports "manifest up to date"

The body's "FFT averaging: reset to off every launch → where you left it" row is not this PR. ClientDisplaySettings::fftAverage()/saveFftAverage() were already on main from #5924 (a325fb99e), and this diff adds no averaging persistence.

Blockers

1. anan_backend_test fails at this head. See tests/anan_backend_test.cpp:184-189. AnanBackend::capabilities() (AnanBackend.cpp:487-491) now declares RfGain | Tuning | SpanRate | ReceiveOutputLevel, but the test asserts exact equality with the first three:

$ ctest -R '^anan_backend_test$' --output-on-failure
FAIL: RF gain, tuning AND span restore are declared -- this radio reports no frequency, mode or span, so the client is its only memory

CI doesn't run this target (ci.yml is -R-filtered), so the green build doesn't cover it. The body's "621/621" was measured on c7513e12, not this head. Fix:

        check(c.clientSettingsDomains
                  == (RadioCapabilities::ClientSettingsDomain::RfGain
                      | RadioCapabilities::ClientSettingsDomain::Tuning
                      | RadioCapabilities::ClientSettingsDomain::SpanRate
                      | RadioCapabilities::ClientSettingsDomain::ReceiveOutputLevel),
              "RF gain, tuning, span AND radio output level restore are declared -- "
              "this radio reports none of them, so the client is its only memory");

2. Static checks is red on the command-plane ratchet. See src/gui/MainWindow_Controllers.cpp:3341. Moving the step handler out of MainWindow.cpp carries its slice set %1 step= wire string to another file:

::error file=src/gui/MainWindow_Controllers.cpp,title=command-plane-ratchet::src/gui/MainWindow_Controllers.cpp grew from 4 to 5 raw Flex command(s) above the seam. This baseline may only shrink (#5262 M4).
::notice ... src/gui/MainWindow.cpp is down to 15 from 16

I reproduced this locally with python3 tools/check_command_plane.py. The baseline is per file, so a lateral move counts as growth. Simplest fix: define applyOperatorTuningStep() in MainWindow.cpp, where the code came from. The comment at :3336-3340 gives merge-conflict avoidance as the reason for the move. That reason belongs in the commit message, not in the source. (The other red step in that job, gh pr comment … Resource not accessible by integration, is the fork token's permissions, not this PR.)

3. Regression: with PC Audio off, hotkey, FlexControl, HID and wheel volume jump to, and stick at, the PC level on every radio that doesn't declare ReceiveOutputLevel (Flex, Demo, HL2, Icom, RTL).

  • applyMasterVolume() (MainWindow.cpp:7471) now writes MasterVolume only when PC Audio is on.
  • activeOutputVolumePercent() (MainWindow_Controllers.cpp:3366-3376) reads the radio level only for a ReceiveOutputLevel radio. Everywhere else it falls back to MasterVolume, which no longer tracks the slider in that mode.
  • So every +/-5 starts from the stale PC level and pushes the result to mixer lineout gain.

Reproduced through the automation bridge against the built-in demo (DEMO-0001, isolated settings, offscreen). The same steps on the merge base 5fae7003 and on this head:

step                                   merge base 5fae7003     PR head 04762258
invoke "Master volume" setValue 60     vol 60  (PC on)         vol 60  (PC on)
invoke "PC Audio" click                vol 50  (PC off)        vol 50  (PC off)
invoke "Master volume" setValue 30     vol 30                  vol 30
shortcut master_volume_up              vol 35                  vol 65
shortcut master_volume_up              vol 40                  vol 65   <- stuck at MasterVolume+5

On a Flex that is a +35 jump on the radio's line out from one key press, and "up" can never go past 65. The same read sits in handleFlexControlButton (:563, :569), dispatchHidAction (:1106, :1111), applyFlexControlWheelAction (:1523) and the master_volume_up/down shortcuts (MainWindow_Shortcuts.cpp:1304, :1311). syncTitleBarOutput() (MainWindow.cpp:7442-7454) and your own TCI copy (TciProtocol.cpp:34-45) already use the right rule, with no capability gate. Make this one match:

int MainWindow::activeOutputVolumePercent() const
{
    const bool pcAudio = AppSettings::instance()
                             .value("PcAudioEnabled", "True").toString() == "True";
    if (!pcAudio)
        return m_radioModel.lineoutGain();
    return AppSettings::instance()
        .value(QStringLiteral("MasterVolume"), QStringLiteral("100")).toInt();
}

That matches what applyMasterVolume() actually drives (setLineoutGain() whenever PC Audio is off, on any family). Please also add a socket-free test of the selector, so this can't drift a third time.

4. The PR body describes code that is no longer in the PR. All of these are false at 04762258:

  • "defaulting to 0" / "silent until deliberately raised". The code restores kDefaultLineoutGainPercent = 50 (AnanBackend.cpp:546-554), and its own comment argues against 0.
  • "Scoped to this family", "no migration from the shared key", and the whole "new gui→engine touchpoint AudioOutputVolumePolicy.h" section, including 233 → 236 and the tag discussion. The header doesn't exist and the touchpoint count is unchanged.
  • "Nine mutations". At least two ("disable the output-level split", "restore the 100% default") target removed code.
  • The FFT-averaging row (see Scope).
  • The actual design, a new ClientSettingsDomain::ReceiveOutputLevel bit plus an OperatingState schema bump 2 → 3, isn't mentioned at all.

The body is what the squash commit and later archaeology will read, and the maintainer is being asked to rule on "two deliberate behaviour changes" that aren't what ships. Please rewrite it against the current diff and re-run the mutations against it.

Needs maintainer decision (@ten9876)

  • First-connect default 10 MHz WWV → 14.175 MHz USB (AnanBackend.h:122). This changes a user-visible default. The comment cites "the operator's choice", and no issue calls the old default a defect. A preference, per GOVERNANCE.md. It doesn't block the persistence work and can be split out.
  • The stored span beats the connect dialog's anan.ddc0RateKsps picker (AnanBackend.cpp:648). Disclosed and reasoned in the body. Flagging only because the body asks for a ruling.
  • Seeding the slice step from the flat TuningStepSize key (MainWindow_Wiring.cpp:1854-1866) on every radio with no command plane. docs/agents/settings.md:150-154: on a radio that persists nothing, client-owned radio state is "persisted ONLY through RadioStateMemory's OperatingState document. Never in flat AppSettings keys, never via ad-hoc code paths." The key predates this PR as a widget preference. What's new is reading it back into SliceModel. It's either a UI preference (fine) or per-radio slice state (should ride the Tuning domain).

Nits (non-blocking)

  • SpectrumOverlayMenu.cpp:3098-3114 / .h:164-168: setFftAverage() and setFftWeightedAverage() have no callers (grep -rn finds only SpectrumWidget methods of the same name). Drop them or wire them up.
  • Comments that narrate history (AGENTS.md §"Comments"): RadioCapabilities.h:453-458 ("added on main while this branch was open"), MainWindow_Controllers.cpp:3336-3340, MainWindow.cpp:2028 ("This was a family compare until…"), MainWindow.cpp:6530-6536 ("an earlier version of this handler"), and DeviceDiagnostics.cpp:325-331, which explains a field that isn't there. Many new comments also run well past ~5 lines.
  • TitleBar.cpp:332-334 says the MainWindow handler is "the only side that knows the radio family". It now gates on a capability.
  • RadioCapabilities.h:216 (ADDING A FIELD) asks for the map to be updated. docs/architecture/radio-capabilities-map.md:214 doesn't list ReceiveOutputLevel (it's already missing Cw too).
  • RadioModel.cpp:660-677 (seeding m_lineoutGain so the slider shows the restored level with PC Audio off) has no test. This is the #6124 surface.
  • The step_up/step_down change now sends slice set N step= to a Flex, persists and toasts. Probably right, but please say so in the body.

What was verified vs. read

  • Built and ran anan_backend_test, radio_state_memory_test and client_display_settings_test at the head: one failure, Blocker 1. Mutation: making RadioStateMemory::load() drop receiveOutputLevelPct turns both radio_state_memory_test and anan_backend_test red ("radio A's output level comes back as 35…"), so the new per-radio level tests pin real behaviour.
  • Ratchets: check_command_plane.py reproduces the CI error. check_engine_boundary.py gives identical findings on the merge base and the head (only a line number shifts). gen_touchpoint_manifest.py --check is clean.
  • Bridge, demo only: the volume regression above on base vs. head. step_up persists TuningStepSize across a relaunch. On the demo the slice's stepHz stays 100 because the demo takes the command-plane branch, so the onSliceAdded seeding path isn't reachable there.
  • Could not drive: every ANAN path (restore ordering, the dBm range before wirePanDisplayStatus()'s push, span precedence, first-connect defaults). The demo declares radioOwnsDbmScale=true and no client domains, and there's no G2 here. Those parts are reviewed from the code.
  • Attacks that held: a same-family backend reuse can't carry radio A's dial, span or output level onto radio B (applyRestoredState() resets all four unconditionally, and the test pins it). A level of 0 survives as 0, not as "not restored". A bad mode doesn't drop a good frequency. A stored span that isn't one of the six rates is refused, not snapped. The float-collapse dBm pair is refused. The span capture moved to finishRateChange(), so a rolled-back rate is never captured. setLineoutGain() emits only on an actual change.
  • CI: all runs on this head were created at 18:16Z, before main moved to 8df6db2f (18:26Z), so the green jobs describe a slightly older merge.

@ten9876

ten9876 commented Oct 4, 2026

Copy link
Copy Markdown
Collaborator

Maintainer rulings on #6129 (following the review above)

  • The tuning step is per-radio state. For radios without a command plane, it should ride the Tuning domain in the OperatingState document, not the flat TuningStepSize key (docs/agents/settings.md:150-154). This is now a blocker.
  • First-connect default of 14.175 MHz USB is accepted.
  • The span picker wins. When the operator picks a DDC0 rate in the connect dialog, that choice overrides the saved span.
  • Blockers 1–4 from the review still stand.

@ten9876

ten9876 commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator

Maintainer ruling: #6140 owns dbmRanges[pan] and the dbmRange()/saveDbmRange() helpers. Please rebase onto #6140's version, including its accepted range, rather than defining a second copy. This is in addition to the rulings and blockers above.

— Jeremy KK7GWY + Claude

ten9876 pushed a commit that referenced this pull request Oct 6, 2026
…ite 2. Principle III. (#6140)

**Update, `d60f4660` (2026-10-05):** `main` `12df6943` merged in; one
conflict, in `docs/architecture/radio-capabilities-map.md`, both sides
kept. No code of this PR changed.

**Update, `dabf83e3` (2026-10-05):** one fix on top of the reviewed
head: the 3D floor resync after a zoom is no longer adopted or stored as
the operator's range. Maintainer ruling of 2026-10-05: this PR owns
`dbmRanges[pan]`, its helpers and its accepted range; #6129 rebases onto
it. Line references below are at `7a5ca748`; `MainWindow_Wiring.cpp`
gains four lines after `:4269` since.

**Update after review, `7a5ca748` (2026-10-05).** The change request is
applied, and it narrows this PR:

- **Only the Hermes-Lite 2 is covered now.** Storing and restoring FFT
FPS and the dBm range is gated on an owner the backend declares, not on
backend identity. The HL2 declares both. Icom, ANAN, RTL-SDR and the
demo backend declare none and behave as on `main`. A demo run that
showed FFT FPS restored at `295bff6e` will, by the code, not reproduce
at this head; that was not run.
- **#6129 (open) adds the same `dbmRanges[pan] = {min, max}` table**
with a different accept-set (`min < max`, -250..100), a different gate
(`!radioOwnsDbmScale`: ANAN, Icom) and a restore into the widget instead
of the pan model. With the HL2-only opt-in no radio is claimed by both
PRs any more. The two accept-sets still differ and this PR adopts
whichever the maintainer rules. A trial merge of `7a5ca748` with #6129
at `04762258` conflicts in `MainWindow_Wiring.cpp` and the two
touchpoint files; `ClientDisplaySettings.h` merges as text and then
holds two definitions of `DbmRange`, `dbmRange()` and `saveDbmRange()`.
The comparison is in the review reply.
- **A stored dBm value is validated before it is narrowed to a float**
(NaN, the infinities and ±1e308 are refused through the real store and
reader).
- The source-text pins match whole call statements.

Refs #5678 (HL2 parity; rows P16, the dBm range, and the FPS half of P7
in our status table). No issue exists for this. #5111 (the Flex side) is
not touched.

## Limits, first

- **Where it is stored, since you ruled on that for #6119:** in the
radio's settings document, not a flat key. Two optional tables,
`fftFps[pan]` and `dbmRanges[pan] = {min, max}`, in the existing
per-radio `ClientDisplay` feature document that already holds
`waterfallRates`. No schema bump.
- **Who may store and restore is declared per feature, by the backend.**
FFT FPS: a new optional record, `PanFrameRateShaping`, field
`clientPersistsFrameRate`, read through
`RadioCapabilities::clientPersistsPanFrameRate()`. The dBm range: a new
field in the existing `PanAmplitudeModel`, `clientPersistsDbmRange`,
read through `RadioCapabilities::clientPersistsDbmRange()`. Same shape
as `BackendPanAveraging::clientPersistsAveraging` (#5924). No new
top-level capability bool: the count stays at the frozen 70.
- **Two second conditions remain, and you may want them changed.** FFT
FPS also needs `RadioModel::shapesDisplayRatesLocally()`, as the
averaging path does: the restore sits where the local shaper is seeded.
The range also needs absolute bins, inside its accessor: the client
writes the range into the pan model, which is wrong on a radio whose
bins are scaled by it or whose backend publishes its own range. Neither
condition grants anything without the declaration.
- **The waterfall-rate gate on `main` is not touched.** It still reads
`shapesDisplayRatesLocally()` alone.
- **Half of what was observed.** FFT AVG and Wt Avg are not fixed on the
HL2. #5924 gave them a table in this document (`fftAverages`), kept only
for a backend that declares `clientPersistsAveraging`; the HL2 declares
it false.
- **Not seen in a running app at this head, on any radio.** The defect
was (HL2, 2026-10-01, 2 of 2 restarts: FPS 12 came back 25; a 120 dB
scale came back -40 / 90 dB).
- **It changes what the pan model holds, on an HL2 only:** an operator's
range is now written into `PanadapterModel`. That range is absolute dB
(dBFS under a dBm label).
- **Families:** the Hermes-Lite 2 only, for both settings. It is the
radio the defect was observed on, and it stores nothing itself. Flex,
Icom, ANAN, RTL-SDR and the demo backend declare no owner: nothing is
stored or restored for them, as on `main`. Widening to another family is
one declaration in that family's backend, for its owner or the
maintainer to make; #6129 claims the ANAN side of the range.
- **Yours to move:** the FPS bounds 5..60 (the slider's own) and the
plausibility bounds, moved unchanged from `MainWindow_Wiring.cpp:168` on
`main` to `core/DbmRangePlausibility.h`.

## What was wrong on `main`

A Flex stores both and reports them on connect. A radio with no display
engine reports nothing (observed on the HL2, and fixed here for the HL2
only; line references are at `7a5ca748`):

- **FFT FPS** stays at the widget default (`SpectrumWidget.h:1666`, 25)
and pan wiring seeds the shaper from the widget
(`MainWindow_Wiring.cpp:3320`).
- **The dBm range:** pan wiring primes the widget from the model
(`MainWindow_Wiring.cpp:4074`, `MainWindow_Session.cpp:1855`), the model
starts at -130 / -40 (`PanadapterModel.h:279`), and on a radio that does
not own the scale nothing ever moves it: on an HL2 the range command is
attempted and dropped at the command plane (the HL2 leaves
`radioOwnsDbmScale` at its default `true`, `RadioCapabilities.h:439`, so
the gate at `MainWindow_Wiring.cpp:3779` does not stop it), and nothing
echoes.
- Of a pan's display settings only the waterfall rate
(`core/ClientDisplaySettings.h:90-132`) and, since #5924, FFT averaging
for a backend that declares it (`:48-88`) have a client store.

## The change

- **FFT FPS**, where the backend declares the client its owner, is saved
by the Display slider (before the adaptive-throttle return), Clone to
all Pans and Reset to Defaults, and restored into the widget before the
shaper is seeded from it.
- **The dBm range**, where the backend declares the client its owner, is
saved on a scale request or a finished drag, and restored into the pan
model before the widget is primed from it, at both prime sites. FFT
Floor Auto's own moves are not stored.
- The pending-write key gains the field. It was (radio, pan), so two
fields of one pan edited inside the 250 ms window replaced each other.
- `dbm_range_plausibility_test` now includes the real predicate instead
of a hand copy. One row added to the touchpoint manifest (240 with
`main` at `12df6943` merged in).
- A Flex is unchanged, and so are Icom, ANAN, RTL-SDR and the demo
backend: none declares an owner, so every new reader returns nothing and
every new writer returns before the store. Save and restore of each
setting ask one predicate (`MainWindow::clientPersistsFftFps()`,
`MainWindow::clientOwnsPanDbmRange()`), each reading one
`RadioCapabilities` accessor.
- `docs/architecture/radio-capabilities-map.md` has the new record and
field with each family's value.

## Tests

`client_display_pan_settings_test` (new, socket-free, real store): round
trip per radio and pan; the Flex-shaped scope; ownership of both
settings read through the two accessors off real HL2, Icom, ANAN and
Flex backends (the HL2 stores and restores; the others and an undeclared
backend write nothing, read nothing and leave a held document byte for
byte); refusals on write and read; a newer schema; coexistence with an
`fftAverages` table in #5924's shape; two fields in one deferral window.

`spectrum_overlay_fft_fps_bounds_test` (new, real widget, in a
`TestSettingsProfile`): the slider runs the stored bounds.

**One source-text pin block, and its claim:** the order and presence of
the save and restore calls inside `MainWindow` methods, which link into
no test, and that the save and the restore of each setting ask the one
predicate.

Fail-before, one build and one run per row. The two ownership rows are
at `7a5ca748`; the other rows were measured at `295bff6e` and not run
again:

| mutation | failing checks |
|---|---|
| this branch | 0 |
| `main`'s wiring, and a store that neither reads nor writes the new
tables | 17 |
| pan wiring does not restore FFT FPS | 1 (source text) |
| the store ignores who owns the value on read | 1 |
| the pending-write key has no field | 2 |
| both ownership accessors return true for every backend | 33 (8 each
for Icom, ANAN, Flex and an undeclared backend, and the relative-bins
check) |
| both ownership accessors return false for every backend | 8 (7 HL2,
and the absolute-bins control) |
| a stored range is not validated on read | 1 |
| a scale request does not adopt the range | 1 (source text) |
| the writer replaces the whole document | 6, `fftAverages` and the
waterfall rate among them |
| the stored FPS maximum is 61 | 1 (the slider bounds test) |

Full ctest, macOS, Qt 6.12.0, at `d60f4660` (`main` `12df6943` merged
in, no code of this PR changed): 645 of 646 pass at `-j8`; the one
failure is `peripheral_auth_dialog_test` (#6008), which also fails
alone. The run before it, at `7a5ca748` (`main` `85f2a124` merged in):
644 of 646 pass at `-j8`. Two failures, neither from this PR:
`peripheral_auth_dialog_test` (#6008; diagnostic on #6134), which also
fails alone, and `vkamp_connection_test` (#5254), which passed when
rerun alone. `hl2_receiver_count_restart_paced_test` and
`libre_radar_test` passed at `-j8` in this run.

Static checks (`static-checks.yml`, stdin from `/dev/null`): the script
steps of the `Static checks` job pass, including the touchpoint manifest
(240, up to date) and `check_capability_records.py --strict` (70
booleans, at the frozen 70); colour ratchet +0. `check_a11y` on the
three changed GUI files: no findings. The `Sanitizer option configures`
job was not run locally.

## With the open PRs

- **#5924** (merged, and merged in here at `295bff6e`): it conflicted in
`ClientDisplaySettings.h`, `MainWindow.h` and `MainWindow_Wiring.cpp`,
all resolved by keeping both sides. The tables do not overlap and its
pending-write key has its own prefix.
- **#5919** (merged) is in this branch through `main`.
- #6117 and #6115 (both merged) are in this branch.
- **#6129** (open, head `04762258`): see the update block at the top.
- **#6139** (open, ours): both PRs add to `RadioCapabilities.h` and to
each backend's `capabilities()`, in different places. A trial merge of
`7a5ca748` with its head `98be49a1` is clean; the merged tree was not
compiled.

## Not verified

- The restore in a running app, on any radio, at this head.
- The older rows of the fail-before table at this head (see Tests).
- Linux and Windows: built and tested on macOS only.
- The 3D view in a running app. Its floor resync after a zoom is
excluded from the adopt and the store by a guard (`dabf83e3`) that is
pinned as source text only; the path was not driven with frames. Reset
to Defaults does not reset the dBm range; it did not before.

**Known red on `main`:** `peripheral_auth_dialog_test` (#6008) fails on
this Mac at this head, as it did on clean `main` at `22507580` ("Clear
code or Status is clipped at the default dialog size": a serial-port
name on this host widens the page; diagnostic on #6134). Clean `main` at
`85f2a124` was not built here. `hl2_pan_create_async_test` passed;
`vkamp_connection_test` (#5254) failed at `-j8` and passed alone.

73, ON8ST

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
An ANAN-G2 reports no frequency, mode, tuning step or output level of its
own, so every connect started from defaults. The client now keeps each of
them per radio, each behind a declared capability:

- Tuning: AnanBackend declares the Tuning domain. Frequency and mode are
  restored at connect and captured once per settled tune and on each mode
  change. A stored value is refused, not clamped: a mode modeFromString()
  cannot map, or a frequency at or above the DDS Nyquist. A radio with no
  document starts at 14.175 MHz USB instead of 10 MHz WWV (maintainer
  ruling).
- Tuning step: rides the Tuning domain as tuningStepHz wherever no command
  plane carries `slice set step=`. RadioModel restores it at connect, seeds
  it into each new slice and captures any slice's step change, as it does
  for the CW surface. ANAN, HL2 and legacy RTL get it; Flex, Icom,
  settings-active RTL and the demo are unchanged. The flat TuningStepSize
  key stays a widget preference and is no longer read back into a slice.
- Radio output level: a new ClientSettingsDomain, ReceiveOutputLevel
  (bit 9), and RestoredRadioState::receiveOutputLevelPct, whose sentinel is
  -1 so a deliberately silenced speaker survives as 0. Seeded into both the
  backend and RadioModel::lineoutGain(). OperatingState schema 2 -> 3.
- dBm range: ANAN declares PanAmplitudeModel::clientPersistsDbmRange, so
  aethersdr#6140's store and restore cover it.

Master volume: RadioModel::activeOutputVolumePercent() is the one rule for
the level the operator hears -- MasterVolume with PC Audio on, the line-out
gain with it off, on every family. The hotkeys, FlexControl, HID, wheel,
MIDI and TCI all read it. applyMasterVolume() writes MasterVolume only with
PC Audio on, so moving the radio's level no longer overwrites the PC level.
The PC-Audio toggle re-applies the level where the radio declares
ReceiveOutputLevel, instead of comparing the family string.

The step_up/step_down shortcuts now go through applyOperatorTuningStep(), so
the chosen step reaches the slice and the RX applet; on a Flex that sends
`slice set N step=`, as the STEP buttons already did.

The span is not remembered: the connect dialog's DDC0 rate always wins
(maintainer ruling), so a G2 zoom is lost on reconnect, as on main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@tropo1234

Copy link
Copy Markdown
Contributor Author

@ten9876 the head is now a6299adb, one signed commit, rebased onto #6140 (2e3d7ae7) and merge-tree clean against 30882701. Every blocker, ruling and nit is below, with where it landed. The body is rewritten against this diff.

Blockers

# Done Proof
1 anan_backend_test expects RfGain | Tuning | ReceiveOutputLevel. SpanRate left the PR (ruling 3), so your suggested line loses it. passes in the serial run
2 applyOperatorTuningStep() is defined in MainWindow.cpp again. The merge-avoidance comment is gone. check_command_plane.py --strict: 369, at baseline
3 Your rule, with no capability gate, now lives in RadioModel::activeOutputVolumePercent(). MainWindow and TCI both call it, so no copy is left to drift. new socket-free radio_model_client_state_test: no backend and PC Audio off reads the line out. Putting the gate back (M6) turns it red. G2 with PC Audio off: 30 → 35 → 40
4 Body rewritten. The 12 mutations were re-run against this head, each target green beforehand: 12 killed. table in the body

Rulings

  • Step is per-radio. It rides Tuning as RestoredRadioState::tuningStepHz, through RadioModel, following the CW capture/restore. It is restored at connect, seeded into each new slice and captured from any slice's step change. TuningStepSize no longer seeds a slice. ANAN, HL2 and legacy RTL get it; Icom, settings-active RTL and the demo get nothing, as on main. The body says so.
  • 14.175 MHz USB is kept.
  • Picker wins. SpanRate is out entirely: the domain bit, the restore and its validation, the capture (including the finishRateChange() emit), the tests and the body row. I removed it rather than keeping "the saved span when nothing was picked", because that case never occurs: populateFamilyParams() inserts anan.ddc0RateKsps on every connect path. A G2 zoom is lost on reconnect, as on main.
  • fix(gui): FFT FPS and the dBm range survive a restart. Principle VIII. #6140 owns dbmRanges. Our accessors, their tests and both MainWindow_Wiring.cpp hunks are deleted, and that file is now identical to main. ANAN declares clientPersistsDbmRange = true, and its row in client_display_pan_settings_test is flipped. That row was the only "absolute bins, no owner" control, so a synthetic case now sits beside the relative-bins pair. Icom drops out. The G2 relaunch was the first run of fix(gui): FFT FPS and the dBm range survive a restart. Principle VIII. #6140's restore in the app, and the range came back.

Nits

  • SpectrumOverlayMenu setters: removed.
  • History comments: all five are gone, and every comment the diff adds is trimmed.
  • TitleBar.cpp: back to main. Its only changes were that comment and a reformatted read.
  • Capabilities map: lists Cw and ReceiveOutputLevel, and notes the step under Tuning.
  • RadioModel.cpp level seeding: now tested ("the restored level reaches the model…"; M7 kills it).
  • step_up/step_down on a Flex: stated in the body.
  • DeviceDiagnostics.cpp: back to main.
  • The FFT-averaging row: gone from the body (RTL M1: confirmed receive state, free capture browsing and finer zoom #5924 owns it). The unused #include "TitleBar.h" is also gone.

Bot threads on bb459020

All five can be resolved:

Not run

The three header-only mutations: #6140's declaration term, which the synthetic case now guards, and the two new isEmpty() terms. Each would rebuild most of aethercore twice. No HL2 or RTL bench for the step.

@tropo1234
tropo1234 requested a review from ten9876 October 7, 2026 03:21

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

Review

Reviewed a6299adbd2decbea1792a03580659c3aa96183c6.

Verdict: Approve. The change matches the persistence intent and the maintainer rulings already recorded on this PR: the tuning step rides the Tuning domain, the first-connect default of 14.175 MHz USB is the accepted default, the connect dialog's DDC0 rate wins, and #6140 owns the dBm range. No blockers.

No linked issue. The review is against the PR body plus those rulings.

Scope

Area Change
ANAN restore Declares Tuning and ReceiveOutputLevel. Frequency and mode are refused, not clamped, and judged separately. A stored level of 0 is kept. Nothing stored starts at 14.175 MHz USB and output level 50.
RadioModel Restores, seeds, and captures the client-owned step where there is no command plane. Clears that step on a radio swap. Seeds a stored line-out level into the model. activeOutputVolumePercent() is the level the operator hears, with no capability gate.
Volume surfaces Hotkeys, FlexControl, HID, the wheel, MIDI, and TCI GET read that helper. applyMasterVolume() writes MasterVolume only while PC Audio is on. The PC Audio toggle re-applies the destination level only when ReceiveOutputLevel is declared.
Schema OperatingState schema 2 → 3 (tuningStepHz, receiveOutputLevelPct). A newer document stays read-only.
Display ANAN sets clientPersistsDbmRange. No second dBm store.
Step shortcuts step_up / step_down send slice set N step= on a command-plane radio and update the RX applet.
Tests anan_backend_test, radio_state_memory_test, client_display_pan_settings_test, new radio_model_client_state_test.

Out of scope, as ruled: saved span, FFT averaging, and a second dBm implementation.

Prior request for changes

The review on 04762258 is addressed on this head. Persistence is a declared domain, not a family check or a flat key. The volume selector has no capability gate. The step is per radio on Tuning, and TuningStepSize is not read back into the slice. Span restore is gone. dBm accessors are gone. applyOperatorTuningStep() is back in MainWindow.cpp. The body matches this diff.

Verification

CI on this SHA succeeded: build, check-macos, check-windows, Static checks, and Sanitizer option configures. A merge with current main (25de97c35a3493e01839e5a436e128817346dffa, the style-guide commit) is clean. No local rebuild.

radio_model_client_state_test constructs AnanBackend, whose P2Client binds a QUdpSocket only in start(). This test never connects, so it does not bind. It is registered with add_test and no extra socket name. Not run locally; the green CI suite on this SHA is the execution evidence.

Non-blocking

handRestoredStateToBackend() clears m_clientStepHz before loading, and sets m_lineoutGain only when a level was stored. Disconnect already resets the model gain to 50, and that is the path a radio change takes. A same-family connectToRadio() that does not disconnect first would leave the previous radio's level on the slider while the backend falls back to 50.

Follow-up, not a merge prerequisite

The new test pins the restored model level and the step swap. It does not pin the model level across a radio with no stored document. A socket-free case: restore radio A at 35, then radio B with no document, and expect lineoutGain() == 50 without relying on onDisconnected().

@jensenpat
jensenpat dismissed ten9876’s stale review October 7, 2026 13:52

Dismissing the request for changes on 0476225. Head a6299ad addresses those blockers and the later maintainer rulings (per-radio Tuning step, span picker wins, dBm range owned by #6140). Approving review is #6129 (review).

@jensenpat
jensenpat merged commit 01f73ec into aethersdr:main Oct 7, 2026
5 checks passed
@tropo1234
tropo1234 deleted the anan-session-state branch October 7, 2026 14:47
ten9876 added a commit that referenced this pull request Oct 12, 2026
… Principle VIII.

An audit of every page against the 106 PRs in v26.10.2, each fact checked
against the source at the release commit:

- Menu paths: AetherMap, AetherModem, Callsign Lookup and FreeDV Reporter
  are on View, and Waveforms is on Tools.
- New coverage: Windows Firewall rules and the Network Diagnostics page
  (#6289), live CW contacts (#6268), NB2 on the HL2 and ANAN-G2 (#5966),
  ANAN-G2 AGC-T and per-radio memory (#6282, #6129), HL2 state restore
  and its level changes (#6259, #6254, #5975), DVK import (#6247, #6262,
  #6251), Copy Assist from unprocessed audio (#6216), keyboard-operable
  status-bar indicators (#6267), the KPA1500 (#5905), CTR2 extensions
  and the AetherKnob (#6338), live PPM (#6082), and Icom COMP and the
  squelch scale (#6174, #6184).
- Known issues whose GitHub issues are closed are removed.
- The style guide now asks for US spelling, and every page follows it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ten9876 added a commit that referenced this pull request Oct 12, 2026
… and re-take the Stable snapshot. Principle VIII. (#6396)

## Summary

This brings the user guide (docs.aethersdr.com) up to date with
everything in v26.10.2 and re-takes the Stable snapshot so the default
site shows it. Docs only, apart from the TCI page prose in
`tools/docs/gen_reference.py`; no application source, test or CI
changes.

Every page was audited against the 106 PRs in the release. Every fact
that was added or kept was checked against the source at `f753dbfc4`,
the v26.10.2 release commit, not taken from the PR bodies alone.

## What changed

- **Menu paths.**
- AetherModem, Callsign Lookup, AetherMap and FreeDV Reporter are on
**Tools**. They are created on View and then moved to Tools
(`MainWindow_Menus.cpp:1519-1520`, `:1569-1575`); the style guide's
example says **Tools → AetherMap...**.
  - **Show live CW contacts** is on **View**.
  - Waveforms is on **Tools**, not File.
  - The VUDU applet button was called TXDSP.
  - Labels now match the source's `...` vs `…`.
- **Gaps from this cycle.**
- **Windows and Network Diagnostics:** Windows Firewall rules and the
Network Diagnostics Windows Firewall page (#6289); the `aether.network`
and `aether.nvafx` log categories (#6289, #6308).
  - **Remote access:** Tailscale shim revocation semantics (#6325).
  - **Sleep, App Nap and power throttling (#6195):** per-OS notes.
- **Hermes-Lite 2:** state restore, RX audio at unity, CW at TUNE level,
multi-interface discovery and a discovery troubleshooting entry (#6259,
#6254, #5975, #6009, #6293).
- **ANAN-G2:** AGC-T range, a "What is remembered" section, and supply
voltage and link status (#6282, #6129, #6137, #6211).
  - **NB2** on the HL2 and ANAN-G2 (#5966).
- **DVK:** WAV import requirements, the slot menu without Delete, status
text and licensing (#6247, #6262, #6251).
  - **Copy Assist** from unprocessed audio (#6216).
  - **Live CW contacts** replace the decoder's contact card (#6268).
  - **Status-bar indicators** work from the keyboard (#6267).
- **Volume:** controllers, hotkeys and TCI `volume` follow the active
output (#6129, #6260).
- **Peripherals:** the KPA1500 (#5905), CTR2 link extensions and the
AetherKnob (#6338), TMate 2 udev (#6250), MIDI RADE Modem and FDV modes
(#6031, #6038).
  - **RTL-SDR:** live PPM (#6082).
  - **Icom:** COMP and the MK2 squelch scale (#6174, #6184).
- **Known issues.** Entries whose GitHub issues are closed are removed
(each checked with `gh issue view`). As decided, this includes three
closed today without a demonstrated fix: #5107, #4824 and #5665.
- **US spelling.** `docs-style-guide.md` asked for British spelling; it
now asks for US, by the maintainer's decision, and every page is
converted. The generated TCI pages are converted at their source, the
prose strings in `tools/docs/gen_reference.py`. Code spans, links and
URLs were left alone. UI labels were checked against the source strings,
and none of them are British. Two in-page anchors and two Log Analyzer
rule anchors (`#bnr-is-grayed-out-…`) follow the renamed headings.
- **Stable snapshot.** `snapshot_stable.py 26.10.2` was re-run on the
release tree with these corrections, before `main` was merged in, so
Stable carries no post-release docs. `docs/` also has `main`'s
post-release pages, in US spelling. Stable alone keeps the IC-9700
waterfall-rate entry (#5996): #6402 fixed it after the 26.10.2 tag, so
26.10.2 still has it. The PDF manual already attached to the v26.10.2
release keeps the tag's docs.

## Validation

- `npm ci && npm run build` in `docs/user`: success. This is the
broken-link and broken-anchor check.
- `node tools/docs/test_log_rules.mjs`: 707 checks passed for 45 rules.
- `python3 tools/docs/gen_reference.py --check`: up to date (8 pages).
- `git diff --check`: clean.
- Menu paths on a build of this branch (offscreen, isolated settings, no
radio), `menu list`: `Tools → AetherModem...`, `Tools → Callsign
Lookup...`, `Tools → AetherMap...`, `Tools → FreeDV Reporter...`, `Tools
→ Waveforms...`, `View → Show live CW contacts`.

## Screenshots now out of date (not recaptured)

These need a recapture pass on the radio:
- **Old panadapter title strip (#6386):** `panadapter`, `main-window`,
`first-session-*`, `multi-slice-two-slices`, `split-pair`,
`tnf-markers`, `offscreen-vfo-indicator`, `pan-3d-stacked-trace`,
`default-light-theme`.
- **Pre-canon windows (#6334, #6339, #6341, #6372):** `aetherrx*`,
`aethertx*`, `aethermodem-aprs`, `bnr-panel`, `connect-dialog`,
`connect-by-ip-card`, `connect-icom-rows`, `connect-smartlink-card`,
`rx-agc-calibration`.
- **Other:**
  - `title-bar` (lowercase tab states, #6281);
  - `menu-help` (#6294);
  - `display-panel` (#6371);
  - `support-diagnostics-dialog`;
  - `waveforms-dialog` and `tailnet-remote-access` (show shim 0.4.0);
  - `about-window` (v26.10.1);
  - `radio-setup-qrz`.

## Found during the audit, not fixed here

- **App string bug:** `TailnetShimDialog.cpp:628` tells the user to
"Connect remotely with Connect → Manual"; the real path is **File →
Connect to Radio... → Connect by IP**.
- **ANAN AGC-T tooltips:** the RX applet and VFO tooltips show the
slider position as dB, which is wrong on ANAN. #6282 lists this as a
follow-up.
- **MIDI Master Volume on non-Flex radios:** it is refused while PC
Audio is off, whereas hotkeys, FlexControl and HID drive the line out.
The docs describe the code as it is.
- **RTL mode names:** `rtl-sdr.md` writes "FM-N" and "CW-R", but the
source strings are `FMN` and `CWR`. Left as is until someone confirms
what the mode menu displays.
- **IC-705 COMP readback:** inferred from the MK2 (#6174) and not
verified on an IC-705.
- **Not yet documented:** the #6230 Radio Health row "Forward responses
skipped", and the Store/MSIX package.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 (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