mirror of
https://github.com/JS8Call-improved/JS8Call-improved
synced 2026-08-13 17:47:36 -04:00
12462 lines
437 KiB
C++
12462 lines
437 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, ¤tDecodeStartA,
|
|
&nextDecodeStartA, &startA, &szA, &cycleA);
|
|
|
|
static qint32 currentDecodeStartB = -1;
|
|
static qint32 nextDecodeStartB = -1;
|
|
qCDebug(decoder_js8) << "? FAST " << currentDecodeStartB
|
|
<< nextDecodeStartB;
|
|
couldDecodeB =
|
|
isDecodeReady(Varicode::JS8CallFast, k, k0, ¤tDecodeStartB,
|
|
&nextDecodeStartB, &startB, &szB, &cycleB);
|
|
|
|
static qint32 currentDecodeStartC = -1;
|
|
static qint32 nextDecodeStartC = -1;
|
|
qCDebug(decoder_js8) << "? TURBO " << currentDecodeStartC
|
|
<< nextDecodeStartC;
|
|
couldDecodeC =
|
|
isDecodeReady(Varicode::JS8CallTurbo, k, k0, ¤tDecodeStartC,
|
|
&nextDecodeStartC, &startC, &szC, &cycleC);
|
|
|
|
static qint32 currentDecodeStartE = -1;
|
|
static qint32 nextDecodeStartE = -1;
|
|
qCDebug(decoder_js8) << "? SLOW " << currentDecodeStartE
|
|
<< nextDecodeStartE;
|
|
couldDecodeE =
|
|
isDecodeReady(Varicode::JS8CallSlow, k, k0, ¤tDecodeStartE,
|
|
&nextDecodeStartE, &startE, &szE, &cycleE);
|
|
|
|
#if JS8_ENABLE_JS8I
|
|
static qint32 currentDecodeStartI = -1;
|
|
static qint32 nextDecodeStartI = -1;
|
|
qCDebug(decoder_js8) << "? ULTRA " << currentDecodeStartI
|
|
<< nextDecodeStartI;
|
|
couldDecodeI =
|
|
isDecodeReady(Varicode::JS8CallUltra, k, k0, ¤tDecodeStartI,
|
|
&nextDecodeStartI, &startI, &szI, &cycleI);
|
|
#endif
|
|
|
|
if (couldDecodeA) {
|
|
DecodeParams d;
|
|
d.submode = Varicode::JS8CallNormal;
|
|
d.start = startA;
|
|
d.sz = szA;
|
|
m_decoderQueue.append(d);
|
|
decodes++;
|
|
}
|
|
|
|
if (couldDecodeB) {
|
|
DecodeParams d;
|
|
d.submode = Varicode::JS8CallFast;
|
|
d.start = startB;
|
|
d.sz = szB;
|
|
m_decoderQueue.append(d);
|
|
decodes++;
|
|
}
|
|
|
|
if (couldDecodeC) {
|
|
DecodeParams d;
|
|
d.submode = Varicode::JS8CallTurbo;
|
|
d.start = startC;
|
|
d.sz = szC;
|
|
m_decoderQueue.append(d);
|
|
decodes++;
|
|
}
|
|
|
|
if (couldDecodeE) {
|
|
DecodeParams d;
|
|
d.submode = Varicode::JS8CallSlow;
|
|
d.start = startE;
|
|
d.sz = szE;
|
|
m_decoderQueue.append(d);
|
|
decodes++;
|
|
}
|
|
|
|
#if JS8_ENABLE_JS8I
|
|
if (couldDecodeI) {
|
|
DecodeParams d;
|
|
d.submode = Varicode::JS8CallUltra;
|
|
d.start = startI;
|
|
d.sz = szI;
|
|
m_decoderQueue.append(d);
|
|
decodes++;
|
|
}
|
|
#endif
|
|
|
|
return decodes > 0;
|
|
}
|
|
|
|
/**
|
|
* @brief MainWindow::decodeEnqueueReadyExperiment
|
|
* compute the available decoder ranges that can be processed and
|
|
* place them in the decode queue
|
|
*
|
|
* experiment with decoding on a much shorter interval than usual
|
|
*
|
|
* @param k - the current frame count
|
|
* @param k0 - the previous frame count
|
|
* @return true if decoder ranges were queued, false otherwise
|
|
*/
|
|
bool MainWindow::decodeEnqueueReadyExperiment(qint32 k, qint32 /*k0*/) {
|
|
// TODO: make this non-static field of MainWindow?
|
|
// map of last decode positions for each submode
|
|
// static QMap<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 ¤t_message = QString::fromLatin1(msgsent);
|
|
if (m_config.watchdog() && current_message != m_msgSent0) {
|
|
// new messages don't reset the idle timer :|
|
|
// tx_watchdog (false); // in case we are auto sequencing
|
|
m_msgSent0 = current_message;
|
|
}
|
|
|
|
if (!m_tune) {
|
|
write_transmit_entry("ALL.TXT");
|
|
}
|
|
|
|
// TODO: jsherer - perhaps an on_transmitting signal?
|
|
m_lastTxStartTime = DriftingDateTime::currentDateTimeUtc();
|
|
|
|
m_transmitting = true;
|
|
transmitDisplay(true);
|
|
statusUpdate();
|
|
}
|
|
|
|
// TODO: stop
|
|
if (!m_btxok && m_btxok0 && m_iptt == 1)
|
|
stopTx();
|
|
}
|
|
|
|
void MainWindow::updateClockUI(const QDateTime &now) {
|
|
qint64 drift = DriftingDateTime::drift();
|
|
QStringList parts;
|
|
parts
|
|
<< (now.time().toString() +
|
|
(!drift
|
|
? " "
|
|
: QString(" (%1%2ms)").arg(drift > 0 ? "+" : "").arg(drift)));
|
|
parts << now.date().toString("yyyy MMM dd");
|
|
ui->labUTC->setText(parts.join("\n"));
|
|
}
|
|
|
|
//------------------------------------------------------------- //guiUpdate()
|
|
void MainWindow::guiUpdate() {
|
|
|
|
unsigned period = JS8::Submode::period(m_nSubMode);
|
|
|
|
m_TRperiod = period; // Investigate: Does anyone need this?
|
|
|
|
// Propagate any tx delay change to m_hb_loop and m_cq_loop.
|
|
double tx_delay_now = m_config.txDelay();
|
|
if (tx_delay_now != m_TxDelay) {
|
|
m_TxDelay = tx_delay_now;
|
|
qint64 tx_delay_ms = std::lround(tx_delay_now * 1000);
|
|
m_hb_loop->onTxDelayChange(tx_delay_ms);
|
|
m_cq_loop->onTxDelayChange(tx_delay_ms);
|
|
}
|
|
|
|
const QDateTime now = DriftingDateTime::currentDateTimeUtc();
|
|
const qint64 seconds_since_epoch = now.toSecsSinceEpoch();
|
|
|
|
if (m_transmitting or m_auto or m_tune) {
|
|
refuseToSendIn30mWSPRBand();
|
|
prepareSending(now.toMSecsSinceEpoch());
|
|
}
|
|
|
|
// Once per second:
|
|
if (seconds_since_epoch != m_sec0) {
|
|
m_sec0 = seconds_since_epoch;
|
|
|
|
updateClockUI(now);
|
|
|
|
if (m_monitoring or m_transmitting) {
|
|
// We are lucky that TX delay starts well into the second
|
|
// and lasts less than a second. So as long as we
|
|
// do this near the begining of a second, we will never hit
|
|
// the confusing "progress" of tx delay.
|
|
progressBar.setMaximum(period);
|
|
int progress = seconds_since_epoch % period;
|
|
progressBar.setValue(progress);
|
|
} else {
|
|
progressBar.setValue(0);
|
|
}
|
|
|
|
if (m_transmitting) {
|
|
tx_status_label.setStyleSheet(
|
|
"QLabel{background-color: #ff2222; color:#000}");
|
|
if (m_tune) {
|
|
tx_status_label.setText("Tx: TUNE");
|
|
} else {
|
|
auto message =
|
|
DecodedText(msgsent, msgibits, m_nSubMode).message();
|
|
tx_status_label.setText(
|
|
QString("Tx: %1").arg(message).left(40).trimmed());
|
|
}
|
|
transmitDisplay(true);
|
|
} else if (m_monitoring) {
|
|
if (m_tx_watchdog) {
|
|
tx_status_label.setStyleSheet(
|
|
"QLabel{background-color: #000; color:#fff}");
|
|
tx_status_label.setText("Idle timeout");
|
|
} else {
|
|
tx_status_label.setStyleSheet(
|
|
"QLabel{background-color: #22ff22}");
|
|
tx_status_label.setText(m_decoderBusy ? "Decoding"
|
|
: "Receiving");
|
|
}
|
|
transmitDisplay(false);
|
|
} else if (!m_tx_watchdog) {
|
|
tx_status_label.setStyleSheet("");
|
|
tx_status_label.setText("");
|
|
}
|
|
|
|
auto callLabel = m_config.my_callsign();
|
|
if (m_config.use_dynamic_grid() && !m_config.my_grid().isEmpty()) {
|
|
callLabel =
|
|
QString("%1 - %2").arg(callLabel).arg(m_config.my_grid());
|
|
}
|
|
ui->labCallsign->setText(callLabel);
|
|
|
|
if (!m_monitoring) {
|
|
ui->signal_meter_widget->setValue(0, 0);
|
|
}
|
|
|
|
// once per period
|
|
if (seconds_since_epoch % period == 0) {
|
|
tryBandHop();
|
|
}
|
|
|
|
// Need to do processing at the end of the period
|
|
// or when there is something in m_rxActivityQueue.
|
|
bool forceDirty = (seconds_since_epoch % period == 0) ||
|
|
((seconds_since_epoch - 1) % period == 0) ||
|
|
!m_rxActivityQueue.isEmpty();
|
|
|
|
// update the dial frequency once per second..
|
|
displayDialFrequency();
|
|
updateHBButtonDisplay();
|
|
updateCQButtonDisplay();
|
|
|
|
// once per second...but not when we're transmitting, unless it's in the
|
|
// first second...
|
|
if (!m_transmitting || (seconds_since_epoch % period == 0)) {
|
|
// process all received activity...
|
|
processActivity(forceDirty);
|
|
|
|
// process outgoing tx queue...
|
|
processTxQueue();
|
|
|
|
// once processed, lets update the display...
|
|
displayActivity(forceDirty);
|
|
updateButtonDisplay();
|
|
updateTextDisplay();
|
|
}
|
|
} // end of stuff we do once per second.
|
|
|
|
displayTransmit();
|
|
|
|
m_iptt0 = m_iptt;
|
|
m_btxok0 = m_btxok;
|
|
|
|
// Set the time to hit the start of the next UI_POLL_INTERVAL_MS slot.
|
|
// This automatically hits close to the start of each second
|
|
// and hence close to the start of each transmit period.
|
|
qint64 now_at_end_ms = DriftingDateTime::currentMSecsSinceEpoch();
|
|
qint64 time_into_poll_slot = now_at_end_ms % UI_POLL_INTERVAL_MS;
|
|
qint64 until_start_of_next_poll_slot =
|
|
UI_POLL_INTERVAL_MS - time_into_poll_slot;
|
|
m_guiTimer.start(until_start_of_next_poll_slot);
|
|
} // End of guiUpdate
|
|
|
|
void MainWindow::startTx() {
|
|
#if IDLE_BLOCKS_TX
|
|
if (m_tx_watchdog) {
|
|
return;
|
|
}
|
|
#endif
|
|
|
|
auto text = ui->extFreeTextMsgEdit->toPlainText();
|
|
if (!ensureCreateMessageReady(text)) {
|
|
return;
|
|
}
|
|
|
|
if (!prepareNextMessageFrame()) {
|
|
return;
|
|
}
|
|
|
|
m_dateTimeQSOOn = QDateTime{};
|
|
if (m_transmitting)
|
|
m_restart = true;
|
|
|
|
if (!m_auto)
|
|
auto_tx_mode(true);
|
|
|
|
// disallow editing of the text while transmitting
|
|
// ui->extFreeTextMsgEdit->setReadOnly(true);
|
|
update_dynamic_property(ui->extFreeTextMsgEdit, "transmitting", true);
|
|
|
|
// update the tx button display
|
|
updateTxButtonDisplay();
|
|
}
|
|
|
|
void MainWindow::transmit() {
|
|
if (m_modulator->isIdle()) {
|
|
qDebug(mainwindow_js8) << "Asking the modulator to emit audio.";
|
|
Q_EMIT sendMessage(freq() + m_XIT, m_nSubMode, m_TxDelay, m_soundOutput,
|
|
m_config.audio_output_channel());
|
|
ui->signal_meter_widget->setValue(0, 0);
|
|
} else {
|
|
qDebug(mainwindow_js8) << "Not asking the modulator to emit audio as "
|
|
"modulator isn't idle.";
|
|
}
|
|
}
|
|
|
|
void MainWindow::stopTx() {
|
|
Q_EMIT endTransmitMessage();
|
|
|
|
auto dt = DecodedText(m_currentMessage.trimmed(), m_currentMessageBits,
|
|
m_nSubMode);
|
|
last_tx_label.setText("Last Tx: " +
|
|
dt.message()); // m_currentMessage.trimmed());
|
|
|
|
// TODO: uncomment if we want to mark after the frame is sent.
|
|
//// // start message marker
|
|
//// // - keep track of the total message sent so far, and mark it having
|
|
/// been sent / m_totalTxMessage.append(dt.message()); /
|
|
/// ui->extFreeTextMsgEdit->setCharsSent(m_totalTxMessage.length()); /
|
|
/// qCDebug(mainwindow_js8) << "total sent:\n" << m_totalTxMessage; / // end
|
|
/// message marker
|
|
|
|
m_btxok = false;
|
|
m_transmitting = false;
|
|
m_iptt = 0;
|
|
m_lastTxStopTime = DriftingDateTime::currentDateTimeUtc();
|
|
if (!m_tx_watchdog) {
|
|
tx_status_label.setStyleSheet("");
|
|
tx_status_label.setText("");
|
|
}
|
|
|
|
#if IDLE_BLOCKS_TX
|
|
bool shouldContinue = !m_tx_watchdog && prepareNextMessageFrame();
|
|
#else
|
|
bool shouldContinue = prepareNextMessageFrame();
|
|
#endif
|
|
if (!shouldContinue) {
|
|
// TODO: jsherer - split this up...
|
|
ui->extFreeTextMsgEdit->clear();
|
|
ui->extFreeTextMsgEdit->setReadOnly(false);
|
|
update_dynamic_property(ui->extFreeTextMsgEdit, "transmitting", false);
|
|
bool previous_stopTxButtonIsLongterm = m_stopTxButtonIsLongterm;
|
|
m_stopTxButtonIsLongterm = false;
|
|
on_stopTxButton_clicked();
|
|
m_stopTxButtonIsLongterm = previous_stopTxButtonIsLongterm;
|
|
tryRestoreFreqOffset();
|
|
}
|
|
|
|
pttReleaseTimer.start(
|
|
TX_SWITCHOFF_DELAY); // end-of-transmission sequencer delay stopTx2
|
|
monitor(true);
|
|
statusUpdate();
|
|
}
|
|
|
|
/**
|
|
* stopTx2 is called from stopTx to open the PTT
|
|
*/
|
|
void MainWindow::stopTx2() {
|
|
// GM8JCF: m_txFrameCount is set to the number of frames to be transmitted
|
|
// when the send button is pressed and remains at that count until the last
|
|
// frame is transmitted. So, we keep the PTT ON so long as m_txFrameCount is
|
|
// non-zero
|
|
|
|
qCDebug(mainwindow_js8) << "stopTx2 frames left" << m_txFrameCount;
|
|
|
|
// If we're holding the PTT and there are more frames to transmit, do not
|
|
// emit the PTT signal
|
|
if (!m_tune && m_config.hold_ptt() && m_txFrameCount > 0) {
|
|
return;
|
|
}
|
|
|
|
// Otherwise, emit the PTT signal
|
|
emitPTT(false);
|
|
}
|
|
|
|
void MainWindow::TxAgain() { auto_tx_mode(true); }
|
|
|
|
void MainWindow::cacheActivity(QString key) {
|
|
m_callActivityBandCache[key] = m_callActivity;
|
|
m_bandActivityBandCache[key] = m_bandActivity;
|
|
m_rxTextBandCache[key] = ui->textEditRX->toHtml();
|
|
m_heardGraphIncomingBandCache[key] = m_heardGraphIncoming;
|
|
m_heardGraphOutgoingBandCache[key] = m_heardGraphOutgoing;
|
|
}
|
|
|
|
void MainWindow::restoreActivity(QString key) {
|
|
if (m_callActivityBandCache.contains(key)) {
|
|
m_callActivity = m_callActivityBandCache[key];
|
|
}
|
|
|
|
if (m_bandActivityBandCache.contains(key)) {
|
|
m_bandActivity = m_bandActivityBandCache[key];
|
|
}
|
|
|
|
if (m_rxTextBandCache.contains(key)) {
|
|
ui->textEditRX->setHtml(m_rxTextBandCache[key]);
|
|
}
|
|
|
|
if (m_heardGraphIncomingBandCache.contains(key)) {
|
|
m_heardGraphIncoming = m_heardGraphIncomingBandCache[key];
|
|
}
|
|
|
|
if (m_heardGraphOutgoingBandCache.contains(key)) {
|
|
m_heardGraphOutgoing = m_heardGraphOutgoingBandCache[key];
|
|
}
|
|
|
|
displayActivity(true);
|
|
}
|
|
|
|
void MainWindow::clearActivity() {
|
|
qCDebug(mainwindow_js8) << "clear activity";
|
|
|
|
m_callSeenHeartbeat.clear();
|
|
m_compoundCallCache.clear();
|
|
m_rxCallCache.clear();
|
|
m_rxCallQueue.clear();
|
|
m_rxRecentCache.clear();
|
|
m_rxDirectedCache.clear();
|
|
m_rxCommandQueue.clear();
|
|
m_lastTxMessage.clear();
|
|
|
|
refreshInboxCounts();
|
|
resetTimeDeltaAverage();
|
|
|
|
clearBandActivity();
|
|
clearRXActivity();
|
|
clearCallActivity();
|
|
|
|
displayActivity(true);
|
|
}
|
|
|
|
void MainWindow::clearBandActivity() {
|
|
qCDebug(mainwindow_js8) << "clear band activity";
|
|
m_bandActivity.clear();
|
|
ui->tableWidgetRXAll->setRowCount(0);
|
|
|
|
resetTimeDeltaAverage();
|
|
displayBandActivity();
|
|
}
|
|
|
|
void MainWindow::clearRXActivity() {
|
|
qCDebug(mainwindow_js8) << "clear rx activity";
|
|
|
|
m_rxFrameBlockNumbers.clear();
|
|
m_rxActivityQueue.clear();
|
|
|
|
ui->textEditRX->clear();
|
|
|
|
// make sure to clear the read only and transmitting flags so there's always
|
|
// a "way out"
|
|
ui->extFreeTextMsgEdit->clear();
|
|
ui->extFreeTextMsgEdit->setReadOnly(false);
|
|
update_dynamic_property(ui->extFreeTextMsgEdit, "transmitting", false);
|
|
}
|
|
|
|
void MainWindow::clearCallActivity() {
|
|
qCDebug(mainwindow_js8) << "clear call activity";
|
|
|
|
m_callActivity.clear();
|
|
|
|
m_heardGraphIncoming.clear();
|
|
m_heardGraphOutgoing.clear();
|
|
|
|
ui->tableWidgetCalls->setRowCount(0);
|
|
|
|
bool showIconColumn = false;
|
|
createGroupCallsignTableRows(ui->tableWidgetCalls, "", showIconColumn);
|
|
|
|
resetTimeDeltaAverage();
|
|
displayCallActivity();
|
|
}
|
|
|
|
void MainWindow::createGroupCallsignTableRows(QTableWidget *table,
|
|
QString const &selectedCall,
|
|
bool &showIconColumn) {
|
|
int count = 0;
|
|
auto now = DriftingDateTime::currentDateTimeUtc();
|
|
int callsignAging = m_config.callsign_aging();
|
|
|
|
int startCol = 1;
|
|
|
|
foreach (auto cd, m_callActivity.values()) {
|
|
if (cd.call.trimmed().isEmpty()) {
|
|
continue;
|
|
}
|
|
if (callsignAging &&
|
|
cd.utcTimestamp.secsTo(now) / 60 >= callsignAging) {
|
|
continue;
|
|
}
|
|
count++;
|
|
}
|
|
|
|
table->horizontalHeaderItem(startCol)->setText(
|
|
count == 0 ? columnLabel("Callsigns")
|
|
: QString(columnLabel("Callsigns (%1)")).arg(count));
|
|
|
|
if (!m_config.avoid_allcall()) {
|
|
table->insertRow(table->rowCount());
|
|
|
|
auto emptyItem = new QTableWidgetItem("");
|
|
emptyItem->setData(Qt::UserRole, QVariant("@ALLCALL"));
|
|
table->setItem(table->rowCount() - 1, 0, emptyItem);
|
|
|
|
auto item = new QTableWidgetItem(QString("@ALLCALL"));
|
|
item->setData(Qt::UserRole, QVariant("@ALLCALL"));
|
|
|
|
table->setItem(table->rowCount() - 1, startCol, item);
|
|
table->setSpan(table->rowCount() - 1, startCol, 1,
|
|
table->columnCount());
|
|
if (selectedCall == "@ALLCALL") {
|
|
table->item(table->rowCount() - 1, 0)->setSelected(true);
|
|
table->item(table->rowCount() - 1, startCol)->setSelected(true);
|
|
}
|
|
}
|
|
|
|
auto groups = m_config.my_groups().values();
|
|
std::sort(groups.begin(), groups.end());
|
|
foreach (auto group, groups) {
|
|
int col = 0;
|
|
table->insertRow(table->rowCount());
|
|
|
|
bool hasMessage = m_rxInboxCountCache.value(group, 0) > 0;
|
|
|
|
auto iconItem = new QTableWidgetItem(hasMessage ? "\u2691" : "");
|
|
iconItem->setData(Qt::UserRole, QVariant(group));
|
|
iconItem->setToolTip(hasMessage ? "Message Available" : "");
|
|
iconItem->setTextAlignment(Qt::AlignHCenter | Qt::AlignVCenter);
|
|
table->setItem(table->rowCount() - 1, col++, iconItem);
|
|
if (hasMessage) {
|
|
showIconColumn = true;
|
|
}
|
|
|
|
auto item = new QTableWidgetItem(group);
|
|
item->setData(Qt::UserRole, QVariant(group));
|
|
item->setToolTip(generateCallDetail(group));
|
|
table->setItem(table->rowCount() - 1, col, item);
|
|
table->setSpan(table->rowCount() - 1, col, 1, table->columnCount());
|
|
|
|
if (selectedCall == group) {
|
|
table->item(table->rowCount() - 1, 0)->setSelected(true);
|
|
table->item(table->rowCount() - 1, col)->setSelected(true);
|
|
}
|
|
}
|
|
}
|
|
|
|
void MainWindow::displayTextForFreq(QString text, int freq, QDateTime date,
|
|
bool isTx, bool isNewLine, bool isLast) {
|
|
int lowFreq = freq / 10 * 10;
|
|
int highFreq = lowFreq + 10;
|
|
|
|
int block = -1;
|
|
|
|
if (m_rxFrameBlockNumbers.contains(freq)) {
|
|
block = m_rxFrameBlockNumbers[freq];
|
|
} else if (m_rxFrameBlockNumbers.contains(lowFreq)) {
|
|
block = m_rxFrameBlockNumbers[lowFreq];
|
|
freq = lowFreq;
|
|
} else if (m_rxFrameBlockNumbers.contains(highFreq)) {
|
|
block = m_rxFrameBlockNumbers[highFreq];
|
|
freq = highFreq;
|
|
}
|
|
|
|
qCDebug(mainwindow_js8) << "existing block?" << block << freq;
|
|
|
|
if (isNewLine) {
|
|
m_rxFrameBlockNumbers.remove(freq);
|
|
m_rxFrameBlockNumbers.remove(lowFreq);
|
|
m_rxFrameBlockNumbers.remove(highFreq);
|
|
block = -1;
|
|
}
|
|
|
|
block = writeMessageTextToUI(date, text, freq, isTx, block);
|
|
|
|
// never cache tx or last lines
|
|
if (/*isTx || */ isLast) {
|
|
// reset the cache so we're always progressing forward
|
|
m_rxFrameBlockNumbers.clear();
|
|
} else {
|
|
m_rxFrameBlockNumbers.insert(freq, block);
|
|
m_rxFrameBlockNumbers.insert(lowFreq, block);
|
|
m_rxFrameBlockNumbers.insert(highFreq, block);
|
|
}
|
|
}
|
|
|
|
void MainWindow::writeNoticeTextToUI(QDateTime date, QString text) {
|
|
auto c = ui->textEditRX->textCursor();
|
|
c.movePosition(QTextCursor::End);
|
|
if (c.block().length() > 1) {
|
|
c.insertBlock();
|
|
}
|
|
|
|
text = text.toHtmlEscaped();
|
|
c.insertBlock();
|
|
c.insertHtml(QString("<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(" ", " ");
|
|
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() &&
|
|
m_messageBuffer.isEmpty() &&
|
|
(!m_config.heartbeat_qso_pause() ||
|
|
m_prevSelectedCallsign.isEmpty());
|
|
}
|
|
|
|
void MainWindow::updateModeButtonText() {
|
|
auto multi = ui->actionModeMultiDecoder->isChecked();
|
|
auto autoreply = ui->actionModeAutoreply->isChecked();
|
|
auto heartbeat =
|
|
ui->actionModeJS8HB->isEnabled() && ui->actionModeJS8HB->isChecked();
|
|
|
|
auto modeText = JS8::Submode::name(m_nSubMode);
|
|
if (multi) {
|
|
modeText += QString("+MULTI");
|
|
}
|
|
|
|
if (autoreply) {
|
|
if (m_config.autoreply_confirmation()) {
|
|
modeText += QString("+AUTO+CONF");
|
|
} else {
|
|
modeText += QString("+AUTO");
|
|
}
|
|
}
|
|
|
|
if (heartbeat) {
|
|
if (presentlyWantHBReplies()) {
|
|
modeText += QString("+HB+ACK");
|
|
} else {
|
|
modeText += QString("+HB");
|
|
}
|
|
}
|
|
|
|
ui->modeButton->setText(modeText);
|
|
}
|
|
|
|
void MainWindow::updateButtonDisplay() {
|
|
bool isTransmitting = isMessageQueuedForTransmit();
|
|
|
|
auto selectedCallsign = callsignSelected(true);
|
|
bool emptyCallsign = selectedCallsign.isEmpty();
|
|
bool emptyInfo = m_config.my_info().isEmpty();
|
|
bool emptyStatus = m_config.my_status().isEmpty();
|
|
|
|
bool previous_hbButtonisLongterm = m_hbButtonIsLongterm;
|
|
m_hbButtonIsLongterm = false;
|
|
ui->hbMacroButton->setDisabled(isTransmitting);
|
|
m_hbButtonIsLongterm = previous_hbButtonisLongterm;
|
|
|
|
bool previous_cqButtonisLongterm = m_cqButtonIsLongterm;
|
|
m_cqButtonIsLongterm = false;
|
|
ui->cqMacroButton->setDisabled(isTransmitting);
|
|
m_cqButtonIsLongterm = previous_cqButtonisLongterm;
|
|
|
|
ui->replyMacroButton->setDisabled(isTransmitting || emptyCallsign);
|
|
ui->snrMacroButton->setDisabled(isTransmitting || emptyCallsign);
|
|
ui->infoMacroButton->setDisabled(isTransmitting || emptyInfo);
|
|
ui->statusMacroButton->setDisabled(isTransmitting || emptyStatus);
|
|
ui->macrosMacroButton->setDisabled(isTransmitting);
|
|
ui->queryButton->setDisabled(isTransmitting || emptyCallsign);
|
|
ui->deselectButton->setDisabled(isTransmitting || emptyCallsign);
|
|
ui->queryButton->setText(
|
|
emptyCallsign ? "Directed"
|
|
: QString("Directed to %1").arg(selectedCallsign));
|
|
|
|
// update mode button text
|
|
updateModeButtonText();
|
|
}
|
|
|
|
void MainWindow::updateHBButtonDisplay() {
|
|
if (m_hb_loop->isActive()) {
|
|
QDateTime now = DriftingDateTime::currentDateTimeUtc();
|
|
QDateTime nextHeartbeat = m_hb_loop->nextActivity();
|
|
long secs = std::lround(now.msecsTo(nextHeartbeat) / 1000.0);
|
|
|
|
QString hbBase = presentlyWantHBReplies() ? "HB + ACK" : "HB";
|
|
|
|
if (secs > 0) {
|
|
ui->hbMacroButton->setText(
|
|
QString("%1 (%2)").arg(hbBase).arg(secs));
|
|
} else {
|
|
// Dead code?
|
|
ui->hbMacroButton->setText(QString("%1 (now)").arg(hbBase));
|
|
}
|
|
} else {
|
|
if (presentlyWantHBReplies()) {
|
|
ui->hbMacroButton->setText("HB + ACK");
|
|
} else {
|
|
ui->hbMacroButton->setText("HB");
|
|
}
|
|
}
|
|
}
|
|
|
|
void MainWindow::updateCQButtonDisplay() {
|
|
if (m_cq_loop->isActive()) {
|
|
QDateTime now = DriftingDateTime::currentDateTimeUtc();
|
|
QDateTime nextCQ = m_cq_loop->nextActivity();
|
|
long secs = std::lround(now.msecsTo(nextCQ) / 1000.0);
|
|
// qCDebug(mainwindow_js8)
|
|
// << "updateCQButtonDisplay, signal due at" << nextCQ
|
|
// << "so" << secs << "s to go";
|
|
if (secs > 0) {
|
|
ui->cqMacroButton->setText(QString("CQ (%1)").arg(secs));
|
|
} else {
|
|
// Dead code?
|
|
ui->cqMacroButton->setText("CQ (now)");
|
|
}
|
|
} else {
|
|
ui->cqMacroButton->setText("CQ");
|
|
// qCDebug(mainwindow_js8) << "updateCQButtonDisplay while m_cq_loop is
|
|
// off";
|
|
}
|
|
}
|
|
|
|
void MainWindow::updateTextDisplay() {
|
|
bool canTransmit = ensureCanTransmit();
|
|
bool isTransmitting = isMessageQueuedForTransmit();
|
|
bool emptyText = ui->extFreeTextMsgEdit->toPlainText().isEmpty();
|
|
|
|
ui->startTxButton->setDisabled(!canTransmit || isTransmitting || emptyText);
|
|
|
|
if (m_txTextDirty) {
|
|
// debounce frame and word count
|
|
if (m_txTextDirtyDebounce.isActive()) {
|
|
m_txTextDirtyDebounce.stop();
|
|
}
|
|
m_txTextDirtyDebounce.setSingleShot(true);
|
|
m_txTextDirtyDebounce.start(100);
|
|
m_txTextDirty = false;
|
|
}
|
|
}
|
|
|
|
#if __APPLE__
|
|
#define USE_SYNC_FRAME_COUNT 0
|
|
#else
|
|
#define USE_SYNC_FRAME_COUNT 0
|
|
#endif
|
|
|
|
void MainWindow::refreshTextDisplay() {
|
|
qCDebug(mainwindow_js8) << "refreshing text display...";
|
|
auto text = ui->extFreeTextMsgEdit->toPlainText();
|
|
|
|
#if USE_SYNC_FRAME_COUNT
|
|
auto frames = buildMessageFrames(text);
|
|
|
|
QStringList textList;
|
|
qCDebug(mainwindow_js8) << "frames:";
|
|
foreach (auto frame, frames) {
|
|
auto dt = DecodedText(frame.first, frame.second);
|
|
qCDebug(mainwindow_js8) << "->" << frame << dt.message()
|
|
<< Varicode::frameTypeString(dt.frameType());
|
|
textList.append(dt.message());
|
|
}
|
|
|
|
auto transmitText = textList.join("");
|
|
auto count = frames.length();
|
|
|
|
// ugh...i hate these globals
|
|
m_txTextDirtyLastSelectedCall = callsignSelected(true);
|
|
m_txTextDirtyLastText = text;
|
|
m_txFrameCountEstimate = count;
|
|
m_txTextDirty = false;
|
|
|
|
updateTextWordCheckerDisplay();
|
|
updateTextStatsDisplay(transmitText, count);
|
|
updateTxButtonDisplay();
|
|
|
|
#else
|
|
// prepare selected callsign for directed message
|
|
QString selectedCall = callsignSelected();
|
|
|
|
// prepare compound
|
|
QString mycall = m_config.my_callsign();
|
|
QString mygrid = m_config.my_grid().left(4);
|
|
bool forceIdentify = !m_config.avoid_forced_identify();
|
|
bool forceData = false;
|
|
|
|
BuildMessageFramesThread *t =
|
|
new BuildMessageFramesThread(mycall, mygrid, selectedCall, text,
|
|
forceIdentify, forceData, m_nSubMode);
|
|
|
|
connect(t, &BuildMessageFramesThread::finished, t, &QObject::deleteLater);
|
|
connect(t, &BuildMessageFramesThread::resultReady, this,
|
|
[this, text](QString transmitText, int frames) {
|
|
// ugh...i hate these globals
|
|
m_txTextDirtyLastSelectedCall = callsignSelected(true);
|
|
m_txTextDirtyLastText = text;
|
|
m_txFrameCountEstimate = frames;
|
|
m_txTextDirty = false;
|
|
|
|
updateTextWordCheckerDisplay();
|
|
updateTextStatsDisplay(transmitText, m_txFrameCountEstimate);
|
|
updateTxButtonDisplay();
|
|
});
|
|
t->start();
|
|
#endif
|
|
}
|
|
|
|
void MainWindow::updateTextWordCheckerDisplay() {
|
|
if (!m_config.spellcheck()) {
|
|
return;
|
|
}
|
|
|
|
JSCChecker::checkRange(ui->extFreeTextMsgEdit, 0, -1);
|
|
}
|
|
|
|
void MainWindow::updateTextStatsDisplay(QString text, int count) {
|
|
const double fpm = 60.0 / m_TRperiod;
|
|
if (count > 0) {
|
|
auto words = text.split(" ", Qt::SkipEmptyParts).length();
|
|
auto wpm = QString::number(words / (count / fpm), 'f', 1);
|
|
auto cpm = QString::number(text.length() / (count / fpm), 'f', 1);
|
|
wpm_label.setText(QString("%1wpm / %2cpm").arg(wpm).arg(cpm));
|
|
wpm_label.setVisible(true);
|
|
} else {
|
|
wpm_label.setVisible(false);
|
|
wpm_label.clear();
|
|
}
|
|
}
|
|
|
|
void MainWindow::updateTxButtonDisplay() {
|
|
// can we transmit at all?
|
|
bool canTransmit = ensureCanTransmit();
|
|
|
|
// if we're tuning or have a message queued
|
|
if (m_tune || isMessageQueuedForTransmit()) {
|
|
int count = m_txFrameCount;
|
|
int left = m_txFrameQueue.count();
|
|
int sent = count - left;
|
|
QString buttonText;
|
|
if (m_tune) {
|
|
buttonText = State::Tuning.toString();
|
|
} else if (m_transmitting) {
|
|
buttonText =
|
|
State::timed(State::Sending, ((left + 1) * m_TRperiod) -
|
|
((m_sec0 + 1) % m_TRperiod));
|
|
} else {
|
|
buttonText = State::timed(
|
|
State::Ready, sent == 1 ? ((left + 1) * m_TRperiod)
|
|
: (((left + 2) * m_TRperiod) -
|
|
((m_sec0 + 1) % m_TRperiod)));
|
|
}
|
|
ui->startTxButton->setText(buttonText);
|
|
ui->startTxButton->setEnabled(false);
|
|
ui->startTxButton->setFlat(true);
|
|
} else {
|
|
QString const buttonText =
|
|
m_txFrameCountEstimate > 0
|
|
? State::timed(State::Send, m_txFrameCountEstimate * m_TRperiod)
|
|
: State::Send.toString();
|
|
ui->startTxButton->setText(buttonText);
|
|
ui->startTxButton->setEnabled(canTransmit &&
|
|
m_txFrameCountEstimate > 0);
|
|
ui->startTxButton->setFlat(false);
|
|
}
|
|
}
|
|
|
|
QString MainWindow::callsignSelected(bool) {
|
|
if (!ui->tableWidgetCalls->selectedItems().isEmpty()) {
|
|
auto selectedCalls = ui->tableWidgetCalls->selectedItems();
|
|
if (!selectedCalls.isEmpty()) {
|
|
auto call = selectedCalls.first()->data(Qt::UserRole).toString();
|
|
if (!call.isEmpty()) {
|
|
return call;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!ui->tableWidgetRXAll->selectedItems().isEmpty()) {
|
|
int selectedOffset = -1;
|
|
auto selectedItems = ui->tableWidgetRXAll->selectedItems();
|
|
selectedOffset = selectedItems.first()->data(Qt::UserRole).toInt();
|
|
|
|
int threshold = 0;
|
|
auto activity = m_bandActivity.value(selectedOffset);
|
|
if (!activity.isEmpty()) {
|
|
threshold = JS8::Submode::rxThreshold(activity.last().submode);
|
|
}
|
|
|
|
auto keys = m_callActivity.keys();
|
|
|
|
std::stable_sort(keys.begin(), keys.end(),
|
|
[this](QString const &lhs, QString const &rhs) {
|
|
auto const lhsTS =
|
|
m_callActivity[lhs].utcTimestamp;
|
|
auto const rhsTS =
|
|
m_callActivity[rhs].utcTimestamp;
|
|
|
|
return lhsTS == rhsTS ? lhs < rhs : rhsTS < lhsTS;
|
|
});
|
|
|
|
// Return the first callsign at a frequency within the
|
|
// threshold limit of the selected offset, if any.
|
|
|
|
auto const offsetLo = selectedOffset - threshold;
|
|
auto const offsetHi = selectedOffset + threshold;
|
|
|
|
for (auto const &key : keys) {
|
|
if (auto const &d = m_callActivity[key];
|
|
offsetLo <= d.offset && d.offset <= offsetHi) {
|
|
return d.call;
|
|
}
|
|
}
|
|
}
|
|
|
|
#if ALLOW_USE_INPUT_TEXT_CALLSIGN
|
|
if (useInputText) {
|
|
auto text = ui->extFreeTextMsgEdit->toPlainText().left(
|
|
11); // Maximum callsign is 6 + / + 4 = 11 characters
|
|
auto calls = Varicode::parseCallsigns(text);
|
|
if (!calls.isEmpty() && text.startsWith(calls.first()) &&
|
|
calls.first() != m_config.my_callsign()) {
|
|
return calls.first();
|
|
}
|
|
}
|
|
#endif
|
|
|
|
return QString();
|
|
}
|
|
|
|
void MainWindow::callsignSelectedChanged(QString /*old*/,
|
|
QString selectedCall) {
|
|
auto placeholderText =
|
|
QString("Type your outgoing messages here.").toUpper();
|
|
if (selectedCall.isEmpty()) {
|
|
// try to restore hb
|
|
if (m_hbPaused) {
|
|
ui->hbMacroButton->setChecked(true);
|
|
m_hbPaused = false;
|
|
}
|
|
} else {
|
|
placeholderText =
|
|
QString("Type your outgoing directed message to %1 here.")
|
|
.arg(selectedCall)
|
|
.toUpper();
|
|
|
|
// when we select a callsign, use it as the qso start time
|
|
if (!m_callSelectedTime.contains(selectedCall)) {
|
|
m_callSelectedTime[selectedCall] =
|
|
DriftingDateTime::currentDateTimeUtc();
|
|
}
|
|
|
|
if (m_config.heartbeat_qso_pause()) {
|
|
// TODO: jsherer - HB issue
|
|
// don't hb if we select a callsign... (but we should keep track so
|
|
// if we deselect, we restore our hb)
|
|
if (ui->hbMacroButton->isChecked()) {
|
|
qCDebug(mainwindow_js8)
|
|
<< "Unchecking hbMacroButton after selection"
|
|
<< selectedCall << "but planning to resurrect later";
|
|
ui->hbMacroButton->setChecked(false);
|
|
m_hbPaused = true;
|
|
}
|
|
|
|
// don't cq if we select a callsign... (and it will not be restored
|
|
// otherwise)
|
|
if (ui->cqMacroButton->isChecked()) {
|
|
qCDebug(mainwindow_js8)
|
|
<< "Unchecking cqMacroButton after selection"
|
|
<< selectedCall;
|
|
ui->cqMacroButton->setChecked(false);
|
|
}
|
|
}
|
|
}
|
|
ui->extFreeTextMsgEdit->setPlaceholderText(placeholderText);
|
|
|
|
#if SHOW_CALL_DETAIL_BROWSER
|
|
auto html = generateCallDetail(selectedCall);
|
|
ui->callDetailTextBrowser->setHtml(html);
|
|
ui->callDetailTextBrowser->setVisible(
|
|
!selectedCall.isEmpty() && (!hearing.isEmpty() || !heardby.isEmpty()));
|
|
#endif
|
|
|
|
m_prevSelectedCallsign = selectedCall;
|
|
|
|
// immediately update the display
|
|
updateButtonDisplay();
|
|
updateTextDisplay();
|
|
statusChanged();
|
|
}
|
|
|
|
void MainWindow::clearCallsignSelected() {
|
|
// remove the date cache
|
|
m_callSelectedTime.remove(m_prevSelectedCallsign);
|
|
|
|
// remove the callsign selection
|
|
ui->tableWidgetCalls->clearSelection();
|
|
ui->tableWidgetRXAll->clearSelection();
|
|
}
|
|
|
|
bool MainWindow::isRecentOffset(int submode, int offset) {
|
|
if (abs(offset - freq()) <= JS8::Submode::rxThreshold(submode)) {
|
|
return true;
|
|
}
|
|
return (m_rxRecentCache.contains(offset / 10 * 10) &&
|
|
m_rxRecentCache[offset / 10 * 10]->secsTo(
|
|
DriftingDateTime::currentDateTimeUtc()) < 120);
|
|
}
|
|
|
|
void MainWindow::markOffsetRecent(int offset) {
|
|
m_rxRecentCache.insert(
|
|
offset / 10 * 10, new QDateTime(DriftingDateTime::currentDateTimeUtc()),
|
|
10);
|
|
m_rxRecentCache.insert(
|
|
offset / 10 * 10 + 10,
|
|
new QDateTime(DriftingDateTime::currentDateTimeUtc()), 10);
|
|
}
|
|
|
|
bool MainWindow::isDirectedOffset(int offset, bool *pIsAllCall) {
|
|
bool isDirected = (m_rxDirectedCache.contains(offset / 10 * 10) &&
|
|
m_rxDirectedCache[offset / 10 * 10]->date.secsTo(
|
|
DriftingDateTime::currentDateTimeUtc()) < 120);
|
|
|
|
if (isDirected && pIsAllCall) {
|
|
*pIsAllCall = m_rxDirectedCache[offset / 10 * 10]->isAllcall;
|
|
}
|
|
|
|
return isDirected;
|
|
}
|
|
|
|
void MainWindow::markOffsetDirected(int offset, bool isAllCall) {
|
|
CachedDirectedType *d1 = new CachedDirectedType{
|
|
isAllCall, DriftingDateTime::currentDateTimeUtc()};
|
|
CachedDirectedType *d2 = new CachedDirectedType{
|
|
isAllCall, DriftingDateTime::currentDateTimeUtc()};
|
|
m_rxDirectedCache.insert(offset / 10 * 10, d1, 10);
|
|
m_rxDirectedCache.insert(offset / 10 * 10 + 10, d2, 10);
|
|
}
|
|
|
|
void MainWindow::clearOffsetDirected(int offset) {
|
|
m_rxDirectedCache.remove(offset / 10 * 10);
|
|
m_rxDirectedCache.remove(offset / 10 * 10 + 10);
|
|
}
|
|
|
|
bool MainWindow::isMyCallIncluded(const QString &text) {
|
|
QString myCall = Radio::base_callsign(m_config.my_callsign());
|
|
|
|
if (myCall.isEmpty()) {
|
|
return false;
|
|
}
|
|
|
|
if (!text.contains(myCall)) {
|
|
return false;
|
|
}
|
|
|
|
auto calls = Varicode::parseCallsigns(text);
|
|
return calls.contains(myCall) || calls.contains(m_config.my_callsign());
|
|
}
|
|
|
|
bool MainWindow::isAllCallIncluded(const QString &text) {
|
|
return text.contains("@ALLCALL") || text.contains("@HB");
|
|
}
|
|
|
|
bool MainWindow::isGroupCallIncluded(const QString &text) {
|
|
return m_config.my_groups().contains(text);
|
|
}
|
|
|
|
void MainWindow::processActivity(bool force) {
|
|
if (!m_rxDirty && !force) {
|
|
return;
|
|
}
|
|
|
|
// Recent Rx Activity
|
|
processRxActivity();
|
|
|
|
// Process Idle Activity
|
|
processIdleActivity();
|
|
|
|
// Grouped Compound Activity
|
|
processCompoundActivity();
|
|
|
|
// Buffered Activity
|
|
processBufferedActivity();
|
|
|
|
// Command Activity
|
|
processCommandActivity();
|
|
|
|
// Process PSKReporter Spots
|
|
processSpots();
|
|
|
|
m_rxDirty = false;
|
|
}
|
|
|
|
void MainWindow::resetTimeDeltaAverage() {
|
|
m_driftMsMMA = 0;
|
|
m_driftMsMMA_N = 0;
|
|
}
|
|
|
|
void MainWindow::setDrift(int n) { DriftingDateTime::setDrift(n); }
|
|
|
|
void MainWindow::processIdleActivity() {
|
|
auto const now = DriftingDateTime::currentDateTimeUtc();
|
|
|
|
// if we detect an idle offset, insert an ellipsis into the activity queue
|
|
// and band activity
|
|
|
|
for (auto [offset, activity] : m_bandActivity.asKeyValueRange()) {
|
|
if (activity.isEmpty())
|
|
continue;
|
|
|
|
auto const last = activity.last();
|
|
|
|
if ((last.bits & Varicode::JS8CallLast) == Varicode::JS8CallLast)
|
|
continue;
|
|
if (last.text == m_config.mfi())
|
|
continue;
|
|
if (last.utcTimestamp.secsTo(now) <
|
|
JS8::Submode::period(last.submode) * 1.50)
|
|
continue;
|
|
|
|
ActivityDetail d = {};
|
|
d.text = m_config.mfi();
|
|
d.utcTimestamp = last.utcTimestamp;
|
|
d.snr = last.snr;
|
|
d.tdrift = last.tdrift;
|
|
d.dial = last.dial;
|
|
d.offset = last.offset;
|
|
d.submode = last.submode;
|
|
|
|
if (hasExistingMessageBuffer(d.submode, offset, false, nullptr)) {
|
|
m_messageBuffer[offset].msgs.append(d);
|
|
}
|
|
|
|
m_rxActivityQueue.append(d);
|
|
activity.append(d);
|
|
}
|
|
}
|
|
|
|
void MainWindow::processRxActivity() {
|
|
if (m_rxActivityQueue.isEmpty()) {
|
|
return;
|
|
}
|
|
|
|
int freqOffset = freq();
|
|
|
|
qCDebug(mainwindow_js8)
|
|
<< m_messageBuffer.count() << "message buffers open";
|
|
|
|
while (!m_rxActivityQueue.isEmpty()) {
|
|
ActivityDetail d = m_rxActivityQueue.dequeue();
|
|
|
|
if (canSendNetworkMessage()) {
|
|
sendNetworkMessage(
|
|
"RX.ACTIVITY", d.text,
|
|
{{"_ID", QVariant(-1)},
|
|
{"FREQ", QVariant(d.dial + d.offset)},
|
|
{"DIAL", QVariant(d.dial)},
|
|
{"OFFSET", QVariant(d.offset)},
|
|
{"SNR", QVariant(d.snr)},
|
|
{"SPEED", QVariant(d.submode)},
|
|
{"TDRIFT", QVariant(d.tdrift)},
|
|
{"UTC", QVariant(d.utcTimestamp.toMSecsSinceEpoch())}});
|
|
}
|
|
|
|
// use the actual frequency and check its delta from our current
|
|
// frequency meaning, if our current offset is 1502 and the d.freq is
|
|
// 1492, the delta is <= 10;
|
|
bool shouldDisplay =
|
|
abs(d.offset - freqOffset) <= JS8::Submode::rxThreshold(d.submode);
|
|
|
|
int prevOffset = d.offset;
|
|
if (hasExistingMessageBuffer(d.submode, d.offset, false, &prevOffset) &&
|
|
((m_messageBuffer[prevOffset].cmd.to == m_config.my_callsign()) ||
|
|
// (isAllCallIncluded(m_messageBuffer[prevOffset].cmd.to)) ||
|
|
// // uncomment this if we want to incrementally print allcalls
|
|
(isGroupCallIncluded(m_messageBuffer[prevOffset].cmd.to)))) {
|
|
d.isBuffered = true;
|
|
shouldDisplay = true;
|
|
|
|
if (!m_messageBuffer[prevOffset].compound.isEmpty()) {
|
|
// qCDebug(mainwindow_js8) << "should display compound too
|
|
// because at this point it hasn't been displayed" <<
|
|
// m_messageBuffer[prevOffset].compound.last().call;
|
|
|
|
auto lastCompound = m_messageBuffer[prevOffset].compound.last();
|
|
|
|
// fixup compound call incremental text
|
|
d.text = QString("%1: %2").arg(lastCompound.call).arg(d.text);
|
|
d.utcTimestamp =
|
|
qMin(d.utcTimestamp, lastCompound.utcTimestamp);
|
|
}
|
|
|
|
} else if (hasClosedExistingMessageBuffer(d.offset)) {
|
|
// incremental typeahead should just be displayed...
|
|
// TODO: should the buffer be reopened?
|
|
shouldDisplay = true;
|
|
|
|
} else if (d.isDirected && d.text.contains("<....>")) {
|
|
// if this is a _partial_ directed message, skip until the complete
|
|
// call comes through.
|
|
continue;
|
|
|
|
} else if (d.isDirected &&
|
|
(d.text.contains(": HB ") ||
|
|
d.text.contains(": @ALLCALL HB"))) { // TODO: HEARTBEAT
|
|
// if this is a heartbeat, process elsewhere...
|
|
continue;
|
|
}
|
|
|
|
// if this is the first data frame of a standard message, parse the
|
|
// first word callsigns and spot them :)
|
|
if ((d.bits & Varicode::JS8CallFirst) == Varicode::JS8CallFirst &&
|
|
!d.isDirected && !d.isCompound) {
|
|
auto calls = Varicode::parseCallsigns(d.text);
|
|
if (!calls.isEmpty()) {
|
|
auto theirCall = calls.first();
|
|
if (d.text.startsWith(theirCall) &&
|
|
d.text.mid(theirCall.length(), 1) == ":") {
|
|
CallDetail cd = {};
|
|
cd.call = theirCall;
|
|
cd.dial = d.dial;
|
|
cd.offset = d.offset;
|
|
cd.snr = d.snr;
|
|
cd.bits = d.bits;
|
|
cd.tdrift = d.tdrift;
|
|
cd.utcTimestamp = d.utcTimestamp;
|
|
cd.submode = d.submode;
|
|
logCallActivity(cd, true);
|
|
}
|
|
}
|
|
}
|
|
|
|
// TODO: incremental printing of directed messages
|
|
// Display if:
|
|
// 1) this is a directed message header "to" us and should be
|
|
// buffered... 2) or, this is a buffered message frame for a buffer with
|
|
// us as the recipient.
|
|
|
|
if (!shouldDisplay) {
|
|
continue;
|
|
}
|
|
|
|
bool isFirst =
|
|
(d.bits & Varicode::JS8CallFirst) == Varicode::JS8CallFirst;
|
|
bool isLast = (d.bits & Varicode::JS8CallLast) == Varicode::JS8CallLast;
|
|
|
|
// if we're the last message, let's display our EOT character
|
|
if (isLast) {
|
|
d.text = QString("%1 %2 ")
|
|
.arg(Varicode::rstrip(d.text))
|
|
.arg(m_config.eot());
|
|
}
|
|
|
|
// log it to the display!
|
|
displayTextForFreq(d.text, d.offset, d.utcTimestamp, false, isFirst,
|
|
isLast);
|
|
|
|
// If we've received a message to be displayed, we should no longer call
|
|
// CQ.
|
|
if (m_cq_loop->isActive()) {
|
|
qCDebug(mainwindow_js8)
|
|
<< "Canceling calling CQ loop to priorize incoming messages.";
|
|
m_cq_loop->onLoopCancel();
|
|
}
|
|
|
|
if (isLast) {
|
|
clearOffsetDirected(d.offset);
|
|
}
|
|
|
|
if (isLast && !d.isBuffered) {
|
|
// buffered commands need the rxFrameBlockNumbers cache so it can
|
|
// fixup its display all other "last" data frames can clear the
|
|
// rxFrameBlockNumbers cache so the next message will be on a new
|
|
// line.
|
|
m_rxFrameBlockNumbers.remove(d.offset);
|
|
}
|
|
}
|
|
|
|
#if 0
|
|
// TODO: this works but should also print in the rx window.
|
|
foreach(auto offset, m_bandActivity.keys()){
|
|
if(seen.contains(offset)){
|
|
continue;
|
|
}
|
|
|
|
if(m_bandActivity[offset].isEmpty()){
|
|
continue;
|
|
}
|
|
|
|
auto last = m_bandActivity[offset].last();
|
|
if((last.bits & Varicode::JS8CallLast) == Varicode::JS8CallLast){
|
|
continue;
|
|
}
|
|
|
|
auto now = DriftingDateTime::currentDateTimeUtc();
|
|
if(last.utcTimestamp.secsTo(now) < m_TRperiod){
|
|
continue;
|
|
}
|
|
|
|
ActivityDetail d = {};
|
|
d.text = " . . . ";
|
|
d.utcTimestamp = now;
|
|
d.snr = -99;
|
|
|
|
m_bandActivity[offset].append(d);
|
|
}
|
|
#endif
|
|
}
|
|
|
|
void MainWindow::processCompoundActivity() {
|
|
if (m_messageBuffer.isEmpty()) {
|
|
return;
|
|
}
|
|
|
|
// group compound callsign and directed commands together.
|
|
foreach (auto freq, m_messageBuffer.keys()) {
|
|
|
|
auto &buffer = m_messageBuffer[freq];
|
|
|
|
qCDebug(mainwindow_js8) << "-> grouping buffer for freq" << freq;
|
|
|
|
if (buffer.compound.isEmpty()) {
|
|
qCDebug(mainwindow_js8) << "-> buffer.compound is empty...skip";
|
|
continue;
|
|
}
|
|
|
|
// if we don't have an initialized command, skip...
|
|
int bits = buffer.cmd.bits;
|
|
bool validBits =
|
|
(bits == Varicode::JS8Call ||
|
|
((bits & Varicode::JS8CallFirst) == Varicode::JS8CallFirst) ||
|
|
((bits & Varicode::JS8CallLast) == Varicode::JS8CallLast) ||
|
|
((bits & Varicode::JS8CallData) == Varicode::JS8CallData));
|
|
if (!validBits) {
|
|
qCDebug(mainwindow_js8) << "-> buffer.cmd bits is invalid...skip";
|
|
continue;
|
|
}
|
|
|
|
// if we need two compound calls, but less than two have arrived...skip
|
|
if (buffer.cmd.from == "<....>" && buffer.cmd.to == "<....>" &&
|
|
buffer.compound.length() < 2) {
|
|
qCDebug(mainwindow_js8)
|
|
<< "-> buffer needs two compound, but has less...skip";
|
|
continue;
|
|
}
|
|
|
|
// if we need one compound call, but non have arrived...skip
|
|
if ((buffer.cmd.from == "<....>" || buffer.cmd.to == "<....>") &&
|
|
buffer.compound.length() < 1) {
|
|
qCDebug(mainwindow_js8)
|
|
<< "-> buffer needs one compound, but has less...skip";
|
|
continue;
|
|
}
|
|
|
|
if (buffer.cmd.from == "<....>") {
|
|
auto d = buffer.compound.dequeue();
|
|
buffer.cmd.from = d.call;
|
|
buffer.cmd.grid = d.grid;
|
|
buffer.cmd.isCompound = true;
|
|
buffer.cmd.utcTimestamp =
|
|
qMin(buffer.cmd.utcTimestamp, d.utcTimestamp);
|
|
|
|
if ((d.bits & Varicode::JS8CallLast) == Varicode::JS8CallLast) {
|
|
buffer.cmd.bits = d.bits;
|
|
}
|
|
}
|
|
|
|
if (buffer.cmd.to == "<....>") {
|
|
auto d = buffer.compound.dequeue();
|
|
buffer.cmd.to = d.call;
|
|
buffer.cmd.isCompound = true;
|
|
buffer.cmd.utcTimestamp =
|
|
qMin(buffer.cmd.utcTimestamp, d.utcTimestamp);
|
|
|
|
if ((d.bits & Varicode::JS8CallLast) == Varicode::JS8CallLast) {
|
|
buffer.cmd.bits = d.bits;
|
|
}
|
|
}
|
|
|
|
if ((buffer.cmd.bits & Varicode::JS8CallLast) !=
|
|
Varicode::JS8CallLast) {
|
|
qCDebug(mainwindow_js8) << "-> still not last message...skip";
|
|
continue;
|
|
}
|
|
|
|
// fixup the datetime with the "minimum" dt seen
|
|
// this will allow us to delete the activity lines
|
|
// when the compound buffered command comes in.
|
|
auto dt = buffer.cmd.utcTimestamp;
|
|
foreach (auto c, buffer.compound) {
|
|
dt = qMin(dt, c.utcTimestamp);
|
|
}
|
|
foreach (auto m, buffer.msgs) {
|
|
dt = qMin(dt, m.utcTimestamp);
|
|
}
|
|
buffer.cmd.utcTimestamp = dt;
|
|
|
|
qCDebug(mainwindow_js8)
|
|
<< "buffered compound command ready" << buffer.cmd.from
|
|
<< buffer.cmd.to << buffer.cmd.cmd;
|
|
|
|
m_rxCommandQueue.append(buffer.cmd);
|
|
m_messageBuffer.remove(freq);
|
|
|
|
// TODO: only if to me?
|
|
m_lastClosedMessageBufferOffset = freq;
|
|
}
|
|
}
|
|
|
|
void MainWindow::processBufferedActivity() {
|
|
if (m_messageBuffer.isEmpty())
|
|
return;
|
|
|
|
foreach (auto freq, m_messageBuffer.keys()) {
|
|
auto buffer = m_messageBuffer[freq];
|
|
|
|
// check to make sure we empty old buffers by getting the latest
|
|
// timestamp and checking to see if it's older than one minute.
|
|
auto dt = DriftingDateTime::currentDateTimeUtc().addDays(-1);
|
|
if (buffer.cmd.utcTimestamp.isValid()) {
|
|
dt = qMax(dt, buffer.cmd.utcTimestamp);
|
|
}
|
|
if (!buffer.compound.isEmpty()) {
|
|
dt = qMax(dt, buffer.compound.last().utcTimestamp);
|
|
}
|
|
if (!buffer.msgs.isEmpty()) {
|
|
dt = qMax(dt, buffer.msgs.last().utcTimestamp);
|
|
}
|
|
|
|
// if the buffer has messages older than 1 minute, and we still haven't
|
|
// closed it, let's mark it as the last frame
|
|
if (dt.secsTo(DriftingDateTime::currentDateTimeUtc()) > 60 &&
|
|
!buffer.msgs.isEmpty()) {
|
|
buffer.msgs.last().bits |= Varicode::JS8CallLast;
|
|
}
|
|
|
|
// but, if the buffer is older than 1.5 minutes, and we still haven't
|
|
// closed it, just remove it and skip
|
|
if (dt.secsTo(DriftingDateTime::currentDateTimeUtc()) > 90) {
|
|
m_messageBuffer.remove(freq);
|
|
continue;
|
|
}
|
|
|
|
// if the buffer has no messages, skip
|
|
if (buffer.msgs.isEmpty()) {
|
|
continue;
|
|
}
|
|
|
|
// if the buffered message hasn't seen the last message, skip
|
|
if ((buffer.msgs.last().bits & Varicode::JS8CallLast) !=
|
|
Varicode::JS8CallLast) {
|
|
continue;
|
|
}
|
|
|
|
QString message;
|
|
foreach (auto part, buffer.msgs) {
|
|
message.append(part.text);
|
|
}
|
|
message = Varicode::rstrip(message);
|
|
|
|
QString checksum;
|
|
|
|
bool valid = false;
|
|
|
|
if (Varicode::isCommandBuffered(buffer.cmd.cmd)) {
|
|
int checksumSize = Varicode::isCommandChecksumed(buffer.cmd.cmd);
|
|
|
|
if (checksumSize == 32) {
|
|
message = Varicode::lstrip(message);
|
|
checksum = message.right(6);
|
|
message = message.left(message.length() - 7);
|
|
valid = Varicode::checksum32Valid(checksum, message);
|
|
} else if (checksumSize == 16) {
|
|
message = Varicode::lstrip(message);
|
|
checksum = message.right(3);
|
|
message = message.left(message.length() - 4);
|
|
valid = Varicode::checksum16Valid(checksum, message);
|
|
} else if (checksumSize == 0) {
|
|
valid = true;
|
|
}
|
|
} else {
|
|
valid = true;
|
|
}
|
|
|
|
if (valid) {
|
|
buffer.cmd.bits |= Varicode::JS8CallLast;
|
|
buffer.cmd.text = message;
|
|
buffer.cmd.isBuffered = true;
|
|
m_rxCommandQueue.append(buffer.cmd);
|
|
} else {
|
|
qCDebug(mainwindow_js8)
|
|
<< "Buffered message failed checksum...discarding";
|
|
qCDebug(mainwindow_js8) << "Checksum:" << checksum;
|
|
qCDebug(mainwindow_js8) << "Message:" << message;
|
|
}
|
|
|
|
// regardless of valid or not, remove the "complete" buffered message
|
|
// from the buffer cache
|
|
m_messageBuffer.remove(freq);
|
|
m_lastClosedMessageBufferOffset = freq;
|
|
}
|
|
}
|
|
|
|
void MainWindow::processCommandActivity() {
|
|
#if 0
|
|
if (!m_txFrameQueue.isEmpty()) {
|
|
return;
|
|
}
|
|
#endif
|
|
|
|
if (m_rxCommandQueue.isEmpty()) {
|
|
return;
|
|
}
|
|
|
|
#if 0
|
|
bool processed = false;
|
|
|
|
int f = currentFreq();
|
|
#endif
|
|
|
|
auto now = DriftingDateTime::currentDateTimeUtc();
|
|
|
|
while (!m_rxCommandQueue.isEmpty()) {
|
|
auto d = m_rxCommandQueue.dequeue();
|
|
|
|
auto selectedCallsign = callsignSelected();
|
|
bool isAllCall = isAllCallIncluded(d.to);
|
|
bool isGroupCall = isGroupCallIncluded(d.to);
|
|
|
|
qCDebug(mainwindow_js8)
|
|
<< "try processing command" << d.from << d.to << d.cmd << d.dial
|
|
<< d.offset << d.grid << d.extra << isAllCall << isGroupCall;
|
|
|
|
// if we need a compound callsign but never got one...skip
|
|
if (d.from == "<....>" || d.to == "<....>") {
|
|
continue;
|
|
}
|
|
|
|
// we're only processing a subset of queries at this point
|
|
if (!Varicode::isCommandAllowed(d.cmd)) {
|
|
continue;
|
|
}
|
|
|
|
// is this to me?
|
|
bool toMe =
|
|
d.to == m_config.my_callsign().trimmed() ||
|
|
d.to == Radio::base_callsign(m_config.my_callsign()).trimmed();
|
|
|
|
// log call activity...
|
|
CallDetail cd = {};
|
|
cd.call = d.from;
|
|
cd.grid = d.grid;
|
|
cd.snr = d.snr;
|
|
cd.dial = d.dial;
|
|
cd.offset = d.offset;
|
|
cd.bits = d.bits;
|
|
cd.ackTimestamp =
|
|
d.text.contains(": ACK") || toMe ? d.utcTimestamp : QDateTime{};
|
|
cd.utcTimestamp = d.utcTimestamp;
|
|
cd.tdrift = d.tdrift;
|
|
cd.submode = d.submode;
|
|
logCallActivity(cd, true);
|
|
logHeardGraph(d.from, d.to);
|
|
|
|
// Placeholder for callbacks to be passed to confirmThenEnqueueMessage
|
|
// and enqueueMessage
|
|
Callback callback = nullptr;
|
|
|
|
// PROCESS BUFFERED HEARING FOR EVERYONE
|
|
if (d.cmd == " HEARING") {
|
|
// 1. parse callsigns
|
|
// 2. log it to the heard graph
|
|
auto calls = Varicode::parseCallsigns(d.text);
|
|
foreach (auto call, calls) {
|
|
logHeardGraph(d.from, call);
|
|
}
|
|
}
|
|
|
|
// PROCESS BUFFERED GRID FOR EVERYONE
|
|
if (d.cmd == " GRID") {
|
|
// 1. parse grids
|
|
// 2. log it to our call activity
|
|
auto grids = Varicode::parseGrids(d.text);
|
|
foreach (auto grid, grids) {
|
|
CallDetail cd = {};
|
|
cd.bits = d.bits;
|
|
cd.call = d.from;
|
|
cd.dial = d.dial;
|
|
cd.offset = d.offset;
|
|
cd.grid = grid;
|
|
cd.snr = d.snr;
|
|
cd.utcTimestamp = d.utcTimestamp;
|
|
cd.tdrift = d.tdrift;
|
|
cd.submode = d.submode;
|
|
|
|
// PROCESS GRID SPOTS TO APRSIS FOR EVERYONE
|
|
if (d.to == "@APRSIS") {
|
|
spotAprsGrid(cd.dial, cd.offset, cd.snr, cd.call, cd.grid);
|
|
}
|
|
|
|
logCallActivity(cd, true);
|
|
}
|
|
}
|
|
|
|
// PROCESS @JS8NET, @APRSIS, AND OTHER GROUP SPOTS FOR EVERYONE
|
|
if (d.to.startsWith("@")) {
|
|
spotCmd(d);
|
|
}
|
|
|
|
// PROCESS @APRSIS CMD SPOTS FOR EVERYONE
|
|
if (d.to == "@APRSIS") {
|
|
spotAprsCmd(d);
|
|
}
|
|
|
|
// PREPARE CMD TEXT STRING
|
|
QStringList textList = {
|
|
QString("%1: %2%3").arg(d.from).arg(d.to).arg(d.cmd)};
|
|
|
|
if (!d.extra.isEmpty()) {
|
|
textList.append(d.extra);
|
|
}
|
|
|
|
if (!d.text.isEmpty()) {
|
|
textList.append(d.text);
|
|
}
|
|
|
|
QString text = textList.join(" ");
|
|
bool isLast = (d.bits & Varicode::JS8CallLast) == Varicode::JS8CallLast;
|
|
if (isLast) {
|
|
// append the eot character to the text
|
|
text = QString("%1 %2 ")
|
|
.arg(Varicode::rstrip(text))
|
|
.arg(m_config.eot());
|
|
}
|
|
|
|
// log the text to directed txt log
|
|
writeMsgTxt(text, d.snr, d.offset);
|
|
|
|
// write all directed messages to api
|
|
if (canSendNetworkMessage()) {
|
|
sendNetworkMessage(
|
|
"RX.DIRECTED", text,
|
|
{{"_ID", QVariant(-1)},
|
|
{"FROM", QVariant(d.from)},
|
|
{"TO", QVariant(d.to)},
|
|
{"CMD", QVariant(d.cmd)},
|
|
{"GRID", QVariant(d.grid)},
|
|
{"EXTRA", QVariant(d.extra)},
|
|
{"TEXT", QVariant(text)},
|
|
{"FREQ", QVariant(d.dial + d.offset)},
|
|
{"DIAL", QVariant(d.dial)},
|
|
{"OFFSET", QVariant(d.offset)},
|
|
{"SNR", QVariant(d.snr)},
|
|
{"SPEED", QVariant(d.submode)},
|
|
{"TDRIFT", QVariant(d.tdrift)},
|
|
{"UTC", QVariant(d.utcTimestamp.toMSecsSinceEpoch())}});
|
|
}
|
|
|
|
// we're only responding to allcalls if we are participating in the
|
|
// allcall group but, don't avoid for heartbeats...those are technically
|
|
// allcalls but are processed differently
|
|
if (isAllCall && m_config.avoid_allcall() && d.cmd != " CQ" &&
|
|
d.cmd != " HB" && d.cmd != " HEARTBEAT") {
|
|
continue;
|
|
}
|
|
|
|
// we're only responding to allcall, groupcalls, and our callsign at
|
|
// this point, so we'll end after logging the callsigns we've heard
|
|
if (!isAllCall && !toMe && !isGroupCall) {
|
|
continue;
|
|
}
|
|
|
|
ActivityDetail ad = {};
|
|
ad.isLowConfidence = false;
|
|
ad.isDirected = true;
|
|
ad.bits = d.bits;
|
|
ad.dial = d.dial;
|
|
ad.offset = d.offset;
|
|
ad.snr = d.snr;
|
|
ad.text = text;
|
|
ad.utcTimestamp = d.utcTimestamp;
|
|
|
|
// we'd be double printing here if were on frequency, so let's be
|
|
// "smart" about this...
|
|
bool shouldDisplay = true;
|
|
|
|
// don't display ping allcalls
|
|
if (isAllCall && (d.cmd != " " || ad.text.contains("@HB HEARTBEAT"))) {
|
|
shouldDisplay = false;
|
|
}
|
|
|
|
if (shouldDisplay) {
|
|
auto c = ui->textEditRX->textCursor();
|
|
c.movePosition(QTextCursor::End);
|
|
ui->textEditRX->setTextCursor(c);
|
|
|
|
// ACKs and SNRs are the most likely source of items to be
|
|
// overwritten (multiple responses at once)... so don't overwrite
|
|
// those (i.e., print each on a new line)
|
|
bool shouldOverwrite =
|
|
(!d.cmd.contains(" ACK") &&
|
|
!d.cmd.contains(" SNR")); /* && isRecentOffset(d.freq);*/
|
|
|
|
if (shouldOverwrite &&
|
|
ui->textEditRX->find(d.utcTimestamp.time().toString(),
|
|
QTextDocument::FindBackward)) {
|
|
// ... maybe we could delete the last line that had this message
|
|
// on this frequency...
|
|
c = ui->textEditRX->textCursor();
|
|
c.movePosition(QTextCursor::StartOfBlock);
|
|
c.movePosition(QTextCursor::EndOfBlock,
|
|
QTextCursor::KeepAnchor);
|
|
qCDebug(mainwindow_js8) << "should display directed message, "
|
|
"erasing last rx activity line..."
|
|
<< c.selectedText().toUpper();
|
|
c.removeSelectedText();
|
|
c.deletePreviousChar();
|
|
c.deletePreviousChar();
|
|
/*
|
|
c.deleteChar();
|
|
c.deleteChar();
|
|
*/
|
|
}
|
|
|
|
// log it to the display!
|
|
displayTextForFreq(ad.text, ad.offset, ad.utcTimestamp, false, true,
|
|
false);
|
|
|
|
/*
|
|
// and send it to the network in case we want to interact with it
|
|
from an external app... if(canSendNetworkMessage()){
|
|
sendNetworkMessage("RX.DIRECTED.ME", ad.text, {
|
|
{"_ID", QVariant(-1)},
|
|
{"FROM", QVariant(d.from)},
|
|
{"TO", QVariant(d.to)},
|
|
{"CMD", QVariant(d.cmd)},
|
|
{"GRID", QVariant(d.grid)},
|
|
{"EXTRA", QVariant(d.extra)},
|
|
{"TEXT", QVariant(d.text)},
|
|
{"FREQ", QVariant(ad.dial+ad.offset)},
|
|
{"DIAL", QVariant(ad.dial)},
|
|
{"OFFSET", QVariant(ad.offset)},
|
|
{"SNR", QVariant(ad.snr)},
|
|
{"SPEED", QVariant(ad.submode)},
|
|
{"TDRIFT", QVariant(ad.tdrift)},
|
|
{"UTC", QVariant(ad.utcTimestamp.toMSecsSinceEpoch())}
|
|
});
|
|
}
|
|
*/
|
|
|
|
if (!isAllCall) {
|
|
// If we've received a message to be displayed,
|
|
// we should no longer call CQ
|
|
if (m_cq_loop->isActive()) {
|
|
qCDebug(mainwindow_js8)
|
|
<< "Canceling CQ loop to prioritize incoming messages, "
|
|
"case II";
|
|
m_cq_loop->onLoopCancel();
|
|
}
|
|
|
|
// notification for directed message
|
|
tryNotify("directed");
|
|
}
|
|
}
|
|
|
|
// we're only responding to callsigns in our whitelist if we have one
|
|
// defined... make sure the whitelist is empty (no restrictions) or the
|
|
// from callsign or its base callsign is on it
|
|
auto whitelist = m_config.auto_whitelist();
|
|
if (!whitelist.isEmpty() &&
|
|
!(whitelist.contains(d.from) ||
|
|
whitelist.contains(Radio::base_callsign(d.from)))) {
|
|
qCDebug(mainwindow_js8)
|
|
<< "skipping command for whitelist" << d.from;
|
|
continue;
|
|
}
|
|
|
|
// we'll never reply to a blacklisted callsign or base callsign
|
|
auto blacklist = m_config.auto_blacklist();
|
|
if (!blacklist.isEmpty() &&
|
|
(blacklist.contains(d.from) ||
|
|
blacklist.contains(Radio::base_callsign(d.from)))) {
|
|
qCDebug(mainwindow_js8)
|
|
<< "skipping command for blacklist" << d.from;
|
|
continue;
|
|
}
|
|
|
|
// if this is an allcall, check to make sure we haven't replied to their
|
|
// allcall recently (in the past ten minutes) that way we never get
|
|
// spammed by allcalls at too high of a frequency
|
|
if (isAllCall && m_txAllcallCommandCache.contains(d.from) &&
|
|
m_txAllcallCommandCache[d.from]->secsTo(now) / 60 < 15) {
|
|
qCDebug(mainwindow_js8)
|
|
<< "skipping command for allcall timeout" << d.from;
|
|
continue;
|
|
}
|
|
|
|
// don't actually process any automatic message replies while in idle
|
|
if (m_tx_watchdog) {
|
|
qCDebug(mainwindow_js8)
|
|
<< "skipping command for idle timeout" << d.from;
|
|
continue;
|
|
}
|
|
|
|
// HACK: if this is an autoreply cmd and relay path is populated and cmd
|
|
// is not MSG or MSG TO:, then swap out the relay path
|
|
if (Varicode::isCommandAutoreply(d.cmd) && !d.relayPath.isEmpty() &&
|
|
!d.cmd.startsWith(" MSG") && !d.cmd.startsWith(" QUERY")) {
|
|
d.from = d.relayPath;
|
|
}
|
|
|
|
// construct a reply, if needed
|
|
QString reply;
|
|
int priority = PriorityNormal;
|
|
int freq = -1;
|
|
|
|
// QUERIED SNR
|
|
if (d.cmd == " SNR?" && !isAllCall) {
|
|
reply = QString("%1 SNR %2")
|
|
.arg(d.from)
|
|
.arg(Varicode::formatSNR(d.snr));
|
|
}
|
|
|
|
// QUERIED INFO
|
|
else if (d.cmd == " INFO?" && !isAllCall) {
|
|
QString info = m_config.my_info();
|
|
if (info.isEmpty()) {
|
|
continue;
|
|
}
|
|
|
|
reply = QString("%1 INFO %2")
|
|
.arg(d.from)
|
|
.arg(replaceMacros(info, buildMacroValues(), true));
|
|
}
|
|
|
|
// QUERIED ACTIVE
|
|
else if (d.cmd == " STATUS?" && !isAllCall) {
|
|
QString status = m_config.my_status();
|
|
if (status.isEmpty()) {
|
|
continue;
|
|
}
|
|
|
|
reply = QString("%1 STATUS %2")
|
|
.arg(d.from)
|
|
.arg(replaceMacros(status, buildMacroValues(), true));
|
|
}
|
|
|
|
// QUERIED GRID
|
|
else if (d.cmd == " GRID?" && !isAllCall) {
|
|
QString grid = m_config.my_grid();
|
|
if (grid.isEmpty()) {
|
|
continue;
|
|
}
|
|
|
|
reply = QString("%1 GRID %2").arg(d.from).arg(grid);
|
|
}
|
|
|
|
// QUERIED STATIONS HEARD
|
|
else if (d.cmd == " HEARING?" && !isAllCall) {
|
|
auto calls = m_callActivity.keys();
|
|
|
|
std::stable_sort(calls.begin(), calls.end(),
|
|
[this](QString const &lhs, QString const &rhs) {
|
|
return m_callActivity[rhs].utcTimestamp <
|
|
m_callActivity[lhs].utcTimestamp;
|
|
});
|
|
|
|
auto const callsignAging = m_config.callsign_aging();
|
|
auto const maxStations = 4;
|
|
auto i = 0;
|
|
QStringList lines;
|
|
|
|
for (auto const &call : calls) {
|
|
if (i >= maxStations)
|
|
break;
|
|
if (call == d.from)
|
|
continue;
|
|
|
|
auto const &cd = m_callActivity[call];
|
|
|
|
if (callsignAging &&
|
|
cd.utcTimestamp.secsTo(now) / 60 >= callsignAging) {
|
|
continue;
|
|
}
|
|
|
|
lines.append(cd.call);
|
|
i++;
|
|
}
|
|
|
|
lines.prepend(QString("%1 HEARING").arg(d.from));
|
|
reply = lines.join(' ');
|
|
}
|
|
|
|
// PROCESS RELAY
|
|
else if (d.cmd == ">" && !isAllCall && !m_config.relay_off()) {
|
|
|
|
// 1. see if there are any more hops to process
|
|
// 2. if so, forward
|
|
// 3. otherwise, display alert & reply dialog
|
|
|
|
QString callToPattern = {
|
|
R"(^(?<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
|
|
else if (d.cmd == " QUERY MSGS" &&
|
|
ui->actionModeAutoreply->isChecked()) {
|
|
auto who = d.from; // keep in mind, this is the sender, not the
|
|
// original requestor if relayed
|
|
auto replyPath = d.from;
|
|
|
|
if (d.relayPath.contains(">")) {
|
|
auto path = d.relayPath.split(">");
|
|
who = path.last();
|
|
replyPath = d.relayPath;
|
|
}
|
|
|
|
// if this is an allcall or a directed call, check to see if we have
|
|
// a stored message for user. we reply yes if the user would be able
|
|
// to retreive a stored message
|
|
auto mid = getNextMessageIdForCallsign(who);
|
|
if (mid != -1) {
|
|
reply = QString("%1 YES MSG ID %2").arg(replyPath).arg(mid);
|
|
}
|
|
|
|
// Group messaging - if isGroupCall, check to see if there's a
|
|
// message id for the group and return it if there's not an
|
|
// individual message
|
|
// TODO: include group name in response to differentiate from direct
|
|
// messages?
|
|
else if (isGroupCall) {
|
|
mid = getNextGroupMessageIdForCallsign(d.to, d.from);
|
|
if (mid != -1) {
|
|
reply = QString("%1 YES MSG ID %2").arg(replyPath).arg(mid);
|
|
}
|
|
}
|
|
|
|
// if this is not an allcall and we have no messages, reply no.
|
|
if (!isAllCall && reply.isEmpty()) {
|
|
reply = QString("%1 NO").arg(replyPath);
|
|
}
|
|
}
|
|
|
|
// PROCESS BUFFERED QUERY CALL
|
|
else if (d.cmd == " QUERY CALL" &&
|
|
ui->actionModeAutoreply->isChecked()) {
|
|
auto replyPath = d.from;
|
|
if (d.relayPath.contains(">")) {
|
|
replyPath = d.relayPath;
|
|
}
|
|
|
|
auto who = d.text;
|
|
if (who.isEmpty()) {
|
|
continue;
|
|
}
|
|
|
|
auto callsigns = Varicode::parseCallsigns(who);
|
|
if (callsigns.isEmpty()) {
|
|
continue;
|
|
}
|
|
|
|
QStringList replies;
|
|
int callsignAging = m_config.callsign_aging();
|
|
auto baseCall = callsigns.first();
|
|
foreach (auto cd, m_callActivity.values()) {
|
|
if (callsignAging &&
|
|
cd.utcTimestamp.secsTo(now) / 60 >= callsignAging) {
|
|
continue;
|
|
}
|
|
|
|
if (baseCall == cd.call ||
|
|
baseCall == Radio::base_callsign(cd.call)) {
|
|
auto r = QString("%1 (%2)")
|
|
.arg(Varicode::formatSNR(cd.snr))
|
|
.arg(since(cd.utcTimestamp))
|
|
.trimmed();
|
|
replies.append(r);
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!replies.isEmpty()) {
|
|
replies.prepend(QString("%1 YES").arg(replyPath));
|
|
}
|
|
|
|
reply = replies.join(" ");
|
|
|
|
if (!reply.isEmpty()) {
|
|
if (isAllCall) {
|
|
m_txAllcallCommandCache.insert(d.from, new QDateTime(now),
|
|
25);
|
|
}
|
|
}
|
|
}
|
|
|
|
#if 0
|
|
// PROCESS ALERT
|
|
else if (d.cmd == "!" && !isAllCall) {
|
|
|
|
// create alert dialog
|
|
processAlertReplyForCommand(d, d.from, " ");
|
|
|
|
// make sure this is explicit
|
|
continue;
|
|
}
|
|
#endif
|
|
|
|
// well, if there's no reply, don't do anything...
|
|
if (reply.isEmpty()) {
|
|
continue;
|
|
}
|
|
|
|
// do not queue @ALLCALL replies if auto-reply is not checked
|
|
if (!ui->actionModeAutoreply->isChecked() && isAllCall) {
|
|
continue;
|
|
}
|
|
|
|
#if 0
|
|
// TODO: jsherer - HB issue here
|
|
// do not queue a reply if it's a HB and HB is not active
|
|
// if((!ui->hbMacroButton->isChecked() || m_hbInterval <= 0) && d.cmd.contains("HB")){
|
|
// continue;
|
|
// }
|
|
#endif
|
|
|
|
// do not queue for reply if there's text in the window
|
|
if (!ui->extFreeTextMsgEdit->toPlainText().isEmpty()) {
|
|
continue;
|
|
}
|
|
|
|
// do not queue for reply if there's a buffer open to us
|
|
int bufferOffset = 0;
|
|
if (hasExistingMessageBufferToMe(&bufferOffset)) {
|
|
qCDebug(mainwindow_js8) << "skipping reply due to open buffer"
|
|
<< bufferOffset << m_messageBuffer.count();
|
|
continue;
|
|
}
|
|
|
|
// add @ALLCALLs to the @ALLCALL cache
|
|
if (isAllCall) {
|
|
m_txAllcallCommandCache.insert(d.from, new QDateTime(now), 25);
|
|
}
|
|
|
|
// queue the reply here to be sent when a free interval is available on
|
|
// the frequency that was sent unless, this is an allcall, to which we
|
|
// should be responding on a clear frequency offset we always want to
|
|
// make sure that the directed cache has been updated at this point so
|
|
// we have the most information available to make a frequency selection.
|
|
if (m_config.autoreply_confirmation()) {
|
|
confirmThenEnqueueMessage(90, priority, reply, freq, callback);
|
|
} else {
|
|
enqueueMessage(priority, reply, freq, callback);
|
|
}
|
|
}
|
|
}
|
|
|
|
QString MainWindow::inboxPath() {
|
|
return QDir::toNativeSeparators(
|
|
m_config.writeable_data_dir().absoluteFilePath("inbox.db3"));
|
|
}
|
|
|
|
void MainWindow::refreshInboxCounts() {
|
|
auto inbox = Inbox(inboxPath());
|
|
if (inbox.open()) {
|
|
// reset inbox counts
|
|
m_rxInboxCountCache.clear();
|
|
|
|
// compute new counts from db
|
|
auto v = inbox.values("UNREAD", "$", "%", 0, 10000);
|
|
foreach (auto pair, v) {
|
|
auto params = pair.second.params();
|
|
auto to = params.value("TO").toString();
|
|
if (to.isEmpty() ||
|
|
(to != m_config.my_callsign() &&
|
|
to != Radio::base_callsign(m_config.my_callsign()))) {
|
|
continue;
|
|
}
|
|
auto from = params.value("FROM").toString();
|
|
if (from.isEmpty()) {
|
|
continue;
|
|
}
|
|
|
|
m_rxInboxCountCache[from] = m_rxInboxCountCache.value(from, 0) + 1;
|
|
|
|
if (!m_callActivity.contains(from)) {
|
|
auto const utc = params.value("UTC").toString();
|
|
auto const snr = params.value("SNR").toInt();
|
|
auto const dial = params.value("DIAL").toInt();
|
|
auto const offset = params.value("OFFSET").toInt();
|
|
auto const tdrift = params.value("TDRIFT").toInt();
|
|
auto const submode = params.value("SUBMODE").toInt();
|
|
|
|
CallDetail cd;
|
|
cd.call = from;
|
|
cd.snr = snr;
|
|
cd.dial = dial;
|
|
cd.offset = offset;
|
|
cd.tdrift = tdrift;
|
|
cd.utcTimestamp =
|
|
QDateTime::fromString(utc, "yyyy-MM-dd hh:mm:ss");
|
|
cd.utcTimestamp.setTimeZone(QTimeZone::utc());
|
|
cd.ackTimestamp = cd.utcTimestamp;
|
|
cd.submode = submode;
|
|
logCallActivity(cd, false);
|
|
}
|
|
}
|
|
|
|
// Now handle group message counts
|
|
QMap<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 ¶ms) {
|
|
if (!canSendNetworkMessage()) {
|
|
return;
|
|
}
|
|
|
|
auto m = Message(type, message, params);
|
|
|
|
if (m_config.udpEnabled()) {
|
|
m_messageClient->send(m);
|
|
}
|
|
|
|
if (m_config.tcpEnabled()) {
|
|
m_messageServer->send(m);
|
|
}
|
|
}
|
|
|
|
void MainWindow::pskReporterError(QString const &message) {
|
|
qCDebug(mainwindow_js8) << "PSK Reporter Error:" << message;
|
|
|
|
showStatusMessage(tr("Spotting to PSK Reporter unavailable"));
|
|
}
|
|
|
|
void MainWindow::setRig(Frequency f) {
|
|
if (f) {
|
|
m_freqNominal = f;
|
|
m_freqTxNominal = m_freqNominal - m_XIT;
|
|
}
|
|
|
|
if (m_transmitting && !m_config.tx_qsy_allowed())
|
|
return;
|
|
|
|
if ((m_monitoring || m_transmitting) && m_config.transceiver_online()) {
|
|
if (m_config.split_mode()) {
|
|
Q_EMIT m_config.transceiver_tx_frequency(m_freqTxNominal);
|
|
}
|
|
|
|
Q_EMIT m_config.transceiver_frequency(m_freqNominal);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @brief Update and send station status
|
|
*
|
|
* Sends station status updates to both WSJT-X protocol clients (if enabled)
|
|
* and native JSON API clients (if not conflicting). When WSJT-X protocol
|
|
* is enabled on the same port/address as the native JSON API, the native
|
|
* JSON messages are skipped to avoid conflicts.
|
|
*/
|
|
void MainWindow::statusUpdate() {
|
|
// Send WSJT-X Status message if protocol is enabled
|
|
if (m_wsjtxMessageMapper && m_config.wsjtx_protocol_enabled()) {
|
|
QString dx_call = callsignSelected();
|
|
QString dx_grid = "";
|
|
if (!dx_call.isEmpty() && m_callActivity.contains(dx_call)) {
|
|
dx_grid = m_callActivity[dx_call].grid;
|
|
}
|
|
QString mode = JS8::Submode::name(m_nSubMode);
|
|
QString tx_message = m_transmitting ? m_currentMessage : "";
|
|
|
|
m_wsjtxMessageMapper->sendStatusUpdate(
|
|
dialFrequency(), freq(),
|
|
"JS8", // mode
|
|
dx_call, m_config.my_callsign(), m_config.my_grid(), dx_grid,
|
|
true, // tx_enabled - JS8Call always allows TX when not in special
|
|
// modes
|
|
m_transmitting,
|
|
m_decoderBusy || m_monitoring, // decoding
|
|
tx_message);
|
|
}
|
|
|
|
// Send native JSON message only if not conflicting with WSJT-X
|
|
if (canSendNetworkMessage()) {
|
|
// Don't send JSON if WSJT-X is enabled on the same port/address
|
|
bool skip_json = false;
|
|
if (m_config.wsjtx_protocol_enabled() &&
|
|
m_config.wsjtx_server_port() == m_config.udp_server_port() &&
|
|
m_config.wsjtx_server_name() == m_config.udp_server_name()) {
|
|
skip_json = true;
|
|
}
|
|
|
|
if (!skip_json) {
|
|
sendNetworkMessage("STATION.STATUS", "",
|
|
{
|
|
{"FREQ", QVariant(dialFrequency() + freq())},
|
|
{"DIAL", QVariant(dialFrequency())},
|
|
{"OFFSET", QVariant(freq())},
|
|
{"SPEED", QVariant(m_nSubMode)},
|
|
{"SELECTED", QVariant(callsignSelected())},
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
void MainWindow::childEvent(QChildEvent *e) {
|
|
if (e->child()->isWidgetType()) {
|
|
switch (e->type()) {
|
|
case QEvent::ChildAdded:
|
|
add_child_to_event_filter(e->child());
|
|
break;
|
|
case QEvent::ChildRemoved:
|
|
remove_child_from_event_filter(e->child());
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
QMainWindow::childEvent(e);
|
|
}
|
|
|
|
// add widget and any child widgets to our event filter so that we can
|
|
// take action on key press ad mouse press events anywhere in the main window
|
|
void MainWindow::add_child_to_event_filter(QObject *target) {
|
|
if (target && target->isWidgetType()) {
|
|
target->installEventFilter(this);
|
|
}
|
|
auto const &children = target->children();
|
|
for (auto iter = children.begin(); iter != children.end(); ++iter) {
|
|
add_child_to_event_filter(*iter);
|
|
}
|
|
}
|
|
|
|
// recursively remove widget and any child widgets from our event filter
|
|
void MainWindow::remove_child_from_event_filter(QObject *target) {
|
|
auto const &children = target->children();
|
|
for (auto iter = children.begin(); iter != children.end(); ++iter) {
|
|
remove_child_from_event_filter(*iter);
|
|
}
|
|
if (target && target->isWidgetType()) {
|
|
target->removeEventFilter(this);
|
|
}
|
|
}
|
|
|
|
void MainWindow::resetIdleTimer() {
|
|
if (m_idleMinutes) {
|
|
m_idleMinutes = 0;
|
|
qCDebug(mainwindow_js8) << "idle" << m_idleMinutes << "minutes";
|
|
}
|
|
}
|
|
|
|
void MainWindow::incrementIdleTimer() {
|
|
m_idleMinutes++;
|
|
qCDebug(mainwindow_js8)
|
|
<< "increment idle to" << m_idleMinutes << "minutes";
|
|
}
|
|
|
|
void MainWindow::tx_watchdog(bool triggered) {
|
|
auto prior = m_tx_watchdog;
|
|
m_tx_watchdog = triggered;
|
|
if (triggered) {
|
|
m_isTimeToSend = false;
|
|
if (m_tune)
|
|
stop_tuning();
|
|
if (m_auto)
|
|
auto_tx_mode(false);
|
|
stopTx();
|
|
tx_status_label.setStyleSheet(
|
|
"QLabel{background-color: #000000; color:#ffffff; }");
|
|
tx_status_label.setText("Idle timeout");
|
|
|
|
// if the watchdog is triggered...we're no longer active
|
|
bool wasAuto = ui->actionModeAutoreply->isChecked();
|
|
bool wasHB = ui->hbMacroButton->isChecked();
|
|
bool wasCQ = ui->cqMacroButton->isChecked();
|
|
|
|
// save the button states
|
|
ui->actionModeAutoreply->setChecked(false);
|
|
qCDebug(mainwindow_js8) << "Unchecking the hbMacroButton and "
|
|
"cqMacroButton from TX watchdog.";
|
|
ui->hbMacroButton->setChecked(false);
|
|
ui->cqMacroButton->setChecked(false);
|
|
|
|
// clear the tx queues
|
|
resetMessageTransmitQueue();
|
|
|
|
QMessageBox *msgBox = new QMessageBox(this);
|
|
msgBox->setIcon(QMessageBox::Information);
|
|
msgBox->setWindowTitle("Idle Timeout");
|
|
msgBox->setInformativeText(
|
|
QString("You have been idle for more than %1 minutes.")
|
|
.arg(m_config.watchdog()));
|
|
msgBox->addButton(QMessageBox::Ok);
|
|
|
|
connect(msgBox, &QMessageBox::finished, this,
|
|
[this, wasAuto, wasHB, wasCQ](int /*result*/) {
|
|
// restore the button states
|
|
ui->actionModeAutoreply->setChecked(wasAuto);
|
|
ui->hbMacroButton->setChecked(wasHB);
|
|
ui->cqMacroButton->setChecked(wasCQ);
|
|
|
|
this->tx_watchdog(false);
|
|
});
|
|
msgBox->setModal(true);
|
|
msgBox->show();
|
|
}
|
|
if (prior != triggered)
|
|
statusUpdate();
|
|
}
|
|
|
|
void MainWindow::write_frequency_entry(QString const &file_name) {
|
|
if (!m_config.write_logs()) {
|
|
return;
|
|
}
|
|
|
|
// Write freq changes to ALL.TXT only below 30 MHz.
|
|
QFile f2{m_config.writeable_data_dir().absoluteFilePath(file_name)};
|
|
if (f2.open(QIODevice::WriteOnly | QIODevice::Text | QIODevice::Append)) {
|
|
QTextStream out(&f2);
|
|
out << DriftingDateTime::currentDateTimeUtc().toString(
|
|
"yyyy-MM-dd hh:mm:ss")
|
|
<< " " << qSetRealNumberPrecision(12) << (m_freqNominal / 1.e6)
|
|
<< " MHz "
|
|
<< "JS8" << Qt::endl;
|
|
f2.close();
|
|
} else {
|
|
QTimer::singleShot(0, [this,
|
|
message = tr("Cannot open \"%1\" for append: %2")
|
|
.arg(f2.fileName())
|
|
.arg(f2.errorString())] {
|
|
JS8MessageBox::warning_message(this, tr("Log File Error"), message);
|
|
});
|
|
}
|
|
}
|
|
|
|
void MainWindow::write_transmit_entry(QString const &file_name) {
|
|
if (!m_config.write_logs()) {
|
|
return;
|
|
}
|
|
|
|
QFile f{m_config.writeable_data_dir().absoluteFilePath(file_name)};
|
|
if (f.open(QIODevice::WriteOnly | QIODevice::Text | QIODevice::Append)) {
|
|
QTextStream out(&f);
|
|
auto time = DriftingDateTime::currentDateTimeUtc();
|
|
time = time.addSecs(-(time.time().second() % m_TRperiod));
|
|
auto dt =
|
|
DecodedText(m_currentMessage, m_currentMessageBits, m_nSubMode);
|
|
out << time.toString("yyyy-MM-dd hh:mm:ss") << " Transmitting "
|
|
<< qSetRealNumberPrecision(12) << (m_freqNominal / 1.e6) << " MHz "
|
|
<< "JS8"
|
|
<< ": " << dt.message() << Qt::endl;
|
|
f.close();
|
|
} else {
|
|
QTimer::singleShot(0, [this,
|
|
message = tr("Cannot open \"%1\" for append: %2")
|
|
.arg(f.fileName())
|
|
.arg(f.errorString())] {
|
|
JS8MessageBox::warning_message(this, tr("Log File Error"), message);
|
|
});
|
|
}
|
|
}
|
|
|
|
void MainWindow::writeAllTxt(QStringView message) {
|
|
if (!m_config.write_logs())
|
|
return;
|
|
|
|
// Write decoded text to file "ALL.TXT".
|
|
|
|
QFile f{m_config.writeable_data_dir().absoluteFilePath("ALL.TXT")};
|
|
|
|
if (f.open(QIODevice::WriteOnly | QIODevice::Text | QIODevice::Append)) {
|
|
QTextStream out(&f);
|
|
|
|
if (m_RxLog == 1) {
|
|
out << DriftingDateTime::currentDateTimeUtc().toString(
|
|
"yyyy-MM-dd hh:mm:ss")
|
|
<< " " << qSetRealNumberPrecision(12) << (m_freqNominal / 1.e6)
|
|
<< " MHz JS8" << Qt::endl;
|
|
|
|
m_RxLog = 0;
|
|
}
|
|
|
|
out << message << Qt::endl;
|
|
|
|
f.close();
|
|
} else {
|
|
JS8MessageBox::warning_message(this, tr("File Open Error"),
|
|
tr("Cannot open \"%1\" for append: %2")
|
|
.arg(f.fileName())
|
|
.arg(f.errorString()));
|
|
}
|
|
}
|
|
|
|
void MainWindow::writeMsgTxt(QStringView message, int snr, int offset) {
|
|
if (!m_config.write_logs())
|
|
return;
|
|
|
|
// Write decoded text to file "DIRECTED.TXT".
|
|
|
|
QFile f{m_config.writeable_data_dir().absoluteFilePath("DIRECTED.TXT")};
|
|
|
|
if (f.open(QIODevice::WriteOnly | QIODevice::Text | QIODevice::Append)) {
|
|
QTextStream out(&f);
|
|
QString output = DriftingDateTime::currentDateTimeUtc().toString(
|
|
"yyyy-MM-dd hh:mm:ss") %
|
|
"\t" % Radio::frequency_MHz_string(m_freqNominal) %
|
|
"\t" % QString::number(offset) % "\t" %
|
|
Varicode::formatSNR(snr) % "\t" % message;
|
|
|
|
out << output << Qt::endl;
|
|
|
|
f.close();
|
|
} else {
|
|
JS8MessageBox::warning_message(this, tr("File Open Error"),
|
|
tr("Cannot open \"%1\" for append: %2")
|
|
.arg(f.fileName())
|
|
.arg(f.errorString()));
|
|
}
|
|
}
|
|
|
|
QByteArray MainWindow::wisdomFileName() const {
|
|
return QDir::toNativeSeparators(
|
|
m_config.writeable_data_dir().absoluteFilePath(
|
|
"js8call_wisdom.dat"))
|
|
.toLocal8Bit();
|
|
}
|
|
|
|
Q_LOGGING_CATEGORY(decoder_js8, "decoder.js8", QtWarningMsg)
|
|
Q_LOGGING_CATEGORY(mainwindow_js8, "mainwindow.js8", QtWarningMsg)
|