diff --git a/docs/automation-bridge.md b/docs/automation-bridge.md index e79e4a6ef..e29dabdca 100644 --- a/docs/automation-bridge.md +++ b/docs/automation-bridge.md @@ -3299,6 +3299,15 @@ hardware tests. Raw RS-BA1 datagram logging is intentionally off by default and should only be enabled briefly when these structured diagnostics are insufficient. +**`civ power standby|wake|probe|setting`** sends the documented CI-V power command through +the active authenticated Icom session. `wake` applies the active model's +verified native-LAN framing; unsupported models fail closed. `standby` first +quiesces CI-V polling, and `probe` sends one identity read while quiesced so a +hardware test can distinguish an asleep radio from an artificially quiet +client. This is an explicit hardware-test action only: disconnect never powers +the radio down. `setting` reads the IC-9700's Power OFF Setting (`1A 05 01 46`) +without exposing the raw CI-V injector. + ### `controls` The CI-V control and meter registry, joined against what is actually wired. diff --git a/src/core/AutomationServer.cpp b/src/core/AutomationServer.cpp index 054711f4e..4dec93ce1 100644 --- a/src/core/AutomationServer.cpp +++ b/src/core/AutomationServer.cpp @@ -3446,7 +3446,7 @@ const std::vector& AutomationServer::verbRegistry() }); add("civ", {}, - "civ |trace [all]|session|scheduler|incident> — CI-V " + "civ |power |trace [all]|session|scheduler|incident> — CI-V " "inject, frame trace, lease/scheduler health, or last incident " "(Icom; send is TX-gated)", parseActionRest, @@ -7963,12 +7963,13 @@ QJsonObject AutomationServer::doCiv(const QString& action, const QString& arg) return err(QStringLiteral("no backend available")); const QString a = action.trimmed().toLower(); - if (a.isEmpty() || (a != QLatin1String("send") && a != QLatin1String("trace") + if (a.isEmpty() || (a != QLatin1String("send") && a != QLatin1String("power") + && a != QLatin1String("trace") && a != QLatin1String("session") && a != QLatin1String("incident") && a != QLatin1String("scheduler"))) { return err(QStringLiteral( - "civ requires an action (send|trace|session|scheduler|incident)")); + "civ requires an action (send|power|trace|session|scheduler|incident)")); } if (a == QLatin1String("send") && !m_txAllowed) { return err(QStringLiteral( @@ -8000,13 +8001,29 @@ QJsonObject AutomationServer::doCiv(const QString& action, const QString& arg) failure = msg; }, Qt::DirectConnection); - const QString verb = a == QLatin1String("send") ? QStringLiteral("civ.send") + QString verb; + QVariant extensionArg = arg.trimmed(); + if (a == QLatin1String("power")) { + const QString state = arg.trimmed().toLower(); + if (state != QLatin1String("standby") && state != QLatin1String("wake") + && state != QLatin1String("probe") && state != QLatin1String("setting")) { + disconnect(okConn); + disconnect(errConn); + return err(QStringLiteral("civ power requires standby|wake|probe|setting")); + } + verb = state == QLatin1String("wake") ? QStringLiteral("power.wake") + : state == QLatin1String("probe") ? QStringLiteral("power.probe") + : state == QLatin1String("setting") ? QStringLiteral("power.setting") + : QStringLiteral("power.standby"); + } else { + verb = a == QLatin1String("send") ? QStringLiteral("civ.send") : a == QLatin1String("trace") ? QStringLiteral("civ.trace") : a == QLatin1String("incident") ? QStringLiteral("civ.incident") : a == QLatin1String("scheduler") ? QStringLiteral("civ.scheduler.status") : QStringLiteral("civ.session"); - backend->invokeExtension(QStringLiteral("icom"), verb, rid, arg.trimmed()); + } + backend->invokeExtension(QStringLiteral("icom"), verb, rid, extensionArg); disconnect(okConn); disconnect(errConn); diff --git a/src/core/backends/IRadioBackend.h b/src/core/backends/IRadioBackend.h index e49900c6f..faaa82f1d 100644 --- a/src/core/backends/IRadioBackend.h +++ b/src/core/backends/IRadioBackend.h @@ -799,6 +799,11 @@ class IRadioBackend : public QObject { void disconnected(); void connectionError(const QString& reason); + // A non-fatal connection phase the operator is expected to wait through. + // Unlike configurationWarning this names no fault, and unlike + // connectionError it must never start reconnect policy by itself. + void connectionProgress(const QString& message); + // 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 diff --git a/src/core/backends/icom/CivCodec.cpp b/src/core/backends/icom/CivCodec.cpp index 304402608..14bf25ae8 100644 --- a/src/core/backends/icom/CivCodec.cpp +++ b/src/core/backends/icom/CivCodec.cpp @@ -48,6 +48,20 @@ std::vector buildFrameSub(std::uint8_t to, std::uint8_t cmd, std:: return buildFrame(to, cmd, body); } +std::vector cmdPowerOn(std::uint8_t to, std::size_t extraPreambleBytes, + std::uint8_t from) +{ + std::vector frame = buildFrameSub(to, cmd::kPower, 0x01, {}); + frame[3] = from; + frame.insert(frame.begin(), extraPreambleBytes, kCivPreamble); + return frame; +} + +std::vector cmdPowerOff(std::uint8_t to) +{ + return buildFrameSub(to, cmd::kPower, 0x00, {}); +} + // Which commands carry a subcommand is a per-command fact, not a positional // one. Treating every second byte as a subcommand would turn command 0x05's // first frequency digit into a "subcommand"; treating none of them as one @@ -85,6 +99,16 @@ bool commandHasSubcommand(std::uint8_t command) std::optional parseFrame(std::span frame) { + // Wake synchronization fill is a run of FE bytes before the standard FE FE + // envelope. Parse from the final pair immediately before the first address. + std::size_t firstNonPreamble = 0; + while (firstNonPreamble < frame.size() + && frame[firstNonPreamble] == kCivPreamble) { + ++firstNonPreamble; + } + if (firstNonPreamble > 2) { + frame = frame.subspan(firstNonPreamble - 2); + } // FE FE ... — the shortest legal frame is 6 bytes // (an FB/FA acknowledgement with no payload). if (frame.size() < 6) diff --git a/src/core/backends/icom/CivCodec.h b/src/core/backends/icom/CivCodec.h index ab0dd6059..0216c653b 100644 --- a/src/core/backends/icom/CivCodec.h +++ b/src/core/backends/icom/CivCodec.h @@ -230,6 +230,13 @@ inline constexpr std::uint8_t kTuneOffset = 0x21; inline constexpr std::uint8_t kVfoMode = 0x26; } // namespace cmd +// Wake a transceiver from standby. `extraPreambleBytes` names model/baud- +// specific FE synchronization fill before the standard two-byte preamble. +[[nodiscard]] std::vector cmdPowerOff(std::uint8_t to); +[[nodiscard]] std::vector cmdPowerOn(std::uint8_t to, + std::size_t extraPreambleBytes = 0, + std::uint8_t from = kControllerAddress); + [[nodiscard]] std::vector cmdSendCwMessage( std::uint8_t to, std::string_view ascii); [[nodiscard]] std::vector cmdAbortCwMessage(std::uint8_t to); diff --git a/src/core/backends/icom/IcomCivBackend.cpp b/src/core/backends/icom/IcomCivBackend.cpp index 144635c29..b61438d1b 100644 --- a/src/core/backends/icom/IcomCivBackend.cpp +++ b/src/core/backends/icom/IcomCivBackend.cpp @@ -1,4 +1,5 @@ #include "core/backends/icom/IcomCivBackend.h" +#include "core/backends/icom/IcomConnectBootstrap.h" #include #include @@ -765,6 +766,20 @@ void IcomCivBackend::connectRadio(const RadioConnectRequest& request) m_civReported = 0; m_civAmbiguous = false; m_connectBurstSent = false; + m_connectIdentityPending = false; + m_connectDirectedFallbackAttempted = false; + m_connectReadinessPending = false; + m_connectionPublished = false; + m_connectWakeAttempted = false; + const qint64 connectNowUtcMs = QDateTime::currentMSecsSinceEpoch(); + if (m_lastConnectWakeUtcMs <= 0 + || connectNowUtcMs - m_lastConnectWakeUtcMs >= kConnectWakeCooldownMs) { + m_connectWakeAttempts = 0; + } + if (m_lastConnectSessionRetryUtcMs <= 0 + || connectNowUtcMs - m_lastConnectSessionRetryUtcMs >= kConnectWakeCooldownMs) { + m_connectSessionRetryAttempted = false; + } m_modelByName = nullptr; // 48 kHz, FIXED — the rate is deliberately not negotiable here. // @@ -806,6 +821,7 @@ void IcomCivBackend::disconnectRadio() { finishMemoryRefresh(false); m_tuneTimer->stop(); + m_powerTestQuiesced = false; ++m_sessionGeneration; m_civRecoveryStartedAtMs = 0; m_lastCivRecoveryAttemptAtMs = 0; @@ -902,13 +918,15 @@ void IcomCivBackend::disconnectRadio() m_tuning = false; m_cwBreakInMode = 1; m_preTuneTxPowerPercent = -1; - if (m_connected) { - m_connected = false; + const bool published = m_connectionPublished; + m_connectionPublished = false; + m_connected = false; + if (published) { emit disconnected(); } } -bool IcomCivBackend::isConnected() const { return m_connected; } +bool IcomCivBackend::isConnected() const { return m_connectionPublished; } // The connect-edge read burst. // @@ -1081,6 +1099,115 @@ void IcomCivBackend::sendConnectReadBurst() } +void IcomCivBackend::queueConnectIdentityProbe(std::string key, bool directed) +{ + if (!m_session) { + return; + } + m_connectIdentityPending = true; + const std::uint8_t destination = (m_civAddressPinned || directed) + ? m_session->civAddress() : kBroadcastAddress; + queueRead(cmdReadId(destination), key, + IcomCivScheduler::Priority::Maintenance); + pumpCiv(nowMs()); + const std::uint64_t generation = m_sessionGeneration; + QTimer::singleShot(kConnectIdentityTimeoutMs, this, [this, generation] { + if (!m_connectIdentityPending || generation != m_sessionGeneration) { + return; + } + m_connectIdentityPending = false; + if (!m_civAddressPinned && !m_connectDirectedFallbackAttempted) { + m_connectDirectedFallbackAttempted = true; + qCInfo(lcIcomAddr) + << "broadcast identity probe timed out; trying the selected address"; + queueConnectIdentityProbe("identity.connect-probe-directed", + /*directed=*/true); + return; + } + if (!m_connectWakeAttempted) { + const qint64 nowUtcMs = QDateTime::currentMSecsSinceEpoch(); + const bool wakeRecently = m_lastConnectWakeUtcMs > 0 + && nowUtcMs - m_lastConnectWakeUtcMs < kConnectWakeCooldownMs; + const bool wakeLimitReached = wakeRecently && m_connectWakeAttempts >= 2; + const ConnectPowerAction action = connectPowerAction( + m_modelByName, ConnectIdentityResult::TimedOut, + m_connectSessionRetryAttempted, wakeLimitReached); + if (action == ConnectPowerAction::RetrySession) { + m_connectSessionRetryAttempted = true; + m_lastConnectSessionRetryUtcMs = nowUtcMs; + emit connectionProgress(QStringLiteral("Connecting to radio…")); + qCInfo(lcIcomLink) + << "initial IC-9700 identity timed out; retrying one fresh session before wake"; + m_session->stop(); + onSessionDisconnected(QString{}); + } else if (action == ConnectPowerAction::Wake) { + qCInfo(lcIcomLink) + << "explicit IC-9700 connect has no CI-V payload; sending bounded wake"; + wakeForConnect(); + } else if (action == ConnectPowerAction::Stop) { + failConnectReadiness(QStringLiteral( + "The radio did not answer the CI-V identity probe; " + "wake-on-connect is unavailable or its retry limit was reached.")); + } + } + }); +} + +void IcomCivBackend::failConnectReadiness(const QString& reason) +{ + m_connectIdentityPending = false; + m_connectReadinessPending = false; + if (m_session) { + m_session->stop(); + } + onSessionDisconnected(reason); +} + +void IcomCivBackend::wakeForConnect() +{ + if (!m_session || !m_modelByName) { + return; + } + const std::optional powerOn = + profileFor(*m_modelByName).powerOn; + if (!powerOn) { + return; + } + + m_connectWakeAttempted = true; + ++m_connectWakeAttempts; + m_lastConnectWakeUtcMs = QDateTime::currentMSecsSinceEpoch(); + m_postWakeStallReconnectIssued = false; + m_connectIdentityPending = false; + terminateScheduler(IcomCivScheduler::TerminalOutcome::Cancelled, + SchedulerWaiterOutcome::Cancelled); + + std::vector frame = cmdPowerOn( + m_session->civAddress(), powerOn->extraPreambleBytes, + powerOn->controllerAddress); + traceCiv(/*outbound=*/true, frame); + m_session->sendCiv(frame); + emit connectionProgress(m_connectWakeAttempts == 1 + ? QStringLiteral("Waking the radio… Connection will continue automatically.") + : QStringLiteral("The radio is still starting… Sending one final wake request.")); + + const std::uint64_t generation = m_sessionGeneration; + QTimer::singleShot(powerOn->readyDelayMs, this, [this, generation] { + if (!m_session || !m_connected || generation != m_sessionGeneration) { + return; + } + // A cold standby pipe does not become usable after 18 01 even though + // the radio itself wakes. Hardware proof on the IC-9700 showed that a + // fresh RS-BA1 session is required. End this otherwise healthy-looking + // session without an error: RadioModel treats the edge as an expected + // recoverable disconnect and reconnects automatically. + qCInfo(lcIcomLink) + << "IC-9700 wake delay complete; opening a fresh RS-BA1 session"; + m_session->stop(); + onSessionDisconnected(QString{}); + }); +} + int IcomCivBackend::queueMemorySnapshot(const MemoryProfile& profile, int selectedGroup) { if (!m_session || !m_model) { @@ -1488,8 +1615,13 @@ void IcomCivBackend::onSessionConnected(const QString& deviceName) m_civSeedAddress = m_modelByName->civAddress; } - // ONE BROADCAST 0x19 0x00 — the actual auto-detect, and the only frame this - // change adds to the connect edge. + // IDENTITY BEFORE POWER. On a hardware-verified IC-9700, an authenticated + // RS-BA1 session survives network standby but its CI-V pipe has two observed + // shapes: an existing pipe rejects 19 00 with FA, while a cold-start pipe + // carries no payload at all. This function is reached only after the + // operator deliberately requested Connect, so either standby shape admits + // one IC-9700 wake. An ordinary awake identity continues without touching + // power; every unverified model fails closed. // // CI-V is addressed, so a directed query can only ever confirm an address we // already believe; asked at 0x00 the radio answers with the address it @@ -1503,7 +1635,12 @@ void IcomCivBackend::onSessionConnected(const QString& deviceName) // // SENT ONCE PER CONNECT. Never polled, never retried on a timer — see the // bounded wait below and RFC #4983. - if (!m_civAddressPinned) { + const bool conditionalWake = m_modelByName + && profileFor(*m_modelByName).powerOn.has_value(); + m_connectReadinessPending = conditionalWake; + if (conditionalWake) { + queueConnectIdentityProbe("identity.connect-probe"); + } else if (!m_civAddressPinned) { queueRead(cmdReadId(kBroadcastAddress), "identity.broadcast", IcomCivScheduler::Priority::Maintenance); } @@ -1516,9 +1653,9 @@ void IcomCivBackend::onSessionConnected(const QString& deviceName) // correction path. Only a radio whose name we do not recognise waits, and // only for as long as it takes to learn where to send the snapshot; sending // it to a guessed address first would be twenty frames to nobody. - if (m_civAddressPinned || m_modelByName) { + if (!conditionalWake && (m_civAddressPinned || m_modelByName)) { sendConnectReadBurst(); - } else { + } else if (!conditionalWake) { m_civDetectTimer = new QTimer(this); m_civDetectTimer->setSingleShot(true); connect(m_civDetectTimer, &QTimer::timeout, this, [this] { @@ -1535,7 +1672,23 @@ void IcomCivBackend::onSessionConnected(const QString& deviceName) }); m_civDetectTimer->start(kCivDetectTimeoutMs); } - applyScopeStartup(); + if (!conditionalWake) { + applyScopeStartup(); + } + + if (conditionalWake) { + return; + } + + publishConnectedSession(); +} + +void IcomCivBackend::publishConnectedSession() +{ + if (m_connectionPublished) { + return; + } + m_connectionPublished = true; // CONNECTED FIRST, then the state. // @@ -1668,11 +1821,15 @@ void IcomCivBackend::onSessionDisconnected(const QString& reason) m_tuneTimer->stop(); m_tuning = false; m_preTuneTxPowerPercent = -1; - const bool was = m_connected; + const bool was = m_connectionPublished; + const bool wasConnecting = m_connectReadinessPending; if (was && !reason.isEmpty()) { recordIncident(QStringLiteral("session-disconnected"), reason); } m_connected = false; + m_connectionPublished = false; + m_connectIdentityPending = false; + m_connectReadinessPending = false; ++m_sessionGeneration; if (m_session) { disconnect(m_session.get(), nullptr, this, nullptr); @@ -1705,7 +1862,7 @@ void IcomCivBackend::onSessionDisconnected(const QString& reason) emit sliceChanged(sliceId(), d); } - if (was) + if (was || wasConnecting) emit disconnected(); if (!reason.isEmpty()) emit connectionError(reason); @@ -1835,10 +1992,12 @@ void IcomCivBackend::onCivFrame(const CivFrame& frame, m_scopeCentreHz = sweep->centreHz(); m_scopeSpanHz = sweep->bandwidthHz() / 2; } - emit panCenterBandwidthChanged(panId(), - static_cast(sweep->centreHz()) / 1e6, - static_cast(sweep->bandwidthHz()) / 1e6); - emit spectrumFrameReady(0, floatBytes(toDbm(*sweep, geom, m_scopeCal))); + if (!m_connectReadinessPending) { + emit panCenterBandwidthChanged(panId(), + static_cast(sweep->centreHz()) / 1e6, + static_cast(sweep->bandwidthHz()) / 1e6); + emit spectrumFrameReady(0, floatBytes(toDbm(*sweep, geom, m_scopeCal))); + } return; } @@ -1868,8 +2027,32 @@ void IcomCivBackend::onCivFrame(const CivFrame& frame, if (frame.cmd == cmd::kReadId && frame.from == kControllerAddress) return; + const IcomCivScheduler::Stats schedulerBefore = m_civScheduler.stats(); + const bool connectIdentityTransaction = m_connectIdentityPending + && (schedulerBefore.inFlightKey == "identity.connect-probe" + || schedulerBefore.inFlightKey == "identity.connect-probe-directed"); const IcomCivScheduler::Observation observation = m_civScheduler.observe(frame, frameAtMs); + if (connectIdentityTransaction && frame.isNg()) { + const qint64 nowUtcMs = QDateTime::currentMSecsSinceEpoch(); + const bool wakeRecently = m_lastConnectWakeUtcMs > 0 + && nowUtcMs - m_lastConnectWakeUtcMs < kConnectWakeCooldownMs; + const bool wakeLimitReached = wakeRecently && m_connectWakeAttempts >= 2; + const ConnectPowerAction action = connectPowerAction( + m_modelByName, ConnectIdentityResult::Rejected, + m_connectSessionRetryAttempted, wakeLimitReached); + m_connectIdentityPending = false; + if (action == ConnectPowerAction::Wake) { + qCInfo(lcIcomLink) + << "IC-9700 rejected the connect identity probe; sending bounded wake"; + wakeForConnect(); + } else if (action == ConnectPowerAction::Stop) { + failConnectReadiness(QStringLiteral( + "The radio rejected the CI-V identity probe; " + "wake-on-connect is unavailable or its retry limit was reached.")); + } + return; + } if (recoveryFrequencyCandidate && observation != IcomCivScheduler::Observation::Stale) { // CI-V has no transaction identifier. The strongest correlation the @@ -1908,6 +2091,13 @@ void IcomCivBackend::onCivFrame(const CivFrame& frame, switch (frame.cmd) { case cmd::kReadId: { if (auto addr = parseModelIdReply(frame)) { + const bool completedReadiness = m_connectReadinessPending; + m_connectReadinessPending = false; + m_connectSessionRetryAttempted = false; + m_lastConnectSessionRetryUtcMs = 0; + m_connectWakeAttempts = 0; + const bool completesConnectProbe = m_connectIdentityPending; + m_connectIdentityPending = false; // THE ADDRESS ARRIVES TWICE — in the frame's `from` byte and in the // payload — and they agreed on every measured run. Prefer the // payload, because that is what the command is defined to answer, @@ -1921,6 +2111,10 @@ void IcomCivBackend::onCivFrame(const CivFrame& frame, << "- using the payload"; } adoptReportedCivAddress(*addr); + if (completedReadiness) { + publishConnectedSession(); + emit connectionProgress(QString{}); + } // AMBIGUOUS BUS: two devices answered with different addresses, so // neither one's identity can be trusted either. Leave m_model where // the name put it. @@ -1969,6 +2163,17 @@ void IcomCivBackend::onCivFrame(const CivFrame& frame, applyScopeStartup(); } publishIdentity(); + if ((completesConnectProbe || completedReadiness) + && !m_connectBurstSent) { + sendConnectReadBurst(); + applyScopeStartup(); + if (m_meterTimer) { + m_meterTimer->start(kMeterTickMs); + } + if (m_linkTimer) { + m_linkTimer->start(kLinkTickMs); + } + } } pumpCiv(frameAtMs); return; @@ -3361,19 +3566,24 @@ void IcomCivBackend::pumpCiv(qint64 nowMs) const bool routineDispatch = dispatch->priority == IcomCivScheduler::Priority::Ptt || dispatch->priority == IcomCivScheduler::Priority::ActiveMeter; traceCiv(/*outbound=*/true, dispatch->frame, routineDispatch); + const std::optional parsedDispatch = parseFrame(dispatch->frame); if (dispatch->supersedes) { - if (dispatch->frame.size() > 5) { - noteControlSent(dispatch->frame[4], dispatch->frame[5], true); - } else if (dispatch->frame.size() > 4) { - noteControlSent(dispatch->frame[4], 0, false); + if (parsedDispatch) { + noteControlSent(parsedDispatch->cmd, parsedDispatch->sub, + parsedDispatch->hasSub); } } - if (dispatch->frame.size() > 4) { + if (parsedDispatch) { QString hex; - for (std::size_t i = 4; i + 1 < dispatch->frame.size(); ++i) { - hex += QStringLiteral("%1 ").arg(dispatch->frame[i], 2, 16, QLatin1Char('0')); + hex += QStringLiteral("%1").arg(parsedDispatch->cmd, 2, 16, QLatin1Char('0')); + if (parsedDispatch->hasSub) { + hex += QStringLiteral(" %1").arg(parsedDispatch->sub, 2, 16, + QLatin1Char('0')); } - m_lastOutboundCiv = hex.trimmed(); + for (const std::uint8_t byte : parsedDispatch->data) { + hex += QStringLiteral(" %1").arg(byte, 2, 16, QLatin1Char('0')); + } + m_lastOutboundCiv = hex; m_lastOutboundCivKey = QString::fromStdString(dispatch->key); m_lastOutboundCivAtMs = nowMs; } @@ -3587,7 +3797,7 @@ void IcomCivBackend::terminateScheduler( void IcomCivBackend::sendUserCommand(const std::vector& frame) { - if (!m_session || !m_connected) + if (!m_session || !m_connectionPublished) return; const qint64 now = nowMs(); const std::string key = semanticKey(frame); @@ -5520,10 +5730,10 @@ void IcomCivBackend::traceCiv(bool outbound, std::span frame // reply this whole category was added to make visible, is four bytes // and came out undecorated. Exactly the wrong-but-plausible output the // comment below warns about, in the direction that was not checked. - const int cmdIdx = outbound ? 4 : 0; QString tag; - if (frame.size() > static_cast(cmdIdx)) { - const std::uint8_t c = frame[cmdIdx]; + const std::optional parsed = outbound ? parseFrame(frame) : std::nullopt; + if (parsed || (!outbound && !frame.empty())) { + const std::uint8_t c = outbound ? parsed->cmd : frame[0]; tag = QStringLiteral(" cmd=%1").arg(c, 2, 16, QLatin1Char('0')); // Which commands carry a subcommand is a per-command fact, and // commandHasSubcommand() is the single list parseFrame() decodes @@ -5531,10 +5741,12 @@ void IcomCivBackend::traceCiv(bool outbound, std::span frame // drift would label command 0x05's first frequency digit as a // subcommand — the wrong-but-plausible output this tag exists to // avoid. - if (frame.size() > static_cast(cmdIdx) + 1 - && commandHasSubcommand(c)) { + const bool hasSub = outbound ? parsed->hasSub + : frame.size() > 1 && commandHasSubcommand(c); + if (hasSub) { + const std::uint8_t sub = outbound ? parsed->sub : frame[1]; tag += QStringLiteral(" sub=%1") - .arg(frame[cmdIdx + 1], 2, 16, QLatin1Char('0')); + .arg(sub, 2, 16, QLatin1Char('0')); } } qCDebug(lcIcomCiv).noquote().nospace() @@ -5774,6 +5986,115 @@ void IcomCivBackend::invokeExtension(const QString& ns, const QString& verb, qui emit extensionResult(requestId, result); return; } + if (verb == QLatin1String("power.standby") + || verb == QLatin1String("power.wake") + || verb == QLatin1String("power.probe") + || verb == QLatin1String("power.setting")) { + if (!m_session || !m_connectionPublished || !m_model) { + emit extensionError(requestId, QStringLiteral("not connected")); + return; + } + const bool wake = verb == QLatin1String("power.wake"); + const bool probe = verb == QLatin1String("power.probe"); + const bool setting = verb == QLatin1String("power.setting"); + if (setting) { + if (m_model->civAddress != 0xA2) { + emit extensionError(requestId, + QStringLiteral("remote power setting read is verified only for IC-9700")); + return; + } + const std::vector frame = + cmdReadSetting(m_session->civAddress(), 146); + sendUserCommand(frame); + emit extensionResult( + requestId, + QVariantMap{{QStringLiteral("sent"), true}, + {QStringLiteral("setting"), QStringLiteral("1a 05 01 46")}}); + return; + } + if (probe) { + if (!m_powerTestQuiesced) { + if (m_meterTimer) { + m_meterTimer->stop(); + } + if (m_linkTimer) { + m_linkTimer->stop(); + } + m_powerTestQuiesced = true; + } + // Each diagnostic probe is a fresh observation. The preceding + // unanswered probe may still be the scheduler's in-flight read + // because polling is intentionally stopped and nothing else would + // advance its timeout. + terminateScheduler(IcomCivScheduler::TerminalOutcome::Cancelled, + SchedulerWaiterOutcome::Cancelled); + const std::vector frame = + cmdReadId(m_session->civAddress()); + queueRead(frame, "power.probe", + IcomCivScheduler::Priority::Maintenance); + pumpCiv(nowMs()); + emit extensionResult( + requestId, + QVariantMap{{QStringLiteral("sent"), true}, + {QStringLiteral("state"), QStringLiteral("probe")}, + {QStringLiteral("bytes"), static_cast(frame.size())}}); + return; + } + std::vector frame; + const std::optional powerProfile = + profileFor(*m_model).powerOn; + if (!powerProfile) { + emit extensionError( + requestId, + QStringLiteral("network power control is not hardware-verified for %1") + .arg(QString::fromLatin1(m_model->name))); + return; + } + if (wake) { + frame = cmdPowerOn(m_session->civAddress(), + powerProfile->extraPreambleBytes, + powerProfile->controllerAddress); + } else { + frame = cmdPowerOff(m_session->civAddress()); + } + if (m_meterTimer) { + m_meterTimer->stop(); + } + if (m_linkTimer) { + m_linkTimer->stop(); + } + terminateScheduler(IcomCivScheduler::TerminalOutcome::Cancelled, + SchedulerWaiterOutcome::Cancelled); + m_powerTestQuiesced = true; + traceCiv(/*outbound=*/true, frame); + m_session->sendCiv(frame); + if (wake) { + QTimer::singleShot(powerProfile->readyDelayMs, this, [this] { + if (!m_powerTestQuiesced || !m_session || !m_connected) { + return; + } + m_powerTestQuiesced = false; + if (m_meterTimer) { + m_meterTimer->start(kMeterTickMs); + } + if (m_linkTimer) { + m_linkTimer->start(kLinkTickMs); + } + pumpCiv(nowMs()); + }); + } + emit extensionResult( + requestId, + QVariantMap{{QStringLiteral("sent"), true}, + {QStringLiteral("state"), wake ? QStringLiteral("wake") + : QStringLiteral("standby")}, + {QStringLiteral("bytes"), static_cast(frame.size())}, + {QStringLiteral("controller"), QStringLiteral("e1")}, + {QStringLiteral("pollingQuiesced"), true}, + {QStringLiteral("resumeDelayMs"), + wake ? powerProfile->readyDelayMs : 0}}); + return; + } if (verb == QLatin1String("civ.send")) { // RAW INJECTION. The caller supplies the command bytes ONLY — the // preamble, addresses and terminator are ours. That is not politeness: @@ -6033,6 +6354,20 @@ void IcomCivBackend::onLinkTick() } qCWarning(lcIcomLink) << "CI-V data restarts produced no command reply; reconnecting session"; + const qint64 nowUtcMs = QDateTime::currentMSecsSinceEpoch(); + const bool wakeRecently = m_lastConnectWakeUtcMs > 0 + && nowUtcMs - m_lastConnectWakeUtcMs < kConnectWakeCooldownMs; + if (postWakeStallAction(wakeRecently, m_postWakeStallReconnectIssued) + == PostWakeStallAction::ReconnectQuietly) { + m_postWakeStallReconnectIssued = true; + qCInfo(lcIcomLink) + << "IC-9700 boot interrupted the first post-wake CI-V session; " + "reconnecting once without reporting a fault"; + emit connectionProgress(QStringLiteral( + "The radio is finishing startup… Reconnecting automatically.")); + disconnectRadio(); + return; + } const QString reason = QStringLiteral( "Icom CI-V stream stopped responding; reconnecting the radio session"); disconnectRadio(); diff --git a/src/core/backends/icom/IcomCivBackend.h b/src/core/backends/icom/IcomCivBackend.h index 74b6e648a..4b9a8140e 100644 --- a/src/core/backends/icom/IcomCivBackend.h +++ b/src/core/backends/icom/IcomCivBackend.h @@ -166,6 +166,7 @@ class IcomCivBackend : public IRadioBackend { private slots: void onSessionConnected(const QString& deviceName); + void publishConnectedSession(); void onSessionDisconnected(const QString& reason); void onCivFrame(const AetherSDR::icom::CivFrame& frame, std::uint64_t sessionGeneration); @@ -304,6 +305,9 @@ private slots: // bunching as a suspected cause of an unrecoverable CI-V stall; restructuring // it belongs to that scheduler work, not here. void sendConnectReadBurst(); + void queueConnectIdentityProbe(std::string key, bool directed = false); + void wakeForConnect(); + void failConnectReadiness(const QString& reason); int queueMemorySnapshot(const MemoryProfile& profile, int selectedGroup); void finishMemoryRefresh(bool success); void finishMemoryRefreshWhenDrained(quint64 generation); @@ -337,6 +341,25 @@ private slots: bool m_civAmbiguous = false; // Whether sendConnectReadBurst() has already run this session. bool m_connectBurstSent = false; + // IC-9700 network wake is conditional: only failed broadcast and selected- + // address identity reads admit 18 01, and each fresh RS-BA1 session gets at + // most one write. + bool m_connectIdentityPending = false; + bool m_connectDirectedFallbackAttempted = false; + // True from the first transport-connected edge of a wake-capable radio + // until its CI-V identity reply. Scope packets can arrive before + // that proof; do not expose a half-ready panadapter to the operator. + bool m_connectReadinessPending = false; + bool m_connectionPublished = false; + bool m_connectWakeAttempted = false; + bool m_connectSessionRetryAttempted = false; + qint64 m_lastConnectSessionRetryUtcMs = 0; + // Survives backend reconnects so the hardware-proven two-session sequence + // cannot become an unbounded reconnect loop. + qint64 m_lastConnectWakeUtcMs = 0; + int m_connectWakeAttempts = 0; + bool m_postWakeStallReconnectIssued = false; + static constexpr int kConnectWakeCooldownMs = 300000; bool m_memoryRefreshActive = false; quint64 m_memoryRefreshGeneration = 0; QSet m_memoryRefreshReplies; @@ -350,6 +373,7 @@ private slots: // address, so a radio that answers nothing still connects. QTimer* m_civDetectTimer = nullptr; static constexpr int kCivDetectTimeoutMs = 1000; + static constexpr int kConnectIdentityTimeoutMs = 3000; // applyScopeStartup() now has two callers — the connect edge and a late // model resolution — and the radio only needs telling once. bool m_scopeStarted = false; @@ -392,6 +416,10 @@ private slots: QTimer* m_meterTimer = nullptr; QTimer* m_linkTimer = nullptr; QTimer* m_tuneTimer = nullptr; + // Explicit hardware-test state. Power-off quiesces every CI-V producer so + // the response-free command is not followed by traffic that keeps the + // radio awake. Ordinary disconnect never enters this state. + bool m_powerTestQuiesced = false; QString m_deviceName; std::uint64_t m_frequencyHz = 0; diff --git a/src/core/backends/icom/IcomConnectBootstrap.h b/src/core/backends/icom/IcomConnectBootstrap.h new file mode 100644 index 000000000..18077552d --- /dev/null +++ b/src/core/backends/icom/IcomConnectBootstrap.h @@ -0,0 +1,71 @@ +#pragma once + +#include "core/backends/icom/CivCodec.h" +#include "core/backends/icom/IcomModels.h" + +#include +#include + +namespace AetherSDR::icom { + +enum class ConnectIdentityResult : std::uint8_t { + Identified, + Rejected, + TimedOut, +}; + +enum class ConnectPowerAction : std::uint8_t { + Continue, + RetrySession, + Wake, + Stop, +}; + +enum class PostWakeStallAction : std::uint8_t { + ReportError, + ReconnectQuietly, +}; + +// connectRadio() is reached only after the operator selected a radio, supplied +// its credentials and requested Connect. For a hardware-verified model that is +// explicit permission to wake: both FA and silence are observed IC-9700 +// standby shapes. Presence of powerOn still means this exact model/transport +// pair survived the hardware sequence; command-table similarity is not +// evidence. The caller supplies whether the hardware-bounded wake budget has +// been exhausted; IC-9700 cold RS-BA1 recovery needs at most two fresh-session +// attempts on the tested radio. +[[nodiscard]] inline ConnectPowerAction connectPowerAction( + const IcomModel* model, ConnectIdentityResult result, + bool sessionRetryAttempted, bool wakeBudgetExhausted) +{ + if (result == ConnectIdentityResult::Identified) { + return ConnectPowerAction::Continue; + } + if (!model || !profileFor(*model).powerOn) { + return ConnectPowerAction::Stop; + } + // A newly opened RS-BA1 CI-V pipe can be slow even while the radio is fully + // awake. Preserve the original connect path by proving silence on one fresh + // session before interpreting a timeout as standby. An explicit FA is a + // real negative reply, not startup latency, and may proceed directly. + if (result == ConnectIdentityResult::TimedOut && !sessionRetryAttempted) { + return ConnectPowerAction::RetrySession; + } + if (!wakeBudgetExhausted) { + return ConnectPowerAction::Wake; + } + return ConnectPowerAction::Stop; +} + +// The IC-9700 can answer during early boot and then briefly lose CI-V while +// the RS-BA1 transport stays authenticated. One silent fresh session is part +// of wake convergence; a second stall is a real fault and must remain visible. +[[nodiscard]] inline PostWakeStallAction postWakeStallAction( + bool wakeRecently, bool quietReconnectIssued) +{ + return wakeRecently && !quietReconnectIssued + ? PostWakeStallAction::ReconnectQuietly + : PostWakeStallAction::ReportError; +} + +} // namespace AetherSDR::icom diff --git a/src/core/backends/icom/IcomModels.cpp b/src/core/backends/icom/IcomModels.cpp index 14171ac68..7cacfc77f 100644 --- a/src/core/backends/icom/IcomModels.cpp +++ b/src/core/backends/icom/IcomModels.cpp @@ -628,6 +628,10 @@ const IcomModelProfile& profileFor(const IcomModel& model) noexcept .powerConversion = MeterCalibrationProfile::PowerConversion::RelativePercentOfBandRating, .hasPaCurrentTelemetry = true, }, + // IC-9700 CI-V Reference Guide 2019, command 18 01: at 115200 baud send + // 150 FE bytes BEFORE the standard CI-V frame. The standard frame then + // contributes its own two-byte FE FE preamble (152 leading FE total). + .powerOn = PowerOnProfile{150, 0xE1, 10000}, .civRecovery = CivRecoveryProfile{1000, 3}, .memory = MemoryProfile{MemoryDialect::Ic9700, 1, 3, 1, 99, false, "Band"}, // IC-9700 CI-V Reference Guide 2019, printed p. 8. diff --git a/src/core/backends/icom/IcomModels.h b/src/core/backends/icom/IcomModels.h index 61f259fb2..5a13328bb 100644 --- a/src/core/backends/icom/IcomModels.h +++ b/src/core/backends/icom/IcomModels.h @@ -438,6 +438,16 @@ struct CivRecoveryProfile { int maxAttempts = 3; }; +// Presence means this model's wake framing has been established for the +// network transport this backend actually uses. Command-table similarity alone +// is not enough: serial CI-V guides can require baud-dependent FE fill that an +// RS-BA1 session does not expose. +struct PowerOnProfile { + std::size_t extraPreambleBytes = 0; + std::uint8_t controllerAddress = kControllerAddress; + int readyDelayMs = 10000; +}; + // Model-owned 1A 05 register addresses for radio-authoritative network state. // These differ across Icom command tables and are absent from the IC-705 guide. struct NetworkConfigurationProfile { @@ -469,6 +479,7 @@ struct IcomModelProfile { SetMenuProfile setMenu; ScopeCommandProfile scope; MeterCalibrationProfile meters; + std::optional powerOn; std::optional civRecovery; std::optional memory; std::optional networkConfiguration; diff --git a/src/gui/MainWindow.cpp b/src/gui/MainWindow.cpp index 60725f9d6..b0c24fe5c 100644 --- a/src/gui/MainWindow.cpp +++ b/src/gui/MainWindow.cpp @@ -6028,13 +6028,24 @@ void MainWindow::onConnectionStateChanged(bool connected) // Auto-hide the connection dialog on successful connect m_connPanel->hide(); - // Close reconnect dialog if it was showing - if (m_reconnectDlg) { + // A transport connection is not the end of an in-progress radio wake: + // RS-BA1 can authenticate and even deliver scope frames before CI-V + // answers. Keep the wake dialog until the backend supplies its explicit + // protocol-readiness completion update. + if (m_reconnectDlg && !m_radioWakeInProgress) { QDialog* reconnectDialog = m_reconnectDlg; m_reconnectDlg = nullptr; reconnectDialog->close(); reconnectDialog->deleteLater(); } + if (m_radioWakeInProgress) { + const QString progress = m_panadapterConnectionAnimationLabel.isEmpty() + ? tr("Connecting to radio…") + : m_panadapterConnectionAnimationLabel; + m_connStatusLabel->setText(tr("Connecting")); + m_connPanel->setStatusText(progress); + setPanadapterConnectionAnimation(true, progress); + } // Load band stack bookmarks for this radio BandStackSettings::instance().load(); @@ -6366,16 +6377,23 @@ void MainWindow::onConnectionStateChanged(bool connected) } } + const QString recoveryLabel = m_radioWakeInProgress + && !m_panadapterConnectionAnimationLabel.isEmpty() + ? m_panadapterConnectionAnimationLabel + : tr("Reconnecting to radio…"); setPanadapterConnectionAnimation( !m_userDisconnected && !terminalConnectionFailure, - "Reconnecting to radio…"); + recoveryLabel); if (terminalConnectionFailure) { showConnectionDialog(); } - // Show reconnect dialog on unexpected disconnect (only one at a time) - if (!m_userDisconnected && !m_reconnectDlg) { + // A wake-capable radio deliberately recycles its authenticated transport + // while booting. The panadapter wake overlay already explains that + // bounded operation, so do not stack a flashing disconnect dialog over + // it. Ordinary unexpected disconnects retain the existing dialog. + if (!m_userDisconnected && !m_reconnectDlg && !m_radioWakeInProgress) { const bool frameless = framelessWindowEnabled(); m_reconnectDlg = new QDialog(this); m_reconnectDlg->setWindowTitle(tr("Radio Disconnected")); @@ -6395,7 +6413,8 @@ void MainWindow::onConnectionStateChanged(bool connected) root->setContentsMargins(0, 0, 0, 0); root->setSpacing(0); - auto* titleBar = new FramelessWindowTitleBar(tr("Radio Disconnected"), m_reconnectDlg); + auto* titleBar = new FramelessWindowTitleBar( + tr("Radio Disconnected"), m_reconnectDlg); titleBar->setObjectName(QStringLiteral("framelessWindowTitleBar")); titleBar->setVisible(frameless); root->addWidget(titleBar); @@ -6412,7 +6431,8 @@ void MainWindow::onConnectionStateChanged(bool connected) title->setAlignment(Qt::AlignCenter); layout->addWidget(title); - auto* body = new QLabel(tr("AetherSDR is attempting to reconnect automatically."), content); + auto* body = new QLabel( + tr("AetherSDR is attempting to reconnect automatically."), content); body->setObjectName(QStringLiteral("reconnectBody")); body->setAlignment(Qt::AlignCenter); body->setWordWrap(true); @@ -6455,11 +6475,34 @@ void MainWindow::onConnectionError(const QString& msg) // maybeAutoConnectToDiscoveredRadio(). Done here rather than in that slot // because this is the only place a connect is known to have ended badly. noteAutoConnectFinished(false); + + // A wake failure is terminal for the bounded connect attempt. Clear its + // overlay and return to the connection panel without briefly presenting + // the ordinary unexpected-disconnect dialog underneath it. + if (m_radioWakeInProgress) { + m_radioWakeInProgress = false; + ++m_radioWakeGeneration; + if (m_reconnectDlg) { + QDialog* wakeDialog = m_reconnectDlg; + m_reconnectDlg = nullptr; + wakeDialog->close(); + wakeDialog->deleteLater(); + } + const QString errorText = tr("Error: %1").arg(msg); + m_connPanel->setStatusText(errorText); + m_connStatusLabel->setText(tr("Error")); + statusBar()->showMessage(tr("Connection error: %1").arg(msg), 5000); + setPanadapterConnectionAnimation(false); + showConnectionDialog(); + return; + } + m_connPanel->setStatusText("Error: " + msg); m_connStatusLabel->setText("Error"); statusBar()->showMessage("Connection error: " + msg, 5000); - if (!m_reconnectDlg) + if (!m_reconnectDlg) { setPanadapterConnectionAnimation(false); + } } void MainWindow::onWanCertFingerprintMismatch(const QString& host, diff --git a/src/gui/MainWindow.h b/src/gui/MainWindow.h index c182378d9..bf797b8d5 100644 --- a/src/gui/MainWindow.h +++ b/src/gui/MainWindow.h @@ -1563,6 +1563,13 @@ private slots: float m_lastPaTempC{0.0f}; bool m_userDisconnected{false}; // true after explicit disconnect, blocks auto-connect bool m_commandDroppedNoticeShown{false}; // one status-bar notice per connect session (M0, #5263) + // A backend may deliberately recycle its transport while bringing a radio + // out of standby. Keep that bounded recovery distinct from an unexpected + // disconnect so the UI can explain what is happening instead of alarming + // the operator for each required session reset. + bool m_radioWakeInProgress{false}; + int m_radioWakeGeneration{0}; + static constexpr int kRadioWakeWatchdogMs = 75000; // Auto-reconnect bookkeeping — see maybeAutoConnectToDiscoveredRadio(). // // The slot is driven by radioUpdated as well as radioDiscovered, and diff --git a/src/gui/MainWindow_Session.cpp b/src/gui/MainWindow_Session.cpp index af81d758a..2b802196a 100644 --- a/src/gui/MainWindow_Session.cpp +++ b/src/gui/MainWindow_Session.cpp @@ -78,6 +78,7 @@ #include #include #include + #include #include #include @@ -365,6 +366,8 @@ void MainWindow::wireDiscovery() connect(m_connPanel, &ConnectionPanel::disconnectRequested, this, [this]{ m_userDisconnected = true; + m_radioWakeInProgress = false; + ++m_radioWakeGeneration; m_wanReconnectTimer.stop(); m_wanReconnectAttemptInProgress = false; setPanadapterConnectionAnimation(false); @@ -811,6 +814,69 @@ void MainWindow::wireRadioModel() connect(&m_radioModel, &RadioModel::connectionError, this, &MainWindow::onConnectionError); + connect(&m_radioModel, &RadioModel::connectionProgress, + this, [this](const QString& message) { + // An empty update is the backend's positive, protocol-level readiness + // proof. A transport-connected edge is insufficient: the IC-9700 can + // authenticate RS-BA1 while its CI-V command plane is still asleep. + if (message.isEmpty()) { + if (m_radioWakeInProgress) { + m_radioWakeInProgress = false; + ++m_radioWakeGeneration; + if (m_reconnectDlg) { + QDialog* wakeDialog = m_reconnectDlg; + m_reconnectDlg = nullptr; + wakeDialog->close(); + wakeDialog->deleteLater(); + } + m_connStatusLabel->setText(tr("Connected")); + m_connPanel->setStatusText(tr("Connected")); + m_connPanel->hide(); + setPanadapterConnectionAnimation(false); + statusBar()->showMessage(tr("Radio connected."), 5000); + } + return; + } + + const bool startingWake = !m_radioWakeInProgress; + m_radioWakeInProgress = true; + const int generation = startingWake ? ++m_radioWakeGeneration + : m_radioWakeGeneration; + statusBar()->showMessage(message, kRadioWakeWatchdogMs); + m_connPanel->setStatusText(message); + setPanadapterConnectionAnimation(true, message); + + if (!startingWake) { + return; + } + + // The complete wake includes authenticated RS-BA1 reconnects, not just + // the response-free CI-V write. Bound the user-visible operation across + // those session generations; a per-session timeout would restart on + // every retry and could leave the UI claiming progress forever. + QTimer::singleShot(kRadioWakeWatchdogMs, this, [this, generation]() { + if (!m_radioWakeInProgress || generation != m_radioWakeGeneration) { + return; + } + m_radioWakeInProgress = false; + ++m_radioWakeGeneration; + m_userDisconnected = true; + m_radioModel.disconnectFromRadio(); + if (m_reconnectDlg) { + QDialog* wakeDialog = m_reconnectDlg; + m_reconnectDlg = nullptr; + wakeDialog->close(); + wakeDialog->deleteLater(); + } + const QString failure = tr( + "The radio did not finish connecting. Check the network and radio settings, then try again."); + m_connPanel->setStatusText(failure); + m_connStatusLabel->setText(tr("Error")); + statusBar()->showMessage(failure, 15000); + setPanadapterConnectionAnimation(false); + showConnectionDialog(); + }); + }); // Radio configuration advice: shown, but it does NOT touch the session. // Deliberately not onConnectionError — see IRadioBackend::configurationWarning. // 15 s rather than the usual 4: this one names a four-level menu path the diff --git a/src/models/RadioModel.cpp b/src/models/RadioModel.cpp index 6356d172e..9957046f2 100644 --- a/src/models/RadioModel.cpp +++ b/src/models/RadioModel.cpp @@ -1554,6 +1554,8 @@ void RadioModel::setupBackend(const QString& family) this, &RadioModel::onDisconnected); connect(m_backend.get(), &IRadioBackend::connectionError, this, &RadioModel::onConnectionError); + connect(m_backend.get(), &IRadioBackend::connectionProgress, + this, &RadioModel::connectionProgress); // 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, diff --git a/src/models/RadioModel.h b/src/models/RadioModel.h index 1bf2cef74..b698eb689 100644 --- a/src/models/RadioModel.h +++ b/src/models/RadioModel.h @@ -1014,6 +1014,9 @@ class RadioModel : public QObject { void rawSliceModeListsChanged(); void metersChanged(); void connectionError(const QString& msg); + // Non-fatal progress while a backend is still completing an explicit + // connection request (for example, waiting for a radio to wake). + void connectionProgress(const QString& msg); // Radio CONFIGURATION advice that does not end the session. See // IRadioBackend::configurationWarning for why this is a separate channel. void configurationWarning(const QString& msg); diff --git a/tests/icom_civ_test.cpp b/tests/icom_civ_test.cpp index 39b459458..46921908a 100644 --- a/tests/icom_civ_test.cpp +++ b/tests/icom_civ_test.cpp @@ -439,6 +439,21 @@ static void testModes() static void testCommands() { + check(bytesAre(cmdPowerOff(kIc705), + {0xFE, 0xFE, kIc705, kControllerAddress, 0x18, 0x00, 0xFD}), + "power-off command is the documented standard frame"); + check(bytesAre(cmdPowerOn(kIc705), + {0xFE, 0xFE, 0xA4, 0xE0, 0x18, 0x01, 0xFD}), + "IC-705 wake is the standard 18 01 frame"); + const std::vector ic9700Wake = cmdPowerOn(0xA2, 150); + const std::optional parsedWake = parseFrame(ic9700Wake); + check(ic9700Wake.size() == 157 + && std::all_of(ic9700Wake.begin(), ic9700Wake.begin() + 152, + [](std::uint8_t byte) { return byte == 0xFE; }) + && parsedWake && parsedWake->to == 0xA2 + && parsedWake->from == kControllerAddress + && parsedWake->cmd == cmd::kPower && parsedWake->sub == 0x01, + "IC-9700 wake carries and parses its documented maximum FE preamble"); check(bytesAre(cmdSetPtt(kIc705, true), {0xFE, 0xFE, 0xA4, 0xE0, 0x1C, 0x00, 0x01, 0xFD}), "PTT on is 1C 00 01"); check(bytesAre(cmdSetTransmitFrequencyCheck(kIc705, true), diff --git a/tests/icom_family_test.cpp b/tests/icom_family_test.cpp index d1874055b..7bae3e2d7 100644 --- a/tests/icom_family_test.cpp +++ b/tests/icom_family_test.cpp @@ -12,6 +12,7 @@ #include "models/RadioModel.h" #include "core/RadioDiscovery.h" #include "core/backends/icom/IcomCivBackend.h" +#include "core/backends/icom/IcomConnectBootstrap.h" #include "core/backends/icom/IcomControls.h" #include "core/backends/icom/IcomModels.h" #include "core/backends/icom/CivCodec.h" @@ -49,6 +50,23 @@ struct IcomCivBackendTestAccess { backend.m_connected = true; backend.onCivFrame(frame, backend.m_sessionGeneration); } + + static void beginUnpublishedReadiness(IcomCivBackend& backend) + { + backend.m_connected = true; + backend.m_connectionPublished = false; + backend.m_connectReadinessPending = true; + } + + static void failConnectReadiness(IcomCivBackend& backend, const QString& reason) + { + backend.failConnectReadiness(reason); + } + + static bool readinessPending(const IcomCivBackend& backend) + { + return backend.m_connectReadinessPending; + } }; } // namespace AetherSDR::icom @@ -226,6 +244,41 @@ int main(int argc, char** argv) } } + // Wake/session bounces happen before connected() is published. They still + // need a disconnect edge so RadioModel opens the promised fresh session; + // terminal Stop paths need an error and must release readiness instead. + { + icom::IcomCivBackend bounceBackend; + int disconnects = 0; + int errors = 0; + QObject::connect(&bounceBackend, &IRadioBackend::disconnected, + [&disconnects] { ++disconnects; }); + QObject::connect(&bounceBackend, &IRadioBackend::connectionError, + [&errors](const QString&) { ++errors; }); + icom::IcomCivBackendTestAccess::beginUnpublishedReadiness(bounceBackend); + check(QMetaObject::invokeMethod( + &bounceBackend, "onSessionDisconnected", Qt::DirectConnection, + Q_ARG(QString, QString{})), + "unpublished wake bounce reaches the backend disconnect handler"); + check(disconnects == 1 && errors == 0, + "unpublished wake bounce emits the reconnect-driving disconnect edge"); + + icom::IcomCivBackend stopBackend; + int stopErrors = 0; + QObject::connect(&stopBackend, &IRadioBackend::connectionError, + [&stopErrors](const QString& reason) { + if (!reason.isEmpty()) { + ++stopErrors; + } + }); + icom::IcomCivBackendTestAccess::beginUnpublishedReadiness(stopBackend); + icom::IcomCivBackendTestAccess::failConnectReadiness( + stopBackend, QStringLiteral("identity stopped")); + check(stopErrors == 1 + && !icom::IcomCivBackendTestAccess::readinessPending(stopBackend), + "terminal identity Stop reports failure and releases readiness"); + } + // The Icom transport reports one stable VFO as slice 0. On reconnect, // RadioModel stages the old SliceModel so the UI can keep its subscriptions // alive while the backend confirms the new session. The non-Flex materializer @@ -367,6 +420,65 @@ int main(int argc, char** argv) "IC-9700 profile does not declare GPS hardware"); check(!icom::profileFor(*icom::modelForCivAddress(0xB6)).hasGpsHardware, "IC-7300MK2 profile does not declare GPS hardware"); + const auto ic705PowerOn = icom::profileFor( + *icom::modelForCivAddress(0xA4)).powerOn; + const auto ic9700PowerOn = icom::profileFor( + *icom::modelForCivAddress(0xA2)).powerOn; + const auto mk2PowerOn = icom::profileFor( + *icom::modelForCivAddress(0xB6)).powerOn; + check(!ic705PowerOn && ic9700PowerOn + && ic9700PowerOn->extraPreambleBytes == 150 + && ic9700PowerOn->controllerAddress == 0xE1 + && ic9700PowerOn->readyDelayMs == 10000 + && !mk2PowerOn, + "only the hardware-verified IC-9700 LAN path declares wake"); + check(!icom::profileFor(icom::unknownModel()).powerOn, + "unverified Icom paths never inherit wake-on-connect from a sibling model"); + check(icom::connectPowerAction( + icom::modelForCivAddress(0xA2), + icom::ConnectIdentityResult::Identified, false, false) + == icom::ConnectPowerAction::Continue, + "an awake IC-9700 connect never sends power-on"); + check(icom::connectPowerAction( + icom::modelForCivAddress(0xA2), + icom::ConnectIdentityResult::Rejected, false, false) + == icom::ConnectPowerAction::Wake, + "an IC-9700 FA identity reply admits one verified wake"); + check(icom::connectPowerAction( + icom::modelForCivAddress(0xA2), + icom::ConnectIdentityResult::TimedOut, false, false) + == icom::ConnectPowerAction::RetrySession, + "initial IC-9700 silence retries a fresh session before wake"); + check(icom::connectPowerAction( + icom::modelForCivAddress(0xA2), + icom::ConnectIdentityResult::TimedOut, true, false) + == icom::ConnectPowerAction::Wake, + "repeated IC-9700 silence admits bounded wake after the session retry"); + check(icom::connectPowerAction( + icom::modelForCivAddress(0xA2), + icom::ConnectIdentityResult::Rejected, true, true) + == icom::ConnectPowerAction::Stop, + "an exhausted IC-9700 wake budget cannot loop power-on"); + check(icom::connectPowerAction( + icom::modelForCivAddress(0xA2), + icom::ConnectIdentityResult::Rejected, true, false) + == icom::ConnectPowerAction::Wake, + "a fresh-session IC-9700 retry remains available inside the bounded budget"); + check(icom::postWakeStallAction(true, false) + == icom::PostWakeStallAction::ReconnectQuietly, + "the first recent post-wake CI-V stall reconnects without a false error"); + check(icom::postWakeStallAction(true, true) + == icom::PostWakeStallAction::ReportError + && icom::postWakeStallAction(false, false) + == icom::PostWakeStallAction::ReportError, + "only one stall is absorbed by a recent hardware wake"); + for (const std::uint8_t address : {std::uint8_t{0xA4}, std::uint8_t{0xB6}}) { + check(icom::connectPowerAction( + icom::modelForCivAddress(address), + icom::ConnectIdentityResult::Rejected, false, false) + == icom::ConnectPowerAction::Stop, + "unverified Icom models fail closed on rejected identity"); + } const auto ic9700Network = icom::profileFor( *icom::modelForCivAddress(0xA2)).networkConfiguration; const auto ic705Network = icom::profileFor(