Skip to content

feat(radio): radio-declared band capability via optional bands= discovery/status key#4027

Open
nigelfenton wants to merge 4 commits into
aethersdr:mainfrom
nigelfenton:feat/radio-declared-bands
Open

feat(radio): radio-declared band capability via optional bands= discovery/status key#4027
nigelfenton wants to merge 4 commits into
aethersdr:mainfrom
nigelfenton:feat/radio-declared-bands

Conversation

@nigelfenton

Copy link
Copy Markdown
Contributor

Summary

A radio may now declare the band set it can actually tune with an optional discovery/status key:

bands=2m,440,23cm

Names come from the existing BandDefs vocabulary; they are validated, deduplicated and case-folded on parse, so a malformed declaration cannot inject junk into the band UI. When the key is present, the band selector is built from the declaration (in BandDefs order) instead of the HF layout + ModelCapabilities flags. When it is absent — every real Flex radio — parsing yields an empty list and the band UI is unchanged, bit for bit. The key is accepted on both the discovery packet and the radio status line (so a direct-IP connect with no discovery packet can still declare), and a mid-session change propagates through the existing infoChanged refresh.

Why

Gateways and emulators present non-Flex hardware to AetherSDR as a Flex model, and the model string then drives a band menu the hardware cannot honour. Two live examples from my bench, bridged through Aether-gate — a universal radio→Flex bridge I have built on top of my public flex-sim core (the Aether-gate repository goes public alongside this feature):

  • an Icom IC-9700 (2 m / 70 cm / 23 cm) must present as a FLEX-6700 to reach 2 m at all — and then gets a full HF grid it cannot tune;
  • a Kenwood TS-450S (HF only) presents as a FLEX-6600 and is offered 6 m / 2 m / XVTR entries it has no hardware for.

With bands=, each declares its truth. Same IC-9700, stock vs this branch:

Before — stock, offered the full HF grid it cannot tune:

After — exactly 2m / 440 / 23cm:

Scope / non-goals

  • Narrows the band selector only. Slice capacity and other capabilities still derive from the model string — ModelCapabilities remains the single source of model truth.
  • Utility rows (WWV / GEN / 2200 / 630) and the user-configured XVTR row are deliberately untouched — application features, not hardware band claims.
  • Unknown band names are dropped, not invented: the vocabulary is exactly BandDefs.
  • Self-contained and engine-agnostic — the declared-band set is model-layer state on RadioModel, and the only gui/ touch is the menu build.

Constitution principle honored

  • Principle VII — Untrusted Input Is Validated At The Boundary. bands= is untrusted wire input (discovery / status). parseDeclaredBands validates every name against BandDefs at the parse boundary, deduplicates, and drops anything unrecognised, so the band UI can never be driven by a malformed or hostile declaration. Absent / empty ⇒ the real-Flex path, unchanged.
  • Principle I — FlexLib Authority. The declaration narrows the band selector only; slice capacity and model capabilities still derive from ModelCapabilities / the model string. No FlexLib-sourced model truth is overridden.
  • Principle IV — Clean-Room. This AetherSDR-side code is written against AetherSDR's own headers; nothing is decompiled, disassembled, or reverse-engineered.

Test plan

  • Local build passes (cmake --build build) — Windows 11 (MSVC / Ninja) and Ubuntu 24.04 (GCC / Ninja)
  • Behavior verified on a real radio — live IC-9700 and TS-450S bridges; band-menu narrowing and tuning confirmed in AE (screenshots above)
  • Existing tests pass — radio_discovery_test (with 3 new checks for the key), model_capabilities_test (212), xvtr_policy_test (38), all green on both platforms
  • N/A — not a user-reported bug

Checklist

  • Commits are signed (docs/COMMIT-SIGNING.md) — ED25519, verified
  • No new flat-key AppSettings calls (Principle V) — N/A: no settings surface in this change
  • Code is clean-room (Principle IV)
  • All meter UI uses MeterSmootherN/A: no meter UI in this change
  • Documentation updated if user-visible behavior changed — the bands= key is documented in the discovery-field handling and the commit messages; happy to add a dedicated docs note if reviewers prefer
  • Security-sensitive changes reference a GHSA if applicable — N/A

🤖 Generated with Claude Code

A radio (or gateway presenting non-Flex hardware) can now declare its true
band set with an optional discovery/status key, e.g. bands=2m,440,23cm
(names from BandDefs). When present, the band menu is built from the
declaration instead of the HF layout + ModelCapabilities flags — so an
Icom IC-9700 bridged in as a FLEX-6700 offers 2m/440/23cm rather than an
HF grid it cannot tune. Names are validated against BandDefs and
deduplicated; unknown names are dropped.

