From 292d55cf8a6617f44e08074d7b9d4a2a40974634 Mon Sep 17 00:00:00 2001 From: Dean Lee Date: Tue, 4 Oct 2022 06:19:42 +0800 Subject: [PATCH] c++ cabana: Initial version (#25946) * draft * continue * fix QChart unresponsive with large points * build with --extras * add filter * save DBC button * more buttons * add flag to use qcamera * stop replay in dctor * README * use getMsg * video control * edit signal * add colors * correct ts * add/edit signals * use bus:address as key old-commit-hash: 1b8324af876e66630b5f4e50623e3136a39f6ecb --- SConstruct | 4 + selfdrive/ui/SConscript | 1 + tools/cabana/.gitignore | 4 + tools/cabana/README | 9 + tools/cabana/SConscript | 20 ++ tools/cabana/cabana | 4 + tools/cabana/cabana.cc | 34 +++ tools/cabana/chartswidget.cc | 102 ++++++++ tools/cabana/chartswidget.h | 34 +++ tools/cabana/detailwidget.cc | 458 +++++++++++++++++++++++++++++++++ tools/cabana/detailwidget.h | 102 ++++++++ tools/cabana/mainwin.cc | 38 +++ tools/cabana/mainwin.h | 22 ++ tools/cabana/messageswidget.cc | 94 +++++++ tools/cabana/messageswidget.h | 24 ++ tools/cabana/parser.cc | 98 +++++++ tools/cabana/parser.h | 66 +++++ tools/cabana/videowidget.cc | 80 ++++++ tools/cabana/videowidget.h | 17 ++ tools/replay/SConscript | 1 + tools/replay/replay.cc | 2 +- tools/replay/replay.h | 4 + tools/ubuntu_setup.sh | 1 + 23 files changed, 1218 insertions(+), 1 deletion(-) create mode 100644 tools/cabana/.gitignore create mode 100644 tools/cabana/README create mode 100644 tools/cabana/SConscript create mode 100755 tools/cabana/cabana create mode 100644 tools/cabana/cabana.cc create mode 100644 tools/cabana/chartswidget.cc create mode 100644 tools/cabana/chartswidget.h create mode 100644 tools/cabana/detailwidget.cc create mode 100644 tools/cabana/detailwidget.h create mode 100644 tools/cabana/mainwin.cc create mode 100644 tools/cabana/mainwin.h create mode 100644 tools/cabana/messageswidget.cc create mode 100644 tools/cabana/messageswidget.h create mode 100644 tools/cabana/parser.cc create mode 100644 tools/cabana/parser.h create mode 100644 tools/cabana/videowidget.cc create mode 100644 tools/cabana/videowidget.h diff --git a/SConstruct b/SConstruct index 178b0cc872..e015218f2a 100644 --- a/SConstruct +++ b/SConstruct @@ -433,6 +433,10 @@ SConscript(['selfdrive/navd/SConscript']) SConscript(['tools/replay/SConscript']) +opendbc = abspath([File('opendbc/can/libdbc.so')]) +Export('opendbc') +SConscript(['tools/cabana/SConscript']) + if GetOption('test'): SConscript('panda/tests/safety/SConscript') diff --git a/selfdrive/ui/SConscript b/selfdrive/ui/SConscript index 92f6578dfc..84e055752a 100644 --- a/selfdrive/ui/SConscript +++ b/selfdrive/ui/SConscript @@ -32,6 +32,7 @@ if maps: qt_env['CPPDEFINES'] += ["ENABLE_MAPS"] widgets = qt_env.Library("qt_widgets", widgets_src, LIBS=base_libs) +Export('widgets') qt_libs = [widgets, qt_util] + base_libs # build assets diff --git a/tools/cabana/.gitignore b/tools/cabana/.gitignore new file mode 100644 index 0000000000..0c21d5530d --- /dev/null +++ b/tools/cabana/.gitignore @@ -0,0 +1,4 @@ +moc_* +*.moc + +_cabana diff --git a/tools/cabana/README b/tools/cabana/README new file mode 100644 index 0000000000..f64e6b2d2d --- /dev/null +++ b/tools/cabana/README @@ -0,0 +1,9 @@ +# 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 + +See [openpilot wiki](https://github.com/commaai/openpilot/wiki/Cabana) diff --git a/tools/cabana/SConscript b/tools/cabana/SConscript new file mode 100644 index 0000000000..f32ee166b6 --- /dev/null +++ b/tools/cabana/SConscript @@ -0,0 +1,20 @@ +import os +Import('env', 'qt_env', 'arch', 'common', 'messaging', 'visionipc', + 'cereal', 'transformations', 'widgets', 'replay_lib', 'opendbc') + +base_frameworks = qt_env['FRAMEWORKS'] +base_libs = [common, messaging, cereal, visionipc, transformations, 'zmq', + 'capnp', 'kj', 'm', 'ssl', 'crypto', 'pthread'] + qt_env["LIBS"] + +if arch == "Darwin": + base_frameworks.append('OpenCL') +else: + base_libs.append('OpenCL') + +qt_libs = ['qt_util', 'Qt5Charts'] + base_libs +if arch in ['x86_64', 'Darwin'] and GetOption('extras'): + qt_env['CXXFLAGS'] += ["-Wno-deprecated-declarations"] + + # qt_env["LD_LIBRARY_PATH"] = [Dir(f"#opendbc/can").abspath] + cabana_libs = [widgets, cereal, messaging, visionipc, replay_lib, opendbc,'avutil', 'avcodec', 'avformat', 'bz2', 'curl', 'yuv'] + qt_libs + qt_env.Program('_cabana', ['cabana.cc', 'mainwin.cc', 'chartswidget.cc', 'videowidget.cc', 'parser.cc', 'messageswidget.cc', 'detailwidget.cc'], LIBS=cabana_libs, FRAMEWORKS=base_frameworks) diff --git a/tools/cabana/cabana b/tools/cabana/cabana new file mode 100755 index 0000000000..b29dd66e3d --- /dev/null +++ b/tools/cabana/cabana @@ -0,0 +1,4 @@ +#!/bin/sh +cd "$(dirname "$0")" +export LD_LIBRARY_PATH="../../opendbc/can:$LD_LIBRARY_PATH" +exec ./_cabana "$1" diff --git a/tools/cabana/cabana.cc b/tools/cabana/cabana.cc new file mode 100644 index 0000000000..0adc744b49 --- /dev/null +++ b/tools/cabana/cabana.cc @@ -0,0 +1,34 @@ +#include +#include + +#include "selfdrive/ui/qt/util.h" +#include "tools/cabana/mainwin.h" + +const QString DEMO_ROUTE = "4cf7a6ad03080c90|2021-09-29--13-46-36"; +Parser *parser = nullptr; + +int main(int argc, char *argv[]) { + initApp(argc, argv); + QApplication app(argc, argv); + + 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({"data_dir", "local directory with routes", "data_dir"}); + cmd_parser.addOption({"qcam", "use qcamera"}); + cmd_parser.process(app); + const QStringList args = cmd_parser.positionalArguments(); + if (args.empty() && !cmd_parser.isSet("demo")) { + cmd_parser.showHelp(); + } + + const QString route = args.empty() ? DEMO_ROUTE : args.first(); + parser = new Parser(&app); + if (!parser->loadRoute(route, cmd_parser.value("data_dir"), cmd_parser.isSet("qcam"))) { + return 0; + } + MainWindow w; + w.showMaximized(); + return app.exec(); +} diff --git a/tools/cabana/chartswidget.cc b/tools/cabana/chartswidget.cc new file mode 100644 index 0000000000..a8fe39968e --- /dev/null +++ b/tools/cabana/chartswidget.cc @@ -0,0 +1,102 @@ +#include "tools/cabana/chartswidget.h" + +#include + +using namespace QtCharts; + +int64_t get_raw_value(const QByteArray &msg, const Signal &sig) { + int64_t ret = 0; + + int i = sig.msb / 8; + int bits = sig.size; + while (i >= 0 && i < msg.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 = (msg[i] >> (lsb - (i * 8))) & ((1ULL << size) - 1); + ret |= d << (bits - size); + + bits -= size; + i = sig.is_little_endian ? i - 1 : i + 1; + } + return ret; +} + +ChartsWidget::ChartsWidget(QWidget *parent) : QWidget(parent) { + main_layout = new QVBoxLayout(this); + main_layout->setContentsMargins(0, 0, 0, 0); + connect(parser, &Parser::updated, this, &ChartsWidget::updateState); + connect(parser, &Parser::showPlot, this, &ChartsWidget::addChart); + connect(parser, &Parser::hidePlot, this, &ChartsWidget::removeChart); +} + +void ChartsWidget::addChart(const QString &id, const QString &sig_name) { + const QString char_name = id + sig_name; + if (charts.find(char_name) == charts.end()) { + QLineSeries *series = new QLineSeries(); + series->setUseOpenGL(true); + auto chart = new QChart(); + chart->setTitle(id + ": " + sig_name); + chart->addSeries(series); + chart->createDefaultAxes(); + chart->legend()->hide(); + auto chart_view = new QChartView(chart); + chart_view->setMinimumSize({width(), 300}); + chart_view->setMaximumSize({width(), 300}); + chart_view->setRenderHint(QPainter::Antialiasing); + main_layout->addWidget(chart_view); + charts[char_name] = {.id = id, .sig_name = sig_name, .chart_view = chart_view}; + } +} + +void ChartsWidget::removeChart(const QString &id, const QString &sig_name) { + auto it = charts.find(id + sig_name); + if (it == charts.end()) return; + + delete it->second.chart_view; + charts.erase(it); +} + +void ChartsWidget::updateState() { + static double last_update = millis_since_boot(); + double current_ts = millis_since_boot(); + bool update = (current_ts - last_update) > 500; + if (update) { + last_update = current_ts; + } + + auto getSig = [=](const QString &id, const QString &name) -> const Signal * { + for (auto &sig : parser->getMsg(id)->sigs) { + if (name == sig.name.c_str()) return &sig; + } + return nullptr; + }; + + for (auto &[_, c] : charts) { + if (auto sig = getSig(c.id, c.sig_name)) { + const auto &can_data = parser->can_msgs[c.id].back(); + int64_t val = get_raw_value(can_data.dat, *sig); + if (sig->is_signed) { + val -= ((val >> (sig->size - 1)) & 0x1) ? (1ULL << sig->size) : 0; + } + double value = val * sig->factor + sig->offset; + + if (value > c.max_y) c.max_y = value; + if (value < c.min_y) c.min_y = value; + + while (c.data.size() > DATA_LIST_SIZE) { + c.data.pop_front(); + } + c.data.push_back({can_data.ts / 1000., value}); + + if (update) { + QChart *chart = c.chart_view->chart(); + QLineSeries *series = (QLineSeries *)chart->series()[0]; + series->replace(c.data); + chart->axisX()->setRange(c.data.front().x(), c.data.back().x()); + chart->axisY()->setRange(c.min_y, c.max_y); + } + } + } +} diff --git a/tools/cabana/chartswidget.h b/tools/cabana/chartswidget.h new file mode 100644 index 0000000000..7bc8335a32 --- /dev/null +++ b/tools/cabana/chartswidget.h @@ -0,0 +1,34 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "tools/cabana/parser.h" + +class ChartsWidget : public QWidget { + Q_OBJECT + + public: + ChartsWidget(QWidget *parent = nullptr); + inline bool hasChart(const QString &id, const QString &sig_name) { + return charts.find(id+sig_name) != charts.end(); + } + void addChart(const QString &id, const QString &sig_name); + void removeChart(const QString &id, const QString &sig_name); + void updateState(); + + protected: + QVBoxLayout *main_layout; + struct SignalChart { + QString id; + QString sig_name; + int max_y = 0; + int min_y = 0; + QList data; + QtCharts::QChartView *chart_view = nullptr; + }; + std::map charts; +}; diff --git a/tools/cabana/detailwidget.cc b/tools/cabana/detailwidget.cc new file mode 100644 index 0000000000..6799376577 --- /dev/null +++ b/tools/cabana/detailwidget.cc @@ -0,0 +1,458 @@ +#include "tools/cabana/detailwidget.h" + +#include +#include +#include +#include +#include +#include + +#include "selfdrive/ui/qt/widgets/scrollview.h" + +const QString SIGNAL_COLORS[] = {"#9FE2BF", "#40E0D0", "#6495ED", "#CCCCFF", "#FF7F50", "#FFBF00"}; + +static QVector BIG_ENDIAN_START_BITS = []() { + QVector ret; + for (int i = 0; i < 64; i++) { + for (int j = 7; j >= 0; j--) { + ret.push_back(j + i * 8); + } + } + return ret; +}(); + +static int bigEndianBitIndex(int index) { + // TODO: Add a helper function in dbc.h + return BIG_ENDIAN_START_BITS.indexOf(index); +} + +DetailWidget::DetailWidget(QWidget *parent) : QWidget(parent) { + QVBoxLayout *main_layout = new QVBoxLayout(this); + QLabel *title = new QLabel(tr("SELECTED MESSAGE:"), this); + main_layout->addWidget(title); + + QHBoxLayout *name_layout = new QHBoxLayout(); + name_label = new QLabel(this); + name_label->setStyleSheet("font-weight:bold;"); + name_layout->addWidget(name_label); + name_layout->addStretch(); + edit_btn = new QPushButton(tr("Edit"), this); + edit_btn->setVisible(false); + QObject::connect(edit_btn, &QPushButton::clicked, [=]() { + EditMessageDialog dlg(msg_id, this); + int ret = dlg.exec(); + if (ret) { + setMsg(msg_id); + } + }); + name_layout->addWidget(edit_btn); + main_layout->addLayout(name_layout); + + binary_view = new BinaryView(this); + main_layout->addWidget(binary_view); + + QHBoxLayout *signals_layout = new QHBoxLayout(); + signals_layout->addWidget(new QLabel(tr("Signals"))); + signals_layout->addStretch(); + add_sig_btn = new QPushButton(tr("Add signal"), this); + add_sig_btn->setVisible(false); + QObject::connect(add_sig_btn, &QPushButton::clicked, [=]() { + AddSignalDialog dlg(msg_id, this); + int ret = dlg.exec(); + if (ret) { + setMsg(msg_id); + } + }); + signals_layout->addWidget(add_sig_btn); + main_layout->addLayout(signals_layout); + + QWidget *container = new QWidget(this); + QVBoxLayout *container_layout = new QVBoxLayout(container); + signal_edit_layout = new QVBoxLayout(); + signal_edit_layout->setSpacing(2); + container_layout->addLayout(signal_edit_layout); + + messages_view = new MessagesView(this); + container_layout->addWidget(messages_view); + + QScrollArea *scroll = new QScrollArea(this); + scroll->setWidget(container); + scroll->setWidgetResizable(true); + scroll->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Expanding); + + main_layout->addWidget(scroll); + setFixedWidth(600); + + connect(parser, &Parser::updated, this, &DetailWidget::updateState); +} + +void DetailWidget::updateState() { + if (msg_id.isEmpty()) return; + + auto &list = parser->can_msgs[msg_id]; + if (!list.empty()) { + binary_view->setData(list.back().dat); + messages_view->setMessages(list); + } +} + +SignalForm::SignalForm(const Signal &sig, QWidget *parent) : QWidget(parent) { + QVBoxLayout *v_layout = new QVBoxLayout(this); + + QHBoxLayout *h = new QHBoxLayout(); + h->addWidget(new QLabel(tr("Name"))); + name = new QLineEdit(sig.name.c_str()); + h->addWidget(name); + v_layout->addLayout(h); + + h = new QHBoxLayout(); + h->addWidget(new QLabel(tr("Size"))); + size = new QSpinBox(); + size->setValue(sig.size); + h->addWidget(size); + v_layout->addLayout(h); + + h = new QHBoxLayout(); + h->addWidget(new QLabel(tr("Most significant bit"))); + msb = new QSpinBox(); + msb->setValue(sig.msb); + h->addWidget(msb); + v_layout->addLayout(h); + + h = new QHBoxLayout(); + h->addWidget(new QLabel(tr("Endianness"))); + endianness = new QComboBox(); + endianness->addItems({"Little", "Big"}); + endianness->setCurrentIndex(sig.is_little_endian ? 0 : 1); + h->addWidget(endianness); + v_layout->addLayout(h); + + h = new QHBoxLayout(); + h->addWidget(new QLabel(tr("sign"))); + sign = new QComboBox(); + sign->addItems({"Signed", "Unsigned"}); + sign->setCurrentIndex(sig.is_signed ? 0 : 1); + h->addWidget(sign); + v_layout->addLayout(h); + + h = new QHBoxLayout(); + h->addWidget(new QLabel(tr("Factor"))); + factor = new QSpinBox(); + factor->setValue(sig.factor); + h->addWidget(factor); + v_layout->addLayout(h); + + h = new QHBoxLayout(); + h->addWidget(new QLabel(tr("Offset"))); + offset = new QSpinBox(); + offset->setValue(sig.offset); + h->addWidget(offset); + v_layout->addLayout(h); + + // TODO: parse the following parameters in opendbc + h = new QHBoxLayout(); + h->addWidget(new QLabel(tr("Unit"))); + unit = new QLineEdit(); + h->addWidget(unit); + v_layout->addLayout(h); + + h = new QHBoxLayout(); + h->addWidget(new QLabel(tr("Comment"))); + comment = new QLineEdit(); + h->addWidget(comment); + v_layout->addLayout(h); + + h = new QHBoxLayout(); + h->addWidget(new QLabel(tr("Minimum value"))); + min_val = new QSpinBox(); + h->addWidget(min_val); + v_layout->addLayout(h); + + h = new QHBoxLayout(); + h->addWidget(new QLabel(tr("Maximum value"))); + max_val = new QSpinBox(); + h->addWidget(max_val); + v_layout->addLayout(h); + + h = new QHBoxLayout(); + h->addWidget(new QLabel(tr("Value descriptions"))); + val_desc = new QLineEdit(); + h->addWidget(val_desc); + v_layout->addLayout(h); +} + +std::optional SignalForm::getSignal() { + Signal sig = {}; + sig.name = name->text().toStdString(); + sig.size = size->text().toInt(); + sig.offset = offset->text().toDouble(); + sig.factor = factor->text().toDouble(); + sig.msb = msb->text().toInt(); + sig.is_signed = sign->currentIndex() == 0; + sig.is_little_endian = endianness->currentIndex() == 0; + if (sig.is_little_endian) { + sig.lsb = sig.start_bit; + sig.msb = sig.start_bit + sig.size - 1; + } else { + sig.lsb = BIG_ENDIAN_START_BITS[bigEndianBitIndex(sig.start_bit) + sig.size - 1]; + sig.msb = sig.start_bit; + } + return (sig.name.empty() || sig.size <= 0) ? std::nullopt : std::optional(sig); +} + +void DetailWidget::setMsg(const QString &id) { + msg_id = id; + QString name = tr("untitled"); + + for (auto edit : signal_edit) { + delete edit; + } + signal_edit.clear(); + int i = 0; + auto msg = parser->getMsg(id); + if (msg) { + for (auto &s : msg->sigs) { + SignalEdit *edit = new SignalEdit(id, s, i++, this); + connect(edit, &SignalEdit::removed, [=]() { + QTimer::singleShot(0, [=]() { setMsg(id); }); + }); + signal_edit_layout->addWidget(edit); + signal_edit.push_back(edit); + } + name = msg->name.c_str(); + } + name_label->setText(name); + binary_view->setMsg(msg_id); + + edit_btn->setVisible(true); + add_sig_btn->setVisible(msg != nullptr); +} + +SignalEdit::SignalEdit(const QString &id, const Signal &sig, int idx, QWidget *parent) : id(id), name_(sig.name.c_str()), QWidget(parent) { + QVBoxLayout *main_layout = new QVBoxLayout(this); + main_layout->setContentsMargins(0, 0, 0, 0); + + // title + QHBoxLayout *title_layout = new QHBoxLayout(); + QLabel *icon = new QLabel(">"); + icon->setStyleSheet("font-weight:bold"); + title_layout->addWidget(icon); + title = new ElidedLabel(this); + title->setText(sig.name.c_str()); + title->setStyleSheet(QString("font-weight:bold; color:%1").arg(SIGNAL_COLORS[idx % std::size(SIGNAL_COLORS)])); + connect(title, &ElidedLabel::clicked, [=]() { + edit_container->isVisible() ? edit_container->hide() : edit_container->show(); + icon->setText(edit_container->isVisible() ? "▼" : ">"); + }); + title_layout->addWidget(title); + title_layout->addStretch(); + QPushButton *show_plot = new QPushButton(tr("Show Plot")); + QObject::connect(show_plot, &QPushButton::clicked, [=]() { + if (show_plot->text() == tr("Show Plot")) { + emit parser->showPlot(id, name_); + show_plot->setText(tr("Hide Plot")); + } else { + emit parser->hidePlot(id, name_); + show_plot->setText(tr("Show Plot")); + } + }); + title_layout->addWidget(show_plot); + main_layout->addLayout(title_layout); + + edit_container = new QWidget(this); + QVBoxLayout *v_layout = new QVBoxLayout(edit_container); + form = new SignalForm(sig, this); + v_layout->addWidget(form); + + QHBoxLayout *h = new QHBoxLayout(); + remove_btn = new QPushButton(tr("Remove Signal")); + QObject::connect(remove_btn, &QPushButton::clicked, this, &SignalEdit::remove); + h->addWidget(remove_btn); + h->addStretch(); + QPushButton *save_btn = new QPushButton(tr("Save")); + QObject::connect(save_btn, &QPushButton::clicked, this, &SignalEdit::save); + h->addWidget(save_btn); + v_layout->addLayout(h); + + edit_container->setVisible(false); + main_layout->addWidget(edit_container); +} + +void SignalEdit::save() { + Msg *msg = const_cast(parser->getMsg(id)); + if (!msg) return; + + for (auto &sig : msg->sigs) { + if (name_ == sig.name.c_str()) { + if (auto s = form->getSignal()) { + sig = *s; + } + break; + } + } +} + +void SignalEdit::remove() { + QMessageBox msgbox; + msgbox.setText(tr("Remove signal")); + msgbox.setInformativeText(tr("Are you sure you want to remove signal '%1'").arg(name_)); + msgbox.setStandardButtons(QMessageBox::Ok | QMessageBox::Cancel); + msgbox.setDefaultButton(QMessageBox::Cancel); + if (msgbox.exec()) { + parser->removeSignal(id, name_); + emit removed(); + } +} + +BinaryView::BinaryView(QWidget *parent) { + QVBoxLayout *main_layout = new QVBoxLayout(this); + table = new QTableWidget(this); + table->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch); + table->horizontalHeader()->hide(); + table->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + table->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + main_layout->addWidget(table); + table->setColumnCount(9); + setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed); +} + +void BinaryView::setMsg(const QString &id) { + auto msg = parser->getMsg(Parser::addressFromId(id)); + int row_count = msg ? msg->size : parser->can_msgs[id].back().dat.size(); + + table->setRowCount(row_count); + table->setColumnCount(9); + for (int i = 0; i < table->rowCount(); ++i) { + for (int j = 0; j < table->columnCount(); ++j) { + auto item = new QTableWidgetItem(); + item->setTextAlignment(Qt::AlignCenter); + if (j == 8) { + QFont font; + font.setBold(true); + item->setFont(font); + } + table->setItem(i, j, item); + } + } + + if (msg) { + for (int i = 0; i < msg->sigs.size(); ++i) { + const auto &sig = msg->sigs[i]; + int start = sig.is_little_endian ? sig.start_bit : bigEndianBitIndex(sig.start_bit); + for (int j = start; j <= start + sig.size - 1; ++j) { + table->item(j / 8, j % 8)->setBackground(QColor(SIGNAL_COLORS[i % std::size(SIGNAL_COLORS)])); + } + } + } + + setFixedHeight(table->rowHeight(0) * table->rowCount() + 25); + if (!parser->can_msgs.empty()) { + setData(parser->can_msgs[id].back().dat); + } +} + +void BinaryView::setData(const QByteArray &binary) { + std::string s; + for (int j = 0; j < binary.size(); ++j) { + s += std::bitset<8>(binary[j]).to_string(); + } + + char hex[3] = {'\0'}; + for (int i = 0; i < binary.size(); ++i) { + for (int j = 0; j < 8; ++j) { + table->item(i, j)->setText(QChar(s[i * 8 + j])); + } + sprintf(&hex[0], "%02X", (unsigned char)binary[i]); + table->item(i, 8)->setText(hex); + } +} + +MessagesView::MessagesView(QWidget *parent) : QWidget(parent) { + QVBoxLayout *main_layout = new QVBoxLayout(this); + QLabel *title = new QLabel("MESSAGE TIME BYTES"); + main_layout->addWidget(title); + + message_layout = new QVBoxLayout(); + main_layout->addLayout(message_layout); + main_layout->addStretch(); +} + +void MessagesView::setMessages(const std::list &list) { + auto begin = list.begin(); + std::advance(begin, std::max(0, (int)(list.size() - 100))); + int j = 0; + for (auto it = begin; it != list.end(); ++it) { + QLabel *label; + if (j >= messages.size()) { + label = new QLabel(); + message_layout->addWidget(label); + messages.push_back(label); + } else { + label = messages[j]; + } + label->setText(it->hex_dat); + ++j; + } +} + +EditMessageDialog::EditMessageDialog(const QString &id, QWidget *parent) : QDialog(parent) { + setWindowTitle(tr("Edit message")); + QVBoxLayout *main_layout = new QVBoxLayout(this); + main_layout->addWidget(new QLabel(tr("ID: (%1)").arg(id))); + + auto msg = const_cast(parser->getMsg(Parser::addressFromId(id))); + QHBoxLayout *h_layout = new QHBoxLayout(); + h_layout->addWidget(new QLabel(tr("Name"))); + h_layout->addStretch(); + QLineEdit *name_edit = new QLineEdit(this); + name_edit->setText(msg ? msg->name.c_str() : "untitled"); + h_layout->addWidget(name_edit); + main_layout->addLayout(h_layout); + + h_layout = new QHBoxLayout(); + h_layout->addWidget(new QLabel(tr("Size"))); + h_layout->addStretch(); + QSpinBox *size_spin = new QSpinBox(this); + size_spin->setValue(msg ? msg->size : parser->can_msgs[id].back().dat.size()); + h_layout->addWidget(size_spin); + main_layout->addLayout(h_layout); + + auto buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); + main_layout->addWidget(buttonBox); + + connect(buttonBox, &QDialogButtonBox::accepted, [=]() { + if (size_spin->value() <= 0 || name_edit->text().isEmpty()) return; + + if (msg) { + msg->name = name_edit->text().toStdString(); + msg->size = size_spin->value(); + } else { + Msg m = {}; + m.address = Parser::addressFromId(id); + m.name = name_edit->text().toStdString(); + m.size = size_spin->value(); + parser->addNewMsg(m); + } + QDialog::accept(); + }); + connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); +} + +AddSignalDialog::AddSignalDialog(const QString &id, QWidget *parent) : QDialog(parent) { + setWindowTitle(tr("Add signal to %1").arg(parser->getMsg(id)->name.c_str())); + QVBoxLayout *main_layout = new QVBoxLayout(this); + Signal sig = {.name = "untitled"}; + auto form = new SignalForm(sig, this); + main_layout->addWidget(form); + auto buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); + main_layout->addWidget(buttonBox); + connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); + connect(buttonBox, &QDialogButtonBox::accepted, [=]() { + if (auto msg = const_cast(parser->getMsg(id))) { + if (auto signal = form->getSignal()) { + msg->sigs.push_back(*signal); + } + } + QDialog::accept(); + }); +} diff --git a/tools/cabana/detailwidget.h b/tools/cabana/detailwidget.h new file mode 100644 index 0000000000..70f2804f70 --- /dev/null +++ b/tools/cabana/detailwidget.h @@ -0,0 +1,102 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "opendbc/can/common.h" +#include "opendbc/can/common_dbc.h" +#include "selfdrive/ui/qt/widgets/controls.h" +#include "tools/cabana/parser.h" + +class SignalForm : public QWidget { + Q_OBJECT + + public: + SignalForm(const Signal &sig, QWidget *parent); + std::optional getSignal(); + QLineEdit *name, *unit, *comment, *val_desc; + QSpinBox *size, *msb, *lsb, *factor, *offset, *min_val, *max_val; + QComboBox *sign, *endianness; +}; + +class MessagesView : public QWidget { + Q_OBJECT + + public: + MessagesView(QWidget *parent); + void setMessages(const std::list &data); + std::vector messages; + QVBoxLayout *message_layout; +}; + +class BinaryView : public QWidget { + Q_OBJECT + + public: + BinaryView(QWidget *parent); + void setMsg(const QString &id); + void setData(const QByteArray &binary); + + QTableWidget *table; +}; + +class SignalEdit : public QWidget { + Q_OBJECT + + public: + SignalEdit(const QString &id, const Signal &sig, int idx, QWidget *parent); + void save(); + +signals: + void removed(); + protected: + void remove(); + QString id; + QString name_; + ElidedLabel *title; + SignalForm *form; + QWidget *edit_container; + QPushButton *remove_btn; +}; + +class DetailWidget : public QWidget { + Q_OBJECT + public: + DetailWidget(QWidget *parent); + void setMsg(const QString &id); + + public slots: + void updateState(); + + protected: + QLabel *name_label = nullptr; + QPushButton *edit_btn, *add_sig_btn; + QVBoxLayout *signal_edit_layout; + Signal *sig = nullptr; + MessagesView *messages_view; + QString msg_id; + BinaryView *binary_view; + std::vector signal_edit; +}; + +class EditMessageDialog : public QDialog { + Q_OBJECT + + public: + EditMessageDialog(const QString &id, QWidget *parent); +}; + +class AddSignalDialog : public QDialog { + Q_OBJECT + + public: + AddSignalDialog(const QString &id, QWidget *parent); +}; diff --git a/tools/cabana/mainwin.cc b/tools/cabana/mainwin.cc new file mode 100644 index 0000000000..d1b0d98f5f --- /dev/null +++ b/tools/cabana/mainwin.cc @@ -0,0 +1,38 @@ +#include "tools/cabana/mainwin.h" + +#include +#include + +MainWindow::MainWindow() : QWidget() { + assert(parser != nullptr); + + QVBoxLayout *main_layout = new QVBoxLayout(this); + QHBoxLayout *h_layout = new QHBoxLayout(); + main_layout->addLayout(h_layout); + + messages_widget = new MessagesWidget(this); + QObject::connect(messages_widget, &MessagesWidget::msgChanged, [=](const QString &id) { + detail_widget->setMsg(id); + }); + h_layout->addWidget(messages_widget); + + detail_widget = new DetailWidget(this); + h_layout->addWidget(detail_widget); + + // right widget + QWidget *right_container = new QWidget(this); + right_container->setFixedWidth(640); + QVBoxLayout *r_layout = new QVBoxLayout(right_container); + video_widget = new VideoWidget(this); + r_layout->addWidget(video_widget); + + charts_widget = new ChartsWidget(this); + QScrollArea *scroll = new QScrollArea(this); + scroll->setWidget(charts_widget); + scroll->setWidgetResizable(true); + scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + scroll->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Expanding); + r_layout->addWidget(scroll); + + h_layout->addWidget(right_container); +} diff --git a/tools/cabana/mainwin.h b/tools/cabana/mainwin.h new file mode 100644 index 0000000000..f19c48297e --- /dev/null +++ b/tools/cabana/mainwin.h @@ -0,0 +1,22 @@ +#pragma once + +#include + +#include "tools/cabana/chartswidget.h" +#include "tools/cabana/detailwidget.h" +#include "tools/cabana/messageswidget.h" +#include "tools/cabana/parser.h" +#include "tools/cabana/videowidget.h" + +class MainWindow : public QWidget { + Q_OBJECT + + public: + MainWindow(); + + protected: + VideoWidget *video_widget; + MessagesWidget *messages_widget; + DetailWidget *detail_widget; + ChartsWidget *charts_widget; +}; diff --git a/tools/cabana/messageswidget.cc b/tools/cabana/messageswidget.cc new file mode 100644 index 0000000000..d81d3e9ede --- /dev/null +++ b/tools/cabana/messageswidget.cc @@ -0,0 +1,94 @@ +#include "tools/cabana/messageswidget.h" + +#include +#include +#include +#include +#include +#include + +MessagesWidget::MessagesWidget(QWidget *parent) : QWidget(parent) { + QVBoxLayout *main_layout = new QVBoxLayout(this); + + QHBoxLayout *dbc_file_layout = new QHBoxLayout(); + QComboBox *combo = new QComboBox(this); + auto dbc_names = get_dbc_names(); + for (const auto &name : dbc_names) { + combo->addItem(QString::fromStdString(name)); + } + connect(combo, &QComboBox::currentTextChanged, [=](const QString &dbc) { + parser->openDBC(dbc); + }); + // For test purpose + combo->setCurrentText("toyota_nodsu_pt_generated"); + dbc_file_layout->addWidget(combo); + + dbc_file_layout->addStretch(); + QPushButton *save_btn = new QPushButton(tr("Save DBC"), this); + QObject::connect(save_btn, &QPushButton::clicked, [=]() { + // TODO: save DBC to file + }); + dbc_file_layout->addWidget(save_btn); + + main_layout->addLayout(dbc_file_layout); + + filter = new QLineEdit(this); + filter->setPlaceholderText(tr("filter messages")); + main_layout->addWidget(filter); + + table_widget = new QTableWidget(this); + table_widget->setSelectionBehavior(QAbstractItemView::SelectRows); + table_widget->setSelectionMode(QAbstractItemView::SingleSelection); + table_widget->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Expanding); + table_widget->setColumnCount(4); + table_widget->setColumnWidth(0, 250); + table_widget->setColumnWidth(1, 80); + table_widget->setColumnWidth(2, 80); + table_widget->setHorizontalHeaderLabels({tr("Name"), tr("ID"), tr("Count"), tr("Bytes")}); + table_widget->horizontalHeader()->setStretchLastSection(true); + QObject::connect(table_widget, &QTableWidget::itemSelectionChanged, [=]() { + auto id = table_widget->selectedItems()[0]->data(Qt::UserRole); + emit msgChanged(id.toString()); + }); + main_layout->addWidget(table_widget); + + connect(parser, &Parser::updated, this, &MessagesWidget::updateState); +} + +void MessagesWidget::updateState() { + auto getTableItem = [=](int row, int col) -> QTableWidgetItem * { + auto item = table_widget->item(row, col); + if (!item) { + item = new QTableWidgetItem(); + table_widget->setItem(row, col, item); + } + return item; + }; + + table_widget->setRowCount(parser->can_msgs.size()); + int i = 0; + const QString filter_str = filter->text().toLower(); + for (const auto &[id, list] : parser->can_msgs) { + assert(!list.empty()); + + QString name; + if (auto msg = parser->getMsg(list.back().address)) { + name = msg->name.c_str(); + } else { + name = tr("untitled"); + } + if (!filter_str.isEmpty() && !name.toLower().contains(filter_str)) { + table_widget->hideRow(i++); + continue; + } + + auto item = getTableItem(i, 0); + item->setText(name); + item->setData(Qt::UserRole, id); + getTableItem(i, 1)->setText(id); + getTableItem(i, 2)->setText(QString("%1").arg(parser->counters[id])); + getTableItem(i, 3)->setText(list.back().hex_dat); + table_widget->showRow(i); + i++; + } +} diff --git a/tools/cabana/messageswidget.h b/tools/cabana/messageswidget.h new file mode 100644 index 0000000000..dca725199d --- /dev/null +++ b/tools/cabana/messageswidget.h @@ -0,0 +1,24 @@ +#pragma once + +#include +#include +#include + +#include "tools/cabana/parser.h" + +class MessagesWidget : public QWidget { + Q_OBJECT + + public: + MessagesWidget(QWidget *parent); + + public slots: + void updateState(); + + signals: + void msgChanged(const QString &id); + + protected: + QLineEdit *filter; + QTableWidget *table_widget; +}; diff --git a/tools/cabana/parser.cc b/tools/cabana/parser.cc new file mode 100644 index 0000000000..481f0dfbdf --- /dev/null +++ b/tools/cabana/parser.cc @@ -0,0 +1,98 @@ +#include "tools/cabana/parser.h" + +#include + +#include "cereal/messaging/messaging.h" + +Parser::Parser(QObject *parent) : QObject(parent) { + qRegisterMetaType>(); + QObject::connect(this, &Parser::received, this, &Parser::process, Qt::QueuedConnection); + + thread = new QThread(); + connect(thread, &QThread::started, [=]() { recvThread(); }); + QObject::connect(thread, &QThread::finished, thread, &QThread::deleteLater); + thread->start(); +} + +Parser::~Parser() { + replay->stop(); + exit = true; + thread->quit(); + thread->wait(); +} + +bool Parser::loadRoute(const QString &route, const QString &data_dir, bool use_qcam) { + replay = new Replay(route, {"can", "roadEncodeIdx"}, {}, nullptr, use_qcam ? REPLAY_FLAG_QCAMERA : 0, data_dir, this); + if (!replay->load()) { + return false; + } + replay->start(); + return true; +} + +void Parser::openDBC(const QString &name) { + dbc_name = name; + dbc = const_cast(dbc_lookup(name.toStdString())); + msg_map.clear(); + for (auto &msg : dbc->msgs) { + msg_map[msg.address] = &msg; + } +} + +void Parser::process(std::vector can) { + for (auto &data : can) { + ++counters[data.id]; + auto &list = can_msgs[data.id]; + while (list.size() > DATA_LIST_SIZE) { + list.pop_front(); + } + list.push_back(data); + } + emit updated(); +} + +void Parser::recvThread() { + AlignedBuffer aligned_buf; + std::unique_ptr context(Context::create()); + std::unique_ptr subscriber(SubSocket::create(context.get(), "can")); + subscriber->setTimeout(100); + while (!exit) { + std::unique_ptr msg(subscriber->receive()); + if (!msg) continue; + + capnp::FlatArrayMessageReader cmsg(aligned_buf.align(msg.get())); + cereal::Event::Reader event = cmsg.getRoot(); + std::vector can; + can.reserve(event.getCan().size()); + for (const auto &c : event.getCan()) { + CanData &data = can.emplace_back(); + data.address = c.getAddress(); + data.bus_time = c.getBusTime(); + data.source = c.getSrc(); + data.dat.append((char *)c.getDat().begin(), c.getDat().size()); + data.hex_dat = data.dat.toHex(' ').toUpper(); + data.id = QString("%1:%2").arg(data.source).arg(data.address, 1, 16); + data.ts = (event.getLogMonoTime() - replay->routeStartTime()) / (double)1e6; + } + emit received(can); + } +} + +void Parser::addNewMsg(const Msg &msg) { + dbc->msgs.push_back(msg); + msg_map[dbc->msgs.back().address] = &dbc->msgs.back(); +} + +void Parser::removeSignal(const QString &id, const QString &sig_name) { + Msg *msg = const_cast(getMsg(id)); + if (!msg) return; + + auto it = std::find_if(msg->sigs.begin(), msg->sigs.end(), [=](auto &sig) { return sig_name == sig.name.c_str(); }); + if (it != msg->sigs.end()) { + msg->sigs.erase(it); + } +} + +uint32_t Parser::addressFromId(const QString &id) { + return id.mid(id.indexOf(':') + 1).toUInt(nullptr, 16); +} diff --git a/tools/cabana/parser.h b/tools/cabana/parser.h new file mode 100644 index 0000000000..fd5ded7c5e --- /dev/null +++ b/tools/cabana/parser.h @@ -0,0 +1,66 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "opendbc/can/common.h" +#include "opendbc/can/common_dbc.h" +#include "tools/replay/replay.h" + +const int DATA_LIST_SIZE = 500; +// const int FPS = 20; + +struct CanData { + QString id; + double ts; + uint32_t address; + uint16_t bus_time; + uint8_t source; + QByteArray dat; + QString hex_dat; +}; + +class Parser : public QObject { + Q_OBJECT + + public: + Parser(QObject *parent); + ~Parser(); + static uint32_t addressFromId(const QString &id); + bool loadRoute(const QString &route, const QString &data_dir, bool use_qcam); + void openDBC(const QString &name); + void saveDBC(const QString &name) {} + void addNewMsg(const Msg &msg); + void removeSignal(const QString &id, const QString &sig_name); + const Msg *getMsg(const QString &id) { + return getMsg(addressFromId(id)); + } + const Msg *getMsg(uint32_t address) { + auto it = msg_map.find(address); + return it != msg_map.end() ? it->second : nullptr; + } + signals: + void showPlot(const QString &id, const QString &name); + void hidePlot(const QString &id, const QString &name); + void received(std::vector can); + void updated(); + + public: + void recvThread(); + void process(std::vector can); + QThread *thread; + QString dbc_name; + std::atomic exit = false; + std::map> can_msgs; + std::map counters; + Replay *replay = nullptr; + DBC *dbc = nullptr; + std::map msg_map; +}; + +Q_DECLARE_METATYPE(std::vector); + +extern Parser *parser; diff --git a/tools/cabana/videowidget.cc b/tools/cabana/videowidget.cc new file mode 100644 index 0000000000..caa109eab0 --- /dev/null +++ b/tools/cabana/videowidget.cc @@ -0,0 +1,80 @@ +#include "tools/cabana/videowidget.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "tools/cabana/parser.h" + +inline QString formatTime(int seconds) { + return QDateTime::fromTime_t(seconds).toString(seconds > 60 * 60 ? "hh::mm::ss" : "mm::ss"); +} + +VideoWidget::VideoWidget(QWidget *parent) : QWidget(parent) { + QVBoxLayout *main_layout = new QVBoxLayout(this); + + cam_widget = new CameraViewWidget("camerad", VISION_STREAM_ROAD, true, this); + cam_widget->setFixedSize(640, 480); + main_layout->addWidget(cam_widget); + + // slider controls + QHBoxLayout *slider_layout = new QHBoxLayout(); + QLabel *time_label = new QLabel("00:00"); + slider_layout->addWidget(time_label); + + slider = new QSlider(Qt::Horizontal, this); + // slider->setFixedWidth(640); + slider->setSingleStep(1); + slider->setMaximum(parser->replay->totalSeconds()); + QObject::connect(slider, &QSlider::sliderReleased, [=]() { + time_label->setText(formatTime(slider->value())); + parser->replay->seekTo(slider->value(), false); + }); + slider_layout->addWidget(slider); + + QLabel *total_time_label = new QLabel(formatTime(parser->replay->totalSeconds())); + slider_layout->addWidget(total_time_label); + + main_layout->addLayout(slider_layout); + + // btn controls + QHBoxLayout *control_layout = new QHBoxLayout(); + QPushButton *play = new QPushButton("⏸"); + play->setStyleSheet("font-weight:bold"); + QObject::connect(play, &QPushButton::clicked, [=]() { + bool is_paused = parser->replay->isPaused(); + play->setText(is_paused ? "⏸" : "▶"); + parser->replay->pause(!is_paused); + }); + control_layout->addWidget(play); + + QButtonGroup *group = new QButtonGroup(this); + group->setExclusive(true); + for (float speed : {0.1, 0.5, 1., 2.}) { + QPushButton *btn = new QPushButton(QString("%1x").arg(speed), this); + btn->setCheckable(true); + QObject::connect(btn, &QPushButton::clicked, [=]() { + parser->replay->setSpeed(speed); + }); + control_layout->addWidget(btn); + group->addButton(btn); + if (speed == 1.0) btn->setChecked(true); + } + + main_layout->addLayout(control_layout); + + QTimer *timer = new QTimer(this); + timer->setInterval(1000); + timer->callOnTimeout([=]() { + int current_seconds = parser->replay->currentSeconds(); + time_label->setText(formatTime(current_seconds)); + if (!slider->isSliderDown()) { + slider->setValue(current_seconds); + } + }); + timer->start(); +} diff --git a/tools/cabana/videowidget.h b/tools/cabana/videowidget.h new file mode 100644 index 0000000000..f0b9e458bd --- /dev/null +++ b/tools/cabana/videowidget.h @@ -0,0 +1,17 @@ +#pragma once + +#include +#include + +#include "selfdrive/ui/qt/widgets/cameraview.h" + +class VideoWidget : public QWidget { + Q_OBJECT + +public: + VideoWidget(QWidget *parnet = nullptr); + +protected: + CameraViewWidget *cam_widget; + QSlider *slider; +}; diff --git a/tools/replay/SConscript b/tools/replay/SConscript index d3967708fa..9985375688 100644 --- a/tools/replay/SConscript +++ b/tools/replay/SConscript @@ -18,6 +18,7 @@ if arch in ['x86_64', 'Darwin'] or GetOption('extras'): replay_lib_src = ["replay.cc", "consoleui.cc", "camera.cc", "filereader.cc", "logreader.cc", "framereader.cc", "route.cc", "util.cc"] replay_lib = qt_env.Library("qt_replay", replay_lib_src, LIBS=qt_libs, FRAMEWORKS=base_frameworks) + Export('replay_lib') replay_libs = [replay_lib, 'avutil', 'avcodec', 'avformat', 'bz2', 'curl', 'yuv', 'ncurses'] + qt_libs qt_env.Program("replay", ["main.cc"], LIBS=replay_libs, FRAMEWORKS=base_frameworks) diff --git a/tools/replay/replay.cc b/tools/replay/replay.cc index c6c78f47ae..1684dfaca9 100644 --- a/tools/replay/replay.cc +++ b/tools/replay/replay.cc @@ -382,7 +382,7 @@ void Replay::stream() { if (cur_which < sockets_.size() && sockets_[cur_which] != nullptr) { // keep time - long etime = cur_mono_time_ - evt_start_ts; + long etime = (cur_mono_time_ - evt_start_ts) / speed_; long rtime = nanos_since_boot() - loop_start_ts; long behind_ns = etime - rtime; // if behind_ns is greater than 1 second, it means that an invalid segemnt is skipped by seeking/replaying diff --git a/tools/replay/replay.h b/tools/replay/replay.h index e86c453f7e..3327362f97 100644 --- a/tools/replay/replay.h +++ b/tools/replay/replay.h @@ -52,8 +52,11 @@ public: inline void removeFlag(REPLAY_FLAGS flag) { flags_ &= ~flag; } inline const Route* route() const { return route_.get(); } inline int currentSeconds() const { return (cur_mono_time_ - route_start_ts_) / 1e9; } + inline uint64_t routeStartTime() const { return route_start_ts_; } inline int toSeconds(uint64_t mono_time) const { return (mono_time - route_start_ts_) / 1e9; } inline int totalSeconds() const { return segments_.size() * 60; } + inline void setSpeed(float speed) { speed_ = speed; } + inline float getSpeed() const { return speed_; } inline const std::string &carFingerprint() const { return car_fingerprint_; } inline const std::vector> getTimeline() { std::lock_guard lk(timeline_lock); @@ -112,4 +115,5 @@ protected: QFuture timeline_future; std::vector> timeline; std::string car_fingerprint_; + float speed_ = 1.0; }; diff --git a/tools/ubuntu_setup.sh b/tools/ubuntu_setup.sh index 863b853718..7e021bcc23 100755 --- a/tools/ubuntu_setup.sh +++ b/tools/ubuntu_setup.sh @@ -63,6 +63,7 @@ function install_ubuntu_common_requirements() { qttools5-dev-tools \ libqt5sql5-sqlite \ libqt5svg5-dev \ + libqt5charts5-dev \ libqt5x11extras5-dev \ libreadline-dev \ libdw1 \