Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 10 additions & 20 deletions src/gui/AtuPreTuneDialog.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -642,16 +642,10 @@ void AtuPreTuneDialog::beginNextPoint()
if (firstOfBand && !m_originalPanId.isEmpty() && p.bandHighMhz > p.bandLowMhz) {
const double center = (p.bandLowMhz + p.bandHighMhz) / 2.0;
const double width = (p.bandHighMhz - p.bandLowMhz) * 1.10;
const QString centerStr = QString::number(center, 'f', 6);
const QString widthStr = QString::number(width, 'f', 6);
if (auto* pan = m_radio->panadapter(m_originalPanId)) {
// Local model nudge before the radio echo (aetherd RFC 2.3: the
// normalized setter replaces applyPanStatus({center,bandwidth})).
pan->setCenterBandwidth(center, width);
}
m_radio->sendCommand(
QString("display pan set %1 center=%2 bandwidth=%3")
.arg(m_originalPanId, centerStr, widthStr));
// Center and bandwidth together, and deferred rather than dropped if a
// profile load is holding radio-state writes (#4142). The local model
// advances only when the command reaches the wire.
m_radio->requestPanCenter(m_originalPanId, center, width);
}

// Move slice to target. SliceModel::setFrequency uses autopan=0 — no recenter.
Expand Down Expand Up @@ -885,16 +879,12 @@ void AtuPreTuneDialog::restoreOriginalFrequency()
// Restore the panadapter zoom captured at sweep start — same
// optimistic-update pattern used for band transitions.
if (!m_originalPanId.isEmpty() && m_originalPanBandwidthMhz > 0.0) {
const QString centerStr = QString::number(m_originalPanCenterMhz, 'f', 6);
const QString widthStr = QString::number(m_originalPanBandwidthMhz, 'f', 6);
if (auto* pan = m_radio->panadapter(m_originalPanId)) {
// Local model nudge before the radio echo (aetherd RFC 2.3: the
// normalized setter replaces applyPanStatus({center,bandwidth})).
pan->setCenterBandwidth(m_originalPanCenterMhz, m_originalPanBandwidthMhz);
}
m_radio->sendCommand(
QString("display pan set %1 center=%2 bandwidth=%3")
.arg(m_originalPanId, centerStr, widthStr));
// Restoring the pre-sweep zoom is a user-visible promise: dropping it
// during a profile load would strand the pan on the sweep's band view.
// Deferred and replayed instead (#4142).
m_radio->requestPanCenter(m_originalPanId,
m_originalPanCenterMhz,
m_originalPanBandwidthMhz);
}
}

Expand Down
46 changes: 27 additions & 19 deletions src/gui/MainWindow.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -6080,28 +6080,28 @@ void MainWindow::applyPanRangeRequest(const QString& panId, double centerMhz,
}

auto* pan = m_radioModel.panadapter(panId);
const QString centerStr = QString::number(centerMhz, 'f', 6);
const QString bandwidthStr = QString::number(bandwidthMhz, 'f', 6);

if (pan) {
if (qFuzzyCompare(pan->centerMhz(), centerMhz)
&& qFuzzyCompare(pan->bandwidthMhz(), bandwidthMhz)) {
return;
}
// Update both values together before the radio echo arrives. Explicit
// zoom workflows are especially sensitive to center/bandwidth skew;
// splitting them produced the P1/P2 waterfall-loss and zoom-drift bugs.
// (aetherd RFC 2.3: setCenterBandwidth replaces applyPanStatus here.)
pan->setCenterBandwidth(centerMhz, bandwidthMhz);
}

m_radioModel.sendCommand(
QString("display pan set %1 center=%2 bandwidth=%3")
.arg(panId, centerStr, bandwidthStr));
// Center and bandwidth travel together in one command. Explicit zoom
// workflows are especially sensitive to center/bandwidth skew; splitting
// them produced the P1/P2 waterfall-loss and zoom-drift bugs.
//
// #4142: this pair is classified profile-owned, so it was silently DROPPED
// during a profile load — zoom and drag, not just typed frequency entry.
// requestPanCenter() defers and replays it, and only advances the local
// model when the command actually reaches the wire.
const bool sent = m_radioModel.requestPanCenter(panId, centerMhz, bandwidthMhz);

