diff --git a/docs/architecture/aetherd-touchpoints.md b/docs/architecture/aetherd-touchpoints.md index 3bca9f927..d0e09c601 100644 --- a/docs/architecture/aetherd-touchpoints.md +++ b/docs/architecture/aetherd-touchpoints.md @@ -167,7 +167,7 @@ Burndown manifest for the engine/UI decoupling ([RFC](../aetherd-headless-engine | `core/aprs/AprsStationList.h` | 1 | universal — Heard-APRS-station model (calls/positions/last-heard); radio-agnostic spot-like data. | unconverted | | `core/backends/ConnectionSharingPolicy.h` | 2 | universal — Fail-closed discovery-time policy for whether a busy radio family permits another client. Canonical cross-family connection safety pending capability descriptors. | unconverted | | `core/backends/IRadioBackend.h` | 1 | universal — THE radio seam (RFC §5.5) — the canonical intent verbs, typed deltas and normalized signals every family implements. Universal by definition: the UI reaching this header is the seam working as designed, not coupling. Everything below it in core/backends// is family-private. | unconverted | -| `core/backends/RadioCapabilities.h` | 2 | universal — Backend-neutral capability descriptor consumed above the radio seam. Universal by definition; family implementations populate it. | unconverted | +| `core/backends/RadioCapabilities.h` | 5 | universal — Backend-neutral capability descriptor consumed above the radio seam. Universal by definition; family implementations populate it. | unconverted | | `core/backends/anan/AnanDiscovery.h` | 2 | vendor(anan) — openHPSDR Protocol 2 discovery and ANAN-G2 identity handling. Family-specific discovery belongs below the ANAN backend seam. | unconverted | | `core/backends/anan/AnanSettings.h` | 1 | ui-support — Client-side connection and ADC configuration for the ANAN backend. Persisted setup plumbing, not live radio state. | unconverted | | `core/backends/anan/P2Protocol.h` | 1 | vendor(anan) — openHPSDR Ethernet Protocol 2 wire primitives for the ANAN-G2 backend. Family protocol code belongs below the radio seam. | unconverted | diff --git a/docs/architecture/radio-capabilities-map.md b/docs/architecture/radio-capabilities-map.md index 00923203f..278ac0d6b 100644 --- a/docs/architecture/radio-capabilities-map.md +++ b/docs/architecture/radio-capabilities-map.md @@ -47,6 +47,7 @@ traps and why the DAX crash guard is deliberately *not* the DAX capability. | `hasSelectableMicInputs` | ✅ | ❌ | ❌ | `MainWindow::applyCapabilitiesToUi` → `PhoneCwApplet::setSelectableMicInputs` | The MIC/BAL/LINE/ACC/PC list. False collapses it to PC and adopts that into TransmitModel. Icom: ❌ (the radio picks its own input) | | `hasDownwardExpander` | ✅ | ❌ | ❌ | `MainWindow::applyCapabilitiesToUi` → `PhoneApplet::setDexpVisible`; `AutomationServer` transmit snapshot | The radio has an authoritative DEXP/downward-expander command and read-back path. False hides the complete row and omits `dexp`/`dexpLevel` from automation state. Icom: ❌ until a model profile evidences and implements that full path; the IC-9700 does not borrow Flex's compander surface | | `rxFilterWidthsHz` | empty | empty | empty | `MainWindow::applyCapabilitiesToUi` → `RxApplet::setRadioFilterWidths` **and** `VfoWidget::setRadioFilterWidths` | The RX filter widths a radio can actually reach, **narrowest first**. **Empty = continuous or unknown**, and the operator's configurable list stays in force. Icom publishes the selected slot's actual 1A 03 width plus factory defaults for the two unselected slots that CI-V cannot read, and republishes on mode, slot, or width changes. Both filter surfaces read it — the VFO grid did not, which is how the two disagreed about what the radio could do | +| `rxFilterControl` | empty | empty | empty | `MainWindow::applyCapabilitiesToUi` → `RxApplet::setRadioFilterControl` **and** `VfoWidget::setRadioFilterControl` | Stable radio-owned preset identity plus the mode's continuous width limits. Icom publishes FIL1/FIL2/FIL3 in radio order; changing the selected slot's 1A 03 width updates its tooltip/passband content without renaming or reordering the button. An empty preset list preserves the legacy width-only behavior for every other family | | `hasTxFilterControls` | ✅ | ✅ | ❌ | `MainWindow::applyCapabilitiesToUi` → `PhoneApplet::setTxFilterControlsAvailable` | Independent TX low/high cutoff controls. Icom: true only for model profiles with a verified low/high edge register; false hides the complete row (including IC-9700, whose documented SSB TX bandwidth is WIDE/MID/NAR rather than independent cutoffs) | | `txFilterLowEdgesHz` / `txFilterHighEdgesHz` | empty | empty | empty | `MainWindow::applyCapabilitiesToUi` → `PhoneApplet::setTxFilterEdges` | The discrete TX passband edges a radio can actually reach, ascending. **Empty = continuous or unknown**. Icom publishes per-model tables only where the model's own CI-V guide defines the WIDE/MID/NAR/SSB-D settings; the Phone applet steps through those values and rejects an exact typed value outside the list | | `canReboot` | ✅ | ❌ | ❌ | `RadioSetupDialog` | Shows the Reboot row and enables its button only while connected. Icom: ❌ because power-off over Wi-Fi is a one-way trip, not a remote reboot. | diff --git a/docs/automation-bridge.md b/docs/automation-bridge.md index aa5afed92..6ad39d786 100644 --- a/docs/automation-bridge.md +++ b/docs/automation-bridge.md @@ -724,7 +724,7 @@ connects). | `equalizer` (or `eq`) | — | 8-band RX+TX graphic EQ: `rxEnabled`/`txEnabled` and `rx`/`tx` band maps keyed by label (`63`…`8k`). Validate EQ-applet slider changes. | | `meters` | — | `{all:[…]}` — every radio meter with `name`, `value`, `unit`, `low`/`high`, `description`, and **`age_ms`** (staleness): a meter that updates has small `age_ms` and a tracking `value`. | | `slices` | — | array of all slice snapshots | -| `slice` | `active` (default) / `tx` / `` | one slice (sliceId, letter, frequency, mode, filterLow/High, rxAntenna, nb/nr/anf + levels, **squelch/squelchLevel, agcMode/agcThreshold, apf/apfLevel**, **adaptiveFilterEnabled/adaptiveMinLowCut/adaptiveMaxHighCut/adaptiveMinSnr/adaptiveResponse/adaptiveSplatter/adaptiveActive** (SSB adaptive RX filter — `adaptiveActive` is the live AUTO-fit state), **linkedTo** (Slice Link peer id, `-1` when unlinked), txSlice, …) | +| `slice` | `active` (default) / `tx` / `` | one slice (sliceId, letter, frequency, mode, filterLow/High, **filterPresetId/filterPreset** for a radio-owned FIL slot, rxAntenna, nb/nr/anf + levels, **squelch/squelchLevel, agcMode/agcThreshold, apf/apfLevel**, **adaptiveFilterEnabled/adaptiveMinLowCut/adaptiveMaxHighCut/adaptiveMinSnr/adaptiveResponse/adaptiveSplatter/adaptiveActive** (SSB adaptive RX filter — `adaptiveActive` is the live AUTO-fit state), **linkedTo** (Slice Link peer id, `-1` when unlinked), txSlice, …) | | `hostnb` | — (optional property) | HOST-SIDE noise blanker, read from the DSP: `{receivers:[{ddc,panId,on,level,threshold,requestedOn,requestedLevel,hasChain}]}`. **Distinct from `get slice nb`** — that reports the slice model, which is set the instant the button is clicked and stays true even if the intent never reached the DSP. `on`/`level` here are what the WDSP stage actually has; `requestedOn`/`requestedLevel` are what the backend was asked for, reported alongside so the two can be COMPARED. Errors on a radio that does not declare `hasHostNoiseBlanker` rather than returning an empty success. | | `clock` | — | AetherClock snapshot: `state`/`stateName` (NoSignal/Acquiring/Locked), `station`/`stationName` (WWV/WWVH/WWVB), `decodedUtc` (ISO-8601, empty until a decode), `offsetMs` (decoded − host at the second edge; positive = host behind broadcast), `lockQuality` (0–100), `sliceId` (bound slice, −1 when stopped), `gpsTimeAvailable`. Validate applet Start/Tune/station-switch actions and lock progress without pixels. | | `pans` | — | array of all panadapter snapshots | @@ -1420,6 +1420,7 @@ re-poll `get slices`. | `tx` | `` | make a slice the TX slice — the external-split transition; radio enforces single-TX | | `mode` | `` e.g. `DSTR` | set the active slice mode through `SliceModel`; validated against the radio-advertised mode list | | `filter` | ` ` e.g. `-3000 -150` | set the active slice passband through `SliceModel::setFilterWidth`, the operator-intent setter — so the edges reach `IRadioBackend::setSliceFilter` and not just the model. Necessary because a mode change mirrors the passband *inside* the model without emitting that intent, which can leave a backend that owns its own DSP chain running the pre-mirror passband while `get_state` reports the mirrored one. Assert the passband before measuring anything through the audio path. Returns both the requested edges and the post-normalization `filterLow`/`filterHigh` the model actually holds. Use `-4000 4000` for a carrier-straddling AM passband | +| `filterpreset` | `` | select a stable radio-owned RX filter slot without conflating it with a passband-width edit. Returns the requested slot; re-poll `get slice active filterPreset` and the filter edges for radio-authoritative readback | | `agc` | ` [threshold 0..100]` | set the active slice's receive AGC through `SliceModel`'s operator setters, so it emits `agcCommandIssued` and reaches `IRadioBackend::setSliceAgc`. Applies the threshold before the mode so a combined request arrives at the backend as one coherent pair. On a backend that owns its DSP chain (HL2) this maps to the WDSP RXA AGC mode and the AGC ceiling in dB; on Flex it is the firmware's own AGC. Use `off` with a low threshold to get a linear path for measurement | | `dsp` | ` [level]` | drive the receive DSP controls an operator drives — noise blanker, noise reduction, auto-notch, and squelch (with an optional 0..100 level). `slice dsp squelch` is the squelch path; there is deliberately no separate squelch verb (#5102) | | `tone` | ` [freq]` | set the FM CTCSS encode mode and tone. The value is applied before the mode, so enabling CTCSS never keys on the previous tone for a round trip. The mode pair is what a FlexRadio slice carries | diff --git a/src/core/AutomationServer.cpp b/src/core/AutomationServer.cpp index e2e8e2340..78d5e540c 100644 --- a/src/core/AutomationServer.cpp +++ b/src/core/AutomationServer.cpp @@ -1250,7 +1250,7 @@ QImage grabWidget(QWidget* w) QString sliceActionList() { return QStringLiteral( - "add|remove|select|tx|mode|filter|agc|dsp|tone|offset|diversity|" + "add|remove|select|tx|mode|filter|filterpreset|agc|dsp|tone|offset|diversity|" "centerlock|link|txant|rxant|rxsource|fixture|clearfixture"); } @@ -1673,8 +1673,16 @@ QWidget* primaryTopLevelWindow() // linkedTo: peer slice id when this slice is a Slice Link member, else -1 // (supplied by the GUI's peer query — the link is client-side state). -QJsonObject sliceSnapshot(const SliceModel* s, int linkedTo) +QJsonObject sliceSnapshot(const SliceModel* s, int linkedTo, + const RxFilterControl& filterControl) { + QString filterPreset; + for (const RxFilterPreset& preset : filterControl.presets) { + if (preset.id == filterControl.selectedPresetId) { + filterPreset = preset.label; + break; + } + } return QJsonObject{ {QStringLiteral("sliceId"), s->sliceId()}, {QStringLiteral("letter"), s->letter()}, @@ -1683,6 +1691,8 @@ QJsonObject sliceSnapshot(const SliceModel* s, int linkedTo) {QStringLiteral("mode"), s->mode()}, {QStringLiteral("filterLow"), s->filterLow()}, {QStringLiteral("filterHigh"), s->filterHigh()}, + {QStringLiteral("filterPresetId"), filterControl.selectedPresetId}, + {QStringLiteral("filterPreset"), filterPreset}, {QStringLiteral("active"), s->isActive()}, {QStringLiteral("txSlice"), s->isTxSlice()}, {QStringLiteral("rxAntenna"), s->rxAntenna()}, @@ -5706,7 +5716,7 @@ QJsonObject AutomationServer::doGet(const QString& model, const QString& selecto } else if (model == QLatin1String("slices")) { QJsonArray arr; for (const SliceModel* s : radio->slices()) - arr.append(sliceSnapshot(s, sliceLinkPeerOf(s))); + arr.append(sliceSnapshot(s, sliceLinkPeerOf(s), radio->radioFilterControl())); return QJsonObject{{QStringLiteral("ok"), true}, {QStringLiteral("slices"), arr}}; } else if (model == QLatin1String("pans")) { QJsonArray arr; @@ -5785,7 +5795,7 @@ QJsonObject AutomationServer::doGet(const QString& model, const QString& selecto } if (!s) return err(QStringLiteral("no slice for selector '") + selector + QStringLiteral("'")); - data = sliceSnapshot(s, sliceLinkPeerOf(s)); + data = sliceSnapshot(s, sliceLinkPeerOf(s), radio->radioFilterControl()); } else if (model == QLatin1String("pan")) { const PanadapterModel* p = nullptr; if (selector.isEmpty() || selector == QLatin1String("active")) @@ -7184,6 +7194,46 @@ QJsonObject AutomationServer::doSlice(const QString& action, const QString& arg) {QStringLiteral("filterLow"), s->filterLow()}, {QStringLiteral("filterHigh"), s->filterHigh()}}; } + if (action == QLatin1String("filterpreset")) { + QString requested = arg.trimmed().toUpper(); + if (requested.startsWith(QLatin1String("FIL"))) { + requested.remove(0, 3); + } + bool okPreset = false; + const int presetId = requested.toInt(&okPreset); + const RxFilterControl control = radio->radioFilterControl(); + const auto preset = std::find_if( + control.presets.cbegin(), control.presets.cend(), + [presetId](const RxFilterPreset& candidate) { + return candidate.id == presetId; + }); + if (!okPreset || preset == control.presets.cend()) { + return err(QStringLiteral( + "slice filterpreset requires a radio-advertised preset (e.g. FIL1)")); + } + + SliceModel* s = nullptr; + for (SliceModel* candidate : radio->slices()) { + if (candidate->isActive()) { + s = candidate; + break; + } + } + if (!s && !radio->slices().isEmpty()) { + s = radio->slices().first(); + } + if (!s) { + return err(QStringLiteral("no slice available to select a filter preset on")); + } + + radio->selectRadioFilterPreset(s->sliceId(), presetId); + return QJsonObject{{QStringLiteral("ok"), true}, + {QStringLiteral("slice"), QStringLiteral("filterpreset")}, + {QStringLiteral("id"), s->sliceId()}, + {QStringLiteral("presetId"), presetId}, + {QStringLiteral("preset"), preset->label}, + {QStringLiteral("requested"), true}}; + } if (action == QLatin1String("agc")) { // "slice agc [threshold 0..100]" — drive the RX AGC // through the same operator setters the RX applet uses, so the change diff --git a/src/core/backends/IRadioBackend.h b/src/core/backends/IRadioBackend.h index e49900c6f..f8d7f3a86 100644 --- a/src/core/backends/IRadioBackend.h +++ b/src/core/backends/IRadioBackend.h @@ -129,6 +129,16 @@ class IRadioBackend : public QObject { virtual void setSliceFrequency(int sliceId, double hz) = 0; virtual void setSliceMode(int sliceId, const QString& mode) = 0; virtual void setSliceFilter(int sliceId, int lowHz, int highHz) = 0; + // Select a stable radio-owned RX filter preset. The passband setter above + // remains exclusively a resize/reposition intent; keeping the two verbs + // distinct prevents a width that happens to equal a preset from changing + // slots. Empty RadioCapabilities::rxFilterControl.presets means callers + // never invoke this default no-op. + virtual void setSliceFilterPreset(int sliceId, int presetId) + { + Q_UNUSED(sliceId); + Q_UNUSED(presetId); + } // Receive AGC. mode is the neutral vocabulary the slice model uses — // "off" / "slow" / "med" / "fast"; thresholdDb is the operator's 0..100 // AGC-threshold value. A backend whose hardware owns the AGC translates diff --git a/src/core/backends/RadioCapabilities.h b/src/core/backends/RadioCapabilities.h index aefd1d7e0..5afa6d720 100644 --- a/src/core/backends/RadioCapabilities.h +++ b/src/core/backends/RadioCapabilities.h @@ -26,6 +26,37 @@ struct DeclaredBandRange { bool operator==(const DeclaredBandRange&) const = default; }; +// A stable, radio-owned receive-filter preset. `id` is the identity used on +// the wire (for example Icom FIL1/FIL2/FIL3); widthHz is mutable content of +// that preset and must never be used as its identity. +struct RxFilterPreset { + int id = 0; + QString label; + int widthHz = 0; + + bool operator==(const RxFilterPreset&) const = default; +}; + +struct RxFilterControl { + QList presets; + int selectedPresetId = 0; + int minimumWidthHz = 0; + int maximumWidthHz = 0; + int widthStepHz = 0; + + bool operator==(const RxFilterControl&) const = default; +}; + +// A capability update reaches the two legacy/new presentation setters one at +// a time. Treat the preset metadata as usable only when it describes every +// width in the current presentation list; this keeps a disconnect or mode +// transition from indexing stale FIL metadata against a newly rebuilt list. +[[nodiscard]] inline bool hasCompleteRxFilterPresets(const RxFilterControl& control, + qsizetype widthCount) +{ + return !control.presets.isEmpty() && control.presets.size() == widthCount; +} + enum class FmTonePresentation { Legacy, Hidden, @@ -487,6 +518,11 @@ struct RadioCapabilities { // control sweep over hardware that cannot follow it. QList rxFilterWidthsHz; + // Stable preset identity and continuous-width limits for radios where a + // preset selects a mutable hardware slot. Empty preserves the legacy + // width-only button contract above (Flex/HL2/ANAN/Sim). + RxFilterControl rxFilterControl; + // Whether the radio implements the independent TX low/high cutoff controls // presented by PhoneApplet. False hides the complete control row rather // than offering controls whose writes the backend cannot honour. diff --git a/src/core/backends/icom/CivCodec.cpp b/src/core/backends/icom/CivCodec.cpp index 91ca78a07..4f6578fb9 100644 --- a/src/core/backends/icom/CivCodec.cpp +++ b/src/core/backends/icom/CivCodec.cpp @@ -461,6 +461,30 @@ std::vector filterWidthsForMode(const std::string& mode) return {l.fil3, l.fil2, l.fil1}; } +std::vector filterPresetsForMode(const std::string& mode, + int selectedPresetId, + int selectedWidthHz) +{ + const FilterLadder ladder = ladderFor(mode); + std::vector presets{ + {1, ladder.fil1}, + {2, ladder.fil2}, + {3, ladder.fil3}, + }; + if (ladder.fil1 == ladder.fil2 && ladder.fil2 == ladder.fil3) { + presets.resize(1); + } + if (selectedWidthHz > 0) { + for (FilterPresetState& preset : presets) { + if (preset.id == selectedPresetId) { + preset.widthHz = selectedWidthHz; + break; + } + } + } + return presets; +} + std::pair passbandForModeAndFilter(const std::string& mode, int filter) { const FilterLadder l = ladderFor(mode); @@ -485,6 +509,43 @@ std::pair passbandForModeAndFilter(const std::string& mode, int filter : std::pair{low, high}; } +std::optional filterPresetRecallPlan( + std::uint8_t to, const std::string& ladderMode, CivMode wireMode, + bool dataMode, int presetId, bool useVfoMode) +{ + const std::vector presets = filterPresetsForMode(ladderMode); + if (presetId < 1 || presetId > static_cast(presets.size())) { + return std::nullopt; + } + + FilterPresetRecallPlan plan; + plan.commands.push_back(useVfoMode + ? cmdSetVfoMode(to, wireMode, dataMode, presetId) + : cmdSetMode(to, wireMode, presetId)); + + plan.widthHz = presets[static_cast(presetId - 1)].widthHz; + plan.pbtCode = kPbtCentreCode; + const PassbandEdges centred = passbandFromWidthAndPbt( + passbandCentreHz(ladderMode, plan.widthHz), plan.widthHz, + plan.pbtCode, plan.pbtCode); + plan.lowHz = centred.lowHz; + plan.highHz = centred.highHz; + + const std::optional widthCode = + filterWidthCodeFor(ladderMode, plan.widthHz); + if (!widthCode) { + // Fixed-width modes still select a FIL slot, but have no 1A 03 or + // Twin-PBT write. Zero keeps the backend's programmable-width cache + // invalid; the display edges above still describe the selected slot. + plan.widthHz = 0; + return plan; + } + plan.commands.push_back(cmdSetFilterWidth(to, *widthCode)); + plan.commands.push_back(cmdSetLevel(to, level::kPbtInner, plan.pbtCode)); + plan.commands.push_back(cmdSetLevel(to, level::kPbtOuter, plan.pbtCode)); + return plan; +} + // --------------------------------------------------------------------------- // IF filter width (1A 03), Twin PBT (14 07 / 14 08) // --------------------------------------------------------------------------- diff --git a/src/core/backends/icom/CivCodec.h b/src/core/backends/icom/CivCodec.h index 336c7db4c..21f6b908e 100644 --- a/src/core/backends/icom/CivCodec.h +++ b/src/core/backends/icom/CivCodec.h @@ -510,6 +510,18 @@ struct VfoModeState { // the same narrow-to-wide order as every other filter row in the app. [[nodiscard]] std::vector filterWidthsForMode(const std::string& mode); +struct FilterPresetState { + int id = 0; + int widthHz = 0; + + bool operator==(const FilterPresetState&) const = default; +}; + +// FIL1/FIL2/FIL3 in radio order. The selected slot may carry a custom width; +// replacing its content must never reorder or rename the slot. +[[nodiscard]] std::vector filterPresetsForMode( + const std::string& mode, int selectedPresetId = 0, int selectedWidthHz = 0); + // The passband that filter gives in that mode, in Hz relative to the carrier, // sign carrying the sideband (SliceModel's convention). The backend needs this // because an IC-705's IF filters cannot be read back as Hz — nothing else in @@ -517,6 +529,21 @@ struct VfoModeState { [[nodiscard]] std::pair passbandForModeAndFilter(const std::string& mode, int filter); +// Complete wire plan for recalling a FIL button. The select command comes +// first, followed (where the mode permits it) by the slot's factory width and +// centred Twin-PBT writes. Keeping this Qt- and transport-free lets the exact +// operator command sequence be mutation-tested without a fake radio socket. +struct FilterPresetRecallPlan { + std::vector> commands; + int widthHz = 0; + int lowHz = 0; + int highHz = 0; + int pbtCode = 128; +}; +[[nodiscard]] std::optional filterPresetRecallPlan( + std::uint8_t to, const std::string& ladderMode, CivMode wireMode, + bool dataMode, int presetId, bool useVfoMode); + // --------------------------------------------------------------------------- // IF filter WIDTH (1A 03) — the actual passband, not the slot that holds it // --------------------------------------------------------------------------- diff --git a/src/core/backends/icom/IcomCivBackend.cpp b/src/core/backends/icom/IcomCivBackend.cpp index a86185b8d..8155d093b 100644 --- a/src/core/backends/icom/IcomCivBackend.cpp +++ b/src/core/backends/icom/IcomCivBackend.cpp @@ -485,17 +485,36 @@ RadioCapabilities IcomCivBackend::capabilities() const // 1A 03 reports only the SELECTED slot's actual width. Replace that slot's // factory value once the reply is current, while retaining the documented // defaults for the two unselected slots that the protocol cannot expose. - if (m_model->hasScope || m_model->isKnown()) - { + if (m_model->hasScope || m_model->isKnown()) { // std::vector from the codec (which stays Qt-free) into the // QList the capability struct carries. - auto widths = filterWidthsForMode(currentLadderMode().toStdString()); + std::vector widths = filterWidthsForMode(currentLadderMode().toStdString()); if (widths.size() == 3 && passbandWidthIsCurrent() && m_ifWidthHz > 0 && m_filter >= 1 && m_filter <= 3) { widths[static_cast(3 - m_filter)] = m_ifWidthHz; std::sort(widths.begin(), widths.end()); } c.rxFilterWidthsHz = QList(widths.begin(), widths.end()); + + // FIL1/FIL2/FIL3 are identities, not widths. The selected slot's + // width is mutable through 1A 03, so publish the identity separately + // and keep it in radio order even when its content changes. Width-only + // consumers retain the legacy narrow-to-wide list above. + const FilterWidthLimits limits = + filterWidthLimitsFor(currentLadderMode().toStdString()); + c.rxFilterControl.minimumWidthHz = limits.minHz; + c.rxFilterControl.maximumWidthHz = limits.maxHz; + c.rxFilterControl.widthStepHz = limits.minHz == 200 ? 200 : 50; + c.rxFilterControl.selectedPresetId = m_filter; + const int selectedWidth = passbandWidthIsCurrent() ? m_ifWidthHz : 0; + const std::vector presets = filterPresetsForMode( + currentLadderMode().toStdString(), m_filter, selectedWidth); + for (const FilterPresetState& preset : presets) { + c.rxFilterControl.presets.append(RxFilterPreset{ + preset.id, + QStringLiteral("FIL%1").arg(preset.id), + preset.widthHz}); + } } // THE TRANSMIT PASSBAND IS A SHORT LIST, NOT A SLIDER. Published so the @@ -3978,69 +3997,11 @@ void IcomCivBackend::setSliceFilter(int, int lowHz, int highHz) const std::string ladder = currentLadderMode().toStdString(); const int width = std::abs(highHz - lowHz); - // ── Which control did the operator actually touch? ──────────────────── - // - // ONE SEAM VERB, TWO RADIO CONTROLS, and conflating them is how an Icom - // ends up with three filter buttons that all select the same width. - // - // * The FILTER BUTTONS emit one of the three widths this backend - // published as rxFilterWidthsHz. On the radio that is a SLOT change — - // FIL1/FIL2/FIL3 — and it must stay one, because the slots are the - // operator's own three presets and clicking through them must not - // redefine them. - // * DRAGGING A PASSBAND EDGE emits anything at all. On the radio that is - // a WIDTH change (1A 03) on the slot already selected, plus a PBT shift - // if the window also moved — exactly what turning the radio's own - // FILTER and PBT knobs does. - // - // An exact factory-ladder match is therefore read as a button press. A drag - // that lands on one is inherently ambiguous at this seam; preserve the - // operator's stored preset instead of silently redefining it. - const auto ladderWidths = filterWidthsForMode(ladder); - const bool isSlotPick = std::find(ladderWidths.begin(), ladderWidths.end(), width) - != ladderWidths.end(); const FilterWidthLimits limits = filterWidthLimitsFor(ladder); - // FM, DV and WFM have no settable width at all, so the slot IS the only - // filter control the radio offers there and every request has to be a slot - // pick. Sending 1A 03 in FM writes a width into whichever mode the radio - // last had one for. - if (isSlotPick || limits.maxHz <= 0) { - // MODE-AWARE. Snapping against the SSB thresholds whatever the mode put - // every AM width on FIL1 and every CW width on FIL3 — three buttons and - // one filter, in both directions. - const int filter = filterForWidthHz(ladder, width); - m_filter = filter; - // THE FILTER BUTTON MUST NOT DROP THE RADIO OUT OF DATA. Command 06 - // carries mode and slot with no DATA byte, and writing it is what - // clears DATA on the radio — so a filter change sent as 06 took an - // operator running FT8 in USB-D back to plain USB and their transmit - // audio back to the microphone, from a button that says nothing about - // the mode. 26 restates DATA with the new slot in the same frame. - // - // m_dataMode here is the RADIO's reported state, not a guess: it is - // read at connect, re-read after every front-panel mode change, and - // confirmed after every mode write, so this re-asserts what the radio - // said (Constitution II) rather than pushing a client belief over it. - if (profileFor(*m_model).supports(IcomFeature::VfoMode)) - sendUserCommand(cmdSetVfoMode(addr, m_mode, m_dataMode, filter)); - else - sendUserCommand(cmdSetMode(addr, m_mode, filter)); - - // THE NEW SLOT'S WIDTH IS A DIFFERENT NUMBER and only the radio knows - // it. Drop the width we hold for the old slot so the fallback ladder - // draws the window until 1A 03 answers, rather than leaving the - // previous slot's Hz on screen under a new slot's label. - m_ifWidthHz = 0; - // A write is intent; the radio's own reply is state. sendUserCommand - // schedules the mode readback itself (confirmationFor maps 26 -> read - // 26, 06 -> read 04), and that reply's handler asks for the width and - // PBT of whatever slot the radio actually landed on. - SliceDelta d; - const auto [low, high] = passbandForModeAndFilter(ladder, filter); - d.filterLow = low; - d.filterHigh = high; - emit sliceChanged(sliceId(), d); + // FM, DV and WFM have no settable width. Their FIL selection travels + // through setSliceFilterPreset(); a skirt edit has no radio command. + if (limits.maxHz <= 0) { return; } @@ -4096,6 +4057,42 @@ void IcomCivBackend::setSliceFilter(int, int lowHz, int highHz) emit sliceChanged(sliceId(), d); } +void IcomCivBackend::setSliceFilterPreset(int, int presetId) +{ + const std::string ladder = currentLadderMode().toStdString(); + const std::uint8_t addr = m_session ? m_session->civAddress() : 0xA4; + const std::optional plan = filterPresetRecallPlan( + addr, ladder, m_mode, m_dataMode, presetId, + profileFor(*m_model).supports(IcomFeature::VfoMode)); + if (!plan) { + return; + } + for (const std::vector& command : plan->commands) { + sendUserCommand(command); + } + + // A filter button is a PRESET RECALL, not merely a slot selector. This is + // also how the Flex buttons behave: after an operator drags the skirts, + // clicking the active button reapplies its stored passband. Icom remembers + // a mutable width and Twin-PBT position inside each FIL slot, so selecting + // FIL2 alone would immediately read the customised shape back and appear + // to do nothing. The recall plan therefore follows the slot-select command + // with the mode's factory 1A 03 width and centred 14 07/08 PBT writes. + // Radio readback remains authoritative and corrects any value it quantises. + m_filter = presetId; + m_ifWidthHz = plan->widthHz; + m_ifWidthMode = m_mode; + m_ifWidthData = m_dataMode; + m_ifWidthSlot = m_filter; + m_pbtInner = plan->pbtCode; + m_pbtOuter = plan->pbtCode; + publishCapabilities(); + SliceDelta d; + d.filterLow = plan->lowHz; + d.filterHigh = plan->highHz; + emit sliceChanged(sliceId(), d); +} + void IcomCivBackend::setTxFilter(int lowHz, int highHz) { // NO PROFILE, NO WRITE. The SET-menu item numbers that hold the transmit diff --git a/src/core/backends/icom/IcomCivBackend.h b/src/core/backends/icom/IcomCivBackend.h index b0d5f89b4..cc0bde056 100644 --- a/src/core/backends/icom/IcomCivBackend.h +++ b/src/core/backends/icom/IcomCivBackend.h @@ -78,6 +78,7 @@ class IcomCivBackend : public IRadioBackend { void setSliceFrequency(int sliceId, double hz) override; void setSliceMode(int sliceId, const QString& mode) override; void setSliceFilter(int sliceId, int lowHz, int highHz) override; + void setSliceFilterPreset(int sliceId, int presetId) override; void setTxFilter(int lowHz, int highHz) override; void setSliceAgc(int sliceId, const QString& mode, int thresholdDb) override; void setPanCenter(const QString& panId, double hz, diff --git a/src/gui/AppletPanel.cpp b/src/gui/AppletPanel.cpp index 0b25f8df0..85b9ae44b 100644 --- a/src/gui/AppletPanel.cpp +++ b/src/gui/AppletPanel.cpp @@ -1684,6 +1684,13 @@ void AppletPanel::setRadioFilterWidths(const QList& widthsHz) m_rxApplet->setRadioFilterWidths(widthsHz); } +void AppletPanel::setRadioFilterControl(const RxFilterControl& control) +{ + if (m_rxApplet) { + m_rxApplet->setRadioFilterControl(control); + } +} + void AppletPanel::setMicLevelMeterState(MicMeterSessionState session, bool available) { diff --git a/src/gui/AppletPanel.h b/src/gui/AppletPanel.h index 673b77900..346eca6d6 100644 --- a/src/gui/AppletPanel.h +++ b/src/gui/AppletPanel.h @@ -1,6 +1,7 @@ #pragma once #include "core/AudioEngine.h" +#include "core/backends/RadioCapabilities.h" #include #include @@ -223,6 +224,7 @@ class AppletPanel : public QWidget { void setSelectableMicInputs(bool selectable); void setMicLevelMeterState(MicMeterSessionState session, bool available); void setRadioFilterWidths(const QList& widthsHz); + void setRadioFilterControl(const RxFilterControl& control); // Show/hide the DAX and DAX-IQ buttons and applets based on whether the // connected radio produces per-slice audio / per-pan IQ streams diff --git a/src/gui/FilterPassbandMath.h b/src/gui/FilterPassbandMath.h new file mode 100644 index 000000000..206f3f694 --- /dev/null +++ b/src/gui/FilterPassbandMath.h @@ -0,0 +1,55 @@ +#pragma once + +#include + +namespace AetherSDR { + +enum class PassbandDragEdge { + Both, + Low, + High, +}; + +struct PassbandEdgePair { + int lowHz = 0; + int highHz = 0; + + bool operator==(const PassbandEdgePair&) const = default; +}; + +inline double passbandDragScaleHzPerPixel(int spanHz, int pixels) noexcept +{ + return static_cast(std::max(0, spanHz)) / std::max(1, pixels); +} + +inline PassbandEdgePair constrainPassbandWidth(int lowHz, int highHz, + int minimumWidthHz, + int maximumWidthHz, + PassbandDragEdge edge) noexcept +{ + const int minimum = std::max(1, minimumWidthHz); + const int maximum = std::max(minimum, maximumWidthHz); + const auto resize = [edge](int low, int high, int width) { + if (edge == PassbandDragEdge::Low) { + low = high - width; + } else if (edge == PassbandDragEdge::High) { + high = low + width; + } else { + const int centre = (low + high) / 2; + low = centre - width / 2; + high = low + width; + } + return PassbandEdgePair{low, high}; + }; + + const int width = highHz - lowHz; + if (width < minimum) { + return resize(lowHz, highHz, minimum); + } + if (width > maximum) { + return resize(lowHz, highHz, maximum); + } + return {lowHz, highHz}; +} + +} // namespace AetherSDR diff --git a/src/gui/FilterPassbandWidget.cpp b/src/gui/FilterPassbandWidget.cpp index 7ab2b7fc2..d7c380214 100644 --- a/src/gui/FilterPassbandWidget.cpp +++ b/src/gui/FilterPassbandWidget.cpp @@ -1,10 +1,12 @@ #include "FilterPassbandWidget.h" +#include "FilterPassbandMath.h" #include "MacCursorCompat.h" #include #include #include #include +#include namespace AetherSDR { @@ -32,6 +34,15 @@ void FilterPassbandWidget::setMode(const QString& mode) update(); } +void FilterPassbandWidget::setWidthRange(int minimumHz, int maximumHz, int stepHz) +{ + const bool validRange = minimumHz > 0 && maximumHz >= minimumHz; + m_minimumWidthHz = validRange ? minimumHz : 50; + m_maximumWidthHz = validRange ? maximumHz : 0; + m_widthStepHz = validRange && stepHz > 0 ? stepHz : 50; + update(); +} + // ─── Paint ────────────────────────────────────────────────────────────────── void FilterPassbandWidget::paintEvent(QPaintEvent*) @@ -155,8 +166,20 @@ void FilterPassbandWidget::mouseMoveEvent(QMouseEvent* ev) const int usableW = width() - 32; const int usableH = height(); - const double hzPerPxH = 6000.0 / std::max(usableW, 1); - const double hzPerPxV = 4000.0 / std::max(usableH, 1); + const bool radioWidthRange = m_maximumWidthHz > 0; + // The legacy 6 kHz / 4 kHz spans are gesture scales, not filter limits. + // Empty capabilities (including disconnect) must restore that contract. + const double hzPerPxH = passbandDragScaleHzPerPixel( + radioWidthRange ? m_maximumWidthHz : 6000, usableW); + const double hzPerPxV = passbandDragScaleHzPerPixel( + radioWidthRange ? m_maximumWidthHz - m_minimumWidthHz : 4000, usableH); + const auto snapHz = [this, radioWidthRange](int hz) { + if (!radioWidthRange) { + return (hz / 50) * 50; + } + return static_cast(std::round( + static_cast(hz) / m_widthStepHz)) * m_widthStepHz; + }; int newLo = m_dragStartLo; int newHi = m_dragStartHi; @@ -165,36 +188,35 @@ void FilterPassbandWidget::mouseMoveEvent(QMouseEvent* ev) // Horizontal: shift passband, vertical: symmetric width int shiftHz = static_cast(dx * hzPerPxH); int bwChange = static_cast(-dy * hzPerPxV); - shiftHz = (shiftHz / 50) * 50; - bwChange = (bwChange / 50) * 50; + shiftHz = snapHz(shiftHz); + bwChange = snapHz(bwChange); newLo = m_dragStartLo + shiftHz - bwChange / 2; newHi = m_dragStartHi + shiftHz + bwChange / 2; } else if (m_dragMode == DragLo) { int deltaHz = static_cast(dx * hzPerPxH); - deltaHz = (deltaHz / 50) * 50; + deltaHz = snapHz(deltaHz); newLo = m_dragStartLo + deltaHz; } else if (m_dragMode == DragHi) { int deltaHz = static_cast(dx * hzPerPxH); - deltaHz = (deltaHz / 50) * 50; + deltaHz = snapHz(deltaHz); newHi = m_dragStartHi + deltaHz; } - // Enforce minimum bandwidth - if (newHi - newLo < MIN_BW) { - if (m_dragMode == DragLo) - newLo = newHi - MIN_BW; - else if (m_dragMode == DragHi) - newHi = newLo + MIN_BW; - else { - int center = (newLo + newHi) / 2; - newLo = center - MIN_BW / 2; - newHi = center + MIN_BW / 2; - } - } - - // Snap to 50 Hz grid - newLo = (newLo / 50) * 50; - newHi = (newHi / 50) * 50; + // Enforce the connected radio's mode-specific width range while keeping + // the edge the operator did not touch anchored. + const PassbandDragEdge draggedEdge = m_dragMode == DragLo + ? PassbandDragEdge::Low + : m_dragMode == DragHi ? PassbandDragEdge::High : PassbandDragEdge::Both; + const PassbandEdgePair constrained = constrainPassbandWidth( + newLo, newHi, m_minimumWidthHz, + radioWidthRange ? m_maximumWidthHz : std::numeric_limits::max(), + draggedEdge); + newLo = constrained.lowHz; + newHi = constrained.highHz; + + // Snap to the radio's width grid. + newLo = snapHz(newLo); + newHi = snapHz(newHi); if (newLo != m_lo || newHi != m_hi) { m_lo = newLo; diff --git a/src/gui/FilterPassbandWidget.h b/src/gui/FilterPassbandWidget.h index c3de8c266..cd52926fa 100644 --- a/src/gui/FilterPassbandWidget.h +++ b/src/gui/FilterPassbandWidget.h @@ -18,6 +18,7 @@ class FilterPassbandWidget : public QWidget { void setFilter(int lo, int hi); void setMode(const QString& mode); + void setWidthRange(int minimumHz, int maximumHz, int stepHz); int filterLo() const { return m_lo; } int filterHi() const { return m_hi; } @@ -38,6 +39,9 @@ class FilterPassbandWidget : public QWidget { int m_lo{100}; int m_hi{2800}; QString m_mode{"USB"}; + int m_minimumWidthHz{50}; + int m_maximumWidthHz{0}; // no advertised limit: preserve legacy gestures + int m_widthStepHz{50}; enum DragMode { DragNone, DragShift, DragLo, DragHi }; DragMode m_dragMode{DragNone}; @@ -45,7 +49,6 @@ class FilterPassbandWidget : public QWidget { int m_dragStartLo{0}; int m_dragStartHi{0}; - static constexpr int MIN_BW = 50; }; } // namespace AetherSDR diff --git a/src/gui/MainWindow.cpp b/src/gui/MainWindow.cpp index 99aec6336..a41fca57a 100644 --- a/src/gui/MainWindow.cpp +++ b/src/gui/MainWindow.cpp @@ -7277,6 +7277,8 @@ void MainWindow::applyCapabilitiesToUi(bool connected, const RadioCapabilities& // can never move. // Empty on disconnect, which RESTORES the operator's own list rather // than stranding them on the last radio's three filters. + m_appletPanel->setRadioFilterControl( + connected ? caps.rxFilterControl : RxFilterControl{}); m_appletPanel->setRadioFilterWidths(connected ? caps.rxFilterWidthsHz : QList{}); // Same contract for the TRANSMIT passband, and the same restore-on- @@ -7399,6 +7401,8 @@ void MainWindow::applyCapabilitiesToUi(bool connected, const RadioCapabilities& vfo->setHasHostNoiseBlanker(hostNoiseBlanker); // The VFO's filter grid and the RX applet's are two views of one // radio; only the applet was being told what the hardware has. + vfo->setRadioFilterControl( + connected ? caps.rxFilterControl : RxFilterControl{}); vfo->setRadioFilterWidths(connected ? caps.rxFilterWidthsHz : QList{}); } diff --git a/src/gui/MainWindow_Wiring.cpp b/src/gui/MainWindow_Wiring.cpp index 17078676e..516f442dc 100644 --- a/src/gui/MainWindow_Wiring.cpp +++ b/src/gui/MainWindow_Wiring.cpp @@ -1599,6 +1599,7 @@ bool MainWindow::reattachSliceVisualsToPanadapter(SliceModel* s) targetVfo->setHasLmsNoiseFilters(m_radioModel.hasLmsNoiseFilters()); targetVfo->setHasManualNotch(m_radioModel.hasManualNotch()); targetVfo->setHasHostNoiseBlanker(m_radioModel.hasHostNoiseBlanker()); + targetVfo->setRadioFilterControl(m_radioModel.radioFilterControl()); targetVfo->setRadioFilterWidths(m_radioModel.radioFilterWidthsHz()); wireVfoWidget(targetVfo, s); targetVfo->setDiversityAllowed(m_radioModel.isDiversityAllowed()); @@ -2171,6 +2172,7 @@ void MainWindow::onSliceAdded(SliceModel* s) vfo->setHasLmsNoiseFilters(m_radioModel.hasLmsNoiseFilters()); vfo->setHasManualNotch(m_radioModel.hasManualNotch()); vfo->setHasHostNoiseBlanker(m_radioModel.hasHostNoiseBlanker()); + vfo->setRadioFilterControl(m_radioModel.radioFilterControl()); vfo->setRadioFilterWidths(m_radioModel.radioFilterWidthsHz()); wireVfoWidget(vfo, s); diff --git a/src/gui/RxApplet.cpp b/src/gui/RxApplet.cpp index bf612c7f8..08f99c100 100644 --- a/src/gui/RxApplet.cpp +++ b/src/gui/RxApplet.cpp @@ -2790,7 +2790,7 @@ void RxApplet::updateFilterButtons() static constexpr int kMaxRxFilters = 6; const QString key = QStringLiteral("FilterPresets_%1").arg(m_slice->mode()); const QString saved = AppSettings::instance().value(key, "").toString(); - if (!saved.isEmpty()) { + if (m_radioFilterWidths.isEmpty() && !saved.isEmpty()) { QVector loadedWidths; QVector loadedLo; QVector loadedHi; @@ -2825,6 +2825,16 @@ void RxApplet::updateFilterButtons() } } + if (hasCompleteRxFilterPresets(m_radioFilterControl, m_filterBtns.size())) { + for (int i = 0; i < m_filterBtns.size(); ++i) { + QSignalBlocker blocker(m_filterBtns[i]); + m_filterBtns[i]->setChecked( + m_radioFilterControl.presets.at(i).id + == m_radioFilterControl.selectedPresetId); + } + return; + } + const int width = m_slice->filterHigh() - m_slice->filterLow(); // Find the single closest matching filter preset @@ -2874,7 +2884,7 @@ void RxApplet::updateModeSettings(const QString& mode) m_filterWidths.clear(); m_filterCustomLo.clear(); m_filterCustomHi.clear(); - if (!saved.isEmpty()) { + if (m_radioFilterWidths.isEmpty() && !saved.isEmpty()) { for (const auto& s : saved.split(',', Qt::SkipEmptyParts)) { if (s.contains(':')) { const auto parts = s.split(':'); @@ -2962,6 +2972,28 @@ void RxApplet::setRadioFilterWidths(const QList& widthsHz) rebuildFilterButtons(); } +void RxApplet::setRadioFilterControl(const RxFilterControl& control) +{ + if (control == m_radioFilterControl) { + return; + } + m_radioFilterControl = control; + if (!control.presets.isEmpty()) { + QVector widths; + widths.reserve(control.presets.size()); + for (const RxFilterPreset& preset : control.presets) { + widths.append(preset.widthHz); + } + m_radioFilterWidths = widths; + } + if (m_filterPassband) { + m_filterPassband->setWidthRange(control.minimumWidthHz, + control.maximumWidthHz, + control.widthStepHz); + } + rebuildFilterButtons(); +} + void RxApplet::rebuildFilterButtons() { // Remove old buttons @@ -2977,12 +3009,33 @@ void RxApplet::rebuildFilterButtons() const bool customisable = m_radioFilterWidths.isEmpty(); for (int i = 0; i < widths.size(); ++i) { const int w = widths[i]; - auto* btn = mkToggle(formatStepLabel(w)); + const bool stablePresets = + hasCompleteRxFilterPresets(m_radioFilterControl, widths.size()); + const RxFilterPreset preset = stablePresets + ? m_radioFilterControl.presets.at(i) : RxFilterPreset{}; + auto* btn = mkToggle(stablePresets ? preset.label : formatStepLabel(w)); + if (stablePresets) { + btn->setToolTip(QStringLiteral("%1: %2 receive bandwidth") + .arg(preset.label, formatStepLabel(preset.widthHz))); + btn->setAccessibleName(QStringLiteral("Receive filter %1") + .arg(preset.label)); + } btn->setStyleSheet(kButtonBase() + kBlueActive()); - connect(btn, &QPushButton::clicked, this, [this, i, customisable](bool) { - if (!m_slice) return; + connect(btn, &QPushButton::clicked, this, + [this, i, customisable, stablePresets, preset](bool) { + if (!m_slice) { + return; + } + if (stablePresets) { + if (m_radioModel) { + m_radioModel->selectRadioFilterPreset(m_slice->sliceId(), preset.id); + } + return; + } const QVector& live = effectiveFilterWidths(); - if (i >= live.size()) return; + if (i >= live.size()) { + return; + } if (customisable && m_filterCustomLo[i] != INT_MIN) { m_slice->setFilterWidth(m_filterCustomLo[i], m_filterCustomHi[i]); } else { diff --git a/src/gui/RxApplet.h b/src/gui/RxApplet.h index e69b9424e..d92c4b975 100644 --- a/src/gui/RxApplet.h +++ b/src/gui/RxApplet.h @@ -4,6 +4,8 @@ #include #include +#include "core/backends/RadioCapabilities.h" + class ScrollableLabel; namespace AetherSDR { class FilterPassbandWidget; } @@ -182,6 +184,7 @@ class RxApplet : public QWidget { // An EMPTY list restores the operator's own configurable set, so this is // reversible on disconnect rather than a one-way edit of their settings. void setRadioFilterWidths(const QList& widthsHz); + void setRadioFilterControl(const RxFilterControl& control); private: // The list actually in force: the radio's when it declared one, else the // operator's configurable set. Every site that indexes filter buttons must @@ -253,6 +256,7 @@ class RxApplet : public QWidget { // so the settings-driven list is not overwritten — reconnecting to a radio // with continuous filters must give the operator their own list back. QVector m_radioFilterWidths; + RxFilterControl m_radioFilterControl; // Parallel "custom edges" — INT_MIN sentinel = use mode rules. (#2259) QVector m_filterCustomLo; QVector m_filterCustomHi; diff --git a/src/gui/VfoWidget.cpp b/src/gui/VfoWidget.cpp index 0ec95dce8..716828dfa 100644 --- a/src/gui/VfoWidget.cpp +++ b/src/gui/VfoWidget.cpp @@ -3328,6 +3328,25 @@ void VfoWidget::setRadioFilterWidths(const QList& widthsHz) updateModeTab(); } +void VfoWidget::setRadioFilterControl(const RxFilterControl& control) +{ + if (control == m_radioFilterControl) { + return; + } + m_radioFilterControl = control; + if (!control.presets.isEmpty()) { + QVector widths; + widths.reserve(control.presets.size()); + for (const RxFilterPreset& preset : control.presets) { + widths.append(preset.widthHz); + } + m_radioFilterWidths = widths; + } + if (m_slice) { + updateModeTab(); + } +} + void VfoWidget::setHasManualNotch(bool has) { if (m_hasManualNotch == has) @@ -5427,7 +5446,7 @@ void VfoWidget::updateModeTab() m_filterWidths.clear(); m_filterCustomLo.clear(); m_filterCustomHi.clear(); - if (!saved.isEmpty()) { + if (m_radioFilterWidths.isEmpty() && !saved.isEmpty()) { for (const auto& s : saved.split(',', Qt::SkipEmptyParts)) { if (s.contains(':')) { const auto parts = s.split(':'); @@ -5556,12 +5575,31 @@ void VfoWidget::rebuildFilterButtons() for (int i = 0; i < m_filterWidths.size(); ++i) { const int w = m_filterWidths[i]; - auto* btn = new QPushButton(formatFilterLabel(w)); + const bool stablePresets = + hasCompleteRxFilterPresets(m_radioFilterControl, m_filterWidths.size()); + const RxFilterPreset preset = stablePresets + ? m_radioFilterControl.presets.at(i) : RxFilterPreset{}; + auto* btn = new QPushButton(stablePresets ? preset.label : formatFilterLabel(w)); + if (stablePresets) { + btn->setToolTip(QStringLiteral("%1: %2 receive bandwidth") + .arg(preset.label, formatFilterLabel(preset.widthHz))); + btn->setAccessibleName(QStringLiteral("Receive filter %1") + .arg(preset.label)); + } btn->setCheckable(true); btn->setFixedHeight(26); btn->setStyleSheet(kModeBtn); - connect(btn, &QPushButton::clicked, this, [this, i](bool) { - if (!m_slice) return; + connect(btn, &QPushButton::clicked, this, + [this, i, stablePresets, preset](bool) { + if (!m_slice) { + return; + } + if (stablePresets) { + if (m_radioModel) { + m_radioModel->selectRadioFilterPreset(m_slice->sliceId(), preset.id); + } + return; + } if (m_filterCustomLo[i] != INT_MIN) { // Custom edges from right-click → "Set Custom Edges..." m_slice->setFilterWidth(m_filterCustomLo[i], m_filterCustomHi[i]); @@ -5762,7 +5800,7 @@ void VfoWidget::updateFilterHighlight() // Format mirrors updateModeTab(): "width" or "lo:hi" entries (#2259). const QString key = QStringLiteral("FilterPresets_%1").arg(m_slice->mode()); const QString saved = AppSettings::instance().value(key, "").toString(); - if (!saved.isEmpty()) { + if (m_radioFilterWidths.isEmpty() && !saved.isEmpty()) { QVector loadedWidths; QVector loadedLo; QVector loadedHi; @@ -5796,6 +5834,16 @@ void VfoWidget::updateFilterHighlight() } } + if (hasCompleteRxFilterPresets(m_radioFilterControl, m_filterBtns.size())) { + for (int i = 0; i < m_filterBtns.size(); ++i) { + QSignalBlocker blocker(m_filterBtns[i]); + m_filterBtns[i]->setChecked( + m_radioFilterControl.presets.at(i).id + == m_radioFilterControl.selectedPresetId); + } + return; + } + const int width = m_slice->filterHigh() - m_slice->filterLow(); int bestIdx = -1, bestDist = INT_MAX; for (int i = 0; i < m_filterWidths.size(); ++i) { diff --git a/src/gui/VfoWidget.h b/src/gui/VfoWidget.h index 9cc6765bb..f6b3b4d78 100644 --- a/src/gui/VfoWidget.h +++ b/src/gui/VfoWidget.h @@ -15,6 +15,7 @@ #include #include "core/KiwiSdrProtocol.h" +#include "core/backends/RadioCapabilities.h" class QPushButton; class ScrollableLabel; @@ -611,6 +612,7 @@ class VfoWidget : public QWidget { // the VFO grid was simply never given it, so the two filter surfaces // in the app disagreed about what the radio could do. void setRadioFilterWidths(const QList& widthsHz); + void setRadioFilterControl(const RxFilterControl& control); // Reflect whether any client-side AetherDSP NR module (NR2 / NR4 / MNR / // BNR / DFNR / RN2) is active by accenting the ADSP launcher, so the cue is @@ -739,6 +741,7 @@ class VfoWidget : public QWidget { QVector m_filterWidths; // Radio-declared ladder; empty when the radio does not declare one. QVector m_radioFilterWidths; + RxFilterControl m_radioFilterControl; // Parallel to m_filterWidths. When a slot has user-defined custom // edges (right-click → "Set Custom Edges..."), the lo/hi are stored // here and applied directly instead of going through applyFilterPreset's diff --git a/src/models/RadioModel.cpp b/src/models/RadioModel.cpp index efdcda1fe..674d0716e 100644 --- a/src/models/RadioModel.cpp +++ b/src/models/RadioModel.cpp @@ -3970,6 +3970,29 @@ QList RadioModel::radioFilterWidthsHz() const return backendCapabilities().rxFilterWidthsHz; } +RxFilterControl RadioModel::radioFilterControl() const +{ + if (!m_backend || !isConnected()) { + return {}; + } + return backendCapabilities().rxFilterControl; +} + +void RadioModel::selectRadioFilterPreset(int sliceId, int presetId) +{ + if (!m_backend || !isConnected()) { + return; + } + const RxFilterControl control = backendCapabilities().rxFilterControl; + const bool declared = std::any_of( + control.presets.cbegin(), control.presets.cend(), + [presetId](const RxFilterPreset& preset) { return preset.id == presetId; }); + if (!declared) { + return; + } + m_backend->setSliceFilterPreset(sliceId, presetId); +} + bool RadioModel::hasRadioSideWaterfallAutoBlack() const { if (!m_backend || !isConnected()) { diff --git a/src/models/RadioModel.h b/src/models/RadioModel.h index 4013a00b5..9e3fe90a1 100644 --- a/src/models/RadioModel.h +++ b/src/models/RadioModel.h @@ -388,6 +388,8 @@ class RadioModel : public QObject { // "use the operator's own presets", which is what every radio without a // fixed IF ladder wants and what a disconnected app should show. QList radioFilterWidthsHz() const; + RxFilterControl radioFilterControl() const; + void selectRadioFilterPreset(int sliceId, int presetId); // Whether the RADIO computes the waterfall black level per tile // (RadioCapabilities::hasRadioSideWaterfallAutoBlack) — the HW position of // the Display panel's Black Level button. Same permissive disconnected rule. diff --git a/tests/filter_passband_math_test.cpp b/tests/filter_passband_math_test.cpp new file mode 100644 index 000000000..297e800d7 --- /dev/null +++ b/tests/filter_passband_math_test.cpp @@ -0,0 +1,60 @@ +#include "gui/FilterPassbandMath.h" +#include "core/backends/RadioCapabilities.h" + +#include +#include + +using namespace AetherSDR; + +static int g_failures = 0; + +static void check(bool condition, const char* message) +{ + if (!condition) { + std::fprintf(stderr, "FAIL: %s\n", message); + ++g_failures; + } +} + +int main() +{ + RxFilterControl radioPresets; + radioPresets.presets = { + {1, QStringLiteral("FIL1"), 3000}, + {2, QStringLiteral("FIL2"), 2400}, + {3, QStringLiteral("FIL3"), 1800}, + }; + check(hasCompleteRxFilterPresets(radioPresets, 3), + "a complete radio preset list is safe to present"); + check(!hasCompleteRxFilterPresets(radioPresets, 8), + "stale FIL metadata is rejected during disconnect list rebuild"); + check(!hasCompleteRxFilterPresets(RxFilterControl{}, 3), + "an empty radio preset list uses the legacy presentation"); + + const double usbScale = passbandDragScaleHzPerPixel(3600, 168); + const double amScale = passbandDragScaleHzPerPixel(10000, 168); + check(std::abs(usbScale - (3600.0 / 168.0)) < 0.001, + "USB/CW drag scale uses the radio's 3.6 kHz ceiling"); + check(std::abs(amScale - (10000.0 / 168.0)) < 0.001, + "AM drag scale uses the radio's 10 kHz ceiling"); + check(amScale > usbScale, + "different mode capabilities produce different skirt mappings"); + + check(constrainPassbandWidth(300, 310, 50, 3600, PassbandDragEdge::Low) + == PassbandEdgePair{260, 310}, + "low-edge narrowing anchors the untouched high edge"); + check(constrainPassbandWidth(300, 310, 50, 3600, PassbandDragEdge::High) + == PassbandEdgePair{300, 350}, + "high-edge narrowing anchors the untouched low edge"); + check(constrainPassbandWidth(-7000, 7000, 200, 10000, PassbandDragEdge::Both) + == PassbandEdgePair{-5000, 5000}, + "AM symmetric resize clamps to the radio's 10 kHz maximum"); + check(constrainPassbandWidth(-1800, 1800, 50, 3600, PassbandDragEdge::Both) + == PassbandEdgePair{-1800, 1800}, + "an in-range CW/SSB width remains unchanged"); + + if (g_failures == 0) { + std::printf("filter_passband_math_test: all checks passed\n"); + } + return g_failures == 0 ? 0 : 1; +} diff --git a/tests/filter_passband_widget_test.cpp b/tests/filter_passband_widget_test.cpp new file mode 100644 index 000000000..196a0bcde --- /dev/null +++ b/tests/filter_passband_widget_test.cpp @@ -0,0 +1,82 @@ +#include "gui/FilterPassbandWidget.h" + +#include +#include +#include + +using namespace AetherSDR; + +static int g_failures = 0; + +static void checkEdges(const FilterPassbandWidget& widget, int low, int high, + const char* message) +{ + if (widget.filterLo() != low || widget.filterHi() != high) { + std::fprintf(stderr, "FAIL: %s: expected %d..%d, got %d..%d\n", + message, low, high, widget.filterLo(), widget.filterHi()); + ++g_failures; + } +} + +// Drive the production event handler without a socket or a radio peer. +static void drag(FilterPassbandWidget& widget, int x, int dx, int dy) +{ + const QPointF start(x, 30); + const QPointF end(x + dx, 30 + dy); + QMouseEvent press(QEvent::MouseButtonPress, start, start, + Qt::LeftButton, Qt::LeftButton, Qt::NoModifier); + QApplication::sendEvent(&widget, &press); + QMouseEvent move(QEvent::MouseMove, end, end, + Qt::NoButton, Qt::LeftButton, Qt::NoModifier); + QApplication::sendEvent(&widget, &move); + QMouseEvent release(QEvent::MouseButtonRelease, end, end, + Qt::LeftButton, Qt::NoButton, Qt::NoModifier); + QApplication::sendEvent(&widget, &release); +} + +int main(int argc, char** argv) +{ + QApplication app(argc, argv); + FilterPassbandWidget widget; + widget.resize(200, 80); + widget.setMode(QStringLiteral("AM")); + + widget.setFilter(-5000, 5000); + drag(widget, 100, 5, 0); + checkEdges(widget, -4850, 5150, + "empty capabilities preserve a 10 kHz width and legacy rounding"); + + widget.setFilter(-1000, 1000); + drag(widget, 100, 0, -10); + checkEdges(widget, -1250, 1250, "legacy vertical scale remains 4 kHz"); + + widget.setFilter(-5000, 5000); + drag(widget, 190, 100, 0); + checkEdges(widget, -5000, 8550, "legacy high-edge drag has no 6 kHz ceiling"); + + widget.setFilter(-1000, 1000); + drag(widget, 10, 200, 0); + checkEdges(widget, 950, 1000, "legacy minimum width still applies"); + + widget.setWidthRange(200, 10000, 200); + widget.setFilter(-4000, 4000); + drag(widget, 190, 100, 0); + checkEdges(widget, -4000, 6000, "advertised AM maximum still applies"); + + widget.setWidthRange(50, 3600, 50); + widget.setMode(QStringLiteral("USB")); + widget.setFilter(300, 2700); + drag(widget, 190, 100, 0); + checkEdges(widget, 300, 3900, "mode change adopts the advertised USB maximum"); + + widget.setWidthRange(0, 0, 0); + widget.setMode(QStringLiteral("AM")); + widget.setFilter(-5000, 5000); + drag(widget, 100, 5, 0); + checkEdges(widget, -4850, 5150, "disconnect restores unrestricted legacy width"); + + if (g_failures == 0) { + std::puts("filter_passband_widget_test: all checks passed"); + } + return g_failures == 0 ? 0 : 1; +} diff --git a/tests/icom_civ_test.cpp b/tests/icom_civ_test.cpp index 5d0391b26..95c070767 100644 --- a/tests/icom_civ_test.cpp +++ b/tests/icom_civ_test.cpp @@ -497,6 +497,24 @@ static void testModes() // three identical buttons, two of which read as broken. check(filterWidthsForMode("WFM").size() == 1, "WFM publishes its single filter once"); + // BUTTON IDENTITY IS THE FIL SLOT, NOT ITS MUTABLE WIDTH. Customising the + // selected slot must update its tooltip/passband content without sorting + // it into another button position or changing its label. + const std::vector usbCustom = + filterPresetsForMode("USB", 2, 2700); + check((usbCustom == std::vector{{1, 3000}, {2, 2700}, {3, 1800}}), + "a custom USB FIL2 width keeps FIL1/FIL2/FIL3 in radio order"); + const std::vector amCustom = + filterPresetsForMode("AM", 1, 10000); + check((amCustom == std::vector{{1, 10000}, {2, 6000}, {3, 3000}}), + "a custom AM FIL1 width stays FIL1 instead of becoming a 10K button"); + const std::vector cwCustom = + filterPresetsForMode("CW", 3, 700); + check((cwCustom == std::vector{{1, 1200}, {2, 500}, {3, 700}}), + "a custom CW FIL3 width stays FIL3 even when wider than FIL2"); + check(filterPresetsForMode("WFM").size() == 1, + "a fixed WFM filter publishes one stable slot"); + // A sideband passband sits off the carrier and carries its sideband in the // sign; AM and its relatives straddle it. { @@ -516,6 +534,108 @@ static void testModes() const auto [lo, hi] = passbandForModeAndFilter("SAM", 2); check(hi - lo == 6000, "SAM FIL2 is 6 kHz wide, not the 3 kHz SSB fallback"); } + + // CLICKING ANY FIL BUTTON RECALLS ITS FACTORY SHAPE. These are the exact + // edges setSliceFilterPreset feeds back through the width + centred-PBT + // writer, including when the requested slot is already selected. Cover all + // three identities and every mode family exercised by the live sweep. + const auto checkRecall = [](const std::string& mode, int presetId, + int expectedLow, int expectedHigh) { + bool dataMode = false; + const std::optional wireMode = modeFromNeutral(mode, dataMode); + check(wireMode.has_value(), "recall mode maps to CI-V"); + if (!wireMode) { + return; + } + const std::optional plan = filterPresetRecallPlan( + kIc705, mode, *wireMode, dataMode, presetId, true); + check(plan.has_value(), "FIL recall plan exists"); + if (!plan) { + return; + } + const std::string recallName = mode + " FIL" + std::to_string(presetId); + check(plan->lowHz == expectedLow && plan->highHz == expectedHigh, + (recallName + " recalls its factory width and centred passband").c_str()); + check(plan->pbtCode == kPbtCentreCode, + (recallName + " recall centres Twin PBT").c_str()); + check(plan->commands.size() == 4, + (recallName + + " recall selects, resets width, and centres both PBTs").c_str()); + if (plan->commands.size() != 4) { + return; + } + check(plan->commands[0] + == cmdSetVfoMode(kIc705, *wireMode, dataMode, presetId), + "recall selects the requested FIL identity first"); + const std::optional widthCode = + filterWidthCodeFor(mode, expectedHigh - expectedLow); + check(widthCode.has_value(), "factory recall width has a CI-V code"); + if (!widthCode) { + return; + } + check(plan->commands[1] == cmdSetFilterWidth(kIc705, *widthCode), + "recall writes the factory filter width"); + check(plan->commands[2] + == cmdSetLevel(kIc705, level::kPbtInner, kPbtCentreCode), + "recall centres inner PBT"); + check(plan->commands[3] + == cmdSetLevel(kIc705, level::kPbtOuter, kPbtCentreCode), + "recall centres outer PBT"); + }; + checkRecall("USB", 1, 0, 3000); + checkRecall("USB", 2, 300, 2700); + checkRecall("USB", 3, 600, 2400); + checkRecall("LSB", 1, -3000, 0); + checkRecall("LSB", 2, -2700, -300); + checkRecall("LSB", 3, -2400, -600); + checkRecall("AM", 1, -4500, 4500); + checkRecall("AM", 2, -3000, 3000); + checkRecall("AM", 3, -1500, 1500); + checkRecall("CW", 1, -600, 600); + checkRecall("CW", 2, -250, 250); + checkRecall("CW", 3, -125, 125); + + // Fixed-width modes still own selectable FIL identities. They must emit + // only the mode/slot command, never an IF-width or Twin-PBT write. + struct FixedRecallCase { + const char* mode; + CivMode wireMode; + bool dataMode; + int slots; + }; + for (const FixedRecallCase& test : { + FixedRecallCase{"FM", CivMode::Fm, false, 3}, + FixedRecallCase{"NFM", CivMode::Fm, false, 3}, + FixedRecallCase{"DFM", CivMode::Fm, true, 3}, + FixedRecallCase{"WFM", CivMode::Wfm, false, 1}, + FixedRecallCase{"DV", CivMode::Dv, false, 3}, + FixedRecallCase{"DSTAR", CivMode::Dv, false, 3}}) { + for (bool useVfoMode : {false, true}) { + for (int presetId = 1; presetId <= test.slots; ++presetId) { + const auto plan = filterPresetRecallPlan( + kIc705, test.mode, test.wireMode, test.dataMode, + presetId, useVfoMode); + check(plan.has_value(), "fixed-mode FIL selection has a plan"); + if (!plan) { + continue; + } + const auto select = useVfoMode + ? cmdSetVfoMode(kIc705, test.wireMode, test.dataMode, presetId) + : cmdSetMode(kIc705, test.wireMode, presetId); + check(plan->commands == std::vector>{select}, + "fixed-mode recall sends only the requested slot selection"); + check(plan->widthHz == 0, + "fixed-mode recall does not claim a programmable IF width"); + const auto [low, high] = passbandForModeAndFilter(test.mode, presetId); + check(plan->lowHz == low && plan->highHz == high, + "fixed-mode recall retains the selected slot's display edges"); + } + } + } + check(!filterPresetRecallPlan(kIc705, "FM", CivMode::Fm, false, 0, true), + "fixed-mode recall rejects undeclared slot zero"); + check(!filterPresetRecallPlan(kIc705, "WFM", CivMode::Wfm, false, 2, true), + "WFM recall rejects a second slot"); } static void testCommands() diff --git a/tests/tests.cmake b/tests/tests.cmake index 229cd5d41..9a749cafe 100644 --- a/tests/tests.cmake +++ b/tests/tests.cmake @@ -4125,6 +4125,24 @@ target_include_directories(rx_filter_step_test PRIVATE src) target_link_libraries(rx_filter_step_test PRIVATE Qt6::Core) add_test(NAME rx_filter_step_test COMMAND rx_filter_step_test) +# Socket-free passband interaction math. Pins mode-aware skirt scaling and the +# untouched-edge anchor contract without constructing the full desktop applet. +add_executable(filter_passband_math_test tests/filter_passband_math_test.cpp) +target_include_directories(filter_passband_math_test PRIVATE src) +target_link_libraries(filter_passband_math_test PRIVATE Qt6::Core) +add_test(NAME filter_passband_math_test COMMAND filter_passband_math_test) + +# Socket-free Qt event injection: empty capabilities preserve legacy gestures, +# while advertised limits still reach the production widget (PR #5363). +add_executable(filter_passband_widget_test + tests/filter_passband_widget_test.cpp + src/gui/FilterPassbandWidget.cpp) +target_include_directories(filter_passband_widget_test PRIVATE src) +target_link_libraries(filter_passband_widget_test PRIVATE Qt6::Widgets) +add_test(NAME filter_passband_widget_test COMMAND filter_passband_widget_test) +set_tests_properties(filter_passband_widget_test PROPERTIES + ENVIRONMENT "QT_QPA_PLATFORM=offscreen") + add_executable(amp_applet_test tests/amp_applet_test.cpp src/gui/AmpApplet.cpp