sunnypilot v2026.08.02-4605

version: sunnypilot v2026.003.000 (dev)
date: 2026-08-02T04:29:20
master commit: 1a07e47228
This commit is contained in:
github-actions[bot]
2026-08-02 04:29:20 +00:00
commit ed3a61840f
4011 changed files with 1263768 additions and 0 deletions
View File
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env python3
import sys
from openpilot.tools.lib.logreader import LogReader, ReadMode
def main():
if len(sys.argv) != 2:
print("Usage: python auto_source.py <log_path>")
sys.exit(1)
log_path = sys.argv[1]
lr = LogReader(log_path, default_mode=ReadMode.AUTO, sort_by_time=True)
print("\n".join(lr.logreader_identifiers))
if __name__ == "__main__":
main()
+11
View File
@@ -0,0 +1,11 @@
moc_*
*.moc
*.generated.qrc
assets.cc
bootstrap_icons.cc
_cabana
dbc/car_fingerprint_to_dbc.json
tests/test_cabana
tests/test_dbc_core
+100
View File
@@ -0,0 +1,100 @@
# 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
--auto Auto load the route from the best available source (no video):
internal, openpilotci, comma_api, car_segments, testing_closet
--qcam load qcamera
--ecam load wide road camera
--msgq read can messages from msgq
--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 <ip-address> read can messages from zmq at the specified ip-address
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
```
## Examples
### Running Cabana in Demo Mode
To run Cabana using a built-in demo route, use the following command:
```shell
cabana --demo
```
### Loading a Specific Route
To load a specific route for replay, provide the route as an argument:
```shell
cabana "5beb9b58bd12b691/0000010a--a51155e496"
```
Replace "5beb9b58bd12b691/0000010a--a51155e496" with your desired route identifier.
### Running Cabana with multiple cameras
To run Cabana with multiple cameras, use the following command:
```shell
cabana "5beb9b58bd12b691/0000010a--a51155e496" --dcam --ecam
```
### Streaming CAN Messages from a comma Device
[SSH into your device](https://github.com/commaai/openpilot/wiki/SSH) and start the bridge with the following command:
```shell
cd /data/openpilot
./openpilot/cereal/messaging/bridge &
```
Then Run Cabana with the device's IP address:
```shell
cabana --zmq <ipaddress>
```
Replace &lt;ipaddress&gt; with your comma device's IP address.
While streaming from the device, Cabana will log the CAN messages to a local directory. By default, this directory is ~/cabana_live_stream/. You can change the log directory in Cabana by navigating to menu -> tools -> settings.
After disconnecting from the device, you can replay the logged CAN messages from the stream selector dialog -> browse local route.
### Streaming CAN Messages from Panda
To read CAN messages from a connected Panda, use the following command:
```shell
cabana --panda
```
### Using the Stream Selector Dialog
If you run Cabana without any arguments, a stream selector dialog will pop up, allowing you to choose the stream.
```shell
cabana
```
## Additional Information
For more information, see the [openpilot wiki](https://github.com/commaai/openpilot/wiki/Cabana)
+5
View File
@@ -0,0 +1,5 @@
<!DOCTYPE RCC><RCC version="1.0">
<qresource>
<file>cabana-icon.png</file>
</qresource>
</RCC>
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

+510
View File
@@ -0,0 +1,510 @@
#include "tools/cabana/binaryview.h"
#include "tools/cabana/dbc/dbcqt.h"
#include <algorithm>
#include <cstdio>
#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(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, this, &BinaryView::refresh);
QObject::connect(undoNotifier(), &QtUndoNotifier::indexChanged, this, &BinaryView::refresh);
addShortcuts();
setWhatsThis(R"(
<b>Binary View</b><br/>
<!-- TODO: add description here -->
<span style="color:gray">Shortcuts</span><br />
Delete Signal:
<span style="background-color:lightGray;color:gray">&nbsp;x&nbsp;</span>,
<span style="background-color:lightGray;color:gray">&nbsp;Backspace&nbsp;</span>,
<span style="background-color:lightGray;color:gray">&nbsp;Delete&nbsp;</span><br />
Change endianness: <span style="background-color:lightGray;color:gray">&nbsp;e&nbsp; </span><br />
Change signedness: <span style="background-color:lightGray;color:gray">&nbsp;s&nbsp;</span><br />
Open chart:
<span style="background-color:lightGray;color:gray">&nbsp;c&nbsp;</span>,
<span style="background-color:lightGray;color:gray">&nbsp;p&nbsp;</span>,
<span style="background-color:lightGray;color:gray">&nbsp;g&nbsp;</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::instance()->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;
auto has = [](const auto &v, auto p) { return std::find(v.begin(), v.end(), p) != v.end(); };
if ((sig && has(item_sigs, sig)) || (hovered_sig && has(item_sigs, 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(last_mouse_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(last_mouse_pos = 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(pos); index.isValid()) {
auto item = (BinaryViewModel::Item *)index.internalPointer();
const cabana::Signal *sig = item->sigs.empty() ? nullptr : item->sigs.back();
highlight(sig);
}
}
void BinaryView::mouseMoveEvent(QMouseEvent *event) {
highlightPosition(last_mouse_pos = event->pos());
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::instance()->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();
if (underMouse()) highlightPosition(last_mouse_pos);
}
std::set<const cabana::Signal *> BinaryView::getOverlappingSignals() const {
std::set<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.insert(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();
bit_flip_tracker = {};
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()) {
fprintf(stderr, "signal %s out of bounds.start_bit: %d size: %d\n",
sig->name.c_str(), sig->start_bit, 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);
}
endResetModel();
updateState();
}
void BinaryViewModel::updateItem(int row, int col, uint8_t val, const QColor &color) {
auto &item = items[row * column_count + col];
item.valid = true;
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;
// Handle size changes in binary data
if (binary.size() > row_count) {
beginInsertRows({}, row_count, binary.size() - 1);
row_count = binary.size();
items.resize(row_count * column_count);
endInsertRows();
}
auto &bit_flips = heatmap_live_mode ? last_msg.bit_flip_counts : getBitFlipChanges(binary.size());
// Find the maximum bit flip count across the message
uint32_t max_bit_flip_count = 1; // Default to 1 to avoid division by zero
for (const auto &row : bit_flips) {
for (uint32_t count : row) {
max_bit_flip_count = std::max(max_bit_flip_count, count);
}
}
const double max_alpha = 255.0;
const double min_alpha_with_signal = 25.0; // Base alpha for small flip counts
const double min_alpha_no_signal = 10.0; // Base alpha for small flip counts for no signal bits
const double log_factor = 1.0 + 0.2; // Factor for logarithmic scaling
const double log_scaler = max_alpha / log2(log_factor * max_bit_flip_count);
for (size_t i = 0; i < binary.size(); ++i) {
for (int j = 0; j < 8; ++j) {
auto &item = items[i * column_count + j];
int bit_val = (binary[i] >> (7 - j)) & 1;
double alpha = item.sigs.empty() ? 0 : min_alpha_with_signal;
uint32_t flip_count = bit_flips[i][j];
if (flip_count > 0) {
double normalized_alpha = log2(1.0 + flip_count * log_factor) * log_scaler;
double min_alpha = item.sigs.empty() ? min_alpha_no_signal : min_alpha_with_signal;
alpha = std::clamp(normalized_alpha, min_alpha, max_alpha);
}
auto color = item.bg_color;
color.setAlpha(alpha);
updateItem(i, j, bit_val, color);
}
updateItem(i, 8, binary[i], toQColor(last_msg.colors[i]));
}
}
const std::vector<std::array<uint32_t, 8>> &BinaryViewModel::getBitFlipChanges(size_t msg_size) {
// Return cached results if time range and data are unchanged
auto time_range = can->timeRange();
if (bit_flip_tracker.time_range == time_range && !bit_flip_tracker.flip_counts.empty())
return bit_flip_tracker.flip_counts;
bit_flip_tracker.time_range = time_range;
bit_flip_tracker.flip_counts.assign(msg_size, std::array<uint32_t, 8>{});
// Iterate over events within the specified time range and calculate bit flips
auto [first, last] = can->eventsInRange(msg_id, time_range);
if (std::distance(first, last) <= 1) return bit_flip_tracker.flip_counts;
std::vector<uint8_t> prev_values((*first)->dat, (*first)->dat + (*first)->size);
for (auto it = std::next(first); it != last; ++it) {
const CanEvent *event = *it;
int size = std::min<int>(msg_size, event->size);
for (int i = 0; i < size; ++i) {
const uint8_t diff = event->dat[i] ^ prev_values[i];
if (!diff) continue;
auto &bit_flips = bit_flip_tracker.flip_counts[i];
for (int bit = 0; bit < 8; ++bit) {
if (diff & (1u << bit)) ++bit_flips[7 - bit];
}
prev_values[i] = event->dat[i];
}
}
return bit_flip_tracker.flip_counts;
}
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;
if (idx < 0 || idx >= (int)model->items.size()) return false;
auto &s = model->items[idx].sigs;
return std::find(s.begin(), s.end(), sig) != s.end();
}
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 ? toQColor(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() || std::find(item->sigs.begin(), item->sigs.end(), bin_view->resize_sig) == item->sigs.end()) { // not resizing
if (item->sigs.size() > 0) {
for (auto &s : item->sigs) {
if (s == bin_view->hovered_sig) {
painter->fillRect(option.rect, toQColor(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 = (std::find(item->sigs.begin(), item->sigs.end(), bin_view->hovered_sig) != item->sigs.end()) ? QPalette::BrightText : QPalette::Text;
painter->setPen(option.palette.color(bin_view->is_message_active ? QPalette::Normal : QPalette::Disabled, 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 = toQColor(sig->color);
color.setAlpha(item->bg_color.alpha());
// Mixing the signal color with the Base background color to fade it
painter->fillRect(rc, option.palette.color(QPalette::Base));
painter->fillRect(rc, color);
// Draw edges
color = toQColor(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);
}
}
}
+104
View File
@@ -0,0 +1,104 @@
#pragma once
#include <set>
#include <tuple>
#include <vector>
#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;
}
const std::vector<std::array<uint32_t, 8>> &getBitFlipChanges(size_t msg_size);
struct BitFlipTracker {
std::optional<std::pair<double, double>> time_range;
std::vector<std::array<uint32_t, 8>> flip_counts;
} bit_flip_tracker;
struct Item {
QColor bg_color = QColor(102, 86, 169, 255);
bool is_msb = false;
bool is_lsb = false;
uint8_t val;
std::vector<const cabana::Signal *> sigs;
bool valid = false;
};
std::vector<Item> items;
bool heatmap_live_mode = true;
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);
std::set<const cabana::Signal*> getOverlappingSignals() const;
void updateState() { model->updateState(); }
void paintEvent(QPaintEvent *event) override {
is_message_active = can->isMessageActive(model->msg_id);
QTableView::paintEvent(event);
}
QSize minimumSizeHint() const override;
void setHeatmapLiveMode(bool live) { model->heatmap_live_mode = live; updateState(); }
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;
QPoint last_mouse_pos{-1, -1};
BinaryViewModel *model;
BinaryItemDelegate *delegate;
bool is_message_active = false;
const cabana::Signal *resize_sig = nullptr;
const cabana::Signal *hovered_sig = nullptr;
friend class BinaryItemDelegate;
};
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
set -e
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )"
ROOT="$(cd "$DIR/../../../" && pwd)"
install_qt() {
if [[ "$(uname)" == "Darwin" ]]; then
brew install qt@5
brew link qt@5 || true
else
SUDO=""
if [[ ! $(id -u) -eq 0 ]]; then
SUDO="sudo"
fi
$SUDO apt-get install -y --no-install-recommends \
qtbase5-dev \
qtbase5-dev-tools \
qttools5-dev-tools \
libqt5charts5-dev \
libqt5svg5-dev \
libqt5serialbus5-dev \
libqt5x11extras5-dev \
libqt5opengl5-dev
fi
}
# Install Qt if not found
if ! command -v qmake &> /dev/null; then
echo "Qt not found, installing dependencies..."
install_qt
fi
# Build _cabana
cd "$ROOT"
scons -u openpilot/tools/cabana/_cabana openpilot/cereal/messaging/bridge
exec "$DIR/_cabana" "$@"
+187
View File
@@ -0,0 +1,187 @@
#include <cstdio>
#include <cstring>
#include <memory>
#include <string>
#include <QApplication>
#include "tools/cabana/mainwin.h"
#include "tools/cabana/streams/devicestream.h"
#include "tools/cabana/streams/pandastream.h"
#include "tools/cabana/streams/replaystream.h"
#ifdef __linux__
#include "tools/cabana/streams/socketcanstream.h"
#endif
namespace {
struct CabanaArgs {
bool demo = false;
bool auto_source = false;
bool qcam = false;
bool ecam = false;
bool dcam = false;
bool msgq = false;
bool panda = false;
bool no_vipc = false;
std::string panda_serial;
std::string socketcan;
std::string zmq;
std::string data_dir;
std::string dbc;
std::string route;
};
void printUsage(const char *argv0) {
fprintf(stderr,
"Usage: %s [options] [route]\n"
"\n"
" route the drive to replay. find your drives at connect.comma.ai\n"
"\n"
"Options:\n"
" --help show this help\n"
" --demo use a demo route instead of providing your own\n"
" --auto Auto load the route from the best available source (no video):\n"
" internal, openpilotci, comma_api, car_segments, testing_closet\n"
" --qcam load qcamera\n"
" --ecam load wide road camera\n"
" --dcam load driver camera\n"
" --msgq read can messages from the msgq\n"
" --panda read can messages from panda\n"
" --panda-serial <serial> read can messages from panda with given serial\n"
#ifdef __linux__
" --socketcan <device> read can messages from given SocketCAN device\n"
#endif
" --zmq <ip-address> read can messages from zmq at the specified ip-address\n"
" --data_dir <dir> local directory with routes\n"
" --no-vipc do not output video\n"
" --dbc <file> dbc file to open\n",
argv0);
}
// Returns true if value was consumed from argv[i+1].
bool takeValue(int argc, char *argv[], int &i, std::string &out) {
if (i + 1 >= argc) {
fprintf(stderr, "error: %s requires a value\n", argv[i]);
return false;
}
out = argv[++i];
return true;
}
// Returns 0 to continue, or a process exit code (0 for --help, 1 for errors).
int parseArgs(int argc, char *argv[], CabanaArgs &args, bool &ok) {
ok = false;
for (int i = 1; i < argc; ++i) {
const char *a = argv[i];
if (std::strcmp(a, "--help") == 0 || std::strcmp(a, "-h") == 0) {
printUsage(argv[0]);
return 0;
} else if (std::strcmp(a, "--demo") == 0) {
args.demo = true;
} else if (std::strcmp(a, "--auto") == 0) {
args.auto_source = true;
} else if (std::strcmp(a, "--qcam") == 0) {
args.qcam = true;
} else if (std::strcmp(a, "--ecam") == 0) {
args.ecam = true;
} else if (std::strcmp(a, "--dcam") == 0) {
args.dcam = true;
} else if (std::strcmp(a, "--msgq") == 0) {
args.msgq = true;
} else if (std::strcmp(a, "--panda") == 0) {
args.panda = true;
} else if (std::strcmp(a, "--panda-serial") == 0) {
if (!takeValue(argc, argv, i, args.panda_serial)) return 1;
args.panda = true;
} else if (std::strcmp(a, "--socketcan") == 0) {
if (!takeValue(argc, argv, i, args.socketcan)) return 1;
#ifdef __linux__
#else
fprintf(stderr, "error: --socketcan is only supported on Linux\n");
return 1;
#endif
} else if (std::strcmp(a, "--zmq") == 0) {
if (!takeValue(argc, argv, i, args.zmq)) return 1;
} else if (std::strcmp(a, "--data_dir") == 0) {
if (!takeValue(argc, argv, i, args.data_dir)) return 1;
} else if (std::strcmp(a, "--no-vipc") == 0) {
args.no_vipc = true;
} else if (std::strcmp(a, "--dbc") == 0) {
if (!takeValue(argc, argv, i, args.dbc)) return 1;
} else if (a[0] == '-') {
fprintf(stderr, "error: unknown option %s\n", a);
printUsage(argv[0]);
return 1;
} else if (args.route.empty()) {
args.route = a;
} else {
fprintf(stderr, "error: unexpected argument %s\n", a);
printUsage(argv[0]);
return 1;
}
}
ok = true;
return 0;
}
} // namespace
int main(int argc, char *argv[]) {
QCoreApplication::setApplicationName("Cabana");
initApp(argc, argv, false);
QApplication app(argc, argv);
app.setApplicationDisplayName("Cabana");
//app.setWindowIcon(QIcon(":cabana-icon.png")); // TODO: do this in imgui
UnixSignalHandler signalHandler;
utils::setTheme(settings.theme);
CabanaArgs args;
bool args_ok = false;
if (const int code = parseArgs(argc, argv, args, args_ok); !args_ok) {
return code;
}
AbstractStream *stream = nullptr;
if (args.msgq) {
stream = new DeviceStream(&app);
} else if (!args.zmq.empty()) {
stream = new DeviceStream(&app, QString::fromStdString(args.zmq));
} else if (args.panda || !args.panda_serial.empty()) {
try {
stream = new PandaStream(&app, {.serial = args.panda_serial});
} catch (std::exception &e) {
fprintf(stderr, "%s\n", e.what());
return 0;
}
#ifdef __linux__
} else if (SocketCanStream::available() && !args.socketcan.empty()) {
stream = new SocketCanStream(&app, {.device = args.socketcan});
#endif
} else {
uint32_t replay_flags = REPLAY_FLAG_NONE;
if (args.ecam) replay_flags |= REPLAY_FLAG_ECAM;
if (args.qcam) replay_flags |= REPLAY_FLAG_QCAMERA;
if (args.dcam) replay_flags |= REPLAY_FLAG_DCAM;
if (args.no_vipc) replay_flags |= REPLAY_FLAG_NO_VIPC;
QString route;
if (!args.route.empty()) {
route = QString::fromStdString(args.route);
} else if (args.demo) {
route = DEMO_ROUTE;
}
if (!route.isEmpty()) {
auto replay_stream = std::make_unique<ReplayStream>(&app);
if (!replay_stream->loadRoute(route.toStdString(), args.data_dir, replay_flags, args.auto_source)) {
return 0;
}
stream = replay_stream.release();
}
}
MainWindow w(stream, QString::fromStdString(args.dbc));
return app.exec();
}
+125
View File
@@ -0,0 +1,125 @@
#include "tools/cabana/cameraview.h"
#include <algorithm>
#include <chrono>
#include <cmath>
#include <cstdio>
#include <QApplication>
#include <QPainter>
#include "common/yuv.h"
CameraWidget::CameraWidget(std::string stream_name, VisionStreamType type, QWidget* parent) :
stream_name(stream_name), active_stream_type(type), requested_stream_type(type), QWidget(parent) {
setAttribute(Qt::WA_OpaquePaintEvent);
qRegisterMetaType<std::set<VisionStreamType>>("availableStreams");
QObject::connect(this, &CameraWidget::vipcThreadFrameReceived, this, &CameraWidget::vipcFrameReceived, Qt::QueuedConnection);
QObject::connect(this, &CameraWidget::vipcAvailableStreamsUpdated, this, &CameraWidget::availableStreamsUpdated, Qt::QueuedConnection);
QObject::connect(QApplication::instance(), &QCoreApplication::aboutToQuit, this, &CameraWidget::stopVipcThread);
}
CameraWidget::~CameraWidget() {
stopVipcThread();
}
void CameraWidget::showEvent(QShowEvent *event) {
if (!vipc_thread.joinable()) {
clearFrames();
vipc_exit = false;
vipc_thread = std::thread(&CameraWidget::vipcThread, this);
}
}
void CameraWidget::stopVipcThread() {
vipc_exit = true;
if (vipc_thread.joinable()) {
vipc_thread.join();
}
}
void CameraWidget::availableStreamsUpdated(std::set<VisionStreamType> streams) {
available_streams = streams;
}
void CameraWidget::paintEvent(QPaintEvent *event) {
QPainter p(this);
p.fillRect(rect(), bg);
std::lock_guard lk(frame_lock);
if (rgb_frame.isNull()) return;
// Scale for aspect ratio
float widget_ratio = (float)width() / height();
float frame_ratio = (float)rgb_frame.width() / rgb_frame.height();
int w = std::lround(width() * std::min(frame_ratio / widget_ratio, 1.0f));
int h = std::lround(height() * std::min(widget_ratio / frame_ratio, 1.0f));
QRect video_rect((width() - w) / 2, (height() - h) / 2, w, h);
p.setRenderHint(QPainter::SmoothPixmapTransform);
if (active_stream_type == VISION_STREAM_DRIVER) {
// mirror driver camera horizontally
const qreal cx = video_rect.x() + video_rect.width() / 2.0;
p.translate(cx, 0);
p.scale(-1, 1);
p.translate(-cx, 0);
}
p.drawImage(video_rect, rgb_frame);
}
void CameraWidget::vipcFrameReceived() {
update();
}
void CameraWidget::vipcThread() {
VisionStreamType cur_stream = requested_stream_type;
std::unique_ptr<VisionIpcClient> vipc_client;
VisionIpcBufExtra frame_meta = {};
while (!vipc_exit) {
if (!vipc_client || cur_stream != requested_stream_type) {
clearFrames();
fprintf(stderr, "connecting to stream %d, was connected to %d\n",
(int)requested_stream_type, (int)cur_stream);
cur_stream = requested_stream_type;
vipc_client.reset(new VisionIpcClient(stream_name, cur_stream, false));
}
active_stream_type = cur_stream;
if (!vipc_client->connected) {
clearFrames();
auto streams = VisionIpcClient::getAvailableStreams(stream_name, false);
if (streams.empty()) {
std::this_thread::sleep_for(std::chrono::milliseconds(100));
continue;
}
emit vipcAvailableStreamsUpdated(streams);
if (!vipc_client->connect(false)) {
std::this_thread::sleep_for(std::chrono::milliseconds(100));
continue;
}
}
if (VisionBuf *buf = vipc_client->recv(&frame_meta, 100)) {
// NV12 -> RGBA once per frame on the receive thread; paint just draws the image
if (rgb_back.width() != (int)buf->width || rgb_back.height() != (int)buf->height) {
rgb_back = QImage(buf->width, buf->height, QImage::Format_RGBA8888);
}
yuv::nv12_to_rgba(buf->y, buf->stride, buf->uv, buf->stride,
rgb_back.bits(), rgb_back.bytesPerLine(), buf->width, buf->height);
{
std::lock_guard lk(frame_lock);
rgb_frame.swap(rgb_back);
}
emit vipcThreadFrameReceived();
}
}
}
void CameraWidget::clearFrames() {
std::lock_guard lk(frame_lock);
rgb_frame = QImage();
rgb_back = QImage();
available_streams.clear();
}
+55
View File
@@ -0,0 +1,55 @@
#pragma once
#include <atomic>
#include <mutex>
#include <set>
#include <string>
#include <thread>
#include <utility>
#include <QImage>
#include <QWidget>
#include "msgq/visionipc/visionipc_client.h"
class CameraWidget : public QWidget {
Q_OBJECT
public:
explicit CameraWidget(std::string stream_name, VisionStreamType stream_type, QWidget* parent = nullptr);
~CameraWidget();
void setStreamType(VisionStreamType type) { requested_stream_type = type; }
VisionStreamType getStreamType() { return active_stream_type; }
void stopVipcThread();
signals:
void clicked();
void vipcThreadFrameReceived();
void vipcAvailableStreamsUpdated(std::set<VisionStreamType>);
protected:
void paintEvent(QPaintEvent *event) override;
void showEvent(QShowEvent *event) override;
void hideEvent(QHideEvent *event) override { stopVipcThread(); }
void mouseReleaseEvent(QMouseEvent *event) override { emit clicked(); }
void vipcThread();
void clearFrames();
QColor bg = Qt::black;
QImage rgb_frame; // written by vipc thread, drawn by GUI thread; guarded by frame_lock
QImage rgb_back; // vipc thread only
std::string stream_name;
std::atomic<VisionStreamType> active_stream_type;
std::atomic<VisionStreamType> requested_stream_type;
std::set<VisionStreamType> available_streams;
std::thread vipc_thread;
std::atomic<bool> vipc_exit = false;
std::mutex frame_lock;
protected slots:
void vipcFrameReceived();
void availableStreamsUpdated(std::set<VisionStreamType> streams);
};
Q_DECLARE_METATYPE(std::set<VisionStreamType>);
+770
View File
@@ -0,0 +1,770 @@
#include "tools/cabana/chart/chart.h"
#include "tools/cabana/dbc/dbcqt.h"
#include <algorithm>
#include <limits>
#include <random>
#include <QActionGroup>
#include <QContextMenuEvent>
#include <QMouseEvent>
#include <QPainterPath>
#include "tools/cabana/chart/chartswidget.h"
const int AXIS_X_TOP_MARGIN = 4;
const int X_TICK_COUNT = 5;
const double MIN_ZOOM_SECONDS = 0.01; // 10ms
// 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); }
static QMargins layoutMargins(const QStyle *style) {
return {
style->pixelMetric(QStyle::PM_LayoutLeftMargin),
style->pixelMetric(QStyle::PM_LayoutTopMargin),
style->pixelMetric(QStyle::PM_LayoutRightMargin),
style->pixelMetric(QStyle::PM_LayoutBottomMargin),
};
}
ChartView::ChartView(const std::pair<double, double> &x_range, ChartsWidget *parent)
: x_min(x_range.first), x_max(x_range.second), charts_widget(parent), QWidget(parent) {
series_type = (SeriesType)settings.chart_series_type;
align_to = 50;
setMouseTracking(true);
tip_label = new TipLabel(this);
createToolButtons();
signal_value_font.setPointSize(9);
QObject::connect(dbcNotifier(), &QtDBCNotifier::signalRemoved, this, &ChartView::signalRemoved);
QObject::connect(dbcNotifier(), &QtDBCNotifier::signalUpdated, this, &ChartView::signalUpdated);
QObject::connect(dbcNotifier(), &QtDBCNotifier::msgRemoved, this, &ChartView::msgRemoved);
QObject::connect(dbcNotifier(), &QtDBCNotifier::msgUpdated, this, &ChartView::msgUpdated);
}
void ChartView::createToolButtons() {
close_btn = new ToolButton("x", tr("Remove Chart"), this);
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); });
manage_btn = new ToolButton("list", "", this);
manage_btn->setMenu(menu);
manage_btn->setPopupMode(QToolButton::InstantPopup);
manage_btn->setStyleSheet("QToolButton::menu-indicator { image: none; }");
close_act = new QAction(tr("Close"), this);
QObject::connect(close_act, &QAction::triggered, [this] () { charts_widget->removeChart(this); });
QObject::connect(close_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::addSignal(const MessageId &msg_id, const cabana::Signal *sig) {
if (hasSignal(msg_id, sig)) return;
sigs.push_back({.msg_id = msg_id, .sig = sig, .color = uniqueColor(toQColor(sig->color))});
updateSeries(sig);
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();
sigs.erase(std::remove_if(sigs.begin(), sigs.end(), predicate), sigs.end());
if (sigs.empty()) {
charts_widget->removeChart(this);
} else if (sigs.size() != prev_size) {
emit charts_widget->seriesChanged();
updateAxisY();
updateTitle();
}
}
void ChartView::signalUpdated(const cabana::Signal *sig) {
auto it = std::find_if(sigs.begin(), sigs.end(), [sig](auto &s) { return s.sig == sig; });
if (it != sigs.end()) {
if (it->color != toQColor(sig->color)) {
it->color = uniqueColor(toQColor(sig->color), sig);
}
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) {
QWidget::resizeEvent(event);
const auto margins = layoutMargins(style());
QPixmap grip = utils::icon("grip-horizontal");
move_icon_rect = QRect(QPoint(margins.left(), margins.top()), grip.size() / grip.devicePixelRatio());
close_btn->resize(close_btn->sizeHint());
manage_btn->resize(manage_btn->sizeHint());
close_btn->move(rect().right() - margins.right() - close_btn->width(), margins.top());
manage_btn->move(close_btn->x() - manage_btn->width() - style()->pixelMetric(QStyle::PM_LayoutHorizontalSpacing), margins.top());
updatePlotArea(align_to, true);
}
void ChartView::updatePlotArea(int left_pos, bool force) {
if (align_to != left_pos || force) {
align_to = left_pos;
const auto margins = layoutMargins(style());
QFont bold_font = font();
bold_font.setBold(true);
QFontMetrics fm(font()), bfm(bold_font);
const int marker_size = fm.height() - 4;
const int row_height = std::max(marker_size, fm.height()) + QFontMetrics(signal_value_font).height() + 3;
const int legend_left = move_icon_rect.right() + margins.left();
const int legend_right = std::max(manage_btn->x() - margins.right(), legend_left + 10);
// layout legend entries left-to-right, wrapping between the move icon and the buttons
legend_rects.clear();
int x = legend_left, y = margins.top();
for (auto &s : sigs) {
int w = marker_size + 5 + bfm.horizontalAdvance(QString::fromStdString(s.sig->name)) +
fm.horizontalAdvance(QString::fromStdString(" " + msgName(s.msg_id) + " " + s.msg_id.toString()));
w = std::min(w, legend_right - legend_left); // keep oversized entries clear of the header buttons
if (x + w > legend_right && x > legend_left) {
x = legend_left;
y += row_height;
}
legend_rects.emplace_back(x, y, w, std::max(marker_size, fm.height()));
x += w + 12;
}
// add top space for the legend and signal values
int adjust_top = (y + row_height) - margins.top();
adjust_top = std::max(adjust_top, manage_btn->geometry().bottom() + style()->pixelMetric(QStyle::PM_LayoutTopMargin));
// add right space for x-axis label
QSizeF x_label_size = fm.size(Qt::TextSingleLine, QString::number(x_max, 'f', xAxisPrecision())) + QSizeF{5, 5};
plot_area = rect().adjusted(align_to + margins.left(), adjust_top + margins.top(),
-x_label_size.width() / 2 - margins.right(),
-x_label_size.height() - margins.bottom());
resetChartCache();
}
}
void ChartView::updateTitle() {
split_chart_act->setEnabled(sigs.size() > 1);
updatePlotArea(align_to, true);
}
void ChartView::updatePlot(double cur, double min, double max) {
cur_sec = cur;
if (min != x_min || max != x_max) {
x_min = min;
x_max = max;
updateAxisY();
// update tooltip
if (tooltip_x >= 0) {
showTip(secondsAtPoint({tooltip_x, 0}));
}
resetChartCache();
}
update();
}
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;
for (const CanEvent *e : events) {
if (sig->getValue(e->dat, e->size, &value)) {
const double ts = can->toSeconds(e->mono_time);
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() || can->toSeconds(it->second.back()->mono_time) > 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);
}
}
}
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 = QString::fromStdString(sigs[0].sig->unit);
for (auto &s : sigs) {
if (!s.visible) continue;
// Only show unit when all signals have the same unit
if (unit != QString::fromStdString(s.sig->unit)) {
unit.clear();
}
auto first = std::lower_bound(s.vals.cbegin(), s.vals.cend(), x_min, xLessThan);
auto last = std::lower_bound(first, s.vals.cend(), 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 (y_unit != unit) {
y_unit = 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 != y_min || max_y != y_max || y_label_width == 0) {
y_min = min_y;
y_max = max_y;
y_tick_count = tick_count;
y_precision = std::max(int(-std::floor(std::log10((max_y - min_y) / (tick_count - 1)))), 0);
QFontMetrics fm(font());
int max_label_width = 0;
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.horizontalAdvance(QString::number(value, 'f', y_precision)));
}
int title_spacing = y_unit.isEmpty() ? 0 : fm.size(Qt::TextSingleLine, y_unit).height();
y_label_width = title_spacing + max_label_width + 15;
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};
}
int ChartView::xAxisPrecision() const {
return std::max(int(-std::floor(std::log10((x_max - x_min) / (X_TICK_COUNT - 1)))), 2);
}
// 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::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) {
press_pos = event->pos();
if (event->button() == Qt::LeftButton && move_icon_rect.contains(event->pos())) {
charts_widget->startChartDrag(this, event->globalPos());
} else if (event->button() == Qt::LeftButton && event->modifiers().testFlag(Qt::ShiftModifier)) {
// Save current playback state when scrubbing
resume_after_scrub = !can->isPaused();
if (resume_after_scrub) {
can->pause(true);
}
mouse_mode = MouseMode::Scrub;
} else if (event->button() == Qt::LeftButton && plot_area.contains(event->pos())) {
mouse_mode = MouseMode::Rubber;
rubber_rect = QRect();
} else {
QWidget::mousePressEvent(event);
}
}
void ChartView::mouseMoveEvent(QMouseEvent *ev) {
// Scrubbing
if (mouse_mode == MouseMode::Scrub && ev->modifiers().testFlag(Qt::ShiftModifier)) {
if (plot_area.contains(ev->pos())) {
can->seekTo(std::clamp(secondsAtPoint(ev->pos()), can->minSeconds(), can->maxSeconds()));
}
}
if (mouse_mode == MouseMode::Rubber) {
// horizontal selection, clamped to the plot area
int left = std::clamp(std::min(press_pos.x(), ev->pos().x()), plot_area.left(), plot_area.right());
int right = std::clamp(std::max(press_pos.x(), ev->pos().x()), plot_area.left(), plot_area.right());
rubber_rect = QRect(left, plot_area.top(), right - left, plot_area.height());
update();
}
clearTrackPoints();
if (mouse_mode != MouseMode::Rubber && plot_area.contains(ev->pos()) && isActiveWindow()) {
charts_widget->showValueTip(secondsAtPoint(ev->pos()));
} else if (tip_label->isVisible()) {
charts_widget->showValueTip(-1);
}
QWidget::mouseMoveEvent(ev);
}
void ChartView::mouseReleaseEvent(QMouseEvent *event) {
if (event->button() == Qt::LeftButton && mouse_mode == MouseMode::Rubber) {
mouse_mode = MouseMode::None;
// Prevent zooming/seeking past the end of the route
double min = std::clamp(secondsAtPoint(rubber_rect.topLeft()), can->minSeconds(), can->maxSeconds());
double max = std::clamp(secondsAtPoint(rubber_rect.bottomRight()), can->minSeconds(), can->maxSeconds());
if (rubber_rect.width() <= 0) {
// no rubber dragged, seek to mouse position
can->seekTo(std::clamp(secondsAtPoint(press_pos), can->minSeconds(), can->maxSeconds()));
} else if (rubber_rect.width() > 10 && (max - min) > MIN_ZOOM_SECONDS) {
charts_widget->zoom_undo_stack.push(new ZoomCommand({min, max}));
}
rubber_rect = QRect();
update();
} else if (event->button() == Qt::LeftButton && mouse_mode == MouseMode::None && sigs.size() > 1) {
// toggle series visibility by clicking its legend entry
for (int i = 0; i < sigs.size() && i < legend_rects.size(); ++i) {
if (legend_rects[i].contains(press_pos) && legend_rects[i].contains(event->pos())) {
sigs[i].visible = !sigs[i].visible;
updateAxisY();
updateTitle();
break;
}
}
} else if (event->button() == Qt::RightButton) {
charts_widget->zoom_undo_stack.undo();
} else {
QWidget::mouseReleaseEvent(event);
}
// Resume playback if we were scrubbing
if (mouse_mode == MouseMode::Scrub) {
mouse_mode = MouseMode::None;
if (resume_after_scrub) {
can->pause(false);
resume_after_scrub = false;
}
}
}
void ChartView::takeSignalsFrom(ChartView *source) {
for (auto &s : source->sigs) {
sigs.push_back(std::move(s));
sigs.back().color = uniqueColor(sigs.back().color, sigs.back().sig);
}
source->sigs.clear();
updateAxisY();
updateTitle();
charts_widget->removeChart(source);
}
void ChartView::showTip(double sec) {
QRect tip_area(0, plot_area.top(), rect().width(), plot_area.height());
QRect visible_rect = charts_widget->chartVisibleRect(this).intersected(tip_area);
if (visible_rect.isEmpty()) {
tip_label->hide();
return;
}
tooltip_x = xPos(sec);
qreal x = -1;
QStringList text_list;
for (auto &s : sigs) {
if (s.visible) {
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 v) { return p.x() > v; });
if (it != s.vals.crend() && it->x() >= x_min) {
value = QString::fromStdString(s.sig->formatValue(it->y(), false));
s.track_pt = *it;
x = std::max(x, xPos(it->x()));
}
QString name = sigs.size() > 1 ? QString::fromStdString(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.color.name(), name, value, min, max);
}
}
if (x < 0) {
x = tooltip_x;
}
QPoint pt(x, plot_area.top());
text_list.push_front(QString::number(secondsAtPoint({x, 0}), 'f', 3));
QString text = "<p style='white-space:pre'>" % text_list.join("<br />") % "</p>";
tip_label->showText(pt, text, this, visible_rect);
update();
}
void ChartView::hideTip() {
clearTrackPoints();
tooltip_x = -1;
tip_label->hide();
update();
}
void ChartView::resetChartCache() {
chart_pixmap = QPixmap();
update();
}
void ChartView::paintEvent(QPaintEvent *event) {
QPainter painter(this);
painter.setRenderHints(QPainter::Antialiasing);
// the static layer is invalidated on x-range change and data merge, so cache it in live mode too
const qreal dpr = devicePixelRatioF();
if (chart_pixmap.isNull() || chart_pixmap.size() != size() * dpr) {
chart_pixmap = QPixmap(size() * dpr);
chart_pixmap.setDevicePixelRatio(dpr);
QPainter p(&chart_pixmap);
p.setRenderHints(QPainter::Antialiasing);
p.setFont(font());
drawStaticLayer(&p);
}
painter.drawPixmap(QPoint(), chart_pixmap);
if (can_drop) {
painter.setPen(QPen(palette().color(QPalette::Highlight), 4));
painter.drawRect(rect());
}
drawForeground(&painter);
}
void ChartView::drawStaticLayer(QPainter *painter) {
painter->fillRect(rect(), palette().color(QPalette::Base));
painter->drawPixmap(move_icon_rect.topLeft(), utils::icon("grip-horizontal"));
drawAxes(painter);
drawLegend(painter);
drawSeries(painter);
}
void ChartView::drawAxes(QPainter *painter) {
const QColor text_color = palette().color(QPalette::Text);
QColor grid_color = text_color;
grid_color.setAlpha(50);
QFontMetrics fm(font());
painter->setFont(font());
// y grid lines and tick labels
for (int i = 0; i < y_tick_count; ++i) {
double value = y_min + i * (y_max - y_min) / (y_tick_count - 1);
qreal y = yPos(value);
painter->setPen(grid_color);
painter->drawLine(QPointF(plot_area.left(), y), QPointF(plot_area.right(), y));
painter->setPen(text_color);
QRectF label_rect(0, y - fm.height() / 2.0, plot_area.left() - 6, fm.height());
painter->drawText(label_rect, Qt::AlignRight | Qt::AlignVCenter, QString::number(value, 'f', y_precision));
}
// rotated y axis title (unit)
if (!y_unit.isEmpty()) {
painter->save();
painter->translate(plot_area.left() - y_label_width + fm.height() / 2.0, plot_area.center().y());
painter->rotate(-90);
painter->drawText(QRectF(-plot_area.height() / 2.0, -fm.height() / 2.0, plot_area.height(), fm.height()),
Qt::AlignCenter, y_unit);
painter->restore();
}
// x grid lines and tick labels
const int x_precision = xAxisPrecision();
for (int i = 0; i < X_TICK_COUNT; ++i) {
double sec = x_min + i * (x_max - x_min) / (X_TICK_COUNT - 1);
qreal x = xPos(sec);
painter->setPen(grid_color);
painter->drawLine(QPointF(x, plot_area.top()), QPointF(x, plot_area.bottom()));
painter->setPen(text_color);
QString label = QString::number(sec, 'f', x_precision);
QRectF label_rect(x - 100, plot_area.bottom() + AXIS_X_TOP_MARGIN, 200, fm.height());
painter->drawText(label_rect, Qt::AlignHCenter | Qt::AlignTop, label);
}
}
void ChartView::drawLegend(QPainter *painter) {
QColor title_color = palette().color(QPalette::WindowText);
// Draw message details in similar color, but slightly fade it to the background
QColor msg_color = title_color;
msg_color.setAlpha(180);
QFont bold_font = font();
bold_font.setBold(true);
const int marker_size = QFontMetrics(font()).height() - 4;
for (int i = 0; i < sigs.size() && i < legend_rects.size(); ++i) {
const auto &s = sigs[i];
const QRect &r = legend_rects[i];
painter->setPen(Qt::NoPen);
painter->setBrush(s.color);
QRectF marker_rect(r.left(), r.center().y() - marker_size / 2.0, marker_size, marker_size);
series_type == SeriesType::Scatter ? painter->drawEllipse(marker_rect) : painter->drawRect(marker_rect);
bold_font.setStrikeOut(!s.visible);
QFont normal_font = font();
normal_font.setStrikeOut(!s.visible);
qreal x = r.left() + marker_size + 5;
painter->setFont(bold_font);
painter->setPen(title_color);
QString name = QFontMetrics(bold_font).elidedText(QString::fromStdString(s.sig->name), Qt::ElideRight, r.right() - x);
painter->drawText(QRectF(x, r.top(), r.right() - x, r.height()), Qt::AlignLeft | Qt::AlignVCenter, name);
x += QFontMetrics(bold_font).horizontalAdvance(name);
painter->setFont(normal_font);
painter->setPen(msg_color);
QString msg = QFontMetrics(normal_font).elidedText(QString::fromStdString(" " + msgName(s.msg_id) + " " + s.msg_id.toString()),
Qt::ElideRight, r.right() - x);
painter->drawText(QRectF(x, r.top(), r.right() - x, r.height()), Qt::AlignLeft | Qt::AlignVCenter, msg);
}
}
void ChartView::drawSeries(QPainter *painter) {
painter->save();
painter->setClipRect(plot_area);
for (auto &s : sigs) {
if (!s.visible) continue;
// visible points in vals to compute point density
auto first = std::lower_bound(s.vals.cbegin(), s.vals.cend(), x_min, xLessThan);
auto last = std::lower_bound(first, s.vals.cend(), x_max, xLessThan);
int num_points = std::max<int>(last - first, 1);
double pixels_per_point = 0;
if (first != last) {
const QPointF &right_pt = last == s.vals.cend() ? s.vals.back() : *last;
pixels_per_point = (xPos(right_pt.x()) - xPos(first->x())) / num_points;
}
if (series_type == SeriesType::Scatter) {
qreal radius = std::clamp(pixels_per_point / 2.0, 2.0, 8.0) / 2.0;
painter->setPen(Qt::NoPen);
painter->setBrush(s.color);
for (auto it = first; it != last; ++it) {
painter->drawEllipse(QPointF(xPos(it->x()), yPos(it->y())), radius, radius);
}
} else {
const auto &points = series_type == SeriesType::StepLine ? s.step_vals : s.vals;
auto begin = std::lower_bound(points.cbegin(), points.cend(), x_min, xLessThan);
if (begin != points.cbegin()) --begin;
auto end = std::lower_bound(begin, points.cend(), x_max, xLessThan);
if (end != points.cend()) ++end;
if (begin == end) continue;
std::vector<QPointF> polyline;
polyline.reserve(end - begin);
for (auto it = begin; it != end; ++it) {
polyline.emplace_back(xPos(it->x()), yPos(it->y()));
}
painter->setPen(QPen(s.color, 2));
painter->setBrush(Qt::NoBrush);
painter->drawPolyline(polyline.data(), polyline.size());
// show points when zoomed in enough
if (num_points == 1 || pixels_per_point > 20) {
painter->setPen(Qt::NoPen);
painter->setBrush(s.color);
for (auto it = first; it != last; ++it) {
painter->drawEllipse(QPointF(xPos(it->x()), yPos(it->y())), 4, 4);
}
}
}
}
painter->restore();
}
void ChartView::drawForeground(QPainter *painter) {
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.visible) {
painter->setBrush(s.color.darker(125));
QPointF pos(xPos(s.track_pt.x()), yPos(s.track_pt.y()));
painter->drawEllipse(pos, 5.5, 5.5);
track_line_x = std::max(track_line_x, pos.x());
}
}
if (track_line_x > 0) {
painter->setPen(QPen(Qt::darkGray, 1, Qt::DashLine));
painter->drawLine(QPointF{track_line_x, (qreal)plot_area.top()}, QPointF{track_line_x, (qreal)plot_area.bottom()});
}
drawRubberBandTimeRange(painter);
}
void ChartView::drawRubberBandTimeRange(QPainter *painter) {
if (rubber_rect.width() <= 1) return;
// selection rect
QColor highlight = palette().color(QPalette::Highlight);
QColor fill = highlight;
fill.setAlpha(50);
painter->fillRect(rubber_rect, fill);
painter->setPen(highlight);
painter->setBrush(Qt::NoBrush);
painter->drawRect(rubber_rect);
// time labels at the bottom corners
painter->setPen(Qt::white);
painter->setFont(font());
for (const auto &pt : {rubber_rect.bottomLeft(), rubber_rect.bottomRight()}) {
QString sec = QString::number(secondsAtPoint(pt), '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) {
// draw vertical time line
qreal x = std::clamp(xPos(cur_sec), (qreal)plot_area.left(), (qreal)plot_area.right());
painter->setPen(QPen(palette().color(QPalette::Text), 1));
painter->drawLine(QPointF{x, plot_area.top() - 1.0}, QPointF{x, plot_area.bottom() + 1.0});
// draw current time under the axis-x
QString time_str = QString::number(cur_sec, 'f', 2);
QSize time_str_size = QFontMetrics(font()).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, utils::isDarkTheme() ? Qt::darkGray : Qt::gray);
painter->setPen(palette().color(QPalette::BrightText));
painter->setFont(font());
painter->drawText(time_str_rect, Qt::AlignCenter, time_str);
}
void ChartView::drawSignalValue(QPainter *painter) {
painter->setFont(signal_value_font);
painter->setPen(palette().color(QPalette::Text));
for (int i = 0; i < sigs.size() && i < legend_rects.size(); ++i) {
const auto &s = sigs[i];
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() >= x_min) ? QString::fromStdString(s.sig->formatValue(it->y())) : "--";
QRectF value_rect(legend_rects[i].bottomLeft() - QPoint(0, 1), legend_rects[i].size());
QString elided_val = painter->fontMetrics().elidedText(value, Qt::ElideRight, value_rect.width());
painter->drawText(value_rect, Qt::AlignHCenter | Qt::AlignTop, elided_val);
}
}
QColor ChartView::uniqueColor(QColor color, const cabana::Signal *exclude) const {
for (auto &s : sigs) {
if (s.sig != exclude && std::abs(color.hueF() - s.color.hueF()) < 0.1) {
// use different color to distinguish it from others.
auto last_color = sigs.back().color;
static thread_local std::mt19937 rng{std::random_device{}()};
std::uniform_int_distribution<int> sat(35, 99);
std::uniform_int_distribution<int> val(85, 99);
color.setHsvF(std::fmod(last_color.hueF() + 60 / 360.0, 1.0),
sat(rng) / 100.0,
val(rng) / 100.0);
break;
}
}
return color;
}
void ChartView::setSeriesType(SeriesType type) {
if (type != series_type) {
series_type = type;
menu->actions()[(int)type]->setChecked(true);
updateTitle();
}
}
+130
View File
@@ -0,0 +1,130 @@
#pragma once
#include <functional>
#include <tuple>
#include <utility>
#include <vector>
#include <QMenu>
#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 QWidget {
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();
double secondsAtPoint(const QPointF &pt) const {
return x_min + (pt.x() - plot_area.left()) * (x_max - x_min) / std::max(plot_area.width(), 1);
}
struct SigItem {
MessageId msg_id;
const cabana::Signal *sig = nullptr;
QColor color;
bool visible = true;
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 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 contextMenuEvent(QContextMenuEvent *event) override;
void mousePressEvent(QMouseEvent *event) override;
void mouseMoveEvent(QMouseEvent *event) override;
void mouseReleaseEvent(QMouseEvent *event) override;
void resizeEvent(QResizeEvent *event) override;
QSize sizeHint() const override;
void updateAxisY();
void updateTitle();
void resetChartCache();
void paintEvent(QPaintEvent *event) override;
void drawStaticLayer(QPainter *painter);
void drawAxes(QPainter *painter);
void drawLegend(QPainter *painter);
void drawSeries(QPainter *painter);
void drawForeground(QPainter *painter);
void drawSignalValue(QPainter *painter);
void drawTimeline(QPainter *painter);
void drawRubberBandTimeRange(QPainter *painter);
int xAxisPrecision() const;
std::tuple<double, double, int> getNiceAxisNumbers(qreal min, qreal max, int tick_count);
qreal niceNumber(qreal x, bool ceiling);
QColor uniqueColor(QColor color, const cabana::Signal *exclude = nullptr) const;
void removeIf(std::function<bool(const SigItem &)> predicate);
void takeSignalsFrom(ChartView *source);
void setDropHighlight(bool highlight) { if (std::exchange(can_drop, highlight) != highlight) update(); }
inline void clearTrackPoints() { for (auto &s : sigs) s.track_pt = {}; }
inline qreal xPos(double sec) const { return plot_area.left() + (sec - x_min) / (x_max - x_min) * plot_area.width(); }
inline qreal yPos(double val) const { return plot_area.bottom() - (val - y_min) / (y_max - y_min) * plot_area.height(); }
// layout
QRect plot_area;
QRect move_icon_rect;
std::vector<QRect> legend_rects;
// axes
double x_min;
double x_max;
double y_min = 0;
double y_max = 1;
int y_tick_count = 3;
int y_precision = 0;
QString y_unit;
int y_label_width = 0;
int align_to = 0;
// interaction
enum class MouseMode { None, Rubber, Scrub };
MouseMode mouse_mode = MouseMode::None;
QPoint press_pos;
QRect rubber_rect;
bool resume_after_scrub = false;
QMenu *menu;
QAction *split_chart_act;
QAction *close_act;
ToolButton *manage_btn;
ToolButton *close_btn;
TipLabel *tip_label;
std::vector<SigItem> sigs;
double cur_sec = 0;
SeriesType series_type = SeriesType::Line;
QPixmap chart_pixmap;
bool can_drop = false;
double tooltip_x = -1;
QFont signal_value_font;
ChartsWidget *charts_widget;
friend class ChartsWidget;
};
@@ -0,0 +1,661 @@
#include "tools/cabana/chart/chartswidget.h"
#include "tools/cabana/dbc/dbcqt.h"
#include <algorithm>
#include <future>
#include <QApplication>
#include <QMenu>
#include <QMouseEvent>
#include <QScrollBar>
#include <QToolBar>
#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
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);
auto chart_type_action = toolbar->addAction("");
QMenu *chart_type_menu = new QMenu(this);
auto types = std::array{tr("Line"), tr("Step"), tr("Scatter")};
for (int i = 0; i < types.size(); ++i) {
QString type_text = types[i];
chart_type_menu->addAction(type_text, this, [=]() {
settings.chart_series_type = i;
chart_type_action->setText("Type: " + type_text);
settingChanged();
});
}
chart_type_action->setText("Type: " + types[settings.chart_series_type]);
chart_type_action->setMenu(chart_type_menu);
qobject_cast<QToolButton *>(toolbar->widgetForAction(chart_type_action))->setPopupMode(QToolButton::InstantPopup);
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);
QWidget *spacer = new QWidget(this);
spacer->setSizePolicy(QSizePolicy::MinimumExpanding, QSizePolicy::Preferred);
toolbar->addWidget(spacer);
range_lb_action = toolbar->addWidget(range_lb = new QLabel(this));
range_slider = new LogSlider(1000, Qt::Horizontal, this);
range_slider->setFixedWidth(150 * qApp->devicePixelRatio());
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
undo_zoom_action = toolbar->addAction(utils::icon("arrow-counterclockwise"), tr("Undo Zoom"), [this]() { zoom_undo_stack.undo(); });
redo_zoom_action = toolbar->addAction(utils::icon("arrow-clockwise"), tr("Redo Zoom"), [this]() { zoom_undo_stack.redo(); });
undo_zoom_action->setEnabled(false);
redo_zoom_action->setEnabled(false);
zoom_undo_stack.setCallbacks({.index_changed = [this]() {
undo_zoom_action->setEnabled(zoom_undo_stack.canUndo());
redo_zoom_action->setEnabled(zoom_undo_stack.canRedo());
}});
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->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);
// chart drag preview
drag_preview = new QLabel(this);
drag_preview->setAttribute(Qt::WA_TransparentForMouseEvents);
drag_preview->hide();
// 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 = std::make_pair(can->minSeconds(), can->minSeconds() + 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(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, this, &ChartsWidget::removeAll);
QObject::connect(can, &AbstractStream::eventsMerged, this, &ChartsWidget::eventsMerged);
QObject::connect(can, &AbstractStream::msgsReceived, this, &ChartsWidget::updateState);
QObject::connect(can, &AbstractStream::seeking, this, &ChartsWidget::updateState);
QObject::connect(can, &AbstractStream::timeRangeChanged, this, &ChartsWidget::timeRangeChanged);
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, &ChartsWidget::toggleChartsDocking);
setIsDocked(true);
newTab();
qApp->installEventFilter(this);
setWhatsThis(tr(R"(
<b>Chart View</b><br />
<b>Click</b>: Click to seek to a corresponding time.<br />
<b>Drag</b>: Zoom into the chart.<br />
<b>Shift + Drag</b>: Scrub through the chart to view values.<br />
<b>Right Mouse</b>: Open the context menu.<br />
)"));
}
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((int)charts_in_tab.size()));
}
}
void ChartsWidget::eventsMerged(const MessageEventsMap &new_events) {
std::vector<std::future<void>> futures;
for (auto c : charts) {
futures.push_back(std::async(std::launch::async, &ChartView::updateSeries, c, nullptr, &new_events));
}
for (auto &f : futures) f.get();
}
void ChartsWidget::timeRangeChanged(const std::optional<std::pair<double, double>> &time_range) {
updateToolBar();
updateState();
}
void ChartsWidget::zoomReset() {
can->setTimeRange(std::nullopt);
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) {
emit showTip(sec);
if (sec < 0 && !value_tip_visible_) return;
value_tip_visible_ = sec >= 0;
for (auto c : currentCharts()) {
value_tip_visible_ ? c->showTip(sec) : c->hideTip();
}
}
void ChartsWidget::updateState() {
if (charts.empty()) return;
const auto &time_range = can->timeRange();
const double cur_sec = can->currentSec();
if (!time_range.has_value()) {
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(can->minSeconds(), cur_sec - max_chart_range * 0.1);
}
double max_sec = std::min(display_range.first + max_chart_range, can->maxSeconds());
display_range.first = std::max(can->minSeconds(), max_sec - max_chart_range);
display_range.second = display_range.first + max_chart_range;
}
const auto &range = time_range ? *time_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::setIsDocked(bool docked) {
is_docked = docked;
dock_btn->setIcon(is_docked ? "arrow-up-right-square" : "arrow-down-left-square");
dock_btn->setToolTip(is_docked ? tr("Float the charts window") : tr("Dock the charts window"));
}
void ChartsWidget::updateToolBar() {
title_label->setText(tr("Charts: %1").arg(charts.size()));
columns_action->setText(tr("Columns: %1").arg(column_count));
range_lb->setText(utils::formatSeconds(max_chart_range));
bool is_zoomed = can->timeRange().has_value();
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(can->timeRange()->first, 0, 'f', 2).arg(can->timeRange()->second, 0, 'f', 2) : "");
remove_all_btn->setEnabled(!charts.empty());
}
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"));
}
if (range_slider->maximum() != settings.max_cached_minutes * 60) {
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(int pos) {
auto chart = new ChartView(can->timeRange().value_or(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));
pos = std::clamp(pos, 0, (int)charts.size());
charts.insert(charts.begin() + pos, chart);
currentCharts().insert(currentCharts().begin() + pos, 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) {
int pos = std::find(charts.begin(), charts.end(), src_chart) - charts.begin() + 1;
for (auto it = src_chart->sigs.begin() + 1; it != src_chart->sigs.end(); /**/) {
auto c = createChart(pos);
// Restore to the original color
it->color = toQColor(it->sig->color);
c->sigs.emplace_back(std::move(*it));
c->updateAxisY();
c->updateTitle();
it = src_chart->sigs.erase(it);
}
src_chart->updateAxisY();
src_chart->updateTitle();
updateState();
QTimer::singleShot(0, src_chart, &ChartView::resetChartCache);
}
}
QStringList ChartsWidget::serializeChartIds() const {
QStringList chart_ids;
for (auto c : charts) {
QStringList ids;
for (const auto& s : c->sigs)
ids += QString("%1|%2").arg(QString::fromStdString(s.msg_id.toString()), QString::fromStdString(s.sig->name));
chart_ids += ids.join(',');
}
std::reverse(chart_ids.begin(), chart_ids.end());
return chart_ids;
}
void ChartsWidget::restoreChartsFromIds(const QStringList& chart_ids) {
for (const auto& chart_id : chart_ids) {
int index = 0;
for (const auto& part : chart_id.split(',')) {
const auto sig_parts = part.split('|');
if (sig_parts.size() != 2) continue;
MessageId msg_id = MessageId::fromString(sig_parts[0].toStdString());
if (auto* msg = dbc()->msg(msg_id))
if (auto* sig = msg->sig(sig_parts[1].toStdString()))
showChart(msg_id, sig, true, index++ > 0);
}
}
}
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 &current_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::startChartDrag(ChartView *chart, const QPoint &global_pos) {
stopAutoScroll();
drag = {.source = chart, .press_pos = global_pos};
QPixmap px = chart->grab().scaledToWidth(CHART_MIN_WIDTH * chart->devicePixelRatio(), Qt::SmoothTransformation);
drag_preview->setPixmap(px);
drag_preview->resize(px.size() / px.devicePixelRatio());
}
void ChartsWidget::dragChartMove(const QPoint &global_pos) {
if (!drag.active) {
if ((global_pos - drag.press_pos).manhattanLength() < QApplication::startDragDistance()) return;
drag.active = true;
drag_preview->show();
drag_preview->raise();
}
drag_preview->move(mapFromGlobal(global_pos) + QPoint(5, 5));
// hovering a tab switches to it so the chart can be dropped into another tab
int tab = tabbar->tabAt(tabbar->mapFromGlobal(global_pos));
if (tab >= 0 && tab != tabbar->currentIndex()) {
tabbar->setCurrentIndex(tab);
}
const QPoint container_pos = charts_container->mapFromGlobal(global_pos);
ChartView *target = nullptr;
for (auto c : currentCharts()) {
if (c != drag.source && c->isVisible() && c->geometry().contains(container_pos)) {
target = c;
break;
}
}
if (std::exchange(drop_target, target) != target) {
for (auto c : charts) c->setDropHighlight(c == target);
}
bool in_viewport = charts_scroll->viewport()->rect().contains(charts_scroll->viewport()->mapFromGlobal(global_pos));
bool on_background = !target && in_viewport && !charts_container->childAt(container_pos);
charts_container->drawDropIndicator(on_background ? container_pos : QPoint());
if (in_viewport) {
startAutoScroll(global_pos);
}
}
void ChartsWidget::cancelChartDrag() {
drag = {};
stopAutoScroll();
drag_preview->hide();
charts_container->drawDropIndicator({});
if (auto target = std::exchange(drop_target, nullptr)) target->setDropHighlight(false);
}
void ChartsWidget::dragChartRelease(const QPoint &global_pos) {
ChartView *source = drag.source;
bool active = drag.active;
ChartView *target = drop_target;
cancelChartDrag();
if (!active) return;
const QPoint container_pos = charts_container->mapFromGlobal(global_pos);
bool in_viewport = charts_scroll->viewport()->rect().contains(charts_scroll->viewport()->mapFromGlobal(global_pos));
if (target) {
// merge source into target
target->takeSignalsFrom(source);
} else if (in_viewport && !charts_container->childAt(container_pos)) {
// reorder within the current tab
auto w = charts_container->getDropAfter(container_pos);
if (w != source) {
for (auto &[_, list] : tab_charts) {
list.erase(std::remove(list.begin(), list.end(), source), list.end());
}
auto &cur = currentCharts();
int to = w ? std::find(cur.begin(), cur.end(), w) - cur.begin() + 1 : 0;
cur.insert(cur.begin() + to, source);
updateLayout(true);
updateTabBar();
}
}
}
void ChartsWidget::startAutoScroll(const QPoint &global_pos) {
auto_scroll_pos = global_pos;
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(auto_scroll_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);
}
if (value == scroll->value()) {
stopAutoScroll();
} else if (chartDragActive()) {
// refresh the drop indicator/target at the new scroll position
dragChartMove(auto_scroll_pos);
}
}
QSize ChartsWidget::minimumSizeHint() const {
return QSize(CHART_MIN_WIDTH * 1.5, QWidget::minimumSizeHint().height());
}
void ChartsWidget::newChart() {
SignalSelector dlg(tr("New Chart"), this);
if (dlg.exec() == QDialog::Accepted) {
auto items = dlg.seletedItems();
if (!items.empty()) {
auto c = createChart();
for (auto it : items) {
c->addSignal(it->msg_id, it->sig);
}
updateState();
}
}
}
void ChartsWidget::removeChart(ChartView *chart) {
if (drag.source == chart) cancelChartDrag();
if (drop_target == chart) drop_target = nullptr;
charts.erase(std::remove(charts.begin(), charts.end(), chart), charts.end());
chart->deleteLater();
for (auto &[_, list] : tab_charts) {
list.erase(std::remove(list.begin(), list.end(), chart), list.end());
}
updateToolBar();
updateLayout(true);
alignCharts();
emit seriesChanged();
}
void ChartsWidget::removeAll() {
while (tabbar->count() > 1) {
tabbar->removeTab(1);
}
tab_charts.clear();
if (!charts.empty()) {
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 *o, QEvent *e) {
// route all mouse events to the chart drag, even when the source chart is hidden by a tab switch
if (chartDragActive()) {
if (e->type() == QEvent::MouseMove) {
dragChartMove(static_cast<QMouseEvent *>(e)->globalPos());
return true;
} else if (e->type() == QEvent::MouseButtonRelease && static_cast<QMouseEvent *>(e)->button() == Qt::LeftButton) {
dragChartRelease(static_cast<QMouseEvent *>(e)->globalPos());
return false; // let the release through so Qt clears the implicit mouse grab
} else if (e->type() == QEvent::MouseButtonPress || e->type() == QEvent::MouseButtonRelease) {
return true; // swallow other buttons during the drag
}
}
if (!value_tip_visible_) return false;
if (e->type() == QEvent::MouseMove) {
bool on_tip = qobject_cast<TipLabel *>(o) != nullptr;
auto global_pos = static_cast<QMouseEvent *>(e)->globalPos();
for (const auto &c : charts) {
auto local_pos = c->mapFromGlobal(global_pos);
if (c->plot_area.contains(local_pos)) {
if (on_tip) {
showValueTip(c->secondsAtPoint(local_pos));
}
return false;
}
}
showValueTip(-1);
} else if (e->type() == QEvent::Wheel) {
if (auto tip = qobject_cast<TipLabel *>(o)) {
// Forward the event to the parent widget
QCoreApplication::sendEvent(tip->parentWidget(), e);
}
}
return false;
}
bool ChartsWidget::event(QEvent *event) {
bool back_button = false;
switch (event->type()) {
case QEvent::Resize:
updateLayout();
break;
case QEvent::MouseButtonPress:
back_button = static_cast<QMouseEvent *>(event)->button() == Qt::BackButton;
break;
case QEvent::NativeGesture:
back_button = (static_cast<QNativeGestureEvent *>(event)->value() == 180);
break;
case QEvent::WindowDeactivate:
case QEvent::FocusOut:
if (chartDragActive()) cancelChartDrag();
showValueTip(-1);
default:
break;
}
if (back_button) {
zoom_undo_stack.undo();
return true; // Return true since the event has been handled
}
return QFrame::event(event);
}
// ChartsContainer
ChartsContainer::ChartsContainer(ChartsWidget *parent) : charts_widget(parent), QWidget(parent) {
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::paintEvent(QPaintEvent *ev) {
if (!drop_indictor_pos.isNull() && !childAt(drop_indictor_pos)) {
QRect r = geometry();
r.setHeight(CHART_SPACING);
if (auto insert_after = getDropAfter(drop_indictor_pos)) {
r.moveTop(insert_after->geometry().bottom());
}
QPainter p(this);
p.fillRect(r, palette().highlight());
}
}
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;
}
+138
View File
@@ -0,0 +1,138 @@
#pragma once
#include <unordered_map>
#include <utility>
#include <QGridLayout>
#include <QLabel>
#include <QScrollArea>
#include <QTimer>
#include <QToolBar>
#include "tools/cabana/chart/signalselector.h"
#include "tools/cabana/commands.h"
#include "tools/cabana/dbc/dbcmanager.h"
#include "tools/cabana/streams/abstractstream.h"
const int CHART_MIN_WIDTH = 300;
class ChartView;
class ChartsWidget;
class ChartsContainer : public QWidget {
public:
ChartsContainer(ChartsWidget *parent);
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; }
QStringList serializeChartIds() const;
void restoreChartsFromIds(const QStringList &chart_ids);
public slots:
void setColumnCount(int n);
void removeAll();
void timeRangeChanged(const std::optional<std::pair<double, double>> &time_range);
void setIsDocked(bool dock);
signals:
void toggleChartsDocking();
void seriesChanged();
void showTip(double seconds);
private:
QSize minimumSizeHint() const override;
bool event(QEvent *event) override;
void alignCharts();
void newChart();
ChartView *createChart(int pos = 0);
void removeChart(ChartView *chart);
void splitChart(ChartView *chart);
QRect chartVisibleRect(ChartView *chart);
void eventsMerged(const MessageEventsMap &new_events);
void updateState();
void zoomReset();
void startChartDrag(ChartView *chart, const QPoint &global_pos);
void dragChartMove(const QPoint &global_pos);
void dragChartRelease(const QPoint &global_pos);
void cancelChartDrag();
bool chartDragActive() const { return drag.source != nullptr; }
void startAutoScroll(const QPoint &global_pos);
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 std::vector<ChartView *> &currentCharts() { 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 is_docked = true;
ToolButton *dock_btn;
QToolBar *toolbar;
QAction *undo_zoom_action;
QAction *redo_zoom_action;
QAction *reset_zoom_action;
ToolButton *reset_zoom_btn;
UndoStack zoom_undo_stack;
ToolButton *remove_all_btn;
std::vector<ChartView *> charts;
std::unordered_map<int, std::vector<ChartView *>> tab_charts;
TabBar *tabbar;
ChartsContainer *charts_container;
QScrollArea *charts_scroll;
uint32_t max_chart_range = 0;
std::pair<double, double> display_range;
QAction *columns_action;
int column_count = 1;
int current_column_count = 0;
struct ChartDrag {
ChartView *source = nullptr;
QPoint press_pos; // global
bool active = false;
} drag;
QLabel *drag_preview;
ChartView *drop_target = nullptr;
int auto_scroll_count = 0;
QPoint auto_scroll_pos;
QTimer *auto_scroll_timer;
QTimer *align_timer;
int current_theme = 0;
bool value_tip_visible_ = false;
friend class ChartView;
friend class ChartsContainer;
};
class ZoomCommand : public UndoCommand {
public:
ZoomCommand(std::pair<double, double> range) : range(range) {
prev_range = can->timeRange();
}
void undo() override { can->setTimeRange(prev_range); }
void redo() override { can->setTimeRange(range); }
std::optional<std::pair<double, double>> prev_range, range;
};
@@ -0,0 +1,107 @@
#include "tools/cabana/chart/signalselector.h"
#include "tools/cabana/dbc/dbcqt.h"
#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);
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(QString::fromStdString(m->name)).arg(QString::fromStdString(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, &msg_id](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(toQColor(sig->color).name(), QString::fromStdString(sig->name));
if (show_msg_name) text += QString(" <font color=\"gray\">%0 %1</font>").arg(QString::fromStdString(msgName(id)), QString::fromStdString(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);
}
std::vector<SignalSelector::ListItem *> SignalSelector::seletedItems() {
std::vector<SignalSelector::ListItem *> ret;
for (int i = 0; i < selected_list->count(); ++i) ret.push_back((ListItem *)selected_list->item(i));
return ret;
}
@@ -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);
std::vector<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;
};
+100
View File
@@ -0,0 +1,100 @@
#include "tools/cabana/chart/sparkline.h"
#include <algorithm>
#include <limits>
#include <QPainter>
void Sparkline::update(const cabana::Signal *sig, CanEventIter first, CanEventIter last, int range, QSize size) {
if (first == last || size.isEmpty()) {
pixmap = QPixmap();
return;
}
points_.clear();
min_val = std::numeric_limits<double>::max();
max_val = std::numeric_limits<double>::lowest();
points_.reserve(std::distance(first, last));
uint64_t start_time = (*first)->mono_time;
double value = 0.0;
for (auto it = first; it != last; ++it) {
if (sig->getValue((*it)->dat, (*it)->size, &value)) {
min_val = std::min(min_val, value);
max_val = std::max(max_val, value);
points_.emplace_back(((*it)->mono_time - start_time) / 1e9, value);
}
}
if (points_.empty()) {
pixmap = QPixmap();
return;
}
freq_ = points_.size() / std::max(points_.back().x() - points_.front().x(), 1.0);
render(toQColor(sig->color), range, size);
}
void Sparkline::render(const QColor &color, int range, QSize size) {
// Adjust for flat lines
bool is_flat_line = min_val == max_val;
if (is_flat_line) {
min_val -= 1.0;
max_val += 1.0;
}
// Calculate scaling
const double xscale = (size.width() - 1) / (double)range;
const double yscale = (size.height() - 3) / (max_val - min_val);
bool draw_individual_points = (points_.back().x() * xscale / points_.size()) > 8.0;
// Transform or downsample points
render_points_.reserve(points_.size());
render_points_.clear();
if (draw_individual_points) {
for (const auto &p : points_) {
render_points_.emplace_back(p.x() * xscale, 1.0 + (max_val - p.y()) * yscale);
}
} else if (is_flat_line) {
double y = size.height() / 2.0;
render_points_.emplace_back(0.0, y);
render_points_.emplace_back(points_.back().x() * xscale, y);
} else {
double prev_y = points_.front().y();
render_points_.emplace_back(points_.front().x() * xscale, 1.0 + (max_val - prev_y) * yscale);
bool in_flat = false;
for (size_t i = 1; i < points_.size(); ++i) {
const auto &p = points_[i];
double y = p.y();
if (std::abs(y - prev_y) < 1e-6) {
in_flat = true;
} else {
if (in_flat) render_points_.emplace_back(points_[i - 1].x() * xscale, 1.0 + (max_val - prev_y) * yscale);
render_points_.emplace_back(p.x() * xscale, 1.0 + (max_val - y) * yscale);
in_flat = false;
}
prev_y = y;
}
if (in_flat) render_points_.emplace_back(points_.back().x() * xscale, 1.0 + (max_val - prev_y) * yscale);
}
// Render to pixmap
qreal dpr = qApp->devicePixelRatio();
const QSize pixmap_size = size * dpr;
if (pixmap.size() != pixmap_size) {
pixmap = QPixmap(pixmap_size);
}
pixmap.setDevicePixelRatio(dpr);
pixmap.fill(Qt::transparent);
QPainter painter(&pixmap);
painter.setRenderHint(QPainter::Antialiasing, render_points_.size() <= 500);
painter.setPen(color);
painter.drawPolyline(render_points_.data(), render_points_.size());
painter.setPen(QPen(color, 3));
if (draw_individual_points) {
painter.drawPoints(render_points_.data(), render_points_.size());
} else {
painter.drawPoint(render_points_.back());
}
}
+26
View File
@@ -0,0 +1,26 @@
#pragma once
#include <QPixmap>
#include <QPointF>
#include <vector>
#include "tools/cabana/dbc/dbc.h"
#include "tools/cabana/streams/abstractstream.h"
class Sparkline {
public:
void update(const cabana::Signal *sig, CanEventIter first, CanEventIter last, 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_;
std::vector<QPointF> render_points_;
double freq_ = 0;
};
+58
View File
@@ -0,0 +1,58 @@
#include "tools/cabana/chart/tiplabel.h"
#include <utility>
#include <QApplication>
#include <QStylePainter>
#include <QToolTip>
#include "tools/cabana/settings.h"
#include "tools/cabana/utils/util.h"
TipLabel::TipLabel(QWidget *parent) : QLabel(parent, Qt::ToolTip | Qt::FramelessWindowHint) {
setAttribute(Qt::WA_ShowWithoutActivating);
setAttribute(Qt::WA_TransparentForMouseEvents);
setForegroundRole(QPalette::ToolTipText);
setBackgroundRole(QPalette::ToolTipBase);
QFont font;
font.setPointSizeF(8.34563465);
setFont(font);
auto palette = QToolTip::palette();
if (!utils::isDarkTheme()) {
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));
setTextFormat(Qt::RichText);
}
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);
}
+12
View File
@@ -0,0 +1,12 @@
#pragma once
#include <QLabel>
class TipLabel : public QLabel {
Q_OBJECT
public:
TipLabel(QWidget *parent = nullptr);
void showText(const QPoint &pt, const QString &sec, QWidget *w, const QRect &rect);
void paintEvent(QPaintEvent *ev) override;
};
+175
View File
@@ -0,0 +1,175 @@
#include "tools/cabana/commands.h"
#include <cmath>
// UndoStack
void UndoStack::push(UndoCommand *cmd) {
commands_.resize(index_); // drop any redoable commands
if (clean_index_ > index_) clean_index_ = -1;
commands_.emplace_back(cmd);
cmd->redo();
setIndex(index_ + 1);
}
void UndoStack::undo() {
if (!canUndo()) return;
commands_[index_ - 1]->undo();
setIndex(index_ - 1);
}
void UndoStack::redo() {
if (!canRedo()) return;
commands_[index_]->redo();
setIndex(index_ + 1);
}
void UndoStack::clear() {
bool was_clean = isClean();
commands_.clear();
index_ = clean_index_ = 0;
if (callbacks_.index_changed) callbacks_.index_changed();
if (!was_clean && callbacks_.clean_changed) callbacks_.clean_changed(true);
}
void UndoStack::setClean() {
if (!isClean()) {
clean_index_ = index_;
if (callbacks_.clean_changed) callbacks_.clean_changed(true);
}
}
void UndoStack::setIndex(int index) {
bool was_clean = isClean();
index_ = index;
if (callbacks_.index_changed) callbacks_.index_changed();
if (isClean() != was_clean && callbacks_.clean_changed) callbacks_.clean_changed(isClean());
}
UndoStack *UndoStack::instance() {
static UndoStack undo_stack;
return &undo_stack;
}
QtUndoNotifier::QtUndoNotifier(QObject *parent) : QObject(parent) {
UndoStack::instance()->setCallbacks({
.index_changed = [this]() { emit indexChanged(); },
.clean_changed = [this](bool clean) { emit cleanChanged(clean); },
});
}
QtUndoNotifier *undoNotifier() {
static QtUndoNotifier notifier;
return &notifier;
}
// EditMsgCommand
EditMsgCommand::EditMsgCommand(const MessageId &id, const std::string &name, int size,
const std::string &node, const std::string &comment)
: id(id), new_name(name), new_size(size), new_node(node), new_comment(comment) {
if (auto msg = dbc()->msg(id)) {
old_name = msg->name;
old_size = msg->size;
old_node = msg->transmitter;
old_comment = msg->comment;
text = "edit message " + name + ":" + std::to_string(id.address);
} else {
text = "new message " + name + ":" + std::to_string(id.address);
}
}
void EditMsgCommand::undo() {
if (old_name.empty())
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) : id(id) {
if (auto msg = dbc()->msg(id)) {
message = *msg;
text = "remove message " + message.name + ":" + std::to_string(id.address);
}
}
void RemoveMsgCommand::undo() {
if (!message.name.empty()) {
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.empty())
dbc()->removeMsg(id);
}
// AddSigCommand
AddSigCommand::AddSigCommand(const MessageId &id, const cabana::Signal &sig)
: id(id), signal(sig) {
text = "add signal " + sig.name + " to " + msgName(id) + ":" + std::to_string(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) : id(id) {
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);
}
}
}
text = "remove signal " + sig->name + " from " + msgName(id) + ":" + std::to_string(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)
: id(id) {
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});
}
}
}
text = "edit signal " + sig->name + " in " + msgName(id) + ":" + std::to_string(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); }
+120
View File
@@ -0,0 +1,120 @@
#pragma once
#include <functional>
#include <memory>
#include <string>
#include <utility>
#include <vector>
#include <QObject>
#include "tools/cabana/dbc/dbcmanager.h"
#include "tools/cabana/streams/abstractstream.h"
class UndoCommand {
public:
virtual ~UndoCommand() = default;
virtual void undo() = 0;
virtual void redo() = 0;
std::string text;
};
class UndoStack {
public:
struct Callbacks {
std::function<void()> index_changed;
std::function<void(bool)> clean_changed;
};
void push(UndoCommand *cmd); // takes ownership and calls redo()
void undo();
void redo();
void clear();
void setClean();
bool isClean() const { return clean_index_ == index_; }
bool canUndo() const { return index_ > 0; }
bool canRedo() const { return index_ < (int)commands_.size(); }
std::string undoText() const { return canUndo() ? commands_[index_ - 1]->text : ""; }
std::string redoText() const { return canRedo() ? commands_[index_]->text : ""; }
void setCallbacks(Callbacks callbacks) { callbacks_ = std::move(callbacks); }
static UndoStack *instance();
private:
void setIndex(int index);
std::vector<std::unique_ptr<UndoCommand>> commands_;
int index_ = 0;
int clean_index_ = 0;
Callbacks callbacks_;
};
// emits Qt signals for the global undo stack
class QtUndoNotifier : public QObject {
Q_OBJECT
public:
explicit QtUndoNotifier(QObject *parent = nullptr);
signals:
void indexChanged();
void cleanChanged(bool clean);
};
QtUndoNotifier *undoNotifier();
class EditMsgCommand : public UndoCommand {
public:
EditMsgCommand(const MessageId &id, const std::string &name, int size, const std::string &node,
const std::string &comment);
void undo() override;
void redo() override;
private:
const MessageId id;
std::string old_name, new_name, old_comment, new_comment, old_node, new_node;
int old_size = 0, new_size = 0;
};
class RemoveMsgCommand : public UndoCommand {
public:
RemoveMsgCommand(const MessageId &id);
void undo() override;
void redo() override;
private:
const MessageId id;
cabana::Msg message;
};
class AddSigCommand : public UndoCommand {
public:
AddSigCommand(const MessageId &id, const cabana::Signal &sig);
void undo() override;
void redo() override;
private:
const MessageId id;
bool msg_created = false;
cabana::Signal signal = {};
};
class RemoveSigCommand : public UndoCommand {
public:
RemoveSigCommand(const MessageId &id, const cabana::Signal *sig);
void undo() override;
void redo() override;
private:
const MessageId id;
std::vector<cabana::Signal> sigs;
};
class EditSignalCommand : public UndoCommand {
public:
EditSignalCommand(const MessageId &id, const cabana::Signal *sig, const cabana::Signal &new_sig);
void undo() override;
void redo() override;
private:
const MessageId id;
std::vector<std::pair<cabana::Signal, cabana::Signal>> sigs; // {old_sig, new_sig}
};
+46
View File
@@ -0,0 +1,46 @@
#pragma once
#include <array>
#include <cstdint>
#include <unordered_map>
#include <vector>
#include "tools/cabana/core/color.h"
#include "tools/cabana/core/message_id.h"
struct CanData {
void compute(const MessageId &msg_id, const uint8_t *data, int size, double current_sec,
double playback_speed, const std::vector<uint8_t> &mask, double frequency = 0);
double ts = 0.;
uint32_t count = 0;
double freq = 0;
std::vector<uint8_t> dat;
std::vector<CabanaColor> colors;
struct ByteLastChange {
double ts = 0;
int delta = 0;
int same_delta_counter = 0;
bool suppressed = false;
};
std::vector<ByteLastChange> last_changes;
std::vector<std::array<uint32_t, 8>> bit_flip_counts;
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 event, uint64_t ts) const { return event->mono_time < ts; }
constexpr bool operator()(uint64_t ts, const CanEvent *const event) const { return ts < event->mono_time; }
};
using MessageEventsMap = std::unordered_map<MessageId, std::vector<const CanEvent *>>;
using CanEventIter = std::vector<const CanEvent *>::const_iterator;
+79
View File
@@ -0,0 +1,79 @@
#pragma once
#include <algorithm>
#include <cmath>
#include <cstdint>
struct CabanaColor {
uint8_t r = 0;
uint8_t g = 0;
uint8_t b = 0;
uint8_t a = 255;
constexpr CabanaColor() = default;
constexpr CabanaColor(uint8_t red, uint8_t green, uint8_t blue, uint8_t alpha = 255)
: r(red), g(green), b(blue), a(alpha) {}
static CabanaColor fromHsv(float hue, float saturation, float value, float alpha = 1.0f) {
const float h = hue - std::floor(hue);
const float c = value * saturation;
const float x = c * (1.0f - std::fabs(std::fmod(h * 6.0f, 2.0f) - 1.0f));
const float m = value - c;
float red = 0, green = 0, blue = 0;
switch (static_cast<int>(h * 6.0f) % 6) {
case 0: red = c; green = x; break;
case 1: red = x; green = c; break;
case 2: green = c; blue = x; break;
case 3: green = x; blue = c; break;
case 4: red = x; blue = c; break;
default: red = c; blue = x; break;
}
auto channel = [m](float v) { return static_cast<uint8_t>(std::clamp((v + m) * 255.0f, 0.0f, 255.0f) + 0.5f); };
return {channel(red), channel(green), channel(blue),
static_cast<uint8_t>(std::clamp(alpha * 255.0f, 0.0f, 255.0f) + 0.5f)};
}
CabanaColor darker(int factor = 200) const {
if (factor <= 0) return *this;
if (factor < 100) return lighter(10000 / factor);
auto [hue, saturation, value] = hsv();
return fromHsv(hue, saturation, value * 100.0f / factor, a / 255.0f);
}
CabanaColor lighter(int factor = 150) const {
if (factor <= 0) return *this;
if (factor < 100) return darker(10000 / factor);
auto [hue, saturation, value] = hsv();
const float scaled_value = value * factor / 100.0f;
if (scaled_value > 1.0f) saturation = std::max(0.0f, saturation - (scaled_value - 1.0f));
return fromHsv(hue, saturation, std::min(1.0f, scaled_value), a / 255.0f);
}
constexpr int red() const { return r; }
constexpr int green() const { return g; }
constexpr int blue() const { return b; }
constexpr int alpha() const { return a; }
float alphaF() const { return a / 255.0f; }
void setAlphaF(float alpha) { a = static_cast<uint8_t>(std::clamp(alpha * 255.0f, 0.0f, 255.0f) + 0.5f); }
constexpr bool operator==(const CabanaColor &other) const {
return r == other.r && g == other.g && b == other.b && a == other.a;
}
private:
struct Hsv { float hue; float saturation; float value; };
Hsv hsv() const {
const float red = r / 255.0f, green = g / 255.0f, blue = b / 255.0f;
const float maximum = std::max({red, green, blue});
const float minimum = std::min({red, green, blue});
const float delta = maximum - minimum;
float hue = 0;
if (delta > 0) {
if (maximum == red) hue = std::fmod((green - blue) / delta, 6.0f) / 6.0f;
else if (maximum == green) hue = ((blue - red) / delta + 2.0f) / 6.0f;
else hue = ((red - green) / delta + 4.0f) / 6.0f;
if (hue < 0) hue += 1.0f;
}
return {hue, maximum == 0 ? 0 : delta / maximum, maximum};
}
};
+27
View File
@@ -0,0 +1,27 @@
#pragma once
#include <cstdint>
#include <cstdio>
#include <functional>
#include <string>
#include <tuple>
constexpr int INVALID_SOURCE = 0xff;
struct MessageId {
uint8_t source = 0;
uint32_t address = 0;
std::string toString() const { char b[64]; snprintf(b, sizeof(b), "%u:%X", source, address); return b; }
static MessageId fromString(const std::string &s) {
const auto p = s.find(':');
if (p == std::string::npos) return {};
return {.source = static_cast<uint8_t>(std::stoul(s.substr(0, p))), .address = static_cast<uint32_t>(std::stoul(s.substr(p + 1), nullptr, 16))};
}
bool operator==(const MessageId &o) const { return source == o.source && address == o.address; }
bool operator!=(const MessageId &o) const { return !(*this == o); }
bool operator<(const MessageId &o) const { return std::tie(source, address) < std::tie(o.source, o.address); }
bool operator>(const MessageId &o) const { return o < *this; }
};
template <> struct std::hash<MessageId> {
size_t operator()(const MessageId &id) const noexcept { return std::hash<uint8_t>{}(id.source) ^ (std::hash<uint32_t>{}(id.address) << 1); }
};
+34
View File
@@ -0,0 +1,34 @@
#pragma once
#include <string>
#include <vector>
constexpr int LIGHT_THEME = 1;
constexpr int DARK_THEME = 2;
struct CabanaSettingsState {
enum DragDirection { MsbFirst, LsbFirst, AlwaysLE, AlwaysBE };
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;
int chart_series_type = 0;
int theme = 0;
int sparkline_range = 15;
bool multiple_lines_hex = false;
bool log_livestream = true;
bool suppress_defined_signals = false;
std::string log_path;
std::string last_dir;
std::string last_route_dir;
std::vector<std::string> recent_files;
DragDirection drag_direction = MsbFirst;
std::string recent_dbc_file;
std::string active_msg_id;
std::vector<std::string> selected_msg_ids;
std::vector<std::string> active_charts;
};
+231
View File
@@ -0,0 +1,231 @@
#include "tools/cabana/dbc/dbc.h"
#include <algorithm>
#include <cmath>
namespace {
int numDecimals(double value) {
int decimals = 0;
while (decimals < 6 && std::fabs(value - std::round(value)) > 1e-9) {
value *= 10.0;
++decimals;
}
return decimals;
}
}
// 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 std::string &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 std::string &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;
transmitter = other.transmitter;
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 std::string &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;
}
std::string cabana::Msg::newSignalName() {
std::string new_name;
for (int i = 1; /**/; ++i) {
new_name = "NEW_SIGNAL_" + std::to_string(i);
if (sig(new_name) == nullptr) break;
}
return new_name;
}
void cabana::Msg::update() {
if (transmitter.empty()) {
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 -= sz;
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.empty()) {
receiver_name = DEFAULT_NODE_NAME;
}
float h = 19 * (float)lsb / 64.0;
h = fmod(h, 1.0);
size_t hash = std::hash<std::string>{}(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 = CabanaColor::fromHsv(h, s, v);
precision = std::max(numDecimals(factor), numDecimals(offset));
}
std::string cabana::Signal::formatValue(double value, bool with_unit) 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;
}
}
char buf[64];
snprintf(buf, sizeof(buf), "%.*f", precision, value);
std::string val_str(buf);
if (with_unit && !unit.empty()) {
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) {
const int msb_byte = sig.msb / 8;
if (msb_byte >= (int)data_size) return 0;
const int lsb_byte = sig.lsb / 8;
uint64_t val = 0;
// Fast path: signal fits in a single byte
if (msb_byte == lsb_byte) {
val = (data[msb_byte] >> (sig.lsb & 7)) & ((1ULL << sig.size) - 1);
} else {
// Multi-byte case: signal spans across multiple bytes
int bits = sig.size;
int i = msb_byte;
const int step = sig.is_little_endian ? -1 : 1;
while (i >= 0 && i < (int)data_size && bits > 0) {
const int msb = (i == msb_byte) ? sig.msb & 7 : 7;
const int lsb = (i == lsb_byte) ? sig.lsb & 7 : 0;
const int nbits = msb - lsb + 1;
val = (val << nbits) | ((data[i] >> lsb) & ((1ULL << nbits) - 1));
bits -= nbits;
i += step;
}
}
// Sign extension (if needed)
if (sig.is_signed && (val & (1ULL << (sig.size - 1)))) {
val |= ~((1ULL << sig.size) - 1);
}
return static_cast<int64_t>(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;
}
}
+94
View File
@@ -0,0 +1,94 @@
#pragma once
#include <cstdio>
#include <cstdlib>
#include <functional>
#include <limits>
#include <string>
#include <utility>
#include <vector>
#include "tools/cabana/core/color.h"
#include "tools/cabana/core/message_id.h"
const std::string UNTITLED = "untitled";
const std::string DEFAULT_NODE_NAME = "XXX";
constexpr int CAN_MAX_DATA_BYTES = 64;
typedef std::vector<std::pair<double, std::string>> 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;
std::string formatValue(double value, bool with_unit = true) 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;
std::string name;
int start_bit, msb, lsb, size;
double factor = 1.0;
double offset = 0;
bool is_signed;
bool is_little_endian;
double min, max;
std::string unit;
std::string comment;
std::string receiver_name;
ValueDescription val_desc;
int precision = 0;
CabanaColor 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 std::string &sig_name, const cabana::Signal &sig);
void removeSignal(const std::string &sig_name);
Msg &operator=(const Msg &other);
int indexOf(const cabana::Signal *sig) const;
cabana::Signal *sig(const std::string &sig_name) const;
std::string newSignalName();
void update();
inline const std::vector<cabana::Signal *> &getSignals() const { return sigs; }
uint32_t address;
std::string name;
uint32_t size;
std::string comment;
std::string 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 std::string doubleToString(double value) {
char buf[64];
snprintf(buf, sizeof(buf), "%.*g", std::numeric_limits<double>::digits10, value);
return buf;
}
+271
View File
@@ -0,0 +1,271 @@
#include "tools/cabana/dbc/dbcfile.h"
#include <algorithm>
#include <cassert>
#include <filesystem>
#include <fstream>
#include <regex>
#include <sstream>
#include <stdexcept>
namespace {
std::string trim(const std::string &value) {
const auto first = value.find_first_not_of(" \t\r\n");
if (first == std::string::npos) return {};
return value.substr(first, value.find_last_not_of(" \t\r\n") - first + 1);
}
bool startsWith(const std::string &value, const char *prefix) {
return value.rfind(prefix, 0) == 0;
}
std::string unescapeComment(std::string value) {
for (size_t pos = 0; (pos = value.find("\\\"", pos)) != std::string::npos; ++pos) {
value.replace(pos, 2, "\"");
}
return trim(value);
}
bool commentComplete(const std::string &line) {
bool escaped = false;
for (size_t i = 0; i < line.size(); ++i) {
if (line[i] == '\\' && !escaped) {
escaped = true;
continue;
}
if (line[i] == '"' && !escaped) {
size_t next = line.find_first_not_of(" \t\r\n", i + 1);
if (next != std::string::npos && line[next] == ';') return true;
}
escaped = false;
}
return false;
}
} // namespace
DBCFile::DBCFile(const std::string &dbc_file_name) {
std::ifstream file(dbc_file_name, std::ios::binary);
if (!file) throw std::runtime_error("Failed to open file.");
filename = dbc_file_name;
name_ = std::filesystem::path(dbc_file_name).stem().string();
parse(std::string(std::istreambuf_iterator<char>(file), std::istreambuf_iterator<char>()));
}
DBCFile::DBCFile(const std::string &name, const std::string &content) : name_(name) {
parse(content);
}
bool DBCFile::save() {
assert(!filename.empty());
return writeContents(filename);
}
bool DBCFile::saveAs(const std::string &new_filename) {
filename = new_filename;
return save();
}
bool DBCFile::writeContents(const std::string &fn) {
std::ofstream file(fn, std::ios::binary | std::ios::trunc);
if (!file) return false;
file << generateDBC();
return file.good();
}
void DBCFile::updateMsg(const MessageId &id, const std::string &name, uint32_t size,
const std::string &node, const std::string &comment) {
auto &m = msgs[id.address];
m.address = id.address;
m.name = name;
m.size = size;
m.transmitter = node.empty() ? 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 std::string &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;
}
cabana::Signal *DBCFile::signal(uint32_t address, const std::string &name) {
auto m = msg(address);
return m ? m->sig(name) : nullptr;
}
void DBCFile::parse(const std::string &content) {
msgs.clear();
header.clear();
std::istringstream input(content);
std::string raw_line;
cabana::Msg *current_msg = nullptr;
int multiplexor_cnt = 0;
int line_num = 0;
bool seen_first = false;
while (std::getline(input, raw_line)) {
++line_num;
const size_t first_nonspace = raw_line.find_first_not_of(" \t\r");
std::string line = first_nonspace == std::string::npos ? std::string() : raw_line.substr(first_nonspace);
const int statement_line = line_num;
if ((startsWith(line, "CM_ BO_") || startsWith(line, "CM_ SG_ ")) && !commentComplete(line)) {
std::string continuation;
while (std::getline(input, continuation)) {
++line_num;
line += "\n" + continuation;
if (commentComplete(line)) break;
}
}
bool seen = true;
try {
if (startsWith(line, "BO_ ")) {
multiplexor_cnt = 0;
current_msg = parseBO(line);
} else if (startsWith(line, "SG_ ")) {
parseSG(line, current_msg, multiplexor_cnt);
} else if (startsWith(line, "VAL_ ")) {
parseVAL(line);
} else if (startsWith(line, "CM_ BO_")) {
parseCM_BO(line);
} else if (startsWith(line, "CM_ SG_ ")) {
parseCM_SG(line);
} else {
seen = false;
}
} catch (const std::exception &e) {
throw std::runtime_error("[" + filename + ":" + std::to_string(statement_line) + "]" + e.what() + ": " + line);
}
if (seen) seen_first = true;
else if (!seen_first) header += raw_line + "\n";
}
for (auto &[_, message] : msgs) message.update();
}
cabana::Msg *DBCFile::parseBO(const std::string &line) {
static const std::regex pattern(R"(^BO_ ([[:alnum:]_]+) ([[:alnum:]_]+) *: ([[:alnum:]_]+) ([[:alnum:]_]+))");
std::smatch match;
if (!std::regex_search(line, match, pattern)) throw std::runtime_error("Invalid BO_ line format");
const uint32_t address = std::stoul(match[1].str());
if (msgs.count(address)) throw std::runtime_error("Duplicate message address: " + std::to_string(address));
auto &message = msgs[address];
message.address = address;
message.name = match[2].str();
message.size = std::stoul(match[3].str());
message.transmitter = trim(match[4].str());
return &message;
}
void DBCFile::parseSG(const std::string &line, cabana::Msg *current_msg, int &multiplexor_cnt) {
static const std::regex pattern(R"dbc(^SG_ ([[:alnum:]_]+)(?: +([[:alnum:]_]+))? *: ([0-9]+)\|([0-9]+)@([0-9]+)([+-]) \(([0-9.+\-eE]+),([0-9.+\-eE]+)\) \[([0-9.+\-eE]+)\|([0-9.+\-eE]+)\] "(.*)" (.*))dbc");
if (!current_msg) throw std::runtime_error("No Message");
std::smatch match;
if (!std::regex_search(line, match, pattern)) throw std::runtime_error("Invalid SG_ line format");
if (current_msg->sig(match[1].str())) throw std::runtime_error("Duplicate signal name");
cabana::Signal signal{};
const std::string indicator = match[2].str();
if (!indicator.empty()) {
if (indicator == "M") {
if (++multiplexor_cnt >= 2) throw std::runtime_error("Multiple multiplexor");
signal.type = cabana::Signal::Type::Multiplexor;
} else {
signal.type = cabana::Signal::Type::Multiplexed;
signal.multiplex_value = indicator.size() > 1 ? std::stoi(indicator.substr(1)) : 0;
}
}
signal.name = match[1].str();
signal.start_bit = std::stoi(match[3].str());
signal.size = std::stoi(match[4].str());
signal.is_little_endian = match[5].str() == "1";
signal.is_signed = match[6].str() == "-";
signal.factor = std::stod(match[7].str());
signal.offset = std::stod(match[8].str());
signal.min = std::stod(match[9].str());
signal.max = std::stod(match[10].str());
signal.unit = match[11].str();
signal.receiver_name = trim(match[12].str());
current_msg->sigs.push_back(new cabana::Signal(signal));
}
void DBCFile::parseCM_BO(const std::string &line) {
std::istringstream prefix(line.substr(7));
uint32_t address = 0;
prefix >> address;
const size_t first_quote = line.find('"');
const size_t last_quote = line.rfind('"');
if (!prefix || first_quote == std::string::npos || last_quote <= first_quote) {
throw std::runtime_error("Invalid message comment format");
}
if (auto message = msg(address)) message->comment = unescapeComment(line.substr(first_quote + 1, last_quote - first_quote - 1));
}
void DBCFile::parseCM_SG(const std::string &line) {
std::istringstream prefix(line.substr(7));
uint32_t address = 0;
std::string name;
prefix >> address >> name;
const size_t first_quote = line.find('"');
const size_t last_quote = line.rfind('"');
if (!prefix || name.empty() || first_quote == std::string::npos || last_quote <= first_quote) {
throw std::runtime_error("Invalid CM_ SG_ line format");
}
if (auto sig = signal(address, name)) sig->comment = unescapeComment(line.substr(first_quote + 1, last_quote - first_quote - 1));
}
void DBCFile::parseVAL(const std::string &line) {
static const std::regex header_pattern(R"(^VAL_ ([[:alnum:]_]+) ([[:alnum:]_]+) (.*))");
static const std::regex entry_pattern(R"dbc(([+-]?[0-9]+(?:\.[0-9]+)?)\s+"([^"]*)")dbc");
std::smatch match;
if (!std::regex_search(line, match, header_pattern)) throw std::runtime_error("invalid VAL_ line format");
if (auto sig = signal(std::stoul(match[1].str()), match[2].str())) {
const std::string entries = match[3].str();
for (std::sregex_iterator it(entries.begin(), entries.end(), entry_pattern), end; it != end; ++it) {
sig->val_desc.emplace_back(std::stod((*it)[1].str()), trim((*it)[2].str()));
}
}
}
std::string DBCFile::generateDBC() {
std::string dbc_string, comment, val_desc;
for (const auto &[address, m] : msgs) {
const std::string &transmitter = m.transmitter.empty() ? DEFAULT_NODE_NAME : m.transmitter;
dbc_string += "BO_ " + std::to_string(address) + " " + m.name + ": " + std::to_string(m.size) + " " + transmitter + "\n";
if (!m.comment.empty()) {
std::string escaped = m.comment;
for (size_t pos = 0; (pos = escaped.find('"', pos)) != std::string::npos; pos += 2) escaped.replace(pos, 1, "\\\"");
comment += "CM_ BO_ " + std::to_string(address) + " \"" + escaped + "\";\n";
}
for (auto sig : m.getSignals()) {
std::string mux;
if (sig->type == cabana::Signal::Type::Multiplexor) mux = "M ";
else if (sig->type == cabana::Signal::Type::Multiplexed) mux = "m" + std::to_string(sig->multiplex_value) + " ";
const std::string &receiver = sig->receiver_name.empty() ? DEFAULT_NODE_NAME : sig->receiver_name;
dbc_string += " SG_ " + sig->name + " " + mux + ": " + std::to_string(sig->start_bit) + "|" + std::to_string(sig->size) + "@" +
(sig->is_little_endian ? "1" : "0") + (sig->is_signed ? "-" : "+") +
" (" + doubleToString(sig->factor) + "," + doubleToString(sig->offset) + ")" +
" [" + doubleToString(sig->min) + "|" + doubleToString(sig->max) + "] \"" + sig->unit + "\" " + receiver + "\n";
if (!sig->comment.empty()) {
std::string escaped = sig->comment;
for (size_t pos = 0; (pos = escaped.find('"', pos)) != std::string::npos; pos += 2) escaped.replace(pos, 1, "\\\"");
comment += "CM_ SG_ " + std::to_string(address) + " " + sig->name + " \"" + escaped + "\";\n";
}
if (!sig->val_desc.empty()) {
std::string text;
for (const auto &[value, description] : sig->val_desc) {
if (!text.empty()) text += " ";
text += doubleToString(value) + " \"" + description + "\"";
}
val_desc += "VAL_ " + std::to_string(address) + " " + sig->name + " " + text + ";\n";
}
}
dbc_string += "\n";
}
return header + dbc_string + comment + val_desc;
}
+44
View File
@@ -0,0 +1,44 @@
#pragma once
#include <map>
#include <string>
#include "tools/cabana/dbc/dbc.h"
class DBCFile {
public:
DBCFile(const std::string &dbc_file_name);
DBCFile(const std::string &name, const std::string &content);
~DBCFile() {}
bool save();
bool saveAs(const std::string &new_filename);
bool writeContents(const std::string &fn);
std::string generateDBC();
void updateMsg(const MessageId &id, const std::string &name, uint32_t size, const std::string &node, const std::string &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 std::string &name);
inline cabana::Msg *msg(const MessageId &id) { return msg(id.address); }
cabana::Signal *signal(uint32_t address, const std::string &name);
inline std::string name() const { return name_.empty() ? "untitled" : name_; }
inline bool isEmpty() const { return msgs.empty() && name_.empty(); }
std::string filename;
private:
void parse(const std::string &content);
cabana::Msg *parseBO(const std::string &line);
void parseSG(const std::string &line, cabana::Msg *current_msg, int &multiplexor_cnt);
void parseCM_BO(const std::string &line);
void parseCM_SG(const std::string &line);
void parseVAL(const std::string &line);
std::string header;
std::map<uint32_t, cabana::Msg> msgs;
std::string name_;
};
+182
View File
@@ -0,0 +1,182 @@
#include "tools/cabana/dbc/dbcmanager.h"
#include <algorithm>
#include <cassert>
#include <set>
bool DBCManager::open(const SourceSet &sources, const std::string &dbc_file_name, std::string *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;
}
if (callbacks_.file_changed) callbacks_.file_changed();
return true;
}
bool DBCManager::open(const SourceSet &sources, const std::string &name, const std::string &content, std::string *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;
}
if (callbacks_.file_changed) callbacks_.file_changed();
return true;
}
void DBCManager::close(const SourceSet &sources) {
for (auto s : sources) {
dbc_files[s] = nullptr;
}
if (callbacks_.file_changed) callbacks_.file_changed();
}
void DBCManager::close(DBCFile *dbc_file) {
for (auto &[_, f] : dbc_files) {
if (f.get() == dbc_file) f = nullptr;
}
if (callbacks_.file_changed) callbacks_.file_changed();
}
void DBCManager::closeAll() {
dbc_files.clear();
if (callbacks_.file_changed) callbacks_.file_changed();
}
void DBCManager::addSignal(const MessageId &id, const cabana::Signal &sig) {
if (auto m = msg(id)) {
if (auto s = m->addSignal(sig)) {
if (callbacks_.signal_added) callbacks_.signal_added(id, s);
if (callbacks_.mask_updated) callbacks_.mask_updated();
}
}
}
void DBCManager::updateSignal(const MessageId &id, const std::string &sig_name, const cabana::Signal &sig) {
if (auto m = msg(id)) {
if (auto s = m->updateSignal(sig_name, sig)) {
if (callbacks_.signal_updated) callbacks_.signal_updated(s);
if (callbacks_.mask_updated) callbacks_.mask_updated();
}
}
}
void DBCManager::removeSignal(const MessageId &id, const std::string &sig_name) {
if (auto m = msg(id)) {
if (auto s = m->sig(sig_name)) {
if (callbacks_.signal_removed) callbacks_.signal_removed(s);
m->removeSignal(sig_name);
if (callbacks_.mask_updated) callbacks_.mask_updated();
}
}
}
void DBCManager::updateMsg(const MessageId &id, const std::string &name, uint32_t size, const std::string &node, const std::string &comment) {
auto dbc_file = findDBCFile(id);
assert(dbc_file); // This should be impossible
dbc_file->updateMsg(id, name, size, node, comment);
if (callbacks_.msg_updated) callbacks_.msg_updated(id);
}
void DBCManager::removeMsg(const MessageId &id) {
auto dbc_file = findDBCFile(id);
assert(dbc_file); // This should be impossible
dbc_file->removeMsg(id);
if (callbacks_.msg_removed) callbacks_.msg_removed(id);
if (callbacks_.mask_updated) callbacks_.mask_updated();
}
std::string DBCManager::newMsgName(const MessageId &id) {
char buf[64];
snprintf(buf, sizeof(buf), "NEW_MSG_%X", id.address);
return buf;
}
std::string DBCManager::newSignalName(const MessageId &id) {
auto m = msg(id);
return m ? m->newSignalName() : "";
}
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 std::string &name) {
auto dbc_file = findDBCFile(source);
return dbc_file ? dbc_file->msg(name) : nullptr;
}
std::vector<std::string> DBCManager::signalNames() {
// Used for autocompletion
std::set<std::string> names;
for (auto &f : allDBCFiles()) {
for (auto &[_, m] : f->getMessages()) {
for (auto sig : m.getSignals()) {
names.insert(sig->name);
}
}
}
std::vector<std::string> ret(names.begin(), names.end());
std::sort(ret.begin(), ret.end());
return ret;
}
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;
}
std::string toString(const SourceSet &ss) {
std::string result;
for (int source : ss) {
if (!result.empty()) result += ", ";
result += (source == -1) ? "all" : std::to_string(source);
}
return result;
}
DBCManager *dbc() {
static DBCManager dbc_manager;
return &dbc_manager;
}
+70
View File
@@ -0,0 +1,70 @@
#pragma once
#include <functional>
#include <memory>
#include <map>
#include <set>
#include <string>
#include <vector>
#include "tools/cabana/dbc/dbcfile.h"
typedef std::set<int> SourceSet;
const SourceSet SOURCE_ALL = {-1};
inline bool operator<(const std::shared_ptr<DBCFile> &l, const std::shared_ptr<DBCFile> &r) { return l.get() < r.get(); }
class DBCManager {
public:
struct Callbacks {
std::function<void(MessageId, const cabana::Signal *)> signal_added;
std::function<void(const cabana::Signal *)> signal_removed;
std::function<void(const cabana::Signal *)> signal_updated;
std::function<void(MessageId)> msg_updated;
std::function<void(MessageId)> msg_removed;
std::function<void()> file_changed;
std::function<void()> mask_updated;
};
DBCManager() = default;
bool open(const SourceSet &sources, const std::string &dbc_file_name, std::string *error = nullptr);
bool open(const SourceSet &sources, const std::string &name, const std::string &content, std::string *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 std::string &sig_name, const cabana::Signal &sig);
void removeSignal(const MessageId &id, const std::string &sig_name);
void updateMsg(const MessageId &id, const std::string &name, uint32_t size, const std::string &node, const std::string &comment);
void removeMsg(const MessageId &id);
std::string newMsgName(const MessageId &id);
std::string newSignalName(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 std::string &name);
std::vector<std::string> signalNames();
inline int dbcCount() { return allDBCFiles().size(); }
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();
void setCallbacks(Callbacks callbacks) { callbacks_ = std::move(callbacks); }
private:
std::map<int, std::shared_ptr<DBCFile>> dbc_files;
Callbacks callbacks_;
};
DBCManager *dbc();
std::string toString(const SourceSet &ss);
inline std::string msgName(const MessageId &id) {
auto msg = dbc()->msg(id);
return msg ? msg->name : UNTITLED;
}
+18
View File
@@ -0,0 +1,18 @@
#include "tools/cabana/dbc/dbcqt.h"
QtDBCNotifier::QtDBCNotifier(QObject *parent) : QObject(parent) {
dbc()->setCallbacks({
.signal_added = [this](MessageId id, const cabana::Signal *sig) { emit signalAdded(id, sig); },
.signal_removed = [this](const cabana::Signal *sig) { emit signalRemoved(sig); },
.signal_updated = [this](const cabana::Signal *sig) { emit signalUpdated(sig); },
.msg_updated = [this](MessageId id) { emit msgUpdated(id); },
.msg_removed = [this](MessageId id) { emit msgRemoved(id); },
.file_changed = [this]() { emit DBCFileChanged(); },
.mask_updated = [this]() { emit maskUpdated(); },
});
}
QtDBCNotifier *dbcNotifier() {
static QtDBCNotifier notifier;
return &notifier;
}
+27
View File
@@ -0,0 +1,27 @@
#pragma once
#include <QMetaType>
#include <QObject>
#include "tools/cabana/dbc/dbcmanager.h"
Q_DECLARE_METATYPE(MessageId)
Q_DECLARE_METATYPE(ValueDescription)
class QtDBCNotifier : public QObject {
Q_OBJECT
public:
explicit QtDBCNotifier(QObject *parent = nullptr);
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();
};
QtDBCNotifier *dbcNotifier();
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env python3
import argparse
import json
from opendbc.car import Bus
from opendbc.car.fingerprints import MIGRATION
from opendbc.car.values import PLATFORMS
def generate_dbc_dict() -> dict[str, str]:
dbc_map = {}
for platform in PLATFORMS.values():
if platform != "MOCK":
if Bus.pt in platform.config.dbc_dict:
dbc_map[platform.name] = platform.config.dbc_dict[Bus.pt]
elif Bus.main in platform.config.dbc_dict:
dbc_map[platform.name] = platform.config.dbc_dict[Bus.main]
elif Bus.party in platform.config.dbc_dict:
dbc_map[platform.name] = platform.config.dbc_dict[Bus.party]
else:
raise ValueError("Unknown main type")
for m in MIGRATION:
if MIGRATION[m] in dbc_map:
dbc_map[m] = dbc_map[MIGRATION[m]]
return dbc_map
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(json.dumps(dict(sorted(generate_dbc_dict().items())), indent=2))
print(f"Generated and written to {args.out}")
+53
View File
@@ -0,0 +1,53 @@
we're migrating cabana away from Qt and to eventually entirely use imgui
we are doing it incrementally, in small pieces that are easy to execute and verify.
we will repeat this until we're all done.
# Cabana Qt API inventory
these are all still in cabana. we remove them from this list once they're gone.
each bullet is an atomic unit of work.
our workflow is:
- pick the easiest of the bulleted items from below
- implement it and make] sure it builds
- spin up reviewer agents to review the code in a clean context and a separate one to click around in xvfb as a gui test
- then implement the fixes from the above reviewer agents
some rules
- do not add more Qt usage ever
- `QObject`, `QMetaObject`, `QMetaType`
- `QApplication`, `QCoreApplication`, `QGuiApplication`
- `QString`, `QStringList`, `QStringBuilder`, `QChar`, `QLatin1Char`
- `QVariant`
- `QTimer`
- `QWidget`, `QMainWindow`, `QWindow`
- `QDialog`, `QDialogButtonBox`, `QMessageBox`, `QProgressDialog`
- `QFileDialog`
- `QMenu`, `QMenuBar`, `QAction`, `QActionGroup`, `QWidgetAction`
- `QToolBar`, `QToolButton`, `QPushButton`
- `QCheckBox`, `QRadioButton`, `QButtonGroup`, `QAbstractButton`
- `QComboBox`, `QLineEdit`, `QTextEdit`, `QSpinBox`, `QSlider`
- `QLabel`, `QGroupBox`, `QFrame`
- `QTabBar`, `QTabWidget`, `QSplitter`, `QScrollArea`, `QScrollBar`
- `QDockWidget`, `QStatusBar`, `QProgressBar`
- `QFormLayout`, `QGridLayout`, `QHBoxLayout`, `QVBoxLayout`
- `QSizePolicy`
- `QAbstractItemModel`, `QAbstractTableModel`, `QModelIndex`
- `QAbstractItemView`, `QTableView`, `QTreeView`
- `QTableWidget`, `QTableWidgetItem`, `QListWidget`, `QListWidgetItem`
- `QItemSelection`, `QItemSelectionModel`, `QItemSelectionRange`
- `QHeaderView`, `QStyledItemDelegate`, `QStyleOptionViewItem`
- `QValidator`, `QIntValidator`
- `QColor`, `QRgb`, `QPalette`
- `QBrush`, `QPen`
- `QPainter`, `QPainterPath`, `QStylePainter`
- `QImage`, `QPixmap`, `QPixmapCache`, `QStaticText`
- `QFont`, `QFontDatabase`, `QFontMetrics`, `QTextDocument`
- `QStyle`, `QStyleOption`, `QStyleOptionFrame`, `QStyleOptionSlider`
- `QPoint`, `QPointF`, `QRect`, `QRectF`, `QRegion`
- `QSize`, `QSizeF`
- `QEvent`, `QPaintEvent`, `QResizeEvent`, `QShowEvent`, `QCloseEvent`
- `QMouseEvent`, `QWheelEvent`, `QNativeGestureEvent`, `QContextMenuEvent`
- `QKeySequence`, `QShortcut`, `QToolTip`
+324
View File
@@ -0,0 +1,324 @@
#include "tools/cabana/detailwidget.h"
#include "tools/cabana/dbc/dbcqt.h"
#include <QFormLayout>
#include <QMenu>
#include <QRadioButton>
#include <QPushButton>
#include <QToolBar>
#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);
createToolBar();
// 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(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(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, this, &DetailWidget::refresh);
QObject::connect(undoNotifier(), &QtUndoNotifier::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::createToolBar() {
QToolBar *toolbar = new QToolBar(this);
int icon_size = style()->pixelMetric(QStyle::PM_SmallIconSize);
toolbar->setIconSize({icon_size, icon_size});
toolbar->addWidget(name_label = new ElidedLabel(this));
name_label->setStyleSheet("QLabel{font-weight:bold;}");
QWidget *spacer = new QWidget();
spacer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred);
toolbar->addWidget(spacer);
// Heatmap label and radio buttons
toolbar->addWidget(new QLabel(tr("Heatmap:"), this));
auto *heatmap_live = new QRadioButton(tr("Live"), this);
auto *heatmap_all = new QRadioButton(tr("All"), this);
heatmap_live->setChecked(true);
toolbar->addWidget(heatmap_live);
toolbar->addWidget(heatmap_all);
// Edit and remove buttons
toolbar->addSeparator();
toolbar->addAction(utils::icon("pencil"), tr("Edit Message"), this, &DetailWidget::editMsg);
action_remove_msg = toolbar->addAction(utils::icon("x-lg"), tr("Remove Message"), this, &DetailWidget::removeMsg);
layout()->addWidget(toolbar);
connect(heatmap_live, &QAbstractButton::toggled, this, [this](bool on) { binary_view->setHeatmapLiveMode(on); });
connect(can, &AbstractStream::timeRangeChanged, this, [=](const std::optional<std::pair<double, double>> &range) {
auto text = range ? QString("%1 - %2").arg(range->first, 0, 'f', 3).arg(range->second, 0, 'f', 3) : "All";
heatmap_all->setText(text);
(range ? heatmap_all : heatmap_live)->setChecked(true);
});
}
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);
}
}
}
}
int DetailWidget::findOrAddTab(const MessageId& message_id) {
int index = tabbar->count() - 1;
for (/**/; index >= 0; --index) {
if (tabbar->tabData(index).value<MessageId>() == message_id) break;
}
if (index == -1) {
index = tabbar->addTab(QString::fromStdString(message_id.toString()));
tabbar->setTabData(index, QVariant::fromValue(message_id));
tabbar->setTabToolTip(index, QString::fromStdString(msgName(message_id)));
}
return index;
}
void DetailWidget::setMessage(const MessageId &message_id) {
if (std::exchange(msg_id, message_id) == message_id) return;
tabbar->blockSignals(true);
int index = findOrAddTab(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);
}
std::pair<QString, QStringList> DetailWidget::serializeMessageIds() const {
QStringList msgs;
for (int i = 0; i < tabbar->count(); ++i) {
MessageId id = tabbar->tabData(i).value<MessageId>();
msgs.append(QString::fromStdString(id.toString()));
}
return std::make_pair(QString::fromStdString(msg_id.toString()), msgs);
}
void DetailWidget::restoreTabs(const QString active_msg_id, const QStringList& msg_ids) {
tabbar->blockSignals(true);
for (const auto& str_id : msg_ids) {
MessageId id = MessageId::fromString(str_id.toStdString());
if (dbc()->msg(id) != nullptr)
findOrAddTab(id);
}
tabbar->blockSignals(false);
auto active_id = MessageId::fromString(active_msg_id.toStdString());
if (dbc()->msg(active_id) != nullptr)
setMessage(active_id);
}
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(QString::fromStdString(s->name)));
}
}
QString msg_name = msg ? QString("%1 (%2)").arg(QString::fromStdString(msg->name), QString::fromStdString(msg->transmitter)) : QString::fromStdString(msgName(msg_id));
name_label->setText(msg_name);
name_label->setToolTip(msg_name);
action_remove_msg->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, QString::fromStdString(msgName(msg_id)), size, this);
if (dlg.exec()) {
UndoStack::instance()->push(new EditMsgCommand(msg_id, dlg.name_edit->text().trimmed().toStdString(), dlg.size_spin->value(),
dlg.node->text().trimmed().toStdString(), dlg.comment_edit->toPlainText().trimmed().toStdString()));
}
}
void DetailWidget::removeMsg() {
UndoStack::instance()->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(QString::fromStdString(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));
size_spin->setRange(1, CAN_MAX_DATA_BYTES);
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(QString::fromStdString(msg->transmitter));
comment_edit->setText(QString::fromStdString(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(QString::fromStdString(UNTITLED), Qt::CaseInsensitive) != 0;
error_label->setVisible(false);
if (!text.isEmpty() && valid && text != original_name) {
valid = dbc()->msg(msg_id.source, text.toStdString()) == 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());
}
DetailWidget* CenterWidget::ensureDetailWidget() {
if (!detail_widget) {
delete welcome_widget;
welcome_widget = nullptr;
layout()->addWidget(detail_widget = new DetailWidget(((MainWindow*)parentWidget())->charts_widget, this));
}
return detail_widget;
}
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;
}
+75
View File
@@ -0,0 +1,75 @@
#pragma once
#include <QDialogButtonBox>
#include <QSplitter>
#include <QTabWidget>
#include <QTextEdit>
#include <set>
#include "tools/cabana/binaryview.h"
#include "tools/cabana/chart/chartswidget.h"
#include "tools/cabana/historylog.h"
#include "tools/cabana/signalview.h"
#include "tools/cabana/utils/elidedlabel.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();
std::pair<QString, QStringList> serializeMessageIds() const;
void restoreTabs(const QString active_msg_id, const QStringList &msg_ids);
private:
void createToolBar();
int findOrAddTab(const MessageId& message_id);
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;
QAction *action_remove_msg;
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 &message_id) { ensureDetailWidget()->setMessage(message_id); }
DetailWidget* getDetailWidget() { return detail_widget; }
DetailWidget* ensureDetailWidget();
void clear();
private:
QWidget *createWelcomeWidget();
DetailWidget *detail_widget = nullptr;
QWidget *welcome_widget = nullptr;
};
+249
View File
@@ -0,0 +1,249 @@
#include "tools/cabana/historylog.h"
#include "tools/cabana/dbc/dbcqt.h"
#include <functional>
#include <QFileDialog>
#include <QPainter>
#include <QVBoxLayout>
#include "tools/cabana/commands.h"
#include "tools/cabana/utils/export.h"
QVariant HistoryLogModel::data(const QModelIndex &index, int role) const {
const auto &m = messages[index.row()];
const int col = index.column();
if (role == Qt::DisplayRole) {
if (col == 0) return QString::number(can->toSeconds(m.mono_time), 'f', 3);
if (!isHexMode()) return QString::fromStdString(sigs[col - 1]->formatValue(m.sig_values[col - 1], false));
} else if (role == Qt::TextAlignmentRole) {
return (uint32_t)(Qt::AlignRight | Qt::AlignVCenter);
}
if (isHexMode() && col == 1) {
if (role == ColorsRole) return QVariant::fromValue((void *)(&m.colors));
if (role == BytesRole) return QVariant::fromValue((void *)(&m.data));
}
return {};
}
void HistoryLogModel::setMessage(const MessageId &message_id) {
msg_id = message_id;
reset();
}
void HistoryLogModel::reset() {
beginResetModel();
sigs.clear();
if (auto dbc_msg = dbc()->msg(msg_id)) {
sigs = dbc_msg->getSignals();
}
messages.clear();
hex_colors = {};
endResetModel();
setFilter(0, "", nullptr);
}
QVariant HistoryLogModel::headerData(int section, Qt::Orientation orientation, int role) const {
if (orientation == Qt::Horizontal) {
if (role == Qt::DisplayRole || role == Qt::ToolTipRole) {
if (section == 0) return "Time";
if (isHexMode()) return "Data";
QString name = QString::fromStdString(sigs[section - 1]->name);
QString unit = QString::fromStdString(sigs[section - 1]->unit);
return unit.isEmpty() ? name : QString("%1 (%2)").arg(name, unit);
} else if (role == Qt::BackgroundRole && section > 0 && !isHexMode()) {
// Alpha-blend the signal color with the background to ensure contrast
QColor sigColor = toQColor(sigs[section - 1]->color);
sigColor.setAlpha(128);
return QBrush(sigColor);
}
}
return {};
}
void HistoryLogModel::setHexMode(bool hex) {
hex_mode = hex;
reset();
}
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;
updateState(true);
}
void HistoryLogModel::updateState(bool clear) {
if (clear && !messages.empty()) {
beginRemoveRows({}, 0, messages.size() - 1);
messages.clear();
endRemoveRows();
}
uint64_t current_time = can->toMonoTime(can->lastMessage(msg_id).ts) + 1;
fetchData(messages.begin(), current_time, messages.empty() ? 0 : messages.front().mono_time);
}
bool HistoryLogModel::canFetchMore(const QModelIndex &parent) const {
const auto &events = can->events(msg_id);
return !events.empty() && !messages.empty() && messages.back().mono_time > events.front()->mono_time;
}
void HistoryLogModel::fetchMore(const QModelIndex &parent) {
if (!messages.empty())
fetchData(messages.end(), messages.back().mono_time, 0);
}
void HistoryLogModel::fetchData(std::deque<Message>::iterator insert_pos, uint64_t from_time, uint64_t min_time) {
const auto &events = can->events(msg_id);
auto first = std::upper_bound(events.rbegin(), events.rend(), from_time, [](uint64_t ts, auto e) {
return ts > e->mono_time;
});
std::vector<HistoryLogModel::Message> msgs;
std::vector<double> values(sigs.size());
msgs.reserve(batch_size);
for (; first != events.rend() && (*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)) {
msgs.emplace_back(Message{e->mono_time, values, {e->dat, e->dat + e->size}});
if (msgs.size() >= batch_size && min_time == 0) {
break;
}
}
}
if (!msgs.empty()) {
if (isHexMode() && (min_time > 0 || messages.empty())) {
const auto freq = can->lastMessage(msg_id).freq;
const std::vector<uint8_t> no_mask;
for (auto &m : msgs) {
hex_colors.compute(msg_id, m.data.data(), m.data.size(), m.mono_time / (double)1e9, can->getSpeed(), no_mask, freq);
m.colors = hex_colors.colors;
}
}
int pos = std::distance(messages.begin(), insert_pos);
beginInsertRows({}, pos , pos + msgs.size() - 1);
messages.insert(insert_pos, std::move_iterator(msgs.begin()), std::move_iterator(msgs.end()));
endInsertRows();
}
}
// HeaderView
QSize HeaderView::sectionSizeFromContents(int logicalIndex) const {
static const QSize time_col_size = fontMetrics().size(Qt::TextSingleLine, "000000.000") + 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(utils::isDarkTheme() ? 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);
export_btn = new ToolButton("filetype-csv", tr("Export to CSV file..."));
h->addWidget(export_btn, 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));
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));
logs->setItemDelegate(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->setFrameShape(QFrame::NoFrame);
QObject::connect(display_type_cb, qOverload<int>(&QComboBox::activated), model, &HistoryLogModel::setHexMode);
QObject::connect(signals_cb, SIGNAL(activated(int)), this, SLOT(filterChanged()));
QObject::connect(comp_box, SIGNAL(activated(int)), this, SLOT(filterChanged()));
QObject::connect(value_edit, &QLineEdit::textEdited, this, &LogsWidget::filterChanged);
QObject::connect(export_btn, &QToolButton::clicked, this, &LogsWidget::exportToCSV);
QObject::connect(can, &AbstractStream::seekedTo, model, &HistoryLogModel::reset);
QObject::connect(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, model, &HistoryLogModel::reset);
QObject::connect(undoNotifier(), &QtUndoNotifier::indexChanged, model, &HistoryLogModel::reset);
QObject::connect(model, &HistoryLogModel::modelReset, this, &LogsWidget::modelReset);
QObject::connect(model, &HistoryLogModel::rowsInserted, [this]() { export_btn->setEnabled(true); });
}
void LogsWidget::modelReset() {
signals_cb->clear();
for (auto s : model->sigs) {
signals_cb->addItem(QString::fromStdString(s->name));
}
export_btn->setEnabled(false);
value_edit->clear();
comp_box->setCurrentIndex(0);
filters_widget->setVisible(!model->sigs.empty());
}
void LogsWidget::filterChanged() {
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);
}
void LogsWidget::exportToCSV() {
QString dir = QString("%1/%2_%3.csv").arg(QString::fromStdString(settings.last_dir)).arg(QString::fromStdString(can->routeName())).arg(QString::fromStdString(msgName(model->msg_id)));
QString fn = QFileDialog::getSaveFileName(this, QString("Export %1 to CSV file").arg(QString::fromStdString(msgName(model->msg_id))),
dir, tr("csv (*.csv)"));
if (!fn.isEmpty()) {
model->isHexMode() ? utils::exportToCSV(fn.toStdString(), model->msg_id)
: utils::exportSignalsToCSV(fn.toStdString(), model->msg_id);
}
}
+81
View File
@@ -0,0 +1,81 @@
#pragma once
#include <deque>
#include <vector>
#include <QComboBox>
#include <QHeaderView>
#include <QLineEdit>
#include <QTableView>
#include "tools/cabana/dbc/dbcmanager.h"
#include "tools/cabana/streams/abstractstream.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(bool clear = false);
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;
bool canFetchMore(const QModelIndex &parent) const override;
int rowCount(const QModelIndex &parent = QModelIndex()) const override { return messages.size(); }
int columnCount(const QModelIndex &parent = QModelIndex()) const override { return !isHexMode() ? sigs.size() + 1 : 2; }
inline bool isHexMode() const { return sigs.empty() || hex_mode; }
void reset();
void setHexMode(bool hex_mode);
struct Message {
uint64_t mono_time = 0;
std::vector<double> sig_values;
std::vector<uint8_t> data;
std::vector<CabanaColor> colors;
};
void fetchData(std::deque<Message>::iterator insert_pos, uint64_t from_time, uint64_t min_time);
MessageId msg_id;
CanData hex_colors;
const int batch_size = 50;
int filter_sig_idx = -1;
double filter_value = 0;
std::function<bool(double, double)> filter_cmp = nullptr;
std::deque<Message> messages;
std::vector<cabana::Signal *> sigs;
bool hex_mode = false;
};
class LogsWidget : public QFrame {
Q_OBJECT
public:
LogsWidget(QWidget *parent);
void setMessage(const MessageId &message_id) { model->setMessage(message_id); }
void updateState() { model->updateState(); }
void showEvent(QShowEvent *event) override { model->updateState(true); }
private slots:
void filterChanged();
void exportToCSV();
void modelReset();
private:
QTableView *logs;
HistoryLogModel *model;
QComboBox *signals_cb, *comp_box, *display_type_cb;
QLineEdit *value_edit;
QWidget *filters_widget;
ToolButton *export_btn;
MessageBytesDelegate *delegate;
};
+740
View File
@@ -0,0 +1,740 @@
#include "tools/cabana/mainwin.h"
#include "tools/cabana/dbc/dbcqt.h"
#include <algorithm>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <iterator>
#include <string>
#include <vector>
#include <QFileDialog>
#include <QMenuBar>
#include <QMessageBox>
#include <QProgressDialog>
#include <QResizeEvent>
#include <QShortcut>
#include <QTextDocument>
#include <QVBoxLayout>
#include "json11/json11.hpp"
#include "tools/cabana/commands.h"
#include "tools/cabana/streamselector.h"
#include "tools/cabana/tools/findsignal.h"
#include "tools/cabana/utils/export.h"
#include "tools/replay/py_downloader.h"
#include "tools/replay/util.h"
MainWindow::MainWindow(AbstractStream *stream, const QString &dbc_file) : QMainWindow() {
loadFingerprints();
createDockWindows();
setCentralWidget(center_widget = new CenterWidget(this));
createActions();
createStatusBar();
createShortcuts();
// save default window state to allow resetting it
default_state = utils::toBytes(saveState());
// restore states; restoreGeometry() itself corrects stale off-screen geometry
restoreGeometry(utils::qbytes(settings.geometry));
restoreState(utils::qbytes(settings.window_state));
// install handlers
static auto static_main_win = this;
qRegisterMetaType<uint64_t>("uint64_t");
qRegisterMetaType<SourceSet>("SourceSet");
installDownloadProgressHandler([](uint64_t cur, uint64_t total, bool success) {
emit static_main_win->updateProgressBar(cur, total, success);
});
installMessageHandler([](ReplyMsgType type, const std::string msg) {
emit static_main_win->showMessage(QString::fromStdString(msg), 2000);
});
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(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, this, &MainWindow::DBCFileChanged);
QObject::connect(undoNotifier(), &QtUndoNotifier::cleanChanged, this, &MainWindow::undoStackCleanChanged);
QObject::connect(&settings, &Settings::changed, this, &MainWindow::updateStatus);
QTimer::singleShot(0, this, [=]() { stream ? openStream(stream, dbc_file) : selectAndOpenStream(); });
show();
}
void MainWindow::loadFingerprints() {
std::ifstream json_file((QApplication::applicationDirPath() + "/dbc/car_fingerprint_to_dbc.json").toStdString());
if (!json_file) return;
const std::string contents{std::istreambuf_iterator<char>(json_file), std::istreambuf_iterator<char>()};
std::string err;
auto doc = json11::Json::parse(contents, err);
if (!err.empty() || !doc.is_object()) return;
fingerprint_to_dbc.clear();
for (const auto &kv : doc.object_items()) {
if (kv.second.is_string()) {
fingerprint_to_dbc.emplace(kv.first, kv.second.string_value());
}
}
}
void MainWindow::createActions() {
// File menu
QMenu *file_menu = menuBar()->addMenu(tr("&File"));
file_menu->addAction(tr("Open Stream..."), this, &MainWindow::selectAndOpenStream);
close_stream_act = file_menu->addAction(tr("Close stream"), this, &MainWindow::closeStream);
export_to_csv_act = file_menu->addAction(tr("Export to CSV..."), this, &MainWindow::exportToCSV);
close_stream_act->setEnabled(false);
export_to_csv_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"));
QObject::connect(manage_dbcs_menu, &QMenu::aboutToShow, this, &MainWindow::updateLoadSaveMenus);
open_recent_menu = file_menu->addMenu(tr("Open &Recent"));
QObject::connect(open_recent_menu, &QMenu::aboutToShow, this, &MainWindow::updateRecentFileMenu);
file_menu->addSeparator();
QMenu *load_opendbc_menu = file_menu->addMenu(tr("Load DBC from commaai/opendbc"));
// load_opendbc_menu->setStyleSheet("QMenu { menu-scrollable: true; }");
std::vector<std::string> dbc_names;
std::error_code ec;
for (const auto &entry : std::filesystem::directory_iterator(OPENDBC_FILE_PATH, ec)) {
if (entry.is_regular_file() && entry.path().extension() == ".dbc") {
dbc_names.push_back(entry.path().filename().string());
}
}
std::sort(dbc_names.begin(), dbc_names.end());
for (const auto &dbc_name : dbc_names) {
QString name = QString::fromStdString(dbc_name);
load_opendbc_menu->addAction(name, [this, 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"));
undo_act = edit_menu->addAction(tr("&Undo"), []() { UndoStack::instance()->undo(); });
undo_act->setShortcuts(QKeySequence::Undo);
redo_act = edit_menu->addAction(tr("&Redo"), []() { UndoStack::instance()->redo(); });
redo_act->setShortcuts(QKeySequence::Redo);
QObject::connect(undoNotifier(), &QtUndoNotifier::indexChanged, this, &MainWindow::updateUndoRedoActions);
updateUndoRedoActions();
// 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(utils::qbytes(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);
QObject::connect(messages_widget, &MessagesWidget::titleChanged, messages_dock, &QDockWidget::setWindowTitle);
QObject::connect(messages_widget, &MessagesWidget::msgSelectionChanged, center_widget, &CenterWidget::setMessage);
// 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);
video_splitter->addWidget(charts_container);
video_splitter->setStretchFactor(1, 1);
video_splitter->restoreState(utils::qbytes(settings.video_splitter_state));
video_splitter->handle(1)->setEnabled(!can->liveStreaming());
video_dock->setWidget(video_splitter);
QObject::connect(charts_widget, &ChartsWidget::toggleChartsDocking, this, &MainWindow::toggleChartsDocking);
QObject::connect(charts_widget, &ChartsWidget::showTip, video_widget, &VideoWidget::showThumbnail);
}
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, this, []() {
if (can) can->pause(!can->isPaused());
});
// TODO: add more shortcuts here.
}
void MainWindow::undoStackCleanChanged(bool clean) {
setWindowModified(!clean);
}
void MainWindow::updateUndoRedoActions() {
auto stack = UndoStack::instance();
undo_act->setEnabled(stack->canUndo());
undo_act->setText(stack->canUndo() ? tr("&Undo %1").arg(QString::fromStdString(stack->undoText())) : tr("&Undo"));
redo_act->setEnabled(stack->canRedo());
redo_act->setText(stack->canRedo() ? tr("&Redo %1").arg(QString::fromStdString(stack->redoText())) : tr("&Redo"));
}
void MainWindow::DBCFileChanged() {
UndoStack::instance()->clear();
// Update file menu
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->setEnabled(dynamic_cast<DummyStream *>(can) == nullptr);
QStringList title;
for (auto f : dbc()->allDBCFiles()) {
title.push_back(tr("(%1) %2").arg(QString::fromStdString(toString(dbc()->sources(f))), QString::fromStdString(f->name())));
}
setWindowFilePath(title.join(" | "));
QTimer::singleShot(0, this, &::MainWindow::restoreSessionState);
}
void MainWindow::selectAndOpenStream() {
StreamSelector dlg(this);
if (dlg.exec()) {
openStream(dlg.stream(), dlg.dbcFile());
} else if (!can) {
openStream(new DummyStream(this));
}
}
void MainWindow::closeStream() {
openStream(new DummyStream(this));
if (dbc()->nonEmptyDBCCount() > 0) {
emit dbcNotifier()->DBCFileChanged();
}
statusBar()->showMessage(tr("stream closed"));
}
void MainWindow::exportToCSV() {
QString dir = QString("%1/%2.csv").arg(QString::fromStdString(settings.last_dir)).arg(QString::fromStdString(can->routeName()));
QString fn = QFileDialog::getSaveFileName(this, "Export stream to CSV file", dir, tr("csv (*.csv)"));
if (!fn.isEmpty()) {
utils::exportToCSV(fn.toStdString());
}
}
void MainWindow::newFile(SourceSet s) {
closeFile(s);
dbc()->open(s, std::string(""), std::string(""));
}
void MainWindow::openFile(SourceSet s) {
remindSaveChanges();
QString fn = QFileDialog::getOpenFileName(this, tr("Open File"), QString::fromStdString(settings.last_dir), "DBC (*.dbc)");
if (!fn.isEmpty()) {
loadFile(fn, s);
}
}
void MainWindow::loadFile(const QString &fn, SourceSet s) {
if (!fn.isEmpty()) {
closeFile(s);
std::string error;
if (dbc()->open(s, fn.toStdString(), &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(QString::fromStdString(error));
msg_box.exec();
}
}
}
void MainWindow::loadDBCFromOpendbc(const QString &name) {
loadFile(QString("%1/%2").arg(OPENDBC_FILE_PATH, name));
}
void MainWindow::loadFromClipboard(SourceSet s, bool close_all) {
std::string text;
if (!utils::getClipboardText(&text)) {
QMessageBox::warning(this, tr("Load From Clipboard"), tr("No clipboard tool found. Install xclip (X11) or wl-clipboard (Wayland)."));
return;
}
if (text.empty()) {
QMessageBox::warning(this, tr("Load From Clipboard"), tr("Clipboard is empty."));
return;
}
closeFile(s);
std::string error;
bool ret = dbc()->open(s, std::string(""), text, &error);
if (ret && dbc()->nonEmptyDBCCount() > 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(QString::fromStdString(error));
msg_box.exec();
}
}
void MainWindow::openStream(AbstractStream *stream, const QString &dbc_file) {
if (can) {
QObject::connect(can, &QObject::destroyed, this, [=]() { startStream(stream, dbc_file); });
can->deleteLater();
} else {
startStream(stream, dbc_file);
}
}
void MainWindow::startStream(AbstractStream *stream, QString dbc_file) {
center_widget->clear();
delete messages_widget;
delete video_splitter;
can = stream;
can->setParent(this); // take ownership
can->start();
loadFile(dbc_file);
statusBar()->showMessage(tr("Stream [%1] started").arg(QString::fromStdString(can->routeName())), 2000);
bool has_stream = dynamic_cast<DummyStream *>(can) == nullptr;
close_stream_act->setEnabled(has_stream);
export_to_csv_act->setEnabled(has_stream);
tools_menu->setEnabled(has_stream);
createDockWidgets();
video_dock->setWindowTitle(QString::fromStdString(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()->nonEmptyDBCCount()) {
newFile();
}
QObject::connect(can, &AbstractStream::eventsMerged, this, &MainWindow::eventsMerged);
if (has_stream) {
auto wait_dlg = new QProgressDialog(
can->liveStreaming() ? tr("Waiting for the live stream to start...") : tr("Loading segment data..."),
tr("&Abort"), 0, 100, this);
wait_dlg->setWindowModality(Qt::WindowModal);
wait_dlg->setFixedSize(400, wait_dlg->sizeHint().height());
QObject::connect(wait_dlg, &QProgressDialog::canceled, this, &MainWindow::close);
QObject::connect(can, &AbstractStream::eventsMerged, wait_dlg, &QProgressDialog::deleteLater);
QObject::connect(this, &MainWindow::updateProgressBar, wait_dlg, [=](uint64_t cur, uint64_t total, bool success) {
wait_dlg->setValue((int)((cur / (double)total) * 100));
});
}
}
void MainWindow::eventsMerged() {
if (!can->liveStreaming() && std::exchange(car_fingerprint, QString::fromStdString(can->carFingerprint())) != car_fingerprint) {
video_dock->setWindowTitle(tr("ROUTE: %1 FINGERPRINT: %2")
.arg(QString::fromStdString(can->routeName()))
.arg(car_fingerprint.isEmpty() ? tr("Unknown Car") : car_fingerprint));
// Don't overwrite already loaded DBC
auto it = fingerprint_to_dbc.find(car_fingerprint.toStdString());
if (!dbc()->nonEmptyDBCCount() && it != fingerprint_to_dbc.end()) {
QTimer::singleShot(0, this, [this, dbc_name = QString::fromStdString(it->second)]() {
loadDBCFromOpendbc(dbc_name + ".dbc");
});
}
}
}
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::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.empty()) {
dbc_file->save();
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(QString::fromStdString(toString(dbc()->sources(dbc_file))));
QString fn = QFileDialog::getSaveFileName(this, title, QString::fromStdString((std::filesystem::path(settings.last_dir) / "untitled.dbc").string()), tr("DBC (*.dbc)"));
if (!fn.isEmpty()) {
dbc_file->saveAs(fn.toStdString());
UndoStack::instance()->setClean();
statusBar()->showMessage(tr("File saved as %1").arg(fn), 2000);
updateRecentFiles(fn);
}
}
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);
if (utils::setClipboardText(dbc_file->generateDBC())) {
QMessageBox::information(this, tr("Copy To Clipboard"), tr("DBC Successfully copied!"));
} else {
QMessageBox::warning(this, tr("Copy To Clipboard"), tr("Failed to copy DBC to clipboard. Install xclip (X11) or wl-clipboard (Wayland)."));
}
}
void MainWindow::updateLoadSaveMenus() {
manage_dbcs_menu->clear();
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.
auto dbc_file = dbc()->findDBCFile(source);
if (dbc_file) {
bus_menu->addSeparator();
bus_menu->addAction(QString::fromStdString(dbc_file->name()) + " (" + QString::fromStdString(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); });
}
bus_menu->setTitle(tr("Bus %1 (%2)").arg(source).arg(dbc_file ? QString::fromStdString(dbc_file->name()) : "No DBCs loaded"));
manage_dbcs_menu->addMenu(bus_menu);
}
}
void MainWindow::updateRecentFiles(const QString &fn) {
const std::string filename = fn.toStdString();
settings.recent_files.erase(std::remove(settings.recent_files.begin(), settings.recent_files.end(), filename), settings.recent_files.end());
settings.recent_files.insert(settings.recent_files.begin(), filename);
while (settings.recent_files.size() > MAX_RECENT_FILES) {
settings.recent_files.pop_back();
}
settings.last_dir = std::filesystem::absolute(fn.toStdString()).parent_path().string();
}
void MainWindow::updateRecentFileMenu() {
open_recent_menu->clear();
int num_recent_files = std::min<int>(settings.recent_files.size(), MAX_RECENT_FILES);
if (!num_recent_files) {
open_recent_menu->addAction(tr("No Recent Files"))->setEnabled(false);
return;
}
for (int i = 0; i < num_recent_files; ++i) {
QString text = tr("&%1 %2").arg(i + 1).arg(QString::fromStdString(std::filesystem::path(settings.recent_files[i]).filename().string()));
open_recent_menu->addAction(text, this, [this, file = settings.recent_files[i]]() { loadFile(QString::fromStdString(file)); });
}
}
void MainWindow::remindSaveChanges() {
while (!UndoStack::instance()->isClean()) {
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) break;
save();
}
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));
}
bool MainWindow::eventFilter(QObject *obj, QEvent *event) {
if (obj == floating_window && event->type() == QEvent::Close) {
toggleChartsDocking();
return true;
}
return QMainWindow::eventFilter(obj, event);
}
void MainWindow::toggleChartsDocking() {
if (floating_window) {
// Dock the charts widget back to the main window
floating_window->removeEventFilter(this);
charts_layout->insertWidget(0, charts_widget, 1);
floating_window->deleteLater();
floating_window = nullptr;
charts_widget->setIsDocked(true);
} else {
// Float the charts widget in a separate window
floating_window = new QWidget(this, Qt::Window);
floating_window->setWindowTitle("Charts");
floating_window->setLayout(new QVBoxLayout());
floating_window->layout()->addWidget(charts_widget);
floating_window->installEventFilter(this);
floating_window->showMaximized();
charts_widget->setIsDocked(false);
}
}
void MainWindow::closeEvent(QCloseEvent *event) {
remindSaveChanges();
installDownloadProgressHandler(nullptr);
installMessageHandler(nullptr);
if (floating_window)
floating_window->deleteLater();
// save states
settings.geometry = utils::toBytes(saveGeometry());
settings.window_state = utils::toBytes(saveState());
if (can && !can->liveStreaming()) {
settings.video_splitter_state = utils::toBytes(video_splitter->saveState());
}
if (messages_widget) {
settings.message_header_state = messages_widget->saveHeaderState();
}
saveSessionState();
settings.save();
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();
}
}
void MainWindow::saveSessionState() {
settings.recent_dbc_file = "";
settings.active_msg_id = "";
settings.selected_msg_ids.clear();
settings.active_charts.clear();
for (auto &f : dbc()->allDBCFiles())
if (!f->isEmpty()) { settings.recent_dbc_file = f->filename; break; }
if (auto *detail = center_widget->getDetailWidget()) {
auto [active_id, ids] = detail->serializeMessageIds();
settings.active_msg_id = active_id.toStdString();
settings.selected_msg_ids.clear();
for (const auto &id : ids) settings.selected_msg_ids.push_back(id.toStdString());
}
if (charts_widget) {
settings.active_charts.clear();
for (const auto &id : charts_widget->serializeChartIds()) settings.active_charts.push_back(id.toStdString());
}
}
void MainWindow::restoreSessionState() {
if (settings.recent_dbc_file.empty() || dbc()->nonEmptyDBCCount() == 0) return;
QString dbc_file;
for (auto& f : dbc()->allDBCFiles())
if (!f->isEmpty()) { dbc_file = QString::fromStdString(f->filename); break; }
if (dbc_file.toStdString() != settings.recent_dbc_file) return;
if (!settings.selected_msg_ids.empty()) {
QStringList ids;
for (const auto &id : settings.selected_msg_ids) ids.push_back(QString::fromStdString(id));
center_widget->ensureDetailWidget()->restoreTabs(QString::fromStdString(settings.active_msg_id), ids);
}
if (charts_widget != nullptr && !settings.active_charts.empty()) {
QStringList ids;
for (const auto &id : settings.active_charts) ids.push_back(QString::fromStdString(id));
charts_widget->restoreChartsFromIds(ids);
}
}
// 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));
auto parent = 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();
}
+119
View File
@@ -0,0 +1,119 @@
#pragma once
#include <QDockWidget>
#include <QMainWindow>
#include <QMenu>
#include <QProgressBar>
#include <QSplitter>
#include <QStatusBar>
#include <cstdint>
#include <set>
#include <string>
#include <unordered_map>
#include <vector>
#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(AbstractStream *stream, const QString &dbc_file);
void toggleChartsDocking();
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 selectAndOpenStream();
void openStream(AbstractStream *stream, const QString &dbc_file = {});
void closeStream();
void exportToCSV();
void newFile(SourceSet s = SOURCE_ALL);
void openFile(SourceSet s = SOURCE_ALL);
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 startStream(AbstractStream *stream, QString dbc_file);
bool eventFilter(QObject *obj, QEvent *event) override;
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 updateRecentFiles(const QString &fn);
void updateRecentFileMenu();
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 updateUndoRedoActions();
void onlineHelp();
void toggleFullScreen();
void updateStatus();
void updateLoadSaveMenus();
void createDockWidgets();
void eventsMerged();
void saveSessionState();
void restoreSessionState();
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;
std::unordered_map<std::string, std::string> fingerprint_to_dbc;
QSplitter *video_splitter = nullptr;
enum { MAX_RECENT_FILES = 15 };
QMenu *open_recent_menu = nullptr;
QMenu *manage_dbcs_menu = nullptr;
QMenu *tools_menu = nullptr;
QAction *close_stream_act = nullptr;
QAction *export_to_csv_act = nullptr;
QAction *save_dbc = nullptr;
QAction *save_dbc_as = nullptr;
QAction *copy_dbc_to_clipboard = nullptr;
QAction *undo_act = nullptr;
QAction *redo_act = nullptr;
QString car_fingerprint;
std::vector<uint8_t> default_state;
};
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;
};
+465
View File
@@ -0,0 +1,465 @@
#include "tools/cabana/messageswidget.h"
#include "tools/cabana/dbc/dbcqt.h"
#include <limits>
#include <utility>
#include <QCheckBox>
#include <QHBoxLayout>
#include <QPainter>
#include <QPalette>
#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);
// 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);
// 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);
// 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(can, &AbstractStream::msgsReceived, model, &MessageListModel::msgsReceived);
QObject::connect(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, model, &MessageListModel::dbcModified);
QObject::connect(undoNotifier(), &QtUndoNotifier::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 &current, 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);
}
}
});
setWhatsThis(tr(R"(
<b>Message View</b><br/>
<!-- TODO: add description 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<br />
<span style="color:gray">Shortcuts</span><br />
Horizontal Scrolling: <span style="background-color:lightGray;color:gray">&nbsp;shift+wheel&nbsp;</span>
)"));
}
QWidget *MessagesWidget::createToolBar() {
QWidget *toolbar = new QWidget(this);
QHBoxLayout *layout = new QHBoxLayout(toolbar);
layout->setContentsMargins(0, 9, 0, 0);
layout->addWidget(suppress_add = new QPushButton("Suppress Highlighted"));
layout->addWidget(suppress_clear = new QPushButton());
suppress_clear->setToolTip(tr("Clear suppressed"));
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);
layout->addWidget(suppress_defined_signals);
auto view_button = new ToolButton("three-dots", tr("View..."));
view_button->setMenu(menu);
view_button->setPopupMode(QToolButton::InstantPopup);
view_button->setStyleSheet("QToolButton::menu-indicator { image: none; }");
layout->addWidget(view_button);
QObject::connect(suppress_add, &QPushButton::clicked, this, &MessagesWidget::suppressHighlighted);
QObject::connect(suppress_clear, &QPushButton::clicked, this, &MessagesWidget::suppressHighlighted);
QObject::connect(suppress_defined_signals, &QCheckBox::stateChanged, can, &AbstractStream::suppressDefinedSignals);
suppressHighlighted();
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;
});
emit titleChanged(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() {
int n = sender() == suppress_add ? can->suppressHighlighted() : (can->clearSuppressed(), 0);
suppress_clear->setText(n > 0 ? tr("Clear (%1)").arg(n) : tr("Clear"));
suppress_clear->setEnabled(n > 0);
}
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("Multi-Line bytes"), this, &MessagesWidget::setMultiLineBytes);
action->setCheckable(true);
action->setChecked(settings.multiple_lines_hex);
action = menu->addAction(tr("Show inactive messages"), model, &MessageListModel::showInactiveMessages);
action->setCheckable(true);
action->setChecked(model->show_inactive_messages);
}
void MessagesWidget::setMultiLineBytes(bool multi) {
settings.multiple_lines_hex = multi;
delegate->setMultipleLines(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 = [](float freq) {
if (freq > 0) {
return freq >= 0.95 ? QString::number(std::nearbyint(freq)) : QString::number(freq, 'f', 2);
} else {
return QStringLiteral("--");
}
};
const static QString NA = QStringLiteral("N/A");
const auto &item = items_[index.row()];
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) : NA;
case Column::ADDRESS: return toHexString(item.id.address);
case Column::NODE: return item.node;
case Column::FREQ: return item.id.source != INVALID_SOURCE ? getFreq(can->lastMessage(item.id).freq) : NA;
case Column::COUNT: return item.id.source != INVALID_SOURCE ? QString::number(can->lastMessage(item.id).count) : NA;
case Column::DATA: return item.id.source != INVALID_SOURCE ? "" : NA;
}
} else if (role == ColorsRole) {
return QVariant::fromValue((void*)(&can->lastMessage(item.id).colors));
} else if (role == BytesRole && index.column() == Column::DATA && item.id.source != INVALID_SOURCE) {
return QVariant::fromValue((void*)(&can->lastMessage(item.id).dat));
} else if (role == Qt::ToolTipRole && index.column() == Column::NAME) {
auto msg = dbc()->msg(item.id);
auto tooltip = item.name;
if (msg && !msg->comment.empty()) tooltip += "<br /><span style=\"color:gray;\">" + QString::fromStdString(msg->comment) + "</span>";
return tooltip;
}
return {};
}
void MessageListModel::setFilterStrings(const std::map<int, QString> &filters) {
filters_ = filters;
filterAndSort();
}
void MessageListModel::showInactiveMessages(bool show) {
show_inactive_messages = show;
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 compare = [this](const auto &l, const auto &r) {
switch (sort_column) {
case Column::NAME: return std::tie(l.name, l.id) < std::tie(r.name, r.id);
case Column::SOURCE: return std::tie(l.id.source, l.id.address) < std::tie(r.id.source, r.id.address);
case Column::ADDRESS: return std::tie(l.id.address, l.id.source) < std::tie(r.id.address, r.id.source);
case Column::NODE: return std::tie(l.node, l.id) < std::tie(r.node, r.id);
case Column::FREQ: return std::tie(can->lastMessage(l.id).freq, l.id) < std::tie(can->lastMessage(r.id).freq, r.id);
case Column::COUNT: return std::tie(can->lastMessage(l.id).count, l.id) < std::tie(can->lastMessage(r.id).count, r.id);
default: return false; // Default case to suppress compiler warning
}
};
if (sort_order == Qt::DescendingOrder)
std::stable_sort(items.rbegin(), items.rend(), compare);
else
std::stable_sort(items.begin(), items.end(), compare);
}
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_.empty())
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->second;
switch (it->first) {
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 QString::fromStdString(s->name).contains(txt, Qt::CaseInsensitive); });
}
break;
}
case Column::SOURCE:
match = parseRange(txt, item.id.source);
break;
case Column::ADDRESS:
match = toHexString(item.id.address).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:
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;
}
bool 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;
items.reserve(all_messages.size());
for (const auto &id : all_messages) {
if (show_inactive_messages || can->isMessageActive(id)) {
auto msg = dbc()->msg(id);
Item item = {.id = id,
.name = msg ? QString::fromStdString(msg->name) : QString::fromStdString(UNTITLED),
.node = msg ? QString::fromStdString(msg->transmitter) : QString()};
if (match(item))
items.emplace_back(item);
}
}
sortItems(items);
if (items_ != items) {
beginResetModel();
items_ = std::move(items);
endResetModel();
return true;
}
return false;
}
void MessageListModel::msgsReceived(const std::set<MessageId> *new_msgs, bool has_new_ids) {
if (has_new_ids || ((filters_.count(Column::FREQ) || filters_.count(Column::COUNT) || filters_.count(Column::DATA)) &&
++sort_threshold_ == settings.fps)) {
sort_threshold_ = 0;
if (filterAndSort()) return;
}
// Update viewport
emit dataChanged(index(0, 0), index(rowCount() - 1, columnCount() - 1));
}
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 {
const auto &item = ((MessageListModel*)model())->items_[index.row()];
if (!can->isMessageActive(item.id)) {
QStyleOptionViewItem custom_option = option;
custom_option.palette.setBrush(QPalette::Text, custom_option.palette.color(QPalette::Disabled, QPalette::Text));
auto color = QApplication::palette().color(QPalette::HighlightedText);
color.setAlpha(100);
custom_option.palette.setBrush(QPalette::HighlightedText, color);
QTreeView::drawRow(painter, custom_option, index);
} else {
QTreeView::drawRow(painter, option, index);
}
QPen oldPen = painter->pen();
const int gridHint = style()->styleHint(QStyle::SH_Table_GridLineColor, &option, this);
painter->setPen(QColor::fromRgba(static_cast<QRgb>(gridHint)));
// Draw bottom border for the row
painter->drawLine(option.rect.bottomLeft(), option.rect.bottomRight());
// Draw vertical borders for each column
for (int i = 0; i < header()->count(); ++i) {
int sectionX = header()->sectionViewportPosition(i);
painter->drawLine(sectionX, option.rect.top(), sectionX, option.rect.bottom());
}
painter->setPen(oldPen);
}
void MessageView::setModel(QAbstractItemModel *model) {
QTreeView::setModel(model);
// Bypass the slow call to QTreeView::dataChanged.
// QTreeView::dataChanged will invalidate the height cache and that's what we don't need in MessageView.
QObject::disconnect(model, &QAbstractItemModel::dataChanged, this, nullptr);
QObject::connect(model, &QAbstractItemModel::dataChanged, this,
[this](const QModelIndex &tl, const QModelIndex &br, const auto &roles) { QAbstractItemView::dataChanged(tl, br, 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());
}
}
setUniformRowHeights(!delegate->multipleLines());
header()->resizeSection(MessageListModel::Column::DATA, delegate->sizeForBytes(max_bytes).width());
}
void MessageView::wheelEvent(QWheelEvent *event) {
if (event->modifiers() == Qt::ShiftModifier) {
QApplication::sendEvent(horizontalScrollBar(), event);
} else {
QTreeView::wheelEvent(event);
}
}
// 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() {
std::map<int, QString> filters;
for (int i = 0; i < (int)editors.size(); i++) {
if (!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 < (int)editors.size(); 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 = (int)editors.size(); i < count(); i++) {
QString column_name = model()->headerData(i, Qt::Horizontal, Qt::DisplayRole).toString();
auto edit = new QLineEdit(this);
edit->setClearButtonEnabled(true);
edit->setPlaceholderText(tr("Filter %1").arg(column_name));
QObject::connect(edit, &QLineEdit::textChanged, this, &MessageViewHeader::updateFilters);
editors.push_back(edit);
}
setViewportMargins(0, 0, 0, !editors.empty() ? editors[0]->sizeHint().height() : 0);
QHeaderView::updateGeometries();
updateHeaderPositions();
}
QSize MessageViewHeader::sizeHint() const {
QSize sz = QHeaderView::sizeHint();
return !editors.empty() ? QSize(sz.width(), sz.height() + editors[0]->height() + 1) : sz;
}
+128
View File
@@ -0,0 +1,128 @@
#pragma once
#include <algorithm>
#include <cstdint>
#include <map>
#include <optional>
#include <set>
#include <vector>
#include <QAbstractTableModel>
#include <QHeaderView>
#include <QLineEdit>
#include <QMenu>
#include <QTreeView>
#include <QWheelEvent>
#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 std::map<int, QString> &filters);
void showInactiveMessages(bool show);
void msgsReceived(const std::set<MessageId> *new_msgs, bool has_new_ids);
bool 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_;
bool show_inactive_messages = true;
private:
void sortItems(std::vector<MessageListModel::Item> &items);
bool match(const MessageListModel::Item &id);
std::map<int, QString> filters_;
std::set<MessageId> dbc_messages_;
int sort_column = 0;
Qt::SortOrder sort_order = Qt::AscendingOrder;
int sort_threshold_ = 0;
};
class MessageView : public QTreeView {
Q_OBJECT
public:
MessageView(QWidget *parent) : QTreeView(parent) {}
void updateBytesSectionSize();
void setModel(QAbstractItemModel *model) override;
protected:
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 wheelEvent(QWheelEvent *event) override;
};
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();
std::vector<QLineEdit *> editors;
};
class MessagesWidget : public QWidget {
Q_OBJECT
public:
MessagesWidget(QWidget *parent);
void selectMessage(const MessageId &message_id);
std::vector<uint8_t> saveHeaderState() const {
const auto state = view->header()->saveState();
return {state.begin(), state.end()};
}
bool restoreHeaderState(const std::vector<uint8_t> &state) const {
return view->header()->restoreState({(const char *)state.data(), (int)state.size()});
}
void suppressHighlighted();
signals:
void msgSelectionChanged(const MessageId &message_id);
void titleChanged(const QString &title);
protected:
QWidget *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;
QMenu *menu;
};
+346
View File
@@ -0,0 +1,346 @@
#include "tools/cabana/panda.h"
#include <unistd.h>
#include <cassert>
#include <stdexcept>
#include <vector>
#include <memory>
#include "openpilot/cereal/messaging/messaging.h"
#include "common/swaglog.h"
#include "common/util.h"
static libusb_context *init_usb_ctx() {
libusb_context *context = nullptr;
int err = libusb_init(&context);
if (err != 0) {
LOGE("libusb initialization error");
return nullptr;
}
#if LIBUSB_API_VERSION >= 0x01000106
libusb_set_option(context, LIBUSB_OPTION_LOG_LEVEL, LIBUSB_LOG_LEVEL_INFO);
#else
libusb_set_debug(context, 3);
#endif
return context;
}
Panda::Panda(std::string serial, uint32_t bus_offset) : bus_offset(bus_offset) {
if (!init_usb_connection(serial)) {
throw std::runtime_error("Error connecting to panda");
}
LOGW("connected to %s over USB", serial.c_str());
hw_type = get_hw_type();
can_reset_communications();
}
Panda::~Panda() {
cleanup_usb();
}
bool Panda::connected() {
return connected_flag;
}
bool Panda::comms_healthy() {
return comms_healthy_flag;
}
std::string Panda::hw_serial() {
return hw_serial_str;
}
std::vector<std::string> Panda::list(bool usb_only) {
static std::unique_ptr<libusb_context, decltype(&libusb_exit)> context(init_usb_ctx(), libusb_exit);
ssize_t num_devices;
libusb_device **dev_list = NULL;
std::vector<std::string> serials;
if (!context) { return serials; }
num_devices = libusb_get_device_list(context.get(), &dev_list);
if (num_devices < 0) {
LOGE("libusb can't get device list");
goto finish;
}
for (size_t i = 0; i < num_devices; ++i) {
libusb_device *device = dev_list[i];
libusb_device_descriptor desc;
libusb_get_device_descriptor(device, &desc);
if (desc.idVendor == 0x3801 && desc.idProduct == 0xddcc) {
libusb_device_handle *handle = NULL;
int ret = libusb_open(device, &handle);
if (ret < 0) { goto finish; }
unsigned char desc_serial[26] = { 0 };
ret = libusb_get_string_descriptor_ascii(handle, desc.iSerialNumber, desc_serial, std::size(desc_serial));
libusb_close(handle);
if (ret < 0) { goto finish; }
serials.push_back(std::string((char *)desc_serial, ret));
}
}
finish:
if (dev_list != NULL) {
libusb_free_device_list(dev_list, 1);
}
return serials;
}
void Panda::set_safety_model(cereal::CarParams::SafetyModel safety_model, uint16_t safety_param) {
control_write(0xdc, (uint16_t)safety_model, safety_param);
}
cereal::PandaState::PandaType Panda::get_hw_type() {
unsigned char hw_query[1] = {0};
control_read(0xc1, 0, 0, hw_query, 1);
return (cereal::PandaState::PandaType)(hw_query[0]);
}
void Panda::send_heartbeat(bool engaged, bool engaged_mads) {
control_write(0xf3, engaged, engaged_mads);
}
void Panda::set_can_speed_kbps(uint16_t bus, uint16_t speed) {
control_write(0xde, bus, (speed * 10));
}
void Panda::set_data_speed_kbps(uint16_t bus, uint16_t speed) {
control_write(0xf9, bus, (speed * 10));
}
bool Panda::can_receive(std::vector<can_frame>& out_vec) {
// Check if enough space left in buffer to store RECV_SIZE data
assert(receive_buffer_size + RECV_SIZE <= sizeof(receive_buffer));
int recv = bulk_read(0x81, &receive_buffer[receive_buffer_size], RECV_SIZE);
if (!comms_healthy()) {
return false;
}
bool ret = true;
if (recv > 0) {
receive_buffer_size += recv;
ret = unpack_can_buffer(receive_buffer, receive_buffer_size, out_vec);
}
return ret;
}
void Panda::can_reset_communications() {
control_write(0xc0, 0, 0);
}
bool Panda::unpack_can_buffer(uint8_t *data, uint32_t &size, std::vector<can_frame> &out_vec) {
int pos = 0;
while (pos <= size - sizeof(can_header)) {
can_header header;
memcpy(&header, &data[pos], sizeof(can_header));
const uint8_t data_len = dlc_to_len[header.data_len_code];
if (pos + sizeof(can_header) + data_len > size) {
// we don't have all the data for this message yet
break;
}
if (calculate_checksum(&data[pos], sizeof(can_header) + data_len) != 0) {
LOGE("Panda CAN checksum failed");
size = 0;
can_reset_communications();
return false;
}
can_frame &canData = out_vec.emplace_back();
canData.address = header.addr;
canData.src = header.bus + bus_offset;
if (header.rejected) {
canData.src += CAN_REJECTED_BUS_OFFSET;
}
if (header.returned) {
canData.src += CAN_RETURNED_BUS_OFFSET;
}
canData.dat.assign((char *)&data[pos + sizeof(can_header)], data_len);
pos += sizeof(can_header) + data_len;
}
// move the overflowing data to the beginning of the buffer for the next round
memmove(data, &data[pos], size - pos);
size -= pos;
return true;
}
uint8_t Panda::calculate_checksum(uint8_t *data, uint32_t len) {
uint8_t checksum = 0U;
for (uint32_t i = 0U; i < len; i++) {
checksum ^= data[i];
}
return checksum;
}
// USB implementation methods
bool Panda::init_usb_connection(const std::string& serial) {
ssize_t num_devices;
libusb_device **dev_list = NULL;
int err = 0;
ctx = init_usb_ctx();
if (!ctx) { goto fail; }
// connect by serial
num_devices = libusb_get_device_list(ctx, &dev_list);
if (num_devices < 0) { goto fail; }
for (size_t i = 0; i < num_devices; ++i) {
libusb_device_descriptor desc;
libusb_get_device_descriptor(dev_list[i], &desc);
if (desc.idVendor == 0x3801 && desc.idProduct == 0xddcc) {
int ret = libusb_open(dev_list[i], &dev_handle);
if (dev_handle == NULL || ret < 0) { goto fail; }
unsigned char desc_serial[26] = { 0 };
ret = libusb_get_string_descriptor_ascii(dev_handle, desc.iSerialNumber, desc_serial, std::size(desc_serial));
if (ret < 0) { goto fail; }
hw_serial_str = std::string((char *)desc_serial, ret);
if (serial.empty() || serial == hw_serial_str) {
break;
}
libusb_close(dev_handle);
dev_handle = NULL;
}
}
if (dev_handle == NULL) goto fail;
libusb_free_device_list(dev_list, 1);
if (libusb_kernel_driver_active(dev_handle, 0) == 1) {
libusb_detach_kernel_driver(dev_handle, 0);
}
err = libusb_set_configuration(dev_handle, 1);
if (err != 0) { goto fail; }
err = libusb_claim_interface(dev_handle, 0);
if (err != 0) { goto fail; }
return true;
fail:
if (dev_list != NULL) {
libusb_free_device_list(dev_list, 1);
}
cleanup_usb();
return false;
}
void Panda::cleanup_usb() {
if (dev_handle) {
libusb_release_interface(dev_handle, 0);
libusb_close(dev_handle);
dev_handle = nullptr;
}
if (ctx) {
libusb_exit(ctx);
ctx = nullptr;
}
connected_flag = false;
}
void Panda::handle_usb_issue(int err, const char func[]) {
LOGE_100("usb error %d \"%s\" in %s", err, libusb_strerror((enum libusb_error)err), func);
if (err == LIBUSB_ERROR_NO_DEVICE) {
LOGE("lost connection");
connected_flag = false;
}
}
int Panda::control_write(uint8_t bRequest, uint16_t wValue, uint16_t wIndex, unsigned int timeout) {
int err;
const uint8_t bmRequestType = LIBUSB_ENDPOINT_OUT | LIBUSB_REQUEST_TYPE_VENDOR | LIBUSB_RECIPIENT_DEVICE;
if (!connected_flag) {
return LIBUSB_ERROR_NO_DEVICE;
}
do {
err = libusb_control_transfer(dev_handle, bmRequestType, bRequest, wValue, wIndex, NULL, 0, timeout);
if (err < 0) handle_usb_issue(err, __func__);
} while (err < 0 && connected_flag);
return err;
}
int Panda::control_read(uint8_t bRequest, uint16_t wValue, uint16_t wIndex, unsigned char *data, uint16_t wLength, unsigned int timeout) {
int err;
const uint8_t bmRequestType = LIBUSB_ENDPOINT_IN | LIBUSB_REQUEST_TYPE_VENDOR | LIBUSB_RECIPIENT_DEVICE;
if (!connected_flag) {
return LIBUSB_ERROR_NO_DEVICE;
}
do {
err = libusb_control_transfer(dev_handle, bmRequestType, bRequest, wValue, wIndex, data, wLength, timeout);
if (err < 0) handle_usb_issue(err, __func__);
} while (err < 0 && connected_flag);
return err;
}
int Panda::bulk_write(unsigned char endpoint, unsigned char* data, int length, unsigned int timeout) {
int err;
int transferred = 0;
if (!connected_flag) {
return 0;
}
do {
err = libusb_bulk_transfer(dev_handle, endpoint, data, length, &transferred, timeout);
if (err == LIBUSB_ERROR_TIMEOUT) {
LOGW("Transmit buffer full");
break;
} else if (err != 0 || length != transferred) {
handle_usb_issue(err, __func__);
}
} while (err != 0 && connected_flag);
return transferred;
}
int Panda::bulk_read(unsigned char endpoint, unsigned char* data, int length, unsigned int timeout) {
int err;
int transferred = 0;
if (!connected_flag) {
return 0;
}
do {
err = libusb_bulk_transfer(dev_handle, endpoint, data, length, &transferred, timeout);
if (err == LIBUSB_ERROR_TIMEOUT) {
break; // timeout is okay to exit, recv still happened
} else if (err == LIBUSB_ERROR_OVERFLOW) {
comms_healthy_flag = false;
LOGE_100("overflow got 0x%x", transferred);
} else if (err != 0) {
handle_usb_issue(err, __func__);
}
} while (err != 0 && connected_flag);
return transferred;
}
+95
View File
@@ -0,0 +1,95 @@
#pragma once
#include <cstdint>
#include <ctime>
#include <functional>
#include <list>
#include <memory>
#include <optional>
#include <string>
#include <vector>
#include <atomic>
#include <mutex>
#include <libusb-1.0/libusb.h>
#include "openpilot/cereal/gen/cpp/car.capnp.h"
#include "openpilot/cereal/gen/cpp/log.capnp.h"
#include "panda/board/health.h"
#include "panda/board/can.h"
#define USB_TX_SOFT_LIMIT (0x100U)
#define USBPACKET_MAX_SIZE (0x40)
#define RECV_SIZE (0x4000U)
#define TIMEOUT 0
#define CAN_REJECTED_BUS_OFFSET 0xC0U
#define CAN_RETURNED_BUS_OFFSET 0x80U
#define PANDA_BUS_OFFSET 4
struct __attribute__((packed)) can_header {
uint8_t reserved : 1;
uint8_t bus : 3;
uint8_t data_len_code : 4;
uint8_t rejected : 1;
uint8_t returned : 1;
uint8_t extended : 1;
uint32_t addr : 29;
uint8_t checksum : 8;
};
struct can_frame {
long address;
std::string dat;
long src;
};
class Panda {
public:
Panda(std::string serial="", uint32_t bus_offset=0);
~Panda();
cereal::PandaState::PandaType hw_type = cereal::PandaState::PandaType::UNKNOWN;
const uint32_t bus_offset;
bool connected();
bool comms_healthy();
std::string hw_serial();
// Static functions
static std::vector<std::string> list(bool usb_only=false);
// Panda functionality
cereal::PandaState::PandaType get_hw_type();
void set_safety_model(cereal::CarParams::SafetyModel safety_model, uint16_t safety_param=0U);
void send_heartbeat(bool engaged, bool engaged_mads = false);
void set_can_speed_kbps(uint16_t bus, uint16_t speed);
void set_data_speed_kbps(uint16_t bus, uint16_t speed);
bool can_receive(std::vector<can_frame>& out_vec);
void can_reset_communications();
private:
// USB connection members
libusb_context *ctx = nullptr;
libusb_device_handle *dev_handle = nullptr;
std::string hw_serial_str;
std::atomic<bool> connected_flag = true;
std::atomic<bool> comms_healthy_flag = true;
// CAN buffer members
uint8_t receive_buffer[RECV_SIZE + sizeof(can_header) + 64];
uint32_t receive_buffer_size = 0;
// Internal methods
bool init_usb_connection(const std::string& serial);
void cleanup_usb();
void handle_usb_issue(int err, const char func[]);
int control_write(uint8_t request, uint16_t param1, uint16_t param2, unsigned int timeout=TIMEOUT);
int control_read(uint8_t request, uint16_t param1, uint16_t param2, unsigned char *data, uint16_t length, unsigned int timeout=TIMEOUT);
int bulk_write(unsigned char endpoint, unsigned char* data, int length, unsigned int timeout=TIMEOUT);
int bulk_read(unsigned char endpoint, unsigned char* data, int length, unsigned int timeout=TIMEOUT);
bool unpack_can_buffer(uint8_t *data, uint32_t &size, std::vector<can_frame> &out_vec);
uint8_t calculate_checksum(uint8_t *data, uint32_t len);
};
+618
View File
@@ -0,0 +1,618 @@
#include "tools/cabana/settings.h"
#include <algorithm>
#include <cerrno>
#include <cctype>
#include <charconv>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <iterator>
#include <map>
#include <string_view>
#include <system_error>
#include <utility>
#include <vector>
#include <fcntl.h>
#include <sys/file.h>
#include <unistd.h>
#ifdef __APPLE__
#include <CoreFoundation/CoreFoundation.h>
#endif
#include <QAbstractButton>
#include <QDialogButtonBox>
#include <QFileDialog>
#include <QFormLayout>
#include <QPushButton>
#include <type_traits>
#include "json11/json11.hpp"
#include "tools/cabana/utils/util.h"
const int MIN_CACHE_MINIUTES = 30;
const int MAX_CACHE_MINIUTES = 120;
Settings settings;
namespace {
std::filesystem::path settingsFile() {
return utils::configPath() / "cabana.json";
}
struct LoadedSettings {
json11::Json::object values;
bool exists = false;
bool valid = true;
};
class FileLock {
public:
explicit FileLock(const std::filesystem::path &path) {
fd = open(path.c_str(), O_CREAT | O_CLOEXEC, 0600);
if (fd < 0 || flock(fd, LOCK_EX) < 0) {
fprintf(stderr, "failed to lock Cabana settings %s: %s\n", path.c_str(), strerror(errno));
if (fd >= 0) close(fd);
fd = -1;
}
}
~FileLock() {
if (fd >= 0) close(fd);
}
bool isLocked() const { return fd >= 0; }
private:
int fd = -1;
};
LoadedSettings loadSettings() {
std::ifstream input(settingsFile());
if (!input) return {};
const std::string contents{std::istreambuf_iterator<char>(input), std::istreambuf_iterator<char>()};
std::string error;
auto settings_json = json11::Json::parse(contents, error);
if (!error.empty() || !settings_json.is_object()) {
fprintf(stderr, "failed to read Cabana settings %s%s%s\n", settingsFile().c_str(), error.empty() ? "" : ": ", error.c_str());
return {.exists = true, .valid = false};
}
return {.values = settings_json.object_items(), .exists = true};
}
bool ensureSettingsDirectory() {
const auto path = settingsFile();
std::error_code error;
std::filesystem::create_directories(path.parent_path(), error);
if (error) {
fprintf(stderr, "failed to create Cabana settings directory %s: %s\n", path.parent_path().c_str(), error.message().c_str());
return false;
}
return true;
}
bool writeAll(int fd, const std::string &data) {
size_t written = 0;
while (written < data.size()) {
ssize_t result = write(fd, data.data() + written, data.size() - written);
if (result < 0 && errno == EINTR) continue;
if (result <= 0) return false;
written += result;
}
return true;
}
bool saveSettings(const json11::Json::object &settings_json) {
const auto path = settingsFile();
const std::string contents = json11::Json(settings_json).dump();
std::string temporary_path = path.string() + ".tmp.XXXXXX";
int fd = mkstemp(temporary_path.data());
if (fd < 0) {
fprintf(stderr, "failed to create temporary Cabana settings %s: %s\n", temporary_path.c_str(), strerror(errno));
return false;
}
bool success = writeAll(fd, contents) && fsync(fd) == 0;
if (close(fd) < 0) success = false;
if (success && rename(temporary_path.c_str(), path.c_str()) < 0) success = false;
if (success) {
int dir_fd = open(path.parent_path().c_str(), O_RDONLY | O_CLOEXEC);
success = dir_fd >= 0 && fsync(dir_fd) == 0;
if (dir_fd >= 0 && close(dir_fd) < 0) success = false;
}
if (!success) {
const int saved_errno = errno;
unlink(temporary_path.c_str());
fprintf(stderr, "failed to save Cabana settings to %s: %s\n", path.c_str(), strerror(saved_errno));
}
return success;
}
bool preserveCorruptSettings() {
const auto path = settingsFile();
auto backup = path;
backup += ".corrupt";
for (int i = 1; std::filesystem::exists(backup); ++i) {
backup = path;
backup += ".corrupt." + std::to_string(i);
}
if (rename(path.c_str(), backup.c_str()) < 0) {
fprintf(stderr, "failed to preserve corrupt Cabana settings %s: %s\n", path.c_str(), strerror(errno));
return false;
}
fprintf(stderr, "preserved corrupt Cabana settings at %s\n", backup.c_str());
return true;
}
// TODO: Remove the legacy QSettings migration after users have had time to migrate to cabana.json.
struct LegacyValue {
std::vector<std::string> strings;
std::string bytes;
bool is_byte_array = false;
};
using LegacySettings = std::map<std::string, LegacyValue>;
int hexDigit(char c) {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
return -1;
}
#ifndef __APPLE__
void appendUtf8(std::string &result, uint32_t codepoint) {
if (codepoint <= 0x7f) {
result.push_back(codepoint);
} else if (codepoint <= 0x7ff) {
result.push_back(0xc0 | (codepoint >> 6));
result.push_back(0x80 | (codepoint & 0x3f));
} else if (codepoint <= 0xffff) {
result.push_back(0xe0 | (codepoint >> 12));
result.push_back(0x80 | ((codepoint >> 6) & 0x3f));
result.push_back(0x80 | (codepoint & 0x3f));
} else {
result.push_back(0xf0 | (codepoint >> 18));
result.push_back(0x80 | ((codepoint >> 12) & 0x3f));
result.push_back(0x80 | ((codepoint >> 6) & 0x3f));
result.push_back(0x80 | (codepoint & 0x3f));
}
}
LegacyValue decodeIniValue(std::string_view encoded) {
std::vector<std::vector<uint32_t>> decoded(1);
std::vector<bool> quoted(1, false);
bool in_quotes = false;
for (size_t i = 0; i < encoded.size();) {
char c = encoded[i++];
if (c == '"') {
in_quotes = !in_quotes;
quoted.back() = true;
} else if (c == ',' && !in_quotes) {
decoded.emplace_back();
quoted.push_back(false);
while (i < encoded.size() && (encoded[i] == ' ' || encoded[i] == '\t')) ++i;
} else if (c == '\\' && i < encoded.size()) {
c = encoded[i++];
static const std::map<char, char> escapes = {
{'a', '\a'}, {'b', '\b'}, {'f', '\f'}, {'n', '\n'}, {'r', '\r'}, {'t', '\t'},
{'v', '\v'}, {'"', '"'}, {'?', '?'}, {'\'', '\''}, {'\\', '\\'},
};
if (auto it = escapes.find(c); it != escapes.end()) {
decoded.back().push_back(static_cast<unsigned char>(it->second));
} else if (c == 'x' && i < encoded.size() && hexDigit(encoded[i]) >= 0) {
uint32_t value = 0;
while (i < encoded.size() && hexDigit(encoded[i]) >= 0) value = (value << 4) + hexDigit(encoded[i++]);
decoded.back().push_back(value & 0xffff);
} else if (c >= '0' && c <= '7') {
uint32_t value = c - '0';
while (i < encoded.size() && encoded[i] >= '0' && encoded[i] <= '7') value = (value << 3) + (encoded[i++] - '0');
decoded.back().push_back(value & 0xffff);
}
} else {
decoded.back().push_back(static_cast<unsigned char>(c));
}
}
LegacyValue result;
for (size_t i = 0; i < decoded.size(); ++i) {
auto &value = decoded[i];
if (!quoted[i]) {
while (!value.empty() && (value.front() == ' ' || value.front() == '\t')) value.erase(value.begin());
while (!value.empty() && (value.back() == ' ' || value.back() == '\t')) value.pop_back();
}
std::string string_value;
for (size_t j = 0; j < value.size(); ++j) {
uint32_t codepoint = value[j];
if (codepoint >= 0xd800 && codepoint <= 0xdbff && j + 1 < value.size() && value[j + 1] >= 0xdc00 && value[j + 1] <= 0xdfff) {
codepoint = 0x10000 + ((codepoint - 0xd800) << 10) + (value[++j] - 0xdc00);
}
appendUtf8(string_value, codepoint);
}
result.strings.push_back(std::move(string_value));
}
if (result.strings.size() == 1 && result.strings[0] == "@Invalid()") {
result.strings.clear();
} else if (decoded.size() == 1) {
static constexpr std::string_view prefix = "@ByteArray(";
const auto &value = decoded[0];
if (value.size() >= prefix.size() + 1 && std::equal(prefix.begin(), prefix.end(), value.begin()) && value.back() == ')') {
result.is_byte_array = true;
result.bytes.reserve(value.size() - prefix.size() - 1);
for (size_t i = prefix.size(); i + 1 < value.size(); ++i) result.bytes.push_back(value[i] & 0xff);
}
}
if (!result.is_byte_array) {
for (auto &value : result.strings) {
if (value.compare(0, 2, "@@") == 0) value.erase(0, 1);
}
}
return result;
}
LegacySettings loadLegacySettings() {
auto path = settingsFile();
path.replace_filename("cabana.conf");
std::ifstream input(path);
if (!input) return {};
LegacySettings settings;
bool in_general_section = false;
std::string line;
while (std::getline(input, line)) {
if (!line.empty() && line.back() == '\r') line.pop_back();
if (line == "[General]") {
in_general_section = true;
continue;
}
if (!line.empty() && line.front() == '[') {
in_general_section = false;
continue;
}
if (!in_general_section || line.empty() || line.front() == ';') continue;
if (auto separator = line.find('='); separator != std::string::npos) {
settings[line.substr(0, separator)] = decodeIniValue(std::string_view(line).substr(separator + 1));
}
}
return settings;
}
#else
std::string cfStringToUtf8(CFStringRef value) {
CFIndex length = CFStringGetLength(value);
CFIndex size = CFStringGetMaximumSizeForEncoding(length, kCFStringEncodingUTF8) + 1;
std::string result(size, '\0');
if (!CFStringGetCString(value, result.data(), size, kCFStringEncodingUTF8)) return {};
result.resize(strlen(result.c_str()));
return result;
}
LegacyValue cfStringValue(CFStringRef string) {
LegacyValue value;
if (CFStringHasPrefix(string, CFSTR("@ByteArray(")) && CFStringHasSuffix(string, CFSTR(")"))) {
CFRange range{11, CFStringGetLength(string) - 12};
std::vector<UniChar> data(range.length);
CFStringGetCharacters(string, range, data.data());
value.is_byte_array = true;
value.bytes.reserve(data.size());
for (UniChar c : data) value.bytes.push_back(c & 0xff);
} else {
std::string string_value = cfStringToUtf8(string);
if (string_value.compare(0, 2, "@@") == 0) string_value.erase(0, 1);
value.strings.push_back(std::move(string_value));
}
return value;
}
LegacySettings loadLegacySettings() {
LegacySettings settings;
CFDictionaryRef values = CFPreferencesCopyMultiple(nullptr, CFSTR("com.cabana"),
kCFPreferencesCurrentUser, kCFPreferencesAnyHost);
if (values == nullptr) return settings;
CFIndex count = CFDictionaryGetCount(values);
std::vector<const void *> keys(count);
std::vector<const void *> objects(count);
CFDictionaryGetKeysAndValues(values, keys.data(), objects.data());
for (CFIndex i = 0; i < count; ++i) {
if (CFGetTypeID(keys[i]) != CFStringGetTypeID()) continue;
std::string key = cfStringToUtf8(static_cast<CFStringRef>(keys[i]));
CFTypeRef object = objects[i];
LegacyValue value;
if (CFGetTypeID(object) == CFBooleanGetTypeID()) {
value.strings.push_back(CFBooleanGetValue(static_cast<CFBooleanRef>(object)) ? "true" : "false");
} else if (CFGetTypeID(object) == CFNumberGetTypeID()) {
int number = 0;
if (CFNumberGetValue(static_cast<CFNumberRef>(object), kCFNumberIntType, &number)) value.strings.push_back(std::to_string(number));
} else if (CFGetTypeID(object) == CFStringGetTypeID()) {
value = cfStringValue(static_cast<CFStringRef>(object));
} else if (CFGetTypeID(object) == CFDataGetTypeID()) {
auto data = static_cast<CFDataRef>(object);
value.is_byte_array = true;
value.bytes.assign(reinterpret_cast<const char *>(CFDataGetBytePtr(data)), CFDataGetLength(data));
} else if (CFGetTypeID(object) == CFArrayGetTypeID()) {
auto array = static_cast<CFArrayRef>(object);
for (CFIndex j = 0; j < CFArrayGetCount(array); ++j) {
CFTypeRef item = CFArrayGetValueAtIndex(array, j);
if (CFGetTypeID(item) != CFStringGetTypeID()) {
value.strings.clear();
break;
}
auto item_value = cfStringValue(static_cast<CFStringRef>(item));
if (item_value.strings.size() != 1) {
value.strings.clear();
break;
}
value.strings.push_back(std::move(item_value.strings[0]));
}
}
if (!value.strings.empty() || value.is_byte_array || CFGetTypeID(object) == CFArrayGetTypeID()) {
settings.emplace(std::move(key), std::move(value));
}
}
CFRelease(values);
return settings;
}
#endif
template <typename T>
void readLegacySetting(const LegacySettings &legacy_settings, const char *key, T &value) {
auto it = legacy_settings.find(key);
if (it == legacy_settings.end() || it->second.strings.size() != 1) return;
const auto &stored = it->second.strings[0];
if constexpr (std::is_same_v<T, bool>) {
if (stored == "true") value = true;
if (stored == "false") value = false;
} else if constexpr (std::is_integral_v<T> || std::is_enum_v<T>) {
int number = 0;
auto [end, error] = std::from_chars(stored.data(), stored.data() + stored.size(), number);
if (error == std::errc{} && end == stored.data() + stored.size()) value = static_cast<T>(number);
}
}
void readLegacySetting(const LegacySettings &legacy_settings, const char *key, std::string &value) {
auto it = legacy_settings.find(key);
if (it != legacy_settings.end() && it->second.strings.size() == 1) value = it->second.strings[0];
}
void readLegacySetting(const LegacySettings &legacy_settings, const char *key, std::vector<std::string> &value) {
auto it = legacy_settings.find(key);
if (it != legacy_settings.end() && !it->second.is_byte_array) value = it->second.strings;
}
void readLegacySetting(const LegacySettings &legacy_settings, const char *key, std::vector<uint8_t> &value) {
auto it = legacy_settings.find(key);
if (it != legacy_settings.end() && it->second.is_byte_array) {
value.assign(it->second.bytes.begin(), it->second.bytes.end());
}
}
template <typename T>
void readSetting(const json11::Json::object &settings_json, const char *key, T &value) {
auto it = settings_json.find(key);
if (it == settings_json.end()) return;
if constexpr (std::is_same_v<T, bool>) {
if (it->second.is_bool()) value = it->second.bool_value();
} else if constexpr (std::is_integral_v<T>) {
if (it->second.is_number()) value = it->second.int_value();
} else if constexpr (std::is_enum_v<T>) {
if (it->second.is_number()) value = static_cast<T>(it->second.int_value());
}
}
void readSetting(const json11::Json::object &settings_json, const char *key, std::string &value) {
auto it = settings_json.find(key);
if (it != settings_json.end() && it->second.is_string()) value = it->second.string_value();
}
void readSetting(const json11::Json::object &settings_json, const char *key, std::vector<std::string> &value) {
auto it = settings_json.find(key);
if (it == settings_json.end() || !it->second.is_array()) return;
std::vector<std::string> stored;
for (const auto &item : it->second.array_items()) {
if (!item.is_string()) return;
stored.push_back(item.string_value());
}
value = std::move(stored);
}
void readSetting(const json11::Json::object &settings_json, const char *key, std::vector<uint8_t> &value) {
auto it = settings_json.find(key);
if (it == settings_json.end() || !it->second.is_string()) return;
const auto &hex = it->second.string_value();
if (hex.size() % 2 == 0 && std::all_of(hex.begin(), hex.end(), [](unsigned char c) { return std::isxdigit(c); })) {
value.clear();
value.reserve(hex.size() / 2);
for (size_t i = 0; i < hex.size(); i += 2) {
value.push_back((hexDigit(hex[i]) << 4) | hexDigit(hex[i + 1]));
}
}
}
template <typename T>
void writeSetting(json11::Json::object &settings_json, const char *key, const T &value) {
if constexpr (std::is_same_v<T, bool>) {
settings_json[key] = value;
} else if constexpr (std::is_integral_v<T> || std::is_enum_v<T>) {
settings_json[key] = static_cast<int>(value);
}
}
void writeSetting(json11::Json::object &settings_json, const char *key, const std::string &value) {
settings_json[key] = value;
}
void writeSetting(json11::Json::object &settings_json, const char *key, const std::vector<std::string> &value) {
settings_json[key] = value;
}
void writeSetting(json11::Json::object &settings_json, const char *key, const std::vector<uint8_t> &value) {
static const char digits[] = "0123456789abcdef";
std::string hex;
hex.reserve(value.size() * 2);
for (uint8_t b : value) {
hex.push_back(digits[b >> 4]);
hex.push_back(digits[b & 0xf]);
}
settings_json[key] = hex;
}
template <class Store, class SettingOperation>
void settingsOp(Store &s, SettingOperation op) {
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);
op(s, "recent_dbc_file", settings.recent_dbc_file);
op(s, "active_msg_id", settings.active_msg_id);
op(s, "selected_msg_ids", settings.selected_msg_ids);
op(s, "active_charts", settings.active_charts);
}
} // namespace
Settings::Settings() {
last_dir = last_route_dir = utils::homePath();
log_path = utils::homePath() + "/cabana_live_stream/";
const auto stored_settings = loadSettings();
if (stored_settings.valid) {
if (stored_settings.exists) {
settingsOp(stored_settings.values, [](const auto &s, const char *key, auto &value) { readSetting(s, key, value); });
} else {
auto legacy_settings = loadLegacySettings();
settingsOp(legacy_settings, [](const auto &s, const char *key, auto &value) { readLegacySetting(s, key, value); });
}
}
fps = std::clamp(fps, 1, 100);
}
// Must be called before main() returns: json11's internal statistics are constructed on first
// use at runtime, so they are destroyed before this pre-main global. Saving from ~Settings
// would use them after destruction and corrupt the heap.
void Settings::save() {
if (!ensureSettingsDirectory()) return;
auto lock_path = settingsFile();
lock_path += ".lock";
FileLock lock(lock_path);
if (!lock.isLocked()) return;
auto stored_settings = loadSettings();
if (!stored_settings.valid && !preserveCorruptSettings()) return;
settingsOp(stored_settings.values, [](auto &s, const char *key, const auto &value) { writeSetting(s, key, value); });
saveSettings(stored_settings.values);
}
// 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(MIN_CACHE_MINIUTES, MAX_CACHE_MINIUTES);
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("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);
log_livestream->setChecked(settings.log_livestream);
QHBoxLayout *path_layout = new QHBoxLayout(log_livestream);
path_layout->addWidget(log_path = new QLineEdit(QString::fromStdString(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"),
QString::fromStdString(utils::homePath()),
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_height = chart_height->value();
settings.log_livestream = log_livestream->isChecked();
settings.log_path = log_path->text().toStdString();
settings.drag_direction = (Settings::DragDirection)drag_direction->currentIndex();
emit settings.changed();
QDialog::accept();
}
+45
View File
@@ -0,0 +1,45 @@
#pragma once
#include <cstdint>
#include <vector>
#include <QComboBox>
#include <QDialog>
#include <QGroupBox>
#include <QLineEdit>
#include <QSpinBox>
#include "tools/cabana/core/settings.h"
class Settings : public QObject, public CabanaSettingsState {
Q_OBJECT
public:
Settings();
void save();
// Qt frontend layout state. This intentionally stays outside CabanaSettingsState.
std::vector<uint8_t> geometry;
std::vector<uint8_t> video_splitter_state;
std::vector<uint8_t> window_state;
std::vector<uint8_t> message_header_state;
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;
+719
View File
@@ -0,0 +1,719 @@
#include "tools/cabana/signalview.h"
#include "tools/cabana/dbc/dbcqt.h"
#include <algorithm>
#include <future>
#include <QDialogButtonBox>
#include <QHBoxLayout>
#include <QHeaderView>
#include <QMessageBox>
#include <QPainter>
#include <QPainterPath>
#include <QPushButton>
#include <QScrollBar>
#include <QVBoxLayout>
#include "tools/cabana/commands.h"
#include "tools/cabana/utils/util.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(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, this, &SignalModel::refresh);
QObject::connect(dbcNotifier(), &QtDBCNotifier::msgUpdated, this, &SignalModel::handleMsgChanged);
QObject::connect(dbcNotifier(), &QtDBCNotifier::msgRemoved, this, &SignalModel::handleMsgChanged);
QObject::connect(dbcNotifier(), &QtDBCNotifier::signalAdded, this, &SignalModel::handleSignalAdded);
QObject::connect(dbcNotifier(), &QtDBCNotifier::signalUpdated, this, &SignalModel::handleSignalUpdated);
QObject::connect(dbcNotifier(), &QtDBCNotifier::signalRemoved, this, &SignalModel::handleSignalRemoved);
}
void SignalModel::insertItem(SignalModel::Item *root_item, int pos, const cabana::Signal *sig) {
Item *parent_item = new Item{.type = Item::Sig, .parent = root_item, .sig = sig, .title = QString::fromStdString(sig->name)};
root_item->children.insert(root_item->children.begin() + pos, parent_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) {
auto item = new Item{.type = (Item::Type)(i + Item::Name), .parent = parent_item, .sig = sig, .title = titles[i]};
parent_item->children.push_back(item);
if (item->type == Item::ExtraInfo) {
parent_item = item;
}
}
}
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() || QString::fromStdString(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;
return getItem(parent)->children.size();
}
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->children.empty()) {
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 ? QString::fromStdString(item->sig->name) : item->title;
} else {
switch (item->type) {
case Item::Sig: return item->sig_val;
case Item::Name: return QString::fromStdString(item->sig->name);
case Item::Size: return item->sig->size;
case Item::Node: return QString::fromStdString(item->sig->receiver_name);
case Item::SignalType: return signalTypeToString(item->sig->type);
case Item::MultiplexValue: return item->sig->multiplex_value;
case Item::Offset: return QString::fromStdString(doubleToString(item->sig->offset));
case Item::Factor: return QString::fromStdString(doubleToString(item->sig->factor));
case Item::Unit: return QString::fromStdString(item->sig->unit);
case Item::Comment: return QString::fromStdString(item->sig->comment);
case Item::Min: return QString::fromStdString(doubleToString(item->sig->min));
case Item::Max: return QString::fromStdString(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(QString::fromStdString(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::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().toStdString(); break;
case Item::Size: s.size = value.toInt(); break;
case Item::Node: s.receiver_name = value.toString().trimmed().toStdString(); 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().toStdString(); break;
case Item::Comment: s.comment = value.toString().toStdString(); 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;
}
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(QString::fromStdString(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::instance()->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 (QString::fromStdString(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);
auto item = root->children[row];
root->children.erase(root->children.begin() + row);
root->children.insert(root->children.begin() + to, item);
endMoveRows();
}
}
}
}
void SignalModel::handleSignalRemoved(const cabana::Signal *sig) {
if (int row = signalRow(sig); row != -1) {
beginRemoveRows({}, row, row);
delete root->children[row];
root->children.erase(root->children.begin() + row);
endRemoveRows();
}
}
// SignalItemDelegate
SignalItemDelegate::SignalItemDelegate(QObject *parent) : QStyledItemDelegate(parent) {
name_validator = new NameValidator(this);
node_validator = new NodeValidator(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;
}
width = std::min<int>(option.widget->size().width() / 3.0, option.fontMetrics.horizontalAdvance(text) + spacing);
}
return {width, option.fontMetrics.height() + option.widget->style()->pixelMetric(QStyle::PM_FocusFrameVMargin) * 2};
}
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 {
const int h_margin = option.widget->style()->pixelMetric(QStyle::PM_FocusFrameHMargin) + 1;
const int v_margin = option.widget->style()->pixelMetric(QStyle::PM_FocusFrameVMargin);
auto item = static_cast<SignalModel::Item*>(index.internalPointer());
QRect rect = option.rect.adjusted(h_margin, v_margin, -h_margin, -v_margin);
painter->setRenderHint(QPainter::Antialiasing);
if (option.state & QStyle::State_Selected) {
painter->fillRect(option.rect, option.palette.brush(QPalette::Normal, QPalette::Highlight));
}
if (index.column() == 0) {
if (item->type == SignalModel::Item::Sig) {
// color label
QPainterPath path;
QRect icon_rect{rect.x(), rect.y(), color_label_width, rect.height()};
path.addRoundedRect(icon_rect, 3, 3);
painter->setPen(item->highlight ? Qt::white : Qt::black);
painter->setFont(label_font);
painter->fillPath(path, toQColor(item->sig->color.darker(item->highlight ? 125 : 0)));
painter->drawText(icon_rect, Qt::AlignCenter, QString::number(item->row() + 1));
rect.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{rect.x(), rect.y(), option.fontMetrics.horizontalAdvance(indicator), rect.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);
rect.setLeft(indicator_rect.right() + h_margin * 2);
}
} else {
rect.setLeft(option.widget->style()->pixelMetric(QStyle::PM_TreeViewIndentation) + color_label_width + h_margin * 3);
}
// name
auto text = option.fontMetrics.elidedText(index.data(Qt::DisplayRole).toString(), Qt::ElideRight, rect.width());
painter->setPen(option.palette.color(option.state & QStyle::State_Selected ? QPalette::HighlightedText : QPalette::Text));
painter->setFont(option.font);
painter->drawText(rect, option.displayAlignment, text);
} else if (index.column() == 1) {
if (!item->sparkline.pixmap.isNull()) {
QSize sparkline_size = item->sparkline.pixmap.size() / item->sparkline.pixmap.devicePixelRatio();
painter->drawPixmap(QRect(rect.topLeft(), sparkline_size), item->sparkline.pixmap);
// min-max value
painter->setPen(option.palette.color(option.state & QStyle::State_Selected ? QPalette::HighlightedText : QPalette::Text));
rect.adjust(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.horizontalAdvance(min), fm.horizontalAdvance(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).horizontalAdvance(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);
return e;
} else if (item->type == SignalModel::Item::Size) {
QSpinBox *spin = new QSpinBox(parent);
spin->setFrame(false);
spin->setRange(1, CAN_MAX_DATA_BYTES);
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(QString::fromStdString(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);
filter_edit->setValidator(new NonWhitespaceValidator(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(dbcNotifier(), &QtDBCNotifier::signalAdded, this, &SignalView::handleSignalAdded);
QObject::connect(dbcNotifier(), &QtDBCNotifier::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 description 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::instance()->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 || item->type == SignalModel::Item::ExtraInfo) {
auto expand_index = model->index(index.row(), 0, index.parent());
tree->setExpanded(expand_index, !tree->isExpanded(expand_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();
}
std::pair<QModelIndex, QModelIndex> SignalView::visibleSignalRange() {
auto topLevelIndex = [](QModelIndex index) {
while (index.isValid() && index.parent().isValid()) index = index.parent();
return index;
};
const auto viewport_rect = tree->viewport()->rect();
QModelIndex first_visible = tree->indexAt(viewport_rect.topLeft());
if (first_visible.parent().isValid()) {
first_visible = topLevelIndex(first_visible);
first_visible = first_visible.siblingAtRow(first_visible.row() + 1);
}
QModelIndex last_visible = topLevelIndex(tree->indexAt(viewport_rect.bottomRight()));
if (!last_visible.isValid()) {
last_visible = model->index(model->rowCount() - 1, 0);
}
return {first_visible, last_visible};
}
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 = QString::fromStdString(item->sig->formatValue(value));
max_value_width = std::max(max_value_width, fontMetrics().horizontalAdvance(item->sig_val));
}
}
auto [first_visible, last_visible] = visibleSignalRange();
if (first_visible.isValid() && last_visible.isValid()) {
const static int min_max_width = QFontMetrics(delegate->minmax_font).horizontalAdvance("-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);
auto [first, last] = can->eventsInRange(model->msg_id, std::make_pair(last_msg.ts -settings.sparkline_range, last_msg.ts));
std::vector<std::future<void>> futures;
for (int i = first_visible.row(); i <= last_visible.row(); ++i) {
auto item = model->getItem(model->index(i, 1));
futures.push_back(std::async(std::launch::async,
&Sparkline::update, &item->sparkline, item->sig, first, last, settings.sparkline_range, size));
}
for (auto &f : futures) f.get();
}
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(QString::fromStdString(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.toStdString()});
}
}
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;
}
+153
View File
@@ -0,0 +1,153 @@
#pragma once
#include <memory>
#include <set>
#include <utility>
#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() { for (auto c : children) delete c; }
inline int row() {
auto it = std::find(parent->children.begin(), parent->children.end(), this);
return it != parent->children.end() ? std::distance(parent->children.begin(), it) : -1;
}
Type type = Type::Root;
Item *parent = nullptr;
std::vector<Item *> children;
const cabana::Signal *sig = nullptr;
QString title;
bool highlight = 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;
private:
void insertItem(SignalModel::Item *root_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;
};
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);
std::pair<QModelIndex, QModelIndex> visibleSignalRange();
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 setModel(QAbstractItemModel *model) override {
QTreeView::setModel(model);
// Bypass the slow call to QTreeView::dataChanged.
QObject::disconnect(model, &QAbstractItemModel::dataChanged, this, nullptr);
QObject::connect(model, &QAbstractItemModel::dataChanged, this,
[this](const QModelIndex &tl, const QModelIndex &br, const auto &roles) { QAbstractItemView::dataChanged(tl, br, 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;
};
@@ -0,0 +1,326 @@
#include "tools/cabana/streams/abstractstream.h"
#include "tools/cabana/dbc/dbcqt.h"
#include <limits>
#include <utility>
#include <QApplication>
#include "common/timing.h"
#include "tools/cabana/settings.h"
static const int EVENT_NEXT_BUFFER_SIZE = 6 * 1024 * 1024; // 6MB
AbstractStream *can = nullptr;
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(this, &AbstractStream::seeking, this, [this](double sec) { current_sec_ = sec; });
QObject::connect(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, this, &AbstractStream::updateMasks);
QObject::connect(dbcNotifier(), &QtDBCNotifier::maskUpdated, this, &AbstractStream::updateMasks);
}
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.bit_flip_counts[i][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;
for (auto &[_, m] : messages_) {
for (auto &last_change : m.last_changes) {
const double dt = current_sec_ - last_change.ts;
if (dt < 2.0) {
last_change.suppressed = true;
}
cnt += last_change.suppressed;
}
for (auto &flip_counts : m.bit_flip_counts) flip_counts.fill(0);
}
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 (time_range_ && (current_sec_ < time_range_->first || current_sec_ >= time_range_->second)) {
seekTo(time_range_->first);
return;
}
if (sources.size() != prev_src_size) {
updateMasks();
emit sourcesUpdated(sources);
}
emit msgsReceived(&msgs, prev_msg_size != last_msgs.size());
}
void AbstractStream::setTimeRange(const std::optional<std::pair<double, double>> &range) {
time_range_ = range;
if (time_range_ && (current_sec_ < time_range_->first || current_sec_ >= time_range_->second)) {
seekTo(time_range_->first);
}
emit timeRangeChanged(time_range_);
}
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) const {
static CanData empty_data = {};
auto it = last_msgs.find(id);
return it != last_msgs.end() ? it->second : empty_data;
}
bool AbstractStream::isMessageActive(const MessageId &id) const {
if (id.source == INVALID_SOURCE) {
return false;
}
// Check if the message is active based on time difference and frequency
const auto &m = lastMessage(id);
float delta = currentSec() - m.ts;
if (m.freq < std::numeric_limits<double>::epsilon()) {
return delta < 1.5;
}
return delta < (5.0 / m.freq) + (1.0 / settings.fps);
}
void AbstractStream::updateLastMsgsTo(double sec) {
current_sec_ = sec;
uint64_t last_ts = toMonoTime(sec);
std::unordered_map<MessageId, CanData> msgs;
msgs.reserve(events_.size());
for (const auto &[id, ev] : events_) {
auto it = std::upper_bound(ev.begin(), ev.end(), last_ts, CompareCanEvent());
if (it != ev.begin()) {
auto &m = msgs[id];
double freq = 0;
// Keep suppressed bits.
if (auto old_m = messages_.find(id); old_m != messages_.end()) {
freq = old_m->second.freq;
m.last_changes.reserve(old_m->second.last_changes.size());
std::transform(old_m->second.last_changes.cbegin(), old_m->second.last_changes.cend(),
std::back_inserter(m.last_changes),
[](const auto &change) { return CanData::ByteLastChange{.suppressed = change.suppressed}; });
}
auto prev = std::prev(it);
m.compute(id, (*prev)->dat, (*prev)->size, toSeconds((*prev)->mono_time), getSpeed(), {}, freq);
m.count = std::distance(ev.begin(), prev) + 1;
}
}
new_msgs_.clear();
messages_ = std::move(msgs);
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);
std::lock_guard lk(mutex_);
seek_finished_ = true;
seek_finished_cv_.notify_one();
}
void AbstractStream::waitForSeekFinshed() {
std::unique_lock lock(mutex_);
seek_finished_cv_.wait(lock, [this]() { return seek_finished_; });
seek_finished_ = false;
}
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(); });
// Group events by message ID
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);
}
}
std::pair<CanEventIter, CanEventIter> AbstractStream::eventsInRange(const MessageId &id, std::optional<std::pair<double, double>> time_range) const {
const auto &events = can->events(id);
if (!time_range) return {events.begin(), events.end()};
auto first = std::lower_bound(events.begin(), events.end(), can->toMonoTime(time_range->first), CompareCanEvent());
auto last = std::upper_bound(first, events.end(), can->toMonoTime(time_range->second), CompareCanEvent());
return {first, last};
}
namespace {
enum Color { GREYISH_BLUE, CYAN, RED};
CabanaColor getColor(int c) {
constexpr int start_alpha = 128;
static const CabanaColor colors[] = {
[GREYISH_BLUE] = CabanaColor(102, 86, 169, start_alpha / 2),
[CYAN] = CabanaColor(0, 187, 255, start_alpha),
[RED] = CabanaColor(255, 0, 0, start_alpha),
};
return settings.theme == LIGHT_THEME ? colors[c] : colors[c].lighter(135);
}
inline CabanaColor blend(const CabanaColor &a, const CabanaColor &b) {
return CabanaColor((a.red() + b.red()) / 2, (a.green() + b.green()) / 2, (a.blue() + b.blue()) / 2, (a.alpha() + b.alpha()) / 2);
}
// Calculate the frequency from the past one minute data
double calc_freq(const MessageId &msg_id, double current_sec) {
auto [first, last] = can->eventsInRange(msg_id, std::make_pair(current_sec - 59, current_sec));
int count = std::distance(first, last);
if (count <= 1) return 0.0;
double duration = ((*std::prev(last))->mono_time - (*first)->mono_time) / 1e9;
return duration > std::numeric_limits<double>::epsilon() ? (count - 1) / duration : 0.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.assign(can_data, can_data + size);
colors.assign(size, CabanaColor(0, 0, 0, 0));
last_changes.resize(size);
bit_flip_counts.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
last_change.same_delta_counter += std::signbit(delta) == std::signbit(last_change.delta) ? 1 : -4;
last_change.same_delta_counter = std::clamp(last_change.same_delta_counter, 0, 16);
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
auto &row_bit_flips = bit_flip_counts[i];
const uint8_t diff = (cur ^ last);
for (int bit = 0; bit < 8; bit++) {
if (diff & (1u << bit)) {
++row_bit_flips[7 - bit];
}
}
last_change.ts = ts;
last_change.delta = delta;
} else {
// Fade out
colors[i].setAlphaF(std::max(0.0f, colors[i].alphaF() - alpha_delta));
}
}
}
memcpy(dat.data(), can_data, size);
}
@@ -0,0 +1,119 @@
#pragma once
#include <algorithm>
#include <array>
#include <condition_variable>
#include <chrono>
#include <memory>
#include <mutex>
#include <optional>
#include <set>
#include <unordered_map>
#include <utility>
#include <vector>
#include "openpilot/cereal/messaging/messaging.h"
#include "tools/cabana/core/can_data.h"
#include "tools/cabana/dbc/dbcmanager.h"
#include "tools/cabana/utils/util.h"
#include "tools/replay/util.h"
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 std::string routeName() const = 0;
virtual std::string carFingerprint() const { return ""; }
virtual std::chrono::system_clock::time_point beginDateTime() const { return {}; }
virtual uint64_t beginMonoTime() const { return 0; }
virtual double minSeconds() const { return 0; }
virtual double maxSeconds() const { return 0; }
virtual void setSpeed(float speed) {}
virtual double getSpeed() { return 1; }
virtual bool isPaused() const { return false; }
virtual void pause(bool pause) {}
void setTimeRange(const std::optional<std::pair<double, double>> &range);
const std::optional<std::pair<double, double>> &timeRange() const { return time_range_; }
inline double currentSec() const { return current_sec_; }
inline uint64_t toMonoTime(double sec) const { return beginMonoTime() + std::max(sec, 0.0) * 1e9; }
inline double toSeconds(uint64_t mono_time) const { return std::max(0.0, (mono_time - beginMonoTime()) / 1e9); }
inline const std::unordered_map<MessageId, CanData> &lastMessages() const { return last_msgs; }
bool isMessageActive(const MessageId &id) const;
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;
const std::vector<const CanEvent *> &events(const MessageId &id) const;
std::pair<CanEventIter, CanEventIter> eventsInRange(const MessageId &id, std::optional<std::pair<double, double>> time_range) const;
size_t suppressHighlighted();
void clearSuppressed();
void suppressDefinedSignals(bool suppress);
signals:
void paused();
void resume();
void seeking(double sec);
void seekedTo(double sec);
void timeRangeChanged(const std::optional<std::pair<double, double>> &range);
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);
void waitForSeekFinshed();
virtual void updateLastMessages();
std::vector<const CanEvent *> all_events_;
double current_sec_ = 0;
std::optional<std::pair<double, double>> time_range_;
private:
void updateLastMsgsTo(double sec);
void updateMasks();
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::condition_variable seek_finished_cv_;
bool seek_finished_ = false;
std::set<MessageId> new_msgs_;
std::unordered_map<MessageId, CanData> messages_;
std::unordered_map<MessageId, std::vector<uint8_t>> masks_;
};
class AbstractOpenStreamWidget : public QWidget {
Q_OBJECT
public:
AbstractOpenStreamWidget(QWidget *parent = nullptr) : QWidget(parent) {}
virtual AbstractStream *open() = 0;
signals:
void enableOpenButton(bool);
};
class DummyStream : public AbstractStream {
Q_OBJECT
public:
DummyStream(QObject *parent) : AbstractStream(parent) {}
std::string routeName() const override { return "No Stream"; }
void start() override {}
};
// A global pointer referring to the unique AbstractStream object
extern AbstractStream *can;
@@ -0,0 +1,148 @@
#include "tools/cabana/streams/devicestream.h"
#include <cerrno>
#include <chrono>
#include <csignal>
#include <cstring>
#include <fcntl.h>
#include <filesystem>
#include <memory>
#include <string>
#include <thread>
#include <unistd.h>
#include <sys/wait.h>
#include "openpilot/cereal/services.h"
#include <QButtonGroup>
#include <QFormLayout>
#include <QMessageBox>
#include <QRadioButton>
#include "tools/cabana/utils/util.h"
// DeviceStream
DeviceStream::DeviceStream(QObject *parent, QString address) : zmq_address(address), LiveStream(parent) {
}
DeviceStream::~DeviceStream() {
stop();
stopBridge();
}
void DeviceStream::stopBridge() {
if (bridge_pid <= 0) return;
::kill(bridge_pid, SIGTERM);
for (int i = 0; i < 30; ++i) {
int status = 0;
pid_t r = ::waitpid(bridge_pid, &status, WNOHANG);
if (r == bridge_pid || (r < 0 && errno == ECHILD)) {
bridge_pid = -1;
return;
}
usleep(100000); // 100ms, up to ~3s
}
::kill(bridge_pid, SIGKILL);
::waitpid(bridge_pid, nullptr, 0);
bridge_pid = -1;
}
void DeviceStream::start() {
if (!zmq_address.isEmpty()) {
stopBridge();
const std::string path = (std::filesystem::path(QCoreApplication::applicationDirPath().toStdString()) /
"../../openpilot/cereal/messaging/bridge").lexically_normal().string();
const std::string addr = zmq_address.toStdString();
const char *can_filter = "/\"can/\"";
// Self-pipe: write end is CLOEXEC so it closes on successful exec. If exec
// fails, the child writes errno and the parent aborts stream start.
int err_pipe[2] = {-1, -1};
if (::pipe(err_pipe) != 0) {
QMessageBox::warning(nullptr, tr("Error"),
tr("Failed to start bridge: %1").arg(QString::fromLocal8Bit(strerror(errno))));
return;
}
pid_t pid = ::fork();
if (pid == 0) {
::close(err_pipe[0]);
::fcntl(err_pipe[1], F_SETFD, FD_CLOEXEC);
execl(path.c_str(), path.c_str(), addr.c_str(), can_filter, static_cast<char *>(nullptr));
const int err = errno;
(void)!::write(err_pipe[1], &err, sizeof(err));
_exit(127);
}
::close(err_pipe[1]);
if (pid < 0) {
::close(err_pipe[0]);
QMessageBox::warning(nullptr, tr("Error"),
tr("Failed to start bridge: %1").arg(QString::fromLocal8Bit(strerror(errno))));
return;
}
int exec_errno = 0;
const ssize_t n = ::read(err_pipe[0], &exec_errno, sizeof(exec_errno));
::close(err_pipe[0]);
if (n == static_cast<ssize_t>(sizeof(exec_errno))) {
// Child failed to exec; reap and surface the error.
int status = 0;
::waitpid(pid, &status, 0);
QMessageBox::warning(nullptr, tr("Error"),
tr("Failed to start bridge: %1").arg(QString::fromLocal8Bit(strerror(exec_errno))));
return;
}
bridge_pid = pid;
}
LiveStream::start();
}
void DeviceStream::streamThread() {
zmq_address.isEmpty() ? unsetenv("ZMQ") : setenv("ZMQ", "1", 1);
std::unique_ptr<Context> context(Context::create());
std::unique_ptr<SubSocket> sock(SubSocket::create(context.get(), "can", "127.0.0.1", false, true, services.at("can").queue_size));
assert(sock != NULL);
// run as fast as messages come in
while (!exit_) {
std::unique_ptr<Message> msg(sock->receive(true));
if (!msg) {
std::this_thread::sleep_for(std::chrono::milliseconds(50));
continue;
}
handleEvent(kj::ArrayPtr<capnp::word>((capnp::word*)msg->getData(), msg->getSize() / sizeof(capnp::word)));
}
}
// OpenDeviceWidget
OpenDeviceWidget::OpenDeviceWidget(QWidget *parent) : AbstractOpenStreamWidget(parent) {
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"));
ip_address->setValidator(new IpAddressValidator(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);
}
AbstractStream *OpenDeviceWidget::open() {
QString ip = ip_address->text().isEmpty() ? "127.0.0.1" : ip_address->text();
bool msgq = group->checkedId() == 0;
return new DeviceStream(qApp, msgq ? "" : ip);
}
@@ -0,0 +1,34 @@
#pragma once
#include "tools/cabana/streams/livestream.h"
#include <sys/types.h>
class DeviceStream : public LiveStream {
Q_OBJECT
public:
DeviceStream(QObject *parent, QString address = {});
~DeviceStream();
inline std::string routeName() const override {
return "Live Streaming From " + (zmq_address.isEmpty() ? std::string("127.0.0.1") : zmq_address.toStdString());
}
protected:
void start() override;
void streamThread() override;
void stopBridge();
pid_t bridge_pid = -1;
const QString zmq_address;
};
class OpenDeviceWidget : public AbstractOpenStreamWidget {
Q_OBJECT
public:
OpenDeviceWidget(QWidget *parent = nullptr);
AbstractStream *open() override;
private:
QLineEdit *ip_address;
QButtonGroup *group;
};
@@ -0,0 +1,151 @@
#include "tools/cabana/streams/livestream.h"
#include <algorithm>
#include <chrono>
#include <fstream>
#include <iomanip>
#include <memory>
#include <sstream>
#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) {
const time_t start_time = start_ts;
std::tm local_time = {};
localtime_r(&start_time, &local_time);
std::ostringstream date;
date << std::put_time(&local_time, "%Y-%m-%d--%H-%M-%S");
QString dir = QString("%1/%2--%3")
.arg(QString::fromStdString(settings.log_path))
.arg(QString::fromStdString(date.str()))
.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>();
}
}
LiveStream::~LiveStream() {
stop();
}
void LiveStream::start() {
begin_date_time = std::chrono::system_clock::now();
fps_ = settings.fps;
exit_ = false;
stream_thread = std::thread(&LiveStream::streamThread, this);
update_thread = std::thread(&LiveStream::updateThread, this);
}
void LiveStream::stop() {
exit_ = true;
if (stream_thread.joinable()) stream_thread.join();
if (update_thread.joinable()) update_thread.join();
}
void LiveStream::updateThread() {
while (!exit_) {
std::this_thread::sleep_for(std::chrono::milliseconds(1000 / fps_));
// coalesce: skip the emit if the main thread hasn't processed the previous one yet.
if (!update_pending_.exchange(true)) {
emit privateUpdateLastMsgsSignal();
}
}
}
// 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));
}
}
}
// called on the main thread by the queued privateUpdateLastMsgsSignal connection
void LiveStream::updateLastMessages() {
update_pending_ = false;
fps_ = settings.fps;
{
// merge events received from live stream thread.
std::lock_guard lk(lock);
mergeEvents(received_events_);
uint64_t last_received_ts = !received_events_.empty() ? received_events_.back()->mono_time : 0;
lastest_event_ts = std::max(lastest_event_ts, last_received_ts);
received_events_.clear();
}
if (!all_events_.empty()) {
begin_event_ts = all_events_.front()->mono_time;
updateEvents();
}
}
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;
}
AbstractStream::updateLastMessages();
}
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, lastest_event_ts);
post_last_event = (first_event_ts == lastest_event_ts);
emit seekedTo((current_event_ts - begin_event_ts) / 1e9);
}
void LiveStream::pause(bool pause) {
paused_ = pause;
emit(pause ? paused() : resume());
}
@@ -0,0 +1,57 @@
#pragma once
#include <algorithm>
#include <atomic>
#include <memory>
#include <thread>
#include <vector>
#include "tools/cabana/streams/abstractstream.h"
class LiveStream : public AbstractStream {
Q_OBJECT
public:
LiveStream(QObject *parent);
virtual ~LiveStream();
void start() override;
void stop();
inline std::chrono::system_clock::time_point beginDateTime() const override { return begin_date_time; }
inline uint64_t beginMonoTime() const override { return begin_event_ts; }
double maxSeconds() const override { return std::max(1.0, (lastest_event_ts - 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);
std::atomic<bool> exit_ = false;
private:
void updateThread();
void updateLastMessages() override;
void updateEvents();
std::mutex lock;
std::thread stream_thread, update_thread;
std::atomic<bool> update_pending_ = false;
std::atomic<int> fps_ = 10;
std::vector<const CanEvent *> received_events_;
std::chrono::system_clock::time_point begin_date_time;
uint64_t begin_event_ts = 0;
uint64_t lastest_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;
};
@@ -0,0 +1,189 @@
#include "tools/cabana/streams/pandastream.h"
#include <chrono>
#include <cstdio>
#include <thread>
#include <QCheckBox>
#include <QLabel>
#include <QMessageBox>
#include <QPushButton>
#include <QTimer>
PandaStream::PandaStream(QObject *parent, PandaStreamConfig config_) : config(config_), LiveStream(parent) {
if (!connect()) {
throw std::runtime_error("Failed to connect to panda");
}
}
bool PandaStream::connect() {
try {
fprintf(stderr, "Connecting to panda %s\n", config.serial.c_str());
panda.reset(new Panda(config.serial));
config.bus_config.resize(3);
fprintf(stderr, "Connected\n");
} catch (const std::exception& e) {
return false;
}
panda->set_safety_model(cereal::CarParams::SafetyModel::NO_OUTPUT);
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 (!exit_) {
std::this_thread::sleep_for(std::chrono::milliseconds(1));
if (!panda->connected()) {
fprintf(stderr, "Connection to panda lost. Attempting reconnect.\n");
if (!connect()){
std::this_thread::sleep_for(std::chrono::milliseconds(1000));
continue;
}
}
raw_can_data.clear();
if (!panda->can_receive(raw_can_data)) {
fprintf(stderr, "failed to receive\n");
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].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);
}
}
// OpenPandaWidget
OpenPandaWidget::OpenPandaWidget(QWidget *parent) : AbstractOpenStreamWidget(parent) {
form_layout = new QFormLayout(this);
if (can && dynamic_cast<PandaStream *>(can) != nullptr) {
form_layout->addWidget(new QLabel(tr("Already connected to %1.").arg(QString::fromStdString(can->routeName()))));
form_layout->addWidget(new QLabel("Close the current connection via [File menu -> Close Stream] before connecting to another Panda."));
QTimer::singleShot(0, [this]() { emit enableOpenButton(false); });
return;
}
QHBoxLayout *serial_layout = new QHBoxLayout();
serial_layout->addWidget(serial_edit = new QComboBox());
QPushButton *refresh = new QPushButton(tr("Refresh"));
refresh->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Preferred);
serial_layout->addWidget(refresh);
form_layout->addRow(tr("Serial"), serial_layout);
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() {
for (int i = form_layout->rowCount() - 1; i > 0; --i) {
form_layout->removeRow(i);
}
QString serial = serial_edit->currentText();
bool has_fd = false;
bool has_panda = !serial.isEmpty();
if (has_panda) {
try {
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) {
fprintf(stderr, "failed to open panda %s\n", serial.toUtf8().constData());
has_panda = false;
}
}
if (has_panda) {
config.serial = serial.toStdString();
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;});
}
form_layout->addRow(tr("Bus %1:").arg(i), bus_layout);
}
} else {
config.serial = "";
form_layout->addWidget(new QLabel(tr("No panda found")));
}
}
AbstractStream *OpenPandaWidget::open() {
try {
return new PandaStream(qApp, config);
} catch (std::exception &e) {
QMessageBox::warning(nullptr, tr("Warning"), tr("Failed to connect to panda: '%1'").arg(e.what()));
return nullptr;
}
}
@@ -0,0 +1,57 @@
#pragma once
#include <memory>
#include <vector>
#include <QComboBox>
#include <QFormLayout>
#include "tools/cabana/streams/livestream.h"
#include "tools/cabana/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 BusConfig {
int can_speed_kbps = 500;
int data_speed_kbps = 2000;
bool can_fd = false;
};
struct PandaStreamConfig {
std::string serial = "";
std::vector<BusConfig> bus_config;
};
class PandaStream : public LiveStream {
Q_OBJECT
public:
PandaStream(QObject *parent, PandaStreamConfig config_ = {});
~PandaStream() { stop(); }
inline std::string routeName() const override {
return "Panda: " + config.serial;
}
protected:
bool connect();
void streamThread() override;
std::unique_ptr<Panda> panda;
PandaStreamConfig config = {};
};
class OpenPandaWidget : public AbstractOpenStreamWidget {
Q_OBJECT
public:
OpenPandaWidget(QWidget *parent = nullptr);
AbstractStream *open() override;
private:
void refreshSerials();
void buildConfigForm();
QComboBox *serial_edit;
QFormLayout *form_layout;
PandaStreamConfig config = {};
};
@@ -0,0 +1,175 @@
#include "tools/cabana/streams/replaystream.h"
#include <filesystem>
#include <QLabel>
#include <QFileDialog>
#include <QGridLayout>
#include <QMessageBox>
#include <QPushButton>
#include "common/timing.h"
#include "common/util.h"
#include "tools/cabana/streams/routes.h"
ReplayStream::ReplayStream(QObject *parent) : AbstractStream(parent) {
unsetenv("ZMQ");
setenv("COMMA_CACHE", "/tmp/comma_download_cache", 1);
op_prefix = std::make_unique<OpenpilotPrefix>();
QObject::connect(&settings, &Settings::changed, this, [this]() {
if (replay) replay->setSegmentCacheLimit(settings.max_cached_minutes);
});
}
void ReplayStream::mergeSegments() {
auto event_data = replay->getEventData();
for (const auto &[n, seg] : event_data->segments) {
if (!processed_segments.count(n)) {
processed_segments.insert(n);
std::vector<const CanEvent *> new_events;
new_events.reserve(seg->log->events.size());
for (const Event &e : seg->log->events) {
if (e.which == cereal::Event::Which::CAN) {
capnp::FlatArrayMessageReader reader(e.data);
auto event = reader.getRoot<cereal::Event>();
for (const auto &c : event.getCan()) {
new_events.push_back(newEvent(e.mono_time, c));
}
}
}
mergeEvents(new_events);
}
}
}
bool ReplayStream::loadRoute(const std::string &route, const std::string &data_dir, uint32_t replay_flags, bool auto_source) {
replay.reset(new Replay(route, {"can", "roadEncodeIdx", "driverEncodeIdx", "wideRoadEncodeIdx", "carParams"},
{}, nullptr, replay_flags, data_dir, auto_source));
replay->setSegmentCacheLimit(settings.max_cached_minutes);
replay->installEventFilter([this](const Event *event) { return eventFilter(event); });
// Forward replay callbacks to corresponding Qt signals.
replay->onSeeking = [this](double sec) { emit seeking(sec); };
replay->onSeekedTo = [this](double sec) {
emit seekedTo(sec);
waitForSeekFinshed();
};
replay->onQLogLoaded = [this](std::shared_ptr<LogReader> qlog) { emit qLogLoaded(qlog); };
replay->onSegmentsMerged = [this]() { QMetaObject::invokeMethod(this, &ReplayStream::mergeSegments, Qt::BlockingQueuedConnection); };
bool success = replay->load();
if (!success) {
if (replay->lastRouteError() == RouteLoadError::Unauthorized) {
auto auth_content = util::read_file(util::getenv("HOME") + "/.comma/auth.json");
QString message;
if (auth_content.empty()) {
message = "Authentication Required. Please run the following command to authenticate:\n\n"
"python3 openpilot/tools/lib/auth.py\n\n"
"This will grant access to routes from your comma account.";
} else {
message = tr("Access Denied. You do not have permission to access route:\n\n%1\n\n"
"This is likely a private route.").arg(QString::fromStdString(route));
}
QMessageBox::warning(nullptr, tr("Access Denied"), message);
} else if (replay->lastRouteError() == RouteLoadError::NetworkError) {
QMessageBox::warning(nullptr, tr("Network Error"),
tr("Unable to load the route:\n\n %1.\n\nPlease check your network connection and try again.").arg(QString::fromStdString(route)));
} else if (replay->lastRouteError() == RouteLoadError::FileNotFound) {
QMessageBox::warning(nullptr, tr("Route Not Found"),
tr("The specified route could not be found:\n\n %1.\n\nPlease check the route name and try again.").arg(QString::fromStdString(route)));
} else {
QMessageBox::warning(nullptr, tr("Route Load Failed"), tr("Failed to load route: '%1'").arg(QString::fromStdString(route)));
}
}
return success;
}
bool ReplayStream::eventFilter(const Event *event) {
static double prev_update_ts = 0;
if (event->which == cereal::Event::Which::CAN) {
double current_sec = toSeconds(event->mono_time);
capnp::FlatArrayMessageReader reader(event->data);
auto e = reader.getRoot<cereal::Event>();
for (const auto &c : e.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());
}
// OpenReplayWidget
OpenReplayWidget::OpenReplayWidget(QWidget *parent) : AbstractOpenStreamWidget(parent) {
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 route name or browse for local/remote route"));
auto browse_remote_btn = new QPushButton(tr("Remote route..."), this);
grid_layout->addWidget(browse_remote_btn, 0, 2);
auto browse_local_btn = new QPushButton(tr("Local route..."), this);
grid_layout->addWidget(browse_local_btn, 0, 3);
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)));
cameras[0]->setChecked(true);
camera_layout->addStretch(1);
grid_layout->addItem(camera_layout, 1, 1);
setMinimumWidth(550);
QObject::connect(browse_local_btn, &QPushButton::clicked, [=]() {
QString dir = QFileDialog::getExistingDirectory(this, tr("Open Local Route"), QString::fromStdString(settings.last_route_dir));
if (!dir.isEmpty()) {
route_edit->setText(dir);
settings.last_route_dir = std::filesystem::absolute(dir.toStdString()).parent_path().string();
}
});
QObject::connect(browse_remote_btn, &QPushButton::clicked, [this]() {
RoutesDialog route_dlg(this);
if (route_dlg.exec()) {
route_edit->setText(route_dlg.route());
}
});
}
AbstractStream *OpenReplayWidget::open() {
QString route = route_edit->text();
QString data_dir;
if (int idx = route.lastIndexOf('/'); idx != -1 && util::file_exists(route.toStdString())) {
data_dir = route.mid(0, idx + 1);
route = route.mid(idx + 1);
}
bool is_valid_format = Route::parseRoute(route.toStdString()).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.toStdString(), data_dir.toStdString(), flags)) {
return replay_stream.release();
}
}
return nullptr;
}
@@ -0,0 +1,59 @@
#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"
Q_DECLARE_METATYPE(std::shared_ptr<LogReader>);
class ReplayStream : public AbstractStream {
Q_OBJECT
public:
ReplayStream(QObject *parent);
void start() override { replay->start(); }
bool loadRoute(const std::string &route, const std::string &data_dir, uint32_t replay_flags = REPLAY_FLAG_NONE, bool auto_source = false);
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 std::string routeName() const override { return replay->route().name(); }
inline std::string carFingerprint() const override { return replay->carFingerprint(); }
double minSeconds() const override { return replay->minSeconds(); }
double maxSeconds() const { return replay->maxSeconds(); }
inline std::chrono::system_clock::time_point beginDateTime() const override {
return std::chrono::system_clock::from_time_t(replay->routeDateTime());
}
inline uint64_t beginMonoTime() const override { return replay->routeStartNanos(); }
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;
signals:
void qLogLoaded(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(QWidget *parent = nullptr);
AbstractStream *open() override;
private:
QLineEdit *route_edit;
std::vector<QCheckBox *> cameras;
};
+196
View File
@@ -0,0 +1,196 @@
#include "tools/cabana/streams/routes.h"
#include <chrono>
#include <ctime>
#include <string>
#include <thread>
#include <utility>
#include <QApplication>
#include <QDialogButtonBox>
#include <QFormLayout>
#include <QListWidget>
#include <QMessageBox>
#include <QPainter>
#include "json11/json11.hpp"
#include "tools/replay/py_downloader.h"
namespace {
// Parse a PyDownloader JSON response into (success, error_code).
std::pair<bool, int> checkApiResponse(const std::string &result) {
if (result.empty()) return {false, 500};
std::string err;
auto doc = json11::Json::parse(result, err);
if (!err.empty()) return {false, 500};
if (doc.is_object() && doc["error"].is_string()) {
return {false, doc["error"].string_value() == "unauthorized" ? 401 : 500};
}
return {true, 0};
}
int64_t nowUnixMs() {
return std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::system_clock::now().time_since_epoch())
.count();
}
// Parse ISO-8601 (with optional fractional seconds / Z) to unix ms. Returns 0 on failure.
int64_t parseIsoToUnixMs(const std::string &s) {
std::string bytes = s;
if (!bytes.empty() && (bytes.back() == 'Z' || bytes.back() == 'z')) bytes.pop_back();
int millis = 0;
auto dot = bytes.find('.');
if (dot != std::string::npos) {
std::string frac = bytes.substr(dot + 1);
bytes = bytes.substr(0, dot);
while (frac.size() < 3) frac.push_back('0');
millis = std::atoi(frac.substr(0, 3).c_str());
}
std::tm tm{};
const char *ret = strptime(bytes.c_str(), "%Y-%m-%dT%H:%M:%S", &tm);
if (!ret) ret = strptime(bytes.c_str(), "%Y-%m-%d %H:%M:%S", &tm);
if (!ret) return 0;
tm.tm_isdst = -1;
time_t secs = timegm(&tm);
if (secs == static_cast<time_t>(-1)) return 0;
return static_cast<int64_t>(secs) * 1000 + millis;
}
QString formatUnixMs(int64_t ms) {
time_t secs = static_cast<time_t>(ms / 1000);
std::tm tm{};
localtime_r(&secs, &tm);
char buf[64];
std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &tm);
return QString::fromUtf8(buf);
}
} // namespace
// The RouteListWidget class extends QListWidget to display a custom message when empty
class RouteListWidget : public QListWidget {
public:
RouteListWidget(QWidget *parent = nullptr) : QListWidget(parent) {}
void setEmptyText(const QString &text) {
empty_text_ = text;
viewport()->update();
}
void paintEvent(QPaintEvent *event) override {
QListWidget::paintEvent(event);
if (count() == 0) {
QPainter painter(viewport());
painter.drawText(viewport()->rect(), Qt::AlignCenter, empty_text_);
}
}
QString empty_text_ = tr("No items");
};
RoutesDialog::RoutesDialog(QWidget *parent) : QDialog(parent) {
setWindowTitle(tr("Remote routes"));
QFormLayout *layout = new QFormLayout(this);
layout->addRow(tr("Device"), device_list_ = new QComboBox(this));
layout->addRow(period_selector_ = new QComboBox(this));
layout->addRow(route_list_ = new RouteListWidget(this));
auto button_box = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
layout->addRow(button_box);
device_list_->addItem(tr("Loading..."));
period_selector_->addItem(tr("Last week"), 7);
period_selector_->addItem(tr("Last 2 weeks"), 14);
period_selector_->addItem(tr("Last month"), 30);
period_selector_->addItem(tr("Last 6 months"), 180);
period_selector_->addItem(tr("Preserved"), -1);
connect(device_list_, QOverload<int>::of(&QComboBox::currentIndexChanged), this, &RoutesDialog::fetchRoutes);
connect(period_selector_, QOverload<int>::of(&QComboBox::currentIndexChanged), this, &RoutesDialog::fetchRoutes);
connect(route_list_, &QListWidget::itemDoubleClicked, this, &QDialog::accept);
connect(button_box, &QDialogButtonBox::accepted, this, &QDialog::accept);
connect(button_box, &QDialogButtonBox::rejected, this, &QDialog::reject);
// Fetch devices
std::thread([this, alive = std::weak_ptr<bool>(alive_)]() {
std::string result = PyDownloader::getDevices();
QMetaObject::invokeMethod(qApp, [this, alive, r = QString::fromStdString(result), response = checkApiResponse(result)]() {
if (!alive.expired()) parseDeviceList(r, response.first, response.second);
}, Qt::QueuedConnection);
}).detach();
}
void RoutesDialog::parseDeviceList(const QString &json, bool success, int error_code) {
if (success) {
device_list_->clear();
std::string err;
auto doc = json11::Json::parse(json.toStdString(), err);
if (err.empty() && doc.is_array()) {
for (const auto &device : doc.array_items()) {
QString dongle_id = QString::fromStdString(device["dongle_id"].string_value());
device_list_->addItem(dongle_id, dongle_id);
}
}
} else {
QMessageBox::warning(this, tr("Error"), error_code == 401 ? tr("Unauthorized. Authenticate with openpilot/tools/lib/auth.py") : tr("Network error"));
reject();
}
}
void RoutesDialog::fetchRoutes() {
if (device_list_->currentIndex() == -1 || device_list_->currentData().isNull())
return;
route_list_->clear();
route_list_->setEmptyText(tr("Loading..."));
std::string did = device_list_->currentText().toStdString();
int period = period_selector_->currentData().toInt();
bool preserved = (period == -1);
int64_t start_ms = 0, end_ms = 0;
if (!preserved) {
end_ms = nowUnixMs();
start_ms = end_ms - static_cast<int64_t>(period) * 24LL * 60LL * 60LL * 1000LL;
}
int request_id = ++fetch_id_;
std::thread([this, alive = std::weak_ptr<bool>(alive_), did, start_ms, end_ms, preserved, request_id]() {
std::string result = PyDownloader::getDeviceRoutes(did, start_ms, end_ms, preserved);
QMetaObject::invokeMethod(qApp, [this, alive, r = QString::fromStdString(result), response = checkApiResponse(result), request_id]() {
if (!alive.expired() && fetch_id_ == request_id) parseRouteList(r, response.first, response.second);
}, Qt::QueuedConnection);
}).detach();
}
void RoutesDialog::parseRouteList(const QString &json, bool success, int error_code) {
if (success) {
std::string err;
auto doc = json11::Json::parse(json.toStdString(), err);
if (err.empty() && doc.is_array()) {
for (const auto &route : doc.array_items()) {
int64_t from_ms = 0, to_ms = 0;
if (period_selector_->currentData().toInt() == -1) {
from_ms = parseIsoToUnixMs(route["start_time"].string_value());
to_ms = parseIsoToUnixMs(route["end_time"].string_value());
} else {
from_ms = static_cast<int64_t>(route["start_time_utc_millis"].number_value());
to_ms = static_cast<int64_t>(route["end_time_utc_millis"].number_value());
}
const int mins = static_cast<int>((to_ms - from_ms) / 60000);
auto item = new QListWidgetItem(QString("%1 %2min").arg(formatUnixMs(from_ms)).arg(mins));
item->setData(Qt::UserRole, QString::fromStdString(route["fullname"].string_value()));
route_list_->addItem(item);
}
}
if (route_list_->count() > 0) route_list_->setCurrentRow(0);
} else {
QMessageBox::warning(this, tr("Error"), tr("Failed to fetch routes. Check your network connection."));
reject();
}
route_list_->setEmptyText(tr("No items"));
}
QString RoutesDialog::route() {
auto current_item = route_list_->currentItem();
return current_item ? current_item->data(Qt::UserRole).toString() : "";
}
+28
View File
@@ -0,0 +1,28 @@
#pragma once
#include <atomic>
#include <memory>
#include <QComboBox>
#include <QDialog>
class RouteListWidget;
class RoutesDialog : public QDialog {
Q_OBJECT
public:
RoutesDialog(QWidget *parent);
QString route();
protected:
void parseDeviceList(const QString &json, bool success, int error_code);
void parseRouteList(const QString &json, bool success, int error_code);
void fetchRoutes();
QComboBox *device_list_;
QComboBox *period_selector_;
RouteListWidget *route_list_;
std::atomic<int> fetch_id_{0};
// expires on destruction; guards main-thread callbacks from detached worker threads
std::shared_ptr<bool> alive_ = std::make_shared<bool>(true);
};
@@ -0,0 +1,148 @@
#include "tools/cabana/streams/socketcanstream.h"
#include <linux/can.h>
#include <linux/can/raw.h>
#include <net/if.h>
#include <sys/ioctl.h>
#include <sys/socket.h>
#include <unistd.h>
#include <cstdio>
#include <filesystem>
#include <fstream>
#include <QFormLayout>
#include <QHBoxLayout>
#include <QMessageBox>
#include <QPushButton>
SocketCanStream::SocketCanStream(QObject *parent, SocketCanStreamConfig config_) : config(config_), LiveStream(parent) {
if (!available()) {
throw std::runtime_error("SocketCAN not available");
}
fprintf(stderr, "Connecting to SocketCAN device %s\n", config.device.c_str());
if (!connect()) {
throw std::runtime_error("Failed to connect to SocketCAN device");
}
}
SocketCanStream::~SocketCanStream() {
stop();
if (sock_fd >= 0) {
::close(sock_fd);
sock_fd = -1;
}
}
bool SocketCanStream::available() {
int fd = socket(PF_CAN, SOCK_RAW, CAN_RAW);
if (fd < 0) return false;
::close(fd);
return true;
}
bool SocketCanStream::connect() {
sock_fd = socket(PF_CAN, SOCK_RAW, CAN_RAW);
if (sock_fd < 0) {
fprintf(stderr, "Failed to create CAN socket\n");
return false;
}
// Enable CAN-FD
int fd_enable = 1;
setsockopt(sock_fd, SOL_CAN_RAW, CAN_RAW_FD_FRAMES, &fd_enable, sizeof(fd_enable));
struct ifreq ifr = {};
strncpy(ifr.ifr_name, config.device.c_str(), IFNAMSIZ - 1);
if (ioctl(sock_fd, SIOCGIFINDEX, &ifr) < 0) {
fprintf(stderr, "Failed to get interface index for %s\n", config.device.c_str());
::close(sock_fd);
sock_fd = -1;
return false;
}
struct sockaddr_can addr = {};
addr.can_family = AF_CAN;
addr.can_ifindex = ifr.ifr_ifindex;
if (bind(sock_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
fprintf(stderr, "Failed to bind CAN socket\n");
::close(sock_fd);
sock_fd = -1;
return false;
}
// Set read timeout so the thread can check for interruption
struct timeval tv = {.tv_sec = 0, .tv_usec = 100000}; // 100ms
setsockopt(sock_fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
return true;
}
void SocketCanStream::streamThread() {
struct canfd_frame frame;
while (!exit_) {
ssize_t nbytes = read(sock_fd, &frame, sizeof(frame));
if (nbytes <= 0) continue;
uint8_t len = (nbytes == CAN_MTU) ? frame.len : frame.len; // works for both CAN and CAN-FD
MessageBuilder msg;
auto evt = msg.initEvent();
auto canData = evt.initCan(1);
canData[0].setAddress(frame.can_id & CAN_EFF_MASK);
canData[0].setSrc(0);
canData[0].setDat(kj::arrayPtr(frame.data, len));
handleEvent(capnp::messageToFlatArray(msg));
}
}
OpenSocketCanWidget::OpenSocketCanWidget(QWidget *parent) : AbstractOpenStreamWidget(parent) {
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().toStdString(); });
// Populate devices
refreshDevices();
}
void OpenSocketCanWidget::refreshDevices() {
device_edit->clear();
// Scan /sys/class/net/ for CAN interfaces (type 280 = ARPHRD_CAN)
std::error_code ec;
for (const auto &entry : std::filesystem::directory_iterator("/sys/class/net", ec)) {
std::ifstream type_file(entry.path() / "type");
int type = 0;
if (type_file >> type && type == 280) {
device_edit->addItem(QString::fromStdString(entry.path().filename().string()));
}
}
}
AbstractStream *OpenSocketCanWidget::open() {
try {
return 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 nullptr;
}
}
@@ -0,0 +1,42 @@
#pragma once
#include <QComboBox>
#include "tools/cabana/streams/livestream.h"
struct SocketCanStreamConfig {
std::string device = ""; // TODO: support multiple devices/buses at once
};
class SocketCanStream : public LiveStream {
Q_OBJECT
public:
SocketCanStream(QObject *parent, SocketCanStreamConfig config_ = {});
~SocketCanStream();
static bool available();
inline std::string routeName() const override {
return "Live Streaming From Socket CAN " + config.device;
}
protected:
void streamThread() override;
bool connect();
SocketCanStreamConfig config = {};
int sock_fd = -1;
};
class OpenSocketCanWidget : public AbstractOpenStreamWidget {
Q_OBJECT
public:
OpenSocketCanWidget(QWidget *parent = nullptr);
AbstractStream *open() override;
private:
void refreshDevices();
QComboBox *device_edit;
SocketCanStreamConfig config = {};
};
+69
View File
@@ -0,0 +1,69 @@
#include "tools/cabana/streamselector.h"
#include <filesystem>
#include <QFileDialog>
#include <QLabel>
#include <QPushButton>
#include "tools/cabana/streams/devicestream.h"
#include "tools/cabana/streams/pandastream.h"
#include "tools/cabana/streams/replaystream.h"
#ifdef __linux__
#include "tools/cabana/streams/socketcanstream.h"
#endif
StreamSelector::StreamSelector(QWidget *parent) : QDialog(parent) {
setWindowTitle(tr("Open stream"));
QVBoxLayout *layout = new QVBoxLayout(this);
tab = new QTabWidget(this);
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);
btn_box = new QDialogButtonBox(QDialogButtonBox::Open | QDialogButtonBox::Cancel);
layout->addWidget(btn_box);
addStreamWidget(new OpenReplayWidget, tr("&Replay"));
addStreamWidget(new OpenPandaWidget, tr("&Panda"));
#ifdef __linux__
if (SocketCanStream::available()) {
addStreamWidget(new OpenSocketCanWidget, tr("&SocketCAN"));
}
#endif
addStreamWidget(new OpenDeviceWidget, tr("&Device"));
QObject::connect(btn_box, &QDialogButtonBox::rejected, this, &QDialog::reject);
QObject::connect(btn_box, &QDialogButtonBox::accepted, [=]() {
setEnabled(false);
if (stream_ = ((AbstractOpenStreamWidget *)tab->currentWidget())->open(); stream_) {
accept();
}
setEnabled(true);
});
QObject::connect(file_btn, &QPushButton::clicked, [this]() {
QString fn = QFileDialog::getOpenFileName(this, tr("Open File"), QString::fromStdString(settings.last_dir), "DBC (*.dbc)");
if (!fn.isEmpty()) {
dbc_file->setText(fn);
settings.last_dir = std::filesystem::absolute(fn.toStdString()).parent_path().string();
}
});
}
void StreamSelector::addStreamWidget(AbstractOpenStreamWidget *w, const QString &title) {
tab->addTab(w, title);
auto open_btn = btn_box->button(QDialogButtonBox::Open);
QObject::connect(w, &AbstractOpenStreamWidget::enableOpenButton, open_btn, &QPushButton::setEnabled);
}
+24
View File
@@ -0,0 +1,24 @@
#pragma once
#include <QDialogButtonBox>
#include <QDialog>
#include <QLineEdit>
#include <QTabWidget>
#include "tools/cabana/streams/abstractstream.h"
class StreamSelector : public QDialog {
Q_OBJECT
public:
StreamSelector(QWidget *parent = nullptr);
void addStreamWidget(AbstractOpenStreamWidget *w, const QString &title);
QString dbcFile() const { return dbc_file->text(); }
AbstractStream *stream() const { return stream_; }
private:
AbstractStream *stream_ = nullptr;
QLineEdit *dbc_file;
QTabWidget *tab;
QDialogButtonBox *btn_box;
};
+225
View File
@@ -0,0 +1,225 @@
#undef INFO
#include <filesystem>
#include <sstream>
#include "catch2/catch.hpp"
#include "tools/cabana/dbc/dbcfile.h"
#include "tools/cabana/dbc/dbcmanager.h"
#include "tools/cabana/core/settings.h"
#ifdef QT_CORE_LIB
#include <QColor>
#endif
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") {
std::string fn = std::string(OPENDBC_FILE_PATH) + "/tesla_can.dbc";
DBCFile dbc_origin(fn);
DBCFile dbc_from_generated("", dbc_origin.generateDBC());
REQUIRE(dbc_origin.getMessages().size() == dbc_from_generated.getMessages().size());
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("DBCFile::generateDBC - comment order") {
// Ensure that message comments are followed by signal comments and in the correct order
std::string content = R"(BO_ 160 message_1: 8 EON
SG_ signal_1 : 0|12@1+ (1,0) [0|4095] "unit" XXX
BO_ 162 message_2: 8 EON
SG_ signal_2 : 0|12@1+ (1,0) [0|4095] "unit" XXX
CM_ BO_ 160 "message comment";
CM_ SG_ 160 signal_1 "signal comment";
CM_ BO_ 162 "message comment";
CM_ SG_ 162 signal_2 "signal comment";
)";
DBCFile dbc("", content);
REQUIRE(dbc.generateDBC() == content);
}
TEST_CASE("DBCFile::generateDBC -- preserve original header") {
std::string content = R"(VERSION "1.0"
NS_ :
CM_
BS_:
BU_: EON
BO_ 160 message_1: 8 EON
SG_ signal_1 : 0|12@1+ (1,0) [0|4095] "unit" XXX
CM_ BO_ 160 "message comment";
CM_ SG_ 160 signal_1 "signal comment";
)";
DBCFile dbc("", content);
REQUIRE(dbc.generateDBC() == content);
}
TEST_CASE("DBCFile::generateDBC - escaped quotes") {
std::string content = R"(BO_ 160 message_1: 8 EON
SG_ signal_1 : 0|12@1+ (1,0) [0|4095] "unit" XXX
CM_ BO_ 160 "message comment with \"escaped quotes\"";
CM_ SG_ 160 signal_1 "signal comment with \"escaped quotes\"";
)";
DBCFile dbc("", content);
REQUIRE(dbc.generateDBC() == content);
}
TEST_CASE("parse_dbc") {
std::string 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
";
CM_ BO_ 162 "message comment with \"escaped quotes\"";
CM_ SG_ 162 signal_1 "signal comment with \"escaped quotes\"";
)";
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, std::string>{0, "disabled"});
REQUIRE(sig_1->val_desc[1] == std::pair<double, std::string>{1.2, "initializing"});
REQUIRE(sig_1->val_desc[2] == std::pair<double, std::string>{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");
// escaped quotes
REQUIRE(msg->comment == "message comment with \"escaped quotes\"");
REQUIRE(msg->sigs[0]->comment == "signal comment with \"escaped quotes\"");
}
TEST_CASE("parse_opendbc") {
std::vector<std::string> errors;
for (const auto &entry : std::filesystem::directory_iterator(OPENDBC_FILE_PATH)) {
if (!entry.is_regular_file() || entry.path().extension() != ".dbc") continue;
try {
auto dbc = DBCFile(entry.path().string());
} catch (std::exception &e) {
errors.push_back(e.what());
}
}
std::ostringstream details;
for (const auto &error : errors) details << error << '\n';
INFO(details.str());
REQUIRE(errors.empty());
}
TEST_CASE("DBCManager core callbacks") {
DBCManager manager;
int files_changed = 0;
int signals_added = 0;
int masks_updated = 0;
manager.setCallbacks({
.signal_added = [&](MessageId, const cabana::Signal *) { ++signals_added; },
.file_changed = [&]() { ++files_changed; },
.mask_updated = [&]() { ++masks_updated; },
});
std::string error;
REQUIRE(manager.open(SOURCE_ALL, "test", "BO_ 160 message: 8 XXX\n", &error));
REQUIRE(error.empty());
REQUIRE(files_changed == 1);
cabana::Signal signal{};
signal.name = "speed";
signal.start_bit = 0;
signal.size = 8;
signal.is_little_endian = true;
manager.addSignal({.source = 0, .address = 160}, signal);
REQUIRE(signals_added == 1);
REQUIRE(masks_updated == 1);
REQUIRE(manager.msg({.source = 0, .address = 160})->sig("speed") != nullptr);
}
TEST_CASE("Cabana settings core defaults") {
CabanaSettingsState state;
REQUIRE(state.fps == 10);
REQUIRE(state.chart_range == 180);
REQUIRE(state.drag_direction == CabanaSettingsState::MsbFirst);
REQUIRE(state.recent_files.empty());
}
#ifdef QT_CORE_LIB
TEST_CASE("CabanaColor preserves QColor transformations") {
const std::vector<QColor> colors = {
QColor(102, 86, 169, 64), QColor(0, 187, 255, 128), QColor(255, 0, 0, 128), QColor(45, 120, 75, 255),
};
for (const auto &qt_color : colors) {
CabanaColor color(qt_color.red(), qt_color.green(), qt_color.blue(), qt_color.alpha());
for (int factor : {75, 100, 135, 150, 200}) {
const auto lighter = color.lighter(factor);
const auto qt_lighter = qt_color.lighter(factor);
CHECK(std::abs(lighter.red() - qt_lighter.red()) <= 1);
CHECK(std::abs(lighter.green() - qt_lighter.green()) <= 1);
CHECK(std::abs(lighter.blue() - qt_lighter.blue()) <= 1);
const auto darker = color.darker(factor);
const auto qt_darker = qt_color.darker(factor);
CHECK(std::abs(darker.red() - qt_darker.red()) <= 1);
CHECK(std::abs(darker.green() - qt_darker.green()) <= 1);
CHECK(std::abs(darker.blue() - qt_darker.blue()) <= 1);
}
}
}
#endif
@@ -0,0 +1,7 @@
#define CATCH_CONFIG_RUNNER
#include "catch2/catch.hpp"
int main(int argc, char **argv) {
const int res = Catch::Session().run(argc, argv);
return (res < 0xff ? res : 0xff);
}
+284
View File
@@ -0,0 +1,284 @@
#include "tools/cabana/tools/findsignal.h"
#include <set>
#include <thread>
#include <QFormLayout>
#include <QHBoxLayout>
#include <QHeaderView>
#include <QMenu>
#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 QString::fromStdString(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.empty() ? histories.back() : initial_signals;
filtered_signals.clear();
filtered_signals.reserve(prev_sigs.size());
unsigned int num_threads = std::max(1u, std::thread::hardware_concurrency());
size_t chunk = (prev_sigs.size() + num_threads - 1) / num_threads;
std::vector<std::thread> threads;
for (unsigned int t = 0; t < num_threads && t * chunk < (size_t)prev_sigs.size(); ++t) {
size_t start = t * chunk;
size_t end = std::min(start + chunk, (size_t)prev_sigs.size());
threads.emplace_back([&, start, end]() {
for (size_t i = start; i < end; ++i) {
const auto &s = prev_sigs[i];
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(can->toSeconds((*it)->mono_time), 0, 'f', 3).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});
}
}
});
}
for (auto &th : threads) th.join();
histories.push_back(filtered_signals);
endResetModel();
}
void FindSignalModel::undo() {
if (!histories.empty()) {
beginResetModel();
histories.pop_back();
filtered_signals.clear();
if (!histories.empty()) 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-separated values. Leave blank for all"));
message_layout->addRow(tr("Address"), address_edit = new QLineEdit());
address_edit->setPlaceholderText(tr("comma-separated 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.empty()) {
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() {
std::set<ushort> buses;
for (auto bus : bus_edit->text().trimmed().split(",")) {
bus = bus.trimmed();
if (!bus.isEmpty()) buses.insert(bus.toUShort());
}
std::set<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->toMonoTime(first_sec);
model->last_time = std::numeric_limits<uint64_t>::max();
if (last_sec > 0) {
model->last_time = can->toMonoTime(last_sec);
}
model->initial_signals.clear();
for (const auto &[id, m] : can->lastMessages()) {
if ((buses.empty() || buses.count(id.source)) && (addresses.empty() || addresses.count(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.empty());
message_group->setEnabled(model->histories.empty());
search_btn->setText(model->histories.empty() ? tr("Find") : tr("Find Next"));
reset_btn->setEnabled(!model->histories.empty());
undo_btn->setEnabled(model->histories.size() > 1);
search_btn->setEnabled(model->rowCount() > 0 || model->histories.empty());
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::instance()->push(new AddSigCommand(s.id, s.sig));
emit openMessage(s.id);
}
}
}
+66
View File
@@ -0,0 +1,66 @@
#pragma once
#include <algorithm>
#include <limits>
#include <string>
#include <vector>
#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((int)filtered_signals.size(), 300); }
void search(std::function<bool(double)> cmp);
void reset();
void undo();
std::vector<SearchSignal> filtered_signals;
std::vector<SearchSignal> initial_signals;
std::vector<std::vector<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;
};
@@ -0,0 +1,161 @@
#include "tools/cabana/tools/findsimilarbits.h"
#include <algorithm>
#include <unordered_map>
#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(QString::fromStdString(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);
}
std::vector<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) {
std::unordered_map<uint32_t, std::vector<uint32_t>> mismatches;
std::unordered_map<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);
}
}
}
}
std::vector<mismatched_struct> result;
result.reserve(mismatches.size());
for (auto it = mismatches.begin(); it != mismatches.end(); ++it) {
if (auto cnt = msg_count[it->first]; cnt > (uint32_t)min_msgs_cnt) {
auto &mismatched = it->second;
for (int i = 0; i < (int)mismatched.size(); ++i) {
if (float perc = (mismatched[i] / (double)cnt) * 100; perc < 50) {
result.push_back({it->first, (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;
}
@@ -0,0 +1,36 @@
#pragma once
#include <vector>
#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;
};
std::vector<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;
};
+40
View File
@@ -0,0 +1,40 @@
#include "tools/cabana/tools/routeinfo.h"
#include <QHeaderView>
#include <QScrollBar>
#include <QTableWidget>
#include <QVBoxLayout>
#include "tools/cabana/streams/replaystream.h"
RouteInfoDlg::RouteInfoDlg(QWidget *parent) : QDialog(parent) {
auto *replay = qobject_cast<ReplayStream *>(can)->getReplay();
setWindowTitle(tr("Route: %1").arg(QString::fromStdString(replay->route().name())));
auto *table = new QTableWidget(replay->route().segments().size(), 7, this);
table->setToolTip(tr("Click on a row to seek to the corresponding segment."));
table->setEditTriggers(QAbstractItemView::NoEditTriggers);
table->setSelectionBehavior(QAbstractItemView::SelectRows);
table->setSelectionMode(QAbstractItemView::SingleSelection);
table->setHorizontalHeaderLabels({"", "rlog", "fcam", "ecam", "dcam", "qlog", "qcam"});
table->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents);
table->verticalHeader()->setVisible(false);
table->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
int row = 0;
for (const auto &[seg_num, seg] : replay->route().segments()) {
table->setItem(row, 0, new QTableWidgetItem(QString::number(seg_num)));
table->setItem(row, 1, new QTableWidgetItem(seg.rlog.empty() ? "--" : "Yes"));
table->setItem(row, 2, new QTableWidgetItem(seg.road_cam.empty() ? "--" : "Yes"));
table->setItem(row, 3, new QTableWidgetItem(seg.wide_road_cam.empty() ? "--" : "Yes"));
table->setItem(row, 4, new QTableWidgetItem(seg.driver_cam.empty() ? "--" : "Yes"));
table->setItem(row, 5, new QTableWidgetItem(seg.qlog.empty() ? "--" : "Yes"));
table->setItem(row, 6, new QTableWidgetItem(seg.qcamera.empty() ? "--" : "Yes"));
++row;
}
table->setMinimumWidth(table->horizontalHeader()->length() + table->verticalScrollBar()->sizeHint().width());
table->setMinimumHeight(table->rowHeight(0) * std::min(table->rowCount(), 13) + table->horizontalHeader()->height() + table->frameWidth() * 2);
connect(table, &QTableWidget::itemClicked, [](QTableWidgetItem *item) { can->seekTo(item->row() * 60.0); });
QVBoxLayout *layout = new QVBoxLayout(this);
layout->addWidget(table);
}
+8
View File
@@ -0,0 +1,8 @@
#pragma once
#include <QDialog>
class RouteInfoDlg : public QDialog {
Q_OBJECT
public:
RouteInfoDlg(QWidget *parent = nullptr);
};
@@ -0,0 +1,29 @@
#include "tools/cabana/utils/elidedlabel.h"
#include <QPainter>
#include <QStyleOption>
ElidedLabel::ElidedLabel(QWidget *parent) : ElidedLabel({}, parent) {}
ElidedLabel::ElidedLabel(const QString &text, QWidget *parent) : QLabel(text.trimmed(), parent) {
setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Preferred);
setMinimumWidth(1);
}
void ElidedLabel::resizeEvent(QResizeEvent* event) {
QLabel::resizeEvent(event);
lastText_ = elidedText_ = "";
}
void ElidedLabel::paintEvent(QPaintEvent *event) {
const QString curText = text();
if (curText != lastText_) {
elidedText_ = fontMetrics().elidedText(curText, Qt::ElideRight, contentsRect().width());
lastText_ = curText;
}
QPainter painter(this);
drawFrame(&painter);
QStyleOption opt;
opt.initFrom(this);
style()->drawItemText(&painter, contentsRect(), alignment(), opt.palette, isEnabled(), elidedText_, foregroundRole());
}
@@ -0,0 +1,25 @@
#pragma once
#include <QLabel>
#include <QMouseEvent>
class ElidedLabel : public QLabel {
Q_OBJECT
public:
explicit ElidedLabel(QWidget *parent = 0);
explicit ElidedLabel(const QString &text, QWidget *parent = 0);
signals:
void clicked();
protected:
void paintEvent(QPaintEvent *event) override;
void resizeEvent(QResizeEvent* event) override;
void mouseReleaseEvent(QMouseEvent *event) override {
if (rect().contains(event->pos())) {
emit clicked();
}
}
QString lastText_, elidedText_;
};
+45
View File
@@ -0,0 +1,45 @@
#include "tools/cabana/utils/export.h"
#include <fstream>
#include <iomanip>
#include "tools/cabana/streams/abstractstream.h"
namespace utils {
void exportToCSV(const std::string &file_name, std::optional<MessageId> msg_id) {
std::ofstream stream(file_name, std::ios::trunc);
if (stream) {
stream << "time,addr,bus,data\n";
for (auto e : msg_id ? can->events(*msg_id) : can->allEvents()) {
stream << std::fixed << std::setprecision(3) << can->toSeconds(e->mono_time) << ","
<< "0x" << std::hex << e->address << std::dec << "," << static_cast<int>(e->src) << ",0x"
<< std::uppercase << std::hex << std::setfill('0');
for (int i = 0; i < e->size; ++i) stream << std::setw(2) << static_cast<int>(e->dat[i]);
stream << std::nouppercase << std::dec << "\n";
}
}
}
void exportSignalsToCSV(const std::string &file_name, const MessageId &msg_id) {
std::ofstream stream(file_name, std::ios::trunc);
if (auto msg = dbc()->msg(msg_id); msg && !msg->sigs.empty() && stream) {
stream << "time,addr,bus";
for (auto s : msg->sigs)
stream << "," << s->name.c_str();
stream << "\n";
for (auto e : can->events(msg_id)) {
stream << std::fixed << std::setprecision(3) << can->toSeconds(e->mono_time) << ","
<< "0x" << std::hex << e->address << std::dec << "," << static_cast<int>(e->src);
for (auto s : msg->sigs) {
double value = 0;
s->getValue(e->dat, e->size, &value);
stream << "," << std::fixed << std::setprecision(s->precision) << value;
}
stream << "\n";
}
}
}
} // namespace utils
+11
View File
@@ -0,0 +1,11 @@
#pragma once
#include <optional>
#include <string>
#include "tools/cabana/dbc/dbcmanager.h"
namespace utils {
void exportToCSV(const std::string &file_name, std::optional<MessageId> msg_id = std::nullopt);
void exportSignalsToCSV(const std::string &file_name, const MessageId &msg_id);
} // namespace utils
+542
View File
@@ -0,0 +1,542 @@
#include "tools/cabana/utils/util.h"
#include <algorithm>
#include <cerrno>
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <csignal>
#include <ctime>
#include <filesystem>
#include <limits>
#include <memory>
#include <string>
#include <sys/socket.h>
#include <sys/wait.h>
#include <unistd.h>
#include <QColor>
#include <QFontDatabase>
#include <QPixmapCache>
#include <QPainterPath>
#include <unordered_map>
#include "common/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)
: font_metrics(QApplication::font()), 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 {
if (option.state & QStyle::State_Selected) {
painter->fillRect(option.rect, option.palette.brush(QPalette::Normal, QPalette::Highlight));
}
QRect item_rect = option.rect.adjusted(h_margin, v_margin, -h_margin, -v_margin);
QColor highlighted_color = option.palette.color(QPalette::HighlightedText);
auto text_color = index.data(Qt::ForegroundRole).value<QColor>();
bool inactive = text_color.isValid();
if (!inactive) {
text_color = option.palette.color(QPalette::Text);
}
auto data = index.data(BytesRole);
if (!data.isValid()) {
painter->setFont(option.font);
painter->setPen(option.state & QStyle::State_Selected ? highlighted_color : text_color);
QString text = font_metrics.elidedText(index.data(Qt::DisplayRole).toString(), Qt::ElideRight, item_rect.width());
painter->drawText(item_rect, Qt::AlignLeft | Qt::AlignVCenter, text);
return;
}
// Paint hex column
const auto &bytes = *static_cast<std::vector<uint8_t> *>(data.value<void *>());
const auto &colors = *static_cast<std::vector<CabanaColor> *>(index.data(ColorsRole).value<void *>());
painter->setFont(fixed_font);
const QPen text_pen(option.state & QStyle::State_Selected ? highlighted_color : text_color);
const QPoint pt = item_rect.topLeft();
for (int i = 0; i < bytes.size(); ++i) {
int row = !multiple_lines ? 0 : i / 8;
int column = !multiple_lines ? i : i % 8;
QRect r({pt.x() + column * byte_size.width(), pt.y() + row * byte_size.height()}, byte_size);
if (!inactive && 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, toQColor(colors[i]));
} else {
painter->setPen(text_pen);
}
utils::drawStaticText(painter, r, hex_text_table[bytes[i]]);
}
}
// 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() {
if (::socketpair(AF_UNIX, SOCK_STREAM, 0, sig_fd)) {
qFatal("Couldn't create TERM socketpair");
}
waiter = std::thread([this]() {
int tmp = 0;
while (::read(sig_fd[1], &tmp, sizeof(tmp)) < 0) {
if (errno != EINTR) return;
}
if (shutting_down.load()) return;
// Marshal exit onto the GUI thread (qApp methods are not thread-safe).
QMetaObject::invokeMethod(qApp, []() {
printf("\nexiting...\n");
qApp->closeAllWindows();
qApp->exit();
}, Qt::QueuedConnection);
});
std::signal(SIGINT, signalHandler);
std::signal(SIGTERM, UnixSignalHandler::signalHandler);
}
UnixSignalHandler::~UnixSignalHandler() {
shutting_down.store(true);
int dummy = 0;
(void)!::write(sig_fd[0], &dummy, sizeof(dummy));
if (waiter.joinable()) waiter.join();
::close(sig_fd[0]);
::close(sig_fd[1]);
}
void UnixSignalHandler::signalHandler(int s) {
(void)!::write(sig_fd[0], &s, sizeof(s));
}
// NameValidator
NameValidator::NameValidator(QObject *parent) : QValidator(parent) {}
QValidator::State NameValidator::validate(QString &input, int &pos) const {
Q_UNUSED(pos);
input.replace(' ', '_');
if (input.isEmpty()) return QValidator::Intermediate;
for (const QChar &c : input) {
if (!c.isLetterOrNumber() && c != '_') return QValidator::Invalid;
}
return QValidator::Acceptable;
}
// NodeValidator
NodeValidator::NodeValidator(QObject *parent) : QValidator(parent) {}
QValidator::State NodeValidator::validate(QString &input, int &pos) const {
Q_UNUSED(pos);
if (input.isEmpty()) return QValidator::Intermediate;
// Match ^\w+(,\w+)*$ ; a trailing comma is Intermediate (user still typing).
bool need_word = true;
for (const QChar &c : input) {
if (c.isLetterOrNumber() || c == '_') {
need_word = false;
} else if (c == ',' && !need_word) {
need_word = true;
} else {
return QValidator::Invalid;
}
}
return need_word ? QValidator::Intermediate : QValidator::Acceptable;
}
// NonWhitespaceValidator
NonWhitespaceValidator::NonWhitespaceValidator(QObject *parent) : QValidator(parent) {}
QValidator::State NonWhitespaceValidator::validate(QString &input, int &pos) const {
Q_UNUSED(pos);
if (input.isEmpty()) return QValidator::Intermediate;
for (const QChar &c : input) {
if (c.isSpace()) return QValidator::Invalid;
}
return QValidator::Acceptable;
}
// IpAddressValidator
IpAddressValidator::IpAddressValidator(QObject *parent) : QValidator(parent) {}
QValidator::State IpAddressValidator::validate(QString &input, int &pos) const {
Q_UNUSED(pos);
if (input.isEmpty()) return QValidator::Intermediate;
int dots = 0;
int value = 0;
bool has_digit = false;
for (const QChar &c : input) {
if (c.isDigit()) {
value = has_digit ? value * 10 + c.digitValue() : c.digitValue();
if (value > 255) return QValidator::Invalid;
has_digit = true;
} else if (c == '.') {
if (!has_digit || dots >= 3) return QValidator::Invalid;
++dots;
has_digit = false;
value = 0;
} else {
return QValidator::Invalid;
}
}
return (dots == 3 && has_digit) ? QValidator::Acceptable : QValidator::Intermediate;
}
DoubleValidator::DoubleValidator(QObject *parent) : QValidator(parent) {}
QValidator::State DoubleValidator::validate(QString &input, int &pos) const {
Q_UNUSED(pos);
if (input.isEmpty()) return QValidator::Intermediate;
// Match QString::toDouble(): C locale, no hex floats / inf / nan.
const std::string bytes = input.toLatin1().toStdString();
// strtod accepts 0x… hex floats and p-exponents; QString::toDouble does not.
if (bytes.find_first_of("xXpP") != std::string::npos) {
return QValidator::Invalid;
}
const char *start = bytes.c_str();
char *end = nullptr;
const double value = std::strtod(start, &end);
if (end == start) {
// Still typing a sign, decimal point, or exponent prefix.
if (input == "-" || input == "+" || input == "." || input == "-." || input == "+.") {
return QValidator::Intermediate;
}
return QValidator::Invalid;
}
if (*end == '\0') {
// Reject inf/nan (strtod accepts them; QDoubleValidator / toDouble path should not).
return std::isfinite(value) ? QValidator::Acceptable : QValidator::Invalid;
}
// Partial exponent / trailing sign while typing (e.g. "1e", "1e-", "1.").
for (const char *p = end; *p; ++p) {
const char c = *p;
if (!(c == 'e' || c == 'E' || c == '+' || c == '-' || c == '.' || (c >= '0' && c <= '9'))) {
return QValidator::Invalid;
}
}
return QValidator::Intermediate;
}
namespace utils {
std::string homePath() {
const char *home = ::getenv("HOME");
return home ? home : "";
}
std::filesystem::path configPath() {
#ifdef __APPLE__
return std::filesystem::path(homePath()) / "Library/Preferences";
#else
const char *xdg = ::getenv("XDG_CONFIG_HOME");
return (xdg && xdg[0]) ? std::filesystem::path(xdg) : std::filesystem::path(homePath()) / ".config";
#endif
}
#ifdef __APPLE__
static const char *clipboard_read_cmds[] = {"pbpaste"};
static const char *clipboard_write_cmds[] = {"pbcopy"};
#else
static const char *clipboard_read_cmds[] = {"wl-paste --no-newline 2>/dev/null", "xclip -selection clipboard -o 2>/dev/null", "xsel -ob 2>/dev/null"};
static const char *clipboard_write_cmds[] = {"wl-copy 2>/dev/null", "xclip -selection clipboard 2>/dev/null", "xsel -ib 2>/dev/null"};
#endif
bool getClipboardText(std::string *text) {
text->clear();
bool has_tool = false;
for (const char *cmd : clipboard_read_cmds) {
FILE *f = ::popen(cmd, "r");
if (!f) continue;
std::string out;
char buf[4096];
for (size_t n; (n = ::fread(buf, 1, sizeof(buf), f)) > 0;) out.append(buf, n);
int status = ::pclose(f);
if (status == 0) {
*text = std::move(out);
return true;
}
has_tool |= WIFEXITED(status) && WEXITSTATUS(status) != 127; // 127: command not found
}
return has_tool; // tool present but clipboard empty
}
bool setClipboardText(const std::string &text) {
std::signal(SIGPIPE, SIG_IGN);
for (const char *cmd : clipboard_write_cmds) {
FILE *f = ::popen(cmd, "w");
if (!f) continue;
size_t written = ::fwrite(text.data(), 1, text.size(), f);
if (::pclose(f) == 0 && written == text.size()) return true;
}
return false;
}
bool isDarkTheme() {
QColor windowColor = QApplication::palette().color(QPalette::Window);
return windowColor.lightness() < 128;
}
QPixmap icon(const QString &id) {
bool dark_theme = isDarkTheme();
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) {
if (absolute_time) {
const auto ms_total = static_cast<int64_t>(std::llround(sec * 1000.0));
const std::time_t secs = static_cast<std::time_t>(ms_total / 1000);
int millis = static_cast<int>(ms_total % 1000);
if (millis < 0) millis = -millis;
std::tm tm{};
localtime_r(&secs, &tm);
char buf[64];
std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &tm);
if (include_milliseconds) {
return QString::asprintf("%s.%03d", buf, millis);
}
return QString::fromUtf8(buf);
}
// Relative duration (not wall-clock).
const bool show_hours = sec > 60 * 60;
int total_ms = static_cast<int>(std::llround(std::max(0.0, sec) * 1000.0));
const int hours = total_ms / (3600 * 1000);
const int minutes = (total_ms / (60 * 1000)) % 60;
const int seconds = (total_ms / 1000) % 60;
const int millis = total_ms % 1000;
if (show_hours) {
return include_milliseconds ? QString::asprintf("%02d:%02d:%02d.%03d", hours, minutes, seconds, millis)
: QString::asprintf("%02d:%02d:%02d", hours, minutes, seconds);
}
return include_milliseconds ? QString::asprintf("%02d:%02d.%03d", minutes, seconds, millis)
: QString::asprintf("%02d:%02d", minutes, seconds);
}
} // 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(QString::fromStdString(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");
}
void sigTermHandler(int s) {
std::signal(s, SIG_DFL);
qApp->quit();
}
void initApp(int argc, char *argv[], bool disable_hidpi) {
// setup signal handlers to exit gracefully
std::signal(SIGINT, sigTermHandler);
std::signal(SIGTERM, sigTermHandler);
std::filesystem::path app_dir;
#ifdef __APPLE__
// Get the devicePixelRatio, and scale accordingly to maintain 1:1 rendering
QApplication tmp(argc, argv);
app_dir = QCoreApplication::applicationDirPath().toStdString();
if (disable_hidpi) {
qputenv("QT_SCALE_FACTOR", QString::number(1.0 / tmp.devicePixelRatio()).toLocal8Bit());
}
#else
app_dir = std::filesystem::path(util::readlink("/proc/self/exe")).parent_path();
#endif
qputenv("QT_DBL_CLICK_DIST", "150");
// ensure the current dir matches the exectuable's directory
std::error_code ec;
std::filesystem::current_path(app_dir, ec);
}
// embedded at build time from the bootstrap_icons package (see SConscript)
extern const unsigned char bootstrap_icons_svg[];
extern const size_t bootstrap_icons_svg_len;
static std::unordered_map<std::string, std::string> load_bootstrap_icons() {
std::unordered_map<std::string, std::string> icons;
const std::string content(reinterpret_cast<const char *>(bootstrap_icons_svg), bootstrap_icons_svg_len);
const std::string sym_open = "<symbol ";
const std::string sym_close = "</symbol>";
const std::string id_attr = "id=\"";
size_t pos = 0;
while ((pos = content.find(sym_open, pos)) != std::string::npos) {
size_t end = content.find(sym_close, pos);
if (end == std::string::npos) break;
end += sym_close.size();
// extract id
size_t id_start = content.find(id_attr, pos);
if (id_start != std::string::npos && id_start < end) {
id_start += id_attr.size();
size_t id_end = content.find('"', id_start);
if (id_end != std::string::npos && id_end < end) {
std::string id = content.substr(id_start, id_end - id_start);
std::string svg_str = content.substr(pos, end - pos);
// replace <symbol with <svg, </symbol> with </svg>
svg_str.replace(0, 7, "<svg"); // "<symbol" (7) -> "<svg" (4)
svg_str.replace(svg_str.size() - 9, 9, "</svg>"); // "</symbol>" (9) -> "</svg>" (6)
icons[id] = std::move(svg_str);
}
}
pos = end;
}
return icons;
}
QPixmap bootstrapPixmap(const QString &id) {
static auto icons = load_bootstrap_icons();
QPixmap pixmap;
auto it = icons.find(id.toStdString());
if (it != icons.end()) {
pixmap.loadFromData((const uchar *)it->second.data(), it->second.size(), "svg");
}
return pixmap;
}
+220
View File
@@ -0,0 +1,220 @@
#pragma once
#include <array>
#include <atomic>
#include <cmath>
#include <filesystem>
#include <string>
#include <thread>
#include <vector>
#include <utility>
#include <QApplication>
#include <QColor>
#include <QFont>
#include <QFontMetrics>
#include <QPainter>
#include <QStaticText>
#include <QStringBuilder>
#include <QStyledItemDelegate>
#include <QToolButton>
#include <QValidator>
#include "tools/cabana/dbc/dbc.h"
#include "tools/cabana/settings.h"
inline QColor toQColor(const CabanaColor &color) {
return QColor(color.r, color.g, color.b, color.a);
}
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;
QFontMetrics font_metrics;
QFont fixed_font;
QSize byte_size = {};
bool multiple_lines = false;
int h_margin, v_margin;
};
// Accepts a single identifier: one or more [A-Za-z0-9_], spaces rewritten to '_'.
class NameValidator : public QValidator {
Q_OBJECT
public:
NameValidator(QObject *parent=nullptr);
QValidator::State validate(QString &input, int &pos) const override;
};
// Accepts comma-separated identifiers: \w+(,\w+)*
class NodeValidator : public QValidator {
Q_OBJECT
public:
NodeValidator(QObject *parent=nullptr);
QValidator::State validate(QString &input, int &pos) const override;
};
// Accepts one or more non-whitespace characters (\S+).
class NonWhitespaceValidator : public QValidator {
Q_OBJECT
public:
NonWhitespaceValidator(QObject *parent=nullptr);
QValidator::State validate(QString &input, int &pos) const override;
};
// Accepts a dotted IPv4 address (0-255 per octet).
class IpAddressValidator : public QValidator {
Q_OBJECT
public:
IpAddressValidator(QObject *parent=nullptr);
QValidator::State validate(QString &input, int &pos) const override;
};
// C-locale floating-point validator (matches QString::toDouble).
class DoubleValidator : public QValidator {
Q_OBJECT
public:
DoubleValidator(QObject *parent = nullptr);
QValidator::State validate(QString &input, int &pos) const override;
};
namespace utils {
QPixmap icon(const QString &id);
std::string homePath();
std::filesystem::path configPath();
bool getClipboardText(std::string *text); // false if no clipboard tool is available
bool setClipboardText(const std::string &text);
bool isDarkTheme();
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') {
static const char digits[] = "0123456789ABCDEF";
QString hex;
hex.reserve(dat.size() * (separator ? 3 : 2));
for (size_t i = 0; i < dat.size(); ++i) {
if (separator && i) hex += QLatin1Char(separator);
hex += QLatin1Char(digits[dat[i] >> 4]);
hex += QLatin1Char(digits[dat[i] & 0xf]);
}
return hex;
}
// boundary conversions for the remaining Qt byte-array based state APIs
template <typename T>
std::vector<uint8_t> toBytes(const T &dat) { return {dat.begin(), dat.end()}; }
inline auto qbytes(const std::vector<uint8_t> &dat) {
return decltype(QString().toUtf8())((const char *)dat.data(), (int)dat.size());
}
}
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();
};
// Watches SIGINT/SIGTERM via a self-pipe and a dedicated waiter thread
// (no Qt notifiers/timers). Exit is marshaled onto the GUI thread.
class UnixSignalHandler {
public:
UnixSignalHandler();
~UnixSignalHandler();
static void signalHandler(int s);
private:
inline static int sig_fd[2] = {};
std::atomic<bool> shutting_down{false};
std::thread waiter;
};
int num_decimals(double num);
QString signalToolTip(const cabana::Signal *sig);
inline QString toHexString(int value) { return QString("0x%1").arg(QString::number(value, 16).toUpper(), 2, '0'); }
void initApp(int argc, char *argv[], bool disable_hidpi = true);
QPixmap bootstrapPixmap(const QString &id);
+433
View File
@@ -0,0 +1,433 @@
#include "tools/cabana/videowidget.h"
#include <algorithm>
#include <thread>
#include <QAction>
#include <QActionGroup>
#include <QMenu>
#include <QMouseEvent>
#include <QPainter>
#include <QStyleOptionSlider>
#include <QVBoxLayout>
#include "tools/cabana/tools/routeinfo.h"
const int MIN_VIDEO_HEIGHT = 100;
const int THUMBNAIL_MARGIN = 3;
// Indexed by TimelineType: None, Engaged, AlertInfo, AlertWarning, AlertCritical, UserBookmark
static const QColor timeline_colors[] = {
QColor(111, 143, 175),
QColor(0, 163, 108),
Qt::green,
QColor(255, 195, 0),
QColor(199, 0, 57),
Qt::magenta,
};
static Replay *getReplay() {
auto stream = qobject_cast<ReplayStream *>(can);
return stream ? stream->getReplay() : nullptr;
}
VideoWidget::VideoWidget(QWidget *parent) : QFrame(parent) {
setFrameStyle(QFrame::StyledPanel | QFrame::Plain);
auto main_layout = new QVBoxLayout(this);
main_layout->setContentsMargins(0, 0, 0, 0);
main_layout->setSpacing(0);
if (!can->liveStreaming())
main_layout->addWidget(createCameraWidget());
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);
QObject::connect(can, &AbstractStream::seeking, this, &VideoWidget::updateState);
QObject::connect(can, &AbstractStream::timeRangeChanged, this, &VideoWidget::timeRangeChanged);
updatePlayBtnState();
setWhatsThis(tr(R"(
<b>Video</b><br />
<!-- TODO: add description 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">&nbsp;space&nbsp;</span>
)").arg(timeline_colors[(int)TimelineType::None].name(),
timeline_colors[(int)TimelineType::Engaged].name(),
timeline_colors[(int)TimelineType::UserBookmark].name(),
timeline_colors[(int)TimelineType::AlertInfo].name(),
timeline_colors[(int)TimelineType::AlertWarning].name(),
timeline_colors[(int)TimelineType::AlertCritical].name()));
}
void VideoWidget::createPlaybackController() {
QToolBar *toolbar = new QToolBar(this);
layout()->addWidget(toolbar);
int icon_size = style()->pixelMetric(QStyle::PM_SmallIconSize);
toolbar->setIconSize({icon_size, icon_size});
toolbar->addAction(utils::icon("rewind"), tr("Seek backward"), []() { can->seekTo(can->currentSec() - 1); });
play_toggle_action = toolbar->addAction(utils::icon("play"), tr("Play"), []() { can->pause(!can->isPaused()); });
toolbar->addAction(utils::icon("fast-forward"), tr("Seek forward"), []() { can->seekTo(can->currentSec() + 1); });
if (can->liveStreaming()) {
skip_to_end_action = toolbar->addAction(utils::icon("skip-end"), tr("Skip to the end"), this, [this]() {
// set speed to 1.0
speed_btn->menu()->actions()[7]->setChecked(true);
can->pause(false);
can->seekTo(can->maxSeconds() + 1);
});
}
time_display_action = toolbar->addAction("", this, [this]() {
settings.absolute_time = !settings.absolute_time;
time_display_action->setToolTip(settings.absolute_time ? tr("Elapsed time") : tr("Absolute time"));
updateState();
});
QWidget *spacer = new QWidget();
spacer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred);
toolbar->addWidget(spacer);
if (!can->liveStreaming()) {
toolbar->addAction(utils::icon("repeat"), tr("Loop playback"), this, &VideoWidget::loopPlaybackClicked);
createSpeedDropdown(toolbar);
toolbar->addSeparator();
toolbar->addAction(utils::icon("info-circle"), tr("View route details"), this, &VideoWidget::showRouteInfo);
} else {
createSpeedDropdown(toolbar);
}
}
void VideoWidget::createSpeedDropdown(QToolBar *toolbar) {
toolbar->addWidget(speed_btn = new QToolButton(this));
speed_btn->setMenu(new QMenu(speed_btn));
speed_btn->setPopupMode(QToolButton::InstantPopup);
QActionGroup *speed_group = new QActionGroup(this);
speed_group->setExclusive(true);
for (float speed : {0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 0.8, 1., 2., 3., 5.}) {
auto act = speed_btn->menu()->addAction(QString("%1x").arg(speed), this, [this, speed]() {
can->setSpeed(speed);
speed_btn->setText(QString("%1x ").arg(speed));
});
speed_group->addAction(act);
act->setCheckable(true);
if (speed == 1.0) {
act->setChecked(true);
act->trigger();
}
}
QFont font = speed_btn->font();
font.setBold(true);
speed_btn->setFont(font);
speed_btn->setMinimumWidth(speed_btn->fontMetrics().horizontalAdvance("0.05x ") + style()->pixelMetric(QStyle::PM_MenuButtonIndicator));
}
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);
l->addWidget(cam_widget = new StreamCameraView("camerad", VISION_STREAM_ROAD));
cam_widget->setMinimumHeight(MIN_VIDEO_HEIGHT);
cam_widget->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::MinimumExpanding);
l->addWidget(slider = new Slider(w));
slider->setSingleStep(0);
slider->setTimeRange(can->minSeconds(), can->maxSeconds());
QObject::connect(slider, &QSlider::sliderReleased, [this]() { can->seekTo(slider->currentSecond()); });
QObject::connect(can, &AbstractStream::paused, cam_widget, qOverload<>(&StreamCameraView::update));
QObject::connect(can, &AbstractStream::eventsMerged, this, [this]() { slider->update(); });
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());
});
QObject::connect(static_cast<ReplayStream*>(can), &ReplayStream::qLogLoaded, cam_widget, &StreamCameraView::parseQLog, Qt::QueuedConnection);
slider->installEventFilter(this);
return w;
}
void VideoWidget::vipcAvailableStreamsUpdated(std::set<VisionStreamType> streams) {
static const QString stream_names[] = {"Road camera", "Driver camera", "Wide road 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() {
bool is_looping = getReplay()->loop();
getReplay()->setLoop(!is_looping);
qobject_cast<QAction*>(sender())->setIcon(utils::icon(!is_looping ? "repeat" : "repeat-1"));
}
void VideoWidget::timeRangeChanged() {
const auto time_range = can->timeRange();
if (can->liveStreaming()) {
skip_to_end_action->setEnabled(!time_range.has_value());
return;
}
time_range ? slider->setTimeRange(time_range->first, time_range->second)
: slider->setTimeRange(can->minSeconds(), can->maxSeconds());
updateState();
}
QString VideoWidget::formatTime(double sec, bool include_milliseconds) {
if (settings.absolute_time)
sec += std::chrono::duration<double>(can->beginDateTime().time_since_epoch()).count();
return utils::formatSeconds(sec, include_milliseconds, settings.absolute_time);
}
void VideoWidget::updateState() {
if (slider) {
if (!slider->isSliderDown()) {
slider->setCurrentSecond(can->currentSec());
}
if (camera_tab->count() == 0) { // No streams available
cam_widget->update(); // Manually refresh to show alert events
}
time_display_action->setText(QString("%1 / %2").arg(formatTime(can->currentSec(), true),
formatTime(slider->maximum() / slider->factor)));
} else {
time_display_action->setText(formatTime(can->currentSec(), true));
}
}
void VideoWidget::updatePlayBtnState() {
play_toggle_action->setIcon(utils::icon(can->isPaused() ? "play" : "pause"));
play_toggle_action->setToolTip(can->isPaused() ? tr("Play") : tr("Pause"));
}
void VideoWidget::showThumbnail(double seconds) {
if (can->liveStreaming()) return;
cam_widget->thumbnail_dispaly_time = seconds;
slider->thumbnail_dispaly_time = seconds;
cam_widget->update();
slider->update();
}
void VideoWidget::showRouteInfo() {
RouteInfoDlg *route_info = new RouteInfoDlg(this);
route_info->setAttribute(Qt::WA_DeleteOnClose);
route_info->show();
}
bool VideoWidget::eventFilter(QObject *obj, QEvent *event) {
if (event->type() == QEvent::MouseMove) {
auto [min_sec, max_sec] = can->timeRange().value_or(std::make_pair(can->minSeconds(), can->maxSeconds()));
showThumbnail(min_sec + static_cast<QMouseEvent *>(event)->pos().x() * (max_sec - min_sec) / slider->width());
} else if (event->type() == QEvent::Leave) {
showThumbnail(-1);
}
return false;
}
// Slider
Slider::Slider(QWidget *parent) : QSlider(Qt::Horizontal, parent) {
setMouseTracking(true);
}
void Slider::paintEvent(QPaintEvent *ev) {
QPainter p(this);
QStyleOptionSlider opt;
initStyleOption(&opt);
QRect handle_rect = style()->subControlRect(QStyle::CC_Slider, &opt, QStyle::SC_SliderHandle, this);
QRect groove_rect = style()->subControlRect(QStyle::CC_Slider, &opt, QStyle::SC_SliderGroove, this);
// Adjust groove height to match handle height
int handle_height = handle_rect.height();
groove_rect.setHeight(handle_height * 0.5);
groove_rect.moveCenter(QPoint(groove_rect.center().x(), rect().center().y()));
p.fillRect(groove_rect, 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;
QRect r = groove_rect;
r.setLeft(((std::max(min, begin) - min) / (max - min)) * width());
r.setRight(((std::min(max, end) - min) / (max - min)) * width());
p.fillRect(r, color);
};
if (auto replay = getReplay()) {
for (const auto &entry : *replay->getTimeline()) {
fillRange(entry.start_time, entry.end_time, timeline_colors[(int)entry.type]);
}
QColor empty_color = palette().color(QPalette::Window);
empty_color.setAlpha(160);
const auto event_data = replay->getEventData();
for (const auto &[n, _] : replay->route().segments()) {
if (!event_data->isSegmentLoaded(n))
fillRange(n * 60.0, (n + 1) * 60.0, empty_color);
}
}
opt.minimum = minimum();
opt.maximum = maximum();
opt.subControls = QStyle::SC_SliderHandle;
opt.sliderPosition = value();
style()->drawComplexControl(QStyle::CC_Slider, &opt, &p);
if (thumbnail_dispaly_time >= 0) {
int left = (thumbnail_dispaly_time - min) * width() / (max - min) - 1;
QRect rc(left, rect().top() + 1, 2, rect().height() - 2);
p.setBrush(palette().highlight());
p.setPen(Qt::NoPen);
p.drawRoundedRect(rc, 1.5, 1.5);
}
}
void Slider::mousePressEvent(QMouseEvent *e) {
QSlider::mousePressEvent(e);
if (e->button() == Qt::LeftButton && !isSliderDown()) {
setValue(minimum() + ((maximum() - minimum()) * e->x()) / width());
emit sliderReleased();
}
}
// StreamCameraView
StreamCameraView::StreamCameraView(std::string stream_name, VisionStreamType stream_type, QWidget *parent)
: CameraWidget(stream_name, stream_type, parent) {
}
void StreamCameraView::parseQLog(std::shared_ptr<LogReader> qlog) {
std::mutex mutex;
const auto &events = qlog->events;
unsigned int num_threads = std::max(1u, std::thread::hardware_concurrency());
size_t chunk = (events.size() + num_threads - 1) / num_threads;
std::vector<std::thread> threads;
for (unsigned int t = 0; t < num_threads && t * chunk < events.size(); ++t) {
size_t start = t * chunk;
size_t end = std::min(start + chunk, events.size());
threads.emplace_back([this, &mutex, &events, start, end]() {
for (size_t i = start; i < end; ++i) {
const Event &e = events[i];
if (e.which == cereal::Event::Which::THUMBNAIL) {
capnp::FlatArrayMessageReader reader(e.data);
auto thumb_data = reader.getRoot<cereal::Event>().getThumbnail();
auto image_data = thumb_data.getThumbnail();
if (QPixmap thumb; thumb.loadFromData(image_data.begin(), image_data.size(), "jpeg")) {
QPixmap generated_thumb = generateThumbnail(thumb, can->toSeconds(thumb_data.getTimestampEof()));
std::lock_guard lock(mutex);
thumbnails[thumb_data.getTimestampEof()] = generated_thumb;
big_thumbnails[thumb_data.getTimestampEof()] = thumb;
}
}
}
});
}
for (auto &th : threads) th.join();
update();
}
void StreamCameraView::paintEvent(QPaintEvent *event) {
CameraWidget::paintEvent(event);
QPainter p(this);
bool scrubbing = false;
if (thumbnail_dispaly_time >= 0) {
scrubbing = can->isPaused();
scrubbing ? drawScrubThumbnail(p) : drawThumbnail(p);
}
if (auto alert = getReplay()->findAlertAtTime(scrubbing ? thumbnail_dispaly_time : can->currentSec())) {
drawAlert(p, rect(), *alert);
}
if (can->isPaused()) {
p.setPen(QColor(200, 200, 200, static_cast<int>(255 * 0.7f)));
p.setFont(QFont(font().family(), 16, QFont::Bold));
p.drawText(rect(), Qt::AlignCenter, tr("PAUSED"));
}
}
QPixmap StreamCameraView::generateThumbnail(QPixmap thumb, double seconds) {
QPixmap scaled = thumb.scaledToHeight(MIN_VIDEO_HEIGHT - THUMBNAIL_MARGIN * 2, Qt::SmoothTransformation);
QPainter p(&scaled);
p.setPen(QPen(palette().color(QPalette::BrightText), 2));
p.drawRect(scaled.rect());
if (auto alert = getReplay()->findAlertAtTime(seconds)) {
p.setFont(QFont(font().family(), 10));
drawAlert(p, scaled.rect(), *alert);
}
return scaled;
}
void StreamCameraView::drawScrubThumbnail(QPainter &p) {
p.fillRect(rect(), Qt::black);
auto it = big_thumbnails.lower_bound(can->toMonoTime(thumbnail_dispaly_time));
if (it != big_thumbnails.end()) {
QPixmap scaled_thumb = it->second.scaled(rect().size(), Qt::KeepAspectRatio, Qt::SmoothTransformation);
QRect thumb_rect(rect().center() - scaled_thumb.rect().center(), scaled_thumb.size());
p.drawPixmap(thumb_rect.topLeft(), scaled_thumb);
drawTime(p, thumb_rect, thumbnail_dispaly_time);
}
}
void StreamCameraView::drawThumbnail(QPainter &p) {
auto it = thumbnails.lower_bound(can->toMonoTime(thumbnail_dispaly_time));
if (it != thumbnails.end()) {
const QPixmap &thumb = it->second;
auto [min_sec, max_sec] = can->timeRange().value_or(std::make_pair(can->minSeconds(), can->maxSeconds()));
int pos = (thumbnail_dispaly_time - min_sec) * width() / (max_sec - min_sec);
int x = std::clamp(pos - thumb.width() / 2, THUMBNAIL_MARGIN, width() - thumb.width() - THUMBNAIL_MARGIN + 1);
int y = height() - thumb.height() - THUMBNAIL_MARGIN;
p.drawPixmap(x, y, thumb);
drawTime(p, QRect{x, y, thumb.width(), thumb.height()}, thumbnail_dispaly_time);
}
}
void StreamCameraView::drawTime(QPainter &p, const QRect &rect, double seconds) {
p.setPen(palette().color(QPalette::BrightText));
p.setFont(QFont(font().family(), 10));
p.drawText(rect.adjusted(0, 0, 0, -THUMBNAIL_MARGIN), Qt::AlignHCenter | Qt::AlignBottom, QString::number(seconds, 'f', 3));
}
void StreamCameraView::drawAlert(QPainter &p, const QRect &rect, const Timeline::Entry &alert) {
p.setPen(QPen(palette().color(QPalette::BrightText), 2));
QColor color = timeline_colors[int(alert.type)];
color.setAlphaF(0.5);
QString text = QString::fromStdString(alert.text1);
if (!alert.text2.empty()) text += "\n" + QString::fromStdString(alert.text2);
QRect text_rect = rect.adjusted(1, 1, -1, -1);
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);
}
+81
View File
@@ -0,0 +1,81 @@
#pragma once
#include <map>
#include <memory>
#include <set>
#include <string>
#include <utility>
#include <QFrame>
#include <QSlider>
#include <QToolBar>
#include <QTabBar>
#include "tools/cabana/cameraview.h"
#include "tools/cabana/utils/util.h"
#include "tools/replay/logreader.h"
#include "tools/cabana/streams/replaystream.h"
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) { setRange(min * factor, max * factor); }
void mousePressEvent(QMouseEvent *e) override;
void paintEvent(QPaintEvent *ev) override;
const double factor = 1000.0;
double thumbnail_dispaly_time = -1;
};
class StreamCameraView : public CameraWidget {
Q_OBJECT
public:
StreamCameraView(std::string stream_name, VisionStreamType stream_type, QWidget *parent = nullptr);
void paintEvent(QPaintEvent *event) override;
void parseQLog(std::shared_ptr<LogReader> qlog);
private:
QPixmap generateThumbnail(QPixmap thumbnail, double seconds);
void drawAlert(QPainter &p, const QRect &rect, const Timeline::Entry &alert);
void drawThumbnail(QPainter &p);
void drawScrubThumbnail(QPainter &p);
void drawTime(QPainter &p, const QRect &rect, double seconds);
std::map<uint64_t, QPixmap> big_thumbnails;
std::map<uint64_t, QPixmap> thumbnails;
double thumbnail_dispaly_time = -1;
friend class VideoWidget;
};
class VideoWidget : public QFrame {
Q_OBJECT
public:
VideoWidget(QWidget *parnet = nullptr);
void showThumbnail(double seconds);
protected:
bool eventFilter(QObject *obj, QEvent *event) override;
QString formatTime(double sec, bool include_milliseconds = false);
void timeRangeChanged();
void updateState();
void updatePlayBtnState();
QWidget *createCameraWidget();
void createPlaybackController();
void createSpeedDropdown(QToolBar *toolbar);
void loopPlaybackClicked();
void vipcAvailableStreamsUpdated(std::set<VisionStreamType> streams);
void showRouteInfo();
StreamCameraView *cam_widget;
QAction *time_display_action = nullptr;
QAction *play_toggle_action = nullptr;
QToolButton *speed_btn = nullptr;
QAction *skip_to_end_action = nullptr;
Slider *slider = nullptr;
QTabBar *camera_tab = nullptr;
};
+66
View File
@@ -0,0 +1,66 @@
# Camera stream
`compressed_vipc.py` connects to a remote device running openpilot, decodes the video streams, and republishes them over VisionIPC.
## Usage
### On the device
SSH into the device and run following in separate terminals:
`cd /data/openpilot && ./openpilot/cereal/messaging/bridge`
`cd /data/openpilot/system/loggerd && ./encoderd`
`cd /data/openpilot/system/camerad && ./camerad`
Note that both the device and your PC must be on the same openpilot commit.
Alternatively paste this as a single command:
```
(
cd /data/openpilot
./openpilot/cereal/messaging/bridge &
cd /data/openpilot/system/camerad/
./camerad &
cd /data/openpilot/system/loggerd/
./encoderd &
wait
) ; trap 'kill $(jobs -p)' SIGINT
```
Ctrl+C will stop all three processes.
### On the PC
Decode the stream with `compressed_vipc.py`:
```cd ~/openpilot/tools/camerastream && ./compressed_vipc.py <ip>```
To actually display the stream, run `watch3` in separate terminal:
```cd ~/openpilot/selfdrive/ui/ && ./watch3.py```
## compressed_vipc.py usage
```
$ python3 compressed_vipc.py -h
usage: compressed_vipc.py [-h] [--cams CAMS] [--server SERVER] [--silent] addr
Decode video streams and broadcast on VisionIPC
positional arguments:
addr Address of comma three
options:
-h, --help show this help message and exit
--cams CAMS Cameras to decode
--server SERVER choose vipc server name
--silent Suppress debug output
```
## Example:
```
cd ~/openpilot/tools/camerastream && ./compressed_vipc.py comma-ffffffff --cams 0
cd ~/openpilot/selfdrive/ui/ && ./watch3.py
```
+159
View File
@@ -0,0 +1,159 @@
#!/usr/bin/env python3
import os
import argparse
import multiprocessing
import time
import signal
from collections import deque
import openpilot.cereal.messaging as messaging
from msgq.visionipc import VisionIpcServer, VisionStreamType
from openpilot.tools.camerastream.ffmpeg_decoder import Decoder, FFmpegError
V4L2_BUF_FLAG_KEYFRAME = 8
# start encoderd
# also start cereal messaging bridge
# then run this "./compressed_vipc.py <ip>"
ENCODE_SOCKETS = {
VisionStreamType.VISION_STREAM_ROAD: "roadEncodeData",
VisionStreamType.VISION_STREAM_DRIVER: "driverEncodeData",
VisionStreamType.VISION_STREAM_WIDE_ROAD: "wideRoadEncodeData",
}
def decoder(addr, vipc_server, vst, W, H, debug=False):
sock_name = ENCODE_SOCKETS[vst]
if debug:
print(f"start decoder for {sock_name}, {W}x{H}")
codec = Decoder("hevc")
os.environ["ZMQ"] = "1"
messaging.reset_context()
sock = messaging.sub_sock(sock_name, None, addr=addr, conflate=False)
cnt = 0
last_idx = -1
seen_iframe = False
time_q = deque()
def resync():
nonlocal seen_iframe
codec.reset()
seen_iframe = False
time_q.clear()
while 1:
msgs = messaging.drain_sock(sock, wait_for_one=True)
for evt in msgs:
evta = getattr(evt, evt.which())
if last_idx != -1 and evta.idx.encodeId != (last_idx + 1):
if debug:
print("DROP PACKET!")
resync()
last_idx = evta.idx.encodeId
if not seen_iframe and not (evta.idx.flags & V4L2_BUF_FLAG_KEYFRAME):
if debug:
print("waiting for iframe")
continue
time_q.append(time.monotonic())
network_latency = (int(time.time()*1e9) - evta.unixTimestampNanos)/1e6 # noqa: TID251
frame_latency = ((evta.idx.timestampEof/1e9) - (evta.idx.timestampSof/1e9))*1000
process_latency = ((evt.logMonoTime/1e9) - (evta.idx.timestampEof/1e9))*1000
# put in header (first) — VPS/SPS/PPS only, no frame expected
if not seen_iframe:
try:
codec.decode(evta.header)
except FFmpegError as e:
if debug:
print(f"HEADER ERROR: {e}")
resync()
continue
seen_iframe = True
try:
img_yuv = codec.decode(evta.data)
except FFmpegError as e:
if debug:
print(f"DECODE ERROR: {e}")
resync()
continue
if img_yuv is None:
if debug:
print("DROP SURFACE")
continue
if codec.width != W or codec.height != H:
if debug:
print(f"DECODE ERROR: decoded frame is {codec.width}x{codec.height}, expected {W}x{H}")
resync()
continue
frame_start_time = time_q.popleft()
vipc_server.send(vst, img_yuv.data, cnt, int(frame_start_time*1e9), int(time.monotonic()*1e9))
cnt += 1
pc_latency = (time.monotonic()-frame_start_time)*1000
if debug:
print(f"{len(msgs):2d} {evta.idx.encodeId:4d} {evt.logMonoTime/1e9:.3f} {evta.idx.timestampEof/1e6:.3f} \
roll {frame_latency:6.2f} ms latency {process_latency:6.2f} ms + {network_latency:6.2f} ms + {pc_latency:6.2f} ms \
= {process_latency+network_latency+pc_latency:6.2f} ms", len(evta.data), sock_name)
class CompressedVipc:
def __init__(self, addr, vision_streams, server_name, debug=False):
print("getting frame sizes")
os.environ["ZMQ"] = "1"
messaging.reset_context()
sm = messaging.SubMaster([ENCODE_SOCKETS[s] for s in vision_streams], addr=addr)
while min(sm.recv_frame.values()) == 0:
sm.update(100)
os.environ.pop("ZMQ")
messaging.reset_context()
self.vipc_server = VisionIpcServer(server_name)
for vst in vision_streams:
ed = sm[ENCODE_SOCKETS[vst]]
self.vipc_server.create_buffers(vst, 4, ed.width, ed.height)
self.vipc_server.start_listener()
self.procs = []
for vst in vision_streams:
ed = sm[ENCODE_SOCKETS[vst]]
p = multiprocessing.Process(target=decoder, args=(addr, self.vipc_server, vst, ed.width, ed.height, debug))
p.start()
self.procs.append(p)
def join(self):
for p in self.procs:
p.join()
def kill(self):
for p in self.procs:
p.terminate()
self.join()
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Decode video streams and broadcast on VisionIPC")
parser.add_argument("addr", help="Address of comma three")
parser.add_argument("--cams", default="0,1,2", help="Cameras to decode")
parser.add_argument("--server", default="camerad", help="choose vipc server name")
parser.add_argument("--silent", action="store_true", help="Suppress debug output")
args = parser.parse_args()
vision_streams = [
VisionStreamType.VISION_STREAM_ROAD,
VisionStreamType.VISION_STREAM_DRIVER,
VisionStreamType.VISION_STREAM_WIDE_ROAD,
]
vsts = [vision_streams[int(x)] for x in args.cams.split(",")]
cvipc = CompressedVipc(args.addr, vsts, args.server, debug=(not args.silent))
# register exit handler
signal.signal(signal.SIGINT, lambda sig, frame: cvipc.kill())
cvipc.join()
@@ -0,0 +1,261 @@
import ctypes
import errno
import os
import ffmpeg
import numpy as np
AV_INPUT_BUFFER_PADDING_SIZE = 64
AV_LOG_QUIET = -8
SWS_FAST_BILINEAR = 1
class FFmpegError(RuntimeError):
pass
class AVPacket(ctypes.Structure):
# Public prefix of AVPacket. Only data and size are modified here; the packet
# remains non-refcounted and points at Decoder._packet_buffer.
_fields_ = [
("buf", ctypes.c_void_p),
("pts", ctypes.c_int64),
("dts", ctypes.c_int64),
("data", ctypes.POINTER(ctypes.c_uint8)),
("size", ctypes.c_int),
]
class AVFrame(ctypes.Structure):
# Public prefix of AVFrame through format. Stable within a libavutil major
_fields_ = [
("data", ctypes.POINTER(ctypes.c_uint8) * 8),
("linesize", ctypes.c_int * 8),
("extended_data", ctypes.POINTER(ctypes.POINTER(ctypes.c_uint8))),
("width", ctypes.c_int),
("height", ctypes.c_int),
("nb_samples", ctypes.c_int),
("format", ctypes.c_int),
]
def _bind(fn, restype, *argtypes):
fn.restype = restype
fn.argtypes = list(argtypes)
return fn
def _load_libraries():
avutil = ctypes.CDLL(os.path.join(ffmpeg.LIB_DIR, "libavutil.so.59"), mode=ctypes.RTLD_GLOBAL)
avcodec = ctypes.CDLL(os.path.join(ffmpeg.LIB_DIR, "libavcodec.so.61"), mode=ctypes.RTLD_GLOBAL)
swscale = ctypes.CDLL(os.path.join(ffmpeg.LIB_DIR, "libswscale.so.8"), mode=ctypes.RTLD_GLOBAL)
c_int, c_char_p, c_void_p, c_size_t = ctypes.c_int, ctypes.c_char_p, ctypes.c_void_p, ctypes.c_size_t
c_uint8_p = ctypes.POINTER(ctypes.c_uint8)
c_void_p_p = ctypes.POINTER(c_void_p)
_bind(avutil.av_log_set_level, None, c_int)
_bind(avutil.av_opt_set, c_int, c_void_p, c_char_p, c_char_p, c_int)
_bind(avutil.av_strerror, c_int, c_int, c_char_p, c_size_t)
_bind(avutil.av_get_pix_fmt, c_int, c_char_p)
_bind(avcodec.avcodec_find_decoder_by_name, c_void_p, c_char_p)
_bind(avcodec.avcodec_alloc_context3, c_void_p, c_void_p)
_bind(avcodec.avcodec_open2, c_int, c_void_p, c_void_p, c_void_p)
_bind(avcodec.avcodec_free_context, None, c_void_p_p)
_bind(avcodec.avcodec_flush_buffers, None, c_void_p)
_bind(avcodec.avcodec_send_packet, c_int, c_void_p, ctypes.POINTER(AVPacket))
_bind(avcodec.avcodec_receive_frame, c_int, c_void_p, ctypes.POINTER(AVFrame))
_bind(avcodec.av_packet_alloc, ctypes.POINTER(AVPacket))
_bind(avcodec.av_packet_free, None, ctypes.POINTER(ctypes.POINTER(AVPacket)))
_bind(avcodec.av_frame_alloc, ctypes.POINTER(AVFrame))
_bind(avcodec.av_frame_free, None, ctypes.POINTER(ctypes.POINTER(AVFrame)))
_bind(avcodec.av_frame_unref, None, ctypes.POINTER(AVFrame))
_bind(swscale.sws_getCachedContext, c_void_p,
c_void_p, c_int, c_int, c_int, c_int, c_int, c_int, c_int, c_void_p, c_void_p, c_void_p)
_bind(swscale.sws_scale, c_int,
c_void_p, ctypes.POINTER(c_uint8_p), ctypes.POINTER(c_int),
c_int, c_int, ctypes.POINTER(c_uint8_p), ctypes.POINTER(c_int))
_bind(swscale.sws_freeContext, None, c_void_p)
avutil.av_log_set_level(AV_LOG_QUIET)
return avutil, avcodec, swscale
_avutil, _avcodec, _swscale = _load_libraries()
_DataArray = ctypes.POINTER(ctypes.c_uint8) * 4
_LinesizeArray = ctypes.c_int * 4
AV_PIX_FMT_NV12 = _avutil.av_get_pix_fmt(b"nv12")
assert AV_PIX_FMT_NV12 >= 0
def _error_string(code: int) -> str:
buf = ctypes.create_string_buffer(256)
if _avutil.av_strerror(code, buf, len(buf)) == 0:
return buf.value.decode(errors="replace")
return f"FFmpeg error {code}"
def _check(code: int, operation: str) -> None:
if code < 0:
raise FFmpegError(f"{operation}: {_error_string(code)}")
class Decoder:
def __init__(self, codec_name: str = "hevc"):
self.closed = True
self._sws_context = ctypes.c_void_p()
self._packet_buffer = bytearray()
self._packet_address = 0
self._packet_data = None
self._output = np.empty(0, dtype=np.uint8)
self._dst_data = _DataArray()
self._dst_linesize = _LinesizeArray()
self.width = 0
self.height = 0
self._src_format = -1
codec = _avcodec.avcodec_find_decoder_by_name(codec_name.encode())
if not codec:
raise FFmpegError(f"decoder not found: {codec_name}")
self._context = ctypes.c_void_p(_avcodec.avcodec_alloc_context3(codec))
if not self._context:
raise MemoryError("avcodec_alloc_context3 failed")
self._packet = _avcodec.av_packet_alloc()
if not self._packet:
_avcodec.avcodec_free_context(ctypes.byref(self._context))
raise MemoryError("av_packet_alloc failed")
self._frame = _avcodec.av_frame_alloc()
if not self._frame:
_avcodec.av_packet_free(ctypes.byref(self._packet))
_avcodec.avcodec_free_context(ctypes.byref(self._context))
raise MemoryError("av_frame_alloc failed")
try:
# Frame threading holds decoded frames to populate worker pipelines.
# Slice threads can reduce decode time without adding that frame queue;
# four was the latency minimum on the replay camera workload.
_check(_avutil.av_opt_set(self._context, b"threads", b"4", 0), "set decoder threads")
_check(_avutil.av_opt_set(self._context, b"thread_type", b"slice", 0), "set decoder thread type")
_check(_avutil.av_opt_set(self._context, b"flags", b"+low_delay", 0), "set low-delay mode")
_check(_avcodec.avcodec_open2(self._context, codec, None), "open decoder")
except Exception:
_avcodec.av_frame_free(ctypes.byref(self._frame))
_avcodec.av_packet_free(ctypes.byref(self._packet))
_avcodec.avcodec_free_context(ctypes.byref(self._context))
raise
self.closed = False
def __enter__(self):
self._ensure_open()
return self
def __exit__(self, exc_type, exc_value, traceback):
self.close()
def _ensure_open(self) -> None:
if self.closed:
raise RuntimeError("decoder is closed")
def _prepare_packet(self, data) -> None:
size = len(data)
required = size + AV_INPUT_BUFFER_PADDING_SIZE
if len(self._packet_buffer) < required:
# Grow-only; the address stays valid until the next reallocation.
self._packet_buffer = bytearray(required)
self._packet_address = ctypes.addressof(ctypes.c_uint8.from_buffer(self._packet_buffer))
self._packet_data = ctypes.cast(self._packet_address, ctypes.POINTER(ctypes.c_uint8))
self._packet_buffer[:size] = data
ctypes.memset(self._packet_address + size, 0, AV_INPUT_BUFFER_PADDING_SIZE)
self._packet.contents.data = self._packet_data
self._packet.contents.size = size
def _prepare_output(self, frame: AVFrame) -> None:
width, height, src_format = frame.width, frame.height, frame.format
if width <= 0 or height <= 0 or width % 2 or height % 2:
raise FFmpegError(f"unsupported frame dimensions: {width}x{height}")
if (width, height, src_format) == (self.width, self.height, self._src_format):
return
sws_context = _swscale.sws_getCachedContext(
self._sws_context, width, height, src_format,
width, height, AV_PIX_FMT_NV12, SWS_FAST_BILINEAR,
None, None, None,
)
if not sws_context:
raise FFmpegError("sws_getCachedContext failed")
self._sws_context = ctypes.c_void_p(sws_context)
self.width, self.height = width, height
self._src_format = src_format
self._output = np.empty(width * height * 3 // 2, dtype=np.uint8)
output_address = self._output.ctypes.data
self._dst_data = _DataArray(
ctypes.cast(output_address, ctypes.POINTER(ctypes.c_uint8)),
ctypes.cast(output_address + width * height, ctypes.POINTER(ctypes.c_uint8)),
None,
None,
)
self._dst_linesize = _LinesizeArray(width, width, 0, 0)
def _receive(self) -> np.ndarray | None:
"""Return one NV12 frame, or None if the decoder needs more input.
The returned buffer is reused on the next successful decode; callers must
use or copy it before calling decode again.
"""
result = _avcodec.avcodec_receive_frame(self._context, self._frame)
if result == -errno.EAGAIN:
return None
_check(result, "receive decoded frame")
try:
frame = self._frame.contents
self._prepare_output(frame)
rows = _swscale.sws_scale(
self._sws_context, frame.data, frame.linesize, 0, frame.height,
self._dst_data, self._dst_linesize,
)
if rows != frame.height:
raise FFmpegError(f"convert decoded frame: produced {rows} of {frame.height} rows")
return self._output
finally:
_avcodec.av_frame_unref(self._frame)
def decode(self, data) -> np.ndarray | None:
self._ensure_open()
if len(data) == 0:
return None
self._prepare_packet(data)
result = _avcodec.avcodec_send_packet(self._context, self._packet)
# The packet buffer is ours, not FFmpeg's. Clear the borrowed pointer so
# packet teardown can never attempt to release it.
self._packet.contents.data = None
self._packet.contents.size = 0
_check(result, "send packet to decoder")
return self._receive()
def reset(self) -> None:
"""Discard decoder state after a stream discontinuity."""
self._ensure_open()
_avcodec.avcodec_flush_buffers(self._context)
def close(self) -> None:
if self.closed:
return
self.closed = True
_swscale.sws_freeContext(self._sws_context)
_avcodec.av_frame_free(ctypes.byref(self._frame))
_avcodec.av_packet_free(ctypes.byref(self._packet))
_avcodec.avcodec_free_context(ctypes.byref(self._context))
def __del__(self):
self.close()
+374
View File
@@ -0,0 +1,374 @@
#!/usr/bin/env python3
import os
import sys
import time
import logging
import subprocess
import threading
import queue
import multiprocessing
import itertools
import numpy as np
import tqdm
from argparse import ArgumentParser
from collections.abc import Callable
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor, as_completed
from openpilot.tools.lib.route import Route
from openpilot.tools.lib.logreader import LogReader
from openpilot.tools.lib.filereader import FileReader
from openpilot.tools.lib.framereader import FrameReader, ffprobe
from openpilot.selfdrive.test.process_replay.migration import migrate_all
from openpilot.common.prefix import OpenpilotPrefix
from openpilot.common.utils import Timer
from msgq.visionipc import VisionIpcServer, VisionStreamType
FRAMERATE = 20
DEMO_ROUTE, DEMO_START, DEMO_END = '5beb9b58bd12b691/0000010a--a51155e496', 90, 105
logger = logging.getLogger('clip')
def parse_args():
parser = ArgumentParser(description="Direct clip renderer")
parser.add_argument("route", nargs="?", help="Route ID (dongle/route or dongle/route/start/end)")
parser.add_argument("-s", "--start", type=int, help="Start time in seconds")
parser.add_argument("-e", "--end", type=int, help="End time in seconds")
parser.add_argument("-o", "--output", default="output.mp4", help="Output file path")
parser.add_argument("-d", "--data-dir", help="Local directory with route data")
parser.add_argument("-t", "--title", help="Title overlay text")
parser.add_argument("-f", "--file-size", type=float, default=9.0, help="Target file size in MB")
parser.add_argument("-x", "--speed", type=int, default=1, help="Speed multiplier")
parser.add_argument("--demo", action="store_true", help="Use demo route with default timing")
parser.add_argument("--big", action="store_true", help="Use big UI (2160x1080)")
parser.add_argument("--qcam", action="store_true", help="Use qcamera instead of fcamera")
parser.add_argument("--windowed", action="store_true", help="Show window")
parser.add_argument("--no-metadata", action="store_true", help="Disable metadata overlay")
parser.add_argument("--no-time-overlay", action="store_true", help="Disable time overlay")
args = parser.parse_args()
if args.demo:
args.route, args.start, args.end = args.route or DEMO_ROUTE, args.start or DEMO_START, args.end or DEMO_END
elif not args.route:
parser.error("route is required (or use --demo)")
if args.route and args.route.count('/') == 3:
parts = args.route.split('/')
args.route, args.start, args.end = '/'.join(parts[:2]), args.start or int(parts[2]), args.end or int(parts[3])
if args.start is None or args.end is None:
parser.error("--start and --end are required")
if args.end <= args.start:
parser.error(f"end ({args.end}) must be greater than start ({args.start})")
return args
def setup_env(output_path: str, big: bool = False, speed: int = 1, target_mb: float = 0, duration: int = 0, headless: bool = True):
os.environ.update({"RECORD": "1", "RECORD_OUTPUT": str(Path(output_path).with_suffix(".mp4"))})
if headless:
os.environ["OFFSCREEN"] = "1"
if speed > 1:
os.environ["RECORD_SPEED"] = str(speed)
if target_mb > 0 and duration > 0:
os.environ["RECORD_BITRATE"] = f"{int(target_mb * 8 * 1024 / (duration / speed))}k"
if big:
os.environ["BIG"] = "1"
def _download_segment(path: str) -> bytes:
with FileReader(path) as f:
return bytes(f.read())
def _parse_and_chunk_segment(args: tuple) -> list[dict]:
raw_data, fps = args
from openpilot.tools.lib.logreader import _LogFileReader
messages = migrate_all(list(_LogFileReader("", dat=raw_data, sort_by_time=True)))
if not messages:
return []
dt_ns, chunks, current, next_time = 1e9 / fps, [], {}, messages[0].logMonoTime + 1e9 / fps
for msg in messages:
if msg.logMonoTime >= next_time:
chunks.append(current)
current, next_time = {}, next_time + dt_ns * ((msg.logMonoTime - next_time) // dt_ns + 1)
current[msg.which()] = msg
return chunks + [current] if current else chunks
def load_logs_parallel(log_paths: list[str], fps: int = 20) -> list[dict]:
num_workers = min(16, len(log_paths), (multiprocessing.cpu_count() or 1))
logger.info(f"Downloading {len(log_paths)} segments with {num_workers} workers...")
with ThreadPoolExecutor(max_workers=num_workers) as pool:
futures = {pool.submit(_download_segment, path): idx for idx, path in enumerate(log_paths)}
raw_data = {futures[f]: f.result() for f in as_completed(futures)}
logger.info("Parsing and chunking segments...")
with multiprocessing.Pool(num_workers) as pool:
return list(itertools.chain.from_iterable(pool.map(_parse_and_chunk_segment, [(raw_data[i], fps) for i in range(len(log_paths))])))
def patch_submaster(message_chunks, ui_state):
# Reset started_frame so alerts render correctly (recv_frame must be >= started_frame)
ui_state.started_frame = 0
ui_state.started_time = time.monotonic()
def mock_update(timeout=None):
sm, t = ui_state.sm, time.monotonic()
sm.updated = dict.fromkeys(sm.services, False)
if sm.frame < len(message_chunks):
for svc, msg in message_chunks[sm.frame].items():
if svc in sm.data:
sm.seen[svc] = sm.updated[svc] = sm.alive[svc] = sm.valid[svc] = True
sm.data[svc] = getattr(msg.as_builder(), svc)
sm.logMonoTime[svc], sm.recv_time[svc], sm.recv_frame[svc] = msg.logMonoTime, t, sm.frame
sm.frame += 1
ui_state.sm.update = mock_update
def get_frame_dimensions(camera_path: str) -> tuple[int, int]:
"""Get frame dimensions from a video file using ffprobe."""
probe = ffprobe(camera_path)
stream = probe["streams"][0]
return stream["width"], stream["height"]
def iter_segment_frames(camera_paths, start_time, end_time, fps=20, use_qcam=False, frame_size: tuple[int, int] | None = None):
frames_per_seg = fps * 60
start_frame, end_frame = int(start_time * fps), int(end_time * fps)
current_seg: int = -1
get_frame: Callable[[int], np.ndarray] | None = None
for global_idx in range(start_frame, end_frame):
seg_idx, local_idx = global_idx // frames_per_seg, global_idx % frames_per_seg
if seg_idx != current_seg:
current_seg = seg_idx
path = camera_paths[seg_idx] if seg_idx < len(camera_paths) else None
if not path:
raise RuntimeError(f"No camera file for segment {seg_idx}")
if use_qcam:
w, h = frame_size or get_frame_dimensions(path)
with FileReader(path) as f:
result = subprocess.run(["ffmpeg", "-v", "quiet", "-i", "-", "-f", "rawvideo", "-pix_fmt", "nv12", "-"],
input=f.read(), capture_output=True)
if result.returncode != 0:
raise RuntimeError(f"ffmpeg failed: {result.stderr.decode()}")
seg_frames = np.frombuffer(result.stdout, dtype=np.uint8).reshape(-1, w * h * 3 // 2)
get_frame = seg_frames.__getitem__
else:
get_frame = FrameReader(path, pix_fmt="nv12").get
assert get_frame is not None
yield global_idx, get_frame(local_idx)
class FrameQueue:
def __init__(self, camera_paths, start_time, end_time, fps=20, prefetch_count=60, use_qcam=False):
# Probe first valid camera file for dimensions
first_path = next((p for p in camera_paths if p), None)
if not first_path:
raise RuntimeError("No valid camera paths")
self.frame_w, self.frame_h = get_frame_dimensions(first_path)
self._queue, self._stop, self._error = queue.Queue(maxsize=prefetch_count), threading.Event(), None
self._thread = threading.Thread(target=self._worker,
args=(camera_paths, start_time, end_time, fps, use_qcam, (self.frame_w, self.frame_h)), daemon=True)
self._thread.start()
def _worker(self, camera_paths, start_time, end_time, fps, use_qcam, frame_size):
try:
for idx, data in iter_segment_frames(camera_paths, start_time, end_time, fps, use_qcam, frame_size):
if self._stop.is_set():
break
self._queue.put((idx, data.tobytes()))
except Exception as e:
logger.exception("Decode error")
self._error = e
finally:
self._queue.put(None)
def get(self, timeout=60.0):
if self._error:
raise self._error
result = self._queue.get(timeout=timeout)
if result is None:
raise StopIteration("No more frames")
return result
def stop(self):
self._stop.set()
while not self._queue.empty():
try:
self._queue.get_nowait()
except queue.Empty:
break
self._thread.join(timeout=2.0)
def load_route_metadata(route):
from openpilot.common.params import Params, UnknownKeyName
path = next((item for item in route.log_paths() if item), None)
if not path:
raise Exception('error getting route metadata: cannot find any uploaded logs')
lr = LogReader(path)
init_data, car_params = lr.first('initData'), lr.first('carParams')
params = Params()
for entry in init_data.params.entries:
try:
params.put(entry.key, params.cpp2python(entry.key, entry.value), block=True)
except UnknownKeyName:
pass
origin = init_data.gitRemote.split('/')[3] if len(init_data.gitRemote.split('/')) > 3 else 'unknown'
return {
'version': init_data.version, 'route': route.name.canonical_name,
'car': car_params.carFingerprint if car_params else 'unknown', 'origin': origin,
'branch': init_data.gitBranch, 'commit': init_data.gitCommit[:7], 'modified': str(init_data.dirty).lower(),
}
def draw_text_box(text, x, y, size, gui_app, font, color=None, center=False):
import pyray as rl
from openpilot.system.ui.lib.text_measure import measure_text_cached
box_color, text_color = rl.Color(0, 0, 0, 85), color or rl.WHITE
text_size = measure_text_cached(font, text, size)
text_width, text_height = int(text_size.x), int(text_size.y)
if center:
x = (gui_app.width - text_width) // 2
rl.draw_rectangle(x - 8, y - 4, text_width + 16, text_height + 8, box_color)
rl.draw_text_ex(font, text, rl.Vector2(x, y), size, 0, text_color)
def render_overlays(gui_app, font, big, metadata, title, start_time, frame_idx, show_metadata, show_time):
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.lib.wrap_text import wrap_text
metadata_size = 16 if big else 12
title_size = 32 if big else 24
time_size = 24 if big else 16
# Time overlay
time_width = 0
if show_time:
t = start_time + frame_idx / FRAMERATE
time_text = f"{int(t) // 60:02d}:{int(t) % 60:02d}"
time_width = int(measure_text_cached(font, time_text, time_size).x)
draw_text_box(time_text, gui_app.width - time_width - 5, 0, time_size, gui_app, font)
# Metadata overlay (first 5 seconds)
if show_metadata and metadata and frame_idx < FRAMERATE * 5:
m = metadata
text = ", ".join([f"openpilot v{m['version']}", f"route: {m['route']}", f"car: {m['car']}", f"origin: {m['origin']}",
f"branch: {m['branch']}", f"commit: {m['commit']}", f"modified: {m['modified']}"])
# Wrap text if too wide (leave margin on each side)
margin = 2 * (time_width + 10 if show_time else 20) # leave enough margin for time overlay
max_width = gui_app.width - margin
lines = wrap_text(font, text, metadata_size, max_width)
# Draw wrapped metadata text
y_offset = 6
for line in lines:
draw_text_box(line, 0, y_offset, metadata_size, gui_app, font, center=True)
line_height = int(measure_text_cached(font, line, metadata_size).y) + 4
y_offset += line_height
# Title overlay
if title:
draw_text_box(title, 0, 60, title_size, gui_app, font, center=True)
def clip(route: Route, output: str, start: int, end: int, headless: bool = True, big: bool = False,
title: str | None = None, show_metadata: bool = True, show_time: bool = True, use_qcam: bool = False):
timer, duration = Timer(), end - start
import pyray as rl
if big:
from openpilot.selfdrive.ui.onroad.augmented_road_view import AugmentedRoadView
else:
from openpilot.selfdrive.ui.mici.onroad.augmented_road_view import AugmentedRoadView
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.system.ui.lib.application import gui_app, FontWeight
timer.lap("import")
logger.info(f"Clipping {route.name.canonical_name}, {start}s-{end}s ({duration}s)")
seg_start, seg_end = start // 60, (end - 1) // 60 + 1
all_chunks = load_logs_parallel(route.log_paths()[seg_start:seg_end], fps=FRAMERATE)
timer.lap("logs")
frame_start = (start - seg_start * 60) * FRAMERATE
message_chunks = all_chunks[frame_start:frame_start + duration * FRAMERATE]
if not message_chunks:
logger.error("No messages to render")
sys.exit(1)
if headless:
rl.set_config_flags(rl.ConfigFlags.FLAG_WINDOW_HIDDEN)
with OpenpilotPrefix(shared_download_cache=True):
metadata = load_route_metadata(route) if show_metadata else None
camera_paths = route.qcamera_paths() if use_qcam else route.camera_paths()
frame_queue = FrameQueue(camera_paths, start, end, fps=FRAMERATE, use_qcam=use_qcam)
ecamera_paths = route.ecamera_paths() if not use_qcam else []
wide_frame_queue: FrameQueue | None = None
if any(p for p in ecamera_paths[seg_start:seg_end] if p):
wide_frame_queue = FrameQueue(ecamera_paths, start, end, fps=FRAMERATE)
vipc = VisionIpcServer("camerad")
vipc.create_buffers(VisionStreamType.VISION_STREAM_ROAD, 4, frame_queue.frame_w, frame_queue.frame_h)
if wide_frame_queue:
vipc.create_buffers(VisionStreamType.VISION_STREAM_WIDE_ROAD, 4, wide_frame_queue.frame_w, wide_frame_queue.frame_h)
vipc.start_listener()
patch_submaster(message_chunks, ui_state)
gui_app.init_window("clip", fps=FRAMERATE)
road_view = AugmentedRoadView()
road_view.set_rect(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
font = gui_app.font(FontWeight.NORMAL)
timer.lap("setup")
frame_idx = 0
with tqdm.tqdm(total=len(message_chunks), desc="Rendering", unit="frame") as pbar:
for should_render, _, _ in gui_app.render():
if frame_idx >= len(message_chunks):
break
_, frame_bytes = frame_queue.get()
vipc.send(VisionStreamType.VISION_STREAM_ROAD, frame_bytes, frame_idx, int(frame_idx * 5e7), int(frame_idx * 5e7))
if wide_frame_queue:
_, wide_bytes = wide_frame_queue.get()
vipc.send(VisionStreamType.VISION_STREAM_WIDE_ROAD, wide_bytes, frame_idx, int(frame_idx * 5e7), int(frame_idx * 5e7))
ui_state.update()
if should_render:
road_view.render()
render_overlays(gui_app, font, big, metadata, title, start, frame_idx, show_metadata, show_time)
frame_idx += 1
pbar.update(1)
timer.lap("render")
frame_queue.stop()
if wide_frame_queue:
wide_frame_queue.stop()
gui_app.close()
timer.lap("ffmpeg")
logger.info(f"Clip saved to: {Path(output).resolve()}")
logger.info(f"Generated {timer.fmt(duration)}")
def main():
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s\t%(message)s")
args = parse_args()
headless = not args.windowed
setup_env(args.output, big=args.big, speed=args.speed, target_mb=args.file_size, duration=args.end - args.start, headless=headless)
clip(Route(args.route, data_dir=args.data_dir), args.output, args.start, args.end, headless,
args.big, args.title, not args.no_metadata, not args.no_time_overlay, args.qcam)
if __name__ == "__main__":
main()
+10
View File
@@ -0,0 +1,10 @@
__pycache__/
jot_*.o
*.o
jotpluggler
car_fingerprint_to_dbc.h
generated_event_extractors.h
generated_dbcs/.stamp
generated_dbcs/*.dbc
layouts/.jotpluggler_autosave/
reports/
File diff suppressed because it is too large Load Diff
+914
View File
@@ -0,0 +1,914 @@
#pragma once
#include "openpilot/cereal/gen/cpp/log.capnp.h"
#include "imgui.h"
#include "tools/jotpluggler/dbc.h"
#include "tools/jotpluggler/util.h"
#include <algorithm>
#include <array>
#include <atomic>
#include <cstdint>
#include <filesystem>
#include <future>
#include <functional>
#include <memory>
#include <optional>
#include <string>
#include <string_view>
#include <unordered_map>
#include <vector>
// *****
// app options & entry point
// *****
struct Options {
std::string layout;
std::string route_name;
std::string data_dir;
std::string output_path;
std::string stream_address = "127.0.0.1";
int width = 1600;
int height = 900;
bool show = false;
bool sync_load = false;
bool stream = false;
double stream_buffer_seconds = 30.0;
};
int run(const Options &options);
// *****
// sketch layout & route data
// *****
struct PlotRange {
bool valid = false;
double left = 0.0;
double right = 0.0;
double bottom = 0.0;
double top = 1.0;
bool has_y_limit_min = false;
bool has_y_limit_max = false;
double y_limit_min = 0.0;
double y_limit_max = 1.0;
};
struct CustomPythonSeries {
std::string linked_source;
std::vector<std::string> additional_sources;
std::string globals_code;
std::string function_code;
};
struct Curve {
std::string name;
std::string label;
std::array<uint8_t, 3> color = {160, 170, 180};
bool visible = true;
bool derivative = false;
double derivative_dt = 0.0;
double value_scale = 1.0;
double value_offset = 0.0;
bool runtime_only = false;
std::optional<CustomPythonSeries> custom_python;
std::string runtime_error_message;
std::vector<double> xs;
std::vector<double> ys;
};
enum class PaneKind : uint8_t {
Plot,
Map,
Thumbnail,
Camera,
};
enum class CameraViewKind : uint8_t {
Road,
Driver,
WideRoad,
QRoad,
};
struct Pane {
PaneKind kind = PaneKind::Plot;
CameraViewKind camera_view = CameraViewKind::Road;
std::string title;
PlotRange range;
std::vector<Curve> curves;
};
enum class SplitOrientation {
Horizontal,
Vertical,
};
struct WorkspaceNode {
bool is_pane = false;
int pane_index = -1;
SplitOrientation orientation = SplitOrientation::Horizontal;
std::vector<float> sizes;
std::vector<WorkspaceNode> children;
};
struct WorkspaceTab {
std::string tab_name;
WorkspaceNode root;
std::vector<Pane> panes;
};
struct RouteSeries {
std::string path;
std::vector<double> times;
std::vector<double> values;
};
struct CameraSegmentFile {
int segment = -1;
std::string path;
};
struct CameraFrameIndexEntry {
double timestamp = 0.0;
int segment = -1;
int decode_index = -1;
uint32_t frame_id = 0;
};
struct CameraFeedIndex {
std::vector<CameraSegmentFile> segment_files;
std::vector<CameraFrameIndexEntry> entries;
};
struct ThumbnailFrame {
double timestamp = 0.0;
int segment = -1;
std::vector<uint8_t> jpeg;
};
enum class LogOrigin : uint8_t {
Log,
OperatingSystem,
Alert,
};
struct LogEntry {
double mono_time = 0.0;
double boot_time = 0.0;
double wall_time = 0.0;
uint8_t level = 20;
std::string source;
std::string func;
std::string message;
std::string context;
LogOrigin origin = LogOrigin::Log;
};
struct EnumInfo {
std::vector<std::string> names;
};
struct SeriesFormat {
int decimals = 3;
bool integer_like = false;
bool has_negative = false;
int digits_before = 1;
int total_width = 0;
char fmt[16] = "%7.3f";
};
enum class CanServiceKind : uint8_t {
Can,
Sendcan,
};
struct CanMessageId {
CanServiceKind service = CanServiceKind::Can;
uint8_t bus = 0;
uint32_t address = 0;
bool operator==(const CanMessageId &other) const {
return service == other.service && bus == other.bus && address == other.address;
}
};
struct CanMessageIdHash {
size_t operator()(const CanMessageId &id) const {
return (static_cast<size_t>(id.service) << 40)
^ (static_cast<size_t>(id.bus) << 32)
^ static_cast<size_t>(id.address);
}
};
struct CanFrameSample {
double mono_time = 0.0;
uint16_t bus_time = 0;
std::string data;
};
struct LiveCanFrame {
double mono_time = 0.0;
uint8_t bus = 0;
uint32_t address = 0;
uint16_t bus_time = 0;
std::string data;
};
struct CanMessageData {
CanMessageId id;
std::vector<CanFrameSample> samples;
};
struct TimelineEntry {
enum class Type : uint8_t {
None,
Engaged,
AlertInfo,
AlertWarning,
AlertCritical,
};
double start_time = 0.0;
double end_time = 0.0;
Type type = Type::None;
};
struct GpsPoint {
double time = 0.0;
double lat = 0.0;
double lon = 0.0;
float bearing = 0.0f;
TimelineEntry::Type type = TimelineEntry::Type::None;
};
struct GpsTrace {
std::vector<GpsPoint> points;
double min_lat = 0.0;
double max_lat = 0.0;
double min_lon = 0.0;
double max_lon = 0.0;
};
enum class LogSelector : uint8_t {
Auto,
RLog,
QLog,
};
struct RouteIdentifier {
std::string dongle_id;
std::string log_id;
int slice_begin = 0;
int slice_end = -1;
bool slice_explicit = false;
LogSelector selector = LogSelector::Auto;
bool selector_explicit = false;
int available_begin = 0;
int available_end = 0;
bool empty() const {
return dongle_id.empty() || log_id.empty();
}
std::string canonical() const {
return empty() ? std::string() : dongle_id + "/" + log_id;
}
std::string onebox() const {
return empty() ? std::string() : dongle_id + "|" + log_id;
}
std::string display_slice() const {
const int begin = slice_explicit ? slice_begin : available_begin;
const int end = slice_explicit ? slice_end : available_end;
if (end < 0) {
return std::to_string(begin) + ":";
}
if (end == begin) {
return std::to_string(begin);
}
return std::to_string(begin) + ":" + std::to_string(end);
}
char selector_char() const {
switch (selector) {
case LogSelector::RLog: return 'r';
case LogSelector::QLog: return 'q';
case LogSelector::Auto:
default: return 'a';
}
}
std::string full_spec() const {
if (empty()) return {};
std::string spec = dongle_id + "/" + log_id;
if (slice_explicit) {
spec += "/";
spec += display_slice();
}
if (selector_explicit) {
spec += "/";
spec.push_back(selector_char());
}
return spec;
}
};
struct RouteData {
std::vector<RouteSeries> series;
std::vector<std::string> paths;
std::vector<std::string> roots;
std::vector<CanMessageData> can_messages;
CameraFeedIndex road_camera;
CameraFeedIndex driver_camera;
CameraFeedIndex wide_road_camera;
CameraFeedIndex qroad_camera;
std::vector<ThumbnailFrame> thumbnails;
GpsTrace gps_trace;
std::vector<LogEntry> logs;
std::vector<TimelineEntry> timeline;
std::unordered_map<std::string, EnumInfo> enum_info;
std::unordered_map<std::string, SeriesFormat> series_formats;
std::string car_fingerprint;
std::string dbc_name;
RouteIdentifier route_id;
bool has_time_range = false;
double x_min = 0.0;
double x_max = 1.0;
};
struct StreamExtractBatch {
std::vector<RouteSeries> series;
std::vector<CanMessageData> can_messages;
std::vector<LogEntry> logs;
std::vector<TimelineEntry> timeline;
std::unordered_map<std::string, EnumInfo> enum_info;
std::string car_fingerprint;
std::string dbc_name;
bool has_time_offset = false;
double time_offset = 0.0;
};
struct SketchLayout {
std::vector<WorkspaceTab> tabs;
std::vector<std::string> roots;
int current_tab_index = 0;
};
enum class RouteLoadStage {
Resolving,
DownloadingSegment,
ParsingSegment,
Finished,
};
struct RouteLoadProgress {
RouteLoadStage stage = RouteLoadStage::Resolving;
size_t segment_index = 0;
size_t segment_count = 0;
uint64_t current = 0;
uint64_t total = 0;
size_t segments_downloaded = 0;
size_t segments_parsed = 0;
size_t total_segments = 0;
uint64_t bytes_downloaded = 0;
int num_workers = 1;
std::string segment_name;
};
using RouteLoadProgressCallback = std::function<void(const RouteLoadProgress &)>;
class StreamAccumulator {
public:
explicit StreamAccumulator(const std::string &dbc_name = {}, std::optional<double> time_offset = std::nullopt);
~StreamAccumulator();
StreamAccumulator(const StreamAccumulator &) = delete;
StreamAccumulator &operator=(const StreamAccumulator &) = delete;
void setDbcName(const std::string &dbc_name);
void appendEvent(kj::ArrayPtr<const capnp::word> data);
void appendCanFrames(CanServiceKind service, const std::vector<LiveCanFrame> &frames);
StreamExtractBatch takeBatch();
const std::string &carFingerprint() const;
const std::string &dbc_name() const;
std::optional<double> timeOffset() const;
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
SketchLayout load_sketch_layout(const std::filesystem::path &layout_path);
std::vector<std::string> available_dbc_names();
std::vector<std::string> collect_route_roots_for_paths(const std::vector<std::string> &paths);
std::optional<dbc::Database> load_dbc_by_name(const std::string &dbc_name);
std::vector<RouteSeries> decode_can_messages(const std::vector<CanMessageData> &can_messages,
const std::string &dbc_name,
std::unordered_map<std::string, EnumInfo> *enum_info = nullptr);
RouteData load_route_data(const std::string &route_name,
const std::string &data_dir = {},
const std::string &dbc_name = {},
const RouteLoadProgressCallback &progress = {});
RouteIdentifier parse_route_identifier(std::string_view route_name);
void rebuild_gps_trace(RouteData *route_data);
// *****
// icons
// *****
namespace icon {
constexpr const char ARROW_DOWN_UP[] = "\xef\x84\xa7";
constexpr const char ARROW_LEFT_RIGHT[] = "\xef\x84\xab";
constexpr const char BAR_CHART[] = "\xef\x85\xbe";
constexpr const char BOX_ARROW_UP_RIGHT[] = "\xef\x87\x85";
constexpr const char CLIPBOARD[] = "\xef\x8a\x90";
constexpr const char CLIPBOARD2[] = "\xef\x9c\xb3";
constexpr const char DISTRIBUTE_HORIZONTAL[] = "\xef\x8c\x83";
constexpr const char DISTRIBUTE_VERTICAL[] = "\xef\x8c\x84";
constexpr const char FILE_EARMARK_IMAGE[] = "\xef\x8d\xad";
constexpr const char FILES[] = "\xef\x8f\x82";
constexpr const char INFO_CIRCLE[] = "\xef\x90\xb1";
constexpr const char PALETTE[] = "\xef\x92\xb1";
constexpr const char PLUS_SLASH_MINUS[] = "\xef\x9a\xaa";
constexpr const char SAVE[] = "\xef\x94\xa5";
constexpr const char SLIDERS[] = "\xef\x95\xab";
constexpr const char TRASH[] = "\xef\x97\x9e";
constexpr const char X_SQUARE[] = "\xef\x98\xa9";
constexpr const char ZOOM_OUT[] = "\xef\x98\xad";
} // namespace icon
void icon_add_font(float size, bool merge = false, const ImFont *base_font = nullptr);
bool icon_menu_item(const char *glyph,
const char *label,
const char *shortcut = nullptr,
bool selected = false,
bool enabled = true);
// *****
// app session, UI state, & internal API
// *****
class AsyncRouteLoader;
class CameraFeedView;
class ThumbnailView;
class StreamPoller;
class MapDataManager;
enum class SessionDataMode : uint8_t {
Route,
Stream,
};
enum class StreamSourceKind : uint8_t {
CerealLocal,
CerealRemote,
};
struct StreamSourceConfig {
StreamSourceKind kind = StreamSourceKind::CerealLocal;
std::string address = "127.0.0.1";
};
struct BrowserNode {
std::string label;
std::string full_path;
std::vector<BrowserNode> children;
};
struct AppSession {
std::filesystem::path layout_path;
std::filesystem::path autosave_path;
std::string route_name;
std::string data_dir;
std::string dbc_override;
StreamSourceConfig stream_source;
double stream_buffer_seconds = 30.0;
SessionDataMode data_mode = SessionDataMode::Route;
RouteIdentifier route_id;
SketchLayout layout;
RouteData route_data;
std::unordered_map<std::string, RouteSeries *> series_by_path;
std::vector<BrowserNode> browser_nodes;
std::unique_ptr<AsyncRouteLoader> route_loader;
std::unique_ptr<StreamPoller> stream_poller;
std::array<std::unique_ptr<CameraFeedView>, 4> pane_camera_feeds;
std::unique_ptr<ThumbnailView> thumbnail_view;
std::unique_ptr<MapDataManager> map_data;
bool async_route_loading = false;
double next_stream_custom_refresh_time = 0.0;
bool stream_paused = false;
std::optional<double> stream_time_offset;
};
struct TabUiState {
struct MapPaneState {
bool initialized = false;
bool follow = false;
float zoom = 1.0f;
double center_lat = 0.0;
double center_lon = 0.0;
};
struct CameraPaneState {
bool fit_to_pane = true;
};
bool dock_needs_build = true;
int active_pane_index = 0;
int runtime_id = 0;
ImVec2 last_dockspace_size = ImVec2(0.0f, 0.0f);
std::vector<MapPaneState> map_panes;
std::vector<CameraPaneState> camera_panes;
};
struct CustomSeriesEditorState {
bool open = false;
bool open_help = false;
bool request_select = false;
bool selected = false;
bool focus_name = false;
int selected_template = 0;
int selected_additional_source = -1;
std::string name;
std::string linked_source;
std::vector<std::string> additional_sources;
std::string globals_code;
std::string function_code = "return value";
std::string preview_label;
std::vector<double> preview_xs;
std::vector<double> preview_ys;
bool preview_is_result = false;
};
enum class LogTimeMode : uint8_t {
Route,
Boot,
WallClock,
};
struct LogsUiState {
bool selected = false;
bool request_select = false;
bool all_sources = true;
uint32_t enabled_levels_mask = 0b11110;
int expanded_index = -1;
std::string search;
std::vector<std::string> selected_sources;
double last_auto_scroll_time = -1.0;
LogTimeMode time_mode = LogTimeMode::Route;
};
struct AxisLimitsEditorState {
bool open = false;
int pane_index = -1;
double x_min = 0.0;
double x_max = 1.0;
bool y_min_enabled = false;
bool y_max_enabled = false;
double y_min = 0.0;
double y_max = 1.0;
};
struct DbcEditorState {
bool open = false;
bool loaded = false;
std::string source_name;
std::filesystem::path source_path;
enum class SourceKind : uint8_t {
None,
Generated,
Opendbc,
};
SourceKind source_kind = SourceKind::None;
std::string save_name;
std::string text;
};
enum class TimelineDragMode : uint8_t {
None,
ScrubCursor,
PanViewport,
ResizeLeft,
ResizeRight,
};
struct UndoStack {
static constexpr size_t kMaxHistory = 50;
std::vector<SketchLayout> history;
int position = -1;
void reset(const SketchLayout &layout) {
history.clear();
history.push_back(layout);
position = 0;
}
void push(const SketchLayout &layout) {
if (position < 0) {
reset(layout);
return;
}
if (position + 1 < static_cast<int>(history.size())) {
history.resize(static_cast<size_t>(position + 1));
}
history.push_back(layout);
if (history.size() > kMaxHistory) {
history.erase(history.begin());
}
position = static_cast<int>(history.size()) - 1;
}
bool can_undo() const {
return position > 0;
}
bool can_redo() const {
return position >= 0 && position + 1 < static_cast<int>(history.size());
}
const SketchLayout &undo() {
return history[static_cast<size_t>(--position)];
}
const SketchLayout &redo() {
return history[static_cast<size_t>(++position)];
}
};
struct UiState {
bool open_open_route = false;
bool open_stream = false;
bool open_load_layout = false;
bool open_save_layout = false;
bool open_preferences = false;
bool open_find_signal = false;
bool request_close = false;
bool request_reset_layout = false;
bool request_save_layout = false;
bool request_new_tab = false;
bool request_duplicate_tab = false;
bool request_close_tab = false;
bool follow_latest = false;
bool has_shared_range = false;
bool has_tracker_time = false;
bool layout_dirty = false;
bool playback_loop = false;
bool playback_playing = false;
bool show_deprecated_fields = false;
bool show_fps_overlay = false;
bool fps_overlay_initialized = false;
bool suppress_range_side_effects = false;
bool browser_nodes_dirty = false;
int active_tab_index = 0;
int next_tab_runtime_id = 1;
int requested_tab_index = -1;
int rename_tab_index = -1;
bool focus_rename_tab_input = false;
std::vector<TabUiState> tabs;
std::string route_buffer;
std::string stream_address_buffer;
std::string rename_tab_buffer;
std::string browser_filter;
std::string data_dir_buffer;
std::string load_layout_buffer;
std::string save_layout_buffer;
std::string find_signal_buffer;
std::string selected_browser_path;
std::vector<std::string> selected_browser_paths;
std::string browser_selection_anchor;
std::string route_slice_buffer;
std::string error_text;
bool open_error_popup = false;
std::string status_text = "Ready";
std::string route_copy_feedback_text;
double route_copy_feedback_until = 0.0;
bool editing_route_slice = false;
bool focus_route_slice_input = false;
StreamSourceKind stream_source_kind = StreamSourceKind::CerealLocal;
float sidebar_width = 320.0f;
double route_x_min = 0.0;
double route_x_max = 1.0;
double x_view_min = 0.0;
double x_view_max = 1.0;
double tracker_time = 0.0;
double playback_rate = 1.0;
double playback_step = 0.1;
double stream_buffer_seconds = 30.0;
TimelineDragMode timeline_drag_mode = TimelineDragMode::None;
double timeline_drag_anchor_time = 0.0;
double timeline_drag_anchor_x_min = 0.0;
double timeline_drag_anchor_x_max = 0.0;
AxisLimitsEditorState axis_limits;
DbcEditorState dbc_editor;
CustomSeriesEditorState custom_series;
LogsUiState logs;
UndoStack undo;
};
// app.cc public API
const WorkspaceTab *app_active_tab(const SketchLayout &layout, const UiState &state);
WorkspaceTab *app_active_tab(SketchLayout *layout, const UiState &state);
TabUiState *app_active_tab_state(UiState *state);
void app_push_mono_font();
void app_pop_mono_font();
bool app_add_curve_to_active_pane(AppSession *session, UiState *state, const std::string &path);
std::string app_curve_display_name(const Curve &curve);
std::array<uint8_t, 3> app_next_curve_color(const Pane &pane);
const RouteSeries *app_find_route_series(const AppSession &session, const std::string &path);
void app_decimate_samples(const std::vector<double> &xs_in,
const std::vector<double> &ys_in,
int max_points,
std::vector<double> *xs_out,
std::vector<double> *ys_out);
std::optional<double> app_sample_xy_value_at_time(const std::vector<double> &xs,
const std::vector<double> &ys,
bool stairs,
double tm);
void save_layout_json(const SketchLayout &layout, const std::filesystem::path &path);
// *****
// browser
// *****
void rebuild_route_index(AppSession *session);
void rebuild_browser_nodes(AppSession *session, UiState *state);
SeriesFormat compute_series_format(const std::vector<double> &values, bool enum_like = false);
std::string format_display_value(double display_value,
const SeriesFormat &format,
const EnumInfo *enum_info);
std::vector<std::string> decode_browser_drag_payload(std::string_view payload);
void collect_visible_leaf_paths(const BrowserNode &node,
const std::string &filter,
std::vector<std::string> *out);
void draw_browser_node(AppSession *session,
const BrowserNode &node,
UiState *state,
const std::string &filter,
const std::vector<std::string> &visible_paths);
// *****
// custom series
// *****
void open_custom_series_editor(UiState *state, const std::string &preferred_source = {});
std::string preferred_custom_series_source(const Pane &pane);
void refresh_all_custom_curves(AppSession *session, UiState *state);
void draw_custom_series_editor(AppSession *session, UiState *state);
// *****
// logs
// *****
void draw_logs_tab(AppSession *session, UiState *state);
// *****
// map
// *****
void draw_map_pane(AppSession *session, UiState *state, Pane *pane, int pane_index);
// *****
// runtime (GLFW, async loaders, streaming, camera)
// *****
struct GLFWwindow;
struct RouteLoadSnapshot {
bool active = false;
size_t total_segments = 0;
size_t segments_downloaded = 0;
size_t segments_parsed = 0;
};
struct StreamPollSnapshot {
bool active = false;
bool connected = false;
bool paused = false;
StreamSourceKind source_kind = StreamSourceKind::CerealLocal;
std::string source_label;
std::string dbc_name;
std::string car_fingerprint;
double buffer_seconds = 30.0;
uint64_t received_messages = 0;
};
class GlfwRuntime {
public:
explicit GlfwRuntime(const Options &options);
~GlfwRuntime();
GlfwRuntime(const GlfwRuntime &) = delete;
GlfwRuntime &operator=(const GlfwRuntime &) = delete;
GLFWwindow *window() const;
private:
GLFWwindow *window_ = nullptr;
};
class ImGuiRuntime {
public:
explicit ImGuiRuntime(GLFWwindow *window);
~ImGuiRuntime();
ImGuiRuntime(const ImGuiRuntime &) = delete;
ImGuiRuntime &operator=(const ImGuiRuntime &) = delete;
};
class TerminalRouteProgress {
public:
explicit TerminalRouteProgress(bool enabled);
~TerminalRouteProgress();
TerminalRouteProgress(const TerminalRouteProgress &) = delete;
TerminalRouteProgress &operator=(const TerminalRouteProgress &) = delete;
void update(const RouteLoadProgress &progress);
void finish();
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
class AsyncRouteLoader {
public:
explicit AsyncRouteLoader(bool enable_terminal_progress);
~AsyncRouteLoader();
AsyncRouteLoader(const AsyncRouteLoader &) = delete;
AsyncRouteLoader &operator=(const AsyncRouteLoader &) = delete;
void start(const std::string &route_name, const std::string &data_dir, const std::string &dbc_name);
RouteLoadSnapshot snapshot() const;
bool consume(RouteData *route_data, std::string *error_text);
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
class StreamPoller {
public:
StreamPoller();
~StreamPoller();
StreamPoller(const StreamPoller &) = delete;
StreamPoller &operator=(const StreamPoller &) = delete;
void start(const StreamSourceConfig &source,
double buffer_seconds,
const std::string &dbc_name,
std::optional<double> time_offset = std::nullopt);
void setPaused(bool paused);
void stop();
StreamPollSnapshot snapshot() const;
bool consume(StreamExtractBatch *batch, std::string *error_text);
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
class CameraFeedView {
public:
CameraFeedView();
~CameraFeedView();
CameraFeedView(const CameraFeedView &) = delete;
CameraFeedView &operator=(const CameraFeedView &) = delete;
void setRouteData(const RouteData &route_data);
void setCameraIndex(const CameraFeedIndex &camera_index, CameraViewKind view);
void update(double tracker_time);
void draw(float width, bool loading);
void drawSized(ImVec2 size, bool loading, bool fit_to_pane = false);
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
class ThumbnailView {
public:
ThumbnailView();
~ThumbnailView();
ThumbnailView(const ThumbnailView &) = delete;
ThumbnailView &operator=(const ThumbnailView &) = delete;
void setThumbnails(const std::vector<ThumbnailFrame> &thumbnails);
void update(double tracker_time);
void drawSized(ImVec2 size, bool loading);
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
+458
View File
@@ -0,0 +1,458 @@
#include "tools/jotpluggler/app.h"
#include "imgui_internal.h"
#include <cmath>
#include <cstdio>
#include <unordered_set>
namespace {
constexpr float BROWSER_VALUE_WIDTH = 88.0f;
bool path_matches_filter(const std::string &path, const std::string &lower_filter) {
if (lower_filter.empty()) return true;
return lowercase_copy(path).find(lower_filter) != std::string::npos;
}
void insert_browser_path(std::vector<BrowserNode> *nodes, const std::string &path) {
size_t start = 0;
while (start < path.size() && path[start] == '/') {
++start;
}
std::vector<std::string> parts;
while (start < path.size()) {
const size_t end = path.find('/', start);
parts.push_back(path.substr(start, end == std::string::npos ? std::string::npos : end - start));
if (end == std::string::npos) break;
start = end + 1;
}
if (parts.empty()) {
return;
}
std::vector<BrowserNode> *current_nodes = nodes;
std::string current_path;
for (size_t i = 0; i < parts.size(); ++i) {
if (!current_path.empty()) {
current_path += "/";
}
current_path += parts[i];
auto it = std::find_if(current_nodes->begin(), current_nodes->end(),
[&](const BrowserNode &node) { return node.label == parts[i]; });
if (it == current_nodes->end()) {
current_nodes->push_back(BrowserNode{.label = parts[i]});
it = std::prev(current_nodes->end());
}
if (i + 1 == parts.size()) {
it->full_path = "/" + current_path;
}
current_nodes = &it->children;
}
}
void sort_browser_nodes(std::vector<BrowserNode> *nodes) {
std::sort(nodes->begin(), nodes->end(), [](const BrowserNode &a, const BrowserNode &b) {
if (a.children.empty() != b.children.empty()) {
return !a.children.empty();
}
return a.label < b.label;
});
for (BrowserNode &node : *nodes) {
sort_browser_nodes(&node.children);
}
}
std::vector<BrowserNode> build_browser_tree(const std::vector<std::string> &paths) {
std::vector<BrowserNode> nodes;
for (const std::string &path : paths) {
insert_browser_path(&nodes, path);
}
sort_browser_nodes(&nodes);
return nodes;
}
bool is_deprecated_browser_path(const std::string &path) {
return path.find("DEPRECATED") != std::string::npos || path.find("/deprecated/") != std::string::npos;
}
std::vector<std::string> visible_browser_paths(const RouteData &route_data, bool show_deprecated_fields) {
if (show_deprecated_fields) return route_data.paths;
std::vector<std::string> filtered;
filtered.reserve(route_data.paths.size());
for (const std::string &path : route_data.paths) {
if (!is_deprecated_browser_path(path)) {
filtered.push_back(path);
}
}
return filtered;
}
bool browser_selection_contains(const UiState &state, std::string_view path) {
return std::find(state.selected_browser_paths.begin(), state.selected_browser_paths.end(), path)
!= state.selected_browser_paths.end();
}
std::vector<std::string> browser_drag_paths(const UiState &state, const std::string &dragged_path) {
if (browser_selection_contains(state, dragged_path) && !state.selected_browser_paths.empty()) {
return state.selected_browser_paths;
}
return {dragged_path};
}
std::string encode_browser_drag_payload(const std::vector<std::string> &paths) {
std::string payload;
for (size_t i = 0; i < paths.size(); ++i) {
if (i != 0) {
payload.push_back('\n');
}
payload += paths[i];
}
return payload;
}
void set_browser_selection_single(UiState *state, const std::string &path) {
state->selected_browser_paths = {path};
state->selected_browser_path = path;
state->browser_selection_anchor = path;
}
void toggle_browser_selection(UiState *state, const std::string &path) {
auto it = std::find(state->selected_browser_paths.begin(), state->selected_browser_paths.end(), path);
if (it == state->selected_browser_paths.end()) {
state->selected_browser_paths.push_back(path);
} else {
state->selected_browser_paths.erase(it);
}
state->selected_browser_path = path;
state->browser_selection_anchor = path;
if (state->selected_browser_paths.empty()) {
state->selected_browser_path.clear();
}
}
void select_browser_range(UiState *state, const std::vector<std::string> &visible_paths, const std::string &clicked_path) {
if (visible_paths.empty()) {
set_browser_selection_single(state, clicked_path);
return;
}
const std::string anchor = state->browser_selection_anchor.empty() ? clicked_path : state->browser_selection_anchor;
const auto anchor_it = std::find(visible_paths.begin(), visible_paths.end(), anchor);
const auto clicked_it = std::find(visible_paths.begin(), visible_paths.end(), clicked_path);
if (clicked_it == visible_paths.end()) {
return;
}
if (anchor_it == visible_paths.end()) {
set_browser_selection_single(state, clicked_path);
return;
}
const auto [begin_it, end_it] = std::minmax(anchor_it, clicked_it);
std::vector<std::string> selected;
selected.reserve(static_cast<size_t>(std::distance(begin_it, end_it)) + 1);
for (auto it = begin_it; it != end_it + 1; ++it) {
selected.push_back(*it);
}
state->selected_browser_paths = std::move(selected);
state->selected_browser_path = clicked_path;
}
void prune_browser_selection(UiState *state, const std::vector<std::string> &visible_paths) {
const std::unordered_set<std::string> visible_set(visible_paths.begin(), visible_paths.end());
auto is_visible = [&](const std::string &path) {
return visible_set.count(path) > 0;
};
state->selected_browser_paths.erase(
std::remove_if(state->selected_browser_paths.begin(), state->selected_browser_paths.end(),
[&](const std::string &path) { return !is_visible(path); }),
state->selected_browser_paths.end());
if (!state->selected_browser_path.empty() && !is_visible(state->selected_browser_path)) {
state->selected_browser_path.clear();
}
if (!state->browser_selection_anchor.empty() && !is_visible(state->browser_selection_anchor)) {
state->browser_selection_anchor.clear();
}
if (state->selected_browser_paths.empty()) {
state->selected_browser_path.clear();
} else if (state->selected_browser_path.empty()) {
state->selected_browser_path = state->selected_browser_paths.back();
}
}
std::optional<double> sample_route_series_value(const RouteSeries &series, double tm, bool stairs) {
return app_sample_xy_value_at_time(series.times, series.values, stairs, tm);
}
std::string browser_series_value_text(const AppSession &session, const UiState &state, std::string_view path) {
auto it = session.series_by_path.find(std::string(path));
if (it == session.series_by_path.end() || it->second == nullptr) return {};
const RouteSeries &series = *it->second;
if (series.values.empty()) return {};
const auto enum_it = session.route_data.enum_info.find(series.path);
const EnumInfo *enum_info = enum_it == session.route_data.enum_info.end() ? nullptr : &enum_it->second;
const bool stairs = enum_info != nullptr;
std::optional<double> value;
if (state.has_tracker_time) {
value = sample_route_series_value(series, state.tracker_time, stairs);
} else {
value = series.values.back();
}
if (!value.has_value()) return {};
const auto display_it = session.route_data.series_formats.find(series.path);
const SeriesFormat display_info = display_it == session.route_data.series_formats.end()
? compute_series_format(series.values, enum_info != nullptr)
: display_it->second;
return format_display_value(*value, display_info, enum_info);
}
bool browser_node_matches(const BrowserNode &node, const std::string &filter) {
if (filter.empty()) return true;
if (!node.full_path.empty() && path_matches_filter(node.full_path, filter)) {
return true;
}
for (const BrowserNode &child : node.children) {
if (browser_node_matches(child, filter)) return true;
}
return false;
}
} // namespace
namespace {
int decimals_needed(double value) {
const double abs_value = std::abs(value);
if (abs_value < 1.0e-12) return 0;
for (int decimals = 0; decimals <= 6; ++decimals) {
const double scale = std::pow(10.0, decimals);
if (std::abs(abs_value * scale - std::round(abs_value * scale)) < 1.0e-6) {
return decimals;
}
}
return 6;
}
void finalize_series_format(SeriesFormat *format) {
format->digits_before = std::max(format->digits_before, 1);
format->decimals = std::clamp(format->decimals, 0, 6);
format->integer_like = format->decimals == 0;
const int sign_width = format->has_negative ? 1 : 0;
const int dot_width = format->decimals > 0 ? 1 : 0;
format->total_width = sign_width + format->digits_before + dot_width + format->decimals;
std::snprintf(format->fmt, sizeof(format->fmt), "%%%d.%df", format->total_width, format->decimals);
}
} // namespace
SeriesFormat compute_series_format(const std::vector<double> &values, bool enum_like) {
SeriesFormat format;
if (values.empty()) return format;
const size_t step = std::max<size_t>(1, values.size() / 256);
bool saw_finite = false;
bool all_integer = enum_like;
double min_value = 0.0;
double max_value = 0.0;
int max_needed_decimals = 0;
for (size_t i = 0; i < values.size(); i += step) {
const double value = values[i];
if (!std::isfinite(value)) continue;
if (!saw_finite) {
min_value = value;
max_value = value;
saw_finite = true;
} else {
min_value = std::min(min_value, value);
max_value = std::max(max_value, value);
}
if (std::abs(value - std::round(value)) > 1.0e-9) {
all_integer = false;
}
if (!all_integer) {
max_needed_decimals = std::max(max_needed_decimals, decimals_needed(value));
}
}
if (!saw_finite) return format;
format.has_negative = min_value < 0.0;
const double peak = std::max(std::abs(min_value), std::abs(max_value));
format.digits_before = peak < 1.0 ? 1 : static_cast<int>(std::floor(std::log10(peak))) + 1;
if (enum_like || all_integer) {
format.decimals = 0;
} else if (peak >= 1000.0) {
format.decimals = std::min(max_needed_decimals, 1);
} else if (peak >= 100.0) {
format.decimals = std::min(max_needed_decimals, 2);
} else {
format.decimals = std::min(max_needed_decimals, 4);
}
finalize_series_format(&format);
return format;
}
std::string format_display_value(double display_value,
const SeriesFormat &display_info,
const EnumInfo *enum_info) {
if (!std::isfinite(display_value)) return "---";
if (enum_info != nullptr) {
const int idx = static_cast<int>(std::llround(display_value));
if (idx >= 0 && std::abs(display_value - static_cast<double>(idx)) < 0.01
&& static_cast<size_t>(idx) < enum_info->names.size()
&& !enum_info->names[static_cast<size_t>(idx)].empty()) {
return enum_info->names[static_cast<size_t>(idx)];
}
}
char buf[64] = {};
std::snprintf(buf, sizeof(buf), display_info.fmt, display_value);
return buf;
}
std::vector<std::string> decode_browser_drag_payload(std::string_view payload) {
std::vector<std::string> out;
size_t begin = 0;
while (begin <= payload.size()) {
const size_t end = payload.find('\n', begin);
const size_t length = (end == std::string_view::npos ? payload.size() : end) - begin;
if (length > 0) {
out.emplace_back(payload.substr(begin, length));
}
if (end == std::string_view::npos) break;
begin = end + 1;
}
return out;
}
void collect_visible_leaf_paths(const BrowserNode &node,
const std::string &filter,
std::vector<std::string> *out) {
if (!browser_node_matches(node, filter)) {
return;
}
if (node.children.empty()) {
if (!node.full_path.empty()) {
out->push_back(node.full_path);
}
return;
}
for (const BrowserNode &child : node.children) {
collect_visible_leaf_paths(child, filter, out);
}
}
void rebuild_browser_nodes(AppSession *session, UiState *state) {
const std::vector<std::string> paths = visible_browser_paths(session->route_data, state->show_deprecated_fields);
session->browser_nodes = build_browser_tree(paths);
prune_browser_selection(state, paths);
}
void rebuild_route_index(AppSession *session) {
session->series_by_path.clear();
session->route_data.series_formats.clear();
for (RouteSeries &series : session->route_data.series) {
session->series_by_path.emplace(series.path, &series);
const bool enum_like = session->route_data.enum_info.find(series.path) != session->route_data.enum_info.end();
session->route_data.series_formats.emplace(series.path, compute_series_format(series.values, enum_like));
}
}
void draw_browser_node(AppSession *session,
const BrowserNode &node,
UiState *state,
const std::string &filter,
const std::vector<std::string> &visible_paths) {
if (!browser_node_matches(node, filter)) {
return;
}
if (node.children.empty()) {
const std::string value_text = browser_series_value_text(*session, *state, node.full_path);
const ImGuiStyle &style = ImGui::GetStyle();
const ImVec2 row_size(std::max(1.0f, ImGui::GetContentRegionAvail().x), ImGui::GetFrameHeight());
ImGui::PushID(node.full_path.c_str());
const bool clicked = ImGui::InvisibleButton("##browser_leaf", row_size);
const bool hovered = ImGui::IsItemHovered();
const ImRect rect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax());
ImDrawList *draw_list = ImGui::GetWindowDrawList();
if (hovered) {
const ImU32 bg = ImGui::GetColorU32(ImGuiCol_HeaderHovered);
draw_list->AddRectFilled(rect.Min, rect.Max, bg, 0.0f);
}
const float value_right = rect.Max.x - style.FramePadding.x;
const float value_left = value_right - (value_text.empty() ? 0.0f : BROWSER_VALUE_WIDTH);
const float label_left = rect.Min.x + style.FramePadding.x;
const float label_right = value_text.empty()
? rect.Max.x - style.FramePadding.x
: std::max(label_left + 40.0f, value_left - 10.0f);
ImGui::RenderTextEllipsis(draw_list,
ImVec2(label_left, rect.Min.y + style.FramePadding.y),
ImVec2(label_right, rect.Max.y),
label_right,
node.label.c_str(),
nullptr,
nullptr);
if (!value_text.empty()) {
app_push_mono_font();
ImGui::PushStyleColor(ImGuiCol_Text, color_rgb(116, 124, 133));
ImGui::RenderTextClipped(ImVec2(value_left, rect.Min.y + style.FramePadding.y),
ImVec2(value_right, rect.Max.y),
value_text.c_str(),
nullptr,
nullptr,
ImVec2(1.0f, 0.0f));
ImGui::PopStyleColor();
app_pop_mono_font();
}
if (clicked) {
const bool shift_down = ImGui::GetIO().KeyShift;
const bool ctrl_down = ImGui::GetIO().KeyCtrl || ImGui::GetIO().KeySuper;
if (shift_down) {
select_browser_range(state, visible_paths, node.full_path);
} else if (ctrl_down) {
toggle_browser_selection(state, node.full_path);
} else {
set_browser_selection_single(state, node.full_path);
}
}
if (hovered && ImGui::IsMouseDoubleClicked(0)) {
set_browser_selection_single(state, node.full_path);
app_add_curve_to_active_pane(session, state, node.full_path);
}
if (ImGui::BeginDragDropSource(ImGuiDragDropFlags_SourceAllowNullID)) {
const std::vector<std::string> drag_paths = browser_drag_paths(*state, node.full_path);
const std::string payload = encode_browser_drag_payload(drag_paths);
ImGui::SetDragDropPayload("JOTP_BROWSER_PATHS", payload.c_str(), payload.size() + 1);
if (drag_paths.size() == 1) {
ImGui::TextUnformatted(drag_paths.front().c_str());
} else {
ImGui::Text("%zu timeseries", drag_paths.size());
ImGui::TextUnformatted(drag_paths.front().c_str());
}
ImGui::EndDragDropSource();
}
ImGui::PopID();
return;
}
ImGuiTreeNodeFlags flags = ImGuiTreeNodeFlags_SpanAvailWidth;
const bool open = ImGui::TreeNodeEx(node.label.c_str(), flags);
if (open) {
for (const BrowserNode &child : node.children) {
draw_browser_node(session, child, state, filter, visible_paths);
}
ImGui::TreePop();
}
}
+54
View File
@@ -0,0 +1,54 @@
#include "tools/jotpluggler/camera.h"
#include "imgui.h"
#include "imgui_internal.h"
namespace {
bool draw_camera_fit_toggle_overlay(bool fit_to_pane) {
const ImVec2 window_pos = ImGui::GetWindowPos();
const ImVec2 content_min = ImGui::GetWindowContentRegionMin();
const ImRect rect(ImVec2(window_pos.x + content_min.x + 8.0f, window_pos.y + content_min.y + 8.0f),
ImVec2(window_pos.x + content_min.x + 58.0f, window_pos.y + content_min.y + 28.0f));
const bool hovered = ImGui::IsMouseHoveringRect(rect.Min, rect.Max, false);
const bool held = hovered && ImGui::IsMouseDown(ImGuiMouseButton_Left);
if (hovered) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
ImDrawList *draw_list = ImGui::GetWindowDrawList();
draw_list->AddRectFilled(rect.Min, rect.Max, hovered ? IM_COL32(255, 255, 255, 234) : IM_COL32(255, 255, 255, 214), 4.0f);
draw_list->AddRect(rect.Min, rect.Max, IM_COL32(184, 189, 196, 255), 4.0f, 0, 1.0f);
const ImRect box(ImVec2(rect.Min.x + 6.0f, rect.Min.y + 4.0f), ImVec2(rect.Min.x + 18.0f, rect.Min.y + 16.0f));
draw_list->AddRect(box.Min, box.Max, IM_COL32(112, 120, 129, 255), 2.0f, 0, 1.0f);
if (fit_to_pane) {
draw_list->AddLine(ImVec2(box.Min.x + 2.5f, box.Min.y + 6.5f), ImVec2(box.Min.x + 5.5f, box.Max.y - 2.5f), IM_COL32(60, 111, 202, 255), 1.8f);
draw_list->AddLine(ImVec2(box.Min.x + 5.5f, box.Max.y - 2.5f), ImVec2(box.Max.x - 2.5f, box.Min.y + 2.5f), IM_COL32(60, 111, 202, 255), 1.8f);
}
draw_list->AddText(ImVec2(box.Max.x + 6.0f, rect.Min.y + 3.0f), IM_COL32(72, 79, 88, 255), "Fit");
return hovered && !held && ImGui::IsMouseReleased(ImGuiMouseButton_Left);
}
} // namespace
void draw_camera_pane(AppSession *session, UiState *state, TabUiState *tab_state, int pane_index, const Pane &pane) {
CameraFeedView *feed = session->pane_camera_feeds[static_cast<size_t>(pane.camera_view)].get();
if (feed == nullptr) {
ImGui::TextDisabled("Camera unavailable");
return;
}
const bool fit_to_pane = tab_state != nullptr
&& pane_index >= 0
&& pane_index < static_cast<int>(tab_state->camera_panes.size())
? tab_state->camera_panes[static_cast<size_t>(pane_index)].fit_to_pane
: true;
if (state->has_tracker_time) {
feed->update(state->tracker_time);
}
feed->drawSized(ImGui::GetContentRegionAvail(), session->async_route_loading, fit_to_pane);
if (tab_state != nullptr
&& pane_index >= 0
&& pane_index < static_cast<int>(tab_state->camera_panes.size())
&& draw_camera_fit_toggle_overlay(fit_to_pane)) {
tab_state->camera_panes[static_cast<size_t>(pane_index)].fit_to_pane = !fit_to_pane;
}
}
+5
View File
@@ -0,0 +1,5 @@
#pragma once
#include "tools/jotpluggler/app.h"
void draw_camera_pane(AppSession *session, UiState *state, TabUiState *tab_state, int pane_index, const Pane &pane);
+187
View File
@@ -0,0 +1,187 @@
#include "tools/jotpluggler/common.h"
#include <algorithm>
#include <array>
#include <cstdlib>
namespace {
std::string format_coord(const GpsPoint &point) {
return util::string_format("%.5f,%.5f", point.lat, point.lon);
}
} // namespace
const CameraViewSpec &camera_view_spec(CameraViewKind view) {
auto it = std::find_if(kCameraViewSpecs.begin(), kCameraViewSpecs.end(), [&](const CameraViewSpec &spec) {
return spec.view == view;
});
return it != kCameraViewSpecs.end() ? *it : kCameraViewSpecs.front();
}
const CameraViewSpec *camera_view_spec_from_special_item(std::string_view item_id) {
auto it = std::find_if(kCameraViewSpecs.begin(), kCameraViewSpecs.end(), [&](const CameraViewSpec &spec) {
return item_id == spec.special_item_id;
});
return it != kCameraViewSpecs.end() ? &*it : nullptr;
}
const CameraViewSpec *camera_view_spec_from_layout_name(std::string_view layout_name) {
auto it = std::find_if(kCameraViewSpecs.begin(), kCameraViewSpecs.end(), [&](const CameraViewSpec &spec) {
return layout_name == spec.layout_name;
});
return it != kCameraViewSpecs.end() ? &*it : nullptr;
}
const SpecialItemSpec *special_item_spec(std::string_view item_id) {
auto it = std::find_if(kSpecialItemSpecs.begin(), kSpecialItemSpecs.end(), [&](const SpecialItemSpec &spec) {
return item_id == spec.id;
});
return it != kSpecialItemSpecs.end() ? &*it : nullptr;
}
const char *special_item_label(std::string_view item_id) {
const SpecialItemSpec *spec = special_item_spec(item_id);
return spec != nullptr ? spec->label : "Item";
}
bool pane_kind_is_special(PaneKind kind) {
return kind == PaneKind::Map || kind == PaneKind::Thumbnail || kind == PaneKind::Camera;
}
bool is_default_special_title(std::string_view title) {
if (title == "Map" || title == "Thumbnail") return true;
return std::any_of(kCameraViewSpecs.begin(), kCameraViewSpecs.end(), [&](const CameraViewSpec &spec) {
return title == spec.label;
});
}
CameraViewKind sidebar_preview_camera_view(const AppSession &session) {
return session.route_data.road_camera.entries.empty() && !session.route_data.qroad_camera.entries.empty()
? CameraViewKind::QRoad
: CameraViewKind::Road;
}
const std::filesystem::path &repo_root() {
static const std::filesystem::path root(JOTP_REPO_ROOT);
return root;
}
ImU32 timeline_entry_color(TimelineEntry::Type type, float alpha) {
return timeline_entry_color(type, alpha, {111, 143, 175});
}
ImU32 timeline_entry_color(TimelineEntry::Type type, float alpha, std::array<uint8_t, 3> none_color) {
switch (type) {
case TimelineEntry::Type::Engaged:
return ImGui::GetColorU32(color_rgb(0, 163, 108, alpha));
case TimelineEntry::Type::AlertInfo:
return ImGui::GetColorU32(color_rgb(255, 195, 0, alpha));
case TimelineEntry::Type::AlertWarning:
case TimelineEntry::Type::AlertCritical:
return ImGui::GetColorU32(color_rgb(199, 0, 57, alpha));
case TimelineEntry::Type::None:
default:
return ImGui::GetColorU32(color_rgb(none_color, alpha));
}
}
const char *timeline_entry_label(TimelineEntry::Type type) {
static constexpr const char *kLabels[] = {
"disengaged",
"engaged",
"alert info",
"alert warning",
"alert critical",
};
const size_t index = static_cast<size_t>(type);
return index < std::size(kLabels) ? kLabels[index] : kLabels[0];
}
TimelineEntry::Type timeline_type_at_time(const std::vector<TimelineEntry> &timeline, double time_value) {
for (const TimelineEntry &entry : timeline) {
if (time_value >= entry.start_time && time_value <= entry.end_time) {
return entry.type;
}
}
return TimelineEntry::Type::None;
}
std::string normalize_stream_address(std::string address) {
return is_local_stream_address(address) ? "127.0.0.1" : address;
}
const char *stream_source_kind_label(StreamSourceKind kind) {
static constexpr const char *kLabels[] = {
"Local (MSGQ)",
"Remote (ZMQ)",
};
const size_t index = static_cast<size_t>(kind);
return index < std::size(kLabels) ? kLabels[index] : kLabels[0];
}
std::string stream_source_target_label(const StreamSourceConfig &source) {
switch (source.kind) {
case StreamSourceKind::CerealRemote:
return normalize_stream_address(source.address);
case StreamSourceKind::CerealLocal:
default:
return "127.0.0.1";
}
}
bool env_flag_enabled(const char *name, bool default_value) {
const char *raw = std::getenv(name);
if (raw == nullptr || raw[0] == '\0') {
return default_value;
}
const std::string value = lowercase_copy(util::strip(raw));
return !(value == "0" || value == "false" || value == "no" || value == "off");
}
bool app_begin_popup(const char *str_id, ImGuiWindowFlags flags) {
return ImGui::BeginPopup(str_id, flags | ImGuiWindowFlags_NoMove);
}
bool app_begin_popup_modal(const char *name, bool *p_open, ImGuiWindowFlags flags) {
return ImGui::BeginPopupModal(name, p_open, flags | ImGuiWindowFlags_NoMove);
}
void open_external_url(std::string_view url) {
#ifdef __APPLE__
const std::string command = "open " + shell_quote(url) + " &";
#else
const std::string command = "xdg-open " + shell_quote(url) + " >/dev/null 2>&1 &";
#endif
util::check_system(command);
}
std::string route_useradmin_url(const RouteIdentifier &route_id) {
return route_id.empty() ? std::string()
: "https://useradmin.comma.ai/?onebox=" + route_id.dongle_id + "%7C" + route_id.log_id;
}
std::string route_connect_url(const RouteIdentifier &route_id) {
return route_id.empty() ? std::string()
: "https://connect.comma.ai/" + route_id.canonical();
}
std::string route_google_maps_url(const GpsTrace &trace) {
if (trace.points.size() < 2) {
return {};
}
const std::string prefix = "https://www.google.com/maps/dir/?api=1&travelmode=driving&origin="
+ format_coord(trace.points.front()) + "&destination=" + format_coord(trace.points.back());
for (size_t n = std::min<size_t>(9, trace.points.size() > 2 ? trace.points.size() - 2 : 0); ; --n) {
std::string url = prefix;
if (n > 0) {
url += "&waypoints=";
for (size_t i = 0; i < n; ++i) {
if (i) url += "%7C";
url += format_coord(trace.points[1 + ((trace.points.size() - 2) * (i + 1)) / (n + 1)]);
}
}
if (url.size() <= 1900 || n == 0) return url;
}
}
+68
View File
@@ -0,0 +1,68 @@
#pragma once
#include "tools/jotpluggler/app.h"
#include <array>
#include <string_view>
struct CameraViewSpec {
CameraViewKind view = CameraViewKind::Road;
const char *label = "";
const char *runtime_name = "";
const char *layout_name = "";
const char *special_item_id = "";
CameraFeedIndex RouteData::*route_member = nullptr;
};
struct SpecialItemSpec {
const char *id = "";
const char *label = "";
PaneKind kind = PaneKind::Plot;
CameraViewKind camera_view = CameraViewKind::Road;
};
inline constexpr std::array<CameraViewSpec, 4> kCameraViewSpecs = {{
{CameraViewKind::Road, "Road Camera", "road", "road", "camera_road", &RouteData::road_camera},
{CameraViewKind::Driver, "Driver Camera", "driver", "driver", "camera_driver", &RouteData::driver_camera},
{CameraViewKind::WideRoad, "Wide Road Camera", "wide", "wide_road", "camera_wide_road", &RouteData::wide_road_camera},
{CameraViewKind::QRoad, "qRoad Camera", "qroad", "qroad", "camera_qroad", &RouteData::qroad_camera},
}};
inline constexpr std::array<SpecialItemSpec, 6> kSpecialItemSpecs = {{
{"map", "Map", PaneKind::Map, CameraViewKind::Road},
{"thumbnail", "Thumbnail", PaneKind::Thumbnail, CameraViewKind::Road},
{kCameraViewSpecs[0].special_item_id, kCameraViewSpecs[0].label, PaneKind::Camera, kCameraViewSpecs[0].view},
{kCameraViewSpecs[1].special_item_id, kCameraViewSpecs[1].label, PaneKind::Camera, kCameraViewSpecs[1].view},
{kCameraViewSpecs[2].special_item_id, kCameraViewSpecs[2].label, PaneKind::Camera, kCameraViewSpecs[2].view},
{kCameraViewSpecs[3].special_item_id, kCameraViewSpecs[3].label, PaneKind::Camera, kCameraViewSpecs[3].view},
}};
const CameraViewSpec &camera_view_spec(CameraViewKind view);
const CameraViewSpec *camera_view_spec_from_special_item(std::string_view item_id);
const CameraViewSpec *camera_view_spec_from_layout_name(std::string_view layout_name);
const SpecialItemSpec *special_item_spec(std::string_view item_id);
const char *special_item_label(std::string_view item_id);
bool pane_kind_is_special(PaneKind kind);
bool is_default_special_title(std::string_view title);
CameraViewKind sidebar_preview_camera_view(const AppSession &session);
const std::filesystem::path &repo_root();
ImU32 timeline_entry_color(TimelineEntry::Type type, float alpha = 1.0f);
ImU32 timeline_entry_color(TimelineEntry::Type type, float alpha, std::array<uint8_t, 3> none_color);
const char *timeline_entry_label(TimelineEntry::Type type);
TimelineEntry::Type timeline_type_at_time(const std::vector<TimelineEntry> &timeline, double time_value);
std::string normalize_stream_address(std::string address);
const char *stream_source_kind_label(StreamSourceKind kind);
std::string stream_source_target_label(const StreamSourceConfig &source);
bool env_flag_enabled(const char *name, bool default_value = false);
bool app_begin_popup(const char *str_id, ImGuiWindowFlags flags = 0);
bool app_begin_popup_modal(const char *name,
bool *p_open = nullptr,
ImGuiWindowFlags flags = ImGuiWindowFlags_AlwaysAutoResize);
void open_external_url(std::string_view url);
std::string route_useradmin_url(const RouteIdentifier &route_id);
std::string route_connect_url(const RouteIdentifier &route_id);
std::string route_google_maps_url(const GpsTrace &trace);
@@ -0,0 +1,750 @@
#include "tools/jotpluggler/app.h"
#include "tools/jotpluggler/common.h"
#include "implot.h"
#include <cfloat>
#include <chrono>
#include <cstring>
#include <regex>
#include <set>
#include <stdexcept>
#include <unistd.h>
#include "json11/json11.hpp"
namespace fs = std::filesystem;
namespace {
struct PythonEvalResult {
std::vector<double> xs;
std::vector<double> ys;
};
struct CustomSeriesTemplate {
const char *name;
const char *globals_code;
const char *function_code;
const char *preview_text;
int required_additional_sources;
const char *requirement_text;
};
void write_binary_vector(const fs::path &path, const std::vector<double> &values) {
write_file_or_throw(path, values.data(), values.size() * sizeof(double));
}
std::vector<double> read_binary_vector(const fs::path &path) {
const std::string raw = read_file_or_throw(path);
if (raw.size() % sizeof(double) != 0) {
throw std::runtime_error("Invalid binary series file: " + path.string());
}
std::vector<double> values(raw.size() / sizeof(double));
if (!values.empty()) {
std::memcpy(values.data(), raw.data(), raw.size());
}
return values;
}
void write_text_file(const fs::path &path, std::string_view text) {
write_file_or_throw(path, text);
}
fs::path create_custom_series_temp_dir() {
const auto stamp = std::chrono::steady_clock::now().time_since_epoch().count();
const fs::path dir = fs::temp_directory_path() / ("jotpluggler_math_" + std::to_string(::getpid()) + "_" + std::to_string(stamp));
fs::create_directories(dir);
return dir;
}
void reset_custom_series_editor(CustomSeriesEditorState *editor) {
*editor = CustomSeriesEditorState{};
}
bool add_additional_source(CustomSeriesEditorState *editor, const std::string &path) {
if (path.empty() || path == editor->linked_source) return false;
if (std::find(editor->additional_sources.begin(), editor->additional_sources.end(), path) != editor->additional_sources.end()) {
return false;
}
editor->additional_sources.push_back(path);
return true;
}
std::string next_custom_curve_name(const Pane &pane) {
std::set<std::string> used;
for (const Curve &curve : pane.curves) {
if (!curve.label.empty()) {
used.insert(curve.label);
}
if (!curve.name.empty()) {
used.insert(curve.name);
}
}
for (int i = 1; i < 1000; ++i) {
const std::string candidate = "series" + std::to_string(i);
if (used.find(candidate) == used.end()) {
return candidate;
}
}
return "series";
}
Curve make_custom_curve(const Pane &pane,
const std::string &name,
const CustomPythonSeries &spec,
PythonEvalResult result) {
Curve curve;
curve.name = name;
curve.label = name;
curve.color = app_next_curve_color(pane);
curve.runtime_only = true;
curve.custom_python = spec;
curve.xs = std::move(result.xs);
curve.ys = std::move(result.ys);
return curve;
}
bool upsert_custom_curve_in_pane(WorkspaceTab *tab, int pane_index, Curve curve) {
if (pane_index < 0 || pane_index >= static_cast<int>(tab->panes.size())) {
return false;
}
Pane &pane = tab->panes[static_cast<size_t>(pane_index)];
for (Curve &existing : pane.curves) {
if (existing.runtime_only && existing.name == curve.name) {
existing.visible = true;
existing.label = curve.label;
existing.custom_python = curve.custom_python;
existing.xs = std::move(curve.xs);
existing.ys = std::move(curve.ys);
return false;
}
}
pane.curves.push_back(std::move(curve));
return true;
}
std::set<std::string> collect_custom_series_paths(const CustomPythonSeries &spec,
std::string_view globals_code,
std::string_view function_code) {
std::set<std::string> paths;
if (!spec.linked_source.empty()) {
paths.insert(spec.linked_source);
}
paths.insert(spec.additional_sources.begin(), spec.additional_sources.end());
static const std::regex kPathRegex(R"([tv]\(\s*["']([^"']+)["']\s*\))");
const auto collect_from = [&](std::string_view code) {
std::string owned(code);
for (std::sregex_iterator it(owned.begin(), owned.end(), kPathRegex), end; it != end; ++it) {
paths.insert((*it)[1].str());
}
};
collect_from(globals_code);
collect_from(function_code);
return paths;
}
PythonEvalResult evaluate_custom_python_series(const AppSession &session,
const CustomPythonSeries &spec) {
const std::set<std::string> referenced_paths =
collect_custom_series_paths(spec, spec.globals_code, spec.function_code);
if (referenced_paths.empty()) throw std::runtime_error("No input series referenced. Set an input timeseries or reference route paths in code.");
const fs::path temp_dir = create_custom_series_temp_dir();
try {
const fs::path globals_path = temp_dir / "globals.py";
const fs::path code_path = temp_dir / "code.py";
const fs::path manifest_path = temp_dir / "manifest.json";
const fs::path out_t_path = temp_dir / "result.t.bin";
const fs::path out_v_path = temp_dir / "result.v.bin";
write_text_file(globals_path, spec.globals_code);
write_text_file(code_path, spec.function_code);
json11::Json::array paths_json(session.route_data.paths.begin(), session.route_data.paths.end());
json11::Json::array additional_json(spec.additional_sources.begin(), spec.additional_sources.end());
json11::Json::array series_json;
size_t series_index = 0;
for (const std::string &path : referenced_paths) {
const RouteSeries *series = app_find_route_series(session, path);
if (series == nullptr || series->times.size() < 2 || series->times.size() != series->values.size()) {
throw std::runtime_error("Missing route series " + path);
}
const std::string prefix = "series_" + std::to_string(series_index++);
const fs::path time_path = temp_dir / (prefix + ".t.bin");
const fs::path value_path = temp_dir / (prefix + ".v.bin");
write_binary_vector(time_path, series->times);
write_binary_vector(value_path, series->values);
series_json.push_back(json11::Json::object{
{"path", path}, {"t", time_path.string()}, {"v", value_path.string()}});
}
const json11::Json manifest_json = json11::Json::object{
{"paths", std::move(paths_json)},
{"linked_source", spec.linked_source},
{"additional_sources", std::move(additional_json)},
{"series", std::move(series_json)},
};
write_text_file(manifest_path, manifest_json.dump());
const CommandResult process = run_process_capture_output({
"python3",
(repo_root() / "openpilot" / "tools" / "jotpluggler" / "math_eval.py").string(),
manifest_path.string(),
globals_path.string(),
code_path.string(),
out_t_path.string(),
out_v_path.string(),
});
if (process.exit_code != 0) {
const std::string error_text = util::strip(process.output);
throw std::runtime_error(error_text.empty() ? "Python evaluation failed" : error_text);
}
PythonEvalResult result;
result.xs = read_binary_vector(out_t_path);
result.ys = read_binary_vector(out_v_path);
if (result.xs.size() < 2 || result.xs.size() != result.ys.size()) {
throw std::runtime_error("Custom series returned invalid output");
}
fs::remove_all(temp_dir);
return result;
} catch (...) {
std::error_code ignore_error;
fs::remove_all(temp_dir, ignore_error);
throw;
}
}
void refresh_custom_curve_samples(AppSession *session, UiState *state, Curve *curve) {
if (!curve->custom_python.has_value()) {
return;
}
if (!session->route_data.has_time_range || session->route_data.series.empty()) {
curve->runtime_error_message.clear();
curve->xs.clear();
curve->ys.clear();
return;
}
try {
PythonEvalResult result = evaluate_custom_python_series(*session, *curve->custom_python);
curve->runtime_error_message.clear();
curve->xs = std::move(result.xs);
curve->ys = std::move(result.ys);
} catch (const std::exception &err) {
curve->xs.clear();
curve->ys.clear();
const std::string err_text = err.what();
if (session->data_mode == SessionDataMode::Stream && util::starts_with(err_text, "Missing route series ")) {
curve->runtime_error_message = err_text;
return;
}
const std::string error_message = std::string("Failed to evaluate custom series \"")
+ app_curve_display_name(*curve) + "\":\n\n" + err_text;
if (curve->runtime_error_message != error_message) {
curve->runtime_error_message = error_message;
state->error_text = error_message;
state->open_error_popup = true;
}
}
}
const std::array<CustomSeriesTemplate, 4> &custom_series_templates() {
static constexpr std::array<CustomSeriesTemplate, 4> kTemplates = {{
{
.name = "Derivative",
.globals_code = "",
.function_code = "return np.gradient(value, time)",
.preview_text = "return np.gradient(value, time)",
.required_additional_sources = 0,
.requirement_text = "",
},
{
.name = "Difference",
.globals_code = "",
.function_code = "return value - v1",
.preview_text = "Requires one additional source timeseries.\n\nreturn value - v1",
.required_additional_sources = 1,
.requirement_text = "Difference requires one additional source timeseries for v1.",
},
{
.name = "Smoothing",
.globals_code = "window = 20\nweights = np.ones(window) / window",
.function_code = "return np.convolve(value, weights, mode='same')",
.preview_text = "window = 20\nweights = np.ones(window) / window\n\nreturn np.convolve(value, weights, mode='same')",
.required_additional_sources = 0,
.requirement_text = "",
},
{
.name = "Integral",
.globals_code = "",
.function_code = "dt = np.mean(np.diff(time))\nreturn np.cumsum(value) * dt",
.preview_text = "dt = np.mean(np.diff(time))\nreturn np.cumsum(value) * dt",
.required_additional_sources = 0,
.requirement_text = "",
},
}};
return kTemplates;
}
void draw_custom_series_help_popup(CustomSeriesEditorState *editor) {
if (editor->open_help) {
ImGui::OpenPopup("Custom Series Help");
editor->open_help = false;
}
if (!app_begin_popup_modal("Custom Series Help")) {
return;
}
ImGui::TextUnformatted("Available variables");
ImGui::Separator();
ImGui::BulletText("np: numpy");
ImGui::BulletText("t(path), v(path): timestamps and values for a route series");
ImGui::BulletText("paths: all available route series paths");
ImGui::BulletText("time, value: linked input timeseries");
ImGui::BulletText("t1, v1, t2, v2, ...: additional source timeseries");
ImGui::Spacing();
ImGui::TextWrapped("Write either a single expression like \"return np.gradient(value, time)\" "
"or a multi-line Python body that returns an array or a (times, values) tuple.");
ImGui::Spacing();
if (ImGui::Button("Close", ImVec2(120.0f, 0.0f))) {
ImGui::CloseCurrentPopup();
}
ImGui::EndPopup();
}
void draw_custom_series_preview(const AppSession &session, CustomSeriesEditorState *editor) {
std::vector<double> preview_xs;
std::vector<double> preview_ys;
std::string preview_label = editor->preview_label;
if (editor->preview_is_result && editor->preview_xs.size() > 1 && editor->preview_xs.size() == editor->preview_ys.size()) {
preview_xs = editor->preview_xs;
preview_ys = editor->preview_ys;
if (preview_label.empty()) {
preview_label = "Result preview";
}
} else if (!editor->linked_source.empty()) {
if (const RouteSeries *series = app_find_route_series(session, editor->linked_source); series != nullptr
&& series->times.size() > 1 && series->times.size() == series->values.size()) {
preview_xs = series->times;
preview_ys = series->values;
preview_label = "Input preview (not result)";
}
}
if (!preview_xs.empty() && preview_xs.size() == preview_ys.size()) {
std::vector<double> plot_xs;
std::vector<double> plot_ys;
app_decimate_samples(preview_xs, preview_ys, 1200, &plot_xs, &plot_ys);
const double preview_x_min = preview_xs.front();
const double preview_x_max = preview_xs.back() > preview_xs.front()
? preview_xs.back()
: preview_xs.front() + 1e-6;
std::string plot_id = "##custom_series_preview";
if (editor->preview_is_result) {
plot_id += "_result_";
plot_id += editor->name.empty() ? preview_label : editor->name;
} else if (!editor->linked_source.empty()) {
plot_id += "_input_";
plot_id += editor->linked_source;
}
ImGui::TextUnformatted(preview_label.c_str());
if (!editor->linked_source.empty() && !editor->preview_is_result) {
ImGui::SameLine();
ImGui::TextDisabled("%s", editor->linked_source.c_str());
}
if (ImPlot::BeginPlot(plot_id.c_str(),
ImVec2(-1.0f, std::max(180.0f, ImGui::GetContentRegionAvail().y - 6.0f)),
ImPlotFlags_NoTitle | ImPlotFlags_NoMenus | ImPlotFlags_NoLegend)) {
ImPlot::SetupAxes(nullptr, nullptr, ImPlotAxisFlags_NoMenus | ImPlotAxisFlags_NoHighlight,
ImPlotAxisFlags_NoMenus | ImPlotAxisFlags_NoHighlight | ImPlotAxisFlags_AutoFit | ImPlotAxisFlags_RangeFit);
ImPlot::SetupAxisLimitsConstraints(ImAxis_X1, preview_x_min, preview_x_max);
ImPlot::SetupAxisLimits(ImAxis_X1, preview_x_min, preview_x_max, ImPlotCond_Once);
ImPlot::SetupAxisFormat(ImAxis_X1, "%.1f");
ImPlot::SetupAxisFormat(ImAxis_Y1, "%.6g");
ImPlotSpec spec;
spec.LineColor = color_rgb(35, 107, 180);
spec.LineWeight = 2.0f;
ImPlot::PlotLine("##custom_preview_line", plot_xs.data(), plot_ys.data(), static_cast<int>(plot_xs.size()), spec);
ImPlot::EndPlot();
}
} else {
ImGui::SetCursorPosY(ImGui::GetCursorPosY() + 72.0f);
ImGui::PushStyleColor(ImGuiCol_Text, color_rgb(116, 124, 133));
ImGui::TextWrapped("Choose an input timeseries or click Preview to evaluate the custom result.");
ImGui::PopStyleColor();
}
}
std::string custom_series_name_status(const Pane &pane, std::string_view name) {
const std::string trimmed = util::strip(std::string(name));
if (trimmed.empty()) return "name required";
if (!trimmed.empty() && trimmed.front() == '/') {
return "cannot start with /";
}
for (const Curve &curve : pane.curves) {
if (curve.runtime_only && curve.name == trimmed) return "updates existing curve";
}
return "new curve";
}
const CustomSeriesTemplate &selected_custom_series_template(const CustomSeriesEditorState &editor) {
const auto &templates = custom_series_templates();
return templates[static_cast<size_t>(std::clamp(editor.selected_template, 0, static_cast<int>(templates.size()) - 1))];
}
bool custom_series_template_ready(const CustomSeriesEditorState &editor) {
const CustomSeriesTemplate &templ = selected_custom_series_template(editor);
return !editor.linked_source.empty()
&& static_cast<int>(editor.additional_sources.size()) >= templ.required_additional_sources;
}
bool prepare_custom_series_spec(CustomSeriesEditorState *editor,
UiState *state,
bool require_name,
CustomPythonSeries *out_spec) {
editor->name = util::strip(editor->name);
editor->linked_source = util::strip(editor->linked_source);
for (std::string &path : editor->additional_sources) {
path = util::strip(path);
}
editor->additional_sources.erase(
std::remove_if(editor->additional_sources.begin(), editor->additional_sources.end(),
[&](const std::string &path) { return path.empty() || path == editor->linked_source; }),
editor->additional_sources.end());
if (require_name && editor->name.empty()) {
state->error_text = "Custom series name is required.";
state->open_error_popup = true;
return false;
}
if (require_name && !editor->name.empty() && editor->name.front() == '/') {
state->error_text = "Custom series names may not start with '/'.";
state->open_error_popup = true;
return false;
}
*out_spec = CustomPythonSeries{
.linked_source = editor->linked_source,
.additional_sources = editor->additional_sources,
.globals_code = editor->globals_code,
.function_code = editor->function_code,
};
return true;
}
bool preview_custom_series_editor(AppSession *session, UiState *state) {
CustomSeriesEditorState &editor = state->custom_series;
const CustomSeriesTemplate &templ = selected_custom_series_template(editor);
if (editor.linked_source.empty()) {
state->error_text = "Choose an input timeseries before previewing.";
state->open_error_popup = true;
state->status_text = "Custom series preview failed";
return false;
}
if (static_cast<int>(editor.additional_sources.size()) < templ.required_additional_sources) {
state->error_text = templ.requirement_text;
state->open_error_popup = true;
state->status_text = "Custom series preview failed";
return false;
}
CustomPythonSeries spec;
if (!prepare_custom_series_spec(&editor, state, false, &spec)) return false;
try {
PythonEvalResult result = evaluate_custom_python_series(*session, spec);
editor.preview_label = editor.name.empty() ? "Result preview" : editor.name;
editor.preview_xs = std::move(result.xs);
editor.preview_ys = std::move(result.ys);
editor.preview_is_result = true;
state->status_text = "Previewed custom series";
return true;
} catch (const std::exception &err) {
state->error_text = err.what();
state->open_error_popup = true;
state->status_text = "Custom series preview failed";
return false;
}
}
bool apply_custom_series_editor(AppSession *session, UiState *state) {
WorkspaceTab *tab = app_active_tab(&session->layout, *state);
TabUiState *tab_state = app_active_tab_state(state);
if (tab == nullptr || tab_state == nullptr) {
state->status_text = "No active pane";
return false;
}
if (tab_state->active_pane_index < 0 || tab_state->active_pane_index >= static_cast<int>(tab->panes.size())) {
state->status_text = "No active pane";
return false;
}
CustomSeriesEditorState &editor = state->custom_series;
CustomPythonSeries spec;
if (!prepare_custom_series_spec(&editor, state, true, &spec)) return false;
try {
PythonEvalResult result = evaluate_custom_python_series(*session, spec);
const SketchLayout before_layout = session->layout;
Pane &pane = tab->panes[static_cast<size_t>(tab_state->active_pane_index)];
editor.preview_label = editor.name;
editor.preview_xs = result.xs;
editor.preview_ys = result.ys;
editor.preview_is_result = true;
const bool inserted = upsert_custom_curve_in_pane(tab,
tab_state->active_pane_index,
make_custom_curve(pane, editor.name, spec, std::move(result)));
state->undo.push(before_layout);
state->status_text = inserted ? "Created custom series " + editor.name
: "Updated custom series " + editor.name;
return true;
} catch (const std::exception &err) {
state->error_text = err.what();
state->open_error_popup = true;
state->status_text = "Custom series failed";
return false;
}
}
} // namespace
void open_custom_series_editor(UiState *state, const std::string &preferred_source) {
CustomSeriesEditorState &editor = state->custom_series;
if (!editor.open && editor.name.empty() && editor.linked_source.empty() && editor.function_code == "return value") {
editor.focus_name = true;
}
if (editor.linked_source.empty() && !preferred_source.empty()) {
editor.linked_source = preferred_source;
}
editor.open = true;
editor.request_select = true;
}
std::string preferred_custom_series_source(const Pane &pane) {
for (const Curve &curve : pane.curves) {
if (!curve.name.empty() && curve.name.front() == '/') {
return curve.name;
}
if (curve.custom_python.has_value() && !curve.custom_python->linked_source.empty()) {
return curve.custom_python->linked_source;
}
}
return {};
}
void refresh_all_custom_curves(AppSession *session, UiState *state) {
for (WorkspaceTab &tab : session->layout.tabs) {
for (Pane &pane : tab.panes) {
for (Curve &curve : pane.curves) {
refresh_custom_curve_samples(session, state, &curve);
}
}
}
}
void draw_editor_source_panel(UiState *state, CustomSeriesEditorState &editor) {
ImGui::TextWrapped("Input timeseries. Provides arguments time and value:");
ImGui::SetNextItemWidth(-FLT_MIN);
input_text_string("##custom_linked_source", &editor.linked_source, ImGuiInputTextFlags_ReadOnly);
if (ImGui::BeginDragDropTarget()) {
if (const ImGuiPayload *payload = ImGui::AcceptDragDropPayload("JOTP_BROWSER_PATH")) {
editor.linked_source = static_cast<const char *>(payload->Data);
editor.additional_sources.erase(
std::remove(editor.additional_sources.begin(), editor.additional_sources.end(), editor.linked_source),
editor.additional_sources.end());
editor.preview_is_result = false;
}
ImGui::EndDragDropTarget();
}
if (ImGui::Button("Use Selected", ImVec2(120.0f, 0.0f)) && !state->selected_browser_path.empty()) {
editor.linked_source = state->selected_browser_path;
editor.additional_sources.erase(
std::remove(editor.additional_sources.begin(), editor.additional_sources.end(), editor.linked_source),
editor.additional_sources.end());
editor.preview_is_result = false;
}
ImGui::SameLine();
if (ImGui::Button("Clear", ImVec2(120.0f, 0.0f))) {
editor.linked_source.clear();
editor.preview_is_result = false;
}
ImGui::Spacing();
ImGui::TextUnformatted("Additional source timeseries:");
ImGui::SameLine();
const CustomSeriesTemplate &tmpl = selected_custom_series_template(editor);
if (tmpl.required_additional_sources > 0) {
const bool ready = static_cast<int>(editor.additional_sources.size()) >= tmpl.required_additional_sources;
ImGui::TextColored(ready ? color_rgb(58, 126, 73) : color_rgb(180, 122, 44), "%s", tmpl.requirement_text);
}
ImGui::SameLine();
ImGui::BeginDisabled(editor.selected_additional_source < 0
|| editor.selected_additional_source >= static_cast<int>(editor.additional_sources.size()));
if (ImGui::Button("Remove Selected", ImVec2(140.0f, 0.0f))
&& editor.selected_additional_source >= 0
&& editor.selected_additional_source < static_cast<int>(editor.additional_sources.size())) {
editor.additional_sources.erase(editor.additional_sources.begin()
+ static_cast<std::ptrdiff_t>(editor.selected_additional_source));
editor.selected_additional_source = editor.additional_sources.empty()
? -1 : std::clamp(editor.selected_additional_source, 0, static_cast<int>(editor.additional_sources.size()) - 1);
editor.preview_is_result = false;
}
ImGui::EndDisabled();
if (ImGui::BeginChild("##custom_additional_sources", ImVec2(0.0f, 156.0f), true)) {
if (ImGui::BeginTable("##custom_additional_table", 2,
ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_SizingStretchProp)) {
ImGui::TableSetupColumn("id", ImGuiTableColumnFlags_WidthFixed, 42.0f);
ImGui::TableSetupColumn("path", ImGuiTableColumnFlags_WidthStretch);
for (size_t i = 0; i < editor.additional_sources.size(); ++i) {
ImGui::TableNextRow();
ImGui::TableNextColumn();
ImGui::Text("v%zu", i + 1);
ImGui::TableNextColumn();
if (ImGui::Selectable(editor.additional_sources[i].c_str(),
editor.selected_additional_source == static_cast<int>(i),
ImGuiSelectableFlags_SpanAllColumns)) {
editor.selected_additional_source = static_cast<int>(i);
}
}
ImGui::EndTable();
}
if (ImGui::BeginDragDropTarget()) {
if (const ImGuiPayload *payload = ImGui::AcceptDragDropPayload("JOTP_BROWSER_PATH")) {
if (add_additional_source(&editor, static_cast<const char *>(payload->Data)))
editor.preview_is_result = false;
}
ImGui::EndDragDropTarget();
}
}
ImGui::EndChild();
if (ImGui::Button("Add Selected", ImVec2(120.0f, 0.0f))) {
for (const std::string &path : state->selected_browser_paths) {
if (add_additional_source(&editor, path)) editor.preview_is_result = false;
}
}
ImGui::Spacing();
ImGui::SeparatorText("Function library");
const auto &templates = custom_series_templates();
if (ImGui::BeginChild("##custom_series_template_list", ImVec2(0.0f, 132.0f), true)) {
for (size_t i = 0; i < templates.size(); ++i) {
if (ImGui::Selectable(templates[i].name, editor.selected_template == static_cast<int>(i),
ImGuiSelectableFlags_AllowDoubleClick)) {
editor.selected_template = static_cast<int>(i);
if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) {
editor.globals_code = templates[i].globals_code;
editor.function_code = templates[i].function_code;
editor.preview_is_result = false;
}
}
}
}
ImGui::EndChild();
if (ImGui::Button("Use Selected Example")) {
const auto &sel = selected_custom_series_template(editor);
editor.globals_code = sel.globals_code;
editor.function_code = sel.function_code;
editor.preview_is_result = false;
}
ImGui::Spacing();
ImGui::TextDisabled("Preview");
ImGui::BeginChild("##custom_series_template_preview", ImVec2(0.0f, 0.0f), true);
ImGui::TextUnformatted(selected_custom_series_template(editor).preview_text);
ImGui::EndChild();
}
void draw_editor_code_panel(CustomSeriesEditorState &editor, const Pane *active_pane) {
const std::string name_status = active_pane != nullptr ? custom_series_name_status(*active_pane, editor.name) : "no active pane";
ImGui::TextUnformatted("New name:");
ImGui::SameLine();
const bool name_error = name_status == "name required" || name_status == "cannot start with /";
ImGui::TextColored(name_error ? color_rgb(200, 72, 64) : color_rgb(58, 126, 73), "%s", name_status.c_str());
if (editor.focus_name) { ImGui::SetKeyboardFocusHere(); editor.focus_name = false; }
ImGui::SetNextItemWidth(-FLT_MIN);
input_text_string("##custom_series_name", &editor.name, ImGuiInputTextFlags_AutoSelectAll);
ImGui::Spacing();
ImGui::SeparatorText("Global variables");
ImGui::SameLine();
if (ImGui::SmallButton("Help")) editor.open_help = true;
const float globals_h = std::max(96.0f, ImGui::GetContentRegionAvail().y * 0.28f);
if (input_text_multiline_string("##custom_series_globals", &editor.globals_code,
ImVec2(-FLT_MIN, globals_h), ImGuiInputTextFlags_AllowTabInput))
editor.preview_is_result = false;
ImGui::Spacing();
ImGui::TextUnformatted("def calc(time, value):");
const float func_h = std::max(180.0f, ImGui::GetContentRegionAvail().y - 16.0f);
if (input_text_multiline_string("##custom_series_function", &editor.function_code,
ImVec2(-FLT_MIN, func_h), ImGuiInputTextFlags_AllowTabInput))
editor.preview_is_result = false;
}
void draw_custom_series_editor(AppSession *session, UiState *state) {
CustomSeriesEditorState &editor = state->custom_series;
if (!editor.open) return;
WorkspaceTab *tab = app_active_tab(&session->layout, *state);
TabUiState *tab_state = app_active_tab_state(state);
Pane *active_pane = (tab && tab_state && tab_state->active_pane_index >= 0
&& tab_state->active_pane_index < static_cast<int>(tab->panes.size()))
? &tab->panes[static_cast<size_t>(tab_state->active_pane_index)] : nullptr;
if (editor.focus_name && active_pane && editor.name.empty())
editor.name = next_custom_curve_name(*active_pane);
draw_custom_series_help_popup(&editor);
if (ImGui::BeginTabBar("##custom_series_tabs")) {
if (ImGui::BeginTabItem("Single Function")) {
const float footer_height = ImGui::GetFrameHeightWithSpacing() * 2.0f + 10.0f;
if (ImGui::BeginChild("##custom_series_body",
ImVec2(0.0f, std::max(1.0f, ImGui::GetContentRegionAvail().y - footer_height)), false)) {
if (ImGui::BeginChild("##custom_series_preview_child",
ImVec2(0.0f, std::max(200.0f, ImGui::GetContentRegionAvail().y * 0.28f)), true))
draw_custom_series_preview(*session, &editor);
ImGui::EndChild();
ImGui::Spacing();
if (ImGui::BeginTable("##custom_series_editor_table", 2,
ImGuiTableFlags_Resizable | ImGuiTableFlags_BordersInnerV | ImGuiTableFlags_SizingStretchProp,
ImVec2(0.0f, std::max(1.0f, ImGui::GetContentRegionAvail().y)))) {
ImGui::TableSetupColumn("left", ImGuiTableColumnFlags_WidthFixed, 320.0f);
ImGui::TableSetupColumn("right", ImGuiTableColumnFlags_WidthStretch);
ImGui::TableNextColumn();
if (ImGui::BeginChild("##custom_series_left", ImVec2(0.0f, 0.0f), false))
draw_editor_source_panel(state, editor);
ImGui::EndChild();
ImGui::TableNextColumn();
if (ImGui::BeginChild("##custom_series_right", ImVec2(0.0f, 0.0f), false))
draw_editor_code_panel(editor, active_pane);
ImGui::EndChild();
ImGui::EndTable();
}
}
ImGui::EndChild();
ImGui::Spacing();
if (ImGui::Button("New", ImVec2(120.0f, 0.0f))) {
reset_custom_series_editor(&editor);
if (!state->selected_browser_path.empty()) editor.linked_source = state->selected_browser_path;
editor.open = true;
editor.focus_name = true;
}
ImGui::SameLine();
ImGui::BeginDisabled(!custom_series_template_ready(editor));
if (ImGui::Button("Preview Result", ImVec2(120.0f, 0.0f)))
preview_custom_series_editor(session, state);
ImGui::EndDisabled();
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled) && !custom_series_template_ready(editor)) {
if (editor.linked_source.empty()) ImGui::SetTooltip("Choose an input timeseries first.");
else ImGui::SetTooltip("%s", selected_custom_series_template(editor).requirement_text);
}
ImGui::SameLine();
if (ImGui::Button("Apply", ImVec2(120.0f, 0.0f))) apply_custom_series_editor(session, state);
ImGui::SameLine();
if (ImGui::Button("Close", ImVec2(120.0f, 0.0f))) { editor.open = false; editor.request_select = false; }
ImGui::EndTabItem();
}
ImGui::EndTabBar();
}
}
+400
View File
@@ -0,0 +1,400 @@
#pragma once
#include "common/util.h"
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <filesystem>
#include <fstream>
#include <optional>
#include <regex>
#include <sstream>
#include <stdexcept>
#include <string>
#include <tuple>
#include <unordered_map>
#include <vector>
namespace dbc {
struct ValueDescriptionEntry {
double value = 0.0;
std::string text;
};
struct Signal {
enum class Type {
Normal = 0,
Multiplexed,
Multiplexor,
};
Type type = Type::Normal;
std::string name;
int start_bit = 0;
int msb = 0;
int lsb = 0;
int size = 0;
double factor = 1.0;
double offset = 0.0;
double min = 0.0;
double max = 0.0;
bool is_signed = false;
bool is_little_endian = false;
std::string unit;
std::string comment;
std::string receiver_name;
int multiplex_value = 0;
int multiplexor_index = -1;
std::vector<ValueDescriptionEntry> value_descriptions;
};
struct Message {
uint32_t address = 0;
std::string name;
uint32_t size = 0;
std::string comment;
std::string transmitter;
std::vector<Signal> signals;
int multiplexor_index = -1;
const std::vector<Signal> &getSignals() const { return signals; }
};
class Database {
public:
Database() = default;
explicit Database(const std::filesystem::path &path);
static Database fromContent(const std::string &content, const std::string &filename = "<dbc>");
const Message *message(uint32_t address) const;
const std::unordered_map<uint32_t, Message> &messages() const { return messages_; }
std::vector<std::string> enumNames(const Signal &signal) const;
private:
void parse(const std::string &content, const std::string &filename);
void parseBo(const std::string &line, int line_number, Message **current_message);
void parseSg(const std::string &line, int line_number, Message *current_message);
void parseVal(const std::string &line, int line_number);
void parseCmBo(const std::string &line, int line_number);
void parseCmSg(const std::string &line, int line_number);
void finalize();
std::string filename_;
std::unordered_map<uint32_t, Message> messages_;
};
void updateMsbLsb(Signal *signal);
double rawSignalValue(const Signal &signal, const uint8_t *data, size_t data_size);
std::optional<double> signalValue(const Signal &signal, const Message &message, const uint8_t *data, size_t data_size);
namespace {
std::string unescape_dbc_string(std::string text) {
size_t pos = 0;
while ((pos = text.find("\\\"", pos)) != std::string::npos) {
text.replace(pos, 2, "\"");
++pos;
}
return text;
}
int flip_bit_pos(int start_bit) {
return 8 * (start_bit / 8) + 7 - start_bit % 8;
}
std::string read_multiline_statement(std::istream &stream, std::string statement, int *line_number) {
static const std::regex statement_end(R"(\"\s*;\s*$)");
while (true) {
const std::string trimmed = util::strip(statement);
if (std::regex_search(trimmed, statement_end)) {
return trimmed;
}
std::string next_line;
if (!std::getline(stream, next_line)) {
return trimmed;
}
statement += "\n";
statement += next_line;
++(*line_number);
}
}
} // namespace
inline void updateMsbLsb(Signal *signal) {
if (signal->is_little_endian) {
signal->lsb = signal->start_bit;
signal->msb = signal->start_bit + signal->size - 1;
} else {
signal->lsb = flip_bit_pos(flip_bit_pos(signal->start_bit) + signal->size - 1);
signal->msb = signal->start_bit;
}
}
inline double rawSignalValue(const Signal &signal, const uint8_t *data, size_t data_size) {
const int msb_byte = signal.msb / 8;
if (msb_byte >= static_cast<int>(data_size)) return 0.0;
const int lsb_byte = signal.lsb / 8;
uint64_t val = 0;
if (msb_byte == lsb_byte) {
val = (data[msb_byte] >> (signal.lsb & 7)) & ((1ULL << signal.size) - 1);
} else {
int bits = signal.size;
int i = msb_byte;
const int step = signal.is_little_endian ? -1 : 1;
while (i >= 0 && i < static_cast<int>(data_size) && bits > 0) {
const int msb = (i == msb_byte) ? signal.msb & 7 : 7;
const int lsb = (i == lsb_byte) ? signal.lsb & 7 : 0;
const int nbits = msb - lsb + 1;
val = (val << nbits) | ((data[i] >> lsb) & ((1ULL << nbits) - 1));
bits -= nbits;
i += step;
}
}
if (signal.is_signed && (val & (1ULL << (signal.size - 1)))) {
val |= ~((1ULL << signal.size) - 1);
}
return static_cast<int64_t>(val) * signal.factor + signal.offset;
}
[[noreturn]] inline void parse_error(const std::string &filename, int line_number, const std::string &message, const std::string &line) {
std::ostringstream out;
out << "[" << filename << ":" << line_number << "] " << message << ": " << line;
throw std::runtime_error(out.str());
}
inline Database::Database(const std::filesystem::path &path) {
const std::string content = util::read_file(path.string());
if (content.empty() && !std::filesystem::exists(path)) {
throw std::runtime_error("Failed to open DBC " + path.string());
}
parse(content, path.filename().string());
}
inline Database Database::fromContent(const std::string &content, const std::string &filename) {
Database db;
db.parse(content, filename);
return db;
}
inline const Message *Database::message(uint32_t address) const {
auto it = messages_.find(address);
return it == messages_.end() ? nullptr : &it->second;
}
inline std::vector<std::string> Database::enumNames(const Signal &signal) const {
if (signal.value_descriptions.empty()) return {};
int max_index = -1;
for (const auto &entry : signal.value_descriptions) {
const double rounded = std::round(entry.value);
if (std::abs(entry.value - rounded) > 1e-6 || rounded < 0.0 || rounded > 512.0) return {};
max_index = std::max(max_index, static_cast<int>(rounded));
}
if (max_index < 0) return {};
std::vector<std::string> names(static_cast<size_t>(max_index + 1));
for (const auto &entry : signal.value_descriptions) {
names[static_cast<size_t>(std::llround(entry.value))] = entry.text;
}
return names;
}
inline void Database::parse(const std::string &content, const std::string &filename) {
filename_ = filename;
messages_.clear();
std::istringstream stream(content);
std::string raw_line;
Message *current_message = nullptr;
int line_number = 0;
while (std::getline(stream, raw_line)) {
++line_number;
std::string line = util::strip(raw_line);
if (line.empty()) continue;
if (util::starts_with(line, "BO_ ")) {
parseBo(line, line_number, &current_message);
} else if (util::starts_with(line, "SG_ ")) {
if (current_message == nullptr) {
parse_error(filename, line_number, "Signal without current message", line);
}
parseSg(line, line_number, current_message);
} else if (util::starts_with(line, "VAL_ ")) {
parseVal(line, line_number);
} else if (util::starts_with(line, "CM_ BO_")) {
parseCmBo(read_multiline_statement(stream, raw_line, &line_number), line_number);
} else if (util::starts_with(line, "CM_ SG_")) {
parseCmSg(read_multiline_statement(stream, raw_line, &line_number), line_number);
}
}
finalize();
}
inline void Database::parseBo(const std::string &line, int line_number, Message **current_message) {
static const std::regex pattern(R"(^BO_\s+(\w+)\s+(\w+)\s*:\s*(\w+)\s+(\w+)\s*$)");
std::smatch match;
if (!std::regex_match(line, match, pattern)) {
parse_error("<dbc>", line_number, "Invalid BO_ line format", line);
}
uint32_t address = static_cast<uint32_t>(std::stoul(match[1].str(), nullptr, 0));
if (messages_.find(address) != messages_.end()) {
parse_error(filename_, line_number, "Duplicate message address", line);
}
Message &message = messages_[address];
message.address = address;
message.name = match[2].str();
message.size = static_cast<uint32_t>(std::stoul(match[3].str(), nullptr, 0));
message.transmitter = match[4].str();
message.signals.clear();
message.multiplexor_index = -1;
*current_message = &message;
}
inline void Database::parseSg(const std::string &line, int line_number, Message *current_message) {
static const std::regex multiplex_pattern(R"(^SG_\s+(\w+)\s+(\w+)\s*:\s*(\d+)\|(\d+)@(\d)([+-])\s+\(([0-9.+\-eE]+),([0-9.+\-eE]+)\)\s+\[([0-9.+\-eE]+)\|([0-9.+\-eE]+)\]\s+\"(.*)\"\s+(.*)$)");
static const std::regex normal_pattern(R"(^SG_\s+(\w+)\s*:\s*(\d+)\|(\d+)@(\d)([+-])\s+\(([0-9.+\-eE]+),([0-9.+\-eE]+)\)\s+\[([0-9.+\-eE]+)\|([0-9.+\-eE]+)\]\s+\"(.*)\"\s+(.*)$)");
std::smatch match;
Signal signal;
int offset = 0;
if (std::regex_match(line, match, normal_pattern)) {
offset = 0;
} else if (std::regex_match(line, match, multiplex_pattern)) {
offset = 1;
const std::string indicator = match[2].str();
if (indicator == "M") {
if (std::any_of(current_message->signals.begin(), current_message->signals.end(), [](const Signal &existing) {
return existing.type == Signal::Type::Multiplexor;
})) {
parse_error(filename_, line_number, "Multiple multiplexor", line);
}
signal.type = Signal::Type::Multiplexor;
} else if (!indicator.empty() && indicator.front() == 'm') {
signal.type = Signal::Type::Multiplexed;
signal.multiplex_value = std::stoi(indicator.substr(1));
} else {
parse_error("<dbc>", line_number, "Invalid multiplex indicator", line);
}
} else {
parse_error("<dbc>", line_number, "Invalid SG_ line format", line);
}
signal.name = match[1].str();
if (std::any_of(current_message->signals.begin(), current_message->signals.end(), [&](const Signal &existing) {
return existing.name == signal.name;
})) {
parse_error(filename_, line_number, "Duplicate signal name", line);
}
signal.start_bit = std::stoi(match[2 + offset].str());
signal.size = std::stoi(match[3 + offset].str());
signal.is_little_endian = match[4 + offset].str() == "1";
signal.is_signed = match[5 + offset].str() == "-";
signal.factor = std::stod(match[6 + offset].str());
signal.offset = std::stod(match[7 + offset].str());
signal.min = std::stod(match[8 + offset].str());
signal.max = std::stod(match[9 + offset].str());
signal.unit = match[10 + offset].str();
signal.receiver_name = util::strip(match[11 + offset].str());
updateMsbLsb(&signal);
current_message->signals.push_back(std::move(signal));
}
inline void Database::parseVal(const std::string &line, int line_number) {
static const std::regex prefix(R"(^VAL_\s+(\w+)\s+(\w+)\s+(.*);$)");
std::smatch match;
if (!std::regex_match(line, match, prefix)) {
parse_error("<dbc>", line_number, "Invalid VAL_ line format", line);
}
const uint32_t address = static_cast<uint32_t>(std::stoul(match[1].str(), nullptr, 0));
auto msg_it = messages_.find(address);
if (msg_it == messages_.end()) {
return;
}
auto sig_it = std::find_if(msg_it->second.signals.begin(), msg_it->second.signals.end(), [&](const Signal &signal) {
return signal.name == match[2].str();
});
if (sig_it == msg_it->second.signals.end()) {
return;
}
static const std::regex entry_pattern(R"(([+-]?\d+(?:\.\d+)?)\s+\"((?:[^\"\\]|\\.)*)\")");
const std::string defs = match[3].str();
for (std::sregex_iterator it(defs.begin(), defs.end(), entry_pattern), end; it != end; ++it) {
sig_it->value_descriptions.push_back(ValueDescriptionEntry{
.value = std::stod((*it)[1].str()),
.text = (*it)[2].str(),
});
}
}
inline void Database::parseCmBo(const std::string &line, int line_number) {
static const std::regex pattern(R"(^CM_\s+BO_\s*(\w+)\s*\"((?:[^\"\\]|\\.|[\r\n])*)\"\s*;\s*$)");
std::smatch match;
if (!std::regex_match(line, match, pattern)) {
parse_error(filename_, line_number, "Invalid message comment format", line);
}
const uint32_t address = static_cast<uint32_t>(std::stoul(match[1].str(), nullptr, 0));
auto it = messages_.find(address);
if (it != messages_.end()) {
it->second.comment = unescape_dbc_string(match[2].str());
}
}
inline void Database::parseCmSg(const std::string &line, int line_number) {
static const std::regex pattern(R"(^CM_\s+SG_\s*(\w+)\s*(\w+)\s*\"((?:[^\"\\]|\\.|[\r\n])*)\"\s*;\s*$)");
std::smatch match;
if (!std::regex_match(line, match, pattern)) {
parse_error(filename_, line_number, "Invalid signal comment format", line);
}
const uint32_t address = static_cast<uint32_t>(std::stoul(match[1].str(), nullptr, 0));
auto msg_it = messages_.find(address);
if (msg_it == messages_.end()) return;
auto sig_it = std::find_if(msg_it->second.signals.begin(), msg_it->second.signals.end(), [&](const Signal &signal) {
return signal.name == match[2].str();
});
if (sig_it != msg_it->second.signals.end()) {
sig_it->comment = unescape_dbc_string(match[3].str());
}
}
inline void Database::finalize() {
for (auto &[_, message] : messages_) {
std::sort(message.signals.begin(), message.signals.end(), [](const Signal &left, const Signal &right) {
return std::tie(right.type, left.multiplex_value, left.start_bit, left.name)
< std::tie(left.type, right.multiplex_value, right.start_bit, right.name);
});
message.multiplexor_index = -1;
for (size_t i = 0; i < message.signals.size(); ++i) {
if (message.signals[i].type == Signal::Type::Multiplexor) {
message.multiplexor_index = static_cast<int>(i);
break;
}
}
for (Signal &signal : message.signals) {
signal.multiplexor_index = signal.type == Signal::Type::Multiplexed ? message.multiplexor_index : -1;
if (signal.type == Signal::Type::Multiplexed && signal.multiplexor_index < 0) {
signal.type = Signal::Type::Normal;
signal.multiplex_value = 0;
}
}
}
}
inline std::optional<double> signalValue(const Signal &signal, const Message &message, const uint8_t *data, size_t data_size) {
if (signal.multiplexor_index >= 0) {
const Signal &multiplexor = message.signals[static_cast<size_t>(signal.multiplexor_index)];
const double mux_value = rawSignalValue(multiplexor, data, data_size);
if (std::llround(mux_value) != signal.multiplex_value) return std::nullopt;
}
return rawSignalValue(signal, data, data_size);
}
} // namespace dbc
@@ -0,0 +1,327 @@
import sys
import capnp
from pathlib import Path
NO_DISCRIMINANT = 65535
SCALAR_KINDS = {
"bool": "Bool",
"int8": "Int",
"int16": "Int",
"int32": "Int",
"int64": "Int",
"uint8": "UInt",
"uint16": "UInt",
"uint32": "UInt",
"uint64": "UInt",
"float32": "Float",
"float64": "Float",
"enum": "Enum",
}
NESTED_TYPE_KINDS = {"struct", "list"}
IGNORED_TYPE_KINDS = {"void", "text", "data", "interface", "anyPointer"}
def cxx_string(value):
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
def accessor(prefix, name):
return prefix + name[:1].upper() + name[1:]
def field_type(field):
if field.proto.which() == "group":
return "struct"
return field.proto.slot.type.which()
def field_type_proto(field):
return field.proto.slot.type if field.proto.which() == "slot" else None
def scalar_kind(type_proto):
if type_proto is None:
return None
return SCALAR_KINDS.get(type_proto.which())
def enum_names(schema):
if schema is None:
return []
names_by_ordinal = schema.enumerants
if not names_by_ordinal:
return []
max_ordinal = max(names_by_ordinal.values())
out = [""] * (max_ordinal + 1)
for name, ordinal in names_by_ordinal.items():
out[ordinal] = name
return out
class Generator:
def __init__(self, event_schema):
self.event_schema = event_schema
self.fixed_paths = []
self.tmp_index = 0
self.lines = []
self.emits_memo = {}
def tmp(self, prefix):
self.tmp_index += 1
return f"{prefix}_{self.tmp_index}"
def add_fixed_path(self, path):
slot = len(self.fixed_paths)
self.fixed_paths.append(path)
return slot
def emit(self, indent, text=""):
self.lines.append(" " * indent + text)
def scalar_double_expr(self, value_expr, kind):
if kind == "Bool":
return f"({value_expr} ? 1.0 : 0.0)"
if kind == "Enum":
return f"static_cast<double>(static_cast<uint16_t>({value_expr}))"
return f"static_cast<double>({value_expr})"
def emit_enum_capture(self, indent, path_expr, names):
if not names:
return
names_expr = "{" + ", ".join(cxx_string(name) for name in names) + "}"
self.emit(indent, f"capture_static_enum_info({path_expr}, {names_expr}, series);")
def emit_node(self, indent, type_kind, type_proto, schema, expr, path, path_expr, dynamic_path):
if not self.node_emits(type_kind, type_proto, schema):
return
kind = scalar_kind(type_proto)
if kind is not None:
double_expr = self.scalar_double_expr(expr, kind)
if dynamic_path:
if kind == "Enum":
self.emit_enum_capture(indent, path_expr, enum_names(schema))
self.emit(indent, f"append_dynamic_scalar_point({path_expr}, tm, {double_expr}, series);")
else:
slot = self.add_fixed_path(path)
if kind == "Enum":
self.emit_enum_capture(indent, cxx_string(path), enum_names(schema))
self.emit(indent, f"append_fixed_scalar_point(&series->fixed_series[{slot}], tm, {double_expr});")
return
if type_kind == "struct":
self.emit_struct(indent, schema, expr, path, path_expr, dynamic_path)
return
if type_kind == "list":
self.emit_list(indent, type_proto, schema, expr, path, path_expr, dynamic_path)
def emit_field(self, indent, struct_schema, reader_expr, field_name, base_path, base_path_expr, dynamic_path):
field = struct_schema.fields[field_name]
proto = field.proto
type_kind = field_type(field)
type_proto = field_type_proto(field)
kind = scalar_kind(type_proto)
value_schema = field.schema if kind == "Enum" or type_kind in NESTED_TYPE_KINDS else None
if not self.node_emits(type_kind, type_proto, value_schema):
return
field_path = f"{base_path}/{field_name}"
field_path_expr = None
if dynamic_path:
field_path_var = self.tmp("path")
self.emit(indent, f"const std::string {field_path_var} = {base_path_expr} + {cxx_string('/' + field_name)};")
field_path_expr = field_path_var
get_call = f"{reader_expr}.{accessor('get', field_name)}()"
has_call = f"{reader_expr}.{accessor('has', field_name)}()"
conditions = []
if proto.discriminantValue != NO_DISCRIMINANT:
conditions.append(f"{reader_expr}.which() == static_cast<decltype({reader_expr}.which())>({proto.discriminantValue})")
if proto.which() == "slot" and type_kind in NESTED_TYPE_KINDS:
conditions.append(has_call)
if conditions:
self.emit(indent, f"if ({' && '.join(conditions)}) {{")
indent += 2
value_var = self.tmp("value")
self.emit(indent, f"const auto {value_var} = {get_call};")
self.emit_node(indent, type_kind, type_proto, value_schema, value_var, field_path, field_path_expr, dynamic_path)
if conditions:
indent -= 2
self.emit(indent, "}")
def emit_struct(self, indent, schema, reader_expr, path, path_expr, dynamic_path):
if schema is None:
return
for field_name in schema.fieldnames:
self.emit_field(indent, schema, reader_expr, field_name, path, path_expr, dynamic_path)
def emit_list(self, indent, type_proto, schema, list_expr, path, path_expr, dynamic_path):
elem_type = type_proto.list.elementType
elem_kind = elem_type.which()
if elem_kind in IGNORED_TYPE_KINDS:
return
base_path_var = path_expr
if base_path_var is None:
base_path_var = self.tmp("base_path")
self.emit(indent, f"const std::string {base_path_var} = {cxx_string(path)};")
elem_scalar = scalar_kind(elem_type)
if elem_scalar is not None:
self.emit(indent, f"if ({list_expr}.size() <= 16) {{")
index_var = self.tmp("i")
self.emit(indent + 2, f"for (uint {index_var} = 0; {index_var} < {list_expr}.size(); ++{index_var}) {{")
item_series = self.tmp("item_series")
self.emit(indent + 4, f"RouteSeries *{item_series} = ensure_list_scalar_series({base_path_var}, {index_var}, series);")
if elem_scalar == "Enum":
self.emit_enum_capture(indent + 4, f"{item_series}->path", enum_names(schema.elementType))
self.emit(indent + 4, f"append_fixed_scalar_point({item_series}, tm, {self.scalar_double_expr(f'{list_expr}[{index_var}]', elem_scalar)});")
self.emit(indent + 2, "}")
self.emit(indent, "}")
return
if elem_kind in {"struct", "list"}:
index_var = self.tmp("i")
self.emit(indent, f"for (uint {index_var} = 0; {index_var} < {list_expr}.size(); ++{index_var}) {{")
item_path = self.tmp("item_path")
self.emit(indent + 2, f"const std::string {item_path} = {base_path_var} + \"/\" + std::to_string({index_var});")
item = self.tmp("item")
self.emit(indent + 2, f"const auto {item} = {list_expr}[{index_var}];")
if elem_kind == "struct":
self.emit_struct(indent + 2, schema.elementType, item, path, item_path, True)
else:
self.emit_list(indent + 2, elem_type, schema.elementType, item, path, item_path, True)
self.emit(indent, "}")
def node_emits(self, type_kind, type_proto, schema, seen=frozenset()):
if scalar_kind(type_proto) is not None:
return True
if type_kind == "struct":
if schema is None:
return False
schema_id = int(schema.node.id)
if schema_id in seen:
return False
if schema_id in self.emits_memo:
return self.emits_memo[schema_id]
next_seen = seen | {schema_id}
for field_name in schema.fieldnames:
field = schema.fields[field_name]
ft = field_type(field)
ftp = field_type_proto(field)
fkind = scalar_kind(ftp)
if ft in IGNORED_TYPE_KINDS:
continue
fschema = field.schema if fkind == "Enum" or ft in NESTED_TYPE_KINDS else None
if self.node_emits(ft, ftp, fschema, next_seen):
self.emits_memo[schema_id] = True
return True
self.emits_memo[schema_id] = False
return False
if type_kind == "list":
if type_proto is None or schema is None:
return False
elem_type = type_proto.list.elementType
elem_kind = elem_type.which()
if elem_kind in IGNORED_TYPE_KINDS:
return False
if scalar_kind(elem_type) is not None:
return True
if elem_kind == "struct":
return self.node_emits("struct", None, schema.elementType, seen)
if elem_kind == "list":
return self.node_emits("list", elem_type, schema.elementType, seen)
return False
def emit_can_special(self, indent, service_name):
service_kind = "CanServiceKind::Can" if service_name == "can" else "CanServiceKind::Sendcan"
self.emit(indent, f"const CanServiceKind can_service = {service_kind};")
self.emit(indent, f"for (const auto &msg : event.{accessor('get', service_name)}()) {{")
self.emit(indent + 2, "append_can_frame(can_service, static_cast<uint8_t>(msg.getSrc()), msg.getAddress(), msg.getDeprecated().getBusTime(), msg.getDat(), tm, series);") # noqa: E501
self.emit(indent + 2, "if (skip_raw_can) {")
self.emit(indent + 4, "const auto dat = msg.getDat();")
self.emit(indent + 4, f"decode_can_frame(can_dbc, {cxx_string(service_name)}, static_cast<uint8_t>(msg.getSrc()), msg.getAddress(), dat.begin(), dat.size(), tm, series);") # noqa: E501
self.emit(indent + 2, "}")
self.emit(indent, "}")
self.emit(indent, "if (skip_raw_can) {")
self.emit(indent + 2, "return true;")
self.emit(indent, "}")
def emit_event_case(self, field_name):
field = self.event_schema.fields[field_name]
proto = field.proto
type_kind = field_type(field)
type_proto = field_type_proto(field)
kind = scalar_kind(type_proto)
schema = field.schema if kind == "Enum" or type_kind in NESTED_TYPE_KINDS else None
self.emit(4, f"case static_cast<cereal::Event::Which>({proto.discriminantValue}): {{")
valid_slot = self.add_fixed_path(f"/{field_name}/valid")
mono_slot = self.add_fixed_path(f"/{field_name}/logMonoTime")
seconds_slot = self.add_fixed_path(f"/{field_name}/t")
self.emit(6, f"append_fixed_scalar_point(&series->fixed_series[{valid_slot}], tm, event.getValid() ? 1.0 : 0.0);")
self.emit(6, f"append_fixed_scalar_point(&series->fixed_series[{mono_slot}], tm, static_cast<double>(event.getLogMonoTime()));")
self.emit(6, f"append_fixed_scalar_point(&series->fixed_series[{seconds_slot}], tm, tm);")
if field_name in {"can", "sendcan"}:
self.emit_can_special(6, field_name)
if self.node_emits(type_kind, type_proto, schema):
payload = self.tmp("payload")
self.emit(6, f"const auto {payload} = event.{accessor('get', field_name)}();")
self.emit_node(6, type_kind, type_proto, schema, payload, f"/{field_name}", None, False)
self.emit(6, "return true;")
self.emit(4, "}")
def generate(self):
self.lines = []
self.emit(0, "// Generated by openpilot/tools/jotpluggler/generate_event_extractors.py; do not edit.")
self.emit(0, "")
self.emit(0, "const std::vector<std::string> &static_event_fixed_paths() {")
self.emit(2, "static const std::vector<std::string> paths = {")
path_insert_at = len(self.lines)
self.emit(2, "};")
self.emit(2, "return paths;")
self.emit(0, "}")
self.emit(0, "")
self.emit(0, "void capture_static_enum_info(const std::string &path, std::initializer_list<std::string_view> names, SeriesAccumulator *series) {")
self.emit(2, "if (series->enum_info.find(path) != series->enum_info.end()) {")
self.emit(4, "return;")
self.emit(2, "}")
self.emit(2, "EnumInfo info;")
self.emit(2, "info.names.reserve(names.size());")
self.emit(2, "for (std::string_view name : names) {")
self.emit(4, "info.names.emplace_back(name);")
self.emit(2, "}")
self.emit(2, "if (!info.names.empty()) {")
self.emit(4, "series->enum_info.emplace(path, std::move(info));")
self.emit(2, "}")
self.emit(0, "}")
self.emit(0, "")
self.emit(0, "bool append_event_static_reader(cereal::Event::Which which, const cereal::Event::Reader &event, const dbc::Database *can_dbc, bool skip_raw_can, double time_offset, SeriesAccumulator *series) {") # noqa: E501
self.emit(2, "const double tm = static_cast<double>(event.getLogMonoTime()) / 1.0e9 - time_offset;")
self.emit(2, "switch (which) {")
for field_name in self.event_schema.union_fields:
self.emit_event_case(field_name)
self.emit(4, "default:")
self.emit(6, "return false;")
self.emit(2, "}")
self.emit(0, "}")
path_lines = [" " + cxx_string(path) + "," for path in self.fixed_paths]
self.lines[path_insert_at:path_insert_at] = path_lines
return "\n".join(self.lines) + "\n"
if __name__ == "__main__":
if len(sys.argv) != 3:
print(f"usage: {sys.argv[0]} <repo-root> <output>", file=sys.stderr)
sys.exit(2)
repo_root = Path(sys.argv[1]).resolve()
output = Path(sys.argv[2])
capnp.remove_import_hook()
log = capnp.load(str(repo_root / "openpilot" / "cereal" / "log.capnp"), imports=[str(repo_root / "opendbc_repo" / "opendbc" / "car")])
generated = Generator(log.Event.schema).generate()
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(generated)
@@ -0,0 +1,2 @@
*
!.gitignore

Some files were not shown because too many files have changed in this diff Show More