qDebug() << "Pan range request:" << source
<< "center" << centerMhz
<< "bandwidth" << bandwidthMhz;
<< "bandwidth" << bandwidthMhz
<< (sent ? "" : "(deferred: profile load)");
}

void MainWindow::setActiveSlice(int sliceId)
Expand Down Expand Up @@ -7898,16 +7898,24 @@ void MainWindow::centerActiveSliceInPanadapter(bool forceRadioCenter, double cen
m_panStack->setActivePan(applet->panId());
}

// Keep the local spectrum centered immediately so the active slice marker
// is visible before the radio's status echo arrives.
sw->setFrequencyRange(targetMhz, bandwidthMhz);
pushSliceFrequencyToOverlays(s, targetMhz);

// #4142 — ask the radio FIRST, so the local view can only advance if the
// command actually reached the wire. During a profile load this center write
// is suppressed; centering the spectrum on a center the radio never took is
// exactly what projects honest tiles into a lying frame and bakes black rows
// into waterfall history. requestPanCenter() defers and replays it instead.
bool centerDeferred = false;
if (forceRadioCenter && m_radioModel.isConnected()) {
m_radioModel.sendCommand(
QString("display pan set %1 center=%2")
.arg(s->panId()).arg(targetMhz, 0, 'f', 6));
centerDeferred = !m_radioModel.requestPanCenter(s->panId(), targetMhz);
}

// Keep the local spectrum centered immediately so the active slice marker is
// visible before the radio's status echo arrives — unless the center was
// deferred, in which case the pan must keep showing truthful spectrum for the
// span the radio still has.
if (!centerDeferred) {
sw->setFrequencyRange(targetMhz, bandwidthMhz);
}
pushSliceFrequencyToOverlays(s, targetMhz);

TuneCenteringResult result;
result.oldCenterMhz = pan ? pan->centerMhz() : targetMhz;
Expand Down
12 changes: 6 additions & 6 deletions src/gui/MainWindow_DigitalModes.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1194,18 +1194,18 @@ void MainWindow::activateWFM(int sliceId)
m_wfmSliceId = sliceId;

// Centre the pan (and with it the DAX IQ stream) on the slice — once.
// applyPanStatus updates the local model immediately so offsets computed
// before the radio echoes the new centre are already correct.
// requestPanCenter() updates the local model as it puts the command on the
// wire, so offsets computed before the radio echoes the new centre are
// already correct — and during a profile load it defers the write instead of
// letting it be dropped, which would have left the DAX IQ stream centred
// somewhere the client no longer believed it was (#4142).
auto centerPanAtSlice = [this, s]() {
const QString panId = s->panId();
if (panId.isEmpty()) return;
const double freq = s->frequency();
auto* pan = m_radioModel.panadapter(panId);
if (pan && qFuzzyCompare(pan->centerMhz(), freq)) return;
const QString freqStr = QString::number(freq, 'f', 6);
if (pan) pan->setCenterBandwidth(freq, -1.0); // aetherd RFC 2.3
m_radioModel.sendCommand(
QString("display pan set %1 center=%2").arg(panId, freqStr));
m_radioModel.requestPanCenter(panId, freq);
};
centerPanAtSlice();

