mirror of
https://github.com/saitohirga/WSJT-X.git
synced 2024-11-25 21:58:38 -05:00
1937612685
The UDP "Free text" message comamnd should not expose implementation differences between tab one and tab two. The "send next" capability is not available on tab two so use of it has been removed from the UDP interface. The ability to trigger the immediate (or in next Tx period if the current period is Rx) sending of the current free text message has been added to the UDP interface. This is done by sending a "Free text" UDP message containing an empty text message with the "Send" flag set. git-svn-id: svn+ssh://svn.code.sf.net/p/wsjt/wsjt/branches/wsjtx@5725 ab8295b8-cf94-4d9e-aec4-7959e3be5d79
509 lines
19 KiB
C++
509 lines
19 KiB
C++
//
|
|
// MessageAggregator - an example application that utilizes the WSJT-X
|
|
// messaging facility
|
|
//
|
|
// This application is only provided as a simple GUI application
|
|
// example to demonstrate the WSJT-X messaging facility. It allows the
|
|
// user to set the server details either as a unicast UDP server or,
|
|
// if a multicast group address is provided, as a multicast server.
|
|
// The benefit of the multicast server is that multiple servers can be
|
|
// active at once each receiving all WSJT-X broadcast messages and
|
|
// each able to respond to individual WSJT_X clients. To utilize the
|
|
// multicast group features each WSJT-X client must set the same
|
|
// multicast group address as the UDP server address for example
|
|
// 239.255.0.0 for a site local multicast group.
|
|
//
|
|
// The UI is a small panel to input the service port number and
|
|
// optionally the multicast group address. Below that a table
|
|
// representing the log entries where any QSO logged messages
|
|
// broadcast from WSJT-X clients are displayed. The bottom of the
|
|
// application main window is a dock area where a dock window will
|
|
// appear for each WSJT-X client, this window contains a table of the
|
|
// current decode messages broadcast from that WSJT-X client and a
|
|
// status line showing the status update messages broadcast from the
|
|
// WSJT_X client. The dock windows may be arranged in a tab bar, side
|
|
// by side, below each other or, completely detached from the dock
|
|
// area as floating windows. Double clicking the dock window title bar
|
|
// or dragging and dropping with the mouse allows these different
|
|
// arrangements.
|
|
//
|
|
// The application also provides a simple menu bar including a view
|
|
// menu that allows each dock window to be hidden or revealed.
|
|
//
|
|
|
|
#include <iostream>
|
|
#include <exception>
|
|
|
|
#include <QtWidgets>
|
|
#include <QFile>
|
|
#include <QStandardItemModel>
|
|
#include <QStandardItem>
|
|
#include <QSortFilterProxyModel>
|
|
#include <QFont>
|
|
#include <QDateTime>
|
|
#include <QTime>
|
|
#include <QHash>
|
|
|
|
#include "MessageServer.hpp"
|
|
#include "NetworkMessage.hpp"
|
|
|
|
#include "qt_helpers.hpp"
|
|
|
|
using port_type = MessageServer::port_type;
|
|
using Frequency = MessageServer::Frequency;
|
|
|
|
QRegExp message_alphabet {"[- A-Za-z0-9+./?]*"};
|
|
|
|
//
|
|
// Decodes Model - simple data model for all decodes
|
|
//
|
|
// The model is a basic table with uniform row format. Rows consist of
|
|
// QStandardItem instances containing the string representation of the
|
|
// column data and if the underlying field is not a string then the
|
|
// UserRole+1 role contains the underlying data item.
|
|
//
|
|
// Three slots are provided to add a new decode, remove all decodes
|
|
// for a client and, to build a reply to CQ message for a given row
|
|
// which is emitted as a signal respectively.
|
|
//
|
|
class DecodesModel
|
|
: public QStandardItemModel
|
|
{
|
|
Q_OBJECT;
|
|
|
|
public:
|
|
DecodesModel (QObject * parent = nullptr)
|
|
: QStandardItemModel {0, 7, parent}
|
|
, text_font_ {"Courier", 10}
|
|
{
|
|
setHeaderData (0, Qt::Horizontal, tr ("Client"));
|
|
setHeaderData (1, Qt::Horizontal, tr ("Time"));
|
|
setHeaderData (2, Qt::Horizontal, tr ("Snr"));
|
|
setHeaderData (3, Qt::Horizontal, tr ("DT"));
|
|
setHeaderData (4, Qt::Horizontal, tr ("DF"));
|
|
setHeaderData (5, Qt::Horizontal, tr ("Md"));
|
|
setHeaderData (6, Qt::Horizontal, tr ("Message"));
|
|
}
|
|
|
|
Q_SLOT void add_decode (bool is_new, QString const& client_id, QTime time, qint32 snr, float delta_time
|
|
, quint32 delta_frequency, QString const& mode, QString const& message)
|
|
{
|
|
if (!is_new)
|
|
{
|
|
int target_row {-1};
|
|
for (auto row = 0; row < rowCount (); ++row)
|
|
{
|
|
if (data (index (row, 0)).toString () == client_id)
|
|
{
|
|
auto row_time = item (row, 1)->data ().toTime ();
|
|
if (row_time == time
|
|
&& item (row, 2)->data ().toInt () == snr
|
|
&& item (row, 3)->data ().toFloat () == delta_time
|
|
&& item (row, 4)->data ().toUInt () == delta_frequency
|
|
&& data (index (row, 5)).toString () == mode
|
|
&& data (index (row, 6)).toString () == message)
|
|
{
|
|
return;
|
|
}
|
|
if (time <= row_time)
|
|
{
|
|
target_row = row; // last row with same time
|
|
}
|
|
}
|
|
}
|
|
if (target_row >= 0)
|
|
{
|
|
insertRow (target_row + 1, make_row (client_id, time, snr, delta_time, delta_frequency, mode, message));
|
|
return;
|
|
}
|
|
}
|
|
|
|
appendRow (make_row (client_id, time, snr, delta_time, delta_frequency, mode, message));
|
|
}
|
|
|
|
QList<QStandardItem *> make_row (QString const& client_id, QTime time, qint32 snr, float delta_time
|
|
, quint32 delta_frequency, QString const& mode, QString const& message) const
|
|
{
|
|
auto time_item = new QStandardItem {time.toString ("hh:mm")};
|
|
time_item->setData (time);
|
|
time_item->setTextAlignment (Qt::AlignRight);
|
|
|
|
auto snr_item = new QStandardItem {QString::number (snr)};
|
|
snr_item->setData (snr);
|
|
snr_item->setTextAlignment (Qt::AlignRight);
|
|
|
|
auto dt = new QStandardItem {QString::number (delta_time)};
|
|
dt->setData (delta_time);
|
|
dt->setTextAlignment (Qt::AlignRight);
|
|
|
|
auto df = new QStandardItem {QString::number (delta_frequency)};
|
|
df->setData (delta_frequency);
|
|
df->setTextAlignment (Qt::AlignRight);
|
|
|
|
auto md = new QStandardItem {mode};
|
|
md->setTextAlignment (Qt::AlignHCenter);
|
|
|
|
QList<QStandardItem *> row {
|
|
new QStandardItem {client_id}, time_item, snr_item, dt, df, md, new QStandardItem {message}};
|
|
Q_FOREACH (auto& item, row)
|
|
{
|
|
item->setEditable (false);
|
|
item->setFont (text_font_);
|
|
item->setTextAlignment (item->textAlignment () | Qt::AlignVCenter);
|
|
}
|
|
return row;
|
|
}
|
|
|
|
Q_SLOT void clear_decodes (QString const& client_id)
|
|
{
|
|
for (auto row = rowCount () - 1; row >= 0; --row)
|
|
{
|
|
if (data (index (row, 0)).toString () == client_id)
|
|
{
|
|
removeRow (row);
|
|
}
|
|
}
|
|
}
|
|
|
|
Q_SLOT void do_reply (QModelIndex const& source)
|
|
{
|
|
auto row = source.row ();
|
|
Q_EMIT reply (data (index (row, 0)).toString ()
|
|
, item (row, 1)->data ().toTime ()
|
|
, item (row, 2)->data ().toInt ()
|
|
, item (row, 3)->data ().toFloat ()
|
|
, item (row, 4)->data ().toInt ()
|
|
, data (index (row, 5)).toString ()
|
|
, data (index (row, 6)).toString ());
|
|
}
|
|
|
|
Q_SIGNAL void reply (QString const& id, QTime time, qint32 snr, float delta_time, quint32 delta_frequency
|
|
, QString const& mode, QString const& message);
|
|
|
|
private:
|
|
QFont text_font_;
|
|
};
|
|
|
|
class ClientWidget
|
|
: public QDockWidget
|
|
{
|
|
Q_OBJECT;
|
|
|
|
public:
|
|
explicit ClientWidget (QAbstractItemModel * decodes_model, QString const& id, QWidget * parent = 0)
|
|
: QDockWidget {id, parent}
|
|
, id_ {id}
|
|
, decodes_table_view_ {new QTableView}
|
|
, message_line_edit_ {new QLineEdit}
|
|
, auto_off_button_ {new QPushButton {tr ("&Auto Off")}}
|
|
, halt_tx_button_ {new QPushButton {tr ("&Halt Tx")}}
|
|
, mode_label_ {new QLabel}
|
|
, dx_call_label_ {new QLabel}
|
|
, frequency_label_ {new QLabel}
|
|
, report_label_ {new QLabel}
|
|
{
|
|
auto content_layout = new QVBoxLayout;
|
|
content_layout->setContentsMargins (QMargins {2, 2, 2, 2});
|
|
|
|
// set up table
|
|
auto proxy_model = new DecodesFilterModel {id, this};
|
|
proxy_model->setSourceModel (decodes_model);
|
|
decodes_table_view_->setModel (proxy_model);
|
|
decodes_table_view_->verticalHeader ()->hide ();
|
|
decodes_table_view_->hideColumn (0);
|
|
content_layout->addWidget (decodes_table_view_);
|
|
|
|
// set up controls
|
|
auto control_layout = new QHBoxLayout;
|
|
auto form_layout = new QFormLayout;
|
|
form_layout->addRow (tr ("Free text:"), message_line_edit_);
|
|
message_line_edit_->setValidator (new QRegExpValidator {message_alphabet, this});
|
|
connect (message_line_edit_, &QLineEdit::textEdited, [this] (QString const& text) {
|
|
Q_EMIT do_free_text (id_, text, false);
|
|
});
|
|
connect (message_line_edit_, &QLineEdit::editingFinished, [this] () {
|
|
Q_EMIT do_free_text (id_, message_line_edit_->text (), true);
|
|
});
|
|
control_layout->addLayout (form_layout);
|
|
control_layout->addWidget (auto_off_button_);
|
|
control_layout->addWidget (halt_tx_button_);
|
|
connect (auto_off_button_, &QAbstractButton::clicked, [this] (bool /* checked */) {
|
|
Q_EMIT do_halt_tx (id_, true);
|
|
});
|
|
connect (halt_tx_button_, &QAbstractButton::clicked, [this] (bool /* checked */) {
|
|
Q_EMIT do_halt_tx (id_, false);
|
|
});
|
|
content_layout->addLayout (control_layout);
|
|
|
|
// set up status area
|
|
auto status_bar = new QStatusBar;
|
|
status_bar->addPermanentWidget (mode_label_);
|
|
status_bar->addPermanentWidget (dx_call_label_);
|
|
status_bar->addPermanentWidget (frequency_label_);
|
|
status_bar->addPermanentWidget (report_label_);
|
|
content_layout->addWidget (status_bar);
|
|
connect (this, &ClientWidget::topLevelChanged, status_bar, &QStatusBar::setSizeGripEnabled);
|
|
|
|
// set up central widget
|
|
auto content_widget = new QFrame;
|
|
content_widget->setFrameStyle (QFrame::StyledPanel | QFrame::Sunken);
|
|
content_widget->setLayout (content_layout);
|
|
setWidget (content_widget);
|
|
// setMinimumSize (QSize {550, 0});
|
|
setFeatures (DockWidgetMovable | DockWidgetFloatable);
|
|
setAllowedAreas (Qt::BottomDockWidgetArea);
|
|
|
|
// connect up table view signals
|
|
connect (decodes_table_view_, &QTableView::doubleClicked, this, [this, proxy_model] (QModelIndex const& index) {
|
|
Q_EMIT do_reply (proxy_model->mapToSource (index));
|
|
});
|
|
}
|
|
|
|
Q_SLOT void update_status (QString const& id, Frequency f, QString const& mode, QString const& dx_call
|
|
, QString const& report, QString const& tx_mode, bool tx_enabled, bool transmitting)
|
|
{
|
|
if (id == id_)
|
|
{
|
|
mode_label_->setText (QString {"Mode: %1%2"}.arg (mode).arg (tx_mode.isEmpty () ? tx_mode : '(' + tx_mode + ')'));
|
|
dx_call_label_->setText ("DX CALL: " + dx_call);
|
|
frequency_label_->setText ("QRG: " + Radio::pretty_frequency_MHz_string (f));
|
|
report_label_->setText ("SNR: " + report);
|
|
update_dynamic_property (frequency_label_, "transmitting", transmitting);
|
|
auto_off_button_->setEnabled (tx_enabled);
|
|
halt_tx_button_->setEnabled (transmitting);
|
|
}
|
|
}
|
|
|
|
Q_SLOT void decode_added (bool /*is_new*/, QString const& client_id, QTime /*time*/, qint32 /*snr*/
|
|
, float /*delta_time*/, quint32 /*delta_frequency*/, QString const& /*mode*/
|
|
, QString const& /*message*/)
|
|
{
|
|
if (client_id == id_)
|
|
{
|
|
decodes_table_view_->resizeColumnsToContents ();
|
|
decodes_table_view_->horizontalHeader ()->setStretchLastSection (true);
|
|
decodes_table_view_->scrollToBottom ();
|
|
}
|
|
}
|
|
|
|
Q_SIGNAL void do_reply (QModelIndex const&);
|
|
Q_SIGNAL void do_halt_tx (QString const& id, bool auto_only);
|
|
Q_SIGNAL void do_free_text (QString const& id, QString const& text, bool);
|
|
|
|
private:
|
|
class DecodesFilterModel final
|
|
: public QSortFilterProxyModel
|
|
{
|
|
public:
|
|
DecodesFilterModel (QString const& id, QObject * parent = nullptr)
|
|
: QSortFilterProxyModel {parent}
|
|
, id_ {id}
|
|
{}
|
|
|
|
protected:
|
|
bool filterAcceptsRow (int source_row, QModelIndex const& source_parent) const override
|
|
{
|
|
auto source_index_col0 = sourceModel ()->index (source_row, 0, source_parent);
|
|
return sourceModel ()->data (source_index_col0).toString () == id_;
|
|
}
|
|
|
|
private:
|
|
QString id_;
|
|
};
|
|
|
|
QString id_;
|
|
QTableView * decodes_table_view_;
|
|
QLineEdit * message_line_edit_;
|
|
QAbstractButton * auto_off_button_;
|
|
QAbstractButton * halt_tx_button_;
|
|
QLabel * mode_label_;
|
|
QLabel * dx_call_label_;
|
|
QLabel * frequency_label_;
|
|
QLabel * report_label_;
|
|
};
|
|
|
|
class MainWindow
|
|
: public QMainWindow
|
|
{
|
|
Q_OBJECT;
|
|
|
|
public:
|
|
MainWindow ()
|
|
: log_ {new QStandardItemModel {0, 10, this}}
|
|
, decodes_model_ {new DecodesModel {this}}
|
|
, server_ {new MessageServer {this}}
|
|
, multicast_group_line_edit_ {new QLineEdit}
|
|
, log_table_view_ {new QTableView}
|
|
{
|
|
// logbook
|
|
log_->setHeaderData (0, Qt::Horizontal, tr ("Date/Time"));
|
|
log_->setHeaderData (1, Qt::Horizontal, tr ("Callsign"));
|
|
log_->setHeaderData (2, Qt::Horizontal, tr ("Grid"));
|
|
log_->setHeaderData (3, Qt::Horizontal, tr ("Name"));
|
|
log_->setHeaderData (4, Qt::Horizontal, tr ("Frequency"));
|
|
log_->setHeaderData (5, Qt::Horizontal, tr ("Mode"));
|
|
log_->setHeaderData (6, Qt::Horizontal, tr ("Sent"));
|
|
log_->setHeaderData (7, Qt::Horizontal, tr ("Rec'd"));
|
|
log_->setHeaderData (8, Qt::Horizontal, tr ("Power"));
|
|
log_->setHeaderData (9, Qt::Horizontal, tr ("Comments"));
|
|
connect (server_, &MessageServer::qso_logged, this, &MainWindow::log_qso);
|
|
|
|
// menu bar
|
|
auto file_menu = menuBar ()->addMenu (tr ("&File"));
|
|
|
|
auto exit_action = new QAction {tr ("E&xit"), this};
|
|
exit_action->setShortcuts (QKeySequence::Quit);
|
|
exit_action->setToolTip (tr ("Exit the application"));
|
|
file_menu->addAction (exit_action);
|
|
connect (exit_action, &QAction::triggered, this, &MainWindow::close);
|
|
|
|
view_menu_ = menuBar ()->addMenu (tr ("&View"));
|
|
|
|
// central layout
|
|
auto central_layout = new QVBoxLayout;
|
|
|
|
// server details
|
|
auto port_spin_box = new QSpinBox;
|
|
port_spin_box->setMinimum (1);
|
|
port_spin_box->setMaximum (std::numeric_limits<port_type>::max ());
|
|
auto group_box_layout = new QFormLayout;
|
|
group_box_layout->addRow (tr ("Port number:"), port_spin_box);
|
|
group_box_layout->addRow (tr ("Multicast Group (blank for unicast server):"), multicast_group_line_edit_);
|
|
auto group_box = new QGroupBox {tr ("Server Details")};
|
|
group_box->setLayout (group_box_layout);
|
|
central_layout->addWidget (group_box);
|
|
|
|
log_table_view_->setModel (log_);
|
|
log_table_view_->verticalHeader ()->hide ();
|
|
central_layout->addWidget (log_table_view_);
|
|
|
|
// central widget
|
|
auto central_widget = new QWidget;
|
|
central_widget->setLayout (central_layout);
|
|
|
|
// main window setup
|
|
setCentralWidget (central_widget);
|
|
setDockOptions (AnimatedDocks | AllowNestedDocks | AllowTabbedDocks);
|
|
setTabPosition (Qt::BottomDockWidgetArea, QTabWidget::North);
|
|
|
|
// connect up server
|
|
connect (server_, &MessageServer::error, [this] (QString const& message) {
|
|
QMessageBox::warning (this, tr ("Network Error"), message);
|
|
});
|
|
connect (server_, &MessageServer::client_opened, this, &MainWindow::add_client);
|
|
connect (server_, &MessageServer::client_closed, this, &MainWindow::remove_client);
|
|
connect (server_, &MessageServer::client_closed, decodes_model_, &DecodesModel::clear_decodes);
|
|
connect (server_, &MessageServer::decode, decodes_model_, &DecodesModel::add_decode);
|
|
connect (server_, &MessageServer::clear_decodes, decodes_model_, &DecodesModel::clear_decodes);
|
|
connect (decodes_model_, &DecodesModel::reply, server_, &MessageServer::reply);
|
|
|
|
// UI behaviour
|
|
connect (port_spin_box, static_cast<void (QSpinBox::*)(int)> (&QSpinBox::valueChanged)
|
|
, [this] (port_type port) {server_->start (port);});
|
|
connect (multicast_group_line_edit_, &QLineEdit::editingFinished, [this, port_spin_box] () {
|
|
server_->start (port_spin_box->value (), QHostAddress {multicast_group_line_edit_->text ()});
|
|
});
|
|
|
|
port_spin_box->setValue (2237); // start up in unicast mode
|
|
show ();
|
|
}
|
|
|
|
Q_SLOT void log_qso (QString const& /*id*/, QDateTime time, QString const& dx_call, QString const& dx_grid
|
|
, Frequency dial_frequency, QString const& mode, QString const& report_sent
|
|
, QString const& report_received, QString const& tx_power, QString const& comments
|
|
, QString const& name)
|
|
{
|
|
QList<QStandardItem *> row;
|
|
row << new QStandardItem {time.toString ("dd-MMM-yyyy hh:mm")}
|
|
<< new QStandardItem {dx_call}
|
|
<< new QStandardItem {dx_grid}
|
|
<< new QStandardItem {name}
|
|
<< new QStandardItem {Radio::frequency_MHz_string (dial_frequency)}
|
|
<< new QStandardItem {mode}
|
|
<< new QStandardItem {report_sent}
|
|
<< new QStandardItem {report_received}
|
|
<< new QStandardItem {tx_power}
|
|
<< new QStandardItem {comments};
|
|
log_->appendRow (row);
|
|
log_table_view_->resizeColumnsToContents ();
|
|
log_table_view_->horizontalHeader ()->setStretchLastSection (true);
|
|
log_table_view_->scrollToBottom ();
|
|
}
|
|
|
|
private:
|
|
void add_client (QString const& id)
|
|
{
|
|
auto dock = new ClientWidget {decodes_model_, id, this};
|
|
dock->setAttribute (Qt::WA_DeleteOnClose);
|
|
auto view_action = dock->toggleViewAction ();
|
|
view_action->setEnabled (true);
|
|
view_menu_->addAction (view_action);
|
|
addDockWidget (Qt::BottomDockWidgetArea, dock);
|
|
connect (server_, &MessageServer::status_update, dock, &ClientWidget::update_status);
|
|
connect (server_, &MessageServer::decode, dock, &ClientWidget::decode_added);
|
|
connect (dock, &ClientWidget::do_reply, decodes_model_, &DecodesModel::do_reply);
|
|
connect (dock, &ClientWidget::do_halt_tx, server_, &MessageServer::halt_tx);
|
|
connect (dock, &ClientWidget::do_free_text, server_, &MessageServer::free_text);
|
|
connect (view_action, &QAction::toggled, dock, &ClientWidget::setVisible);
|
|
dock_widgets_[id] = dock;
|
|
server_->replay (id);
|
|
}
|
|
|
|
void remove_client (QString const& id)
|
|
{
|
|
auto iter = dock_widgets_.find (id);
|
|
if (iter != std::end (dock_widgets_))
|
|
{
|
|
(*iter)->close ();
|
|
dock_widgets_.erase (iter);
|
|
}
|
|
}
|
|
|
|
QStandardItemModel * log_;
|
|
QMenu * view_menu_;
|
|
DecodesModel * decodes_model_;
|
|
MessageServer * server_;
|
|
QLineEdit * multicast_group_line_edit_;
|
|
QTableView * log_table_view_;
|
|
|
|
// maps client id to widgets
|
|
QHash<QString, ClientWidget *> dock_widgets_;
|
|
};
|
|
|
|
#include "MessageAggregator.moc"
|
|
|
|
int main (int argc, char * argv[])
|
|
{
|
|
QApplication app {argc, argv};
|
|
try
|
|
{
|
|
QObject::connect (&app, SIGNAL (lastWindowClosed ()), &app, SLOT (quit ()));
|
|
|
|
app.setApplicationName ("WSJT-X Reference UDP Message Aggregator Server");
|
|
app.setApplicationVersion ("1.0");
|
|
|
|
{
|
|
QFile file {":/qss/default.qss"};
|
|
if (!file.open (QFile::ReadOnly))
|
|
{
|
|
throw_qstring ("failed to open \"" + file.fileName () + "\": " + file.errorString ());
|
|
}
|
|
app.setStyleSheet (file.readAll());
|
|
}
|
|
|
|
MainWindow window;
|
|
return app.exec ();
|
|
}
|
|
catch (std::exception const & e)
|
|
{
|
|
QMessageBox::critical (nullptr, app.applicationName (), e.what ());
|
|
std:: cerr << "Error: " << e.what () << '\n';
|
|
}
|
|
catch (...)
|
|
{
|
|
QMessageBox::critical (nullptr, app.applicationName (), QObject::tr ("Unexpected error"));
|
|
std:: cerr << "Unexpected error\n";
|
|
}
|
|
return -1;
|
|
}
|