diff --git a/src/core/PanadapterStream.cpp b/src/core/PanadapterStream.cpp index 75b4ab25b..759118667 100644 --- a/src/core/PanadapterStream.cpp +++ b/src/core/PanadapterStream.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -465,6 +466,10 @@ void PanadapterStream::clearRegisteredStreams() m_orphanStreams.clear(); m_everRegisteredPanStreams.clear(); // new session — re-arm from scratch (#3856) m_everRegisteredWfStreams.clear(); + // The external-source suppression mask is session state too — a bit left + // set across a disconnect would silently mute that DAX channel on the + // next radio connection. (feat/kiwi-audio-to-dax) + m_externalDaxSourceMask.store(0, std::memory_order_relaxed); resetAudioStreamStats(); qCDebug(lcVita49) << "PanadapterStream: cleared all registered streams"; } @@ -776,6 +781,14 @@ void PanadapterStream::processDatagram(const QByteArray& data) if (daxChannel >= 0) { int channel = daxChannel; + // An external source (injectDaxAudio, e.g. a KiwiSDR) is supplying + // this channel's audio — drop the Flex payload so the two sources + // don't mix. (feat/kiwi-audio-to-dax) + if (isValidDaxChannel(channel) + && (m_externalDaxSourceMask.load(std::memory_order_relaxed) + & (1u << channel))) { + return; + } QByteArray pcm; if (pcc == PCC_IF_NARROW) { // Float32 stereo big-endian from radio → native float32 stereo @@ -1458,6 +1471,39 @@ quint32 PanadapterStream::daxStreamIdForChannel(int channel) const return 0; } +void PanadapterStream::injectDaxAudio(int channel, const QByteArray& pcm) +{ + // Feed non-Flex audio (e.g. a KiwiSDR) onto a DAX channel using the same + // signal the Flex path uses, so TciServer / the DAX bridge need no + // changes. `pcm` is already the native DAX format — 24 kHz stereo + // float32: both Flex packet-class paths above (PCC_IF_NARROW and + // PCC_IF_NARROW_REDUCED, fw 4.2.18) normalize to exactly that before + // `daxAudioReady`, and KiwiSdrClient resamples its 12 kHz feed to match. + // No repackaging is required — consumers handle arbitrary payload sizes. + // (feat/kiwi-audio-to-dax) + if (!isValidDaxChannel(channel) || pcm.isEmpty()) { + return; + } + // Every Flex-path emission of daxAudioReady happens on this object's + // network thread; AutoConnection consumers (TciServer, DaxBridge) rely on + // that to get queued delivery. Re-invoke so an injection from the GUI + // thread keeps the identical contract instead of running the consumers' + // resampler/socket work synchronously inside the caller's slot. + if (QThread::currentThread() != thread()) { + QMetaObject::invokeMethod( + this, + [this, channel, pcm]() { emit daxAudioReady(channel, pcm); }, + Qt::QueuedConnection); + return; + } + emit daxAudioReady(channel, pcm); +} + +void PanadapterStream::setExternalDaxSourceMask(quint32 mask) +{ + m_externalDaxSourceMask.store(mask, std::memory_order_relaxed); +} + void PanadapterStream::unregisterDaxStream(quint32 streamId) { int channel = 0; diff --git a/src/core/PanadapterStream.h b/src/core/PanadapterStream.h index c81e65944..ee320e41c 100644 --- a/src/core/PanadapterStream.h +++ b/src/core/PanadapterStream.h @@ -171,6 +171,30 @@ class PanadapterStream : public QObject { }; QVector daxChannelSnapshot() const; + // External DAX audio injection (feat/kiwi-audio-to-dax). + // Emits `daxAudioReady(channel, pcm)` for audio that did not come from the + // Flex (e.g. a KiwiSDR replacing a slice's receive source). `pcm` must be + // the native DAX format: 24 kHz stereo float32 — both Flex packet-class + // paths (PCC_IF_NARROW and PCC_IF_NARROW_REDUCED, fw 4.2.18) normalize to + // that before `daxAudioReady`, and the external source must match it; a + // firmware DAX-rate change breaks this assumption (pitch shift), so + // re-verify here first. Callable from any thread — the emission is + // re-invoked onto this object's (network) thread so consumers see the + // same thread contract as the Flex path. `setExternalDaxSourceMask` marks + // channels (bit N = channel N) whose *Flex* DAX payload must be dropped, + // so the injected audio is the only thing WSJT-X hears on that channel; + // read lock-free on the RX thread. + void injectDaxAudio(int channel, const QByteArray& pcm); + void setExternalDaxSourceMask(quint32 mask); + + // The one definition of a routable DAX audio channel (1..4 on current + // radios; bounded above by the 32-bit suppression mask). Keep every + // channel-validity check on this predicate so the bounds can't diverge. + static constexpr bool isValidDaxChannel(int channel) + { + return channel >= 1 && channel < 32; + } + // DAX IQ stream routing void registerIqStream(quint32 streamId, int channel); void unregisterIqStream(quint32 streamId); @@ -406,6 +430,11 @@ private slots: int audioPacketJitterMs() const { return m_audioPacketJitterMs.load(); } private: + // Bit N set => drop the Flex DAX payload for channel N (an external + // source — e.g. a KiwiSDR — is supplying that channel's audio via + // injectDaxAudio instead). Read on the RX parse thread, written from the + // GUI thread. (feat/kiwi-audio-to-dax) + std::atomic m_externalDaxSourceMask{0}; std::atomic m_totalRxBytes{0}; std::atomic m_totalTxBytes{0}; std::atomic m_audioPacketGapMs{0}; diff --git a/src/gui/MainWindow.cpp b/src/gui/MainWindow.cpp index cdf087f8f..f77d2b7f1 100644 --- a/src/gui/MainWindow.cpp +++ b/src/gui/MainWindow.cpp @@ -5275,6 +5275,7 @@ void MainWindow::onConnectionStateChanged(bool connected) m_suppressStartupPanLayoutRearrange = false; m_layoutRestoreUntilMs = 0; clearKiwiSdrPanDisplaySourceOverrides(); + resetKiwiSdrDaxSuppressionState(); if (m_appletPanel) { m_appletPanel->clearSliceButtons(); } diff --git a/src/gui/MainWindow.h b/src/gui/MainWindow.h index 2c5d47cf3..fc191927a 100644 --- a/src/gui/MainWindow.h +++ b/src/gui/MainWindow.h @@ -413,6 +413,15 @@ private slots: const QString& profileId, bool selectSlice); void clearKiwiSdrVirtualAntennaForSlice(int sliceId); + // Route a KiwiSDR profile's decoded audio onto its slice's DAX channel so + // WSJT-X (over DAX/TCI) decodes the Kiwi. The "suppress the Flex payload + // on Kiwi-fed DAX channels" mask is kept current event-driven — see + // refreshKiwiSdrDaxSuppression — and kiwiSdrDaxStallTick feeds silence + // while a suppressed channel's Kiwi source stalls. (feat/kiwi-audio-to-dax) + void routeKiwiSdrAudioToDax(const QString& profileId, const QByteArray& pcm); + void refreshKiwiSdrDaxSuppression(); + void kiwiSdrDaxStallTick(); + void resetKiwiSdrDaxSuppressionState(); void prepareKiwiSdrBandRecallForPan(const QString& panId); bool finishPreparedKiwiSdrBandRecallForSlice(SliceModel* slice); void finishPreparedKiwiSdrBandRecallForPan(const QString& panId); @@ -1017,6 +1026,22 @@ private slots: // band recall that superseded it within the grace window. QHash m_kiwiSdrBandRecallGenerations; QSet m_kiwiSdrFlexDisplayPans; + // Kiwi→DAX stall watchdog (feat/kiwi-audio-to-dax): monotonic clock, + // per-channel last-Kiwi-chunk timestamps for suppressed channels, the + // channels currently being silence-filled, and the tick timer (runs only + // while the suppression mask is non-zero). + QElapsedTimer m_kiwiDaxClock; + QHash m_kiwiDaxLastAudioMs; + QSet m_kiwiDaxStalledChannels; + QTimer* m_kiwiDaxStallTimer{nullptr}; + // Suppression latch (mirrors TciServer::m_channelTrx, #3669): sliceId → + // the last DAX channel that slice held while Kiwi-fed. Keeps the + // suppression bit set through the radio's transient dax=0→N status + // rebroadcasts so Flex payload can't dual-feed the consumer mid-flip. + // Invalidated on clear/slice-removal (refresh prune), genuine stream + // release (RadioModel::daxStreamUnregistered), channel takeover by + // another slice, and disconnect (resetKiwiSdrDaxSuppressionState). + QHash m_kiwiDaxLatchedChannels; // Retain client-side receiver state across the slice teardown/rebuild a // FLEX band-stack recall performs. The trackers are pure lifecycle policy; // MainWindow owns their side effects and shared grace window. diff --git a/src/gui/MainWindow_KiwiSdr.cpp b/src/gui/MainWindow_KiwiSdr.cpp index 8dfa66ad0..946313dc6 100644 --- a/src/gui/MainWindow_KiwiSdr.cpp +++ b/src/gui/MainWindow_KiwiSdr.cpp @@ -575,6 +575,7 @@ void MainWindow::setKiwiSdrVirtualAntennaForSliceInternal(int sliceId, } const bool wasExternalReceiveActive = slice->externalReceiveReplacementActive(); slice->setExternalReceiveAudioReplacementMute(true); + refreshKiwiSdrDaxSuppression(); // (feat/kiwi-audio-to-dax) if (!wasExternalReceiveActive && slice->isDiversityChild() && slice->flexAudioGain() <= 0.0f) { // A Flex diversity child always reports audio_level=0 (its audio is @@ -639,6 +640,188 @@ void MainWindow::setKiwiSdrVirtualAntennaForSliceInternal(int sliceId, | KiwiSdrUiSyncWaterfallAvailability); } +namespace { +// Kiwi→DAX stall watchdog (feat/kiwi-audio-to-dax): while a channel's Flex +// payload is suppressed, the Kiwi must keep it fed — see kiwiSdrDaxStallTick. +constexpr int kKiwiDaxStallTickMs = 250; // watchdog cadence +constexpr int kKiwiDaxStallThresholdMs = 400; // > worst-case Kiwi chunk jitter +} // namespace + +void MainWindow::routeKiwiSdrAudioToDax(const QString& profileId, + const QByteArray& pcm) +{ + if (!m_kiwiSdrManager || pcm.isEmpty()) { + return; + } + const int sliceId = m_kiwiSdrManager->assignedSliceForProfile(profileId); + if (sliceId < 0) { + return; + } + SliceModel* slice = m_radioModel.slice(sliceId); + if (!slice || !slice->externalReceiveReplacementActive()) { + return; + } + const int channel = slice->daxChannel(); + if (!m_radioModel.isValidDaxChannel(channel)) { + return; // no DAX channel bound yet (no TCI/DAX client on this slice) + } + if (m_kiwiDaxClock.isValid()) { + m_kiwiDaxLastAudioMs.insert(channel, m_kiwiDaxClock.elapsed()); + } + if (m_kiwiDaxStalledChannels.remove(channel)) { + qCInfo(lcKiwiSdr) << "KiwiSDR audio resumed on DAX channel" << channel; + } + m_radioModel.injectDaxAudio(channel, pcm); +} + +// Recompute the Flex-suppression mask from live slice state. Event-driven: +// wired to SliceModel::daxChannelChanged, RadioModel::sliceRemoved, and the +// Kiwi assign/clear lifecycle (wireKiwiSdr), so it stays correct even while +// no Kiwi audio is flowing (connect window, stall, reconnect backoff) and +// after teardowns where the slice is already gone — it must never depend on +// looking a particular slice up. +void MainWindow::refreshKiwiSdrDaxSuppression() +{ + // Latch upkeep first: an entry whose slice is gone (removed — RadioModel + // has already dropped it) or no longer Kiwi-fed must not keep a bit alive. + for (auto it = m_kiwiDaxLatchedChannels.begin(); + it != m_kiwiDaxLatchedChannels.end();) { + SliceModel* slice = m_radioModel.slice(it.key()); + if (!slice || !slice->externalReceiveReplacementActive()) { + it = m_kiwiDaxLatchedChannels.erase(it); + } else { + ++it; + } + } + + quint32 mask = 0; + for (SliceModel* slice : m_radioModel.slices()) { + if (!slice || !slice->externalReceiveReplacementActive()) { + continue; + } + const int channel = slice->daxChannel(); + if (m_radioModel.isValidDaxChannel(channel)) { + // Latch on assignment: remember the resolved channel so the bit + // survives the radio's transient dax=0→N rebroadcast (the + // dual-feed window) — mirroring TciServer::m_channelTrx (#3669). + m_kiwiDaxLatchedChannels.insert(slice->sliceId(), channel); + mask |= (1u << channel); + continue; + } + const auto latched = m_kiwiDaxLatchedChannels.constFind(slice->sliceId()); + if (latched == m_kiwiDaxLatchedChannels.constEnd()) { + continue; // never resolved a channel — nothing to hold + } + // dax reads 0: either a transient rebroadcast (hold the bit — this is + // the latch's whole purpose) or the channel moved on. If another slice + // now owns the latched channel, holding the bit would suppress that + // slice's live Flex feed — the orphaned-bit defect this lifecycle work + // exists to prevent — so the latch yields to the new owner. + bool takenOver = false; + for (SliceModel* other : m_radioModel.slices()) { + if (other && other != slice + && other->daxChannel() == latched.value()) { + takenOver = true; + break; + } + } + if (takenOver) { + m_kiwiDaxLatchedChannels.erase(latched); + } else { + mask |= (1u << latched.value()); + } + } + m_radioModel.setExternalDaxSourceMask(mask); + + // Watchdog bookkeeping: track exactly the suppressed channels. A channel + // that just became suppressed is armed "as of now" so the stall timer + // covers the initial websocket connect window too. + if (!m_kiwiDaxClock.isValid()) { + m_kiwiDaxClock.start(); + } + const qint64 now = m_kiwiDaxClock.elapsed(); + for (auto it = m_kiwiDaxLastAudioMs.begin(); + it != m_kiwiDaxLastAudioMs.end();) { + if (!(mask & (1u << it.key()))) { + m_kiwiDaxStalledChannels.remove(it.key()); + it = m_kiwiDaxLastAudioMs.erase(it); + } else { + ++it; + } + } + for (int channel = 1; m_radioModel.isValidDaxChannel(channel); ++channel) { + if ((mask & (1u << channel)) + && !m_kiwiDaxLastAudioMs.contains(channel)) { + m_kiwiDaxLastAudioMs.insert(channel, now); + } + } + if (mask != 0) { + if (!m_kiwiDaxStallTimer) { + m_kiwiDaxStallTimer = new QTimer(this); + m_kiwiDaxStallTimer->setInterval(kKiwiDaxStallTickMs); + connect(m_kiwiDaxStallTimer, &QTimer::timeout, + this, &MainWindow::kiwiSdrDaxStallTick); + } + if (!m_kiwiDaxStallTimer->isActive()) { + m_kiwiDaxStallTimer->start(); + } + } else if (m_kiwiDaxStallTimer) { + m_kiwiDaxStallTimer->stop(); + } +} + +// While a channel is suppressed but the Kiwi has stopped delivering (stall, +// reconnect backoff, initial connect), neither source reaches the DAX +// consumers — they are all push-only, so WSJT-X's input would freeze +// mid-stream with no indication which side failed. Feed silence at the +// native DAX format instead: decoders keep their clocks, and real audio +// resumes seamlessly when the Kiwi recovers. +void MainWindow::kiwiSdrDaxStallTick() +{ + if (!m_radioModel.hasPanStream() || !m_kiwiDaxClock.isValid()) { + return; + } + const qint64 now = m_kiwiDaxClock.elapsed(); + for (auto it = m_kiwiDaxLastAudioMs.cbegin(); + it != m_kiwiDaxLastAudioMs.cend(); ++it) { + if (now - it.value() < kKiwiDaxStallThresholdMs) { + continue; + } + const int channel = it.key(); + if (!m_kiwiDaxStalledChannels.contains(channel)) { + m_kiwiDaxStalledChannels.insert(channel); + qCInfo(lcKiwiSdr) + << "KiwiSDR audio stalled on DAX channel" << channel + << "- feeding silence until it resumes"; + } + // One tick's worth of 24 kHz stereo float32 silence. + static const QByteArray silence( + 24000 * kKiwiDaxStallTickMs / 1000 * 2 + * static_cast(sizeof(float)), + '\0'); + m_radioModel.injectDaxAudio(channel, silence); + } +} + +// Radio disconnect: RadioModel stages slices out WITHOUT emitting +// sliceRemoved (they go to the stale-session store), so the event-driven +// refresh never runs and the core-side mask reset in clearRegisteredStreams() +// has no GUI-side sibling — the stall timer would keep injecting silence on +// stale channels into a fully disconnected app. Reset here, from the same +// disconnect hook that clears the other per-session Kiwi GUI state +// (onConnectionStateChanged → clearKiwiSdrPanDisplaySourceOverrides). +void MainWindow::resetKiwiSdrDaxSuppressionState() +{ + if (m_kiwiDaxStallTimer) { + m_kiwiDaxStallTimer->stop(); + } + m_kiwiDaxLastAudioMs.clear(); + m_kiwiDaxStalledChannels.clear(); + // Channel latch is per-session state too (TciServer clears m_channelTrx + // on disconnect for the same reason): reconnect re-resolves from scratch. + m_kiwiDaxLatchedChannels.clear(); +} + void MainWindow::clearKiwiSdrVirtualAntennaForSlice(int sliceId) { qCInfo(lcKiwiSdr).noquote() @@ -662,6 +845,10 @@ void MainWindow::clearKiwiSdrVirtualAntennaForSlice(int sliceId) spectrum->setKiwiSdrWaterfallProfile(QString()); } } + // Outside the slice lookup on purpose: during slice removal RadioModel + // has already dropped the slice, and the mask must still be recomputed. + // (feat/kiwi-audio-to-dax) + refreshKiwiSdrDaxSuppression(); if (!panId.isEmpty()) { m_kiwiSdrFlexDisplayPans.remove(panId); @@ -1690,6 +1877,59 @@ void MainWindow::wireKiwiSdr() audio->removeKiwiSdrAudioSource(id); }, Qt::QueuedConnection); } + // Route the Kiwi audio onto its slice's DAX channel so WSJT-X + // (DAX/TCI) decodes the Kiwi, not the muted Flex. Deliberately + // outside the if (m_audio) guard: DAX/TCI decode is independent of + // the local audio engine, and routeKiwiSdrAudioToDax never touches + // m_audio. (feat/kiwi-audio-to-dax) + connect(m_kiwiSdrManager, &KiwiSdrManager::decodedAudioReady, + this, [this](const QString& id, const QByteArray& pcm) { + routeKiwiSdrAudioToDax(id, pcm); + }, Qt::QueuedConnection); + // Keep the DAX suppression mask event-driven (feat/kiwi-audio-to-dax): + // a slice's DAX channel can (re)bind lazily (TCI audio_start) or drop + // to 0 (WSJT-X exit) while no Kiwi audio is flowing, so the refresh + // must follow the transitions themselves — mirroring wireDaxSlice + // (MainWindow_DigitalModes.cpp, #2895) — not the audio chunks. + const auto wireKiwiDaxSlice = [this](SliceModel* s) { + if (!s) { + return; + } + connect(s, &SliceModel::daxChannelChanged, + this, [this](int) { refreshKiwiSdrDaxSuppression(); }); + }; + for (SliceModel* s : m_radioModel.slices()) { + wireKiwiDaxSlice(s); + } + connect(&m_radioModel, &RadioModel::sliceAdded, + this, wireKiwiDaxSlice); + // RadioModel drops the slice from its list BEFORE emitting + // sliceRemoved; refreshKiwiSdrDaxSuppression recomputes from the + // remaining slices, so this is exactly what clears the removed + // slice's mask bit. + connect(&m_radioModel, &RadioModel::sliceRemoved, + this, [this](int) { refreshKiwiSdrDaxSuppression(); }); + // The latch (m_kiwiDaxLatchedChannels) deliberately holds a bit + // through a slice's transient dax=0; a channel whose dax_rx stream is + // genuinely gone (grace-expiry release after WSJT-X exit, radio-side + // removal) must drop out of it here, or the held bit would suppress + // Flex DAX for the channel's next holder. + connect(&m_radioModel, &RadioModel::daxStreamUnregistered, + this, [this](int channel) { + bool dropped = false; + for (auto it = m_kiwiDaxLatchedChannels.begin(); + it != m_kiwiDaxLatchedChannels.end();) { + if (it.value() == channel) { + it = m_kiwiDaxLatchedChannels.erase(it); + dropped = true; + } else { + ++it; + } + } + if (dropped) { + refreshKiwiSdrDaxSuppression(); + } + }); connect(&m_radioModel.transmitModel(), &TransmitModel::moxChanged, this, [this](bool) { syncKiwiSdrTransmitMute(); }); connect(&m_radioModel.transmitModel(), &TransmitModel::tuneChanged, @@ -1909,6 +2149,11 @@ void MainWindow::wireKiwiSdr() spectrum->setKiwiSdrWaterfallProfile(QString()); } } + // After the replacement-mute restore, and outside the slice + // lookup on purpose: during slice removal RadioModel has already + // dropped the slice, and the mask must still be recomputed. + // (feat/kiwi-audio-to-dax) + refreshKiwiSdrDaxSuppression(); scheduleKiwiSdrUiSync(KiwiSdrUiSyncWaterfallAvailability | KiwiSdrUiSyncDiversityEsc); syncKiwiSdrPanadapterUiState(panId); diff --git a/src/models/RadioModel.cpp b/src/models/RadioModel.cpp index 2d82cf0e5..c2e5394da 100644 --- a/src/models/RadioModel.cpp +++ b/src/models/RadioModel.cpp @@ -666,6 +666,10 @@ RadioModel::RadioModel(QObject* parent) if (!isConnected()) return; sendCommand(QString("stream remove 0x%1").arg(streamId, 0, 16)); }); + // Seam forward for above-seam consumers (EB3) — Qt drops the trailing + // streamId argument. + connect(m_panStream, &PanadapterStream::daxStreamUnregistered, + this, &RadioModel::daxStreamUnregistered); // 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. @@ -7698,6 +7702,26 @@ bool RadioModel::ensureDaxTxStream(DaxTxRequestReason reason) return true; } +// Seam forwarders (engine-boundary EB3): let above-seam callers reach the DAX +// stream through the model instead of including the vendor PanadapterStream +// header. Pure delegation — no behavior of its own. +bool RadioModel::isValidDaxChannel(int channel) const +{ + return PanadapterStream::isValidDaxChannel(channel); +} + +void RadioModel::injectDaxAudio(int channel, const QByteArray& pcm) +{ + if (m_panStream) + m_panStream->injectDaxAudio(channel, pcm); +} + +void RadioModel::setExternalDaxSourceMask(quint32 mask) +{ + if (m_panStream) + m_panStream->setExternalDaxSourceMask(mask); +} + QJsonObject RadioModel::troubleshootingSnapshot() const { QJsonObject snapshot; diff --git a/src/models/RadioModel.h b/src/models/RadioModel.h index 9779c2c51..3081a6a7b 100644 --- a/src/models/RadioModel.h +++ b/src/models/RadioModel.h @@ -78,6 +78,15 @@ class RadioModel : public QObject { // Access the underlying connection and panadapter stream RadioConnection* connection() { return m_connection; } PanadapterStream* panStream() { return m_panStream; } + + // Seam forwarders for above-seam callers (GUI) that must not include the + // vendor PanadapterStream header directly (engine-boundary EB3). RadioModel + // already owns the stream, so DAX-channel helpers route through here rather + // than leaking the vendor type into the GUI. + bool hasPanStream() const { return m_panStream != nullptr; } + bool isValidDaxChannel(int channel) const; + void injectDaxAudio(int channel, const QByteArray& pcm); + void setExternalDaxSourceMask(quint32 mask); // Sub-models owned by RadioModel (main thread). (#502) MeterModel& meterModel() { return m_meterModel; } TunerModel& tunerModel() { return m_tunerModel; } @@ -548,6 +557,12 @@ class RadioModel : public QObject { void sliceAdded(SliceModel* slice); void sliceRemoved(int sliceId); void rawSliceModeListsChanged(); + // Seam forward of PanadapterStream::daxStreamUnregistered (streamId + // dropped): fires when a dax_rx stream is genuinely gone — radio-side + // removal or the #3305 grace-expiry release — as opposed to a slice's + // dax binding transiently reading 0 during a status rebroadcast. GUI + // consumers (kiwi DAX suppression latch) key invalidation off this. + void daxStreamUnregistered(int channel); void metersChanged(); void connectionError(const QString& msg); // Phase 2 of GHSA-wfx7-w6p8-4jr2 (#2951): forwarded from