Expand Down
87 changes: 54 additions & 33 deletions src/gui/MainWindow_Wiring.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -467,23 +467,31 @@ bool MainWindow::snapCenterLockForSlice(SliceModel* slice, double mhz, bool send
bandwidthMhz > 0.0 ? std::max(mhz, bandwidthMhz / 2.0) : mhz;
bool changed = false;

if (sw && bandwidthMhz > 0.0
&& (!qFuzzyCompare(sw->centerMhz(), targetCenterMhz)
|| !qFuzzyCompare(sw->bandwidthMhz(), bandwidthMhz))) {
sw->setFrequencyRangeImmediate(targetCenterMhz, bandwidthMhz);
changed = true;
}

const bool modelNeedsCenter = !qFuzzyCompare(pan->centerMhz(), targetCenterMhz);
bool centerDeferred = false;

if (!kiwiDisplayActive && modelNeedsCenter) {
pan->setCenterBandwidth(targetCenterMhz, -1.0); // aetherd RFC 2.3
changed = true;
if (sendCommand) {
// #4142 — defer, never drop. requestPanCenter() advances the local
// model only when the command actually reaches the wire, and queues
// it for replay when a profile load is holding radio-state writes.
centerDeferred = !m_radioModel.requestPanCenter(panId, targetCenterMhz);
} else {
// Local-only snap: the caller explicitly wants no radio write, so
// there is no wire command for the model to diverge from.
pan->setCenterBandwidth(targetCenterMhz, -1.0); // aetherd RFC 2.3
}
changed = !centerDeferred;
}

if (sendCommand && !kiwiDisplayActive && modelNeedsCenter) {
m_radioModel.sendCommand(
QStringLiteral("display pan set %1 center=%2")
.arg(panId, QString::number(targetCenterMhz, 'f', 6)));
// Never move the visible span while the radio stays put. A view that claims
// a center the radio never took is precisely what projects honest tiles into
// a lying frame and bakes black rows into waterfall history (#4142).
if (!centerDeferred && sw && bandwidthMhz > 0.0
&& (!qFuzzyCompare(sw->centerMhz(), targetCenterMhz)
|| !qFuzzyCompare(sw->bandwidthMhz(), bandwidthMhz))) {
sw->setFrequencyRangeImmediate(targetCenterMhz, bandwidthMhz);
changed = true;
}

return changed;
Expand Down Expand Up @@ -1952,6 +1960,15 @@ void MainWindow::scheduleProfileLoadRecovery(const QString& profileType,
});
QTimer::singleShot(kProfileLoadDeferredPanFlushDelayMs, this, [this]() {
flushPendingProfileLoadPanDimensions();
// Ordered after dimensions on the SAME timer — no fourth scheduler.
// Bin size is bandwidth/xpixels, so the pan must know its pixel geometry
// before it is recentered. This timer is the first flush point past hold
// expiry; the two earlier dimension flushes (1500/3500 ms) run INSIDE the
// hold, where xpixels/ypixels are exempt but a center write is not.
// flushPendingProfileLoadPanCenters() re-checks the hold itself and
// early-returns while it is armed, so no center can escape early even if
// this call site were moved. (#4142)
m_radioModel.flushPendingProfileLoadPanCenters();
});
QTimer::singleShot(kProfileLoadPostHoldRecoveryDelayMs, this, [this]() {
m_profileLoadPendingFftYpixels.clear();
Expand Down Expand Up @@ -2411,13 +2428,13 @@ void MainWindow::wirePanadapter(PanadapterApplet* applet)
}
if (auto* pan = m_radioModel.panadapter(applet->panId())) {
center = std::max(center, pan->bandwidthMhz() / 2.0);
// The radio may acknowledge this command without echoing a display
// status to the setting client. Keep the canonical model aligned
// with the visible pan so TCI dds: follows the IQ center.
pan->setCenterBandwidth(center, -1.0);
}
m_radioModel.sendCommand(
QString("display pan set %1 center=%2").arg(applet->panId()).arg(center, 0, 'f', 6));
// The radio may acknowledge this command without echoing a display status
// to the setting client, so requestPanCenter() keeps the canonical model
// aligned with the wire command (TCI dds: follows this center) — and
// during a profile load it defers rather than letting sendCmd drop it
// silently (#4142).
m_radioModel.requestPanCenter(applet->panId(), center);
});
// Band/Segment Zoom toggle off the pan's radio-authoritative model state
// (togglePanZoomModeForPan) — shared with the keyboard/MIDI shortcuts
Expand Down Expand Up @@ -3214,9 +3231,11 @@ void MainWindow::wirePanadapter(PanadapterApplet* applet)
// In-window drag moves pass the unchanged center purely to tune
// without reveal — only emit the pan command when it actually moves.
if (!qFuzzyCompare(centerMhz, pan->centerMhz())) {
m_radioModel.sendCommand(
QString("display pan set %1 center=%2")
.arg(pan->panId()).arg(centerMhz, 0, 'f', 6));
// Deferred rather than dropped during a profile load (#4142).
// The SpectrumWidget owns its own view during an edge-pan drag,
// so the gesture still tracks the cursor; the radio catches up
// when the deferred center flushes.
m_radioModel.requestPanCenter(pan->panId(), centerMhz);
}
}
queueActiveSliceForSpectrumTarget(target->sliceId());
Expand Down Expand Up @@ -3665,10 +3684,12 @@ MainWindow::TuneCenteringResult MainWindow::revealFrequencyIfNeeded(
? kPanFollowAnimationDurationMs
: 0;

pan->setCenterBandwidth(result.newCenterMhz, -1.0); // aetherd RFC 2.3
m_radioModel.sendCommand(
QString("display pan set %1 center=%2")
.arg(pan->panId()).arg(result.newCenterMhz, 0, 'f', 6));
// #4142 — defer, never drop. During a profile load the pan center write
// is suppressed at the wire; advancing the local center anyway is what
// made the waterfall go black (the view claimed a center the radio never
// took). requestPanCenter() applies the model update only when the
// command actually goes out, and replays it once the hold lifts.
m_radioModel.requestPanCenter(pan->panId(), result.newCenterMhz);
return result;
}

