IQ.Pilot Release Commit @ bec7652

This commit is contained in:
IQ.Lvbs history cleanup
2026-08-22 23:42:41 -05:00
commit 58039e647c
4603 changed files with 1236178 additions and 0 deletions

11
iqpilot/tools/cabana/.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
moc_*
*.moc
*.generated.qrc
assets.cc
bootstrap_icons.cc
_cabana
dbc/car_fingerprint_to_dbc.json
tests/test_cabana
tests/test_dbc_core

View File

@@ -0,0 +1,117 @@
# 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 iqdbc (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 [konn3kt](https://konn3kt.com).
## 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
konn3kt.com
```
## 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
./cereal/messaging/bridge &
```
Then Run Cabana with the device's IP address:
```shell
cabana --zmq <ipaddress>
```
Replace &lt;ipaddress&gt; with your device's IP address.
If you can't run the bridge on the device, `--bridge <ipaddress>` runs
`cereal/messaging/bridge` locally against the device instead.
### Streaming CAN Messages from a Remote Device over konn3kt
To watch a device that isn't on your network, `tools/cabana/konn3kt_canproxy.py`
re-publishes its live CAN onto a local ZMQ socket:
```shell
export KONN3KT_JWT="<your konn3kt jwt>"
./tools/cabana/konn3kt_canproxy.py <dongle_id>
cabana --zmq 127.0.0.1
```
The device must have `CanLiveStreaming` enabled so `canlived` is running. See the
header of `konn3kt_canproxy.py` for the full topology.
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)

View File

@@ -0,0 +1,147 @@
import subprocess
import os
import shutil
import iqdbc
import sys
venv_site_packages = os.path.join(Dir("#").abspath, ".venv", "lib", "python3.12", "site-packages")
if os.path.isdir(venv_site_packages) and venv_site_packages not in sys.path:
sys.path.insert(0, venv_site_packages)
import libusb
Import('env', 'arch', 'common', 'messaging', 'visionipc', 'cereal', 'replay_lib', 'ffmpeg_libs')
# Detect Qt - skip build if not available
if arch == "Darwin":
try:
brew_prefix = subprocess.check_output(['brew', '--prefix'], encoding='utf8').strip()
has_qt = os.path.isdir(os.path.join(brew_prefix, "opt/qt@5"))
except (FileNotFoundError, subprocess.CalledProcessError):
has_qt = False
else:
has_qt = shutil.which('qmake') is not None
if not has_qt:
Return()
qt_env = env.Clone()
qt_modules = ["Widgets", "Gui", "Core"]
qt_libs = []
if arch == "Darwin":
qt_env['QTDIR'] = f"{brew_prefix}/opt/qt@5"
qt_dirs = [
os.path.join(qt_env['QTDIR'], "include"),
]
qt_dirs += [f"{qt_env['QTDIR']}/include/Qt{m}" for m in qt_modules]
qt_env["LINKFLAGS"] += ["-F" + os.path.join(qt_env['QTDIR'], "lib")]
qt_env["FRAMEWORKS"] += [f"Qt{m}" for m in qt_modules]
qt_env.AppendENVPath('PATH', os.path.join(qt_env['QTDIR'], "bin"))
else:
qt_install_prefix = subprocess.check_output(['qmake', '-query', 'QT_INSTALL_PREFIX'], encoding='utf8').strip()
qt_install_headers = subprocess.check_output(['qmake', '-query', 'QT_INSTALL_HEADERS'], encoding='utf8').strip()
qt_env['QTDIR'] = qt_install_prefix
qt_dirs = [
f"{qt_install_headers}",
]
qt_gui_path = os.path.join(qt_install_headers, "QtGui")
qt_gui_dirs = [d for d in os.listdir(qt_gui_path) if os.path.isdir(os.path.join(qt_gui_path, d))]
qt_dirs += [f"{qt_install_headers}/QtGui/{qt_gui_dirs[0]}/QtGui", ] if qt_gui_dirs else []
qt_dirs += [f"{qt_install_headers}/Qt{m}" for m in qt_modules]
qt_libs = [f"Qt5{m}" for m in qt_modules]
qt_env['QT3DIR'] = qt_env['QTDIR']
qt_env.Tool('qt3')
qt_env['CPPPATH'] += qt_dirs
qt_flags = [
"-D_REENTRANT",
"-DQT_NO_DEBUG",
"-DQT_WIDGETS_LIB",
"-DQT_GUI_LIB",
"-DQT_CORE_LIB",
"-DQT_MESSAGELOGCONTEXT",
]
qt_env['CXXFLAGS'] += qt_flags
qt_env['LIBPATH'] += ['#iqpilot/selfdrive/ui', ]
qt_env['LIBS'] = qt_libs
base_frameworks = qt_env['FRAMEWORKS']
base_libs = [common, messaging, cereal, visionipc, 'm', 'pthread'] + qt_env["LIBS"]
if arch == "Darwin":
base_frameworks += ['CoreFoundation', 'CoreVideo', 'CoreMedia', 'IOKit', 'Security', 'VideoToolbox']
cabana_env = qt_env.Clone()
cabana_env['CPPPATH'] += [libusb.INCLUDE_DIR]
cabana_env['LIBPATH'] += [libusb.LIB_DIR]
# IQ.Pilot patch: replay_lib fetches routes over libcurl and signs konn3kt JWTs with
# OpenSSL (tools/replay/api.cc), and visionipc references OpenCL — upstream gets all
# three transitively from its Python downloader / vendored wheels, we link them here.
cabana_libs = [cereal, messaging, visionipc, replay_lib] + ffmpeg_libs + \
['bz2', 'zstd', 'curl', 'ssl', 'crypto', 'usb-1.0'] + base_libs
if arch == "Darwin":
base_frameworks += ['OpenCL']
else:
cabana_libs += ['OpenCL']
opendbc_path = '-DOPENDBC_FILE_PATH=\'"%s"\'' % iqdbc.DBC_PATH
cabana_env['CXXFLAGS'] += [opendbc_path]
# embed the bootstrap icons SVG into the binary
def build_bootstrap_icons_src(target, source, env):
data = open(str(source[0]), 'rb').read()
with open(str(target[0]), 'w') as f:
f.write('#include <cstddef>\n')
f.write('extern const unsigned char bootstrap_icons_svg[];\n')
f.write('extern const size_t bootstrap_icons_svg_len;\n')
f.write('const unsigned char bootstrap_icons_svg[] = {\n')
for i in range(0, len(data), 32):
f.write(','.join(str(b) for b in data[i:i+32]) + ',\n')
f.write('};\n')
f.write('const size_t bootstrap_icons_svg_len = sizeof(bootstrap_icons_svg);\n')
return None
# IQ.Pilot patch: no comma-deps-bootstrap-icons wheel here; the SVG is checked in
# under third_party/bootstrap (see its pull.sh).
bootstrap_icons_src = cabana_env.Command('assets/bootstrap_icons.cc', '#iqpilot/third_party/bootstrap/bootstrap-icons.svg',
cabana_env.PrettyAction(build_bootstrap_icons_src, 'GEN'))
# build assets
assets = "assets/assets.cc"
cabana_env.Command(assets, "assets/assets.qrc", cabana_env.PrettyAction("rcc $SOURCES -o $TARGET", 'RCC'))
cabana_env.Depends(assets, Glob('/assets/*', exclude=[assets, "assets/assets.o"]))
cabana_srcs = ['mainwin.cc', 'streams/pandastream.cc', 'streams/devicestream.cc', 'streams/livestream.cc', 'streams/abstractstream.cc', 'streams/replaystream.cc', 'binaryview.cc', 'historylog.cc', 'videowidget.cc', 'signalview.cc',
'streams/routes.cc', 'dbc/dbc.cc', 'dbc/dbcfile.cc', 'dbc/dbcmanager.cc', 'dbc/dbcqt.cc',
'utils/export.cc', 'utils/util.cc', 'utils/elidedlabel.cc',
'chart/chartswidget.cc', 'chart/chart.cc', 'chart/signalselector.cc', 'chart/tiplabel.cc', 'chart/sparkline.cc',
'commands.cc', 'messageswidget.cc', 'streamselector.cc', 'settings.cc', 'panda.cc',
'cameraview.cc', 'detailwidget.cc', 'tools/findsimilarbits.cc', 'tools/findsignal.cc', 'tools/routeinfo.cc']
if arch != "Darwin":
cabana_srcs += ['streams/socketcanstream.cc']
cabana_lib = cabana_env.Library("cabana_lib", cabana_srcs + [bootstrap_icons_src], LIBS=cabana_libs, FRAMEWORKS=base_frameworks)
cabana_env.Program('_cabana', ['cabana.cc', cabana_lib, assets], LIBS=cabana_libs, FRAMEWORKS=base_frameworks)
if GetOption('extras'):
# This target deliberately uses the base environment and links no Qt libraries.
# It prevents Qt dependencies from creeping back into the DBC core.
dbc_core_test_env = env.Clone()
dbc_core_test_env['CXXFLAGS'] += [opendbc_path]
dbc_core_test_objects = [
dbc_core_test_env.Object('tests/dbc_core_tests', 'tests/test_cabana.cc'),
dbc_core_test_env.Object('tests/dbc_core_model', 'dbc/dbc.cc'),
dbc_core_test_env.Object('tests/dbc_core_file', 'dbc/dbcfile.cc'),
dbc_core_test_env.Object('tests/dbc_core_manager', 'dbc/dbcmanager.cc'),
]
dbc_core_test_env.Program('tests/test_dbc_core', dbc_core_test_objects)
output_json_file = 'iqpilot/tools/cabana/dbc/car_fingerprint_to_dbc.json'
generate_dbc = cabana_env.Command('#' + output_json_file,
['dbc/generate_dbc_json.py'],
cabana_env.PrettyAction("python3 iqpilot/tools/cabana/dbc/generate_dbc_json.py --out " + output_json_file,
'GEN', logfile='${TARGET}.log'))
cabana_env.Depends(generate_dbc, ["#iqpilot/common", "#iqpilot/cereal"])

View File

@@ -0,0 +1 @@
*.cc

View File

@@ -0,0 +1,5 @@
<!DOCTYPE RCC><RCC version="1.0">
<qresource>
<file>cabana-icon.png</file>
</qresource>
</RCC>

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -0,0 +1,510 @@
#include "tools/cabana/binaryview.h"
#include "tools/cabana/dbc/dbcqt.h"
#include <algorithm>
#include <cstdio>
#include <QFontDatabase>
#include <QHeaderView>
#include <QMouseEvent>
#include <QPainter>
#include <QScrollBar>
#include <QShortcut>
#include <QToolTip>
#include "tools/cabana/commands.h"
// BinaryView
const int CELL_HEIGHT = 36;
const int VERTICAL_HEADER_WIDTH = 30;
inline int get_bit_pos(const QModelIndex &index) { return flipBitPos(index.row() * 8 + index.column()); }
BinaryView::BinaryView(QWidget *parent) : QTableView(parent) {
model = new BinaryViewModel(this);
setModel(model);
delegate = new BinaryItemDelegate(this);
setItemDelegate(delegate);
horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch);
horizontalHeader()->setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont));
verticalHeader()->setSectionsClickable(false);
verticalHeader()->setSectionResizeMode(QHeaderView::Fixed);
verticalHeader()->setDefaultSectionSize(CELL_HEIGHT);
horizontalHeader()->hide();
setShowGrid(false);
setMouseTracking(true);
setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
QObject::connect(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, this, &BinaryView::refresh);
QObject::connect(undoNotifier(), &QtUndoNotifier::indexChanged, this, &BinaryView::refresh);
addShortcuts();
setWhatsThis(R"(
<b>Binary View</b><br/>
<!-- TODO: add description here -->
<span style="color:gray">Shortcuts</span><br />
Delete Signal:
<span style="background-color:lightGray;color:gray">&nbsp;x&nbsp;</span>,
<span style="background-color:lightGray;color:gray">&nbsp;Backspace&nbsp;</span>,
<span style="background-color:lightGray;color:gray">&nbsp;Delete&nbsp;</span><br />
Change endianness: <span style="background-color:lightGray;color:gray">&nbsp;e&nbsp; </span><br />
Change signedness: <span style="background-color:lightGray;color:gray">&nbsp;s&nbsp;</span><br />
Open chart:
<span style="background-color:lightGray;color:gray">&nbsp;c&nbsp;</span>,
<span style="background-color:lightGray;color:gray">&nbsp;p&nbsp;</span>,
<span style="background-color:lightGray;color:gray">&nbsp;g&nbsp;</span>
)");
}
void BinaryView::addShortcuts() {
// Delete (x, backspace, delete)
QShortcut *shortcut_delete_x = new QShortcut(QKeySequence(Qt::Key_X), this);
QShortcut *shortcut_delete_backspace = new QShortcut(QKeySequence(Qt::Key_Backspace), this);
QShortcut *shortcut_delete_delete = new QShortcut(QKeySequence(Qt::Key_Delete), this);
QObject::connect(shortcut_delete_delete, &QShortcut::activated, shortcut_delete_x, &QShortcut::activated);
QObject::connect(shortcut_delete_backspace, &QShortcut::activated, shortcut_delete_x, &QShortcut::activated);
QObject::connect(shortcut_delete_x, &QShortcut::activated, [=]{
if (hovered_sig != nullptr) {
UndoStack::instance()->push(new RemoveSigCommand(model->msg_id, hovered_sig));
hovered_sig = nullptr;
}
});
// Change endianness (e)
QShortcut *shortcut_endian = new QShortcut(QKeySequence(Qt::Key_E), this);
QObject::connect(shortcut_endian, &QShortcut::activated, [=]{
if (hovered_sig != nullptr) {
cabana::Signal s = *hovered_sig;
s.is_little_endian = !s.is_little_endian;
emit editSignal(hovered_sig, s);
}
});
// Change signedness (s)
QShortcut *shortcut_sign = new QShortcut(QKeySequence(Qt::Key_S), this);
QObject::connect(shortcut_sign, &QShortcut::activated, [=]{
if (hovered_sig != nullptr) {
cabana::Signal s = *hovered_sig;
s.is_signed = !s.is_signed;
emit editSignal(hovered_sig, s);
}
});
// Open chart (c, p, g)
QShortcut *shortcut_plot = new QShortcut(QKeySequence(Qt::Key_P), this);
QShortcut *shortcut_plot_g = new QShortcut(QKeySequence(Qt::Key_G), this);
QShortcut *shortcut_plot_c = new QShortcut(QKeySequence(Qt::Key_C), this);
QObject::connect(shortcut_plot_g, &QShortcut::activated, shortcut_plot, &QShortcut::activated);
QObject::connect(shortcut_plot_c, &QShortcut::activated, shortcut_plot, &QShortcut::activated);
QObject::connect(shortcut_plot, &QShortcut::activated, [=]{
if (hovered_sig != nullptr) {
emit showChart(model->msg_id, hovered_sig, true, false);
}
});
}
QSize BinaryView::minimumSizeHint() const {
return {(horizontalHeader()->minimumSectionSize() + 1) * 9 + VERTICAL_HEADER_WIDTH + 2,
CELL_HEIGHT * std::min(model->rowCount(), 10) + 2};
}
void BinaryView::highlight(const cabana::Signal *sig) {
if (sig != hovered_sig) {
for (int i = 0; i < model->items.size(); ++i) {
auto &item_sigs = model->items[i].sigs;
auto has = [](const auto &v, auto p) { return std::find(v.begin(), v.end(), p) != v.end(); };
if ((sig && has(item_sigs, sig)) || (hovered_sig && has(item_sigs, hovered_sig))) {
auto index = model->index(i / model->columnCount(), i % model->columnCount());
emit model->dataChanged(index, index, {Qt::DisplayRole});
}
}
hovered_sig = sig;
emit signalHovered(hovered_sig);
}
}
void BinaryView::setSelection(const QRect &rect, QItemSelectionModel::SelectionFlags flags) {
auto index = indexAt(last_mouse_pos);
if (!anchor_index.isValid() || !index.isValid())
return;
QItemSelection selection;
auto [start, size, is_lb] = getSelection(index);
for (int i = 0; i < size; ++i) {
int pos = is_lb ? flipBitPos(start + i) : flipBitPos(start) + i;
selection << QItemSelectionRange{model->index(pos / 8, pos % 8)};
}
selectionModel()->select(selection, flags);
}
void BinaryView::mousePressEvent(QMouseEvent *event) {
resize_sig = nullptr;
if (auto index = indexAt(last_mouse_pos = event->pos()); index.isValid() && index.column() != 8) {
anchor_index = index;
auto item = (const BinaryViewModel::Item *)anchor_index.internalPointer();
int bit_pos = get_bit_pos(anchor_index);
for (auto s : item->sigs) {
if (bit_pos == s->lsb || bit_pos == s->msb) {
int idx = flipBitPos(bit_pos == s->lsb ? s->msb : s->lsb);
anchor_index = model->index(idx / 8, idx % 8);
resize_sig = s;
break;
}
}
}
event->accept();
}
void BinaryView::highlightPosition(const QPoint &pos) {
if (auto index = indexAt(pos); index.isValid()) {
auto item = (BinaryViewModel::Item *)index.internalPointer();
const cabana::Signal *sig = item->sigs.empty() ? nullptr : item->sigs.back();
highlight(sig);
}
}
void BinaryView::mouseMoveEvent(QMouseEvent *event) {
highlightPosition(last_mouse_pos = event->pos());
QTableView::mouseMoveEvent(event);
}
void BinaryView::mouseReleaseEvent(QMouseEvent *event) {
QTableView::mouseReleaseEvent(event);
auto release_index = indexAt(event->pos());
if (release_index.isValid() && anchor_index.isValid()) {
if (selectionModel()->hasSelection()) {
auto sig = resize_sig ? *resize_sig : cabana::Signal{};
std::tie(sig.start_bit, sig.size, sig.is_little_endian) = getSelection(release_index);
resize_sig ? emit editSignal(resize_sig, sig)
: UndoStack::instance()->push(new AddSigCommand(model->msg_id, sig));
} else {
auto item = (const BinaryViewModel::Item *)anchor_index.internalPointer();
if (item && item->sigs.size() > 0)
emit signalClicked(item->sigs.back());
}
}
clearSelection();
anchor_index = QModelIndex();
resize_sig = nullptr;
}
void BinaryView::leaveEvent(QEvent *event) {
highlight(nullptr);
QTableView::leaveEvent(event);
}
void BinaryView::setMessage(const MessageId &message_id) {
model->msg_id = message_id;
verticalScrollBar()->setValue(0);
refresh();
}
void BinaryView::refresh() {
clearSelection();
anchor_index = QModelIndex();
resize_sig = nullptr;
hovered_sig = nullptr;
model->refresh();
if (underMouse()) highlightPosition(last_mouse_pos);
}
std::set<const cabana::Signal *> BinaryView::getOverlappingSignals() const {
std::set<const cabana::Signal *> overlapping;
for (const auto &item : model->items) {
if (item.sigs.size() > 1) {
for (auto s : item.sigs) {
if (s->type == cabana::Signal::Type::Normal) overlapping.insert(s);
}
}
}
return overlapping;
}
std::tuple<int, int, bool> BinaryView::getSelection(QModelIndex index) {
if (index.column() == 8) {
index = model->index(index.row(), 7);
}
bool is_lb = true;
if (resize_sig) {
is_lb = resize_sig->is_little_endian;
} else if (settings.drag_direction == Settings::DragDirection::MsbFirst) {
is_lb = index < anchor_index;
} else if (settings.drag_direction == Settings::DragDirection::LsbFirst) {
is_lb = !(index < anchor_index);
} else if (settings.drag_direction == Settings::DragDirection::AlwaysLE) {
is_lb = true;
} else if (settings.drag_direction == Settings::DragDirection::AlwaysBE) {
is_lb = false;
}
int cur_bit_pos = get_bit_pos(index);
int anchor_bit_pos = get_bit_pos(anchor_index);
int start_bit = is_lb ? std::min(cur_bit_pos, anchor_bit_pos) : get_bit_pos(std::min(index, anchor_index));
int size = is_lb ? std::abs(cur_bit_pos - anchor_bit_pos) + 1 : std::abs(flipBitPos(cur_bit_pos) - flipBitPos(anchor_bit_pos)) + 1;
return {start_bit, size, is_lb};
}
// BinaryViewModel
void BinaryViewModel::refresh() {
beginResetModel();
bit_flip_tracker = {};
items.clear();
if (auto dbc_msg = dbc()->msg(msg_id)) {
row_count = dbc_msg->size;
items.resize(row_count * column_count);
for (auto sig : dbc_msg->getSignals()) {
for (int j = 0; j < sig->size; ++j) {
int pos = sig->is_little_endian ? flipBitPos(sig->start_bit + j) : flipBitPos(sig->start_bit) + j;
int idx = column_count * (pos / 8) + pos % 8;
if (idx >= items.size()) {
fprintf(stderr, "signal %s out of bounds.start_bit: %d size: %d\n",
sig->name.c_str(), sig->start_bit, sig->size);
break;
}
if (j == 0) sig->is_little_endian ? items[idx].is_lsb = true : items[idx].is_msb = true;
if (j == sig->size - 1) sig->is_little_endian ? items[idx].is_msb = true : items[idx].is_lsb = true;
auto &sigs = items[idx].sigs;
sigs.push_back(sig);
if (sigs.size() > 1) {
std::sort(sigs.begin(), sigs.end(), [](auto l, auto r) { return l->size > r->size; });
}
}
}
} else {
row_count = can->lastMessage(msg_id).dat.size();
items.resize(row_count * column_count);
}
endResetModel();
updateState();
}
void BinaryViewModel::updateItem(int row, int col, uint8_t val, const QColor &color) {
auto &item = items[row * column_count + col];
item.valid = true;
if (item.val != val || item.bg_color != color) {
item.val = val;
item.bg_color = color;
auto idx = index(row, col);
emit dataChanged(idx, idx, {Qt::DisplayRole});
}
}
void BinaryViewModel::updateState() {
const auto &last_msg = can->lastMessage(msg_id);
const auto &binary = last_msg.dat;
// Handle size changes in binary data
if (binary.size() > row_count) {
beginInsertRows({}, row_count, binary.size() - 1);
row_count = binary.size();
items.resize(row_count * column_count);
endInsertRows();
}
auto &bit_flips = heatmap_live_mode ? last_msg.bit_flip_counts : getBitFlipChanges(binary.size());
// Find the maximum bit flip count across the message
uint32_t max_bit_flip_count = 1; // Default to 1 to avoid division by zero
for (const auto &row : bit_flips) {
for (uint32_t count : row) {
max_bit_flip_count = std::max(max_bit_flip_count, count);
}
}
const double max_alpha = 255.0;
const double min_alpha_with_signal = 25.0; // Base alpha for small flip counts
const double min_alpha_no_signal = 10.0; // Base alpha for small flip counts for no signal bits
const double log_factor = 1.0 + 0.2; // Factor for logarithmic scaling
const double log_scaler = max_alpha / log2(log_factor * max_bit_flip_count);
for (size_t i = 0; i < binary.size(); ++i) {
for (int j = 0; j < 8; ++j) {
auto &item = items[i * column_count + j];
int bit_val = (binary[i] >> (7 - j)) & 1;
double alpha = item.sigs.empty() ? 0 : min_alpha_with_signal;
uint32_t flip_count = bit_flips[i][j];
if (flip_count > 0) {
double normalized_alpha = log2(1.0 + flip_count * log_factor) * log_scaler;
double min_alpha = item.sigs.empty() ? min_alpha_no_signal : min_alpha_with_signal;
alpha = std::clamp(normalized_alpha, min_alpha, max_alpha);
}
auto color = item.bg_color;
color.setAlpha(alpha);
updateItem(i, j, bit_val, color);
}
updateItem(i, 8, binary[i], toQColor(last_msg.colors[i]));
}
}
const std::vector<std::array<uint32_t, 8>> &BinaryViewModel::getBitFlipChanges(size_t msg_size) {
// Return cached results if time range and data are unchanged
auto time_range = can->timeRange();
if (bit_flip_tracker.time_range == time_range && !bit_flip_tracker.flip_counts.empty())
return bit_flip_tracker.flip_counts;
bit_flip_tracker.time_range = time_range;
bit_flip_tracker.flip_counts.assign(msg_size, std::array<uint32_t, 8>{});
// Iterate over events within the specified time range and calculate bit flips
auto [first, last] = can->eventsInRange(msg_id, time_range);
if (std::distance(first, last) <= 1) return bit_flip_tracker.flip_counts;
std::vector<uint8_t> prev_values((*first)->dat, (*first)->dat + (*first)->size);
for (auto it = std::next(first); it != last; ++it) {
const CanEvent *event = *it;
int size = std::min<int>(msg_size, event->size);
for (int i = 0; i < size; ++i) {
const uint8_t diff = event->dat[i] ^ prev_values[i];
if (!diff) continue;
auto &bit_flips = bit_flip_tracker.flip_counts[i];
for (int bit = 0; bit < 8; ++bit) {
if (diff & (1u << bit)) ++bit_flips[7 - bit];
}
prev_values[i] = event->dat[i];
}
}
return bit_flip_tracker.flip_counts;
}
QVariant BinaryViewModel::headerData(int section, Qt::Orientation orientation, int role) const {
if (orientation == Qt::Vertical) {
switch (role) {
case Qt::DisplayRole: return section;
case Qt::SizeHintRole: return QSize(VERTICAL_HEADER_WIDTH, 0);
case Qt::TextAlignmentRole: return Qt::AlignCenter;
}
}
return {};
}
QVariant BinaryViewModel::data(const QModelIndex &index, int role) const {
auto item = (const BinaryViewModel::Item *)index.internalPointer();
return role == Qt::ToolTipRole && item && !item->sigs.empty() ? signalToolTip(item->sigs.back()) : QVariant();
}
// BinaryItemDelegate
BinaryItemDelegate::BinaryItemDelegate(QObject *parent) : QStyledItemDelegate(parent) {
small_font.setPixelSize(8);
hex_font = QFontDatabase::systemFont(QFontDatabase::FixedFont);
hex_font.setBold(true);
bin_text_table[0].setText("0");
bin_text_table[1].setText("1");
for (int i = 0; i < 256; ++i) {
hex_text_table[i].setText(QStringLiteral("%1").arg(i, 2, 16, QLatin1Char('0')).toUpper());
hex_text_table[i].prepare({}, hex_font);
}
}
bool BinaryItemDelegate::hasSignal(const QModelIndex &index, int dx, int dy, const cabana::Signal *sig) const {
if (!index.isValid()) return false;
auto model = (const BinaryViewModel*)(index.model());
int idx = (index.row() + dy) * model->columnCount() + index.column() + dx;
if (idx < 0 || idx >= (int)model->items.size()) return false;
auto &s = model->items[idx].sigs;
return std::find(s.begin(), s.end(), sig) != s.end();
}
void BinaryItemDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const {
auto item = (const BinaryViewModel::Item *)index.internalPointer();
BinaryView *bin_view = (BinaryView *)parent();
painter->save();
if (index.column() == 8) {
if (item->valid) {
painter->setFont(hex_font);
painter->fillRect(option.rect, item->bg_color);
}
} else if (option.state & QStyle::State_Selected) {
auto color = bin_view->resize_sig ? toQColor(bin_view->resize_sig->color) : option.palette.color(QPalette::Active, QPalette::Highlight);
painter->fillRect(option.rect, color);
painter->setPen(option.palette.color(QPalette::BrightText));
} else if (!bin_view->selectionModel()->hasSelection() || std::find(item->sigs.begin(), item->sigs.end(), bin_view->resize_sig) == item->sigs.end()) { // not resizing
if (item->sigs.size() > 0) {
for (auto &s : item->sigs) {
if (s == bin_view->hovered_sig) {
painter->fillRect(option.rect, toQColor(s->color.darker(125))); // 4/5x brightness
} else {
drawSignalCell(painter, option, index, s);
}
}
} else if (item->valid && item->bg_color.alpha() > 0) {
painter->fillRect(option.rect, item->bg_color);
}
auto color_role = (std::find(item->sigs.begin(), item->sigs.end(), bin_view->hovered_sig) != item->sigs.end()) ? QPalette::BrightText : QPalette::Text;
painter->setPen(option.palette.color(bin_view->is_message_active ? QPalette::Normal : QPalette::Disabled, color_role));
}
if (item->sigs.size() > 1) {
painter->fillRect(option.rect, QBrush(Qt::darkGray, Qt::Dense7Pattern));
} else if (!item->valid) {
painter->fillRect(option.rect, QBrush(Qt::darkGray, Qt::BDiagPattern));
}
if (item->valid) {
utils::drawStaticText(painter, option.rect, index.column() == 8 ? hex_text_table[item->val] : bin_text_table[item->val]);
}
if (item->is_msb || item->is_lsb) {
painter->setFont(small_font);
painter->drawText(option.rect.adjusted(8, 0, -8, -3), Qt::AlignRight | Qt::AlignBottom, item->is_msb ? "M" : "L");
}
painter->restore();
}
// Draw border on edge of signal
void BinaryItemDelegate::drawSignalCell(QPainter *painter, const QStyleOptionViewItem &option,
const QModelIndex &index, const cabana::Signal *sig) const {
bool draw_left = !hasSignal(index, -1, 0, sig);
bool draw_top = !hasSignal(index, 0, -1, sig);
bool draw_right = !hasSignal(index, 1, 0, sig);
bool draw_bottom = !hasSignal(index, 0, 1, sig);
const int spacing = 2;
QRect rc = option.rect.adjusted(draw_left * 3, draw_top * spacing, draw_right * -3, draw_bottom * -spacing);
QRegion subtract;
if (!draw_top) {
if (!draw_left && !hasSignal(index, -1, -1, sig)) {
subtract += QRect{rc.left(), rc.top(), 3, spacing};
} else if (!draw_right && !hasSignal(index, 1, -1, sig)) {
subtract += QRect{rc.right() - 2, rc.top(), 3, spacing};
}
}
if (!draw_bottom) {
if (!draw_left && !hasSignal(index, -1, 1, sig)) {
subtract += QRect{rc.left(), rc.bottom() - (spacing - 1), 3, spacing};
} else if (!draw_right && !hasSignal(index, 1, 1, sig)) {
subtract += QRect{rc.right() - 2, rc.bottom() - (spacing - 1), 3, spacing};
}
}
painter->setClipRegion(QRegion(rc).subtracted(subtract));
auto item = (const BinaryViewModel::Item *)index.internalPointer();
QColor color = toQColor(sig->color);
color.setAlpha(item->bg_color.alpha());
// Mixing the signal color with the Base background color to fade it
painter->fillRect(rc, option.palette.color(QPalette::Base));
painter->fillRect(rc, color);
// Draw edges
color = toQColor(sig->color.darker(125));
painter->setPen(QPen(color, 1));
if (draw_left) painter->drawLine(rc.topLeft(), rc.bottomLeft());
if (draw_right) painter->drawLine(rc.topRight(), rc.bottomRight());
if (draw_bottom) painter->drawLine(rc.bottomLeft(), rc.bottomRight());
if (draw_top) painter->drawLine(rc.topLeft(), rc.topRight());
if (!subtract.isEmpty()) {
// fill gaps inside corners.
painter->setPen(QPen(color, 2, Qt::SolidLine, Qt::SquareCap, Qt::MiterJoin));
for (auto &r : subtract) {
painter->drawRect(r);
}
}
}

View File

@@ -0,0 +1,104 @@
#pragma once
#include <set>
#include <tuple>
#include <vector>
#include <QStyledItemDelegate>
#include <QTableView>
#include "tools/cabana/dbc/dbcmanager.h"
#include "tools/cabana/streams/abstractstream.h"
class BinaryItemDelegate : public QStyledItemDelegate {
public:
BinaryItemDelegate(QObject *parent);
void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override;
bool hasSignal(const QModelIndex &index, int dx, int dy, const cabana::Signal *sig) const;
void drawSignalCell(QPainter* painter, const QStyleOptionViewItem &option, const QModelIndex &index, const cabana::Signal *sig) const;
QFont small_font, hex_font;
std::array<QStaticText, 256> hex_text_table;
std::array<QStaticText, 2> bin_text_table;
};
class BinaryViewModel : public QAbstractTableModel {
public:
BinaryViewModel(QObject *parent) : QAbstractTableModel(parent) {}
void refresh();
void updateState();
void updateItem(int row, int col, uint8_t val, const QColor &color);
QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override;
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
int rowCount(const QModelIndex &parent = QModelIndex()) const override { return row_count; }
int columnCount(const QModelIndex &parent = QModelIndex()) const override { return column_count; }
QModelIndex index(int row, int column, const QModelIndex &parent = QModelIndex()) const override {
return createIndex(row, column, (void *)&items[row * column_count + column]);
}
Qt::ItemFlags flags(const QModelIndex &index) const override {
return (index.column() == column_count - 1) ? Qt::ItemIsEnabled : Qt::ItemIsEnabled | Qt::ItemIsSelectable;
}
const std::vector<std::array<uint32_t, 8>> &getBitFlipChanges(size_t msg_size);
struct BitFlipTracker {
std::optional<std::pair<double, double>> time_range;
std::vector<std::array<uint32_t, 8>> flip_counts;
} bit_flip_tracker;
struct Item {
QColor bg_color = QColor(102, 86, 169, 255);
bool is_msb = false;
bool is_lsb = false;
uint8_t val;
std::vector<const cabana::Signal *> sigs;
bool valid = false;
};
std::vector<Item> items;
bool heatmap_live_mode = true;
MessageId msg_id;
int row_count = 0;
const int column_count = 9;
};
class BinaryView : public QTableView {
Q_OBJECT
public:
BinaryView(QWidget *parent = nullptr);
void setMessage(const MessageId &message_id);
void highlight(const cabana::Signal *sig);
std::set<const cabana::Signal*> getOverlappingSignals() const;
void updateState() { model->updateState(); }
void paintEvent(QPaintEvent *event) override {
is_message_active = can->isMessageActive(model->msg_id);
QTableView::paintEvent(event);
}
QSize minimumSizeHint() const override;
void setHeatmapLiveMode(bool live) { model->heatmap_live_mode = live; updateState(); }
signals:
void signalClicked(const cabana::Signal *sig);
void signalHovered(const cabana::Signal *sig);
void editSignal(const cabana::Signal *origin_s, cabana::Signal &s);
void showChart(const MessageId &id, const cabana::Signal *sig, bool show, bool merge);
private:
void addShortcuts();
void refresh();
std::tuple<int, int, bool> getSelection(QModelIndex index);
void setSelection(const QRect &rect, QItemSelectionModel::SelectionFlags flags) override;
void mousePressEvent(QMouseEvent *event) override;
void mouseMoveEvent(QMouseEvent *event) override;
void mouseReleaseEvent(QMouseEvent *event) override;
void leaveEvent(QEvent *event) override;
void highlightPosition(const QPoint &pt);
QModelIndex anchor_index;
QPoint last_mouse_pos{-1, -1};
BinaryViewModel *model;
BinaryItemDelegate *delegate;
bool is_message_active = false;
const cabana::Signal *resize_sig = nullptr;
const cabana::Signal *hovered_sig = nullptr;
friend class BinaryItemDelegate;
};

38
iqpilot/tools/cabana/cabana Executable file
View File

@@ -0,0 +1,38 @@
#!/usr/bin/env bash
set -e
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )"
ROOT="$(cd "$DIR/../../../" && pwd)"
install_qt() {
if [[ "$(uname)" == "Darwin" ]]; then
brew install qt@5
brew link qt@5 || true
else
SUDO=""
if [[ ! $(id -u) -eq 0 ]]; then
SUDO="sudo"
fi
$SUDO apt-get install -y --no-install-recommends \
qtbase5-dev \
qtbase5-dev-tools \
qttools5-dev-tools \
libqt5charts5-dev \
libqt5svg5-dev \
libqt5serialbus5-dev \
libqt5x11extras5-dev \
libqt5opengl5-dev
fi
}
# Install Qt if not found
if ! command -v qmake &> /dev/null; then
echo "Qt not found, installing dependencies..."
install_qt
fi
# Build _cabana
cd "$ROOT"
scons -u iqpilot/tools/cabana/_cabana iqpilot/cereal/messaging/bridge
exec "$DIR/_cabana" "$@"

View File

@@ -0,0 +1,195 @@
#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 bridge;
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 konn3kt.com\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> subscribe to a zmq 'can' publisher at the specified ip-address\n"
" (a device running cereal/messaging/bridge, or\n"
" tools/cabana/konn3kt_canproxy.py on 127.0.0.1)\n"
" --bridge <ip-address> run cereal/messaging/bridge locally against the device\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, "--bridge") == 0) {
if (!takeValue(argc, argv, i, args.bridge)) 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, DeviceStream::Mode::Msgq);
} else if (!args.zmq.empty()) {
stream = new DeviceStream(&app, DeviceStream::Mode::Zmq, QString::fromStdString(args.zmq));
} else if (!args.bridge.empty()) {
stream = new DeviceStream(&app, DeviceStream::Mode::Bridge, QString::fromStdString(args.bridge));
} 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();
}

View File

@@ -0,0 +1,125 @@
#include "tools/cabana/cameraview.h"
#include <algorithm>
#include <chrono>
#include <cmath>
#include <cstdio>
#include <QApplication>
#include <QPainter>
#include "common/yuv.h"
CameraWidget::CameraWidget(std::string stream_name, VisionStreamType type, QWidget* parent) :
stream_name(stream_name), active_stream_type(type), requested_stream_type(type), QWidget(parent) {
setAttribute(Qt::WA_OpaquePaintEvent);
qRegisterMetaType<std::set<VisionStreamType>>("availableStreams");
QObject::connect(this, &CameraWidget::vipcThreadFrameReceived, this, &CameraWidget::vipcFrameReceived, Qt::QueuedConnection);
QObject::connect(this, &CameraWidget::vipcAvailableStreamsUpdated, this, &CameraWidget::availableStreamsUpdated, Qt::QueuedConnection);
QObject::connect(QApplication::instance(), &QCoreApplication::aboutToQuit, this, &CameraWidget::stopVipcThread);
}
CameraWidget::~CameraWidget() {
stopVipcThread();
}
void CameraWidget::showEvent(QShowEvent *event) {
if (!vipc_thread.joinable()) {
clearFrames();
vipc_exit = false;
vipc_thread = std::thread(&CameraWidget::vipcThread, this);
}
}
void CameraWidget::stopVipcThread() {
vipc_exit = true;
if (vipc_thread.joinable()) {
vipc_thread.join();
}
}
void CameraWidget::availableStreamsUpdated(std::set<VisionStreamType> streams) {
available_streams = streams;
}
void CameraWidget::paintEvent(QPaintEvent *event) {
QPainter p(this);
p.fillRect(rect(), bg);
std::lock_guard lk(frame_lock);
if (rgb_frame.isNull()) return;
// Scale for aspect ratio
float widget_ratio = (float)width() / height();
float frame_ratio = (float)rgb_frame.width() / rgb_frame.height();
int w = std::lround(width() * std::min(frame_ratio / widget_ratio, 1.0f));
int h = std::lround(height() * std::min(widget_ratio / frame_ratio, 1.0f));
QRect video_rect((width() - w) / 2, (height() - h) / 2, w, h);
p.setRenderHint(QPainter::SmoothPixmapTransform);
if (active_stream_type == VISION_STREAM_DRIVER) {
// mirror driver camera horizontally
const qreal cx = video_rect.x() + video_rect.width() / 2.0;
p.translate(cx, 0);
p.scale(-1, 1);
p.translate(-cx, 0);
}
p.drawImage(video_rect, rgb_frame);
}
void CameraWidget::vipcFrameReceived() {
update();
}
void CameraWidget::vipcThread() {
VisionStreamType cur_stream = requested_stream_type;
std::unique_ptr<VisionIpcClient> vipc_client;
VisionIpcBufExtra frame_meta = {};
while (!vipc_exit) {
if (!vipc_client || cur_stream != requested_stream_type) {
clearFrames();
fprintf(stderr, "connecting to stream %d, was connected to %d\n",
(int)requested_stream_type, (int)cur_stream);
cur_stream = requested_stream_type;
vipc_client.reset(new VisionIpcClient(stream_name, cur_stream, false));
}
active_stream_type = cur_stream;
if (!vipc_client->connected) {
clearFrames();
auto streams = VisionIpcClient::getAvailableStreams(stream_name, false);
if (streams.empty()) {
std::this_thread::sleep_for(std::chrono::milliseconds(100));
continue;
}
emit vipcAvailableStreamsUpdated(streams);
if (!vipc_client->connect(false)) {
std::this_thread::sleep_for(std::chrono::milliseconds(100));
continue;
}
}
if (VisionBuf *buf = vipc_client->recv(&frame_meta, 100)) {
// NV12 -> RGBA once per frame on the receive thread; paint just draws the image
if (rgb_back.width() != (int)buf->width || rgb_back.height() != (int)buf->height) {
rgb_back = QImage(buf->width, buf->height, QImage::Format_RGBA8888);
}
yuv::nv12_to_rgba(buf->y, buf->stride, buf->uv, buf->stride,
rgb_back.bits(), rgb_back.bytesPerLine(), buf->width, buf->height);
{
std::lock_guard lk(frame_lock);
rgb_frame.swap(rgb_back);
}
emit vipcThreadFrameReceived();
}
}
}
void CameraWidget::clearFrames() {
std::lock_guard lk(frame_lock);
rgb_frame = QImage();
rgb_back = QImage();
available_streams.clear();
}

View File

@@ -0,0 +1,56 @@
#pragma once
#include <atomic>
#include <mutex>
#include <set>
#include <string>
#include <thread>
#include <utility>
#include <QImage>
#include <QWidget>
#include "cereal/visionstream.h"
#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>);

View File

@@ -0,0 +1,770 @@
#include "tools/cabana/chart/chart.h"
#include "tools/cabana/dbc/dbcqt.h"
#include <algorithm>
#include <limits>
#include <random>
#include <QActionGroup>
#include <QContextMenuEvent>
#include <QMouseEvent>
#include <QPainterPath>
#include "tools/cabana/chart/chartswidget.h"
const int AXIS_X_TOP_MARGIN = 4;
const int X_TICK_COUNT = 5;
const double MIN_ZOOM_SECONDS = 0.01; // 10ms
// Define a small value of epsilon to compare double values
const float EPSILON = 0.000001;
static inline bool xLessThan(const QPointF &p, float x) { return p.x() < (x - EPSILON); }
static QMargins layoutMargins(const QStyle *style) {
return {
style->pixelMetric(QStyle::PM_LayoutLeftMargin),
style->pixelMetric(QStyle::PM_LayoutTopMargin),
style->pixelMetric(QStyle::PM_LayoutRightMargin),
style->pixelMetric(QStyle::PM_LayoutBottomMargin),
};
}
ChartView::ChartView(const std::pair<double, double> &x_range, ChartsWidget *parent)
: x_min(x_range.first), x_max(x_range.second), charts_widget(parent), QWidget(parent) {
series_type = (SeriesType)settings.chart_series_type;
align_to = 50;
setMouseTracking(true);
tip_label = new TipLabel(this);
createToolButtons();
signal_value_font.setPointSize(9);
QObject::connect(dbcNotifier(), &QtDBCNotifier::signalRemoved, this, &ChartView::signalRemoved);
QObject::connect(dbcNotifier(), &QtDBCNotifier::signalUpdated, this, &ChartView::signalUpdated);
QObject::connect(dbcNotifier(), &QtDBCNotifier::msgRemoved, this, &ChartView::msgRemoved);
QObject::connect(dbcNotifier(), &QtDBCNotifier::msgUpdated, this, &ChartView::msgUpdated);
}
void ChartView::createToolButtons() {
close_btn = new ToolButton("x", tr("Remove Chart"), this);
menu = new QMenu(this);
// series types
auto change_series_group = new QActionGroup(menu);
change_series_group->setExclusive(true);
QStringList types{tr("Line"), tr("Step Line"), tr("Scatter")};
for (int i = 0; i < types.size(); ++i) {
QAction *act = new QAction(types[i], change_series_group);
act->setData(i);
act->setCheckable(true);
act->setChecked(i == (int)series_type);
menu->addAction(act);
}
menu->addSeparator();
menu->addAction(tr("Manage Signals"), this, &ChartView::manageSignals);
split_chart_act = menu->addAction(tr("Split Chart"), [this]() { charts_widget->splitChart(this); });
manage_btn = new ToolButton("list", "", this);
manage_btn->setMenu(menu);
manage_btn->setPopupMode(QToolButton::InstantPopup);
manage_btn->setStyleSheet("QToolButton::menu-indicator { image: none; }");
close_act = new QAction(tr("Close"), this);
QObject::connect(close_act, &QAction::triggered, [this] () { charts_widget->removeChart(this); });
QObject::connect(close_btn, &QToolButton::clicked, close_act, &QAction::triggered);
QObject::connect(change_series_group, &QActionGroup::triggered, [this](QAction *action) {
setSeriesType((SeriesType)action->data().toInt());
});
}
QSize ChartView::sizeHint() const {
return {CHART_MIN_WIDTH, settings.chart_height};
}
void ChartView::addSignal(const MessageId &msg_id, const cabana::Signal *sig) {
if (hasSignal(msg_id, sig)) return;
sigs.push_back({.msg_id = msg_id, .sig = sig, .color = uniqueColor(toQColor(sig->color))});
updateSeries(sig);
updateTitle();
emit charts_widget->seriesChanged();
}
bool ChartView::hasSignal(const MessageId &msg_id, const cabana::Signal *sig) const {
return std::any_of(sigs.cbegin(), sigs.cend(), [&](auto &s) { return s.msg_id == msg_id && s.sig == sig; });
}
void ChartView::removeIf(std::function<bool(const SigItem &s)> predicate) {
int prev_size = sigs.size();
sigs.erase(std::remove_if(sigs.begin(), sigs.end(), predicate), sigs.end());
if (sigs.empty()) {
charts_widget->removeChart(this);
} else if (sigs.size() != prev_size) {
emit charts_widget->seriesChanged();
updateAxisY();
updateTitle();
}
}
void ChartView::signalUpdated(const cabana::Signal *sig) {
auto it = std::find_if(sigs.begin(), sigs.end(), [sig](auto &s) { return s.sig == sig; });
if (it != sigs.end()) {
if (it->color != toQColor(sig->color)) {
it->color = uniqueColor(toQColor(sig->color), sig);
}
updateTitle();
updateSeries(sig);
}
}
void ChartView::msgUpdated(MessageId id) {
if (std::any_of(sigs.cbegin(), sigs.cend(), [=](auto &s) { return s.msg_id.address == id.address; })) {
updateTitle();
}
}
void ChartView::manageSignals() {
SignalSelector dlg(tr("Manage Chart"), this);
for (auto &s : sigs) {
dlg.addSelected(s.msg_id, s.sig);
}
if (dlg.exec() == QDialog::Accepted) {
auto items = dlg.seletedItems();
for (auto s : items) {
addSignal(s->msg_id, s->sig);
}
removeIf([&](auto &s) {
return std::none_of(items.cbegin(), items.cend(), [&](auto &it) { return s.msg_id == it->msg_id && s.sig == it->sig; });
});
}
}
void ChartView::resizeEvent(QResizeEvent *event) {
QWidget::resizeEvent(event);
const auto margins = layoutMargins(style());
QPixmap grip = utils::icon("grip-horizontal");
move_icon_rect = QRect(QPoint(margins.left(), margins.top()), grip.size() / grip.devicePixelRatio());
close_btn->resize(close_btn->sizeHint());
manage_btn->resize(manage_btn->sizeHint());
close_btn->move(rect().right() - margins.right() - close_btn->width(), margins.top());
manage_btn->move(close_btn->x() - manage_btn->width() - style()->pixelMetric(QStyle::PM_LayoutHorizontalSpacing), margins.top());
updatePlotArea(align_to, true);
}
void ChartView::updatePlotArea(int left_pos, bool force) {
if (align_to != left_pos || force) {
align_to = left_pos;
const auto margins = layoutMargins(style());
QFont bold_font = font();
bold_font.setBold(true);
QFontMetrics fm(font()), bfm(bold_font);
const int marker_size = fm.height() - 4;
const int row_height = std::max(marker_size, fm.height()) + QFontMetrics(signal_value_font).height() + 3;
const int legend_left = move_icon_rect.right() + margins.left();
const int legend_right = std::max(manage_btn->x() - margins.right(), legend_left + 10);
// layout legend entries left-to-right, wrapping between the move icon and the buttons
legend_rects.clear();
int x = legend_left, y = margins.top();
for (auto &s : sigs) {
int w = marker_size + 5 + bfm.horizontalAdvance(QString::fromStdString(s.sig->name)) +
fm.horizontalAdvance(QString::fromStdString(" " + msgName(s.msg_id) + " " + s.msg_id.toString()));
w = std::min(w, legend_right - legend_left); // keep oversized entries clear of the header buttons
if (x + w > legend_right && x > legend_left) {
x = legend_left;
y += row_height;
}
legend_rects.emplace_back(x, y, w, std::max(marker_size, fm.height()));
x += w + 12;
}
// add top space for the legend and signal values
int adjust_top = (y + row_height) - margins.top();
adjust_top = std::max(adjust_top, manage_btn->geometry().bottom() + style()->pixelMetric(QStyle::PM_LayoutTopMargin));
// add right space for x-axis label
QSizeF x_label_size = fm.size(Qt::TextSingleLine, QString::number(x_max, 'f', xAxisPrecision())) + QSizeF{5, 5};
plot_area = rect().adjusted(align_to + margins.left(), adjust_top + margins.top(),
-x_label_size.width() / 2 - margins.right(),
-x_label_size.height() - margins.bottom());
resetChartCache();
}
}
void ChartView::updateTitle() {
split_chart_act->setEnabled(sigs.size() > 1);
updatePlotArea(align_to, true);
}
void ChartView::updatePlot(double cur, double min, double max) {
cur_sec = cur;
if (min != x_min || max != x_max) {
x_min = min;
x_max = max;
updateAxisY();
// update tooltip
if (tooltip_x >= 0) {
showTip(secondsAtPoint({tooltip_x, 0}));
}
resetChartCache();
}
update();
}
void ChartView::appendCanEvents(const cabana::Signal *sig, const std::vector<const CanEvent *> &events,
std::vector<QPointF> &vals, std::vector<QPointF> &step_vals) {
vals.reserve(vals.size() + events.capacity());
step_vals.reserve(step_vals.size() + events.capacity() * 2);
double value = 0;
for (const CanEvent *e : events) {
if (sig->getValue(e->dat, e->size, &value)) {
const double ts = can->toSeconds(e->mono_time);
vals.emplace_back(ts, value);
if (!step_vals.empty())
step_vals.emplace_back(ts, step_vals.back().y());
step_vals.emplace_back(ts, value);
}
}
}
void ChartView::updateSeries(const cabana::Signal *sig, const MessageEventsMap *msg_new_events) {
for (auto &s : sigs) {
if (!sig || s.sig == sig) {
if (!msg_new_events) {
s.vals.clear();
s.step_vals.clear();
}
auto events = msg_new_events ? msg_new_events : &can->eventsMap();
auto it = events->find(s.msg_id);
if (it == events->end() || it->second.empty()) continue;
if (s.vals.empty() || can->toSeconds(it->second.back()->mono_time) > s.vals.back().x()) {
appendCanEvents(s.sig, it->second, s.vals, s.step_vals);
} else {
std::vector<QPointF> vals, step_vals;
appendCanEvents(s.sig, it->second, vals, step_vals);
s.vals.insert(std::lower_bound(s.vals.begin(), s.vals.end(), vals.front().x(), xLessThan),
vals.begin(), vals.end());
s.step_vals.insert(std::lower_bound(s.step_vals.begin(), s.step_vals.end(), step_vals.front().x(), xLessThan),
step_vals.begin(), step_vals.end());
}
if (!can->liveStreaming()) {
s.segment_tree.build(s.vals);
}
}
}
updateAxisY();
// invoke resetChartCache in ui thread
QMetaObject::invokeMethod(this, &ChartView::resetChartCache, Qt::QueuedConnection);
}
// auto zoom on yaxis
void ChartView::updateAxisY() {
if (sigs.empty()) return;
double min = std::numeric_limits<double>::max();
double max = std::numeric_limits<double>::lowest();
QString unit = QString::fromStdString(sigs[0].sig->unit);
for (auto &s : sigs) {
if (!s.visible) continue;
// Only show unit when all signals have the same unit
if (unit != QString::fromStdString(s.sig->unit)) {
unit.clear();
}
auto first = std::lower_bound(s.vals.cbegin(), s.vals.cend(), x_min, xLessThan);
auto last = std::lower_bound(first, s.vals.cend(), x_max, xLessThan);
s.min = std::numeric_limits<double>::max();
s.max = std::numeric_limits<double>::lowest();
if (can->liveStreaming()) {
for (auto it = first; it != last; ++it) {
if (it->y() < s.min) s.min = it->y();
if (it->y() > s.max) s.max = it->y();
}
} else {
std::tie(s.min, s.max) = s.segment_tree.minmax(std::distance(s.vals.cbegin(), first), std::distance(s.vals.cbegin(), last));
}
min = std::min(min, s.min);
max = std::max(max, s.max);
}
if (min == std::numeric_limits<double>::max()) min = 0;
if (max == std::numeric_limits<double>::lowest()) max = 0;
if (y_unit != unit) {
y_unit = unit;
y_label_width = 0; // recalc width
}
double delta = std::abs(max - min) < 1e-3 ? 1 : (max - min) * 0.05;
auto [min_y, max_y, tick_count] = getNiceAxisNumbers(min - delta, max + delta, 3);
if (min_y != y_min || max_y != y_max || y_label_width == 0) {
y_min = min_y;
y_max = max_y;
y_tick_count = tick_count;
y_precision = std::max(int(-std::floor(std::log10((max_y - min_y) / (tick_count - 1)))), 0);
QFontMetrics fm(font());
int max_label_width = 0;
for (int i = 0; i < tick_count; i++) {
qreal value = min_y + (i * (max_y - min_y) / (tick_count - 1));
max_label_width = std::max(max_label_width, fm.horizontalAdvance(QString::number(value, 'f', y_precision)));
}
int title_spacing = y_unit.isEmpty() ? 0 : fm.size(Qt::TextSingleLine, y_unit).height();
y_label_width = title_spacing + max_label_width + 15;
emit axisYLabelWidthChanged(y_label_width);
}
}
std::tuple<double, double, int> ChartView::getNiceAxisNumbers(qreal min, qreal max, int tick_count) {
qreal range = niceNumber((max - min), true); // range with ceiling
qreal step = niceNumber(range / (tick_count - 1), false);
min = std::floor(min / step);
max = std::ceil(max / step);
tick_count = int(max - min) + 1;
return {min * step, max * step, tick_count};
}
int ChartView::xAxisPrecision() const {
return std::max(int(-std::floor(std::log10((x_max - x_min) / (X_TICK_COUNT - 1)))), 2);
}
// nice numbers can be expressed as form of 1*10^n, 2* 10^n or 5*10^n
qreal ChartView::niceNumber(qreal x, bool ceiling) {
qreal z = std::pow(10, std::floor(std::log10(x))); //find corresponding number of the form of 10^n than is smaller than x
qreal q = x / z; //q<10 && q>=1;
if (ceiling) {
if (q <= 1.0) q = 1;
else if (q <= 2.0) q = 2;
else if (q <= 5.0) q = 5;
else q = 10;
} else {
if (q < 1.5) q = 1;
else if (q < 3.0) q = 2;
else if (q < 7.0) q = 5;
else q = 10;
}
return q * z;
}
void ChartView::contextMenuEvent(QContextMenuEvent *event) {
QMenu context_menu(this);
context_menu.addActions(menu->actions());
context_menu.addSeparator();
context_menu.addAction(charts_widget->undo_zoom_action);
context_menu.addAction(charts_widget->redo_zoom_action);
context_menu.addSeparator();
context_menu.addAction(close_act);
context_menu.exec(event->globalPos());
}
void ChartView::mousePressEvent(QMouseEvent *event) {
press_pos = event->pos();
if (event->button() == Qt::LeftButton && move_icon_rect.contains(event->pos())) {
charts_widget->startChartDrag(this, event->globalPos());
} else if (event->button() == Qt::LeftButton && event->modifiers().testFlag(Qt::ShiftModifier)) {
// Save current playback state when scrubbing
resume_after_scrub = !can->isPaused();
if (resume_after_scrub) {
can->pause(true);
}
mouse_mode = MouseMode::Scrub;
} else if (event->button() == Qt::LeftButton && plot_area.contains(event->pos())) {
mouse_mode = MouseMode::Rubber;
rubber_rect = QRect();
} else {
QWidget::mousePressEvent(event);
}
}
void ChartView::mouseMoveEvent(QMouseEvent *ev) {
// Scrubbing
if (mouse_mode == MouseMode::Scrub && ev->modifiers().testFlag(Qt::ShiftModifier)) {
if (plot_area.contains(ev->pos())) {
can->seekTo(std::clamp(secondsAtPoint(ev->pos()), can->minSeconds(), can->maxSeconds()));
}
}
if (mouse_mode == MouseMode::Rubber) {
// horizontal selection, clamped to the plot area
int left = std::clamp(std::min(press_pos.x(), ev->pos().x()), plot_area.left(), plot_area.right());
int right = std::clamp(std::max(press_pos.x(), ev->pos().x()), plot_area.left(), plot_area.right());
rubber_rect = QRect(left, plot_area.top(), right - left, plot_area.height());
update();
}
clearTrackPoints();
if (mouse_mode != MouseMode::Rubber && plot_area.contains(ev->pos()) && isActiveWindow()) {
charts_widget->showValueTip(secondsAtPoint(ev->pos()));
} else if (tip_label->isVisible()) {
charts_widget->showValueTip(-1);
}
QWidget::mouseMoveEvent(ev);
}
void ChartView::mouseReleaseEvent(QMouseEvent *event) {
if (event->button() == Qt::LeftButton && mouse_mode == MouseMode::Rubber) {
mouse_mode = MouseMode::None;
// Prevent zooming/seeking past the end of the route
double min = std::clamp(secondsAtPoint(rubber_rect.topLeft()), can->minSeconds(), can->maxSeconds());
double max = std::clamp(secondsAtPoint(rubber_rect.bottomRight()), can->minSeconds(), can->maxSeconds());
if (rubber_rect.width() <= 0) {
// no rubber dragged, seek to mouse position
can->seekTo(std::clamp(secondsAtPoint(press_pos), can->minSeconds(), can->maxSeconds()));
} else if (rubber_rect.width() > 10 && (max - min) > MIN_ZOOM_SECONDS) {
charts_widget->zoom_undo_stack.push(new ZoomCommand({min, max}));
}
rubber_rect = QRect();
update();
} else if (event->button() == Qt::LeftButton && mouse_mode == MouseMode::None && sigs.size() > 1) {
// toggle series visibility by clicking its legend entry
for (int i = 0; i < sigs.size() && i < legend_rects.size(); ++i) {
if (legend_rects[i].contains(press_pos) && legend_rects[i].contains(event->pos())) {
sigs[i].visible = !sigs[i].visible;
updateAxisY();
updateTitle();
break;
}
}
} else if (event->button() == Qt::RightButton) {
charts_widget->zoom_undo_stack.undo();
} else {
QWidget::mouseReleaseEvent(event);
}
// Resume playback if we were scrubbing
if (mouse_mode == MouseMode::Scrub) {
mouse_mode = MouseMode::None;
if (resume_after_scrub) {
can->pause(false);
resume_after_scrub = false;
}
}
}
void ChartView::takeSignalsFrom(ChartView *source) {
for (auto &s : source->sigs) {
sigs.push_back(std::move(s));
sigs.back().color = uniqueColor(sigs.back().color, sigs.back().sig);
}
source->sigs.clear();
updateAxisY();
updateTitle();
charts_widget->removeChart(source);
}
void ChartView::showTip(double sec) {
QRect tip_area(0, plot_area.top(), rect().width(), plot_area.height());
QRect visible_rect = charts_widget->chartVisibleRect(this).intersected(tip_area);
if (visible_rect.isEmpty()) {
tip_label->hide();
return;
}
tooltip_x = xPos(sec);
qreal x = -1;
QStringList text_list;
for (auto &s : sigs) {
if (s.visible) {
QString value = "--";
// use reverse iterator to find last item <= sec.
auto it = std::lower_bound(s.vals.crbegin(), s.vals.crend(), sec, [](auto &p, double v) { return p.x() > v; });
if (it != s.vals.crend() && it->x() >= x_min) {
value = QString::fromStdString(s.sig->formatValue(it->y(), false));
s.track_pt = *it;
x = std::max(x, xPos(it->x()));
}
QString name = sigs.size() > 1 ? QString::fromStdString(s.sig->name) + ": " : "";
QString min = s.min == std::numeric_limits<double>::max() ? "--" : QString::number(s.min);
QString max = s.max == std::numeric_limits<double>::lowest() ? "--" : QString::number(s.max);
text_list << QString("<span style=\"color:%1;\">■ </span>%2<b>%3</b> (%4, %5)")
.arg(s.color.name(), name, value, min, max);
}
}
if (x < 0) {
x = tooltip_x;
}
QPoint pt(x, plot_area.top());
text_list.push_front(QString::number(secondsAtPoint({x, 0}), 'f', 3));
QString text = "<p style='white-space:pre'>" % text_list.join("<br />") % "</p>";
tip_label->showText(pt, text, this, visible_rect);
update();
}
void ChartView::hideTip() {
clearTrackPoints();
tooltip_x = -1;
tip_label->hide();
update();
}
void ChartView::resetChartCache() {
chart_pixmap = QPixmap();
update();
}
void ChartView::paintEvent(QPaintEvent *event) {
QPainter painter(this);
painter.setRenderHints(QPainter::Antialiasing);
// the static layer is invalidated on x-range change and data merge, so cache it in live mode too
const qreal dpr = devicePixelRatioF();
if (chart_pixmap.isNull() || chart_pixmap.size() != size() * dpr) {
chart_pixmap = QPixmap(size() * dpr);
chart_pixmap.setDevicePixelRatio(dpr);
QPainter p(&chart_pixmap);
p.setRenderHints(QPainter::Antialiasing);
p.setFont(font());
drawStaticLayer(&p);
}
painter.drawPixmap(QPoint(), chart_pixmap);
if (can_drop) {
painter.setPen(QPen(palette().color(QPalette::Highlight), 4));
painter.drawRect(rect());
}
drawForeground(&painter);
}
void ChartView::drawStaticLayer(QPainter *painter) {
painter->fillRect(rect(), palette().color(QPalette::Base));
painter->drawPixmap(move_icon_rect.topLeft(), utils::icon("grip-horizontal"));
drawAxes(painter);
drawLegend(painter);
drawSeries(painter);
}
void ChartView::drawAxes(QPainter *painter) {
const QColor text_color = palette().color(QPalette::Text);
QColor grid_color = text_color;
grid_color.setAlpha(50);
QFontMetrics fm(font());
painter->setFont(font());
// y grid lines and tick labels
for (int i = 0; i < y_tick_count; ++i) {
double value = y_min + i * (y_max - y_min) / (y_tick_count - 1);
qreal y = yPos(value);
painter->setPen(grid_color);
painter->drawLine(QPointF(plot_area.left(), y), QPointF(plot_area.right(), y));
painter->setPen(text_color);
QRectF label_rect(0, y - fm.height() / 2.0, plot_area.left() - 6, fm.height());
painter->drawText(label_rect, Qt::AlignRight | Qt::AlignVCenter, QString::number(value, 'f', y_precision));
}
// rotated y axis title (unit)
if (!y_unit.isEmpty()) {
painter->save();
painter->translate(plot_area.left() - y_label_width + fm.height() / 2.0, plot_area.center().y());
painter->rotate(-90);
painter->drawText(QRectF(-plot_area.height() / 2.0, -fm.height() / 2.0, plot_area.height(), fm.height()),
Qt::AlignCenter, y_unit);
painter->restore();
}
// x grid lines and tick labels
const int x_precision = xAxisPrecision();
for (int i = 0; i < X_TICK_COUNT; ++i) {
double sec = x_min + i * (x_max - x_min) / (X_TICK_COUNT - 1);
qreal x = xPos(sec);
painter->setPen(grid_color);
painter->drawLine(QPointF(x, plot_area.top()), QPointF(x, plot_area.bottom()));
painter->setPen(text_color);
QString label = QString::number(sec, 'f', x_precision);
QRectF label_rect(x - 100, plot_area.bottom() + AXIS_X_TOP_MARGIN, 200, fm.height());
painter->drawText(label_rect, Qt::AlignHCenter | Qt::AlignTop, label);
}
}
void ChartView::drawLegend(QPainter *painter) {
QColor title_color = palette().color(QPalette::WindowText);
// Draw message details in similar color, but slightly fade it to the background
QColor msg_color = title_color;
msg_color.setAlpha(180);
QFont bold_font = font();
bold_font.setBold(true);
const int marker_size = QFontMetrics(font()).height() - 4;
for (int i = 0; i < sigs.size() && i < legend_rects.size(); ++i) {
const auto &s = sigs[i];
const QRect &r = legend_rects[i];
painter->setPen(Qt::NoPen);
painter->setBrush(s.color);
QRectF marker_rect(r.left(), r.center().y() - marker_size / 2.0, marker_size, marker_size);
series_type == SeriesType::Scatter ? painter->drawEllipse(marker_rect) : painter->drawRect(marker_rect);
bold_font.setStrikeOut(!s.visible);
QFont normal_font = font();
normal_font.setStrikeOut(!s.visible);
qreal x = r.left() + marker_size + 5;
painter->setFont(bold_font);
painter->setPen(title_color);
QString name = QFontMetrics(bold_font).elidedText(QString::fromStdString(s.sig->name), Qt::ElideRight, r.right() - x);
painter->drawText(QRectF(x, r.top(), r.right() - x, r.height()), Qt::AlignLeft | Qt::AlignVCenter, name);
x += QFontMetrics(bold_font).horizontalAdvance(name);
painter->setFont(normal_font);
painter->setPen(msg_color);
QString msg = QFontMetrics(normal_font).elidedText(QString::fromStdString(" " + msgName(s.msg_id) + " " + s.msg_id.toString()),
Qt::ElideRight, r.right() - x);
painter->drawText(QRectF(x, r.top(), r.right() - x, r.height()), Qt::AlignLeft | Qt::AlignVCenter, msg);
}
}
void ChartView::drawSeries(QPainter *painter) {
painter->save();
painter->setClipRect(plot_area);
for (auto &s : sigs) {
if (!s.visible) continue;
// visible points in vals to compute point density
auto first = std::lower_bound(s.vals.cbegin(), s.vals.cend(), x_min, xLessThan);
auto last = std::lower_bound(first, s.vals.cend(), x_max, xLessThan);
int num_points = std::max<int>(last - first, 1);
double pixels_per_point = 0;
if (first != last) {
const QPointF &right_pt = last == s.vals.cend() ? s.vals.back() : *last;
pixels_per_point = (xPos(right_pt.x()) - xPos(first->x())) / num_points;
}
if (series_type == SeriesType::Scatter) {
qreal radius = std::clamp(pixels_per_point / 2.0, 2.0, 8.0) / 2.0;
painter->setPen(Qt::NoPen);
painter->setBrush(s.color);
for (auto it = first; it != last; ++it) {
painter->drawEllipse(QPointF(xPos(it->x()), yPos(it->y())), radius, radius);
}
} else {
const auto &points = series_type == SeriesType::StepLine ? s.step_vals : s.vals;
auto begin = std::lower_bound(points.cbegin(), points.cend(), x_min, xLessThan);
if (begin != points.cbegin()) --begin;
auto end = std::lower_bound(begin, points.cend(), x_max, xLessThan);
if (end != points.cend()) ++end;
if (begin == end) continue;
std::vector<QPointF> polyline;
polyline.reserve(end - begin);
for (auto it = begin; it != end; ++it) {
polyline.emplace_back(xPos(it->x()), yPos(it->y()));
}
painter->setPen(QPen(s.color, 2));
painter->setBrush(Qt::NoBrush);
painter->drawPolyline(polyline.data(), polyline.size());
// show points when zoomed in enough
if (num_points == 1 || pixels_per_point > 20) {
painter->setPen(Qt::NoPen);
painter->setBrush(s.color);
for (auto it = first; it != last; ++it) {
painter->drawEllipse(QPointF(xPos(it->x()), yPos(it->y())), 4, 4);
}
}
}
}
painter->restore();
}
void ChartView::drawForeground(QPainter *painter) {
drawTimeline(painter);
drawSignalValue(painter);
// draw track points
painter->setPen(Qt::NoPen);
qreal track_line_x = -1;
for (auto &s : sigs) {
if (!s.track_pt.isNull() && s.visible) {
painter->setBrush(s.color.darker(125));
QPointF pos(xPos(s.track_pt.x()), yPos(s.track_pt.y()));
painter->drawEllipse(pos, 5.5, 5.5);
track_line_x = std::max(track_line_x, pos.x());
}
}
if (track_line_x > 0) {
painter->setPen(QPen(Qt::darkGray, 1, Qt::DashLine));
painter->drawLine(QPointF{track_line_x, (qreal)plot_area.top()}, QPointF{track_line_x, (qreal)plot_area.bottom()});
}
drawRubberBandTimeRange(painter);
}
void ChartView::drawRubberBandTimeRange(QPainter *painter) {
if (rubber_rect.width() <= 1) return;
// selection rect
QColor highlight = palette().color(QPalette::Highlight);
QColor fill = highlight;
fill.setAlpha(50);
painter->fillRect(rubber_rect, fill);
painter->setPen(highlight);
painter->setBrush(Qt::NoBrush);
painter->drawRect(rubber_rect);
// time labels at the bottom corners
painter->setPen(Qt::white);
painter->setFont(font());
for (const auto &pt : {rubber_rect.bottomLeft(), rubber_rect.bottomRight()}) {
QString sec = QString::number(secondsAtPoint(pt), 'f', 2);
auto r = painter->fontMetrics().boundingRect(sec).adjusted(-6, -AXIS_X_TOP_MARGIN, 6, AXIS_X_TOP_MARGIN);
pt == rubber_rect.bottomLeft() ? r.moveTopRight(pt + QPoint{0, 2}) : r.moveTopLeft(pt + QPoint{0, 2});
painter->fillRect(r, Qt::gray);
painter->drawText(r, Qt::AlignCenter, sec);
}
}
void ChartView::drawTimeline(QPainter *painter) {
// draw vertical time line
qreal x = std::clamp(xPos(cur_sec), (qreal)plot_area.left(), (qreal)plot_area.right());
painter->setPen(QPen(palette().color(QPalette::Text), 1));
painter->drawLine(QPointF{x, plot_area.top() - 1.0}, QPointF{x, plot_area.bottom() + 1.0});
// draw current time under the axis-x
QString time_str = QString::number(cur_sec, 'f', 2);
QSize time_str_size = QFontMetrics(font()).size(Qt::TextSingleLine, time_str) + QSize(8, 2);
QRectF time_str_rect(QPointF(x - time_str_size.width() / 2.0, plot_area.bottom() + AXIS_X_TOP_MARGIN), time_str_size);
QPainterPath path;
path.addRoundedRect(time_str_rect, 3, 3);
painter->fillPath(path, utils::isDarkTheme() ? Qt::darkGray : Qt::gray);
painter->setPen(palette().color(QPalette::BrightText));
painter->setFont(font());
painter->drawText(time_str_rect, Qt::AlignCenter, time_str);
}
void ChartView::drawSignalValue(QPainter *painter) {
painter->setFont(signal_value_font);
painter->setPen(palette().color(QPalette::Text));
for (int i = 0; i < sigs.size() && i < legend_rects.size(); ++i) {
const auto &s = sigs[i];
auto it = std::lower_bound(s.vals.crbegin(), s.vals.crend(), cur_sec,
[](auto &p, double x) { return p.x() > x + EPSILON; });
QString value = (it != s.vals.crend() && it->x() >= x_min) ? QString::fromStdString(s.sig->formatValue(it->y())) : "--";
QRectF value_rect(legend_rects[i].bottomLeft() - QPoint(0, 1), legend_rects[i].size());
QString elided_val = painter->fontMetrics().elidedText(value, Qt::ElideRight, value_rect.width());
painter->drawText(value_rect, Qt::AlignHCenter | Qt::AlignTop, elided_val);
}
}
QColor ChartView::uniqueColor(QColor color, const cabana::Signal *exclude) const {
for (auto &s : sigs) {
if (s.sig != exclude && std::abs(color.hueF() - s.color.hueF()) < 0.1) {
// use different color to distinguish it from others.
auto last_color = sigs.back().color;
static thread_local std::mt19937 rng{std::random_device{}()};
std::uniform_int_distribution<int> sat(35, 99);
std::uniform_int_distribution<int> val(85, 99);
color.setHsvF(std::fmod(last_color.hueF() + 60 / 360.0, 1.0),
sat(rng) / 100.0,
val(rng) / 100.0);
break;
}
}
return color;
}
void ChartView::setSeriesType(SeriesType type) {
if (type != series_type) {
series_type = type;
menu->actions()[(int)type]->setChecked(true);
updateTitle();
}
}

View File

@@ -0,0 +1,130 @@
#pragma once
#include <functional>
#include <tuple>
#include <utility>
#include <vector>
#include <QMenu>
#include "tools/cabana/chart/tiplabel.h"
#include "tools/cabana/dbc/dbcmanager.h"
#include "tools/cabana/streams/abstractstream.h"
enum class SeriesType {
Line = 0,
StepLine,
Scatter
};
class ChartsWidget;
class ChartView : public QWidget {
Q_OBJECT
public:
ChartView(const std::pair<double, double> &x_range, ChartsWidget *parent = nullptr);
void addSignal(const MessageId &msg_id, const cabana::Signal *sig);
bool hasSignal(const MessageId &msg_id, const cabana::Signal *sig) const;
void updateSeries(const cabana::Signal *sig = nullptr, const MessageEventsMap *msg_new_events = nullptr);
void updatePlot(double cur, double min, double max);
void setSeriesType(SeriesType type);
void updatePlotArea(int left, bool force = false);
void showTip(double sec);
void hideTip();
double secondsAtPoint(const QPointF &pt) const {
return x_min + (pt.x() - plot_area.left()) * (x_max - x_min) / std::max(plot_area.width(), 1);
}
struct SigItem {
MessageId msg_id;
const cabana::Signal *sig = nullptr;
QColor color;
bool visible = true;
std::vector<QPointF> vals;
std::vector<QPointF> step_vals;
QPointF track_pt{};
SegmentTree segment_tree;
double min = 0;
double max = 0;
};
signals:
void axisYLabelWidthChanged(int w);
private slots:
void signalUpdated(const cabana::Signal *sig);
void manageSignals();
void msgUpdated(MessageId id);
void msgRemoved(MessageId id) { removeIf([=](auto &s) { return s.msg_id.address == id.address && !dbc()->msg(id); }); }
void signalRemoved(const cabana::Signal *sig) { removeIf([=](auto &s) { return s.sig == sig; }); }
private:
void appendCanEvents(const cabana::Signal *sig, const std::vector<const CanEvent *> &events,
std::vector<QPointF> &vals, std::vector<QPointF> &step_vals);
void createToolButtons();
void contextMenuEvent(QContextMenuEvent *event) override;
void mousePressEvent(QMouseEvent *event) override;
void mouseMoveEvent(QMouseEvent *event) override;
void mouseReleaseEvent(QMouseEvent *event) override;
void resizeEvent(QResizeEvent *event) override;
QSize sizeHint() const override;
void updateAxisY();
void updateTitle();
void resetChartCache();
void paintEvent(QPaintEvent *event) override;
void drawStaticLayer(QPainter *painter);
void drawAxes(QPainter *painter);
void drawLegend(QPainter *painter);
void drawSeries(QPainter *painter);
void drawForeground(QPainter *painter);
void drawSignalValue(QPainter *painter);
void drawTimeline(QPainter *painter);
void drawRubberBandTimeRange(QPainter *painter);
int xAxisPrecision() const;
std::tuple<double, double, int> getNiceAxisNumbers(qreal min, qreal max, int tick_count);
qreal niceNumber(qreal x, bool ceiling);
QColor uniqueColor(QColor color, const cabana::Signal *exclude = nullptr) const;
void removeIf(std::function<bool(const SigItem &)> predicate);
void takeSignalsFrom(ChartView *source);
void setDropHighlight(bool highlight) { if (std::exchange(can_drop, highlight) != highlight) update(); }
inline void clearTrackPoints() { for (auto &s : sigs) s.track_pt = {}; }
inline qreal xPos(double sec) const { return plot_area.left() + (sec - x_min) / (x_max - x_min) * plot_area.width(); }
inline qreal yPos(double val) const { return plot_area.bottom() - (val - y_min) / (y_max - y_min) * plot_area.height(); }
// layout
QRect plot_area;
QRect move_icon_rect;
std::vector<QRect> legend_rects;
// axes
double x_min;
double x_max;
double y_min = 0;
double y_max = 1;
int y_tick_count = 3;
int y_precision = 0;
QString y_unit;
int y_label_width = 0;
int align_to = 0;
// interaction
enum class MouseMode { None, Rubber, Scrub };
MouseMode mouse_mode = MouseMode::None;
QPoint press_pos;
QRect rubber_rect;
bool resume_after_scrub = false;
QMenu *menu;
QAction *split_chart_act;
QAction *close_act;
ToolButton *manage_btn;
ToolButton *close_btn;
TipLabel *tip_label;
std::vector<SigItem> sigs;
double cur_sec = 0;
SeriesType series_type = SeriesType::Line;
QPixmap chart_pixmap;
bool can_drop = false;
double tooltip_x = -1;
QFont signal_value_font;
ChartsWidget *charts_widget;
friend class ChartsWidget;
};

View File

@@ -0,0 +1,661 @@
#include "tools/cabana/chart/chartswidget.h"
#include "tools/cabana/dbc/dbcqt.h"
#include <algorithm>
#include <future>
#include <QApplication>
#include <QMenu>
#include <QMouseEvent>
#include <QScrollBar>
#include <QToolBar>
#include "tools/cabana/chart/chart.h"
const int MAX_COLUMN_COUNT = 4;
const int CHART_SPACING = 4;
ChartsWidget::ChartsWidget(QWidget *parent) : QFrame(parent) {
align_timer = new QTimer(this);
auto_scroll_timer = new QTimer(this);
setFrameStyle(QFrame::StyledPanel | QFrame::Plain);
QVBoxLayout *main_layout = new QVBoxLayout(this);
main_layout->setContentsMargins(0, 0, 0, 0);
main_layout->setSpacing(0);
// toolbar
toolbar = new QToolBar(tr("Charts"), this);
int icon_size = style()->pixelMetric(QStyle::PM_SmallIconSize);
toolbar->setIconSize({icon_size, icon_size});
auto new_plot_btn = new ToolButton("file-plus", tr("New Chart"));
auto new_tab_btn = new ToolButton("window-stack", tr("New Tab"));
toolbar->addWidget(new_plot_btn);
toolbar->addWidget(new_tab_btn);
toolbar->addWidget(title_label = new QLabel());
title_label->setContentsMargins(0, 0, style()->pixelMetric(QStyle::PM_LayoutHorizontalSpacing), 0);
auto chart_type_action = toolbar->addAction("");
QMenu *chart_type_menu = new QMenu(this);
auto types = std::array{tr("Line"), tr("Step"), tr("Scatter")};
for (int i = 0; i < types.size(); ++i) {
QString type_text = types[i];
chart_type_menu->addAction(type_text, this, [=]() {
settings.chart_series_type = i;
chart_type_action->setText("Type: " + type_text);
settingChanged();
});
}
chart_type_action->setText("Type: " + types[settings.chart_series_type]);
chart_type_action->setMenu(chart_type_menu);
qobject_cast<QToolButton *>(toolbar->widgetForAction(chart_type_action))->setPopupMode(QToolButton::InstantPopup);
QMenu *menu = new QMenu(this);
for (int i = 0; i < MAX_COLUMN_COUNT; ++i) {
menu->addAction(tr("%1").arg(i + 1), [=]() { setColumnCount(i + 1); });
}
columns_action = toolbar->addAction("");
columns_action->setMenu(menu);
qobject_cast<QToolButton*>(toolbar->widgetForAction(columns_action))->setPopupMode(QToolButton::InstantPopup);
QWidget *spacer = new QWidget(this);
spacer->setSizePolicy(QSizePolicy::MinimumExpanding, QSizePolicy::Preferred);
toolbar->addWidget(spacer);
range_lb_action = toolbar->addWidget(range_lb = new QLabel(this));
range_slider = new LogSlider(1000, Qt::Horizontal, this);
range_slider->setFixedWidth(150 * qApp->devicePixelRatio());
range_slider->setToolTip(tr("Set the chart range"));
range_slider->setRange(1, settings.max_cached_minutes * 60);
range_slider->setSingleStep(1);
range_slider->setPageStep(60); // 1 min
range_slider_action = toolbar->addWidget(range_slider);
// zoom controls
undo_zoom_action = toolbar->addAction(utils::icon("arrow-counterclockwise"), tr("Undo Zoom"), [this]() { zoom_undo_stack.undo(); });
redo_zoom_action = toolbar->addAction(utils::icon("arrow-clockwise"), tr("Redo Zoom"), [this]() { zoom_undo_stack.redo(); });
undo_zoom_action->setEnabled(false);
redo_zoom_action->setEnabled(false);
zoom_undo_stack.setCallbacks({.index_changed = [this]() {
undo_zoom_action->setEnabled(zoom_undo_stack.canUndo());
redo_zoom_action->setEnabled(zoom_undo_stack.canRedo());
}});
reset_zoom_action = toolbar->addWidget(reset_zoom_btn = new ToolButton("zoom-out", tr("Reset Zoom")));
reset_zoom_btn->setToolButtonStyle(Qt::ToolButtonTextBesideIcon);
toolbar->addWidget(remove_all_btn = new ToolButton("x-square", tr("Remove all charts")));
toolbar->addWidget(dock_btn = new ToolButton(""));
main_layout->addWidget(toolbar);
// tabbar
tabbar = new TabBar(this);
tabbar->setAutoHide(true);
tabbar->setExpanding(false);
tabbar->setDrawBase(true);
tabbar->setUsesScrollButtons(true);
main_layout->addWidget(tabbar);
// charts
charts_container = new ChartsContainer(this);
charts_container->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed);
charts_scroll = new QScrollArea(this);
charts_scroll->viewport()->setBackgroundRole(QPalette::Base);
charts_scroll->setFrameStyle(QFrame::NoFrame);
charts_scroll->setWidgetResizable(true);
charts_scroll->setWidget(charts_container);
charts_scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
main_layout->addWidget(charts_scroll);
// chart drag preview
drag_preview = new QLabel(this);
drag_preview->setAttribute(Qt::WA_TransparentForMouseEvents);
drag_preview->hide();
// init settings
current_theme = settings.theme;
column_count = std::clamp(settings.chart_column_count, 1, MAX_COLUMN_COUNT);
max_chart_range = std::clamp(settings.chart_range, 1, settings.max_cached_minutes * 60);
display_range = std::make_pair(can->minSeconds(), can->minSeconds() + max_chart_range);
range_slider->setValue(max_chart_range);
updateToolBar();
align_timer->setSingleShot(true);
QObject::connect(align_timer, &QTimer::timeout, this, &ChartsWidget::alignCharts);
QObject::connect(auto_scroll_timer, &QTimer::timeout, this, &ChartsWidget::doAutoScroll);
QObject::connect(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, this, &ChartsWidget::removeAll);
QObject::connect(can, &AbstractStream::eventsMerged, this, &ChartsWidget::eventsMerged);
QObject::connect(can, &AbstractStream::msgsReceived, this, &ChartsWidget::updateState);
QObject::connect(can, &AbstractStream::seeking, this, &ChartsWidget::updateState);
QObject::connect(can, &AbstractStream::timeRangeChanged, this, &ChartsWidget::timeRangeChanged);
QObject::connect(range_slider, &QSlider::valueChanged, this, &ChartsWidget::setMaxChartRange);
QObject::connect(new_plot_btn, &QToolButton::clicked, this, &ChartsWidget::newChart);
QObject::connect(remove_all_btn, &QToolButton::clicked, this, &ChartsWidget::removeAll);
QObject::connect(reset_zoom_btn, &QToolButton::clicked, this, &ChartsWidget::zoomReset);
QObject::connect(&settings, &Settings::changed, this, &ChartsWidget::settingChanged);
QObject::connect(new_tab_btn, &QToolButton::clicked, this, &ChartsWidget::newTab);
QObject::connect(this, &ChartsWidget::seriesChanged, this, &ChartsWidget::updateTabBar);
QObject::connect(tabbar, &QTabBar::tabCloseRequested, this, &ChartsWidget::removeTab);
QObject::connect(tabbar, &QTabBar::currentChanged, [this](int index) {
if (index != -1) updateLayout(true);
});
QObject::connect(dock_btn, &QToolButton::clicked, this, &ChartsWidget::toggleChartsDocking);
setIsDocked(true);
newTab();
qApp->installEventFilter(this);
setWhatsThis(tr(R"(
<b>Chart View</b><br />
<b>Click</b>: Click to seek to a corresponding time.<br />
<b>Drag</b>: Zoom into the chart.<br />
<b>Shift + Drag</b>: Scrub through the chart to view values.<br />
<b>Right Mouse</b>: Open the context menu.<br />
)"));
}
void ChartsWidget::newTab() {
static int tab_unique_id = 0;
int idx = tabbar->addTab("");
tabbar->setTabData(idx, tab_unique_id++);
tabbar->setCurrentIndex(idx);
updateTabBar();
}
void ChartsWidget::removeTab(int index) {
int id = tabbar->tabData(index).toInt();
for (auto &c : tab_charts[id]) {
removeChart(c);
}
tab_charts.erase(id);
tabbar->removeTab(index);
updateTabBar();
}
void ChartsWidget::updateTabBar() {
for (int i = 0; i < tabbar->count(); ++i) {
const auto &charts_in_tab = tab_charts[tabbar->tabData(i).toInt()];
tabbar->setTabText(i, QString("Tab %1 (%2)").arg(i + 1).arg((int)charts_in_tab.size()));
}
}
void ChartsWidget::eventsMerged(const MessageEventsMap &new_events) {
std::vector<std::future<void>> futures;
for (auto c : charts) {
futures.push_back(std::async(std::launch::async, &ChartView::updateSeries, c, nullptr, &new_events));
}
for (auto &f : futures) f.get();
}
void ChartsWidget::timeRangeChanged(const std::optional<std::pair<double, double>> &time_range) {
updateToolBar();
updateState();
}
void ChartsWidget::zoomReset() {
can->setTimeRange(std::nullopt);
zoom_undo_stack.clear();
}
QRect ChartsWidget::chartVisibleRect(ChartView *chart) {
const QRect visible_rect(-charts_container->pos(), charts_scroll->viewport()->size());
return chart->rect().intersected(QRect(chart->mapFrom(charts_container, visible_rect.topLeft()), visible_rect.size()));
}
void ChartsWidget::showValueTip(double sec) {
emit showTip(sec);
if (sec < 0 && !value_tip_visible_) return;
value_tip_visible_ = sec >= 0;
for (auto c : currentCharts()) {
value_tip_visible_ ? c->showTip(sec) : c->hideTip();
}
}
void ChartsWidget::updateState() {
if (charts.empty()) return;
const auto &time_range = can->timeRange();
const double cur_sec = can->currentSec();
if (!time_range.has_value()) {
double pos = (cur_sec - display_range.first) / std::max<float>(1.0, max_chart_range);
if (pos < 0 || pos > 0.8) {
display_range.first = std::max(can->minSeconds(), cur_sec - max_chart_range * 0.1);
}
double max_sec = std::min(display_range.first + max_chart_range, can->maxSeconds());
display_range.first = std::max(can->minSeconds(), max_sec - max_chart_range);
display_range.second = display_range.first + max_chart_range;
}
const auto &range = time_range ? *time_range : display_range;
for (auto c : charts) {
c->updatePlot(cur_sec, range.first, range.second);
}
}
void ChartsWidget::setMaxChartRange(int value) {
max_chart_range = settings.chart_range = range_slider->value();
updateToolBar();
updateState();
}
void ChartsWidget::setIsDocked(bool docked) {
is_docked = docked;
dock_btn->setIcon(is_docked ? "arrow-up-right-square" : "arrow-down-left-square");
dock_btn->setToolTip(is_docked ? tr("Float the charts window") : tr("Dock the charts window"));
}
void ChartsWidget::updateToolBar() {
title_label->setText(tr("Charts: %1").arg(charts.size()));
columns_action->setText(tr("Columns: %1").arg(column_count));
range_lb->setText(utils::formatSeconds(max_chart_range));
bool is_zoomed = can->timeRange().has_value();
range_lb_action->setVisible(!is_zoomed);
range_slider_action->setVisible(!is_zoomed);
undo_zoom_action->setVisible(is_zoomed);
redo_zoom_action->setVisible(is_zoomed);
reset_zoom_action->setVisible(is_zoomed);
reset_zoom_btn->setText(is_zoomed ? tr("%1-%2").arg(can->timeRange()->first, 0, 'f', 2).arg(can->timeRange()->second, 0, 'f', 2) : "");
remove_all_btn->setEnabled(!charts.empty());
}
void ChartsWidget::settingChanged() {
if (std::exchange(current_theme, settings.theme) != current_theme) {
undo_zoom_action->setIcon(utils::icon("arrow-counterclockwise"));
redo_zoom_action->setIcon(utils::icon("arrow-clockwise"));
}
if (range_slider->maximum() != settings.max_cached_minutes * 60) {
range_slider->setRange(1, settings.max_cached_minutes * 60);
}
for (auto c : charts) {
c->setFixedHeight(settings.chart_height);
c->setSeriesType((SeriesType)settings.chart_series_type);
c->resetChartCache();
}
}
ChartView *ChartsWidget::findChart(const MessageId &id, const cabana::Signal *sig) {
for (auto c : charts)
if (c->hasSignal(id, sig)) return c;
return nullptr;
}
ChartView *ChartsWidget::createChart(int pos) {
auto chart = new ChartView(can->timeRange().value_or(display_range), this);
chart->setFixedHeight(settings.chart_height);
chart->setMinimumWidth(CHART_MIN_WIDTH);
chart->setSizePolicy(QSizePolicy::MinimumExpanding, QSizePolicy::Fixed);
QObject::connect(chart, &ChartView::axisYLabelWidthChanged, align_timer, qOverload<>(&QTimer::start));
pos = std::clamp(pos, 0, (int)charts.size());
charts.insert(charts.begin() + pos, chart);
currentCharts().insert(currentCharts().begin() + pos, chart);
updateLayout(true);
updateToolBar();
return chart;
}
void ChartsWidget::showChart(const MessageId &id, const cabana::Signal *sig, bool show, bool merge) {
ChartView *chart = findChart(id, sig);
if (show && !chart) {
chart = merge && currentCharts().size() > 0 ? currentCharts().front() : createChart();
chart->addSignal(id, sig);
updateState();
} else if (!show && chart) {
chart->removeIf([&](auto &s) { return s.msg_id == id && s.sig == sig; });
}
}
void ChartsWidget::splitChart(ChartView *src_chart) {
if (src_chart->sigs.size() > 1) {
int pos = std::find(charts.begin(), charts.end(), src_chart) - charts.begin() + 1;
for (auto it = src_chart->sigs.begin() + 1; it != src_chart->sigs.end(); /**/) {
auto c = createChart(pos);
// Restore to the original color
it->color = toQColor(it->sig->color);
c->sigs.emplace_back(std::move(*it));
c->updateAxisY();
c->updateTitle();
it = src_chart->sigs.erase(it);
}
src_chart->updateAxisY();
src_chart->updateTitle();
updateState();
QTimer::singleShot(0, src_chart, &ChartView::resetChartCache);
}
}
QStringList ChartsWidget::serializeChartIds() const {
QStringList chart_ids;
for (auto c : charts) {
QStringList ids;
for (const auto& s : c->sigs)
ids += QString("%1|%2").arg(QString::fromStdString(s.msg_id.toString()), QString::fromStdString(s.sig->name));
chart_ids += ids.join(',');
}
std::reverse(chart_ids.begin(), chart_ids.end());
return chart_ids;
}
void ChartsWidget::restoreChartsFromIds(const QStringList& chart_ids) {
for (const auto& chart_id : chart_ids) {
int index = 0;
for (const auto& part : chart_id.split(',')) {
const auto sig_parts = part.split('|');
if (sig_parts.size() != 2) continue;
MessageId msg_id = MessageId::fromString(sig_parts[0].toStdString());
if (auto* msg = dbc()->msg(msg_id))
if (auto* sig = msg->sig(sig_parts[1].toStdString()))
showChart(msg_id, sig, true, index++ > 0);
}
}
}
void ChartsWidget::setColumnCount(int n) {
n = std::clamp(n, 1, MAX_COLUMN_COUNT);
if (column_count != n) {
column_count = settings.chart_column_count = n;
updateToolBar();
updateLayout();
}
}
void ChartsWidget::updateLayout(bool force) {
auto charts_layout = charts_container->charts_layout;
int n = MAX_COLUMN_COUNT;
for (; n > 1; --n) {
if ((n * CHART_MIN_WIDTH + (n - 1) * charts_layout->horizontalSpacing()) < charts_layout->geometry().width()) break;
}
bool show_column_cb = n > 1;
columns_action->setVisible(show_column_cb);
n = std::min(column_count, n);
auto &current_charts = currentCharts();
if ((current_charts.size() != charts_layout->count() || n != current_column_count) || force) {
current_column_count = n;
charts_container->setUpdatesEnabled(false);
for (auto c : charts) {
c->setVisible(false);
}
for (int i = 0; i < current_charts.size(); ++i) {
charts_layout->addWidget(current_charts[i], i / n, i % n);
if (current_charts[i]->sigs.empty()) {
// the chart will be resized after add signal. delay setVisible to reduce flicker.
QTimer::singleShot(0, current_charts[i], [c = current_charts[i]]() { c->setVisible(true); });
} else {
current_charts[i]->setVisible(true);
}
}
charts_container->setUpdatesEnabled(true);
}
}
void ChartsWidget::startChartDrag(ChartView *chart, const QPoint &global_pos) {
stopAutoScroll();
drag = {.source = chart, .press_pos = global_pos};
QPixmap px = chart->grab().scaledToWidth(CHART_MIN_WIDTH * chart->devicePixelRatio(), Qt::SmoothTransformation);
drag_preview->setPixmap(px);
drag_preview->resize(px.size() / px.devicePixelRatio());
}
void ChartsWidget::dragChartMove(const QPoint &global_pos) {
if (!drag.active) {
if ((global_pos - drag.press_pos).manhattanLength() < QApplication::startDragDistance()) return;
drag.active = true;
drag_preview->show();
drag_preview->raise();
}
drag_preview->move(mapFromGlobal(global_pos) + QPoint(5, 5));
// hovering a tab switches to it so the chart can be dropped into another tab
int tab = tabbar->tabAt(tabbar->mapFromGlobal(global_pos));
if (tab >= 0 && tab != tabbar->currentIndex()) {
tabbar->setCurrentIndex(tab);
}
const QPoint container_pos = charts_container->mapFromGlobal(global_pos);
ChartView *target = nullptr;
for (auto c : currentCharts()) {
if (c != drag.source && c->isVisible() && c->geometry().contains(container_pos)) {
target = c;
break;
}
}
if (std::exchange(drop_target, target) != target) {
for (auto c : charts) c->setDropHighlight(c == target);
}
bool in_viewport = charts_scroll->viewport()->rect().contains(charts_scroll->viewport()->mapFromGlobal(global_pos));
bool on_background = !target && in_viewport && !charts_container->childAt(container_pos);
charts_container->drawDropIndicator(on_background ? container_pos : QPoint());
if (in_viewport) {
startAutoScroll(global_pos);
}
}
void ChartsWidget::cancelChartDrag() {
drag = {};
stopAutoScroll();
drag_preview->hide();
charts_container->drawDropIndicator({});
if (auto target = std::exchange(drop_target, nullptr)) target->setDropHighlight(false);
}
void ChartsWidget::dragChartRelease(const QPoint &global_pos) {
ChartView *source = drag.source;
bool active = drag.active;
ChartView *target = drop_target;
cancelChartDrag();
if (!active) return;
const QPoint container_pos = charts_container->mapFromGlobal(global_pos);
bool in_viewport = charts_scroll->viewport()->rect().contains(charts_scroll->viewport()->mapFromGlobal(global_pos));
if (target) {
// merge source into target
target->takeSignalsFrom(source);
} else if (in_viewport && !charts_container->childAt(container_pos)) {
// reorder within the current tab
auto w = charts_container->getDropAfter(container_pos);
if (w != source) {
for (auto &[_, list] : tab_charts) {
list.erase(std::remove(list.begin(), list.end(), source), list.end());
}
auto &cur = currentCharts();
int to = w ? std::find(cur.begin(), cur.end(), w) - cur.begin() + 1 : 0;
cur.insert(cur.begin() + to, source);
updateLayout(true);
updateTabBar();
}
}
}
void ChartsWidget::startAutoScroll(const QPoint &global_pos) {
auto_scroll_pos = global_pos;
auto_scroll_timer->start(50);
}
void ChartsWidget::stopAutoScroll() {
auto_scroll_timer->stop();
auto_scroll_count = 0;
}
void ChartsWidget::doAutoScroll() {
QScrollBar *scroll = charts_scroll->verticalScrollBar();
if (auto_scroll_count < scroll->pageStep()) {
++auto_scroll_count;
}
int value = scroll->value();
QPoint pos = charts_scroll->viewport()->mapFromGlobal(auto_scroll_pos);
QRect area = charts_scroll->viewport()->rect();
if (pos.y() - area.top() < settings.chart_height / 2) {
scroll->setValue(value - auto_scroll_count);
} else if (area.bottom() - pos.y() < settings.chart_height / 2) {
scroll->setValue(value + auto_scroll_count);
}
if (value == scroll->value()) {
stopAutoScroll();
} else if (chartDragActive()) {
// refresh the drop indicator/target at the new scroll position
dragChartMove(auto_scroll_pos);
}
}
QSize ChartsWidget::minimumSizeHint() const {
return QSize(CHART_MIN_WIDTH * 1.5, QWidget::minimumSizeHint().height());
}
void ChartsWidget::newChart() {
SignalSelector dlg(tr("New Chart"), this);
if (dlg.exec() == QDialog::Accepted) {
auto items = dlg.seletedItems();
if (!items.empty()) {
auto c = createChart();
for (auto it : items) {
c->addSignal(it->msg_id, it->sig);
}
updateState();
}
}
}
void ChartsWidget::removeChart(ChartView *chart) {
if (drag.source == chart) cancelChartDrag();
if (drop_target == chart) drop_target = nullptr;
charts.erase(std::remove(charts.begin(), charts.end(), chart), charts.end());
chart->deleteLater();
for (auto &[_, list] : tab_charts) {
list.erase(std::remove(list.begin(), list.end(), chart), list.end());
}
updateToolBar();
updateLayout(true);
alignCharts();
emit seriesChanged();
}
void ChartsWidget::removeAll() {
while (tabbar->count() > 1) {
tabbar->removeTab(1);
}
tab_charts.clear();
if (!charts.empty()) {
for (auto c : charts) {
delete c;
}
charts.clear();
emit seriesChanged();
}
zoomReset();
}
void ChartsWidget::alignCharts() {
int plot_left = 0;
for (auto c : charts) {
plot_left = std::max(plot_left, c->y_label_width);
}
plot_left = std::max((plot_left / 10) * 10 + 10, 50);
for (auto c : charts) {
c->updatePlotArea(plot_left);
}
}
bool ChartsWidget::eventFilter(QObject *o, QEvent *e) {
// route all mouse events to the chart drag, even when the source chart is hidden by a tab switch
if (chartDragActive()) {
if (e->type() == QEvent::MouseMove) {
dragChartMove(static_cast<QMouseEvent *>(e)->globalPos());
return true;
} else if (e->type() == QEvent::MouseButtonRelease && static_cast<QMouseEvent *>(e)->button() == Qt::LeftButton) {
dragChartRelease(static_cast<QMouseEvent *>(e)->globalPos());
return false; // let the release through so Qt clears the implicit mouse grab
} else if (e->type() == QEvent::MouseButtonPress || e->type() == QEvent::MouseButtonRelease) {
return true; // swallow other buttons during the drag
}
}
if (!value_tip_visible_) return false;
if (e->type() == QEvent::MouseMove) {
bool on_tip = qobject_cast<TipLabel *>(o) != nullptr;
auto global_pos = static_cast<QMouseEvent *>(e)->globalPos();
for (const auto &c : charts) {
auto local_pos = c->mapFromGlobal(global_pos);
if (c->plot_area.contains(local_pos)) {
if (on_tip) {
showValueTip(c->secondsAtPoint(local_pos));
}
return false;
}
}
showValueTip(-1);
} else if (e->type() == QEvent::Wheel) {
if (auto tip = qobject_cast<TipLabel *>(o)) {
// Forward the event to the parent widget
QCoreApplication::sendEvent(tip->parentWidget(), e);
}
}
return false;
}
bool ChartsWidget::event(QEvent *event) {
bool back_button = false;
switch (event->type()) {
case QEvent::Resize:
updateLayout();
break;
case QEvent::MouseButtonPress:
back_button = static_cast<QMouseEvent *>(event)->button() == Qt::BackButton;
break;
case QEvent::NativeGesture:
back_button = (static_cast<QNativeGestureEvent *>(event)->value() == 180);
break;
case QEvent::WindowDeactivate:
case QEvent::FocusOut:
if (chartDragActive()) cancelChartDrag();
showValueTip(-1);
default:
break;
}
if (back_button) {
zoom_undo_stack.undo();
return true; // Return true since the event has been handled
}
return QFrame::event(event);
}
// ChartsContainer
ChartsContainer::ChartsContainer(ChartsWidget *parent) : charts_widget(parent), QWidget(parent) {
setBackgroundRole(QPalette::Window);
QVBoxLayout *charts_main_layout = new QVBoxLayout(this);
charts_main_layout->setContentsMargins(0, CHART_SPACING, 0, CHART_SPACING);
charts_layout = new QGridLayout();
charts_layout->setSpacing(CHART_SPACING);
charts_main_layout->addLayout(charts_layout);
charts_main_layout->addStretch(0);
}
void ChartsContainer::paintEvent(QPaintEvent *ev) {
if (!drop_indictor_pos.isNull() && !childAt(drop_indictor_pos)) {
QRect r = geometry();
r.setHeight(CHART_SPACING);
if (auto insert_after = getDropAfter(drop_indictor_pos)) {
r.moveTop(insert_after->geometry().bottom());
}
QPainter p(this);
p.fillRect(r, palette().highlight());
}
}
ChartView *ChartsContainer::getDropAfter(const QPoint &pos) const {
auto it = std::find_if(charts_widget->currentCharts().crbegin(), charts_widget->currentCharts().crend(), [&pos](auto c) {
auto area = c->geometry();
return pos.x() >= area.left() && pos.x() <= area.right() && pos.y() >= area.bottom();
});
return it == charts_widget->currentCharts().crend() ? nullptr : *it;
}

View File

@@ -0,0 +1,138 @@
#pragma once
#include <unordered_map>
#include <utility>
#include <QGridLayout>
#include <QLabel>
#include <QScrollArea>
#include <QTimer>
#include <QToolBar>
#include "tools/cabana/chart/signalselector.h"
#include "tools/cabana/commands.h"
#include "tools/cabana/dbc/dbcmanager.h"
#include "tools/cabana/streams/abstractstream.h"
const int CHART_MIN_WIDTH = 300;
class ChartView;
class ChartsWidget;
class ChartsContainer : public QWidget {
public:
ChartsContainer(ChartsWidget *parent);
void drawDropIndicator(const QPoint &pt) { drop_indictor_pos = pt; update(); }
void paintEvent(QPaintEvent *ev) override;
ChartView *getDropAfter(const QPoint &pos) const;
QGridLayout *charts_layout;
ChartsWidget *charts_widget;
QPoint drop_indictor_pos;
};
class ChartsWidget : public QFrame {
Q_OBJECT
public:
ChartsWidget(QWidget *parent = nullptr);
void showChart(const MessageId &id, const cabana::Signal *sig, bool show, bool merge);
inline bool hasSignal(const MessageId &id, const cabana::Signal *sig) { return findChart(id, sig) != nullptr; }
QStringList serializeChartIds() const;
void restoreChartsFromIds(const QStringList &chart_ids);
public slots:
void setColumnCount(int n);
void removeAll();
void timeRangeChanged(const std::optional<std::pair<double, double>> &time_range);
void setIsDocked(bool dock);
signals:
void toggleChartsDocking();
void seriesChanged();
void showTip(double seconds);
private:
QSize minimumSizeHint() const override;
bool event(QEvent *event) override;
void alignCharts();
void newChart();
ChartView *createChart(int pos = 0);
void removeChart(ChartView *chart);
void splitChart(ChartView *chart);
QRect chartVisibleRect(ChartView *chart);
void eventsMerged(const MessageEventsMap &new_events);
void updateState();
void zoomReset();
void startChartDrag(ChartView *chart, const QPoint &global_pos);
void dragChartMove(const QPoint &global_pos);
void dragChartRelease(const QPoint &global_pos);
void cancelChartDrag();
bool chartDragActive() const { return drag.source != nullptr; }
void startAutoScroll(const QPoint &global_pos);
void stopAutoScroll();
void doAutoScroll();
void updateToolBar();
void updateTabBar();
void setMaxChartRange(int value);
void updateLayout(bool force = false);
void settingChanged();
void showValueTip(double sec);
bool eventFilter(QObject *obj, QEvent *event) override;
void newTab();
void removeTab(int index);
inline std::vector<ChartView *> &currentCharts() { return tab_charts[tabbar->tabData(tabbar->currentIndex()).toInt()]; }
ChartView *findChart(const MessageId &id, const cabana::Signal *sig);
QLabel *title_label;
QLabel *range_lb;
LogSlider *range_slider;
QAction *range_lb_action;
QAction *range_slider_action;
bool is_docked = true;
ToolButton *dock_btn;
QToolBar *toolbar;
QAction *undo_zoom_action;
QAction *redo_zoom_action;
QAction *reset_zoom_action;
ToolButton *reset_zoom_btn;
UndoStack zoom_undo_stack;
ToolButton *remove_all_btn;
std::vector<ChartView *> charts;
std::unordered_map<int, std::vector<ChartView *>> tab_charts;
TabBar *tabbar;
ChartsContainer *charts_container;
QScrollArea *charts_scroll;
uint32_t max_chart_range = 0;
std::pair<double, double> display_range;
QAction *columns_action;
int column_count = 1;
int current_column_count = 0;
struct ChartDrag {
ChartView *source = nullptr;
QPoint press_pos; // global
bool active = false;
} drag;
QLabel *drag_preview;
ChartView *drop_target = nullptr;
int auto_scroll_count = 0;
QPoint auto_scroll_pos;
QTimer *auto_scroll_timer;
QTimer *align_timer;
int current_theme = 0;
bool value_tip_visible_ = false;
friend class ChartView;
friend class ChartsContainer;
};
class ZoomCommand : public UndoCommand {
public:
ZoomCommand(std::pair<double, double> range) : range(range) {
prev_range = can->timeRange();
}
void undo() override { can->setTimeRange(prev_range); }
void redo() override { can->setTimeRange(range); }
std::optional<std::pair<double, double>> prev_range, range;
};

View File

@@ -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;
}

View File

@@ -0,0 +1,30 @@
#pragma once
#include <QComboBox>
#include <QDialog>
#include <QListWidget>
#include "tools/cabana/dbc/dbcmanager.h"
class SignalSelector : public QDialog {
public:
struct ListItem : public QListWidgetItem {
ListItem(const MessageId &msg_id, const cabana::Signal *sig, QListWidget *parent) : msg_id(msg_id), sig(sig), QListWidgetItem(parent) {}
MessageId msg_id;
const cabana::Signal *sig;
};
SignalSelector(QString title, QWidget *parent);
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;
};

View File

@@ -0,0 +1,100 @@
#include "tools/cabana/chart/sparkline.h"
#include <algorithm>
#include <limits>
#include <QPainter>
void Sparkline::update(const cabana::Signal *sig, CanEventIter first, CanEventIter last, int range, QSize size) {
if (first == last || size.isEmpty()) {
pixmap = QPixmap();
return;
}
points_.clear();
min_val = std::numeric_limits<double>::max();
max_val = std::numeric_limits<double>::lowest();
points_.reserve(std::distance(first, last));
uint64_t start_time = (*first)->mono_time;
double value = 0.0;
for (auto it = first; it != last; ++it) {
if (sig->getValue((*it)->dat, (*it)->size, &value)) {
min_val = std::min(min_val, value);
max_val = std::max(max_val, value);
points_.emplace_back(((*it)->mono_time - start_time) / 1e9, value);
}
}
if (points_.empty()) {
pixmap = QPixmap();
return;
}
freq_ = points_.size() / std::max(points_.back().x() - points_.front().x(), 1.0);
render(toQColor(sig->color), range, size);
}
void Sparkline::render(const QColor &color, int range, QSize size) {
// Adjust for flat lines
bool is_flat_line = min_val == max_val;
if (is_flat_line) {
min_val -= 1.0;
max_val += 1.0;
}
// Calculate scaling
const double xscale = (size.width() - 1) / (double)range;
const double yscale = (size.height() - 3) / (max_val - min_val);
bool draw_individual_points = (points_.back().x() * xscale / points_.size()) > 8.0;
// Transform or downsample points
render_points_.reserve(points_.size());
render_points_.clear();
if (draw_individual_points) {
for (const auto &p : points_) {
render_points_.emplace_back(p.x() * xscale, 1.0 + (max_val - p.y()) * yscale);
}
} else if (is_flat_line) {
double y = size.height() / 2.0;
render_points_.emplace_back(0.0, y);
render_points_.emplace_back(points_.back().x() * xscale, y);
} else {
double prev_y = points_.front().y();
render_points_.emplace_back(points_.front().x() * xscale, 1.0 + (max_val - prev_y) * yscale);
bool in_flat = false;
for (size_t i = 1; i < points_.size(); ++i) {
const auto &p = points_[i];
double y = p.y();
if (std::abs(y - prev_y) < 1e-6) {
in_flat = true;
} else {
if (in_flat) render_points_.emplace_back(points_[i - 1].x() * xscale, 1.0 + (max_val - prev_y) * yscale);
render_points_.emplace_back(p.x() * xscale, 1.0 + (max_val - y) * yscale);
in_flat = false;
}
prev_y = y;
}
if (in_flat) render_points_.emplace_back(points_.back().x() * xscale, 1.0 + (max_val - prev_y) * yscale);
}
// Render to pixmap
qreal dpr = qApp->devicePixelRatio();
const QSize pixmap_size = size * dpr;
if (pixmap.size() != pixmap_size) {
pixmap = QPixmap(pixmap_size);
}
pixmap.setDevicePixelRatio(dpr);
pixmap.fill(Qt::transparent);
QPainter painter(&pixmap);
painter.setRenderHint(QPainter::Antialiasing, render_points_.size() <= 500);
painter.setPen(color);
painter.drawPolyline(render_points_.data(), render_points_.size());
painter.setPen(QPen(color, 3));
if (draw_individual_points) {
painter.drawPoints(render_points_.data(), render_points_.size());
} else {
painter.drawPoint(render_points_.back());
}
}

View File

@@ -0,0 +1,26 @@
#pragma once
#include <QPixmap>
#include <QPointF>
#include <vector>
#include "tools/cabana/dbc/dbc.h"
#include "tools/cabana/streams/abstractstream.h"
class Sparkline {
public:
void update(const cabana::Signal *sig, CanEventIter first, CanEventIter last, int range, QSize size);
inline double freq() const { return freq_; }
bool isEmpty() const { return pixmap.isNull(); }
QPixmap pixmap;
double min_val = 0;
double max_val = 0;
private:
void render(const QColor &color, int range, QSize size);
std::vector<QPointF> points_;
std::vector<QPointF> render_points_;
double freq_ = 0;
};

View File

@@ -0,0 +1,58 @@
#include "tools/cabana/chart/tiplabel.h"
#include <utility>
#include <QApplication>
#include <QStylePainter>
#include <QToolTip>
#include "tools/cabana/settings.h"
#include "tools/cabana/utils/util.h"
TipLabel::TipLabel(QWidget *parent) : QLabel(parent, Qt::ToolTip | Qt::FramelessWindowHint) {
setAttribute(Qt::WA_ShowWithoutActivating);
setAttribute(Qt::WA_TransparentForMouseEvents);
setForegroundRole(QPalette::ToolTipText);
setBackgroundRole(QPalette::ToolTipBase);
QFont font;
font.setPointSizeF(8.34563465);
setFont(font);
auto palette = QToolTip::palette();
if (!utils::isDarkTheme()) {
palette.setColor(QPalette::ToolTipBase, QApplication::palette().color(QPalette::Base));
palette.setColor(QPalette::ToolTipText, QRgb(0x404044)); // same color as chart label brush
}
setPalette(palette);
ensurePolished();
setMargin(1 + style()->pixelMetric(QStyle::PM_ToolTipLabelFrameWidth, nullptr, this));
setTextFormat(Qt::RichText);
}
void TipLabel::showText(const QPoint &pt, const QString &text, QWidget *w, const QRect &rect) {
setText(text);
if (!text.isEmpty()) {
QSize extra(1, 1);
resize(sizeHint() + extra);
QPoint tip_pos(pt.x() + 8, rect.top() + 2);
if (tip_pos.x() + size().width() >= rect.right()) {
tip_pos.rx() = pt.x() - size().width() - 8;
}
if (rect.contains({tip_pos, size()})) {
move(w->mapToGlobal(tip_pos));
setVisible(true);
return;
}
}
setVisible(false);
}
void TipLabel::paintEvent(QPaintEvent *ev) {
QStylePainter p(this);
QStyleOptionFrame opt;
opt.init(this);
p.drawPrimitive(QStyle::PE_PanelTipLabel, opt);
p.end();
QLabel::paintEvent(ev);
}

View File

@@ -0,0 +1,12 @@
#pragma once
#include <QLabel>
class TipLabel : public QLabel {
Q_OBJECT
public:
TipLabel(QWidget *parent = nullptr);
void showText(const QPoint &pt, const QString &sec, QWidget *w, const QRect &rect);
void paintEvent(QPaintEvent *ev) override;
};

View File

@@ -0,0 +1,175 @@
#include "tools/cabana/commands.h"
#include <cmath>
// UndoStack
void UndoStack::push(UndoCommand *cmd) {
commands_.resize(index_); // drop any redoable commands
if (clean_index_ > index_) clean_index_ = -1;
commands_.emplace_back(cmd);
cmd->redo();
setIndex(index_ + 1);
}
void UndoStack::undo() {
if (!canUndo()) return;
commands_[index_ - 1]->undo();
setIndex(index_ - 1);
}
void UndoStack::redo() {
if (!canRedo()) return;
commands_[index_]->redo();
setIndex(index_ + 1);
}
void UndoStack::clear() {
bool was_clean = isClean();
commands_.clear();
index_ = clean_index_ = 0;
if (callbacks_.index_changed) callbacks_.index_changed();
if (!was_clean && callbacks_.clean_changed) callbacks_.clean_changed(true);
}
void UndoStack::setClean() {
if (!isClean()) {
clean_index_ = index_;
if (callbacks_.clean_changed) callbacks_.clean_changed(true);
}
}
void UndoStack::setIndex(int index) {
bool was_clean = isClean();
index_ = index;
if (callbacks_.index_changed) callbacks_.index_changed();
if (isClean() != was_clean && callbacks_.clean_changed) callbacks_.clean_changed(isClean());
}
UndoStack *UndoStack::instance() {
static UndoStack undo_stack;
return &undo_stack;
}
QtUndoNotifier::QtUndoNotifier(QObject *parent) : QObject(parent) {
UndoStack::instance()->setCallbacks({
.index_changed = [this]() { emit indexChanged(); },
.clean_changed = [this](bool clean) { emit cleanChanged(clean); },
});
}
QtUndoNotifier *undoNotifier() {
static QtUndoNotifier notifier;
return &notifier;
}
// EditMsgCommand
EditMsgCommand::EditMsgCommand(const MessageId &id, const std::string &name, int size,
const std::string &node, const std::string &comment)
: id(id), new_name(name), new_size(size), new_node(node), new_comment(comment) {
if (auto msg = dbc()->msg(id)) {
old_name = msg->name;
old_size = msg->size;
old_node = msg->transmitter;
old_comment = msg->comment;
text = "edit message " + name + ":" + std::to_string(id.address);
} else {
text = "new message " + name + ":" + std::to_string(id.address);
}
}
void EditMsgCommand::undo() {
if (old_name.empty())
dbc()->removeMsg(id);
else
dbc()->updateMsg(id, old_name, old_size, old_node, old_comment);
}
void EditMsgCommand::redo() {
dbc()->updateMsg(id, new_name, new_size, new_node, new_comment);
}
// RemoveMsgCommand
RemoveMsgCommand::RemoveMsgCommand(const MessageId &id) : id(id) {
if (auto msg = dbc()->msg(id)) {
message = *msg;
text = "remove message " + message.name + ":" + std::to_string(id.address);
}
}
void RemoveMsgCommand::undo() {
if (!message.name.empty()) {
dbc()->updateMsg(id, message.name, message.size, message.transmitter, message.comment);
for (auto s : message.getSignals())
dbc()->addSignal(id, *s);
}
}
void RemoveMsgCommand::redo() {
if (!message.name.empty())
dbc()->removeMsg(id);
}
// AddSigCommand
AddSigCommand::AddSigCommand(const MessageId &id, const cabana::Signal &sig)
: id(id), signal(sig) {
text = "add signal " + sig.name + " to " + msgName(id) + ":" + std::to_string(id.address);
}
void AddSigCommand::undo() {
dbc()->removeSignal(id, signal.name);
if (msg_created) dbc()->removeMsg(id);
}
void AddSigCommand::redo() {
if (auto msg = dbc()->msg(id); !msg) {
msg_created = true;
dbc()->updateMsg(id, dbc()->newMsgName(id), can->lastMessage(id).dat.size(), "", "");
}
signal.name = dbc()->newSignalName(id);
signal.max = std::pow(2, signal.size) - 1;
dbc()->addSignal(id, signal);
}
// RemoveSigCommand
RemoveSigCommand::RemoveSigCommand(const MessageId &id, const cabana::Signal *sig) : id(id) {
sigs.push_back(*sig);
if (sig->type == cabana::Signal::Type::Multiplexor) {
for (const auto &s : dbc()->msg(id)->sigs) {
if (s->type == cabana::Signal::Type::Multiplexed) {
sigs.push_back(*s);
}
}
}
text = "remove signal " + sig->name + " from " + msgName(id) + ":" + std::to_string(id.address);
}
void RemoveSigCommand::undo() { for (const auto &s : sigs) dbc()->addSignal(id, s); }
void RemoveSigCommand::redo() { for (const auto &s : sigs) dbc()->removeSignal(id, s.name); }
// EditSignalCommand
EditSignalCommand::EditSignalCommand(const MessageId &id, const cabana::Signal *sig, const cabana::Signal &new_sig)
: id(id) {
sigs.push_back({*sig, new_sig});
if (sig->type == cabana::Signal::Type::Multiplexor && new_sig.type == cabana::Signal::Type::Normal) {
// convert all multiplexed signals to normal signals
auto msg = dbc()->msg(id);
assert(msg);
for (const auto &s : msg->sigs) {
if (s->type == cabana::Signal::Type::Multiplexed) {
auto new_s = *s;
new_s.type = cabana::Signal::Type::Normal;
sigs.push_back({*s, new_s});
}
}
}
text = "edit signal " + sig->name + " in " + msgName(id) + ":" + std::to_string(id.address);
}
void EditSignalCommand::undo() { for (const auto &s : sigs) dbc()->updateSignal(id, s.second.name, s.first); }
void EditSignalCommand::redo() { for (const auto &s : sigs) dbc()->updateSignal(id, s.first.name, s.second); }

View File

@@ -0,0 +1,120 @@
#pragma once
#include <functional>
#include <memory>
#include <string>
#include <utility>
#include <vector>
#include <QObject>
#include "tools/cabana/dbc/dbcmanager.h"
#include "tools/cabana/streams/abstractstream.h"
class UndoCommand {
public:
virtual ~UndoCommand() = default;
virtual void undo() = 0;
virtual void redo() = 0;
std::string text;
};
class UndoStack {
public:
struct Callbacks {
std::function<void()> index_changed;
std::function<void(bool)> clean_changed;
};
void push(UndoCommand *cmd); // takes ownership and calls redo()
void undo();
void redo();
void clear();
void setClean();
bool isClean() const { return clean_index_ == index_; }
bool canUndo() const { return index_ > 0; }
bool canRedo() const { return index_ < (int)commands_.size(); }
std::string undoText() const { return canUndo() ? commands_[index_ - 1]->text : ""; }
std::string redoText() const { return canRedo() ? commands_[index_]->text : ""; }
void setCallbacks(Callbacks callbacks) { callbacks_ = std::move(callbacks); }
static UndoStack *instance();
private:
void setIndex(int index);
std::vector<std::unique_ptr<UndoCommand>> commands_;
int index_ = 0;
int clean_index_ = 0;
Callbacks callbacks_;
};
// emits Qt signals for the global undo stack
class QtUndoNotifier : public QObject {
Q_OBJECT
public:
explicit QtUndoNotifier(QObject *parent = nullptr);
signals:
void indexChanged();
void cleanChanged(bool clean);
};
QtUndoNotifier *undoNotifier();
class EditMsgCommand : public UndoCommand {
public:
EditMsgCommand(const MessageId &id, const std::string &name, int size, const std::string &node,
const std::string &comment);
void undo() override;
void redo() override;
private:
const MessageId id;
std::string old_name, new_name, old_comment, new_comment, old_node, new_node;
int old_size = 0, new_size = 0;
};
class RemoveMsgCommand : public UndoCommand {
public:
RemoveMsgCommand(const MessageId &id);
void undo() override;
void redo() override;
private:
const MessageId id;
cabana::Msg message;
};
class AddSigCommand : public UndoCommand {
public:
AddSigCommand(const MessageId &id, const cabana::Signal &sig);
void undo() override;
void redo() override;
private:
const MessageId id;
bool msg_created = false;
cabana::Signal signal = {};
};
class RemoveSigCommand : public UndoCommand {
public:
RemoveSigCommand(const MessageId &id, const cabana::Signal *sig);
void undo() override;
void redo() override;
private:
const MessageId id;
std::vector<cabana::Signal> sigs;
};
class EditSignalCommand : public UndoCommand {
public:
EditSignalCommand(const MessageId &id, const cabana::Signal *sig, const cabana::Signal &new_sig);
void undo() override;
void redo() override;
private:
const MessageId id;
std::vector<std::pair<cabana::Signal, cabana::Signal>> sigs; // {old_sig, new_sig}
};

View File

@@ -0,0 +1,46 @@
#pragma once
#include <array>
#include <cstdint>
#include <unordered_map>
#include <vector>
#include "tools/cabana/core/color.h"
#include "tools/cabana/core/message_id.h"
struct CanData {
void compute(const MessageId &msg_id, const uint8_t *data, int size, double current_sec,
double playback_speed, const std::vector<uint8_t> &mask, double frequency = 0);
double ts = 0.;
uint32_t count = 0;
double freq = 0;
std::vector<uint8_t> dat;
std::vector<CabanaColor> colors;
struct ByteLastChange {
double ts = 0;
int delta = 0;
int same_delta_counter = 0;
bool suppressed = false;
};
std::vector<ByteLastChange> last_changes;
std::vector<std::array<uint32_t, 8>> bit_flip_counts;
double last_freq_update_ts = 0;
};
struct CanEvent {
uint8_t src;
uint32_t address;
uint64_t mono_time;
uint8_t size;
uint8_t dat[];
};
struct CompareCanEvent {
constexpr bool operator()(const CanEvent *const event, uint64_t ts) const { return event->mono_time < ts; }
constexpr bool operator()(uint64_t ts, const CanEvent *const event) const { return ts < event->mono_time; }
};
using MessageEventsMap = std::unordered_map<MessageId, std::vector<const CanEvent *>>;
using CanEventIter = std::vector<const CanEvent *>::const_iterator;

View File

@@ -0,0 +1,79 @@
#pragma once
#include <algorithm>
#include <cmath>
#include <cstdint>
struct CabanaColor {
uint8_t r = 0;
uint8_t g = 0;
uint8_t b = 0;
uint8_t a = 255;
constexpr CabanaColor() = default;
constexpr CabanaColor(uint8_t red, uint8_t green, uint8_t blue, uint8_t alpha = 255)
: r(red), g(green), b(blue), a(alpha) {}
static CabanaColor fromHsv(float hue, float saturation, float value, float alpha = 1.0f) {
const float h = hue - std::floor(hue);
const float c = value * saturation;
const float x = c * (1.0f - std::fabs(std::fmod(h * 6.0f, 2.0f) - 1.0f));
const float m = value - c;
float red = 0, green = 0, blue = 0;
switch (static_cast<int>(h * 6.0f) % 6) {
case 0: red = c; green = x; break;
case 1: red = x; green = c; break;
case 2: green = c; blue = x; break;
case 3: green = x; blue = c; break;
case 4: red = x; blue = c; break;
default: red = c; blue = x; break;
}
auto channel = [m](float v) { return static_cast<uint8_t>(std::clamp((v + m) * 255.0f, 0.0f, 255.0f) + 0.5f); };
return {channel(red), channel(green), channel(blue),
static_cast<uint8_t>(std::clamp(alpha * 255.0f, 0.0f, 255.0f) + 0.5f)};
}
CabanaColor darker(int factor = 200) const {
if (factor <= 0) return *this;
if (factor < 100) return lighter(10000 / factor);
auto [hue, saturation, value] = hsv();
return fromHsv(hue, saturation, value * 100.0f / factor, a / 255.0f);
}
CabanaColor lighter(int factor = 150) const {
if (factor <= 0) return *this;
if (factor < 100) return darker(10000 / factor);
auto [hue, saturation, value] = hsv();
const float scaled_value = value * factor / 100.0f;
if (scaled_value > 1.0f) saturation = std::max(0.0f, saturation - (scaled_value - 1.0f));
return fromHsv(hue, saturation, std::min(1.0f, scaled_value), a / 255.0f);
}
constexpr int red() const { return r; }
constexpr int green() const { return g; }
constexpr int blue() const { return b; }
constexpr int alpha() const { return a; }
float alphaF() const { return a / 255.0f; }
void setAlphaF(float alpha) { a = static_cast<uint8_t>(std::clamp(alpha * 255.0f, 0.0f, 255.0f) + 0.5f); }
constexpr bool operator==(const CabanaColor &other) const {
return r == other.r && g == other.g && b == other.b && a == other.a;
}
private:
struct Hsv { float hue; float saturation; float value; };
Hsv hsv() const {
const float red = r / 255.0f, green = g / 255.0f, blue = b / 255.0f;
const float maximum = std::max({red, green, blue});
const float minimum = std::min({red, green, blue});
const float delta = maximum - minimum;
float hue = 0;
if (delta > 0) {
if (maximum == red) hue = std::fmod((green - blue) / delta, 6.0f) / 6.0f;
else if (maximum == green) hue = ((blue - red) / delta + 2.0f) / 6.0f;
else hue = ((red - green) / delta + 4.0f) / 6.0f;
if (hue < 0) hue += 1.0f;
}
return {hue, maximum == 0 ? 0 : delta / maximum, maximum};
}
};

View File

@@ -0,0 +1,27 @@
#pragma once
#include <cstdint>
#include <cstdio>
#include <functional>
#include <string>
#include <tuple>
constexpr int INVALID_SOURCE = 0xff;
struct MessageId {
uint8_t source = 0;
uint32_t address = 0;
std::string toString() const { char b[64]; snprintf(b, sizeof(b), "%u:%X", source, address); return b; }
static MessageId fromString(const std::string &s) {
const auto p = s.find(':');
if (p == std::string::npos) return {};
return {.source = static_cast<uint8_t>(std::stoul(s.substr(0, p))), .address = static_cast<uint32_t>(std::stoul(s.substr(p + 1), nullptr, 16))};
}
bool operator==(const MessageId &o) const { return source == o.source && address == o.address; }
bool operator!=(const MessageId &o) const { return !(*this == o); }
bool operator<(const MessageId &o) const { return std::tie(source, address) < std::tie(o.source, o.address); }
bool operator>(const MessageId &o) const { return o < *this; }
};
template <> struct std::hash<MessageId> {
size_t operator()(const MessageId &id) const noexcept { return std::hash<uint8_t>{}(id.source) ^ (std::hash<uint32_t>{}(id.address) << 1); }
};

View File

@@ -0,0 +1,34 @@
#pragma once
#include <string>
#include <vector>
constexpr int LIGHT_THEME = 1;
constexpr int DARK_THEME = 2;
struct CabanaSettingsState {
enum DragDirection { MsbFirst, LsbFirst, AlwaysLE, AlwaysBE };
bool absolute_time = false;
int fps = 10;
int max_cached_minutes = 30;
int chart_height = 200;
int chart_column_count = 1;
int chart_range = 3 * 60;
int chart_series_type = 0;
int theme = 0;
int sparkline_range = 15;
bool multiple_lines_hex = false;
bool log_livestream = true;
bool suppress_defined_signals = false;
std::string log_path;
std::string last_dir;
std::string last_route_dir;
std::vector<std::string> recent_files;
DragDirection drag_direction = MsbFirst;
std::string recent_dbc_file;
std::string active_msg_id;
std::vector<std::string> selected_msg_ids;
std::vector<std::string> active_charts;
};

View File

@@ -0,0 +1,231 @@
#include "tools/cabana/dbc/dbc.h"
#include <algorithm>
#include <cmath>
namespace {
int numDecimals(double value) {
int decimals = 0;
while (decimals < 6 && std::fabs(value - std::round(value)) > 1e-9) {
value *= 10.0;
++decimals;
}
return decimals;
}
}
// cabana::Msg
cabana::Msg::~Msg() {
for (auto s : sigs) {
delete s;
}
}
cabana::Signal *cabana::Msg::addSignal(const cabana::Signal &sig) {
auto s = sigs.emplace_back(new cabana::Signal(sig));
update();
return s;
}
cabana::Signal *cabana::Msg::updateSignal(const std::string &sig_name, const cabana::Signal &new_sig) {
auto s = sig(sig_name);
if (s) {
*s = new_sig;
update();
}
return s;
}
void cabana::Msg::removeSignal(const std::string &sig_name) {
auto it = std::find_if(sigs.begin(), sigs.end(), [&](auto &s) { return s->name == sig_name; });
if (it != sigs.end()) {
delete *it;
sigs.erase(it);
update();
}
}
cabana::Msg &cabana::Msg::operator=(const cabana::Msg &other) {
address = other.address;
name = other.name;
size = other.size;
comment = other.comment;
transmitter = other.transmitter;
for (auto s : sigs) delete s;
sigs.clear();
for (auto s : other.sigs) {
sigs.push_back(new cabana::Signal(*s));
}
update();
return *this;
}
cabana::Signal *cabana::Msg::sig(const std::string &sig_name) const {
auto it = std::find_if(sigs.begin(), sigs.end(), [&](auto &s) { return s->name == sig_name; });
return it != sigs.end() ? *it : nullptr;
}
int cabana::Msg::indexOf(const cabana::Signal *sig) const {
for (int i = 0; i < sigs.size(); ++i) {
if (sigs[i] == sig) return i;
}
return -1;
}
std::string cabana::Msg::newSignalName() {
std::string new_name;
for (int i = 1; /**/; ++i) {
new_name = "NEW_SIGNAL_" + std::to_string(i);
if (sig(new_name) == nullptr) break;
}
return new_name;
}
void cabana::Msg::update() {
if (transmitter.empty()) {
transmitter = DEFAULT_NODE_NAME;
}
mask.assign(size, 0x00);
multiplexor = nullptr;
// sort signals
std::sort(sigs.begin(), sigs.end(), [](auto l, auto r) {
return std::tie(r->type, l->multiplex_value, l->start_bit, l->name) <
std::tie(l->type, r->multiplex_value, r->start_bit, r->name);
});
for (auto sig : sigs) {
if (sig->type == cabana::Signal::Type::Multiplexor) {
multiplexor = sig;
}
sig->update();
// update mask
int i = sig->msb / 8;
int bits = sig->size;
while (i >= 0 && i < size && bits > 0) {
int lsb = (int)(sig->lsb / 8) == i ? sig->lsb : i * 8;
int msb = (int)(sig->msb / 8) == i ? sig->msb : (i + 1) * 8 - 1;
int sz = msb - lsb + 1;
int shift = (lsb - (i * 8));
mask[i] |= ((1ULL << sz) - 1) << shift;
bits -= sz;
i = sig->is_little_endian ? i - 1 : i + 1;
}
}
for (auto sig : sigs) {
sig->multiplexor = sig->type == cabana::Signal::Type::Multiplexed ? multiplexor : nullptr;
if (!sig->multiplexor) {
if (sig->type == cabana::Signal::Type::Multiplexed) {
sig->type = cabana::Signal::Type::Normal;
}
sig->multiplex_value = 0;
}
}
}
// cabana::Signal
void cabana::Signal::update() {
updateMsbLsb(*this);
if (receiver_name.empty()) {
receiver_name = DEFAULT_NODE_NAME;
}
float h = 19 * (float)lsb / 64.0;
h = fmod(h, 1.0);
size_t hash = std::hash<std::string>{}(name);
float s = 0.25 + 0.25 * (float)(hash & 0xff) / 255.0;
float v = 0.75 + 0.25 * (float)((hash >> 8) & 0xff) / 255.0;
color = CabanaColor::fromHsv(h, s, v);
precision = std::max(numDecimals(factor), numDecimals(offset));
}
std::string cabana::Signal::formatValue(double value, bool with_unit) const {
// Show enum string
int64_t raw_value = round((value - offset) / factor);
for (const auto &[val, desc] : val_desc) {
if (std::abs(raw_value - val) < 1e-6) {
return desc;
}
}
char buf[64];
snprintf(buf, sizeof(buf), "%.*f", precision, value);
std::string val_str(buf);
if (with_unit && !unit.empty()) {
val_str += " " + unit;
}
return val_str;
}
bool cabana::Signal::getValue(const uint8_t *data, size_t data_size, double *val) const {
if (multiplexor && get_raw_value(data, data_size, *multiplexor) != multiplex_value) {
return false;
}
*val = get_raw_value(data, data_size, *this);
return true;
}
bool cabana::Signal::operator==(const cabana::Signal &other) const {
return name == other.name && size == other.size &&
start_bit == other.start_bit &&
msb == other.msb && lsb == other.lsb &&
is_signed == other.is_signed && is_little_endian == other.is_little_endian &&
factor == other.factor && offset == other.offset &&
min == other.min && max == other.max && comment == other.comment && unit == other.unit && val_desc == other.val_desc &&
multiplex_value == other.multiplex_value && type == other.type && receiver_name == other.receiver_name;
}
// helper functions
double get_raw_value(const uint8_t *data, size_t data_size, const cabana::Signal &sig) {
const int msb_byte = sig.msb / 8;
if (msb_byte >= (int)data_size) return 0;
const int lsb_byte = sig.lsb / 8;
uint64_t val = 0;
// Fast path: signal fits in a single byte
if (msb_byte == lsb_byte) {
val = (data[msb_byte] >> (sig.lsb & 7)) & ((1ULL << sig.size) - 1);
} else {
// Multi-byte case: signal spans across multiple bytes
int bits = sig.size;
int i = msb_byte;
const int step = sig.is_little_endian ? -1 : 1;
while (i >= 0 && i < (int)data_size && bits > 0) {
const int msb = (i == msb_byte) ? sig.msb & 7 : 7;
const int lsb = (i == lsb_byte) ? sig.lsb & 7 : 0;
const int nbits = msb - lsb + 1;
val = (val << nbits) | ((data[i] >> lsb) & ((1ULL << nbits) - 1));
bits -= nbits;
i += step;
}
}
// Sign extension (if needed)
if (sig.is_signed && (val & (1ULL << (sig.size - 1)))) {
val |= ~((1ULL << sig.size) - 1);
}
return static_cast<int64_t>(val) * sig.factor + sig.offset;
}
void updateMsbLsb(cabana::Signal &s) {
if (s.is_little_endian) {
s.lsb = s.start_bit;
s.msb = s.start_bit + s.size - 1;
} else {
s.lsb = flipBitPos(flipBitPos(s.start_bit) + s.size - 1);
s.msb = s.start_bit;
}
}

View File

@@ -0,0 +1,94 @@
#pragma once
#include <cstdio>
#include <cstdlib>
#include <functional>
#include <limits>
#include <string>
#include <utility>
#include <vector>
#include "tools/cabana/core/color.h"
#include "tools/cabana/core/message_id.h"
const std::string UNTITLED = "untitled";
const std::string DEFAULT_NODE_NAME = "XXX";
constexpr int CAN_MAX_DATA_BYTES = 64;
typedef std::vector<std::pair<double, std::string>> ValueDescription;
namespace cabana {
class Signal {
public:
Signal() = default;
Signal(const Signal &other) = default;
void update();
bool getValue(const uint8_t *data, size_t data_size, double *val) const;
std::string formatValue(double value, bool with_unit = true) const;
bool operator==(const cabana::Signal &other) const;
inline bool operator!=(const cabana::Signal &other) const { return !(*this == other); }
enum class Type {
Normal = 0,
Multiplexed,
Multiplexor
};
Type type = Type::Normal;
std::string name;
int start_bit, msb, lsb, size;
double factor = 1.0;
double offset = 0;
bool is_signed;
bool is_little_endian;
double min, max;
std::string unit;
std::string comment;
std::string receiver_name;
ValueDescription val_desc;
int precision = 0;
CabanaColor color;
// Multiplexed
int multiplex_value = 0;
Signal *multiplexor = nullptr;
};
class Msg {
public:
Msg() = default;
Msg(const Msg &other) { *this = other; }
~Msg();
cabana::Signal *addSignal(const cabana::Signal &sig);
cabana::Signal *updateSignal(const std::string &sig_name, const cabana::Signal &sig);
void removeSignal(const std::string &sig_name);
Msg &operator=(const Msg &other);
int indexOf(const cabana::Signal *sig) const;
cabana::Signal *sig(const std::string &sig_name) const;
std::string newSignalName();
void update();
inline const std::vector<cabana::Signal *> &getSignals() const { return sigs; }
uint32_t address;
std::string name;
uint32_t size;
std::string comment;
std::string transmitter;
std::vector<cabana::Signal *> sigs;
std::vector<uint8_t> mask;
cabana::Signal *multiplexor = nullptr;
};
} // namespace cabana
// Helper functions
double get_raw_value(const uint8_t *data, size_t data_size, const cabana::Signal &sig);
void updateMsbLsb(cabana::Signal &s);
inline int flipBitPos(int start_bit) { return 8 * (start_bit / 8) + 7 - start_bit % 8; }
inline std::string doubleToString(double value) {
char buf[64];
snprintf(buf, sizeof(buf), "%.*g", std::numeric_limits<double>::digits10, value);
return buf;
}

View File

@@ -0,0 +1,271 @@
#include "tools/cabana/dbc/dbcfile.h"
#include <algorithm>
#include <cassert>
#include <filesystem>
#include <fstream>
#include <regex>
#include <sstream>
#include <stdexcept>
namespace {
std::string trim(const std::string &value) {
const auto first = value.find_first_not_of(" \t\r\n");
if (first == std::string::npos) return {};
return value.substr(first, value.find_last_not_of(" \t\r\n") - first + 1);
}
bool startsWith(const std::string &value, const char *prefix) {
return value.rfind(prefix, 0) == 0;
}
std::string unescapeComment(std::string value) {
for (size_t pos = 0; (pos = value.find("\\\"", pos)) != std::string::npos; ++pos) {
value.replace(pos, 2, "\"");
}
return trim(value);
}
bool commentComplete(const std::string &line) {
bool escaped = false;
for (size_t i = 0; i < line.size(); ++i) {
if (line[i] == '\\' && !escaped) {
escaped = true;
continue;
}
if (line[i] == '"' && !escaped) {
size_t next = line.find_first_not_of(" \t\r\n", i + 1);
if (next != std::string::npos && line[next] == ';') return true;
}
escaped = false;
}
return false;
}
} // namespace
DBCFile::DBCFile(const std::string &dbc_file_name) {
std::ifstream file(dbc_file_name, std::ios::binary);
if (!file) throw std::runtime_error("Failed to open file.");
filename = dbc_file_name;
name_ = std::filesystem::path(dbc_file_name).stem().string();
parse(std::string(std::istreambuf_iterator<char>(file), std::istreambuf_iterator<char>()));
}
DBCFile::DBCFile(const std::string &name, const std::string &content) : name_(name) {
parse(content);
}
bool DBCFile::save() {
assert(!filename.empty());
return writeContents(filename);
}
bool DBCFile::saveAs(const std::string &new_filename) {
filename = new_filename;
return save();
}
bool DBCFile::writeContents(const std::string &fn) {
std::ofstream file(fn, std::ios::binary | std::ios::trunc);
if (!file) return false;
file << generateDBC();
return file.good();
}
void DBCFile::updateMsg(const MessageId &id, const std::string &name, uint32_t size,
const std::string &node, const std::string &comment) {
auto &m = msgs[id.address];
m.address = id.address;
m.name = name;
m.size = size;
m.transmitter = node.empty() ? DEFAULT_NODE_NAME : node;
m.comment = comment;
}
cabana::Msg *DBCFile::msg(uint32_t address) {
auto it = msgs.find(address);
return it != msgs.end() ? &it->second : nullptr;
}
cabana::Msg *DBCFile::msg(const std::string &name) {
auto it = std::find_if(msgs.begin(), msgs.end(), [&name](auto &m) { return m.second.name == name; });
return it != msgs.end() ? &it->second : nullptr;
}
cabana::Signal *DBCFile::signal(uint32_t address, const std::string &name) {
auto m = msg(address);
return m ? m->sig(name) : nullptr;
}
void DBCFile::parse(const std::string &content) {
msgs.clear();
header.clear();
std::istringstream input(content);
std::string raw_line;
cabana::Msg *current_msg = nullptr;
int multiplexor_cnt = 0;
int line_num = 0;
bool seen_first = false;
while (std::getline(input, raw_line)) {
++line_num;
const size_t first_nonspace = raw_line.find_first_not_of(" \t\r");
std::string line = first_nonspace == std::string::npos ? std::string() : raw_line.substr(first_nonspace);
const int statement_line = line_num;
if ((startsWith(line, "CM_ BO_") || startsWith(line, "CM_ SG_ ")) && !commentComplete(line)) {
std::string continuation;
while (std::getline(input, continuation)) {
++line_num;
line += "\n" + continuation;
if (commentComplete(line)) break;
}
}
bool seen = true;
try {
if (startsWith(line, "BO_ ")) {
multiplexor_cnt = 0;
current_msg = parseBO(line);
} else if (startsWith(line, "SG_ ")) {
parseSG(line, current_msg, multiplexor_cnt);
} else if (startsWith(line, "VAL_ ")) {
parseVAL(line);
} else if (startsWith(line, "CM_ BO_")) {
parseCM_BO(line);
} else if (startsWith(line, "CM_ SG_ ")) {
parseCM_SG(line);
} else {
seen = false;
}
} catch (const std::exception &e) {
throw std::runtime_error("[" + filename + ":" + std::to_string(statement_line) + "]" + e.what() + ": " + line);
}
if (seen) seen_first = true;
else if (!seen_first) header += raw_line + "\n";
}
for (auto &[_, message] : msgs) message.update();
}
cabana::Msg *DBCFile::parseBO(const std::string &line) {
static const std::regex pattern(R"(^BO_ ([[:alnum:]_]+) ([[:alnum:]_]+) *: ([[:alnum:]_]+) ([[:alnum:]_]+))");
std::smatch match;
if (!std::regex_search(line, match, pattern)) throw std::runtime_error("Invalid BO_ line format");
const uint32_t address = std::stoul(match[1].str());
if (msgs.count(address)) throw std::runtime_error("Duplicate message address: " + std::to_string(address));
auto &message = msgs[address];
message.address = address;
message.name = match[2].str();
message.size = std::stoul(match[3].str());
message.transmitter = trim(match[4].str());
return &message;
}
void DBCFile::parseSG(const std::string &line, cabana::Msg *current_msg, int &multiplexor_cnt) {
static const std::regex pattern(R"dbc(^SG_ ([[:alnum:]_]+)(?: +([[:alnum:]_]+))? *: ([0-9]+)\|([0-9]+)@([0-9]+)([+-]) \(([0-9.+\-eE]+),([0-9.+\-eE]+)\) \[([0-9.+\-eE]+)\|([0-9.+\-eE]+)\] "(.*)" (.*))dbc");
if (!current_msg) throw std::runtime_error("No Message");
std::smatch match;
if (!std::regex_search(line, match, pattern)) throw std::runtime_error("Invalid SG_ line format");
if (current_msg->sig(match[1].str())) throw std::runtime_error("Duplicate signal name");
cabana::Signal signal{};
const std::string indicator = match[2].str();
if (!indicator.empty()) {
if (indicator == "M") {
if (++multiplexor_cnt >= 2) throw std::runtime_error("Multiple multiplexor");
signal.type = cabana::Signal::Type::Multiplexor;
} else {
signal.type = cabana::Signal::Type::Multiplexed;
signal.multiplex_value = indicator.size() > 1 ? std::stoi(indicator.substr(1)) : 0;
}
}
signal.name = match[1].str();
signal.start_bit = std::stoi(match[3].str());
signal.size = std::stoi(match[4].str());
signal.is_little_endian = match[5].str() == "1";
signal.is_signed = match[6].str() == "-";
signal.factor = std::stod(match[7].str());
signal.offset = std::stod(match[8].str());
signal.min = std::stod(match[9].str());
signal.max = std::stod(match[10].str());
signal.unit = match[11].str();
signal.receiver_name = trim(match[12].str());
current_msg->sigs.push_back(new cabana::Signal(signal));
}
void DBCFile::parseCM_BO(const std::string &line) {
std::istringstream prefix(line.substr(7));
uint32_t address = 0;
prefix >> address;
const size_t first_quote = line.find('"');
const size_t last_quote = line.rfind('"');
if (!prefix || first_quote == std::string::npos || last_quote <= first_quote) {
throw std::runtime_error("Invalid message comment format");
}
if (auto message = msg(address)) message->comment = unescapeComment(line.substr(first_quote + 1, last_quote - first_quote - 1));
}
void DBCFile::parseCM_SG(const std::string &line) {
std::istringstream prefix(line.substr(7));
uint32_t address = 0;
std::string name;
prefix >> address >> name;
const size_t first_quote = line.find('"');
const size_t last_quote = line.rfind('"');
if (!prefix || name.empty() || first_quote == std::string::npos || last_quote <= first_quote) {
throw std::runtime_error("Invalid CM_ SG_ line format");
}
if (auto sig = signal(address, name)) sig->comment = unescapeComment(line.substr(first_quote + 1, last_quote - first_quote - 1));
}
void DBCFile::parseVAL(const std::string &line) {
static const std::regex header_pattern(R"(^VAL_ ([[:alnum:]_]+) ([[:alnum:]_]+) (.*))");
static const std::regex entry_pattern(R"dbc(([+-]?[0-9]+(?:\.[0-9]+)?)\s+"([^"]*)")dbc");
std::smatch match;
if (!std::regex_search(line, match, header_pattern)) throw std::runtime_error("invalid VAL_ line format");
if (auto sig = signal(std::stoul(match[1].str()), match[2].str())) {
const std::string entries = match[3].str();
for (std::sregex_iterator it(entries.begin(), entries.end(), entry_pattern), end; it != end; ++it) {
sig->val_desc.emplace_back(std::stod((*it)[1].str()), trim((*it)[2].str()));
}
}
}
std::string DBCFile::generateDBC() {
std::string dbc_string, comment, val_desc;
for (const auto &[address, m] : msgs) {
const std::string &transmitter = m.transmitter.empty() ? DEFAULT_NODE_NAME : m.transmitter;
dbc_string += "BO_ " + std::to_string(address) + " " + m.name + ": " + std::to_string(m.size) + " " + transmitter + "\n";
if (!m.comment.empty()) {
std::string escaped = m.comment;
for (size_t pos = 0; (pos = escaped.find('"', pos)) != std::string::npos; pos += 2) escaped.replace(pos, 1, "\\\"");
comment += "CM_ BO_ " + std::to_string(address) + " \"" + escaped + "\";\n";
}
for (auto sig : m.getSignals()) {
std::string mux;
if (sig->type == cabana::Signal::Type::Multiplexor) mux = "M ";
else if (sig->type == cabana::Signal::Type::Multiplexed) mux = "m" + std::to_string(sig->multiplex_value) + " ";
const std::string &receiver = sig->receiver_name.empty() ? DEFAULT_NODE_NAME : sig->receiver_name;
dbc_string += " SG_ " + sig->name + " " + mux + ": " + std::to_string(sig->start_bit) + "|" + std::to_string(sig->size) + "@" +
(sig->is_little_endian ? "1" : "0") + (sig->is_signed ? "-" : "+") +
" (" + doubleToString(sig->factor) + "," + doubleToString(sig->offset) + ")" +
" [" + doubleToString(sig->min) + "|" + doubleToString(sig->max) + "] \"" + sig->unit + "\" " + receiver + "\n";
if (!sig->comment.empty()) {
std::string escaped = sig->comment;
for (size_t pos = 0; (pos = escaped.find('"', pos)) != std::string::npos; pos += 2) escaped.replace(pos, 1, "\\\"");
comment += "CM_ SG_ " + std::to_string(address) + " " + sig->name + " \"" + escaped + "\";\n";
}
if (!sig->val_desc.empty()) {
std::string text;
for (const auto &[value, description] : sig->val_desc) {
if (!text.empty()) text += " ";
text += doubleToString(value) + " \"" + description + "\"";
}
val_desc += "VAL_ " + std::to_string(address) + " " + sig->name + " " + text + ";\n";
}
}
dbc_string += "\n";
}
return header + dbc_string + comment + val_desc;
}

View File

@@ -0,0 +1,44 @@
#pragma once
#include <map>
#include <string>
#include "tools/cabana/dbc/dbc.h"
class DBCFile {
public:
DBCFile(const std::string &dbc_file_name);
DBCFile(const std::string &name, const std::string &content);
~DBCFile() {}
bool save();
bool saveAs(const std::string &new_filename);
bool writeContents(const std::string &fn);
std::string generateDBC();
void updateMsg(const MessageId &id, const std::string &name, uint32_t size, const std::string &node, const std::string &comment);
inline void removeMsg(const MessageId &id) { msgs.erase(id.address); }
inline const std::map<uint32_t, cabana::Msg> &getMessages() const { return msgs; }
cabana::Msg *msg(uint32_t address);
cabana::Msg *msg(const std::string &name);
inline cabana::Msg *msg(const MessageId &id) { return msg(id.address); }
cabana::Signal *signal(uint32_t address, const std::string &name);
inline std::string name() const { return name_.empty() ? "untitled" : name_; }
inline bool isEmpty() const { return msgs.empty() && name_.empty(); }
std::string filename;
private:
void parse(const std::string &content);
cabana::Msg *parseBO(const std::string &line);
void parseSG(const std::string &line, cabana::Msg *current_msg, int &multiplexor_cnt);
void parseCM_BO(const std::string &line);
void parseCM_SG(const std::string &line);
void parseVAL(const std::string &line);
std::string header;
std::map<uint32_t, cabana::Msg> msgs;
std::string name_;
};

View File

@@ -0,0 +1,182 @@
#include "tools/cabana/dbc/dbcmanager.h"
#include <algorithm>
#include <cassert>
#include <set>
bool DBCManager::open(const SourceSet &sources, const std::string &dbc_file_name, std::string *error) {
try {
auto it = std::find_if(dbc_files.begin(), dbc_files.end(),
[&](auto &f) { return f.second && f.second->filename == dbc_file_name; });
auto file = (it != dbc_files.end()) ? it->second : std::make_shared<DBCFile>(dbc_file_name);
for (auto s : sources) {
dbc_files[s] = file;
}
} catch (std::exception &e) {
if (error) *error = e.what();
return false;
}
if (callbacks_.file_changed) callbacks_.file_changed();
return true;
}
bool DBCManager::open(const SourceSet &sources, const std::string &name, const std::string &content, std::string *error) {
try {
auto file = std::make_shared<DBCFile>(name, content);
for (auto s : sources) {
dbc_files[s] = file;
}
} catch (std::exception &e) {
if (error) *error = e.what();
return false;
}
if (callbacks_.file_changed) callbacks_.file_changed();
return true;
}
void DBCManager::close(const SourceSet &sources) {
for (auto s : sources) {
dbc_files[s] = nullptr;
}
if (callbacks_.file_changed) callbacks_.file_changed();
}
void DBCManager::close(DBCFile *dbc_file) {
for (auto &[_, f] : dbc_files) {
if (f.get() == dbc_file) f = nullptr;
}
if (callbacks_.file_changed) callbacks_.file_changed();
}
void DBCManager::closeAll() {
dbc_files.clear();
if (callbacks_.file_changed) callbacks_.file_changed();
}
void DBCManager::addSignal(const MessageId &id, const cabana::Signal &sig) {
if (auto m = msg(id)) {
if (auto s = m->addSignal(sig)) {
if (callbacks_.signal_added) callbacks_.signal_added(id, s);
if (callbacks_.mask_updated) callbacks_.mask_updated();
}
}
}
void DBCManager::updateSignal(const MessageId &id, const std::string &sig_name, const cabana::Signal &sig) {
if (auto m = msg(id)) {
if (auto s = m->updateSignal(sig_name, sig)) {
if (callbacks_.signal_updated) callbacks_.signal_updated(s);
if (callbacks_.mask_updated) callbacks_.mask_updated();
}
}
}
void DBCManager::removeSignal(const MessageId &id, const std::string &sig_name) {
if (auto m = msg(id)) {
if (auto s = m->sig(sig_name)) {
if (callbacks_.signal_removed) callbacks_.signal_removed(s);
m->removeSignal(sig_name);
if (callbacks_.mask_updated) callbacks_.mask_updated();
}
}
}
void DBCManager::updateMsg(const MessageId &id, const std::string &name, uint32_t size, const std::string &node, const std::string &comment) {
auto dbc_file = findDBCFile(id);
assert(dbc_file); // This should be impossible
dbc_file->updateMsg(id, name, size, node, comment);
if (callbacks_.msg_updated) callbacks_.msg_updated(id);
}
void DBCManager::removeMsg(const MessageId &id) {
auto dbc_file = findDBCFile(id);
assert(dbc_file); // This should be impossible
dbc_file->removeMsg(id);
if (callbacks_.msg_removed) callbacks_.msg_removed(id);
if (callbacks_.mask_updated) callbacks_.mask_updated();
}
std::string DBCManager::newMsgName(const MessageId &id) {
char buf[64];
snprintf(buf, sizeof(buf), "NEW_MSG_%X", id.address);
return buf;
}
std::string DBCManager::newSignalName(const MessageId &id) {
auto m = msg(id);
return m ? m->newSignalName() : "";
}
const std::map<uint32_t, cabana::Msg> &DBCManager::getMessages(uint8_t source) {
static std::map<uint32_t, cabana::Msg> empty_msgs;
auto dbc_file = findDBCFile(source);
return dbc_file ? dbc_file->getMessages() : empty_msgs;
}
cabana::Msg *DBCManager::msg(const MessageId &id) {
auto dbc_file = findDBCFile(id);
return dbc_file ? dbc_file->msg(id) : nullptr;
}
cabana::Msg *DBCManager::msg(uint8_t source, const std::string &name) {
auto dbc_file = findDBCFile(source);
return dbc_file ? dbc_file->msg(name) : nullptr;
}
std::vector<std::string> DBCManager::signalNames() {
// Used for autocompletion
std::set<std::string> names;
for (auto &f : allDBCFiles()) {
for (auto &[_, m] : f->getMessages()) {
for (auto sig : m.getSignals()) {
names.insert(sig->name);
}
}
}
std::vector<std::string> ret(names.begin(), names.end());
std::sort(ret.begin(), ret.end());
return ret;
}
int DBCManager::nonEmptyDBCCount() {
auto files = allDBCFiles();
return std::count_if(files.cbegin(), files.cend(), [](auto &f) { return !f->isEmpty(); });
}
DBCFile *DBCManager::findDBCFile(const uint8_t source) {
// Find DBC file that matches id.source, fall back to SOURCE_ALL if no specific DBC is found
auto it = dbc_files.count(source) ? dbc_files.find(source) : dbc_files.find(-1);
return it != dbc_files.end() ? it->second.get() : nullptr;
}
std::set<DBCFile *> DBCManager::allDBCFiles() {
std::set<DBCFile *> files;
for (const auto &[_, f] : dbc_files) {
if (f) files.insert(f.get());
}
return files;
}
const SourceSet DBCManager::sources(const DBCFile *dbc_file) const {
SourceSet sources;
for (auto &[s, f] : dbc_files) {
if (f.get() == dbc_file) sources.insert(s);
}
return sources;
}
std::string toString(const SourceSet &ss) {
std::string result;
for (int source : ss) {
if (!result.empty()) result += ", ";
result += (source == -1) ? "all" : std::to_string(source);
}
return result;
}
DBCManager *dbc() {
static DBCManager dbc_manager;
return &dbc_manager;
}

View File

@@ -0,0 +1,70 @@
#pragma once
#include <functional>
#include <memory>
#include <map>
#include <set>
#include <string>
#include <vector>
#include "tools/cabana/dbc/dbcfile.h"
typedef std::set<int> SourceSet;
const SourceSet SOURCE_ALL = {-1};
inline bool operator<(const std::shared_ptr<DBCFile> &l, const std::shared_ptr<DBCFile> &r) { return l.get() < r.get(); }
class DBCManager {
public:
struct Callbacks {
std::function<void(MessageId, const cabana::Signal *)> signal_added;
std::function<void(const cabana::Signal *)> signal_removed;
std::function<void(const cabana::Signal *)> signal_updated;
std::function<void(MessageId)> msg_updated;
std::function<void(MessageId)> msg_removed;
std::function<void()> file_changed;
std::function<void()> mask_updated;
};
DBCManager() = default;
bool open(const SourceSet &sources, const std::string &dbc_file_name, std::string *error = nullptr);
bool open(const SourceSet &sources, const std::string &name, const std::string &content, std::string *error = nullptr);
void close(const SourceSet &sources);
void close(DBCFile *dbc_file);
void closeAll();
void addSignal(const MessageId &id, const cabana::Signal &sig);
void updateSignal(const MessageId &id, const std::string &sig_name, const cabana::Signal &sig);
void removeSignal(const MessageId &id, const std::string &sig_name);
void updateMsg(const MessageId &id, const std::string &name, uint32_t size, const std::string &node, const std::string &comment);
void removeMsg(const MessageId &id);
std::string newMsgName(const MessageId &id);
std::string newSignalName(const MessageId &id);
const std::map<uint32_t, cabana::Msg> &getMessages(uint8_t source);
cabana::Msg *msg(const MessageId &id);
cabana::Msg* msg(uint8_t source, const std::string &name);
std::vector<std::string> signalNames();
inline int dbcCount() { return allDBCFiles().size(); }
int nonEmptyDBCCount();
const SourceSet sources(const DBCFile *dbc_file) const;
DBCFile *findDBCFile(const uint8_t source);
inline DBCFile *findDBCFile(const MessageId &id) { return findDBCFile(id.source); }
std::set<DBCFile *> allDBCFiles();
void setCallbacks(Callbacks callbacks) { callbacks_ = std::move(callbacks); }
private:
std::map<int, std::shared_ptr<DBCFile>> dbc_files;
Callbacks callbacks_;
};
DBCManager *dbc();
std::string toString(const SourceSet &ss);
inline std::string msgName(const MessageId &id) {
auto msg = dbc()->msg(id);
return msg ? msg->name : UNTITLED;
}

View File

@@ -0,0 +1,18 @@
#include "tools/cabana/dbc/dbcqt.h"
QtDBCNotifier::QtDBCNotifier(QObject *parent) : QObject(parent) {
dbc()->setCallbacks({
.signal_added = [this](MessageId id, const cabana::Signal *sig) { emit signalAdded(id, sig); },
.signal_removed = [this](const cabana::Signal *sig) { emit signalRemoved(sig); },
.signal_updated = [this](const cabana::Signal *sig) { emit signalUpdated(sig); },
.msg_updated = [this](MessageId id) { emit msgUpdated(id); },
.msg_removed = [this](MessageId id) { emit msgRemoved(id); },
.file_changed = [this]() { emit DBCFileChanged(); },
.mask_updated = [this]() { emit maskUpdated(); },
});
}
QtDBCNotifier *dbcNotifier() {
static QtDBCNotifier notifier;
return &notifier;
}

View File

@@ -0,0 +1,27 @@
#pragma once
#include <QMetaType>
#include <QObject>
#include "tools/cabana/dbc/dbcmanager.h"
Q_DECLARE_METATYPE(MessageId)
Q_DECLARE_METATYPE(ValueDescription)
class QtDBCNotifier : public QObject {
Q_OBJECT
public:
explicit QtDBCNotifier(QObject *parent = nullptr);
signals:
void signalAdded(MessageId id, const cabana::Signal *sig);
void signalRemoved(const cabana::Signal *sig);
void signalUpdated(const cabana::Signal *sig);
void msgUpdated(MessageId id);
void msgRemoved(MessageId id);
void DBCFileChanged();
void maskUpdated();
};
QtDBCNotifier *dbcNotifier();

View File

@@ -0,0 +1,38 @@
#!/usr/bin/env python3
import argparse
import json
from iqdbc.car import Bus
from iqdbc.car.fingerprints import MIGRATION
from iqdbc.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}")

View File

@@ -0,0 +1,53 @@
we're migrating cabana away from Qt and to eventually entirely use imgui
we are doing it incrementally, in small pieces that are easy to execute and verify.
we will repeat this until we're all done.
# Cabana Qt API inventory
these are all still in cabana. we remove them from this list once they're gone.
each bullet is an atomic unit of work.
our workflow is:
- pick the easiest of the bulleted items from below
- implement it and make] sure it builds
- spin up reviewer agents to review the code in a clean context and a separate one to click around in xvfb as a gui test
- then implement the fixes from the above reviewer agents
some rules
- do not add more Qt usage ever
- `QObject`, `QMetaObject`, `QMetaType`
- `QApplication`, `QCoreApplication`, `QGuiApplication`
- `QString`, `QStringList`, `QStringBuilder`, `QChar`, `QLatin1Char`
- `QVariant`
- `QTimer`
- `QWidget`, `QMainWindow`, `QWindow`
- `QDialog`, `QDialogButtonBox`, `QMessageBox`, `QProgressDialog`
- `QFileDialog`
- `QMenu`, `QMenuBar`, `QAction`, `QActionGroup`, `QWidgetAction`
- `QToolBar`, `QToolButton`, `QPushButton`
- `QCheckBox`, `QRadioButton`, `QButtonGroup`, `QAbstractButton`
- `QComboBox`, `QLineEdit`, `QTextEdit`, `QSpinBox`, `QSlider`
- `QLabel`, `QGroupBox`, `QFrame`
- `QTabBar`, `QTabWidget`, `QSplitter`, `QScrollArea`, `QScrollBar`
- `QDockWidget`, `QStatusBar`, `QProgressBar`
- `QFormLayout`, `QGridLayout`, `QHBoxLayout`, `QVBoxLayout`
- `QSizePolicy`
- `QAbstractItemModel`, `QAbstractTableModel`, `QModelIndex`
- `QAbstractItemView`, `QTableView`, `QTreeView`
- `QTableWidget`, `QTableWidgetItem`, `QListWidget`, `QListWidgetItem`
- `QItemSelection`, `QItemSelectionModel`, `QItemSelectionRange`
- `QHeaderView`, `QStyledItemDelegate`, `QStyleOptionViewItem`
- `QValidator`, `QIntValidator`
- `QColor`, `QRgb`, `QPalette`
- `QBrush`, `QPen`
- `QPainter`, `QPainterPath`, `QStylePainter`
- `QImage`, `QPixmap`, `QPixmapCache`, `QStaticText`
- `QFont`, `QFontDatabase`, `QFontMetrics`, `QTextDocument`
- `QStyle`, `QStyleOption`, `QStyleOptionFrame`, `QStyleOptionSlider`
- `QPoint`, `QPointF`, `QRect`, `QRectF`, `QRegion`
- `QSize`, `QSizeF`
- `QEvent`, `QPaintEvent`, `QResizeEvent`, `QShowEvent`, `QCloseEvent`
- `QMouseEvent`, `QWheelEvent`, `QNativeGestureEvent`, `QContextMenuEvent`
- `QKeySequence`, `QShortcut`, `QToolTip`

View File

@@ -0,0 +1,324 @@
#include "tools/cabana/detailwidget.h"
#include "tools/cabana/dbc/dbcqt.h"
#include <QFormLayout>
#include <QMenu>
#include <QRadioButton>
#include <QPushButton>
#include <QToolBar>
#include "tools/cabana/commands.h"
#include "tools/cabana/mainwin.h"
// DetailWidget
DetailWidget::DetailWidget(ChartsWidget *charts, QWidget *parent) : charts(charts), QWidget(parent) {
QVBoxLayout *main_layout = new QVBoxLayout(this);
main_layout->setContentsMargins(0, 0, 0, 0);
// tabbar
tabbar = new TabBar(this);
tabbar->setUsesScrollButtons(true);
tabbar->setAutoHide(true);
tabbar->setContextMenuPolicy(Qt::CustomContextMenu);
main_layout->addWidget(tabbar);
createToolBar();
// warning
warning_widget = new QWidget(this);
QHBoxLayout *warning_hlayout = new QHBoxLayout(warning_widget);
warning_hlayout->addWidget(warning_icon = new QLabel(this), 0, Qt::AlignTop);
warning_hlayout->addWidget(warning_label = new QLabel(this), 1, Qt::AlignLeft);
warning_widget->hide();
main_layout->addWidget(warning_widget);
// msg widget
splitter = new QSplitter(Qt::Vertical, this);
splitter->addWidget(binary_view = new BinaryView(this));
splitter->addWidget(signal_view = new SignalView(charts, this));
binary_view->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Minimum);
signal_view->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Expanding);
splitter->setStretchFactor(0, 0);
splitter->setStretchFactor(1, 1);
tab_widget = new QTabWidget(this);
tab_widget->setStyleSheet("QTabWidget::pane {border: none; margin-bottom: -2px;}");
tab_widget->setTabPosition(QTabWidget::South);
tab_widget->addTab(splitter, utils::icon("file-earmark-ruled"), "&Msg");
tab_widget->addTab(history_log = new LogsWidget(this), utils::icon("stopwatch"), "&Logs");
main_layout->addWidget(tab_widget);
QObject::connect(binary_view, &BinaryView::signalHovered, signal_view, &SignalView::signalHovered);
QObject::connect(binary_view, &BinaryView::signalClicked, [this](const cabana::Signal *s) { signal_view->selectSignal(s, true); });
QObject::connect(binary_view, &BinaryView::editSignal, signal_view->model, &SignalModel::saveSignal);
QObject::connect(binary_view, &BinaryView::showChart, charts, &ChartsWidget::showChart);
QObject::connect(signal_view, &SignalView::showChart, charts, &ChartsWidget::showChart);
QObject::connect(signal_view, &SignalView::highlight, binary_view, &BinaryView::highlight);
QObject::connect(tab_widget, &QTabWidget::currentChanged, [this]() { updateState(); });
QObject::connect(can, &AbstractStream::msgsReceived, this, &DetailWidget::updateState);
QObject::connect(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, this, &DetailWidget::refresh);
QObject::connect(undoNotifier(), &QtUndoNotifier::indexChanged, this, &DetailWidget::refresh);
QObject::connect(tabbar, &QTabBar::customContextMenuRequested, this, &DetailWidget::showTabBarContextMenu);
QObject::connect(tabbar, &QTabBar::currentChanged, [this](int index) {
if (index != -1) {
setMessage(tabbar->tabData(index).value<MessageId>());
}
});
QObject::connect(tabbar, &QTabBar::tabCloseRequested, tabbar, &QTabBar::removeTab);
QObject::connect(charts, &ChartsWidget::seriesChanged, signal_view, &SignalView::updateChartState);
}
void DetailWidget::createToolBar() {
QToolBar *toolbar = new QToolBar(this);
int icon_size = style()->pixelMetric(QStyle::PM_SmallIconSize);
toolbar->setIconSize({icon_size, icon_size});
toolbar->addWidget(name_label = new ElidedLabel(this));
name_label->setStyleSheet("QLabel{font-weight:bold;}");
QWidget *spacer = new QWidget();
spacer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred);
toolbar->addWidget(spacer);
// Heatmap label and radio buttons
toolbar->addWidget(new QLabel(tr("Heatmap:"), this));
auto *heatmap_live = new QRadioButton(tr("Live"), this);
auto *heatmap_all = new QRadioButton(tr("All"), this);
heatmap_live->setChecked(true);
toolbar->addWidget(heatmap_live);
toolbar->addWidget(heatmap_all);
// Edit and remove buttons
toolbar->addSeparator();
toolbar->addAction(utils::icon("pencil"), tr("Edit Message"), this, &DetailWidget::editMsg);
action_remove_msg = toolbar->addAction(utils::icon("x-lg"), tr("Remove Message"), this, &DetailWidget::removeMsg);
layout()->addWidget(toolbar);
connect(heatmap_live, &QAbstractButton::toggled, this, [this](bool on) { binary_view->setHeatmapLiveMode(on); });
connect(can, &AbstractStream::timeRangeChanged, this, [=](const std::optional<std::pair<double, double>> &range) {
auto text = range ? QString("%1 - %2").arg(range->first, 0, 'f', 3).arg(range->second, 0, 'f', 3) : "All";
heatmap_all->setText(text);
(range ? heatmap_all : heatmap_live)->setChecked(true);
});
}
void DetailWidget::showTabBarContextMenu(const QPoint &pt) {
int index = tabbar->tabAt(pt);
if (index >= 0) {
QMenu menu(this);
menu.addAction(tr("Close Other Tabs"));
if (menu.exec(tabbar->mapToGlobal(pt))) {
tabbar->moveTab(index, 0);
tabbar->setCurrentIndex(0);
while (tabbar->count() > 1) {
tabbar->removeTab(1);
}
}
}
}
int DetailWidget::findOrAddTab(const MessageId& message_id) {
int index = tabbar->count() - 1;
for (/**/; index >= 0; --index) {
if (tabbar->tabData(index).value<MessageId>() == message_id) break;
}
if (index == -1) {
index = tabbar->addTab(QString::fromStdString(message_id.toString()));
tabbar->setTabData(index, QVariant::fromValue(message_id));
tabbar->setTabToolTip(index, QString::fromStdString(msgName(message_id)));
}
return index;
}
void DetailWidget::setMessage(const MessageId &message_id) {
if (std::exchange(msg_id, message_id) == message_id) return;
tabbar->blockSignals(true);
int index = findOrAddTab(message_id);
tabbar->setCurrentIndex(index);
tabbar->blockSignals(false);
setUpdatesEnabled(false);
signal_view->setMessage(msg_id);
binary_view->setMessage(msg_id);
history_log->setMessage(msg_id);
refresh();
setUpdatesEnabled(true);
}
std::pair<QString, QStringList> DetailWidget::serializeMessageIds() const {
QStringList msgs;
for (int i = 0; i < tabbar->count(); ++i) {
MessageId id = tabbar->tabData(i).value<MessageId>();
msgs.append(QString::fromStdString(id.toString()));
}
return std::make_pair(QString::fromStdString(msg_id.toString()), msgs);
}
void DetailWidget::restoreTabs(const QString active_msg_id, const QStringList& msg_ids) {
tabbar->blockSignals(true);
for (const auto& str_id : msg_ids) {
MessageId id = MessageId::fromString(str_id.toStdString());
if (dbc()->msg(id) != nullptr)
findOrAddTab(id);
}
tabbar->blockSignals(false);
auto active_id = MessageId::fromString(active_msg_id.toStdString());
if (dbc()->msg(active_id) != nullptr)
setMessage(active_id);
}
void DetailWidget::refresh() {
QStringList warnings;
auto msg = dbc()->msg(msg_id);
if (msg) {
if (msg_id.source == INVALID_SOURCE) {
warnings.push_back(tr("No messages received."));
} else if (msg->size != can->lastMessage(msg_id).dat.size()) {
warnings.push_back(tr("Message size (%1) is incorrect.").arg(msg->size));
}
for (auto s : binary_view->getOverlappingSignals()) {
warnings.push_back(tr("%1 has overlapping bits.").arg(QString::fromStdString(s->name)));
}
}
QString msg_name = msg ? QString("%1 (%2)").arg(QString::fromStdString(msg->name), QString::fromStdString(msg->transmitter)) : QString::fromStdString(msgName(msg_id));
name_label->setText(msg_name);
name_label->setToolTip(msg_name);
action_remove_msg->setEnabled(msg != nullptr);
if (!warnings.isEmpty()) {
warning_label->setText(warnings.join('\n'));
warning_icon->setPixmap(utils::icon(msg ? "exclamation-triangle" : "info-circle"));
}
warning_widget->setVisible(!warnings.isEmpty());
}
void DetailWidget::updateState(const std::set<MessageId> *msgs) {
if ((msgs && !msgs->count(msg_id)))
return;
if (tab_widget->currentIndex() == 0)
binary_view->updateState();
else
history_log->updateState();
}
void DetailWidget::editMsg() {
auto msg = dbc()->msg(msg_id);
int size = msg ? msg->size : can->lastMessage(msg_id).dat.size();
EditMessageDialog dlg(msg_id, QString::fromStdString(msgName(msg_id)), size, this);
if (dlg.exec()) {
UndoStack::instance()->push(new EditMsgCommand(msg_id, dlg.name_edit->text().trimmed().toStdString(), dlg.size_spin->value(),
dlg.node->text().trimmed().toStdString(), dlg.comment_edit->toPlainText().trimmed().toStdString()));
}
}
void DetailWidget::removeMsg() {
UndoStack::instance()->push(new RemoveMsgCommand(msg_id));
}
// EditMessageDialog
EditMessageDialog::EditMessageDialog(const MessageId &msg_id, const QString &title, int size, QWidget *parent)
: original_name(title), msg_id(msg_id), QDialog(parent) {
setWindowTitle(tr("Edit message: %1").arg(QString::fromStdString(msg_id.toString())));
QFormLayout *form_layout = new QFormLayout(this);
form_layout->addRow("", error_label = new QLabel);
error_label->setVisible(false);
form_layout->addRow(tr("Name"), name_edit = new QLineEdit(title, this));
name_edit->setValidator(new NameValidator(name_edit));
form_layout->addRow(tr("Size"), size_spin = new QSpinBox(this));
size_spin->setRange(1, CAN_MAX_DATA_BYTES);
size_spin->setValue(size);
form_layout->addRow(tr("Node"), node = new QLineEdit(this));
node->setValidator(new NameValidator(name_edit));
form_layout->addRow(tr("Comment"), comment_edit = new QTextEdit(this));
form_layout->addRow(btn_box = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel));
if (auto msg = dbc()->msg(msg_id)) {
node->setText(QString::fromStdString(msg->transmitter));
comment_edit->setText(QString::fromStdString(msg->comment));
}
validateName(name_edit->text());
setFixedWidth(parent->width() * 0.9);
connect(name_edit, &QLineEdit::textEdited, this, &EditMessageDialog::validateName);
connect(btn_box, &QDialogButtonBox::accepted, this, &QDialog::accept);
connect(btn_box, &QDialogButtonBox::rejected, this, &QDialog::reject);
}
void EditMessageDialog::validateName(const QString &text) {
bool valid = text.compare(QString::fromStdString(UNTITLED), Qt::CaseInsensitive) != 0;
error_label->setVisible(false);
if (!text.isEmpty() && valid && text != original_name) {
valid = dbc()->msg(msg_id.source, text.toStdString()) == nullptr;
if (!valid) {
error_label->setText(tr("Name already exists"));
error_label->setVisible(true);
}
}
btn_box->button(QDialogButtonBox::Ok)->setEnabled(valid);
}
// CenterWidget
CenterWidget::CenterWidget(QWidget *parent) : QWidget(parent) {
QVBoxLayout *main_layout = new QVBoxLayout(this);
main_layout->setContentsMargins(0, 0, 0, 0);
main_layout->addWidget(welcome_widget = createWelcomeWidget());
}
DetailWidget* CenterWidget::ensureDetailWidget() {
if (!detail_widget) {
delete welcome_widget;
welcome_widget = nullptr;
layout()->addWidget(detail_widget = new DetailWidget(((MainWindow*)parentWidget())->charts_widget, this));
}
return detail_widget;
}
void CenterWidget::clear() {
delete detail_widget;
detail_widget = nullptr;
if (!welcome_widget) {
layout()->addWidget(welcome_widget = createWelcomeWidget());
}
}
QWidget *CenterWidget::createWelcomeWidget() {
QWidget *w = new QWidget(this);
QVBoxLayout *main_layout = new QVBoxLayout(w);
main_layout->addStretch(0);
QLabel *logo = new QLabel("CABANA");
logo->setAlignment(Qt::AlignCenter);
logo->setStyleSheet("font-size:50px;font-weight:bold;");
main_layout->addWidget(logo);
auto newShortcutRow = [](const QString &title, const QString &key) {
QHBoxLayout *hlayout = new QHBoxLayout();
auto btn = new QToolButton();
btn->setText(key);
btn->setEnabled(false);
hlayout->addWidget(new QLabel(title), 0, Qt::AlignRight);
hlayout->addWidget(btn, 0, Qt::AlignLeft);
return hlayout;
};
auto lb = new QLabel(tr("<-Select a message to view details"));
lb->setAlignment(Qt::AlignHCenter);
main_layout->addWidget(lb);
main_layout->addLayout(newShortcutRow("Pause", "Space"));
main_layout->addLayout(newShortcutRow("Help", "F1"));
main_layout->addLayout(newShortcutRow("WhatsThis", "Shift+F1"));
main_layout->addStretch(0);
w->setStyleSheet("QLabel{color:darkGray;}");
w->setBackgroundRole(QPalette::Base);
w->setAutoFillBackground(true);
return w;
}

View File

@@ -0,0 +1,75 @@
#pragma once
#include <QDialogButtonBox>
#include <QSplitter>
#include <QTabWidget>
#include <QTextEdit>
#include <set>
#include "tools/cabana/binaryview.h"
#include "tools/cabana/chart/chartswidget.h"
#include "tools/cabana/historylog.h"
#include "tools/cabana/signalview.h"
#include "tools/cabana/utils/elidedlabel.h"
class EditMessageDialog : public QDialog {
public:
EditMessageDialog(const MessageId &msg_id, const QString &title, int size, QWidget *parent);
void validateName(const QString &text);
MessageId msg_id;
QString original_name;
QDialogButtonBox *btn_box;
QLineEdit *name_edit;
QLineEdit *node;
QTextEdit *comment_edit;
QLabel *error_label;
QSpinBox *size_spin;
};
class DetailWidget : public QWidget {
Q_OBJECT
public:
DetailWidget(ChartsWidget *charts, QWidget *parent);
void setMessage(const MessageId &message_id);
void refresh();
std::pair<QString, QStringList> serializeMessageIds() const;
void restoreTabs(const QString active_msg_id, const QStringList &msg_ids);
private:
void createToolBar();
int findOrAddTab(const MessageId& message_id);
void showTabBarContextMenu(const QPoint &pt);
void editMsg();
void removeMsg();
void updateState(const std::set<MessageId> *msgs = nullptr);
MessageId msg_id;
QLabel *warning_icon, *warning_label;
ElidedLabel *name_label;
QWidget *warning_widget;
TabBar *tabbar;
QTabWidget *tab_widget;
QAction *action_remove_msg;
LogsWidget *history_log;
BinaryView *binary_view;
SignalView *signal_view;
ChartsWidget *charts;
QSplitter *splitter;
};
class CenterWidget : public QWidget {
Q_OBJECT
public:
CenterWidget(QWidget *parent);
void setMessage(const MessageId &message_id) { ensureDetailWidget()->setMessage(message_id); }
DetailWidget* getDetailWidget() { return detail_widget; }
DetailWidget* ensureDetailWidget();
void clear();
private:
QWidget *createWelcomeWidget();
DetailWidget *detail_widget = nullptr;
QWidget *welcome_widget = nullptr;
};

View File

@@ -0,0 +1,249 @@
#include "tools/cabana/historylog.h"
#include "tools/cabana/dbc/dbcqt.h"
#include <functional>
#include <QFileDialog>
#include <QPainter>
#include <QVBoxLayout>
#include "tools/cabana/commands.h"
#include "tools/cabana/utils/export.h"
QVariant HistoryLogModel::data(const QModelIndex &index, int role) const {
const auto &m = messages[index.row()];
const int col = index.column();
if (role == Qt::DisplayRole) {
if (col == 0) return QString::number(can->toSeconds(m.mono_time), 'f', 3);
if (!isHexMode()) return QString::fromStdString(sigs[col - 1]->formatValue(m.sig_values[col - 1], false));
} else if (role == Qt::TextAlignmentRole) {
return (uint32_t)(Qt::AlignRight | Qt::AlignVCenter);
}
if (isHexMode() && col == 1) {
if (role == ColorsRole) return QVariant::fromValue((void *)(&m.colors));
if (role == BytesRole) return QVariant::fromValue((void *)(&m.data));
}
return {};
}
void HistoryLogModel::setMessage(const MessageId &message_id) {
msg_id = message_id;
reset();
}
void HistoryLogModel::reset() {
beginResetModel();
sigs.clear();
if (auto dbc_msg = dbc()->msg(msg_id)) {
sigs = dbc_msg->getSignals();
}
messages.clear();
hex_colors = {};
endResetModel();
setFilter(0, "", nullptr);
}
QVariant HistoryLogModel::headerData(int section, Qt::Orientation orientation, int role) const {
if (orientation == Qt::Horizontal) {
if (role == Qt::DisplayRole || role == Qt::ToolTipRole) {
if (section == 0) return "Time";
if (isHexMode()) return "Data";
QString name = QString::fromStdString(sigs[section - 1]->name);
QString unit = QString::fromStdString(sigs[section - 1]->unit);
return unit.isEmpty() ? name : QString("%1 (%2)").arg(name, unit);
} else if (role == Qt::BackgroundRole && section > 0 && !isHexMode()) {
// Alpha-blend the signal color with the background to ensure contrast
QColor sigColor = toQColor(sigs[section - 1]->color);
sigColor.setAlpha(128);
return QBrush(sigColor);
}
}
return {};
}
void HistoryLogModel::setHexMode(bool hex) {
hex_mode = hex;
reset();
}
void HistoryLogModel::setFilter(int sig_idx, const QString &value, std::function<bool(double, double)> cmp) {
filter_sig_idx = sig_idx;
filter_value = value.toDouble();
filter_cmp = value.isEmpty() ? nullptr : cmp;
updateState(true);
}
void HistoryLogModel::updateState(bool clear) {
if (clear && !messages.empty()) {
beginRemoveRows({}, 0, messages.size() - 1);
messages.clear();
endRemoveRows();
}
uint64_t current_time = can->toMonoTime(can->lastMessage(msg_id).ts) + 1;
fetchData(messages.begin(), current_time, messages.empty() ? 0 : messages.front().mono_time);
}
bool HistoryLogModel::canFetchMore(const QModelIndex &parent) const {
const auto &events = can->events(msg_id);
return !events.empty() && !messages.empty() && messages.back().mono_time > events.front()->mono_time;
}
void HistoryLogModel::fetchMore(const QModelIndex &parent) {
if (!messages.empty())
fetchData(messages.end(), messages.back().mono_time, 0);
}
void HistoryLogModel::fetchData(std::deque<Message>::iterator insert_pos, uint64_t from_time, uint64_t min_time) {
const auto &events = can->events(msg_id);
auto first = std::upper_bound(events.rbegin(), events.rend(), from_time, [](uint64_t ts, auto e) {
return ts > e->mono_time;
});
std::vector<HistoryLogModel::Message> msgs;
std::vector<double> values(sigs.size());
msgs.reserve(batch_size);
for (; first != events.rend() && (*first)->mono_time > min_time; ++first) {
const CanEvent *e = *first;
for (int i = 0; i < sigs.size(); ++i) {
sigs[i]->getValue(e->dat, e->size, &values[i]);
}
if (!filter_cmp || filter_cmp(values[filter_sig_idx], filter_value)) {
msgs.emplace_back(Message{e->mono_time, values, {e->dat, e->dat + e->size}});
if (msgs.size() >= batch_size && min_time == 0) {
break;
}
}
}
if (!msgs.empty()) {
if (isHexMode() && (min_time > 0 || messages.empty())) {
const auto freq = can->lastMessage(msg_id).freq;
const std::vector<uint8_t> no_mask;
for (auto &m : msgs) {
hex_colors.compute(msg_id, m.data.data(), m.data.size(), m.mono_time / (double)1e9, can->getSpeed(), no_mask, freq);
m.colors = hex_colors.colors;
}
}
int pos = std::distance(messages.begin(), insert_pos);
beginInsertRows({}, pos , pos + msgs.size() - 1);
messages.insert(insert_pos, std::move_iterator(msgs.begin()), std::move_iterator(msgs.end()));
endInsertRows();
}
}
// HeaderView
QSize HeaderView::sectionSizeFromContents(int logicalIndex) const {
static const QSize time_col_size = fontMetrics().size(Qt::TextSingleLine, "000000.000") + QSize(10, 6);
if (logicalIndex == 0) {
return time_col_size;
} else {
int default_size = qMax(100, (rect().width() - time_col_size.width()) / (model()->columnCount() - 1));
QString text = model()->headerData(logicalIndex, this->orientation(), Qt::DisplayRole).toString();
const QRect rect = fontMetrics().boundingRect({0, 0, default_size, 2000}, defaultAlignment(), text.replace(QChar('_'), ' '));
QSize size = rect.size() + QSize{10, 6};
return QSize{qMax(size.width(), default_size), size.height()};
}
}
void HeaderView::paintSection(QPainter *painter, const QRect &rect, int logicalIndex) const {
auto bg_role = model()->headerData(logicalIndex, Qt::Horizontal, Qt::BackgroundRole);
if (bg_role.isValid()) {
painter->fillRect(rect, bg_role.value<QBrush>());
}
QString text = model()->headerData(logicalIndex, Qt::Horizontal, Qt::DisplayRole).toString();
painter->setPen(palette().color(utils::isDarkTheme() ? QPalette::BrightText : QPalette::Text));
painter->drawText(rect.adjusted(5, 3, -5, -3), defaultAlignment(), text.replace(QChar('_'), ' '));
}
// LogsWidget
LogsWidget::LogsWidget(QWidget *parent) : QFrame(parent) {
setFrameStyle(QFrame::StyledPanel | QFrame::Plain);
QVBoxLayout *main_layout = new QVBoxLayout(this);
main_layout->setContentsMargins(0, 0, 0, 0);
main_layout->setSpacing(0);
QWidget *toolbar = new QWidget(this);
toolbar->setAutoFillBackground(true);
QHBoxLayout *h = new QHBoxLayout(toolbar);
filters_widget = new QWidget(this);
QHBoxLayout *filter_layout = new QHBoxLayout(filters_widget);
filter_layout->setContentsMargins(0, 0, 0, 0);
filter_layout->addWidget(display_type_cb = new QComboBox(this));
filter_layout->addWidget(signals_cb = new QComboBox(this));
filter_layout->addWidget(comp_box = new QComboBox(this));
filter_layout->addWidget(value_edit = new QLineEdit(this));
h->addWidget(filters_widget);
h->addStretch(0);
export_btn = new ToolButton("filetype-csv", tr("Export to CSV file..."));
h->addWidget(export_btn, 0, Qt::AlignRight);
display_type_cb->addItems({"Signal", "Hex"});
display_type_cb->setToolTip(tr("Display signal value or raw hex value"));
comp_box->addItems({">", "=", "!=", "<"});
value_edit->setClearButtonEnabled(true);
value_edit->setValidator(new DoubleValidator(this));
main_layout->addWidget(toolbar);
QFrame *line = new QFrame(this);
line->setFrameStyle(QFrame::HLine | QFrame::Sunken);
main_layout->addWidget(line);
main_layout->addWidget(logs = new QTableView(this));
logs->setModel(model = new HistoryLogModel(this));
logs->setItemDelegate(delegate = new MessageBytesDelegate(this));
logs->setHorizontalHeader(new HeaderView(Qt::Horizontal, this));
logs->horizontalHeader()->setDefaultAlignment(Qt::AlignRight | (Qt::Alignment)Qt::TextWordWrap);
logs->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents);
logs->verticalHeader()->setSectionResizeMode(QHeaderView::Fixed);
logs->verticalHeader()->setDefaultSectionSize(delegate->sizeForBytes(8).height());
logs->setFrameShape(QFrame::NoFrame);
QObject::connect(display_type_cb, qOverload<int>(&QComboBox::activated), model, &HistoryLogModel::setHexMode);
QObject::connect(signals_cb, SIGNAL(activated(int)), this, SLOT(filterChanged()));
QObject::connect(comp_box, SIGNAL(activated(int)), this, SLOT(filterChanged()));
QObject::connect(value_edit, &QLineEdit::textEdited, this, &LogsWidget::filterChanged);
QObject::connect(export_btn, &QToolButton::clicked, this, &LogsWidget::exportToCSV);
QObject::connect(can, &AbstractStream::seekedTo, model, &HistoryLogModel::reset);
QObject::connect(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, model, &HistoryLogModel::reset);
QObject::connect(undoNotifier(), &QtUndoNotifier::indexChanged, model, &HistoryLogModel::reset);
QObject::connect(model, &HistoryLogModel::modelReset, this, &LogsWidget::modelReset);
QObject::connect(model, &HistoryLogModel::rowsInserted, [this]() { export_btn->setEnabled(true); });
}
void LogsWidget::modelReset() {
signals_cb->clear();
for (auto s : model->sigs) {
signals_cb->addItem(QString::fromStdString(s->name));
}
export_btn->setEnabled(false);
value_edit->clear();
comp_box->setCurrentIndex(0);
filters_widget->setVisible(!model->sigs.empty());
}
void LogsWidget::filterChanged() {
if (value_edit->text().isEmpty() && !value_edit->isModified()) return;
std::function<bool(double, double)> cmp = nullptr;
switch (comp_box->currentIndex()) {
case 0: cmp = std::greater<double>{}; break;
case 1: cmp = std::equal_to<double>{}; break;
case 2: cmp = [](double l, double r) { return l != r; }; break; // not equal
case 3: cmp = std::less<double>{}; break;
}
model->setFilter(signals_cb->currentIndex(), value_edit->text(), cmp);
}
void LogsWidget::exportToCSV() {
QString dir = QString("%1/%2_%3.csv").arg(QString::fromStdString(settings.last_dir)).arg(QString::fromStdString(can->routeName())).arg(QString::fromStdString(msgName(model->msg_id)));
QString fn = QFileDialog::getSaveFileName(this, QString("Export %1 to CSV file").arg(QString::fromStdString(msgName(model->msg_id))),
dir, tr("csv (*.csv)"));
if (!fn.isEmpty()) {
model->isHexMode() ? utils::exportToCSV(fn.toStdString(), model->msg_id)
: utils::exportSignalsToCSV(fn.toStdString(), model->msg_id);
}
}

View File

@@ -0,0 +1,81 @@
#pragma once
#include <deque>
#include <vector>
#include <QComboBox>
#include <QHeaderView>
#include <QLineEdit>
#include <QTableView>
#include "tools/cabana/dbc/dbcmanager.h"
#include "tools/cabana/streams/abstractstream.h"
class HeaderView : public QHeaderView {
public:
HeaderView(Qt::Orientation orientation, QWidget *parent = nullptr) : QHeaderView(orientation, parent) {}
QSize sectionSizeFromContents(int logicalIndex) const override;
void paintSection(QPainter *painter, const QRect &rect, int logicalIndex) const;
};
class HistoryLogModel : public QAbstractTableModel {
Q_OBJECT
public:
HistoryLogModel(QObject *parent) : QAbstractTableModel(parent) {}
void setMessage(const MessageId &message_id);
void updateState(bool clear = false);
void setFilter(int sig_idx, const QString &value, std::function<bool(double, double)> cmp);
QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override;
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
void fetchMore(const QModelIndex &parent) override;
bool canFetchMore(const QModelIndex &parent) const override;
int rowCount(const QModelIndex &parent = QModelIndex()) const override { return messages.size(); }
int columnCount(const QModelIndex &parent = QModelIndex()) const override { return !isHexMode() ? sigs.size() + 1 : 2; }
inline bool isHexMode() const { return sigs.empty() || hex_mode; }
void reset();
void setHexMode(bool hex_mode);
struct Message {
uint64_t mono_time = 0;
std::vector<double> sig_values;
std::vector<uint8_t> data;
std::vector<CabanaColor> colors;
};
void fetchData(std::deque<Message>::iterator insert_pos, uint64_t from_time, uint64_t min_time);
MessageId msg_id;
CanData hex_colors;
const int batch_size = 50;
int filter_sig_idx = -1;
double filter_value = 0;
std::function<bool(double, double)> filter_cmp = nullptr;
std::deque<Message> messages;
std::vector<cabana::Signal *> sigs;
bool hex_mode = false;
};
class LogsWidget : public QFrame {
Q_OBJECT
public:
LogsWidget(QWidget *parent);
void setMessage(const MessageId &message_id) { model->setMessage(message_id); }
void updateState() { model->updateState(); }
void showEvent(QShowEvent *event) override { model->updateState(true); }
private slots:
void filterChanged();
void exportToCSV();
void modelReset();
private:
QTableView *logs;
HistoryLogModel *model;
QComboBox *signals_cb, *comp_box, *display_type_cb;
QLineEdit *value_edit;
QWidget *filters_widget;
ToolButton *export_btn;
MessageBytesDelegate *delegate;
};

View File

@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""
konn3kt_canproxy — view a remote device's live CAN in Cabana, through konn3kt.
Run this on your laptop. It connects to the konn3kt CAN stream for a device you have
access to and re-publishes the raw capnp frames onto a LOCAL ZMQ "can" socket. Then you
just open Cabana → Live → Device, pick ZMQ, and enter 127.0.0.1 — exactly the local
workflow, but the frames are coming from a device across the internet.
Flow:
device canlived ──ws──> konn3kt relay ──ws──> THIS PROXY ──zmq──> Cabana (127.0.0.1)
The proxy never parses the CAN bytes; it forwards the exact capnp Event frames the device
produced, so Cabana decodes them identically to a local ZMQ bridge.
Auth: you authenticate as yourself (a konn3kt user JWT), not as the device. Get your JWT
from the konn3kt app/web session and pass it via --token or the KONN3KT_JWT env var.
Access is enforced server-side (owner-only, or a superuser with an explicit owner grant).
Usage:
export KONN3KT_JWT="<your konn3kt jwt>"
./konn3kt_canproxy.py <dongle_id>
# then in Cabana: Live → Device → ZMQ → 127.0.0.1
Requires the iqpilot/openpilot Python env (for cereal.messaging) and websocket-client.
"""
import argparse
import os
import sys
import time
from websocket import create_connection, ABNF, WebSocketException
# Force cereal messaging onto ZMQ so the local "can" publisher binds a TCP port that
# Cabana's ZMQ Device stream connects to. Must be set before importing messaging.
os.environ["ZMQ"] = "1"
import iqpilot.cereal.messaging as messaging # noqa: E402
DEFAULT_HOST = os.getenv("KONN3KT_HOST", "wss://api-iqlabs.konn3kt.com")
RECONNECT_MIN = 1.0
RECONNECT_MAX = 10.0
def _ws_host(host: str) -> str:
host = host.rstrip("/")
if host.startswith("https://"):
return "wss://" + host[len("https://"):]
if host.startswith("http://"):
return "ws://" + host[len("http://"):]
if host.startswith(("ws://", "wss://")):
return host
return "wss://" + host
def run(dongle_id: str, host: str, token: str) -> None:
ws_uri = f"{_ws_host(host)}/v1/devices/{dongle_id}/can-stream?sig={token}"
# Local ZMQ publisher for "can"; Cabana subscribes to this on 127.0.0.1.
pub = messaging.pub_sock("can")
backoff = RECONNECT_MIN
while True:
try:
print(f"[canproxy] connecting to konn3kt for {dongle_id} ...", file=sys.stderr)
ws = create_connection(ws_uri, enable_multithread=True, timeout=30.0)
print("[canproxy] connected. Open Cabana → Live → Device → ZMQ → 127.0.0.1", file=sys.stderr)
backoff = RECONNECT_MIN
n = 0
while True:
opcode, data = ws.recv_data(control_frame=True)
if opcode == ABNF.OPCODE_BINARY:
# Republish the exact capnp Event bytes locally.
pub.send(data)
n += 1
if n % 1000 == 0:
print(f"[canproxy] forwarded {n} frames", file=sys.stderr)
elif opcode == ABNF.OPCODE_CLOSE:
print("[canproxy] server closed the stream", file=sys.stderr)
break
elif opcode == ABNF.OPCODE_PING:
ws.pong(data)
except (WebSocketException, OSError) as e:
print(f"[canproxy] disconnected: {e}; reconnecting in {backoff:.0f}s", file=sys.stderr)
time.sleep(backoff)
backoff = min(backoff * 2, RECONNECT_MAX)
finally:
try:
ws.close()
except Exception:
pass
def main() -> None:
ap = argparse.ArgumentParser(description="Bridge a konn3kt remote CAN stream into local ZMQ for Cabana.")
ap.add_argument("dongle_id", help="dongle id of the device to stream")
ap.add_argument("--host", default=DEFAULT_HOST, help=f"konn3kt host (default: {DEFAULT_HOST})")
ap.add_argument("--token", default=os.getenv("KONN3KT_JWT"),
help="konn3kt user JWT (or set KONN3KT_JWT)")
args = ap.parse_args()
if not args.token:
ap.error("no token: pass --token or set KONN3KT_JWT")
try:
run(args.dongle_id, args.host, args.token)
except KeyboardInterrupt:
print("\n[canproxy] bye", file=sys.stderr)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,741 @@
#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 "third_party/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"
// IQ.Pilot patch: iqpilot has no py_downloader; installDownloadProgressHandler still
// lives in replay/util.h here.
#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_iqdbc_menu = file_menu->addMenu(tr("Load DBC from commaai/iqdbc"));
// load_iqdbc_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_iqdbc_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();
}

View File

@@ -0,0 +1,119 @@
#pragma once
#include <QDockWidget>
#include <QMainWindow>
#include <QMenu>
#include <QProgressBar>
#include <QSplitter>
#include <QStatusBar>
#include <cstdint>
#include <set>
#include <string>
#include <unordered_map>
#include <vector>
#include "tools/cabana/chart/chartswidget.h"
#include "tools/cabana/dbc/dbcmanager.h"
#include "tools/cabana/detailwidget.h"
#include "tools/cabana/messageswidget.h"
#include "tools/cabana/videowidget.h"
#include "tools/cabana/tools/findsimilarbits.h"
class MainWindow : public QMainWindow {
Q_OBJECT
public:
MainWindow(AbstractStream *stream, const QString &dbc_file);
void toggleChartsDocking();
void showStatusMessage(const QString &msg, int timeout = 0) { statusBar()->showMessage(msg, timeout); }
void loadFile(const QString &fn, SourceSet s = SOURCE_ALL);
ChartsWidget *charts_widget = nullptr;
public slots:
void selectAndOpenStream();
void openStream(AbstractStream *stream, const QString &dbc_file = {});
void closeStream();
void exportToCSV();
void newFile(SourceSet s = SOURCE_ALL);
void openFile(SourceSet s = SOURCE_ALL);
void loadDBCFromOpendbc(const QString &name);
void save();
void saveAs();
void saveToClipboard();
signals:
void showMessage(const QString &msg, int timeout);
void updateProgressBar(uint64_t cur, uint64_t total, bool success);
protected:
void startStream(AbstractStream *stream, QString dbc_file);
bool eventFilter(QObject *obj, QEvent *event) override;
void remindSaveChanges();
void closeFile(SourceSet s = SOURCE_ALL);
void closeFile(DBCFile *dbc_file);
void saveFile(DBCFile *dbc_file);
void saveFileAs(DBCFile *dbc_file);
void saveFileToClipboard(DBCFile *dbc_file);
void loadFingerprints();
void loadFromClipboard(SourceSet s = SOURCE_ALL, bool close_all = true);
void updateRecentFiles(const QString &fn);
void updateRecentFileMenu();
void createActions();
void createDockWindows();
void createStatusBar();
void createShortcuts();
void closeEvent(QCloseEvent *event) override;
void DBCFileChanged();
void updateDownloadProgress(uint64_t cur, uint64_t total, bool success);
void setOption();
void findSimilarBits();
void findSignal();
void undoStackCleanChanged(bool clean);
void updateUndoRedoActions();
void onlineHelp();
void toggleFullScreen();
void updateStatus();
void updateLoadSaveMenus();
void createDockWidgets();
void eventsMerged();
void saveSessionState();
void restoreSessionState();
VideoWidget *video_widget = nullptr;
QDockWidget *video_dock;
QDockWidget *messages_dock;
MessagesWidget *messages_widget = nullptr;
CenterWidget *center_widget;
QWidget *floating_window = nullptr;
QVBoxLayout *charts_layout;
QProgressBar *progress_bar;
QLabel *status_label;
std::unordered_map<std::string, std::string> fingerprint_to_dbc;
QSplitter *video_splitter = nullptr;
enum { MAX_RECENT_FILES = 15 };
QMenu *open_recent_menu = nullptr;
QMenu *manage_dbcs_menu = nullptr;
QMenu *tools_menu = nullptr;
QAction *close_stream_act = nullptr;
QAction *export_to_csv_act = nullptr;
QAction *save_dbc = nullptr;
QAction *save_dbc_as = nullptr;
QAction *copy_dbc_to_clipboard = nullptr;
QAction *undo_act = nullptr;
QAction *redo_act = nullptr;
QString car_fingerprint;
std::vector<uint8_t> default_state;
};
class HelpOverlay : public QWidget {
Q_OBJECT
public:
HelpOverlay(MainWindow *parent);
protected:
void drawHelpForWidget(QPainter &painter, QWidget *w);
void paintEvent(QPaintEvent *event) override;
void mouseReleaseEvent(QMouseEvent *event) override;
bool eventFilter(QObject *obj, QEvent *event) override;
};

View File

@@ -0,0 +1,465 @@
#include "tools/cabana/messageswidget.h"
#include "tools/cabana/dbc/dbcqt.h"
#include <limits>
#include <utility>
#include <QCheckBox>
#include <QHBoxLayout>
#include <QPainter>
#include <QPalette>
#include <QPushButton>
#include <QScrollBar>
#include <QVBoxLayout>
#include "tools/cabana/commands.h"
MessagesWidget::MessagesWidget(QWidget *parent) : menu(new QMenu(this)), QWidget(parent) {
QVBoxLayout *main_layout = new QVBoxLayout(this);
main_layout->setContentsMargins(0, 0, 0, 0);
// toolbar
main_layout->addWidget(createToolBar());
// message table
main_layout->addWidget(view = new MessageView(this));
view->setItemDelegate(delegate = new MessageBytesDelegate(view, settings.multiple_lines_hex));
view->setModel(model = new MessageListModel(this));
view->setHeader(header = new MessageViewHeader(this));
view->setSortingEnabled(true);
view->sortByColumn(MessageListModel::Column::NAME, Qt::AscendingOrder);
view->setAllColumnsShowFocus(true);
view->setEditTriggers(QAbstractItemView::NoEditTriggers);
view->setItemsExpandable(false);
view->setIndentation(0);
view->setRootIsDecorated(false);
// Must be called before setting any header parameters to avoid overriding
restoreHeaderState(settings.message_header_state);
header->setSectionsMovable(true);
header->setSectionResizeMode(MessageListModel::Column::DATA, QHeaderView::Fixed);
header->setStretchLastSection(true);
header->setContextMenuPolicy(Qt::CustomContextMenu);
// signals/slots
QObject::connect(menu, &QMenu::aboutToShow, this, &MessagesWidget::menuAboutToShow);
QObject::connect(header, &MessageViewHeader::customContextMenuRequested, this, &MessagesWidget::headerContextMenuEvent);
QObject::connect(view->horizontalScrollBar(), &QScrollBar::valueChanged, header, &MessageViewHeader::updateHeaderPositions);
QObject::connect(can, &AbstractStream::msgsReceived, model, &MessageListModel::msgsReceived);
QObject::connect(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, model, &MessageListModel::dbcModified);
QObject::connect(undoNotifier(), &QtUndoNotifier::indexChanged, model, &MessageListModel::dbcModified);
QObject::connect(model, &MessageListModel::modelReset, [this]() {
if (current_msg_id) {
selectMessage(*current_msg_id);
}
view->updateBytesSectionSize();
updateTitle();
});
QObject::connect(view->selectionModel(), &QItemSelectionModel::currentChanged, [=](const QModelIndex &current, const QModelIndex &previous) {
if (current.isValid() && current.row() < model->items_.size()) {
const auto &id = model->items_[current.row()].id;
if (!current_msg_id || id != *current_msg_id) {
current_msg_id = id;
emit msgSelectionChanged(*current_msg_id);
}
}
});
setWhatsThis(tr(R"(
<b>Message View</b><br/>
<!-- TODO: add description here -->
<span style="color:gray">Byte color</span><br />
<span style="color:gray;"> </span> constant changing<br />
<span style="color:blue;"> </span> increasing<br />
<span style="color:red;"> </span> decreasing<br />
<span style="color:gray">Shortcuts</span><br />
Horizontal Scrolling: <span style="background-color:lightGray;color:gray">&nbsp;shift+wheel&nbsp;</span>
)"));
}
QWidget *MessagesWidget::createToolBar() {
QWidget *toolbar = new QWidget(this);
QHBoxLayout *layout = new QHBoxLayout(toolbar);
layout->setContentsMargins(0, 9, 0, 0);
layout->addWidget(suppress_add = new QPushButton("Suppress Highlighted"));
layout->addWidget(suppress_clear = new QPushButton());
suppress_clear->setToolTip(tr("Clear suppressed"));
layout->addStretch(1);
QCheckBox *suppress_defined_signals = new QCheckBox(tr("Suppress Signals"), this);
suppress_defined_signals->setToolTip(tr("Suppress defined signals"));
suppress_defined_signals->setChecked(settings.suppress_defined_signals);
layout->addWidget(suppress_defined_signals);
auto view_button = new ToolButton("three-dots", tr("View..."));
view_button->setMenu(menu);
view_button->setPopupMode(QToolButton::InstantPopup);
view_button->setStyleSheet("QToolButton::menu-indicator { image: none; }");
layout->addWidget(view_button);
QObject::connect(suppress_add, &QPushButton::clicked, this, &MessagesWidget::suppressHighlighted);
QObject::connect(suppress_clear, &QPushButton::clicked, this, &MessagesWidget::suppressHighlighted);
QObject::connect(suppress_defined_signals, &QCheckBox::stateChanged, can, &AbstractStream::suppressDefinedSignals);
suppressHighlighted();
return toolbar;
}
void MessagesWidget::updateTitle() {
auto stats = std::accumulate(
model->items_.begin(), model->items_.end(), std::pair<size_t, size_t>(),
[](const auto &pair, const auto &item) {
auto m = dbc()->msg(item.id);
return m ? std::make_pair(pair.first + 1, pair.second + m->sigs.size()) : pair;
});
emit titleChanged(tr("%1 Messages (%2 DBC Messages, %3 Signals)")
.arg(model->items_.size()).arg(stats.first).arg(stats.second));
}
void MessagesWidget::selectMessage(const MessageId &msg_id) {
auto it = std::find_if(model->items_.cbegin(), model->items_.cend(),
[&msg_id](auto &item) { return item.id == msg_id; });
if (it != model->items_.cend()) {
view->setCurrentIndex(model->index(std::distance(model->items_.cbegin(), it), 0));
}
}
void MessagesWidget::suppressHighlighted() {
int n = sender() == suppress_add ? can->suppressHighlighted() : (can->clearSuppressed(), 0);
suppress_clear->setText(n > 0 ? tr("Clear (%1)").arg(n) : tr("Clear"));
suppress_clear->setEnabled(n > 0);
}
void MessagesWidget::headerContextMenuEvent(const QPoint &pos) {
menu->exec(header->mapToGlobal(pos));
}
void MessagesWidget::menuAboutToShow() {
menu->clear();
for (int i = 0; i < header->count(); ++i) {
int logical_index = header->logicalIndex(i);
auto action = menu->addAction(model->headerData(logical_index, Qt::Horizontal).toString(),
[=](bool checked) { header->setSectionHidden(logical_index, !checked); });
action->setCheckable(true);
action->setChecked(!header->isSectionHidden(logical_index));
// Can't hide the name column
action->setEnabled(logical_index > 0);
}
menu->addSeparator();
auto action = menu->addAction(tr("Multi-Line bytes"), this, &MessagesWidget::setMultiLineBytes);
action->setCheckable(true);
action->setChecked(settings.multiple_lines_hex);
action = menu->addAction(tr("Show inactive messages"), model, &MessageListModel::showInactiveMessages);
action->setCheckable(true);
action->setChecked(model->show_inactive_messages);
}
void MessagesWidget::setMultiLineBytes(bool multi) {
settings.multiple_lines_hex = multi;
delegate->setMultipleLines(multi);
view->updateBytesSectionSize();
view->doItemsLayout();
}
// MessageListModel
QVariant MessageListModel::headerData(int section, Qt::Orientation orientation, int role) const {
if (orientation == Qt::Horizontal && role == Qt::DisplayRole) {
switch (section) {
case Column::NAME: return tr("Name");
case Column::SOURCE: return tr("Bus");
case Column::ADDRESS: return tr("ID");
case Column::NODE: return tr("Node");
case Column::FREQ: return tr("Freq");
case Column::COUNT: return tr("Count");
case Column::DATA: return tr("Bytes");
}
}
return {};
}
QVariant MessageListModel::data(const QModelIndex &index, int role) const {
if (!index.isValid() || index.row() >= items_.size()) return {};
auto getFreq = [](float freq) {
if (freq > 0) {
return freq >= 0.95 ? QString::number(std::nearbyint(freq)) : QString::number(freq, 'f', 2);
} else {
return QStringLiteral("--");
}
};
const static QString NA = QStringLiteral("N/A");
const auto &item = items_[index.row()];
if (role == Qt::DisplayRole) {
switch (index.column()) {
case Column::NAME: return item.name;
case Column::SOURCE: return item.id.source != INVALID_SOURCE ? QString::number(item.id.source) : NA;
case Column::ADDRESS: return toHexString(item.id.address);
case Column::NODE: return item.node;
case Column::FREQ: return item.id.source != INVALID_SOURCE ? getFreq(can->lastMessage(item.id).freq) : NA;
case Column::COUNT: return item.id.source != INVALID_SOURCE ? QString::number(can->lastMessage(item.id).count) : NA;
case Column::DATA: return item.id.source != INVALID_SOURCE ? "" : NA;
}
} else if (role == ColorsRole) {
return QVariant::fromValue((void*)(&can->lastMessage(item.id).colors));
} else if (role == BytesRole && index.column() == Column::DATA && item.id.source != INVALID_SOURCE) {
return QVariant::fromValue((void*)(&can->lastMessage(item.id).dat));
} else if (role == Qt::ToolTipRole && index.column() == Column::NAME) {
auto msg = dbc()->msg(item.id);
auto tooltip = item.name;
if (msg && !msg->comment.empty()) tooltip += "<br /><span style=\"color:gray;\">" + QString::fromStdString(msg->comment) + "</span>";
return tooltip;
}
return {};
}
void MessageListModel::setFilterStrings(const std::map<int, QString> &filters) {
filters_ = filters;
filterAndSort();
}
void MessageListModel::showInactiveMessages(bool show) {
show_inactive_messages = show;
filterAndSort();
}
void MessageListModel::dbcModified() {
dbc_messages_.clear();
for (const auto &[_, m] : dbc()->getMessages(-1)) {
dbc_messages_.insert(MessageId{.source = INVALID_SOURCE, .address = m.address});
}
filterAndSort();
}
void MessageListModel::sortItems(std::vector<MessageListModel::Item> &items) {
auto compare = [this](const auto &l, const auto &r) {
switch (sort_column) {
case Column::NAME: return std::tie(l.name, l.id) < std::tie(r.name, r.id);
case Column::SOURCE: return std::tie(l.id.source, l.id.address) < std::tie(r.id.source, r.id.address);
case Column::ADDRESS: return std::tie(l.id.address, l.id.source) < std::tie(r.id.address, r.id.source);
case Column::NODE: return std::tie(l.node, l.id) < std::tie(r.node, r.id);
case Column::FREQ: return std::tie(can->lastMessage(l.id).freq, l.id) < std::tie(can->lastMessage(r.id).freq, r.id);
case Column::COUNT: return std::tie(can->lastMessage(l.id).count, l.id) < std::tie(can->lastMessage(r.id).count, r.id);
default: return false; // Default case to suppress compiler warning
}
};
if (sort_order == Qt::DescendingOrder)
std::stable_sort(items.rbegin(), items.rend(), compare);
else
std::stable_sort(items.begin(), items.end(), compare);
}
static bool parseRange(const QString &filter, uint32_t value, int base = 10) {
// Parse out filter string into a range (e.g. "1" -> {1, 1}, "1-3" -> {1, 3}, "1-" -> {1, inf})
unsigned int min = std::numeric_limits<unsigned int>::min();
unsigned int max = std::numeric_limits<unsigned int>::max();
auto s = filter.split('-');
bool ok = s.size() >= 1 && s.size() <= 2;
if (ok && !s[0].isEmpty()) min = s[0].toUInt(&ok, base);
if (ok && s.size() == 1) {
max = min;
} else if (ok && s.size() == 2 && !s[1].isEmpty()) {
max = s[1].toUInt(&ok, base);
}
return ok && value >= min && value <= max;
}
bool MessageListModel::match(const MessageListModel::Item &item) {
if (filters_.empty())
return true;
bool match = true;
const auto &data = can->lastMessage(item.id);
for (auto it = filters_.cbegin(); it != filters_.cend() && match; ++it) {
const QString &txt = it->second;
switch (it->first) {
case Column::NAME: {
match = item.name.contains(txt, Qt::CaseInsensitive);
if (!match) {
const auto m = dbc()->msg(item.id);
match = m && std::any_of(m->sigs.cbegin(), m->sigs.cend(),
[&txt](const auto &s) { return QString::fromStdString(s->name).contains(txt, Qt::CaseInsensitive); });
}
break;
}
case Column::SOURCE:
match = parseRange(txt, item.id.source);
break;
case Column::ADDRESS:
match = toHexString(item.id.address).contains(txt, Qt::CaseInsensitive);
match = match || parseRange(txt, item.id.address, 16);
break;
case Column::NODE:
match = item.node.contains(txt, Qt::CaseInsensitive);
break;
case Column::FREQ:
match = parseRange(txt, data.freq);
break;
case Column::COUNT:
match = parseRange(txt, data.count);
break;
case Column::DATA:
match = utils::toHex(data.dat).contains(txt, Qt::CaseInsensitive);
break;
}
}
return match;
}
bool MessageListModel::filterAndSort() {
// merge CAN and DBC messages
std::vector<MessageId> all_messages;
all_messages.reserve(can->lastMessages().size() + dbc_messages_.size());
auto dbc_msgs = dbc_messages_;
for (const auto &[id, m] : can->lastMessages()) {
all_messages.push_back(id);
dbc_msgs.erase(MessageId{.source = INVALID_SOURCE, .address = id.address});
}
all_messages.insert(all_messages.end(), dbc_msgs.begin(), dbc_msgs.end());
// filter and sort
std::vector<Item> items;
items.reserve(all_messages.size());
for (const auto &id : all_messages) {
if (show_inactive_messages || can->isMessageActive(id)) {
auto msg = dbc()->msg(id);
Item item = {.id = id,
.name = msg ? QString::fromStdString(msg->name) : QString::fromStdString(UNTITLED),
.node = msg ? QString::fromStdString(msg->transmitter) : QString()};
if (match(item))
items.emplace_back(item);
}
}
sortItems(items);
if (items_ != items) {
beginResetModel();
items_ = std::move(items);
endResetModel();
return true;
}
return false;
}
void MessageListModel::msgsReceived(const std::set<MessageId> *new_msgs, bool has_new_ids) {
if (has_new_ids || ((filters_.count(Column::FREQ) || filters_.count(Column::COUNT) || filters_.count(Column::DATA)) &&
++sort_threshold_ == settings.fps)) {
sort_threshold_ = 0;
if (filterAndSort()) return;
}
// Update viewport
emit dataChanged(index(0, 0), index(rowCount() - 1, columnCount() - 1));
}
void MessageListModel::sort(int column, Qt::SortOrder order) {
if (column != Column::DATA) {
sort_column = column;
sort_order = order;
filterAndSort();
}
}
// MessageView
void MessageView::drawRow(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const {
const auto &item = ((MessageListModel*)model())->items_[index.row()];
if (!can->isMessageActive(item.id)) {
QStyleOptionViewItem custom_option = option;
custom_option.palette.setBrush(QPalette::Text, custom_option.palette.color(QPalette::Disabled, QPalette::Text));
auto color = QApplication::palette().color(QPalette::HighlightedText);
color.setAlpha(100);
custom_option.palette.setBrush(QPalette::HighlightedText, color);
QTreeView::drawRow(painter, custom_option, index);
} else {
QTreeView::drawRow(painter, option, index);
}
QPen oldPen = painter->pen();
const int gridHint = style()->styleHint(QStyle::SH_Table_GridLineColor, &option, this);
painter->setPen(QColor::fromRgba(static_cast<QRgb>(gridHint)));
// Draw bottom border for the row
painter->drawLine(option.rect.bottomLeft(), option.rect.bottomRight());
// Draw vertical borders for each column
for (int i = 0; i < header()->count(); ++i) {
int sectionX = header()->sectionViewportPosition(i);
painter->drawLine(sectionX, option.rect.top(), sectionX, option.rect.bottom());
}
painter->setPen(oldPen);
}
void MessageView::setModel(QAbstractItemModel *model) {
QTreeView::setModel(model);
// Bypass the slow call to QTreeView::dataChanged.
// QTreeView::dataChanged will invalidate the height cache and that's what we don't need in MessageView.
QObject::disconnect(model, &QAbstractItemModel::dataChanged, this, nullptr);
QObject::connect(model, &QAbstractItemModel::dataChanged, this,
[this](const QModelIndex &tl, const QModelIndex &br, const auto &roles) { QAbstractItemView::dataChanged(tl, br, roles); });
}
void MessageView::updateBytesSectionSize() {
auto delegate = ((MessageBytesDelegate *)itemDelegate());
int max_bytes = 8;
if (!delegate->multipleLines()) {
for (const auto &[_, m] : can->lastMessages()) {
max_bytes = std::max<int>(max_bytes, m.dat.size());
}
}
setUniformRowHeights(!delegate->multipleLines());
header()->resizeSection(MessageListModel::Column::DATA, delegate->sizeForBytes(max_bytes).width());
}
void MessageView::wheelEvent(QWheelEvent *event) {
if (event->modifiers() == Qt::ShiftModifier) {
QApplication::sendEvent(horizontalScrollBar(), event);
} else {
QTreeView::wheelEvent(event);
}
}
// MessageViewHeader
MessageViewHeader::MessageViewHeader(QWidget *parent) : QHeaderView(Qt::Horizontal, parent) {
QObject::connect(this, &QHeaderView::sectionResized, this, &MessageViewHeader::updateHeaderPositions);
QObject::connect(this, &QHeaderView::sectionMoved, this, &MessageViewHeader::updateHeaderPositions);
}
void MessageViewHeader::updateFilters() {
std::map<int, QString> filters;
for (int i = 0; i < (int)editors.size(); i++) {
if (!editors[i]->text().isEmpty()) {
filters[i] = editors[i]->text();
}
}
qobject_cast<MessageListModel*>(model())->setFilterStrings(filters);
}
void MessageViewHeader::updateHeaderPositions() {
QSize sz = QHeaderView::sizeHint();
for (int i = 0; i < (int)editors.size(); i++) {
int h = editors[i]->sizeHint().height();
editors[i]->setGeometry(sectionViewportPosition(i), sz.height(), sectionSize(i), h);
editors[i]->setHidden(isSectionHidden(i));
}
}
void MessageViewHeader::updateGeometries() {
for (int i = (int)editors.size(); i < count(); i++) {
QString column_name = model()->headerData(i, Qt::Horizontal, Qt::DisplayRole).toString();
auto edit = new QLineEdit(this);
edit->setClearButtonEnabled(true);
edit->setPlaceholderText(tr("Filter %1").arg(column_name));
QObject::connect(edit, &QLineEdit::textChanged, this, &MessageViewHeader::updateFilters);
editors.push_back(edit);
}
setViewportMargins(0, 0, 0, !editors.empty() ? editors[0]->sizeHint().height() : 0);
QHeaderView::updateGeometries();
updateHeaderPositions();
}
QSize MessageViewHeader::sizeHint() const {
QSize sz = QHeaderView::sizeHint();
return !editors.empty() ? QSize(sz.width(), sz.height() + editors[0]->height() + 1) : sz;
}

View File

@@ -0,0 +1,128 @@
#pragma once
#include <algorithm>
#include <cstdint>
#include <map>
#include <optional>
#include <set>
#include <vector>
#include <QAbstractTableModel>
#include <QHeaderView>
#include <QLineEdit>
#include <QMenu>
#include <QTreeView>
#include <QWheelEvent>
#include "tools/cabana/dbc/dbcmanager.h"
#include "tools/cabana/streams/abstractstream.h"
class MessageListModel : public QAbstractTableModel {
Q_OBJECT
public:
enum Column {
NAME = 0,
SOURCE,
ADDRESS,
NODE,
FREQ,
COUNT,
DATA,
};
MessageListModel(QObject *parent) : QAbstractTableModel(parent) {}
QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override;
int columnCount(const QModelIndex &parent = QModelIndex()) const override { return Column::DATA + 1; }
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const;
int rowCount(const QModelIndex &parent = QModelIndex()) const override { return items_.size(); }
void sort(int column, Qt::SortOrder order = Qt::AscendingOrder) override;
void setFilterStrings(const std::map<int, QString> &filters);
void showInactiveMessages(bool show);
void msgsReceived(const std::set<MessageId> *new_msgs, bool has_new_ids);
bool filterAndSort();
void dbcModified();
struct Item {
MessageId id;
QString name;
QString node;
bool operator==(const Item &other) const {
return id == other.id && name == other.name && node == other.node;
}
};
std::vector<Item> items_;
bool show_inactive_messages = true;
private:
void sortItems(std::vector<MessageListModel::Item> &items);
bool match(const MessageListModel::Item &id);
std::map<int, QString> filters_;
std::set<MessageId> dbc_messages_;
int sort_column = 0;
Qt::SortOrder sort_order = Qt::AscendingOrder;
int sort_threshold_ = 0;
};
class MessageView : public QTreeView {
Q_OBJECT
public:
MessageView(QWidget *parent) : QTreeView(parent) {}
void updateBytesSectionSize();
void setModel(QAbstractItemModel *model) override;
protected:
void drawRow(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override;
void drawBranches(QPainter *painter, const QRect &rect, const QModelIndex &index) const override {}
void wheelEvent(QWheelEvent *event) override;
};
class MessageViewHeader : public QHeaderView {
// https://stackoverflow.com/a/44346317
Q_OBJECT
public:
MessageViewHeader(QWidget *parent);
void updateHeaderPositions();
void updateGeometries() override;
QSize sizeHint() const override;
void updateFilters();
std::vector<QLineEdit *> editors;
};
class MessagesWidget : public QWidget {
Q_OBJECT
public:
MessagesWidget(QWidget *parent);
void selectMessage(const MessageId &message_id);
std::vector<uint8_t> saveHeaderState() const {
const auto state = view->header()->saveState();
return {state.begin(), state.end()};
}
bool restoreHeaderState(const std::vector<uint8_t> &state) const {
return view->header()->restoreState({(const char *)state.data(), (int)state.size()});
}
void suppressHighlighted();
signals:
void msgSelectionChanged(const MessageId &message_id);
void titleChanged(const QString &title);
protected:
QWidget *createToolBar();
void headerContextMenuEvent(const QPoint &pos);
void menuAboutToShow();
void setMultiLineBytes(bool multi);
void updateTitle();
MessageView *view;
MessageViewHeader *header;
MessageBytesDelegate *delegate;
std::optional<MessageId> current_msg_id;
MessageListModel *model;
QPushButton *suppress_add;
QPushButton *suppress_clear;
QMenu *menu;
};

View File

@@ -0,0 +1,346 @@
#include "tools/cabana/panda.h"
#include <unistd.h>
#include <cassert>
#include <stdexcept>
#include <vector>
#include <memory>
#include "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) {
control_write(0xf3, engaged, 0);
}
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;
}

View File

@@ -0,0 +1,95 @@
#pragma once
#include <cstdint>
#include <ctime>
#include <functional>
#include <list>
#include <memory>
#include <optional>
#include <string>
#include <vector>
#include <atomic>
#include <mutex>
#include <libusb-1.0/libusb.h>
#include "cereal/gen/cpp/car.capnp.h"
#include "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);
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);
};

View File

@@ -0,0 +1,618 @@
#include "tools/cabana/settings.h"
#include <algorithm>
#include <cerrno>
#include <cctype>
#include <charconv>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <iterator>
#include <map>
#include <string_view>
#include <system_error>
#include <utility>
#include <vector>
#include <fcntl.h>
#include <sys/file.h>
#include <unistd.h>
#ifdef __APPLE__
#include <CoreFoundation/CoreFoundation.h>
#endif
#include <QAbstractButton>
#include <QDialogButtonBox>
#include <QFileDialog>
#include <QFormLayout>
#include <QPushButton>
#include <type_traits>
#include "third_party/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();
}

View File

@@ -0,0 +1,45 @@
#pragma once
#include <cstdint>
#include <vector>
#include <QComboBox>
#include <QDialog>
#include <QGroupBox>
#include <QLineEdit>
#include <QSpinBox>
#include "tools/cabana/core/settings.h"
class Settings : public QObject, public CabanaSettingsState {
Q_OBJECT
public:
Settings();
void save();
// Qt frontend layout state. This intentionally stays outside CabanaSettingsState.
std::vector<uint8_t> geometry;
std::vector<uint8_t> video_splitter_state;
std::vector<uint8_t> window_state;
std::vector<uint8_t> message_header_state;
signals:
void changed();
};
class SettingsDlg : public QDialog {
public:
SettingsDlg(QWidget *parent);
void save();
QSpinBox *fps;
QSpinBox *cached_minutes;
QSpinBox *chart_height;
QComboBox *chart_series_type;
QComboBox *theme;
QGroupBox *log_livestream;
QLineEdit *log_path;
QComboBox *drag_direction;
};
extern Settings settings;

View File

@@ -0,0 +1,719 @@
#include "tools/cabana/signalview.h"
#include "tools/cabana/dbc/dbcqt.h"
#include <algorithm>
#include <future>
#include <QDialogButtonBox>
#include <QHBoxLayout>
#include <QHeaderView>
#include <QMessageBox>
#include <QPainter>
#include <QPainterPath>
#include <QPushButton>
#include <QScrollBar>
#include <QVBoxLayout>
#include "tools/cabana/commands.h"
#include "tools/cabana/utils/util.h"
// SignalModel
static QString signalTypeToString(cabana::Signal::Type type) {
if (type == cabana::Signal::Type::Multiplexor) return "Multiplexor Signal";
else if (type == cabana::Signal::Type::Multiplexed) return "Multiplexed Signal";
else return "Normal Signal";
}
SignalModel::SignalModel(QObject *parent) : root(new Item), QAbstractItemModel(parent) {
QObject::connect(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, this, &SignalModel::refresh);
QObject::connect(dbcNotifier(), &QtDBCNotifier::msgUpdated, this, &SignalModel::handleMsgChanged);
QObject::connect(dbcNotifier(), &QtDBCNotifier::msgRemoved, this, &SignalModel::handleMsgChanged);
QObject::connect(dbcNotifier(), &QtDBCNotifier::signalAdded, this, &SignalModel::handleSignalAdded);
QObject::connect(dbcNotifier(), &QtDBCNotifier::signalUpdated, this, &SignalModel::handleSignalUpdated);
QObject::connect(dbcNotifier(), &QtDBCNotifier::signalRemoved, this, &SignalModel::handleSignalRemoved);
}
void SignalModel::insertItem(SignalModel::Item *root_item, int pos, const cabana::Signal *sig) {
Item *parent_item = new Item{.type = Item::Sig, .parent = root_item, .sig = sig, .title = QString::fromStdString(sig->name)};
root_item->children.insert(root_item->children.begin() + pos, parent_item);
QString titles[]{"Name", "Size", "Receiver Nodes", "Little Endian", "Signed", "Offset", "Factor", "Type",
"Multiplex Value", "Extra Info", "Unit", "Comment", "Minimum Value", "Maximum Value", "Value Table"};
for (int i = 0; i < std::size(titles); ++i) {
auto item = new Item{.type = (Item::Type)(i + Item::Name), .parent = parent_item, .sig = sig, .title = titles[i]};
parent_item->children.push_back(item);
if (item->type == Item::ExtraInfo) {
parent_item = item;
}
}
}
void SignalModel::setMessage(const MessageId &id) {
msg_id = id;
filter_str = "";
refresh();
}
void SignalModel::setFilter(const QString &txt) {
filter_str = txt;
refresh();
}
void SignalModel::refresh() {
beginResetModel();
root.reset(new SignalModel::Item);
if (auto msg = dbc()->msg(msg_id)) {
for (auto s : msg->getSignals()) {
if (filter_str.isEmpty() || QString::fromStdString(s->name).contains(filter_str, Qt::CaseInsensitive)) {
insertItem(root.get(), root->children.size(), s);
}
}
}
endResetModel();
}
SignalModel::Item *SignalModel::getItem(const QModelIndex &index) const {
auto item = index.isValid() ? (SignalModel::Item *)index.internalPointer() : nullptr;
return item ? item : root.get();
}
int SignalModel::rowCount(const QModelIndex &parent) const {
if (parent.isValid() && parent.column() > 0) return 0;
return getItem(parent)->children.size();
}
Qt::ItemFlags SignalModel::flags(const QModelIndex &index) const {
if (!index.isValid()) return Qt::NoItemFlags;
auto item = getItem(index);
Qt::ItemFlags flags = Qt::ItemIsSelectable | Qt::ItemIsEnabled;
if (index.column() == 1 && item->children.empty()) {
flags |= (item->type == Item::Endian || item->type == Item::Signed) ? Qt::ItemIsUserCheckable : Qt::ItemIsEditable;
}
if (item->type == Item::MultiplexValue && item->sig->type != cabana::Signal::Type::Multiplexed) {
flags &= ~Qt::ItemIsEnabled;
}
return flags;
}
int SignalModel::signalRow(const cabana::Signal *sig) const {
for (int i = 0; i < root->children.size(); ++i) {
if (root->children[i]->sig == sig) return i;
}
return -1;
}
QModelIndex SignalModel::index(int row, int column, const QModelIndex &parent) const {
if (parent.isValid() && parent.column() != 0) return {};
auto parent_item = getItem(parent);
if (parent_item && row < parent_item->children.size()) {
return createIndex(row, column, parent_item->children[row]);
}
return {};
}
QModelIndex SignalModel::parent(const QModelIndex &index) const {
if (!index.isValid()) return {};
Item *parent_item = getItem(index)->parent;
return !parent_item || parent_item == root.get() ? QModelIndex() : createIndex(parent_item->row(), 0, parent_item);
}
QVariant SignalModel::data(const QModelIndex &index, int role) const {
if (index.isValid()) {
const Item *item = getItem(index);
if (role == Qt::DisplayRole || role == Qt::EditRole) {
if (index.column() == 0) {
return item->type == Item::Sig ? QString::fromStdString(item->sig->name) : item->title;
} else {
switch (item->type) {
case Item::Sig: return item->sig_val;
case Item::Name: return QString::fromStdString(item->sig->name);
case Item::Size: return item->sig->size;
case Item::Node: return QString::fromStdString(item->sig->receiver_name);
case Item::SignalType: return signalTypeToString(item->sig->type);
case Item::MultiplexValue: return item->sig->multiplex_value;
case Item::Offset: return QString::fromStdString(doubleToString(item->sig->offset));
case Item::Factor: return QString::fromStdString(doubleToString(item->sig->factor));
case Item::Unit: return QString::fromStdString(item->sig->unit);
case Item::Comment: return QString::fromStdString(item->sig->comment);
case Item::Min: return QString::fromStdString(doubleToString(item->sig->min));
case Item::Max: return QString::fromStdString(doubleToString(item->sig->max));
case Item::Desc: {
QStringList val_desc;
for (auto &[val, desc] : item->sig->val_desc) {
val_desc << QString("%1 \"%2\"").arg(val).arg(QString::fromStdString(desc));
}
return val_desc.join(" ");
}
default: break;
}
}
} else if (role == Qt::CheckStateRole && index.column() == 1) {
if (item->type == Item::Endian) return item->sig->is_little_endian ? Qt::Checked : Qt::Unchecked;
if (item->type == Item::Signed) return item->sig->is_signed ? Qt::Checked : Qt::Unchecked;
} else if (role == Qt::ToolTipRole && item->type == Item::Sig) {
return (index.column() == 0) ? signalToolTip(item->sig) : QString();
}
}
return {};
}
bool SignalModel::setData(const QModelIndex &index, const QVariant &value, int role) {
if (role != Qt::EditRole && role != Qt::CheckStateRole) return false;
Item *item = getItem(index);
cabana::Signal s = *item->sig;
switch (item->type) {
case Item::Name: s.name = value.toString().toStdString(); break;
case Item::Size: s.size = value.toInt(); break;
case Item::Node: s.receiver_name = value.toString().trimmed().toStdString(); break;
case Item::SignalType: s.type = (cabana::Signal::Type)value.toInt(); break;
case Item::MultiplexValue: s.multiplex_value = value.toInt(); break;
case Item::Endian: s.is_little_endian = value.toBool(); break;
case Item::Signed: s.is_signed = value.toBool(); break;
case Item::Offset: s.offset = value.toDouble(); break;
case Item::Factor: s.factor = value.toDouble(); break;
case Item::Unit: s.unit = value.toString().toStdString(); break;
case Item::Comment: s.comment = value.toString().toStdString(); break;
case Item::Min: s.min = value.toDouble(); break;
case Item::Max: s.max = value.toDouble(); break;
case Item::Desc: s.val_desc = value.value<ValueDescription>(); break;
default: return false;
}
bool ret = saveSignal(item->sig, s);
emit dataChanged(index, index, {Qt::DisplayRole, Qt::EditRole, Qt::CheckStateRole});
return ret;
}
bool SignalModel::saveSignal(const cabana::Signal *origin_s, cabana::Signal &s) {
auto msg = dbc()->msg(msg_id);
if (s.name != origin_s->name && msg->sig(s.name) != nullptr) {
QString text = tr("There is already a signal with the same name '%1'").arg(QString::fromStdString(s.name));
QMessageBox::warning(nullptr, tr("Failed to save signal"), text);
return false;
}
if (s.is_little_endian != origin_s->is_little_endian) {
s.start_bit = flipBitPos(s.start_bit);
}
UndoStack::instance()->push(new EditSignalCommand(msg_id, origin_s, s));
return true;
}
void SignalModel::handleMsgChanged(MessageId id) {
if (id.address == msg_id.address) {
refresh();
}
}
void SignalModel::handleSignalAdded(MessageId id, const cabana::Signal *sig) {
if (id == msg_id) {
if (filter_str.isEmpty()) {
int i = dbc()->msg(msg_id)->indexOf(sig);
beginInsertRows({}, i, i);
insertItem(root.get(), i, sig);
endInsertRows();
} else if (QString::fromStdString(sig->name).contains(filter_str, Qt::CaseInsensitive)) {
refresh();
}
}
}
void SignalModel::handleSignalUpdated(const cabana::Signal *sig) {
if (int row = signalRow(sig); row != -1) {
emit dataChanged(index(row, 0), index(row, 1), {Qt::DisplayRole, Qt::EditRole, Qt::CheckStateRole});
if (filter_str.isEmpty()) {
// move row when the order changes.
int to = dbc()->msg(msg_id)->indexOf(sig);
if (to != row) {
beginMoveRows({}, row, row, {}, to > row ? to + 1 : to);
auto item = root->children[row];
root->children.erase(root->children.begin() + row);
root->children.insert(root->children.begin() + to, item);
endMoveRows();
}
}
}
}
void SignalModel::handleSignalRemoved(const cabana::Signal *sig) {
if (int row = signalRow(sig); row != -1) {
beginRemoveRows({}, row, row);
delete root->children[row];
root->children.erase(root->children.begin() + row);
endRemoveRows();
}
}
// SignalItemDelegate
SignalItemDelegate::SignalItemDelegate(QObject *parent) : QStyledItemDelegate(parent) {
name_validator = new NameValidator(this);
node_validator = new NodeValidator(this);
double_validator = new DoubleValidator(this);
label_font.setPointSize(8);
minmax_font.setPixelSize(10);
}
QSize SignalItemDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const {
int width = option.widget->size().width() / 2;
if (index.column() == 0) {
int spacing = option.widget->style()->pixelMetric(QStyle::PM_TreeViewIndentation) + color_label_width + 8;
auto text = index.data(Qt::DisplayRole).toString();
auto item = (SignalModel::Item *)index.internalPointer();
if (item->type == SignalModel::Item::Sig && item->sig->type != cabana::Signal::Type::Normal) {
text += item->sig->type == cabana::Signal::Type::Multiplexor ? QString(" M ") : QString(" m%1 ").arg(item->sig->multiplex_value);
spacing += (option.widget->style()->pixelMetric(QStyle::PM_FocusFrameHMargin) + 1) * 2;
}
width = std::min<int>(option.widget->size().width() / 3.0, option.fontMetrics.horizontalAdvance(text) + spacing);
}
return {width, option.fontMetrics.height() + option.widget->style()->pixelMetric(QStyle::PM_FocusFrameVMargin) * 2};
}
void SignalItemDelegate::updateEditorGeometry(QWidget *editor, const QStyleOptionViewItem &option, const QModelIndex &index) const {
auto item = (SignalModel::Item *)index.internalPointer();
if (editor && item->type == SignalModel::Item::Sig && index.column() == 1) {
QRect geom = option.rect;
geom.setLeft(geom.right() - editor->sizeHint().width());
editor->setGeometry(geom);
button_size = geom.size();
return;
}
QStyledItemDelegate::updateEditorGeometry(editor, option, index);
}
void SignalItemDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const {
const int h_margin = option.widget->style()->pixelMetric(QStyle::PM_FocusFrameHMargin) + 1;
const int v_margin = option.widget->style()->pixelMetric(QStyle::PM_FocusFrameVMargin);
auto item = static_cast<SignalModel::Item*>(index.internalPointer());
QRect rect = option.rect.adjusted(h_margin, v_margin, -h_margin, -v_margin);
painter->setRenderHint(QPainter::Antialiasing);
if (option.state & QStyle::State_Selected) {
painter->fillRect(option.rect, option.palette.brush(QPalette::Normal, QPalette::Highlight));
}
if (index.column() == 0) {
if (item->type == SignalModel::Item::Sig) {
// color label
QPainterPath path;
QRect icon_rect{rect.x(), rect.y(), color_label_width, rect.height()};
path.addRoundedRect(icon_rect, 3, 3);
painter->setPen(item->highlight ? Qt::white : Qt::black);
painter->setFont(label_font);
painter->fillPath(path, toQColor(item->sig->color.darker(item->highlight ? 125 : 0)));
painter->drawText(icon_rect, Qt::AlignCenter, QString::number(item->row() + 1));
rect.setLeft(icon_rect.right() + h_margin * 2);
// multiplexer indicator
if (item->sig->type != cabana::Signal::Type::Normal) {
QString indicator = item->sig->type == cabana::Signal::Type::Multiplexor ? QString(" M ") : QString(" m%1 ").arg(item->sig->multiplex_value);
QRect indicator_rect{rect.x(), rect.y(), option.fontMetrics.horizontalAdvance(indicator), rect.height()};
painter->setBrush(Qt::gray);
painter->setPen(Qt::NoPen);
painter->drawRoundedRect(indicator_rect, 3, 3);
painter->setPen(Qt::white);
painter->drawText(indicator_rect, Qt::AlignCenter, indicator);
rect.setLeft(indicator_rect.right() + h_margin * 2);
}
} else {
rect.setLeft(option.widget->style()->pixelMetric(QStyle::PM_TreeViewIndentation) + color_label_width + h_margin * 3);
}
// name
auto text = option.fontMetrics.elidedText(index.data(Qt::DisplayRole).toString(), Qt::ElideRight, rect.width());
painter->setPen(option.palette.color(option.state & QStyle::State_Selected ? QPalette::HighlightedText : QPalette::Text));
painter->setFont(option.font);
painter->drawText(rect, option.displayAlignment, text);
} else if (index.column() == 1) {
if (!item->sparkline.pixmap.isNull()) {
QSize sparkline_size = item->sparkline.pixmap.size() / item->sparkline.pixmap.devicePixelRatio();
painter->drawPixmap(QRect(rect.topLeft(), sparkline_size), item->sparkline.pixmap);
// min-max value
painter->setPen(option.palette.color(option.state & QStyle::State_Selected ? QPalette::HighlightedText : QPalette::Text));
rect.adjust(sparkline_size.width() + 1, 0, 0, 0);
int value_adjust = 10;
if (!item->sparkline.isEmpty() && (item->highlight || option.state & QStyle::State_Selected)) {
painter->drawLine(rect.topLeft(), rect.bottomLeft());
rect.adjust(5, -v_margin, 0, v_margin);
painter->setFont(minmax_font);
QString min = QString::number(item->sparkline.min_val);
QString max = QString::number(item->sparkline.max_val);
painter->drawText(rect, Qt::AlignLeft | Qt::AlignTop, max);
painter->drawText(rect, Qt::AlignLeft | Qt::AlignBottom, min);
QFontMetrics fm(minmax_font);
value_adjust = std::max(fm.horizontalAdvance(min), fm.horizontalAdvance(max)) + 5;
} else if (!item->sparkline.isEmpty() && item->sig->type == cabana::Signal::Type::Multiplexed) {
// display freq of multiplexed signal
painter->setFont(label_font);
QString freq = QString("%1 hz").arg(item->sparkline.freq(), 0, 'g', 2);
painter->drawText(rect.adjusted(5, 0, 0, 0), Qt::AlignLeft | Qt::AlignVCenter, freq);
value_adjust = QFontMetrics(label_font).horizontalAdvance(freq) + 10;
}
// signal value
painter->setFont(option.font);
rect.adjust(value_adjust, 0, -button_size.width(), 0);
auto text = option.fontMetrics.elidedText(index.data(Qt::DisplayRole).toString(), Qt::ElideRight, rect.width());
painter->drawText(rect, Qt::AlignRight | Qt::AlignVCenter, text);
} else {
QStyledItemDelegate::paint(painter, option, index);
}
}
}
QWidget *SignalItemDelegate::createEditor(QWidget *parent, const QStyleOptionViewItem &option, const QModelIndex &index) const {
auto item = (SignalModel::Item *)index.internalPointer();
if (item->type == SignalModel::Item::Name || item->type == SignalModel::Item::Node || item->type == SignalModel::Item::Offset ||
item->type == SignalModel::Item::Factor || item->type == SignalModel::Item::MultiplexValue ||
item->type == SignalModel::Item::Min || item->type == SignalModel::Item::Max) {
QLineEdit *e = new QLineEdit(parent);
e->setFrame(false);
if (item->type == SignalModel::Item::Name) e->setValidator(name_validator);
else if (item->type == SignalModel::Item::Node) e->setValidator(node_validator);
else e->setValidator(double_validator);
return e;
} else if (item->type == SignalModel::Item::Size) {
QSpinBox *spin = new QSpinBox(parent);
spin->setFrame(false);
spin->setRange(1, CAN_MAX_DATA_BYTES);
return spin;
} else if (item->type == SignalModel::Item::SignalType) {
QComboBox *c = new QComboBox(parent);
c->addItem(signalTypeToString(cabana::Signal::Type::Normal), (int)cabana::Signal::Type::Normal);
if (!dbc()->msg(((SignalModel *)index.model())->msg_id)->multiplexor) {
c->addItem(signalTypeToString(cabana::Signal::Type::Multiplexor), (int)cabana::Signal::Type::Multiplexor);
} else if (item->sig->type != cabana::Signal::Type::Multiplexor) {
c->addItem(signalTypeToString(cabana::Signal::Type::Multiplexed), (int)cabana::Signal::Type::Multiplexed);
}
return c;
} else if (item->type == SignalModel::Item::Desc) {
ValueDescriptionDlg dlg(item->sig->val_desc, parent);
dlg.setWindowTitle(QString::fromStdString(item->sig->name));
if (dlg.exec()) {
((QAbstractItemModel *)index.model())->setData(index, QVariant::fromValue(dlg.val_desc));
}
return nullptr;
}
return QStyledItemDelegate::createEditor(parent, option, index);
}
void SignalItemDelegate::setModelData(QWidget *editor, QAbstractItemModel *model, const QModelIndex &index) const {
auto item = (SignalModel::Item *)index.internalPointer();
if (item->type == SignalModel::Item::SignalType) {
model->setData(index, ((QComboBox*)editor)->currentData().toInt());
return;
}
QStyledItemDelegate::setModelData(editor, model, index);
}
// SignalView
SignalView::SignalView(ChartsWidget *charts, QWidget *parent) : charts(charts), QFrame(parent) {
setFrameStyle(QFrame::StyledPanel | QFrame::Plain);
// title bar
QWidget *title_bar = new QWidget(this);
QHBoxLayout *hl = new QHBoxLayout(title_bar);
hl->addWidget(signal_count_lb = new QLabel());
filter_edit = new QLineEdit(this);
filter_edit->setValidator(new NonWhitespaceValidator(this));
filter_edit->setClearButtonEnabled(true);
filter_edit->setPlaceholderText(tr("Filter Signal"));
hl->addWidget(filter_edit);
hl->addStretch(1);
// WARNING: increasing the maximum range can result in severe performance degradation.
// 30s is a reasonable value at present.
const int max_range = 30; // 30s
settings.sparkline_range = std::clamp(settings.sparkline_range, 1, max_range);
hl->addWidget(sparkline_label = new QLabel());
hl->addWidget(sparkline_range_slider = new QSlider(Qt::Horizontal, this));
sparkline_range_slider->setRange(1, max_range);
sparkline_range_slider->setValue(settings.sparkline_range);
sparkline_range_slider->setToolTip(tr("Sparkline time range"));
auto collapse_btn = new ToolButton("dash-square", tr("Collapse All"));
collapse_btn->setIconSize({12, 12});
hl->addWidget(collapse_btn);
// tree view
tree = new TreeView(this);
tree->setModel(model = new SignalModel(this));
tree->setItemDelegate(delegate = new SignalItemDelegate(this));
tree->setFrameShape(QFrame::NoFrame);
tree->setHeaderHidden(true);
tree->setMouseTracking(true);
tree->setExpandsOnDoubleClick(false);
tree->setEditTriggers(QAbstractItemView::AllEditTriggers);
tree->header()->setSectionResizeMode(0, QHeaderView::ResizeToContents);
tree->header()->setStretchLastSection(true);
tree->setMinimumHeight(300);
// Use a distinctive background for the whole row containing a QSpinBox or QLineEdit
QString nodeBgColor = palette().color(QPalette::AlternateBase).name(QColor::HexArgb);
tree->setStyleSheet(QString("QSpinBox{background-color:%1;border:none;} QLineEdit{background-color:%1;}").arg(nodeBgColor));
QVBoxLayout *main_layout = new QVBoxLayout(this);
main_layout->setContentsMargins(0, 0, 0, 0);
main_layout->setSpacing(0);
main_layout->addWidget(title_bar);
main_layout->addWidget(tree);
updateToolBar();
QObject::connect(filter_edit, &QLineEdit::textEdited, model, &SignalModel::setFilter);
QObject::connect(sparkline_range_slider, &QSlider::valueChanged, this, &SignalView::setSparklineRange);
QObject::connect(collapse_btn, &QPushButton::clicked, tree, &QTreeView::collapseAll);
QObject::connect(tree, &QAbstractItemView::clicked, this, &SignalView::rowClicked);
QObject::connect(tree, &QTreeView::viewportEntered, [this]() { emit highlight(nullptr); });
QObject::connect(tree, &QTreeView::entered, [this](const QModelIndex &index) { emit highlight(model->getItem(index)->sig); });
QObject::connect(model, &QAbstractItemModel::modelReset, this, &SignalView::rowsChanged);
QObject::connect(model, &QAbstractItemModel::rowsRemoved, this, &SignalView::rowsChanged);
QObject::connect(dbcNotifier(), &QtDBCNotifier::signalAdded, this, &SignalView::handleSignalAdded);
QObject::connect(dbcNotifier(), &QtDBCNotifier::signalUpdated, this, &SignalView::handleSignalUpdated);
QObject::connect(tree->verticalScrollBar(), &QScrollBar::valueChanged, [this]() { updateState(); });
QObject::connect(tree->verticalScrollBar(), &QScrollBar::rangeChanged, [this]() { updateState(); });
QObject::connect(can, &AbstractStream::msgsReceived, this, &SignalView::updateState);
QObject::connect(tree->header(), &QHeaderView::sectionResized, [this](int logicalIndex, int oldSize, int newSize) {
if (logicalIndex == 1) {
value_column_width = newSize;
updateState();
}
});
setWhatsThis(tr(R"(
<b>Signal view</b><br />
<!-- TODO: add description here -->
)"));
}
void SignalView::setMessage(const MessageId &id) {
max_value_width = 0;
filter_edit->clear();
model->setMessage(id);
}
void SignalView::rowsChanged() {
for (int i = 0; i < model->rowCount(); ++i) {
auto index = model->index(i, 1);
if (!tree->indexWidget(index)) {
QWidget *w = new QWidget(this);
QHBoxLayout *h = new QHBoxLayout(w);
int v_margin = style()->pixelMetric(QStyle::PM_FocusFrameVMargin);
int h_margin = style()->pixelMetric(QStyle::PM_FocusFrameHMargin);
h->setContentsMargins(0, v_margin, -h_margin, v_margin);
h->setSpacing(style()->pixelMetric(QStyle::PM_ToolBarItemSpacing));
auto remove_btn = new ToolButton("x", tr("Remove signal"));
auto plot_btn = new ToolButton("graph-up", "");
plot_btn->setCheckable(true);
h->addWidget(plot_btn);
h->addWidget(remove_btn);
tree->setIndexWidget(index, w);
auto sig = model->getItem(index)->sig;
QObject::connect(remove_btn, &QToolButton::clicked, [=]() { UndoStack::instance()->push(new RemoveSigCommand(model->msg_id, sig)); });
QObject::connect(plot_btn, &QToolButton::clicked, [=](bool checked) {
emit showChart(model->msg_id, sig, checked, QGuiApplication::keyboardModifiers() & Qt::ShiftModifier);
});
}
}
updateToolBar();
updateChartState();
updateState();
}
void SignalView::rowClicked(const QModelIndex &index) {
auto item = model->getItem(index);
if (item->type == SignalModel::Item::Sig || item->type == SignalModel::Item::ExtraInfo) {
auto expand_index = model->index(index.row(), 0, index.parent());
tree->setExpanded(expand_index, !tree->isExpanded(expand_index));
}
}
void SignalView::selectSignal(const cabana::Signal *sig, bool expand) {
if (int row = model->signalRow(sig); row != -1) {
auto idx = model->index(row, 0);
if (expand) {
tree->setExpanded(idx, !tree->isExpanded(idx));
}
tree->scrollTo(idx, QAbstractItemView::PositionAtTop);
tree->setCurrentIndex(idx);
}
}
void SignalView::updateChartState() {
int i = 0;
for (auto item : model->root->children) {
bool chart_opened = charts->hasSignal(model->msg_id, item->sig);
auto buttons = tree->indexWidget(model->index(i, 1))->findChildren<QToolButton *>();
if (buttons.size() > 0) {
buttons[0]->setChecked(chart_opened);
buttons[0]->setToolTip(chart_opened ? tr("Close Plot") : tr("Show Plot\nSHIFT click to add to previous opened plot"));
}
++i;
}
}
void SignalView::signalHovered(const cabana::Signal *sig) {
auto &children = model->root->children;
for (int i = 0; i < children.size(); ++i) {
bool highlight = children[i]->sig == sig;
if (std::exchange(children[i]->highlight, highlight) != highlight) {
emit model->dataChanged(model->index(i, 0), model->index(i, 0), {Qt::DecorationRole});
emit model->dataChanged(model->index(i, 1), model->index(i, 1), {Qt::DisplayRole});
}
}
}
void SignalView::updateToolBar() {
signal_count_lb->setText(tr("Signals: %1").arg(model->rowCount()));
sparkline_label->setText(utils::formatSeconds(settings.sparkline_range));
}
void SignalView::setSparklineRange(int value) {
settings.sparkline_range = value;
updateToolBar();
updateState();
}
void SignalView::handleSignalAdded(MessageId id, const cabana::Signal *sig) {
if (id.address == model->msg_id.address) {
selectSignal(sig);
}
}
void SignalView::handleSignalUpdated(const cabana::Signal *sig) {
if (int row = model->signalRow(sig); row != -1)
updateState();
}
std::pair<QModelIndex, QModelIndex> SignalView::visibleSignalRange() {
auto topLevelIndex = [](QModelIndex index) {
while (index.isValid() && index.parent().isValid()) index = index.parent();
return index;
};
const auto viewport_rect = tree->viewport()->rect();
QModelIndex first_visible = tree->indexAt(viewport_rect.topLeft());
if (first_visible.parent().isValid()) {
first_visible = topLevelIndex(first_visible);
first_visible = first_visible.siblingAtRow(first_visible.row() + 1);
}
QModelIndex last_visible = topLevelIndex(tree->indexAt(viewport_rect.bottomRight()));
if (!last_visible.isValid()) {
last_visible = model->index(model->rowCount() - 1, 0);
}
return {first_visible, last_visible};
}
void SignalView::updateState(const std::set<MessageId> *msgs) {
const auto &last_msg = can->lastMessage(model->msg_id);
if (model->rowCount() == 0 || (msgs && !msgs->count(model->msg_id)) || last_msg.dat.size() == 0) return;
for (auto item : model->root->children) {
double value = 0;
if (item->sig->getValue(last_msg.dat.data(), last_msg.dat.size(), &value)) {
item->sig_val = QString::fromStdString(item->sig->formatValue(value));
max_value_width = std::max(max_value_width, fontMetrics().horizontalAdvance(item->sig_val));
}
}
auto [first_visible, last_visible] = visibleSignalRange();
if (first_visible.isValid() && last_visible.isValid()) {
const static int min_max_width = QFontMetrics(delegate->minmax_font).horizontalAdvance("-000.00") + 5;
int available_width = value_column_width - delegate->button_size.width();
int value_width = std::min<int>(max_value_width + min_max_width, available_width / 2);
QSize size(available_width - value_width,
delegate->button_size.height() - style()->pixelMetric(QStyle::PM_FocusFrameVMargin) * 2);
auto [first, last] = can->eventsInRange(model->msg_id, std::make_pair(last_msg.ts -settings.sparkline_range, last_msg.ts));
std::vector<std::future<void>> futures;
for (int i = first_visible.row(); i <= last_visible.row(); ++i) {
auto item = model->getItem(model->index(i, 1));
futures.push_back(std::async(std::launch::async,
&Sparkline::update, &item->sparkline, item->sig, first, last, settings.sparkline_range, size));
}
for (auto &f : futures) f.get();
}
for (int i = 0; i < model->rowCount(); ++i) {
emit model->dataChanged(model->index(i, 1), model->index(i, 1), {Qt::DisplayRole});
}
}
void SignalView::resizeEvent(QResizeEvent* event) {
updateState();
QFrame::resizeEvent(event);
}
// ValueDescriptionDlg
ValueDescriptionDlg::ValueDescriptionDlg(const ValueDescription &descriptions, QWidget *parent) : QDialog(parent) {
QHBoxLayout *toolbar_layout = new QHBoxLayout();
QPushButton *add = new QPushButton(utils::icon("plus"), "");
QPushButton *remove = new QPushButton(utils::icon("dash"), "");
remove->setEnabled(false);
toolbar_layout->addWidget(add);
toolbar_layout->addWidget(remove);
toolbar_layout->addStretch(0);
table = new QTableWidget(descriptions.size(), 2, this);
table->setItemDelegate(new Delegate(this));
table->setHorizontalHeaderLabels({"Value", "Description"});
table->horizontalHeader()->setStretchLastSection(true);
table->setSelectionBehavior(QAbstractItemView::SelectRows);
table->setSelectionMode(QAbstractItemView::SingleSelection);
table->setEditTriggers(QAbstractItemView::DoubleClicked | QAbstractItemView::EditKeyPressed);
table->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
int row = 0;
for (auto &[val, desc] : descriptions) {
table->setItem(row, 0, new QTableWidgetItem(QString::number(val)));
table->setItem(row, 1, new QTableWidgetItem(QString::fromStdString(desc)));
++row;
}
auto btn_box = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
QVBoxLayout *main_layout = new QVBoxLayout(this);
main_layout->addLayout(toolbar_layout);
main_layout->addWidget(table);
main_layout->addWidget(btn_box);
setMinimumWidth(500);
QObject::connect(btn_box, &QDialogButtonBox::accepted, this, &ValueDescriptionDlg::save);
QObject::connect(btn_box, &QDialogButtonBox::rejected, this, &QDialog::reject);
QObject::connect(add, &QPushButton::clicked, [this]() {
table->setRowCount(table->rowCount() + 1);
table->setItem(table->rowCount() - 1, 0, new QTableWidgetItem);
table->setItem(table->rowCount() - 1, 1, new QTableWidgetItem);
});
QObject::connect(remove, &QPushButton::clicked, [this]() { table->removeRow(table->currentRow()); });
QObject::connect(table, &QTableWidget::itemSelectionChanged, [=]() {
remove->setEnabled(table->currentRow() != -1);
});
}
void ValueDescriptionDlg::save() {
for (int i = 0; i < table->rowCount(); ++i) {
QString val = table->item(i, 0)->text().trimmed();
QString desc = table->item(i, 1)->text().trimmed();
if (!val.isEmpty() && !desc.isEmpty()) {
val_desc.push_back({val.toDouble(), desc.toStdString()});
}
}
QDialog::accept();
}
QWidget *ValueDescriptionDlg::Delegate::createEditor(QWidget *parent, const QStyleOptionViewItem &option, const QModelIndex &index) const {
QLineEdit *edit = new QLineEdit(parent);
edit->setFrame(false);
if (index.column() == 0) {
edit->setValidator(new DoubleValidator(parent));
}
return edit;
}

View File

@@ -0,0 +1,153 @@
#pragma once
#include <memory>
#include <set>
#include <utility>
#include <QAbstractItemModel>
#include <QLabel>
#include <QLineEdit>
#include <QSlider>
#include <QStyledItemDelegate>
#include <QTableWidget>
#include <QTreeView>
#include "tools/cabana/chart/chartswidget.h"
#include "tools/cabana/chart/sparkline.h"
class SignalModel : public QAbstractItemModel {
Q_OBJECT
public:
struct Item {
enum Type {Root, Sig, Name, Size, Node, Endian, Signed, Offset, Factor, SignalType, MultiplexValue, ExtraInfo, Unit, Comment, Min, Max, Desc };
~Item() { for (auto c : children) delete c; }
inline int row() {
auto it = std::find(parent->children.begin(), parent->children.end(), this);
return it != parent->children.end() ? std::distance(parent->children.begin(), it) : -1;
}
Type type = Type::Root;
Item *parent = nullptr;
std::vector<Item *> children;
const cabana::Signal *sig = nullptr;
QString title;
bool highlight = false;
QString sig_val = "-";
Sparkline sparkline;
};
SignalModel(QObject *parent);
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
int columnCount(const QModelIndex &parent = QModelIndex()) const override { return 2; }
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
QModelIndex index(int row, int column, const QModelIndex &parent = QModelIndex()) const override;
QModelIndex parent(const QModelIndex &index) const override;
Qt::ItemFlags flags(const QModelIndex &index) const override;
bool setData(const QModelIndex &index, const QVariant &value, int role = Qt::EditRole) override;
void setMessage(const MessageId &id);
void setFilter(const QString &txt);
bool saveSignal(const cabana::Signal *origin_s, cabana::Signal &s);
Item *getItem(const QModelIndex &index) const;
int signalRow(const cabana::Signal *sig) const;
private:
void insertItem(SignalModel::Item *root_item, int pos, const cabana::Signal *sig);
void handleSignalAdded(MessageId id, const cabana::Signal *sig);
void handleSignalUpdated(const cabana::Signal *sig);
void handleSignalRemoved(const cabana::Signal *sig);
void handleMsgChanged(MessageId id);
void refresh();
MessageId msg_id;
QString filter_str;
std::unique_ptr<Item> root;
friend class SignalView;
friend class SignalItemDelegate;
};
class ValueDescriptionDlg : public QDialog {
public:
ValueDescriptionDlg(const ValueDescription &descriptions, QWidget *parent);
ValueDescription val_desc;
private:
struct Delegate : public QStyledItemDelegate {
Delegate(QWidget *parent) : QStyledItemDelegate(parent) {}
QWidget *createEditor(QWidget *parent, const QStyleOptionViewItem &option, const QModelIndex &index) const override;
};
void save();
QTableWidget *table;
};
class SignalItemDelegate : public QStyledItemDelegate {
public:
SignalItemDelegate(QObject *parent);
void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override;
QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override;
QWidget *createEditor(QWidget *parent, const QStyleOptionViewItem &option, const QModelIndex &index) const override;
void updateEditorGeometry(QWidget *editor, const QStyleOptionViewItem &option, const QModelIndex &index) const override;
void setModelData(QWidget *editor, QAbstractItemModel *model, const QModelIndex &index) const override;
QValidator *name_validator, *double_validator, *node_validator;
QFont label_font, minmax_font;
const int color_label_width = 18;
mutable QSize button_size;
};
class SignalView : public QFrame {
Q_OBJECT
public:
SignalView(ChartsWidget *charts, QWidget *parent);
void setMessage(const MessageId &id);
void signalHovered(const cabana::Signal *sig);
void updateChartState();
void selectSignal(const cabana::Signal *sig, bool expand = false);
void rowClicked(const QModelIndex &index);
SignalModel *model = nullptr;
signals:
void highlight(const cabana::Signal *sig);
void showChart(const MessageId &id, const cabana::Signal *sig, bool show, bool merge);
private:
void rowsChanged();
void resizeEvent(QResizeEvent* event) override;
void updateToolBar();
void setSparklineRange(int value);
void handleSignalAdded(MessageId id, const cabana::Signal *sig);
void handleSignalUpdated(const cabana::Signal *sig);
void updateState(const std::set<MessageId> *msgs = nullptr);
std::pair<QModelIndex, QModelIndex> visibleSignalRange();
struct TreeView : public QTreeView {
TreeView(QWidget *parent) : QTreeView(parent) {}
void rowsInserted(const QModelIndex &parent, int start, int end) override {
((SignalView *)parentWidget())->rowsChanged();
// update widget geometries in QTreeView::rowsInserted
QTreeView::rowsInserted(parent, start, end);
}
void setModel(QAbstractItemModel *m) override {
QTreeView::setModel(m);
// Bypass the slow call to QTreeView::dataChanged.
QObject::disconnect(m, &QAbstractItemModel::dataChanged, this, nullptr);
QObject::connect(m, &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;
};

View File

@@ -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);
}

View File

@@ -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 "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;

View File

@@ -0,0 +1,158 @@
#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 "cereal/services.h"
#include <QButtonGroup>
#include <QFormLayout>
#include <QMessageBox>
#include <QRadioButton>
#include "tools/cabana/utils/util.h"
// DeviceStream
DeviceStream::DeviceStream(QObject *parent, Mode mode, QString address)
: mode_(mode), address_(address.isEmpty() ? "127.0.0.1" : 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 (mode_ == Mode::Bridge) {
stopBridge();
const std::string path = (std::filesystem::path(QCoreApplication::applicationDirPath().toStdString()) /
"../../cereal/messaging/bridge").lexically_normal().string();
const std::string addr = 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() {
// Bridge mode republishes into local msgq, so only the direct Zmq mode talks ZMQ.
// (Upstream sets ZMQ=1 for its bridge path too, which reads nothing — the bridge
// publishes to msgq.)
mode_ == Mode::Zmq ? setenv("ZMQ", "1", 1) : unsetenv("ZMQ");
const std::string address = mode_ == Mode::Zmq ? address_.toStdString() : "127.0.0.1";
std::unique_ptr<Context> context(Context::create());
std::unique_ptr<SubSocket> sock(SubSocket::create(context.get(), "can", address, 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"));
QRadioButton *bridge = new QRadioButton(tr("Bridge"));
zmq->setToolTip(tr("Subscribe directly to a ZMQ 'can' publisher: a device running "
"cereal/messaging/bridge, or konn3kt_canproxy.py on 127.0.0.1."));
bridge->setToolTip(tr("Run cereal/messaging/bridge locally against the device and read msgq."));
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, static_cast<int>(DeviceStream::Mode::Msgq));
group->addButton(zmq, static_cast<int>(DeviceStream::Mode::Zmq));
group->addButton(bridge, static_cast<int>(DeviceStream::Mode::Bridge));
QFormLayout *form_layout = new QFormLayout(this);
form_layout->addRow(msgq);
form_layout->addRow(zmq, ip_address);
form_layout->addRow(bridge);
QObject::connect(group, qOverload<QAbstractButton *, bool>(&QButtonGroup::buttonToggled), [=](QAbstractButton *button, bool checked) {
if (checked) ip_address->setEnabled(button != msgq);
});
zmq->setChecked(true);
}
AbstractStream *OpenDeviceWidget::open() {
auto mode = static_cast<DeviceStream::Mode>(group->checkedId());
return new DeviceStream(qApp, mode, mode == DeviceStream::Mode::Msgq ? "" : ip_address->text());
}

View File

@@ -0,0 +1,48 @@
#pragma once
#include "tools/cabana/streams/livestream.h"
#include <sys/types.h>
// IQ.Pilot patch: upstream (#38484) folded the ZMQ path into "fork a local bridge and
// read msgq". iqpilot needs the direct ZMQ attach kept as a first-class mode, because
// tools/cabana/konn3kt_canproxy.py publishes a remote device's CAN onto a LOCAL ZMQ
// "can" socket and Cabana attaches to it — see that script's header for the topology.
// So the mode is explicit rather than inferred from whether an address was entered:
//
// Msgq - local msgq, no address (cabana running on the device)
// Zmq - ZMQ subscribe straight to <address> (konn3kt_canproxy, or `bridge` on the device)
// Bridge - fork cereal/messaging/bridge <address>, (upstream's convenience path)
// which ZMQ-subscribes there and republishes
// to local msgq, then read msgq
class DeviceStream : public LiveStream {
Q_OBJECT
public:
enum class Mode { Msgq, Zmq, Bridge };
DeviceStream(QObject *parent, Mode mode = Mode::Msgq, QString address = {});
~DeviceStream();
inline std::string routeName() const override {
return "Live Streaming From " + address_.toStdString();
}
protected:
void start() override;
void streamThread() override;
void stopBridge();
pid_t bridge_pid = -1;
const Mode mode_;
const QString address_;
};
class OpenDeviceWidget : public AbstractOpenStreamWidget {
Q_OBJECT
public:
OpenDeviceWidget(QWidget *parent = nullptr);
AbstractStream *open() override;
private:
QLineEdit *ip_address;
QButtonGroup *group;
};

View File

@@ -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());
}

View File

@@ -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;
};

View File

@@ -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;
}
}

View File

@@ -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 = {};
};

View File

@@ -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;
}

View File

@@ -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;
};

View File

@@ -0,0 +1,200 @@
#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 "third_party/json11/json11.hpp"
// IQ.Pilot patch: iqpilot's tools/replay has no py_downloader — device and route
// listing come from the konn3kt API over libcurl. CommaApi2 returns the same JSON
// shapes and the same {"error": ...} envelope upstream's PyDownloader produces, so
// only the call sites change.
#include "tools/replay/api.h"
namespace {
// Parse a konn3kt API 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 = CommaApi2::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 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 = CommaApi2::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() : "";
}

View File

@@ -0,0 +1,28 @@
#pragma once
#include <atomic>
#include <memory>
#include <QComboBox>
#include <QDialog>
class RouteListWidget;
class RoutesDialog : public QDialog {
Q_OBJECT
public:
RoutesDialog(QWidget *parent);
QString route();
protected:
void parseDeviceList(const QString &json, bool success, int error_code);
void parseRouteList(const QString &json, bool success, int error_code);
void fetchRoutes();
QComboBox *device_list_;
QComboBox *period_selector_;
RouteListWidget *route_list_;
std::atomic<int> fetch_id_{0};
// expires on destruction; guards main-thread callbacks from detached worker threads
std::shared_ptr<bool> alive_ = std::make_shared<bool>(true);
};

View File

@@ -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;
}
}

View File

@@ -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 = {};
};

View File

@@ -0,0 +1,69 @@
#include "tools/cabana/streamselector.h"
#include <filesystem>
#include <QFileDialog>
#include <QLabel>
#include <QPushButton>
#include "tools/cabana/streams/devicestream.h"
#include "tools/cabana/streams/pandastream.h"
#include "tools/cabana/streams/replaystream.h"
#ifdef __linux__
#include "tools/cabana/streams/socketcanstream.h"
#endif
StreamSelector::StreamSelector(QWidget *parent) : QDialog(parent) {
setWindowTitle(tr("Open stream"));
QVBoxLayout *layout = new QVBoxLayout(this);
tab = new QTabWidget(this);
layout->addWidget(tab);
QHBoxLayout *dbc_layout = new QHBoxLayout();
dbc_file = new QLineEdit(this);
dbc_file->setReadOnly(true);
dbc_file->setPlaceholderText(tr("Choose a dbc file to open"));
QPushButton *file_btn = new QPushButton(tr("Browse..."));
dbc_layout->addWidget(new QLabel(tr("dbc File")));
dbc_layout->addWidget(dbc_file);
dbc_layout->addWidget(file_btn);
layout->addLayout(dbc_layout);
QFrame *line = new QFrame(this);
line->setFrameStyle(QFrame::HLine | QFrame::Sunken);
layout->addWidget(line);
btn_box = new QDialogButtonBox(QDialogButtonBox::Open | QDialogButtonBox::Cancel);
layout->addWidget(btn_box);
addStreamWidget(new OpenReplayWidget, tr("&Replay"));
addStreamWidget(new OpenPandaWidget, tr("&Panda"));
#ifdef __linux__
if (SocketCanStream::available()) {
addStreamWidget(new OpenSocketCanWidget, tr("&SocketCAN"));
}
#endif
addStreamWidget(new OpenDeviceWidget, tr("&Device"));
QObject::connect(btn_box, &QDialogButtonBox::rejected, this, &QDialog::reject);
QObject::connect(btn_box, &QDialogButtonBox::accepted, [=]() {
setEnabled(false);
if (stream_ = ((AbstractOpenStreamWidget *)tab->currentWidget())->open(); stream_) {
accept();
}
setEnabled(true);
});
QObject::connect(file_btn, &QPushButton::clicked, [this]() {
QString fn = QFileDialog::getOpenFileName(this, tr("Open File"), QString::fromStdString(settings.last_dir), "DBC (*.dbc)");
if (!fn.isEmpty()) {
dbc_file->setText(fn);
settings.last_dir = std::filesystem::absolute(fn.toStdString()).parent_path().string();
}
});
}
void StreamSelector::addStreamWidget(AbstractOpenStreamWidget *w, const QString &title) {
tab->addTab(w, title);
auto open_btn = btn_box->button(QDialogButtonBox::Open);
QObject::connect(w, &AbstractOpenStreamWidget::enableOpenButton, open_btn, &QPushButton::setEnabled);
}

View File

@@ -0,0 +1,24 @@
#pragma once
#include <QDialogButtonBox>
#include <QDialog>
#include <QLineEdit>
#include <QTabWidget>
#include "tools/cabana/streams/abstractstream.h"
class StreamSelector : public QDialog {
Q_OBJECT
public:
StreamSelector(QWidget *parent = nullptr);
void addStreamWidget(AbstractOpenStreamWidget *w, const QString &title);
QString dbcFile() const { return dbc_file->text(); }
AbstractStream *stream() const { return stream_; }
private:
AbstractStream *stream_ = nullptr;
QLineEdit *dbc_file;
QTabWidget *tab;
QDialogButtonBox *btn_box;
};

View File

@@ -0,0 +1,100 @@
import json
import subprocess
from pathlib import Path
CABANA_DIR = Path(__file__).parent
CABANA_BIN = CABANA_DIR / "_cabana"
def read(name):
return (CABANA_DIR / name).read_text()
class TestCabanaBinary:
def test_help(self):
assert CABANA_BIN.exists(), "cabana not built (scons -u iqpilot/tools/cabana/_cabana)"
result = subprocess.run([str(CABANA_BIN), "--help"], capture_output=True, text=True, timeout=60)
assert result.returncode == 0, result.stderr
assert "Usage:" in result.stderr
def test_help_documents_both_live_can_modes(self):
assert CABANA_BIN.exists(), "cabana not built (scons -u iqpilot/tools/cabana/_cabana)"
# --zmq attaches directly (konn3kt_canproxy); --bridge is upstream's local-bridge path
out = subprocess.run([str(CABANA_BIN), "--help"], capture_output=True, text=True, timeout=60).stderr
assert "--zmq" in out
assert "--bridge" in out
assert "konn3kt_canproxy.py" in out
def test_launcher_builds_the_right_targets(self):
launcher = read("cabana")
# iqpilot is not nested under openpilot/
assert "scons -u iqpilot/tools/cabana/_cabana iqpilot/cereal/messaging/bridge" in launcher
assert "openpilot/tools/cabana" not in launcher
class TestKonn3ktIntegration:
def test_routes_dialog_uses_konn3kt_api(self):
# upstream shells into tools/lib via PyDownloader; iqpilot goes through CommaApi2
routes = read("streams/routes.cc")
assert "PyDownloader::" not in routes
assert "py_downloader.h" not in routes
assert "CommaApi2::getDevices()" in routes
assert "CommaApi2::getDeviceRoutes(" in routes
def test_no_duplicate_qt_api_client(self):
# the old Qt HttpRequest/JWT client was folded into tools/replay/api.cc
assert not (CABANA_DIR / "utils" / "api.cc").exists()
assert not (CABANA_DIR / "utils" / "api.h").exists()
def test_no_comma_endpoints(self):
for name in ("streams/routes.cc", "cabana.cc", "README.md"):
body = read(name)
assert "connect.comma.ai" not in body, name
assert "api.comma.ai" not in body, name
def test_dbc_menu_points_at_iqdbc(self):
mainwin = read("mainwin.cc")
assert "commaai/iqdbc" in mainwin
assert "commaai/opendbc" not in mainwin
def test_dbc_json_generator_imports_iqdbc(self):
gen = read("dbc/generate_dbc_json.py")
assert "from iqdbc.car" in gen
assert "from opendbc.car" not in gen
def test_generated_dbc_json_covers_iqdbc_platforms(self):
# built by scons; cabana reads it to auto-select a DBC per car fingerprint
path = CABANA_DIR / "dbc" / "car_fingerprint_to_dbc.json"
assert path.is_file(), "run scons -u iqpilot/tools/cabana"
mapping = json.loads(path.read_text())
assert len(mapping) > 100
assert all(isinstance(v, str) and v for v in mapping.values())
def test_canproxy_targets_konn3kt(self):
proxy = read("konn3kt_canproxy.py")
assert "konn3kt" in proxy
# the proxy publishes on a local ZMQ 'can' socket that --zmq attaches to
assert 'os.environ["ZMQ"] = "1"' in proxy
class TestDeviceStreamModes:
"""The ZMQ attach is what konn3kt_canproxy feeds; upstream #38484 replaced it with a
bridge fork. Both must exist, and only the direct attach may set ZMQ=1."""
def test_three_modes_exist(self):
header = read("streams/devicestream.h")
assert "enum class Mode { Msgq, Zmq, Bridge };" in header
def test_only_zmq_mode_talks_zmq(self):
src = read("streams/devicestream.cc")
assert 'mode_ == Mode::Zmq ? setenv("ZMQ", "1", 1) : unsetenv("ZMQ")' in src
def test_zmq_mode_subscribes_to_the_given_address(self):
# regression guard: upstream hardcodes 127.0.0.1 and relies on the bridge fork,
# which never reads the canproxy publisher
src = read("streams/devicestream.cc")
assert 'const std::string address = mode_ == Mode::Zmq ? address_.toStdString() : "127.0.0.1";' in src
def test_only_bridge_mode_forks_the_bridge(self):
src = read("streams/devicestream.cc")
assert "if (mode_ == Mode::Bridge) {" in src

View File

@@ -0,0 +1,206 @@
#include <filesystem>
#include <sstream>
#include "common/tests/native_test.h"
#include "tools/cabana/dbc/dbcfile.h"
#include "tools/cabana/dbc/dbcmanager.h"
const std::string TEST_RLOG_URL = "https://commadataci.blob.core.windows.net/openpilotci/0c94aa1e1296d7c6/2021-05-05--19-48-37/0/rlog.bz2";
void test_generate_dbc() {
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]);
}
}
}
void test_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);
}
void test_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);
}
void test_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);
}
void test_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\"");
}
void test_parse_iqdbc() {
std::vector<std::string> errors;
int parsed = 0;
for (const auto &entry : std::filesystem::recursive_directory_iterator(OPENDBC_FILE_PATH)) {
if (!entry.is_regular_file() || entry.path().extension() != ".dbc") continue;
try {
auto dbc = DBCFile(entry.path().string());
++parsed;
} catch (std::exception &e) {
errors.push_back(e.what());
}
}
// guard against OPENDBC_FILE_PATH pointing somewhere empty, which would make the
// loop above pass vacuously
REQUIRE(parsed > 100);
std::ostringstream details;
for (const auto &error : errors) details << error << '\n';
if (!errors.empty()) std::cerr << details.str();
REQUIRE(errors.empty());
}
void test_dbc_manager() {
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);
}
void test_cabana_core() {
test_generate_dbc();
test_comment_order();
test_preserve_original_header();
test_escaped_quotes();
test_parse_dbc();
test_parse_iqdbc();
test_dbc_manager();
}
int main() {
return run_native_test(test_cabana_core);
}

View File

@@ -0,0 +1,284 @@
#include "tools/cabana/tools/findsignal.h"
#include <set>
#include <thread>
#include <QFormLayout>
#include <QHBoxLayout>
#include <QHeaderView>
#include <QMenu>
#include <QTimer>
#include <QVBoxLayout>
// FindSignalModel
QVariant FindSignalModel::headerData(int section, Qt::Orientation orientation, int role) const {
static QString titles[] = {"Id", "Start Bit, size", "(time, value)"};
if (role != Qt::DisplayRole) return {};
return orientation == Qt::Horizontal ? titles[section] : QString::number(section + 1);
}
QVariant FindSignalModel::data(const QModelIndex &index, int role) const {
if (role == Qt::DisplayRole) {
const auto &s = filtered_signals[index.row()];
switch (index.column()) {
case 0: return QString::fromStdString(s.id.toString());
case 1: return QString("%1, %2").arg(s.sig.start_bit).arg(s.sig.size);
case 2: return s.values.join(" ");
}
}
return {};
}
void FindSignalModel::search(std::function<bool(double)> cmp) {
beginResetModel();
std::mutex lock;
const auto prev_sigs = !histories.empty() ? histories.back() : initial_signals;
filtered_signals.clear();
filtered_signals.reserve(prev_sigs.size());
unsigned int num_threads = std::max(1u, std::thread::hardware_concurrency());
size_t chunk = (prev_sigs.size() + num_threads - 1) / num_threads;
std::vector<std::thread> threads;
for (unsigned int t = 0; t < num_threads && t * chunk < (size_t)prev_sigs.size(); ++t) {
size_t start = t * chunk;
size_t end = std::min(start + chunk, (size_t)prev_sigs.size());
threads.emplace_back([&, start, end]() {
for (size_t i = start; i < end; ++i) {
const auto &s = prev_sigs[i];
const auto &events = can->events(s.id);
auto first = std::upper_bound(events.cbegin(), events.cend(), s.mono_time, CompareCanEvent());
auto last = events.cend();
if (last_time < std::numeric_limits<uint64_t>::max()) {
last = std::upper_bound(events.cbegin(), events.cend(), last_time, CompareCanEvent());
}
auto it = std::find_if(first, last, [&](const CanEvent *e) { return cmp(get_raw_value(e->dat, e->size, s.sig)); });
if (it != last) {
auto values = s.values;
values += QString("(%1, %2)").arg(can->toSeconds((*it)->mono_time), 0, 'f', 3).arg(get_raw_value((*it)->dat, (*it)->size, s.sig));
std::lock_guard lk(lock);
filtered_signals.push_back({.id = s.id, .mono_time = (*it)->mono_time, .sig = s.sig, .values = values});
}
}
});
}
for (auto &th : threads) th.join();
histories.push_back(filtered_signals);
endResetModel();
}
void FindSignalModel::undo() {
if (!histories.empty()) {
beginResetModel();
histories.pop_back();
filtered_signals.clear();
if (!histories.empty()) filtered_signals = histories.back();
endResetModel();
}
}
void FindSignalModel::reset() {
beginResetModel();
histories.clear();
filtered_signals.clear();
initial_signals.clear();
endResetModel();
}
// FindSignalDlg
FindSignalDlg::FindSignalDlg(QWidget *parent) : QDialog(parent, Qt::WindowFlags() | Qt::Window) {
setWindowTitle(tr("Find Signal"));
setAttribute(Qt::WA_DeleteOnClose);
QVBoxLayout *main_layout = new QVBoxLayout(this);
// Messages group
message_group = new QGroupBox(tr("Messages"), this);
QFormLayout *message_layout = new QFormLayout(message_group);
message_layout->addRow(tr("Bus"), bus_edit = new QLineEdit());
bus_edit->setPlaceholderText(tr("comma-separated values. Leave blank for all"));
message_layout->addRow(tr("Address"), address_edit = new QLineEdit());
address_edit->setPlaceholderText(tr("comma-separated hex values. Leave blank for all"));
QHBoxLayout *hlayout = new QHBoxLayout();
hlayout->addWidget(first_time_edit = new QLineEdit("0"));
hlayout->addWidget(new QLabel("-"));
hlayout->addWidget(last_time_edit = new QLineEdit("MAX"));
hlayout->addWidget(new QLabel("seconds"));
hlayout->addStretch(0);
message_layout->addRow(tr("Time"), hlayout);
// Signal group
properties_group = new QGroupBox(tr("Signal"));
QFormLayout *property_layout = new QFormLayout(properties_group);
property_layout->setFieldGrowthPolicy(QFormLayout::FieldsStayAtSizeHint);
hlayout = new QHBoxLayout();
hlayout->addWidget(min_size = new QSpinBox);
hlayout->addWidget(new QLabel("-"));
hlayout->addWidget(max_size = new QSpinBox);
hlayout->addWidget(litter_endian = new QCheckBox(tr("Little endian")));
hlayout->addWidget(is_signed = new QCheckBox(tr("Signed")));
hlayout->addStretch(0);
min_size->setRange(1, 64);
max_size->setRange(1, 64);
min_size->setValue(8);
max_size->setValue(8);
litter_endian->setChecked(true);
property_layout->addRow(tr("Size"), hlayout);
property_layout->addRow(tr("Factor"), factor_edit = new QLineEdit("1.0"));
property_layout->addRow(tr("Offset"), offset_edit = new QLineEdit("0.0"));
// find group
QGroupBox *find_group = new QGroupBox(tr("Find signal"), this);
QVBoxLayout *vlayout = new QVBoxLayout(find_group);
hlayout = new QHBoxLayout();
hlayout->addWidget(new QLabel(tr("Value")));
hlayout->addWidget(compare_cb = new QComboBox(this));
hlayout->addWidget(value1 = new QLineEdit);
hlayout->addWidget(to_label = new QLabel("-"));
hlayout->addWidget(value2 = new QLineEdit);
hlayout->addWidget(undo_btn = new QPushButton(tr("Undo prev find"), this));
hlayout->addWidget(search_btn = new QPushButton(tr("Find")));
hlayout->addWidget(reset_btn = new QPushButton(tr("Reset"), this));
vlayout->addLayout(hlayout);
compare_cb->addItems({"=", ">", ">=", "!=", "<", "<=", "between"});
value1->setFocus(Qt::OtherFocusReason);
value2->setVisible(false);
to_label->setVisible(false);
undo_btn->setEnabled(false);
reset_btn->setEnabled(false);
auto double_validator = new DoubleValidator(this);
for (auto edit : {value1, value2, factor_edit, offset_edit, first_time_edit, last_time_edit}) {
edit->setValidator(double_validator);
}
vlayout->addWidget(view = new QTableView(this));
view->setContextMenuPolicy(Qt::CustomContextMenu);
view->horizontalHeader()->setStretchLastSection(true);
view->horizontalHeader()->setSelectionMode(QAbstractItemView::NoSelection);
view->setSelectionBehavior(QAbstractItemView::SelectRows);
view->setModel(model = new FindSignalModel(this));
hlayout = new QHBoxLayout();
hlayout->addWidget(message_group);
hlayout->addWidget(properties_group);
main_layout->addLayout(hlayout);
main_layout->addWidget(find_group);
main_layout->addWidget(stats_label = new QLabel());
setMinimumSize({700, 650});
QObject::connect(search_btn, &QPushButton::clicked, this, &FindSignalDlg::search);
QObject::connect(undo_btn, &QPushButton::clicked, model, &FindSignalModel::undo);
QObject::connect(model, &QAbstractItemModel::modelReset, this, &FindSignalDlg::modelReset);
QObject::connect(reset_btn, &QPushButton::clicked, model, &FindSignalModel::reset);
QObject::connect(view, &QTableView::customContextMenuRequested, this, &FindSignalDlg::customMenuRequested);
QObject::connect(view, &QTableView::doubleClicked, [this](const QModelIndex &index) {
if (index.isValid()) emit openMessage(model->filtered_signals[index.row()].id);
});
QObject::connect(compare_cb, qOverload<int>(&QComboBox::currentIndexChanged), [=](int index) {
to_label->setVisible(index == compare_cb->count() - 1);
value2->setVisible(index == compare_cb->count() - 1);
});
}
void FindSignalDlg::search() {
if (model->histories.empty()) {
setInitialSignals();
}
auto v1 = value1->text().toDouble();
auto v2 = value2->text().toDouble();
std::function<bool(double)> cmp = nullptr;
switch (compare_cb->currentIndex()) {
case 0: cmp = [v1](double v) { return v == v1;}; break;
case 1: cmp = [v1](double v) { return v > v1;}; break;
case 2: cmp = [v1](double v) { return v >= v1;}; break;
case 3: cmp = [v1](double v) { return v != v1;}; break;
case 4: cmp = [v1](double v) { return v < v1;}; break;
case 5: cmp = [v1](double v) { return v <= v1;}; break;
case 6: cmp = [v1, v2](double v) { return v >= v1 && v <= v2;}; break;
}
properties_group->setEnabled(false);
message_group->setEnabled(false);
search_btn->setEnabled(false);
stats_label->setVisible(false);
search_btn->setText("Finding ....");
QTimer::singleShot(0, this, [=]() { model->search(cmp); });
}
void FindSignalDlg::setInitialSignals() {
std::set<ushort> buses;
for (auto bus : bus_edit->text().trimmed().split(",")) {
bus = bus.trimmed();
if (!bus.isEmpty()) buses.insert(bus.toUShort());
}
std::set<uint32_t> addresses;
for (auto addr : address_edit->text().trimmed().split(",")) {
addr = addr.trimmed();
if (!addr.isEmpty()) addresses.insert(addr.toULong(nullptr, 16));
}
cabana::Signal sig{};
sig.is_little_endian = litter_endian->isChecked();
sig.is_signed = is_signed->isChecked();
sig.factor = factor_edit->text().toDouble();
sig.offset = offset_edit->text().toDouble();
double first_time_val = first_time_edit->text().toDouble();
double last_time_val = last_time_edit->text().toDouble();
auto [first_sec, last_sec] = std::minmax(first_time_val, last_time_val);
uint64_t first_time = can->toMonoTime(first_sec);
model->last_time = std::numeric_limits<uint64_t>::max();
if (last_sec > 0) {
model->last_time = can->toMonoTime(last_sec);
}
model->initial_signals.clear();
for (const auto &[id, m] : can->lastMessages()) {
if ((buses.empty() || buses.count(id.source)) && (addresses.empty() || addresses.count(id.address))) {
const auto &events = can->events(id);
auto e = std::lower_bound(events.cbegin(), events.cend(), first_time, CompareCanEvent());
if (e != events.cend()) {
const int total_size = m.dat.size() * 8;
for (int size = min_size->value(); size <= max_size->value(); ++size) {
for (int start = 0; start <= total_size - size; ++start) {
FindSignalModel::SearchSignal s{.id = id, .mono_time = first_time, .sig = sig};
s.sig.start_bit = start;
s.sig.size = size;
updateMsbLsb(s.sig);
s.value = get_raw_value((*e)->dat, (*e)->size, s.sig);
model->initial_signals.push_back(s);
}
}
}
}
}
}
void FindSignalDlg::modelReset() {
properties_group->setEnabled(model->histories.empty());
message_group->setEnabled(model->histories.empty());
search_btn->setText(model->histories.empty() ? tr("Find") : tr("Find Next"));
reset_btn->setEnabled(!model->histories.empty());
undo_btn->setEnabled(model->histories.size() > 1);
search_btn->setEnabled(model->rowCount() > 0 || model->histories.empty());
stats_label->setVisible(true);
stats_label->setText(tr("%1 matches. right click on an item to create signal. double click to open message").arg(model->filtered_signals.size()));
}
void FindSignalDlg::customMenuRequested(const QPoint &pos) {
if (auto index = view->indexAt(pos); index.isValid()) {
QMenu menu(this);
menu.addAction(tr("Create Signal"));
if (menu.exec(view->mapToGlobal(pos))) {
auto &s = model->filtered_signals[index.row()];
UndoStack::instance()->push(new AddSigCommand(s.id, s.sig));
emit openMessage(s.id);
}
}
}

View File

@@ -0,0 +1,66 @@
#pragma once
#include <algorithm>
#include <limits>
#include <string>
#include <vector>
#include <QAbstractTableModel>
#include <QCheckBox>
#include <QLabel>
#include <QPushButton>
#include <QTableView>
#include "tools/cabana/commands.h"
#include "tools/cabana/settings.h"
class FindSignalModel : public QAbstractTableModel {
public:
struct SearchSignal {
MessageId id = {};
uint64_t mono_time = 0;
cabana::Signal sig = {};
double value = 0.;
QStringList values;
};
FindSignalModel(QObject *parent) : QAbstractTableModel(parent) {}
QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override;
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
int columnCount(const QModelIndex &parent = QModelIndex()) const override { return 3; }
int rowCount(const QModelIndex &parent = QModelIndex()) const override { return std::min((int)filtered_signals.size(), 300); }
void search(std::function<bool(double)> cmp);
void reset();
void undo();
std::vector<SearchSignal> filtered_signals;
std::vector<SearchSignal> initial_signals;
std::vector<std::vector<SearchSignal>> histories;
uint64_t last_time = std::numeric_limits<uint64_t>::max();
};
class FindSignalDlg : public QDialog {
Q_OBJECT
public:
FindSignalDlg(QWidget *parent);
signals:
void openMessage(const MessageId &id);
private:
void search();
void modelReset();
void setInitialSignals();
void customMenuRequested(const QPoint &pos);
QLineEdit *value1, *value2, *factor_edit, *offset_edit;
QLineEdit *bus_edit, *address_edit, *first_time_edit, *last_time_edit;
QComboBox *compare_cb;
QSpinBox *min_size, *max_size;
QCheckBox *litter_endian, *is_signed;
QPushButton *search_btn, *reset_btn, *undo_btn;
QGroupBox *properties_group, *message_group;
QTableView *view;
QLabel *to_label, *stats_label;
FindSignalModel *model;
};

View File

@@ -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;
}

View File

@@ -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;
};

View File

@@ -0,0 +1,40 @@
#include "tools/cabana/tools/routeinfo.h"
#include <QHeaderView>
#include <QScrollBar>
#include <QTableWidget>
#include <QVBoxLayout>
#include "tools/cabana/streams/replaystream.h"
RouteInfoDlg::RouteInfoDlg(QWidget *parent) : QDialog(parent) {
auto *replay = qobject_cast<ReplayStream *>(can)->getReplay();
setWindowTitle(tr("Route: %1").arg(QString::fromStdString(replay->route().name())));
auto *table = new QTableWidget(replay->route().segments().size(), 7, this);
table->setToolTip(tr("Click on a row to seek to the corresponding segment."));
table->setEditTriggers(QAbstractItemView::NoEditTriggers);
table->setSelectionBehavior(QAbstractItemView::SelectRows);
table->setSelectionMode(QAbstractItemView::SingleSelection);
table->setHorizontalHeaderLabels({"", "rlog", "fcam", "ecam", "dcam", "qlog", "qcam"});
table->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents);
table->verticalHeader()->setVisible(false);
table->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
int row = 0;
for (const auto &[seg_num, seg] : replay->route().segments()) {
table->setItem(row, 0, new QTableWidgetItem(QString::number(seg_num)));
table->setItem(row, 1, new QTableWidgetItem(seg.rlog.empty() ? "--" : "Yes"));
table->setItem(row, 2, new QTableWidgetItem(seg.road_cam.empty() ? "--" : "Yes"));
table->setItem(row, 3, new QTableWidgetItem(seg.wide_road_cam.empty() ? "--" : "Yes"));
table->setItem(row, 4, new QTableWidgetItem(seg.driver_cam.empty() ? "--" : "Yes"));
table->setItem(row, 5, new QTableWidgetItem(seg.qlog.empty() ? "--" : "Yes"));
table->setItem(row, 6, new QTableWidgetItem(seg.qcamera.empty() ? "--" : "Yes"));
++row;
}
table->setMinimumWidth(table->horizontalHeader()->length() + table->verticalScrollBar()->sizeHint().width());
table->setMinimumHeight(table->rowHeight(0) * std::min(table->rowCount(), 13) + table->horizontalHeader()->height() + table->frameWidth() * 2);
connect(table, &QTableWidget::itemClicked, [](QTableWidgetItem *item) { can->seekTo(item->row() * 60.0); });
QVBoxLayout *layout = new QVBoxLayout(this);
layout->addWidget(table);
}

View File

@@ -0,0 +1,8 @@
#pragma once
#include <QDialog>
class RouteInfoDlg : public QDialog {
Q_OBJECT
public:
RouteInfoDlg(QWidget *parent = nullptr);
};

View File

@@ -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());
}

View File

@@ -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_;
};

View File

@@ -0,0 +1,45 @@
#include "tools/cabana/utils/export.h"
#include <fstream>
#include <iomanip>
#include "tools/cabana/streams/abstractstream.h"
namespace utils {
void exportToCSV(const std::string &file_name, std::optional<MessageId> msg_id) {
std::ofstream stream(file_name, std::ios::trunc);
if (stream) {
stream << "time,addr,bus,data\n";
for (auto e : msg_id ? can->events(*msg_id) : can->allEvents()) {
stream << std::fixed << std::setprecision(3) << can->toSeconds(e->mono_time) << ","
<< "0x" << std::hex << e->address << std::dec << "," << static_cast<int>(e->src) << ",0x"
<< std::uppercase << std::hex << std::setfill('0');
for (int i = 0; i < e->size; ++i) stream << std::setw(2) << static_cast<int>(e->dat[i]);
stream << std::nouppercase << std::dec << "\n";
}
}
}
void exportSignalsToCSV(const std::string &file_name, const MessageId &msg_id) {
std::ofstream stream(file_name, std::ios::trunc);
if (auto msg = dbc()->msg(msg_id); msg && !msg->sigs.empty() && stream) {
stream << "time,addr,bus";
for (auto s : msg->sigs)
stream << "," << s->name.c_str();
stream << "\n";
for (auto e : can->events(msg_id)) {
stream << std::fixed << std::setprecision(3) << can->toSeconds(e->mono_time) << ","
<< "0x" << std::hex << e->address << std::dec << "," << static_cast<int>(e->src);
for (auto s : msg->sigs) {
double value = 0;
s->getValue(e->dat, e->size, &value);
stream << "," << std::fixed << std::setprecision(s->precision) << value;
}
stream << "\n";
}
}
}
} // namespace utils

View File

@@ -0,0 +1,11 @@
#pragma once
#include <optional>
#include <string>
#include "tools/cabana/dbc/dbcmanager.h"
namespace utils {
void exportToCSV(const std::string &file_name, std::optional<MessageId> msg_id = std::nullopt);
void exportSignalsToCSV(const std::string &file_name, const MessageId &msg_id);
} // namespace utils

View File

@@ -0,0 +1,545 @@
#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);
// IQ.Pilot patch: ToolButton("") (chartswidget.cc) asks for an empty id, so pm can
// be null. Painting a null QPixmap is a no-op that logs two QPainter warnings on
// every dark-theme start. Upstream candidate.
if (dark_theme && !pm.isNull()) {
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;
}

View File

@@ -0,0 +1,220 @@
#pragma once
#include <array>
#include <atomic>
#include <cmath>
#include <filesystem>
#include <string>
#include <thread>
#include <vector>
#include <utility>
#include <QApplication>
#include <QColor>
#include <QFont>
#include <QFontMetrics>
#include <QPainter>
#include <QStaticText>
#include <QStringBuilder>
#include <QStyledItemDelegate>
#include <QToolButton>
#include <QValidator>
#include "tools/cabana/dbc/dbc.h"
#include "tools/cabana/settings.h"
inline QColor toQColor(const CabanaColor &color) {
return QColor(color.r, color.g, color.b, color.a);
}
class LogSlider : public QSlider {
Q_OBJECT
public:
LogSlider(double factor, Qt::Orientation orientation, QWidget *parent = nullptr) : factor(factor), QSlider(orientation, parent) {}
void setRange(double min, double max) {
log_min = factor * std::log10(min);
log_max = factor * std::log10(max);
QSlider::setRange(min, max);
setValue(QSlider::value());
}
int value() const {
double v = log_min + (log_max - log_min) * ((QSlider::value() - minimum()) / double(maximum() - minimum()));
return std::lround(std::pow(10, v / factor));
}
void setValue(int v) {
double log_v = std::clamp(factor * std::log10(v), log_min, log_max);
v = minimum() + (maximum() - minimum()) * ((log_v - log_min) / (log_max - log_min));
QSlider::setValue(v);
}
private:
double factor, log_min = 0, log_max = 1;
};
enum {
ColorsRole = Qt::UserRole + 1,
BytesRole = Qt::UserRole + 2
};
class SegmentTree {
public:
SegmentTree() = default;
void build(const std::vector<QPointF> &arr);
inline std::pair<double, double> minmax(int left, int right) const { return get_minmax(1, 0, size - 1, left, right); }
private:
std::pair<double, double> get_minmax(int n, int left, int right, int range_left, int range_right) const;
void build_tree(const std::vector<QPointF> &arr, int n, int left, int right);
std::vector<std::pair<double, double>> tree;
int size = 0;
};
class MessageBytesDelegate : public QStyledItemDelegate {
Q_OBJECT
public:
MessageBytesDelegate(QObject *parent, bool multiple_lines = false);
void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override;
QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override;
bool multipleLines() const { return multiple_lines; }
void setMultipleLines(bool v) { multiple_lines = v; }
QSize sizeForBytes(int n) const;
private:
std::array<QStaticText, 256> hex_text_table;
QFontMetrics font_metrics;
QFont fixed_font;
QSize byte_size = {};
bool multiple_lines = false;
int h_margin, v_margin;
};
// Accepts a single identifier: one or more [A-Za-z0-9_], spaces rewritten to '_'.
class NameValidator : public QValidator {
Q_OBJECT
public:
NameValidator(QObject *parent=nullptr);
QValidator::State validate(QString &input, int &pos) const override;
};
// Accepts comma-separated identifiers: \w+(,\w+)*
class NodeValidator : public QValidator {
Q_OBJECT
public:
NodeValidator(QObject *parent=nullptr);
QValidator::State validate(QString &input, int &pos) const override;
};
// Accepts one or more non-whitespace characters (\S+).
class NonWhitespaceValidator : public QValidator {
Q_OBJECT
public:
NonWhitespaceValidator(QObject *parent=nullptr);
QValidator::State validate(QString &input, int &pos) const override;
};
// Accepts a dotted IPv4 address (0-255 per octet).
class IpAddressValidator : public QValidator {
Q_OBJECT
public:
IpAddressValidator(QObject *parent=nullptr);
QValidator::State validate(QString &input, int &pos) const override;
};
// C-locale floating-point validator (matches QString::toDouble).
class DoubleValidator : public QValidator {
Q_OBJECT
public:
DoubleValidator(QObject *parent = nullptr);
QValidator::State validate(QString &input, int &pos) const override;
};
namespace utils {
QPixmap icon(const QString &id);
std::string homePath();
std::filesystem::path configPath();
bool getClipboardText(std::string *text); // false if no clipboard tool is available
bool setClipboardText(const std::string &text);
bool isDarkTheme();
void setTheme(int theme);
QString formatSeconds(double sec, bool include_milliseconds = false, bool absolute_time = false);
inline void drawStaticText(QPainter *p, const QRect &r, const QStaticText &text) {
auto size = (r.size() - text.size()) / 2;
p->drawStaticText(r.left() + size.width(), r.top() + size.height(), text);
}
inline QString toHex(const std::vector<uint8_t> &dat, char separator = '\0') {
static const char digits[] = "0123456789ABCDEF";
QString hex;
hex.reserve(dat.size() * (separator ? 3 : 2));
for (size_t i = 0; i < dat.size(); ++i) {
if (separator && i) hex += QLatin1Char(separator);
hex += QLatin1Char(digits[dat[i] >> 4]);
hex += QLatin1Char(digits[dat[i] & 0xf]);
}
return hex;
}
// boundary conversions for the remaining Qt byte-array based state APIs
template <typename T>
std::vector<uint8_t> toBytes(const T &dat) { return {dat.begin(), dat.end()}; }
inline auto qbytes(const std::vector<uint8_t> &dat) {
return decltype(QString().toUtf8())((const char *)dat.data(), (int)dat.size());
}
}
class ToolButton : public QToolButton {
Q_OBJECT
public:
ToolButton(const QString &icon, const QString &tooltip = {}, QWidget *parent = nullptr) : QToolButton(parent) {
setIcon(icon);
setToolTip(tooltip);
setAutoRaise(true);
const int metric = QApplication::style()->pixelMetric(QStyle::PM_SmallIconSize);
setIconSize({metric, metric});
theme = settings.theme;
connect(&settings, &Settings::changed, this, &ToolButton::updateIcon);
}
void setIcon(const QString &icon) {
icon_str = icon;
QToolButton::setIcon(utils::icon(icon_str));
}
private:
void updateIcon() { if (std::exchange(theme, settings.theme) != theme) setIcon(icon_str); }
QString icon_str;
int theme;
};
class TabBar : public QTabBar {
Q_OBJECT
public:
TabBar(QWidget *parent) : QTabBar(parent) {}
int addTab(const QString &text);
private:
void closeTabClicked();
};
// Watches SIGINT/SIGTERM via a self-pipe and a dedicated waiter thread
// (no Qt notifiers/timers). Exit is marshaled onto the GUI thread.
class UnixSignalHandler {
public:
UnixSignalHandler();
~UnixSignalHandler();
static void signalHandler(int s);
private:
inline static int sig_fd[2] = {};
std::atomic<bool> shutting_down{false};
std::thread waiter;
};
int num_decimals(double num);
QString signalToolTip(const cabana::Signal *sig);
inline QString toHexString(int value) { return QString("0x%1").arg(QString::number(value, 16).toUpper(), 2, '0'); }
void initApp(int argc, char *argv[], bool disable_hidpi = true);
QPixmap bootstrapPixmap(const QString &id);

View File

@@ -0,0 +1,433 @@
#include "tools/cabana/videowidget.h"
#include <algorithm>
#include <thread>
#include <QAction>
#include <QActionGroup>
#include <QMenu>
#include <QMouseEvent>
#include <QPainter>
#include <QStyleOptionSlider>
#include <QVBoxLayout>
#include "tools/cabana/tools/routeinfo.h"
const int MIN_VIDEO_HEIGHT = 100;
const int THUMBNAIL_MARGIN = 3;
// Indexed by TimelineType: None, Engaged, AlertInfo, AlertWarning, AlertCritical, UserBookmark
static const QColor timeline_colors[] = {
QColor(111, 143, 175),
QColor(0, 163, 108),
Qt::green,
QColor(255, 195, 0),
QColor(199, 0, 57),
Qt::magenta,
};
static Replay *getReplay() {
auto stream = qobject_cast<ReplayStream *>(can);
return stream ? stream->getReplay() : nullptr;
}
VideoWidget::VideoWidget(QWidget *parent) : QFrame(parent) {
setFrameStyle(QFrame::StyledPanel | QFrame::Plain);
auto main_layout = new QVBoxLayout(this);
main_layout->setContentsMargins(0, 0, 0, 0);
main_layout->setSpacing(0);
if (!can->liveStreaming())
main_layout->addWidget(createCameraWidget());
createPlaybackController();
setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Maximum);
QObject::connect(can, &AbstractStream::paused, this, &VideoWidget::updatePlayBtnState);
QObject::connect(can, &AbstractStream::resume, this, &VideoWidget::updatePlayBtnState);
QObject::connect(can, &AbstractStream::msgsReceived, this, &VideoWidget::updateState);
QObject::connect(can, &AbstractStream::seeking, this, &VideoWidget::updateState);
QObject::connect(can, &AbstractStream::timeRangeChanged, this, &VideoWidget::timeRangeChanged);
updatePlayBtnState();
setWhatsThis(tr(R"(
<b>Video</b><br />
<!-- TODO: add description here -->
<span style="color:gray">Timeline color</span>
<table>
<tr><td><span style="color:%1;"> </span>Disengaged </td>
<td><span style="color:%2;"> </span>Engaged</td></tr>
<tr><td><span style="color:%3;"> </span>User Flag </td>
<td><span style="color:%4;"> </span>Info</td></tr>
<tr><td><span style="color:%5;"> </span>Warning </td>
<td><span style="color:%6;"> </span>Critical</td></tr>
</table>
<span style="color:gray">Shortcuts</span><br/>
Pause/Resume: <span style="background-color:lightGray;color:gray">&nbsp;space&nbsp;</span>
)").arg(timeline_colors[(int)TimelineType::None].name(),
timeline_colors[(int)TimelineType::Engaged].name(),
timeline_colors[(int)TimelineType::UserBookmark].name(),
timeline_colors[(int)TimelineType::AlertInfo].name(),
timeline_colors[(int)TimelineType::AlertWarning].name(),
timeline_colors[(int)TimelineType::AlertCritical].name()));
}
void VideoWidget::createPlaybackController() {
QToolBar *toolbar = new QToolBar(this);
layout()->addWidget(toolbar);
int icon_size = style()->pixelMetric(QStyle::PM_SmallIconSize);
toolbar->setIconSize({icon_size, icon_size});
toolbar->addAction(utils::icon("rewind"), tr("Seek backward"), []() { can->seekTo(can->currentSec() - 1); });
play_toggle_action = toolbar->addAction(utils::icon("play"), tr("Play"), []() { can->pause(!can->isPaused()); });
toolbar->addAction(utils::icon("fast-forward"), tr("Seek forward"), []() { can->seekTo(can->currentSec() + 1); });
if (can->liveStreaming()) {
skip_to_end_action = toolbar->addAction(utils::icon("skip-end"), tr("Skip to the end"), this, [this]() {
// set speed to 1.0
speed_btn->menu()->actions()[7]->setChecked(true);
can->pause(false);
can->seekTo(can->maxSeconds() + 1);
});
}
time_display_action = toolbar->addAction("", this, [this]() {
settings.absolute_time = !settings.absolute_time;
time_display_action->setToolTip(settings.absolute_time ? tr("Elapsed time") : tr("Absolute time"));
updateState();
});
QWidget *spacer = new QWidget();
spacer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred);
toolbar->addWidget(spacer);
if (!can->liveStreaming()) {
toolbar->addAction(utils::icon("repeat"), tr("Loop playback"), this, &VideoWidget::loopPlaybackClicked);
createSpeedDropdown(toolbar);
toolbar->addSeparator();
toolbar->addAction(utils::icon("info-circle"), tr("View route details"), this, &VideoWidget::showRouteInfo);
} else {
createSpeedDropdown(toolbar);
}
}
void VideoWidget::createSpeedDropdown(QToolBar *toolbar) {
toolbar->addWidget(speed_btn = new QToolButton(this));
speed_btn->setMenu(new QMenu(speed_btn));
speed_btn->setPopupMode(QToolButton::InstantPopup);
QActionGroup *speed_group = new QActionGroup(this);
speed_group->setExclusive(true);
for (float speed : {0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 0.8, 1., 2., 3., 5.}) {
auto act = speed_btn->menu()->addAction(QString("%1x").arg(speed), this, [this, speed]() {
can->setSpeed(speed);
speed_btn->setText(QString("%1x ").arg(speed));
});
speed_group->addAction(act);
act->setCheckable(true);
if (speed == 1.0) {
act->setChecked(true);
act->trigger();
}
}
QFont font = speed_btn->font();
font.setBold(true);
speed_btn->setFont(font);
speed_btn->setMinimumWidth(speed_btn->fontMetrics().horizontalAdvance("0.05x ") + style()->pixelMetric(QStyle::PM_MenuButtonIndicator));
}
QWidget *VideoWidget::createCameraWidget() {
QWidget *w = new QWidget(this);
QVBoxLayout *l = new QVBoxLayout(w);
l->setContentsMargins(0, 0, 0, 0);
l->setSpacing(0);
l->addWidget(camera_tab = new TabBar(w));
camera_tab->setAutoHide(true);
camera_tab->setExpanding(false);
l->addWidget(cam_widget = new StreamCameraView("camerad", VISION_STREAM_ROAD));
cam_widget->setMinimumHeight(MIN_VIDEO_HEIGHT);
cam_widget->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::MinimumExpanding);
l->addWidget(slider = new Slider(w));
slider->setSingleStep(0);
slider->setTimeRange(can->minSeconds(), can->maxSeconds());
QObject::connect(slider, &QSlider::sliderReleased, [this]() { can->seekTo(slider->currentSecond()); });
QObject::connect(can, &AbstractStream::paused, cam_widget, qOverload<>(&StreamCameraView::update));
QObject::connect(can, &AbstractStream::eventsMerged, this, [this]() { slider->update(); });
QObject::connect(cam_widget, &CameraWidget::clicked, []() { can->pause(!can->isPaused()); });
QObject::connect(cam_widget, &CameraWidget::vipcAvailableStreamsUpdated, this, &VideoWidget::vipcAvailableStreamsUpdated);
QObject::connect(camera_tab, &QTabBar::currentChanged, [this](int index) {
if (index != -1) cam_widget->setStreamType((VisionStreamType)camera_tab->tabData(index).toInt());
});
QObject::connect(static_cast<ReplayStream*>(can), &ReplayStream::qLogLoaded, cam_widget, &StreamCameraView::parseQLog, Qt::QueuedConnection);
slider->installEventFilter(this);
return w;
}
void VideoWidget::vipcAvailableStreamsUpdated(std::set<VisionStreamType> streams) {
static const QString stream_names[] = {"Road camera", "Driver camera", "Wide road camera"};
for (int i = 0; i < streams.size(); ++i) {
if (camera_tab->count() <= i) {
camera_tab->addTab(QString());
}
int type = *std::next(streams.begin(), i);
camera_tab->setTabText(i, stream_names[type]);
camera_tab->setTabData(i, type);
}
while (camera_tab->count() > streams.size()) {
camera_tab->removeTab(camera_tab->count() - 1);
}
}
void VideoWidget::loopPlaybackClicked() {
bool is_looping = getReplay()->loop();
getReplay()->setLoop(!is_looping);
qobject_cast<QAction*>(sender())->setIcon(utils::icon(!is_looping ? "repeat" : "repeat-1"));
}
void VideoWidget::timeRangeChanged() {
const auto time_range = can->timeRange();
if (can->liveStreaming()) {
skip_to_end_action->setEnabled(!time_range.has_value());
return;
}
time_range ? slider->setTimeRange(time_range->first, time_range->second)
: slider->setTimeRange(can->minSeconds(), can->maxSeconds());
updateState();
}
QString VideoWidget::formatTime(double sec, bool include_milliseconds) {
if (settings.absolute_time)
sec += std::chrono::duration<double>(can->beginDateTime().time_since_epoch()).count();
return utils::formatSeconds(sec, include_milliseconds, settings.absolute_time);
}
void VideoWidget::updateState() {
if (slider) {
if (!slider->isSliderDown()) {
slider->setCurrentSecond(can->currentSec());
}
if (camera_tab->count() == 0) { // No streams available
cam_widget->update(); // Manually refresh to show alert events
}
time_display_action->setText(QString("%1 / %2").arg(formatTime(can->currentSec(), true),
formatTime(slider->maximum() / slider->factor)));
} else {
time_display_action->setText(formatTime(can->currentSec(), true));
}
}
void VideoWidget::updatePlayBtnState() {
play_toggle_action->setIcon(utils::icon(can->isPaused() ? "play" : "pause"));
play_toggle_action->setToolTip(can->isPaused() ? tr("Play") : tr("Pause"));
}
void VideoWidget::showThumbnail(double seconds) {
if (can->liveStreaming()) return;
cam_widget->thumbnail_dispaly_time = seconds;
slider->thumbnail_dispaly_time = seconds;
cam_widget->update();
slider->update();
}
void VideoWidget::showRouteInfo() {
RouteInfoDlg *route_info = new RouteInfoDlg(this);
route_info->setAttribute(Qt::WA_DeleteOnClose);
route_info->show();
}
bool VideoWidget::eventFilter(QObject *obj, QEvent *event) {
if (event->type() == QEvent::MouseMove) {
auto [min_sec, max_sec] = can->timeRange().value_or(std::make_pair(can->minSeconds(), can->maxSeconds()));
showThumbnail(min_sec + static_cast<QMouseEvent *>(event)->pos().x() * (max_sec - min_sec) / slider->width());
} else if (event->type() == QEvent::Leave) {
showThumbnail(-1);
}
return false;
}
// Slider
Slider::Slider(QWidget *parent) : QSlider(Qt::Horizontal, parent) {
setMouseTracking(true);
}
void Slider::paintEvent(QPaintEvent *ev) {
QPainter p(this);
QStyleOptionSlider opt;
initStyleOption(&opt);
QRect handle_rect = style()->subControlRect(QStyle::CC_Slider, &opt, QStyle::SC_SliderHandle, this);
QRect groove_rect = style()->subControlRect(QStyle::CC_Slider, &opt, QStyle::SC_SliderGroove, this);
// Adjust groove height to match handle height
int handle_height = handle_rect.height();
groove_rect.setHeight(handle_height * 0.5);
groove_rect.moveCenter(QPoint(groove_rect.center().x(), rect().center().y()));
p.fillRect(groove_rect, timeline_colors[(int)TimelineType::None]);
double min = minimum() / factor;
double max = maximum() / factor;
auto fillRange = [&](double begin, double end, const QColor &color) {
if (begin > max || end < min) return;
QRect r = groove_rect;
r.setLeft(((std::max(min, begin) - min) / (max - min)) * width());
r.setRight(((std::min(max, end) - min) / (max - min)) * width());
p.fillRect(r, color);
};
if (auto replay = getReplay()) {
for (const auto &entry : *replay->getTimeline()) {
fillRange(entry.start_time, entry.end_time, timeline_colors[(int)entry.type]);
}
QColor empty_color = palette().color(QPalette::Window);
empty_color.setAlpha(160);
const auto event_data = replay->getEventData();
for (const auto &[n, _] : replay->route().segments()) {
if (!event_data->isSegmentLoaded(n))
fillRange(n * 60.0, (n + 1) * 60.0, empty_color);
}
}
opt.minimum = minimum();
opt.maximum = maximum();
opt.subControls = QStyle::SC_SliderHandle;
opt.sliderPosition = value();
style()->drawComplexControl(QStyle::CC_Slider, &opt, &p);
if (thumbnail_dispaly_time >= 0) {
int left = (thumbnail_dispaly_time - min) * width() / (max - min) - 1;
QRect rc(left, rect().top() + 1, 2, rect().height() - 2);
p.setBrush(palette().highlight());
p.setPen(Qt::NoPen);
p.drawRoundedRect(rc, 1.5, 1.5);
}
}
void Slider::mousePressEvent(QMouseEvent *e) {
QSlider::mousePressEvent(e);
if (e->button() == Qt::LeftButton && !isSliderDown()) {
setValue(minimum() + ((maximum() - minimum()) * e->x()) / width());
emit sliderReleased();
}
}
// StreamCameraView
StreamCameraView::StreamCameraView(std::string stream_name, VisionStreamType stream_type, QWidget *parent)
: CameraWidget(stream_name, stream_type, parent) {
}
void StreamCameraView::parseQLog(std::shared_ptr<LogReader> qlog) {
std::mutex mutex;
const auto &events = qlog->events;
unsigned int num_threads = std::max(1u, std::thread::hardware_concurrency());
size_t chunk = (events.size() + num_threads - 1) / num_threads;
std::vector<std::thread> threads;
for (unsigned int t = 0; t < num_threads && t * chunk < events.size(); ++t) {
size_t start = t * chunk;
size_t end = std::min(start + chunk, events.size());
threads.emplace_back([this, &mutex, &events, start, end]() {
for (size_t i = start; i < end; ++i) {
const Event &e = events[i];
if (e.which == cereal::Event::Which::THUMBNAIL) {
capnp::FlatArrayMessageReader reader(e.data);
auto thumb_data = reader.getRoot<cereal::Event>().getThumbnail();
auto image_data = thumb_data.getThumbnail();
if (QPixmap thumb; thumb.loadFromData(image_data.begin(), image_data.size(), "jpeg")) {
QPixmap generated_thumb = generateThumbnail(thumb, can->toSeconds(thumb_data.getTimestampEof()));
std::lock_guard lock(mutex);
thumbnails[thumb_data.getTimestampEof()] = generated_thumb;
big_thumbnails[thumb_data.getTimestampEof()] = thumb;
}
}
}
});
}
for (auto &th : threads) th.join();
update();
}
void StreamCameraView::paintEvent(QPaintEvent *event) {
CameraWidget::paintEvent(event);
QPainter p(this);
bool scrubbing = false;
if (thumbnail_dispaly_time >= 0) {
scrubbing = can->isPaused();
scrubbing ? drawScrubThumbnail(p) : drawThumbnail(p);
}
if (auto alert = getReplay()->findAlertAtTime(scrubbing ? thumbnail_dispaly_time : can->currentSec())) {
drawAlert(p, rect(), *alert);
}
if (can->isPaused()) {
p.setPen(QColor(200, 200, 200, static_cast<int>(255 * 0.7f)));
p.setFont(QFont(font().family(), 16, QFont::Bold));
p.drawText(rect(), Qt::AlignCenter, tr("PAUSED"));
}
}
QPixmap StreamCameraView::generateThumbnail(QPixmap thumb, double seconds) {
QPixmap scaled = thumb.scaledToHeight(MIN_VIDEO_HEIGHT - THUMBNAIL_MARGIN * 2, Qt::SmoothTransformation);
QPainter p(&scaled);
p.setPen(QPen(palette().color(QPalette::BrightText), 2));
p.drawRect(scaled.rect());
if (auto alert = getReplay()->findAlertAtTime(seconds)) {
p.setFont(QFont(font().family(), 10));
drawAlert(p, scaled.rect(), *alert);
}
return scaled;
}
void StreamCameraView::drawScrubThumbnail(QPainter &p) {
p.fillRect(rect(), Qt::black);
auto it = big_thumbnails.lower_bound(can->toMonoTime(thumbnail_dispaly_time));
if (it != big_thumbnails.end()) {
QPixmap scaled_thumb = it->second.scaled(rect().size(), Qt::KeepAspectRatio, Qt::SmoothTransformation);
QRect thumb_rect(rect().center() - scaled_thumb.rect().center(), scaled_thumb.size());
p.drawPixmap(thumb_rect.topLeft(), scaled_thumb);
drawTime(p, thumb_rect, thumbnail_dispaly_time);
}
}
void StreamCameraView::drawThumbnail(QPainter &p) {
auto it = thumbnails.lower_bound(can->toMonoTime(thumbnail_dispaly_time));
if (it != thumbnails.end()) {
const QPixmap &thumb = it->second;
auto [min_sec, max_sec] = can->timeRange().value_or(std::make_pair(can->minSeconds(), can->maxSeconds()));
int pos = (thumbnail_dispaly_time - min_sec) * width() / (max_sec - min_sec);
int x = std::clamp(pos - thumb.width() / 2, THUMBNAIL_MARGIN, width() - thumb.width() - THUMBNAIL_MARGIN + 1);
int y = height() - thumb.height() - THUMBNAIL_MARGIN;
p.drawPixmap(x, y, thumb);
drawTime(p, QRect{x, y, thumb.width(), thumb.height()}, thumbnail_dispaly_time);
}
}
void StreamCameraView::drawTime(QPainter &p, const QRect &rect, double seconds) {
p.setPen(palette().color(QPalette::BrightText));
p.setFont(QFont(font().family(), 10));
p.drawText(rect.adjusted(0, 0, 0, -THUMBNAIL_MARGIN), Qt::AlignHCenter | Qt::AlignBottom, QString::number(seconds, 'f', 3));
}
void StreamCameraView::drawAlert(QPainter &p, const QRect &rect, const Timeline::Entry &alert) {
p.setPen(QPen(palette().color(QPalette::BrightText), 2));
QColor color = timeline_colors[int(alert.type)];
color.setAlphaF(0.5);
QString text = QString::fromStdString(alert.text1);
if (!alert.text2.empty()) text += "\n" + QString::fromStdString(alert.text2);
QRect text_rect = rect.adjusted(1, 1, -1, -1);
QRect r = p.fontMetrics().boundingRect(text_rect, Qt::AlignTop | Qt::AlignHCenter | Qt::TextWordWrap, text);
p.fillRect(text_rect.left(), r.top(), text_rect.width(), r.height(), color);
p.drawText(text_rect, Qt::AlignTop | Qt::AlignHCenter | Qt::TextWordWrap, text);
}

View File

@@ -0,0 +1,81 @@
#pragma once
#include <map>
#include <memory>
#include <set>
#include <string>
#include <utility>
#include <QFrame>
#include <QSlider>
#include <QToolBar>
#include <QTabBar>
#include "tools/cabana/cameraview.h"
#include "tools/cabana/utils/util.h"
#include "tools/replay/logreader.h"
#include "tools/cabana/streams/replaystream.h"
class Slider : public QSlider {
Q_OBJECT
public:
Slider(QWidget *parent);
double currentSecond() const { return value() / factor; }
void setCurrentSecond(double sec) { setValue(sec * factor); }
void setTimeRange(double min, double max) { setRange(min * factor, max * factor); }
void mousePressEvent(QMouseEvent *e) override;
void paintEvent(QPaintEvent *ev) override;
const double factor = 1000.0;
double thumbnail_dispaly_time = -1;
};
class StreamCameraView : public CameraWidget {
Q_OBJECT
public:
StreamCameraView(std::string stream_name, VisionStreamType stream_type, QWidget *parent = nullptr);
void paintEvent(QPaintEvent *event) override;
void parseQLog(std::shared_ptr<LogReader> qlog);
private:
QPixmap generateThumbnail(QPixmap thumbnail, double seconds);
void drawAlert(QPainter &p, const QRect &rect, const Timeline::Entry &alert);
void drawThumbnail(QPainter &p);
void drawScrubThumbnail(QPainter &p);
void drawTime(QPainter &p, const QRect &rect, double seconds);
std::map<uint64_t, QPixmap> big_thumbnails;
std::map<uint64_t, QPixmap> thumbnails;
double thumbnail_dispaly_time = -1;
friend class VideoWidget;
};
class VideoWidget : public QFrame {
Q_OBJECT
public:
VideoWidget(QWidget *parnet = nullptr);
void showThumbnail(double seconds);
protected:
bool eventFilter(QObject *obj, QEvent *event) override;
QString formatTime(double sec, bool include_milliseconds = false);
void timeRangeChanged();
void updateState();
void updatePlayBtnState();
QWidget *createCameraWidget();
void createPlaybackController();
void createSpeedDropdown(QToolBar *toolbar);
void loopPlaybackClicked();
void vipcAvailableStreamsUpdated(std::set<VisionStreamType> streams);
void showRouteInfo();
StreamCameraView *cam_widget;
QAction *time_display_action = nullptr;
QAction *play_toggle_action = nullptr;
QToolButton *speed_btn = nullptr;
QAction *skip_to_end_action = nullptr;
Slider *slider = nullptr;
QTabBar *camera_tab = nullptr;
};