//---------------------------------------------------------- MainWindow #include "mainwindow.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "JS8_Audio/NotificationAudio.h" #include "JS8_Audio/soundin.h" #include "JS8_Audio/soundout.h" #include "JS8_Include/EventFilter.h" #include "JS8_Main/Bands.h" #include "JS8_Main/DriftingDateTime.h" #include "JS8_Main/Geodesic.h" #include "JS8_Main/HelpTextWindow.h" #include "JS8_Main/Inbox.h" #include "JS8_Main/MessageClient.h" #include "JS8_Main/MultiSettings.h" #include "JS8_Main/Radio.h" #include "JS8_Main/SelfDestructMessageBox.h" #include "JS8_Main/SignalMeter.h" #include "JS8_Main/StationList.h" #include "JS8_Main/qt_helpers.h" #include "JS8_Main/revision_utils.h" #include "JS8_Mode/Decoder.h" #include "JS8_Mode/Detector.h" #include "JS8_Mode/JS8Submode.h" #include "JS8_Mode/Modulator.h" #include "JS8_Mode/decodedtext.h" #include "JS8_Network/NetworkAccessManager.h" #include "JS8_Transceiver/TransceiverFactory.h" #include "JS8_UDP/WSJTXMessageClient.h" #include "JS8_UDP/WSJTXMessageMapper.h" #include "JS8_UI/about.h" #include "JS8_UI/widegraph.h" #include "JS8_jsc/jsc_checker.h" #include "logqso.h" #include "messagereplydialog.h" #include "messagewindow.h" #include "moc_mainwindow.cpp" #include "ui_mainwindow.h" Q_DECLARE_LOGGING_CATEGORY(mainwindow_js8) // TODO: Move to member: static char message[29]; static char msgsent[29]; static int msgibits; // How often to poll the UI, in MS. // Some things may depend on this being a divisor of 1000. constexpr quint32 UI_POLL_INTERVAL_MS = 100; // How many milliseconds to wait before releasing PTT at end of transmission. constexpr int TX_SWITCHOFF_DELAY = 200; int volatile itone[JS8_NUM_SYMBOLS]; // Audio tones for all Tx symbols struct dec_data dec_data; // for sharing with Fortran struct specData specData; // Used by plotter std::mutex fftw_mutex; namespace { namespace Default { constexpr Radio::Frequency DIAL_FREQUENCY = 14078000; constexpr auto FREQUENCY = 1500; constexpr auto SUBMODE = Varicode::JS8CallNormal; } // namespace Default namespace State { constexpr auto RX = 1; constexpr auto TX = 2; } // namespace State int ms_minute_error() { auto const now = DriftingDateTime::currentDateTimeLocal(); auto const time = now.time(); auto const second = time.second(); return now.msecsTo(now.addSecs(second > 30 ? 60 - second : -second)) - time.msec(); } QString since(QDateTime const &time) { auto const delta = time.secsTo(DriftingDateTime::currentDateTimeUtc()); if (delta >= 60 * 60 * 24) return QString("%1d").arg(delta / (60 * 60 * 24)); else if (delta >= 60 * 60) return QString("%1h").arg(delta / (60 * 60)); else if (delta >= 60) return QString("%1m").arg(delta / (60)); else if (delta >= 15) return QString("%1s").arg(delta - (delta % 15)); else return QString("now"); } namespace State { constexpr QStringView Ready = u"Ready"; constexpr QStringView Send = u"Send"; constexpr QStringView Sending = u"Sending"; constexpr QStringView Tuning = u"Tuning"; QString timed(QStringView const state, int const delay) { auto time = std::div(delay, 60); if (time.quot && time.rem) return QString("%1 (%2m %3s)").arg(state).arg(time.quot).arg(time.rem); else if (time.quot) return QString("%1 (%2m)").arg(state).arg(time.quot); else return QString("%1 (%2s)").arg(state).arg(time.rem); } } // namespace State #if 0 int round(int numToRound, int multiple) { if(multiple == 0) { return numToRound; } int roundDown = ( (int) (numToRound) / multiple) * multiple; if(numToRound - roundDown > multiple/2){ return roundDown + multiple; } return roundDown; } #endif int roundUp(int numToRound, int multiple) { if (multiple == 0) { return numToRound; } int roundDown = (numToRound / multiple) * multiple; return roundDown + multiple; } auto replaceMacros(QString const &text, QMap const &values, bool const prune) { QString output = text; for (auto const [key, value] : values.asKeyValueRange()) { output = output.replace(key, value.toUpper()); } return prune ? output.replace(QRegularExpression("[<](?:[^>]+)[>]"), "") : output; } // Copy at most size bytes into the array, filling any unused size // with spaces if less than size bytes were available to copy. For // convenience, return a one past the end iterator, i.e., equal to // array + size. auto copyByteData(QByteArrayView const bytes, char *const array, qsizetype const size) { return std::fill_n( std::copy_n(bytes.begin(), std::min(size, bytes.size()), array), size - bytes.size(), ' '); } // Copy at most size bytes into the array, padding out the message // with spaces if less than size bytes were available to copy, and // null-terminate it. Caller is responsible for ensuring that at // least (size + 1) bytes of space are available. void copyMessage(QStringView const string, char *const array, qsizetype const size = 28) { *copyByteData(string.toLocal8Bit(), array, size) = '\0'; } // Emulation of the Fortran 'flat1' subroutine. void flat1(float const *const savg, int const iz, int const nsmo, float *const slin) { constexpr int x_size = 8192; constexpr int nstep = 20; constexpr int nh = nstep / 2; // Define bounds for smoothing int const ia = nsmo / 2 + 1; int const ib = iz - nsmo / 2 - 1; std::vector x(x_size, 0.0f); // Smooth savg using median percentiles auto const rank = std::clamp(static_cast(std::round(0.5f * nsmo)), 0, nsmo - 1); for (int i = ia; i <= ib; i += nstep) { auto const data = &savg[i - nsmo / 2]; auto temp = std::vector(data, data + nsmo); std::nth_element(temp.begin(), temp.begin() + rank, temp.end()); x[i] = temp[rank]; std::fill(x.begin() + (i - nh), x.begin() + (i + nh), x[i]); } // Extend smoothed values to boundaries std::fill(x.begin(), x.begin() + ia, x[ia]); std::fill(x.begin() + ib + 1, x.begin() + iz, x[ib]); // Compute scaling factor float x0 = 0.001f * *std::max_element(x.begin() + iz / 10, x.begin() + (9 * iz) / 10); // Normalize savg to compute slin for (int i = 0; i < iz; ++i) slin[i] = savg[i] / (x[i] + x0); } // Emulation of the Fortran 'smo' subroutine. However, doesn't copy the data // back from b to a; rather, a is input and, b is output. Since we invariably // call this twice, we can just swap the order of the arrays to achieve the // same result without the extra two copy operations. void smo(float const *const a, float *const b, int const npts, int const nadd) { auto const nh = nadd / 2; // Smooth the array for (int i = nh; i < npts - nh; ++i) { float sum = 0.0f; for (int j = -nh; j <= nh; ++j) { sum += a[i + j]; } b[i] = sum; } // Set edges to zero for (int i = 0; i < nh; ++i) b[i] = 0.0f; // Zero out leading edge for (int i = npts - nh; i < npts; ++i) b[i] = 0.0f; // Zero out trailing edge } } // namespace //--------------------------------------------------- MainWindow constructor MainWindow::MainWindow(QString const &program_info, QDir const &temp_directory, bool const multiple, MultiSettings *multi_settings, QWidget *parent) : QMainWindow(parent), m_stopTxButtonIsLongterm{true}, m_hbButtonIsLongterm{true}, m_cqButtonIsLongterm{true}, m_network_manager{this}, m_valid{true}, m_multiple{multiple}, m_multi_settings{multi_settings}, m_configurations_button{0}, m_settings{multi_settings->settings()}, m_settings_read{false}, ui(new Ui::MainWindow), m_config{temp_directory, m_settings, this}, m_rigErrorMessageBox{JS8MessageBox::Critical, tr("Rig Control Error"), JS8MessageBox::Cancel | JS8MessageBox::Ok | JS8MessageBox::Retry}, m_wideGraph(new WideGraph(m_settings)), // no parent so that it has a taskbar icon m_logDlg(new LogQSO(program_title(), m_settings, &m_config, nullptr)), m_lastDialFreq{0}, m_detector{new Detector{JS8_RX_SAMPLE_RATE, JS8_NTMAX}}, m_FFTSize{6912 / 2}, // conservative value to avoid buffer overruns m_soundInput{new SoundInput}, m_modulator{new Modulator}, m_soundOutput{new SoundOutput}, m_notification{new NotificationAudio}, m_cq_loop{new TxLoop{"CQ calls"}}, m_hb_loop{new TxLoop{"HB calls"}}, m_decoder{this}, m_secBandChanged{0}, m_freqNominal{0}, m_freqTxNominal{0}, m_XIT{0}, m_sec0{-1}, m_RxLog{1}, // Write Date and Time to RxLog m_nutc0{999999}, m_TRperiod{60}, m_inGain{0}, m_idleMinutes{0}, m_nSubMode{Default::SUBMODE}, m_frequency_list_fcal_iter{m_config.frequencies()->begin()}, m_i3bit{0}, m_btxok{false}, m_auto{false}, m_restart{false}, m_currentMessageType{-1}, m_lastMessageType{-1}, m_tuneup{false}, m_isTimeToSend{false}, m_ihsym{0}, m_px{0.0}, m_iptt0{0}, m_btxok0{false}, m_onAirFreq0{0.0}, m_first_error{true}, tx_status_label{"Receiving"}, m_appDir{QApplication::applicationDirPath()}, m_palette{"Linrad"}, m_txFrameCountEstimate{0}, m_txFrameCount{0}, m_txFrameCountSent{0}, m_txTextDirty{false}, m_driftMsMMA{0}, m_driftMsMMA_N{0}, m_previousFreq{0}, m_hbInterval{0}, m_cqInterval{0}, m_hbPaused{false}, m_msAudioOutputBuffered(0u), m_framesAudioInputBuffered(JS8_RX_SAMPLE_RATE / 10), m_audioThreadPriority(QThread::HighPriority), m_notificationAudioThreadPriority(QThread::LowPriority), m_decoderThreadPriority(QThread::HighPriority), m_splitMode{false}, m_monitoring{false}, m_generateAudioWhenPttConfirmedByTX{false}, m_transmitting{false}, m_tune{false}, m_tx_watchdog{false}, m_block_pwr_tooltip{false}, m_PwrBandSetOK{true}, m_lastMonitoredFrequency{Default::DIAL_FREQUENCY}, m_messageClient{new MessageClient{m_config.udp_server_name(), m_config.udp_server_port(), this}}, m_messageServer{new MessageServer()}, m_wsjtxMessageClient{nullptr}, m_wsjtxMessageMapper{nullptr}, m_n3fjpClient{new TCPClient{this}}, m_pskReporter{new PSKReporter{&m_config, program_info}}, // UR m_spotClient{new SpotClient{"spot.js8call.com", 50000, program_info}}, m_aprsClient{new APRSISClient{"rotate.aprs2.net", 14580}}, m_manual{&m_network_manager} { ui->setupUi(this); createStatusBar(); add_child_to_event_filter(this); m_baseCall = Radio::base_callsign(m_config.my_callsign()); m_opCall = m_config.opCall(); // Closedown. connect(ui->actionExit, &QAction::triggered, this, &QMainWindow::close); // parts of the rig error message box that are fixed m_rigErrorMessageBox.setInformativeText( tr("Do you want to reconfigure the radio interface?")); m_rigErrorMessageBox.setDefaultButton(JS8MessageBox::Ok); // start audio thread and hook up slots & signals for shutdown management // these objects need to be in the audio thread so that invoking // their slots is done in a thread safe way m_soundOutput->moveToThread(&m_audioThread); m_modulator->moveToThread(&m_audioThread); m_soundInput->moveToThread(&m_audioThread); m_detector->moveToThread(&m_audioThread); // notification audio operates in its own thread at a lower priority m_notification->moveToThread(&m_notificationAudioThread); // Move the aprs client message server, psk reporter, and spot client // to the network thread at a lower priority. m_aprsClient->moveToThread(&m_networkThread); m_messageServer->moveToThread(&m_networkThread); m_pskReporter->moveToThread(&m_networkThread); m_spotClient->moveToThread(&m_networkThread); // hook up the message server slots and signals and disposal connect(m_messageServer, &MessageServer::message, this, &MainWindow::tcpNetworkMessage); connect(this, &MainWindow::apiSetMaxConnections, m_messageServer, &MessageServer::setMaxConnections); connect(this, &MainWindow::apiSetServer, m_messageServer, &MessageServer::setServer); connect(this, &MainWindow::apiStartServer, m_messageServer, &MessageServer::start); connect(this, &MainWindow::apiStopServer, m_messageServer, &MessageServer::stop); connect(&m_config, &Configuration::tcp_server_changed, m_messageServer, &MessageServer::setServerHost); connect(&m_config, &Configuration::tcp_server_port_changed, m_messageServer, &MessageServer::setServerPort); connect(&m_config, &Configuration::tcp_max_connections_changed, m_messageServer, &MessageServer::setMaxConnections); connect(&m_networkThread, &QThread::finished, m_messageServer, &QObject::deleteLater); // hook up the aprs client slots and signals and disposal connect(this, &MainWindow::aprsClientEnqueueSpot, m_aprsClient, &APRSISClient::enqueueSpot); connect(this, &MainWindow::aprsClientEnqueueThirdParty, m_aprsClient, &APRSISClient::enqueueThirdParty); connect(this, &MainWindow::aprsClientSendReports, m_aprsClient, &APRSISClient::sendReports); connect(this, &MainWindow::aprsClientSetLocalStation, m_aprsClient, &APRSISClient::setLocalStation); connect(this, &MainWindow::aprsClientSetPaused, m_aprsClient, &APRSISClient::setPaused); connect(this, &MainWindow::aprsClientSetServer, m_aprsClient, &APRSISClient::setServer); connect(this, &MainWindow::aprsClientSetSkipPercent, m_aprsClient, &APRSISClient::setSkipPercent); connect(&m_networkThread, &QThread::finished, m_aprsClient, &QObject::deleteLater); // hook up the psk reporter slots and signals and disposal connect(m_pskReporter, &PSKReporter::errorOccurred, this, &MainWindow::pskReporterError); connect(this, &MainWindow::pskReporterSendReport, m_pskReporter, &PSKReporter::sendReport); connect(this, &MainWindow::pskReporterAddRemoteStation, m_pskReporter, &PSKReporter::addRemoteStation); connect(this, &MainWindow::pskReporterSetLocalStation, m_pskReporter, &PSKReporter::setLocalStation); connect(&m_networkThread, &QThread::started, m_pskReporter, &PSKReporter::start); connect(&m_networkThread, &QThread::finished, m_pskReporter, &QObject::deleteLater); // hook up the spot client signals and disposal connect(this, &MainWindow::spotClientEnqueueCmd, m_spotClient, &SpotClient::enqueueCmd); connect(this, &MainWindow::spotClientEnqueueSpot, m_spotClient, &SpotClient::enqueueSpot); connect(this, &MainWindow::spotClientSetLocalStation, m_spotClient, &SpotClient::setLocalStation); connect(&m_networkThread, &QThread::started, m_spotClient, &SpotClient::start); connect(&m_networkThread, &QThread::finished, m_spotClient, &QObject::deleteLater); // hook up sound output stream slots & signals and disposal connect(this, &MainWindow::initializeAudioOutputStream, m_soundOutput, &SoundOutput::setFormat); connect(m_soundOutput, &SoundOutput::error, this, &MainWindow::showSoundOutError); connect(m_soundOutput, &SoundOutput::error, &m_config, &Configuration::invalidate_audio_output_device); connect(this, &MainWindow::outAttenuationChanged, m_soundOutput, &SoundOutput::setAttenuation); connect(&m_audioThread, &QThread::finished, m_soundOutput, &QObject::deleteLater); connect(this, &MainWindow::initializeNotificationAudioOutputStream, m_notification, &NotificationAudio::setDevice); connect(&m_config, &Configuration::test_notify, this, &MainWindow::tryNotify); connect(this, &MainWindow::playNotification, m_notification, &NotificationAudio::play); connect(&m_notificationAudioThread, &QThread::finished, m_notification, &QObject::deleteLater); // hook up Modulator slots and disposal connect(this, &MainWindow::transmitFrequency, m_modulator, &Modulator::setAudioFrequency); connect(this, &MainWindow::endTransmitMessage, m_modulator, &Modulator::stop); connect(this, &MainWindow::tune, m_modulator, &Modulator::tune); connect(this, &MainWindow::sendMessage, m_modulator, &Modulator::start); connect(&m_audioThread, &QThread::finished, m_modulator, &QObject::deleteLater); // hook up the audio input stream signals, slots and disposal connect(this, &MainWindow::startAudioInputStream, m_soundInput, &SoundInput::start); connect(this, &MainWindow::suspendAudioInputStream, m_soundInput, &SoundInput::suspend); connect(this, &MainWindow::resumeAudioInputStream, m_soundInput, &SoundInput::resume); connect(this, &MainWindow::finished, m_soundInput, &SoundInput::stop); connect(m_soundInput, &SoundInput::error, this, &MainWindow::showSoundInError); connect(m_soundInput, &SoundInput::error, &m_config, &Configuration::invalidate_audio_input_device); // connect(m_soundInput, &SoundInput::status, this, // &MainWindow::showStatusMessage); connect(&m_audioThread, &QThread::finished, m_soundInput, &QObject::deleteLater); connect(this, &MainWindow::finished, this, &MainWindow::close); // hook up the detector signals, slots and disposal connect(this, &MainWindow::FFTSize, m_detector, &Detector::setBlockSize); connect(m_detector, &Detector::framesWritten, this, &MainWindow::dataSink); connect(&m_audioThread, &QThread::finished, m_detector, &QObject::deleteLater); // setup the waterfall connect(m_wideGraph.data(), &WideGraph::f11f12, this, &MainWindow::f11f12); connect(m_wideGraph.data(), &WideGraph::setXIT, this, &MainWindow::setXIT); connect(this, &MainWindow::finished, m_wideGraph.data(), &WideGraph::close); // setup the log QSO dialog connect(m_logDlg.data(), &LogQSO::acceptQSO, this, &MainWindow::acceptQSO); connect(this, &MainWindow::finished, m_logDlg.data(), &LogQSO::close); // Network message handling connect(m_messageClient, &MessageClient::message, this, &MainWindow::udpNetworkMessage); /** * @brief Initialize WSJT-X protocol if enabled * * Creates and configures the WSJT-X message client and mapper when the * WSJT-X protocol is enabled. Sets up signal connections for configuration * changes and disables the native JSON client if it conflicts with WSJT-X * on the same port/address. */ if (m_config.wsjtx_protocol_enabled()) { QString id = QApplication::applicationName(); QString version = QApplication::applicationVersion(); QString revision = ""; // Get from your version system if available m_wsjtxMessageClient = new WSJTXMessageClient{ id, version, revision, m_config.wsjtx_server_name(), m_config.wsjtx_server_port(), m_config.wsjtx_interface_names(), // Use selected interfaces m_config.wsjtx_TTL(), this}; m_wsjtxMessageClient->enable(m_config.wsjtx_accept_requests()); m_wsjtxMessageMapper = new WSJTXMessageMapper(m_wsjtxMessageClient, this, this); // Disable native JSON client if it's using the same port/address as // WSJT-X if (m_config.wsjtx_server_port() == m_config.udp_server_port() && m_config.wsjtx_server_name() == m_config.udp_server_name()) { m_messageClient->set_server_port(0); // Disable native JSON client } // Connect configuration changes connect(&m_config, &Configuration::wsjtx_server_changed, [this](QString const &server_name) { m_wsjtxMessageClient->set_server( server_name, m_config.wsjtx_interface_names()); // Check if we need to disable native JSON client if (m_config.wsjtx_protocol_enabled() && m_config.wsjtx_server_port() == m_config.udp_server_port() && server_name == m_config.udp_server_name()) { m_messageClient->set_server_port(0); } else if (m_config.wsjtx_protocol_enabled() && m_config.wsjtx_server_port() != m_config.udp_server_port()) { m_messageClient->set_server_port( m_config.udp_server_port()); } }); connect(&m_config, &Configuration::wsjtx_server_port_changed, [this](quint16 port) { m_wsjtxMessageClient->set_server_port(port); // Check if we need to disable native JSON client if (m_config.wsjtx_protocol_enabled() && port == m_config.udp_server_port() && m_config.wsjtx_server_name() == m_config.udp_server_name()) { m_messageClient->set_server_port(0); } else if (m_config.wsjtx_protocol_enabled() && port != m_config.udp_server_port()) { m_messageClient->set_server_port( m_config.udp_server_port()); } }); connect(&m_config, &Configuration::wsjtx_TTL_changed, this, [this](int ttl) { if (m_wsjtxMessageClient) { m_wsjtxMessageClient->set_TTL(ttl); } }); connect(&m_config, &Configuration::wsjtx_interfaces_changed, [this](QStringList const &interfaces) { if (m_wsjtxMessageClient) { m_wsjtxMessageClient->set_server( m_config.wsjtx_server_name(), interfaces); } }); } // decoder queue handler // connect (&m_decodeThread, &QThread::finished, m_notification, // &QObject::deleteLater); connect(this, &MainWindow::decodedLineReady, // this, &MainWindow::processDecodedLine); connect(&m_decoder, &JS8::Decoder::decodeEvent, this, &MainWindow::processDecodeEvent); m_dateTimeQSOOn = QDateTime{}; // initialize decoded text font and hook up font change signals // defer initialization until after construction otherwise menu // fonts do not get set QTimer::singleShot(0, this, &MainWindow::initialize_fonts); connect(&m_config, &Configuration::gui_text_font_changed, [this](QFont const &font) { set_application_font(font); }); connect(&m_config, &Configuration::table_font_changed, [this](QFont const &) { ui->tableWidgetRXAll->setFont(m_config.table_font()); ui->tableWidgetCalls->setFont(m_config.table_font()); }); connect(&m_config, &Configuration::rx_text_font_changed, [this](QFont const &) { setTextEditFont(ui->textEditRX, m_config.rx_text_font()); }); connect(&m_config, &Configuration::compose_text_font_changed, [this](QFont const &) { setTextEditFont(ui->extFreeTextMsgEdit, m_config.compose_text_font()); }); connect(&m_config, &Configuration::colors_changed, [this]() { setTextEditStyle(ui->textEditRX, m_config.color_rx_foreground(), m_config.color_rx_background(), m_config.rx_text_font()); setTextEditStyle( ui->extFreeTextMsgEdit, m_config.color_compose_foreground(), m_config.color_compose_background(), m_config.compose_text_font()); ui->extFreeTextMsgEdit->setFont(m_config.compose_text_font(), m_config.color_compose_foreground(), m_config.color_compose_background()); // rehighlight auto d = ui->textEditRX->document(); if (d) { for (int i = 0; i < d->lineCount(); i++) { auto b = d->findBlockByLineNumber(i); switch (b.userState()) { case State::RX: highlightBlock(b, m_config.rx_text_font(), m_config.color_rx_foreground(), QColor(Qt::transparent)); break; case State::TX: highlightBlock(b, m_config.tx_text_font(), m_config.color_tx_foreground(), QColor(Qt::transparent)); break; } } } }); setWindowTitle(program_title()); buildColumnLabelMap(); // Hook up working frequencies. ui->currentFreq->setCursor(QCursor(Qt::PointingHandCursor)); ui->currentFreq->display("14.078 000"); ui->currentFreq->installEventFilter(new EventFilter::MouseButtonPress( [this](QMouseEvent *event) { QMenu *menu = new QMenu(ui->currentFreq); buildFrequencyMenu(menu); menu->popup(event->globalPosition().toPoint()); return true; }, this)); ui->labDialFreqOffset->setCursor(QCursor(Qt::PointingHandCursor)); ui->labDialFreqOffset->installEventFilter(new EventFilter::MouseButtonPress( [this](QMouseEvent *) { on_actionSetOffset_triggered(); return true; }, this)); // Hook up callsign label click to open preferences ui->labCallsign->setCursor(QCursor(Qt::PointingHandCursor)); ui->labCallsign->installEventFilter(new EventFilter::MouseButtonPress( [this](QMouseEvent *) { openSettings(0); return true; }, this)); // hook up configuration signals connect(&m_config, &Configuration::transceiver_update, this, &MainWindow::handle_transceiver_update); connect(&m_config, &Configuration::transceiver_failure, this, &MainWindow::handle_transceiver_failure); connect(&m_config, &Configuration::udp_server_name_changed, m_messageClient, &MessageClient::set_server_name); connect(&m_config, &Configuration::udp_server_port_changed, m_messageClient, &MessageClient::set_server_port); // Disable native JSON client if WSJT-X protocol is enabled on the same // port/address This prevents JSON PING messages from interfering with // WSJT-X binary protocol connect( &m_config, &Configuration::wsjtx_protocol_enabled_changed, this, [this](bool enabled) { if (enabled && m_config.wsjtx_server_port() == m_config.udp_server_port() && m_config.wsjtx_server_name() == m_config.udp_server_name()) { // Disable native JSON client to avoid conflicts with WSJT-X // protocol m_messageClient->set_server_port(0); } else if (!enabled) { // Re-enable native JSON client if WSJT-X is disabled m_messageClient->set_server_port(m_config.udp_server_port()); } }); connect(&m_config, &Configuration::band_schedule_changed, this, [this]() { this->m_bandHopped = true; }); connect(&m_config, &Configuration::auto_switch_bands_changed, this, [this](bool auto_switch_bands) { this->m_bandHopped = this->m_bandHopped || auto_switch_bands; }); connect(&m_config, &Configuration::manual_band_hop_requested, this, &MainWindow::manualBandHop); connect(&m_config, &Configuration::enumerating_audio_devices, [this]() { showStatusMessage(tr("Enumerating audio devices")); }); // set up configurations menu connect(m_multi_settings, &MultiSettings::configurationNameChanged, [this](QString const &name) { if ("Default" != name) { config_label.setText(name); config_label.show(); } else { config_label.hide(); } }); m_multi_settings->create_menu_actions(this, ui->menuConfig); m_configurations_button = m_rigErrorMessageBox.addButton( tr("Configurations..."), QMessageBox::ActionRole); connect(ui->extFreeTextMsgEdit, &QTextEdit::textChanged, [this]() { currentTextChanged(); }); m_guiTimer.setTimerType(Qt::PreciseTimer); m_guiTimer.setSingleShot(true); connect(&m_guiTimer, &QTimer::timeout, this, &MainWindow::guiUpdate); m_guiTimer.start(UI_POLL_INTERVAL_MS); pttReleaseTimer.setTimerType(Qt::PreciseTimer); pttReleaseTimer.setSingleShot(true); connect(&pttReleaseTimer, &QTimer::timeout, this, &MainWindow::stopTx2); logQSOTimer.setSingleShot(true); connect(&logQSOTimer, &QTimer::timeout, this, &MainWindow::on_logQSOButton_clicked); tuneButtonTimer.setSingleShot(true); connect(&tuneButtonTimer, &QTimer::timeout, this, &MainWindow::end_tuning); tuneATU_Timer.setSingleShot(true); connect(&tuneATU_Timer, &QTimer::timeout, this, &MainWindow::stopTuneATU); TxAgainTimer.setSingleShot(true); connect(&TxAgainTimer, &QTimer::timeout, this, &MainWindow::TxAgain); connect(m_wideGraph.data(), &WideGraph::changeFreq, this, &MainWindow::changeFreq); connect(m_wideGraph.data(), &WideGraph::qsy, this, &MainWindow::qsy); // DriftingDateTime management: connect(m_wideGraph.data(), &WideGraph::want_new_drift, &DriftingDateTimeSingleton::getSingleton(), &DriftingDateTimeSingleton::setDrift); // Distribute Drift change: connect(&DriftingDateTimeSingleton::getSingleton(), &DriftingDateTimeSingleton::driftChanged, this, &MainWindow::onDriftChanged); connect(&DriftingDateTimeSingleton::getSingleton(), &DriftingDateTimeSingleton::driftChanged, m_wideGraph.data(), &WideGraph::onDriftChanged); connect(&DriftingDateTimeSingleton::getSingleton(), &DriftingDateTimeSingleton::driftChanged, m_cq_loop, &TxLoop::onDriftChange); connect(&DriftingDateTimeSingleton::getSingleton(), &DriftingDateTimeSingleton::driftChanged, m_hb_loop, &TxLoop::onDriftChange); // HB and CQ loop: // For now, disable HB loop while CQ loop runs and vice versa: connect(m_cq_loop, &TxLoop::nextActivityChanged, this, [this](const QDateTime &) { this->m_hb_loop->onLoopCancel(); }); connect(m_hb_loop, &TxLoop::nextActivityChanged, this, [this](const QDateTime &) { this->m_cq_loop->onLoopCancel(); }); // It is not advisable to send a HB in one period and a CQ in the very next, // or a CQ first and then a HB too soon, so that transmissions of people // interested in the QSO might be drowned. // // We could conceivably devise a clean conflict resolution mechanism // disallowing automatic transmissions of one type if an automatic // transmission of the other type has happened recently. In the absence of // such a mechanism, just do either one or the other. // // People who call CQ are expected to monitor somewhat closely, so they will // have no problems triggering HBs at will. // Propagate tx submode changes to the CQ and HB loop: connect(this, &MainWindow::submodeChanged, this->m_hb_loop, &TxLoop::onModeChange); connect(this, &MainWindow::submodeChanged, this->m_cq_loop, &TxLoop::onModeChange); // When the loops are switched off, tell the UI: connect(m_hb_loop, &TxLoop::canceled, ui->hbMacroButton, [this]() { this->ui->hbMacroButton->setChecked(false); }); connect(m_cq_loop, &TxLoop::canceled, ui->cqMacroButton, [this]() { this->ui->cqMacroButton->setChecked(false); }); // The loops can trigger transmissions. That is what they are for. connect(m_hb_loop, &TxLoop::triggerTxNow, this, [this]() { this->sendHB(); }); connect(m_cq_loop, &TxLoop::triggerTxNow, this, [this]() { this->sendCQ(true); }); // Something like this would be nice to have: // connect(m_config, &Configuration::txDelayChanged, m_cq_loop, // &TxLoop::onTxDelayChange); connect(m_config, // &Configuration::txDelayChanged, m_hb_loop, &TxLoop::onTxDelayChange); But // the pertaining signals are not offered by Configuration, and the code was // somewhat of a beast to get into to change that, so some equivalent is // done in a pedestrian way via our polling routine. That pedestrian code // also handles conversion of incoming tx delay in (double) seconds to // outgoing (qint64) milliseconds. decodeBusy(false); m_msg[0][0] = 0; displayDialFrequency(); readSettings(); // Restore user's setup params { std::lock_guard lock(fftw_mutex); fftwf_import_wisdom_from_filename(wisdomFileName()); } m_networkThread.start(m_networkThreadPriority); m_audioThread.start(m_audioThreadPriority); m_notificationAudioThread.start(m_notificationAudioThreadPriority); m_decoder.start(m_decoderThreadPriority); Q_EMIT startAudioInputStream(m_config.audio_input_device(), m_framesAudioInputBuffered, m_detector, m_config.audio_input_channel()); Q_EMIT initializeAudioOutputStream( m_config.audio_output_device(), AudioDevice::Mono == m_config.audio_output_channel() ? 1 : 2, m_msAudioOutputBuffered); Q_EMIT initializeNotificationAudioOutputStream( m_config.notification_audio_output_device(), m_msAudioOutputBuffered); Q_EMIT transmitFrequency(freq() + m_XIT); enable_DXCC_entity( m_config .DXCC()); // sets text window proportions and (re)inits the logbook // this must be done before initializing the mode as some modes need // to turn off split on the rig e.g. WSPR m_config.transceiver_online(); setupJS8(); Q_EMIT transmitFrequency(freq() + m_XIT); statusChanged(); connect(&minuteTimer, &QTimer::timeout, this, &MainWindow::on_the_minute); minuteTimer.setSingleShot(true); minuteTimer.start(ms_minute_error() + 60 * 1000); QTimer::singleShot(0, this, &MainWindow::checkStartupWarnings); // UI Customizations & Tweaks ui->horizontalLayoutBand->insertSpacing(1, 6); ui->horizontalLayoutBand->insertWidget(2, m_wideGraph.data(), 1); ui->horizontalLayoutBand->insertSpacing(3, 8); // remove disabled menus from the menu bar foreach (auto action, ui->menuBar->actions()) { if (action->isEnabled()) { continue; } ui->menuBar->removeAction(action); } // auto f = findFreeFreqOffset(1000, 2000, 50); // setFreqOffsetForRestore(f, false); ui->actionModeAutoreply->setChecked(m_config.autoreply_on_at_startup()); ui->spotButton->setChecked(m_config.spot_to_reporting_networks()); QActionGroup *modeActionGroup = new QActionGroup(this); ui->actionModeJS8Normal->setActionGroup(modeActionGroup); ui->actionModeJS8Fast->setActionGroup(modeActionGroup); ui->actionModeJS8Turbo->setActionGroup(modeActionGroup); ui->actionModeJS8Slow->setActionGroup(modeActionGroup); ui->actionModeJS8Ultra->setActionGroup(modeActionGroup); ui->modeButton->installEventFilter(new EventFilter::MouseButtonPress( [this](QMouseEvent *event) { ui->menuModeJS8->popup(event->globalPosition().toPoint()); return true; }, this)); if (!JS8_ENABLE_JS8A) ui->actionModeJS8Normal->setVisible(false); if (!JS8_ENABLE_JS8B) ui->actionModeJS8Fast->setVisible(false); if (!JS8_ENABLE_JS8C) ui->actionModeJS8Turbo->setVisible(false); if (!JS8_ENABLE_JS8E) ui->actionModeJS8Slow->setVisible(false); if (!JS8_ENABLE_JS8I) ui->actionModeJS8Ultra->setVisible(false); // prep prepareMonitorControls(); prepareHeartbeatMode(canCurrentModeSendHeartbeat() && ui->actionModeJS8HB->isChecked()); ui->extFreeTextMsgEdit->installEventFilter(new EventFilter::EnterKeyPress( [this](QKeyEvent *const event) { if (event->modifiers() & Qt::ShiftModifier) return false; if (ui->extFreeTextMsgEdit->isReadOnly()) return false; if (ui->extFreeTextMsgEdit->toPlainText().trimmed().isEmpty()) return true; if (!ensureCanTransmit()) return true; if (!ensureCallsignSet(true)) return true; toggleTx(true); return true; }, this)); ui->textEditRX->viewport()->installEventFilter( new EventFilter::MouseButtonDblClick( [this](QMouseEvent *) { QTimer::singleShot(150, this, [this]() { // When we double click the rx window, we send the selected // text to the log dialog when the text could be an snr // value prefixed with a - or +, we extend the selection to // include it. auto textCursor = ui->textEditRX->textCursor(); auto text = textCursor.selectedText(); if (text.isEmpty()) return; auto const start = textCursor.selectionStart(); auto const end = textCursor.selectionEnd(); textCursor.clearSelection(); textCursor.setPosition(start); textCursor.movePosition(QTextCursor::PreviousCharacter, QTextCursor::MoveAnchor); textCursor.movePosition(QTextCursor::NextCharacter, QTextCursor::KeepAnchor, 1 + end - start); if (auto const prev = textCursor.selectedText(); prev.startsWith("-") || prev.startsWith("+")) { ui->textEditRX->setTextCursor(textCursor); text = prev; } m_logDlg->acceptText(text); }); return false; }, this)); auto clearActionSep = new QAction(nullptr); clearActionSep->setSeparator(true); auto clearActionAll = new QAction(QString("Clear All Lists"), nullptr); connect(clearActionAll, &QAction::triggered, this, [this]() { if (QMessageBox::Yes != QMessageBox::question( this, "Clear All Activity", "Are you sure you would like to clear all activity?", QMessageBox::Yes | QMessageBox::No)) { return; } clearActivity(); }); // setup tablewidget context menus auto clearAction1 = new QAction(QString("Clear"), ui->textEditRX); connect(clearAction1, &QAction::triggered, this, [this]() { clearRXActivity(); }); auto saveAction = new QAction(QString("Save As..."), ui->textEditRX); connect(saveAction, &QAction::triggered, this, [this]() { auto writePath = QStandardPaths::writableLocation(QStandardPaths::DocumentsLocation); auto writeDir = QDir(writePath); auto defaultFilename = writeDir.absoluteFilePath( QString("js8call-%1.txt") .arg(DriftingDateTime::currentDateTimeUtc().toString( "yyyyMMdd"))); QString selectedFilter = "*.txt"; auto filename = QFileDialog::getSaveFileName( this, "Save As...", defaultFilename, "Text files (*.txt);; All files (*)", &selectedFilter); if (filename.isEmpty()) { return; } auto text = ui->textEditRX->toPlainText(); QFile f(filename); if (f.open(QIODevice::Truncate | QIODevice::WriteOnly | QIODevice::Text)) { QTextStream stream(&f); stream << text; } }); ui->textEditRX->setContextMenuPolicy(Qt::CustomContextMenu); connect( ui->textEditRX, &QTableWidget::customContextMenuRequested, this, [this, clearAction1, clearActionAll, saveAction](QPoint const &point) { QMenu *menu = new QMenu(ui->textEditRX); buildEditMenu(menu, ui->textEditRX); menu->addSeparator(); menu->addAction(clearAction1); menu->addAction(clearActionAll); menu->addSeparator(); menu->addAction(saveAction); menu->popup(ui->textEditRX->mapToGlobal(point)); }); auto clearAction2 = new QAction(QString("Clear"), ui->extFreeTextMsgEdit); connect(clearAction2, &QAction::triggered, this, [this]() { resetMessage(); m_lastTxMessage.clear(); }); auto restoreAction = new QAction(QString("Restore Previous Message"), ui->extFreeTextMsgEdit); connect(restoreAction, &QAction::triggered, this, [this]() { this->restoreMessage(); }); ui->extFreeTextMsgEdit->setContextMenuPolicy(Qt::CustomContextMenu); connect( ui->extFreeTextMsgEdit, &QTableWidget::customContextMenuRequested, this, [this, clearAction2, clearActionAll, restoreAction](QPoint const &point) { QMenu *menu = new QMenu(ui->extFreeTextMsgEdit); auto selectedCall = callsignSelected(); bool missingCallsign = selectedCall.isEmpty(); buildSuggestionsMenu(menu, ui->extFreeTextMsgEdit, point); restoreAction->setDisabled(m_lastTxMessage.isEmpty()); menu->addAction(restoreAction); auto savedMenu = menu->addMenu("Saved Messages..."); buildSavedMessagesMenu(savedMenu); auto directedMenu = menu->addMenu(QString("Directed to %1...").arg(selectedCall)); directedMenu->setDisabled(missingCallsign); buildQueryMenu(directedMenu, selectedCall); auto relayMenu = menu->addMenu("Relay via..."); relayMenu->setDisabled( ui->extFreeTextMsgEdit->toPlainText().isEmpty() || m_callActivity.isEmpty()); buildRelayMenu(relayMenu); menu->addSeparator(); buildEditMenu(menu, ui->extFreeTextMsgEdit); menu->addSeparator(); menu->addAction(clearAction2); menu->addAction(clearActionAll); menu->popup(ui->extFreeTextMsgEdit->mapToGlobal(point)); displayActivity(true); }); auto clearAction3 = new QAction(QString("Clear"), ui->tableWidgetRXAll); connect(clearAction3, &QAction::triggered, this, [this]() { clearBandActivity(); }); auto removeActivity = new QAction(QString("Remove Activity"), ui->tableWidgetRXAll); connect(removeActivity, &QAction::triggered, this, [this]() { if (ui->tableWidgetRXAll->selectedItems().isEmpty()) { return; } auto selectedItems = ui->tableWidgetRXAll->selectedItems(); int selectedOffset = selectedItems.first()->data(Qt::UserRole).toInt(); m_bandActivity.remove(selectedOffset); displayActivity(true); }); auto logAction = new QAction(QString("Log..."), ui->tableWidgetCalls); connect(logAction, &QAction::triggered, this, &MainWindow::on_logQSOButton_clicked); // Disable default header mouseover and click behaviors, they are confusing // to users because they give the appearance of allowing sorting by header // clicks, which is not actually implemented ui->tableWidgetRXAll->horizontalHeader()->setHighlightSections(false); ui->tableWidgetRXAll->horizontalHeader()->setSectionsClickable(false); ui->tableWidgetRXAll->horizontalHeader()->setContextMenuPolicy( Qt::CustomContextMenu); connect( ui->tableWidgetRXAll->horizontalHeader(), &QHeaderView::customContextMenuRequested, this, [this](QPoint const &point) { QMenu *menu = new QMenu(ui->tableWidgetRXAll); QMenu *sortByMenu = menu->addMenu("Sort By..."); buildBandActivitySortByMenu(sortByMenu); QMenu *showColumnsMenu = menu->addMenu("Show Columns..."); buildShowColumnsMenu(showColumnsMenu, "band"); menu->popup( ui->tableWidgetRXAll->horizontalHeader()->mapToGlobal(point)); }); ui->tableWidgetRXAll->setContextMenuPolicy(Qt::CustomContextMenu); connect( ui->tableWidgetRXAll, &QTableWidget::customContextMenuRequested, this, [this, clearAction3, clearActionAll, removeActivity, logAction](QPoint const &point) { QMenu *menu = new QMenu(ui->tableWidgetRXAll); // clear the selection of the call widget on right click // but only if the table has rows. if (ui->tableWidgetRXAll->rowAt(point.y()) != -1) { ui->tableWidgetCalls->selectionModel()->clearSelection(); } QString selectedCall = callsignSelected(); bool missingCallsign = selectedCall.isEmpty(); bool isAllCall = isAllCallIncluded(selectedCall); int selectedOffset = -1; if (!ui->tableWidgetRXAll->selectedItems().isEmpty()) { auto selectedItems = ui->tableWidgetRXAll->selectedItems(); selectedOffset = selectedItems.first()->data(Qt::UserRole).toInt(); } if (selectedOffset != -1) { auto qsyAction = menu->addAction( QString("Jump to %1Hz").arg(selectedOffset)); connect(qsyAction, &QAction::triggered, this, [this, selectedOffset]() { setFreqOffsetForRestore(selectedOffset, false); }); if (m_wideGraph->filterEnabled()) { auto filterQsyAction = menu->addAction( QString("Center filter at %1Hz").arg(selectedOffset)); connect(filterQsyAction, &QAction::triggered, this, [this, selectedOffset]() { m_wideGraph->setFilterCenter(selectedOffset); }); } auto items = m_bandActivity.value(selectedOffset); if (!items.isEmpty()) { int submode = items.last().submode; auto speed = JS8::Submode::name(submode); if (submode != m_nSubMode) { auto qrqAction = menu->addAction(QString("Jump to %1%2 speed") .arg(speed.left(1)) .arg(speed.mid(1).toLower())); connect(qrqAction, &QAction::triggered, this, [this, submode]() { setSubmode(submode); }); } int tdrift = -int(items.last().tdrift * 1000); auto qtrAction = menu->addAction( QString("Jump to %1 ms time drift").arg(tdrift)); connect(qtrAction, &QAction::triggered, this, [this, tdrift]() { setDrift(tdrift); }); } menu->addSeparator(); } menu->addAction(logAction); logAction->setDisabled(missingCallsign || isAllCall); menu->addSeparator(); auto savedMenu = menu->addMenu("Saved Messages..."); buildSavedMessagesMenu(savedMenu); auto directedMenu = menu->addMenu(QString("Directed to %1...").arg(selectedCall)); directedMenu->setDisabled(missingCallsign); buildQueryMenu(directedMenu, selectedCall); auto relayAction = buildRelayAction(selectedCall); relayAction->setText(QString("Relay via %1...").arg(selectedCall)); relayAction->setDisabled(missingCallsign); menu->addActions({relayAction}); auto deselectAction = menu->addAction(QString("Deselect %1").arg(selectedCall)); deselectAction->setDisabled(missingCallsign); connect(deselectAction, &QAction::triggered, this, [this]() { ui->tableWidgetRXAll->clearSelection(); ui->tableWidgetCalls->clearSelection(); }); menu->addSeparator(); removeActivity->setDisabled(selectedOffset == -1); menu->addAction(removeActivity); menu->addSeparator(); menu->addAction(clearAction3); menu->addAction(clearActionAll); menu->popup(ui->tableWidgetRXAll->mapToGlobal(point)); displayActivity(true); }); auto clearAction4 = new QAction(QString("Clear Entire List"), ui->tableWidgetCalls); connect(clearAction4, &QAction::triggered, this, [this]() { clearCallActivity(); }); auto addStation = new QAction(QString("Add New Station or Group..."), ui->tableWidgetCalls); connect(addStation, &QAction::triggered, this, [this]() { bool ok = false; QString callsign = QInputDialog::getText(this, tr("Add New Station or Group"), tr("Station or Group Callsign:"), QLineEdit::Normal, "", &ok) .toUpper() .trimmed(); if (!ok || callsign.trimmed().isEmpty()) { return; } // if we're adding allcall, turn off allcall avoidance if (callsign == "@ALLCALL") { m_config.set_avoid_allcall(false); } else if (callsign.startsWith("@")) { if (Varicode::isCompoundCallsign(callsign)) { m_config.addGroup(callsign); } else { JS8MessageBox::critical_message( this, QString("%1 is not a valid group").arg(callsign)); } } else { if (Varicode::isValidCallsign(callsign, nullptr)) { CallDetail cd = {}; cd.call = callsign; m_callActivity[callsign] = cd; } else { JS8MessageBox::critical_message( this, QString("%1 is not a valid callsign or group") .arg(callsign)); } } displayActivity(true); }); auto removeStation = new QAction(QString("Remove Station"), ui->tableWidgetCalls); connect(removeStation, &QAction::triggered, this, [this]() { QString selectedCall = callsignSelected(); if (selectedCall.isEmpty()) { return; } if (selectedCall == "@ALLCALL") { m_config.set_avoid_allcall(true); } else if (selectedCall.startsWith("@")) { m_config.removeGroup(selectedCall); } else if (m_callActivity.contains(selectedCall)) { m_callActivity.remove(selectedCall); } displayActivity(true); }); connect(ui->actionShow_Message_Inbox, &QAction::triggered, this, [this]() { QString selectedCall = callsignSelected(); if (selectedCall.isEmpty()) { selectedCall = "%"; } Inbox inbox(inboxPath()); if (!inbox.open()) { return; } QList> msgs; msgs.append( inbox.values("STORE", "$.params.TO", selectedCall, 0, 1000)); msgs.append( inbox.values("READ", "$.params.FROM", selectedCall, 0, 1000)); foreach (auto pair, inbox.values("UNREAD", "$.params.FROM", selectedCall, 0, 1000)) { msgs.append(pair); // mark as read auto msg = pair.second; msg.setType("READ"); inbox.set(pair.first, msg); } std::stable_sort( msgs.begin(), msgs.end(), [](QPair const &a, QPair const &b) { return QVariant::compare(a.second.params().value("UTC"), b.second.params().value("UTC")) == QPartialOrdering::Greater; }); auto mw = new MessageWindow(this); connect(mw, &MessageWindow::finished, this, [this](int) { refreshInboxCounts(); displayCallActivity(); }); connect(mw, &MessageWindow::deleteMessage, this, [this](int id) { Inbox inbox(inboxPath()); if (!inbox.open()) { return; } inbox.del(id); }); connect(mw, &MessageWindow::replyMessage, this, [this, mw](const QString &text) { addMessageText(text, true, true); refreshInboxCounts(); displayCallActivity(); mw->close(); }); mw->setCall(selectedCall); mw->populateMessages(msgs); mw->show(); }); auto historyAction = new QAction(QString("Show Message Inbox..."), ui->tableWidgetCalls); connect(historyAction, &QAction::triggered, ui->actionShow_Message_Inbox, &QAction::trigger); auto localMessageAction = new QAction(QString("Store Message..."), ui->tableWidgetCalls); connect(localMessageAction, &QAction::triggered, this, [this]() { QString selectedCall = callsignSelected(); if (selectedCall.isEmpty()) { return; } auto m = new MessageReplyDialog(this); m->setWindowTitle("Message"); m->setLabel( QString("Store this message locally for %1:").arg(selectedCall)); if (m->exec() != QMessageBox::Accepted) { return; } CommandDetail d = {}; d.cmd = " MSG "; d.to = selectedCall; d.from = m_config.my_callsign(); d.relayPath = d.from; d.text = m->textValue(); d.utcTimestamp = DriftingDateTime::currentDateTimeUtc(); d.submode = m_nSubMode; addCommandToStorage("STORE", d); }); // Disable default header mouseover and click behaviors, they are confusing // to users because they give the appearance of allowing sorting by header // clicks, which is not actually implemented ui->tableWidgetCalls->horizontalHeader()->setHighlightSections(false); ui->tableWidgetCalls->horizontalHeader()->setSectionsClickable(false); ui->tableWidgetCalls->horizontalHeader()->setContextMenuPolicy( Qt::CustomContextMenu); connect( ui->tableWidgetCalls->horizontalHeader(), &QHeaderView::customContextMenuRequested, this, [this](QPoint const &point) { QMenu *menu = new QMenu(ui->tableWidgetCalls); QMenu *sortByMenu = menu->addMenu("Sort By..."); buildCallActivitySortByMenu(sortByMenu); QMenu *showColumnsMenu = menu->addMenu("Show Columns..."); buildShowColumnsMenu(showColumnsMenu, "call"); menu->popup( ui->tableWidgetCalls->horizontalHeader()->mapToGlobal(point)); }); ui->tableWidgetCalls->setContextMenuPolicy(Qt::CustomContextMenu); connect( ui->tableWidgetCalls, &QTableWidget::customContextMenuRequested, this, [this, logAction, historyAction, localMessageAction, clearAction4, clearActionAll, addStation, removeStation](QPoint const &point) { QMenu *menu = new QMenu(ui->tableWidgetCalls); // clear the selection of the call widget on right click // but only if the table has rows. if (ui->tableWidgetCalls->rowAt(point.y()) != -1) { ui->tableWidgetRXAll->selectionModel()->clearSelection(); } QString selectedCall = callsignSelected(); bool isAllCall = isAllCallIncluded(selectedCall); // bool isGroupCall = isGroupCallIncluded(selectedCall); bool missingCallsign = selectedCall.isEmpty(); if (!missingCallsign && !isAllCall) { int selectedOffset = m_callActivity[selectedCall].offset; if (selectedOffset != -1) { auto qsyAction = menu->addAction( QString("Jump to %1Hz").arg(selectedOffset)); connect(qsyAction, &QAction::triggered, this, [this, selectedOffset]() { setFreqOffsetForRestore(selectedOffset, false); }); if (m_wideGraph->filterEnabled()) { auto filterQsyAction = menu->addAction(QString("Center filter at %1Hz") .arg(selectedOffset)); connect(filterQsyAction, &QAction::triggered, this, [this, selectedOffset]() { m_wideGraph->setFilterCenter( selectedOffset); }); } int submode = m_callActivity[selectedCall].submode; auto speed = JS8::Submode::name(submode); if (submode != m_nSubMode) { auto qrqAction = menu->addAction(QString("Jump to %1%2 speed") .arg(speed.left(1)) .arg(speed.mid(1).toLower())); connect(qrqAction, &QAction::triggered, this, [this, submode]() { setSubmode(submode); }); } int tdrift = -int(m_callActivity[selectedCall].tdrift * 1000); auto qtrAction = menu->addAction( QString("Jump to %1 ms time drift").arg(tdrift)); connect(qtrAction, &QAction::triggered, this, [this, tdrift]() { setDrift(tdrift); }); menu->addSeparator(); } } menu->addAction(logAction); logAction->setDisabled(missingCallsign || isAllCall); menu->addAction(historyAction); historyAction->setDisabled(missingCallsign || isAllCall || !hasMessageHistory(selectedCall)); menu->addAction(localMessageAction); localMessageAction->setDisabled(missingCallsign || isAllCall); menu->addSeparator(); auto savedMenu = menu->addMenu("Saved Messages..."); buildSavedMessagesMenu(savedMenu); auto directedMenu = menu->addMenu(QString("Directed to %1...").arg(selectedCall)); directedMenu->setDisabled(missingCallsign); buildQueryMenu(directedMenu, selectedCall); auto relayAction = buildRelayAction(selectedCall); relayAction->setText(QString("Relay via %1...").arg(selectedCall)); relayAction->setDisabled(missingCallsign || isAllCall); menu->addActions({relayAction}); auto deselect = menu->addAction(QString("Deselect %1").arg(selectedCall)); deselect->setDisabled(missingCallsign); connect(deselect, &QAction::triggered, this, [this]() { ui->tableWidgetRXAll->clearSelection(); ui->tableWidgetCalls->clearSelection(); }); menu->addSeparator(); menu->addAction(addStation); removeStation->setDisabled(missingCallsign); removeStation->setText(selectedCall.startsWith("@") ? "Remove This Group" : "Remove This Station"); menu->addAction(removeStation); menu->addSeparator(); menu->addAction(clearAction4); menu->addAction(clearActionAll); menu->popup(ui->tableWidgetCalls->mapToGlobal(point)); }); connect(ui->tableWidgetRXAll->selectionModel(), &QItemSelectionModel::selectionChanged, this, &MainWindow::tableSelectionChanged); connect(ui->tableWidgetCalls->selectionModel(), &QItemSelectionModel::selectionChanged, this, &MainWindow::tableSelectionChanged); auto p = ui->tableWidgetRXAll->palette(); p.setColor(QPalette::Inactive, QPalette::Highlight, p.color(QPalette::Active, QPalette::Highlight)); ui->tableWidgetRXAll->setPalette(p); p = ui->tableWidgetCalls->palette(); p.setColor(QPalette::Inactive, QPalette::Highlight, p.color(QPalette::Active, QPalette::Highlight)); ui->tableWidgetCalls->setPalette(p); ui->hbMacroButton->setContextMenuPolicy(Qt::CustomContextMenu); connect(ui->hbMacroButton, &QPushButton::customContextMenuRequested, this, [this](QPoint const &point) { QMenu *menu = new QMenu(ui->hbMacroButton); buildHeartbeatMenu(menu); menu->popup(ui->hbMacroButton->mapToGlobal(point)); }); ui->cqMacroButton->setContextMenuPolicy(Qt::CustomContextMenu); connect(ui->cqMacroButton, &QPushButton::customContextMenuRequested, this, [this](QPoint const &point) { QMenu *menu = new QMenu(ui->cqMacroButton); buildCQMenu(menu); menu->popup(ui->cqMacroButton->mapToGlobal(point)); }); // Don't block heartbeat's first run... m_lastTxStartTime = DriftingDateTime::currentDateTimeUtc().addSecs(-300); // But do block the decoder's first run until 50% through next transmit // period m_lastTxStopTime = nextTransmitCycle().addSecs(-m_TRperiod / 2); int width = 75; /* QList btns; foreach(auto child, ui->buttonGrid->children()){ if(!child->isWidgetType()){ continue; } if(!child->objectName().contains("Button")){ continue; } auto b = qobject_cast(child); width = qMax(width, b->geometry().width()); btns.append(b); } */ foreach (auto child, ui->buttonGrid->children()) { if (!child->isWidgetType()) { continue; } if (!child->objectName().contains("Button")) { continue; } auto b = qobject_cast(child); b->setCursor(QCursor(Qt::PointingHandCursor)); } auto buttonLayout = ui->buttonGrid->layout(); auto gridButtonLayout = qobject_cast(buttonLayout); gridButtonLayout->setColumnMinimumWidth(0, width); gridButtonLayout->setColumnMinimumWidth(1, width); gridButtonLayout->setColumnMinimumWidth(2, width); gridButtonLayout->setColumnStretch(0, 1); gridButtonLayout->setColumnStretch(1, 1); gridButtonLayout->setColumnStretch(2, 1); // dial up and down buttons sizes ui->dialFreqUpButton->setFixedSize(30, 24); ui->dialFreqDownButton->setFixedSize(30, 24); // Prepare spotting configuration... prepareApi(); prepareSpotting(); displayActivity(true); m_txTextDirtyDebounce.setSingleShot(true); connect(&m_txTextDirtyDebounce, &QTimer::timeout, this, &MainWindow::refreshTextDisplay); qCDebug(mainwindow_js8) << "Main window constructor has done all connect (aka plumbing) work."; m_TxDelay = m_config.txDelay(); m_hb_loop->onTxDelayChange(llround(m_TxDelay * 1000.0)); m_cq_loop->onTxDelayChange(llround(m_TxDelay * 1000.0)); m_hb_loop->onPlumbingCompleted(); m_cq_loop->onPlumbingCompleted(); DriftingDateTimeSingleton::getSingleton().onPlumbingCompleted(); qCDebug(mainwindow_js8) << "Initialization with onPlumbingCompleted() has completed."; QTimer::singleShot(500, this, &MainWindow::initializeDummyData); QTimer::singleShot(500, this, &MainWindow::initializeGroupMessageDummyData); // this must be the last statement of constructor if (!m_valid) throw std::runtime_error{"Fatal initialization exception"}; } void MainWindow::checkVersion(bool const alertOnUpToDate) { auto m = new QNetworkAccessManager(this); connect(m, &QNetworkAccessManager::finished, this, [this, alertOnUpToDate](QNetworkReply *reply) { if (reply->error()) { qCDebug(mainwindow_js8) << "Checking for Updates Error:" << reply->errorString(); return; } QString content = reply->readAll().trimmed(); auto const currentVersion = QVersionNumber::fromString(version()); auto const networkVersion = QVersionNumber::fromString(content); qCDebug(mainwindow_js8) << "Checking Version" << currentVersion << "with" << networkVersion; if (currentVersion < networkVersion) { SelfDestructMessageBox *m = new SelfDestructMessageBox( 60, "New Updates Available", QString("A new version (%1) of JS8Call is now " "available. Please see the GitHub " "Releases for more details.") .arg(content), QMessageBox::Information, QMessageBox::Ok, QMessageBox::Ok, false, this); m->show(); } else if (alertOnUpToDate) { SelfDestructMessageBox *m = new SelfDestructMessageBox( 60, "No Updates Available", QString("Your version (%1) of JS8Call is up-to-date.") .arg(version()), QMessageBox::Information, QMessageBox::Ok, QMessageBox::Ok, false, this); m->show(); } }); qCDebug(mainwindow_js8) << "Checking for Updates..."; QUrl url("https://github.com/JS8Call-improved/JS8Call-improved/releases/" "latest/download/version.txt"); QNetworkRequest r(url); m->get(r); } void MainWindow::checkStartupWarnings() { if (m_config.check_for_updates()) { checkVersion(false); } ensureCallsignSet(false); } void MainWindow::initializeDummyData() { if (!QApplication::applicationName().contains("dummy")) { return; } #if 0 Codeword all; foreach(CodewordPair p, JSC::compress("")){ all.append(p.first); } qCDebug(mainwindow_js8) << all; qCDebug(mainwindow_js8) << JSC::decompress(all) << (JSC::decompress(all) == "HELLO WORLD "); exit(-1); #endif #if 0 auto t = new QTimer(this); t->setInterval(150); connect(t, &QTimer::timeout, this, [this](){ if(!ui->extFreeTextMsgEdit->hasFocus()){ return; } auto c = ui->extFreeTextMsgEdit->textCursor(); int pos = qMin(c.selectionStart(), c.selectionEnd()); if(pos <= 5 && c.selectionStart() != c.selectionEnd()){ c.clearSelection(); ui->extFreeTextMsgEdit->setTextCursor(c); } }); t->start(); auto kpe = new KeyPressEater(); connect(kpe, &KeyPressEater::keyPressed, this, [this](QObject *obj, QKeyEvent * e, bool *pProcessed){ auto t = e->text(); auto c = ui->extFreeTextMsgEdit->textCursor(); int pos = qMin(c.selectionStart(), c.selectionEnd()); if(e->key() == Qt::Key_Escape){ *pProcessed = false; return; } QTextCursor cc(c); cc.setPosition(5); cc.movePosition(QTextCursor::NextWord); int cpos = qMax(cc.selectionStart(), cc.selectionEnd()); if(e->key() == Qt::Key_Backspace && e->modifiers().testFlag(Qt::ControlModifier) && pos < cpos){ *pProcessed = true; return; } if((e->key() == Qt::Key_Backspace && pos <= 5) || (e->key() == Qt::Key_Delete && pos < 5)){ *pProcessed = true; return; } if(e->key() == Qt::Key_V && e->modifiers().testFlag(Qt::ControlModifier) && pos <= 5){ *pProcessed = true; return; } if(!t.isEmpty() && pos < 5){ *pProcessed = true; return; } }); ui->extFreeTextMsgEdit->installEventFilter(kpe); connect(ui->extFreeTextMsgEdit, &QTextEdit::copyAvailable, this, [this](bool available){ if(!available){ return; } auto c = ui->extFreeTextMsgEdit->textCursor(); qCDebug(mainwindow_js8) << "select" << c.selectionStart() << c.selectionEnd(); int pos = qMin(c.selectionStart(), c.selectionEnd()); if(pos <= 5){ auto text = c.selectedText(); if(!text.isEmpty()){ c.clearSelection(); ui->extFreeTextMsgEdit->setTextCursor(c); } } }); /* connect(ui->extFreeTextMsgEdit->document(), &QTextDocument::cursorPositionChanged, this, [this](const QTextCursor &){ auto c = ui->extFreeTextMsgEdit->textCursor(); int pos = qMin(c.selectionStart(), c.selectionEnd()); if(pos <= 5){ auto text = c.selectedText(); if(!text.isEmpty()){ c.clearSelection(); ui->extFreeTextMsgEdit->setTextCursor(c); } } }); connect(ui->extFreeTextMsgEdit, &QTextEdit::cursorPositionChanged, this, [this](){ auto c = ui->extFreeTextMsgEdit->textCursor(); int pos = qMin(c.selectionStart(), c.selectionEnd()); if(pos <= 5){ auto text = c.selectedText(); if(!text.isEmpty()){ c.clearSelection(); ui->extFreeTextMsgEdit->setTextCursor(c); } } }); */ /*connect(ui->extFreeTextMsgEdit->document(), &QTextDocument::contentsChange, this, [this](int from, int removed, int added){ if(from < 5 && removed == 1){ ui->extFreeTextMsgEdit->document()->blockSignals(true); ui->extFreeTextMsgEdit->document()->undo(); ui->extFreeTextMsgEdit->document()->clearUndoRedoStacks(QTextDocument::RedoStack); ui->extFreeTextMsgEdit->document()->blockSignals(false); } });*/ ui->extFreeTextMsgEdit->setText("HELLO BRAVE NEW WORLD"); auto c = ui->extFreeTextMsgEdit->textCursor(); c.setPosition(0); c.setPosition(5, QTextCursor::KeepAnchor); auto f = c.charFormat(); f.setFontStrikeOut(true); c.setCharFormat(f); #endif // this causes a segfault! // processDecodedLine("223000 -15 -0.3 1681 B 6t++yk+aJbaE 6 \n"); ui->extFreeTextMsgEdit->setPlainText("HELLOBRAVE NEW WORLD"); ui->extFreeTextMsgEdit->setCharsSent(6); logHeardGraph("KN4CRD", "OH8STN"); logHeardGraph("KN4CRD", "K0OG"); logHeardGraph("K0OG", "KN4CRD"); auto path = QDir::toNativeSeparators( m_config.writeable_data_dir().absoluteFilePath(QString("test.db3"))); auto inbox = Inbox(path); if (inbox.open()) { qCDebug(mainwindow_js8) << "test inbox opened" << inbox.count("test", "$", "%") << "messages"; // int i = inbox.append(Message("test", "booya1")); int i = inbox.append(Message("test", "booya2")); qCDebug(mainwindow_js8) << "i" << i; qCDebug(mainwindow_js8) << inbox.set(i, Message("test", "booya3")); auto m = inbox.value(i); qCDebug(mainwindow_js8) << QString(m.toJson()); qCDebug(mainwindow_js8) << inbox.del(i); foreach (auto pair, inbox.values("test", "$", "%", 0, 5)) { qCDebug(mainwindow_js8) << pair.first << QString(pair.second.toJson()); } } auto d = DecodedText("SN5-lUuJkby0", Varicode::JS8CallFirst, 1); qCDebug(mainwindow_js8) << "KN4CRD: K0OG ===>" << d.message(); // qCDebug(mainwindow_js8) << Varicode::isValidCallsign("@GROUP1", nullptr); // qCDebug(mainwindow_js8) << Varicode::packAlphaNumeric50("VE7/KN4CRD"); // qCDebug(mainwindow_js8) << // Varicode::unpackAlphaNumeric50(Varicode::packAlphaNumeric50("VE7/KN4CRD")); // qCDebug(mainwindow_js8) << // Varicode::unpackAlphaNumeric50(Varicode::packAlphaNumeric50("@GROUP/42")); // qCDebug(mainwindow_js8) << // Varicode::unpackAlphaNumeric50(Varicode::packAlphaNumeric50("SP1ATOM")); if (!m_config.my_groups().contains("@GROUP42")) { m_config.addGroup("@GROUP42"); } QList calls = {"KN4CRD", "VE7/KN4CRD", "KN4CRD/P", "KC9QNE", "KI6SSI", "K0OG", "LB9YH", "VE7/LB9YH", "M0IAX", "N0JDS", "OH8STN", "VA3OSO", "VK1MIC", "W0FW"}; auto dt = DriftingDateTime::currentDateTimeUtc().addSecs(-300); int i = 0; foreach (auto call, calls) { CallDetail cd = {}; cd.call = call; cd.through = i == 2 ? "KN4CRD" : ""; cd.dial = 7078000; cd.offset = 500 + 100 * i; cd.snr = i == 3 ? -100 : i; cd.ackTimestamp = i == 1 ? dt.addSecs(-900) : QDateTime{}; cd.utcTimestamp = dt; cd.grid = i == 5 ? "J042" : i == 6 ? " FN42FN42FN" : ""; cd.tdrift = 0.1 * i; cd.submode = i % 3; logCallActivity(cd, false); ActivityDetail ad = {}; ad.bits = Varicode::JS8CallFirst | Varicode::JS8CallLast; ad.snr = i == 3 ? -100 : i; ad.dial = 7078000; ad.offset = 500 + 100 * i; ad.text = QString("%1: %2 TEST MESSAGE") .arg(call) .arg(m_config.my_callsign()); ad.utcTimestamp = dt; ad.submode = cd.submode; m_bandActivity[500 + 100 * i] = {ad}; markOffsetDirected(500 + 100 * i, false); i++; } ActivityDetail adHB1 = {}; adHB1.bits = Varicode::JS8CallFirst; adHB1.snr = 0; adHB1.dial = 7078000; adHB1.offset = 750; adHB1.text = QString("KN4CRD: HB AUTO EM73"); adHB1.utcTimestamp = DriftingDateTime::currentDateTimeUtc(); adHB1.submode = Varicode::JS8CallNormal; m_bandActivity[750].append(adHB1); ActivityDetail adHB2 = {}; adHB2.bits = Varicode::JS8CallLast; adHB2.snr = 0; adHB2.dial = 7078000; adHB2.offset = 750; adHB2.text = QString(" MSG ID 1"); adHB2.utcTimestamp = DriftingDateTime::currentDateTimeUtc(); adHB2.submode = Varicode::JS8CallNormal; m_bandActivity[750].append(adHB2); CommandDetail cmd = {}; cmd.cmd = ">"; cmd.to = m_config.my_callsign(); cmd.from = "N0JDS"; cmd.relayPath = "N0JDS>OH8STN"; cmd.text = "HELLO BRAVE SOUL"; cmd.utcTimestamp = dt; cmd.submode = Varicode::JS8CallNormal; addCommandToMyInbox(cmd); QString eot = m_config.eot(); displayTextForFreq(QString("KN4CRD: @ALLCALL? %1 ").arg(eot), 42, DriftingDateTime::currentDateTimeUtc().addSecs(-315), true, true, true); displayTextForFreq(QString("J1Y: KN4CRD SNR -05 %1 ").arg(eot), 42, DriftingDateTime::currentDateTimeUtc().addSecs(-300), false, true, true); displayTextForFreq(QString("HELLO BRAVE NEW WORLD %1 ").arg(eot), 42, DriftingDateTime::currentDateTimeUtc().addSecs(-300), false, true, true); auto now = DriftingDateTime::currentDateTimeUtc(); displayTextForFreq(QString("KN4CRD: JY1 ACK -12 %1 ").arg(eot), 780, now, false, true, true); displayTextForFreq(QString("KN4CRD: JY1 ACK -12 %1 ").arg(eot), 780, now, false, true, true); // should be hidden (duplicate) displayTextForFreq(QString("OH8STN: JY1 ACK -12 %1 ").arg(eot), 780, now, false, true, true); displayTextForFreq(QString("KN4CRD: JY1 ACK -10 %1 ").arg(eot), 800, now, false, true, true); displayTextForFreq(QString("KN4CRD: JY1 ACK -12 %1 ").arg(eot), 780, now.addSecs(120), false, true, true); displayTextForFreq(QString("HELLO\\nBRAVE\\nNEW\\nWORLD %1 ").arg(eot), 1500, now, false, true, true); displayActivity(true); } void MainWindow::initializeGroupMessageDummyData() { if (!QApplication::applicationName().contains("dummy")) { return; } if (!m_config.my_groups().contains("@GROUP42")) { m_config.addGroup("@GROUP42"); } auto dt = DriftingDateTime::currentDateTimeUtc().addSecs(-300); CommandDetail cmd = {}; cmd.cmd = " MSG TO:"; cmd.from = "KN4CRD"; cmd.to = "@GROUP42"; cmd.utcTimestamp = dt; cmd.submode = Varicode::JS8CallNormal; cmd.text = "@GROUP42 TEST MESSAGE TO GROUP"; m_rxCommandQueue.append(cmd); processCommandActivity(); dt = DriftingDateTime::currentDateTimeUtc().addSecs(-150); CommandDetail cmd1 = {}; cmd1.cmd = " MSG TO:"; cmd1.from = "KN4CRD"; cmd1.to = "@GROUP42"; cmd1.utcTimestamp = dt; cmd1.submode = Varicode::JS8CallNormal; cmd1.text = "@GROUP42 ANOTHER TEST MESSAGE TO GROUP"; m_rxCommandQueue.append(cmd1); processCommandActivity(); CommandDetail cmd2 = {}; cmd2.cmd = " QUERY MSGS"; cmd2.from = "W1AW"; cmd2.to = "@GROUP42"; cmd2.utcTimestamp = DriftingDateTime::currentDateTimeUtc(); cmd2.submode = Varicode::JS8CallNormal; m_rxCommandQueue.append(cmd2); processCommandActivity(); int mid = getNextGroupMessageIdForCallsign("@GROUP42", "W1AW"); qCDebug(mainwindow_js8) << "Testing group messaging"; qCDebug(mainwindow_js8) << "Test message ID: " << mid; std::string textString = "MSG "; textString += std::to_string(mid); qCDebug(mainwindow_js8) << "Text string: " << textString.c_str(); CommandDetail cmd3 = {}; cmd3.cmd = " QUERY"; cmd3.from = "W1AW"; cmd3.to = "@GROUP42"; cmd3.utcTimestamp = DriftingDateTime::currentDateTimeUtc(); cmd3.submode = Varicode::JS8CallNormal; cmd3.text = textString.c_str(); m_rxCommandQueue.append(cmd3); processCommandActivity(); dt = DriftingDateTime::currentDateTimeUtc().addSecs(-300); CommandDetail cmd4 = {}; cmd4.cmd = " MSG TO:"; cmd4.from = "KN4CRD"; cmd4.to = "K4RWR"; cmd4.utcTimestamp = dt; cmd4.submode = Varicode::JS8CallNormal; cmd4.text = "W1AW TEST MESSAGE TO STATION"; m_rxCommandQueue.append(cmd4); processCommandActivity(); dt = DriftingDateTime::currentDateTimeUtc().addSecs(-150); CommandDetail cmd5 = {}; cmd5.cmd = " MSG TO:"; cmd5.from = "KN4CRD"; cmd5.to = "K4RWR"; cmd5.utcTimestamp = dt; cmd5.submode = Varicode::JS8CallNormal; cmd5.text = "W1AW ANOTHER TEST MESSAGE TO STATION"; m_rxCommandQueue.append(cmd5); processCommandActivity(); CommandDetail cmd6 = {}; cmd6.cmd = " QUERY MSGS"; cmd6.from = "W1AW"; cmd6.to = "K4RWR"; cmd6.utcTimestamp = DriftingDateTime::currentDateTimeUtc(); cmd6.submode = Varicode::JS8CallNormal; m_rxCommandQueue.append(cmd6); processCommandActivity(); mid = getNextMessageIdForCallsign("W1AW"); qCDebug(mainwindow_js8) << "Testing group messaging"; qCDebug(mainwindow_js8) << "Test message ID: " << mid; textString = "MSG "; textString += std::to_string(mid); qCDebug(mainwindow_js8) << "Text string: " << textString.c_str(); CommandDetail cmd7 = {}; cmd7.cmd = " QUERY"; cmd7.from = "W1AW"; cmd7.to = "K4RWR"; cmd7.utcTimestamp = DriftingDateTime::currentDateTimeUtc(); cmd7.submode = Varicode::JS8CallNormal; cmd7.text = textString.c_str(); m_rxCommandQueue.append(cmd7); processCommandActivity(); displayActivity(true); } void MainWindow::initialize_fonts() { set_application_font(m_config.text_font()); setTextEditFont(ui->textEditRX, m_config.rx_text_font()); setTextEditFont(ui->extFreeTextMsgEdit, m_config.tx_text_font()); displayActivity(true); } void MainWindow::on_the_minute() { if (minuteTimer.isSingleShot()) { minuteTimer.setSingleShot(false); minuteTimer.start(60 * 1000); // run free } else { auto const &ms_error = ms_minute_error(); if (qAbs(ms_error) > 1000) // keep drift within +-1s { minuteTimer.setSingleShot(true); minuteTimer.start(ms_error + 60 * 1000); } } if (m_config.watchdog()) { incrementIdleTimer(); } else { tx_watchdog(false); } } void MainWindow::tryBandHop() { // see if we need to hop bands... if (!m_config.auto_switch_bands()) { return; } // make sure we're not transmitting if (isMessageQueuedForTransmit()) { return; } // get the current band auto dialFreq = dialFrequency(); auto currentBand = m_config.bands()->find(dialFreq); // get the stations list auto stations = m_config.stations()->station_list(); // order stations by (switch_at, switch_until) time tuple std::stable_sort( stations.begin(), stations.end(), [](StationList::Station const &a, StationList::Station const &b) { return (a.switch_at_ < b.switch_at_) || (a.switch_at_ == b.switch_at_ && a.switch_until_ < b.switch_until_); }); // we just set the date to a known y/m/d to make the comparisons easier QDateTime d = DriftingDateTime::currentDateTimeUtc(); d.setDate(QDate(2000, 1, 1)); QDateTime startOfDay = QDateTime(QDate(2000, 1, 1), QTime(0, 0), QTimeZone::utc()); QDateTime endOfDay = QDateTime(QDate(2000, 1, 1), QTime(23, 59, 59, 999), QTimeZone::utc()); StationList::Station *hopStation = nullptr; // See if we can find a needed band switch... // In the case of overlapping windows, choose the latest one foreach (auto station, stations) { // we can switch to this frequency if we're in the time range, inclusive // of switch_at, exclusive of switch_until and if we are switching to a // different frequency than the last hop. this allows us to switch bands // at that time, but then later we can later switch to a different band // if needed without the automatic band switching to take over bool inTimeRange = ((station.switch_at_ <= d && d <= station.switch_until_) || // <- normal range, 12-16 && 6-8, // evaluated as 12 <= d <= 16 || 6 // <= d <= 8 (station.switch_until_ < station.switch_at_ && ( // <- say for a range of 12->2 & 2->12; 12->2, (station.switch_at_ <= d && d <= endOfDay) || // should be evaluated as 12 <= d <= // 23:59 || 00:00 <= d <= 2 (startOfDay <= d && d <= station.switch_until_)))); if (inTimeRange) { delete hopStation; hopStation = new StationList::Station(station); } } // If we have a candidate station, see if the hop is valid, and if so, do it if (hopStation != nullptr) { bool noOverride = (m_bandHopped || (!m_bandHopped && hopStation->frequency_ != m_bandHoppedFreq)); bool freqIsDifferent = (hopStation->frequency_ != dialFreq); bool canSwitch = (noOverride && freqIsDifferent); // switch, if we can and the band is different than our current band if (canSwitch) { Frequency frequency = hopStation->frequency_; m_bandHopped = false; m_bandHoppedFreq = frequency; SelfDestructMessageBox *m = new SelfDestructMessageBox( 30, "Scheduled Frequency Change", QString("A scheduled frequency change has arrived. The rig " "frequency will be changed to %1 MHz in %2 second(s).") .arg(Radio::frequency_MHz_string(frequency)), QMessageBox::Information, QMessageBox::Ok | QMessageBox::Cancel, QMessageBox::Ok, true, this); connect(m, &SelfDestructMessageBox::finished, this, [this, m, frequency]() { if (m->result() == QMessageBox::Ok) { m_bandHopped = true; setRig(frequency); } m->deleteLater(); }); m->show(); #if 0 // TODO: jsherer - this is totally a hack because of the signal that gets emitted to clearActivity on band change... QTimer *t = new QTimer(this); t->setInterval(250); t->setSingleShot(true); connect(t, &QTimer::timeout, this, [this, frequency, dialFreq](){ auto message = QString("Scheduled frequency switch from %1 MHz to %2 MHz"); message = message.arg(Radio::frequency_MHz_string(dialFreq)); message = message.arg(Radio::frequency_MHz_string(frequency)); writeNoticeTextToUI(DriftingDateTime::currentDateTimeUtc(), message); }); t->start(); #endif return; } delete hopStation; } } void MainWindow::manualBandHop(const StationList::Station station) { // make sure we're not transmitting if (isMessageQueuedForTransmit()) { return; } Frequency frequency = station.frequency_; m_bandHopped = true; m_bandHoppedFreq = frequency; setRig(frequency); } //--------------------------------------------------- MainWindow destructor MainWindow::~MainWindow() { { std::lock_guard lock(fftw_mutex); fftwf_export_wisdom_to_filename(wisdomFileName()); } m_networkThread.quit(); m_networkThread.wait(); m_audioThread.quit(); m_audioThread.wait(); m_notificationAudioThread.quit(); m_notificationAudioThread.wait(); m_decoder.quit(); remove_child_from_event_filter(this); } //-------------------------------------------------------- writeSettings() void MainWindow::writeSettings() { m_settings->beginGroup("MainWindow"); m_settings->setValue("geometry", saveGeometry()); m_settings->setValue("geometryNoControls", m_geometryNoControls); m_settings->setValue("state", saveState()); m_settings->setValue("MainSplitter", ui->mainSplitter->saveState()); m_settings->setValue("TextHorizontalSplitter", ui->textHorizontalSplitter->saveState()); m_settings->setValue("BandActivityVisible", ui->tableWidgetRXAll->isVisible()); m_settings->setValue("BandHBActivityVisible", ui->actionShow_Band_Heartbeats_and_ACKs->isChecked()); m_settings->setValue("TextVerticalSplitter", ui->textVerticalSplitter->saveState()); m_settings->setValue("TimeDrift", DriftingDateTime::drift()); m_settings->setValue("ShowTooltips", ui->actionShow_Tooltips->isChecked()); m_settings->setValue("ShowStatusbar", ui->statusBar->isVisible()); m_settings->setValue("RXActivity", ui->textEditRX->toHtml()); m_settings->endGroup(); m_settings->beginGroup("Common"); m_settings->setValue("Freq", freq()); m_settings->setValue("SubMode", m_nSubMode); m_settings->setValue("SubModeHB", ui->actionModeJS8HB->isChecked()); m_settings->setValue("SubModeHBAck", ui->actionHeartbeatAcknowledgements->isChecked()); m_settings->setValue("SubModeMultiDecode", ui->actionModeMultiDecoder->isChecked()); m_settings->setValue("DialFreq", QVariant::fromValue(m_lastMonitoredFrequency)); m_settings->setValue("OutAttenuation", ui->outAttenuation->value()); m_settings->setValue("pwrBandTxMemory", m_pwrBandTxMemory); m_settings->setValue("pwrBandTuneMemory", m_pwrBandTuneMemory); m_settings->setValue("SortBy", QVariant(m_sortCache)); m_settings->setValue("ShowColumns", QVariant(m_showColumnsCache)); m_settings->setValue("HBInterval", m_hbInterval); m_settings->setValue("CQInterval", m_cqInterval); // TODO: jsherer - need any other customizations? /*m_settings->setValue("PanelLeftGeometry", ui->tableWidgetRXAll->geometry()); m_settings->setValue("PanelRightGeometry", ui->tableWidgetCalls->geometry()); m_settings->setValue("PanelTopGeometry", ui->extFreeTextMsg->geometry()); m_settings->setValue("PanelBottomGeometry", ui->extFreeTextMsgEdit->geometry()); m_settings->setValue("PanelWaterfallGeometry", ui->bandHorizontalWidget->geometry());*/ // m_settings->setValue("MainSplitter", // QVariant::fromValue(ui->mainSplitter->sizes())); m_settings->endGroup(); auto now = DriftingDateTime::currentDateTimeUtc(); int callsignAging = m_config.callsign_aging(); m_settings->beginGroup("CallActivity"); m_settings->remove(""); // remove all keys in current group foreach (auto cd, m_callActivity.values()) { if (cd.call.trimmed().isEmpty()) { continue; } if (callsignAging && cd.utcTimestamp.secsTo(now) / 60 >= callsignAging) { continue; } m_settings->setValue( cd.call.trimmed(), QVariantMap{ {"snr", QVariant(cd.snr)}, {"grid", QVariant(cd.grid)}, {"dial", QVariant(cd.dial)}, {"freq", QVariant(cd.offset)}, {"tdrift", QVariant(cd.tdrift)}, #if CACHE_CALL_DATETIME_AS_STRINGS {"ackTimestamp", QVariant(cd.ackTimestamp.toString("yyyy-MM-dd hh:mm:ss"))}, {"utcTimestamp", QVariant(cd.utcTimestamp.toString("yyyy-MM-dd hh:mm:ss"))}, #else {"ackTimestamp", QVariant(cd.ackTimestamp)}, {"utcTimestamp", QVariant(cd.utcTimestamp)}, #endif {"submode", QVariant(cd.submode)}, }); } m_settings->endGroup(); } //---------------------------------------------------------- readSettings() void MainWindow::readSettings() { m_settings->beginGroup("MainWindow"); setMinimumSize(800, 400); restoreGeometry( m_settings->value("geometry", saveGeometry()).toByteArray()); setMinimumSize(800, 400); m_geometryNoControls = m_settings->value("geometryNoControls", saveGeometry()).toByteArray(); restoreState(m_settings->value("state", saveState()).toByteArray()); auto mainSplitterState = m_settings->value("MainSplitter").toByteArray(); if (!mainSplitterState.isEmpty()) { ui->mainSplitter->restoreState(mainSplitterState); } auto horizontalState = m_settings->value("TextHorizontalSplitter").toByteArray(); if (!horizontalState.isEmpty()) { ui->textHorizontalSplitter->restoreState(horizontalState); auto hsizes = ui->textHorizontalSplitter->sizes(); ui->tableWidgetRXAll->setVisible(hsizes.at(0) > 0); ui->tableWidgetCalls->setVisible(hsizes.at(2) > 0); } m_bandActivityWasVisible = m_settings->value("BandActivityVisible", true).toBool(); ui->tableWidgetRXAll->setVisible(m_bandActivityWasVisible); auto verticalState = m_settings->value("TextVerticalSplitter").toByteArray(); if (!verticalState.isEmpty()) { ui->textVerticalSplitter->restoreState(verticalState); } setDrift(m_settings->value("TimeDrift", 0).toInt()); ui->actionShow_Waterfall_Controls->setChecked( m_wideGraph->controlsVisible()); ui->actionShow_Waterfall_Time_Drift_Controls->setChecked( m_wideGraph->timeControlsVisible()); ui->actionShow_Tooltips->setChecked( m_settings->value("ShowTooltips", true).toBool()); ui->actionShow_Statusbar->setChecked( m_settings->value("ShowStatusbar", true).toBool()); ui->statusBar->setVisible(ui->actionShow_Statusbar->isChecked()); ui->textEditRX->setHtml( m_config.reset_activity() ? "" : m_settings->value("RXActivity", "").toString()); ui->actionShow_Band_Heartbeats_and_ACKs->setChecked( m_settings->value("BandHBActivityVisible", true).toBool()); m_settings->endGroup(); m_settings->beginGroup("Common"); // set the frequency offset setFreqOffsetForRestore( m_settings->value("Freq", Default::FREQUENCY).toInt(), false); // XXX setSubmode(m_settings->value("SubMode", Default::SUBMODE).toInt()); ui->actionModeJS8HB->setChecked( m_settings->value("SubModeHB", false).toBool()); ui->actionHeartbeatAcknowledgements->setChecked( m_settings->value("SubModeHBAck", false).toBool()); ui->actionModeMultiDecoder->setChecked( m_settings->value("SubModeMultiDecode", true).toBool()); m_lastMonitoredFrequency = m_settings ->value("DialFreq", QVariant::fromValue(Default::DIAL_FREQUENCY)) .value(); setFreq(0); // ensure a change is signaled setFreq(m_settings->value("Freq", Default::FREQUENCY).toInt()); // setup initial value of tx attenuator m_block_pwr_tooltip = true; ui->outAttenuation->setValue( m_settings->value("OutAttenuation", 0).toInt()); m_block_pwr_tooltip = false; m_pwrBandTxMemory = m_settings->value("pwrBandTxMemory").toHash(); m_pwrBandTuneMemory = m_settings->value("pwrBandTuneMemory").toHash(); m_sortCache = m_settings->value("SortBy").toMap(); m_showColumnsCache = m_settings->value("ShowColumns").toMap(); m_hbInterval = m_settings->value("HBInterval", 0).toInt(); m_cqInterval = m_settings->value("CQInterval", 0).toInt(); // TODO: jsherer - any other customizations? // ui->mainSplitter->setSizes(m_settings->value("MainSplitter", // QVariant::fromValue(ui->mainSplitter->sizes())).value >()); // ui->tableWidgetRXAll->restoreGeometry(m_settings->value("PanelLeftGeometry", // ui->tableWidgetRXAll->saveGeometry()).toByteArray()); // ui->tableWidgetCalls->restoreGeometry(m_settings->value("PanelRightGeometry", // ui->tableWidgetCalls->saveGeometry()).toByteArray()); // ui->extFreeTextMsg->setGeometry( m_settings->value("PanelTopGeometry", // ui->extFreeTextMsg->geometry()).toRect()); // ui->extFreeTextMsgEdit->setGeometry( // m_settings->value("PanelBottomGeometry", // ui->extFreeTextMsgEdit->geometry()).toRect()); // ui->bandHorizontalWidget->setGeometry( // m_settings->value("PanelWaterfallGeometry", // ui->bandHorizontalWidget->geometry()).toRect()); qCDebug(mainwindow_js8) // << m_settings->value("PanelTopGeometry") << ui->extFreeTextMsg; setTextEditStyle(ui->textEditRX, m_config.color_rx_foreground(), m_config.color_rx_background(), m_config.rx_text_font()); setTextEditStyle( ui->extFreeTextMsgEdit, m_config.color_compose_foreground(), m_config.color_compose_background(), m_config.compose_text_font()); ui->extFreeTextMsgEdit->setFont(m_config.compose_text_font(), m_config.color_compose_foreground(), m_config.color_compose_background()); m_settings->endGroup(); // use these initialisation settings to tune the audio o/p buffer // size and audio thread priority m_settings->beginGroup("Tune"); m_msAudioOutputBuffered = m_settings->value("Audio/OutputBufferMs").toInt(); m_framesAudioInputBuffered = m_settings->value("Audio/InputBufferFrames", JS8_RX_SAMPLE_RATE / 10) .toInt(); m_audioThreadPriority = static_cast( m_settings->value("Audio/ThreadPriority", QThread::TimeCriticalPriority) .toInt() % 8); m_notificationAudioThreadPriority = static_cast( m_settings ->value("Audio/NotificationThreadPriority", QThread::LowPriority) .toInt() % 8); m_decoderThreadPriority = static_cast( m_settings->value("Audio/DecoderThreadPriority", QThread::HighPriority) .toInt() % 8); m_networkThreadPriority = static_cast( m_settings->value("Network/NetworkThreadPriority", QThread::LowPriority) .toInt() % 8); m_settings->endGroup(); if (m_config.reset_activity()) { // NOOP } else { m_settings->beginGroup("CallActivity"); foreach (auto call, m_settings->allKeys()) { auto values = m_settings->value(call).toMap(); auto snr = values.value("snr", -64).toInt(); auto grid = values.value("grid", "").toString(); auto dial = values.value("dial", 0).toInt(); auto freq = values.value("freq", 0).toInt(); auto tdrift = values.value("tdrift", 0).toFloat(); #if CACHE_CALL_DATETIME_AS_STRINGS auto ackTimestampStr = values.value("ackTimestamp", "").toString(); auto ackTimestamp = QDateTime::fromString(ackTimestampStr, "yyyy-MM-dd hh:mm:ss"); ackTimestamp.setUtcOffset(0); auto utcTimestampStr = values.value("utcTimestamp", "").toString(); auto utcTimestamp = QDateTime::fromString(utcTimestampStr, "yyyy-MM-dd hh:mm:ss"); utcTimestamp.setUtcOffset(0); #else auto ackTimestamp = values.value("ackTimestamp").toDateTime(); auto utcTimestamp = values.value("utcTimestamp").toDateTime(); #endif auto submode = values.value("submode", Varicode::JS8CallNormal).toInt(); CallDetail cd = {}; cd.call = call; cd.snr = snr; cd.grid = grid; cd.dial = dial; cd.offset = freq; cd.tdrift = tdrift; cd.ackTimestamp = ackTimestamp; cd.utcTimestamp = utcTimestamp; cd.submode = submode; logCallActivity(cd, false); } m_settings->endGroup(); } m_settings_read = true; } void MainWindow::set_application_font(QFont const &font) { qApp->setFont(font); // set font in the application style sheet as well in case it has // been modified in the style sheet which has priority qApp->setStyleSheet(qApp->styleSheet() + "* {" + font_as_stylesheet(font) + '}'); for (auto &widget : qApp->topLevelWidgets()) { widget->updateGeometry(); } } //-------------------------------------------------------------- dataSink() void MainWindow::dataSink(qint64 frames) { constexpr int NMAX = JS8_NTMAX * 12000; constexpr int nfft3 = 16384; constexpr std::array nch = {1, 2, 4, 9, 18, 36, 72}; // symspec global vars static int ja = 0; static int k0 = 999999999; static WF::SPlot ssum = {}; static WF::SPlot s = {}; int k(frames); if (k0 == 999999999) { m_ihsym = int((float)frames / (float)JS8_NSPS) * 2; ja = k; k0 = k; } // qCDebug(mainwindow_js8) << "k" << k << "k0" << k0 << "delta" << k-k0; // Get power, spectrum, and ihsym int const nsmo = m_wideGraph->smoothYellow() - 1; // make sure the ssum global is reset every period cycle static int lastCycle = -1; int const cycle = JS8::Submode::computeCycleForDecode(m_nSubMode, k); if (cycle != lastCycle) { qCDebug(decoder_js8) << "period loop, resetting ssum"; ssum.fill(0.0f); } lastCycle = cycle; // cap ihsym based on the period max m_ihsym = m_ihsym % (m_TRperiod * JS8_RX_SAMPLE_RATE / JS8_NSPS * 2); int const jstep = JS8_NSPS / 2; if (k >= 2048 && k <= NMAX) { if (k < k0) { // Start a new data block ja = 0; ssum.fill(0.0f); m_ihsym = 0; std::fill(std::begin(dec_data.d2) + k, std::end(dec_data.d2), 0); } float gain = pow(10.0f, 0.1f * m_inGain); float sq = 0.0f; float pxmax = 0.0f; for (int i = k0; i < k; ++i) { float x1 = dec_data.d2[i]; pxmax = std::max(pxmax, fabs(x1)); sq += x1 * x1; } m_px = sq > 0.0f ? 10.0f * log10(sq / (k - k0)) : 0.0f; m_pxmax = pxmax > 0.0f ? 20.0f * log10(pxmax) : 0.0f; k0 = k; ja += jstep; // Perform real to complex FFT; note that the Fortran code performed // this operation in the four2a subroutine, which contains a cache // of plans. However, since the FFTW3 library itself caches, this // resulted in double caching, so as an experiment we're allowing // the library to handle caching. // // Note that the FFTW library requires all calls but for the plan // execution, i.e., fftwf_execute(), to be serialized. The library // doesn't require prescriptive serialization, anything will do so // long as the result is one thread at a time. // // Providing room for an extra complex value, i.e., a pair of floats, // real and imaginary parts, allows us to use the same buffer for the // FFT input and output. // // While the memory for the FFT can come from anywhere, if we ask the // library for it, it'll guarantee that it's aligned for use of SIMD // instructions, which will in turn allow it to use them. fftwf_complex *fftw_complex; float *fftw_real; fftwf_plan fftw_plan; { std::lock_guard lock(fftw_mutex); fftw_complex = fftwf_alloc_complex(nfft3 / 2 + 1); if (!fftw_complex) { throw std::runtime_error("Failed to allocate FFT data"); } fftw_real = reinterpret_cast(fftw_complex); fftw_plan = fftwf_plan_dft_r2c_1d(nfft3, fftw_real, fftw_complex, FFTW_ESTIMATE_PATIENT); if (!fftw_plan) { fftwf_free(fftw_complex); throw std::runtime_error("Failed to create FFT plan"); } } // Copy data and apply the window, then execute the FFT. for (int i = 0; i < nfft3; ++i) { if (int j = ja + i - nfft3; j >= 0 && j < NMAX) fftw_real[i] = 0.1f * dec_data.d2[j]; } ++m_ihsym; fftwf_execute(fftw_plan); // Process the resulting spectrum. m_df3 = 12000.0f / nfft3; auto const iz = std::min(JS8_NSMAX, static_cast(5000.0f / m_df3)); auto const cx = reinterpret_cast *>(fftw_complex); auto const fac = std::pow(1.0f / nfft3, 2.0f); for (int i = 0; i < iz; ++i) { auto const sx = fac * std::norm(cx[i]); ssum[i] += sx; s[i] = 1000.0f * gain * sx; } // We're now done with the plan and the FFT storage. Even though // the plan is being 'destroyed' here, the library will cache it // at its discretion. As above, the calls must be serialized. { std::lock_guard lock(fftw_mutex); fftwf_destroy_plan(fftw_plan); fftwf_free(fftw_complex); } // Update average spectra. for (int i = 0; i < iz; ++i) specData.savg[i] = ssum[i] / m_ihsym; if (m_ihsym % 10 == 0) { auto const mode4 = nch[nsmo]; auto const nsmo = 4 * std::min(10 * mode4, 150); flat1(specData.savg, iz, nsmo, specData.slin); if (mode4 >= 2) { std::array tmp; smo(specData.slin, tmp.data(), iz, mode4); smo(tmp.data(), specData.slin, iz, mode4); } std::fill(std::begin(specData.slin), std::begin(specData.slin) + 250, 0.0f); auto const ia = static_cast(500.0 / m_df3); auto const ib = static_cast(2700.0 / m_df3); auto const smin = *std::min_element(std::begin(specData.slin) + ia, std::begin(specData.slin) + ib); auto const smax = *std::max_element(std::begin(specData.slin), std::begin(specData.slin) + iz); auto const scale = (smax > smin) ? 50.0f / (smax - smin) : 0.0f; for (auto &val : specData.slin) val = std::max(0.0f, scale * (val - smin)); } } else if (k < 2048) m_ihsym = 0; // make sure ja is equal to k so if we jump ahead in the buffer, everything // resolves correctly ja = k; if (m_ihsym <= 0) return; if (ui) ui->signal_meter_widget->setValue(m_px, m_pxmax); // Update thermometer if (m_monitoring) m_wideGraph->dataSink(s, m_df3); decode(k); } void MainWindow::showSoundInError(const QString &errorMsg) { JS8MessageBox::critical_message(this, tr("Error in Sound Input"), errorMsg); } void MainWindow::showSoundOutError(const QString &errorMsg) { JS8MessageBox::critical_message(this, tr("Error in Sound Output"), errorMsg); } void MainWindow::showStatusMessage(const QString &statusMsg) { statusBar()->showMessage(statusMsg, 5000); } void MainWindow::on_menuModeJS8_aboutToShow() { bool canChangeMode = !m_transmitting && m_txFrameCount == 0 && m_txFrameQueue.isEmpty(); ui->actionModeJS8Normal->setEnabled(canChangeMode); ui->actionModeJS8Fast->setEnabled(canChangeMode); ui->actionModeJS8Turbo->setEnabled(canChangeMode); ui->actionModeJS8Slow->setEnabled(canChangeMode); ui->actionModeJS8Ultra->setEnabled(canChangeMode); // dynamically replace the autoreply menu item text auto autoreplyText = ui->actionModeAutoreply->text(); if (m_config.autoreply_confirmation() && !autoreplyText.contains(" with Confirmation")) { autoreplyText.replace("Autoreply", "Autoreply with Confirmation"); autoreplyText.replace("&AUTO", "&AUTO+CONF"); ui->actionModeAutoreply->setText(autoreplyText); } else if (!m_config.autoreply_confirmation() && autoreplyText.contains(" with Confirmation")) { autoreplyText.replace(" with Confirmation", ""); autoreplyText.replace("+CONF", ""); ui->actionModeAutoreply->setText(autoreplyText); } } void MainWindow::on_menuControl_aboutToShow() { auto freqMenu = new QMenu(this->menuBar()); buildFrequencyMenu(freqMenu); ui->actionSetFrequency->setMenu(freqMenu); auto heartbeatMenu = new QMenu(this->menuBar()); buildHeartbeatMenu(heartbeatMenu); ui->actionHeartbeat->setMenu(heartbeatMenu); auto cqMenu = new QMenu(this->menuBar()); buildCQMenu(cqMenu); ui->actionCQ->setMenu(cqMenu); ui->actionEnable_Monitor_RX->setChecked(ui->monitorButton->isChecked()); ui->actionEnable_Transmitter_TX->setChecked( ui->monitorTxButton->isChecked()); ui->actionEnable_Reporting_SPOT->setChecked(ui->spotButton->isChecked()); ui->actionEnable_Tuning_Tone_TUNE->setChecked(ui->tuneButton->isChecked()); } void MainWindow::on_actionCheck_for_Updates_triggered() { checkVersion(true); } void MainWindow::on_actionUser_Guide_triggered() { QDesktopServices::openUrl( QUrl("https://js8call-improved.com/downloads/JS8Call_User_Guide.pdf")); } void MainWindow::on_actionEnable_Monitor_RX_toggled(bool checked) { ui->monitorButton->setChecked(checked); } void MainWindow::on_actionEnable_Transmitter_TX_toggled(bool checked) { ui->monitorTxButton->setChecked(checked); } void MainWindow::on_actionEnable_Reporting_SPOT_toggled(bool checked) { ui->spotButton->setChecked(checked); } void MainWindow::on_actionEnable_Tuning_Tone_TUNE_toggled(bool checked) { ui->tuneButton->setChecked(checked); on_tuneButton_clicked(checked); } void MainWindow::on_menuWindow_aboutToShow() { ui->actionShow_Fullscreen->setChecked( (windowState() & Qt::WindowFullScreen) == Qt::WindowFullScreen); ui->actionShow_Statusbar->setChecked(ui->statusBar && ui->statusBar->isVisible()); auto hsizes = ui->textHorizontalSplitter->sizes(); ui->actionShow_Band_Activity->setChecked(hsizes.at(0) > 0); ui->actionShow_Call_Activity->setChecked(hsizes.at(2) > 0); auto vsizes = ui->mainSplitter->sizes(); ui->actionShow_Frequency_Clock->setChecked(vsizes.first() > 0); ui->actionShow_Waterfall->setChecked(vsizes.last() > 0); ui->actionShow_Waterfall_Controls->setChecked( ui->actionShow_Waterfall->isChecked() && m_wideGraph->controlsVisible()); ui->actionShow_Waterfall_Time_Drift_Controls->setChecked( ui->actionShow_Waterfall->isChecked() && m_wideGraph->timeControlsVisible()); QMenu *sortBandMenu = new QMenu(this->menuBar()); // ui->menuWindow); buildBandActivitySortByMenu(sortBandMenu); ui->actionSort_Band_Activity->setMenu(sortBandMenu); ui->actionSort_Band_Activity->setEnabled( ui->actionShow_Band_Activity->isChecked()); QMenu *sortCallMenu = new QMenu(this->menuBar()); // ui->menuWindow); buildCallActivitySortByMenu(sortCallMenu); ui->actionSort_Call_Activity->setMenu(sortCallMenu); ui->actionSort_Call_Activity->setEnabled( ui->actionShow_Call_Activity->isChecked()); QMenu *showBandMenu = new QMenu(this->menuBar()); // ui->menuWindow); buildShowColumnsMenu(showBandMenu, "band"); ui->actionShow_Band_Activity_Columns->setMenu(showBandMenu); ui->actionShow_Band_Activity_Columns->setEnabled( ui->actionShow_Band_Activity->isChecked()); QMenu *showCallMenu = new QMenu(this->menuBar()); // ui->menuWindow); buildShowColumnsMenu(showCallMenu, "call"); ui->actionShow_Call_Activity_Columns->setMenu(showCallMenu); ui->actionShow_Call_Activity_Columns->setEnabled( ui->actionShow_Call_Activity->isChecked()); ui->actionShow_Band_Heartbeats_and_ACKs->setEnabled( ui->actionShow_Band_Activity->isChecked()); } void MainWindow::on_actionFocus_Message_Receive_Area_triggered() { ui->textEditRX->setFocus(); } void MainWindow::on_actionFocus_Message_Reply_Area_triggered() { ui->extFreeTextMsgEdit->setFocus(); } void MainWindow::on_actionFocus_Band_Activity_Table_triggered() { ui->tableWidgetRXAll->setFocus(); } void MainWindow::on_actionFocus_Call_Activity_Table_triggered() { ui->tableWidgetCalls->setFocus(); } void MainWindow::on_actionClear_All_Activity_triggered() { clearActivity(); } void MainWindow::on_actionClear_Band_Activity_triggered() { clearBandActivity(); } void MainWindow::on_actionClear_RX_Activity_triggered() { clearRXActivity(); } void MainWindow::on_actionClear_Call_Activity_triggered() { clearCallActivity(); } void MainWindow::on_actionSetOffset_triggered() { bool ok = false; auto const currentFreq = freq(); QString newFreq = QInputDialog::getText(this, tr("Set Frequency Offset"), tr("Offset in Hz:"), QLineEdit::Normal, QString("%1").arg(currentFreq), &ok) .toUpper() .trimmed(); int offset = newFreq.toInt(&ok); if (!ok) { return; } setFreqOffsetForRestore(offset, false); } void MainWindow::on_actionShow_Fullscreen_triggered(bool checked) { auto state = windowState(); if (checked) { state |= Qt::WindowFullScreen; } else { state &= ~Qt::WindowFullScreen; } setWindowState(state); } void MainWindow::on_actionShow_Statusbar_triggered(bool checked) { if (!ui->statusBar) { return; } ui->statusBar->setVisible(checked); } void MainWindow::on_actionShow_Frequency_Clock_triggered(bool checked) { auto vsizes = ui->mainSplitter->sizes(); vsizes[0] = checked ? ui->logHorizontalWidget->minimumHeight() : 0; ui->logHorizontalWidget->setVisible(checked); ui->mainSplitter->setSizes(vsizes); } void MainWindow::on_actionShow_Band_Activity_triggered(bool checked) { auto hsizes = ui->textHorizontalSplitter->sizes(); if (m_bandActivityWidth == 0) { m_bandActivityWidth = ui->textHorizontalSplitter->width() / 4; } if (m_callActivityWidth == 0) { m_callActivityWidth = ui->textHorizontalSplitter->width() / 4; } if (m_textActivityWidth == 0) { m_textActivityWidth = ui->textHorizontalSplitter->width() / 2; } if (checked) { hsizes[0] = m_bandActivityWidth; hsizes[1] = m_textActivityWidth; if (hsizes[2]) hsizes[2] = m_callActivityWidth; } else { if (hsizes[0]) m_bandActivityWidth = hsizes[0]; if (hsizes[1]) m_textActivityWidth = hsizes[1]; if (hsizes[2]) m_callActivityWidth = hsizes[2]; hsizes[0] = 0; } ui->textHorizontalSplitter->setSizes(hsizes); ui->tableWidgetRXAll->setVisible(checked); m_bandActivityWasVisible = checked; } void MainWindow::on_actionShow_Band_Heartbeats_and_ACKs_triggered(bool) { displayBandActivity(); } void MainWindow::on_actionShow_Call_Activity_triggered(bool checked) { auto hsizes = ui->textHorizontalSplitter->sizes(); if (m_bandActivityWidth == 0) { m_bandActivityWidth = ui->textHorizontalSplitter->width() / 4; } if (m_callActivityWidth == 0) { m_callActivityWidth = ui->textHorizontalSplitter->width() / 4; } if (m_textActivityWidth == 0) { m_textActivityWidth = ui->textHorizontalSplitter->width() / 2; } if (checked) { if (hsizes[0]) hsizes[0] = m_bandActivityWidth; hsizes[1] = m_textActivityWidth; hsizes[2] = m_callActivityWidth; } else { if (hsizes[0]) m_bandActivityWidth = hsizes[0]; if (hsizes[1]) m_textActivityWidth = hsizes[1]; if (hsizes[2]) m_callActivityWidth = hsizes[2]; hsizes[2] = 0; } ui->textHorizontalSplitter->setSizes(hsizes); ui->tableWidgetCalls->setVisible(checked); } void MainWindow::on_actionShow_Waterfall_triggered(bool checked) { auto vsizes = ui->mainSplitter->sizes(); if (m_waterfallHeight == 0) { m_waterfallHeight = ui->mainSplitter->height() / 4; } if (checked) { vsizes[vsizes.length() - 1] = m_waterfallHeight; } else { m_waterfallHeight = vsizes[vsizes.length() - 1]; vsizes[1] += m_waterfallHeight; vsizes[vsizes.length() - 1] = 0; } ui->mainSplitter->setSizes(vsizes); ui->bandHorizontalWidget->setVisible(checked); } void MainWindow::on_actionShow_Waterfall_Controls_triggered(bool checked) { m_wideGraph->setControlsVisible(checked); if (checked && !ui->bandHorizontalWidget->isVisible()) { on_actionShow_Waterfall_triggered(checked); } } void MainWindow::on_actionShow_Waterfall_Time_Drift_Controls_triggered( bool checked) { m_wideGraph->setTimeControlsVisible(checked); if (checked && !ui->bandHorizontalWidget->isVisible()) { on_actionShow_Waterfall_triggered(checked); } } void MainWindow::on_actionReset_Window_Sizes_triggered() { // auto size = this->centralWidget()->size(); ui->mainSplitter->setSizes({ui->logHorizontalWidget->minimumHeight(), ui->mainSplitter->height() / 2, ui->macroHorizonalWidget->minimumHeight(), ui->mainSplitter->height() / 4}); ui->textHorizontalSplitter->setSizes( {ui->textHorizontalSplitter->width() / 4, ui->textHorizontalSplitter->width() / 2, ui->textHorizontalSplitter->width() / 4}); ui->textVerticalSplitter->setSizes( {ui->textVerticalSplitter->height() / 2, ui->textVerticalSplitter->height() / 2}); } void MainWindow::on_actionSettings_triggered() { openSettings(); } void MainWindow::openSettings(int tab) { m_config.select_tab(tab); // things that might change that we need know about auto callsign = m_config.my_callsign(); auto my_grid = m_config.my_grid(); auto spot_on = m_config.spot_to_reporting_networks(); if (QDialog::Accepted == m_config.exec()) { if (m_config.my_callsign() != callsign) { m_baseCall = Radio::base_callsign(m_config.my_callsign()); } if (m_config.my_callsign() != callsign || m_config.my_grid() != my_grid) { statusUpdate(); } enable_DXCC_entity(m_config.DXCC()); // sets text window proportions and // (re)inits the logbook prepareApi(); prepareSpotting(); // this will close the connection to PSKReporter if it has been // disabled if (spot_on && !m_config.spot_to_reporting_networks()) { Q_EMIT pskReporterSendReport(true); } if (m_config.restart_audio_input() && !m_config.audio_input_device().isNull()) { Q_EMIT startAudioInputStream(m_config.audio_input_device(), m_framesAudioInputBuffered, m_detector, m_config.audio_input_channel()); } if (m_config.restart_audio_output() && !m_config.audio_output_device().isNull()) { Q_EMIT initializeAudioOutputStream( m_config.audio_output_device(), AudioDevice::Mono == m_config.audio_output_channel() ? 1 : 2, m_msAudioOutputBuffered); } if (m_config.restart_notification_audio_output() && !m_config.notification_audio_output_device().isNull()) { Q_EMIT initializeNotificationAudioOutputStream( m_config.notification_audio_output_device(), m_msAudioOutputBuffered); } displayDialFrequency(); displayActivity(true); setup_status_bar(); setupJS8(); m_config.transceiver_online(); setXIT(freq()); m_opCall = m_config.opCall(); } } void MainWindow::prepareApi() { // the udp api is prepared by default (always listening) // so, we just need to prepare the tcp api bool enabled = m_config.tcpEnabled(); if (enabled) { emit apiSetMaxConnections(m_config.tcp_max_connections()); emit apiSetServer(m_config.tcp_server_name(), m_config.tcp_server_port()); emit apiStartServer(); } else { emit apiStopServer(); } } void MainWindow::prepareSpotting() { if (m_config.spot_to_reporting_networks()) { spotSetLocal(); pskSetLocal(); aprsSetLocal(); emit aprsClientSetSkipPercent(0.25); emit aprsClientSetServer(m_config.aprs_server_name(), m_config.aprs_server_port()); emit aprsClientSetPaused(false); ui->spotButton->setChecked(true); } else { emit aprsClientSetPaused(true); ui->spotButton->setChecked(false); } } void MainWindow::on_spotButton_clicked(bool checked) { // 1. save setting m_config.set_spot_to_reporting_networks(checked); // 2. prepare prepareApi(); prepareSpotting(); } void MainWindow::on_monitorButton_clicked(bool checked) { if (!m_transmitting) { auto prior = m_monitoring; monitor(checked); if (checked && !prior) { if (m_config.monitor_last_used()) { // put rig back where it was when last in control setRig(m_lastMonitoredFrequency); setXIT(freq()); } setFreq(freq()); // ensure FreqCal triggers } // Get Configuration in/out of strict split and mode checking Q_EMIT m_config.sync_transceiver(true, checked); } else { ui->monitorButton->setChecked(false); // disallow } } void MainWindow::monitor(bool state) { ui->monitorButton->setChecked(state); // make sure widegraph is running if we are monitoring, otherwise pause it. m_wideGraph->setPaused(!state); if (state) { if (!m_monitoring) Q_EMIT resumeAudioInputStream(); } else { Q_EMIT suspendAudioInputStream(); } m_monitoring = state; } void MainWindow::on_actionAbout_triggered() // Display "About" { CAboutDlg{this}.exec(); } void MainWindow::on_monitorButton_toggled(bool) { resetPushButtonToggleText(ui->monitorButton); } void MainWindow::on_monitorTxButton_toggled(bool checked) { resetPushButtonToggleText(ui->monitorTxButton); if (!checked) { qCDebug(mainwindow_js8) << "on_monitorTxButton_toggled(" << checked << ") to stop TX."; on_stopTxButton_clicked(); } } void MainWindow::on_tuneButton_toggled(bool) { resetPushButtonToggleText(ui->tuneButton); } void MainWindow::on_spotButton_toggled(bool) { resetPushButtonToggleText(ui->spotButton); } void MainWindow::auto_tx_mode(bool state) { qCDebug(mainwindow_js8) << "auto_tx_mode(" << state << ")"; m_auto = state; statusUpdate(); if (state) { // Let us not wait until the next polling slot, but prepare transmission // now, even though that may waste a few CPU cycles through double work // that will be done soon anyway: prepareSending(DriftingDateTime::currentMSecsSinceEpoch()); } else { // This function is called recursively from on_stopTxButton_clicked()! bool previous_stopTxButtonisLongterm = m_stopTxButtonIsLongterm; m_stopTxButtonIsLongterm = false; on_stopTxButton_clicked(); m_stopTxButtonIsLongterm = previous_stopTxButtonisLongterm; } qCDebug(mainwindow_js8) << "auto_tx_mode(" << state << ") completed."; } void MainWindow::keyPressEvent(QKeyEvent *e) { switch (e->key()) { case Qt::Key_Escape: on_stopTxButton_clicked(); stopTx(); return; case Qt::Key_F5: on_logQSOButton_clicked(); return; } QMainWindow::keyPressEvent(e); } void MainWindow::f11f12(int const n) { if (n == 11) setFreq(freq() - 1); if (n == 12) setFreq(freq() + 1); } Radio::Frequency MainWindow::dialFrequency() { return Frequency{m_rigState.ptt() && m_rigState.split() ? m_rigState.tx_frequency() : m_rigState.frequency()}; } void MainWindow::setSubmode(int submode) { m_nSubMode = submode; ui->actionModeJS8Normal->setChecked(submode == Varicode::JS8CallNormal); ui->actionModeJS8Fast->setChecked(submode == Varicode::JS8CallFast); ui->actionModeJS8Turbo->setChecked(submode == Varicode::JS8CallTurbo); ui->actionModeJS8Slow->setChecked(submode == Varicode::JS8CallSlow); ui->actionModeJS8Ultra->setChecked(submode == Varicode::JS8CallUltra); setupJS8(); Q_EMIT submodeChanged(Varicode::intToSubmode(submode)); } void MainWindow::updateCurrentBand() { QVariant state = ui->readFreq->property("state"); if (!state.isValid()) { return; } auto dial_frequency = dialFrequency(); auto const &band_name = m_config.bands()->find(dial_frequency); if (m_lastBand == band_name) { return; } cacheActivity(m_lastBand); // clear activity on startup if asked or on when the previous band is not // empty if (m_config.reset_activity() || !m_lastBand.isEmpty()) { clearActivity(); } m_wideGraph->setBand(band_name); qCDebug(mainwindow_js8) << "setting band" << band_name; /** * @brief Send WSJT-X Status message on band change * * When the band changes, send a status update to WSJT-X protocol clients * and native JSON API clients (if not conflicting). */ // Send WSJT-X Status message if protocol is enabled (band change triggers // status update) if (m_wsjtxMessageMapper && m_config.wsjtx_protocol_enabled()) { QString dx_call = callsignSelected(); QString dx_grid = ""; if (!dx_call.isEmpty() && m_callActivity.contains(dx_call)) { dx_grid = m_callActivity[dx_call].grid; } QString tx_message = m_transmitting ? m_currentMessage : ""; m_wsjtxMessageMapper->sendStatusUpdate( dialFrequency(), freq(), "JS8", // mode dx_call, m_config.my_callsign(), m_config.my_grid(), dx_grid, true, // tx_enabled m_transmitting, m_decoderBusy || m_monitoring, // decoding tx_message); } // Send native JSON message only if not conflicting with WSJT-X bool skip_json = false; if (m_config.wsjtx_protocol_enabled() && m_config.wsjtx_server_port() == m_config.udp_server_port() && m_config.wsjtx_server_name() == m_config.udp_server_name()) { skip_json = true; } if (!skip_json) { sendNetworkMessage( "RIG.FREQ", "", {{"_ID", QVariant(-1)}, {"BAND", QVariant(band_name)}, {"FREQ", QVariant((quint64)dialFrequency() + freq())}, {"DIAL", QVariant((quint64)dialFrequency())}, {"OFFSET", QVariant((quint64)freq())}}); } m_lastBand = band_name; band_changed(); restoreActivity(m_lastBand); } void MainWindow::displayDialFrequency() { #if 0 qCDebug(mainwindow_js8) << "rx nominal" << m_freqNominal; qCDebug(mainwindow_js8) << "tx nominal" << m_freqTxNominal; qCDebug(mainwindow_js8) << "offset set to" << freq() << freq(); #endif auto dial_frequency = dialFrequency(); auto audio_frequency = freq(); // lookup band auto const &band_name = m_config.bands()->find(dial_frequency); auto sFreq = Radio::pretty_frequency_MHz_string(dial_frequency); ui->currentFreq->setDigitCount(sFreq.length()); ui->currentFreq->display(sFreq); if (m_splitMode && m_transmitting) { audio_frequency += m_XIT; } ui->labDialFreqOffset->setText(QString("%1 Hz").arg(audio_frequency)); } void MainWindow::statusChanged() { statusUpdate(); } bool MainWindow::eventFilter(QObject *object, QEvent *event) { switch (event->type()) { case QEvent::KeyPress: // fall through case QEvent::MouseButtonPress: // reset the Tx watchdog resetIdleTimer(); tx_watchdog(false); break; case QEvent::ChildAdded: // ensure our child widgets get added to our event filter add_child_to_event_filter(static_cast(event)->child()); break; case QEvent::ChildRemoved: // ensure our child widgets get d=removed from our event filter remove_child_from_event_filter( static_cast(event)->child()); break; case QEvent::ToolTip: if (!ui->actionShow_Tooltips->isChecked()) { return true; } break; default: break; } return QObject::eventFilter(object, event); } void MainWindow::createStatusBar() // createStatusBar { tx_status_label.setAlignment(Qt::AlignCenter); tx_status_label.setMinimumSize(QSize{150, 18}); tx_status_label.setStyleSheet("QLabel{background-color: #22ff22}"); tx_status_label.setFrameStyle(QFrame::Panel | QFrame::Sunken); statusBar()->addWidget(&tx_status_label); config_label.setAlignment(Qt::AlignCenter); config_label.setMinimumSize(QSize{80, 18}); config_label.setFrameStyle(QFrame::Panel | QFrame::Sunken); statusBar()->addWidget(&config_label); config_label.hide(); // only shown for non-default configuration mode_label.setAlignment(Qt::AlignCenter); mode_label.setMinimumSize(QSize{80, 18}); mode_label.setStyleSheet("QLabel{background-color: #6699ff}"); mode_label.setFrameStyle(QFrame::Panel | QFrame::Sunken); mode_label.setText("JS8"); statusBar()->addWidget(&mode_label); last_tx_label.setAlignment(Qt::AlignCenter); last_tx_label.setMinimumSize(QSize{150, 18}); last_tx_label.setFrameStyle(QFrame::Panel | QFrame::Sunken); statusBar()->addWidget(&last_tx_label); statusBar()->addPermanentWidget(&progressBar); progressBar.setMinimumSize(QSize{100, 18}); progressBar.setFormat("%v/%m"); statusBar()->addPermanentWidget(&wpm_label); wpm_label.setMinimumSize(QSize{120, 18}); wpm_label.setFrameStyle(QFrame::Panel | QFrame::Sunken); wpm_label.setAlignment(Qt::AlignCenter); } void MainWindow::setup_status_bar() { last_tx_label.clear(); } void MainWindow::closeEvent(QCloseEvent *e) { m_valid = false; // suppresses subprocess errors m_config.transceiver_offline(); writeSettings(); m_guiTimer.stop(); m_prefixes.reset(); m_shortcuts.reset(); m_mouseCmnds.reset(); Q_EMIT finished(); QMainWindow::closeEvent(e); } void MainWindow::on_dialFreqUpButton_clicked() { setRig(m_freqNominal + 250); } void MainWindow::on_dialFreqDownButton_clicked() { setRig(m_freqNominal - 250); } void MainWindow::on_actionAdd_Log_Entry_triggered() { on_logQSOButton_clicked(); } void MainWindow::on_actionCopyright_Notice_triggered() { auto const &message = tr( "If you make fair use of any part of this program under terms of the " "GNU " "General Public License, you must display the following copyright " "notice prominently in your derivative work:\n\n" "\"The algorithms, source code, look-and-feel of WSJT-X and related " "programs, and protocol specifications for the modes FSK441, FT8, JT4, " "JT6M, JT9, JT65, JTMS, QRA64, ISCAT, MSK144 are Copyright (C) " "2001-2018 by one or more of the following authors: Joseph Taylor, " "K1JT; Bill Somerville, G4WJS; Steven Franke, K9AN; Nico Palermo, " "IV3NWV; Greg Beam, KI7MT; Michael Black, W9MDB; Edson Pereira, " "PY2SDR; " "Philip Karn, KA9Q; and other members of the WSJT Development " "Group.\n\n" "Further, the source code of JS8Call contains material Copyright (C) " "2018-2019 by Jordan Sherer, KN4CRD.\""); JS8MessageBox::warning_message(this, message); } /** * @brief MainWindow::isDecodeReady * determine if decoding is ready for a given submode * @param submode - submode to test * @param k - current frame count * @param k0 - previous frame count * @param pCurrentDecodeStart - input pointer to a static integer with the * current decode start position * @param pNextDecodeStart - input pointer to a static integer with the next * decode start position * @param pStart - output pointer to the next start position when decode is * ready * @param pSz - output pointer to the next size when decode is ready * @param pCycle - output pointer to the next cycle when decode is ready * @return true if decode is ready for this submode, false otherwise */ bool MainWindow::isDecodeReady(int const submode, qint32 const k, qint32 const k0, qint32 *pCurrentDecodeStart, qint32 *pNextDecodeStart, qint32 *pStart, qint32 *pSz, qint32 *pCycle) { if (pCurrentDecodeStart == nullptr || pNextDecodeStart == nullptr) { return false; } qint32 const cycleFrames = JS8::Submode::samplesPerPeriod(submode); qint32 const framesNeeded = JS8::Submode::samplesNeeded(submode); qint32 const currentCycle = JS8::Submode::computeCycleForDecode(submode, k); qint32 const delta = qAbs(k - k0); if (delta > cycleFrames) { qCDebug(decoder_js8) << "-->" << JS8::Submode::name(submode) << "buffer advance delta" << delta; } // say, current decode start is 360000 and the next is 540000 (right before // we loop) frames needed are 150000 and then we turn off rx until k is // 110000 and the cycle frames are 180000 and k0 is a proper 100000 we need // to still reset... so, if k is less than the last decode start - cycle // frames (in this case 360000-180000, or 180000), then we should reset. // but, what if k is now 182000?? // k=182000 // k < current (182000 < 360000) true // k < max(0, current-cycleframes+framesNeeded) (k < 180000+150000) true // k=6000 // k < current (6000<360000) true // k < max(0, 360000-180000+150000) true // k=350000 // k < current (350000<360000) true // k < max(0, 360000-350000+150000) false // are we in the space between the end of the last decode and the start of // the next decode? bool const deadAir = (k < *pCurrentDecodeStart && k < qMax(0, *pCurrentDecodeStart - cycleFrames + framesNeeded)); // on buffer loop or init, prepare proper next decode start if ((deadAir) || (k < k0) || (delta > cycleFrames) || (*pCurrentDecodeStart == -1) || (*pNextDecodeStart == -1)) { *pCurrentDecodeStart = currentCycle * cycleFrames; *pNextDecodeStart = *pCurrentDecodeStart + cycleFrames; } bool const ready = *pCurrentDecodeStart + framesNeeded <= k; if (ready) { qCDebug(decoder_js8) << "-->" << JS8::Submode::name(submode) << "from" << *pCurrentDecodeStart << "to" << *pCurrentDecodeStart + framesNeeded << "k" << k << "k0" << k0; if (pCycle) *pCycle = currentCycle; if (pStart) *pStart = *pCurrentDecodeStart; if (pSz) *pSz = qMax(framesNeeded, k - (*pCurrentDecodeStart)); *pCurrentDecodeStart = *pNextDecodeStart; *pNextDecodeStart = *pCurrentDecodeStart + cycleFrames; } return ready; } /** * @brief MainWindow::decode * try decoding * @return true if the decoder was activated, false otherwise */ bool MainWindow::decode(qint32 k) { static int k0 = 9999999; int kZero = k0; k0 = k; qCDebug(decoder_js8) << "decoder checking if ready..." << "k" << k << "k0" << kZero << "busy?" << m_decoderBusy << "lock exists?" << (QFile{m_config.temp_dir().absoluteFilePath(".lock")}.exists()); if (k == kZero) { qCDebug(decoder_js8) << "--> decoder stream has not advanced"; return false; } if (!m_monitoring) { qCDebug(decoder_js8) << "--> decoder stream is not active"; return false; } bool ready = false; #if JS8_USE_EXPERIMENTAL_DECODE_TIMING ready = decodeEnqueueReady(k, kZero); if (ready || !m_decoderQueue.isEmpty()) { qCDebug(decoder_js8) << "--> decoder is ready to be run with" << m_decoderQueue.count() << "decode periods"; } #else ready = decodeEnqueueReadyExperiment(k, kZero); if (ready || !m_decoderQueue.isEmpty()) { qCDebug(decoder_js8) << "--> decoder is ready to be run with" << m_decoderQueue.count() << "decode periods"; } #endif // // TODO: what follows can likely be pulled out to an async process // // pause decoder if we are currently transmitting if (m_transmitting) { // We used to use isMessageQueuedForTransmit, and some form of checking // for queued messages but, that just caused problems with missing // decodes, so we only pause if we are actually actively transmitting. qCDebug(decoder_js8) << "--> decoder paused during transmit"; return false; } if (m_decoderBusyStartTime.isValid() && m_decoderBusyStartTime.msecsTo(QDateTime::currentDateTimeUtc()) < 1000) { qCDebug(decoder_js8) << "--> decoder paused for 1000 ms after last decode start"; return false; } int threshold = m_nSubMode == Varicode::JS8CallSlow ? 4000 : 2000; // two seconds if (isInDecodeDelayThreshold(threshold)) { qCDebug(decoder_js8) << "--> decoder paused for" << threshold << "ms after transmit stop"; return false; } // critical section (modifying dec_data) qint32 submode = -1; if (!decodeProcessQueue(&submode)) { return false; } decodeStart(); return true; } /** * @brief MainWindow::decodeEnqueueReady * compute the available decoder ranges that can be processed and * place them in the decode queue * @param k - the current frame count * @param k0 - the previous frame count * @return true if decoder ranges were queued, false otherwise */ bool MainWindow::decodeEnqueueReady(qint32 k, qint32 k0) { // compute the next decode for each submode // enqueue those decodes that are "ready" // on an interval, issue a decode int decodes = 0; bool couldDecodeA = false; qint32 startA = -1; qint32 szA = -1; qint32 cycleA = -1; bool couldDecodeB = false; qint32 startB = -1; qint32 szB = -1; qint32 cycleB = -1; bool couldDecodeC = false; qint32 startC = -1; qint32 szC = -1; qint32 cycleC = -1; bool couldDecodeE = false; qint32 startE = -1; qint32 szE = -1; qint32 cycleE = -1; #if JS8_ENABLE_JS8I bool couldDecodeI = false; qint32 startI = -1; qint32 szI = -1; qint32 cycleI = -1; #endif static qint32 currentDecodeStartA = -1; static qint32 nextDecodeStartA = -1; qCDebug(decoder_js8) << "? NORMAL " << currentDecodeStartA << nextDecodeStartA; couldDecodeA = isDecodeReady(Varicode::JS8CallNormal, k, k0, ¤tDecodeStartA, &nextDecodeStartA, &startA, &szA, &cycleA); static qint32 currentDecodeStartB = -1; static qint32 nextDecodeStartB = -1; qCDebug(decoder_js8) << "? FAST " << currentDecodeStartB << nextDecodeStartB; couldDecodeB = isDecodeReady(Varicode::JS8CallFast, k, k0, ¤tDecodeStartB, &nextDecodeStartB, &startB, &szB, &cycleB); static qint32 currentDecodeStartC = -1; static qint32 nextDecodeStartC = -1; qCDebug(decoder_js8) << "? TURBO " << currentDecodeStartC << nextDecodeStartC; couldDecodeC = isDecodeReady(Varicode::JS8CallTurbo, k, k0, ¤tDecodeStartC, &nextDecodeStartC, &startC, &szC, &cycleC); static qint32 currentDecodeStartE = -1; static qint32 nextDecodeStartE = -1; qCDebug(decoder_js8) << "? SLOW " << currentDecodeStartE << nextDecodeStartE; couldDecodeE = isDecodeReady(Varicode::JS8CallSlow, k, k0, ¤tDecodeStartE, &nextDecodeStartE, &startE, &szE, &cycleE); #if JS8_ENABLE_JS8I static qint32 currentDecodeStartI = -1; static qint32 nextDecodeStartI = -1; qCDebug(decoder_js8) << "? ULTRA " << currentDecodeStartI << nextDecodeStartI; couldDecodeI = isDecodeReady(Varicode::JS8CallUltra, k, k0, ¤tDecodeStartI, &nextDecodeStartI, &startI, &szI, &cycleI); #endif if (couldDecodeA) { DecodeParams d; d.submode = Varicode::JS8CallNormal; d.start = startA; d.sz = szA; m_decoderQueue.append(d); decodes++; } if (couldDecodeB) { DecodeParams d; d.submode = Varicode::JS8CallFast; d.start = startB; d.sz = szB; m_decoderQueue.append(d); decodes++; } if (couldDecodeC) { DecodeParams d; d.submode = Varicode::JS8CallTurbo; d.start = startC; d.sz = szC; m_decoderQueue.append(d); decodes++; } if (couldDecodeE) { DecodeParams d; d.submode = Varicode::JS8CallSlow; d.start = startE; d.sz = szE; m_decoderQueue.append(d); decodes++; } #if JS8_ENABLE_JS8I if (couldDecodeI) { DecodeParams d; d.submode = Varicode::JS8CallUltra; d.start = startI; d.sz = szI; m_decoderQueue.append(d); decodes++; } #endif return decodes > 0; } /** * @brief MainWindow::decodeEnqueueReadyExperiment * compute the available decoder ranges that can be processed and * place them in the decode queue * * experiment with decoding on a much shorter interval than usual * * @param k - the current frame count * @param k0 - the previous frame count * @return true if decoder ranges were queued, false otherwise */ bool MainWindow::decodeEnqueueReadyExperiment(qint32 k, qint32 /*k0*/) { // TODO: make this non-static field of MainWindow? // map of last decode positions for each submode // static QMap m_lastDecodeStartMap; // TODO: make this non-static field of MainWindow? // map of submodes to decode + optional alternate decode positions static QMap> submodes = { {Varicode::JS8CallSlow, {0}}, {Varicode::JS8CallNormal, {0}}, {Varicode::JS8CallFast, {0}}, // NORMAL: 0, 10, 20 --- ALT: 15, 25 {Varicode::JS8CallTurbo, {0}}, // NORMAL: 0, 6, 12, 18 --- ALT: 15, 21, // 27 #if JS8_ENABLE_JS8I {Varicode::JS8CallUltra, {0}}, #endif }; static qint32 maxSamples = JS8_RX_SAMPLE_SIZE; static qint32 oneSecondSamples = JS8_RX_SAMPLE_RATE; int decodes = 0; // do we have a better way to check this? bool multi = ui->actionModeMultiDecoder->isChecked(); // do we need to process alternate positions? bool skipAlt = true; foreach (auto submode, submodes.keys()) { // do we have a better way to check this? bool everySecond = m_wideGraph->shouldAutoSyncSubmode(submode); // skip if multi is disabled and this mode is not the current submode // and we're not autosyncing this mode if (!everySecond && !multi && submode != m_nSubMode) { continue; } // check all alternate decode positions foreach (auto alt, submodes.value(submode)) { // skip alt decode positions if needed if (skipAlt && alt != 0) { continue; } // skip alts if we are decoding every second if (everySecond && alt != 0) { continue; } qint32 const cycle = JS8::Submode::computeAltCycleForDecode( submode, k, alt * oneSecondSamples); qint32 const cycleFrames = JS8::Submode::samplesPerPeriod(submode); qint32 const cycleFramesNeeded = JS8::Submode::samplesForSymbols( submode); // computeFramesNeededForDecode(submode) // - oneSecondSamples; qint32 cycleFramesReady = k - (cycle * cycleFrames); if (cycleFramesReady < 0) { cycleFramesReady = k + (maxSamples - (cycle * cycleFrames)); } if (!m_lastDecodeStartMap.contains(submode)) { m_lastDecodeStartMap[submode] = cycle * cycleFrames; } qint32 lastDecodeStart = m_lastDecodeStartMap[submode]; qint32 incrementedBy = k - lastDecodeStart; if (k < lastDecodeStart) { incrementedBy = maxSamples - lastDecodeStart + k; } qCDebug(decoder_js8) << JS8::Submode::name(submode) << "alt" << alt << "cycle" << cycle << "cycle frames" << cycleFrames << "cycle start" << cycle * cycleFrames << "cycle end" << (cycle + 1) * cycleFrames << "k" << k << "frames ready" << cycleFramesReady << "incremeted by" << incrementedBy; if (everySecond && incrementedBy >= oneSecondSamples) { DecodeParams d; d.submode = submode; d.sz = cycleFrames; d.start = k - d.sz; if (d.start < 0) { d.start += maxSamples; } m_decoderQueue.append(d); decodes++; // keep track of last decode position m_lastDecodeStartMap[submode] = k; } else if ((incrementedBy >= 1.5 * oneSecondSamples && cycleFramesReady >= cycleFramesNeeded) || // within every 3/2 seconds // for normal positions (incrementedBy >= oneSecondSamples && cycleFramesReady >= cycleFramesNeeded - 1.5 * oneSecondSamples) || // within the last // 3/2 seconds of a // new cycle (incrementedBy >= oneSecondSamples && cycleFramesReady < 1.5 * oneSecondSamples) // within the first 3/2 // seconds of a new cycle ) { DecodeParams d; d.submode = submode; d.start = cycle * cycleFrames; d.sz = cycleFramesReady; m_decoderQueue.append(d); decodes++; // keep track of last decode position m_lastDecodeStartMap[submode] = k; } } } return decodes > 0; } /** * @brief MainWindow::decodeProcessQueue * process the decode queue by merging available decode ranges * into the dec_data shared structure for the decoder to process * @param pSubmode - the lowest speed submode in this iteration * @return true if the decoder is ready to be run, false otherwise */ bool MainWindow::decodeProcessQueue(qint32 *pSubmode) { // critical section QMutexLocker mutex(m_detector->getMutex()); if (m_decoderBusy) { int seconds = m_decoderBusyStartTime.secsTo(QDateTime::currentDateTimeUtc()); if (seconds > 60) { qCDebug(decoder_js8) << "--> decoder should be killed!" << QString("(%1 seconds)").arg(seconds); } else if (seconds > 30) { qCDebug(decoder_js8) << "--> decoder is hanging!" << QString("(%1 seconds)").arg(seconds); } else { qCDebug(decoder_js8) << "--> decoder is busy!"; } return false; } if (m_decoderQueue.isEmpty()) { qCDebug(decoder_js8) << "--> decoder has nothing to process!"; return false; } int submode = -1; int maxDecodes = 1; bool multi = ui->actionModeMultiDecoder->isChecked(); if (multi) { maxDecodes = JS8_ENABLE_JS8I ? 5 : 4; } int count = m_decoderQueue.count(); if (count > maxDecodes) { qCDebug(decoder_js8) << "--> decoder skipping at least 1 decode cycle" << "count" << count << "max" << maxDecodes; } // default to no submodes being decoded, then bitwise OR the modes together // to decode them all at once dec_data.params.nsubmodes = 0; while (!m_decoderQueue.isEmpty()) { auto params = m_decoderQueue.front(); m_decoderQueue.removeFirst(); // skip if we are not in multi mode and the submode doesn't equal the // global submode if (!multi && params.submode != m_nSubMode) { continue; } if (submode == -1 || params.submode < submode) { submode = params.submode; } switch (params.submode) { case Varicode::JS8CallNormal: dec_data.params.kposA = params.start; dec_data.params.kszA = params.sz; dec_data.params.nsubmodes |= (params.submode + 1); break; case Varicode::JS8CallFast: dec_data.params.kposB = params.start; dec_data.params.kszB = params.sz; dec_data.params.nsubmodes |= (params.submode << 1); break; case Varicode::JS8CallTurbo: dec_data.params.kposC = params.start; dec_data.params.kszC = params.sz; dec_data.params.nsubmodes |= (params.submode << 1); break; case Varicode::JS8CallSlow: dec_data.params.kposE = params.start; dec_data.params.kszE = params.sz; dec_data.params.nsubmodes |= (params.submode << 1); break; #if JS8_ENABLE_JS8I case Varicode::JS8CallUltra: dec_data.params.kposI = params.start; dec_data.params.kszI = params.sz; dec_data.params.nsubmodes |= (params.submode << 1); break; #endif } } if (submode == -1) { qCDebug(decoder_js8) << "--> decoder has no segments to decode!"; return false; } dec_data.params.syncStats = (m_wideGraph->shouldDisplayDecodeAttempts() || m_wideGraph->isAutoSyncEnabled()); dec_data.params.newdat = 1; auto const period_unsigned = JS8::Submode::period(submode); // Need to use a signed integer here, auto const period_signed = (int)period_unsigned; // as (2 - period_unsigned) results in an enourmeous number close to 2**32. auto const t = DriftingDateTime::currentDateTimeUtc().addSecs(2 - period_signed); auto const ihr = t.toString("hh").toInt(); auto const imin = t.toString("mm").toInt(); auto const isec = t.toString("ss").toInt(); dec_data.params.nutc = code_time(ihr, imin, isec - isec % period_unsigned); dec_data.params.nfqso = freq(); dec_data.params.nfa = m_wideGraph->filterEnabled() ? m_wideGraph->filterMinimum() : 0; dec_data.params.nfb = m_wideGraph->filterEnabled() ? m_wideGraph->filterMaximum() : 5000; if (dec_data.params.nutc < m_nutc0) m_RxLog = 1; // Date and Time to ALL.TXT if (dec_data.params.newdat == 1) m_nutc0 = dec_data.params.nutc; // keep track of the minimum submode if (pSubmode) *pSubmode = submode; return true; } /** * @brief MainWindow::decodeStart * copy the dec_data structure to shared memory and * remove the lock file to start the decoding process */ void MainWindow::decodeStart() { // critical section QMutexLocker mutex(m_detector->getMutex()); if (m_decoderBusy) { qCDebug(decoder_js8) << "--> decoder cannot start...busy (busy flag)"; return; } // Mark the decoder busy; decodeDone is responsible for marking // the decode _not_ busy decodeBusy(true); qCDebug(decoder_js8) << "--> decoder starting" << " --> kin:" << dec_data.params.kin << " --> newdat:" << dec_data.params.newdat << " --> nsubmodes:" << dec_data.params.nsubmodes << " --> A:" << dec_data.params.kposA << dec_data.params.kposA + dec_data.params.kszA << QString("(%1)").arg(dec_data.params.kszA) << " --> B:" << dec_data.params.kposB << dec_data.params.kposB + dec_data.params.kszB << QString("(%1)").arg(dec_data.params.kszB) << " --> C:" << dec_data.params.kposC << dec_data.params.kposC + dec_data.params.kszC << QString("(%1)").arg(dec_data.params.kszC) << " --> E:" << dec_data.params.kposE << dec_data.params.kposE + dec_data.params.kszE << QString("(%1)").arg(dec_data.params.kszE) << " --> I:" << dec_data.params.kposI << dec_data.params.kposI + dec_data.params.kszI << QString("(%1)").arg(dec_data.params.kszI); m_decoder.decode(); } /** * @brief MainWindow::decodeBusy * mark the decoder as currently busy (to prevent overlapping decodes) * @param b - true if busy, false otherwise */ void MainWindow::decodeBusy(bool b) // decodeBusy() { m_decoderBusy = b; if (m_decoderBusy) { tx_status_label.setText("Decoding"); m_decoderBusyStartTime = QDateTime:: currentDateTimeUtc(); // DriftingDateTime::currentDateTimeUtc(); m_decoderBusyFreq = dialFrequency(); m_decoderBusyBand = m_config.bands()->find(m_decoderBusyFreq); } } /** * @brief MainWindow::decodeDone * clean up after a decode is finished */ void MainWindow::decodeDone() { // critical section QMutexLocker mutex(m_detector->getMutex()); dec_data.params.newdat = false; m_RxLog = 0; // cleanup old cached messages (messages > submode period old) std::erase_if(m_messageDupeCache, [](auto const &it) { return it.second.secsTo(QDateTime::currentDateTimeUtc()) > JS8::Submode::period(it.first.submode); }); decodeBusy(false); } QDateTime MainWindow::nextTransmitCycle() { auto timestamp = DriftingDateTime::currentDateTimeUtc(); // remove milliseconds auto t = timestamp.time(); t.setHMS(t.hour(), t.minute(), t.second()); timestamp.setTime(t); // round to 15 second increment int secondsSinceEpoch = (timestamp.toMSecsSinceEpoch() / 1000); int delta = roundUp(secondsSinceEpoch, m_TRperiod) + 1 - secondsSinceEpoch; timestamp = timestamp.addSecs(delta); return timestamp; } QList generateOffsets(int minOffset, int maxOffset) { QList offsets; // TODO: these offsets aren't ordered correctly... for (int i = minOffset; i <= maxOffset; i++) { offsets.append(i); } return offsets; } void MainWindow::processDecodeEvent(JS8::Event::Variant const &event) { static QList driftQueue; static qint32 syncStart = -1; std::visit( [this](auto &&e) { using T = std::decay_t; if constexpr (std::is_same_v) { if (m_wideGraph->shouldDisplayDecodeAttempts()) { m_wideGraph->drawHorizontalLine(QColor(Qt::yellow), 0, 5); } } else if constexpr (std::is_same_v) { syncStart = e.position; } else if constexpr (std::is_same_v) { if (m_wideGraph->shouldDisplayDecodeAttempts()) { auto const drawDecodeLine = [this, freq = static_cast(e.frequency), mode = e.mode](QColor const &color) { m_wideGraph->drawDecodeLine( color, freq, freq + JS8::Submode::bandwidth(mode)); }; if (e.type == JS8::Event::SyncState::Type::DECODED) { drawDecodeLine(Qt::red); } else if (auto const xdtMs = static_cast(e.dt * 1000); std::abs(xdtMs) <= 2000) { if (e.sync.candidate < 10) drawDecodeLine(Qt::darkCyan); else if (e.sync.candidate <= 15) drawDecodeLine(Qt::cyan); else if (e.sync.candidate <= 21) drawDecodeLine(Qt::white); } } } else if constexpr (std::is_same_v) { qCDebug(decoder_js8) << "decode duration" << m_decoderBusyStartTime.msecsTo( QDateTime::currentDateTimeUtc()) << "ms"; // TODO: move this into a function if (!driftQueue.isEmpty()) { if (m_driftMsMMA_N == 0) { m_driftMsMMA_N = 1; m_driftMsMMA = DriftingDateTime::drift(); } // let the widegraph know for timing control m_wideGraph->notifyDriftedSignalsDecoded( driftQueue.count()); while (!driftQueue.isEmpty()) { qint32 newDrift = driftQueue.first(); driftQueue.removeFirst(); m_driftMsMMA = (((m_driftMsMMA_N - 1) * m_driftMsMMA) + newDrift) / m_driftMsMMA_N; if (m_driftMsMMA_N < 60) m_driftMsMMA_N++; // cap it to 60 observations } // XXX The following lines do nothing; it's a completely dead // store. For // now, just #ifdefing them out, but they were in // the 2.2.1-devel code, and presumably they were important; // need to see what the intent was here. #if 0 qint32 driftLimitMs = JS8::Submode::period(Varicode::JS8CallNormal) * 1000; qint32 newDriftMs = m_driftMsMMA; if(newDriftMs < 0){ newDriftMs = -((-newDriftMs) % driftLimitMs); } else { newDriftMs = ((newDriftMs) % driftLimitMs); } #endif setDrift(m_driftMsMMA); // writeNoticeTextToUI(QDateTime::currentDateTimeUtc(), // QString("Automatic Drift: %1").arg(driftAvg)); } m_bDecoded = e.decoded > 0; decodeDone(); } else if constexpr (std::is_same_v) { // A frame is valid if we haven't seen the same frame in the // past 1/2 decode period. // // Note: Success here depends on decodes ordered such that // frequencies // near `dec_data.params.nfqso` arrive here first, so it's // key to process the decode candidates in an ordered // manner, likely by sorting the raw take from the initial // selection pass. DecodedText decodedtext(e); FrameCacheKey dedupeKey(decodedtext.submode(), decodedtext.frame()); if (auto const it = m_messageDupeCache.find(dedupeKey); it != m_messageDupeCache.end()) { if (it->second.secsTo(QDateTime::currentDateTimeUtc()) < 0.5 * JS8::Submode::period(decodedtext.submode())) { qCDebug(mainwindow_js8) << "duplicate frame at" << it->second << "using key" << QString("%1:%2") .arg(dedupeKey.submode) .arg(dedupeKey.frame); return; } } #if 0 // frames are valid if they meet our minimum rx threshold for the submode bool bValidFrame = decodedtext.snr() >= JS8::Submode::rxSNRThreshold(decodedtext.submode()); qCDebug(mainwindow_js8) << "valid" << bValidFrame << JS8::Submode::name(decodedtext.submode()) << "decoded text" << decodedtext.message(); // skip if invalid if(!bValidFrame) { return; } #else qCDebug(mainwindow_js8) << JS8::Submode::name(decodedtext.submode()) << "decoded text" << decodedtext.message(); #endif // TODO: move this into a function // compute time drift for non-dupe messages if (m_wideGraph->shouldAutoSyncSubmode(decodedtext.submode())) { int m = decodedtext.submode(); float xdt = decodedtext.dt(); // if we're here at this point, we _should_ be operating a // decode every second // // so we need to figure out where: // // 1) this current decode started // 2) when that cycle _should_ have started // 3) compute the delta // 4) apply the drift qint32 periodMs = 1000 * JS8::Submode::period(m); // writeNoticeTextToUI(now, QString("Decode at %1 (kin: %2, // lastDecoded: // %3)").arg(syncStart).arg(dec_data.params.kin).arg(m_lastDecodeStartMap.value(m))); float expectedStartDelay = JS8::Submode::startDelayMS(m) / 1000.0; float decodedSignalTime = (float)syncStart / (float)JS8_RX_SAMPLE_RATE; // writeNoticeTextToUI(now, QString("--> started at %1 // seconds into the start of my drifted // minute").arg(decodedSignalTime)); // writeNoticeTextToUI(now, QString("--> we add a time delta // of %1 seconds into the start of the cycle").arg(xdt)); // adjust for expected start delay decodedSignalTime -= expectedStartDelay; // adjust for time delta decodedSignalTime += xdt; // ensure that we are within a 60 second minute if (decodedSignalTime < 0) { decodedSignalTime += 60.0f; } else if (decodedSignalTime > 60) { decodedSignalTime -= 60.0f; } // writeNoticeTextToUI(now, QString("--> so signal adjusted // started at %1 seconds into the start of my drifted // minute").arg(decodedSignalTime)); qint32 decodedSignalTimeMs = 1000 * decodedSignalTime; qint32 cycleStartTimeMs = (decodedSignalTimeMs / periodMs) * periodMs; qint32 driftMs = cycleStartTimeMs - decodedSignalTimeMs; // writeNoticeTextToUI(now, QString("--> which is a drift // adjustment of %1 milliseconds").arg(driftMs)); // if we have a large negative offset (say -14000), use the // positive inverse of +1000 if (driftMs + periodMs < qAbs(driftMs)) { driftMs += periodMs; } // if we have a large positive offset (say 14000, use the // negative inverse of -1000) else if (qAbs(driftMs - periodMs) < driftMs) { driftMs -= periodMs; } // writeNoticeTextToUI(now, QString("--> which is a // corrected drift adjustment of %1 // milliseconds").arg(driftMs)); qint32 newDrift = DriftingDateTime::drift() + driftMs; if (newDrift < 0) { newDrift %= -periodMs; } else { newDrift %= periodMs; } // writeNoticeTextToUI(now, QString("--> which is rounded to // a total drift of %1 milliseconds for this // period").arg(newDrift)); driftQueue.append(newDrift); } // if the frame is valid, cache it! m_messageDupeCache.insert_or_assign( dedupeKey, QDateTime::currentDateTimeUtc()); // log valid frames to ALL.txt (and correct their timestamp // format) auto freq = dialFrequency(); // if we changed frequencies, use the old frequency that we // started the decode with if (m_decoderBusyFreq != freq) { freq = m_decoderBusyFreq; } auto date = DriftingDateTime::currentDateTimeUtc().toString( "yyyy-MM-dd"); writeAllTxt(date + " " + decodedtext.string() + " " + decodedtext.message()); /** * @brief Send decode to WSJT-X protocol * * Converts JS8Call decode events to WSJT-X Decode messages and * sends them to WSJT-X protocol clients. For HeartBeat * messages, ensures the message text includes callsign and grid * so clients can properly associate them with grid plots. */ if (m_wsjtxMessageMapper && m_config.wsjtx_protocol_enabled()) { // Convert decode time from JS8Call format to QTime auto const hms = decode_time(decodedtext.time()); QTime decode_time = QTime(hms.hour, hms.minute, hms.second); // Send decode message // Use "JS8" as the mode string (WSJT-X expects mode names // like "FT8", "FT4", "JT9", etc.) m_wsjtxMessageMapper->sendDecode( true, // is_new - always true for new decodes decode_time, decodedtext.snr(), decodedtext.dt(), static_cast(decodedtext.frequencyOffset()), "JS8", // mode string decodedtext.message(), decodedtext.isLowConfidence()); } ActivityDetail d = {}; CallDetail cd = {}; CommandDetail cmd = {}; CallDetail td = {}; // Parse General Activity #if 1 bool shouldParseGeneralActivity = true; if (shouldParseGeneralActivity && !decodedtext.messageWords().isEmpty()) { int offset = decodedtext.frequencyOffset(); if (!m_bandActivity.contains(offset)) { int const range = JS8::Submode::rxThreshold(decodedtext.submode()); QList offsets = generateOffsets(offset - range, offset + range); foreach (int prevOffset, offsets) { if (!m_bandActivity.contains(prevOffset)) { continue; } m_bandActivity[offset] = m_bandActivity[prevOffset]; m_bandActivity.remove(prevOffset); break; } } // ActivityDetail d = {}; d.isLowConfidence = decodedtext.isLowConfidence(); d.isCompound = decodedtext.isCompound(); d.isDirected = decodedtext.isDirectedMessage(); d.bits = decodedtext.bits(); d.dial = freq; d.offset = offset; d.text = decodedtext.message(); d.utcTimestamp = DriftingDateTime::currentDateTimeUtc(); d.snr = decodedtext.snr(); d.isBuffered = false; d.submode = decodedtext.submode(); d.tdrift = m_wideGraph->shouldAutoSyncSubmode(d.submode) ? DriftingDateTime::drift() / 1000.0 : decodedtext.dt(); // if we have any "first" frame, and a buffer is already // established, clear it... int prevBufferOffset = -1; if (((d.bits & Varicode::JS8CallFirst) == Varicode::JS8CallFirst) && hasExistingMessageBuffer(decodedtext.submode(), d.offset, true, &prevBufferOffset)) { qCDebug(mainwindow_js8) << "first message encountered, " "clearing existing buffer" << prevBufferOffset; m_messageBuffer.remove(d.offset); } // if we have a data frame, and a message buffer has been // established, buffer it... if (hasExistingMessageBuffer(decodedtext.submode(), d.offset, true, &prevBufferOffset) && !decodedtext.isCompound() && !decodedtext.isDirectedMessage()) { qCDebug(mainwindow_js8) << "buffering data" << d.dial << d.offset << d.text; d.isBuffered = true; m_messageBuffer[d.offset].msgs.append(d); // TODO: incremental display if it's "to" me. } m_rxActivityQueue.append(d); m_bandActivity[offset].append(d); while (m_bandActivity[offset].count() > 10) { m_bandActivity[offset].removeFirst(); } } #endif // Process compound callsign commands (put them in cache)" #if 1 qCDebug(mainwindow_js8) << "decoded" << decodedtext.frameType() << decodedtext.isCompound() << decodedtext.isDirectedMessage() << decodedtext.isHeartbeat(); bool shouldProcessCompound = true; if (shouldProcessCompound && decodedtext.isCompound() && !decodedtext.isDirectedMessage()) { cd.call = decodedtext.compoundCall(); cd.grid = decodedtext.extra(); // compound calls via pings // may contain grid... cd.snr = decodedtext.snr(); cd.dial = freq; cd.offset = decodedtext.frequencyOffset(); cd.utcTimestamp = DriftingDateTime::currentDateTimeUtc(); cd.bits = decodedtext.bits(); cd.submode = decodedtext.submode(); cd.tdrift = m_wideGraph->shouldAutoSyncSubmode(d.submode) ? DriftingDateTime::drift() / 1000.0 : decodedtext.dt(); // Only respond to HEARTBEATS...remember that CQ messages // are "Alt" pings if (decodedtext.isHeartbeat()) { if (decodedtext.isAlt()) { // this is a cq with a standard or compound call, // ala "KN4CRD/P: @ALLCALL CQ CQ CQ" cd.cqTimestamp = DriftingDateTime::currentDateTimeUtc(); // convert CQ to a directed command and process... cmd.from = cd.call; cmd.to = "@ALLCALL"; cmd.cmd = " CQ"; cmd.snr = cd.snr; cmd.bits = cd.bits; cmd.grid = cd.grid; cmd.dial = cd.dial; cmd.offset = cd.offset; cmd.utcTimestamp = cd.utcTimestamp; cmd.tdrift = cd.tdrift; cmd.submode = cd.submode; cmd.text = decodedtext.message(); // TODO: check bits so we only auto respond to // "finished" cqs m_rxCommandQueue.append(cmd); // since this is no longer processed here we omit // logging it here. if we change this behavior, we'd // change this back to logging here. // logCallActivity(cd, true); // notification for cq tryNotify("cq"); } else { // convert HEARTBEAT to a directed command and // process... cmd.from = cd.call; cmd.to = "@HB"; cmd.cmd = " HEARTBEAT"; cmd.snr = cd.snr; cmd.bits = cd.bits; cmd.grid = cd.grid; cmd.dial = cd.dial; cmd.offset = cd.offset; cmd.utcTimestamp = cd.utcTimestamp; cmd.tdrift = cd.tdrift; cmd.submode = cd.submode; // TODO: check bits so we only auto respond to // "finished" heartbeats m_rxCommandQueue.append(cmd); // notification for hb tryNotify("hb"); } } else { qCDebug(mainwindow_js8) << "buffering compound call" << cd.offset << cd.call << cd.bits; hasExistingMessageBuffer(cd.submode, cd.offset, true, nullptr); m_messageBuffer[cd.offset].compound.append(cd); } } #endif // Parse commands // KN4CRD K1JT ? #if 1 bool shouldProcessDirected = true; if (shouldProcessDirected && decodedtext.isDirectedMessage()) { auto parts = decodedtext.directedMessage(); cmd.from = parts.at(0); cmd.to = parts.at(1); cmd.cmd = parts.at(2); cmd.dial = freq; cmd.offset = decodedtext.frequencyOffset(); cmd.snr = decodedtext.snr(); cmd.utcTimestamp = DriftingDateTime::currentDateTimeUtc(); cmd.bits = decodedtext.bits(); cmd.extra = parts.length() > 2 ? parts.mid(3).join(" ") : ""; cmd.submode = decodedtext.submode(); cmd.tdrift = m_wideGraph->shouldAutoSyncSubmode(cmd.submode) ? DriftingDateTime::drift() / 1000.0 : decodedtext.dt(); // if the command is a buffered command and its not the last // frame OR we have from or to in a separate message // (compound call) if ((Varicode::isCommandBuffered(cmd.cmd) && (cmd.bits & Varicode::JS8CallLast) != Varicode::JS8CallLast) || cmd.from == "<....>" || cmd.to == "<....>") { qCDebug(mainwindow_js8) << "buffering cmd" << cmd.dial << cmd.offset << cmd.cmd << cmd.from << cmd.to; // log complete buffered callsigns immediately if (cmd.from != "<....>" && cmd.to != "<....>") { CallDetail cmdcd = {}; cmdcd.call = cmd.from; cmdcd.bits = cmd.bits; cmdcd.snr = cmd.snr; cmdcd.dial = cmd.dial; cmdcd.offset = cmd.offset; cmdcd.utcTimestamp = cmd.utcTimestamp; cmdcd.ackTimestamp = cmd.to == m_config.my_callsign() ? cmd.utcTimestamp : QDateTime{}; cmdcd.tdrift = cmd.tdrift; cmdcd.submode = cmd.submode; logCallActivity(cmdcd, false); logHeardGraph(cmd.from, cmd.to); } // merge any existing buffer to this frequency hasExistingMessageBuffer(cmd.submode, cmd.offset, true, nullptr); if (cmd.to == m_config.my_callsign()) { d.shouldDisplay = true; } m_messageBuffer[cmd.offset].cmd = cmd; m_messageBuffer[cmd.offset].msgs.clear(); } else { m_rxCommandQueue.append(cmd); } // check to see if this is a station we've heard 3rd party bool shouldCaptureThirdPartyCallsigns = false; if (shouldCaptureThirdPartyCallsigns && Radio::base_callsign(cmd.to) != Radio::base_callsign(m_config.my_callsign())) { QString relayCall = QString("%1|%2") .arg(Radio::base_callsign(cmd.from)) .arg(Radio::base_callsign(cmd.to)); int snr = -100; if (parts.length() == 4) { snr = QString(parts.at(3)).toInt(); } // CallDetail td = {}; td.through = cmd.from; td.call = cmd.to; td.grid = ""; td.snr = snr; td.dial = cmd.dial; td.offset = cmd.offset; td.utcTimestamp = cmd.utcTimestamp; td.tdrift = cmd.tdrift; td.submode = cmd.submode; logCallActivity(td, true); logHeardGraph(cmd.from, cmd.to); } } #endif } }, event); } bool MainWindow::hasExistingMessageBufferToMe(int *const pOffset) { for (auto const [offset, buffer] : m_messageBuffer.asKeyValueRange()) { // if this is a valid buffer and it's to me... if (buffer.cmd.utcTimestamp.isValid() && (buffer.cmd.to == m_config.my_callsign() || buffer.cmd.to == Radio::base_callsign(m_config.my_callsign()))) { if (pOffset) *pOffset = offset; return true; } } return false; } bool MainWindow::hasExistingMessageBuffer(int submode, int offset, bool drift, int *pPrevOffset) { if (m_messageBuffer.contains(offset)) { if (pPrevOffset) *pPrevOffset = offset; return true; } int const range = JS8::Submode::rxThreshold(submode); QList offsets = generateOffsets(offset - range, offset + range); foreach (int prevOffset, offsets) { if (!m_messageBuffer.contains(prevOffset)) { continue; } if (drift) { m_messageBuffer[offset] = m_messageBuffer[prevOffset]; m_messageBuffer.remove(prevOffset); } if (pPrevOffset) *pPrevOffset = prevOffset; return true; } return false; } bool MainWindow::hasClosedExistingMessageBuffer(int offset) { #if 0 int range = 10; if(m_nSubMode == Varicode::JS8CallFast){ range = 16; } if(m_nSubMode == Varicode::JS8CallTurbo){ range = 32; } return offset - range <= m_lastClosedMessageBufferOffset && m_lastClosedMessageBufferOffset <= offset + range; #elif 0 int range = 10; if (m_nSubMode == Varicode::JS8CallFast) { range = 16; } if (m_nSubMode == Varicode::JS8CallTurbo) { range = 32; } return m_lastClosedMessageBufferOffset - range <= offset && offset <= m_lastClosedMessageBufferOffset + range; #else Q_UNUSED(offset); #endif return false; } void MainWindow::logCallActivity(CallDetail d, bool spot) { // don't log empty calls if (d.call.trimmed().isEmpty()) { return; } // don't log relay calls if (d.call.contains(">")) { return; } if (m_callActivity.contains(d.call)) { // update (keep grid) CallDetail old = m_callActivity[d.call]; if (d.grid.isEmpty() && !old.grid.isEmpty()) { d.grid = old.grid; } if (!d.ackTimestamp.isValid() && old.ackTimestamp.isValid()) { d.ackTimestamp = old.ackTimestamp; } if (!d.cqTimestamp.isValid() && old.cqTimestamp.isValid()) { d.cqTimestamp = old.cqTimestamp; } m_callActivity[d.call] = d; } else { // create m_callActivity[d.call] = d; // notification of old and new callsigns if (m_logBook.hasWorkedBefore(d.call, "")) { tryNotify("call_old"); } else { tryNotify("call_new"); } } // enqueue for spotting to psk reporter if (spot) { m_rxCallQueue.append(d); } } void MainWindow::logHeardGraph(QString from, QString to) { auto my_callsign = m_config.my_callsign(); // hearing if (m_heardGraphOutgoing.contains(my_callsign)) { m_heardGraphOutgoing[my_callsign].insert(from); } else { m_heardGraphOutgoing[my_callsign].insert(from); } // heard by if (m_heardGraphIncoming.contains(from)) { m_heardGraphIncoming[from].insert(my_callsign); } else { m_heardGraphIncoming[from] = {my_callsign}; } if (to == "@ALLCALL") { return; } // hearing if (m_heardGraphOutgoing.contains(from)) { m_heardGraphOutgoing[from].insert(to); } else { m_heardGraphOutgoing[from] = {to}; } // heard by if (m_heardGraphIncoming.contains(to)) { m_heardGraphIncoming[to].insert(from); } else { m_heardGraphIncoming[to] = {from}; } } QString MainWindow::lookupCallInCompoundCache(QString const &call) { QString myBaseCall = Radio::base_callsign(m_config.my_callsign()); if (call == myBaseCall) { return m_config.my_callsign(); } return m_compoundCallCache.value(call, call); } void MainWindow::spotReport(int const submode, int const dial, int const offset, int const snr, QString const &callsign, QString const &grid) { if (!m_config.spot_to_reporting_networks() || (m_config.spot_blacklist().contains(callsign) || m_config.spot_blacklist().contains(Radio::base_callsign(callsign)))) return; Q_EMIT spotClientEnqueueSpot(callsign, grid, submode, dial, offset, snr); } void MainWindow::spotCmd(CommandDetail const &cmd) { if (!m_config.spot_to_reporting_networks() || (m_config.spot_blacklist().contains(cmd.from) || m_config.spot_blacklist().contains(Radio::base_callsign(cmd.from)))) return; QString cmdStr = cmd.cmd; if (!cmdStr.trimmed().isEmpty()) { cmdStr = Varicode::lstrip(cmd.cmd); } Q_EMIT spotClientEnqueueCmd(cmdStr, cmd.from, cmd.to, cmd.relayPath, cmd.text, cmd.grid, cmd.extra, cmd.submode, cmd.dial, cmd.offset, cmd.snr); } // KN4CRD: @APRSIS CMD :EMAIL-2 :email@domain.com booya{1 void MainWindow::spotAprsCmd(CommandDetail const &cmd) { if (!m_config.spot_to_reporting_networks()) return; if (!m_config.spot_to_aprs()) return; if (m_config.spot_blacklist().contains(cmd.from) || m_config.spot_blacklist().contains(Radio::base_callsign(cmd.from))) return; if (cmd.cmd != " CMD") return; qCDebug(mainwindow_js8) << "APRSISClient Enqueueing Third Party Text" << cmd.from << cmd.text; auto by_call = APRSISClient::replaceCallsignSuffixWithSSID( m_config.my_callsign(), Radio::base_callsign(m_config.my_callsign())); auto from_call = APRSISClient::replaceCallsignSuffixWithSSID( cmd.from, Radio::base_callsign(cmd.from)); // we use a queued signal here so we can process these spots in a network // thread to prevent blocking the gui/decoder while waiting on TCP emit aprsClientEnqueueThirdParty(by_call, from_call, cmd.text); } void MainWindow::spotAprsGrid(int dial, int offset, int snr, QString callsign, QString grid) { if (!m_config.spot_to_reporting_networks()) return; if (!m_config.spot_to_aprs()) return; if (m_config.spot_blacklist().contains(callsign) || m_config.spot_blacklist().contains(Radio::base_callsign(callsign))) return; if (grid.length() < 4) return; Frequency frequency = dial + offset; auto comment = QString("%1MHz %2dB") .arg(Radio::frequency_MHz_string(frequency)) .arg(Varicode::formatSNR(snr)); if (callsign.contains("/")) { comment = QString("%1 %2").arg(callsign).arg(comment); } auto by_call = APRSISClient::replaceCallsignSuffixWithSSID( m_config.my_callsign(), Radio::base_callsign(m_config.my_callsign())); auto from_call = APRSISClient::replaceCallsignSuffixWithSSID( callsign, Radio::base_callsign(callsign)); // we use a queued signal here so we can process these spots in a network // thread to prevent blocking the gui/decoder while waiting on TCP emit aprsClientEnqueueSpot(by_call, from_call, grid, comment); } void MainWindow::pskLogReport(QString const &mode, int const dial, int const offset, int const snr, QString const &callsign, QString const &grid, QDateTime const &utcTimestamp) { if (!m_config.spot_to_reporting_networks() || (m_config.spot_blacklist().contains(callsign) || m_config.spot_blacklist().contains(Radio::base_callsign(callsign)))) return; Q_EMIT pskReporterAddRemoteStation(callsign, grid, dial + offset, mode, snr, utcTimestamp); } void MainWindow::refuseToSendIn30mWSPRBand() { if (m_transmitting or m_auto or m_tune) { m_dateTimeLastTX = DriftingDateTime::currentDateTimeLocal(); // Don't transmit another mode in the 30 m WSPR sub-band Frequency onAirFreq = m_freqNominal + freq(); // qCDebug(mainwindow_js8) << "transmitting on" << onAirFreq; if (10139900 <= onAirFreq && onAirFreq <= 10140320) { qCWarning(mainwindow_js8) << "QRG" << onAirFreq << "found to be in WSPR guard band 10139.9 - 10140.32 kHz " "where this programm will not transmit, so canceling all " "transmissions."; m_isTimeToSend = false; if (m_auto) auto_tx_mode(false); if (m_hb_loop->isActive()) { m_hb_loop->onLoopCancel(); } if (m_cq_loop->isActive()) { m_cq_loop->onLoopCancel(); } if (onAirFreq != m_onAirFreq0) { m_onAirFreq0 = onAirFreq; QTimer::singleShot(0, [this] { JS8MessageBox::warning_message( this, tr("WSPR Guard Band"), tr("Please choose another Tx frequency." " The app will not knowingly transmit another" " mode in the WSPR sub-band on 30m.")); }); } } } } void MainWindow::prepareSending(qint64 nowMS) { // TX Duration in seconds. const double tx_duration = JS8::Submode::txDuration(m_nSubMode); const unsigned period = JS8::Submode::period(m_nSubMode); const double seconds_into_the_period = (nowMS % (period * 1000)) / 1000.0; const double tx_delay = m_TxDelay; const bool time_is_in_tx_delay = (period - tx_delay) <= seconds_into_the_period; // Are we during the time we might be sending? const bool m_timeToSend = ((0 <= seconds_into_the_period) and (seconds_into_the_period < tx_duration)) or time_is_in_tx_delay or m_tune; auto const msgLength = QStringView(m_nextFreeTextMsg).trimmed().length(); // TODO: stop if (msgLength == 0 && !m_tune) { m_stopTxButtonIsLongterm = false; this->on_stopTxButton_clicked(); m_stopTxButtonIsLongterm = true; } double const fraction_of_tx_slot = seconds_into_the_period / period; // 15.0 - 12.6 double const ratio = JS8::Submode::computeRatio(m_nSubMode, m_TRperiod); // the late threshold is the dead air time minus the tx delay time float lateThreshold = ratio - (m_config.txDelay() / m_TRperiod); if (m_nSubMode == Varicode::JS8CallFast) { // for the faster mode, only allow 3/4 late threshold lateThreshold *= 0.75; } else if (m_nSubMode == Varicode::JS8CallTurbo || m_nSubMode == Varicode::JS8CallUltra) { // for the turbo and ultra mode, only allow 1/2 late threshold lateThreshold *= 0.5; }; // qCDebug(mainwindow_js8) << "nowMS" << nowMS << "period" << period << // "tx_delay" << tx_delay // << "seconds_into_the_period" << // seconds_into_the_period // << "time_is_in_tx_delay" << time_is_in_tx_delay // << "fraction_of_tx_slot" << fraction_of_tx_slot // << "m_iptt" << m_iptt << "m_timeToSend" << // m_timeToSend << "msgLength" << msgLength << // "m_tune" << m_tune; if (m_iptt == 0 && ((m_timeToSend && (fraction_of_tx_slot < lateThreshold or time_is_in_tx_delay) && 0 < msgLength) || m_tune)) { // This signals the transmitter to switch to sending. // When that has happened, we get a callback from // handle_transceiver_update, which will start the audio. m_iptt = 1; m_generateAudioWhenPttConfirmedByTX = true; setRig(); setXIT(freq()); emitPTT(true); } // TODO: stop if (!m_timeToSend and !m_tune) m_btxok = false; // Time to stop transmitting // Calculate Tx tones when needed if ((m_iptt == 1 && m_iptt0 == 0) || m_restart) { //---------------------------------------------------------------------- copyMessage(m_nextFreeTextMsg, message); if (m_lastMessageSent != m_currentMessage || m_lastMessageType != m_currentMessageType) { m_lastMessageSent = m_currentMessage; m_lastMessageType = m_currentMessageType; } m_currentMessageType = 0; if (m_tune) { itone[0] = 0; } else { JS8::encode(m_i3bit, JS8::Costas::array(JS8::Submode::costas(m_nSubMode)), message, const_cast(reinterpret_cast( itone))); // XXX ick... std::fill_n(std::begin(msgsent), 22, ' '); std::copy_n(std::begin(message), 12, std::begin(msgsent)); if (mainwindow_js8().isDebugEnabled()) { qCDebug(mainwindow_js8) << "-> msg:" << message; qCDebug(mainwindow_js8) << "-> bit:" << m_i3bit; for (int i = 0; i < 7; ++i) qCDebug(mainwindow_js8) << "-> tone" << i << "=" << itone[i]; for (int i = JS8_NUM_SYMBOLS - 7; i < JS8_NUM_SYMBOLS; ++i) qCDebug(mainwindow_js8) << "-> tone" << i << "=" << itone[i]; } msgibits = m_i3bit; msgsent[22] = 0; m_currentMessage = QString::fromLatin1(msgsent).trimmed(); m_currentMessageBits = msgibits; emitTones(); } if (m_tune) { m_currentMessage = "TUNE"; m_currentMessageType = -1; } if (m_restart) { write_transmit_entry("ALL.TXT"); } auto msg_parts = m_currentMessage.split(' ', Qt::SkipEmptyParts); if (msg_parts.size() > 2) { // clean up short code forms msg_parts[0].remove(QChar{'<'}); msg_parts[1].remove(QChar{'>'}); } if ((m_currentMessageType < 6 || 7 == m_currentMessageType) && msg_parts.length() >= 3 && (msg_parts[1] == m_config.my_callsign() || msg_parts[1] == m_baseCall)) { int i1; bool ok; i1 = msg_parts[2].toInt(&ok); if (ok and i1 >= -50 and i1 < 50) { m_rptSent = msg_parts[2]; } else { if (msg_parts[2].mid(0, 1) == "R") { i1 = msg_parts[2].mid(1).toInt(&ok); if (ok and i1 >= -50 and i1 < 50) { m_rptSent = msg_parts[2].mid(1); } } } } m_restart = false; //---------------------------------------------------------------------- } if (m_iptt == 1 && m_iptt0 == 0) { auto const ¤t_message = QString::fromLatin1(msgsent); if (m_config.watchdog() && current_message != m_msgSent0) { // new messages don't reset the idle timer :| // tx_watchdog (false); // in case we are auto sequencing m_msgSent0 = current_message; } if (!m_tune) { write_transmit_entry("ALL.TXT"); } // TODO: jsherer - perhaps an on_transmitting signal? m_lastTxStartTime = DriftingDateTime::currentDateTimeUtc(); m_transmitting = true; transmitDisplay(true); statusUpdate(); } // TODO: stop if (!m_btxok && m_btxok0 && m_iptt == 1) stopTx(); } void MainWindow::updateClockUI(const QDateTime &now) { qint64 drift = DriftingDateTime::drift(); QStringList parts; parts << (now.time().toString() + (!drift ? " " : QString(" (%1%2ms)").arg(drift > 0 ? "+" : "").arg(drift))); parts << now.date().toString("yyyy MMM dd"); ui->labUTC->setText(parts.join("\n")); } //------------------------------------------------------------- //guiUpdate() void MainWindow::guiUpdate() { unsigned period = JS8::Submode::period(m_nSubMode); m_TRperiod = period; // Investigate: Does anyone need this? // Propagate any tx delay change to m_hb_loop and m_cq_loop. double tx_delay_now = m_config.txDelay(); if (tx_delay_now != m_TxDelay) { m_TxDelay = tx_delay_now; qint64 tx_delay_ms = std::lround(tx_delay_now * 1000); m_hb_loop->onTxDelayChange(tx_delay_ms); m_cq_loop->onTxDelayChange(tx_delay_ms); } const QDateTime now = DriftingDateTime::currentDateTimeUtc(); const qint64 seconds_since_epoch = now.toSecsSinceEpoch(); if (m_transmitting or m_auto or m_tune) { refuseToSendIn30mWSPRBand(); prepareSending(now.toMSecsSinceEpoch()); } // Once per second: if (seconds_since_epoch != m_sec0) { m_sec0 = seconds_since_epoch; updateClockUI(now); if (m_monitoring or m_transmitting) { // We are lucky that TX delay starts well into the second // and lasts less than a second. So as long as we // do this near the begining of a second, we will never hit // the confusing "progress" of tx delay. progressBar.setMaximum(period); int progress = seconds_since_epoch % period; progressBar.setValue(progress); } else { progressBar.setValue(0); } if (m_transmitting) { tx_status_label.setStyleSheet( "QLabel{background-color: #ff2222; color:#000}"); if (m_tune) { tx_status_label.setText("Tx: TUNE"); } else { auto message = DecodedText(msgsent, msgibits, m_nSubMode).message(); tx_status_label.setText( QString("Tx: %1").arg(message).left(40).trimmed()); } transmitDisplay(true); } else if (m_monitoring) { if (m_tx_watchdog) { tx_status_label.setStyleSheet( "QLabel{background-color: #000; color:#fff}"); tx_status_label.setText("Idle timeout"); } else { tx_status_label.setStyleSheet( "QLabel{background-color: #22ff22}"); tx_status_label.setText(m_decoderBusy ? "Decoding" : "Receiving"); } transmitDisplay(false); } else if (!m_tx_watchdog) { tx_status_label.setStyleSheet(""); tx_status_label.setText(""); } auto callLabel = m_config.my_callsign(); if (m_config.use_dynamic_grid() && !m_config.my_grid().isEmpty()) { callLabel = QString("%1 - %2").arg(callLabel).arg(m_config.my_grid()); } ui->labCallsign->setText(callLabel); if (!m_monitoring) { ui->signal_meter_widget->setValue(0, 0); } // once per period if (seconds_since_epoch % period == 0) { tryBandHop(); } // Need to do processing at the end of the period // or when there is something in m_rxActivityQueue. bool forceDirty = (seconds_since_epoch % period == 0) || ((seconds_since_epoch - 1) % period == 0) || !m_rxActivityQueue.isEmpty(); // update the dial frequency once per second.. displayDialFrequency(); updateHBButtonDisplay(); updateCQButtonDisplay(); // once per second...but not when we're transmitting, unless it's in the // first second... if (!m_transmitting || (seconds_since_epoch % period == 0)) { // process all received activity... processActivity(forceDirty); // process outgoing tx queue... processTxQueue(); // once processed, lets update the display... displayActivity(forceDirty); updateButtonDisplay(); updateTextDisplay(); } } // end of stuff we do once per second. displayTransmit(); m_iptt0 = m_iptt; m_btxok0 = m_btxok; // Set the time to hit the start of the next UI_POLL_INTERVAL_MS slot. // This automatically hits close to the start of each second // and hence close to the start of each transmit period. qint64 now_at_end_ms = DriftingDateTime::currentMSecsSinceEpoch(); qint64 time_into_poll_slot = now_at_end_ms % UI_POLL_INTERVAL_MS; qint64 until_start_of_next_poll_slot = UI_POLL_INTERVAL_MS - time_into_poll_slot; m_guiTimer.start(until_start_of_next_poll_slot); } // End of guiUpdate void MainWindow::startTx() { #if IDLE_BLOCKS_TX if (m_tx_watchdog) { return; } #endif auto text = ui->extFreeTextMsgEdit->toPlainText(); if (!ensureCreateMessageReady(text)) { return; } if (!prepareNextMessageFrame()) { return; } m_dateTimeQSOOn = QDateTime{}; if (m_transmitting) m_restart = true; if (!m_auto) auto_tx_mode(true); // disallow editing of the text while transmitting // ui->extFreeTextMsgEdit->setReadOnly(true); update_dynamic_property(ui->extFreeTextMsgEdit, "transmitting", true); // update the tx button display updateTxButtonDisplay(); } void MainWindow::transmit() { if (m_modulator->isIdle()) { qDebug(mainwindow_js8) << "Asking the modulator to emit audio."; Q_EMIT sendMessage(freq() + m_XIT, m_nSubMode, m_TxDelay, m_soundOutput, m_config.audio_output_channel()); ui->signal_meter_widget->setValue(0, 0); } else { qDebug(mainwindow_js8) << "Not asking the modulator to emit audio as " "modulator isn't idle."; } } void MainWindow::stopTx() { Q_EMIT endTransmitMessage(); auto dt = DecodedText(m_currentMessage.trimmed(), m_currentMessageBits, m_nSubMode); last_tx_label.setText("Last Tx: " + dt.message()); // m_currentMessage.trimmed()); // TODO: uncomment if we want to mark after the frame is sent. //// // start message marker //// // - keep track of the total message sent so far, and mark it having /// been sent / m_totalTxMessage.append(dt.message()); / /// ui->extFreeTextMsgEdit->setCharsSent(m_totalTxMessage.length()); / /// qCDebug(mainwindow_js8) << "total sent:\n" << m_totalTxMessage; / // end /// message marker m_btxok = false; m_transmitting = false; m_iptt = 0; m_lastTxStopTime = DriftingDateTime::currentDateTimeUtc(); if (!m_tx_watchdog) { tx_status_label.setStyleSheet(""); tx_status_label.setText(""); } #if IDLE_BLOCKS_TX bool shouldContinue = !m_tx_watchdog && prepareNextMessageFrame(); #else bool shouldContinue = prepareNextMessageFrame(); #endif if (!shouldContinue) { // TODO: jsherer - split this up... ui->extFreeTextMsgEdit->clear(); ui->extFreeTextMsgEdit->setReadOnly(false); update_dynamic_property(ui->extFreeTextMsgEdit, "transmitting", false); bool previous_stopTxButtonIsLongterm = m_stopTxButtonIsLongterm; m_stopTxButtonIsLongterm = false; on_stopTxButton_clicked(); m_stopTxButtonIsLongterm = previous_stopTxButtonIsLongterm; tryRestoreFreqOffset(); } pttReleaseTimer.start( TX_SWITCHOFF_DELAY); // end-of-transmission sequencer delay stopTx2 monitor(true); statusUpdate(); } /** * stopTx2 is called from stopTx to open the PTT */ void MainWindow::stopTx2() { // GM8JCF: m_txFrameCount is set to the number of frames to be transmitted // when the send button is pressed and remains at that count until the last // frame is transmitted. So, we keep the PTT ON so long as m_txFrameCount is // non-zero qCDebug(mainwindow_js8) << "stopTx2 frames left" << m_txFrameCount; // If we're holding the PTT and there are more frames to transmit, do not // emit the PTT signal if (!m_tune && m_config.hold_ptt() && m_txFrameCount > 0) { return; } // Otherwise, emit the PTT signal emitPTT(false); } void MainWindow::TxAgain() { auto_tx_mode(true); } void MainWindow::cacheActivity(QString key) { m_callActivityBandCache[key] = m_callActivity; m_bandActivityBandCache[key] = m_bandActivity; m_rxTextBandCache[key] = ui->textEditRX->toHtml(); m_heardGraphIncomingBandCache[key] = m_heardGraphIncoming; m_heardGraphOutgoingBandCache[key] = m_heardGraphOutgoing; } void MainWindow::restoreActivity(QString key) { if (m_callActivityBandCache.contains(key)) { m_callActivity = m_callActivityBandCache[key]; } if (m_bandActivityBandCache.contains(key)) { m_bandActivity = m_bandActivityBandCache[key]; } if (m_rxTextBandCache.contains(key)) { ui->textEditRX->setHtml(m_rxTextBandCache[key]); } if (m_heardGraphIncomingBandCache.contains(key)) { m_heardGraphIncoming = m_heardGraphIncomingBandCache[key]; } if (m_heardGraphOutgoingBandCache.contains(key)) { m_heardGraphOutgoing = m_heardGraphOutgoingBandCache[key]; } displayActivity(true); } void MainWindow::clearActivity() { qCDebug(mainwindow_js8) << "clear activity"; m_callSeenHeartbeat.clear(); m_compoundCallCache.clear(); m_rxCallCache.clear(); m_rxCallQueue.clear(); m_rxRecentCache.clear(); m_rxDirectedCache.clear(); m_rxCommandQueue.clear(); m_lastTxMessage.clear(); refreshInboxCounts(); resetTimeDeltaAverage(); clearBandActivity(); clearRXActivity(); clearCallActivity(); displayActivity(true); } void MainWindow::clearBandActivity() { qCDebug(mainwindow_js8) << "clear band activity"; m_bandActivity.clear(); ui->tableWidgetRXAll->setRowCount(0); resetTimeDeltaAverage(); displayBandActivity(); } void MainWindow::clearRXActivity() { qCDebug(mainwindow_js8) << "clear rx activity"; m_rxFrameBlockNumbers.clear(); m_rxActivityQueue.clear(); ui->textEditRX->clear(); // make sure to clear the read only and transmitting flags so there's always // a "way out" ui->extFreeTextMsgEdit->clear(); ui->extFreeTextMsgEdit->setReadOnly(false); update_dynamic_property(ui->extFreeTextMsgEdit, "transmitting", false); } void MainWindow::clearCallActivity() { qCDebug(mainwindow_js8) << "clear call activity"; m_callActivity.clear(); m_heardGraphIncoming.clear(); m_heardGraphOutgoing.clear(); ui->tableWidgetCalls->setRowCount(0); bool showIconColumn = false; createGroupCallsignTableRows(ui->tableWidgetCalls, "", showIconColumn); resetTimeDeltaAverage(); displayCallActivity(); } void MainWindow::createGroupCallsignTableRows(QTableWidget *table, QString const &selectedCall, bool &showIconColumn) { int count = 0; auto now = DriftingDateTime::currentDateTimeUtc(); int callsignAging = m_config.callsign_aging(); int startCol = 1; foreach (auto cd, m_callActivity.values()) { if (cd.call.trimmed().isEmpty()) { continue; } if (callsignAging && cd.utcTimestamp.secsTo(now) / 60 >= callsignAging) { continue; } count++; } table->horizontalHeaderItem(startCol)->setText( count == 0 ? columnLabel("Callsigns") : QString(columnLabel("Callsigns (%1)")).arg(count)); if (!m_config.avoid_allcall()) { table->insertRow(table->rowCount()); auto emptyItem = new QTableWidgetItem(""); emptyItem->setData(Qt::UserRole, QVariant("@ALLCALL")); table->setItem(table->rowCount() - 1, 0, emptyItem); auto item = new QTableWidgetItem(QString("@ALLCALL")); item->setData(Qt::UserRole, QVariant("@ALLCALL")); table->setItem(table->rowCount() - 1, startCol, item); table->setSpan(table->rowCount() - 1, startCol, 1, table->columnCount()); if (selectedCall == "@ALLCALL") { table->item(table->rowCount() - 1, 0)->setSelected(true); table->item(table->rowCount() - 1, startCol)->setSelected(true); } } auto groups = m_config.my_groups().values(); std::sort(groups.begin(), groups.end()); foreach (auto group, groups) { int col = 0; table->insertRow(table->rowCount()); bool hasMessage = m_rxInboxCountCache.value(group, 0) > 0; auto iconItem = new QTableWidgetItem(hasMessage ? "\u2691" : ""); iconItem->setData(Qt::UserRole, QVariant(group)); iconItem->setToolTip(hasMessage ? "Message Available" : ""); iconItem->setTextAlignment(Qt::AlignHCenter | Qt::AlignVCenter); table->setItem(table->rowCount() - 1, col++, iconItem); if (hasMessage) { showIconColumn = true; } auto item = new QTableWidgetItem(group); item->setData(Qt::UserRole, QVariant(group)); item->setToolTip(generateCallDetail(group)); table->setItem(table->rowCount() - 1, col, item); table->setSpan(table->rowCount() - 1, col, 1, table->columnCount()); if (selectedCall == group) { table->item(table->rowCount() - 1, 0)->setSelected(true); table->item(table->rowCount() - 1, col)->setSelected(true); } } } void MainWindow::displayTextForFreq(QString text, int freq, QDateTime date, bool isTx, bool isNewLine, bool isLast) { int lowFreq = freq / 10 * 10; int highFreq = lowFreq + 10; int block = -1; if (m_rxFrameBlockNumbers.contains(freq)) { block = m_rxFrameBlockNumbers[freq]; } else if (m_rxFrameBlockNumbers.contains(lowFreq)) { block = m_rxFrameBlockNumbers[lowFreq]; freq = lowFreq; } else if (m_rxFrameBlockNumbers.contains(highFreq)) { block = m_rxFrameBlockNumbers[highFreq]; freq = highFreq; } qCDebug(mainwindow_js8) << "existing block?" << block << freq; if (isNewLine) { m_rxFrameBlockNumbers.remove(freq); m_rxFrameBlockNumbers.remove(lowFreq); m_rxFrameBlockNumbers.remove(highFreq); block = -1; } block = writeMessageTextToUI(date, text, freq, isTx, block); // never cache tx or last lines if (/*isTx || */ isLast) { // reset the cache so we're always progressing forward m_rxFrameBlockNumbers.clear(); } else { m_rxFrameBlockNumbers.insert(freq, block); m_rxFrameBlockNumbers.insert(lowFreq, block); m_rxFrameBlockNumbers.insert(highFreq, block); } } void MainWindow::writeNoticeTextToUI(QDateTime date, QString text) { auto c = ui->textEditRX->textCursor(); c.movePosition(QTextCursor::End); if (c.block().length() > 1) { c.insertBlock(); } text = text.toHtmlEscaped(); c.insertBlock(); c.insertHtml(QString("%1 - %2") .arg(date.time().toString()) .arg(text)); c.movePosition(QTextCursor::End); ui->textEditRX->ensureCursorVisible(); ui->textEditRX->verticalScrollBar()->setValue( ui->textEditRX->verticalScrollBar()->maximum()); } int MainWindow::writeMessageTextToUI(QDateTime date, QString text, int freq, bool isTx, int block) { auto c = ui->textEditRX->textCursor(); // find an existing block (that does not contain an EOT marker) bool found = false; if (block != -1) { QTextBlock b = c.document()->findBlockByNumber(block); c.setPosition(b.position()); c.movePosition(QTextCursor::EndOfBlock, QTextCursor::KeepAnchor); auto blockText = c.selectedText(); c.clearSelection(); c.movePosition(QTextCursor::EndOfBlock, QTextCursor::MoveAnchor); if (!blockText.contains(m_config.eot())) { found = true; } } if (!found) { c.movePosition(QTextCursor::End); if (c.block().length() > 1) { c.insertBlock(); } } // fixup duplicate acks auto tc = c.document()->find(text); if (!tc.isNull() && tc.selectedText() == text && (text.contains(" ACK ") || text.contains(" HEARTBEAT SNR "))) { tc.select(QTextCursor::BlockUnderCursor); if (tc.selectedText().trimmed().startsWith(date.time().toString())) { qCDebug(mainwindow_js8) << "found" << tc.selectedText() << "so not displaying..."; return tc.blockNumber(); } } if (found) { c.clearSelection(); c.insertText(text); } else { text = text.toHtmlEscaped(); text = text.replace("\n", "
"); text = text.replace(" ", "  "); c.insertBlock(); c.insertHtml(QString("%1 - (%2) - %3") .arg(date.time().toString()) .arg(freq) .arg(text)); } if (isTx) { c.block().setUserState(State::TX); highlightBlock(c.block(), m_config.tx_text_font(), m_config.color_tx_foreground(), QColor(Qt::transparent)); } else { c.block().setUserState(State::RX); highlightBlock(c.block(), m_config.rx_text_font(), m_config.color_rx_foreground(), QColor(Qt::transparent)); } ui->textEditRX->ensureCursorVisible(); ui->textEditRX->verticalScrollBar()->setValue( ui->textEditRX->verticalScrollBar()->maximum()); return c.blockNumber(); } bool MainWindow::isMessageQueuedForTransmit() { return m_transmitting || m_txFrameCount > 0; } bool MainWindow::isInDecodeDelayThreshold(int ms) { if (!m_lastTxStopTime.isValid() || m_lastTxStopTime.isNull()) { return false; } return m_lastTxStopTime.msecsTo(DriftingDateTime::currentDateTimeUtc()) < ms; } void MainWindow::prependMessageText(QString text) { // don't add message text if we already have a transmission queued... if (isMessageQueuedForTransmit()) { return; } auto c = QTextCursor(ui->extFreeTextMsgEdit->textCursor()); c.movePosition(QTextCursor::Start); c.insertText(text); } void MainWindow::addMessageText(QString text, bool clear, bool selectFirstPlaceholder) { // don't add message text if we already have a transmission queued... if (isMessageQueuedForTransmit()) { return; } if (clear) { ui->extFreeTextMsgEdit->clear(); } QTextCursor c = ui->extFreeTextMsgEdit->textCursor(); if (c.hasSelection()) { c.removeSelectedText(); } int pos = c.position(); c.movePosition(QTextCursor::PreviousCharacter, QTextCursor::KeepAnchor); bool isSpace = c.selectedText().isEmpty() || c.selectedText().at(0).isSpace(); c.clearSelection(); c.setPosition(pos); if (!isSpace) { c.insertText(" "); } c.insertText(text); if (selectFirstPlaceholder) { auto match = QRegularExpression("(\\[[^\\]]+\\])") .match(ui->extFreeTextMsgEdit->toPlainText()); if (match.hasMatch()) { c.setPosition(match.capturedStart()); c.setPosition(match.capturedEnd(), QTextCursor::KeepAnchor); ui->extFreeTextMsgEdit->setTextCursor(c); } } ui->extFreeTextMsgEdit->setFocus(); } void MainWindow::confirmThenEnqueueMessage(int timeout, int priority, QString message, int offset, Callback c) { SelfDestructMessageBox *m = new SelfDestructMessageBox( timeout, "Autoreply Confirmation Required", QString("A transmission is queued for autoreply:\n\n%1\n\nWould you " "like to send this transmission?") .arg(message), QMessageBox::Question, QMessageBox::Yes | QMessageBox::No, QMessageBox::No, false, this); connect(m, &SelfDestructMessageBox::finished, this, [this, m, priority, message, offset, c](int) { // make sure we delete the message box later... m->deleteLater(); if (m->result() == QMessageBox::Yes) { enqueueMessage(priority, message, offset, c); } }); m->setWindowModality(Qt::NonModal); m->show(); } void MainWindow::enqueueMessage(int priority, QString message, int offset, Callback c) { m_txMessageQueue.enqueue(PrioritizedMessage{ DriftingDateTime::currentDateTimeUtc(), priority, message, offset, c}); } void MainWindow::resetMessage() { resetMessageUI(); resetMessageTransmitQueue(); } void MainWindow::resetMessageUI() { m_nextFreeTextMsg.clear(); ui->extFreeTextMsgEdit->clear(); ui->extFreeTextMsgEdit->setReadOnly(false); update_dynamic_property(ui->extFreeTextMsgEdit, "transmitting", false); if (ui->startTxButton->isChecked()) { ui->startTxButton->setChecked(false); } } bool MainWindow::ensureCallsignSet(bool alert) { if (m_config.my_callsign().trimmed().isEmpty()) { if (alert) JS8MessageBox::warning_message( this, tr("Please enter your callsign in the settings.")); openSettings(); return false; } if (m_config.my_grid().trimmed().isEmpty()) { if (alert) JS8MessageBox::warning_message( this, tr("Please enter your grid locator in the settings.")); openSettings(); return false; } return true; } bool MainWindow::ensureKeyNotStuck(QString const &text) { // be annoying and drop messages with all the same character to reduce // spam... if (text.length() > 5 && QString(text).replace(text.at(0), "").trimmed().isEmpty()) { return false; } return true; } bool MainWindow::ensureNotIdle() { if (!m_config.watchdog()) { return true; } if (m_idleMinutes < m_config.watchdog()) { return true; } tx_watchdog(true); // disable transmit and auto replies return false; } bool MainWindow::ensureCanTransmit() { return ui->monitorTxButton->isChecked(); } bool MainWindow::ensureCreateMessageReady(const QString &text) { if (text.isEmpty()) { return false; } if (!ensureCanTransmit()) { on_stopTxButton_clicked(); return false; } if (!ensureCallsignSet()) { on_stopTxButton_clicked(); return false; } if (!ensureNotIdle()) { on_stopTxButton_clicked(); return false; } if (!ensureKeyNotStuck(text)) { on_stopTxButton_clicked(); ui->monitorButton->setChecked(false); ui->monitorTxButton->setChecked(false); on_monitorButton_clicked(false); on_monitorTxButton_toggled(false); foreach (auto obj, this->children()) { if (obj->isWidgetType()) { auto wid = qobject_cast(obj); wid->setEnabled(false); } } return false; } return true; } QString MainWindow::createMessage(QString const &text, bool *pDisableTypeahead) { return createMessageTransmitQueue( replaceMacros(text, buildMacroValues(), false), true, false, pDisableTypeahead); } QString MainWindow::appendMessage(QString const &text, bool isData, bool *pDisableTypeahead) { return createMessageTransmitQueue( replaceMacros(text, buildMacroValues(), false), false, isData, pDisableTypeahead); } QString MainWindow::createMessageTransmitQueue(QString const &text, bool reset, bool isData, bool *pDisableTypeahead) { if (reset) { resetMessageTransmitQueue(); } auto frames = buildMessageFrames(text, isData, pDisableTypeahead); QStringList lines; foreach (auto frame, frames) { auto dt = DecodedText(frame.first, frame.second, m_nSubMode); lines.append(dt.message()); } m_txFrameQueue.append(frames); m_txFrameCount += frames.length(); // TODO: jsherer - move this outside of create message transmit queue // if we're transmitting a message to be displayed, we should bump the // repeat buttons... "Bump the repeat buttons" from 2018 probably translates // to "stop automatic transmission loops" in 2025: qCDebug(mainwindow_js8) // << "Cancel HB and CQ transmit loops in createMessageTransmitQueue"; // m_cq_loop->onLoopCancel(); // m_hb_loop->onLoopCancel(); // But the loops cause this code to be executed as part of their // normal operation, when the first transmission is sent. // So the cancelation makes it impossible to iterate through the loop a // second time. // return the text return lines.join(""); } void MainWindow::restoreMessage() { if (m_lastTxMessage.isEmpty()) { return; } addMessageText(Varicode::rstrip(m_lastTxMessage), true); } void MainWindow::resetMessageTransmitQueue() { m_txFrameCount = 0; m_txFrameCountSent = 0; m_txFrameQueue.clear(); m_txMessageQueue.clear(); // reset the total message sent m_totalTxMessage.clear(); } QPair MainWindow::popMessageFrame() { if (m_txFrameQueue.isEmpty()) { return QPair{}; } return m_txFrameQueue.dequeue(); } void MainWindow::currentTextChanged() { auto const text = ui->extFreeTextMsgEdit->toPlainText(); // keep track of dirty flags m_txTextDirty = text != m_txTextDirtyLastText; m_txTextDirtyLastText = text; // immediately update the display updateButtonDisplay(); updateTextDisplay(); } void MainWindow::tableSelectionChanged(QItemSelection const &, QItemSelection const &) { currentTextChanged(); auto const selectedCall = callsignSelected(); if (selectedCall != m_prevSelectedCallsign) { callsignSelectedChanged(m_prevSelectedCallsign, selectedCall); } } QList> MainWindow::buildMessageFrames(const QString &text, bool isData, bool *pDisableTypeahead) { // prepare selected callsign for directed message QString selectedCall = callsignSelected(); // prepare compound QString mycall = m_config.my_callsign(); QString mygrid = m_config.my_grid().left(4); bool forceIdentify = !m_config.avoid_forced_identify(); // TODO: might want to be more explicit? bool forceData = m_txFrameCountSent > 0 && isData; Varicode::MessageInfo info; auto frames = Varicode::buildMessageFrames(mycall, mygrid, selectedCall, text, forceIdentify, forceData, m_nSubMode, &info); if (pDisableTypeahead) { // checksummed commands should not allow typeahead *pDisableTypeahead = (!info.dirCmd.isEmpty() && Varicode::isCommandChecksumed(info.dirCmd)); } #if 0 qCDebug(mainwindow_js8) << "frames:"; foreach(auto frame, frames){ auto dt = DecodedText(frame.frame, frame.bits); qCDebug(mainwindow_js8) << "->" << frame << dt.message() << Varicode::frameTypeString(dt.frameType()); } #endif return frames; } bool MainWindow::prepareNextMessageFrame() { // check to see if the last i3bit was a last bit bool i3bitLast = (m_i3bit & Varicode::JS8CallLast) == Varicode::JS8CallLast; // TODO: should this be user configurable? bool shouldForceDataForTypeahead = !i3bitLast; // reset i3 m_i3bit = Varicode::JS8Call; // typeahead bool shouldDisableTypeahead = false; if (ui->extFreeTextMsgEdit->isDirty() && !ui->extFreeTextMsgEdit->isEmpty()) { // block edit events while computing next frame QString newText; ui->extFreeTextMsgEdit->setReadOnly(true); { auto sent = ui->extFreeTextMsgEdit->sentText(); auto unsent = ui->extFreeTextMsgEdit->unsentText(); qCDebug(mainwindow_js8) << "text dirty for typeahead\n" << sent << "\n" << unsent; m_txFrameQueue.clear(); m_txFrameCount = 0; newText = appendMessage(unsent, shouldForceDataForTypeahead, &shouldDisableTypeahead); // if this was the last frame, append a newline if (i3bitLast) { m_totalTxMessage.append("\n"); newText.prepend("\n"); } qCDebug(mainwindow_js8) << "unsent replaced to" << "\n" << newText; } ui->extFreeTextMsgEdit->setReadOnly(shouldDisableTypeahead); ui->extFreeTextMsgEdit->replaceUnsentText(newText, true); ui->extFreeTextMsgEdit->setClean(); } QPair f = popMessageFrame(); auto frame = f.first; auto bits = f.second; // if not the first frame, ensure first bit is not set if (m_txFrameCountSent > 0) { bits &= ~Varicode::JS8CallFirst; } // if last frame, ensure the last bit is set if (m_txFrameQueue.isEmpty()) { bits |= Varicode::JS8CallLast; } if (frame.isEmpty()) { m_nextFreeTextMsg.clear(); updateTxButtonDisplay(); return false; } // append this frame to the total message sent so far auto dt = DecodedText(frame, bits, m_nSubMode); m_totalTxMessage.append(dt.message()); ui->extFreeTextMsgEdit->setCharsSent(m_totalTxMessage.length()); m_txFrameCountSent += 1; m_lastTxMessage = m_totalTxMessage; qCDebug(mainwindow_js8) << "total sent:" << m_txFrameCountSent << "\n" << m_totalTxMessage; // display the frame... if (m_txFrameQueue.isEmpty()) { displayTextForFreq( QString("%1 %2 ").arg(dt.message()).arg(m_config.eot()), freq(), DriftingDateTime::currentDateTimeUtc(), true, false, true); } else { displayTextForFreq(dt.message(), freq(), DriftingDateTime::currentDateTimeUtc(), true, m_txFrameCountSent == 1, false); } m_nextFreeTextMsg = frame; m_i3bit = bits; updateTxButtonDisplay(); return true; } bool MainWindow::isFreqOffsetFree(int const f, int const bw) { // if this frequency is our current frequency, or it's in our // directed cache, it's free. if ((freq() == f) || isDirectedOffset(f, nullptr)) return true; // Run through the band activity; if there's no activity for a given // offset, or we last received on it more than 30 seconds ago, then // it's free. If it's an occupied slot within the bandwidth of where // we'd like to transmit, then it's not free. auto const now = DriftingDateTime::currentDateTimeUtc(); for (auto [offset, activity] : m_bandActivity.asKeyValueRange()) { if (activity.isEmpty() || activity.last().utcTimestamp.secsTo(now) >= 30) continue; if (qAbs(offset - f) < bw) return false; } return true; } int MainWindow::findFreeFreqOffset(int fmin, int fmax, int bw) { int nslots = (fmax - fmin) / bw; int f = fmin; for (int i = 0; i < nslots; i++) { f = fmin + bw * (QRandomGenerator::global()->generate() % nslots); if (isFreqOffsetFree(f, bw)) { return f; } } for (int i = 0; i < nslots; i++) { f = fmin + (QRandomGenerator::global()->generate() % (fmax - fmin)); if (isFreqOffsetFree(f, bw)) { return f; } } // return fmin if there's no free offset return fmin; } #if 0 // schedulePing void MainWindow::scheduleHeartbeat(bool first){ auto timestamp = DriftingDateTime::currentDateTimeUtc(); // if we have the heartbeat interval disabled, return early, unless this is a "heartbeat now" if(!m_config.heartbeat() && !first){ heartbeatTimer.stop(); return; } // remove milliseconds auto t = timestamp.time(); t.setHMS(t.hour(), t.minute(), t.second()); timestamp.setTime(t); // round to 15 second increment int secondsSinceEpoch = (timestamp.toMSecsSinceEpoch()/1000); int delta = roundUp(secondsSinceEpoch, 15) + 1 + (first ? 0 : qMax(1, m_config.heartbeat()) * 60) - secondsSinceEpoch; timestamp = timestamp.addSecs(delta); // 25% of the time, switch intervals float prob = (float) QRandomGenerator::global()->generate() / (RAND_MAX); if(prob < 0.25){ timestamp = timestamp.addSecs(15); } m_nextHeartbeat = timestamp; m_nextHeartbeatQueued = false; m_nextHeartPaused = false; if(!heartbeatTimer.isActive()){ heartbeatTimer.setInterval(1000); heartbeatTimer.start(); } } // pausePing void MainWindow::pauseHeartbeat(){ m_nextHeartPaused = true; if(heartbeatTimer.isActive()){ heartbeatTimer.stop(); } } // unpausePing void MainWindow::unpauseHeartbeat(){ scheduleHeartbeat(false); } // checkPing void MainWindow::checkHeartbeat(){ if(m_config.heartbeat() <= 0){ return; } auto secondsUntilHeartbeat = DriftingDateTime::currentDateTimeUtc().secsTo(m_nextHeartbeat); if(secondsUntilHeartbeat > 5 && m_txHeartbeatQueue.isEmpty()){ return; } if(m_nextHeartbeatQueued){ return; } if(m_tx_watchdog){ return; } // idle heartbeat watchdog! if (m_config.watchdog() && m_idleMinutes >= m_config.watchdog ()){ tx_watchdog (true); // disable transmit return; } prepareHeartbeat(); } // preparePing void MainWindow::prepareHeartbeat(){ QStringList lines; QString mycall = m_config.my_callsign(); QString mygrid = m_config.my_grid().left(4); // JS8Call Style if(m_txHeartbeatQueue.isEmpty()){ lines.append(QString("%1: HEARTBEAT %2").arg(mycall).arg(mygrid)); } else { while(!m_txHeartbeatQueue.isEmpty() && lines.length() < 1){ lines.append(m_txHeartbeatQueue.dequeue()); } } // Choose a ping frequency auto f = m_config.heartbeat_anywhere() ? -1 : findFreeFreqOffset(500, 1000, 50); auto text = lines.join(QChar('\n')); if(text.isEmpty()){ return; } // Queue the ping enqueueMessage(PriorityLow, text, f, [this](){ m_nextHeartbeatQueued = false; }); m_nextHeartbeatQueued = true; } #endif void MainWindow::on_startTxButton_toggled(bool checked) { if (checked) { startTx(); } else { resetMessage(); on_stopTxButton_clicked(); stopTx(); } } void MainWindow::toggleTx(bool start) { if (start && ui->startTxButton->isChecked()) { return; } if (!start && !ui->startTxButton->isChecked()) { return; } qCDebug(mainwindow_js8) << "toggleTx(" << start << ") setting the TX button."; ui->startTxButton->setChecked(start); } void MainWindow::on_logQSOButton_clicked() // Log QSO button { QString call = callsignSelected(); if (m_callSelectedTime.contains(call)) { m_dateTimeQSOOn = m_callSelectedTime[call]; } if (!m_dateTimeQSOOn.isValid()) { m_dateTimeQSOOn = DriftingDateTime::currentDateTimeUtc(); } auto dateTimeQSOOff = DriftingDateTime::currentDateTimeUtc(); if (dateTimeQSOOff < m_dateTimeQSOOn) dateTimeQSOOff = m_dateTimeQSOOn; if (call.startsWith("@")) { call = ""; } QString grid = ""; if (m_callActivity.contains(call)) { grid = m_callActivity[call].grid; } QString opCall = m_opCall; if (opCall.isEmpty()) { opCall = m_config.my_callsign(); } QString comments = ui->textEditRX->textCursor().selectedText(); // don't reset the log window if the call hasn't changed. if (!m_logDlg->currentCall().isEmpty() && call.trimmed() == m_logDlg->currentCall()) { m_logDlg->show(); return; } m_logDlg->initLogQSO(call.trimmed(), grid.trimmed(), "JS8", m_rptSent, m_rptRcvd, m_dateTimeQSOOn, dateTimeQSOOff, m_freqNominal + freq(), m_config.my_callsign(), m_config.my_grid(), opCall, comments); } void MainWindow::acceptQSO(QDateTime const &QSO_date_off, QString const &call, QString const &grid, Frequency dial_freq, QString const &mode, QString const &submode, QString const &rpt_sent, QString const &rpt_received, QString const &comments, QString const &name, QDateTime const &QSO_date_on, QString const &operator_call, QString const &my_call, QString const &my_grid, QByteArray const &ADIF, QVariantMap const &additionalFields) { QString date = QSO_date_on.toString("yyyyMMdd"); m_logBook.addAsWorked(m_hisCall, m_config.bands()->find(m_freqNominal), mode, submode, grid, date, name, comments); // Log to JS8Call API if (canSendNetworkMessage()) { sendNetworkMessage( "LOG.QSO", QString(ADIF), {{"_ID", QVariant(-1)}, {"UTC.ON", QVariant(QSO_date_on.toMSecsSinceEpoch())}, {"UTC.OFF", QVariant(QSO_date_off.toMSecsSinceEpoch())}, {"CALL", QVariant(call)}, {"GRID", QVariant(grid)}, {"FREQ", QVariant(dial_freq)}, {"MODE", QVariant(mode)}, {"SUBMODE", QVariant(submode)}, {"RPT.SENT", QVariant(rpt_sent)}, {"RPT.RECV", QVariant(rpt_received)}, {"NAME", QVariant(name)}, {"COMMENTS", QVariant(comments)}, {"STATION.OP", QVariant(operator_call)}, {"STATION.CALL", QVariant(my_call)}, {"STATION.GRID", QVariant(my_grid)}, {"EXTRA", additionalFields}}); } // Log to N1MM Logger if (m_config.broadcast_to_n1mm() && m_config.valid_n1mm_info()) { const QHostAddress n1mmhost = QHostAddress(m_config.n1mm_server_name()); QUdpSocket _sock; auto rzult = _sock.writeDatagram(ADIF + " ", n1mmhost, quint16(m_config.n1mm_server_port())); if (rzult == -1) { bool hidden = m_logDlg->isHidden(); m_logDlg->setHidden(true); JS8MessageBox::warning_message( this, tr("Error sending log to N1MM"), tr("Write returned \"%1\"").arg(rzult)); m_logDlg->setHidden(hidden); } } // Log to N3FJP Logger if (m_config.broadcast_to_n3fjp() && m_config.valid_n3fjp_info()) { QString data = QString("" "" "TRUE" "FALSE" "%1" "%2" "%3" "%4" "%5" "%6" "JS8" "%7" "%8" "%9" "%10" "%11" "%12" ""); data = data.arg(QSO_date_on.toString("yyyy/MM/dd")); data = data.arg(QSO_date_on.toString("H:mm")); data = data.arg(call); data = data.arg(grid); data = data.arg(m_config.bands()->find(dial_freq).replace("m", "")); data = data.arg(Radio::frequency_MHz_string(dial_freq)); data = data.arg(operator_call); data = data.arg(name); data = data.arg(comments); data = data.arg(rpt_sent); data = data.arg(rpt_received); int other = 0; QStringList additional; if (!additionalFields.isEmpty()) { foreach (auto key, additionalFields.keys()) { QString n3key; if (N3FJP_ADIF_MAP.contains(key)) { n3key = N3FJP_ADIF_MAP.value(key); } else { other++; n3key = N3FJP_ADIF_MAP.value(QString("*%1").arg(other)); } if (n3key.isEmpty()) { break; } auto value = additionalFields[key].toString(); additional.append(QString("<%1>%2").arg(n3key).arg(value)); } } data = data.arg(additional.join("")); auto host = m_config.n3fjp_server_name(); auto port = m_config.n3fjp_server_port(); if (m_n3fjpClient->sendNetworkMessage(host, port, data.toLocal8Bit(), true, 500)) { QTimer::singleShot(300, this, [this, host, port]() { m_n3fjpClient->sendNetworkMessage( host, port, "", true, 100); m_n3fjpClient->sendNetworkMessage(host, port, "\r\n", true, 100); }); } else { bool hidden = m_logDlg->isHidden(); m_logDlg->setHidden(true); JS8MessageBox::warning_message( this, tr("Error sending log to N3FJP"), tr("Write failed for \"%1:%2\"").arg(host).arg(port)); m_logDlg->setHidden(hidden); } } /** * @brief Log QSO to WSJT-X Protocol * * Sends QSO logged information to WSJT-X protocol clients. Sends both * the QSOLogged message and the LoggedADIF message (type 12) which * contains the ADIF formatted record. The ADIF message is what most * WSJT-X clients actually use for logging. */ if (m_wsjtxMessageMapper && m_config.wsjtx_protocol_enabled()) { m_wsjtxMessageMapper->sendQSOLogged(QSO_date_off, call, grid, dial_freq, mode, rpt_sent, rpt_received, my_call, my_grid); // Also send ADIF formatted message (this is what clients actually use) if (m_wsjtxMessageClient) { m_wsjtxMessageClient->logged_ADIF(ADIF); } } // reload the logbook data m_logBook.init(); clearCallsignSelected(); displayCallActivity(); m_dateTimeQSOOn = QDateTime{}; } void MainWindow::on_actionModeJS8HB_toggled(bool) { // prep hb mode prepareHeartbeatMode(canCurrentModeSendHeartbeat() && ui->actionModeJS8HB->isChecked()); displayActivity(true); setupJS8(); } void MainWindow::on_actionHeartbeatAcknowledgements_toggled(bool) { // prep hb ack mode prepareHeartbeatMode(canCurrentModeSendHeartbeat() && ui->actionModeJS8HB->isChecked()); displayActivity(true); setupJS8(); } void MainWindow::on_actionModeMultiDecoder_toggled(bool checked) { Q_UNUSED(checked); displayActivity(true); setupJS8(); } void MainWindow::on_actionModeJS8Normal_triggered() { setupJS8(); } void MainWindow::on_actionModeJS8Fast_triggered() { setupJS8(); } void MainWindow::on_actionModeJS8Turbo_triggered() { setupJS8(); } void MainWindow::on_actionModeJS8Slow_triggered() { setupJS8(); } void MainWindow::on_actionModeJS8Ultra_triggered() { setupJS8(); } void MainWindow::on_actionModeAutoreply_toggled(bool) { // update the HB ack option (needs autoreply on) prepareHeartbeatMode(canCurrentModeSendHeartbeat() && ui->actionModeJS8HB->isChecked()); // then update the js8 mode setupJS8(); } bool MainWindow::canCurrentModeSendHeartbeat() const { return (m_nSubMode == Varicode::JS8CallFast || m_nSubMode == Varicode::JS8CallNormal || m_nSubMode == Varicode::JS8CallSlow); } void MainWindow::prepareMonitorControls() { // on_monitorButton_toggled(!m_config.monitor_off_at_startup()); ui->monitorTxButton->setChecked(!m_config.transmit_off_at_startup()); } void MainWindow::prepareHeartbeatMode(bool enabled) { // Not all submodes supports HBs. ui->hbMacroButton->setVisible(enabled); if (!enabled) { m_hb_loop->onLoopCancel(); ui->hbMacroButton->setChecked(false); } ui->actionHeartbeat->setEnabled(enabled); ui->actionModeJS8HB->setEnabled(canCurrentModeSendHeartbeat()); ui->actionHeartbeatAcknowledgements->setEnabled( enabled && ui->actionModeAutoreply->isChecked()); #if 0 if(enabled){ m_config.addGroup("@HB"); } else { m_config.removeGroup("@HB"); } #endif #if 0 //ui->actionCQ->setEnabled(!enabled); //ui->actionFocus_Message_Reply_Area->setEnabled(!enabled); // default to not displaying the other buttons // ui->cqMacroButton->setVisible(!enabled); // ui->replyMacroButton->setVisible(!enabled); // ui->snrMacroButton->setVisible(!enabled); // ui->infoMacroButton->setVisible(!enabled); // ui->macrosMacroButton->setVisible(!enabled); // ui->queryButton->setVisible(!enabled); // ui->extFreeTextMsgEdit->setVisible(!enabled); // if(enabled){ // ui->extFreeTextMsgEdit->clear(); // } // show heartbeat and acks in hb mode only // ui->actionShow_Band_Heartbeats_and_ACKs->setChecked(enabled); // ui->actionShow_Band_Heartbeats_and_ACKs->setVisible(true); // ui->actionShow_Band_Heartbeats_and_ACKs->setEnabled(false); #endif updateHBButtonDisplay(); updateButtonDisplay(); } void MainWindow::setupJS8() { m_nSubMode = Varicode::JS8CallNormal; if (ui->actionModeJS8Normal->isChecked()) m_nSubMode = Varicode::JS8CallNormal; else if (ui->actionModeJS8Fast->isChecked()) m_nSubMode = Varicode::JS8CallFast; else if (ui->actionModeJS8Turbo->isChecked()) m_nSubMode = Varicode::JS8CallTurbo; else if (ui->actionModeJS8Slow->isChecked()) m_nSubMode = Varicode::JS8CallSlow; else if (ui->actionModeJS8Ultra->isChecked()) m_nSubMode = Varicode::JS8CallUltra; // Only enable heartbeat for modes that support it prepareHeartbeatMode(canCurrentModeSendHeartbeat() && ui->actionModeJS8HB->isChecked()); updateModeButtonText(); m_wideGraph->setSubMode(m_nSubMode); m_wideGraph->setFilterMinimumBandwidth( JS8::Submode::bandwidth(m_nSubMode) + JS8::Submode::rxThreshold(m_nSubMode) * 2); enable_DXCC_entity(m_config.DXCC()); m_config.frequencies()->filter(m_config.region(), Mode::JS8); m_FFTSize = JS8_NSPS / 2; Q_EMIT FFTSize(m_FFTSize); setup_status_bar(); m_TRperiod = JS8::Submode::period(m_nSubMode); m_wideGraph->show(); Q_ASSERT(JS8_NTMAX == 60); m_wideGraph->setPeriod(m_TRperiod); m_detector->setTRPeriod(JS8_NTMAX); // TODO - not thread safe updateTextDisplay(); refreshTextDisplay(); statusChanged(); } void MainWindow::setFreq(int const n) { m_freq = n; m_wideGraph->setFreq(n); Q_EMIT transmitFrequency(n + m_XIT); statusUpdate(); } void MainWindow::on_actionErase_ALL_TXT_triggered() // Erase ALL.TXT { int ret = JS8MessageBox::query_message( this, tr("Confirm Erase"), tr("Are you sure you want to erase file ALL.TXT?")); if (ret == JS8MessageBox::Yes) { QFile f{m_config.writeable_data_dir().absoluteFilePath("ALL.TXT")}; f.remove(); m_RxLog = 1; } } void MainWindow::on_actionErase_js8call_log_adi_triggered() { int ret = JS8MessageBox::query_message( this, tr("Confirm Erase"), tr("Are you sure you want to erase file js8call_log.adi?")); if (ret == JS8MessageBox::Yes) { QFile f{ m_config.writeable_data_dir().absoluteFilePath("js8call_log.adi")}; f.remove(); m_logBook.init(); } } void MainWindow::on_actionOpen_log_directory_triggered() { QDesktopServices::openUrl( QUrl::fromLocalFile(m_config.writeable_data_dir().absolutePath())); } void MainWindow::band_changed() { if (m_config.pwrBandTxMemory() && !m_tune) { if (m_pwrBandTxMemory.contains(m_lastBand)) { ui->outAttenuation->setValue(m_pwrBandTxMemory[m_lastBand].toInt()); } else { m_pwrBandTxMemory[m_lastBand] = ui->outAttenuation->value(); } } } void MainWindow::enable_DXCC_entity(bool /*on*/) { m_logBook.init(); // re-read the log and cty.dat files updateGeometry(); } void MainWindow::buildFrequencyMenu(QMenu *menu) { auto custom = menu->addAction("Set a Custom Frequency..."); connect(custom, &QAction::triggered, this, [this]() { bool ok = false; auto currentFreq = Radio::frequency_MHz_string(dialFrequency()); QString newFreq = QInputDialog::getText(this, tr("Set a Custom Frequency"), tr("Frequency in MHz:"), QLineEdit::Normal, currentFreq, &ok) .toUpper() .trimmed(); if (!ok) { return; } setRig(Radio::frequency(newFreq, 6)); }); menu->addSeparator(); auto frequencies = m_config.frequencies()->frequency_list(); std::sort(frequencies.begin(), frequencies.end(), [](FrequencyList_v2::Item &a, FrequencyList_v2::Item &b) { return a.frequency_ < b.frequency_; }); foreach (auto f, frequencies) { auto freq = Radio::pretty_frequency_MHz_string(f.frequency_); auto const &band = m_config.bands()->find(f.frequency_); auto a = menu->addAction(QString("%1:%2%2%3 MHz") .arg(band) .arg(QString(" ").repeated(5 - band.length())) .arg(freq)); connect(a, &QAction::triggered, this, [this, f]() { setRig(f.frequency_); }); } } void MainWindow::buildHeartbeatMenu(QMenu *menu) { if (m_hbInterval > 0) { auto startStop = menu->addAction(ui->hbMacroButton->isChecked() ? "Stop Heartbeat Timer" : "Start Heartbeat Timer"); connect(startStop, &QAction::triggered, this, [this]() { ui->hbMacroButton->toggle(); }); menu->addSeparator(); } buildRepeatMenu(menu, ui->hbMacroButton, false, &m_hbInterval); menu->addSeparator(); auto now = menu->addAction("Send Heartbeat Now"); connect(now, &QAction::triggered, this, &MainWindow::sendHB); } void MainWindow::buildCQMenu(QMenu *menu) { if (m_cqInterval > 0) { auto startStop = menu->addAction(ui->cqMacroButton->isChecked() ? "Stop CQ Timer" : "Start CQ Timer"); connect(startStop, &QAction::triggered, this, [this]() { ui->cqMacroButton->toggle(); }); menu->addSeparator(); } buildRepeatMenu(menu, ui->cqMacroButton, true, &m_cqInterval); menu->addSeparator(); auto now = menu->addAction("Send CQ Now"); connect(now, &QAction::triggered, this, [this]() { sendCQ(false); }); } void MainWindow::buildRepeatMenu(QMenu *menu, QPushButton *button, bool isLowInterval, int *interval) { QList> items = { {"On demand / do not repeat", 0}, {"Repeat every 1 minute", 1}, {"Repeat every 5 minutes", 5}, {"Repeat every 10 minutes", 10}, {"Repeat every 15 minutes", 15}, {"Repeat every 30 minutes", 30}, {"Repeat every 60 minutes", 60}, {"Repeat every N minutes (Custom Interval)", -1}, // this needs to be last because of isSet bool }; if (isLowInterval) { items.removeAt(6); // remove the sixty minute interval items.removeAt(5); // remove the thirty minute interval } else { items.removeAt(2); // remove the five minute interval items.removeAt(1); // remove the one minute interval } auto customFormat = QString("Repeat every %1 minutes (Custom Interval)"); QActionGroup *group = new QActionGroup(menu); bool isSet = false; foreach (auto pair, items) { int minutes = pair.second; bool isMatch = *interval == minutes; bool isCustom = (minutes == -1 && isSet == false); if (isMatch) { isSet = true; } auto text = pair.first; if (isCustom) { text = QString(customFormat).arg(*interval); } QAction *action = menu->addAction(text); action->setData(minutes); action->setCheckable(true); action->setChecked(isMatch || isCustom); group->addAction(action); connect( action, &QAction::toggled, this, [this, action, customFormat, minutes, interval, button](bool checked) { int min = minutes; if (checked) { if (minutes == -1) { bool ok = false; min = QInputDialog::getInt(this, "Repeat every N minutes", "Minutes", 0, 1, 1440, 1, &ok); if (!ok) { return; } action->setText(QString(customFormat).arg(*interval)); } *interval = min; if (min > 0) { // force a re-toggle button->setChecked(false); } button->setChecked(min > 0); } }); } } void MainWindow::sendHB() { QString mycall = m_config.my_callsign(); QString mygrid = m_config.my_grid().left(4); QStringList parts; parts.append(QString("%1:").arg(mycall)); #if JS8_CUSTOMIZE_HB auto hb = m_config.hb_message(); #else auto hb = QString{}; #endif if (hb.isEmpty()) { parts.append("HEARTBEAT"); parts.append(mygrid); } else { parts.append(hb); } QString message = parts.join(" ").trimmed(); auto f = findFreeFreqOffset(500, 1000, 50); if (freq() <= 1000) { f = freq(); } else if (m_config.heartbeat_anywhere()) { f = -1; } enqueueMessage(PriorityLow + 1, message, f, nullptr); processTxQueue(); } void MainWindow::sendHeartbeatAck(QString to, int snr, QString extra) { #if JS8_HB_ACK_SNR_CONFIGURABLE auto message = m_config.heartbeat_ack_snr() ? QString("%1 SNR %2 %3") .arg(to) .arg(Varicode::formatSNR(snr)) .arg(extra) .trimmed() : QString("%1 ACK %2").arg(to).arg(extra).trimmed(); #else auto message = QString("%1 HEARTBEAT SNR %2 %3") .arg(to) .arg(Varicode::formatSNR(snr)) .arg(extra) .trimmed(); #endif auto f = m_config.heartbeat_anywhere() ? -1 : findFreeFreqOffset(500, 1000, 50); if (m_config.autoreply_confirmation()) { confirmThenEnqueueMessage(90, PriorityLow + 1, message, f, [this]() { processTxQueue(); }); } else { enqueueMessage(PriorityLow + 1, message, f, nullptr); processTxQueue(); } } void MainWindow::on_hbMacroButton_toggled(bool checked) { qCDebug(mainwindow_js8) << "on_hbMacroButton_toggled(" << checked << ")"; if (checked) { // only clear callsign if we do not allow hbs while in qso if (m_config.heartbeat_qso_pause()) { clearCallsignSelected(); } if (m_hbInterval) { if (!m_hb_loop->isActive()) { qCDebug(mainwindow_js8) << "Starting HB loop from on_hbMacroButton_toggled()"; m_hb_loop->onTxLoopPeriodChangeStart(m_hbInterval * (qint64)60000); } } else { qCDebug(mainwindow_js8) << "Sending single HB from on_hbMacroButton_toggled()"; m_hb_loop->onLoopCancel(); // Heartbeat, but not in a loop. sendHB(); // make this button emulate a single press button ui->hbMacroButton->setChecked(false); } } else { if (m_hb_loop->isActive() && m_hbButtonIsLongterm) { qCDebug(mainwindow_js8) << "Stopping HB loop from on_hbMacroButton_toggled()"; m_hb_loop->onLoopCancel(); } } qCDebug(mainwindow_js8) << "updateHBButtonDisplay called via on_hbMacroButton_toggled"; updateHBButtonDisplay(); } void MainWindow::on_hbMacroButton_clicked() {} void MainWindow::sendCQ(bool repeat) { if (!repeat && m_cq_loop->isActive()) { qCDebug(mainwindow_js8) << "Cancel CQ loop on single-shot CQ"; m_cq_loop->onLoopCancel(); } if (!repeat && m_hb_loop->isActive()) { qCDebug(mainwindow_js8) << "Cancel HB loop on single-shot CQ"; m_hb_loop->onLoopCancel(); } QString message = m_config.cq_message(); if (message.isEmpty()) { QString mygrid = m_config.my_grid().left(4); message = QString("CQ CQ CQ %1").arg(mygrid).trimmed(); } clearCallsignSelected(); addMessageText(replaceMacros(message, buildMacroValues(), true)); if (repeat || m_config.transmit_directed()) toggleTx(true); } void MainWindow::on_cqMacroButton_toggled(bool checked) { qCDebug(mainwindow_js8) << "on_cqMacroButton_toggled(" << checked << ")"; if (checked) { clearCallsignSelected(); if (m_cqInterval) { qCDebug(mainwindow_js8) << "Starting CQ loop from on_cqMacroButton_toggled()"; m_cq_loop->onTxLoopPeriodChangeStart(m_cqInterval * (qint64)60000); } else { qCDebug(mainwindow_js8) << "Sending single CQ from on_cqMacroButton_toggled()"; m_cq_loop->onLoopCancel(); sendCQ(false); // make this button emulate a single press button ui->cqMacroButton->setChecked(false); } } else { if (m_cq_loop->isActive() && m_cqButtonIsLongterm) { qCDebug(mainwindow_js8) << "Stopping CQ loop from on_cqMacroButton_toggled()"; m_cq_loop->onLoopCancel(); } } qCDebug(mainwindow_js8) << "updateCQButtonDisplay called via on_cqMacroButton_toggled"; updateCQButtonDisplay(); } void MainWindow::on_cqMacroButton_clicked() {} void MainWindow::on_replyMacroButton_clicked() { QString call = callsignSelected(); if (call.isEmpty()) { return; } auto message = m_config.reply_message(); message = replaceMacros(message, buildMacroValues(), true); addMessageText(QString("%1 %2").arg(call).arg(message)); if (m_config.transmit_directed()) toggleTx(true); } void MainWindow::on_snrMacroButton_clicked() { QString call = callsignSelected(); if (call.isEmpty()) { return; } auto now = DriftingDateTime::currentDateTimeUtc(); int callsignAging = m_config.callsign_aging(); if (!m_callActivity.contains(call)) { return; } auto cd = m_callActivity[call]; if (callsignAging && cd.utcTimestamp.secsTo(now) / 60 >= callsignAging) { return; } auto snr = Varicode::formatSNR(cd.snr); addMessageText(QString("%1 SNR %2").arg(call).arg(snr)); if (m_config.transmit_directed()) toggleTx(true); } void MainWindow::on_infoMacroButton_clicked() { QString info = m_config.my_info(); if (info.isEmpty()) { return; } addMessageText( QString("INFO %1").arg(replaceMacros(info, buildMacroValues(), true))); if (m_config.transmit_directed()) toggleTx(true); } void MainWindow::on_statusMacroButton_clicked() { QString status = m_config.my_status(); if (status.isEmpty()) { return; } addMessageText(QString("STATUS %1") .arg(replaceMacros(status, buildMacroValues(), true))); if (m_config.transmit_directed()) toggleTx(true); } void MainWindow::setShowColumn(QString tableKey, QString columnKey, bool value) { m_showColumnsCache[tableKey + columnKey] = QVariant(value); displayBandActivity(); displayCallActivity(); } bool MainWindow::showColumn(QString tableKey, QString columnKey, bool default_) { return m_showColumnsCache.value(tableKey + columnKey, QVariant(default_)) .toBool(); } QString MainWindow::columnLabel(QString defaultLabel) { bool minimalLabels = showColumn("all", "minimal_labels", false); // If we are not rendering minimal labels, return the default if (!minimalLabels) { return defaultLabel; } // If there is an entry, send it, if not, return default return m_columnLabelMap.value(defaultLabel, defaultLabel); } void MainWindow::buildShowColumnsMenu(QMenu *menu, QString tableKey) { QList> columnKeys = { {"Frequency Offset", "offset"}, {"Last heard timestamp", "timestamp"}, {"SNR", "snr"}, {"Time Delta", "tdrift"}, {"Mode Speed", "submode"}, }; QMap defaultOverride = { {"submode", false}, {"tdrift", false}, {"grid", false}, {"distance", false}, {"azimuth", false}, {"minimal_labels", false}}; if (tableKey == "call") { columnKeys.prepend({"Callsign", "callsign"}); columnKeys.append({ {"Grid Locator", "grid"}, {"Distance", "distance"}, {"Azimuth", "azimuth"}, {"Worked Before", "log"}, {"Logged Name", "logName"}, {"Logged Comment", "logComment"}, }); } columnKeys.prepend({"Minimal Column Labels", "minimal_labels"}); columnKeys.prepend({"Show Column Labels", "labels"}); int columnIndex = 0; QString origTableKey = tableKey; foreach (auto p, columnKeys) { auto columnLabel = p.first; auto columnKey = p.second; auto a = menu->addAction(columnLabel); a->setCheckable(true); // Add separator after second item // If this is the second item, it is the minimal labels item, so set the // table key to all if (++columnIndex == 2) { tableKey = "all"; menu->addSeparator(); } bool showByDefault = true; if (defaultOverride.contains(columnKey)) { showByDefault = defaultOverride[columnKey]; } a->setChecked(showColumn(tableKey, columnKey, showByDefault)); connect(a, &QAction::triggered, this, [this, a, tableKey, columnKey]() { setShowColumn(tableKey, columnKey, a->isChecked()); }); // If we have switched to a custom table key in this iteration, reset to // the original key if (tableKey != origTableKey) { tableKey = origTableKey; } } } void MainWindow::setSortBy(QString key, QString value) { m_sortCache[key] = QVariant(value); displayBandActivity(); displayCallActivity(); } QString MainWindow::getSortBy(QString const &key, QString const &defaultValue) const { return m_sortCache.value(key, QVariant(defaultValue)).toString(); } MainWindow::SortByReverse MainWindow::getSortByReverse(QString const &key, QString const &defaultValue) const { auto const sortBy = getSortBy(key, defaultValue); auto const reverse = sortBy.startsWith("-"); return {reverse ? sortBy.sliced(1) : sortBy, reverse}; } void MainWindow::buildSortByMenu(QMenu *menu, QString key, QString defaultValue, QList> values) { auto currentSortBy = getSortBy(key, defaultValue); QActionGroup *g = new QActionGroup(menu); g->setExclusive(true); foreach (auto p, values) { auto k = p.first; auto v = p.second; auto a = menu->addAction(k); a->setCheckable(true); a->setChecked(v == currentSortBy); a->setActionGroup(g); connect(a, &QAction::triggered, this, [this, a, key, v]() { if (a->isChecked()) { setSortBy(key, v); } }); } } void MainWindow::buildBandActivitySortByMenu(QMenu *menu) { buildSortByMenu(menu, "bandActivity", "offset", {{"Frequency offset", "offset"}, {"Last heard timestamp (oldest first)", "timestamp"}, {"Last heard timestamp (recent first)", "-timestamp"}, {"SNR (weakest first)", "snr"}, {"SNR (strongest first)", "-snr"}, {"Mode Speed (slowest first)", "submode"}, {"Mode Speed (fastest first)", "-submode"}}); } void MainWindow::buildCallActivitySortByMenu(QMenu *menu) { buildSortByMenu(menu, "callActivity", "callsign", {{"Callsign", "callsign"}, {"Callsigns Replied (recent first)", "ackTimestamp"}, {"Frequency offset", "offset"}, {"Distance (closest first)", "distance"}, {"Distance (farthest first)", "-distance"}, {"Azimuth", "azimuth"}, {"Last heard timestamp (oldest first)", "timestamp"}, {"Last heard timestamp (recent first)", "-timestamp"}, {"SNR (weakest first)", "snr"}, {"SNR (strongest first)", "-snr"}, {"Mode Speed (slowest first)", "submode"}, {"Mode Speed (fastest first)", "-submode"}}); } void MainWindow::buildQueryMenu(QMenu *menu, QString call) { bool isAllCall = isAllCallIncluded(call); // for now, we're going to omit displaying the call...delete this if we want // the other functionality call = ""; auto grid = m_config.my_grid(); bool emptyInfo = m_config.my_info().isEmpty(); bool emptyGrid = m_config.my_grid().isEmpty(); auto callAction = menu->addAction( QString("Send a directed message to selected callsign")); connect(callAction, &QAction::triggered, this, [this]() { QString selectedCall = callsignSelected(); if (selectedCall.isEmpty()) { return; } addMessageText(QString("%1 ").arg(selectedCall), true); }); menu->addSeparator(); auto sendReplyAction = menu->addAction( QString("%1 Reply - Send reply message to selected callsign") .arg(call) .trimmed()); connect(sendReplyAction, &QAction::triggered, this, [this]() { QString selectedCall = callsignSelected(); if (selectedCall.isEmpty()) { return; } auto message = m_config.reply_message(); message = replaceMacros(message, buildMacroValues(), true); addMessageText(QString("%1 %2").arg(selectedCall).arg(message), true); }); auto sendSNRAction = menu->addAction( QString("%1 SNR - Send a signal report to the selected callsign") .arg(call) .trimmed()); sendSNRAction->setEnabled(m_callActivity.contains(callsignSelected())); connect(sendSNRAction, &QAction::triggered, this, [this]() { QString selectedCall = callsignSelected(); if (selectedCall.isEmpty()) { return; } if (!m_callActivity.contains(selectedCall)) { return; } auto d = m_callActivity[selectedCall]; addMessageText(QString("%1 SNR %2") .arg(selectedCall) .arg(Varicode::formatSNR(d.snr)), true); if (m_config.transmit_directed()) toggleTx(true); }); auto infoAction = menu->addAction( QString("%1 INFO - Send my station information").arg(call).trimmed()); infoAction->setDisabled(emptyInfo); connect(infoAction, &QAction::triggered, this, [this]() { QString selectedCall = callsignSelected(); if (selectedCall.isEmpty()) { return; } addMessageText( QString("%1 INFO %2").arg(selectedCall).arg(m_config.my_info()), true); if (m_config.transmit_directed()) toggleTx(true); }); auto gridAction = menu->addAction( QString("%1 GRID %2 - Send my current station Maidenhead grid locator") .arg(call) .arg(grid) .trimmed()); gridAction->setDisabled(emptyGrid); connect(gridAction, &QAction::triggered, this, [this]() { QString selectedCall = callsignSelected(); if (selectedCall.isEmpty()) { return; } addMessageText( QString("%1 GRID %2").arg(selectedCall).arg(m_config.my_grid()), true); if (m_config.transmit_directed()) toggleTx(true); }); menu->addSeparator(); auto snrQueryAction = menu->addAction( QString("%1 SNR? - What is my signal report?").arg(call).trimmed()); snrQueryAction->setDisabled(isAllCall); connect(snrQueryAction, &QAction::triggered, this, [this]() { QString selectedCall = callsignSelected(); if (selectedCall.isEmpty()) { return; } addMessageText(QString("%1 SNR?").arg(selectedCall), true); if (m_config.transmit_directed()) toggleTx(true); }); auto infoQueryAction = menu->addAction(QString("%1 INFO? - What is your station information?") .arg(call) .trimmed()); infoQueryAction->setDisabled(isAllCall); connect(infoQueryAction, &QAction::triggered, this, [this]() { QString selectedCall = callsignSelected(); if (selectedCall.isEmpty()) { return; } addMessageText(QString("%1 INFO?").arg(selectedCall), true); if (m_config.transmit_directed()) toggleTx(true); }); auto gridQueryAction = menu->addAction(QString("%1 GRID? - What is your current grid locator?") .arg(call) .trimmed()); gridQueryAction->setDisabled(isAllCall); connect(gridQueryAction, &QAction::triggered, this, [this]() { QString selectedCall = callsignSelected(); if (selectedCall.isEmpty()) { return; } addMessageText(QString("%1 GRID?").arg(selectedCall), true); if (m_config.transmit_directed()) toggleTx(true); }); auto stationIdleQueryAction = menu->addAction( QString("%1 STATUS? - What is your station status message?") .arg(call) .trimmed()); stationIdleQueryAction->setDisabled(isAllCall); connect(stationIdleQueryAction, &QAction::triggered, this, [this]() { QString selectedCall = callsignSelected(); if (selectedCall.isEmpty()) { return; } addMessageText(QString("%1 STATUS?").arg(selectedCall), true); if (m_config.transmit_directed()) toggleTx(true); }); auto heardQueryAction = menu->addAction( QString("%1 HEARING? - What are the stations are you hearing? (Top 4 " "ranked by most recently heard)") .arg(call) .trimmed()); heardQueryAction->setDisabled(isAllCall); connect(heardQueryAction, &QAction::triggered, this, [this]() { QString selectedCall = callsignSelected(); if (selectedCall.isEmpty()) { return; } addMessageText(QString("%1 HEARING?").arg(selectedCall), true); if (m_config.transmit_directed()) toggleTx(true); }); #if 0 auto retransmitAction = menu->addAction(QString("%1|[MESSAGE] - Please ACK and retransmit the following message").arg(call).trimmed()); retransmitAction->setDisabled(isAllCall); connect(retransmitAction, &QAction::triggered, this, [this](){ QString selectedCall = callsignSelected(); if(selectedCall.isEmpty()){ return; } addMessageText(QString("%1|[MESSAGE]").arg(selectedCall), true, true); }); #endif auto alertAction = menu->addAction( QString("%1>[MESSAGE] - Please relay this message to its destination") .arg(call) .trimmed()); alertAction->setDisabled(isAllCall); connect(alertAction, &QAction::triggered, this, [this]() { QString selectedCall = callsignSelected(); if (selectedCall.isEmpty()) { return; } addMessageText(QString("%1>[MESSAGE]").arg(selectedCall), true, true); }); auto msgAction = menu->addAction( QString("%1 MSG [MESSAGE] - Please store this message in your inbox") .arg(call) .trimmed()); msgAction->setDisabled(isAllCall); connect(msgAction, &QAction::triggered, this, [this]() { QString selectedCall = callsignSelected(); if (selectedCall.isEmpty()) { return; } addMessageText(QString("%1 MSG [MESSAGE]").arg(selectedCall), true, true); }); auto msgToAction = menu->addAction( QString("%1 MSG TO:[CALLSIGN] [MESSAGE] - Please store this message at " "your station for later retreival by [CALLSIGN]") .arg(call) .trimmed()); msgToAction->setDisabled(isAllCall); connect(msgToAction, &QAction::triggered, this, [this]() { QString selectedCall = callsignSelected(); if (selectedCall.isEmpty()) { return; } addMessageText( QString("%1 MSG TO:[CALLSIGN] [MESSAGE]").arg(selectedCall), true, true); }); auto qsoQueryAction = menu->addAction( QString("%1 QUERY CALL [CALLSIGN]? - Please acknowledge you can " "communicate directly with [CALLSIGN]") .arg(call) .trimmed()); connect(qsoQueryAction, &QAction::triggered, this, [this]() { QString selectedCall = callsignSelected(); if (selectedCall.isEmpty()) { return; } addMessageText(QString("%1 QUERY CALL [CALLSIGN]?").arg(selectedCall), true, true); }); auto qsoQueryMsgsAction = menu->addAction( QString("%1 QUERY MSGS - Do you have any messages for me?") .arg(call) .trimmed()); connect(qsoQueryMsgsAction, &QAction::triggered, this, [this]() { QString selectedCall = callsignSelected(); if (selectedCall.isEmpty()) { return; } addMessageText(QString("%1 QUERY MSGS").arg(selectedCall), true, true); }); auto qsoQueryMsgAction = menu->addAction(QString("%1 QUERY MSG [ID] - Please deliver the " "complete message identified by ID") .arg(call) .trimmed()); connect(qsoQueryMsgAction, &QAction::triggered, this, [this]() { QString selectedCall = callsignSelected(); if (selectedCall.isEmpty()) { return; } addMessageText(QString("%1 QUERY MSG [ID]").arg(selectedCall), true, true); }); menu->addSeparator(); auto agnAction = menu->addAction( QString("%1 AGN? - Please repeat your last transmission") .arg(call) .trimmed()); connect(agnAction, &QAction::triggered, this, [this]() { QString selectedCall = callsignSelected(); if (selectedCall.isEmpty()) { return; } addMessageText(QString("%1 AGN?").arg(selectedCall), true); if (m_config.transmit_directed()) toggleTx(true); }); auto qslQueryAction = menu->addAction( QString("%1 QSL? - Did you receive my last transmission?") .arg(call) .trimmed()); connect(qslQueryAction, &QAction::triggered, this, [this]() { QString selectedCall = callsignSelected(); if (selectedCall.isEmpty()) { return; } addMessageText(QString("%1 QSL?").arg(selectedCall), true); if (m_config.transmit_directed()) toggleTx(true); }); auto qslAction = menu->addAction( QString("%1 QSL - I confirm I received your last transmission") .arg(call) .trimmed()); connect(qslAction, &QAction::triggered, this, [this]() { QString selectedCall = callsignSelected(); if (selectedCall.isEmpty()) { return; } addMessageText(QString("%1 QSL").arg(selectedCall), true); if (m_config.transmit_directed()) toggleTx(true); }); auto yesAction = menu->addAction( QString("%1 YES - I confirm your last inquiry").arg(call).trimmed()); connect(yesAction, &QAction::triggered, this, [this]() { QString selectedCall = callsignSelected(); if (selectedCall.isEmpty()) { return; } addMessageText(QString("%1 YES").arg(selectedCall), true); if (m_config.transmit_directed()) toggleTx(true); }); auto noAction = menu->addAction(QString("%1 NO - I do not confirm your last inquiry") .arg(call) .trimmed()); connect(noAction, &QAction::triggered, this, [this]() { QString selectedCall = callsignSelected(); if (selectedCall.isEmpty()) { return; } addMessageText(QString("%1 NO").arg(selectedCall), true); if (m_config.transmit_directed()) toggleTx(true); }); auto hwAction = menu->addAction( QString("%1 HW CPY? - How do you copy?").arg(call).trimmed()); connect(hwAction, &QAction::triggered, this, [this]() { QString selectedCall = callsignSelected(); if (selectedCall.isEmpty()) { return; } addMessageText(QString("%1 HW CPY?").arg(selectedCall), true); if (m_config.transmit_directed()) toggleTx(true); }); auto rrAction = menu->addAction( QString("%1 RR - Roger. Received. I copy.").arg(call).trimmed()); connect(rrAction, &QAction::triggered, this, [this]() { QString selectedCall = callsignSelected(); if (selectedCall.isEmpty()) { return; } addMessageText(QString("%1 RR").arg(selectedCall), true); if (m_config.transmit_directed()) toggleTx(true); }); auto fbAction = menu->addAction(QString("%1 FB - Fine Business").arg(call).trimmed()); connect(fbAction, &QAction::triggered, this, [this]() { QString selectedCall = callsignSelected(); if (selectedCall.isEmpty()) { return; } addMessageText(QString("%1 FB").arg(selectedCall), true); if (m_config.transmit_directed()) toggleTx(true); }); auto sevenThreeAction = menu->addAction( QString("%1 73 - I send my best regards").arg(call).trimmed()); connect(sevenThreeAction, &QAction::triggered, this, [this]() { QString selectedCall = callsignSelected(); if (selectedCall.isEmpty()) { return; } addMessageText(QString("%1 73").arg(selectedCall), true); if (m_config.transmit_directed()) toggleTx(true); }); auto skAction = menu->addAction(QString("%1 SK - End of contact").arg(call).trimmed()); connect(skAction, &QAction::triggered, this, [this]() { QString selectedCall = callsignSelected(); if (selectedCall.isEmpty()) { return; } addMessageText(QString("%1 SK").arg(selectedCall), true); if (m_config.transmit_directed()) toggleTx(true); }); auto ditDitAction = menu->addAction( QString("%1 DIT DIT - End of contact / Two bits").arg(call).trimmed()); connect(ditDitAction, &QAction::triggered, this, [this]() { QString selectedCall = callsignSelected(); if (selectedCall.isEmpty()) { return; } addMessageText(QString("%1 DIT DIT").arg(selectedCall), true); if (m_config.transmit_directed()) toggleTx(true); }); } void MainWindow::buildRelayMenu(QMenu *menu) { auto now = DriftingDateTime::currentDateTimeUtc(); int callsignAging = m_config.callsign_aging(); foreach (auto cd, m_callActivity.values()) { if (callsignAging && cd.utcTimestamp.secsTo(now) / 60 >= callsignAging) { continue; } menu->addAction(buildRelayAction(cd.call)); } } QAction *MainWindow::buildRelayAction(QString call) { QAction *a = new QAction(call, nullptr); connect(a, &QAction::triggered, this, [this, call]() { prependMessageText(QString("%1>").arg(call)); }); return a; } void MainWindow::buildEditMenu(QMenu *menu, QTextEdit *edit) { bool hasSelection = !edit->textCursor().selectedText().isEmpty(); auto cut = menu->addAction("Cu&t"); cut->setEnabled(hasSelection && !edit->isReadOnly()); connect(edit, &QTextEdit::copyAvailable, this, [edit, cut](bool copyAvailable) { cut->setEnabled(copyAvailable && !edit->isReadOnly()); }); connect(cut, &QAction::triggered, this, [edit]() { edit->copy(); edit->textCursor().removeSelectedText(); }); auto copy = menu->addAction("&Copy"); copy->setEnabled(hasSelection); connect(edit, &QTextEdit::copyAvailable, this, [copy](bool copyAvailable) { copy->setEnabled(copyAvailable); }); connect(copy, &QAction::triggered, edit, &QTextEdit::copy); auto paste = menu->addAction("&Paste"); paste->setEnabled(edit->canPaste()); connect(paste, &QAction::triggered, edit, &QTextEdit::paste); } QMap MainWindow::buildMacroValues() { auto lastActive = DriftingDateTime::currentDateTimeUtc().addSecs(-m_idleMinutes * 60); QString myIdle = since(lastActive).toUpper().replace("NOW", "0M"); QString myVersion = version().replace("-devel", "").replace("-rc", ""); QMap values = { {"", m_config.my_callsign()}, {"", m_config.my_grid().left(4)}, {"", m_config.my_grid().left(12)}, {"", m_config.my_info()}, {"", m_config.hb_message()}, {"", m_config.cq_message()}, {"", m_config.reply_message()}, {"", m_config.my_status()}, {"", myVersion}, {"", myIdle}, }; auto selectedCall = callsignSelected(); if (m_callActivity.contains(selectedCall)) { auto cd = m_callActivity[selectedCall]; values[""] = selectedCall; values[""] = QString("%1 ms").arg((int)(1000 * cd.tdrift)); if (cd.snr > -31) { values[""] = Varicode::formatSNR(cd.snr); } } // these macros can have recursive macros values[""] = replaceMacros(values[""], values, false); values[""] = replaceMacros(values[""], values, false); values[""] = replaceMacros(values[""], values, false); values[""] = replaceMacros(values[""], values, false); values[""] = replaceMacros(values[""], values, false); return values; } void MainWindow::buildColumnLabelMap() { // This is the map of full-length strings to shortened versions // Add new minimal labels here as needed m_columnLabelMap = {{"Callsigns", "Call"}, {"Callsigns (%1)", "Call(%1)"}, {"Offset", "Off"}, {"SNR", "SN"}, {"Time Delta", "TD"}, {"Speed", "Sp"}, {"Distance", "Dist"}, {"Azimuth", "Az"}, {"%1 ms", "%1"}, {"%1 dB", "%1"}, {"%1 Hz", "%1"}}; // Populate original header maps int cols = ui->tableWidgetRXAll->columnCount(); for (int c = 0; c < cols; ++c) { QString label = ui->tableWidgetRXAll->horizontalHeaderItem(c)->text(); m_origRxHeaderLabelMap[c] = label; } cols = ui->tableWidgetCalls->columnCount(); for (int c = 0; c < cols; ++c) { QString label = ui->tableWidgetCalls->horizontalHeaderItem(c)->text(); m_origCallActivityHeaderLabelMap[c] = label; } } void MainWindow::buildSuggestionsMenu(QMenu *menu, QTextEdit *edit, const QPoint &point) { if (!m_config.spellcheck()) { return; } bool found = false; auto c = edit->cursorForPosition(point); if (c.charFormat().underlineStyle() != QTextCharFormat::WaveUnderline) { return; } c.movePosition(QTextCursor::StartOfWord); c.movePosition(QTextCursor::EndOfWord, QTextCursor::KeepAnchor); auto word = c.selectedText().toUpper().trimmed(); if (word.isEmpty()) { return; } QStringList suggestions = JSCChecker::suggestions(word, 5, &found); if (suggestions.isEmpty() && !found) { return; } if (suggestions.isEmpty()) { auto a = menu->addAction("No Suggestions"); a->setDisabled(true); } else { foreach (auto suggestion, suggestions) { auto a = menu->addAction(suggestion); connect(a, &QAction::triggered, this, [edit, point, suggestion]() { auto c = edit->cursorForPosition(point); c.select(QTextCursor::WordUnderCursor); c.insertText(suggestion); }); } } menu->addSeparator(); } void MainWindow::buildSavedMessagesMenu(QMenu *menu) { auto values = buildMacroValues(); foreach (QString macro, m_config.macros()->stringList()) { QAction *action = menu->addAction(replaceMacros(macro, values, false)); connect(action, &QAction::triggered, this, [this, macro]() { auto values = buildMacroValues(); addMessageText(replaceMacros(macro, values, true)); if (m_config.transmit_directed()) toggleTx(true); }); } menu->addSeparator(); auto editAction = new QAction(QString("&Edit Saved Messages"), menu); menu->addAction(editAction); connect(editAction, &QAction::triggered, this, [this]() { openSettings(5); }); auto saveAction = new QAction(QString("&Save Current Message"), menu); saveAction->setDisabled(ui->extFreeTextMsgEdit->toPlainText().isEmpty()); menu->addAction(saveAction); connect(saveAction, &QAction::triggered, this, [this]() { auto macros = m_config.macros(); if (macros->insertRow(macros->rowCount())) { auto index = macros->index(macros->rowCount() - 1); macros->setData(index, ui->extFreeTextMsgEdit->toPlainText()); writeSettings(); } }); } void MainWindow::on_queryButton_pressed() { QMenu *menu = ui->queryButton->menu(); if (!menu) { menu = new QMenu(ui->queryButton); } menu->clear(); buildQueryMenu(menu, callsignSelected()); ui->queryButton->setMenu(menu); ui->queryButton->showMenu(); } void MainWindow::on_macrosMacroButton_pressed() { QMenu *menu = ui->macrosMacroButton->menu(); if (!menu) { menu = new QMenu(ui->macrosMacroButton); } menu->clear(); buildSavedMessagesMenu(menu); ui->macrosMacroButton->setMenu(menu); ui->macrosMacroButton->showMenu(); } void MainWindow::on_deselectButton_pressed() { clearCallsignSelected(); } void MainWindow::on_tableWidgetRXAll_cellClicked(int /*row*/, int /*col*/) { ui->tableWidgetCalls->selectionModel()->select( ui->tableWidgetCalls->selectionModel()->selection(), QItemSelectionModel::Deselect); displayCallActivity(); } void MainWindow::on_tableWidgetRXAll_cellDoubleClicked(int row, int col) { on_tableWidgetRXAll_cellClicked(row, col); // TODO: jsherer - could also parse the messages for the last callsign? auto item = ui->tableWidgetRXAll->item(row, 0); int offset = item->text().replace(" Hz", "").toInt(); // switch to the offset of this row setFreqOffsetForRestore(offset, false); // TODO: prompt mode switch? // print the history in the main window... int activityAging = m_config.activity_aging(); QDateTime now = DriftingDateTime::currentDateTimeUtc(); QDateTime firstActivity = now; QString activityText; bool isLast = false; foreach (auto d, m_bandActivity[offset]) { if (activityAging && d.utcTimestamp.secsTo(now) / 60 >= activityAging) { continue; } if (activityText.isEmpty()) { firstActivity = d.utcTimestamp; } activityText.append(d.text); isLast = (d.bits & Varicode::JS8CallLast) == Varicode::JS8CallLast; if (isLast) { activityText = QString("%1 %2 ") .arg(Varicode::rstrip(activityText)) .arg(m_config.eot()); } } if (!activityText.isEmpty()) { displayTextForFreq(activityText, offset, firstActivity, false, true, isLast); } } QString MainWindow::generateCallDetail(QString selectedCall) { if (selectedCall.isEmpty()) { return ""; } // heard detail QString hearing = m_heardGraphOutgoing.value(selectedCall).values().join(", "); QString heardby = m_heardGraphIncoming.value(selectedCall).values().join(", "); QStringList detail = { QString("

%1

").arg(selectedCall.toHtmlEscaped()), hearing.isEmpty() ? "" : QString("

HEARING: %1

") .arg(hearing.toHtmlEscaped()), heardby.isEmpty() ? "" : QString("

HEARD BY: %1

") .arg(heardby.toHtmlEscaped()), }; return detail.join("\n"); } void MainWindow::on_tableWidgetCalls_cellClicked(int /*row*/, int /*col*/) { ui->tableWidgetRXAll->selectionModel()->select( ui->tableWidgetRXAll->selectionModel()->selection(), QItemSelectionModel::Deselect); displayBandActivity(); } void MainWindow::on_tableWidgetCalls_cellDoubleClicked(int row, int col) { on_tableWidgetCalls_cellClicked(row, col); auto call = callsignSelected(); addMessageText(call); #if SHOW_MESSAGE_HISTORY_ON_DOUBLECLICK if (m_rxInboxCountCache.value(call, 0) > 0) { // TODO: // CommandDetail d = m_rxCallsignInboxCountCache[call].first(); // m_rxCallsignInboxCountCache[call].removeFirst(); // // processAlertReplyForCommand(d, d.relayPath, d.cmd); Inbox i(inboxPath()); if (i.open()) { QList msgs; foreach (auto pair, i.values("UNREAD", "$.params.FROM", call, 0, 1000)) { msgs.append(pair.second); } auto mw = new MessageWindow(this); mw->populateMessages(msgs); mw->show(); auto pair = i.firstUnreadFrom(call); auto id = pair.first; auto msg = pair.second; auto params = msg.params(); CommandDetail d; d.cmd = params.value("CMD").toString(); d.extra = params.value("EXTRA").toString(); d.freq = params.value("OFFSET").toInt(); d.from = params.value("FROM").toString(); d.grid = params.value("GRID").toString(); d.relayPath = params.value("PATH").toString(); d.snr = params.value("SNR").toInt(); d.tdrift = params.value("TDRIFT").toFloat(); d.text = params.value("TEXT").toString(); d.to = params.value("TO").toString(); d.utcTimestamp = QDateTime::fromString( params.value("UTC").toString(), "yyyy-MM-dd hh:mm:ss"); d.utcTimestamp.setUtcOffset(0); msg.setType("READ"); i.set(id, msg); m_rxInboxCountCache[call] = max(0, m_rxInboxCountCache.value(call) - 1); processAlertReplyForCommand(d, d.relayPath, d.cmd); } } else { addMessageText(call); } #endif } void MainWindow::on_tuneButton_clicked(bool checked) { static bool lastChecked = false; if (lastChecked == checked) return; lastChecked = checked; if (checked && m_tune == false) { // we're starting tuning so remember Tx // and change pwr to Tune value if (m_config.pwrBandTuneMemory()) { m_pwrBandTxMemory[m_lastBand] = ui->outAttenuation->value(); // remember our Tx pwr m_PwrBandSetOK = false; if (m_pwrBandTuneMemory.contains(m_lastBand)) { ui->outAttenuation->setValue( m_pwrBandTuneMemory[m_lastBand].toInt()); // set to Tune pwr } m_PwrBandSetOK = true; } } if (m_tune) { tuneButtonTimer.start(250); } else { itone[0] = 0; on_monitorButton_clicked(true); m_tune = true; } Q_EMIT tune(checked); } void MainWindow::end_tuning() { tuneATU_Timer.stop(); // stop tune watchdog when stopping Tune manually on_stopTxButton_clicked(); // we're turning off so remember our Tune pwr setting and reset to Tx pwr if (m_config.pwrBandTuneMemory() || m_config.pwrBandTxMemory()) { m_pwrBandTuneMemory[m_lastBand] = ui->outAttenuation->value(); // remember our Tune pwr m_PwrBandSetOK = false; ui->outAttenuation->setValue( m_pwrBandTxMemory[m_lastBand].toInt()); // set to Tx pwr m_PwrBandSetOK = true; } } void MainWindow::stop_tuning() { tuneATU_Timer.stop(); // stop tune watchdog when stopping Tune manually on_tuneButton_clicked(false); ui->tuneButton->setChecked(false); m_isTimeToSend = false; m_tune = false; } void MainWindow::stopTuneATU() { on_tuneButton_clicked(false); m_isTimeToSend = false; } void MainWindow::resetPushButtonToggleText(QPushButton *btn) { bool checked = btn->isChecked(); auto style = btn->styleSheet(); if (checked) { style = style.replace("font-weight:normal;", "font-weight:bold;"); } else { style = style.replace("font-weight:bold;", "font-weight:normal;"); } btn->setStyleSheet(style); #if PUSH_BUTTON_CHECKMARK auto on = "✓ "; auto text = btn->text(); if (checked) { btn->setText(on + text.replace(on, "")); } else { btn->setText(text.replace(on, "")); } #endif #if PUSH_BUTTON_MIN_WIDTH int width = 0; QList btns; foreach (auto child, ui->buttonGrid->children()) { if (!child->isWidgetType()) { continue; } if (!child->objectName().contains("Button")) { continue; } auto b = qobject_cast(child); width = qMax(width, b->geometry().width()); btns.append(b); } foreach (auto child, btns) { child->setMinimumWidth(width); } #endif } void MainWindow::on_stopTxButton_clicked() // Stop Tx { if (m_tune) stop_tuning(); if (m_auto and !m_tuneup) auto_tx_mode(false); m_btxok = false; resetMessage(); if (m_stopTxButtonIsLongterm) { m_hb_loop->onLoopCancel(); m_cq_loop->onLoopCancel(); } } void MainWindow::rigOpen() { update_dynamic_property(ui->readFreq, "state", "warning"); ui->readFreq->setText("CAT"); ui->readFreq->setEnabled(true); m_config.transceiver_online(); Q_EMIT m_config.sync_transceiver(true, true); } void MainWindow::on_readFreq_clicked() { if (m_transmitting) return; if (m_config.transceiver_online()) { Q_EMIT m_config.sync_transceiver(true, true); } } void MainWindow::setXIT(int audio_freq) { if (m_transmitting && !m_config.tx_qsy_allowed()) { qCWarning(mainwindow_js8) << "Ignoring change of audio freq to" << audio_freq << "as currently transmitting."; return; } // m_XIT is the frequency diff that will be added to the audio frequency // and subtracted from the radio frequency. // The new audio frequency is in the 1500 - 2000 Hz range, // the audio actually transmitted is 1500 - 2160 Hz (TURBO has 160 Hz // bandwith). This way, the unwanted triple audio frequency possibly // generated by audio distortions is safely beyond the TX audio bandwidth of // 3 kHz and will not result in transmission. Also, the 1500 - 2160 Hz range // should not be distorted or dampened by the TX's audio filter. if (m_config.split_mode()) { const int next_lower_multiple_of_500 = audio_freq - audio_freq % 500; m_XIT = 1500 - next_lower_multiple_of_500; } else { m_XIT = 0; } const int new_audio_frequency = audio_freq + m_XIT; if ((m_monitoring || m_transmitting) && m_config.is_transceiver_online() && m_config.split_mode()) { // All conditions are met, reset the transceiver Tx dial frequency m_freqTxNominal = m_freqNominal - m_XIT; qCDebug(mainwindow_js8) << "For incoming AF" << audio_freq << "setting tx HF to" << m_freqTxNominal << "and new AF to" << new_audio_frequency; Q_EMIT m_config.transceiver_tx_frequency(m_freqTxNominal); } // Now set the audio Tx freq Q_EMIT transmitFrequency(new_audio_frequency); } void MainWindow::qsy(int const hzDelta) { setRig(m_freqNominal + hzDelta); setFreqOffsetForRestore(m_wideGraph->centerFreq(), false); // Adjust band activity frequencies. BandActivity bandActivity; for (auto [key, value] : m_bandActivity.asKeyValueRange()) { if (value.isEmpty()) continue; auto const newKey = key - hzDelta; bandActivity[newKey] = value; bandActivity[newKey].last().offset -= hzDelta; } m_bandActivity.swap(bandActivity); // Adjust call activity frequencies. for (auto [key, value] : m_callActivity.asKeyValueRange()) { value.offset -= hzDelta; } displayActivity(true); } void MainWindow::onDriftChanged(qint64 /*new_drift_ms*/) { // here we reset the buffer position without clearing the buffer // this makes the detected emit the correct k when drifting time qCDebug(mainwindow_js8) << "Processing drift change."; m_detector->resetBufferPosition(); } void MainWindow::setFreqOffsetForRestore(int freq, bool shouldRestore) { changeFreq(freq); if (shouldRestore) { m_shouldRestoreFreq = true; } else { m_previousFreq = 0; m_shouldRestoreFreq = false; } } bool MainWindow::tryRestoreFreqOffset() { if (!m_shouldRestoreFreq || m_previousFreq == 0) { return false; } setFreqOffsetForRestore(m_previousFreq, false); return true; } void MainWindow::changeFreq(int const newFreq) { // Don't allow QSY if we've already queued a transmission, // unless we have that functionality enabled. if (isMessageQueuedForTransmit() && !m_config.tx_qsy_allowed()) return; // TODO: jsherer - here's where we'd set minimum frequency again (later?) m_previousFreq = freq(); setFreq(std::max(0, newFreq)); displayDialFrequency(); } void MainWindow::handle_transceiver_update( Transceiver::TransceiverState const &new_rig_state) { qCDebug(mainwindow_js8) << "MainWindow::handle_transceiver_update:" << new_rig_state; Transceiver::TransceiverState old_state{m_rigState}; // GM8JCF: in stopTx2 we maintain PTT if there are still untransmitted JS8 // frames and we are holding the PTT KN4CRD: if we're not holding the PTT we // need to check to ensure it's safe to transmit if (m_config.hold_ptt() || (new_rig_state.ptt() && !m_rigState .ptt())) // safe to start audio (caveat - DX Lab Suite Commander) { if (m_generateAudioWhenPttConfirmedByTX && m_iptt) // waiting to Tx and still needed { // The Modulator nicely emits silence during txDelay, so let us just // tigger it. transmit(); } m_generateAudioWhenPttConfirmedByTX = false; } m_rigState = new_rig_state; auto old_freqNominal = m_freqNominal; if (!old_freqNominal) { // always take initial rig frequency to avoid start up problems // with bogus Tx frequencies m_freqNominal = new_rig_state.frequency(); } if (old_state.online() == false && new_rig_state.online() == true) { // initializing on_monitorButton_clicked(!m_config.monitor_off_at_startup()); on_monitorTxButton_toggled(!m_config.transmit_off_at_startup()); } if (new_rig_state.frequency() != old_state.frequency() || new_rig_state.split() != m_splitMode) { m_splitMode = new_rig_state.split(); if (!new_rig_state.ptt()) { m_freqNominal = new_rig_state.frequency(); if (old_freqNominal != m_freqNominal) { m_freqTxNominal = m_freqNominal; } if (m_monitoring) { m_lastMonitoredFrequency = m_freqNominal; } if (m_lastDialFreq != m_freqNominal) { m_lastDialFreq = m_freqNominal; m_secBandChanged = DriftingDateTime::currentMSecsSinceEpoch() / 1000; if (m_freqNominal != m_bandHoppedFreq) { m_bandHopped = false; } if (new_rig_state.frequency() < 30000000u) { write_frequency_entry("ALL.TXT"); } if (m_config.spot_to_reporting_networks()) { spotSetLocal(); pskSetLocal(); aprsSetLocal(); } statusChanged(); m_wideGraph->setDialFreq(m_freqNominal / 1.e6f); } } else { m_freqTxNominal = new_rig_state.split() ? new_rig_state.tx_frequency() : new_rig_state.frequency(); } } // ensure frequency display is correct // setRig(); updateCurrentBand(); displayDialFrequency(); update_dynamic_property(ui->readFreq, "state", "ok"); ui->readFreq->setEnabled(false); ui->readFreq->setText(new_rig_state.split() ? "CAT/S" : "CAT"); } void MainWindow::handle_transceiver_failure(QString const &reason) { update_dynamic_property(ui->readFreq, "state", "error"); ui->readFreq->setEnabled(true); on_stopTxButton_clicked(); rigFailure(reason); } void MainWindow::rigFailure(QString const &reason) { if (m_first_error) { // one automatic retry QTimer::singleShot(0, this, &MainWindow::rigOpen); m_first_error = false; } else { m_rigErrorMessageBox.setDetailedText(reason); // don't call slot functions directly to avoid recursion m_rigErrorMessageBox.exec(); auto const clicked_button = m_rigErrorMessageBox.clickedButton(); if (clicked_button == m_configurations_button) { ui->menuConfig->exec(QCursor::pos()); } else { switch (m_rigErrorMessageBox.standardButton(clicked_button)) { case JS8MessageBox::Ok: m_config.select_tab(1); QTimer::singleShot(0, this, &MainWindow::on_actionSettings_triggered); break; case JS8MessageBox::Retry: QTimer::singleShot(0, this, &MainWindow::rigOpen); break; case JS8MessageBox::Cancel: QTimer::singleShot(0, this, &MainWindow::close); break; default: break; // squashing compile warnings } } m_first_error = true; // reset } } void MainWindow::on_outAttenuation_valueChanged(int const a) { if (m_PwrBandSetOK) { if (!m_tune && m_config.pwrBandTxMemory()) m_pwrBandTxMemory[m_lastBand] = a; // remember our Tx pwr if (m_tune && m_config.pwrBandTuneMemory()) m_pwrBandTuneMemory[m_lastBand] = a; // remember our Tune pwr } // Slider interpreted as dB / 100. Q_EMIT outAttenuationChanged(a / 10.0); } void MainWindow::spotSetLocal() { Q_EMIT spotClientSetLocalStation( m_config.my_callsign(), m_config.my_grid(), replaceMacros(m_config.my_info(), buildMacroValues(), true)); } void MainWindow::pskSetLocal() { Q_EMIT pskReporterSetLocalStation( m_config.my_callsign(), m_config.my_grid(), replaceMacros(m_config.my_info(), buildMacroValues(), true)); } void MainWindow::aprsSetLocal() { Q_EMIT aprsClientSetLocalStation( "APJ8CL", QString::number(APRSISClient::hashCallsign("APJ8CL"))); } void MainWindow::transmitDisplay(bool transmitting) { if (transmitting == m_transmitting) { if (transmitting) { ui->signal_meter_widget->setValue(0, 0); if (m_monitoring) monitor(false); m_btxok = true; } } updateTxButtonDisplay(); } void MainWindow::postDecode(bool is_new, QString const &) { #if 0 auto const& decode = message.trimmed (); auto const& parts = decode.left (22).split (' ', QString::SkipEmptyParts); if (parts.size () >= 5) { auto has_seconds = parts[0].size () > 4; m_messageClient->decode (is_new , QTime::fromString (parts[0], has_seconds ? "hhmmss" : "hhmm") , parts[1].toInt () , parts[2].toFloat (), parts[3].toUInt (), parts[4] , decode.mid (has_seconds ? 24 : 22, 21) , QChar {'?'} == decode.mid (has_seconds ? 24 + 21 : 22 + 21, 1) , m_diskData); } #endif if (is_new) { m_rxDirty = true; } } void MainWindow::tryNotify(QString const &key) { if (auto const path = m_config.notification_path(key); !path.isEmpty()) { emit playNotification(path); } } void MainWindow::displayTransmit() { // Transmit Activity update_dynamic_property(ui->startTxButton, "transmitting", m_transmitting); update_dynamic_property(ui->monitorTxButton, "transmitting", m_transmitting); } bool MainWindow::presentlyWantHBReplies() { return ui->actionModeAutoreply->isChecked() && ui->actionHeartbeatAcknowledgements->isChecked() && m_messageBuffer.isEmpty() && (!m_config.heartbeat_qso_pause() || m_prevSelectedCallsign.isEmpty()); } void MainWindow::updateModeButtonText() { auto multi = ui->actionModeMultiDecoder->isChecked(); auto autoreply = ui->actionModeAutoreply->isChecked(); auto heartbeat = ui->actionModeJS8HB->isEnabled() && ui->actionModeJS8HB->isChecked(); auto modeText = JS8::Submode::name(m_nSubMode); if (multi) { modeText += QString("+MULTI"); } if (autoreply) { if (m_config.autoreply_confirmation()) { modeText += QString("+AUTO+CONF"); } else { modeText += QString("+AUTO"); } } if (heartbeat) { if (presentlyWantHBReplies()) { modeText += QString("+HB+ACK"); } else { modeText += QString("+HB"); } } ui->modeButton->setText(modeText); } void MainWindow::updateButtonDisplay() { bool isTransmitting = isMessageQueuedForTransmit(); auto selectedCallsign = callsignSelected(true); bool emptyCallsign = selectedCallsign.isEmpty(); bool emptyInfo = m_config.my_info().isEmpty(); bool emptyStatus = m_config.my_status().isEmpty(); bool previous_hbButtonisLongterm = m_hbButtonIsLongterm; m_hbButtonIsLongterm = false; ui->hbMacroButton->setDisabled(isTransmitting); m_hbButtonIsLongterm = previous_hbButtonisLongterm; bool previous_cqButtonisLongterm = m_cqButtonIsLongterm; m_cqButtonIsLongterm = false; ui->cqMacroButton->setDisabled(isTransmitting); m_cqButtonIsLongterm = previous_cqButtonisLongterm; ui->replyMacroButton->setDisabled(isTransmitting || emptyCallsign); ui->snrMacroButton->setDisabled(isTransmitting || emptyCallsign); ui->infoMacroButton->setDisabled(isTransmitting || emptyInfo); ui->statusMacroButton->setDisabled(isTransmitting || emptyStatus); ui->macrosMacroButton->setDisabled(isTransmitting); ui->queryButton->setDisabled(isTransmitting || emptyCallsign); ui->deselectButton->setDisabled(isTransmitting || emptyCallsign); ui->queryButton->setText( emptyCallsign ? "Directed" : QString("Directed to %1").arg(selectedCallsign)); // update mode button text updateModeButtonText(); } void MainWindow::updateHBButtonDisplay() { if (m_hb_loop->isActive()) { QDateTime now = DriftingDateTime::currentDateTimeUtc(); QDateTime nextHeartbeat = m_hb_loop->nextActivity(); long secs = std::lround(now.msecsTo(nextHeartbeat) / 1000.0); QString hbBase = presentlyWantHBReplies() ? "HB + ACK" : "HB"; if (secs > 0) { ui->hbMacroButton->setText( QString("%1 (%2)").arg(hbBase).arg(secs)); } else { // Dead code? ui->hbMacroButton->setText(QString("%1 (now)").arg(hbBase)); } } else { if (presentlyWantHBReplies()) { ui->hbMacroButton->setText("HB + ACK"); } else { ui->hbMacroButton->setText("HB"); } } } void MainWindow::updateCQButtonDisplay() { if (m_cq_loop->isActive()) { QDateTime now = DriftingDateTime::currentDateTimeUtc(); QDateTime nextCQ = m_cq_loop->nextActivity(); long secs = std::lround(now.msecsTo(nextCQ) / 1000.0); // qCDebug(mainwindow_js8) // << "updateCQButtonDisplay, signal due at" << nextCQ // << "so" << secs << "s to go"; if (secs > 0) { ui->cqMacroButton->setText(QString("CQ (%1)").arg(secs)); } else { // Dead code? ui->cqMacroButton->setText("CQ (now)"); } } else { ui->cqMacroButton->setText("CQ"); // qCDebug(mainwindow_js8) << "updateCQButtonDisplay while m_cq_loop is // off"; } } void MainWindow::updateTextDisplay() { bool canTransmit = ensureCanTransmit(); bool isTransmitting = isMessageQueuedForTransmit(); bool emptyText = ui->extFreeTextMsgEdit->toPlainText().isEmpty(); ui->startTxButton->setDisabled(!canTransmit || isTransmitting || emptyText); if (m_txTextDirty) { // debounce frame and word count if (m_txTextDirtyDebounce.isActive()) { m_txTextDirtyDebounce.stop(); } m_txTextDirtyDebounce.setSingleShot(true); m_txTextDirtyDebounce.start(100); m_txTextDirty = false; } } #if __APPLE__ #define USE_SYNC_FRAME_COUNT 0 #else #define USE_SYNC_FRAME_COUNT 0 #endif void MainWindow::refreshTextDisplay() { qCDebug(mainwindow_js8) << "refreshing text display..."; auto text = ui->extFreeTextMsgEdit->toPlainText(); #if USE_SYNC_FRAME_COUNT auto frames = buildMessageFrames(text); QStringList textList; qCDebug(mainwindow_js8) << "frames:"; foreach (auto frame, frames) { auto dt = DecodedText(frame.first, frame.second); qCDebug(mainwindow_js8) << "->" << frame << dt.message() << Varicode::frameTypeString(dt.frameType()); textList.append(dt.message()); } auto transmitText = textList.join(""); auto count = frames.length(); // ugh...i hate these globals m_txTextDirtyLastSelectedCall = callsignSelected(true); m_txTextDirtyLastText = text; m_txFrameCountEstimate = count; m_txTextDirty = false; updateTextWordCheckerDisplay(); updateTextStatsDisplay(transmitText, count); updateTxButtonDisplay(); #else // prepare selected callsign for directed message QString selectedCall = callsignSelected(); // prepare compound QString mycall = m_config.my_callsign(); QString mygrid = m_config.my_grid().left(4); bool forceIdentify = !m_config.avoid_forced_identify(); bool forceData = false; BuildMessageFramesThread *t = new BuildMessageFramesThread(mycall, mygrid, selectedCall, text, forceIdentify, forceData, m_nSubMode); connect(t, &BuildMessageFramesThread::finished, t, &QObject::deleteLater); connect(t, &BuildMessageFramesThread::resultReady, this, [this, text](QString transmitText, int frames) { // ugh...i hate these globals m_txTextDirtyLastSelectedCall = callsignSelected(true); m_txTextDirtyLastText = text; m_txFrameCountEstimate = frames; m_txTextDirty = false; updateTextWordCheckerDisplay(); updateTextStatsDisplay(transmitText, m_txFrameCountEstimate); updateTxButtonDisplay(); }); t->start(); #endif } void MainWindow::updateTextWordCheckerDisplay() { if (!m_config.spellcheck()) { return; } JSCChecker::checkRange(ui->extFreeTextMsgEdit, 0, -1); } void MainWindow::updateTextStatsDisplay(QString text, int count) { const double fpm = 60.0 / m_TRperiod; if (count > 0) { auto words = text.split(" ", Qt::SkipEmptyParts).length(); auto wpm = QString::number(words / (count / fpm), 'f', 1); auto cpm = QString::number(text.length() / (count / fpm), 'f', 1); wpm_label.setText(QString("%1wpm / %2cpm").arg(wpm).arg(cpm)); wpm_label.setVisible(true); } else { wpm_label.setVisible(false); wpm_label.clear(); } } void MainWindow::updateTxButtonDisplay() { // can we transmit at all? bool canTransmit = ensureCanTransmit(); // if we're tuning or have a message queued if (m_tune || isMessageQueuedForTransmit()) { int count = m_txFrameCount; int left = m_txFrameQueue.count(); int sent = count - left; QString buttonText; if (m_tune) { buttonText = State::Tuning.toString(); } else if (m_transmitting) { buttonText = State::timed(State::Sending, ((left + 1) * m_TRperiod) - ((m_sec0 + 1) % m_TRperiod)); } else { buttonText = State::timed( State::Ready, sent == 1 ? ((left + 1) * m_TRperiod) : (((left + 2) * m_TRperiod) - ((m_sec0 + 1) % m_TRperiod))); } ui->startTxButton->setText(buttonText); ui->startTxButton->setEnabled(false); ui->startTxButton->setFlat(true); } else { QString const buttonText = m_txFrameCountEstimate > 0 ? State::timed(State::Send, m_txFrameCountEstimate * m_TRperiod) : State::Send.toString(); ui->startTxButton->setText(buttonText); ui->startTxButton->setEnabled(canTransmit && m_txFrameCountEstimate > 0); ui->startTxButton->setFlat(false); } } QString MainWindow::callsignSelected(bool) { if (!ui->tableWidgetCalls->selectedItems().isEmpty()) { auto selectedCalls = ui->tableWidgetCalls->selectedItems(); if (!selectedCalls.isEmpty()) { auto call = selectedCalls.first()->data(Qt::UserRole).toString(); if (!call.isEmpty()) { return call; } } } if (!ui->tableWidgetRXAll->selectedItems().isEmpty()) { int selectedOffset = -1; auto selectedItems = ui->tableWidgetRXAll->selectedItems(); selectedOffset = selectedItems.first()->data(Qt::UserRole).toInt(); int threshold = 0; auto activity = m_bandActivity.value(selectedOffset); if (!activity.isEmpty()) { threshold = JS8::Submode::rxThreshold(activity.last().submode); } auto keys = m_callActivity.keys(); std::stable_sort(keys.begin(), keys.end(), [this](QString const &lhs, QString const &rhs) { auto const lhsTS = m_callActivity[lhs].utcTimestamp; auto const rhsTS = m_callActivity[rhs].utcTimestamp; return lhsTS == rhsTS ? lhs < rhs : rhsTS < lhsTS; }); // Return the first callsign at a frequency within the // threshold limit of the selected offset, if any. auto const offsetLo = selectedOffset - threshold; auto const offsetHi = selectedOffset + threshold; for (auto const &key : keys) { if (auto const &d = m_callActivity[key]; offsetLo <= d.offset && d.offset <= offsetHi) { return d.call; } } } #if ALLOW_USE_INPUT_TEXT_CALLSIGN if (useInputText) { auto text = ui->extFreeTextMsgEdit->toPlainText().left( 11); // Maximum callsign is 6 + / + 4 = 11 characters auto calls = Varicode::parseCallsigns(text); if (!calls.isEmpty() && text.startsWith(calls.first()) && calls.first() != m_config.my_callsign()) { return calls.first(); } } #endif return QString(); } void MainWindow::callsignSelectedChanged(QString /*old*/, QString selectedCall) { auto placeholderText = QString("Type your outgoing messages here.").toUpper(); if (selectedCall.isEmpty()) { // try to restore hb if (m_hbPaused) { ui->hbMacroButton->setChecked(true); m_hbPaused = false; } } else { placeholderText = QString("Type your outgoing directed message to %1 here.") .arg(selectedCall) .toUpper(); // when we select a callsign, use it as the qso start time if (!m_callSelectedTime.contains(selectedCall)) { m_callSelectedTime[selectedCall] = DriftingDateTime::currentDateTimeUtc(); } if (m_config.heartbeat_qso_pause()) { // TODO: jsherer - HB issue // don't hb if we select a callsign... (but we should keep track so // if we deselect, we restore our hb) if (ui->hbMacroButton->isChecked()) { qCDebug(mainwindow_js8) << "Unchecking hbMacroButton after selection" << selectedCall << "but planning to resurrect later"; ui->hbMacroButton->setChecked(false); m_hbPaused = true; } // don't cq if we select a callsign... (and it will not be restored // otherwise) if (ui->cqMacroButton->isChecked()) { qCDebug(mainwindow_js8) << "Unchecking cqMacroButton after selection" << selectedCall; ui->cqMacroButton->setChecked(false); } } } ui->extFreeTextMsgEdit->setPlaceholderText(placeholderText); #if SHOW_CALL_DETAIL_BROWSER auto html = generateCallDetail(selectedCall); ui->callDetailTextBrowser->setHtml(html); ui->callDetailTextBrowser->setVisible( !selectedCall.isEmpty() && (!hearing.isEmpty() || !heardby.isEmpty())); #endif m_prevSelectedCallsign = selectedCall; // immediately update the display updateButtonDisplay(); updateTextDisplay(); statusChanged(); } void MainWindow::clearCallsignSelected() { // remove the date cache m_callSelectedTime.remove(m_prevSelectedCallsign); // remove the callsign selection ui->tableWidgetCalls->clearSelection(); ui->tableWidgetRXAll->clearSelection(); } bool MainWindow::isRecentOffset(int submode, int offset) { if (abs(offset - freq()) <= JS8::Submode::rxThreshold(submode)) { return true; } return (m_rxRecentCache.contains(offset / 10 * 10) && m_rxRecentCache[offset / 10 * 10]->secsTo( DriftingDateTime::currentDateTimeUtc()) < 120); } void MainWindow::markOffsetRecent(int offset) { m_rxRecentCache.insert( offset / 10 * 10, new QDateTime(DriftingDateTime::currentDateTimeUtc()), 10); m_rxRecentCache.insert( offset / 10 * 10 + 10, new QDateTime(DriftingDateTime::currentDateTimeUtc()), 10); } bool MainWindow::isDirectedOffset(int offset, bool *pIsAllCall) { bool isDirected = (m_rxDirectedCache.contains(offset / 10 * 10) && m_rxDirectedCache[offset / 10 * 10]->date.secsTo( DriftingDateTime::currentDateTimeUtc()) < 120); if (isDirected && pIsAllCall) { *pIsAllCall = m_rxDirectedCache[offset / 10 * 10]->isAllcall; } return isDirected; } void MainWindow::markOffsetDirected(int offset, bool isAllCall) { CachedDirectedType *d1 = new CachedDirectedType{ isAllCall, DriftingDateTime::currentDateTimeUtc()}; CachedDirectedType *d2 = new CachedDirectedType{ isAllCall, DriftingDateTime::currentDateTimeUtc()}; m_rxDirectedCache.insert(offset / 10 * 10, d1, 10); m_rxDirectedCache.insert(offset / 10 * 10 + 10, d2, 10); } void MainWindow::clearOffsetDirected(int offset) { m_rxDirectedCache.remove(offset / 10 * 10); m_rxDirectedCache.remove(offset / 10 * 10 + 10); } bool MainWindow::isMyCallIncluded(const QString &text) { QString myCall = Radio::base_callsign(m_config.my_callsign()); if (myCall.isEmpty()) { return false; } if (!text.contains(myCall)) { return false; } auto calls = Varicode::parseCallsigns(text); return calls.contains(myCall) || calls.contains(m_config.my_callsign()); } bool MainWindow::isAllCallIncluded(const QString &text) { return text.contains("@ALLCALL") || text.contains("@HB"); } bool MainWindow::isGroupCallIncluded(const QString &text) { return m_config.my_groups().contains(text); } void MainWindow::processActivity(bool force) { if (!m_rxDirty && !force) { return; } // Recent Rx Activity processRxActivity(); // Process Idle Activity processIdleActivity(); // Grouped Compound Activity processCompoundActivity(); // Buffered Activity processBufferedActivity(); // Command Activity processCommandActivity(); // Process PSKReporter Spots processSpots(); m_rxDirty = false; } void MainWindow::resetTimeDeltaAverage() { m_driftMsMMA = 0; m_driftMsMMA_N = 0; } void MainWindow::setDrift(int n) { DriftingDateTime::setDrift(n); } void MainWindow::processIdleActivity() { auto const now = DriftingDateTime::currentDateTimeUtc(); // if we detect an idle offset, insert an ellipsis into the activity queue // and band activity for (auto [offset, activity] : m_bandActivity.asKeyValueRange()) { if (activity.isEmpty()) continue; auto const last = activity.last(); if ((last.bits & Varicode::JS8CallLast) == Varicode::JS8CallLast) continue; if (last.text == m_config.mfi()) continue; if (last.utcTimestamp.secsTo(now) < JS8::Submode::period(last.submode) * 1.50) continue; ActivityDetail d = {}; d.text = m_config.mfi(); d.utcTimestamp = last.utcTimestamp; d.snr = last.snr; d.tdrift = last.tdrift; d.dial = last.dial; d.offset = last.offset; d.submode = last.submode; if (hasExistingMessageBuffer(d.submode, offset, false, nullptr)) { m_messageBuffer[offset].msgs.append(d); } m_rxActivityQueue.append(d); activity.append(d); } } void MainWindow::processRxActivity() { if (m_rxActivityQueue.isEmpty()) { return; } int freqOffset = freq(); qCDebug(mainwindow_js8) << m_messageBuffer.count() << "message buffers open"; while (!m_rxActivityQueue.isEmpty()) { ActivityDetail d = m_rxActivityQueue.dequeue(); if (canSendNetworkMessage()) { sendNetworkMessage( "RX.ACTIVITY", d.text, {{"_ID", QVariant(-1)}, {"FREQ", QVariant(d.dial + d.offset)}, {"DIAL", QVariant(d.dial)}, {"OFFSET", QVariant(d.offset)}, {"SNR", QVariant(d.snr)}, {"SPEED", QVariant(d.submode)}, {"TDRIFT", QVariant(d.tdrift)}, {"UTC", QVariant(d.utcTimestamp.toMSecsSinceEpoch())}}); } // use the actual frequency and check its delta from our current // frequency meaning, if our current offset is 1502 and the d.freq is // 1492, the delta is <= 10; bool shouldDisplay = abs(d.offset - freqOffset) <= JS8::Submode::rxThreshold(d.submode); int prevOffset = d.offset; if (hasExistingMessageBuffer(d.submode, d.offset, false, &prevOffset) && ((m_messageBuffer[prevOffset].cmd.to == m_config.my_callsign()) || // (isAllCallIncluded(m_messageBuffer[prevOffset].cmd.to)) || // // uncomment this if we want to incrementally print allcalls (isGroupCallIncluded(m_messageBuffer[prevOffset].cmd.to)))) { d.isBuffered = true; shouldDisplay = true; if (!m_messageBuffer[prevOffset].compound.isEmpty()) { // qCDebug(mainwindow_js8) << "should display compound too // because at this point it hasn't been displayed" << // m_messageBuffer[prevOffset].compound.last().call; auto lastCompound = m_messageBuffer[prevOffset].compound.last(); // fixup compound call incremental text d.text = QString("%1: %2").arg(lastCompound.call).arg(d.text); d.utcTimestamp = qMin(d.utcTimestamp, lastCompound.utcTimestamp); } } else if (hasClosedExistingMessageBuffer(d.offset)) { // incremental typeahead should just be displayed... // TODO: should the buffer be reopened? shouldDisplay = true; } else if (d.isDirected && d.text.contains("<....>")) { // if this is a _partial_ directed message, skip until the complete // call comes through. continue; } else if (d.isDirected && (d.text.contains(": HB ") || d.text.contains(": @ALLCALL HB"))) { // TODO: HEARTBEAT // if this is a heartbeat, process elsewhere... continue; } // if this is the first data frame of a standard message, parse the // first word callsigns and spot them :) if ((d.bits & Varicode::JS8CallFirst) == Varicode::JS8CallFirst && !d.isDirected && !d.isCompound) { auto calls = Varicode::parseCallsigns(d.text); if (!calls.isEmpty()) { auto theirCall = calls.first(); if (d.text.startsWith(theirCall) && d.text.mid(theirCall.length(), 1) == ":") { CallDetail cd = {}; cd.call = theirCall; cd.dial = d.dial; cd.offset = d.offset; cd.snr = d.snr; cd.bits = d.bits; cd.tdrift = d.tdrift; cd.utcTimestamp = d.utcTimestamp; cd.submode = d.submode; logCallActivity(cd, true); } } } // TODO: incremental printing of directed messages // Display if: // 1) this is a directed message header "to" us and should be // buffered... 2) or, this is a buffered message frame for a buffer with // us as the recipient. if (!shouldDisplay) { continue; } bool isFirst = (d.bits & Varicode::JS8CallFirst) == Varicode::JS8CallFirst; bool isLast = (d.bits & Varicode::JS8CallLast) == Varicode::JS8CallLast; // if we're the last message, let's display our EOT character if (isLast) { d.text = QString("%1 %2 ") .arg(Varicode::rstrip(d.text)) .arg(m_config.eot()); } // log it to the display! displayTextForFreq(d.text, d.offset, d.utcTimestamp, false, isFirst, isLast); // If we've received a message to be displayed, we should no longer call // CQ. if (m_cq_loop->isActive()) { qCDebug(mainwindow_js8) << "Canceling calling CQ loop to priorize incoming messages."; m_cq_loop->onLoopCancel(); } if (isLast) { clearOffsetDirected(d.offset); } if (isLast && !d.isBuffered) { // buffered commands need the rxFrameBlockNumbers cache so it can // fixup its display all other "last" data frames can clear the // rxFrameBlockNumbers cache so the next message will be on a new // line. m_rxFrameBlockNumbers.remove(d.offset); } } #if 0 // TODO: this works but should also print in the rx window. foreach(auto offset, m_bandActivity.keys()){ if(seen.contains(offset)){ continue; } if(m_bandActivity[offset].isEmpty()){ continue; } auto last = m_bandActivity[offset].last(); if((last.bits & Varicode::JS8CallLast) == Varicode::JS8CallLast){ continue; } auto now = DriftingDateTime::currentDateTimeUtc(); if(last.utcTimestamp.secsTo(now) < m_TRperiod){ continue; } ActivityDetail d = {}; d.text = " . . . "; d.utcTimestamp = now; d.snr = -99; m_bandActivity[offset].append(d); } #endif } void MainWindow::processCompoundActivity() { if (m_messageBuffer.isEmpty()) { return; } // group compound callsign and directed commands together. foreach (auto freq, m_messageBuffer.keys()) { auto &buffer = m_messageBuffer[freq]; qCDebug(mainwindow_js8) << "-> grouping buffer for freq" << freq; if (buffer.compound.isEmpty()) { qCDebug(mainwindow_js8) << "-> buffer.compound is empty...skip"; continue; } // if we don't have an initialized command, skip... int bits = buffer.cmd.bits; bool validBits = (bits == Varicode::JS8Call || ((bits & Varicode::JS8CallFirst) == Varicode::JS8CallFirst) || ((bits & Varicode::JS8CallLast) == Varicode::JS8CallLast) || ((bits & Varicode::JS8CallData) == Varicode::JS8CallData)); if (!validBits) { qCDebug(mainwindow_js8) << "-> buffer.cmd bits is invalid...skip"; continue; } // if we need two compound calls, but less than two have arrived...skip if (buffer.cmd.from == "<....>" && buffer.cmd.to == "<....>" && buffer.compound.length() < 2) { qCDebug(mainwindow_js8) << "-> buffer needs two compound, but has less...skip"; continue; } // if we need one compound call, but non have arrived...skip if ((buffer.cmd.from == "<....>" || buffer.cmd.to == "<....>") && buffer.compound.length() < 1) { qCDebug(mainwindow_js8) << "-> buffer needs one compound, but has less...skip"; continue; } if (buffer.cmd.from == "<....>") { auto d = buffer.compound.dequeue(); buffer.cmd.from = d.call; buffer.cmd.grid = d.grid; buffer.cmd.isCompound = true; buffer.cmd.utcTimestamp = qMin(buffer.cmd.utcTimestamp, d.utcTimestamp); if ((d.bits & Varicode::JS8CallLast) == Varicode::JS8CallLast) { buffer.cmd.bits = d.bits; } } if (buffer.cmd.to == "<....>") { auto d = buffer.compound.dequeue(); buffer.cmd.to = d.call; buffer.cmd.isCompound = true; buffer.cmd.utcTimestamp = qMin(buffer.cmd.utcTimestamp, d.utcTimestamp); if ((d.bits & Varicode::JS8CallLast) == Varicode::JS8CallLast) { buffer.cmd.bits = d.bits; } } if ((buffer.cmd.bits & Varicode::JS8CallLast) != Varicode::JS8CallLast) { qCDebug(mainwindow_js8) << "-> still not last message...skip"; continue; } // fixup the datetime with the "minimum" dt seen // this will allow us to delete the activity lines // when the compound buffered command comes in. auto dt = buffer.cmd.utcTimestamp; foreach (auto c, buffer.compound) { dt = qMin(dt, c.utcTimestamp); } foreach (auto m, buffer.msgs) { dt = qMin(dt, m.utcTimestamp); } buffer.cmd.utcTimestamp = dt; qCDebug(mainwindow_js8) << "buffered compound command ready" << buffer.cmd.from << buffer.cmd.to << buffer.cmd.cmd; m_rxCommandQueue.append(buffer.cmd); m_messageBuffer.remove(freq); // TODO: only if to me? m_lastClosedMessageBufferOffset = freq; } } void MainWindow::processBufferedActivity() { if (m_messageBuffer.isEmpty()) return; foreach (auto freq, m_messageBuffer.keys()) { auto buffer = m_messageBuffer[freq]; // check to make sure we empty old buffers by getting the latest // timestamp and checking to see if it's older than one minute. auto dt = DriftingDateTime::currentDateTimeUtc().addDays(-1); if (buffer.cmd.utcTimestamp.isValid()) { dt = qMax(dt, buffer.cmd.utcTimestamp); } if (!buffer.compound.isEmpty()) { dt = qMax(dt, buffer.compound.last().utcTimestamp); } if (!buffer.msgs.isEmpty()) { dt = qMax(dt, buffer.msgs.last().utcTimestamp); } // if the buffer has messages older than 1 minute, and we still haven't // closed it, let's mark it as the last frame if (dt.secsTo(DriftingDateTime::currentDateTimeUtc()) > 60 && !buffer.msgs.isEmpty()) { buffer.msgs.last().bits |= Varicode::JS8CallLast; } // but, if the buffer is older than 1.5 minutes, and we still haven't // closed it, just remove it and skip if (dt.secsTo(DriftingDateTime::currentDateTimeUtc()) > 90) { m_messageBuffer.remove(freq); continue; } // if the buffer has no messages, skip if (buffer.msgs.isEmpty()) { continue; } // if the buffered message hasn't seen the last message, skip if ((buffer.msgs.last().bits & Varicode::JS8CallLast) != Varicode::JS8CallLast) { continue; } QString message; foreach (auto part, buffer.msgs) { message.append(part.text); } message = Varicode::rstrip(message); QString checksum; bool valid = false; if (Varicode::isCommandBuffered(buffer.cmd.cmd)) { int checksumSize = Varicode::isCommandChecksumed(buffer.cmd.cmd); if (checksumSize == 32) { message = Varicode::lstrip(message); checksum = message.right(6); message = message.left(message.length() - 7); valid = Varicode::checksum32Valid(checksum, message); } else if (checksumSize == 16) { message = Varicode::lstrip(message); checksum = message.right(3); message = message.left(message.length() - 4); valid = Varicode::checksum16Valid(checksum, message); } else if (checksumSize == 0) { valid = true; } } else { valid = true; } if (valid) { buffer.cmd.bits |= Varicode::JS8CallLast; buffer.cmd.text = message; buffer.cmd.isBuffered = true; m_rxCommandQueue.append(buffer.cmd); } else { qCDebug(mainwindow_js8) << "Buffered message failed checksum...discarding"; qCDebug(mainwindow_js8) << "Checksum:" << checksum; qCDebug(mainwindow_js8) << "Message:" << message; } // regardless of valid or not, remove the "complete" buffered message // from the buffer cache m_messageBuffer.remove(freq); m_lastClosedMessageBufferOffset = freq; } } void MainWindow::processCommandActivity() { #if 0 if (!m_txFrameQueue.isEmpty()) { return; } #endif if (m_rxCommandQueue.isEmpty()) { return; } #if 0 bool processed = false; int f = currentFreq(); #endif auto now = DriftingDateTime::currentDateTimeUtc(); while (!m_rxCommandQueue.isEmpty()) { auto d = m_rxCommandQueue.dequeue(); auto selectedCallsign = callsignSelected(); bool isAllCall = isAllCallIncluded(d.to); bool isGroupCall = isGroupCallIncluded(d.to); qCDebug(mainwindow_js8) << "try processing command" << d.from << d.to << d.cmd << d.dial << d.offset << d.grid << d.extra << isAllCall << isGroupCall; // if we need a compound callsign but never got one...skip if (d.from == "<....>" || d.to == "<....>") { continue; } // we're only processing a subset of queries at this point if (!Varicode::isCommandAllowed(d.cmd)) { continue; } // is this to me? bool toMe = d.to == m_config.my_callsign().trimmed() || d.to == Radio::base_callsign(m_config.my_callsign()).trimmed(); // log call activity... CallDetail cd = {}; cd.call = d.from; cd.grid = d.grid; cd.snr = d.snr; cd.dial = d.dial; cd.offset = d.offset; cd.bits = d.bits; cd.ackTimestamp = d.text.contains(": ACK") || toMe ? d.utcTimestamp : QDateTime{}; cd.utcTimestamp = d.utcTimestamp; cd.tdrift = d.tdrift; cd.submode = d.submode; logCallActivity(cd, true); logHeardGraph(d.from, d.to); // Placeholder for callbacks to be passed to confirmThenEnqueueMessage // and enqueueMessage Callback callback = nullptr; // PROCESS BUFFERED HEARING FOR EVERYONE if (d.cmd == " HEARING") { // 1. parse callsigns // 2. log it to the heard graph auto calls = Varicode::parseCallsigns(d.text); foreach (auto call, calls) { logHeardGraph(d.from, call); } } // PROCESS BUFFERED GRID FOR EVERYONE if (d.cmd == " GRID") { // 1. parse grids // 2. log it to our call activity auto grids = Varicode::parseGrids(d.text); foreach (auto grid, grids) { CallDetail cd = {}; cd.bits = d.bits; cd.call = d.from; cd.dial = d.dial; cd.offset = d.offset; cd.grid = grid; cd.snr = d.snr; cd.utcTimestamp = d.utcTimestamp; cd.tdrift = d.tdrift; cd.submode = d.submode; // PROCESS GRID SPOTS TO APRSIS FOR EVERYONE if (d.to == "@APRSIS") { spotAprsGrid(cd.dial, cd.offset, cd.snr, cd.call, cd.grid); } logCallActivity(cd, true); } } // PROCESS @JS8NET, @APRSIS, AND OTHER GROUP SPOTS FOR EVERYONE if (d.to.startsWith("@")) { spotCmd(d); } // PROCESS @APRSIS CMD SPOTS FOR EVERYONE if (d.to == "@APRSIS") { spotAprsCmd(d); } // PREPARE CMD TEXT STRING QStringList textList = { QString("%1: %2%3").arg(d.from).arg(d.to).arg(d.cmd)}; if (!d.extra.isEmpty()) { textList.append(d.extra); } if (!d.text.isEmpty()) { textList.append(d.text); } QString text = textList.join(" "); bool isLast = (d.bits & Varicode::JS8CallLast) == Varicode::JS8CallLast; if (isLast) { // append the eot character to the text text = QString("%1 %2 ") .arg(Varicode::rstrip(text)) .arg(m_config.eot()); } // log the text to directed txt log writeMsgTxt(text, d.snr, d.offset); // write all directed messages to api if (canSendNetworkMessage()) { sendNetworkMessage( "RX.DIRECTED", text, {{"_ID", QVariant(-1)}, {"FROM", QVariant(d.from)}, {"TO", QVariant(d.to)}, {"CMD", QVariant(d.cmd)}, {"GRID", QVariant(d.grid)}, {"EXTRA", QVariant(d.extra)}, {"TEXT", QVariant(text)}, {"FREQ", QVariant(d.dial + d.offset)}, {"DIAL", QVariant(d.dial)}, {"OFFSET", QVariant(d.offset)}, {"SNR", QVariant(d.snr)}, {"SPEED", QVariant(d.submode)}, {"TDRIFT", QVariant(d.tdrift)}, {"UTC", QVariant(d.utcTimestamp.toMSecsSinceEpoch())}}); } // we're only responding to allcalls if we are participating in the // allcall group but, don't avoid for heartbeats...those are technically // allcalls but are processed differently if (isAllCall && m_config.avoid_allcall() && d.cmd != " CQ" && d.cmd != " HB" && d.cmd != " HEARTBEAT") { continue; } // we're only responding to allcall, groupcalls, and our callsign at // this point, so we'll end after logging the callsigns we've heard if (!isAllCall && !toMe && !isGroupCall) { continue; } ActivityDetail ad = {}; ad.isLowConfidence = false; ad.isDirected = true; ad.bits = d.bits; ad.dial = d.dial; ad.offset = d.offset; ad.snr = d.snr; ad.text = text; ad.utcTimestamp = d.utcTimestamp; // we'd be double printing here if were on frequency, so let's be // "smart" about this... bool shouldDisplay = true; // don't display ping allcalls if (isAllCall && (d.cmd != " " || ad.text.contains("@HB HEARTBEAT"))) { shouldDisplay = false; } if (shouldDisplay) { auto c = ui->textEditRX->textCursor(); c.movePosition(QTextCursor::End); ui->textEditRX->setTextCursor(c); // ACKs and SNRs are the most likely source of items to be // overwritten (multiple responses at once)... so don't overwrite // those (i.e., print each on a new line) bool shouldOverwrite = (!d.cmd.contains(" ACK") && !d.cmd.contains(" SNR")); /* && isRecentOffset(d.freq);*/ if (shouldOverwrite && ui->textEditRX->find(d.utcTimestamp.time().toString(), QTextDocument::FindBackward)) { // ... maybe we could delete the last line that had this message // on this frequency... c = ui->textEditRX->textCursor(); c.movePosition(QTextCursor::StartOfBlock); c.movePosition(QTextCursor::EndOfBlock, QTextCursor::KeepAnchor); qCDebug(mainwindow_js8) << "should display directed message, " "erasing last rx activity line..." << c.selectedText().toUpper(); c.removeSelectedText(); c.deletePreviousChar(); c.deletePreviousChar(); /* c.deleteChar(); c.deleteChar(); */ } // log it to the display! displayTextForFreq(ad.text, ad.offset, ad.utcTimestamp, false, true, false); /* // and send it to the network in case we want to interact with it from an external app... if(canSendNetworkMessage()){ sendNetworkMessage("RX.DIRECTED.ME", ad.text, { {"_ID", QVariant(-1)}, {"FROM", QVariant(d.from)}, {"TO", QVariant(d.to)}, {"CMD", QVariant(d.cmd)}, {"GRID", QVariant(d.grid)}, {"EXTRA", QVariant(d.extra)}, {"TEXT", QVariant(d.text)}, {"FREQ", QVariant(ad.dial+ad.offset)}, {"DIAL", QVariant(ad.dial)}, {"OFFSET", QVariant(ad.offset)}, {"SNR", QVariant(ad.snr)}, {"SPEED", QVariant(ad.submode)}, {"TDRIFT", QVariant(ad.tdrift)}, {"UTC", QVariant(ad.utcTimestamp.toMSecsSinceEpoch())} }); } */ if (!isAllCall) { // If we've received a message to be displayed, // we should no longer call CQ if (m_cq_loop->isActive()) { qCDebug(mainwindow_js8) << "Canceling CQ loop to prioritize incoming messages, " "case II"; m_cq_loop->onLoopCancel(); } // notification for directed message tryNotify("directed"); } } // we're only responding to callsigns in our whitelist if we have one // defined... make sure the whitelist is empty (no restrictions) or the // from callsign or its base callsign is on it auto whitelist = m_config.auto_whitelist(); if (!whitelist.isEmpty() && !(whitelist.contains(d.from) || whitelist.contains(Radio::base_callsign(d.from)))) { qCDebug(mainwindow_js8) << "skipping command for whitelist" << d.from; continue; } // we'll never reply to a blacklisted callsign or base callsign auto blacklist = m_config.auto_blacklist(); if (!blacklist.isEmpty() && (blacklist.contains(d.from) || blacklist.contains(Radio::base_callsign(d.from)))) { qCDebug(mainwindow_js8) << "skipping command for blacklist" << d.from; continue; } // if this is an allcall, check to make sure we haven't replied to their // allcall recently (in the past ten minutes) that way we never get // spammed by allcalls at too high of a frequency if (isAllCall && m_txAllcallCommandCache.contains(d.from) && m_txAllcallCommandCache[d.from]->secsTo(now) / 60 < 15) { qCDebug(mainwindow_js8) << "skipping command for allcall timeout" << d.from; continue; } // don't actually process any automatic message replies while in idle if (m_tx_watchdog) { qCDebug(mainwindow_js8) << "skipping command for idle timeout" << d.from; continue; } // HACK: if this is an autoreply cmd and relay path is populated and cmd // is not MSG or MSG TO:, then swap out the relay path if (Varicode::isCommandAutoreply(d.cmd) && !d.relayPath.isEmpty() && !d.cmd.startsWith(" MSG") && !d.cmd.startsWith(" QUERY")) { d.from = d.relayPath; } // construct a reply, if needed QString reply; int priority = PriorityNormal; int freq = -1; // QUERIED SNR if (d.cmd == " SNR?" && !isAllCall) { reply = QString("%1 SNR %2") .arg(d.from) .arg(Varicode::formatSNR(d.snr)); } // QUERIED INFO else if (d.cmd == " INFO?" && !isAllCall) { QString info = m_config.my_info(); if (info.isEmpty()) { continue; } reply = QString("%1 INFO %2") .arg(d.from) .arg(replaceMacros(info, buildMacroValues(), true)); } // QUERIED ACTIVE else if (d.cmd == " STATUS?" && !isAllCall) { QString status = m_config.my_status(); if (status.isEmpty()) { continue; } reply = QString("%1 STATUS %2") .arg(d.from) .arg(replaceMacros(status, buildMacroValues(), true)); } // QUERIED GRID else if (d.cmd == " GRID?" && !isAllCall) { QString grid = m_config.my_grid(); if (grid.isEmpty()) { continue; } reply = QString("%1 GRID %2").arg(d.from).arg(grid); } // QUERIED STATIONS HEARD else if (d.cmd == " HEARING?" && !isAllCall) { auto calls = m_callActivity.keys(); std::stable_sort(calls.begin(), calls.end(), [this](QString const &lhs, QString const &rhs) { return m_callActivity[rhs].utcTimestamp < m_callActivity[lhs].utcTimestamp; }); auto const callsignAging = m_config.callsign_aging(); auto const maxStations = 4; auto i = 0; QStringList lines; for (auto const &call : calls) { if (i >= maxStations) break; if (call == d.from) continue; auto const &cd = m_callActivity[call]; if (callsignAging && cd.utcTimestamp.secsTo(now) / 60 >= callsignAging) { continue; } lines.append(cd.call); i++; } lines.prepend(QString("%1 HEARING").arg(d.from)); reply = lines.join(' '); } // PROCESS RELAY else if (d.cmd == ">" && !isAllCall && !m_config.relay_off()) { // 1. see if there are any more hops to process // 2. if so, forward // 3. otherwise, display alert & reply dialog QString callToPattern = { R"(^(?\b(?[A-Z0-9]{1,4}\/)?(?([0-9A-Z])?([0-9A-Z])([0-9])([A-Z])?([A-Z])?([A-Z])?)(?\/[A-Z0-9]{1,4})?(?[> ]))\b)"}; QRegularExpression re(callToPattern); auto text = d.text; auto match = re.match(text); // if the text starts with a callsign, and relay is not disabled, // and this is not a group callsign, then relay. if (match.hasMatch() && !isGroupCall) { // replace freetext with relayed free text if (match.captured("type") != ">") { text = text.replace(match.capturedStart("type"), match.capturedLength("type"), ">"); } reply = QString("%1 *DE* %2").arg(text).arg(d.from); // otherwise, as long as we're not an ACK...alert the user and // either send an ACK or Message } else if (!d.text.startsWith("ACK")) { // parse out the callsign path auto calls = parseRelayPathCallsigns(d.from, d.text); // put these third party calls in the heard list foreach (auto call, calls) { CallDetail cd = {}; cd.call = call; cd.snr = -64; cd.dial = d.dial; cd.offset = d.offset; cd.through = d.from; cd.utcTimestamp = DriftingDateTime::currentDateTimeUtc(); cd.tdrift = d.tdrift; cd.submode = d.submode; logCallActivity(cd, false); } d.relayPath = calls.join('>'); reply = QString("%1 ACK").arg(d.relayPath); // check to see if the relay text contains a command that should // be replied to instead of an ack. QStringList relayedCmds = d.text.split(" "); if (!relayedCmds.isEmpty()) { auto first = relayedCmds.first(); auto valid = Varicode::isCommandAllowed(first); if (!valid) { first = " " + first; valid = Varicode::isCommandAllowed(first); if (valid) { relayedCmds.removeFirst(); } } // HACK: "MSG TO:" should be supported but contains a space // :( if (!relayedCmds.isEmpty()) { if (first == " MSG") { auto second = relayedCmds.first(); if (second == "TO:") { first = " MSG TO:"; relayedCmds.removeFirst(); } else if (second.startsWith("TO:")) { first = " MSG TO:"; relayedCmds.replace(0, second.mid(3)); } } else if (first == " QUERY") { auto second = relayedCmds.first(); if (second == "MSGS" || second == "MSGS?") { first = " QUERY MSGS"; relayedCmds.removeFirst(); } else if (second == "CALL") { first = " QUERY CALL"; relayedCmds.removeFirst(); } } } if (Varicode::isCommandAllowed(first) && Varicode::isCommandAutoreply(first)) { CommandDetail rd = {}; rd.bits = d.bits; rd.cmd = first; rd.dial = d.dial; rd.offset = d.offset; rd.from = d.from; // note, MSG and QUERY commands are not set // with from as the relay path. rd.relayPath = d.relayPath; rd.text = relayedCmds.join(" "); // d.text; rd.to = d.to; rd.utcTimestamp = d.utcTimestamp; m_rxCommandQueue.insert(0, rd); continue; } } #if STORE_RELAY_MSGS_TO_INBOX // if we make it here, this is a message addCommandToMyInbox(d); #endif } } // PROCESS MESSAGE STORAGE else if (d.cmd == " MSG TO:" && !isAllCall && !m_config.relay_off()) { // store message QStringList segs = d.text.split(" "); if (segs.isEmpty()) { continue; } auto to = segs.first(); segs.removeFirst(); auto text = segs.join(" ").trimmed(); auto calls = parseRelayPathCallsigns(d.from, text); d.relayPath = calls.join(">"); CommandDetail cd = {}; cd.bits = d.bits; cd.cmd = d.cmd; cd.extra = d.extra; cd.dial = d.dial; cd.offset = d.offset; cd.from = d.from; cd.grid = d.grid; cd.relayPath = d.relayPath; cd.snr = d.snr; cd.tdrift = d.tdrift; cd.text = text; cd.to = Radio::base_callsign(to); cd.utcTimestamp = d.utcTimestamp; cd.submode = d.submode; qCDebug(mainwindow_js8) << "storing message to" << to << ":" << text; addCommandToStorage("STORE", cd); // we haven't replaced the from with the relay path, so we have to // use it for the ack if there is one reply = QString("%1 ACK").arg(calls.length() > 1 ? d.relayPath : d.from); } // PROCESS AGN else if (d.cmd == " AGN?" && !isAllCall && !isGroupCall && !m_lastTxMessage.isEmpty()) { reply = Varicode::rstrip(m_lastTxMessage); } // PROCESS ACTIVE HEARTBEAT // if we have hb mode enabled and auto reply enabled and auto ack // enabled and no callsign is selected update: if we're in HB // mode, doesn't matter if a callsign is selected. else if ((d.cmd == " HB" || d.cmd == " HEARTBEAT") && canCurrentModeSendHeartbeat() && ui->actionModeJS8HB->isChecked() && ui->actionModeAutoreply->isChecked() && ui->actionHeartbeatAcknowledgements->isChecked()) { // do not process HB activity if buffer is not empty, this prevents // broken incoming MSG's if (!m_messageBuffer.isEmpty()) { qCDebug(mainwindow_js8) << "hb paused for messageBuffer"; continue; } // check to make sure we aren't pausing HB transmissions (ACKs) // while a callsign is selected if (m_config.heartbeat_qso_pause() && !selectedCallsign.isEmpty()) { qCDebug(mainwindow_js8) << "hb paused during qso"; continue; } // check to make sure this callsign isn't blacklisted if (m_config.hb_blacklist().contains(d.from) || m_config.hb_blacklist().contains( Radio::base_callsign(d.from))) { qCDebug(mainwindow_js8) << "hb blacklist blocking" << d.from; continue; } // check to see if we have a message for a station who is // heartbeating QString extra; auto mid = getNextMessageIdForCallsign(d.from); if (mid != -1) { extra = QString("MSG ID %1").arg(mid); } // group messaging - if isGroupCall, check to see if there's a // message id for the group and return it if there's not an // individual message // TODO: include group name in response to differentiate from direct // messages? else if (isGroupCall) { mid = getNextGroupMessageIdForCallsign(d.to, d.from); if (mid != -1) { extra = QString("MSG ID %1").arg(mid); } } // TODO: require confirmation? sendHeartbeatAck(d.from, d.snr, extra); if (isAllCall) { // since all pings are technically @ALLCALL, let's bump the // allcall cache here... m_txAllcallCommandCache.insert(d.from, new QDateTime(now), 5); } continue; } // PROCESS HEARTBEAT SNR else if (d.cmd == " HEARTBEAT SNR") { qCDebug(mainwindow_js8) << "skipping incoming hb snr" << d.text; continue; } // PROCESS CQ else if (d.cmd == " CQ") { qCDebug(mainwindow_js8) << "skipping incoming cq" << d.text; continue; } // PROCESS MSG else if (d.cmd == " MSG" && !isAllCall) { auto text = d.text; qCDebug(mainwindow_js8) << "adding message to inbox" << text; auto calls = parseRelayPathCallsigns(d.from, text); d.cmd = " MSG "; d.relayPath = calls.join(">"); d.text = text; addCommandToMyInbox(d); // notification tryNotify("inbox"); // we haven't replaced the from with the relay path, so we have to // use it for the ack if there is one reply = QString("%1 ACK").arg(calls.length() > 1 ? d.relayPath : d.from); #define SHOW_ALERT_FOR_MSG 1 #if SHOW_ALERT_FOR_MSG SelfDestructMessageBox *m = new SelfDestructMessageBox( 300, "New Message Received", QString("A new message was received at %1 UTC from %2") .arg(d.utcTimestamp.time().toString()) .arg(d.from), QMessageBox::Information, QMessageBox::Ok, QMessageBox::Ok, false, this); m->show(); #endif } // PROCESS ACKS else if (d.cmd == " ACK" && !isAllCall) { qCDebug(mainwindow_js8) << "skipping incoming ack" << d.text; // notification for ack tryNotify("ack"); // make sure this is explicit continue; } // PROCESS BUFFERED CMD else if (d.cmd == " CMD" && !isAllCall) { qCDebug(mainwindow_js8) << "skipping incoming command" << d.text; // make sure this is explicit continue; } // PROCESS BUFFERED QUERY else if (d.cmd == " QUERY" && !isAllCall) { auto who = d.from; // keep in mind, this is the sender, not the // original requestor if relayed auto replyPath = d.from; if (d.relayPath.contains(">")) { auto path = d.relayPath.split(">"); who = path.last(); replyPath = d.relayPath; } QStringList segs = d.text.split(" "); if (segs.isEmpty()) { continue; } auto cmd = segs.first(); segs.removeFirst(); if (cmd == "MSG" && !segs.isEmpty()) { auto inbox = Inbox(inboxPath()); if (!inbox.open()) { continue; } bool ok = false; int mid = QString(segs.first()).toInt(&ok); if (!ok) { continue; } auto msg = inbox.value(mid); auto params = msg.params(); if (params.isEmpty()) { continue; } auto from = params.value("FROM").toString().trimmed(); auto to = params.value("TO").toString().trimmed(); // group messaging - allow any message to a @GROUP to be // retrieved by anybody bool isGroupMsg = to.startsWith("@"); if (!isGroupMsg && to != who && to != Radio::base_callsign(who)) { continue; } auto text = params.value("TEXT").toString().trimmed(); if (text.isEmpty()) { continue; } /* * mark as delivered (so subsequent HBs and QUERY MSGS don't * receive this message) * * Do this in callbacks so that messages are only marked read * after any potential confirmations have been made by the user, * and the message has been processed in the transaction queue. */ if (!isGroupMsg) { callback = [this, mid, msg]() { this->markMsgDelivered(mid, msg); }; } else { callback = [this, mid, who]() { this->markGroupMsgDeliveredForCallsign(mid, who); }; } auto lookaheadMid = getLookaheadMessageIdForCallsign(who, mid); if (lookaheadMid == -1 && isGroupMsg) { lookaheadMid = getLookaheadGroupMessageIdForCallsign(d.to, who, mid); } // and reply if (lookaheadMid != -1) { reply = QString("%1 MSG %2 FROM %3 NEXT MSG ID %4"); reply = reply.arg(replyPath); reply = reply.arg(text); reply = reply.arg(from); reply = reply.arg(lookaheadMid); } else { reply = QString("%1 MSG %2 FROM %3"); reply = reply.arg(replyPath); reply = reply.arg(text); reply = reply.arg(from); } } } // PROCESS BUFFERED QUERY MSGS else if (d.cmd == " QUERY MSGS" && ui->actionModeAutoreply->isChecked()) { auto who = d.from; // keep in mind, this is the sender, not the // original requestor if relayed auto replyPath = d.from; if (d.relayPath.contains(">")) { auto path = d.relayPath.split(">"); who = path.last(); replyPath = d.relayPath; } // if this is an allcall or a directed call, check to see if we have // a stored message for user. we reply yes if the user would be able // to retreive a stored message auto mid = getNextMessageIdForCallsign(who); if (mid != -1) { reply = QString("%1 YES MSG ID %2").arg(replyPath).arg(mid); } // Group messaging - if isGroupCall, check to see if there's a // message id for the group and return it if there's not an // individual message // TODO: include group name in response to differentiate from direct // messages? else if (isGroupCall) { mid = getNextGroupMessageIdForCallsign(d.to, d.from); if (mid != -1) { reply = QString("%1 YES MSG ID %2").arg(replyPath).arg(mid); } } // if this is not an allcall and we have no messages, reply no. if (!isAllCall && reply.isEmpty()) { reply = QString("%1 NO").arg(replyPath); } } // PROCESS BUFFERED QUERY CALL else if (d.cmd == " QUERY CALL" && ui->actionModeAutoreply->isChecked()) { auto replyPath = d.from; if (d.relayPath.contains(">")) { replyPath = d.relayPath; } auto who = d.text; if (who.isEmpty()) { continue; } auto callsigns = Varicode::parseCallsigns(who); if (callsigns.isEmpty()) { continue; } QStringList replies; int callsignAging = m_config.callsign_aging(); auto baseCall = callsigns.first(); foreach (auto cd, m_callActivity.values()) { if (callsignAging && cd.utcTimestamp.secsTo(now) / 60 >= callsignAging) { continue; } if (baseCall == cd.call || baseCall == Radio::base_callsign(cd.call)) { auto r = QString("%1 (%2)") .arg(Varicode::formatSNR(cd.snr)) .arg(since(cd.utcTimestamp)) .trimmed(); replies.append(r); break; } } if (!replies.isEmpty()) { replies.prepend(QString("%1 YES").arg(replyPath)); } reply = replies.join(" "); if (!reply.isEmpty()) { if (isAllCall) { m_txAllcallCommandCache.insert(d.from, new QDateTime(now), 25); } } } #if 0 // PROCESS ALERT else if (d.cmd == "!" && !isAllCall) { // create alert dialog processAlertReplyForCommand(d, d.from, " "); // make sure this is explicit continue; } #endif // well, if there's no reply, don't do anything... if (reply.isEmpty()) { continue; } // do not queue @ALLCALL replies if auto-reply is not checked if (!ui->actionModeAutoreply->isChecked() && isAllCall) { continue; } #if 0 // TODO: jsherer - HB issue here // do not queue a reply if it's a HB and HB is not active // if((!ui->hbMacroButton->isChecked() || m_hbInterval <= 0) && d.cmd.contains("HB")){ // continue; // } #endif // do not queue for reply if there's text in the window if (!ui->extFreeTextMsgEdit->toPlainText().isEmpty()) { continue; } // do not queue for reply if there's a buffer open to us int bufferOffset = 0; if (hasExistingMessageBufferToMe(&bufferOffset)) { qCDebug(mainwindow_js8) << "skipping reply due to open buffer" << bufferOffset << m_messageBuffer.count(); continue; } // add @ALLCALLs to the @ALLCALL cache if (isAllCall) { m_txAllcallCommandCache.insert(d.from, new QDateTime(now), 25); } // queue the reply here to be sent when a free interval is available on // the frequency that was sent unless, this is an allcall, to which we // should be responding on a clear frequency offset we always want to // make sure that the directed cache has been updated at this point so // we have the most information available to make a frequency selection. if (m_config.autoreply_confirmation()) { confirmThenEnqueueMessage(90, priority, reply, freq, callback); } else { enqueueMessage(priority, reply, freq, callback); } } } QString MainWindow::inboxPath() { return QDir::toNativeSeparators( m_config.writeable_data_dir().absoluteFilePath("inbox.db3")); } void MainWindow::refreshInboxCounts() { auto inbox = Inbox(inboxPath()); if (inbox.open()) { // reset inbox counts m_rxInboxCountCache.clear(); // compute new counts from db auto v = inbox.values("UNREAD", "$", "%", 0, 10000); foreach (auto pair, v) { auto params = pair.second.params(); auto to = params.value("TO").toString(); if (to.isEmpty() || (to != m_config.my_callsign() && to != Radio::base_callsign(m_config.my_callsign()))) { continue; } auto from = params.value("FROM").toString(); if (from.isEmpty()) { continue; } m_rxInboxCountCache[from] = m_rxInboxCountCache.value(from, 0) + 1; if (!m_callActivity.contains(from)) { auto const utc = params.value("UTC").toString(); auto const snr = params.value("SNR").toInt(); auto const dial = params.value("DIAL").toInt(); auto const offset = params.value("OFFSET").toInt(); auto const tdrift = params.value("TDRIFT").toInt(); auto const submode = params.value("SUBMODE").toInt(); CallDetail cd; cd.call = from; cd.snr = snr; cd.dial = dial; cd.offset = offset; cd.tdrift = tdrift; cd.utcTimestamp = QDateTime::fromString(utc, "yyyy-MM-dd hh:mm:ss"); cd.utcTimestamp.setTimeZone(QTimeZone::utc()); cd.ackTimestamp = cd.utcTimestamp; cd.submode = submode; logCallActivity(cd, false); } } // Now handle group message counts QMap groupMessageCounts = inbox.getGroupMessageCounts(); foreach (auto key, groupMessageCounts.keys()) { m_rxInboxCountCache[key] = groupMessageCounts[key]; } } } bool MainWindow::hasMessageHistory(QString call) { auto inbox = Inbox(inboxPath()); if (!inbox.open()) { return false; } int store = inbox.count("STORE", "$.params.TO", call); int unread = inbox.count("UNREAD", "$.params.FROM", call); int read = inbox.count("READ", "$.params.FROM", call); return (store + unread + read) > 0; } int MainWindow::addCommandToMyInbox(CommandDetail d) { // local cache for inbox count m_rxInboxCountCache[d.from] = m_rxInboxCountCache.value(d.from, 0) + 1; // add it to my unread inbox return addCommandToStorage("UNREAD", d); } int MainWindow::addCommandToStorage(QString type, CommandDetail d) { // inbox: auto inbox = Inbox(inboxPath()); if (!inbox.open()) { return -1; } QVariantMap v = { {"UTC", QVariant(d.utcTimestamp.toString("yyyy-MM-dd hh:mm:ss"))}, {"TO", QVariant(d.to)}, {"FROM", QVariant(d.from)}, {"PATH", QVariant(d.relayPath)}, {"TDRIFT", QVariant(d.tdrift)}, {"FREQ", QVariant(d.dial + d.offset)}, {"DIAL", QVariant(d.dial)}, {"OFFSET", QVariant(d.offset)}, {"CMD", QVariant(d.cmd)}, {"SNR", QVariant(d.snr)}, {"SUBMODE", QVariant(d.submode)}, }; if (!d.grid.isEmpty()) { v["GRID"] = QVariant(d.grid); } if (!d.extra.isEmpty()) { v["EXTRA"] = QVariant(d.extra); } if (!d.text.isEmpty()) { v["TEXT"] = QVariant(d.text); } auto m = Message(type, "", v); return inbox.append(m); } int MainWindow::getNextMessageIdForCallsign(QString callsign) { auto inbox = Inbox(inboxPath()); if (!inbox.open()) { return -1; } auto v1 = inbox.values("STORE", "$.params.TO", callsign, 0, 10); foreach (auto pair, v1) { auto params = pair.second.params(); auto text = params.value("TEXT").toString().trimmed(); if (!text.isEmpty()) { return pair.first; } } auto v2 = inbox.values("STORE", "$.params.TO", Radio::base_callsign(callsign), 0, 10); foreach (auto pair, v2) { auto params = pair.second.params(); auto text = params.value("TEXT").toString().trimmed(); if (!text.isEmpty()) { return pair.first; } } return -1; } int MainWindow::getLookaheadMessageIdForCallsign(QString callsign, int msgId) { auto inbox = Inbox(inboxPath()); if (!inbox.open()) { return -1; } int mid = inbox.getLookaheadMessageIdForCallsign(callsign, msgId); if (mid == -1) { mid = inbox.getLookaheadMessageIdForCallsign( Radio::base_callsign(callsign), msgId); } if (mid != -1) { return mid; } return -1; } // Facade for Inbox::getNextGroupMessageIdForCallsign int MainWindow::getNextGroupMessageIdForCallsign(QString group_name, QString callsign) { Inbox inbox(inboxPath()); if (!inbox.open()) { return -1; } return inbox.getNextGroupMessageIdForCallsign(group_name, callsign); } // Facade for Inbox::getLookaheadGroupMessageIdForCallsign int MainWindow::getLookaheadGroupMessageIdForCallsign(QString group_name, QString callsign, int afterMsgId) { Inbox inbox(inboxPath()); if (!inbox.open()) { return -1; } int mid = inbox.getLookaheadGroupMessageIdForCallsign(group_name, callsign, afterMsgId); if (mid == -1) { mid = inbox.getLookaheadGroupMessageIdForCallsign( group_name, Radio::base_callsign(callsign), afterMsgId); } if (mid != -1) { return mid; } return -1; } // Facade for Inbox::markGroupMsgDeliveredForCallsign bool MainWindow::markGroupMsgDeliveredForCallsign(int msgId, QString callsign) { Inbox inbox(inboxPath()); if (!inbox.open()) { return false; } return inbox.markGroupMsgDeliveredForCallsign(msgId, callsign); } bool MainWindow::markMsgDelivered(int mid, Message msg) { Inbox inbox(inboxPath()); if (!inbox.open()) { return false; } msg.setType("DELIVERED"); return inbox.set(mid, msg); } QStringList MainWindow::parseRelayPathCallsigns(QString from, QString text) { QStringList calls; QString callDePattern = { R"(\s([*]DE[*]|VIA)\s(?\b(?[A-Z0-9]{1,4}\/)?(?([0-9A-Z])?([0-9A-Z])([0-9])([A-Z])?([A-Z])?([A-Z])?)(?\/[A-Z0-9]{1,4})?)\b)"}; QRegularExpression re(callDePattern); auto iter = re.globalMatch(text); while (iter.hasNext()) { auto match = iter.next(); calls.prepend(match.captured("callsign")); } calls.prepend(from); return calls; } void MainWindow::processSpots() { if (!m_config.spot_to_reporting_networks()) { m_rxCallQueue.clear(); return; } if (m_rxCallQueue.isEmpty()) { return; } // Is it ok to post spots to PSKReporter? int nsec = DriftingDateTime::currentSecsSinceEpoch() - m_secBandChanged; bool okToPost = (nsec > (4 * m_TRperiod) / 5); if (!okToPost) { return; } while (!m_rxCallQueue.isEmpty()) { CallDetail d = m_rxCallQueue.dequeue(); if (d.call.isEmpty()) { continue; } if (m_config.spot_blacklist().contains(d.call) || m_config.spot_blacklist().contains(Radio::base_callsign(d.call))) { continue; } qCDebug(mainwindow_js8) << "spotting call to reporting networks" << d.call << d.snr << d.dial << d.offset; spotReport(d.submode, d.dial, d.offset, d.snr, d.call, d.grid); pskLogReport("JS8", d.dial, d.offset, d.snr, d.call, d.grid, d.utcTimestamp); if (canSendNetworkMessage()) { sendNetworkMessage("RX.SPOT", "", { {"_ID", QVariant(-1)}, {"FREQ", QVariant(d.dial + d.offset)}, {"DIAL", QVariant(d.dial)}, {"OFFSET", QVariant(d.offset)}, {"CALL", QVariant(d.call)}, {"SNR", QVariant(d.snr)}, {"GRID", QVariant(d.grid)}, }); } } } void MainWindow::processTxQueue() { #if IDLE_BLOCKS_TX if (m_tx_watchdog) { return; } #endif if (m_txMessageQueue.isEmpty()) { return; } // grab the next message... auto head = m_txMessageQueue.head(); // decide if it's ok to transmit... int f = head.offset; if (f == -1) { f = freq(); } // we need a valid frequency... if (f <= 0) { return; } // tx frame queue needs to be empty... if (!m_txFrameQueue.isEmpty()) { return; } // our message box needs to be empty... if (!ui->extFreeTextMsgEdit->toPlainText().isEmpty()) { return; } // and if we are a low priority message, we need to have not transmitted in // the past 30 seconds... if (head.priority <= PriorityLow && m_lastTxStartTime.secsTo(DriftingDateTime::currentDateTimeUtc()) <= 30) { return; } // if so... dequeue the next message from the queue... auto message = m_txMessageQueue.dequeue(); // add the message to the outgoing message text box addMessageText(message.message, true); // check to see if this is a high priority message, or if we have autoreply // enabled, or if this is a ping and the ping button is enabled if (message.priority >= PriorityHigh || message.message.contains(" HEARTBEAT ") || message.message.contains(" HB ") || message.message.contains(" ACK ") || ui->actionModeAutoreply->isChecked()) { // then try to set the frequency... setFreqOffsetForRestore(f, true); // then prepare to transmit... toggleTx(true); } if (message.callback) { message.callback(); } } void MainWindow::displayActivity(bool force) { if (!m_rxDisplayDirty && !force) { return; } // Band Activity displayBandActivity(); // Call Activity displayCallActivity(); m_rxDisplayDirty = false; } // updateBandActivity void MainWindow::displayBandActivity() { auto now = DriftingDateTime::currentDateTimeUtc(); // Reset the header label text to accommodate minimal label setting int cols = ui->tableWidgetRXAll->columnCount(); for (int c = 0; c < cols; ++c) { ui->tableWidgetRXAll->horizontalHeaderItem(c)->setText( columnLabel(m_origRxHeaderLabelMap[c])); } ui->tableWidgetRXAll->setFont(m_config.table_font()); // Selected Offset int selectedOffset = -1; auto selectedItems = ui->tableWidgetRXAll->selectedItems(); if (!selectedItems.isEmpty()) { selectedOffset = selectedItems.first()->data(Qt::UserRole).toInt(); } ui->tableWidgetRXAll->setUpdatesEnabled(false); { // Scroll Position auto const currentScrollPos = ui->tableWidgetRXAll->verticalScrollBar()->value(); // Clear the table ui->tableWidgetRXAll->setRowCount(0); // Sort! auto const sort = getSortByReverse("bandActivity", "offset"); auto keys = m_bandActivity.keys(); // Base comparison, called by the detail comparisons. We may not need // to proceed to the detail comparison at all here, if at least one of // the lists is empty. If both are non-empty, delegate to the detail // comparison, providing it with the last list elements. auto const compare = [this](int const lhsKey, int const rhsKey, auto &&detail) { auto const &lhs = m_bandActivity[lhsKey]; auto const &rhs = m_bandActivity[rhsKey]; if (lhs.isEmpty()) return false; if (rhs.isEmpty()) return true; return detail(lhs.last(), rhs.last()); }; // Time stamp comparison, easy stuff, just a total ordering on the // UTC time stamp field. auto const compareTimestamp = [compare](int const lhsKey, int const rhsKey) { return compare(lhsKey, rhsKey, [](auto &&lhs, auto &&rhs) { return lhs.utcTimestamp < rhs.utcTimestamp; }); }; // SNR comparison; we always want insane SNR values to be at the end // of the list and the list is going to be reversed if reverse is set, // so we want to set things up so that insane elements are either all // at the beginning in the case of a reverse, or all at the end in the // standard case. Reverse takes care of itself; we just need to sort // out standard. auto const compareSNR = [compare, reverse = sort.reverse]( int const lhsKey, int const rhsKey) { return compare(lhsKey, rhsKey, [reverse](auto &&lhs, auto &&rhs) { auto lhsSNR = lhs.snr; auto rhsSNR = rhs.snr; if (!reverse) { if (lhsSNR < -60 || lhsSNR > 60) lhsSNR = -lhsSNR; if (rhsSNR < -60 || rhsSNR > 60) rhsSNR = -rhsSNR; } return lhsSNR < rhsSNR; }); }; // Submode comparison; slow mode isn't at the start of the enumeration; // it's in the middle of it. All the other modes are in the expected // order. auto const compareSubmode = [compare](int const lhsKey, int const rhsKey) { return compare(lhsKey, rhsKey, [](auto &&lhs, auto &&rhs) { auto lhsSubmode = lhs.submode; auto rhsSubmode = rhs.submode; if (lhsSubmode == Varicode::JS8CallSlow) lhsSubmode = -lhsSubmode; if (rhsSubmode == Varicode::JS8CallSlow) rhsSubmode = -rhsSubmode; return lhsSubmode < rhsSubmode; }); }; // Always perform an initial sort by offset. std::stable_sort(keys.begin(), keys.end()); // If something other than offset was requested as the sort by, perform // an additional stable sort by the field requested. if (sort.by == "timestamp") std::stable_sort(keys.begin(), keys.end(), compareTimestamp); else if (sort.by == "snr") std::stable_sort(keys.begin(), keys.end(), compareSNR); else if (sort.by == "submode") std::stable_sort(keys.begin(), keys.end(), compareSubmode); // The sort comparators leave things in forward order. If a reverse sort // was requested, reverse the keys. if (sort.reverse) std::reverse(keys.begin(), keys.end()); // Build the table foreach (int offset, keys) { bool isOffsetSelected = (offset == selectedOffset); QList items = m_bandActivity[offset]; if (items.length() > 0) { QDateTime timestamp; QStringList text; QString age; int snr = 0; float tdrift = 0; int submode = -1; int activityAging = m_config.activity_aging(); // hide items that shouldn't appear for (int i = 0; i < items.length(); i++) { auto item = items[i]; bool shouldDisplay = true; // hide aged items if (!isOffsetSelected && activityAging && item.utcTimestamp.secsTo(now) / 60 >= activityAging) { shouldDisplay = false; } // hide heartbeat items if (!ui->actionShow_Band_Heartbeats_and_ACKs->isChecked()) { // hide heartbeats and acks if we have heartbeating // hidden if (item.text.contains(" @HB ") || item.text.contains(" HEARTBEAT ")) { shouldDisplay = false; // hide the previous item if this it shouldn't be // displayed either... if (i > 0 && items[i - 1].shouldDisplay && items[i - 1].text.endsWith(": ")) { items[i - 1].shouldDisplay = false; } } // if our previous item should not be displayed (or this // is the first frame) and we have a MSG ID, then don't // display it either. if ((i == 0 || (i > 0 && !items[i - 1].shouldDisplay)) && (item.text.contains(" MSG ID "))) { shouldDisplay = false; } } // hide empty items if (item.text.isEmpty()) { shouldDisplay = false; } // set the visibility of the item items[i].shouldDisplay = shouldDisplay; } // show the items that should appear foreach (ActivityDetail item, items) { if (!item.shouldDisplay) { continue; } if (item.isLowConfidence) { item.text = QString("[%1]").arg(item.text); } if ((item.bits & Varicode::JS8CallLast) == Varicode::JS8CallLast) { // append the eot character to the text item.text = QString("%1 %2 ") .arg(Varicode::rstrip(item.text)) .arg(m_config.eot()); } text.append(item.text); snr = item.snr; age = since(item.utcTimestamp); timestamp = item.utcTimestamp; tdrift = item.tdrift; submode = item.submode; } auto joined = Varicode::rstrip(text.join("")); if (joined.isEmpty()) { continue; } ui->tableWidgetRXAll->insertRow( ui->tableWidgetRXAll->rowCount()); int row = ui->tableWidgetRXAll->rowCount() - 1; int col = 0; auto offsetItem = new QTableWidgetItem( QString(columnLabel("%1 Hz")).arg(offset)); offsetItem->setData(Qt::UserRole, QVariant(offset)); offsetItem->setTextAlignment(Qt::AlignRight | Qt::AlignVCenter); ui->tableWidgetRXAll->setItem(row, col++, offsetItem); auto ageItem = new QTableWidgetItem(age); ageItem->setTextAlignment(Qt::AlignCenter); ageItem->setToolTip(timestamp.toString()); ui->tableWidgetRXAll->setItem(row, col++, ageItem); auto snrText = Varicode::formatSNR(snr); auto snrItem = new QTableWidgetItem( snrText.isEmpty() ? "" : QString(columnLabel("%1 dB")).arg(snrText)); snrItem->setTextAlignment(Qt::AlignRight | Qt::AlignVCenter); ui->tableWidgetRXAll->setItem(row, col++, snrItem); auto tdriftItem = new QTableWidgetItem( QString(columnLabel("%1 ms")).arg((int)(1000 * tdrift))); tdriftItem->setData(Qt::UserRole, QVariant(tdrift)); tdriftItem->setTextAlignment(Qt::AlignRight | Qt::AlignVCenter); ui->tableWidgetRXAll->setItem(row, col++, tdriftItem); auto name = JS8::Submode::name(submode); auto submodeItem = new QTableWidgetItem(name.left(1).replace("H", "N")); submodeItem->setToolTip(name); submodeItem->setData(Qt::UserRole, QVariant(name)); submodeItem->setTextAlignment(Qt::AlignCenter); ui->tableWidgetRXAll->setItem(row, col++, submodeItem); // align right if eliding... int colWidth = ui->tableWidgetRXAll->columnWidth(3); auto textItem = new QTableWidgetItem(joined); auto html = QString("%1").arg(joined.toHtmlEscaped()); html = html.replace(m_config.eot(), m_config.eot() + "

"); html = html.replace(QRegularExpression("([<]br[/][>])+$"), ""); textItem->setToolTip(html); QFontMetrics fm(textItem->font()); auto elidedText = fm.elidedText(joined, Qt::ElideLeft, colWidth); auto flag = Qt::AlignLeft | Qt::AlignVCenter; if (elidedText != joined) { flag = Qt::AlignRight | Qt::AlignVCenter; textItem->setText(joined); } textItem->setTextAlignment(flag); ui->tableWidgetRXAll->setItem(row, col++, textItem); if (isOffsetSelected) { for (int i = 0; i < ui->tableWidgetRXAll->columnCount(); i++) { ui->tableWidgetRXAll->item(row, i)->setSelected(true); } } bool isDirectedAllCall = false; if ((isDirectedOffset(offset, &isDirectedAllCall) && !isDirectedAllCall) || isMyCallIncluded(text.last())) { for (int i = 0; i < ui->tableWidgetRXAll->columnCount(); i++) { ui->tableWidgetRXAll->item(row, i)->setBackground( QBrush(m_config.color_MyCall())); } } if (!text.isEmpty()) { auto const list = joined.split(QRegularExpression("[:> ]"), Qt::SkipEmptyParts); QSet words(list.begin(), list.end()); if (words.contains("CQ")) { for (int i = 0; i < ui->tableWidgetRXAll->columnCount(); i++) { ui->tableWidgetRXAll->item(row, i)->setBackground( QBrush(m_config.color_CQ())); } } auto matchingSecondaryWords = m_config.secondary_highlight_words() & words; if (!matchingSecondaryWords.isEmpty()) { for (int i = 0; i < ui->tableWidgetRXAll->columnCount(); i++) { ui->tableWidgetRXAll->item(row, i)->setBackground( QBrush(m_config.color_secondary_highlight())); } } auto matchingPrimaryWords = m_config.primary_highlight_words() & words; if (!matchingPrimaryWords.isEmpty()) { for (int i = 0; i < ui->tableWidgetRXAll->columnCount(); i++) { ui->tableWidgetRXAll->item(row, i)->setBackground( QBrush(m_config.color_primary_highlight())); } } } } } // Set table color auto style = QString( "QTableWidget { background:%1; selection-background-color:%2; " "alternate-background-color:%1; color:%3; } " "QTableWidget::item:selected { background-color: %2; color: %3; }"); style = style.arg(m_config.color_table_background().name()); style = style.arg(m_config.color_table_highlight().name()); style = style.arg(m_config.color_table_foreground().name()); ui->tableWidgetRXAll->setStyleSheet(style); // Set the table palette for inactive selected row auto p = ui->tableWidgetRXAll->palette(); p.setColor(QPalette::Highlight, m_config.color_table_highlight()); p.setColor(QPalette::HighlightedText, m_config.color_table_foreground()); p.setColor(QPalette::Inactive, QPalette::Highlight, p.color(QPalette::Active, QPalette::Highlight)); ui->tableWidgetRXAll->setPalette(p); // Set item fonts for (int row = 0; row < ui->tableWidgetRXAll->rowCount(); row++) { for (int col = 0; col < ui->tableWidgetRXAll->columnCount(); col++) { auto item = ui->tableWidgetRXAll->item(row, col); if (item) { item->setFont(m_config.table_font()); } } } // Column labels ui->tableWidgetRXAll->horizontalHeader()->setVisible( showColumn("band", "labels")); // Hide columns ui->tableWidgetRXAll->setColumnHidden(0, !showColumn("band", "offset")); ui->tableWidgetRXAll->setColumnHidden(1, !showColumn("band", "timestamp")); ui->tableWidgetRXAll->setColumnHidden(2, !showColumn("band", "snr")); ui->tableWidgetRXAll->setColumnHidden( 3, !showColumn("band", "tdrift", false)); ui->tableWidgetRXAll->setColumnHidden( 4, !showColumn("band", "submode", false)); // Resize the table columns ui->tableWidgetRXAll->resizeColumnToContents(0); ui->tableWidgetRXAll->resizeColumnToContents(1); ui->tableWidgetRXAll->resizeColumnToContents(2); ui->tableWidgetRXAll->resizeColumnToContents(3); ui->tableWidgetRXAll->resizeColumnToContents(4); // Reset the scroll position ui->tableWidgetRXAll->verticalScrollBar()->setValue(currentScrollPos); } ui->tableWidgetRXAll->setUpdatesEnabled(true); } // updateCallActivity void MainWindow::displayCallActivity() { auto now = DriftingDateTime::currentDateTimeUtc(); // Reset the header label text to accommodate minimal label setting int cols = ui->tableWidgetCalls->columnCount(); for (int c = 0; c < cols; ++c) { ui->tableWidgetCalls->horizontalHeaderItem(c)->setText( columnLabel(m_origCallActivityHeaderLabelMap[c])); } ui->tableWidgetCalls->setFont(m_config.table_font()); // Selected callsign QString selectedCall = callsignSelected(); auto currentScrollPos = ui->tableWidgetCalls->verticalScrollBar()->value(); ui->tableWidgetCalls->setUpdatesEnabled(false); { // Clear the table ui->tableWidgetCalls->setRowCount(0); ui->tableWidgetCalls->horizontalHeaderItem(8)->setText( m_config.miles() ? "mi" : "km"); bool showIconColumn = false; createGroupCallsignTableRows( ui->tableWidgetCalls, selectedCall, showIconColumn); // isAllCallIncluded(selectedCall)); // || // isGroupCallIncluded(selectedCall)); // Build the table auto const sort = getSortByReverse("callActivity", "callsign"); auto keys = m_callActivity.keys(); auto const compareOffset = [this](QString const &lhsKey, QString const &rhsKey) { return m_callActivity[lhsKey].offset < m_callActivity[rhsKey].offset; }; auto const compareAzimuth = [this, reverse = sort.reverse, my_grid = m_config.my_grid()]( QString const &lhsKey, QString const &rhsKey) { auto const lhs = Geodesic::vector(my_grid, m_callActivity[lhsKey].grid) .azimuth(); auto const rhs = Geodesic::vector(my_grid, m_callActivity[rhsKey].grid) .azimuth(); // We always want invalid azimuths to be at the end of the list, // and the list is going to be reversed if reverse is set, so we // want to set things up so that invalid elements are either all // at the beginning in the case of a reverse, or all at the end // in the standard case. if (!lhs) return reverse && rhs; else if (!rhs) return !reverse; else return lhs < rhs; }; auto const compareDistance = [this, reverse = sort.reverse, my_grid = m_config.my_grid()]( QString const &lhsKey, QString const &rhsKey) { auto const lhs = Geodesic::vector(my_grid, m_callActivity[lhsKey].grid) .distance(); auto const rhs = Geodesic::vector(my_grid, m_callActivity[rhsKey].grid) .distance(); // We always want invalid distances to be at the end of the // list, and the list is going to be reversed if reverse is set, // so we want to set things up so that invalid elements are // either all at the beginning in the case of a reverse, or all // at the end in the standard case. if (!lhs) return reverse && rhs; else if (!rhs) return !reverse; else return lhs < rhs; }; auto const compareTimestamp = [this](QString const &lhsKey, QString const &rhsKey) { return m_callActivity[lhsKey].utcTimestamp < m_callActivity[rhsKey].utcTimestamp; }; auto const compareAckTimestamp = [this](QString const &lhsKey, QString const &rhsKey) { return m_callActivity[rhsKey].ackTimestamp < m_callActivity[lhsKey].ackTimestamp; }; auto const compareSNR = [this, reverse = sort.reverse](QString const &lhsKey, QString const &rhsKey) { auto lhs = m_callActivity[lhsKey].snr; auto rhs = m_callActivity[rhsKey].snr; // We always want insane SNR values to be at the end of the // list, and the list is going to be reversed if reverse is set, // so we want to set things up so that insane elements are // either all at the beginning in the case of a reverse, or all // at the end in the standard case. Reverse takes care of // itself; we just need to sort out standard. if (!reverse) { if (lhs < -60 || lhs > 60) lhs = -lhs; if (rhs < -60 || rhs > 60) rhs = -rhs; } return lhs < rhs; }; auto const compareSubmode = [this](QString const &lhsKey, QString const &rhsKey) { auto lhs = m_callActivity[lhsKey].submode; auto rhs = m_callActivity[rhsKey].submode; // Slow mode isn't at the start of the enumeration; it's in the // middle of it. All the other modes are in the expected order. if (lhs == Varicode::JS8CallSlow) lhs = -lhs; if (rhs == Varicode::JS8CallSlow) rhs = -rhs; return lhs < rhs; }; // Always perform an initial sort by callsign. std::stable_sort(keys.begin(), keys.end()); // If something other than callsign was requested as the sort by, // perform an additional stable sort by the field requested. if (sort.by == "offset") std::stable_sort(keys.begin(), keys.end(), compareOffset); else if (sort.by == "distance") std::stable_sort(keys.begin(), keys.end(), compareDistance); else if (sort.by == "azimuth") std::stable_sort(keys.begin(), keys.end(), compareAzimuth); else if (sort.by == "timestamp") std::stable_sort(keys.begin(), keys.end(), compareTimestamp); else if (sort.by == "ackTimestamp") std::stable_sort(keys.begin(), keys.end(), compareAckTimestamp); else if (sort.by == "snr") std::stable_sort(keys.begin(), keys.end(), compareSNR); else if (sort.by == "submode") std::stable_sort(keys.begin(), keys.end(), compareSubmode); // The sort comparators leave things in forward order. If a reverse sort // was requested, reverse the keys. if (sort.reverse) std::reverse(keys.begin(), keys.end()); // pin messages to the top std::stable_sort(keys.begin(), keys.end(), [this](QString const &lhsKey, QString const &rhsKey) { auto const lhs = (int)!( m_rxInboxCountCache.value(lhsKey, 0) > 0); auto const rhs = (int)!( m_rxInboxCountCache.value(rhsKey, 0) > 0); return lhs < rhs; }); int callsignAging = m_config.callsign_aging(); foreach (QString call, keys) { if (call.trimmed().isEmpty()) { continue; } CallDetail d = m_callActivity[call]; if (d.call.trimmed().isEmpty()) { continue; } bool isCallSelected = (call == selectedCall); // icon flags (flag -> star -> empty) bool hasMessage = m_rxInboxCountCache.value(d.call, 0) > 0; // display telephone icon if called cq in the past 5 minutes bool hasCQ = d.cqTimestamp.isValid() && d.cqTimestamp.secsTo(now) / 60 < 5; // display star if they've acked a message from us bool hasACK = d.ackTimestamp.isValid(); if (!isCallSelected && !hasMessage && callsignAging && d.utcTimestamp.secsTo(now) / 60 >= callsignAging) { continue; } ui->tableWidgetCalls->insertRow(ui->tableWidgetCalls->rowCount()); int row = ui->tableWidgetCalls->rowCount() - 1; int col = 0; #if SHOW_THROUGH_CALLS QString displayCall = d.through.isEmpty() ? d.call : QString("%1>%2").arg(d.through).arg(d.call); #else QString displayCall = d.call; #endif bool hasThrough = !d.through.isEmpty(); auto iconItem = new QTableWidgetItem(hasMessage ? "\u2691" : hasACK ? "\u2605" : hasCQ ? "\u260E" : hasThrough ? "\u269F" : ""); iconItem->setData(Qt::UserRole, QVariant(d.call)); iconItem->setToolTip( hasMessage ? "Message Available" : hasACK ? QString("Hearing Your Station (%1)") .arg(since(d.ackTimestamp)) : hasCQ ? QString("Calling CQ (%1)").arg(since(d.cqTimestamp)) : hasThrough ? QString("Heard Through Relay (%1)").arg(d.through) : ""); iconItem->setTextAlignment(Qt::AlignCenter); ui->tableWidgetCalls->setItem(row, col++, iconItem); if (hasMessage || hasACK || hasCQ || hasThrough) { showIconColumn = true; } auto displayItem = new QTableWidgetItem(displayCall); displayItem->setData(Qt::UserRole, QVariant(d.call)); displayItem->setToolTip(generateCallDetail(displayCall)); ui->tableWidgetCalls->setItem(row, col++, displayItem); #if ONLY_SHOW_HEARD_CALLSIGNS if (d.utcTimestamp.isValid()) { #else if (true) { #endif auto ageItem = new QTableWidgetItem(since(d.utcTimestamp)); ageItem->setTextAlignment(Qt::AlignCenter); ageItem->setToolTip(d.utcTimestamp.toString()); ui->tableWidgetCalls->setItem(row, col++, ageItem); auto snrText = Varicode::formatSNR(d.snr); auto snrItem = new QTableWidgetItem( snrText.isEmpty() ? "" : QString(columnLabel("%1 dB")).arg(snrText)); snrItem->setTextAlignment(Qt::AlignRight | Qt::AlignVCenter); ui->tableWidgetCalls->setItem(row, col++, snrItem); auto offsetItem = new QTableWidgetItem( QString(columnLabel("%1 Hz")).arg(d.offset)); offsetItem->setData(Qt::UserRole, QVariant(d.offset)); offsetItem->setTextAlignment(Qt::AlignRight | Qt::AlignVCenter); ui->tableWidgetCalls->setItem(row, col++, offsetItem); auto tdriftItem = new QTableWidgetItem( QString(columnLabel("%1 ms")).arg((int)(1000 * d.tdrift))); tdriftItem->setTextAlignment(Qt::AlignRight | Qt::AlignVCenter); ui->tableWidgetCalls->setItem(row, col++, tdriftItem); auto name = JS8::Submode::name(d.submode); auto modeItem = new QTableWidgetItem(name.left(1).replace("H", "N")); modeItem->setToolTip(name); modeItem->setData(Qt::UserRole, QVariant(name)); modeItem->setTextAlignment(Qt::AlignCenter); ui->tableWidgetCalls->setItem(row, col++, modeItem); auto gridItem = new QTableWidgetItem( QString("%1").arg(d.grid.trimmed().left(4))); gridItem->setToolTip(d.grid.trimmed()); ui->tableWidgetCalls->setItem(row, col++, gridItem); auto const vector = Geodesic::vector(m_config.my_grid(), d.grid); auto const units = !showColumn("call", "labels"); auto distanceItem = new QTableWidgetItem( vector.distance().toString(m_config.miles(), units)); distanceItem->setTextAlignment(Qt::AlignRight | Qt::AlignVCenter); ui->tableWidgetCalls->setItem(row, col++, distanceItem); auto azimuthItem = new QTableWidgetItem(vector.azimuth().toString(units)); if (auto const azimuth = vector.azimuth()) azimuthItem->setToolTip(azimuth.compass().toString()); azimuthItem->setTextAlignment(Qt::AlignRight | Qt::AlignVCenter); ui->tableWidgetCalls->setItem(row, col++, azimuthItem); QString flag; if (m_logBook.hasWorkedBefore(d.call, "")) { // unicode checkmark flag = "\u2713"; } auto workedBeforeItem = new QTableWidgetItem(flag); workedBeforeItem->setTextAlignment(Qt::AlignCenter); ui->tableWidgetCalls->setItem(row, col++, workedBeforeItem); QString logDetailGrid; QString logDetailDate; QString logDetailName; QString logDetailComment; bool gridItemEmpty = gridItem->text().isEmpty(); if ((gridItemEmpty && showColumn("call", "grid")) || showColumn("call", "log") || showColumn("call", "logName") || showColumn("call", "logComment")) { m_logBook.findCallDetails(d.call, logDetailGrid, logDetailDate, logDetailName, logDetailComment); } if (gridItemEmpty && !logDetailGrid.isEmpty()) { gridItem->setText(logDetailGrid.trimmed().left(4)); gridItem->setToolTip(logDetailGrid.trimmed()); auto const vector = Geodesic::vector(m_config.my_grid(), d.grid); auto const units = !showColumn("call", "labels"); distanceItem->setText( vector.distance().toString(m_config.miles(), units)); azimuthItem->setText(vector.azimuth().toString(units)); if (auto const azimuth = vector.azimuth()) azimuthItem->setToolTip(azimuth.compass().toString()); // update the call activity cache with the loaded grid if (m_callActivity.contains(d.call)) { m_callActivity[call].grid = logDetailGrid.trimmed(); } } if (!logDetailDate.isEmpty()) { auto lastLogged = QDate::fromString(logDetailDate, "yyyyMMdd"); workedBeforeItem->setToolTip( QString("Last Logged: %1").arg(lastLogged.toString())); } auto logNameItem = new QTableWidgetItem(logDetailName); logNameItem->setTextAlignment(Qt::AlignCenter); logNameItem->setToolTip(logDetailName); ui->tableWidgetCalls->setItem(row, col++, logNameItem); auto logCommentItem = new QTableWidgetItem(logDetailComment); logCommentItem->setTextAlignment(Qt::AlignCenter); logCommentItem->setToolTip(logDetailComment); ui->tableWidgetCalls->setItem(row, col++, logCommentItem); } else { ui->tableWidgetCalls->setItem(row, col++, new QTableWidgetItem("")); // age ui->tableWidgetCalls->setItem(row, col++, new QTableWidgetItem("")); // snr ui->tableWidgetCalls->setItem(row, col++, new QTableWidgetItem("")); // freq ui->tableWidgetCalls->setItem( row, col++, new QTableWidgetItem("")); // tdrift ui->tableWidgetCalls->setItem(row, col++, new QTableWidgetItem("")); // mode ui->tableWidgetCalls->setItem(row, col++, new QTableWidgetItem("")); // grid ui->tableWidgetCalls->setItem( row, col++, new QTableWidgetItem("")); // distance ui->tableWidgetCalls->setItem( row, col++, new QTableWidgetItem("")); // azimuth ui->tableWidgetCalls->setItem( row, col++, new QTableWidgetItem("")); // worked before ui->tableWidgetCalls->setItem( row, col++, new QTableWidgetItem("")); // log name ui->tableWidgetCalls->setItem( row, col++, new QTableWidgetItem("")); // log comment } if (isCallSelected) { for (int i = 0; i < ui->tableWidgetCalls->columnCount(); i++) { ui->tableWidgetCalls->item(row, i)->setSelected(true); } } if (hasCQ) { for (int i = 0; i < ui->tableWidgetCalls->columnCount(); i++) { ui->tableWidgetCalls->item(row, i)->setBackground( QBrush(m_config.color_CQ())); } } if (m_config.secondary_highlight_words().contains(call)) { for (int i = 0; i < ui->tableWidgetCalls->columnCount(); i++) { ui->tableWidgetCalls->item(row, i)->setBackground( QBrush(m_config.color_secondary_highlight())); } } if (m_config.primary_highlight_words().contains(call)) { for (int i = 0; i < ui->tableWidgetCalls->columnCount(); i++) { ui->tableWidgetCalls->item(row, i)->setBackground( QBrush(m_config.color_primary_highlight())); } } } // Set table color auto style = QString( "QTableWidget { background:%1; selection-background-color:%2; " "alternate-background-color:%1; color:%3; } " "QTableWidget::item:selected { background-color: %2; color: %3; }"); style = style.arg(m_config.color_table_background().name()); style = style.arg(m_config.color_table_highlight().name()); style = style.arg(m_config.color_table_foreground().name()); ui->tableWidgetCalls->setStyleSheet(style); // Set the table palette for inactive selected row auto p = ui->tableWidgetCalls->palette(); p.setColor(QPalette::Highlight, m_config.color_table_highlight()); p.setColor(QPalette::HighlightedText, m_config.color_table_foreground()); p.setColor(QPalette::Inactive, QPalette::Highlight, p.color(QPalette::Active, QPalette::Highlight)); ui->tableWidgetCalls->setPalette(p); // Set item fonts for (int row = 0; row < ui->tableWidgetCalls->rowCount(); row++) { auto bold = ui->tableWidgetCalls->item(row, 0)->text() == "\u2691"; for (int col = 0; col < ui->tableWidgetCalls->columnCount(); col++) { auto item = ui->tableWidgetCalls->item(row, col); if (item) { auto f = m_config.table_font(); if (bold) { f.setBold(true); } item->setFont(f); } } } // Column labels ui->tableWidgetCalls->horizontalHeader()->setVisible( showColumn("call", "labels")); // Hide columns ui->tableWidgetCalls->setColumnHidden(0, !showIconColumn); ui->tableWidgetCalls->setColumnHidden(1, !showColumn("call", "callsign")); ui->tableWidgetCalls->setColumnHidden(2, !showColumn("call", "timestamp")); ui->tableWidgetCalls->setColumnHidden(3, !showColumn("call", "snr")); ui->tableWidgetCalls->setColumnHidden(4, !showColumn("call", "offset")); ui->tableWidgetCalls->setColumnHidden( 5, !showColumn("call", "tdrift", false)); ui->tableWidgetCalls->setColumnHidden( 6, !showColumn("call", "submode", false)); ui->tableWidgetCalls->setColumnHidden( 7, !showColumn("call", "grid", false)); ui->tableWidgetCalls->setColumnHidden( 8, !showColumn("call", "distance", false)); ui->tableWidgetCalls->setColumnHidden( 9, !showColumn("call", "azimuth", false)); ui->tableWidgetCalls->setColumnHidden(10, !showColumn("call", "log")); ui->tableWidgetCalls->setColumnHidden(11, !showColumn("call", "logName")); ui->tableWidgetCalls->setColumnHidden( 12, !showColumn("call", "logComment")); // Resize the table columns ui->tableWidgetCalls->resizeColumnToContents(0); ui->tableWidgetCalls->resizeColumnToContents(1); ui->tableWidgetCalls->resizeColumnToContents(2); ui->tableWidgetCalls->resizeColumnToContents(3); ui->tableWidgetCalls->resizeColumnToContents(4); ui->tableWidgetCalls->resizeColumnToContents(5); ui->tableWidgetCalls->resizeColumnToContents(6); ui->tableWidgetCalls->resizeColumnToContents(7); ui->tableWidgetCalls->resizeColumnToContents(8); ui->tableWidgetCalls->resizeColumnToContents(9); ui->tableWidgetCalls->resizeColumnToContents(10); ui->tableWidgetCalls->resizeColumnToContents(11); // Reset the scroll position ui->tableWidgetCalls->verticalScrollBar()->setValue(currentScrollPos); } ui->tableWidgetCalls->setUpdatesEnabled(true); } void MainWindow::emitPTT(bool on) { qCDebug(mainwindow_js8) << "Setting PTT to" << (on ? "on" : "off"); Q_EMIT m_config.transceiver_ptt(on); // emit to network sendNetworkMessage( "RIG.PTT", on ? "on" : "off", { {"_ID", QVariant(-1)}, {"PTT", QVariant(on)}, {"UTC", QVariant( DriftingDateTime::currentDateTimeUtc().toMSecsSinceEpoch())}, }); } void MainWindow::emitTones() { if (!canSendNetworkMessage()) { return; } // emit tone numbers to network QVariantList t; for (int i = 0; i < JS8_NUM_SYMBOLS; i++) { // qCDebug(mainwindow_js8) << "tone" << i << "=" << itone[i]; t.append(QVariant((int)itone[i])); } sendNetworkMessage("TX.FRAME", "", {{"_ID", QVariant(-1)}, {"TONES", t}}); } void MainWindow::udpNetworkMessage(Message const &message) { if (!m_config.udpEnabled()) { return; } if (!m_config.accept_udp_requests()) { return; } networkMessage(message); } void MainWindow::tcpNetworkMessage(Message const &message) { if (!m_config.tcpEnabled()) { return; } if (!m_config.accept_tcp_requests()) { return; } networkMessage(message); } void MainWindow::networkMessage(Message const &message) { auto type = message.type(); if (type == "PING") { return; } auto id = message.id(); qCDebug(mainwindow_js8) << "try processing network message" << type << id; // Inspired by FLDigi // TODO: MAIN.RX - Turn on RX // TODO: MAIN.TX - Transmit // TODO: MAIN.PTT - PTT // TODO: MAIN.TUNE - Tune // TODO: MAIN.HALT - Halt // TODO: MAIN.AUTO - Auto // TODO: MAIN.SPOT - Spot // TODO: MAIN.HB - HB // RIG.GET_FREQ - Get the current Frequency // RIG.SET_FREQ - Set the current Frequency if (type == "RIG.GET_FREQ") { // Send WSJT-X Status message if protocol is enabled if (m_wsjtxMessageMapper && m_config.wsjtx_protocol_enabled()) { QString dx_call = callsignSelected(); QString dx_grid = ""; if (!dx_call.isEmpty() && m_callActivity.contains(dx_call)) { dx_grid = m_callActivity[dx_call].grid; } QString tx_message = m_transmitting ? m_currentMessage : ""; m_wsjtxMessageMapper->sendStatusUpdate( dialFrequency(), freq(), "JS8", // mode dx_call, m_config.my_callsign(), m_config.my_grid(), dx_grid, true, // tx_enabled m_transmitting, m_decoderBusy || m_monitoring, // decoding tx_message); } // Send native JSON message only if not conflicting with WSJT-X bool skip_json = false; if (m_config.wsjtx_protocol_enabled() && m_config.wsjtx_server_port() == m_config.udp_server_port() && m_config.wsjtx_server_name() == m_config.udp_server_name()) { skip_json = true; } if (!skip_json) { sendNetworkMessage( "RIG.FREQ", "", {{"_ID", id}, {"FREQ", QVariant((quint64)dialFrequency() + freq())}, {"DIAL", QVariant((quint64)dialFrequency())}, {"OFFSET", QVariant((quint64)freq())}}); } return; } if (type == "RIG.SET_FREQ") { auto params = message.params(); if (params.contains("DIAL")) { bool ok = false; auto f = params["DIAL"].toInt(&ok); if (ok) { setRig(f); displayDialFrequency(); } } if (params.contains("OFFSET")) { bool ok = false; auto f = params["OFFSET"].toInt(&ok); if (ok) { setFreqOffsetForRestore(f, false); } } } // STATION.GET_CALLSIGN - Get the current callsign // STATION.GET_GRID - Get the current grid locator // STATION.SET_GRID - Set the current grid locator // STATION.GET_INFO - Get the current station qth // STATION.SET_INFO - Set the current station qth if (type == "STATION.GET_CALLSIGN") { sendNetworkMessage("STATION.CALLSIGN", m_config.my_callsign(), { {"_ID", id}, }); return; } if (type == "STATION.GET_GRID") { sendNetworkMessage("STATION.GRID", m_config.my_grid(), { {"_ID", id}, }); return; } if (type == "STATION.SET_GRID") { m_config.set_dynamic_location(message.value()); sendNetworkMessage("STATION.GRID", m_config.my_grid(), { {"_ID", id}, }); return; } if (type == "STATION.GET_INFO") { sendNetworkMessage("STATION.INFO", m_config.my_info(), { {"_ID", id}, }); return; } if (type == "STATION.SET_INFO") { m_config.set_dynamic_station_info(message.value()); sendNetworkMessage("STATION.INFO", m_config.my_info(), { {"_ID", id}, }); return; } if (type == "STATION.GET_STATUS") { sendNetworkMessage("STATION.STATUS", m_config.my_status(), { {"_ID", id}, }); return; } if (type == "STATION.SET_STATUS") { m_config.set_dynamic_station_status(message.value()); sendNetworkMessage("STATION.STATUS", m_config.my_status(), { {"_ID", id}, }); return; } // RX.GET_CALL_ACTIVITY // RX.GET_CALL_SELECTED // RX.GET_BAND_ACTIVITY // RX.GET_TEXT if (type == "RX.GET_CALL_ACTIVITY") { auto now = DriftingDateTime::currentDateTimeUtc(); int callsignAging = m_config.callsign_aging(); QVariantMap calls = { {"_ID", id}, }; foreach (auto cd, m_callActivity.values()) { if (callsignAging && cd.utcTimestamp.secsTo(now) / 60 >= callsignAging) { continue; } QVariantMap detail; detail["SNR"] = QVariant(cd.snr); detail["GRID"] = QVariant(cd.grid); detail["UTC"] = QVariant(cd.utcTimestamp.toMSecsSinceEpoch()); calls[cd.call] = QVariant(detail); } sendNetworkMessage("RX.CALL_ACTIVITY", "", calls); return; } if (type == "RX.GET_CALL_SELECTED") { sendNetworkMessage("RX.CALL_SELECTED", callsignSelected(), { {"_ID", id}, }); return; } if (type == "RX.GET_BAND_ACTIVITY") { QVariantMap offsets = { {"_ID", id}, }; for (auto const [offset, activity] : m_bandActivity.asKeyValueRange()) { if (activity.isEmpty()) continue; auto const d = activity.last(); offsets[QString("%1").arg(offset)] = QVariant(QVariantMap{ {"FREQ", QVariant(d.dial + d.offset)}, {"DIAL", QVariant(d.dial)}, {"OFFSET", QVariant(d.offset)}, {"TEXT", QVariant(d.text)}, {"SNR", QVariant(d.snr)}, {"UTC", QVariant(d.utcTimestamp.toMSecsSinceEpoch())}}); } sendNetworkMessage("RX.BAND_ACTIVITY", "", offsets); return; } if (type == "RX.GET_TEXT") { sendNetworkMessage("RX.TEXT", ui->textEditRX->toPlainText().right(1024), { {"_ID", id}, }); return; } // TX.GET_TEXT // TX.SET_TEXT // TX.SEND_MESSAGE if (type == "TX.GET_TEXT") { sendNetworkMessage("TX.TEXT", ui->extFreeTextMsgEdit->toPlainText().right(1024), { {"_ID", id}, }); return; } if (type == "TX.SET_TEXT") { addMessageText(message.value(), true); sendNetworkMessage("TX.TEXT", ui->extFreeTextMsgEdit->toPlainText().right(1024), { {"_ID", id}, }); return; } if (type == "TX.SEND_MESSAGE") { auto text = message.value(); if (!text.isEmpty()) { enqueueMessage(PriorityNormal, text, -1, nullptr); processTxQueue(); return; } } // MODE.GET_SPEED // MODE.SET_SPEED if (type == "MODE.GET_SPEED") { sendNetworkMessage("MODE.SPEED", "", { {"_ID", id}, {"SPEED", m_nSubMode}, }); return; } if (type == "MODE.SET_SPEED") { auto ok = false; auto const speed = message.params().value("SPEED", QVariant(m_nSubMode)).toInt(&ok); if (ok) { if (speed == Varicode::JS8CallNormal) ui->actionModeJS8Normal->setChecked(true); else if (speed == Varicode::JS8CallFast) ui->actionModeJS8Fast->setChecked(true); else if (speed == Varicode::JS8CallTurbo) ui->actionModeJS8Turbo->setChecked(true); else if (speed == Varicode::JS8CallSlow) ui->actionModeJS8Slow->setChecked(true); else if (speed == Varicode::JS8CallUltra) ui->actionModeJS8Ultra->setChecked(true); setupJS8(); } sendNetworkMessage("MODE.SPEED", "", { {"_ID", id}, {"SPEED", m_nSubMode}, }); return; } // INBOX.GET_MESSAGES // INBOX.STORE_MESSAGE if (type == "INBOX.GET_MESSAGES") { QString selectedCall = message.params().value("CALLSIGN", "").toString(); if (selectedCall.isEmpty()) { selectedCall = "%"; } Inbox inbox(inboxPath()); if (!inbox.open()) { return; } QList> msgs; msgs.append( inbox.values("STORE", "$.params.TO", selectedCall, 0, 1000)); msgs.append( inbox.values("READ", "$.params.FROM", selectedCall, 0, 1000)); foreach (auto pair, inbox.values("UNREAD", "$.params.FROM", selectedCall, 0, 1000)) { msgs.append(pair); } std::stable_sort( msgs.begin(), msgs.end(), [](QPair const &a, QPair const &b) { return QVariant::compare(a.second.params().value("UTC"), b.second.params().value("UTC")) == QPartialOrdering::Greater; }); QVariantList l; foreach (auto pair, msgs) { l << pair.second.toVariantMap(); } sendNetworkMessage("INBOX.MESSAGES", "", { {"_ID", id}, {"MESSAGES", l}, }); return; } if (type == "INBOX.STORE_MESSAGE") { QString selectedCall = message.params().value("CALLSIGN", "").toString(); if (selectedCall.isEmpty()) { return; } QString text = message.params().value("TEXT", "").toString(); if (text.isEmpty()) { return; } CommandDetail d = {}; d.cmd = " MSG "; d.to = selectedCall; d.from = m_config.my_callsign(); d.relayPath = d.from; d.text = text; d.utcTimestamp = DriftingDateTime::currentDateTimeUtc(); d.submode = m_nSubMode; auto mid = addCommandToStorage("STORE", d); sendNetworkMessage("INBOX.MESSAGE", "", { {"_ID", id}, {"ID", mid}, }); return; } // WINDOW.RAISE if (type == "WINDOW.RAISE") { setWindowState(Qt::WindowActive); activateWindow(); raise(); return; } qCDebug(mainwindow_js8) << "Unable to process networkMessage:" << type; } bool MainWindow::canSendNetworkMessage() { return m_config.udpEnabled() || m_config.tcpEnabled(); } void MainWindow::sendNetworkMessage(QString const &type, QString const &message) { if (!canSendNetworkMessage()) { return; } auto m = Message(type, message); if (m_config.udpEnabled()) { m_messageClient->send(m); } if (m_config.tcpEnabled()) { m_messageServer->send(m); } } void MainWindow::sendNetworkMessage(QString const &type, QString const &message, QVariantMap const ¶ms) { if (!canSendNetworkMessage()) { return; } auto m = Message(type, message, params); if (m_config.udpEnabled()) { m_messageClient->send(m); } if (m_config.tcpEnabled()) { m_messageServer->send(m); } } void MainWindow::pskReporterError(QString const &message) { qCDebug(mainwindow_js8) << "PSK Reporter Error:" << message; showStatusMessage(tr("Spotting to PSK Reporter unavailable")); } void MainWindow::setRig(Frequency f) { if (f) { m_freqNominal = f; m_freqTxNominal = m_freqNominal - m_XIT; } if (m_transmitting && !m_config.tx_qsy_allowed()) return; if ((m_monitoring || m_transmitting) && m_config.transceiver_online()) { if (m_config.split_mode()) { Q_EMIT m_config.transceiver_tx_frequency(m_freqTxNominal); } Q_EMIT m_config.transceiver_frequency(m_freqNominal); } } /** * @brief Update and send station status * * Sends station status updates to both WSJT-X protocol clients (if enabled) * and native JSON API clients (if not conflicting). When WSJT-X protocol * is enabled on the same port/address as the native JSON API, the native * JSON messages are skipped to avoid conflicts. */ void MainWindow::statusUpdate() { // Send WSJT-X Status message if protocol is enabled if (m_wsjtxMessageMapper && m_config.wsjtx_protocol_enabled()) { QString dx_call = callsignSelected(); QString dx_grid = ""; if (!dx_call.isEmpty() && m_callActivity.contains(dx_call)) { dx_grid = m_callActivity[dx_call].grid; } QString mode = JS8::Submode::name(m_nSubMode); QString tx_message = m_transmitting ? m_currentMessage : ""; m_wsjtxMessageMapper->sendStatusUpdate( dialFrequency(), freq(), "JS8", // mode dx_call, m_config.my_callsign(), m_config.my_grid(), dx_grid, true, // tx_enabled - JS8Call always allows TX when not in special // modes m_transmitting, m_decoderBusy || m_monitoring, // decoding tx_message); } // Send native JSON message only if not conflicting with WSJT-X if (canSendNetworkMessage()) { // Don't send JSON if WSJT-X is enabled on the same port/address bool skip_json = false; if (m_config.wsjtx_protocol_enabled() && m_config.wsjtx_server_port() == m_config.udp_server_port() && m_config.wsjtx_server_name() == m_config.udp_server_name()) { skip_json = true; } if (!skip_json) { sendNetworkMessage("STATION.STATUS", "", { {"FREQ", QVariant(dialFrequency() + freq())}, {"DIAL", QVariant(dialFrequency())}, {"OFFSET", QVariant(freq())}, {"SPEED", QVariant(m_nSubMode)}, {"SELECTED", QVariant(callsignSelected())}, }); } } } void MainWindow::childEvent(QChildEvent *e) { if (e->child()->isWidgetType()) { switch (e->type()) { case QEvent::ChildAdded: add_child_to_event_filter(e->child()); break; case QEvent::ChildRemoved: remove_child_from_event_filter(e->child()); break; default: break; } } QMainWindow::childEvent(e); } // add widget and any child widgets to our event filter so that we can // take action on key press ad mouse press events anywhere in the main window void MainWindow::add_child_to_event_filter(QObject *target) { if (target && target->isWidgetType()) { target->installEventFilter(this); } auto const &children = target->children(); for (auto iter = children.begin(); iter != children.end(); ++iter) { add_child_to_event_filter(*iter); } } // recursively remove widget and any child widgets from our event filter void MainWindow::remove_child_from_event_filter(QObject *target) { auto const &children = target->children(); for (auto iter = children.begin(); iter != children.end(); ++iter) { remove_child_from_event_filter(*iter); } if (target && target->isWidgetType()) { target->removeEventFilter(this); } } void MainWindow::resetIdleTimer() { if (m_idleMinutes) { m_idleMinutes = 0; qCDebug(mainwindow_js8) << "idle" << m_idleMinutes << "minutes"; } } void MainWindow::incrementIdleTimer() { m_idleMinutes++; qCDebug(mainwindow_js8) << "increment idle to" << m_idleMinutes << "minutes"; } void MainWindow::tx_watchdog(bool triggered) { auto prior = m_tx_watchdog; m_tx_watchdog = triggered; if (triggered) { m_isTimeToSend = false; if (m_tune) stop_tuning(); if (m_auto) auto_tx_mode(false); stopTx(); tx_status_label.setStyleSheet( "QLabel{background-color: #000000; color:#ffffff; }"); tx_status_label.setText("Idle timeout"); // if the watchdog is triggered...we're no longer active bool wasAuto = ui->actionModeAutoreply->isChecked(); bool wasHB = ui->hbMacroButton->isChecked(); bool wasCQ = ui->cqMacroButton->isChecked(); // save the button states ui->actionModeAutoreply->setChecked(false); qCDebug(mainwindow_js8) << "Unchecking the hbMacroButton and " "cqMacroButton from TX watchdog."; ui->hbMacroButton->setChecked(false); ui->cqMacroButton->setChecked(false); // clear the tx queues resetMessageTransmitQueue(); QMessageBox *msgBox = new QMessageBox(this); msgBox->setIcon(QMessageBox::Information); msgBox->setWindowTitle("Idle Timeout"); msgBox->setInformativeText( QString("You have been idle for more than %1 minutes.") .arg(m_config.watchdog())); msgBox->addButton(QMessageBox::Ok); connect(msgBox, &QMessageBox::finished, this, [this, wasAuto, wasHB, wasCQ](int /*result*/) { // restore the button states ui->actionModeAutoreply->setChecked(wasAuto); ui->hbMacroButton->setChecked(wasHB); ui->cqMacroButton->setChecked(wasCQ); this->tx_watchdog(false); }); msgBox->setModal(true); msgBox->show(); } if (prior != triggered) statusUpdate(); } void MainWindow::write_frequency_entry(QString const &file_name) { if (!m_config.write_logs()) { return; } // Write freq changes to ALL.TXT only below 30 MHz. QFile f2{m_config.writeable_data_dir().absoluteFilePath(file_name)}; if (f2.open(QIODevice::WriteOnly | QIODevice::Text | QIODevice::Append)) { QTextStream out(&f2); out << DriftingDateTime::currentDateTimeUtc().toString( "yyyy-MM-dd hh:mm:ss") << " " << qSetRealNumberPrecision(12) << (m_freqNominal / 1.e6) << " MHz " << "JS8" << Qt::endl; f2.close(); } else { QTimer::singleShot(0, [this, message = tr("Cannot open \"%1\" for append: %2") .arg(f2.fileName()) .arg(f2.errorString())] { JS8MessageBox::warning_message(this, tr("Log File Error"), message); }); } } void MainWindow::write_transmit_entry(QString const &file_name) { if (!m_config.write_logs()) { return; } QFile f{m_config.writeable_data_dir().absoluteFilePath(file_name)}; if (f.open(QIODevice::WriteOnly | QIODevice::Text | QIODevice::Append)) { QTextStream out(&f); auto time = DriftingDateTime::currentDateTimeUtc(); time = time.addSecs(-(time.time().second() % m_TRperiod)); auto dt = DecodedText(m_currentMessage, m_currentMessageBits, m_nSubMode); out << time.toString("yyyy-MM-dd hh:mm:ss") << " Transmitting " << qSetRealNumberPrecision(12) << (m_freqNominal / 1.e6) << " MHz " << "JS8" << ": " << dt.message() << Qt::endl; f.close(); } else { QTimer::singleShot(0, [this, message = tr("Cannot open \"%1\" for append: %2") .arg(f.fileName()) .arg(f.errorString())] { JS8MessageBox::warning_message(this, tr("Log File Error"), message); }); } } void MainWindow::writeAllTxt(QStringView message) { if (!m_config.write_logs()) return; // Write decoded text to file "ALL.TXT". QFile f{m_config.writeable_data_dir().absoluteFilePath("ALL.TXT")}; if (f.open(QIODevice::WriteOnly | QIODevice::Text | QIODevice::Append)) { QTextStream out(&f); if (m_RxLog == 1) { out << DriftingDateTime::currentDateTimeUtc().toString( "yyyy-MM-dd hh:mm:ss") << " " << qSetRealNumberPrecision(12) << (m_freqNominal / 1.e6) << " MHz JS8" << Qt::endl; m_RxLog = 0; } out << message << Qt::endl; f.close(); } else { JS8MessageBox::warning_message(this, tr("File Open Error"), tr("Cannot open \"%1\" for append: %2") .arg(f.fileName()) .arg(f.errorString())); } } void MainWindow::writeMsgTxt(QStringView message, int snr, int offset) { if (!m_config.write_logs()) return; // Write decoded text to file "DIRECTED.TXT". QFile f{m_config.writeable_data_dir().absoluteFilePath("DIRECTED.TXT")}; if (f.open(QIODevice::WriteOnly | QIODevice::Text | QIODevice::Append)) { QTextStream out(&f); QString output = DriftingDateTime::currentDateTimeUtc().toString( "yyyy-MM-dd hh:mm:ss") % "\t" % Radio::frequency_MHz_string(m_freqNominal) % "\t" % QString::number(offset) % "\t" % Varicode::formatSNR(snr) % "\t" % message; out << output << Qt::endl; f.close(); } else { JS8MessageBox::warning_message(this, tr("File Open Error"), tr("Cannot open \"%1\" for append: %2") .arg(f.fileName()) .arg(f.errorString())); } } QByteArray MainWindow::wisdomFileName() const { return QDir::toNativeSeparators( m_config.writeable_data_dir().absoluteFilePath( "js8call_wisdom.dat")) .toLocal8Bit(); } Q_LOGGING_CATEGORY(decoder_js8, "decoder.js8", QtWarningMsg) Q_LOGGING_CATEGORY(mainwindow_js8, "mainwindow.js8", QtWarningMsg)