diff --git a/docs/HERMES.md b/docs/HERMES.md index c3abde9df..fe76d9cc3 100644 --- a/docs/HERMES.md +++ b/docs/HERMES.md @@ -2961,6 +2961,19 @@ Backend pan ids are now translated through a first-seen-order allocator and stay OPAQUE in both directions — `RadioModel` does not parse a family's naming scheme, and a backend does not learn about the `0xE1000000` stream-id space. +HL2 RF gain is stored per band in the radio's `OperatingState` document. +On reconnect, the backend restores the start band's entry (or its default for +an unvisited band). The display mirrors that value; the legacy +`DisplayRfGain_hl2` setting is ignored during startup so it cannot overwrite +the band's entry (#5400). The `RfGain` client-settings domain remains enabled: +`RadioStateMemory` needs it to load and save the per-band map. + +An explicit `lnaGainDb` connection parameter can temporarily override the live +gain without replacing an existing start-band entry (#5402). A band change +restores the new band's gain; an operator gain change updates the current band. +All panadapters still share the one hardware LNA. Flex and Icom restoration +behavior is unchanged. + ### 20.10 The dynamic lifecycle: receivers come and go while the radio runs The count was fixed at connect, from a persisted setting. It is now the diff --git a/src/core/backends/hl2/Hl2Backend.cpp b/src/core/backends/hl2/Hl2Backend.cpp index cedcddaec..b89a40bb9 100644 --- a/src/core/backends/hl2/Hl2Backend.cpp +++ b/src/core/backends/hl2/Hl2Backend.cpp @@ -8,6 +8,7 @@ #include "core/backends/hl2/Hl2RxDsp.h" #include "core/backends/hl2/Hl2TxDsp.h" +#include "core/backends/hl2/Hl2BandMemoryPolicy.h" #include "core/backends/hl2/Hl2OverloadPolicy.h" #include "core/backends/hl2/Hl2DspSetupPolicy.h" #include "core/backends/hl2/Hl2TxLevelPolicy.h" @@ -1713,11 +1714,29 @@ void Hl2Backend::connectRadio(const RadioConnectRequest& request) // Per-band memory (RFC #4603 PR 3): the session comes up with the start // band's remembered LNA. The explicit param still wins via the guard. m_currentBandKey = hl2::bandKeyForHz(startFreqHz); - if (m_haveRestoredState - && !request.params.contains(QStringLiteral("lnaGainDb"))) { - m_lnaGainDb = qBound(kLnaGainMinDb, - m_lnaDbByBand.value(m_currentBandKey, m_lnaDefaultDb), - kLnaGainMaxDb); + { + const bool paramPresent = + request.params.contains(QStringLiteral("lnaGainDb")); + const bool hasStored = m_lnaDbByBand.contains(m_currentBandKey); + const AetherSDR::hl2::ConnectLna seed = AetherSDR::hl2::connectLna( + m_haveRestoredState, hasStored, + m_lnaDbByBand.value(m_currentBandKey, m_lnaDefaultDb), + paramPresent, + request.params.value(QStringLiteral("lnaGainDb")).toInt(), + m_lnaDefaultDb, kLnaGainMinDb, kLnaGainMaxDb); + // Only take the live value when the policy actually had something to + // say: with no restored state and no param it returns the default, + // which must not stamp on a value the lines above already settled. + if (m_haveRestoredState || paramPresent) { + m_lnaGainDb = seed.liveDb; + } + m_lnaSessionPin = seed.sessionPin; + if (m_lnaSessionPin) { + qCInfo(lcHl2) << "HL2: lnaGainDb param pins" << m_lnaGainDb + << "dB for this session;" << m_currentBandKey + << "keeps its stored" + << m_lnaDbByBand.value(m_currentBandKey) << "dB"; + } } // Seed the DRIVE from the start band's memory and echo it upward NOW — // before linkUp — so TransmitModel carries the restored value when its @@ -3007,6 +3026,9 @@ void Hl2Backend::setPanRfGain(const QString& panId, int gainDb) qCInfo(lcHl2) << "HL2 LNA gain:" << m_lnaGainDb << "dB (requested" << gainDb << ")"; // The operator's gain belongs to the band they set it on (RFC #4603 PR 3). + // This is also what ends a session pin: the value is now the operator's + // own choice for this band, so the band memory is theirs to overwrite. + m_lnaSessionPin = false; if (!m_currentBandKey.isEmpty()) m_lnaDbByBand.insert(m_currentBandKey, m_lnaGainDb); notifyOperatingStateChanged(); @@ -4303,6 +4325,7 @@ void Hl2Backend::applyRestoredState(const RestoredRadioState& state) m_driveByBand.clear(); m_lnaDefaultDb = 20; // Hl2Backend.h: m_lnaGainDb's constructed default m_lnaGainDb = 20; + m_lnaSessionPin = false; m_driveDefaultPercent = -1; m_rfPowerPercent = 100; // TransmitModel's session default m_sampleRateHz = 48000; // construction default — radio B must not @@ -4571,7 +4594,22 @@ RestoredRadioState Hl2Backend::currentOperatingState() const for (auto it = m_driveByBand.constBegin(); it != m_driveByBand.constEnd(); ++it) driveByBand.insert(it.key(), it.value()); if (!m_currentBandKey.isEmpty()) { - lnaByBand.insert(m_currentBandKey, m_lnaGainDb); + // THE SAME PRESERVATION RULE AS THE WRITE-BACK, and it has to be here + // too. This snapshot is taken on a debounced store that any unrelated + // action schedules -- a same-band tune, a mode change, a filter change + // -- so it reaches the band map long BEFORE the first band change. + // Protecting only rememberCurrentBandState() left the session pin free + // to be persisted through this path: restore 20 m at -12, connect with + // lnaGainDb=20, tune within 20 m, and the capture stored 20 for 20 m. + // (#5402 review, Ozy311.) + // + // One policy, two call sites asking it -- not two copies of the rule. + lnaByBand.insert( + m_currentBandKey, + AetherSDR::hl2::bandMemoryWriteback( + m_lnaGainDb, m_lnaSessionPin, + m_lnaDbByBand.contains(m_currentBandKey), + m_lnaDbByBand.value(m_currentBandKey))); driveByBand.insert(m_currentBandKey, m_rfPowerPercent); } @@ -4637,7 +4675,12 @@ void Hl2Backend::rememberCurrentBandState() { if (m_currentBandKey.isEmpty()) return; - m_lnaDbByBand.insert(m_currentBandKey, m_lnaGainDb); + m_lnaDbByBand.insert( + m_currentBandKey, + AetherSDR::hl2::bandMemoryWriteback( + m_lnaGainDb, m_lnaSessionPin, + m_lnaDbByBand.contains(m_currentBandKey), + m_lnaDbByBand.value(m_currentBandKey))); m_driveByBand.insert(m_currentBandKey, m_rfPowerPercent); } @@ -4652,6 +4695,9 @@ void Hl2Backend::applyPerBandStateFor(double freqHz, const char* reason) // review: the drive that makes 5 W on 80 m is not polite on 10 m, so a // band change must never carry the old band's drive along. rememberCurrentBandState(); + // Clear only AFTER writeback preserves the start band. Later bands must + // record their own gains normally. + m_lnaSessionPin = false; const QString oldBand = m_currentBandKey; m_currentBandKey = newBand; diff --git a/src/core/backends/hl2/Hl2Backend.h b/src/core/backends/hl2/Hl2Backend.h index 54d844a9f..1cbdda9e0 100644 --- a/src/core/backends/hl2/Hl2Backend.h +++ b/src/core/backends/hl2/Hl2Backend.h @@ -747,6 +747,10 @@ class Hl2Backend : public IRadioBackend { QMap m_lnaDbByBand; QMap m_driveByBand; int m_lnaDefaultDb = 20; // matches m_lnaGainDb's own default + // The connect param pinned a gain that the start band did not have stored. + // Live value honoured, persistence refused: see Hl2BandMemoryPolicy.h. + // Cleared when the operator changes gain or leaves the start band. + bool m_lnaSessionPin = false; int m_driveDefaultPercent = -1; // <0: no restored default; leave drive alone QString m_currentBandKey; // True while band-memory / restore code drives setTxPower() itself: the diff --git a/src/core/backends/hl2/Hl2BandMemoryPolicy.h b/src/core/backends/hl2/Hl2BandMemoryPolicy.h new file mode 100644 index 000000000..d995c3603 --- /dev/null +++ b/src/core/backends/hl2/Hl2BandMemoryPolicy.h @@ -0,0 +1,96 @@ +#pragma once + +// Per-band LNA memory: which value a session comes up on, and which value the +// band memory records when the operator leaves that band. +// +// These are two separate questions and the backend previously answered only the +// first. The second is where the defect lives: a connect that pins the LNA via +// the namespaced lnaGainDb param diverges the live value from the start band's +// stored entry, and the FIRST band change then writes the live value back over +// that entry (Hl2Backend::rememberCurrentBandState). The operator's calibration +// for that band is gone, replaced by a number that was only ever meant to hold +// for one session. +// +// Symptom, and why it is worth a header: the loss is silent and it is delayed. +// Nothing is wrong at connect — the pinned value is what was asked for. The +// stored entry dies later, on an unrelated action, and the next session comes +// up on the pinned value as though the operator had chosen it. By the time a +// band sounds wrong there is nothing left on disk that says what it used to be. +// +// They live in a header, evaluated by Hl2Backend rather than copied into it, so +// the suite exercises the SAME expressions the backend runs — the reasoning +// Hl2TxLevelPolicy.h states, and the same reason it applies here: a test +// against a re-typed copy of this decision would agree with itself while the +// backend kept the bug. +// +// See Hl2Backend::connectRadio and ::applyPerBandStateFor for the surrounding +// ordering; this header is the decision only. + +namespace AetherSDR::hl2 { + +// A clamp local to this header so the decision is testable without pulling in +// the backend's translation unit. Mirrors qBound's argument order. +constexpr int clampDb(int minDb, int v, int maxDb) +{ + return v < minDb ? minDb : (v > maxDb ? maxDb : v); +} + +// What a session comes up on for the start band. +struct ConnectLna { + int liveDb = 0; + // TRUE when liveDb came from the connect param while the start band ALSO + // had a stored entry — i.e. the live value is a session pin that the + // operator never chose for this band. Purely informational to the caller; + // it is bandMemoryWriteback below that decides what it costs. + bool sessionPin = false; +}; + +inline ConnectLna connectLna(bool haveRestoredState, + bool hasStoredEntry, int storedDb, + bool paramPresent, int paramDb, + int defaultDb, int minDb, int maxDb) +{ + ConnectLna out; + // The explicit param still wins the LIVE value. That precedence is + // deliberate and documented at the call site: an automation or test caller + // pins the gain outright, and a stored entry must not silently ignore what + // the caller asked for. This header does not reverse it. + if (paramPresent) { + // Preserve the pre-existing explicit-parameter behavior; this PR + // changes persistence, not the connect parameter's range handling. + out.liveDb = paramDb; + out.sessionPin = haveRestoredState && hasStoredEntry && paramDb != storedDb; + return out; + } + if (haveRestoredState) { + out.liveDb = clampDb(minDb, hasStoredEntry ? storedDb : defaultDb, maxDb); + return out; + } + out.liveDb = defaultDb; + return out; +} + +// What rememberCurrentBandState() should record for the band being left. +// +// Normally the live value: leaving a band records what the operator set while +// they were on it, which is the whole point of the memory. +// +// The exception is a session pin. That value came from the connect param, not +// from the operator acting on this band, and the band already had an entry of +// its own — so recording it would overwrite a calibration with a number nobody +// chose for this band. The stored entry is kept instead. +// +// Note what this deliberately does NOT do: it does not make the pin invisible. +// The live gain stays pinned, the radio runs at the requested value, and every +// pan is told about it. Only the persistence is refused, because persistence is +// the part that outlives the session that asked for it. +inline int bandMemoryWriteback(int liveDb, bool sessionPin, + bool hasStoredEntry, int storedDb) +{ + if (sessionPin && hasStoredEntry) { + return storedDb; + } + return liveDb; +} + +} // namespace AetherSDR::hl2 diff --git a/src/gui/MainWindow_Session.cpp b/src/gui/MainWindow_Session.cpp index c489cae63..08cd6d303 100644 --- a/src/gui/MainWindow_Session.cpp +++ b/src/gui/MainWindow_Session.cpp @@ -32,6 +32,7 @@ #include "PhoneCwApplet.h" #include "SpectrumOverlayMenu.h" #include "RfGainPresentation.h" +#include "RfGainRestore.h" #include "core/backends/ConnectionSharingPolicy.h" // in-use share gate (#4448), shared with ConnectionPanel #include "core/backends/sim/SimBackend.h" // demo owns its audio — see wirePanStreamRxAudioSinks #include "core/CwSidetoneGenerator.h" @@ -1667,19 +1668,15 @@ void MainWindow::wirePanLifecycle() const bool clientOwnsRfGain = m_radioModel.backendCapabilities().clientSettingsDomains.testFlag( RadioCapabilities::ClientSettingsDomain::RfGain); - // Flex deliberately declares no client-owned RF-gain domain: the - // radio persists and reports its pan gain. Sim likewise regenerates - // its scene. Only a backend that explicitly delegates this domain - // (currently HL2) may receive a saved client replay. - const bool restoreSavedRfGain = clientOwnsRfGain && haveSavedRfGain; PanadapterModel* activePan = m_radioModel.activePanadapter(); - const int rfGain = restoreSavedRfGain - ? s.value(rfGainKey).toInt() - : (activePan ? activePan->rfGain() : 0); m_radioModel.setPanWnb(wnbOn); m_radioModel.setPanWnbLevel(wnbLevel); - if (restoreSavedRfGain) - m_radioModel.setPanRfGain(rfGain); + const int rfGain = restoreLegacyRfGain( + m_radioModel.backendCapabilities().family, clientOwnsRfGain, + haveSavedRfGain ? std::optional(s.value(rfGainKey).toInt()) + : std::nullopt, + activePan ? activePan->rfGain() : 0, + [this](int gain) { m_radioModel.setPanRfGain(gain); }); sw->setWnbActive(wnbOn); sw->setRfGain(rfGain); sw->overlayMenu()->setWnbState(wnbOn, wnbLevel); diff --git a/src/gui/RfGainRestore.h b/src/gui/RfGainRestore.h new file mode 100644 index 000000000..b77928ef0 --- /dev/null +++ b/src/gui/RfGainRestore.h @@ -0,0 +1,23 @@ +#pragma once + +#include +#include + +namespace AetherSDR { + +// HL2 restores its per-band gain through RadioStateMemory. Its legacy display +// value has no band identity and must not be replayed as an operator change +// (#5400). Keep the existing replay rules for every other family. +template +int restoreLegacyRfGain(QStringView family, bool clientOwnsGain, + std::optional savedGain, int currentGain, + SetGain setGain) +{ + if (family != u"hl2" && clientOwnsGain && savedGain.has_value()) { + setGain(*savedGain); + return *savedGain; + } + return currentGain; +} + +} // namespace AetherSDR diff --git a/tests/hl2_band_memory_test.cpp b/tests/hl2_band_memory_test.cpp new file mode 100644 index 000000000..2dc658ff4 --- /dev/null +++ b/tests/hl2_band_memory_test.cpp @@ -0,0 +1,187 @@ +// Per-band LNA memory across a connect that pins the gain. +// +// The defect these cover is a SILENT, DELAYED loss of operator calibration. +// A connect carrying the namespaced lnaGainDb param sets the live gain to the +// pinned value while the start band's stored entry says something else; the +// first band change then calls rememberCurrentBandState(), which writes the +// live value back over that entry. Nothing is wrong at connect, nothing warns, +// and the band that used to be calibrated comes up on the pinned value in every +// later session as though the operator had chosen it. +// +// NO FIELD OBSERVATION IS CLAIMED FOR THIS MECHANISM. An earlier version of +// this comment cited a bench run in which 40 m went from -6 dB to -12 dB. That +// loss is real but it is NOT this defect: neither launch supplied a +// lnaGainDb connect param, so no session pin existed and this path never fired. +// The cause was a separate global RF-gain replay. Inference presented as +// observation, corrected in the PR body and left corrected here. (#5402 review.) +// +// The defect below is established by reading the path and by these assertions. +// +// Hl2Backend evaluates these same functions rather than its own copy, so what +// passes here is what the radio runs +// (core/backends/hl2/Hl2BandMemoryPolicy.h). + +#include "core/backends/hl2/Hl2BandMemoryPolicy.h" + +#include + +using AetherSDR::hl2::bandMemoryWriteback; +using AetherSDR::hl2::connectLna; + +namespace { + +int g_failures = 0; + +void check(bool ok, const char* what) +{ + std::printf("%s %s\n", ok ? "[ OK ]" : "[FAIL]", what); + if (!ok) { + ++g_failures; + } +} + +// This station's clamp, from Hl2Backend's kLnaGainMinDb/kLnaGainMaxDb. +constexpr int kMin = -12; +constexpr int kMax = 48; +constexpr int kDefault = 20; + +} // namespace + +int main() +{ + // ---- A connect with no param takes the band's stored entry ------------- + // + // Already true before this header existed. Kept because it is the + // precondition for everything below: if a plain connect did NOT restore the + // stored entry, the writeback case would be unreachable and the defect + // would be somewhere else entirely. + { + const auto seed = connectLna(/*haveRestoredState=*/true, + /*hasStoredEntry=*/true, /*storedDb=*/-12, + /*paramPresent=*/false, /*paramDb=*/0, + kDefault, kMin, kMax); + check(seed.liveDb == -12, + "a plain connect comes up on the start band's stored entry"); + check(!seed.sessionPin, + "and nothing about that value is a session pin"); + } + + // ---- A connect WITH the param pins the live value ---------------------- + // + // The param still wins, deliberately: it is how an automation or test + // caller pins the gain, and this fix does not reverse that precedence. + // What it does is mark the divergence, because the divergence is what the + // band memory must not swallow. + { + const auto seed = connectLna(/*haveRestoredState=*/true, + /*hasStoredEntry=*/true, /*storedDb=*/-12, + /*paramPresent=*/true, /*paramDb=*/20, + kDefault, kMin, kMax); + check(seed.liveDb == 20, + "an explicit lnaGainDb param still wins the live value"); + check(seed.sessionPin, + "and is marked a session pin, because the band stored -12"); + } + + // ---- THE DEFECT: the first band change must not consume the entry ------ + { + const auto seed = connectLna(true, true, -12, true, 20, kDefault, kMin, kMax); + const int written = bandMemoryWriteback(seed.liveDb, seed.sessionPin, + /*hasStoredEntry=*/true, + /*storedDb=*/-12); + check(written == -12, + "leaving the start band after a pinned connect KEEPS the stored -12"); + } + + // ---- A pin that agrees with the entry is not a pin --------------------- + // + // Pinning the value the band already had costs nothing and must not be + // treated as a divergence — otherwise the flag is set on ordinary + // automation connects and stops meaning anything. + { + const auto seed = connectLna(true, true, -12, true, -12, kDefault, kMin, kMax); + check(!seed.sessionPin, + "a param equal to the stored entry is not a session pin"); + check(bandMemoryWriteback(seed.liveDb, seed.sessionPin, true, -12) == -12, + "and records the same -12 either way"); + } + + // ---- An operator value on a band with no entry is still recorded ------- + // + // The fix must not turn the memory off. A band the operator has never + // calibrated has nothing to protect, so the live value is what gets stored + // — including when it arrived as a connect param. + { + const auto seed = connectLna(true, /*hasStoredEntry=*/false, 0, + /*paramPresent=*/true, /*paramDb=*/6, + kDefault, kMin, kMax); + check(!seed.sessionPin, + "a param on an uncalibrated band is not a pin — nothing to lose"); + check(bandMemoryWriteback(seed.liveDb, seed.sessionPin, false, 0) == 6, + "and leaving that band records it, so the memory still works"); + } + + // ---- Ordinary operation is untouched ---------------------------------- + { + // No pin at all: the operator moved the slider to +30 on a band that + // remembered -12. That is real intent and must overwrite. + check(bandMemoryWriteback(/*liveDb=*/30, /*sessionPin=*/false, + /*hasStoredEntry=*/true, /*storedDb=*/-12) == 30, + "without a pin, the live value overwrites the entry as before"); + } + + // ---- A stored entry outside the clamp is bounded, not honoured --------- + { + const auto seed = connectLna(true, true, /*storedDb=*/900, + false, 0, kDefault, kMin, kMax); + check(seed.liveDb == kMax, + "a stored entry above the range clamps to the ceiling"); + } + + + // ---- THE SNAPSHOT PATH, which the write-back protection alone missed ---- + // + // Hl2Backend::currentOperatingState() builds the persisted band map, and it + // runs on a DEBOUNCED store that any unrelated action schedules -- a + // same-band tune, a mode change, a filter change. So it reaches the map long + // before the first band change, and protecting only rememberCurrentBandState() + // left the pin free to be persisted through it. (#5402 review, Ozy311.) + // + // These cases cover the shared policy. hl2_gain_restore_test separately + // exercises the actual backend snapshot writer and its caller state. + { + // The reviewer's exact scenario: 20 m stored at -12, connect pins 20, + // then a same-band tune triggers a capture. The capture must record -12. + const auto seed = connectLna(/*haveRestoredState=*/true, + /*hasStoredEntry=*/true, /*storedDb=*/-12, + /*paramPresent=*/true, /*paramDb=*/20, + kDefault, kMin, kMax); + check(seed.liveDb == 20 && seed.sessionPin, + "snapshot: the pin is live at 20 and marked"); + check(bandMemoryWriteback(seed.liveDb, seed.sessionPin, + /*hasStoredEntry=*/true, /*storedDb=*/-12) == -12, + "snapshot: a capture during a pinned session records the stored -12"); + } + { + // Without a pin the snapshot must still record the live value, or a + // capture would freeze the band memory against genuine operator changes. + check(bandMemoryWriteback(/*liveDb=*/30, /*sessionPin=*/false, + /*hasStoredEntry=*/true, /*storedDb=*/-12) == 30, + "snapshot: without a pin the capture records the live value"); + } + { + // A pinned session on a band with NO stored entry has nothing to + // protect, so the capture records the live value and the memory still + // learns the band. + check(bandMemoryWriteback(/*liveDb=*/6, /*sessionPin=*/false, + /*hasStoredEntry=*/false, /*storedDb=*/0) == 6, + "snapshot: an uncalibrated band still records through a capture"); + } + + if (g_failures == 0) { + std::printf("\nALL PASS\n"); + return 0; + } + std::printf("\nFAILURES PRESENT\n"); + return 1; +} diff --git a/tests/hl2_gain_restore_test.cpp b/tests/hl2_gain_restore_test.cpp new file mode 100644 index 000000000..e2bda455b --- /dev/null +++ b/tests/hl2_gain_restore_test.cpp @@ -0,0 +1,168 @@ +#include "TestSettingsProfile.h" +#include "core/AppSettings.h" +#include "core/RadioStateMemory.h" +#include "core/backends/hl2/Hl2Backend.h" +#include "gui/RfGainRestore.h" + +#include +#include +#include + +using namespace AetherSDR; + +namespace { +int failures = 0; +void check(bool condition, const char* label) +{ + std::printf("%s %s\n", condition ? "[ OK ]" : "[FAIL]", label); + if (!condition) { + ++failures; + } +} + +int bandGain(const RestoredRadioState& state, const QString& band) +{ + return state.extension.value(QStringLiteral("rfGain")).toObject() + .value(QStringLiteral("lnaDbByBand")).toObject().value(band).toInt(999); +} + +RestoredRadioState rememberedGain() +{ + RestoredRadioState state; + state.rfFrequencyHz = 14'074'000.0; + state.sampleRateHz = 48'000; + state.extensionSchemaVersion = 1; + state.extension = QJsonObject{ + {QStringLiteral("rfGain"), QJsonObject{ + {QStringLiteral("defaultDb"), 20}, + {QStringLiteral("lnaDbByBand"), QJsonObject{ + {QStringLiteral("20m"), -12}, {QStringLiteral("40m"), -6}}}}}}; + return state; +} + +// Exercise synchronous connect seeding and capture without starting transport. +// boardMaxRx skips the unicast discovery socket. No event loop is pumped: +// finishDspSetup cannot run, and disconnect cancels it before destruction. +// TEST-NET-1 alone would NOT make this socket-free. +class GainSession { +public: + QString panId; + int echoedGain = 999; + hl2::Hl2Backend backend; + + GainSession(const RestoredRadioState& state, std::optional pin = std::nullopt) + { + QObject::connect(&backend, &IRadioBackend::panCenterBandwidthChanged, + &backend, [this](const QString& id, double, double) { + panId = id; + }); + QObject::connect(&backend, &IRadioBackend::panRfGainChanged, + &backend, [this](const QString&, int gain) { + echoedGain = gain; + }); + backend.applyRestoredState(state); + RadioConnectRequest request; + request.host = QStringLiteral("192.0.2.1"); + request.serial = QStringLiteral("AA:BB:CC:DD:EE:01"); + request.params.insert(QStringLiteral("boardMaxRx"), 4); + if (pin.has_value()) { + request.params.insert(QStringLiteral("lnaGainDb"), *pin); + } + backend.connectRadio(request); + backend.setSliceFrequency(0, state.rfFrequencyHz); // publish the pan identity + check(!panId.isEmpty(), "connect seeding creates a usable pan identity"); + } + ~GainSession() { backend.disconnectRadio(); } + + int liveGain() const + { + return backend.healthSnapshot().values.value(QStringLiteral("lnaGainDb"), 999).toInt(); + } + int restoreDisplay(std::optional savedGain, int& writes) + { + const RadioCapabilities caps = backend.capabilities(); + return restoreLegacyRfGain(caps.family, + caps.clientSettingsDomains.testFlag(RadioCapabilities::ClientSettingsDomain::RfGain), + savedGain, liveGain(), [this, &writes](int gain) { + ++writes; + backend.setPanRfGain(panId, gain); + }); + } +}; +} // namespace + +int main(int argc, char** argv) +{ + TestSettingsProfile profile(QStringLiteral("aether-hl2-gain-restore")); + if (!profile.isValid()) { + return 1; + } + QCoreApplication app(argc, argv); + AppSettings::instance().load(); + const RadioSettingsScope scope(QStringLiteral("hl2"), QStringLiteral("AA:BB:CC:DD:EE:01")); + RadioCapabilities caps; + { + GainSession session(rememberedGain()); + caps = session.backend.capabilities(); + check(caps.clientSettingsDomains.testFlag(RadioCapabilities::ClientSettingsDomain::RfGain), + "HL2 retains the RF-gain domain required by per-band storage"); + int writes = 0; + check(session.restoreDisplay(20, writes) == -12 && writes == 0, + "startup displays the restored band gain without replaying the legacy +20"); + check(session.liveGain() == -12, "legacy display restore leaves live 20m gain at -12"); + session.backend.setSliceFrequency(0, 14'080'000.0); + check(bandGain(session.backend.currentOperatingState(), QStringLiteral("20m")) == -12, + "same-band capture preserves the saved 20m gain"); + session.backend.setSliceFrequency(0, 7'074'000.0); + check(session.liveGain() == -6 && session.echoedGain == -6, + "band hop applies and publishes 40m gain"); + session.backend.setSliceFrequency(0, 14'074'000.0); + check(session.liveGain() == -12 && session.echoedGain == -12, + "return to 20m applies and publishes its own gain"); + session.backend.setPanRfGain(session.panId, 5); + check(session.liveGain() == 5 && session.echoedGain == 5, + "operator gain change still applies and publishes"); + check(RadioStateMemory::store(scope, caps, session.backend.currentOperatingState()), + "updated gain persists through the production OperatingState store"); + } + { + GainSession session(RadioStateMemory::load(scope, caps)); + int writes = 0; + check(session.restoreDisplay(20, writes) == 5 && writes == 0 && session.liveGain() == 5, + "a recreated session restores the operator's +5 despite stale global +20"); + check(bandGain(session.backend.currentOperatingState(), QStringLiteral("40m")) == -6, + "saving 20m leaves 40m unchanged"); + } + { + GainSession session(rememberedGain(), 20); + check(session.liveGain() == 20, "explicit connect override really sets live gain to +20"); + check(bandGain(session.backend.currentOperatingState(), QStringLiteral("20m")) == -12, + "production capture preserves -12 while the connect override is active"); + session.backend.setSliceFrequency(0, 14'080'000.0); + check(bandGain(session.backend.currentOperatingState(), QStringLiteral("20m")) == -12, + "same-band tune cannot persist the temporary override"); + session.backend.setSliceFrequency(0, 7'074'000.0); + session.backend.setSliceFrequency(0, 14'074'000.0); + check(session.liveGain() == -12, "band writeback preserves the overridden start band"); + session.backend.setPanRfGain(session.panId, 5); + check(bandGain(session.backend.currentOperatingState(), QStringLiteral("20m")) == 5, + "operator changes still reach the production snapshot after a pin"); + } + // Cross-family compatibility at the exact display-restore seam. No Flex or + // Icom backend is instantiated or changed; their current domain is empty. + for (const QString& family : {QStringLiteral("flex"), QStringLiteral("icom"), + QStringLiteral("sim"), QStringLiteral("anan")}) { + int writes = 0; + const int result = restoreLegacyRfGain(family, false, 20, 7, + [&writes](int) { ++writes; }); + check(result == 7 && writes == 0, "radio-owned gain retains the existing no-replay behavior"); + } + int writes = 0; + check(restoreLegacyRfGain(u"other", true, 20, 7, + [&writes](int gain) { writes += gain == 20; }) == 20 && writes == 1, + "a non-HL2 client-owned family retains its existing saved replay"); + check(restoreLegacyRfGain(u"other", true, std::nullopt, 7, + [&writes](int) { ++writes; }) == 7 && writes == 1, + "an absent saved gain never writes a default"); + return failures ? 1 : 0; +} diff --git a/tests/tests.cmake b/tests/tests.cmake index c457903df..a2685cf09 100644 --- a/tests/tests.cmake +++ b/tests/tests.cmake @@ -4077,6 +4077,17 @@ add_executable(hl2_tx_level_policy_test ) target_include_directories(hl2_tx_level_policy_test PRIVATE src) add_test(NAME hl2_tx_level_policy_test COMMAND hl2_tx_level_policy_test) +# Socket-free HL2 gain persistence: boardMaxRx bypasses discovery; the test +# never pumps events and cancels DSP setup before it can start Metis UDP. +add_executable(hl2_gain_restore_test tests/hl2_gain_restore_test.cpp) +target_include_directories(hl2_gain_restore_test PRIVATE src tests) +target_link_libraries(hl2_gain_restore_test PRIVATE aethercore Qt6::Core) +add_test(NAME hl2_gain_restore_test COMMAND hl2_gain_restore_test) +add_executable(hl2_band_memory_test + tests/hl2_band_memory_test.cpp +) +target_include_directories(hl2_band_memory_test PRIVATE src) +add_test(NAME hl2_band_memory_test COMMAND hl2_band_memory_test) add_executable(slice_link_policy_test tests/slice_link_policy_test.cpp ) @@ -4408,6 +4419,7 @@ target_link_libraries(CAT_Flex_test PRIVATE Qt6::Core Qt6::Network) # directly (rather than linking aethercore) needs the vendored SQLite engine. # Conditional targets are guarded with if(TARGET ...). set(AETHER_SETTINGS_CONSUMERS + hl2_gain_restore_test icom_identity_test icom_control_profile_test control_resource_service_test