diff --git a/docs/architecture/radio-capabilities-map.md b/docs/architecture/radio-capabilities-map.md index f8db125ea..e53d431b3 100644 --- a/docs/architecture/radio-capabilities-map.md +++ b/docs/architecture/radio-capabilities-map.md @@ -35,6 +35,7 @@ traps and why the DAX crash guard is deliberately *not* the DAX capability. | Field | Flex | HL2 | Sim | Read at | Effect | |---|:--:|:--:|:--:|---|---| +| `canCreateSlices` | ✅ | ❌ | ❌ | `RadioModel::addSliceOnPan`, only without a command plane | Admission to the neutral backend's independent slice-creation hook on an existing pan. Flex and Sim use their existing command adapters without consulting this field, so the values shown are declarations, not a UI availability rule; do not gate +RX on this field alone. Capacity remains `maxSlices`; paired/fixed receiver topologies do not gain independent creation. Icom, ANAN and RTL explicitly declare false; RTL remains one slice in RFC #5468 P01. | | `family` | `"flex"` | `"hl2"` | `"sim"` | `MainWindow::rfGainSettingsKey` | Scopes the persisted RF-gain key per family | | `model` | from provider | `"Hermes-Lite 2"` | `"AetherSDR Demo"` | `FlexBackend::capabilities` | Key into the ModelCapabilities table | | `manufacturer` | `"FlexRadio"` | `"Hermes-Lite"` | `"AetherSDR"` | `MainWindow::refreshRadioIdentityLabels` | Status-bar make row ABOVE the model, shown only when the model string does not already carry the brand (`FLEX-8400M` does, `IC-705` does not). Display only — nothing branches on it. Icom: `"Icom"` | diff --git a/docs/automation-bridge.md b/docs/automation-bridge.md index a3c557841..87cb35c76 100644 --- a/docs/automation-bridge.md +++ b/docs/automation-bridge.md @@ -1679,7 +1679,7 @@ re-poll `get slices`. | `action` | `value` | effect | |---|---|---| -| `add` | optional `` | create a slice (radio-wide slot capacity is pre-checked; refused at the slice limit, naming any foreign occupant) | +| `add` | optional finite positive `` | request a slice through RadioModel (radio-wide slot capacity is pre-checked; refused at the slice limit, naming any foreign occupant). Omit the value for default placement; an explicit malformed, non-finite or non-positive value is an error, never a default-frequency fallback | | `remove` | `` | remove a slice (refuses the last one) | | `select` | `` | make a slice the active slice (`slice set active=1`) | | `tx` | `` | make a slice the TX slice — the external-split transition; radio enforces single-TX | @@ -1698,6 +1698,18 @@ re-poll `get slices`. | `fixture` | ` [A-H]` | disconnected-only test fixture: synthesize an owned slice through the normal slice-status path, optionally with a single radio `index_letter`, so `dumpTree` can assert UI without a radio | | `clearfixture` | `` | remove a slice created by `fixture`; when the final fixture is removed, restores the pre-fixture disconnected model/max-slice state | +Ordinary `add`/`remove` requests report acceptance, not completion. An accepted +request can still be pending; re-poll `get slices` for authoritative ownership. +Explicit invalid `add` values return `"slice add requires a finite positive +frequency in MHz"` after the capacity pre-check. A RadioModel refusal returns +`"refused: radio did not accept slice creation"` or +`"refused: radio did not accept slice removal"`; this includes unsupported +backend operations and does not imply that a wire command was sent. The latter +replaces the earlier non-Flex `"not supported on this radio (no Flex command +plane)"` response, so scripts matching that text must update. Removal retains +`"refused: cannot remove the last slice"` and `"no slice with id "` +for the local last-slice and unknown-ID checks, respectively. + For a manual SQL band/profile-restore check, compare `get slice`'s `squelch`/`squelchLevel` with `dumpTree`'s **RX applet → Squelch threshold** and the VFO SQL control immediately after the transition. A radio-driven diff --git a/src/core/AutomationServer.cpp b/src/core/AutomationServer.cpp index b20f1a53f..1e41fdcd9 100644 --- a/src/core/AutomationServer.cpp +++ b/src/core/AutomationServer.cpp @@ -7158,10 +7158,15 @@ QJsonObject AutomationServer::doSlice(const QString& action, const QString& arg) bool okF = false; const double freq = arg.toDouble(&okF); - if (okF && freq > 0) - radio->addSliceOnPan(radio->panId(), freq); // specific frequency - else - radio->addSlice(); // default (TX freq / active pan) + if (!arg.isEmpty() && (!okF || !std::isfinite(freq) || freq <= 0.0)) { + return err(QStringLiteral("slice add requires a finite positive frequency in MHz")); + } + const bool accepted = arg.isEmpty() + ? radio->addSlice() + : radio->addSliceOnPan(radio->panId(), freq); + if (!accepted) { + return err(QStringLiteral("refused: radio did not accept slice creation")); + } return QJsonObject{{QStringLiteral("ok"), true}, {QStringLiteral("slice"), QStringLiteral("add")}, {QStringLiteral("freq"), okF ? QJsonValue(freq) : QJsonValue()}, {QStringLiteral("requested"), true}, @@ -7176,12 +7181,9 @@ QJsonObject AutomationServer::doSlice(const QString& action, const QString& arg) return err(QStringLiteral("refused: cannot remove the last slice")); if (!radio->slice(id)) return err(QStringLiteral("no slice with id ") + arg); - // `slice remove` is Flex wire text and no seam verb exists for it yet - // — refuse rather than report ok for a command the model will drop - // (M0, #5263). - if (!radio->hasCommandPlane()) - return err(QStringLiteral("not supported on this radio (no Flex command plane)")); - radio->sendCommand(QStringLiteral("slice remove %1").arg(id)); + if (!radio->removeSlice(id)) { + return err(QStringLiteral("refused: radio did not accept slice removal")); + } return QJsonObject{{QStringLiteral("ok"), true}, {QStringLiteral("slice"), QStringLiteral("remove")}, {QStringLiteral("id"), id}}; } diff --git a/src/core/backends/IRadioBackend.h b/src/core/backends/IRadioBackend.h index 24ef46ffb..867c8ef80 100644 --- a/src/core/backends/IRadioBackend.h +++ b/src/core/backends/IRadioBackend.h @@ -313,6 +313,30 @@ class IRadioBackend : public QObject { // a single answer. virtual void setActiveSlice(int sliceId) { Q_UNUSED(sliceId); } + // ---- ordinary receive-slice lifecycle ---- + // panId is backend-owned and opaque; frequencyHz is absolute RF in Hz. + // True accepts ownership of a request, not confirmation of a new/removed + // slice. Publish confirmed state through sliceChanged / sliceRemoved; + // report a later failure through sliceLifecycleFailed. A false return is + // final refusal: callers must never fall back to another command plane. + // Fixed/paired receiver topologies keep the default refusal. Flex and Sim + // retain RadioModel's existing command-plane adapter for these requests. + // + // A backend must cancel pending work on disconnect/reconnect and discard + // completions from retired sessions or receiver instances before emitting + // state/failure. Reused slice integers alone cannot identify pending work. + virtual bool createSlice(const QString& panId, double frequencyHz) + { + Q_UNUSED(panId); + Q_UNUSED(frequencyHz); + return false; + } + virtual bool removeSlice(int sliceId) + { + Q_UNUSED(sliceId); + return false; + } + // ---- panadapter lifecycle ---- // // Bring up / tear down a panadapter (and, on a backend where a pan IS a @@ -892,6 +916,11 @@ class IRadioBackend : public QObject { // compared a slice count that never fell against maxSlices() and reported // "Slice capacity is full" on a radio with one receiver running. void sliceRemoved(int sliceId); + // Failure of an accepted ordinary lifecycle request. operation is "create" + // or "remove"; sliceId is -1 when creation never allocated a published ID. + // This is diagnostic, not a state delta or a split/TX completion protocol. + void sliceLifecycleFailed(const QString& operation, int sliceId, + const QString& reason); void meterUpdate(const QString& meterId, double value); // Normalized transmit-status delta (aetherd RFC 2.3 — TransmitModel diff --git a/src/core/backends/RadioCapabilities.h b/src/core/backends/RadioCapabilities.h index 62e0afa7c..e460dc14c 100644 --- a/src/core/backends/RadioCapabilities.h +++ b/src/core/backends/RadioCapabilities.h @@ -118,6 +118,12 @@ struct RadioCapabilities { QString manufacturer; // Receive + // Independent slice creation on an existing pan through the neutral backend + // hook. RadioModel consults this only without a command plane; Flex and Sim + // retain their command adapters regardless of this value. Do not use this + // field alone to gate +RX in the UI. Separate from maxSlices: a paired + // receiver/pan topology can support several slices but not this operation. + bool canCreateSlices = false; int maxSlices = 1; // independent demod slices the radio supports int maxPanadapters = 1; // simultaneous panadapters QVector sampleRatesHz; // supported per-receiver sample rates (Hz) diff --git a/src/core/backends/anan/AnanBackend.cpp b/src/core/backends/anan/AnanBackend.cpp index a1f8f5b21..e0038ce6e 100644 --- a/src/core/backends/anan/AnanBackend.cpp +++ b/src/core/backends/anan/AnanBackend.cpp @@ -275,6 +275,7 @@ RadioCapabilities AnanBackend::capabilities() const c.hasAgcThreshold = true; // Host receiver DSP implements threshold/off gain. c.manufacturer = QStringLiteral("Apache Labs"); c.model = QStringLiteral("ANAN-G2"); + c.canCreateSlices = false; c.maxSlices = 1; c.maxPanadapters = 1; c.sampleRatesHz = {48000, 96000, 192000, 384000, 768000, 1536000}; diff --git a/src/core/backends/flex/FlexBackend.cpp b/src/core/backends/flex/FlexBackend.cpp index 52df6a445..ffce64db3 100644 --- a/src/core/backends/flex/FlexBackend.cpp +++ b/src/core/backends/flex/FlexBackend.cpp @@ -143,6 +143,7 @@ RadioCapabilities FlexBackend::capabilities() const // derived-from-name truth used to *seed* the reported capabilities; a fuller // FlexBackend refines these from live radio status as touchpoints convert. const ModelCapabilities mc = capabilitiesFor(caps.model); + caps.canCreateSlices = true; caps.maxSlices = mc.maxSlices; // approx: pan capacity is not strictly slice count on real Flex hardware; // refined from live radio status in a later touchpoint conversion. diff --git a/src/core/backends/hl2/Hl2Backend.cpp b/src/core/backends/hl2/Hl2Backend.cpp index ad8e20d33..3f0a3294c 100644 --- a/src/core/backends/hl2/Hl2Backend.cpp +++ b/src/core/backends/hl2/Hl2Backend.cpp @@ -1436,6 +1436,7 @@ RadioCapabilities Hl2Backend::capabilities() const // maxSlices") is what this closes. const int ceiling = m_connected ? receiverCeiling() : std::max(1, m_ids.size()); + c.canCreateSlices = false; c.maxSlices = ceiling; c.maxPanadapters = ceiling; for (const int rate : kIqSampleRatesHz) diff --git a/src/core/backends/icom/IcomCivBackend.cpp b/src/core/backends/icom/IcomCivBackend.cpp index 18b78ba54..a04b57db1 100644 --- a/src/core/backends/icom/IcomCivBackend.cpp +++ b/src/core/backends/icom/IcomCivBackend.cpp @@ -278,6 +278,7 @@ RadioCapabilities IcomCivBackend::capabilities() const c.manufacturer = QStringLiteral("Icom"); c.model = QString::fromUtf8(m.name.data(), static_cast(m.name.size())); + c.canCreateSlices = false; c.maxSlices = m.receivers; c.maxPanadapters = m.hasScope ? m.receivers : 0; c.tuningMinHz = static_cast(m.tuningMinHz); diff --git a/src/core/backends/rtl/RtlSdrBackend.cpp b/src/core/backends/rtl/RtlSdrBackend.cpp index 425ed6ec3..01e86f95e 100644 --- a/src/core/backends/rtl/RtlSdrBackend.cpp +++ b/src/core/backends/rtl/RtlSdrBackend.cpp @@ -120,6 +120,7 @@ RadioCapabilities RtlSdrBackend::capabilities() const c.cwPitchStepHz = 10; // Receiver limits + c.canCreateSlices = false; c.maxSlices = 1; c.maxPanadapters = 1; diff --git a/src/core/backends/sim/SimBackend.cpp b/src/core/backends/sim/SimBackend.cpp index 2022059a4..668bc6589 100644 --- a/src/core/backends/sim/SimBackend.cpp +++ b/src/core/backends/sim/SimBackend.cpp @@ -273,6 +273,7 @@ RadioCapabilities SimBackend::capabilities() const caps.model = demoModelName(); caps.fmTonePresentation = FmTonePresentation::Legacy; caps.fmDtcsCodes = {}; + caps.canCreateSlices = false; caps.maxSlices = 1; // Phase 1: a single slice. Phase 2 raises this. // Four receivers since #4887 phase 4 — enough to exercise the workspace // canvas's per-pan items and measure the multi-pan render budget in CI diff --git a/src/gui/MainWindow_Session.cpp b/src/gui/MainWindow_Session.cpp index df3054e34..48611ace4 100644 --- a/src/gui/MainWindow_Session.cpp +++ b/src/gui/MainWindow_Session.cpp @@ -936,6 +936,10 @@ void MainWindow::wireRadioModel() QString("%1 supports a maximum of %2 panadapters") .arg(model).arg(limit), 4000); }); + connect(&m_radioModel, &RadioModel::sliceLifecycleFailed, this, + [this](const QString& operation, int, const QString& reason) { + statusBar()->showMessage(tr("Slice %1 failed: %2").arg(operation, reason), 6000); + }); connect(&m_radioModel, &RadioModel::sliceCreateFailed, this, [this](int limit, const QString& model) { statusBar()->showMessage( diff --git a/src/gui/MainWindow_Wiring.cpp b/src/gui/MainWindow_Wiring.cpp index 6fc91adb4..fb1a05a81 100644 --- a/src/gui/MainWindow_Wiring.cpp +++ b/src/gui/MainWindow_Wiring.cpp @@ -5295,12 +5295,11 @@ void MainWindow::wirePanadapter(PanadapterApplet* applet) }); connect(sw, &SpectrumWidget::sliceCloseRequested, this, [this](int sliceId) { - if (m_radioModel.slices().size() <= 1) return; - if (SliceModel* slice = m_radioModel.slice(sliceId); - centerLockActiveForSlice(slice)) { - clearCenterLockForPan(slice->panId(), true); + // onSliceRemoved clears center lock only after authoritative removal. + // A refused or pending request must leave the current receiver intact. + if (!m_radioModel.removeSlice(sliceId)) { + statusBar()->showMessage(tr("Cannot remove this slice"), 4000); } - m_radioModel.sendCommand(QString("slice remove %1").arg(sliceId)); }); connect(sw, &SpectrumWidget::sliceCreateRequested, this, [this, applet](double freqMhz) { @@ -5713,12 +5712,11 @@ void MainWindow::wireVfoWidget(VfoWidget* w, SliceModel* s) syncKiwiSdrDiversityEscControls(); }); connect(w, &VfoWidget::closeSliceRequested, this, [this, sliceId]() { - if (m_radioModel.slices().size() <= 1) return; - if (SliceModel* slice = m_radioModel.slice(sliceId); - centerLockActiveForSlice(slice)) { - clearCenterLockForPan(slice->panId(), true); + // onSliceRemoved clears center lock only after authoritative removal. + // A refused or pending request must leave the current receiver intact. + if (!m_radioModel.removeSlice(sliceId)) { + statusBar()->showMessage(tr("Cannot remove this slice"), 4000); } - m_radioModel.sendCommand(QString("slice remove %1").arg(sliceId)); }); connect(w, &VfoWidget::stepTuneRequested, this, [this, sliceId](double mhz) { if (auto* sl = m_radioModel.slice(sliceId)) diff --git a/src/models/RadioModel.cpp b/src/models/RadioModel.cpp index b2a94de25..6de3606dc 100644 --- a/src/models/RadioModel.cpp +++ b/src/models/RadioModel.cpp @@ -893,62 +893,7 @@ void RadioModel::setupBackend(const QString& family) // per backend, so every consumer of rxDemodAudioReady is family-blind and // survives a swap without rewiring. See the signal's header comment. wireRxDemodAudioBus(); - - // aetherd RFC 2.3: the first converted touchpoint. The backend decodes the - // universal pan center/bandwidth from Flex status and emits this normalized - // signal; RadioModel drives the addressed PanadapterModel. (Template for the - // remaining universal fields and the other mixed models.) - connect(m_backend.get(), &IRadioBackend::panCenterBandwidthChanged, this, - [this](const QString& panId, double centerMhz, double bandwidthMhz) { - // aetherd Gap B (Step 2c): remember the geometry even when no - // PanadapterModel resolves — an HL2 session has none, and the neutral - // waterfall rows below still need the band edges. - // Which pane this geometry belongs to. Was pan index 0 unconditionally, - // which is right at one receiver and wrong at four: every pan's - // waterfall then scaled against the first pan's band edges, so three of - // them drew the correct spectrum over the wrong frequency axis. - const int panIdx = m_flexBackend ? kNeutralPanIndexNone - : neutralPanIndexFor(panId); - if (panIdx != kNeutralPanIndexNone) { - m_backendPanCenterMhz[panIdx] = centerMhz; - m_backendPanBandwidthMhz[panIdx] = bandwidthMhz; - } - auto* pan = resolveBackendPan(panId); - if (!pan && !m_flexBackend && !m_connection) { - // aetherd Gap B (Step 2c): materialise the pan for a non-Flex backend - // WITHOUT a wire. No Flex "display pan" status exists to create one, - // so without this there is no PanadapterModel to route frames to and - // the UI never builds a pane — the render path was falling back to the - // active spectrum widget, which is null when no pan applet exists. - // - // The !m_connection gate: a backend that vends its own RadioConnection - // (the demo's Route A SimBackend) claims its pans from its own wire - // status, so minting a neutral twin here created a second, ownerless - // pan applet — the ghost "Slice A" of #4671. Its pans arrive through - // the claim path; only truly wire-less backends (HL2) materialise. - // - // One pane per backend pan id, so a multi-receiver backend gets a - // pane each instead of four receivers sharing one. - pan = ensureOwnedPanadapter(neutralPanIdString(panIdx)); - if (pan) - pan->setWaterfallId(neutralWfIdString(panIdx)); - } - if (!pan) return; - const bool spanChanged = pan->setCenterBandwidth(centerMhz, bandwidthMhz); - // A backend that snaps a requested span to fixed hardware rates (HL2 - // offers four) reports back the span it actually runs. When that equals - // what the model already held, the change-gated setter emits nothing — - // and that is exactly the case the view most needs to hear about, because - // it applied the operator's request optimistically and is now wider than - // the data. Scoped to backends that stream raw spectra so Flex status - // echoes, which are frequent and never refuse anything, keep their - // existing no-op behaviour. (#4470) - if (!spanChanged && shapesDisplayRatesLocally()) { - pan->republishCenterBandwidth(); - } - // Legacy signal MainWindow still consumes (unchanged behavior). - emit panadapterInfoChanged(pan->centerMhz(), pan->bandwidthMhz()); - }); + wireBackendReceiverState(); // aetherd RFC 2.3: min/max dBm — the second universal pan field. The backend // decodes the display level range; RadioModel applies it to the addressed @@ -1101,26 +1046,6 @@ void RadioModel::setupBackend(const QString& family) } }); - // The backend confirms a slice is GONE. Paired with panRemoved above, - // because on a backend where a slice IS a receiver, closing one retires - // both. Without this the SliceModel outlived its receiver and every later - // capacity check counted it. - connect(m_backend.get(), &IRadioBackend::sliceRemoved, this, - [this](int sliceId) { - SliceModel* s = slice(sliceId); - if (!s) - return; - m_slices.removeAll(s); - qCDebug(lcProtocol) << "RadioModel: backend slice removed" << sliceId; - // Removing the transmit slice moves transmit without any slice delta to - // announce it, so the TX-waveform meter binding has to be recomputed - // here as well as on sliceChanged. - m_meterModel.setActiveTxSlice(activeTxSliceNum()); - emit sliceRemoved(sliceId); - emit slotOccupancyChanged(sliceId); - s->deleteLater(); - }); - // The pan's front end is wide (its band filter had to be bypassed). connect(m_backend.get(), &IRadioBackend::panWideChanged, this, [this](const QString& panId, bool wide) { @@ -1271,97 +1196,464 @@ void RadioModel::setupBackend(const QString& family) static_cast(value)); }); - // aetherd RFC 2.3: SliceModel touchpoint. The backend decodes Flex slice - // status into a typed SliceDelta; RadioModel routes it to the addressed slice. - // This is an AutoConnection: because FlexBackend shares RadioModel's thread it - // resolves to a synchronous DirectConnection today, so a slice just appended - // to m_slices is populated before the sliceAdded UI notify below. (If a - // backend is ever moved to a worker thread this becomes queued — the ordering - // guarantee would then need an explicit populate step, not Qt::DirectConnection - // across threads. #4068 review.) - connect(m_backend.get(), &IRadioBackend::sliceChanged, this, - [this](int sliceId, const SliceDelta& delta) { - SliceModel* s = slice(sliceId); - // A non-Flex backend labels its pan with its own id ("hl2"), but the UI - // associates a slice with a pan by matching PanadapterModel::panId(). Left - // unmapped the slice belongs to no pan, so no slice flag is drawn on the - // panadapter even though the VFO shows the right frequency. Re-address the - // delta at the pan we materialised. - // - // ...but ONLY when we materialised one. A backend that vends its own - // RadioConnection claims its pans from its own wire, so its pan ids ARE - // the model keys — the same discriminator resolveBackendPan() uses. The - // demo announces slice.panId = "0x40000000", already a key; rewriting it - // into the neutral space pointed the slice at a pan nothing holds, and - // every slice-to-pane association keys off that exact equality (pan title - // bar, adaptive RX filter, auto-squelch, centre-lock, band recall). (#4671) - SliceDelta mapped = delta; - if (!m_flexBackend && !m_connection && mapped.panId) { - // Through the SAME allocator the geometry handler uses, so a slice - // lands on the pane its own receiver feeds. Pinned to index 0 while - // one pan existed; at four receivers that put every slice flag on - // the first panadapter. - mapped.panId = neutralPanIdString(neutralPanIndexFor(*mapped.panId)); + // aetherd RFC 2.3: TransmitModel touchpoint. The backend decodes the five + // Flex transmit-family status planes (transmit/interlock/ATU/APD/APD-sampler) + // into a typed TransmitDelta; RadioModel drives the TransmitModel. Driven + // synchronously from the matching decode*Status() calls in the status + // handlers (main-thread AutoConnection → DirectConnection). + connect(m_backend.get(), &IRadioBackend::transmitChanged, this, + [this](const TransmitDelta& delta) { + // A backend-reported MOX edge is radio state, not local intent. + // Keep it out of TransmitModel::moxChanged, whose consumers + // own this client's audio, DAX, recorder and serial PTT. + if (delta.mox) + publishBackendTransmitEdge(*delta.mox); + m_transmitModel.applyChanges(delta); + if (delta.cwSpeed && !usesFlexCommandPlane()) { + m_cwxModel.adoptSpeed(*delta.cwSpeed); + } + }); + connect(m_backend.get(), &IRadioBackend::keyingStateConfirmed, + this, &RadioModel::radioTransmitConfirmed); + + // aetherd 2.4 (#4094): power-amp status decoded in the backend drives AmpModel. + connect(m_backend.get(), &IRadioBackend::amplifierChanged, this, + [this](const AmpDelta& delta) { m_amplifier.applyChanges(delta); }); + + // aetherd 2.4 (#4092): TGXL tuner status decoded in the backend drives TunerModel. + connect(m_backend.get(), &IRadioBackend::tunerChanged, this, + [this](const TunerDelta& delta) { m_tunerModel.applyChanges(delta); }); + + // aetherd RFC 2.3 (RadioModel residual): radio-global status decoded in the + // backend drives RadioModel's own state via applyRadioChanges. + connect(m_backend.get(), &IRadioBackend::radioChanged, this, + [this](const RadioDelta& delta) { applyRadioChanges(delta); }); + + // aetherd RFC 2.3 (RadioModel residual): GPS / memory-slot / profile status + // decoded in the backend drive RadioModel's own state via the apply* methods. + connect(m_backend.get(), &IRadioBackend::gpsChanged, this, + [this](const GpsDelta& delta) { applyGpsChanges(delta); }); + connect(m_backend.get(), &IRadioBackend::memoryChanged, this, + [this](const MemoryDelta& delta) { applyMemoryChanges(delta); }); + connect(m_backend.get(), &IRadioBackend::memoryRefreshStarted, this, + [this](int total) { + m_memoryRefreshActive = true; + m_memoryImportFailures = 0; + emit memoryRefreshStarted(total); + }); + connect(m_backend.get(), &IRadioBackend::memoryRefreshProgress, this, + &RadioModel::memoryRefreshProgress); + connect(m_backend.get(), &IRadioBackend::memoryRefreshFinished, this, + [this](bool success, int completed, int total) { + // The backend finishes only after publishing its final delta. Commit + // the bank before announcing success, including empty-channel removals. + const bool saved = !m_memoryRefreshActive || !usesLocalMemoryBank() + || m_localMemories.flush(); + if (!saved) { + emit configurationWarning(QStringLiteral("Memory Sync could not save the bank: %1") + .arg(m_localMemories.lastError())); } - if (!s && !m_flexBackend) { - // aetherd Gap B (Step 2c): no Flex "slice" status ever runs for a - // non-Flex backend, so nothing would create the model and every delta - // would be dropped (slice panel stuck at 0.000000). Materialise it on - // the first delta and route mode intents back through the seam. - if (auto it = m_staleSlices.find(sliceId); - it != m_staleSlices.end() && it.value()) { - s = it.value(); - m_staleSlices.erase(it); - qCDebug(lcProtocol) << "RadioModel: reclaimed non-Flex slice" - << sliceId << "from previous session"; - m_slices.append(s); - s->applyChanges(mapped); - m_meterModel.setActiveTxSlice(activeTxSliceNum()); - refreshTxPowerLimit(); - // Reclaim deliberately does not emit sliceAdded: the UI already - // owns this object. Notify non-UI observers through the existing - // occupancy edge so adapters that detached on disconnect can - // reattach and republish it. - emit slotOccupancyChanged(sliceId); - // Reuse the same SliceModel so every UI subscriber — including - // RX Controls — stays attached. A sliceAdded here would build a - // duplicate VFO for an object the UI already owns. + const int stored = saved ? std::max(0, completed - m_memoryImportFailures) : 0; + success = success && saved && m_memoryImportFailures == 0; + m_memoryRefreshActive = false; + m_memoryImportFailures = 0; + emit memoryRefreshFinished(success, stored, total); + }); + connect(m_backend.get(), &IRadioBackend::profileChanged, this, + [this](const ProfileDelta& delta) { applyProfileChanges(delta); }); + + // NOTE: the three connects below hang off the Flex PanadapterStream, which a + // backend carrying its own IQ does not have. They ran unconditionally, so an + // HL2 setup logged "QObject::connect: invalid nullptr parameter" and wired + // nothing -- including meterDataReady, which is part of why the S-meter has + // never reached the UI on this backend. + // + // Guarded individually rather than with an early return: the m_connection + // and IRadioBackend connects further down are interleaved with these and ARE + // needed by a self-IQ backend. Returning early here would have skipped + // IRadioBackend::connected and broken HL2 connection outright. + + // Centralized DAX RX channel ownership (#3305): PanadapterStream decides + // WHEN a dax_rx stream must exist (refcounted acquire/release from the + // bridge/TCI/RADE); RadioModel is the command plane that makes it so. + if (m_panStream) + connect(m_panStream, &PanadapterStream::daxStreamCreateNeeded, + this, [this](int ch) { + if (!isConnected()) { + // Dropped create (connect gap): tell the manager so the latch + // clears and its retry cadence re-fires — otherwise the channel + // wedges with createPending stuck true (the #3669 wedge class). + m_panStream->notifyDaxCreateFailed(ch); + return; + } + sendCmd(QString("stream create type=dax_rx dax_channel=%1").arg(ch), + [this, ch](int code, const QString& body) { + if (code != 0) { + qCWarning(lcDax) << "RadioModel: dax_rx stream create for channel" + << ch << "failed, code" << Qt::hex << code << body; + m_panStream->notifyDaxCreateFailed(ch); return; } - s = new SliceModel(sliceId, this); - connect(s, &SliceModel::modeChangeRequested, this, - [this, s](const QString& mode) { - if (m_backend) m_backend->setSliceMode(s->sliceId(), mode); - }); - // Tuning and filter intents route through the seam too. A non-Flex - // backend never sees SliceModel::commandReady (that carries Flex - // wire text through the Flex-only slice sink), so without these the - // operator's tune/filter changes update the UI and are then dropped. - // Both signals are OPERATOR-issued only — radio-status application - // does not emit them — so echoing radio state back as a command - // cannot happen (Principle II). - connect(s, &SliceModel::frequencyCommandIssued, this, - [this, s](double mhz) { - if (m_backend) m_backend->setSliceFrequency(s->sliceId(), mhz * 1.0e6); - }); - connect(s, &SliceModel::filterCommandIssued, this, - [this, s](int lowHz, int highHz) { - if (m_backend) m_backend->setSliceFilter(s->sliceId(), lowHz, highHz); - }); - // AGC is the same shape: the RX applet's mode combo and threshold - // slider drive SliceModel, whose Flex wire text a non-Flex backend - // never sees. Without this the controls move, the model updates and - // the DSP keeps whatever it was opened with — a dead slider. - connect(s, &SliceModel::agcCommandIssued, this, - [this, s](const QString& mode, int thresholdDb) { - if (m_backend) m_backend->setSliceAgc(s->sliceId(), mode, thresholdDb); - }); - // Receive DSP the radio runs. Same reasoning as AGC above: the - // applet toggles drive SliceModel, whose Flex wire text a non-Flex - // backend never sees, so without these the controls move and the - // radio's own NR/NB/notch/squelch keep whatever state they had. - connect(s, &SliceModel::noiseReductionCommandIssued, this, - [this, s](bool on, int level) { + // Success needs no action here. The #1439 legacy client- + // registration nudge is decided in handleDaxRxStreamRegistry, when + // the registration status has definitively told us whether the + // radio auto-bound the stream (slice=) — deciding here + // would race that status: on WAN/SmartLink (and any firmware that + // binds after the create reply) the binding isn't known yet, so a + // reply-first ordering would fire a same-value `slice set dax=` + // re-assert and blip audio, the very thing the gate avoids (#4017). + }); + }); + if (m_panStream) + connect(m_panStream, &PanadapterStream::daxStreamRemoveNeeded, + this, [this](quint32 streamId, int ch) { + Q_UNUSED(ch); + if (!isConnected()) return; + sendCommand(QString("stream remove 0x%1").arg(streamId, 0, 16)); + }); + + // RadioConnection (created + owned by the backend above, on its own worker + // thread #502 so TCP I/O never blocks paintEvent) — wire its signals to us. + // Signals from RadioConnection auto-queue to main thread (#502) + // + // RadioConnection is the Flex TCP command channel; a self-IQ backend has + // none and m_connection is null, so each of these logged an "invalid + // nullptr parameter" connect. The lifecycle it would have carried arrives + // through the neutral IRadioBackend signals below instead, which is why the + // block after this one is gated on !m_connection. + if (m_connection) { + connect(m_connection, &RadioConnection::statusReceived, + this, &RadioModel::onStatusReceived); + connect(m_connection, &RadioConnection::messageReceived, + this, &RadioModel::onMessageReceived); + connect(m_connection, &RadioConnection::connected, + this, &RadioModel::onConnected); + connect(m_connection, &RadioConnection::disconnected, + this, &RadioModel::onDisconnected); + connect(m_connection, &RadioConnection::errorOccurred, + this, &RadioModel::onConnectionError); + connect(m_connection, &RadioConnection::versionReceived, + this, &RadioModel::onVersionReceived); + + // Response callbacks: RadioConnection emits commandResponse on worker thread, + // we dispatch to the matching callback on the main thread. (#502) + connect(m_connection, &RadioConnection::commandResponse, + this, [this](quint32 seq, int code, const QString& body) { + auto it = m_pendingCallbacks.find(seq); + if (it != m_pendingCallbacks.end()) { + it.value()(code, body); + m_pendingCallbacks.erase(it); + } + }); + + } // if (m_connection) + + // aetherd Gap B (Step 2b): a backend that does not drive the lifecycle + // through a Flex RadioConnection drives it through the neutral + // IRadioBackend signals instead. + // + // The guard MUST mirror the connect dispatch in connectToRadio(): that does + // `if (m_connection) else if (m_backend) `, so + // whichever side initiates the connect is the side that reports the lifecycle. + // Hence "!m_connection" here. + // + // A previous revision relaxed this to "!m_flexBackend" so that + // SimBackend::disconnectRadio() (the `sim disconnect` fault) would be heard — + // it emitted IRadioBackend::disconnected into nothing, leaving the model + // "connected" with dead audio. But SimBackend is an RFC #4288 Route A hybrid: + // it vends a synthetic RadioConnection AND re-emits that connection's + // lifecycle as its own IRadioBackend signals. With the relaxed guard, sim was + // the one family where BOTH blocks were live, so a single wire event reached + // onConnected/onDisconnected TWICE — running registerAsGuiClient twice (two + // GUI-client batches, two 10 s UDP health timers, a second m_panStream->start()), + // emitting connectionStateChanged twice, and staging session models twice + // (which zeroed m_staleSessionOwnHandle and defeated the #3977 reclaim guard). + // + // The real fix for `sim disconnect` belongs on the other side of the seam: + // SimBackend::disconnectRadio() now tears down its synthetic connection, so the + // wire reports the disconnect through this single path. See SimBackend.cpp. + if (!m_connection) { + connect(m_backend.get(), &IRadioBackend::connected, + this, &RadioModel::onConnected); + connect(m_backend.get(), &IRadioBackend::disconnected, + this, &RadioModel::onDisconnected); + connect(m_backend.get(), &IRadioBackend::connectionError, + this, &RadioModel::onConnectionError); + // Advisory only — deliberately NOT routed through onConnectionError, + // which starts the reconnect timer. Re-emitted for the UI to surface. + connect(m_backend.get(), &IRadioBackend::configurationWarning, + this, &RadioModel::configurationWarning); + } + + // Transport counters from a backend that owns its own socket. Wired + // unconditionally: a backend that measures nothing never emits this, and one + // that does is the only source the network readouts have. + connect(m_backend.get(), &IRadioBackend::linkStatsUpdated, + this, &RadioModel::applyBackendLinkStats); + + // Forward VITA-49 meter packets to MeterModel (cross-thread, auto-queued) + if (m_panStream) + connect(m_panStream, &PanadapterStream::meterDataReady, + &m_meterModel, &MeterModel::updateValues); + + // HAND THE FRESH BACKEND THE MIC GAIN THE MODEL ALREADY HOLDS. + // + // The seam wired in the constructor carries operator INTENT — it fires when + // the slider moves, and a backend rebuild is not the slider moving. So + // without this, a family swap silently parts the two: the new modulator is + // constructed at its own 1.0 default (Hl2TxDsp::m_micGain) while + // TransmitModel::m_micLevel still holds the operator's position, because + // nothing resets that model and micLevel is not persisted for + // applyRestoredState() to restore. Connect an HL2, set MIC to 80, visit the + // demo or a Flex, come back: the slider reads 80, the snapshot's micLevel + // reads 80, and the radio is transmitting at unity. + // + // That is the readback-agreeing-with-the-failure shape this whole change + // exists to eliminate, so it cannot be left standing one seam over. Pushing + // here rather than in the connect path because the disagreement is created + // by CONSTRUCTION, not by connecting — the modulator is wrong the moment it + // exists, and a backend that is never connected should still answer + // healthSnapshot() honestly. + // + // Free on the constructor's own call, where TransmitModel is at its 50 and + // 50 maps to the 1.0 the modulator already holds. Same Flex gate as the + // seam: on a Flex the slider's `transmit set miclevel=` reaches the radio's + // own preamp and this must not double it. + if (m_backend && !usesFlexCommandPlane()) + m_backend->setMicGain(m_transmitModel.micLevel()); +} + +void RadioModel::applyBackendLinkStats(const IRadioBackend::LinkStats& stats) +{ + if (!stats.reported) + return; + + const bool first = !m_linkStats.reported; + m_linkStats = stats; + // What this transport can MEASURE, latched separately from what it measured + // this second. Sticky-once-true so a window that closes with no samples in + // it does not flip a readout back to "not measured" mid-session, and read by + // hasLinkRtt() / hasLinkTiming() after stopNetworkMonitor() has dropped the + // counters. See the member declaration for why the distinction matters. + m_backendLinkShape.reports = true; + if (stats.rttMs >= 0) + m_backendLinkShape.hasRtt = true; + if (stats.gapMs >= 0) + m_backendLinkShape.hasTiming = true; + + if (first) { + // First snapshot of the session. resetNetworkHealthSamples() (called + // from the shared reset) now reads the new source, so the deltas are + // seeded from THIS snapshot rather than from zero — otherwise a + // reconnect's entire prior packet count lands in the first loss-window + // sample and scores the link as a catastrophe on its first second. + // + // Shared with startNetworkMonitor() rather than open-coded: the two + // drifted once, and the field this path had forgotten (m_lastPingRtt) + // is invisible on a transport that reports rttMs < 0, so the previous + // Flex session's RTT scored the HL2 link with nothing on screen to + // contradict it. + resetNetworkQualitySession(); + } + + if (stats.rttMs >= 0) + m_lastPingRtt = stats.rttMs; + + evaluateNetworkQuality(); + + // The heartbeat is a statement about the RADIO, not about the timer that + // asked. Only a tick that saw fresh traffic counts as a beat; a tick on a + // silent link deliberately says nothing, so MainWindow's miss timer runs + // out and the indicator goes to its alarm state. + if (stats.alive) + emit pingReceived(); +} + +void RadioModel::wireBackendReceiverState() +{ + if (!m_backend) { + return; + } + const quint64 generation = m_backendReceiverGeneration; + // aetherd RFC 2.3: the first converted touchpoint. The backend decodes the + // universal pan center/bandwidth from Flex status and emits this normalized + // signal; RadioModel drives the addressed PanadapterModel. (Template for the + // remaining universal fields and the other mixed models.) + connect(m_backend.get(), &IRadioBackend::panCenterBandwidthChanged, this, + [this, generation](const QString& panId, double centerMhz, double bandwidthMhz) { + // Qt may already have queued this call before sender destruction. + if (generation != m_backendReceiverGeneration) { + return; + } + // aetherd Gap B (Step 2c): remember the geometry even when no + // PanadapterModel resolves — an HL2 session has none, and the neutral + // waterfall rows below still need the band edges. + // Which pane this geometry belongs to. Was pan index 0 unconditionally, + // which is right at one receiver and wrong at four: every pan's + // waterfall then scaled against the first pan's band edges, so three of + // them drew the correct spectrum over the wrong frequency axis. + const int panIdx = m_flexBackend ? kNeutralPanIndexNone + : neutralPanIndexFor(panId); + if (panIdx != kNeutralPanIndexNone) { + m_backendPanCenterMhz[panIdx] = centerMhz; + m_backendPanBandwidthMhz[panIdx] = bandwidthMhz; + } + auto* pan = resolveBackendPan(panId); + if (!pan && !m_flexBackend && !m_connection) { + // aetherd Gap B (Step 2c): materialise the pan for a non-Flex backend + // WITHOUT a wire. No Flex "display pan" status exists to create one, + // so without this there is no PanadapterModel to route frames to and + // the UI never builds a pane — the render path was falling back to the + // active spectrum widget, which is null when no pan applet exists. + // + // The !m_connection gate: a backend that vends its own RadioConnection + // (the demo's Route A SimBackend) claims its pans from its own wire + // status, so minting a neutral twin here created a second, ownerless + // pan applet — the ghost "Slice A" of #4671. Its pans arrive through + // the claim path; only truly wire-less backends (HL2) materialise. + // + // One pane per backend pan id, so a multi-receiver backend gets a + // pane each instead of four receivers sharing one. + pan = ensureOwnedPanadapter(neutralPanIdString(panIdx)); + if (pan) + pan->setWaterfallId(neutralWfIdString(panIdx)); + } + if (!pan) return; + const bool spanChanged = pan->setCenterBandwidth(centerMhz, bandwidthMhz); + // A backend that snaps a requested span to fixed hardware rates (HL2 + // offers four) reports back the span it actually runs. When that equals + // what the model already held, the change-gated setter emits nothing — + // and that is exactly the case the view most needs to hear about, because + // it applied the operator's request optimistically and is now wider than + // the data. Scoped to backends that stream raw spectra so Flex status + // echoes, which are frequent and never refuse anything, keep their + // existing no-op behaviour. (#4470) + if (!spanChanged && shapesDisplayRatesLocally()) { + pan->republishCenterBandwidth(); + } + // Legacy signal MainWindow still consumes (unchanged behavior). + emit panadapterInfoChanged(pan->centerMhz(), pan->bandwidthMhz()); + }); + + // The backend confirms a slice is GONE. Paired with panRemoved above, + // because on a backend where a slice IS a receiver, closing one retires + // both. Without this the SliceModel outlived its receiver and every later + // capacity check counted it. + connect(m_backend.get(), &IRadioBackend::sliceRemoved, this, + [this, generation](int sliceId) { + // Qt may already have queued this call before sender destruction. + if (generation != m_backendReceiverGeneration) { + return; + } + SliceModel* s = slice(sliceId); + if (!s) + return; + m_slices.removeAll(s); + qCDebug(lcProtocol) << "RadioModel: backend slice removed" << sliceId; + // Removing the transmit slice moves transmit without any slice delta to + // announce it, so the TX-waveform meter binding has to be recomputed + // here as well as on sliceChanged. + m_meterModel.setActiveTxSlice(activeTxSliceNum()); + emit sliceRemoved(sliceId); + emit slotOccupancyChanged(sliceId); + s->deleteLater(); + }); + + // aetherd RFC 2.3: SliceModel touchpoint. The backend decodes Flex slice + // status into a typed SliceDelta; RadioModel routes it to the addressed slice. + // This is an AutoConnection: because FlexBackend shares RadioModel's thread it + // resolves to a synchronous DirectConnection today, so a slice just appended + // to m_slices is populated before the sliceAdded UI notify below. (If a + // backend is ever moved to a worker thread this becomes queued — the ordering + // guarantee would then need an explicit populate step, not Qt::DirectConnection + // across threads. #4068 review.) + connect(m_backend.get(), &IRadioBackend::sliceChanged, this, + [this, generation](int sliceId, const SliceDelta& delta) { + // Qt may already have queued this call before sender destruction. + if (generation != m_backendReceiverGeneration) { + return; + } + SliceModel* s = slice(sliceId); + // A non-Flex backend labels its pan with its own id ("hl2"), but the UI + // associates a slice with a pan by matching PanadapterModel::panId(). Left + // unmapped the slice belongs to no pan, so no slice flag is drawn on the + // panadapter even though the VFO shows the right frequency. Re-address the + // delta at the pan we materialised. + // + // ...but ONLY when we materialised one. A backend that vends its own + // RadioConnection claims its pans from its own wire, so its pan ids ARE + // the model keys — the same discriminator resolveBackendPan() uses. The + // demo announces slice.panId = "0x40000000", already a key; rewriting it + // into the neutral space pointed the slice at a pan nothing holds, and + // every slice-to-pane association keys off that exact equality (pan title + // bar, adaptive RX filter, auto-squelch, centre-lock, band recall). (#4671) + SliceDelta mapped = delta; + if (!m_flexBackend && !m_connection && mapped.panId) { + // Through the SAME allocator the geometry handler uses, so a slice + // lands on the pane its own receiver feeds. Pinned to index 0 while + // one pan existed; at four receivers that put every slice flag on + // the first panadapter. + mapped.panId = neutralPanIdString(neutralPanIndexFor(*mapped.panId)); + } + if (!s && !m_flexBackend) { + // aetherd Gap B (Step 2c): no Flex "slice" status ever runs for a + // non-Flex backend, so nothing would create the model and every delta + // would be dropped (slice panel stuck at 0.000000). Materialise it on + // the first delta and route mode intents back through the seam. + if (auto it = m_staleSlices.find(sliceId); + it != m_staleSlices.end() && it.value()) { + s = it.value(); + m_staleSlices.erase(it); + qCDebug(lcProtocol) << "RadioModel: reclaimed non-Flex slice" + << sliceId << "from previous session"; + m_slices.append(s); + s->applyChanges(mapped); + m_meterModel.setActiveTxSlice(activeTxSliceNum()); + refreshTxPowerLimit(); + // Reclaim deliberately does not emit sliceAdded: the UI already + // owns this object. Notify non-UI observers through the existing + // occupancy edge so adapters that detached on disconnect can + // reattach and republish it. + emit slotOccupancyChanged(sliceId); + // Reuse the same SliceModel so every UI subscriber — including + // RX Controls — stays attached. A sliceAdded here would build a + // duplicate VFO for an object the UI already owns. + return; + } + s = new SliceModel(sliceId, this); + connect(s, &SliceModel::modeChangeRequested, this, + [this, s](const QString& mode) { + if (m_backend) m_backend->setSliceMode(s->sliceId(), mode); + }); + // Tuning and filter intents route through the seam too. A non-Flex + // backend never sees SliceModel::commandReady (that carries Flex + // wire text through the Flex-only slice sink), so without these the + // operator's tune/filter changes update the UI and are then dropped. + // Both signals are OPERATOR-issued only — radio-status application + // does not emit them — so echoing radio state back as a command + // cannot happen (Principle II). + connect(s, &SliceModel::frequencyCommandIssued, this, + [this, s](double mhz) { + if (m_backend) m_backend->setSliceFrequency(s->sliceId(), mhz * 1.0e6); + }); + connect(s, &SliceModel::filterCommandIssued, this, + [this, s](int lowHz, int highHz) { + if (m_backend) m_backend->setSliceFilter(s->sliceId(), lowHz, highHz); + }); + // AGC is the same shape: the RX applet's mode combo and threshold + // slider drive SliceModel, whose Flex wire text a non-Flex backend + // never sees. Without this the controls move, the model updates and + // the DSP keeps whatever it was opened with — a dead slider. + connect(s, &SliceModel::agcCommandIssued, this, + [this, s](const QString& mode, int thresholdDb) { + if (m_backend) m_backend->setSliceAgc(s->sliceId(), mode, thresholdDb); + }); + // Receive DSP the radio runs. Same reasoning as AGC above: the + // applet toggles drive SliceModel, whose Flex wire text a non-Flex + // backend never sees, so without these the controls move and the + // radio's own NR/NB/notch/squelch keep whatever state they had. + connect(s, &SliceModel::noiseReductionCommandIssued, this, + [this, s](bool on, int level) { if (m_backend) m_backend->setSliceNoiseReduction(s->sliceId(), on, level); }); connect(s, &SliceModel::noiseBlankerCommandIssued, this, @@ -1442,315 +1734,55 @@ void RadioModel::setupBackend(const QString& family) // for a radio with a single shared register (Icom) and is // overridable by a radio with two (Flex). m_backend->setXitOffset(hz); - }); - - wireSliceAudioIntentsToBackend(s); - m_slices.append(s); - s->applyChanges(mapped); - m_meterModel.setActiveTxSlice(activeTxSliceNum()); - refreshTxPowerLimit(); - emit sliceAdded(s); - return; - } - if (s) { - s->applyChanges(mapped); - // Which slice owns transmit decides which TX-waveform meters resolve - // — MeterModel::compPeakIndexForActiveTxSlice() keys the compression - // meter off it, and it initialises to -1 meaning "none". - // - // The five existing calls all live in handleSliceStatus(), the Flex - // TEXT status path, which a seam backend never reaches. So on any - // non-Flex family m_activeTxSlice stayed -1 forever: TX:COMPPEAK was - // defined, its value arrived and was stored, and nothing ever read it - // because no index resolved. The gauge sat at zero while the - // compressor behind it was working. - // - // Unconditional rather than gated on delta.txSlice: setActiveTxSlice() - // early-returns when the value is unchanged, and the transmit slice - // can also move because a slice was REMOVED, which carries no delta - // at all. - m_meterModel.setActiveTxSlice(activeTxSliceNum()); - if (mapped.frequency.has_value() || mapped.txSlice.has_value()) { - refreshTxPowerLimit(); - } - } - }); - - // aetherd RFC 2.3: TransmitModel touchpoint. The backend decodes the five - // Flex transmit-family status planes (transmit/interlock/ATU/APD/APD-sampler) - // into a typed TransmitDelta; RadioModel drives the TransmitModel. Driven - // synchronously from the matching decode*Status() calls in the status - // handlers (main-thread AutoConnection → DirectConnection). - connect(m_backend.get(), &IRadioBackend::transmitChanged, this, - [this](const TransmitDelta& delta) { - // A backend-reported MOX edge is radio state, not local intent. - // Keep it out of TransmitModel::moxChanged, whose consumers - // own this client's audio, DAX, recorder and serial PTT. - if (delta.mox) - publishBackendTransmitEdge(*delta.mox); - m_transmitModel.applyChanges(delta); - if (delta.cwSpeed && !usesFlexCommandPlane()) { - m_cwxModel.adoptSpeed(*delta.cwSpeed); - } - }); - connect(m_backend.get(), &IRadioBackend::keyingStateConfirmed, - this, &RadioModel::radioTransmitConfirmed); - - // aetherd 2.4 (#4094): power-amp status decoded in the backend drives AmpModel. - connect(m_backend.get(), &IRadioBackend::amplifierChanged, this, - [this](const AmpDelta& delta) { m_amplifier.applyChanges(delta); }); - - // aetherd 2.4 (#4092): TGXL tuner status decoded in the backend drives TunerModel. - connect(m_backend.get(), &IRadioBackend::tunerChanged, this, - [this](const TunerDelta& delta) { m_tunerModel.applyChanges(delta); }); - - // aetherd RFC 2.3 (RadioModel residual): radio-global status decoded in the - // backend drives RadioModel's own state via applyRadioChanges. - connect(m_backend.get(), &IRadioBackend::radioChanged, this, - [this](const RadioDelta& delta) { applyRadioChanges(delta); }); - - // aetherd RFC 2.3 (RadioModel residual): GPS / memory-slot / profile status - // decoded in the backend drive RadioModel's own state via the apply* methods. - connect(m_backend.get(), &IRadioBackend::gpsChanged, this, - [this](const GpsDelta& delta) { applyGpsChanges(delta); }); - connect(m_backend.get(), &IRadioBackend::memoryChanged, this, - [this](const MemoryDelta& delta) { applyMemoryChanges(delta); }); - connect(m_backend.get(), &IRadioBackend::memoryRefreshStarted, this, - [this](int total) { - m_memoryRefreshActive = true; - m_memoryImportFailures = 0; - emit memoryRefreshStarted(total); - }); - connect(m_backend.get(), &IRadioBackend::memoryRefreshProgress, this, - &RadioModel::memoryRefreshProgress); - connect(m_backend.get(), &IRadioBackend::memoryRefreshFinished, this, - [this](bool success, int completed, int total) { - // The backend finishes only after publishing its final delta. Commit - // the bank before announcing success, including empty-channel removals. - const bool saved = !m_memoryRefreshActive || !usesLocalMemoryBank() - || m_localMemories.flush(); - if (!saved) { - emit configurationWarning(QStringLiteral("Memory Sync could not save the bank: %1") - .arg(m_localMemories.lastError())); - } - const int stored = saved ? std::max(0, completed - m_memoryImportFailures) : 0; - success = success && saved && m_memoryImportFailures == 0; - m_memoryRefreshActive = false; - m_memoryImportFailures = 0; - emit memoryRefreshFinished(success, stored, total); - }); - connect(m_backend.get(), &IRadioBackend::profileChanged, this, - [this](const ProfileDelta& delta) { applyProfileChanges(delta); }); - - // NOTE: the three connects below hang off the Flex PanadapterStream, which a - // backend carrying its own IQ does not have. They ran unconditionally, so an - // HL2 setup logged "QObject::connect: invalid nullptr parameter" and wired - // nothing -- including meterDataReady, which is part of why the S-meter has - // never reached the UI on this backend. - // - // Guarded individually rather than with an early return: the m_connection - // and IRadioBackend connects further down are interleaved with these and ARE - // needed by a self-IQ backend. Returning early here would have skipped - // IRadioBackend::connected and broken HL2 connection outright. - - // Centralized DAX RX channel ownership (#3305): PanadapterStream decides - // WHEN a dax_rx stream must exist (refcounted acquire/release from the - // bridge/TCI/RADE); RadioModel is the command plane that makes it so. - if (m_panStream) - connect(m_panStream, &PanadapterStream::daxStreamCreateNeeded, - this, [this](int ch) { - if (!isConnected()) { - // Dropped create (connect gap): tell the manager so the latch - // clears and its retry cadence re-fires — otherwise the channel - // wedges with createPending stuck true (the #3669 wedge class). - m_panStream->notifyDaxCreateFailed(ch); + }); + + wireSliceAudioIntentsToBackend(s); + m_slices.append(s); + s->applyChanges(mapped); + m_meterModel.setActiveTxSlice(activeTxSliceNum()); + refreshTxPowerLimit(); + emit sliceAdded(s); return; } - sendCmd(QString("stream create type=dax_rx dax_channel=%1").arg(ch), - [this, ch](int code, const QString& body) { - if (code != 0) { - qCWarning(lcDax) << "RadioModel: dax_rx stream create for channel" - << ch << "failed, code" << Qt::hex << code << body; - m_panStream->notifyDaxCreateFailed(ch); - return; + if (s) { + s->applyChanges(mapped); + // Which slice owns transmit decides which TX-waveform meters resolve + // — MeterModel::compPeakIndexForActiveTxSlice() keys the compression + // meter off it, and it initialises to -1 meaning "none". + // + // The five existing calls all live in handleSliceStatus(), the Flex + // TEXT status path, which a seam backend never reaches. So on any + // non-Flex family m_activeTxSlice stayed -1 forever: TX:COMPPEAK was + // defined, its value arrived and was stored, and nothing ever read it + // because no index resolved. The gauge sat at zero while the + // compressor behind it was working. + // + // Unconditional rather than gated on delta.txSlice: setActiveTxSlice() + // early-returns when the value is unchanged, and the transmit slice + // can also move because a slice was REMOVED, which carries no delta + // at all. + m_meterModel.setActiveTxSlice(activeTxSliceNum()); + if (mapped.frequency.has_value() || mapped.txSlice.has_value()) { + refreshTxPowerLimit(); } - // Success needs no action here. The #1439 legacy client- - // registration nudge is decided in handleDaxRxStreamRegistry, when - // the registration status has definitively told us whether the - // radio auto-bound the stream (slice=) — deciding here - // would race that status: on WAN/SmartLink (and any firmware that - // binds after the create reply) the binding isn't known yet, so a - // reply-first ordering would fire a same-value `slice set dax=` - // re-assert and blip audio, the very thing the gate avoids (#4017). - }); - }); - if (m_panStream) - connect(m_panStream, &PanadapterStream::daxStreamRemoveNeeded, - this, [this](quint32 streamId, int ch) { - Q_UNUSED(ch); - if (!isConnected()) return; - sendCommand(QString("stream remove 0x%1").arg(streamId, 0, 16)); + } }); - // RadioConnection (created + owned by the backend above, on its own worker - // thread #502 so TCP I/O never blocks paintEvent) — wire its signals to us. - // Signals from RadioConnection auto-queue to main thread (#502) - // - // RadioConnection is the Flex TCP command channel; a self-IQ backend has - // none and m_connection is null, so each of these logged an "invalid - // nullptr parameter" connect. The lifecycle it would have carried arrives - // through the neutral IRadioBackend signals below instead, which is why the - // block after this one is gated on !m_connection. - if (m_connection) { - connect(m_connection, &RadioConnection::statusReceived, - this, &RadioModel::onStatusReceived); - connect(m_connection, &RadioConnection::messageReceived, - this, &RadioModel::onMessageReceived); - connect(m_connection, &RadioConnection::connected, - this, &RadioModel::onConnected); - connect(m_connection, &RadioConnection::disconnected, - this, &RadioModel::onDisconnected); - connect(m_connection, &RadioConnection::errorOccurred, - this, &RadioModel::onConnectionError); - connect(m_connection, &RadioConnection::versionReceived, - this, &RadioModel::onVersionReceived); - - // Response callbacks: RadioConnection emits commandResponse on worker thread, - // we dispatch to the matching callback on the main thread. (#502) - connect(m_connection, &RadioConnection::commandResponse, - this, [this](quint32 seq, int code, const QString& body) { - auto it = m_pendingCallbacks.find(seq); - if (it != m_pendingCallbacks.end()) { - it.value()(code, body); - m_pendingCallbacks.erase(it); + connect(m_backend.get(), &IRadioBackend::sliceLifecycleFailed, this, + [this, generation](const QString& operation, int sliceId, const QString& reason) { + if (generation != m_backendReceiverGeneration) { + return; } + qCWarning(lcProtocol) << "RadioModel: slice" << operation << "failed:" + << sliceId << reason; + emit sliceLifecycleFailed(operation, sliceId, reason); }); - - } // if (m_connection) - - // aetherd Gap B (Step 2b): a backend that does not drive the lifecycle - // through a Flex RadioConnection drives it through the neutral - // IRadioBackend signals instead. - // - // The guard MUST mirror the connect dispatch in connectToRadio(): that does - // `if (m_connection) else if (m_backend) `, so - // whichever side initiates the connect is the side that reports the lifecycle. - // Hence "!m_connection" here. - // - // A previous revision relaxed this to "!m_flexBackend" so that - // SimBackend::disconnectRadio() (the `sim disconnect` fault) would be heard — - // it emitted IRadioBackend::disconnected into nothing, leaving the model - // "connected" with dead audio. But SimBackend is an RFC #4288 Route A hybrid: - // it vends a synthetic RadioConnection AND re-emits that connection's - // lifecycle as its own IRadioBackend signals. With the relaxed guard, sim was - // the one family where BOTH blocks were live, so a single wire event reached - // onConnected/onDisconnected TWICE — running registerAsGuiClient twice (two - // GUI-client batches, two 10 s UDP health timers, a second m_panStream->start()), - // emitting connectionStateChanged twice, and staging session models twice - // (which zeroed m_staleSessionOwnHandle and defeated the #3977 reclaim guard). - // - // The real fix for `sim disconnect` belongs on the other side of the seam: - // SimBackend::disconnectRadio() now tears down its synthetic connection, so the - // wire reports the disconnect through this single path. See SimBackend.cpp. - if (!m_connection) { - connect(m_backend.get(), &IRadioBackend::connected, - this, &RadioModel::onConnected); - connect(m_backend.get(), &IRadioBackend::disconnected, - this, &RadioModel::onDisconnected); - connect(m_backend.get(), &IRadioBackend::connectionError, - this, &RadioModel::onConnectionError); - // Advisory only — deliberately NOT routed through onConnectionError, - // which starts the reconnect timer. Re-emitted for the UI to surface. - connect(m_backend.get(), &IRadioBackend::configurationWarning, - this, &RadioModel::configurationWarning); - } - - // Transport counters from a backend that owns its own socket. Wired - // unconditionally: a backend that measures nothing never emits this, and one - // that does is the only source the network readouts have. - connect(m_backend.get(), &IRadioBackend::linkStatsUpdated, - this, &RadioModel::applyBackendLinkStats); - - // Forward VITA-49 meter packets to MeterModel (cross-thread, auto-queued) - if (m_panStream) - connect(m_panStream, &PanadapterStream::meterDataReady, - &m_meterModel, &MeterModel::updateValues); - - // HAND THE FRESH BACKEND THE MIC GAIN THE MODEL ALREADY HOLDS. - // - // The seam wired in the constructor carries operator INTENT — it fires when - // the slider moves, and a backend rebuild is not the slider moving. So - // without this, a family swap silently parts the two: the new modulator is - // constructed at its own 1.0 default (Hl2TxDsp::m_micGain) while - // TransmitModel::m_micLevel still holds the operator's position, because - // nothing resets that model and micLevel is not persisted for - // applyRestoredState() to restore. Connect an HL2, set MIC to 80, visit the - // demo or a Flex, come back: the slider reads 80, the snapshot's micLevel - // reads 80, and the radio is transmitting at unity. - // - // That is the readback-agreeing-with-the-failure shape this whole change - // exists to eliminate, so it cannot be left standing one seam over. Pushing - // here rather than in the connect path because the disagreement is created - // by CONSTRUCTION, not by connecting — the modulator is wrong the moment it - // exists, and a backend that is never connected should still answer - // healthSnapshot() honestly. - // - // Free on the constructor's own call, where TransmitModel is at its 50 and - // 50 maps to the 1.0 the modulator already holds. Same Flex gate as the - // seam: on a Flex the slider's `transmit set miclevel=` reaches the radio's - // own preamp and this must not double it. - if (m_backend && !usesFlexCommandPlane()) - m_backend->setMicGain(m_transmitModel.micLevel()); -} - -void RadioModel::applyBackendLinkStats(const IRadioBackend::LinkStats& stats) -{ - if (!stats.reported) - return; - - const bool first = !m_linkStats.reported; - m_linkStats = stats; - // What this transport can MEASURE, latched separately from what it measured - // this second. Sticky-once-true so a window that closes with no samples in - // it does not flip a readout back to "not measured" mid-session, and read by - // hasLinkRtt() / hasLinkTiming() after stopNetworkMonitor() has dropped the - // counters. See the member declaration for why the distinction matters. - m_backendLinkShape.reports = true; - if (stats.rttMs >= 0) - m_backendLinkShape.hasRtt = true; - if (stats.gapMs >= 0) - m_backendLinkShape.hasTiming = true; - - if (first) { - // First snapshot of the session. resetNetworkHealthSamples() (called - // from the shared reset) now reads the new source, so the deltas are - // seeded from THIS snapshot rather than from zero — otherwise a - // reconnect's entire prior packet count lands in the first loss-window - // sample and scores the link as a catastrophe on its first second. - // - // Shared with startNetworkMonitor() rather than open-coded: the two - // drifted once, and the field this path had forgotten (m_lastPingRtt) - // is invisible on a transport that reports rttMs < 0, so the previous - // Flex session's RTT scored the HL2 link with nothing on screen to - // contradict it. - resetNetworkQualitySession(); - } - - if (stats.rttMs >= 0) - m_lastPingRtt = stats.rttMs; - - evaluateNetworkQuality(); - - // The heartbeat is a statement about the RADIO, not about the timer that - // asked. Only a tick that saw fresh traffic counts as a beat; a tick on a - // silent link deliberately says nothing, so MainWindow's miss timer runs - // out and the indicator goes to its alarm state. - if (stats.alive) - emit pingReceived(); } void RadioModel::teardownBackend() { + ++m_backendReceiverGeneration; + m_sliceLifecycleCommandSinkForTest = {}; m_memoryRefreshActive = false; m_memoryImportFailures = 0; // Drop the backend and everything it owns (RadioConnection, PanadapterStream @@ -5039,72 +5071,72 @@ void RadioModel::cwAutoTuneOnce(int sliceId) sendCmd(QString("slice auto_tune %1").arg(sliceId)); } -void RadioModel::addSlice() +bool RadioModel::addSlice() { if (m_activePanId.isEmpty()) { qCWarning(lcProtocol) << "RadioModel::addSlice: no panadapter, cannot create slice"; - return; - } - - // Create a new slice offset from existing slices so VFO flags deconflict. - // Use pan center, but if an existing slice is within 5 kHz, offset by - // 20% of the visible bandwidth. - auto* pan = activePanadapter(); - double newFreq = pan ? pan->centerMhz() : 14.1; - const double offsetMhz = (pan ? pan->bandwidthMhz() : 0.2) * 0.2; // 20% of visible BW - for (auto* s : m_slices) { - if (std::abs(s->frequency() - newFreq) < 0.005) { // within 5 kHz - newFreq += offsetMhz; - break; - } + return false; } - const QString freq = QString::number(newFreq, 'f', 6); - const QString cmd = QString("slice create pan=%1 freq=%2").arg(m_activePanId, freq); - - qCDebug(lcProtocol) << "RadioModel::addSlice:" << cmd; - sendCmd(cmd, [this](int code, const QString& body) { - if (code != 0) { - qCWarning(lcProtocol) << "RadioModel: slice create failed, code" - << Qt::hex << code << "body:" << body; - emit sliceCreateFailed(maxSlices(), m_model); - } else { - qCDebug(lcProtocol) << "RadioModel: new slice created, index =" << body; - } - }); + return addSliceOnPan(m_activePanId); } -void RadioModel::addSliceOnPan(const QString& panId) +bool RadioModel::addSliceOnPan(const QString& panId) { - if (panId.isEmpty()) { addSlice(); return; } - - auto* pan = panadapter(panId); - double newFreq = pan ? pan->centerMhz() : 14.1; - const double offsetMhz = (pan ? pan->bandwidthMhz() : 0.2) * 0.2; - for (auto* s : m_slices) { + if (panId.isEmpty()) { + return addSlice(); + } + // Preserve the existing placement: pan center, shifted by 20% of visible + // bandwidth if any existing slice is within 5 kHz. + PanadapterModel* pan = panadapter(panId); + if (!pan) { + return false; + } + double newFreq = pan->centerMhz(); + const double offsetMhz = pan->bandwidthMhz() * 0.2; + for (SliceModel* s : m_slices) { if (std::abs(s->frequency() - newFreq) < 0.005) { newFreq += offsetMhz; break; } } - addSliceOnPan(panId, newFreq); + return addSliceOnPan(panId, newFreq); } -void RadioModel::addSliceOnPan(const QString& panId, double freqMhz) +bool RadioModel::addSliceOnPan(const QString& panId, double freqMhz) { - if (panId.isEmpty()) { - qCWarning(lcProtocol) << "RadioModel::addSliceOnPan: no panadapter, cannot create slice"; - return; + if (panId.isEmpty() || !panadapter(panId)) { + qCWarning(lcProtocol) << "RadioModel::addSliceOnPan: unknown panadapter" << panId; + return false; } - if (!std::isfinite(freqMhz)) { + const double frequencyHz = freqMhz * 1.0e6; + if (!std::isfinite(freqMhz) || !std::isfinite(frequencyHz) || freqMhz <= 0.0) { qCWarning(lcProtocol) << "RadioModel::addSliceOnPan: invalid frequency" << freqMhz; - return; + return false; + } + if (!hasCommandPlane()) { + // Refusal is terminal. Paired/fixed receivers do not acquire an + // independent lifecycle just because maxSlices happens to exceed one. + if (m_backend && backendCapabilities().canCreateSlices + && m_backend->createSlice(backendPanIdFor(panId), frequencyHz)) { + return true; + } + // This refusal replaces sendCmd's loud commandDropped path (#5263). + // GUI callers may discard the result; the existing lifecycle signal + // still tells the operator that the request did not create a receiver. + const QString reason = tr("this radio cannot create a slice here"); + qCWarning(lcProtocol) << "RadioModel::addSliceOnPan: backend declined" << panId << reason; + emit sliceLifecycleFailed(QStringLiteral("create"), -1, reason); + return false; } const QString freq = QString::number(freqMhz, 'f', 6); const QString cmd = QString("slice create pan=%1 freq=%2").arg(panId, freq); - + const quint64 generation = m_backendReceiverGeneration; qCDebug(lcProtocol) << "RadioModel::addSliceOnPan:" << cmd; - sendCmd(cmd, [this](int code, const QString& body) { + return dispatchSliceLifecycleCommand(cmd, [this, generation](int code, const QString& body) { + if (generation != m_backendReceiverGeneration) { + return; + } if (code != 0) { qCWarning(lcProtocol) << "RadioModel: slice create failed, code" << Qt::hex << code << "body:" << body; @@ -5115,6 +5147,27 @@ void RadioModel::addSliceOnPan(const QString& panId, double freqMhz) }); } +bool RadioModel::removeSlice(int sliceId) +{ + // Ordinary close cannot remove the last receiver or a foreign/unknown ID. + // Split/TX cleanup has its own ownership contract and does not use this. + if (m_slices.size() <= 1 || !slice(sliceId)) { + return false; + } + if (!hasCommandPlane()) { + return m_backend && m_backend->removeSlice(sliceId); + } + return dispatchSliceLifecycleCommand(QStringLiteral("slice remove %1").arg(sliceId)); +} + +bool RadioModel::dispatchSliceLifecycleCommand(const QString& command, ResponseCallback callback) +{ + if (m_sliceLifecycleCommandSinkForTest) { + return m_sliceLifecycleCommandSinkForTest(command, std::move(callback)); + } + return sendCmd(command, std::move(callback)) != 0; +} + void RadioModel::createPanadapter() { // A backend that owns its own receivers creates them at the seam. The Flex @@ -9204,9 +9257,11 @@ void RadioModel::setBackendForTest(std::unique_ptr backend, // the destructor's disconnect then runs against freed memory — which is // exactly what a second call to this helper produced (SIGSEGV in // QObject::disconnect at teardown, all checks having passed). + dropAllSessionModelsForFamilySwitch(); teardownBackend(); m_backend = std::move(backend); m_family = family; + wireBackendReceiverState(); } QString RadioModel::neutralPanIdStringForTest(int panIdx) diff --git a/src/models/RadioModel.h b/src/models/RadioModel.h index 9ac6000fc..b29b5ac7d 100644 --- a/src/models/RadioModel.h +++ b/src/models/RadioModel.h @@ -836,9 +836,11 @@ class RadioModel : public QObject { std::chrono::steady_clock::time_point scheduledAt = {}); void cwAutoTune(int sliceId, bool intermittent); // int=1 start loop, int=0 stop void cwAutoTuneOnce(int sliceId); // one-shot (no int= param) - void addSlice(); // Create a new slice on the active panadapter - void addSliceOnPan(const QString& panId); // Create a new slice on a specific pan - void addSliceOnPan(const QString& panId, double freqMhz); // Create slice on specific pan/frequency + bool addSlice(); // Create a new slice on the active panadapter + bool addSliceOnPan(const QString& panId); // Create a new slice on a specific pan + bool addSliceOnPan(const QString& panId, double freqMhz); + // Ordinary RX close. Confirmation is sliceRemoved, never this return value. + bool removeSlice(int sliceId); void createPanadapter(); // Create a new independent panadapter void removePanadapter(const QString& panId); void setPanBandwidth(double bandwidthMhz); @@ -1146,6 +1148,8 @@ class RadioModel : public QObject { // Emitted when the radio rejects a slice create command (e.g. limit reached across // all Multi-Flex clients — our local slice count may be below maxSlices()). void sliceCreateFailed(int limit, const QString& model); + void sliceLifecycleFailed(const QString& operation, int sliceId, + const QString& reason); // Emitted when a pan needs xpixels/ypixels pushed (after profile change, reconnect, etc.) void panDimensionsNeeded(const QString& panId); // Emitted when the radio reports its antenna list (e.g. "ANT1,ANT2,RX_A,RX_B"). @@ -1657,17 +1661,23 @@ private slots: } } - // Install a backend directly, bypassing buildBackend()'s family wiring. - // - // The DSP read-back path — AutomationServer's `get dsp` — needs exactly one - // thing from this model: backend()->dspChains(). Reaching it through - // buildBackend() would mean constructing a real family backend, i.e. a wire - // object and its I/O thread, inside a test whose whole point is that it - // opens no socket. Takes ownership. Nothing in production calls this; the - // family string is set alongside because the read-back reports it. + // Install a socket-free backend with the same normalized receiver-state + // bindings used by production. Replacement drops old session models. void setBackendForTest(std::unique_ptr backend, const QString& family); + // Replace only the ordinary lifecycle command transport, including replies. + // Tests can pin Flex/Sim encoding without constructing a wire object/peer. + void setSliceLifecycleCommandSinkForTest( + std::function sink) + { + m_sliceLifecycleCommandSinkForTest = std::move(sink); + } private: + friend class RadioModelSliceLifecycleTestAccess; + void wireBackendReceiverState(); + bool dispatchSliceLifecycleCommand(const QString& command, ResponseCallback callback = {}); + quint64 m_backendReceiverGeneration = 0; + std::function m_sliceLifecycleCommandSinkForTest; PanadapterModel* resolveBackendPan(const QString& backendPanId); // Connect a slice's operator-issued AUDIO and TX-slice intents to the // backend seam. Must be called from EVERY site that constructs a diff --git a/tests/anan_backend_test.cpp b/tests/anan_backend_test.cpp index ddcb1d2fe..cc0da7cb1 100644 --- a/tests/anan_backend_test.cpp +++ b/tests/anan_backend_test.cpp @@ -89,6 +89,7 @@ int main(int argc, char** argv) { AnanBackend backend; const RadioCapabilities c = backend.capabilities(); + check(!c.canCreateSlices, "ANAN fixed receiver does not expose independent slice creation"); check(c.family == QStringLiteral("anan"), "family is anan"); check(c.model == QStringLiteral("ANAN-G2"), "model is ANAN-G2"); check(c.maxSlices == 1 && c.maxPanadapters == 1, "single slice, single pan in this phase"); diff --git a/tests/backend_slice_lifecycle_test.cpp b/tests/backend_slice_lifecycle_test.cpp new file mode 100644 index 000000000..f4480272a --- /dev/null +++ b/tests/backend_slice_lifecycle_test.cpp @@ -0,0 +1,497 @@ +// RFC #5468 P01: ordinary RX requests use the backend seam and model lifetime +// follows normalized state. Socket-free: injected backend state and a command / +// reply sink; no synthetic firmware peer, USB device, DSP channel or wire. +#include "TestSettingsProfile.h" +#include "core/AutomationServer.h" +#include "core/RadioConnection.h" +#include "core/backends/IRadioBackend.h" +#include "models/RadioModel.h" +#include "models/SliceModel.h" +#include "models/PanadapterModel.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace AetherSDR { +class RadioModelSliceLifecycleTestAccess +{ +public: + static void useCommandPlane(RadioModel& radio, RadioConnection* connection) + { + radio.m_connection = connection; + } + static void setSessionSerials(RadioModel& radio, const QString& connected, + const QString& nextTarget) + { + radio.m_connectedSessionSerial = connected; + radio.m_lastInfo.serial = nextTarget; + } + static void completeDisconnect(RadioModel& radio) + { + // Inject the receipt edge into the real handler. setBackendForTest + // installs receiver bindings, not setupBackend's connection wiring. + radio.onDisconnected(); + } +}; +class AutomationServerTestAccess +{ +public: + static QJsonObject slice(AutomationServer& server, const QString& action, + const QString& arg = {}) + { + const QJsonObject request{{"cmd", "slice"}, {"action", action}, {"value", arg}}; + return server.handleLine(QJsonDocument(request).toJson(QJsonDocument::Compact), nullptr); + } +}; +} // namespace AetherSDR + +using namespace AetherSDR; + +namespace { +int failures = 0; +void check(bool condition, const char* description) +{ + std::printf("[%s] %s\n", condition ? "PASS" : "FAIL", description); + if (!condition) { + ++failures; + } +} + +class FixedBackend : public IRadioBackend +{ +public: + RadioCapabilities caps; + int frequencyCalls = 0; + int modeCalls = 0; + bool connected = true; + int disconnectCalls = 0; + // Uninitialized RadioConnection has no socket, timer, thread or peer. Its + // presence models the exact ownership boundary used by Flex and hybrid Sim. + std::unique_ptr commandPlane; + RadioCapabilities capabilities() const override { return caps; } + void connectRadio(const RadioConnectRequest&) override {} + void disconnectRadio() override { connected = false; ++disconnectCalls; } + bool isConnected() const override { return connected; } + void setSliceFrequency(int, double) override { ++frequencyCalls; } + void setSliceMode(int, const QString&) override { ++modeCalls; } + void setSliceFilter(int, int, int) override {} + void setSliceAgc(int, const QString&, int) override {} + void setPanCenter(const QString&, double, PanCenterIntent) override {} + void setKeying(bool) override {} // no transport can transmit + void invokeExtension(const QString&, const QString&, quint64, const QVariant&) override {} +}; + +class LifecycleBackend : public FixedBackend +{ +public: + LifecycleBackend() + { + caps.maxSlices = 4; + caps.canCreateSlices = true; + } + bool acceptCreate = true; + bool acceptRemove = true; + bool removeSynchronously = false; + int createCalls = 0; + int removeCalls = 0; + QString requestedPan; + double requestedHz = 0.0; + int requestedRemoval = -1; + bool createSlice(const QString& panId, double frequencyHz) override + { + ++createCalls; + requestedPan = panId; + requestedHz = frequencyHz; + return acceptCreate; + } + bool removeSlice(int sliceId) override + { + ++removeCalls; + requestedRemoval = sliceId; + if (acceptRemove && removeSynchronously) { + emit sliceRemoved(sliceId); + } + return acceptRemove; + } +}; + +SliceDelta fullDelta(const QString& pan, double frequency = 14.2) +{ + SliceDelta delta; + delta.panId = pan; + delta.frequency = frequency; + delta.mode = QStringLiteral("USB"); + delta.filterLow = 150; + delta.filterHigh = 2700; + delta.inUse = true; + delta.audioPan = 37; + return delta; +} + +LifecycleBackend* install(RadioModel& radio, const QString& family = QStringLiteral("rtl")) +{ + auto backend = std::make_unique(); + LifecycleBackend* pointer = backend.get(); + radio.setBackendForTest(std::move(backend), family); + return pointer; +} + +QString publishPan(RadioModel& radio, IRadioBackend& backend, const QString& opaquePan) +{ + emit backend.panCenterBandwidthChanged(opaquePan, 14.2, 0.1); + check(radio.panadapters().size() == 1, "production geometry binding creates one pan"); + return radio.panId(); +} + +void testNeutralLifecycle() +{ + RadioModel radio; + check(!radio.addSlice() && !radio.addSliceOnPan({}, 14.2) && !radio.removeSlice(0), + "absent backend/pan refuses ordinary lifecycle"); + LifecycleBackend* backend = install(radio); + const QString opaquePan = QStringLiteral("usb:capture/one"); + const QString pan = publishPan(radio, *backend, opaquePan); + check(!pan.isEmpty() && pan != opaquePan, "fixture exercises opaque backend-to-model pan mapping"); + QSignalSpy added(&radio, &RadioModel::sliceAdded); + QSignalSpy removed(&radio, &RadioModel::sliceRemoved); + QSignalSpy dropped(&radio, &RadioModel::commandDropped); + QSignalSpy failed(&radio, &RadioModel::sliceLifecycleFailed); + int lifecycleCommands = 0; + radio.setSliceLifecycleCommandSinkForTest([&](const QString&, std::function) { + ++lifecycleCommands; + return true; + }); + bool completeAtNotification = false; + QObject::connect(&radio, &RadioModel::sliceAdded, &radio, [&](SliceModel* slice) { + completeAtNotification = slice->panId() == pan && slice->frequency() == 14.234567 + && slice->mode() == QLatin1String("USB") && slice->filterLow() == 150 + && slice->filterHigh() == 2700 && slice->audioPan() == 37; + }); + + check(!radio.addSliceOnPan(QStringLiteral("unknown"), 14.2), "unknown pan refuses without dispatch"); + for (const double invalid : {0.0, -1.0, std::numeric_limits::infinity(), + std::numeric_limits::quiet_NaN(), + std::numeric_limits::max()}) { + check(!radio.addSliceOnPan(pan, invalid), "invalid/overflowing RF frequency refuses"); + } + check(backend->createCalls == 0 && radio.slices().isEmpty(), "invalid requests never reach backend or create state"); + check(radio.addSliceOnPan(pan, 14.234567), "backend accepts explicit slice creation"); + check(backend->createCalls == 1 && backend->requestedPan == opaquePan + && std::abs(backend->requestedHz - 14234567.0) < 0.001, + "exactly one request uses opaque pan identity and Hz once"); + check(radio.slices().isEmpty() && added.isEmpty(), "accepted request has no optimistic model"); + emit backend->sliceChanged(0, fullDelta(opaquePan, 14.234567)); + SliceModel* original = radio.slice(0); + check(original && added.size() == 1 && completeAtNotification, + "first normalized delta publishes one fully populated slice"); + SliceDelta update; + update.frequency = 14.25; + emit backend->sliceChanged(0, update); + check(radio.slice(0) == original && original->frequency() == 14.25 && added.size() == 1, + "later delta updates existing object without duplicate ownership"); + check(backend->frequencyCalls == 0 && backend->modeCalls == 0, + "normalized status never echoes tuning or mode intents"); + original->setFrequency(14.26); + original->setMode(QStringLiteral("LSB")); + check(backend->frequencyCalls == 1 && backend->modeCalls == 1, + "repeated status does not duplicate per-slice intent bindings"); + check(!radio.removeSlice(0) && backend->removeCalls == 0, "last receiver cannot be closed"); + + backend->acceptCreate = false; + check(!radio.addSliceOnPan(pan, 14.22), "backend creation refusal propagates"); + check(failed.size() == 1 && failed.at(0).at(0).toString() == QLatin1String("create") + && failed.at(0).at(1).toInt() == -1 && !failed.at(0).at(2).toString().isEmpty(), + "backend creation refusal tells the operator through the lifecycle failure signal"); + backend->caps.canCreateSlices = false; + const int callsBeforeUnsupported = backend->createCalls; + check(!radio.addSliceOnPan(pan, 14.23) && backend->createCalls == callsBeforeUnsupported, + "capacity alone cannot grant independent slice creation"); + check(failed.size() == 2 && failed.at(1).at(0).toString() == QLatin1String("create") + && failed.at(1).at(1).toInt() == -1 && !failed.at(1).at(2).toString().isEmpty(), + "unsupported creation also tells the operator without calling the backend hook"); + check(radio.slices().size() == 1 && radio.slice(0) == original + && original->frequency() == 14.26 && original->mode() == QLatin1String("LSB") + && radio.panadapters().size() == 1 && original->panId() == pan + && added.size() == 1 && removed.isEmpty(), + "creation refusals preserve receiver state and ownership"); + check(lifecycleCommands == 0 && dropped.isEmpty(), + "creation refusal diagnostic does not fall back to a Flex command"); + failed.clear(); + backend->caps.canCreateSlices = true; + backend->acceptCreate = true; + check(radio.addSliceOnPan(pan, 14.24), "pending creation is accepted before a later failure"); + emit backend->sliceLifecycleFailed(QStringLiteral("create"), -1, QStringLiteral("receiver preparation failed")); + check(failed.size() == 1 && failed.at(0).at(2).toString() == QLatin1String("receiver preparation failed") + && radio.slices().size() == 1, + "asynchronous failure is observable without inventing state"); + + emit backend->sliceChanged(1, fullDelta(opaquePan, 14.3)); + emit backend->sliceChanged(3, fullDelta(opaquePan, 14.35)); + SliceModel* sibling = radio.slice(3); + backend->acceptRemove = false; + check(!radio.removeSlice(1), "backend removal refusal propagates"); + check(radio.slice(1) && radio.slice(3) == sibling && removed.isEmpty() + && radio.panadapters().size() == 1, + "refusal preserves slices and pan without authoritative removal notification"); + check(!radio.removeSlice(2) && backend->removeCalls == 1, "unknown sparse ID is not a vector position"); + backend->acceptRemove = true; + check(radio.removeSlice(1) && backend->requestedRemoval == 1 && radio.slice(1), + "pending removal dispatches requested stable ID without removing it"); + emit backend->sliceRemoved(1); + check(!radio.slice(1) && radio.slice(0) == original && radio.slice(3) == sibling + && sibling->panId() == pan && sibling->audioPan() == 37 && removed.size() == 1, + "confirmed middle removal preserves sibling identity, pan and audio destination"); + emit backend->sliceChanged(1, fullDelta(opaquePan, 14.32)); + check(radio.slice(0) == original && radio.slice(3) == sibling, "reusing a free slot does not renumber survivors"); + backend->removeSynchronously = true; + check(radio.removeSlice(1) && !radio.slice(1), "synchronous confirmation is safe for the request caller"); + emit backend->sliceRemoved(1); + check(removed.size() == 2, "duplicate removal emits no duplicate model removal"); +} + +void testReplacement() +{ + RadioModel radio; + LifecycleBackend* old = install(radio); + publishPan(radio, *old, QStringLiteral("old-pan")); + emit old->sliceChanged(0, fullDelta(QStringLiteral("old-pan"))); + // AutoConnection queues from the emitting thread. Queue real production + // callbacks, then destroy their sender before pumping the receiving thread. + std::thread producer([old] { + emit old->sliceChanged(2, fullDelta(QStringLiteral("old-pan"))); + emit old->sliceRemoved(0); + emit old->panCenterBandwidthChanged(QStringLiteral("old-pan"), 999.0, 10.0); + emit old->sliceLifecycleFailed(QStringLiteral("remove"), 0, QStringLiteral("old failure")); + }); + producer.join(); + LifecycleBackend* current = install(radio, QStringLiteral("hl2")); + const QString pan = publishPan(radio, *current, QStringLiteral("new-pan")); + emit current->sliceChanged(0, fullDelta(QStringLiteral("new-pan"), 14.4)); + SliceModel* survivor = radio.slice(0); + QSignalSpy added(&radio, &RadioModel::sliceAdded); + QSignalSpy removed(&radio, &RadioModel::sliceRemoved); + QSignalSpy failed(&radio, &RadioModel::sliceLifecycleFailed); + QCoreApplication::processEvents(); + check(radio.slice(0) == survivor && !radio.slice(2) && added.isEmpty() + && removed.isEmpty() && failed.isEmpty(), + "already queued retired-backend events cannot create, remove or fail the new session"); + check(radio.panadapters().size() == 1 && radio.panadapter(pan)->centerMhz() == 14.2, + "retired geometry cannot recreate an old pane or change new geometry"); + emit current->sliceChanged(1, fullDelta(QStringLiteral("new-pan"))); + check(added.size() == 1, "replacement installs the production binding exactly once"); + // Replacing the same family is still a new backend identity. + current = install(radio, QStringLiteral("hl2")); + check(radio.slices().isEmpty() && radio.panadapters().isEmpty(), "same-family replacement drops old model ownership"); + current->caps.canCreateSlices = false; + const QString thirdPan = publishPan(radio, *current, QStringLiteral("third-pan")); + check(!radio.addSliceOnPan(thirdPan, 14.2) && current->createCalls == 0, + "replacement cannot inherit the previous backend's creation capability"); + emit current->sliceChanged(0, fullDelta(QStringLiteral("third-pan"))); + check(radio.slices().size() == 1, "same-family replacement can publish fresh state"); + // Complete RTL -> another family -> RTL through the same production teardown + // and receiver bindings. Reusing the first RTL pan ID must not retain state. + current = install(radio, QStringLiteral("rtl")); + check(radio.slices().isEmpty() && radio.panadapters().isEmpty(), + "returning to RTL starts without the intervening family's models"); + const QString rtlPan = publishPan(radio, *current, QStringLiteral("old-pan")); + emit current->sliceChanged(0, fullDelta(QStringLiteral("old-pan"), 14.5)); + check(radio.addSliceOnPan(rtlPan, 14.6) && current->createCalls == 1 + && current->requestedPan == QLatin1String("old-pan") + && std::abs(current->requestedHz - 14600000.0) < 0.001, + "returning RTL uses its own creation capability and fresh pan mapping"); + SliceModel* returned = radio.slice(0); + returned->setFrequency(14.55); + check(current->frequencyCalls == 1 && radio.slices().size() == 1 + && returned->panId() == rtlPan, + "returning RTL has one intent binding and accepted creation remains pending"); +} + +void testDisconnectAndReclaim() +{ + for (const bool sameRadio : {true, false}) { + RadioModel radio; + LifecycleBackend* backend = install(radio); + const QString opaquePan = QStringLiteral("disconnect-pan"); + const QString pan = publishPan(radio, *backend, opaquePan); + emit backend->sliceChanged(0, fullDelta(opaquePan, 14.2)); + emit backend->sliceChanged(1, fullDelta(opaquePan, 14.3)); + SliceModel* original = radio.slice(0); + SliceModel* sibling = radio.slice(1); + PanadapterModel* originalPan = radio.panadapter(pan); + QSignalSpy added(&radio, &RadioModel::sliceAdded); + QSignalSpy removed(&radio, &RadioModel::sliceRemoved); + QSignalSpy failed(&radio, &RadioModel::sliceLifecycleFailed); + QSignalSpy connectionState(&radio, &RadioModel::connectionStateChanged); + QSignalSpy occupancy(&radio, &RadioModel::slotOccupancyChanged); + check(radio.addSliceOnPan(pan, 14.4) && radio.removeSlice(1) + && radio.slices().size() == 2, + "disconnect fixture starts with accepted, unconfirmed create and removal"); + + // Same-family radio swaps can replace the target before the old backend + // reports disconnect. The connected serial, not that target, owns the + // surviving models. Both serials are inputs to the production handler. + RadioModelSliceLifecycleTestAccess::setSessionSerials( + radio, QStringLiteral("rtl-device-A"), + sameRadio ? QStringLiteral("rtl-device-A") : QStringLiteral("rtl-device-B")); + radio.disconnectFromRadio(); + check(backend->disconnectCalls == 1 && !radio.isConnected(), + "ordinary disconnect dispatches once through the neutral backend"); + RadioModelSliceLifecycleTestAccess::completeDisconnect(radio); + check(connectionState.size() == 1 && !connectionState.at(0).at(0).toBool(), + "production disconnect handler publishes the disconnected state"); + check(radio.slice(0) == original && radio.slice(1) == sibling + && radio.panadapter(pan) == originalPan && removed.isEmpty() + && added.isEmpty() && failed.isEmpty(), + "disconnect preserves UI objects without completing pending slice operations"); + + // Inject only the next connected state and invoke the existing production + // staging seam. No synthetic transport or duplicate lifecycle wiring. + backend->connected = true; + radio.stageSessionModelsForReconnectForTest(); + check(radio.slices().isEmpty() && radio.panadapters().isEmpty(), + "reconnect staging withdraws prior-session models from live ownership"); + if (sameRadio) { + check(removed.isEmpty() && !originalPan->centerKnown(), + "same-radio staging preserves reclaim candidates but resets center authority"); + emit backend->panCenterBandwidthChanged(opaquePan, 14.25, 0.1); + emit backend->sliceChanged(0, fullDelta(opaquePan, 14.25)); + emit backend->sliceChanged(1, fullDelta(opaquePan, 14.35)); + check(radio.slice(0) == original && radio.slice(1) == sibling + && radio.panadapter(pan) == originalPan && added.isEmpty() + && removed.isEmpty() && occupancy.size() == 2, + "same-radio normalized state reclaims objects without duplicate slice ownership"); + check(original->frequency() == 14.25 && sibling->frequency() == 14.35 + && originalPan->centerKnown() && originalPan->centerMhz() == 14.25, + "reclaim refreshes slice and pan state from the new session"); + original->setFrequency(14.26); + check(backend->frequencyCalls == 1 && backend->createCalls == 1 + && backend->removeCalls == 1, + "reclaim retains one intent binding and never retries pending lifecycle requests"); + } else { + check(removed.size() == 2 && occupancy.size() == 2, + "changed radio serial prunes old ownership after production disconnect capture"); + emit backend->panCenterBandwidthChanged(opaquePan, 14.25, 0.1); + emit backend->sliceChanged(0, fullDelta(opaquePan, 14.25)); + check(radio.slice(0) != original && radio.panadapter(pan) != originalPan + && added.size() == 1 && radio.slices().size() == 1, + "same-family different-radio state cannot reclaim the old device's objects"); + } + } +} + +void testCommandAdapter() +{ + using Reply = std::function; + for (const QString family : {QStringLiteral("flex"), QStringLiteral("sim")}) { + RadioModel radio; + LifecycleBackend* backend = install(radio, family); + backend->caps.canCreateSlices = family == QLatin1String("flex"); + const QString pan = publishPan(radio, *backend, QStringLiteral("adapter-pan")); + emit backend->sliceChanged(0, fullDelta(QStringLiteral("adapter-pan"), 14.2)); + backend->commandPlane = std::make_unique(); + RadioModelSliceLifecycleTestAccess::useCommandPlane(radio, backend->commandPlane.get()); + check(radio.hasCommandPlane(), "fixture supplies command-plane ownership independently of the sink"); + QStringList commands; + std::vector replies; + bool accept = true; + radio.setSliceLifecycleCommandSinkForTest([&](const QString& command, Reply reply) { + commands.append(command); + if (reply) { + replies.push_back(std::move(reply)); + } + return accept; + }); + QSignalSpy failuresSpy(&radio, &RadioModel::sliceCreateFailed); + check(radio.addSlice(), "command-plane adapter accepts default ordinary creation"); + check(commands.value(commands.size() - 1) == QStringLiteral("slice create pan=%1 freq=14.220000").arg(pan), + "command adapter preserves center offset and six-decimal MHz formatting"); + check(radio.addSliceOnPan(pan, 14.2345678) + && commands.value(commands.size() - 1) == QStringLiteral("slice create pan=%1 freq=14.234568").arg(pan), + "explicit creation preserves existing command encoding"); + check(!replies.empty(), "accepted command dispatch retains its response callback"); + if (!replies.empty()) { + replies.back()(0, QStringLiteral("1")); + check(radio.slices().size() == 1 && failuresSpy.isEmpty(), "successful reply alone cannot invent slice ownership"); + replies.back()(1, QStringLiteral("capacity")); + check(failuresSpy.size() == 1, "existing Flex create failure callback remains observable"); + } + emit backend->sliceChanged(1, fullDelta(QStringLiteral("adapter-pan"), 14.3)); + check(radio.removeSlice(1) && commands.value(commands.size() - 1) == QLatin1String("slice remove 1") && radio.slice(1), + "command adapter preserves removal encoding and status authority"); + accept = false; + check(!radio.addSliceOnPan(pan, 14.27) && !radio.removeSlice(1), "command transport refusal propagates"); + check(backend->createCalls == 0 && backend->removeCalls == 0, + "Flex/Sim adapter never also dispatches neutral lifecycle verbs"); + install(radio, QStringLiteral("rtl")); + if (!replies.empty()) { + replies.front()(1, QStringLiteral("late reply")); + check(failuresSpy.size() == 1, "retired command callback cannot report failure into a new session"); + } + } +} + +void testBridgeAndDefaults() +{ + FixedBackend fixed; + check(!fixed.capabilities().canCreateSlices && !fixed.createSlice(QStringLiteral("pan"), 14200000.0) + && !fixed.removeSlice(1), "default backend lifecycle refuses without opting fixed topologies in"); + RadioModel radio; + LifecycleBackend* backend = install(radio); + publishPan(radio, *backend, QStringLiteral("bridge-pan")); + emit backend->sliceChanged(0, fullDelta(QStringLiteral("bridge-pan"))); + AutomationServer server; + server.setRadioModel(&radio); + for (const QString& invalid : {QStringLiteral("invalid"), QStringLiteral("0"), + QStringLiteral("-1"), QStringLiteral("nan"), + QStringLiteral("inf")}) { + const QJsonObject invalidReply = AutomationServerTestAccess::slice( + server, QStringLiteral("add"), invalid); + check(!invalidReply.value("ok").toBool(), "bridge refuses explicit invalid frequency instead of defaulting"); + } + check(backend->createCalls == 0, "malformed bridge requests never create at a default frequency"); + backend->acceptCreate = false; + QJsonObject reply = AutomationServerTestAccess::slice(server, QStringLiteral("add"), QStringLiteral("14.25")); + check(!reply.value("ok").toBool() && !reply.value("requested").toBool(), "bridge refuses rejected creation truthfully"); + backend->acceptCreate = true; + reply = AutomationServerTestAccess::slice(server, QStringLiteral("add"), QStringLiteral("14.25")); + check(reply.value("ok").toBool() && reply.value("requested").toBool() + && reply.value("sliceCount").toInt() == 1 + && backend->requestedPan == QLatin1String("bridge-pan") + && std::abs(backend->requestedHz - 14250000.0) < 0.001, + "bridge distinguishes accepted explicit-frequency request from confirmed count"); + emit backend->sliceChanged(2, fullDelta(QStringLiteral("bridge-pan"), 14.25)); + backend->acceptRemove = false; + reply = AutomationServerTestAccess::slice(server, QStringLiteral("remove"), QStringLiteral("2")); + check(!reply.value("ok").toBool() && radio.slice(2), "bridge refuses rejected removal without changing state"); + backend->acceptRemove = true; + backend->removeSynchronously = true; + reply = AutomationServerTestAccess::slice(server, QStringLiteral("remove"), QStringLiteral("2")); + check(reply.value("ok").toBool() && !radio.slice(2), "bridge supports confirmed neutral backend removal"); + reply = AutomationServerTestAccess::slice(server, QStringLiteral("remove"), QStringLiteral("0")); + check(!reply.value("ok").toBool() && radio.slice(0), "bridge retains last-slice refusal"); +} +} // namespace + +int main(int argc, char** argv) +{ + TestSettingsProfile profile(QStringLiteral("aether-backend-slice-lifecycle-test")); + qputenv("AETHER_AUTOMATION", "1"); + QCoreApplication app(argc, argv); + check(profile.isValid(), "isolated settings profile is available"); + testNeutralLifecycle(); + testReplacement(); + testDisconnectAndReclaim(); + testCommandAdapter(); + testBridgeAndDefaults(); + return failures == 0 ? 0 : 1; +} diff --git a/tests/icom_control_profile_test.cpp b/tests/icom_control_profile_test.cpp index 9f2f3d1c6..ceee44bf4 100644 --- a/tests/icom_control_profile_test.cpp +++ b/tests/icom_control_profile_test.cpp @@ -166,6 +166,7 @@ int main(int argc, char** argv) { FlexBackend flex; const RadioCapabilities caps = flex.capabilities(); + check(caps.canCreateSlices, "Flex retains independent ordinary slice creation"); check(caps.hasAgcThreshold && caps.hasAmCarrierLevel && caps.hasVoxDelay, "Flex retains AGC threshold, AM carrier, and VOX delay"); check(!caps.hasModeIndependentSquelch, "Flex retains its mode-specific SQL policy"); @@ -174,6 +175,8 @@ int main(int argc, char** argv) && caps.cwPitchStepHz == 10, "Flex retains its existing CW control ranges"); hl2::Hl2Backend hl2Backend; + check(!hl2Backend.capabilities().canCreateSlices, + "HL2 paired receiver/pan topology does not expose independent creation"); check(hl2Backend.capabilities().hasAgcThreshold, "HL2 retains host AGC threshold"); } { @@ -184,6 +187,7 @@ int main(int argc, char** argv) IcomCivBackend backend; IcomCivBackendTestAccess::selectModel(backend, *ic705); const RadioCapabilities caps = backend.capabilities(); + check(!caps.canCreateSlices, "Icom fixed receivers do not expose independent slice creation"); check(caps.txPowerBands.size() == 1 && caps.txPowerMaxWattsAt(14'200'000.0) == 10.0, "IC-705 alone declares its continuous 10 W rated-output range"); diff --git a/tests/rtl_backend_test.cpp b/tests/rtl_backend_test.cpp index 7c1cf7227..9893330a8 100644 --- a/tests/rtl_backend_test.cpp +++ b/tests/rtl_backend_test.cpp @@ -33,6 +33,7 @@ int main(int argc, char** argv) // 1. Check capabilities declaration (Principle VI: receive-only) const auto caps = backend->capabilities(); + check(!caps.canCreateSlices, "RTL-SDR retains its fixed single receiver in P01"); check(caps.family == "rtl", "capabilities.family is rtl"); check(!caps.canTransmit, "RTL-SDR cannot transmit"); check(caps.txPowerMaxWatts == 0.0, "RTL-SDR max TX power is 0"); diff --git a/tests/sim_backend_test.cpp b/tests/sim_backend_test.cpp index dcfedb12e..c9a54fe10 100644 --- a/tests/sim_backend_test.cpp +++ b/tests/sim_backend_test.cpp @@ -68,6 +68,7 @@ void testCapabilitiesAreReceiveOnly() { SimBackend sim; const RadioCapabilities caps = sim.capabilities(); + report("demo does not advertise independent slice creation", !caps.canCreateSlices); report("capabilities family is 'sim'", caps.family == QStringLiteral("sim")); report("a demo radio cannot transmit (Principle VI)", !caps.canTransmit); report("TX power is zero when RX-only", caps.txPowerMaxWatts == 0.0); diff --git a/tests/tests.cmake b/tests/tests.cmake index cf2b66326..1813e136c 100644 --- a/tests/tests.cmake +++ b/tests/tests.cmake @@ -4267,6 +4267,14 @@ target_link_libraries(automation_dsp_backend_readback_test PRIVATE ) add_test(NAME automation_dsp_backend_readback_test COMMAND automation_dsp_backend_readback_test) +# Ordinary RX lifecycle through production model/backend bindings and an +# injected command/reply sink. No sockets, firmware peer, DSP or USB access. +add_executable(backend_slice_lifecycle_test tests/backend_slice_lifecycle_test.cpp) +target_include_directories(backend_slice_lifecycle_test PRIVATE src tests) +target_link_libraries(backend_slice_lifecycle_test PRIVATE + aethercore Qt6::Core Qt6::Test +) +add_test(NAME backend_slice_lifecycle_test COMMAND backend_slice_lifecycle_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) @@ -4642,6 +4650,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 + backend_slice_lifecycle_test client_display_settings_test rx_applet_squelch_reconciliation_test rtl_slice_settings_test @@ -4734,6 +4743,7 @@ set(AETHER_AUTOMATION_SERVER_TESTS automation_rn2_probe_test connect_state_model_test automation_dsp_backend_readback_test + backend_slice_lifecycle_test tci_automation_test ) foreach(_automation_test IN LISTS AETHER_AUTOMATION_SERVER_TESTS)