Repository navigation
Fix per-model DAX IQ rate limits and safe restoration - #6492
Conversation
There was a problem hiding this comment.
Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.
An organization owner can choose the repository organization as the billing source for reviews requested by members with a paid Copilot license. Learn about Copilot code review billing.
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
Issue fit
Partially. The capability table, the Flex publisher, the applet's disabled-with-reason choices, the setSampleRate/createStream guards, the profile-recovery ordering and the "fallback does not overwrite the saved preference" behaviour all land as #6486 asks, and the test coverage for them is unusually thorough. I verified the Principle I claim directly against the local FlexLib reference (~/reference/FlexLib/FlexLib/ModelInfo.cs, v4.2.18 — the PR cites 4.2.20, but the field and every value match): DaxIqSampleRates is {24000,48000,96000} for exactly FLEX-6300, FLEX-6400, FLEX-6400M, FLEX-8400, FLEX-8400M, AU-510 and AU-510M; {24000,48000,96000,192000} elsewhere including RT-2122; ImmutableList<int>.Empty on DEFAULT. The table in ModelCapabilities.cpp reproduces that exactly, and the struct's new field sits in the position the positional kTable initializers use.
Two defects below, one of which is a protocol-side regression against the issue's own "Guard production rate requests … including non-GUI consumers" line — it guards them a little too hard.
Scope
| File / group | What it changes | Claimed? | Verdict |
|---|---|---|---|
ModelCapabilities.{h,cpp} |
maxDaxIqRateHz + daxIqSampleRates() from FlexLib |
Yes | In scope |
RadioCapabilities.h, FlexBackend.cpp, RadioModel.cpp |
Publish/consume the rate list; setSampleRateProvider |
Yes | In scope — new capability surface, see note |
DaxIqModel.{h,cpp} |
Provider, supportsSampleRate/sampleRateForPreference/requestedSampleRate, request guards |
Yes | In scope |
DaxIqApplet.{h,cpp} |
Disabled choices, availability registry, restore timer, syncStreamState |
Yes | In scope |
TciServer.{h,cpp} |
Rate admission + effective-rate announce/reconcile | Yes | In scope |
MainWindow_Wiring.cpp |
Profile-load recovery applies the supported rate before create | Yes | In scope |
tests/*, tests.cmake |
Socket-free regression target + opt-in bridge proof | Yes | In scope |
docs/user/docs/{dax-iq-streaming,tci-server}.md |
Limits documented | Yes | In scope |
docs/user/src/components/LogAnalyzer/rules.json |
Line-number refresh only (+5 in RadioModel.cpp, +46 in DaxIqModel.cpp) |
Yes | In scope — mechanical, consistent with the diff |
Everything in the diff is explained by #6486. No CHANGELOG entry, no new flat AppSettings keys (confirmed — only existing DaxIqRate%1/DaxIqEnabled%1 are read/written), no preference changes smuggled in: every UI change is "show the unsupported rate, disabled, with a reason", which the issue asks for in those words.
One note rather than a finding: RadioCapabilities::daxIqSampleRates is new public capability surface. It's QList<int>, so per the bools-only capability ratchet it needs no FROZEN entry, and the issue explicitly asks for "the existing model/backend capability design". Flagging it only so the maintainer sees it consciously.
Blockers
1. src/core/TciServer.cpp:1485-1488 — an unknown model silently discards a TCI client's rate request instead of deferring it
const bool supported = ok
&& (rate == 24000 || rate == 48000
|| rate == 96000 || rate == 192000)
&& (!m_model || m_model->daxIqModel().supportsSampleRate(rate));
if (!supported) {
replyText(ws, QStringLiteral("iq_samplerate:%1;")
.arg(achievedIqSampleRate()));
continue;
}supportsSampleRate() is false for every rate while daxIqSampleRates is empty, and empty is the state for an unknown or not-yet-reported model — FlexBackend::capabilities() builds the list from capabilitiesFor(m_modelProvider()), and m_modelProvider is [this]{ return m_model; } (RadioModel.cpp:884), which is empty until the radio's model= status arrives. The TCI server also runs whenever the TCI applet is enabled, radio or not.
Failure scenario: SDC or CW Skimmer is already connected to the TCI port (the documented topology — the server outlives radio connections). The operator starts AetherSDR, or connects the radio by IP. The skimmer negotiates iq_samplerate:96000; once, at its own connect, before model= lands. Today that request is whitelisted and stored in m_iqSampleRate, and applied when the streams come up. After this change it is refused, m_iqSampleRate stays at the 48000 default, and nothing recovers it: when the model does resolve, announceEffectiveIqSampleRate() computes sampleRateForPreference(48000) == 48000 == m_announcedIqSampleRate and returns early, so there is no re-announce and no cue for the skimmer to retry. The skimmer runs the whole session at 48 kHz on a radio that supports 192.
The PR documents the behaviour ("Until a supported model is identified … TCI rate requests follow the same model limits"), but documenting it doesn't stop it being a regression for a non-GUI consumer that only asks once. The applet path doesn't have this problem because it re-arms on capabilitiesChanged; only the TCI SET is lossy.
An empty list means unknown, not nothing is allowed — and the rest of the machinery you added already clamps correctly at apply time (ensureIqStream() bails on rate == 0, effectiveIqSampleRate() downgrades). So store the whitelisted preference and let it clamp:
const QList<int> modelRates =
m_model ? m_model->daxIqModel().supportedSampleRates() : QList<int>{};
// An empty list is "model not identified yet", not "no rate is
// legal": a skimmer negotiates its rate once, at connect, which can
// precede the radio's `model=` status. Refusing then would strand it
// at 48k for the session. Keep the preference; effectiveIqSampleRate()
// clamps it the moment the model is known.
const bool supported = ok
&& (rate == 24000 || rate == 48000
|| rate == 96000 || rate == 192000)
&& (modelRates.isEmpty() || modelRates.contains(rate));A known-but-incapable model (the FLEX-6400 case the PR is actually about) still refuses, which is the behaviour the test asserts.
2. src/gui/DaxIqApplet.cpp:268 + :296 — the On/Off pill's own styling is immediately reverted by the new availability registry
m_iqEnable[idx]->setText(exists ? "On" : "Off");
m_iqEnable[idx]->setStyleSheet(exists ? kIqBtnOn : kIqBtnOff);
...
if (m_model && m_availability) {
m_availability->refreshEngaged();
}refreshEngaged() → ControlAvailabilityRegistry::applyOne → ThemeManager::setWidgetForegroundToken(w, …) (ThemeManager.cpp:1632-1653), which ends in applyStyleSheet(widget, it->stylesheetTemplate) — the template cached when the registry first tracked the widget, not the sheet the widget has now. Unlike eventFilter and the theme-switch path, which both guard with w->styleSheet() == it.value().appliedStylesheet ("somebody set their own sheet afterwards"; ThemeManager.h:524-529 names "TX indicator state" as exactly this case), setWidgetForegroundToken has no such guard.
Because setWidgetForegroundToken early-returns when the token is unchanged, the clobber lands precisely on the transitions where the engaged state flips — which is exactly when syncStreamState just changed the stylesheet:
- Registered while the channel is Off (template =
kIqBtnOff). Stream comes up →setStyleSheet(kIqBtnOn)→refreshEngaged()→ Inactive→Active, token"color.control.inactive"→""→ re-pusheskIqBtnOff. Button reads "On" in the Off colours. - Registered while the channel is already On (the late-binding path your own test exercises, template =
kIqBtnOn). Turning it off →setStyleSheet(kIqBtnOff)→ Active→Inactive → re-pusheskIqBtnOn. Button reads "Off" in the On colours, and that template never updates.
This is reasoned from the code, not observed running — but it is the first time this applet has had a ControlAvailabilityRegistry, so it is introduced here. tests/dax_iq_rate_limits_test.cpp:318 and :331 assert text() == "On" / "Off" and the accessible description, never styleSheet(), so the suite passes straight through it.
The fix is to stop fighting over the sheet: route the state styling through the theme manager so the cached template is the current one (also the house rule for setStyleSheet escapes):
m_iqEnable[idx]->setText(exists ? "On" : "Off");
ThemeManager::instance().applyStyleSheet(m_iqEnable[idx], exists ? kIqBtnOn : kIqBtnOff);Please add an assertion on the rendered sheet (or on stateOf() plus styleSheet()) so this can't silently come back.
Nits (non-blocking)
src/models/DaxIqModel.cpp:160—sampleRateForPreference()'s final fallback isrates.contains(48000) ? 48000 : 0. Harmless today (every non-empty list is a prefix of{24,48,96,192}), but a future list without 48000, or a 96k preference on a 24/48-only model, lands on a rate nobody asked for rather than the highest supported one ≤ the preference.std::upper_boundover the sorted list would be the honest shape.src/gui/DaxIqApplet.cpp:313-342—refreshRateChoices()writescombo->setAccessibleDescription()directly whileControlAvailabilityRegistry::applyTextalso owns that channel through itsaetherAvailabilityDescriptionhold property. The hold mechanism is designed to tolerate a second writer, but the resulting text is now ordering-dependent betweenrefreshRateChoices()and the nextrefreshEngaged(). Worth a comment at least.src/gui/DaxIqApplet.cpp:313-342—reasonsis accumulated only onch == 0and then applied to all four combos. Correct (the list is per-radio, not per-channel) but it reads like a loop-variable bug; hoist it out of the channel loop.src/core/TciServer.cpp:1494— on a successful SET the reply echoesrate, the preference, whileachievedIqSampleRate()is used everywhere else. Consistent today only because the SET is refused whenever the two could differ; if Blocker 1 is fixed as suggested, echo the effective rate here too.tests/dax_iq_rate_limits_test.cpp:88—ok = ok && server.m_iqSampleRate == 192000after the rejected 192 kHz SET is vacuous:m_iqSampleRatewas already 192000 from the accepted SET and the reject path never writes it, so that clause passes even with the whole reconcile path deleted. Thesent.last() == "iq_samplerate:96000;"clause next to it is the one doing the work.tests/dax_iq_rate_limits_test.cpp—spin(1600)against a 1500 msQTimeris 100 ms of slack; that will flake under TSan/ASan. 2500 ms costs nothing here.tests/dax_iq_rate_limits_test.cpp:281,284—--capturewrites fixed/private/tmp/dax-iq-*.pngpaths in a shared directory. Opt-in and harmless, butQTemporaryDiris one line.- From the security audit (CodeGuard):
tests/dax_iq_rate_limits_test.cpp:197—bridge.setAuthToken(qEnvironmentVariable("AETHER_MCP_TOKEN")). If that variable is unset the token is empty andAutomationServer's gate (if (!m_authToken.isEmpty() && cmd != "ping")) is skipped, so--bridge-proofruns the bridge unauthenticated for its 60 s window. Mitigated by the owner-only socket,AETHER_AUTOMATION_NO_TX=1andsetTxAllowed(false), so not a finding — but failing fast on an empty token would be better than silently dropping auth.
Socket test disclosure
tests/dax_iq_rate_limits_test.cpp adds a socket path: under the opt-in --bridge-proof argument it starts the production AutomationServer on the owner-only local socket named by AETHER_AUTOMATION_SOCKET (QLocalServer; no radio transport, no firmware peer, TX pinned off). The default run is socket-free, tests.cmake:6493-6504 registers dax_iq_rate_limits_test with no arguments so CI never reaches that branch, and the tests.cmake comment names the socket. The PR body discloses it. Recording per the socket-test rule; not a finding.
What I tried to break
- The capability table itself. Checked row-by-row against the local FlexLib
ModelInfo.cs(v4.2.18) rather than the PR's citation: the 96 kHz set, the 192 kHz set, RT-2122 and the empty DEFAULT all match, and the newmaxDaxIqRateHzfield's position in the struct matches the positionalkTableinitializers, so no row is silently off by one. - Other backends losing DAX IQ.
daxIqSampleRatesis set only byFlexBackend, andhasDaxStreamsisfalseon HL2, Icom and Sim — so the new "empty list ⇒ no streams" rule cannot regress a non-Flex or simulator path. - Double-create on restore. The deleted
m_restorePendingcomment named a real symptom; the singleQTimer+isActive()guard is strictly stronger (one timer object cannot double-schedule), andrestoreChannelsNow()keeps thestream(...).existsskip. I also checked the newcapabilitiesChanged → restoreEnabledChannels()edge:publishCapabilitieshas four callers and fires on capability revisions, not per status line, so it cannot become a 1.5 s create loop. DaxIqAppletlifetime.~DaxIqApplet() = default;is out-of-line in the TU that includesControlAvailabilityRegistry.h, so theunique_ptrto the forward-declared type is fine; the registry being bothunique_ptr-owned and QObject-parented tothisis redundant but not a double free (~QObjectdeparents).m_modelis aQPointerand every lambda re-checksm_model != model, so a stale connection from a replaced model is inert.MainWindow_Wiring.cpp:3150-3165ordering.setSampleRaterecordsm_desiredRatebefore itsexists/streamIdearly return, and the subsequentapplyStreamStatus(isNew)reapply usessampleRateForPreference(m_desiredRate)— so dropping the 600 ms delayedsetSampleRategenuinely does restore at the right rate without racing a later session.- Injection through the new rate path. The TCI value goes through
toInt()and a closed whitelist; bothQString::argsubstitutions in the emittedstream set 0x%1 daxiq_rate=%2are integers, so no client text reaches a Flex command string. Confirmed independently by the security-audit (CodeGuard) pass, which also refuted every CS-VLN-CMDI-BACKTICK hit as Markdown-quoted wire literals in comments, the SSRF-LOCALHOST/PII-IP hits as loopback-normalisation comments, and the PATH-UNC/PII hits as JSON-escaped regex bodies inrules.json. No confirmed security finding. - What I could not check: nothing was built or run — every finding above is reasoned from the code in
/tmp/aetherclaude/pr-6492. The PR's bridge-proof screenshots and the "live 96 kHz radio validation" box are unverifiable from here, and CI being green covers only the-R-filtered subset.
Recommendation
Request changes — on Blocker 1 primarily. The capability work underneath is accurate and well tested (the mutation list and the replacement/reconnect cases are better than most PRs in this area get), but the TCI admission check turns "model not known yet" into a permanent silent downgrade for exactly the consumer class the issue names, and the applet's On/Off pill loses its colour on every state flip. Both are small, local fixes to code already in the diff. Concrete next step: apply the two suggested edits, add an assertion on the enable button's rendered stylesheet across one On→Off→On cycle, and loosen the spin(1600) slack.
Thanks for the FlexLib citation with line-level detail — being able to diff your table against the reference row-by-row is what made the Principle I half of this review quick and conclusive.
🤖 aethersdr-agent · cost: $13.6579 · model: claude-opus-5
|
Cisco CodeGuard — static analysis of this PR (154 finding(s))
Automated static scan by Cisco DefenseClaw CodeGuard on the changed files. Advisory — some may be false positives; the review above verifies them. 🤖 aethersdr-agent · cost: $13.8265 · model: claude-opus-5 |
|
Addressed the review in signed commit a630422. Both blockers reproduced and fixed.
Nits addressed: shared unavailable-rate reasons are calculated outside the channel loop; rate descriptions are supplied before a registry refresh so per-rate reasons and active/inactive announcements compose consistently; the rejected-SET test now starts from a different stored preference; restore tests wait for the expected signal with a 2.5 s deadline (negative checks also allow 2.5 s); optional captures use a unique I left the generic Verification: the two new regression scenarios failed before the fixes and pass afterward. Native ARM64 macOS application build with RADE enabled, all four focused CTests, docs build, architecture/capability/test/theme/accessibility checks, missing-token refusal, and the authenticated TX-disabled agent automation bridge proof passed. The default test remains socket-free. No radio transport or RF transmission was used; live radio/profile-load validation remains unperformed. CI will rerun on this commit; the preceding commit had all required checks green. CodeGuard: I inspected the 40 individual alerts shown in the advisory comment. The backticks and loopback addresses are in C++ comments; The PR remains ready for review. |
nigelfenton
left a comment
There was a problem hiding this comment.
Reviewed at the request of the maintainers. Both blockers from the earlier review are properly fixed rather than demoted — I checked the current head (a630422e) and proved each fix with a mutation rather than reading the diff and believing it.
Everything below was built and run on Windows/MSVC (Qt 6.12.0, RelWithDebInfo) in a scratch worktree at the PR head.
Issue fit
Yes. I verified the Principle I claim independently: all 19 rows of the rate table match FlexLib's DaxIqSampleRates, and the case-insensitive prefix contract resolves correctly — AU-510 catches AU-510M, AU-520 catches AU-520M, and AU-510 does not swallow AU-520. Deriving the list from maxDaxIqRateHz rather than hand-listing four values per row is the right call; it makes a wrong row impossible to express as an inconsistent set.
Scope
| File / group | Claimed by the body? | Verdict |
|---|---|---|
ModelCapabilities.{h,cpp} |
Yes | In scope; table verified against FlexLib |
RadioCapabilities.h, FlexBackend.cpp, RadioModel.cpp |
Yes | In scope — new capability surface, see nit 1 |
DaxIqModel.{h,cpp}, DaxIqApplet.{h,cpp} |
Yes | In scope |
TciServer.{h,cpp} |
Yes | In scope |
MainWindow_Wiring.cpp |
Yes | In scope |
tests/*, tests.cmake |
Yes | In scope; preflight cleared (below) |
docs/user/docs/*, rules.json |
Yes | In scope; rules.json is mechanical line-number refresh |
No CHANGELOG.md entry, no new flat settings keys, no smuggled preference — every UI change is "show the unsupported rate, disabled, with a reason", which #6486 asks for in those words.
The one thing blocking merge
CONFLICTING on docs/user/src/components/LogAnalyzer/rules.json — the only conflicting file; everything else auto-merges cleanly. Worth mentioning once rather than twice: #6491 conflicts on this same generated file. If you are rebasing both, that is one regeneration each, and whichever lands second will need it again.
Two non-blocking observations
1. daxIqSampleRates is not recorded in docs/architecture/radio-capabilities-map.md. See the inline comment — the header's own ADDING A FIELD contract asks for it, and the sibling hasDaxStreams is recorded there at line 239.
2. The TCI fix is pinned, but not by the TCI test. When I reverted the blocker-1 fix, tci_server_review_test passed unchanged — the catch came from dax_iq_rate_limits_test ("TCI defers pre-identification rates without commands, then clamps and restores intent"). Coverage exists and works, so this is not a gap. It is only that the assertion guarding TciServer.cpp lives in the DAX target, so a future TCI-only change could move that line with its own test still green. Entirely your call whether that is worth a mirrored assertion.
What I tried to break
- Reverted the blocker-1 fix (removed
modelRates.isEmpty() ||, restoring "unknown model refuses every rate"): caught, 1 failure. The skimmer-stranded-at-48k regression is genuinely pinned, not just described. - Reverted the blocker-2 fix (raw
setStyleSheetback at the On/Off pill): caught, 1 failure — "Off to On updates the rendered background after availability refresh". - Clean baseline after restoring both:
dax_iq_rate_limits_test51 assertions / 0 failures;model_capabilities_testall OK; both exit 0 with real stdout. - Traced the zero-rate path.
sampleRateForPreference()returns 0 for an unknown model, so I checked every consumer rather than assuming:ensureIqStream()bails onrate == 0,achievedIqSampleRate()falls back tom_iqSampleRate,announceEffectiveIqSampleRate()returns early. Every known model includes 48000, so the 0 is reachable only in the unknown case, which is handled throughout. - Re-walked the skimmer scenario end to end.
capabilitiesChangedfiresannounceEffectiveIqSampleRate()andreconcileIqStreams()oncemodel=lands, so a preference retained before identification is reconciled rather than stranded. The earlier failure path is closed. - Preflight cleared. The socket path is behind an explicit
--bridge-proofargument and a non-emptyAETHER_MCP_TOKEN, andadd_testpasses no arguments, so the default CTest run is socket-free. I did not exercise the bridge-proof mode. - Not verified: the live profile-load path (source-reviewed per your own note, not exercised through a real load), and the bridge screenshots, which are on a fork branch outside this diff.
Recommendation: rebase to clear the rules.json conflict, and consider folding the map-doc row into the same push. Good piece of work — the mutation evidence in your test plan held up when I re-ran it independently.
73, Nigel G0JKN
a630422 to
81d56bc
Compare
jensenpat
left a comment
There was a problem hiding this comment.
Reviewed head 81d56bc after the requested rebase onto main 5b43981. No remaining blocking findings.
- Added the missing
daxIqSampleRatescapability-map contract in 81d56bc (docs/architecture/radio-capabilities-map.md:265); replied to and resolved both review threads with evidence. - Regenerated the conflicted Log Analyzer references.
git range-diffconfirms the source fixes survived the rebase unchanged. - Independently matched all 19 model-rate rows and unknown-model behavior against the local official FlexLib reference. Reviewed request validation, pending TCI negotiation, supported-rate reapplication, model replacement, restore timer cancellation, radio-confirmed stream state, and ThemeManager ownership.
- Native macOS build passed for
model_capabilities_test,dax_iq_rate_limits_test,control_availability_registry_test, andtci_server_review_test. Three selected socket-free CTests passed (0 failures, 12.74 s). The DAX target exercises the early TCI and On/Off styling regressions. Reused the author's and Windows reviewer's mutation/bridge evidence; did not claim those sessions were rerun here. - Base/head engine-boundary, manifest, capability-record and command-plane gates passed with no growth; test registration, frozen CI gate, QSettings, accessibility, color, generated reference and 707 Log Analyzer checks passed.
Limits: the broader TCI target was not run because its current suite includes socket tests. No fresh full desktop application build, optional bridge socket run, live profile load, radio or RF/TX testing in this pass. The MainWindow recovery order was source-reviewed. Automated code-review tooling was unavailable; this was a manual review. Hosted CI and Docs are still running and must pass before landing; documentation was not rebuilt locally.
Apply the published FlexLib model table to DAX IQ requests, restores, and TCI subscriptions. Preserve valid 192 kHz preferences on capable models and explain unsupported choices in the applet. Fixes aethersdr#6486 Refs aethersdr#6437 Generated with OpenAI Codex.
Seed confirmed stream state and clear meter levels when the applet binds or replaces its radio model. Track accepted pending sample-rate intent through refreshes and announce confirmed streams as engaged. Resolve remembered rates before profile recovery creates streams, so a saved 192k preference restores at 96k on limited models without a delayed request targeting a later session. Regression checks failed before the fixes and now pass. Three additional mutations were caught; the native ARM64 application with RADE, four focused CTest targets, isolated TX-disabled bridge proof, docs build and static checks pass. Live radio/profile-load validation remains unperformed. Refs aethersdr#6486, aethersdr#6437.
Remember whitelisted TCI rates negotiated before model identification, without issuing IQ commands until capabilities are known. Announce the effective rate when available and retain the preference across models. Apply IQ button state sheets through ThemeManager so availability updates cannot restore a stale On/Off template. Compose accessible rate reasons before registry state text and calculate shared reasons once per radio. Regressions first failed for early negotiation and Off-to-On styling, then passed after these fixes. Strengthen rejection and accessibility coverage, wait for restore signals with more timer slack, isolate optional captures, and require an auth token before starting the opt-in bridge fixture. Native ARM64+RADE application build, four focused tests, docs/static checks, missing-token refusal and the TX-disabled bridge proof passed. Live radio validation remains unperformed. Refs aethersdr#6486, aethersdr#6437.
81d56bc to
4b815a6
Compare
jensenpat
left a comment
There was a problem hiding this comment.
Approved exact head 4b815a6 after resolving the new generated-reference conflict against main 1dd498a.
The earlier review remains applicable: git range-diff confirms the DAX source fixes and capability-map entry are unchanged; only generated Log Analyzer line references changed during rebase. All four commits are verified. Both review threads remain answered and resolved.
Fresh proof on this head: incremental native macOS build passed for model_capabilities_test, dax_iq_rate_limits_test, control_availability_registry_test, and tci_server_review_test; three selected socket-free CTests passed, 0 failures, 11.48 s. The broader TCI target was built but not executed because its current suite includes socket tests. Base/head boundary, touchpoint, capability-record and command-plane checks match without growth; generated references and all 707 Log Analyzer checks pass. All hosted checks passed on the preceding rebased head; they must pass again on this new head before landing.
No new correctness findings. Existing author/Windows mutation and bridge evidence is reused, not claimed as a fresh session. No optional bridge socket session, live radio, RF/TX or live profile-load test was performed. A separate automated review tool was unavailable; review was manual. Hosted Docs handles the site build.
Summary
Fixes #6486. Implements the per-model DAX IQ rate-table item from #6437; the parent checklist remains open.
FLEX-6400M offered 192 kHz despite supporting only 24/48/96 kHz, and the request model accepted arbitrary rates. The existing model table and Flex capability payload now publish supported rates. Production requests, stream recreation, profile recovery, and TCI consumers enforce those limits.
Unsupported choices remain visible and disabled with accessible reasons. A saved 192 kHz preference uses 96 kHz on a limited model without overwriting the preference, then returns to 192 kHz on a capable model. Reconnect/model replacement cancel stale restore timers and seed confirmed stream state. TCI retains valid negotiation received before model identification, issues no IQ commands while unidentified, and reconciles the preference when capabilities arrive. Known-model unsupported SETs preserve the current preference. ThemeManager tracks the On/Off stylesheet through availability transitions.
The rate table follows the official published FlexLib v4.2.20 source,
FlexLib/ModelInfo.cs. Unknown models have an empty rate list. The existing case-insensitive and suffixed-model matching contract is preserved.docs/architecture/radio-capabilities-map.mdrecords the capability and its consumers; user DAX IQ and TCI pages describe the behavior.Principle I — FlexLib is the protocol authority. Radio-reported stream state remains authoritative.
Validation
1dd498a76; the generated Log Analyzer references were regenerated to resolve the conflict. The source fixes were preserved unchanged.model_capabilities_test,dax_iq_rate_limits_test,control_availability_registry_test, andtci_server_review_test. Three socket-free CTests passed. The broader TCI target was built but not executed because its current suite includes socket tests.4b815a608. No local docs-site or PDF rebuild in the landing pass.The default DAX regression test is socket-free. Opt-in
dax_iq_rate_limits_test --bridge-proofbinds the production automation bridge's owner-only local socket, requires a non-empty token, and pins TX off. It was not executed in the landing pass.Existing bridge artifacts: request/response evidence, limited-model applet, disabled 192k choice, capable-model applet. Assets reside on a separate fork branch.
Live radio, RF/TX, and live profile-load behavior remain unverified. The MainWindow profile recovery order was source-reviewed; the production model sequence is exercised by the DAX regression. A separate automated code-review tool was unavailable; the landing review was manual.
Generated with OpenAI Codex (GPT-6 Astra)