Add openpilot tools
This commit is contained in:
6
tools/cabana/.gitignore
vendored
Normal file
6
tools/cabana/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
moc_*
|
||||
*.moc
|
||||
|
||||
cabana
|
||||
dbc/car_fingerprint_to_dbc.json
|
||||
tests/test_cabana
|
||||
32
tools/cabana/README.md
Normal file
32
tools/cabana/README.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# Cabana
|
||||
|
||||
Cabana is a tool developed to view raw CAN data. One use for this is creating and editing [CAN Dictionaries](http://socialledge.com/sjsu/index.php/DBC_Format) (DBC files), and the tool provides direct integration with [commaai/opendbc](https://github.com/commaai/opendbc) (a collection of DBC files), allowing you to load the DBC files direct from source, and save to your fork. In addition, you can load routes from [comma connect](https://connect.comma.ai).
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
```bash
|
||||
$ ./cabana -h
|
||||
Usage: ./cabana [options] route
|
||||
|
||||
Options:
|
||||
-h, --help Displays help on commandline options.
|
||||
--help-all Displays help including Qt specific options.
|
||||
--demo use a demo route instead of providing your own
|
||||
--qcam load qcamera
|
||||
--ecam load wide road camera
|
||||
--stream read can messages from live streaming
|
||||
--panda read can messages from panda
|
||||
--panda-serial <panda-serial> read can messages from panda with given serial
|
||||
--socketcan <socketcan> read can messages from given SocketCAN device
|
||||
--zmq <zmq> the ip address on which to receive zmq
|
||||
messages
|
||||
--data_dir <data_dir> local directory with routes
|
||||
--no-vipc do not output video
|
||||
--dbc <dbc> dbc file to open
|
||||
|
||||
Arguments:
|
||||
route the drive to replay. find your drives at
|
||||
connect.comma.ai
|
||||
```
|
||||
|
||||
See [openpilot wiki](https://github.com/commaai/openpilot/wiki/Cabana)
|
||||
41
tools/cabana/SConscript
Normal file
41
tools/cabana/SConscript
Normal file
@@ -0,0 +1,41 @@
|
||||
Import('qt_env', 'arch', 'common', 'messaging', 'visionipc', 'replay_lib', 'cereal', 'widgets')
|
||||
|
||||
base_frameworks = qt_env['FRAMEWORKS']
|
||||
base_libs = [common, messaging, cereal, visionipc, 'qt_util', 'zmq', 'capnp', 'kj', 'm', 'ssl', 'crypto', 'pthread'] + qt_env["LIBS"]
|
||||
|
||||
if arch == "Darwin":
|
||||
base_frameworks.append('OpenCL')
|
||||
base_frameworks.append('QtCharts')
|
||||
base_frameworks.append('QtSerialBus')
|
||||
else:
|
||||
base_libs.append('OpenCL')
|
||||
base_libs.append('Qt5Charts')
|
||||
base_libs.append('Qt5SerialBus')
|
||||
|
||||
qt_libs = ['qt_util'] + base_libs
|
||||
|
||||
cabana_env = qt_env.Clone()
|
||||
cabana_libs = [widgets, cereal, messaging, visionipc, replay_lib, 'panda', 'avutil', 'avcodec', 'avformat', 'bz2', 'curl', 'yuv', 'usb-1.0'] + qt_libs
|
||||
opendbc_path = '-DOPENDBC_FILE_PATH=\'"%s"\'' % (cabana_env.Dir("../../opendbc").abspath)
|
||||
cabana_env['CXXFLAGS'] += [opendbc_path]
|
||||
|
||||
# build assets
|
||||
assets = "assets/assets.cc"
|
||||
assets_src = "assets/assets.qrc"
|
||||
cabana_env.Command(assets, assets_src, f"rcc $SOURCES -o $TARGET")
|
||||
cabana_env.Depends(assets, Glob('/assets/*', exclude=[assets, assets_src, "assets/assets.o"]))
|
||||
|
||||
cabana_lib = cabana_env.Library("cabana_lib", ['mainwin.cc', 'streams/socketcanstream.cc', 'streams/pandastream.cc', 'streams/devicestream.cc', 'streams/livestream.cc', 'streams/abstractstream.cc', 'streams/replaystream.cc', 'binaryview.cc', 'historylog.cc', 'videowidget.cc', 'signalview.cc',
|
||||
'dbc/dbc.cc', 'dbc/dbcfile.cc', 'dbc/dbcmanager.cc',
|
||||
'chart/chartswidget.cc', 'chart/chart.cc', 'chart/signalselector.cc', 'chart/tiplabel.cc', 'chart/sparkline.cc',
|
||||
'commands.cc', 'messageswidget.cc', 'streamselector.cc', 'settings.cc', 'util.cc', 'detailwidget.cc', 'tools/findsimilarbits.cc', 'tools/findsignal.cc'], LIBS=cabana_libs, FRAMEWORKS=base_frameworks)
|
||||
cabana_env.Program('cabana', ['cabana.cc', cabana_lib, assets], LIBS=cabana_libs, FRAMEWORKS=base_frameworks)
|
||||
|
||||
if GetOption('extras'):
|
||||
cabana_env.Program('tests/test_cabana', ['tests/test_runner.cc', 'tests/test_cabana.cc', cabana_lib], LIBS=[cabana_libs])
|
||||
|
||||
output_json_file = 'tools/cabana/dbc/car_fingerprint_to_dbc.json'
|
||||
generate_dbc = cabana_env.Command('#' + output_json_file,
|
||||
['dbc/generate_dbc_json.py'],
|
||||
"python3 tools/cabana/dbc/generate_dbc_json.py --out " + output_json_file)
|
||||
cabana_env.Depends(generate_dbc, ["#common", "#selfdrive/boardd", '#opendbc', "#cereal", Glob("#opendbc/*.dbc")])
|
||||
1
tools/cabana/assets/.gitignore
vendored
Normal file
1
tools/cabana/assets/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
*.cc
|
||||
6
tools/cabana/assets/assets.qrc
Normal file
6
tools/cabana/assets/assets.qrc
Normal file
@@ -0,0 +1,6 @@
|
||||
<!DOCTYPE RCC><RCC version="1.0">
|
||||
<qresource>
|
||||
<file alias="bootstrap-icons.svg">../../../third_party/bootstrap/bootstrap-icons.svg</file>
|
||||
<file>cabana-icon.png</file>
|
||||
</qresource>
|
||||
</RCC>
|
||||
BIN
tools/cabana/assets/cabana-icon.png
Normal file
BIN
tools/cabana/assets/cabana-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
458
tools/cabana/binaryview.cc
Normal file
458
tools/cabana/binaryview.cc
Normal file
@@ -0,0 +1,458 @@
|
||||
#include "tools/cabana/binaryview.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include <QDebug>
|
||||
#include <QFontDatabase>
|
||||
#include <QHeaderView>
|
||||
#include <QMouseEvent>
|
||||
#include <QPainter>
|
||||
#include <QScrollBar>
|
||||
#include <QShortcut>
|
||||
#include <QToolTip>
|
||||
|
||||
#include "tools/cabana/commands.h"
|
||||
|
||||
// BinaryView
|
||||
|
||||
const int CELL_HEIGHT = 36;
|
||||
const int VERTICAL_HEADER_WIDTH = 30;
|
||||
inline int get_bit_pos(const QModelIndex &index) { return flipBitPos(index.row() * 8 + index.column()); }
|
||||
|
||||
BinaryView::BinaryView(QWidget *parent) : QTableView(parent) {
|
||||
model = new BinaryViewModel(this);
|
||||
setModel(model);
|
||||
delegate = new BinaryItemDelegate(this);
|
||||
setItemDelegate(delegate);
|
||||
horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch);
|
||||
horizontalHeader()->setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont));
|
||||
verticalHeader()->setSectionsClickable(false);
|
||||
verticalHeader()->setSectionResizeMode(QHeaderView::Fixed);
|
||||
verticalHeader()->setDefaultSectionSize(CELL_HEIGHT);
|
||||
horizontalHeader()->hide();
|
||||
setShowGrid(false);
|
||||
setMouseTracking(true);
|
||||
setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
||||
|
||||
QObject::connect(dbc(), &DBCManager::DBCFileChanged, this, &BinaryView::refresh);
|
||||
QObject::connect(UndoStack::instance(), &QUndoStack::indexChanged, this, &BinaryView::refresh);
|
||||
|
||||
addShortcuts();
|
||||
setWhatsThis(R"(
|
||||
<b>Binary View</b><br/>
|
||||
<!-- TODO: add descprition here -->
|
||||
<span style="color:gray">Shortcuts</span><br />
|
||||
Delete Signal:
|
||||
<span style="background-color:lightGray;color:gray"> x </span>,
|
||||
<span style="background-color:lightGray;color:gray"> Backspace </span>,
|
||||
<span style="background-color:lightGray;color:gray"> Delete </span><br />
|
||||
Change endianness: <span style="background-color:lightGray;color:gray"> e </span><br />
|
||||
Change singedness: <span style="background-color:lightGray;color:gray"> s </span><br />
|
||||
Open chart:
|
||||
<span style="background-color:lightGray;color:gray"> c </span>,
|
||||
<span style="background-color:lightGray;color:gray"> p </span>,
|
||||
<span style="background-color:lightGray;color:gray"> g </span>
|
||||
)");
|
||||
}
|
||||
|
||||
void BinaryView::addShortcuts() {
|
||||
// Delete (x, backspace, delete)
|
||||
QShortcut *shortcut_delete_x = new QShortcut(QKeySequence(Qt::Key_X), this);
|
||||
QShortcut *shortcut_delete_backspace = new QShortcut(QKeySequence(Qt::Key_Backspace), this);
|
||||
QShortcut *shortcut_delete_delete = new QShortcut(QKeySequence(Qt::Key_Delete), this);
|
||||
QObject::connect(shortcut_delete_delete, &QShortcut::activated, shortcut_delete_x, &QShortcut::activated);
|
||||
QObject::connect(shortcut_delete_backspace, &QShortcut::activated, shortcut_delete_x, &QShortcut::activated);
|
||||
QObject::connect(shortcut_delete_x, &QShortcut::activated, [=]{
|
||||
if (hovered_sig != nullptr) {
|
||||
UndoStack::push(new RemoveSigCommand(model->msg_id, hovered_sig));
|
||||
hovered_sig = nullptr;
|
||||
}
|
||||
});
|
||||
|
||||
// Change endianness (e)
|
||||
QShortcut *shortcut_endian = new QShortcut(QKeySequence(Qt::Key_E), this);
|
||||
QObject::connect(shortcut_endian, &QShortcut::activated, [=]{
|
||||
if (hovered_sig != nullptr) {
|
||||
cabana::Signal s = *hovered_sig;
|
||||
s.is_little_endian = !s.is_little_endian;
|
||||
emit editSignal(hovered_sig, s);
|
||||
}
|
||||
});
|
||||
|
||||
// Change signedness (s)
|
||||
QShortcut *shortcut_sign = new QShortcut(QKeySequence(Qt::Key_S), this);
|
||||
QObject::connect(shortcut_sign, &QShortcut::activated, [=]{
|
||||
if (hovered_sig != nullptr) {
|
||||
cabana::Signal s = *hovered_sig;
|
||||
s.is_signed = !s.is_signed;
|
||||
emit editSignal(hovered_sig, s);
|
||||
}
|
||||
});
|
||||
|
||||
// Open chart (c, p, g)
|
||||
QShortcut *shortcut_plot = new QShortcut(QKeySequence(Qt::Key_P), this);
|
||||
QShortcut *shortcut_plot_g = new QShortcut(QKeySequence(Qt::Key_G), this);
|
||||
QShortcut *shortcut_plot_c = new QShortcut(QKeySequence(Qt::Key_C), this);
|
||||
QObject::connect(shortcut_plot_g, &QShortcut::activated, shortcut_plot, &QShortcut::activated);
|
||||
QObject::connect(shortcut_plot_c, &QShortcut::activated, shortcut_plot, &QShortcut::activated);
|
||||
QObject::connect(shortcut_plot, &QShortcut::activated, [=]{
|
||||
if (hovered_sig != nullptr) {
|
||||
emit showChart(model->msg_id, hovered_sig, true, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
QSize BinaryView::minimumSizeHint() const {
|
||||
return {(horizontalHeader()->minimumSectionSize() + 1) * 9 + VERTICAL_HEADER_WIDTH + 2,
|
||||
CELL_HEIGHT * std::min(model->rowCount(), 10) + 2};
|
||||
}
|
||||
|
||||
void BinaryView::highlight(const cabana::Signal *sig) {
|
||||
if (sig != hovered_sig) {
|
||||
for (int i = 0; i < model->items.size(); ++i) {
|
||||
auto &item_sigs = model->items[i].sigs;
|
||||
if ((sig && item_sigs.contains(sig)) || (hovered_sig && item_sigs.contains(hovered_sig))) {
|
||||
auto index = model->index(i / model->columnCount(), i % model->columnCount());
|
||||
emit model->dataChanged(index, index, {Qt::DisplayRole});
|
||||
}
|
||||
}
|
||||
|
||||
hovered_sig = sig;
|
||||
emit signalHovered(hovered_sig);
|
||||
}
|
||||
}
|
||||
|
||||
void BinaryView::setSelection(const QRect &rect, QItemSelectionModel::SelectionFlags flags) {
|
||||
auto index = indexAt(viewport()->mapFromGlobal(QCursor::pos()));
|
||||
if (!anchor_index.isValid() || !index.isValid())
|
||||
return;
|
||||
|
||||
QItemSelection selection;
|
||||
auto [start, size, is_lb] = getSelection(index);
|
||||
for (int i = 0; i < size; ++i) {
|
||||
int pos = is_lb ? flipBitPos(start + i) : flipBitPos(start) + i;
|
||||
selection << QItemSelectionRange{model->index(pos / 8, pos % 8)};
|
||||
}
|
||||
selectionModel()->select(selection, flags);
|
||||
}
|
||||
|
||||
void BinaryView::mousePressEvent(QMouseEvent *event) {
|
||||
resize_sig = nullptr;
|
||||
if (auto index = indexAt(event->pos()); index.isValid() && index.column() != 8) {
|
||||
anchor_index = index;
|
||||
auto item = (const BinaryViewModel::Item *)anchor_index.internalPointer();
|
||||
int bit_pos = get_bit_pos(anchor_index);
|
||||
for (auto s : item->sigs) {
|
||||
if (bit_pos == s->lsb || bit_pos == s->msb) {
|
||||
int idx = flipBitPos(bit_pos == s->lsb ? s->msb : s->lsb);
|
||||
anchor_index = model->index(idx / 8, idx % 8);
|
||||
resize_sig = s;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
event->accept();
|
||||
}
|
||||
|
||||
void BinaryView::highlightPosition(const QPoint &pos) {
|
||||
if (auto index = indexAt(viewport()->mapFromGlobal(pos)); index.isValid()) {
|
||||
auto item = (BinaryViewModel::Item *)index.internalPointer();
|
||||
const cabana::Signal *sig = item->sigs.isEmpty() ? nullptr : item->sigs.back();
|
||||
highlight(sig);
|
||||
}
|
||||
}
|
||||
|
||||
void BinaryView::mouseMoveEvent(QMouseEvent *event) {
|
||||
highlightPosition(event->globalPos());
|
||||
QTableView::mouseMoveEvent(event);
|
||||
}
|
||||
|
||||
void BinaryView::mouseReleaseEvent(QMouseEvent *event) {
|
||||
QTableView::mouseReleaseEvent(event);
|
||||
|
||||
auto release_index = indexAt(event->pos());
|
||||
if (release_index.isValid() && anchor_index.isValid()) {
|
||||
if (selectionModel()->hasSelection()) {
|
||||
auto sig = resize_sig ? *resize_sig : cabana::Signal{};
|
||||
std::tie(sig.start_bit, sig.size, sig.is_little_endian) = getSelection(release_index);
|
||||
resize_sig ? emit editSignal(resize_sig, sig)
|
||||
: UndoStack::push(new AddSigCommand(model->msg_id, sig));
|
||||
} else {
|
||||
auto item = (const BinaryViewModel::Item *)anchor_index.internalPointer();
|
||||
if (item && item->sigs.size() > 0)
|
||||
emit signalClicked(item->sigs.back());
|
||||
}
|
||||
}
|
||||
clearSelection();
|
||||
anchor_index = QModelIndex();
|
||||
resize_sig = nullptr;
|
||||
}
|
||||
|
||||
void BinaryView::leaveEvent(QEvent *event) {
|
||||
highlight(nullptr);
|
||||
QTableView::leaveEvent(event);
|
||||
}
|
||||
|
||||
void BinaryView::setMessage(const MessageId &message_id) {
|
||||
model->msg_id = message_id;
|
||||
verticalScrollBar()->setValue(0);
|
||||
refresh();
|
||||
}
|
||||
|
||||
void BinaryView::refresh() {
|
||||
clearSelection();
|
||||
anchor_index = QModelIndex();
|
||||
resize_sig = nullptr;
|
||||
hovered_sig = nullptr;
|
||||
model->refresh();
|
||||
highlightPosition(QCursor::pos());
|
||||
}
|
||||
|
||||
QSet<const cabana::Signal *> BinaryView::getOverlappingSignals() const {
|
||||
QSet<const cabana::Signal *> overlapping;
|
||||
for (const auto &item : model->items) {
|
||||
if (item.sigs.size() > 1) {
|
||||
for (auto s : item.sigs) {
|
||||
if (s->type == cabana::Signal::Type::Normal) overlapping += s;
|
||||
}
|
||||
}
|
||||
}
|
||||
return overlapping;
|
||||
}
|
||||
|
||||
std::tuple<int, int, bool> BinaryView::getSelection(QModelIndex index) {
|
||||
if (index.column() == 8) {
|
||||
index = model->index(index.row(), 7);
|
||||
}
|
||||
bool is_lb = true;
|
||||
if (resize_sig) {
|
||||
is_lb = resize_sig->is_little_endian;
|
||||
} else if (settings.drag_direction == Settings::DragDirection::MsbFirst) {
|
||||
is_lb = index < anchor_index;
|
||||
} else if (settings.drag_direction == Settings::DragDirection::LsbFirst) {
|
||||
is_lb = !(index < anchor_index);
|
||||
} else if (settings.drag_direction == Settings::DragDirection::AlwaysLE) {
|
||||
is_lb = true;
|
||||
} else if (settings.drag_direction == Settings::DragDirection::AlwaysBE) {
|
||||
is_lb = false;
|
||||
}
|
||||
|
||||
int cur_bit_pos = get_bit_pos(index);
|
||||
int anchor_bit_pos = get_bit_pos(anchor_index);
|
||||
int start_bit = is_lb ? std::min(cur_bit_pos, anchor_bit_pos) : get_bit_pos(std::min(index, anchor_index));
|
||||
int size = is_lb ? std::abs(cur_bit_pos - anchor_bit_pos) + 1 : std::abs(flipBitPos(cur_bit_pos) - flipBitPos(anchor_bit_pos)) + 1;
|
||||
return {start_bit, size, is_lb};
|
||||
}
|
||||
|
||||
// BinaryViewModel
|
||||
|
||||
void BinaryViewModel::refresh() {
|
||||
beginResetModel();
|
||||
items.clear();
|
||||
if (auto dbc_msg = dbc()->msg(msg_id)) {
|
||||
row_count = dbc_msg->size;
|
||||
items.resize(row_count * column_count);
|
||||
for (auto sig : dbc_msg->getSignals()) {
|
||||
for (int j = 0; j < sig->size; ++j) {
|
||||
int pos = sig->is_little_endian ? flipBitPos(sig->start_bit + j) : flipBitPos(sig->start_bit) + j;
|
||||
int idx = column_count * (pos / 8) + pos % 8;
|
||||
if (idx >= items.size()) {
|
||||
qWarning() << "signal " << sig->name << "out of bounds.start_bit:" << sig->start_bit << "size:" << sig->size;
|
||||
break;
|
||||
}
|
||||
if (j == 0) sig->is_little_endian ? items[idx].is_lsb = true : items[idx].is_msb = true;
|
||||
if (j == sig->size - 1) sig->is_little_endian ? items[idx].is_msb = true : items[idx].is_lsb = true;
|
||||
|
||||
auto &sigs = items[idx].sigs;
|
||||
sigs.push_back(sig);
|
||||
if (sigs.size() > 1) {
|
||||
std::sort(sigs.begin(), sigs.end(), [](auto l, auto r) { return l->size > r->size; });
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
row_count = can->lastMessage(msg_id).dat.size();
|
||||
items.resize(row_count * column_count);
|
||||
}
|
||||
int valid_rows = std::min<int>(can->lastMessage(msg_id).dat.size(), row_count);
|
||||
for (int i = 0; i < valid_rows * column_count; ++i) {
|
||||
items[i].valid = true;
|
||||
}
|
||||
endResetModel();
|
||||
updateState();
|
||||
}
|
||||
|
||||
void BinaryViewModel::updateItem(int row, int col, uint8_t val, const QColor &color) {
|
||||
auto &item = items[row * column_count + col];
|
||||
if (item.val != val || item.bg_color != color) {
|
||||
item.val = val;
|
||||
item.bg_color = color;
|
||||
auto idx = index(row, col);
|
||||
emit dataChanged(idx, idx, {Qt::DisplayRole});
|
||||
}
|
||||
}
|
||||
|
||||
void BinaryViewModel::updateState() {
|
||||
const auto &last_msg = can->lastMessage(msg_id);
|
||||
const auto &binary = last_msg.dat;
|
||||
// data size may changed.
|
||||
if (binary.size() > row_count) {
|
||||
beginInsertRows({}, row_count, binary.size() - 1);
|
||||
row_count = binary.size();
|
||||
items.resize(row_count * column_count);
|
||||
endInsertRows();
|
||||
}
|
||||
|
||||
const double max_f = 255.0;
|
||||
const double factor = 0.25;
|
||||
const double scaler = max_f / log2(1.0 + factor);
|
||||
for (int i = 0; i < binary.size(); ++i) {
|
||||
for (int j = 0; j < 8; ++j) {
|
||||
auto &item = items[i * column_count + j];
|
||||
int val = ((binary[i] >> (7 - j)) & 1) != 0 ? 1 : 0;
|
||||
// Bit update frequency based highlighting
|
||||
double offset = !item.sigs.empty() ? 50 : 0;
|
||||
auto n = last_msg.last_changes[i].bit_change_counts[j];
|
||||
double min_f = n == 0 ? offset : offset + 25;
|
||||
double alpha = std::clamp(offset + log2(1.0 + factor * (double)n / (double)last_msg.count) * scaler, min_f, max_f);
|
||||
auto color = item.bg_color;
|
||||
color.setAlpha(alpha);
|
||||
updateItem(i, j, val, color);
|
||||
}
|
||||
updateItem(i, 8, binary[i], last_msg.colors[i]);
|
||||
}
|
||||
}
|
||||
|
||||
QVariant BinaryViewModel::headerData(int section, Qt::Orientation orientation, int role) const {
|
||||
if (orientation == Qt::Vertical) {
|
||||
switch (role) {
|
||||
case Qt::DisplayRole: return section;
|
||||
case Qt::SizeHintRole: return QSize(VERTICAL_HEADER_WIDTH, 0);
|
||||
case Qt::TextAlignmentRole: return Qt::AlignCenter;
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
QVariant BinaryViewModel::data(const QModelIndex &index, int role) const {
|
||||
auto item = (const BinaryViewModel::Item *)index.internalPointer();
|
||||
return role == Qt::ToolTipRole && item && !item->sigs.empty() ? signalToolTip(item->sigs.back()) : QVariant();
|
||||
}
|
||||
|
||||
// BinaryItemDelegate
|
||||
|
||||
BinaryItemDelegate::BinaryItemDelegate(QObject *parent) : QStyledItemDelegate(parent) {
|
||||
small_font.setPixelSize(8);
|
||||
hex_font = QFontDatabase::systemFont(QFontDatabase::FixedFont);
|
||||
hex_font.setBold(true);
|
||||
|
||||
bin_text_table[0].setText("0");
|
||||
bin_text_table[1].setText("1");
|
||||
for (int i = 0; i < 256; ++i) {
|
||||
hex_text_table[i].setText(QStringLiteral("%1").arg(i, 2, 16, QLatin1Char('0')).toUpper());
|
||||
hex_text_table[i].prepare({}, hex_font);
|
||||
}
|
||||
}
|
||||
|
||||
bool BinaryItemDelegate::hasSignal(const QModelIndex &index, int dx, int dy, const cabana::Signal *sig) const {
|
||||
if (!index.isValid()) return false;
|
||||
auto model = (const BinaryViewModel*)(index.model());
|
||||
int idx = (index.row() + dy) * model->columnCount() + index.column() + dx;
|
||||
return (idx >=0 && idx < model->items.size()) ? model->items[idx].sigs.contains(sig) : false;
|
||||
}
|
||||
|
||||
void BinaryItemDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const {
|
||||
auto item = (const BinaryViewModel::Item *)index.internalPointer();
|
||||
BinaryView *bin_view = (BinaryView *)parent();
|
||||
painter->save();
|
||||
|
||||
if (index.column() == 8) {
|
||||
if (item->valid) {
|
||||
painter->setFont(hex_font);
|
||||
painter->fillRect(option.rect, item->bg_color);
|
||||
}
|
||||
} else if (option.state & QStyle::State_Selected) {
|
||||
auto color = bin_view->resize_sig ? bin_view->resize_sig->color : option.palette.color(QPalette::Active, QPalette::Highlight);
|
||||
painter->fillRect(option.rect, color);
|
||||
painter->setPen(option.palette.color(QPalette::BrightText));
|
||||
} else if (!bin_view->selectionModel()->hasSelection() || !item->sigs.contains(bin_view->resize_sig)) { // not resizing
|
||||
if (item->sigs.size() > 0) {
|
||||
for (auto &s : item->sigs) {
|
||||
if (s == bin_view->hovered_sig) {
|
||||
painter->fillRect(option.rect, s->color.darker(125)); // 4/5x brightness
|
||||
} else {
|
||||
drawSignalCell(painter, option, index, s);
|
||||
}
|
||||
}
|
||||
} else if (item->valid && item->bg_color.alpha() > 0) {
|
||||
painter->fillRect(option.rect, item->bg_color);
|
||||
}
|
||||
auto color_role = item->sigs.contains(bin_view->hovered_sig) ? QPalette::BrightText : QPalette::Text;
|
||||
painter->setPen(option.palette.color(color_role));
|
||||
}
|
||||
|
||||
if (item->sigs.size() > 1) {
|
||||
painter->fillRect(option.rect, QBrush(Qt::darkGray, Qt::Dense7Pattern));
|
||||
} else if (!item->valid) {
|
||||
painter->fillRect(option.rect, QBrush(Qt::darkGray, Qt::BDiagPattern));
|
||||
}
|
||||
if (item->valid) {
|
||||
utils::drawStaticText(painter, option.rect, index.column() == 8 ? hex_text_table[item->val] : bin_text_table[item->val]);
|
||||
}
|
||||
if (item->is_msb || item->is_lsb) {
|
||||
painter->setFont(small_font);
|
||||
painter->drawText(option.rect.adjusted(8, 0, -8, -3), Qt::AlignRight | Qt::AlignBottom, item->is_msb ? "M" : "L");
|
||||
}
|
||||
painter->restore();
|
||||
}
|
||||
|
||||
// Draw border on edge of signal
|
||||
void BinaryItemDelegate::drawSignalCell(QPainter *painter, const QStyleOptionViewItem &option,
|
||||
const QModelIndex &index, const cabana::Signal *sig) const {
|
||||
bool draw_left = !hasSignal(index, -1, 0, sig);
|
||||
bool draw_top = !hasSignal(index, 0, -1, sig);
|
||||
bool draw_right = !hasSignal(index, 1, 0, sig);
|
||||
bool draw_bottom = !hasSignal(index, 0, 1, sig);
|
||||
|
||||
const int spacing = 2;
|
||||
QRect rc = option.rect.adjusted(draw_left * 3, draw_top * spacing, draw_right * -3, draw_bottom * -spacing);
|
||||
QRegion subtract;
|
||||
if (!draw_top) {
|
||||
if (!draw_left && !hasSignal(index, -1, -1, sig)) {
|
||||
subtract += QRect{rc.left(), rc.top(), 3, spacing};
|
||||
} else if (!draw_right && !hasSignal(index, 1, -1, sig)) {
|
||||
subtract += QRect{rc.right() - 2, rc.top(), 3, spacing};
|
||||
}
|
||||
}
|
||||
if (!draw_bottom) {
|
||||
if (!draw_left && !hasSignal(index, -1, 1, sig)) {
|
||||
subtract += QRect{rc.left(), rc.bottom() - (spacing - 1), 3, spacing};
|
||||
} else if (!draw_right && !hasSignal(index, 1, 1, sig)) {
|
||||
subtract += QRect{rc.right() - 2, rc.bottom() - (spacing - 1), 3, spacing};
|
||||
}
|
||||
}
|
||||
painter->setClipRegion(QRegion(rc).subtracted(subtract));
|
||||
|
||||
auto item = (const BinaryViewModel::Item *)index.internalPointer();
|
||||
QColor color = sig->color;
|
||||
color.setAlpha(item->bg_color.alpha());
|
||||
// Mixing the signal colour with the Base background color to fade it
|
||||
painter->fillRect(rc, option.palette.color(QPalette::Base));
|
||||
painter->fillRect(rc, color);
|
||||
|
||||
// Draw edges
|
||||
color = sig->color.darker(125);
|
||||
painter->setPen(QPen(color, 1));
|
||||
if (draw_left) painter->drawLine(rc.topLeft(), rc.bottomLeft());
|
||||
if (draw_right) painter->drawLine(rc.topRight(), rc.bottomRight());
|
||||
if (draw_bottom) painter->drawLine(rc.bottomLeft(), rc.bottomRight());
|
||||
if (draw_top) painter->drawLine(rc.topLeft(), rc.topRight());
|
||||
|
||||
if (!subtract.isEmpty()) {
|
||||
// fill gaps inside corners.
|
||||
painter->setPen(QPen(color, 2, Qt::SolidLine, Qt::SquareCap, Qt::MiterJoin));
|
||||
for (auto &r : subtract) {
|
||||
painter->drawRect(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
92
tools/cabana/binaryview.h
Normal file
92
tools/cabana/binaryview.h
Normal file
@@ -0,0 +1,92 @@
|
||||
#pragma once
|
||||
|
||||
#include <tuple>
|
||||
#include <vector>
|
||||
|
||||
#include <QList>
|
||||
#include <QSet>
|
||||
#include <QStyledItemDelegate>
|
||||
#include <QTableView>
|
||||
|
||||
#include "tools/cabana/dbc/dbcmanager.h"
|
||||
#include "tools/cabana/streams/abstractstream.h"
|
||||
|
||||
class BinaryItemDelegate : public QStyledItemDelegate {
|
||||
public:
|
||||
BinaryItemDelegate(QObject *parent);
|
||||
void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override;
|
||||
bool hasSignal(const QModelIndex &index, int dx, int dy, const cabana::Signal *sig) const;
|
||||
void drawSignalCell(QPainter* painter, const QStyleOptionViewItem &option, const QModelIndex &index, const cabana::Signal *sig) const;
|
||||
|
||||
QFont small_font, hex_font;
|
||||
std::array<QStaticText, 256> hex_text_table;
|
||||
std::array<QStaticText, 2> bin_text_table;
|
||||
};
|
||||
|
||||
class BinaryViewModel : public QAbstractTableModel {
|
||||
public:
|
||||
BinaryViewModel(QObject *parent) : QAbstractTableModel(parent) {}
|
||||
void refresh();
|
||||
void updateState();
|
||||
void updateItem(int row, int col, uint8_t val, const QColor &color);
|
||||
QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override;
|
||||
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
|
||||
int rowCount(const QModelIndex &parent = QModelIndex()) const override { return row_count; }
|
||||
int columnCount(const QModelIndex &parent = QModelIndex()) const override { return column_count; }
|
||||
QModelIndex index(int row, int column, const QModelIndex &parent = QModelIndex()) const override {
|
||||
return createIndex(row, column, (void *)&items[row * column_count + column]);
|
||||
}
|
||||
Qt::ItemFlags flags(const QModelIndex &index) const override {
|
||||
return (index.column() == column_count - 1) ? Qt::ItemIsEnabled : Qt::ItemIsEnabled | Qt::ItemIsSelectable;
|
||||
}
|
||||
|
||||
struct Item {
|
||||
QColor bg_color = QColor(102, 86, 169, 255);
|
||||
bool is_msb = false;
|
||||
bool is_lsb = false;
|
||||
uint8_t val;
|
||||
QList<const cabana::Signal *> sigs;
|
||||
bool valid = false;
|
||||
};
|
||||
std::vector<Item> items;
|
||||
|
||||
MessageId msg_id;
|
||||
int row_count = 0;
|
||||
const int column_count = 9;
|
||||
};
|
||||
|
||||
class BinaryView : public QTableView {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
BinaryView(QWidget *parent = nullptr);
|
||||
void setMessage(const MessageId &message_id);
|
||||
void highlight(const cabana::Signal *sig);
|
||||
QSet<const cabana::Signal*> getOverlappingSignals() const;
|
||||
inline void updateState() { model->updateState(); }
|
||||
QSize minimumSizeHint() const override;
|
||||
|
||||
signals:
|
||||
void signalClicked(const cabana::Signal *sig);
|
||||
void signalHovered(const cabana::Signal *sig);
|
||||
void editSignal(const cabana::Signal *origin_s, cabana::Signal &s);
|
||||
void showChart(const MessageId &id, const cabana::Signal *sig, bool show, bool merge);
|
||||
|
||||
private:
|
||||
void addShortcuts();
|
||||
void refresh();
|
||||
std::tuple<int, int, bool> getSelection(QModelIndex index);
|
||||
void setSelection(const QRect &rect, QItemSelectionModel::SelectionFlags flags) override;
|
||||
void mousePressEvent(QMouseEvent *event) override;
|
||||
void mouseMoveEvent(QMouseEvent *event) override;
|
||||
void mouseReleaseEvent(QMouseEvent *event) override;
|
||||
void leaveEvent(QEvent *event) override;
|
||||
void highlightPosition(const QPoint &pt);
|
||||
|
||||
QModelIndex anchor_index;
|
||||
BinaryViewModel *model;
|
||||
BinaryItemDelegate *delegate;
|
||||
const cabana::Signal *resize_sig = nullptr;
|
||||
const cabana::Signal *hovered_sig = nullptr;
|
||||
friend class BinaryItemDelegate;
|
||||
};
|
||||
109
tools/cabana/cabana.cc
Normal file
109
tools/cabana/cabana.cc
Normal file
@@ -0,0 +1,109 @@
|
||||
#include <QApplication>
|
||||
#include <QCommandLineParser>
|
||||
|
||||
#include "selfdrive/ui/qt/util.h"
|
||||
#include "tools/cabana/mainwin.h"
|
||||
#include "tools/cabana/streamselector.h"
|
||||
#include "tools/cabana/streams/devicestream.h"
|
||||
#include "tools/cabana/streams/pandastream.h"
|
||||
#include "tools/cabana/streams/replaystream.h"
|
||||
#include "tools/cabana/streams/socketcanstream.h"
|
||||
|
||||
int main(int argc, char *argv[]) {
|
||||
QCoreApplication::setApplicationName("Cabana");
|
||||
QCoreApplication::setAttribute(Qt::AA_ShareOpenGLContexts);
|
||||
initApp(argc, argv, false);
|
||||
QApplication app(argc, argv);
|
||||
app.setApplicationDisplayName("Cabana");
|
||||
app.setWindowIcon(QIcon(":cabana-icon.png"));
|
||||
|
||||
UnixSignalHandler signalHandler;
|
||||
utils::setTheme(settings.theme);
|
||||
|
||||
QCommandLineParser cmd_parser;
|
||||
cmd_parser.addHelpOption();
|
||||
cmd_parser.addPositionalArgument("route", "the drive to replay. find your drives at connect.comma.ai");
|
||||
cmd_parser.addOption({"demo", "use a demo route instead of providing your own"});
|
||||
cmd_parser.addOption({"qcam", "load qcamera"});
|
||||
cmd_parser.addOption({"ecam", "load wide road camera"});
|
||||
cmd_parser.addOption({"dcam", "load driver camera"});
|
||||
cmd_parser.addOption({"stream", "read can messages from live streaming"});
|
||||
cmd_parser.addOption({"panda", "read can messages from panda"});
|
||||
cmd_parser.addOption({"panda-serial", "read can messages from panda with given serial", "panda-serial"});
|
||||
if (SocketCanStream::available()) {
|
||||
cmd_parser.addOption({"socketcan", "read can messages from given SocketCAN device", "socketcan"});
|
||||
}
|
||||
cmd_parser.addOption({"zmq", "the ip address on which to receive zmq messages", "zmq"});
|
||||
cmd_parser.addOption({"data_dir", "local directory with routes", "data_dir"});
|
||||
cmd_parser.addOption({"no-vipc", "do not output video"});
|
||||
cmd_parser.addOption({"dbc", "dbc file to open", "dbc"});
|
||||
cmd_parser.process(app);
|
||||
|
||||
QString dbc_file = cmd_parser.isSet("dbc") ? cmd_parser.value("dbc") : "";
|
||||
|
||||
AbstractStream *stream = nullptr;
|
||||
if (cmd_parser.isSet("stream")) {
|
||||
stream = new DeviceStream(&app, cmd_parser.value("zmq"));
|
||||
} else if (cmd_parser.isSet("panda") || cmd_parser.isSet("panda-serial")) {
|
||||
PandaStreamConfig config = {};
|
||||
if (cmd_parser.isSet("panda-serial")) {
|
||||
config.serial = cmd_parser.value("panda-serial");
|
||||
}
|
||||
try {
|
||||
stream = new PandaStream(&app, config);
|
||||
} catch (std::exception &e) {
|
||||
qWarning() << e.what();
|
||||
return 0;
|
||||
}
|
||||
} else if (cmd_parser.isSet("socketcan")) {
|
||||
SocketCanStreamConfig config = {};
|
||||
config.device = cmd_parser.value("socketcan");
|
||||
stream = new SocketCanStream(&app, config);
|
||||
} else {
|
||||
uint32_t replay_flags = REPLAY_FLAG_NONE;
|
||||
if (cmd_parser.isSet("ecam")) replay_flags |= REPLAY_FLAG_ECAM;
|
||||
if (cmd_parser.isSet("qcam")) replay_flags |= REPLAY_FLAG_QCAMERA;
|
||||
if (cmd_parser.isSet("dcam")) replay_flags |= REPLAY_FLAG_DCAM;
|
||||
if (cmd_parser.isSet("no-vipc")) replay_flags |= REPLAY_FLAG_NO_VIPC;
|
||||
|
||||
const QStringList args = cmd_parser.positionalArguments();
|
||||
QString route;
|
||||
if (args.size() > 0) {
|
||||
route = args.first();
|
||||
} else if (cmd_parser.isSet("demo")) {
|
||||
route = DEMO_ROUTE;
|
||||
}
|
||||
if (!route.isEmpty()) {
|
||||
auto replay_stream = new ReplayStream(&app);
|
||||
if (!replay_stream->loadRoute(route, cmd_parser.value("data_dir"), replay_flags)) {
|
||||
return 0;
|
||||
}
|
||||
stream = replay_stream;
|
||||
}
|
||||
}
|
||||
|
||||
int ret = 0;
|
||||
{
|
||||
MainWindow w;
|
||||
QTimer::singleShot(0, [&]() {
|
||||
if (!stream) {
|
||||
StreamSelector dlg(&stream);
|
||||
dlg.exec();
|
||||
dbc_file = dlg.dbcFile();
|
||||
}
|
||||
if (!stream) {
|
||||
stream = new DummyStream(&app);
|
||||
}
|
||||
stream->start();
|
||||
if (!dbc_file.isEmpty()) {
|
||||
w.loadFile(dbc_file);
|
||||
}
|
||||
w.show();
|
||||
});
|
||||
|
||||
ret = app.exec();
|
||||
}
|
||||
|
||||
delete can;
|
||||
return ret;
|
||||
}
|
||||
865
tools/cabana/chart/chart.cc
Normal file
865
tools/cabana/chart/chart.cc
Normal file
@@ -0,0 +1,865 @@
|
||||
#include "tools/cabana/chart/chart.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <limits>
|
||||
|
||||
#include <QActionGroup>
|
||||
#include <QApplication>
|
||||
#include <QDrag>
|
||||
#include <QGraphicsLayout>
|
||||
#include <QGraphicsDropShadowEffect>
|
||||
#include <QGraphicsItemGroup>
|
||||
#include <QGraphicsOpacityEffect>
|
||||
#include <QMimeData>
|
||||
#include <QOpenGLWidget>
|
||||
#include <QPropertyAnimation>
|
||||
#include <QRandomGenerator>
|
||||
#include <QRubberBand>
|
||||
#include <QScreen>
|
||||
#include <QWindow>
|
||||
|
||||
#include "tools/cabana/chart/chartswidget.h"
|
||||
|
||||
// ChartAxisElement's padding is 4 (https://codebrowser.dev/qt5/qtcharts/src/charts/axis/chartaxiselement_p.h.html)
|
||||
const int AXIS_X_TOP_MARGIN = 4;
|
||||
// Define a small value of epsilon to compare double values
|
||||
const float EPSILON = 0.000001;
|
||||
static inline bool xLessThan(const QPointF &p, float x) { return p.x() < (x - EPSILON); }
|
||||
|
||||
ChartView::ChartView(const std::pair<double, double> &x_range, ChartsWidget *parent)
|
||||
: charts_widget(parent), QChartView(parent) {
|
||||
series_type = (SeriesType)settings.chart_series_type;
|
||||
chart()->setBackgroundVisible(false);
|
||||
axis_x = new QValueAxis(this);
|
||||
axis_y = new QValueAxis(this);
|
||||
chart()->addAxis(axis_x, Qt::AlignBottom);
|
||||
chart()->addAxis(axis_y, Qt::AlignLeft);
|
||||
chart()->legend()->layout()->setContentsMargins(0, 0, 0, 0);
|
||||
chart()->legend()->setShowToolTips(true);
|
||||
chart()->setMargins({0, 0, 0, 0});
|
||||
|
||||
axis_x->setRange(x_range.first, x_range.second);
|
||||
|
||||
tip_label = new TipLabel(this);
|
||||
createToolButtons();
|
||||
setRubberBand(QChartView::HorizontalRubberBand);
|
||||
setMouseTracking(true);
|
||||
setTheme(settings.theme == DARK_THEME ? QChart::QChart::ChartThemeDark : QChart::ChartThemeLight);
|
||||
signal_value_font.setPointSize(9);
|
||||
|
||||
QObject::connect(axis_y, &QValueAxis::rangeChanged, this, &ChartView::resetChartCache);
|
||||
QObject::connect(axis_y, &QAbstractAxis::titleTextChanged, this, &ChartView::resetChartCache);
|
||||
QObject::connect(window()->windowHandle(), &QWindow::screenChanged, this, &ChartView::resetChartCache);
|
||||
|
||||
QObject::connect(dbc(), &DBCManager::signalRemoved, this, &ChartView::signalRemoved);
|
||||
QObject::connect(dbc(), &DBCManager::signalUpdated, this, &ChartView::signalUpdated);
|
||||
QObject::connect(dbc(), &DBCManager::msgRemoved, this, &ChartView::msgRemoved);
|
||||
QObject::connect(dbc(), &DBCManager::msgUpdated, this, &ChartView::msgUpdated);
|
||||
}
|
||||
|
||||
void ChartView::createToolButtons() {
|
||||
move_icon = new QGraphicsPixmapItem(utils::icon("grip-horizontal"), chart());
|
||||
move_icon->setToolTip(tr("Drag and drop to move chart"));
|
||||
|
||||
QToolButton *remove_btn = new ToolButton("x", tr("Remove Chart"));
|
||||
close_btn_proxy = new QGraphicsProxyWidget(chart());
|
||||
close_btn_proxy->setWidget(remove_btn);
|
||||
close_btn_proxy->setZValue(chart()->zValue() + 11);
|
||||
|
||||
menu = new QMenu(this);
|
||||
// series types
|
||||
auto change_series_group = new QActionGroup(menu);
|
||||
change_series_group->setExclusive(true);
|
||||
QStringList types{tr("Line"), tr("Step Line"), tr("Scatter")};
|
||||
for (int i = 0; i < types.size(); ++i) {
|
||||
QAction *act = new QAction(types[i], change_series_group);
|
||||
act->setData(i);
|
||||
act->setCheckable(true);
|
||||
act->setChecked(i == (int)series_type);
|
||||
menu->addAction(act);
|
||||
}
|
||||
menu->addSeparator();
|
||||
menu->addAction(tr("Manage Signals"), this, &ChartView::manageSignals);
|
||||
split_chart_act = menu->addAction(tr("Split Chart"), [this]() { charts_widget->splitChart(this); });
|
||||
|
||||
QToolButton *manage_btn = new ToolButton("list", "");
|
||||
manage_btn->setMenu(menu);
|
||||
manage_btn->setPopupMode(QToolButton::InstantPopup);
|
||||
manage_btn->setStyleSheet("QToolButton::menu-indicator { image: none; }");
|
||||
manage_btn_proxy = new QGraphicsProxyWidget(chart());
|
||||
manage_btn_proxy->setWidget(manage_btn);
|
||||
manage_btn_proxy->setZValue(chart()->zValue() + 11);
|
||||
|
||||
close_act = new QAction(tr("Close"), this);
|
||||
QObject::connect(close_act, &QAction::triggered, [this] () { charts_widget->removeChart(this); });
|
||||
QObject::connect(remove_btn, &QToolButton::clicked, close_act, &QAction::triggered);
|
||||
QObject::connect(change_series_group, &QActionGroup::triggered, [this](QAction *action) {
|
||||
setSeriesType((SeriesType)action->data().toInt());
|
||||
});
|
||||
}
|
||||
|
||||
QSize ChartView::sizeHint() const {
|
||||
return {CHART_MIN_WIDTH, settings.chart_height};
|
||||
}
|
||||
|
||||
void ChartView::setTheme(QChart::ChartTheme theme) {
|
||||
chart()->setTheme(theme);
|
||||
if (theme == QChart::ChartThemeDark) {
|
||||
axis_x->setTitleBrush(palette().text());
|
||||
axis_x->setLabelsBrush(palette().text());
|
||||
axis_y->setTitleBrush(palette().text());
|
||||
axis_y->setLabelsBrush(palette().text());
|
||||
chart()->legend()->setLabelColor(palette().color(QPalette::Text));
|
||||
}
|
||||
for (auto &s : sigs) {
|
||||
s.series->setColor(s.sig->color);
|
||||
}
|
||||
}
|
||||
|
||||
void ChartView::addSignal(const MessageId &msg_id, const cabana::Signal *sig) {
|
||||
if (hasSignal(msg_id, sig)) return;
|
||||
|
||||
QXYSeries *series = createSeries(series_type, sig->color);
|
||||
sigs.push_back({.msg_id = msg_id, .sig = sig, .series = series});
|
||||
updateSeries(sig);
|
||||
updateSeriesPoints();
|
||||
updateTitle();
|
||||
emit charts_widget->seriesChanged();
|
||||
}
|
||||
|
||||
bool ChartView::hasSignal(const MessageId &msg_id, const cabana::Signal *sig) const {
|
||||
return std::any_of(sigs.cbegin(), sigs.cend(), [&](auto &s) { return s.msg_id == msg_id && s.sig == sig; });
|
||||
}
|
||||
|
||||
void ChartView::removeIf(std::function<bool(const SigItem &s)> predicate) {
|
||||
int prev_size = sigs.size();
|
||||
for (auto it = sigs.begin(); it != sigs.end(); /**/) {
|
||||
if (predicate(*it)) {
|
||||
chart()->removeSeries(it->series);
|
||||
it->series->deleteLater();
|
||||
it = sigs.erase(it);
|
||||
} else {
|
||||
++it;
|
||||
}
|
||||
}
|
||||
if (sigs.empty()) {
|
||||
charts_widget->removeChart(this);
|
||||
} else if (sigs.size() != prev_size) {
|
||||
emit charts_widget->seriesChanged();
|
||||
updateAxisY();
|
||||
resetChartCache();
|
||||
}
|
||||
}
|
||||
|
||||
void ChartView::signalUpdated(const cabana::Signal *sig) {
|
||||
if (std::any_of(sigs.cbegin(), sigs.cend(), [=](auto &s) { return s.sig == sig; })) {
|
||||
for (const auto &s : sigs) {
|
||||
if (s.sig == sig && s.series->color() != sig->color) {
|
||||
setSeriesColor(s.series, sig->color);
|
||||
}
|
||||
}
|
||||
updateTitle();
|
||||
updateSeries(sig);
|
||||
}
|
||||
}
|
||||
|
||||
void ChartView::msgUpdated(MessageId id) {
|
||||
if (std::any_of(sigs.cbegin(), sigs.cend(), [=](auto &s) { return s.msg_id.address == id.address; })) {
|
||||
updateTitle();
|
||||
}
|
||||
}
|
||||
|
||||
void ChartView::manageSignals() {
|
||||
SignalSelector dlg(tr("Manage Chart"), this);
|
||||
for (auto &s : sigs) {
|
||||
dlg.addSelected(s.msg_id, s.sig);
|
||||
}
|
||||
if (dlg.exec() == QDialog::Accepted) {
|
||||
auto items = dlg.seletedItems();
|
||||
for (auto s : items) {
|
||||
addSignal(s->msg_id, s->sig);
|
||||
}
|
||||
removeIf([&](auto &s) {
|
||||
return std::none_of(items.cbegin(), items.cend(), [&](auto &it) { return s.msg_id == it->msg_id && s.sig == it->sig; });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void ChartView::resizeEvent(QResizeEvent *event) {
|
||||
qreal left, top, right, bottom;
|
||||
chart()->layout()->getContentsMargins(&left, &top, &right, &bottom);
|
||||
move_icon->setPos(left, top);
|
||||
close_btn_proxy->setPos(rect().right() - right - close_btn_proxy->size().width(), top);
|
||||
int x = close_btn_proxy->pos().x() - manage_btn_proxy->size().width() - style()->pixelMetric(QStyle::PM_LayoutHorizontalSpacing);
|
||||
manage_btn_proxy->setPos(x, top);
|
||||
if (align_to > 0) {
|
||||
updatePlotArea(align_to, true);
|
||||
}
|
||||
QChartView::resizeEvent(event);
|
||||
}
|
||||
|
||||
void ChartView::updatePlotArea(int left_pos, bool force) {
|
||||
if (align_to != left_pos || force) {
|
||||
align_to = left_pos;
|
||||
|
||||
qreal left, top, right, bottom;
|
||||
chart()->layout()->getContentsMargins(&left, &top, &right, &bottom);
|
||||
QSizeF legend_size = chart()->legend()->layout()->minimumSize();
|
||||
legend_size.setWidth(manage_btn_proxy->sceneBoundingRect().left() - move_icon->sceneBoundingRect().right());
|
||||
chart()->legend()->setGeometry({move_icon->sceneBoundingRect().topRight(), legend_size});
|
||||
|
||||
// add top space for signal value
|
||||
int adjust_top = chart()->legend()->geometry().height() + QFontMetrics(signal_value_font).height() + 3;
|
||||
adjust_top = std::max<int>(adjust_top, manage_btn_proxy->sceneBoundingRect().height() + style()->pixelMetric(QStyle::PM_LayoutTopMargin));
|
||||
// add right space for x-axis label
|
||||
QSizeF x_label_size = QFontMetrics(axis_x->labelsFont()).size(Qt::TextSingleLine, QString::number(axis_x->max(), 'f', 2));
|
||||
x_label_size += QSizeF{5, 5};
|
||||
chart()->setPlotArea(rect().adjusted(align_to + left, adjust_top + top, -x_label_size.width() / 2 - right, -x_label_size.height() - bottom));
|
||||
chart()->layout()->invalidate();
|
||||
resetChartCache();
|
||||
}
|
||||
}
|
||||
|
||||
void ChartView::updateTitle() {
|
||||
for (QLegendMarker *marker : chart()->legend()->markers()) {
|
||||
QObject::connect(marker, &QLegendMarker::clicked, this, &ChartView::handleMarkerClicked, Qt::UniqueConnection);
|
||||
}
|
||||
|
||||
// Use CSS to draw titles in the WindowText color
|
||||
auto tmp = palette().color(QPalette::WindowText);
|
||||
auto titleColorCss = tmp.name(QColor::HexArgb);
|
||||
// Draw message details in similar color, but slightly fade it to the background
|
||||
tmp.setAlpha(180);
|
||||
auto msgColorCss = tmp.name(QColor::HexArgb);
|
||||
|
||||
for (auto &s : sigs) {
|
||||
auto decoration = s.series->isVisible() ? "none" : "line-through";
|
||||
s.series->setName(QString("<span style=\"text-decoration:%1; color:%2\"><b>%3</b> <font color=\"%4\">%5 %6</font></span>")
|
||||
.arg(decoration, titleColorCss, s.sig->name,
|
||||
msgColorCss, msgName(s.msg_id), s.msg_id.toString()));
|
||||
}
|
||||
split_chart_act->setEnabled(sigs.size() > 1);
|
||||
resetChartCache();
|
||||
}
|
||||
|
||||
void ChartView::updatePlot(double cur, double min, double max) {
|
||||
cur_sec = cur;
|
||||
if (min != axis_x->min() || max != axis_x->max()) {
|
||||
axis_x->setRange(min, max);
|
||||
updateAxisY();
|
||||
updateSeriesPoints();
|
||||
// update tooltip
|
||||
if (tooltip_x >= 0) {
|
||||
showTip(chart()->mapToValue({tooltip_x, 0}).x());
|
||||
}
|
||||
resetChartCache();
|
||||
}
|
||||
viewport()->update();
|
||||
}
|
||||
|
||||
void ChartView::updateSeriesPoints() {
|
||||
// Show points when zoomed in enough
|
||||
for (auto &s : sigs) {
|
||||
auto begin = std::lower_bound(s.vals.cbegin(), s.vals.cend(), axis_x->min(), xLessThan);
|
||||
auto end = std::lower_bound(begin, s.vals.cend(), axis_x->max(), xLessThan);
|
||||
if (begin != end) {
|
||||
int num_points = std::max<int>((end - begin), 1);
|
||||
QPointF right_pt = end == s.vals.cend() ? s.vals.back() : *end;
|
||||
double pixels_per_point = (chart()->mapToPosition(right_pt).x() - chart()->mapToPosition(*begin).x()) / num_points;
|
||||
|
||||
if (series_type == SeriesType::Scatter) {
|
||||
qreal size = std::clamp(pixels_per_point / 2.0, 2.0, 8.0);
|
||||
if (s.series->useOpenGL()) {
|
||||
size *= devicePixelRatioF();
|
||||
}
|
||||
((QScatterSeries *)s.series)->setMarkerSize(size);
|
||||
} else {
|
||||
s.series->setPointsVisible(pixels_per_point > 20);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ChartView::appendCanEvents(const cabana::Signal *sig, const std::vector<const CanEvent *> &events,
|
||||
std::vector<QPointF> &vals, std::vector<QPointF> &step_vals) {
|
||||
vals.reserve(vals.size() + events.capacity());
|
||||
step_vals.reserve(step_vals.size() + events.capacity() * 2);
|
||||
|
||||
double value = 0;
|
||||
const uint64_t begin_mono_time = can->routeStartTime() * 1e9;
|
||||
for (const CanEvent *e : events) {
|
||||
if (sig->getValue(e->dat, e->size, &value)) {
|
||||
const double ts = (e->mono_time - std::min(e->mono_time, begin_mono_time)) / 1e9;
|
||||
vals.emplace_back(ts, value);
|
||||
if (!step_vals.empty())
|
||||
step_vals.emplace_back(ts, step_vals.back().y());
|
||||
step_vals.emplace_back(ts, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ChartView::updateSeries(const cabana::Signal *sig, const MessageEventsMap *msg_new_events) {
|
||||
for (auto &s : sigs) {
|
||||
if (!sig || s.sig == sig) {
|
||||
if (!msg_new_events) {
|
||||
s.vals.clear();
|
||||
s.step_vals.clear();
|
||||
}
|
||||
auto events = msg_new_events ? msg_new_events : &can->eventsMap();
|
||||
auto it = events->find(s.msg_id);
|
||||
if (it == events->end() || it->second.empty()) continue;
|
||||
|
||||
if (s.vals.empty() || (it->second.back()->mono_time / 1e9 - can->routeStartTime()) > s.vals.back().x()) {
|
||||
appendCanEvents(s.sig, it->second, s.vals, s.step_vals);
|
||||
} else {
|
||||
std::vector<QPointF> vals, step_vals;
|
||||
appendCanEvents(s.sig, it->second, vals, step_vals);
|
||||
s.vals.insert(std::lower_bound(s.vals.begin(), s.vals.end(), vals.front().x(), xLessThan),
|
||||
vals.begin(), vals.end());
|
||||
s.step_vals.insert(std::lower_bound(s.step_vals.begin(), s.step_vals.end(), step_vals.front().x(), xLessThan),
|
||||
step_vals.begin(), step_vals.end());
|
||||
}
|
||||
|
||||
if (!can->liveStreaming()) {
|
||||
s.segment_tree.build(s.vals);
|
||||
}
|
||||
s.series->replace(QVector<QPointF>::fromStdVector(series_type == SeriesType::StepLine ? s.step_vals : s.vals));
|
||||
}
|
||||
}
|
||||
updateAxisY();
|
||||
// invoke resetChartCache in ui thread
|
||||
QMetaObject::invokeMethod(this, &ChartView::resetChartCache, Qt::QueuedConnection);
|
||||
}
|
||||
|
||||
// auto zoom on yaxis
|
||||
void ChartView::updateAxisY() {
|
||||
if (sigs.empty()) return;
|
||||
|
||||
double min = std::numeric_limits<double>::max();
|
||||
double max = std::numeric_limits<double>::lowest();
|
||||
QString unit = sigs[0].sig->unit;
|
||||
|
||||
for (auto &s : sigs) {
|
||||
if (!s.series->isVisible()) continue;
|
||||
|
||||
// Only show unit when all signals have the same unit
|
||||
if (unit != s.sig->unit) {
|
||||
unit.clear();
|
||||
}
|
||||
|
||||
auto first = std::lower_bound(s.vals.cbegin(), s.vals.cend(), axis_x->min(), xLessThan);
|
||||
auto last = std::lower_bound(first, s.vals.cend(), axis_x->max(), xLessThan);
|
||||
s.min = std::numeric_limits<double>::max();
|
||||
s.max = std::numeric_limits<double>::lowest();
|
||||
if (can->liveStreaming()) {
|
||||
for (auto it = first; it != last; ++it) {
|
||||
if (it->y() < s.min) s.min = it->y();
|
||||
if (it->y() > s.max) s.max = it->y();
|
||||
}
|
||||
} else {
|
||||
std::tie(s.min, s.max) = s.segment_tree.minmax(std::distance(s.vals.cbegin(), first), std::distance(s.vals.cbegin(), last));
|
||||
}
|
||||
min = std::min(min, s.min);
|
||||
max = std::max(max, s.max);
|
||||
}
|
||||
if (min == std::numeric_limits<double>::max()) min = 0;
|
||||
if (max == std::numeric_limits<double>::lowest()) max = 0;
|
||||
|
||||
if (axis_y->titleText() != unit) {
|
||||
axis_y->setTitleText(unit);
|
||||
y_label_width = 0; // recalc width
|
||||
}
|
||||
|
||||
double delta = std::abs(max - min) < 1e-3 ? 1 : (max - min) * 0.05;
|
||||
auto [min_y, max_y, tick_count] = getNiceAxisNumbers(min - delta, max + delta, 3);
|
||||
if (min_y != axis_y->min() || max_y != axis_y->max() || y_label_width == 0) {
|
||||
axis_y->setRange(min_y, max_y);
|
||||
axis_y->setTickCount(tick_count);
|
||||
|
||||
int n = std::max(int(-std::floor(std::log10((max_y - min_y) / (tick_count - 1)))), 0);
|
||||
int max_label_width = 0;
|
||||
QFontMetrics fm(axis_y->labelsFont());
|
||||
for (int i = 0; i < tick_count; i++) {
|
||||
qreal value = min_y + (i * (max_y - min_y) / (tick_count - 1));
|
||||
max_label_width = std::max(max_label_width, fm.width(QString::number(value, 'f', n)));
|
||||
}
|
||||
|
||||
int title_spacing = unit.isEmpty() ? 0 : QFontMetrics(axis_y->titleFont()).size(Qt::TextSingleLine, unit).height();
|
||||
y_label_width = title_spacing + max_label_width + 15;
|
||||
axis_y->setLabelFormat(QString("%.%1f").arg(n));
|
||||
emit axisYLabelWidthChanged(y_label_width);
|
||||
}
|
||||
}
|
||||
|
||||
std::tuple<double, double, int> ChartView::getNiceAxisNumbers(qreal min, qreal max, int tick_count) {
|
||||
qreal range = niceNumber((max - min), true); // range with ceiling
|
||||
qreal step = niceNumber(range / (tick_count - 1), false);
|
||||
min = std::floor(min / step);
|
||||
max = std::ceil(max / step);
|
||||
tick_count = int(max - min) + 1;
|
||||
return {min * step, max * step, tick_count};
|
||||
}
|
||||
|
||||
// nice numbers can be expressed as form of 1*10^n, 2* 10^n or 5*10^n
|
||||
qreal ChartView::niceNumber(qreal x, bool ceiling) {
|
||||
qreal z = std::pow(10, std::floor(std::log10(x))); //find corresponding number of the form of 10^n than is smaller than x
|
||||
qreal q = x / z; //q<10 && q>=1;
|
||||
if (ceiling) {
|
||||
if (q <= 1.0) q = 1;
|
||||
else if (q <= 2.0) q = 2;
|
||||
else if (q <= 5.0) q = 5;
|
||||
else q = 10;
|
||||
} else {
|
||||
if (q < 1.5) q = 1;
|
||||
else if (q < 3.0) q = 2;
|
||||
else if (q < 7.0) q = 5;
|
||||
else q = 10;
|
||||
}
|
||||
return q * z;
|
||||
}
|
||||
|
||||
void ChartView::leaveEvent(QEvent *event) {
|
||||
if (tip_label->isVisible()) {
|
||||
charts_widget->showValueTip(-1);
|
||||
}
|
||||
QChartView::leaveEvent(event);
|
||||
}
|
||||
|
||||
QPixmap getBlankShadowPixmap(const QPixmap &px, int radius) {
|
||||
QGraphicsDropShadowEffect *e = new QGraphicsDropShadowEffect;
|
||||
e->setColor(QColor(40, 40, 40, 245));
|
||||
e->setOffset(0, 0);
|
||||
e->setBlurRadius(radius);
|
||||
|
||||
qreal dpr = px.devicePixelRatio();
|
||||
QPixmap blank(px.size());
|
||||
blank.setDevicePixelRatio(dpr);
|
||||
blank.fill(Qt::white);
|
||||
|
||||
QGraphicsScene scene;
|
||||
QGraphicsPixmapItem item(blank);
|
||||
item.setGraphicsEffect(e);
|
||||
scene.addItem(&item);
|
||||
|
||||
QPixmap shadow(px.size() + QSize(radius * dpr * 2, radius * dpr * 2));
|
||||
shadow.setDevicePixelRatio(dpr);
|
||||
shadow.fill(Qt::transparent);
|
||||
QPainter p(&shadow);
|
||||
scene.render(&p, {QPoint(), shadow.size() / dpr}, item.boundingRect().adjusted(-radius, -radius, radius, radius));
|
||||
return shadow;
|
||||
}
|
||||
|
||||
static QPixmap getDropPixmap(const QPixmap &src) {
|
||||
static QPixmap shadow_px;
|
||||
const int radius = 10;
|
||||
if (shadow_px.size() != src.size() + QSize(radius * 2, radius * 2)) {
|
||||
shadow_px = getBlankShadowPixmap(src, radius);
|
||||
}
|
||||
QPixmap px = shadow_px;
|
||||
QPainter p(&px);
|
||||
QRectF target_rect(QPointF(radius, radius), src.size() / src.devicePixelRatio());
|
||||
p.drawPixmap(target_rect.topLeft(), src);
|
||||
p.setCompositionMode(QPainter::CompositionMode_DestinationIn);
|
||||
p.fillRect(target_rect, QColor(0, 0, 0, 200));
|
||||
return px;
|
||||
}
|
||||
|
||||
void ChartView::contextMenuEvent(QContextMenuEvent *event) {
|
||||
QMenu context_menu(this);
|
||||
context_menu.addActions(menu->actions());
|
||||
context_menu.addSeparator();
|
||||
context_menu.addAction(charts_widget->undo_zoom_action);
|
||||
context_menu.addAction(charts_widget->redo_zoom_action);
|
||||
context_menu.addSeparator();
|
||||
context_menu.addAction(close_act);
|
||||
context_menu.exec(event->globalPos());
|
||||
}
|
||||
|
||||
void ChartView::mousePressEvent(QMouseEvent *event) {
|
||||
if (event->button() == Qt::LeftButton && move_icon->sceneBoundingRect().contains(event->pos())) {
|
||||
QMimeData *mimeData = new QMimeData;
|
||||
mimeData->setData(CHART_MIME_TYPE, QByteArray::number((qulonglong)this));
|
||||
QPixmap px = grab().scaledToWidth(CHART_MIN_WIDTH * viewport()->devicePixelRatio(), Qt::SmoothTransformation);
|
||||
charts_widget->stopAutoScroll();
|
||||
QDrag *drag = new QDrag(this);
|
||||
drag->setMimeData(mimeData);
|
||||
drag->setPixmap(getDropPixmap(px));
|
||||
drag->setHotSpot(-QPoint(5, 5));
|
||||
drag->exec(Qt::CopyAction | Qt::MoveAction, Qt::MoveAction);
|
||||
} else if (event->button() == Qt::LeftButton && QApplication::keyboardModifiers().testFlag(Qt::ShiftModifier)) {
|
||||
// Save current playback state when scrubbing
|
||||
resume_after_scrub = !can->isPaused();
|
||||
if (resume_after_scrub) {
|
||||
can->pause(true);
|
||||
}
|
||||
is_scrubbing = true;
|
||||
} else {
|
||||
QChartView::mousePressEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
void ChartView::mouseReleaseEvent(QMouseEvent *event) {
|
||||
auto rubber = findChild<QRubberBand *>();
|
||||
if (event->button() == Qt::LeftButton && rubber && rubber->isVisible()) {
|
||||
rubber->hide();
|
||||
auto rect = rubber->geometry().normalized();
|
||||
// Prevent zooming/seeking past the end of the route
|
||||
double min = std::clamp(chart()->mapToValue(rect.topLeft()).x(), 0., can->totalSeconds());
|
||||
double max = std::clamp(chart()->mapToValue(rect.bottomRight()).x(), 0., can->totalSeconds());
|
||||
if (rubber->width() <= 0) {
|
||||
// no rubber dragged, seek to mouse position
|
||||
can->seekTo(min);
|
||||
} else if (rubber->width() > 10 && (max - min) > 0.01) { // Minimum range is 10 milliseconds.
|
||||
charts_widget->zoom_undo_stack->push(new ZoomCommand(charts_widget, {min, max}));
|
||||
} else {
|
||||
viewport()->update();
|
||||
}
|
||||
event->accept();
|
||||
} else if (event->button() == Qt::RightButton) {
|
||||
charts_widget->zoom_undo_stack->undo();
|
||||
event->accept();
|
||||
} else {
|
||||
QGraphicsView::mouseReleaseEvent(event);
|
||||
}
|
||||
|
||||
// Resume playback if we were scrubbing
|
||||
is_scrubbing = false;
|
||||
if (resume_after_scrub) {
|
||||
can->pause(false);
|
||||
resume_after_scrub = false;
|
||||
}
|
||||
}
|
||||
|
||||
void ChartView::mouseMoveEvent(QMouseEvent *ev) {
|
||||
const auto plot_area = chart()->plotArea();
|
||||
// Scrubbing
|
||||
if (is_scrubbing && QApplication::keyboardModifiers().testFlag(Qt::ShiftModifier)) {
|
||||
if (plot_area.contains(ev->pos())) {
|
||||
can->seekTo(std::clamp(chart()->mapToValue(ev->pos()).x(), 0., can->totalSeconds()));
|
||||
}
|
||||
}
|
||||
|
||||
auto rubber = findChild<QRubberBand *>();
|
||||
bool is_zooming = rubber && rubber->isVisible();
|
||||
clearTrackPoints();
|
||||
|
||||
if (!is_zooming && plot_area.contains(ev->pos())) {
|
||||
const double sec = chart()->mapToValue(ev->pos()).x();
|
||||
charts_widget->showValueTip(sec);
|
||||
} else if (tip_label->isVisible()) {
|
||||
charts_widget->showValueTip(-1);
|
||||
}
|
||||
|
||||
QChartView::mouseMoveEvent(ev);
|
||||
if (is_zooming) {
|
||||
QRect rubber_rect = rubber->geometry();
|
||||
rubber_rect.setLeft(std::max(rubber_rect.left(), (int)plot_area.left()));
|
||||
rubber_rect.setRight(std::min(rubber_rect.right(), (int)plot_area.right()));
|
||||
if (rubber_rect != rubber->geometry()) {
|
||||
rubber->setGeometry(rubber_rect);
|
||||
}
|
||||
viewport()->update();
|
||||
}
|
||||
}
|
||||
|
||||
void ChartView::showTip(double sec) {
|
||||
QRect tip_area(0, chart()->plotArea().top(), rect().width(), chart()->plotArea().height());
|
||||
QRect visible_rect = charts_widget->chartVisibleRect(this).intersected(tip_area);
|
||||
if (visible_rect.isEmpty()) {
|
||||
tip_label->hide();
|
||||
return;
|
||||
}
|
||||
|
||||
tooltip_x = chart()->mapToPosition({sec, 0}).x();
|
||||
qreal x = -1;
|
||||
QStringList text_list;
|
||||
for (auto &s : sigs) {
|
||||
if (s.series->isVisible()) {
|
||||
QString value = "--";
|
||||
// use reverse iterator to find last item <= sec.
|
||||
auto it = std::lower_bound(s.vals.crbegin(), s.vals.crend(), sec, [](auto &p, double x) { return p.x() > x; });
|
||||
if (it != s.vals.crend() && it->x() >= axis_x->min()) {
|
||||
value = QString::number(it->y());
|
||||
s.track_pt = *it;
|
||||
x = std::max(x, chart()->mapToPosition(*it).x());
|
||||
}
|
||||
QString name = sigs.size() > 1 ? s.sig->name + ": " : "";
|
||||
QString min = s.min == std::numeric_limits<double>::max() ? "--" : QString::number(s.min);
|
||||
QString max = s.max == std::numeric_limits<double>::lowest() ? "--" : QString::number(s.max);
|
||||
text_list << QString("<span style=\"color:%1;\">■ </span>%2<b>%3</b> (%4, %5)")
|
||||
.arg(s.series->color().name(), name, value, min, max);
|
||||
}
|
||||
}
|
||||
if (x < 0) {
|
||||
x = tooltip_x;
|
||||
}
|
||||
QPoint pt(x, chart()->plotArea().top());
|
||||
text_list.push_front(QString::number(chart()->mapToValue({x, 0}).x(), 'f', 3));
|
||||
QString text = "<p style='white-space:pre'>" % text_list.join("<br />") % "</p>";
|
||||
tip_label->showText(pt, text, this, visible_rect);
|
||||
viewport()->update();
|
||||
}
|
||||
|
||||
void ChartView::hideTip() {
|
||||
clearTrackPoints();
|
||||
tooltip_x = -1;
|
||||
tip_label->hide();
|
||||
viewport()->update();
|
||||
}
|
||||
|
||||
void ChartView::dragEnterEvent(QDragEnterEvent *event) {
|
||||
if (event->mimeData()->hasFormat(CHART_MIME_TYPE)) {
|
||||
drawDropIndicator(event->source() != this);
|
||||
event->acceptProposedAction();
|
||||
}
|
||||
}
|
||||
|
||||
void ChartView::dragMoveEvent(QDragMoveEvent *event) {
|
||||
if (event->mimeData()->hasFormat(CHART_MIME_TYPE)) {
|
||||
event->setDropAction(event->source() == this ? Qt::MoveAction : Qt::CopyAction);
|
||||
event->accept();
|
||||
}
|
||||
charts_widget->startAutoScroll();
|
||||
}
|
||||
|
||||
void ChartView::dropEvent(QDropEvent *event) {
|
||||
if (event->mimeData()->hasFormat(CHART_MIME_TYPE)) {
|
||||
if (event->source() != this) {
|
||||
ChartView *source_chart = (ChartView *)event->source();
|
||||
for (auto &s : source_chart->sigs) {
|
||||
source_chart->chart()->removeSeries(s.series);
|
||||
addSeries(s.series);
|
||||
}
|
||||
sigs.insert(sigs.end(), std::move_iterator(source_chart->sigs.begin()), std::move_iterator(source_chart->sigs.end()));
|
||||
updateAxisY();
|
||||
updateTitle();
|
||||
startAnimation();
|
||||
|
||||
source_chart->sigs.clear();
|
||||
charts_widget->removeChart(source_chart);
|
||||
event->acceptProposedAction();
|
||||
}
|
||||
can_drop = false;
|
||||
}
|
||||
}
|
||||
|
||||
void ChartView::resetChartCache() {
|
||||
chart_pixmap = QPixmap();
|
||||
viewport()->update();
|
||||
}
|
||||
|
||||
void ChartView::startAnimation() {
|
||||
QGraphicsOpacityEffect *eff = new QGraphicsOpacityEffect(this);
|
||||
viewport()->setGraphicsEffect(eff);
|
||||
QPropertyAnimation *a = new QPropertyAnimation(eff, "opacity");
|
||||
a->setDuration(250);
|
||||
a->setStartValue(0.3);
|
||||
a->setEndValue(1);
|
||||
a->setEasingCurve(QEasingCurve::InBack);
|
||||
a->start(QPropertyAnimation::DeleteWhenStopped);
|
||||
}
|
||||
|
||||
void ChartView::paintEvent(QPaintEvent *event) {
|
||||
if (!can->liveStreaming()) {
|
||||
if (chart_pixmap.isNull()) {
|
||||
const qreal dpr = viewport()->devicePixelRatioF();
|
||||
chart_pixmap = QPixmap(viewport()->size() * dpr);
|
||||
chart_pixmap.setDevicePixelRatio(dpr);
|
||||
QPainter p(&chart_pixmap);
|
||||
p.setRenderHints(QPainter::Antialiasing);
|
||||
drawBackground(&p, viewport()->rect());
|
||||
scene()->setSceneRect(viewport()->rect());
|
||||
scene()->render(&p, viewport()->rect());
|
||||
}
|
||||
|
||||
QPainter painter(viewport());
|
||||
painter.setRenderHints(QPainter::Antialiasing);
|
||||
painter.drawPixmap(QPoint(), chart_pixmap);
|
||||
if (can_drop) {
|
||||
painter.setPen(QPen(palette().color(QPalette::Highlight), 4));
|
||||
painter.drawRect(viewport()->rect());
|
||||
}
|
||||
QRectF exposed_rect = mapToScene(event->region().boundingRect()).boundingRect();
|
||||
drawForeground(&painter, exposed_rect);
|
||||
} else {
|
||||
QChartView::paintEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
void ChartView::drawBackground(QPainter *painter, const QRectF &rect) {
|
||||
painter->fillRect(rect, palette().color(QPalette::Base));
|
||||
}
|
||||
|
||||
void ChartView::drawForeground(QPainter *painter, const QRectF &rect) {
|
||||
drawTimeline(painter);
|
||||
drawSignalValue(painter);
|
||||
// draw track points
|
||||
painter->setPen(Qt::NoPen);
|
||||
qreal track_line_x = -1;
|
||||
for (auto &s : sigs) {
|
||||
if (!s.track_pt.isNull() && s.series->isVisible()) {
|
||||
painter->setBrush(s.series->color().darker(125));
|
||||
QPointF pos = chart()->mapToPosition(s.track_pt);
|
||||
painter->drawEllipse(pos, 5.5, 5.5);
|
||||
track_line_x = std::max(track_line_x, pos.x());
|
||||
}
|
||||
}
|
||||
if (track_line_x > 0) {
|
||||
auto plot_area = chart()->plotArea();
|
||||
painter->setPen(QPen(Qt::darkGray, 1, Qt::DashLine));
|
||||
painter->drawLine(QPointF{track_line_x, plot_area.top()}, QPointF{track_line_x, plot_area.bottom()});
|
||||
}
|
||||
|
||||
// paint points. OpenGL mode lacks certain features (such as showing points)
|
||||
painter->setPen(Qt::NoPen);
|
||||
for (auto &s : sigs) {
|
||||
if (s.series->useOpenGL() && s.series->isVisible() && s.series->pointsVisible()) {
|
||||
auto first = std::lower_bound(s.vals.cbegin(), s.vals.cend(), axis_x->min(), xLessThan);
|
||||
auto last = std::lower_bound(first, s.vals.cend(), axis_x->max(), xLessThan);
|
||||
painter->setBrush(s.series->color());
|
||||
for (auto it = first; it != last; ++it) {
|
||||
painter->drawEllipse(chart()->mapToPosition(*it), 4, 4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
drawRubberBandTimeRange(painter);
|
||||
}
|
||||
|
||||
void ChartView::drawRubberBandTimeRange(QPainter *painter) {
|
||||
auto rubber = findChild<QRubberBand *>();
|
||||
if (rubber && rubber->isVisible() && rubber->width() > 1) {
|
||||
painter->setPen(Qt::white);
|
||||
auto rubber_rect = rubber->geometry().normalized();
|
||||
for (const auto &pt : {rubber_rect.bottomLeft(), rubber_rect.bottomRight()}) {
|
||||
QString sec = QString::number(chart()->mapToValue(pt).x(), 'f', 2);
|
||||
auto r = painter->fontMetrics().boundingRect(sec).adjusted(-6, -AXIS_X_TOP_MARGIN, 6, AXIS_X_TOP_MARGIN);
|
||||
pt == rubber_rect.bottomLeft() ? r.moveTopRight(pt + QPoint{0, 2}) : r.moveTopLeft(pt + QPoint{0, 2});
|
||||
painter->fillRect(r, Qt::gray);
|
||||
painter->drawText(r, Qt::AlignCenter, sec);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ChartView::drawTimeline(QPainter *painter) {
|
||||
const auto plot_area = chart()->plotArea();
|
||||
// draw vertical time line
|
||||
qreal x = std::clamp(chart()->mapToPosition(QPointF{cur_sec, 0}).x(), plot_area.left(), plot_area.right());
|
||||
painter->setPen(QPen(chart()->titleBrush().color(), 2));
|
||||
painter->drawLine(QPointF{x, plot_area.top()}, QPointF{x, plot_area.bottom() + 1});
|
||||
|
||||
// draw current time under the axis-x
|
||||
QString time_str = QString::number(cur_sec, 'f', 2);
|
||||
QSize time_str_size = QFontMetrics(axis_x->labelsFont()).size(Qt::TextSingleLine, time_str) + QSize(8, 2);
|
||||
QRectF time_str_rect(QPointF(x - time_str_size.width() / 2.0, plot_area.bottom() + AXIS_X_TOP_MARGIN), time_str_size);
|
||||
QPainterPath path;
|
||||
path.addRoundedRect(time_str_rect, 3, 3);
|
||||
painter->fillPath(path, settings.theme == DARK_THEME ? Qt::darkGray : Qt::gray);
|
||||
painter->setPen(palette().color(QPalette::BrightText));
|
||||
painter->setFont(axis_x->labelsFont());
|
||||
painter->drawText(time_str_rect, Qt::AlignCenter, time_str);
|
||||
}
|
||||
|
||||
void ChartView::drawSignalValue(QPainter *painter) {
|
||||
auto item_group = qgraphicsitem_cast<QGraphicsItemGroup *>(chart()->legend()->childItems()[0]);
|
||||
assert(item_group != nullptr);
|
||||
auto legend_markers = item_group->childItems();
|
||||
assert(legend_markers.size() == sigs.size());
|
||||
|
||||
painter->setFont(signal_value_font);
|
||||
painter->setPen(chart()->legend()->labelColor());
|
||||
int i = 0;
|
||||
for (auto &s : sigs) {
|
||||
auto it = std::lower_bound(s.vals.crbegin(), s.vals.crend(), cur_sec,
|
||||
[](auto &p, double x) { return p.x() > x + EPSILON; });
|
||||
QString value = (it != s.vals.crend() && it->x() >= axis_x->min()) ? s.sig->formatValue(it->y()) : "--";
|
||||
QRectF marker_rect = legend_markers[i++]->sceneBoundingRect();
|
||||
QRectF value_rect(marker_rect.bottomLeft() - QPoint(0, 1), marker_rect.size());
|
||||
QString elided_val = painter->fontMetrics().elidedText(value, Qt::ElideRight, value_rect.width());
|
||||
painter->drawText(value_rect, Qt::AlignHCenter | Qt::AlignTop, elided_val);
|
||||
}
|
||||
}
|
||||
|
||||
QXYSeries *ChartView::createSeries(SeriesType type, QColor color) {
|
||||
QXYSeries *series = nullptr;
|
||||
if (type == SeriesType::Line) {
|
||||
series = new QLineSeries(this);
|
||||
chart()->legend()->setMarkerShape(QLegend::MarkerShapeRectangle);
|
||||
} else if (type == SeriesType::StepLine) {
|
||||
series = new QLineSeries(this);
|
||||
chart()->legend()->setMarkerShape(QLegend::MarkerShapeFromSeries);
|
||||
} else {
|
||||
series = new QScatterSeries(this);
|
||||
static_cast<QScatterSeries*>(series)->setBorderColor(color);
|
||||
chart()->legend()->setMarkerShape(QLegend::MarkerShapeCircle);
|
||||
}
|
||||
series->setColor(color);
|
||||
// TODO: Due to a bug in CameraWidget the camera frames
|
||||
// are drawn instead of the graphs on MacOS. Re-enable OpenGL when fixed
|
||||
#ifndef __APPLE__
|
||||
series->setUseOpenGL(true);
|
||||
// Qt doesn't properly apply device pixel ratio in OpenGL mode
|
||||
QPen pen = series->pen();
|
||||
pen.setWidthF(2.0 * devicePixelRatioF());
|
||||
series->setPen(pen);
|
||||
#endif
|
||||
addSeries(series);
|
||||
return series;
|
||||
}
|
||||
|
||||
void ChartView::addSeries(QXYSeries *series) {
|
||||
setSeriesColor(series, series->color());
|
||||
chart()->addSeries(series);
|
||||
series->attachAxis(axis_x);
|
||||
series->attachAxis(axis_y);
|
||||
|
||||
// disables the delivery of mouse events to the opengl widget.
|
||||
// this enables the user to select the zoom area when the mouse press on the data point.
|
||||
auto glwidget = findChild<QOpenGLWidget *>();
|
||||
if (glwidget && !glwidget->testAttribute(Qt::WA_TransparentForMouseEvents)) {
|
||||
glwidget->setAttribute(Qt::WA_TransparentForMouseEvents);
|
||||
}
|
||||
}
|
||||
|
||||
void ChartView::setSeriesColor(QXYSeries *series, QColor color) {
|
||||
auto existing_series = chart()->series();
|
||||
for (auto s : existing_series) {
|
||||
if (s != series && std::abs(color.hueF() - qobject_cast<QXYSeries *>(s)->color().hueF()) < 0.1) {
|
||||
// use different color to distinguish it from others.
|
||||
auto last_color = qobject_cast<QXYSeries *>(existing_series.back())->color();
|
||||
color.setHsvF(std::fmod(last_color.hueF() + 60 / 360.0, 1.0),
|
||||
QRandomGenerator::global()->bounded(35, 100) / 100.0,
|
||||
QRandomGenerator::global()->bounded(85, 100) / 100.0);
|
||||
break;
|
||||
}
|
||||
}
|
||||
series->setColor(color);
|
||||
}
|
||||
|
||||
void ChartView::setSeriesType(SeriesType type) {
|
||||
if (type != series_type) {
|
||||
series_type = type;
|
||||
for (auto &s : sigs) {
|
||||
chart()->removeSeries(s.series);
|
||||
s.series->deleteLater();
|
||||
}
|
||||
for (auto &s : sigs) {
|
||||
s.series = createSeries(series_type, s.sig->color);
|
||||
s.series->replace(QVector<QPointF>::fromStdVector(series_type == SeriesType::StepLine ? s.step_vals : s.vals));
|
||||
}
|
||||
updateSeriesPoints();
|
||||
updateTitle();
|
||||
}
|
||||
}
|
||||
|
||||
void ChartView::handleMarkerClicked() {
|
||||
auto marker = qobject_cast<QLegendMarker *>(sender());
|
||||
Q_ASSERT(marker);
|
||||
if (sigs.size() > 1) {
|
||||
auto series = marker->series();
|
||||
series->setVisible(!series->isVisible());
|
||||
marker->setVisible(true);
|
||||
updateAxisY();
|
||||
updateTitle();
|
||||
}
|
||||
}
|
||||
123
tools/cabana/chart/chart.h
Normal file
123
tools/cabana/chart/chart.h
Normal file
@@ -0,0 +1,123 @@
|
||||
#pragma once
|
||||
|
||||
#include <tuple>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include <QMenu>
|
||||
#include <QGraphicsPixmapItem>
|
||||
#include <QGraphicsProxyWidget>
|
||||
#include <QtCharts/QChartView>
|
||||
#include <QtCharts/QLegendMarker>
|
||||
#include <QtCharts/QLineSeries>
|
||||
#include <QtCharts/QScatterSeries>
|
||||
#include <QtCharts/QValueAxis>
|
||||
using namespace QtCharts;
|
||||
|
||||
#include "tools/cabana/chart/tiplabel.h"
|
||||
#include "tools/cabana/dbc/dbcmanager.h"
|
||||
#include "tools/cabana/streams/abstractstream.h"
|
||||
|
||||
enum class SeriesType {
|
||||
Line = 0,
|
||||
StepLine,
|
||||
Scatter
|
||||
};
|
||||
|
||||
class ChartsWidget;
|
||||
class ChartView : public QChartView {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
ChartView(const std::pair<double, double> &x_range, ChartsWidget *parent = nullptr);
|
||||
void addSignal(const MessageId &msg_id, const cabana::Signal *sig);
|
||||
bool hasSignal(const MessageId &msg_id, const cabana::Signal *sig) const;
|
||||
void updateSeries(const cabana::Signal *sig = nullptr, const MessageEventsMap *msg_new_events = nullptr);
|
||||
void updatePlot(double cur, double min, double max);
|
||||
void setSeriesType(SeriesType type);
|
||||
void updatePlotArea(int left, bool force = false);
|
||||
void showTip(double sec);
|
||||
void hideTip();
|
||||
void startAnimation();
|
||||
|
||||
struct SigItem {
|
||||
MessageId msg_id;
|
||||
const cabana::Signal *sig = nullptr;
|
||||
QXYSeries *series = nullptr;
|
||||
std::vector<QPointF> vals;
|
||||
std::vector<QPointF> step_vals;
|
||||
QPointF track_pt{};
|
||||
SegmentTree segment_tree;
|
||||
double min = 0;
|
||||
double max = 0;
|
||||
};
|
||||
|
||||
signals:
|
||||
void axisYLabelWidthChanged(int w);
|
||||
|
||||
private slots:
|
||||
void signalUpdated(const cabana::Signal *sig);
|
||||
void manageSignals();
|
||||
void handleMarkerClicked();
|
||||
void msgUpdated(MessageId id);
|
||||
void msgRemoved(MessageId id) { removeIf([=](auto &s) { return s.msg_id.address == id.address && !dbc()->msg(id); }); }
|
||||
void signalRemoved(const cabana::Signal *sig) { removeIf([=](auto &s) { return s.sig == sig; }); }
|
||||
|
||||
private:
|
||||
void appendCanEvents(const cabana::Signal *sig, const std::vector<const CanEvent *> &events,
|
||||
std::vector<QPointF> &vals, std::vector<QPointF> &step_vals);
|
||||
void createToolButtons();
|
||||
void addSeries(QXYSeries *series);
|
||||
void contextMenuEvent(QContextMenuEvent *event) override;
|
||||
void mousePressEvent(QMouseEvent *event) override;
|
||||
void mouseReleaseEvent(QMouseEvent *event) override;
|
||||
void mouseMoveEvent(QMouseEvent *ev) override;
|
||||
void dragEnterEvent(QDragEnterEvent *event) override;
|
||||
void dragLeaveEvent(QDragLeaveEvent *event) override { drawDropIndicator(false); }
|
||||
void dragMoveEvent(QDragMoveEvent *event) override;
|
||||
void dropEvent(QDropEvent *event) override;
|
||||
void leaveEvent(QEvent *event) override;
|
||||
void resizeEvent(QResizeEvent *event) override;
|
||||
QSize sizeHint() const override;
|
||||
void updateAxisY();
|
||||
void updateTitle();
|
||||
void resetChartCache();
|
||||
void setTheme(QChart::ChartTheme theme);
|
||||
void paintEvent(QPaintEvent *event) override;
|
||||
void drawForeground(QPainter *painter, const QRectF &rect) override;
|
||||
void drawBackground(QPainter *painter, const QRectF &rect) override;
|
||||
void drawDropIndicator(bool draw) { if (std::exchange(can_drop, draw) != can_drop) viewport()->update(); }
|
||||
void drawSignalValue(QPainter *painter);
|
||||
void drawTimeline(QPainter *painter);
|
||||
void drawRubberBandTimeRange(QPainter *painter);
|
||||
std::tuple<double, double, int> getNiceAxisNumbers(qreal min, qreal max, int tick_count);
|
||||
qreal niceNumber(qreal x, bool ceiling);
|
||||
QXYSeries *createSeries(SeriesType type, QColor color);
|
||||
void setSeriesColor(QXYSeries *, QColor color);
|
||||
void updateSeriesPoints();
|
||||
void removeIf(std::function<bool(const SigItem &)> predicate);
|
||||
inline void clearTrackPoints() { for (auto &s : sigs) s.track_pt = {}; }
|
||||
|
||||
int y_label_width = 0;
|
||||
int align_to = 0;
|
||||
QValueAxis *axis_x;
|
||||
QValueAxis *axis_y;
|
||||
QMenu *menu;
|
||||
QAction *split_chart_act;
|
||||
QAction *close_act;
|
||||
QGraphicsPixmapItem *move_icon;
|
||||
QGraphicsProxyWidget *close_btn_proxy;
|
||||
QGraphicsProxyWidget *manage_btn_proxy;
|
||||
TipLabel *tip_label;
|
||||
std::vector<SigItem> sigs;
|
||||
double cur_sec = 0;
|
||||
SeriesType series_type = SeriesType::Line;
|
||||
bool is_scrubbing = false;
|
||||
bool resume_after_scrub = false;
|
||||
QPixmap chart_pixmap;
|
||||
bool can_drop = false;
|
||||
double tooltip_x = -1;
|
||||
QFont signal_value_font;
|
||||
ChartsWidget *charts_widget;
|
||||
friend class ChartsWidget;
|
||||
};
|
||||
539
tools/cabana/chart/chartswidget.cc
Normal file
539
tools/cabana/chart/chartswidget.cc
Normal file
@@ -0,0 +1,539 @@
|
||||
#include "tools/cabana/chart/chartswidget.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include <QApplication>
|
||||
#include <QFutureSynchronizer>
|
||||
#include <QMenu>
|
||||
#include <QScrollBar>
|
||||
#include <QToolBar>
|
||||
#include <QtConcurrent>
|
||||
|
||||
#include "tools/cabana/chart/chart.h"
|
||||
|
||||
const int MAX_COLUMN_COUNT = 4;
|
||||
const int CHART_SPACING = 4;
|
||||
|
||||
ChartsWidget::ChartsWidget(QWidget *parent) : QFrame(parent) {
|
||||
align_timer = new QTimer(this);
|
||||
auto_scroll_timer = new QTimer(this);
|
||||
setFrameStyle(QFrame::StyledPanel | QFrame::Plain);
|
||||
QVBoxLayout *main_layout = new QVBoxLayout(this);
|
||||
main_layout->setContentsMargins(0, 0, 0, 0);
|
||||
main_layout->setSpacing(0);
|
||||
|
||||
// toolbar
|
||||
QToolBar *toolbar = new QToolBar(tr("Charts"), this);
|
||||
int icon_size = style()->pixelMetric(QStyle::PM_SmallIconSize);
|
||||
toolbar->setIconSize({icon_size, icon_size});
|
||||
|
||||
auto new_plot_btn = new ToolButton("file-plus", tr("New Chart"));
|
||||
auto new_tab_btn = new ToolButton("window-stack", tr("New Tab"));
|
||||
toolbar->addWidget(new_plot_btn);
|
||||
toolbar->addWidget(new_tab_btn);
|
||||
toolbar->addWidget(title_label = new QLabel());
|
||||
title_label->setContentsMargins(0, 0, style()->pixelMetric(QStyle::PM_LayoutHorizontalSpacing), 0);
|
||||
|
||||
QMenu *menu = new QMenu(this);
|
||||
for (int i = 0; i < MAX_COLUMN_COUNT; ++i) {
|
||||
menu->addAction(tr("%1").arg(i + 1), [=]() { setColumnCount(i + 1); });
|
||||
}
|
||||
columns_action = toolbar->addAction("");
|
||||
columns_action->setMenu(menu);
|
||||
qobject_cast<QToolButton*>(toolbar->widgetForAction(columns_action))->setPopupMode(QToolButton::InstantPopup);
|
||||
|
||||
QLabel *stretch_label = new QLabel(this);
|
||||
stretch_label->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred);
|
||||
toolbar->addWidget(stretch_label);
|
||||
|
||||
range_lb_action = toolbar->addWidget(range_lb = new QLabel(this));
|
||||
range_slider = new LogSlider(1000, Qt::Horizontal, this);
|
||||
range_slider->setMaximumWidth(200);
|
||||
range_slider->setToolTip(tr("Set the chart range"));
|
||||
range_slider->setRange(1, settings.max_cached_minutes * 60);
|
||||
range_slider->setSingleStep(1);
|
||||
range_slider->setPageStep(60); // 1 min
|
||||
range_slider_action = toolbar->addWidget(range_slider);
|
||||
|
||||
// zoom controls
|
||||
zoom_undo_stack = new QUndoStack(this);
|
||||
toolbar->addAction(undo_zoom_action = zoom_undo_stack->createUndoAction(this));
|
||||
undo_zoom_action->setIcon(utils::icon("arrow-counterclockwise"));
|
||||
toolbar->addAction(redo_zoom_action = zoom_undo_stack->createRedoAction(this));
|
||||
redo_zoom_action->setIcon(utils::icon("arrow-clockwise"));
|
||||
reset_zoom_action = toolbar->addWidget(reset_zoom_btn = new ToolButton("zoom-out", tr("Reset Zoom")));
|
||||
reset_zoom_btn->setToolButtonStyle(Qt::ToolButtonTextBesideIcon);
|
||||
|
||||
toolbar->addWidget(remove_all_btn = new ToolButton("x-square", tr("Remove all charts")));
|
||||
toolbar->addWidget(dock_btn = new ToolButton(""));
|
||||
main_layout->addWidget(toolbar);
|
||||
|
||||
// tabbar
|
||||
tabbar = new TabBar(this);
|
||||
tabbar->setAutoHide(true);
|
||||
tabbar->setExpanding(false);
|
||||
tabbar->setDrawBase(true);
|
||||
tabbar->setAcceptDrops(true);
|
||||
tabbar->setChangeCurrentOnDrag(true);
|
||||
tabbar->setUsesScrollButtons(true);
|
||||
main_layout->addWidget(tabbar);
|
||||
|
||||
// charts
|
||||
charts_container = new ChartsContainer(this);
|
||||
charts_container->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed);
|
||||
charts_scroll = new QScrollArea(this);
|
||||
charts_scroll->viewport()->setBackgroundRole(QPalette::Base);
|
||||
charts_scroll->setFrameStyle(QFrame::NoFrame);
|
||||
charts_scroll->setWidgetResizable(true);
|
||||
charts_scroll->setWidget(charts_container);
|
||||
charts_scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
||||
main_layout->addWidget(charts_scroll);
|
||||
|
||||
// init settings
|
||||
current_theme = settings.theme;
|
||||
column_count = std::clamp(settings.chart_column_count, 1, MAX_COLUMN_COUNT);
|
||||
max_chart_range = std::clamp(settings.chart_range, 1, settings.max_cached_minutes * 60);
|
||||
display_range = {0, max_chart_range};
|
||||
range_slider->setValue(max_chart_range);
|
||||
updateToolBar();
|
||||
|
||||
align_timer->setSingleShot(true);
|
||||
QObject::connect(align_timer, &QTimer::timeout, this, &ChartsWidget::alignCharts);
|
||||
QObject::connect(auto_scroll_timer, &QTimer::timeout, this, &ChartsWidget::doAutoScroll);
|
||||
QObject::connect(dbc(), &DBCManager::DBCFileChanged, this, &ChartsWidget::removeAll);
|
||||
QObject::connect(can, &AbstractStream::eventsMerged, this, &ChartsWidget::eventsMerged);
|
||||
QObject::connect(can, &AbstractStream::msgsReceived, this, &ChartsWidget::updateState);
|
||||
QObject::connect(range_slider, &QSlider::valueChanged, this, &ChartsWidget::setMaxChartRange);
|
||||
QObject::connect(new_plot_btn, &QToolButton::clicked, this, &ChartsWidget::newChart);
|
||||
QObject::connect(remove_all_btn, &QToolButton::clicked, this, &ChartsWidget::removeAll);
|
||||
QObject::connect(reset_zoom_btn, &QToolButton::clicked, this, &ChartsWidget::zoomReset);
|
||||
QObject::connect(&settings, &Settings::changed, this, &ChartsWidget::settingChanged);
|
||||
QObject::connect(new_tab_btn, &QToolButton::clicked, this, &ChartsWidget::newTab);
|
||||
QObject::connect(this, &ChartsWidget::seriesChanged, this, &ChartsWidget::updateTabBar);
|
||||
QObject::connect(tabbar, &QTabBar::tabCloseRequested, this, &ChartsWidget::removeTab);
|
||||
QObject::connect(tabbar, &QTabBar::currentChanged, [this](int index) {
|
||||
if (index != -1) updateLayout(true);
|
||||
});
|
||||
QObject::connect(dock_btn, &QToolButton::clicked, [this]() {
|
||||
emit dock(!docking);
|
||||
docking = !docking;
|
||||
updateToolBar();
|
||||
});
|
||||
|
||||
newTab();
|
||||
setWhatsThis(tr(R"(
|
||||
<b>Chart view</b><br />
|
||||
<!-- TODO: add descprition here -->
|
||||
)"));
|
||||
}
|
||||
|
||||
void ChartsWidget::newTab() {
|
||||
static int tab_unique_id = 0;
|
||||
int idx = tabbar->addTab("");
|
||||
tabbar->setTabData(idx, tab_unique_id++);
|
||||
tabbar->setCurrentIndex(idx);
|
||||
updateTabBar();
|
||||
}
|
||||
|
||||
void ChartsWidget::removeTab(int index) {
|
||||
int id = tabbar->tabData(index).toInt();
|
||||
for (auto &c : tab_charts[id]) {
|
||||
removeChart(c);
|
||||
}
|
||||
tab_charts.erase(id);
|
||||
tabbar->removeTab(index);
|
||||
updateTabBar();
|
||||
}
|
||||
|
||||
void ChartsWidget::updateTabBar() {
|
||||
for (int i = 0; i < tabbar->count(); ++i) {
|
||||
const auto &charts_in_tab = tab_charts[tabbar->tabData(i).toInt()];
|
||||
tabbar->setTabText(i, QString("Tab %1 (%2)").arg(i + 1).arg(charts_in_tab.count()));
|
||||
}
|
||||
}
|
||||
|
||||
void ChartsWidget::eventsMerged(const MessageEventsMap &new_events) {
|
||||
QFutureSynchronizer<void> future_synchronizer;
|
||||
for (auto c : charts) {
|
||||
future_synchronizer.addFuture(QtConcurrent::run(c, &ChartView::updateSeries, nullptr, &new_events));
|
||||
}
|
||||
}
|
||||
|
||||
void ChartsWidget::setZoom(double min, double max) {
|
||||
zoomed_range = {min, max};
|
||||
is_zoomed = zoomed_range != display_range;
|
||||
updateToolBar();
|
||||
updateState();
|
||||
emit rangeChanged(min, max, is_zoomed);
|
||||
}
|
||||
|
||||
void ChartsWidget::zoomReset() {
|
||||
setZoom(display_range.first, display_range.second);
|
||||
zoom_undo_stack->clear();
|
||||
}
|
||||
|
||||
QRect ChartsWidget::chartVisibleRect(ChartView *chart) {
|
||||
const QRect visible_rect(-charts_container->pos(), charts_scroll->viewport()->size());
|
||||
return chart->rect().intersected(QRect(chart->mapFrom(charts_container, visible_rect.topLeft()), visible_rect.size()));
|
||||
}
|
||||
|
||||
void ChartsWidget::showValueTip(double sec) {
|
||||
for (auto c : currentCharts()) {
|
||||
sec >= 0 ? c->showTip(sec) : c->hideTip();
|
||||
}
|
||||
}
|
||||
|
||||
void ChartsWidget::updateState() {
|
||||
if (charts.isEmpty()) return;
|
||||
|
||||
const double cur_sec = can->currentSec();
|
||||
if (!is_zoomed) {
|
||||
double pos = (cur_sec - display_range.first) / std::max<float>(1.0, max_chart_range);
|
||||
if (pos < 0 || pos > 0.8) {
|
||||
display_range.first = std::max(0.0, cur_sec - max_chart_range * 0.1);
|
||||
}
|
||||
double max_sec = std::min(display_range.first + max_chart_range, can->totalSeconds());
|
||||
display_range.first = std::max(0.0, max_sec - max_chart_range);
|
||||
display_range.second = display_range.first + max_chart_range;
|
||||
} else if (cur_sec < (zoomed_range.first - 0.1) || cur_sec >= zoomed_range.second) {
|
||||
// loop in zoomed range
|
||||
can->seekTo(zoomed_range.first);
|
||||
}
|
||||
|
||||
const auto &range = is_zoomed ? zoomed_range : display_range;
|
||||
for (auto c : charts) {
|
||||
c->updatePlot(cur_sec, range.first, range.second);
|
||||
}
|
||||
}
|
||||
|
||||
void ChartsWidget::setMaxChartRange(int value) {
|
||||
max_chart_range = settings.chart_range = range_slider->value();
|
||||
updateToolBar();
|
||||
updateState();
|
||||
}
|
||||
|
||||
void ChartsWidget::updateToolBar() {
|
||||
title_label->setText(tr("Charts: %1").arg(charts.size()));
|
||||
columns_action->setText(tr("Column: %1").arg(column_count));
|
||||
range_lb->setText(utils::formatSeconds(max_chart_range));
|
||||
range_lb_action->setVisible(!is_zoomed);
|
||||
range_slider_action->setVisible(!is_zoomed);
|
||||
undo_zoom_action->setVisible(is_zoomed);
|
||||
redo_zoom_action->setVisible(is_zoomed);
|
||||
reset_zoom_action->setVisible(is_zoomed);
|
||||
reset_zoom_btn->setText(is_zoomed ? tr("%1-%2").arg(zoomed_range.first, 0, 'f', 2).arg(zoomed_range.second, 0, 'f', 2) : "");
|
||||
remove_all_btn->setEnabled(!charts.isEmpty());
|
||||
dock_btn->setIcon(docking ? "arrow-up-right-square" : "arrow-down-left-square");
|
||||
dock_btn->setToolTip(docking ? tr("Undock charts") : tr("Dock charts"));
|
||||
}
|
||||
|
||||
void ChartsWidget::settingChanged() {
|
||||
if (std::exchange(current_theme, settings.theme) != current_theme) {
|
||||
undo_zoom_action->setIcon(utils::icon("arrow-counterclockwise"));
|
||||
redo_zoom_action->setIcon(utils::icon("arrow-clockwise"));
|
||||
auto theme = settings.theme == DARK_THEME ? QChart::QChart::ChartThemeDark : QChart::ChartThemeLight;
|
||||
for (auto c : charts) {
|
||||
c->setTheme(theme);
|
||||
}
|
||||
}
|
||||
range_slider->setRange(1, settings.max_cached_minutes * 60);
|
||||
for (auto c : charts) {
|
||||
c->setFixedHeight(settings.chart_height);
|
||||
c->setSeriesType((SeriesType)settings.chart_series_type);
|
||||
c->resetChartCache();
|
||||
}
|
||||
}
|
||||
|
||||
ChartView *ChartsWidget::findChart(const MessageId &id, const cabana::Signal *sig) {
|
||||
for (auto c : charts)
|
||||
if (c->hasSignal(id, sig)) return c;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
ChartView *ChartsWidget::createChart() {
|
||||
auto chart = new ChartView(is_zoomed ? zoomed_range : display_range, this);
|
||||
chart->setFixedHeight(settings.chart_height);
|
||||
chart->setMinimumWidth(CHART_MIN_WIDTH);
|
||||
chart->setSizePolicy(QSizePolicy::MinimumExpanding, QSizePolicy::Fixed);
|
||||
QObject::connect(chart, &ChartView::axisYLabelWidthChanged, align_timer, qOverload<>(&QTimer::start));
|
||||
charts.push_front(chart);
|
||||
currentCharts().push_front(chart);
|
||||
updateLayout(true);
|
||||
updateToolBar();
|
||||
return chart;
|
||||
}
|
||||
|
||||
void ChartsWidget::showChart(const MessageId &id, const cabana::Signal *sig, bool show, bool merge) {
|
||||
ChartView *chart = findChart(id, sig);
|
||||
if (show && !chart) {
|
||||
chart = merge && currentCharts().size() > 0 ? currentCharts().front() : createChart();
|
||||
chart->addSignal(id, sig);
|
||||
updateState();
|
||||
} else if (!show && chart) {
|
||||
chart->removeIf([&](auto &s) { return s.msg_id == id && s.sig == sig; });
|
||||
}
|
||||
}
|
||||
|
||||
void ChartsWidget::splitChart(ChartView *src_chart) {
|
||||
if (src_chart->sigs.size() > 1) {
|
||||
for (auto it = src_chart->sigs.begin() + 1; it != src_chart->sigs.end(); /**/) {
|
||||
auto c = createChart();
|
||||
src_chart->chart()->removeSeries(it->series);
|
||||
|
||||
// Restore to the original color
|
||||
it->series->setColor(it->sig->color);
|
||||
|
||||
c->addSeries(it->series);
|
||||
c->sigs.emplace_back(std::move(*it));
|
||||
c->updateAxisY();
|
||||
c->updateTitle();
|
||||
it = src_chart->sigs.erase(it);
|
||||
}
|
||||
src_chart->updateAxisY();
|
||||
src_chart->updateTitle();
|
||||
QTimer::singleShot(0, src_chart, &ChartView::resetChartCache);
|
||||
}
|
||||
}
|
||||
|
||||
void ChartsWidget::setColumnCount(int n) {
|
||||
n = std::clamp(n, 1, MAX_COLUMN_COUNT);
|
||||
if (column_count != n) {
|
||||
column_count = settings.chart_column_count = n;
|
||||
updateToolBar();
|
||||
updateLayout();
|
||||
}
|
||||
}
|
||||
|
||||
void ChartsWidget::updateLayout(bool force) {
|
||||
auto charts_layout = charts_container->charts_layout;
|
||||
int n = MAX_COLUMN_COUNT;
|
||||
for (; n > 1; --n) {
|
||||
if ((n * CHART_MIN_WIDTH + (n - 1) * charts_layout->horizontalSpacing()) < charts_layout->geometry().width()) break;
|
||||
}
|
||||
|
||||
bool show_column_cb = n > 1;
|
||||
columns_action->setVisible(show_column_cb);
|
||||
|
||||
n = std::min(column_count, n);
|
||||
auto ¤t_charts = currentCharts();
|
||||
if ((current_charts.size() != charts_layout->count() || n != current_column_count) || force) {
|
||||
current_column_count = n;
|
||||
charts_container->setUpdatesEnabled(false);
|
||||
for (auto c : charts) {
|
||||
c->setVisible(false);
|
||||
}
|
||||
for (int i = 0; i < current_charts.size(); ++i) {
|
||||
charts_layout->addWidget(current_charts[i], i / n, i % n);
|
||||
if (current_charts[i]->sigs.empty()) {
|
||||
// the chart will be resized after add signal. delay setVisible to reduce flicker.
|
||||
QTimer::singleShot(0, current_charts[i], [c = current_charts[i]]() { c->setVisible(true); });
|
||||
} else {
|
||||
current_charts[i]->setVisible(true);
|
||||
}
|
||||
}
|
||||
charts_container->setUpdatesEnabled(true);
|
||||
}
|
||||
}
|
||||
|
||||
void ChartsWidget::startAutoScroll() {
|
||||
auto_scroll_timer->start(50);
|
||||
}
|
||||
|
||||
void ChartsWidget::stopAutoScroll() {
|
||||
auto_scroll_timer->stop();
|
||||
auto_scroll_count = 0;
|
||||
}
|
||||
|
||||
void ChartsWidget::doAutoScroll() {
|
||||
QScrollBar *scroll = charts_scroll->verticalScrollBar();
|
||||
if (auto_scroll_count < scroll->pageStep()) {
|
||||
++auto_scroll_count;
|
||||
}
|
||||
|
||||
int value = scroll->value();
|
||||
QPoint pos = charts_scroll->viewport()->mapFromGlobal(QCursor::pos());
|
||||
QRect area = charts_scroll->viewport()->rect();
|
||||
|
||||
if (pos.y() - area.top() < settings.chart_height / 2) {
|
||||
scroll->setValue(value - auto_scroll_count);
|
||||
} else if (area.bottom() - pos.y() < settings.chart_height / 2) {
|
||||
scroll->setValue(value + auto_scroll_count);
|
||||
}
|
||||
bool vertical_unchanged = value == scroll->value();
|
||||
if (vertical_unchanged) {
|
||||
stopAutoScroll();
|
||||
} else {
|
||||
// mouseMoveEvent to updates the drag-selection rectangle
|
||||
const QPoint globalPos = charts_scroll->viewport()->mapToGlobal(pos);
|
||||
const QPoint windowPos = charts_scroll->window()->mapFromGlobal(globalPos);
|
||||
QMouseEvent mm(QEvent::MouseMove, pos, windowPos, globalPos,
|
||||
Qt::NoButton, Qt::LeftButton, Qt::NoModifier, Qt::MouseEventSynthesizedByQt);
|
||||
QApplication::sendEvent(charts_scroll->viewport(), &mm);
|
||||
}
|
||||
}
|
||||
|
||||
QSize ChartsWidget::minimumSizeHint() const {
|
||||
return QSize(CHART_MIN_WIDTH, QWidget::minimumSizeHint().height());
|
||||
}
|
||||
|
||||
void ChartsWidget::resizeEvent(QResizeEvent *event) {
|
||||
QWidget::resizeEvent(event);
|
||||
updateLayout();
|
||||
}
|
||||
|
||||
void ChartsWidget::newChart() {
|
||||
SignalSelector dlg(tr("New Chart"), this);
|
||||
if (dlg.exec() == QDialog::Accepted) {
|
||||
auto items = dlg.seletedItems();
|
||||
if (!items.isEmpty()) {
|
||||
auto c = createChart();
|
||||
for (auto it : items) {
|
||||
c->addSignal(it->msg_id, it->sig);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ChartsWidget::removeChart(ChartView *chart) {
|
||||
charts.removeOne(chart);
|
||||
chart->deleteLater();
|
||||
for (auto &[_, list] : tab_charts) {
|
||||
list.removeOne(chart);
|
||||
}
|
||||
updateToolBar();
|
||||
updateLayout(true);
|
||||
alignCharts();
|
||||
emit seriesChanged();
|
||||
}
|
||||
|
||||
void ChartsWidget::removeAll() {
|
||||
while (tabbar->count() > 1) {
|
||||
tabbar->removeTab(1);
|
||||
}
|
||||
tab_charts.clear();
|
||||
|
||||
if (!charts.isEmpty()) {
|
||||
for (auto c : charts) {
|
||||
delete c;
|
||||
}
|
||||
charts.clear();
|
||||
emit seriesChanged();
|
||||
}
|
||||
zoomReset();
|
||||
}
|
||||
|
||||
void ChartsWidget::alignCharts() {
|
||||
int plot_left = 0;
|
||||
for (auto c : charts) {
|
||||
plot_left = std::max(plot_left, c->y_label_width);
|
||||
}
|
||||
plot_left = std::max((plot_left / 10) * 10 + 10, 50);
|
||||
for (auto c : charts) {
|
||||
c->updatePlotArea(plot_left);
|
||||
}
|
||||
}
|
||||
|
||||
bool ChartsWidget::eventFilter(QObject *obj, QEvent *event) {
|
||||
if (obj != this && event->type() == QEvent::Close) {
|
||||
emit dock_btn->clicked();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ChartsWidget::event(QEvent *event) {
|
||||
bool back_button = false;
|
||||
switch (event->type()) {
|
||||
case QEvent::MouseButtonPress: {
|
||||
QMouseEvent *ev = static_cast<QMouseEvent *>(event);
|
||||
back_button = ev->button() == Qt::BackButton;
|
||||
break;
|
||||
}
|
||||
case QEvent::NativeGesture: {
|
||||
QNativeGestureEvent *ev = static_cast<QNativeGestureEvent *>(event);
|
||||
back_button = (ev->value() == 180);
|
||||
break;
|
||||
}
|
||||
case QEvent::WindowActivate:
|
||||
case QEvent::WindowDeactivate:
|
||||
case QEvent::FocusIn:
|
||||
case QEvent::FocusOut:
|
||||
case QEvent::Leave:
|
||||
showValueTip(-1);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (back_button) {
|
||||
zoom_undo_stack->undo();
|
||||
return true;
|
||||
}
|
||||
return QFrame::event(event);
|
||||
}
|
||||
|
||||
// ChartsContainer
|
||||
|
||||
ChartsContainer::ChartsContainer(ChartsWidget *parent) : charts_widget(parent), QWidget(parent) {
|
||||
setAcceptDrops(true);
|
||||
setBackgroundRole(QPalette::Window);
|
||||
QVBoxLayout *charts_main_layout = new QVBoxLayout(this);
|
||||
charts_main_layout->setContentsMargins(0, CHART_SPACING, 0, CHART_SPACING);
|
||||
charts_layout = new QGridLayout();
|
||||
charts_layout->setSpacing(CHART_SPACING);
|
||||
charts_main_layout->addLayout(charts_layout);
|
||||
charts_main_layout->addStretch(0);
|
||||
}
|
||||
|
||||
void ChartsContainer::dragEnterEvent(QDragEnterEvent *event) {
|
||||
if (event->mimeData()->hasFormat(CHART_MIME_TYPE)) {
|
||||
event->acceptProposedAction();
|
||||
drawDropIndicator(event->pos());
|
||||
}
|
||||
}
|
||||
|
||||
void ChartsContainer::dropEvent(QDropEvent *event) {
|
||||
if (event->mimeData()->hasFormat(CHART_MIME_TYPE)) {
|
||||
auto w = getDropAfter(event->pos());
|
||||
auto chart = qobject_cast<ChartView *>(event->source());
|
||||
if (w != chart) {
|
||||
for (auto &[_, list] : charts_widget->tab_charts) {
|
||||
list.removeOne(chart);
|
||||
}
|
||||
int to = w ? charts_widget->currentCharts().indexOf(w) + 1 : 0;
|
||||
charts_widget->currentCharts().insert(to, chart);
|
||||
charts_widget->updateLayout(true);
|
||||
charts_widget->updateTabBar();
|
||||
event->acceptProposedAction();
|
||||
chart->startAnimation();
|
||||
}
|
||||
drawDropIndicator({});
|
||||
}
|
||||
}
|
||||
|
||||
void ChartsContainer::paintEvent(QPaintEvent *ev) {
|
||||
if (!drop_indictor_pos.isNull() && !childAt(drop_indictor_pos)) {
|
||||
QRect r;
|
||||
if (auto insert_after = getDropAfter(drop_indictor_pos)) {
|
||||
QRect area = insert_after->geometry();
|
||||
r = QRect(area.left(), area.bottom() + 1, area.width(), CHART_SPACING);
|
||||
} else {
|
||||
r = geometry();
|
||||
r.setHeight(CHART_SPACING);
|
||||
}
|
||||
|
||||
QPainter p(this);
|
||||
p.setPen(QPen(palette().highlight(), 2));
|
||||
p.drawLine(r.topLeft() + QPoint(1, 0), r.bottomLeft() + QPoint(1, 0));
|
||||
p.drawLine(r.topLeft() + QPoint(0, r.height() / 2), r.topRight() + QPoint(0, r.height() / 2));
|
||||
p.drawLine(r.topRight(), r.bottomRight());
|
||||
}
|
||||
}
|
||||
|
||||
ChartView *ChartsContainer::getDropAfter(const QPoint &pos) const {
|
||||
auto it = std::find_if(charts_widget->currentCharts().crbegin(), charts_widget->currentCharts().crend(), [&pos](auto c) {
|
||||
auto area = c->geometry();
|
||||
return pos.x() >= area.left() && pos.x() <= area.right() && pos.y() >= area.bottom();
|
||||
});
|
||||
return it == charts_widget->currentCharts().crend() ? nullptr : *it;
|
||||
}
|
||||
130
tools/cabana/chart/chartswidget.h
Normal file
130
tools/cabana/chart/chartswidget.h
Normal file
@@ -0,0 +1,130 @@
|
||||
#pragma once
|
||||
|
||||
#include <unordered_map>
|
||||
#include <utility>
|
||||
|
||||
#include <QGridLayout>
|
||||
#include <QLabel>
|
||||
#include <QScrollArea>
|
||||
#include <QTimer>
|
||||
#include <QUndoCommand>
|
||||
#include <QUndoStack>
|
||||
|
||||
#include "tools/cabana/chart/signalselector.h"
|
||||
#include "tools/cabana/dbc/dbcmanager.h"
|
||||
#include "tools/cabana/streams/abstractstream.h"
|
||||
|
||||
const int CHART_MIN_WIDTH = 300;
|
||||
const QString CHART_MIME_TYPE = "application/x-cabanachartview";
|
||||
|
||||
class ChartView;
|
||||
class ChartsWidget;
|
||||
|
||||
class ChartsContainer : public QWidget {
|
||||
public:
|
||||
ChartsContainer(ChartsWidget *parent);
|
||||
void dragEnterEvent(QDragEnterEvent *event) override;
|
||||
void dropEvent(QDropEvent *event) override;
|
||||
void dragLeaveEvent(QDragLeaveEvent *event) override { drawDropIndicator({}); }
|
||||
void drawDropIndicator(const QPoint &pt) { drop_indictor_pos = pt; update(); }
|
||||
void paintEvent(QPaintEvent *ev) override;
|
||||
ChartView *getDropAfter(const QPoint &pos) const;
|
||||
|
||||
QGridLayout *charts_layout;
|
||||
ChartsWidget *charts_widget;
|
||||
QPoint drop_indictor_pos;
|
||||
};
|
||||
|
||||
class ChartsWidget : public QFrame {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
ChartsWidget(QWidget *parent = nullptr);
|
||||
void showChart(const MessageId &id, const cabana::Signal *sig, bool show, bool merge);
|
||||
inline bool hasSignal(const MessageId &id, const cabana::Signal *sig) { return findChart(id, sig) != nullptr; }
|
||||
|
||||
public slots:
|
||||
void setColumnCount(int n);
|
||||
void removeAll();
|
||||
void setZoom(double min, double max);
|
||||
|
||||
signals:
|
||||
void dock(bool floating);
|
||||
void rangeChanged(double min, double max, bool is_zommed);
|
||||
void seriesChanged();
|
||||
|
||||
private:
|
||||
QSize minimumSizeHint() const override;
|
||||
void resizeEvent(QResizeEvent *event) override;
|
||||
bool event(QEvent *event) override;
|
||||
void alignCharts();
|
||||
void newChart();
|
||||
ChartView *createChart();
|
||||
void removeChart(ChartView *chart);
|
||||
void splitChart(ChartView *chart);
|
||||
QRect chartVisibleRect(ChartView *chart);
|
||||
void eventsMerged(const MessageEventsMap &new_events);
|
||||
void updateState();
|
||||
void zoomReset();
|
||||
void startAutoScroll();
|
||||
void stopAutoScroll();
|
||||
void doAutoScroll();
|
||||
void updateToolBar();
|
||||
void updateTabBar();
|
||||
void setMaxChartRange(int value);
|
||||
void updateLayout(bool force = false);
|
||||
void settingChanged();
|
||||
void showValueTip(double sec);
|
||||
bool eventFilter(QObject *obj, QEvent *event) override;
|
||||
void newTab();
|
||||
void removeTab(int index);
|
||||
inline QList<ChartView *> ¤tCharts() { return tab_charts[tabbar->tabData(tabbar->currentIndex()).toInt()]; }
|
||||
ChartView *findChart(const MessageId &id, const cabana::Signal *sig);
|
||||
|
||||
QLabel *title_label;
|
||||
QLabel *range_lb;
|
||||
LogSlider *range_slider;
|
||||
QAction *range_lb_action;
|
||||
QAction *range_slider_action;
|
||||
bool docking = true;
|
||||
ToolButton *dock_btn;
|
||||
|
||||
QAction *undo_zoom_action;
|
||||
QAction *redo_zoom_action;
|
||||
QAction *reset_zoom_action;
|
||||
ToolButton *reset_zoom_btn;
|
||||
QUndoStack *zoom_undo_stack;
|
||||
|
||||
ToolButton *remove_all_btn;
|
||||
QList<ChartView *> charts;
|
||||
std::unordered_map<int, QList<ChartView *>> tab_charts;
|
||||
TabBar *tabbar;
|
||||
ChartsContainer *charts_container;
|
||||
QScrollArea *charts_scroll;
|
||||
uint32_t max_chart_range = 0;
|
||||
bool is_zoomed = false;
|
||||
std::pair<double, double> display_range;
|
||||
std::pair<double, double> zoomed_range;
|
||||
QAction *columns_action;
|
||||
int column_count = 1;
|
||||
int current_column_count = 0;
|
||||
int auto_scroll_count = 0;
|
||||
QTimer *auto_scroll_timer;
|
||||
QTimer *align_timer;
|
||||
int current_theme = 0;
|
||||
friend class ZoomCommand;
|
||||
friend class ChartView;
|
||||
friend class ChartsContainer;
|
||||
};
|
||||
|
||||
class ZoomCommand : public QUndoCommand {
|
||||
public:
|
||||
ZoomCommand(ChartsWidget *charts, std::pair<double, double> range) : charts(charts), range(range), QUndoCommand() {
|
||||
prev_range = charts->is_zoomed ? charts->zoomed_range : charts->display_range;
|
||||
setText(QObject::tr("Zoom to %1-%2").arg(range.first, 0, 'f', 2).arg(range.second, 0, 'f', 2));
|
||||
}
|
||||
void undo() override { charts->setZoom(prev_range.first, prev_range.second); }
|
||||
void redo() override { charts->setZoom(range.first, range.second); }
|
||||
ChartsWidget *charts;
|
||||
std::pair<double, double> prev_range, range;
|
||||
};
|
||||
108
tools/cabana/chart/signalselector.cc
Normal file
108
tools/cabana/chart/signalselector.cc
Normal file
@@ -0,0 +1,108 @@
|
||||
#include "tools/cabana/chart/signalselector.h"
|
||||
|
||||
#include <QCompleter>
|
||||
#include <QDialogButtonBox>
|
||||
#include <QGridLayout>
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
#include <QPushButton>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#include "tools/cabana/streams/abstractstream.h"
|
||||
|
||||
SignalSelector::SignalSelector(QString title, QWidget *parent) : QDialog(parent) {
|
||||
setWindowTitle(title);
|
||||
QGridLayout *main_layout = new QGridLayout(this);
|
||||
|
||||
// left column
|
||||
main_layout->addWidget(new QLabel(tr("Available Signals")), 0, 0);
|
||||
main_layout->addWidget(msgs_combo = new QComboBox(this), 1, 0);
|
||||
msgs_combo->setEditable(true);
|
||||
msgs_combo->lineEdit()->setPlaceholderText(tr("Select a msg..."));
|
||||
msgs_combo->setInsertPolicy(QComboBox::NoInsert);
|
||||
msgs_combo->completer()->setCompletionMode(QCompleter::PopupCompletion);
|
||||
msgs_combo->completer()->setFilterMode(Qt::MatchContains);
|
||||
|
||||
main_layout->addWidget(available_list = new QListWidget(this), 2, 0);
|
||||
|
||||
// buttons
|
||||
QVBoxLayout *btn_layout = new QVBoxLayout();
|
||||
QPushButton *add_btn = new QPushButton(utils::icon("chevron-right"), "", this);
|
||||
add_btn->setEnabled(false);
|
||||
QPushButton *remove_btn = new QPushButton(utils::icon("chevron-left"), "", this);
|
||||
remove_btn->setEnabled(false);
|
||||
btn_layout->addStretch(0);
|
||||
btn_layout->addWidget(add_btn);
|
||||
btn_layout->addWidget(remove_btn);
|
||||
btn_layout->addStretch(0);
|
||||
main_layout->addLayout(btn_layout, 0, 1, 3, 1);
|
||||
|
||||
// right column
|
||||
main_layout->addWidget(new QLabel(tr("Selected Signals")), 0, 2);
|
||||
main_layout->addWidget(selected_list = new QListWidget(this), 1, 2, 2, 1);
|
||||
|
||||
auto buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
|
||||
main_layout->addWidget(buttonBox, 3, 2);
|
||||
|
||||
for (const auto &[id, _] : can->lastMessages()) {
|
||||
if (auto m = dbc()->msg(id)) {
|
||||
msgs_combo->addItem(QString("%1 (%2)").arg(m->name).arg(id.toString()), QVariant::fromValue(id));
|
||||
}
|
||||
}
|
||||
msgs_combo->model()->sort(0);
|
||||
msgs_combo->setCurrentIndex(-1);
|
||||
|
||||
QObject::connect(msgs_combo, qOverload<int>(&QComboBox::currentIndexChanged), this, &SignalSelector::updateAvailableList);
|
||||
QObject::connect(available_list, &QListWidget::currentRowChanged, [=](int row) { add_btn->setEnabled(row != -1); });
|
||||
QObject::connect(selected_list, &QListWidget::currentRowChanged, [=](int row) { remove_btn->setEnabled(row != -1); });
|
||||
QObject::connect(available_list, &QListWidget::itemDoubleClicked, this, &SignalSelector::add);
|
||||
QObject::connect(selected_list, &QListWidget::itemDoubleClicked, this, &SignalSelector::remove);
|
||||
QObject::connect(add_btn, &QPushButton::clicked, [this]() { if (auto item = available_list->currentItem()) add(item); });
|
||||
QObject::connect(remove_btn, &QPushButton::clicked, [this]() { if (auto item = selected_list->currentItem()) remove(item); });
|
||||
QObject::connect(buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept);
|
||||
QObject::connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject);
|
||||
}
|
||||
|
||||
void SignalSelector::add(QListWidgetItem *item) {
|
||||
auto it = (ListItem *)item;
|
||||
addItemToList(selected_list, it->msg_id, it->sig, true);
|
||||
delete item;
|
||||
}
|
||||
|
||||
void SignalSelector::remove(QListWidgetItem *item) {
|
||||
auto it = (ListItem *)item;
|
||||
if (it->msg_id == msgs_combo->currentData().value<MessageId>()) {
|
||||
addItemToList(available_list, it->msg_id, it->sig);
|
||||
}
|
||||
delete item;
|
||||
}
|
||||
|
||||
void SignalSelector::updateAvailableList(int index) {
|
||||
if (index == -1) return;
|
||||
available_list->clear();
|
||||
MessageId msg_id = msgs_combo->itemData(index).value<MessageId>();
|
||||
auto selected_items = seletedItems();
|
||||
for (auto s : dbc()->msg(msg_id)->getSignals()) {
|
||||
bool is_selected = std::any_of(selected_items.begin(), selected_items.end(), [=, sig = s](auto it) { return it->msg_id == msg_id && it->sig == sig; });
|
||||
if (!is_selected) {
|
||||
addItemToList(available_list, msg_id, s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void SignalSelector::addItemToList(QListWidget *parent, const MessageId id, const cabana::Signal *sig, bool show_msg_name) {
|
||||
QString text = QString("<span style=\"color:%0;\">■ </span> %1").arg(sig->color.name(), sig->name);
|
||||
if (show_msg_name) text += QString(" <font color=\"gray\">%0 %1</font>").arg(msgName(id), id.toString());
|
||||
|
||||
QLabel *label = new QLabel(text);
|
||||
label->setContentsMargins(5, 0, 5, 0);
|
||||
auto new_item = new ListItem(id, sig, parent);
|
||||
new_item->setSizeHint(label->sizeHint());
|
||||
parent->setItemWidget(new_item, label);
|
||||
}
|
||||
|
||||
QList<SignalSelector::ListItem *> SignalSelector::seletedItems() {
|
||||
QList<SignalSelector::ListItem *> ret;
|
||||
for (int i = 0; i < selected_list->count(); ++i) ret.push_back((ListItem *)selected_list->item(i));
|
||||
return ret;
|
||||
}
|
||||
30
tools/cabana/chart/signalselector.h
Normal file
30
tools/cabana/chart/signalselector.h
Normal file
@@ -0,0 +1,30 @@
|
||||
#pragma once
|
||||
|
||||
#include <QComboBox>
|
||||
#include <QDialog>
|
||||
#include <QListWidget>
|
||||
|
||||
#include "tools/cabana/dbc/dbcmanager.h"
|
||||
|
||||
class SignalSelector : public QDialog {
|
||||
public:
|
||||
struct ListItem : public QListWidgetItem {
|
||||
ListItem(const MessageId &msg_id, const cabana::Signal *sig, QListWidget *parent) : msg_id(msg_id), sig(sig), QListWidgetItem(parent) {}
|
||||
MessageId msg_id;
|
||||
const cabana::Signal *sig;
|
||||
};
|
||||
|
||||
SignalSelector(QString title, QWidget *parent);
|
||||
QList<ListItem *> seletedItems();
|
||||
inline void addSelected(const MessageId &id, const cabana::Signal *sig) { addItemToList(selected_list, id, sig, true); }
|
||||
|
||||
private:
|
||||
void updateAvailableList(int index);
|
||||
void addItemToList(QListWidget *parent, const MessageId id, const cabana::Signal *sig, bool show_msg_name = false);
|
||||
void add(QListWidgetItem *item);
|
||||
void remove(QListWidgetItem *item);
|
||||
|
||||
QComboBox *msgs_combo;
|
||||
QListWidget *available_list;
|
||||
QListWidget *selected_list;
|
||||
};
|
||||
59
tools/cabana/chart/sparkline.cc
Normal file
59
tools/cabana/chart/sparkline.cc
Normal file
@@ -0,0 +1,59 @@
|
||||
#include "tools/cabana/chart/sparkline.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <limits>
|
||||
#include <QPainter>
|
||||
|
||||
#include "tools/cabana/streams/abstractstream.h"
|
||||
|
||||
void Sparkline::update(const MessageId &msg_id, const cabana::Signal *sig, double last_msg_ts, int range, QSize size) {
|
||||
const auto &msgs = can->events(msg_id);
|
||||
uint64_t ts = (last_msg_ts + can->routeStartTime()) * 1e9;
|
||||
uint64_t first_ts = (ts > range * 1e9) ? ts - range * 1e9 : 0;
|
||||
auto first = std::lower_bound(msgs.cbegin(), msgs.cend(), first_ts, CompareCanEvent());
|
||||
auto last = std::upper_bound(first, msgs.cend(), ts, CompareCanEvent());
|
||||
|
||||
if (first != last && !size.isEmpty()) {
|
||||
points.clear();
|
||||
double value = 0;
|
||||
for (auto it = first; it != last; ++it) {
|
||||
if (sig->getValue((*it)->dat, (*it)->size, &value)) {
|
||||
points.emplace_back(((*it)->mono_time - (*first)->mono_time) / 1e9, value);
|
||||
}
|
||||
}
|
||||
const auto [min, max] = std::minmax_element(points.begin(), points.end(),
|
||||
[](auto &l, auto &r) { return l.y() < r.y(); });
|
||||
min_val = min->y() == max->y() ? min->y() - 1 : min->y();
|
||||
max_val = min->y() == max->y() ? max->y() + 1 : max->y();
|
||||
freq_ = points.size() / std::max(points.back().x() - points.front().x(), 1.0);
|
||||
render(sig->color, range, size);
|
||||
} else {
|
||||
pixmap = QPixmap();
|
||||
}
|
||||
}
|
||||
|
||||
void Sparkline::render(const QColor &color, int range, QSize size) {
|
||||
const double xscale = (size.width() - 1) / (double)range;
|
||||
const double yscale = (size.height() - 3) / (max_val - min_val);
|
||||
for (auto &v : points) {
|
||||
v = QPoint(v.x() * xscale, 1 + std::abs(v.y() - max_val) * yscale);
|
||||
}
|
||||
|
||||
qreal dpr = qApp->devicePixelRatio();
|
||||
size *= dpr;
|
||||
if (size != pixmap.size()) {
|
||||
pixmap = QPixmap(size);
|
||||
}
|
||||
pixmap.setDevicePixelRatio(dpr);
|
||||
pixmap.fill(Qt::transparent);
|
||||
QPainter painter(&pixmap);
|
||||
painter.setRenderHint(QPainter::Antialiasing, points.size() < 500);
|
||||
painter.setPen(color);
|
||||
painter.drawPolyline(points.data(), points.size());
|
||||
painter.setPen(QPen(color, 3));
|
||||
if ((points.back().x() - points.front().x()) / points.size() > 8) {
|
||||
painter.drawPoints(points.data(), points.size());
|
||||
} else {
|
||||
painter.drawPoint(points.back());
|
||||
}
|
||||
}
|
||||
24
tools/cabana/chart/sparkline.h
Normal file
24
tools/cabana/chart/sparkline.h
Normal file
@@ -0,0 +1,24 @@
|
||||
#pragma once
|
||||
|
||||
#include <QPixmap>
|
||||
#include <QPointF>
|
||||
#include <vector>
|
||||
|
||||
#include "tools/cabana/dbc/dbc.h"
|
||||
|
||||
class Sparkline {
|
||||
public:
|
||||
void update(const MessageId &msg_id, const cabana::Signal *sig, double last_msg_ts, int range, QSize size);
|
||||
inline double freq() const { return freq_; }
|
||||
bool isEmpty() const { return pixmap.isNull(); }
|
||||
|
||||
QPixmap pixmap;
|
||||
double min_val = 0;
|
||||
double max_val = 0;
|
||||
|
||||
private:
|
||||
void render(const QColor &color, int range, QSize size);
|
||||
|
||||
std::vector<QPointF> points;
|
||||
double freq_ = 0;
|
||||
};
|
||||
55
tools/cabana/chart/tiplabel.cc
Normal file
55
tools/cabana/chart/tiplabel.cc
Normal file
@@ -0,0 +1,55 @@
|
||||
#include "tools/cabana/chart/tiplabel.h"
|
||||
|
||||
#include <utility>
|
||||
|
||||
#include <QApplication>
|
||||
#include <QStylePainter>
|
||||
#include <QToolTip>
|
||||
|
||||
#include "tools/cabana/settings.h"
|
||||
|
||||
TipLabel::TipLabel(QWidget *parent) : QLabel(parent, Qt::ToolTip | Qt::FramelessWindowHint) {
|
||||
setForegroundRole(QPalette::ToolTipText);
|
||||
setBackgroundRole(QPalette::ToolTipBase);
|
||||
QFont font;
|
||||
font.setPointSizeF(8.34563465);
|
||||
setFont(font);
|
||||
auto palette = QToolTip::palette();
|
||||
if (settings.theme != DARK_THEME) {
|
||||
palette.setColor(QPalette::ToolTipBase, QApplication::palette().color(QPalette::Base));
|
||||
palette.setColor(QPalette::ToolTipText, QRgb(0x404044)); // same color as chart label brush
|
||||
}
|
||||
setPalette(palette);
|
||||
ensurePolished();
|
||||
setMargin(1 + style()->pixelMetric(QStyle::PM_ToolTipLabelFrameWidth, nullptr, this));
|
||||
setAttribute(Qt::WA_ShowWithoutActivating);
|
||||
setTextFormat(Qt::RichText);
|
||||
setVisible(false);
|
||||
}
|
||||
|
||||
void TipLabel::showText(const QPoint &pt, const QString &text, QWidget *w, const QRect &rect) {
|
||||
setText(text);
|
||||
if (!text.isEmpty()) {
|
||||
QSize extra(1, 1);
|
||||
resize(sizeHint() + extra);
|
||||
QPoint tip_pos(pt.x() + 8, rect.top() + 2);
|
||||
if (tip_pos.x() + size().width() >= rect.right()) {
|
||||
tip_pos.rx() = pt.x() - size().width() - 8;
|
||||
}
|
||||
if (rect.contains({tip_pos, size()})) {
|
||||
move(w->mapToGlobal(tip_pos));
|
||||
setVisible(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setVisible(false);
|
||||
}
|
||||
|
||||
void TipLabel::paintEvent(QPaintEvent *ev) {
|
||||
QStylePainter p(this);
|
||||
QStyleOptionFrame opt;
|
||||
opt.init(this);
|
||||
p.drawPrimitive(QStyle::PE_PanelTipLabel, opt);
|
||||
p.end();
|
||||
QLabel::paintEvent(ev);
|
||||
}
|
||||
10
tools/cabana/chart/tiplabel.h
Normal file
10
tools/cabana/chart/tiplabel.h
Normal file
@@ -0,0 +1,10 @@
|
||||
#pragma once
|
||||
|
||||
#include <QLabel>
|
||||
|
||||
class TipLabel : public QLabel {
|
||||
public:
|
||||
TipLabel(QWidget *parent = nullptr);
|
||||
void showText(const QPoint &pt, const QString &sec, QWidget *w, const QRect &rect);
|
||||
void paintEvent(QPaintEvent *ev) override;
|
||||
};
|
||||
124
tools/cabana/commands.cc
Normal file
124
tools/cabana/commands.cc
Normal file
@@ -0,0 +1,124 @@
|
||||
#include <QApplication>
|
||||
|
||||
#include "tools/cabana/commands.h"
|
||||
|
||||
// EditMsgCommand
|
||||
|
||||
EditMsgCommand::EditMsgCommand(const MessageId &id, const QString &name, int size,
|
||||
const QString &node, const QString &comment, QUndoCommand *parent)
|
||||
: id(id), new_name(name), new_size(size), new_node(node), new_comment(comment), QUndoCommand(parent) {
|
||||
if (auto msg = dbc()->msg(id)) {
|
||||
old_name = msg->name;
|
||||
old_size = msg->size;
|
||||
old_node = msg->transmitter;
|
||||
old_comment = msg->comment;
|
||||
setText(QObject::tr("edit message %1:%2").arg(name).arg(id.address));
|
||||
} else {
|
||||
setText(QObject::tr("new message %1:%2").arg(name).arg(id.address));
|
||||
}
|
||||
}
|
||||
|
||||
void EditMsgCommand::undo() {
|
||||
if (old_name.isEmpty())
|
||||
dbc()->removeMsg(id);
|
||||
else
|
||||
dbc()->updateMsg(id, old_name, old_size, old_node, old_comment);
|
||||
}
|
||||
|
||||
void EditMsgCommand::redo() {
|
||||
dbc()->updateMsg(id, new_name, new_size, new_node, new_comment);
|
||||
}
|
||||
|
||||
// RemoveMsgCommand
|
||||
|
||||
RemoveMsgCommand::RemoveMsgCommand(const MessageId &id, QUndoCommand *parent) : id(id), QUndoCommand(parent) {
|
||||
if (auto msg = dbc()->msg(id)) {
|
||||
message = *msg;
|
||||
setText(QObject::tr("remove message %1:%2").arg(message.name).arg(id.address));
|
||||
}
|
||||
}
|
||||
|
||||
void RemoveMsgCommand::undo() {
|
||||
if (!message.name.isEmpty()) {
|
||||
dbc()->updateMsg(id, message.name, message.size, message.transmitter, message.comment);
|
||||
for (auto s : message.getSignals())
|
||||
dbc()->addSignal(id, *s);
|
||||
}
|
||||
}
|
||||
|
||||
void RemoveMsgCommand::redo() {
|
||||
if (!message.name.isEmpty())
|
||||
dbc()->removeMsg(id);
|
||||
}
|
||||
|
||||
// AddSigCommand
|
||||
|
||||
AddSigCommand::AddSigCommand(const MessageId &id, const cabana::Signal &sig, QUndoCommand *parent)
|
||||
: id(id), signal(sig), QUndoCommand(parent) {
|
||||
setText(QObject::tr("add signal %1 to %2:%3").arg(sig.name).arg(msgName(id)).arg(id.address));
|
||||
}
|
||||
|
||||
void AddSigCommand::undo() {
|
||||
dbc()->removeSignal(id, signal.name);
|
||||
if (msg_created) dbc()->removeMsg(id);
|
||||
}
|
||||
|
||||
void AddSigCommand::redo() {
|
||||
if (auto msg = dbc()->msg(id); !msg) {
|
||||
msg_created = true;
|
||||
dbc()->updateMsg(id, dbc()->newMsgName(id), can->lastMessage(id).dat.size(), "", "");
|
||||
}
|
||||
signal.name = dbc()->newSignalName(id);
|
||||
signal.max = std::pow(2, signal.size) - 1;
|
||||
dbc()->addSignal(id, signal);
|
||||
}
|
||||
|
||||
// RemoveSigCommand
|
||||
|
||||
RemoveSigCommand::RemoveSigCommand(const MessageId &id, const cabana::Signal *sig, QUndoCommand *parent)
|
||||
: id(id), QUndoCommand(parent) {
|
||||
sigs.push_back(*sig);
|
||||
if (sig->type == cabana::Signal::Type::Multiplexor) {
|
||||
for (const auto &s : dbc()->msg(id)->sigs) {
|
||||
if (s->type == cabana::Signal::Type::Multiplexed) {
|
||||
sigs.push_back(*s);
|
||||
}
|
||||
}
|
||||
}
|
||||
setText(QObject::tr("remove signal %1 from %2:%3").arg(sig->name).arg(msgName(id)).arg(id.address));
|
||||
}
|
||||
|
||||
void RemoveSigCommand::undo() { for (const auto &s : sigs) dbc()->addSignal(id, s); }
|
||||
void RemoveSigCommand::redo() { for (const auto &s : sigs) dbc()->removeSignal(id, s.name); }
|
||||
|
||||
// EditSignalCommand
|
||||
|
||||
EditSignalCommand::EditSignalCommand(const MessageId &id, const cabana::Signal *sig, const cabana::Signal &new_sig, QUndoCommand *parent)
|
||||
: id(id), QUndoCommand(parent) {
|
||||
sigs.push_back({*sig, new_sig});
|
||||
if (sig->type == cabana::Signal::Type::Multiplexor && new_sig.type == cabana::Signal::Type::Normal) {
|
||||
// convert all multiplexed signals to normal signals
|
||||
auto msg = dbc()->msg(id);
|
||||
assert(msg);
|
||||
for (const auto &s : msg->sigs) {
|
||||
if (s->type == cabana::Signal::Type::Multiplexed) {
|
||||
auto new_s = *s;
|
||||
new_s.type = cabana::Signal::Type::Normal;
|
||||
sigs.push_back({*s, new_s});
|
||||
}
|
||||
}
|
||||
}
|
||||
setText(QObject::tr("edit signal %1 in %2:%3").arg(sig->name).arg(msgName(id)).arg(id.address));
|
||||
}
|
||||
|
||||
void EditSignalCommand::undo() { for (const auto &s : sigs) dbc()->updateSignal(id, s.second.name, s.first); }
|
||||
void EditSignalCommand::redo() { for (const auto &s : sigs) dbc()->updateSignal(id, s.first.name, s.second); }
|
||||
|
||||
namespace UndoStack {
|
||||
|
||||
QUndoStack *instance() {
|
||||
static QUndoStack *undo_stack = new QUndoStack(qApp);
|
||||
return undo_stack;
|
||||
}
|
||||
|
||||
} // namespace UndoStack
|
||||
72
tools/cabana/commands.h
Normal file
72
tools/cabana/commands.h
Normal file
@@ -0,0 +1,72 @@
|
||||
#pragma once
|
||||
|
||||
#include <utility>
|
||||
|
||||
#include <QUndoCommand>
|
||||
#include <QUndoStack>
|
||||
|
||||
#include "tools/cabana/dbc/dbcmanager.h"
|
||||
#include "tools/cabana/streams/abstractstream.h"
|
||||
|
||||
class EditMsgCommand : public QUndoCommand {
|
||||
public:
|
||||
EditMsgCommand(const MessageId &id, const QString &name, int size, const QString &node,
|
||||
const QString &comment, QUndoCommand *parent = nullptr);
|
||||
void undo() override;
|
||||
void redo() override;
|
||||
|
||||
private:
|
||||
const MessageId id;
|
||||
QString old_name, new_name, old_comment, new_comment, old_node, new_node;
|
||||
int old_size = 0, new_size = 0;
|
||||
};
|
||||
|
||||
class RemoveMsgCommand : public QUndoCommand {
|
||||
public:
|
||||
RemoveMsgCommand(const MessageId &id, QUndoCommand *parent = nullptr);
|
||||
void undo() override;
|
||||
void redo() override;
|
||||
|
||||
private:
|
||||
const MessageId id;
|
||||
cabana::Msg message;
|
||||
};
|
||||
|
||||
class AddSigCommand : public QUndoCommand {
|
||||
public:
|
||||
AddSigCommand(const MessageId &id, const cabana::Signal &sig, QUndoCommand *parent = nullptr);
|
||||
void undo() override;
|
||||
void redo() override;
|
||||
|
||||
private:
|
||||
const MessageId id;
|
||||
bool msg_created = false;
|
||||
cabana::Signal signal = {};
|
||||
};
|
||||
|
||||
class RemoveSigCommand : public QUndoCommand {
|
||||
public:
|
||||
RemoveSigCommand(const MessageId &id, const cabana::Signal *sig, QUndoCommand *parent = nullptr);
|
||||
void undo() override;
|
||||
void redo() override;
|
||||
|
||||
private:
|
||||
const MessageId id;
|
||||
QList<cabana::Signal> sigs;
|
||||
};
|
||||
|
||||
class EditSignalCommand : public QUndoCommand {
|
||||
public:
|
||||
EditSignalCommand(const MessageId &id, const cabana::Signal *sig, const cabana::Signal &new_sig, QUndoCommand *parent = nullptr);
|
||||
void undo() override;
|
||||
void redo() override;
|
||||
|
||||
private:
|
||||
const MessageId id;
|
||||
QList<std::pair<cabana::Signal, cabana::Signal>> sigs; // QList<{old_sig, new_sig}>
|
||||
};
|
||||
|
||||
namespace UndoStack {
|
||||
QUndoStack *instance();
|
||||
inline void push(QUndoCommand *cmd) { instance()->push(cmd); }
|
||||
};
|
||||
211
tools/cabana/dbc/dbc.cc
Normal file
211
tools/cabana/dbc/dbc.cc
Normal file
@@ -0,0 +1,211 @@
|
||||
#include "tools/cabana/dbc/dbc.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "tools/cabana/util.h"
|
||||
|
||||
uint qHash(const MessageId &item) {
|
||||
return qHash(item.source) ^ qHash(item.address);
|
||||
}
|
||||
|
||||
// cabana::Msg
|
||||
|
||||
cabana::Msg::~Msg() {
|
||||
for (auto s : sigs) {
|
||||
delete s;
|
||||
}
|
||||
}
|
||||
|
||||
cabana::Signal *cabana::Msg::addSignal(const cabana::Signal &sig) {
|
||||
auto s = sigs.emplace_back(new cabana::Signal(sig));
|
||||
update();
|
||||
return s;
|
||||
}
|
||||
|
||||
cabana::Signal *cabana::Msg::updateSignal(const QString &sig_name, const cabana::Signal &new_sig) {
|
||||
auto s = sig(sig_name);
|
||||
if (s) {
|
||||
*s = new_sig;
|
||||
update();
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
void cabana::Msg::removeSignal(const QString &sig_name) {
|
||||
auto it = std::find_if(sigs.begin(), sigs.end(), [&](auto &s) { return s->name == sig_name; });
|
||||
if (it != sigs.end()) {
|
||||
delete *it;
|
||||
sigs.erase(it);
|
||||
update();
|
||||
}
|
||||
}
|
||||
|
||||
cabana::Msg &cabana::Msg::operator=(const cabana::Msg &other) {
|
||||
address = other.address;
|
||||
name = other.name;
|
||||
size = other.size;
|
||||
comment = other.comment;
|
||||
|
||||
for (auto s : sigs) delete s;
|
||||
sigs.clear();
|
||||
for (auto s : other.sigs) {
|
||||
sigs.push_back(new cabana::Signal(*s));
|
||||
}
|
||||
|
||||
update();
|
||||
return *this;
|
||||
}
|
||||
|
||||
cabana::Signal *cabana::Msg::sig(const QString &sig_name) const {
|
||||
auto it = std::find_if(sigs.begin(), sigs.end(), [&](auto &s) { return s->name == sig_name; });
|
||||
return it != sigs.end() ? *it : nullptr;
|
||||
}
|
||||
|
||||
int cabana::Msg::indexOf(const cabana::Signal *sig) const {
|
||||
for (int i = 0; i < sigs.size(); ++i) {
|
||||
if (sigs[i] == sig) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
QString cabana::Msg::newSignalName() {
|
||||
QString new_name;
|
||||
for (int i = 1; /**/; ++i) {
|
||||
new_name = QString("NEW_SIGNAL_%1").arg(i);
|
||||
if (sig(new_name) == nullptr) break;
|
||||
}
|
||||
return new_name;
|
||||
}
|
||||
|
||||
void cabana::Msg::update() {
|
||||
if (transmitter.isEmpty()) {
|
||||
transmitter = DEFAULT_NODE_NAME;
|
||||
}
|
||||
mask.assign(size, 0x00);
|
||||
multiplexor = nullptr;
|
||||
|
||||
// sort signals
|
||||
std::sort(sigs.begin(), sigs.end(), [](auto l, auto r) {
|
||||
return std::tie(r->type, l->multiplex_value, l->start_bit, l->name) <
|
||||
std::tie(l->type, r->multiplex_value, r->start_bit, r->name);
|
||||
});
|
||||
|
||||
for (auto sig : sigs) {
|
||||
if (sig->type == cabana::Signal::Type::Multiplexor) {
|
||||
multiplexor = sig;
|
||||
}
|
||||
sig->update();
|
||||
|
||||
// update mask
|
||||
int i = sig->msb / 8;
|
||||
int bits = sig->size;
|
||||
while (i >= 0 && i < size && bits > 0) {
|
||||
int lsb = (int)(sig->lsb / 8) == i ? sig->lsb : i * 8;
|
||||
int msb = (int)(sig->msb / 8) == i ? sig->msb : (i + 1) * 8 - 1;
|
||||
|
||||
int sz = msb - lsb + 1;
|
||||
int shift = (lsb - (i * 8));
|
||||
|
||||
mask[i] |= ((1ULL << sz) - 1) << shift;
|
||||
|
||||
bits -= size;
|
||||
i = sig->is_little_endian ? i - 1 : i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
for (auto sig : sigs) {
|
||||
sig->multiplexor = sig->type == cabana::Signal::Type::Multiplexed ? multiplexor : nullptr;
|
||||
if (!sig->multiplexor) {
|
||||
if (sig->type == cabana::Signal::Type::Multiplexed) {
|
||||
sig->type = cabana::Signal::Type::Normal;
|
||||
}
|
||||
sig->multiplex_value = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// cabana::Signal
|
||||
|
||||
void cabana::Signal::update() {
|
||||
updateMsbLsb(*this);
|
||||
if (receiver_name.isEmpty()) {
|
||||
receiver_name = DEFAULT_NODE_NAME;
|
||||
}
|
||||
|
||||
float h = 19 * (float)lsb / 64.0;
|
||||
h = fmod(h, 1.0);
|
||||
size_t hash = qHash(name);
|
||||
float s = 0.25 + 0.25 * (float)(hash & 0xff) / 255.0;
|
||||
float v = 0.75 + 0.25 * (float)((hash >> 8) & 0xff) / 255.0;
|
||||
|
||||
color = QColor::fromHsvF(h, s, v);
|
||||
precision = std::max(num_decimals(factor), num_decimals(offset));
|
||||
}
|
||||
|
||||
QString cabana::Signal::formatValue(double value) const {
|
||||
// Show enum string
|
||||
int64_t raw_value = round((value - offset) / factor);
|
||||
for (const auto &[val, desc] : val_desc) {
|
||||
if (std::abs(raw_value - val) < 1e-6) {
|
||||
return desc;
|
||||
}
|
||||
}
|
||||
|
||||
QString val_str = QString::number(value, 'f', precision);
|
||||
if (!unit.isEmpty()) {
|
||||
val_str += " " + unit;
|
||||
}
|
||||
return val_str;
|
||||
}
|
||||
|
||||
bool cabana::Signal::getValue(const uint8_t *data, size_t data_size, double *val) const {
|
||||
if (multiplexor && get_raw_value(data, data_size, *multiplexor) != multiplex_value) {
|
||||
return false;
|
||||
}
|
||||
*val = get_raw_value(data, data_size, *this);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool cabana::Signal::operator==(const cabana::Signal &other) const {
|
||||
return name == other.name && size == other.size &&
|
||||
start_bit == other.start_bit &&
|
||||
msb == other.msb && lsb == other.lsb &&
|
||||
is_signed == other.is_signed && is_little_endian == other.is_little_endian &&
|
||||
factor == other.factor && offset == other.offset &&
|
||||
min == other.min && max == other.max && comment == other.comment && unit == other.unit && val_desc == other.val_desc &&
|
||||
multiplex_value == other.multiplex_value && type == other.type && receiver_name == other.receiver_name;
|
||||
}
|
||||
|
||||
// helper functions
|
||||
|
||||
double get_raw_value(const uint8_t *data, size_t data_size, const cabana::Signal &sig) {
|
||||
int64_t val = 0;
|
||||
|
||||
int i = sig.msb / 8;
|
||||
int bits = sig.size;
|
||||
while (i >= 0 && i < data_size && bits > 0) {
|
||||
int lsb = (int)(sig.lsb / 8) == i ? sig.lsb : i * 8;
|
||||
int msb = (int)(sig.msb / 8) == i ? sig.msb : (i + 1) * 8 - 1;
|
||||
int size = msb - lsb + 1;
|
||||
|
||||
uint64_t d = (data[i] >> (lsb - (i * 8))) & ((1ULL << size) - 1);
|
||||
val |= d << (bits - size);
|
||||
|
||||
bits -= size;
|
||||
i = sig.is_little_endian ? i - 1 : i + 1;
|
||||
}
|
||||
if (sig.is_signed) {
|
||||
val -= ((val >> (sig.size - 1)) & 0x1) ? (1ULL << sig.size) : 0;
|
||||
}
|
||||
return val * sig.factor + sig.offset;
|
||||
}
|
||||
|
||||
void updateMsbLsb(cabana::Signal &s) {
|
||||
if (s.is_little_endian) {
|
||||
s.lsb = s.start_bit;
|
||||
s.msb = s.start_bit + s.size - 1;
|
||||
} else {
|
||||
s.lsb = flipBitPos(flipBitPos(s.start_bit) + s.size - 1);
|
||||
s.msb = s.start_bit;
|
||||
}
|
||||
}
|
||||
122
tools/cabana/dbc/dbc.h
Normal file
122
tools/cabana/dbc/dbc.h
Normal file
@@ -0,0 +1,122 @@
|
||||
#pragma once
|
||||
|
||||
#include <limits>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include <QColor>
|
||||
#include <QList>
|
||||
#include <QMetaType>
|
||||
#include <QString>
|
||||
|
||||
|
||||
const QString UNTITLED = "untitled";
|
||||
const QString DEFAULT_NODE_NAME = "XXX";
|
||||
|
||||
struct MessageId {
|
||||
uint8_t source = 0;
|
||||
uint32_t address = 0;
|
||||
|
||||
QString toString() const {
|
||||
return QString("%1:%2").arg(source).arg(address, 1, 16);
|
||||
}
|
||||
|
||||
bool operator==(const MessageId &other) const {
|
||||
return source == other.source && address == other.address;
|
||||
}
|
||||
|
||||
bool operator!=(const MessageId &other) const {
|
||||
return !(*this == other);
|
||||
}
|
||||
|
||||
bool operator<(const MessageId &other) const {
|
||||
return std::pair{source, address} < std::pair{other.source, other.address};
|
||||
}
|
||||
|
||||
bool operator>(const MessageId &other) const {
|
||||
return std::pair{source, address} > std::pair{other.source, other.address};
|
||||
}
|
||||
};
|
||||
|
||||
uint qHash(const MessageId &item);
|
||||
Q_DECLARE_METATYPE(MessageId);
|
||||
|
||||
template <>
|
||||
struct std::hash<MessageId> {
|
||||
std::size_t operator()(const MessageId &k) const noexcept { return qHash(k); }
|
||||
};
|
||||
|
||||
typedef QList<std::pair<double, QString>> ValueDescription;
|
||||
|
||||
namespace cabana {
|
||||
|
||||
class Signal {
|
||||
public:
|
||||
Signal() = default;
|
||||
Signal(const Signal &other) = default;
|
||||
void update();
|
||||
bool getValue(const uint8_t *data, size_t data_size, double *val) const;
|
||||
QString formatValue(double value) const;
|
||||
bool operator==(const cabana::Signal &other) const;
|
||||
inline bool operator!=(const cabana::Signal &other) const { return !(*this == other); }
|
||||
|
||||
enum class Type {
|
||||
Normal = 0,
|
||||
Multiplexed,
|
||||
Multiplexor
|
||||
};
|
||||
|
||||
Type type = Type::Normal;
|
||||
QString name;
|
||||
int start_bit, msb, lsb, size;
|
||||
double factor = 1.0;
|
||||
double offset = 0;
|
||||
bool is_signed;
|
||||
bool is_little_endian;
|
||||
double min, max;
|
||||
QString unit;
|
||||
QString comment;
|
||||
QString receiver_name;
|
||||
ValueDescription val_desc;
|
||||
int precision = 0;
|
||||
QColor color;
|
||||
|
||||
// Multiplexed
|
||||
int multiplex_value = 0;
|
||||
Signal *multiplexor = nullptr;
|
||||
};
|
||||
|
||||
class Msg {
|
||||
public:
|
||||
Msg() = default;
|
||||
Msg(const Msg &other) { *this = other; }
|
||||
~Msg();
|
||||
cabana::Signal *addSignal(const cabana::Signal &sig);
|
||||
cabana::Signal *updateSignal(const QString &sig_name, const cabana::Signal &sig);
|
||||
void removeSignal(const QString &sig_name);
|
||||
Msg &operator=(const Msg &other);
|
||||
int indexOf(const cabana::Signal *sig) const;
|
||||
cabana::Signal *sig(const QString &sig_name) const;
|
||||
QString newSignalName();
|
||||
void update();
|
||||
inline const std::vector<cabana::Signal *> &getSignals() const { return sigs; }
|
||||
|
||||
uint32_t address;
|
||||
QString name;
|
||||
uint32_t size;
|
||||
QString comment;
|
||||
QString transmitter;
|
||||
std::vector<cabana::Signal *> sigs;
|
||||
|
||||
std::vector<uint8_t> mask;
|
||||
cabana::Signal *multiplexor = nullptr;
|
||||
};
|
||||
|
||||
} // namespace cabana
|
||||
|
||||
// Helper functions
|
||||
double get_raw_value(const uint8_t *data, size_t data_size, const cabana::Signal &sig);
|
||||
void updateMsbLsb(cabana::Signal &s);
|
||||
inline int flipBitPos(int start_bit) { return 8 * (start_bit / 8) + 7 - start_bit % 8; }
|
||||
inline QString doubleToString(double value) { return QString::number(value, 'g', std::numeric_limits<double>::digits10); }
|
||||
240
tools/cabana/dbc/dbcfile.cc
Normal file
240
tools/cabana/dbc/dbcfile.cc
Normal file
@@ -0,0 +1,240 @@
|
||||
#include "tools/cabana/dbc/dbcfile.h"
|
||||
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QRegularExpression>
|
||||
#include <QTextStream>
|
||||
|
||||
DBCFile::DBCFile(const QString &dbc_file_name) {
|
||||
QFile file(dbc_file_name);
|
||||
if (file.open(QIODevice::ReadOnly)) {
|
||||
name_ = QFileInfo(dbc_file_name).baseName();
|
||||
filename = dbc_file_name;
|
||||
// Remove auto save file extension
|
||||
if (dbc_file_name.endsWith(AUTO_SAVE_EXTENSION)) {
|
||||
filename.chop(AUTO_SAVE_EXTENSION.length());
|
||||
}
|
||||
parse(file.readAll());
|
||||
} else {
|
||||
throw std::runtime_error("Failed to open file.");
|
||||
}
|
||||
}
|
||||
|
||||
DBCFile::DBCFile(const QString &name, const QString &content) : name_(name), filename("") {
|
||||
// Open from clipboard
|
||||
parse(content);
|
||||
}
|
||||
|
||||
bool DBCFile::save() {
|
||||
assert(!filename.isEmpty());
|
||||
if (writeContents(filename)) {
|
||||
cleanupAutoSaveFile();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool DBCFile::saveAs(const QString &new_filename) {
|
||||
filename = new_filename;
|
||||
return save();
|
||||
}
|
||||
|
||||
bool DBCFile::autoSave() {
|
||||
return !filename.isEmpty() && writeContents(filename + AUTO_SAVE_EXTENSION);
|
||||
}
|
||||
|
||||
void DBCFile::cleanupAutoSaveFile() {
|
||||
if (!filename.isEmpty()) {
|
||||
QFile::remove(filename + AUTO_SAVE_EXTENSION);
|
||||
}
|
||||
}
|
||||
|
||||
bool DBCFile::writeContents(const QString &fn) {
|
||||
QFile file(fn);
|
||||
if (file.open(QIODevice::WriteOnly)) {
|
||||
file.write(generateDBC().toUtf8());
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void DBCFile::updateMsg(const MessageId &id, const QString &name, uint32_t size, const QString &node, const QString &comment) {
|
||||
auto &m = msgs[id.address];
|
||||
m.address = id.address;
|
||||
m.name = name;
|
||||
m.size = size;
|
||||
m.transmitter = node.isEmpty() ? DEFAULT_NODE_NAME : node;
|
||||
m.comment = comment;
|
||||
}
|
||||
|
||||
cabana::Msg *DBCFile::msg(uint32_t address) {
|
||||
auto it = msgs.find(address);
|
||||
return it != msgs.end() ? &it->second : nullptr;
|
||||
}
|
||||
|
||||
cabana::Msg *DBCFile::msg(const QString &name) {
|
||||
auto it = std::find_if(msgs.begin(), msgs.end(), [&name](auto &m) { return m.second.name == name; });
|
||||
return it != msgs.end() ? &(it->second) : nullptr;
|
||||
}
|
||||
|
||||
int DBCFile::signalCount() {
|
||||
return std::accumulate(msgs.cbegin(), msgs.cend(), 0, [](int &n, const auto &m) { return n + m.second.sigs.size(); });
|
||||
}
|
||||
|
||||
void DBCFile::parse(const QString &content) {
|
||||
static QRegularExpression bo_regexp(R"(^BO_ (\w+) (\w+) *: (\w+) (\w+))");
|
||||
static QRegularExpression sg_regexp(R"(^SG_ (\w+) : (\d+)\|(\d+)@(\d+)([\+|\-]) \(([0-9.+\-eE]+),([0-9.+\-eE]+)\) \[([0-9.+\-eE]+)\|([0-9.+\-eE]+)\] \"(.*)\" (.*))");
|
||||
static QRegularExpression sgm_regexp(R"(^SG_ (\w+) (\w+) *: (\d+)\|(\d+)@(\d+)([\+|\-]) \(([0-9.+\-eE]+),([0-9.+\-eE]+)\) \[([0-9.+\-eE]+)\|([0-9.+\-eE]+)\] \"(.*)\" (.*))");
|
||||
static QRegularExpression msg_comment_regexp(R"(^CM_ BO_ *(\w+) *\"([^"]*)\"\s*;)");
|
||||
static QRegularExpression sg_comment_regexp(R"(^CM_ SG_ *(\w+) *(\w+) *\"([^"]*)\"\s*;)");
|
||||
static QRegularExpression val_regexp(R"(VAL_ (\w+) (\w+) (\s*[-+]?[0-9]+\s+\".+?\"[^;]*))");
|
||||
|
||||
int line_num = 0;
|
||||
QString line;
|
||||
auto dbc_assert = [&line_num, &line, this](bool condition, const QString &msg = "") {
|
||||
if (!condition) throw std::runtime_error(QString("[%1:%2]%3: %4").arg(filename).arg(line_num).arg(msg).arg(line).toStdString());
|
||||
};
|
||||
auto get_sig = [this](uint32_t address, const QString &name) -> cabana::Signal * {
|
||||
auto m = (cabana::Msg *)msg(address);
|
||||
return m ? (cabana::Signal *)m->sig(name) : nullptr;
|
||||
};
|
||||
|
||||
msgs.clear();
|
||||
QTextStream stream((QString *)&content);
|
||||
cabana::Msg *current_msg = nullptr;
|
||||
int multiplexor_cnt = 0;
|
||||
while (!stream.atEnd()) {
|
||||
++line_num;
|
||||
QString raw_line = stream.readLine();
|
||||
line = raw_line.trimmed();
|
||||
if (line.startsWith("BO_ ")) {
|
||||
multiplexor_cnt = 0;
|
||||
auto match = bo_regexp.match(line);
|
||||
dbc_assert(match.hasMatch());
|
||||
auto address = match.captured(1).toUInt();
|
||||
dbc_assert(msgs.count(address) == 0, QString("Duplicate message address: %1").arg(address));
|
||||
current_msg = &msgs[address];
|
||||
current_msg->address = address;
|
||||
current_msg->name = match.captured(2);
|
||||
current_msg->size = match.captured(3).toULong();
|
||||
current_msg->transmitter = match.captured(4).trimmed();
|
||||
} else if (line.startsWith("SG_ ")) {
|
||||
int offset = 0;
|
||||
auto match = sg_regexp.match(line);
|
||||
if (!match.hasMatch()) {
|
||||
match = sgm_regexp.match(line);
|
||||
offset = 1;
|
||||
}
|
||||
dbc_assert(match.hasMatch());
|
||||
dbc_assert(current_msg, "No Message");
|
||||
auto name = match.captured(1);
|
||||
dbc_assert(current_msg->sig(name) == nullptr, "Duplicate signal name");
|
||||
cabana::Signal s{};
|
||||
if (offset == 1) {
|
||||
auto indicator = match.captured(2);
|
||||
if (indicator == "M") {
|
||||
// Only one signal within a single message can be the multiplexer switch.
|
||||
dbc_assert(++multiplexor_cnt < 2, "Multiple multiplexor");
|
||||
s.type = cabana::Signal::Type::Multiplexor;
|
||||
} else {
|
||||
s.type = cabana::Signal::Type::Multiplexed;
|
||||
s.multiplex_value = indicator.mid(1).toInt();
|
||||
}
|
||||
}
|
||||
s.name = name;
|
||||
s.start_bit = match.captured(offset + 2).toInt();
|
||||
s.size = match.captured(offset + 3).toInt();
|
||||
s.is_little_endian = match.captured(offset + 4).toInt() == 1;
|
||||
s.is_signed = match.captured(offset + 5) == "-";
|
||||
s.factor = match.captured(offset + 6).toDouble();
|
||||
s.offset = match.captured(offset + 7).toDouble();
|
||||
s.min = match.captured(8 + offset).toDouble();
|
||||
s.max = match.captured(9 + offset).toDouble();
|
||||
s.unit = match.captured(10 + offset);
|
||||
s.receiver_name = match.captured(11 + offset).trimmed();
|
||||
|
||||
current_msg->sigs.push_back(new cabana::Signal(s));
|
||||
} else if (line.startsWith("VAL_ ")) {
|
||||
auto match = val_regexp.match(line);
|
||||
dbc_assert(match.hasMatch());
|
||||
if (auto s = get_sig(match.captured(1).toUInt(), match.captured(2))) {
|
||||
QStringList desc_list = match.captured(3).trimmed().split('"');
|
||||
for (int i = 0; i < desc_list.size(); i += 2) {
|
||||
auto val = desc_list[i].trimmed();
|
||||
if (!val.isEmpty() && (i + 1) < desc_list.size()) {
|
||||
auto desc = desc_list[i + 1].trimmed();
|
||||
s->val_desc.push_back({val.toDouble(), desc});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (line.startsWith("CM_ BO_")) {
|
||||
if (!line.endsWith("\";")) {
|
||||
int pos = stream.pos() - raw_line.length() - 1;
|
||||
line = content.mid(pos, content.indexOf("\";", pos));
|
||||
}
|
||||
auto match = msg_comment_regexp.match(line);
|
||||
dbc_assert(match.hasMatch());
|
||||
if (auto m = (cabana::Msg *)msg(match.captured(1).toUInt())) {
|
||||
m->comment = match.captured(2).trimmed();
|
||||
}
|
||||
} else if (line.startsWith("CM_ SG_ ")) {
|
||||
if (!line.endsWith("\";")) {
|
||||
int pos = stream.pos() - raw_line.length() - 1;
|
||||
line = content.mid(pos, content.indexOf("\";", pos));
|
||||
}
|
||||
auto match = sg_comment_regexp.match(line);
|
||||
dbc_assert(match.hasMatch());
|
||||
if (auto s = get_sig(match.captured(1).toUInt(), match.captured(2))) {
|
||||
s->comment = match.captured(3).trimmed();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (auto &[_, m] : msgs) {
|
||||
m.update();
|
||||
}
|
||||
}
|
||||
|
||||
QString DBCFile::generateDBC() {
|
||||
QString dbc_string, signal_comment, message_comment, val_desc;
|
||||
for (const auto &[address, m] : msgs) {
|
||||
const QString transmitter = m.transmitter.isEmpty() ? DEFAULT_NODE_NAME : m.transmitter;
|
||||
dbc_string += QString("BO_ %1 %2: %3 %4\n").arg(address).arg(m.name).arg(m.size).arg(transmitter);
|
||||
if (!m.comment.isEmpty()) {
|
||||
message_comment += QString("CM_ BO_ %1 \"%2\";\n").arg(address).arg(m.comment);
|
||||
}
|
||||
for (auto sig : m.getSignals()) {
|
||||
QString multiplexer_indicator;
|
||||
if (sig->type == cabana::Signal::Type::Multiplexor) {
|
||||
multiplexer_indicator = "M ";
|
||||
} else if (sig->type == cabana::Signal::Type::Multiplexed) {
|
||||
multiplexer_indicator = QString("m%1 ").arg(sig->multiplex_value);
|
||||
}
|
||||
dbc_string += QString(" SG_ %1 %2: %3|%4@%5%6 (%7,%8) [%9|%10] \"%11\" %12\n")
|
||||
.arg(sig->name)
|
||||
.arg(multiplexer_indicator)
|
||||
.arg(sig->start_bit)
|
||||
.arg(sig->size)
|
||||
.arg(sig->is_little_endian ? '1' : '0')
|
||||
.arg(sig->is_signed ? '-' : '+')
|
||||
.arg(doubleToString(sig->factor))
|
||||
.arg(doubleToString(sig->offset))
|
||||
.arg(doubleToString(sig->min))
|
||||
.arg(doubleToString(sig->max))
|
||||
.arg(sig->unit)
|
||||
.arg(sig->receiver_name.isEmpty() ? DEFAULT_NODE_NAME : sig->receiver_name);
|
||||
if (!sig->comment.isEmpty()) {
|
||||
signal_comment += QString("CM_ SG_ %1 %2 \"%3\";\n").arg(address).arg(sig->name).arg(sig->comment);
|
||||
}
|
||||
if (!sig->val_desc.isEmpty()) {
|
||||
QStringList text;
|
||||
for (auto &[val, desc] : sig->val_desc) {
|
||||
text << QString("%1 \"%2\"").arg(val).arg(desc);
|
||||
}
|
||||
val_desc += QString("VAL_ %1 %2 %3;\n").arg(address).arg(sig->name).arg(text.join(" "));
|
||||
}
|
||||
}
|
||||
dbc_string += "\n";
|
||||
}
|
||||
return dbc_string + message_comment + signal_comment + val_desc;
|
||||
}
|
||||
41
tools/cabana/dbc/dbcfile.h
Normal file
41
tools/cabana/dbc/dbcfile.h
Normal file
@@ -0,0 +1,41 @@
|
||||
#pragma once
|
||||
|
||||
#include <map>
|
||||
|
||||
#include "tools/cabana/dbc/dbc.h"
|
||||
|
||||
const QString AUTO_SAVE_EXTENSION = ".tmp";
|
||||
|
||||
class DBCFile {
|
||||
public:
|
||||
DBCFile(const QString &dbc_file_name);
|
||||
DBCFile(const QString &name, const QString &content);
|
||||
~DBCFile() {}
|
||||
|
||||
bool save();
|
||||
bool saveAs(const QString &new_filename);
|
||||
bool autoSave();
|
||||
bool writeContents(const QString &fn);
|
||||
void cleanupAutoSaveFile();
|
||||
QString generateDBC();
|
||||
|
||||
void updateMsg(const MessageId &id, const QString &name, uint32_t size, const QString &node, const QString &comment);
|
||||
inline void removeMsg(const MessageId &id) { msgs.erase(id.address); }
|
||||
|
||||
inline const std::map<uint32_t, cabana::Msg> &getMessages() const { return msgs; }
|
||||
cabana::Msg *msg(uint32_t address);
|
||||
cabana::Msg *msg(const QString &name);
|
||||
inline cabana::Msg *msg(const MessageId &id) { return msg(id.address); }
|
||||
|
||||
int signalCount();
|
||||
inline int msgCount() { return msgs.size(); }
|
||||
inline QString name() { return name_.isEmpty() ? "untitled" : name_; }
|
||||
inline bool isEmpty() { return (signalCount() == 0) && name_.isEmpty(); }
|
||||
|
||||
QString filename;
|
||||
|
||||
private:
|
||||
void parse(const QString &content);
|
||||
std::map<uint32_t, cabana::Msg> msgs;
|
||||
QString name_;
|
||||
};
|
||||
202
tools/cabana/dbc/dbcmanager.cc
Normal file
202
tools/cabana/dbc/dbcmanager.cc
Normal file
@@ -0,0 +1,202 @@
|
||||
#include "tools/cabana/dbc/dbcmanager.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <numeric>
|
||||
|
||||
bool DBCManager::open(const SourceSet &sources, const QString &dbc_file_name, QString *error) {
|
||||
try {
|
||||
auto it = std::find_if(dbc_files.begin(), dbc_files.end(),
|
||||
[&](auto &f) { return f.second && f.second->filename == dbc_file_name; });
|
||||
auto file = (it != dbc_files.end()) ? it->second : std::make_shared<DBCFile>(dbc_file_name);
|
||||
for (auto s : sources) {
|
||||
dbc_files[s] = file;
|
||||
}
|
||||
} catch (std::exception &e) {
|
||||
if (error) *error = e.what();
|
||||
return false;
|
||||
}
|
||||
|
||||
emit DBCFileChanged();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool DBCManager::open(const SourceSet &sources, const QString &name, const QString &content, QString *error) {
|
||||
try {
|
||||
auto file = std::make_shared<DBCFile>(name, content);
|
||||
for (auto s : sources) {
|
||||
dbc_files[s] = file;
|
||||
}
|
||||
} catch (std::exception &e) {
|
||||
if (error) *error = e.what();
|
||||
return false;
|
||||
}
|
||||
|
||||
emit DBCFileChanged();
|
||||
return true;
|
||||
}
|
||||
|
||||
void DBCManager::close(const SourceSet &sources) {
|
||||
for (auto s : sources) {
|
||||
dbc_files[s] = nullptr;
|
||||
}
|
||||
emit DBCFileChanged();
|
||||
}
|
||||
|
||||
void DBCManager::close(DBCFile *dbc_file) {
|
||||
for (auto &[_, f] : dbc_files) {
|
||||
if (f.get() == dbc_file) f = nullptr;
|
||||
}
|
||||
emit DBCFileChanged();
|
||||
}
|
||||
|
||||
void DBCManager::closeAll() {
|
||||
dbc_files.clear();
|
||||
emit DBCFileChanged();
|
||||
}
|
||||
|
||||
void DBCManager::addSignal(const MessageId &id, const cabana::Signal &sig) {
|
||||
if (auto m = msg(id)) {
|
||||
if (auto s = m->addSignal(sig)) {
|
||||
emit signalAdded(id, s);
|
||||
emit maskUpdated();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void DBCManager::updateSignal(const MessageId &id, const QString &sig_name, const cabana::Signal &sig) {
|
||||
if (auto m = msg(id)) {
|
||||
if (auto s = m->updateSignal(sig_name, sig)) {
|
||||
emit signalUpdated(s);
|
||||
emit maskUpdated();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void DBCManager::removeSignal(const MessageId &id, const QString &sig_name) {
|
||||
if (auto m = msg(id)) {
|
||||
if (auto s = m->sig(sig_name)) {
|
||||
emit signalRemoved(s);
|
||||
m->removeSignal(sig_name);
|
||||
emit maskUpdated();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void DBCManager::updateMsg(const MessageId &id, const QString &name, uint32_t size, const QString &node, const QString &comment) {
|
||||
auto dbc_file = findDBCFile(id);
|
||||
assert(dbc_file); // This should be impossible
|
||||
dbc_file->updateMsg(id, name, size, node, comment);
|
||||
emit msgUpdated(id);
|
||||
}
|
||||
|
||||
void DBCManager::removeMsg(const MessageId &id) {
|
||||
auto dbc_file = findDBCFile(id);
|
||||
assert(dbc_file); // This should be impossible
|
||||
dbc_file->removeMsg(id);
|
||||
emit msgRemoved(id);
|
||||
emit maskUpdated();
|
||||
}
|
||||
|
||||
QString DBCManager::newMsgName(const MessageId &id) {
|
||||
return QString("NEW_MSG_") + QString::number(id.address, 16).toUpper();
|
||||
}
|
||||
|
||||
QString DBCManager::newSignalName(const MessageId &id) {
|
||||
auto m = msg(id);
|
||||
return m ? m->newSignalName() : "";
|
||||
}
|
||||
|
||||
const std::vector<uint8_t> &DBCManager::mask(const MessageId &id) {
|
||||
static std::vector<uint8_t> empty_mask;
|
||||
auto m = msg(id);
|
||||
return m ? m->mask : empty_mask;
|
||||
}
|
||||
|
||||
const std::map<uint32_t, cabana::Msg> &DBCManager::getMessages(uint8_t source) {
|
||||
static std::map<uint32_t, cabana::Msg> empty_msgs;
|
||||
auto dbc_file = findDBCFile(source);
|
||||
return dbc_file ? dbc_file->getMessages() : empty_msgs;
|
||||
}
|
||||
|
||||
cabana::Msg *DBCManager::msg(const MessageId &id) {
|
||||
auto dbc_file = findDBCFile(id);
|
||||
return dbc_file ? dbc_file->msg(id) : nullptr;
|
||||
}
|
||||
|
||||
cabana::Msg *DBCManager::msg(uint8_t source, const QString &name) {
|
||||
auto dbc_file = findDBCFile(source);
|
||||
return dbc_file ? dbc_file->msg(name) : nullptr;
|
||||
}
|
||||
|
||||
QStringList DBCManager::signalNames() {
|
||||
// Used for autocompletion
|
||||
QStringList ret;
|
||||
for (auto &f : allDBCFiles()) {
|
||||
for (auto &[_, m] : f->getMessages()) {
|
||||
for (auto sig : m.getSignals()) {
|
||||
ret << sig->name;
|
||||
}
|
||||
}
|
||||
}
|
||||
ret.sort();
|
||||
ret.removeDuplicates();
|
||||
return ret;
|
||||
}
|
||||
|
||||
int DBCManager::signalCount(const MessageId &id) {
|
||||
auto m = msg(id);
|
||||
return m ? m->sigs.size() : 0;
|
||||
}
|
||||
|
||||
int DBCManager::signalCount() {
|
||||
auto files = allDBCFiles();
|
||||
return std::accumulate(files.cbegin(), files.cend(), 0, [](int &n, auto &f) { return n + f->signalCount(); });
|
||||
}
|
||||
|
||||
int DBCManager::msgCount() {
|
||||
auto files = allDBCFiles();
|
||||
return std::accumulate(files.cbegin(), files.cend(), 0, [](int &n, auto &f) { return n + f->msgCount(); });
|
||||
}
|
||||
|
||||
int DBCManager::dbcCount() {
|
||||
return allDBCFiles().size();
|
||||
}
|
||||
|
||||
int DBCManager::nonEmptyDBCCount() {
|
||||
auto files = allDBCFiles();
|
||||
return std::count_if(files.cbegin(), files.cend(), [](auto &f) { return !f->isEmpty(); });
|
||||
}
|
||||
|
||||
DBCFile *DBCManager::findDBCFile(const uint8_t source) {
|
||||
// Find DBC file that matches id.source, fall back to SOURCE_ALL if no specific DBC is found
|
||||
auto it = dbc_files.count(source) ? dbc_files.find(source) : dbc_files.find(-1);
|
||||
return it != dbc_files.end() ? it->second.get() : nullptr;
|
||||
}
|
||||
|
||||
std::set<DBCFile *> DBCManager::allDBCFiles() {
|
||||
std::set<DBCFile *> files;
|
||||
for (const auto &[_, f] : dbc_files) {
|
||||
if (f) files.insert(f.get());
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
const SourceSet DBCManager::sources(const DBCFile *dbc_file) const {
|
||||
SourceSet sources;
|
||||
for (auto &[s, f] : dbc_files) {
|
||||
if (f.get() == dbc_file) sources.insert(s);
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
|
||||
QString toString(const SourceSet &ss) {
|
||||
return std::accumulate(ss.cbegin(), ss.cend(), QString(), [](QString str, int source) {
|
||||
if (!str.isEmpty()) str += ", ";
|
||||
return str + (source == -1 ? QStringLiteral("all") : QString::number(source));
|
||||
});
|
||||
}
|
||||
|
||||
DBCManager *dbc() {
|
||||
static DBCManager dbc_manager(nullptr);
|
||||
return &dbc_manager;
|
||||
}
|
||||
74
tools/cabana/dbc/dbcmanager.h
Normal file
74
tools/cabana/dbc/dbcmanager.h
Normal file
@@ -0,0 +1,74 @@
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <memory>
|
||||
#include <map>
|
||||
#include <set>
|
||||
#include <vector>
|
||||
|
||||
#include "tools/cabana/dbc/dbcfile.h"
|
||||
|
||||
typedef std::set<int> SourceSet;
|
||||
const SourceSet SOURCE_ALL = {-1};
|
||||
const int INVALID_SOURCE = 0xff;
|
||||
inline bool operator<(const std::shared_ptr<DBCFile> &l, const std::shared_ptr<DBCFile> &r) { return l.get() < r.get(); }
|
||||
|
||||
class DBCManager : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
DBCManager(QObject *parent) : QObject(parent) {}
|
||||
~DBCManager() {}
|
||||
bool open(const SourceSet &sources, const QString &dbc_file_name, QString *error = nullptr);
|
||||
bool open(const SourceSet &sources, const QString &name, const QString &content, QString *error = nullptr);
|
||||
void close(const SourceSet &sources);
|
||||
void close(DBCFile *dbc_file);
|
||||
void closeAll();
|
||||
|
||||
void addSignal(const MessageId &id, const cabana::Signal &sig);
|
||||
void updateSignal(const MessageId &id, const QString &sig_name, const cabana::Signal &sig);
|
||||
void removeSignal(const MessageId &id, const QString &sig_name);
|
||||
|
||||
void updateMsg(const MessageId &id, const QString &name, uint32_t size, const QString &node, const QString &comment);
|
||||
void removeMsg(const MessageId &id);
|
||||
|
||||
QString newMsgName(const MessageId &id);
|
||||
QString newSignalName(const MessageId &id);
|
||||
const std::vector<uint8_t>& mask(const MessageId &id);
|
||||
|
||||
const std::map<uint32_t, cabana::Msg> &getMessages(uint8_t source);
|
||||
cabana::Msg *msg(const MessageId &id);
|
||||
cabana::Msg* msg(uint8_t source, const QString &name);
|
||||
|
||||
QStringList signalNames();
|
||||
int signalCount(const MessageId &id);
|
||||
int signalCount();
|
||||
int msgCount();
|
||||
int dbcCount();
|
||||
int nonEmptyDBCCount();
|
||||
|
||||
const SourceSet sources(const DBCFile *dbc_file) const;
|
||||
DBCFile *findDBCFile(const uint8_t source);
|
||||
inline DBCFile *findDBCFile(const MessageId &id) { return findDBCFile(id.source); }
|
||||
std::set<DBCFile *> allDBCFiles();
|
||||
|
||||
signals:
|
||||
void signalAdded(MessageId id, const cabana::Signal *sig);
|
||||
void signalRemoved(const cabana::Signal *sig);
|
||||
void signalUpdated(const cabana::Signal *sig);
|
||||
void msgUpdated(MessageId id);
|
||||
void msgRemoved(MessageId id);
|
||||
void DBCFileChanged();
|
||||
void maskUpdated();
|
||||
|
||||
private:
|
||||
std::map<int, std::shared_ptr<DBCFile>> dbc_files;
|
||||
};
|
||||
|
||||
DBCManager *dbc();
|
||||
|
||||
QString toString(const SourceSet &ss);
|
||||
inline QString msgName(const MessageId &id) {
|
||||
auto msg = dbc()->msg(id);
|
||||
return msg ? msg->name : UNTITLED;
|
||||
}
|
||||
24
tools/cabana/dbc/generate_dbc_json.py
Normal file
24
tools/cabana/dbc/generate_dbc_json.py
Normal file
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import json
|
||||
|
||||
from openpilot.selfdrive.car.car_helpers import get_interface_attr
|
||||
|
||||
|
||||
def generate_dbc_json() -> str:
|
||||
all_cars_by_brand = get_interface_attr("CAR_INFO")
|
||||
all_dbcs_by_brand = get_interface_attr("DBC")
|
||||
dbc_map = {car: all_dbcs_by_brand[brand][car]['pt'] for brand, cars in all_cars_by_brand.items() for car in cars if car != 'mock'}
|
||||
return json.dumps(dict(sorted(dbc_map.items())), indent=2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Generate mapping for all car fingerprints to DBC names and outputs json file",
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
|
||||
|
||||
parser.add_argument("--out", required=True, help="Generated json filepath")
|
||||
args = parser.parse_args()
|
||||
|
||||
with open(args.out, 'w') as f:
|
||||
f.write(generate_dbc_json())
|
||||
print(f"Generated and written to {args.out}")
|
||||
276
tools/cabana/detailwidget.cc
Normal file
276
tools/cabana/detailwidget.cc
Normal file
@@ -0,0 +1,276 @@
|
||||
#include "tools/cabana/detailwidget.h"
|
||||
|
||||
#include <QFormLayout>
|
||||
#include <QMenu>
|
||||
#include <QSpacerItem>
|
||||
|
||||
#include "tools/cabana/commands.h"
|
||||
#include "tools/cabana/mainwin.h"
|
||||
|
||||
// DetailWidget
|
||||
|
||||
DetailWidget::DetailWidget(ChartsWidget *charts, QWidget *parent) : charts(charts), QWidget(parent) {
|
||||
QVBoxLayout *main_layout = new QVBoxLayout(this);
|
||||
main_layout->setContentsMargins(0, 0, 0, 0);
|
||||
|
||||
// tabbar
|
||||
tabbar = new TabBar(this);
|
||||
tabbar->setUsesScrollButtons(true);
|
||||
tabbar->setAutoHide(true);
|
||||
tabbar->setContextMenuPolicy(Qt::CustomContextMenu);
|
||||
main_layout->addWidget(tabbar);
|
||||
|
||||
// message title
|
||||
QHBoxLayout *title_layout = new QHBoxLayout();
|
||||
title_layout->setContentsMargins(3, 6, 3, 0);
|
||||
auto spacer = new QSpacerItem(0, 1);
|
||||
title_layout->addItem(spacer);
|
||||
title_layout->addWidget(name_label = new ElidedLabel(this), 1);
|
||||
name_label->setStyleSheet("QLabel{font-weight:bold;}");
|
||||
name_label->setAlignment(Qt::AlignCenter);
|
||||
auto edit_btn = new ToolButton("pencil", tr("Edit Message"));
|
||||
title_layout->addWidget(edit_btn);
|
||||
title_layout->addWidget(remove_btn = new ToolButton("x-lg", tr("Remove Message")));
|
||||
spacer->changeSize(edit_btn->sizeHint().width() * 2 + 9, 1);
|
||||
main_layout->addLayout(title_layout);
|
||||
|
||||
// warning
|
||||
warning_widget = new QWidget(this);
|
||||
QHBoxLayout *warning_hlayout = new QHBoxLayout(warning_widget);
|
||||
warning_hlayout->addWidget(warning_icon = new QLabel(this), 0, Qt::AlignTop);
|
||||
warning_hlayout->addWidget(warning_label = new QLabel(this), 1, Qt::AlignLeft);
|
||||
warning_widget->hide();
|
||||
main_layout->addWidget(warning_widget);
|
||||
|
||||
// msg widget
|
||||
splitter = new QSplitter(Qt::Vertical, this);
|
||||
splitter->addWidget(binary_view = new BinaryView(this));
|
||||
splitter->addWidget(signal_view = new SignalView(charts, this));
|
||||
binary_view->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Minimum);
|
||||
signal_view->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Expanding);
|
||||
splitter->setStretchFactor(0, 0);
|
||||
splitter->setStretchFactor(1, 1);
|
||||
|
||||
tab_widget = new QTabWidget(this);
|
||||
tab_widget->setStyleSheet("QTabWidget::pane {border: none; margin-bottom: -2px;}");
|
||||
tab_widget->setTabPosition(QTabWidget::South);
|
||||
tab_widget->addTab(splitter, utils::icon("file-earmark-ruled"), "&Msg");
|
||||
tab_widget->addTab(history_log = new LogsWidget(this), utils::icon("stopwatch"), "&Logs");
|
||||
main_layout->addWidget(tab_widget);
|
||||
|
||||
QObject::connect(edit_btn, &QToolButton::clicked, this, &DetailWidget::editMsg);
|
||||
QObject::connect(remove_btn, &QToolButton::clicked, this, &DetailWidget::removeMsg);
|
||||
QObject::connect(binary_view, &BinaryView::signalHovered, signal_view, &SignalView::signalHovered);
|
||||
QObject::connect(binary_view, &BinaryView::signalClicked, [this](const cabana::Signal *s) { signal_view->selectSignal(s, true); });
|
||||
QObject::connect(binary_view, &BinaryView::editSignal, signal_view->model, &SignalModel::saveSignal);
|
||||
QObject::connect(binary_view, &BinaryView::showChart, charts, &ChartsWidget::showChart);
|
||||
QObject::connect(signal_view, &SignalView::showChart, charts, &ChartsWidget::showChart);
|
||||
QObject::connect(signal_view, &SignalView::highlight, binary_view, &BinaryView::highlight);
|
||||
QObject::connect(tab_widget, &QTabWidget::currentChanged, [this]() { updateState(); });
|
||||
QObject::connect(can, &AbstractStream::msgsReceived, this, &DetailWidget::updateState);
|
||||
QObject::connect(dbc(), &DBCManager::DBCFileChanged, this, &DetailWidget::refresh);
|
||||
QObject::connect(UndoStack::instance(), &QUndoStack::indexChanged, this, &DetailWidget::refresh);
|
||||
QObject::connect(tabbar, &QTabBar::customContextMenuRequested, this, &DetailWidget::showTabBarContextMenu);
|
||||
QObject::connect(tabbar, &QTabBar::currentChanged, [this](int index) {
|
||||
if (index != -1) {
|
||||
setMessage(tabbar->tabData(index).value<MessageId>());
|
||||
}
|
||||
});
|
||||
QObject::connect(tabbar, &QTabBar::tabCloseRequested, tabbar, &QTabBar::removeTab);
|
||||
QObject::connect(charts, &ChartsWidget::seriesChanged, signal_view, &SignalView::updateChartState);
|
||||
}
|
||||
|
||||
void DetailWidget::showTabBarContextMenu(const QPoint &pt) {
|
||||
int index = tabbar->tabAt(pt);
|
||||
if (index >= 0) {
|
||||
QMenu menu(this);
|
||||
menu.addAction(tr("Close Other Tabs"));
|
||||
if (menu.exec(tabbar->mapToGlobal(pt))) {
|
||||
tabbar->moveTab(index, 0);
|
||||
tabbar->setCurrentIndex(0);
|
||||
while (tabbar->count() > 1) {
|
||||
tabbar->removeTab(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void DetailWidget::setMessage(const MessageId &message_id) {
|
||||
if (std::exchange(msg_id, message_id) == message_id) return;
|
||||
|
||||
tabbar->blockSignals(true);
|
||||
int index = tabbar->count() - 1;
|
||||
for (/**/; index >= 0; --index) {
|
||||
if (tabbar->tabData(index).value<MessageId>() == message_id) break;
|
||||
}
|
||||
if (index == -1) {
|
||||
index = tabbar->addTab(message_id.toString());
|
||||
tabbar->setTabData(index, QVariant::fromValue(message_id));
|
||||
tabbar->setTabToolTip(index, msgName(message_id));
|
||||
}
|
||||
tabbar->setCurrentIndex(index);
|
||||
tabbar->blockSignals(false);
|
||||
|
||||
setUpdatesEnabled(false);
|
||||
signal_view->setMessage(msg_id);
|
||||
binary_view->setMessage(msg_id);
|
||||
history_log->setMessage(msg_id);
|
||||
refresh();
|
||||
setUpdatesEnabled(true);
|
||||
}
|
||||
|
||||
void DetailWidget::refresh() {
|
||||
QStringList warnings;
|
||||
auto msg = dbc()->msg(msg_id);
|
||||
if (msg) {
|
||||
if (msg_id.source == INVALID_SOURCE) {
|
||||
warnings.push_back(tr("No messages received."));
|
||||
} else if (msg->size != can->lastMessage(msg_id).dat.size()) {
|
||||
warnings.push_back(tr("Message size (%1) is incorrect.").arg(msg->size));
|
||||
}
|
||||
for (auto s : binary_view->getOverlappingSignals()) {
|
||||
warnings.push_back(tr("%1 has overlapping bits.").arg(s->name));
|
||||
}
|
||||
} else {
|
||||
warnings.push_back(tr("Drag-Select in binary view to create new signal."));
|
||||
}
|
||||
|
||||
QString msg_name = msg ? QString("%1 (%2)").arg(msg->name, msg->transmitter) : msgName(msg_id);
|
||||
name_label->setText(msg_name);
|
||||
name_label->setToolTip(msg_name);
|
||||
remove_btn->setEnabled(msg != nullptr);
|
||||
|
||||
if (!warnings.isEmpty()) {
|
||||
warning_label->setText(warnings.join('\n'));
|
||||
warning_icon->setPixmap(utils::icon(msg ? "exclamation-triangle" : "info-circle"));
|
||||
}
|
||||
warning_widget->setVisible(!warnings.isEmpty());
|
||||
}
|
||||
|
||||
void DetailWidget::updateState(const std::set<MessageId> *msgs) {
|
||||
if ((msgs && !msgs->count(msg_id)))
|
||||
return;
|
||||
|
||||
if (tab_widget->currentIndex() == 0)
|
||||
binary_view->updateState();
|
||||
else
|
||||
history_log->updateState();
|
||||
}
|
||||
|
||||
void DetailWidget::editMsg() {
|
||||
auto msg = dbc()->msg(msg_id);
|
||||
int size = msg ? msg->size : can->lastMessage(msg_id).dat.size();
|
||||
EditMessageDialog dlg(msg_id, msgName(msg_id), size, this);
|
||||
if (dlg.exec()) {
|
||||
UndoStack::push(new EditMsgCommand(msg_id, dlg.name_edit->text().trimmed(), dlg.size_spin->value(),
|
||||
dlg.node->text().trimmed(), dlg.comment_edit->toPlainText().trimmed()));
|
||||
}
|
||||
}
|
||||
|
||||
void DetailWidget::removeMsg() {
|
||||
UndoStack::push(new RemoveMsgCommand(msg_id));
|
||||
}
|
||||
|
||||
// EditMessageDialog
|
||||
|
||||
EditMessageDialog::EditMessageDialog(const MessageId &msg_id, const QString &title, int size, QWidget *parent)
|
||||
: original_name(title), msg_id(msg_id), QDialog(parent) {
|
||||
setWindowTitle(tr("Edit message: %1").arg(msg_id.toString()));
|
||||
QFormLayout *form_layout = new QFormLayout(this);
|
||||
|
||||
form_layout->addRow("", error_label = new QLabel);
|
||||
error_label->setVisible(false);
|
||||
form_layout->addRow(tr("Name"), name_edit = new QLineEdit(title, this));
|
||||
name_edit->setValidator(new NameValidator(name_edit));
|
||||
|
||||
form_layout->addRow(tr("Size"), size_spin = new QSpinBox(this));
|
||||
// TODO: limit the maximum?
|
||||
size_spin->setMinimum(1);
|
||||
size_spin->setValue(size);
|
||||
|
||||
form_layout->addRow(tr("Node"), node = new QLineEdit(this));
|
||||
node->setValidator(new NameValidator(name_edit));
|
||||
form_layout->addRow(tr("Comment"), comment_edit = new QTextEdit(this));
|
||||
form_layout->addRow(btn_box = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel));
|
||||
|
||||
if (auto msg = dbc()->msg(msg_id)) {
|
||||
node->setText(msg->transmitter);
|
||||
comment_edit->setText(msg->comment);
|
||||
}
|
||||
validateName(name_edit->text());
|
||||
setFixedWidth(parent->width() * 0.9);
|
||||
connect(name_edit, &QLineEdit::textEdited, this, &EditMessageDialog::validateName);
|
||||
connect(btn_box, &QDialogButtonBox::accepted, this, &QDialog::accept);
|
||||
connect(btn_box, &QDialogButtonBox::rejected, this, &QDialog::reject);
|
||||
}
|
||||
|
||||
void EditMessageDialog::validateName(const QString &text) {
|
||||
bool valid = text.compare(UNTITLED, Qt::CaseInsensitive) != 0;
|
||||
error_label->setVisible(false);
|
||||
if (!text.isEmpty() && valid && text != original_name) {
|
||||
valid = dbc()->msg(msg_id.source, text) == nullptr;
|
||||
if (!valid) {
|
||||
error_label->setText(tr("Name already exists"));
|
||||
error_label->setVisible(true);
|
||||
}
|
||||
}
|
||||
btn_box->button(QDialogButtonBox::Ok)->setEnabled(valid);
|
||||
}
|
||||
|
||||
// CenterWidget
|
||||
|
||||
CenterWidget::CenterWidget(QWidget *parent) : QWidget(parent) {
|
||||
QVBoxLayout *main_layout = new QVBoxLayout(this);
|
||||
main_layout->setContentsMargins(0, 0, 0, 0);
|
||||
main_layout->addWidget(welcome_widget = createWelcomeWidget());
|
||||
}
|
||||
|
||||
void CenterWidget::setMessage(const MessageId &msg_id) {
|
||||
if (!detail_widget) {
|
||||
delete welcome_widget;
|
||||
welcome_widget = nullptr;
|
||||
layout()->addWidget(detail_widget = new DetailWidget(((MainWindow*)parentWidget())->charts_widget, this));
|
||||
}
|
||||
detail_widget->setMessage(msg_id);
|
||||
}
|
||||
|
||||
void CenterWidget::clear() {
|
||||
delete detail_widget;
|
||||
detail_widget = nullptr;
|
||||
if (!welcome_widget) {
|
||||
layout()->addWidget(welcome_widget = createWelcomeWidget());
|
||||
}
|
||||
}
|
||||
|
||||
QWidget *CenterWidget::createWelcomeWidget() {
|
||||
QWidget *w = new QWidget(this);
|
||||
QVBoxLayout *main_layout = new QVBoxLayout(w);
|
||||
main_layout->addStretch(0);
|
||||
QLabel *logo = new QLabel("CABANA");
|
||||
logo->setAlignment(Qt::AlignCenter);
|
||||
logo->setStyleSheet("font-size:50px;font-weight:bold;");
|
||||
main_layout->addWidget(logo);
|
||||
|
||||
auto newShortcutRow = [](const QString &title, const QString &key) {
|
||||
QHBoxLayout *hlayout = new QHBoxLayout();
|
||||
auto btn = new QToolButton();
|
||||
btn->setText(key);
|
||||
btn->setEnabled(false);
|
||||
hlayout->addWidget(new QLabel(title), 0, Qt::AlignRight);
|
||||
hlayout->addWidget(btn, 0, Qt::AlignLeft);
|
||||
return hlayout;
|
||||
};
|
||||
|
||||
auto lb = new QLabel(tr("<-Select a message to view details"));
|
||||
lb->setAlignment(Qt::AlignHCenter);
|
||||
main_layout->addWidget(lb);
|
||||
main_layout->addLayout(newShortcutRow("Pause", "Space"));
|
||||
main_layout->addLayout(newShortcutRow("Help", "F1"));
|
||||
main_layout->addLayout(newShortcutRow("WhatsThis", "Shift+F1"));
|
||||
main_layout->addStretch(0);
|
||||
|
||||
w->setStyleSheet("QLabel{color:darkGray;}");
|
||||
w->setBackgroundRole(QPalette::Base);
|
||||
w->setAutoFillBackground(true);
|
||||
return w;
|
||||
}
|
||||
69
tools/cabana/detailwidget.h
Normal file
69
tools/cabana/detailwidget.h
Normal file
@@ -0,0 +1,69 @@
|
||||
#pragma once
|
||||
|
||||
#include <QDialogButtonBox>
|
||||
#include <QSplitter>
|
||||
#include <QTabWidget>
|
||||
#include <QTextEdit>
|
||||
#include <set>
|
||||
|
||||
#include "selfdrive/ui/qt/widgets/controls.h"
|
||||
#include "tools/cabana/binaryview.h"
|
||||
#include "tools/cabana/chart/chartswidget.h"
|
||||
#include "tools/cabana/historylog.h"
|
||||
#include "tools/cabana/signalview.h"
|
||||
|
||||
class EditMessageDialog : public QDialog {
|
||||
public:
|
||||
EditMessageDialog(const MessageId &msg_id, const QString &title, int size, QWidget *parent);
|
||||
void validateName(const QString &text);
|
||||
|
||||
MessageId msg_id;
|
||||
QString original_name;
|
||||
QDialogButtonBox *btn_box;
|
||||
QLineEdit *name_edit;
|
||||
QLineEdit *node;
|
||||
QTextEdit *comment_edit;
|
||||
QLabel *error_label;
|
||||
QSpinBox *size_spin;
|
||||
};
|
||||
|
||||
class DetailWidget : public QWidget {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
DetailWidget(ChartsWidget *charts, QWidget *parent);
|
||||
void setMessage(const MessageId &message_id);
|
||||
void refresh();
|
||||
|
||||
private:
|
||||
void showTabBarContextMenu(const QPoint &pt);
|
||||
void editMsg();
|
||||
void removeMsg();
|
||||
void updateState(const std::set<MessageId> *msgs = nullptr);
|
||||
|
||||
MessageId msg_id;
|
||||
QLabel *warning_icon, *warning_label;
|
||||
ElidedLabel *name_label;
|
||||
QWidget *warning_widget;
|
||||
TabBar *tabbar;
|
||||
QTabWidget *tab_widget;
|
||||
QToolButton *remove_btn;
|
||||
LogsWidget *history_log;
|
||||
BinaryView *binary_view;
|
||||
SignalView *signal_view;
|
||||
ChartsWidget *charts;
|
||||
QSplitter *splitter;
|
||||
};
|
||||
|
||||
class CenterWidget : public QWidget {
|
||||
Q_OBJECT
|
||||
public:
|
||||
CenterWidget(QWidget *parent);
|
||||
void setMessage(const MessageId &msg_id);
|
||||
void clear();
|
||||
|
||||
private:
|
||||
QWidget *createWelcomeWidget();
|
||||
DetailWidget *detail_widget = nullptr;
|
||||
QWidget *welcome_widget = nullptr;
|
||||
};
|
||||
306
tools/cabana/historylog.cc
Normal file
306
tools/cabana/historylog.cc
Normal file
@@ -0,0 +1,306 @@
|
||||
#include "tools/cabana/historylog.h"
|
||||
|
||||
#include <functional>
|
||||
|
||||
#include <QPainter>
|
||||
#include <QPushButton>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#include "tools/cabana/commands.h"
|
||||
|
||||
QVariant HistoryLogModel::data(const QModelIndex &index, int role) const {
|
||||
const bool show_signals = display_signals_mode && sigs.size() > 0;
|
||||
const auto &m = messages[index.row()];
|
||||
if (role == Qt::DisplayRole) {
|
||||
if (index.column() == 0) {
|
||||
return QString::number((m.mono_time / (double)1e9) - can->routeStartTime(), 'f', 2);
|
||||
}
|
||||
int i = index.column() - 1;
|
||||
return show_signals ? QString::number(m.sig_values[i], 'f', sigs[i]->precision) : QString();
|
||||
} else if (role == ColorsRole) {
|
||||
return QVariant::fromValue((void *)(&m.colors));
|
||||
} else if (role == BytesRole) {
|
||||
return QVariant::fromValue((void *)(&m.data));
|
||||
} else if (role == Qt::TextAlignmentRole) {
|
||||
return (uint32_t)(Qt::AlignRight | Qt::AlignVCenter);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
void HistoryLogModel::setMessage(const MessageId &message_id) {
|
||||
msg_id = message_id;
|
||||
}
|
||||
|
||||
void HistoryLogModel::refresh(bool fetch_message) {
|
||||
beginResetModel();
|
||||
sigs.clear();
|
||||
if (auto dbc_msg = dbc()->msg(msg_id)) {
|
||||
sigs = dbc_msg->getSignals();
|
||||
}
|
||||
last_fetch_time = 0;
|
||||
has_more_data = true;
|
||||
messages.clear();
|
||||
hex_colors = {};
|
||||
if (fetch_message) {
|
||||
updateState();
|
||||
}
|
||||
endResetModel();
|
||||
}
|
||||
|
||||
QVariant HistoryLogModel::headerData(int section, Qt::Orientation orientation, int role) const {
|
||||
if (orientation == Qt::Horizontal) {
|
||||
const bool show_signals = display_signals_mode && !sigs.empty();
|
||||
if (role == Qt::DisplayRole || role == Qt::ToolTipRole) {
|
||||
if (section == 0) {
|
||||
return "Time";
|
||||
}
|
||||
if (show_signals) {
|
||||
QString name = sigs[section - 1]->name;
|
||||
if (!sigs[section - 1]->unit.isEmpty()) {
|
||||
name += QString(" (%1)").arg(sigs[section - 1]->unit);
|
||||
}
|
||||
return name;
|
||||
} else {
|
||||
return "Data";
|
||||
}
|
||||
} else if (role == Qt::BackgroundRole && section > 0 && show_signals) {
|
||||
// Alpha-blend the signal color with the background to ensure contrast
|
||||
QColor sigColor = sigs[section - 1]->color;
|
||||
sigColor.setAlpha(128);
|
||||
return QBrush(sigColor);
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
void HistoryLogModel::setDynamicMode(int state) {
|
||||
dynamic_mode = state != 0;
|
||||
refresh();
|
||||
}
|
||||
|
||||
void HistoryLogModel::setDisplayType(int type) {
|
||||
display_signals_mode = type == 0;
|
||||
refresh();
|
||||
}
|
||||
|
||||
void HistoryLogModel::segmentsMerged() {
|
||||
if (!dynamic_mode) {
|
||||
has_more_data = true;
|
||||
}
|
||||
}
|
||||
|
||||
void HistoryLogModel::setFilter(int sig_idx, const QString &value, std::function<bool(double, double)> cmp) {
|
||||
filter_sig_idx = sig_idx;
|
||||
filter_value = value.toDouble();
|
||||
filter_cmp = value.isEmpty() ? nullptr : cmp;
|
||||
}
|
||||
|
||||
void HistoryLogModel::updateState() {
|
||||
uint64_t current_time = (can->lastMessage(msg_id).ts + can->routeStartTime()) * 1e9 + 1;
|
||||
auto new_msgs = dynamic_mode ? fetchData(current_time, last_fetch_time) : fetchData(0);
|
||||
if (!new_msgs.empty()) {
|
||||
beginInsertRows({}, 0, new_msgs.size() - 1);
|
||||
messages.insert(messages.begin(), std::move_iterator(new_msgs.begin()), std::move_iterator(new_msgs.end()));
|
||||
endInsertRows();
|
||||
}
|
||||
has_more_data = new_msgs.size() >= batch_size;
|
||||
last_fetch_time = current_time;
|
||||
}
|
||||
|
||||
void HistoryLogModel::fetchMore(const QModelIndex &parent) {
|
||||
if (!messages.empty()) {
|
||||
auto new_msgs = fetchData(messages.back().mono_time);
|
||||
if (!new_msgs.empty()) {
|
||||
beginInsertRows({}, messages.size(), messages.size() + new_msgs.size() - 1);
|
||||
messages.insert(messages.end(), std::move_iterator(new_msgs.begin()), std::move_iterator(new_msgs.end()));
|
||||
endInsertRows();
|
||||
}
|
||||
has_more_data = new_msgs.size() >= batch_size;
|
||||
}
|
||||
}
|
||||
|
||||
template <class InputIt>
|
||||
std::deque<HistoryLogModel::Message> HistoryLogModel::fetchData(InputIt first, InputIt last, uint64_t min_time) {
|
||||
std::deque<HistoryLogModel::Message> msgs;
|
||||
std::vector<double> values(sigs.size());
|
||||
for (; first != last && (*first)->mono_time > min_time; ++first) {
|
||||
const CanEvent *e = *first;
|
||||
for (int i = 0; i < sigs.size(); ++i) {
|
||||
sigs[i]->getValue(e->dat, e->size, &values[i]);
|
||||
}
|
||||
if (!filter_cmp || filter_cmp(values[filter_sig_idx], filter_value)) {
|
||||
auto &m = msgs.emplace_back();
|
||||
m.mono_time = e->mono_time;
|
||||
m.data.assign(e->dat, e->dat + e->size);
|
||||
m.sig_values = values;
|
||||
if (msgs.size() >= batch_size && min_time == 0) {
|
||||
return msgs;
|
||||
}
|
||||
}
|
||||
}
|
||||
return msgs;
|
||||
}
|
||||
|
||||
std::deque<HistoryLogModel::Message> HistoryLogModel::fetchData(uint64_t from_time, uint64_t min_time) {
|
||||
const auto &events = can->events(msg_id);
|
||||
const auto freq = can->lastMessage(msg_id).freq;
|
||||
const bool update_colors = !display_signals_mode || sigs.empty();
|
||||
const std::vector<uint8_t> no_mask;
|
||||
const auto speed = can->getSpeed();
|
||||
if (dynamic_mode) {
|
||||
auto first = std::upper_bound(events.rbegin(), events.rend(), from_time, [](uint64_t ts, auto e) {
|
||||
return ts > e->mono_time;
|
||||
});
|
||||
auto msgs = fetchData(first, events.rend(), min_time);
|
||||
if (update_colors && (min_time > 0 || messages.empty())) {
|
||||
for (auto it = msgs.rbegin(); it != msgs.rend(); ++it) {
|
||||
hex_colors.compute(msg_id, it->data.data(), it->data.size(), it->mono_time / (double)1e9, speed, no_mask, freq);
|
||||
it->colors = hex_colors.colors;
|
||||
}
|
||||
}
|
||||
return msgs;
|
||||
} else {
|
||||
assert(min_time == 0);
|
||||
auto first = std::upper_bound(events.cbegin(), events.cend(), from_time, CompareCanEvent());
|
||||
auto msgs = fetchData(first, events.cend(), 0);
|
||||
if (update_colors) {
|
||||
for (auto it = msgs.begin(); it != msgs.end(); ++it) {
|
||||
hex_colors.compute(msg_id, it->data.data(), it->data.size(), it->mono_time / (double)1e9, speed, no_mask, freq);
|
||||
it->colors = hex_colors.colors;
|
||||
}
|
||||
}
|
||||
return msgs;
|
||||
}
|
||||
}
|
||||
|
||||
// HeaderView
|
||||
|
||||
QSize HeaderView::sectionSizeFromContents(int logicalIndex) const {
|
||||
static const QSize time_col_size = fontMetrics().boundingRect({0, 0, 200, 200}, defaultAlignment(), "000000.000").size() + QSize(10, 6);
|
||||
if (logicalIndex == 0) {
|
||||
return time_col_size;
|
||||
} else {
|
||||
int default_size = qMax(100, (rect().width() - time_col_size.width()) / (model()->columnCount() - 1));
|
||||
QString text = model()->headerData(logicalIndex, this->orientation(), Qt::DisplayRole).toString();
|
||||
const QRect rect = fontMetrics().boundingRect({0, 0, default_size, 2000}, defaultAlignment(), text.replace(QChar('_'), ' '));
|
||||
QSize size = rect.size() + QSize{10, 6};
|
||||
return QSize{qMax(size.width(), default_size), size.height()};
|
||||
}
|
||||
}
|
||||
|
||||
void HeaderView::paintSection(QPainter *painter, const QRect &rect, int logicalIndex) const {
|
||||
auto bg_role = model()->headerData(logicalIndex, Qt::Horizontal, Qt::BackgroundRole);
|
||||
if (bg_role.isValid()) {
|
||||
painter->fillRect(rect, bg_role.value<QBrush>());
|
||||
}
|
||||
QString text = model()->headerData(logicalIndex, Qt::Horizontal, Qt::DisplayRole).toString();
|
||||
painter->setPen(palette().color(settings.theme == DARK_THEME ? QPalette::BrightText : QPalette::Text));
|
||||
painter->drawText(rect.adjusted(5, 3, -5, -3), defaultAlignment(), text.replace(QChar('_'), ' '));
|
||||
}
|
||||
|
||||
// LogsWidget
|
||||
|
||||
LogsWidget::LogsWidget(QWidget *parent) : QFrame(parent) {
|
||||
setFrameStyle(QFrame::StyledPanel | QFrame::Plain);
|
||||
QVBoxLayout *main_layout = new QVBoxLayout(this);
|
||||
main_layout->setContentsMargins(0, 0, 0, 0);
|
||||
main_layout->setSpacing(0);
|
||||
|
||||
QWidget *toolbar = new QWidget(this);
|
||||
toolbar->setAutoFillBackground(true);
|
||||
QHBoxLayout *h = new QHBoxLayout(toolbar);
|
||||
|
||||
filters_widget = new QWidget(this);
|
||||
QHBoxLayout *filter_layout = new QHBoxLayout(filters_widget);
|
||||
filter_layout->setContentsMargins(0, 0, 0, 0);
|
||||
filter_layout->addWidget(display_type_cb = new QComboBox(this));
|
||||
filter_layout->addWidget(signals_cb = new QComboBox(this));
|
||||
filter_layout->addWidget(comp_box = new QComboBox(this));
|
||||
filter_layout->addWidget(value_edit = new QLineEdit(this));
|
||||
h->addWidget(filters_widget);
|
||||
h->addStretch(0);
|
||||
h->addWidget(dynamic_mode = new QCheckBox(tr("Dynamic")), 0, Qt::AlignRight);
|
||||
|
||||
display_type_cb->addItems({"Signal", "Hex"});
|
||||
display_type_cb->setToolTip(tr("Display signal value or raw hex value"));
|
||||
comp_box->addItems({">", "=", "!=", "<"});
|
||||
value_edit->setClearButtonEnabled(true);
|
||||
value_edit->setValidator(new DoubleValidator(this));
|
||||
dynamic_mode->setChecked(true);
|
||||
dynamic_mode->setEnabled(!can->liveStreaming());
|
||||
|
||||
main_layout->addWidget(toolbar);
|
||||
QFrame *line = new QFrame(this);
|
||||
line->setFrameStyle(QFrame::HLine | QFrame::Sunken);
|
||||
main_layout->addWidget(line);
|
||||
main_layout->addWidget(logs = new QTableView(this));
|
||||
logs->setModel(model = new HistoryLogModel(this));
|
||||
delegate = new MessageBytesDelegate(this);
|
||||
logs->setHorizontalHeader(new HeaderView(Qt::Horizontal, this));
|
||||
logs->horizontalHeader()->setDefaultAlignment(Qt::AlignRight | (Qt::Alignment)Qt::TextWordWrap);
|
||||
logs->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents);
|
||||
logs->verticalHeader()->setSectionResizeMode(QHeaderView::Fixed);
|
||||
logs->verticalHeader()->setDefaultSectionSize(delegate->sizeForBytes(8).height());
|
||||
logs->verticalHeader()->setVisible(false);
|
||||
logs->setFrameShape(QFrame::NoFrame);
|
||||
|
||||
QObject::connect(display_type_cb, qOverload<int>(&QComboBox::activated), [this](int index) {
|
||||
logs->setItemDelegateForColumn(1, index == 1 ? delegate : nullptr);
|
||||
model->setDisplayType(index);
|
||||
});
|
||||
QObject::connect(dynamic_mode, &QCheckBox::stateChanged, model, &HistoryLogModel::setDynamicMode);
|
||||
QObject::connect(signals_cb, SIGNAL(activated(int)), this, SLOT(setFilter()));
|
||||
QObject::connect(comp_box, SIGNAL(activated(int)), this, SLOT(setFilter()));
|
||||
QObject::connect(value_edit, &QLineEdit::textChanged, this, &LogsWidget::setFilter);
|
||||
QObject::connect(can, &AbstractStream::seekedTo, model, &HistoryLogModel::refresh);
|
||||
QObject::connect(dbc(), &DBCManager::DBCFileChanged, this, &LogsWidget::refresh);
|
||||
QObject::connect(UndoStack::instance(), &QUndoStack::indexChanged, this, &LogsWidget::refresh);
|
||||
QObject::connect(can, &AbstractStream::eventsMerged, model, &HistoryLogModel::segmentsMerged);
|
||||
}
|
||||
|
||||
void LogsWidget::setMessage(const MessageId &message_id) {
|
||||
model->setMessage(message_id);
|
||||
refresh();
|
||||
}
|
||||
|
||||
void LogsWidget::refresh() {
|
||||
model->setFilter(0, "", nullptr);
|
||||
model->refresh(isVisible());
|
||||
bool has_signal = model->sigs.size();
|
||||
if (has_signal) {
|
||||
signals_cb->clear();
|
||||
for (auto s : model->sigs) {
|
||||
signals_cb->addItem(s->name);
|
||||
}
|
||||
}
|
||||
logs->setItemDelegateForColumn(1, !has_signal || display_type_cb->currentIndex() == 1 ? delegate : nullptr);
|
||||
value_edit->clear();
|
||||
comp_box->setCurrentIndex(0);
|
||||
filters_widget->setVisible(has_signal);
|
||||
}
|
||||
|
||||
void LogsWidget::setFilter() {
|
||||
if (value_edit->text().isEmpty() && !value_edit->isModified()) return;
|
||||
|
||||
std::function<bool(double, double)> cmp = nullptr;
|
||||
switch (comp_box->currentIndex()) {
|
||||
case 0: cmp = std::greater<double>{}; break;
|
||||
case 1: cmp = std::equal_to<double>{}; break;
|
||||
case 2: cmp = [](double l, double r) { return l != r; }; break; // not equal
|
||||
case 3: cmp = std::less<double>{}; break;
|
||||
}
|
||||
model->setFilter(signals_cb->currentIndex(), value_edit->text(), cmp);
|
||||
model->refresh();
|
||||
}
|
||||
|
||||
void LogsWidget::updateState() {
|
||||
if (isVisible() && dynamic_mode->isChecked()) {
|
||||
model->updateState();
|
||||
}
|
||||
}
|
||||
|
||||
void LogsWidget::showEvent(QShowEvent *event) {
|
||||
if (dynamic_mode->isChecked() || model->canFetchMore({}) && model->rowCount() == 0) {
|
||||
model->refresh();
|
||||
}
|
||||
}
|
||||
94
tools/cabana/historylog.h
Normal file
94
tools/cabana/historylog.h
Normal file
@@ -0,0 +1,94 @@
|
||||
#pragma once
|
||||
|
||||
#include <deque>
|
||||
#include <vector>
|
||||
|
||||
#include <QCheckBox>
|
||||
#include <QComboBox>
|
||||
#include <QHeaderView>
|
||||
#include <QLineEdit>
|
||||
#include <QTableView>
|
||||
|
||||
#include "tools/cabana/dbc/dbcmanager.h"
|
||||
#include "tools/cabana/streams/abstractstream.h"
|
||||
#include "tools/cabana/util.h"
|
||||
|
||||
class HeaderView : public QHeaderView {
|
||||
public:
|
||||
HeaderView(Qt::Orientation orientation, QWidget *parent = nullptr) : QHeaderView(orientation, parent) {}
|
||||
QSize sectionSizeFromContents(int logicalIndex) const override;
|
||||
void paintSection(QPainter *painter, const QRect &rect, int logicalIndex) const;
|
||||
};
|
||||
|
||||
class HistoryLogModel : public QAbstractTableModel {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
HistoryLogModel(QObject *parent) : QAbstractTableModel(parent) {}
|
||||
void setMessage(const MessageId &message_id);
|
||||
void updateState();
|
||||
void setFilter(int sig_idx, const QString &value, std::function<bool(double, double)> cmp);
|
||||
QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override;
|
||||
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
|
||||
void fetchMore(const QModelIndex &parent) override;
|
||||
inline bool canFetchMore(const QModelIndex &parent) const override { return has_more_data; }
|
||||
int rowCount(const QModelIndex &parent = QModelIndex()) const override { return messages.size(); }
|
||||
int columnCount(const QModelIndex &parent = QModelIndex()) const override {
|
||||
return display_signals_mode && !sigs.empty() ? sigs.size() + 1 : 2;
|
||||
}
|
||||
void refresh(bool fetch_message = true);
|
||||
|
||||
public slots:
|
||||
void setDisplayType(int type);
|
||||
void setDynamicMode(int state);
|
||||
void segmentsMerged();
|
||||
|
||||
public:
|
||||
struct Message {
|
||||
uint64_t mono_time = 0;
|
||||
std::vector<double> sig_values;
|
||||
std::vector<uint8_t> data;
|
||||
std::vector<QColor> colors;
|
||||
};
|
||||
|
||||
template <class InputIt>
|
||||
std::deque<HistoryLogModel::Message> fetchData(InputIt first, InputIt last, uint64_t min_time);
|
||||
std::deque<Message> fetchData(uint64_t from_time, uint64_t min_time = 0);
|
||||
|
||||
MessageId msg_id;
|
||||
CanData hex_colors;
|
||||
bool has_more_data = true;
|
||||
const int batch_size = 50;
|
||||
int filter_sig_idx = -1;
|
||||
double filter_value = 0;
|
||||
uint64_t last_fetch_time = 0;
|
||||
std::function<bool(double, double)> filter_cmp = nullptr;
|
||||
std::deque<Message> messages;
|
||||
std::vector<cabana::Signal *> sigs;
|
||||
bool dynamic_mode = true;
|
||||
bool display_signals_mode = true;
|
||||
};
|
||||
|
||||
class LogsWidget : public QFrame {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
LogsWidget(QWidget *parent);
|
||||
void setMessage(const MessageId &message_id);
|
||||
void updateState();
|
||||
void showEvent(QShowEvent *event) override;
|
||||
|
||||
private slots:
|
||||
void setFilter();
|
||||
|
||||
private:
|
||||
void refresh();
|
||||
|
||||
QTableView *logs;
|
||||
HistoryLogModel *model;
|
||||
QCheckBox *dynamic_mode;
|
||||
QComboBox *signals_cb, *comp_box, *display_type_cb;
|
||||
QLineEdit *value_edit;
|
||||
QWidget *filters_widget;
|
||||
MessageBytesDelegate *delegate;
|
||||
};
|
||||
697
tools/cabana/mainwin.cc
Normal file
697
tools/cabana/mainwin.cc
Normal file
@@ -0,0 +1,697 @@
|
||||
#include "tools/cabana/mainwin.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
|
||||
#include <QClipboard>
|
||||
#include <QDesktopWidget>
|
||||
#include <QFile>
|
||||
#include <QFileDialog>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonObject>
|
||||
#include <QMenuBar>
|
||||
#include <QMessageBox>
|
||||
#include <QResizeEvent>
|
||||
#include <QShortcut>
|
||||
#include <QTextDocument>
|
||||
#include <QUndoView>
|
||||
#include <QVBoxLayout>
|
||||
#include <QWidgetAction>
|
||||
|
||||
#include "tools/cabana/commands.h"
|
||||
#include "tools/cabana/streamselector.h"
|
||||
#include "tools/cabana/tools/findsignal.h"
|
||||
#include "tools/replay/replay.h"
|
||||
|
||||
MainWindow::MainWindow() : QMainWindow() {
|
||||
loadFingerprints();
|
||||
createDockWindows();
|
||||
setCentralWidget(center_widget = new CenterWidget(this));
|
||||
createActions();
|
||||
createStatusBar();
|
||||
createShortcuts();
|
||||
|
||||
// save default window state to allow resetting it
|
||||
default_state = saveState();
|
||||
|
||||
// restore states
|
||||
restoreGeometry(settings.geometry);
|
||||
if (isMaximized()) {
|
||||
setGeometry(QApplication::desktop()->availableGeometry(this));
|
||||
}
|
||||
restoreState(settings.window_state);
|
||||
|
||||
// install handlers
|
||||
static auto static_main_win = this;
|
||||
qRegisterMetaType<uint64_t>("uint64_t");
|
||||
qRegisterMetaType<SourceSet>("SourceSet");
|
||||
qRegisterMetaType<ReplyMsgType>("ReplyMsgType");
|
||||
installDownloadProgressHandler([](uint64_t cur, uint64_t total, bool success) {
|
||||
emit static_main_win->updateProgressBar(cur, total, success);
|
||||
});
|
||||
qInstallMessageHandler([](QtMsgType type, const QMessageLogContext &context, const QString &msg) {
|
||||
if (type == QtDebugMsg) std::cout << msg.toStdString() << std::endl;
|
||||
emit static_main_win->showMessage(msg, 2000);
|
||||
});
|
||||
installMessageHandler([](ReplyMsgType type, const std::string msg) {
|
||||
qInfo() << QString::fromStdString(msg);
|
||||
});
|
||||
|
||||
setStyleSheet(QString(R"(QMainWindow::separator {
|
||||
width: %1px; /* when vertical */
|
||||
height: %1px; /* when horizontal */
|
||||
})").arg(style()->pixelMetric(QStyle::PM_SplitterWidth)));
|
||||
|
||||
QObject::connect(this, &MainWindow::showMessage, statusBar(), &QStatusBar::showMessage);
|
||||
QObject::connect(this, &MainWindow::updateProgressBar, this, &MainWindow::updateDownloadProgress);
|
||||
QObject::connect(dbc(), &DBCManager::DBCFileChanged, this, &MainWindow::DBCFileChanged);
|
||||
QObject::connect(UndoStack::instance(), &QUndoStack::cleanChanged, this, &MainWindow::undoStackCleanChanged);
|
||||
QObject::connect(UndoStack::instance(), &QUndoStack::indexChanged, this, &MainWindow::undoStackIndexChanged);
|
||||
QObject::connect(&settings, &Settings::changed, this, &MainWindow::updateStatus);
|
||||
QObject::connect(StreamNotifier::instance(), &StreamNotifier::changingStream, this, &MainWindow::changingStream);
|
||||
QObject::connect(StreamNotifier::instance(), &StreamNotifier::streamStarted, this, &MainWindow::streamStarted);
|
||||
}
|
||||
|
||||
void MainWindow::loadFingerprints() {
|
||||
QFile json_file(QApplication::applicationDirPath() + "/dbc/car_fingerprint_to_dbc.json");
|
||||
if (json_file.open(QIODevice::ReadOnly)) {
|
||||
fingerprint_to_dbc = QJsonDocument::fromJson(json_file.readAll());
|
||||
}
|
||||
// get opendbc names
|
||||
for (auto fn : QDir(OPENDBC_FILE_PATH).entryList({"*.dbc"}, QDir::Files, QDir::Name)) {
|
||||
opendbc_names << QFileInfo(fn).baseName();
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::createActions() {
|
||||
// File menu
|
||||
QMenu *file_menu = menuBar()->addMenu(tr("&File"));
|
||||
file_menu->addAction(tr("Open Stream..."), this, &MainWindow::openStream);
|
||||
close_stream_act = file_menu->addAction(tr("Close stream"), this, &MainWindow::closeStream);
|
||||
close_stream_act->setEnabled(false);
|
||||
file_menu->addSeparator();
|
||||
|
||||
file_menu->addAction(tr("New DBC File"), [this]() { newFile(); }, QKeySequence::New);
|
||||
file_menu->addAction(tr("Open DBC File..."), [this]() { openFile(); }, QKeySequence::Open);
|
||||
|
||||
manage_dbcs_menu = file_menu->addMenu(tr("Manage &DBC Files"));
|
||||
|
||||
open_recent_menu = file_menu->addMenu(tr("Open &Recent"));
|
||||
for (int i = 0; i < MAX_RECENT_FILES; ++i) {
|
||||
recent_files_acts[i] = new QAction(this);
|
||||
recent_files_acts[i]->setVisible(false);
|
||||
QObject::connect(recent_files_acts[i], &QAction::triggered, this, &MainWindow::openRecentFile);
|
||||
open_recent_menu->addAction(recent_files_acts[i]);
|
||||
}
|
||||
updateRecentFileActions();
|
||||
|
||||
file_menu->addSeparator();
|
||||
QMenu *load_opendbc_menu = file_menu->addMenu(tr("Load DBC from commaai/opendbc"));
|
||||
// load_opendbc_menu->setStyleSheet("QMenu { menu-scrollable: true; }");
|
||||
for (const auto &dbc_name : opendbc_names) {
|
||||
load_opendbc_menu->addAction(dbc_name, [this, name = dbc_name]() { loadDBCFromOpendbc(name); });
|
||||
}
|
||||
|
||||
file_menu->addAction(tr("Load DBC From Clipboard"), [=]() { loadFromClipboard(); });
|
||||
|
||||
file_menu->addSeparator();
|
||||
save_dbc = file_menu->addAction(tr("Save DBC..."), this, &MainWindow::save, QKeySequence::Save);
|
||||
save_dbc_as = file_menu->addAction(tr("Save DBC As..."), this, &MainWindow::saveAs, QKeySequence::SaveAs);
|
||||
copy_dbc_to_clipboard = file_menu->addAction(tr("Copy DBC To Clipboard"), this, &MainWindow::saveToClipboard);
|
||||
|
||||
file_menu->addSeparator();
|
||||
file_menu->addAction(tr("Settings..."), this, &MainWindow::setOption, QKeySequence::Preferences);
|
||||
|
||||
file_menu->addSeparator();
|
||||
file_menu->addAction(tr("E&xit"), qApp, &QApplication::closeAllWindows, QKeySequence::Quit);
|
||||
|
||||
// Edit Menu
|
||||
QMenu *edit_menu = menuBar()->addMenu(tr("&Edit"));
|
||||
auto undo_act = UndoStack::instance()->createUndoAction(this, tr("&Undo"));
|
||||
undo_act->setShortcuts(QKeySequence::Undo);
|
||||
edit_menu->addAction(undo_act);
|
||||
auto redo_act = UndoStack::instance()->createRedoAction(this, tr("&Rndo"));
|
||||
redo_act->setShortcuts(QKeySequence::Redo);
|
||||
edit_menu->addAction(redo_act);
|
||||
edit_menu->addSeparator();
|
||||
|
||||
QMenu *commands_menu = edit_menu->addMenu(tr("Command &List"));
|
||||
QWidgetAction *commands_act = new QWidgetAction(this);
|
||||
commands_act->setDefaultWidget(new QUndoView(UndoStack::instance()));
|
||||
commands_menu->addAction(commands_act);
|
||||
|
||||
// View Menu
|
||||
QMenu *view_menu = menuBar()->addMenu(tr("&View"));
|
||||
auto act = view_menu->addAction(tr("Full Screen"), this, &MainWindow::toggleFullScreen, QKeySequence::FullScreen);
|
||||
addAction(act);
|
||||
view_menu->addSeparator();
|
||||
view_menu->addAction(messages_dock->toggleViewAction());
|
||||
view_menu->addAction(video_dock->toggleViewAction());
|
||||
view_menu->addSeparator();
|
||||
view_menu->addAction(tr("Reset Window Layout"), [this]() { restoreState(default_state); });
|
||||
|
||||
// Tools Menu
|
||||
tools_menu = menuBar()->addMenu(tr("&Tools"));
|
||||
tools_menu->addAction(tr("Find &Similar Bits"), this, &MainWindow::findSimilarBits);
|
||||
tools_menu->addAction(tr("&Find Signal"), this, &MainWindow::findSignal);
|
||||
|
||||
// Help Menu
|
||||
QMenu *help_menu = menuBar()->addMenu(tr("&Help"));
|
||||
help_menu->addAction(tr("Help"), this, &MainWindow::onlineHelp, QKeySequence::HelpContents);
|
||||
help_menu->addAction(tr("About &Qt"), qApp, &QApplication::aboutQt);
|
||||
}
|
||||
|
||||
void MainWindow::createDockWindows() {
|
||||
messages_dock = new QDockWidget(tr("MESSAGES"), this);
|
||||
messages_dock->setObjectName("MessagesPanel");
|
||||
messages_dock->setAllowedAreas(Qt::LeftDockWidgetArea | Qt::RightDockWidgetArea | Qt::TopDockWidgetArea | Qt::BottomDockWidgetArea);
|
||||
messages_dock->setFeatures(QDockWidget::DockWidgetMovable | QDockWidget::DockWidgetFloatable | QDockWidget::DockWidgetClosable);
|
||||
addDockWidget(Qt::LeftDockWidgetArea, messages_dock);
|
||||
|
||||
video_dock = new QDockWidget("", this);
|
||||
video_dock->setObjectName(tr("VideoPanel"));
|
||||
video_dock->setAllowedAreas(Qt::LeftDockWidgetArea | Qt::RightDockWidgetArea);
|
||||
video_dock->setFeatures(QDockWidget::DockWidgetMovable | QDockWidget::DockWidgetFloatable | QDockWidget::DockWidgetClosable);
|
||||
addDockWidget(Qt::RightDockWidgetArea, video_dock);
|
||||
}
|
||||
|
||||
void MainWindow::createDockWidgets() {
|
||||
messages_widget = new MessagesWidget(this);
|
||||
messages_dock->setWidget(messages_widget);
|
||||
|
||||
// right panel
|
||||
charts_widget = new ChartsWidget(this);
|
||||
QWidget *charts_container = new QWidget(this);
|
||||
charts_layout = new QVBoxLayout(charts_container);
|
||||
charts_layout->setContentsMargins(0, 0, 0, 0);
|
||||
charts_layout->addWidget(charts_widget);
|
||||
|
||||
// splitter between video and charts
|
||||
video_splitter = new QSplitter(Qt::Vertical, this);
|
||||
video_widget = new VideoWidget(this);
|
||||
video_splitter->addWidget(video_widget);
|
||||
QObject::connect(charts_widget, &ChartsWidget::rangeChanged, video_widget, &VideoWidget::updateTimeRange);
|
||||
|
||||
video_splitter->addWidget(charts_container);
|
||||
video_splitter->setStretchFactor(1, 1);
|
||||
video_splitter->restoreState(settings.video_splitter_state);
|
||||
video_splitter->handle(1)->setEnabled(!can->liveStreaming());
|
||||
video_dock->setWidget(video_splitter);
|
||||
QObject::connect(charts_widget, &ChartsWidget::dock, this, &MainWindow::dockCharts);
|
||||
}
|
||||
|
||||
void MainWindow::createStatusBar() {
|
||||
progress_bar = new QProgressBar();
|
||||
progress_bar->setRange(0, 100);
|
||||
progress_bar->setTextVisible(true);
|
||||
progress_bar->setFixedSize({300, 16});
|
||||
progress_bar->setVisible(false);
|
||||
statusBar()->addWidget(new QLabel(tr("For Help, Press F1")));
|
||||
statusBar()->addPermanentWidget(progress_bar);
|
||||
|
||||
statusBar()->addPermanentWidget(status_label = new QLabel(this));
|
||||
updateStatus();
|
||||
}
|
||||
|
||||
void MainWindow::createShortcuts() {
|
||||
auto shortcut = new QShortcut(QKeySequence(Qt::Key_Space), this, nullptr, nullptr, Qt::ApplicationShortcut);
|
||||
QObject::connect(shortcut, &QShortcut::activated, []() { can->pause(!can->isPaused()); });
|
||||
// TODO: add more shortcuts here.
|
||||
}
|
||||
|
||||
void MainWindow::undoStackIndexChanged(int index) {
|
||||
int count = UndoStack::instance()->count();
|
||||
if (count >= 0) {
|
||||
QString command_text;
|
||||
if (index == count) {
|
||||
command_text = (count == prev_undostack_count ? "Redo " : "") + UndoStack::instance()->text(index - 1);
|
||||
} else if (index < prev_undostack_index) {
|
||||
command_text = tr("Undo %1").arg(UndoStack::instance()->text(index));
|
||||
} else if (index > prev_undostack_index) {
|
||||
command_text = tr("Redo %1").arg(UndoStack::instance()->text(index - 1));
|
||||
}
|
||||
statusBar()->showMessage(command_text, 2000);
|
||||
}
|
||||
prev_undostack_index = index;
|
||||
prev_undostack_count = count;
|
||||
autoSave();
|
||||
updateLoadSaveMenus();
|
||||
}
|
||||
|
||||
void MainWindow::undoStackCleanChanged(bool clean) {
|
||||
if (clean) {
|
||||
prev_undostack_index = 0;
|
||||
prev_undostack_count = 0;
|
||||
}
|
||||
setWindowModified(!clean);
|
||||
}
|
||||
|
||||
void MainWindow::DBCFileChanged() {
|
||||
UndoStack::instance()->clear();
|
||||
updateLoadSaveMenus();
|
||||
}
|
||||
|
||||
void MainWindow::openStream() {
|
||||
AbstractStream *stream = nullptr;
|
||||
StreamSelector dlg(&stream, this);
|
||||
if (dlg.exec()) {
|
||||
if (!dlg.dbcFile().isEmpty()) {
|
||||
loadFile(dlg.dbcFile());
|
||||
}
|
||||
stream->start();
|
||||
statusBar()->showMessage(tr("Route %1 loaded").arg(can->routeName()), 2000);
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::closeStream() {
|
||||
AbstractStream *stream = new DummyStream(this);
|
||||
stream->start();
|
||||
if (dbc()->nonEmptyDBCCount() > 0) {
|
||||
emit dbc()->DBCFileChanged();
|
||||
}
|
||||
statusBar()->showMessage(tr("stream closed"));
|
||||
}
|
||||
|
||||
void MainWindow::newFile(SourceSet s) {
|
||||
closeFile(s);
|
||||
dbc()->open(s, "", "");
|
||||
}
|
||||
|
||||
void MainWindow::openFile(SourceSet s) {
|
||||
remindSaveChanges();
|
||||
QString fn = QFileDialog::getOpenFileName(this, tr("Open File"), settings.last_dir, "DBC (*.dbc)");
|
||||
if (!fn.isEmpty()) {
|
||||
loadFile(fn, s);
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::loadFile(const QString &fn, SourceSet s) {
|
||||
if (!fn.isEmpty()) {
|
||||
closeFile(s);
|
||||
|
||||
QString dbc_fn = fn;
|
||||
// Prompt user to load auto saved file if it exists.
|
||||
if (QFile::exists(fn + AUTO_SAVE_EXTENSION)) {
|
||||
auto ret = QMessageBox::question(this, tr("Auto saved DBC found"), tr("Auto saved DBC file from previous session found. Do you want to load it instead?"));
|
||||
if (ret == QMessageBox::Yes) {
|
||||
dbc_fn += AUTO_SAVE_EXTENSION;
|
||||
UndoStack::instance()->resetClean(); // Force user to save on close so the auto saved file is not lost
|
||||
}
|
||||
}
|
||||
|
||||
QString error;
|
||||
if (dbc()->open(s, dbc_fn, &error)) {
|
||||
updateRecentFiles(fn);
|
||||
statusBar()->showMessage(tr("DBC File %1 loaded").arg(fn), 2000);
|
||||
} else {
|
||||
QMessageBox msg_box(QMessageBox::Warning, tr("Failed to load DBC file"), tr("Failed to parse DBC file %1").arg(fn));
|
||||
msg_box.setDetailedText(error);
|
||||
msg_box.exec();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::openRecentFile() {
|
||||
if (auto action = qobject_cast<QAction *>(sender())) {
|
||||
loadFile(action->data().toString());
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::loadDBCFromOpendbc(const QString &name) {
|
||||
QString opendbc_file_path = QString("%1/%2.dbc").arg(OPENDBC_FILE_PATH, name);
|
||||
loadFile(opendbc_file_path);
|
||||
}
|
||||
|
||||
void MainWindow::loadFromClipboard(SourceSet s, bool close_all) {
|
||||
closeFile(s);
|
||||
|
||||
QString dbc_str = QGuiApplication::clipboard()->text();
|
||||
QString error;
|
||||
bool ret = dbc()->open(s, "", dbc_str, &error);
|
||||
if (ret && dbc()->msgCount() > 0) {
|
||||
QMessageBox::information(this, tr("Load From Clipboard"), tr("DBC Successfully Loaded!"));
|
||||
} else {
|
||||
QMessageBox msg_box(QMessageBox::Warning, tr("Failed to load DBC from clipboard"), tr("Make sure that you paste the text with correct format."));
|
||||
msg_box.setDetailedText(error);
|
||||
msg_box.exec();
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::changingStream() {
|
||||
center_widget->clear();
|
||||
delete messages_widget;
|
||||
delete video_splitter;
|
||||
}
|
||||
|
||||
void MainWindow::streamStarted() {
|
||||
bool has_stream = dynamic_cast<DummyStream *>(can) == nullptr;
|
||||
close_stream_act->setEnabled(has_stream);
|
||||
tools_menu->setEnabled(has_stream);
|
||||
createDockWidgets();
|
||||
|
||||
video_dock->setWindowTitle(can->routeName());
|
||||
if (can->liveStreaming() || video_splitter->sizes()[0] == 0) {
|
||||
// display video at minimum size.
|
||||
video_splitter->setSizes({1, 1});
|
||||
}
|
||||
// Don't overwrite already loaded DBC
|
||||
if (!dbc()->msgCount()) {
|
||||
newFile();
|
||||
}
|
||||
|
||||
QObject::connect(messages_widget, &MessagesWidget::msgSelectionChanged, center_widget, &CenterWidget::setMessage);
|
||||
QObject::connect(can, &AbstractStream::eventsMerged, this, &MainWindow::eventsMerged);
|
||||
QObject::connect(can, &AbstractStream::sourcesUpdated, this, &MainWindow::updateLoadSaveMenus);
|
||||
}
|
||||
|
||||
void MainWindow::eventsMerged() {
|
||||
if (!can->liveStreaming() && std::exchange(car_fingerprint, can->carFingerprint()) != car_fingerprint) {
|
||||
video_dock->setWindowTitle(tr("ROUTE: %1 FINGERPRINT: %2")
|
||||
.arg(can->routeName())
|
||||
.arg(car_fingerprint.isEmpty() ? tr("Unknown Car") : car_fingerprint));
|
||||
// Don't overwrite already loaded DBC
|
||||
if (!dbc()->msgCount() && !car_fingerprint.isEmpty()) {
|
||||
auto dbc_name = fingerprint_to_dbc[car_fingerprint];
|
||||
if (dbc_name != QJsonValue::Undefined) {
|
||||
// Prevent dialog that load autosaved file from blocking replay->start().
|
||||
QTimer::singleShot(0, this, [dbc_name, this]() { loadDBCFromOpendbc(dbc_name.toString()); });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::save() {
|
||||
// Save all open DBC files
|
||||
for (auto dbc_file : dbc()->allDBCFiles()) {
|
||||
if (dbc_file->isEmpty()) continue;
|
||||
saveFile(dbc_file);
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::saveAs() {
|
||||
// Save as all open DBC files. Should not be called with more than 1 file open
|
||||
for (auto dbc_file : dbc()->allDBCFiles()) {
|
||||
if (dbc_file->isEmpty()) continue;
|
||||
saveFileAs(dbc_file);
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::autoSave() {
|
||||
if (!UndoStack::instance()->isClean()) {
|
||||
for (auto dbc_file : dbc()->allDBCFiles()) {
|
||||
if (!dbc_file->filename.isEmpty()) {
|
||||
dbc_file->autoSave();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::cleanupAutoSaveFile() {
|
||||
for (auto dbc_file : dbc()->allDBCFiles()) {
|
||||
dbc_file->cleanupAutoSaveFile();
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::closeFile(SourceSet s) {
|
||||
remindSaveChanges();
|
||||
if (s == SOURCE_ALL) {
|
||||
dbc()->closeAll();
|
||||
} else {
|
||||
dbc()->close(s);
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::closeFile(DBCFile *dbc_file) {
|
||||
assert(dbc_file != nullptr);
|
||||
remindSaveChanges();
|
||||
dbc()->close(dbc_file);
|
||||
// Ensure we always have at least one file open
|
||||
if (dbc()->dbcCount() == 0) {
|
||||
newFile();
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::saveFile(DBCFile *dbc_file) {
|
||||
assert(dbc_file != nullptr);
|
||||
if (!dbc_file->filename.isEmpty()) {
|
||||
dbc_file->save();
|
||||
updateLoadSaveMenus();
|
||||
UndoStack::instance()->setClean();
|
||||
statusBar()->showMessage(tr("File saved"), 2000);
|
||||
} else if (!dbc_file->isEmpty()) {
|
||||
saveFileAs(dbc_file);
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::saveFileAs(DBCFile *dbc_file) {
|
||||
QString title = tr("Save File (bus: %1)").arg(toString(dbc()->sources(dbc_file)));
|
||||
QString fn = QFileDialog::getSaveFileName(this, title, QDir::cleanPath(settings.last_dir + "/untitled.dbc"), tr("DBC (*.dbc)"));
|
||||
if (!fn.isEmpty()) {
|
||||
dbc_file->saveAs(fn);
|
||||
UndoStack::instance()->setClean();
|
||||
statusBar()->showMessage(tr("File saved as %1").arg(fn), 2000);
|
||||
updateRecentFiles(fn);
|
||||
updateLoadSaveMenus();
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::saveToClipboard() {
|
||||
// Copy all open DBC files to clipboard. Should not be called with more than 1 file open
|
||||
for (auto dbc_file : dbc()->allDBCFiles()) {
|
||||
if (dbc_file->isEmpty()) continue;
|
||||
saveFileToClipboard(dbc_file);
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::saveFileToClipboard(DBCFile *dbc_file) {
|
||||
assert(dbc_file != nullptr);
|
||||
QGuiApplication::clipboard()->setText(dbc_file->generateDBC());
|
||||
QMessageBox::information(this, tr("Copy To Clipboard"), tr("DBC Successfully copied!"));
|
||||
}
|
||||
|
||||
void MainWindow::updateLoadSaveMenus() {
|
||||
int cnt = dbc()->nonEmptyDBCCount();
|
||||
save_dbc->setText(cnt > 1 ? tr("Save %1 DBCs...").arg(cnt) : tr("Save DBC..."));
|
||||
save_dbc->setEnabled(cnt > 0);
|
||||
save_dbc_as->setEnabled(cnt == 1);
|
||||
|
||||
// TODO: Support clipboard for multiple files
|
||||
copy_dbc_to_clipboard->setEnabled(cnt == 1);
|
||||
|
||||
manage_dbcs_menu->clear();
|
||||
manage_dbcs_menu->setEnabled(dynamic_cast<DummyStream *>(can) == nullptr);
|
||||
|
||||
for (int source : can->sources) {
|
||||
if (source >= 64) continue; // Sent and blocked buses are handled implicitly
|
||||
|
||||
SourceSet ss = {source, uint8_t(source + 128), uint8_t(source + 192)};
|
||||
|
||||
QMenu *bus_menu = new QMenu(this);
|
||||
bus_menu->addAction(tr("New DBC File..."), [=]() { newFile(ss); });
|
||||
bus_menu->addAction(tr("Open DBC File..."), [=]() { openFile(ss); });
|
||||
bus_menu->addAction(tr("Load DBC From Clipboard..."), [=]() { loadFromClipboard(ss, false); });
|
||||
|
||||
// Show sub-menu for each dbc for this source.
|
||||
QString file_name = "No DBCs loaded";
|
||||
if (auto dbc_file = dbc()->findDBCFile(source)) {
|
||||
bus_menu->addSeparator();
|
||||
bus_menu->addAction(dbc_file->name() + " (" + toString(dbc()->sources(dbc_file)) + ")")->setEnabled(false);
|
||||
bus_menu->addAction(tr("Save..."), [=]() { saveFile(dbc_file); });
|
||||
bus_menu->addAction(tr("Save As..."), [=]() { saveFileAs(dbc_file); });
|
||||
bus_menu->addAction(tr("Copy to Clipboard..."), [=]() { saveFileToClipboard(dbc_file); });
|
||||
bus_menu->addAction(tr("Remove from this bus..."), [=]() { closeFile(ss); });
|
||||
bus_menu->addAction(tr("Remove from all buses..."), [=]() { closeFile(dbc_file); });
|
||||
|
||||
file_name = dbc_file->name();
|
||||
}
|
||||
|
||||
manage_dbcs_menu->addMenu(bus_menu);
|
||||
bus_menu->setTitle(tr("Bus %1 (%2)").arg(source).arg(file_name));
|
||||
}
|
||||
|
||||
QStringList title;
|
||||
for (auto f : dbc()->allDBCFiles()) {
|
||||
title.push_back(tr("(%1) %2").arg(toString(dbc()->sources(f)), f->name()));
|
||||
}
|
||||
setWindowFilePath(title.join(" | "));
|
||||
}
|
||||
|
||||
void MainWindow::updateRecentFiles(const QString &fn) {
|
||||
settings.recent_files.removeAll(fn);
|
||||
settings.recent_files.prepend(fn);
|
||||
while (settings.recent_files.size() > MAX_RECENT_FILES) {
|
||||
settings.recent_files.removeLast();
|
||||
}
|
||||
settings.last_dir = QFileInfo(fn).absolutePath();
|
||||
updateRecentFileActions();
|
||||
}
|
||||
|
||||
void MainWindow::updateRecentFileActions() {
|
||||
int num_recent_files = std::min<int>(settings.recent_files.size(), MAX_RECENT_FILES);
|
||||
|
||||
for (int i = 0; i < num_recent_files; ++i) {
|
||||
QString text = tr("&%1 %2").arg(i + 1).arg(QFileInfo(settings.recent_files[i]).fileName());
|
||||
recent_files_acts[i]->setText(text);
|
||||
recent_files_acts[i]->setData(settings.recent_files[i]);
|
||||
recent_files_acts[i]->setVisible(true);
|
||||
}
|
||||
for (int i = num_recent_files; i < MAX_RECENT_FILES; ++i) {
|
||||
recent_files_acts[i]->setVisible(false);
|
||||
}
|
||||
open_recent_menu->setEnabled(num_recent_files > 0);
|
||||
}
|
||||
|
||||
void MainWindow::remindSaveChanges() {
|
||||
bool discard_changes = false;
|
||||
while (!UndoStack::instance()->isClean() && !discard_changes) {
|
||||
QString text = tr("You have unsaved changes. Press ok to save them, cancel to discard.");
|
||||
int ret = (QMessageBox::question(this, tr("Unsaved Changes"), text, QMessageBox::Ok | QMessageBox::Cancel));
|
||||
if (ret == QMessageBox::Ok) {
|
||||
save();
|
||||
} else {
|
||||
discard_changes = true;
|
||||
}
|
||||
}
|
||||
UndoStack::instance()->clear();
|
||||
}
|
||||
|
||||
void MainWindow::updateDownloadProgress(uint64_t cur, uint64_t total, bool success) {
|
||||
if (success && cur < total) {
|
||||
progress_bar->setValue((cur / (double)total) * 100);
|
||||
progress_bar->setFormat(tr("Downloading %p% (%1)").arg(formattedDataSize(total).c_str()));
|
||||
progress_bar->show();
|
||||
} else {
|
||||
progress_bar->hide();
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::updateStatus() {
|
||||
status_label->setText(tr("Cached Minutes:%1 FPS:%2").arg(settings.max_cached_minutes).arg(settings.fps));
|
||||
}
|
||||
|
||||
void MainWindow::dockCharts(bool dock) {
|
||||
if (dock && floating_window) {
|
||||
floating_window->removeEventFilter(charts_widget);
|
||||
charts_layout->insertWidget(0, charts_widget, 1);
|
||||
floating_window->deleteLater();
|
||||
floating_window = nullptr;
|
||||
} else if (!dock && !floating_window) {
|
||||
floating_window = new QWidget(this);
|
||||
floating_window->setWindowFlags(Qt::Window);
|
||||
floating_window->setWindowTitle("Charts");
|
||||
floating_window->setLayout(new QVBoxLayout());
|
||||
floating_window->layout()->addWidget(charts_widget);
|
||||
floating_window->installEventFilter(charts_widget);
|
||||
floating_window->showMaximized();
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::closeEvent(QCloseEvent *event) {
|
||||
cleanupAutoSaveFile();
|
||||
remindSaveChanges();
|
||||
|
||||
installDownloadProgressHandler(nullptr);
|
||||
qInstallMessageHandler(nullptr);
|
||||
|
||||
if (floating_window)
|
||||
floating_window->deleteLater();
|
||||
|
||||
// save states
|
||||
settings.geometry = saveGeometry();
|
||||
settings.window_state = saveState();
|
||||
if (!can->liveStreaming()) {
|
||||
settings.video_splitter_state = video_splitter->saveState();
|
||||
}
|
||||
settings.message_header_state = messages_widget->saveHeaderState();
|
||||
|
||||
QWidget::closeEvent(event);
|
||||
}
|
||||
|
||||
void MainWindow::setOption() {
|
||||
SettingsDlg dlg(this);
|
||||
dlg.exec();
|
||||
}
|
||||
|
||||
void MainWindow::findSimilarBits() {
|
||||
FindSimilarBitsDlg *dlg = new FindSimilarBitsDlg(this);
|
||||
QObject::connect(dlg, &FindSimilarBitsDlg::openMessage, messages_widget, &MessagesWidget::selectMessage);
|
||||
dlg->show();
|
||||
}
|
||||
|
||||
void MainWindow::findSignal() {
|
||||
FindSignalDlg *dlg = new FindSignalDlg(this);
|
||||
QObject::connect(dlg, &FindSignalDlg::openMessage, messages_widget, &MessagesWidget::selectMessage);
|
||||
dlg->show();
|
||||
}
|
||||
|
||||
void MainWindow::onlineHelp() {
|
||||
if (auto help = findChild<HelpOverlay*>()) {
|
||||
help->close();
|
||||
} else {
|
||||
help = new HelpOverlay(this);
|
||||
help->setGeometry(rect());
|
||||
help->show();
|
||||
help->raise();
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::toggleFullScreen() {
|
||||
if (isFullScreen()) {
|
||||
menuBar()->show();
|
||||
statusBar()->show();
|
||||
showNormal();
|
||||
showMaximized();
|
||||
} else {
|
||||
menuBar()->hide();
|
||||
statusBar()->hide();
|
||||
showFullScreen();
|
||||
}
|
||||
}
|
||||
|
||||
// HelpOverlay
|
||||
HelpOverlay::HelpOverlay(MainWindow *parent) : QWidget(parent) {
|
||||
setAttribute(Qt::WA_NoSystemBackground, true);
|
||||
setAttribute(Qt::WA_TranslucentBackground, true);
|
||||
setAttribute(Qt::WA_DeleteOnClose);
|
||||
parent->installEventFilter(this);
|
||||
}
|
||||
|
||||
void HelpOverlay::paintEvent(QPaintEvent *event) {
|
||||
QPainter painter(this);
|
||||
painter.fillRect(rect(), QColor(0, 0, 0, 50));
|
||||
MainWindow *parent = (MainWindow *)parentWidget();
|
||||
drawHelpForWidget(painter, parent->findChild<MessagesWidget *>());
|
||||
drawHelpForWidget(painter, parent->findChild<BinaryView *>());
|
||||
drawHelpForWidget(painter, parent->findChild<SignalView *>());
|
||||
drawHelpForWidget(painter, parent->findChild<ChartsWidget *>());
|
||||
drawHelpForWidget(painter, parent->findChild<VideoWidget *>());
|
||||
}
|
||||
|
||||
void HelpOverlay::drawHelpForWidget(QPainter &painter, QWidget *w) {
|
||||
if (w && w->isVisible() && !w->whatsThis().isEmpty()) {
|
||||
QPoint pt = mapFromGlobal(w->mapToGlobal(w->rect().center()));
|
||||
if (rect().contains(pt)) {
|
||||
QTextDocument document;
|
||||
document.setHtml(w->whatsThis());
|
||||
QSize doc_size = document.size().toSize();
|
||||
QPoint topleft = {pt.x() - doc_size.width() / 2, pt.y() - doc_size.height() / 2};
|
||||
painter.translate(topleft);
|
||||
painter.fillRect(QRect{{0, 0}, doc_size}, palette().toolTipBase());
|
||||
document.drawContents(&painter);
|
||||
painter.translate(-topleft);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool HelpOverlay::eventFilter(QObject *obj, QEvent *event) {
|
||||
if (obj == parentWidget() && event->type() == QEvent::Resize) {
|
||||
QResizeEvent *resize_event = (QResizeEvent *)(event);
|
||||
setGeometry(QRect{QPoint(0, 0), resize_event->size()});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void HelpOverlay::mouseReleaseEvent(QMouseEvent *event) {
|
||||
close();
|
||||
}
|
||||
117
tools/cabana/mainwin.h
Normal file
117
tools/cabana/mainwin.h
Normal file
@@ -0,0 +1,117 @@
|
||||
#pragma once
|
||||
|
||||
#include <QDockWidget>
|
||||
#include <QJsonDocument>
|
||||
#include <QMainWindow>
|
||||
#include <QMenu>
|
||||
#include <QProgressBar>
|
||||
#include <QSplitter>
|
||||
#include <QStatusBar>
|
||||
#include <set>
|
||||
|
||||
#include "tools/cabana/chart/chartswidget.h"
|
||||
#include "tools/cabana/dbc/dbcmanager.h"
|
||||
#include "tools/cabana/detailwidget.h"
|
||||
#include "tools/cabana/messageswidget.h"
|
||||
#include "tools/cabana/videowidget.h"
|
||||
#include "tools/cabana/tools/findsimilarbits.h"
|
||||
|
||||
class MainWindow : public QMainWindow {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
MainWindow();
|
||||
void dockCharts(bool dock);
|
||||
void showStatusMessage(const QString &msg, int timeout = 0) { statusBar()->showMessage(msg, timeout); }
|
||||
void loadFile(const QString &fn, SourceSet s = SOURCE_ALL);
|
||||
ChartsWidget *charts_widget = nullptr;
|
||||
|
||||
public slots:
|
||||
void openStream();
|
||||
void closeStream();
|
||||
void changingStream();
|
||||
void streamStarted();
|
||||
|
||||
void newFile(SourceSet s = SOURCE_ALL);
|
||||
void openFile(SourceSet s = SOURCE_ALL);
|
||||
void openRecentFile();
|
||||
void loadDBCFromOpendbc(const QString &name);
|
||||
void save();
|
||||
void saveAs();
|
||||
void saveToClipboard();
|
||||
|
||||
signals:
|
||||
void showMessage(const QString &msg, int timeout);
|
||||
void updateProgressBar(uint64_t cur, uint64_t total, bool success);
|
||||
|
||||
protected:
|
||||
void remindSaveChanges();
|
||||
void closeFile(SourceSet s = SOURCE_ALL);
|
||||
void closeFile(DBCFile *dbc_file);
|
||||
void saveFile(DBCFile *dbc_file);
|
||||
void saveFileAs(DBCFile *dbc_file);
|
||||
void saveFileToClipboard(DBCFile *dbc_file);
|
||||
void loadFingerprints();
|
||||
void loadFromClipboard(SourceSet s = SOURCE_ALL, bool close_all = true);
|
||||
void autoSave();
|
||||
void cleanupAutoSaveFile();
|
||||
void updateRecentFiles(const QString &fn);
|
||||
void updateRecentFileActions();
|
||||
void createActions();
|
||||
void createDockWindows();
|
||||
void createStatusBar();
|
||||
void createShortcuts();
|
||||
void closeEvent(QCloseEvent *event) override;
|
||||
void DBCFileChanged();
|
||||
void updateDownloadProgress(uint64_t cur, uint64_t total, bool success);
|
||||
void setOption();
|
||||
void findSimilarBits();
|
||||
void findSignal();
|
||||
void undoStackCleanChanged(bool clean);
|
||||
void undoStackIndexChanged(int index);
|
||||
void onlineHelp();
|
||||
void toggleFullScreen();
|
||||
void updateStatus();
|
||||
void updateLoadSaveMenus();
|
||||
void createDockWidgets();
|
||||
void eventsMerged();
|
||||
|
||||
VideoWidget *video_widget = nullptr;
|
||||
QDockWidget *video_dock;
|
||||
QDockWidget *messages_dock;
|
||||
MessagesWidget *messages_widget = nullptr;
|
||||
CenterWidget *center_widget;
|
||||
QWidget *floating_window = nullptr;
|
||||
QVBoxLayout *charts_layout;
|
||||
QProgressBar *progress_bar;
|
||||
QLabel *status_label;
|
||||
QJsonDocument fingerprint_to_dbc;
|
||||
QStringList opendbc_names;
|
||||
QSplitter *video_splitter = nullptr;
|
||||
enum { MAX_RECENT_FILES = 15 };
|
||||
QAction *recent_files_acts[MAX_RECENT_FILES] = {};
|
||||
QMenu *open_recent_menu = nullptr;
|
||||
QMenu *manage_dbcs_menu = nullptr;
|
||||
QMenu *tools_menu = nullptr;
|
||||
QAction *close_stream_act = nullptr;
|
||||
QAction *save_dbc = nullptr;
|
||||
QAction *save_dbc_as = nullptr;
|
||||
QAction *copy_dbc_to_clipboard = nullptr;
|
||||
QString car_fingerprint;
|
||||
int prev_undostack_index = 0;
|
||||
int prev_undostack_count = 0;
|
||||
QByteArray default_state;
|
||||
friend class OnlineHelp;
|
||||
};
|
||||
|
||||
class HelpOverlay : public QWidget {
|
||||
Q_OBJECT
|
||||
public:
|
||||
HelpOverlay(MainWindow *parent);
|
||||
|
||||
protected:
|
||||
void drawHelpForWidget(QPainter &painter, QWidget *w);
|
||||
void paintEvent(QPaintEvent *event) override;
|
||||
void mouseReleaseEvent(QMouseEvent *event) override;
|
||||
bool eventFilter(QObject *obj, QEvent *event) override;
|
||||
};
|
||||
442
tools/cabana/messageswidget.cc
Normal file
442
tools/cabana/messageswidget.cc
Normal file
@@ -0,0 +1,442 @@
|
||||
#include "tools/cabana/messageswidget.h"
|
||||
|
||||
#include <limits>
|
||||
#include <utility>
|
||||
|
||||
#include <QCheckBox>
|
||||
#include <QHBoxLayout>
|
||||
#include <QPainter>
|
||||
#include <QPushButton>
|
||||
#include <QScrollBar>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#include "tools/cabana/commands.h"
|
||||
|
||||
MessagesWidget::MessagesWidget(QWidget *parent) : menu(new QMenu(this)), QWidget(parent) {
|
||||
QVBoxLayout *main_layout = new QVBoxLayout(this);
|
||||
main_layout->setContentsMargins(0, 0, 0, 0);
|
||||
main_layout->setSpacing(0);
|
||||
// toolbar
|
||||
main_layout->addWidget(createToolBar());
|
||||
// message table
|
||||
main_layout->addWidget(view = new MessageView(this));
|
||||
view->setItemDelegate(delegate = new MessageBytesDelegate(view, settings.multiple_lines_hex));
|
||||
view->setModel(model = new MessageListModel(this));
|
||||
view->setHeader(header = new MessageViewHeader(this));
|
||||
view->setSortingEnabled(true);
|
||||
view->sortByColumn(MessageListModel::Column::NAME, Qt::AscendingOrder);
|
||||
view->setAllColumnsShowFocus(true);
|
||||
view->setEditTriggers(QAbstractItemView::NoEditTriggers);
|
||||
view->setItemsExpandable(false);
|
||||
view->setIndentation(0);
|
||||
view->setRootIsDecorated(false);
|
||||
view->setUniformRowHeights(!settings.multiple_lines_hex);
|
||||
|
||||
// Must be called before setting any header parameters to avoid overriding
|
||||
restoreHeaderState(settings.message_header_state);
|
||||
header->setSectionsMovable(true);
|
||||
header->setSectionResizeMode(MessageListModel::Column::DATA, QHeaderView::Fixed);
|
||||
header->setStretchLastSection(true);
|
||||
header->setContextMenuPolicy(Qt::CustomContextMenu);
|
||||
|
||||
// suppress
|
||||
QHBoxLayout *suppress_layout = new QHBoxLayout();
|
||||
suppress_layout->addWidget(suppress_add = new QPushButton("Suppress Highlighted"));
|
||||
suppress_layout->addWidget(suppress_clear = new QPushButton());
|
||||
suppress_clear->setToolTip(tr("Clear suppressed"));
|
||||
suppress_layout->addStretch(1);
|
||||
QCheckBox *suppress_defined_signals = new QCheckBox(tr("Suppress Signals"), this);
|
||||
suppress_defined_signals->setToolTip(tr("Suppress defined signals"));
|
||||
suppress_defined_signals->setChecked(settings.suppress_defined_signals);
|
||||
suppress_layout->addWidget(suppress_defined_signals);
|
||||
main_layout->addLayout(suppress_layout);
|
||||
|
||||
// signals/slots
|
||||
QObject::connect(menu, &QMenu::aboutToShow, this, &MessagesWidget::menuAboutToShow);
|
||||
QObject::connect(header, &MessageViewHeader::customContextMenuRequested, this, &MessagesWidget::headerContextMenuEvent);
|
||||
QObject::connect(view->horizontalScrollBar(), &QScrollBar::valueChanged, header, &MessageViewHeader::updateHeaderPositions);
|
||||
QObject::connect(suppress_defined_signals, &QCheckBox::stateChanged, can, &AbstractStream::suppressDefinedSignals);
|
||||
QObject::connect(can, &AbstractStream::msgsReceived, model, &MessageListModel::msgsReceived);
|
||||
QObject::connect(dbc(), &DBCManager::DBCFileChanged, model, &MessageListModel::dbcModified);
|
||||
QObject::connect(UndoStack::instance(), &QUndoStack::indexChanged, model, &MessageListModel::dbcModified);
|
||||
QObject::connect(model, &MessageListModel::modelReset, [this]() {
|
||||
if (current_msg_id) {
|
||||
selectMessage(*current_msg_id);
|
||||
}
|
||||
view->updateBytesSectionSize();
|
||||
updateTitle();
|
||||
});
|
||||
QObject::connect(view->selectionModel(), &QItemSelectionModel::currentChanged, [=](const QModelIndex ¤t, const QModelIndex &previous) {
|
||||
if (current.isValid() && current.row() < model->items_.size()) {
|
||||
const auto &id = model->items_[current.row()].id;
|
||||
if (!current_msg_id || id != *current_msg_id) {
|
||||
current_msg_id = id;
|
||||
emit msgSelectionChanged(*current_msg_id);
|
||||
}
|
||||
}
|
||||
});
|
||||
QObject::connect(suppress_add, &QPushButton::clicked, this, &MessagesWidget::suppressHighlighted);
|
||||
QObject::connect(suppress_clear, &QPushButton::clicked, this, &MessagesWidget::suppressHighlighted);
|
||||
suppressHighlighted();
|
||||
|
||||
setWhatsThis(tr(R"(
|
||||
<b>Message View</b><br/>
|
||||
<!-- TODO: add descprition here -->
|
||||
<span style="color:gray">Byte color</span><br />
|
||||
<span style="color:gray;">■ </span> constant changing<br />
|
||||
<span style="color:blue;">■ </span> increasing<br />
|
||||
<span style="color:red;">■ </span> decreasing
|
||||
)"));
|
||||
}
|
||||
|
||||
QToolBar *MessagesWidget::createToolBar() {
|
||||
QToolBar *toolbar = new QToolBar(this);
|
||||
toolbar->setIconSize({12, 12});
|
||||
toolbar->addWidget(num_msg_label = new QLabel(this));
|
||||
num_msg_label->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred);
|
||||
|
||||
auto views_btn = toolbar->addAction(utils::icon("three-dots"), tr("View..."));
|
||||
views_btn->setMenu(menu);
|
||||
auto view_button = qobject_cast<QToolButton *>(toolbar->widgetForAction(views_btn));
|
||||
view_button->setPopupMode(QToolButton::InstantPopup);
|
||||
view_button->setToolButtonStyle(Qt::ToolButtonIconOnly);
|
||||
view_button->setStyleSheet("QToolButton::menu-indicator { image: none; }");
|
||||
return toolbar;
|
||||
}
|
||||
|
||||
void MessagesWidget::updateTitle() {
|
||||
auto stats = std::accumulate(
|
||||
model->items_.begin(), model->items_.end(), std::pair<size_t, size_t>(),
|
||||
[](const auto &pair, const auto &item) {
|
||||
auto m = dbc()->msg(item.id);
|
||||
return m ? std::make_pair(pair.first + 1, pair.second + m->sigs.size()) : pair;
|
||||
});
|
||||
num_msg_label->setText(tr("%1 Messages (%2 DBC Messages, %3 Signals)")
|
||||
.arg(model->items_.size()).arg(stats.first).arg(stats.second));
|
||||
}
|
||||
|
||||
void MessagesWidget::selectMessage(const MessageId &msg_id) {
|
||||
auto it = std::find_if(model->items_.cbegin(), model->items_.cend(),
|
||||
[&msg_id](auto &item) { return item.id == msg_id; });
|
||||
if (it != model->items_.cend()) {
|
||||
view->setCurrentIndex(model->index(std::distance(model->items_.cbegin(), it), 0));
|
||||
}
|
||||
}
|
||||
|
||||
void MessagesWidget::suppressHighlighted() {
|
||||
if (sender() == suppress_add) {
|
||||
size_t n = can->suppressHighlighted();
|
||||
suppress_clear->setText(tr("Clear (%1)").arg(n));
|
||||
suppress_clear->setEnabled(true);
|
||||
} else {
|
||||
can->clearSuppressed();
|
||||
suppress_clear->setText(tr("Clear"));
|
||||
suppress_clear->setEnabled(false);
|
||||
}
|
||||
}
|
||||
|
||||
void MessagesWidget::headerContextMenuEvent(const QPoint &pos) {
|
||||
menu->exec(header->mapToGlobal(pos));
|
||||
}
|
||||
|
||||
void MessagesWidget::menuAboutToShow() {
|
||||
menu->clear();
|
||||
for (int i = 0; i < header->count(); ++i) {
|
||||
int logical_index = header->logicalIndex(i);
|
||||
auto action = menu->addAction(model->headerData(logical_index, Qt::Horizontal).toString(),
|
||||
[=](bool checked) { header->setSectionHidden(logical_index, !checked); });
|
||||
action->setCheckable(true);
|
||||
action->setChecked(!header->isSectionHidden(logical_index));
|
||||
// Can't hide the name column
|
||||
action->setEnabled(logical_index > 0);
|
||||
}
|
||||
menu->addSeparator();
|
||||
auto action = menu->addAction(tr("Mutlti-Line bytes"), this, &MessagesWidget::setMultiLineBytes);
|
||||
action->setCheckable(true);
|
||||
action->setChecked(settings.multiple_lines_hex);
|
||||
}
|
||||
|
||||
void MessagesWidget::setMultiLineBytes(bool multi) {
|
||||
settings.multiple_lines_hex = multi;
|
||||
delegate->setMultipleLines(multi);
|
||||
view->setUniformRowHeights(!multi);
|
||||
view->updateBytesSectionSize();
|
||||
view->doItemsLayout();
|
||||
}
|
||||
|
||||
// MessageListModel
|
||||
|
||||
QVariant MessageListModel::headerData(int section, Qt::Orientation orientation, int role) const {
|
||||
if (orientation == Qt::Horizontal && role == Qt::DisplayRole) {
|
||||
switch (section) {
|
||||
case Column::NAME: return tr("Name");
|
||||
case Column::SOURCE: return tr("Bus");
|
||||
case Column::ADDRESS: return tr("ID");
|
||||
case Column::NODE: return tr("Node");
|
||||
case Column::FREQ: return tr("Freq");
|
||||
case Column::COUNT: return tr("Count");
|
||||
case Column::DATA: return tr("Bytes");
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
QVariant MessageListModel::data(const QModelIndex &index, int role) const {
|
||||
if (!index.isValid() || index.row() >= items_.size()) return {};
|
||||
|
||||
auto getFreq = [](const CanData &d) {
|
||||
if (d.freq > 0 && (can->currentSec() - d.ts - 1.0 / settings.fps) < (5.0 / d.freq)) {
|
||||
return d.freq >= 0.95 ? QString::number(std::nearbyint(d.freq)) : QString::number(d.freq, 'f', 2);
|
||||
} else {
|
||||
return QStringLiteral("--");
|
||||
}
|
||||
};
|
||||
|
||||
const auto &item = items_[index.row()];
|
||||
const auto &data = can->lastMessage(item.id);
|
||||
if (role == Qt::DisplayRole) {
|
||||
switch (index.column()) {
|
||||
case Column::NAME: return item.name;
|
||||
case Column::SOURCE: return item.id.source != INVALID_SOURCE ? QString::number(item.id.source) : "N/A";
|
||||
case Column::ADDRESS: return QString::number(item.id.address, 16);
|
||||
case Column::NODE: return item.node;
|
||||
case Column::FREQ: return item.id.source != INVALID_SOURCE ? getFreq(data) : "N/A";
|
||||
case Column::COUNT: return item.id.source != INVALID_SOURCE ? QString::number(data.count) : "N/A";
|
||||
case Column::DATA: return item.id.source != INVALID_SOURCE ? "" : "N/A";
|
||||
}
|
||||
} else if (role == ColorsRole) {
|
||||
return QVariant::fromValue((void*)(&data.colors));
|
||||
} else if (role == BytesRole && index.column() == Column::DATA && item.id.source != INVALID_SOURCE) {
|
||||
return QVariant::fromValue((void*)(&data.dat));
|
||||
} else if (role == Qt::ToolTipRole && index.column() == Column::NAME) {
|
||||
auto msg = dbc()->msg(item.id);
|
||||
auto tooltip = item.name;
|
||||
if (msg && !msg->comment.isEmpty()) tooltip += "<br /><span style=\"color:gray;\">" + msg->comment + "</span>";
|
||||
return tooltip;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
void MessageListModel::setFilterStrings(const QMap<int, QString> &filters) {
|
||||
filters_ = filters;
|
||||
filterAndSort();
|
||||
}
|
||||
|
||||
void MessageListModel::dbcModified() {
|
||||
dbc_messages_.clear();
|
||||
for (const auto &[_, m] : dbc()->getMessages(-1)) {
|
||||
dbc_messages_.insert(MessageId{.source = INVALID_SOURCE, .address = m.address});
|
||||
}
|
||||
filterAndSort();
|
||||
}
|
||||
|
||||
void MessageListModel::sortItems(std::vector<MessageListModel::Item> &items) {
|
||||
auto do_sort = [order = sort_order](std::vector<MessageListModel::Item> &m, auto proj) {
|
||||
std::stable_sort(m.begin(), m.end(), [order, proj = std::move(proj)](auto &l, auto &r) {
|
||||
return order == Qt::AscendingOrder ? proj(l) < proj(r) : proj(l) > proj(r);
|
||||
});
|
||||
};
|
||||
switch (sort_column) {
|
||||
case Column::NAME: do_sort(items, [](auto &item) { return std::tie(item.name, item.id); }); break;
|
||||
case Column::SOURCE: do_sort(items, [](auto &item) { return std::tie(item.id.source, item.id); }); break;
|
||||
case Column::ADDRESS: do_sort(items, [](auto &item) { return std::tie(item.id.address, item.id);}); break;
|
||||
case Column::NODE: do_sort(items, [](auto &item) { return std::tie(item.node, item.id);}); break;
|
||||
case Column::FREQ: do_sort(items, [](auto &item) { return std::make_pair(can->lastMessage(item.id).freq, item.id); }); break;
|
||||
case Column::COUNT: do_sort(items, [](auto &item) { return std::make_pair(can->lastMessage(item.id).count, item.id); }); break;
|
||||
}
|
||||
}
|
||||
|
||||
static bool parseRange(const QString &filter, uint32_t value, int base = 10) {
|
||||
// Parse out filter string into a range (e.g. "1" -> {1, 1}, "1-3" -> {1, 3}, "1-" -> {1, inf})
|
||||
unsigned int min = std::numeric_limits<unsigned int>::min();
|
||||
unsigned int max = std::numeric_limits<unsigned int>::max();
|
||||
auto s = filter.split('-');
|
||||
bool ok = s.size() >= 1 && s.size() <= 2;
|
||||
if (ok && !s[0].isEmpty()) min = s[0].toUInt(&ok, base);
|
||||
if (ok && s.size() == 1) {
|
||||
max = min;
|
||||
} else if (ok && s.size() == 2 && !s[1].isEmpty()) {
|
||||
max = s[1].toUInt(&ok, base);
|
||||
}
|
||||
return ok && value >= min && value <= max;
|
||||
}
|
||||
|
||||
bool MessageListModel::match(const MessageListModel::Item &item) {
|
||||
if (filters_.isEmpty())
|
||||
return true;
|
||||
|
||||
bool match = true;
|
||||
const auto &data = can->lastMessage(item.id);
|
||||
for (auto it = filters_.cbegin(); it != filters_.cend() && match; ++it) {
|
||||
const QString &txt = it.value();
|
||||
switch (it.key()) {
|
||||
case Column::NAME: {
|
||||
match = item.name.contains(txt, Qt::CaseInsensitive);
|
||||
if (!match) {
|
||||
const auto m = dbc()->msg(item.id);
|
||||
match = m && std::any_of(m->sigs.cbegin(), m->sigs.cend(),
|
||||
[&txt](const auto &s) { return s->name.contains(txt, Qt::CaseInsensitive); });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case Column::SOURCE:
|
||||
match = parseRange(txt, item.id.source);
|
||||
break;
|
||||
case Column::ADDRESS:
|
||||
match = QString::number(item.id.address, 16).contains(txt, Qt::CaseInsensitive);
|
||||
match = match || parseRange(txt, item.id.address, 16);
|
||||
break;
|
||||
case Column::NODE:
|
||||
match = item.node.contains(txt, Qt::CaseInsensitive);
|
||||
break;
|
||||
case Column::FREQ:
|
||||
// TODO: Hide stale messages?
|
||||
match = parseRange(txt, data.freq);
|
||||
break;
|
||||
case Column::COUNT:
|
||||
match = parseRange(txt, data.count);
|
||||
break;
|
||||
case Column::DATA:
|
||||
match = utils::toHex(data.dat).contains(txt, Qt::CaseInsensitive);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return match;
|
||||
}
|
||||
|
||||
void MessageListModel::filterAndSort() {
|
||||
// merge CAN and DBC messages
|
||||
std::vector<MessageId> all_messages;
|
||||
all_messages.reserve(can->lastMessages().size() + dbc_messages_.size());
|
||||
auto dbc_msgs = dbc_messages_;
|
||||
for (const auto &[id, m] : can->lastMessages()) {
|
||||
all_messages.push_back(id);
|
||||
dbc_msgs.erase(MessageId{.source = INVALID_SOURCE, .address = id.address});
|
||||
}
|
||||
all_messages.insert(all_messages.end(), dbc_msgs.begin(), dbc_msgs.end());
|
||||
|
||||
// filter and sort
|
||||
std::vector<Item> items;
|
||||
for (const auto &id : all_messages) {
|
||||
auto msg = dbc()->msg(id);
|
||||
Item item = {.id = id,
|
||||
.name = msg ? msg->name : UNTITLED,
|
||||
.node = msg ? msg->transmitter : QString()};
|
||||
if (match(item))
|
||||
items.emplace_back(item);
|
||||
}
|
||||
sortItems(items);
|
||||
|
||||
if (items_ != items) {
|
||||
beginResetModel();
|
||||
items_ = std::move(items);
|
||||
endResetModel();
|
||||
}
|
||||
}
|
||||
|
||||
void MessageListModel::msgsReceived(const std::set<MessageId> *new_msgs, bool has_new_ids) {
|
||||
if (has_new_ids || filters_.contains(Column::FREQ) || filters_.contains(Column::COUNT) || filters_.contains(Column::DATA)) {
|
||||
filterAndSort();
|
||||
}
|
||||
for (int i = 0; i < items_.size(); ++i) {
|
||||
if (!new_msgs || new_msgs->count(items_[i].id)) {
|
||||
for (int col = Column::FREQ; col < columnCount(); ++col)
|
||||
emit dataChanged(index(i, col), index(i, col), {Qt::DisplayRole});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void MessageListModel::sort(int column, Qt::SortOrder order) {
|
||||
if (column != Column::DATA) {
|
||||
sort_column = column;
|
||||
sort_order = order;
|
||||
filterAndSort();
|
||||
}
|
||||
}
|
||||
|
||||
// MessageView
|
||||
|
||||
void MessageView::drawRow(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const {
|
||||
QTreeView::drawRow(painter, option, index);
|
||||
const int gridHint = style()->styleHint(QStyle::SH_Table_GridLineColor, &option, this);
|
||||
const QColor gridColor = QColor::fromRgba(static_cast<QRgb>(gridHint));
|
||||
QPen old_pen = painter->pen();
|
||||
painter->setPen(gridColor);
|
||||
painter->drawLine(option.rect.left(), option.rect.bottom(), option.rect.right(), option.rect.bottom());
|
||||
|
||||
auto y = option.rect.y();
|
||||
painter->translate(visualRect(model()->index(0, 0)).x() - indentation() - .5, -.5);
|
||||
for (int i = 0; i < header()->count(); ++i) {
|
||||
painter->translate(header()->sectionSize(header()->logicalIndex(i)), 0);
|
||||
painter->drawLine(0, y, 0, y + option.rect.height());
|
||||
}
|
||||
painter->setPen(old_pen);
|
||||
painter->resetTransform();
|
||||
}
|
||||
|
||||
void MessageView::dataChanged(const QModelIndex &topLeft, const QModelIndex &bottomRight, const QVector<int> &roles) {
|
||||
// Bypass the slow call to QTreeView::dataChanged.
|
||||
// QTreeView::dataChanged will invalidate the height cache and that's what we don't need in MessageView.
|
||||
QAbstractItemView::dataChanged(topLeft, bottomRight, roles);
|
||||
}
|
||||
|
||||
void MessageView::updateBytesSectionSize() {
|
||||
auto delegate = ((MessageBytesDelegate *)itemDelegate());
|
||||
int max_bytes = 8;
|
||||
if (!delegate->multipleLines()) {
|
||||
for (const auto &[_, m] : can->lastMessages()) {
|
||||
max_bytes = std::max<int>(max_bytes, m.dat.size());
|
||||
}
|
||||
}
|
||||
header()->resizeSection(MessageListModel::Column::DATA, delegate->sizeForBytes(max_bytes).width());
|
||||
}
|
||||
|
||||
// MessageViewHeader
|
||||
|
||||
MessageViewHeader::MessageViewHeader(QWidget *parent) : QHeaderView(Qt::Horizontal, parent) {
|
||||
QObject::connect(this, &QHeaderView::sectionResized, this, &MessageViewHeader::updateHeaderPositions);
|
||||
QObject::connect(this, &QHeaderView::sectionMoved, this, &MessageViewHeader::updateHeaderPositions);
|
||||
}
|
||||
|
||||
void MessageViewHeader::updateFilters() {
|
||||
QMap<int, QString> filters;
|
||||
for (int i = 0; i < count(); i++) {
|
||||
if (editors[i] && !editors[i]->text().isEmpty()) {
|
||||
filters[i] = editors[i]->text();
|
||||
}
|
||||
}
|
||||
qobject_cast<MessageListModel*>(model())->setFilterStrings(filters);
|
||||
}
|
||||
|
||||
void MessageViewHeader::updateHeaderPositions() {
|
||||
QSize sz = QHeaderView::sizeHint();
|
||||
for (int i = 0; i < count(); i++) {
|
||||
if (editors[i]) {
|
||||
int h = editors[i]->sizeHint().height();
|
||||
editors[i]->setGeometry(sectionViewportPosition(i), sz.height(), sectionSize(i), h);
|
||||
editors[i]->setHidden(isSectionHidden(i));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void MessageViewHeader::updateGeometries() {
|
||||
for (int i = 0; i < count(); i++) {
|
||||
if (!editors[i]) {
|
||||
QString column_name = model()->headerData(i, Qt::Horizontal, Qt::DisplayRole).toString();
|
||||
editors[i] = new QLineEdit(this);
|
||||
editors[i]->setClearButtonEnabled(true);
|
||||
editors[i]->setPlaceholderText(tr("Filter %1").arg(column_name));
|
||||
|
||||
QObject::connect(editors[i], &QLineEdit::textChanged, this, &MessageViewHeader::updateFilters);
|
||||
}
|
||||
}
|
||||
setViewportMargins(0, 0, 0, editors[0] ? editors[0]->sizeHint().height() : 0);
|
||||
|
||||
QHeaderView::updateGeometries();
|
||||
updateHeaderPositions();
|
||||
}
|
||||
|
||||
QSize MessageViewHeader::sizeHint() const {
|
||||
QSize sz = QHeaderView::sizeHint();
|
||||
return editors[0] ? QSize(sz.width(), sz.height() + editors[0]->height() + 1) : sz;
|
||||
}
|
||||
116
tools/cabana/messageswidget.h
Normal file
116
tools/cabana/messageswidget.h
Normal file
@@ -0,0 +1,116 @@
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <optional>
|
||||
#include <set>
|
||||
#include <vector>
|
||||
|
||||
#include <QAbstractTableModel>
|
||||
#include <QHeaderView>
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
#include <QMenu>
|
||||
#include <QToolBar>
|
||||
#include <QTreeView>
|
||||
|
||||
#include "tools/cabana/dbc/dbcmanager.h"
|
||||
#include "tools/cabana/streams/abstractstream.h"
|
||||
|
||||
class MessageListModel : public QAbstractTableModel {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
enum Column {
|
||||
NAME = 0,
|
||||
SOURCE,
|
||||
ADDRESS,
|
||||
NODE,
|
||||
FREQ,
|
||||
COUNT,
|
||||
DATA,
|
||||
};
|
||||
|
||||
MessageListModel(QObject *parent) : QAbstractTableModel(parent) {}
|
||||
QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override;
|
||||
int columnCount(const QModelIndex &parent = QModelIndex()) const override { return Column::DATA + 1; }
|
||||
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const;
|
||||
int rowCount(const QModelIndex &parent = QModelIndex()) const override { return items_.size(); }
|
||||
void sort(int column, Qt::SortOrder order = Qt::AscendingOrder) override;
|
||||
void setFilterStrings(const QMap<int, QString> &filters);
|
||||
void msgsReceived(const std::set<MessageId> *new_msgs, bool has_new_ids);
|
||||
void filterAndSort();
|
||||
void dbcModified();
|
||||
|
||||
struct Item {
|
||||
MessageId id;
|
||||
QString name;
|
||||
QString node;
|
||||
bool operator==(const Item &other) const {
|
||||
return id == other.id && name == other.name && node == other.node;
|
||||
}
|
||||
};
|
||||
std::vector<Item> items_;
|
||||
|
||||
private:
|
||||
void sortItems(std::vector<MessageListModel::Item> &items);
|
||||
bool match(const MessageListModel::Item &id);
|
||||
|
||||
QMap<int, QString> filters_;
|
||||
std::set<MessageId> dbc_messages_;
|
||||
int sort_column = 0;
|
||||
Qt::SortOrder sort_order = Qt::AscendingOrder;
|
||||
};
|
||||
|
||||
class MessageView : public QTreeView {
|
||||
Q_OBJECT
|
||||
public:
|
||||
MessageView(QWidget *parent) : QTreeView(parent) {}
|
||||
void drawRow(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override;
|
||||
void drawBranches(QPainter *painter, const QRect &rect, const QModelIndex &index) const override {}
|
||||
void dataChanged(const QModelIndex &topLeft, const QModelIndex &bottomRight, const QVector<int> &roles = QVector<int>()) override;
|
||||
void updateBytesSectionSize();
|
||||
};
|
||||
|
||||
class MessageViewHeader : public QHeaderView {
|
||||
// https://stackoverflow.com/a/44346317
|
||||
Q_OBJECT
|
||||
public:
|
||||
MessageViewHeader(QWidget *parent);
|
||||
void updateHeaderPositions();
|
||||
void updateGeometries() override;
|
||||
QSize sizeHint() const override;
|
||||
void updateFilters();
|
||||
|
||||
QMap<int, QLineEdit *> editors;
|
||||
};
|
||||
|
||||
class MessagesWidget : public QWidget {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
MessagesWidget(QWidget *parent);
|
||||
void selectMessage(const MessageId &message_id);
|
||||
QByteArray saveHeaderState() const { return view->header()->saveState(); }
|
||||
bool restoreHeaderState(const QByteArray &state) const { return view->header()->restoreState(state); }
|
||||
void suppressHighlighted();
|
||||
|
||||
signals:
|
||||
void msgSelectionChanged(const MessageId &message_id);
|
||||
|
||||
protected:
|
||||
QToolBar *createToolBar();
|
||||
void headerContextMenuEvent(const QPoint &pos);
|
||||
void menuAboutToShow();
|
||||
void setMultiLineBytes(bool multi);
|
||||
void updateTitle();
|
||||
|
||||
MessageView *view;
|
||||
MessageViewHeader *header;
|
||||
MessageBytesDelegate *delegate;
|
||||
std::optional<MessageId> current_msg_id;
|
||||
MessageListModel *model;
|
||||
QPushButton *suppress_add;
|
||||
QPushButton *suppress_clear;
|
||||
QLabel *num_msg_label;
|
||||
QMenu *menu;
|
||||
};
|
||||
139
tools/cabana/settings.cc
Normal file
139
tools/cabana/settings.cc
Normal file
@@ -0,0 +1,139 @@
|
||||
#include "tools/cabana/settings.h"
|
||||
|
||||
#include <QAbstractButton>
|
||||
#include <QDialogButtonBox>
|
||||
#include <QDir>
|
||||
#include <QFileDialog>
|
||||
#include <QFormLayout>
|
||||
#include <QPushButton>
|
||||
#include <QSettings>
|
||||
#include <QStandardPaths>
|
||||
#include <type_traits>
|
||||
|
||||
#include "tools/cabana/util.h"
|
||||
|
||||
Settings settings;
|
||||
|
||||
template <class SettingOperation>
|
||||
void settings_op(SettingOperation op) {
|
||||
QSettings s("cabana");
|
||||
op(s, "absolute_time", settings.absolute_time);
|
||||
op(s, "fps", settings.fps);
|
||||
op(s, "max_cached_minutes", settings.max_cached_minutes);
|
||||
op(s, "chart_height", settings.chart_height);
|
||||
op(s, "chart_range", settings.chart_range);
|
||||
op(s, "chart_column_count", settings.chart_column_count);
|
||||
op(s, "last_dir", settings.last_dir);
|
||||
op(s, "last_route_dir", settings.last_route_dir);
|
||||
op(s, "window_state", settings.window_state);
|
||||
op(s, "geometry", settings.geometry);
|
||||
op(s, "video_splitter_state", settings.video_splitter_state);
|
||||
op(s, "recent_files", settings.recent_files);
|
||||
op(s, "message_header_state", settings.message_header_state);
|
||||
op(s, "chart_series_type", settings.chart_series_type);
|
||||
op(s, "theme", settings.theme);
|
||||
op(s, "sparkline_range", settings.sparkline_range);
|
||||
op(s, "multiple_lines_hex", settings.multiple_lines_hex);
|
||||
op(s, "log_livestream", settings.log_livestream);
|
||||
op(s, "log_path", settings.log_path);
|
||||
op(s, "drag_direction", (int &)settings.drag_direction);
|
||||
op(s, "suppress_defined_signals", settings.suppress_defined_signals);
|
||||
}
|
||||
|
||||
Settings::Settings() {
|
||||
last_dir = last_route_dir = QDir::homePath();
|
||||
log_path = QStandardPaths::writableLocation(QStandardPaths::HomeLocation) + "/cabana_live_stream/";
|
||||
settings_op([](QSettings &s, const QString &key, auto &value) {
|
||||
if (auto v = s.value(key); v.canConvert<std::decay_t<decltype(value)>>())
|
||||
value = v.value<std::decay_t<decltype(value)>>();
|
||||
});
|
||||
}
|
||||
|
||||
Settings::~Settings() {
|
||||
settings_op([](QSettings &s, const QString &key, auto &v) { s.setValue(key, v); });
|
||||
}
|
||||
|
||||
// SettingsDlg
|
||||
|
||||
SettingsDlg::SettingsDlg(QWidget *parent) : QDialog(parent) {
|
||||
setWindowTitle(tr("Settings"));
|
||||
QVBoxLayout *main_layout = new QVBoxLayout(this);
|
||||
QGroupBox *groupbox = new QGroupBox("General");
|
||||
QFormLayout *form_layout = new QFormLayout(groupbox);
|
||||
|
||||
form_layout->addRow(tr("Color Theme"), theme = new QComboBox(this));
|
||||
theme->setToolTip(tr("You may need to restart cabana after changes theme"));
|
||||
theme->addItems({tr("Automatic"), tr("Light"), tr("Dark")});
|
||||
theme->setCurrentIndex(settings.theme);
|
||||
|
||||
form_layout->addRow("FPS", fps = new QSpinBox(this));
|
||||
fps->setRange(10, 100);
|
||||
fps->setSingleStep(10);
|
||||
fps->setValue(settings.fps);
|
||||
|
||||
form_layout->addRow(tr("Max Cached Minutes"), cached_minutes = new QSpinBox(this));
|
||||
cached_minutes->setRange(5, 60);
|
||||
cached_minutes->setSingleStep(1);
|
||||
cached_minutes->setValue(settings.max_cached_minutes);
|
||||
main_layout->addWidget(groupbox);
|
||||
|
||||
groupbox = new QGroupBox("New Signal Settings");
|
||||
form_layout = new QFormLayout(groupbox);
|
||||
form_layout->addRow(tr("Drag Direction"), drag_direction = new QComboBox(this));
|
||||
drag_direction->addItems({tr("MSB First"), tr("LSB First"), tr("Always Little Endian"), tr("Always Big Endian")});
|
||||
drag_direction->setCurrentIndex(settings.drag_direction);
|
||||
main_layout->addWidget(groupbox);
|
||||
|
||||
groupbox = new QGroupBox("Chart");
|
||||
form_layout = new QFormLayout(groupbox);
|
||||
form_layout->addRow(tr("Default Series Type"), chart_series_type = new QComboBox(this));
|
||||
chart_series_type->addItems({tr("Line"), tr("Step Line"), tr("Scatter")});
|
||||
chart_series_type->setCurrentIndex(settings.chart_series_type);
|
||||
|
||||
form_layout->addRow(tr("Chart Height"), chart_height = new QSpinBox(this));
|
||||
chart_height->setRange(100, 500);
|
||||
chart_height->setSingleStep(10);
|
||||
chart_height->setValue(settings.chart_height);
|
||||
main_layout->addWidget(groupbox);
|
||||
|
||||
log_livestream = new QGroupBox(tr("Enable live stream logging"), this);
|
||||
log_livestream->setCheckable(true);
|
||||
QHBoxLayout *path_layout = new QHBoxLayout(log_livestream);
|
||||
path_layout->addWidget(log_path = new QLineEdit(settings.log_path, this));
|
||||
log_path->setReadOnly(true);
|
||||
auto browse_btn = new QPushButton(tr("B&rowse..."));
|
||||
path_layout->addWidget(browse_btn);
|
||||
main_layout->addWidget(log_livestream);
|
||||
|
||||
auto buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
|
||||
main_layout->addWidget(buttonBox);
|
||||
setFixedSize(400, sizeHint().height());
|
||||
|
||||
QObject::connect(browse_btn, &QPushButton::clicked, [this]() {
|
||||
QString fn = QFileDialog::getExistingDirectory(
|
||||
this, tr("Log File Location"),
|
||||
QStandardPaths::writableLocation(QStandardPaths::HomeLocation),
|
||||
QFileDialog::ShowDirsOnly | QFileDialog::DontResolveSymlinks);
|
||||
if (!fn.isEmpty()) {
|
||||
log_path->setText(fn);
|
||||
}
|
||||
});
|
||||
QObject::connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject);
|
||||
QObject::connect(buttonBox, &QDialogButtonBox::accepted, this, &SettingsDlg::save);
|
||||
}
|
||||
|
||||
void SettingsDlg::save() {
|
||||
if (std::exchange(settings.theme, theme->currentIndex()) != settings.theme) {
|
||||
// set theme before emit changed
|
||||
utils::setTheme(settings.theme);
|
||||
}
|
||||
settings.fps = fps->value();
|
||||
settings.max_cached_minutes = cached_minutes->value();
|
||||
settings.chart_series_type = chart_series_type->currentIndex();
|
||||
settings.chart_height = chart_height->value();
|
||||
settings.log_livestream = log_livestream->isChecked();
|
||||
settings.log_path = log_path->text();
|
||||
settings.drag_direction = (Settings::DragDirection)drag_direction->currentIndex();
|
||||
emit settings.changed();
|
||||
QDialog::accept();
|
||||
}
|
||||
67
tools/cabana/settings.h
Normal file
67
tools/cabana/settings.h
Normal file
@@ -0,0 +1,67 @@
|
||||
#pragma once
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QComboBox>
|
||||
#include <QDialog>
|
||||
#include <QGroupBox>
|
||||
#include <QLineEdit>
|
||||
#include <QSpinBox>
|
||||
|
||||
#define LIGHT_THEME 1
|
||||
#define DARK_THEME 2
|
||||
|
||||
class Settings : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
enum DragDirection {
|
||||
MsbFirst,
|
||||
LsbFirst,
|
||||
AlwaysLE,
|
||||
AlwaysBE,
|
||||
};
|
||||
|
||||
Settings();
|
||||
~Settings();
|
||||
|
||||
bool absolute_time = false;
|
||||
int fps = 10;
|
||||
int max_cached_minutes = 30;
|
||||
int chart_height = 200;
|
||||
int chart_column_count = 1;
|
||||
int chart_range = 3 * 60; // 3 minutes
|
||||
int chart_series_type = 0;
|
||||
int theme = 0;
|
||||
int sparkline_range = 15; // 15 seconds
|
||||
bool multiple_lines_hex = false;
|
||||
bool log_livestream = true;
|
||||
bool suppress_defined_signals = false;
|
||||
QString log_path;
|
||||
QString last_dir;
|
||||
QString last_route_dir;
|
||||
QByteArray geometry;
|
||||
QByteArray video_splitter_state;
|
||||
QByteArray window_state;
|
||||
QStringList recent_files;
|
||||
QByteArray message_header_state;
|
||||
DragDirection drag_direction = MsbFirst;
|
||||
|
||||
signals:
|
||||
void changed();
|
||||
};
|
||||
|
||||
class SettingsDlg : public QDialog {
|
||||
public:
|
||||
SettingsDlg(QWidget *parent);
|
||||
void save();
|
||||
QSpinBox *fps;
|
||||
QSpinBox *cached_minutes;
|
||||
QSpinBox *chart_height;
|
||||
QComboBox *chart_series_type;
|
||||
QComboBox *theme;
|
||||
QGroupBox *log_livestream;
|
||||
QLineEdit *log_path;
|
||||
QComboBox *drag_direction;
|
||||
};
|
||||
|
||||
extern Settings settings;
|
||||
727
tools/cabana/signalview.cc
Normal file
727
tools/cabana/signalview.cc
Normal file
@@ -0,0 +1,727 @@
|
||||
#include "tools/cabana/signalview.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include <QCompleter>
|
||||
#include <QDialogButtonBox>
|
||||
#include <QHBoxLayout>
|
||||
#include <QHeaderView>
|
||||
#include <QMessageBox>
|
||||
#include <QPainter>
|
||||
#include <QPainterPath>
|
||||
#include <QPushButton>
|
||||
#include <QScrollBar>
|
||||
#include <QtConcurrent>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#include "tools/cabana/commands.h"
|
||||
|
||||
// SignalModel
|
||||
|
||||
static QString signalTypeToString(cabana::Signal::Type type) {
|
||||
if (type == cabana::Signal::Type::Multiplexor) return "Multiplexor Signal";
|
||||
else if (type == cabana::Signal::Type::Multiplexed) return "Multiplexed Signal";
|
||||
else return "Normal Signal";
|
||||
}
|
||||
|
||||
SignalModel::SignalModel(QObject *parent) : root(new Item), QAbstractItemModel(parent) {
|
||||
QObject::connect(dbc(), &DBCManager::DBCFileChanged, this, &SignalModel::refresh);
|
||||
QObject::connect(dbc(), &DBCManager::msgUpdated, this, &SignalModel::handleMsgChanged);
|
||||
QObject::connect(dbc(), &DBCManager::msgRemoved, this, &SignalModel::handleMsgChanged);
|
||||
QObject::connect(dbc(), &DBCManager::signalAdded, this, &SignalModel::handleSignalAdded);
|
||||
QObject::connect(dbc(), &DBCManager::signalUpdated, this, &SignalModel::handleSignalUpdated);
|
||||
QObject::connect(dbc(), &DBCManager::signalRemoved, this, &SignalModel::handleSignalRemoved);
|
||||
}
|
||||
|
||||
void SignalModel::insertItem(SignalModel::Item *parent_item, int pos, const cabana::Signal *sig) {
|
||||
Item *item = new Item{.sig = sig, .parent = parent_item, .title = sig->name, .type = Item::Sig};
|
||||
parent_item->children.insert(pos, item);
|
||||
QString titles[]{"Name", "Size", "Receiver Nodes", "Little Endian", "Signed", "Offset", "Factor", "Type",
|
||||
"Multiplex Value", "Extra Info", "Unit", "Comment", "Minimum Value", "Maximum Value", "Value Table"};
|
||||
for (int i = 0; i < std::size(titles); ++i) {
|
||||
item->children.push_back(new Item{.sig = sig, .parent = item, .title = titles[i], .type = (Item::Type)(i + Item::Name)});
|
||||
}
|
||||
}
|
||||
|
||||
void SignalModel::setMessage(const MessageId &id) {
|
||||
msg_id = id;
|
||||
filter_str = "";
|
||||
refresh();
|
||||
}
|
||||
|
||||
void SignalModel::setFilter(const QString &txt) {
|
||||
filter_str = txt;
|
||||
refresh();
|
||||
}
|
||||
|
||||
void SignalModel::refresh() {
|
||||
beginResetModel();
|
||||
root.reset(new SignalModel::Item);
|
||||
if (auto msg = dbc()->msg(msg_id)) {
|
||||
for (auto s : msg->getSignals()) {
|
||||
if (filter_str.isEmpty() || s->name.contains(filter_str, Qt::CaseInsensitive)) {
|
||||
insertItem(root.get(), root->children.size(), s);
|
||||
}
|
||||
}
|
||||
}
|
||||
endResetModel();
|
||||
}
|
||||
|
||||
SignalModel::Item *SignalModel::getItem(const QModelIndex &index) const {
|
||||
auto item = index.isValid() ? (SignalModel::Item *)index.internalPointer() : nullptr;
|
||||
return item ? item : root.get();
|
||||
}
|
||||
|
||||
int SignalModel::rowCount(const QModelIndex &parent) const {
|
||||
if (parent.isValid() && parent.column() > 0) return 0;
|
||||
|
||||
auto parent_item = getItem(parent);
|
||||
int row_count = parent_item->children.size();
|
||||
if (parent_item->type == Item::Sig && !parent_item->extra_expanded) {
|
||||
row_count -= (Item::Desc - Item::ExtraInfo);
|
||||
}
|
||||
return row_count;
|
||||
}
|
||||
|
||||
Qt::ItemFlags SignalModel::flags(const QModelIndex &index) const {
|
||||
if (!index.isValid()) return Qt::NoItemFlags;
|
||||
|
||||
auto item = getItem(index);
|
||||
Qt::ItemFlags flags = Qt::ItemIsSelectable | Qt::ItemIsEnabled;
|
||||
if (index.column() == 1 && item->type != Item::Sig && item->type != Item::ExtraInfo) {
|
||||
flags |= (item->type == Item::Endian || item->type == Item::Signed) ? Qt::ItemIsUserCheckable : Qt::ItemIsEditable;
|
||||
}
|
||||
if (item->type == Item::MultiplexValue && item->sig->type != cabana::Signal::Type::Multiplexed) {
|
||||
flags &= ~Qt::ItemIsEnabled;
|
||||
}
|
||||
return flags;
|
||||
}
|
||||
|
||||
int SignalModel::signalRow(const cabana::Signal *sig) const {
|
||||
for (int i = 0; i < root->children.size(); ++i) {
|
||||
if (root->children[i]->sig == sig) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
QModelIndex SignalModel::index(int row, int column, const QModelIndex &parent) const {
|
||||
if (parent.isValid() && parent.column() != 0) return {};
|
||||
|
||||
auto parent_item = getItem(parent);
|
||||
if (parent_item && row < parent_item->children.size()) {
|
||||
return createIndex(row, column, parent_item->children[row]);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
QModelIndex SignalModel::parent(const QModelIndex &index) const {
|
||||
if (!index.isValid()) return {};
|
||||
Item *parent_item = getItem(index)->parent;
|
||||
return !parent_item || parent_item == root.get() ? QModelIndex() : createIndex(parent_item->row(), 0, parent_item);
|
||||
}
|
||||
|
||||
QVariant SignalModel::data(const QModelIndex &index, int role) const {
|
||||
if (index.isValid()) {
|
||||
const Item *item = getItem(index);
|
||||
if (role == Qt::DisplayRole || role == Qt::EditRole) {
|
||||
if (index.column() == 0) {
|
||||
return item->type == Item::Sig ? item->sig->name : item->title;
|
||||
} else {
|
||||
switch (item->type) {
|
||||
case Item::Sig: return item->sig_val;
|
||||
case Item::Name: return item->sig->name;
|
||||
case Item::Size: return item->sig->size;
|
||||
case Item::Node: return item->sig->receiver_name;
|
||||
case Item::SignalType: return signalTypeToString(item->sig->type);
|
||||
case Item::MultiplexValue: return item->sig->multiplex_value;
|
||||
case Item::Offset: return doubleToString(item->sig->offset);
|
||||
case Item::Factor: return doubleToString(item->sig->factor);
|
||||
case Item::Unit: return item->sig->unit;
|
||||
case Item::Comment: return item->sig->comment;
|
||||
case Item::Min: return doubleToString(item->sig->min);
|
||||
case Item::Max: return doubleToString(item->sig->max);
|
||||
case Item::Desc: {
|
||||
QStringList val_desc;
|
||||
for (auto &[val, desc] : item->sig->val_desc) {
|
||||
val_desc << QString("%1 \"%2\"").arg(val).arg(desc);
|
||||
}
|
||||
return val_desc.join(" ");
|
||||
}
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
} else if (role == Qt::CheckStateRole && index.column() == 1) {
|
||||
if (item->type == Item::Endian) return item->sig->is_little_endian ? Qt::Checked : Qt::Unchecked;
|
||||
if (item->type == Item::Signed) return item->sig->is_signed ? Qt::Checked : Qt::Unchecked;
|
||||
} else if (role == Qt::DecorationRole && index.column() == 0 && item->type == Item::ExtraInfo) {
|
||||
return utils::icon(item->parent->extra_expanded ? "chevron-compact-down" : "chevron-compact-up");
|
||||
} else if (role == Qt::ToolTipRole && item->type == Item::Sig) {
|
||||
return (index.column() == 0) ? signalToolTip(item->sig) : QString();
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
bool SignalModel::setData(const QModelIndex &index, const QVariant &value, int role) {
|
||||
if (role != Qt::EditRole && role != Qt::CheckStateRole) return false;
|
||||
|
||||
Item *item = getItem(index);
|
||||
cabana::Signal s = *item->sig;
|
||||
switch (item->type) {
|
||||
case Item::Name: s.name = value.toString(); break;
|
||||
case Item::Size: s.size = value.toInt(); break;
|
||||
case Item::Node: s.receiver_name = value.toString().trimmed(); break;
|
||||
case Item::SignalType: s.type = (cabana::Signal::Type)value.toInt(); break;
|
||||
case Item::MultiplexValue: s.multiplex_value = value.toInt(); break;
|
||||
case Item::Endian: s.is_little_endian = value.toBool(); break;
|
||||
case Item::Signed: s.is_signed = value.toBool(); break;
|
||||
case Item::Offset: s.offset = value.toDouble(); break;
|
||||
case Item::Factor: s.factor = value.toDouble(); break;
|
||||
case Item::Unit: s.unit = value.toString(); break;
|
||||
case Item::Comment: s.comment = value.toString(); break;
|
||||
case Item::Min: s.min = value.toDouble(); break;
|
||||
case Item::Max: s.max = value.toDouble(); break;
|
||||
case Item::Desc: s.val_desc = value.value<ValueDescription>(); break;
|
||||
default: return false;
|
||||
}
|
||||
bool ret = saveSignal(item->sig, s);
|
||||
emit dataChanged(index, index, {Qt::DisplayRole, Qt::EditRole, Qt::CheckStateRole});
|
||||
return ret;
|
||||
}
|
||||
|
||||
void SignalModel::showExtraInfo(const QModelIndex &index) {
|
||||
auto item = getItem(index);
|
||||
if (item->type == Item::ExtraInfo) {
|
||||
if (!item->parent->extra_expanded) {
|
||||
item->parent->extra_expanded = true;
|
||||
beginInsertRows(index.parent(), Item::ExtraInfo - 2, Item::Desc - 2);
|
||||
endInsertRows();
|
||||
} else {
|
||||
item->parent->extra_expanded = false;
|
||||
beginRemoveRows(index.parent(), Item::ExtraInfo - 2, Item::Desc - 2);
|
||||
endRemoveRows();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool SignalModel::saveSignal(const cabana::Signal *origin_s, cabana::Signal &s) {
|
||||
auto msg = dbc()->msg(msg_id);
|
||||
if (s.name != origin_s->name && msg->sig(s.name) != nullptr) {
|
||||
QString text = tr("There is already a signal with the same name '%1'").arg(s.name);
|
||||
QMessageBox::warning(nullptr, tr("Failed to save signal"), text);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (s.is_little_endian != origin_s->is_little_endian) {
|
||||
s.start_bit = flipBitPos(s.start_bit);
|
||||
}
|
||||
UndoStack::push(new EditSignalCommand(msg_id, origin_s, s));
|
||||
return true;
|
||||
}
|
||||
|
||||
void SignalModel::handleMsgChanged(MessageId id) {
|
||||
if (id.address == msg_id.address) {
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
|
||||
void SignalModel::handleSignalAdded(MessageId id, const cabana::Signal *sig) {
|
||||
if (id == msg_id) {
|
||||
if (filter_str.isEmpty()) {
|
||||
int i = dbc()->msg(msg_id)->indexOf(sig);
|
||||
beginInsertRows({}, i, i);
|
||||
insertItem(root.get(), i, sig);
|
||||
endInsertRows();
|
||||
} else if (sig->name.contains(filter_str, Qt::CaseInsensitive)) {
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void SignalModel::handleSignalUpdated(const cabana::Signal *sig) {
|
||||
if (int row = signalRow(sig); row != -1) {
|
||||
emit dataChanged(index(row, 0), index(row, 1), {Qt::DisplayRole, Qt::EditRole, Qt::CheckStateRole});
|
||||
|
||||
if (filter_str.isEmpty()) {
|
||||
// move row when the order changes.
|
||||
int to = dbc()->msg(msg_id)->indexOf(sig);
|
||||
if (to != row) {
|
||||
beginMoveRows({}, row, row, {}, to > row ? to + 1 : to);
|
||||
root->children.move(row, to);
|
||||
endMoveRows();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void SignalModel::handleSignalRemoved(const cabana::Signal *sig) {
|
||||
if (int row = signalRow(sig); row != -1) {
|
||||
beginRemoveRows({}, row, row);
|
||||
delete root->children.takeAt(row);
|
||||
endRemoveRows();
|
||||
}
|
||||
}
|
||||
|
||||
// SignalItemDelegate
|
||||
|
||||
SignalItemDelegate::SignalItemDelegate(QObject *parent) : QStyledItemDelegate(parent) {
|
||||
name_validator = new NameValidator(this);
|
||||
node_validator = new QRegExpValidator(QRegExp("^\\w+(,\\w+)*$"), this);
|
||||
double_validator = new DoubleValidator(this);
|
||||
|
||||
label_font.setPointSize(8);
|
||||
minmax_font.setPixelSize(10);
|
||||
}
|
||||
|
||||
QSize SignalItemDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const {
|
||||
int width = option.widget->size().width() / 2;
|
||||
if (index.column() == 0) {
|
||||
int spacing = option.widget->style()->pixelMetric(QStyle::PM_TreeViewIndentation) + color_label_width + 8;
|
||||
auto text = index.data(Qt::DisplayRole).toString();
|
||||
auto item = (SignalModel::Item *)index.internalPointer();
|
||||
if (item->type == SignalModel::Item::Sig && item->sig->type != cabana::Signal::Type::Normal) {
|
||||
text += item->sig->type == cabana::Signal::Type::Multiplexor ? QString(" M ") : QString(" m%1 ").arg(item->sig->multiplex_value);
|
||||
spacing += (option.widget->style()->pixelMetric(QStyle::PM_FocusFrameHMargin) + 1) * 2;
|
||||
}
|
||||
auto it = width_cache.find(text);
|
||||
if (it == width_cache.end()) {
|
||||
it = width_cache.insert(text, option.fontMetrics.width(text));
|
||||
}
|
||||
width = std::min<int>(option.widget->size().width() / 3.0, it.value() + spacing);
|
||||
}
|
||||
return {width, option.fontMetrics.height()};
|
||||
}
|
||||
|
||||
void SignalItemDelegate::updateEditorGeometry(QWidget *editor, const QStyleOptionViewItem &option, const QModelIndex &index) const {
|
||||
auto item = (SignalModel::Item *)index.internalPointer();
|
||||
if (editor && item->type == SignalModel::Item::Sig && index.column() == 1) {
|
||||
QRect geom = option.rect;
|
||||
geom.setLeft(geom.right() - editor->sizeHint().width());
|
||||
editor->setGeometry(geom);
|
||||
button_size = geom.size();
|
||||
return;
|
||||
}
|
||||
QStyledItemDelegate::updateEditorGeometry(editor, option, index);
|
||||
}
|
||||
|
||||
void SignalItemDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const {
|
||||
auto item = (SignalModel::Item *)index.internalPointer();
|
||||
if (item && item->type == SignalModel::Item::Sig) {
|
||||
painter->setRenderHint(QPainter::Antialiasing);
|
||||
if (option.state & QStyle::State_Selected) {
|
||||
painter->fillRect(option.rect, option.palette.brush(QPalette::Normal, QPalette::Highlight));
|
||||
}
|
||||
|
||||
int h_margin = option.widget->style()->pixelMetric(QStyle::PM_FocusFrameHMargin) + 1;
|
||||
int v_margin = option.widget->style()->pixelMetric(QStyle::PM_FocusFrameVMargin);
|
||||
QRect r = option.rect.adjusted(h_margin, v_margin, -h_margin, -v_margin);
|
||||
if (index.column() == 0) {
|
||||
// color label
|
||||
QPainterPath path;
|
||||
QRect icon_rect{r.x(), r.y(), color_label_width, r.height()};
|
||||
path.addRoundedRect(icon_rect, 3, 3);
|
||||
painter->setPen(item->highlight ? Qt::white : Qt::black);
|
||||
painter->setFont(label_font);
|
||||
painter->fillPath(path, item->sig->color.darker(item->highlight ? 125 : 0));
|
||||
painter->drawText(icon_rect, Qt::AlignCenter, QString::number(item->row() + 1));
|
||||
|
||||
r.setLeft(icon_rect.right() + h_margin * 2);
|
||||
// multiplexer indicator
|
||||
if (item->sig->type != cabana::Signal::Type::Normal) {
|
||||
QString indicator = item->sig->type == cabana::Signal::Type::Multiplexor ? QString(" M ") : QString(" m%1 ").arg(item->sig->multiplex_value);
|
||||
QRect indicator_rect{r.x(), r.y(), option.fontMetrics.width(indicator), r.height()};
|
||||
painter->setBrush(Qt::gray);
|
||||
painter->setPen(Qt::NoPen);
|
||||
painter->drawRoundedRect(indicator_rect, 3, 3);
|
||||
painter->setPen(Qt::white);
|
||||
painter->drawText(indicator_rect, Qt::AlignCenter, indicator);
|
||||
r.setLeft(indicator_rect.right() + h_margin * 2);
|
||||
}
|
||||
|
||||
// name
|
||||
auto text = option.fontMetrics.elidedText(index.data(Qt::DisplayRole).toString(), Qt::ElideRight, r.width());
|
||||
painter->setPen(option.palette.color(option.state & QStyle::State_Selected ? QPalette::HighlightedText : QPalette::Text));
|
||||
painter->setFont(option.font);
|
||||
painter->drawText(r, option.displayAlignment, text);
|
||||
} else if (index.column() == 1 && !item->sparkline.pixmap.isNull()) {
|
||||
// sparkline
|
||||
QSize sparkline_size = item->sparkline.pixmap.size() / item->sparkline.pixmap.devicePixelRatio();
|
||||
painter->drawPixmap(QRect(r.topLeft(), sparkline_size), item->sparkline.pixmap);
|
||||
// min-max value
|
||||
painter->setPen(option.palette.color(option.state & QStyle::State_Selected ? QPalette::HighlightedText : QPalette::Text));
|
||||
QRect rect = r.adjusted(sparkline_size.width() + 1, 0, 0, 0);
|
||||
int value_adjust = 10;
|
||||
if (!item->sparkline.isEmpty() && (item->highlight || option.state & QStyle::State_Selected)) {
|
||||
painter->drawLine(rect.topLeft(), rect.bottomLeft());
|
||||
rect.adjust(5, -v_margin, 0, v_margin);
|
||||
painter->setFont(minmax_font);
|
||||
QString min = QString::number(item->sparkline.min_val);
|
||||
QString max = QString::number(item->sparkline.max_val);
|
||||
painter->drawText(rect, Qt::AlignLeft | Qt::AlignTop, max);
|
||||
painter->drawText(rect, Qt::AlignLeft | Qt::AlignBottom, min);
|
||||
QFontMetrics fm(minmax_font);
|
||||
value_adjust = std::max(fm.width(min), fm.width(max)) + 5;
|
||||
} else if (!item->sparkline.isEmpty() && item->sig->type == cabana::Signal::Type::Multiplexed) {
|
||||
// display freq of multiplexed signal
|
||||
painter->setFont(label_font);
|
||||
QString freq = QString("%1 hz").arg(item->sparkline.freq(), 0, 'g', 2);
|
||||
painter->drawText(rect.adjusted(5, 0, 0, 0), Qt::AlignLeft | Qt::AlignVCenter, freq);
|
||||
value_adjust = QFontMetrics(label_font).width(freq) + 10;
|
||||
}
|
||||
// signal value
|
||||
painter->setFont(option.font);
|
||||
rect.adjust(value_adjust, 0, -button_size.width(), 0);
|
||||
auto text = option.fontMetrics.elidedText(index.data(Qt::DisplayRole).toString(), Qt::ElideRight, rect.width());
|
||||
painter->drawText(rect, Qt::AlignRight | Qt::AlignVCenter, text);
|
||||
}
|
||||
} else {
|
||||
QStyledItemDelegate::paint(painter, option, index);
|
||||
}
|
||||
}
|
||||
|
||||
QWidget *SignalItemDelegate::createEditor(QWidget *parent, const QStyleOptionViewItem &option, const QModelIndex &index) const {
|
||||
auto item = (SignalModel::Item *)index.internalPointer();
|
||||
if (item->type == SignalModel::Item::Name || item->type == SignalModel::Item::Node || item->type == SignalModel::Item::Offset ||
|
||||
item->type == SignalModel::Item::Factor || item->type == SignalModel::Item::MultiplexValue ||
|
||||
item->type == SignalModel::Item::Min || item->type == SignalModel::Item::Max) {
|
||||
QLineEdit *e = new QLineEdit(parent);
|
||||
e->setFrame(false);
|
||||
if (item->type == SignalModel::Item::Name) e->setValidator(name_validator);
|
||||
else if (item->type == SignalModel::Item::Node) e->setValidator(node_validator);
|
||||
else e->setValidator(double_validator);
|
||||
|
||||
if (item->type == SignalModel::Item::Name) {
|
||||
QCompleter *completer = new QCompleter(dbc()->signalNames(), e);
|
||||
completer->setCaseSensitivity(Qt::CaseInsensitive);
|
||||
completer->setFilterMode(Qt::MatchContains);
|
||||
e->setCompleter(completer);
|
||||
}
|
||||
return e;
|
||||
} else if (item->type == SignalModel::Item::Size) {
|
||||
QSpinBox *spin = new QSpinBox(parent);
|
||||
spin->setFrame(false);
|
||||
spin->setRange(1, 64);
|
||||
return spin;
|
||||
} else if (item->type == SignalModel::Item::SignalType) {
|
||||
QComboBox *c = new QComboBox(parent);
|
||||
c->addItem(signalTypeToString(cabana::Signal::Type::Normal), (int)cabana::Signal::Type::Normal);
|
||||
if (!dbc()->msg(((SignalModel *)index.model())->msg_id)->multiplexor) {
|
||||
c->addItem(signalTypeToString(cabana::Signal::Type::Multiplexor), (int)cabana::Signal::Type::Multiplexor);
|
||||
} else if (item->sig->type != cabana::Signal::Type::Multiplexor) {
|
||||
c->addItem(signalTypeToString(cabana::Signal::Type::Multiplexed), (int)cabana::Signal::Type::Multiplexed);
|
||||
}
|
||||
return c;
|
||||
} else if (item->type == SignalModel::Item::Desc) {
|
||||
ValueDescriptionDlg dlg(item->sig->val_desc, parent);
|
||||
dlg.setWindowTitle(item->sig->name);
|
||||
if (dlg.exec()) {
|
||||
((QAbstractItemModel *)index.model())->setData(index, QVariant::fromValue(dlg.val_desc));
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
return QStyledItemDelegate::createEditor(parent, option, index);
|
||||
}
|
||||
|
||||
void SignalItemDelegate::setModelData(QWidget *editor, QAbstractItemModel *model, const QModelIndex &index) const {
|
||||
auto item = (SignalModel::Item *)index.internalPointer();
|
||||
if (item->type == SignalModel::Item::SignalType) {
|
||||
model->setData(index, ((QComboBox*)editor)->currentData().toInt());
|
||||
return;
|
||||
}
|
||||
QStyledItemDelegate::setModelData(editor, model, index);
|
||||
}
|
||||
|
||||
// SignalView
|
||||
|
||||
SignalView::SignalView(ChartsWidget *charts, QWidget *parent) : charts(charts), QFrame(parent) {
|
||||
setFrameStyle(QFrame::StyledPanel | QFrame::Plain);
|
||||
// title bar
|
||||
QWidget *title_bar = new QWidget(this);
|
||||
QHBoxLayout *hl = new QHBoxLayout(title_bar);
|
||||
hl->addWidget(signal_count_lb = new QLabel());
|
||||
filter_edit = new QLineEdit(this);
|
||||
QRegularExpression re("\\S+");
|
||||
filter_edit->setValidator(new QRegularExpressionValidator(re, this));
|
||||
filter_edit->setClearButtonEnabled(true);
|
||||
filter_edit->setPlaceholderText(tr("Filter Signal"));
|
||||
hl->addWidget(filter_edit);
|
||||
hl->addStretch(1);
|
||||
|
||||
// WARNING: increasing the maximum range can result in severe performance degradation.
|
||||
// 30s is a reasonable value at present.
|
||||
const int max_range = 30; // 30s
|
||||
settings.sparkline_range = std::clamp(settings.sparkline_range, 1, max_range);
|
||||
hl->addWidget(sparkline_label = new QLabel());
|
||||
hl->addWidget(sparkline_range_slider = new QSlider(Qt::Horizontal, this));
|
||||
sparkline_range_slider->setRange(1, max_range);
|
||||
sparkline_range_slider->setValue(settings.sparkline_range);
|
||||
sparkline_range_slider->setToolTip(tr("Sparkline time range"));
|
||||
|
||||
auto collapse_btn = new ToolButton("dash-square", tr("Collapse All"));
|
||||
collapse_btn->setIconSize({12, 12});
|
||||
hl->addWidget(collapse_btn);
|
||||
|
||||
// tree view
|
||||
tree = new TreeView(this);
|
||||
tree->setModel(model = new SignalModel(this));
|
||||
tree->setItemDelegate(delegate = new SignalItemDelegate(this));
|
||||
tree->setFrameShape(QFrame::NoFrame);
|
||||
tree->setHeaderHidden(true);
|
||||
tree->setMouseTracking(true);
|
||||
tree->setExpandsOnDoubleClick(false);
|
||||
tree->setEditTriggers(QAbstractItemView::AllEditTriggers);
|
||||
tree->header()->setSectionResizeMode(0, QHeaderView::ResizeToContents);
|
||||
tree->header()->setStretchLastSection(true);
|
||||
tree->setMinimumHeight(300);
|
||||
|
||||
// Use a distinctive background for the whole row containing a QSpinBox or QLineEdit
|
||||
QString nodeBgColor = palette().color(QPalette::AlternateBase).name(QColor::HexArgb);
|
||||
tree->setStyleSheet(QString("QSpinBox{background-color:%1;border:none;} QLineEdit{background-color:%1;}").arg(nodeBgColor));
|
||||
|
||||
QVBoxLayout *main_layout = new QVBoxLayout(this);
|
||||
main_layout->setContentsMargins(0, 0, 0, 0);
|
||||
main_layout->setSpacing(0);
|
||||
main_layout->addWidget(title_bar);
|
||||
main_layout->addWidget(tree);
|
||||
updateToolBar();
|
||||
|
||||
QObject::connect(filter_edit, &QLineEdit::textEdited, model, &SignalModel::setFilter);
|
||||
QObject::connect(sparkline_range_slider, &QSlider::valueChanged, this, &SignalView::setSparklineRange);
|
||||
QObject::connect(collapse_btn, &QPushButton::clicked, tree, &QTreeView::collapseAll);
|
||||
QObject::connect(tree, &QAbstractItemView::clicked, this, &SignalView::rowClicked);
|
||||
QObject::connect(tree, &QTreeView::viewportEntered, [this]() { emit highlight(nullptr); });
|
||||
QObject::connect(tree, &QTreeView::entered, [this](const QModelIndex &index) { emit highlight(model->getItem(index)->sig); });
|
||||
QObject::connect(model, &QAbstractItemModel::modelReset, this, &SignalView::rowsChanged);
|
||||
QObject::connect(model, &QAbstractItemModel::rowsRemoved, this, &SignalView::rowsChanged);
|
||||
QObject::connect(dbc(), &DBCManager::signalAdded, this, &SignalView::handleSignalAdded);
|
||||
QObject::connect(dbc(), &DBCManager::signalUpdated, this, &SignalView::handleSignalUpdated);
|
||||
QObject::connect(tree->verticalScrollBar(), &QScrollBar::valueChanged, [this]() { updateState(); });
|
||||
QObject::connect(tree->verticalScrollBar(), &QScrollBar::rangeChanged, [this]() { updateState(); });
|
||||
QObject::connect(can, &AbstractStream::msgsReceived, this, &SignalView::updateState);
|
||||
QObject::connect(tree->header(), &QHeaderView::sectionResized, [this](int logicalIndex, int oldSize, int newSize) {
|
||||
if (logicalIndex == 1) {
|
||||
value_column_width = newSize;
|
||||
updateState();
|
||||
}
|
||||
});
|
||||
|
||||
setWhatsThis(tr(R"(
|
||||
<b>Signal view</b><br />
|
||||
<!-- TODO: add descprition here -->
|
||||
)"));
|
||||
}
|
||||
|
||||
void SignalView::setMessage(const MessageId &id) {
|
||||
max_value_width = 0;
|
||||
filter_edit->clear();
|
||||
model->setMessage(id);
|
||||
}
|
||||
|
||||
void SignalView::rowsChanged() {
|
||||
for (int i = 0; i < model->rowCount(); ++i) {
|
||||
auto index = model->index(i, 1);
|
||||
if (!tree->indexWidget(index)) {
|
||||
QWidget *w = new QWidget(this);
|
||||
QHBoxLayout *h = new QHBoxLayout(w);
|
||||
int v_margin = style()->pixelMetric(QStyle::PM_FocusFrameVMargin);
|
||||
int h_margin = style()->pixelMetric(QStyle::PM_FocusFrameHMargin);
|
||||
h->setContentsMargins(0, v_margin, -h_margin, v_margin);
|
||||
h->setSpacing(style()->pixelMetric(QStyle::PM_ToolBarItemSpacing));
|
||||
|
||||
auto remove_btn = new ToolButton("x", tr("Remove signal"));
|
||||
auto plot_btn = new ToolButton("graph-up", "");
|
||||
plot_btn->setCheckable(true);
|
||||
h->addWidget(plot_btn);
|
||||
h->addWidget(remove_btn);
|
||||
|
||||
tree->setIndexWidget(index, w);
|
||||
auto sig = model->getItem(index)->sig;
|
||||
QObject::connect(remove_btn, &QToolButton::clicked, [=]() { UndoStack::push(new RemoveSigCommand(model->msg_id, sig)); });
|
||||
QObject::connect(plot_btn, &QToolButton::clicked, [=](bool checked) {
|
||||
emit showChart(model->msg_id, sig, checked, QGuiApplication::keyboardModifiers() & Qt::ShiftModifier);
|
||||
});
|
||||
}
|
||||
}
|
||||
updateToolBar();
|
||||
updateChartState();
|
||||
updateState();
|
||||
}
|
||||
|
||||
void SignalView::rowClicked(const QModelIndex &index) {
|
||||
auto item = model->getItem(index);
|
||||
if (item->type == SignalModel::Item::Sig) {
|
||||
auto sig_index = model->index(index.row(), 0, index.parent());
|
||||
tree->setExpanded(sig_index, !tree->isExpanded(sig_index));
|
||||
} else if (item->type == SignalModel::Item::ExtraInfo) {
|
||||
model->showExtraInfo(index);
|
||||
}
|
||||
}
|
||||
|
||||
void SignalView::selectSignal(const cabana::Signal *sig, bool expand) {
|
||||
if (int row = model->signalRow(sig); row != -1) {
|
||||
auto idx = model->index(row, 0);
|
||||
if (expand) {
|
||||
tree->setExpanded(idx, !tree->isExpanded(idx));
|
||||
}
|
||||
tree->scrollTo(idx, QAbstractItemView::PositionAtTop);
|
||||
tree->setCurrentIndex(idx);
|
||||
}
|
||||
}
|
||||
|
||||
void SignalView::updateChartState() {
|
||||
int i = 0;
|
||||
for (auto item : model->root->children) {
|
||||
bool chart_opened = charts->hasSignal(model->msg_id, item->sig);
|
||||
auto buttons = tree->indexWidget(model->index(i, 1))->findChildren<QToolButton *>();
|
||||
if (buttons.size() > 0) {
|
||||
buttons[0]->setChecked(chart_opened);
|
||||
buttons[0]->setToolTip(chart_opened ? tr("Close Plot") : tr("Show Plot\nSHIFT click to add to previous opened plot"));
|
||||
}
|
||||
++i;
|
||||
}
|
||||
}
|
||||
|
||||
void SignalView::signalHovered(const cabana::Signal *sig) {
|
||||
auto &children = model->root->children;
|
||||
for (int i = 0; i < children.size(); ++i) {
|
||||
bool highlight = children[i]->sig == sig;
|
||||
if (std::exchange(children[i]->highlight, highlight) != highlight) {
|
||||
emit model->dataChanged(model->index(i, 0), model->index(i, 0), {Qt::DecorationRole});
|
||||
emit model->dataChanged(model->index(i, 1), model->index(i, 1), {Qt::DisplayRole});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void SignalView::updateToolBar() {
|
||||
signal_count_lb->setText(tr("Signals: %1").arg(model->rowCount()));
|
||||
sparkline_label->setText(utils::formatSeconds(settings.sparkline_range));
|
||||
}
|
||||
|
||||
void SignalView::setSparklineRange(int value) {
|
||||
settings.sparkline_range = value;
|
||||
updateToolBar();
|
||||
updateState();
|
||||
}
|
||||
|
||||
void SignalView::handleSignalAdded(MessageId id, const cabana::Signal *sig) {
|
||||
if (id.address == model->msg_id.address) {
|
||||
selectSignal(sig);
|
||||
}
|
||||
}
|
||||
|
||||
void SignalView::handleSignalUpdated(const cabana::Signal *sig) {
|
||||
if (int row = model->signalRow(sig); row != -1)
|
||||
updateState();
|
||||
}
|
||||
|
||||
void SignalView::updateState(const std::set<MessageId> *msgs) {
|
||||
const auto &last_msg = can->lastMessage(model->msg_id);
|
||||
if (model->rowCount() == 0 || (msgs && !msgs->count(model->msg_id)) || last_msg.dat.size() == 0) return;
|
||||
|
||||
for (auto item : model->root->children) {
|
||||
double value = 0;
|
||||
if (item->sig->getValue(last_msg.dat.data(), last_msg.dat.size(), &value)) {
|
||||
item->sig_val = item->sig->formatValue(value);
|
||||
}
|
||||
max_value_width = std::max(max_value_width, fontMetrics().width(item->sig_val));
|
||||
}
|
||||
|
||||
QModelIndex top = tree->indexAt(QPoint(0, 0));
|
||||
if (top.isValid()) {
|
||||
// update visible sparkline
|
||||
int first_visible_row = top.parent().isValid() ? top.parent().row() + 1 : top.row();
|
||||
int last_visible_row = model->rowCount() - 1;
|
||||
QModelIndex bottom = tree->indexAt(tree->viewport()->rect().bottomLeft());
|
||||
if (bottom.isValid()) {
|
||||
last_visible_row = bottom.parent().isValid() ? bottom.parent().row() : bottom.row();
|
||||
}
|
||||
|
||||
const static int min_max_width = QFontMetrics(delegate->minmax_font).width("-000.00") + 5;
|
||||
int available_width = value_column_width - delegate->button_size.width();
|
||||
int value_width = std::min<int>(max_value_width + min_max_width, available_width / 2);
|
||||
QSize size(available_width - value_width,
|
||||
delegate->button_size.height() - style()->pixelMetric(QStyle::PM_FocusFrameVMargin) * 2);
|
||||
QFutureSynchronizer<void> synchronizer;
|
||||
for (int i = first_visible_row; i <= last_visible_row; ++i) {
|
||||
auto item = model->getItem(model->index(i, 1));
|
||||
synchronizer.addFuture(QtConcurrent::run(
|
||||
&item->sparkline, &Sparkline::update, model->msg_id, item->sig, last_msg.ts, settings.sparkline_range, size));
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < model->rowCount(); ++i) {
|
||||
emit model->dataChanged(model->index(i, 1), model->index(i, 1), {Qt::DisplayRole});
|
||||
}
|
||||
}
|
||||
|
||||
void SignalView::resizeEvent(QResizeEvent* event) {
|
||||
updateState();
|
||||
QFrame::resizeEvent(event);
|
||||
}
|
||||
|
||||
// ValueDescriptionDlg
|
||||
|
||||
ValueDescriptionDlg::ValueDescriptionDlg(const ValueDescription &descriptions, QWidget *parent) : QDialog(parent) {
|
||||
QHBoxLayout *toolbar_layout = new QHBoxLayout();
|
||||
QPushButton *add = new QPushButton(utils::icon("plus"), "");
|
||||
QPushButton *remove = new QPushButton(utils::icon("dash"), "");
|
||||
remove->setEnabled(false);
|
||||
toolbar_layout->addWidget(add);
|
||||
toolbar_layout->addWidget(remove);
|
||||
toolbar_layout->addStretch(0);
|
||||
|
||||
table = new QTableWidget(descriptions.size(), 2, this);
|
||||
table->setItemDelegate(new Delegate(this));
|
||||
table->setHorizontalHeaderLabels({"Value", "Description"});
|
||||
table->horizontalHeader()->setStretchLastSection(true);
|
||||
table->setSelectionBehavior(QAbstractItemView::SelectRows);
|
||||
table->setSelectionMode(QAbstractItemView::SingleSelection);
|
||||
table->setEditTriggers(QAbstractItemView::DoubleClicked | QAbstractItemView::EditKeyPressed);
|
||||
table->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
|
||||
|
||||
int row = 0;
|
||||
for (auto &[val, desc] : descriptions) {
|
||||
table->setItem(row, 0, new QTableWidgetItem(QString::number(val)));
|
||||
table->setItem(row, 1, new QTableWidgetItem(desc));
|
||||
++row;
|
||||
}
|
||||
|
||||
auto btn_box = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
|
||||
QVBoxLayout *main_layout = new QVBoxLayout(this);
|
||||
main_layout->addLayout(toolbar_layout);
|
||||
main_layout->addWidget(table);
|
||||
main_layout->addWidget(btn_box);
|
||||
setMinimumWidth(500);
|
||||
|
||||
QObject::connect(btn_box, &QDialogButtonBox::accepted, this, &ValueDescriptionDlg::save);
|
||||
QObject::connect(btn_box, &QDialogButtonBox::rejected, this, &QDialog::reject);
|
||||
QObject::connect(add, &QPushButton::clicked, [this]() {
|
||||
table->setRowCount(table->rowCount() + 1);
|
||||
table->setItem(table->rowCount() - 1, 0, new QTableWidgetItem);
|
||||
table->setItem(table->rowCount() - 1, 1, new QTableWidgetItem);
|
||||
});
|
||||
QObject::connect(remove, &QPushButton::clicked, [this]() { table->removeRow(table->currentRow()); });
|
||||
QObject::connect(table, &QTableWidget::itemSelectionChanged, [=]() {
|
||||
remove->setEnabled(table->currentRow() != -1);
|
||||
});
|
||||
}
|
||||
|
||||
void ValueDescriptionDlg::save() {
|
||||
for (int i = 0; i < table->rowCount(); ++i) {
|
||||
QString val = table->item(i, 0)->text().trimmed();
|
||||
QString desc = table->item(i, 1)->text().trimmed();
|
||||
if (!val.isEmpty() && !desc.isEmpty()) {
|
||||
val_desc.push_back({val.toDouble(), desc});
|
||||
}
|
||||
}
|
||||
QDialog::accept();
|
||||
}
|
||||
|
||||
QWidget *ValueDescriptionDlg::Delegate::createEditor(QWidget *parent, const QStyleOptionViewItem &option, const QModelIndex &index) const {
|
||||
QLineEdit *edit = new QLineEdit(parent);
|
||||
edit->setFrame(false);
|
||||
if (index.column() == 0) {
|
||||
edit->setValidator(new DoubleValidator(parent));
|
||||
}
|
||||
return edit;
|
||||
}
|
||||
149
tools/cabana/signalview.h
Normal file
149
tools/cabana/signalview.h
Normal file
@@ -0,0 +1,149 @@
|
||||
#pragma once
|
||||
|
||||
#include <memory>
|
||||
#include <set>
|
||||
|
||||
#include <QAbstractItemModel>
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
#include <QSlider>
|
||||
#include <QStyledItemDelegate>
|
||||
#include <QTableWidget>
|
||||
#include <QTreeView>
|
||||
|
||||
#include "tools/cabana/chart/chartswidget.h"
|
||||
#include "tools/cabana/chart/sparkline.h"
|
||||
|
||||
class SignalModel : public QAbstractItemModel {
|
||||
Q_OBJECT
|
||||
public:
|
||||
struct Item {
|
||||
enum Type {Root, Sig, Name, Size, Node, Endian, Signed, Offset, Factor, SignalType, MultiplexValue, ExtraInfo, Unit, Comment, Min, Max, Desc };
|
||||
~Item() { qDeleteAll(children); }
|
||||
inline int row() { return parent->children.indexOf(this); }
|
||||
|
||||
Type type = Type::Root;
|
||||
Item *parent = nullptr;
|
||||
QList<Item *> children;
|
||||
|
||||
const cabana::Signal *sig = nullptr;
|
||||
QString title;
|
||||
bool highlight = false;
|
||||
bool extra_expanded = false;
|
||||
QString sig_val = "-";
|
||||
Sparkline sparkline;
|
||||
};
|
||||
|
||||
SignalModel(QObject *parent);
|
||||
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
|
||||
int columnCount(const QModelIndex &parent = QModelIndex()) const override { return 2; }
|
||||
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
|
||||
QModelIndex index(int row, int column, const QModelIndex &parent = QModelIndex()) const override;
|
||||
QModelIndex parent(const QModelIndex &index) const override;
|
||||
Qt::ItemFlags flags(const QModelIndex &index) const override;
|
||||
bool setData(const QModelIndex &index, const QVariant &value, int role = Qt::EditRole) override;
|
||||
void setMessage(const MessageId &id);
|
||||
void setFilter(const QString &txt);
|
||||
bool saveSignal(const cabana::Signal *origin_s, cabana::Signal &s);
|
||||
Item *getItem(const QModelIndex &index) const;
|
||||
int signalRow(const cabana::Signal *sig) const;
|
||||
void showExtraInfo(const QModelIndex &index);
|
||||
|
||||
private:
|
||||
void insertItem(SignalModel::Item *parent_item, int pos, const cabana::Signal *sig);
|
||||
void handleSignalAdded(MessageId id, const cabana::Signal *sig);
|
||||
void handleSignalUpdated(const cabana::Signal *sig);
|
||||
void handleSignalRemoved(const cabana::Signal *sig);
|
||||
void handleMsgChanged(MessageId id);
|
||||
void refresh();
|
||||
|
||||
MessageId msg_id;
|
||||
QString filter_str;
|
||||
std::unique_ptr<Item> root;
|
||||
friend class SignalView;
|
||||
friend class SignalItemDelegate;
|
||||
};
|
||||
|
||||
class ValueDescriptionDlg : public QDialog {
|
||||
public:
|
||||
ValueDescriptionDlg(const ValueDescription &descriptions, QWidget *parent);
|
||||
ValueDescription val_desc;
|
||||
|
||||
private:
|
||||
struct Delegate : public QStyledItemDelegate {
|
||||
Delegate(QWidget *parent) : QStyledItemDelegate(parent) {}
|
||||
QWidget *createEditor(QWidget *parent, const QStyleOptionViewItem &option, const QModelIndex &index) const override;
|
||||
};
|
||||
|
||||
void save();
|
||||
QTableWidget *table;
|
||||
};
|
||||
|
||||
class SignalItemDelegate : public QStyledItemDelegate {
|
||||
public:
|
||||
SignalItemDelegate(QObject *parent);
|
||||
void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override;
|
||||
QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override;
|
||||
QWidget *createEditor(QWidget *parent, const QStyleOptionViewItem &option, const QModelIndex &index) const override;
|
||||
void updateEditorGeometry(QWidget *editor, const QStyleOptionViewItem &option, const QModelIndex &index) const override;
|
||||
void setModelData(QWidget *editor, QAbstractItemModel *model, const QModelIndex &index) const override;
|
||||
|
||||
QValidator *name_validator, *double_validator, *node_validator;
|
||||
QFont label_font, minmax_font;
|
||||
const int color_label_width = 18;
|
||||
mutable QSize button_size;
|
||||
mutable QHash<QString, int> width_cache;
|
||||
};
|
||||
|
||||
class SignalView : public QFrame {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
SignalView(ChartsWidget *charts, QWidget *parent);
|
||||
void setMessage(const MessageId &id);
|
||||
void signalHovered(const cabana::Signal *sig);
|
||||
void updateChartState();
|
||||
void selectSignal(const cabana::Signal *sig, bool expand = false);
|
||||
void rowClicked(const QModelIndex &index);
|
||||
SignalModel *model = nullptr;
|
||||
|
||||
signals:
|
||||
void highlight(const cabana::Signal *sig);
|
||||
void showChart(const MessageId &id, const cabana::Signal *sig, bool show, bool merge);
|
||||
|
||||
private:
|
||||
void rowsChanged();
|
||||
void resizeEvent(QResizeEvent* event) override;
|
||||
void updateToolBar();
|
||||
void setSparklineRange(int value);
|
||||
void handleSignalAdded(MessageId id, const cabana::Signal *sig);
|
||||
void handleSignalUpdated(const cabana::Signal *sig);
|
||||
void updateState(const std::set<MessageId> *msgs = nullptr);
|
||||
|
||||
struct TreeView : public QTreeView {
|
||||
TreeView(QWidget *parent) : QTreeView(parent) {}
|
||||
void rowsInserted(const QModelIndex &parent, int start, int end) override {
|
||||
((SignalView *)parentWidget())->rowsChanged();
|
||||
// update widget geometries in QTreeView::rowsInserted
|
||||
QTreeView::rowsInserted(parent, start, end);
|
||||
}
|
||||
void dataChanged(const QModelIndex &topLeft, const QModelIndex &bottomRight, const QVector<int> &roles = QVector<int>()) override {
|
||||
// Bypass the slow call to QTreeView::dataChanged.
|
||||
QAbstractItemView::dataChanged(topLeft, bottomRight, roles);
|
||||
}
|
||||
void leaveEvent(QEvent *event) override {
|
||||
emit static_cast<SignalView *>(parentWidget())->highlight(nullptr);
|
||||
QTreeView::leaveEvent(event);
|
||||
}
|
||||
};
|
||||
int max_value_width = 0;
|
||||
int value_column_width = 0;
|
||||
TreeView *tree;
|
||||
QLabel *sparkline_label;
|
||||
QSlider *sparkline_range_slider;
|
||||
QLineEdit *filter_edit;
|
||||
ChartsWidget *charts;
|
||||
QLabel *signal_count_lb;
|
||||
SignalItemDelegate *delegate;
|
||||
friend SignalItemDelegate;
|
||||
};
|
||||
286
tools/cabana/streams/abstractstream.cc
Normal file
286
tools/cabana/streams/abstractstream.cc
Normal file
@@ -0,0 +1,286 @@
|
||||
#include "tools/cabana/streams/abstractstream.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <utility>
|
||||
|
||||
#include "common/timing.h"
|
||||
#include "tools/cabana/settings.h"
|
||||
|
||||
static const int EVENT_NEXT_BUFFER_SIZE = 6 * 1024 * 1024; // 6MB
|
||||
|
||||
AbstractStream *can = nullptr;
|
||||
|
||||
StreamNotifier *StreamNotifier::instance() {
|
||||
static StreamNotifier notifier;
|
||||
return ¬ifier;
|
||||
}
|
||||
|
||||
AbstractStream::AbstractStream(QObject *parent) : QObject(parent) {
|
||||
assert(parent != nullptr);
|
||||
event_buffer_ = std::make_unique<MonotonicBuffer>(EVENT_NEXT_BUFFER_SIZE);
|
||||
|
||||
QObject::connect(this, &AbstractStream::privateUpdateLastMsgsSignal, this, &AbstractStream::updateLastMessages, Qt::QueuedConnection);
|
||||
QObject::connect(this, &AbstractStream::seekedTo, this, &AbstractStream::updateLastMsgsTo);
|
||||
QObject::connect(dbc(), &DBCManager::DBCFileChanged, this, &AbstractStream::updateMasks);
|
||||
QObject::connect(dbc(), &DBCManager::maskUpdated, this, &AbstractStream::updateMasks);
|
||||
QObject::connect(this, &AbstractStream::streamStarted, [this]() {
|
||||
emit StreamNotifier::instance()->changingStream();
|
||||
delete can;
|
||||
can = this;
|
||||
emit StreamNotifier::instance()->streamStarted();
|
||||
});
|
||||
}
|
||||
|
||||
void AbstractStream::updateMasks() {
|
||||
std::lock_guard lk(mutex_);
|
||||
masks_.clear();
|
||||
if (!settings.suppress_defined_signals)
|
||||
return;
|
||||
|
||||
for (const auto s : sources) {
|
||||
for (const auto &[address, m] : dbc()->getMessages(s)) {
|
||||
masks_[{.source = (uint8_t)s, .address = address}] = m.mask;
|
||||
}
|
||||
}
|
||||
// clear bit change counts
|
||||
for (auto &[id, m] : messages_) {
|
||||
auto &mask = masks_[id];
|
||||
const int size = std::min(mask.size(), m.last_changes.size());
|
||||
for (int i = 0; i < size; ++i) {
|
||||
for (int j = 0; j < 8; ++j) {
|
||||
if (((mask[i] >> (7 - j)) & 1) != 0) m.last_changes[i].bit_change_counts[j] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void AbstractStream::suppressDefinedSignals(bool suppress) {
|
||||
settings.suppress_defined_signals = suppress;
|
||||
updateMasks();
|
||||
}
|
||||
|
||||
size_t AbstractStream::suppressHighlighted() {
|
||||
std::lock_guard lk(mutex_);
|
||||
size_t cnt = 0;
|
||||
const double cur_ts = currentSec();
|
||||
for (auto &[_, m] : messages_) {
|
||||
for (auto &last_change : m.last_changes) {
|
||||
const double dt = cur_ts - last_change.ts;
|
||||
if (dt < 2.0) {
|
||||
last_change.suppressed = true;
|
||||
}
|
||||
// clear bit change counts
|
||||
last_change.bit_change_counts.fill(0);
|
||||
cnt += last_change.suppressed;
|
||||
}
|
||||
}
|
||||
return cnt;
|
||||
}
|
||||
|
||||
void AbstractStream::clearSuppressed() {
|
||||
std::lock_guard lk(mutex_);
|
||||
for (auto &[_, m] : messages_) {
|
||||
std::for_each(m.last_changes.begin(), m.last_changes.end(), [](auto &c) { c.suppressed = false; });
|
||||
}
|
||||
}
|
||||
|
||||
void AbstractStream::updateLastMessages() {
|
||||
auto prev_src_size = sources.size();
|
||||
auto prev_msg_size = last_msgs.size();
|
||||
std::set<MessageId> msgs;
|
||||
{
|
||||
std::lock_guard lk(mutex_);
|
||||
for (const auto &id : new_msgs_) {
|
||||
const auto &can_data = messages_[id];
|
||||
current_sec_ = std::max(current_sec_, can_data.ts);
|
||||
last_msgs[id] = can_data;
|
||||
sources.insert(id.source);
|
||||
}
|
||||
msgs = std::move(new_msgs_);
|
||||
}
|
||||
|
||||
if (sources.size() != prev_src_size) {
|
||||
updateMasks();
|
||||
emit sourcesUpdated(sources);
|
||||
}
|
||||
emit msgsReceived(&msgs, prev_msg_size != last_msgs.size());
|
||||
}
|
||||
|
||||
void AbstractStream::updateEvent(const MessageId &id, double sec, const uint8_t *data, uint8_t size) {
|
||||
std::lock_guard lk(mutex_);
|
||||
messages_[id].compute(id, data, size, sec, getSpeed(), masks_[id]);
|
||||
new_msgs_.insert(id);
|
||||
}
|
||||
|
||||
const std::vector<const CanEvent *> &AbstractStream::events(const MessageId &id) const {
|
||||
static std::vector<const CanEvent *> empty_events;
|
||||
auto it = events_.find(id);
|
||||
return it != events_.end() ? it->second : empty_events;
|
||||
}
|
||||
|
||||
const CanData &AbstractStream::lastMessage(const MessageId &id) {
|
||||
static CanData empty_data = {};
|
||||
auto it = last_msgs.find(id);
|
||||
return it != last_msgs.end() ? it->second : empty_data;
|
||||
}
|
||||
|
||||
// it is thread safe to update data in updateLastMsgsTo.
|
||||
// updateLastMsgsTo is always called in UI thread.
|
||||
void AbstractStream::updateLastMsgsTo(double sec) {
|
||||
new_msgs_.clear();
|
||||
messages_.clear();
|
||||
|
||||
current_sec_ = sec;
|
||||
uint64_t last_ts = (sec + routeStartTime()) * 1e9;
|
||||
for (const auto &[id, ev] : events_) {
|
||||
auto it = std::upper_bound(ev.begin(), ev.end(), last_ts, CompareCanEvent());
|
||||
if (it != ev.begin()) {
|
||||
auto prev = std::prev(it);
|
||||
double ts = (*prev)->mono_time / 1e9 - routeStartTime();
|
||||
auto &m = messages_[id];
|
||||
m.compute(id, (*prev)->dat, (*prev)->size, ts, getSpeed(), {});
|
||||
m.count = std::distance(ev.begin(), prev) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
bool id_changed = messages_.size() != last_msgs.size() ||
|
||||
std::any_of(messages_.cbegin(), messages_.cend(),
|
||||
[this](const auto &m) { return !last_msgs.count(m.first); });
|
||||
last_msgs = messages_;
|
||||
emit msgsReceived(nullptr, id_changed);
|
||||
}
|
||||
|
||||
const CanEvent *AbstractStream::newEvent(uint64_t mono_time, const cereal::CanData::Reader &c) {
|
||||
auto dat = c.getDat();
|
||||
CanEvent *e = (CanEvent *)event_buffer_->allocate(sizeof(CanEvent) + sizeof(uint8_t) * dat.size());
|
||||
e->src = c.getSrc();
|
||||
e->address = c.getAddress();
|
||||
e->mono_time = mono_time;
|
||||
e->size = dat.size();
|
||||
memcpy(e->dat, (uint8_t *)dat.begin(), e->size);
|
||||
return e;
|
||||
}
|
||||
|
||||
void AbstractStream::mergeEvents(const std::vector<const CanEvent *> &events) {
|
||||
static MessageEventsMap msg_events;
|
||||
std::for_each(msg_events.begin(), msg_events.end(), [](auto &e) { e.second.clear(); });
|
||||
for (auto e : events) {
|
||||
msg_events[{.source = e->src, .address = e->address}].push_back(e);
|
||||
}
|
||||
|
||||
if (!events.empty()) {
|
||||
for (const auto &[id, new_e] : msg_events) {
|
||||
if (!new_e.empty()) {
|
||||
auto &e = events_[id];
|
||||
auto pos = std::upper_bound(e.cbegin(), e.cend(), new_e.front()->mono_time, CompareCanEvent());
|
||||
e.insert(pos, new_e.cbegin(), new_e.cend());
|
||||
}
|
||||
}
|
||||
auto pos = std::upper_bound(all_events_.cbegin(), all_events_.cend(), events.front()->mono_time, CompareCanEvent());
|
||||
all_events_.insert(pos, events.cbegin(), events.cend());
|
||||
emit eventsMerged(msg_events);
|
||||
}
|
||||
lastest_event_ts = all_events_.empty() ? 0 : all_events_.back()->mono_time;
|
||||
}
|
||||
|
||||
// CanData
|
||||
|
||||
namespace {
|
||||
|
||||
enum Color { GREYISH_BLUE, CYAN, RED};
|
||||
QColor getColor(int c) {
|
||||
constexpr int start_alpha = 128;
|
||||
static const QColor colors[] = {
|
||||
[GREYISH_BLUE] = QColor(102, 86, 169, start_alpha / 2),
|
||||
[CYAN] = QColor(0, 187, 255, start_alpha),
|
||||
[RED] = QColor(255, 0, 0, start_alpha),
|
||||
};
|
||||
return settings.theme == LIGHT_THEME ? colors[c] : colors[c].lighter(135);
|
||||
}
|
||||
|
||||
inline QColor blend(const QColor &a, const QColor &b) {
|
||||
return QColor((a.red() + b.red()) / 2, (a.green() + b.green()) / 2, (a.blue() + b.blue()) / 2, (a.alpha() + b.alpha()) / 2);
|
||||
}
|
||||
|
||||
// Calculate the frequency of the past minute.
|
||||
double calc_freq(const MessageId &msg_id, double current_sec) {
|
||||
const auto &events = can->events(msg_id);
|
||||
uint64_t cur_mono_time = (can->routeStartTime() + current_sec) * 1e9;
|
||||
uint64_t first_mono_time = std::max<int64_t>(0, cur_mono_time - 59 * 1e9);
|
||||
auto first = std::lower_bound(events.begin(), events.end(), first_mono_time, CompareCanEvent());
|
||||
auto second = std::lower_bound(first, events.end(), cur_mono_time, CompareCanEvent());
|
||||
if (first != events.end() && second != events.end()) {
|
||||
double duration = ((*second)->mono_time - (*first)->mono_time) / 1e9;
|
||||
uint32_t count = std::distance(first, second);
|
||||
return count / std::max(1.0, duration);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void CanData::compute(const MessageId &msg_id, const uint8_t *can_data, const int size, double current_sec,
|
||||
double playback_speed, const std::vector<uint8_t> &mask, double in_freq) {
|
||||
ts = current_sec;
|
||||
++count;
|
||||
|
||||
if (auto sec = seconds_since_boot(); (sec - last_freq_update_ts) >= 1) {
|
||||
last_freq_update_ts = sec;
|
||||
freq = !in_freq ? calc_freq(msg_id, ts) : in_freq;
|
||||
}
|
||||
|
||||
if (dat.size() != size) {
|
||||
dat.resize(size);
|
||||
colors.assign(size, QColor(0, 0, 0, 0));
|
||||
last_changes.resize(size);
|
||||
std::for_each(last_changes.begin(), last_changes.end(), [current_sec](auto &c) { c.ts = current_sec; });
|
||||
} else {
|
||||
constexpr int periodic_threshold = 10;
|
||||
constexpr float fade_time = 2.0;
|
||||
const float alpha_delta = 1.0 / (freq + 1) / (fade_time * playback_speed);
|
||||
|
||||
for (int i = 0; i < size; ++i) {
|
||||
auto &last_change = last_changes[i];
|
||||
|
||||
uint8_t mask_byte = last_change.suppressed ? 0x00 : 0xFF;
|
||||
if (i < mask.size()) mask_byte &= ~(mask[i]);
|
||||
|
||||
const uint8_t last = dat[i] & mask_byte;
|
||||
const uint8_t cur = can_data[i] & mask_byte;
|
||||
if (last != cur) {
|
||||
const int delta = cur - last;
|
||||
// Keep track if signal is changing randomly, or mostly moving in the same direction
|
||||
if (std::signbit(delta) == std::signbit(last_change.delta)) {
|
||||
last_change.same_delta_counter = std::min(16, last_change.same_delta_counter + 1);
|
||||
} else {
|
||||
last_change.same_delta_counter = std::max(0, last_change.same_delta_counter - 4);
|
||||
}
|
||||
|
||||
const double delta_t = ts - last_change.ts;
|
||||
// Mostly moves in the same direction, color based on delta up/down
|
||||
if (delta_t * freq > periodic_threshold || last_change.same_delta_counter > 8) {
|
||||
// Last change was while ago, choose color based on delta up or down
|
||||
colors[i] = getColor(cur > last ? CYAN : RED);
|
||||
} else {
|
||||
// Periodic changes
|
||||
colors[i] = blend(colors[i], getColor(GREYISH_BLUE));
|
||||
}
|
||||
|
||||
// Track bit level changes
|
||||
const uint8_t tmp = (cur ^ last);
|
||||
for (int bit = 0; bit < 8; bit++) {
|
||||
if (tmp & (1 << (7 - bit))) {
|
||||
last_change.bit_change_counts[bit] += 1;
|
||||
}
|
||||
}
|
||||
|
||||
last_change.ts = ts;
|
||||
last_change.delta = delta;
|
||||
} else {
|
||||
// Fade out
|
||||
colors[i].setAlphaF(std::max(0.0, colors[i].alphaF() - alpha_delta));
|
||||
}
|
||||
}
|
||||
}
|
||||
memcpy(dat.data(), can_data, size);
|
||||
}
|
||||
157
tools/cabana/streams/abstractstream.h
Normal file
157
tools/cabana/streams/abstractstream.h
Normal file
@@ -0,0 +1,157 @@
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <set>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
#include <QColor>
|
||||
#include <QDateTime>
|
||||
|
||||
#include "cereal/messaging/messaging.h"
|
||||
#include "tools/cabana/dbc/dbcmanager.h"
|
||||
#include "tools/cabana/util.h"
|
||||
|
||||
struct CanData {
|
||||
void compute(const MessageId &msg_id, const uint8_t *dat, const int size, double current_sec,
|
||||
double playback_speed, const std::vector<uint8_t> &mask, double in_freq = 0);
|
||||
|
||||
double ts = 0.;
|
||||
uint32_t count = 0;
|
||||
double freq = 0;
|
||||
std::vector<uint8_t> dat;
|
||||
std::vector<QColor> colors;
|
||||
|
||||
struct ByteLastChange {
|
||||
double ts;
|
||||
int delta;
|
||||
int same_delta_counter;
|
||||
bool suppressed;
|
||||
std::array<uint32_t, 8> bit_change_counts;
|
||||
};
|
||||
std::vector<ByteLastChange> last_changes;
|
||||
double last_freq_update_ts = 0;
|
||||
};
|
||||
|
||||
struct CanEvent {
|
||||
uint8_t src;
|
||||
uint32_t address;
|
||||
uint64_t mono_time;
|
||||
uint8_t size;
|
||||
uint8_t dat[];
|
||||
};
|
||||
|
||||
struct CompareCanEvent {
|
||||
constexpr bool operator()(const CanEvent *const e, uint64_t ts) const { return e->mono_time < ts; }
|
||||
constexpr bool operator()(uint64_t ts, const CanEvent *const e) const { return ts < e->mono_time; }
|
||||
};
|
||||
|
||||
struct BusConfig {
|
||||
int can_speed_kbps = 500;
|
||||
int data_speed_kbps = 2000;
|
||||
bool can_fd = false;
|
||||
};
|
||||
|
||||
typedef std::unordered_map<MessageId, std::vector<const CanEvent *>> MessageEventsMap;
|
||||
|
||||
class AbstractStream : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
AbstractStream(QObject *parent);
|
||||
virtual ~AbstractStream() {}
|
||||
virtual void start() = 0;
|
||||
virtual bool liveStreaming() const { return true; }
|
||||
virtual void seekTo(double ts) {}
|
||||
virtual QString routeName() const = 0;
|
||||
virtual QString carFingerprint() const { return ""; }
|
||||
virtual QDateTime beginDateTime() const { return {}; }
|
||||
virtual double routeStartTime() const { return 0; }
|
||||
inline double currentSec() const { return current_sec_; }
|
||||
virtual double totalSeconds() const { return lastEventMonoTime() / 1e9 - routeStartTime(); }
|
||||
virtual void setSpeed(float speed) {}
|
||||
virtual double getSpeed() { return 1; }
|
||||
virtual bool isPaused() const { return false; }
|
||||
virtual void pause(bool pause) {}
|
||||
|
||||
inline const std::unordered_map<MessageId, CanData> &lastMessages() const { return last_msgs; }
|
||||
inline const MessageEventsMap &eventsMap() const { return events_; }
|
||||
inline const std::vector<const CanEvent *> &allEvents() const { return all_events_; }
|
||||
const CanData &lastMessage(const MessageId &id);
|
||||
const std::vector<const CanEvent *> &events(const MessageId &id) const;
|
||||
|
||||
size_t suppressHighlighted();
|
||||
void clearSuppressed();
|
||||
void suppressDefinedSignals(bool suppress);
|
||||
|
||||
signals:
|
||||
void paused();
|
||||
void resume();
|
||||
void seekedTo(double sec);
|
||||
void streamStarted();
|
||||
void eventsMerged(const MessageEventsMap &events_map);
|
||||
void msgsReceived(const std::set<MessageId> *new_msgs, bool has_new_ids);
|
||||
void sourcesUpdated(const SourceSet &s);
|
||||
void privateUpdateLastMsgsSignal();
|
||||
|
||||
public:
|
||||
SourceSet sources;
|
||||
|
||||
protected:
|
||||
void mergeEvents(const std::vector<const CanEvent *> &events);
|
||||
const CanEvent *newEvent(uint64_t mono_time, const cereal::CanData::Reader &c);
|
||||
void updateEvent(const MessageId &id, double sec, const uint8_t *data, uint8_t size);
|
||||
uint64_t lastEventMonoTime() const { return lastest_event_ts; }
|
||||
|
||||
std::vector<const CanEvent *> all_events_;
|
||||
uint64_t lastest_event_ts = 0;
|
||||
|
||||
private:
|
||||
void updateLastMessages();
|
||||
void updateLastMsgsTo(double sec);
|
||||
void updateMasks();
|
||||
|
||||
double current_sec_ = 0;
|
||||
MessageEventsMap events_;
|
||||
std::unordered_map<MessageId, CanData> last_msgs;
|
||||
std::unique_ptr<MonotonicBuffer> event_buffer_;
|
||||
|
||||
// Members accessed in multiple threads. (mutex protected)
|
||||
std::mutex mutex_;
|
||||
std::set<MessageId> new_msgs_;
|
||||
std::unordered_map<MessageId, CanData> messages_;
|
||||
std::unordered_map<MessageId, std::vector<uint8_t>> masks_;
|
||||
};
|
||||
|
||||
class AbstractOpenStreamWidget : public QWidget {
|
||||
public:
|
||||
AbstractOpenStreamWidget(AbstractStream **stream, QWidget *parent = nullptr) : stream(stream), QWidget(parent) {}
|
||||
virtual bool open() = 0;
|
||||
virtual QString title() = 0;
|
||||
|
||||
protected:
|
||||
AbstractStream **stream = nullptr;
|
||||
};
|
||||
|
||||
class DummyStream : public AbstractStream {
|
||||
Q_OBJECT
|
||||
public:
|
||||
DummyStream(QObject *parent) : AbstractStream(parent) {}
|
||||
QString routeName() const override { return tr("No Stream"); }
|
||||
void start() override { emit streamStarted(); }
|
||||
};
|
||||
|
||||
class StreamNotifier : public QObject {
|
||||
Q_OBJECT
|
||||
public:
|
||||
StreamNotifier(QObject *parent = nullptr) : QObject(parent) {}
|
||||
static StreamNotifier* instance();
|
||||
signals:
|
||||
void streamStarted();
|
||||
void changingStream();
|
||||
};
|
||||
|
||||
// A global pointer referring to the unique AbstractStream object
|
||||
extern AbstractStream *can;
|
||||
70
tools/cabana/streams/devicestream.cc
Normal file
70
tools/cabana/streams/devicestream.cc
Normal file
@@ -0,0 +1,70 @@
|
||||
#include "tools/cabana/streams/devicestream.h"
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
#include <QButtonGroup>
|
||||
#include <QFormLayout>
|
||||
#include <QRadioButton>
|
||||
#include <QRegularExpression>
|
||||
#include <QRegularExpressionValidator>
|
||||
#include <QThread>
|
||||
|
||||
// DeviceStream
|
||||
|
||||
DeviceStream::DeviceStream(QObject *parent, QString address) : zmq_address(address), LiveStream(parent) {
|
||||
}
|
||||
|
||||
void DeviceStream::streamThread() {
|
||||
zmq_address.isEmpty() ? unsetenv("ZMQ") : setenv("ZMQ", "1", 1);
|
||||
|
||||
std::unique_ptr<Context> context(Context::create());
|
||||
std::string address = zmq_address.isEmpty() ? "127.0.0.1" : zmq_address.toStdString();
|
||||
std::unique_ptr<SubSocket> sock(SubSocket::create(context.get(), "can", address));
|
||||
assert(sock != NULL);
|
||||
// run as fast as messages come in
|
||||
while (!QThread::currentThread()->isInterruptionRequested()) {
|
||||
std::unique_ptr<Message> msg(sock->receive(true));
|
||||
if (!msg) {
|
||||
QThread::msleep(50);
|
||||
continue;
|
||||
}
|
||||
handleEvent(kj::ArrayPtr<capnp::word>((capnp::word*)msg->getData(), msg->getSize() / sizeof(capnp::word)));
|
||||
}
|
||||
}
|
||||
|
||||
AbstractOpenStreamWidget *DeviceStream::widget(AbstractStream **stream) {
|
||||
return new OpenDeviceWidget(stream);
|
||||
}
|
||||
|
||||
// OpenDeviceWidget
|
||||
|
||||
OpenDeviceWidget::OpenDeviceWidget(AbstractStream **stream) : AbstractOpenStreamWidget(stream) {
|
||||
QRadioButton *msgq = new QRadioButton(tr("MSGQ"));
|
||||
QRadioButton *zmq = new QRadioButton(tr("ZMQ"));
|
||||
ip_address = new QLineEdit(this);
|
||||
ip_address->setPlaceholderText(tr("Enter device Ip Address"));
|
||||
QString ip_range = "(?:[0-1]?[0-9]?[0-9]|2[0-4][0-9]|25[0-5])";
|
||||
QString pattern("^" + ip_range + "\\." + ip_range + "\\." + ip_range + "\\." + ip_range + "$");
|
||||
QRegularExpression re(pattern);
|
||||
ip_address->setValidator(new QRegularExpressionValidator(re, this));
|
||||
|
||||
group = new QButtonGroup(this);
|
||||
group->addButton(msgq, 0);
|
||||
group->addButton(zmq, 1);
|
||||
|
||||
QFormLayout *form_layout = new QFormLayout(this);
|
||||
form_layout->addRow(msgq);
|
||||
form_layout->addRow(zmq, ip_address);
|
||||
QObject::connect(group, qOverload<QAbstractButton *, bool>(&QButtonGroup::buttonToggled), [=](QAbstractButton *button, bool checked) {
|
||||
ip_address->setEnabled(button == zmq && checked);
|
||||
});
|
||||
zmq->setChecked(true);
|
||||
}
|
||||
|
||||
bool OpenDeviceWidget::open() {
|
||||
QString ip = ip_address->text().isEmpty() ? "127.0.0.1" : ip_address->text();
|
||||
bool msgq = group->checkedId() == 0;
|
||||
*stream = new DeviceStream(qApp, msgq ? "" : ip);
|
||||
return true;
|
||||
}
|
||||
30
tools/cabana/streams/devicestream.h
Normal file
30
tools/cabana/streams/devicestream.h
Normal file
@@ -0,0 +1,30 @@
|
||||
#pragma once
|
||||
|
||||
#include "tools/cabana/streams/livestream.h"
|
||||
|
||||
class DeviceStream : public LiveStream {
|
||||
Q_OBJECT
|
||||
public:
|
||||
DeviceStream(QObject *parent, QString address = {});
|
||||
static AbstractOpenStreamWidget *widget(AbstractStream **stream);
|
||||
inline QString routeName() const override {
|
||||
return QString("Live Streaming From %1").arg(zmq_address.isEmpty() ? "127.0.0.1" : zmq_address);
|
||||
}
|
||||
|
||||
protected:
|
||||
void streamThread() override;
|
||||
const QString zmq_address;
|
||||
};
|
||||
|
||||
class OpenDeviceWidget : public AbstractOpenStreamWidget {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
OpenDeviceWidget(AbstractStream **stream);
|
||||
bool open() override;
|
||||
QString title() override { return tr("&Device"); }
|
||||
|
||||
private:
|
||||
QLineEdit *ip_address;
|
||||
QButtonGroup *group;
|
||||
};
|
||||
140
tools/cabana/streams/livestream.cc
Normal file
140
tools/cabana/streams/livestream.cc
Normal file
@@ -0,0 +1,140 @@
|
||||
#include "tools/cabana/streams/livestream.h"
|
||||
|
||||
#include <QThread>
|
||||
#include <algorithm>
|
||||
#include <fstream>
|
||||
#include <memory>
|
||||
|
||||
#include "common/timing.h"
|
||||
#include "common/util.h"
|
||||
|
||||
struct LiveStream::Logger {
|
||||
Logger() : start_ts(seconds_since_epoch()), segment_num(-1) {}
|
||||
|
||||
void write(kj::ArrayPtr<capnp::word> data) {
|
||||
int n = (seconds_since_epoch() - start_ts) / 60.0;
|
||||
if (std::exchange(segment_num, n) != segment_num) {
|
||||
QString dir = QString("%1/%2--%3")
|
||||
.arg(settings.log_path)
|
||||
.arg(QDateTime::fromSecsSinceEpoch(start_ts).toString("yyyy-MM-dd--hh-mm-ss"))
|
||||
.arg(n);
|
||||
util::create_directories(dir.toStdString(), 0755);
|
||||
fs.reset(new std::ofstream((dir + "/rlog").toStdString(), std::ios::binary | std::ios::out));
|
||||
}
|
||||
|
||||
auto bytes = data.asBytes();
|
||||
fs->write((const char*)bytes.begin(), bytes.size());
|
||||
}
|
||||
|
||||
std::unique_ptr<std::ofstream> fs;
|
||||
int segment_num;
|
||||
uint64_t start_ts;
|
||||
};
|
||||
|
||||
LiveStream::LiveStream(QObject *parent) : AbstractStream(parent) {
|
||||
if (settings.log_livestream) {
|
||||
logger = std::make_unique<Logger>();
|
||||
}
|
||||
stream_thread = new QThread(this);
|
||||
|
||||
QObject::connect(&settings, &Settings::changed, this, &LiveStream::startUpdateTimer);
|
||||
QObject::connect(stream_thread, &QThread::started, [=]() { streamThread(); });
|
||||
QObject::connect(stream_thread, &QThread::finished, stream_thread, &QThread::deleteLater);
|
||||
}
|
||||
|
||||
void LiveStream::startUpdateTimer() {
|
||||
update_timer.stop();
|
||||
update_timer.start(1000.0 / settings.fps, this);
|
||||
timer_id = update_timer.timerId();
|
||||
}
|
||||
|
||||
void LiveStream::start() {
|
||||
emit streamStarted();
|
||||
stream_thread->start();
|
||||
startUpdateTimer();
|
||||
begin_date_time = QDateTime::currentDateTime();
|
||||
}
|
||||
|
||||
LiveStream::~LiveStream() {
|
||||
update_timer.stop();
|
||||
stream_thread->requestInterruption();
|
||||
stream_thread->quit();
|
||||
stream_thread->wait();
|
||||
}
|
||||
|
||||
// called in streamThread
|
||||
void LiveStream::handleEvent(kj::ArrayPtr<capnp::word> data) {
|
||||
if (logger) {
|
||||
logger->write(data);
|
||||
}
|
||||
|
||||
capnp::FlatArrayMessageReader reader(data);
|
||||
auto event = reader.getRoot<cereal::Event>();
|
||||
if (event.which() == cereal::Event::Which::CAN) {
|
||||
const uint64_t mono_time = event.getLogMonoTime();
|
||||
std::lock_guard lk(lock);
|
||||
for (const auto &c : event.getCan()) {
|
||||
received_events_.push_back(newEvent(mono_time, c));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void LiveStream::timerEvent(QTimerEvent *event) {
|
||||
if (event->timerId() == timer_id) {
|
||||
{
|
||||
// merge events received from live stream thread.
|
||||
std::lock_guard lk(lock);
|
||||
mergeEvents(received_events_);
|
||||
received_events_.clear();
|
||||
}
|
||||
if (!all_events_.empty()) {
|
||||
begin_event_ts = all_events_.front()->mono_time;
|
||||
updateEvents();
|
||||
return;
|
||||
}
|
||||
}
|
||||
QObject::timerEvent(event);
|
||||
}
|
||||
|
||||
void LiveStream::updateEvents() {
|
||||
static double prev_speed = 1.0;
|
||||
|
||||
if (first_update_ts == 0) {
|
||||
first_update_ts = nanos_since_boot();
|
||||
first_event_ts = current_event_ts = all_events_.back()->mono_time;
|
||||
}
|
||||
|
||||
if (paused_ || prev_speed != speed_) {
|
||||
prev_speed = speed_;
|
||||
first_update_ts = nanos_since_boot();
|
||||
first_event_ts = current_event_ts;
|
||||
return;
|
||||
}
|
||||
|
||||
uint64_t last_ts = post_last_event && speed_ == 1.0
|
||||
? all_events_.back()->mono_time
|
||||
: first_event_ts + (nanos_since_boot() - first_update_ts) * speed_;
|
||||
auto first = std::upper_bound(all_events_.cbegin(), all_events_.cend(), current_event_ts, CompareCanEvent());
|
||||
auto last = std::upper_bound(first, all_events_.cend(), last_ts, CompareCanEvent());
|
||||
|
||||
for (auto it = first; it != last; ++it) {
|
||||
const CanEvent *e = *it;
|
||||
MessageId id = {.source = e->src, .address = e->address};
|
||||
updateEvent(id, (e->mono_time - begin_event_ts) / 1e9, e->dat, e->size);
|
||||
current_event_ts = e->mono_time;
|
||||
}
|
||||
emit privateUpdateLastMsgsSignal();
|
||||
}
|
||||
|
||||
void LiveStream::seekTo(double sec) {
|
||||
sec = std::max(0.0, sec);
|
||||
first_update_ts = nanos_since_boot();
|
||||
current_event_ts = first_event_ts = std::min<uint64_t>(sec * 1e9 + begin_event_ts, lastEventMonoTime());
|
||||
post_last_event = (first_event_ts == lastEventMonoTime());
|
||||
emit seekedTo((current_event_ts - begin_event_ts) / 1e9);
|
||||
}
|
||||
|
||||
void LiveStream::pause(bool pause) {
|
||||
paused_ = pause;
|
||||
emit(pause ? paused() : resume());
|
||||
}
|
||||
52
tools/cabana/streams/livestream.h
Normal file
52
tools/cabana/streams/livestream.h
Normal file
@@ -0,0 +1,52 @@
|
||||
#pragma once
|
||||
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
#include <QBasicTimer>
|
||||
|
||||
#include "tools/cabana/streams/abstractstream.h"
|
||||
|
||||
class LiveStream : public AbstractStream {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
LiveStream(QObject *parent);
|
||||
virtual ~LiveStream();
|
||||
void start() override;
|
||||
inline QDateTime beginDateTime() const { return begin_date_time; }
|
||||
inline double routeStartTime() const override { return begin_event_ts / 1e9; }
|
||||
void setSpeed(float speed) override { speed_ = speed; }
|
||||
double getSpeed() override { return speed_; }
|
||||
bool isPaused() const override { return paused_; }
|
||||
void pause(bool pause) override;
|
||||
void seekTo(double sec) override;
|
||||
|
||||
protected:
|
||||
virtual void streamThread() = 0;
|
||||
void handleEvent(kj::ArrayPtr<capnp::word> event);
|
||||
|
||||
private:
|
||||
void startUpdateTimer();
|
||||
void timerEvent(QTimerEvent *event) override;
|
||||
void updateEvents();
|
||||
|
||||
std::mutex lock;
|
||||
QThread *stream_thread;
|
||||
std::vector<const CanEvent *> received_events_;
|
||||
|
||||
int timer_id;
|
||||
QBasicTimer update_timer;
|
||||
|
||||
QDateTime begin_date_time;
|
||||
uint64_t begin_event_ts = 0;
|
||||
uint64_t current_event_ts = 0;
|
||||
uint64_t first_event_ts = 0;
|
||||
uint64_t first_update_ts = 0;
|
||||
bool post_last_event = true;
|
||||
double speed_ = 1;
|
||||
bool paused_ = false;
|
||||
|
||||
struct Logger;
|
||||
std::unique_ptr<Logger> logger;
|
||||
};
|
||||
220
tools/cabana/streams/pandastream.cc
Normal file
220
tools/cabana/streams/pandastream.cc
Normal file
@@ -0,0 +1,220 @@
|
||||
#include "tools/cabana/streams/pandastream.h"
|
||||
|
||||
#include <QDebug>
|
||||
#include <QCheckBox>
|
||||
#include <QLabel>
|
||||
#include <QMessageBox>
|
||||
#include <QPushButton>
|
||||
#include <QThread>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
// TODO: remove clearLayout
|
||||
static void clearLayout(QLayout* layout) {
|
||||
while (layout->count() > 0) {
|
||||
QLayoutItem* item = layout->takeAt(0);
|
||||
if (QWidget* widget = item->widget()) {
|
||||
widget->deleteLater();
|
||||
}
|
||||
if (QLayout* childLayout = item->layout()) {
|
||||
clearLayout(childLayout);
|
||||
}
|
||||
delete item;
|
||||
}
|
||||
}
|
||||
|
||||
PandaStream::PandaStream(QObject *parent, PandaStreamConfig config_) : config(config_), LiveStream(parent) {
|
||||
if (config.serial.isEmpty()) {
|
||||
auto serials = Panda::list();
|
||||
if (serials.size() == 0) {
|
||||
throw std::runtime_error("No panda found");
|
||||
}
|
||||
config.serial = QString::fromStdString(serials[0]);
|
||||
}
|
||||
|
||||
qDebug() << "Connecting to panda with serial" << config.serial;
|
||||
if (!connect()) {
|
||||
throw std::runtime_error("Failed to connect to panda");
|
||||
}
|
||||
}
|
||||
|
||||
bool PandaStream::connect() {
|
||||
try {
|
||||
panda.reset(new Panda(config.serial.toStdString()));
|
||||
config.bus_config.resize(3);
|
||||
qDebug() << "Connected";
|
||||
} catch (const std::exception& e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
panda->set_safety_model(cereal::CarParams::SafetyModel::SILENT);
|
||||
|
||||
for (int bus = 0; bus < config.bus_config.size(); bus++) {
|
||||
panda->set_can_speed_kbps(bus, config.bus_config[bus].can_speed_kbps);
|
||||
|
||||
// CAN-FD
|
||||
if (panda->hw_type == cereal::PandaState::PandaType::RED_PANDA || panda->hw_type == cereal::PandaState::PandaType::RED_PANDA_V2) {
|
||||
if (config.bus_config[bus].can_fd) {
|
||||
panda->set_data_speed_kbps(bus, config.bus_config[bus].data_speed_kbps);
|
||||
} else {
|
||||
// Hack to disable can-fd by setting data speed to a low value
|
||||
panda->set_data_speed_kbps(bus, 10);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void PandaStream::streamThread() {
|
||||
std::vector<can_frame> raw_can_data;
|
||||
|
||||
while (!QThread::currentThread()->isInterruptionRequested()) {
|
||||
QThread::msleep(1);
|
||||
|
||||
if (!panda->connected()) {
|
||||
qDebug() << "Connection to panda lost. Attempting reconnect.";
|
||||
if (!connect()){
|
||||
QThread::msleep(1000);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
raw_can_data.clear();
|
||||
if (!panda->can_receive(raw_can_data)) {
|
||||
qDebug() << "failed to receive";
|
||||
continue;
|
||||
}
|
||||
|
||||
MessageBuilder msg;
|
||||
auto evt = msg.initEvent();
|
||||
auto canData = evt.initCan(raw_can_data.size());
|
||||
for (uint i = 0; i<raw_can_data.size(); i++) {
|
||||
canData[i].setAddress(raw_can_data[i].address);
|
||||
canData[i].setBusTime(raw_can_data[i].busTime);
|
||||
canData[i].setDat(kj::arrayPtr((uint8_t*)raw_can_data[i].dat.data(), raw_can_data[i].dat.size()));
|
||||
canData[i].setSrc(raw_can_data[i].src);
|
||||
}
|
||||
|
||||
handleEvent(capnp::messageToFlatArray(msg));
|
||||
|
||||
panda->send_heartbeat(false);
|
||||
}
|
||||
}
|
||||
|
||||
AbstractOpenStreamWidget *PandaStream::widget(AbstractStream **stream) {
|
||||
return new OpenPandaWidget(stream);
|
||||
}
|
||||
|
||||
// OpenPandaWidget
|
||||
|
||||
OpenPandaWidget::OpenPandaWidget(AbstractStream **stream) : AbstractOpenStreamWidget(stream) {
|
||||
QVBoxLayout *main_layout = new QVBoxLayout(this);
|
||||
main_layout->addStretch(1);
|
||||
|
||||
QFormLayout *form_layout = new QFormLayout();
|
||||
|
||||
QHBoxLayout *serial_layout = new QHBoxLayout();
|
||||
serial_edit = new QComboBox();
|
||||
serial_edit->setFixedWidth(300);
|
||||
serial_layout->addWidget(serial_edit);
|
||||
|
||||
QPushButton *refresh = new QPushButton(tr("Refresh"));
|
||||
refresh->setFixedWidth(100);
|
||||
serial_layout->addWidget(refresh);
|
||||
form_layout->addRow(tr("Serial"), serial_layout);
|
||||
main_layout->addLayout(form_layout);
|
||||
|
||||
config_layout = new QFormLayout();
|
||||
main_layout->addLayout(config_layout);
|
||||
|
||||
main_layout->addStretch(1);
|
||||
|
||||
QObject::connect(refresh, &QPushButton::clicked, this, &OpenPandaWidget::refreshSerials);
|
||||
QObject::connect(serial_edit, &QComboBox::currentTextChanged, this, &OpenPandaWidget::buildConfigForm);
|
||||
|
||||
// Populate serials
|
||||
refreshSerials();
|
||||
buildConfigForm();
|
||||
}
|
||||
|
||||
void OpenPandaWidget::refreshSerials() {
|
||||
serial_edit->clear();
|
||||
for (auto serial : Panda::list()) {
|
||||
serial_edit->addItem(QString::fromStdString(serial));
|
||||
}
|
||||
}
|
||||
|
||||
void OpenPandaWidget::buildConfigForm() {
|
||||
clearLayout(config_layout);
|
||||
QString serial = serial_edit->currentText();
|
||||
|
||||
bool has_fd = false;
|
||||
bool has_panda = !serial.isEmpty();
|
||||
|
||||
if (has_panda) {
|
||||
try {
|
||||
Panda panda = Panda(serial.toStdString());
|
||||
has_fd = (panda.hw_type == cereal::PandaState::PandaType::RED_PANDA) || (panda.hw_type == cereal::PandaState::PandaType::RED_PANDA_V2);
|
||||
} catch (const std::exception& e) {
|
||||
has_panda = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (has_panda) {
|
||||
config.serial = serial;
|
||||
config.bus_config.resize(3);
|
||||
for (int i = 0; i < config.bus_config.size(); i++) {
|
||||
QHBoxLayout *bus_layout = new QHBoxLayout;
|
||||
|
||||
// CAN Speed
|
||||
bus_layout->addWidget(new QLabel(tr("CAN Speed (kbps):")));
|
||||
QComboBox *can_speed = new QComboBox;
|
||||
for (int j = 0; j < std::size(speeds); j++) {
|
||||
can_speed->addItem(QString::number(speeds[j]));
|
||||
|
||||
if (data_speeds[j] == config.bus_config[i].can_speed_kbps) {
|
||||
can_speed->setCurrentIndex(j);
|
||||
}
|
||||
}
|
||||
QObject::connect(can_speed, qOverload<int>(&QComboBox::currentIndexChanged), [=](int index) {config.bus_config[i].can_speed_kbps = speeds[index];});
|
||||
bus_layout->addWidget(can_speed);
|
||||
|
||||
// CAN-FD Speed
|
||||
if (has_fd) {
|
||||
QCheckBox *enable_fd = new QCheckBox("CAN-FD");
|
||||
bus_layout->addWidget(enable_fd);
|
||||
bus_layout->addWidget(new QLabel(tr("Data Speed (kbps):")));
|
||||
QComboBox *data_speed = new QComboBox;
|
||||
for (int j = 0; j < std::size(data_speeds); j++) {
|
||||
data_speed->addItem(QString::number(data_speeds[j]));
|
||||
|
||||
if (data_speeds[j] == config.bus_config[i].data_speed_kbps) {
|
||||
data_speed->setCurrentIndex(j);
|
||||
}
|
||||
}
|
||||
|
||||
data_speed->setEnabled(false);
|
||||
bus_layout->addWidget(data_speed);
|
||||
|
||||
QObject::connect(data_speed, qOverload<int>(&QComboBox::currentIndexChanged), [=](int index) {config.bus_config[i].data_speed_kbps = data_speeds[index];});
|
||||
QObject::connect(enable_fd, &QCheckBox::stateChanged, data_speed, &QComboBox::setEnabled);
|
||||
QObject::connect(enable_fd, &QCheckBox::stateChanged, [=](int state) {config.bus_config[i].can_fd = (bool)state;});
|
||||
}
|
||||
|
||||
config_layout->addRow(tr("Bus %1:").arg(i), bus_layout);
|
||||
}
|
||||
} else {
|
||||
config.serial = "";
|
||||
config_layout->addWidget(new QLabel(tr("No panda found")));
|
||||
}
|
||||
}
|
||||
|
||||
bool OpenPandaWidget::open() {
|
||||
try {
|
||||
*stream = new PandaStream(qApp, config);
|
||||
} catch (std::exception &e) {
|
||||
QMessageBox::warning(nullptr, tr("Warning"), tr("Failed to connect to panda: '%1'").arg(e.what()));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
52
tools/cabana/streams/pandastream.h
Normal file
52
tools/cabana/streams/pandastream.h
Normal file
@@ -0,0 +1,52 @@
|
||||
#pragma once
|
||||
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
#include <QComboBox>
|
||||
#include <QFormLayout>
|
||||
|
||||
#include "tools/cabana/streams/livestream.h"
|
||||
#include "selfdrive/boardd/panda.h"
|
||||
|
||||
const uint32_t speeds[] = {10U, 20U, 50U, 100U, 125U, 250U, 500U, 1000U};
|
||||
const uint32_t data_speeds[] = {10U, 20U, 50U, 100U, 125U, 250U, 500U, 1000U, 2000U, 5000U};
|
||||
|
||||
struct PandaStreamConfig {
|
||||
QString serial = "";
|
||||
std::vector<BusConfig> bus_config;
|
||||
};
|
||||
|
||||
class PandaStream : public LiveStream {
|
||||
Q_OBJECT
|
||||
public:
|
||||
PandaStream(QObject *parent, PandaStreamConfig config_ = {});
|
||||
static AbstractOpenStreamWidget *widget(AbstractStream **stream);
|
||||
inline QString routeName() const override {
|
||||
return QString("Live Streaming From Panda %1").arg(config.serial);
|
||||
}
|
||||
|
||||
protected:
|
||||
void streamThread() override;
|
||||
bool connect();
|
||||
|
||||
std::unique_ptr<Panda> panda;
|
||||
PandaStreamConfig config = {};
|
||||
};
|
||||
|
||||
class OpenPandaWidget : public AbstractOpenStreamWidget {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
OpenPandaWidget(AbstractStream **stream);
|
||||
bool open() override;
|
||||
QString title() override { return tr("&Panda"); }
|
||||
|
||||
private:
|
||||
void refreshSerials();
|
||||
void buildConfigForm();
|
||||
|
||||
QComboBox *serial_edit;
|
||||
QFormLayout *config_layout;
|
||||
PandaStreamConfig config = {};
|
||||
};
|
||||
145
tools/cabana/streams/replaystream.cc
Normal file
145
tools/cabana/streams/replaystream.cc
Normal file
@@ -0,0 +1,145 @@
|
||||
#include "tools/cabana/streams/replaystream.h"
|
||||
|
||||
#include <QLabel>
|
||||
#include <QFileDialog>
|
||||
#include <QGridLayout>
|
||||
#include <QMessageBox>
|
||||
#include <QPushButton>
|
||||
|
||||
ReplayStream::ReplayStream(QObject *parent) : AbstractStream(parent) {
|
||||
unsetenv("ZMQ");
|
||||
setenv("COMMA_CACHE", "/tmp/comma_download_cache", 1);
|
||||
|
||||
// TODO: Remove when OpenpilotPrefix supports ZMQ
|
||||
#ifndef __APPLE__
|
||||
op_prefix = std::make_unique<OpenpilotPrefix>();
|
||||
#endif
|
||||
|
||||
QObject::connect(&settings, &Settings::changed, this, [this]() {
|
||||
if (replay) replay->setSegmentCacheLimit(settings.max_cached_minutes);
|
||||
});
|
||||
}
|
||||
|
||||
static bool event_filter(const Event *e, void *opaque) {
|
||||
return ((ReplayStream *)opaque)->eventFilter(e);
|
||||
}
|
||||
|
||||
void ReplayStream::mergeSegments() {
|
||||
for (auto &[n, seg] : replay->segments()) {
|
||||
if (seg && seg->isLoaded() && !processed_segments.count(n)) {
|
||||
processed_segments.insert(n);
|
||||
|
||||
std::vector<const CanEvent *> new_events;
|
||||
new_events.reserve(seg->log->events.size());
|
||||
for (auto it = seg->log->events.cbegin(); it != seg->log->events.cend(); ++it) {
|
||||
if ((*it)->which == cereal::Event::Which::CAN) {
|
||||
const uint64_t ts = (*it)->mono_time;
|
||||
for (const auto &c : (*it)->event.getCan()) {
|
||||
new_events.push_back(newEvent(ts, c));
|
||||
}
|
||||
}
|
||||
}
|
||||
mergeEvents(new_events);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool ReplayStream::loadRoute(const QString &route, const QString &data_dir, uint32_t replay_flags) {
|
||||
replay.reset(new Replay(route, {"can", "roadEncodeIdx", "driverEncodeIdx", "wideRoadEncodeIdx", "carParams"},
|
||||
{}, nullptr, replay_flags, data_dir, this));
|
||||
replay->setSegmentCacheLimit(settings.max_cached_minutes);
|
||||
replay->installEventFilter(event_filter, this);
|
||||
QObject::connect(replay.get(), &Replay::seekedTo, this, &AbstractStream::seekedTo);
|
||||
QObject::connect(replay.get(), &Replay::segmentsMerged, this, &ReplayStream::mergeSegments);
|
||||
QObject::connect(replay.get(), &Replay::qLogLoaded, this, &ReplayStream::qLogLoaded, Qt::QueuedConnection);
|
||||
return replay->load();
|
||||
}
|
||||
|
||||
void ReplayStream::start() {
|
||||
emit streamStarted();
|
||||
replay->start();
|
||||
}
|
||||
|
||||
bool ReplayStream::eventFilter(const Event *event) {
|
||||
static double prev_update_ts = 0;
|
||||
if (event->which == cereal::Event::Which::CAN) {
|
||||
double current_sec = event->mono_time / 1e9 - routeStartTime();
|
||||
for (const auto &c : event->event.getCan()) {
|
||||
MessageId id = {.source = c.getSrc(), .address = c.getAddress()};
|
||||
const auto dat = c.getDat();
|
||||
updateEvent(id, current_sec, (const uint8_t*)dat.begin(), dat.size());
|
||||
}
|
||||
}
|
||||
|
||||
double ts = millis_since_boot();
|
||||
if ((ts - prev_update_ts) > (1000.0 / settings.fps)) {
|
||||
emit privateUpdateLastMsgsSignal();
|
||||
prev_update_ts = ts;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void ReplayStream::pause(bool pause) {
|
||||
replay->pause(pause);
|
||||
emit(pause ? paused() : resume());
|
||||
}
|
||||
|
||||
|
||||
AbstractOpenStreamWidget *ReplayStream::widget(AbstractStream **stream) {
|
||||
return new OpenReplayWidget(stream);
|
||||
}
|
||||
|
||||
// OpenReplayWidget
|
||||
|
||||
OpenReplayWidget::OpenReplayWidget(AbstractStream **stream) : AbstractOpenStreamWidget(stream) {
|
||||
// TODO: get route list from api.comma.ai
|
||||
QGridLayout *grid_layout = new QGridLayout(this);
|
||||
grid_layout->addWidget(new QLabel(tr("Route")), 0, 0);
|
||||
grid_layout->addWidget(route_edit = new QLineEdit(this), 0, 1);
|
||||
route_edit->setPlaceholderText(tr("Enter remote route name or click browse to select a local route"));
|
||||
auto file_btn = new QPushButton(tr("Browse..."), this);
|
||||
grid_layout->addWidget(file_btn, 0, 2);
|
||||
|
||||
grid_layout->addWidget(new QLabel(tr("Camera")), 1, 0);
|
||||
QHBoxLayout *camera_layout = new QHBoxLayout();
|
||||
for (auto c : {tr("Road camera"), tr("Driver camera"), tr("Wide road camera")})
|
||||
camera_layout->addWidget(cameras.emplace_back(new QCheckBox(c, this)));
|
||||
camera_layout->addStretch(1);
|
||||
grid_layout->addItem(camera_layout, 1, 1);
|
||||
|
||||
setMinimumWidth(550);
|
||||
QObject::connect(file_btn, &QPushButton::clicked, [=]() {
|
||||
QString dir = QFileDialog::getExistingDirectory(this, tr("Open Local Route"), settings.last_route_dir);
|
||||
if (!dir.isEmpty()) {
|
||||
route_edit->setText(dir);
|
||||
settings.last_route_dir = QFileInfo(dir).absolutePath();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
bool OpenReplayWidget::open() {
|
||||
QString route = route_edit->text();
|
||||
QString data_dir;
|
||||
if (int idx = route.lastIndexOf('/'); idx != -1) {
|
||||
data_dir = route.mid(0, idx + 1);
|
||||
route = route.mid(idx + 1);
|
||||
}
|
||||
|
||||
bool is_valid_format = Route::parseRoute(route).str.size() > 0;
|
||||
if (!is_valid_format) {
|
||||
QMessageBox::warning(nullptr, tr("Warning"), tr("Invalid route format: '%1'").arg(route));
|
||||
} else {
|
||||
auto replay_stream = std::make_unique<ReplayStream>(qApp);
|
||||
uint32_t flags = REPLAY_FLAG_NONE;
|
||||
if (cameras[1]->isChecked()) flags |= REPLAY_FLAG_DCAM;
|
||||
if (cameras[2]->isChecked()) flags |= REPLAY_FLAG_ECAM;
|
||||
if (flags == REPLAY_FLAG_NONE && !cameras[0]->isChecked()) flags = REPLAY_FLAG_NO_VIPC;
|
||||
|
||||
if (replay_stream->loadRoute(route, data_dir, flags)) {
|
||||
*stream = replay_stream.release();
|
||||
} else {
|
||||
QMessageBox::warning(nullptr, tr("Warning"), tr("Failed to load route: '%1'").arg(route));
|
||||
}
|
||||
}
|
||||
return *stream != nullptr;
|
||||
}
|
||||
57
tools/cabana/streams/replaystream.h
Normal file
57
tools/cabana/streams/replaystream.h
Normal file
@@ -0,0 +1,57 @@
|
||||
#pragma once
|
||||
|
||||
#include <QCheckBox>
|
||||
#include <algorithm>
|
||||
#include <memory>
|
||||
#include <set>
|
||||
#include <vector>
|
||||
|
||||
#include "common/prefix.h"
|
||||
#include "tools/cabana/streams/abstractstream.h"
|
||||
#include "tools/replay/replay.h"
|
||||
|
||||
class ReplayStream : public AbstractStream {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
ReplayStream(QObject *parent);
|
||||
void start() override;
|
||||
bool loadRoute(const QString &route, const QString &data_dir, uint32_t replay_flags = REPLAY_FLAG_NONE);
|
||||
bool eventFilter(const Event *event);
|
||||
void seekTo(double ts) override { replay->seekTo(std::max(double(0), ts), false); }
|
||||
bool liveStreaming() const override { return false; }
|
||||
inline QString routeName() const override { return replay->route()->name(); }
|
||||
inline QString carFingerprint() const override { return replay->carFingerprint().c_str(); }
|
||||
double totalSeconds() const override { return replay->totalSeconds(); }
|
||||
inline QDateTime beginDateTime() const { return replay->route()->datetime(); }
|
||||
inline double routeStartTime() const override { return replay->routeStartTime() / (double)1e9; }
|
||||
inline const Route *route() const { return replay->route(); }
|
||||
inline void setSpeed(float speed) override { replay->setSpeed(speed); }
|
||||
inline float getSpeed() const { return replay->getSpeed(); }
|
||||
inline Replay *getReplay() const { return replay.get(); }
|
||||
inline bool isPaused() const override { return replay->isPaused(); }
|
||||
void pause(bool pause) override;
|
||||
static AbstractOpenStreamWidget *widget(AbstractStream **stream);
|
||||
|
||||
signals:
|
||||
void qLogLoaded(int segnum, std::shared_ptr<LogReader> qlog);
|
||||
|
||||
private:
|
||||
void mergeSegments();
|
||||
std::unique_ptr<Replay> replay = nullptr;
|
||||
std::set<int> processed_segments;
|
||||
std::unique_ptr<OpenpilotPrefix> op_prefix;
|
||||
};
|
||||
|
||||
class OpenReplayWidget : public AbstractOpenStreamWidget {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
OpenReplayWidget(AbstractStream **stream);
|
||||
bool open() override;
|
||||
QString title() override { return tr("&Replay"); }
|
||||
|
||||
private:
|
||||
QLineEdit *route_edit;
|
||||
std::vector<QCheckBox *> cameras;
|
||||
};
|
||||
115
tools/cabana/streams/socketcanstream.cc
Normal file
115
tools/cabana/streams/socketcanstream.cc
Normal file
@@ -0,0 +1,115 @@
|
||||
#include "tools/cabana/streams/socketcanstream.h"
|
||||
|
||||
#include <QDebug>
|
||||
#include <QFormLayout>
|
||||
#include <QHBoxLayout>
|
||||
#include <QMessageBox>
|
||||
#include <QPushButton>
|
||||
#include <QThread>
|
||||
|
||||
SocketCanStream::SocketCanStream(QObject *parent, SocketCanStreamConfig config_) : config(config_), LiveStream(parent) {
|
||||
if (!available()) {
|
||||
throw std::runtime_error("SocketCAN plugin not available");
|
||||
}
|
||||
|
||||
qDebug() << "Connecting to SocketCAN device" << config.device;
|
||||
if (!connect()) {
|
||||
throw std::runtime_error("Failed to connect to SocketCAN device");
|
||||
}
|
||||
}
|
||||
|
||||
bool SocketCanStream::available() {
|
||||
return QCanBus::instance()->plugins().contains("socketcan");
|
||||
}
|
||||
|
||||
bool SocketCanStream::connect() {
|
||||
// Connecting might generate some warnings about missing socketcan/libsocketcan libraries
|
||||
// These are expected and can be ignored, we don't need the advanced features of libsocketcan
|
||||
QString errorString;
|
||||
device.reset(QCanBus::instance()->createDevice("socketcan", config.device, &errorString));
|
||||
|
||||
if (!device) {
|
||||
qDebug() << "Failed to create SocketCAN device" << errorString;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!device->connectDevice()) {
|
||||
qDebug() << "Failed to connect to device";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void SocketCanStream::streamThread() {
|
||||
while (!QThread::currentThread()->isInterruptionRequested()) {
|
||||
QThread::msleep(1);
|
||||
|
||||
auto frames = device->readAllFrames();
|
||||
if (frames.size() == 0) continue;
|
||||
|
||||
MessageBuilder msg;
|
||||
auto evt = msg.initEvent();
|
||||
auto canData = evt.initCan(frames.size());
|
||||
|
||||
for (uint i = 0; i < frames.size(); i++) {
|
||||
if (!frames[i].isValid()) continue;
|
||||
|
||||
canData[i].setAddress(frames[i].frameId());
|
||||
canData[i].setSrc(0);
|
||||
|
||||
auto payload = frames[i].payload();
|
||||
canData[i].setDat(kj::arrayPtr((uint8_t*)payload.data(), payload.size()));
|
||||
}
|
||||
|
||||
handleEvent(capnp::messageToFlatArray(msg));
|
||||
}
|
||||
}
|
||||
|
||||
AbstractOpenStreamWidget *SocketCanStream::widget(AbstractStream **stream) {
|
||||
return new OpenSocketCanWidget(stream);
|
||||
}
|
||||
|
||||
OpenSocketCanWidget::OpenSocketCanWidget(AbstractStream **stream) : AbstractOpenStreamWidget(stream) {
|
||||
QVBoxLayout *main_layout = new QVBoxLayout(this);
|
||||
main_layout->addStretch(1);
|
||||
|
||||
QFormLayout *form_layout = new QFormLayout();
|
||||
|
||||
QHBoxLayout *device_layout = new QHBoxLayout();
|
||||
device_edit = new QComboBox();
|
||||
device_edit->setFixedWidth(300);
|
||||
device_layout->addWidget(device_edit);
|
||||
|
||||
QPushButton *refresh = new QPushButton(tr("Refresh"));
|
||||
refresh->setFixedWidth(100);
|
||||
device_layout->addWidget(refresh);
|
||||
form_layout->addRow(tr("Device"), device_layout);
|
||||
main_layout->addLayout(form_layout);
|
||||
|
||||
main_layout->addStretch(1);
|
||||
|
||||
QObject::connect(refresh, &QPushButton::clicked, this, &OpenSocketCanWidget::refreshDevices);
|
||||
QObject::connect(device_edit, &QComboBox::currentTextChanged, this, [=]{ config.device = device_edit->currentText(); });
|
||||
|
||||
// Populate devices
|
||||
refreshDevices();
|
||||
}
|
||||
|
||||
void OpenSocketCanWidget::refreshDevices() {
|
||||
device_edit->clear();
|
||||
for (auto device : QCanBus::instance()->availableDevices(QStringLiteral("socketcan"))) {
|
||||
device_edit->addItem(device.name());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
bool OpenSocketCanWidget::open() {
|
||||
try {
|
||||
*stream = new SocketCanStream(qApp, config);
|
||||
} catch (std::exception &e) {
|
||||
QMessageBox::warning(nullptr, tr("Warning"), tr("Failed to connect to SocketCAN device: '%1'").arg(e.what()));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
48
tools/cabana/streams/socketcanstream.h
Normal file
48
tools/cabana/streams/socketcanstream.h
Normal file
@@ -0,0 +1,48 @@
|
||||
#pragma once
|
||||
|
||||
#include <memory>
|
||||
|
||||
#include <QtSerialBus/QCanBus>
|
||||
#include <QtSerialBus/QCanBusDevice>
|
||||
#include <QtSerialBus/QCanBusDeviceInfo>
|
||||
#include <QComboBox>
|
||||
|
||||
#include "tools/cabana/streams/livestream.h"
|
||||
|
||||
struct SocketCanStreamConfig {
|
||||
QString device = ""; // TODO: support multiple devices/buses at once
|
||||
};
|
||||
|
||||
class SocketCanStream : public LiveStream {
|
||||
Q_OBJECT
|
||||
public:
|
||||
SocketCanStream(QObject *parent, SocketCanStreamConfig config_ = {});
|
||||
static AbstractOpenStreamWidget *widget(AbstractStream **stream);
|
||||
static bool available();
|
||||
|
||||
inline QString routeName() const override {
|
||||
return QString("Live Streaming From Socket CAN %1").arg(config.device);
|
||||
}
|
||||
|
||||
protected:
|
||||
void streamThread() override;
|
||||
bool connect();
|
||||
|
||||
SocketCanStreamConfig config = {};
|
||||
std::unique_ptr<QCanBusDevice> device;
|
||||
};
|
||||
|
||||
class OpenSocketCanWidget : public AbstractOpenStreamWidget {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
OpenSocketCanWidget(AbstractStream **stream);
|
||||
bool open() override;
|
||||
QString title() override { return tr("&SocketCAN"); }
|
||||
|
||||
private:
|
||||
void refreshDevices();
|
||||
|
||||
QComboBox *device_edit;
|
||||
SocketCanStreamConfig config = {};
|
||||
};
|
||||
71
tools/cabana/streamselector.cc
Normal file
71
tools/cabana/streamselector.cc
Normal file
@@ -0,0 +1,71 @@
|
||||
#include "tools/cabana/streamselector.h"
|
||||
|
||||
#include <QDialogButtonBox>
|
||||
#include <QFileDialog>
|
||||
#include <QLabel>
|
||||
#include <QPushButton>
|
||||
|
||||
#include "streams/socketcanstream.h"
|
||||
#include "tools/cabana/streams/devicestream.h"
|
||||
#include "tools/cabana/streams/pandastream.h"
|
||||
#include "tools/cabana/streams/replaystream.h"
|
||||
#include "tools/cabana/streams/socketcanstream.h"
|
||||
|
||||
StreamSelector::StreamSelector(AbstractStream **stream, QWidget *parent) : QDialog(parent) {
|
||||
setWindowTitle(tr("Open stream"));
|
||||
QVBoxLayout *main_layout = new QVBoxLayout(this);
|
||||
|
||||
QWidget *w = new QWidget(this);
|
||||
QVBoxLayout *layout = new QVBoxLayout(w);
|
||||
tab = new QTabWidget(this);
|
||||
tab->setTabBarAutoHide(true);
|
||||
layout->addWidget(tab);
|
||||
|
||||
QHBoxLayout *dbc_layout = new QHBoxLayout();
|
||||
dbc_file = new QLineEdit(this);
|
||||
dbc_file->setReadOnly(true);
|
||||
dbc_file->setPlaceholderText(tr("Choose a dbc file to open"));
|
||||
QPushButton *file_btn = new QPushButton(tr("Browse..."));
|
||||
dbc_layout->addWidget(new QLabel(tr("dbc File")));
|
||||
dbc_layout->addWidget(dbc_file);
|
||||
dbc_layout->addWidget(file_btn);
|
||||
layout->addLayout(dbc_layout);
|
||||
|
||||
QFrame *line = new QFrame(this);
|
||||
line->setFrameStyle(QFrame::HLine | QFrame::Sunken);
|
||||
layout->addWidget(line);
|
||||
|
||||
main_layout->addWidget(w);
|
||||
auto btn_box = new QDialogButtonBox(QDialogButtonBox::Open | QDialogButtonBox::Cancel);
|
||||
main_layout->addWidget(btn_box);
|
||||
|
||||
addStreamWidget(ReplayStream::widget(stream));
|
||||
addStreamWidget(PandaStream::widget(stream));
|
||||
if (SocketCanStream::available()) {
|
||||
addStreamWidget(SocketCanStream::widget(stream));
|
||||
}
|
||||
addStreamWidget(DeviceStream::widget(stream));
|
||||
|
||||
QObject::connect(btn_box, &QDialogButtonBox::rejected, this, &QDialog::reject);
|
||||
QObject::connect(btn_box, &QDialogButtonBox::accepted, [=]() {
|
||||
btn_box->button(QDialogButtonBox::Open)->setEnabled(false);
|
||||
w->setEnabled(false);
|
||||
if (((AbstractOpenStreamWidget *)tab->currentWidget())->open()) {
|
||||
accept();
|
||||
} else {
|
||||
btn_box->button(QDialogButtonBox::Open)->setEnabled(true);
|
||||
w->setEnabled(true);
|
||||
}
|
||||
});
|
||||
QObject::connect(file_btn, &QPushButton::clicked, [this]() {
|
||||
QString fn = QFileDialog::getOpenFileName(this, tr("Open File"), settings.last_dir, "DBC (*.dbc)");
|
||||
if (!fn.isEmpty()) {
|
||||
dbc_file->setText(fn);
|
||||
settings.last_dir = QFileInfo(fn).absolutePath();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void StreamSelector::addStreamWidget(AbstractOpenStreamWidget *w) {
|
||||
tab->addTab(w, w->title());
|
||||
}
|
||||
20
tools/cabana/streamselector.h
Normal file
20
tools/cabana/streamselector.h
Normal file
@@ -0,0 +1,20 @@
|
||||
#pragma once
|
||||
|
||||
#include <QDialog>
|
||||
#include <QLineEdit>
|
||||
#include <QTabWidget>
|
||||
|
||||
#include "tools/cabana/streams/abstractstream.h"
|
||||
|
||||
class StreamSelector : public QDialog {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
StreamSelector(AbstractStream **stream, QWidget *parent = nullptr);
|
||||
void addStreamWidget(AbstractOpenStreamWidget *w);
|
||||
QString dbcFile() const { return dbc_file->text(); }
|
||||
|
||||
private:
|
||||
QLineEdit *dbc_file;
|
||||
QTabWidget *tab;
|
||||
};
|
||||
87
tools/cabana/tests/test_cabana.cc
Normal file
87
tools/cabana/tests/test_cabana.cc
Normal file
@@ -0,0 +1,87 @@
|
||||
|
||||
#undef INFO
|
||||
#include "catch2/catch.hpp"
|
||||
#include "tools/replay/logreader.h"
|
||||
#include "tools/cabana/dbc/dbcmanager.h"
|
||||
#include "tools/cabana/streams/abstractstream.h"
|
||||
|
||||
const std::string TEST_RLOG_URL = "https://commadataci.blob.core.windows.net/openpilotci/0c94aa1e1296d7c6/2021-05-05--19-48-37/0/rlog.bz2";
|
||||
|
||||
TEST_CASE("DBCFile::generateDBC") {
|
||||
QString fn = QString("%1/%2.dbc").arg(OPENDBC_FILE_PATH, "tesla_can");
|
||||
DBCFile dbc_origin(fn);
|
||||
DBCFile dbc_from_generated("", dbc_origin.generateDBC());
|
||||
|
||||
REQUIRE(dbc_origin.msgCount() == dbc_from_generated.msgCount());
|
||||
auto &msgs = dbc_origin.getMessages();
|
||||
auto &new_msgs = dbc_from_generated.getMessages();
|
||||
for (auto &[id, m] : msgs) {
|
||||
auto &new_m = new_msgs.at(id);
|
||||
REQUIRE(m.name == new_m.name);
|
||||
REQUIRE(m.size == new_m.size);
|
||||
REQUIRE(m.getSignals().size() == new_m.getSignals().size());
|
||||
auto sigs = m.getSignals();
|
||||
auto new_sigs = new_m.getSignals();
|
||||
for (int i = 0; i < sigs.size(); ++i) {
|
||||
REQUIRE(*sigs[i] == *new_sigs[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("parse_dbc") {
|
||||
QString content = R"(
|
||||
BO_ 160 message_1: 8 EON
|
||||
SG_ signal_1 : 0|12@1+ (1,0) [0|4095] "unit" XXX
|
||||
SG_ signal_2 : 12|1@1+ (1.0,0.0) [0.0|1] "" XXX
|
||||
|
||||
BO_ 162 message_1: 8 XXX
|
||||
SG_ signal_1 M : 0|12@1+ (1,0) [0|4095] "unit" XXX
|
||||
SG_ signal_2 M4 : 12|1@1+ (1.0,0.0) [0.0|1] "" XXX
|
||||
|
||||
VAL_ 160 signal_1 0 "disabled" 1.2 "initializing" 2 "fault";
|
||||
|
||||
CM_ BO_ 160 "message comment" ;
|
||||
CM_ SG_ 160 signal_1 "signal comment";
|
||||
CM_ SG_ 160 signal_2 "multiple line comment
|
||||
1
|
||||
2
|
||||
";)";
|
||||
|
||||
DBCFile file("", content);
|
||||
auto msg = file.msg(160);
|
||||
REQUIRE(msg != nullptr);
|
||||
REQUIRE(msg->name == "message_1");
|
||||
REQUIRE(msg->size == 8);
|
||||
REQUIRE(msg->comment == "message comment");
|
||||
REQUIRE(msg->sigs.size() == 2);
|
||||
REQUIRE(msg->transmitter == "EON");
|
||||
REQUIRE(file.msg("message_1") != nullptr);
|
||||
|
||||
auto sig_1 = msg->sigs[0];
|
||||
REQUIRE(sig_1->name == "signal_1");
|
||||
REQUIRE(sig_1->start_bit == 0);
|
||||
REQUIRE(sig_1->size == 12);
|
||||
REQUIRE(sig_1->min == 0);
|
||||
REQUIRE(sig_1->max == 4095);
|
||||
REQUIRE(sig_1->unit == "unit");
|
||||
REQUIRE(sig_1->comment == "signal comment");
|
||||
REQUIRE(sig_1->receiver_name == "XXX");
|
||||
REQUIRE(sig_1->val_desc.size() == 3);
|
||||
REQUIRE(sig_1->val_desc[0] == std::pair<double, QString>{0, "disabled"});
|
||||
REQUIRE(sig_1->val_desc[1] == std::pair<double, QString>{1.2, "initializing"});
|
||||
REQUIRE(sig_1->val_desc[2] == std::pair<double, QString>{2, "fault"});
|
||||
|
||||
auto &sig_2 = msg->sigs[1];
|
||||
REQUIRE(sig_2->comment == "multiple line comment \n1\n2");
|
||||
|
||||
// multiplexed signals
|
||||
msg = file.msg(162);
|
||||
REQUIRE(msg != nullptr);
|
||||
REQUIRE(msg->sigs.size() == 2);
|
||||
REQUIRE(msg->sigs[0]->type == cabana::Signal::Type::Multiplexor);
|
||||
REQUIRE(msg->sigs[1]->type == cabana::Signal::Type::Multiplexed);
|
||||
REQUIRE(msg->sigs[1]->multiplex_value == 4);
|
||||
REQUIRE(msg->sigs[1]->start_bit == 12);
|
||||
REQUIRE(msg->sigs[1]->size == 1);
|
||||
REQUIRE(msg->sigs[1]->receiver_name == "XXX");
|
||||
}
|
||||
10
tools/cabana/tests/test_runner.cc
Normal file
10
tools/cabana/tests/test_runner.cc
Normal file
@@ -0,0 +1,10 @@
|
||||
#define CATCH_CONFIG_RUNNER
|
||||
#include "catch2/catch.hpp"
|
||||
#include <QCoreApplication>
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
// unit tests for Qt
|
||||
QCoreApplication app(argc, argv);
|
||||
const int res = Catch::Session().run(argc, argv);
|
||||
return (res < 0xff ? res : 0xff);
|
||||
}
|
||||
269
tools/cabana/tools/findsignal.cc
Normal file
269
tools/cabana/tools/findsignal.cc
Normal file
@@ -0,0 +1,269 @@
|
||||
#include "tools/cabana/tools/findsignal.h"
|
||||
|
||||
#include <QFormLayout>
|
||||
#include <QHBoxLayout>
|
||||
#include <QHeaderView>
|
||||
#include <QMenu>
|
||||
#include <QtConcurrent>
|
||||
#include <QTimer>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
// FindSignalModel
|
||||
|
||||
QVariant FindSignalModel::headerData(int section, Qt::Orientation orientation, int role) const {
|
||||
static QString titles[] = {"Id", "Start Bit, size", "(time, value)"};
|
||||
if (role != Qt::DisplayRole) return {};
|
||||
return orientation == Qt::Horizontal ? titles[section] : QString::number(section + 1);
|
||||
}
|
||||
|
||||
QVariant FindSignalModel::data(const QModelIndex &index, int role) const {
|
||||
if (role == Qt::DisplayRole) {
|
||||
const auto &s = filtered_signals[index.row()];
|
||||
switch (index.column()) {
|
||||
case 0: return s.id.toString();
|
||||
case 1: return QString("%1, %2").arg(s.sig.start_bit).arg(s.sig.size);
|
||||
case 2: return s.values.join(" ");
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
void FindSignalModel::search(std::function<bool(double)> cmp) {
|
||||
beginResetModel();
|
||||
|
||||
std::mutex lock;
|
||||
const auto prev_sigs = !histories.isEmpty() ? histories.back() : initial_signals;
|
||||
filtered_signals.clear();
|
||||
filtered_signals.reserve(prev_sigs.size());
|
||||
QtConcurrent::blockingMap(prev_sigs, [&](auto &s) {
|
||||
const auto &events = can->events(s.id);
|
||||
auto first = std::upper_bound(events.cbegin(), events.cend(), s.mono_time, CompareCanEvent());
|
||||
auto last = events.cend();
|
||||
if (last_time < std::numeric_limits<uint64_t>::max()) {
|
||||
last = std::upper_bound(events.cbegin(), events.cend(), last_time, CompareCanEvent());
|
||||
}
|
||||
|
||||
auto it = std::find_if(first, last, [&](const CanEvent *e) { return cmp(get_raw_value(e->dat, e->size, s.sig)); });
|
||||
if (it != last) {
|
||||
auto values = s.values;
|
||||
values += QString("(%1, %2)").arg((*it)->mono_time / 1e9 - can->routeStartTime(), 0, 'f', 2).arg(get_raw_value((*it)->dat, (*it)->size, s.sig));
|
||||
std::lock_guard lk(lock);
|
||||
filtered_signals.push_back({.id = s.id, .mono_time = (*it)->mono_time, .sig = s.sig, .values = values});
|
||||
}
|
||||
});
|
||||
histories.push_back(filtered_signals);
|
||||
|
||||
endResetModel();
|
||||
}
|
||||
|
||||
void FindSignalModel::undo() {
|
||||
if (!histories.isEmpty()) {
|
||||
beginResetModel();
|
||||
histories.pop_back();
|
||||
filtered_signals.clear();
|
||||
if (!histories.isEmpty()) filtered_signals = histories.back();
|
||||
endResetModel();
|
||||
}
|
||||
}
|
||||
|
||||
void FindSignalModel::reset() {
|
||||
beginResetModel();
|
||||
histories.clear();
|
||||
filtered_signals.clear();
|
||||
initial_signals.clear();
|
||||
endResetModel();
|
||||
}
|
||||
|
||||
// FindSignalDlg
|
||||
FindSignalDlg::FindSignalDlg(QWidget *parent) : QDialog(parent, Qt::WindowFlags() | Qt::Window) {
|
||||
setWindowTitle(tr("Find Signal"));
|
||||
setAttribute(Qt::WA_DeleteOnClose);
|
||||
QVBoxLayout *main_layout = new QVBoxLayout(this);
|
||||
|
||||
// Messages group
|
||||
message_group = new QGroupBox(tr("Messages"), this);
|
||||
QFormLayout *message_layout = new QFormLayout(message_group);
|
||||
message_layout->addRow(tr("Bus"), bus_edit = new QLineEdit());
|
||||
bus_edit->setPlaceholderText(tr("comma-seperated values. Leave blank for all"));
|
||||
message_layout->addRow(tr("Address"), address_edit = new QLineEdit());
|
||||
address_edit->setPlaceholderText(tr("comma-seperated hex values. Leave blank for all"));
|
||||
QHBoxLayout *hlayout = new QHBoxLayout();
|
||||
hlayout->addWidget(first_time_edit = new QLineEdit("0"));
|
||||
hlayout->addWidget(new QLabel("-"));
|
||||
hlayout->addWidget(last_time_edit = new QLineEdit("MAX"));
|
||||
hlayout->addWidget(new QLabel("seconds"));
|
||||
hlayout->addStretch(0);
|
||||
message_layout->addRow(tr("Time"), hlayout);
|
||||
|
||||
// Signal group
|
||||
properties_group = new QGroupBox(tr("Signal"));
|
||||
QFormLayout *property_layout = new QFormLayout(properties_group);
|
||||
property_layout->setFieldGrowthPolicy(QFormLayout::FieldsStayAtSizeHint);
|
||||
|
||||
hlayout = new QHBoxLayout();
|
||||
hlayout->addWidget(min_size = new QSpinBox);
|
||||
hlayout->addWidget(new QLabel("-"));
|
||||
hlayout->addWidget(max_size = new QSpinBox);
|
||||
hlayout->addWidget(litter_endian = new QCheckBox(tr("Little endian")));
|
||||
hlayout->addWidget(is_signed = new QCheckBox(tr("Signed")));
|
||||
hlayout->addStretch(0);
|
||||
min_size->setRange(1, 64);
|
||||
max_size->setRange(1, 64);
|
||||
min_size->setValue(8);
|
||||
max_size->setValue(8);
|
||||
litter_endian->setChecked(true);
|
||||
property_layout->addRow(tr("Size"), hlayout);
|
||||
property_layout->addRow(tr("Factor"), factor_edit = new QLineEdit("1.0"));
|
||||
property_layout->addRow(tr("Offset"), offset_edit = new QLineEdit("0.0"));
|
||||
|
||||
// find group
|
||||
QGroupBox *find_group = new QGroupBox(tr("Find signal"), this);
|
||||
QVBoxLayout *vlayout = new QVBoxLayout(find_group);
|
||||
hlayout = new QHBoxLayout();
|
||||
hlayout->addWidget(new QLabel(tr("Value")));
|
||||
hlayout->addWidget(compare_cb = new QComboBox(this));
|
||||
hlayout->addWidget(value1 = new QLineEdit);
|
||||
hlayout->addWidget(to_label = new QLabel("-"));
|
||||
hlayout->addWidget(value2 = new QLineEdit);
|
||||
hlayout->addWidget(undo_btn = new QPushButton(tr("Undo prev find"), this));
|
||||
hlayout->addWidget(search_btn = new QPushButton(tr("Find")));
|
||||
hlayout->addWidget(reset_btn = new QPushButton(tr("Reset"), this));
|
||||
vlayout->addLayout(hlayout);
|
||||
|
||||
compare_cb->addItems({"=", ">", ">=", "!=", "<", "<=", "between"});
|
||||
value1->setFocus(Qt::OtherFocusReason);
|
||||
value2->setVisible(false);
|
||||
to_label->setVisible(false);
|
||||
undo_btn->setEnabled(false);
|
||||
reset_btn->setEnabled(false);
|
||||
|
||||
auto double_validator = new DoubleValidator(this);
|
||||
for (auto edit : {value1, value2, factor_edit, offset_edit, first_time_edit, last_time_edit}) {
|
||||
edit->setValidator(double_validator);
|
||||
}
|
||||
|
||||
vlayout->addWidget(view = new QTableView(this));
|
||||
view->setContextMenuPolicy(Qt::CustomContextMenu);
|
||||
view->horizontalHeader()->setStretchLastSection(true);
|
||||
view->horizontalHeader()->setSelectionMode(QAbstractItemView::NoSelection);
|
||||
view->setSelectionBehavior(QAbstractItemView::SelectRows);
|
||||
view->setModel(model = new FindSignalModel(this));
|
||||
|
||||
hlayout = new QHBoxLayout();
|
||||
hlayout->addWidget(message_group);
|
||||
hlayout->addWidget(properties_group);
|
||||
main_layout->addLayout(hlayout);
|
||||
main_layout->addWidget(find_group);
|
||||
main_layout->addWidget(stats_label = new QLabel());
|
||||
|
||||
setMinimumSize({700, 650});
|
||||
QObject::connect(search_btn, &QPushButton::clicked, this, &FindSignalDlg::search);
|
||||
QObject::connect(undo_btn, &QPushButton::clicked, model, &FindSignalModel::undo);
|
||||
QObject::connect(model, &QAbstractItemModel::modelReset, this, &FindSignalDlg::modelReset);
|
||||
QObject::connect(reset_btn, &QPushButton::clicked, model, &FindSignalModel::reset);
|
||||
QObject::connect(view, &QTableView::customContextMenuRequested, this, &FindSignalDlg::customMenuRequested);
|
||||
QObject::connect(view, &QTableView::doubleClicked, [this](const QModelIndex &index) {
|
||||
if (index.isValid()) emit openMessage(model->filtered_signals[index.row()].id);
|
||||
});
|
||||
QObject::connect(compare_cb, qOverload<int>(&QComboBox::currentIndexChanged), [=](int index) {
|
||||
to_label->setVisible(index == compare_cb->count() - 1);
|
||||
value2->setVisible(index == compare_cb->count() - 1);
|
||||
});
|
||||
}
|
||||
|
||||
void FindSignalDlg::search() {
|
||||
if (model->histories.isEmpty()) {
|
||||
setInitialSignals();
|
||||
}
|
||||
auto v1 = value1->text().toDouble();
|
||||
auto v2 = value2->text().toDouble();
|
||||
std::function<bool(double)> cmp = nullptr;
|
||||
switch (compare_cb->currentIndex()) {
|
||||
case 0: cmp = [v1](double v) { return v == v1;}; break;
|
||||
case 1: cmp = [v1](double v) { return v > v1;}; break;
|
||||
case 2: cmp = [v1](double v) { return v >= v1;}; break;
|
||||
case 3: cmp = [v1](double v) { return v != v1;}; break;
|
||||
case 4: cmp = [v1](double v) { return v < v1;}; break;
|
||||
case 5: cmp = [v1](double v) { return v <= v1;}; break;
|
||||
case 6: cmp = [v1, v2](double v) { return v >= v1 && v <= v2;}; break;
|
||||
}
|
||||
properties_group->setEnabled(false);
|
||||
message_group->setEnabled(false);
|
||||
search_btn->setEnabled(false);
|
||||
stats_label->setVisible(false);
|
||||
search_btn->setText("Finding ....");
|
||||
QTimer::singleShot(0, this, [=]() { model->search(cmp); });
|
||||
}
|
||||
|
||||
void FindSignalDlg::setInitialSignals() {
|
||||
QSet<ushort> buses;
|
||||
for (auto bus : bus_edit->text().trimmed().split(",")) {
|
||||
bus = bus.trimmed();
|
||||
if (!bus.isEmpty()) buses.insert(bus.toUShort());
|
||||
}
|
||||
|
||||
QSet<uint32_t> addresses;
|
||||
for (auto addr : address_edit->text().trimmed().split(",")) {
|
||||
addr = addr.trimmed();
|
||||
if (!addr.isEmpty()) addresses.insert(addr.toULong(nullptr, 16));
|
||||
}
|
||||
|
||||
cabana::Signal sig{};
|
||||
sig.is_little_endian = litter_endian->isChecked();
|
||||
sig.is_signed = is_signed->isChecked();
|
||||
sig.factor = factor_edit->text().toDouble();
|
||||
sig.offset = offset_edit->text().toDouble();
|
||||
|
||||
double first_time_val = first_time_edit->text().toDouble();
|
||||
double last_time_val = last_time_edit->text().toDouble();
|
||||
auto [first_sec, last_sec] = std::minmax(first_time_val, last_time_val);
|
||||
uint64_t first_time = (can->routeStartTime() + first_sec) * 1e9;
|
||||
model->last_time = std::numeric_limits<uint64_t>::max();
|
||||
if (last_sec > 0) {
|
||||
model->last_time = (can->routeStartTime() + last_sec) * 1e9;
|
||||
}
|
||||
model->initial_signals.clear();
|
||||
|
||||
for (const auto &[id, m] : can->lastMessages()) {
|
||||
if (buses.isEmpty() || buses.contains(id.source) && (addresses.isEmpty() || addresses.contains(id.address))) {
|
||||
const auto &events = can->events(id);
|
||||
auto e = std::lower_bound(events.cbegin(), events.cend(), first_time, CompareCanEvent());
|
||||
if (e != events.cend()) {
|
||||
const int total_size = m.dat.size() * 8;
|
||||
for (int size = min_size->value(); size <= max_size->value(); ++size) {
|
||||
for (int start = 0; start <= total_size - size; ++start) {
|
||||
FindSignalModel::SearchSignal s{.id = id, .mono_time = first_time, .sig = sig};
|
||||
s.sig.start_bit = start;
|
||||
s.sig.size = size;
|
||||
updateMsbLsb(s.sig);
|
||||
s.value = get_raw_value((*e)->dat, (*e)->size, s.sig);
|
||||
model->initial_signals.push_back(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void FindSignalDlg::modelReset() {
|
||||
properties_group->setEnabled(model->histories.isEmpty());
|
||||
message_group->setEnabled(model->histories.isEmpty());
|
||||
search_btn->setText(model->histories.isEmpty() ? tr("Find") : tr("Find Next"));
|
||||
reset_btn->setEnabled(!model->histories.isEmpty());
|
||||
undo_btn->setEnabled(model->histories.size() > 1);
|
||||
search_btn->setEnabled(model->rowCount() > 0 || model->histories.isEmpty());
|
||||
stats_label->setVisible(true);
|
||||
stats_label->setText(tr("%1 matches. right click on an item to create signal. double click to open message").arg(model->filtered_signals.size()));
|
||||
}
|
||||
|
||||
void FindSignalDlg::customMenuRequested(const QPoint &pos) {
|
||||
if (auto index = view->indexAt(pos); index.isValid()) {
|
||||
QMenu menu(this);
|
||||
menu.addAction(tr("Create Signal"));
|
||||
if (menu.exec(view->mapToGlobal(pos))) {
|
||||
auto &s = model->filtered_signals[index.row()];
|
||||
UndoStack::push(new AddSigCommand(s.id, s.sig));
|
||||
emit openMessage(s.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
64
tools/cabana/tools/findsignal.h
Normal file
64
tools/cabana/tools/findsignal.h
Normal file
@@ -0,0 +1,64 @@
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <limits>
|
||||
|
||||
#include <QAbstractTableModel>
|
||||
#include <QCheckBox>
|
||||
#include <QLabel>
|
||||
#include <QPushButton>
|
||||
#include <QTableView>
|
||||
|
||||
#include "tools/cabana/commands.h"
|
||||
#include "tools/cabana/settings.h"
|
||||
|
||||
class FindSignalModel : public QAbstractTableModel {
|
||||
public:
|
||||
struct SearchSignal {
|
||||
MessageId id = {};
|
||||
uint64_t mono_time = 0;
|
||||
cabana::Signal sig = {};
|
||||
double value = 0.;
|
||||
QStringList values;
|
||||
};
|
||||
|
||||
FindSignalModel(QObject *parent) : QAbstractTableModel(parent) {}
|
||||
QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override;
|
||||
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
|
||||
int columnCount(const QModelIndex &parent = QModelIndex()) const override { return 3; }
|
||||
int rowCount(const QModelIndex &parent = QModelIndex()) const override { return std::min(filtered_signals.size(), 300); }
|
||||
void search(std::function<bool(double)> cmp);
|
||||
void reset();
|
||||
void undo();
|
||||
|
||||
QList<SearchSignal> filtered_signals;
|
||||
QList<SearchSignal> initial_signals;
|
||||
QList<QList<SearchSignal>> histories;
|
||||
uint64_t last_time = std::numeric_limits<uint64_t>::max();
|
||||
};
|
||||
|
||||
class FindSignalDlg : public QDialog {
|
||||
Q_OBJECT
|
||||
public:
|
||||
FindSignalDlg(QWidget *parent);
|
||||
|
||||
signals:
|
||||
void openMessage(const MessageId &id);
|
||||
|
||||
private:
|
||||
void search();
|
||||
void modelReset();
|
||||
void setInitialSignals();
|
||||
void customMenuRequested(const QPoint &pos);
|
||||
|
||||
QLineEdit *value1, *value2, *factor_edit, *offset_edit;
|
||||
QLineEdit *bus_edit, *address_edit, *first_time_edit, *last_time_edit;
|
||||
QComboBox *compare_cb;
|
||||
QSpinBox *min_size, *max_size;
|
||||
QCheckBox *litter_endian, *is_signed;
|
||||
QPushButton *search_btn, *reset_btn, *undo_btn;
|
||||
QGroupBox *properties_group, *message_group;
|
||||
QTableView *view;
|
||||
QLabel *to_label, *stats_label;
|
||||
FindSignalModel *model;
|
||||
};
|
||||
160
tools/cabana/tools/findsimilarbits.cc
Normal file
160
tools/cabana/tools/findsimilarbits.cc
Normal file
@@ -0,0 +1,160 @@
|
||||
#include "tools/cabana/tools/findsimilarbits.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include <QGridLayout>
|
||||
#include <QHeaderView>
|
||||
#include <QHBoxLayout>
|
||||
#include <QIntValidator>
|
||||
#include <QLabel>
|
||||
#include <QPushButton>
|
||||
#include <QRadioButton>
|
||||
|
||||
#include "tools/cabana/dbc/dbcmanager.h"
|
||||
#include "tools/cabana/streams/abstractstream.h"
|
||||
|
||||
FindSimilarBitsDlg::FindSimilarBitsDlg(QWidget *parent) : QDialog(parent, Qt::WindowFlags() | Qt::Window) {
|
||||
setWindowTitle(tr("Find similar bits"));
|
||||
setAttribute(Qt::WA_DeleteOnClose);
|
||||
|
||||
QVBoxLayout *main_layout = new QVBoxLayout(this);
|
||||
|
||||
QHBoxLayout *src_layout = new QHBoxLayout();
|
||||
src_bus_combo = new QComboBox(this);
|
||||
find_bus_combo = new QComboBox(this);
|
||||
for (auto cb : {src_bus_combo, find_bus_combo}) {
|
||||
for (uint8_t bus : can->sources) {
|
||||
cb->addItem(QString::number(bus), bus);
|
||||
}
|
||||
}
|
||||
|
||||
msg_cb = new QComboBox(this);
|
||||
// TODO: update when src_bus_combo changes
|
||||
for (auto &[address, msg] : dbc()->getMessages(-1)) {
|
||||
msg_cb->addItem(msg.name, address);
|
||||
}
|
||||
msg_cb->model()->sort(0);
|
||||
msg_cb->setCurrentIndex(0);
|
||||
|
||||
byte_idx_sb = new QSpinBox(this);
|
||||
byte_idx_sb->setFixedWidth(50);
|
||||
byte_idx_sb->setRange(0, 63);
|
||||
|
||||
bit_idx_sb = new QSpinBox(this);
|
||||
bit_idx_sb->setFixedWidth(50);
|
||||
bit_idx_sb->setRange(0, 7);
|
||||
|
||||
src_layout->addWidget(new QLabel(tr("Bus")));
|
||||
src_layout->addWidget(src_bus_combo);
|
||||
src_layout->addWidget(msg_cb);
|
||||
src_layout->addWidget(new QLabel(tr("Byte Index")));
|
||||
src_layout->addWidget(byte_idx_sb);
|
||||
src_layout->addWidget(new QLabel(tr("Bit Index")));
|
||||
src_layout->addWidget(bit_idx_sb);
|
||||
src_layout->addStretch(0);
|
||||
|
||||
QHBoxLayout *find_layout = new QHBoxLayout();
|
||||
find_layout->addWidget(new QLabel(tr("Bus")));
|
||||
find_layout->addWidget(find_bus_combo);
|
||||
find_layout->addWidget(new QLabel(tr("Equal")));
|
||||
equal_combo = new QComboBox(this);
|
||||
equal_combo->addItems({"Yes", "No"});
|
||||
find_layout->addWidget(equal_combo);
|
||||
min_msgs = new QLineEdit(this);
|
||||
min_msgs->setValidator(new QIntValidator(this));
|
||||
min_msgs->setText("100");
|
||||
find_layout->addWidget(new QLabel(tr("Min msg count")));
|
||||
find_layout->addWidget(min_msgs);
|
||||
search_btn = new QPushButton(tr("&Find"), this);
|
||||
find_layout->addWidget(search_btn);
|
||||
find_layout->addStretch(0);
|
||||
|
||||
QGridLayout *grid_layout = new QGridLayout();
|
||||
grid_layout->addWidget(new QLabel("Find From:"), 0, 0);
|
||||
grid_layout->addLayout(src_layout, 0, 1);
|
||||
grid_layout->addWidget(new QLabel("Find In:"), 1, 0);
|
||||
grid_layout->addLayout(find_layout, 1, 1);
|
||||
main_layout->addLayout(grid_layout);
|
||||
|
||||
table = new QTableWidget(this);
|
||||
table->setSelectionBehavior(QAbstractItemView::SelectRows);
|
||||
table->setSelectionMode(QAbstractItemView::SingleSelection);
|
||||
table->setEditTriggers(QAbstractItemView::NoEditTriggers);
|
||||
table->horizontalHeader()->setStretchLastSection(true);
|
||||
main_layout->addWidget(table);
|
||||
|
||||
setMinimumSize({700, 500});
|
||||
QObject::connect(search_btn, &QPushButton::clicked, this, &FindSimilarBitsDlg::find);
|
||||
QObject::connect(table, &QTableWidget::doubleClicked, [this](const QModelIndex &index) {
|
||||
if (index.isValid()) {
|
||||
MessageId msg_id = {.source = (uint8_t)find_bus_combo->currentData().toUInt(), .address = table->item(index.row(), 0)->text().toUInt(0, 16)};
|
||||
emit openMessage(msg_id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void FindSimilarBitsDlg::find() {
|
||||
search_btn->setEnabled(false);
|
||||
table->clear();
|
||||
uint32_t selected_address = msg_cb->currentData().toUInt();
|
||||
auto msg_mismatched = calcBits(src_bus_combo->currentText().toUInt(), selected_address, byte_idx_sb->value(), bit_idx_sb->value(),
|
||||
find_bus_combo->currentText().toUInt(), equal_combo->currentIndex() == 0, min_msgs->text().toInt());
|
||||
table->setRowCount(msg_mismatched.size());
|
||||
table->setColumnCount(6);
|
||||
table->setHorizontalHeaderLabels({"address", "byte idx", "bit idx", "mismatches", "total msgs", "% mismatched"});
|
||||
for (int i = 0; i < msg_mismatched.size(); ++i) {
|
||||
auto &m = msg_mismatched[i];
|
||||
table->setItem(i, 0, new QTableWidgetItem(QString("%1").arg(m.address, 1, 16)));
|
||||
table->setItem(i, 1, new QTableWidgetItem(QString::number(m.byte_idx)));
|
||||
table->setItem(i, 2, new QTableWidgetItem(QString::number(m.bit_idx)));
|
||||
table->setItem(i, 3, new QTableWidgetItem(QString::number(m.mismatches)));
|
||||
table->setItem(i, 4, new QTableWidgetItem(QString::number(m.total)));
|
||||
table->setItem(i, 5, new QTableWidgetItem(QString::number(m.perc, 'f', 2)));
|
||||
}
|
||||
search_btn->setEnabled(true);
|
||||
}
|
||||
|
||||
QList<FindSimilarBitsDlg::mismatched_struct> FindSimilarBitsDlg::calcBits(uint8_t bus, uint32_t selected_address, int byte_idx,
|
||||
int bit_idx, uint8_t find_bus, bool equal, int min_msgs_cnt) {
|
||||
QHash<uint32_t, QVector<uint32_t>> mismatches;
|
||||
QHash<uint32_t, uint32_t> msg_count;
|
||||
const auto &events = can->allEvents();
|
||||
int bit_to_find = -1;
|
||||
for (const CanEvent *e : events) {
|
||||
if (e->src == bus) {
|
||||
if (e->address == selected_address && e->size > byte_idx) {
|
||||
bit_to_find = ((e->dat[byte_idx] >> (7 - bit_idx)) & 1) != 0;
|
||||
}
|
||||
}
|
||||
if (e->src == find_bus) {
|
||||
++msg_count[e->address];
|
||||
if (bit_to_find == -1) continue;
|
||||
|
||||
auto &mismatched = mismatches[e->address];
|
||||
if (mismatched.size() < e->size * 8) {
|
||||
mismatched.resize(e->size * 8);
|
||||
}
|
||||
for (int i = 0; i < e->size; ++i) {
|
||||
for (int j = 0; j < 8; ++j) {
|
||||
int bit = ((e->dat[i] >> (7 - j)) & 1) != 0;
|
||||
mismatched[i * 8 + j] += equal ? (bit != bit_to_find) : (bit == bit_to_find);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
QList<mismatched_struct> result;
|
||||
result.reserve(mismatches.size());
|
||||
for (auto it = mismatches.begin(); it != mismatches.end(); ++it) {
|
||||
if (auto cnt = msg_count[it.key()]; cnt > min_msgs_cnt) {
|
||||
auto &mismatched = it.value();
|
||||
for (int i = 0; i < mismatched.size(); ++i) {
|
||||
if (float perc = (mismatched[i] / (double)cnt) * 100; perc < 50) {
|
||||
result.push_back({it.key(), (uint32_t)i / 8, (uint32_t)i % 8, mismatched[i], cnt, perc});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
std::sort(result.begin(), result.end(), [](auto &l, auto &r) { return l.perc < r.perc; });
|
||||
return result;
|
||||
}
|
||||
34
tools/cabana/tools/findsimilarbits.h
Normal file
34
tools/cabana/tools/findsimilarbits.h
Normal file
@@ -0,0 +1,34 @@
|
||||
#pragma once
|
||||
|
||||
#include <QComboBox>
|
||||
#include <QDialog>
|
||||
#include <QLineEdit>
|
||||
#include <QSpinBox>
|
||||
#include <QTableWidget>
|
||||
|
||||
#include "tools/cabana/dbc/dbcmanager.h"
|
||||
|
||||
class FindSimilarBitsDlg : public QDialog {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
FindSimilarBitsDlg(QWidget *parent);
|
||||
|
||||
signals:
|
||||
void openMessage(const MessageId &msg_id);
|
||||
|
||||
private:
|
||||
struct mismatched_struct {
|
||||
uint32_t address, byte_idx, bit_idx, mismatches, total;
|
||||
float perc;
|
||||
};
|
||||
QList<mismatched_struct> calcBits(uint8_t bus, uint32_t selected_address, int byte_idx, int bit_idx, uint8_t find_bus,
|
||||
bool equal, int min_msgs_cnt);
|
||||
void find();
|
||||
|
||||
QTableWidget *table;
|
||||
QComboBox *src_bus_combo, *find_bus_combo, *msg_cb, *equal_combo;
|
||||
QSpinBox *byte_idx_sb, *bit_idx_sb;
|
||||
QPushButton *search_btn;
|
||||
QLineEdit *min_msgs;
|
||||
};
|
||||
283
tools/cabana/util.cc
Normal file
283
tools/cabana/util.cc
Normal file
@@ -0,0 +1,283 @@
|
||||
#include "tools/cabana/util.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <csignal>
|
||||
#include <limits>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <QColor>
|
||||
#include <QDateTime>
|
||||
#include <QFontDatabase>
|
||||
#include <QLocale>
|
||||
#include <QPainter>
|
||||
#include <QPixmapCache>
|
||||
|
||||
#include "selfdrive/ui/qt/util.h"
|
||||
|
||||
// SegmentTree
|
||||
|
||||
void SegmentTree::build(const std::vector<QPointF> &arr) {
|
||||
size = arr.size();
|
||||
tree.resize(4 * size); // size of the tree is 4 times the size of the array
|
||||
if (size > 0) {
|
||||
build_tree(arr, 1, 0, size - 1);
|
||||
}
|
||||
}
|
||||
|
||||
void SegmentTree::build_tree(const std::vector<QPointF> &arr, int n, int left, int right) {
|
||||
if (left == right) {
|
||||
const double y = arr[left].y();
|
||||
tree[n] = {y, y};
|
||||
} else {
|
||||
const int mid = (left + right) >> 1;
|
||||
build_tree(arr, 2 * n, left, mid);
|
||||
build_tree(arr, 2 * n + 1, mid + 1, right);
|
||||
tree[n] = {std::min(tree[2 * n].first, tree[2 * n + 1].first), std::max(tree[2 * n].second, tree[2 * n + 1].second)};
|
||||
}
|
||||
}
|
||||
|
||||
std::pair<double, double> SegmentTree::get_minmax(int n, int left, int right, int range_left, int range_right) const {
|
||||
if (range_left > right || range_right < left)
|
||||
return {std::numeric_limits<double>::max(), std::numeric_limits<double>::lowest()};
|
||||
if (range_left <= left && range_right >= right)
|
||||
return tree[n];
|
||||
int mid = (left + right) >> 1;
|
||||
auto l = get_minmax(2 * n, left, mid, range_left, range_right);
|
||||
auto r = get_minmax(2 * n + 1, mid + 1, right, range_left, range_right);
|
||||
return {std::min(l.first, r.first), std::max(l.second, r.second)};
|
||||
}
|
||||
|
||||
// MessageBytesDelegate
|
||||
|
||||
MessageBytesDelegate::MessageBytesDelegate(QObject *parent, bool multiple_lines) : multiple_lines(multiple_lines), QStyledItemDelegate(parent) {
|
||||
fixed_font = QFontDatabase::systemFont(QFontDatabase::FixedFont);
|
||||
byte_size = QFontMetrics(fixed_font).size(Qt::TextSingleLine, "00 ") + QSize(0, 2);
|
||||
for (int i = 0; i < 256; ++i) {
|
||||
hex_text_table[i].setText(QStringLiteral("%1").arg(i, 2, 16, QLatin1Char('0')).toUpper());
|
||||
hex_text_table[i].prepare({}, fixed_font);
|
||||
}
|
||||
h_margin = QApplication::style()->pixelMetric(QStyle::PM_FocusFrameHMargin) + 1;
|
||||
v_margin = QApplication::style()->pixelMetric(QStyle::PM_FocusFrameVMargin) + 1;
|
||||
}
|
||||
|
||||
QSize MessageBytesDelegate::sizeForBytes(int n) const {
|
||||
int rows = multiple_lines ? std::max(1, n / 8) : 1;
|
||||
return {(n / rows) * byte_size.width() + h_margin * 2, rows * byte_size.height() + v_margin * 2};
|
||||
}
|
||||
|
||||
QSize MessageBytesDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const {
|
||||
auto data = index.data(BytesRole);
|
||||
return sizeForBytes(data.isValid() ? static_cast<std::vector<uint8_t> *>(data.value<void *>())->size() : 0);
|
||||
}
|
||||
|
||||
void MessageBytesDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const {
|
||||
auto data = index.data(BytesRole);
|
||||
if (!data.isValid()) {
|
||||
return QStyledItemDelegate::paint(painter, option, index);
|
||||
}
|
||||
|
||||
QFont old_font = painter->font();
|
||||
QPen old_pen = painter->pen();
|
||||
if (option.state & QStyle::State_Selected) {
|
||||
painter->fillRect(option.rect, option.palette.brush(QPalette::Normal, QPalette::Highlight));
|
||||
}
|
||||
const QPoint pt{option.rect.left() + h_margin, option.rect.top() + v_margin};
|
||||
painter->setFont(fixed_font);
|
||||
|
||||
const auto &bytes = *static_cast<std::vector<uint8_t>*>(data.value<void*>());
|
||||
const auto &colors = *static_cast<std::vector<QColor>*>(index.data(ColorsRole).value<void*>());
|
||||
for (int i = 0; i < bytes.size(); ++i) {
|
||||
int row = !multiple_lines ? 0 : i / 8;
|
||||
int column = !multiple_lines ? i : i % 8;
|
||||
QRect r = QRect({pt.x() + column * byte_size.width(), pt.y() + row * byte_size.height()}, byte_size);
|
||||
if (i < colors.size() && colors[i].alpha() > 0) {
|
||||
if (option.state & QStyle::State_Selected) {
|
||||
painter->setPen(option.palette.color(QPalette::Text));
|
||||
painter->fillRect(r, option.palette.color(QPalette::Window));
|
||||
}
|
||||
painter->fillRect(r, colors[i]);
|
||||
} else if (option.state & QStyle::State_Selected) {
|
||||
painter->setPen(option.palette.color(QPalette::HighlightedText));
|
||||
}
|
||||
utils::drawStaticText(painter, r, hex_text_table[bytes[i]]);
|
||||
}
|
||||
painter->setFont(old_font);
|
||||
painter->setPen(old_pen);
|
||||
}
|
||||
|
||||
// TabBar
|
||||
|
||||
int TabBar::addTab(const QString &text) {
|
||||
int index = QTabBar::addTab(text);
|
||||
QToolButton *btn = new ToolButton("x", tr("Close Tab"));
|
||||
int width = style()->pixelMetric(QStyle::PM_TabCloseIndicatorWidth, nullptr, btn);
|
||||
int height = style()->pixelMetric(QStyle::PM_TabCloseIndicatorHeight, nullptr, btn);
|
||||
btn->setFixedSize({width, height});
|
||||
setTabButton(index, QTabBar::RightSide, btn);
|
||||
QObject::connect(btn, &QToolButton::clicked, this, &TabBar::closeTabClicked);
|
||||
return index;
|
||||
}
|
||||
|
||||
void TabBar::closeTabClicked() {
|
||||
QObject *object = sender();
|
||||
for (int i = 0; i < count(); ++i) {
|
||||
if (tabButton(i, QTabBar::RightSide) == object) {
|
||||
emit tabCloseRequested(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UnixSignalHandler
|
||||
|
||||
UnixSignalHandler::UnixSignalHandler(QObject *parent) : QObject(nullptr) {
|
||||
if (::socketpair(AF_UNIX, SOCK_STREAM, 0, sig_fd)) {
|
||||
qFatal("Couldn't create TERM socketpair");
|
||||
}
|
||||
|
||||
sn = new QSocketNotifier(sig_fd[1], QSocketNotifier::Read, this);
|
||||
connect(sn, &QSocketNotifier::activated, this, &UnixSignalHandler::handleSigTerm);
|
||||
std::signal(SIGINT, signalHandler);
|
||||
std::signal(SIGTERM, UnixSignalHandler::signalHandler);
|
||||
}
|
||||
|
||||
UnixSignalHandler::~UnixSignalHandler() {
|
||||
::close(sig_fd[0]);
|
||||
::close(sig_fd[1]);
|
||||
}
|
||||
|
||||
void UnixSignalHandler::signalHandler(int s) {
|
||||
::write(sig_fd[0], &s, sizeof(s));
|
||||
}
|
||||
|
||||
void UnixSignalHandler::handleSigTerm() {
|
||||
sn->setEnabled(false);
|
||||
int tmp;
|
||||
::read(sig_fd[1], &tmp, sizeof(tmp));
|
||||
|
||||
printf("\nexiting...\n");
|
||||
qApp->closeAllWindows();
|
||||
qApp->exit();
|
||||
}
|
||||
|
||||
// NameValidator
|
||||
|
||||
NameValidator::NameValidator(QObject *parent) : QRegExpValidator(QRegExp("^(\\w+)"), parent) {}
|
||||
|
||||
QValidator::State NameValidator::validate(QString &input, int &pos) const {
|
||||
input.replace(' ', '_');
|
||||
return QRegExpValidator::validate(input, pos);
|
||||
}
|
||||
|
||||
DoubleValidator::DoubleValidator(QObject *parent) : QDoubleValidator(parent) {
|
||||
// Match locale of QString::toDouble() instead of system
|
||||
QLocale locale(QLocale::C);
|
||||
locale.setNumberOptions(QLocale::RejectGroupSeparator);
|
||||
setLocale(locale);
|
||||
}
|
||||
|
||||
namespace utils {
|
||||
QPixmap icon(const QString &id) {
|
||||
bool dark_theme = settings.theme == DARK_THEME;
|
||||
QPixmap pm;
|
||||
QString key = "bootstrap_" % id % (dark_theme ? "1" : "0");
|
||||
if (!QPixmapCache::find(key, &pm)) {
|
||||
pm = bootstrapPixmap(id);
|
||||
if (dark_theme) {
|
||||
QPainter p(&pm);
|
||||
p.setCompositionMode(QPainter::CompositionMode_SourceIn);
|
||||
p.fillRect(pm.rect(), QColor("#bbbbbb"));
|
||||
}
|
||||
QPixmapCache::insert(key, pm);
|
||||
}
|
||||
return pm;
|
||||
}
|
||||
|
||||
void setTheme(int theme) {
|
||||
auto style = QApplication::style();
|
||||
if (!style) return;
|
||||
|
||||
static int prev_theme = 0;
|
||||
if (theme != prev_theme) {
|
||||
prev_theme = theme;
|
||||
QPalette new_palette;
|
||||
if (theme == DARK_THEME) {
|
||||
// "Darcula" like dark theme
|
||||
new_palette.setColor(QPalette::Window, QColor("#353535"));
|
||||
new_palette.setColor(QPalette::WindowText, QColor("#bbbbbb"));
|
||||
new_palette.setColor(QPalette::Base, QColor("#3c3f41"));
|
||||
new_palette.setColor(QPalette::AlternateBase, QColor("#3c3f41"));
|
||||
new_palette.setColor(QPalette::ToolTipBase, QColor("#3c3f41"));
|
||||
new_palette.setColor(QPalette::ToolTipText, QColor("#bbb"));
|
||||
new_palette.setColor(QPalette::Text, QColor("#bbbbbb"));
|
||||
new_palette.setColor(QPalette::Button, QColor("#3c3f41"));
|
||||
new_palette.setColor(QPalette::ButtonText, QColor("#bbbbbb"));
|
||||
new_palette.setColor(QPalette::Highlight, QColor("#2f65ca"));
|
||||
new_palette.setColor(QPalette::HighlightedText, QColor("#bbbbbb"));
|
||||
new_palette.setColor(QPalette::BrightText, QColor("#f0f0f0"));
|
||||
new_palette.setColor(QPalette::Disabled, QPalette::ButtonText, QColor("#777777"));
|
||||
new_palette.setColor(QPalette::Disabled, QPalette::WindowText, QColor("#777777"));
|
||||
new_palette.setColor(QPalette::Disabled, QPalette::Text, QColor("#777777"));
|
||||
new_palette.setColor(QPalette::Light, QColor("#777777"));
|
||||
new_palette.setColor(QPalette::Dark, QColor("#353535"));
|
||||
} else {
|
||||
new_palette = style->standardPalette();
|
||||
}
|
||||
qApp->setPalette(new_palette);
|
||||
style->polish(qApp);
|
||||
for (auto w : QApplication::allWidgets()) {
|
||||
w->setPalette(new_palette);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
QString formatSeconds(double sec, bool include_milliseconds, bool absolute_time) {
|
||||
QString format = absolute_time ? "yyyy-MM-dd hh:mm:ss"
|
||||
: (sec > 60 * 60 ? "hh:mm:ss" : "mm:ss");
|
||||
if (include_milliseconds) format += ".zzz";
|
||||
return QDateTime::fromMSecsSinceEpoch(sec * 1000).toString(format);
|
||||
}
|
||||
|
||||
} // namespace utils
|
||||
|
||||
int num_decimals(double num) {
|
||||
const QString string = QString::number(num);
|
||||
auto dot_pos = string.indexOf('.');
|
||||
return dot_pos == -1 ? 0 : string.size() - dot_pos - 1;
|
||||
}
|
||||
|
||||
QString signalToolTip(const cabana::Signal *sig) {
|
||||
return QObject::tr(R"(
|
||||
%1<br /><span font-size:small">
|
||||
Start Bit: %2 Size: %3<br />
|
||||
MSB: %4 LSB: %5<br />
|
||||
Little Endian: %6 Signed: %7</span>
|
||||
)").arg(sig->name).arg(sig->start_bit).arg(sig->size).arg(sig->msb).arg(sig->lsb)
|
||||
.arg(sig->is_little_endian ? "Y" : "N").arg(sig->is_signed ? "Y" : "N");
|
||||
}
|
||||
|
||||
// MonotonicBuffer
|
||||
|
||||
void *MonotonicBuffer::allocate(size_t bytes, size_t alignment) {
|
||||
assert(bytes > 0);
|
||||
void *p = std::align(alignment, bytes, current_buf, available);
|
||||
if (p == nullptr) {
|
||||
available = next_buffer_size = std::max(next_buffer_size, bytes);
|
||||
current_buf = buffers.emplace_back(std::aligned_alloc(alignment, next_buffer_size));
|
||||
next_buffer_size *= growth_factor;
|
||||
p = current_buf;
|
||||
}
|
||||
|
||||
current_buf = (char *)current_buf + bytes;
|
||||
available -= bytes;
|
||||
return p;
|
||||
}
|
||||
|
||||
MonotonicBuffer::~MonotonicBuffer() {
|
||||
for (auto buf : buffers) {
|
||||
free(buf);
|
||||
}
|
||||
}
|
||||
179
tools/cabana/util.h
Normal file
179
tools/cabana/util.h
Normal file
@@ -0,0 +1,179 @@
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <deque>
|
||||
#include <vector>
|
||||
#include <utility>
|
||||
|
||||
#include <QApplication>
|
||||
#include <QByteArray>
|
||||
#include <QDoubleValidator>
|
||||
#include <QFont>
|
||||
#include <QPainter>
|
||||
#include <QRegExpValidator>
|
||||
#include <QSocketNotifier>
|
||||
#include <QStaticText>
|
||||
#include <QStringBuilder>
|
||||
#include <QStyledItemDelegate>
|
||||
#include <QToolButton>
|
||||
|
||||
#include "tools/cabana/dbc/dbc.h"
|
||||
#include "tools/cabana/settings.h"
|
||||
|
||||
class LogSlider : public QSlider {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
LogSlider(double factor, Qt::Orientation orientation, QWidget *parent = nullptr) : factor(factor), QSlider(orientation, parent) {}
|
||||
|
||||
void setRange(double min, double max) {
|
||||
log_min = factor * std::log10(min);
|
||||
log_max = factor * std::log10(max);
|
||||
QSlider::setRange(min, max);
|
||||
setValue(QSlider::value());
|
||||
}
|
||||
int value() const {
|
||||
double v = log_min + (log_max - log_min) * ((QSlider::value() - minimum()) / double(maximum() - minimum()));
|
||||
return std::lround(std::pow(10, v / factor));
|
||||
}
|
||||
void setValue(int v) {
|
||||
double log_v = std::clamp(factor * std::log10(v), log_min, log_max);
|
||||
v = minimum() + (maximum() - minimum()) * ((log_v - log_min) / (log_max - log_min));
|
||||
QSlider::setValue(v);
|
||||
}
|
||||
|
||||
private:
|
||||
double factor, log_min = 0, log_max = 1;
|
||||
};
|
||||
|
||||
enum {
|
||||
ColorsRole = Qt::UserRole + 1,
|
||||
BytesRole = Qt::UserRole + 2
|
||||
};
|
||||
|
||||
class SegmentTree {
|
||||
public:
|
||||
SegmentTree() = default;
|
||||
void build(const std::vector<QPointF> &arr);
|
||||
inline std::pair<double, double> minmax(int left, int right) const { return get_minmax(1, 0, size - 1, left, right); }
|
||||
|
||||
private:
|
||||
std::pair<double, double> get_minmax(int n, int left, int right, int range_left, int range_right) const;
|
||||
void build_tree(const std::vector<QPointF> &arr, int n, int left, int right);
|
||||
std::vector<std::pair<double, double>> tree;
|
||||
int size = 0;
|
||||
};
|
||||
|
||||
class MessageBytesDelegate : public QStyledItemDelegate {
|
||||
Q_OBJECT
|
||||
public:
|
||||
MessageBytesDelegate(QObject *parent, bool multiple_lines = false);
|
||||
void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override;
|
||||
QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override;
|
||||
bool multipleLines() const { return multiple_lines; }
|
||||
void setMultipleLines(bool v) { multiple_lines = v; }
|
||||
QSize sizeForBytes(int n) const;
|
||||
|
||||
private:
|
||||
std::array<QStaticText, 256> hex_text_table;
|
||||
QFont fixed_font;
|
||||
QSize byte_size = {};
|
||||
bool multiple_lines = false;
|
||||
int h_margin, v_margin;
|
||||
};
|
||||
|
||||
class NameValidator : public QRegExpValidator {
|
||||
Q_OBJECT
|
||||
public:
|
||||
NameValidator(QObject *parent=nullptr);
|
||||
QValidator::State validate(QString &input, int &pos) const override;
|
||||
};
|
||||
|
||||
class DoubleValidator : public QDoubleValidator {
|
||||
Q_OBJECT
|
||||
public:
|
||||
DoubleValidator(QObject *parent = nullptr);
|
||||
};
|
||||
|
||||
namespace utils {
|
||||
QPixmap icon(const QString &id);
|
||||
void setTheme(int theme);
|
||||
QString formatSeconds(double sec, bool include_milliseconds = false, bool absolute_time = false);
|
||||
inline void drawStaticText(QPainter *p, const QRect &r, const QStaticText &text) {
|
||||
auto size = (r.size() - text.size()) / 2;
|
||||
p->drawStaticText(r.left() + size.width(), r.top() + size.height(), text);
|
||||
}
|
||||
inline QString toHex(const std::vector<uint8_t> &dat, char separator = '\0') {
|
||||
return QByteArray::fromRawData((const char *)dat.data(), dat.size()).toHex(separator).toUpper();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class ToolButton : public QToolButton {
|
||||
Q_OBJECT
|
||||
public:
|
||||
ToolButton(const QString &icon, const QString &tooltip = {}, QWidget *parent = nullptr) : QToolButton(parent) {
|
||||
setIcon(icon);
|
||||
setToolTip(tooltip);
|
||||
setAutoRaise(true);
|
||||
const int metric = QApplication::style()->pixelMetric(QStyle::PM_SmallIconSize);
|
||||
setIconSize({metric, metric});
|
||||
theme = settings.theme;
|
||||
connect(&settings, &Settings::changed, this, &ToolButton::updateIcon);
|
||||
}
|
||||
void setIcon(const QString &icon) {
|
||||
icon_str = icon;
|
||||
QToolButton::setIcon(utils::icon(icon_str));
|
||||
}
|
||||
|
||||
private:
|
||||
void updateIcon() { if (std::exchange(theme, settings.theme) != theme) setIcon(icon_str); }
|
||||
QString icon_str;
|
||||
int theme;
|
||||
};
|
||||
|
||||
class TabBar : public QTabBar {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
TabBar(QWidget *parent) : QTabBar(parent) {}
|
||||
int addTab(const QString &text);
|
||||
|
||||
private:
|
||||
void closeTabClicked();
|
||||
};
|
||||
|
||||
class UnixSignalHandler : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
UnixSignalHandler(QObject *parent = nullptr);
|
||||
~UnixSignalHandler();
|
||||
static void signalHandler(int s);
|
||||
|
||||
public slots:
|
||||
void handleSigTerm();
|
||||
|
||||
private:
|
||||
inline static int sig_fd[2] = {};
|
||||
QSocketNotifier *sn;
|
||||
};
|
||||
|
||||
class MonotonicBuffer {
|
||||
public:
|
||||
MonotonicBuffer(size_t initial_size) : next_buffer_size(initial_size) {}
|
||||
~MonotonicBuffer();
|
||||
void *allocate(size_t bytes, size_t alignment = 16ul);
|
||||
void deallocate(void *p) {}
|
||||
|
||||
private:
|
||||
void *current_buf = nullptr;
|
||||
size_t next_buffer_size = 0;
|
||||
size_t available = 0;
|
||||
std::deque<void *> buffers;
|
||||
static constexpr float growth_factor = 1.5;
|
||||
};
|
||||
|
||||
int num_decimals(double num);
|
||||
QString signalToolTip(const cabana::Signal *sig);
|
||||
411
tools/cabana/videowidget.cc
Normal file
411
tools/cabana/videowidget.cc
Normal file
@@ -0,0 +1,411 @@
|
||||
#include "tools/cabana/videowidget.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <utility>
|
||||
|
||||
#include <QAction>
|
||||
#include <QActionGroup>
|
||||
#include <QMenu>
|
||||
#include <QMouseEvent>
|
||||
#include <QPainter>
|
||||
#include <QStackedLayout>
|
||||
#include <QStyleOptionSlider>
|
||||
#include <QVBoxLayout>
|
||||
#include <QtConcurrent>
|
||||
|
||||
#include "tools/cabana/streams/replaystream.h"
|
||||
|
||||
const int MIN_VIDEO_HEIGHT = 100;
|
||||
const int THUMBNAIL_MARGIN = 3;
|
||||
|
||||
static const QColor timeline_colors[] = {
|
||||
[(int)TimelineType::None] = QColor(111, 143, 175),
|
||||
[(int)TimelineType::Engaged] = QColor(0, 163, 108),
|
||||
[(int)TimelineType::UserFlag] = Qt::magenta,
|
||||
[(int)TimelineType::AlertInfo] = Qt::green,
|
||||
[(int)TimelineType::AlertWarning] = QColor(255, 195, 0),
|
||||
[(int)TimelineType::AlertCritical] = QColor(199, 0, 57),
|
||||
};
|
||||
|
||||
VideoWidget::VideoWidget(QWidget *parent) : QFrame(parent) {
|
||||
setFrameStyle(QFrame::StyledPanel | QFrame::Plain);
|
||||
auto main_layout = new QVBoxLayout(this);
|
||||
if (!can->liveStreaming())
|
||||
main_layout->addWidget(createCameraWidget());
|
||||
main_layout->addLayout(createPlaybackController());
|
||||
|
||||
setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Maximum);
|
||||
QObject::connect(can, &AbstractStream::paused, this, &VideoWidget::updatePlayBtnState);
|
||||
QObject::connect(can, &AbstractStream::resume, this, &VideoWidget::updatePlayBtnState);
|
||||
QObject::connect(can, &AbstractStream::msgsReceived, this, &VideoWidget::updateState);
|
||||
|
||||
updatePlayBtnState();
|
||||
setWhatsThis(tr(R"(
|
||||
<b>Video</b><br />
|
||||
<!-- TODO: add descprition here -->
|
||||
<span style="color:gray">Timeline color</span>
|
||||
<table>
|
||||
<tr><td><span style="color:%1;">■ </span>Disengaged </td>
|
||||
<td><span style="color:%2;">■ </span>Engaged</td></tr>
|
||||
<tr><td><span style="color:%3;">■ </span>User Flag </td>
|
||||
<td><span style="color:%4;">■ </span>Info</td></tr>
|
||||
<tr><td><span style="color:%5;">■ </span>Warning </td>
|
||||
<td><span style="color:%6;">■ </span>Critical</td></tr>
|
||||
</table>
|
||||
<span style="color:gray">Shortcuts</span><br/>
|
||||
Pause/Resume: <span style="background-color:lightGray;color:gray"> space </span>
|
||||
)").arg(timeline_colors[(int)TimelineType::None].name(),
|
||||
timeline_colors[(int)TimelineType::Engaged].name(),
|
||||
timeline_colors[(int)TimelineType::UserFlag].name(),
|
||||
timeline_colors[(int)TimelineType::AlertInfo].name(),
|
||||
timeline_colors[(int)TimelineType::AlertWarning].name(),
|
||||
timeline_colors[(int)TimelineType::AlertCritical].name()));
|
||||
}
|
||||
|
||||
QHBoxLayout *VideoWidget::createPlaybackController() {
|
||||
QHBoxLayout *layout = new QHBoxLayout();
|
||||
layout->addWidget(seek_backward_btn = new ToolButton("rewind", tr("Seek backward")));
|
||||
layout->addWidget(play_btn = new ToolButton("play", tr("Play")));
|
||||
layout->addWidget(seek_forward_btn = new ToolButton("fast-forward", tr("Seek forward")));
|
||||
|
||||
if (can->liveStreaming()) {
|
||||
layout->addWidget(skip_to_end_btn = new ToolButton("skip-end", tr("Skip to the end"), this));
|
||||
QObject::connect(skip_to_end_btn, &QToolButton::clicked, [this]() {
|
||||
// set speed to 1.0
|
||||
speed_btn->menu()->actions()[7]->setChecked(true);
|
||||
can->pause(false);
|
||||
can->seekTo(can->totalSeconds() + 1);
|
||||
});
|
||||
}
|
||||
|
||||
layout->addWidget(time_btn = new QToolButton);
|
||||
time_btn->setToolTip(settings.absolute_time ? tr("Elapsed time") : tr("Absolute time"));
|
||||
time_btn->setAutoRaise(true);
|
||||
layout->addStretch(0);
|
||||
|
||||
if (!can->liveStreaming()) {
|
||||
layout->addWidget(loop_btn = new ToolButton("repeat", tr("Loop playback")));
|
||||
QObject::connect(loop_btn, &QToolButton::clicked, this, &VideoWidget::loopPlaybackClicked);
|
||||
}
|
||||
|
||||
// speed selector
|
||||
layout->addWidget(speed_btn = new QToolButton(this));
|
||||
speed_btn->setAutoRaise(true);
|
||||
speed_btn->setMenu(new QMenu(speed_btn));
|
||||
speed_btn->setPopupMode(QToolButton::InstantPopup);
|
||||
QActionGroup *speed_group = new QActionGroup(this);
|
||||
speed_group->setExclusive(true);
|
||||
|
||||
int max_width = 0;
|
||||
QFont font = speed_btn->font();
|
||||
font.setBold(true);
|
||||
speed_btn->setFont(font);
|
||||
QFontMetrics fm(font);
|
||||
for (float speed : {0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 0.8, 1., 2., 3., 5.}) {
|
||||
QString name = QString("%1x").arg(speed);
|
||||
max_width = std::max(max_width, fm.width(name) + fm.horizontalAdvance(QLatin1Char(' ')) * 2);
|
||||
|
||||
QAction *act = new QAction(name, speed_group);
|
||||
act->setCheckable(true);
|
||||
QObject::connect(act, &QAction::toggled, [this, speed]() {
|
||||
can->setSpeed(speed);
|
||||
speed_btn->setText(QString("%1x ").arg(speed));
|
||||
});
|
||||
speed_btn->menu()->addAction(act);
|
||||
if (speed == 1.0)act->setChecked(true);
|
||||
}
|
||||
speed_btn->setMinimumWidth(max_width + style()->pixelMetric(QStyle::PM_MenuButtonIndicator));
|
||||
|
||||
QObject::connect(play_btn, &QToolButton::clicked, []() { can->pause(!can->isPaused()); });
|
||||
QObject::connect(seek_backward_btn, &QToolButton::clicked, []() { can->seekTo(can->currentSec() - 1); });
|
||||
QObject::connect(seek_forward_btn, &QToolButton::clicked, []() { can->seekTo(can->currentSec() + 1); });
|
||||
QObject::connect(time_btn, &QToolButton::clicked, [this]() {
|
||||
settings.absolute_time = !settings.absolute_time;
|
||||
time_btn->setToolTip(settings.absolute_time ? tr("Elapsed time") : tr("Absolute time"));
|
||||
updateState();
|
||||
});
|
||||
return layout;
|
||||
}
|
||||
|
||||
QWidget *VideoWidget::createCameraWidget() {
|
||||
QWidget *w = new QWidget(this);
|
||||
QVBoxLayout *l = new QVBoxLayout(w);
|
||||
l->setContentsMargins(0, 0, 0, 0);
|
||||
l->setSpacing(0);
|
||||
|
||||
l->addWidget(camera_tab = new TabBar(w));
|
||||
camera_tab->setAutoHide(true);
|
||||
camera_tab->setExpanding(false);
|
||||
|
||||
QStackedLayout *stacked = new QStackedLayout();
|
||||
stacked->setStackingMode(QStackedLayout::StackAll);
|
||||
stacked->addWidget(cam_widget = new CameraWidget("camerad", VISION_STREAM_ROAD, false));
|
||||
cam_widget->setMinimumHeight(MIN_VIDEO_HEIGHT);
|
||||
cam_widget->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::MinimumExpanding);
|
||||
stacked->addWidget(alert_label = new InfoLabel(this));
|
||||
l->addLayout(stacked);
|
||||
|
||||
l->addWidget(slider = new Slider(w));
|
||||
slider->setSingleStep(0);
|
||||
|
||||
setMaximumTime(can->totalSeconds());
|
||||
QObject::connect(slider, &QSlider::sliderReleased, [this]() { can->seekTo(slider->currentSecond()); });
|
||||
QObject::connect(slider, &Slider::updateMaximumTime, this, &VideoWidget::setMaximumTime, Qt::QueuedConnection);
|
||||
QObject::connect(static_cast<ReplayStream*>(can), &ReplayStream::qLogLoaded, slider, &Slider::parseQLog);
|
||||
QObject::connect(cam_widget, &CameraWidget::clicked, []() { can->pause(!can->isPaused()); });
|
||||
QObject::connect(cam_widget, &CameraWidget::vipcAvailableStreamsUpdated, this, &VideoWidget::vipcAvailableStreamsUpdated);
|
||||
QObject::connect(camera_tab, &QTabBar::currentChanged, [this](int index) {
|
||||
if (index != -1) cam_widget->setStreamType((VisionStreamType)camera_tab->tabData(index).toInt());
|
||||
});
|
||||
return w;
|
||||
}
|
||||
|
||||
void VideoWidget::vipcAvailableStreamsUpdated(std::set<VisionStreamType> streams) {
|
||||
static const QString stream_names[] = {
|
||||
[VISION_STREAM_ROAD] = "Road camera",
|
||||
[VISION_STREAM_WIDE_ROAD] = "Wide road camera",
|
||||
[VISION_STREAM_DRIVER] = "Driver camera"};
|
||||
|
||||
for (int i = 0; i < streams.size(); ++i) {
|
||||
if (camera_tab->count() <= i) {
|
||||
camera_tab->addTab(QString());
|
||||
}
|
||||
int type = *std::next(streams.begin(), i);
|
||||
camera_tab->setTabText(i, stream_names[type]);
|
||||
camera_tab->setTabData(i, type);
|
||||
}
|
||||
while (camera_tab->count() > streams.size()) {
|
||||
camera_tab->removeTab(camera_tab->count() - 1);
|
||||
}
|
||||
}
|
||||
|
||||
void VideoWidget::loopPlaybackClicked() {
|
||||
auto replay = qobject_cast<ReplayStream *>(can)->getReplay();
|
||||
if (!replay) return;
|
||||
|
||||
if (replay->hasFlag(REPLAY_FLAG_NO_LOOP)) {
|
||||
replay->removeFlag(REPLAY_FLAG_NO_LOOP);
|
||||
loop_btn->setIcon("repeat");
|
||||
} else {
|
||||
replay->addFlag(REPLAY_FLAG_NO_LOOP);
|
||||
loop_btn->setIcon("repeat-1");
|
||||
}
|
||||
}
|
||||
|
||||
void VideoWidget::setMaximumTime(double sec) {
|
||||
maximum_time = sec;
|
||||
slider->setTimeRange(0, sec);
|
||||
}
|
||||
|
||||
void VideoWidget::updateTimeRange(double min, double max, bool is_zoomed) {
|
||||
if (can->liveStreaming()) {
|
||||
skip_to_end_btn->setEnabled(!is_zoomed);
|
||||
return;
|
||||
}
|
||||
is_zoomed ? slider->setTimeRange(min, max)
|
||||
: slider->setTimeRange(0, maximum_time);
|
||||
}
|
||||
|
||||
QString VideoWidget::formatTime(double sec, bool include_milliseconds) {
|
||||
if (settings.absolute_time)
|
||||
sec = can->beginDateTime().addMSecs(sec * 1000).toMSecsSinceEpoch() / 1000.0;
|
||||
return utils::formatSeconds(sec, include_milliseconds, settings.absolute_time);
|
||||
}
|
||||
|
||||
void VideoWidget::updateState() {
|
||||
if (slider) {
|
||||
if (!slider->isSliderDown())
|
||||
slider->setCurrentSecond(can->currentSec());
|
||||
alert_label->showAlert(slider->alertInfo(can->currentSec()));
|
||||
time_btn->setText(QString("%1 / %2").arg(formatTime(can->currentSec(), true),
|
||||
formatTime(slider->maximum() / slider->factor)));
|
||||
} else {
|
||||
time_btn->setText(formatTime(can->currentSec(), true));
|
||||
}
|
||||
}
|
||||
|
||||
void VideoWidget::updatePlayBtnState() {
|
||||
play_btn->setIcon(can->isPaused() ? "play" : "pause");
|
||||
play_btn->setToolTip(can->isPaused() ? tr("Play") : tr("Pause"));
|
||||
}
|
||||
|
||||
// Slider
|
||||
|
||||
Slider::Slider(QWidget *parent) : QSlider(Qt::Horizontal, parent) {
|
||||
thumbnail_label = new InfoLabel(parent);
|
||||
setMouseTracking(true);
|
||||
}
|
||||
|
||||
AlertInfo Slider::alertInfo(double seconds) {
|
||||
uint64_t mono_time = (seconds + can->routeStartTime()) * 1e9;
|
||||
auto alert_it = alerts.lower_bound(mono_time);
|
||||
bool has_alert = (alert_it != alerts.end()) && ((alert_it->first - mono_time) <= 1e8);
|
||||
return has_alert ? alert_it->second : AlertInfo{};
|
||||
}
|
||||
|
||||
QPixmap Slider::thumbnail(double seconds) {
|
||||
uint64_t mono_time = (seconds + can->routeStartTime()) * 1e9;
|
||||
auto it = thumbnails.lowerBound(mono_time);
|
||||
return it != thumbnails.end() ? it.value() : QPixmap();
|
||||
}
|
||||
|
||||
void Slider::setTimeRange(double min, double max) {
|
||||
assert(min < max);
|
||||
setRange(min * factor, max * factor);
|
||||
}
|
||||
|
||||
void Slider::parseQLog(int segnum, std::shared_ptr<LogReader> qlog) {
|
||||
const auto &segments = qobject_cast<ReplayStream *>(can)->route()->segments();
|
||||
if (segments.size() > 0 && segnum == segments.rbegin()->first && !qlog->events.empty()) {
|
||||
emit updateMaximumTime(qlog->events.back()->mono_time / 1e9 - can->routeStartTime());
|
||||
}
|
||||
|
||||
std::mutex mutex;
|
||||
QtConcurrent::blockingMap(qlog->events.cbegin(), qlog->events.cend(), [&mutex, this](const Event *e) {
|
||||
if (e->which == cereal::Event::Which::THUMBNAIL) {
|
||||
auto thumb = e->event.getThumbnail();
|
||||
auto data = thumb.getThumbnail();
|
||||
if (QPixmap pm; pm.loadFromData(data.begin(), data.size(), "jpeg")) {
|
||||
QPixmap scaled = pm.scaledToHeight(MIN_VIDEO_HEIGHT - THUMBNAIL_MARGIN * 2, Qt::SmoothTransformation);
|
||||
std::lock_guard lk(mutex);
|
||||
thumbnails[thumb.getTimestampEof()] = scaled;
|
||||
}
|
||||
} else if (e->which == cereal::Event::Which::CONTROLS_STATE) {
|
||||
auto cs = e->event.getControlsState();
|
||||
if (cs.getAlertType().size() > 0 && cs.getAlertText1().size() > 0 &&
|
||||
cs.getAlertSize() != cereal::ControlsState::AlertSize::NONE) {
|
||||
std::lock_guard lk(mutex);
|
||||
alerts.emplace(e->mono_time, AlertInfo{cs.getAlertStatus(), cs.getAlertText1().cStr(), cs.getAlertText2().cStr()});
|
||||
}
|
||||
}
|
||||
});
|
||||
update();
|
||||
}
|
||||
|
||||
void Slider::paintEvent(QPaintEvent *ev) {
|
||||
QPainter p(this);
|
||||
QRect r = rect().adjusted(0, 4, 0, -4);
|
||||
p.fillRect(r, timeline_colors[(int)TimelineType::None]);
|
||||
double min = minimum() / factor;
|
||||
double max = maximum() / factor;
|
||||
|
||||
auto fillRange = [&](double begin, double end, const QColor &color) {
|
||||
if (begin > max || end < min) return;
|
||||
r.setLeft(((std::max(min, begin) - min) / (max - min)) * width());
|
||||
r.setRight(((std::min(max, end) - min) / (max - min)) * width());
|
||||
p.fillRect(r, color);
|
||||
};
|
||||
|
||||
const auto replay = qobject_cast<ReplayStream *>(can)->getReplay();
|
||||
for (auto [begin, end, type] : replay->getTimeline()) {
|
||||
fillRange(begin, end, timeline_colors[(int)type]);
|
||||
}
|
||||
|
||||
QColor empty_color = palette().color(QPalette::Window);
|
||||
empty_color.setAlpha(160);
|
||||
for (const auto &[n, seg] : replay->segments()) {
|
||||
if (!(seg && seg->isLoaded()))
|
||||
fillRange(n * 60.0, (n + 1) * 60.0, empty_color);
|
||||
}
|
||||
|
||||
QStyleOptionSlider opt;
|
||||
opt.initFrom(this);
|
||||
opt.minimum = minimum();
|
||||
opt.maximum = maximum();
|
||||
opt.subControls = QStyle::SC_SliderHandle;
|
||||
opt.sliderPosition = value();
|
||||
style()->drawComplexControl(QStyle::CC_Slider, &opt, &p);
|
||||
}
|
||||
|
||||
void Slider::mousePressEvent(QMouseEvent *e) {
|
||||
QSlider::mousePressEvent(e);
|
||||
if (e->button() == Qt::LeftButton && !isSliderDown()) {
|
||||
setValue(minimum() + ((maximum() - minimum()) * e->x()) / width());
|
||||
emit sliderReleased();
|
||||
}
|
||||
}
|
||||
|
||||
void Slider::mouseMoveEvent(QMouseEvent *e) {
|
||||
int pos = std::clamp(e->pos().x(), 0, width());
|
||||
double seconds = (minimum() + pos * ((maximum() - minimum()) / (double)width())) / factor;
|
||||
QPixmap thumb = thumbnail(seconds);
|
||||
if (!thumb.isNull()) {
|
||||
int x = std::clamp(pos - thumb.width() / 2, THUMBNAIL_MARGIN, width() - thumb.width() - THUMBNAIL_MARGIN + 1);
|
||||
int y = -thumb.height() - THUMBNAIL_MARGIN;
|
||||
thumbnail_label->showPixmap(mapToParent(QPoint(x, y)), utils::formatSeconds(seconds), thumb, alertInfo(seconds));
|
||||
} else {
|
||||
thumbnail_label->hide();
|
||||
}
|
||||
QSlider::mouseMoveEvent(e);
|
||||
}
|
||||
|
||||
bool Slider::event(QEvent *event) {
|
||||
switch (event->type()) {
|
||||
case QEvent::WindowActivate:
|
||||
case QEvent::WindowDeactivate:
|
||||
case QEvent::FocusIn:
|
||||
case QEvent::FocusOut:
|
||||
case QEvent::Leave:
|
||||
thumbnail_label->hide();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return QSlider::event(event);
|
||||
}
|
||||
|
||||
// InfoLabel
|
||||
|
||||
InfoLabel::InfoLabel(QWidget *parent) : QWidget(parent, Qt::WindowStaysOnTopHint) {
|
||||
setAttribute(Qt::WA_ShowWithoutActivating);
|
||||
setVisible(false);
|
||||
}
|
||||
|
||||
void InfoLabel::showPixmap(const QPoint &pt, const QString &sec, const QPixmap &pm, const AlertInfo &alert) {
|
||||
second = sec;
|
||||
pixmap = pm;
|
||||
alert_info = alert;
|
||||
setGeometry(QRect(pt, pm.size()));
|
||||
setVisible(true);
|
||||
update();
|
||||
}
|
||||
|
||||
void InfoLabel::showAlert(const AlertInfo &alert) {
|
||||
alert_info = alert;
|
||||
pixmap = {};
|
||||
setVisible(!alert_info.text1.isEmpty());
|
||||
update();
|
||||
}
|
||||
|
||||
void InfoLabel::paintEvent(QPaintEvent *event) {
|
||||
QPainter p(this);
|
||||
p.setPen(QPen(palette().color(QPalette::BrightText), 2));
|
||||
if (!pixmap.isNull()) {
|
||||
p.drawPixmap(0, 0, pixmap);
|
||||
p.drawRect(rect());
|
||||
p.drawText(rect().adjusted(0, 0, 0, -THUMBNAIL_MARGIN), second, Qt::AlignHCenter | Qt::AlignBottom);
|
||||
}
|
||||
if (alert_info.text1.size() > 0) {
|
||||
QColor color = timeline_colors[(int)TimelineType::AlertInfo];
|
||||
if (alert_info.status == cereal::ControlsState::AlertStatus::USER_PROMPT) {
|
||||
color = timeline_colors[(int)TimelineType::AlertWarning];
|
||||
} else if (alert_info.status == cereal::ControlsState::AlertStatus::CRITICAL) {
|
||||
color = timeline_colors[(int)TimelineType::AlertCritical];
|
||||
}
|
||||
color.setAlphaF(0.5);
|
||||
QString text = alert_info.text1;
|
||||
if (!alert_info.text2.isEmpty()) {
|
||||
text += "\n" + alert_info.text2;
|
||||
}
|
||||
|
||||
if (!pixmap.isNull()) {
|
||||
QFont font;
|
||||
font.setPixelSize(11);
|
||||
p.setFont(font);
|
||||
}
|
||||
QRect text_rect = rect().adjusted(2, 2, -2, -2);
|
||||
QRect r = p.fontMetrics().boundingRect(text_rect, Qt::AlignTop | Qt::AlignHCenter | Qt::TextWordWrap, text);
|
||||
p.fillRect(text_rect.left(), r.top(), text_rect.width(), r.height(), color);
|
||||
p.drawText(text_rect, Qt::AlignTop | Qt::AlignHCenter | Qt::TextWordWrap, text);
|
||||
}
|
||||
}
|
||||
90
tools/cabana/videowidget.h
Normal file
90
tools/cabana/videowidget.h
Normal file
@@ -0,0 +1,90 @@
|
||||
#pragma once
|
||||
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <set>
|
||||
|
||||
#include <QHBoxLayout>
|
||||
#include <QFrame>
|
||||
#include <QSlider>
|
||||
#include <QTabBar>
|
||||
|
||||
#include "selfdrive/ui/qt/widgets/cameraview.h"
|
||||
#include "tools/cabana/util.h"
|
||||
#include "tools/replay/logreader.h"
|
||||
|
||||
struct AlertInfo {
|
||||
cereal::ControlsState::AlertStatus status;
|
||||
QString text1;
|
||||
QString text2;
|
||||
};
|
||||
|
||||
class InfoLabel : public QWidget {
|
||||
public:
|
||||
InfoLabel(QWidget *parent);
|
||||
void showPixmap(const QPoint &pt, const QString &sec, const QPixmap &pm, const AlertInfo &alert);
|
||||
void showAlert(const AlertInfo &alert);
|
||||
void paintEvent(QPaintEvent *event) override;
|
||||
QPixmap pixmap;
|
||||
QString second;
|
||||
AlertInfo alert_info;
|
||||
};
|
||||
|
||||
class Slider : public QSlider {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
Slider(QWidget *parent);
|
||||
double currentSecond() const { return value() / factor; }
|
||||
void setCurrentSecond(double sec) { setValue(sec * factor); }
|
||||
void setTimeRange(double min, double max);
|
||||
AlertInfo alertInfo(double sec);
|
||||
QPixmap thumbnail(double sec);
|
||||
void parseQLog(int segnum, std::shared_ptr<LogReader> qlog);
|
||||
|
||||
const double factor = 1000.0;
|
||||
|
||||
signals:
|
||||
void updateMaximumTime(double);
|
||||
|
||||
private:
|
||||
void mousePressEvent(QMouseEvent *e) override;
|
||||
void mouseMoveEvent(QMouseEvent *e) override;
|
||||
bool event(QEvent *event) override;
|
||||
void paintEvent(QPaintEvent *ev) override;
|
||||
|
||||
QMap<uint64_t, QPixmap> thumbnails;
|
||||
std::map<uint64_t, AlertInfo> alerts;
|
||||
InfoLabel *thumbnail_label;
|
||||
};
|
||||
|
||||
class VideoWidget : public QFrame {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
VideoWidget(QWidget *parnet = nullptr);
|
||||
void updateTimeRange(double min, double max, bool is_zommed);
|
||||
void setMaximumTime(double sec);
|
||||
|
||||
protected:
|
||||
QString formatTime(double sec, bool include_milliseconds = false);
|
||||
void updateState();
|
||||
void updatePlayBtnState();
|
||||
QWidget *createCameraWidget();
|
||||
QHBoxLayout *createPlaybackController();
|
||||
void loopPlaybackClicked();
|
||||
void vipcAvailableStreamsUpdated(std::set<VisionStreamType> streams);
|
||||
|
||||
CameraWidget *cam_widget;
|
||||
double maximum_time = 0;
|
||||
QToolButton *time_btn = nullptr;
|
||||
ToolButton *seek_backward_btn = nullptr;
|
||||
ToolButton *play_btn = nullptr;
|
||||
ToolButton *seek_forward_btn = nullptr;
|
||||
ToolButton *loop_btn = nullptr;
|
||||
QToolButton *speed_btn = nullptr;
|
||||
ToolButton *skip_to_end_btn = nullptr;
|
||||
InfoLabel *alert_label = nullptr;
|
||||
Slider *slider = nullptr;
|
||||
QTabBar *camera_tab = nullptr;
|
||||
};
|
||||
Reference in New Issue
Block a user