Expand Down Expand Up @@ -3752,13 +3773,13 @@ MainWindow::TuneCenteringResult MainWindow::revealFrequencyIfNeeded(
? kPanFollowAnimationDurationMs
: 0;

// Apply the center optimistically so the owning spectrum repaints
// immediately; SpectrumWidget decides whether that becomes a short
// retargetable animation or an immediate snap based on shift size.
pan->setCenterBandwidth(result.newCenterMhz, -1.0); // aetherd RFC 2.3
m_radioModel.sendCommand(
QString("display pan set %1 center=%2")
.arg(pan->panId()).arg(result.newCenterMhz, 0, 'f', 6));
// Apply the center so the owning spectrum repaints immediately;
// SpectrumWidget decides whether that becomes a short retargetable animation
// or an immediate snap based on shift size. During a profile load this
// defers instead (#4142) — the model does not advance ahead of the radio, so
// the pan keeps showing truthful spectrum for its real span until the
// deferred center flushes.
m_radioModel.requestPanCenter(pan->panId(), result.newCenterMhz);
return result;
}

Expand Down
40 changes: 40 additions & 0 deletions src/models/ProfileLoadCommand.h
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
#pragma once

#include <QLatin1Char>
#include <QRegularExpression>
#include <QString>
#include <QStringList>
#include <QtGlobal>

namespace AetherSDR {
Expand Down Expand Up @@ -44,4 +46,42 @@ inline ProfileLoadCommand parseProfileLoadCommand(const QString& command)
};
}

// Classifies a command as a write to radio state that a profile load owns and
// will rebuild. RadioModel::sendCmd() consults this as a low-level backstop and
// suppresses matches while the profile-load hold is armed.
//
// Callers must not rely on that backstop to swallow user-initiated writes: a
// suppressed command never reaches the wire and is lost. Route pan center /
// bandwidth writes through RadioModel::requestPanCenter(), which defers and
// coalesces them until the hold lifts instead of dropping them (#4142).
inline bool isProfileOwnedRadioStateWrite(const QString& command)
{
const QString trimmed = command.trimmed();
const QStringList tokens = trimmed.simplified().split(QLatin1Char(' '), Qt::SkipEmptyParts);
if (tokens.size() >= 5
&& tokens[0] == QStringLiteral("display")
&& tokens[1] == QStringLiteral("pan")
&& tokens[2] == QStringLiteral("set")) {
bool hasPixelDimension = false;
for (int i = 4; i < tokens.size(); ++i) {
const QString key = tokens[i].section(QLatin1Char('='), 0, 0);
if (key != QStringLiteral("xpixels") && key != QStringLiteral("ypixels")) {
return true;
}
hasPixelDimension = true;
}
// xpixels/ypixels are allowed past this low-level guard because
// MainWindow queues them during a profile load and flushes them after
// the radio accepts the profile. Do not bypass
// MainWindow::requestPanDimensionsForRadio(); early dimension writes
// can cause the radio to save a partial GUIClient slice layout.
return !hasPixelDimension;
}

return trimmed.startsWith(QStringLiteral("slice set "))
|| trimmed.startsWith(QStringLiteral("display pan set "))
|| trimmed.startsWith(QStringLiteral("display panafall set "))
|| trimmed.startsWith(QStringLiteral("display waterfall set "));
}

} // namespace AetherSDR
Loading
Loading