Real Flex radios never send the key: parsing yields an empty list and the
band UI is unchanged, bit for bit. Accepted on both the discovery packet
and the radio status line (covers direct-IP connects); mid-session
declaration changes propagate through the existing infoChanged refresh.
resolveBandStackKey knows native hardware bands and configured XVTRs but
not radio-declared bands, so selecting a declared 440/23cm was refused
with the needs-a-transverter message. When the band is in the radio's
declaration, pass the declared name through as the stack key - the
declaring radio defines and honours these keys. Flex radios never
declare, so their refusal path is unchanged.
Covers the wire contract both ways: a packet without the key yields an
empty declaration (real Flex radios — band UI untouched), and a gateway
packet's bands=2m,440,23cm is captured verbatim without disturbing the
neighbouring fields. Validation/dedup against BandDefs is RadioModel's
job and out of scope here.
@nigelfenton nigelfenton requested review from a team as code owners July 4, 2026 16:43

@aethersdr-agent aethersdr-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work, @nigelfenton — this is a well-scoped, defensively-written feature. I traced the full path (discovery/status parse → RadioModel::m_declaredBandsrefreshXvtr/wirePanadapter push → SpectrumOverlayMenu::setDeclaredBands → grid build → bandSelected → stack-key resolution) and it holds together. A few things I specifically verified as correct:

  • Additive / no-op for real Flex. parseDeclaredBands("") yields an empty list, and the else branch in setXvtrBands is the pre-existing HF-layout + 4m/2m-capability code moved verbatim under the guard. Real radios get a bit-for-bit-identical menu. ✅
  • Mid-session propagation. handleRadioStatus sets changed = true, which reaches if (changed) emit infoChanged(), and refreshXvtr (connected to infoChanged) now re-pushes declaredBands(). So a status-line bands= change does flow through. ✅
  • No leak in the rebuild. Declared buttons are parented to m_bandPanel, which is fully deleteLater()'d and rebuilt on each setXvtrBands. ✅
  • Boundary validation (Principle VII). Names are validated against kBands, deduped, and case-folded; unknowns dropped. The grid can't be driven by junk. ✅

Two things worth a look, neither a blocker:

1. The validation logic itself has no unit test. The three new checks in radio_discovery_test.cpp only assert that RadioDiscovery captures the raw bands= string verbatim — they never exercise parseDeclaredBands, which is where the actual Principle-VII guarantee lives. I'd add a small test (in the RadioModel/BandDefs test target) covering: unknown name dropped (bands=2m,junk,440[2m,440]), dedup (440,440[440]), and case-fold (2M,23CM[2m,23cm]). That locks in the security property the PR is built around.

2. Declared bands that overlap native capability don't send the declared token. The new stackKey = bandName fallback only fires when resolveBandStackKey returns empty. For a gateway impersonating a FLEX-6700 (has2Meters/has4Meters true), declaring bands=2m,440,23cm means clicking 2m resolves natively to bare key 2 (normalizedNativeBandKey strips the m), so the command is band=2 — while 440 and 23cm fall through to band=440 / band=23cm. That mix is arguably correct-by-impersonation (a 6700's native 2m band-stack slot is legitimate), but it does mean Aether-gate must honour both the bare Flex keys (2/4/6) and the declared tokens depending on the band. Worth confirming that's what the bridge expects — a one-line comment on the ordering would save the next reader the same trace.

Minor: if firmware ever adopts a bands= status key for another purpose it'd land here, but the BandDefs allow-list makes the failure mode "drop everything unrecognised," so it degrades safely. Fine as-is.

Overall this is a clean, engine-agnostic change that keeps model truth in ModelCapabilities and only narrows the selector. Thanks for the thorough PR description and the before/after framing.


🤖 aethersdr-agent · cost: $8.7183 · model: claude-opus-4-8

@aethersdr-agent aethersdr-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, well-scoped change — thanks @nigelfenton. I traced the whole path (discovery/status parse → RadioModel::declaredBands()setDeclaredBands → grid rebuild → band-stack key) and it holds together. A few notes below, nothing blocking.

