js8call/JS8_UI/mainwindow.cpp
Chris-AC9KH 356bdc0f50 Code cleanup - move loose source and header files in source tree to group folders
Rename all header files with .h extension
Fix function declaration in HamlibTransceiver.h that overrode member function but was not marked override
Remove unused variable in Modulator.cpp
Remove unused files from source tree
Remove duplicate LazyFillComboBox files
Reformat codebase to LLVM C++ standard
2026-01-02 16:46:38 -06:00

12482 lines
438 KiB
C++

//---------------------------------------------------------- MainWindow
#include "mainwindow.h"
#include <QAction>
#include <QActionGroup>
#include <QApplication>
#include <QByteArrayView>
#include <QCursor>
#include <QDesktopServices>
#include <QDir>
#include <QElapsedTimer>
#include <QFileDialog>
#include <QHostInfo>
#include <QInputDialog>
#include <QLineEdit>
#include <QLoggingCategory>
#include <QMdiSubWindow>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QPixmap>
#include <QProgressDialog>
#include <QRegularExpression>
#include <QRegularExpressionValidator>
#include <QScrollBar>
#include <QSoundEffect>
#include <QStandardPaths>
#include <QStringBuilder>
#include <QTime>
#include <QTimeZone>
#include <QToolTip>
#include <QUdpSocket>
#include <QUrl>
#include <QVariant>
#include <QVector>
#include <QVersionNumber>
#include <QtConcurrent/QtConcurrentRun>
#include <algorithm>
#include <array>
#include <boost/crc.hpp>
#include <cmath>
#include <complex>
#include <cstring>
#include <fftw3.h>
#include <functional>
#include <iterator>
#include <mutex>
#include <stdexcept>
#include <string_view>
#include <vector>
#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<QString, QString> 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<float> x(x_size, 0.0f);
// Smooth savg using median percentiles
auto const rank =
std::clamp(static_cast<int>(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<float>(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<std::mutex> 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<QPair<int, Message>> 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<int, Message> const &a, QPair<int, Message> 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<QPushButton*> btns;
foreach(auto child, ui->buttonGrid->children()){
if(!child->isWidgetType()){
continue;
}
if(!child->objectName().contains("Button")){
continue;
}
auto b = qobject_cast<QPushButton*>(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<QPushButton *>(child);
b->setCursor(QCursor(Qt::PointingHandCursor));
}
auto buttonLayout = ui->buttonGrid->layout();
auto gridButtonLayout = qobject_cast<QGridLayout *>(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 <a "
"href='https://github.com/JS8Call-improved/"
"JS8Call-improved/releases'>GitHub "
"Releases</a> 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<QString> 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<std::mutex> 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<Frequency>(Default::DIAL_FREQUENCY))
.value<Frequency>();
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<QList<int> >());
// 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<QThread::Priority>(
m_settings->value("Audio/ThreadPriority", QThread::TimeCriticalPriority)
.toInt() %
8);
m_notificationAudioThreadPriority = static_cast<QThread::Priority>(
m_settings
->value("Audio/NotificationThreadPriority", QThread::LowPriority)
.toInt() %
8);
m_decoderThreadPriority = static_cast<QThread::Priority>(
m_settings->value("Audio/DecoderThreadPriority", QThread::HighPriority)
.toInt() %
8);
m_networkThreadPriority = static_cast<QThread::Priority>(
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<std::mutex> 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<float *>(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<int>(5000.0f / m_df3));
auto const cx = reinterpret_cast<std::complex<float> *>(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<std::mutex> 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<float, JS8_NSMAX> 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<int>(500.0 / m_df3);
auto const ib = static_cast<int>(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<QChildEvent *>(event)->child());
break;
case QEvent::ChildRemoved:
// ensure our child widgets get d=removed from our event filter
remove_child_from_event_filter(
static_cast<QChildEvent *>(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, &currentDecodeStartA,
&nextDecodeStartA, &startA, &szA, &cycleA);
static qint32 currentDecodeStartB = -1;
static qint32 nextDecodeStartB = -1;
qCDebug(decoder_js8) << "? FAST " << currentDecodeStartB
<< nextDecodeStartB;
couldDecodeB =
isDecodeReady(Varicode::JS8CallFast, k, k0, &currentDecodeStartB,
&nextDecodeStartB, &startB, &szB, &cycleB);
static qint32 currentDecodeStartC = -1;
static qint32 nextDecodeStartC = -1;
qCDebug(decoder_js8) << "? TURBO " << currentDecodeStartC
<< nextDecodeStartC;
couldDecodeC =
isDecodeReady(Varicode::JS8CallTurbo, k, k0, &currentDecodeStartC,
&nextDecodeStartC, &startC, &szC, &cycleC);
static qint32 currentDecodeStartE = -1;
static qint32 nextDecodeStartE = -1;
qCDebug(decoder_js8) << "? SLOW " << currentDecodeStartE
<< nextDecodeStartE;
couldDecodeE =
isDecodeReady(Varicode::JS8CallSlow, k, k0, &currentDecodeStartE,
&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, &currentDecodeStartI,
&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<qint32, qint32> m_lastDecodeStartMap;
// TODO: make this non-static field of MainWindow?
// map of submodes to decode + optional alternate decode positions
static QMap<qint32, QList<qint32>> 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<int> generateOffsets(int minOffset, int maxOffset) {
QList<int> 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<qint32> driftQueue;
static qint32 syncStart = -1;
std::visit(
[this](auto &&e) {
using T = std::decay_t<decltype(e)>;
if constexpr (std::is_same_v<T, JS8::Event::DecodeStarted>) {
if (m_wideGraph->shouldDisplayDecodeAttempts()) {
m_wideGraph->drawHorizontalLine(QColor(Qt::yellow), 0, 5);
}
} else if constexpr (std::is_same_v<T, JS8::Event::SyncStart>) {
syncStart = e.position;
} else if constexpr (std::is_same_v<T, JS8::Event::SyncState>) {
if (m_wideGraph->shouldDisplayDecodeAttempts()) {
auto const drawDecodeLine =
[this, freq = static_cast<int>(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<int>(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<T,
JS8::Event::DecodeFinished>) {
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<T, JS8::Event::Decoded>) {
// 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<quint32>(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<int> 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<int> 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<int *>(reinterpret_cast<volatile int *>(
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 &current_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("<strong>%1 - %2</strong>")
.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", "<br/>");
text = text.replace(" ", "&nbsp;&nbsp;");
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<QWidget *>(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<QString, int> MainWindow::popMessageFrame() {
if (m_txFrameQueue.isEmpty()) {
return QPair<QString, int>{};
}
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<QPair<QString, int>>
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<QString, int> 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 + " <eor>", 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("<CMD>"
"<ADDDIRECT>"
"<EXCLUDEDUPES>TRUE</EXCLUDEDUPES>"
"<STAYOPEN>FALSE</STAYOPEN>"
"<fldDateStr>%1</fldDateStr>"
"<fldTimeOnStr>%2</fldTimeOnStr>"
"<fldCall>%3</fldCall>"
"<fldGridR>%4</fldGridR>"
"<fldBand>%5</fldBand>"
"<fldFrequency>%6</fldFrequency>"
"<fldMode>JS8</fldMode>"
"<fldOperator>%7</fldOperator>"
"<fldNameR>%8</fldNameR>"
"<fldComments>%9</fldComments>"
"<fldRstS>%10</fldRstS>"
"<fldRstR>%11</fldRstR>"
"%12"
"</CMD>");
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</%1>").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, "<CMD><CHECKLOG></CMD>", 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<QPair<QString, int>> 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<QPair<QString, QString>> columnKeys = {
{"Frequency Offset", "offset"},
{"Last heard timestamp", "timestamp"},
{"SNR", "snr"},
{"Time Delta", "tdrift"},
{"Mode Speed", "submode"},
};
QMap<QString, bool> 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<QPair<QString, QString>> 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<QString, QString> 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<QString, QString> values = {
{"<MYCALL>", m_config.my_callsign()},
{"<MYGRID4>", m_config.my_grid().left(4)},
{"<MYGRID12>", m_config.my_grid().left(12)},
{"<MYINFO>", m_config.my_info()},
{"<MYHB>", m_config.hb_message()},
{"<MYCQ>", m_config.cq_message()},
{"<MYREPLY>", m_config.reply_message()},
{"<MYSTATUS>", m_config.my_status()},
{"<MYVERSION>", myVersion},
{"<MYIDLE>", myIdle},
};
auto selectedCall = callsignSelected();
if (m_callActivity.contains(selectedCall)) {
auto cd = m_callActivity[selectedCall];
values["<CALL>"] = selectedCall;
values["<TDELTA>"] = QString("%1 ms").arg((int)(1000 * cd.tdrift));
if (cd.snr > -31) {
values["<SNR>"] = Varicode::formatSNR(cd.snr);
}
}
// these macros can have recursive macros
values["<MYINFO>"] = replaceMacros(values["<MYINFO>"], values, false);
values["<MYSTATUS>"] = replaceMacros(values["<MYSTATUS>"], values, false);
values["<MYCQ>"] = replaceMacros(values["<MYCQ>"], values, false);
values["<MYHB>"] = replaceMacros(values["<MYHB>"], values, false);
values["<MYREPLY>"] = replaceMacros(values["<MYREPLY>"], 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("<h1>%1</h1>").arg(selectedCall.toHtmlEscaped()),
hearing.isEmpty() ? ""
: QString("<p><strong>HEARING</strong>: %1</p>")
.arg(hearing.toHtmlEscaped()),
heardby.isEmpty() ? ""
: QString("<p><strong>HEARD BY</strong>: %1</p>")
.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<Message> 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<QPushButton *> btns;
foreach (auto child, ui->buttonGrid->children()) {
if (!child->isWidgetType()) {
continue;
}
if (!child->objectName().contains("Button")) {
continue;
}
auto b = qobject_cast<QPushButton *>(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() &&
// The folloing line is disputed, as it disallows replies to HBs
// if there is any (unrelated) activity on the band:
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
// Only reply to a SNR request if there is no incoming MSG's and is not
// directed to AllCall
if (d.cmd == " SNR?" && !isAllCall && m_messageBuffer.isEmpty()) {
reply = QString("%1 SNR %2")
.arg(d.from)
.arg(Varicode::formatSNR(d.snr));
}
// QUERIED INFO
// Only reply to a INFO request if there is no incoming MSG's and is not
// directed to AllCall
else if (d.cmd == " INFO?" && !isAllCall && m_messageBuffer.isEmpty()) {
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
// Only reply to a STATUS request if there is no incoming MSG's and is
// not directed to AllCall
else if (d.cmd == " STATUS?" && !isAllCall &&
m_messageBuffer.isEmpty()) {
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
// Only reply to a GRID request if there is no incoming MSG's and is not
// directed to AllCall
else if (d.cmd == " GRID?" && !isAllCall && m_messageBuffer.isEmpty()) {
QString grid = m_config.my_grid();
if (grid.isEmpty()) {
continue;
}
reply = QString("%1 GRID %2").arg(d.from).arg(grid);
}
// QUERIED STATIONS HEARD
// Only reply to a HEARING request if there is no incoming MSG's and is
// not directed to AllCall
else if (d.cmd == " HEARING?" && !isAllCall &&
m_messageBuffer.isEmpty()) {
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"(^(?<callsign>\b(?<prefix>[A-Z0-9]{1,4}\/)?(?<base>([0-9A-Z])?([0-9A-Z])([0-9])([A-Z])?([A-Z])?([A-Z])?)(?<suffix>\/[A-Z0-9]{1,4})?(?<type>[> ]))\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 <del>and auto ack
// enabled and no callsign is selected</del> 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
// Do not process this request if there is incoming MSG as it breaks the
// incoming
else if (d.cmd == " QUERY MSGS" &&
ui->actionModeAutoreply->isChecked() &&
m_messageBuffer.isEmpty()) {
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
// Do not process this request if there is incoming MSG as it breaks the
// incoming
else if (d.cmd == " QUERY CALL" &&
ui->actionModeAutoreply->isChecked() &&
m_messageBuffer.isEmpty()) {
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<QString, int> 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(?<callsign>\b(?<prefix>[A-Z0-9]{1,4}\/)?(?<base>([0-9A-Z])?([0-9A-Z])([0-9])([A-Z])?([A-Z])?([A-Z])?)(?<suffix>\/[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<ActivityDetail> 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("<qt/>%1").arg(joined.toHtmlEscaped());
html =
html.replace(m_config.eot(), m_config.eot() + "<br/><br/>");
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<QString> 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<QPair<int, Message>> 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<int, Message> const &a, QPair<int, Message> 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 &params) {
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)