mirror of
https://github.com/sunnypilot/sunnypilot.git
synced 2026-09-30 21:23:43 +08:00
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:
Executable
+17
@@ -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()
|
||||
@@ -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
|
||||
@@ -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 <ipaddress> 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)
|
||||
@@ -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 |
@@ -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"> x </span>,
|
||||
<span style="background-color:lightGray;color:gray"> Backspace </span>,
|
||||
<span style="background-color:lightGray;color:gray"> Delete </span><br />
|
||||
Change endianness: <span style="background-color:lightGray;color:gray"> e </span><br />
|
||||
Change signedness: <span style="background-color:lightGray;color:gray"> s </span><br />
|
||||
Open chart:
|
||||
<span style="background-color:lightGray;color:gray"> c </span>,
|
||||
<span style="background-color:lightGray;color:gray"> p </span>,
|
||||
<span style="background-color:lightGray;color:gray"> g </span>
|
||||
)");
|
||||
}
|
||||
|
||||
void BinaryView::addShortcuts() {
|
||||
// Delete (x, backspace, delete)
|
||||
QShortcut *shortcut_delete_x = new QShortcut(QKeySequence(Qt::Key_X), this);
|
||||
QShortcut *shortcut_delete_backspace = new QShortcut(QKeySequence(Qt::Key_Backspace), this);
|
||||
QShortcut *shortcut_delete_delete = new QShortcut(QKeySequence(Qt::Key_Delete), this);
|
||||
QObject::connect(shortcut_delete_delete, &QShortcut::activated, shortcut_delete_x, &QShortcut::activated);
|
||||
QObject::connect(shortcut_delete_backspace, &QShortcut::activated, shortcut_delete_x, &QShortcut::activated);
|
||||
QObject::connect(shortcut_delete_x, &QShortcut::activated, [=]{
|
||||
if (hovered_sig != nullptr) {
|
||||
UndoStack::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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
Executable
+38
@@ -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" "$@"
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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>);
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 ¤t_charts = currentCharts();
|
||||
if ((current_charts.size() != charts_layout->count() || n != current_column_count) || force) {
|
||||
current_column_count = n;
|
||||
charts_container->setUpdatesEnabled(false);
|
||||
for (auto c : charts) {
|
||||
c->setVisible(false);
|
||||
}
|
||||
for (int i = 0; i < current_charts.size(); ++i) {
|
||||
charts_layout->addWidget(current_charts[i], i / n, i % n);
|
||||
if (current_charts[i]->sigs.empty()) {
|
||||
// the chart will be resized after add signal. delay setVisible to reduce flicker.
|
||||
QTimer::singleShot(0, current_charts[i], [c = current_charts[i]]() { c->setVisible(true); });
|
||||
} else {
|
||||
current_charts[i]->setVisible(true);
|
||||
}
|
||||
}
|
||||
charts_container->setUpdatesEnabled(true);
|
||||
}
|
||||
}
|
||||
|
||||
void ChartsWidget::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;
|
||||
}
|
||||
@@ -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 *> ¤tCharts() { return tab_charts[tabbar->tabData(tabbar->currentIndex()).toInt()]; }
|
||||
ChartView *findChart(const MessageId &id, const cabana::Signal *sig);
|
||||
|
||||
QLabel *title_label;
|
||||
QLabel *range_lb;
|
||||
LogSlider *range_slider;
|
||||
QAction *range_lb_action;
|
||||
QAction *range_slider_action;
|
||||
bool 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;
|
||||
};
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 ¬ifier;
|
||||
}
|
||||
|
||||
// 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); }
|
||||
@@ -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}
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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};
|
||||
}
|
||||
};
|
||||
@@ -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); }
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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_;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 ¬ifier;
|
||||
}
|
||||
@@ -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
@@ -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}")
|
||||
@@ -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`
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 ¤t, const QModelIndex &previous) {
|
||||
if (current.isValid() && current.row() < model->items_.size()) {
|
||||
const auto &id = model->items_[current.row()].id;
|
||||
if (!current_msg_id || id != *current_msg_id) {
|
||||
current_msg_id = id;
|
||||
emit msgSelectionChanged(*current_msg_id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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"> shift+wheel </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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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() : "";
|
||||
}
|
||||
@@ -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 = {};
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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_;
|
||||
};
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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"> space </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);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
@@ -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()
|
||||
Executable
+374
@@ -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()
|
||||
@@ -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
@@ -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_;
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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, ¤t_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
Reference in New Issue
Block a user