What checks out

  • Safety invariant is real. info.bands is only ever populated from the bands= key, which real Flex radios never send, so parseDeclaredBands("") yields an empty list and both the grid build and the stackKey = bandName passthrough are inert on real hardware. The "unchanged, bit for bit" claim is accurate — the guard is declaredBands().contains(bandName), which is always false when the list is empty.
  • Compiles cleanly. parseDeclaredBands and the SpectrumOverlayMenu grid loop both sit inside namespace AetherSDR, so the unqualified kBands references resolve; 440/23cm/2m are all present in kBands. All 6 CI jobs (build, check-macos, check-windows, CodeQL, accessibility) are green.
  • Mid-session propagation works. The status-path bands change sets changed = true → emit infoChanged() (RadioModel.cpp:5465), which is wired to refreshXvtr (MainWindow.cpp:5013), which now re-pushes setDeclaredBands. Good.
  • Grid layout math (grid->addWidget(btn, row, col % 3) then if (++col % 3 == 0) ++row, with the trailing if (col % 3) ++row) places buttons correctly and leaves row pointing at the next free row for the XVTR/utility blocks. Utility + XVTR rows render in both branches, matching the stated scope.

Suggestions

  1. The Principle VII validation is the value of this PR, but it has no direct test. The new tests only assert that discovery captures bands=2m,440,23cm verbatim — they exercise the transport, not parseDeclaredBands, where the actual validation/dedup/case-fold lives. I'd add a test that drives a hostile/messy declaration through RadioModel (e.g. connectToRadio or handleRadioStatus with bands=2M,GARBAGE,2m,,440,70cm) and asserts declaredBands() == {"2m","440"} — proving unknown names drop, dupes collapse, and 2M2m canonicalizes. That's the claim reviewers most want backed by CI, and it's reachable through the public declaredBands() getter without exposing the anonymous-namespace helper.

  2. Design note (not a defect): declared VHF/UHF names go out as raw band-stack keys. For a band with no native/XVTR mapping, the fallback sends display pan set <pan> band=440 / band=23cm — non-standard keys that only the declaring gateway understands. That's the intended contract, but there's no signal if the gateway doesn't honor it (silent no-op on the far side rather than the visible refusal a real Flex would give). Worth a sentence in the code comment or docs making explicit that the declaring radio owns these keys end-to-end.

  3. Minor: overlap when the impersonated model natively has a declared band. A FLEX-6700 gateway declaring 2m will resolve 2m → "2" via resolveBandStackKey before the declared-band passthrough fires, so it sends band=2 (native key), not band=2m. Correct behavior, but it means the gateway must also handle native VHF keys — fine to leave as-is, just flagging the asymmetry.

Solid contribution — the fallback-to-model-string design keeps this genuinely additive.


🤖 aethersdr-agent · cost: $7.3480 · model: claude-opus-4-8

…dering

Addresses the two non-blocking review notes on the bands= PR:

1. The Principle-VII validation had no direct test — the discovery tests only
   checked that RadioModel captures the raw bands= string. Extracted
   parseDeclaredBands into DeclaredBands.{h,cpp} (a light, dependency-free TU:
   Qt + BandDefs.h only) and added declared_bands_test covering the security
   property directly: unknown names dropped (2m,junk,440 -> [2m,440]), dedup
   (440,440 -> [440]), case-fold (2M,23CM -> [2m,23cm]), whitespace, and the
   empty/absent -> empty-list real-Flex path. 9/9 pass. Behaviour unchanged;
   RadioModel.cpp now includes+calls the same function.

2. Added a comment at the declared-band stack-key fallback noting that
   resolveBandStackKey() runs first, so a declared band that also matches the
   impersonated model's native capability resolves natively (bare key, e.g.
   2m -> '2' on a FLEX-6700 gateway) while model-less declared bands (440/23cm)
   pass the declared token — so a bridge honours both per band.

All tests pass; Windows build clean.
@nigelfenton

Copy link
Copy Markdown
Contributor Author

Thanks for the careful trace — both notes addressed in c3ece31:

  1. Unit test for the validation. Extracted parseDeclaredBands into DeclaredBands.{h,cpp} (a light TU — Qt + BandDefs.h only) and added declared_bands_test exercising the Principle-VII guarantee directly: unknown dropped (2m,junk,440[2m,440]), dedup (440,440[440]), case-fold (2M,23CM[2m,23cm]), whitespace, and the empty → empty-list real-Flex path. 9/9 pass; behaviour unchanged.

  2. Band-stack key ordering. Added a comment at the fallback: since resolveBandStackKey() runs first, a declared band that also matches native capability resolves natively (e.g. 2m → 2 on a FLEX-6700 gateway) while model-less declared bands (440/23cm) pass the declared token — so yes, a declaring bridge honours both the bare Flex keys and its declared tokens per band. That is exactly what Aether-gate expects.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant