diff --git a/docs/HERMES.md b/docs/HERMES.md index e8c4fcf3b4..6b1de20c74 100644 --- a/docs/HERMES.md +++ b/docs/HERMES.md @@ -2195,8 +2195,8 @@ The Display panel's FPS slider, Clone to all Pans and Reset to Defaults save; `MainWindow::wirePanDisplayStatus` restores into the widget before it seeds the shaper. FFT AVG and Wt Avg have a table of their own in that document (`fftAverages`), kept only for a backend that declares -`BackendPanAveraging::clientPersistsAveraging`. The HL2 declares it false, so on -this radio those two are not remembered. +`BackendPanAveraging::clientPersistsAveraging`. The HL2 declares it, so the +Display panel's FFT AVG slider and Wt Avg toggle are remembered per pan slot too. The dBm range is the odd one: the pan model, not the widget, is what every re-seed reads, so a scale the operator moves is put into the model @@ -3696,7 +3696,10 @@ behavior is unchanged. The count was fixed at connect, from a persisted setting. It is now the operator's, at runtime: "Add Panadapter" and the pane close button. Connect -always comes up with ONE receiver. +always comes up with ONE receiver; the ones the operator had open come back +after link-up through that same add (`Hl2Backend::replayRestoredSession`, from +the `receivers` list of the `OperatingState` document), so the receiver +ceiling at the current span still decides how many return (#5777). Retiring the persisted count mattered for a reason beyond tidiness: it made connect the only place the count could change, and a saved 4 was re-imposed on diff --git a/docs/architecture/radio-capabilities-map.md b/docs/architecture/radio-capabilities-map.md index bb7408afd6..9703e12793 100644 --- a/docs/architecture/radio-capabilities-map.md +++ b/docs/architecture/radio-capabilities-map.md @@ -191,7 +191,7 @@ the production dialog and checks its displayed peak level and frequency. | `hasHostNoiseBlanker` | ❌ | ✅ | ❌ | `RadioModel::hasHostNoiseBlanker()` → `VfoWidget::setHasHostNoiseBlanker` | **THIS HOST** blanks impulse noise in the radio's IQ (WDSP ANB, ahead of the demodulator). OR'd with `hasRadioSideDsp` at the NB button, so a direct-sampling radio gets NB without claiming firmware DSP it does not have — the same exception the manual notch makes. Requires an IQ path this host demodulates: a backend fed finished audio has nothing to blank. Icom: ❌ (the radio's own blanker, under `hasRadioSideDsp`). ANAN: ✅ — the same WDSP ANB, in `AnanRxDsp`. **Not** permissive on disconnect — it can only ADD the button | | `hasAutoRfGain` | ❌ | ✅ | ❌ | `RadioModel::hasAutoRfGain()` → `SpectrumOverlayMenu::setAutoRfGainAvailable`, and `IRadioBackend::setAutoRfGain` | The backend drives its own **receive RF gain** from an ADC-overload observation, and offers an on/off switch for it. NOT the audio AGC: this is gain ahead of every DDC, driven by converter saturation across the whole receive span, which an audio meter in one slice cannot see. HL2: ✅ — one AD9866 behind every DDC, and its overload flag rides the EP6 C&C bytes. **Off by default** — RFC #5535 approved it armed, but the shipped LNA default of +20 dB sits one dB above the baseline the loop will arm from, so default-on would refuse on every fresh connect; it waits on a trustworthy gain axis at that default. Arming in the default `bandscope` law starts the wideband gate — about 0.11 Mbit/s on the same 100BASE-T link the EP6 receivers share, which is the sort of thing this table is read for. The flag itself says only that the switch exists. **Not** permissive on disconnect — it can only ADD the Auto checkbox beside the ANT panel's RF Gain slider. Also reachable headlessly as `pan autorfgain on\|off`, because that checkbox lives in a popup the bridge's `invoke` refuses to drive while hidden | | `hasDdcPanEdgeRolloff` | ❌ | ❌ | ❌ | `MainWindow::onConnectionStateChanged()` → `SpectrumWidget::setPanEdgeTaperEnabled()` | Real, bench-measured attenuation baked into the sampled data itself toward the extreme edges of the panadapter bandwidth — not a display artifact. True only for ANAN-G2, the first (and so far only) DDC-based backend; Flex/HL2/Sim report false since none of their receive chains have this shape. A capability flag rather than a family-string check, so a future DDC backend gets the same display-only edge crop automatically. Icom: ❌ (CI-V ships finished audio, not a decimated IQ stream with an edge to taper) | -| `backendPanAveraging` (record) | absent | **10 ms** | absent | `MainWindow::onConnectionStateChanged()` → `SpectrumWidget::setClientFftSmoothingEnabled()` | The backend already averages the panadapter per the operator's FFT AVG before the frame leaves it, so the widget skips its fixed client-side EMA (`SMOOTH_ALPHA`) instead of averaging twice. Engaged by ANAN (WDSP's display analyzer, `AnanPanAnalyzer`) and HL2 (`Hl2Spectrum`'s time-constant average, power domain unless the weighted toggle selects log-recursive); `msPerAverageStep` is what one FFT AVG step means (10 ms on both). Engaged by RTL too (`SpectrumTemporalAverage` in its acquisition-owned accumulator, 10 ms per step). `clientPersistsAveraging` makes the client the persistence owner for FFT AVG and Wt Avg (per-radio `ClientDisplay.fftAverages`); RTL-owned, ✅ only for RTL, false for ANAN and HL2. `averageDescription` / `weightedDescription` replace the controls' default wording where the backend's units or weighted semantics differ; RTL supplies both, others leave them empty. Icom: absent | +| `backendPanAveraging` (record) | absent | **10 ms** | absent | `MainWindow::onConnectionStateChanged()` → `SpectrumWidget::setClientFftSmoothingEnabled()` | The backend already averages the panadapter per the operator's FFT AVG before the frame leaves it, so the widget skips its fixed client-side EMA (`SMOOTH_ALPHA`) instead of averaging twice. Engaged by ANAN (WDSP's display analyzer, `AnanPanAnalyzer`) and HL2 (`Hl2Spectrum`'s time-constant average, power domain unless the weighted toggle selects log-recursive); `msPerAverageStep` is what one FFT AVG step means (10 ms on both). Engaged by RTL too (`SpectrumTemporalAverage` in its acquisition-owned accumulator, 10 ms per step). `clientPersistsAveraging` makes the client the persistence owner for FFT AVG and Wt Avg (per-radio `ClientDisplay.fftAverages`); ✅ for RTL and HL2, false for ANAN. `averageDescription` / `weightedDescription` replace the controls' default wording where the backend's units or weighted semantics differ; RTL supplies both, others leave them empty. Icom: absent | | `panFrameRateShaping` (record) | absent | **engaged**, `clientPersistsFrameRate` ✅ | absent | `MainWindow::clientPersistsFftFps()` → `RadioCapabilities::clientPersistsPanFrameRate()`, from `scheduleClientFftFpsSave` and `wirePanDisplayStatus` | Who keeps a pan's FFT frame rate (Display ▸ FFT FPS) where this engine paces the frames. Engaged with `clientPersistsFrameRate` true makes the client the persistence owner: the per-radio `ClientDisplay.fftFps` table is written and restored. Absent keeps a family's existing behavior, which is to store nothing. HL2 only; Flex (the radio stores and reports it), Icom, ANAN, RTL and Sim leave it absent. The save and the restore read the one accessor | | `panZoomModes` (record) | ✅ engaged | — (absent) | — (absent) | `MainWindow::onConnectionStateChanged()` and the `PanadapterStack::panAdded` lambda → `SpectrumWidget::setBandSegmentZoomAvailable()`; `MainWindow::togglePanZoomModeForPan` and `MainWindow::setPanZoomMode` → `gui/PanZoomModeGate.h` | Per-pan band/segment zoom (`display pan set band_zoom=`/`segment_zoom=`). ENGAGED means the radio answers that wire text; ABSENT means undeclared and the gate REFUSES — the write would otherwise be dropped inside `RadioModel::sendCmd` while the control moved anyway. One predicate serves both the B/S button enable and all six command surfaces (shortcuts, MIDI, FlexControl, the RC28/Stream Deck/T-Mate2 chain, the wheel, the automation bridge), so "the button is grey" and "the keystroke is refused" cannot drift apart. A refusal on this rung logs and calls `MainWindow::showUnsupportedControlNotice()`, because a gate refuses before the send and `commandDropped()` never fires. A record and not a bool per #5262 M2 — the two zoom modes are separate wire keys — and a record and not a family string per the standing #5554 notice. Icom/ANAN/RTL: absent | | `hasRadioSideWaterfallAutoBlack` | ✅ | ❌ | ❌ | `MainWindow::applyRadioSideDspToPanDisplay` | The HW position of the Display ▸ Black Level button. False cycles Off ↔ SW. **Masks, never rewrites** the stored preference — see below | @@ -219,7 +219,7 @@ the production dialog and checks its displayed peak level and frequency. | `canWriteMemories` | ✅ | ❌ | ❌ | `RadioModel::memoriesWritable`, memory dialog and panadapter memory panel | Separates native ownership from mutation support. Icom's radio-side store stays read-only, while the shared AetherSDR database remains writable for Add, Import, inline edits, Remove, and Tune on every Icom model. | | `canApplyMemories` | ✅ | ❌ | ❌ | `RadioModel::tryMemoryCommand` | True means the backend accepts its native memory-apply command. Initial Icom support is ❌ and applies recallable cached fields through the existing neutral slice setters instead of entering vendor Memory mode; split/RPS/DV/DD records are display-only. | | `canRefreshMemories` | ❌ | ❌ | ❌ | Memory Channels dialog → `RadioModel::refreshMemories` | Explicit, button-only radio-memory snapshots. IC-7300MK2 reads 99 channels; IC-9700 reads all 297 or one selected band; IC-705 requires one selected group and reads only its 100 channels. No memory scan runs during connection. | -| `clientSettingsDomains` | empty | Tuning\|Passband\|SpanRate\|RfGain\|TxSetpoints\|Memories\|Agc\|Cw | empty | `RadioStateMemory::shouldEngage` → `RadioModel::handRestoredStateToBackend` | connect-time operating-state restore + debounced capture (RFC #4603 PR 3). RTL can transfer overlapping domains to `RtlSlices` after accepted capture and successful feature ownership; see the RTL table below. Consumers re-read current capabilities for persistence and receive runtime changes through `capabilitiesChanged`: `Hl2Backend::applyRestoredState` seeds rate/freq/LNA at connect, `pushInitialState` applies restored mode+passband (reconciled with #4484 — restored as a pair, so mode and passband cannot disagree) and the start band's drive; per-band LNA/drive maps ride the extension document and follow TX-slice band changes. `Agc` (#4909) carries the mode + threshold pair as typed universal fields — FLAT, not per-band, and seeded onto EVERY receiver by `Hl2Backend::seedReceiverAgc()`, because the AGC runs in host-side WDSP and no HPSDR register can be asked what it is. Seeding runs from `connectRadio` when the connect SERIAL changes or the receivers were rebuilt from nothing — never on a plain auto-reconnect, because `handRestoredStateToBackend` re-hands the document before every connect and `buildReceivers` preserves live receiver state, so an unconditional seed flattened per-receiver AGC on each dropped link. The AGC-off level rides the same domain PER RECEIVER (`RestoredRadioState::agcOffLevels`, the `agcOffLevels` array of the `OperatingState` document): `RadioStateMemory` stores and loads it only when the backend declares `Agc` AND `hasAgcThreshold`, the same seed gives each receiver its own entry, and a document without the array restores the default of 10. ANAN declares `hasAgcThreshold` without the `Agc` domain and RTL-SDR declares neither, so both ignore it. Memories is declarative only — the bank engages on `persistsMemories` and keeps its own shared document (PR 6). `Cw` carries the keyer and sidetone surface (`RadioModel::captureClientOwnedCwState`). `Tuning` also carries the tuning step (`tuningStepHz`) where there is no command plane: `RadioModel` seeds it into each new slice and captures any slice's step change. `ReceiveOutputLevel` (ANAN today) carries the radio's own output level, seeded into both the backend and `RadioModel::lineoutGain()`. Flex/Sim: no-op by empty declaration. | +| `clientSettingsDomains` | empty | Tuning\|Passband\|SpanRate\|RfGain\|TxSetpoints\|Memories\|Agc\|Cw\|Receivers\|Notches | empty | `RadioStateMemory::shouldEngage` → `RadioModel::handRestoredStateToBackend` | connect-time operating-state restore + debounced capture (RFC #4603 PR 3). RTL can transfer overlapping domains to `RtlSlices` after accepted capture and successful feature ownership; see the RTL table below. Consumers re-read current capabilities for persistence and receive runtime changes through `capabilitiesChanged`: `Hl2Backend::applyRestoredState` seeds rate/freq/LNA at connect, `pushInitialState` applies restored mode+passband (reconciled with #4484 — restored as a pair, so mode and passband cannot disagree) and the start band's drive; per-band LNA/drive maps ride the extension document and follow TX-slice band changes. `Agc` (#4909) carries the mode + threshold pair as typed universal fields — FLAT, not per-band, and seeded onto EVERY receiver by `Hl2Backend::seedReceiverAgc()`, because the AGC runs in host-side WDSP and no HPSDR register can be asked what it is. Seeding runs from `connectRadio` when the connect SERIAL changes or the receivers were rebuilt from nothing — never on a plain auto-reconnect, because `handRestoredStateToBackend` re-hands the document before every connect and `buildReceivers` preserves live receiver state, so an unconditional seed flattened per-receiver AGC on each dropped link. The AGC-off level rides the same domain PER RECEIVER (`RestoredRadioState::agcOffLevels`, the `agcOffLevels` array of the `OperatingState` document): `RadioStateMemory` stores and loads it only when the backend declares `Agc` AND `hasAgcThreshold`, the same seed gives each receiver its own entry, and a document without the array restores the default of 10. ANAN declares `hasAgcThreshold` without the `Agc` domain and RTL-SDR declares neither, so both ignore it. Memories is declarative only — the bank engages on `persistsMemories` and keeps its own shared document (PR 6). `Cw` carries the keyer and sidetone surface (`RadioModel::captureClientOwnedCwState`). `Tuning` also carries the tuning step (`tuningStepHz`) where there is no command plane: `RadioModel` seeds it into each new slice and captures any slice's step change. `ReceiveOutputLevel` (ANAN today) carries the radio's own output level, seeded into both the backend and `RadioModel::lineoutGain()`. HL2 also keeps, inside its extension sub-objects, the TUNE power per band (`txSetpoints.tuneByBand`, first-set baseline `tuneDefaultPercent`, echoed to `TransmitModel` like drive) and a chosen auto-RF-gain law and floor (`rfGain.autoLaw` / `autoFloorDb`). `Receivers` (HL2) is the `receivers` list: each receiver's frequency, pan centre, mode, passband, blanker, APF, squelch, RIT/XIT and audio gain/pan, receiver A first; A's entry seeds A at connect and `Hl2Backend::replayRestoredSession()` reopens B.. after link-up through the operator's add path, so the receiver ceiling still applies (#5777). AGC stays flat. `Notches` (HL2) is the `notches` list (centre, width, active), recreated after link-up through `createNotch()` so each comes back with a marker; the global notch bypass is not kept. Flex/Sim: no-op by empty declaration. | | `extensionNamespaces` | `["flex"]` | `["hl2"]` | — | `RadioModel::backendDeclaresExtension` (every `invokeExtension` pre-check), `MainWindow::applyCapabilitiesToUi` (the `sim` DemoApplet gate, #5263) | Flex: amp / tuner operate/bypass/autotune verbs. HL2: `freqcal.get` / `.set` / `.set_live`, behind the `freqcal` bridge verb and the Calibration page; `hw.get` / `hw.set`, the declared HL2 variant (codec, dither bit, filter board, gateware ATU) behind the HL2 Hardware page — **no bridge verb yet**, unlike `freqcal`, so these are reachable from the dialog only. `hw.set` is PARTIAL: absent keys keep their current value. One exception is worth knowing before writing a caller — **changing `codec` re-seeds `ditherBit`**, because 0x00[11] means band volts on a bare HL2 and a loudspeaker on the two boards that carry a codec, so carrying the old value across would carry a decision about something else (`Hl2HardwareOptions::ditherBitOnCodecChange`). A caller that names `ditherBit` in the SAME call still wins — that is how you declare a board and its speaker together — which also means a caller can set `{codec: 0, ditherBit: true}` and switch on a bare HL2's band-voltage output deliberately. The dialog never sends that shape; plus `bandscope.enable` (#5650), the wideband bandscope gate (endpoint `0x04`), reached **only** by the bridge's `bandscope` verb — no UI and no setting, on purpose: it is a diagnostic whose readings land in the health dialog's Converter section, uncalibrated and pre-DDC, and nothing in the app makes a decision from it. Icom: `["icom"]`, with PC-audio, scope, control-map, scheduler and diagnostic verbs. **#5262 M1 converted the family-string pre-checks** (PC-audio ×2, `power.wake`, the AX.25 capture dialog) onto `backendDeclaresExtension()` — the gate asks whether the backend *declares the namespace*, not whether it is that family, so a future backend answering the same verbs is not excluded by name. **Still outstanding:** the Flex accessory routes (`RadioModel.cpp:2385/2390/2399/2413`) do not pre-check at all — they gate on `if (m_backend)` and would fire `flex` verbs at whatever backend is connected. | | `maxNotchFilters` | 1000 | 1024 | 0 | `MainWindow::applyCapabilitiesToUi`, `SpectrumWidget::setNotchCapabilities` | The sidebar `+TNF` button and the panadapter's add/remove-notch entries. **0 hides them.** Flex's figure is a UI sanity limit (neither FlexLib nor the wire declares one); HL2's is WDSP's real notch-database size | | `notchHasDepth` | ✅ | ❌ | ❌ | `SpectrumWidget::setNotchCapabilities` | The depth submenu on a notch's right-click menu. A WDSP notch is a full null with no depth to set | diff --git a/src/core/RadioStateMemory.cpp b/src/core/RadioStateMemory.cpp index a2de0aeb2b..5a0299f40e 100644 --- a/src/core/RadioStateMemory.cpp +++ b/src/core/RadioStateMemory.cpp @@ -5,6 +5,7 @@ #include #include +#include namespace AetherSDR { namespace RadioStateMemory { @@ -33,6 +34,27 @@ bool ownsAgcOffLevel(const RadioCapabilities& caps) // feature documents (one domain, one document — PR 6). constexpr const char* kExtRfGainKey = "rfGain"; constexpr const char* kExtTxSetpointsKey = "txSetpoints"; +constexpr const char* kExtReceiversKey = "receivers"; +constexpr const char* kExtNotchesKey = "notches"; + +// The declared domains' sub-objects of `ext`; load() and store() share it so +// the two gates cannot drift apart. +QJsonObject gateExtension(const RadioCapabilities& caps, const QJsonObject& ext) +{ + const std::pair domains[] = { + {Domain::RfGain, kExtRfGainKey}, + {Domain::TxSetpoints, kExtTxSetpointsKey}, + {Domain::Receivers, kExtReceiversKey}, + {Domain::Notches, kExtNotchesKey}, + }; + QJsonObject gated; + for (const auto& [domain, key] : domains) { + if (has(caps, domain) && ext.contains(QLatin1String(key))) { + gated.insert(QLatin1String(key), ext.value(QLatin1String(key))); + } + } + return gated; +} } // namespace @@ -115,18 +137,8 @@ RestoredRadioState load(const RadioSettingsScope& scope, // sub-object is handed over, so a narrowed declaration cannot smuggle // another domain's data to the backend (PR #4614 review — previously the // whole blob rode on any one of the ext domains). - const QJsonObject storedExt = doc.value(QStringLiteral("ext")).toObject(); - QJsonObject gatedExt; - if (has(caps, Domain::RfGain) - && storedExt.contains(QLatin1String(kExtRfGainKey))) { - gatedExt.insert(QLatin1String(kExtRfGainKey), - storedExt.value(QLatin1String(kExtRfGainKey))); - } - if (has(caps, Domain::TxSetpoints) - && storedExt.contains(QLatin1String(kExtTxSetpointsKey))) { - gatedExt.insert(QLatin1String(kExtTxSetpointsKey), - storedExt.value(QLatin1String(kExtTxSetpointsKey))); - } + const QJsonObject gatedExt = + gateExtension(caps, doc.value(QStringLiteral("ext")).toObject()); if (!gatedExt.isEmpty()) { state.extension = gatedExt; state.extensionSchemaVersion = @@ -245,17 +257,7 @@ bool store(const RadioSettingsScope& scope, const RadioCapabilities& caps, } // Extension: same per-domain sub-object gate as load(). - QJsonObject gatedExt; - if (has(caps, Domain::RfGain) - && state.extension.contains(QLatin1String(kExtRfGainKey))) { - gatedExt.insert(QLatin1String(kExtRfGainKey), - state.extension.value(QLatin1String(kExtRfGainKey))); - } - if (has(caps, Domain::TxSetpoints) - && state.extension.contains(QLatin1String(kExtTxSetpointsKey))) { - gatedExt.insert(QLatin1String(kExtTxSetpointsKey), - state.extension.value(QLatin1String(kExtTxSetpointsKey))); - } + const QJsonObject gatedExt = gateExtension(caps, state.extension); if (!gatedExt.isEmpty()) { doc.insert(QStringLiteral("ext"), gatedExt); doc.insert(QStringLiteral("extVersion"), state.extensionSchemaVersion); diff --git a/src/core/backends/IRadioBackend.h b/src/core/backends/IRadioBackend.h index 7014d6f6cf..973a6ab87c 100644 --- a/src/core/backends/IRadioBackend.h +++ b/src/core/backends/IRadioBackend.h @@ -590,9 +590,10 @@ class IRadioBackend : public QObject { // rfpower drop notice. virtual void setTxPower(int percent) { Q_UNUSED(percent); } - // TUNE power (0..100) changed while the TUNE carrier may be up. NEVER keys: - // a backend generating its own carrier re-applies drive to one in progress - // and otherwise does nothing, since the next setTune() carries the value. + // TUNE power (0..100) changed: unkeyed, or on a carrier this client keyed. + // NEVER keys: a backend generating its own carrier re-applies drive to one in + // progress; otherwise it may only record the value (HL2 keeps it per band), + // since the next setTune() carries it. // Owner thread, like setTxPower(). Declaring // TransmitDriveControl::tunePowerAppliesLive promises it is implemented. virtual void setTunePower(int percent) { Q_UNUSED(percent); } diff --git a/src/core/backends/RadioCapabilities.h b/src/core/backends/RadioCapabilities.h index a2add30c08..eb86c591f3 100644 --- a/src/core/backends/RadioCapabilities.h +++ b/src/core/backends/RadioCapabilities.h @@ -485,6 +485,10 @@ struct RadioCapabilities { // The radio's own receive output level. Not the PC sink's, which is // app-global and stays the flat MasterVolume key. ReceiveOutputLevel = 1u << 9, + // Each receiver's own setpoints (blanker, APF, squelch, RIT/XIT, audio, + // pan centre) and how many receivers run; the backend replays the set. + Receivers = 1u << 10, + Notches = 1u << 11, // host-side manual notches (centre, width, active) }; Q_DECLARE_FLAGS(ClientSettingsDomains, ClientSettingsDomain) ClientSettingsDomains clientSettingsDomains; // default: empty — restore nothing diff --git a/src/core/backends/hl2/Hl2Backend.cpp b/src/core/backends/hl2/Hl2Backend.cpp index feedcf2c73..e0a2275aeb 100644 --- a/src/core/backends/hl2/Hl2Backend.cpp +++ b/src/core/backends/hl2/Hl2Backend.cpp @@ -3,6 +3,7 @@ #include "core/backends/hl2/Hl2Bands.h" #include "core/backends/hl2/Hl2ModeVocabulary.h" +#include #include #include @@ -234,6 +235,24 @@ std::pair defaultPassbandForMode(const QString& mode) noexcept return {150, 3000}; // matches modeFromString's USB fallback } +// The tuning range capabilities() declares; a remembered frequency, pan centre +// or notch outside it is dropped. +constexpr double kTuningMinHz = 100'000.0; +constexpr double kTuningMaxHz = 38'400'000.0; + +[[nodiscard]] bool restorableRfHz(double hz) noexcept +{ + return hz >= kTuningMinHz && hz <= kTuningMaxHz; +} + +// A remembered passband is kept only as a sane pair inside the 12 kHz audio +// ceiling; a CW pair must also contain the carrier (pre-#4914 pairs do not). +[[nodiscard]] bool restorablePassband(double lowHz, double highHz, bool cw) noexcept +{ + return lowHz < highHz && lowHz >= -12'000.0 && highHz <= 12'000.0 + && (!cw || (lowHz < 0.0 && highHz > 0.0)); +} + // The CW BFO offset for `mode`, in Hz of audio: where a signal on the marker // should come out. Positive for upper-sideband CW, negative for lower, zero // otherwise. WDSP's detector has no BFO (the NBP edges select the sideband), @@ -435,6 +454,9 @@ Hl2Backend::Hl2Backend(QObject* parent) : IRadioBackend(parent) pushInitialState(); emitAllSliceState(); defineMeters(); + // Once per connect, after receiver A is published: the remembered + // notches and receivers B.. come back through the operator's own paths. + replayRestoredSession(); // Tell the IO board where we came up. applyBandFilter() is NOT called on // this path — the connect-time filter byte is primed straight into // MetisClient::Params instead — so without this the board would hold @@ -959,6 +981,24 @@ void Hl2Backend::announceReceiverCeilingRevision() } bool Hl2Backend::createPanadapter() +{ + // The operator is reshaping the set; a tail the last replay kept is theirs + // to rebuild, not to have resurrected. + m_replayUnreopened.clear(); + // A new receiver starts where the first one is, so its pan is not of DC. + Receiver seed; + if (!m_rx.empty()) { + const Receiver& first = m_rx.front(); + seed.sliceFreqHz = first.sliceFreqHz; + seed.ncoHz = first.ncoHz; + seed.mode = first.mode; + seed.filterLowHz = first.filterLowHz; + seed.filterHighHz = first.filterHighHz; + } + return addReceiver(seed); +} + +bool Hl2Backend::addReceiver(const Receiver& requested) { if (!m_connected) { qCWarning(lcHl2) << "HL2: cannot add a receiver before the radio is connected"; @@ -981,17 +1021,15 @@ bool Hl2Backend::createPanadapter() const int ddc = m_ids.append(); - Receiver seed; + Receiver seed = requested; + // AGC is flat: every receiver runs the first one's (seedReceiverAgc). if (!m_rx.empty()) { const Receiver& first = m_rx.front(); - seed.sliceFreqHz = first.sliceFreqHz; - seed.ncoHz = first.ncoHz; - seed.mode = first.mode; - seed.filterLowHz = first.filterLowHz; - seed.filterHighHz = first.filterHighHz; seed.agcMode = first.agcMode; seed.agcModeBeforeDigital = first.agcModeBeforeDigital; seed.agcThresholdDb = first.agcThresholdDb; + // A data mode runs AGC off over the held mode, as a mode click does. + followModeAgc(seed, first.mode); } // The AGC-off level is this receiver's own: the one remembered for its // index, else the default. It is not copied from the first receiver. @@ -1039,7 +1077,7 @@ bool Hl2Backend::createPanadapter() publishWideState(); startReceiverDspBuild(opened ? opened->uiNumber : -1); - + notifyOperatingStateChanged(); return true; } @@ -1272,6 +1310,7 @@ bool Hl2Backend::removePanadapter(const QString& panId) qCWarning(lcHl2) << "HL2: refusing to close the last receiver"; return false; } + m_replayUnreopened.clear(); // see createPanadapter() // m_txDdc and the active DDC are indices, and removal renumbers every // index after the closed one. A role on the closing receiver must move; a // role after it must shift down, or it names the wrong receiver. @@ -1366,6 +1405,7 @@ bool Hl2Backend::removePanadapter(const QString& panId) } // The TX slice may have moved; republish so the indicator follows. emitAllSliceState(); + notifyOperatingStateChanged(); return true; } @@ -1808,8 +1848,8 @@ RadioCapabilities Hl2Backend::capabilities() const // d101: the loop settles, 0.307 dB in 74 s quiescent). // Tuning range: the AD9866 samples at 76.8 MHz, so the first Nyquist zone is // DC to 38.4 MHz; below 100 kHz the input transformer rolls off. - c.tuningMinHz = 100'000.0; - c.tuningMaxHz = 38'400'000.0; + c.tuningMinHz = kTuningMinHz; + c.tuningMaxHz = kTuningMaxHz; c.sliceFrequencyControl = {SliceFrequencyControl::Authority::Engine, 100'000, 38'400'000}; // Modes the headless receive path accepts. ModelReceiveControlTarget checks @@ -1890,8 +1930,9 @@ RadioCapabilities Hl2Backend::capabilities() const c.hasDdcPanEdgeRolloff = false; // The panadapter is averaged here, in Hl2Spectrum, per the operator's FFT // AVG (setPanAverage). SpectrumWidget skips its own fixed SMOOTH_ALPHA EMA - // while this is set, so the two never stack (RFC #5782). - c.backendPanAveraging = BackendPanAveraging{kMsPerAverageStep, false, {}, {}}; + // while this is set, so the two never stack (RFC #5782). The client keeps + // the setting per radio: Hl2Spectrum starts at none on every connect. + c.backendPanAveraging = BackendPanAveraging{kMsPerAverageStep, true, {}, {}}; // The pan's frame rate is paced on this host and the radio stores no // display state: the client is the only memory FFT FPS has. c.panFrameRateShaping = PanFrameRateShaping{/*clientPersistsFrameRate*/ true}; @@ -2057,7 +2098,11 @@ RadioCapabilities Hl2Backend::capabilities() const // completeness, and Memories stays out of // RadioStateMemory's ext gate (one domain, one // document — RFC #4603 PR 6). - | RadioCapabilities::ClientSettingsDomain::Memories; + | RadioCapabilities::ClientSettingsDomain::Memories + // Receiver B.. and every receiver's own setpoints, + // and the notches: WDSP on this host holds them all. + | RadioCapabilities::ClientSettingsDomain::Receivers + | RadioCapabilities::ClientSettingsDomain::Notches; // (extensionNamespaces is declared above, with the freqcal/nb verbs it // names — an earlier revision of this comment claimed none existed.) return c; @@ -2140,14 +2185,13 @@ void Hl2Backend::connectRadio(const RadioConnectRequest& request) << "cl1=" << m_hw.cl1RefClock << "atuGateware=" << m_hw.atuGateware; - // Notches are SESSION state, and the same call is true on both sides of the - // seam: RadioModel::onDisconnected() calls m_tnfModel.clear(), and a - // same-family reconnect rebuilds no backend, so anything kept here would be - // replayed into WDSP by seedNotches() with nothing on screen naming it — - // audible nulls with no marker to right-click and no id to remove them by. + // RadioModel::onDisconnected() clears its notches, so none may survive here + // unannounced: they would be nulls with no marker. The remembered ones come + // back through replayRestoredSession(), which reports each with a new id. // m_nextNotchId deliberately keeps counting: an id is never reused. m_notches.clear(); m_notchesEnabled = true; + m_sessionReplayPending = true; // The span the operator last chose, snapped to a rate we can actually run // and to the current low-bandwidth ceiling. Applied BEFORE the explicit @@ -2226,14 +2270,15 @@ void Hl2Backend::connectRadio(const RadioConnectRequest& request) delta.rfPower = drive; emit transmitChanged(delta); } + applyTuneMemoryFor(m_currentBandKey); } // Receiver count: the smallest of what the operator asked for, what the // board has (discovery byte 0x13: hl2b5up_main reports 4, skimmer variants // 9-12 with no TX), and what the link carries at this rate (4 RX at 384 // kHz is ~89 Mbit/s and drops packets). Connect always starts with one; - // more come from "Add Panadapter". `numRx` is only an explicit connect - // param, for automation. + // more come from "Add Panadapter" or replayRestoredSession() after link-up. + // `numRx` is only an explicit connect param, for automation. m_requestedNumRx = 1; if (request.params.contains(QStringLiteral("numRx"))) m_requestedNumRx = request.params.value(QStringLiteral("numRx")).toInt(); @@ -2363,6 +2408,10 @@ void Hl2Backend::connectRadio(const RadioConnectRequest& request) if (request.serial != m_agcSeededSerial || !m_rxCarriedState) { m_agcSeededSerial = request.serial; seedReceiverAgc(); + seedOpenReceiversFromMemory(); + if (!m_rx.empty()) { + mp.rxFrequencyHz = ncoCommandHz(m_rx.front().ncoHz); + } } Hl2RxDsp::Config dc; @@ -3128,6 +3177,7 @@ void Hl2Backend::setSliceNoiseBlanker(int sliceId, AetherSDR::NoiseBlankerKind k r->nbLevel = qBound(0, level, 100); r->nbFill = fill; pushNoiseBlanker(*r); + notifyOperatingStateChanged(); } ReceiveDispatch Hl2Backend::requestSliceDsp(int sliceId, const SliceDspRequest& request) @@ -3183,6 +3233,7 @@ void Hl2Backend::setSliceApf(int sliceId, bool on, int level) r->apfLevel = qBound(0, level, 100); pushApf(*r); emitSliceState(ddc); + notifyOperatingStateChanged(); } void Hl2Backend::requestSliceAgc(int sliceId, const SliceAgcRequest& request) @@ -3246,6 +3297,7 @@ void Hl2Backend::setSliceSquelch(int sliceId, bool on, int level) r->squelchLevel = qBound(0, level, 100); pushSquelch(*r); emitSliceState(ddc); + notifyOperatingStateChanged(); } void Hl2Backend::setSliceAudioMute(int sliceId, bool mute) @@ -3278,6 +3330,7 @@ void Hl2Backend::setSliceAudioGain(int sliceId, int gainPercent) } r->audioGain = scaled; emitSliceState(ddcForSlice(sliceId)); + notifyOperatingStateChanged(); } void Hl2Backend::setSliceAudioPan(int sliceId, int panPercent) @@ -3285,7 +3338,12 @@ void Hl2Backend::setSliceAudioPan(int sliceId, int panPercent) Receiver* r = rx(ddcForSlice(sliceId)); if (!r) return; - r->audioPanPercent = std::clamp(panPercent, 0, 100); + const int clamped = std::clamp(panPercent, 0, 100); + if (clamped == r->audioPanPercent) { + return; + } + r->audioPanPercent = clamped; + notifyOperatingStateChanged(); } void Hl2Backend::setActiveSlice(int sliceId) @@ -3363,6 +3421,7 @@ void Hl2Backend::setTxSlice(int sliceId) // whichever was selected. emitSliceState(previous); emitSliceState(ddc); + notifyOperatingStateChanged(); } // Manual notch filters: WDSP notches on this host that behave like a Flex TNF @@ -3499,6 +3558,7 @@ void Hl2Backend::createNotch(double centerHz, double widthHz) delta.widthHz = notch.widthHz; delta.active = notch.active; emit notchChanged(notch.id, delta); + notifyOperatingStateChanged(); } void Hl2Backend::setNotch(int notchId, const AetherSDR::NotchDelta& delta) @@ -3541,6 +3601,7 @@ void Hl2Backend::setNotch(int notchId, const AetherSDR::NotchDelta& delta) applied.widthHz = notch.widthHz; applied.active = notch.active; emit notchChanged(notch.id, applied); + notifyOperatingStateChanged(); } void Hl2Backend::removeNotch(int notchId) @@ -3558,6 +3619,7 @@ void Hl2Backend::removeNotch(int notchId) Q_ARG(int, index)); } emit notchRemoved(notchId); + notifyOperatingStateChanged(); } void Hl2Backend::setNotchesEnabled(bool on) @@ -3609,6 +3671,7 @@ void Hl2Backend::setPanCenter(const QString& panId, double hz, PanCenterIntent) applyBandFilter("pan"); publishWideState(); emitPanState(ddc); + notifyOperatingStateChanged(); } void Hl2Backend::setPanBandwidth(const QString& panId, double hz) @@ -4613,6 +4676,7 @@ void Hl2Backend::setSliceRitEnabled(int sliceId, bool on) } else { emitSliceState(ddc); } + notifyOperatingStateChanged(); } void Hl2Backend::setSliceRitOffset(int sliceId, int hz) @@ -4634,6 +4698,7 @@ void Hl2Backend::setSliceRitOffset(int sliceId, int hz) } else { emitSliceState(ddc); } + notifyOperatingStateChanged(); } void Hl2Backend::setSliceXitEnabled(int sliceId, bool on) @@ -4649,6 +4714,7 @@ void Hl2Backend::setSliceXitEnabled(int sliceId, bool on) setTxFrequency(r->sliceFreqHz); } emitSliceState(ddc); + notifyOperatingStateChanged(); } void Hl2Backend::setSliceXitOffset(int sliceId, int hz) @@ -4669,6 +4735,7 @@ void Hl2Backend::setSliceXitOffset(int sliceId, int hz) setTxFrequency(r->sliceFreqHz); } emitSliceState(ddc); + notifyOperatingStateChanged(); } void Hl2Backend::setCwPitch(int hz) @@ -5106,6 +5173,9 @@ void Hl2Backend::setTune(bool on, int tunePowerPercent, const AetherSDR::TxCoord // Straight to the drive register rather than through setTxPower(), which // would overwrite the saved RF power we have to restore on release. if (tunePowerPercent >= 0) { + if (recordTunePower(tunePowerPercent)) { + notifyOperatingStateChanged(); + } applyDrive(tunePowerPercent); m_rfDriveOwed = true; } @@ -5115,13 +5185,20 @@ void Hl2Backend::setTune(bool on, int tunePowerPercent, const AetherSDR::TxCoord } else { setKeying(false, operation, completion); // clears m_tuning and restores the operator's RF power setTxTestTone(0.0, 0.0, operation); + // A band changed under the carrier held its TUNE power back + // (applyTuneMemoryFor); with the carrier down it applies now. + applyTuneMemoryFor(m_currentBandKey); } } -// Drive only, straight to the register: m_rfPowerPercent stays the value the -// unkey restores. A carrier whose admission has lapsed takes no drive change. +// Recorded for the band whether or not a carrier is up. Drive only, straight +// to the register: m_rfPowerPercent stays the value the unkey restores. A +// carrier whose admission has lapsed takes no drive change. void Hl2Backend::setTunePower(int percent) { + if (recordTunePower(percent)) { + notifyOperatingStateChanged(); + } if (!m_tuning || !TxCoordinator::Command{m_tuneOperation, true}.permitsDispatch(TxCoordinator::monotonicMs())) { return; @@ -5130,6 +5207,38 @@ void Hl2Backend::setTunePower(int percent) m_rfDriveOwed = true; } +bool Hl2Backend::recordTunePower(int percent) +{ + const int clamped = std::clamp(percent, 0, 100); + if (clamped == m_tunePowerPercent) { + return false; + } + m_tunePowerPercent = clamped; + if (!m_currentBandKey.isEmpty()) { + m_tuneByBand.insert(m_currentBandKey, clamped); + } + // First-set-wins baseline for bands never tuned, as for drive. + if (m_tuneDefaultPercent < 0) { + m_tuneDefaultPercent = clamped; + } + return true; +} + +void Hl2Backend::applyTuneMemoryFor(const QString& bandKey) +{ + // Unlike drive there is no safe floor to invent: with nothing remembered + // TransmitModel's TUNE power stands, and the next operator change records it. + // Not under a carrier: the drive it runs at is the one the slider shows. + const int tune = m_tuneByBand.value(bandKey, m_tuneDefaultPercent); + if (m_tuning || tune < 0 || tune == m_tunePowerPercent) { + return; + } + m_tunePowerPercent = tune; + TransmitDelta delta; + delta.tunePower = tune; + emit transmitChanged(delta); +} + // The radio keeps radiating after the MOX-off frame: keyed IQ already in its TX // FIFO drains (tx_buffer_latency, radio.v:934) and the last sample is held for // ptt_hang_time (radio.v:935, 1036-1047), while drive applies at once @@ -6571,9 +6680,18 @@ void Hl2Backend::applyRestoredState(const RestoredRadioState& state) // lambda would surface radio A's number as radio B's. m_autoRfGainRefusal.clear(); m_autoGainState = AetherSDR::hl2::AutoGainState{}; - // The same call the constructor makes. Neither the law nor the floor is - // persisted: both are the constructed default after every connect. + // The same call the constructor makes; a chosen law and floor are read back + // from rfGain below. installDefaultAutoGainLaw(); + m_autoGainLawChosen = false; + m_autoGainFloorChosen = false; + m_tuneByBand.clear(); + m_tuneDefaultPercent = -1; + m_tunePowerPercent = -1; + m_restoredReceivers.clear(); + m_replayUnreopened.clear(); + m_restoredTxReceiver = 0; + m_restoredNotches.clear(); m_autoGainReason = AetherSDR::hl2::AutoGainReason::Disarmed; m_autoGainBandKey.clear(); m_autoGainBaselineDb = 0; @@ -6620,7 +6738,7 @@ void Hl2Backend::applyRestoredState(const RestoredRadioState& state) // that does rebuild gets a fresh pair via RadioModel::setupBackend(). RestoredRadioState valid; - if (state.rfFrequencyHz >= 100'000.0 && state.rfFrequencyHz <= 38'400'000.0) + if (restorableRfHz(state.rfFrequencyHz)) valid.rfFrequencyHz = state.rfFrequencyHz; // Accept, then canonicalise to the spelling the menu offers (e.g. NFM -> // FM), so the mode combo can find the restored mode. Alias pairs share one @@ -6631,8 +6749,7 @@ void Hl2Backend::applyRestoredState(const RestoredRadioState& state) valid.mode = canonicalOfferedMode(state.mode); // A passband is kept only as a sane pair; mode+passband are applied // together in pushInitialState() (the #4484 reconciliation). - if (state.filterLowHz < state.filterHighHz - && state.filterLowHz >= -12'000.0 && state.filterHighHz <= 12'000.0) + if (restorablePassband(state.filterLowHz, state.filterHighHz, /*cw=*/false)) { valid.filterLowHz = state.filterLowHz; valid.filterHighHz = state.filterHighHz; @@ -6700,6 +6817,25 @@ void Hl2Backend::applyRestoredState(const RestoredRadioState& state) m_lnaDbByBand.insert(it.key(), qBound(kLnaGainMinDb, it.value().toInt(), kLnaGainMaxDb)); + // The law first: installing one installs its floor, which a chosen floor + // then overrides. An unknown law or an out-of-range floor is dropped. + if (const QString law = rfGain.value(QStringLiteral("autoLaw")).toString(); + !law.isEmpty()) { + if (!setAutoRfGainMode(law)) { + qCInfo(lcHl2) << "HL2 restore: dropping unknown auto RF gain law" << law; + } + } + if (const QJsonValue floor = rfGain.value(QStringLiteral("autoFloorDb")); + floor.isDouble()) { + const int floorDb = floor.toInt(-1); + if (floorDb >= 0 && floorDb <= kAutoRfGainFloorMaxDb) { + m_autoGainConfig.maxOffsetDb = floorDb; + m_autoGainFloorChosen = true; + } else { + qCInfo(lcHl2) << "HL2 restore: dropping out-of-range auto RF gain floor" + << floor.toVariant(); + } + } const QJsonObject txSetpoints = state.extension.value(QStringLiteral("txSetpoints")).toObject(); @@ -6710,6 +6846,13 @@ void Hl2Backend::applyRestoredState(const RestoredRadioState& state) txSetpoints.value(QStringLiteral("driveByBand")).toObject(); for (auto it = driveByBand.constBegin(); it != driveByBand.constEnd(); ++it) m_driveByBand.insert(it.key(), qBound(0, it.value().toInt(), 100)); + if (txSetpoints.contains(QStringLiteral("tuneDefaultPercent"))) + m_tuneDefaultPercent = qBound( + 0, txSetpoints.value(QStringLiteral("tuneDefaultPercent")).toInt(), 100); + const QJsonObject tuneByBand = + txSetpoints.value(QStringLiteral("tuneByBand")).toObject(); + for (auto it = tuneByBand.constBegin(); it != tuneByBand.constEnd(); ++it) + m_tuneByBand.insert(it.key(), qBound(0, it.value().toInt(), 100)); // Mic level is dropped, not clamped, when out of range: the floor is mute, // so a clamped bad value would silently take the operator off the air. @@ -6744,6 +6887,51 @@ void Hl2Backend::applyRestoredState(const RestoredRadioState& state) } } + // Every receiver's own state, receiver A first. Entries are validated one + // by one; a bad entry ends the list there, so the receivers after it do not + // move up into its slot. + const QJsonArray receivers = + state.extension.value(QStringLiteral("receivers")).toArray(); + for (qsizetype i = 0; i < receivers.size() && i < kMaxReceivers; ++i) { + const std::optional r = receiverFromMemory(receivers.at(i).toObject()); + if (!r) { + qCInfo(lcHl2) << "HL2 restore: dropping remembered receiver" << i + << "and the ones after it"; + break; + } + m_restoredReceivers.push_back(*r); + if (receivers.at(i).toObject().value(QStringLiteral("transmit")).toBool(false)) + m_restoredTxReceiver = static_cast(i); + } + // Receiver A's entry, not the flat fields: those follow the transmit + // receiver, and transmit comes back to its own receiver after the replay. + if (!m_restoredReceivers.empty()) { + const Receiver& first = m_restoredReceivers.front(); + valid.rfFrequencyHz = first.sliceFreqHz; + valid.mode = first.mode; + valid.filterLowHz = first.filterLowHz; + valid.filterHighHz = first.filterHighHz; + } + const QJsonArray notches = + state.extension.value(QStringLiteral("notches")).toArray(); + const int maxNotches = capabilities().maxNotchFilters; + for (const QJsonValue& value : notches) { + const QJsonObject o = value.toObject(); + NotchRecord notch; + notch.centerHz = o.value(QStringLiteral("centerHz")).toDouble(); + notch.widthHz = o.value(QStringLiteral("widthHz")).toDouble(); + notch.active = o.value(QStringLiteral("active")).toBool(true); + if (!restorableRfHz(notch.centerHz) + || !(notch.widthHz > 0.0 && notch.widthHz <= 12'000.0)) { + qCInfo(lcHl2) << "HL2 restore: dropping invalid notch" << o; + continue; + } + if (static_cast(m_restoredNotches.size()) >= maxNotches) { + break; + } + m_restoredNotches.push_back(notch); + } + m_restoredState = valid; m_haveRestoredState = true; // Capture-side AGC pair only, reset so a same-family swap cannot write @@ -6766,7 +6954,9 @@ void Hl2Backend::applyRestoredState(const RestoredRadioState& state) << valid.filterHighHz << "rate" << valid.sampleRateHz << "agc" << valid.agcMode << valid.agcThreshold << "lna bands" << m_lnaDbByBand.size() << "drive bands" - << m_driveByBand.size(); + << m_driveByBand.size() << "tune bands" << m_tuneByBand.size() + << "receivers" << m_restoredReceivers.size() + << "notches" << m_restoredNotches.size(); } // Seeds every receiver's AGC pair (unlike mode/passband, which are per-slice): @@ -6808,6 +6998,185 @@ void Hl2Backend::seedReceiverAgc() m_agcOffLevelsLive = true; } +QJsonObject Hl2Backend::receiverMemoryJson(const Receiver& r) +{ + // No AGC here: it is captured flat (seedReceiverAgc), not per receiver. + return QJsonObject{ + {QStringLiteral("freqHz"), r.sliceFreqHz}, + {QStringLiteral("ncoHz"), r.ncoHz}, + {QStringLiteral("mode"), r.mode}, + {QStringLiteral("filterLowHz"), r.filterLowHz}, + {QStringLiteral("filterHighHz"), r.filterHighHz}, + {QStringLiteral("nbKind"), static_cast(r.nbKind)}, + {QStringLiteral("nbLevel"), r.nbLevel}, + {QStringLiteral("nbFill"), static_cast(r.nbFill)}, + {QStringLiteral("apfOn"), r.apfOn}, + {QStringLiteral("apfLevel"), r.apfLevel}, + {QStringLiteral("squelchOn"), r.squelchOn}, + {QStringLiteral("squelchLevel"), r.squelchLevel}, + {QStringLiteral("ritOn"), r.ritOn}, + {QStringLiteral("ritHz"), r.ritHz}, + {QStringLiteral("xitOn"), r.xitOn}, + {QStringLiteral("xitHz"), r.xitHz}, + {QStringLiteral("audioGain"), qRound(r.audioGain * 100.0f)}, + {QStringLiteral("audioPan"), r.audioPanPercent}, + }; +} + +std::optional Hl2Backend::receiverFromMemory(const QJsonObject& o) const +{ + // Operator data from disk: each field is checked and a bad one is dropped + // to the default, never clamped into a value nobody chose. + const double freqHz = o.value(QStringLiteral("freqHz")).toDouble(); + const QString mode = o.value(QStringLiteral("mode")).toString(); + if (!restorableRfHz(freqHz) || !isKnownModeString(mode)) { + return std::nullopt; + } + Receiver r; + r.sliceFreqHz = freqHz; + r.ncoHz = freqHz; + r.mode = canonicalOfferedMode(mode); + const auto [defaultLo, defaultHi] = defaultPassbandForMode(r.mode); + r.filterLowHz = defaultLo; + r.filterHighHz = defaultHi; + const int lo = o.value(QStringLiteral("filterLowHz")).toInt(defaultLo); + const int hi = o.value(QStringLiteral("filterHighHz")).toInt(defaultHi); + if (restorablePassband(lo, hi, cwBfoOffsetHz(r.mode, m_cwPitchHz) != 0.0)) { + r.filterLowHz = lo; + r.filterHighHz = hi; + } + const double ncoHz = o.value(QStringLiteral("ncoHz")).toDouble(); + if (restorableRfHz(ncoHz)) { + r.ncoHz = ncoHz; + } + auto percent = [&o](const char* key, int fallback) { + const int v = o.value(QLatin1String(key)).toInt(-1); + return (v >= 0 && v <= 100) ? v : fallback; + }; + auto flag = [&o](const char* key, bool fallback) { + const QJsonValue v = o.value(QLatin1String(key)); + return v.isBool() ? v.toBool() : fallback; + }; + auto offset = [&o](const char* key) { + const int v = o.value(QLatin1String(key)).toInt(0); + return std::abs(v) <= kRitXitMaxHz ? v : 0; + }; + if (const int kind = o.value(QStringLiteral("nbKind")).toInt(-1); + isValidNoiseBlankerKind(kind)) { + r.nbKind = static_cast(kind); + } + if (const int fill = o.value(QStringLiteral("nbFill")).toInt(-1); + isValidNoiseBlankerFill(fill)) { + r.nbFill = static_cast(fill); + } + r.nbLevel = percent("nbLevel", r.nbLevel); + r.apfOn = flag("apfOn", r.apfOn); + r.apfLevel = percent("apfLevel", r.apfLevel); + r.squelchOn = flag("squelchOn", r.squelchOn); + r.squelchLevel = percent("squelchLevel", r.squelchLevel); + r.ritOn = flag("ritOn", false); + r.ritHz = offset("ritHz"); + r.xitOn = flag("xitOn", false); + r.xitHz = offset("xitHz"); + r.audioGain = static_cast(percent("audioGain", 100)) / 100.0f; + r.audioPanPercent = percent("audioPan", r.audioPanPercent); + r.nbEchoPending = true; + return r; +} + +void Hl2Backend::seedOpenReceiversFromMemory() +{ + const std::size_t count = std::min(m_rx.size(), m_restoredReceivers.size()); + for (std::size_t i = 0; i < count; ++i) { + Receiver& r = m_rx[i]; + const Receiver& memory = m_restoredReceivers[i]; + // Receiver A's frequency, mode and passband came in through the flat + // fields, taken from this same entry; the others take their own. + if (i > 0) { + const QString previousMode = r.mode; + r.sliceFreqHz = memory.sliceFreqHz; + r.mode = memory.mode; + r.filterLowHz = memory.filterLowHz; + r.filterHighHz = memory.filterHighHz; + followModeAgc(r, previousMode); + } + seedReceiverExtras(r, memory); + } +} + +void Hl2Backend::seedReceiverExtras(Receiver& r, const Receiver& memory) const +{ + r.nbKind = memory.nbKind; + r.nbLevel = memory.nbLevel; + r.nbFill = memory.nbFill; + r.apfOn = memory.apfOn; + r.apfLevel = memory.apfLevel; + r.squelchOn = memory.squelchOn; + r.squelchLevel = memory.squelchLevel; + r.ritOn = memory.ritOn; + r.ritHz = memory.ritHz; + r.xitOn = memory.xitOn; + r.xitHz = memory.xitHz; + r.audioGain = memory.audioGain; + r.audioPanPercent = memory.audioPanPercent; + r.nbEchoPending = true; + // The pan centre only where the receiver still listens inside it. + const double usableHz = m_sampleRateHz / 2.0 * kUsablePassbandFraction; + if (std::abs(rxTunedHz(r) - memory.ncoHz) <= usableHz) { + r.ncoHz = memory.ncoHz; + } else { + r.ncoHz = rxTunedHz(r); + } +} + +void Hl2Backend::replayRestoredSession() +{ + if (!m_sessionReplayPending) { + return; + } + m_sessionReplayPending = false; + // Notches first: a receiver added below is seeded with them when it opens. + // createNotch() mints the ids and reports each one, which is what puts the + // markers back on the panadapter. + for (const NotchRecord& notch : m_restoredNotches) { + const int before = m_nextNotchId; + createNotch(notch.centerHz, notch.widthHz); + if (!notch.active && m_nextNotchId != before) { + AetherSDR::NotchDelta delta; + delta.active = false; + setNotch(before, delta); + } + } + // Receivers B..: each through the same add the operator uses, so the + // receiver ceiling at this span still decides how many come back. + // From the count already running: an explicit numRx connect param may have + // opened more than receiver A, and those take their slots in the list. + const double usableHz = m_sampleRateHz / 2.0 * kUsablePassbandFraction; + m_replayUnreopened.clear(); + for (std::size_t i = m_rx.size(); i < m_restoredReceivers.size(); ++i) { + Receiver seed = m_restoredReceivers[i]; + if (std::abs(rxTunedHz(seed) - seed.ncoHz) > usableHz) { + seed.ncoHz = rxTunedHz(seed); + } + if (!addReceiver(seed)) { + // Kept, not dropped: the capture this replay triggers would + // otherwise erase them, and a later connect may have room. + m_replayUnreopened.assign(m_restoredReceivers.begin() + + static_cast(i), + m_restoredReceivers.end()); + qCInfo(lcHl2) << "HL2 restore:" << m_replayUnreopened.size() + << "remembered receiver(s) not reopened; kept for the next connect"; + break; + } + } + // Transmit back on the receiver that held it. Ownership only, never a key; + // setTxSlice() moves the TX NCO and the band memory with it. + if (m_restoredTxReceiver > 0) { + if (const auto* ids = m_ids.byDdc(m_restoredTxReceiver)) + setTxSlice(ids->uiNumber); + } +} + RestoredRadioState Hl2Backend::currentOperatingState() const { RestoredRadioState state; @@ -6875,9 +7244,22 @@ RestoredRadioState Hl2Backend::currentOperatingState() const // they differ when arming was declined, and persisting // false then would stop it ever arming again. {QStringLiteral("autoEnabled"), m_autoRfGainWanted}}; + if (m_autoGainLawChosen) { + rfGain.insert(QStringLiteral("autoLaw"), m_autoGainMode); + } + if (m_autoGainFloorChosen) { + rfGain.insert(QStringLiteral("autoFloorDb"), m_autoGainConfig.maxOffsetDb); + } QJsonObject txSetpoints{{QStringLiteral("driveByBand"), driveByBand}}; if (m_driveDefaultPercent >= 0) txSetpoints.insert(QStringLiteral("defaultPercent"), m_driveDefaultPercent); + QJsonObject tuneByBand; + for (auto it = m_tuneByBand.constBegin(); it != m_tuneByBand.constEnd(); ++it) + tuneByBand.insert(it.key(), it.value()); + if (!tuneByBand.isEmpty()) + txSetpoints.insert(QStringLiteral("tuneByBand"), tuneByBand); + if (m_tuneDefaultPercent >= 0) + txSetpoints.insert(QStringLiteral("tuneDefaultPercent"), m_tuneDefaultPercent); // The TX passband is flat, not per band or mode: it is one pair of // sliders, and per-band memory would move them on their own. Written only @@ -6902,8 +7284,38 @@ RestoredRadioState Hl2Backend::currentOperatingState() const // to end — and written as a NUMBER rather than a build string, so the next // change to the mapping is a comparison rather than a table of versions. txSetpoints.insert(QStringLiteral("micLevelCurve"), kMicLevelCurve); + // Receivers in DDC order, so A is first and a closed one leaves no gap. + QJsonArray receivers; + for (std::size_t i = 0; i < m_rx.size(); ++i) { + QJsonObject entry = receiverMemoryJson(m_rx[i]); + if (static_cast(i) == m_txDdc) + entry.insert(QStringLiteral("transmit"), true); + receivers.append(entry); + } + for (const Receiver& r : m_replayUnreopened) + receivers.append(receiverMemoryJson(r)); + // Until the replay has run, the remembered notches are still the truth; + // store() rebuilds the document, so leaving them out would erase them. + QJsonArray notches; + for (const NotchRecord& notch : m_sessionReplayPending ? m_restoredNotches : m_notches) { + notches.append(QJsonObject{{QStringLiteral("centerHz"), notch.centerHz}, + {QStringLiteral("widthHz"), notch.widthHz}, + {QStringLiteral("active"), notch.active}}); + } + // Same for the receivers while none are running or B.. are not yet back. + if (m_rx.empty() || m_sessionReplayPending) { + receivers = QJsonArray{}; + for (std::size_t i = 0; i < m_restoredReceivers.size(); ++i) { + QJsonObject entry = receiverMemoryJson(m_restoredReceivers[i]); + if (static_cast(i) == m_restoredTxReceiver) + entry.insert(QStringLiteral("transmit"), true); + receivers.append(entry); + } + } state.extension = QJsonObject{{QStringLiteral("rfGain"), rfGain}, - {QStringLiteral("txSetpoints"), txSetpoints}}; + {QStringLiteral("txSetpoints"), txSetpoints}, + {QStringLiteral("receivers"), receivers}, + {QStringLiteral("notches"), notches}}; state.extensionSchemaVersion = 1; return state; } @@ -7069,8 +7481,10 @@ void Hl2Backend::setAutoRfGainFloorDb(int floorDb) return; } m_autoGainConfig.maxOffsetDb = clamped; + m_autoGainFloorChosen = true; qCInfo(lcHl2) << "HL2 auto RF gain: floor set to" << clamped << "dB below the operator's baseline"; + notifyOperatingStateChanged(); } // The law a backend starts with, and the only place that says so: the @@ -7124,6 +7538,10 @@ bool Hl2Backend::setAutoRfGainMode(const QString& mode) } m_autoGainConfig = law.config; m_autoGainMode = law.name; + // The law installs its own floor, so an earlier floor choice is gone with it. + m_autoGainLawChosen = (m != QLatin1String("default")); + m_autoGainFloorChosen = false; + notifyOperatingStateChanged(); // A law that needs the wideband reading needs the stream that carries it. // Called unconditionally so that switching AWAY from bandscope mode also // releases the gate, rather than leaving it running for a law that ignores @@ -7329,10 +7747,11 @@ void Hl2Backend::applyPerBandStateFor(double freqHz, const char* reason) delta.rfPower = drive; emit transmitChanged(delta); } + applyTuneMemoryFor(newBand); qCInfo(lcHl2) << "HL2 band memory (" << reason << "):" << oldBand << "->" << newBand << "lna" << m_lnaGainDb << "dB drive" - << m_rfPowerPercent << '%'; + << m_rfPowerPercent << "% tune" << m_tunePowerPercent << '%'; notifyOperatingStateChanged(); } @@ -7411,6 +7830,12 @@ void Hl2Backend::pushInitialState() delta.rfPower = drive; emit transmitChanged(delta); } + // The start band's TUNE power, re-echoed for the same reason. + if (m_tunePowerPercent >= 0) { + TransmitDelta delta; + delta.tunePower = m_tunePowerPercent; + emit transmitChanged(delta); + } } m_passbandDerivedThisConnect = true; } @@ -7987,7 +8412,7 @@ void Hl2Backend::applyBandFilter(const char* reason) void Hl2Backend::emitSliceState(int ddc) { - const Receiver* r = rx(ddc); + Receiver* r = rx(ddc); const auto* ids = m_ids.byDdc(ddc); if (!r || !ids) return; @@ -8005,6 +8430,15 @@ void Hl2Backend::emitSliceState(int ddc) d.filterHigh = r->filterHighHz; d.audioGain = qRound(r->audioGain * 100.0f); d.audioMute = r->audioMuted; + d.audioPan = r->audioPanPercent; + // A restored blanker, once, so the NB control shows it. Not on every echo: + // SliceModel re-emits all three NB signals for each one it receives. + if (r->nbEchoPending) { + d.nbKind = r->nbKind; + d.nbLevel = r->nbLevel; + d.nbFill = r->nbFill; + r->nbEchoPending = false; + } // The AGC pair the DSP is running, so a restored AGC is visible (#4909). // Safe to echo: SliceModel::applyDelta() assigns these without emitting // agcCommandIssued. diff --git a/src/core/backends/hl2/Hl2Backend.h b/src/core/backends/hl2/Hl2Backend.h index 8a6d7155fe..471efc7318 100644 --- a/src/core/backends/hl2/Hl2Backend.h +++ b/src/core/backends/hl2/Hl2Backend.h @@ -6,6 +6,7 @@ #include "core/dsp/WdspSMeter.h" #include +#include #include #include #include @@ -215,9 +216,9 @@ class Hl2Backend : public IRadioBackend, public IAutoRfGainControl { // interval. What "bandscope" degenerates to without the measurement. // "binary" binaryHighLowConfig(): two-state per-band switch. // Same state machine, different numbers; selecting a mode installs its floor. - // "default" names the law a backend starts with. Neither law nor floor is - // persisted: every connect reinstalls the default (applyRestoredState). - // Returns false, changing nothing, on an unknown name. + // "default" names the law a backend starts with. A chosen law and floor are + // persisted per radio (rfGain); "default" clears the choice. Returns false, + // changing nothing, on an unknown name. bool setAutoRfGainMode(const QString& mode); // IAutoRfGainControl (AutoRfGainControl.h), thin forwarders. autoRfGainControl() @@ -300,6 +301,7 @@ class Hl2Backend : public IRadioBackend, public IAutoRfGainControl { // Fires link edges through MetisClient's signals and seeds the connect // baseline, so hl2_auto_gain_law_test reads the installed law without a radio. friend struct Hl2AutoGainLawTestAccess; + friend struct Hl2SessionMemoryTestAccess; void applyKeying(bool key, const TxCoordinator::Operation& operation, const TxCoordinator::Completion& completion, bool cwBreakIn); void invalidateTxDspConfiguration(); @@ -535,6 +537,8 @@ class Hl2Backend : public IRadioBackend, public IAutoRfGainControl { AetherSDR::NoiseBlankerKind nbKind = AetherSDR::NoiseBlankerKind::Off; int nbLevel = 50; AetherSDR::NoiseBlankerFill nbFill = AetherSDR::kDefaultNoiseBlankerFill; + // A restored blanker still to be published once (emitSliceState). + bool nbEchoPending = false; // APF request and AGC-off level, held like the blanker: nothing echoes // them and a fresh chain must be told again. Literal defaults match @@ -637,6 +641,28 @@ class Hl2Backend : public IRadioBackend, public IAutoRfGainControl { // Index of `notchId` in m_notches — which IS its WDSP handle — or -1. [[nodiscard]] int notchIndexFor(int notchId) const; + + // Session memory beyond the flat fields (Receivers and Notches domains), + // validated by applyRestoredState() and replayed once per connect by + // replayRestoredSession(). Index 0 is receiver A; ids in the notches are unused. + std::vector m_restoredReceivers; + std::vector m_restoredNotches; + int m_restoredTxReceiver = 0; // the list index that held transmit + // Remembered receivers the replay could not reopen (ceiling, failed open). + // Captured after the live ones so a short connect does not erase them; + // dropped once the operator adds or closes a receiver. + std::vector m_replayUnreopened; + bool m_sessionReplayPending = false; + void replayRestoredSession(); + // The open receivers' own setpoints from the document, before their DSPs + // open: receiver A's extras, and the whole entry for any a numRx param opened. + void seedOpenReceiversFromMemory(); + void seedReceiverExtras(Receiver& r, const Receiver& memory) const; + [[nodiscard]] static QJsonObject receiverMemoryJson(const Receiver& r); + // Nullopt for an entry with no usable frequency or mode; other bad fields drop alone. + [[nodiscard]] std::optional receiverFromMemory(const QJsonObject& o) const; + // createPanadapter() with the new receiver's state given rather than copied. + bool addReceiver(const Receiver& seed); // Push the whole notch set + tune frequency into a receiver added later. void seedNotches(const Receiver& r); // Re-point a receiver's notch axis at its current NCO; call wherever ncoHz moves. @@ -854,6 +880,10 @@ class Hl2Backend : public IRadioBackend, public IAutoRfGainControl { // is just numbers). No initialiser: installDefaultAutoGainLaw() sets name and // config together, so there is one copy of the default. QString m_autoGainMode; + // Whether the law and the floor are the operator's choice, so only a choice is + // persisted: a stored default would freeze it against a later build's default. + bool m_autoGainLawChosen = false; + bool m_autoGainFloorChosen = false; // A law is a name and its numbers, kept as one value so neither is installed // without the other. struct AutoGainLaw { @@ -1028,6 +1058,15 @@ class Hl2Backend : public IRadioBackend, public IAutoRfGainControl { // True while band-memory/restore code drives setTxPower(): only operator intent // bootstraps the baseline or records into the per-band map. bool m_applyingBandMemory = false; + // TUNE power per band, with the same first-set baseline rule as drive. -1 in + // m_tunePowerPercent: nothing known yet, TransmitModel's value stands. + QMap m_tuneByBand; + int m_tuneDefaultPercent = -1; + int m_tunePowerPercent = -1; + // Records an operator's TUNE power under the current band; false when unchanged. + bool recordTunePower(int percent); + // The band's remembered TUNE power, echoed to TransmitModel; nothing when unknown. + void applyTuneMemoryFor(const QString& bandKey); // The operator's TX passband and whether they have set one. // defaultTxPassbandForMode() is re-pushed on every mode set and TX-slice move diff --git a/src/gui/MainWindow_SwrSweep.cpp b/src/gui/MainWindow_SwrSweep.cpp index 2c96b33d8d..0cef8f606a 100644 --- a/src/gui/MainWindow_SwrSweep.cpp +++ b/src/gui/MainWindow_SwrSweep.cpp @@ -631,14 +631,16 @@ void MainWindow::finishSwrSweepAfterTuneStopped() return; if (m_radioModel.isConnected()) { + // TUNE power first, while still on the swept band: a radio that keeps + // TUNE power per band (HL2) records it under the band it is set on. + if (m_swrSweep.originalTunePower != m_swrSweep.sweepTunePower) + m_radioModel.transmitModel().setTunePower(m_swrSweep.originalTunePower); if (!m_swrSweep.preserveBandSwitchOnFinish) { if (auto* s = m_radioModel.slice(m_swrSweep.sliceId); s && m_swrSweep.originalFreqMhz > 0.0) { s->setFrequency(m_swrSweep.originalFreqMhz); } } - if (m_swrSweep.originalTunePower != m_swrSweep.sweepTunePower) - m_radioModel.transmitModel().setTunePower(m_swrSweep.originalTunePower); if (!m_swrSweep.preserveBandSwitchOnFinish && m_swrSweep.finalAborted && !m_swrSweep.panId.isEmpty() diff --git a/src/models/RadioModel.cpp b/src/models/RadioModel.cpp index 308d4134fb..88a2ceb12d 100644 --- a/src/models/RadioModel.cpp +++ b/src/models/RadioModel.cpp @@ -1942,7 +1942,8 @@ namespace { // setter is real (setters default to no-ops): // rfpower, tunepower canTransmit + transmitDriveControl (setTxPower; // tunepower rides setTune()'s tunePowerPercent at -// key-down; while TUNE is keyed it is routed only to a +// key-down and, unkeyed, setTunePower() where declared +// live; while TUNE is keyed it is routed only to a // live carrier (tuneCarrierLive()) whose backend // declares tunePowerAppliesLive) // miclevel canTransmit (setMicGain) @@ -2208,11 +2209,13 @@ RadioModel::RadioModel(QObject* parent) m_backend->setTxPower(percent); }); - // TUNE power to a carrier already up, on a backend that declares it live. - // Not keyed: setTune() carries the value at the next key-down. + // TUNE power to a backend that declares it live: applied to a carrier this + // client keyed, recorded while unkeyed (the HL2 remembers it per band). A + // TUNE decoded off the radio is not this client's carrier. Never keys; + // setTune() still carries the value at the next key-down. connect(&m_transmitModel, &TransmitModel::tunePowerCommandIssued, this, [this](int percent) { - if (!m_backend || !tuneCarrierLive()) { + if (!m_backend || (m_transmitModel.isTuning() && !tuneCarrierLive())) { return; } const RadioCapabilities caps = backendCapabilities(); diff --git a/tests/hl2_session_memory_test.cpp b/tests/hl2_session_memory_test.cpp new file mode 100644 index 0000000000..29d452a5c5 --- /dev/null +++ b/tests/hl2_session_memory_test.cpp @@ -0,0 +1,601 @@ +// What a Hermes-Lite 2 remembers across a restart beyond the flat operating +// state: TUNE power per band, a chosen auto-RF-gain law and floor, every +// receiver's own setpoints and the receiver set (#5777), and the manual +// notches. The radio stores none of these, so the client is its memory. +// +// Socket-free: the link edge is injected as in hl2_pan_create_async_test, so +// the replay runs through the production linkUp handler and the operator's add +// path. RadioStateMemory is driven against an isolated settings profile. + +#include "core/AppSettings.h" +#include "core/RadioSettingsScope.h" +#include "core/RadioStateMemory.h" +#include "core/backends/NotchDelta.h" +#include "core/backends/SliceDelta.h" +#include "core/backends/TransmitDelta.h" +#include "core/backends/hl2/Hl2Backend.h" +#include "core/backends/hl2/Hl2Receivers.h" +#include "core/backends/hl2/Hl2RxDsp.h" +#include "core/backends/hl2/MetisClient.h" + +#include "TestDspBuildWait.h" +#include "TestSettingsProfile.h" +#include "TxTestAuthority.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace AetherSDR::hl2 { + +struct Hl2SessionMemoryTestAccess { + // One configured receiver and a link edge, without connectRadio(). + static bool bringUp(Hl2Backend& backend, bool replayPending, int count = 1) + { + backend.m_boardMaxRx = Hl2Backend::kAssumedBoardMaxRx; + backend.buildReceivers(count); + backend.seedOpenReceiversFromMemory(); // as connectRadio() does + for (int ddc = 0; ddc < count; ++ddc) { + Hl2Backend::Receiver* const receiver = backend.rx(ddc); + if (!receiver || !receiver->dsp) { + return false; + } + Hl2RxDsp::Config config; + config.inputSampleRateHz = backend.m_rateLedger.committed(); + std::tie(config.filterLowHz, config.filterHighHz) = backend.dspFilterHz(*receiver); + bool configured = false; + int channelId = -1; + QMetaObject::invokeMethod(receiver->dsp, [receiver, config, &configured, &channelId] { + configured = receiver->dsp->configure(config); + channelId = receiver->dsp->wdspChannelId(); + }, Qt::BlockingQueuedConnection); + if (!configured) { + return false; + } + receiver->configuredRateHz = config.inputSampleRateHz; + Hl2ReceiverIds* const ids = backend.m_ids.mutableByDdc(ddc); + ids->dspChannel = channelId; + ids->analyzerId = ids->uiNumber; + } + backend.publishIoDsps(); + // What connectRadio() arms for every connect. + backend.m_sessionReplayPending = replayPending; + QMetaObject::invokeMethod(backend.m_metis, "linkUp", Qt::BlockingQueuedConnection); + QCoreApplication::sendPostedEvents(&backend, QEvent::MetaCall); + return backend.isConnected(); + } + static void linkUpAgain(Hl2Backend& backend) + { + QMetaObject::invokeMethod(backend.m_metis, "linkUp", Qt::BlockingQueuedConnection); + QCoreApplication::sendPostedEvents(&backend, QEvent::MetaCall); + } + static int receiverCount(const Hl2Backend& backend) + { + return static_cast(backend.m_rx.size()); + } + static int ceiling(const Hl2Backend& backend) { return backend.receiverCeiling(); } + static bool buildsSettled(const Hl2Backend& backend) + { + for (const Hl2Backend::Receiver& r : backend.m_rx) { + if (r.dspBuildInFlight) { + return false; + } + } + return true; + } + static int restoredReceivers(const Hl2Backend& backend) + { + return static_cast(backend.m_restoredReceivers.size()); + } + static void seedFirstReceiver(Hl2Backend& backend) { backend.seedOpenReceiversFromMemory(); } + static AetherSDR::NoiseBlankerKind nbKind(const Hl2Backend& backend, int ddc) + { + const Hl2Backend::Receiver* r = backend.rx(ddc); + return r ? r->nbKind : AetherSDR::NoiseBlankerKind::Off; + } + static double sliceHz(const Hl2Backend& backend, int ddc) + { + const Hl2Backend::Receiver* r = backend.rx(ddc); + return r ? r->sliceFreqHz : 0.0; + } + static int txDdc(const Hl2Backend& backend) { return backend.m_txDdc; } + static void setTuning(Hl2Backend& backend, bool on) { backend.m_tuning = on; } + static void armReplay(Hl2Backend& backend) { backend.m_sessionReplayPending = true; } +}; + +} // namespace AetherSDR::hl2 + +using namespace AetherSDR; +using AetherSDR::hl2::Hl2Backend; +using Access = AetherSDR::hl2::Hl2SessionMemoryTestAccess; + +namespace { + +int g_failures = 0; + +void check(bool condition, const char* what) +{ + std::fprintf(stderr, "[%s] %s\n", condition ? " OK " : "FAIL", what); + if (!condition) { + ++g_failures; + } +} + +QJsonObject ext(const RestoredRadioState& state, const char* key) +{ + return state.extension.value(QLatin1String(key)).toObject(); +} + +QJsonObject receiverEntry(double freqHz, const QString& mode) +{ + return QJsonObject{{QStringLiteral("freqHz"), freqHz}, + {QStringLiteral("ncoHz"), freqHz}, + {QStringLiteral("mode"), mode}}; +} + +RestoredRadioState stateWith(const QJsonArray& receivers, const QJsonArray& notches = {}) +{ + RestoredRadioState state; + state.rfFrequencyHz = 7'100'000.0; + state.mode = QStringLiteral("LSB"); + state.extensionSchemaVersion = 1; + state.extension.insert(QStringLiteral("receivers"), receivers); + state.extension.insert(QStringLiteral("notches"), notches); + return state; +} + +// The setters the operator reaches on receiver A land in the document, and a +// new backend reading it back gives receiver A the same values. +void receiverSetpointsRoundTrip() +{ + Hl2Backend backend; + backend.setSliceNoiseBlanker(0, NoiseBlankerKind::Advanced, 70, NoiseBlankerFill::MeanHold); + backend.setSliceApf(0, true, 35); + backend.setSliceSquelch(0, true, 44); + backend.setSliceRitEnabled(0, true); + backend.setSliceRitOffset(0, 120); + backend.setSliceXitEnabled(0, true); + backend.setSliceXitOffset(0, -80); + backend.setSliceAudioGain(0, 60); + backend.setSliceAudioPan(0, 20); + + const RestoredRadioState captured = backend.currentOperatingState(); + const QJsonArray receivers = captured.extension.value(QStringLiteral("receivers")).toArray(); + check(receivers.size() == 1, "capture lists the one running receiver"); + const QJsonObject a = receivers.at(0).toObject(); + check(a.value(QStringLiteral("nbKind")).toInt() == static_cast(NoiseBlankerKind::Advanced) + && a.value(QStringLiteral("nbLevel")).toInt() == 70 + && a.value(QStringLiteral("nbFill")).toInt() == static_cast(NoiseBlankerFill::MeanHold), + "capture carries the blanker kind, level and fill"); + check(a.value(QStringLiteral("apfOn")).toBool() && a.value(QStringLiteral("apfLevel")).toInt() == 35, + "capture carries APF"); + check(a.value(QStringLiteral("squelchOn")).toBool() + && a.value(QStringLiteral("squelchLevel")).toInt() == 44, + "capture carries squelch"); + check(a.value(QStringLiteral("ritOn")).toBool() && a.value(QStringLiteral("ritHz")).toInt() == 120 + && a.value(QStringLiteral("xitOn")).toBool() + && a.value(QStringLiteral("xitHz")).toInt() == -80, + "capture carries RIT and XIT"); + check(a.value(QStringLiteral("audioGain")).toInt() == 60 + && a.value(QStringLiteral("audioPan")).toInt() == 20, + "capture carries audio gain and pan"); + + Hl2Backend next; + QHash seen; + int nbEchoes = 0; + SliceDelta nb; + QObject::connect(&next, &IRadioBackend::sliceChanged, &next, + [&](int id, const SliceDelta& d) { + seen[id] = d; + if (id == 0 && d.nbKind) { + ++nbEchoes; + nb = d; + } + }); + next.applyRestoredState(captured); + check(Access::restoredReceivers(next) == 1, "restore keeps the valid entry"); + Access::seedFirstReceiver(next); + next.setSliceFrequency(0, next.currentOperatingState().rfFrequencyHz); // a publish + next.setSliceFrequency(0, next.currentOperatingState().rfFrequencyHz + 100.0); + const SliceDelta d = seen.value(0); + check(nb.nbKind && *nb.nbKind == NoiseBlankerKind::Advanced && nb.nbLevel && *nb.nbLevel == 70 + && nb.nbFill && *nb.nbFill == NoiseBlankerFill::MeanHold, + "receiver A comes back with its blanker, and publishes it"); + check(nbEchoes == 1, "the restored blanker is published once, not on every slice echo"); + check(d.apf && *d.apf && d.apfLevel && *d.apfLevel == 35, "receiver A comes back with APF"); + check(d.squelchOn && *d.squelchOn && d.squelchLevel && *d.squelchLevel == 44, + "receiver A comes back with squelch"); + check(d.ritOn && *d.ritOn && d.ritFreq && *d.ritFreq == 120 && d.xitOn && *d.xitOn + && d.xitFreq && *d.xitFreq == -80, + "receiver A comes back with RIT and XIT"); + check(d.audioGain && *d.audioGain == 60 && d.audioPan && *d.audioPan == 20, + "receiver A comes back with audio gain and pan"); +} + +// Each field is judged on its own: a bad one falls to its default and the +// rest of the entry stands; an entry with no usable frequency ends the list. +void invalidEntriesAreDroppedNotClamped() +{ + QJsonObject a = receiverEntry(7'100'000.0, QStringLiteral("LSB")); + a.insert(QStringLiteral("nbKind"), 7); + a.insert(QStringLiteral("apfLevel"), 250); + a.insert(QStringLiteral("ritOn"), true); + a.insert(QStringLiteral("ritHz"), 20'000); + a.insert(QStringLiteral("squelchLevel"), 30); + QJsonArray receivers{a, receiverEntry(50.0, QStringLiteral("USB")), + receiverEntry(14'200'000.0, QStringLiteral("USB"))}; + Hl2Backend backend; + backend.applyRestoredState(stateWith(receivers)); + check(Access::restoredReceivers(backend) == 1, + "an entry with an impossible frequency ends the list, so C does not move into B"); + Access::seedFirstReceiver(backend); + const QJsonObject back = backend.currentOperatingState() + .extension.value(QStringLiteral("receivers")).toArray().at(0).toObject(); + check(back.value(QStringLiteral("nbKind")).toInt() == 0, "an unknown blanker kind reads as off"); + check(back.value(QStringLiteral("apfLevel")).toInt() == 50, "an out-of-range APF level reads as the default"); + check(back.value(QStringLiteral("ritHz")).toInt() == 0, "an offset past the RIT limit reads as zero"); + check(back.value(QStringLiteral("squelchLevel")).toInt() == 30, "the valid fields of the entry stand"); +} + +// Receiver A's entry wins over the flat fields, which follow the transmit +// receiver and so can describe receiver B. +void receiverAEntryOverridesTheFlatFields() +{ + QJsonObject a = receiverEntry(3'700'000.0, QStringLiteral("LSB")); + Hl2Backend backend; + RestoredRadioState state = stateWith(QJsonArray{a}); + state.rfFrequencyHz = 14'200'000.0; + state.mode = QStringLiteral("USB"); + backend.applyRestoredState(state); + check(backend.restoredStateForTest().rfFrequencyHz == 3'700'000.0 + && backend.restoredStateForTest().mode == QStringLiteral("LSB"), + "receiver A starts on its own frequency and mode"); +} + +// After link-up the remembered receivers and notches come back through the +// add and create paths, once per connect, within the receiver ceiling. +void theSessionIsReplayedOnceAfterLinkUp() +{ + QJsonObject b = receiverEntry(14'074'000.0, QStringLiteral("DIGU")); + b.insert(QStringLiteral("nbKind"), static_cast(NoiseBlankerKind::Impulse)); + b.insert(QStringLiteral("audioGain"), 40); + const QJsonObject c = receiverEntry(10'136'000.0, QStringLiteral("USB")); + const QJsonArray receivers{receiverEntry(7'100'000.0, QStringLiteral("LSB")), b, c}; + const QJsonArray notches{ + QJsonObject{{QStringLiteral("centerHz"), 7'101'000.0}, {QStringLiteral("widthHz"), 100.0}}, + QJsonObject{{QStringLiteral("centerHz"), 7'102'000.0}, {QStringLiteral("widthHz"), 200.0}, + {QStringLiteral("active"), false}}}; + + Hl2Backend backend; + backend.applyRestoredState(stateWith(receivers, notches)); + QHash slices; + QHash notchesSeen; + QObject::connect(&backend, &IRadioBackend::sliceChanged, &backend, + [&slices](int id, const SliceDelta& d) { slices[id] = d; }); + QObject::connect(&backend, &IRadioBackend::notchChanged, &backend, + [¬chesSeen](int id, const NotchDelta& d) { notchesSeen[id] = d; }); + check(Access::bringUp(backend, true), "the injected link edge reaches the backend"); + check(Access::receiverCount(backend) == 3, "receivers B and C are reopened"); + bool bFound = false; + bool cFound = false; + for (auto it = slices.constBegin(); it != slices.constEnd(); ++it) { + if (it.key() == 0 || !it->frequency) { + continue; + } + if (qAbs(*it->frequency - 14.074) < 1e-6) { + bFound = it->mode && *it->mode == QStringLiteral("DIGU") && it->nbKind + && *it->nbKind == NoiseBlankerKind::Impulse && it->audioGain && *it->audioGain == 40 + && it->agcMode && *it->agcMode == QStringLiteral("off"); + } + if (qAbs(*it->frequency - 10.136) < 1e-6) { + cFound = it->mode && *it->mode == QStringLiteral("USB"); + } + } + check(bFound, "B comes back with its frequency, mode, blanker and audio, AGC off in DIGU"); + check(cFound, "C comes back with its own frequency and mode"); + int active = 0; + int inactive = 0; + for (const NotchDelta& d : notchesSeen) { + (d.active && *d.active ? active : inactive) += 1; + } + check(notchesSeen.size() == 2 && active == 1 && inactive == 1, + "both notches come back with markers, the inactive one inactive"); + AetherSDR::test::spinUntil([&] { return Access::buildsSettled(backend); }); + + Access::linkUpAgain(backend); + check(Access::receiverCount(backend) == 3 && notchesSeen.size() == 2, + "a link-up after EP6 silence replays nothing a second time"); + AetherSDR::test::spinUntil([&] { return Access::buildsSettled(backend); }); +} + +// Transmit comes back on the receiver that held it, and the capture says which. +void transmitReturnsToItsReceiver() +{ + QJsonObject b = receiverEntry(14'200'000.0, QStringLiteral("USB")); + b.insert(QStringLiteral("transmit"), true); + const QJsonArray receivers{receiverEntry(7'100'000.0, QStringLiteral("LSB")), b}; + Hl2Backend backend; + backend.applyRestoredState(stateWith(receivers)); + check(Access::bringUp(backend, true), "the injected link edge reaches the backend"); + check(Access::txDdc(backend) == 1, "transmit is back on receiver B"); + const QJsonArray captured = backend.currentOperatingState() + .extension.value(QStringLiteral("receivers")).toArray(); + check(captured.size() == 2 && !captured.at(0).toObject().contains(QStringLiteral("transmit")) + && captured.at(1).toObject().value(QStringLiteral("transmit")).toBool(), + "the capture marks the transmit receiver"); + AetherSDR::test::spinUntil([&] { return Access::buildsSettled(backend); }); +} + +// Receivers a connect already opened (the numRx param) take their slots in +// the list; the replay adds only the ones after them. +void theReplayCountsReceiversAlreadyRunning() +{ + const QJsonArray receivers{receiverEntry(7'100'000.0, QStringLiteral("LSB")), + receiverEntry(14'074'000.0, QStringLiteral("USB")), + receiverEntry(10'136'000.0, QStringLiteral("USB"))}; + Hl2Backend backend; + backend.applyRestoredState(stateWith(receivers)); + check(Access::bringUp(backend, true, 2), "two receivers are up before the replay"); + check(Access::receiverCount(backend) == 3, "the replay adds only the third"); + check(Access::sliceHz(backend, 1) == 14'074'000.0, + "the receiver the connect opened takes its own remembered entry"); + AetherSDR::test::spinUntil([&] { return Access::buildsSettled(backend); }); +} + +// A capture taken before the replay has run keeps the remembered lists: the +// store rebuilds the document, so a list left out would be erased. +void aCaptureBeforeTheReplayKeepsTheLists() +{ + const QJsonArray receivers{receiverEntry(7'100'000.0, QStringLiteral("LSB")), + receiverEntry(14'074'000.0, QStringLiteral("USB"))}; + const QJsonArray notches{ + QJsonObject{{QStringLiteral("centerHz"), 7'101'000.0}, {QStringLiteral("widthHz"), 100.0}}}; + Hl2Backend backend; + backend.applyRestoredState(stateWith(receivers, notches)); + Access::armReplay(backend); + const RestoredRadioState captured = backend.currentOperatingState(); + check(captured.extension.value(QStringLiteral("receivers")).toArray().size() == 2, + "both remembered receivers are kept"); + check(captured.extension.value(QStringLiteral("notches")).toArray().size() == 1, + "the remembered notch is kept"); +} + +RadioConnectRequest request(const QString& serial) +{ + RadioConnectRequest req; + req.host = QStringLiteral("192.0.2.1"); // TEST-NET-1, never routable + req.port = 1024; + req.serial = serial; + return req; +} + +void settleConnect(Hl2Backend& backend) +{ + QEventLoop loop; + QObject::connect(&backend, &Hl2Backend::dspSetupFinished, &loop, &QEventLoop::quit); + QTimer::singleShot(120'000, &loop, &QEventLoop::quit); + loop.exec(); +} + +// Receiver A's remembered setpoints seed it on a new radio, or on receivers +// rebuilt from nothing; a reconnect of the same radio keeps its live values. +void aReconnectKeepsReceiverALive() +{ + QJsonObject a = receiverEntry(7'100'000.0, QStringLiteral("LSB")); + a.insert(QStringLiteral("nbKind"), static_cast(NoiseBlankerKind::Impulse)); + const RestoredRadioState doc = stateWith(QJsonArray{a}); + Hl2Backend backend; + backend.applyRestoredState(doc); + backend.connectRadio(request(QStringLiteral("AA:BB:CC:00:00:01"))); + settleConnect(backend); + check(Access::nbKind(backend, 0) == NoiseBlankerKind::Impulse, + "a first connect seeds receiver A from the document"); + backend.disconnectRadio(); + + backend.setSliceNoiseBlanker(0, NoiseBlankerKind::Off, 50, kDefaultNoiseBlankerFill); + backend.applyRestoredState(doc); + backend.connectRadio(request(QStringLiteral("AA:BB:CC:00:00:01"))); + settleConnect(backend); + check(Access::nbKind(backend, 0) == NoiseBlankerKind::Off, + "a reconnect of the same radio keeps receiver A's live blanker"); + backend.disconnectRadio(); + + backend.applyRestoredState(doc); + backend.connectRadio(request(QStringLiteral("AA:BB:CC:00:00:02"))); + settleConnect(backend); + check(Access::nbKind(backend, 0) == NoiseBlankerKind::Impulse, + "another radio is seeded from its own document"); + backend.disconnectRadio(); +} + +void theReplayStopsAtTheReceiverCeiling() +{ + QJsonArray receivers; + for (int i = 0; i < 8; ++i) { + receivers.append(receiverEntry(7'000'000.0 + 10'000.0 * i, QStringLiteral("LSB"))); + } + Hl2Backend backend; + backend.applyRestoredState(stateWith(receivers)); + check(Access::bringUp(backend, true), "the injected link edge reaches the backend"); + check(Access::receiverCount(backend) == Access::ceiling(backend), + "no more receivers come back than this span allows"); + check(backend.currentOperatingState().extension.value(QStringLiteral("receivers")) + .toArray().size() == 8, + "the receivers that did not fit are still in the document for the next connect"); + AetherSDR::test::spinUntil([&] { return Access::buildsSettled(backend); }); + check(backend.removePanadapter(QStringLiteral("hl2-1")), "the operator closes receiver B"); + const int afterClose = backend.currentOperatingState() + .extension.value(QStringLiteral("receivers")).toArray().size(); + check(afterClose == Access::receiverCount(backend), + "once the operator reshapes the set, the kept tail is dropped"); + AetherSDR::test::spinUntil([&] { return Access::buildsSettled(backend); }); +} + +void withoutAConnectNothingIsReplayed() +{ + const QJsonArray receivers{receiverEntry(7'100'000.0, QStringLiteral("LSB")), + receiverEntry(14'074'000.0, QStringLiteral("USB"))}; + Hl2Backend backend; + backend.applyRestoredState(stateWith(receivers)); + check(Access::bringUp(backend, false), "the injected link edge reaches the backend"); + check(Access::receiverCount(backend) == 1, "a link-up no connect armed replays nothing"); +} + +// TUNE power is recorded whether or not a carrier is up, under the band the +// transmit receiver is on, and a band change brings back the band's own value. +void tunePowerIsRememberedPerBand() +{ + Hl2Backend backend; + QList echoed; + QObject::connect(&backend, &IRadioBackend::transmitChanged, &backend, + [&echoed](const TransmitDelta& d) { + if (d.tunePower) echoed << *d.tunePower; + }); + backend.setSliceFrequency(0, 7'100'000.0); + backend.setTunePower(30); + backend.setSliceFrequency(0, 14'200'000.0); + check(echoed.isEmpty(), "a new band starts on the first-set baseline, which is the value already held"); + backend.setTunePower(55); + backend.setSliceFrequency(0, 7'150'000.0); + check(!echoed.isEmpty() && echoed.last() == 30, "back on 40 m, its own TUNE power is echoed"); + + const QJsonObject tx = ext(backend.currentOperatingState(), "txSetpoints"); + const QJsonObject byBand = tx.value(QStringLiteral("tuneByBand")).toObject(); + check(byBand.value(QStringLiteral("40m")).toInt() == 30 + && byBand.value(QStringLiteral("20m")).toInt() == 55, + "the capture holds TUNE power per band"); + check(tx.value(QStringLiteral("tuneDefaultPercent")).toInt() == 30, + "the first value set is the baseline for bands never tuned"); + + Hl2Backend next; + QList restored; + QObject::connect(&next, &IRadioBackend::transmitChanged, &next, + [&restored](const TransmitDelta& d) { + if (d.tunePower) restored << *d.tunePower; + }); + RestoredRadioState state; + state.extensionSchemaVersion = 1; + state.extension.insert(QStringLiteral("txSetpoints"), tx); + next.applyRestoredState(state); + next.setSliceFrequency(0, 14'250'000.0); + check(!restored.isEmpty() && restored.last() == 55, + "a restored radio entering 20 m echoes 20 m's TUNE power"); + + // Under a carrier the drive is the slider's value: no band memory moves it. + restored.clear(); + Access::setTuning(next, true); + next.setSliceFrequency(0, 7'100'000.0); + check(restored.isEmpty(), "a band change under a TUNE carrier echoes no other TUNE power"); + TxTestAuthority authority; + next.setTune(false, -1, authority.operation, {}); + check(!restored.isEmpty() && restored.last() == 30, + "at unkey the new band's own TUNE power applies"); +} + +// Only an operator's choice of law or floor is kept; a law change takes the +// law's own floor with it. +void autoGainLawAndFloorAreKeptOnlyWhenChosen() +{ + Hl2Backend untouched; + const QJsonObject plain = ext(untouched.currentOperatingState(), "rfGain"); + check(!plain.contains(QStringLiteral("autoLaw")) && !plain.contains(QStringLiteral("autoFloorDb")), + "nothing is written for a law and floor nobody chose"); + + Hl2Backend backend; + check(backend.setLaw(QStringLiteral("ramp")), "the ramp law is accepted"); + backend.setFloorDb(10); + const QJsonObject chosen = ext(backend.currentOperatingState(), "rfGain"); + check(chosen.value(QStringLiteral("autoLaw")).toString() == QStringLiteral("ramp") + && chosen.value(QStringLiteral("autoFloorDb")).toInt() == 10, + "a chosen law and floor are written"); + + RestoredRadioState state; + state.extensionSchemaVersion = 1; + state.extension.insert(QStringLiteral("rfGain"), chosen); + Hl2Backend next; + next.applyRestoredState(state); + check(next.law() == QStringLiteral("ramp") && next.floorDb() == 10, + "a restore comes back on the chosen law and floor"); + + next.setLaw(QStringLiteral("probe")); + const QJsonObject relawed = ext(next.currentOperatingState(), "rfGain"); + check(relawed.value(QStringLiteral("autoLaw")).toString() == QStringLiteral("probe") + && !relawed.contains(QStringLiteral("autoFloorDb")), + "a new law drops the earlier floor choice"); + next.setLaw(QStringLiteral("default")); + check(!ext(next.currentOperatingState(), "rfGain").contains(QStringLiteral("autoLaw")), + "choosing the default clears the law choice"); + + RestoredRadioState bad; + bad.extensionSchemaVersion = 1; + bad.extension.insert(QStringLiteral("rfGain"), + QJsonObject{{QStringLiteral("autoLaw"), QStringLiteral("bogus")}, + {QStringLiteral("autoFloorDb"), 999}}); + Hl2Backend fresh; + Hl2Backend defaulted; + defaulted.applyRestoredState(bad); + check(defaulted.law() == fresh.law() && defaulted.floorDb() == fresh.floorDb(), + "an unknown law and an out-of-range floor are dropped"); +} + +// The two new domains ride the document only where the backend declares them. +void theNewDomainsAreGatedByDeclaration() +{ + const RadioSettingsScope scope(QStringLiteral("hl2"), QStringLiteral("00:1C:C0:00:00:01")); + Hl2Backend backend; + RestoredRadioState state = backend.currentOperatingState(); + RadioCapabilities caps = backend.capabilities(); + check(caps.clientSettingsDomains.testFlag(RadioCapabilities::ClientSettingsDomain::Receivers) + && caps.clientSettingsDomains.testFlag(RadioCapabilities::ClientSettingsDomain::Notches), + "the HL2 declares the Receivers and Notches domains"); + check(caps.backendPanAveraging && caps.backendPanAveraging->clientPersistsAveraging, + "the HL2 makes the client the owner of FFT AVG and Wt Avg"); + check(RadioStateMemory::store(scope, caps, state), "the document is stored"); + const RestoredRadioState loaded = RadioStateMemory::load(scope, caps); + check(loaded.extension.contains(QStringLiteral("receivers")) + && loaded.extension.contains(QStringLiteral("notches")), + "declared, both lists round-trip through the store"); + + caps.clientSettingsDomains &= ~RadioCapabilities::ClientSettingsDomains( + RadioCapabilities::ClientSettingsDomain::Receivers); + const RestoredRadioState narrowed = RadioStateMemory::load(scope, caps); + check(!narrowed.extension.contains(QStringLiteral("receivers")) + && narrowed.extension.contains(QStringLiteral("notches")), + "an undeclared domain's list is withheld on load"); +} + +} // namespace + +int main(int argc, char** argv) +{ + TestSettingsProfile profile(QStringLiteral("hl2-session-memory")); + if (!profile.isValid()) { + std::fprintf(stderr, "Cannot isolate test settings\n"); + return 1; + } + QCoreApplication app(argc, argv); + AppSettings::instance().load(); + receiverSetpointsRoundTrip(); + invalidEntriesAreDroppedNotClamped(); + receiverAEntryOverridesTheFlatFields(); + theSessionIsReplayedOnceAfterLinkUp(); + transmitReturnsToItsReceiver(); + theReplayCountsReceiversAlreadyRunning(); + aCaptureBeforeTheReplayKeepsTheLists(); + theReplayStopsAtTheReceiverCeiling(); + aReconnectKeepsReceiverALive(); + withoutAConnectNothingIsReplayed(); + tunePowerIsRememberedPerBand(); + autoGainLawAndFloorAreKeptOnlyWhenChosen(); + theNewDomainsAreGatedByDeclaration(); + std::fprintf(stderr, "hl2_session_memory_test: %d failure(s)\n", g_failures); + return g_failures == 0 ? 0 : 1; +} diff --git a/tests/tests.cmake b/tests/tests.cmake index c91fe9c92a..e9694831fa 100644 --- a/tests/tests.cmake +++ b/tests/tests.cmake @@ -1578,6 +1578,14 @@ target_include_directories(hl2_pan_create_async_test PRIVATE src tests) target_link_libraries(hl2_pan_create_async_test PRIVATE aethercore Qt6::Core Qt6::Network Qt6::Test) add_test(NAME hl2_pan_create_async_test COMMAND hl2_pan_create_async_test) +# What an HL2 remembers beyond the flat operating state: TUNE power per band, +# a chosen auto-RF-gain law and floor, each receiver's setpoints and the +# receiver set (#5777), and the notches. Injected link edge, no socket. +add_executable(hl2_session_memory_test tests/hl2_session_memory_test.cpp) +target_include_directories(hl2_session_memory_test PRIVATE src tests) +target_link_libraries(hl2_session_memory_test PRIVATE aethercore Qt6::Core Qt6::Network Qt6::Test) +add_test(NAME hl2_session_memory_test COMMAND hl2_session_memory_test) + # RFC #5535 approved the automatic RF-gain loop ON THE CONDITION that it is # visible -- the clipping AND the regulator's own action. This pins both, and # pins the rule that stops the second from making the radio unusable with a @@ -7864,6 +7872,7 @@ set(AETHER_SETTINGS_CONSUMERS radio_model_client_state_test squelch_level_scale_test hl2_pan_create_async_test + hl2_session_memory_test anan_backend_test anan_noise_blanker_readback_test noise_blanker_kind_model_test diff --git a/tests/transmit_seam_drop_notice_test.cpp b/tests/transmit_seam_drop_notice_test.cpp index 8884b085c7..71c0861d66 100644 --- a/tests/transmit_seam_drop_notice_test.cpp +++ b/tests/transmit_seam_drop_notice_test.cpp @@ -227,9 +227,9 @@ static void cwPitchReachesSeamWithoutDropNotice() "cw pitch: no commandDropped for a pitch the backend applied"); } -// Unkeyed, tune power has nothing to re-apply: setTune() hands tunePower() to -// the backend at key time (#4551), so the text is not a drop and no -// setTunePower() is sent. +// Unkeyed, tune power reaches a live-declaring backend to be recorded (the +// HL2 keeps it per band), and setTune() still hands tunePower() over at key +// time (#4551), so the text is not a drop. static void tunePowerDeliveredAtKeyTimeWithoutDropNotice() { Fixture f(hostModulatingTransmitter()); @@ -237,8 +237,8 @@ static void tunePowerDeliveredAtKeyTimeWithoutDropNotice() f.radio.transmitModel().setTunePower(25); check(!f.droppedStartingWith(QStringLiteral("transmit set tunepower=")), "tunepower: no commandDropped on a backend that applies it at key time"); - check(f.backend->tunePowers.isEmpty(), - "tunepower while not tuning: no setTunePower() reaches the backend"); + check(f.backend->tunePowers == QList{25}, + "tunepower while not tuning: setTunePower(25) reaches the backend to be recorded"); f.radio.transmitModel().startTune(); check(!f.backend->tunes.isEmpty() && f.backend->tunes.first() == qMakePair(true, 25), "tunepower: TUNE keyed with setTune(true, 25), the slider's value"); @@ -256,6 +256,7 @@ static void tunePowerChangedWhileKeyedAppliesLive() check(f.radio.transmitModel().isTuning(), "premise: TUNE is keyed"); const auto tunesAtKeyDown = f.backend->tunes; f.dropped.clear(); + f.backend->tunePowers.clear(); // the unkeyed 10 was recorded; see above f.radio.transmitModel().setTunePower(30); check(f.backend->tunePowers == QList{30}, "live tune power: setTunePower(30) reached the backend exactly once"); @@ -276,8 +277,8 @@ static void tunePowerChangedWhileKeyedAppliesLive() f.radio.transmitModel().stopTune(); f.radio.transmitModel().setTunePower(50); - check(f.backend->tunePowers == QList{30}, - "after TUNE is released: no setTunePower() reaches the backend"); + check(f.backend->tunePowers == QList{30, 50}, + "after TUNE is released: setTunePower(50) reaches the backend to be recorded"); } // A tune state decoded off the radio is not a TUNE this client admitted: with