Repository navigation
Conversation
Add optional NOAA radar and original-observation playback to both PSK Reporter map views, including bounded background loading, retained coverage, and the accepted globe navigation improvements. Fix readiness after small pans and disclose unknown live observation age with persistent load-failure notices. The revised combined scope is explicitly accepted; opacity and dBZ legend follow-ups remain under aethersdr#3574. Validation: macOS app build, 8/8 focused headless tests, regression counterchecks, and isolated automation-bridge label verification. Squashed-from: aethersdr#5477 Co-authored-by: Robbie Foust <2991296+rfoust@users.noreply.github.com> Co-authored-by: jensenpat <84298137+jensenpat@users.noreply.github.com>
…. Principle VIII. Place the attached diversity pair's ordered master flag on the right and partner on the left, matching the contributor-reported SmartSDR reference. Preserve locked edge behavior and slice-ID fallback ordering. Add socket-free mapping and ordering coverage. Squashed-from: aethersdr#5470 Co-authored-by: CJ Johnson <396488+chibondking@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
## Summary Follow-up to aethersdr#5402, which merged with one disclosed nit unfixed: in `Hl2Backend::setPanRfGain` the equality early return ```cpp const int clamped = qBound(kLnaGainMinDb, gainDb, kLnaGainMaxDb); if (clamped == m_lnaGainDb) return; ``` sits **above** both `m_lnaSessionPin = false;` and the `m_lnaDbByBand.insert(...)` that follows it. So an operator who sets the gain to exactly the value already live neither ends a session pin nor records their band choice — and the one value guaranteed to be already live is the one a connect param pinned. Concretely: 20 m stored at −12, connect with `lnaGainDb=20`, the operator still on 20 m deliberately sets 20. The write is a no-op, the pin survives, and `currentOperatingState()` keeps persisting −12 for a band the operator has just told the radio they want at +20. This is the follow-up I committed to in aethersdr#5402 (comment). Nothing else in the merged fix changes. **The window is narrow and worth saying plainly.** It is the **start band only**: after the first band change `applyPerBandStateFor` clears the pin, and without a pin `bandMemoryWriteback` returns the live value anyway. Reachability is also narrow — `lnaGainDb` is a connect param the shipped UI does not populate, so a pin needs an automation or embedder connect. And the failure direction is conservative: an old calibration survives rather than dying. That is exactly why @Ozy311 filed it as a non-blocking nit and why it should not have held aethersdr#5402's merge. It is still wrong, and it was uncovered as well as unfixed. ## What changed, and why this shape The early return was protecting one thing that is genuinely redundant on an unchanged value: `applyLnaGainDb()`, which writes the AD9866 LNA register through `Metis`, moves `m_dbRef` and echoes `panRfGainChanged` to every pan. That stays guarded — an unchanged value is still not re-sent to the radio and still does not re-echo. Everything **below** it was never redundant. The pin clear and the band-map write are consequences of *the operator having chosen this value for this band*, and an operator who deliberately confirms the pinned value has chosen it just as much as one who moved the slider. So the guard now wraps only the register write, and the persistence half runs unconditionally. `notifyOperatingStateChanged()` is guarded on `moved || endedPin || recordedBand` so that a write which moves nothing, ends no pin and changes no stored entry does not schedule a debounced store for a no-op. Any of the three actually changing notifies exactly as before. ## Constitution principle honored **Principle VIII — Evidence Over Assertion.** The new assertions were run red against the unmodified merge base before the production change and green after; the exact commands and output are below. Not Principle XI: XI is the squash-merge CI re-run and the maintainer's reproduction, which is not mine to claim. ## Test plan Both directions were actually executed, in this worktree, in this order. **1. Test added first, built and run against unmodified `origin/main` (`10a566e3`) — RED:** ``` $ ninja -j 6 hl2_gain_restore_test # test file only, production untouched $ ./hl2_gain_restore_test ... [ OK ] connect seeding creates a usable pan identity [ OK ] same-value case starts pinned at +20 with 20m still stored as -12 [FAIL] an operator write of the pinned value itself records the band [FAIL] the confirmed value survives a band round trip instead of reverting to -12 ... exit status 1 — 26 checks pass, 2 fail ``` **2. Production change applied, incremental rebuild, same binary re-run — GREEN:** ``` $ ninja -j 6 hl2_gain_restore_test # rebuilds Hl2Backend.cpp.o and relinks $ ./hl2_gain_restore_test ... [ OK ] connect seeding creates a usable pan identity [ OK ] same-value case starts pinned at +20 with 20m still stored as -12 [ OK ] an operator write of the pinned value itself records the band [ OK ] the confirmed value survives a band round trip instead of reverting to -12 ... exit status 0 — 28 checks pass, 0 fail ``` The mutation is the one @Ozy311's aethersdr#5402 review taught: the new assertion must fail on the merged code, not merely agree with a re-typed copy of the rule. That is also why the case is in `tests/hl2_gain_restore_test.cpp`, which drives the real `Hl2Backend`, rather than in the policy-header test. - [x] Local build passes — for the `hl2_gain_restore_test` target and its `aethercore` dependency (`ninja hl2_gain_restore_test`, exit 0). **A full `cmake --build build` was NOT run here** and the rest of the suite was NOT run here; CI covers both. - [ ] Behavior verified on a real radio — **not by me, not yet.** See below. - [x] Existing tests pass — the other 15 checks in `hl2_gain_restore_test` pass alongside the 3 new ones. Other test binaries were not built or run here. - [x] Reproduction steps documented — above. ### What still wants hardware, and who has it Separately from this diff: the half of aethersdr#5402 that closes aethersdr#5400 — the GUI startup replay — has landed on socket-free tests and a demo backend. @Ozy311 wrote that their fresh execution was limited to the standalone policy test and that they did not rerun the expanded suite or the GUI startup path; @jensenpat wrote that the bridge session was app-startup evidence, not HL2 hardware evidence, with no physical radio connected. I filed aethersdr#5400 from a real Hermes-Lite 2 and still have it, so the operator here will confirm the merged behaviour on hardware across a genuine restart and report it on aethersdr#5402 whether or not it agrees with the tests. That confirmation is not a precondition for this diff, which is a pure persistence-ordering fix with a test that fails without it. ## Checklist - [x] Commits are signed (SSH) - [x] No new flat-key `AppSettings` calls — no settings keys added - [x] Code is clean-room - [x] All meter UI uses `MeterSmoother` — no UI touched - [x] Documentation updated if user-visible behavior changed — none needed; this restores the behaviour the existing comment at the call site already describes ("This is also what ends a session pin") - [x] Security-sensitive changes reference a GHSA — n/a ## Deliberately not widened - `connectLna()`'s unclamped explicit-parameter branch (aethersdr#5402 bot nit 4) is left alone — it was documented rather than deferred, and `Hl2BandMemoryPolicy.h` says so in its own comment. - `MainWindow::restoreBandState` is untouched. It calls `m_radioModel.setPanRfGain(snap.rfGain)` without going through `restoreLegacyRfGain`, so wiring it up would reintroduce aethersdr#5400 exactly — but it has no callers anywhere in `src` or `tests` (only its declaration in `MainWindow.h:969` and its definition in `MainWindow.cpp:10614`). Dead, not broken. - No re-opening of the merged design, and no neighbouring refactor. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Document the existing get dsp backend payload, chain levels, absence semantics, and property narrowing. Clarify that HL2 DSP runs on the host, distinguish get hostnb from model snapshots, and identify the fields queried directly from WDSP. Validation: bridge documentation checker, JSON example and source field comparison, whitespace check, and PR CI. Documentation only. Squashed-from: aethersdr#5465 Co-authored-by: on8st <258096273+on8st@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: jensenpat <patjensen@gmail.com>
## Summary Closes aethersdr#5478. Adds an opt-in NASA/GSFC 2016 VIIRS city-light overlay to the PSK Reporter flat map and globe. Saved controls provide brightness (70% default), faint-light enhancement (50%), and warmth (25%; zero retains NASA's original grayscale appearance). Day/night fades lights through civil twilight using current UTC; the imagery is historical and independent of radar playback. Radar support from aethersdr#5477 has merged. This branch is rebased onto main at `8e7a670b9f91d735171fca0aa9821bfc07ff18b7`; the diff contains only city-light changes. City-light-specific changes: - Shared bounded NASA WMS loader with HTTP disk caching, response/dimension validation, cancellation, and retry status; fixed 2016 dataset with visible attribution. - Flat-map world wrapping and globe geographic texture placement, below radar and report markers/paths. - GPU brightness, faint-light, warmth, and twilight processing on the globe; slider changes and unrelated basemap uploads retain city-light textures and shaders. - One compact, click-through map notice stacks radar and city-light loading messages. Each clears independently, with delayed display to avoid flashes on quick city-light loads. - Documentation: `docs/psk-reporter-city-lights.md`. The proposed control layout and appearance defaults need maintainer design review. Full-world/date-line exports still have less detail than regional views; this is not a tiled city-light renderer. No new dependency or radio/protocol behavior is introduced by the city-light commits. ## Constitution principle honored Principles V and VII: preferences remain under the existing PskReporter object; external image responses and dimensions are bounded and validated. Principle IV: implemented independently from public NASA documentation and imagery. OpenHamClock was used only as a public visual reference; no implementation code was inspected or copied. Principle XI: a native GPU regression probe failed when basemap uploads discarded city-light resources and passed after removing those resets. ## Test plan - Native macOS ARM64 RelWithDebInfo build with RADE enabled; final executable and build processor verified ARM64, with no RNNoise x86 sources. - Focused CTest: city_lights_source_test, weather_radar_source_test, weather_radar_placement_test, weather_radar_wrap_render_test, weather_radar_loading_test, map_image_cache_test, globe_navigation_test, psk_reporter_map_behavior_test. - Opt-in native OpenGL test: cityLightsGlobeDrawsAboveDetailAndBelowRadar covers actual rendered output, slider changes without texture replacement, basemap-upload residency, warmth, and radar ordering. - Isolated DEMO-0001 app validation with transmission disabled: controls/layout inspected, both projections exercised during development, preferences checked in the isolated settings database. No live radio used. - Engine boundary, test registration, frozen CI test gate, and diff whitespace checks. - New city-light source tests inject QNetworkReply objects and do not bind sockets or emulate external firmware. GPU cases use the existing explicit environment opt-in; they skip in ordinary runs. - Windows/Linux execution and hosted CI results are not claimed. New registered tests follow the repository's full-suite policy; the per-PR frozen test gate is unchanged. ## Checklist - [x] Commits are signed and signatures verified - [x] No new flat-key AppSettings calls - [x] Clean-room implementation - [x] User-facing documentation updated; CHANGELOG unchanged - [x] Local ARM64 build and focused checks - [ ] Hosted CI reviewed - [ ] Maintainer design review - [x] Radar dependency merged and city-light branch rebased onto main --------- Co-authored-by: Jeremy [KK7GWY] <kk7gwy@aethersdr.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…current log sites (aethersdr#5481) Closes aethersdr#5480. `redactPii()` grew one hand-written regex at a time as log sites were added, and the patterns drifted apart: the name and coordinate rules learned to match quoted and JSON-shaped values while the token rule never did, so the same value was scrubbed in one spelling and passed through in another. This regenerates every keyword rule from a single table over one shared value grammar, so a spelling that works for one field works for all of them. New `src/core/LogRedactionPolicy.h` holds the tables, mirroring how `SettingsCredentialPolicy` backs `SettingsSanitizer`. Adding a field is one row plus one test case. ## Coverage added Each with a case in `async_log_writer_test`, driven through the real writer (`enqueue` → `formatLine` → `redactPii` → disk) rather than through `redactPii()` alone: - **Home-directory prefix → `~`** for `/home/<user>`, `/Users/<user>` and `C:\Users\<user>`, both slash styles and Qt-doubled backslashes, and for a root the running process does not itself have (Flatpak, elevated runs, a log copied off another machine). User names may contain spaces, so the segment runs to the next separator. - **IPv6 literals** — compressed, bracketed-with-port, IPv4-mapped and `%scope`. - **Email addresses**, in prose and as field values. `SmartLinkClient`'s call-site truncation is now the redactor's job. - **`user` / `username`**, in both `user <name>` and `username=` forms. - **Peer hostnames** after `connecting to` / `connected to` / `reconnecting to` / `disconnected from` / `connect to` / `pin for` / `resolving`, with `:port` and trailing prose preserved. - **Maidenhead grid** as a keyword field and as a bare word after `grid`, plus quoted coordinate pairs like `location="47.6,-122.3"`. - **URL authority**, including userinfo. - **Quoted, JSON and Qt-escaped (`\"value\"`) spellings** for every field rule, not only bare `key=value` protocol text. The JSON fixtures are generated through `QDebug`'s own formatting rather than hand-written, so they prove the rules survive Qt's quoting. ## Two bugs found while regenerating the rules - The IPv6 rule now keys on *contains `::`* or *exactly eight groups*. A looser "two or more hex groups" pattern also matches a MAC address and an elapsed-time value like `12:34:56`, and destroyed both — `mac 00:1C:2D:05:37:2A` became `[v6-redacted]`. MAC redaction now runs ahead of it too. Pinned by `testIpv6DoesNotEatMacOrClock`. - A retained value prefix is emitted only when the value is **strictly longer** than the prefix. A value no longer than the prefix was previously "redacted" to itself. ## Support bundle `SupportBundle` now streams historical logs through `redactPii()` into the archive instead of `QFile::copy`. Lines are redacted at capture, so a log written by this build is already clean — but an older log on disk was written by whatever redactor shipped at the time, and a bundle generated after an upgrade carried those lines out unchanged. Redaction is idempotent, so re-running it over a clean line is a no-op. The source log on disk is deliberately left alone: it is the operator's own diagnostic record, and rewriting it would destroy detail they may still need. ## Docs `docs/log-redaction.md` records what the redactor does and does not scrub, so a new log site can be checked against it. The **limitations** are the point of the page: a keyword matcher cannot see a value that carries no keyword, and several writers never reach `AsyncLogWriter` at all — the SpotHub feed logs (`DxClusterClient`, `N1MMSpotClient`, `SpotCollectorClient`, `WsjtxClient`, `FreeDvClient`) write directly and are also not collected into a support bundle. Documented rather than papered over, so a passing redactor unit test is not read as "all project logs are sanitised". Also documented: the fields deliberately **not** redacted (callsign, model, firmware, version, ports, stream ids), with a test asserting they stay readable, since removing them has broken triage before. ## Verification - **349/349 CTests pass** and the app links. - All seven static checks pass: engine boundary `--strict`, touchpoint manifest `--check`, test registration, frozen CI gate, network timeouts, theme seed, shader dialects. - **Four mutations confirm the new tests bite** — removing the home-path rules, removing the auth-scheme skip, and removing the short-value guard each fail their case. The short-value assertion was *tightened after the first version of it passed against the mutant*: it had asserted the absence of `token=ab ` with a trailing space, which is absent either way.⚠️ **Reviewers on Arch:** this branch cannot be linked on a box running `onnxruntime-cuda 1.29.0-3` — a protobuf ABI mismatch in the system `libonnxruntime.so` breaks 326 targets. Pristine `origin/main` fails identically, so it is environmental, not this change. The numbers above are from a build configured with onnxruntime hidden from pkg-config. ## Not in this PR - No individual log site is changed, per the issue's stated scope — the coverage is in the redactor so existing sites are fixed together. One site raised in review does need a call-site decision rather than a redactor rule, and is left for a separate change with its own scope ruling. - `SettingsSanitizer` is untouched; it is already policy-driven. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Share NASA GIBS and NWS outage cooldowns across map consumers while retaining local cache access. Bound Retry-After, preserve cached images on transport failures, and keep radar readiness independent of provider backoff and camera movement. Verified full macOS ARM64 build with RADE, three focused CTests, five detected regression mutations, current-main merge-tree equivalence, and an isolated DEMO-0001 map lifecycle smoke test. Review requests addressed and all 11 threads resolved. Co-authored-by: jensenpat <patjensen@gmail.com> Co-authored-by: Robbie Foust <rfoust@gmail.com>
## Summary RTL RFC aethersdr#5468 requires every receiver's complete guarded RF passband to fit the shared capture before tuning, filter/mode changes or sample-rate changes are accepted. Add a pure `SharedCapturePolicy` helper that selects the nearest legal quantized tuner center, validates coherent readback and produces a deterministic restore plan within the current capture. The helper validates asymmetric filter/usable margins, signed carrier/BFO translation, transition guards, disjoint hardware domains, bounded numeric inputs, stable slot IDs and caller-supplied receiver capacity. It keeps an already legal center, refuses an impossible desired set atomically, and restores fitting entries in ascending stable-ID order without recentering or renumbering. Conservative floating-point arithmetic rejects unrepresentable boundary fringes rather than widening acceptance with a tolerance. This is F2 of aethersdr#5468 and is independent of P01 aethersdr#5471. The helper compiles into the static `aethercore` library; the registered test compiles that same implementation. No live backend calls it yet. USB/DSP/audio/viewport/UI/persistence integration and supported receiver counts remain later RFC phases. The new contract is documented in `docs/shared-capture-policy.md`. This PR does not close the umbrella RFC. ## Review follow-up An additional regression rejects a center grid whose 1e-5 Hz step is smaller than the double spacing at 2^39 Hz while its grid indices remain within the independent index bound. The capture and slice otherwise fit. Disabling only the resolution guard makes this new check fail, closing the reported mutation-coverage gap. The final readback validation remains, with its defense-in-depth purpose made explicit. The contract now distinguishes adapter-declared logical readback from measured physical RF frequency. In [Osmocom librtlsdr v2.0.2](https://github.com/osmocom/rtl-sdr/blob/v2.0.2/src/librtlsdr.c#L884-L913) and [RTL-SDR Blog commit aed0ea19](https://github.com/rtlsdrblog/rtl-sdr-blog/blob/aed0ea19f3a273370a13c9009b96313c75d54c7b/src/librtlsdr.c#L887-L936), `rtlsdr_get_center_freq()` returns the cached requested frequency stored by successful tuning; it does not measure achieved PLL frequency. Other versions and forks need verification. Actual readback must still satisfy the declared domains and complete-set containment checks. ## Constitution principles honored Principle VII — invalid numeric descriptors, impossible intervals, malformed identities, oversized collections and incoherent readback fail at the policy boundary. Principle II — a selection is only a proposal; the integration layer must validate coherent capture state before publication. Logical library readback does not establish physical RF accuracy. ## Validation - Follow-up on macOS ARM64, Apple clang 21.0.0, standalone C++20 `-O2`: **260 checks, 0 failures**. The test is socket-free and requires no Qt event loop, hardware, DSP or settings. - Follow-up mutation in a temporary source copy: disabling only `stepHz < resolution` produces **260 checks, 1 failure**, specifically the new unresolved-grid assertion. Production guards remain unchanged. - `git diff --check` passes for the follow-up. - Prior reviewed head `5cd78ad10bf3ce86c404cddf7c2aea9b4215c844`: Linux normal engine policy object and registered CTest built with GCC 16.2.1; all 259 then-existing assertions passed. The independent rational oracle matched all 2,000 cases, upper-transition-guard and generation mutations failed as expected, and the exact restored source passed. Strict engine-boundary, test-registration, frozen CI-gate and touchpoint checks passed on that head. Those are recorded prior-head results, not a new Linux run of this follow-up. - Linux follow-up: normal engine policy object and registered CTest rebuilt; **260 checks pass**. Removing only the resolution guard compiled and failed the new assertion. Exact source restoration, rebuild and final CTest pass. Strict engine-boundary, registration, frozen CI gate, touchpoint manifest and whitespace checks pass. - Signed follow-up head `bec2815c605442864fc425133ddd887cb200680d`; full source matches validation base `e60c3bea2ac8e5b762be9a3c6f52aa1bab8fb9ad` plus six-file digest `b2237190f1a875addf40557d39d9e5e8c01fcc171906908861a1c51ac46c4cf5`. All seven PR checks passed on this follow-up head. - Review follow-up `beb054c384c9bb1334c68ab6ecb8bc8ec96ebd93` corrects only the Osmocom source anchor; implementation and tests are unchanged. Independent review of `bec2815c` merged with main `f0ed969b` passed all 260 checks in native macOS ARM64 optimized and ASan/UBSan builds. Grid-resolution, upper-guard, generation and restore-capacity mutations all failed as expected. Engine-boundary, registration, frozen CI-gate, touchpoint and whitespace checks passed. See the live Checks tab for the documentation follow-up status. No live-radio or performance claim is made for this unintegrated policy. The test is registered in the default graph without expanding the frozen per-PR allow-list. No dependencies, threads, settings owner, socket-owning test, UI controls or CHANGELOG entry are added. Full application behavior, physical RF accuracy and supported receiver capacity remain integration gates. --- 73, Ozy **K6OZY** · GPT-6 Astra-Ultra --------- Co-authored-by: Robbie Foust <rfoust@gmail.com>
RTL discovery sometimes uses `rtl:<index>` to select a device. This PR separates that connection locator from the USB-reported serial, so restore, retry and save use the same reported-serial identity. A same-family swap flushes and disconnects the old session before changing its scope. Adds the compiled `RtlSliceSettings` schema-1 owner, exact-row patch/remove semantics, explicit legacy migration, and fixed-capture restore planning through F2. Empty documents suppress reimport; unsupported/corrupt documents fail closed; failed atomic writes remain retryable; omitted slices and unknown members survive. Status-aware exact reads distinguish missing, corrupt and unavailable rows, including corrupt JSON with a newer raw schema. Generic fallback readers retain their behavior. ## Identity and upgrade impact The identity rule implements [Jeremy's RFC approval](aethersdr#5468 (comment)) and RFC §1.1. It deliberately allows shared saved state: - Unique genuine reported serials, including numeric serials, retain their scope. - Anonymous dongles write the RTL family row and share its tuning, mode, passband, rate and RF-gain memory. An identified dongle with no exact row inherits that family state; an existing exact row takes precedence. - Duplicate reported serials share one exact row, including the second dongle previously connected through an index locator. They can overwrite each other's saved state. - Old locator-keyed `OperatingState` rows such as `rtl:0` remain intact but unclaimed. When no applicable serial/family row exists, the first connection receives empty restored state and uses backend defaults until state is saved under the new scope. If an applicable row exists, it is restored instead. No automatic migration or per-dongle recovery of ambiguous index-keyed rows is provided. These changes affect anonymous and index-selected duplicate identities, not every RTL operator. The owner migration below is not a migration of these active `OperatingState` rows. [The contract document](https://github.com/aethersdr/AetherSDR/blob/6efba467bbe7773eec5c634fa154e05b83d8f27a/docs/rtl-slice-settings.md) details the consequences. The family-row sharing follows Jeremy's explicit RFC approval; this PR does not claim per-dongle isolation. The earlier review request for a separate anonymous namespace or disabled persistence is superseded by that approved rule. ## Staging and ownership This is F3a of RFC aethersdr#5468, following merged F2 aethersdr#5472 and targeting main. Current `OperatingState` remains the sole runtime owner of RTL tuning/passband/rate. `RtlSlices` migration and runtime writes remain inactive until F4/M1 supplies accepted capture state, measured margins and mode/BFO metadata. The documented cutover preserves the frozen legacy snapshot through an explicit RF-gain writer. No multi-RX or DSP behavior is enabled here. Principle V governs the single feature owner and stable keyed entries; Principles VII/XIV govern malformed/newer-state refusal and atomic writes. ## Original Linux validation (head `4c2e6e97808d`) - Linux x86-64 on Nobara-Dell, GCC 16.2.1, Qt 6.11.1, RelWithDebInfo with RTL enabled: complete `aethercore` library and four focused test targets build. - Four registered CTests pass: `rtl_slice_settings_test`, `radio_state_memory_test`, `rtl_backend_test`, and `shared_capture_policy_test`. The owner/model test passes **119 assertions**; the F2 policy test passes **260 checks**. - The new assertions exercise production model save/connect/restore paths using an injected transport-free backend: anonymous-to-unseen-identified fallback, exact precedence, anonymous/duplicate sharing, and preserved/unclaimed old locator rows. They also test the inactive `RtlSlices` owner's corresponding behavior and rejection of a malformed old document even when the proposed patch would replace its bad field. - Removing only the old-document decode gate makes the new corruption assertion fail. The source was restored exactly, the engine/tests rebuilt, and all four CTests passed again. Earlier F3a mutation evidence also covers wrong preconnect scope, newer-schema bypass and dropped unknown gain fields. - Strict engine boundary, test registration, frozen CI gate, touchpoint manifest and whitespace checks pass. Existing boundary debt and the frozen seven-test gate are unchanged. Validated source for signed head `4c2e6e97808d8ddd47b54d35634020a76c2de262` is Linux base `5cd78ad10bf3ce86c404cddf7c2aea9b4215c844` plus source digest `27d689ae7faf74310728d34b6de744846d02d3d08868f613ee27da675b2e82cb`. Tests used isolated Linux user/network namespaces and temporary settings profiles; the new test opens no socket listener or synthetic peer. This establishes engine/focused-test compilation and behavior, not a full application, GUI, physical USB reorder, hardware or live RF result. No app launcher changed. ## Bookmark repair and current-main integration The canonical identity change also affected Band Stack, whose old empty-ID guard refused anonymous bookmark saves. Connected anonymous RTL scopes now explicitly carry anonymous-identity provenance. Band Stack accepts that deliberate family scope, while ordinary unknown empty scopes and disconnected anonymous models still refuse bookmark access. Manual/automatic adds, expiry, removal, reconnect and store-reopen persistence are covered through the production model and storage classes. Identified bookmark scopes retain exact-row behavior; anonymous dongles share their family bookmark row. Ambiguous old locator-keyed bookmarks and legacy sections remain intact and unclaimed; no automatic recovery is provided. Merged current main, including aethersdr#5472 (`ab03be0629a498603ff02ef68a7dbfbd0de91d10`), preserving both settings-consumer registrations and the corrected librtlsdr source link. The PR now targets main for full platform CI. Repair validation on macOS ARM64 (Qt 6.11.1, RelWithDebInfo, RADE enabled): - Built aethercore and four focused targets; `rtl_slice_settings_test`, `bandstack_scoped_test`, `radio_state_memory_test` and `shared_capture_policy_test` pass. - Reinstating Band Stack's old empty-ID guards makes six new regression assertions fail. Restored the fix, rebuilt, and all four tests pass. - Strict engine boundary, test registration, frozen CI gate, touchpoint manifest and whitespace checks pass. Test executable and CMake processors verified ARM64; RNNoise x86 sources absent. - No new socket test, live radio, GUI or USB validation. Local librtlsdr is unavailable, so the RTL hardware backend target was not built locally. The earlier Linux RTL-enabled validation above remains separate evidence. Related: aethersdr#5468. Does not close the multi-stage RFC. --- 73, Ozy **K6OZY** · GPT-6 Astra-Ultra --------- Co-authored-by: Robbie Foust <rfoust@gmail.com>
) ## Summary Fixes aethersdr#5501. When Flex restores manual SQL 26 after a band change, the RX applet currently enters Manual using its stale cached threshold 20. Adopt the incoming threshold before refreshing the manual controls, so the slice cache, RX slider and VFO slider agree without another SQL write. Combined slice deltas publish SQL before `modeChanged`. Reuse the existing mode-eligibility rule against the current model mode, rather than the button's previous enabled state. This keeps incoming CW state out of an Off-to-Manual transition and permits an incoming USB threshold to be adopted in the same delta. The existing mode/capability policy itself is unchanged. Production changes are limited to RxApplet. No new persistence, command encoding, timer, backend logic, or dependency is introduced. Detached slices already reopen their manual-echo gate; the tests exercise that production detach path. ## Constitution principle honored Principle II: reconcile incoming radio state without replaying the client's remembered threshold. Principle XI: drive the real applet/model transition and verify that removing the fix makes the regression fail. ## Validation - Local macOS ARM64 RelWithDebInfo app build, `cmake --build build -j22`. - Headless, socket-free CTest selection: `rx_applet_squelch_reconciliation_test`, `slice_model_squelch_memory_test`, `rx_filter_step_test` (3/3 passed at signed commit `05c68846cc0dcbfe882962a036cc09a6b6aa27da`). - The new target constructs production SliceModel, RxApplet and VfoWidget objects, with no RadioModel instance, radio connection or firmware peer. It checks the captured band/status burst in both widget connection orders, split-field updates, repeated reports, later SQL-button writes, Auto(8)/margin(10)/Manual(45) separation, detached and distinct slices, Kiwi exclusion, combined CW/USB status, and the existing digital override/detach behavior. The Auto-off starting sequence from [issue 3505](aethersdr#3505) is an injected regression check only. That separate issue remains open and requires independent verification. - Mutation: restoring the previous manual-cache condition makes both band-burst rows fail with actual 20 versus expected 26. The level-first split row and combined mode/status row also fail. The production fix was restored, rebuilt at the signed commit, and all 3 selected CTests passed again. - Engine-boundary, test-registration, frozen CI test-gate, bridge documentation, and whitespace checks passed. - Commits signed. No new socket-owning test, CI gate addition, or CHANGELOG entry. ## Review follow-up validation Signed follow-up `b539715affa079dde897685a13478881bc7ddac0` adds two socket-free full-on-report rows and documents the reconciliation contract. The focused ARM64 RelWithDebInfo build (dedicated `build-codex-arm64`, local ARM64 toolchain, RADE enabled) passed all three selected CTests. Removing the Off-to-Manual cache adoption made both new rows fail with manual cache 45 versus expected 8; restoring the guard returned all three CTests to green. The prior independent review also removed the complete production fix and observed four failures in the original band/split/mode rows. No additional hardware run was performed for this test/documentation follow-up. The new test runs locally and joins the unfiltered post-merge Full Suite and weekly sanitizer workflows. The frozen per-PR CI selection compiles it but does not execute it. ## Live proof Receive-only agent automation bridge run on FLEX-8400M firmware 4.2.18.41174, one owned slice/pan, isolated settings, stable GUIClientID, TX permission disabled. | Observation on return to 20m USB | Radio/live SQL | Manual cache | RX slider | VFO slider state | |---|---:|---:|---:|---:| | Unfixed binary | 26 | 20 | 20 | 20 | | Fixed, before restart | 26 | 26 | 26 | 26 | | Fixed, after normal Quit/relaunch | 26 | 26 | 26 | 26 | Seeded 20m USB Manual/26 versus 40m LSB Off/20 with actual SQL controls, then used the production BAND actions. The old binary reproduced the mismatch without an intervening mode change. The fixed binary reconciled two complete pre-restart returns and two complete post-restart returns; an additional post-restart return was sampled during temporary slice teardown, retained, then observed converged before proceeding. Normal restart changed PID 71740 to 71766 with unchanged GUIClientID and first process exit code 0. Before restarting, a Manual → Auto → Off → Manual button cycle preserved the manual 26 separately from the computed Auto level (62 observed) and margin 10, then sent/restored 26. The retained capture contains 201 model snapshots and 5,815 unique log events across old/fixed phases, with zero detected log gaps. Captured band-return intervals contained no outgoing SQL commands; the socket-free regression is the proof of the isolated no-write requirement. Original 20m and 40m slice/pan values were restored with empty comparison diffs (excluding session object/handle identifiers), returning to 20m USB SQL Off/20. All observed snapshots reported no transmitting; the client quit and lock was released. The live binary is verification merge `9a8ea1b59eea5226dd6e1788608c5f9a22abc650`: this fix at `05c68846cc0dcbfe882962a036cc09a6b6aa27da` plus aethersdr#5500 at `93bbff39cf59605bde00405a0bb4a935ba4aae38`, which supplies the Persist diagnostic fields. Those diagnostic changes are excluded from this PR. The combined app also built successfully and passed the same 3/3 focused CTests. Fixed executable SHA-256: `a785be3ed2f4904c0ac9528334024ee61ec1075bd8ddcae4166bc40b8c2dfa43`; unfixed executable: `9e0b0b7447ee48a354e5a8cd97778550ff5ec2eb8b877327d6e536e6131ad8e3`. Before → after RX applet captures below. The native slider has no numeric label; the table and [structured evidence](https://raw.githubusercontent.com/jensenpat/AetherSDR/7096420249e7cd6c4098f12aeb687a8bc664b676/.pr-assets/5501-proof.json) provide its numeric state. VFO state was read from its widget while that surface was hidden; no rendered VFO claim is made.   This is focused SQL diagnostic evidence, not a full RadioCert Persist certification or proof of physical audio gating. Other Flex models/firmware, MultiFlex, hardware occurrence of arbitrary delayed full SQL-on report ordering, and Icom remain unverified. The Auto regression covers active Auto, delayed threshold-only/off reports, and full on-reports after both operator-driven and radio-driven Off. Full on-reports are reconciled as current radio truth, including their threshold; a later Off/45 acknowledgement supersedes a delayed on/8 report. Both sequences assert RX/VFO agreement and zero outgoing commands or squelch intents. These are injected ordering tests, not claims that specific firmware produces these sequences. No bridge addition was needed beyond the already proposed aethersdr#5500 diagnostics. Generated with OpenAI Codex (GPT-6 Astra) --------- Co-authored-by: Robbie Foust <rfoust@gmail.com>
## Summary Part of aethersdr#2554: add the Runtime Monitor Overview tab on top of the landed Threads, Logs, and Memory increments. Four cards show CPU Total, Max Thread, resident memory, and GUI Tick Lag; four charts show CPU, memory, the top five threads, and tick lag across 1 min / 5 min / 15 min / 1 h ranges. The Overview page scrolls at small dialog sizes while preserving the graphs' 220 px minimum height. This keeps the complete five-row thread legend and plot available without forcing every tab or restored dialog geometry above screen height. CPU Total uses cumulative whole-process CPU time (`getrusage(RUSAGE_SELF)` / `GetProcessTimes`) over the measured interval, normalized by `idealThreadCount()`. CPU work from threads that start and exit between snapshots is retained. Per-thread sampling supplies Max Thread and the window-ranked top-five chart; an unavailable busiest-thread reading displays a dash. Worst tick lag buckets by maximum, while mean fields average. `MainWindow` destroys the dialog before its owned rings and meter, and unchanged card bands avoid redundant restyling. The maintainer accepted GUI Tick Lag instead of frame rate, five independent thread lines instead of stacked area, and per-tab timeframe selectors; selector consolidation is tracked in aethersdr#5496. Thresholds remain the issue's starting values (CPU 50/80%, thread 70/90%, memory 1/2 GB); GUI Tick Lag is unbanded. The heartbeat uses `Qt::PreciseTimer`, which also sharpens `PerfTelemetry`'s measurement. ## Scope | File group | Purpose | |---|---| | SystemInfo / SystemInfoCollector | Whole-process CPU accounting and typed samples with explicit busiest-thread validity | | CpuHistoryRing / UiTickLagMeter | Bounded history, window ranking, bucket maxima, and event-loop lag | | SystemInfoDialog / MainWindow | Cards, charts, scrollable Overview, sampling and ownership | | TimeSeriesGraphWidget | Wrapped legends and value-hint/tick-label overlap correction | | Tests / tests.cmake / pipelines.md | Socket-free regression coverage, registration, and sampling documentation | The shared graph changes also affect Network Diagnostics: wrapped entries remain named/clickable rather than being dropped, and tick labels no longer show through value hints. The Overview scroll fix is confined to SystemInfoDialog. No feature changes to radio protocols, settings, CI gates, or CHANGELOG. Main is integrated through `604127e7`; its unrelated changes are not part of the feature diff. ## Verification Final integrated head: `661b800f` (fix `878b5225`). - Five focused registered tests: `system_info_test`, `system_info_collector_test`, `system_info_dialog_test`, `cpu_history_ring_test`, and `ui_tick_lag_meter_test`. - CPU workloads now measure native thread CPU time, synchronize snapshot boundaries, and require bounded real work. The collector queues churn before startup and completes it before its first timer tick. No fixed wall-clock sleep is treated as guaranteed CPU availability. Both CPU targets passed three repeated pairs with 16 concurrent CPU-load processes; all load processes were stopped afterwards. - The populated dialog test supplies ten records and five duplicate thread names requiring TID labels, shrinks the dialog, checks scrolling, and verifies all five series colors render. Restoring the previous layout makes all five render checks fail. - Restoring the collector's defective live-thread sum fails the measured churn assertion (0.00% versus 0.64% required in the mutation run). Changing Worst back to bucket mean fails the 40 ms versus 26.25 ms assertion; removing the lag floor is also caught. - Strict engine boundary, test registration, touchpoint manifest, frozen CI gate, whitespace, color ratchet (+0), and changed-GUI accessibility checks pass. - The focused Runtime Monitor targets run after merge in full-suite and weekly in sanitizers; they are not in the frozen per-PR test gate. The sanitizer CI check only configures the sanitizer options. Full application build passed on macOS ARM64, Qt 6.11.1, RelWithDebInfo, RADE enabled; CMake host/system and executable verified ARM64, with no RNNoise x86 sources. The final five focused tests passed in 4.51 s while compilation was still active. All five GitHub checks passed at this head (Linux/macOS/Windows builds plus static checks and sanitizer configuration). Final full-app smoke: owned offscreen instance, isolated settings, explicit authenticated socket, PC output mute verified, TX disabled, only `DEMO-0001` (AetherSDR Demo). All four timeframe selections succeeded; 750x480 resize returned exactly 750x480; `scrollTo systemInfoOverviewThreadsGraph` returned `inViewport:true` and `vScroll:218`, with all five thread labels visible in the inspected screenshot. Close/reopen retained history (12 samples before, 13 after resuming). Cards continued updating after disconnect (CPU0.7%, Max Thread8.9%, memory163.5MB). Normal quit with the monitor open completed and the owned process exited. Qt's offscreen plugin cannot render the QRhi spectrum; the Runtime Monitor QPainter charts and automation remained functional. No final native Windows/Linux runtime or sanitizer runtime pass is claimed. Windows timing evidence supplied by the author on `7874f875`: coarse-timer idle card readings of 13.6–14.0 ms became 1.0–3.6 ms with the precise timer on the same Windows 11 / Qt 6.8.3 machine. This was reviewed but not independently rerun for the final patch. [Before/after thread](aethersdr#5427 (comment)). ## Contribution checks - Signed commits; current-main integration signed. - Clean-room implementation; no new engine-to-GUI dependency. - No live radio or TX validation; the change measures the client's own runtime. - Principle XI: fixes are demonstrated with failing mutations and restored passing tests. ## Earlier author evidence The following artifacts cover their explicitly named earlier SHAs, not the final head: [aethersdr-pr5427-overview-tab-2026-09-04.zip](https://github.com/user-attachments/files/31852689/aethersdr-pr5427-overview-tab-2026-09-04.zip) — heads `939db8e7` and `d14709d1` (Overview cues, legend wrap, Network-dialog sweep, observer-effect bench) [aethersdr-pr5427-cpu-total-fix-2026-09-05.zip](https://github.com/user-attachments/files/31868502/aethersdr-pr5427-cpu-total-fix-2026-09-05.zip) — head `d45e2f7e` (CPU Total fix: the two regression targets' full outputs, mutation transcript, full ctest log, card-vs-footer tree dump, About screenshot) [aethersdr-pr5427-review-round-2026-09-08.zip](https://github.com/user-attachments/files/31939275/aethersdr-pr5427-review-round-2026-09-08.zip) — head `aa71ec6c` (About SHA, focused targets, full ctest log, the bridge `resize` before/after, quit-with-dialog-open shutdown trace), sha256 `128bba0f4b909670618b3329c488add05acd4d93980adbe0bc1bc8fdcaf7e006` [aethersdr-pr5427-windows-ticklag-2026-09-08.zip](https://github.com/user-attachments/files/31969002/aethersdr-pr5427-windows-ticklag-2026-09-08.zip) — heads `aa71ec6c` (before) and `7874f875` (after, `Qt::PreciseTimer`), Windows 11 x86-64: README with the before/after table, both configure+build logs, both app logs, transcript of every reading, box facts, timer-resolution readings, six screenshots; attached on [comment 5589502642](aethersdr#5427 (comment)), sha256 `b9accaec9a0ee3315cd4f7cb4de37937791058966c4004f282216556c07fb575` <img width="760" height="1336" alt="1-about-d14709d1" src="https://github.com/user-attachments/assets/cbf55eb2-b7c8-4eb2-911b-fbd36d425f25" /> <img width="1800" height="1392" alt="2-overview-tab" src="https://github.com/user-attachments/assets/ca77e19a-fa8a-44fb-ad45-d3c40a424121" /> <img width="1800" height="1392" alt="3-legend-all-five-named" src="https://github.com/user-attachments/assets/0d480445-1d9e-4093-8ea4-a9906a792695" /> <img width="854" height="442" alt="4-legend-third-row-click" src="https://github.com/user-attachments/assets/6f6657b7-50bb-4484-b8b8-3c52589b9aa3" /> <img width="1960" height="1520" alt="5-network-overview-before" src="https://github.com/user-attachments/assets/0f9e0d8d-f725-455f-930e-3d3e4f2d4a4f" /> <img width="1960" height="1520" alt="6-network-overview-after" src="https://github.com/user-attachments/assets/d2d19d85-2f0d-4ac7-8e6c-9a2ab73b28ab" /> — authored by agent (Claude Code) on behalf of @skerker 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01K15daEuCZ3NwghXsXcfiBV --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Co-authored-by: Ozy311 <Ozy311@users.noreply.github.com> Co-authored-by: Robbie Foust <rfoust@gmail.com>
…rsdr#5493) ## Summary Closes aethersdr#5492. The PSK Reporter controls currently crowd horizontal rows above the map. This change moves them into a resizable, scrollable left sidebar: WSPR first, then Reports, Band conditions, Map, City lights, and Weather radar. It uses existing theme tokens and combo styling, with explicit keyboard tab order. Transmit once and the beacon status dot/text share a row. A single footer beneath the map contains the mode legend, reception statistics, update status, and connection indicator. Attribution stays on the map, with wrapping and collision handling in both flat and globe renderers. Sidebar wheel gestures scroll instead of changing WSPR spin boxes or overlay sliders; intentional click, drag, and keyboard editing remain available. This is a visual/UX proposal for maintainer review. Dark basemap rendering is deferred to a separate change. ## Constitution principle honored Principle XI — the scroll regression was reproduced in a Qt harness, and the fix was exercised with focused controls, the spin-box editor, and scroll limits. Principle V — existing feature-owned settings remain in the PskReporter object. ## Test plan - [x] macOS ARM64 application build with the dedicated toolchain; verified ARM64 host/system/executable and absence of RNNoise x86 sources. RADE remains enabled. - [x] Existing `psk_reporter_map_behavior_test` passes. This covers client/map behavior, not the new dialog layout. - [x] Isolated Qt harness verifies wheels preserve spin-box/slider values and scroll the sidebar, keyboard editing still works, and scrolling at the limit preserves values. - [x] Earlier isolated UI checks covered 1200×900 and 720×480 layouts, light/dark app themes, and native globe rendering. Final wheel fix was built and harness-tested. - [x] `git diff --check` passes; colour and accessibility audits passed during implementation. - No live transmit testing. Linux/Windows runtime checks have not been performed; upstream CI is pending. ## Checklist - [x] Signed commit - [x] No new flat-key settings - [x] Clean-room implementation - [x] Scope limited to PSK Reporter layout/status and shared map overlay sizing - No meter changes, new dependencies, protocol changes, or changelog entry. ## Review clarification The map renderers retain their existing optional on-map legend API. PSK Reporter now leaves that API empty because its legend is in the footer; the shared wrapping/stacking code is retained so an existing optional legend, if populated by a future caller, follows the same narrow-map sizing as attribution. No additional on-map legend is displayed by this dialog. The operator approved this layout direction and explicitly authorized review takeover and merge on 2026-09-08.
…nciple XI. Restore HF300 decode discrimination by narrowing the correlator lowpass below the tone shift. Reset HDLC frame assembly after consumption so frames sharing one flag decode independently, and repair linkage for both offline AX.25 diagnostic tools. Independent review reproduced 0 versus 7 frames on the supplied recording, verified the HDLC regression fails without the fix, and passed four focused tests at the PR head. The author also reports a successful live FLEX-8400/BPQ session. Squashed-from: aethersdr#5494 Co-authored-by: Jay Hendershott <133457133+Chipensaw@users.noreply.github.com>
Wire FreeDV Reporter row double-clicks to the active slice through the shared tuning policy. Follow the existing auto-mode preference to activate RADE when available, with compile and backend guards. Fixes aethersdr#4125. The operator waived additional regression tests for this PR. Existing PR CI passed; no new local tests were run. Squashed-from: aethersdr#5512 Co-authored-by: Ryan Butler <26440683+NF0T@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
## Summary Add an external `radiocert persist` supervisor that diagnoses settings retention across band, mode, RX antenna and normal client restart transitions. It records model and visible-control disagreements over time, so later convergence cannot conceal an earlier failure. This is the first Flex mutation plan for the broader cross-family persistence design. The runner uses a separate settings profile, stable GUIClientID across real Quit/relaunch, exact radio discovery identity, ownership/TX prerequisites, an atomic write-ahead journal and guarded restoration. Forty applet contracts cover power setpoints, slice audio, manual SQL, AGC, microphone/processor/monitor, AM/VOX-delay/expander, CW and both complete radio EQ curves. Unsupported or unexercised controls remain explicit gaps. Two supporting repairs make those observations usable: explicit settings profiles no longer import/move ordinary native preferences, and FFT Average/FPS models follow successfully dispatched requests when Flex acknowledges without echoing. Separate request flags and last radio publications preserve provenance; subsequent radio status always wins, with no persistence or timer replay. The read-only bridge snapshot, runner instructions, cross-family issue matrix, control inventory and live results are documented. An opt-in two-slice runner now exercises distinct SQL/AGC/filter/audio state, active-slice selection, SQL on/off isolation, normal process restart with both slices present, and test-created slice removal/reopening with guarded restoration. Both Persist entry points keep TX permission disabled. The separate explicitly authorized TX harness now checks exact radio/frequency/mode/antenna/offsets, uses unsmoothed forward power for the physical-watt cutoff, reads the correct bridge liveness field, and requires explicit false TX flags when confirming unkey. The SQL defect discovered by this run is tracked in aethersdr#5501 and its repair in aethersdr#5508; this PR does not change squelch behavior. - [Research and design](https://github.com/jensenpat/AetherSDR/blob/93bbff39cf59605bde00405a0bb4a935ba4aae38/docs/research/radiocert-persist-research-2026-09-07.md) - [Expanded run and 40-control results](https://github.com/jensenpat/AetherSDR/blob/93bbff39cf59605bde00405a0bb4a935ba4aae38/docs/research/persist-flex8400m-expanded-run-2026-09-07.md) ## Constitution principle honored Principle VIII — evidence distinguishes dispatched intent, radio publication, model and presentation. Principles II/III — radio state remains authoritative. Principle VI — receive-only operation excludes keying and VOX enable. Principle XIV — action intent is journaled atomically before dispatch. FFT request-update behavior was checked against the published FlexLib 4.2.18 `Panadapter.Average` and `FPS` setters; no proprietary binary was inspected. ## Test plan - [x] Local macOS app build passed with `cmake --build build -j22`. - [x] 25/25 selected headless CTests passed: 20 AppSettings safety scenarios, panadapter model, display status gate, MCP/probe field mappings and Persist policy (30 internal Python cases). No new socket-owning CTest or fake firmware peer. - [x] FFT regression mutation check: omitting request-state updates failed the expected 17/15 versus old 50/25 assertions; production source/binary remained intact. - [x] Bridge documentation generation, test registration, engine boundary and whitespace checks passed. - [x] Live FLEX-8400M firmware 4.2.18.41174, one client/slice/pan, separate profile, 20m/40m and ANT1/ANT2 receive switching. All 40 applet setpoints matched after normal restart; band revisits exposed SQL radio=26 versus manual cache/slider=20. Context-aware final verification matched all 40 original values. FFT revisit/restart/cleanup checked radio-published values. - [x] CI passed for the expanded Icom report head; the final review-repair head requires its own fresh checks. Local build/tests and hardware evidence used the source now committed here, built from base `ab03be0629a498603ff02ef68a7dbfbd0de91d10` with the implementation changes present. Only report portability edits followed that run. App SHA-256: `9e0b0b7447ee48a354e5a8cd97778550ff5ec2eb8b877327d6e536e6131ad8e3`. The final experiment captured 901 snapshots, 275 action intents and 5418 wire-log events, with zero detected log gaps. TX permission stayed disabled; no keying action was requested. This does not establish physical RF behavior or a whole-radio pass/fail verdict. Raw artifacts remain with the operator; publishable observations and ordered SQL excerpts are in the linked report. Remaining coverage includes Icom/other-family mutation plans, MultiFlex, multiple panadapters, client DSP/CHAIN, audio-device lifecycle, systematic automatic SQL and live validation of the new AGC OFF/FM-boundary matrix, profiles and crash/power-cycle recovery. Two owned slices on one pan are covered by the follow-up run below; this is not general multislice/MultiFlex certification. Process supervision currently supports macOS/Linux. Cleanup covers seeded fields; contextual band-stack/frequency/span side effects are journaled for review rather than claimed restored. ## Follow-up Flex testing — 2026-09-08 The tooling through commit `775b3876` was exercised against integration build `9a8ea1b59eea5226dd6e1788608c5f9a22abc650`, combining original aethersdr#5500 head `93bbff39` with the separate aethersdr#5508 squelch fix `05c68846`. Executable SHA-256: `a785be3ed2f4904c0ac9528334024ee61ec1075bd8ddcae4166bc40b8c2dfa43`. The squelch implementation is not part of this PR; these combined-build results must not be attributed to standalone aethersdr#5500. - **Two-slice Persist:** all 18 final diagnostic checkpoints established on FLEX-8400M firmware 4.2.18.41174. Distinct manual SQL 26/39, AGC, filter and audio settings stayed isolated across selection and mode round trips. SQL off/on on B left A unchanged. Both slices survived normal Quit/relaunch with model and actual RX controls agreeing. Only test-created B was removed/reopened. Final original slice/pan/TX/EQ comparisons were empty, and both processes exited normally. - **TX/control sweep, separately authorized:** ANT2 dummy load, 14.200 MHz, USB/LSB/AM/FM/CW/DIGU, maximum 10 W. All six modes produced TUNE output; USB/LSB/DIGU two-tone and FM PTT also produced observed RF. Peak radio-reported unsmoothed forward power was 7.326 W. All 24 recorded windows, including the first instrumentation-stopped attempt, confirmed unkeyed and zero post-TX visible power. Independent radio subscription confirmed ANT2 before/during 15 later bursts; this is protocol/state evidence, not an external measurement of jack/relay routing. Twenty-five TX/EQ controls had model/widget and restoration observations; external-change proof was RF power only, before and after restart. AM/CW control restart and physical microphone/DSP effects remain untested. - **AM PTT limitation:** about 0.184 W, below the harness's 0.3 W positive-carrier reporting threshold; retained as inconclusive rather than passed. A pre-key scratch-script error, repaired liveness-field mismatch, operator-requested pause and the AM stop remain documented. The first multislice run also retained a pan cleanup discrepancy; the final full run exercised the corrected guarded cleanup. - **AGC finding isolated:** a non-FM round trip retained threshold/off-level 43/17. USB→FM→USB changed both 43/17 and 65/10 to 50/50 in direct radio status while the owned RX-only app was suspended. After resume, model and slider matched; an explicit fresh `sub slice all` two seconds later still returned 50/50. This rules out stale UI or missing subscription as the explanation in this reproduction. Whether the reset is intentional firmware behavior or a Flex persistence defect remains unconfirmed. No client AGC replay workaround was added. The TX sweep's receive AGC side effect was explicitly restored, and full baseline comparisons matched afterward. - **Local verification:** the four selected headless CTests passed on the combined worktree (Persist policy, TX safety and the two squelch tests). In the isolated PR-update tree, 39 Python Persist policy cases and all 12 TX safety checks passed. Ownership and unsmoothed-power regressions were mutation-checked in temporary copies. Bridge-doc generation, test registration, engine-boundary and whitespace checks passed. No new native build was required for this Python/docs extension; native application behavior is unchanged in this update. The reports in this PR contain the control/protocol/reply/restart/restoration matrix and the AGC reproduction. Raw journals and exact runner-source snapshots for `validation-multislice-20260908` remain with the operator; identifying radio data is excluded. These Flex reports do not claim full-radio certification or physical audio-gating proof. Icom observations are recorded separately below. - [Two-slice and TX/control results](https://github.com/jensenpat/AetherSDR/blob/775b3876fa909e89e552fd82ab356378d4d88a32/docs/research/persist-flex-multislice-tx-2026-09-08.md) - [AGC FM-boundary isolation and fresh-subscription proof](https://github.com/jensenpat/AetherSDR/blob/775b3876fa909e89e552fd82ab356378d4d88a32/docs/research/persist-flex-agc-fm-boundary-2026-09-08.md) ## FM / AGC off-level extension and Icom handoff The two-slice runner now tracks distinct AGC threshold/off-level pairs 43/17 and 61/29, checks the mode-dependent RX slider with AGC Off and enabled before/after restart, and exercises each slice through FM and back. Retention concerns are recorded before any explicit cleanup; only stable AGC/filter changes on the exercised slice are accepted for guarded restoration. Peer changes, missing/mistyped fields, unrelated drift and unstable state interrupt the run. This changes diagnostic tooling only; no production AGC replay was added. - 45 socket-free Persist policy cases and all 12 TX safety checks passed on this extension. Deliberately weakening the FM cleanup guard failed its test. Bridge-doc generation, test registration and whitespace checks passed. - **The expanded matrix has not yet run on hardware.** The earlier 18-checkpoint result covers the previous runner. No new native app build was performed for this Python/docs update. - The [Flex-to-IC-7300MK2 handoff](https://github.com/jensenpat/AetherSDR/blob/06021fbbedf94764df8d7a7bce5583fad60bd07e/docs/research/persist-flex-to-icom-handoff-2026-09-08.md) captures all prior findings, the intermittent Icom Auto SQL/reconnect reproduction plan, family capability boundaries, and a per-field freshness measurement proposal. Existing CI-V queue/response/generation diagnostics should be joined to confirmed model/widget state before optimizing polling priorities. The proposed per-setting telemetry and scheduler tuning are not implemented here. The first Icom round subsequently ran under its own explicit authorization; see the observations below. ## First IC-7300MK2 round — 2026-09-08 [Full Icom report: controls, freshness, reconnect failures, TX limits and restoration](https://github.com/jensenpat/AetherSDR/blob/06021fbbedf94764df8d7a7bce5583fad60bd07e/docs/research/persist-icom7300mk2-first-run-2026-09-08.md) This used the existing combined integration app `9a8ea1b5` and executable hash listed above, with dedicated local Icom lab scripts. No native rebuild or production Icom/scheduler change was made. Public Flex mutation runners still reject Icom; this report is not a generalized Icom runner or standalone-PR certification. - **Controls and retention:** ten actual SQL/AGC/AF/microphone widget actions obtained matching CI-V value replies and model/widget convergence. Initial write confirmations were approximately 70–98 ms; optimistic presentation often preceded them. Corrected exact-model reconnect trials retained Off, Manual 26 and explicit Auto. A normal process restart adopted seeded Manual 39 at 2.548 seconds and AGC Fast at 3.077 seconds; default SQL 20/AGC Med appeared before fresh adoption. Unexpected Auto activation was not reproduced. Physical front-panel convergence was not tested in that first round; the expanded round below subsequently exercised inbound adoption through safe raw writes. - **Freshness:** a 30-second idle receive window counted 937 replies with no timeouts or stale replies. After discarding a four-second collector initialization interval, SQL/AGC/RF-control confirmation ages reached 3.24–3.33 seconds, versus 177 ms for the S-meter. These are client confirmation ages, not exact physical sample ages. The original transaction deduplication was insufficient because periodic reads reuse request generations; its latency percentiles are not a complete distribution. The report retains that correction and proposes stable event IDs plus per-field/context confirmation timestamps before scheduler optimization. - **Readiness failures:** several authenticated connections failed CI-V identity, including directed B6. Early apparent persistence outcomes under Unknown Icom are inconclusive. Exact model and fresh seed gates were added to the local experiment before valid reconnect trials. A later reconnect succeeded; no retry-delay fix is claimed. - **Separately authorized TX:** ANT1 dummy load, 7.200 MHz, all modes, maximum 10 W. Nine windows produced fresh reported RF; highest unsmoothed calibrated peak was 7.692 W, with SWR 1.0. Two zero-output AM/DIGU attempts stopped for missing valid SWR, and CW text stopped for missing fresh forward-power telemetry. All twelve windows confirmed unkey and zero actual post-TX gauge; final USB also sampled the gauge while keyed. CW reverse received mode/readback coverage only. First DIGU TUNE remained inconclusive despite later two-tone success; DATA and non-DATA inputs already read LAN. Physical RF-jack routing, microphone/DAX/VOX/ATU and DSP effects are not certified. - **Cleanup:** final slice/pan/EQ comparisons matched baseline. Original 7.224540 MHz LSB, Manual SQL 14, AGC Med and power setpoints were restored; RF 100% and Tune 10% restoration occurred with verified TX permission disabled. The tuner remains bypassed; no ATU tuning cycle was run. Final app quit normally and released the lock. Harness preflight failures and the RX-only raw-write refusal remain documented. - **Current local checks:** 45 Persist policy cases, all 12 TX safety checks, bridge-doc generation and whitespace validation passed for this report update. Native tests/build were not rerun for documentation-only changes. Raw private radio/session artifacts remain with the operator and are excluded from the PR. ## Expanded IC-7300MK2 Persist and combined findings — 2026-09-08 [Expanded matrix, combined backlog and restoration limitation](https://github.com/jensenpat/AetherSDR/blob/7e6c4805481c73ae814210c83c15edd4c8721e8e/docs/research/persist-icom7300mk2-expanded-run-2026-09-08.md) The same combined integration app completed ten mode routes, 30 factory filter-preset recalls, five band checkpoints, fourteen primary control setpoints plus additional DSP/RIT/XIT/CW/VOX setpoints, four normal process retention restarts, and six same-process Off/Manual/Auto reconnect trials. Custom filter width/PBT, unselected VFO state, SSB/DATA transmit passbands and FM CTCSS obtained independent CI-V readback and retained through the specified transitions. Raw SQL/AGC/AF/microphone/RF-gain writes also reached matching model/widgets before and after restart; this is not physical front-panel timing evidence. - **Four new restart concerns:** RX-ANT remains selected in radio replies while the app shows ANT1, and waterfall rate 63 returns to 100; both occurred at all four process retention restarts. Separately, Off→restart→Manual used default 20 instead of remembered 26, and explicit Auto→restart returned as Manual. All six same-process reconnect trials retained their requested SQL intent. Unexpected Auto activation remains unreproduced. - **Additional gaps preserved:** mode changes carry the current filter slot; factory-preset recall is not slot-only selection. Scope Zoom in did not establish a narrower span. VFO exchange returned `FA`, while direct unselected-VFO reads/writes retained. NR and custom-filter values required radio normalization. The report combines these with first-round identity/freshness failures and unresolved CW/AM/DIGU TX paths, plus the prior Flex FM/AGC and squelch findings. - **One final guarded TX window:** USB two-tone on ANT1 at 7.200 MHz, maximum 10 W authorization, RF 3%/Tune 2%, native watchdog and calibrated-meter cutoff. Six positive samples, peak 2.098 W, SWR 1.0, successful unkey and zero immediate/later actual gauge. This is protocol/radio-meter evidence, not independent physical routing or wattmeter proof. - **Restoration exception:** the 30 factory preset recalls changed hidden width/PBT definitions whose pre-recall originals were not all captured. The known active LSB FIL1 and post-recall USB FIL3 baseline were restored; full hidden filter-bank restoration cannot be claimed. Final captured slice/pan/TX/EQ/display comparisons were otherwise empty, raw unselected-VFO/TX-register restoration was verified, original RF 100%/Tune 10% were restored with TX permission disabled, then the app quit normally and released the lock. Tuner remains bypassed. - **Validation:** 45 Python Persist policy cases, all 12 TX safety checks, bridge-doc generation and whitespace checks passed in the publication worktree. No new native build/tests or production Icom/scheduler change. Both public Persist mutation runners remain Flex-only; this is explicit local Icom lab evidence, not a generalized Icom runner. ## Checklist - [x] Commits signed. Mergeability against newer upstream changes remains a separate review gate. - [x] No new flat AppSettings feature keys; existing AutoConnect key is initialized only in the dedicated test profile before first launch. - [x] Clean-room code; no meter display changes. - [x] Automation and research documentation updated; CHANGELOG unchanged. Generated with OpenAI Codex (GPT-6 Astra) ## Review repairs The applet diagnostic now combines model and widget evidence before assigning a scenario or per-control verdict. Widget disagreements are sampled over the whole observation window and survive later convergence. A failed seed presentation makes subsequent retention inconclusive; hidden, disabled, missing, ambiguous and unselected controls remain explicit presentation gaps. Markdown uses the same combined outcomes as the JSON journal. The aetherd panadapter resource now includes Average/FPS request flags and last radio publications, including same-value requests and confirmations. The v1 catalogue documents those fields. Settings regression coverage now exercises an explicit profile outside Qt test mode using only a private INI fixture, plus the legacy XML relocation guard. Native preference fixtures are deliberately avoided because CFPreferences can ignore HOME. Windows/native-path cases that cannot be confined to a private temporary directory skip explicitly with exit 77. The no-echo change remains limited to Average/FPS; weighted averaging and waterfall-rate echo behavior are not newly claimed. The settings isolation repair is a prerequisite for safely launching the supervisor's dedicated profile. The TX harness remains a separate, explicitly authorized entry point; Persist never runs it. RFC aethersdr#5342's remaining universal-framework scope stays open. Validation of the review repairs: 51 socket-free Persist policy cases and all 12 TX safety checks; 26 focused ARM64 CTests (settings safety, panadapter model, control resource service and Python policies); bridge-doc generation, test registration, frozen CI gate, engine boundary and whitespace checks. Mutation checks failed as expected when widget evidence or seed history was discarded, either settings isolation guard was removed, or same-value FFT provenance notifications were omitted. No live radio, keying or new hardware claims were used for this repair. Native verification builds the focused targets, not the whole desktop application. --------- Co-authored-by: Robbie Foust <rfoust@gmail.com>
…inciple V. (aethersdr#5379) Fixes aethersdr#5353. Dismissing the RTTY pane now persists the operator enable flag in the existing RttyDecoder object; mode and frequency changes respect it. Radio Setup → Phone & CW → RTTY Decode re-enables the pane. Sensitivity remains in the same feature-owned object and is preserved by enable changes (Principles V and XIV). Two small review repairs are included. The new settings label uses ThemeManager, clearing the hardcoded-color ratchet. Native testing then demonstrated that wirePanadapter() removed the original close-to-MainWindow connection after decoder routing had installed it. The dismissal handler is now connected immediately after that bulk disconnect, so it survives startup and pan rewiring. Scope: the nine original GUI/test/generated-doc paths plus MainWindow_Wiring.cpp for the demonstrated signal fix. No engine, radio protocol, transmit, workflow, dependency or governance changes. Validation: the settings CTest passes13 assertions; forcing enabled=true fails4 and dropping sibling settings fails1. Static registration, frozen gate, engine boundary, color and generated manifest checks pass. Full macOS arm64 Release app builds and its development signature verifies (ASR/RADE/DFNR off). Native before/after verification and final CI results are recorded in the follow-up review. The final incremental build compiles repair746cd8cf; embedded configure-time version metadata remains0415206d, so that label alone must not be mistaken for its source revision. Native private-profile testing uses the built-in RX-only demo simulator, with TX disabled; no live radio/RF proof is claimed. Native after-proof on the signed repair: dismiss→USB/RTTY switch→simulator frequency change stayed hidden and persisted enabled=False; Radio Setup re-enable reopened it. Dismissal survived a fresh process restart in the same private profile. The earlier implementation failed this same sequence. Demo is single-slice; no multi-slice live-radio assertion is made. Final CI: all seven checks passed on746cd8cf, including macOS/Windows/Linux builds. --------- Co-authored-by: aethersdr-agent[bot] <273844287+aethersdr-agent[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Ozy311 <Ozy311@users.noreply.github.com> Co-authored-by: Robbie Foust <rfoust@gmail.com>
…ethersdr#5514) Normal app restarts lost three client-owned Icom preferences and displayed a stale RX antenna selection. This fixes the four discrepancies found during RadioCert Persist aethersdr#5500 in a separate, main-based PR. | Finding | Result | |---|---| | Radio retained RX-ANT but the app showed ANT1 | IC-7300MK2 bare `12` readback now updates both antenna controls at startup, periodically, and after writes. The scheduler expects the reply's `00` subcommand and retires the transaction promptly. | | Manual 26 → Off → restart → Manual returned 20 | Remember the previous manual choice in a scoped client document; a later operator click returns to 26. Fresh enabled radio SQL remains authoritative. | | Explicit Auto became Manual after restart | Remember client Auto intent and resume only after fresh enabled SQL readback. A radio reporting Off wins and cancels old Auto intent. | | Waterfall speed 63 reset to 100 | Persist explicit local display-rate changes by radio/pan slot and restore before seeding the local shaper. | Waterfall speed was already mapped to AetherSDR's local display timing; this adds persistence, not a native CI-V waterfall-speed command. The two SQL repairs use the shared Icom path, and the waterfall repair uses local-rate shaping, so they also apply to IC-705/IC-9700 in code. RX-ANT readback is MK2-profile-only; injected tests verify that IC-705/IC-9700 do not acquire those reads or publications. Neither model was hardware-tested in this run. The documents use checked atomic writes, exact radio scopes, schema/range validation and future-schema protection. No radio-owned SQL threshold is replayed on attachment, and Flex radio-owned cadence remains outside this store. This does not claim to resolve the unreproduced report of unexpected Auto activation. ## Review fixes and final validation - Reject malformed or unsupported antenna replies before scheduler completion and scrub bookkeeping. Empty, invalid-enum and oversized first replies cannot make the default antenna value eligible for replay. - Coalesce SQL and waterfall settings edits within 250 ms; capture the original radio scope/value and flush on disconnect, reattachment, owner teardown and normal quit. - Reattach from current-session SQL readback without a transient Off state. Construction defaults, previous-session state and unconfirmed optimistic edits cannot resume Auto. - Tested the combined PR/current-main tree (main `ac92489b`) with a complete ARM64 desktop build, RADE enabled, and six focused registered CTests. Strict boundary, registration, CI-gate and generated-manifest checks passed. - Separate mutation builds failed on all three repaired issues. Coverage also verifies rapid SQL edits followed by disconnect, Auto reattachment, stale/optimistic state rejection and future-schema protection after flushing. - This review did not repeat the contributor's hardware run below. Its isolated offscreen desktop attempt could not initialize QRhi before the automation bridge started; review-owned processes were stopped, and no final GUI-render or hardware-convergence claim is made. ## Original contributor validation (before review fixes) - Final macOS build and **10/10 selected headless CTests passed**. Coverage includes protocol/scheduler/profile guards, real RxApplet/SliceModel reconciliation and scoped display persistence. No new radio peers or socket listeners enter CTest. - Mutation checks failed with antenna adoption, SQL saves, and waterfall saves disabled; both SQL restart cases were detected. The new transaction test also failed before correcting the bare-query/subcommand-reply matcher. - **Live IC-7300MK2, RX-only:** 16 established observations across two normal Quit/relaunch cycles, two same-process reconnects, Off/Manual/Auto transitions and restoration. Actual antenna controls, threshold and rate sliders agreed with the expected model/readback. Recorded antenna transactions completed in 3–26 ms; all sampled scheduler snapshots showed zero timeouts. - Original slice/pan/transmit snapshots matched after restoration; fresh ANT1 and Manual 14 replies confirmed cleanup. App quit normally, TX remained disabled/unkeyed, and lab lock was released. - Strict engine-boundary and test-registration checks plus `git diff --check` passed. CI is separate; this is focused proof rather than a new full Persist/TX sweep. The focused proof report is included in `docs/research/icom-restart-fixes-2026-09-08.md`. Detailed screenshots and session observations are retained locally and are not attached publicly. Generated with OpenAI Codex (GPT-6 Astra) --------- Co-authored-by: Robbie Foust <rfoust@gmail.com>
## Summary Adds two independent PSK Reporter map controls: **Map brightness** (20–100%, default 100%) and **Dark map** (off by default). Dark map recolours the existing OpenStreetMap tiles into dark navy/grey backgrounds and lighter printed labels. Brightness adjusts the resulting base imagery without changing data-overlay colours. Start at 100% brightness when comparing styles. The sidebar/footer work from aethersdr#5493 is already merged. This PR combines the brightness and dark-map controls on the integrated upstream base (`0ac8721aa`), with the squash-merge conflict resolved. - Flat view: brightness is a camera-bounded black compositing layer; dark mode recolours display tiles through a small QGeoView tile-image factory hook. The original images remain in the decoded/network caches. Existing tiles can return to their originals without another fetch. - Globe: the atlas/detail shader uses the same luminance ramp and brightness multiplier. City lights, radar, paths, and markers render afterward. When Dark map is enabled, night shading derives from its dark palette in both app themes. - Shared state initializes a lazily created globe and survives projection changes. Settings stay in the existing `PskReporter` object. Brightness participates in the sidebar wheel guard and tab order. Attribution and the map-only footer remain outside the adjustments. No new map provider, paid service, credentials, tile URL, refresh policy, or dependency. This is raster colour remapping, not semantic cartography: feature colours and printed label halos are also remapped, so some map details lose their original distinctions. See `docs/map/psk-basemap-brightness.md`. The beacon status dot now uses an explicit stop outcome (completed, cancelled, or interrupted), so changing translated status text cannot change its colour. Existing colours and stop behaviour are preserved. ## Validation - macOS ARM64 RelWithDebInfo build against the integrated upstream base; RADE enabled, ARM64 host/system/executable verified, no RNNoise x86 sources. - Five focused registered tests: `dark_basemap_test`, `map_tile_reply_test`, `map_image_cache_test`, `psk_reporter_map_behavior_test`, and `weather_radar_loading_test`. New/modified tests use images or injected HTTP replies; no sockets or synthetic radio peers. - Mutation checks: bypassing tile styling fails the dark-map test; bypassing the display factory fails the new cache/factory case. - Isolated UI verification covers defaults, normal-quit/restart persistence, minimum/wide layout, wheel guards, and light/dark app themes. Native prototype checks additionally covered flat/globe detail rendering, city lights, dark-map toggling, and the corrected light-theme night shadow. The final integrated review uses the built-in RX-only demo; no live-radio or transmit testing. - Colour ratchet, theme seed, accessibility, test registration, engine boundary, and whitespace checks pass. Existing engine-boundary warnings remain. - The status-outcome follow-up passed an ARM64 application rebuild and the focused PSK Reporter behaviour test. The generated architecture touchpoint inventory was refreshed after CI detected an outdated include count. - Linux/Windows runtime rendering has not been tested. CI on the updated head is a separate check; this is not a full-suite claim. ## Review status The operator tested and accepted the dark-map appearance and requested that it be combined here. Ready for maintainer review. Visual/UX acceptance remains a maintainer decision; operator testing does not substitute for that approval. Principle V: both preferences belong to the feature-owned settings object. Principle X: current-main integration preserves the merged sidebar and intervening changes. No CHANGELOG or radio/protocol changes.
aethersdr#5357) Compensate measured ANAN-G2 DDC0 edge droop with an in-app RX calibration sweep. Keep calibration ownership, rate changes, bypass and apply inside AnanBackend; gate GUI and bridge controls on connected ANAN capability through IRadioBackend extensions. Validate with four focused tests and a non-ANAN refusal regression, including a guard-removal mutation. Cross-radio staged-result persistence certification remains deferred by the operator. Squashed-from: aethersdr#5357 Co-authored-by: tropo1234 <299180028+tropo1234@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: jensenpat <patjensen@gmail.com>
…rinciple IV. Derive waterfall start units from the server-advertised zoom_max/zoom_cap for request encoding, quantization tolerance, and frame-header decoding. Preserve the default KiwiSDR 2^24 scale and add socket-free regression coverage for non-default server scales. Implementation and original regression tests by Ingomar Otter, DL9ET (@kgbvax). Robbie Foust's follow-up is limited to review, validation, correcting a comment, pinning the protocol-source reference, and issue/PR documentation. Fixes aethersdr#5536. Related to aethersdr#5528; Web-888 family support follows in aethersdr#5530. Co-authored-by: Ingomar Otter <dl9et@n44.de> Co-authored-by: Claude Code <noreply@anthropic.com>
Add Web-888 receiver family support on the existing KiwiSDR path, including persistent selection and reliable waterfall setup replay. Original implementation and PR by Ingomar (@kgbvax). Review fixes and validation by Robbie Foust (@rfoust). Closes aethersdr#5528. Co-authored-by: Ingomar Otter <dl9et@n44.de> Co-authored-by: Claude Code <noreply@anthropic.com> Co-authored-by: Robbie Foust <rfoust@gmail.com>
…r#5458) ## Summary Continues RFC aethersdr#3849 after merged aethersdr#5455 with opt-in, same-user **non-TX** connection control for the headless daemon. This is one Stage 3 sub-slice, not completion of the RFC. - Default local clients remain observers. `--allow-local-control` grants every client admitted by the current-user endpoint observe + control; it is not per-application consent or remote authentication. Discovery remains independently opt-in. - Add typed `radio.connect` / `radio.disconnect` for the daemon-owned `radio-1`. Connect selects an existing catalogue ID at its exact current revision; arbitrary endpoints, credentials, force flags and raw commands are rejected. Busy sessions cannot be replaced. - Keep native translation below the vendor seam. The injectable target reserves connection/teardown state, publishes bounded lifecycle/error observations, and cancels legacy reconnect on failure/disconnect. `accepted:true` acknowledges intent, not RX readiness. Controllers share disconnect authority; client loss does not undo accepted intent. - Enforce the advertised per-session 100/s, burst-200 request budget on every post-negotiation frame, including malformed/rejected requests. Exhaustion is terminal. This applies to every negotiated client, observers included, so it is a protocol-wide behavior change for any pre-existing observe-only client above that rate. - Integrate the maintainer's aethersdr#5455 catalogue changes unchanged. Claim the endpoint before settings/model construction, load native settings before model consumers, bind the target once before dispatch, and stop delivery before teardown. No receive setters, transmit grants/methods, remote listener, credential provisioning, Icom manual setup, desktop migration, or default native discovery/connection is introduced. The existing 30-second timer requests cancellation; it does not bound synchronous backend teardown. ## Constitution principle honored Principle VII — typed, bounded boundary input validation and catalogue-only destination selection. Principle VI — no protocol transmit authority or keying surface. Principles VIII/XI — source-backed review and demonstrated regression checks. Principle XIV — preserve settings initialization ordering and avoid touching settings on failed listen. ## Test plan - [x] ARM64 macOS app and daemon built using the dedicated local toolchain, RelWithDebInfo and RADE enabled. Host/system processor and final executables are arm64; no RNNoise x86 sources; daemon has no QtWidgets dependency. - [x] Seven focused registered CTests pass: `control_protocol_codec_test`, `control_authorization_test`, `control_resource_service_test`, `radio_catalogue_test`, `control_connection_test`, `radio_discovery_source_test`, `aetherd_discovery_startup_test`. - [x] Mutations individually fail their assertions: control-grant guard, catalogue revision, busy reservation, request charging, terminal rate latch, startup ordering, and pre-dispatch target-binding latch. Fixed tests return green. Mutations were confined to scratch copies. - [x] Actual daemon, isolated settings/wisdom and endpoint, production simulator only: default observer connect refusal; three connect/slice-0/peer-observation/disconnect cycles; three immediate queued cancellations; authoritative subscription delivery. Test daemons stopped; existing desktop process untouched. - [x] Strict test registration, frozen CI-test gate, engine-boundary ratchets, generated touchpoint manifest and whitespace checks pass. No frozen per-PR gate expansion. - [x] Final source/security review covers all 20 changed files at `37a7fdd30f8d5565f6f5c2961066621a86bcb82a` against merged catalogue/main `9a90802a577eabba3bdb9127340a59e46272ac30`: no remaining actionable findings, no deferred source-review work. Current-main `17d61de5` prospective merge is conflict-free; its later changes are Windows Store packaging/tests. - [ ] PR CI results pending. - [ ] Physical-radio convergence and Windows/Linux runtime verification were not performed. No LAN/USB scan or RF/TX test was authorized or attempted. Simulator evidence is not hardware proof. Socket/process disclosure: the new protocol test is socket-free and injects the target/source. The expanded startup test launches our real daemon with an invalid logical endpoint name, which fails before socket binding; no firmware stand-in or native discovery is used. Only the manual daemon integration smoke opens isolated current-user endpoints belonging to the server under test. Existing fresh-process settings readback stays isolated. Local build limitations: optional DFNR/RTL libraries were unavailable on this Mac. Compiled source matches the reviewed commit; the desktop's embedded SHA reflects the pre-commit configure. This does not claim a full unfiltered suite or cross-platform runtime pass. ## Checklist - [x] All branch commits have verified signatures. - [x] No new flat-key AppSettings calls or persistence format. - [x] Clean-room implementation; no proprietary binary derivation. - [x] No meter/UI changes. - [x] Protocol catalogue, Stage 3 documentation and AGENTS migration status updated; no CHANGELOG entry. - [x] No reportable vulnerability requiring a GHSA was found in the final diff. Tracking note: aethersdr#5455 automatically closed aethersdr#3849 when it merged. This PR deliberately uses a non-closing reference; remaining RFC work is still outstanding. Existing issue coordination with @ten9876 is preserved. --------- Co-authored-by: Jeremy [KK7GWY] <kk7gwy@aethersdr.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…ethersdr#4181). Principle VIII. (aethersdr#5105) Automation access tokens now use `QRandomGenerator::system()` for both 64-bit halves. Bridge enablement is persisted by MainWindow after the asynchronous bind succeeds, so closing Radio Setup during the token read cannot lose the operator's opt-in. Failed binds clear saved enablement; environment-forced starts preserve the saved opt-in on either outcome. Radio Setup reconciles the result without emitting another toggle, including when reopened during a pending read, and repeated starts reuse the pending request. Addresses aethersdr#4181 items 2 and 3. Item 1 (`dump_tree` filtering on `toolTip` and `accessibleDescription`) remains open under aethersdr#4181. Startup failure without an open Radio Setup dialog is logged; this change does not add a global notification. Scope: five GUI files (`MainWindow.cpp/.h`, `MainWindow_Session.cpp`, `RadioSetupDialog.cpp/.h`). No map/QGeoView files or committed tests are included. Validation at `dafc4339cf7e0952e245d8db28afae93d72ad320`: - Socket-free scratch probe compiling production callback/guard/result-handler code with injected token-read and bind boundaries: 8 failed assertions before the repair, zero after. Covered dialog destruction/reopen, failed binds, forced results, stale callbacks after stop/restart, and repeated starts. This is local evidence, not a committed regression test. - Full macOS arm64 Release app build passed (Qt 6.11.1; ASR, RADE and DFNR disabled; `-j3`); local development bundle signature verified. All seven PR checks passed, including macOS, Windows and Linux builds. - Engine-boundary, color-ratchet, accessibility and generated touchpoint-manifest checks passed. Real OS credential-store behavior and bind-failure GUI notification were not exercised by the scratch probe; no radio or TX testing was performed. Native signed Mac app: private settings, authenticated real bridge startup and Radio Setup open/close/reopen remained responsive. The environment-forced run kept TX disabled and never connected a radio; deferred token delivery and failed bind remain covered by the injected harness, not a native keychain test. --------- Co-authored-by: aethersdr-agent[bot] <273844287+aethersdr-agent[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Ozy311 <Ozy311@users.noreply.github.com> Co-authored-by: Jeremy [KK7GWY] <kk7gwy@aethersdr.com>
…ethersdr#5130) ## Summary Fixes aethersdr#5087 TCI has no client-identification message and the WebSocket handshake is a bare upgrade, so `TciServer: client connected from "::ffff:127.0.0.1"` was all a support bundle ever held about a misbehaving client. For the common same-machine case the OS knows which process owns that socket; this asks it. What changed: - New `src/core/TciPeerProcess.{h,cpp}` — `resolveLoopbackPeerProcess(peerAddr, peerPort)`: pure OS lookup, no Qt networking beyond `QHostAddress`. Linux: `/proc/net/tcp6` then `/proc/net/tcp` (a loopback IPv4 client on the Any-bound listener appears v4-mapped in `tcp6`) → socket inode → `/proc/*/fd` → `comm` + `exe`. macOS: `proc_listpids` → `PROC_PIDLISTFDS` → `PROC_PIDFDSOCKETINFO` matching the client's local port/address → `proc_name` + `proc_pidpath`. Windows: `GetExtendedTcpTable(TCP_TABLE_OWNER_PID_ALL)` for AF_INET and AF_INET6 → `QueryFullProcessImageNameW`; version from the exe's authored `StringFileInfo` strings — ProductVersion first (it is where build metadata lives: WSJT-X's reads "3.0.1 c04dd8"), then FileVersion, trying the declared Translation pairs plus the standard Unicode blocks 040904b0/000004b0 (real exes ship Translation entries that don't match their actual block — measured on WSJT-X, Windows proof below; .NET's FileVersionInfo carries the same fallbacks); the numeric `VS_FIXEDFILEINFO` quad only as a last resort — its always-4-part shape is rewritten by the log sanitizer's IPv4 rule, authored strings are not. macOS: the bundle's `Info.plist`, found by walking up from `…/Foo.app/Contents/MacOS/foo` — a file read, never an execution of the client; reported as `CFBundleShortVersionString` plus `CFBundleVersion` in the form "3.0.1 (123)" when the build number differs. Linux: an ELF binary embeds no version; for distro-installed clients the dpkg database, read as plain files (`/var/lib/dpkg/info/*.list` maps the exe path to its package, `/var/lib/dpkg/status` maps the package to its version, which carries the packaging build, e.g. "2.6.1+repack-2build1"), supplies it — never a package tool executed; home-built binaries match no `.list` and stay version-less; rpm's database is not plain text, so non-dpkg distros remain a follow-up. Non-loopback peers and every failure return unresolved. - `26d7ebfb` (found by the Linux leg of the per-platform proof below): the Linux fd sweep now compares `/proc/<pid>/fd` link targets by raw `readlink(2)`. `QFile::symLinkTarget()` absolutizes a relative-looking target against the link's directory, so the raw `socket:[N]` came back as `/proc/<pid>/fd/socket:[N]` and the comparison never matched — the Linux resolver resolved nothing, ever. macOS/Windows use native APIs, untouched. - `TciServer::onNewConnection()` logs today's line unchanged, then `resolvePeerProcess()` runs the lookup on `QtConcurrent` with a `QFutureWatcher` — the descriptor sweep is unbounded and must not delay `sendInitBurst()`. When it lands, a second line: `TciServer: client ::ffff:127.0.0.1:51234 process="wsjtx"` (+ `version="…"` when known), and `clientsChanged()` fires. The executable path is never logged; it is kept in memory for the Network Diagnostics tooltip only (maintainer ruling, 2026-09-07). A non-loopback peer gets one `qCDebug` saying identity is unavailable, so the absent field is self-explaining in a bundle. Identity is kept on `ClientState`/`TciClientInfo`. - Network Diagnostics → TCI clients table: the endpoint cell reads `127.0.0.1:51234 (wsjtx)` with exe path (+ version) as its tooltip. No new column; nothing changes when unresolved. - No unit test. The lookup's positive claim (a loopback socket resolves to its owning process) needs a real socket, and the self-connected `QTcpServer` test that carried it was removed in `9476eb04`: AGENTS.md routes positive convergence to the automation bridge, and the three-platform bundles below prove it with real clients. The negative guard (remote peer, port 0 never resolve) is a one-line check documented at the top of `resolveLoopbackPeerProcess()`. - CMake: new `.cpp` in the aethercore list; Windows links `iphlpapi version`. ## Limitations **What is collected.** Only the one process that opened a TCP connection to the TCI port, and only three fields: its short name, its executable path, and a best-effort version. The log line carries the name and version; the path appears only in the Network Diagnostics tooltip, in-app. The version comes from file reads scoped to that one executable (`Info.plist`, the dpkg status file, the exe's resource strings) — nothing is executed. Remote peers get nothing. On Linux and macOS the lookup briefly reads other same-user processes' descriptor lists to find the match (what `lsof -i` does); nothing from that walk is kept. The log is local and leaves the machine only if the user shares a bundle. Privacy text, as the maintainer proposed: *"When a TCI client connects from this computer, AetherSDR records the connecting program's name and version in its local log."* Packaged clients now report a version — with build metadata where the packager authored it — on all three platforms. The version is read from what the OS or package manager records about the binary; the client is never executed. The honest remainder: - **A home-built client reports name + exe but no version, on every platform.** A bare binary's version usually exists only as compiled-in strings (fldigi's Help→Build info stamp, for example), which no metadata query can see — so for self-built clients the aethersdr#5087 support question ("which version is the user running?") still comes back empty. - macOS reports the bundle's `Info.plist` keys; a build hash appears only if the packager put one there (WSJT-X's mac plist carries `v3.0.2`/`3.0.2`, not the `ccdfaf` its own About shows). - Linux covers dpkg-owned binaries; rpm-based distros are a stated follow-up. - Same-user loopback clients only, by design (above). ## Constitution principle honored Principle VIII — Evidence Over Assertion: the bundle now records which program was connected instead of the operator reconstructing it from memory. ## Test plan - [x] Local build passes — clean configure + full build, macOS (Intel, Qt 6.8.3): 2952/2952 clean, then reconfigure+rebuild at the commit - [x] Behavior verified on a real radio if applicable — **n/a**, no radio involvement (the TCI server listens without one); verified with a live local client via the agent automation bridge, below - [x] Existing tests pass (CI) — CI compiles all three backends (`build` / `check-macos` / `check-windows`). This PR registers no test (see "What changed"); the proof is the bridge bundles below - [x] Reproduction steps documented — n/a (enhancement) ### Proof — `b3d25350` on three platforms (agent automation bridge) The quoted lines below were read at `b3d25350`; as of `ad427370` the log line has no `exe=` field (the tooltip still carries the path). Each platform leg: clean configure + full build at `b3d25350`, Help→About SHA verified (screenshots in the zips), real local clients over `ws://…:50001`, radio (where connected) RX only — nothing keyed. Readings verbatim from the attached complete unedited logs: | Platform | Client | Reading | |---|---|---| | Windows 11 | WSJT-X 3.0.1 (installed exe) | `TciServer: client ::1:61576 process="wsjtx" exe="C:\WSJT\wsjtx\bin\wsjtx.exe" version="3.0.1 c04dd8"` — authored ProductVersion: version AND build hash, unmasked, 1 ms after connect | | Linux | wsjtx 3.0.1 (dpkg-owned) | `TciServer: client ::ffff:127.0.0.1:59908 process="wsjtx" exe="/usr/bin/wsjtx" version="3.0.1"` — the dpkg-recorded version (independently confirmed on the box: `dpkg-query -W wsjtx` → `3.0.1`), 116 ms | | Linux | fldigi (home-built) | `process="fldigi" exe="…/fldigi-tci/src/fldigi"` — no `version=` clause (owned by no package — the designed version-less path), 159 ms | | macOS | WSJT-X 3.0.2 (`Info.plist` keys differ: `v3.0.2` vs `3.0.2`) | `TciServer: client ::1:50159 process="wsjtx" exe="/Applications/wsjtx.app/Contents/MacOS/wsjtx" version="v3.0.2 (3.0.2)"` — both keys, `short (build)` form, 11 ms | | macOS | Python.app 3.14.4 (bare WebSocket upgrade; plist keys equal) | `process="Python" exe="…/Python.app/Contents/MacOS/Python" version="3.14.4"` — equal keys de-duplicate to the short version alone | | macOS | fldigi (home-built, no bundle) | `process="fldigi" exe="…/fldigi-tci/src/fldigi"` — no `version=` clause: a bare binary carries no OS-queryable version metadata (its git stamp is a compiled-in string, visible only in its own Help→Build info) | Network Diagnostics → TCI Clients shows the endpoint cell as `127.0.0.1:<port> (wsjtx)` with exe path (+ version) in the tooltip:  How the per-platform legs got here (before-readings live in the attached zips): - **Linux** (`26d7ebfb`): at `cfb74fa2` no Linux client ever resolved — the `QFile::symLinkTarget()` defect under "What changed"; the unit test failed 6 checks on the box. - **Windows** (`1897df2f`, `b3d25350`): the numeric-quad-only read rendered every Windows version sanitizer-masked (`version="*.*.*. 0"`), and WSJT-X declares a `\VarFileInfo\Translation` block it doesn't use — both measured with the version-resource probe in the Windows zip, both described under "What changed". - **macOS**: bundle metadata carries no git hash (WSJT-X's own About shows `v3.0.2 ccdfaf`; its plist carries `v3.0.2`/`3.0.2`) — stated under Limitations. Evidence zips (complete unedited logs, About screenshots, unit-test outputs, probe/client scripts): [aethersdr-pr5130-linux-version-2026-08-24.zip](https://github.com/user-attachments/files/31382260/aethersdr-pr5130-linux-version-2026-08-24.zip) [aethersdr-pr5130-windows-d2-2026-08-24.zip](https://github.com/user-attachments/files/31368161/aethersdr-pr5130-windows-d2-2026-08-24.zip) [aethersdr-pr5130-mac-version-2026-08-24.zip](https://github.com/user-attachments/files/31392447/aethersdr-pr5130-mac-version-2026-08-24.zip) [aethersdr-pr5130-ruling-revision-2026-09-08.zip](https://github.com/user-attachments/files/31941139/aethersdr-pr5130-ruling-revision-2026-09-08.zip) — head `d1e69c7e` (About SHA, Python client log line without `exe=`, TCI Clients cell, `tci_peer_process_test` 12/12, quit-with-dialog-open trace), sha256 `c8fdb2a4b6b76f2d1539c28683a62aef7d7db91aaf1200efaabf757993b8da4b` **Review round (2026-09-08).** `ad427370` name + version only in the log; `d1e69c7e` macOS sweep limited to this user's pids. Maintainer ruling 2026-09-07. `74a92bf7` then `9476eb04`: the socket-owning unit test first met the AGENTS.md disclosure/exit-77 obligations, then was removed — the bridge bundles are the proof. ## Checklist - [x] Commits are signed - [x] No new flat-key `AppSettings` calls — **n/a**, no settings - [x] Code is clean-room - [x] All meter UI uses `MeterSmoother` — **n/a** - [x] Documentation updated if user-visible behavior changed — **n/a**, diagnostic log line + one table cell; described above - [x] Security-sensitive changes reference a GHSA — **n/a**. Logged data is the name/path of a program that connected to us on the operator's own machine (what `ss -tp`/`lsof` show any local user); same-user processes only. — authored by agent (Claude Code) on behalf of @skerker **Review round (2026-09-10), `7ff41b5e`.** Every finding and nit from the second review pass, plus a merge of `origin/main`: - `findSocketInode()` skips zero-inode (TIME_WAIT/orphaned) rows instead of returning them — measured 6/6 misses before, 6/6 resolves after, with a client that reused its source port; same guard on both Windows tables (`dwOwningPid == 0`). - `redactPii()` now exempts the literal prefix `version="` alongside `ver=`, so a 4-part authored version (`2.2.159.0`) reaches the bundle intact; new case in `async_log_writer_test`, and the field is listed in `docs/log-redaction.md` under *Deliberately NOT redacted*. - The identity line spells the peer the way the Network Diagnostics table does (`::1` / `::ffff:127.0.0.1` → `127.0.0.1`) and escapes quotes, backslashes and control characters in the client-chosen name/version (a `comm` of `x"\nINF forged` logs as one field on one line). - Linux checks `st_uid` of `/proc/<pid>` against `getuid()` before reading a descriptor table — "same-user only" is now enforced on Linux and macOS; the header states that Windows is bound only by `OpenProcess()` rights. - dpkg lookups are cached per executable path + mtime. - `/proc/net` hex words are read back with `qFromBigEndian`/`memcpy` (endian-correct); the tcp/tcp6 comment names the right socket. - With main merged, the finished lambda uses `clientStateFor()` and `disconnectSnapshot()` carries `processName`/`processVersion`. The log line above therefore reads `TciServer: client 127.0.0.1:51234 process="wsjtx" version="3.0.1 c04dd8"` on all three platforms. Verified on Linux against the built-in demo (v4, `::1`, in-process client, hostile name, TIME_WAIT reuse, quit with lookups in flight). --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Jeremy [KK7GWY] <kk7gwy@aethersdr.com>
## What it does Adds `sweep-closed-prs` to `cache-cleanup.yml`: a daily (`17 4 * * *`) plus on-demand job that deletes caches on `refs/pull/N/merge` once the PR is no longer open. Those caches live on a ref only that PR can read. When it closes they become permanently unreachable but keep holding budget for up to the full 7-day retention, which is not configurable. ## Why scheduled, not `pull_request: closed` The obvious trigger cannot work, which is why aethersdr#5008 pulled the original version rather than shipping it: for a `pull_request` event whose head is a fork, GitHub issues a **read-only** `GITHUB_TOKEN`, and a `permissions:` block can only lower that ceiling, never raise it. `gh cache delete` returns 403 and the job fails. **44 of the last 60 closed PRs here are from forks** (measured 2026-09-10). `pull_request_target` would get a writable token and is the documented-safe shape for a job that checks nothing out. It's deliberately not used: a schedule needs no privileged trigger at all, is idempotent, and **self-heals** — it picks up PRs closed while the workflow was broken, renamed, or disabled, which an event-driven job misses forever. The cost is latency, and it is not quite free: while the repo is over its allowance, dead entries compete with main's live ones for LRU until the sweep runs. If that bites, the job is idempotent and cheap enough to also hang off the existing `workflow_run` trigger, with the schedule kept as the backstop. Not done here; see follow-ups. ## Measured premise (2026-09-10, 25 days after aethersdr#5008) The original draft held this PR until it could be shown that anything still accumulates on PR refs after aethersdr#5008's save-on-main split. It does: ``` PR-ref caches: 20 entries, 6881 MiB repo usage: 23 entries, 9.88 GiB of 10 GiB aethersdr#5458 MERGED 1720 MiB ← unreachable, swept by this job aethersdr#5462 OPEN 396 MiB aethersdr#5539 OPEN 1323 MiB aethersdr#5542 OPEN 1720 MiB aethersdr#5547 OPEN 1720 MiB ``` That is past the "> 2 GiB, or any single Qt-sized entry → merge" threshold the draft set for itself. The "PRs should restore rather than write" hypothesis did not hold: each open PR carries its own copy of the same 1,297 MiB Qt key main holds, because at the allowance LRU evicts main's copy between runs and the next PR re-saves it. This job reclaims the closed-PR leg of that loop; the loop itself is a ci.yml matter (follow-ups). ## Failure behaviour | case | behaviour | |---|---| | PR state unresolvable | Skipped and retried next run — a transient API error must not become data loss. Counted, and surfaced as a `::warning::` with gh's stderr so a persistent cause (missing scope, rate limit) cannot hide | | `--limit 500` truncation | Emits `::warning::`, because silent truncation looks exactly like "nothing to clean" | | Individual delete fails / LRU took it first | Deleted by id like prune-main; a 404 is logged, does not abort the loop, and does not count toward `freed` | | Dispatch overlaps the cron | `concurrency` group queues the second run | ## Cannot be exercised on this PR `schedule` and `workflow_dispatch` only fire from the **default branch**. First real run is after merge: trigger it via `workflow_dispatch` immediately rather than waiting for 04:17, and read the log before trusting it. That first run is also what confirms the `pull-requests: read` grant is sufficient for `gh pr view` under `GITHUB_TOKEN`. ## Not addressed here - Caches on **tag refs** (`refs/tags/v*`) from the release workflows are swept by neither job. Low volume; noted so it isn't silently forgotten. - The open-PR duplication loop above. The durable fix is giving the dependency caches (Qt, FFTW3, DeepFilterNet3, qtkeychain) the same restore-on-PR / save-on-main split aethersdr#5008 gave the compiler caches; `install-qt-action`'s built-in cache has no restore-only mode and needs its own decision. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ethersdr#5548) (aethersdr#5551) ## What Two one-line settings changes plus the comments that justify them, for aethersdr#5548 items **4**, **5a** and **5b**: | file | change | |---|---| | `cache-cleanup.yml` | `KEEP: "2"` → `"1"` in `prune-main` | | `ci.yml` | Linux `CCACHE_MAXSIZE: 2000M` → `1500M` | | `cache-cleanup.yml` header | note that `GET …/actions/cache/usage-policy` 404s here (item 4); replace the stale entry-size table | | `ci.yml` comments | rewrite the cap budget with measured sizes and KEEP=1; fix the Windows sccache comment's stale 628 MiB figure | ## Why Ten of the fifty main CI runs between 2026-09-06 and 2026-09-10 were cold builds — Build step 27–37 min instead of 3–6 — because "Restore ccache objects" completed in 0 s with `Cache not found for input keys: ccache-linux-build-`. `prune-main` was skipped on every trigger between the previous save and the miss, so GitHub's LRU eviction removed it: the repo has read 9.2–10.8 GiB on every `prune-main` usage line since 2026-09-04. The 2026-08-15 budget projected ~6.3 GiB on main. It used entry sizes measured while the caches were still filling, and assumed entries compress to ~55% of the ccache directory. Neither held. Measured from the Save steps on every main run 2026-09-01..05: | entry on main | cap | budgeted | measured | ×2 kept | |---|---|---|---|---| | `ccache-linux-build-` | 2000M | 1.1 GiB | **1.88 GiB** (ccache compresses its own objects; entry ≈ 95% of dir) | 3.8 | | `sccache-windows-` | 1G | 0.38 | 1.02 | 2.0 | | `ccache-sid-canary-` | 1000M | 0.57 | 0.94 | 1.9 | | `ccache-ccache-macos-ci-` | 500M | 0.18 | 0.44 | 0.9 | | deps (Qt macOS 1.3, install-qt 0.4, …) | | 1.8 | 1.8 | 1.8 | | | | **6.3** | | **~10.4** | That is over the allowance with zero PR-ref entries, so aethersdr#5009 and aethersdr#5548 item 1 (which trim PR refs) cannot fix it on their own. **KEEP=1** frees ~4.5 GiB and brings main to ~6.1 GiB. The second copy was insurance against a sparse/corrupt save; the `success()` gate on every save step already prevents the sparse case, the insurance was never used in 25 days, and one corrupt-entry recovery would cost a single cold build where the insurance was causing them weekly. **1500M, not 1000M**: the 2000M cap was earning its keep — main hit rate 99.2–99.7% at 2000M versus 94.21% recorded at 1000M, PR runs 91–99%. 1500M trims the largest entry to ~1.4 GiB (budget ~5.5 GiB) without giving the whole window back. The comment says how to judge it from the "ccache stats" step. ## Verification Cannot be exercised on this PR: `prune-main` runs only on a main push, and the Linux save only fires from main. Locally: both files parse as YAML; no behavioural change beyond the two values. After merge, over the following week: - `prune-main`'s "Report repo cache usage" line reads ≤ ~6 GiB (≤ ~8 GiB while open PRs still hold dependency caches, until item 1 lands). - "Restore ccache objects" on main is never 0 s; Build stays in the 3–6 min band. - `ccache --show-stats` on main: "Cache size" ~1.5/1.5 GB and hits ≥ ~99% once the entry has re-warmed (the first save after merge starts from the 661 MB cold entry now on main). Items 1 (dependency caches save-on-main, with the install-qt-action → aqt port) and 3 (tag-ref sweep) follow in a separate PR. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…#5548) (aethersdr#5552) ## What Items **1** and **3** of aethersdr#5548. Independent of aethersdr#5551 (items 4/5a/5b); the two can land in either order. ### Item 1 — dependency caches restore on PR, save only on main (`ci.yml`) Every dependency cache was a bidirectional `actions/cache` step: | job | step | key | size | |---|---|---|---| | build (Linux) | DeepFilterNet3 | `deepfilter-Linux-X64-…` | 17 MiB | | check-windows | Qt (via `install-qt-action`, `cache: true`) | `install-qt-action-windows-…` | 396 MiB | | check-windows | FFTW3 | `Windows-fftw3-3.3.5-…` | 2 MiB | | check-windows | DeepFilterNet3 | `deepfilter-Windows-X64-…` | 31 MiB | | check-macos | Qt | `qt-6.8.3-macos-clang64-…` | **1,297 MiB** | | check-macos | DeepFilterNet3 | `deepfilter-macOS-ARM64-…` | 24 MiB | | check-macos | qtkeychain | `qtkeychain-macos-arm64-…` | ~0 | A PR that missed wrote its own copy onto `refs/pull/N/merge`, readable by nothing else. With the repo at its allowance, LRU evicted main's copy between runs and the next PR re-saved it — four open PRs each holding the same 1.3 GiB Qt entry on 2026-09-10. Each step is now `actions/cache/restore` next to its setup script, plus an `actions/cache/save` **at the end of the job, after the tests**, gated on `github.ref == 'refs/heads/main' && success() && steps.<id>.outputs.cache-hit != 'true'`. All three halves matter: the bidirectional step these replace saved from a post step with `post-if: success()`, so a tree only reached main once the whole job had passed with it, and an inline save would persist a tree the build never validated under a content-hash key nothing re-saves; the keys are content hashes, so a save against an existing key would only warn on every main run. `cache-hit` semantics are unchanged (no step uses `restore-keys`), so the existing setup-step gating is untouched. The reasoning and the accepted cost — a PR that bumps Qt or edits a setup script misses on every push until it merges — are written once on the Linux job's steps. `QT_VERSION` and `AQTINSTALL_VERSION` move to workflow-level `env:` so check-windows and check-macos read one value; `QT_ARCH` stays on check-windows. **Behaviour change beyond the split:** the Windows `Install FFTW3` step is now gated on the cache hit (it ran on every run before). `setup-fftw.ps1` writes only under `third_party/fftw3` and exports nothing, so a hit needs no script run; this matches `windows-installer.yml`'s copy of the step. **Windows Qt port.** `jurplel/install-qt-action` has no restore-only mode, so check-windows now installs Qt the way check-macos does: pinned `aqtinstall==3.3.0` in a venv, `aqt install-qt windows desktop 6.8.3 win64_msvc2022_64 -m qtmultimedia qtwebsockets qtserialport qtshadertools`, explicit restore/save on `qt-6.8.3-windows-win64_msvc2022_64-aqt3.3.0-mm.ws.sp.st`. A separate "Export Qt environment" step (runs on hit and miss) sets what the action set — `QT_ROOT_DIR`, `QT_PLUGIN_PATH`, `QML2_IMPORT_PATH`, `bin` on PATH — plus `Qt6_DIR` and `CMAKE_PREFIX_PATH`, and fails if `qmake.exe` is not at the expected layout path. Version and arch are spelled exactly as in `windows-installer.yml`. The Configure step is unchanged; `find_package(Qt6)` resolves through the exported variables as before. ### Item 3 — sweep tag-ref caches (`cache-cleanup.yml`) `appimage.yml`, `macos-dmg.yml` and `windows-installer.yml` run on `push: tags` only, and main never runs them, so their caches (Opus, FFTW3, hidapi, Vulkan SDK, macos-deps, DMG-leg ccache, install-qt) exist only on `refs/tags/v*`. A run on `v26.9.2` can read its own ref and main, never `v26.9.1`'s, so every release writes a full set nothing later reads; neither `prune-main` nor the closed-PR sweep looked there, and `macos-dmg`'s `ccache-action` append-timestamps its key (~0.5 GiB per leg per release). `sweep-closed-prs` → `sweep-dead-refs`, one step that handles both closed-PR refs and tag-ref entries whose `lastAccessedAt` is older than 48 h. Last-access rather than "not the newest tag" because the one legitimate reader is a re-run of the same tag's workflow after a flaky release step; 48 h keeps a next-morning retry warm. One `gh cache list` (sorted oldest-access first, so hitting the 500 cap drops entries we would keep rather than the candidates), one jq pass emitting `id:size` pairs, a null-safe idle test, and a shared delete-by-id loop with 404 tolerance. ### Also - `cache-cleanup.yml`'s closed-PR comment described the dependency-cache loop as an open ci.yml problem; it now records that ci.yml saves nothing from PR refs and that this sweep is the backstop for a regression to a bidirectional step. - The two sentences aethersdr#5551 left in `ci.yml` describing item 1 as pending are flipped. - Both "no qmake" errors now say how to clear a poisoned main entry (`gh cache delete --key … --ref refs/heads/main`), since no sweeper matches `qt-*` on main. ## Verification - Both workflows parse; every bash `run:` block passes `shellcheck -S warning` (the only hits are pre-existing `${{ }}` false positives). - **Cannot be fully exercised on this PR:** the `Save` steps only fire from main, and the sweep only from schedule/dispatch on the default branch. What this PR's own CI does prove: the Windows aqt install + export path (a cold miss, since no `qt-6.8.3-windows-…` entry exists yet), the FFTW3 miss gating, and that all three jobs still configure and build. Expect check-windows to be slower on this PR only — it is downloading Qt with no cache to restore. - The sweep script was dry-run locally against the live listing with `gh cache delete` stubbed: no candidates today (no PR-ref or tag-ref caches exist), and the pair parser / 404 path verified with synthetic input. - **After merge, by hand:** `gh cache delete` main's now-orphaned `install-qt-action-windows-…` entry (396 MiB; nothing reads it after this PR and no sweeper matches it on main). Then read the first main run's log to confirm all seven `Save` steps executed (not skipped) and note the Windows Qt entry's size against the 396 MiB the action's entry was. - After merge: first main run writes the Windows Qt entry; subsequent PRs restore it. Over the following week `gh cache list --json key,ref` should show no `qt-`/`deepfilter-`/`fftw3`/`qtkeychain-` keys on `refs/pull/*/merge`, main's dependency entries should keep a stable `createdAt`, and after the next release `sweep-dead-refs` should report the tag's set swept two days later. Trigger it once via `workflow_dispatch` after merge and read the log. Item 2 (sweep on `workflow_run`) stays deferred per the issue: with this in, there is little left on PR refs for it to reclaim. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
## Summary Continues aethersdr#3849 after aethersdr#5458 with one bounded receive-control method: `slice.setFrequency` for an existing owned slice. Coordination: aethersdr#3849 (comment). - Retains observe-only startup and the existing explicit `--allow-local-control` opt-in. No TX method, raw command, new credential path, discovery behavior or desktop migration. - Validates the control grant, canonical session/slice identity, exact observed resource revision, integer Hz, live ownership/lock/connection state and backend coverage before one typed dispatch. - Separates backend frequency observations from the existing optimistic desktop value. `accepted:true` is not a hardware ACK or convergence guarantee; revisions are freshness checks, not CAS or controller reservations. - Refuses TX-capable sessions until explicit idle readback, invalidating that knowledge on local/radio TX activity and connection/backend transitions. - Documents backend authority and coverage explicitly. Sim is the exercised path. RTL-SDR can qualify when built; TX-enabled HL2, Flex and ANAN remain unavailable where readback or verified bounds are missing. Icom stays outside the daemon's current catalogue connection scope. Existing desktop tuning remains unchanged. See `docs/aetherd-local-slice-frequency-control.md` for the precise contract, errors, competing-controller behavior and limitations. ## Constitution principles Principle II: accepted intent never fabricates observed frequency. Principle VI: no TX operation or grant is added. Principle VII: strict boundary validation and fail-closed coverage/idle checks. Principle XI: behavioral tests and deliberate guard mutations demonstrate the safety claims. ## Verification - ARM64 `AetherSDR` and `aetherd` built with the dedicated Mac toolchain, RADE enabled; ARM64 processors/executables verified, no RNNoise x86 sources, no QtWidgets daemon dependency. - Nine focused CTests passed: `control_protocol_codec_test`, `control_authorization_test`, `local_control_server_test`, `control_resource_service_test`, `radio_catalogue_test`, `control_connection_test`, `control_slice_frequency_test`, `radio_discovery_source_test`, `aetherd_discovery_startup_test`. - New socket-free production-target tests cover grants, schema, ownership, limits, delayed/differing/same-value observations, optimistic desktop separation, revision conflicts/recreation, multiple controllers, TX-idle admission, lifetime, owning thread and request budgets. - Mutation checks: removing revision validation and assuming idle at construction each failed the new test. A separate local-keying regression failed before the fix; all tests pass with guards restored. - Real daemon/local-server smoke with production simulator: observe-only refusal, opt-in connection/tuning, subscription events, two controllers, stale revisions and disconnect. - Native Cocoa desktop MCP proof on the exact rebuilt ARM64 artifact: authenticated identity, isolated settings, `txAllowed:false`, `DEMO-0001` tune/disconnect/reconnect/tune. The test instance was stopped afterward. - Engine boundary, generated touchpoint manifest, test registration, frozen CI gate and whitespace checks pass. Existing engine-boundary legacy warnings remain unchanged. - Git merge-tree confirms clean integration with refreshed upstream/main `df0520ad7725153a2802b1643d8c84edb01b450a`, preserving both subsequent upstream changes. Runtime evidence is for the reviewed branch on the aethersdr#5458 merge base, not a rebuilt synthetic merge. The new CTest does **not** bind sockets or simulate third-party firmware. Its `LocalControlServer` objects are inert binding-policy checks. The existing local-server test and manual daemon smoke bind our actual current-user local service; the server test required an unsandboxed run to bind on macOS. The daemon used `--discover-sim`, never `--discover-local`. Desktop startup retains its normal discovery listeners, but only `DEMO-0001` was selected: no hardware connection or TX testing occurred. RTL-SDR is not available in this local build; hardware convergence and Windows/Linux runtime validation remain untested. Per-PR CI is pending. ## Review The functional review found and fixed a missing invalidation edge: a local transmitting/MOX/TUNE active interval must discard previously confirmed idle, and a subsequent local false edge must not restore it. The regression failed before the fix and passes afterward. The independent architecture/control-path reviews and the security diff review found no remaining actionable findings. Security scan `eaf6de71-33fa-4562-9d92-597bb4b13a7e` reviewed the exact pre-commit working-tree patch on `981b7fe6593699ac8a5317c4cbe513ca1f56b6d7` and recorded zero findings. **Report artifact caveat:** its exporter retained two already-completed checkpoint items as deferred, so the sealed report labels coverage partial. The preserved final submitted checkpoint explicitly has complete coverage, no deferred items, and all five surfaces reviewed; the sealed report also includes those five completed surfaces. The sealed artifacts were preserved unchanged, and this is not presented as a clean automated coverage verdict. The hardware/platform limitations above still apply. ## Checklist - [x] Signed commit `abd7d72f29faa567cc17255e7362ebe74769f214`; local SSH signature verification passed. - [x] No new settings keys or dependencies; no `CHANGELOG.md` change. - [x] Clean-room typed backend integration; Flex range uncertainty is left unavailable rather than using commented-out FlexLib limits. - [x] Protocol/resource/capability documentation and test registration updated; no frozen PR test-gate expansion. --------- Co-authored-by: Jeremy [KK7GWY] <kk7gwy@aethersdr.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…sdr#5548) (aethersdr#5553) ## What Two CI-budget fixes in `ci.yml` and `codeql.yml`: 1. **Pin `py7zr==1.0.0`** in both aqt venvs (check-windows, check-macos), as a workflow-level `PY7ZR_VERSION` next to `AQTINSTALL_VERSION`. Fixes red main after aethersdr#5552. 2. **`trap-caching: false`** on the CodeQL init step. Removes a 2.9 GiB cache entry that is written on every source-touching push to main and never read. ## Why — py7zr The first main run after aethersdr#5552 ([34528490345](https://github.com/aethersdr/AetherSDR/actions/runs/34528490345)) failed in check-windows during `Install Qt LTS via aqtinstall`: ``` WARNING : Caught Bad7zFile, terminating installer workers ERROR : Specified path is bad: lib/cmake/Qt6ShaderToolsTools py7zr.exceptions.Bad7zFile: Specified path is bad: lib/cmake/Qt6ShaderToolsTools ``` This is [aqtinstall aethersdr#995](miurahr/aqtinstall#995): py7zr 1.1.x added symlink sanitising that intermittently rejects Qt's archives on Windows (~50% of runs per the reporter; maintainer confirmed the source; pinning py7zr 1.0/1.1.0 is the reported workaround). `jurplel/install-qt-action` never hit it because it pins `py7zr==1.0.*` alongside `aqtinstall==3.3.*`. The venv aethersdr#5552 introduced installs `aqtinstall==3.3.0` alone, and its `py7zr>=0.22.0` requirement resolves to 1.1.3. It passed both PR runs of aethersdr#5552 and then failed the first main run — the shape of an intermittent bug, not a main-specific one. The Qt cache key is deliberately unchanged: the extractor does not change what it lays down. Not touched: the aqt installs in `.github/docker/Dockerfile`, `appimage.yml`, `macos-dmg.yml` (Linux/macOS, no reports there). ## Why — CodeQL TRAP caching `codeql-action` uploads a TRAP cache keyed on the commit sha from every default-branch run (`trap-caching.ts`, `uploadTrapCaches`), and `codeql.yml` runs on every source-touching push to main — 5 to 23 times a day over the last two weeks. TRAP caches only speed up PR analyses: on the default branch the action logs `Analyzing default branch. Skipping downloading of TRAP caches`, and this workflow has no `pull_request` trigger, so **no run has ever restored one**. The action's own `cleanupTrapCaches` needs `actions: write`, which `codeql.yml` deliberately does not grant, so it 403s silently; LRU eviction was the only thing removing the entries, and each eviction round could take `ci.yml`'s compiler caches with it. Measured 2026-09-10: one `codeql-trap-1-2.27.0-cpp-<sha>` entry on main at **2,939 MiB** — the largest single cache in the repository, absent from every budget table in aethersdr#5008 / aethersdr#5551. Sept 6, the day the cold Linux builds began, had 23 CodeQL runs on main. With this off, main's steady state drops from ~9.5 GiB to ~6.6 GiB and the transient peak from over 10 to ~8.2 GiB. Analysis results are unaffected; only the extractor's cache of parsed files is not persisted. ## Verification - A PR run exercises the Windows aqt path cold (no `qt-6.8.3-windows-…` entry on main yet, since main's save never ran). One green run does not prove an intermittent bug fixed; the pin matching install-qt-action's known-good configuration is the evidence. - CodeQL does not run on PRs, so the `trap-caching` change is exercised by the first source-touching push to main: its log should show no `Uploading TRAP cache` line, and no new `codeql-trap-*` entry should appear. - After merge: the first main run should show `Save Qt` executed on check-windows and the entry on `refs/heads/main`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
The v26.9.2 production release generated `26.9.205.0` because aethersdr#5346 applied the Windows workflow run counter to both production and development MSIX versions. Production tag pushes now normalize the CMake release version instead: `26.9.2` produces `26.9.2.0`, independent of run number or flight configuration. Store-ineligible release versions no longer abort Windows delivery. A nonzero hotfix revision, unsupported Store patch, or mismatched/suffixed tag returns `storeEligible = false`, an empty MSIX version and an explanatory warning. The workflow skips MSIX creation, Store symbol-package validation and production Store submission while retaining portable ZIP, Inno installer, debug-symbol artifacts and GitHub release attachment. Supported releases retain their existing symbol packaging and draft-only submission behavior. Developer flights intentionally keep higher run-based versions (`26.9.<run>.0`), as requested by the operator. Their separate CLI issue and live certification proof remain outside this production fix. Documentation restores the instruction to compare production versions with the highest accepted Partner Center package before upload. Validation on the updated changes: - PowerShell 7.6.5 on macOS ARM64: all 140 socket-free Store policy assertions pass (review follow-up f2bfee5 trimmed an inert loop and added tag-spelling and workflow-gate cases). Publisher calls use an in-process substitute; no Store authentication or network calls occur. - Executed the actual planning step extracted from the workflow YAML with normal, hotfix, suffixed and mismatched tag inputs. Every case retains `releaseArtifacts=true`; only the normal release enables `storeEligible` and `productionDraft`. Checked the parsed workflow gates preserve ZIP, EXE and symbol delivery. - Five independent mutations fail: restoring either release-path throw, disabling all release artifacts for ineligible Store versions, enabling an ineligible Store submission, and restoring production run-counter numbering. Restored code passes all assertions. - actionlint 1.7.12, strict test-registration and whitespace checks pass. No test registration or frozen CI gate changes. - No application compile, Windows SDK packaging or live Store ingestion was run for this code change. The automation bridge cannot exercise packaging policy. Hosted PR CI is separate; it does not execute the tag-triggered Windows Installer workflow. The Windows release workflow change requires maintainer CODEOWNERS review. The already-repackaged 26.9.2.0 artifact is separate release recovery work. Generated with OpenAI Codex (GPT-6 Astra) --------- Co-authored-by: Jeremy [KK7GWY] <kk7gwy@aethersdr.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…'s own carrier stops reaching the demodulator (aethersdr#5850) Closes aethersdr#5497. > ## Read this before merging: this PR must NOT land alone, and the CW rule here is narrower than it was. > > **Landing order, ruled by @ten9876 on 2026-09-24: hold aethersdr#5850 until a fix for aethersdr#5498 is ready, then land them together or back to back.** Merging this alone widens the post-unkey dropout from ~114 ms to ~250 ms. Nothing below should be read as this PR being ready to merge on its own. > > **1. aethersdr#5498.** aethersdr#5497 and aethersdr#5498 occupy the same 200 ms and do **not** share a fix. The +57 dB leak this removes was partially *masking* aethersdr#5498's dropout, so deferring the unmute makes that hole **2.2× worse**: measured at `final`, **113.74 ms median on stock → 250.65 ms with this hold**. > > **No fix for aethersdr#5498 has landed.** aethersdr#5498 is still an open issue; aethersdr#5855 measures its staleness but does not close it. On current `main`, merging this alone turns a 114 ms dropout into roughly 250 ms. The leg carrying both candidate fixes measured 151.10 ms — still 37 ms worse than stock — so aethersdr#5498 does not fully pay it back either. > > **2. CW full break-in — ruled by @ten9876 / KK7GWY, 2026-09-24:** per `AGENTS.md` § Autonomous Agent Boundaries the break-in behaviour is UX and the maintainer's call. **The hold is skipped only when the break-in delay is shorter than the hold.** At the default 500 ms delay the key-up that ends the over gets the normal hold. Details in *"The CW question"* below. > > **Landing order and a trivial conflict.** aethersdr#5880 (`hl2/health-block-expiry`) touches `Hl2Backend.h` too. The overlap is **one `friend` declaration at a shared anchor** — both insert immediately after `friend struct Hl2TxGateTestAccess;` — and is resolved by keeping both lines. aethersdr#5880 adds no data members and nothing this change reads; whichever lands second needs a trivial rebase and no re-measurement. ## The CW question — ruled by @ten9876 **The ruling (@ten9876 / KK7GWY, 2026-09-24, on this PR): CW full break-in skips the unkey hold only when the break-in delay is shorter than the hold.** That is the case where the hold would swallow the space between elements and take QSK away. At a longer delay, including the default `cwDelay` of 500 ms, the hang fires once at the end of the over and that key-up gets the ordinary hold. The predicate is `cwBreakIn && m_cwHangTimer && m_cwHangTimer->interval() < m_unkeyUnmuteHoldMs`; `QTimer::interval()` still returns the hang `setCwKeying()` started after the single-shot fires, so nothing new is latched. ### Why the hang timer now calls `applyKeying` directly `applyKeying(…, cwBreakIn)` has exactly two call sites. `setKeying()` hard-codes `false`. `setCwKeying()`'s **key-DOWN** branch passes `true` — and that is the only `true` in the tree. The key-**UP** in full break-in never came through either: it arrives from the `m_cwHangTimer` timeout, which called `setKeying(false, …)`. So on the one edge that arms the hold, `cwBreakIn` was **always false, including in full break-in**. A hold gated on the parameter as it stood would have been dead code in precisely the case it exists for, and the change would have shipped looking correct and doing nothing. **The fix is to make the flag reach the release edge, not to invent a predicate.** The hang timer now calls `applyKeying(false, m_cwHangOperation, completion, /*cwBreakIn=*/true)` directly. **Nothing moves on the wire:** `applyKeying`'s only other use of the argument chooses `MetisClient::setCwMox()` over `setMox()`, and `setMoxImpl()` reads `cwBreakIn` only inside its `keyed ? … : operation` arm — so on an **unkey** the two are the same function. Checked against the source rather than assumed, because this is a transmit path. There is no reach across a thread boundary for `TransmitModel::cwBreakIn()`, and there must not be: the operator's setting arrives as `setCwKeying()`'s `breakIn` argument, the backend latches it as `m_cwAutoKeyed` for the element and its hang, and the hang timer — which is parented to the backend and therefore fires on the backend's own thread — hands it on as the parameter. ### Semi-break-in keeps the hold, and that is asserted With break-in off, `setCwKeying()` never keys at all: CW rides an MOX/PTT the operator asserted, and the over ends when **they** release it. That release arrives through `setKeying()` with `cwBreakIn` false, has a real T/R turnaround behind it, and gets the full hold. Only full break-in skips it. ### The ~17 WPM crossover is arithmetic, not a measurement Restated because it would otherwise read as observed. **There is no CW measurement anywhere behind this change** — aethersdr#5497's eleven windows are all 4-second SSB keys, and nothing in this lab has keyed CW. `setCwKeying()` starts `m_cwHangTimer` at `max(kCwEnvelopeReleaseMs /* 6 ms */, cwDelay)` per element, so at the deliberate-QSK setting of `cwDelay = 0` every element's release reached `applyKeying(false)` and armed the 70 ms hold; the next element's key-down stopped the timer and re-muted. At PARIS timing the inter-element space is `1200/WPM` ms, so it falls below: - `kUnkeyUnmuteHoldMs` = 70 ms at **1200/70 = 17.1 WPM** — the figure this PR and both reviews quoted; and - the **76 ms** that actually elapsed from element key-up to unmute, the 6 ms hang **plus** the hold it armed, at **1200/76 = 15.8 WPM**. Two divisions on two constants in `Hl2Backend`. The second is the honest one and is 1.3 WPM lower than the number on the thread, because the earlier arithmetic counted the hold and not the hang that arms it. Neither is measured, and the tests assert armed-or-not-armed rather than a speed. ### Options considered 1. **Skip the hold whenever full break-in is on.** Not taken: at a delay longer than the hold (including the default 500 ms) the hang fires only at the end of the over, so skipping there buys no between-element listening and lets the +57 dB burst back in for every break-in over. 2. **Keep the hold always and accept semi-QSK.** Not taken: at a short delay it removes the thing a QSK operator switched QSK on to get — a mode that reads "full break-in" and behaves as semi-break-in above ~16 WPM. 3. **Scale the hold to the element rate.** Not taken: more machinery than this defect deserves, and it would need a CW measurement that does not exist. **What the leak costs at a short delay.** What the receiver hears in those milliseconds is the operator's own PA decaying into their own front end, forty-plus times a second at speed — the same +57.55 dB / +10.60 dBFS artefact this change removes everywhere else. It is accepted only where suppressing it would take QSK away. ## The mechanism `Hl2Backend::setKeying` queued the demodulator unmute and *then*, later in the same function, queued the MOX-off. Both targets live on **the same thread** — `Receiver::dsp` is moved to `m_ioThread` in `openReceiverDsp`, `MetisClient` in the constructor — and both edges are `Qt::QueuedConnection`. So this was never a race the unmute might lose: it is FIFO in posting order and the unmute was **guaranteed** to be delivered first. The demodulator listened at full gain while the PA was still up. Measured on ON8ST's own radio into a dummy load: **+57.55 dB** over the receiver's own resting floor, sample peak **+10.60 dBFS** — 3.4× full scale — on 11 of 11 windows. The T/R turnaround was measured with an estimator that uses **both edges** of the leak, so the threshold level cancels algebraically: **W = 59.40 ms median (51.66–66.15, n=11)**, spread/median 0.224. A registered falsifier — that the spread would be comparable to W itself, making a fixed timer the wrong shape of fix — was tested and did **not** fire. **There is nothing to gate on instead, and this was checked rather than assumed.** `Ep6Response::ptt` is decoded, but `MetisClient`'s own note records `ptt_resp` as `cw_on | ext_ptt` — the radio's *external* keying inputs, which never go high for a host MOX key. There is no readback of host MOX on the EP6 wire. A delay is forced. ## The change - Key-down mutes immediately at the top of `applyKeying`; the key-up release moved **below** the `setMox` invoke, so the ordering invariant holds even at a hold of zero, and `m_unkeyUnmuteHoldMs` defers on top of that. - **The hold is armed by the key-up EDGE, not by the unkeyed STATE.** `applyKeying` already computes `keyChanged` for `m_sinceUnkey` and `transmitChanged`; the release now consults it. A second `setKeying(false)` arriving with `m_keyed` already false started no T/R turnaround and so has none of its own to cover — it inherits the one already being covered. - **`mixReceiverAudio` gates on `m_rxAudioMuted`**, the same flag the demodulator uses, instead of `m_keyed`. The deferral window is then a *gap* in `audioFrameReady` rather than 70 ms of digital zeros — zeros fill the engine's presentation prebuffer with silence that must play out before live audio can, which is why the bench's deferred-unmute leg cost ~137 ms at the device for a 70 ms constant. This also removes the `m_keyed`/mute skew `setKeying`'s own comment apologised for. - `applyRxAudioMute` checks its thread affinity in **every** build: a `qCWarning(lcHl2)` always, with `Q_ASSERT_X` on top so a debug run stops at the offending call. `Q_ASSERT` alone compiles out under `QT_NO_DEBUG`, which Qt defines for every non-Debug configuration — i.e. exactly the build that ships. - `setTxAudioMonitor` **splits on `on`**: turning the monitor ON is answered immediately, in every state, because asking to hear the receiver is asking for something; turning it OFF while an unkey's hold is armed leaves the hold running, because asking for off is asking for nothing and cancelling it would unmute inside the T/R window. - **CW full break-in skips the hold only when the break-in delay is shorter than the hold**, per @ten9876's ruling. At the default 500 ms delay the hold arms normally. The predicate is `cwBreakIn && m_cwHangTimer && m_cwHangTimer->interval() < m_unkeyUnmuteHoldMs`. The `m_cwHangTimer` timeout now calls `applyKeying(false, …, /*cwBreakIn=*/true)` instead of `setKeying()`, which is what makes the flag reach the key-up edge at all; the release branch gains an `else if (cwBreakIn)` arm that stops the timer and unmutes now. Wire-identical on an unkey. Semi-break-in takes the branch below it and keeps the full hold. - The constant is `kUnkeyUnmuteHoldMs = 70` seeding a member a test can set to zero, with the measurement and the coverage table (70 ms covers 11/11 with 3.85 ms to spare; 65 covers 9; 60 covers 7) in its comment. There is no setting behind it: a second station needing a different value needs a new measurement and a rebuild. **Two things the diagnosis did not name, both found while implementing.** `setTxAudioMonitor` also drives the mute, and it is now the split above rather than a single immediate path. And `pushInitialState`'s session reset cleared `m_keyed` only; since the mixer no longer reads it, a reconnect-while-keyed session would have been handed a permanently closed gate on the path where its `setKeying(false)` is skipped. That reset now also runs its unmute **after** the cleanup `setKeying(false)`, so the relink path does not reproduce the posting order this PR exists to remove. ## Evidence `tests/hl2_unkey_hold_test.cpp` — an **ordering** test, because this is a scheduling defect. No WDSP, no socket; a real `Hl2RxDsp` probe on the real I/O thread, flushed by a blocking round-trip so "one event-loop turn" is exact. **Before** (stock ordering restored, accessors kept so it compiles): ``` [FAIL] the demodulator is STILL MUTED after the MOX-off has been applied [FAIL] the MOX-off is queued BEFORE the unmute lands, not after [FAIL] the mixer gates on the same flag, so the hold is a GAP and not zeros [FAIL] re-key: the hold is armed [FAIL] re-key: still muted half way through the hold [FAIL] re-key: the second unkey arms a fresh hold [PASS] POSITIVE CONTROL: at a hold of ZERO the release is IMMEDIATE ``` **After:** **80 checks, all pass.** Mutations, each applied, rebuilt, run and reverted: | mutation | reds | |---|---| | M1 — hang timer back to `setKeying(false, …)`, so `cwBreakIn` never reaches the key-up | **7** | | M2 — QSK arm disabled (always hold) | **7** | | M3 — predicate widened to an unconditional `else if (cwBreakIn)` | **3**, all in leg 7c (break-in at the default 500 ms) | | M4 — boundary loosened from `<` to `<=` | **2**, both in leg 7d (delay equal to the hold) | **The positive control passes on both legs, and that is the point.** "Still muted after one turn" is also what a dropped `invokeMethod`, a renamed slot, an unattached DSP and a forgotten `processEvents()` all look like. At a hold of zero, stock and fixed are identical — so that leg cannot mask the defect and can only rule out a dead harness. The redundant-unkey leg **measures** rather than asserts, because the number is charged to aethersdr#5498: with a 200 ms hold, 311 ms of mute past the real MOX-off before the edge guard and 210 ms after it. **The CW legs, with the numbers read off the run.** The structural assertions read `holdArmed()` at the instant the MOX-off lands, while an armed hold would still be running: ``` [PASS] QSK: NO HOLD IS ARMED at the instant the MOX-off lands MEASURED: MOX-off applied 6 ms after the element key-up; hold armed at that instant: NO (hang 6 ms, hold 400 ms) MEASURED: the receiver opened 6 ms after the element key-up (hold 400 ms) [PASS] between-elements: STILL open a full 48 ms inter-element space after the KEY-UP MEASURED: 48 ms after the element key-up, muted: NO (shipped hold 70 ms) [PASS] break-in/500: THE HOLD IS ARMED — a break-in delay LONGER than the hold does not skip it MEASURED: MOX-off applied 476 ms after the key-up; hold armed at that instant: YES (hang 500 ms, hold 400 ms) MEASURED: the hold held the mute 380 ms past the MOX-off (hold 400 ms) [PASS] break-in/equal: THE HOLD IS ARMED — a delay equal to the hold is not shorter than it [PASS] semi: THE HOLD STILL ARMS — only short-delay full break-in skips it MEASURED: semi-break-in held the mute 189 ms past the MOX-off (hold 200 ms) [PASS] double-unkey/QSK: a redundant unkey on an open chain arms no hold MEASURED: with the CW branch present, the mute lasted 210 ms past the real MOX-off (hold 200 ms) ``` **6 ms is `kCwEnvelopeReleaseMs` and nothing else** — the CW envelope hang at `cwDelay = 0`, with no hold behind it. Against a deliberately inflated 400 ms hold, so "skipped" and "short" cannot be confused. The between-elements leg runs on the **shipped 70 ms** constant rather than a test value, because that is the number an operator would have met. **The double-unkey edge guard holds with the CW branch present:** 210 ms for a 200 ms hold, against ~300 ms had the new arm restarted it. **The legs assert on `m_unkeyUnmuteTimer->isActive()`, not only on the mute.** An expired hold also leaves a chain unmuted, so "the receiver is open" alone would not distinguish a skipped hold from a hold that had simply run out. **The build slot was held across the compile AND the run.** These legs measure mute duration against the wall clock, so a competing build does not merely slow them, it moves them. Full `ctest`: **100% of 515 passed**, `HAVE_HIDAPI` OFF. One caveat stated rather than quietly dropped: the first run of that suite had `vkamp_connection_test` fail under `-j4`. It passes alone and passed on the clean re-run; it is a socket test that touches nothing in this change (`grep` for `Hl2Backend|setKeying|cwBreakIn` in it: zero hits). Reported because a flake I do not mention is a flake the next person rediscovers. ## For whoever picks up aethersdr#5498 The RXA-hold candidate removes the bit-zero anchor the bench's T/R-window estimator depends on, so **W cannot be re-measured the same way** on a build carrying it. Measure W first, or keep a binary without the hold. **One caveat on the 250.65 ms figure, stated rather than quietly left.** It was measured on a build that could restart the hold on a redundant unkey (the edge guard above is new). On a single clean unkey it is unaffected; on any path that issues a second `setKeying(false)` inside the window it was an over-estimate. **The bench has not been re-run** — this build is not on the radio, and the figure stands as measured. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Jeremy [KK7GWY] <kk7gwy@aethersdr.com>
…ool from the ratchet Closes aethersdr#5727. direct_bool_fields() accumulated the RadioCapabilities struct a line at a time and split on semicolons, so a member function deleted the next bool from the capability ratchet. A bool added after an accessor was invisible growth with the count left flat. The scan now drops everything at depth >= 2, emits a terminator only when a brace opens a function body, and blanks comments, literals, numbers, and directives in one pass. A constructor that brace-initialises more than one member ends the statement when that block closes, and a C++14 digit separator is no longer read as a character literal. The parser test is registered as capability_record_parser and also runs on the pre-merge Static checks job. FROZEN_BOOL_COUNT stays 71: the fixed parser returns the same 71 names in the same order as the parser it replaces. Squashed-from: aethersdr#5860 Co-authored-by: on8st <258096273+on8st@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: jensenpat <patjensen@gmail.com>
…Principle XI. ## Summary Closes aethersdr#5828. `Hl2Backend::setAutoRfGain` opens on a guard that compares against the **running** flag, and its refusal path deliberately leaves the loop off with the operator's wish recorded. From that state an explicit "off" matched the guard and returned at the first line, never reaching the disarm branch — the only writer of `m_autoRfGainWanted = false`. The profile kept `rfGain.autoEnabled: true`, and the next connect from a baseline the loop trusts armed a control the operator had switched off. **The fix widens the guard onto both flags and routes the withdrawal through the existing disarm branch** — `if (on == m_autoRfGainEnabled && on == m_autoRfGainWanted)`. An earlier revision of this body argued for a separate block *before* the guard and had a whole section explaining why not to widen it. That approach was abandoned during review and this body has been corrected; see *"One exit for 'off'"* below for why widening turned out to be the right call and not merely the tidier one. ## The correction that matters more than the fix Both the triage comment on aethersdr#5828 and **my own reply agreeing with it** concluded that the withdrawal path is unreachable, so a backend-only fix would ship dead code. **The observation is right and the conclusion is wrong**, and that is the most important sentence in this PR. The observation holds. `SpectrumOverlayMenu::setAutoRfGainEnabled` does bring the checkbox back down under a `QSignalBlocker`, and says why — *"The checkbox has to be able to come back down without that looking like the operator unticking it, so this path must not emit."* After a decline there is no tick left to remove, and `MainWindow::wirePanadapter`'s `autoRfGainChanged` lambda never emits the `false`. What neither of us checked is whether the GUI is the only caller. It is not. `AutomationServer::doPan`'s `pan autorfgain on|off` verb on current `main` calls the seam directly, with no `isArmed()` guard: ```cpp ag->setArmed(on); ``` So **`pan autorfgain off` from the refused state drops the withdrawal today**, on shipped code, with no GUI involved. The verb's own comment is what makes the drop invisible rather than merely present: > DELIBERATELY NOT AN ECHO OF THE ARMED STATE. The backend may decline to arm and > log why; reporting `requested` rather than `on` keeps this verb honest about > the difference. It returns `requested: false`, which is true, while the preference stays `true` on disk. A headless caller has no way to see it. The third caller is guarded and is unaffected: `RadioCertification` takes `const bool autoGainWasOn = autoGain && autoGain->isArmed();` and only calls `setArmed(false)` inside `if (autoGainWasOn)`, so it never reaches the refused state. Those three plus `Hl2Backend`'s own `linkUp` handler are the whole caller set; there is no other route above the seam. So the backend fix is not dead code and it is not waiting on the GUI question. The GUI affordance is still worth having, is still a maintainer's call on presentation, and is deliberately **not** in this PR. ## The mechanism `Hl2Backend::setAutoRfGain`: ```cpp if (on == m_autoRfGainEnabled) { return; } ``` The refusal path sets `m_autoRfGainWanted = true` and returns with `m_autoRfGainEnabled` still `false` — deliberately, so the asking survives a decline. Those two compose into a state the control cannot leave: from `enabled=false, wanted=true`, an explicit `setAutoRfGain(false)` matches the guard and returns before the disarm branch. `Hl2Backend::currentOperatingState` persists the **wish** (`{"autoEnabled", m_autoRfGainWanted}`, and its comment explains at length why that is the right thing to persist), so the profile keeps `rfGain.autoEnabled: true`. The `linkUp` handler's `if (m_autoRfGainWanted && !m_autoRfGainEnabled) setAutoRfGain(true);` then arms on the next connect from a baseline it trusts. **This is the default path, not a corner.** `kLnaDefaultGainDb` is +20 dB and `Hl2Backend::kAutoRfGainMaxBaselineDb` is +19, so the first tick on a radio nobody has retuned lands in the refusal. `applyRestoredState`'s own comment already says exactly that. ## One exit for "off", and why the earlier plan was wrong This body previously argued for a second exit *before* the guard, on the grounds that falling through to the disarm branch would log *"disarmed, baseline … restored"* for a loop that never ran. **That reasoning does not survive contact with what the caller is owed.** A withdrawal is not a different event from a disarm. It **is** a disarm, of a loop that happens not to be running, and it owes the caller exactly what a disarm owes: the wish cleared, the refusal reason dropped, a settled verdict emitted, the document republished. Hand-copying that subset into a second exit is what produced the defect this PR's own review caught — a "successful off" that went on reporting *"declined"* through the bridge reply and through the checkbox's accessible description. **One exit, one list.** **And every side effect of the disarm branch is inert from the refused state**, which is what makes routing it there safe rather than merely tidier: - `m_autoRfGainEnabled` is already false. - `applyBandscopeForAutoGain()` computes `wanted` false and returns at `if (!m_bandscopeOwnedByAutoGain)`, because that flag is set only where `m_autoRfGainEnabled` was true. - `m_autoGainReason` is already `Disarmed` — its member default, and the value every route out of a running loop leaves behind. - `m_autoGainState` is default-constructed for the same reason. - `setLnaAutoOffsetDb(0)` returns at its own `requested == m_lnaAutoOffsetDb`, the offset being 0 unless the loop ran. **The one thing that was not inert is the log sentence, and it is conditioned.** That was the whole of the original objection, and it costs one predicate. **The combination the widening newly admits to the arm branch — `on` true, enabled true, wanted false — is unreachable.** `m_autoRfGainEnabled = true` is written in exactly one place and the next statement sets `m_autoRfGainWanted = true`, while every writer of `wanted = false` (the disarm below, and `applyRestoredState`) has already set enabled false. The disarm branch's existing comment — that a REFUSAL does not reach it — stays true and is not contradicted: it is about the refusal itself, and a later explicit off is a different event. Squashed-from: aethersdr#5862 Co-authored-by: on8st <258096273+on8st@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: jensenpat <patjensen@gmail.com>
…theme token Closes aethersdr#5857. Radio Setup value labels used the colour literal #00c8ff, which is neither theme's color.accent.bright (#00c8f0 on Default Dark, #0098c0 on Default Light). A plain setStyleSheet does not re-resolve when the theme changes. Default Gateway and Network Name already called applyStyleSheet, but they passed that literal, so they were tracked and still never moved. All sixteen sites now go through makeValueLabel() and {{color.accent.bright}}. The literal is deleted. radio_setup_region_field_test switches Default Dark and Default Light and reads the resolved colour back. Squashed-from: aethersdr#5863 Co-authored-by: on8st <258096273+on8st@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: jensenpat <patjensen@gmail.com>
…reading rfGain.defaultDb (aethersdr#5829). Principle VII. Closes aethersdr#5829. ## Behaviour change every operator will hear, stated first **An unvisited band now comes up at +20 dB instead of −6 dB on any profile that carries a stuck `rfGain.defaultDb` — 26 dB hotter.** It is receive-only; no transmit path reads this value and nothing here touches the TX gate. But an operator who upgrades and changes nothing will hear a louder band on the first visit to one they have never tuned, and should not have to discover that from a changelog line. On a profile without the key — which is every profile any shipped build has ever produced — nothing changes: +20 was already what they got. The change is visible exactly on the profiles the issue is about. Bands the operator has actually visited are untouched. Those live in `rfGain.lnaDbByBand`, they are real operator memory, and this PR preserves them byte for byte. **This is a maintainer's to accept, not a PR's to assume — DECISION 2 of 2 below.** aethersdr#5829 asked for the consequence to be announced and announcing it is what this section does; whether a 26 dB receive-only change is acceptable to land on an upgrade is a separate judgement, and it is not made here. ## What the key was `Hl2Backend` persisted `rfGain.defaultDb`, read it back on restore into `m_lnaDefaultDb`, and used that member as the LNA gain an **unvisited** band comes up on. Nothing in the tree ever wrote it. Its value was a closed loop — read from the document, used, written straight back out — so whatever a profile held, it held forever, and no operator action could move it. The reporting station's profile sat at −6 dB permanently, with ten of its twelve stored bands at that value because each inherited it on its first visit. ## 1. Nothing writes it — the search, not the claim `git log -S` and `git blame` are not trustworthy in a shallow clone, so this is a **symbol search over the whole tree at `origin/main`**, not a history search. Every occurrence of the member, tree-wide: ``` $ git grep 'm_lnaDefaultDb' origin/main origin/main:src/core/backends/hl2/Hl2Backend.cpp: m_lnaDbByBand.value(m_currentBandKey, m_lnaDefaultDb), origin/main:src/core/backends/hl2/Hl2Backend.cpp: m_lnaDefaultDb, kLnaGainMinDb, kLnaGainMaxDb); origin/main:src/core/backends/hl2/Hl2Backend.cpp: m_lnaDefaultDb = hl2::kLnaDefaultGainDb; origin/main:src/core/backends/hl2/Hl2Backend.cpp: m_lnaDefaultDb = qBound(kLnaGainMinDb, origin/main:src/core/backends/hl2/Hl2Backend.cpp: QJsonObject rfGain{{QStringLiteral("defaultDb"), m_lnaDefaultDb}, origin/main:src/core/backends/hl2/Hl2Backend.cpp: m_lnaDbByBand.value(newBand, m_lnaDefaultDb), origin/main:src/core/backends/hl2/Hl2Backend.h: int m_lnaDefaultDb = hl2::kLnaDefaultGainDb; ``` (Line numbers deliberately omitted throughout — a line citation in this file does not survive the next merge, as the citation check on the issue thread demonstrated. Every reference below is by file and symbol.) Seven sites, and **exactly two are assignments** — both inside `Hl2Backend::applyRestoredState`: the full reset at its head, and the read-from-document further down the same function. One is a persist, in `Hl2Backend::currentOperatingState`. The remaining four are the reads and the in-class initialiser. A case-insensitive `git grep -in 'lnadefault'` over the same tree returns the identical seven lines plus the constant `hl2::kLnaDefaultGainDb` — **no setter, no extension verb, no GUI control, no automation path.** And the key itself, in `src/`: ``` $ git grep 'defaultDb' origin/main -- 'src/' origin/main:src/core/backends/hl2/Hl2Backend.cpp: if (rfGain.contains(QStringLiteral("defaultDb"))) origin/main:src/core/backends/hl2/Hl2Backend.cpp: rfGain.value(QStringLiteral("defaultDb")).toInt(), origin/main:src/core/backends/hl2/Hl2Backend.cpp: QJsonObject rfGain{{QStringLiteral("defaultDb"), m_lnaDefaultDb}, origin/main:src/core/backends/hl2/Hl2BandMemoryPolicy.h: int defaultDb, int minDb, int maxDb) origin/main:src/core/backends/hl2/Hl2BandMemoryPolicy.h: out.liveDb = clampDb(minDb, hasStoredEntry ? storedDb : defaultDb, maxDb); origin/main:src/core/backends/hl2/Hl2BandMemoryPolicy.h: out.liveDb = defaultDb; ``` The three `Hl2BandMemoryPolicy.h` hits are a **function parameter**, not the document key — `hl2::connectLna` receives whatever the caller passes. The one other producer in the tree that can touch an `rfGain` sub-object is `RadioStateMemory::storeRtlRfGainPreservingLegacy`. It **refuses any scope whose family is not `rtl`** as its first act, and even within that scope it inserts `gainDb` and nothing else, deliberately preserving unknown siblings. So it can propagate an existing `defaultDb` but cannot mint one, and it cannot reach an HL2 scope at all. That path is ruled out, not assumed. ## 2. What reads it, and the absent-versus-present delta Three readers, all of them answering the same question — *what gain does a band with no stored entry come up on?* | reader | what it does | key absent (before) | key present (before) | |---|---|---|---| | `Hl2Backend::connectRadio`, the `hl2::connectLna` seed | start band's gain at connect: `m_lnaDbByBand.value(m_currentBandKey, m_lnaDefaultDb)` for the stored value, and `m_lnaDefaultDb` again as the policy's `defaultDb` argument | `kLnaDefaultGainDb`, because the reset at the head of `applyRestoredState` left the member there | the persisted value, clamped to −12..+48 | | `hl2::connectLna` in `Hl2BandMemoryPolicy.h` | returns that `defaultDb` verbatim for a restored session whose start band has no entry, and for the no-restore/no-param case | `kLnaDefaultGainDb` | the persisted value | | `Hl2Backend::applyPerBandStateFor` | every band change: `m_lnaDbByBand.value(newBand, m_lnaDefaultDb)` | `kLnaDefaultGainDb` | the persisted value | So **the key-absent column is already `kLnaDefaultGainDb` everywhere.** That is the whole behaviour delta: this PR makes the key-present column read the same as the key-absent one, which it could not do before because the document won. ## 3. Where `kLnaDefaultGainDb` was already authoritative, and where it was not It was already the single answer in four places, and this is why the change closes a gap rather than adding a second source of truth: - `Hl2Backend::m_lnaGainDb`'s in-class initialiser, and its reset at the head of `applyRestoredState` — the **live** gain a virgin backend and a memoryless radio come up on. - `m_lnaDefaultDb`'s own in-class initialiser in `Hl2Backend.h` — the member being removed here was *itself* initialised to the constant, which is why an absent key already behaved correctly. - `MetisClient::Params::lnaGainDb` — the wire layer's own default. - `Hl2DbReference::kDefaultLnaGainDb`, defined as `= kLnaDefaultGainDb`, so the dB reference chain already derives from it. And the arithmetic in `Hl2Backend.h` and `Hl2Backend.cpp` that explains why the auto-RF-gain loop cannot be armed by default depends on the constant being the shipped baseline: `kLnaDefaultGainDb` (20) sits one dB above `kAutoRfGainMaxBaselineDb` (19). `tests/hl2_gain_split_test.cpp` asserts that relation directly. **That reasoning was already false for any profile carrying a stuck `defaultDb`** — on the reporting station's radio the effective baseline was −6, comfortably inside the trusted region, and the comment's account of what a fresh connect does did not apply. Making the constant authoritative is what makes that documented arithmetic true for everyone. The one place it was **not** authoritative is the unvisited-band fallback, which is precisely the defect. There is now no member: `Hl2Backend.h` carries a comment where `m_lnaDefaultDb` was, saying why a member there would reintroduce the class. ## 4. The change In `src/core/backends/hl2/Hl2Backend.cpp`: - `applyRestoredState` no longer reads the key. The `if (rfGain.contains("defaultDb"))` block is replaced by a comment explaining the closed loop and saying that a document still carrying the key is **not rejected — simply not consulted**, which is what "authoritative" has to mean. - `currentOperatingState` no longer emits the key. This is the half that heals an existing document: the next snapshot writes the `rfGain` object without it and the stuck value decays out with no migration code. - The three readers take `hl2::kLnaDefaultGainDb` directly. - **A document that still carries the key says so in the log**, added in review follow-up. This boundary logs every other value it declines — the pre-aethersdr#4914 CW passband just above, the invalid mic level just below — and this was the one drop that left no trace, on exactly the profiles whose operator hears the change above. It names both numbers, in the mic level's own `toVariant()` form so a hand-edited non-number shows as what it is: ``` HL2: ignoring stale restored LNA default (aethersdr#5829) QVariant(qlonglong, -6) — unvisited bands come up on 20 dB ``` - The reset at the head of `applyRestoredState` goes with the member. In `src/core/backends/hl2/Hl2Backend.h`, `m_lnaDefaultDb` is removed, with a note on the asymmetry against `m_driveDefaultPercent` — that one has a real first-use latch seeded from the first drive the operator sets, and is genuinely per-profile. The two keys sit beside each other in the document and the resemblance is misleading; the issue says so and the header now says so too. **This also retires a clamp the codebase's own rule rejects.** The old read `qBound`ed the document value and `currentOperatingState` then persisted the clamped result. That is exactly what the AGC threshold restore earlier in the same function refuses to do, in its own comment: *"a value outside it is DROPPED rather than clamped: clamping would invent a setpoint the operator never chose and then persist it back."* Under this change the inconsistency disappears with the key rather than needing its own decision. ### The direction is DECISION 1 of 2, and it has not been ruled on aethersdr#5829 carries `maintainer-review` and left the choice between a first-use latch and "the constant is authoritative" explicitly to a maintainer. **There is still no maintainer comment on that thread** — the only two commenters are the triage bot and me — so the ruling the issue asked for is being made at review time rather than before it. I said on the issue that I would not start before a maintainer picked the direction, and then started anyway. That was mine to get wrong; it does not change what is owed here. **A ruling against this direction is not wasted work — say so and I will take the latch instead.** The reasoning that led me to option 2, for a maintainer to accept or overrule: ### The option that was not taken The issue offered a first-use latch instead, mirroring `m_driveDefaultPercent`. It was rejected because it reproduces the same class in milder form: once latched, `m_driveDefaultPercent` is equally unreachable for the life of the profile. Adding a second frozen-value key to fix the first one is not a fix. It would also have kept the clamp-versus-drop inconsistency alive. ## 5. The three inverted assertions These are inversions, not additions. Each carries a comment in the test itself saying why, so none of them reads as an assertion bent to make a build pass. **(a) `tests/hl2_state_restore_test.cpp`, the validation-boundary block.** Was `rfGain.value("defaultDb").toInt() <= 48` — *"a restored LNA default clamps to the AD9866's range"*, fed a document holding 999. Now `!rfGain.contains("defaultDb")` — *"a restored LNA default is neither consulted nor written back: the key is absent from the capture, not clamped into it."* **This one had to be inverted rather than deleted or left alone.** Left as a bound it would have passed vacuously: a missing key reads 0 through `toInt()`, and 0 is ≤ 48. The assertion would have gone green while testing nothing. **(b) `tests/hl2_state_restore_test.cpp`, the radio-swap block.** Was `rfGain.value("defaultDb").toInt() == 20` — *"the LNA default resets to the virgin construction value"*, checking that `applyRestoredState({})` cleared radio A's member before radio B came up. Now `!rfGain.contains("defaultDb")` — *"radio A's persisted LNA default does not survive the swap, and nothing writes a new one: the key is gone from the capture."* There is no member to reset any more, so the property that replaces it is that radio A's stuck 6 cannot appear in radio B's capture at all, because no capture emits the key. Ozy311's radio-swap-leak finding from the aethersdr#4619 review is still guarded, by a stronger statement. **(c) `tests/hl2_gain_restore_test.cpp`, the native-gain loop over {20, 24, 32, 48}.** Was `bandGain(captured, "20m") == gain && ...value("defaultDb").toInt() == gain` — *"capture preserves the stored band and default gain."* Now `bandGain(captured, "20m") == gain && !...contains("defaultDb")` — *"capture preserves the stored band gain and emits no LNA default."* **Only the second half is inverted.** The per-band value is real operator memory and nothing about it changed; that half is asserted unchanged. The `defaultDb` half asserted a round trip of a key nothing could write, and is asserted absent instead. For completeness: `tests/hl2_gain_split_test.cpp` and two other fixtures in `hl2_state_restore_test.cpp` *seed* `defaultDb` but never assert on it. Those fixtures are left seeding it deliberately — a stale key in an input document is now exactly the condition the fix has to survive. ## 6. The test, and why it goes where it goes Extended `tests/hl2_gain_restore_test.cpp` — an already-registered, socket-free target (`tests/tests.cmake`) that constructs `Hl2Backend` directly against TEST-NET-1 with `boardMaxRx` set, so discovery never opens a socket and no event loop runs. It is the right home over the other two candidates because **its subject is exactly the question the key answered**: what gain does a restored profile produce. Its `GainSession` helper already gives a synchronous connect, a live-gain read through the health snapshot, and band hops via `setSliceFrequency`. `hl2_state_restore_test.cpp`'s subject is the validation boundary — whether fields are dropped or clamped — and `hl2_gain_split_test.cpp`'s is the auto-gain split and the arming refusal. Both needed assertion edits; neither is where a *resulting gain* belongs. The assertion, and the second half is the point: ```cpp check(gainWithNoKey == gainWithStaleKey, "a profile carrying a stale rfGain.defaultDb comes up on the same " "unvisited-band gain as a profile without the key"); check(gainWithNoKey == hl2::kLnaDefaultGainDb, "and that gain is the shipped kLnaDefaultGainDb, which is now the " "only answer to what an unvisited band comes up on"); ``` Two profiles, identical except for the key — one without it, one carrying **−6, the number the reporting station's profile actually held** — each hopped to 15 m (21.074 MHz), a band neither has an entry for, since `rememberedGain()` stores only 20 m and 40 m. A fix that stopped writing the key but kept reading it would satisfy the first profile and fail the second, which is the failure mode that matters: *the fix is worthless if an old profile keeps steering the radio.* A third check asserts the key is gone from the stale profile's next capture — the healing half. ## 7. Mutation evidence The mutation is the whole source change reverted while the new test stands, which is the sharpest form available here: `git checkout origin/main -- src/core/backends/hl2/Hl2Backend.cpp src/core/backends/hl2/Hl2Backend.h`, rebuild, run. **Mutant — `src/` at `origin/main`, tests as in this PR.** The rebuild is real; the compiler output is included so the result cannot be a stale binary: ``` $ git checkout origin/main -- src/core/backends/hl2/Hl2Backend.cpp src/core/backends/hl2/Hl2Backend.h $ cmake --build build --target hl2_gain_restore_test hl2_state_restore_test [2/10] Building CXX object CMakeFiles/aethercore.dir/src/core/backends/hl2/Hl2Backend.cpp.o [5/10] Linking CXX static library libaethercore.a [6/10] Building CXX object CMakeFiles/hl2_gain_restore_test.dir/tests/hl2_gain_restore_test.cpp.o [7/10] Linking CXX executable hl2_gain_restore_test [8/10] Building CXX object CMakeFiles/hl2_state_restore_test.dir/tests/hl2_state_restore_test.cpp.o [9/10] Linking CXX executable hl2_state_restore_test $ ./build/hl2_gain_restore_test ; echo exit=$? ... [FAIL] capture preserves the stored band gain and emits no LNA default (x4, the {20,24,32,48} loop) [FAIL] a restored stale defaultDb is dropped from the next capture [FAIL] a profile carrying a stale rfGain.defaultDb comes up on the same unvisited-band gain as a profile without the key [ OK ] and that gain is the shipped kLnaDefaultGainDb, which is now the only answer to what an unvisited band comes up on exit=1 $ ./build/hl2_state_restore_test ; echo exit=$? ... [FAIL] a restored LNA default is neither consulted nor written back: the key is absent from the capture, not clamped into it [FAIL] radio A's persisted LNA default does not survive the swap, and nothing writes a new one: the key is gone from the capture exit=1 ``` **Read which check stayed green, because it is the diagnosis.** Under the mutant, *"and that gain is the shipped kLnaDefaultGainDb"* still passes — the no-key profile was always right, and a test that only checked that half would have gone green against the unfixed code. The check that dies is the **equality between the two profiles**: the stale one comes up on −6 while the clean one comes up on +20. That is the defect, reproduced by the test, in the only place it lives. **Fix restored, rebuilt, re-run:** ``` $ cmake --build build --target hl2_gain_restore_test hl2_state_restore_test hl2_gain_split_test [5/12] Linking CXX static library libaethercore.a [6/12] Building CXX object CMakeFiles/hl2_gain_restore_test.dir/tests/hl2_gain_restore_test.cpp.o [7/12] Building CXX object CMakeFiles/hl2_gain_split_test.dir/tests/hl2_gain_split_test.cpp.o [8/12] Linking CXX executable hl2_gain_restore_test [9/12] Linking CXX executable hl2_gain_split_test [10/12] Building CXX object CMakeFiles/hl2_state_restore_test.dir/tests/hl2_state_restore_test.cpp.o [11/12] Linking CXX executable hl2_state_restore_test hl2_gain_restore_test exit=0 48 OK, 0 FAIL hl2_state_restore_test exit=0 68 OK, 0 FAIL hl2_gain_split_test exit=0 70 OK, 0 FAIL ``` ## 8. Build and test Configured with the line this lab mandates, in a fresh worktree branched from `origin/main` (`e944ec49`): ``` cmake -B build -G Ninja \ -DCMAKE_IGNORE_PREFIX_PATH="/opt/homebrew;/opt/local" \ -DCMAKE_PREFIX_PATH=/Users/stanclaes/Qt/6.8.3/macos \ -DENABLE_ASR=OFF -DCMAKE_BUILD_TYPE=RelWithDebInfo ``` Full build clean: `[3628/3628]`, no warnings introduced. ``` $ ctest --test-dir build --output-on-failure -j4 99% tests passed, 2 tests failed out of 522 Total Test time (real) = 254.68 sec The following tests FAILED: 45 - wdsp_channel_test (Failed) 390 - backend_seam_affinity_test (Failed) ``` **`HAVE_HIDAPI` is OFF for this build**, read back from this tree's own `CMakeCache.txt` (`HIDAPI_FOUND` is empty, so the `target_compile_definitions(aethercore PUBLIC HAVE_HIDAPI)` branch in `CMakeLists.txt` never ran). That describes the configure line above, not the machine — it matters because it is the opposite of what CI does, so an optional-off break shows up here and nowhere else. Neither failure below is one. **Neither failure is from this change**, and both were checked rather than assumed: - **`backend_seam_affinity_test` — pre-existing on `main`.** It fails deterministically, standalone as well as under ctest: *"probe table covers every declared seam signal (51 probed, 52 declared; unprobed: `autoRfGainArmSettled`)"*. That signal is declared in `AutoRfGainControl.h` / `IRadioBackend.h` and emitted from `Hl2Backend`, all on `origin/main`; the test's own probe table has no entry for it. `git diff origin/main` for this branch contains **zero** occurrences of `autoRfGainArmSettled` — this PR neither adds nor removes a seam signal. It is a gap in that probe table and wants its own fix; I have not touched it here and have not filed anything for it. - **`wdsp_channel_test` — a `-j4` load flake.** Run on its own it **passes**, exit 0, ending `WDSP channel tests passed`. It measures block timing against a starved caller, and four-way parallelism on an 8-core box is exactly the condition that perturbs it. The four HL2 targets this change actually bears on all pass: `hl2_gain_restore_test` (48 checks, **49 after the review follow-up below**), `hl2_state_restore_test` (68), `hl2_gain_split_test` (70), `hl2_band_memory_test`. ## Review follow-up — second commit ten9876's pass (no blockers) plus the earlier bot pass. Everything below is in `b3bdfdb`; the two maintainer decisions above are untouched by it. **The log line is the one that changes behaviour.** Covered in section 4. It fires on every restore of a document that still carries the key, which is exactly the population that hears the gain change, and it names the ignored value alongside the one replacing it so a support log traces back to aethersdr#5829 instead of reading as a radio fault. Observed firing across the existing fixtures at `-6` (the reporting station's own number), `6`, `12`, `20`, `24`, `32`, `48` and `999` — the last being the validation-boundary fixture's hand-edited garbage, which is the case the `toVariant()` form exists for. **The heal claim is now pinned at the document layer, and it is not a vacuous assertion.** The review asked for one line on the existing `RadioStateMemory` store/load round trip rather than on `currentOperatingState()`. Taken, and the input to that round trip is `rememberedGain()`, which seeds `defaultDb: 20` — so it is a genuinely stale document going to disk and coming back. Replaying this PR's own mutation (`src/` at `origin/main`, tests as here) it is the **first** check to fail: ``` $ git checkout origin/main -- src/core/backends/hl2/Hl2Backend.cpp src/core/backends/hl2/Hl2Backend.h $ cmake --build build --target hl2_gain_restore_test hl2_state_restore_test [2/10] Building CXX object CMakeFiles/aethercore.dir/src/core/backends/hl2/Hl2Backend.cpp.o [5/10] Linking CXX static library libaethercore.a [7/10] Linking CXX executable hl2_gain_restore_test $ ./build/hl2_gain_restore_test ; echo exit=$? [FAIL] the stored document itself carries no LNA default after a real RadioStateMemory store/load round trip [FAIL] capture preserves the stored band gain and emits no LNA default (x4) [FAIL] a restored stale defaultDb is dropped from the next capture [FAIL] a profile carrying a stale rfGain.defaultDb comes up on the same unvisited-band gain as a profile without the key exit=1 ``` It doubles as the standing guard on the thing both reviewers went after and could not break: `RadioStateMemory::store` rebuilds the gated extension wholesale from `state.extension`, which is *why* dropping the key from the capture is sufficient. If that ever became a preserve-unknown-siblings merge — as `storeRtlRfGainPreservingLegacy` already is for its own family — the key would survive on disk forever and this is the check that says so. **The swap assertion is labelled as documentation, not coverage.** It cannot fail on any post-fix build, so swap-isolation for this field is *gone rather than moved*; the comment now says that in those words, and names what is still live beside it — the per-band map and the drive baseline, which have members to leak and are checked for real. **Three comment slips and a run-on.** "The read above `qBound()`ed" pointed at a read that no longer exists above; the `currentOperatingState()` twin paragraph had a broken wrap; and `Hl2Backend.h` still documented `applyPerBandStateFor` as falling back to "the restored defaults", which since this PR is true only of the drive half. All three fixed, and the new comment no longer runs into `// ARMED LATER, NOT HERE`, so the `autoEnabled` paragraph reads as its own again. **Squash subject** now carries `Principle VII.` — validated at the boundary, dropped rather than clamped, which is the load-bearing one here. Re-run after restoring the fix: `hl2_gain_restore_test` 49 OK / 0 FAIL, `hl2_state_restore_test` 68 / 0, `hl2_gain_split_test` 70 / 0, `hl2_band_memory_test` ALL PASS. `HAVE_HIDAPI` read back OFF from this tree's own `CMakeCache.txt` at run time, not recalled. ## Collision with aethersdr#5862 — textual, not semantic **Derived at review time, not recalled.** aethersdr#5862 (`hl2/5828-autogain-withdrawal`) is `OPEN` and `MERGEABLE`, and touches the same two files: `src/core/backends/hl2/Hl2Backend.cpp` and `tests/hl2_gain_restore_test.cpp`. In `tests/hl2_gain_restore_test.cpp` both branches insert at the **same base offset** — its third hunk and my stale-key block both anchor at pre-image line 191, the end of `main`'s body. That is adjacency at one anchor: **keep both**. Its other two hunks (a helper and `rememberedGain`) are nowhere near mine, and the review-follow-up assertion added here lands in the `RadioStateMemory` round-trip block much earlier in the file, inside none of its hunks — so the follow-up adds no new collision. In `Hl2Backend.cpp` there is no overlap at all: its hunks are in `setLnaAutoOffsetDb` and `setAutoRfGain`; mine are in `connectRadio`'s LNA seed, `applyRestoredState`, `currentOperatingState` and `applyPerBandStateFor`. No shared function, and it reads none of `defaultDb`, `m_lnaDefaultDb` or `kLnaDefaultGainDb`. **Not rebasing onto it** — that would couple two independent fixes. Whichever lands second takes a one-anchor manual resolution, and I am happy for that to be this one. ## Collision with aethersdr#5867 **Checked, and the hunks do not overlap.** aethersdr#5867 (`feat/hl2-squaresdr2-hardware-options`, @jcmerg) touches `Hl2Backend.cpp` and `Hl2Backend.h`, so this is a same-file pair. Its `Hl2Backend.cpp` hunks sit at `publishWideState`, `mixReceiverAudio`, two places in `connectRadio`, `applyKeying`, `repushAllFrequencies`, `setTune`, `invokeExtension`, `pushInitialState` and `applyBandFilter`. Mine sit in `connectRadio`'s per-band LNA seed block, `applyRestoredState`, `currentOperatingState` and `applyPerBandStateFor`. The only shared **function** is `connectRadio`, where aethersdr#5867's two hunks are roughly fifty lines either side of the block I change, well outside each other's context. In `Hl2Backend.h`, aethersdr#5867 adds to the includes and to the public section around the hardware-options accessors; I remove one member from the private state block far below. Neither file should conflict textually. No semantic interaction either: aethersdr#5867 declares which HL2 variant is attached — codec, dither bit, filter board, CL1, ATU — and reads none of `m_lnaDefaultDb`, `rfGain.defaultDb` or `kLnaDefaultGainDb`. Whichever lands first, the second should rebase cleanly. Happy to take the rebase if this is second. ## Not done here - **No migration code**, and this is deliberate rather than an omission. Dropping the key from `currentOperatingState` heals a document on its next snapshot, so a stuck profile fixes itself without a versioned migration. What that does **not** establish is how the −6 got there: nothing in the tree can write it, and neither the issue nor its review could account for it. "No other stuck profiles exist" is unverified, not established — it just happens that the same change repairs them all if they do. - `hl2::connectLna`'s `defaultDb` **parameter stays**, receiving the constant — but the reason given here at first ("it keeps the policy header testable in isolation") does not survive checking, so it is withdrawn. Following ten9876's note that the parameter is now vestigial, I enumerated every call site tree-wide: **15 of them, and all 15 pass `hl2::kLnaDefaultGainDb`** — one in `Hl2Backend::connectRadio`, one in `tests/hl2_dbref_test.cpp`, and thirteen in `tests/hl2_band_memory_test.cpp` through a file-local `kDefault` that is defined as the constant. It is not a test seam: no test passes a different value, and tracing the `(haveRestoredState, hasStoredEntry, paramPresent)` triple through each, **not one test reaches a path that reads `defaultDb` at all.** The parameter has no assertion coverage today. Two corrections to the note itself, both in its favour. The `.value(key, kLnaDefaultGainDb)` fallback beside it really is unobservable — `storedDb` appears twice in `connectLna` and both are short-circuited by `hasStoredEntry` — and `hasStoredEntry` at that call site is literally `m_lnaDbByBand.contains(m_currentBandKey)`, the same map and key, so the two cannot disagree. But **`defaultDb` itself is vestigial, not dead**: it is live on exactly one reachable production path, `m_haveRestoredState && !hasStored && !paramPresent` — a restored profile whose start band has no entry. That is the path this PR's new test covers, and the path no unit test covered before. **Left for a follow-up rather than taken here**, because it is a seam change and this PR is a bug fix. Worth recording for whoever takes it: the final `out.liveDb = defaultDb;` return is the only one that does not clamp, so inlining the constant makes an out-of-range default structurally impossible; and the superficially similar `m_lnaDbByBand.value(newBand, hl2::kLnaDefaultGainDb)` in `applyPerBandStateFor` is **not** dead — it has no `hasStoredEntry` companion, the `.value()` default is the actual mechanism, and a cleanup sweep must not take it along. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Squashed-from: aethersdr#5869 Co-authored-by: on8st <258096273+on8st@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: jensenpat <patjensen@gmail.com>
## What this fixes `band_zoom=`/`segment_zoom=` are FlexLib wire text. Only a Flex radio's command plane answers them — `RadioModel::sendCmd` drops the write at `hasCommandPlane()` and emits `commandDropped()` — so on a Hermes-Lite 2 the control moves and nothing reaches the radio. `SpectrumWidget::setBandSegmentZoomAvailable()` closed that **for the two on-screen buttons only**. Counted at the surface an operator can actually press, there are **seven**, and the buttons were the only gated one: | # | Surface | Symbol | Gated on `main`? | |---|---|---|---| | 1 | The two waterfall-corner `QPushButton`s | `MainWindow::wirePanadapter` | **yes** | | 2 | `band_zoom`/`segment_zoom` shortcut actions | `MainWindow::registerShortcutActions` | no | | 3 | MIDI `global.bandZoom` / `global.segmentZoom` | `MainWindow::registerMidiParams` (its `fireShortcut` lambda) | no | | 4 | FlexControl button table | `MainWindow::handleFlexControlButton` | no | | 5 | RC28 / Stream Deck / T-Mate2 buttons | `MainWindow::dispatchHidAction` | no | | 6 | FlexControl / HID / Ulanzi wheel and dial | `MainWindow::applyFlexControlWheelAction` | no | | 7 | Automation bridge `shortcut` verb | `AutomationServer::doShortcut` | no | Row 5 is worth calling out because it is easy to fold into rows 4 or 6 and it is neither: `dispatchHidAction` carries its own `if/else` chain, wired from `HidEncoderManager` rather than `FlexControlManager` — and it is **live by default**. `tmate2KeyDefaultAction` returns `"BandZoom"` for key 3, so a stock T-Mate2 reaches this path with no configuration at all. Six of the seven fan into just two methods — `MainWindow::togglePanZoomModeForPan` and `MainWindow::setPanZoomMode` — which checked `isConnected()` and a pan id and never the capability. Gating those two closes all six without an edit per surface. The fix is a header-only predicate, `src/gui/PanZoomModeGate.h`, read by both command paths **and by both button-availability computations** (`MainWindow::onConnectionStateChanged` and the `PanadapterStack::panAdded` lambda in `MainWindow::createCentralWidget`), so the enable state and the write decision cannot drift apart. Its middle rung is a **declared capability** — `RadioCapabilities::panZoomModes`, a per-feature record `FlexBackend::capabilities()` engages and every other backend sets to `std::nullopt` explicitly — read off `backendCapabilities()` exactly as `hasDdcPanEdgeRolloff` is read one line away. No family string is involved, and this PR **removes** two `usesFlexCommandPlane()` call sites (including the pre-existing button one) rather than adding any. ## And the refusal says so A capability gate refuses **before** the send, so `RadioModel::sendCmd` is never reached and `commandDropped()` never fires. `MainWindow::showUnsupportedControlNotice()` carries that rule in its own words: *"a control converted from 'drops silently' to 'refuses' would otherwise have taken the operator's only feedback away with it … A gate calls this so a refused control says exactly what a dropped one says."* The two gates already in the tree — `split_toggle` and the `VfoWidget::splitToggled` lambda — pair `qCWarning(lcDevices)` with that notice, and both gates here now do the same: ``` WRN aether.devices: band zoom ignored: this radio declares no band/segment zoom ``` plus the status bar: *"This radio doesn't support that control — nothing was sent to the radio. Further unsupported controls are logged."* **Only the `NotDeclared` rung announces.** `NotConnected` and `NoPan` replace early returns that were silent on `main` and stay silent, so this restores exactly what the gate took away and nothing more. `PanZoomModeRefusal` exists so a caller can tell those apart, and now two production callers do. ## This is NOT a safety finding The only payload either path can emit is `display pan set <panId> band_zoom=<0|1>` (or `segment_zoom=`), handed to `RadioModel::sendCommand`. It is display-domain text selecting what span the panadapter draws. It reaches no TX verb and no keying path, and `ShortcutManager::Action::keysTx` is `false` at both registration sites in `MainWindow::registerShortcutActions` — contrast the CW momentary registrations just above them, which pass `/*keysTx=*/true` explicitly. **A dead control, not a hole.** ## Correction to this lab's FIND-52 FIND-52 records these as ungated shortcut sites, and gets two things wrong: 1. It says the predicate exists **"for the right-click menu"**. It does not. `bandZoomRequested`/`segmentZoomRequested` are emitted from exactly one symbol — the `SpectrumWidget` constructor's two `QPushButton::clicked` lambdas. There is no right-click path; `SpectrumWidget`'s context menu contains no band/segment-zoom action. 2. It says **"only the shortcut path bypasses it"**. Six surfaces do. Both errors have a traceable origin, and it is not the lab's: **two comments on `main` assert the right-click menu**, and the docstring on `togglePanZoomModeForPan` lists four surfaces where there are seven. FIND-52 copied a wrong comment faithfully. This PR corrects both comments, since a PR that leaves them standing would be reproducing the exact failure it is describing. ## The capability record, and a claim this PR first got wrong An earlier revision of this body said a capability route *"trips the ratchet"* and used that to justify `RadioModel::usesFlexCommandPlane()` — a literal `family() == "flex"` test — as the predicate's middle rung. **That was false, and `AGENTS.md`'s standing aethersdr#5554 notice forbids adding one of those above the seam.** Checked against `tools/check_capability_records.py` rather than assumed. Its docstring and its error text both say a new capability lands as a per-feature record (`std::optional<FeatureRecord>`: engaged = present, fields = shape), **not another bool** — and `direct_bool_fields()` counts only DIRECT `bool` members of `RadioCapabilities`. A nested struct's members never reach the field scan (the depth filter appends a line only at depth 1), and the `std::optional<...>` member itself fails the anchored `^\s*bool\s+` match on its type token. **A record cannot move the count**, and the gate reports 71 at the frozen 71 with the record in place. What landed instead: - `RadioCapabilities::PanZoomModes`, `std::optional`, engaged by `FlexBackend::capabilities()` with the verb that carries both modes, and explicitly `std::nullopt` in `hl2`/`icom`/`anan`/`sim`/`rtl` per the header's own ADDING A FIELD rule — then recorded in `docs/architecture/radio-capabilities-map.md`, as that rule also requires. The record carries `setCommand` so the declaration names the verb behind it, which is the other half of the same aethersdr#5554 notice (*"no new capability declared without a verb behind it"*). - The predicate reads `panZoomModes.has_value()` off `backendCapabilities()`. - A second family that gains the verb engages the record and needs no edit above the seam at all. ## Why `src/gui/` and not `src/models/` `PanZoomModeGate.h` is UI policy — what the UI may send, and whether a button is grey. It has zero includes, no Qt type and no engine type, and its only consumers are `MainWindow` and one test. `src/gui/` already holds exactly this shape (`DStarAvailabilityGate.h`, `DvkAvailabilityGate.h`, `DaxRestorePolicy.h`, `BandRecallSliceSelectionPolicy.h`), and tests include those directly without linking the GUI. So it adds **no row** to the aetherd engine/UI touchpoint manifest: the burndown stays at **229**, and there is nothing to convert later. An earlier commit on this branch tagged it `mixed(flex)` in `docs/architecture/aetherd-touchpoint-tags.json`; that commit is reverted here, because with the header on the UI side the row it was arguing about does not exist. ## Mutation testing — my own output, this tree `hl2_pan_limits_declaration_test`, baseline green. Every mutant was written as **fresh bytes and then `touch`ed**, and the `Building CXX`/`Linking` lines were read to confirm the object really recompiled — this branch's earlier harness restored with `shutil.move`, which preserves the original mtime, so ninja judged the stale object current and the first "restored" run reported red on a binary still carrying the mutant. **Mutant A — the predicate stops consulting the capability** (drop the `NotDeclared` rung): ``` RECOMPILE> [1/3] Building CXX object CMakeFiles/hl2_pan_limits_declaration_test.dir/tests/hl2_pan_limits_declaration_test.cpp.o RECOMPILE> [2/3] Linking CXX executable hl2_pan_limits_declaration_test TEST EXIT = 1 (RED - mutant caught) > [FAIL] REFUSED on this radio even connected with a pan: band/segment zoom is Flex wire text and this backend declares no band/segment zoom > [FAIL] and it is refused for the CAPABILITY, not for a missing pan or a missing connection -- the reason a caller would show the operator, and the ONLY rung the call sites announce with showUnsupportedControlNotice() > [FAIL] the B/S buttons are disabled on this radio for the same reason > 3 failure(s) -- restored: recompiled, TEST EXIT = 0 (green) ``` **Mutant B — button availability drifts off the shared predicate** (`bandSegmentZoomAvailable` returns `connected`): ``` TEST EXIT = 1 (RED - mutant caught) > [FAIL] the B/S buttons are disabled on this radio for the same reason > [FAIL] button availability and write admissibility agree on every (connected, declared) pair > 2 failure(s) -- restored: recompiled, TEST EXIT = 0 (green) ``` **Mutant C — ENGAGE the record in `Hl2Backend::capabilities()`.** The one that matters for the new route: it proves the test reads *this radio's declaration* rather than a retyped literal `false`. ``` RECOMPILE> [2/5] Building CXX object CMakeFiles/aethercore.dir/src/core/backends/hl2/Hl2Backend.cpp.o RECOMPILE> [3/5] Linking CXX static library libaethercore.a TEST EXIT = 1 (RED - mutant caught) > [FAIL] this radio declares NO band/segment zoom -- absent, not a false bool, so 'nobody set it' and 'considered no' are not the same record > [FAIL] REFUSED on this radio even connected with a pan: ... > [FAIL] and it is refused for the CAPABILITY, not for a missing pan or a missing connection ... > [FAIL] the B/S buttons are disabled on this radio for the same reason > 4 failure(s) -- restored byte-identical, TEST EXIT = 0 (green) ``` **What the test still cannot see**, stated in the file itself: **no registered test target links `MainWindow*.cpp`**, so deleting the gate at either call site leaves every assertion green. That is why the call sites are demonstrated on a running app below rather than asserted here. ## Driven on a running backend, not reasoned Built at this head and launched offscreen with a fresh `AETHER_SETTINGS_DIR`, `AETHER_AUTOMATION=1`, `AETHER_AUTOMATION_NO_TX=1`, then connected over the automation bridge to `DEMO-0001` — family **`sim`**, which is the case a bare `hasCommandPlane()` test gets wrong and which now declares `panZoomModes = std::nullopt` explicitly. - `dumpTree`: `panZoomBandBtn` and `panZoomSegBtn` both `enabled: false` with their tooltip. - Four presses (`shortcut band_zoom` / `shortcut segment_zoom`, `fired: true` each), and the app log carries **four** lines where the pre-fix head carried none: ``` [03:42:03.656] WRN aether.devices: band zoom ignored: this radio declares no band/segment zoom [03:42:03.700] WRN aether.devices: segment zoom ignored: this radio declares no band/segment zoom [03:42:03.744] WRN aether.devices: band zoom ignored: this radio declares no band/segment zoom [03:42:03.792] WRN aether.devices: segment zoom ignored: this radio declares no band/segment zoom ``` - Reconnected (the notice latch resets on the connect edge), fired once, and `dumpTree`'s `QStatusBar` node reads: ``` "statusMessage": "This radio doesn't support that control — nothing was sent to the radio. Further unsupported controls are logged." ``` ## Gates — all four, both sides `main` = `e944ec49`, this branch, run in-tree: | gate | `main` | this branch | |---|---|---| | `check_engine_boundary.py --strict` | **102** findings, 0 blocking, rc 0 | **102**, 0 blocking, rc 0 | | `gen_touchpoint_manifest.py --check` | `manifest up to date`, **229** touchpoints | identical, **229** | | `check_capability_records.py --strict` | `71 boolean(s), at the frozen 71 — ok (shrink only)` | identical, **71** | | `check_command_plane.py --strict` | **413** raw Flex commands above the seam, baseline 413, 0 blocking | identical, **413** | Nothing grew, including with the new capability record in place — which is the point of the record rather than a bool. ## Suite `HAVE_HIDAPI` is **off** in this cache (no `HAVE_HIDAPI` define on the configure line) — this machine is the no-hidapi configuration. `ctest -j4` on a full build of this head: **521/522 passed, 1 failed, 6 skipped**, 247.27 s. - `backend_seam_affinity_test` — **pre-existing, not this PR, and already repaired on `main`**: `[FAIL] probe table covers every declared seam signal (51 probed, 52 declared; unprobed: autoRfGainArmSettled)`. Introduced by merged aethersdr#5825 (`03ddf480`); repaired by aethersdr#5865 (`9ed3e5d7`, `tests/SeamThreadAffinityProbe.h`), which merged to `main` **after** this branch's base `e944ec49` — so it is absent here by base, not by omission. This PR touches neither file. - `vkamp_connection_test`, the `-j4` flake reported earlier, **passed** this run. Base freshness: `origin/main` is now `a0e8509c`, six commits on from `e944ec49`, and `git diff --name-only e944ec4..origin/main` over this PR's paths is empty. Not rebased for that reason. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Squashed-from: aethersdr#5875 Co-authored-by: on8st <258096273+on8st@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: jensenpat <patjensen@gmail.com>
…s the audio WdspChannel can now set the local FM detector's assumed deviation. The default stays 5000 Hz, which is what create_rxa already builds the stage with, so current FM audio is unchanged. The value is an inverse gain inside WDSP (again = rate / (deviation * 2π)), not a bandwidth. It is stored on Config and pushed again from open(), so a channel rebuild does not snap back to 5 kHz. Values outside 100 Hz–100 kHz are refused, because a tiny positive deviation sends that gain to infinity. Transmit configs are not failed for it. Nothing outside the test calls the setter yet. Squashed-from: aethersdr#5872 Co-authored-by: on8st <258096273+on8st@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: jensenpat <patjensen@gmail.com>
…nothing testing it Cover the Hl2Spectrum FFTW planner lock that aethersdr#5424 landed with no test. FFTW's planner is process-global and not thread-safe. Hl2Backend builds an Hl2Spectrum on its worker while WdspChannel::open() plans, allocates, and frees through FFTW on another thread. aethersdr#5424 takes WdspChannel::fftwSetupLock() in both the constructor and the destructor. This PR does not change that production behavior. hl2_spectrum_test now holds the lock on one thread and checks that construction and destruction wait. An unlocked construct+destroy is timed first and required to finish well under the wait, so a slow constructor cannot look blocked. The destructor has its own assertion: the edge TSan named was a free against an allocation, and a constructor-only lock would leave that half open. This is one new deterministic test toward aethersdr#5443. It does not restore radiomodel_pan_id_mapping_test and does not close the issue. It shows the two call sites take the shared lock. It does not reproduce the race. Squashed-from: aethersdr#5876 Co-authored-by: on8st <258096273+on8st@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: jensenpat <patjensen@gmail.com>
…n the air — and correct the comment claiming the radio's FIFO telemetry could see it (S3 3.3) > **Revised again after @ten9876's review.** Nine items, all acted on. Two were real > defects in the arm chain rather than prose nits, and **one of them re-introduced the very > fault this PR exists to record**. A third showed the floor this body published was **half > the floor** — the bigger half is at key-down, the header already named it, and the body did > not carry it across. All are measured and pinned below, with the mutation runs that make > them fail. > > *(Previous note, from the first revision, still stands: three corrections were folded in, > one of them a fix to the tests rather than to the prose — a negative control had been set up > to avoid the condition it existed to probe.)* ## What these counters are — stated narrowly, because the generous reading is wrong They count the **host end of the client's queue** (`MetisClient::m_txIq`), incremented **before the socket write**. They are not a reading of the radio's DSIQ FIFO and they never observe it. "TX IQ FIFO" in the old title did not say whose; it does now. ## The gateware FIFO rows cannot see a host starvation `Hl2Telemetry::txFifoFillMsbs` and `::txFifoRecovery` report the **gateware's** DSIQ FIFO, fed by **EP2 packet arrivals**. A host-side starvation drops no packet and shortens none: * `MetisClient::onEp2PacerTick` emits a full-size EP2 frame off the wall clock and consults `m_txIq` nowhere — ```cpp const quint64 due = static_cast<quint64>(elapsedUs / m_ep2IntervalUs); while (m_ep2Sent < due && burst < kEp2MaxBurstPerTick) { sendControlPacket(); ``` * `ep2WriteTxIq` zero-fills every slot the supplied span does not reach. The radio is handed an unbroken 48 kHz sample stream whose *content* is partly silence, and **its fill reading is identical either way**. `Hl2Backend::publishTelemetry` asserted the opposite on `origin/main`: > ``` > // TX IQ FIFO — a queue-fed transmission can starve the radio's buffer in a > // way a per-packet generated tone never can, so this is what distinguishes > // "the audio is wrong" from "the audio never arrived". > ``` It cannot, and that comment is corrected here — a healthy reading there was being taken as evidence against a defect the reading is structurally blind to. (While correcting it: the 0–127 figure is the **top seven bits of the fill level**, not the depth. The DSIQ FIFO is 16384 deep on this board; `MetisProtocol.h` declines to convert it to samples.) ### And the blindness is symmetric This is the easier half to forget, and the old body only stated one direction. A datagram lost on the wire, a genuine overrun of the gateware's DSIQ FIFO, or anything else after this client hands bytes to the socket is invisible to **these counters** exactly as a host starvation is invisible to the gateware rows. The two instruments are **disjoint, not overlapping** — neither substitutes for the other, and a fault occurring *between* the socket write and the FIFO is invisible to **both**. **"The counters are clean" is not evidence that the transmission was.** ## The floor — these do NOT answer "was this over clean" The error is a **false positive**, not a miss. **The floor has two halves, at the two ends of the over, and the first version of this body named only the smaller one** — @ten9876 caught that, and the header had already written down the half the body omitted. Both are now measured and pinned. ### The tail, at key-up Real audio does not end on a packet boundary, so the last packet of a perfectly healthy over is short and **is counted**: at the point the count is taken, *"the queue is short"* and *"the over is over"* are indistinguishable. Measured: ``` PROBE key-up tail: 500 samples (500 = 3*126 + 122), drained in 4 packets -> underflowPackets=1 underflowSamples=4 ``` ### The pre-roll, at key-down — and it is the bigger half `MetisClient.h` named this shape and the body did not carry it into the floor: *"a burst of whole-packet underflows at key-down is the missing pre-roll (the queue has not been primed yet)"*. `Hl2Backend::applyKeying` posts `setMox` immediately. The first block of transmit IQ cannot arrive until `Hl2TxDsp::processAudioBlock` has accumulated a whole `Config::dspBlockSize` block at `Config::inputSampleRateHz` — it returns early while `m_inBuffer` is short, and `m_inBuffer` is empty at key-down twice over (`applyKeying` posts `reset()` on unkey, and a new over carries a new `TxCoordinator` context which makes `processAudioBlock` reset again). **Nothing primes `m_txIq` before MOX.** Every EP2 tick in that window takes the empty-queue arm — now counted, by design — and counts a **whole** packet. Measured: ``` PROBE key-down pre-roll: one Hl2TxDsp block is 512 samples at 24000 Hz = 21.33 ms; an EP2 packet is 126 samples at 48000 Hz = 2.625 ms -> 8 whole-packet underflows, 1008 samples of substituted silence, 21.00 ms of every clean over ``` **So every clean over OPENS with eight whole-packet underflows and CLOSES with the short tail** — 1008 samples against at most 125. The pre-roll is worth an order of magnitude more substituted silence than the tail the body used to quote as the whole floor, and the `THE FLOOR` test measured only the tail because it pre-filled the queue. It is now `THE FLOOR, TAIL HALF`, beside a new `THE FLOOR, PRE-ROLL HALF`. **The pre-roll count is derived, not typed.** `dspBlockSize` and `inputSampleRateHz` are read off the real `Hl2TxDsp::Config`, and the EP2 rate off `MetisClient::kEp2AudioRateHz` through the existing `MetisClientTestAccess` friend — so changing the DSP block size or either rate moves the expectation with the code instead of leaving a number that agrees with itself. **8 is a floor from configured constants, and the live figure is higher.** The pacer's phase at key-down is free-running (±1 packet), the mic poll adds up to ~5 ms, and the 48→24 kHz SRC in `TxVoiceProcessor` adds its own group delay. On hardware expect 9–12. The test pins the deterministic part, because that is the part that can regress silently. Neither half is a defect in this client: they are what an unprimed, wall-clock-paced FIFO reads on a **healthy** transmission, and they are the reason these counters are a session budget and not a per-over verdict. `txUnderflowPackets` is the more polluted of the two — its floor is a fixed per-over cost that does not grow with the over's length, while a real starvation adds 126 per packet for as long as it lasts. Suppressing either half needs the count deferred and attributed against the key edges. That is a design change, not a correction, and is **deliberately not made here**; the honest floor is a perfectly reportable state meanwhile. ## The fault is observed, not theoretical `FINDINGS.md` FIND-23 carries **EP2 wire captures** of live speech. Each leg holds exactly one starvation or none: **48, 18 and 64 trailing zeros**, all pure-trailing; the fourth leg had none. Windows with no starvation measure **78.6–78.8 dB** opposite-sideband suppression (0.15 dB spread across four legs and two ALC conditions); windows with one measure **33.7–35.2 dB**. Synthetic zeroing in the clean windows reproduces it: one sample takes 78.99 dB to 48.97 dB, the observed 48 take it to 30.89 dB. **Provenance, because these will otherwise read as on-air figures.** That run (d77) was captured off the wire, but the peer was **`hpsdrsim` on loopback, not a radio** — the run record classes it `loopback`, tcpdump on `lo0`. What the captures establish is that the starvation **happens in ordinary speech** and what it costs **within one capture**; not the on-air magnitude. Separately, 78.7 dB is **not** the modulator's own suppression — d87 measures 87.15–87.19 dB on a steady tone, matching `Hl2TxDsp::designFilters`. ## What was unrecorded ```cpp while (m_txIq.size() > kTxQueueMax) m_txIq.pop_front(); // kTxQueueMax = 12000, ~a quarter second at 48 kHz ``` ```cpp } else if (keyed && !m_txIq.empty()) { const std::size_t n = std::min<std::size_t>(kTxSamplesPerPacket, m_txIq.size()); ... ep2WriteTxIq(pkt, block); // a short block leaves the rest as silence ``` An **empty** queue did not even enter that branch — the chain fell off the end and a whole packet of silence went on the air recorded by nothing. `MetisClient::txQueueDepth()` has **no production caller**: a repo-wide grep finds its own definition and eight call sites, all in `tests/hl2_tx_gate_test.cpp`. **The wire is unchanged byte for byte.** The empty-queue case now enters the branch to be counted and writes nothing — `ep2Packet` has already zero-filled the payload with exactly the bytes `ep2WriteTxIq` would produce for an empty span, and `ep2WriteTxIq` never touches the EADDR slot either way. ## The tests assert the wire, not only the counter `check(txUnderflowSamples() == kTxSamplesPerPacket - kShort)` has the test and the production code computing the same difference from the same input — both sides agreeing proves only that the arithmetic ran twice. A new helper, `ep2IqShape()`, reads the *other* thing off the same packet: how many sample slots carry IQ and where the silence begins — the **trailing-zero signature** FIND-23 measured. It is asserted on the short packet, on the tail packet of the clean over, and negatively on every full packet. ## What @ten9876's review changed in the CODE, not only in the prose ### Two real defects in the arm chain **A NaN tone amplitude routed queued audio into the UNKEYED arm, and transmitted silence uncounted.** `setTxTestTone`'s clamp is ```cpp m_toneAmp = amplitude < 0.0 ? 0.0 : (amplitude > 1.0 ? 1.0 : amplitude); ``` and **both comparisons are false for NaN**, so NaN reaches `m_toneAmp` intact. The queued-IQ arm as filed read `keyed && !m_cwMode && m_toneAmp <= 0.0`, which NaN also fails — as does the tone arm's `m_toneAmp > 0.0`. A keyed client with a full block of audio waiting therefore fell past **both** into the unkeyed backstop: a whole packet of silence on the air, with the audio still in the queue and **nothing counted**. That is the exact fault this PR exists to record, reintroduced by the guard written to describe it. The arm is now just `keyed`, which is exact because the two arms above already consume every keyed input with CW or a positive tone amplitude. Taken as filed from the review's inline suggestion. **The CW and test-tone arms never ended the run.** `MetisClient.h` claims every exit from the queued-IQ path reports; those two did not, so an episode in progress when the operator hit TUNE while keyed sat open until key-up and was then logged against the wrong moment. `setCwKeyDown` likewise cleared the queue without touching it. All three report now. ### `reportTxUnderflowRun()` now has an assertion behind it Correct, and it was the fifth "test that agrees with itself" in this file's history: the run members are private, nothing read the log, and emptying the function left every new check green. The seam is a `qInstallMessageHandler` capture, as in `tests/tci_server_review_test.cpp`, with `aether.hl2.tx.debug=true` turned on explicitly and the sink filtered to the starvation lines — that category is shared with `Hl2Backend`'s gateware telemetry on purpose, so the filter has to be on the message and not on the category. **M8 below is the proof.** ### The run coalescing collapsed CONTIGUOUS starvation only Every full packet flushed the run, so the alternating short/full pattern this header itself names as the clock-drift shape logged once per **pair** — up to ~190 lines a second at the pacer's ~381 frames/s, which is the per-frame flood the coalescing exists to prevent, and the failure mode of aethersdr#5813 where a high-rate burst flushed the support-log tail it was meant to explain. An episode now ends when the queue has **kept up** for about 100 ms — `kUnderflowRunQuietPackets`, **derived** from `kEp2AudioRateHz` rather than chosen as a packet count — plus the unconditional flushes at every exit from the queued-IQ path. Within an over that is one line per episode; across overs the unkey backstop still attributes the line to the over it happened in. ### "Session totals" were process totals, and now say so `MetisClient` is constructed once and nothing resets the three counters — not `start()`, not a link edge, not `flushTxIq()`. They accumulate across reconnects and across radios. **Relabelled rather than reset, and the reason is in the header.** The question they answer is *"how much substituted silence has this PROCESS put on the air"*, and a reset on a link edge would erase exactly the slow accumulation they exist to show. An operator who wants a per-link figure subtracts two readings. A reset would also be a new state-machine edge needing its own test, for a counter whose entire value is the long integral. ### The two other paths that drop queued TX samples `queueTxIq`'s context-mismatch clear and `clearCwKeying()` both drop queued audio and neither feeds the overflow counter. Agreed, and it now says so at both sites: the overflow counter records samples the FIFO **threw away while trying to carry them** — a discontinuity in the middle of an envelope that nothing else records. These two are **abandons**, audio the caller has decided must not be transmitted at all; feeding them to the same counter would make an operator's own mode change read as a fault. The *run* is ended at both, because a starvation in progress really did stop there. ### `aether.hl2.tx` had three category objects; it has two `lcHl2Tx` moved to `LogManager.h`/`.cpp` beside every other category in this codebase — declared **verbatim, with no default-severity argument**, so it keeps exactly the default it had in `Hl2Backend.cpp`. `MetisClient.cpp` already included `LogManager.h` for `lcHl2`, so it now uses the shared object instead of a third file-static one on the same string. `Hl2TxDsp.cpp`'s `lcTxMod` is still its own object; folding that one in is a separate change and is not made here. `LogManager`'s registry row described the category as *"TX IQ FIFO depth with underflow/overflow flags"* — the gateware rows — while it now also carries the host-queue lines whose whole point is that they are **not** that FIFO. The row now names both instruments and says which is which. (This is a file the PR should have touched and did not.) ### The accessors' thread contract is stated at the accessors **"THREAD CONTRACT: I/O THREAD ONLY"**, on the accessors themselves rather than only beside the logging category, so the next reader does not wire them to `healthSnapshot()`. ## Proved by mutation — my own output, re-run on this tree Each cycle rebuilds, **reads the `Building CXX` line back to confirm the mutated TU recompiled**, runs, then restores by *rewriting* the file (mtime moves, so ninja cannot skip the restore rebuild) and reruns. **M1 — count the samples SUPPLIED instead of the silence SUBSTITUTED:** ``` FAIL: and the floor in samples is the packet remainder, here 4 FAIL: underflow counts the SILENCE substituted, not the samples supplied FAIL: an empty queue counts a WHOLE packet of substituted silence ``` **M2 — count on FULL packets as well as short ones** (`n <=` where the code has `n <`): ``` FAIL: THE FLOOR: a CLEAN over that does not end on a packet boundary still counts one underflow -- this counter cannot report 'this over was clean' FAIL: NEGATIVE CONTROL: a queue drained on an exact packet boundary counts no underflow FAIL: the full packet ahead of the short one is not counted FAIL: one short packet, one underflow packet ``` **M3 — double-count overflow:** ``` FAIL: overflow counts exactly the samples dropped past the bound ``` **M6 — WIRE-ONLY: drop the short block, so a starved packet goes out fully silent while every counter stays correct.** This is the one that shows `ep2IqShape()` earns its place — it is killed by **exactly and only the three wire assertions**, with no counter assertion firing: ``` FAIL: every packet of a fed over carries IQ FAIL: the tail packet's WIRE shape is 122 IQ slots then trailing silence FAIL: the short packet's WIRE shape is kShort IQ slots then trailing silence ``` **M7 — naively exempt the end-of-over tail** (count only a whole empty packet), which the pinned floor catches: ``` FAIL: THE FLOOR: a CLEAN over that does not end on a packet boundary still counts one underflow -- this counter cannot report 'this over was clean' FAIL: and the floor in samples is the packet remainder, here 4 FAIL: one short packet, one underflow packet FAIL: underflow counts the SILENCE substituted, not the samples supplied ``` **M8 — EMPTY `reportTxUnderflowRun()`'s body.** The mutation @ten9876 asked for, and the one that had nothing to catch it before this revision. Six assertions go red: ``` FAIL: ONE log line for the whole key-down burst, not one per EP2 packet FAIL: and the line names the length of the episode it is reporting FAIL: the totals are labelled for what they are -- nothing resets them, not start(), not a link edge, not flushTxIq() FAIL: ten short packets spread across twenty are ONE episode and ONE line FAIL: TUNE while keyed ends the episode WHERE IT HAPPENED, not at key-up FAIL: the flush that ends an over also ends the episode ``` **M9 — PUT THE NaN HOLE BACK**, i.e. the arm condition exactly as this PR was filed (`keyed && !m_cwMode && m_toneAmp <= 0.0`). This is the one that shows the finding was a defect and not a style point — under it, a keyed client with a full block queued transmits silence and the counters stay at zero: ``` FAIL: a NaN tone amplitude must not send queued audio to the unkeyed arm -- that transmitted silence UNCOUNTED with a full block waiting FAIL: the queued block was consumed by the queued-IQ arm, as for any other amplitude ``` Note which assertion does *not* fire: `txUnderflowPackets() == 0` still passes, because the silence went out through the arm that counts nothing. That is the fault, exactly. **M10 — FLUSH THE RUN ON THE NEXT FULL PACKET**, i.e. the coalescing as filed: ``` FAIL: a SINGLE full packet does not end an episode -- alternating short/full must not log once per pair FAIL: ten short packets spread across twenty are ONE episode and ONE line ``` **M11 — DROP THE REPORTER FROM THE TONE ARM ONLY**, to show the two new call sites are separately load-bearing rather than one belt-and-braces pair: ``` FAIL: TUNE while keyed ends the episode WHERE IT HAPPENED, not at key-up ``` All nine restored green, with the restore rebuild confirmed recompiling `MetisClient.cpp.o` in both directions on every cycle. Restores are a **rewrite** of the original text, never a file move, so the mtime moves forward and ninja cannot skip the restore rebuild. The overflow test reads `kTxQueueMax` through the existing `MetisClientTestAccess` friend rather than re-typing `12000`. The coalesced log line, as it actually came out of the run — one line per starvation *episode*, not one per EP2 frame at ~380/s and, since this revision, not one per short/full *pair* either: ``` aether.hl2.tx: HL2 tx fifo: HOST queue starved for 1 EP2 packet(s), 126 sample(s) of substituted silence on the air (totals SINCE PROCESS START, not since this link came up and not per radio: underflow 1 packets / 126 samples, overflow 0 samples). This is the CLIENT's queue, not the radio's DSIQ FIFO: the EP2 frame went out full size on schedule, so the gateware's fill and pacingFault readings are unaffected by it and cannot be used to rule it out. ``` ## IT COUNTS; IT DOES NOT REPAIR The truncation is exactly what it was. Giving the FIFO a floor — a pre-roll, a target depth, or holding and ramping the last sample instead of stepping to zero — **changes what goes on the air**, and is a separate decision from being able to see the fault at all. Deliberately not in this PR. ## Build and tests * `-G Ninja`, `RelWithDebInfo`, `-DCMAKE_IGNORE_PREFIX_PATH="/opt/homebrew;/opt/local"`, `-DCMAKE_PREFIX_PATH=.../Qt/6.8.3/macos`, `-DENABLE_ASR=OFF`. * `AETHER_HL2_TX_TXA=1`; **`HAVE_HIDAPI` absent from this build's `build.ninja`** — no hidapi on this host. * `ctest -R '^(hl2_|control_receive_test)'` — **49/49 passed, 0 failed** (223.7 s), re-run on this revision. That is the subset built, not the whole suite. * **`backend_seam_affinity_test` is no longer a caveat.** aethersdr#5865 merged at 00:12Z and the Full test suite on `main`'s head (`a0e8509c`) reports `400/532 Test aethersdr#400: backend_seam_affinity_test ... Passed` and `100% tests passed, 0 tests failed out of 532`. The earlier note in this body is withdrawn — it was true when filed and is not true now. (One check on main's head is red, `check-system-libs-linux`, and it is an apt dependency conflict on Debian sid, not a test.) * This branch is `behind_by 6` on `main`; none of the six commits touch `MetisClient.cpp`, `MetisClient.h`, `Hl2Backend.cpp`, `LogManager.cpp`, `LogManager.h` or `hl2_tx_gate_test.cpp`. ## Collisions **[aethersdr#5867](aethersdr#5867 (`jcmerg`) touches `MetisClient.cpp`, `MetisClient.h` **and** `Hl2Backend.cpp` — a three-way overlap, and the only one of these not authored from this station. Whichever of us lands second rebases. **[aethersdr#5882](aethersdr#5882 — the two-stage EP6 watchdog recovery, the sibling out of the same local branch — inserts at the same `MetisClient.h` anchor. @ten9876 reviewed both and called it: keep both, second one rebases. **aethersdr#5869** and **aethersdr#5880** touch `Hl2Backend.cpp`, where this PR's change is comment-only — textual at worst. `src/core/LogManager.cpp`, `src/core/LogManager.h` and `tests/hl2_tx_gate_test.cpp` are not touched by any other open PR. Nothing here was measured on a radio. **No transmit behaviour changes** — the NaN arm fix changes which *branch* a keyed client with queued IQ takes, and the branch it now takes is the one that transmits that audio instead of substituting silence for it; every other keyed input reaches the same arm as before, and the wire is byte-identical on all of them. This counts, and it stops one path that was silently not counting. --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) Squashed-from: aethersdr#5881 Co-authored-by: on8st <258096273+on8st@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: jensenpat <patjensen@gmail.com>
…grate it; mark the two spectrum tokens nothing paints RESERVED The waterfall gate paces when a row is emitted. It does not integrate the row. The emitted row is the single producer frame that landed on the gate. The comment now says what that frame is per family. On HL2 a production frame is still one unaveraged window: `Hl2Spectrum::setAverageFrames` defaults to 1 and only `hl2_spectrum_test` calls it (aethersdr#5833), so `m_avgPower` is present and unwired. On ANAN every IQ block is fed to the WDSP analyzer and only taking a frame is paced, so the row is already time-averaged. Icom rows are converted scope sweeps, not FFT windows. Flex does not emit `spectrumFrameReady`. The widget seeds its time axis from `line_duration` and then measures real row arrivals. `color.spectrum.peakHold` and `color.spectrum.average` are marked RESERVED because nothing paints them. The live trace uses the `SMOOTH_ALPHA` EMA unless `backendPanAveraging` is set, which today is ANAN, so the widget does not average a second time on top of the analyzer. Comment and documentation corrections only. No executable line changes. Squashed-from: aethersdr#5877 Co-authored-by: on8st <258096273+on8st@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: jensenpat <patjensen@gmail.com>
Closes aethersdr#5803. Three HL2 health rows report a level that has stopped being measured, as though it were current. A fourth reports a clip count the same way. `Hl2Backend::healthSnapshot()` gates `adcPeakDbfs`, `adcRmsDbfs`, `adcCrestDb` and `adcClippedPerBlock` on one condition: ```cpp const bool haveBlock = m_bandscopeBlock.samples > 0; ``` That asks **has a block ever arrived**, never **is it still current**. `m_bandscopeBlock` has exactly two writers: the `bandscopeBlockReady` handler in the constructor, and `resetBandscopeMirrors()`, whose only callers are the three link edges — `linkUp`, `linkDown` and `connectFailed`. The `bandscope.enable` verb clears neither; it forwards `setBandscopeEnabled` to `MetisClient` and nothing else. So **stop the EP4 stream inside a live session — closing the wideband bandscope does exactly that — and the four rows go on publishing one frozen block, as plain numbers, for the rest of the link.** `resetBandscopeMirrors()` states the principle the rest of the code then fails to apply: > *"Back to 'never seen', which is what makes the level rows go ABSENT again rather than keep showing the previous session's last reading."* The author knew the failure mode and applied the cure at the one edge they were thinking about. A gate stopped mid-session is the same sentence with the link still up. **The auto-gain path is handed the age and refuses on it.** `stepAutoGain()` calls ```cpp bandscopeHeadroom(m_bandscopeBlock, m_bandscopeBlockClock.isValid() ? m_bandscopeBlockClock.elapsed() : -1) ``` which returns `Absent` past `kHeadroomMaxAgeMs`. The health rows were handed nothing. Two readers of one block: the regulator refuses stale data, the meter publishes it as fresh. `adcObservedAgoMs` **is** published as its own row — nothing used it to withhold the values. ## Measured Receive-only, nothing keyed, dummy load verified at the switch by port label. Binary built from `e944ec49` with the configure line below. The stream was stopped with `bandscope off` and the gate confirmed closed by a **rising** age before the sweep, so this is the gate being shut rather than a slow block. | LNA | `adcRmsDbfs` | `adcPeakDbfs` | `adcCrestDb` | `adcObservedAgoMs` | `adcSlicePeakDbfs0` | |---:|---:|---:|---:|---:|---:| | −12 | −58.39 | −40.21 | 18.19 | 7 594 ms | −93.15 | | 0 | −58.39 | −40.21 | 18.19 | 10 109 ms | −87.77 | | +10 | −58.39 | −40.21 | 18.19 | 12 619 ms | −77.84 | | +19 | −58.39 | −40.21 | 18.19 | 15 121 ms | −74.11 | **0.00 dB of travel on the three wideband columns across 31 dB of commanded gain**, while `adcSlicePeakDbfs0` — a different subsystem, age 10–20 ms — moved **19.04 dB** over the same four rows. `lnaBaselineDb` and `lnaGainDb` reported every step faithfully. An earlier run on the same radio caught a pair frozen at the +19 rung beside a radio sitting at LNA 0: **21.17 dB wrong, carrying no marker but the age.** ## The fix `haveBlock` now applies the expiry `bandscopeHeadroom()` has always applied, through a predicate the two share: ```cpp [[nodiscard]] inline constexpr bool bandscopeBlockIsCurrent( const Ep4Stats& block, std::int64_t ageMs, std::int64_t maxAgeMs = kHeadroomMaxAgeMs) noexcept { return block.samples > 0 && ageMs >= 0 && ageMs <= maxAgeMs; } ``` `bandscopeHeadroom()` now calls it rather than restating its three conditions, so the classifier and the rows cannot drift apart again — which is what this defect was. Pure, `constexpr`, clock-free, for the reason `Hl2BandscopeHeadroom.h` already gives for everything else in it: as arithmetic these branches are reachable, where driving a real gate to a real expiry is not something a test suite can arrange. ### Why that expiry, and not a separate one `kHeadroomMaxAgeMs` is **3000 ms — three `MetisClient::kBandscopeSampleMs` gate periods** — and its own header already derives it as *"two gate periods … and anything beyond it means the gate stopped delivering. Three periods is that with one period of slack for a late I/O thread."* It also already names its scope: *"A DISPLAY AND CONTROL BOUNDARY, NOT A CALIBRATED ONE."* Display is half of what it was chosen for. I considered choosing separately, on the argument that a health row and a control loop need not want the same window, and rejected it. **The rows and the loop read one block from one gate**, so the derivation is identical — it is a property of the producer, not of the consumer. A separately chosen number would not remove the disagreement, only shrink it: pick anything longer and there is a window in which the loop refuses a block these rows still publish, which is a smaller copy of the bug being fixed; pick anything shorter and the rows blank while the loop still acts on the block, which is the same confusion running the other way. `RadioHealthDialog::kRefreshIntervalMs` polls well inside 3000 ms, so a live gate never blinks. ### `adcObservedAgoMs` is deliberately NOT expired It is gated on `haveObservation` (a block has ever arrived) where the values are gated on `haveBlock` (the newest one is still current). That split is the point: **four dashes and an age of 46 810 ms says the gate stopped; four dashes and a fifth dash says only that something is missing.** Expiring the age along with the values would delete the evidence for the expiry. ### No row is removed `put()` keeps the key in `order` and `labels` and withholds only the value. Dropping rows outright was tried and reverted on aethersdr#5650 round 3, for the reason that file still records — *"the dialog's row list changed shape under a reader mid-refresh."* ## The four rows also go absent during TRANSMIT, and that is a change to every installation **Disclosed because a maintainer should rule on it rather than inherit it**, and because the argument this body makes one row over says the opposite. Raised by @ten9876 as finding 1, carried from the automated pass; I verified the interlock and the hold-off in the source before writing this rather than taking the finding on trust. `MetisClient::bandscopeInterlocked()` returns true for `m_mox`, for the radio's **own** `ptt` — `cw_on | ext_ptt`, so a foot switch, a PTT jack or the internal keyer counts, which `m_mox` never sees — and for `kBandscopeUnkeyHoldoffMs` (300 ms) after unkey. `bandscopeArm()` refuses on it *silently and per tick*, while `onBandscopeTick()` goes on ticking every `kBandscopeSampleMs`. **So no block arrives while keyed.** With the expiry in, the age therefore runs out mid-over. What an operator sees on a 10 s SSB over with the wideband bandscope enabled: | time after key-down | `adcPeakDbfs`, `adcRmsDbfs`, `adcCrestDb`, `adcClippedPerBlock` | `adcObservedAgoMs` | |---|---|---| | 0 – 3000 ms | the last pre-key block's numbers, as today | climbing | | 3000 ms → unkey | **—** in the dialog, JSON `null` on the bridge | climbing, 3 000 → 10 000 | | after unkey | back, at whatever the first new block reads | back under 1 000 | The return is unkey **+** the 300 ms hold-off **+** up to one `kBandscopeSampleMs` tick **+** one capture cycle — under two seconds in practice, and not a latch: `hl2_ep4_ingest_test` §9 asserts the restoration directly, and M2 below is the mutant that would catch losing it. Before this PR those four rows held their last pre-key numbers for the whole over and kept them afterwards for as long as the gate stayed shut. After it they read as dashes with a climbing age beside them. ### Why the slice-row argument does not rule this out — and where that argument was overstated Reason 3 under *"Are the slice-side rows affected?"* declines to expire `adcSlicePeakDbfs0` partly because doing so would be *"a transmit-time display change for every installation, which does not belong in a bug fix."* **Read as a general rule, that sentence forbids this PR as well, and it should not have been written as one.** It fused a real distinction with a claim this change itself breaks. The distinction survives; the general rule does not, and I have narrowed the bullet accordingly. * **The slice row's transmit hold is a decision; the wideband hold was an omission.** `Hl2RxDsp` deliberately holds its last receive value during transmit *because the receive chain is clocked with silence there* — expiring it would overturn a choice somebody made and recorded. Nothing chose to hold an EP4 block through an over, or through a gate closed by `bandscope off`: `healthSnapshot()` simply never asked how old the block was. Here the expiry does not overturn a decision, it **applies** the one `resetBandscopeMirrors()` already states in this file. * **The slice hold ends with the over; the wideband hold did not end at all.** The slice peak resumes on the next DSP block after unkey, ~21 ms. The defect measured here is 46 810 ms old and 21.17 dB wrong **with the link up and the operator receiving** — a receive-time fabrication, which is what aethersdr#5803 filed and what this PR exists to remove. The transmit-time dashes are a consequence of the cure, not its object. * **An age cannot tell the two silences apart, and this change should not pretend it can.** "The gate stopped because the operator closed the bandscope" and "the gate stopped because we are keyed" are one fact to the sensor: it is not sampling the band. Separating them means clearing the mirrors on the gate-off and keying **edges** instead of ageing them out — @ten9876's finding 2, which is a real design and explicitly not asked for in this PR. **And it is defensible on its own terms,** which is the half I would least like a maintainer to inherit silently: while keyed, the converter is looking at our own PA at enormous strength rather than at the band — the interlock exists precisely because a block taken then *"is a picture of us"*. The last pre-key number is not a current measurement of anything, so publishing it as one is the same assertion-without-evidence the rest of this PR removes. Both reviewers reached that conclusion independently and so do I. **The maintainer's call, stated as a question rather than assumed:** if you would rather the wideband rows hold through an over the way the slice row does, that is finding 2's edge-driven clear plus a deliberate keyed-hold — not a reason to leave the receive-time freeze in place. Say so and I will build it; I have not pre-empted it here. ## Are the slice-side rows affected? Verified: no, and the change does not touch them `adcSlicePeakDbfs0` and `adcSliceObservedAgoMs0` come from `Hl2RxDsp::adcPeakDbfs()` / `adcPeakObservedAgoMs()` — WDSP's `RXA_ADC_PK`, stamped on every processed block on the `!m_audioMuted` path, at the DSP block rate (~21 ms at 48 kHz) rather than at a 1 Hz gate. Three reasons they are out of scope, and the third is the one that decides it: 1. **They were measured not to have it.** In the same runs that froze the wideband triple, that column tracked gain over 19.04 dB with its age at 10–20 ms. 2. **They already have a freshness gate where an assertion is made.** `Hl2AdcPairing.h`'s `kSliceStaleMs` (150 ms) is fed `adcPeakObservedAgoMs()` and withholds the pairing verdict, so a held slice peak is never combined with a live overload flag. 3. **The one case where the slice peak does hold is a documented decision, not an oversight.** It stops advancing during transmit because *"the receive chain is clocked with silence there, so `Hl2RxDsp` deliberately holds the last receive value rather than measuring our own mute."* Expiring that row would blank it on every key-down and **overturn a choice somebody made and recorded**, on a row nothing was measured to be wrong about. That distinction is the whole of this reason — it is **not** a general rule that a bug fix may not change what is shown during transmit, which is how this bullet read until review round 2 and which this PR's own expiry breaks. See *"The four rows also go absent during TRANSMIT"* above, where that is set out and disclosed. Whether the slice display row should also carry an expiry is a real design question; it is not this one, and I have not pre-empted it. ## What breaks downstream when a row goes absent — checked, nothing * **The dialog.** `RadioHealthDialog::formatValue` opens `if (!v.isValid()) return QStringLiteral("—");` — a dash, in a row that keeps its place and its label. * **The bridge.** `AutomationServer`'s `bandscope` verb reads the rows through `QJsonValue::fromVariant(snap.values.value(key))`, so an invalid variant serialises as JSON `null` — the same value the pre-first-block case already produces there today. Its comment said *"absent until a block has arrived"*; that sentence is now incomplete, so the second commit here corrects it. No serialisation change. * **Scripts.** Every reader of these keys I can find reaches them as `rows.get("adcRmsDbfs")` through a `None`-tolerant converter — never `rows["adcRmsDbfs"]` — and several already handle the null case explicitly by name, refusing with *"the EP4 stream stopped"* rather than computing on it. Zero direct-subscript accesses. * **Tests.** `hl2_ep4_ingest_test` section 8 already asserts these rows are **absent before any block**; that contract is unchanged and section 9 extends it to the other edge. Squashed-from: aethersdr#5880 Co-authored-by: on8st <258096273+on8st@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: jensenpat <patjensen@gmail.com>
…rinciple VIII. ## What this changes `WdspChannel::filterTaps` and `minimumPhase` could be chosen only in `open()`. The only way to revisit either was `reconfigure()`, which closes and reopens the channel and so **destroys the notch database** — the exact state a caller changing the filter length is usually trying to keep. This adds two verbs under the same `beginControlOperation()` + `g_setupMutex` discipline `setAgc()` and `setNotchesEnabled()` already use: ```cpp bool setFilterTaps(int taps) noexcept; bool setMinimumPhase(bool on) noexcept; ``` **Additive. Nothing calls them, no default moves, and every backend behaves exactly as it did before.** `Hl2RxDsp::kRxFilterTaps` is untouched. ## Changed in review — @ten9876, `CHANGES_REQUESTED` Two blockers, both real, both verified against the source and then measured here before and after. Thank you — the first one in particular was ours and had been sitting in a comment claiming the opposite. ### 1. We held the process-global FFTW planner lock for ~165 ms per call Confirmed exactly as described. `RXASetNC` (`RXA.c`) opens with `SetChannelState(channel, 0, 1)`; `dmode` 1 spins `Sleep(1)` up to a 100-count timeout waiting for `flushflag`, and `flushflag` is cleared only by the down-ramp inside `fexchange2` — which `beginControlOperation()` has already made unreachable, because `processIq()` now returns `Busy`. **Nothing can clock that ramp, so the timeout always expires**, and the whole wait was inside `g_setupMutex`. Reproduced on this tree (macOS arm64, RelWithDebInfo, 48 kHz), instrumenting the locked region and running `wdsp_channel_test`. Measured as an **A/B in one binary**, one env switch selecting which arm runs, so both arms see the same FFTW wisdom state and the same machine: | | per call, 7 calls | sum | |---|---|---| | before (`RXASetNC` does its own stop, under the lock) | **155 – 227 ms** | 1145 ms | | after (stop hoisted out, `dmode` 0) | **1.8 – 28.8 ms** | 85 ms | **One correction to the review's arithmetic, and it makes the case stronger.** The figure is not ~110 ms and the drain is not "most" of it — it is very nearly *all* of it. The port routes `Sleep(1)` to `nanosleep()` (`third_party/wdsp/port/wdsp_port.c`), which on this machine lands at roughly 1.6–2.3 ms rather than 1 ms, so the 100-count timeout costs 155–227 ms. The re-planning the lock actually exists for is the 1.8–28.8 ms left over. **Summed over the seven calls, 93 % of the hold was sleep rather than work, and the lock is now held 13.4× less.** The fix is the one suggested, and it is the shape `reconfigure()` already uses at its own stop: `SetChannelState(m_channelId, 0, 0)` outside `g_setupMutex` before the locked region, then restore outside the lock after. `RXASetNC`'s internal stop and restore both no-op once the state already matches, so the locked region shrinks to the six FIR re-plans. Checked, because both are load-bearing and neither is obvious: - **the stop does not wait** — `dmode` 0 takes no timeout path at all, which is what `setRunning()`'s comment legislates (*"the drain is `processIq()`'s job and this thread cannot wait for it"*). This is now the fifth `SetChannelState` site in the file and the fourth to follow that convention; `open()`'s remains the documented exception. - **the restore does not wait either** — AetherSDR patch 8's wait in case 1 is predicated on `exchange` being **clear**, and a ramp that was never clocked leaves it **set**, so case 1 falls straight through to `flush_slews()`. That also cancels the armed down-ramp rather than stranding it, which is patch 7. **And the header's "the operator hears the mute ramp" was wrong** — corrected. The timeout path force-cleared `downflag` along with `exchange` and `flushflag`, so only the up-slew ever played. It was wrong before this change too, not newly wrong because of it. **Context worth having, and not for fixing here.** `SpectralNR` plans under its own `s_fftwMutex` rather than `fftwSetupLock()` — two mutexes over one process-global planner, with its allocations outside its own guard as well. So the lock we were holding for 165 ms is already contended by a path that does not know this lock exists. That is a separate defect and is not touched here; it is why the 165 ms mattered more than the number alone suggests. ### 2. The guard admitted tap counts that corrupt the filter silently Confirmed, and **`firmin.h` states the contract on the field itself**: ```c int nc; // number of filter coefficients, power of two, >= size ``` `plan_fircore` sets `nfor = nc / size` and `idxmask = nfor - 1`, and `xfircore` walks the overlap-save ring with that value as a **power-of-two mask** (`k = (k + idxmask) & idxmask`, `buffidx = (buffidx + 1) & idxmask`). So `nc >= size` is necessary and not sufficient, and our setter checked only that. **Reproduced rather than reasoned.** Instrumented build, `dspBlockSize` 1024, a 0.1-amplitude tone in a 150–3000 Hz passband, steady-state peak in band (1500 Hz) and out of band (6000 Hz): | taps | nfor | mask | in-band | out-of-band | rejection | | |---|---|---|---|---|---|---| | 1024 | 1 | 0 | 0.39807 | 0.00000 | 139 dB | sound | | 1536 | 1 | 0 | 0.39723 | 0.00032 | **62 dB** | impulse truncated | | 2048 | 2 | 1 | 0.39807 | 0.00000 | 149 dB | sound | | 2560 | 2 | 1 | 0.39807 | 0.00003 | **82 dB** | impulse truncated | | 3072 | 3 | 2 | **0.00008** | 0.00044 | **−15 dB** | ring broken | | 4096 | 4 | 3 | 0.39807 | 0.00000 | 150 dB | sound | | 6144 | 6 | 5 | **0.19966** | 0.03989 | **14 dB** | ring broken | | 8192 | 8 | 7 | 0.39807 | 0.00000 | 161 dB | sound | **At 3072 the wanted signal comes out 74 dB down and the out-of-band tone comes out 15 dB louder than it.** `buffidx` is pinned at 0 (`(0 + 1) & 2 == 0`), so one partition is never written and never read. At 6144 the mask is 5 and half the ring is skipped. Every one of those returned `true`, and **`open()` accepted them identically** — which is the second door the review names. The non-multiple cases are the quieter and probably worse failure: 1536 and 2560 keep their passband intact and lose 77 dB and 67 dB of stopband. Audio that sounds correct and no longer filters. **Fixed where the review said to put it.** `validateConfig()` never looked at `filterTaps` at all; it does now, through one predicate the setter also calls, so `create()`, `reconfigure()` and `setFilterTaps()` cannot disagree. The predicate is a **power of two in [256, 16384] that divides the DSP block size exactly** — every divisor of a power of two is a power of two, so `nc / size` is then exact and itself a power of two and `idxmask` is a real mask. The **256 floor is WDSP's, not ours**: `min_notch_width()` computes `1600.0 / (a->nc / 256)` where `NBP::nc` is an `int`, so below 256 that is a division by zero. It is scoped to receive channels — `filterTaps` reaches WDSP only through `open()`'s `RXASetNC` and is read only by `minimumNotchWidthHz()`, both RX-side. ### Nits - **`minimumNotchWidthHz()` integer division** — fixed, and the review is right that fixing 2 closes it. WDSP does `a->nc / 256` in **integer** arithmetic (`NBP::nc` is an `int`, `nbp.h`); we did it in floating point. They agree at 2048 and 8192 and diverge elsewhere, always **under-reporting** the floor — at 1000 taps we would offer 409.6 Hz where WDSP enforces 533.3 Hz. The new guard makes the two identical by construction, so this is now a statement of intent rather than a behaviour change; it is written that way in the comment rather than left as a coincidence. **What it is still not**, and the review is right to flag it: those two notch assertions remain cached-state assertions, because the value is recomputed from `m_config.filterTaps`, which the setter updates either way. The fix does not change that and does not pretend to. The thing that would give them teeth is a cross-check against `RXANBPGetMinNotchWidth` (`nbp.c`), which exists but is **not on the `aether_wdsp.h` facade** — and aethersdr#5872 is already adding a declaration to that facade, so I have left it out rather than collide. Happy to do it as a follow-up, or here if you would rather. - **The cost paragraph** now carries the lock hold, both arms, and says which one the code takes. - **The `backend_seam_affinity_test` note was stale** — removed. aethersdr#5865 merged 2026-09-21T00:12Z and `tests/SeamThreadAffinityProbe.h` on `origin/main` carries `AETHER_SEAM_PROBE(autoRfGainArmSettled)`. - **`m_config.filterTaps` is now mutable on an open channel** and `minimumNotchWidthHz()` reads it lock-free. No cross-thread reader exists today — every caller of both is on the control thread — so it is sound as it stands, and it stops being sound the moment the gate publishes a tap count another thread reads. Recorded in the header, where whoever writes the gate will be looking. ### Sequencing against aethersdr#5872 — agreed, and neither should rebase Verified with `git merge-tree`. aethersdr#5872 merges into `origin/main` with **zero conflicts**; it conflicts only against *this* branch, in exactly two files, and both hunks have an **empty base** — `WdspChannel.h` at the declaration after `setAgc()`, and `wdsp_channel_test.cpp` in the registration list in `main()`, where both sides obeyed the same "anything added later belongs ABOVE this line" marker and therefore landed on the same anchor. Add/add, keep both, either order. Whichever lands second resolves it in one commit. ### The no-caller question Same question aethersdr#5872 carries, and the answer is the same: the follow-up that gives these a caller is **aethersdr#5578**, which is open, reports the underlying defect (the HL2 RX bandpass FIR runs at 8192 taps unconditionally, ~64 ms of group delay for every receiver), and is where the gate — the part of item 5 that actually changes RX latency behaviour — belongs. ## Why this is filable as a standalone PR This is the primitive half of item 5 of @ten9876's scope-audit review on aethersdr#5462 ([`CHANGES_REQUESTED`, 2026-09-06](aethersdr#5462 (review))). That review's table puts this symbol in the contribute-back column: | File / group | Explained by "Hermes Lite 2 with I/O board"? | Verdict | |---|---|---| | `src/core/dsp/AudioMetrics.*`, `WdspChannel::setFilterTaps` | Partly | **Contribute back**, own PRs | — while putting new `IRadioBackend` virtuals in a different column entirely (`Yes, but new public seam` → **`Needs an RFC` before the code PR**). This PR adds no interface virtual, no capability field and no public protocol surface, so it is on the near side of that line. **The part of item 5 this deliberately does *not* do.** The review's wording is: > 5. **Notch-gated FIR length.** `WdspChannel::setFilterTaps` / `RXASetNC`, > `Hl2RxDsp::kRxFilterTapsLong`, the gate in `Hl2Backend`, the > `wdsp_channel_test` additions. This changes RX latency behaviour, so open an > issue first… The thing that changes RX latency behaviour is the **gate** — the policy that picks a length. That is not here. This PR ships only the two primitives and leaves the policy, the `Hl2RxDsp` constants and the `Hl2Backend` gate alone, so nothing about RX latency moves. The issue that item asked for is aethersdr#5578, and it is open. ## Prior art, stated plainly - **aethersdr#5578** (open) reports the underlying defect: the HL2 RX bandpass FIR runs at 8192 taps unconditionally, costing every receiver ~64 ms of group delay. It has had **no maintainer reply and no follow-up from its reporter**; the only substantive comments on it are our own measurements from 2026-09-17. - **aethersdr#5579** wrote almost exactly this setter, together with the notch gate. **It was closed unmerged by its own author 2 h 18 min after opening, with zero issue comments, zero review comments, zero reviews, and CI that never ran** (`check-runs` `total_count: 0`; combined status `total_count: 0`). **There was no warning to heed and no feedback to incorporate** — I am not implying it was rejected, because nothing happened to it at all. No open PR implements runtime filter tap setters. ## A hazard nobody has priced — read this before writing a caller `RXASetNC` → `RXANBPSetNC` → `setNc_nbp` → `setNc_fircore` (`third_party/wdsp/upstream/firmin.c` — there is no `fircore.c` in this tree) calls `deplan_fircore()` and then `plan_fircore()`. `plan_fircore` re-allocates the partitioned overlap-save state with `malloc0` and resets the write index: ```c a->nfor = a->nc / a->size; a->cset = 0; a->buffidx = 0; a->fftin = (double *) malloc0 (2 * a->size * sizeof (complex)); a->fftout[i] = (double *) malloc0 (2 * a->size * sizeof (complex)); /* per partition */ a->accum = (double *) malloc0 (2 * a->size * sizeof (complex)); ``` `malloc0` (`upstream/utilities.c`) is `_aligned_malloc` followed by `memset(p, 0, size)`. `fftout[]` is the frequency-domain delay line — **the filter history — and it comes back zeroed**, with `buffidx` back at 0. That is the same state `flush_fircore()` produces deliberately, reached here as a side effect. So for one full filter fill after a runtime tap change, the channel emits a filter convolving against zeros and the output ramps up from silence. That matters because the S-meter integrates it. It is the same dive aethersdr#5821 fixed — WDSP's `avg` is an EMA with a 0.100 s time constant, and suppressing the read does not un-integrate silence already in it — arriving through a door aethersdr#5821 does not cover. `Hl2RxDsp` arms its settle countdown on exactly two edges today: the release edge of the transmit mute in `setAudioMuted()`, and a channel install in `installChannel()`. **A runtime tap change is a third edge, and it is not covered.** So, concretely, for whoever writes the gate: `Hl2RxDsp::armMeterSettle()` is private and DSP-thread-only, and `Hl2RxDsp` owns the `WdspChannel` — the dependency runs that way round, so `WdspChannel` cannot and should not arm it. The gate belongs in `Hl2RxDsp`, and it must call `armMeterSettle()` on the tap change the way it already does on the other two edges. `setMinimumPhase()` has no such problem. `RXASetMP` → `RXANBPSetMP` → `setMp_nbp` → `setMp_fircore` sets `a->mp` and calls `calc_fircore()`, which recomputes the masks and flips `cset`. There is no `deplan`/`plan`, no reallocation, and `fftin`/`fftout[]`/`buffidx` are untouched, so the delay line carries straight across the switch. I have not wired the settle call in here because there is no caller to wire it into. It is recorded so the next person does not rediscover it from a meter dive. *(`firmin.c` in this tree carries a local AetherSDR patch — the lazy minimum-phase workspace build, per `AETHERSDR-PATCHES.md` — so the quote above is from the vendored source rather than stock upstream. The `malloc0` allocations and the index resets shown are upstream's.)* ## Not proposed: minimum phase as a default `setMinimumPhase()` stays a primitive with no policy attached. Making minimum phase the default would be a default-UX change for every installation, which is a different kind of decision and is not what this PR is for. Squashed-from: aethersdr#5878 Co-authored-by: on8st <258096273+on8st@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: jensenpat <patjensen@gmail.com>
…and FM. Principle VIII. The HL2 inherited `hasFmRepeaterOffset` (the struct default is true) and declared Legacy FM tone presentation, so the repeater-offset controls and CTCSS encode looked live with no verb behind them. Both are withdrawn. FM and NFM stay on `receiveOnlyModes`, so the radio still will not key in those modes. `receiveModeControl` now includes DSB, CWL, and FM, which the mode menu already offers. CWU and NFM stay off that list. `setSliceMode` collapses those aliases onto CW and FM, so a control-plane request for the alias is refused instead of wedging `request.conflict`, and a direct caller cannot leave the slice holding a spelling the menu cannot show. REV stays enabled when the button is the transmit-frequency check, and is disabled only when it is the duplex reverse and the radio declares no offset. The IC-7300MK2 profile keeps XFC on and duplex off, so that button stays live. Squashed-from: aethersdr#5879 Co-authored-by: on8st <258096273+on8st@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: jensenpat <patjensen@gmail.com>
…own — two-stage EP6 watchdog recovery ## The gap An established link that went quiet had **no protocol-level recovery of its own**. `MetisClient::onWatchdogTick`, on `origin/main`, cleared `m_linkUp`, emitted `linkDown`, and could not then fire again for as long as the link stayed down — its own first line returns on `!m_linkUp`, and the only thing that sets `m_linkUp` back to `true` is an EP6 packet reaching `handleDatagram`. Which is exactly what does not happen once the radio has stopped streaming. (A link that comes back on its own *does* re-arm the watchdog; that was never the stuck case.) `m_startRetryTimer` was armed by exactly two paths — `MetisClient::start()` and `MetisClient::setReceiverCount()` — and neither is this one. The only recovery in the product was a layer up: `RadioModel`'s reconnect timer (`m_reconnectTimer.setInterval(5000)`) re-driving `connectRadio` five seconds after `disconnected()`, which closes and reopens every WDSP channel. The protocol-level answer is one 64-byte datagram. This matters most in the case the client cannot otherwise escape. The gateware has its own anti-wedge watchdog — `dsopenhpsdr1.v`, `watchdog_cnt`, tripping at `&watchdog_cnt` to `run <= 0` — cleared by **EP2 arrivals** (`watchdog_clr`, asserted in `SEQNO0`, the state an EP2 data packet enters). So: EP2 stops, EP6 keeps flowing so our silence timer never runs, the gateware eventually sets `run` to 0, EP6 stops — and only *then* does our watchdog fire, at which point **resuming EP2 cannot restart the radio**. Only a run command can. ### Correction from review — "nothing was sending one" needed a qualifier The first version of this body said nothing was sending a run command during a silence. **That is true only with the bandscope off**, and @ten9876 is right to have caught it. `MetisClient::bandscopeArm` reaches `sendBandscopeRunByte(true)`, which is `metisRunCommand(true, m_watchdogEnabled)` — **run bit set** — and the guard's `bandscopeDisarm` sends `metisRunCommand(false, …)`, run bit set again. The gate cycles at `kBandscopeSampleMs` and, before this PR, was disarmed only when the watchdog declared link loss. So with the bandscope on, a wedged radio was already being told to run **twice per second**, by a sensor that exists for something else entirely. That is an accident and not a recovery: it is absent in the default configuration (the gate is off per session and has to be asked for), it is invisible in every counter, and it stops the moment the operator turns the bandscope off. It also means the old test's "exactly one run command went out" was a property of the fixture, which does not run the gate. **So stage 1 now stops the gate** rather than leaving it cycling until stage 2 — see below. ## What this does **Stage 1** stops the bandscope gate, re-sends the run command once with `wide_spectrum` lowered, and arms the existing retry, keeping `m_linkUp` **true**. **Stage 2** — reached when the retry budget has run out and the stream is still gone — falls through to exactly the teardown that was always there. `m_linkUp` stays true on purpose: clearing it makes a stream that comes back re-emit `linkUp()`, and `Hl2Backend` republishes its entire initial state on that, over the operator's live panes. A recovery that succeeded is invisible above the protocol layer by construction — which is why the two counters below are not optional. ### Stopping the gate at stage 1 `resetBandscopeGate()` rather than `bandscopeDisarm()`: disarm would put a run byte of its own on the wire first, which is the thing being stopped. Three things fall out and all three are wanted: * **the attempt is attributable.** A recovery whose success might have been bought by a 1 Hz sensor tick is not evidence about this code. * **no `bandscopeTimeouts` are manufactured.** `resetBandscopeGate()` stops the guard as well as the tick, so the extra time stage 1 buys cannot charge an operator-visible row for a radio that has gone quiet. (This is the "extra 1.5 s inflates `bandscopeTimeouts`" nit from both passes — subsumed rather than documented.) * **`wide_spectrum` comes down on the wire**, in the recovery datagram itself, rather than being left up by whichever phase the gate happened to be in. The operator's standing intent survives in `m_params.bandscope`, which `resetBandscopeGate()` does not touch. `handleDatagram` calls `applyBandscopeGate()` when the recovery completes — the same call `setReceiverCount()` makes after its own restart, and for the same reason: the run byte has been through a known state and the gate has to re-arm from scratch. An outstanding `requestBandscopeFrame()` is still answerable: the re-armed gate answers it if the recovery works, and stage 2's `failPendingBandscopeFrame()` fails it with the link if it does not. **The cost of this:** the bandscope pauses for the length of the attempt, up to ~1.5 s, and the first block after a recovery is one gate cycle later than it would have been. ## The counters now reach somebody Both passes flagged that `silenceRecoveryAttempts()` / `silenceRecoveriesCompleted()` had zero production callers while the header claimed they existed so a silent recovery "is not also invisible to whoever asks later why the audio had a hole in it". They were right, and a counter nothing reads records nothing. They are now **two fields on `MetisClient::LinkCounters`**, not getters: | key | label | |---|---| | `silenceRecoveryAttempts` | EP6 silence recoveries attempted | | `silenceRecoveriesCompleted` | EP6 silence recoveries completed | `LinkCounters` is this file's established path to the operator: `Hl2Backend` mirrors it from `linkCountersUpdated` onto the GUI thread, and `Hl2Backend::healthSnapshot()` publishes the rows next to "Dropped EP6 packets" — the row an operator is already looking at when they ask why the audio had a hole in it. `RadioHealthDialog::refresh()` and the bridge's `health` verb (`AutomationServer::doHealth`) both iterate `snap.order` generically, so the rows arrive in the dialog and over the bridge with no registration anywhere. The publish is also what makes the read thread-safe, so the standalone accessors and their missing "I/O-thread only" note are gone rather than annotated. The failure path cannot wait for `onReadyRead`. That is the only place `linkCountersUpdated` used to be emitted, and it does not run when nothing arrives. Stage 1 now emits `linkCountersUpdated` in the same turn as the increment, so a recovery that never completes is on the health rows before `start()` replaces `m_link` on the reconnect. Session-life: zeroed with the rest of `m_link` at `start()`, left standing by `stop()`. **One correction to the review here:** `LinkCounters` reaches the health dialog, not the support bundle. `SupportBundle::createBundle` writes redacted logs, `system-info.json`, `radio-info.json` and settings, and carries **no health rows at all** — I checked before repeating it. The dialog's own copy-to-clipboard is the path one of these numbers actually takes into a bug report. Read together or not at all — attempts climbing while completions do not is the shape that says the re-send is not the right answer for whatever is actually failing. ## Fixed from review * **A concurrent `setReceiverCount()` was credited as a completed recovery.** It restarts the stream by its own route (stop + start + priming burst), and with `m_silenceRecoveryArmed` left set, `handleDatagram` scored the EP6 that came back as the run command's success. `setReceiverCount()` now clears the flag. The reverse case needs nothing: the watchdog cannot arm a recovery while a restart is running, because `m_sinceLastEp6` has just been restarted. * **The log line said "attempt budget 5" where four re-sends remain.** `armStartRetry()` now seeds the budget in one place, `kStartResendsAfterArm` names what is left, and the line reads "this one, then up to 4 re-sends at 300 ms". The helper also removes the third hand-rolled copy of `m_startAttempts = 1; m_startRetryTimer->start(…)` — the send stays at each call site, because the three put different bytes on the wire and that is the line a reader most needs to see. * **`static_assert(kMaxStartAttempts * kStartRetryMs < kSilenceTimeoutMs)`.** Two live readings rest on that inequality — `setReceiverCount()`'s "the budget fits inside the window" and stage 2's use of `isActive()` as a bounded wait — and both were comments about constants declared in the same header. A comment does not fail a build. ## The costs, stated honestly * **Link loss is declared ~1500 ms later when recovery fails** (`kMaxStartAttempts` 5 × `kStartRetryMs` 300), so worst case to a rebuilt link goes from ~7.0 s to ~8.5 s. A real regression in the failing case, bought against the succeeding case costing nothing. * **An outstanding C&C request waits ~1.5 s longer for its verdict.** `dropControlRequest()` fires at stage 2 and stage 1 returns above it, so a request caught in a silence fails at ~3.5 s instead of ~2 s, and new requests are accepted throughout because the link reads up. **Left deliberately**, and the reasoning is in the code: the request's own deadline cannot expire meanwhile — `Hl2ControlRequest` counts `Awaiting` in EP6 **frames** and there are none — so nothing is racing it, and failing it at stage 1 would make a recovery that *succeeds* visible to the caller as a failure it never had. 1.5 s of extra wait on the path that was going to fail anyway is the price of a recovery that costs nothing above the protocol layer. Happy to drop it at stage 1 instead if a maintainer prefers the old timing. * **A `setReceiverCount()` restart that fails now gets a second budget.** Its own retry exhausts at 1.5 s; the tick at 2 s sees `m_silenceRecoveryArmed == false` and stage 1 sends five more. That path is **nine run commands and ~3.5 s**, not "exactly the teardown that was always there" as the first body claimed. It is the same 1.5 s priced above, spent twice, on a radio that has already ignored five starts — cheap, but it was not stated and it is now. * **The bandscope pauses for the attempt**, as above. ## What a second `metis-start` actually does — derived from the gateware, not from trying it This is the obvious objection ("you are commanding a radio that thinks it is already streaming"), so it is settled from the RTL. `dsopenhpsdr1.v`, state `RUNSTOP`, entered from `DECODE` on `eth_data == 8'h04` — the **entire** body: ```verilog RUNSTOP: begin run_next = eth_data[0]; wide_spectrum_next = eth_data[1]; runstop_watchdog_valid = 1'b1; end ``` A plain **level assignment**. No edge detection, no FIFO flush, no DDC reset, no counter clear; `state_next` is not assigned, so it falls through the combinational default. A second start while `run` is already 1 re-writes 1 over 1 and nothing downstream sees an edge — every consumer takes `run` as a level. **So a duplicate start is idempotent for `run`.** What it is *not* idempotent for: it **re-latches** `wide_spectrum` from bit 1, and `watchdog_disable` from bit 7 (`runstop_watchdog_valid` gates `watchdog_disable <= eth_data[7];`). That is why this sends `metisRunCommand` with the live watchdog state and not `metisStart()`, and why the `wide_spectrum` bit it carries is now explicitly `false` — the gate is stopped one line above, so leaving the bit up would be asserting a sensor that is no longer running. **Sequence tracking is deliberately left alone — `m_haveRxSeq` is NOT reset.** `setReceiverCount()` resets it because it sends a `metisStop` first, and the gateware zeroes `ep6_seq_no` on `~run`. Here there is no stop. Being exact about the premise, because the obvious phrasing is false: `~run` is **not** the only reset — `START` also zeroes `ep6_seq_no_next` on `stall_req`. The claim that holds is the narrower one: **no path a duplicate run command takes resets the sequence.** Both outcomes then want the same handling: * the radio never halted (the silence was ours) — the stream kept counting, the gap across it is **real**, and resetting would erase a genuine loss of ~760 packets at 48 kHz / 1 RX; * the radio halted and this restarts it — `ep6_seq_no` begins at zero, and the existing backward-gap guard (`if (gap < 0x80000000u)`) already declines to score that as loss. **Both halves are now asserted** rather than argued — see the tests below. Thanks to @ten9876 for pressing on this: a sixty-line comment with no test behind it is a confident paragraph. **No `metis-stop` and no `sendPrimingBurst()`.** `setReceiverCount()` uses both because it changes the EP6 payload layout and needs a hard edge; here the layout is unchanged and there is nothing to re-prime. It also matters that `sendPrimingBurst()` blocks: two `QThread::msleep(10)` calls per invocation, on the thread that paces EP2 — and stalling the pacer is a plausible cause of the very silence being recovered from. ## The test layer — a maintainer's call, made legible **This grows a legacy socket fixture, knowingly, and I am not asking anyone to pretend otherwise.** `AGENTS.md`'s test-layer table routes *"a refusal, a non-event, a dropped/malformed/disconnected input, a TX guard"* to **a socket-free test that injects the transport**, and lists `hl2_receiver_count_restart_test` (fake Metis radio) among four legacy exceptions **tracked for socket-free extraction in aethersdr#5254**. A silent radio is a disconnected input. This PR adds it to that exception. The reason it lands there is mechanical rather than a preference: **stage 1's only observable is a datagram**, it goes out through `MetisClient`'s own `m_socket`, and there is no injectable datagram sink to watch it with. `feedDatagram()` injects the *receive* side; the send side has no counterpart. The socket-free shape aethersdr#5254 wants needs that sink first. **What the socket-free version would need**, concretely: * an injectable datagram sink beside `feedDatagram()` — a `std::function<void(std::span<const std::uint8_t>)>` or a small `IDatagramSink`, set through `MetisClientTestAccess`, that `countTx(sendTo(...))` routes through when present; * a way to advance the silence clock without a real 2 s wall clock — today `m_sinceLastEp6` is a `QElapsedTimer` read directly, so this needs either an injectable clock or a test-only `onWatchdogTick()` entry point; * then the three blocks below become synchronous: feed EP6, stop feeding, advance the clock, assert the run byte appeared on the sink, feed EP6 again, assert the counters. **No socket, no wall clock, ~0 s instead of ~11 s.** I have deliberately **not** built that seam in this PR. It is a change to `MetisClient`'s test surface that belongs to aethersdr#5254 and affects the other three fixtures' extraction too, and bundling it here would make a bug fix carry an architecture decision. **Maintainer's call**: accept the growth as a stop-gap with a note on aethersdr#5254, or ask for the seam and a socket-free driver and I will do that instead. Either answer is cheap for me to act on; the header comment and the test's own header now say which of the two this is, so a later reader does not have to reconstruct it. The honest coverage boundary, stated because @ten9876 did not run the target and does not count these assertions as delivered coverage: a fake radio proves the client agrees with **our model** of the gateware, not with the gateware. ## The tests Three silences, each a distinct claim. The **negative control runs first**: everything before it is a healthy session plus two deliberate receiver-count restarts, and none of it may trip a recovery. 1. **A radio that kept counting.** The fake advances `ep6_seq_no` by 761 across the wedge and honours the run command. The recovery completes, and `droppedPackets()` advances by exactly 761 — *the forward gap is still scored as loss.* Reset `m_haveRxSeq` in stage 1 and this one reports none. 2. **A radio that really halted.** The fake restarts `ep6_seq_no` at zero on the run command. The recovery completes, and `droppedPackets()` does **not** move — *a restarted sequence is a reset, not four billion dropped packets.* Drop the `gap < 0x80000000u` guard and this one reports four billion. 3. **A radio that never comes back.** Every run command ignored. The retry budget is spent, `linkDown` is emitted exactly once — and, new this round, **five run commands are asserted to have gone out** (stage 1's, plus four from the retry), so the failure block cannot pass on a recovery that armed a counter and never touched the socket. A recovered silence also asserts what must **not** happen: no `linkDown`, no `linkUp`, no `metis-stop`. **What is NOT asserted, and I would rather say it than have it found.** Stage 1 stopping the bandscope gate has **no test behind it**. The mutant that would catch it is one line — delete `resetBandscopeGate()` from stage 1 — and nothing in the suite goes red. Two things stand in the way, and both are the same argument as the section above: * the socket-free `hl2_ep4_gate_test` already drives `onWatchdogTick()` through `MetisClientTestAccess` and asserts the stage-2 teardown, including `wide_spectrum` lowered on the wire. It **cannot reach stage 1**, because stage 1 requires `m_socket` and that file deliberately has none. With the injectable sink described above it reaches it in three lines. * covering it in the socket fixture means running the gate there, and the gate's own run bytes are indistinguishable on the wire from the recovery's (`bandscopeDisarm` sends `metisRunCommand(false, …)` — byte-identical to `0x01`). That makes the "exactly five run commands" count non-deterministic, so it would need a **fourth** silence block and another ~5 s of wall clock to keep both claims. I did not spend either without a ruling on the section above. If the answer is "build the seam", this is the first thing it buys. **Cost in wall clock:** the target grows by ~3 s over the previous round (three silences instead of two), ~11 s total for the silence blocks. No `TIMEOUT` property is set, so the 1500 s ctest default applies. ## Proved by mutation — my own output, re-run on this tree Each cycle mutates, rebuilds, **reads the `Building CXX` line back to confirm the mutated TU recompiled**, runs, then restores by *rewriting* the file (mtime moves, so ninja cannot skip the restore rebuild) and reruns. **M4 — disable stage 1 entirely; never re-send the run command:** ``` [2/7] Building CXX object CMakeFiles/aethercore.dir/src/core/backends/hl2/MetisClient.cpp.o FAIL: the silence watchdog re-sent the run command instead of declaring link loss FAIL: exactly one run command went out for the silence FAIL: EP6 resumed: the radio was restarted by the run command FAIL: and the recovery is recorded as completed, not merely attempted FAIL: a real forward gap across the silence is still counted as loss -- the recovery does not reset sequence tracking FAIL: a recovered silence emits no linkDown FAIL: a second silence gets its own recovery FAIL: and this one completes too FAIL: EP6 resumed, counting from zero again FAIL: a third silence gets its own recovery FAIL: which does NOT complete, because the radio never came back FAIL: five run commands went out for the unrecoverable silence -- one from stage 1 and four from the retry -- and then the budget was spent FAIL: an unrecoverable silence still reports link loss => MUTANT KILLED (non-zero exit) ``` **M5 — remove stage 2's fall-through, so a silence never becomes link loss.** Exactly one assertion fails, and it is the one that exists to stop this recovery from becoming a way of never reporting loss: ``` [2/5] Building CXX object CMakeFiles/aethercore.dir/src/core/backends/hl2/MetisClient.cpp.o FAIL: an unrecoverable silence still reports link loss => MUTANT KILLED (non-zero exit) ``` **M6 — `m_haveRxSeq = false;` added to stage 1**, i.e. do what `setReceiverCount()` does. This is the mutant the sixty-line comment needed and did not have: ``` [2/5] Building CXX object CMakeFiles/aethercore.dir/src/core/backends/hl2/MetisClient.cpp.o FAIL: a real forward gap across the silence is still counted as loss -- the recovery does not reset sequence tracking => MUTANT KILLED (non-zero exit) ``` **M7 — remove the backward-gap guard (`if (gap < 0x80000000u)` → `if (true)`)**, the pre-existing code the comment's second bullet leans on: ``` [2/5] Building CXX object CMakeFiles/aethercore.dir/src/core/backends/hl2/MetisClient.cpp.o FAIL: a restarted sequence is a RESET, not four billion dropped packets => MUTANT KILLED (non-zero exit) ``` **M8 — remove `resetBandscopeGate()` from stage 1**, i.e. put the gate-cycling behaviour back: ``` [2/5] Building CXX object CMakeFiles/aethercore.dir/src/core/backends/hl2/MetisClient.cpp.o hl2_receiver_count_restart_test: all checks passed => MUTANT SURVIVED ``` **M8 is reported because it survived**, not in spite of it. It is the experiment behind "the gate stop has no test behind it" above, rather than an assertion that it has none. Every other mutant was killed, and the restore rebuild was confirmed recompiling `MetisClient.cpp.o` in both directions each cycle. ## Gateware provenance The RTL quoted above was read from a snapshot of the Hermes-Lite 2 gateware present on this machine, not from a checkout with a verifiable object. The lab notes record it as `883a338`, the build this radio reports; **that commit id is not independently verified here.** @ten9876 independently checked every quote against the public Hermes-Lite2 master and reports it agrees line for line, which is a stronger provenance than this PR could offer on its own. Nothing in this PR was tried on a radio — no key-down, no antenna port. No transmit behaviour changes; `metisRunCommand` sets bits 0, 1 and 7 of an `EF FE 04` datagram and PTT lives in the EP2 C&C payload. ## On "S3 3.4" Dropped from the title and from the code. It was a row in an internal lab plan, **not** this repository's `docs/architecture/hl2-multi-ddc-test-matrix.md`, whose row 3.4 is "Preamp change on one pane". Citing a document a reviewer cannot open is worse than citing nothing, and this is the third time it has happened in my PRs. The behaviour is described in the title and the first section instead. ## Build and tests * `-G Ninja`, `RelWithDebInfo`, `-DCMAKE_IGNORE_PREFIX_PATH="/opt/homebrew;/opt/local"`, `-DCMAKE_PREFIX_PATH=.../Qt/6.8.3/macos`, `-DENABLE_ASR=OFF`. Full build clean, `EXIT=0`. * `AETHER_HL2_TX_TXA=1`; **`HAVE_HIDAPI` absent from this build's `build.ninja`** (`grep -c` is 0) — no hidapi reachable from this configure line on this host. * `ctest -R '^(hl2_|control_receive_test)'` — **49/49 passed, 0 failed** (235.8 s). That is the subset built, not the whole suite. `hl2_receiver_count_restart_test` passes in **12.7 s**, up ~3 s on the previous round for the third silence. * Rebased onto `origin/main` at `a0e8509c` and re-run after the rebase, not before. `backend_seam_affinity_test` is no longer a standing red on `main` (aethersdr#5865 merged), so it is not an excuse here and was not needed as one. ## Collision with aethersdr#5867 **[aethersdr#5867](aethersdr#5867 (`jcmerg`, `feat/hl2-squaresdr2-hardware-options`) touches `MetisClient.cpp` (+172/−7) and `MetisClient.h` (+70/−0) substantially. Whichever of us lands second rebases; I have no preference about the order. The sibling TX IQ FIFO underflow/overflow counters PR touches the same two files in different places, and this round also touches `Hl2Backend.cpp`/`.h`, so a textual conflict in the mirror lambda is likely rather than possible. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Squashed-from: aethersdr#5882 Co-authored-by: on8st <258096273+on8st@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: jensenpat <patjensen@gmail.com>
## Problem An IC-7300MK2 connected over RS-BA1 was still treated as an experimental Icom radio. The title bar briefly showed the **EXPERIMENTAL** badge, the experimental-support dialog opened, and connect-time Flex-only commands triggered the status-bar warning that the radio did not support a control. ## RFC approval and production status [RFC aethersdr#5517 — Promote IC-7300MK2 to Supported](aethersdr#5517) is maintainer-approved for the **IC-7300MK2 over built-in Ethernet/RS-BA1 only**. The approval explicitly does not promote the original IC-7300, IC-705, IC-9700, unknown Icom models, or the Icom family as a whole. This PR implements the in-app portion of that model-specific promotion: it moves the Ethernet/RS-BA1 IC-7300MK2 from **Experimental** treatment to **production/supported** treatment by removing its experimental title-bar badge and connection disclaimer. Other Icom models retain the existing experimental UX. ## Change - Classify only the CI-V-identified `IC-7300MK2` as non-experimental. The canonical model comes from the backend's verified CI-V `19 00` model ID (`0xB6`); other and unknown Icom models remain experimental. - Hold experimental UI classification while Icom identity is in flight. An ordinary connect falls back to the family warning after the backend's five-attempt identity window, so an unidentified Icom is not promoted by omission. A wake reconnect (Wake Icom on connect) retries identity for far longer, so it holds on `RadioModel::radioWakeActive()` instead and re-evaluates when the wake ends. - Re-evaluate the title-bar badge and notice when canonical capabilities publish the identified model. A deferred notice is dropped if a later re-evaluation has superseded it. - Gate connect-time Flex GUI-client, pan `xpixels`/`ypixels`, RF-gain discovery, waterfall, WNB, and TX DAX writes on the Flex command plane instead of suppressing the unsupported-command warning. These gates apply to every backend without a Flex command plane — **Hermes-Lite 2 and ANAN as well as Icom**; Flex and the demo simulator are unchanged. Only the pan-dimension *write* is skipped; the local widget rescale still runs (aethersdr#4448). - `TransmitModel::resetState()` clears `daxOn` on disconnect. A backend without a Flex command plane never echoes `dax=`, so a Flex session's DAX=on would otherwise carry into the next radio's session. On those backends `daxOn()` no longer follows digital modes; `setDaxTxMode()` still switches the local TX feed. Maintainer ruling: the in-app promotion lands ahead of RFC aethersdr#5517's three-host label gate; `README.md` keeps **early** until the aethersdr#5517 docs PR. ## Validation - `cmake --build build -j22 --target AetherSDR icom_family_test hl2_pc_audio_lock_test` - `QT_QPA_PLATFORM=offscreen ctest --test-dir build -R '^(icom_family_test|hl2_pc_audio_lock_test)$' -j22 --no-tests=error --output-on-failure` — 2/2 passed. - `icom_family_test` pins the identity-hold decision (ordinary → bounded window, wake → until the wake ends); forcing the wake branch to the ordinary window fails it. `transmit_model_test` pins the `daxOn` reset; deleting the reset fails it. - Mutation checks: - Breaking the IC-7300MK2 model exception failed the exact classification assertion. - Removing one Flex command-plane guard failed the zero-drop Icom display-sync assertion. - Static validation passed: engine boundary, command-plane freeze, capability records, test registration, touchpoint manifest, accessibility checker, and `git diff --check`. Existing tracked warnings remain unchanged. ## Live automation-bridge proof RX-only connection to the lab IC-7300MK2 over RS-BA1: - `get radio`: connected model `IC-7300MK2`, one pan, one slice, `transmitting:false`. - Settled `dumpTree`: `experimentalRadioBadge` exists but reports `visible:false`. - No message-box root or experimental-support dialog was present after the delayed identity window. - The visible status bar reported an empty `statusMessage`. - The marked connection log window contained zero `commandDropped` / `no command plane` events. No TX environment flag, keying action, or transmit authorization was used. This proves live identity/connect and the three requested UI surfaces; it is not TX evidence or cross-platform hardware evidence. Generated with OpenAI Codex (GPT-6 Astra) --------- Co-authored-by: Jeremy [KK7GWY] <kk7gwy@aethersdr.com> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
## Summary Add a standard Window menu between View and Help that discovers visible top-level application windows whenever it opens. It provides Minimize, Zoom/Maximize, Full Screen, Bring All to Front, and a checked window list with minimized-state labels, deterministic duplicate numbering, and guarded activation. Transient Qt surfaces are excluded. The existing Canvas Windows submenu retains its open/close semantics. Minimize and Full Screen remain configurable through Configure Shortcuts. Their application-wide window-management policy works in secondary windows, with operating shortcuts disabled, and during text/slider focus. Other operating shortcuts retain their existing context and guards; keying actions cannot acquire the exemption. Minimal Mode uses the fixed Ctrl+Shift+M binding on every platform, freeing Command-M for macOS Minimize. Closes aethersdr#5889. ## Validation - Full AetherSDR build on Linux, integrated with main `199e2612c` at signed head `203e3b7c2`. - Focused CTests: `window_show_state_test`, `shortcut_manager_test`, `window_shortcut_test` — 3/3 passed. - Six deliberate mutations fail: popup filter removal, sort removal, window-only shortcut context, restored operating guard, slider-lease disabling, and an attempted keying-policy exemption. - Isolated offscreen demo-only app (`DEMO-0001`, TX disabled): actual QTest F11 delivery changes both main and active AetherTX `normal → fullscreen → normal`, with operating shortcuts off and on. Dynamic menu restores minimized AetherTX, preserves maximized state, and discovers AetherControl. - Strict engine-boundary, capability-record, command-plane, and touchpoint-manifest checks match main byte-for-byte, including tracked warnings; test registration, frozen CI gate, and whitespace checks pass. - The new key-event test is socket-free, registered in `tests/tests.cmake`, and uses the isolated settings profile. It runs in the existing unfiltered main/sanitizer suites; no PR workflow or frozen gate was changed. Maintainer decision for this PR: accept offscreen proof plus fresh CI and waive the remaining native Wayland/X11/macOS/Windows activation demonstrations. Native compositor activation is not claimed as verified. AI-assisted implementation and remediation. Co-authored-by: Jeremy [KK7GWY] <kk7gwy@aethersdr.com> Co-authored-by: Jeremy [KK7GWY] <105957118+ten9876@users.noreply.github.com>
…84 ms sooner after an unkey. Principle VIII. (aethersdr#5954) Fixes aethersdr#5498. ## What aethersdr#5498 measured, and what it turned out to be aethersdr#5498 reported about 0.17 s of receive audio lost after every unkey on a Hermes-Lite 2, ending in a hard step. **The audio is not lost.** With a signal left on the air across the whole over, it reappears after the unmute exactly on the receive chain's own latency, and no later: | | back to −6 dB after the unmute | same chain, never muted, signal starts | |---|---|---| | USB, linear phase (`main`) | 128 ms | 128 ms | Every input sample after the unmute is played. It is just delayed by the same ~130 ms that every HL2 receive sample always is. (The never-muted reference is primed with signal first, so WDSP's one-time startup up-slew doesn't add ~23 ms to it.) aethersdr#5498's figure starts timing at the unmute, so it counted that delay as a dropout. The "step" the band returns on is the filter's normal onset. It has the same 5–16 ms rise as any signal appearing on a chain that was never muted, not an artefact of the mute. So the lever this PR pulls is the latency itself. (on8st's "RXA hold" leg on aethersdr#5498 is another lever: it removed the hole at `final`, but replays ~130 ms of pre-transmit audio after the over. The ruling below takes the latency instead.) That latency is dominated by the RX bandpass: an 8192-tap **linear-phase** FIR, which delays everything by half its length (4096 samples, ~85 ms at the 48 kHz DSP rate). ## The change (maintainer ruling, KK7GWY, 2026-09-26) **Outside CW, the RX bandpass runs at minimum phase.** It's the same filter length, so the magnitude response, notch depth and 50 Hz notch floor (`kMinNotchWidthHz`) are unchanged. Only the group delay collapses: | | return after unmute, AGC off | AGC medium | |---|---|---| | linear phase (`main`, and CW after this PR) | 128 ms | 137 ms | | **minimum phase (every other mode after this PR)** | **44 ms** | **53 ms** | That is 84 ms less latency on all receive audio in SSB, AM, FM and digital modes, not only after an unkey. **CW keeps linear phase.** on8st measured the cost on aethersdr#5578: through a 300 Hz CW filter, minimum phase raises overshoot from 5.8 to 19.6 % and strengthens the post-edge ring (−33 → −21 dB at +10 ms). Through an SSB-wide passband keying edges are essentially unchanged, and data modes see under two samples of differential delay. How it's wired: `Hl2RxDsp::rxMinimumPhaseFor(mode)` picks the phase, and the phase follows the mode on all three routes a mode reaches the channel: - `buildChannel()` opens the channel in the right phase; - `setMode()` moves it on a live mode change (`WdspChannel::setMinimumPhase()`, which returns early when nothing changes and keeps the notch database); - `installChannel()` re-applies it for a mode that changed while a background rebuild was in flight. **The fade aethersdr#5498 also asked for is not added.** The return is the filter's own onset, the same rise a never-muted chain shows, so this PR treats that half of the issue as answered by the measurement above rather than by a ramp. **WDSP patch 12: the minimum-phase design FFTs plan with `FFTW_ESTIMATE`.** `create_minphase()` built four `FFTW_PATIENT` plans at `nc × pfactor`, which is 131072 points for the bandpass cores at 8192 taps. HL2 on `main` never planned those sizes, so no wisdom file has them. They run only when a filter is designed, never per sample. Measured with an empty wisdom cache and no planner limit, one RX `WdspChannel::create()` at 8192 taps: | | before | patch 12 | |---|---|---| | linear open | 36.4 s | 10.9 s | | minimum-phase open | 103.9 s | 10.9 s | | cold `setMinimumPhase(true)` on `hl2-io` | 69.3 s | 27 ms | Without it, every upgraded HL2 install would pay about 70 s once on its next connect, and a first-ever connect on a slow host would approach the 600 s DSP-setup limit. The patch applies to every caller, so it also covers the equaliser core that upstream builds minimum-phase on every RX and TX channel (all backends). The designed taps differ only at FFT-rounding level. Documented in `third_party/wdsp/AETHERSDR-PATCHES.md`. **Not changed:** the tap count, CW behaviour, aethersdr#5850's 70 ms unkey hold (that covers the radio's T/R turnaround and is independent of this), and every other backend's filter phase. ## Evidence **New `hl2_rxdsp_unmute_return_test`.** Socket-free: a real `Hl2RxDsp`, EP6-shaped blocks, AGC off so it times the filter rather than the AGC. The tone stays on the air before, during and after a 512 ms mute. ``` MEASURED: USB return after unmute 44.0 ms; fresh-onset latency 44.0 ms MEASURED: CWU return after unmute 128.0 ms; fresh-onset latency 128.0 ms MEASURED: USB then setMode(CWU): return 128.0 ms MEASURED: CWU then setMode(USB): return 44.0 ms MEASURED: rebuilt for USB, CWU set mid-build: return 128.0 ms ``` 19/19 checks pass. Mutations, each applied, rebuilt, run and reverted, with the source hash-checked afterwards: | mutation | FAILs | |---|---| | `setMode()` no longer moves the phase | 2 (both live-switch legs) | | `installChannel()` no longer re-applies it | 1 (the mid-rebuild leg) | | minimum phase in CW too (ruling ignored) | 2 | | linear phase everywhere (the old behaviour) | 3 | | `buildChannel()` opens linear | 0 | The last row is expected, and it's why that line's comment calls itself an optimisation. `configure()` also ends in `installChannel()`, so the re-apply decides the phase. Opening in the right phase only saves re-planning six masks under the process-wide FFTW lock on every build. **The property aethersdr#5498 is actually about is pinned too.** "The return is no slower than the chain's own latency" passes on both phases, and on `main`, because it has always been true. **`hl2_noise_blanker_test` now measures both phases.** Its "the blanker at least halves the impulse peak" was calibrated against linear phase. The peak is read after the bandpass, and minimum phase peaks an **unblanked** impulse 2.1 dB lower (0.627 → 0.490), while the blanked residue moves only 0.271 → 0.293. So the USB ratio went 0.43 → 0.60 without the blanker changing at all. The test now runs the same pair of passes on a CWU (linear-phase) chain and keeps "at least halves" there (measured 0.432). On USB it asserts "at least a third off" (0.598). A no-op blanker reads ~1.0 and fails both. **Local suite:** full build; `ctest -R 'hl2|wdsp|spectrum|anan|rtl'` passes 75/75 with patch 12 in (including `wdsp_channel_test` and `hl2_txdsp_test`). The four decoupling gates exit 0. No production `#include` changed. ## What was not verified - **Not driven through the agent automation bridge.** The HL2 receive chain can't be reached from the demo simulator, and a before/after unkey on hardware needs a transmit key, which this run had no authorization for. The evidence is offline: the real `Hl2RxDsp` and WDSP, no radio. - **No listening test.** Whether minimum phase is audible on SSB voice is not measured. on8st's aethersdr#5578 data covers keying edges and group-delay spread, not voice. - **The AGC-medium figures come from the same offline chain.** On-air AGC behaviour after a real over was not re-measured. ## Relation to aethersdr#5578 aethersdr#5578 asks to cut the same 64–85 ms, by switching tap counts on notch presence. This PR removes it outside CW without touching the tap count, so notches keep their 50 Hz floor. CW still carries the full delay, so aethersdr#5578's question remains open for CW. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…hand-maintaining it. Principle XI. (aethersdr#5897) Closes aethersdr#5868. aethersdr#5868 (@G6PWY-Chris) asks for a `Static checks` step that fails the PR when a declared seam signal has no probe-table entry. This is that step — and the argument that the table should not be a hand-maintained list at all. **A checker keeps two lists honest. A generator leaves one.** `tools/gen_seam_probe_table.py` derives the table from `IRadioBackend.h`'s `signals:` section and writes `tests/SeamSignalProbeTable.inc`; `--check` is the CI gate aethersdr#5868 asks for, and falls out of generation for free. The divergence stops being *reported* and starts being *unrepresentable*. ### Why the PR could not see it — narrowly Detection was **late, not absent** — and noisier than "a sticky issue" suggests. `full-suite.yml` runs unfiltered `ctest` on every push to `main` and opens or updates a sticky issue keyed by a fingerprint over the sorted set of failing test names. `sha256("backend_seam_affinity_test")[:12]` is `9aedc16d4457`, which is the tag already carried by aethersdr#5661 — open since 2026-09-13 for an unrelated *intermittent* `SimBackend` rule-6 race in that same test. So the five red runs of 2026-09-20 arrived as comments on an issue titled for a different defect, no `[full-suite]` issue was filed for the day at all, and separating the two was @G6PWY-Chris's work rather than the workflow's. That is how aethersdr#5825 surfaced. Nor is the per-PR lane blind to whole-tree invariants — `static-checks.yml` already runs nine build-free ones. The true statement is the narrower one aethersdr#5868 makes: **this invariant is expressed as a ctest, and no PR lane runs `ctest` over it.** `.github/ci-test-gate.txt` is frozen so `backend_seam_affinity_test` cannot join the per-PR allow-list, and `ci.yml`'s Linux job runs no `ctest` at all. So the assertion first executes post-merge. aethersdr#5825 added `autoRfGainArmSettled` to the header and not to the table: every check on that PR was green and none of them could have been otherwise; `main` went red on the merge with `51 probed, 52 declared`. @G6PWY-Chris bisected that to `03ddf480` and had the one-line repair open in aethersdr#5865 thirty-eight minutes after the red `Full Suite` run finished, then filed aethersdr#5868 for the lane that let it through rather than stopping at the repair. The breakage was ours; the diagnosis, the repair and the issue this PR closes are his. ### What changed | file | | |---|---| | `tools/gen_seam_probe_table.py` | **new.** Parses the `signals:` section; writes the table in declaration order. `--check` exits 1 naming the missing or extra signal. stdlib only. | | `tools/test_gen_seam_probe_table.py` | **new.** 21 scanner tests: the two false positives the triage comment found by running the naive version, plus every construct that would otherwise shorten the table silently. | | `tests/SeamSignalProbeTable.inc` | **new, generated.** 52 `AETHER_SEAM_PROBE(...)` lines. | | `tests/SeamThreadAffinityProbe.h` | `attachAllSeamSignals()`'s 52 hand-written lines become one `#include`. Same 52 signals, same set — only the order changes, to the header's. | | `.github/workflows/static-checks.yml` | two `run:` steps beside `gen_touchpoint_manifest.py --check` — the table check, and the scanner tests that hold the generator to refusing rather than under-reading. Steps, not new files, per the workflow's own header. | | `tests/tests.cmake` | the same `--check` plus the scanner tests as two `add_test()` entries, so `ctest` says so before a push does. Neither joins the frozen per-PR gate. | Named `gen_…` rather than the `check_seam_probe_coverage.py` the issue suggests, to sit with `gen_touchpoint_manifest.py`, `gen_theme_seed.py` and `gen_bridge_docs.py` — all of which are generators whose `--check` is the CI gate. The workflow step is unchanged in shape. ### The parse details from the triage comment All three are handled and pinned by `tools/test_gen_seam_probe_table.py`: 1. **The signals block terminates at the next access specifier.** Running to the class's closing brace also harvests `publishLegacyAudio`, `publishLegacySliceAudio` and `warnAudioDropped`, which are `protected:` and `private:` helpers. `test_signals_section_ends_at_the_next_access_label` asserts each one stays out, and `test_live_header_matches_the_committed_table` asserts it against the real header. Nested types' own labels cannot reach the outer class (`test_nested_scopes_cannot_close_or_open_the_section`), and a commented-out or quoted label cannot forge one (`test_comments_and_strings_cannot_forge_structure`). 2. **The `#define` line is no longer harvestable.** The spurious probe named `sig` came from `#define AETHER_SEAM_PROBE(sig)` living in the same file as the table. It no longer does: the generated `.inc` contains nothing but the table, so "probed" is exactly the set it looks like — and a signal genuinely named `sig` could not be masked. 3. **Fail loudly rather than fall through to "0 missing".** A preprocessor conditional inside the signals section raises with the directive named, rather than emitting whichever branch it happened to read (`test_conditional_compilation_in_the_signals_section_is_refused`); a guard elsewhere in the header does not trip it. A missing class raises rather than rendering an empty table that `--check` would then defend. One deliberate divergence: the triage comment suggests refusing *more than one* `signals:` label. Several sections are ordinary C++ and their union is unambiguous, so the scanner merges them and a test pins that. The refusal is reserved for what genuinely cannot be represented in a flat list — a signal that exists in only some build configurations. ### On the alternative the triage comment flagged for a maintainer That comment raises driving `attachAllSeamSignals()` from the meta-object at runtime (`qt_metacall` + `QMetaObject::connect`), noting it is strictly more robust but trades a typed compile-time `connect` for untyped metacall machinery in a test helper. Generation is a third option that takes the robustness without the trade. The hand-maintained list stops existing either way, but the probe lines remain ordinary `&IRadioBackend::<sig>` connects: still typed, still checked by the compiler, and still readable in a diff. It also keeps the reverse direction free — a probe for a deleted signal is a compile error, exactly as the issue says — while `--check` reports it earlier anyway, in seconds, without a build. ### Demonstrations Four mutants on this tree. `backend_seam_affinity_test` runs before and after the table is regenerated to show the generated table is behaviourally the committed one. **1. The tree as committed — green.** ``` $ python tools/gen_seam_probe_table.py --check seam probe table up to date (52 signals) # exit 0 ``` **2. A probe line deleted — red, naming the signal.** ``` $ python tools/gen_seam_probe_table.py --check ::error::tests/SeamSignalProbeTable.inc is missing a probe for: autoRfGainArmSettled — declared by IRadioBackend and never covered by backend_seam_affinity_test ::error::run `python tools/gen_seam_probe_table.py` and commit tests/SeamSignalProbeTable.inc # exit 1 ``` **3. A signal added to `IRadioBackend.h` with no probe — the aethersdr#5825 scenario.** ``` $ # void droopCompensationChanged(double db); added to the signals: section $ python tools/gen_seam_probe_table.py --check ::error::tests/SeamSignalProbeTable.inc is missing a probe for: droopCompensationChanged — declared by IRadioBackend and never covered by backend_seam_affinity_test ::error::run `python tools/gen_seam_probe_table.py` and commit tests/SeamSignalProbeTable.inc # exit 1 ``` **4. A stale probe for a signal that no longer exists.** aethersdr#5868 rightly says this direction needs no check, because it is already a compile error. `--check` reports it anyway, earlier and without a build: ``` ::error::tests/SeamSignalProbeTable.inc probes a signal IRadioBackend no longer declares: signalThatWasRemoved # exit 1 ``` **5. A signal the scanner cannot read — refused, not silently dropped.** This is the one that matters for a *generated* table. `--check` compares the committed table against the same parser that writes it, so a parser that under-reads produces a table short in exactly the matching way and both agree. Under-reading therefore has to be loud: ``` $ # auto droopCompensationChanged() -> void; added to the signals: section $ python tools/gen_seam_probe_table.py --check ::error::gen_seam_probe_table: IRadioBackend's signals section contains declarations this scanner cannot turn into probe lines, so the table would be silently SHORT — the exact failure this tool exists to prevent: ::error:: - a trailing return type (`auto f() -> void`) — this scanner anchors on a leading `void` ::error:: - `auto droopCompensationChanged() -> void` is a declaration this scanner could not read ::error::Teach tools/gen_seam_probe_table.py the construct (and pin it in tools/test_gen_seam_probe_table.py). Refusing rather than emitting an incomplete table. # exit 1 ``` Each mutated file was restored by rewriting it, and `--check` is green again after each. `tools/test_gen_seam_probe_table.py`: 21 tests, 0 failures. **6. `backend_seam_affinity_test` before and after.** The committed table is the hand-written one; the generated table replaces it. Built and run both ways on the same tree, to show the swap is behaviourally inert: Both legs took the machine's build slot with an `flock` (`tools/build-slot` in the lab), not a `pgrep` poll — the before leg queued 1355 s behind another agent's build, the after leg 625 s. The `Building CXX` line was read back each cycle to confirm the header swap actually recompiled rather than reusing a stale object: ``` before (hand-written table) after (generated table) [PASS] probe table covers every declared seam signal (52 probed, 52 declared) 0 failure(s) 0 failure(s) exit 0 exit 0 $ diff <(before assertions) <(after assertions) 37 lines each, identical — md5 04e000da43d30d54ca919c9b2d12ad9d both ``` The swap is behaviourally inert: same 36 `[PASS]` lines, same coverage figure, same exit. Note what the figure is now sourced from — `52 probed` comes from a file no human maintains, checked against moc's `52 declared` at runtime. To be precise about what that comparison does and does not establish: it is an output-equality check, not a timing one, so core contention could not have changed the result. The slot was taken because the machine was already running two full builds, not because the measurement was fragile. ### Not claimed macOS only — the build and the `backend_seam_affinity_test` runs below are from this machine. The CI behaviour of the new step is asserted from the workflow's structure and from the neighbouring steps, not measured on a runner. `--check` is stdlib Python with no Qt and no build, so the cost claim is a shape argument, not a timing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…heme tokens (aethersdr#5896). Principle XI. (aethersdr#5898) Closes aethersdr#5896. `src/gui/RadioSetupDialog.cpp` styled its caption labels and its line edits from colour **literals**, painted with a plain `QWidget::setStyleSheet`: ```cpp static const QString kLabelStyle = "QLabel { color: #c8d8e8; font-size: 12px; }"; static const QString kEditStyle = "QLineEdit { background: #1a2a3a; border: 1px solid #304050; " "border-radius: 3px; color: #c8d8e8; font-size: 12px; padding: 2px 4px; }"; ``` Only `ThemeManager::applyStyleSheet` records a widget in `m_trackedWidgets` for re-resolution on `themeChanged`; a plain `setStyleSheet` does not. So both constants survived **View ▸ Theme** unchanged. Read out of the shipped theme files rather than quoted: | token | Default Dark | Default Light | |---|---|---| | `color.text.primary` | `#c8d8e8` (`{color.gray.200}`) | `#1a2a3a` (`{color.gray.50}`) | | `color.background.1` | `#1a2a3a` (`{color.gray.800}`) | `#dde5ed` (`{color.gray.800}`) | | `color.background.2` | `#304050` (`{color.gray.700}`) | `#c8d2dc` (`{color.gray.750}`) | | `color.background.0` — the dialog's own ground | `#0f0f1a` | `#f5f5f8` | Under Default Light the captions painted `#c8d8e8` onto `#f5f5f8` — near-white on near-white — and the `QLineEdit`s kept a `#1a2a3a` fill, which is simultaneously Light's `text.primary`, so the text landed black on black. `tools/migrate_colours.py` already maps both, and its module docstring uses this exact pair as its worked example. ## Where this sits, precisely - **aethersdr#5848** (merged) introduced `kValueStyleTemplate` and `makeValueLabel()` in this file, for the four Radio Information **value** fields. - **aethersdr#5857** (open issue) / **aethersdr#5863** (open PR) cover the remaining *value* labels. Neither has merged. aethersdr#5857 scopes itself explicitly — "**Scope: 14 + 2 sites**", all of them `kValueStyle` — so the captions and the line edits were never in it. So this is not "the rest of aethersdr#5857". It is the sibling class in the same file, and the two changes are disjoint in which widgets they convert. ## The scope, and why it stops where it does **The cut is "every literal that is exactly its token's Default Dark value".** That is not a convenience line — it is the property that makes this change provably invisible under the shipped default (`ThemeManager.cpp` reads `ActiveTheme` with `"Default Dark"` as its fallback, and falls back to it again if the stored theme is unavailable): | literal removed | token | Default Dark value | |---|---|---| | `#c8d8e8` | `color.text.primary` | `#c8d8e8` | | `#1a2a3a` | `color.background.1` | `#1a2a3a` | | `#304050` | `color.background.2` | `#304050` | Each substitution is exact, so Radio Setup's dark appearance does not move; only Light does. The test pins that equivalence rather than asserting it. **Two things in the same file are deliberately NOT converted, because both would have to change the dark appearance:** - **`buildSerialTab`'s function-local `kLabelStyle`** — `#8898a8` at 11px, 14 sites, inside `#ifdef HAVE_SERIALPORT` (present in this build; Qt6::SerialPort resolves). `#8898a8` is **no** token's Default Dark value, has no mapping in `migrate_colours.py` and no row in `docs/theming/canonical-tokens.md`. aethersdr#5896 proposes `color.text.secondary` (`#8ea8c0` under Dark) as *defensible rather than established* — that is a judgement about the tab's hierarchy and a visible dark-theme change, so it wants its own PR and its own screenshot. It is marked as deferred in the source, and it no longer shadows anything. - **The Firmware Update group's `m_fwStatusLabel`** — coloured in **13** places, of which exactly **one** (the `FirmwareUploader::finished` switch's `Outcome::Unconfirmed` branch) uses `{{color.accent.warning}}`; its two siblings in the same `switch` are raw `#80e080` / `#e08080`, and there is no `{{color.accent.success}}` in that block at all. Converting one of thirteen is worse than converting none, and `#6888a0` (5 of the 13) is likewise unmapped. Also its own change. Nothing else in the file was touched: `kGroupStyle`, the Kiwi styles and the button styles keep their literals. ## What changed — one production file - **82 plain `setStyleSheet` sites → `applyStyleSheet`**, via two file-local helpers `applyLabelStyle()` / `applyEditStyle()` so each site is a one-line swap: 53 `setStyleSheet(kLabelStyle)`, 20 `setStyleSheet(kEditStyle)`, and the **9** derived sites `setStyleSheet(QString(kEditStyle).replace("QLineEdit", "QComboBox"))` (×7) / `"QSpinBox"` (×2), which `migrate_colours.py` lists as explicitly out of its scope. - **6 `applyStyleSheet(…, <raw-hex constant>)` sites take the templates instead.** These are the shape aethersdr#5896 names: registered for re-resolution, and given nothing to resolve, so they satisfied `audit_colours.py`'s call-site metric while staying dark. (aethersdr#5896's prose says "five" and then lists six; six is right — 3 `kLabelStyle`, 3 `kEditStyle`.) - **`kLabelStyle` and `kEditStyle` are renamed to `kLabelStyleTemplate` / `kEditStyleTemplate`.** A template still holding `{{tokens}}` must never reach `setStyleSheet`, which does not expand them; the rename makes a regressing call site a compile error rather than a label that paints the literal text of a token name. - **`kLpLabelStyle` / `kLpEditStyle` deleted.** The LP-100A row already carried byte-identical token forms of both — its comment called them "token-based equivalents of the file-level `kLabelStyle`/`kEditStyle`" — so that row now uses the file-level templates. Its reasoning is kept verbatim, because it is now the reasoning for the whole file. `kLpBtnStyle` stays: `kBtnStyle` has no file-level token form, and its hover-tier note still applies. The nine sweeps of the form `if (lbl->styleSheet().isEmpty()) lbl->setStyleSheet(kLabelStyle);` keep their guard and their behaviour: `applyStyleSheet` ends in `widget->setStyleSheet(resolved)`, so a converted label's `styleSheet()` is non-empty from the moment it exists. ### Reach, before and after Counted the way `tools/audit_colours.py` counts, over this file with comments stripped: | | base `7c8fdf84` | head | |---|---|---| | `setStyleSheet(` — untracked | 261 | **179** | | `applyStyleSheet(` — tracked | 152 | **163** | | total | 413 (36.8 % tracked) | 342 (47.7 % tracked) | The totals differ because 73 of the 82 converted sites now share the two helper call sites. ## Static gates, both sides of the merge base Merge base is `7c8fdf84`, current `origin/main`. Both trees were scanned by the same tool in the same run, against an extracted copy of that base rather than a quoted number. | Gate | Base `7c8fdf84` | Head | Verdict | |---|---|---|---| | Colour ratchet, `--src src --compare-src <base>/src --summary-only --strict` | 606 / 2622 / 1040 | 606 / 2618 / **958** | PASS, exit 0 | ``` OK unique_colours 606 (base 606, +0) OK total_references 2618 (base 2622, -4) OK setstylesheet 958 (base 1040, -82) ``` `-82` is the converted sites. `-4` is the four literals in the two deleted constant bodies (`#c8d8e8` ×2, `#1a2a3a`, `#304050`). `unique_colours` does not move because all three colours still occur elsewhere in `src/` — including elsewhere in this same file, in button styles this PR does not touch. The other nine `tools/check_*.py --strict` are unmoved: all nine produce **byte-identical output** on both trees once `line=` numbers are normalised (`check_a11y`, `check_capability_records`, `check_ci_test_gate`, `check_command_plane`, `check_engine_boundary`, `check_network_timeouts`, `check_shader_dialects`, `check_test_registration`, `check_theme_seed` — all exit 0). `check_a11y.py src/gui/RadioSetupDialog.cpp` reports **9 findings on both sides**: the same nine `a11y-disabled-reason-not-announced` warnings on `mcpBtn`, `txCheck`×2, `roCheck`, `dhcpBtn`, `staticBtn`, `applyBtn`, `cancelBtn` and `resumeDelayCheck`, each shifted by the 45 lines this diff adds above it. A line shift is not a new finding. ## Test New file `tests/radio_setup_label_theme_token_test.cpp`, registered in `tests/tests.cmake` with `${THEME_TEST_RESOURCES}`. **The theme switch is the only discriminator, and that is the whole shape of the test.** Because every literal removed IS its token's Default Dark value, a freshly built dialog under Default Dark is byte-identical before and after — so a construction-time assertion passes against the broken tree. The colour is therefore read back **across** a switch, dark → light → dark, so a one-way latch cannot pass either. Widgets are collected by **style shape with the colour left open** (`^QLabel \{ color: #[0-9a-fA-F]{6}; font-size: 12px; \}$`), so the same collector runs against both trees; the anchors keep value labels (`font-weight: bold`) and the 11px serial-tab captions out of the set. A **named floor** then pins that the collection is neither empty nor narrowed to the labels that happen to be right: `Nickname:`, `Callsign:`, `Station Name:` and ≥ 8 captions; the `Radio nickname` and `Station callsign` edits and ≥ 3 line edits. All of them are on the eagerly built Radio page, so no deferred page is opened and no hardware probe runs (aethersdr#1776). Three controls guard the oracle itself: 1. `availableThemes()` must contain both themes — without `:/themes/` compiled into the target, `ThemeSeedGenerated.cpp` still resolves every token, so a construction-time reading succeeds while `setActiveTheme()` has nothing to switch to and every "the colour moved" assertion compares a value with itself. 2. The two themes must **disagree** about all three tokens, or "it followed the switch" is unobservable. 3. The three tokens must resolve under Default Dark to exactly the three literals this PR removed — the equivalence claim above, pinned. It cannot agree with itself: those literals are no longer in the production file for these constants. ### Red, then green Built and run three times against the same configure, with only the named thing changed each time. `QT_QPA_PLATFORM=offscreen`. | Tree | Result | |---|---| | **Base `7c8fdf84`**, new test present | **5 passed, 2 failed** — `captionLabelColoursFollowAThemeSwitch` on `not registered against the token: Serial: \| qss=QLabel { color: #c8d8e8; font-size: 12px; }`, and `lineEditColoursFollowAThemeSwitch` on `color.background.1 not registered on Radio nickname`. The five controls and the floor pass, so the failures are the defect and not the harness. | | **Base**, with the two registration loops temporarily removed | **5 passed, 2 failed** — and now the failure is the **switch**: `expected #1a2a3a under Default Light: Serial:` and `expected #dde5ed under Default Light: Radio nickname`. The Default **Dark** pass immediately before it **passes**, because the literal IS the dark value. That is the measurement behind the claim that a construction-time check cannot see this. | | **Head** | **7 passed, 0 failed** (283 ms). | The second row is the one worth reading: it is the same evidence aethersdr#5863 gathered by mutation, reached from the other side — remove the assertion that fires first, and what is left is a dialog that is provably correct under Dark and provably wrong under Light. ### Full build and suite Clean full build of the tree from this configure: **0 errors**, `AetherSDR.app/Contents/MacOS/AetherSDR` links. Full `ctest -j4`: **525 of 525 passed, 0 failed**, 6 skipped, 225 s. `radio_setup_region_field_test` and `radio_setup_ip_config_presentation_test` — the other two tests that build this dialog — both pass. Configure line (the one this lab mandates): ``` cmake -B build -G Ninja \ -DCMAKE_IGNORE_PREFIX_PATH="/opt/homebrew;/opt/local" \ -DCMAKE_PREFIX_PATH=/Users/stanclaes/Qt/6.8.3/macos \ -DENABLE_ASR=OFF -DCMAKE_BUILD_TYPE=RelWithDebInfo ``` ## Landing order — two open PRs touch this file Neither is rebased onto, and this branch is cut from `origin/main` at `7c8fdf84`. - **aethersdr#5867** (`jcmerg`, **+483 / −1** in `src/gui/RadioSetupDialog.cpp`) adds `buildHl2HardwareTab()`. Its hunks are the include block, the constructor's `addPage` registrations, `showEvent`, and a large insertion after `buildCalibrationTab`. It references **neither** `kLabelStyle` nor `kEditStyle`, so the conflict with this PR is positional only — whoever lands second re-applies the other's hunks; no call site has to be reconciled. If aethersdr#5867 lands first, its new tab's labels and edits should use `applyLabelStyle()` / `applyEditStyle()`. - **aethersdr#5863** (ours, open) is the closer one: it edits the **same constant block** — `kValueStyle` sits between `kLabelStyle` and `kEditStyle` — and converts 16 value-label sites, some on lines adjacent to the `kLabelStyle` sweeps. The widget sets are disjoint; the textual overlap is the declaration block and a handful of neighbouring lines. Regions this PR touches: the file-scope constant block; `makeInfoField` / `makeCopyableInfoField`; every `build*Tab` that styles a caption or a line edit; the `buildSerialTab` shadow declaration (comment only); and the LP-100A row in `buildPeripheralsTab`. ## Not claimed - **No screenshot.** Everything above is read from source and from `resources/themes/default-{dark,light}.json`; the operator-visible claim is carried by the test's theme switch, not by an image. - The two deferred items above are **not** fixed. aethersdr#5896 asks for them; this PR says why they are not here rather than leaving the gap silent. - **`HAVE_HIDAPI` was OFF for this configure** — read from this tree's own configure log (`hidapi not found — USB HID encoder support disabled`; no `HAVE_HIDAPI` entry in `CMakeCache.txt`), so the hidapi-gated targets are not part of the 525 below. Nothing in this diff is hidapi-conditional. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…le XI. Capture and restore the compressor pre-drive and phase-rotator stage count in TX preset JSON. Clamp imported values to the supported ranges and preserve current values for older presets. Add export, round-trip, legacy-compatibility and range-clamping regression tests. Squashed-from: aethersdr#5963 Co-authored-by: Randy Nash <42493402+rnash2@users.noreply.github.com> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: jensenpat <patjensen@gmail.com>
aethersdr#5734). Principle VIII. Fixes aethersdr#5734. Refs aethersdr#5952. dexchange() released Sem_OutReady and only then copied its input slot out of r1. With blockForOutput set, that release is what lets the host's fexchange2 return, and the host's next call writes the next block into r1 at once. With inputBlockSize == dspBlockSize the ring holds two slots, so host call w+2 writes the slot worker iteration w has not read yet. A worker preempted between the release and its memcpy processes block w+2, or a torn mix, in place of block w, and nothing reports it. That is how two identical channels in runVector diverged under CPU load. AetherSDR WDSP patch 13 moves the r1 copy ahead of the release. Nothing else moves: out and in are separate allocations in RXA and TXA, and r1_outidx has no other writer outside csDSP. Only bfo channels release there, and every production channel opens non-blocking, so live audio is unchanged. What changes is every offline blocking-mode test. Evidence: - Instrumented fexchange2/dexchange, first diverging block: the diverging channel's worker read slot 0 while the host's r1 write count went 1 -> 3. exchange, exec_bypass, flushflag, up/downflag and the SetChannelState sequence were identical on both channels, so patches 7 and 8 are not involved. - Widening that window reproduces aethersdr#5734's first DIAG line exactly (RX block 3, dL=0.00205633 dR=0.00139192, rmsB=0). rmsB=0 is the correct channel. The block is inside the 10 ms mute delay-up and is exact zeros. - Standalone harness of the vendored library, Linux container, 16 nice-5 spinners: 33/600 trials diverged before, 0/1300 after (bounded planner in both, so the planner theory does not explain it). New runWorkerHandoffTest pins the ordering deterministically, with no load needed. A test-only port pause (wdspPortSetHandoffPauseForTest, 0 in shipping) sleeps the worker right after the release. With the reorder reverted it fails on every run: RX block 3 by 0.00205633, TX block 6 by 1.0e-5. runVector and its 1e-6 bound are unchanged. Squashed-from: aethersdr#5960 Co-authored-by: on8st <258096273+on8st@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: jensenpat <patjensen@gmail.com>
…es 3-4. Principle XI. Both parsers assumed the button bitmask lived in bytes 2-3. A raw capture from a ShuttleXpress shows buttons 1-4 in byte 3 bits 4-7 and button 5 in byte 4 bit 0 (the ShuttlePro v2 button 5-9 positions), so only physical button 1 was seen, reported as button 5. ShuttlePro v2 read byte 2 (always 0) as the low byte, shifting buttons 1-8 to 9-16 and never reading 9-15. - ShuttleXpress: pack (buf[3] >> 4) | (buf[4] & 1) << 4 into bits 0-4. - ShuttlePro v2: buf[3] | buf[4] << 8. - Require len >= 5 for both: m_buf is reused across reads, so a short report would otherwise decode a stale buf[4]. - Fix the layout comments. - Add hid_device_parser_test with the captured reports; it fails 3 checks on the old parser and passes on the fix. Fixes aethersdr#5927 Squashed-from: aethersdr#5931 Co-authored-by: sq9fk <12176404+sq9fk@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: jensenpat <patjensen@gmail.com>
…#5920) ## Summary The ANAN panadapter returns a fixed 1024 points, stretched across the panel. On a ~2000-pixel panel every point covers two pixels, so the trace and the waterfall look soft at every sample rate. A Flex avoids this because it is told the panel width (`xpixels`) and returns one bin per pixel. On the ANAN that `xpixels` command is dropped, because there is no command plane to carry it. This PR passes the width to the backend, and WDSP's display analyzer returns one point per pixel. deskHPSDR sizes its analyzer from the panel width in the same way. - **New seam `IRadioBackend::setPanPixelWidth()`**, a no-op by default. It is reached through `RadioModel::requestLocalPanPixelWidth()`, from the same place in `MainWindow_Wiring.cpp` that sends the Flex `xpixels` command. Only local-shaping backends (`shapesDisplayRatesLocally()`) take the seam. A Flex still gets `display pan set … xpixels=` on the wire, unchanged. Other local-spectrum backends used to have that text dropped. They now get the call and ignore it. - **The count is widened for the display edge crop** (`panPointsForPixelWidth()` in `SpectrumPreviewLogic.h`), so the span that stays on screen still has one point per pixel. `SpectrumWidget::panEdgeCropActive()` becomes public for this, and the crop's margin moves into `panEdgeCropMarginBins()`, which computes the same value as before. - **`AnanPanAnalyzer::setNumPoints()`** changes the count without re-planning. Every count up to WDSP's 16384-point limit fits the 16384-point FFT. The per-point average re-seeds from the next frame, so a width change never blends two point grids and never fades in from −160 dB. During a rebuild the change is deferred and re-applied at install, like the frame rate. - **The droop tables stay at 1024 points**, so stored calibrations and the derived defaults stay valid. They are read onto the current count by linear interpolation (`applyDroopCorrectionDbResampled()`). The calibrator reads each frame back onto the 1024-point grid (`resampleToDroopGrid()`). Rebased onto `main` after aethersdr#5814 and aethersdr#5824. The only conflict was a clash of test-group names: aethersdr#5814's follow-up added its own Group 1c, the seed-after-switch test, at the same spot. The width test is now Group 1d. A width change cannot hand back a frame computed on the old grid: `SetAnalyzer()` clears every ready frame (`analyzer.c`, the `pb_ready` reset), and nothing on this path skips it. ## Constitution principle honored Principle IV: clean-room. This uses the in-tree WDSP analyzer. Only the idea of sizing the analyzer from the panel width comes from deskHPSDR. No code was copied. Principle VIII: the point count, the tone's position on the wider grid, the re-seed and the droop resampling are pinned by tests, listed below. ## Test plan - [x] Local build passes (`cmake --build build`) - [x] Behaviour verified on a real radio (ANAN-G2), on the rebased branch (09-22): - trace and waterfall sharp at 48, 192 and 1536 ksps; - resizing the window narrow and wide keeps every width sharp, with signals at their frequency and no bad frame, with FFT AVG off and on; - with FFT AVG on, the trace returns at its level straight after a resize, with no fade-in; the same after switching log/linear averaging; - a zoom after a resize comes up sharp at the current width; - NB still toggles. - [x] Existing tests pass locally: full `ctest` on the rebased branch, 544 tests, 543 passed, 4 skipped. The one failure was an extra check I had added and then removed, because it could not fail. `ctest -R anan` on the review follow-up `efebd7b5`: 9/9. - [x] Static checks run locally (`tools/check_*.py --strict`): all pass. - [ ] CI - `anan_droop_correction_test`: resampling in both directions. The table is read at the same fraction of the span at 1840 and 3000 points, and a 2500-point frame is read back onto the 1024-point grid. At 1024 points both directions are exact. The span edges are pinned to the table's ends, and a one-point frame is refused without touching the output. A frame wider than the table is averaged over each table cell rather than sampled: an alternating ±1 frame at 2048 points reads at most 0.0005 (0.998 with point sampling). - `spectrum_preview_logic_test`: with the crop, `panPointsForPixelWidth()` returns the smallest count that covers the panel, for every width from 100 to 8000 pixels. Without the crop it returns the width itself. - `anan_rxdsp_handedness_test` Group 1d: at 2083 points a wire tone lands at its frequency (peak 1301, expected 1301), both when that count is set at configure time and when it changes mid-stream with averaging on. After the change the tone reads −13.47 dB against −13.40 dB before it, so the average re-seeds rather than fading in. A count below 2 is ignored. ## Checklist - [x] Commits are signed - [x] No new flat-key `AppSettings` calls. None added. - [x] Code is clean-room (Principle IV) - [x] All meter UI uses `MeterSmoother`. No meter UI touched. - [x] Documentation updated. Not applicable: no capability flag or bridge verb changes. - [x] Security-sensitive changes reference a GHSA. Not applicable. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…ethersdr#5852). Principle II. Fix the HL2 S-meter reading for the second and later receivers. The backend published `SLC1:LEVEL` but defined only `SLC:LEVEL`, so MeterModel discarded each reading. Decode the receiver index from the meter ID, declare a matching per-receiver definition, and withdraw it when a receiver build or connection is rolled back. Ignore withdrawals for meter indices that were never defined. The first receiver and indexless meters retain their existing lookup behavior. The source-token suffix is now a documented meter-update convention; an explicit `sourceIndex` signal field remains a possible follow-up. Validation: meter_model_test and hl2_slice_meter_lifecycle_test exercise the routing and definition lifetime, including refused and superseded connects. No dual-receiver HL2 hardware was available for a live check. Closes aethersdr#5852. Squashed-from: aethersdr#5866 Co-authored-by: on8st <258096273+on8st@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: jensenpat <patjensen@gmail.com>
…, filter board, ATU. Principle V. (aethersdr#5867) ## Summary A bare Hermes-Lite 2, an HL2 with the AK4951 companion board ("HL2+") and a SquareSDR 2 are **indistinguishable over Protocol 1**: same discovery reply, same gateware version, no board ID anywhere on the wire. They are not the same hardware, and the differences are not cosmetic. The config register's dither bit at `0x00[11]` drives a band-voltage output on the first and switches the local codec's loudspeaker on the other two — one bit, and the operator cannot know what their checkbox does unless the application knows which board it is talking to. The operator is the only party who knows which board is on the bench, so this adds `Hl2HardwareOptions` — a per-radio feature document in the same scope, and for the same reason, as `Hl2FreqCal`: that one describes a crystal, this one describes a board. | Setting | What it reaches | |---|---| | **Codec** — none / AK4951 / SquareSDR 2 | Gates the EP2 audio slot (on a bare HL2 that slot is **not audio** — its first word per frame is `EADDR`) and feeds the mixed speaker output to the radio's own codec, 24 → 48 kHz | | **Dither bit** | `C3` bit 3, surfaced with the label and meaning of the *selected* board. The operator's on every variant — never forced. Declaring an AK4951 seeds it on, because the gateware's own init already turned that speaker on | | **Random bit** | `C3` bit 4; offered for parity, no HL2 variant is known to use it | | **Filter board** — none / N2ADR RX+TX / N2ADR TX-only | `applyBandFilter()` now asks for a **receive** or a **transmit** byte instead of one byte for both. TX-only is the SquareSDR 2's wiring, plus its optional 3 MHz receive high-pass | | **ATU (gateware)** | `0x09[20]` while TUNE runs, for an AH-4 tuner the gateware drives — off by default so an I2C tuner on the N2ADR IO board is never asked to start alongside it | | **Speaker level** | The radio's own loudspeaker, 0–100 linear | ### Changed since review — read this first if you looked at an earlier diff This PR **no longer contains CL1**. It was lifted onto its own branch (`feat/hl2-cl1-external-reference`), because a GPSDO on the CL1 jack is an external accessory any of the three variants can have, not a statement about which board is on the bench — and `docs/architecture/hl2-frequency-calibration.md` §4 had already scoped it as a separate change with requirements this PR did not meet. That branch did **not** wait for this one to merge, as this paragraph said earlier: it is open as aethersdr#5923, stacked directly on this PR's head. GitHub cannot base a pull request on a branch that lives only on a fork, so aethersdr#5923 shows these commits too — only its own top commit is the change to review there. It has since been exercised on hardware (a Hermes-Lite 2 with a GPSDO's 10 MHz sine on CL1, fourteen live switches in both directions with the converter never losing its clock), which is what made waiting pointless. Four further fixes came out of the review, and two of them were defects this PR introduced rather than review preferences: - **The `Hardware` document could be written to the family-wide row.** `applyHardwareOptions()` persisted without checking `m_radioSerial`, and `RadioSettingsScope::isValid()` only requires a non-empty *family* — so one `hw.set` before connect would have declared one radio's codec for **every** HL2 the operator owns. That is the exact inverse of the per-radio argument this document is built on. Guarded, mirroring `applyFreqCalPpb()`. - **The page was gated on a family string.** `isHl2Family()` is now `declaresHl2Extension()`, asking `backendDeclaresExtension("hl2")` — the mechanism AGENTS.md's aethersdr#5554 notice requires and the one this page's own reseed already used. - The "Reference Clock and Tuner" group is now "Antenna Tuner", and the last two CL1 mentions in `CMakeLists.txt` and the touchpoint note are gone. - The capability map lists `hw.get` / `hw.set`, and records that — unlike `freqcal` — they have **no bridge verb**. ### What does not change **Defaults reproduce today's behaviour exactly.** `filterBoard` defaults to N2ADR RX+TX — the pattern this backend has driven unconditionally since it could tune — and every other field defaults to the bare board. The new `ccConfig()` and `ccTxDrive()` parameters are defaulted off, so existing callers emit the bytes they emitted before. **PA enable is deliberately untouched.** `setTxDriveLevel()` still derives it from `drive > 0`; the ATU bit is a third, defaulted-off argument alongside it. No PA switch, no per-band `disablePA`, no PA trim table. ### Why the speaker level is its own fader `AudioEngine` applies the operator's volume as a device attenuation on the host's `QAudioSink` (`AudioEngine.cpp:4285`), so it is **not in the samples** at any point upstream of the sound card and no tap could find it. deskHPSDR ties the two together by scaling in WDSP before the buffer that feeds both the sound card and the codec, but that follows from having one knob rather than from a decision — the speaker in the radio and the speakers on the desk are different transducers in different places. It is not coupled to the application's *mute* either: same `QAudioSink` attenuation, equally invisible here, and following it would mean this backend reaching into the audio engine for a fader. ##⚠️ Not in this PR: audio routing **The audio path is still being worked on.** Two things are deliberately absent: 1. **Output routing.** With a codec declared, the radio's loudspeaker is fed *in parallel* with the computer's — there is no PC / radio / both choice yet, and the "PC Audio" button does not gate it. That button is a host-side switch (`AudioEngine::applyBackendAudioCapabilities`) controlling whether the PC's capture and playback streams run; for Icom it additionally reaches the radio through the `icom` namespace, but for HL2 it says nothing about the codec. 2. **The radio's microphone as a TX source.** Not implemented at all — the EP6 mic word is still only a stride (`kRoundMicBytes` has no reader), so transmit audio always comes from the PC. The comment at `Hl2Backend.cpp` saying "no on-radio mic jacks" was true for a bare HL2 and is now false for the other two variants; it is left for that change to correct. **Groundwork already measured**, receive-only against a SquareSDR 2 (gateware 7.5), never keyed — every `C0` byte even so MOX stays clear: > The EP6 mic word updates at a **fixed 12 kHz** and is repeated across rounds, > independent of the DDC rate. Dominant run lengths were 4 at 48 kHz, 8 at 96 kHz > and 16 at 192 kHz — 12000 Hz in every case. That contradicts deskHPSDR's `mic_sample_divisor = rate / 48000` and its "reduce to 48000" comment, which assume the stream runs at the DDC rate. Harmless there, since a zero-order hold preserves the fundamental and the pitch comes out right anyway — but it means a future radio-mic path needs **no anti-alias filter**: the source is bandlimited to 6 kHz, so decimating by `ddcRate / 24000` to reach `submitTxAudio()`'s required 24 kHz is safe, with the hold's images at ≥12 kHz where the transmit filter removes them. Not yet confirmed to be an actual microphone — the level test that would settle it was not run, so "it is the codec's mic input" is the likely reading and not a measured fact. Written up in `docs/HERMES.md` §4, which recorded nothing about this slot before. ### Why both halves land together The codec selection and the dither bit **cannot be split**: the bit's meaning is *defined by* which board is selected. Shipping the dither control without the codec selector would leave a control that cannot know whether it says "band volts" or "loudspeaker" — worse than today, not better. The three-way enum survives that change of policy, but for a different reason than it was introduced with. It records **which board is fitted**, not which policy applies: `Ak4951` and `SquareSdr2` are behaviourally identical today (both only answer `hasLocalCodec()`), and they are kept apart because the document is already persisted as 0/1/2 at schema 1, because the UI names a real board to the operator, and because the boards do differ elsewhere — the EP6 microphone word runs at a different rate on the SquareSDR 2. The HL2 family is still `ExperimentalRadioSupport`-gated (`hl2` → `ShowExperimentalRadioNoticeHl2V1`), so landing the variant settings and the speaker level together, with the routing gap stated plainly above, is the honest shape for this stage rather than holding half of it back. ## Constitution principle honored **Principle V** — per-feature config ownership. One nested-JSON feature document per radio (`Hardware`), read-modify-written whole so a field added later is not dropped by an older field's write. No new flat `AppSettings` keys. **Principle VII** — boundary input validation. Both enums clamp on read, with deliberately *different* fallbacks: an unrecognised codec falls back to `None` (the answer that cannot write into `EADDR`), an unrecognised filter board falls back to N2ADR RX+TX, because `None` would **release the relays** on a radio that has a board. ## Test plan - [x] Local build passes (`cmake --build build`) — Qt 6.12.0, 4246 targets, no errors - [x] Existing tests pass — **524 / 525**. The single failure, `weather_radar_source_test`, is pre-existing and environmental (local clock / DST); its target compiles only `weather_radar_source_test.cpp` + `WeatherRadarSource.cpp` against `Qt6::Core`, so it is provably independent of this change. - [x] New test: `tests/hl2_hardware_document_test.cpp` — the `Hardware` document where it meets the settings store: the save/load round trip with both enums off their defaults, the read-modify-write that keeps a newer build's field alive, and the empty-serial case that would otherwise write the family-wide default row every other HL2 inherits. Socket-free. - [x] `tests/hl2_band_filter_frame_test.cpp` gained the EP2 audio gate: with no codec declared not one audio byte reaches the slot that is `EADDR` on a bare HL2, and withdrawing a codec drains what was already queued. - [x] New test: `tests/hl2_hardware_options_test.cpp`, Qt-free, pins the policy and all three wire encodings **verbatim against deskHPSDR's tables** — the N2ADR band groupings (160 m = 1, 80 m = 66, 60/40 = 68, 30/20 = 72, 17/15 = 80, 12/10 = 96), the `C3` bits, the ATU bit and the EP2 audio slot. None of this is readable back over Protocol 1, so that test is the only evidence there is. - [x] Radio reachable and answering (SquareSDR 2, gateware 7.5); the 12 kHz mic measurement above was taken on it, receive-only - [ ] **Behaviour not yet verified on the radio for the settings themselves** — the codec, dither and ATU paths are write-only on this protocol and were not exercised against hardware in this PR ### Merge conflicts checked Simulated against every open PR touching `backends/hl2/` or `RadioSetupDialog`: **aethersdr#5832, aethersdr#5825, aethersdr#5833, aethersdr#5821, aethersdr#5848, aethersdr#5855 merge clean.** aethersdr#5850 has exactly one trivial conflict — both insert a line after `m_tuning = false;` in the transport reset (`applyAtuTuneRequest(false)` vs `applyRxAudioMute(false)`); keep both, they are semantically independent. aethersdr#5850's move of the mixer gate to `m_rxAudioMuted` is *complementary*: the codec taps sit behind that gate, so the radio's speaker inherits the T/R hold, which is what it should do. ## Checklist - [x] Commits are signed - [x] No new flat-key `AppSettings` calls — nested JSON under one key (Principle V) - [x] Code is clean-room — the HL2 tables come from deskHPSDR/piHPSDR source and Quisk, all open source; nothing decompiled - [x] All meter UI uses `MeterSmoother` — no meter UI in this change - [x] Documentation updated — the 12 kHz mic measurement is written up as a field note in `docs/HERMES.md` §4, including what it is *not* evidence of - [x] No `CHANGELOG.md` edits - [x] No security-sensitive changes 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: jensenpat <patjensen@gmail.com>
…ersdr#5955) Bumps [jurplel/install-qt-action](https://github.com/jurplel/install-qt-action) from 4.4.0 to 4.4.1. <details> <summary>Release notes</summary> <p><em>Sourced from <a href="https://github.com/jurplel/install-qt-action/releases">jurplel/install-qt-action's releases</a>.</em></p> <blockquote> <h2>install-qt-action v4.4.1</h2> <h2>What's Changed</h2> <ul> <li>Provide default host "all_os" for Android and WASM targets in Qt 6.7.0+ by <a href="https://github.com/pzhlkj6612"><code>@pzhlkj6612</code></a> in <a href="https://redirect.github.com/jurplel/install-qt-action/pull/375">jurplel/install-qt-action#375</a></li> <li>Check "python" or "python3" before running scripts; use bash if possible by <a href="https://github.com/pzhlkj6612"><code>@pzhlkj6612</code></a> in <a href="https://redirect.github.com/jurplel/install-qt-action/pull/376">jurplel/install-qt-action#376</a></li> <li>Split aqt stdout and stderr to prevent unexpected string concatenation by <a href="https://github.com/pzhlkj6612"><code>@pzhlkj6612</code></a> in <a href="https://redirect.github.com/jurplel/install-qt-action/pull/377">jurplel/install-qt-action#377</a></li> </ul> <p><strong>Full Changelog</strong>: <a href="https://github.com/jurplel/install-qt-action/compare/v4.4.0...v4.4.1">https://github.com/jurplel/install-qt-action/compare/v4.4.0...v4.4.1</a></p> </blockquote> </details> <details> <summary>Commits</summary> <ul> <li><a href="https://github.com/jurplel/install-qt-action/commit/a9c63c7c123f3069cff414e7e482d95dfa9d8125"><code>a9c63c7</code></a> Build v4.4.1 on branch v4</li> <li><a href="https://github.com/jurplel/install-qt-action/commit/4b1de714df305cbacbcf82bce58e963871c21f80"><code>4b1de71</code></a> Merge pull request <a href="https://redirect.github.com/jurplel/install-qt-action/issues/377">#377</a></li> <li><a href="https://github.com/jurplel/install-qt-action/commit/ab0179b49971f1f306ab1a1759d8322275a00d9e"><code>ab0179b</code></a> Split aqt stdout and stderr to prevent unexpected string concatenation</li> <li><a href="https://github.com/jurplel/install-qt-action/commit/65312ef606a0962fb2372197b37393792e88c2ce"><code>65312ef</code></a> Merge pull request <a href="https://redirect.github.com/jurplel/install-qt-action/issues/376">#376</a></li> <li><a href="https://github.com/jurplel/install-qt-action/commit/53ffb82d713f2bb815e121faa02746918b5da41a"><code>53ffb82</code></a> Check "python" or "python3" before running scripts; use bash if possible</li> <li><a href="https://github.com/jurplel/install-qt-action/commit/bd9d62bbe4dbf9767696b1899bf14ac1cc6c9957"><code>bd9d62b</code></a> Merge pull request <a href="https://redirect.github.com/jurplel/install-qt-action/issues/375">#375</a></li> <li><a href="https://github.com/jurplel/install-qt-action/commit/4ce84e44b849e0506d541d852792b2bf1132e2cc"><code>4ce84e4</code></a> Provide default host "all_os" for Android and WASM targets in Qt 6.7.0+</li> <li>See full diff in <a href="https://github.com/jurplel/install-qt-action/compare/bcb88e3bed2e992f5f9e24c0f9e364a231d278eb...a9c63c7c123f3069cff414e7e482d95dfa9d8125">compare view</a></li> </ul> </details> <br /> [](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) --- <details> <summary>Dependabot commands and options</summary> <br /> You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) </details> Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…so pan is instant and diversity keeps one antenna per ear. Principle XI. (aethersdr#5971) ## Summary Every client RX noise-reduction method now denoises L and R independently, the way RN2 already did. That covers NR2, NR4, DFNR, NNR, BNR and macOS MNR. Refs aethersdr#4182; this removes the adapter its items 1 and 3 describe, and items 2 and 4 remain. **What was wrong** - **NR4, DFNR, NNR, BNR.** These averaged L and R to mono, denoised the mono, and rebuilt stereo through `MonoDspStereoAdapter`. Its balance estimate is a power envelope with about a 1 s time constant, about 2 s once square-rooted. - A hard L/R pan took about **5 seconds** to land. With NR2 or RN2 it was instant. - With diversity receive, both ears got the same blended antenna. - **NR2, MNR.** These kept each channel's waveform but ran **one** noise estimate on the L+R mix. Two antennas, or two slices panned apart for split or SO2R, shared one compromise mask. **What changed** - **NR2:** `SpectralNR` owns a right-channel instance, and every setter and reset reaches both. `processStereoSharedMask()` and its per-hop post2 follower guard are removed. - **NR4, DFNR, NNR:** two library instances, each with its own SRC pair, advanced through the same frame count per block so the image cannot drift. - **BNR:** two NVIDIA AFX denoiser effects. I tried batching both channels through one effect (`num_streams=2`): passing one pointer per stream crashed inside the SDK, and a single stream-major buffer leaked between streams. So batching is not used. - **MNR:** one noise estimator per channel. - **`MonoDspStereoAdapter`** and its test are deleted. - **Lockstep:** DFNR, NNR and BNR emit the shorter of the two channels' outputs. Identical SRC pairs keep the two equal. If they ever differ, a `Q_ASSERT` beside each `min` aborts a debug build, and a release build logs it once per filter instance. - **`probeDspStereo`** (the non-RN2 modes) now asserts the new contract: each side's output is bit-identical whatever the other side carries. It adds `leftIndependent`, `rightIndependent`, `channelsIndependent` and `*IndependenceMaxError` (`-1` when a run's output is empty or differs in length, so a filter that produced nothing never reads as independent). The L/R ratio is still reported but no longer judged. **`preserved` is removed for these modes** (RN2 keeps it). Nothing in the repo read it; `docs/automation-bridge.md` states the removal. The NR2 run disables post2, whose per-instance random comfort noise would otherwise make the three runs differ, and its result says so with `post2Disabled: true`. - **Docs:** `docs/architecture/audio-pipeline.md`, `docs/noise-filter-rate-domains.md` and `docs/automation-bridge.md` no longer describe NR as a mono collapse. **Tradeoffs, accepted by the maintainer** - **L/R balance is not held.** A signal present in both channels is suppressed separately on each side, and the quieter copy has the lower SNR, so it is usually suppressed harder. `main`'s shared mask and mono envelope held the balance by construction. Measured on synthetic tones: - With a strong signal (steady tones, +6 dB left, light noise), the shift is under about 1.5 dB for NR2, NR4 and BNR, up to 2.2 dB for NNR, and 3.6–5.2 dB for DFNR. - It grows as the quieter side nears the noise floor. On gated tones in heavier noise, NR2 and NR4 shift +2 to +5 dB (NR2 up to about +10 dB at 48 kHz), and NNR shifts −3 to −8 dB. - DFNR and BNR can remove a quiet steady carrier on one side entirely: DFNR −12 dB at a 12 dB offset, and BNR tens of dB. - The in-app probe's signal shows 0.3 dB (NR2) to 1.1 dB (DFNR). RN2 has the same property. - No test bounds this shift. Any fixed bound would be fitted to one synthetic signal. - Each method does about twice the work, and BNR holds a second GPU engine. **Found along the way: NVIDIA AFX stale state, fixed here, also present on `main`.** An effect created while another keeps the SDK loaded, after a third was destroyed, starts from the destroyed effect's leftover recurrent state. Its first ~0.5 s then differs from a fresh start by up to 0.27, the full signal level. Two filters running at the same time never leak into each other: I checked idle, busy and destroyed neighbours and concurrent different audio, and every difference was exactly 0. `NvidiaAfxFilter` now calls `NvAFX_Reset` on every effect after `Load` and in `reset()`. (An earlier revision of this description called this cross-effect coupling. That was a misreading.) ## Constitution principle honored Principle XI — Fixes Are Demonstrated. The new checks fail against `main` and pass here; numbers below. ## Test plan - [x] **Local build passes.** Linux, RelWithDebInfo, `ENABLE_ASR=OFF`. - [x] **New `nr_stereo_independence_test`**, via the shared `tests/nr_stereo_independence.h`, at 24 and 48 kHz. Checks: left output ignores right input, right ignores left (bit-exact), and after a hard pan step the vacated channel is more than 40 dB down within 300 ms. - Real NR2, NR4 and NNR pass. - The real DFNR model passes. - **Real BNR on an RTX 4090** (`AETHER_NVAFX_DIR`) passes. - Measured residual 165–222 dB down. - [x] **Proven failing on `main`.** The same checks added to `nr_rate_domain_test` (DFNR and BNR through the deterministic stand-in C APIs) were built in a worktree at `7dcf0cd7b`: - every independence check fails there; - the pan-step residual is **−6.8 to −7.0 dB after 300 ms**, against −216 dB with this PR. - [x] **`nr_rate_domain_test`:** the per-channel sample-count expectation is now `2 × 96000`. - [x] **`mac_nr_filter_test`:** the SNR check used L−R to cancel noise through a shared mask, which is invalid with per-channel masks. It now splits signal from noise in frequency, using tone-band power on exact DFT bins. - The rewritten measurement reproduces the old one: 1.98 vs 1.93 dB input SNR, and 18.6 vs 18.8 dB improvement on `main`'s filter. - Independence and pan-step checks were added. - I first ran it on Linux against a local vDSP stand-in, not committed, which reproduces all 17 of `main`'s results exactly. macOS CI has since run it against real Accelerate, and it passes. - [x] **`nvidia_afx_filter_test`** passes on real hardware. - [x] **AFX reset regression:** - `nr_stereo_independence_test` checks that a BNR filter created while another is alive, after a third was destroyed, matches a fresh one bit-for-bit. It runs on the real GPU. - `nr_rate_domain_test` checks through the stand-in API that every effect is reset before its first `Run`, and that `reset()` resets both effects. It runs on every CI platform. - Mutation: with the reset disabled, both fail. Restored, both pass. - [x] **Passthrough can't pass:** every method must attenuate broadband noise on both channels by at least 3 dB. Measured: NR2 −28 to −31, NR4 −5.6 to −5.8, NNR −18 to −27, DFNR −42 to −49, BNR −82 or more. - Mutation: making `SpectralNR::processStereo` a passthrough fails this check at both rates. The independence and pan checks alone did not catch it. - [x] **Windows:** `NOMINMAX` is defined before `windows.h` in `NvidiaAfxFilter.cpp`. - [x] **Full suite:** 546/550 pass and 3 skip. `connection_panel_size_test` fails identically on `main`'s own build and is unrelated. - [x] **In-app, offscreen, no radio:** `audioCapture probeDspStereo all` shows NR2, NR4, DFNR and NNR `ok` with **zero** independence error. BNR also has zero independence error, now with its availability-check filter alive alongside the three probe filters, but reports `audible: false`, because it removes the probe's steady tones entirely, as on `main`. RN2 is unchanged and `ok`. - [x] **Where CI runs these:** `nr_stereo_independence_test` and `nr_rate_domain_test` are registered in `tests/tests.cmake`. They are not in the PR merge gate's frozen `-R` list; they run in `full-suite.yml`, the unfiltered `ctest` on every push to `main`. The PR gate runs only `mac_nr_filter_test`, on `check-macos`. Real-model and real-GPU results come from local runs, since CI has no GPU or AFX pack. - [x] **Gates:** `check_ci_test_gate.py` and `check_test_registration.py` are both OK. - [ ] Behavior verified on a real radio: the maintainer is testing diversity on a FLEX-8600. ## Checklist - [x] Commits are signed (`docs/COMMIT-SIGNING.md`) - [x] No new flat-key `AppSettings` calls - [x] Code is clean-room - [x] All meter UI uses `MeterSmoother` (no meter changes) - [x] Documentation updated (`docs/`); no `CHANGELOG.md` entry - [x] Security-sensitive changes reference a GHSA if applicable (n/a) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…d, and command BYPASS→STANDBY operate-first so it no longer flashes OPERATE. Principle II. The TGXL status carries no per-port trigger mode on either path, so the port strips stopped guessing one: - Direct path: a port's source cell is shown only while the tuner has a live reading on it, and left out otherwise. - Relay path: port A names the connected radio while there is one, and port B's source cell is left out. Port A no longer reads "NO RADIO" when disconnected; its cell is empty. Port B no longer reads "RF SENSE". AccessoryPortRow::setSourceVisible() hides and shows the cell. The row still shows the cell by default, because the PGXL amplifier's strips carry the source radio as configuration and never hide it. A hidden cell is also left out of the spoken description, the same way the frequency cell already is. BYPASS→STANDBY (the STBY key and the docked rail's cycle) now sends mode=0 before bypass=0. The radio's status between the two commands then already reads STANDBY (operate=0). The old order had it report operate=1 bypass=0, which flashed OPERATE. The model still applies every status exactly as the radio reports it; only the order of the requests changed. Tests: - tgxl_applet_ports_test (new) pins the relay-path source cells and the command order through both the STBY key and the rail. - tgxl_panel_widgets_test pins setSourceVisible and the spoken sentence. - pgxl_panel_test now requires the amplifier's source cell to be visible and spoken. Co-authored-by: rah501xx <bob@bobholzer.com> Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ten9876
force-pushed
the
fix/tgxl-standby-bypass-flicker
branch
from
September 27, 2026 17:07
0dbd4d4 to
82bf67e
Compare
Accessibility check findingsThis PR touches
See Findings are warnings only — the build is not blocked. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fix two TGXL applet display bugs introduced in v26.9.4
Bug 1 — STANDBY/BYPASS mode label doesn't track hardware
Switching between STANDBY and BYPASS requires two FlexRadio commands (mode= then bypass=). The radio echoes full TGXL status after each command, so an intermediate echo temporarily reverts the field still in flight — producing a visible flicker (e.g. STANDBY→BYPASS flashes OPERATE between the two echoes).
Fix: setOperateAndBypass() added to TunerModel to issue both commands atomically with hold guards that block intermediate echoes until the hardware confirms the commanded values.
Regression fix: If the confirmation echo for a held field never arrived (FlexRadio sometimes sends only changed fields), the hold stayed armed indefinitely. The next single-command transition (setOperate / setBypass) did not clear it, leaving the UI stuck. Fixed by clearing all holds at the start of any single-command transition.
Tested on hardware: cycled STANDBY↔BYPASS >20 times, then toggled STANDBY repeatedly — display tracks hardware correctly throughout with no flicker and no stuck labels.
Bug 2 — Source label always shows "RF SENSE" regardless of trigger mode
The TGXL protocol (both relay and direct port-9010 paths) does not expose per-port trigger mode. The source label was permanently hardcoded to "RF SENSE" — an assumption that is wrong on any port configured for PTT.
Fix: The source label in AccessoryPortRow is now hidden by default and only appears when the port has confirmed live source data. On a PTT-mode port the label is absent from startup and stays absent permanently once PTT is ever sensed — matching the behaviour of the actual TGXL front panel exactly. setSourceVisible() added to AccessoryPortRow following the setFrequencyVisible() pattern.
Commits
cc16bc5 — hold guards to prevent STANDBY/BYPASS display flicker
49fb09f — clear stale holds on single-command transitions
4e23fd1 — source label hidden until port trigger mode confirmed
CI notes
The accessibility bot reports 49 warnings across 16 src/gui/ files. These are pre-existing findings in files touched by this PR, not regressions introduced here. Build is not blocked.