Skip to content
Merged
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
10 changes: 10 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5043,6 +5043,16 @@ target_include_directories(radiomodel_dax_null_test PRIVATE src)
target_link_libraries(radiomodel_dax_null_test PRIVATE aethercore Qt6::Core Qt6::Test)
add_test(NAME radiomodel_dax_null_test COMMAND radiomodel_dax_null_test)

add_executable(dbm_range_plausibility_test tests/dbm_range_plausibility_test.cpp)
target_include_directories(dbm_range_plausibility_test PRIVATE src)
target_link_libraries(dbm_range_plausibility_test PRIVATE Qt6::Core)
add_test(NAME dbm_range_plausibility_test COMMAND dbm_range_plausibility_test)

add_executable(radiomodel_pan_range_null_test tests/radiomodel_pan_range_null_test.cpp)
target_include_directories(radiomodel_pan_range_null_test PRIVATE src)
target_link_libraries(radiomodel_pan_range_null_test PRIVATE aethercore Qt6::Core Qt6::Test)
add_test(NAME radiomodel_pan_range_null_test COMMAND radiomodel_pan_range_null_test)

add_executable(radiomodel_pan_id_mapping_test tests/radiomodel_pan_id_mapping_test.cpp)
target_include_directories(radiomodel_pan_id_mapping_test PRIVATE src)
target_link_libraries(radiomodel_pan_id_mapping_test PRIVATE aethercore Qt6::Core Qt6::Test)
Expand Down
13 changes: 13 additions & 0 deletions src/core/backends/IRadioBackend.h
Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,19 @@ class IRadioBackend : public QObject {
void connected();
void disconnected();
void connectionError(const QString& reason);

// A problem with the RADIO'S CONFIGURATION that the operator should fix,
// but which does not end the session. Distinct from connectionError, which
// every consumer treats as fatal: RadioModel starts its reconnect timer on
// it unconditionally, so using that channel for advice tears down a working
// link and then does it again on the next attempt — a permanent reconnect
// loop whose cause reads as a helpful message. That is exactly what an
// IC-9700 with MOD Input set to USB did: connect, warn, drop, repeat every
// 5 s, with the radio itself perfectly healthy.
//
// If it does not stop the radio working, it belongs here.
void configurationWarning(const QString& message);

void capabilitiesChanged();

// A fresh transport snapshot. Emitted on a FIXED cadence while connected,
Expand Down
23 changes: 23 additions & 0 deletions src/core/backends/RadioCapabilities.h
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,29 @@ struct RadioCapabilities {
// non-Flex backend must NOT open the mic on connect. (#4449 review)
bool hostModulates = false;

// The RADIO owns its display dBm scale and will echo back a range sent to
// it. True for a Flex, whose `display pan set min_dbm=…` is a real command
// the radio adopts and reports; false for a backend that decodes its scope
// at a FIXED calibration it does not accept changes to (Icom CI-V, whose
// floor/span come from ScopeCalibration and shift only with the radio's own
// reference level).
//
// This gates the noise-floor auto-adjust, and it has to, because that loop
// is built on the echo: the widget moves its reference level, requests the
// new range, and waits for the radio to confirm before moving again. With
// no command plane the request is dropped, the confirmation never arrives,
// and the auto-floor reads the unchanged floor as "not there yet" and steps
// again — measured at a linear 24 dB/s, walking off the bottom of the scale
// (-202, -226, -250 … -1882 dBm) until dbmRangeLooksPlausible() starts
// rejecting it at -180. Those rejections are the SYMPTOM; the missing echo
// is the fault, which is why raising the reject floor would not have fixed
// it. On an IC-9700 this was the visible "waterfall resets ~1 s after the
// trace fills" and the reconnect churn behind it.
//
// A backend with a fixed scale needs no auto-adjust: its floor is already
// where the calibration puts it.
bool radioOwnsDbmScale = true;

// The RADIO stores memory channels and re-dumps them on connect. True for a
// Flex, whose memory slots live in the radio and are shared by every client
// attached to it; false for a direct-sampling or receiver-only backend (HL2,
Expand Down
113 changes: 107 additions & 6 deletions src/core/backends/icom/IcomCivBackend.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,13 @@ RadioCapabilities IcomCivBackend::capabilities() const
c.canTransmit = m.hasTransmit;
c.txPowerMaxWatts = m.txPowerMaxWatts;

// The scope scale is OURS, not the radio's: it comes from ScopeCalibration
// (floor/span, shifted by the radio's own reference level), and there is no
// CI-V command to set a display dBm range — this backend has no consumer for
// one. Leaving this true made the noise-floor auto-adjust chase an echo that
// can never arrive; see RadioCapabilities::radioOwnsDbmScale.
c.radioOwnsDbmScale = false;

// The RADIO modulates. Contrast the HL2, where the host does — this drives
// the mic-source list and the PC-audio lock, so getting it wrong opens the
// host microphone on a radio that will never use it.
Expand Down Expand Up @@ -155,6 +162,31 @@ RadioCapabilities IcomCivBackend::capabilities() const

void IcomCivBackend::publishCapabilities() { emit capabilitiesChanged(); }

void IcomCivBackend::publishScopeDbmRange()
{
// kUnknown has hasScope=false, so this is a quiet no-op on a backend whose
// radio has not identified itself yet — which is correct: there is no scope
// to draw an axis for, and the connect path publishes once the model is
// known. (m_model is never null; the constructor seeds it with
// unknownModel().)
if (!m_model->hasScope)
return;

// THE AXIS MUST MATCH THE DECODER, INCLUDING THE SIGN.
//
// toDbm() maps a sample to `floorDbm + (v/max)*spanDb - referenceDb`, so
// raising the radio's reference level moves the decoded trace DOWN in dBm.
// The axis has to move the same way. An earlier version of this added
// referenceDb here while toDbm subtracted it, which left the scale wrong by
// 2x the reference whenever it was non-zero — invisible at the default 0,
// and a growing error the further the operator moved it.
//
// Derived from the same ScopeCalibration toDbm() uses rather than repeating
// the arithmetic, so the two cannot drift apart again.
const double floorDbm = m_scopeCal.floorDbm - m_scopeCal.referenceDb;
emit panRangeChanged(panId(), floorDbm, floorDbm + m_scopeCal.spanDb);
}

// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -370,6 +402,35 @@ void IcomCivBackend::checkModInput()
if (m_dataOffModInput < 0 || m_dataModInput < 0)
return;

// ONLY a radio with Wi-Fi has a WLAN modulation source to select.
//
// The 1A 05 item numbers (118/119) and the value table below are read from
// ONE model's CI-V Reference Guide and sent to every Icom, but each model
// numbers its own SET menu and its own enum. On an IC-9700 — LAN only, no
// Wi-Fi — both items were set correctly on the front panel and the radio
// answered 0x01, which this table calls "USB". So either 118/119 are not
// MOD Input on that model, or 0x01 IS its network source; either way
// demanding 0x03 asks for a setting the radio cannot offer, and the warning
// could never be satisfied by any front-panel action.
//
// Reported by an operator with the radio in front of them (2026-08-05): set
// to LAN on both, warned anyway, every session. A check that fires on a
// correctly configured radio is worse than no check — it is the one the
// operator learns to scroll past, and it trains them past the real ones.
//
// Staying silent here loses nothing that was working: the warning was
// WRONG on this radio, not merely noisy. Re-enable per model once the
// mapping is confirmed against that model's own guide (the same bar
// IcomModel::verified sets for the rest of the table).
// Note this also silences the check on an UNIDENTIFIED radio, since
// kUnknown carries hasWifi=false. That is the right outcome, though for a
// second reason: kUnknown is also hasTransmit=false, and a radio this
// client will not let key has no modulation path to warn about. Warning
// there would be advice about a transmission that cannot happen, decoded
// through a value table not known to apply to that model.
if (!m_model->hasWifi)
Comment thread
jensenpat marked this conversation as resolved.
return;

const bool voiceOk = m_dataOffModInput == setting::kModWlan;
const bool dataOk = m_dataModInput == setting::kModWlan;
if (voiceOk && dataOk)
Expand All @@ -393,10 +454,16 @@ void IcomCivBackend::checkModInput()
wrong << QStringLiteral("data modes take modulation from %1")
.arg(name(m_dataModInput));

// A connectionError rather than a log line: this silently costs the
// operator every transmission, and it is fixable in about ten seconds once
// they know which menu to open.
emit connectionError(
// A configurationWarning, NOT a connectionError: this is advice about a
// radio that is otherwise working perfectly. connectionError is fatal to
// every consumer — RadioModel starts its reconnect timer on it — so raising
// it here dropped the session ~4 ms after the CI-V stream came live and
// reconnected into the same check forever. The operator saw a radio that
// would not stay connected and a message about a menu setting, with no way
// to tell that the message WAS the disconnect.
//
// It still reaches the operator; it just no longer costs them the session.
emit configurationWarning(
QStringLiteral("The radio is not listening to network audio — %1. "
"AetherSDR's transmit audio will be ignored and the radio "
"will key at zero output. On the radio: MENU > SET > "
Expand Down Expand Up @@ -468,6 +535,28 @@ void IcomCivBackend::onCivFrame(const CivFrame& frame)
if (!widths.empty() && m_model->hasScope)
emit panBandwidthLimitsChanged(panId(), widths.front() / 1e6,
widths.back() / 1e6);

// ⛔ Publish the Y axis too, or the display invents one and
// never stops. Without a range from the backend the pan
// auto-ranges from its own noise-floor estimate, and because
// MainWindow refuses anything below -180 dBm
// (dbmRangeLooksPlausible) the radio never adopts the value —
// so the estimate is never corrected and drifts further every
// cycle. Observed on a live IC-9700 2026-08-05: a linear
// runaway of -24 dB/s, 84 rejected `display pan set` commands
// in 90 s, min falling -202 -> -898 dBm and still going. The
// operator sees the waterfall reset each time the drift crosses
// the guard, and the radio menu stops responding behind the
// command flood.
//
// The numbers are m_scopeCal's own — ESTIMATES, as its header
// says at length, not a measurement. Publishing an estimate is
// right here: the axis is anchored and stable, and the estimate
// is already the one toDbm() decodes with, so the display and
// the decoder agree. An uncalibrated-but-consistent axis beats
// a self-referential one.
publishScopeDbmRange();

publishMeterDefs();
publishCapabilities();
}
Expand Down Expand Up @@ -1243,6 +1332,12 @@ void IcomCivBackend::invokeExtension(const QString& ns, const QString& verb, qui
sendUserCommand(cmdScopeReference(m_session ? m_session->civAddress() : 0xA4,
arg.toDouble()));
m_scopeCal.referenceDb = arg.toDouble();
// The reference level shifts the whole trace, so the AXIS has to move
// with it. Without this the range published at connect goes stale the
// moment the operator changes the reference — the trace slides and the
// scale it is drawn against does not, which reads as a calibration
// error rather than a missing update.
publishScopeDbmRange();
emit extensionResult(requestId, true);
return;
}
Expand Down Expand Up @@ -1412,8 +1507,14 @@ IRadioBackend::HealthSnapshot IcomCivBackend::healthSnapshot() const
default: return QStringLiteral("?");
}
};
const bool ok = m_dataOffModInput == setting::kModWlan
&& m_dataModInput == setting::kModWlan;
// The verdict, like the warning in checkModInput(), is only meaningful
// on a radio that HAS a WLAN source. Elsewhere show the raw values and
// pass no judgement: an IC-9700 set correctly to LAN reads back 0x01
// here, and appending "NOT WLAN" to that is telling the operator their
// working radio is misconfigured.
const bool ok = !m_model->hasWifi
|| (m_dataOffModInput == setting::kModWlan
&& m_dataModInput == setting::kModWlan);
h.values.insert(QStringLiteral("modinput"),
QStringLiteral("%1 voice / %2 data%3")
.arg(name(m_dataOffModInput), name(m_dataModInput),
Expand Down
4 changes: 4 additions & 0 deletions src/core/backends/icom/IcomCivBackend.h
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,10 @@ private slots:

private:
void publishCapabilities();
// Publish the scope's dBm axis, derived from the SAME ScopeCalibration that
// toDbm() decodes with. Call whenever anything it depends on changes — at
// connect, and on every reference-level change.
void publishScopeDbmRange();
void publishMeterDefs();
void sendUserCommand(const std::vector<std::uint8_t>& frame);
void applyScopeStartup();
Expand Down
14 changes: 14 additions & 0 deletions src/core/backends/icom/IcomModels.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,20 @@ constexpr std::array<IcomModel, 7> kModels{{
/*verified*/ true,
},
{
// IC-9700 — scope geometry MEASURED on a live radio 2026-08-05 (G0JKN),
// not read from the guide, so `verified` stays false: that flag means
// "confirmed against this model's own CI-V Reference Guide" and this
// evidence is a different kind.
//
// 618 consecutive scope frames off an IC-9700 at 10.0.0.7, every one
// 475 pixels wide, decoded through the RS-BA1 CI-V data stream. The
// frame's own bounds header cross-checks: centre 439.864060 MHz (where
// the radio was tuned) and a 500 kHz span, giving 1052.6 Hz per pixel.
//
// So the 475/160/11 inherited from the IC-705 turn out to be RIGHT for
// this model — worth recording precisely because it could not be
// assumed. The IC-7610 row below is the counter-example: 689 points and
// a 0..200 range.
0xA2, "IC-9700", 2, 2,
/*hasNetwork*/ true, /*hasWifi*/ false,
/*hasScope*/ true, 475, 160, 11,
Expand Down
59 changes: 58 additions & 1 deletion src/core/backends/icom/IcomSession.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ constexpr int kTxPumpMs = 10;
// frame swallows every subsequent byte and the radio appears to stop answering
// while the link is demonstrably fine.
constexpr int kCivFrameTimeoutMs = 100;
// Re-send the CI-V data-stream open at the reference's cadence until the radio
// starts streaming. Matches kappanhang / SDR9700 startCivDataTimer(100).
constexpr int kCivOpenRetryMs = 100;
// ~5 s of asking. Long enough for a slow radio, short enough that a radio which
// will never answer says so rather than retrying silently forever.
constexpr int kCivOpenMaxAttempts = 50;

std::span<const std::uint8_t> asSpan(const QByteArray& b)
{
Expand Down Expand Up @@ -101,7 +107,7 @@ bool IcomSession::start(const Params& params)

void IcomSession::stop()
{
for (QTimer** t : {&m_tokenTimer, &m_txTimer, &m_civTimeout}) {
for (QTimer** t : {&m_tokenTimer, &m_txTimer, &m_civTimeout, &m_civOpenRetry}) {
if (*t) {
(*t)->stop();
(*t)->deleteLater();
Expand Down Expand Up @@ -376,6 +382,47 @@ void IcomSession::onSerialReady()
m_serial->sendTracked(buildSerialOpen(m_serial->localSessionId(),
m_serial->remoteSessionId(), m_serialSendSeq++, true));

// ⛔ ONE OPEN IS NOT ENOUGH. Observed on a live IC-9700 2026-08-05: the
// radio accepts the open, reports the pipe ready, and then streams nothing
// — not one CI-V frame in 45 s. Every consequence is downstream and silent:
// no 0x19 0x00 reply, so the model never resolves; no model, so scope and
// transmit stay disabled and no dBm range is published; no range, so the pan
// auto-ranges into a runaway MainWindow rejects once a second. The operator
// sees a blank frequency and a waterfall that keeps resetting.
//
// kappanhang and the SDR9700 reference both re-send the open on a 100 ms
// timer until data flows (their startCivDataTimer), and Aether-gate does the
// same driving THIS radio — 1356 frames in 45 s, 30.1 fps. An IC-705 that
// happens to start on the first open would never expose this.
m_civDataSeen = false;
m_civOpenAttempts = 0;
if (!m_civOpenRetry) {
m_civOpenRetry = new QTimer(this);
connect(m_civOpenRetry, &QTimer::timeout, this, [this]() {
if (m_civDataSeen || !m_serial) {
m_civOpenRetry->stop();
return;
}
// Bounded. The reference retries indefinitely, but it is a headless
// bridge; here an unbounded 10 Hz stream of opens at a radio that is
// never going to answer is just noise that hides the real fault. Say
// so once and stop — a silent forever-retry is how "it just does not
// work" becomes unreportable.
if (++m_civOpenAttempts > kCivOpenMaxAttempts) {
m_civOpenRetry->stop();
qCWarning(lcIcom)
<< "CI-V stream never started after" << kCivOpenMaxAttempts
<< "open attempts — the radio accepted the open and sent no data."
<< "Check CI-V is enabled for the network port on the radio.";
return;
}
m_serial->sendTracked(buildSerialOpen(m_serial->localSessionId(),
m_serial->remoteSessionId(),
m_serialSendSeq++, true));
});
}
m_civOpenRetry->start(kCivOpenRetryMs);

if (!m_civTimeout) {
m_civTimeout = new QTimer(this);
connect(m_civTimeout, &QTimer::timeout, this, &IcomSession::onCivFrameTimeout);
Expand All @@ -394,6 +441,16 @@ void IcomSession::onSerialPayload(const QByteArray& packet)
if (payload.empty())
return; // a keepalive idle, or the serial open/close echo

// First real CI-V payload: the stream is live, so stop asking it to open.
// The reference stops its timer on exactly this condition rather than after
// a fixed count, because a slow radio must not be abandoned early.
if (!m_civDataSeen) {
m_civDataSeen = true;
if (m_civOpenRetry)
m_civOpenRetry->stop();
qCInfo(lcIcom) << "CI-V stream live — open-retry stopped";
}

for (const auto& raw : m_civ.feed(payload)) {
auto frame = parseFrame(raw);
if (!frame)
Expand Down
5 changes: 5 additions & 0 deletions src/core/backends/icom/IcomSession.h
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,11 @@ private slots:
QTimer* m_tokenTimer = nullptr;
QTimer* m_txTimer = nullptr;
QTimer* m_civTimeout = nullptr;
// Re-sends the CI-V data-stream open until the radio actually starts
// streaming. One open is not reliably enough — see onSerialReady().
QTimer* m_civOpenRetry = nullptr;
bool m_civDataSeen = false;
int m_civOpenAttempts = 0;

// Auth state. The auth id and session ids are re-read from the stream grant
// rather than cached from the login — see parseStreamGrant's comment; the
Expand Down
Loading
Loading