From 61608db786e163a55b11bdd1c557f0cd663c5d11 Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Wed, 15 Jul 2026 21:39:47 -0700 Subject: [PATCH 01/35] jp: fix linking on macOS (#38353) * jp: fix linking on macOS * mv to root --- SConstruct | 5 +++++ openpilot/tools/jotpluggler/test_jotpluggler.py | 11 +++++++++++ 2 files changed, 16 insertions(+) create mode 100644 openpilot/tools/jotpluggler/test_jotpluggler.py diff --git a/SConstruct b/SConstruct index 72a25e12d4..2a69cd2e38 100644 --- a/SConstruct +++ b/SConstruct @@ -166,6 +166,11 @@ env = Environment( tools=["default", "cython", "compilation_db", "rednose_filter"], toolpath=["#site_scons/site_tools", "#rednose_repo/site_scons/site_tools"], ) +# SCons' Darwin linker tool doesn't define the variables used to expand RPATH. +if arch == "Darwin": + env["RPATHPREFIX"] = "-Wl,-rpath," + env["RPATHSUFFIX"] = "" + env["_RPATH"] = "${_concat(RPATHPREFIX, RPATH, RPATHSUFFIX, __env__)}" if arch != "larch64": env['_LIBFLAGS'] = _libflags diff --git a/openpilot/tools/jotpluggler/test_jotpluggler.py b/openpilot/tools/jotpluggler/test_jotpluggler.py new file mode 100644 index 0000000000..b9b751c4a5 --- /dev/null +++ b/openpilot/tools/jotpluggler/test_jotpluggler.py @@ -0,0 +1,11 @@ +import subprocess +from pathlib import Path + + +JOTPLUGGLER_DIR = Path(__file__).parent + + +def test_help(): + result = subprocess.run(["./jotpluggler", "-h"], cwd=JOTPLUGGLER_DIR, capture_output=True, text=True) + assert result.returncode == 0, result.stderr + assert "Usage:" in result.stderr From 60716edc3752339a1c83e38745dd5835c8b93060 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Wed, 15 Jul 2026 22:51:41 -0700 Subject: [PATCH 02/35] Fix thumbnail creation (#38354) it's not a stream --- openpilot/system/loggerd/encoder/jpeg_encoder.cc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpilot/system/loggerd/encoder/jpeg_encoder.cc b/openpilot/system/loggerd/encoder/jpeg_encoder.cc index 79f5e1b80f..6258e8144d 100644 --- a/openpilot/system/loggerd/encoder/jpeg_encoder.cc +++ b/openpilot/system/loggerd/encoder/jpeg_encoder.cc @@ -93,7 +93,7 @@ void JpegEncoder::compressToJpeg(uint8_t *y_plane, uint8_t *u_plane, uint8_t *v_ frame->data[2] = v_plane; // Required for MJPEG qscale to take effect (global_quality alone is not enough). frame->quality = FF_QP2LAMBDA * MJPEG_QSCALE; - frame->pts = 0; + frame->pts = AV_NOPTS_VALUE; int err = avcodec_send_frame(codec_ctx, frame); if (err < 0) { From 06a73f538e58c87597b83460011b1aef675ccac8 Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Thu, 16 Jul 2026 13:45:57 -0700 Subject: [PATCH 03/35] cabana: de-Qt, part 1 (#38357) * cabana: de-Qt, part 1 * lil more --- openpilot/tools/cabana/.gitignore | 1 + openpilot/tools/cabana/SConscript | 15 +- openpilot/tools/cabana/binaryview.cc | 13 +- openpilot/tools/cabana/chart/chart.cc | 19 +- openpilot/tools/cabana/chart/chartswidget.cc | 5 +- .../tools/cabana/chart/signalselector.cc | 3 +- openpilot/tools/cabana/chart/sparkline.cc | 2 +- openpilot/tools/cabana/core/can_data.h | 46 +++ openpilot/tools/cabana/core/color.h | 79 ++++ openpilot/tools/cabana/core/message_id.h | 27 ++ openpilot/tools/cabana/core/settings.h | 34 ++ openpilot/tools/cabana/dbc/dbc.cc | 16 +- openpilot/tools/cabana/dbc/dbc.h | 50 +-- openpilot/tools/cabana/dbc/dbcfile.cc | 350 +++++++++--------- openpilot/tools/cabana/dbc/dbcfile.h | 13 +- openpilot/tools/cabana/dbc/dbcmanager.cc | 35 +- openpilot/tools/cabana/dbc/dbcmanager.h | 35 +- openpilot/tools/cabana/dbc/dbcqt.cc | 18 + openpilot/tools/cabana/dbc/dbcqt.h | 27 ++ openpilot/tools/cabana/detailwidget.cc | 3 +- openpilot/tools/cabana/historylog.cc | 11 +- openpilot/tools/cabana/historylog.h | 2 +- openpilot/tools/cabana/mainwin.cc | 65 ++-- openpilot/tools/cabana/messageswidget.cc | 3 +- openpilot/tools/cabana/settings.cc | 40 +- openpilot/tools/cabana/settings.h | 36 +- openpilot/tools/cabana/signalview.cc | 19 +- .../tools/cabana/streams/abstractstream.cc | 23 +- .../tools/cabana/streams/abstractstream.h | 44 +-- openpilot/tools/cabana/streams/livestream.cc | 13 +- openpilot/tools/cabana/streams/livestream.h | 4 +- .../tools/cabana/streams/replaystream.cc | 4 +- openpilot/tools/cabana/streams/replaystream.h | 4 +- openpilot/tools/cabana/streamselector.cc | 4 +- openpilot/tools/cabana/tests/test_cabana.cc | 80 +++- openpilot/tools/cabana/tests/test_runner.cc | 3 - openpilot/tools/cabana/utils/export.cc | 32 +- openpilot/tools/cabana/utils/export.h | 5 +- openpilot/tools/cabana/utils/util.cc | 4 +- openpilot/tools/cabana/utils/util.h | 5 + openpilot/tools/cabana/videowidget.cc | 2 +- 41 files changed, 723 insertions(+), 471 deletions(-) create mode 100644 openpilot/tools/cabana/core/can_data.h create mode 100644 openpilot/tools/cabana/core/color.h create mode 100644 openpilot/tools/cabana/core/message_id.h create mode 100644 openpilot/tools/cabana/core/settings.h create mode 100644 openpilot/tools/cabana/dbc/dbcqt.cc create mode 100644 openpilot/tools/cabana/dbc/dbcqt.h diff --git a/openpilot/tools/cabana/.gitignore b/openpilot/tools/cabana/.gitignore index 927b05e34a..e4212612df 100644 --- a/openpilot/tools/cabana/.gitignore +++ b/openpilot/tools/cabana/.gitignore @@ -7,3 +7,4 @@ assets.cc _cabana dbc/car_fingerprint_to_dbc.json tests/test_cabana +tests/test_dbc_core diff --git a/openpilot/tools/cabana/SConscript b/openpilot/tools/cabana/SConscript index 26c20ddba1..e9c1a976bb 100644 --- a/openpilot/tools/cabana/SConscript +++ b/openpilot/tools/cabana/SConscript @@ -97,7 +97,7 @@ cabana_env.Depends(assets_src, str(bootstrap_icons.SVG_PATH)) cabana_env.Depends(assets, Glob('/assets/*', exclude=[assets, assets_src, "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', + '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', @@ -110,6 +110,19 @@ cabana_env.Program('_cabana', ['cabana.cc', cabana_lib, assets], LIBS=cabana_lib if GetOption('extras'): cabana_env.Program('tests/test_cabana', ['tests/test_runner.cc', 'tests/test_cabana.cc', cabana_lib], LIBS=[cabana_libs]) + # 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_test_runner', 'tests/test_runner.cc'), + 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 = 'openpilot/tools/cabana/dbc/car_fingerprint_to_dbc.json' generate_dbc = cabana_env.Command('#' + output_json_file, ['dbc/generate_dbc_json.py'], diff --git a/openpilot/tools/cabana/binaryview.cc b/openpilot/tools/cabana/binaryview.cc index c86b3ebdae..1c7c7b938e 100644 --- a/openpilot/tools/cabana/binaryview.cc +++ b/openpilot/tools/cabana/binaryview.cc @@ -1,4 +1,5 @@ #include "tools/cabana/binaryview.h" +#include "tools/cabana/dbc/dbcqt.h" #include @@ -34,7 +35,7 @@ BinaryView::BinaryView(QWidget *parent) : QTableView(parent) { setMouseTracking(true); setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - QObject::connect(dbc(), &DBCManager::DBCFileChanged, this, &BinaryView::refresh); + QObject::connect(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, this, &BinaryView::refresh); QObject::connect(UndoStack::instance(), &QUndoStack::indexChanged, this, &BinaryView::refresh); addShortcuts(); @@ -334,7 +335,7 @@ void BinaryViewModel::updateState() { color.setAlpha(alpha); updateItem(i, j, bit_val, color); } - updateItem(i, 8, binary[i], last_msg.colors[i]); + updateItem(i, 8, binary[i], toQColor(last_msg.colors[i])); } } @@ -421,14 +422,14 @@ void BinaryItemDelegate::paint(QPainter *painter, const QStyleOptionViewItem &op painter->fillRect(option.rect, item->bg_color); } } else if (option.state & QStyle::State_Selected) { - auto color = bin_view->resize_sig ? bin_view->resize_sig->color : option.palette.color(QPalette::Active, QPalette::Highlight); + 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, s->color.darker(125)); // 4/5x brightness + painter->fillRect(option.rect, toQColor(s->color.darker(125))); // 4/5x brightness } else { drawSignalCell(painter, option, index, s); } @@ -483,14 +484,14 @@ void BinaryItemDelegate::drawSignalCell(QPainter *painter, const QStyleOptionVie painter->setClipRegion(QRegion(rc).subtracted(subtract)); auto item = (const BinaryViewModel::Item *)index.internalPointer(); - QColor color = sig->color; + 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 = sig->color.darker(125); + 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()); diff --git a/openpilot/tools/cabana/chart/chart.cc b/openpilot/tools/cabana/chart/chart.cc index 9dfdc595f0..b5ddcf0479 100644 --- a/openpilot/tools/cabana/chart/chart.cc +++ b/openpilot/tools/cabana/chart/chart.cc @@ -1,4 +1,5 @@ #include "tools/cabana/chart/chart.h" +#include "tools/cabana/dbc/dbcqt.h" #include #include @@ -52,10 +53,10 @@ ChartView::ChartView(const std::pair &x_range, ChartsWidget *par QObject::connect(axis_y, &QAbstractAxis::titleTextChanged, this, &ChartView::resetChartCache); QObject::connect(window()->windowHandle(), &QWindow::screenChanged, this, &ChartView::resetChartCache); - QObject::connect(dbc(), &DBCManager::signalRemoved, this, &ChartView::signalRemoved); - QObject::connect(dbc(), &DBCManager::signalUpdated, this, &ChartView::signalUpdated); - QObject::connect(dbc(), &DBCManager::msgRemoved, this, &ChartView::msgRemoved); - QObject::connect(dbc(), &DBCManager::msgUpdated, this, &ChartView::msgUpdated); + 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() { @@ -115,14 +116,14 @@ void ChartView::setTheme(QChart::ChartTheme theme) { axis_x->setLineVisible(false); axis_y->setLineVisible(false); for (auto &s : sigs) { - s.series->setColor(s.sig->color); + s.series->setColor(toQColor(s.sig->color)); } } void ChartView::addSignal(const MessageId &msg_id, const cabana::Signal *sig) { if (hasSignal(msg_id, sig)) return; - QXYSeries *series = createSeries(series_type, sig->color); + QXYSeries *series = createSeries(series_type, toQColor(sig->color)); sigs.push_back({.msg_id = msg_id, .sig = sig, .series = series}); updateSeries(sig); updateSeriesPoints(); @@ -157,8 +158,8 @@ void ChartView::removeIf(std::function predicate) { 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->series->color() != sig->color) { - setSeriesColor(it->series, sig->color); + if (it->series->color() != toQColor(sig->color)) { + setSeriesColor(it->series, toQColor(sig->color)); } updateTitle(); updateSeries(sig); @@ -838,7 +839,7 @@ void ChartView::setSeriesType(SeriesType type) { s.series->deleteLater(); } for (auto &s : sigs) { - s.series = createSeries(series_type, s.sig->color); + s.series = createSeries(series_type, toQColor(s.sig->color)); const auto &points = series_type == SeriesType::StepLine ? s.step_vals : s.vals; s.series->replace(QVector(points.cbegin(), points.cend())); } diff --git a/openpilot/tools/cabana/chart/chartswidget.cc b/openpilot/tools/cabana/chart/chartswidget.cc index 44dca42152..e5c70bc536 100644 --- a/openpilot/tools/cabana/chart/chartswidget.cc +++ b/openpilot/tools/cabana/chart/chartswidget.cc @@ -1,4 +1,5 @@ #include "tools/cabana/chart/chartswidget.h" +#include "tools/cabana/dbc/dbcqt.h" #include #include @@ -115,7 +116,7 @@ ChartsWidget::ChartsWidget(QWidget *parent) : QFrame(parent) { align_timer->setSingleShot(true); QObject::connect(align_timer, &QTimer::timeout, this, &ChartsWidget::alignCharts); QObject::connect(auto_scroll_timer, &QTimer::timeout, this, &ChartsWidget::doAutoScroll); - QObject::connect(dbc(), &DBCManager::DBCFileChanged, this, &ChartsWidget::removeAll); + QObject::connect(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); @@ -309,7 +310,7 @@ void ChartsWidget::splitChart(ChartView *src_chart) { src_chart->chart()->removeSeries(it->series); // Restore to the original color - it->series->setColor(it->sig->color); + it->series->setColor(toQColor(it->sig->color)); c->addSeries(it->series); c->sigs.emplace_back(std::move(*it)); diff --git a/openpilot/tools/cabana/chart/signalselector.cc b/openpilot/tools/cabana/chart/signalselector.cc index 6f2fd8de46..12a9c81564 100644 --- a/openpilot/tools/cabana/chart/signalselector.cc +++ b/openpilot/tools/cabana/chart/signalselector.cc @@ -1,4 +1,5 @@ #include "tools/cabana/chart/signalselector.h" +#include "tools/cabana/dbc/dbcqt.h" #include #include @@ -92,7 +93,7 @@ void SignalSelector::updateAvailableList(int index) { } void SignalSelector::addItemToList(QListWidget *parent, const MessageId id, const cabana::Signal *sig, bool show_msg_name) { - QString text = QString(" %1").arg(sig->color.name(), QString::fromStdString(sig->name)); + QString text = QString(" %1").arg(toQColor(sig->color).name(), QString::fromStdString(sig->name)); if (show_msg_name) text += QString(" %0 %1").arg(QString::fromStdString(msgName(id)), QString::fromStdString(id.toString())); QLabel *label = new QLabel(text); diff --git a/openpilot/tools/cabana/chart/sparkline.cc b/openpilot/tools/cabana/chart/sparkline.cc index 91435cd5ac..f5bef0fc2e 100644 --- a/openpilot/tools/cabana/chart/sparkline.cc +++ b/openpilot/tools/cabana/chart/sparkline.cc @@ -31,7 +31,7 @@ void Sparkline::update(const cabana::Signal *sig, CanEventIter first, CanEventIt } freq_ = points_.size() / std::max(points_.back().x() - points_.front().x(), 1.0); - render(sig->color, range, size); + render(toQColor(sig->color), range, size); } void Sparkline::render(const QColor &color, int range, QSize size) { diff --git a/openpilot/tools/cabana/core/can_data.h b/openpilot/tools/cabana/core/can_data.h new file mode 100644 index 0000000000..38141b1890 --- /dev/null +++ b/openpilot/tools/cabana/core/can_data.h @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include +#include + +#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 &mask, double frequency = 0); + + double ts = 0.; + uint32_t count = 0; + double freq = 0; + std::vector dat; + std::vector colors; + + struct ByteLastChange { + double ts = 0; + int delta = 0; + int same_delta_counter = 0; + bool suppressed = false; + }; + std::vector last_changes; + std::vector> 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>; +using CanEventIter = std::vector::const_iterator; diff --git a/openpilot/tools/cabana/core/color.h b/openpilot/tools/cabana/core/color.h new file mode 100644 index 0000000000..c29704dd44 --- /dev/null +++ b/openpilot/tools/cabana/core/color.h @@ -0,0 +1,79 @@ +#pragma once + +#include +#include +#include + +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(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(std::clamp((v + m) * 255.0f, 0.0f, 255.0f) + 0.5f); }; + return {channel(red), channel(green), channel(blue), + static_cast(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(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}; + } +}; diff --git a/openpilot/tools/cabana/core/message_id.h b/openpilot/tools/cabana/core/message_id.h new file mode 100644 index 0000000000..ecac279631 --- /dev/null +++ b/openpilot/tools/cabana/core/message_id.h @@ -0,0 +1,27 @@ +#pragma once +#include +#include +#include +#include +#include + +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(std::stoul(s.substr(0, p))), .address = static_cast(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 { + size_t operator()(const MessageId &id) const noexcept { return std::hash{}(id.source) ^ (std::hash{}(id.address) << 1); } +}; diff --git a/openpilot/tools/cabana/core/settings.h b/openpilot/tools/cabana/core/settings.h new file mode 100644 index 0000000000..cad152de92 --- /dev/null +++ b/openpilot/tools/cabana/core/settings.h @@ -0,0 +1,34 @@ +#pragma once + +#include +#include + +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 recent_files; + DragDirection drag_direction = MsbFirst; + + std::string recent_dbc_file; + std::string active_msg_id; + std::vector selected_msg_ids; + std::vector active_charts; +}; diff --git a/openpilot/tools/cabana/dbc/dbc.cc b/openpilot/tools/cabana/dbc/dbc.cc index 8e41cf54e3..c8a794c73f 100644 --- a/openpilot/tools/cabana/dbc/dbc.cc +++ b/openpilot/tools/cabana/dbc/dbc.cc @@ -1,8 +1,18 @@ #include "tools/cabana/dbc/dbc.h" #include +#include -#include "tools/cabana/utils/util.h" +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 @@ -135,8 +145,8 @@ void cabana::Signal::update() { float s = 0.25 + 0.25 * (float)(hash & 0xff) / 255.0; float v = 0.75 + 0.25 * (float)((hash >> 8) & 0xff) / 255.0; - color = QColor::fromHsvF(h, s, v); - precision = std::max(num_decimals(factor), num_decimals(offset)); + color = CabanaColor::fromHsv(h, s, v); + precision = std::max(numDecimals(factor), numDecimals(offset)); } std::string cabana::Signal::formatValue(double value, bool with_unit) const { diff --git a/openpilot/tools/cabana/dbc/dbc.h b/openpilot/tools/cabana/dbc/dbc.h index a10e7871fe..585325d391 100644 --- a/openpilot/tools/cabana/dbc/dbc.h +++ b/openpilot/tools/cabana/dbc/dbc.h @@ -8,58 +8,14 @@ #include #include -#include -#include +#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; -struct MessageId { - uint8_t source = 0; - uint32_t address = 0; - - std::string toString() const { - char buf[64]; - snprintf(buf, sizeof(buf), "%u:%X", source, address); - return buf; - } - - inline static MessageId fromString(const std::string &str) { - auto pos = str.find(':'); - if (pos == std::string::npos) return {}; - return MessageId{.source = uint8_t(std::stoul(str.substr(0, pos))), - .address = uint32_t(std::stoul(str.substr(pos + 1), nullptr, 16))}; - } - - bool operator==(const MessageId &other) const { - return source == other.source && address == other.address; - } - - bool operator!=(const MessageId &other) const { - return !(*this == other); - } - - bool operator<(const MessageId &other) const { - return std::tie(source, address) < std::tie(other.source, other.address); - } - - bool operator>(const MessageId &other) const { - return std::tie(source, address) > std::tie(other.source, other.address); - } -}; - -Q_DECLARE_METATYPE(MessageId); - -template <> -struct std::hash { - std::size_t operator()(const MessageId &k) const noexcept { - return std::hash{}(k.source) ^ (std::hash{}(k.address) << 1); - } -}; - typedef std::vector> ValueDescription; -Q_DECLARE_METATYPE(ValueDescription); namespace cabana { @@ -92,7 +48,7 @@ public: std::string receiver_name; ValueDescription val_desc; int precision = 0; - QColor color; + CabanaColor color; // Multiplexed int multiplex_value = 0; diff --git a/openpilot/tools/cabana/dbc/dbcfile.cc b/openpilot/tools/cabana/dbc/dbcfile.cc index d9c129ee81..99a5b71822 100644 --- a/openpilot/tools/cabana/dbc/dbcfile.cc +++ b/openpilot/tools/cabana/dbc/dbcfile.cc @@ -1,23 +1,60 @@ #include "tools/cabana/dbc/dbcfile.h" -#include -#include -#include -#include +#include +#include +#include +#include +#include +#include +#include -DBCFile::DBCFile(const std::string &dbc_file_name) { - QFile file(QString::fromStdString(dbc_file_name)); - if (file.open(QIODevice::ReadOnly)) { - name_ = QFileInfo(QString::fromStdString(dbc_file_name)).baseName().toStdString(); - filename = dbc_file_name; - parse(file.readAll()); - } else { - throw std::runtime_error("Failed to open file."); - } +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); } -DBCFile::DBCFile(const std::string &name, const std::string &content) : name_(name), filename("") { - parse(QString::fromStdString(content)); +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(file), std::istreambuf_iterator())); +} + +DBCFile::DBCFile(const std::string &name, const std::string &content) : name_(name) { + parse(content); } bool DBCFile::save() { @@ -31,15 +68,14 @@ bool DBCFile::saveAs(const std::string &new_filename) { } bool DBCFile::writeContents(const std::string &fn) { - QFile file(QString::fromStdString(fn)); - if (file.open(QIODevice::WriteOnly)) { - std::string content = generateDBC(); - return file.write(content.c_str(), content.size()) >= 0; - } - return false; + 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) { +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; @@ -55,178 +91,143 @@ cabana::Msg *DBCFile::msg(uint32_t address) { 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; + return it != msgs.end() ? &it->second : nullptr; } cabana::Signal *DBCFile::signal(uint32_t address, const std::string &name) { auto m = msg(address); - return m ? (cabana::Signal *)m->sig(name) : nullptr; + return m ? m->sig(name) : nullptr; } -void DBCFile::parse(const QString &content) { +void DBCFile::parse(const std::string &content) { msgs.clear(); - - int line_num = 0; - QString line; + 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; - QTextStream stream((QString *)&content); - while (!stream.atEnd()) { + while (std::getline(input, raw_line)) { ++line_num; - QString raw_line = stream.readLine(); - line = raw_line.trimmed(); + 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 (line.startsWith("BO_ ")) { + if (startsWith(line, "BO_ ")) { multiplexor_cnt = 0; current_msg = parseBO(line); - } else if (line.startsWith("SG_ ")) { + } else if (startsWith(line, "SG_ ")) { parseSG(line, current_msg, multiplexor_cnt); - } else if (line.startsWith("VAL_ ")) { + } else if (startsWith(line, "VAL_ ")) { parseVAL(line); - } else if (line.startsWith("CM_ BO_")) { - parseCM_BO(line, content, raw_line, stream); - } else if (line.startsWith("CM_ SG_ ")) { - parseCM_SG(line, content, raw_line, stream); + } else if (startsWith(line, "CM_ BO_")) { + parseCM_BO(line); + } else if (startsWith(line, "CM_ SG_ ")) { + parseCM_SG(line); } else { seen = false; } - } catch (std::exception &e) { - throw std::runtime_error(QString("[%1:%2]%3: %4").arg(QString::fromStdString(filename)).arg(line_num).arg(e.what()).arg(line).toStdString()); - } - - if (seen) { - seen_first = true; - } else if (!seen_first) { - header += raw_line.toStdString() + "\n"; + } 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 &[_, m] : msgs) { - m.update(); - } + for (auto &[_, message] : msgs) message.update(); } -cabana::Msg *DBCFile::parseBO(const QString &line) { - static QRegularExpression bo_regexp(R"(^BO_ (?
\w+) (?\w+) *: (?\w+) (?\w+))"); - - QRegularExpressionMatch match = bo_regexp.match(line); - if (!match.hasMatch()) - throw std::runtime_error("Invalid BO_ line format"); - - uint32_t address = match.captured("address").toUInt(); - if (msgs.count(address) > 0) - throw std::runtime_error(QString("Duplicate message address: %1").arg(address).toStdString()); - - // Create a new message object - cabana::Msg *msg = &msgs[address]; - msg->address = address; - msg->name = match.captured("name").toStdString(); - msg->size = match.captured("size").toULong(); - msg->transmitter = match.captured("transmitter").trimmed().toStdString(); - return msg; +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::parseCM_BO(const QString &line, const QString &content, const QString &raw_line, const QTextStream &stream) { - static QRegularExpression msg_comment_regexp(R"(^CM_ BO_ *(?
\w+) *\"(?(?:[^"\\]|\\.)*)\"\s*;)"); +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"); - QString parse_line = line; - if (!parse_line.endsWith("\";")) { - int pos = stream.pos() - raw_line.length() - 1; - parse_line = content.mid(pos, content.indexOf("\";", pos)); - } - auto match = msg_comment_regexp.match(parse_line); - if (!match.hasMatch()) - throw std::runtime_error("Invalid message comment format"); - - if (auto m = (cabana::Msg *)msg(match.captured("address").toUInt())) - m->comment = match.captured("comment").trimmed().replace("\\\"", "\"").toStdString(); -} - -void DBCFile::parseSG(const QString &line, cabana::Msg *current_msg, int &multiplexor_cnt) { - static QRegularExpression sg_regexp(R"(^SG_ (\w+) *: (\d+)\|(\d+)@(\d+)([\+|\-]) \(([0-9.+\-eE]+),([0-9.+\-eE]+)\) \[([0-9.+\-eE]+)\|([0-9.+\-eE]+)\] \"(.*)\" (.*))"); - static QRegularExpression sgm_regexp(R"(^SG_ (\w+) (\w+) *: (\d+)\|(\d+)@(\d+)([\+|\-]) \(([0-9.+\-eE]+),([0-9.+\-eE]+)\) \[([0-9.+\-eE]+)\|([0-9.+\-eE]+)\] \"(.*)\" (.*))"); - - if (!current_msg) - throw std::runtime_error("No Message"); - - int offset = 0; - auto match = sg_regexp.match(line); - if (!match.hasMatch()) { - match = sgm_regexp.match(line); - offset = 1; - } - if (!match.hasMatch()) - throw std::runtime_error("Invalid SG_ line format"); - - std::string name = match.captured(1).toStdString(); - if (current_msg->sig(name) != nullptr) - throw std::runtime_error("Duplicate signal name"); - - cabana::Signal s{}; - if (offset == 1) { - auto indicator = match.captured(2); + cabana::Signal signal{}; + const std::string indicator = match[2].str(); + if (!indicator.empty()) { if (indicator == "M") { - ++multiplexor_cnt; - // Only one signal within a single message can be the multiplexer switch. - if (multiplexor_cnt >= 2) - throw std::runtime_error("Multiple multiplexor"); - - s.type = cabana::Signal::Type::Multiplexor; + if (++multiplexor_cnt >= 2) throw std::runtime_error("Multiple multiplexor"); + signal.type = cabana::Signal::Type::Multiplexor; } else { - s.type = cabana::Signal::Type::Multiplexed; - s.multiplex_value = indicator.mid(1).toInt(); + signal.type = cabana::Signal::Type::Multiplexed; + signal.multiplex_value = indicator.size() > 1 ? std::stoi(indicator.substr(1)) : 0; } } - s.name = name; - s.start_bit = match.captured(offset + 2).toInt(); - s.size = match.captured(offset + 3).toInt(); - s.is_little_endian = match.captured(offset + 4).toInt() == 1; - s.is_signed = match.captured(offset + 5) == "-"; - s.factor = match.captured(offset + 6).toDouble(); - s.offset = match.captured(offset + 7).toDouble(); - s.min = match.captured(8 + offset).toDouble(); - s.max = match.captured(9 + offset).toDouble(); - s.unit = match.captured(10 + offset).toStdString(); - s.receiver_name = match.captured(11 + offset).trimmed().toStdString(); - current_msg->sigs.push_back(new cabana::Signal(s)); + 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_SG(const QString &line, const QString &content, const QString &raw_line, const QTextStream &stream) { - static QRegularExpression sg_comment_regexp(R"(^CM_ SG_ *(\w+) *(\w+) *\"((?:[^"\\]|\\.)*)\"\s*;)"); - - QString parse_line = line; - if (!parse_line.endsWith("\";")) { - int pos = stream.pos() - raw_line.length() - 1; - parse_line = content.mid(pos, content.indexOf("\";", pos)); +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"); } - auto match = sg_comment_regexp.match(parse_line); - if (!match.hasMatch()) + 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 s = signal(match.captured(1).toUInt(), match.captured(2).toStdString())) { - s->comment = match.captured(3).trimmed().replace("\\\"", "\"").toStdString(); } + if (auto sig = signal(address, name)) sig->comment = unescapeComment(line.substr(first_quote + 1, last_quote - first_quote - 1)); } -void DBCFile::parseVAL(const QString &line) { - static QRegularExpression val_regexp(R"(VAL_ (\w+) (\w+) (\s*[-+]?[0-9]+\s+\".+?\"[^;]*))"); - - auto match = val_regexp.match(line); - if (!match.hasMatch()) - throw std::runtime_error("invalid VAL_ line format"); - - if (auto s = signal(match.captured(1).toUInt(), match.captured(2).toStdString())) { - QStringList desc_list = match.captured(3).trimmed().split('"'); - for (int i = 0; i < desc_list.size(); i += 2) { - auto val = desc_list[i].trimmed(); - if (!val.isEmpty() && (i + 1) < desc_list.size()) { - auto desc = desc_list[i + 1].trimmed(); - s->val_desc.push_back({val.toDouble(), desc.toStdString()}); - } +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())); } } } @@ -237,40 +238,29 @@ std::string DBCFile::generateDBC() { 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_comment = m.comment; - // Replace " with \" - for (size_t pos = 0; (pos = escaped_comment.find('"', pos)) != std::string::npos; pos += 2) - escaped_comment.replace(pos, 1, "\\\""); - comment += "CM_ BO_ " + std::to_string(address) + " \"" + escaped_comment + "\";\n"; + 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 multiplexer_indicator; - if (sig->type == cabana::Signal::Type::Multiplexor) { - multiplexer_indicator = "M "; - } else if (sig->type == cabana::Signal::Type::Multiplexed) { - multiplexer_indicator = "m" + std::to_string(sig->multiplex_value) + " "; - } - const std::string &recv = sig->receiver_name.empty() ? DEFAULT_NODE_NAME : sig->receiver_name; - dbc_string += " SG_ " + sig->name + " " + multiplexer_indicator + ": " + - std::to_string(sig->start_bit) + "|" + std::to_string(sig->size) + "@" + - std::string(1, sig->is_little_endian ? '1' : '0') + - std::string(1, sig->is_signed ? '-' : '+') + + 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 + "\" " + recv + "\n"; + " [" + doubleToString(sig->min) + "|" + doubleToString(sig->max) + "] \"" + sig->unit + "\" " + receiver + "\n"; if (!sig->comment.empty()) { - std::string escaped_comment = sig->comment; - for (size_t pos = 0; (pos = escaped_comment.find('"', pos)) != std::string::npos; pos += 2) - escaped_comment.replace(pos, 1, "\\\""); - comment += "CM_ SG_ " + std::to_string(address) + " " + sig->name + " \"" + escaped_comment + "\";\n"; + 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 (auto &[val, desc] : sig->val_desc) { + for (const auto &[value, description] : sig->val_desc) { if (!text.empty()) text += " "; - char val_buf[64]; - snprintf(val_buf, sizeof(val_buf), "%g", val); - text += std::string(val_buf) + " \"" + desc + "\""; + text += doubleToString(value) + " \"" + description + "\""; } val_desc += "VAL_ " + std::to_string(address) + " " + sig->name + " " + text + ";\n"; } diff --git a/openpilot/tools/cabana/dbc/dbcfile.h b/openpilot/tools/cabana/dbc/dbcfile.h index decb566abd..13841a94fa 100644 --- a/openpilot/tools/cabana/dbc/dbcfile.h +++ b/openpilot/tools/cabana/dbc/dbcfile.h @@ -2,7 +2,6 @@ #include #include -#include #include "tools/cabana/dbc/dbc.h" @@ -32,12 +31,12 @@ public: std::string filename; private: - void parse(const QString &content); - cabana::Msg *parseBO(const QString &line); - void parseSG(const QString &line, cabana::Msg *current_msg, int &multiplexor_cnt); - void parseCM_BO(const QString &line, const QString &content, const QString &raw_line, const QTextStream &stream); - void parseCM_SG(const QString &line, const QString &content, const QString &raw_line, const QTextStream &stream); - void parseVAL(const QString &line); + 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 msgs; diff --git a/openpilot/tools/cabana/dbc/dbcmanager.cc b/openpilot/tools/cabana/dbc/dbcmanager.cc index 2236a93da1..7a95a4f809 100644 --- a/openpilot/tools/cabana/dbc/dbcmanager.cc +++ b/openpilot/tools/cabana/dbc/dbcmanager.cc @@ -1,9 +1,10 @@ #include "tools/cabana/dbc/dbcmanager.h" #include +#include #include -bool DBCManager::open(const SourceSet &sources, const std::string &dbc_file_name, QString *error) { +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; }); @@ -16,11 +17,11 @@ bool DBCManager::open(const SourceSet &sources, const std::string &dbc_file_name return false; } - emit DBCFileChanged(); + if (callbacks_.file_changed) callbacks_.file_changed(); return true; } -bool DBCManager::open(const SourceSet &sources, const std::string &name, const std::string &content, QString *error) { +bool DBCManager::open(const SourceSet &sources, const std::string &name, const std::string &content, std::string *error) { try { auto file = std::make_shared(name, content); for (auto s : sources) { @@ -31,7 +32,7 @@ bool DBCManager::open(const SourceSet &sources, const std::string &name, const s return false; } - emit DBCFileChanged(); + if (callbacks_.file_changed) callbacks_.file_changed(); return true; } @@ -39,26 +40,26 @@ void DBCManager::close(const SourceSet &sources) { for (auto s : sources) { dbc_files[s] = nullptr; } - emit DBCFileChanged(); + 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; } - emit DBCFileChanged(); + if (callbacks_.file_changed) callbacks_.file_changed(); } void DBCManager::closeAll() { dbc_files.clear(); - emit DBCFileChanged(); + 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)) { - emit signalAdded(id, s); - emit maskUpdated(); + if (callbacks_.signal_added) callbacks_.signal_added(id, s); + if (callbacks_.mask_updated) callbacks_.mask_updated(); } } } @@ -66,8 +67,8 @@ void DBCManager::addSignal(const MessageId &id, const cabana::Signal &sig) { 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)) { - emit signalUpdated(s); - emit maskUpdated(); + if (callbacks_.signal_updated) callbacks_.signal_updated(s); + if (callbacks_.mask_updated) callbacks_.mask_updated(); } } } @@ -75,9 +76,9 @@ void DBCManager::updateSignal(const MessageId &id, const std::string &sig_name, void DBCManager::removeSignal(const MessageId &id, const std::string &sig_name) { if (auto m = msg(id)) { if (auto s = m->sig(sig_name)) { - emit signalRemoved(s); + if (callbacks_.signal_removed) callbacks_.signal_removed(s); m->removeSignal(sig_name); - emit maskUpdated(); + if (callbacks_.mask_updated) callbacks_.mask_updated(); } } } @@ -86,15 +87,15 @@ void DBCManager::updateMsg(const MessageId &id, const std::string &name, uint32_ auto dbc_file = findDBCFile(id); assert(dbc_file); // This should be impossible dbc_file->updateMsg(id, name, size, node, comment); - emit msgUpdated(id); + 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); - emit msgRemoved(id); - emit maskUpdated(); + if (callbacks_.msg_removed) callbacks_.msg_removed(id); + if (callbacks_.mask_updated) callbacks_.mask_updated(); } std::string DBCManager::newMsgName(const MessageId &id) { @@ -176,6 +177,6 @@ std::string toString(const SourceSet &ss) { } DBCManager *dbc() { - static DBCManager dbc_manager(nullptr); + static DBCManager dbc_manager; return &dbc_manager; } diff --git a/openpilot/tools/cabana/dbc/dbcmanager.h b/openpilot/tools/cabana/dbc/dbcmanager.h index 4a122073ea..5a09fae03d 100644 --- a/openpilot/tools/cabana/dbc/dbcmanager.h +++ b/openpilot/tools/cabana/dbc/dbcmanager.h @@ -1,6 +1,6 @@ #pragma once -#include +#include #include #include #include @@ -11,17 +11,23 @@ typedef std::set SourceSet; const SourceSet SOURCE_ALL = {-1}; -const int INVALID_SOURCE = 0xff; inline bool operator<(const std::shared_ptr &l, const std::shared_ptr &r) { return l.get() < r.get(); } -class DBCManager : public QObject { - Q_OBJECT - +class DBCManager { public: - DBCManager(QObject *parent) : QObject(parent) {} - ~DBCManager() {} - bool open(const SourceSet &sources, const std::string &dbc_file_name, QString *error = nullptr); - bool open(const SourceSet &sources, const std::string &name, const std::string &content, QString *error = nullptr); + struct Callbacks { + std::function signal_added; + std::function signal_removed; + std::function signal_updated; + std::function msg_updated; + std::function msg_removed; + std::function file_changed; + std::function 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(); @@ -48,18 +54,11 @@ public: DBCFile *findDBCFile(const uint8_t source); inline DBCFile *findDBCFile(const MessageId &id) { return findDBCFile(id.source); } std::set allDBCFiles(); - -signals: - void signalAdded(MessageId id, const cabana::Signal *sig); - void signalRemoved(const cabana::Signal *sig); - void signalUpdated(const cabana::Signal *sig); - void msgUpdated(MessageId id); - void msgRemoved(MessageId id); - void DBCFileChanged(); - void maskUpdated(); + void setCallbacks(Callbacks callbacks) { callbacks_ = std::move(callbacks); } private: std::map> dbc_files; + Callbacks callbacks_; }; DBCManager *dbc(); diff --git a/openpilot/tools/cabana/dbc/dbcqt.cc b/openpilot/tools/cabana/dbc/dbcqt.cc new file mode 100644 index 0000000000..4354caf3f5 --- /dev/null +++ b/openpilot/tools/cabana/dbc/dbcqt.cc @@ -0,0 +1,18 @@ +#include "tools/cabana/dbc/dbcqt.h" + +QtDBCNotifier::QtDBCNotifier(QObject *parent) : QObject(parent) { + dbc()->setCallbacks({ + .signal_added = [this](MessageId id, const cabana::Signal *sig) { emit signalAdded(id, sig); }, + .signal_removed = [this](const cabana::Signal *sig) { emit signalRemoved(sig); }, + .signal_updated = [this](const cabana::Signal *sig) { emit signalUpdated(sig); }, + .msg_updated = [this](MessageId id) { emit msgUpdated(id); }, + .msg_removed = [this](MessageId id) { emit msgRemoved(id); }, + .file_changed = [this]() { emit DBCFileChanged(); }, + .mask_updated = [this]() { emit maskUpdated(); }, + }); +} + +QtDBCNotifier *dbcNotifier() { + static QtDBCNotifier notifier; + return ¬ifier; +} diff --git a/openpilot/tools/cabana/dbc/dbcqt.h b/openpilot/tools/cabana/dbc/dbcqt.h new file mode 100644 index 0000000000..b889f854ed --- /dev/null +++ b/openpilot/tools/cabana/dbc/dbcqt.h @@ -0,0 +1,27 @@ +#pragma once + +#include +#include + +#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(); diff --git a/openpilot/tools/cabana/detailwidget.cc b/openpilot/tools/cabana/detailwidget.cc index 148b059e5b..36a95ff96e 100644 --- a/openpilot/tools/cabana/detailwidget.cc +++ b/openpilot/tools/cabana/detailwidget.cc @@ -1,4 +1,5 @@ #include "tools/cabana/detailwidget.h" +#include "tools/cabana/dbc/dbcqt.h" #include #include @@ -56,7 +57,7 @@ DetailWidget::DetailWidget(ChartsWidget *charts, QWidget *parent) : charts(chart QObject::connect(signal_view, &SignalView::highlight, binary_view, &BinaryView::highlight); QObject::connect(tab_widget, &QTabWidget::currentChanged, [this]() { updateState(); }); QObject::connect(can, &AbstractStream::msgsReceived, this, &DetailWidget::updateState); - QObject::connect(dbc(), &DBCManager::DBCFileChanged, this, &DetailWidget::refresh); + QObject::connect(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, this, &DetailWidget::refresh); QObject::connect(UndoStack::instance(), &QUndoStack::indexChanged, this, &DetailWidget::refresh); QObject::connect(tabbar, &QTabBar::customContextMenuRequested, this, &DetailWidget::showTabBarContextMenu); QObject::connect(tabbar, &QTabBar::currentChanged, [this](int index) { diff --git a/openpilot/tools/cabana/historylog.cc b/openpilot/tools/cabana/historylog.cc index fb79ff9cea..51c2481f3b 100644 --- a/openpilot/tools/cabana/historylog.cc +++ b/openpilot/tools/cabana/historylog.cc @@ -1,4 +1,5 @@ #include "tools/cabana/historylog.h" +#include "tools/cabana/dbc/dbcqt.h" #include @@ -54,7 +55,7 @@ QVariant HistoryLogModel::headerData(int section, Qt::Orientation orientation, i 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 = sigs[section - 1]->color; + QColor sigColor = toQColor(sigs[section - 1]->color); sigColor.setAlpha(128); return QBrush(sigColor); } @@ -207,7 +208,7 @@ LogsWidget::LogsWidget(QWidget *parent) : QFrame(parent) { 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(dbc(), &DBCManager::DBCFileChanged, model, &HistoryLogModel::reset); + QObject::connect(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, model, &HistoryLogModel::reset); QObject::connect(UndoStack::instance(), &QUndoStack::indexChanged, model, &HistoryLogModel::reset); QObject::connect(model, &HistoryLogModel::modelReset, this, &LogsWidget::modelReset); QObject::connect(model, &HistoryLogModel::rowsInserted, [this]() { export_btn->setEnabled(true); }); @@ -238,11 +239,11 @@ void LogsWidget::filterChanged() { } void LogsWidget::exportToCSV() { - QString dir = QString("%1/%2_%3.csv").arg(settings.last_dir).arg(QString::fromStdString(can->routeName())).arg(QString::fromStdString(msgName(model->msg_id))); + 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, model->msg_id) - : utils::exportSignalsToCSV(fn, model->msg_id); + model->isHexMode() ? utils::exportToCSV(fn.toStdString(), model->msg_id) + : utils::exportSignalsToCSV(fn.toStdString(), model->msg_id); } } diff --git a/openpilot/tools/cabana/historylog.h b/openpilot/tools/cabana/historylog.h index 1ac6e5bbad..1d3200b200 100644 --- a/openpilot/tools/cabana/historylog.h +++ b/openpilot/tools/cabana/historylog.h @@ -40,7 +40,7 @@ public: uint64_t mono_time = 0; std::vector sig_values; std::vector data; - std::vector colors; + std::vector colors; }; void fetchData(std::deque::iterator insert_pos, uint64_t from_time, uint64_t min_time); diff --git a/openpilot/tools/cabana/mainwin.cc b/openpilot/tools/cabana/mainwin.cc index 39fb979c79..8d932163a6 100644 --- a/openpilot/tools/cabana/mainwin.cc +++ b/openpilot/tools/cabana/mainwin.cc @@ -1,4 +1,5 @@ #include "tools/cabana/mainwin.h" +#include "tools/cabana/dbc/dbcqt.h" #include #include @@ -65,7 +66,7 @@ MainWindow::MainWindow(AbstractStream *stream, const QString &dbc_file) : QMainW QObject::connect(this, &MainWindow::showMessage, statusBar(), &QStatusBar::showMessage); QObject::connect(this, &MainWindow::updateProgressBar, this, &MainWindow::updateDownloadProgress); - QObject::connect(dbc(), &DBCManager::DBCFileChanged, this, &MainWindow::DBCFileChanged); + QObject::connect(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, this, &MainWindow::DBCFileChanged); QObject::connect(UndoStack::instance(), &QUndoStack::cleanChanged, this, &MainWindow::undoStackCleanChanged); QObject::connect(&settings, &Settings::changed, this, &MainWindow::updateStatus); @@ -253,16 +254,16 @@ void MainWindow::selectAndOpenStream() { void MainWindow::closeStream() { openStream(new DummyStream(this)); if (dbc()->nonEmptyDBCCount() > 0) { - emit dbc()->DBCFileChanged(); + emit dbcNotifier()->DBCFileChanged(); } statusBar()->showMessage(tr("stream closed")); } void MainWindow::exportToCSV() { - QString dir = QString("%1/%2.csv").arg(settings.last_dir).arg(QString::fromStdString(can->routeName())); + 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); + utils::exportToCSV(fn.toStdString()); } } @@ -273,7 +274,7 @@ void MainWindow::newFile(SourceSet s) { void MainWindow::openFile(SourceSet s) { remindSaveChanges(); - QString fn = QFileDialog::getOpenFileName(this, tr("Open File"), settings.last_dir, "DBC (*.dbc)"); + QString fn = QFileDialog::getOpenFileName(this, tr("Open File"), QString::fromStdString(settings.last_dir), "DBC (*.dbc)"); if (!fn.isEmpty()) { loadFile(fn, s); } @@ -283,13 +284,13 @@ void MainWindow::loadFile(const QString &fn, SourceSet s) { if (!fn.isEmpty()) { closeFile(s); - QString error; + 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(error); + msg_box.setDetailedText(QString::fromStdString(error)); msg_box.exec(); } } @@ -303,13 +304,13 @@ void MainWindow::loadFromClipboard(SourceSet s, bool close_all) { closeFile(s); QString dbc_str = QGuiApplication::clipboard()->text(); - QString error; + std::string error; bool ret = dbc()->open(s, std::string(""), dbc_str.toStdString(), &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(error); + msg_box.setDetailedText(QString::fromStdString(error)); msg_box.exec(); } } @@ -427,7 +428,7 @@ void MainWindow::saveFile(DBCFile *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, QDir::cleanPath(settings.last_dir + "/untitled.dbc"), tr("DBC (*.dbc)")); + QString fn = QFileDialog::getSaveFileName(this, title, QDir::cleanPath(QString::fromStdString(settings.last_dir) + "/untitled.dbc"), tr("DBC (*.dbc)")); if (!fn.isEmpty()) { dbc_file->saveAs(fn.toStdString()); UndoStack::instance()->setClean(); @@ -481,12 +482,13 @@ void MainWindow::updateLoadSaveMenus() { } void MainWindow::updateRecentFiles(const QString &fn) { - settings.recent_files.removeAll(fn); - settings.recent_files.prepend(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.removeLast(); + settings.recent_files.pop_back(); } - settings.last_dir = QFileInfo(fn).absolutePath(); + settings.last_dir = QFileInfo(fn).absolutePath().toStdString(); } void MainWindow::updateRecentFileMenu() { @@ -499,8 +501,8 @@ void MainWindow::updateRecentFileMenu() { } for (int i = 0; i < num_recent_files; ++i) { - QString text = tr("&%1 %2").arg(i + 1).arg(QFileInfo(settings.recent_files[i]).fileName()); - open_recent_menu->addAction(text, this, [this, file = settings.recent_files[i]]() { loadFile(file); }); + QString text = tr("&%1 %2").arg(i + 1).arg(QFileInfo(QString::fromStdString(settings.recent_files[i])).fileName()); + open_recent_menu->addAction(text, this, [this, file = settings.recent_files[i]]() { loadFile(QString::fromStdString(file)); }); } } @@ -627,30 +629,39 @@ void MainWindow::saveSessionState() { settings.active_charts.clear(); for (auto &f : dbc()->allDBCFiles()) - if (!f->isEmpty()) { settings.recent_dbc_file = QString::fromStdString(f->filename); break; } + 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; - settings.selected_msg_ids = ids; + 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()); } - if (charts_widget) - settings.active_charts = charts_widget->serializeChartIds(); } void MainWindow::restoreSessionState() { - if (settings.recent_dbc_file.isEmpty() || dbc()->nonEmptyDBCCount() == 0) return; + 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 != settings.recent_dbc_file) return; + if (dbc_file.toStdString() != settings.recent_dbc_file) return; - if (!settings.selected_msg_ids.isEmpty()) - center_widget->ensureDetailWidget()->restoreTabs(settings.active_msg_id, settings.selected_msg_ids); + 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()) - charts_widget->restoreChartsFromIds(settings.active_charts); + 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 diff --git a/openpilot/tools/cabana/messageswidget.cc b/openpilot/tools/cabana/messageswidget.cc index 75cdaa7cc3..22de1350ec 100644 --- a/openpilot/tools/cabana/messageswidget.cc +++ b/openpilot/tools/cabana/messageswidget.cc @@ -1,4 +1,5 @@ #include "tools/cabana/messageswidget.h" +#include "tools/cabana/dbc/dbcqt.h" #include #include @@ -43,7 +44,7 @@ MessagesWidget::MessagesWidget(QWidget *parent) : menu(new QMenu(this)), QWidget 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(dbc(), &DBCManager::DBCFileChanged, model, &MessageListModel::dbcModified); + QObject::connect(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, model, &MessageListModel::dbcModified); QObject::connect(UndoStack::instance(), &QUndoStack::indexChanged, model, &MessageListModel::dbcModified); QObject::connect(model, &MessageListModel::modelReset, [this]() { if (current_msg_id) { diff --git a/openpilot/tools/cabana/settings.cc b/openpilot/tools/cabana/settings.cc index e7b1129a30..e385d83eca 100644 --- a/openpilot/tools/cabana/settings.cc +++ b/openpilot/tools/cabana/settings.cc @@ -17,8 +17,31 @@ const int MAX_CACHE_MINIUTES = 120; Settings settings; +template +void readSetting(QSettings &settings_store, const char *key, T &value) { + if (auto stored = settings_store.value(key); stored.canConvert()) value = stored.value(); +} + +void readSetting(QSettings &settings_store, const char *key, std::string &value) { + value = settings_store.value(key, QString::fromStdString(value)).toString().toStdString(); +} + +void readSetting(QSettings &settings_store, const char *key, std::vector &value) { + value.clear(); + for (const auto &item : settings_store.value(key).toStringList()) value.push_back(item.toStdString()); +} + +template +void writeSetting(QSettings &settings_store, const char *key, const T &value) { settings_store.setValue(key, value); } +void writeSetting(QSettings &settings_store, const char *key, const std::string &value) { settings_store.setValue(key, QString::fromStdString(value)); } +void writeSetting(QSettings &settings_store, const char *key, const std::vector &value) { + QStringList items; + for (const auto &item : value) items.push_back(QString::fromStdString(item)); + settings_store.setValue(key, items); +} + template -void settings_op(SettingOperation op) { +void settingsOp(SettingOperation op) { QSettings s("cabana"); op(s, "absolute_time", settings.absolute_time); op(s, "fps", settings.fps); @@ -48,16 +71,13 @@ void settings_op(SettingOperation op) { } Settings::Settings() { - last_dir = last_route_dir = QDir::homePath(); - log_path = QStandardPaths::writableLocation(QStandardPaths::HomeLocation) + "/cabana_live_stream/"; - settings_op([](QSettings &s, const QString &key, auto &value) { - if (auto v = s.value(key); v.canConvert>()) - value = v.value>(); - }); + last_dir = last_route_dir = QDir::homePath().toStdString(); + log_path = (QStandardPaths::writableLocation(QStandardPaths::HomeLocation) + "/cabana_live_stream/").toStdString(); + settingsOp([](QSettings &s, const char *key, auto &value) { readSetting(s, key, value); }); } Settings::~Settings() { - settings_op([](QSettings &s, const QString &key, auto &v) { s.setValue(key, v); }); + settingsOp([](QSettings &s, const char *key, const auto &value) { writeSetting(s, key, value); }); } // SettingsDlg @@ -102,7 +122,7 @@ SettingsDlg::SettingsDlg(QWidget *parent) : QDialog(parent) { log_livestream = new QGroupBox(tr("Enable live stream logging"), this); log_livestream->setCheckable(true); QHBoxLayout *path_layout = new QHBoxLayout(log_livestream); - path_layout->addWidget(log_path = new QLineEdit(settings.log_path, this)); + 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); @@ -134,7 +154,7 @@ void SettingsDlg::save() { 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(); + settings.log_path = log_path->text().toStdString(); settings.drag_direction = (Settings::DragDirection)drag_direction->currentIndex(); emit settings.changed(); QDialog::accept(); diff --git a/openpilot/tools/cabana/settings.h b/openpilot/tools/cabana/settings.h index 7ab50d1494..4254a8009d 100644 --- a/openpilot/tools/cabana/settings.h +++ b/openpilot/tools/cabana/settings.h @@ -7,50 +7,20 @@ #include #include -#define LIGHT_THEME 1 -#define DARK_THEME 2 +#include "tools/cabana/core/settings.h" -class Settings : public QObject { +class Settings : public QObject, public CabanaSettingsState { Q_OBJECT public: - enum DragDirection { - MsbFirst, - LsbFirst, - AlwaysLE, - AlwaysBE, - }; - Settings(); ~Settings(); - bool absolute_time = false; - int fps = 10; - int max_cached_minutes = 30; - int chart_height = 200; - int chart_column_count = 1; - int chart_range = 3 * 60; // 3 minutes - int chart_series_type = 0; - int theme = 0; - int sparkline_range = 15; // 15 seconds - bool multiple_lines_hex = false; - bool log_livestream = true; - bool suppress_defined_signals = false; - QString log_path; - QString last_dir; - QString last_route_dir; + // Qt frontend layout state. This intentionally stays outside CabanaSettingsState. QByteArray geometry; QByteArray video_splitter_state; QByteArray window_state; - QStringList recent_files; QByteArray message_header_state; - DragDirection drag_direction = MsbFirst; - - // session data - QString recent_dbc_file; - QString active_msg_id; - QStringList selected_msg_ids; - QStringList active_charts; signals: void changed(); diff --git a/openpilot/tools/cabana/signalview.cc b/openpilot/tools/cabana/signalview.cc index a204512e83..5048cc6aa8 100644 --- a/openpilot/tools/cabana/signalview.cc +++ b/openpilot/tools/cabana/signalview.cc @@ -1,4 +1,5 @@ #include "tools/cabana/signalview.h" +#include "tools/cabana/dbc/dbcqt.h" #include #include @@ -25,12 +26,12 @@ static QString signalTypeToString(cabana::Signal::Type type) { } SignalModel::SignalModel(QObject *parent) : root(new Item), QAbstractItemModel(parent) { - QObject::connect(dbc(), &DBCManager::DBCFileChanged, this, &SignalModel::refresh); - QObject::connect(dbc(), &DBCManager::msgUpdated, this, &SignalModel::handleMsgChanged); - QObject::connect(dbc(), &DBCManager::msgRemoved, this, &SignalModel::handleMsgChanged); - QObject::connect(dbc(), &DBCManager::signalAdded, this, &SignalModel::handleSignalAdded); - QObject::connect(dbc(), &DBCManager::signalUpdated, this, &SignalModel::handleSignalUpdated); - QObject::connect(dbc(), &DBCManager::signalRemoved, this, &SignalModel::handleSignalRemoved); + 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) { @@ -304,7 +305,7 @@ void SignalItemDelegate::paint(QPainter *painter, const QStyleOptionViewItem &op path.addRoundedRect(icon_rect, 3, 3); painter->setPen(item->highlight ? Qt::white : Qt::black); painter->setFont(label_font); - painter->fillPath(path, item->sig->color.darker(item->highlight ? 125 : 0)); + painter->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); @@ -481,8 +482,8 @@ SignalView::SignalView(ChartsWidget *charts, QWidget *parent) : charts(charts), QObject::connect(tree, &QTreeView::entered, [this](const QModelIndex &index) { emit highlight(model->getItem(index)->sig); }); QObject::connect(model, &QAbstractItemModel::modelReset, this, &SignalView::rowsChanged); QObject::connect(model, &QAbstractItemModel::rowsRemoved, this, &SignalView::rowsChanged); - QObject::connect(dbc(), &DBCManager::signalAdded, this, &SignalView::handleSignalAdded); - QObject::connect(dbc(), &DBCManager::signalUpdated, this, &SignalView::handleSignalUpdated); + QObject::connect(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); diff --git a/openpilot/tools/cabana/streams/abstractstream.cc b/openpilot/tools/cabana/streams/abstractstream.cc index 1582fcd34d..c58a98084f 100644 --- a/openpilot/tools/cabana/streams/abstractstream.cc +++ b/openpilot/tools/cabana/streams/abstractstream.cc @@ -1,4 +1,5 @@ #include "tools/cabana/streams/abstractstream.h" +#include "tools/cabana/dbc/dbcqt.h" #include #include @@ -18,8 +19,8 @@ AbstractStream::AbstractStream(QObject *parent) : QObject(parent) { 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(dbc(), &DBCManager::DBCFileChanged, this, &AbstractStream::updateMasks); - QObject::connect(dbc(), &DBCManager::maskUpdated, this, &AbstractStream::updateMasks); + QObject::connect(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, this, &AbstractStream::updateMasks); + QObject::connect(dbcNotifier(), &QtDBCNotifier::maskUpdated, this, &AbstractStream::updateMasks); } void AbstractStream::updateMasks() { @@ -233,18 +234,18 @@ std::pair AbstractStream::eventsInRange(const Messag namespace { enum Color { GREYISH_BLUE, CYAN, RED}; -QColor getColor(int c) { +CabanaColor getColor(int c) { constexpr int start_alpha = 128; - static const QColor colors[] = { - [GREYISH_BLUE] = QColor(102, 86, 169, start_alpha / 2), - [CYAN] = QColor(0, 187, 255, start_alpha), - [RED] = QColor(255, 0, 0, start_alpha), + 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 QColor blend(const QColor &a, const QColor &b) { - return QColor((a.red() + b.red()) / 2, (a.green() + b.green()) / 2, (a.blue() + b.blue()) / 2, (a.alpha() + b.alpha()) / 2); +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 @@ -271,7 +272,7 @@ void CanData::compute(const MessageId &msg_id, const uint8_t *can_data, const in if (dat.size() != size) { dat.assign(can_data, can_data + size); - colors.assign(size, QColor(0, 0, 0, 0)); + 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; }); @@ -317,7 +318,7 @@ void CanData::compute(const MessageId &msg_id, const uint8_t *can_data, const in last_change.delta = delta; } else { // Fade out - colors[i].setAlphaF(std::max(0.0, colors[i].alphaF() - alpha_delta)); + colors[i].setAlphaF(std::max(0.0f, colors[i].alphaF() - alpha_delta)); } } } diff --git a/openpilot/tools/cabana/streams/abstractstream.h b/openpilot/tools/cabana/streams/abstractstream.h index 2f3b26fe2a..26f05d9846 100644 --- a/openpilot/tools/cabana/streams/abstractstream.h +++ b/openpilot/tools/cabana/streams/abstractstream.h @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -11,51 +12,12 @@ #include #include -#include -#include - #include "openpilot/cereal/messaging/messaging.h" +#include "tools/cabana/core/can_data.h" #include "tools/cabana/dbc/dbcmanager.h" #include "tools/cabana/utils/util.h" #include "tools/replay/util.h" -struct CanData { - void compute(const MessageId &msg_id, const uint8_t *dat, const int size, double current_sec, - double playback_speed, const std::vector &mask, double in_freq = 0); - - double ts = 0.; - uint32_t count = 0; - double freq = 0; - std::vector dat; - std::vector colors; - - struct ByteLastChange { - double ts = 0; - int delta = 0; - int same_delta_counter = 0; - bool suppressed = false; - }; - std::vector last_changes; - std::vector> 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 e, uint64_t ts) const { return e->mono_time < ts; } - constexpr bool operator()(uint64_t ts, const CanEvent *const e) const { return ts < e->mono_time; } -}; - -typedef std::unordered_map> MessageEventsMap; -using CanEventIter = std::vector::const_iterator; - class AbstractStream : public QObject { Q_OBJECT @@ -67,7 +29,7 @@ public: virtual void seekTo(double ts) {} virtual std::string routeName() const = 0; virtual std::string carFingerprint() const { return ""; } - virtual QDateTime beginDateTime() 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; } diff --git a/openpilot/tools/cabana/streams/livestream.cc b/openpilot/tools/cabana/streams/livestream.cc index ac9a6fa105..5aa4628bc0 100644 --- a/openpilot/tools/cabana/streams/livestream.cc +++ b/openpilot/tools/cabana/streams/livestream.cc @@ -3,7 +3,9 @@ #include #include #include +#include #include +#include #include "common/timing.h" #include "common/util.h" @@ -14,9 +16,14 @@ struct LiveStream::Logger { void write(kj::ArrayPtr 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(settings.log_path) - .arg(QDateTime::fromSecsSinceEpoch(start_ts).toString("yyyy-MM-dd--hh-mm-ss")) + .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)); @@ -55,7 +62,7 @@ void LiveStream::startUpdateTimer() { void LiveStream::start() { stream_thread->start(); startUpdateTimer(); - begin_date_time = QDateTime::currentDateTime(); + begin_date_time = std::chrono::system_clock::now(); } void LiveStream::stop() { diff --git a/openpilot/tools/cabana/streams/livestream.h b/openpilot/tools/cabana/streams/livestream.h index 24b9285092..e88fbadd8e 100644 --- a/openpilot/tools/cabana/streams/livestream.h +++ b/openpilot/tools/cabana/streams/livestream.h @@ -16,7 +16,7 @@ public: virtual ~LiveStream(); void start() override; void stop(); - inline QDateTime beginDateTime() const { return begin_date_time; } + 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; } @@ -41,7 +41,7 @@ private: int timer_id; QBasicTimer update_timer; - QDateTime begin_date_time; + 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; diff --git a/openpilot/tools/cabana/streams/replaystream.cc b/openpilot/tools/cabana/streams/replaystream.cc index b00c6e52c6..c74aa49821 100644 --- a/openpilot/tools/cabana/streams/replaystream.cc +++ b/openpilot/tools/cabana/streams/replaystream.cc @@ -136,10 +136,10 @@ OpenReplayWidget::OpenReplayWidget(QWidget *parent) : AbstractOpenStreamWidget(p setMinimumWidth(550); QObject::connect(browse_local_btn, &QPushButton::clicked, [=]() { - QString dir = QFileDialog::getExistingDirectory(this, tr("Open Local Route"), settings.last_route_dir); + 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 = QFileInfo(dir).absolutePath(); + settings.last_route_dir = QFileInfo(dir).absolutePath().toStdString(); } }); QObject::connect(browse_remote_btn, &QPushButton::clicked, [this]() { diff --git a/openpilot/tools/cabana/streams/replaystream.h b/openpilot/tools/cabana/streams/replaystream.h index 40f8ec8cfb..eecd345715 100644 --- a/openpilot/tools/cabana/streams/replaystream.h +++ b/openpilot/tools/cabana/streams/replaystream.h @@ -26,7 +26,9 @@ public: inline std::string carFingerprint() const override { return replay->carFingerprint(); } double minSeconds() const override { return replay->minSeconds(); } double maxSeconds() const { return replay->maxSeconds(); } - inline QDateTime beginDateTime() const { return QDateTime::fromSecsSinceEpoch(replay->routeDateTime()); } + 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(); } diff --git a/openpilot/tools/cabana/streamselector.cc b/openpilot/tools/cabana/streamselector.cc index 4ad552d4b4..75fdf384b2 100644 --- a/openpilot/tools/cabana/streamselector.cc +++ b/openpilot/tools/cabana/streamselector.cc @@ -52,10 +52,10 @@ StreamSelector::StreamSelector(QWidget *parent) : QDialog(parent) { setEnabled(true); }); QObject::connect(file_btn, &QPushButton::clicked, [this]() { - QString fn = QFileDialog::getOpenFileName(this, tr("Open File"), settings.last_dir, "DBC (*.dbc)"); + 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 = QFileInfo(fn).absolutePath(); + settings.last_dir = QFileInfo(fn).absolutePath().toStdString(); } }); } diff --git a/openpilot/tools/cabana/tests/test_cabana.cc b/openpilot/tools/cabana/tests/test_cabana.cc index 833cfbe4b5..c66f57d593 100644 --- a/openpilot/tools/cabana/tests/test_cabana.cc +++ b/openpilot/tools/cabana/tests/test_cabana.cc @@ -1,9 +1,16 @@ #undef INFO -#include +#include +#include #include "catch2/catch.hpp" +#include "tools/cabana/dbc/dbcfile.h" #include "tools/cabana/dbc/dbcmanager.h" +#include "tools/cabana/core/settings.h" + +#ifdef QT_CORE_LIB +#include +#endif const std::string TEST_RLOG_URL = "https://commadataci.blob.core.windows.net/openpilotci/0c94aa1e1296d7c6/2021-05-05--19-48-37/0/rlog.bz2"; @@ -143,15 +150,76 @@ CM_ SG_ 162 signal_1 "signal comment with \"escaped quotes\""; } TEST_CASE("parse_opendbc") { - QDir dir(OPENDBC_FILE_PATH); - QStringList errors; - for (auto fn : dir.entryList({"*.dbc"}, QDir::Files, QDir::Name)) { + std::vector errors; + for (const auto &entry : std::filesystem::directory_iterator(OPENDBC_FILE_PATH)) { + if (!entry.is_regular_file() || entry.path().extension() != ".dbc") continue; try { - auto dbc = DBCFile(dir.filePath(fn).toStdString()); + auto dbc = DBCFile(entry.path().string()); } catch (std::exception &e) { errors.push_back(e.what()); } } - INFO(errors.join("\n").toStdString()); + std::ostringstream details; + for (const auto &error : errors) details << error << '\n'; + INFO(details.str()); REQUIRE(errors.empty()); } + +TEST_CASE("DBCManager core callbacks") { + DBCManager manager; + int files_changed = 0; + int signals_added = 0; + int masks_updated = 0; + manager.setCallbacks({ + .signal_added = [&](MessageId, const cabana::Signal *) { ++signals_added; }, + .file_changed = [&]() { ++files_changed; }, + .mask_updated = [&]() { ++masks_updated; }, + }); + + std::string error; + REQUIRE(manager.open(SOURCE_ALL, "test", "BO_ 160 message: 8 XXX\n", &error)); + REQUIRE(error.empty()); + REQUIRE(files_changed == 1); + + cabana::Signal signal{}; + signal.name = "speed"; + signal.start_bit = 0; + signal.size = 8; + signal.is_little_endian = true; + manager.addSignal({.source = 0, .address = 160}, signal); + REQUIRE(signals_added == 1); + REQUIRE(masks_updated == 1); + REQUIRE(manager.msg({.source = 0, .address = 160})->sig("speed") != nullptr); +} + +TEST_CASE("Cabana settings core defaults") { + CabanaSettingsState state; + REQUIRE(state.fps == 10); + REQUIRE(state.chart_range == 180); + REQUIRE(state.drag_direction == CabanaSettingsState::MsbFirst); + REQUIRE(state.recent_files.empty()); +} + +#ifdef QT_CORE_LIB +TEST_CASE("CabanaColor preserves QColor transformations") { + const std::vector colors = { + QColor(102, 86, 169, 64), QColor(0, 187, 255, 128), QColor(255, 0, 0, 128), QColor(45, 120, 75, 255), + }; + for (const auto &qt_color : colors) { + CabanaColor color(qt_color.red(), qt_color.green(), qt_color.blue(), qt_color.alpha()); + for (int factor : {75, 100, 135, 150, 200}) { + const auto lighter = color.lighter(factor); + const auto qt_lighter = qt_color.lighter(factor); + CHECK(std::abs(lighter.red() - qt_lighter.red()) <= 1); + CHECK(std::abs(lighter.green() - qt_lighter.green()) <= 1); + CHECK(std::abs(lighter.blue() - qt_lighter.blue()) <= 1); + + const auto darker = color.darker(factor); + const auto qt_darker = qt_color.darker(factor); + CHECK(std::abs(darker.red() - qt_darker.red()) <= 1); + CHECK(std::abs(darker.green() - qt_darker.green()) <= 1); + CHECK(std::abs(darker.blue() - qt_darker.blue()) <= 1); + } + } +} +#endif diff --git a/openpilot/tools/cabana/tests/test_runner.cc b/openpilot/tools/cabana/tests/test_runner.cc index b20ac86c64..a76c8e16b9 100644 --- a/openpilot/tools/cabana/tests/test_runner.cc +++ b/openpilot/tools/cabana/tests/test_runner.cc @@ -1,10 +1,7 @@ #define CATCH_CONFIG_RUNNER #include "catch2/catch.hpp" -#include int main(int argc, char **argv) { - // unit tests for Qt - QCoreApplication app(argc, argv); const int res = Catch::Session().run(argc, argv); return (res < 0xff ? res : 0xff); } diff --git a/openpilot/tools/cabana/utils/export.cc b/openpilot/tools/cabana/utils/export.cc index a7f910193f..d585827ef5 100644 --- a/openpilot/tools/cabana/utils/export.cc +++ b/openpilot/tools/cabana/utils/export.cc @@ -1,41 +1,41 @@ #include "tools/cabana/utils/export.h" -#include -#include +#include +#include #include "tools/cabana/streams/abstractstream.h" namespace utils { -void exportToCSV(const QString &file_name, std::optional msg_id) { - QFile file(file_name); - if (file.open(QIODevice::ReadWrite | QIODevice::Truncate)) { - QTextStream stream(&file); +void exportToCSV(const std::string &file_name, std::optional 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 << QString::number(can->toSeconds(e->mono_time), 'f', 3) << "," - << "0x" << QString::number(e->address, 16) << "," << e->src << "," - << "0x" << QByteArray::fromRawData((const char *)e->dat, e->size).toHex().toUpper() << "\n"; + stream << std::fixed << std::setprecision(3) << can->toSeconds(e->mono_time) << "," + << "0x" << std::hex << e->address << std::dec << "," << static_cast(e->src) << ",0x" + << std::uppercase << std::hex << std::setfill('0'); + for (int i = 0; i < e->size; ++i) stream << std::setw(2) << static_cast(e->dat[i]); + stream << std::nouppercase << std::dec << "\n"; } } } -void exportSignalsToCSV(const QString &file_name, const MessageId &msg_id) { - QFile file(file_name); - if (auto msg = dbc()->msg(msg_id); msg && msg->sigs.size() && file.open(QIODevice::ReadWrite | QIODevice::Truncate)) { - QTextStream stream(&file); +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 << QString::number(can->toSeconds(e->mono_time), 'f', 3) << "," - << "0x" << QString::number(e->address, 16) << "," << e->src; + stream << std::fixed << std::setprecision(3) << can->toSeconds(e->mono_time) << "," + << "0x" << std::hex << e->address << std::dec << "," << static_cast(e->src); for (auto s : msg->sigs) { double value = 0; s->getValue(e->dat, e->size, &value); - stream << "," << QString::number(value, 'f', s->precision); + stream << "," << std::fixed << std::setprecision(s->precision) << value; } stream << "\n"; } diff --git a/openpilot/tools/cabana/utils/export.h b/openpilot/tools/cabana/utils/export.h index 270906b163..ee110e8321 100644 --- a/openpilot/tools/cabana/utils/export.h +++ b/openpilot/tools/cabana/utils/export.h @@ -1,10 +1,11 @@ #pragma once #include +#include #include "tools/cabana/dbc/dbcmanager.h" namespace utils { -void exportToCSV(const QString &file_name, std::optional msg_id = std::nullopt); -void exportSignalsToCSV(const QString &file_name, const MessageId &msg_id); +void exportToCSV(const std::string &file_name, std::optional msg_id = std::nullopt); +void exportSignalsToCSV(const std::string &file_name, const MessageId &msg_id); } // namespace utils diff --git a/openpilot/tools/cabana/utils/util.cc b/openpilot/tools/cabana/utils/util.cc index 50ab764423..415f6f1784 100644 --- a/openpilot/tools/cabana/utils/util.cc +++ b/openpilot/tools/cabana/utils/util.cc @@ -100,7 +100,7 @@ void MessageBytesDelegate::paint(QPainter *painter, const QStyleOptionViewItem & // Paint hex column const auto &bytes = *static_cast *>(data.value()); - const auto &colors = *static_cast *>(index.data(ColorsRole).value()); + const auto &colors = *static_cast *>(index.data(ColorsRole).value()); painter->setFont(fixed_font); const QPen text_pen(option.state & QStyle::State_Selected ? highlighted_color : text_color); @@ -115,7 +115,7 @@ void MessageBytesDelegate::paint(QPainter *painter, const QStyleOptionViewItem & painter->setPen(option.palette.color(QPalette::Text)); painter->fillRect(r, option.palette.color(QPalette::Window)); } - painter->fillRect(r, colors[i]); + painter->fillRect(r, toQColor(colors[i])); } else { painter->setPen(text_pen); } diff --git a/openpilot/tools/cabana/utils/util.h b/openpilot/tools/cabana/utils/util.h index f839ffe7fe..f4d8342b63 100644 --- a/openpilot/tools/cabana/utils/util.h +++ b/openpilot/tools/cabana/utils/util.h @@ -7,6 +7,7 @@ #include #include +#include #include #include #include @@ -21,6 +22,10 @@ #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 diff --git a/openpilot/tools/cabana/videowidget.cc b/openpilot/tools/cabana/videowidget.cc index 87d3cbec95..5b680ad454 100644 --- a/openpilot/tools/cabana/videowidget.cc +++ b/openpilot/tools/cabana/videowidget.cc @@ -203,7 +203,7 @@ void VideoWidget::timeRangeChanged() { QString VideoWidget::formatTime(double sec, bool include_milliseconds) { if (settings.absolute_time) - sec = can->beginDateTime().addMSecs(sec * 1000).toMSecsSinceEpoch() / 1000.0; + sec += std::chrono::duration(can->beginDateTime().time_since_epoch()).count(); return utils::formatSeconds(sec, include_milliseconds, settings.absolute_time); } From 5d23a78c77c461226206c3a0ac624bab73637cbb Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Thu, 16 Jul 2026 14:35:45 -0700 Subject: [PATCH 04/35] cabana: de-Qt, part 2 (#38359) --- openpilot/tools/cabana/binaryview.cc | 6 +- openpilot/tools/cabana/cabana.cc | 180 +++++++++++++---- openpilot/tools/cabana/cameraview.cc | 5 +- openpilot/tools/cabana/chart/chart.cc | 65 +------ openpilot/tools/cabana/chart/chart.h | 1 - openpilot/tools/cabana/chart/chartswidget.cc | 1 - .../tools/cabana/chart/signalselector.cc | 3 - openpilot/tools/cabana/deqt.md | 70 +++++++ openpilot/tools/cabana/mainwin.cc | 28 ++- openpilot/tools/cabana/mainwin.h | 5 +- openpilot/tools/cabana/signalview.cc | 16 +- .../tools/cabana/streams/devicestream.cc | 87 +++++++-- openpilot/tools/cabana/streams/devicestream.h | 5 +- openpilot/tools/cabana/streams/pandastream.cc | 13 +- openpilot/tools/cabana/streams/routes.cc | 99 +++++++--- .../tools/cabana/streams/socketcanstream.cc | 11 +- openpilot/tools/cabana/utils/util.cc | 184 +++++++++++++++--- openpilot/tools/cabana/utils/util.h | 51 +++-- openpilot/tools/cabana/videowidget.cc | 10 +- openpilot/tools/cabana/videowidget.h | 3 - 20 files changed, 607 insertions(+), 236 deletions(-) create mode 100644 openpilot/tools/cabana/deqt.md diff --git a/openpilot/tools/cabana/binaryview.cc b/openpilot/tools/cabana/binaryview.cc index 1c7c7b938e..f396d1c13f 100644 --- a/openpilot/tools/cabana/binaryview.cc +++ b/openpilot/tools/cabana/binaryview.cc @@ -3,7 +3,8 @@ #include -#include +#include + #include #include #include @@ -260,7 +261,8 @@ void BinaryViewModel::refresh() { int pos = sig->is_little_endian ? flipBitPos(sig->start_bit + j) : flipBitPos(sig->start_bit) + j; int idx = column_count * (pos / 8) + pos % 8; if (idx >= items.size()) { - qWarning() << "signal " << sig->name.c_str() << "out of bounds.start_bit:" << sig->start_bit << "size:" << sig->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; diff --git a/openpilot/tools/cabana/cabana.cc b/openpilot/tools/cabana/cabana.cc index db26b4067a..56b43361f8 100644 --- a/openpilot/tools/cabana/cabana.cc +++ b/openpilot/tools/cabana/cabana.cc @@ -1,5 +1,9 @@ +#include +#include +#include +#include + #include -#include #include "tools/cabana/mainwin.h" #include "tools/cabana/streams/devicestream.h" @@ -9,6 +13,120 @@ #include "tools/cabana/streams/socketcanstream.h" #endif +namespace { + +struct CabanaArgs { + bool demo = false; + bool auto_source = false; + bool qcam = false; + bool ecam = false; + bool dcam = false; + bool msgq = false; + bool panda = false; + bool no_vipc = false; + std::string panda_serial; + std::string socketcan; + std::string zmq; + std::string data_dir; + std::string dbc; + std::string route; +}; + +void printUsage(const char *argv0) { + fprintf(stderr, + "Usage: %s [options] [route]\n" + "\n" + " route the drive to replay. find your drives at connect.comma.ai\n" + "\n" + "Options:\n" + " --help show this help\n" + " --demo use a demo route instead of providing your own\n" + " --auto Auto load the route from the best available source (no video):\n" + " internal, openpilotci, comma_api, car_segments, testing_closet\n" + " --qcam load qcamera\n" + " --ecam load wide road camera\n" + " --dcam load driver camera\n" + " --msgq read can messages from the msgq\n" + " --panda read can messages from panda\n" + " --panda-serial read can messages from panda with given serial\n" +#ifdef __linux__ + " --socketcan read can messages from given SocketCAN device\n" +#endif + " --zmq read can messages from zmq at the specified ip-address\n" + " --data_dir local directory with routes\n" + " --no-vipc do not output video\n" + " --dbc dbc file to open\n", + argv0); +} + +// Returns true if value was consumed from argv[i+1]. +bool takeValue(int argc, char *argv[], int &i, std::string &out) { + if (i + 1 >= argc) { + fprintf(stderr, "error: %s requires a value\n", argv[i]); + return false; + } + out = argv[++i]; + return true; +} + +// Returns 0 to continue, or a process exit code (0 for --help, 1 for errors). +int parseArgs(int argc, char *argv[], CabanaArgs &args, bool &ok) { + ok = false; + for (int i = 1; i < argc; ++i) { + const char *a = argv[i]; + if (std::strcmp(a, "--help") == 0 || std::strcmp(a, "-h") == 0) { + printUsage(argv[0]); + return 0; + } else if (std::strcmp(a, "--demo") == 0) { + args.demo = true; + } else if (std::strcmp(a, "--auto") == 0) { + args.auto_source = true; + } else if (std::strcmp(a, "--qcam") == 0) { + args.qcam = true; + } else if (std::strcmp(a, "--ecam") == 0) { + args.ecam = true; + } else if (std::strcmp(a, "--dcam") == 0) { + args.dcam = true; + } else if (std::strcmp(a, "--msgq") == 0) { + args.msgq = true; + } else if (std::strcmp(a, "--panda") == 0) { + args.panda = true; + } else if (std::strcmp(a, "--panda-serial") == 0) { + if (!takeValue(argc, argv, i, args.panda_serial)) return 1; + args.panda = true; + } else if (std::strcmp(a, "--socketcan") == 0) { + if (!takeValue(argc, argv, i, args.socketcan)) return 1; +#ifdef __linux__ +#else + fprintf(stderr, "error: --socketcan is only supported on Linux\n"); + return 1; +#endif + } else if (std::strcmp(a, "--zmq") == 0) { + if (!takeValue(argc, argv, i, args.zmq)) return 1; + } else if (std::strcmp(a, "--data_dir") == 0) { + if (!takeValue(argc, argv, i, args.data_dir)) return 1; + } else if (std::strcmp(a, "--no-vipc") == 0) { + args.no_vipc = true; + } else if (std::strcmp(a, "--dbc") == 0) { + if (!takeValue(argc, argv, i, args.dbc)) return 1; + } else if (a[0] == '-') { + fprintf(stderr, "error: unknown option %s\n", a); + printUsage(argv[0]); + return 1; + } else if (args.route.empty()) { + args.route = a; + } else { + fprintf(stderr, "error: unexpected argument %s\n", a); + printUsage(argv[0]); + return 1; + } + } + ok = true; + return 0; +} + +} // namespace + int main(int argc, char *argv[]) { QCoreApplication::setApplicationName("Cabana"); QCoreApplication::setAttribute(Qt::AA_ShareOpenGLContexts); @@ -20,69 +138,51 @@ int main(int argc, char *argv[]) { UnixSignalHandler signalHandler; utils::setTheme(settings.theme); - QCommandLineParser cmd_parser; - cmd_parser.addHelpOption(); - cmd_parser.addPositionalArgument("route", "the drive to replay. find your drives at connect.comma.ai"); - cmd_parser.addOption({"demo", "use a demo route instead of providing your own"}); - cmd_parser.addOption({"auto", "Auto load the route from the best available source (no video): internal, openpilotci, comma_api, car_segments, testing_closet"}); - cmd_parser.addOption({"qcam", "load qcamera"}); - cmd_parser.addOption({"ecam", "load wide road camera"}); - cmd_parser.addOption({"dcam", "load driver camera"}); - cmd_parser.addOption({"msgq", "read can messages from the msgq"}); - cmd_parser.addOption({"panda", "read can messages from panda"}); - cmd_parser.addOption({"panda-serial", "read can messages from panda with given serial", "panda-serial"}); -#ifdef __linux__ - if (SocketCanStream::available()) { - cmd_parser.addOption({"socketcan", "read can messages from given SocketCAN device", "socketcan"}); + CabanaArgs args; + bool args_ok = false; + if (const int code = parseArgs(argc, argv, args, args_ok); !args_ok) { + return code; } -#endif - cmd_parser.addOption({"zmq", "read can messages from zmq at the specified ip-address", "ip-address"}); - cmd_parser.addOption({"data_dir", "local directory with routes", "data_dir"}); - cmd_parser.addOption({"no-vipc", "do not output video"}); - cmd_parser.addOption({"dbc", "dbc file to open", "dbc"}); - cmd_parser.process(app); AbstractStream *stream = nullptr; - if (cmd_parser.isSet("msgq")) { + if (args.msgq) { stream = new DeviceStream(&app); - } else if (cmd_parser.isSet("zmq")) { - stream = new DeviceStream(&app, cmd_parser.value("zmq")); - } else if (cmd_parser.isSet("panda") || cmd_parser.isSet("panda-serial")) { + } else if (!args.zmq.empty()) { + stream = new DeviceStream(&app, QString::fromStdString(args.zmq)); + } else if (args.panda || !args.panda_serial.empty()) { try { - stream = new PandaStream(&app, {.serial = cmd_parser.value("panda-serial").toStdString()}); + stream = new PandaStream(&app, {.serial = args.panda_serial}); } catch (std::exception &e) { - qWarning() << e.what(); + fprintf(stderr, "%s\n", e.what()); return 0; } #ifdef __linux__ - } else if (SocketCanStream::available() && cmd_parser.isSet("socketcan")) { - stream = new SocketCanStream(&app, {.device = cmd_parser.value("socketcan").toStdString()}); + } else if (SocketCanStream::available() && !args.socketcan.empty()) { + stream = new SocketCanStream(&app, {.device = args.socketcan}); #endif } else { uint32_t replay_flags = REPLAY_FLAG_NONE; - if (cmd_parser.isSet("ecam")) replay_flags |= REPLAY_FLAG_ECAM; - if (cmd_parser.isSet("qcam")) replay_flags |= REPLAY_FLAG_QCAMERA; - if (cmd_parser.isSet("dcam")) replay_flags |= REPLAY_FLAG_DCAM; - if (cmd_parser.isSet("no-vipc")) replay_flags |= REPLAY_FLAG_NO_VIPC; + 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; - const QStringList args = cmd_parser.positionalArguments(); QString route; - if (args.size() > 0) { - route = args.first(); - } else if (cmd_parser.isSet("demo")) { + 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(&app); - bool auto_source = cmd_parser.isSet("auto"); - if (!replay_stream->loadRoute(route.toStdString(), cmd_parser.value("data_dir").toStdString(), replay_flags, auto_source)) { + if (!replay_stream->loadRoute(route.toStdString(), args.data_dir, replay_flags, args.auto_source)) { return 0; } stream = replay_stream.release(); } } - MainWindow w(stream, cmd_parser.value("dbc")); + MainWindow w(stream, QString::fromStdString(args.dbc)); return app.exec(); } diff --git a/openpilot/tools/cabana/cameraview.cc b/openpilot/tools/cabana/cameraview.cc index 13c838efd8..997bd19198 100644 --- a/openpilot/tools/cabana/cameraview.cc +++ b/openpilot/tools/cabana/cameraview.cc @@ -6,6 +6,8 @@ #include #endif +#include + #include namespace { @@ -221,7 +223,8 @@ void CameraWidget::vipcThread() { while (!QThread::currentThread()->isInterruptionRequested()) { if (!vipc_client || cur_stream != requested_stream_type) { clearFrames(); - qDebug().nospace() << "connecting to stream " << requested_stream_type << ", was connected to " << cur_stream; + 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)); } diff --git a/openpilot/tools/cabana/chart/chart.cc b/openpilot/tools/cabana/chart/chart.cc index b5ddcf0479..0ce5d051b3 100644 --- a/openpilot/tools/cabana/chart/chart.cc +++ b/openpilot/tools/cabana/chart/chart.cc @@ -3,18 +3,15 @@ #include #include +#include #include #include #include #include -#include #include -#include #include #include -#include -#include #include #include #include @@ -421,45 +418,6 @@ qreal ChartView::niceNumber(qreal x, bool ceiling) { return q * z; } -QPixmap getBlankShadowPixmap(const QPixmap &px, int radius) { - QGraphicsDropShadowEffect *e = new QGraphicsDropShadowEffect; - e->setColor(QColor(40, 40, 40, 245)); - e->setOffset(0, 0); - e->setBlurRadius(radius); - - qreal dpr = px.devicePixelRatio(); - QPixmap blank(px.size()); - blank.setDevicePixelRatio(dpr); - blank.fill(Qt::white); - - QGraphicsScene scene; - QGraphicsPixmapItem item(blank); - item.setGraphicsEffect(e); - scene.addItem(&item); - - QPixmap shadow(px.size() + QSize(radius * dpr * 2, radius * dpr * 2)); - shadow.setDevicePixelRatio(dpr); - shadow.fill(Qt::transparent); - QPainter p(&shadow); - scene.render(&p, {QPoint(), shadow.size() / dpr}, item.boundingRect().adjusted(-radius, -radius, radius, radius)); - return shadow; -} - -static QPixmap getDropPixmap(const QPixmap &src) { - static QPixmap shadow_px; - const int radius = 10; - if (shadow_px.size() != src.size() + QSize(radius * 2, radius * 2)) { - shadow_px = getBlankShadowPixmap(src, radius); - } - QPixmap px = shadow_px; - QPainter p(&px); - QRectF target_rect(QPointF(radius, radius), src.size() / src.devicePixelRatio()); - p.drawPixmap(target_rect.topLeft(), src); - p.setCompositionMode(QPainter::CompositionMode_DestinationIn); - p.fillRect(target_rect, QColor(0, 0, 0, 200)); - return px; -} - void ChartView::contextMenuEvent(QContextMenuEvent *event) { QMenu context_menu(this); context_menu.addActions(menu->actions()); @@ -479,7 +437,7 @@ void ChartView::mousePressEvent(QMouseEvent *event) { charts_widget->stopAutoScroll(); QDrag *drag = new QDrag(this); drag->setMimeData(mimeData); - drag->setPixmap(getDropPixmap(px)); + drag->setPixmap(px); drag->setHotSpot(-QPoint(5, 5)); drag->exec(Qt::CopyAction | Qt::MoveAction, Qt::MoveAction); } else if (event->button() == Qt::LeftButton && QApplication::keyboardModifiers().testFlag(Qt::ShiftModifier)) { @@ -628,7 +586,6 @@ void ChartView::dropEvent(QDropEvent *event) { sigs.insert(sigs.end(), std::move_iterator(source_chart->sigs.begin()), std::move_iterator(source_chart->sigs.end())); updateAxisY(); updateTitle(); - startAnimation(); source_chart->sigs.clear(); charts_widget->removeChart(source_chart); @@ -643,17 +600,6 @@ void ChartView::resetChartCache() { viewport()->update(); } -void ChartView::startAnimation() { - QGraphicsOpacityEffect *eff = new QGraphicsOpacityEffect(this); - viewport()->setGraphicsEffect(eff); - QPropertyAnimation *a = new QPropertyAnimation(eff, "opacity"); - a->setDuration(250); - a->setStartValue(0.3); - a->setEndValue(1); - a->setEasingCurve(QEasingCurve::InBack); - a->start(QPropertyAnimation::DeleteWhenStopped); -} - void ChartView::paintEvent(QPaintEvent *event) { if (!can->liveStreaming()) { if (chart_pixmap.isNull()) { @@ -822,9 +768,12 @@ void ChartView::setSeriesColor(QXYSeries *series, QColor color) { if (s != series && std::abs(color.hueF() - qobject_cast(s)->color().hueF()) < 0.1) { // use different color to distinguish it from others. auto last_color = qobject_cast(existing_series.back())->color(); + static thread_local std::mt19937 rng{std::random_device{}()}; + std::uniform_int_distribution sat(35, 99); + std::uniform_int_distribution val(85, 99); color.setHsvF(std::fmod(last_color.hueF() + 60 / 360.0, 1.0), - QRandomGenerator::global()->bounded(35, 100) / 100.0, - QRandomGenerator::global()->bounded(85, 100) / 100.0); + sat(rng) / 100.0, + val(rng) / 100.0); break; } } diff --git a/openpilot/tools/cabana/chart/chart.h b/openpilot/tools/cabana/chart/chart.h index f9472bd4f6..ae050269bf 100644 --- a/openpilot/tools/cabana/chart/chart.h +++ b/openpilot/tools/cabana/chart/chart.h @@ -38,7 +38,6 @@ public: void updatePlotArea(int left, bool force = false); void showTip(double sec); void hideTip(); - void startAnimation(); double secondsAtPoint(const QPointF &pt) const { return chart()->mapToValue(pt).x(); } struct SigItem { diff --git a/openpilot/tools/cabana/chart/chartswidget.cc b/openpilot/tools/cabana/chart/chartswidget.cc index e5c70bc536..93d8570996 100644 --- a/openpilot/tools/cabana/chart/chartswidget.cc +++ b/openpilot/tools/cabana/chart/chartswidget.cc @@ -570,7 +570,6 @@ void ChartsContainer::dropEvent(QDropEvent *event) { charts_widget->updateLayout(true); charts_widget->updateTabBar(); event->acceptProposedAction(); - chart->startAnimation(); } drawDropIndicator({}); } diff --git a/openpilot/tools/cabana/chart/signalselector.cc b/openpilot/tools/cabana/chart/signalselector.cc index 12a9c81564..85832e796b 100644 --- a/openpilot/tools/cabana/chart/signalselector.cc +++ b/openpilot/tools/cabana/chart/signalselector.cc @@ -1,7 +1,6 @@ #include "tools/cabana/chart/signalselector.h" #include "tools/cabana/dbc/dbcqt.h" -#include #include #include #include @@ -21,8 +20,6 @@ SignalSelector::SignalSelector(QString title, QWidget *parent) : QDialog(parent) msgs_combo->setEditable(true); msgs_combo->lineEdit()->setPlaceholderText(tr("Select a msg...")); msgs_combo->setInsertPolicy(QComboBox::NoInsert); - msgs_combo->completer()->setCompletionMode(QCompleter::PopupCompletion); - msgs_combo->completer()->setFilterMode(Qt::MatchContains); main_layout->addWidget(available_list = new QListWidget(this), 2, 0); diff --git a/openpilot/tools/cabana/deqt.md b/openpilot/tools/cabana/deqt.md new file mode 100644 index 0000000000..6d2aed8264 --- /dev/null +++ b/openpilot/tools/cabana/deqt.md @@ -0,0 +1,70 @@ +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` +- `QByteArray` +- `QVariant` +- `QVector`, `QMap`, `QSet`, `QPointer` +- `QSettings` +- `QFile`, `QFileInfo`, `QDir`, `QIODevice`, `QStandardPaths` +- `QThread` +- `QTimer`, `QBasicTimer`, `QTimerEvent` +- `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`, `QRubberBand` +- `QFormLayout`, `QGridLayout`, `QHBoxLayout`, `QVBoxLayout` +- `QSizePolicy` +- `QAbstractItemModel`, `QAbstractTableModel`, `QModelIndex` +- `QAbstractItemView`, `QTableView`, `QTreeView` +- `QTableWidget`, `QTableWidgetItem`, `QListWidget`, `QListWidgetItem` +- `QItemSelection`, `QItemSelectionModel`, `QItemSelectionRange` +- `QHeaderView`, `QStyledItemDelegate`, `QStyleOptionViewItem` +- `QValidator`, `QIntValidator`, `QDoubleValidator` +- `QColor`, `QRgb`, `QPalette` +- `QBrush`, `QPen` +- `QPainter`, `QPainterPath`, `QStylePainter` +- `QPixmap`, `QPixmapCache`, `QIcon`, `QStaticText` +- `QFont`, `QFontDatabase`, `QFontMetrics`, `QTextDocument` +- `QStyle`, `QStyleOption`, `QStyleOptionFrame`, `QStyleOptionSlider` +- `QPoint`, `QPointF`, `QRect`, `QRectF`, `QRegion` +- `QSize`, `QSizeF` +- `QCursor`, `QClipboard`, `QScreen`, `QDesktopWidget` +- `QEvent`, `QPaintEvent`, `QResizeEvent`, `QShowEvent`, `QCloseEvent` +- `QMouseEvent`, `QWheelEvent`, `QNativeGestureEvent`, `QContextMenuEvent` +- `QDrag`, `QMimeData` +- `QDragEnterEvent`, `QDragLeaveEvent`, `QDragMoveEvent`, `QDropEvent` +- `QKeySequence`, `QShortcut`, `QToolTip` +- `QChart`, `QChartView`, `QAbstractAxis`, `QValueAxis` +- `QXYSeries`, `QLineSeries`, `QScatterSeries` +- `QLegend`, `QLegendMarker` +- `QGraphicsScene`, `QGraphicsView`, `QGraphicsItemGroup`, `QGraphicsLayout` +- `QGraphicsPixmapItem`, `QGraphicsProxyWidget` +- `QOpenGLWidget`, `QOpenGLFunctions` +- `QOpenGLShader`, `QOpenGLShaderProgram` +- `QMatrix4x4`, `QSurfaceFormat` +- `QUndoCommand`, `QUndoStack`, `QUndoView` diff --git a/openpilot/tools/cabana/mainwin.cc b/openpilot/tools/cabana/mainwin.cc index 8d932163a6..6372ea9295 100644 --- a/openpilot/tools/cabana/mainwin.cc +++ b/openpilot/tools/cabana/mainwin.cc @@ -10,7 +10,6 @@ #include #include #include -#include #include #include #include @@ -21,6 +20,7 @@ #include #include +#include "json11/json11.hpp" #include "tools/cabana/commands.h" #include "tools/cabana/streamselector.h" #include "tools/cabana/tools/findsignal.h" @@ -53,11 +53,9 @@ MainWindow::MainWindow(AbstractStream *stream, const QString &dbc_file) : QMainW installDownloadProgressHandler([](uint64_t cur, uint64_t total, bool success) { emit static_main_win->updateProgressBar(cur, total, success); }); - qInstallMessageHandler([](QtMsgType type, const QMessageLogContext &context, const QString &msg) { - if (type == QtDebugMsg) return; - emit static_main_win->showMessage(msg, 2000); + installMessageHandler([](ReplyMsgType type, const std::string msg) { + emit static_main_win->showMessage(QString::fromStdString(msg), 2000); }); - installMessageHandler([](ReplyMsgType type, const std::string msg) { qInfo() << msg.c_str(); }); setStyleSheet(QString(R"(QMainWindow::separator { width: %1px; /* when vertical */ @@ -76,8 +74,15 @@ MainWindow::MainWindow(AbstractStream *stream, const QString &dbc_file) : QMainW void MainWindow::loadFingerprints() { QFile json_file(QApplication::applicationDirPath() + "/dbc/car_fingerprint_to_dbc.json"); - if (json_file.open(QIODevice::ReadOnly)) { - fingerprint_to_dbc = QJsonDocument::fromJson(json_file.readAll()); + if (!json_file.open(QIODevice::ReadOnly)) return; + std::string err; + auto doc = json11::Json::parse(json_file.readAll().toStdString(), 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()); + } } } @@ -374,8 +379,11 @@ void MainWindow::eventsMerged() { .arg(QString::fromStdString(can->routeName())) .arg(car_fingerprint.isEmpty() ? tr("Unknown Car") : car_fingerprint)); // Don't overwrite already loaded DBC - if (!dbc()->nonEmptyDBCCount() && fingerprint_to_dbc.object().contains(car_fingerprint)) { - QTimer::singleShot(0, this, [this]() { loadDBCFromOpendbc(fingerprint_to_dbc[car_fingerprint].toString() + ".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"); + }); } } } @@ -562,7 +570,7 @@ void MainWindow::closeEvent(QCloseEvent *event) { remindSaveChanges(); installDownloadProgressHandler(nullptr); - qInstallMessageHandler(nullptr); + installMessageHandler(nullptr); if (floating_window) floating_window->deleteLater(); diff --git a/openpilot/tools/cabana/mainwin.h b/openpilot/tools/cabana/mainwin.h index 92c2714ae7..3526a58202 100644 --- a/openpilot/tools/cabana/mainwin.h +++ b/openpilot/tools/cabana/mainwin.h @@ -1,13 +1,14 @@ #pragma once #include -#include #include #include #include #include #include #include +#include +#include #include "tools/cabana/chart/chartswidget.h" #include "tools/cabana/dbc/dbcmanager.h" @@ -85,7 +86,7 @@ protected: QVBoxLayout *charts_layout; QProgressBar *progress_bar; QLabel *status_label; - QJsonDocument fingerprint_to_dbc; + std::unordered_map fingerprint_to_dbc; QSplitter *video_splitter = nullptr; enum { MAX_RECENT_FILES = 15 }; QMenu *open_recent_menu = nullptr; diff --git a/openpilot/tools/cabana/signalview.cc b/openpilot/tools/cabana/signalview.cc index 5048cc6aa8..87294b41f7 100644 --- a/openpilot/tools/cabana/signalview.cc +++ b/openpilot/tools/cabana/signalview.cc @@ -4,7 +4,6 @@ #include #include -#include #include #include #include @@ -16,6 +15,7 @@ #include #include "tools/cabana/commands.h" +#include "tools/cabana/utils/util.h" // SignalModel @@ -252,7 +252,7 @@ void SignalModel::handleSignalRemoved(const cabana::Signal *sig) { SignalItemDelegate::SignalItemDelegate(QObject *parent) : QStyledItemDelegate(parent) { name_validator = new NameValidator(this); - node_validator = new QRegExpValidator(QRegExp("^\\w+(,\\w+)*$"), this); + node_validator = new NodeValidator(this); double_validator = new DoubleValidator(this); label_font.setPointSize(8); @@ -376,15 +376,6 @@ QWidget *SignalItemDelegate::createEditor(QWidget *parent, const QStyleOptionVie else if (item->type == SignalModel::Item::Node) e->setValidator(node_validator); else e->setValidator(double_validator); - if (item->type == SignalModel::Item::Name) { - auto names = dbc()->signalNames(); - QStringList qnames; - for (const auto &n : names) qnames.push_back(QString::fromStdString(n)); - QCompleter *completer = new QCompleter(qnames, e); - completer->setCaseSensitivity(Qt::CaseInsensitive); - completer->setFilterMode(Qt::MatchContains); - e->setCompleter(completer); - } return e; } else if (item->type == SignalModel::Item::Size) { QSpinBox *spin = new QSpinBox(parent); @@ -429,8 +420,7 @@ SignalView::SignalView(ChartsWidget *charts, QWidget *parent) : charts(charts), QHBoxLayout *hl = new QHBoxLayout(title_bar); hl->addWidget(signal_count_lb = new QLabel()); filter_edit = new QLineEdit(this); - QRegularExpression re("\\S+"); - filter_edit->setValidator(new QRegularExpressionValidator(re, this)); + filter_edit->setValidator(new NonWhitespaceValidator(this)); filter_edit->setClearButtonEnabled(true); filter_edit->setPlaceholderText(tr("Filter Signal")); hl->addWidget(filter_edit); diff --git a/openpilot/tools/cabana/streams/devicestream.cc b/openpilot/tools/cabana/streams/devicestream.cc index 96fae908ba..1ce0dbb71f 100644 --- a/openpilot/tools/cabana/streams/devicestream.cc +++ b/openpilot/tools/cabana/streams/devicestream.cc @@ -1,7 +1,13 @@ #include "tools/cabana/streams/devicestream.h" +#include +#include +#include +#include #include #include +#include +#include #include "openpilot/cereal/services.h" @@ -10,36 +16,86 @@ #include #include #include -#include -#include #include +#include "tools/cabana/utils/util.h" + // DeviceStream DeviceStream::DeviceStream(QObject *parent, QString address) : zmq_address(address), LiveStream(parent) { } DeviceStream::~DeviceStream() { - if (!bridge_process) - return; + stopBridge(); +} - bridge_process->terminate(); - if (!bridge_process->waitForFinished(3000)) { - bridge_process->kill(); - bridge_process->waitForFinished(); +void DeviceStream::stopBridge() { + if (bridge_pid <= 0) return; + + ::kill(bridge_pid, SIGTERM); + for (int i = 0; i < 30; ++i) { + int status = 0; + pid_t r = ::waitpid(bridge_pid, &status, WNOHANG); + if (r == bridge_pid || (r < 0 && errno == ECHILD)) { + bridge_pid = -1; + return; + } + usleep(100000); // 100ms, up to ~3s } + ::kill(bridge_pid, SIGKILL); + ::waitpid(bridge_pid, nullptr, 0); + bridge_pid = -1; } void DeviceStream::start() { if (!zmq_address.isEmpty()) { - bridge_process = new QProcess(this); - QString bridge_path = QCoreApplication::applicationDirPath() + "/../../openpilot/cereal/messaging/bridge"; - bridge_process->start(QFileInfo(bridge_path).absoluteFilePath(), QStringList { zmq_address, "/\"can/\"" }); + stopBridge(); + QString bridge_path = QFileInfo(QCoreApplication::applicationDirPath() + + "/../../openpilot/cereal/messaging/bridge").absoluteFilePath(); + const std::string path = bridge_path.toStdString(); + const std::string addr = zmq_address.toStdString(); + const char *can_filter = "/\"can/\""; - if (!bridge_process->waitForStarted()) { - QMessageBox::warning(nullptr, tr("Error"), tr("Failed to start bridge: %1").arg(bridge_process->errorString())); + // 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(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(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(); @@ -69,10 +125,7 @@ OpenDeviceWidget::OpenDeviceWidget(QWidget *parent) : AbstractOpenStreamWidget(p QRadioButton *zmq = new QRadioButton(tr("ZMQ")); ip_address = new QLineEdit(this); ip_address->setPlaceholderText(tr("Enter device Ip Address")); - QString ip_range = "(?:[0-1]?[0-9]?[0-9]|2[0-4][0-9]|25[0-5])"; - QString pattern("^" + ip_range + "\\." + ip_range + "\\." + ip_range + "\\." + ip_range + "$"); - QRegularExpression re(pattern); - ip_address->setValidator(new QRegularExpressionValidator(re, this)); + ip_address->setValidator(new IpAddressValidator(this)); group = new QButtonGroup(this); group->addButton(msgq, 0); diff --git a/openpilot/tools/cabana/streams/devicestream.h b/openpilot/tools/cabana/streams/devicestream.h index 4bcdb5351d..0e6951c92c 100644 --- a/openpilot/tools/cabana/streams/devicestream.h +++ b/openpilot/tools/cabana/streams/devicestream.h @@ -2,7 +2,7 @@ #include "tools/cabana/streams/livestream.h" -#include +#include class DeviceStream : public LiveStream { Q_OBJECT @@ -16,7 +16,8 @@ public: protected: void start() override; void streamThread() override; - QProcess *bridge_process = nullptr; + void stopBridge(); + pid_t bridge_pid = -1; const QString zmq_address; }; diff --git a/openpilot/tools/cabana/streams/pandastream.cc b/openpilot/tools/cabana/streams/pandastream.cc index 3692f71a11..aa1e01c5a3 100644 --- a/openpilot/tools/cabana/streams/pandastream.cc +++ b/openpilot/tools/cabana/streams/pandastream.cc @@ -1,6 +1,7 @@ #include "tools/cabana/streams/pandastream.h" -#include +#include + #include #include #include @@ -16,10 +17,10 @@ PandaStream::PandaStream(QObject *parent, PandaStreamConfig config_) : config(co bool PandaStream::connect() { try { - qDebug() << "Connecting to panda " << config.serial.c_str(); + fprintf(stderr, "Connecting to panda %s\n", config.serial.c_str()); panda.reset(new Panda(config.serial)); config.bus_config.resize(3); - qDebug() << "Connected"; + fprintf(stderr, "Connected\n"); } catch (const std::exception& e) { return false; } @@ -48,7 +49,7 @@ void PandaStream::streamThread() { QThread::msleep(1); if (!panda->connected()) { - qDebug() << "Connection to panda lost. Attempting reconnect."; + fprintf(stderr, "Connection to panda lost. Attempting reconnect.\n"); if (!connect()){ QThread::msleep(1000); continue; @@ -57,7 +58,7 @@ void PandaStream::streamThread() { raw_can_data.clear(); if (!panda->can_receive(raw_can_data)) { - qDebug() << "failed to receive"; + fprintf(stderr, "failed to receive\n"); continue; } @@ -123,7 +124,7 @@ void OpenPandaWidget::buildConfigForm() { 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) { - qDebug() << "failed to open panda" << serial; + fprintf(stderr, "failed to open panda %s\n", serial.toUtf8().constData()); has_panda = false; } } diff --git a/openpilot/tools/cabana/streams/routes.cc b/openpilot/tools/cabana/streams/routes.cc index e3e5cb1b6e..9c19219ce2 100644 --- a/openpilot/tools/cabana/streams/routes.cc +++ b/openpilot/tools/cabana/streams/routes.cc @@ -1,18 +1,20 @@ #include "tools/cabana/streams/routes.h" +#include +#include +#include +#include +#include + #include -#include #include #include -#include -#include -#include #include #include #include #include -#include +#include "json11/json11.hpp" #include "tools/replay/py_downloader.h" namespace { @@ -20,13 +22,52 @@ namespace { // Parse a PyDownloader JSON response into (success, error_code). std::pair checkApiResponse(const std::string &result) { if (result.empty()) return {false, 500}; - auto doc = QJsonDocument::fromJson(QByteArray::fromStdString(result)); - if (doc.isObject() && doc.object().contains("error")) { - return {false, doc.object()["error"].toString() == "unauthorized" ? 401 : 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::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(-1)) return 0; + return static_cast(secs) * 1000 + millis; +} + +QString formatUnixMs(int64_t ms) { + time_t secs = static_cast(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 @@ -83,9 +124,13 @@ RoutesDialog::RoutesDialog(QWidget *parent) : QDialog(parent) { void RoutesDialog::parseDeviceList(const QString &json, bool success, int error_code) { if (success) { device_list_->clear(); - for (const QJsonValue &device : QJsonDocument::fromJson(json.toUtf8()).array()) { - QString dongle_id = device["dongle_id"].toString(); - device_list_->addItem(dongle_id, dongle_id); + std::string err; + auto doc = json11::Json::parse(json.toStdString(), err); + if (err.empty() && doc.is_array()) { + for (const auto &device : doc.array_items()) { + QString dongle_id = QString::fromStdString(device["dongle_id"].string_value()); + device_list_->addItem(dongle_id, dongle_id); + } } } else { QMessageBox::warning(this, tr("Error"), error_code == 401 ? tr("Unauthorized. Authenticate with openpilot/tools/lib/auth.py") : tr("Network error")); @@ -106,9 +151,8 @@ void RoutesDialog::fetchRoutes() { bool preserved = (period == -1); int64_t start_ms = 0, end_ms = 0; if (!preserved) { - QDateTime now = QDateTime::currentDateTime(); - start_ms = now.addDays(-period).toMSecsSinceEpoch(); - end_ms = now.toMSecsSinceEpoch(); + end_ms = nowUnixMs(); + start_ms = end_ms - static_cast(period) * 24LL * 60LL * 60LL * 1000LL; } int request_id = ++fetch_id_; @@ -124,18 +168,23 @@ void RoutesDialog::fetchRoutes() { void RoutesDialog::parseRouteList(const QString &json, bool success, int error_code) { if (success) { - for (const QJsonValue &route : QJsonDocument::fromJson(json.toUtf8()).array()) { - QDateTime from, to; - if (period_selector_->currentData().toInt() == -1) { - from = QDateTime::fromString(route["start_time"].toString(), Qt::ISODateWithMs); - to = QDateTime::fromString(route["end_time"].toString(), Qt::ISODateWithMs); - } else { - from = QDateTime::fromMSecsSinceEpoch(route["start_time_utc_millis"].toDouble()); - to = QDateTime::fromMSecsSinceEpoch(route["end_time_utc_millis"].toDouble()); + 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(route["start_time_utc_millis"].number_value()); + to_ms = static_cast(route["end_time_utc_millis"].number_value()); + } + const int mins = static_cast((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); } - auto item = new QListWidgetItem(QString("%1 %2min").arg(from.toString()).arg(from.secsTo(to) / 60)); - item->setData(Qt::UserRole, route["fullname"].toString()); - route_list_->addItem(item); } if (route_list_->count() > 0) route_list_->setCurrentRow(0); } else { diff --git a/openpilot/tools/cabana/streams/socketcanstream.cc b/openpilot/tools/cabana/streams/socketcanstream.cc index 768465d5a3..cedbddf99e 100644 --- a/openpilot/tools/cabana/streams/socketcanstream.cc +++ b/openpilot/tools/cabana/streams/socketcanstream.cc @@ -7,7 +7,8 @@ #include #include -#include +#include + #include #include #include @@ -20,7 +21,7 @@ SocketCanStream::SocketCanStream(QObject *parent, SocketCanStreamConfig config_) throw std::runtime_error("SocketCAN not available"); } - qDebug() << "Connecting to SocketCAN device" << config.device.c_str(); + fprintf(stderr, "Connecting to SocketCAN device %s\n", config.device.c_str()); if (!connect()) { throw std::runtime_error("Failed to connect to SocketCAN device"); } @@ -44,7 +45,7 @@ bool SocketCanStream::available() { bool SocketCanStream::connect() { sock_fd = socket(PF_CAN, SOCK_RAW, CAN_RAW); if (sock_fd < 0) { - qDebug() << "Failed to create CAN socket"; + fprintf(stderr, "Failed to create CAN socket\n"); return false; } @@ -55,7 +56,7 @@ bool SocketCanStream::connect() { struct ifreq ifr = {}; strncpy(ifr.ifr_name, config.device.c_str(), IFNAMSIZ - 1); if (ioctl(sock_fd, SIOCGIFINDEX, &ifr) < 0) { - qDebug() << "Failed to get interface index for" << config.device.c_str(); + fprintf(stderr, "Failed to get interface index for %s\n", config.device.c_str()); ::close(sock_fd); sock_fd = -1; return false; @@ -65,7 +66,7 @@ bool SocketCanStream::connect() { addr.can_family = AF_CAN; addr.can_ifindex = ifr.ifr_ifindex; if (bind(sock_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { - qDebug() << "Failed to bind CAN socket"; + fprintf(stderr, "Failed to bind CAN socket\n"); ::close(sock_fd); sock_fd = -1; return false; diff --git a/openpilot/tools/cabana/utils/util.cc b/openpilot/tools/cabana/utils/util.cc index 415f6f1784..5c55bcf015 100644 --- a/openpilot/tools/cabana/utils/util.cc +++ b/openpilot/tools/cabana/utils/util.cc @@ -1,7 +1,12 @@ #include "tools/cabana/utils/util.h" #include +#include +#include +#include +#include #include +#include #include #include #include @@ -9,10 +14,8 @@ #include #include -#include #include #include -#include #include #include #include @@ -148,18 +151,35 @@ void TabBar::closeTabClicked() { // UnixSignalHandler -UnixSignalHandler::UnixSignalHandler(QObject *parent) : QObject(nullptr) { +UnixSignalHandler::UnixSignalHandler() { if (::socketpair(AF_UNIX, SOCK_STREAM, 0, sig_fd)) { qFatal("Couldn't create TERM socketpair"); } - sn = new QSocketNotifier(sig_fd[1], QSocketNotifier::Read, this); - connect(sn, &QSocketNotifier::activated, this, &UnixSignalHandler::handleSigTerm); + 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]); } @@ -168,30 +188,118 @@ void UnixSignalHandler::signalHandler(int s) { (void)!::write(sig_fd[0], &s, sizeof(s)); } -void UnixSignalHandler::handleSigTerm() { - sn->setEnabled(false); - int tmp; - (void)!::read(sig_fd[1], &tmp, sizeof(tmp)); - - printf("\nexiting...\n"); - qApp->closeAllWindows(); - qApp->exit(); -} - // NameValidator -NameValidator::NameValidator(QObject *parent) : QRegExpValidator(QRegExp("^(\\w+)"), parent) {} +NameValidator::NameValidator(QObject *parent) : QValidator(parent) {} QValidator::State NameValidator::validate(QString &input, int &pos) const { + Q_UNUSED(pos); input.replace(' ', '_'); - return QRegExpValidator::validate(input, pos); + if (input.isEmpty()) return QValidator::Intermediate; + for (const QChar &c : input) { + if (!c.isLetterOrNumber() && c != '_') return QValidator::Invalid; + } + return QValidator::Acceptable; } -DoubleValidator::DoubleValidator(QObject *parent) : QDoubleValidator(parent) { - // Match locale of QString::toDouble() instead of system - QLocale locale(QLocale::C); - locale.setNumberOptions(QLocale::RejectGroupSeparator); - setLocale(locale); +// 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 QByteArray bytes = input.toLatin1(); + // strtod accepts 0x… hex floats and p-exponents; QString::toDouble does not. + if (bytes.contains('x') || bytes.contains('X') || bytes.contains('p') || bytes.contains('P')) { + return QValidator::Invalid; + } + + const char *start = bytes.constData(); + 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 { @@ -257,10 +365,34 @@ void setTheme(int theme) { } QString formatSeconds(double sec, bool include_milliseconds, bool absolute_time) { - QString format = absolute_time ? "yyyy-MM-dd hh:mm:ss" - : (sec > 60 * 60 ? "hh:mm:ss" : "mm:ss"); - if (include_milliseconds) format += ".zzz"; - return QDateTime::fromMSecsSinceEpoch(sec * 1000).toString(format); + if (absolute_time) { + const auto ms_total = static_cast(std::llround(sec * 1000.0)); + const std::time_t secs = static_cast(ms_total / 1000); + int millis = static_cast(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(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 diff --git a/openpilot/tools/cabana/utils/util.h b/openpilot/tools/cabana/utils/util.h index f4d8342b63..e0318f5c16 100644 --- a/openpilot/tools/cabana/utils/util.h +++ b/openpilot/tools/cabana/utils/util.h @@ -1,23 +1,23 @@ #pragma once #include +#include #include +#include #include #include #include #include #include -#include #include #include #include -#include -#include #include #include #include #include +#include #include "tools/cabana/dbc/dbc.h" #include "tools/cabana/settings.h" @@ -89,17 +89,44 @@ private: int h_margin, v_margin; }; -class NameValidator : public QRegExpValidator { +// 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; }; -class DoubleValidator : public QDoubleValidator { +// 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 { @@ -152,20 +179,18 @@ private: void closeTabClicked(); }; -class UnixSignalHandler : public QObject { - Q_OBJECT - +// 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(QObject *parent = nullptr); + UnixSignalHandler(); ~UnixSignalHandler(); static void signalHandler(int s); -public slots: - void handleSigTerm(); - private: inline static int sig_fd[2] = {}; - QSocketNotifier *sn; + std::atomic shutting_down{false}; + std::thread waiter; }; int num_decimals(double num); diff --git a/openpilot/tools/cabana/videowidget.cc b/openpilot/tools/cabana/videowidget.cc index 5b680ad454..60f7b4d2b8 100644 --- a/openpilot/tools/cabana/videowidget.cc +++ b/openpilot/tools/cabana/videowidget.cc @@ -157,7 +157,7 @@ QWidget *VideoWidget::createCameraWidget() { slider->setTimeRange(can->minSeconds(), can->maxSeconds()); QObject::connect(slider, &QSlider::sliderReleased, [this]() { can->seekTo(slider->currentSecond()); }); - QObject::connect(can, &AbstractStream::paused, cam_widget, [c = cam_widget]() { c->showPausedOverlay(); }); + 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); @@ -324,12 +324,6 @@ void Slider::mousePressEvent(QMouseEvent *e) { // StreamCameraView StreamCameraView::StreamCameraView(std::string stream_name, VisionStreamType stream_type, QWidget *parent) : CameraWidget(stream_name, stream_type, parent) { - fade_animation = new QPropertyAnimation(this, "overlayOpacity"); - fade_animation->setDuration(500); - fade_animation->setStartValue(0.2f); - fade_animation->setEndValue(0.7f); - fade_animation->setEasingCurve(QEasingCurve::InOutQuad); - connect(fade_animation, &QPropertyAnimation::valueChanged, this, QOverload<>::of(&StreamCameraView::update)); } void StreamCameraView::parseQLog(std::shared_ptr qlog) { @@ -376,7 +370,7 @@ void StreamCameraView::paintGL() { } if (can->isPaused()) { - p.setPen(QColor(200, 200, 200, static_cast(255 * fade_animation->currentValue().toFloat()))); + p.setPen(QColor(200, 200, 200, static_cast(255 * 0.7f))); p.setFont(QFont(font().family(), 16, QFont::Bold)); p.drawText(rect(), Qt::AlignCenter, tr("PAUSED")); } diff --git a/openpilot/tools/cabana/videowidget.h b/openpilot/tools/cabana/videowidget.h index e52e92ebd1..d45695904e 100644 --- a/openpilot/tools/cabana/videowidget.h +++ b/openpilot/tools/cabana/videowidget.h @@ -7,7 +7,6 @@ #include #include -#include #include #include #include @@ -37,7 +36,6 @@ class StreamCameraView : public CameraWidget { public: StreamCameraView(std::string stream_name, VisionStreamType stream_type, QWidget *parent = nullptr); void paintGL() override; - void showPausedOverlay() { fade_animation->start(); } void parseQLog(std::shared_ptr qlog); private: @@ -47,7 +45,6 @@ private: void drawScrubThumbnail(QPainter &p); void drawTime(QPainter &p, const QRect &rect, double seconds); - QPropertyAnimation *fade_animation; std::map big_thumbnails; std::map thumbnails; double thumbnail_dispaly_time = -1; From 9aa5c3d2c6fcd3f94925d3dfe4575f4db7178313 Mon Sep 17 00:00:00 2001 From: rkdune Date: Thu, 16 Jul 2026 18:16:08 -0700 Subject: [PATCH 05/35] expose submodule dependencies as an extra --- pyproject.toml | 14 +++++++++----- uv.lock | 34 ++++++++++++++++++---------------- 2 files changed, 27 insertions(+), 21 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9f13917190..51f07d3870 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -98,10 +98,6 @@ tools = [ #"metadrive-simulator @ git+https://github.com/commaai/metadrive.git@minimal ; (platform_machine != 'aarch64')", ] -[project.urls] -Homepage = "https://github.com/commaai/openpilot" - -[dependency-groups] submodules = [ "msgq", "opendbc", @@ -111,6 +107,14 @@ submodules = [ "tinygrad", ] +[project.urls] +Homepage = "https://github.com/commaai/openpilot" + +[dependency-groups] +standalone = [ + "openpilot[submodules]", +] + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" @@ -207,7 +211,7 @@ not-subscriptable = "ignore" # false positives from dynamic types [tool.uv] python-preference = "only-managed" -default-groups = ["submodules"] +default-groups = ["standalone"] override-dependencies = [ "opendbc", # panda pins opendbc from git for standalone use; always use our submodule "av", # teleoprtc's av<13 pin is stale diff --git a/uv.lock b/uv.lock index 2284243b55..bb6a1cb59c 100644 --- a/uv.lock +++ b/uv.lock @@ -922,6 +922,14 @@ docs = [ { name = "jinja2" }, { name = "zensical" }, ] +submodules = [ + { name = "msgq" }, + { name = "opendbc" }, + { name = "pandacan" }, + { name = "rednose" }, + { name = "teleoprtc" }, + { name = "tinygrad" }, +] testing = [ { name = "codespell" }, { name = "coverage" }, @@ -940,13 +948,8 @@ tools = [ ] [package.dev-dependencies] -submodules = [ - { name = "msgq" }, - { name = "opendbc" }, - { name = "pandacan" }, - { name = "rednose" }, - { name = "teleoprtc" }, - { name = "tinygrad" }, +standalone = [ + { name = "openpilot", extra = ["submodules"] }, ] [package.metadata] @@ -977,7 +980,10 @@ requires-dist = [ { name = "jeepney" }, { name = "jinja2", marker = "extra == 'docs'" }, { name = "matplotlib", marker = "extra == 'dev'" }, + { name = "msgq", marker = "extra == 'submodules'", editable = "msgq_repo" }, { name = "numpy", specifier = ">=2.0" }, + { name = "opendbc", marker = "extra == 'submodules'", editable = "opendbc_repo" }, + { name = "pandacan", marker = "extra == 'submodules'", editable = "panda" }, { name = "pillow" }, { name = "pre-commit-hooks", marker = "extra == 'testing'" }, { name = "pycapnp", specifier = "==2.1.0" }, @@ -989,6 +995,7 @@ requires-dist = [ { name = "pytest-xdist", marker = "extra == 'testing'", git = "https://github.com/sshane/pytest-xdist?rev=2b4372bd62699fb412c4fe2f95bf9f01bd2018da" }, { name = "pyzmq" }, { name = "qrcode" }, + { name = "rednose", marker = "extra == 'submodules'", editable = "rednose_repo" }, { name = "requests" }, { name = "ruff", marker = "extra == 'testing'" }, { name = "scons" }, @@ -996,6 +1003,8 @@ requires-dist = [ { name = "setproctitle" }, { name = "setuptools" }, { name = "sounddevice" }, + { name = "teleoprtc", marker = "extra == 'submodules'", editable = "teleoprtc_repo" }, + { name = "tinygrad", marker = "extra == 'submodules'", editable = "tinygrad_repo" }, { name = "tqdm" }, { name = "ty", marker = "extra == 'testing'" }, { name = "websocket-client" }, @@ -1003,17 +1012,10 @@ requires-dist = [ { name = "zensical", marker = "extra == 'docs'" }, { name = "zstandard" }, ] -provides-extras = ["docs", "testing", "dev", "tools"] +provides-extras = ["docs", "testing", "dev", "tools", "submodules"] [package.metadata.requires-dev] -submodules = [ - { name = "msgq", editable = "msgq_repo" }, - { name = "opendbc", editable = "opendbc_repo" }, - { name = "pandacan", editable = "panda" }, - { name = "rednose", editable = "rednose_repo" }, - { name = "teleoprtc", editable = "teleoprtc_repo" }, - { name = "tinygrad", editable = "tinygrad_repo" }, -] +standalone = [{ name = "openpilot", extras = ["submodules"] }] [[package]] name = "packaging" From a04c045cd79b04751ad378bb0a8e1c87c7c7ff21 Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Fri, 17 Jul 2026 09:31:16 -0700 Subject: [PATCH 06/35] cabana: de-Qt, part 3 (#38360) --- openpilot/tools/cabana/.gitignore | 1 + openpilot/tools/cabana/SConscript | 40 +- openpilot/tools/cabana/assets/assets.qrc | 1 - openpilot/tools/cabana/binaryview.cc | 16 +- openpilot/tools/cabana/binaryview.h | 1 + openpilot/tools/cabana/cabana.cc | 3 +- openpilot/tools/cabana/cameraview.cc | 221 +---- openpilot/tools/cabana/cameraview.h | 33 +- openpilot/tools/cabana/chart/chart.cc | 764 +++++++++--------- openpilot/tools/cabana/chart/chart.h | 72 +- openpilot/tools/cabana/chart/chartswidget.cc | 180 +++-- openpilot/tools/cabana/chart/chartswidget.h | 29 +- openpilot/tools/cabana/commands.cc | 103 ++- openpilot/tools/cabana/commands.h | 80 +- openpilot/tools/cabana/deqt.md | 25 +- openpilot/tools/cabana/detailwidget.cc | 6 +- openpilot/tools/cabana/historylog.cc | 2 +- openpilot/tools/cabana/mainwin.cc | 102 ++- openpilot/tools/cabana/mainwin.h | 7 +- openpilot/tools/cabana/messageswidget.cc | 52 +- openpilot/tools/cabana/messageswidget.h | 19 +- openpilot/tools/cabana/settings.cc | 507 +++++++++++- openpilot/tools/cabana/settings.h | 14 +- openpilot/tools/cabana/signalview.cc | 4 +- openpilot/tools/cabana/signalview.h | 7 +- .../tools/cabana/streams/abstractstream.h | 2 +- .../tools/cabana/streams/devicestream.cc | 15 +- openpilot/tools/cabana/streams/livestream.cc | 70 +- openpilot/tools/cabana/streams/livestream.h | 17 +- openpilot/tools/cabana/streams/pandastream.cc | 9 +- .../tools/cabana/streams/replaystream.cc | 4 +- openpilot/tools/cabana/streams/routes.cc | 16 +- openpilot/tools/cabana/streams/routes.h | 4 + .../tools/cabana/streams/socketcanstream.cc | 20 +- openpilot/tools/cabana/streamselector.cc | 4 +- openpilot/tools/cabana/tools/findsignal.cc | 9 +- openpilot/tools/cabana/utils/util.cc | 142 ++-- openpilot/tools/cabana/utils/util.h | 24 +- openpilot/tools/cabana/videowidget.cc | 4 +- openpilot/tools/cabana/videowidget.h | 2 +- tools/setup_dependencies.sh | 2 +- 41 files changed, 1579 insertions(+), 1054 deletions(-) diff --git a/openpilot/tools/cabana/.gitignore b/openpilot/tools/cabana/.gitignore index e4212612df..7f9ac0fde0 100644 --- a/openpilot/tools/cabana/.gitignore +++ b/openpilot/tools/cabana/.gitignore @@ -3,6 +3,7 @@ moc_* *.generated.qrc assets.cc +bootstrap_icons.cc _cabana dbc/car_fingerprint_to_dbc.json diff --git a/openpilot/tools/cabana/SConscript b/openpilot/tools/cabana/SConscript index e9c1a976bb..fcaa3b7930 100644 --- a/openpilot/tools/cabana/SConscript +++ b/openpilot/tools/cabana/SConscript @@ -30,7 +30,7 @@ if arch == "Darwin": ] 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] + ["OpenGL"] + 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() @@ -46,7 +46,7 @@ else: 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] + ["GL"] + qt_libs = [f"Qt5{m}" for m in qt_modules] qt_env['QT3DIR'] = qt_env['QTDIR'] qt_env.Tool('qt3') @@ -67,9 +67,7 @@ base_frameworks = qt_env['FRAMEWORKS'] base_libs = [common, messaging, cereal, visionipc, 'm', 'pthread'] + qt_env["LIBS"] if arch == "Darwin": - base_frameworks += ['QtCharts', 'CoreFoundation', 'CoreVideo', 'CoreMedia', 'IOKit', 'Security', 'VideoToolbox'] -else: - base_libs.append('Qt5Charts') + base_frameworks += ['CoreFoundation', 'CoreVideo', 'CoreMedia', 'IOKit', 'Security', 'VideoToolbox'] cabana_env = qt_env.Clone() cabana_env['CPPPATH'] += [libusb.INCLUDE_DIR] @@ -79,22 +77,26 @@ cabana_libs = [cereal, messaging, visionipc, replay_lib] + ffmpeg_libs + ['bz2', opendbc_path = '-DOPENDBC_FILE_PATH=\'"%s"\'' % (cabana_env.Dir("../../../opendbc_repo/opendbc/dbc").abspath) cabana_env['CXXFLAGS'] += [opendbc_path] -def write_assets_qrc(target, source, env): - with open(str(source[0])) as f: - qrc = f.read() - with open(str(target[0]), "w") as f: - f.write(qrc.replace("@BOOTSTRAP_ICONS_SVG@", str(bootstrap_icons.SVG_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 \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 + +bootstrap_icons_src = cabana_env.Command('assets/bootstrap_icons.cc', str(bootstrap_icons.SVG_PATH), build_bootstrap_icons_src) # build assets assets = "assets/assets.cc" -assets_src = cabana_env.Command( - "assets/assets.generated.qrc", - "assets/assets.qrc", - write_assets_qrc, -) -cabana_env.Command(assets, assets_src, f"rcc $SOURCES -o $TARGET") -cabana_env.Depends(assets_src, str(bootstrap_icons.SVG_PATH)) -cabana_env.Depends(assets, Glob('/assets/*', exclude=[assets, assets_src, "assets/assets.o"])) +cabana_env.Command(assets, "assets/assets.qrc", f"rcc $SOURCES -o $TARGET") +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', @@ -104,7 +106,7 @@ cabana_srcs = ['mainwin.cc', 'streams/pandastream.cc', 'streams/devicestream.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, LIBS=cabana_libs, FRAMEWORKS=base_frameworks) +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'): diff --git a/openpilot/tools/cabana/assets/assets.qrc b/openpilot/tools/cabana/assets/assets.qrc index f5880e5580..009d63f008 100644 --- a/openpilot/tools/cabana/assets/assets.qrc +++ b/openpilot/tools/cabana/assets/assets.qrc @@ -1,6 +1,5 @@ - @BOOTSTRAP_ICONS_SVG@ cabana-icon.png diff --git a/openpilot/tools/cabana/binaryview.cc b/openpilot/tools/cabana/binaryview.cc index f396d1c13f..5e919dc6a3 100644 --- a/openpilot/tools/cabana/binaryview.cc +++ b/openpilot/tools/cabana/binaryview.cc @@ -37,7 +37,7 @@ BinaryView::BinaryView(QWidget *parent) : QTableView(parent) { setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); QObject::connect(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, this, &BinaryView::refresh); - QObject::connect(UndoStack::instance(), &QUndoStack::indexChanged, this, &BinaryView::refresh); + QObject::connect(undoNotifier(), &QtUndoNotifier::indexChanged, this, &BinaryView::refresh); addShortcuts(); setWhatsThis(R"( @@ -66,7 +66,7 @@ void BinaryView::addShortcuts() { QObject::connect(shortcut_delete_backspace, &QShortcut::activated, shortcut_delete_x, &QShortcut::activated); QObject::connect(shortcut_delete_x, &QShortcut::activated, [=]{ if (hovered_sig != nullptr) { - UndoStack::push(new RemoveSigCommand(model->msg_id, hovered_sig)); + UndoStack::instance()->push(new RemoveSigCommand(model->msg_id, hovered_sig)); hovered_sig = nullptr; } }); @@ -126,7 +126,7 @@ void BinaryView::highlight(const cabana::Signal *sig) { } void BinaryView::setSelection(const QRect &rect, QItemSelectionModel::SelectionFlags flags) { - auto index = indexAt(viewport()->mapFromGlobal(QCursor::pos())); + auto index = indexAt(last_mouse_pos); if (!anchor_index.isValid() || !index.isValid()) return; @@ -141,7 +141,7 @@ void BinaryView::setSelection(const QRect &rect, QItemSelectionModel::SelectionF void BinaryView::mousePressEvent(QMouseEvent *event) { resize_sig = nullptr; - if (auto index = indexAt(event->pos()); index.isValid() && index.column() != 8) { + 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); @@ -158,7 +158,7 @@ void BinaryView::mousePressEvent(QMouseEvent *event) { } void BinaryView::highlightPosition(const QPoint &pos) { - if (auto index = indexAt(viewport()->mapFromGlobal(pos)); index.isValid()) { + 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); @@ -166,7 +166,7 @@ void BinaryView::highlightPosition(const QPoint &pos) { } void BinaryView::mouseMoveEvent(QMouseEvent *event) { - highlightPosition(event->globalPos()); + highlightPosition(last_mouse_pos = event->pos()); QTableView::mouseMoveEvent(event); } @@ -179,7 +179,7 @@ void BinaryView::mouseReleaseEvent(QMouseEvent *event) { auto sig = resize_sig ? *resize_sig : cabana::Signal{}; std::tie(sig.start_bit, sig.size, sig.is_little_endian) = getSelection(release_index); resize_sig ? emit editSignal(resize_sig, sig) - : UndoStack::push(new AddSigCommand(model->msg_id, sig)); + : UndoStack::instance()->push(new AddSigCommand(model->msg_id, sig)); } else { auto item = (const BinaryViewModel::Item *)anchor_index.internalPointer(); if (item && item->sigs.size() > 0) @@ -208,7 +208,7 @@ void BinaryView::refresh() { resize_sig = nullptr; hovered_sig = nullptr; model->refresh(); - highlightPosition(QCursor::pos()); + if (underMouse()) highlightPosition(last_mouse_pos); } std::set BinaryView::getOverlappingSignals() const { diff --git a/openpilot/tools/cabana/binaryview.h b/openpilot/tools/cabana/binaryview.h index e568228b37..c49067a1a2 100644 --- a/openpilot/tools/cabana/binaryview.h +++ b/openpilot/tools/cabana/binaryview.h @@ -94,6 +94,7 @@ private: void highlightPosition(const QPoint &pt); QModelIndex anchor_index; + QPoint last_mouse_pos{-1, -1}; BinaryViewModel *model; BinaryItemDelegate *delegate; bool is_message_active = false; diff --git a/openpilot/tools/cabana/cabana.cc b/openpilot/tools/cabana/cabana.cc index 56b43361f8..d8e21815dc 100644 --- a/openpilot/tools/cabana/cabana.cc +++ b/openpilot/tools/cabana/cabana.cc @@ -129,11 +129,10 @@ int parseArgs(int argc, char *argv[], CabanaArgs &args, bool &ok) { int main(int argc, char *argv[]) { QCoreApplication::setApplicationName("Cabana"); - QCoreApplication::setAttribute(Qt::AA_ShareOpenGLContexts); initApp(argc, argv, false); QApplication app(argc, argv); app.setApplicationDisplayName("Cabana"); - app.setWindowIcon(QIcon(":cabana-icon.png")); + //app.setWindowIcon(QIcon(":cabana-icon.png")); // TODO: do this in imgui UnixSignalHandler signalHandler; utils::setTheme(settings.theme); diff --git a/openpilot/tools/cabana/cameraview.cc b/openpilot/tools/cabana/cameraview.cc index 997bd19198..a8df373dc4 100644 --- a/openpilot/tools/cabana/cameraview.cc +++ b/openpilot/tools/cabana/cameraview.cc @@ -1,139 +1,40 @@ #include "tools/cabana/cameraview.h" -#ifdef __APPLE__ -#include -#else -#include -#endif - +#include +#include +#include #include #include +#include -namespace { - -const char frame_vertex_shader[] = -#ifdef __APPLE__ - "#version 330 core\n" -#else - "#version 300 es\n" -#endif - "layout(location = 0) in vec4 aPosition;\n" - "layout(location = 1) in vec2 aTexCoord;\n" - "uniform mat4 uTransform;\n" - "out vec2 vTexCoord;\n" - "void main() {\n" - " gl_Position = uTransform * aPosition;\n" - " vTexCoord = aTexCoord;\n" - "}\n"; - -const char frame_fragment_shader[] = -#ifdef __APPLE__ - "#version 330 core\n" -#else - "#version 300 es\n" - "precision mediump float;\n" -#endif - "uniform sampler2D uTextureY;\n" - "uniform sampler2D uTextureUV;\n" - "in vec2 vTexCoord;\n" - "out vec4 colorOut;\n" - "void main() {\n" - " float y = texture(uTextureY, vTexCoord).r;\n" - " vec2 uv = texture(uTextureUV, vTexCoord).rg - 0.5;\n" - " float r = y + 1.402 * uv.y;\n" - " float g = y - 0.344 * uv.x - 0.714 * uv.y;\n" - " float b = y + 1.772 * uv.x;\n" - " colorOut = vec4(r, g, b, 1.0);\n" - "}\n"; - -} // namespace +#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), QOpenGLWidget(parent) { + stream_name(stream_name), active_stream_type(type), requested_stream_type(type), QWidget(parent) { setAttribute(Qt::WA_OpaquePaintEvent); qRegisterMetaType>("availableStreams"); - QObject::connect(this, &CameraWidget::vipcThreadConnected, this, &CameraWidget::vipcConnected, Qt::BlockingQueuedConnection); 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() { - makeCurrent(); stopVipcThread(); - if (isValid()) { - glDeleteVertexArrays(1, &frame_vao); - glDeleteBuffers(1, &frame_vbo); - glDeleteBuffers(1, &frame_ibo); - glDeleteTextures(2, textures); - shader_program_.reset(); - } - doneCurrent(); -} - -void CameraWidget::initializeGL() { - initializeOpenGLFunctions(); - - shader_program_ = std::make_unique(context()); - shader_program_->addShaderFromSourceCode(QOpenGLShader::Vertex, frame_vertex_shader); - shader_program_->addShaderFromSourceCode(QOpenGLShader::Fragment, frame_fragment_shader); - shader_program_->link(); - - GLint frame_pos_loc = shader_program_->attributeLocation("aPosition"); - GLint frame_texcoord_loc = shader_program_->attributeLocation("aTexCoord"); - - auto [x1, x2, y1, y2] = requested_stream_type == VISION_STREAM_DRIVER ? std::tuple(0.f, 1.f, 1.f, 0.f) : std::tuple(1.f, 0.f, 1.f, 0.f); - const uint8_t frame_indicies[] = {0, 1, 2, 0, 2, 3}; - const float frame_coords[4][4] = { - {-1.0, -1.0, x2, y1}, // bl - {-1.0, 1.0, x2, y2}, // tl - { 1.0, 1.0, x1, y2}, // tr - { 1.0, -1.0, x1, y1}, // br - }; - - glGenVertexArrays(1, &frame_vao); - glBindVertexArray(frame_vao); - glGenBuffers(1, &frame_vbo); - glBindBuffer(GL_ARRAY_BUFFER, frame_vbo); - glBufferData(GL_ARRAY_BUFFER, sizeof(frame_coords), frame_coords, GL_STATIC_DRAW); - glEnableVertexAttribArray(frame_pos_loc); - glVertexAttribPointer(frame_pos_loc, 2, GL_FLOAT, GL_FALSE, - sizeof(frame_coords[0]), (const void *)0); - glEnableVertexAttribArray(frame_texcoord_loc); - glVertexAttribPointer(frame_texcoord_loc, 2, GL_FLOAT, GL_FALSE, - sizeof(frame_coords[0]), (const void *)(sizeof(float) * 2)); - glGenBuffers(1, &frame_ibo); - glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, frame_ibo); - glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(frame_indicies), frame_indicies, GL_STATIC_DRAW); - glBindBuffer(GL_ARRAY_BUFFER, 0); - glBindVertexArray(0); - - glGenTextures(2, textures); - - shader_program_->bind(); - shader_program_->setUniformValue("uTextureY", 0); - shader_program_->setUniformValue("uTextureUV", 1); - shader_program_->release(); } void CameraWidget::showEvent(QShowEvent *event) { - if (!vipc_thread) { + if (!vipc_thread.joinable()) { clearFrames(); - vipc_thread = new QThread(); - connect(vipc_thread, &QThread::started, [=]() { vipcThread(); }); - connect(vipc_thread, &QThread::finished, vipc_thread, &QObject::deleteLater); - vipc_thread->start(); + vipc_exit = false; + vipc_thread = std::thread(&CameraWidget::vipcThread, this); } } void CameraWidget::stopVipcThread() { - makeCurrent(); - if (vipc_thread) { - vipc_thread->requestInterruption(); - vipc_thread->quit(); - vipc_thread->wait(); - vipc_thread = nullptr; + vipc_exit = true; + if (vipc_thread.joinable()) { + vipc_thread.join(); } } @@ -141,74 +42,29 @@ void CameraWidget::availableStreamsUpdated(std::set streams) { available_streams = streams; } -void CameraWidget::paintGL() { - glClearColor(bg.redF(), bg.greenF(), bg.blueF(), bg.alphaF()); - glClear(GL_STENCIL_BUFFER_BIT | GL_COLOR_BUFFER_BIT); +void CameraWidget::paintEvent(QPaintEvent *event) { + QPainter p(this); + p.fillRect(rect(), bg); std::lock_guard lk(frame_lock); - if (!current_frame_) return; + if (rgb_frame.isNull()) return; // Scale for aspect ratio float widget_ratio = (float)width() / height(); - float frame_ratio = (float)stream_width / stream_height; - float scale_x = std::min(frame_ratio / widget_ratio, 1.0f); - float scale_y = std::min(widget_ratio / frame_ratio, 1.0f); + 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); - glViewport(0, 0, width() * devicePixelRatio(), height() * devicePixelRatio()); - - shader_program_->bind(); - QMatrix4x4 transform; - transform.scale(scale_x, scale_y, 1.0f); - shader_program_->setUniformValue("uTransform", transform); - - glPixelStorei(GL_UNPACK_ALIGNMENT, 1); - - glPixelStorei(GL_UNPACK_ROW_LENGTH, stream_stride); - glActiveTexture(GL_TEXTURE0); - glBindTexture(GL_TEXTURE_2D, textures[0]); - glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, stream_width, stream_height, GL_RED, GL_UNSIGNED_BYTE, current_frame_->y); - - glPixelStorei(GL_UNPACK_ROW_LENGTH, stream_stride/2); - glActiveTexture(GL_TEXTURE1); - glBindTexture(GL_TEXTURE_2D, textures[1]); - glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, stream_width/2, stream_height/2, GL_RG, GL_UNSIGNED_BYTE, current_frame_->uv); - - glBindVertexArray(frame_vao); - glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_BYTE, nullptr); - glBindVertexArray(0); - - // Reset both texture units - glActiveTexture(GL_TEXTURE1); - glBindTexture(GL_TEXTURE_2D, 0); - glActiveTexture(GL_TEXTURE0); - glBindTexture(GL_TEXTURE_2D, 0); - glPixelStorei(GL_UNPACK_ALIGNMENT, 4); - glPixelStorei(GL_UNPACK_ROW_LENGTH, 0); - - shader_program_->release(); -} - -void CameraWidget::vipcConnected(VisionIpcClient *vipc_client) { - makeCurrent(); - stream_width = vipc_client->buffers[0].width; - stream_height = vipc_client->buffers[0].height; - stream_stride = vipc_client->buffers[0].stride; - - glBindTexture(GL_TEXTURE_2D, textures[0]); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); - glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); - glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); - glTexImage2D(GL_TEXTURE_2D, 0, GL_R8, stream_width, stream_height, 0, GL_RED, GL_UNSIGNED_BYTE, nullptr); - assert(glGetError() == GL_NO_ERROR); - - glBindTexture(GL_TEXTURE_2D, textures[1]); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); - glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); - glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); - glTexImage2D(GL_TEXTURE_2D, 0, GL_RG8, stream_width/2, stream_height/2, 0, GL_RG, GL_UNSIGNED_BYTE, nullptr); - assert(glGetError() == GL_NO_ERROR); + 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() { @@ -220,7 +76,7 @@ void CameraWidget::vipcThread() { std::unique_ptr vipc_client; VisionIpcBufExtra frame_meta = {}; - while (!QThread::currentThread()->isInterruptionRequested()) { + while (!vipc_exit) { if (!vipc_client || cur_stream != requested_stream_type) { clearFrames(); fprintf(stderr, "connecting to stream %d, was connected to %d\n", @@ -234,23 +90,27 @@ void CameraWidget::vipcThread() { clearFrames(); auto streams = VisionIpcClient::getAvailableStreams(stream_name, false); if (streams.empty()) { - QThread::msleep(100); + std::this_thread::sleep_for(std::chrono::milliseconds(100)); continue; } emit vipcAvailableStreamsUpdated(streams); if (!vipc_client->connect(false)) { - QThread::msleep(100); + std::this_thread::sleep_for(std::chrono::milliseconds(100)); continue; } - emit vipcThreadConnected(vipc_client.get()); } 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); - current_frame_ = buf; - frame_meta_ = frame_meta; + rgb_frame.swap(rgb_back); } emit vipcThreadFrameReceived(); } @@ -259,6 +119,7 @@ void CameraWidget::vipcThread() { void CameraWidget::clearFrames() { std::lock_guard lk(frame_lock); - current_frame_ = nullptr; + rgb_frame = QImage(); + rgb_back = QImage(); available_streams.clear(); } diff --git a/openpilot/tools/cabana/cameraview.h b/openpilot/tools/cabana/cameraview.h index 930b13d82c..40d3776005 100644 --- a/openpilot/tools/cabana/cameraview.h +++ b/openpilot/tools/cabana/cameraview.h @@ -1,23 +1,21 @@ #pragma once -#include +#include #include #include #include +#include #include -#include -#include -#include -#include +#include +#include #include "msgq/visionipc/visionipc_client.h" -class CameraWidget : public QOpenGLWidget, protected QOpenGLFunctions { +class CameraWidget : public QWidget { Q_OBJECT public: - using QOpenGLWidget::QOpenGLWidget; explicit CameraWidget(std::string stream_name, VisionStreamType stream_type, QWidget* parent = nullptr); ~CameraWidget(); void setStreamType(VisionStreamType type) { requested_stream_type = type; } @@ -26,37 +24,30 @@ public: signals: void clicked(); - void vipcThreadConnected(VisionIpcClient *); void vipcThreadFrameReceived(); void vipcAvailableStreamsUpdated(std::set); protected: - void paintGL() override; - void initializeGL() override; + 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(); - GLuint frame_vao, frame_vbo, frame_ibo; - GLuint textures[2]; - std::unique_ptr shader_program_; 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; - int stream_width = 0; - int stream_height = 0; - int stream_stride = 0; std::atomic active_stream_type; std::atomic requested_stream_type; std::set available_streams; - QThread *vipc_thread = nullptr; - std::recursive_mutex frame_lock; - VisionBuf* current_frame_ = nullptr; - VisionIpcBufExtra frame_meta_ = {}; + std::thread vipc_thread; + std::atomic vipc_exit = false; + std::mutex frame_lock; protected slots: - void vipcConnected(VisionIpcClient *vipc_client); void vipcFrameReceived(); void availableStreamsUpdated(std::set streams); }; diff --git a/openpilot/tools/cabana/chart/chart.cc b/openpilot/tools/cabana/chart/chart.cc index 0ce5d051b3..8496c26994 100644 --- a/openpilot/tools/cabana/chart/chart.cc +++ b/openpilot/tools/cabana/chart/chart.cc @@ -6,50 +6,37 @@ #include #include -#include -#include -#include -#include -#include -#include -#include -#include -#include +#include +#include +#include #include "tools/cabana/chart/chartswidget.h" -// ChartAxisElement's padding is 4 (https://codebrowser.dev/qt5/qtcharts/src/charts/axis/chartaxiselement_p.h.html) const int AXIS_X_TOP_MARGIN = 4; -const double MIN_ZOOM_SECONDS = 0.01; // 10ms +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 &x_range, ChartsWidget *parent) - : charts_widget(parent), QChartView(parent) { + : x_min(x_range.first), x_max(x_range.second), charts_widget(parent), QWidget(parent) { series_type = (SeriesType)settings.chart_series_type; - chart()->setBackgroundVisible(false); - axis_x = new QValueAxis(this); - axis_y = new QValueAxis(this); - chart()->addAxis(axis_x, Qt::AlignBottom); - chart()->addAxis(axis_y, Qt::AlignLeft); - chart()->legend()->layout()->setContentsMargins(0, 0, 0, 0); - chart()->legend()->setShowToolTips(true); - chart()->setMargins({0, 0, 0, 0}); - - axis_x->setRange(x_range.first, x_range.second); - + align_to = 50; + setMouseTracking(true); tip_label = new TipLabel(this); createToolButtons(); - setRubberBand(QChartView::HorizontalRubberBand); - setMouseTracking(true); - setTheme(utils::isDarkTheme() ? QChart::QChart::ChartThemeDark : QChart::ChartThemeLight); signal_value_font.setPointSize(9); - QObject::connect(axis_y, &QValueAxis::rangeChanged, this, &ChartView::resetChartCache); - QObject::connect(axis_y, &QAbstractAxis::titleTextChanged, this, &ChartView::resetChartCache); - QObject::connect(window()->windowHandle(), &QWindow::screenChanged, this, &ChartView::resetChartCache); - QObject::connect(dbcNotifier(), &QtDBCNotifier::signalRemoved, this, &ChartView::signalRemoved); QObject::connect(dbcNotifier(), &QtDBCNotifier::signalUpdated, this, &ChartView::signalUpdated); QObject::connect(dbcNotifier(), &QtDBCNotifier::msgRemoved, this, &ChartView::msgRemoved); @@ -57,13 +44,7 @@ ChartView::ChartView(const std::pair &x_range, ChartsWidget *par } void ChartView::createToolButtons() { - move_icon = new QGraphicsPixmapItem(utils::icon("grip-horizontal"), chart()); - move_icon->setToolTip(tr("Drag and drop to move chart")); - - QToolButton *remove_btn = new ToolButton("x", tr("Remove Chart")); - close_btn_proxy = new QGraphicsProxyWidget(chart()); - close_btn_proxy->setWidget(remove_btn); - close_btn_proxy->setZValue(chart()->zValue() + 11); + close_btn = new ToolButton("x", tr("Remove Chart"), this); menu = new QMenu(this); // series types @@ -81,17 +62,14 @@ void ChartView::createToolButtons() { menu->addAction(tr("Manage Signals"), this, &ChartView::manageSignals); split_chart_act = menu->addAction(tr("Split Chart"), [this]() { charts_widget->splitChart(this); }); - QToolButton *manage_btn = new ToolButton("list", ""); + manage_btn = new ToolButton("list", "", this); manage_btn->setMenu(menu); manage_btn->setPopupMode(QToolButton::InstantPopup); manage_btn->setStyleSheet("QToolButton::menu-indicator { image: none; }"); - manage_btn_proxy = new QGraphicsProxyWidget(chart()); - manage_btn_proxy->setWidget(manage_btn); - manage_btn_proxy->setZValue(chart()->zValue() + 11); close_act = new QAction(tr("Close"), this); QObject::connect(close_act, &QAction::triggered, [this] () { charts_widget->removeChart(this); }); - QObject::connect(remove_btn, &QToolButton::clicked, close_act, &QAction::triggered); + QObject::connect(close_btn, &QToolButton::clicked, close_act, &QAction::triggered); QObject::connect(change_series_group, &QActionGroup::triggered, [this](QAction *action) { setSeriesType((SeriesType)action->data().toInt()); }); @@ -101,29 +79,11 @@ QSize ChartView::sizeHint() const { return {CHART_MIN_WIDTH, settings.chart_height}; } -void ChartView::setTheme(QChart::ChartTheme theme) { - chart()->setTheme(theme); - if (theme == QChart::ChartThemeDark) { - axis_x->setTitleBrush(palette().text()); - axis_x->setLabelsBrush(palette().text()); - axis_y->setTitleBrush(palette().text()); - axis_y->setLabelsBrush(palette().text()); - chart()->legend()->setLabelColor(palette().color(QPalette::Text)); - } - axis_x->setLineVisible(false); - axis_y->setLineVisible(false); - for (auto &s : sigs) { - s.series->setColor(toQColor(s.sig->color)); - } -} - void ChartView::addSignal(const MessageId &msg_id, const cabana::Signal *sig) { if (hasSignal(msg_id, sig)) return; - QXYSeries *series = createSeries(series_type, toQColor(sig->color)); - sigs.push_back({.msg_id = msg_id, .sig = sig, .series = series}); + sigs.push_back({.msg_id = msg_id, .sig = sig, .color = uniqueColor(toQColor(sig->color))}); updateSeries(sig); - updateSeriesPoints(); updateTitle(); emit charts_widget->seriesChanged(); } @@ -134,29 +94,21 @@ bool ChartView::hasSignal(const MessageId &msg_id, const cabana::Signal *sig) co void ChartView::removeIf(std::function predicate) { int prev_size = sigs.size(); - for (auto it = sigs.begin(); it != sigs.end(); /**/) { - if (predicate(*it)) { - chart()->removeSeries(it->series); - it->series->deleteLater(); - it = sigs.erase(it); - } else { - ++it; - } - } + 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(); - resetChartCache(); + 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->series->color() != toQColor(sig->color)) { - setSeriesColor(it->series, toQColor(sig->color)); + if (it->color != toQColor(sig->color)) { + it->color = uniqueColor(toQColor(sig->color), sig); } updateTitle(); updateSeries(sig); @@ -186,98 +138,75 @@ void ChartView::manageSignals() { } void ChartView::resizeEvent(QResizeEvent *event) { - qreal left, top, right, bottom; - chart()->layout()->getContentsMargins(&left, &top, &right, &bottom); - move_icon->setPos(left, top); - close_btn_proxy->setPos(rect().right() - right - close_btn_proxy->size().width(), top); - int x = close_btn_proxy->pos().x() - manage_btn_proxy->size().width() - style()->pixelMetric(QStyle::PM_LayoutHorizontalSpacing); - manage_btn_proxy->setPos(x, top); - if (align_to > 0) { - updatePlotArea(align_to, true); - } - QChartView::resizeEvent(event); + 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; - qreal left, top, right, bottom; - chart()->layout()->getContentsMargins(&left, &top, &right, &bottom); - QSizeF legend_size = chart()->legend()->layout()->minimumSize(); - legend_size.setWidth(manage_btn_proxy->sceneBoundingRect().left() - move_icon->sceneBoundingRect().right()); - chart()->legend()->setGeometry({move_icon->sceneBoundingRect().topRight(), legend_size}); + 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); - // add top space for signal value - int adjust_top = chart()->legend()->geometry().height() + QFontMetrics(signal_value_font).height() + 3; - adjust_top = std::max(adjust_top, manage_btn_proxy->sceneBoundingRect().height() + style()->pixelMetric(QStyle::PM_LayoutTopMargin)); + // 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 = QFontMetrics(axis_x->labelsFont()).size(Qt::TextSingleLine, QString::number(axis_x->max(), 'f', 2)); - x_label_size += QSizeF{5, 5}; - chart()->setPlotArea(rect().adjusted(align_to + left, adjust_top + top, -x_label_size.width() / 2 - right, -x_label_size.height() - bottom)); - chart()->layout()->invalidate(); + 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() { - for (QLegendMarker *marker : chart()->legend()->markers()) { - QObject::connect(marker, &QLegendMarker::clicked, this, &ChartView::handleMarkerClicked, Qt::UniqueConnection); - } - - // Use CSS to draw titles in the WindowText color - auto tmp = palette().color(QPalette::WindowText); - auto titleColorCss = tmp.name(QColor::HexArgb); - // Draw message details in similar color, but slightly fade it to the background - tmp.setAlpha(180); - auto msgColorCss = tmp.name(QColor::HexArgb); - - for (auto &s : sigs) { - auto decoration = s.series->isVisible() ? "none" : "line-through"; - s.series->setName(QString("%3 %5 %6") - .arg(decoration, titleColorCss, QString::fromStdString(s.sig->name), - msgColorCss, QString::fromStdString(msgName(s.msg_id)), QString::fromStdString(s.msg_id.toString()))); - } split_chart_act->setEnabled(sigs.size() > 1); - resetChartCache(); + updatePlotArea(align_to, true); } void ChartView::updatePlot(double cur, double min, double max) { cur_sec = cur; - if (min != axis_x->min() || max != axis_x->max()) { - axis_x->setRange(min, max); + if (min != x_min || max != x_max) { + x_min = min; + x_max = max; updateAxisY(); - updateSeriesPoints(); // update tooltip if (tooltip_x >= 0) { - showTip(chart()->mapToValue({tooltip_x, 0}).x()); + showTip(secondsAtPoint({tooltip_x, 0})); } resetChartCache(); } - viewport()->update(); -} - -void ChartView::updateSeriesPoints() { - // Show points when zoomed in enough - for (auto &s : sigs) { - auto begin = std::lower_bound(s.vals.cbegin(), s.vals.cend(), axis_x->min(), xLessThan); - auto end = std::lower_bound(begin, s.vals.cend(), axis_x->max(), xLessThan); - if (begin != end) { - int num_points = std::max((end - begin), 1); - QPointF right_pt = end == s.vals.cend() ? s.vals.back() : *end; - double pixels_per_point = (chart()->mapToPosition(right_pt).x() - chart()->mapToPosition(*begin).x()) / num_points; - - if (series_type == SeriesType::Scatter) { - qreal size = std::clamp(pixels_per_point / 2.0, 2.0, 8.0); - if (s.series->useOpenGL()) { - size *= devicePixelRatioF(); - } - ((QScatterSeries *)s.series)->setMarkerSize(size); - } else { - s.series->setPointsVisible(num_points == 1 || pixels_per_point > 20); - } - } - } + update(); } void ChartView::appendCanEvents(const cabana::Signal *sig, const std::vector &events, @@ -322,8 +251,6 @@ void ChartView::updateSeries(const cabana::Signal *sig, const MessageEventsMap * if (!can->liveStreaming()) { s.segment_tree.build(s.vals); } - const auto &points = series_type == SeriesType::StepLine ? s.step_vals : s.vals; - s.series->replace(QVector(points.cbegin(), points.cend())); } } updateAxisY(); @@ -340,15 +267,15 @@ void ChartView::updateAxisY() { QString unit = QString::fromStdString(sigs[0].sig->unit); for (auto &s : sigs) { - if (!s.series->isVisible()) continue; + 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(), axis_x->min(), xLessThan); - auto last = std::lower_bound(first, s.vals.cend(), axis_x->max(), xLessThan); + 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::max(); s.max = std::numeric_limits::lowest(); if (can->liveStreaming()) { @@ -365,28 +292,28 @@ void ChartView::updateAxisY() { if (min == std::numeric_limits::max()) min = 0; if (max == std::numeric_limits::lowest()) max = 0; - if (axis_y->titleText() != unit) { - axis_y->setTitleText(unit); + 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 != axis_y->min() || max_y != axis_y->max() || y_label_width == 0) { - axis_y->setRange(min_y, max_y); - axis_y->setTickCount(tick_count); + 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); - int n = std::max(int(-std::floor(std::log10((max_y - min_y) / (tick_count - 1)))), 0); + QFontMetrics fm(font()); int max_label_width = 0; - QFontMetrics fm(axis_y->labelsFont()); for (int i = 0; i < tick_count; i++) { qreal value = min_y + (i * (max_y - min_y) / (tick_count - 1)); - max_label_width = std::max(max_label_width, fm.horizontalAdvance(QString::number(value, 'f', n))); + max_label_width = std::max(max_label_width, fm.horizontalAdvance(QString::number(value, 'f', y_precision))); } - int title_spacing = unit.isEmpty() ? 0 : QFontMetrics(axis_y->titleFont()).size(Qt::TextSingleLine, unit).height(); + int title_spacing = y_unit.isEmpty() ? 0 : fm.size(Qt::TextSingleLine, y_unit).height(); y_label_width = title_spacing + max_label_width + 15; - axis_y->setLabelFormat(QString("%.%1f").arg(n)); emit axisYLabelWidthChanged(y_label_width); } } @@ -400,6 +327,10 @@ std::tuple ChartView::getNiceAxisNumbers(qreal min, qreal m 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 @@ -430,344 +361,394 @@ void ChartView::contextMenuEvent(QContextMenuEvent *event) { } void ChartView::mousePressEvent(QMouseEvent *event) { - if (event->button() == Qt::LeftButton && move_icon->sceneBoundingRect().contains(event->pos())) { - QMimeData *mimeData = new QMimeData; - mimeData->setData(CHART_MIME_TYPE, QByteArray::number((qulonglong)this)); - QPixmap px = grab().scaledToWidth(CHART_MIN_WIDTH * viewport()->devicePixelRatio(), Qt::SmoothTransformation); - charts_widget->stopAutoScroll(); - QDrag *drag = new QDrag(this); - drag->setMimeData(mimeData); - drag->setPixmap(px); - drag->setHotSpot(-QPoint(5, 5)); - drag->exec(Qt::CopyAction | Qt::MoveAction, Qt::MoveAction); - } else if (event->button() == Qt::LeftButton && QApplication::keyboardModifiers().testFlag(Qt::ShiftModifier)) { + 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); } - is_scrubbing = true; + mouse_mode = MouseMode::Scrub; + } else if (event->button() == Qt::LeftButton && plot_area.contains(event->pos())) { + mouse_mode = MouseMode::Rubber; + rubber_rect = QRect(); } else { - QChartView::mousePressEvent(event); - } -} - -void ChartView::mouseReleaseEvent(QMouseEvent *event) { - auto rubber = findChild(); - if (event->button() == Qt::LeftButton && rubber && rubber->isVisible()) { - rubber->hide(); - auto rect = rubber->geometry().normalized(); - // Prevent zooming/seeking past the end of the route - double min = std::clamp(chart()->mapToValue(rect.topLeft()).x(), can->minSeconds(), can->maxSeconds()); - double max = std::clamp(chart()->mapToValue(rect.bottomRight()).x(), can->minSeconds(), can->maxSeconds()); - if (rubber->width() <= 0) { - // no rubber dragged, seek to mouse position - can->seekTo(min); - } else if (rubber->width() > 10 && (max - min) > MIN_ZOOM_SECONDS) { - charts_widget->zoom_undo_stack->push(new ZoomCommand({min, max})); - } else { - viewport()->update(); - } - event->accept(); - } else if (event->button() == Qt::RightButton) { - charts_widget->zoom_undo_stack->undo(); - event->accept(); - } else { - QGraphicsView::mouseReleaseEvent(event); - } - - // Resume playback if we were scrubbing - is_scrubbing = false; - if (resume_after_scrub) { - can->pause(false); - resume_after_scrub = false; + QWidget::mousePressEvent(event); } } void ChartView::mouseMoveEvent(QMouseEvent *ev) { - const auto plot_area = chart()->plotArea(); // Scrubbing - if (is_scrubbing && QApplication::keyboardModifiers().testFlag(Qt::ShiftModifier)) { + if (mouse_mode == MouseMode::Scrub && ev->modifiers().testFlag(Qt::ShiftModifier)) { if (plot_area.contains(ev->pos())) { - can->seekTo(std::clamp(chart()->mapToValue(ev->pos()).x(), can->minSeconds(), can->maxSeconds())); + can->seekTo(std::clamp(secondsAtPoint(ev->pos()), can->minSeconds(), can->maxSeconds())); } } - auto rubber = findChild(); - bool is_zooming = rubber && rubber->isVisible(); - clearTrackPoints(); + 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(); + } - if (!is_zooming && plot_area.contains(ev->pos()) && isActiveWindow()) { + 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); +} - QChartView::mouseMoveEvent(ev); - if (is_zooming) { - QRect rubber_rect = rubber->geometry(); - rubber_rect.setLeft(std::max(rubber_rect.left(), (int)plot_area.left())); - rubber_rect.setRight(std::min(rubber_rect.right(), (int)plot_area.right())); - if (rubber_rect != rubber->geometry()) { - rubber->setGeometry(rubber_rect); +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; } - viewport()->update(); } } +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, chart()->plotArea().top(), rect().width(), chart()->plotArea().height()); + 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 = chart()->mapToPosition({sec, 0}).x(); + tooltip_x = xPos(sec); qreal x = -1; QStringList text_list; for (auto &s : sigs) { - if (s.series->isVisible()) { + 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() >= axis_x->min()) { + 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, chart()->mapToPosition(*it).x()); + x = std::max(x, xPos(it->x())); } QString name = sigs.size() > 1 ? QString::fromStdString(s.sig->name) + ": " : ""; QString min = s.min == std::numeric_limits::max() ? "--" : QString::number(s.min); QString max = s.max == std::numeric_limits::lowest() ? "--" : QString::number(s.max); text_list << QString("%2%3 (%4, %5)") - .arg(s.series->color().name(), name, value, min, max); + .arg(s.color.name(), name, value, min, max); } } if (x < 0) { x = tooltip_x; } - QPoint pt(x, chart()->plotArea().top()); - text_list.push_front(QString::number(chart()->mapToValue({x, 0}).x(), 'f', 3)); + QPoint pt(x, plot_area.top()); + text_list.push_front(QString::number(secondsAtPoint({x, 0}), 'f', 3)); QString text = "

" % text_list.join("
") % "

"; tip_label->showText(pt, text, this, visible_rect); - viewport()->update(); + update(); } void ChartView::hideTip() { clearTrackPoints(); tooltip_x = -1; tip_label->hide(); - viewport()->update(); -} - -void ChartView::dragEnterEvent(QDragEnterEvent *event) { - if (event->mimeData()->hasFormat(CHART_MIME_TYPE)) { - drawDropIndicator(event->source() != this); - event->acceptProposedAction(); - } -} - -void ChartView::dragMoveEvent(QDragMoveEvent *event) { - if (event->mimeData()->hasFormat(CHART_MIME_TYPE)) { - event->setDropAction(event->source() == this ? Qt::MoveAction : Qt::CopyAction); - event->accept(); - } - charts_widget->startAutoScroll(); -} - -void ChartView::dropEvent(QDropEvent *event) { - if (event->mimeData()->hasFormat(CHART_MIME_TYPE)) { - if (event->source() != this) { - ChartView *source_chart = (ChartView *)event->source(); - for (auto &s : source_chart->sigs) { - source_chart->chart()->removeSeries(s.series); - addSeries(s.series); - } - sigs.insert(sigs.end(), std::move_iterator(source_chart->sigs.begin()), std::move_iterator(source_chart->sigs.end())); - updateAxisY(); - updateTitle(); - - source_chart->sigs.clear(); - charts_widget->removeChart(source_chart); - event->acceptProposedAction(); - } - can_drop = false; - } + update(); } void ChartView::resetChartCache() { chart_pixmap = QPixmap(); - viewport()->update(); + update(); } void ChartView::paintEvent(QPaintEvent *event) { - if (!can->liveStreaming()) { - if (chart_pixmap.isNull()) { - const qreal dpr = viewport()->devicePixelRatioF(); - chart_pixmap = QPixmap(viewport()->size() * dpr); - chart_pixmap.setDevicePixelRatio(dpr); - QPainter p(&chart_pixmap); - p.setRenderHints(QPainter::Antialiasing); - drawBackground(&p, viewport()->rect()); - scene()->setSceneRect(viewport()->rect()); - scene()->render(&p, viewport()->rect()); - } + QPainter painter(this); + painter.setRenderHints(QPainter::Antialiasing); - QPainter painter(viewport()); - painter.setRenderHints(QPainter::Antialiasing); - painter.drawPixmap(QPoint(), chart_pixmap); - if (can_drop) { - painter.setPen(QPen(palette().color(QPalette::Highlight), 4)); - painter.drawRect(viewport()->rect()); - } - QRectF exposed_rect = mapToScene(event->region().boundingRect()).boundingRect(); - drawForeground(&painter, exposed_rect); - } else { - QChartView::paintEvent(event); + // 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::drawBackground(QPainter *painter, const QRectF &rect) { - painter->fillRect(rect, palette().color(QPalette::Base)); +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::drawForeground(QPainter *painter, const QRectF &rect) { +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(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 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.series->isVisible()) { - painter->setBrush(s.series->color().darker(125)); - QPointF pos = chart()->mapToPosition(s.track_pt); + 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) { - auto plot_area = chart()->plotArea(); painter->setPen(QPen(Qt::darkGray, 1, Qt::DashLine)); - painter->drawLine(QPointF{track_line_x, plot_area.top()}, QPointF{track_line_x, plot_area.bottom()}); - } - - // paint points. OpenGL mode lacks certain features (such as showing points) - painter->setPen(Qt::NoPen); - for (auto &s : sigs) { - if (s.series->useOpenGL() && s.series->isVisible() && s.series->pointsVisible()) { - auto first = std::lower_bound(s.vals.cbegin(), s.vals.cend(), axis_x->min(), xLessThan); - auto last = std::lower_bound(first, s.vals.cend(), axis_x->max(), xLessThan); - painter->setBrush(s.series->color()); - for (auto it = first; it != last; ++it) { - painter->drawEllipse(chart()->mapToPosition(*it), 4, 4); - } - } + 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) { - auto rubber = findChild(); - if (rubber && rubber->isVisible() && rubber->width() > 1) { - painter->setPen(Qt::white); - auto rubber_rect = rubber->geometry().normalized(); - for (const auto &pt : {rubber_rect.bottomLeft(), rubber_rect.bottomRight()}) { - QString sec = QString::number(chart()->mapToValue(pt).x(), 'f', 2); - auto r = painter->fontMetrics().boundingRect(sec).adjusted(-6, -AXIS_X_TOP_MARGIN, 6, AXIS_X_TOP_MARGIN); - pt == rubber_rect.bottomLeft() ? r.moveTopRight(pt + QPoint{0, 2}) : r.moveTopLeft(pt + QPoint{0, 2}); - painter->fillRect(r, Qt::gray); - painter->drawText(r, Qt::AlignCenter, sec); - } + 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) { - const auto plot_area = chart()->plotArea(); // draw vertical time line - qreal x = std::clamp(chart()->mapToPosition(QPointF{cur_sec, 0}).x(), plot_area.left(), plot_area.right()); - painter->setPen(QPen(chart()->titleBrush().color(), 1)); - painter->drawLine(QPointF{x, plot_area.top() - 1}, QPointF{x, plot_area.bottom() + 1}); + 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(axis_x->labelsFont()).size(Qt::TextSingleLine, time_str) + QSize(8, 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(axis_x->labelsFont()); + painter->setFont(font()); painter->drawText(time_str_rect, Qt::AlignCenter, time_str); } void ChartView::drawSignalValue(QPainter *painter) { - auto item_group = qgraphicsitem_cast(chart()->legend()->childItems()[0]); - assert(item_group != nullptr); - auto legend_markers = item_group->childItems(); - assert(legend_markers.size() == sigs.size()); - painter->setFont(signal_value_font); - painter->setPen(chart()->legend()->labelColor()); - int i = 0; - for (auto &s : sigs) { + 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() >= axis_x->min()) ? QString::fromStdString(s.sig->formatValue(it->y())) : "--"; - QRectF marker_rect = legend_markers[i++]->sceneBoundingRect(); - QRectF value_rect(marker_rect.bottomLeft() - QPoint(0, 1), marker_rect.size()); + QString 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); } } -QXYSeries *ChartView::createSeries(SeriesType type, QColor color) { - QXYSeries *series = nullptr; - if (type == SeriesType::Line) { - series = new QLineSeries(this); - chart()->legend()->setMarkerShape(QLegend::MarkerShapeRectangle); - } else if (type == SeriesType::StepLine) { - series = new QLineSeries(this); - chart()->legend()->setMarkerShape(QLegend::MarkerShapeFromSeries); - } else { - series = new QScatterSeries(this); - static_cast(series)->setBorderColor(color); - chart()->legend()->setMarkerShape(QLegend::MarkerShapeCircle); - } - series->setColor(color); - // TODO: Due to a bug in CameraWidget the camera frames - // are drawn instead of the graphs on MacOS. Re-enable OpenGL when fixed -#ifndef __APPLE__ - series->setUseOpenGL(true); - // Qt doesn't properly apply device pixel ratio in OpenGL mode - QPen pen = series->pen(); - pen.setWidthF(2.0 * devicePixelRatioF()); - series->setPen(pen); -#endif - addSeries(series); - return series; -} - -void ChartView::addSeries(QXYSeries *series) { - setSeriesColor(series, series->color()); - chart()->addSeries(series); - series->attachAxis(axis_x); - series->attachAxis(axis_y); - - // disables the delivery of mouse events to the opengl widget. - // this enables the user to select the zoom area when the mouse press on the data point. - auto glwidget = findChild(); - if (glwidget && !glwidget->testAttribute(Qt::WA_TransparentForMouseEvents)) { - glwidget->setAttribute(Qt::WA_TransparentForMouseEvents); - } -} - -void ChartView::setSeriesColor(QXYSeries *series, QColor color) { - auto existing_series = chart()->series(); - for (auto s : existing_series) { - if (s != series && std::abs(color.hueF() - qobject_cast(s)->color().hueF()) < 0.1) { +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 = qobject_cast(existing_series.back())->color(); + auto last_color = sigs.back().color; static thread_local std::mt19937 rng{std::random_device{}()}; std::uniform_int_distribution sat(35, 99); std::uniform_int_distribution val(85, 99); @@ -777,36 +758,13 @@ void ChartView::setSeriesColor(QXYSeries *series, QColor color) { break; } } - series->setColor(color); + return color; } void ChartView::setSeriesType(SeriesType type) { if (type != series_type) { series_type = type; - for (auto &s : sigs) { - chart()->removeSeries(s.series); - s.series->deleteLater(); - } - for (auto &s : sigs) { - s.series = createSeries(series_type, toQColor(s.sig->color)); - const auto &points = series_type == SeriesType::StepLine ? s.step_vals : s.vals; - s.series->replace(QVector(points.cbegin(), points.cend())); - } - updateSeriesPoints(); - updateTitle(); - menu->actions()[(int)type]->setChecked(true); - } -} - -void ChartView::handleMarkerClicked() { - auto marker = qobject_cast(sender()); - Q_ASSERT(marker); - if (sigs.size() > 1) { - auto series = marker->series(); - series->setVisible(!series->isVisible()); - marker->setVisible(true); - updateAxisY(); updateTitle(); } } diff --git a/openpilot/tools/cabana/chart/chart.h b/openpilot/tools/cabana/chart/chart.h index ae050269bf..b03623475a 100644 --- a/openpilot/tools/cabana/chart/chart.h +++ b/openpilot/tools/cabana/chart/chart.h @@ -1,18 +1,11 @@ #pragma once +#include #include #include #include #include -#include -#include -#include -#include -#include -#include -#include -using namespace QtCharts; #include "tools/cabana/chart/tiplabel.h" #include "tools/cabana/dbc/dbcmanager.h" @@ -25,7 +18,7 @@ enum class SeriesType { }; class ChartsWidget; -class ChartView : public QChartView { +class ChartView : public QWidget { Q_OBJECT public: @@ -38,12 +31,15 @@ public: void updatePlotArea(int left, bool force = false); void showTip(double sec); void hideTip(); - double secondsAtPoint(const QPointF &pt) const { return chart()->mapToValue(pt).x(); } + 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; - QXYSeries *series = nullptr; + QColor color; + bool visible = true; std::vector vals; std::vector step_vals; QPointF track_pt{}; @@ -58,7 +54,6 @@ signals: private slots: void signalUpdated(const cabana::Signal *sig); void manageSignals(); - void handleMarkerClicked(); void msgUpdated(MessageId id); void msgRemoved(MessageId id) { removeIf([=](auto &s) { return s.msg_id.address == id.address && !dbc()->msg(id); }); } void signalRemoved(const cabana::Signal *sig) { removeIf([=](auto &s) { return s.sig == sig; }); } @@ -67,52 +62,65 @@ private: void appendCanEvents(const cabana::Signal *sig, const std::vector &events, std::vector &vals, std::vector &step_vals); void createToolButtons(); - void addSeries(QXYSeries *series); void contextMenuEvent(QContextMenuEvent *event) override; void mousePressEvent(QMouseEvent *event) override; + void mouseMoveEvent(QMouseEvent *event) override; void mouseReleaseEvent(QMouseEvent *event) override; - void mouseMoveEvent(QMouseEvent *ev) override; - void dragEnterEvent(QDragEnterEvent *event) override; - void dragLeaveEvent(QDragLeaveEvent *event) override { drawDropIndicator(false); } - void dragMoveEvent(QDragMoveEvent *event) override; - void dropEvent(QDropEvent *event) override; void resizeEvent(QResizeEvent *event) override; QSize sizeHint() const override; void updateAxisY(); void updateTitle(); void resetChartCache(); - void setTheme(QChart::ChartTheme theme); void paintEvent(QPaintEvent *event) override; - void drawForeground(QPainter *painter, const QRectF &rect) override; - void drawBackground(QPainter *painter, const QRectF &rect) override; - void drawDropIndicator(bool draw) { if (std::exchange(can_drop, draw) != can_drop) viewport()->update(); } + void 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 getNiceAxisNumbers(qreal min, qreal max, int tick_count); qreal niceNumber(qreal x, bool ceiling); - QXYSeries *createSeries(SeriesType type, QColor color); - void setSeriesColor(QXYSeries *, QColor color); - void updateSeriesPoints(); + QColor uniqueColor(QColor color, const cabana::Signal *exclude = nullptr) const; void removeIf(std::function 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 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; - QValueAxis *axis_x; - QValueAxis *axis_y; + // 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; - QGraphicsPixmapItem *move_icon; - QGraphicsProxyWidget *close_btn_proxy; - QGraphicsProxyWidget *manage_btn_proxy; + ToolButton *manage_btn; + ToolButton *close_btn; TipLabel *tip_label; std::vector sigs; double cur_sec = 0; SeriesType series_type = SeriesType::Line; - bool is_scrubbing = false; - bool resume_after_scrub = false; QPixmap chart_pixmap; bool can_drop = false; double tooltip_x = -1; diff --git a/openpilot/tools/cabana/chart/chartswidget.cc b/openpilot/tools/cabana/chart/chartswidget.cc index 93d8570996..3144c5132d 100644 --- a/openpilot/tools/cabana/chart/chartswidget.cc +++ b/openpilot/tools/cabana/chart/chartswidget.cc @@ -6,7 +6,7 @@ #include #include -#include +#include #include #include @@ -72,11 +72,14 @@ ChartsWidget::ChartsWidget(QWidget *parent) : QFrame(parent) { range_slider_action = toolbar->addWidget(range_slider); // zoom controls - zoom_undo_stack = new QUndoStack(this); - toolbar->addAction(undo_zoom_action = zoom_undo_stack->createUndoAction(this)); - undo_zoom_action->setIcon(utils::icon("arrow-counterclockwise")); - toolbar->addAction(redo_zoom_action = zoom_undo_stack->createRedoAction(this)); - redo_zoom_action->setIcon(utils::icon("arrow-clockwise")); + 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); @@ -89,8 +92,6 @@ ChartsWidget::ChartsWidget(QWidget *parent) : QFrame(parent) { tabbar->setAutoHide(true); tabbar->setExpanding(false); tabbar->setDrawBase(true); - tabbar->setAcceptDrops(true); - tabbar->setChangeCurrentOnDrag(true); tabbar->setUsesScrollButtons(true); main_layout->addWidget(tabbar); @@ -105,6 +106,11 @@ ChartsWidget::ChartsWidget(QWidget *parent) : QFrame(parent) { 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); @@ -186,7 +192,7 @@ void ChartsWidget::timeRangeChanged(const std::optionalsetTimeRange(std::nullopt); - zoom_undo_stack->clear(); + zoom_undo_stack.clear(); } QRect ChartsWidget::chartVisibleRect(ChartView *chart) { @@ -256,10 +262,6 @@ void ChartsWidget::settingChanged() { if (std::exchange(current_theme, settings.theme) != current_theme) { undo_zoom_action->setIcon(utils::icon("arrow-counterclockwise")); redo_zoom_action->setIcon(utils::icon("arrow-clockwise")); - auto theme = utils::isDarkTheme() ? QChart::QChart::ChartThemeDark : QChart::ChartThemeLight; - for (auto c : charts) { - c->setTheme(theme); - } } if (range_slider->maximum() != settings.max_cached_minutes * 60) { range_slider->setRange(1, settings.max_cached_minutes * 60); @@ -307,12 +309,8 @@ void ChartsWidget::splitChart(ChartView *src_chart) { 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); - src_chart->chart()->removeSeries(it->series); - // Restore to the original color - it->series->setColor(toQColor(it->sig->color)); - - c->addSeries(it->series); + it->color = toQColor(it->sig->color); c->sigs.emplace_back(std::move(*it)); c->updateAxisY(); c->updateTitle(); @@ -320,6 +318,7 @@ void ChartsWidget::splitChart(ChartView *src_chart) { } src_chart->updateAxisY(); src_chart->updateTitle(); + updateState(); QTimer::singleShot(0, src_chart, &ChartView::resetChartCache); } } @@ -390,7 +389,87 @@ void ChartsWidget::updateLayout(bool force) { } } -void ChartsWidget::startAutoScroll() { +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); } @@ -406,7 +485,7 @@ void ChartsWidget::doAutoScroll() { } int value = scroll->value(); - QPoint pos = charts_scroll->viewport()->mapFromGlobal(QCursor::pos()); + QPoint pos = charts_scroll->viewport()->mapFromGlobal(auto_scroll_pos); QRect area = charts_scroll->viewport()->rect(); if (pos.y() - area.top() < settings.chart_height / 2) { @@ -414,16 +493,11 @@ void ChartsWidget::doAutoScroll() { } else if (area.bottom() - pos.y() < settings.chart_height / 2) { scroll->setValue(value + auto_scroll_count); } - bool vertical_unchanged = value == scroll->value(); - if (vertical_unchanged) { + if (value == scroll->value()) { stopAutoScroll(); - } else { - // mouseMoveEvent to updates the drag-selection rectangle - const QPoint globalPos = charts_scroll->viewport()->mapToGlobal(pos); - const QPoint windowPos = charts_scroll->window()->mapFromGlobal(globalPos); - QMouseEvent mm(QEvent::MouseMove, pos, windowPos, globalPos, - Qt::NoButton, Qt::LeftButton, Qt::NoModifier, Qt::MouseEventSynthesizedByQt); - QApplication::sendEvent(charts_scroll->viewport(), &mm); + } else if (chartDragActive()) { + // refresh the drop indicator/target at the new scroll position + dragChartMove(auto_scroll_pos); } } @@ -440,11 +514,14 @@ void ChartsWidget::newChart() { 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) { @@ -484,6 +561,19 @@ void ChartsWidget::alignCharts() { } 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(e)->globalPos()); + return true; + } else if (e->type() == QEvent::MouseButtonRelease && static_cast(e)->button() == Qt::LeftButton) { + dragChartRelease(static_cast(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) { @@ -492,7 +582,7 @@ bool ChartsWidget::eventFilter(QObject *o, QEvent *e) { for (const auto &c : charts) { auto local_pos = c->mapFromGlobal(global_pos); - if (c->chart()->plotArea().contains(local_pos)) { + if (c->plot_area.contains(local_pos)) { if (on_tip) { showValueTip(c->secondsAtPoint(local_pos)); } @@ -524,13 +614,14 @@ bool ChartsWidget::event(QEvent *event) { break; case QEvent::WindowDeactivate: case QEvent::FocusOut: + if (chartDragActive()) cancelChartDrag(); showValueTip(-1); default: break; } if (back_button) { - zoom_undo_stack->undo(); + zoom_undo_stack.undo(); return true; // Return true since the event has been handled } return QFrame::event(event); @@ -539,7 +630,6 @@ bool ChartsWidget::event(QEvent *event) { // ChartsContainer ChartsContainer::ChartsContainer(ChartsWidget *parent) : charts_widget(parent), QWidget(parent) { - setAcceptDrops(true); setBackgroundRole(QPalette::Window); QVBoxLayout *charts_main_layout = new QVBoxLayout(this); charts_main_layout->setContentsMargins(0, CHART_SPACING, 0, CHART_SPACING); @@ -549,32 +639,6 @@ ChartsContainer::ChartsContainer(ChartsWidget *parent) : charts_widget(parent), charts_main_layout->addStretch(0); } -void ChartsContainer::dragEnterEvent(QDragEnterEvent *event) { - if (event->mimeData()->hasFormat(CHART_MIME_TYPE)) { - event->acceptProposedAction(); - drawDropIndicator(event->pos()); - } -} - -void ChartsContainer::dropEvent(QDropEvent *event) { - if (event->mimeData()->hasFormat(CHART_MIME_TYPE)) { - auto w = getDropAfter(event->pos()); - auto chart = qobject_cast(event->source()); - if (w != chart) { - for (auto &[_, list] : charts_widget->tab_charts) { - list.erase(std::remove(list.begin(), list.end(), chart), list.end()); - } - auto &cur = charts_widget->currentCharts(); - int to = w ? std::find(cur.begin(), cur.end(), w) - cur.begin() + 1 : 0; - cur.insert(cur.begin() + to, chart); - charts_widget->updateLayout(true); - charts_widget->updateTabBar(); - event->acceptProposedAction(); - } - drawDropIndicator({}); - } -} - void ChartsContainer::paintEvent(QPaintEvent *ev) { if (!drop_indictor_pos.isNull() && !childAt(drop_indictor_pos)) { QRect r = geometry(); diff --git a/openpilot/tools/cabana/chart/chartswidget.h b/openpilot/tools/cabana/chart/chartswidget.h index ef3fbc471a..8b3003dcd6 100644 --- a/openpilot/tools/cabana/chart/chartswidget.h +++ b/openpilot/tools/cabana/chart/chartswidget.h @@ -8,15 +8,13 @@ #include #include #include -#include -#include #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; -const QString CHART_MIME_TYPE = "application/x-cabanachartview"; class ChartView; class ChartsWidget; @@ -24,9 +22,6 @@ class ChartsWidget; class ChartsContainer : public QWidget { public: ChartsContainer(ChartsWidget *parent); - void dragEnterEvent(QDragEnterEvent *event) override; - void dropEvent(QDropEvent *event) override; - void dragLeaveEvent(QDragLeaveEvent *event) override { drawDropIndicator({}); } void drawDropIndicator(const QPoint &pt) { drop_indictor_pos = pt; update(); } void paintEvent(QPaintEvent *ev) override; ChartView *getDropAfter(const QPoint &pos) const; @@ -69,7 +64,12 @@ private: void eventsMerged(const MessageEventsMap &new_events); void updateState(); void zoomReset(); - void startAutoScroll(); + 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(); @@ -97,7 +97,7 @@ private: QAction *redo_zoom_action; QAction *reset_zoom_action; ToolButton *reset_zoom_btn; - QUndoStack *zoom_undo_stack; + UndoStack zoom_undo_stack; ToolButton *remove_all_btn; std::vector charts; @@ -110,7 +110,15 @@ private: 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; @@ -119,11 +127,10 @@ private: friend class ChartsContainer; }; -class ZoomCommand : public QUndoCommand { +class ZoomCommand : public UndoCommand { public: - ZoomCommand(std::pair range) : range(range), QUndoCommand() { + ZoomCommand(std::pair range) : range(range) { prev_range = can->timeRange(); - setText(QObject::tr("Zoom to %1-%2").arg(range.first, 0, 'f', 2).arg(range.second, 0, 'f', 2)); } void undo() override { can->setTimeRange(prev_range); } void redo() override { can->setTimeRange(range); } diff --git a/openpilot/tools/cabana/commands.cc b/openpilot/tools/cabana/commands.cc index f158528b51..b47bf90b0c 100644 --- a/openpilot/tools/cabana/commands.cc +++ b/openpilot/tools/cabana/commands.cc @@ -1,20 +1,81 @@ -#include - #include "tools/cabana/commands.h" +#include + +// UndoStack + +void UndoStack::push(UndoCommand *cmd) { + commands_.resize(index_); // drop any redoable commands + if (clean_index_ > index_) clean_index_ = -1; + commands_.emplace_back(cmd); + cmd->redo(); + setIndex(index_ + 1); +} + +void UndoStack::undo() { + if (!canUndo()) return; + commands_[index_ - 1]->undo(); + setIndex(index_ - 1); +} + +void UndoStack::redo() { + if (!canRedo()) return; + commands_[index_]->redo(); + setIndex(index_ + 1); +} + +void UndoStack::clear() { + bool was_clean = isClean(); + commands_.clear(); + index_ = clean_index_ = 0; + if (callbacks_.index_changed) callbacks_.index_changed(); + if (!was_clean && callbacks_.clean_changed) callbacks_.clean_changed(true); +} + +void UndoStack::setClean() { + if (!isClean()) { + clean_index_ = index_; + if (callbacks_.clean_changed) callbacks_.clean_changed(true); + } +} + +void UndoStack::setIndex(int index) { + bool was_clean = isClean(); + index_ = index; + if (callbacks_.index_changed) callbacks_.index_changed(); + if (isClean() != was_clean && callbacks_.clean_changed) callbacks_.clean_changed(isClean()); +} + +UndoStack *UndoStack::instance() { + static UndoStack undo_stack; + return &undo_stack; +} + +QtUndoNotifier::QtUndoNotifier(QObject *parent) : QObject(parent) { + UndoStack::instance()->setCallbacks({ + .index_changed = [this]() { emit indexChanged(); }, + .clean_changed = [this](bool clean) { emit cleanChanged(clean); }, + }); +} + +QtUndoNotifier *undoNotifier() { + static QtUndoNotifier notifier; + return ¬ifier; +} + // EditMsgCommand EditMsgCommand::EditMsgCommand(const MessageId &id, const std::string &name, int size, - const std::string &node, const std::string &comment, QUndoCommand *parent) - : id(id), new_name(name), new_size(size), new_node(node), new_comment(comment), QUndoCommand(parent) { + 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; - setText(QObject::tr("edit message %1:%2").arg(QString::fromStdString(name)).arg(id.address)); + text = "edit message " + name + ":" + std::to_string(id.address); } else { - setText(QObject::tr("new message %1:%2").arg(QString::fromStdString(name)).arg(id.address)); + text = "new message " + name + ":" + std::to_string(id.address); } } @@ -31,10 +92,10 @@ void EditMsgCommand::redo() { // RemoveMsgCommand -RemoveMsgCommand::RemoveMsgCommand(const MessageId &id, QUndoCommand *parent) : id(id), QUndoCommand(parent) { +RemoveMsgCommand::RemoveMsgCommand(const MessageId &id) : id(id) { if (auto msg = dbc()->msg(id)) { message = *msg; - setText(QObject::tr("remove message %1:%2").arg(QString::fromStdString(message.name)).arg(id.address)); + text = "remove message " + message.name + ":" + std::to_string(id.address); } } @@ -53,9 +114,9 @@ void RemoveMsgCommand::redo() { // AddSigCommand -AddSigCommand::AddSigCommand(const MessageId &id, const cabana::Signal &sig, QUndoCommand *parent) - : id(id), signal(sig), QUndoCommand(parent) { - setText(QObject::tr("add signal %1 to %2:%3").arg(QString::fromStdString(sig.name)).arg(QString::fromStdString(msgName(id))).arg(id.address)); +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() { @@ -75,8 +136,7 @@ void AddSigCommand::redo() { // RemoveSigCommand -RemoveSigCommand::RemoveSigCommand(const MessageId &id, const cabana::Signal *sig, QUndoCommand *parent) - : id(id), QUndoCommand(parent) { +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) { @@ -85,7 +145,7 @@ RemoveSigCommand::RemoveSigCommand(const MessageId &id, const cabana::Signal *si } } } - setText(QObject::tr("remove signal %1 from %2:%3").arg(QString::fromStdString(sig->name)).arg(QString::fromStdString(msgName(id))).arg(id.address)); + 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); } @@ -93,8 +153,8 @@ void RemoveSigCommand::redo() { for (const auto &s : sigs) dbc()->removeSignal(i // EditSignalCommand -EditSignalCommand::EditSignalCommand(const MessageId &id, const cabana::Signal *sig, const cabana::Signal &new_sig, QUndoCommand *parent) - : id(id), QUndoCommand(parent) { +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 @@ -108,17 +168,8 @@ EditSignalCommand::EditSignalCommand(const MessageId &id, const cabana::Signal * } } } - setText(QObject::tr("edit signal %1 in %2:%3").arg(QString::fromStdString(sig->name)).arg(QString::fromStdString(msgName(id))).arg(id.address)); + 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); } - -namespace UndoStack { - -QUndoStack *instance() { - static QUndoStack *undo_stack = new QUndoStack(qApp); - return undo_stack; -} - -} // namespace UndoStack diff --git a/openpilot/tools/cabana/commands.h b/openpilot/tools/cabana/commands.h index 4081f86985..200a4f2f5b 100644 --- a/openpilot/tools/cabana/commands.h +++ b/openpilot/tools/cabana/commands.h @@ -1,19 +1,70 @@ #pragma once +#include +#include #include #include #include -#include -#include +#include #include "tools/cabana/dbc/dbcmanager.h" #include "tools/cabana/streams/abstractstream.h" -class EditMsgCommand : public QUndoCommand { +class UndoCommand { +public: + virtual ~UndoCommand() = default; + virtual void undo() = 0; + virtual void redo() = 0; + std::string text; +}; + +class UndoStack { +public: + struct Callbacks { + std::function index_changed; + std::function 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> 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, QUndoCommand *parent = nullptr); + const std::string &comment); void undo() override; void redo() override; @@ -23,9 +74,9 @@ private: int old_size = 0, new_size = 0; }; -class RemoveMsgCommand : public QUndoCommand { +class RemoveMsgCommand : public UndoCommand { public: - RemoveMsgCommand(const MessageId &id, QUndoCommand *parent = nullptr); + RemoveMsgCommand(const MessageId &id); void undo() override; void redo() override; @@ -34,9 +85,9 @@ private: cabana::Msg message; }; -class AddSigCommand : public QUndoCommand { +class AddSigCommand : public UndoCommand { public: - AddSigCommand(const MessageId &id, const cabana::Signal &sig, QUndoCommand *parent = nullptr); + AddSigCommand(const MessageId &id, const cabana::Signal &sig); void undo() override; void redo() override; @@ -46,9 +97,9 @@ private: cabana::Signal signal = {}; }; -class RemoveSigCommand : public QUndoCommand { +class RemoveSigCommand : public UndoCommand { public: - RemoveSigCommand(const MessageId &id, const cabana::Signal *sig, QUndoCommand *parent = nullptr); + RemoveSigCommand(const MessageId &id, const cabana::Signal *sig); void undo() override; void redo() override; @@ -57,9 +108,9 @@ private: std::vector sigs; }; -class EditSignalCommand : public QUndoCommand { +class EditSignalCommand : public UndoCommand { public: - EditSignalCommand(const MessageId &id, const cabana::Signal *sig, const cabana::Signal &new_sig, QUndoCommand *parent = nullptr); + EditSignalCommand(const MessageId &id, const cabana::Signal *sig, const cabana::Signal &new_sig); void undo() override; void redo() override; @@ -67,8 +118,3 @@ private: const MessageId id; std::vector> sigs; // {old_sig, new_sig} }; - -namespace UndoStack { - QUndoStack *instance(); - inline void push(QUndoCommand *cmd) { instance()->push(cmd); } -}; diff --git a/openpilot/tools/cabana/deqt.md b/openpilot/tools/cabana/deqt.md index 6d2aed8264..3bef2d7ab0 100644 --- a/openpilot/tools/cabana/deqt.md +++ b/openpilot/tools/cabana/deqt.md @@ -20,13 +20,8 @@ some rules - `QObject`, `QMetaObject`, `QMetaType` - `QApplication`, `QCoreApplication`, `QGuiApplication` - `QString`, `QStringList`, `QStringBuilder`, `QChar`, `QLatin1Char` -- `QByteArray` - `QVariant` -- `QVector`, `QMap`, `QSet`, `QPointer` -- `QSettings` -- `QFile`, `QFileInfo`, `QDir`, `QIODevice`, `QStandardPaths` -- `QThread` -- `QTimer`, `QBasicTimer`, `QTimerEvent` +- `QTimer` - `QWidget`, `QMainWindow`, `QWindow` - `QDialog`, `QDialogButtonBox`, `QMessageBox`, `QProgressDialog` - `QFileDialog` @@ -36,7 +31,7 @@ some rules - `QComboBox`, `QLineEdit`, `QTextEdit`, `QSpinBox`, `QSlider` - `QLabel`, `QGroupBox`, `QFrame` - `QTabBar`, `QTabWidget`, `QSplitter`, `QScrollArea`, `QScrollBar` -- `QDockWidget`, `QStatusBar`, `QProgressBar`, `QRubberBand` +- `QDockWidget`, `QStatusBar`, `QProgressBar` - `QFormLayout`, `QGridLayout`, `QHBoxLayout`, `QVBoxLayout` - `QSizePolicy` - `QAbstractItemModel`, `QAbstractTableModel`, `QModelIndex` @@ -44,27 +39,15 @@ some rules - `QTableWidget`, `QTableWidgetItem`, `QListWidget`, `QListWidgetItem` - `QItemSelection`, `QItemSelectionModel`, `QItemSelectionRange` - `QHeaderView`, `QStyledItemDelegate`, `QStyleOptionViewItem` -- `QValidator`, `QIntValidator`, `QDoubleValidator` +- `QValidator`, `QIntValidator` - `QColor`, `QRgb`, `QPalette` - `QBrush`, `QPen` - `QPainter`, `QPainterPath`, `QStylePainter` -- `QPixmap`, `QPixmapCache`, `QIcon`, `QStaticText` +- `QImage`, `QPixmap`, `QPixmapCache`, `QStaticText` - `QFont`, `QFontDatabase`, `QFontMetrics`, `QTextDocument` - `QStyle`, `QStyleOption`, `QStyleOptionFrame`, `QStyleOptionSlider` - `QPoint`, `QPointF`, `QRect`, `QRectF`, `QRegion` - `QSize`, `QSizeF` -- `QCursor`, `QClipboard`, `QScreen`, `QDesktopWidget` - `QEvent`, `QPaintEvent`, `QResizeEvent`, `QShowEvent`, `QCloseEvent` - `QMouseEvent`, `QWheelEvent`, `QNativeGestureEvent`, `QContextMenuEvent` -- `QDrag`, `QMimeData` -- `QDragEnterEvent`, `QDragLeaveEvent`, `QDragMoveEvent`, `QDropEvent` - `QKeySequence`, `QShortcut`, `QToolTip` -- `QChart`, `QChartView`, `QAbstractAxis`, `QValueAxis` -- `QXYSeries`, `QLineSeries`, `QScatterSeries` -- `QLegend`, `QLegendMarker` -- `QGraphicsScene`, `QGraphicsView`, `QGraphicsItemGroup`, `QGraphicsLayout` -- `QGraphicsPixmapItem`, `QGraphicsProxyWidget` -- `QOpenGLWidget`, `QOpenGLFunctions` -- `QOpenGLShader`, `QOpenGLShaderProgram` -- `QMatrix4x4`, `QSurfaceFormat` -- `QUndoCommand`, `QUndoStack`, `QUndoView` diff --git a/openpilot/tools/cabana/detailwidget.cc b/openpilot/tools/cabana/detailwidget.cc index 36a95ff96e..6b62f54959 100644 --- a/openpilot/tools/cabana/detailwidget.cc +++ b/openpilot/tools/cabana/detailwidget.cc @@ -58,7 +58,7 @@ DetailWidget::DetailWidget(ChartsWidget *charts, QWidget *parent) : charts(chart 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(UndoStack::instance(), &QUndoStack::indexChanged, 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) { @@ -211,13 +211,13 @@ void DetailWidget::editMsg() { 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::push(new EditMsgCommand(msg_id, dlg.name_edit->text().trimmed().toStdString(), dlg.size_spin->value(), + 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::push(new RemoveMsgCommand(msg_id)); + UndoStack::instance()->push(new RemoveMsgCommand(msg_id)); } // EditMessageDialog diff --git a/openpilot/tools/cabana/historylog.cc b/openpilot/tools/cabana/historylog.cc index 51c2481f3b..3fd569c648 100644 --- a/openpilot/tools/cabana/historylog.cc +++ b/openpilot/tools/cabana/historylog.cc @@ -209,7 +209,7 @@ LogsWidget::LogsWidget(QWidget *parent) : QFrame(parent) { 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(UndoStack::instance(), &QUndoStack::indexChanged, 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); }); } diff --git a/openpilot/tools/cabana/mainwin.cc b/openpilot/tools/cabana/mainwin.cc index 6372ea9295..953f573a0b 100644 --- a/openpilot/tools/cabana/mainwin.cc +++ b/openpilot/tools/cabana/mainwin.cc @@ -2,23 +2,21 @@ #include "tools/cabana/dbc/dbcqt.h" #include +#include +#include #include +#include #include +#include -#include -#include -#include #include -#include #include #include #include #include #include #include -#include #include -#include #include "json11/json11.hpp" #include "tools/cabana/commands.h" @@ -37,14 +35,11 @@ MainWindow::MainWindow(AbstractStream *stream, const QString &dbc_file) : QMainW createShortcuts(); // save default window state to allow resetting it - default_state = saveState(); + default_state = utils::toBytes(saveState()); - // restore states - restoreGeometry(settings.geometry); - if (isMaximized()) { - setGeometry(QApplication::desktop()->availableGeometry(this)); - } - restoreState(settings.window_state); + // 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; @@ -65,7 +60,7 @@ MainWindow::MainWindow(AbstractStream *stream, const QString &dbc_file) : QMainW 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(UndoStack::instance(), &QUndoStack::cleanChanged, this, &MainWindow::undoStackCleanChanged); + 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(); }); @@ -73,10 +68,11 @@ MainWindow::MainWindow(AbstractStream *stream, const QString &dbc_file) : QMainW } void MainWindow::loadFingerprints() { - QFile json_file(QApplication::applicationDirPath() + "/dbc/car_fingerprint_to_dbc.json"); - if (!json_file.open(QIODevice::ReadOnly)) return; + std::ifstream json_file((QApplication::applicationDirPath() + "/dbc/car_fingerprint_to_dbc.json").toStdString()); + if (!json_file) return; + const std::string contents{std::istreambuf_iterator(json_file), std::istreambuf_iterator()}; std::string err; - auto doc = json11::Json::parse(json_file.readAll().toStdString(), 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()) { @@ -108,8 +104,17 @@ void MainWindow::createActions() { file_menu->addSeparator(); QMenu *load_opendbc_menu = file_menu->addMenu(tr("Load DBC from commaai/opendbc")); // load_opendbc_menu->setStyleSheet("QMenu { menu-scrollable: true; }"); - for (const auto &dbc_name : QDir(OPENDBC_FILE_PATH).entryList({"*.dbc"}, QDir::Files, QDir::Name)) { - load_opendbc_menu->addAction(dbc_name, [this, name = dbc_name]() { loadDBCFromOpendbc(name); }); + std::vector dbc_names; + std::error_code ec; + for (const auto &entry : std::filesystem::directory_iterator(OPENDBC_FILE_PATH, ec)) { + if (entry.is_regular_file() && entry.path().extension() == ".dbc") { + dbc_names.push_back(entry.path().filename().string()); + } + } + std::sort(dbc_names.begin(), dbc_names.end()); + for (const auto &dbc_name : dbc_names) { + QString name = QString::fromStdString(dbc_name); + load_opendbc_menu->addAction(name, [this, name]() { loadDBCFromOpendbc(name); }); } file_menu->addAction(tr("Load DBC From Clipboard"), [=]() { loadFromClipboard(); }); @@ -127,18 +132,12 @@ void MainWindow::createActions() { // Edit Menu QMenu *edit_menu = menuBar()->addMenu(tr("&Edit")); - auto undo_act = UndoStack::instance()->createUndoAction(this, tr("&Undo")); + undo_act = edit_menu->addAction(tr("&Undo"), []() { UndoStack::instance()->undo(); }); undo_act->setShortcuts(QKeySequence::Undo); - edit_menu->addAction(undo_act); - auto redo_act = UndoStack::instance()->createRedoAction(this, tr("&Redo")); + redo_act = edit_menu->addAction(tr("&Redo"), []() { UndoStack::instance()->redo(); }); redo_act->setShortcuts(QKeySequence::Redo); - edit_menu->addAction(redo_act); - edit_menu->addSeparator(); - - QMenu *commands_menu = edit_menu->addMenu(tr("Command &List")); - QWidgetAction *commands_act = new QWidgetAction(this); - commands_act->setDefaultWidget(new QUndoView(UndoStack::instance())); - commands_menu->addAction(commands_act); + QObject::connect(undoNotifier(), &QtUndoNotifier::indexChanged, this, &MainWindow::updateUndoRedoActions); + updateUndoRedoActions(); // View Menu QMenu *view_menu = menuBar()->addMenu(tr("&View")); @@ -148,7 +147,7 @@ void MainWindow::createActions() { view_menu->addAction(messages_dock->toggleViewAction()); view_menu->addAction(video_dock->toggleViewAction()); view_menu->addSeparator(); - view_menu->addAction(tr("Reset Window Layout"), [this]() { restoreState(default_state); }); + view_menu->addAction(tr("Reset Window Layout"), [this]() { restoreState(utils::qbytes(default_state)); }); // Tools Menu tools_menu = menuBar()->addMenu(tr("&Tools")); @@ -195,7 +194,7 @@ void MainWindow::createDockWidgets() { video_splitter->addWidget(charts_container); video_splitter->setStretchFactor(1, 1); - video_splitter->restoreState(settings.video_splitter_state); + 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); @@ -226,6 +225,14 @@ 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(); @@ -306,11 +313,20 @@ void MainWindow::loadDBCFromOpendbc(const QString &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); - QString dbc_str = QGuiApplication::clipboard()->text(); std::string error; - bool ret = dbc()->open(s, std::string(""), dbc_str.toStdString(), &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 { @@ -436,7 +452,7 @@ void MainWindow::saveFile(DBCFile *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, QDir::cleanPath(QString::fromStdString(settings.last_dir) + "/untitled.dbc"), tr("DBC (*.dbc)")); + 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(); @@ -455,8 +471,11 @@ void MainWindow::saveToClipboard() { void MainWindow::saveFileToClipboard(DBCFile *dbc_file) { assert(dbc_file != nullptr); - QGuiApplication::clipboard()->setText(QString::fromStdString(dbc_file->generateDBC())); - QMessageBox::information(this, tr("Copy To Clipboard"), tr("DBC Successfully copied!")); + 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() { @@ -496,7 +515,7 @@ void MainWindow::updateRecentFiles(const QString &fn) { while (settings.recent_files.size() > MAX_RECENT_FILES) { settings.recent_files.pop_back(); } - settings.last_dir = QFileInfo(fn).absolutePath().toStdString(); + settings.last_dir = std::filesystem::absolute(fn.toStdString()).parent_path().string(); } void MainWindow::updateRecentFileMenu() { @@ -509,7 +528,7 @@ void MainWindow::updateRecentFileMenu() { } for (int i = 0; i < num_recent_files; ++i) { - QString text = tr("&%1 %2").arg(i + 1).arg(QFileInfo(QString::fromStdString(settings.recent_files[i])).fileName()); + 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)); }); } } @@ -576,16 +595,17 @@ void MainWindow::closeEvent(QCloseEvent *event) { floating_window->deleteLater(); // save states - settings.geometry = saveGeometry(); - settings.window_state = saveState(); + settings.geometry = utils::toBytes(saveGeometry()); + settings.window_state = utils::toBytes(saveState()); if (can && !can->liveStreaming()) { - settings.video_splitter_state = video_splitter->saveState(); + 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); } diff --git a/openpilot/tools/cabana/mainwin.h b/openpilot/tools/cabana/mainwin.h index 3526a58202..279ea3b969 100644 --- a/openpilot/tools/cabana/mainwin.h +++ b/openpilot/tools/cabana/mainwin.h @@ -6,9 +6,11 @@ #include #include #include +#include #include #include #include +#include #include "tools/cabana/chart/chartswidget.h" #include "tools/cabana/dbc/dbcmanager.h" @@ -68,6 +70,7 @@ protected: void findSimilarBits(); void findSignal(); void undoStackCleanChanged(bool clean); + void updateUndoRedoActions(); void onlineHelp(); void toggleFullScreen(); void updateStatus(); @@ -97,8 +100,10 @@ protected: 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; - QByteArray default_state; + std::vector default_state; }; class HelpOverlay : public QWidget { diff --git a/openpilot/tools/cabana/messageswidget.cc b/openpilot/tools/cabana/messageswidget.cc index 22de1350ec..b07e3ea0bf 100644 --- a/openpilot/tools/cabana/messageswidget.cc +++ b/openpilot/tools/cabana/messageswidget.cc @@ -45,7 +45,7 @@ MessagesWidget::MessagesWidget(QWidget *parent) : menu(new QMenu(this)), QWidget 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(UndoStack::instance(), &QUndoStack::indexChanged, 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); @@ -212,7 +212,7 @@ QVariant MessageListModel::data(const QModelIndex &index, int role) const { return {}; } -void MessageListModel::setFilterStrings(const QMap &filters) { +void MessageListModel::setFilterStrings(const std::map &filters) { filters_ = filters; filterAndSort(); } @@ -265,14 +265,14 @@ static bool parseRange(const QString &filter, uint32_t value, int base = 10) { } bool MessageListModel::match(const MessageListModel::Item &item) { - if (filters_.isEmpty()) + 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.value(); - switch (it.key()) { + const QString &txt = it->second; + switch (it->first) { case Column::NAME: { match = item.name.contains(txt, Qt::CaseInsensitive); if (!match) { @@ -388,10 +388,13 @@ void MessageView::drawRow(QPainter *painter, const QStyleOptionViewItem &option, painter->setPen(oldPen); } -void MessageView::dataChanged(const QModelIndex &topLeft, const QModelIndex &bottomRight, const QVector &roles) { +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. - QAbstractItemView::dataChanged(topLeft, bottomRight, roles); + 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() { @@ -422,9 +425,9 @@ MessageViewHeader::MessageViewHeader(QWidget *parent) : QHeaderView(Qt::Horizont } void MessageViewHeader::updateFilters() { - QMap filters; - for (int i = 0; i < count(); i++) { - if (editors[i] && !editors[i]->text().isEmpty()) { + std::map filters; + for (int i = 0; i < (int)editors.size(); i++) { + if (!editors[i]->text().isEmpty()) { filters[i] = editors[i]->text(); } } @@ -433,27 +436,24 @@ void MessageViewHeader::updateFilters() { void MessageViewHeader::updateHeaderPositions() { QSize sz = QHeaderView::sizeHint(); - for (int i = 0; i < count(); i++) { - if (editors[i]) { - int h = editors[i]->sizeHint().height(); - editors[i]->setGeometry(sectionViewportPosition(i), sz.height(), sectionSize(i), h); - editors[i]->setHidden(isSectionHidden(i)); - } + 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 = 0; i < count(); i++) { - if (!editors[i]) { - QString column_name = model()->headerData(i, Qt::Horizontal, Qt::DisplayRole).toString(); - editors[i] = new QLineEdit(this); - editors[i]->setClearButtonEnabled(true); - editors[i]->setPlaceholderText(tr("Filter %1").arg(column_name)); + 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(editors[i], &QLineEdit::textChanged, this, &MessageViewHeader::updateFilters); - } + QObject::connect(edit, &QLineEdit::textChanged, this, &MessageViewHeader::updateFilters); + editors.push_back(edit); } - setViewportMargins(0, 0, 0, editors[0] ? editors[0]->sizeHint().height() : 0); + setViewportMargins(0, 0, 0, !editors.empty() ? editors[0]->sizeHint().height() : 0); QHeaderView::updateGeometries(); updateHeaderPositions(); @@ -461,5 +461,5 @@ void MessageViewHeader::updateGeometries() { QSize MessageViewHeader::sizeHint() const { QSize sz = QHeaderView::sizeHint(); - return editors[0] ? QSize(sz.width(), sz.height() + editors[0]->height() + 1) : sz; + return !editors.empty() ? QSize(sz.width(), sz.height() + editors[0]->height() + 1) : sz; } diff --git a/openpilot/tools/cabana/messageswidget.h b/openpilot/tools/cabana/messageswidget.h index 9ffb156604..0a9cd256d8 100644 --- a/openpilot/tools/cabana/messageswidget.h +++ b/openpilot/tools/cabana/messageswidget.h @@ -1,6 +1,8 @@ #pragma once #include +#include +#include #include #include #include @@ -35,7 +37,7 @@ public: QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const; int rowCount(const QModelIndex &parent = QModelIndex()) const override { return items_.size(); } void sort(int column, Qt::SortOrder order = Qt::AscendingOrder) override; - void setFilterStrings(const QMap &filters); + void setFilterStrings(const std::map &filters); void showInactiveMessages(bool show); void msgsReceived(const std::set *new_msgs, bool has_new_ids); bool filterAndSort(); @@ -56,7 +58,7 @@ private: void sortItems(std::vector &items); bool match(const MessageListModel::Item &id); - QMap filters_; + std::map filters_; std::set dbc_messages_; int sort_column = 0; Qt::SortOrder sort_order = Qt::AscendingOrder; @@ -68,11 +70,11 @@ class MessageView : public QTreeView { 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 dataChanged(const QModelIndex &topLeft, const QModelIndex &bottomRight, const QVector &roles = QVector()) override; void wheelEvent(QWheelEvent *event) override; }; @@ -86,7 +88,7 @@ public: QSize sizeHint() const override; void updateFilters(); - QMap editors; + std::vector editors; }; class MessagesWidget : public QWidget { @@ -95,8 +97,13 @@ class MessagesWidget : public QWidget { public: MessagesWidget(QWidget *parent); void selectMessage(const MessageId &message_id); - QByteArray saveHeaderState() const { return view->header()->saveState(); } - bool restoreHeaderState(const QByteArray &state) const { return view->header()->restoreState(state); } + std::vector saveHeaderState() const { + const auto state = view->header()->saveState(); + return {state.begin(), state.end()}; + } + bool restoreHeaderState(const std::vector &state) const { + return view->header()->restoreState({(const char *)state.data(), (int)state.size()}); + } void suppressHighlighted(); signals: diff --git a/openpilot/tools/cabana/settings.cc b/openpilot/tools/cabana/settings.cc index e385d83eca..0c8136b84c 100644 --- a/openpilot/tools/cabana/settings.cc +++ b/openpilot/tools/cabana/settings.cc @@ -1,15 +1,37 @@ #include "tools/cabana/settings.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#ifdef __APPLE__ +#include +#endif + #include #include -#include #include #include #include -#include -#include #include +#include "json11/json11.hpp" #include "tools/cabana/utils/util.h" const int MIN_CACHE_MINIUTES = 30; @@ -17,32 +39,442 @@ 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(input), std::istreambuf_iterator()}; + 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 strings; + std::string bytes; + bool is_byte_array = false; +}; + +using LegacySettings = std::map; + +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> decoded(1); + std::vector 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 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(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(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 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 keys(count); + std::vector 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(keys[i])); + CFTypeRef object = objects[i]; + LegacyValue value; + if (CFGetTypeID(object) == CFBooleanGetTypeID()) { + value.strings.push_back(CFBooleanGetValue(static_cast(object)) ? "true" : "false"); + } else if (CFGetTypeID(object) == CFNumberGetTypeID()) { + int number = 0; + if (CFNumberGetValue(static_cast(object), kCFNumberIntType, &number)) value.strings.push_back(std::to_string(number)); + } else if (CFGetTypeID(object) == CFStringGetTypeID()) { + value = cfStringValue(static_cast(object)); + } else if (CFGetTypeID(object) == CFDataGetTypeID()) { + auto data = static_cast(object); + value.is_byte_array = true; + value.bytes.assign(reinterpret_cast(CFDataGetBytePtr(data)), CFDataGetLength(data)); + } else if (CFGetTypeID(object) == CFArrayGetTypeID()) { + auto array = static_cast(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(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 -void readSetting(QSettings &settings_store, const char *key, T &value) { - if (auto stored = settings_store.value(key); stored.canConvert()) value = stored.value(); +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) { + if (stored == "true") value = true; + if (stored == "false") value = false; + } else if constexpr (std::is_integral_v || std::is_enum_v) { + 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(number); + } } -void readSetting(QSettings &settings_store, const char *key, std::string &value) { - value = settings_store.value(key, QString::fromStdString(value)).toString().toStdString(); +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 readSetting(QSettings &settings_store, const char *key, std::vector &value) { - value.clear(); - for (const auto &item : settings_store.value(key).toStringList()) value.push_back(item.toStdString()); +void readLegacySetting(const LegacySettings &legacy_settings, const char *key, std::vector &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 &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 -void writeSetting(QSettings &settings_store, const char *key, const T &value) { settings_store.setValue(key, value); } -void writeSetting(QSettings &settings_store, const char *key, const std::string &value) { settings_store.setValue(key, QString::fromStdString(value)); } -void writeSetting(QSettings &settings_store, const char *key, const std::vector &value) { - QStringList items; - for (const auto &item : value) items.push_back(QString::fromStdString(item)); - settings_store.setValue(key, items); +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) { + if (it->second.is_bool()) value = it->second.bool_value(); + } else if constexpr (std::is_integral_v) { + if (it->second.is_number()) value = it->second.int_value(); + } else if constexpr (std::is_enum_v) { + if (it->second.is_number()) value = static_cast(it->second.int_value()); + } } -template -void settingsOp(SettingOperation op) { - QSettings s("cabana"); +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 &value) { + auto it = settings_json.find(key); + if (it == settings_json.end() || !it->second.is_array()) return; + + std::vector 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 &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 +void writeSetting(json11::Json::object &settings_json, const char *key, const T &value) { + if constexpr (std::is_same_v) { + settings_json[key] = value; + } else if constexpr (std::is_integral_v || std::is_enum_v) { + settings_json[key] = static_cast(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 &value) { + settings_json[key] = value; +} + +void writeSetting(json11::Json::object &settings_json, const char *key, const std::vector &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 +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); @@ -70,14 +502,38 @@ void settingsOp(SettingOperation op) { op(s, "active_charts", settings.active_charts); } +} // namespace + Settings::Settings() { - last_dir = last_route_dir = QDir::homePath().toStdString(); - log_path = (QStandardPaths::writableLocation(QStandardPaths::HomeLocation) + "/cabana_live_stream/").toStdString(); - settingsOp([](QSettings &s, const char *key, auto &value) { readSetting(s, key, value); }); + 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); } -Settings::~Settings() { - settingsOp([](QSettings &s, const char *key, const auto &value) { writeSetting(s, key, value); }); +// 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 @@ -121,6 +577,7 @@ SettingsDlg::SettingsDlg(QWidget *parent) : QDialog(parent) { 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); @@ -135,7 +592,7 @@ SettingsDlg::SettingsDlg(QWidget *parent) : QDialog(parent) { QObject::connect(browse_btn, &QPushButton::clicked, [this]() { QString fn = QFileDialog::getExistingDirectory( this, tr("Log File Location"), - QStandardPaths::writableLocation(QStandardPaths::HomeLocation), + QString::fromStdString(utils::homePath()), QFileDialog::ShowDirsOnly | QFileDialog::DontResolveSymlinks); if (!fn.isEmpty()) { log_path->setText(fn); diff --git a/openpilot/tools/cabana/settings.h b/openpilot/tools/cabana/settings.h index 4254a8009d..7357ecf4fc 100644 --- a/openpilot/tools/cabana/settings.h +++ b/openpilot/tools/cabana/settings.h @@ -1,6 +1,8 @@ #pragma once -#include +#include +#include + #include #include #include @@ -14,13 +16,13 @@ class Settings : public QObject, public CabanaSettingsState { public: Settings(); - ~Settings(); + void save(); // Qt frontend layout state. This intentionally stays outside CabanaSettingsState. - QByteArray geometry; - QByteArray video_splitter_state; - QByteArray window_state; - QByteArray message_header_state; + std::vector geometry; + std::vector video_splitter_state; + std::vector window_state; + std::vector message_header_state; signals: void changed(); diff --git a/openpilot/tools/cabana/signalview.cc b/openpilot/tools/cabana/signalview.cc index 87294b41f7..ebb5374140 100644 --- a/openpilot/tools/cabana/signalview.cc +++ b/openpilot/tools/cabana/signalview.cc @@ -198,7 +198,7 @@ bool SignalModel::saveSignal(const cabana::Signal *origin_s, cabana::Signal &s) if (s.is_little_endian != origin_s->is_little_endian) { s.start_bit = flipBitPos(s.start_bit); } - UndoStack::push(new EditSignalCommand(msg_id, origin_s, s)); + UndoStack::instance()->push(new EditSignalCommand(msg_id, origin_s, s)); return true; } @@ -515,7 +515,7 @@ void SignalView::rowsChanged() { tree->setIndexWidget(index, w); auto sig = model->getItem(index)->sig; - QObject::connect(remove_btn, &QToolButton::clicked, [=]() { UndoStack::push(new RemoveSigCommand(model->msg_id, sig)); }); + QObject::connect(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); }); diff --git a/openpilot/tools/cabana/signalview.h b/openpilot/tools/cabana/signalview.h index 42db830df7..2e44e55cbf 100644 --- a/openpilot/tools/cabana/signalview.h +++ b/openpilot/tools/cabana/signalview.h @@ -129,9 +129,12 @@ private: // update widget geometries in QTreeView::rowsInserted QTreeView::rowsInserted(parent, start, end); } - void dataChanged(const QModelIndex &topLeft, const QModelIndex &bottomRight, const QVector &roles = QVector()) override { + void setModel(QAbstractItemModel *model) override { + QTreeView::setModel(model); // Bypass the slow call to QTreeView::dataChanged. - QAbstractItemView::dataChanged(topLeft, bottomRight, roles); + QObject::disconnect(model, &QAbstractItemModel::dataChanged, this, nullptr); + QObject::connect(model, &QAbstractItemModel::dataChanged, this, + [this](const QModelIndex &tl, const QModelIndex &br, const auto &roles) { QAbstractItemView::dataChanged(tl, br, roles); }); } void leaveEvent(QEvent *event) override { emit static_cast(parentWidget())->highlight(nullptr); diff --git a/openpilot/tools/cabana/streams/abstractstream.h b/openpilot/tools/cabana/streams/abstractstream.h index 26f05d9846..82cb899937 100644 --- a/openpilot/tools/cabana/streams/abstractstream.h +++ b/openpilot/tools/cabana/streams/abstractstream.h @@ -75,12 +75,12 @@ protected: 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 all_events_; double current_sec_ = 0; std::optional> time_range_; private: - void updateLastMessages(); void updateLastMsgsTo(double sec); void updateMasks(); diff --git a/openpilot/tools/cabana/streams/devicestream.cc b/openpilot/tools/cabana/streams/devicestream.cc index 1ce0dbb71f..91e890316e 100644 --- a/openpilot/tools/cabana/streams/devicestream.cc +++ b/openpilot/tools/cabana/streams/devicestream.cc @@ -1,22 +1,23 @@ #include "tools/cabana/streams/devicestream.h" #include +#include #include #include #include +#include #include #include +#include #include #include #include "openpilot/cereal/services.h" #include -#include #include #include #include -#include #include "tools/cabana/utils/util.h" @@ -26,6 +27,7 @@ DeviceStream::DeviceStream(QObject *parent, QString address) : zmq_address(addre } DeviceStream::~DeviceStream() { + stop(); stopBridge(); } @@ -50,9 +52,8 @@ void DeviceStream::stopBridge() { void DeviceStream::start() { if (!zmq_address.isEmpty()) { stopBridge(); - QString bridge_path = QFileInfo(QCoreApplication::applicationDirPath() + - "/../../openpilot/cereal/messaging/bridge").absoluteFilePath(); - const std::string path = bridge_path.toStdString(); + const std::string path = (std::filesystem::path(QCoreApplication::applicationDirPath().toStdString()) / + "../../openpilot/cereal/messaging/bridge").lexically_normal().string(); const std::string addr = zmq_address.toStdString(); const char *can_filter = "/\"can/\""; @@ -108,10 +109,10 @@ void DeviceStream::streamThread() { std::unique_ptr sock(SubSocket::create(context.get(), "can", "127.0.0.1", false, true, services.at("can").queue_size)); assert(sock != NULL); // run as fast as messages come in - while (!QThread::currentThread()->isInterruptionRequested()) { + while (!exit_) { std::unique_ptr msg(sock->receive(true)); if (!msg) { - QThread::msleep(50); + std::this_thread::sleep_for(std::chrono::milliseconds(50)); continue; } handleEvent(kj::ArrayPtr((capnp::word*)msg->getData(), msg->getSize() / sizeof(capnp::word))); diff --git a/openpilot/tools/cabana/streams/livestream.cc b/openpilot/tools/cabana/streams/livestream.cc index 5aa4628bc0..019a67e8f2 100644 --- a/openpilot/tools/cabana/streams/livestream.cc +++ b/openpilot/tools/cabana/streams/livestream.cc @@ -1,7 +1,7 @@ #include "tools/cabana/streams/livestream.h" -#include #include +#include #include #include #include @@ -42,37 +42,34 @@ LiveStream::LiveStream(QObject *parent) : AbstractStream(parent) { if (settings.log_livestream) { logger = std::make_unique(); } - stream_thread = new QThread(this); - - QObject::connect(&settings, &Settings::changed, this, &LiveStream::startUpdateTimer); - QObject::connect(stream_thread, &QThread::started, [=]() { streamThread(); }); - QObject::connect(stream_thread, &QThread::finished, stream_thread, &QThread::deleteLater); } LiveStream::~LiveStream() { stop(); } -void LiveStream::startUpdateTimer() { - update_timer.stop(); - update_timer.start(1000.0 / settings.fps, this); - timer_id = update_timer.timerId(); -} - void LiveStream::start() { - stream_thread->start(); - startUpdateTimer(); 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() { - if (!stream_thread) return; + exit_ = true; + if (stream_thread.joinable()) stream_thread.join(); + if (update_thread.joinable()) update_thread.join(); +} - update_timer.stop(); - stream_thread->requestInterruption(); - stream_thread->quit(); - stream_thread->wait(); - stream_thread = nullptr; +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 @@ -92,23 +89,22 @@ void LiveStream::handleEvent(kj::ArrayPtr data) { } } -void LiveStream::timerEvent(QTimerEvent *event) { - if (event->timerId() == timer_id) { - { - // merge events received from live stream thread. - std::lock_guard lk(lock); - mergeEvents(received_events_); - 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(); - return; - } +// 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(); } - QObject::timerEvent(event); } void LiveStream::updateEvents() { @@ -138,7 +134,7 @@ void LiveStream::updateEvents() { updateEvent(id, (e->mono_time - begin_event_ts) / 1e9, e->dat, e->size); current_event_ts = e->mono_time; } - emit privateUpdateLastMsgsSignal(); + AbstractStream::updateLastMessages(); } void LiveStream::seekTo(double sec) { diff --git a/openpilot/tools/cabana/streams/livestream.h b/openpilot/tools/cabana/streams/livestream.h index e88fbadd8e..5d65b1743f 100644 --- a/openpilot/tools/cabana/streams/livestream.h +++ b/openpilot/tools/cabana/streams/livestream.h @@ -1,11 +1,11 @@ #pragma once #include +#include #include +#include #include -#include - #include "tools/cabana/streams/abstractstream.h" class LiveStream : public AbstractStream { @@ -29,18 +29,19 @@ protected: virtual void streamThread() = 0; void handleEvent(kj::ArrayPtr event); + std::atomic exit_ = false; + private: - void startUpdateTimer(); - void timerEvent(QTimerEvent *event) override; + void updateThread(); + void updateLastMessages() override; void updateEvents(); std::mutex lock; - QThread *stream_thread; + std::thread stream_thread, update_thread; + std::atomic update_pending_ = false; + std::atomic fps_ = 10; std::vector received_events_; - int timer_id; - QBasicTimer update_timer; - std::chrono::system_clock::time_point begin_date_time; uint64_t begin_event_ts = 0; uint64_t lastest_event_ts = 0; diff --git a/openpilot/tools/cabana/streams/pandastream.cc b/openpilot/tools/cabana/streams/pandastream.cc index aa1e01c5a3..7ccb18a756 100644 --- a/openpilot/tools/cabana/streams/pandastream.cc +++ b/openpilot/tools/cabana/streams/pandastream.cc @@ -1,12 +1,13 @@ #include "tools/cabana/streams/pandastream.h" +#include #include +#include #include #include #include #include -#include #include PandaStream::PandaStream(QObject *parent, PandaStreamConfig config_) : config(config_), LiveStream(parent) { @@ -45,13 +46,13 @@ bool PandaStream::connect() { void PandaStream::streamThread() { std::vector raw_can_data; - while (!QThread::currentThread()->isInterruptionRequested()) { - QThread::msleep(1); + 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()){ - QThread::msleep(1000); + std::this_thread::sleep_for(std::chrono::milliseconds(1000)); continue; } } diff --git a/openpilot/tools/cabana/streams/replaystream.cc b/openpilot/tools/cabana/streams/replaystream.cc index c74aa49821..c502ea061a 100644 --- a/openpilot/tools/cabana/streams/replaystream.cc +++ b/openpilot/tools/cabana/streams/replaystream.cc @@ -1,5 +1,7 @@ #include "tools/cabana/streams/replaystream.h" +#include + #include #include #include @@ -139,7 +141,7 @@ OpenReplayWidget::OpenReplayWidget(QWidget *parent) : AbstractOpenStreamWidget(p 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 = QFileInfo(dir).absolutePath().toStdString(); + settings.last_route_dir = std::filesystem::absolute(dir.toStdString()).parent_path().string(); } }); QObject::connect(browse_remote_btn, &QPushButton::clicked, [this]() { diff --git a/openpilot/tools/cabana/streams/routes.cc b/openpilot/tools/cabana/streams/routes.cc index 9c19219ce2..b6f98da533 100644 --- a/openpilot/tools/cabana/streams/routes.cc +++ b/openpilot/tools/cabana/streams/routes.cc @@ -12,7 +12,6 @@ #include #include #include -#include #include "json11/json11.hpp" #include "tools/replay/py_downloader.h" @@ -112,11 +111,10 @@ RoutesDialog::RoutesDialog(QWidget *parent) : QDialog(parent) { connect(button_box, &QDialogButtonBox::rejected, this, &QDialog::reject); // Fetch devices - QPointer self = this; - std::thread([self]() { + std::thread([this, alive = std::weak_ptr(alive_)]() { std::string result = PyDownloader::getDevices(); - QMetaObject::invokeMethod(qApp, [self, r = QString::fromStdString(result), response = checkApiResponse(result)]() { - if (self) self->parseDeviceList(r, response.first, response.second); + QMetaObject::invokeMethod(qApp, [this, alive, r = QString::fromStdString(result), response = checkApiResponse(result)]() { + if (!alive.expired()) parseDeviceList(r, response.first, response.second); }, Qt::QueuedConnection); }).detach(); } @@ -156,12 +154,10 @@ void RoutesDialog::fetchRoutes() { } int request_id = ++fetch_id_; - QPointer self = this; - std::thread([self, did, start_ms, end_ms, preserved, request_id]() { + std::thread([this, alive = std::weak_ptr(alive_), did, start_ms, end_ms, preserved, request_id]() { std::string result = PyDownloader::getDeviceRoutes(did, start_ms, end_ms, preserved); - if (!self || self->fetch_id_ != request_id) return; - QMetaObject::invokeMethod(qApp, [self, r = QString::fromStdString(result), response = checkApiResponse(result), request_id]() { - if (self && self->fetch_id_ == request_id) self->parseRouteList(r, response.first, response.second); + 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(); } diff --git a/openpilot/tools/cabana/streams/routes.h b/openpilot/tools/cabana/streams/routes.h index 99fa67ef8c..6ed145603f 100644 --- a/openpilot/tools/cabana/streams/routes.h +++ b/openpilot/tools/cabana/streams/routes.h @@ -1,6 +1,8 @@ #pragma once #include +#include + #include #include @@ -21,4 +23,6 @@ protected: QComboBox *period_selector_; RouteListWidget *route_list_; std::atomic fetch_id_{0}; + // expires on destruction; guards main-thread callbacks from detached worker threads + std::shared_ptr alive_ = std::make_shared(true); }; diff --git a/openpilot/tools/cabana/streams/socketcanstream.cc b/openpilot/tools/cabana/streams/socketcanstream.cc index cedbddf99e..b616e7f242 100644 --- a/openpilot/tools/cabana/streams/socketcanstream.cc +++ b/openpilot/tools/cabana/streams/socketcanstream.cc @@ -8,13 +8,13 @@ #include #include +#include +#include -#include #include #include #include #include -#include SocketCanStream::SocketCanStream(QObject *parent, SocketCanStreamConfig config_) : config(config_), LiveStream(parent) { if (!available()) { @@ -82,7 +82,7 @@ bool SocketCanStream::connect() { void SocketCanStream::streamThread() { struct canfd_frame frame; - while (!QThread::currentThread()->isInterruptionRequested()) { + while (!exit_) { ssize_t nbytes = read(sock_fd, &frame, sizeof(frame)); if (nbytes <= 0) continue; @@ -128,14 +128,12 @@ OpenSocketCanWidget::OpenSocketCanWidget(QWidget *parent) : AbstractOpenStreamWi void OpenSocketCanWidget::refreshDevices() { device_edit->clear(); // Scan /sys/class/net/ for CAN interfaces (type 280 = ARPHRD_CAN) - QDir net_dir("/sys/class/net"); - for (const auto &iface : net_dir.entryList(QDir::Dirs | QDir::NoDotAndDotDot)) { - QFile type_file(net_dir.filePath(iface) + "/type"); - if (type_file.open(QIODevice::ReadOnly)) { - int type = type_file.readAll().trimmed().toInt(); - if (type == 280) { - device_edit->addItem(iface); - } + 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())); } } } diff --git a/openpilot/tools/cabana/streamselector.cc b/openpilot/tools/cabana/streamselector.cc index 75fdf384b2..7e8adc568d 100644 --- a/openpilot/tools/cabana/streamselector.cc +++ b/openpilot/tools/cabana/streamselector.cc @@ -1,5 +1,7 @@ #include "tools/cabana/streamselector.h" +#include + #include #include #include @@ -55,7 +57,7 @@ StreamSelector::StreamSelector(QWidget *parent) : QDialog(parent) { 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 = QFileInfo(fn).absolutePath().toStdString(); + settings.last_dir = std::filesystem::absolute(fn.toStdString()).parent_path().string(); } }); } diff --git a/openpilot/tools/cabana/tools/findsignal.cc b/openpilot/tools/cabana/tools/findsignal.cc index d538d676b1..4511af7c01 100644 --- a/openpilot/tools/cabana/tools/findsignal.cc +++ b/openpilot/tools/cabana/tools/findsignal.cc @@ -1,5 +1,6 @@ #include "tools/cabana/tools/findsignal.h" +#include #include #include @@ -210,13 +211,13 @@ void FindSignalDlg::search() { } void FindSignalDlg::setInitialSignals() { - QSet buses; + std::set buses; for (auto bus : bus_edit->text().trimmed().split(",")) { bus = bus.trimmed(); if (!bus.isEmpty()) buses.insert(bus.toUShort()); } - QSet addresses; + std::set addresses; for (auto addr : address_edit->text().trimmed().split(",")) { addr = addr.trimmed(); if (!addr.isEmpty()) addresses.insert(addr.toULong(nullptr, 16)); @@ -239,7 +240,7 @@ void FindSignalDlg::setInitialSignals() { model->initial_signals.clear(); for (const auto &[id, m] : can->lastMessages()) { - if ((buses.isEmpty() || buses.contains(id.source)) && (addresses.isEmpty() || addresses.contains(id.address))) { + 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()) { @@ -276,7 +277,7 @@ void FindSignalDlg::customMenuRequested(const QPoint &pos) { menu.addAction(tr("Create Signal")); if (menu.exec(view->mapToGlobal(pos))) { auto &s = model->filtered_signals[index.row()]; - UndoStack::push(new AddSigCommand(s.id, s.sig)); + UndoStack::instance()->push(new AddSigCommand(s.id, s.sig)); emit openMessage(s.id); } } diff --git a/openpilot/tools/cabana/utils/util.cc b/openpilot/tools/cabana/utils/util.cc index 5c55bcf015..87a0f89427 100644 --- a/openpilot/tools/cabana/utils/util.cc +++ b/openpilot/tools/cabana/utils/util.cc @@ -4,21 +4,21 @@ #include #include #include +#include #include #include #include +#include #include #include #include #include +#include #include #include -#include #include #include -#include -#include #include #include #include "common/util.h" @@ -271,13 +271,13 @@ QValidator::State DoubleValidator::validate(QString &input, int &pos) const { if (input.isEmpty()) return QValidator::Intermediate; // Match QString::toDouble(): C locale, no hex floats / inf / nan. - const QByteArray bytes = input.toLatin1(); + const std::string bytes = input.toLatin1().toStdString(); // strtod accepts 0x… hex floats and p-exponents; QString::toDouble does not. - if (bytes.contains('x') || bytes.contains('X') || bytes.contains('p') || bytes.contains('P')) { + if (bytes.find_first_of("xXpP") != std::string::npos) { return QValidator::Invalid; } - const char *start = bytes.constData(); + const char *start = bytes.c_str(); char *end = nullptr; const double value = std::strtod(start, &end); if (end == start) { @@ -304,6 +304,58 @@ QValidator::State DoubleValidator::validate(QString &input, int &pos) const { 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; @@ -413,20 +465,6 @@ QString signalToolTip(const cabana::Signal *sig) { .arg(sig->is_little_endian ? "Y" : "N").arg(sig->is_signed ? "Y" : "N"); } -void setSurfaceFormat() { - QSurfaceFormat fmt; -#ifdef __APPLE__ - fmt.setVersion(3, 2); - fmt.setProfile(QSurfaceFormat::OpenGLContextProfile::CoreProfile); - fmt.setRenderableType(QSurfaceFormat::OpenGL); -#else - fmt.setRenderableType(QSurfaceFormat::OpenGLES); -#endif - fmt.setSamples(16); - fmt.setStencilBufferSize(1); - QSurfaceFormat::setDefaultFormat(fmt); -} - void sigTermHandler(int s) { std::signal(s, SIG_DFL); qApp->quit(); @@ -437,57 +475,57 @@ void initApp(int argc, char *argv[], bool disable_hidpi) { std::signal(SIGINT, sigTermHandler); std::signal(SIGTERM, sigTermHandler); - QString app_dir; + 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(); + app_dir = QCoreApplication::applicationDirPath().toStdString(); if (disable_hidpi) { qputenv("QT_SCALE_FACTOR", QString::number(1.0 / tmp.devicePixelRatio()).toLocal8Bit()); } #else - app_dir = QFileInfo(util::readlink("/proc/self/exe").c_str()).path(); + app_dir = std::filesystem::path(util::readlink("/proc/self/exe")).parent_path(); #endif - qputenv("QT_DBL_CLICK_DIST", QByteArray::number(150)); + qputenv("QT_DBL_CLICK_DIST", "150"); // ensure the current dir matches the exectuable's directory - QDir::setCurrent(app_dir); - - setSurfaceFormat(); + 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 load_bootstrap_icons() { std::unordered_map icons; - QFile f(":/bootstrap-icons.svg"); - if (f.open(QIODevice::ReadOnly | QIODevice::Text)) { - std::string content = f.readAll().toStdString(); - const std::string sym_open = "(bootstrap_icons_svg), bootstrap_icons_svg_len); + const std::string sym_open = " with - svg_str.replace(0, 7, " ""); // "" (9) -> "" (6) - icons[id] = std::move(svg_str); - } + // 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 with + svg_str.replace(0, 7, " ""); // "" (9) -> "" (6) + icons[id] = std::move(svg_str); } - pos = end; } + pos = end; } return icons; } diff --git a/openpilot/tools/cabana/utils/util.h b/openpilot/tools/cabana/utils/util.h index e0318f5c16..5a3c62d118 100644 --- a/openpilot/tools/cabana/utils/util.h +++ b/openpilot/tools/cabana/utils/util.h @@ -3,12 +3,13 @@ #include #include #include +#include +#include #include #include #include #include -#include #include #include #include @@ -132,6 +133,10 @@ public: 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); @@ -140,7 +145,22 @@ inline void drawStaticText(QPainter *p, const QRect &r, const QStaticText &text) p->drawStaticText(r.left() + size.width(), r.top() + size.height(), text); } inline QString toHex(const std::vector &dat, char separator = '\0') { - return QByteArray::fromRawData((const char *)dat.data(), dat.size()).toHex(separator).toUpper(); + 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 +std::vector toBytes(const T &dat) { return {dat.begin(), dat.end()}; } +inline auto qbytes(const std::vector &dat) { + return decltype(QString().toUtf8())((const char *)dat.data(), (int)dat.size()); } } diff --git a/openpilot/tools/cabana/videowidget.cc b/openpilot/tools/cabana/videowidget.cc index 60f7b4d2b8..67b89cb080 100644 --- a/openpilot/tools/cabana/videowidget.cc +++ b/openpilot/tools/cabana/videowidget.cc @@ -356,8 +356,8 @@ void StreamCameraView::parseQLog(std::shared_ptr qlog) { update(); } -void StreamCameraView::paintGL() { - CameraWidget::paintGL(); +void StreamCameraView::paintEvent(QPaintEvent *event) { + CameraWidget::paintEvent(event); QPainter p(this); bool scrubbing = false; diff --git a/openpilot/tools/cabana/videowidget.h b/openpilot/tools/cabana/videowidget.h index d45695904e..09f7a9931b 100644 --- a/openpilot/tools/cabana/videowidget.h +++ b/openpilot/tools/cabana/videowidget.h @@ -35,7 +35,7 @@ class StreamCameraView : public CameraWidget { public: StreamCameraView(std::string stream_name, VisionStreamType stream_type, QWidget *parent = nullptr); - void paintGL() override; + void paintEvent(QPaintEvent *event) override; void parseQLog(std::shared_ptr qlog); private: diff --git a/tools/setup_dependencies.sh b/tools/setup_dependencies.sh index 80f3ac4e45..b76fdc7dde 100755 --- a/tools/setup_dependencies.sh +++ b/tools/setup_dependencies.sh @@ -44,7 +44,7 @@ function install_linux_deps() { echo "[ ] system packages already installed t=$SECONDS" elif command -v apt-get > /dev/null 2>&1; then $SUDO apt-get update - $SUDO apt-get install -y --no-install-recommends ca-certificates build-essential curl libcurl4-openssl-dev locales git + $SUDO apt-get install -y --no-install-recommends ca-certificates build-essential curl libcurl4-openssl-dev locales git xclip wl-clipboard elif command -v dnf > /dev/null 2>&1; then $SUDO dnf install -y ca-certificates gcc gcc-c++ make curl libcurl-devel glibc-langpack-en git elif command -v yum > /dev/null 2>&1; then From 3a55f31dc5ad69a732950531997e970a66374e14 Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Fri, 17 Jul 2026 15:24:34 -0700 Subject: [PATCH 07/35] agnos 18.5 (#38302) --- launch_env.sh | 2 +- openpilot/common/hardware/tici/agnos.json | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/launch_env.sh b/launch_env.sh index d71a5c2767..3e0ca602b4 100755 --- a/launch_env.sh +++ b/launch_env.sh @@ -16,7 +16,7 @@ export VECLIB_MAXIMUM_THREADS=1 export QCOM_PRIORITY=12 if [ -z "$AGNOS_VERSION" ]; then - export AGNOS_VERSION="18.4" + export AGNOS_VERSION="18.5" fi export STAGING_ROOT="/data/safe_staging" diff --git a/openpilot/common/hardware/tici/agnos.json b/openpilot/common/hardware/tici/agnos.json index 07e2079ec9..f22a57db75 100644 --- a/openpilot/common/hardware/tici/agnos.json +++ b/openpilot/common/hardware/tici/agnos.json @@ -56,28 +56,28 @@ }, { "name": "boot", - "url": "https://commadist.azureedge.net/agnosupdate/boot-8806802b195a5b1396a3ae8dd92a8b7711dc522f6aceafd820e871bae5c8a6d8.img.xz", - "hash": "8806802b195a5b1396a3ae8dd92a8b7711dc522f6aceafd820e871bae5c8a6d8", - "hash_raw": "8806802b195a5b1396a3ae8dd92a8b7711dc522f6aceafd820e871bae5c8a6d8", + "url": "https://commadist.azureedge.net/agnosupdate/boot-19ff57b68e219e4503fcaca716967098d5d0a1de8af833f04dbf13b99aeb4d39.img.xz", + "hash": "19ff57b68e219e4503fcaca716967098d5d0a1de8af833f04dbf13b99aeb4d39", + "hash_raw": "19ff57b68e219e4503fcaca716967098d5d0a1de8af833f04dbf13b99aeb4d39", "size": 17487872, "sparse": false, "full_check": true, "has_ab": true, - "ondevice_hash": "edca8bee1531e66953d107eeceeed2dc7b3ca46417e49d55508f94e58bf95db8" + "ondevice_hash": "ddfe93cc6a8531af92ee331d9bbaeae2f1d933bdb38e579769dc9fe7998eb626" }, { "name": "system", - "url": "https://commadist.azureedge.net/agnosupdate/system-ef0d879302cb29e72110e9c8d3f947c830fd7d37c8192744fc9dbea1af78501f.img.xz", - "hash": "78acfe16a7b62a3a91fc7a81f40a693e4468cec1c69df7d0b1e550aacc646113", - "hash_raw": "ef0d879302cb29e72110e9c8d3f947c830fd7d37c8192744fc9dbea1af78501f", + "url": "https://commadist.azureedge.net/agnosupdate/system-a396dd98ffd49614fb198d1b022a0c7a6d0a1e563c20ce11b0a975105ab50724.img.xz", + "hash": "4dc41c2c072f5f5d5cd484cd6173049cd96acfb9a67bc20049775585fe881539", + "hash_raw": "a396dd98ffd49614fb198d1b022a0c7a6d0a1e563c20ce11b0a975105ab50724", "size": 4718592000, "sparse": true, "full_check": false, "has_ab": true, - "ondevice_hash": "743142c5a898f27b2a1029cca42c8a5d5d1fc0096414422b850fe84c8d0b8342", + "ondevice_hash": "cf1229630b7a2b8497705bca4ba947dbf0c217418ff4febff571aa4f4a878134", "alt": { - "hash": "ef0d879302cb29e72110e9c8d3f947c830fd7d37c8192744fc9dbea1af78501f", - "url": "https://commadist.azureedge.net/agnosupdate/system-ef0d879302cb29e72110e9c8d3f947c830fd7d37c8192744fc9dbea1af78501f.img", + "hash": "a396dd98ffd49614fb198d1b022a0c7a6d0a1e563c20ce11b0a975105ab50724", + "url": "https://commadist.azureedge.net/agnosupdate/system-a396dd98ffd49614fb198d1b022a0c7a6d0a1e563c20ce11b0a975105ab50724.img", "size": 4718592000 } } From c8786d930d3fbe936e184c917769306518eaedeb Mon Sep 17 00:00:00 2001 From: ZwX1616 Date: Fri, 17 Jul 2026 15:27:20 -0700 Subject: [PATCH 08/35] DM: reasonable lockout ramp up (#38358) --- openpilot/cereal/log.capnp | 3 +- openpilot/common/params_keys.h | 1 + openpilot/selfdrive/monitoring/policy.py | 30 ++++++++++++------- .../selfdrive/monitoring/test_monitoring.py | 14 ++++----- openpilot/selfdrive/selfdrived/events.py | 6 ++-- 5 files changed, 29 insertions(+), 25 deletions(-) diff --git a/openpilot/cereal/log.capnp b/openpilot/cereal/log.capnp index 8ce9f868af..e1725d1e44 100644 --- a/openpilot/cereal/log.capnp +++ b/openpilot/cereal/log.capnp @@ -2160,7 +2160,8 @@ struct DriverMonitoringStateDEPRECATED @0xb83cda094a1da284 { struct DriverMonitoringState { lockout @0 :Bool; - lockoutRecoveryPercent @11 :Int8; + lockoutCount @15 :Int8; + lockoutMinutesRemaining @11 :Int8; alert3Count @12 :Int8; noResponseCount @13 :Int8; noResponseForceDecel @14 :Bool; diff --git a/openpilot/common/params_keys.h b/openpilot/common/params_keys.h index 7efa5721dc..0615c02700 100644 --- a/openpilot/common/params_keys.h +++ b/openpilot/common/params_keys.h @@ -37,6 +37,7 @@ inline static std::unordered_map keys = { {"DoShutdown", {CLEAR_ON_MANAGER_START, BOOL}}, {"DoUninstall", {CLEAR_ON_MANAGER_START, BOOL}}, {"DriverTooDistracted", {CLEAR_ON_MANAGER_START | CLEAR_ON_IGNITION_ON, BOOL}}, + {"DriverLockoutCount", {CLEAR_ON_MANAGER_START | CLEAR_ON_IGNITION_ON, INT, "0"}}, {"AlphaLongitudinalEnabled", {PERSISTENT | DEVELOPMENT_ONLY, BOOL}}, {"ExperimentalMode", {PERSISTENT, BOOL}}, {"ExperimentalModeConfirmed", {PERSISTENT, BOOL}}, diff --git a/openpilot/selfdrive/monitoring/policy.py b/openpilot/selfdrive/monitoring/policy.py index 03db1dc02e..1ce9f03b3e 100644 --- a/openpilot/selfdrive/monitoring/policy.py +++ b/openpilot/selfdrive/monitoring/policy.py @@ -41,7 +41,7 @@ class DRIVER_MONITOR_SETTINGS: # lockout specs self._MAX_ALERT_3 = 2 self._MAX_NO_RESPONSE = 1 - self._LOCKOUT_TIME = int(1800 / DT_DMON) + self._LOCKOUT_TIMES = [int(60 * n_min / DT_DMON) for n_min in [1, 5, 15, 30]] self._TIMEOUT_RECOVERY_FACTOR_MAX = 5. self._TIMEOUT_RECOVERY_FACTOR_MIN = 1.25 @@ -152,7 +152,10 @@ class DriverMonitoring: self.cnt_since_alert_3 = 0 self.no_response_timeout = int(self.settings._NO_RESPONSE_TIMEOUT / DT_DMON) self.no_response_cnt = 0 - self.lockout_time = 0 + self.lockout_active = Params().get_bool("DriverTooDistracted") + self.lockout_count = Params().get("DriverLockoutCount") or 0 + self.lockout_duration = self.settings._LOCKOUT_TIMES[min(max(self.lockout_count - 1, 0), len(self.settings._LOCKOUT_TIMES) - 1)] + self.lockout_time_elapsed = 0 self.step_change = 0. self.active_policy = MonitoringPolicy.vision self.driver_interacting = False @@ -163,7 +166,6 @@ class DriverMonitoring: self.threshold_alert_2 = 0. self.dcam_uncertain_cnt = 0 self.dcam_reset_cnt = 0 - self.too_distracted = Params().get_bool("DriverTooDistracted") self._reset_awareness() self._set_policy(MonitoringPolicy.vision) @@ -310,16 +312,20 @@ class DriverMonitoring: self.driver_interacting = driver_engaged if self.alert_3_cnt >= self.settings._MAX_ALERT_3 or self.no_response_cnt >= self.settings._MAX_NO_RESPONSE: - self.too_distracted = True + if not self.lockout_active: + self.lockout_count += 1 + self.lockout_duration = self.settings._LOCKOUT_TIMES[min(self.lockout_count - 1, len(self.settings._LOCKOUT_TIMES) - 1)] + Params().put("DriverLockoutCount", self.lockout_count) + self.lockout_active = True - if self.too_distracted: - self.lockout_time += 1 - if self.lockout_time > self.settings._LOCKOUT_TIME: - self.too_distracted = False + if self.lockout_active: + self.lockout_time_elapsed += 1 + if self.lockout_time_elapsed > self.lockout_duration: + self.lockout_active = False self.alert_3_cnt = 0 self.cnt_since_alert_3 = 0 self.no_response_cnt = 0 - self.lockout_time = 0 + self.lockout_time_elapsed = 0 always_on_valid = self.always_on and not wrong_gear if (self.driver_interacting and self.awareness > 0 and self.active_policy == MonitoringPolicy.wheeltouch) or \ @@ -379,8 +385,10 @@ class DriverMonitoring: dat = messaging.new_message('driverMonitoringState', valid=valid) dm = dat.driverMonitoringState - dm.lockout = self.too_distracted - dm.lockoutRecoveryPercent = to_percent(self.lockout_time / self.settings._LOCKOUT_TIME) + dm.lockout = self.lockout_active + dm.lockoutCount = self.lockout_count + if self.lockout_active: + dm.lockoutMinutesRemaining = max(1, round((self.lockout_duration - self.lockout_time_elapsed) * DT_DMON / 60.)) dm.alert3Count = self.alert_3_cnt dm.noResponseCount = self.no_response_cnt dm.noResponseForceDecel = self.alert_level == AlertLevel.three and self.cnt_since_alert_3 >= self.no_response_timeout diff --git a/openpilot/selfdrive/monitoring/test_monitoring.py b/openpilot/selfdrive/monitoring/test_monitoring.py index 48fffcc1c0..0368dc9b67 100644 --- a/openpilot/selfdrive/monitoring/test_monitoring.py +++ b/openpilot/selfdrive/monitoring/test_monitoring.py @@ -83,21 +83,17 @@ class TestMonitoring: # engaged, distracted past red and beyond the no-response window -> unavailability response + lockout def test_distracted_lockout(self): alert_lvls, d_status = self._run_seq(always_distracted, always_false, always_true, always_false) - s = d_status.settings assert alert_lvls[int(DISTRACTED_SECONDS_TO_RED / DT_DMON)] == 3 - assert d_status.alert_3_cnt == 1 - assert d_status.no_response_cnt == s._MAX_NO_RESPONSE - assert d_status.too_distracted - assert d_status.lockout_time > 0 + assert d_status.lockout_active + assert d_status.lockout_time_elapsed > 0 + assert d_status.lockout_count >= 1 # no face -> wheeltouch red, sustained past the no-response timeout -> unavailability response + lockout def test_invisible_lockout(self): _, d_status = self._run_seq(always_no_face, always_false, always_true, always_false) - s = d_status.settings assert d_status.active_policy == log.DriverMonitoringState.MonitoringPolicy.wheeltouch - assert d_status.alert_3_cnt == 1 - assert d_status.no_response_cnt == s._MAX_NO_RESPONSE - assert d_status.too_distracted + assert d_status.lockout_active + assert d_status.lockout_count >= 1 # engaged, no face detected the whole time, no action def test_fully_invisible_driver(self): diff --git a/openpilot/selfdrive/selfdrived/events.py b/openpilot/selfdrive/selfdrived/events.py index 00d0334ada..367bc03d33 100755 --- a/openpilot/selfdrive/selfdrived/events.py +++ b/openpilot/selfdrive/selfdrived/events.py @@ -10,9 +10,8 @@ from opendbc.car.structs import car import openpilot.cereal.messaging as messaging from openpilot.common.constants import CV from openpilot.common.git import get_short_branch -from openpilot.common.realtime import DT_CTRL, DT_DMON +from openpilot.common.realtime import DT_CTRL from openpilot.selfdrive.locationd.calibrationd import MIN_SPEED_FILTER -from openpilot.selfdrive.monitoring.policy import DRIVER_MONITOR_SETTINGS from openpilot.system.micd import SAMPLE_RATE, SAMPLE_BUFFER from openpilot.selfdrive.ui.feedback.feedbackd import FEEDBACK_MAX_DURATION from openpilot.common.hardware import HARDWARE @@ -23,7 +22,6 @@ VisualAlert = car.CarControl.HUDControl.VisualAlert AudibleAlert = log.SelfdriveState.AudibleAlert EventName = log.OnroadEvent.EventName -DMON_LOCKOUT_TIME = DRIVER_MONITOR_SETTINGS()._LOCKOUT_TIME # Alert priorities class Priority(IntEnum): @@ -269,7 +267,7 @@ def calibration_incomplete_alert(CP: car.CarParams, CS: car.CarState, sm: messag def too_distracted_alert(CP: car.CarParams, CS: car.CarState, sm: messaging.SubMaster, metric: bool, soft_disable_time: int, personality) -> Alert: if sm['driverMonitoringState'].lockout: - mins_left = max(1, round((100 - sm['driverMonitoringState'].lockoutRecoveryPercent) / 100 * DMON_LOCKOUT_TIME * DT_DMON / 60.)) + mins_left = sm['driverMonitoringState'].lockoutMinutesRemaining return NoEntryAlert("Too Distracted", f"{mins_left} minute{'s' if mins_left != 1 else ''} Left", priority=Priority.HIGH) return NoEntryAlert("Pay Attention to Engage", priority=Priority.HIGH) From b9f25f8a43bf6f47921ba8270810fb8315351e8f Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Fri, 17 Jul 2026 15:28:39 -0700 Subject: [PATCH 09/35] webrtcd: move to libdatachannel (#38228) * try libdatachannel * fix uv lock * fix uv lock * remove datachannel abstraction and just use libdatachannel * clean * fix livestream bitrate controller * fix sample * fix import breaking ci * clean up and add catches * bump tele * move to teleop master * fix linter * . * remove libdatachannel explicit reference from pyproject.toml * add PyJWT crypto and catch in registration * spelling * add crypto back to lock --------- Co-authored-by: stefpi <19478336+stefpi@users.noreply.github.com> --- openpilot/system/athena/registration.py | 3 + openpilot/system/webrtc/device/video.py | 10 +- .../webrtc/tests/test_stream_session.py | 12 +- openpilot/system/webrtc/webrtcd.py | 109 +++--- pyproject.toml | 3 +- teleoprtc_repo | 2 +- uv.lock | 315 ++---------------- 7 files changed, 75 insertions(+), 379 deletions(-) diff --git a/openpilot/system/athena/registration.py b/openpilot/system/athena/registration.py index 0e8d4bc49e..35051c0659 100755 --- a/openpilot/system/athena/registration.py +++ b/openpilot/system/athena/registration.py @@ -82,6 +82,9 @@ def register(show_spinner=False) -> str | None: dongleauth = json.loads(resp.text) dongle_id = dongleauth["dongle_id"] break + except NotImplementedError: + # dependency issues with PyJWT will hang the registration test in backoff loop otherwise + raise except Exception: cloudlog.exception("failed to authenticate") backoff = min(backoff + 1, 15) diff --git a/openpilot/system/webrtc/device/video.py b/openpilot/system/webrtc/device/video.py index 3c8a7b93c2..a67247bc3c 100644 --- a/openpilot/system/webrtc/device/video.py +++ b/openpilot/system/webrtc/device/video.py @@ -4,7 +4,6 @@ import time import av from teleoprtc.tracks import TiciVideoStreamTrack -from aiortc import MediaStreamError from openpilot.cereal import messaging from openpilot.common.realtime import DT_MDL @@ -55,6 +54,9 @@ class LiveStreamVideoStreamTrack(TiciVideoStreamTrack): if not enabled: self._seen_keyframe = False + def request_keyframe(self) -> None: + self.params.put("LivestreamRequestKeyframe", True, block=False) + def _build_frame_data(self, msg) -> bytes: encode_data = getattr(msg, msg.which()) if not self.timing_sei_enabled: @@ -71,9 +73,6 @@ class LiveStreamVideoStreamTrack(TiciVideoStreamTrack): async def recv(self): while True: - if self.readyState != "live": - raise MediaStreamError - # while video is disabled, pause here without returning if not self.video_enabled: await asyncio.sleep(0.005) @@ -95,6 +94,3 @@ class LiveStreamVideoStreamTrack(TiciVideoStreamTrack): self.log_debug("track sending frame %d", self._pts) return packet - - def codec_preference(self) -> str | None: - return "H264" diff --git a/openpilot/system/webrtc/tests/test_stream_session.py b/openpilot/system/webrtc/tests/test_stream_session.py index e03932f7b2..7e95097c87 100644 --- a/openpilot/system/webrtc/tests/test_stream_session.py +++ b/openpilot/system/webrtc/tests/test_stream_session.py @@ -1,15 +1,10 @@ import asyncio import json import time -# for aiortc and its dependencies -import warnings -warnings.filterwarnings("ignore", category=DeprecationWarning) -warnings.filterwarnings("ignore", category=RuntimeWarning) # TODO: remove this when google-crc32c publish a python3.12 wheel -from aiortc import RTCDataChannel -from aiortc.mediastreams import VIDEO_CLOCK_RATE, VIDEO_TIME_BASE import capnp from openpilot.cereal import messaging, log +from teleoprtc.tracks import VIDEO_CLOCK_RATE, VIDEO_TIME_BASE from openpilot.system.webrtc.webrtcd import CerealOutgoingMessageProxy, CerealIncomingMessageProxy from openpilot.system.webrtc.device.video import LiveStreamVideoStreamTrack @@ -31,7 +26,8 @@ class TestStreamSession: expected_dict = {"type": "customReservedRawData0", "logMonoTime": 123, "valid": True, "data": "test"} expected_json = json.dumps(expected_dict).encode() - channel = mocker.Mock(spec=RTCDataChannel) + channel = mocker.Mock() + channel.is_open.return_value = True proxy = CerealOutgoingMessageProxy(["customReservedRawData0"]) def mocked_update(t): proxy.sm.update_msgs(0, [test_msg]) @@ -73,7 +69,6 @@ class TestStreamSession: track = LiveStreamVideoStreamTrack("driver") assert track.id.startswith("driver") - assert track.codec_preference() == "H264" for i in range(5): packet = self.loop.run_until_complete(track.recv()) @@ -83,4 +78,3 @@ class TestStreamSession: start_pts = packet.pts assert abs(i + packet.pts - (start_pts + (((time.monotonic_ns() - start_ns) * VIDEO_CLOCK_RATE) // 1_000_000_000))) < 450 #5ms assert packet.size == 0 - diff --git a/openpilot/system/webrtc/webrtcd.py b/openpilot/system/webrtc/webrtcd.py index bf96cd04ba..8276df98f2 100755 --- a/openpilot/system/webrtc/webrtcd.py +++ b/openpilot/system/webrtc/webrtcd.py @@ -1,9 +1,11 @@ #!/usr/bin/env python3 from abc import abstractmethod +from collections.abc import Callable import os import socket import time +import capnp import argparse import asyncio import contextlib @@ -14,17 +16,7 @@ import signal import threading from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import urlparse, parse_qs -from typing import Any, TYPE_CHECKING - -# aiortc and its dependencies have lots of internal warnings :( -import warnings -warnings.filterwarnings("ignore", category=DeprecationWarning) -warnings.filterwarnings("ignore", category=RuntimeWarning) # TODO: remove this when google-crc32c publish a python3.12 wheel - -import capnp -if TYPE_CHECKING: - from aiortc.rtcdatachannel import RTCDataChannel -import aioice.ice +from typing import Any from openpilot.system.webrtc.helpers import StreamRequestBody from openpilot.system.webrtc.schema import generate_field @@ -44,20 +36,8 @@ def _default_route_ip() -> str | None: finally: s.close() -# aioice patch: gather ICE candidates only on the default-route interface -_get_host_addresses = aioice.ice.get_host_addresses -def _primary_host_addresses(use_ipv4: bool, use_ipv6: bool) -> list[str]: - addresses = _get_host_addresses(use_ipv4, use_ipv6) - primary = _default_route_ip() - if primary not in addresses: - return addresses - return [primary, ] -aioice.ice.get_host_addresses = _primary_host_addresses - - class AsyncTaskRunner: def __init__(self): - self.is_running = False self.task = None self.logger = logging.getLogger("webrtcd") @@ -86,10 +66,10 @@ class CerealOutgoingMessageProxy(AsyncTaskRunner): super().__init__() self.services = list(services) self.sm = messaging.SubMaster(self.services) - self.channels: list[RTCDataChannel] = [] + self.channels = [] self._enabled = enabled - def add_channel(self, channel: 'RTCDataChannel'): + def add_channel(self, channel): self.channels.append(channel) def enable(self, enable: bool): @@ -118,20 +98,17 @@ class CerealOutgoingMessageProxy(AsyncTaskRunner): outgoing_msg = {"type": service, "logMonoTime": mono_time, "valid": valid, "data": msg_dict} encoded_msg = json.dumps(outgoing_msg).encode() for channel in self.channels: + if not channel.is_open(): + continue channel.send(encoded_msg) async def run(self): - from aiortc.exceptions import InvalidStateError - while True: if not self._enabled: await asyncio.sleep(0.01) continue try: self.update() - except InvalidStateError: - self.logger.warning("Cereal outgoing proxy invalid state (connection closed)") - break except Exception: self.logger.exception("Cereal outgoing proxy failure") await asyncio.sleep(0.01) @@ -172,17 +149,17 @@ class LivestreamBitrateController(AsyncTaskRunner): high_level = 0.1 # drop immediately med_level = 0.05 # drop after # of samples low_level = 0 # raise after # of samples - down_samples = 5 # 1s + down_samples = 5 param_name = "LivestreamEncoderBitrate" - def __init__(self, peer_connection: Any, params: Params, enabled: bool = True): + def __init__(self, get_stats: Callable[[], dict[str, Any]], params: Params, enabled: bool = True): super().__init__() - self.pc = peer_connection + self.get_stats = get_stats self.params = params self.level = 2 self._publish(self.bitrates[self.level]) - self.prev_lost, self.prev_sent = None, None + self.prev_stats: tuple[Any, ...] | None = None self.counter = 0 self.up_samples = 5 # 1s self._auto = True @@ -199,7 +176,7 @@ class LivestreamBitrateController(AsyncTaskRunner): if not self._auto: continue - loss_rate = await self._sample() + loss_rate = self._sample() if loss_rate is None: continue if loss_rate >= self.med_level and self.level > 0: @@ -216,22 +193,18 @@ class LivestreamBitrateController(AsyncTaskRunner): self.counter = 0 self._publish(self.bitrates[self.level]) - async def _sample(self) -> float | None: - report = await self.pc.getStats() - packets_lost = packets_sent = 0 - for s in report.values(): - if s.type == "remote-inbound-rtp": - packets_lost += s.packetsLost - elif s.type == "outbound-rtp": - packets_sent += s.packetsSent - - if self.prev_lost is None: - self.prev_lost, self.prev_sent = packets_lost, packets_sent + def _sample(self) -> float | None: + report = next(iter(self.get_stats().values()), None) + if report is None: return None - lost_delta = max(0, packets_lost - self.prev_lost) - sent_delta = max(0, packets_sent - self.prev_sent) - self.prev_lost, self.prev_sent = packets_lost, packets_sent - return lost_delta / sent_delta if sent_delta else 0.0 + + current = (report.ssrc, report.fraction_lost, report.packets_lost, report.highest_seq_no, report.jitter, report.lsr, report.dlsr) + if self.prev_stats == current: + return None + self.prev_stats = current + + loss_rate = report.fraction_lost / 256 + return loss_rate def _publish(self, bitrate: float): self.params.put(self.param_name, bitrate) @@ -248,17 +221,15 @@ class StreamSession: shared_pub_master = DynamicPubMaster([]) def __init__(self, body: StreamRequestBody, debug_mode: bool = False): - if debug_mode: - from aiortc.mediastreams import VideoStreamTrack from openpilot.system.webrtc.device.video import LiveStreamVideoStreamTrack from teleoprtc.builder import WebRTCAnswerBuilder self.identifier = str(uuid.uuid4()) self.params = Params() - builder = WebRTCAnswerBuilder(body.sdp) + builder = WebRTCAnswerBuilder(body.sdp, bind_address=_default_route_ip()) self.enabled = body.enabled - self.video_track = LiveStreamVideoStreamTrack(body.init_camera, self.enabled) if not debug_mode else VideoStreamTrack() + self.video_track = LiveStreamVideoStreamTrack(body.init_camera, self.enabled) builder.add_video_stream(body.init_camera, self.video_track) self.stream = builder.stream() @@ -270,7 +241,7 @@ class StreamSession: self.incoming_bridge = CerealIncomingMessageProxy(self.shared_pub_master) if len(body.bridge_services_out) > 0: self.outgoing_bridge = CerealOutgoingMessageProxy(body.bridge_services_out, self.enabled) - self.bitrate_controller = LivestreamBitrateController(self.stream.peer_connection, self.params, self.enabled) + self.bitrate_controller = LivestreamBitrateController(self.stream.get_receiver_report_stats, self.params, self.enabled) self.run_task: asyncio.Task | None = None self._cleanup_lock = asyncio.Lock() @@ -305,13 +276,16 @@ class StreamSession: case "livestreamCameraSwitch": self.video_track.switch_camera(payload["data"]["camera"]) case "livestreamSettings": - self.bitrate_controller.set_quality(payload["data"]["quality"]) + if self.bitrate_controller is not None: + self.bitrate_controller.set_quality(payload["data"]["quality"]) case "livestreamVideoEnable": enabled = payload["data"]["enabled"] self.enabled = enabled self.video_track.enable(enabled) - self.outgoing_bridge.enable(enabled) - self.bitrate_controller.enable(enabled) + if self.outgoing_bridge is not None: + self.outgoing_bridge.enable(enabled) + if self.bitrate_controller is not None: + self.bitrate_controller.enable(enabled) if not enabled: self.params.put("LivestreamRequestKeyframe", True) case "clockSync": @@ -325,7 +299,8 @@ class StreamSession: case _: if payload.get("type") not in self.incoming_bridge_services: return - self.incoming_bridge.send(message) + if self.incoming_bridge is not None: + self.incoming_bridge.send(message) except Exception: self.logger.exception("Cereal incoming proxy failure") @@ -341,7 +316,8 @@ class StreamSession: channel = self.stream.get_messaging_channel() self.outgoing_bridge.add_channel(channel) self.outgoing_bridge.start() - self.bitrate_controller.start() + if self.bitrate_controller is not None: + self.bitrate_controller.start() self.logger.info("Stream session (%s) connected", self.identifier) await self.stream.wait_for_disconnection() @@ -357,7 +333,8 @@ class StreamSession: return self._cleanup_done = True self.params.put("LivestreamRequestKeyframe", False) - await self.bitrate_controller.stop() + if self.bitrate_controller is not None: + await self.bitrate_controller.stop() if self.outgoing_bridge is not None: await self.outgoing_bridge.stop() if self.video_track is not None: @@ -417,7 +394,12 @@ async def handle_get_stream(state: ServerState, raw_body: bytes) -> tuple[int, b session = StreamSession(body, debug_mode) stream_dict[session.identifier] = session try: - answer = await session.get_answer() + answer = await asyncio.wait_for(session.get_answer(), timeout=30) + except TimeoutError: + await session.stop() + stream_dict.pop(session.identifier, None) + logging.getLogger("webrtcd").exception("Timed out creating stream answer") + raise except Exception: await session.stop() stream_dict.pop(session.identifier, None) @@ -558,9 +540,6 @@ async def _shutdown(server: WebrtcdHTTPServer, state: ServerState, loop: asyncio def prewarm_stream_session_imports(debug_mode: bool = False) -> None: - if debug_mode: - from aiortc.mediastreams import VideoStreamTrack - assert VideoStreamTrack from openpilot.system.webrtc.device.video import LiveStreamVideoStreamTrack from teleoprtc.builder import WebRTCAnswerBuilder assert LiveStreamVideoStreamTrack diff --git a/pyproject.toml b/pyproject.toml index 51f07d3870..de356e4b6f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,6 @@ dependencies = [ # body / webrtcd "av", - "aiortc", # logging "pyzmq", @@ -47,7 +46,7 @@ dependencies = [ "xattr", # used in place of 'os.getxattr' for macOS compatibility # athena - "PyJWT", + "PyJWT[crypto]", "websocket_client", # joystickd diff --git a/teleoprtc_repo b/teleoprtc_repo index 22df577821..c0f813f1c4 160000 --- a/teleoprtc_repo +++ b/teleoprtc_repo @@ -1 +1 @@ -Subproject commit 22df577821862e32a011fb0cf42577599f3a79c4 +Subproject commit c0f813f1c4f7e2d29bc0ed6a4d2a7b3268511b0f diff --git a/uv.lock b/uv.lock index bb6a1cb59c..d771c2aacb 100644 --- a/uv.lock +++ b/uv.lock @@ -8,95 +8,6 @@ overrides = [ { name = "opendbc", editable = "opendbc_repo" }, ] -[[package]] -name = "aiohappyeyeballs" -version = "2.7.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", size = 24757, upload-time = "2026-07-01T17:11:55.501Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", size = 15038, upload-time = "2026-07-01T17:11:54.055Z" }, -] - -[[package]] -name = "aiohttp" -version = "3.14.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aiohappyeyeballs" }, - { name = "aiosignal" }, - { name = "attrs" }, - { name = "frozenlist" }, - { name = "multidict" }, - { name = "propcache" }, - { name = "typing-extensions" }, - { name = "yarl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/21/151624b51cd92553d95424daf4bf19f19ce9be9002d19253e7e7ce67197b/aiohttp-3.14.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d35143e27778b4bb0fb189562d7f275bff79c62ab8e98459717c0ea617ff2480", size = 757402, upload-time = "2026-06-07T21:06:40.311Z" }, - { url = "https://files.pythonhosted.org/packages/c2/82/280619e0bd7bf2454987e19282616e84762255dd9c8468f62382e8c191f1/aiohttp-3.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bcfb80a2cc36fba2534e5e5b5264dc7ae6fcd9bf15256da3e53d2f499e6fa29d", size = 512310, upload-time = "2026-06-07T21:06:42.207Z" }, - { url = "https://files.pythonhosted.org/packages/55/b2/2aac325583aaa1353045f96dffa586d8a34e8322e14a7ba49cffeb103ab4/aiohttp-3.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27fd7c91e51729b4f7e1577865fa6d34c9adccbc39aabe9000285b48af9f0ec2", size = 512448, upload-time = "2026-06-07T21:06:43.813Z" }, - { url = "https://files.pythonhosted.org/packages/8a/72/a60607cb849faa8af8a356c9329ea2eb6f395d49e82cc82ccba1fd8deb8f/aiohttp-3.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c567bf9eaf664280116a8688f63016e6b32db2505908e2bdaca1b6438142f2", size = 1766854, upload-time = "2026-06-07T21:06:45.391Z" }, - { url = "https://files.pythonhosted.org/packages/b5/d3/d9fe1c9ec7557ab4d0d82bebaa728c6418f0b93295ec2f4ab015f7710cc7/aiohttp-3.14.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f5e6ff2bdbb8f4cd3fbe41f99e25bbcd58e3bf9f13d3dd31a11e7917251cc77a", size = 1740884, upload-time = "2026-06-07T21:06:47.413Z" }, - { url = "https://files.pythonhosted.org/packages/c1/dc/f2cecfaf9337ba3e63f181500814ff502aa3d00d9c7ec93a9d23d10a27b2/aiohttp-3.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f73e01dc37122325caf079982621262f96d74823c179038a82fddfc50359264", size = 1810034, upload-time = "2026-06-07T21:06:50.165Z" }, - { url = "https://files.pythonhosted.org/packages/66/d7/2ff65c5e65c0d7476daf7e15c032e0805e36811185b9623e3238ad6c763e/aiohttp-3.14.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb2c0c80d431c0d03f2c7dbf125150fedd4f0de17366a7ca33f7ccb822391842", size = 1904054, upload-time = "2026-06-07T21:06:52.035Z" }, - { url = "https://files.pythonhosted.org/packages/20/9c/d445818389df371f56d141d881153ba23183c4735a03f7356ffb43f7757d/aiohttp-3.14.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e6fc1a85fa7194a1a7d19f44e8609180f4a8eb5fa4c7ed8b4355f080fad235c", size = 1790278, upload-time = "2026-06-07T21:06:54.049Z" }, - { url = "https://files.pythonhosted.org/packages/4d/aa/bf04cb4d865fc6101c2229a294ad744973b72e513fdc5a6b791e6983d72a/aiohttp-3.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:686b6c0d3911ec387b444ddf5dc62fb7f7c0a7d5186a7861626496a5ab4aff95", size = 1591795, upload-time = "2026-06-07T21:06:55.911Z" }, - { url = "https://files.pythonhosted.org/packages/dc/b4/4dac0038960427ba832f6609dfb4ea5437d7fd80c72001b9e48f834f428b/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c6fa4dc7ad6f8109c70bb1499e589f76b0b792baf39f9b017eb92c8a81d0a199", size = 1728397, upload-time = "2026-06-07T21:06:57.777Z" }, - { url = "https://files.pythonhosted.org/packages/2b/f9/7cd4e8ad7aa3b75f17d56bb5498dd604a93d4e6eece822ba0568c413fff0/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:87a5eea1b2a5e21e1ebdbb33ad4165359189327e63fc4e4894693e7f821ac817", size = 1766504, upload-time = "2026-06-07T21:07:00.009Z" }, - { url = "https://files.pythonhosted.org/packages/f9/df/fc01d9fcad0f73fed3f3d361f1f94f975947b50dff82919f6dc2bf4316cc/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c1421eb01d4fd608d88cc8290211d177a58532b55ad94076fb349c5bf467f0a", size = 1777806, upload-time = "2026-06-07T21:07:02.064Z" }, - { url = "https://files.pythonhosted.org/packages/41/09/47e2d090bddcc8fb4ccb4c314aadc32d7c5d9bb55f50f6ad1c92fc15d501/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:34b257ec41345c1e8f2df68fa908a7952f5de932723871eb633ecbbff396c9a4", size = 1580707, upload-time = "2026-06-07T21:07:03.942Z" }, - { url = "https://files.pythonhosted.org/packages/3d/36/f1a4ce904ae0b6930cfe9afc96d0896f7ec1a620c400405d63783bb95a9c/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:de538791a80e5d862addbc183f70f0158ac9b9bb872bb147f1fd2a683691e087", size = 1798121, upload-time = "2026-06-07T21:07:05.987Z" }, - { url = "https://files.pythonhosted.org/packages/70/0a/e0075ce9ca0279ee1d4f0c0b85f54fea02ebc83c3007651a72bece658fec/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f71173be42d3241d428f760122febb748de0623f44308a6f120d0dd9ec572e3", size = 1767580, upload-time = "2026-06-07T21:07:07.873Z" }, - { url = "https://files.pythonhosted.org/packages/3e/61/a0c0a8f327a9c52095cdd8e312391b00d3ed64ab6c72bb5c33d8ec251cf7/aiohttp-3.14.1-cp312-cp312-win32.whl", hash = "sha256:ec8dc383ee57ea3e883477dcca3f11b65d58199f1080acaf4cd6ad9a99698be4", size = 452771, upload-time = "2026-06-07T21:07:09.669Z" }, - { url = "https://files.pythonhosted.org/packages/df/d9/ea367c75f16ac9c6cdc8febb25e8318fa21a2b1bc8d6514d4b2d890bface/aiohttp-3.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:2aa92c87868cd13674989f9ee83e5f9f7ea4237589b728048e1f0c8f6caa3271", size = 479873, upload-time = "2026-06-07T21:07:11.538Z" }, - { url = "https://files.pythonhosted.org/packages/03/64/8d96784a7851156db8a4c6c3f6f91042fdf39fb15a4cc38c8b3c14833c45/aiohttp-3.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:2c840c90759922cb5e6dda94596e079a30fb5a5ba548e7e0dc00574703940847", size = 448073, upload-time = "2026-06-07T21:07:13.637Z" }, -] - -[[package]] -name = "aioice" -version = "0.10.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "dnspython" }, - { name = "ifaddr" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/67/04/df7286233f468e19e9bedff023b6b246182f0b2ccb04ceeb69b2994021c6/aioice-0.10.2.tar.gz", hash = "sha256:bf236c6829ee33c8e540535d31cd5a066b531cb56de2be94c46be76d68b1a806", size = 44307, upload-time = "2025-11-28T15:56:48.836Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/e3/0d23b1f930c17d371ce1ec36ee529f22fd19ebc2a07fe3418e3d1d884ce2/aioice-0.10.2-py3-none-any.whl", hash = "sha256:14911c15ab12d096dd14d372ebb4aecbb7420b52c9b76fdfcf54375dec17fcbf", size = 24875, upload-time = "2025-11-28T15:56:47.847Z" }, -] - -[[package]] -name = "aiortc" -version = "1.14.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aioice" }, - { name = "av" }, - { name = "cryptography" }, - { name = "google-crc32c" }, - { name = "pyee" }, - { name = "pylibsrtp" }, - { name = "pyopenssl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/51/9c/4e027bfe0195de0442da301e2389329496745d40ae44d2d7c4571c4290ce/aiortc-1.14.0.tar.gz", hash = "sha256:adc8a67ace10a085721e588e06a00358ed8eaf5f6b62f0a95358ff45628dd762", size = 1180864, upload-time = "2025-10-13T21:40:37.905Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/57/ab/31646a49209568cde3b97eeade0d28bb78b400e6645c56422c101df68932/aiortc-1.14.0-py3-none-any.whl", hash = "sha256:4b244d7e482f4e1f67e685b3468269628eca1ec91fa5b329ab517738cfca086e", size = 93183, upload-time = "2025-10-13T21:40:36.59Z" }, -] - -[[package]] -name = "aiosignal" -version = "1.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "frozenlist" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, -] - [[package]] name = "attrs" version = "26.1.0" @@ -493,15 +404,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/51/25/2a75b47cb057b1e164c604fb81ab690a6cdb5e2260ce651194eae90f64a3/deepmerge-2.1.0-py3-none-any.whl", hash = "sha256:8f148339a91d680a75ecb74ade235d9e759a93df373a0b04e9d31c8666cfeb75", size = 14345, upload-time = "2026-06-22T05:46:06.742Z" }, ] -[[package]] -name = "dnspython" -version = "2.8.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, -] - [[package]] name = "execnet" version = "2.1.2" @@ -528,44 +430,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/47/c99d5268f354002ce80f8d029cd9d7d872969da1de8b93d32de4dc56d6f4/fonttools-4.63.0-py3-none-any.whl", hash = "sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d", size = 1164562, upload-time = "2026-05-14T12:04:29.092Z" }, ] -[[package]] -name = "frozenlist" -version = "1.8.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, - { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, - { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, - { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, - { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, - { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, - { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, - { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, - { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, - { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, - { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, - { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, - { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, - { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, - { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, - { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, - { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, -] - -[[package]] -name = "google-crc32c" -version = "1.8.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/03/41/4b9c02f99e4c5fb477122cd5437403b552873f014616ac1d19ac8221a58d/google_crc32c-1.8.0.tar.gz", hash = "sha256:a428e25fb7691024de47fecfbff7ff957214da51eddded0da0ae0e0f03a2cf79", size = 14192, upload-time = "2025-12-16T00:35:25.142Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/5f/7307325b1198b59324c0fa9807cafb551afb65e831699f2ce211ad5c8240/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:4b8286b659c1335172e39563ab0a768b8015e88e08329fa5321f774275fc3113", size = 31300, upload-time = "2025-12-16T00:21:56.723Z" }, - { url = "https://files.pythonhosted.org/packages/21/8e/58c0d5d86e2220e6a37befe7e6a94dd2f6006044b1a33edf1ff6d9f7e319/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:2a3dc3318507de089c5384cc74d54318401410f82aa65b2d9cdde9d297aca7cb", size = 30867, upload-time = "2025-12-16T00:38:31.302Z" }, - { url = "https://files.pythonhosted.org/packages/ce/a9/a780cc66f86335a6019f557a8aaca8fbb970728f0efd2430d15ff1beae0e/google_crc32c-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14f87e04d613dfa218d6135e81b78272c3b904e2a7053b841481b38a7d901411", size = 33364, upload-time = "2025-12-16T00:40:22.96Z" }, - { url = "https://files.pythonhosted.org/packages/21/3f/3457ea803db0198c9aaca2dd373750972ce28a26f00544b6b85088811939/google_crc32c-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb5c869c2923d56cb0c8e6bcdd73c009c36ae39b652dbe46a05eb4ef0ad01454", size = 33740, upload-time = "2025-12-16T00:40:23.96Z" }, - { url = "https://files.pythonhosted.org/packages/df/c0/87c2073e0c72515bb8733d4eef7b21548e8d189f094b5dad20b0ecaf64f6/google_crc32c-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:3cc0c8912038065eafa603b238abf252e204accab2a704c63b9e14837a854962", size = 34437, upload-time = "2025-12-16T00:35:21.395Z" }, -] - [[package]] name = "hypothesis" version = "6.47.5" @@ -588,15 +452,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] -[[package]] -name = "ifaddr" -version = "0.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e8/ac/fb4c578f4a3256561548cd825646680edcadb9440f3f68add95ade1eb791/ifaddr-0.2.0.tar.gz", hash = "sha256:cc0cbfcaabf765d44595825fb96a99bb12c79716b73b44330ea38ee2b0c4aed4", size = 10485, upload-time = "2022-06-15T21:40:27.561Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/1f/19ebc343cc71a7ffa78f17018535adc5cbdd87afb31d7c34874680148b32/ifaddr-0.2.0-py3-none-any.whl", hash = "sha256:085e0305cfe6f16ab12d72e2024030f5d52674afad6911bb1eee207177b8a748", size = 12314, upload-time = "2022-06-15T21:40:25.756Z" }, -] - [[package]] name = "importlib-resources" version = "7.1.0" @@ -672,6 +527,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b5/91/53255615acd2a1eaca307ede3c90eb550bae9c94581f8c00081b6b1c8f44/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57", size = 75987, upload-time = "2026-03-09T13:15:39.65Z" }, ] +[[package]] +name = "libdatachannel-py" +version = "2026.1.0.dev2" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/2f/68e8306327ddef4b2133d2efb163cb05b319759ce8bd50b8b32dcd03dd95/libdatachannel_py-2026.1.0.dev2-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:6607fa1439e1b5bfceecd387c433470c9d45e439c3c06fa064f5c4669ad7e582", size = 1213155, upload-time = "2026-05-19T03:37:12.796Z" }, + { url = "https://files.pythonhosted.org/packages/fc/e3/10aed36ffaf1744795322aae612db777991575b72a9f04e2c677c2c022bf/libdatachannel_py-2026.1.0.dev2-cp312-cp312-macosx_26_0_arm64.whl", hash = "sha256:a060b1250f57d1fccb36e3a6b36ac8f4fd34926a6b51c564e787e8b7206458aa", size = 1224706, upload-time = "2026-05-19T03:37:12.679Z" }, + { url = "https://files.pythonhosted.org/packages/09/a9/103fc647a8f9c721ab140fe8d2f8dbd90817e917ca763c4eb0f843fe247e/libdatachannel_py-2026.1.0.dev2-cp312-cp312-manylinux_2_35_aarch64.whl", hash = "sha256:0b8aa3be2fa3654ea24f882756d6599e847de866a44f7a291c60994548a2debb", size = 1638879, upload-time = "2026-05-19T03:37:18.138Z" }, + { url = "https://files.pythonhosted.org/packages/09/a8/0dc7d3fe80fc247ec165dbd455bc2a1a307ef65702a43e24473202c2bf42/libdatachannel_py-2026.1.0.dev2-cp312-cp312-manylinux_2_35_x86_64.whl", hash = "sha256:339a79fcbc8c6caf91c620f6e4a0f1b8ccb6a941a966d10a3135c980ae4651a6", size = 1718006, upload-time = "2026-05-19T03:37:11.891Z" }, + { url = "https://files.pythonhosted.org/packages/6d/86/30904a8753e9db60d8c3cf8efda09585fc68f2004d3d7aa2910c93a8eed5/libdatachannel_py-2026.1.0.dev2-cp312-cp312-manylinux_2_38_aarch64.whl", hash = "sha256:b9f476cb065b50856ab2e53bf774ccca9c6a66454b7ce903aaf6dfcdef2a4482", size = 1643757, upload-time = "2026-05-19T03:37:12.207Z" }, + { url = "https://files.pythonhosted.org/packages/d7/9d/1e10131396d28e84a8088a63c14978cc215f6677dc85acdd96b6068f0664/libdatachannel_py-2026.1.0.dev2-cp312-cp312-manylinux_2_38_x86_64.whl", hash = "sha256:1f31db7347549edcd69fcc1ecb8b31e7183894808ccf9387afc49a4d68debaae", size = 1751748, upload-time = "2026-05-19T03:37:06.98Z" }, +] + [[package]] name = "libusb-package" version = "1.0.30.0" @@ -785,33 +653,6 @@ requires-dist = [ ] provides-extras = ["dev"] -[[package]] -name = "multidict" -version = "6.7.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, - { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, - { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, - { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, - { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, - { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, - { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, - { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, - { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, - { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, - { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, - { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, - { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, - { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, - { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, - { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, - { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, - { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, - { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, -] - [[package]] name = "numpy" version = "2.5.0" @@ -876,7 +717,6 @@ name = "openpilot" version = "0.1.0" source = { editable = "." } dependencies = [ - { name = "aiortc" }, { name = "av" }, { name = "cffi" }, { name = "comma-deps-acados" }, @@ -899,7 +739,7 @@ dependencies = [ { name = "numpy" }, { name = "pillow" }, { name = "pycapnp" }, - { name = "pyjwt" }, + { name = "pyjwt", extra = ["crypto"] }, { name = "pyzmq" }, { name = "qrcode" }, { name = "requests" }, @@ -954,7 +794,6 @@ standalone = [ [package.metadata] requires-dist = [ - { name = "aiortc" }, { name = "av" }, { name = "cffi" }, { name = "codespell", marker = "extra == 'testing'" }, @@ -987,7 +826,7 @@ requires-dist = [ { name = "pillow" }, { name = "pre-commit-hooks", marker = "extra == 'testing'" }, { name = "pycapnp", specifier = "==2.1.0" }, - { name = "pyjwt" }, + { name = "pyjwt", extras = ["crypto"] }, { name = "pytest", marker = "extra == 'testing'" }, { name = "pytest-cpp", marker = "extra == 'testing'" }, { name = "pytest-mock", marker = "extra == 'testing'" }, @@ -1096,32 +935,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/46/eba9be9daa403fa94854ce16a458c29df9a01c6c047931c3d8be6016cd9a/pre_commit_hooks-6.0.0-py2.py3-none-any.whl", hash = "sha256:76161b76d321d2f8ee2a8e0b84c30ee8443e01376121fd1c90851e33e3bd7ee2", size = 41338, upload-time = "2025-08-09T19:25:03.513Z" }, ] -[[package]] -name = "propcache" -version = "0.5.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887, upload-time = "2026-05-08T21:00:11.277Z" }, - { url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654, upload-time = "2026-05-08T21:00:12.604Z" }, - { url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190, upload-time = "2026-05-08T21:00:13.935Z" }, - { url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995, upload-time = "2026-05-08T21:00:15.526Z" }, - { url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422, upload-time = "2026-05-08T21:00:16.824Z" }, - { url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342, upload-time = "2026-05-08T21:00:18.362Z" }, - { url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639, upload-time = "2026-05-08T21:00:19.692Z" }, - { url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588, upload-time = "2026-05-08T21:00:21.155Z" }, - { url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029, upload-time = "2026-05-08T21:00:22.713Z" }, - { url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774, upload-time = "2026-05-08T21:00:24.001Z" }, - { url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532, upload-time = "2026-05-08T21:00:25.545Z" }, - { url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592, upload-time = "2026-05-08T21:00:27.186Z" }, - { url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788, upload-time = "2026-05-08T21:00:28.8Z" }, - { url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514, upload-time = "2026-05-08T21:00:30.098Z" }, - { url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018, upload-time = "2026-05-08T21:00:31.622Z" }, - { url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322, upload-time = "2026-05-08T21:00:32.918Z" }, - { url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172, upload-time = "2026-05-08T21:00:35.124Z" }, - { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, -] - [[package]] name = "pycapnp" version = "2.1.0" @@ -1170,18 +983,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" }, ] -[[package]] -name = "pyee" -version = "13.0.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" }, -] - [[package]] name = "pygments" version = "2.20.0" @@ -1200,26 +1001,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, ] -[[package]] -name = "pylibsrtp" -version = "1.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0d/a6/6e532bec974aaecbf9fe4e12538489fb1c28456e65088a50f305aeab9f89/pylibsrtp-1.0.0.tar.gz", hash = "sha256:b39dff075b263a8ded5377f2490c60d2af452c9f06c4d061c7a2b640612b34d4", size = 10858, upload-time = "2025-10-13T16:12:31.552Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/aa/af/89e61a62fa3567f1b7883feb4d19e19564066c2fcd41c37e08d317b51881/pylibsrtp-1.0.0-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:822c30ea9e759b333dc1f56ceac778707c51546e97eb874de98d7d378c000122", size = 1865017, upload-time = "2025-10-13T16:12:15.62Z" }, - { url = "https://files.pythonhosted.org/packages/8d/0e/8d215484a9877adcf2459a8b28165fc89668b034565277fd55d666edd247/pylibsrtp-1.0.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:aaad74e5c8cbc1c32056c3767fea494c1e62b3aea2c908eda2a1051389fdad76", size = 2182739, upload-time = "2025-10-13T16:12:17.121Z" }, - { url = "https://files.pythonhosted.org/packages/57/3f/76a841978877ae13eac0d4af412c13bbd5d83b3df2c1f5f2175f2e0f68e5/pylibsrtp-1.0.0-cp310-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9209b86e662ebbd17c8a9e8549ba57eca92a3e87fb5ba8c0e27b8c43cd08a767", size = 2732922, upload-time = "2025-10-13T16:12:18.348Z" }, - { url = "https://files.pythonhosted.org/packages/0e/14/cf5d2a98a66fdfe258f6b036cda570f704a644fa861d7883a34bc359501e/pylibsrtp-1.0.0-cp310-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:293c9f2ac21a2bd689c477603a1aa235d85cf252160e6715f0101e42a43cbedc", size = 2434534, upload-time = "2025-10-13T16:12:20.074Z" }, - { url = "https://files.pythonhosted.org/packages/bd/08/a3f6e86c04562f7dce6717cd2206a0f84ca85c5e38121d998e0e330194c3/pylibsrtp-1.0.0-cp310-abi3-manylinux_2_28_i686.whl", hash = "sha256:81fb8879c2e522021a7cbd3f4bda1b37c192e1af939dfda3ff95b4723b329663", size = 2345818, upload-time = "2025-10-13T16:12:21.439Z" }, - { url = "https://files.pythonhosted.org/packages/8e/d5/130c2b5b4b51df5631684069c6f0a6761c59d096a33d21503ac207cf0e47/pylibsrtp-1.0.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4ddb562e443cf2e557ea2dfaeef0d7e6b90e96dd38eb079b4ab2c8e34a79f50b", size = 2774490, upload-time = "2025-10-13T16:12:22.659Z" }, - { url = "https://files.pythonhosted.org/packages/91/e3/715a453bfee3bea92a243888ad359094a7727cc6d393f21281320fe7798c/pylibsrtp-1.0.0-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:f02e616c9dfab2b03b32d8cc7b748f9d91814c0211086f987629a60f05f6e2cc", size = 2372603, upload-time = "2025-10-13T16:12:24.036Z" }, - { url = "https://files.pythonhosted.org/packages/e3/56/52fa74294254e1f53a4ff170ee2006e57886cf4bb3db46a02b4f09e1d99f/pylibsrtp-1.0.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:c134fa09e7b80a5b7fed626230c5bc257fd771bd6978e754343e7a61d96bc7e6", size = 2451269, upload-time = "2025-10-13T16:12:25.475Z" }, - { url = "https://files.pythonhosted.org/packages/1e/51/2e9b34f484cbdd3bac999bf1f48b696d7389433e900639089e8fc4e0da0d/pylibsrtp-1.0.0-cp310-abi3-win32.whl", hash = "sha256:bae377c3b402b17b9bbfbfe2534c2edba17aa13bea4c64ce440caacbe0858b55", size = 1247503, upload-time = "2025-10-13T16:12:27.39Z" }, - { url = "https://files.pythonhosted.org/packages/c3/70/43db21af194580aba2d9a6d4c7bd8c1a6e887fa52cd810b88f89096ecad2/pylibsrtp-1.0.0-cp310-abi3-win_amd64.whl", hash = "sha256:8d6527c4a78a39a8d397f8862a8b7cdad4701ee866faf9de4ab8c70be61fd34d", size = 1601659, upload-time = "2025-10-13T16:12:29.037Z" }, - { url = "https://files.pythonhosted.org/packages/8e/ec/6e02b2561d056ea5b33046e3cad21238e6a9097b97d6ccc0fbe52b50c858/pylibsrtp-1.0.0-cp310-abi3-win_arm64.whl", hash = "sha256:2696bdb2180d53ac55d0eb7b58048a2aa30cd4836dd2ca683669889137a94d2a", size = 1159246, upload-time = "2025-10-13T16:12:30.285Z" }, +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, ] [[package]] @@ -1235,19 +1019,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d6/54/da572c98c0b77626a91b5d3b89f0231d8bff5125c225420908632f8b342d/pymdown_extensions-11.0.1-py3-none-any.whl", hash = "sha256:db3943a62bab7e03af1364f0c4083e64b91fb097675a4b6cceccfbe9a77e5eb2", size = 269455, upload-time = "2026-07-02T17:59:21.271Z" }, ] -[[package]] -name = "pyopenssl" -version = "26.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cryptography" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/74/b7/da07bae88f5a9506b4def6f2f4903cf4c3b8831e560dba8fa18ca08f758f/pyopenssl-26.3.0.tar.gz", hash = "sha256:589de7fae1c9ea670d18422ed00fc04da787bbde8e1454aea872aa57b49ad341", size = 182024, upload-time = "2026-06-12T20:28:07.458Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/18/1dd71c9b43192ab83f1d531ad6002dc81108ac36c475f79fb7a295abe2f4/pyopenssl-26.3.0-py3-none-any.whl", hash = "sha256:46367f8f66b92271e6d218da9c87607e1ef5a0bc5c8dea5bb3db82f395c385a3", size = 56008, upload-time = "2026-06-12T20:28:05.999Z" }, -] - [[package]] name = "pyparsing" version = "3.3.2" @@ -1566,18 +1337,12 @@ name = "teleoprtc" version = "1.0.1" source = { editable = "teleoprtc_repo" } dependencies = [ - { name = "aiohttp" }, - { name = "aiortc" }, - { name = "av" }, - { name = "numpy" }, + { name = "libdatachannel-py" }, ] [package.metadata] requires-dist = [ - { name = "aiohttp", specifier = ">=3.7.0" }, - { name = "aiortc", specifier = ">=1.6.0" }, - { name = "av", specifier = ">=11.0.0,<13.0.0" }, - { name = "numpy", specifier = ">=1.19.0" }, + { name = "libdatachannel-py", specifier = ">=2026.1.0.dev2" }, { name = "parameterized", marker = "extra == 'dev'", specifier = ">=0.8" }, { name = "pre-commit", marker = "extra == 'dev'" }, { name = "pytest", marker = "extra == 'dev'" }, @@ -1700,15 +1465,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b8/fc/6a183e71edde90d0c35c2303f23f7a45b6891d1a2c45daf7b8f869831e19/ty-0.0.56-py3-none-win_arm64.whl", hash = "sha256:57538f273d444a5f1293fa7860e967178afe3917611fc5eff16b64e1204fe0d6", size = 11538780, upload-time = "2026-07-01T16:44:53.8Z" }, ] -[[package]] -name = "typing-extensions" -version = "4.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, -] - [[package]] name = "urllib3" version = "2.7.0" @@ -1745,37 +1501,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/63/188f7cb41ab35d795558325d5cc8ab552171d5498cfb178fd14409651e18/xattr-1.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2aaa5d66af6523332189108f34e966ca120ff816dfa077ca34b31e6263f8a236", size = 37754, upload-time = "2025-10-13T22:16:15.306Z" }, ] -[[package]] -name = "yarl" -version = "1.24.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "idna" }, - { name = "multidict" }, - { name = "propcache" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/79/12/1e8f37460ea0f7eb59c221fdaf0ed75e7ac43e97f8093b9c6f411df50a78/yarl-1.24.2.tar.gz", hash = "sha256:9ac374123c6fd7abf64d1fec93962b0bd4ee2c19751755a762a72dd96c0378f8", size = 210798, upload-time = "2026-05-19T21:31:05.599Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f0/da/866bcb01076ba49d2b42b309867bed3826421f1c479655eb7a607b44f20b/yarl-1.24.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b975866c184564c827e0877380f0dae57dcca7e52782128381b72feff6dfceb8", size = 129957, upload-time = "2026-05-19T21:28:51.695Z" }, - { url = "https://files.pythonhosted.org/packages/bf/1d/fcefb70922ea2268a8971d8e5874d9a8218644200fb8465f1dcad55e6851/yarl-1.24.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3b075301a2836a0e297b1b658cb6d6135df535d62efefdd60366bd589c2c82f2", size = 92164, upload-time = "2026-05-19T21:28:53.242Z" }, - { url = "https://files.pythonhosted.org/packages/29/b6/170e2b8d4e3bc30e6bfdcca53556537f5bf595e938632dfcb059311f3ff6/yarl-1.24.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ae44649b00947634ab0dab2a374a638f52923a6e67083f2c156cd5cbd1a881d", size = 91688, upload-time = "2026-05-19T21:28:54.865Z" }, - { url = "https://files.pythonhosted.org/packages/fe/a5/c9f655d5553ea0b99fdac9d6a99ad3f9b3e73b8e5758bb46f58c9831f74c/yarl-1.24.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:507cc19f0b45454e2d6dcd62ff7d062b9f77a2812404e62dbdaec05b50faa035", size = 102902, upload-time = "2026-05-19T21:28:56.963Z" }, - { url = "https://files.pythonhosted.org/packages/5d/bc/6b9664d815d79af4ee553337f9d606c56bbf269186ada9172de45f1b5f60/yarl-1.24.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4c17bad5a530912d2111825d3f05e89bab2dd376aaa8cbc77e449e6db63e576", size = 97931, upload-time = "2026-05-19T21:28:58.56Z" }, - { url = "https://files.pythonhosted.org/packages/98/ec/32ba48acae30fecd60928f5791188b80a9d6ee3840507ffda29fecd37b71/yarl-1.24.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f5f0cbb112838a4a293985b6ed73948a547dadcc1ba6d2089938e7abdedceef8", size = 111030, upload-time = "2026-05-19T21:29:00.148Z" }, - { url = "https://files.pythonhosted.org/packages/82/5a/6f4cd081e5f4934d2ae3a8ef4abe3afacc010d26f0035ee91b35cd7d7c37/yarl-1.24.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ec8356b8a6afcf81fc7aeeef13b1ff7a49dec00f313394bbb9e83830d32ccd7", size = 110392, upload-time = "2026-05-19T21:29:02.155Z" }, - { url = "https://files.pythonhosted.org/packages/7a/da/323a01c349bd5fb01bb6652e314d9bb218cee630a736bdb810ad50e4013f/yarl-1.24.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e7ebcdef69dec6c6451e616f32b622a6d4a2e92b445c992f7c8e5274a6bbc4c", size = 105612, upload-time = "2026-05-19T21:29:04.247Z" }, - { url = "https://files.pythonhosted.org/packages/7c/80/264ab684f181e1a876389374519ff05d10248725535ae2ac4e8ac4e563d6/yarl-1.24.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:47a55d6cf6db2f401017a9e96e5288844e5051911fb4e0c8311a3980f5e59a7d", size = 104487, upload-time = "2026-05-19T21:29:06.491Z" }, - { url = "https://files.pythonhosted.org/packages/41/07/efabe5df87e96d7ad5959760b888344be48cd6884db127b407c6b5503adc/yarl-1.24.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3065657c80a2321225e804048597ad55658a7e76b32d6f5ee4074d04c50401db", size = 102333, upload-time = "2026-05-19T21:29:08.267Z" }, - { url = "https://files.pythonhosted.org/packages/44/0c/bcf7c42603e1009295f586d8890f2ba032c8b53310e815adf0a202c73d9f/yarl-1.24.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cb84b80d88e19ede158619b80813968713d8d008b0e2497a576e6a0557d50712", size = 99025, upload-time = "2026-05-19T21:29:10.682Z" }, - { url = "https://files.pythonhosted.org/packages/4f/82/84482ab1a57a0f21a08afe6a7004c61d741f8f2ecc3b05c321577c612164/yarl-1.24.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:990de4f680b1c217e77ff0d6aa0029f9eb79889c11fb3e9a3942c7eba29c1996", size = 110507, upload-time = "2026-05-19T21:29:12.954Z" }, - { url = "https://files.pythonhosted.org/packages/c4/8d/a546ba1dfe1b0f290e05fef145cd07614c0f15df1a707195e512d1e39d1d/yarl-1.24.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:abb8ec0323b80161e3802da3150ef660b41d0e9be2048b76a363d93eee992c2b", size = 103719, upload-time = "2026-05-19T21:29:14.893Z" }, - { url = "https://files.pythonhosted.org/packages/1a/b6/267f2a09213138473adfce6b8a6e17791d7fee70bd4d9003218e4dec58b0/yarl-1.24.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e7977781f83638a4c73e0f88425563d70173e0dfd90ac006a45c65036293ee3c", size = 110438, upload-time = "2026-05-19T21:29:16.485Z" }, - { url = "https://files.pythonhosted.org/packages/48/2d/1c8d89c7c5f9cad9fb2902445d94e2ab1d7aa35de029afbb8ae95c42d00f/yarl-1.24.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e30dd55825dc554ec5b66a94953b8eda8745926514c5089dfcacecb9c99b5bd1", size = 105719, upload-time = "2026-05-19T21:29:18.367Z" }, - { url = "https://files.pythonhosted.org/packages/a7/25/722e3b93bd687009afb2d59a35e13d30ddd8f80571445bb0c4e4ce26ec66/yarl-1.24.2-cp312-cp312-win_amd64.whl", hash = "sha256:7dafe10c12ddd4d120d528c4b5599c953bd7b12845347d507b95451195bb6cad", size = 92901, upload-time = "2026-05-19T21:29:20.014Z" }, - { url = "https://files.pythonhosted.org/packages/39/47/4486ccfb674c04854a1ef8aa77868b6a6f765feaf69633409d7ca4f02cb8/yarl-1.24.2-cp312-cp312-win_arm64.whl", hash = "sha256:044a09d8401fcf8681977faef6d286b8ade1e2d2e9dceda175d1cfa5ca496f30", size = 87229, upload-time = "2026-05-19T21:29:22.1Z" }, - { url = "https://files.pythonhosted.org/packages/fd/4d/4b880086bd0d3e034d25647be1d830afc3e3f610e98c4ab3490af6b1b6d5/yarl-1.24.2-py3-none-any.whl", hash = "sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9", size = 53576, upload-time = "2026-05-19T21:31:03.909Z" }, -] - [[package]] name = "zensical" version = "0.0.46" From c21b0821daffaa529f2a210ffce67e5166a07b60 Mon Sep 17 00:00:00 2001 From: stef <19478336+stefpi@users.noreply.github.com> Date: Fri, 17 Jul 2026 16:23:44 -0700 Subject: [PATCH 10/35] fix(ui): label alignment and text with icon (#38365) fix align bottom and text positioning with icon --- openpilot/system/ui/widgets/label.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/openpilot/system/ui/widgets/label.py b/openpilot/system/ui/widgets/label.py index 7fe25ab51d..7052b2c2a9 100644 --- a/openpilot/system/ui/widgets/label.py +++ b/openpilot/system/ui/widgets/label.py @@ -188,6 +188,9 @@ class Label(Widget): if self._text_alignment_vertical == rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE: total_text_height = sum(ts.y for ts in self._text_size) or self._font_size * FONT_SCALE text_pos = rl.Vector2(self._rect.x, (self._rect.y + (self._rect.height - total_text_height) // 2)) + elif self._text_alignment_vertical == rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM: + total_text_height = sum(ts.y for ts in self._text_size) or self._font_size * FONT_SCALE + text_pos = rl.Vector2(self._rect.x, self._rect.y + self._rect.height - total_text_height) else: text_pos = rl.Vector2(self._rect.x, self._rect.y) @@ -196,11 +199,11 @@ class Label(Widget): if len(self._text_wrapped) > 0: if self._text_alignment == rl.GuiTextAlignment.TEXT_ALIGN_LEFT: icon_x = self._rect.x + self._text_padding - text_pos.x = self._icon.width + ICON_PADDING + text_pos.x = self._rect.x + self._icon.width + ICON_PADDING elif self._text_alignment == rl.GuiTextAlignment.TEXT_ALIGN_CENTER: total_width = self._icon.width + ICON_PADDING + text_size.x icon_x = self._rect.x + (self._rect.width - total_width) / 2 - text_pos.x = self._icon.width + ICON_PADDING + text_pos.x = self._rect.x + self._icon.width + ICON_PADDING else: icon_x = (self._rect.x + self._rect.width - text_size.x - self._text_padding) - ICON_PADDING - self._icon.width else: From 3f93b0012063987f722abd0f8f5a5061a07bfd99 Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Sat, 18 Jul 2026 08:52:02 -0700 Subject: [PATCH 11/35] webrtc: remove av (#38369) --- openpilot/system/webrtc/device/video.py | 17 +++++++++++------ .../system/webrtc/tests/test_stream_session.py | 5 ++--- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/openpilot/system/webrtc/device/video.py b/openpilot/system/webrtc/device/video.py index a67247bc3c..c300f76485 100644 --- a/openpilot/system/webrtc/device/video.py +++ b/openpilot/system/webrtc/device/video.py @@ -1,8 +1,8 @@ import asyncio +from dataclasses import dataclass import struct import time -import av from teleoprtc.tracks import TiciVideoStreamTrack from openpilot.cereal import messaging @@ -21,6 +21,15 @@ TIMING_SEI_UUID = bytes([ _SEI_PREFIX = b'\x00\x00\x00\x01\x06\x05\x30' + TIMING_SEI_UUID +@dataclass(frozen=True) +class EncodedVideoFrame: + data: bytes + pts: int + + def __bytes__(self) -> bytes: + return self.data + + class LiveStreamVideoStreamTrack(TiciVideoStreamTrack): camera_to_sock_mapping = { "driver": "livestreamDriverEncodeData", @@ -86,11 +95,7 @@ class LiveStreamVideoStreamTrack(TiciVideoStreamTrack): break await asyncio.sleep(0.005) - packet = av.Packet(self._build_frame_data(msg)) - packet.time_base = self._time_base - self._pts = ((time.monotonic_ns() - self._t0_ns) * self._clock_rate) // 1_000_000_000 - packet.pts = self._pts self.log_debug("track sending frame %d", self._pts) - return packet + return EncodedVideoFrame(self._build_frame_data(msg), self._pts) diff --git a/openpilot/system/webrtc/tests/test_stream_session.py b/openpilot/system/webrtc/tests/test_stream_session.py index 7e95097c87..b83e1223d2 100644 --- a/openpilot/system/webrtc/tests/test_stream_session.py +++ b/openpilot/system/webrtc/tests/test_stream_session.py @@ -4,7 +4,7 @@ import time import capnp from openpilot.cereal import messaging, log -from teleoprtc.tracks import VIDEO_CLOCK_RATE, VIDEO_TIME_BASE +from teleoprtc.tracks import VIDEO_CLOCK_RATE from openpilot.system.webrtc.webrtcd import CerealOutgoingMessageProxy, CerealIncomingMessageProxy from openpilot.system.webrtc.device.video import LiveStreamVideoStreamTrack @@ -72,9 +72,8 @@ class TestStreamSession: for i in range(5): packet = self.loop.run_until_complete(track.recv()) - assert packet.time_base == VIDEO_TIME_BASE if i == 0: start_ns = time.monotonic_ns() start_pts = packet.pts assert abs(i + packet.pts - (start_pts + (((time.monotonic_ns() - start_ns) * VIDEO_CLOCK_RATE) // 1_000_000_000))) < 450 #5ms - assert packet.size == 0 + assert bytes(packet) == b"" From 3f49e2d33c40b20424aceafe1d5d2d1973e17acf Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Sat, 18 Jul 2026 09:02:08 -0700 Subject: [PATCH 12/35] jp: add thumbnail source (#38363) --- openpilot/tools/jotpluggler/app.cc | 9 +- openpilot/tools/jotpluggler/app.h | 27 ++ openpilot/tools/jotpluggler/common.cc | 4 +- openpilot/tools/jotpluggler/common.h | 3 +- openpilot/tools/jotpluggler/layout_io.cc | 2 + openpilot/tools/jotpluggler/session.cc | 3 + openpilot/tools/jotpluggler/sketch_layout.cc | 45 ++++ openpilot/tools/jotpluggler/thumbnail.cc | 253 +++++++++++++++++++ openpilot/tools/jotpluggler/thumbnail.h | 5 + 9 files changed, 347 insertions(+), 4 deletions(-) create mode 100644 openpilot/tools/jotpluggler/thumbnail.cc create mode 100644 openpilot/tools/jotpluggler/thumbnail.h diff --git a/openpilot/tools/jotpluggler/app.cc b/openpilot/tools/jotpluggler/app.cc index 01eec15367..e6ba696bae 100644 --- a/openpilot/tools/jotpluggler/app.cc +++ b/openpilot/tools/jotpluggler/app.cc @@ -3,6 +3,7 @@ #include "tools/jotpluggler/common.h" #include "tools/jotpluggler/internal.h" #include "tools/jotpluggler/map.h" +#include "tools/jotpluggler/thumbnail.h" #include "common/hardware/hw.h" #include "imgui_impl_glfw.h" @@ -1033,10 +1034,12 @@ bool apply_special_item_to_pane(WorkspaceTab *tab, TabUiState *tab_state, int pa if (pane.title == UNTITLED_PANE_TITLE || previous_kind != PaneKind::Plot) { pane.title = spec->label; } - } else { + } else if (spec->kind == PaneKind::Camera) { pane.title = spec->label; resize_tab_pane_state(tab_state, tab->panes.size()); tab_state->camera_panes[static_cast(pane_index)].fit_to_pane = true; + } else { + pane.title = spec->label; } tab_state->active_pane_index = pane_index; return true; @@ -1565,6 +1568,8 @@ void draw_pane_windows(AppSession *session, UiState *state) { } if (pane.kind == PaneKind::Map) { draw_map_pane(session, state, &pane, static_cast(i)); + } else if (pane.kind == PaneKind::Thumbnail) { + draw_thumbnail_pane(session, state); } else if (pane.kind == PaneKind::Camera) { draw_camera_pane(session, state, tab_state, static_cast(i), pane); } else { @@ -1847,6 +1852,7 @@ int run(const Options &options) { for (std::unique_ptr &feed : session.pane_camera_feeds) { feed = std::make_unique(); } + session.thumbnail_view = std::make_unique(); sync_camera_feeds(&session); if (session.async_route_loading) { @@ -1892,6 +1898,7 @@ int run(const Options &options) { for (std::unique_ptr &feed : session.pane_camera_feeds) { feed.reset(); } + session.thumbnail_view.reset(); return 0; } catch (const std::exception &err) { std::cerr << err.what() << "\n"; diff --git a/openpilot/tools/jotpluggler/app.h b/openpilot/tools/jotpluggler/app.h index a38f889687..b7754f51b2 100644 --- a/openpilot/tools/jotpluggler/app.h +++ b/openpilot/tools/jotpluggler/app.h @@ -81,6 +81,7 @@ struct Curve { enum class PaneKind : uint8_t { Plot, Map, + Thumbnail, Camera, }; @@ -141,6 +142,12 @@ struct CameraFeedIndex { std::vector entries; }; +struct ThumbnailFrame { + double timestamp = 0.0; + int segment = -1; + std::vector jpeg; +}; + enum class LogOrigin : uint8_t { Log, OperatingSystem, @@ -318,6 +325,7 @@ struct RouteData { CameraFeedIndex driver_camera; CameraFeedIndex wide_road_camera; CameraFeedIndex qroad_camera; + std::vector thumbnails; GpsTrace gps_trace; std::vector logs; std::vector timeline; @@ -445,6 +453,7 @@ bool icon_menu_item(const char *glyph, class AsyncRouteLoader; class CameraFeedView; +class ThumbnailView; class StreamPoller; class MapDataManager; @@ -486,6 +495,7 @@ struct AppSession { std::unique_ptr route_loader; std::unique_ptr stream_poller; std::array, 4> pane_camera_feeds; + std::unique_ptr thumbnail_view; std::unique_ptr map_data; bool async_route_loading = false; double next_stream_custom_refresh_time = 0.0; @@ -885,3 +895,20 @@ private: struct Impl; std::unique_ptr impl_; }; + +class ThumbnailView { +public: + ThumbnailView(); + ~ThumbnailView(); + + ThumbnailView(const ThumbnailView &) = delete; + ThumbnailView &operator=(const ThumbnailView &) = delete; + + void setThumbnails(const std::vector &thumbnails); + void update(double tracker_time); + void drawSized(ImVec2 size, bool loading); + +private: + struct Impl; + std::unique_ptr impl_; +}; diff --git a/openpilot/tools/jotpluggler/common.cc b/openpilot/tools/jotpluggler/common.cc index 8f696657bd..50f5fc0b95 100644 --- a/openpilot/tools/jotpluggler/common.cc +++ b/openpilot/tools/jotpluggler/common.cc @@ -46,11 +46,11 @@ const char *special_item_label(std::string_view item_id) { } bool pane_kind_is_special(PaneKind kind) { - return kind == PaneKind::Map || kind == PaneKind::Camera; + return kind == PaneKind::Map || kind == PaneKind::Thumbnail || kind == PaneKind::Camera; } bool is_default_special_title(std::string_view title) { - if (title == "Map") return true; + if (title == "Map" || title == "Thumbnail") return true; return std::any_of(kCameraViewSpecs.begin(), kCameraViewSpecs.end(), [&](const CameraViewSpec &spec) { return title == spec.label; }); diff --git a/openpilot/tools/jotpluggler/common.h b/openpilot/tools/jotpluggler/common.h index 14db83fd33..5c68b0ed22 100644 --- a/openpilot/tools/jotpluggler/common.h +++ b/openpilot/tools/jotpluggler/common.h @@ -28,8 +28,9 @@ inline constexpr std::array kCameraViewSpecs = {{ {CameraViewKind::QRoad, "qRoad Camera", "qroad", "qroad", "camera_qroad", &RouteData::qroad_camera}, }}; -inline constexpr std::array kSpecialItemSpecs = {{ +inline constexpr std::array kSpecialItemSpecs = {{ {"map", "Map", PaneKind::Map, CameraViewKind::Road}, + {"thumbnail", "Thumbnail", PaneKind::Thumbnail, CameraViewKind::Road}, {kCameraViewSpecs[0].special_item_id, kCameraViewSpecs[0].label, PaneKind::Camera, kCameraViewSpecs[0].view}, {kCameraViewSpecs[1].special_item_id, kCameraViewSpecs[1].label, PaneKind::Camera, kCameraViewSpecs[1].view}, {kCameraViewSpecs[2].special_item_id, kCameraViewSpecs[2].label, PaneKind::Camera, kCameraViewSpecs[2].view}, diff --git a/openpilot/tools/jotpluggler/layout_io.cc b/openpilot/tools/jotpluggler/layout_io.cc index 5c70f7a42a..24e3a4b51f 100644 --- a/openpilot/tools/jotpluggler/layout_io.cc +++ b/openpilot/tools/jotpluggler/layout_io.cc @@ -62,6 +62,8 @@ json11::Json workspace_node_to_json(const WorkspaceNode &node, const WorkspaceTa }; if (pane.kind == PaneKind::Map) { obj["kind"] = "map"; + } else if (pane.kind == PaneKind::Thumbnail) { + obj["kind"] = "thumbnail"; } else if (pane.kind == PaneKind::Camera) { obj["kind"] = "camera"; obj["camera_view"] = camera_view_spec(pane.camera_view).layout_name; diff --git a/openpilot/tools/jotpluggler/session.cc b/openpilot/tools/jotpluggler/session.cc index beb0a292be..4c694a8daa 100644 --- a/openpilot/tools/jotpluggler/session.cc +++ b/openpilot/tools/jotpluggler/session.cc @@ -19,6 +19,9 @@ void sync_camera_feeds(AppSession *session) { session->pane_camera_feeds[i]->setCameraIndex(session->route_data.*(kCameraViewSpecs[i].route_member), kCameraViewSpecs[i].view); } } + if (session->thumbnail_view) { + session->thumbnail_view->setThumbnails(session->route_data.thumbnails); + } } void apply_route_data(AppSession *session, UiState *state, RouteData route_data) { diff --git a/openpilot/tools/jotpluggler/sketch_layout.cc b/openpilot/tools/jotpluggler/sketch_layout.cc index ee307653f6..1f7df72fed 100644 --- a/openpilot/tools/jotpluggler/sketch_layout.cc +++ b/openpilot/tools/jotpluggler/sketch_layout.cc @@ -98,6 +98,7 @@ struct LoadedRouteArtifacts { std::vector can_messages; std::vector logs; std::vector timeline; + std::vector thumbnails; std::unordered_map enum_info; }; @@ -684,6 +685,25 @@ std::vector extract_segment_logs(const std::vector &events) { return logs; } +std::vector extract_segment_thumbnails(const std::vector &events, int segment) { + std::vector thumbnails; + for (const Event &event_record : events) { + if (event_record.which != cereal::Event::Which::THUMBNAIL) continue; + with_parseable_event(event_record.data, [&](const cereal::Event::Reader &event) { + const auto thumbnail = event.getThumbnail(); + const auto jpeg = thumbnail.getThumbnail(); + if (jpeg.size() == 0) return; + const uint64_t timestamp = thumbnail.getTimestampEof(); + ThumbnailFrame frame; + frame.timestamp = static_cast(timestamp != 0 ? timestamp : event.getLogMonoTime()) / 1.0e9; + frame.segment = segment; + frame.jpeg.assign(jpeg.begin(), jpeg.end()); + thumbnails.push_back(std::move(frame)); + }); + } + return thumbnails; +} + RouteMetadata extract_segment_metadata(const std::vector &events) { RouteMetadata metadata; for (const Event &event_record : events) { @@ -796,6 +816,8 @@ Pane parse_dock_area(const json11::Json &dock_area_node) { const std::string kind = dock_area_node["kind"].string_value(); if (kind == "map") { pane.kind = PaneKind::Map; + } else if (kind == "thumbnail") { + pane.kind = PaneKind::Thumbnail; } else if (kind == "camera") { pane.kind = PaneKind::Camera; const std::string camera_view = dock_area_node["camera_view"].string_value(); @@ -1167,6 +1189,7 @@ RouteData build_route_data(std::vector &&series_list, std::vector &&can_messages, std::vector &&logs, std::vector &&timeline, + std::vector &&thumbnails, std::unordered_map &&enum_info, std::string car_fingerprint, std::string dbc_name) { @@ -1233,6 +1256,14 @@ RouteData build_route_data(std::vector &&series_list, route_data.x_min = timeline.front().start_time; route_data.x_max = timeline.back().end_time; } + std::sort(thumbnails.begin(), thumbnails.end(), [](const ThumbnailFrame &a, const ThumbnailFrame &b) { + return a.timestamp < b.timestamp; + }); + if (!route_data.has_time_range && !thumbnails.empty()) { + route_data.has_time_range = true; + route_data.x_min = thumbnails.front().timestamp; + route_data.x_max = thumbnails.back().timestamp; + } if (route_data.has_time_range) { const double time_offset = route_data.x_min; @@ -1254,6 +1285,9 @@ RouteData build_route_data(std::vector &&series_list, entry.start_time -= time_offset; entry.end_time -= time_offset; } + for (ThumbnailFrame &thumbnail : thumbnails) { + thumbnail.timestamp -= time_offset; + } route_data.x_max -= time_offset; route_data.x_min = 0.0; } @@ -1271,6 +1305,7 @@ RouteData build_route_data(std::vector &&series_list, merged_timeline.push_back(std::move(entry)); } route_data.timeline = std::move(merged_timeline); + route_data.thumbnails = std::move(thumbnails); std::sort(can_messages.begin(), can_messages.end(), [](const CanMessageData &a, const CanMessageData &b) { return std::make_tuple(a.id.service, a.id.bus, a.id.address) < std::make_tuple(b.id.service, b.id.bus, b.id.address); @@ -1524,6 +1559,7 @@ LoadedRouteArtifacts load_route_series_parallel( SeriesAccumulator series; std::vector logs; std::vector timeline; + std::vector thumbnails; }; const std::vector> segment_list(segments.begin(), segments.end()); @@ -1586,6 +1622,7 @@ LoadedRouteArtifacts load_route_series_parallel( results[index].series = extract_segment_series(reader.events, schema, can_dbc, skip_raw_can, worker_budget, segment_workers); results[index].logs = extract_segment_logs(reader.events); results[index].timeline = extract_segment_timeline(reader.events); + results[index].thumbnails = extract_segment_thumbnails(reader.events, segment_number); segment_stats.extract_seconds = std::chrono::duration(LoadStats::Clock::now() - extract_start).count(); segment_stats.event_count = reader.events.size(); segment_stats.series_count = populated_series_count(results[index].series); @@ -1612,6 +1649,7 @@ LoadedRouteArtifacts load_route_series_parallel( } std::vector logs; std::vector timeline; + std::vector thumbnails; for (SegmentResult &result : results) { if (!result.logs.empty()) { logs.insert(logs.end(), @@ -1623,12 +1661,18 @@ LoadedRouteArtifacts load_route_series_parallel( std::make_move_iterator(result.timeline.begin()), std::make_move_iterator(result.timeline.end())); } + if (!result.thumbnails.empty()) { + thumbnails.insert(thumbnails.end(), + std::make_move_iterator(result.thumbnails.begin()), + std::make_move_iterator(result.thumbnails.end())); + } } LoadedRouteArtifacts artifacts; artifacts.series = collect_series(std::move(merged)); artifacts.can_messages = std::move(merged.can_messages); artifacts.logs = std::move(logs); artifacts.timeline = std::move(timeline); + artifacts.thumbnails = std::move(thumbnails); artifacts.enum_info = std::move(merged.enum_info); stats->merge_end = LoadStats::Clock::now(); return artifacts; @@ -1834,6 +1878,7 @@ RouteData load_route_data(const std::string &route_name, std::move(artifacts.can_messages), std::move(artifacts.logs), std::move(artifacts.timeline), + std::move(artifacts.thumbnails), std::move(artifacts.enum_info), metadata.car_fingerprint, resolved_dbc); diff --git a/openpilot/tools/jotpluggler/thumbnail.cc b/openpilot/tools/jotpluggler/thumbnail.cc new file mode 100644 index 0000000000..11e184323a --- /dev/null +++ b/openpilot/tools/jotpluggler/thumbnail.cc @@ -0,0 +1,253 @@ +#include "tools/jotpluggler/thumbnail.h" + +#include "imgui_impl_opengl3_loader.h" + +#include +#include +#include + +extern "C" { +#include +#include +} + +namespace { + +bool decode_jpeg(const std::vector &jpeg, int *width, int *height, std::vector *rgba) { + if (jpeg.empty()) return false; + + const AVCodec *codec = avcodec_find_decoder(AV_CODEC_ID_MJPEG); + AVCodecContext *context = codec != nullptr ? avcodec_alloc_context3(codec) : nullptr; + AVFrame *frame = av_frame_alloc(); + AVPacket *packet = av_packet_alloc(); + if (context == nullptr || frame == nullptr || packet == nullptr) { + av_packet_free(&packet); + av_frame_free(&frame); + avcodec_free_context(&context); + return false; + } + + const bool packet_ready = jpeg.size() <= static_cast(std::numeric_limits::max()) + && av_new_packet(packet, static_cast(jpeg.size())) >= 0; + if (packet_ready) { + std::copy(jpeg.begin(), jpeg.end(), packet->data); + } + const bool decoded = packet_ready + && avcodec_open2(context, codec, nullptr) >= 0 + && avcodec_send_packet(context, packet) >= 0 + && avcodec_receive_frame(context, frame) >= 0; + if (!decoded || frame->width <= 0 || frame->height <= 0) { + av_packet_free(&packet); + av_frame_free(&frame); + avcodec_free_context(&context); + return false; + } + + int chroma_x_shift = 0; + int chroma_y_shift = 0; + switch (static_cast(frame->format)) { + case AV_PIX_FMT_YUV420P: + case AV_PIX_FMT_YUVJ420P: + chroma_x_shift = 1; + chroma_y_shift = 1; + break; + case AV_PIX_FMT_YUV422P: + case AV_PIX_FMT_YUVJ422P: + chroma_x_shift = 1; + break; + case AV_PIX_FMT_YUV444P: + case AV_PIX_FMT_YUVJ444P: + break; + default: + av_packet_free(&packet); + av_frame_free(&frame); + avcodec_free_context(&context); + return false; + } + + *width = frame->width; + *height = frame->height; + rgba->resize(static_cast(*width) * static_cast(*height) * 4U); + const bool full_range = frame->color_range == AVCOL_RANGE_JPEG + || frame->format == AV_PIX_FMT_YUVJ420P + || frame->format == AV_PIX_FMT_YUVJ422P + || frame->format == AV_PIX_FMT_YUVJ444P; + for (int y = 0; y < *height; ++y) { + const uint8_t *y_row = frame->data[0] + y * frame->linesize[0]; + const uint8_t *u_row = frame->data[1] + (y >> chroma_y_shift) * frame->linesize[1]; + const uint8_t *v_row = frame->data[2] + (y >> chroma_y_shift) * frame->linesize[2]; + uint8_t *out = rgba->data() + static_cast(y) * static_cast(*width) * 4U; + for (int x = 0; x < *width; ++x) { + const double luma = full_range ? static_cast(y_row[x]) + : 1.164383 * (static_cast(y_row[x]) - 16.0); + const double u = static_cast(u_row[x >> chroma_x_shift]) - 128.0; + const double v = static_cast(v_row[x >> chroma_x_shift]) - 128.0; + const double red = luma + (full_range ? 1.402 : 1.596027) * v; + const double green = luma - (full_range ? 0.344136 : 0.391762) * u + - (full_range ? 0.714136 : 0.812968) * v; + const double blue = luma + (full_range ? 1.772 : 2.017232) * u; + out[x * 4 + 0] = static_cast(std::clamp(std::lround(red), 0L, 255L)); + out[x * 4 + 1] = static_cast(std::clamp(std::lround(green), 0L, 255L)); + out[x * 4 + 2] = static_cast(std::clamp(std::lround(blue), 0L, 255L)); + out[x * 4 + 3] = 255; + } + } + + av_packet_free(&packet); + av_frame_free(&frame); + avcodec_free_context(&context); + return true; +} + +std::string format_thumbnail_time(double seconds) { + const int rounded = std::max(0, static_cast(std::lround(seconds))); + const int hours = rounded / 3600; + const int minutes = (rounded % 3600) / 60; + const int secs = rounded % 60; + if (hours > 0) { + return util::string_format("%d:%02d:%02d", hours, minutes, secs); + } + return util::string_format("%02d:%02d", minutes, secs); +} + +} // namespace + +struct ThumbnailView::Impl { + ~Impl() { + destroy_texture(); + } + + void setThumbnails(const std::vector &next_thumbnails) { + destroy_texture(); + thumbnails = &next_thumbnails; + displayed_index = -1; + failed_index = -1; + } + + void update(double tracker_time) { + if (thumbnails == nullptr || thumbnails->empty()) return; + auto it = std::lower_bound(thumbnails->begin(), thumbnails->end(), tracker_time, + [](const ThumbnailFrame &frame, double time) { + return frame.timestamp < time; + }); + if (it == thumbnails->end()) { + it = std::prev(thumbnails->end()); + } else if (it != thumbnails->begin()) { + const auto previous = std::prev(it); + if (std::abs(previous->timestamp - tracker_time) <= std::abs(it->timestamp - tracker_time)) { + it = previous; + } + } + const int index = static_cast(std::distance(thumbnails->begin(), it)); + if (index == displayed_index || index == failed_index) return; + + int width = 0; + int height = 0; + std::vector rgba; + if (!decode_jpeg(it->jpeg, &width, &height, &rgba)) { + failed_index = index; + return; + } + + if (texture == 0) { + glGenTextures(1, &texture); + } + glBindTexture(GL_TEXTURE_2D, texture); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, rgba.data()); + glBindTexture(GL_TEXTURE_2D, 0); + texture_width = width; + texture_height = height; + displayed_index = index; + failed_index = -1; + } + + void drawSized(ImVec2 size, bool loading) const { + size.x = std::max(1.0f, size.x); + size.y = std::max(1.0f, size.y); + ImGui::InvisibleButton("##thumbnail_sized", size); + const ImVec2 pane_min = ImGui::GetItemRectMin(); + const ImVec2 pane_max = ImGui::GetItemRectMax(); + ImDrawList *draw_list = ImGui::GetWindowDrawList(); + draw_list->AddRectFilled(pane_min, pane_max, IM_COL32(24, 24, 24, 255)); + + if (texture != 0 && texture_width > 0 && texture_height > 0) { + const float scale = std::min(size.x / static_cast(texture_width), + size.y / static_cast(texture_height)); + const ImVec2 image_size(static_cast(texture_width) * scale, + static_cast(texture_height) * scale); + const ImVec2 image_min(pane_min.x + (size.x - image_size.x) * 0.5f, + pane_min.y + (size.y - image_size.y) * 0.5f); + const ImVec2 image_max(image_min.x + image_size.x, image_min.y + image_size.y); + draw_list->AddImage(static_cast(texture), image_min, image_max); + + if (thumbnails != nullptr && displayed_index >= 0 + && displayed_index < static_cast(thumbnails->size())) { + const ThumbnailFrame &frame = (*thumbnails)[static_cast(displayed_index)]; + const std::string label = util::string_format("%s · segment %d · %d/%zu", + format_thumbnail_time(frame.timestamp).c_str(), + frame.segment, + displayed_index + 1, + thumbnails->size()); + const ImVec2 text_size = ImGui::CalcTextSize(label.c_str()); + const ImVec2 label_min(image_min.x, std::max(image_min.y, image_max.y - text_size.y - 14.0f)); + draw_list->AddRectFilled(label_min, image_max, IM_COL32(0, 0, 0, 175)); + draw_list->AddText(ImVec2(label_min.x + 7.0f, label_min.y + 7.0f), IM_COL32_WHITE, label.c_str()); + } + return; + } + + const bool has_thumbnails = thumbnails != nullptr && !thumbnails->empty(); + const char *label = loading ? "loading" : (has_thumbnails ? "invalid thumbnail" : "no thumbnails"); + const ImVec2 text_size = ImGui::CalcTextSize(label); + draw_list->AddText(ImVec2(pane_min.x + (size.x - text_size.x) * 0.5f, + pane_min.y + (size.y - text_size.y) * 0.5f), + IM_COL32(187, 187, 187, 255), label); + } + + void destroy_texture() { + if (texture != 0) { + glDeleteTextures(1, &texture); + } + texture = 0; + texture_width = 0; + texture_height = 0; + } + + const std::vector *thumbnails = nullptr; + int displayed_index = -1; + int failed_index = -1; + GLuint texture = 0; + int texture_width = 0; + int texture_height = 0; +}; + +ThumbnailView::ThumbnailView() : impl_(std::make_unique()) {} +ThumbnailView::~ThumbnailView() = default; + +void ThumbnailView::setThumbnails(const std::vector &thumbnails) { + impl_->setThumbnails(thumbnails); +} + +void ThumbnailView::update(double tracker_time) { + impl_->update(tracker_time); +} + +void ThumbnailView::drawSized(ImVec2 size, bool loading) { + impl_->drawSized(size, loading); +} + +void draw_thumbnail_pane(AppSession *session, UiState *state) { + if (session->thumbnail_view == nullptr) { + ImGui::TextDisabled("Thumbnails unavailable"); + return; + } + if (state->has_tracker_time) { + session->thumbnail_view->update(state->tracker_time); + } + session->thumbnail_view->drawSized(ImGui::GetContentRegionAvail(), session->async_route_loading); +} diff --git a/openpilot/tools/jotpluggler/thumbnail.h b/openpilot/tools/jotpluggler/thumbnail.h new file mode 100644 index 0000000000..6970173a5c --- /dev/null +++ b/openpilot/tools/jotpluggler/thumbnail.h @@ -0,0 +1,5 @@ +#pragma once + +#include "tools/jotpluggler/app.h" + +void draw_thumbnail_pane(AppSession *session, UiState *state); From f0841b827a505db8e843188281fa8d6ceeda4d0b Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Sat, 18 Jul 2026 09:04:41 -0700 Subject: [PATCH 13/35] remove av (#38366) --- openpilot/tools/camerastream/README.md | 12 +- .../tools/camerastream/compressed_vipc.py | 101 ++++--- .../tools/camerastream/ffmpeg_decoder.py | 261 ++++++++++++++++++ pyproject.toml | 3 - uv.lock | 17 -- 5 files changed, 315 insertions(+), 79 deletions(-) create mode 100644 openpilot/tools/camerastream/ffmpeg_decoder.py diff --git a/openpilot/tools/camerastream/README.md b/openpilot/tools/camerastream/README.md index 8b77fc5990..9671199297 100644 --- a/openpilot/tools/camerastream/README.md +++ b/openpilot/tools/camerastream/README.md @@ -44,18 +44,18 @@ To actually display the stream, run `watch3` in separate terminal: ## compressed_vipc.py usage ``` $ python3 compressed_vipc.py -h -usage: compressed_vipc.py [-h] [--nvidia] [--cams CAMS] [--silent] addr +usage: compressed_vipc.py [-h] [--cams CAMS] [--server SERVER] [--silent] addr Decode video streams and broadcast on VisionIPC positional arguments: - addr Address of comma three + addr Address of comma three options: - -h, --help show this help message and exit - --nvidia Use nvidia instead of ffmpeg - --cams CAMS Cameras to decode - --silent Suppress debug output + -h, --help show this help message and exit + --cams CAMS Cameras to decode + --server SERVER choose vipc server name + --silent Suppress debug output ``` diff --git a/openpilot/tools/camerastream/compressed_vipc.py b/openpilot/tools/camerastream/compressed_vipc.py index 35e9d3dab2..56ed12889f 100755 --- a/openpilot/tools/camerastream/compressed_vipc.py +++ b/openpilot/tools/camerastream/compressed_vipc.py @@ -1,17 +1,15 @@ #!/usr/bin/env python3 -import av -import av.video.format import os -import sys import argparse -import numpy as np import multiprocessing import time import signal +from collections import deque import openpilot.cereal.messaging as messaging from msgq.visionipc import VisionIpcServer, VisionStreamType +from openpilot.tools.camerastream.ffmpeg_decoder import Decoder, FFmpegError V4L2_BUF_FLAG_KEYFRAME = 8 @@ -25,23 +23,12 @@ ENCODE_SOCKETS = { VisionStreamType.VISION_STREAM_WIDE_ROAD: "wideRoadEncodeData", } -def decoder(addr, vipc_server, vst, nvidia, W, H, debug=False): +def decoder(addr, vipc_server, vst, W, H, debug=False): sock_name = ENCODE_SOCKETS[vst] if debug: print(f"start decoder for {sock_name}, {W}x{H}") - if nvidia: - os.environ["NV_LOW_LATENCY"] = "3" # both bLowLatency and CUVID_PKT_ENDOFPICTURE - sys.path += os.environ["LD_LIBRARY_PATH"].split(":") - import PyNvCodec as nvc - - nvDec = nvc.PyNvDecoder(W, H, nvc.PixelFormat.NV12, nvc.CudaVideoCodec.HEVC, 0) - cc1 = nvc.ColorspaceConversionContext(nvc.ColorSpace.BT_709, nvc.ColorRange.JPEG) - conv_yuv = nvc.PySurfaceConverter(W, H, nvc.PixelFormat.NV12, nvc.PixelFormat.YUV420, 0) - nvDwn_yuv = nvc.PySurfaceDownloader(W, H, nvc.PixelFormat.YUV420, 0) - img_yuv = np.ndarray((H*W//2*3), dtype=np.uint8) - else: - codec = av.CodecContext.create("hevc", "r") + codec = Decoder("hevc") os.environ["ZMQ"] = "1" messaging.reset_context() @@ -50,13 +37,22 @@ def decoder(addr, vipc_server, vst, nvidia, W, H, debug=False): last_idx = -1 seen_iframe = False - time_q = [] + time_q = deque() + + def resync(): + nonlocal seen_iframe + codec.reset() + seen_iframe = False + time_q.clear() + while 1: msgs = messaging.drain_sock(sock, wait_for_one=True) for evt in msgs: evta = getattr(evt, evt.which()) - if debug and evta.idx.encodeId != 0 and evta.idx.encodeId != (last_idx+1): - print("DROP PACKET!") + if last_idx != -1 and evta.idx.encodeId != (last_idx + 1): + if debug: + print("DROP PACKET!") + resync() last_idx = evta.idx.encodeId if not seen_iframe and not (evta.idx.flags & V4L2_BUF_FLAG_KEYFRAME): if debug: @@ -67,48 +63,48 @@ def decoder(addr, vipc_server, vst, nvidia, W, H, debug=False): frame_latency = ((evta.idx.timestampEof/1e9) - (evta.idx.timestampSof/1e9))*1000 process_latency = ((evt.logMonoTime/1e9) - (evta.idx.timestampEof/1e9))*1000 - # put in header (first) + # put in header (first) — VPS/SPS/PPS only, no frame expected if not seen_iframe: - if nvidia: - nvDec.DecodeSurfaceFromPacket(np.frombuffer(evta.header, dtype=np.uint8)) - else: - codec.decode(av.packet.Packet(evta.header)) + try: + codec.decode(evta.header) + except FFmpegError as e: + if debug: + print(f"HEADER ERROR: {e}") + resync() + continue seen_iframe = True - if nvidia: - rawSurface = nvDec.DecodeSurfaceFromPacket(np.frombuffer(evta.data, dtype=np.uint8)) - if rawSurface.Empty(): - if debug: - print("DROP SURFACE") - continue - convSurface = conv_yuv.Execute(rawSurface, cc1) - nvDwn_yuv.DownloadSingleSurface(convSurface, img_yuv) - else: - frames = codec.decode(av.packet.Packet(evta.data)) - if len(frames) == 0: - if debug: - print("DROP SURFACE") - continue - assert len(frames) == 1 - img_yuv = frames[0].to_ndarray(format=av.video.format.VideoFormat('yuv420p')).flatten() - uv_offset = H*W - y = img_yuv[:uv_offset] - uv = img_yuv[uv_offset:].reshape(2, -1).ravel('F') - img_yuv = np.hstack((y, uv)) + try: + img_yuv = codec.decode(evta.data) + except FFmpegError as e: + if debug: + print(f"DECODE ERROR: {e}") + resync() + continue - vipc_server.send(vst, img_yuv.data, cnt, int(time_q[0]*1e9), int(time.monotonic()*1e9)) + if img_yuv is None: + if debug: + print("DROP SURFACE") + continue + + if codec.width != W or codec.height != H: + if debug: + print(f"DECODE ERROR: decoded frame is {codec.width}x{codec.height}, expected {W}x{H}") + resync() + continue + + frame_start_time = time_q.popleft() + vipc_server.send(vst, img_yuv.data, cnt, int(frame_start_time*1e9), int(time.monotonic()*1e9)) cnt += 1 - pc_latency = (time.monotonic()-time_q[0])*1000 - time_q = time_q[1:] + pc_latency = (time.monotonic()-frame_start_time)*1000 if debug: print(f"{len(msgs):2d} {evta.idx.encodeId:4d} {evt.logMonoTime/1e9:.3f} {evta.idx.timestampEof/1e6:.3f} \ roll {frame_latency:6.2f} ms latency {process_latency:6.2f} ms + {network_latency:6.2f} ms + {pc_latency:6.2f} ms \ = {process_latency+network_latency+pc_latency:6.2f} ms", len(evta.data), sock_name) - class CompressedVipc: - def __init__(self, addr, vision_streams, server_name, nvidia=False, debug=False): + def __init__(self, addr, vision_streams, server_name, debug=False): print("getting frame sizes") os.environ["ZMQ"] = "1" messaging.reset_context() @@ -127,7 +123,7 @@ class CompressedVipc: self.procs = [] for vst in vision_streams: ed = sm[ENCODE_SOCKETS[vst]] - p = multiprocessing.Process(target=decoder, args=(addr, self.vipc_server, vst, nvidia, ed.width, ed.height, debug)) + p = multiprocessing.Process(target=decoder, args=(addr, self.vipc_server, vst, ed.width, ed.height, debug)) p.start() self.procs.append(p) @@ -143,7 +139,6 @@ class CompressedVipc: if __name__ == "__main__": parser = argparse.ArgumentParser(description="Decode video streams and broadcast on VisionIPC") parser.add_argument("addr", help="Address of comma three") - parser.add_argument("--nvidia", action="store_true", help="Use nvidia instead of ffmpeg") parser.add_argument("--cams", default="0,1,2", help="Cameras to decode") parser.add_argument("--server", default="camerad", help="choose vipc server name") parser.add_argument("--silent", action="store_true", help="Suppress debug output") @@ -156,7 +151,7 @@ if __name__ == "__main__": ] vsts = [vision_streams[int(x)] for x in args.cams.split(",")] - cvipc = CompressedVipc(args.addr, vsts, args.server, args.nvidia, debug=(not args.silent)) + cvipc = CompressedVipc(args.addr, vsts, args.server, debug=(not args.silent)) # register exit handler signal.signal(signal.SIGINT, lambda sig, frame: cvipc.kill()) diff --git a/openpilot/tools/camerastream/ffmpeg_decoder.py b/openpilot/tools/camerastream/ffmpeg_decoder.py new file mode 100644 index 0000000000..13cdc12df1 --- /dev/null +++ b/openpilot/tools/camerastream/ffmpeg_decoder.py @@ -0,0 +1,261 @@ +import ctypes +import errno +import os + +import ffmpeg +import numpy as np + + +AV_INPUT_BUFFER_PADDING_SIZE = 64 +AV_LOG_QUIET = -8 +SWS_FAST_BILINEAR = 1 + + +class FFmpegError(RuntimeError): + pass + + +class AVPacket(ctypes.Structure): + # Public prefix of AVPacket. Only data and size are modified here; the packet + # remains non-refcounted and points at Decoder._packet_buffer. + _fields_ = [ + ("buf", ctypes.c_void_p), + ("pts", ctypes.c_int64), + ("dts", ctypes.c_int64), + ("data", ctypes.POINTER(ctypes.c_uint8)), + ("size", ctypes.c_int), + ] + + +class AVFrame(ctypes.Structure): + # Public prefix of AVFrame through format. Stable within a libavutil major + _fields_ = [ + ("data", ctypes.POINTER(ctypes.c_uint8) * 8), + ("linesize", ctypes.c_int * 8), + ("extended_data", ctypes.POINTER(ctypes.POINTER(ctypes.c_uint8))), + ("width", ctypes.c_int), + ("height", ctypes.c_int), + ("nb_samples", ctypes.c_int), + ("format", ctypes.c_int), + ] + + +def _bind(fn, restype, *argtypes): + fn.restype = restype + fn.argtypes = list(argtypes) + return fn + + +def _load_libraries(): + avutil = ctypes.CDLL(os.path.join(ffmpeg.LIB_DIR, "libavutil.so.59"), mode=ctypes.RTLD_GLOBAL) + avcodec = ctypes.CDLL(os.path.join(ffmpeg.LIB_DIR, "libavcodec.so.61"), mode=ctypes.RTLD_GLOBAL) + swscale = ctypes.CDLL(os.path.join(ffmpeg.LIB_DIR, "libswscale.so.8"), mode=ctypes.RTLD_GLOBAL) + + c_int, c_char_p, c_void_p, c_size_t = ctypes.c_int, ctypes.c_char_p, ctypes.c_void_p, ctypes.c_size_t + c_uint8_p = ctypes.POINTER(ctypes.c_uint8) + c_void_p_p = ctypes.POINTER(c_void_p) + + _bind(avutil.av_log_set_level, None, c_int) + _bind(avutil.av_opt_set, c_int, c_void_p, c_char_p, c_char_p, c_int) + _bind(avutil.av_strerror, c_int, c_int, c_char_p, c_size_t) + _bind(avutil.av_get_pix_fmt, c_int, c_char_p) + + _bind(avcodec.avcodec_find_decoder_by_name, c_void_p, c_char_p) + _bind(avcodec.avcodec_alloc_context3, c_void_p, c_void_p) + _bind(avcodec.avcodec_open2, c_int, c_void_p, c_void_p, c_void_p) + _bind(avcodec.avcodec_free_context, None, c_void_p_p) + _bind(avcodec.avcodec_flush_buffers, None, c_void_p) + _bind(avcodec.avcodec_send_packet, c_int, c_void_p, ctypes.POINTER(AVPacket)) + _bind(avcodec.avcodec_receive_frame, c_int, c_void_p, ctypes.POINTER(AVFrame)) + _bind(avcodec.av_packet_alloc, ctypes.POINTER(AVPacket)) + _bind(avcodec.av_packet_free, None, ctypes.POINTER(ctypes.POINTER(AVPacket))) + _bind(avcodec.av_frame_alloc, ctypes.POINTER(AVFrame)) + _bind(avcodec.av_frame_free, None, ctypes.POINTER(ctypes.POINTER(AVFrame))) + _bind(avcodec.av_frame_unref, None, ctypes.POINTER(AVFrame)) + + _bind(swscale.sws_getCachedContext, c_void_p, + c_void_p, c_int, c_int, c_int, c_int, c_int, c_int, c_int, c_void_p, c_void_p, c_void_p) + _bind(swscale.sws_scale, c_int, + c_void_p, ctypes.POINTER(c_uint8_p), ctypes.POINTER(c_int), + c_int, c_int, ctypes.POINTER(c_uint8_p), ctypes.POINTER(c_int)) + _bind(swscale.sws_freeContext, None, c_void_p) + + avutil.av_log_set_level(AV_LOG_QUIET) + return avutil, avcodec, swscale +_avutil, _avcodec, _swscale = _load_libraries() + +_DataArray = ctypes.POINTER(ctypes.c_uint8) * 4 +_LinesizeArray = ctypes.c_int * 4 + +AV_PIX_FMT_NV12 = _avutil.av_get_pix_fmt(b"nv12") +assert AV_PIX_FMT_NV12 >= 0 + + +def _error_string(code: int) -> str: + buf = ctypes.create_string_buffer(256) + if _avutil.av_strerror(code, buf, len(buf)) == 0: + return buf.value.decode(errors="replace") + return f"FFmpeg error {code}" + + +def _check(code: int, operation: str) -> None: + if code < 0: + raise FFmpegError(f"{operation}: {_error_string(code)}") + + +class Decoder: + def __init__(self, codec_name: str = "hevc"): + self.closed = True + self._sws_context = ctypes.c_void_p() + self._packet_buffer = bytearray() + self._packet_address = 0 + self._packet_data = None + self._output = np.empty(0, dtype=np.uint8) + self._dst_data = _DataArray() + self._dst_linesize = _LinesizeArray() + self.width = 0 + self.height = 0 + self._src_format = -1 + + codec = _avcodec.avcodec_find_decoder_by_name(codec_name.encode()) + if not codec: + raise FFmpegError(f"decoder not found: {codec_name}") + + self._context = ctypes.c_void_p(_avcodec.avcodec_alloc_context3(codec)) + if not self._context: + raise MemoryError("avcodec_alloc_context3 failed") + + self._packet = _avcodec.av_packet_alloc() + if not self._packet: + _avcodec.avcodec_free_context(ctypes.byref(self._context)) + raise MemoryError("av_packet_alloc failed") + + self._frame = _avcodec.av_frame_alloc() + if not self._frame: + _avcodec.av_packet_free(ctypes.byref(self._packet)) + _avcodec.avcodec_free_context(ctypes.byref(self._context)) + raise MemoryError("av_frame_alloc failed") + + try: + # Frame threading holds decoded frames to populate worker pipelines. + # Slice threads can reduce decode time without adding that frame queue; + # four was the latency minimum on the replay camera workload. + _check(_avutil.av_opt_set(self._context, b"threads", b"4", 0), "set decoder threads") + _check(_avutil.av_opt_set(self._context, b"thread_type", b"slice", 0), "set decoder thread type") + _check(_avutil.av_opt_set(self._context, b"flags", b"+low_delay", 0), "set low-delay mode") + _check(_avcodec.avcodec_open2(self._context, codec, None), "open decoder") + except Exception: + _avcodec.av_frame_free(ctypes.byref(self._frame)) + _avcodec.av_packet_free(ctypes.byref(self._packet)) + _avcodec.avcodec_free_context(ctypes.byref(self._context)) + raise + + self.closed = False + + def __enter__(self): + self._ensure_open() + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.close() + + def _ensure_open(self) -> None: + if self.closed: + raise RuntimeError("decoder is closed") + + def _prepare_packet(self, data) -> None: + size = len(data) + required = size + AV_INPUT_BUFFER_PADDING_SIZE + if len(self._packet_buffer) < required: + # Grow-only; the address stays valid until the next reallocation. + self._packet_buffer = bytearray(required) + self._packet_address = ctypes.addressof(ctypes.c_uint8.from_buffer(self._packet_buffer)) + self._packet_data = ctypes.cast(self._packet_address, ctypes.POINTER(ctypes.c_uint8)) + + self._packet_buffer[:size] = data + ctypes.memset(self._packet_address + size, 0, AV_INPUT_BUFFER_PADDING_SIZE) + self._packet.contents.data = self._packet_data + self._packet.contents.size = size + + def _prepare_output(self, frame: AVFrame) -> None: + width, height, src_format = frame.width, frame.height, frame.format + if width <= 0 or height <= 0 or width % 2 or height % 2: + raise FFmpegError(f"unsupported frame dimensions: {width}x{height}") + if (width, height, src_format) == (self.width, self.height, self._src_format): + return + + sws_context = _swscale.sws_getCachedContext( + self._sws_context, width, height, src_format, + width, height, AV_PIX_FMT_NV12, SWS_FAST_BILINEAR, + None, None, None, + ) + if not sws_context: + raise FFmpegError("sws_getCachedContext failed") + self._sws_context = ctypes.c_void_p(sws_context) + + self.width, self.height = width, height + self._src_format = src_format + self._output = np.empty(width * height * 3 // 2, dtype=np.uint8) + output_address = self._output.ctypes.data + self._dst_data = _DataArray( + ctypes.cast(output_address, ctypes.POINTER(ctypes.c_uint8)), + ctypes.cast(output_address + width * height, ctypes.POINTER(ctypes.c_uint8)), + None, + None, + ) + self._dst_linesize = _LinesizeArray(width, width, 0, 0) + + def _receive(self) -> np.ndarray | None: + """Return one NV12 frame, or None if the decoder needs more input. + + The returned buffer is reused on the next successful decode; callers must + use or copy it before calling decode again. + """ + result = _avcodec.avcodec_receive_frame(self._context, self._frame) + if result == -errno.EAGAIN: + return None + _check(result, "receive decoded frame") + + try: + frame = self._frame.contents + self._prepare_output(frame) + rows = _swscale.sws_scale( + self._sws_context, frame.data, frame.linesize, 0, frame.height, + self._dst_data, self._dst_linesize, + ) + if rows != frame.height: + raise FFmpegError(f"convert decoded frame: produced {rows} of {frame.height} rows") + return self._output + finally: + _avcodec.av_frame_unref(self._frame) + + def decode(self, data) -> np.ndarray | None: + self._ensure_open() + if len(data) == 0: + return None + + self._prepare_packet(data) + result = _avcodec.avcodec_send_packet(self._context, self._packet) + # The packet buffer is ours, not FFmpeg's. Clear the borrowed pointer so + # packet teardown can never attempt to release it. + self._packet.contents.data = None + self._packet.contents.size = 0 + _check(result, "send packet to decoder") + return self._receive() + + def reset(self) -> None: + """Discard decoder state after a stream discontinuity.""" + self._ensure_open() + _avcodec.avcodec_flush_buffers(self._context) + + def close(self) -> None: + if self.closed: + return + self.closed = True + _swscale.sws_freeContext(self._sws_context) + _avcodec.av_frame_free(ctypes.byref(self._frame)) + _avcodec.av_packet_free(ctypes.byref(self._packet)) + _avcodec.avcodec_free_context(ctypes.byref(self._context)) + + def __del__(self): + self.close() diff --git a/pyproject.toml b/pyproject.toml index de356e4b6f..3d90dfe167 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,9 +37,6 @@ dependencies = [ "comma-deps-git-lfs", "comma-deps-gcc-arm-none-eabi", - # body / webrtcd - "av", - # logging "pyzmq", "sentry-sdk", diff --git a/uv.lock b/uv.lock index d771c2aacb..6e14cbc02a 100644 --- a/uv.lock +++ b/uv.lock @@ -17,21 +17,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] -[[package]] -name = "av" -version = "16.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/78/cd/3a83ffbc3cc25b39721d174487fb0d51a76582f4a1703f98e46170ce83d4/av-16.1.0.tar.gz", hash = "sha256:a094b4fd87a3721dacf02794d3d2c82b8d712c85b9534437e82a8a978c175ffd", size = 4285203, upload-time = "2026-01-11T07:31:33.772Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/84/2535f55edcd426cebec02eb37b811b1b0c163f26b8d3f53b059e2ec32665/av-16.1.0-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:640f57b93f927fba8689f6966c956737ee95388a91bd0b8c8b5e0481f73513d6", size = 26945785, upload-time = "2026-01-09T20:18:34.486Z" }, - { url = "https://files.pythonhosted.org/packages/b6/17/ffb940c9e490bf42e86db4db1ff426ee1559cd355a69609ec1efe4d3a9eb/av-16.1.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:ae3fb658eec00852ebd7412fdc141f17f3ddce8afee2d2e1cf366263ad2a3b35", size = 21481147, upload-time = "2026-01-09T20:18:36.716Z" }, - { url = "https://files.pythonhosted.org/packages/15/c1/e0d58003d2d83c3921887d5c8c9b8f5f7de9b58dc2194356a2656a45cfdc/av-16.1.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:27ee558d9c02a142eebcbe55578a6d817fedfde42ff5676275504e16d07a7f86", size = 39517197, upload-time = "2026-01-11T09:57:31.937Z" }, - { url = "https://files.pythonhosted.org/packages/32/77/787797b43475d1b90626af76f80bfb0c12cfec5e11eafcfc4151b8c80218/av-16.1.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:7ae547f6d5fa31763f73900d43901e8c5fa6367bb9a9840978d57b5a7ae14ed2", size = 41174337, upload-time = "2026-01-11T09:57:35.792Z" }, - { url = "https://files.pythonhosted.org/packages/8e/ac/d90df7f1e3b97fc5554cf45076df5045f1e0a6adf13899e10121229b826c/av-16.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8cf065f9d438e1921dc31fc7aa045790b58aee71736897866420d80b5450f62a", size = 40817720, upload-time = "2026-01-11T09:57:39.039Z" }, - { url = "https://files.pythonhosted.org/packages/80/6f/13c3a35f9dbcebafd03fe0c4cbd075d71ac8968ec849a3cfce406c35a9d2/av-16.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a345877a9d3cc0f08e2bc4ec163ee83176864b92587afb9d08dff50f37a9a829", size = 42267396, upload-time = "2026-01-11T09:57:42.115Z" }, - { url = "https://files.pythonhosted.org/packages/c8/b9/275df9607f7fb44317ccb1d4be74827185c0d410f52b6e2cd770fe209118/av-16.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:f49243b1d27c91cd8c66fdba90a674e344eb8eb917264f36117bf2b6879118fd", size = 31752045, upload-time = "2026-01-11T09:57:45.106Z" }, -] - [[package]] name = "certifi" version = "2026.6.17" @@ -717,7 +702,6 @@ name = "openpilot" version = "0.1.0" source = { editable = "." } dependencies = [ - { name = "av" }, { name = "cffi" }, { name = "comma-deps-acados" }, { name = "comma-deps-bootstrap-icons" }, @@ -794,7 +778,6 @@ standalone = [ [package.metadata] requires-dist = [ - { name = "av" }, { name = "cffi" }, { name = "codespell", marker = "extra == 'testing'" }, { name = "comma-deps-acados" }, From 1c07e0075914b09685683ebb2cd9cb8138ea822f Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Sat, 18 Jul 2026 13:55:47 -0700 Subject: [PATCH 14/35] rm xattr (#38373) * rm xattr * doesn't add new coverage --- openpilot/system/loggerd/xattr_cache.py | 53 +++++++++++++++++++++++-- pyproject.toml | 1 - uv.lock | 20 ---------- 3 files changed, 50 insertions(+), 24 deletions(-) diff --git a/openpilot/system/loggerd/xattr_cache.py b/openpilot/system/loggerd/xattr_cache.py index 39bb172059..e0f6ba9588 100644 --- a/openpilot/system/loggerd/xattr_cache.py +++ b/openpilot/system/loggerd/xattr_cache.py @@ -1,6 +1,53 @@ +import ctypes import errno +import os +import sys -import xattr + +if sys.platform == "darwin": + _libc = ctypes.CDLL(None, use_errno=True) + _libc.getxattr.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_void_p, ctypes.c_size_t, ctypes.c_uint32, ctypes.c_int] + _libc.getxattr.restype = ctypes.c_ssize_t + _libc.setxattr.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_void_p, ctypes.c_size_t, ctypes.c_uint32, ctypes.c_int] + _libc.setxattr.restype = ctypes.c_int + + +def _raise_os_error(path: str) -> None: + error = ctypes.get_errno() + raise OSError(error, os.strerror(error), path) + + +def _getxattr(path: str, attr_name: str) -> bytes: + if sys.platform != "darwin": + return os.getxattr(path, attr_name) + + encoded_path = os.fsencode(path) + encoded_attr_name = os.fsencode(attr_name) + while True: + size = _libc.getxattr(encoded_path, encoded_attr_name, None, 0, 0, 0) + if size == -1: + _raise_os_error(path) + if size == 0: + return b"" + + value = ctypes.create_string_buffer(size) + result = _libc.getxattr(encoded_path, encoded_attr_name, value, size, 0, 0) + if result != -1: + return value.raw[:result] + if ctypes.get_errno() != errno.ERANGE: + _raise_os_error(path) + + +def _setxattr(path: str, attr_name: str, attr_value: bytes) -> None: + if sys.platform != "darwin": + os.setxattr(path, attr_name, attr_value) + return + + encoded_path = os.fsencode(path) + encoded_attr_name = os.fsencode(attr_name) + value = ctypes.create_string_buffer(attr_value) + if _libc.setxattr(encoded_path, encoded_attr_name, value, len(attr_value), 0, 0) == -1: + _raise_os_error(path) _cached_attributes: dict[tuple, bytes | None] = {} @@ -8,7 +55,7 @@ def getxattr(path: str, attr_name: str) -> bytes | None: key = (path, attr_name) if key not in _cached_attributes: try: - response = xattr.getxattr(path, attr_name) + response = _getxattr(path, attr_name) except OSError as e: # ENODATA (Linux) or ENOATTR (macOS) means attribute hasn't been set if e.errno == errno.ENODATA or (hasattr(errno, 'ENOATTR') and e.errno == errno.ENOATTR): @@ -20,4 +67,4 @@ def getxattr(path: str, attr_name: str) -> bytes | None: def setxattr(path: str, attr_name: str, attr_value: bytes) -> None: _cached_attributes.pop((path, attr_name), None) - xattr.setxattr(path, attr_name, attr_value) + _setxattr(path, attr_name, attr_value) diff --git a/pyproject.toml b/pyproject.toml index 3d90dfe167..c33b33c44c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,6 @@ dependencies = [ # logging "pyzmq", "sentry-sdk", - "xattr", # used in place of 'os.getxattr' for macOS compatibility # athena "PyJWT[crypto]", diff --git a/uv.lock b/uv.lock index 6e14cbc02a..4ff0815783 100644 --- a/uv.lock +++ b/uv.lock @@ -734,7 +734,6 @@ dependencies = [ { name = "sounddevice" }, { name = "tqdm" }, { name = "websocket-client" }, - { name = "xattr" }, { name = "zstandard" }, ] @@ -830,7 +829,6 @@ requires-dist = [ { name = "tqdm" }, { name = "ty", marker = "extra == 'testing'" }, { name = "websocket-client" }, - { name = "xattr" }, { name = "zensical", marker = "extra == 'docs'" }, { name = "zstandard" }, ] @@ -1466,24 +1464,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, ] -[[package]] -name = "xattr" -version = "1.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/08/d5/25f7b19af3a2cb4000cac4f9e5525a40bec79f4f5d0ac9b517c0544586a0/xattr-1.3.0.tar.gz", hash = "sha256:30439fabd7de0787b27e9a6e1d569c5959854cb322f64ce7380fedbfa5035036", size = 17148, upload-time = "2025-10-13T22:16:47.353Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/78/00bdc9290066173e53e1e734d8d8e1a84a6faa9c66aee9df81e4d9aeec1c/xattr-1.3.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:dd4e63614722d183e81842cb237fd1cc978d43384166f9fe22368bfcb187ebe5", size = 23476, upload-time = "2025-10-13T22:16:06.942Z" }, - { url = "https://files.pythonhosted.org/packages/53/16/5243722294eb982514fa7b6b87a29dfb7b29b8e5e1486500c5babaf6e4b3/xattr-1.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:995843ef374af73e3370b0c107319611f3cdcdb6d151d629449efecad36be4c4", size = 18556, upload-time = "2025-10-13T22:16:08.209Z" }, - { url = "https://files.pythonhosted.org/packages/d6/5c/d7ab0e547bea885b55f097206459bd612cefb652c5fc1f747130cbc0d42c/xattr-1.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fa23a25220e29d956cedf75746e3df6cc824cc1553326d6516479967c540e386", size = 18869, upload-time = "2025-10-13T22:16:10.319Z" }, - { url = "https://files.pythonhosted.org/packages/98/25/25cc7d64f07de644b7e9057842227adf61017e5bcfe59a79df79f768874c/xattr-1.3.0-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b4345387087fffcd28f709eb45aae113d911e1a1f4f0f70d46b43ba81e69ccdd", size = 38797, upload-time = "2025-10-13T22:16:11.624Z" }, - { url = "https://files.pythonhosted.org/packages/a9/24/cc350bcdbed006dfcc6ade0ac817693b8b3d4b2787f20e427fd0697042e4/xattr-1.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe92bb05eb849ab468fe13e942be0f8d7123f15d074f3aba5223fad0c4b484de", size = 38956, upload-time = "2025-10-13T22:16:13.121Z" }, - { url = "https://files.pythonhosted.org/packages/9b/b2/9416317ac89e2ed759a861857cda0d5e284c3691e6f460d36cc2bd5ce4d1/xattr-1.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6c42ef5bdac3febbe28d3db14d3a8a159d84ba5daca2b13deae6f9f1fc0d4092", size = 38214, upload-time = "2025-10-13T22:16:14.389Z" }, - { url = "https://files.pythonhosted.org/packages/38/63/188f7cb41ab35d795558325d5cc8ab552171d5498cfb178fd14409651e18/xattr-1.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2aaa5d66af6523332189108f34e966ca120ff816dfa077ca34b31e6263f8a236", size = 37754, upload-time = "2025-10-13T22:16:15.306Z" }, -] - [[package]] name = "zensical" version = "0.0.46" From cefcf10ec31f76cbcd98663bf48d7f51bcf56b28 Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Sun, 19 Jul 2026 08:15:01 -0700 Subject: [PATCH 15/35] rm jinja2 --- pyproject.toml | 1 - uv.lock | 2 -- 2 files changed, 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c33b33c44c..d58991adf5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,7 +63,6 @@ dependencies = [ [project.optional-dependencies] docs = [ - "Jinja2", "zensical", ] diff --git a/uv.lock b/uv.lock index 4ff0815783..6d8fbba14d 100644 --- a/uv.lock +++ b/uv.lock @@ -742,7 +742,6 @@ dev = [ { name = "matplotlib" }, ] docs = [ - { name = "jinja2" }, { name = "zensical" }, ] submodules = [ @@ -799,7 +798,6 @@ requires-dist = [ { name = "hypothesis", marker = "extra == 'testing'", specifier = "==6.47.*" }, { name = "inputs" }, { name = "jeepney" }, - { name = "jinja2", marker = "extra == 'docs'" }, { name = "matplotlib", marker = "extra == 'dev'" }, { name = "msgq", marker = "extra == 'submodules'", editable = "msgq_repo" }, { name = "numpy", specifier = ">=2.0" }, From 7d74c3c99b440868490706d34442d1749c06416d Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Sun, 19 Jul 2026 08:25:31 -0700 Subject: [PATCH 16/35] rm setuptools (#38376) * rm setuptools * lock --- pyproject.toml | 1 - uv.lock | 2 -- 2 files changed, 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d58991adf5..7ff0482b2f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,6 @@ dependencies = [ "scons", "pycapnp==2.1.0", # 2.2 introduces a memory leak due to cyclic references "Cython", - "setuptools", "numpy >=2.0", # vendored native dependencies diff --git a/uv.lock b/uv.lock index 6d8fbba14d..cb6f859b50 100644 --- a/uv.lock +++ b/uv.lock @@ -730,7 +730,6 @@ dependencies = [ { name = "scons" }, { name = "sentry-sdk" }, { name = "setproctitle" }, - { name = "setuptools" }, { name = "sounddevice" }, { name = "tqdm" }, { name = "websocket-client" }, @@ -820,7 +819,6 @@ requires-dist = [ { name = "scons" }, { name = "sentry-sdk" }, { name = "setproctitle" }, - { name = "setuptools" }, { name = "sounddevice" }, { name = "teleoprtc", marker = "extra == 'submodules'", editable = "teleoprtc_repo" }, { name = "tinygrad", marker = "extra == 'submodules'", editable = "tinygrad_repo" }, From e475d10adcc7aafc845f81cfb724e31cf5c3fb2b Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Sun, 19 Jul 2026 08:51:16 -0700 Subject: [PATCH 17/35] more ruff (#38377) * lil more ruff * no exclusions! * rm nb exception * and generated * not used * c408 * random * all * unittest is fine --- openpilot/cereal/messaging/__init__.py | 79 +++++++++++++------ .../messaging/tests/test_pub_sub_master.py | 3 +- openpilot/cereal/services.py | 6 +- openpilot/common/swaglog.py | 2 +- openpilot/selfdrive/car/tests/test_models.py | 2 +- .../tests/test_torqued_lat_accel_offset.py | 2 - openpilot/selfdrive/locationd/helpers.py | 3 +- .../selfdrive/locationd/test/test_lagd.py | 11 +-- openpilot/selfdrive/modeld/compile_modeld.py | 4 +- .../selfdrive/pandad/tests/test_pandad_spi.py | 4 +- openpilot/selfdrive/test/mem_usage.py | 2 +- .../selfdrive/ui/layouts/settings/settings.py | 4 +- .../selfdrive/ui/tests/profile_onroad.py | 2 +- openpilot/system/athena/tests/test_athenad.py | 2 +- .../system/loggerd/tests/test_uploader.py | 4 +- openpilot/system/ui/lib/shader_polygon.py | 4 +- openpilot/system/webrtc/webrtcd.py | 5 +- openpilot/tools/lib/url_file.py | 2 +- openpilot/tools/lib/vidindex.py | 2 +- openpilot/tools/replay/lib/ui_helpers.py | 4 +- .../sim/bridge/metadrive/metadrive_bridge.py | 60 +++++++------- pyproject.toml | 19 ++--- .../examples/find_segments_with_message.ipynb | 5 +- .../examples/ford_vin_fingerprint.ipynb | 9 ++- .../examples/hkg_canfd_gear_message.ipynb | 10 +-- tools/car_porting/test_car_model.py | 2 +- 26 files changed, 136 insertions(+), 116 deletions(-) diff --git a/openpilot/cereal/messaging/__init__.py b/openpilot/cereal/messaging/__init__.py index cb5f090095..dcc54baeb0 100644 --- a/openpilot/cereal/messaging/__init__.py +++ b/openpilot/cereal/messaging/__init__.py @@ -7,12 +7,43 @@ import os import capnp import time -from typing import Optional, List, Union, Dict +from typing import Union from openpilot.cereal import log from openpilot.cereal.services import SERVICE_LIST from openpilot.common.utils import MovingAverage +__all__ = ( + "NO_TRAVERSAL_LIMIT", + "Context", + "FrequencyTracker", + "IpcError", + "MultiplePublishersError", + "Poller", + "PubMaster", + "PubSocket", + "SocketEventHandle", + "SubMaster", + "SubSocket", + "delete_fake_prefix", + "drain_sock", + "drain_sock_raw", + "fake_event_handle", + "get_fake_prefix", + "log_from_bytes", + "new_message", + "pub_sock", + "recv_one", + "recv_one_or_none", + "recv_one_retry", + "recv_sock", + "reset_context", + "set_fake_prefix", + "sub_sock", + "toggle_fake_events", + "wait_for_one_event", +) + NO_TRAVERSAL_LIMIT = 2**64-1 @@ -22,8 +53,8 @@ def pub_sock(endpoint: str) -> PubSocket: return msgq.pub_sock(endpoint, segment_size) -def sub_sock(endpoint: str, poller: Optional[Poller] = None, addr: str = "127.0.0.1", - conflate: bool = False, timeout: Optional[int] = None) -> SubSocket: +def sub_sock(endpoint: str, poller: Poller | None = None, addr: str = "127.0.0.1", + conflate: bool = False, timeout: int | None = None) -> SubSocket: service = SERVICE_LIST.get(endpoint) segment_size = service.queue_size if service else 0 return msgq.sub_sock(endpoint, poller=poller, addr=addr, conflate=conflate, @@ -39,7 +70,7 @@ def log_from_bytes(dat: bytes, struct: capnp.lib.capnp._StructModule = log.Event return msg -def new_message(service: Optional[str], size: Optional[int] = None, **kwargs) -> capnp.lib.capnp._DynamicStructBuilder: +def new_message(service: str | None, size: int | None = None, **kwargs) -> capnp.lib.capnp._DynamicStructBuilder: args = { 'valid': False, 'logMonoTime': int(time.monotonic() * 1e9), @@ -54,14 +85,14 @@ def new_message(service: Optional[str], size: Optional[int] = None, **kwargs) -> return dat -def drain_sock(sock: SubSocket, wait_for_one: bool = False) -> List[capnp.lib.capnp._DynamicStructReader]: +def drain_sock(sock: SubSocket, wait_for_one: bool = False) -> list[capnp.lib.capnp._DynamicStructReader]: """Receive all message currently available on the queue""" msgs = drain_sock_raw(sock, wait_for_one=wait_for_one) return [log_from_bytes(m) for m in msgs] # TODO: print when we drop packets? -def recv_sock(sock: SubSocket, wait: bool = False) -> Optional[capnp.lib.capnp._DynamicStructReader]: +def recv_sock(sock: SubSocket, wait: bool = False) -> capnp.lib.capnp._DynamicStructReader | None: """Same as drain sock, but only returns latest message. Consider using conflate instead.""" dat = None @@ -82,14 +113,14 @@ def recv_sock(sock: SubSocket, wait: bool = False) -> Optional[capnp.lib.capnp._ return dat -def recv_one(sock: SubSocket) -> Optional[capnp.lib.capnp._DynamicStructReader]: +def recv_one(sock: SubSocket) -> capnp.lib.capnp._DynamicStructReader | None: dat = sock.receive() if dat is not None: dat = log_from_bytes(dat) return dat -def recv_one_or_none(sock: SubSocket) -> Optional[capnp.lib.capnp._DynamicStructReader]: +def recv_one_or_none(sock: SubSocket) -> capnp.lib.capnp._DynamicStructReader | None: dat = sock.receive(non_blocking=True) if dat is not None: dat = log_from_bytes(dat) @@ -148,27 +179,27 @@ class FrequencyTracker: class SubMaster: - def __init__(self, services: List[str], poll: Optional[str] = None, - ignore_alive: Optional[List[str]] = None, ignore_avg_freq: Optional[List[str]] = None, - ignore_valid: Optional[List[str]] = None, addr: str = "127.0.0.1", frequency: Optional[float] = None): + def __init__(self, services: list[str], poll: str | None = None, + ignore_alive: list[str] | None = None, ignore_avg_freq: list[str] | None = None, + ignore_valid: list[str] | None = None, addr: str = "127.0.0.1", frequency: float | None = None): self.frame = -1 self.services = services - self.seen = {s: False for s in services} - self.updated = {s: False for s in services} - self.recv_time = {s: 0. for s in services} - self.recv_frame = {s: 0 for s in services} + self.seen = dict.fromkeys(services, False) + self.updated = dict.fromkeys(services, False) + self.recv_time = dict.fromkeys(services, 0.0) + self.recv_frame = dict.fromkeys(services, 0) self.sock = {} self.data = {} - self.logMonoTime = {s: 0 for s in services} + self.logMonoTime = dict.fromkeys(services, 0) # zero-frequency / on-demand services are always alive and presumed valid; all others must pass checks on_demand = {s: SERVICE_LIST[s].frequency <= 1e-5 for s in services} - self.static_freq_services = set(s for s in services if not on_demand[s]) + self.static_freq_services = {s for s in services if not on_demand[s]} self.alive = {s: on_demand[s] for s in services} self.freq_ok = {s: on_demand[s] for s in services} self.valid = {s: on_demand[s] for s in services} - self.freq_tracker: Dict[str, FrequencyTracker] = {} + self.freq_tracker: dict[str, FrequencyTracker] = {} self.poller = Poller() polled_services = set([poll, ] if poll is not None else services) self.non_polled_services = set(services) - polled_services @@ -211,7 +242,7 @@ class SubMaster: msgs.append(recv_one_or_none(self.sock[s])) self.update_msgs(time.monotonic(), msgs) - def update_msgs(self, cur_time: float, msgs: List[capnp.lib.capnp._DynamicStructReader]) -> None: + def update_msgs(self, cur_time: float, msgs: list[capnp.lib.capnp._DynamicStructReader]) -> None: self.frame += 1 self.updated = dict.fromkeys(self.services, False) for msg in msgs: @@ -234,21 +265,21 @@ class SubMaster: self.alive[s] = (cur_time - self.recv_time[s]) < (10. / SERVICE_LIST[s].frequency) or (self.seen[s] and self.simulation) self.freq_ok[s] = self.freq_tracker[s].valid or self.simulation - def all_alive(self, service_list: Optional[List[str]] = None) -> bool: + def all_alive(self, service_list: list[str] | None = None) -> bool: return all(self.alive[s] for s in (service_list or self.services) if s not in self.ignore_alive) - def all_freq_ok(self, service_list: Optional[List[str]] = None) -> bool: + def all_freq_ok(self, service_list: list[str] | None = None) -> bool: return all(self.freq_ok[s] for s in (service_list or self.services) if self._check_avg_freq(s)) - def all_valid(self, service_list: Optional[List[str]] = None) -> bool: + def all_valid(self, service_list: list[str] | None = None) -> bool: return all(self.valid[s] for s in (service_list or self.services) if s not in self.ignore_valid) - def all_checks(self, service_list: Optional[List[str]] = None) -> bool: + def all_checks(self, service_list: list[str] | None = None) -> bool: return self.all_alive(service_list) and self.all_freq_ok(service_list) and self.all_valid(service_list) class PubMaster: - def __init__(self, services: List[str]): + def __init__(self, services: list[str]): self.sock = {} for s in services: self.sock[s] = pub_sock(s) diff --git a/openpilot/cereal/messaging/tests/test_pub_sub_master.py b/openpilot/cereal/messaging/tests/test_pub_sub_master.py index eb8f62140b..02f944b69f 100644 --- a/openpilot/cereal/messaging/tests/test_pub_sub_master.py +++ b/openpilot/cereal/messaging/tests/test_pub_sub_master.py @@ -1,6 +1,7 @@ import random import time -from typing import Sized, cast +from typing import cast +from collections.abc import Sized import openpilot.cereal.messaging as messaging from openpilot.cereal.messaging.tests.test_messaging import events, random_sock, random_socks, \ diff --git a/openpilot/cereal/services.py b/openpilot/cereal/services.py index 0f5e50f31f..db1731a986 100755 --- a/openpilot/cereal/services.py +++ b/openpilot/cereal/services.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 from enum import IntEnum -from typing import Optional # TODO: this should be automatically determined using the capnp schema @@ -11,7 +10,7 @@ class QueueSize(IntEnum): class Service: - def __init__(self, should_log: bool, frequency: float, decimation: Optional[int] = None, + def __init__(self, should_log: bool, frequency: float, decimation: int | None = None, queue_size: QueueSize = QueueSize.SMALL): self.should_log = should_log self.frequency = frequency @@ -112,8 +111,7 @@ def build_header(): for k, v in SERVICE_LIST.items(): should_log = "true" if v.should_log else "false" decimation = -1 if v.decimation is None else v.decimation - h += ' { "%s", {"%s", %s, %f, %d, %d}},\n' % \ - (k, k, should_log, v.frequency, decimation, v.queue_size) + h += f' {{ "{k}", {{"{k}", {should_log}, {v.frequency:f}, {decimation:d}, {v.queue_size:d}}}}},\n' h += "};\n" h += "#endif\n" diff --git a/openpilot/common/swaglog.py b/openpilot/common/swaglog.py index ea72766fcb..ac64230eed 100644 --- a/openpilot/common/swaglog.py +++ b/openpilot/common/swaglog.py @@ -39,7 +39,7 @@ class SwaglogRotatingFileHandler(BaseRotatingHandler): return stream def get_existing_logfiles(self): - log_files = list() + log_files = [] base_dir = os.path.dirname(self.base_filename) for fn in os.listdir(base_dir): fp = os.path.join(base_dir, fn) diff --git a/openpilot/selfdrive/car/tests/test_models.py b/openpilot/selfdrive/car/tests/test_models.py index ac2cd3ec7e..436de261e6 100644 --- a/openpilot/selfdrive/car/tests/test_models.py +++ b/openpilot/selfdrive/car/tests/test_models.py @@ -2,7 +2,7 @@ import time import os import pytest import random -import unittest # noqa: TID251 +import unittest from collections import defaultdict, Counter import hypothesis.strategies as st from hypothesis import Phase, given, settings diff --git a/openpilot/selfdrive/controls/tests/test_torqued_lat_accel_offset.py b/openpilot/selfdrive/controls/tests/test_torqued_lat_accel_offset.py index 93a57d7f60..7a63da23a5 100644 --- a/openpilot/selfdrive/controls/tests/test_torqued_lat_accel_offset.py +++ b/openpilot/selfdrive/controls/tests/test_torqued_lat_accel_offset.py @@ -7,8 +7,6 @@ from opendbc.car.lateral import get_friction, FRICTION_THRESHOLD from openpilot.common.realtime import DT_MDL from openpilot.selfdrive.locationd.torqued import TorqueEstimator, MIN_BUCKET_POINTS, POINTS_PER_BUCKET, STEER_BUCKET_BOUNDS -np.random.seed(0) - LA_ERR_STD = 1.0 INPUT_NOISE_STD = 0.08 V_EGO = 30.0 diff --git a/openpilot/selfdrive/locationd/helpers.py b/openpilot/selfdrive/locationd/helpers.py index fe7930c509..849b950de2 100644 --- a/openpilot/selfdrive/locationd/helpers.py +++ b/openpilot/selfdrive/locationd/helpers.py @@ -69,6 +69,7 @@ class NPQueue: class PointBuckets: def __init__(self, x_bounds: list[tuple[float, float]], min_points: list[float], min_points_total: int, points_per_bucket: int, rowsize: int) -> None: + self._rng = np.random.default_rng() self.x_bounds = x_bounds self.buckets = {bounds: NPQueue(maxlen=points_per_bucket, rowsize=rowsize) for bounds in x_bounds} self.buckets_min_points = dict(zip(x_bounds, min_points, strict=True)) @@ -98,7 +99,7 @@ class PointBuckets: points = np.vstack([x.arr for x in self.buckets.values()]) if num_points is None: return points - return points[np.random.choice(np.arange(len(points)), min(len(points), num_points), replace=False)] + return points[self._rng.choice(np.arange(len(points)), min(len(points), num_points), replace=False)] def load_points(self, points: list[list[float]]) -> None: for point in points: diff --git a/openpilot/selfdrive/locationd/test/test_lagd.py b/openpilot/selfdrive/locationd/test/test_lagd.py index 128c19332f..efaba6ead9 100644 --- a/openpilot/selfdrive/locationd/test/test_lagd.py +++ b/openpilot/selfdrive/locationd/test/test_lagd.py @@ -81,6 +81,7 @@ class TestLagd: assert retrieve_initial_lag(params, CP) is None def test_ncc(self): + rng = np.random.default_rng() lag_frames = random.randint(1, 19) desired_sig = np.sin(np.arange(0.0, 10.0, 0.1)) @@ -91,15 +92,15 @@ class TestLagd: assert np.argmax(corr) == lag_frames # add some noise - desired_sig += np.random.normal(0, 0.05, len(desired_sig)) - actual_sig += np.random.normal(0, 0.05, len(actual_sig)) + desired_sig += rng.normal(0, 0.05, len(desired_sig)) + actual_sig += rng.normal(0, 0.05, len(actual_sig)) corr = masked_normalized_cross_correlation(desired_sig, actual_sig, mask, 200)[len(desired_sig) - 1:len(desired_sig) + 20] assert np.argmax(corr) in range(lag_frames - MAX_ERR_FRAMES, lag_frames + MAX_ERR_FRAMES + 1) # mask out 40% of the values, and make them noise - mask = np.random.choice([True, False], size=len(desired_sig), p=[0.6, 0.4]) - desired_sig[~mask] = np.random.normal(0, 1, size=np.sum(~mask)) - actual_sig[~mask] = np.random.normal(0, 1, size=np.sum(~mask)) + mask = rng.choice([True, False], size=len(desired_sig), p=[0.6, 0.4]) + desired_sig[~mask] = rng.normal(0, 1, size=np.sum(~mask)) + actual_sig[~mask] = rng.normal(0, 1, size=np.sum(~mask)) corr = masked_normalized_cross_correlation(desired_sig, actual_sig, mask, 200)[len(desired_sig) - 1:len(desired_sig) + 20] assert np.argmax(corr) in range(lag_frames - MAX_ERR_FRAMES, lag_frames + MAX_ERR_FRAMES + 1) diff --git a/openpilot/selfdrive/modeld/compile_modeld.py b/openpilot/selfdrive/modeld/compile_modeld.py index 769fb69eb3..f74ab27b8c 100755 --- a/openpilot/selfdrive/modeld/compile_modeld.py +++ b/openpilot/selfdrive/modeld/compile_modeld.py @@ -242,7 +242,7 @@ def compile_jit(jit, make_random_inputs, input_keys, make_queues): SEED = 42 def random_inputs_run(fn, seed, test_val=None, test_buffers=None, expect_match=True): input_queues, npy = make_queues(Device.DEFAULT) - np.random.seed(seed) + rng = np.random.default_rng(seed) Tensor.manual_seed(seed) testing = test_val is not None or test_buffers is not None @@ -250,7 +250,7 @@ def compile_jit(jit, make_random_inputs, input_keys, make_queues): for i in range(n_runs): for v in npy.values(): - v[:] = np.random.randn(*v.shape).astype(v.dtype) + v[:] = rng.standard_normal(v.shape).astype(v.dtype) Device.default.synchronize() random_inputs = make_random_inputs() st = time.perf_counter() diff --git a/openpilot/selfdrive/pandad/tests/test_pandad_spi.py b/openpilot/selfdrive/pandad/tests/test_pandad_spi.py index 02f5accfd6..2bc302b47c 100644 --- a/openpilot/selfdrive/pandad/tests/test_pandad_spi.py +++ b/openpilot/selfdrive/pandad/tests/test_pandad_spi.py @@ -38,10 +38,10 @@ class TestBoarddSpi: total_recv_count = 0 total_sent_count = 0 - sent_msgs = {bus: list() for bus in range(3)} + sent_msgs = {bus: [] for bus in range(3)} st = time.monotonic() - ts = {s: list() for s in socks.keys()} + ts = {s: [] for s in socks.keys()} for _ in range(int(os.getenv("TEST_TIME", "20"))): # send some CAN messages if not JUNGLE_SPAM: diff --git a/openpilot/selfdrive/test/mem_usage.py b/openpilot/selfdrive/test/mem_usage.py index 1446d0b4dc..02b7934466 100644 --- a/openpilot/selfdrive/test/mem_usage.py +++ b/openpilot/selfdrive/test/mem_usage.py @@ -7,7 +7,7 @@ from openpilot.common.utils import tabulate DEMO_ROUTE = "5beb9b58bd12b691/0000010a--a51155e496" MB = 1024 * 1024 -TABULATE_OPTS = dict(tablefmt="simple_grid", stralign="center", numalign="center") +TABULATE_OPTS = {"tablefmt": "simple_grid", "stralign": "center", "numalign": "center"} def _get_procs(): diff --git a/openpilot/selfdrive/ui/layouts/settings/settings.py b/openpilot/selfdrive/ui/layouts/settings/settings.py index 68f45df77d..48b75e5dbd 100644 --- a/openpilot/selfdrive/ui/layouts/settings/settings.py +++ b/openpilot/selfdrive/ui/layouts/settings/settings.py @@ -1,5 +1,5 @@ import pyray as rl -from dataclasses import dataclass +from dataclasses import dataclass, field from enum import IntEnum from collections.abc import Callable from openpilot.selfdrive.ui.layouts.settings.developer import DeveloperLayout @@ -43,7 +43,7 @@ class PanelType(IntEnum): class PanelInfo: name: str instance: Widget - button_rect: rl.Rectangle = rl.Rectangle(0, 0, 0, 0) + button_rect: rl.Rectangle = field(default_factory=lambda: rl.Rectangle(0, 0, 0, 0)) class SettingsLayout(Widget): diff --git a/openpilot/selfdrive/ui/tests/profile_onroad.py b/openpilot/selfdrive/ui/tests/profile_onroad.py index 18194d7363..29e44d6260 100755 --- a/openpilot/selfdrive/ui/tests/profile_onroad.py +++ b/openpilot/selfdrive/ui/tests/profile_onroad.py @@ -92,7 +92,7 @@ if __name__ == "__main__": vipc.create_buffers(VisionStreamType.VISION_STREAM_ROAD, 5, W, H) vipc.start_listener() yuv_buffer_size = W * H + (W // 2) * (H // 2) * 2 - yuv_data = np.random.randint(0, 256, yuv_buffer_size, dtype=np.uint8).tobytes() + yuv_data = np.random.default_rng().integers(0, 256, yuv_buffer_size, dtype=np.uint8).tobytes() with cProfile.Profile() as pr: for _ in gui_app.render(): if ui_state.sm.frame >= len(message_chunks): diff --git a/openpilot/system/athena/tests/test_athenad.py b/openpilot/system/athena/tests/test_athenad.py index 253a057b91..accf0784ab 100644 --- a/openpilot/system/athena/tests/test_athenad.py +++ b/openpilot/system/athena/tests/test_athenad.py @@ -437,7 +437,7 @@ class TestAthenadMethods: thread.join() def test_get_logs_to_send_sorted(self): - fl = list() + fl = [] for i in range(10): file = f'swaglog.{i:010}' self._create_file(file, Paths.swaglog_root()) diff --git a/openpilot/system/loggerd/tests/test_uploader.py b/openpilot/system/loggerd/tests/test_uploader.py index eec85eda2e..3af78e2c37 100644 --- a/openpilot/system/loggerd/tests/test_uploader.py +++ b/openpilot/system/loggerd/tests/test_uploader.py @@ -18,8 +18,8 @@ class FakeLogHandler(logging.Handler): self.reset() def reset(self): - self.upload_order = list() - self.upload_ignored = list() + self.upload_order = [] + self.upload_ignored = [] def emit(self, record): try: diff --git a/openpilot/system/ui/lib/shader_polygon.py b/openpilot/system/ui/lib/shader_polygon.py index 94af35e157..de729d6aae 100644 --- a/openpilot/system/ui/lib/shader_polygon.py +++ b/openpilot/system/ui/lib/shader_polygon.py @@ -152,7 +152,7 @@ class ShaderState: self.initialized = False -def _configure_shader_color(state: ShaderState, color: Optional[rl.Color], +def _configure_shader_color(state: ShaderState, color: Optional[rl.Color], # noqa: UP045 # rl.Color is a function, so `rl.Color | None` fails gradient: Gradient | None, origin_rect: rl.Rectangle): assert (color is not None) != (gradient is not None), "Either color or gradient must be provided" @@ -204,7 +204,7 @@ def triangulate(pts: np.ndarray) -> list[tuple[float, float]]: def draw_polygon(origin_rect: rl.Rectangle, points: np.ndarray, - color: Optional[rl.Color] = None, gradient: Gradient | None = None): + color: Optional[rl.Color] = None, gradient: Gradient | None = None): # noqa: UP045 # rl.Color is a function, so `rl.Color | None` fails """ Draw a ribbon polygon (two chains) with a triangle strip and gradient. diff --git a/openpilot/system/webrtc/webrtcd.py b/openpilot/system/webrtc/webrtcd.py index 8276df98f2..82ebb7b018 100755 --- a/openpilot/system/webrtc/webrtcd.py +++ b/openpilot/system/webrtc/webrtcd.py @@ -566,13 +566,14 @@ def webrtcd_thread(host: str, port: int, debug: bool): http_thread.start() shutting_down = False + shutdown_task = None def request_shutdown() -> None: - nonlocal shutting_down + nonlocal shutting_down, shutdown_task if shutting_down: return shutting_down = True - loop.create_task(_shutdown(server, state, loop)) + shutdown_task = loop.create_task(_shutdown(server, state, loop)) for sig in (signal.SIGINT, signal.SIGTERM): loop.add_signal_handler(sig, request_shutdown) diff --git a/openpilot/tools/lib/url_file.py b/openpilot/tools/lib/url_file.py index 3f72429b94..ec0a3d5815 100644 --- a/openpilot/tools/lib/url_file.py +++ b/openpilot/tools/lib/url_file.py @@ -21,7 +21,7 @@ logging.getLogger("urllib3").setLevel(logging.WARNING) def hash_url(link: str) -> str: - return md5((link.split("?")[0]).encode('utf-8')).hexdigest() + return md5(link.split("?", maxsplit=1)[0].encode('utf-8')).hexdigest() def prune_cache(new_entry: str | None = None) -> None: diff --git a/openpilot/tools/lib/vidindex.py b/openpilot/tools/lib/vidindex.py index 4aef6fb4d5..b200700887 100755 --- a/openpilot/tools/lib/vidindex.py +++ b/openpilot/tools/lib/vidindex.py @@ -269,7 +269,7 @@ def hevc_index(hevc_file_name: str, allow_corrupt: bool=False) -> tuple[list, in raise VideoFileInvalid("first byte must be 0x00") prefix_dat = b"" - frame_types = list() + frame_types = [] i = 1 # skip past first byte 0x00 try: diff --git a/openpilot/tools/replay/lib/ui_helpers.py b/openpilot/tools/replay/lib/ui_helpers.py index 6a3e8c20ed..8f0c14983f 100644 --- a/openpilot/tools/replay/lib/ui_helpers.py +++ b/openpilot/tools/replay/lib/ui_helpers.py @@ -122,8 +122,8 @@ def init_plots(arr, name_to_arr_idx, plot_xlims, plot_ylims, plot_names, plot_co title_texts = [] for j2, (nm, cl) in enumerate(zip(pl_list, plot_colors[i], strict=False)): if j2 > 0: - title_texts.append(TextArea(", ", textprops=dict(color="white", fontsize=10))) - title_texts.append(TextArea(nm, textprops=dict(color=label_palette[cl], fontsize=10))) + title_texts.append(TextArea(", ", textprops={"color": "white", "fontsize": 10})) + title_texts.append(TextArea(nm, textprops={"color": label_palette[cl], "fontsize": 10})) packed = HPacker(children=title_texts, pad=0, sep=0) ab = AnchoredOffsetbox(loc='lower center', child=packed, bbox_to_anchor=(0.5, 1.0), bbox_transform=axs[i].transAxes, frameon=False, pad=0) diff --git a/openpilot/tools/sim/bridge/metadrive/metadrive_bridge.py b/openpilot/tools/sim/bridge/metadrive/metadrive_bridge.py index b8dc94cf86..4222ba45a7 100644 --- a/openpilot/tools/sim/bridge/metadrive/metadrive_bridge.py +++ b/openpilot/tools/sim/bridge/metadrive/metadrive_bridge.py @@ -29,11 +29,11 @@ def curve_block(length, angle=45, direction=0): def create_map(track_size=60): curve_len = track_size * 2 - return dict( - type=MapGenerateMethod.PG_MAP_FILE, - lane_num=2, - lane_width=4.5, - config=[ + return { + "type": MapGenerateMethod.PG_MAP_FILE, + "lane_num": 2, + "lane_width": 4.5, + "config": [ None, straight_block(track_size), curve_block(curve_len, 90), @@ -44,7 +44,7 @@ def create_map(track_size=60): straight_block(track_size), curve_block(curve_len, 90), ] - ) + } class MetaDriveBridge(SimulatorBridge): @@ -65,29 +65,29 @@ class MetaDriveBridge(SimulatorBridge): if self.dual_camera: sensors["rgb_wide"] = (RGBCameraWide, W, H) - config = dict( - use_render=self.should_render, - vehicle_config=dict( - enable_reverse=False, - render_vehicle=False, - image_source="rgb_road", - ), - sensors=sensors, - image_on_cuda=_cuda_enable, - image_observation=True, - interface_panel=[], - out_of_route_done=False, - on_continuous_line_done=False, - crash_vehicle_done=False, - crash_object_done=False, - arrive_dest_done=False, - traffic_density=0.0, # traffic is incredibly expensive - map_config=create_map(), - decision_repeat=1, - physics_world_step_size=self.TICKS_PER_FRAME/100, - preload_models=False, - show_logo=False, - anisotropic_filtering=False - ) + config = { + "use_render": self.should_render, + "vehicle_config": { + "enable_reverse": False, + "render_vehicle": False, + "image_source": "rgb_road", + }, + "sensors": sensors, + "image_on_cuda": _cuda_enable, + "image_observation": True, + "interface_panel": [], + "out_of_route_done": False, + "on_continuous_line_done": False, + "crash_vehicle_done": False, + "crash_object_done": False, + "arrive_dest_done": False, + "traffic_density": 0.0, # traffic is incredibly expensive + "map_config": create_map(), + "decision_repeat": 1, + "physics_world_step_size": self.TICKS_PER_FRAME/100, + "preload_models": False, + "show_logo": False, + "anisotropic_filtering": False + } return MetaDriveWorld(queue, config, self.test_duration, self.test_run, self.dual_camera) diff --git a/pyproject.toml b/pyproject.toml index 7ff0482b2f..597f7a4489 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -152,33 +152,26 @@ lint.select = [ "E", "F", "W", "PIE", "C4", "ISC", "A", "B", "NPY", # numpy "UP", # pyupgrade + "ASYNC", + "B904", "B905", + "PLC0207", "TRY203", "TRY400", "TRY401", # try/excepts - "RUF008", "RUF100", + "RUF006", "RUF008", "RUF009", "RUF061", "RUF064", "RUF100", "RUF102", "RUF103", "RUF104", "TID251", "PLE", "PLR1704", ] lint.ignore = [ "E741", "E402", - "C408", - "ISC003", "B027", - "B024", - "NPY002", # new numpy random syntax is worse - "UP045", "UP007", # these don't play nice with raylib atm + "UP007", # this doesn't play nice with raylib atm ] line-length = 160 -exclude = [ - "openpilot/cereal", - "*.ipynb", - "generated", -] lint.flake8-implicit-str-concat.allow-multiline = false [tool.ruff.lint.flake8-tidy-imports.banned-api] "pytest.main".msg = "pytest.main requires special handling that is easy to mess up!" -"unittest".msg = "Use pytest" -"time.time".msg = "Use time.monotonic" +"time.time".msg = "Use time.monotonic" # time.time can skip due to its reference clock, you probably want a monotonic clock # raylib banned APIs "pyray.measure_text_ex".msg = "Use openpilot.system.ui.lib.text_measure" diff --git a/tools/car_porting/examples/find_segments_with_message.ipynb b/tools/car_porting/examples/find_segments_with_message.ipynb index af17bde52b..f91827f7e5 100644 --- a/tools/car_porting/examples/find_segments_with_message.ipynb +++ b/tools/car_porting/examples/find_segments_with_message.ipynb @@ -9,7 +9,6 @@ "source": [ "# Import all cars from opendbc\n", "\n", - "from opendbc.car import structs\n", "from opendbc.car.values import PLATFORMS as TEST_PLATFORMS\n", "\n", "# Example: add additional platforms/segments to test outside of commaCarSegments\n", @@ -147,8 +146,8 @@ } ], "source": [ - "from openpilot.tools.lib.logreader import LogReader, comma_car_segments_source\n", - "from tqdm.notebook import tqdm, tnrange\n", + "from openpilot.tools.lib.logreader import comma_car_segments_source\n", + "from tqdm.notebook import tnrange\n", "\n", "# Example search for CAN ignition messages\n", "# Be careful when filtering by bus, account for odd harness arrangements on Honda/HKG\n", diff --git a/tools/car_porting/examples/ford_vin_fingerprint.ipynb b/tools/car_porting/examples/ford_vin_fingerprint.ipynb index 6b806d22d2..cb5fd8f820 100644 --- a/tools/car_porting/examples/ford_vin_fingerprint.ipynb +++ b/tools/car_porting/examples/ford_vin_fingerprint.ipynb @@ -53,12 +53,12 @@ " if vin.startswith('1FT'):\n", " if vin_positions_567 in F150_CODES:\n", " if vin[7] in LIGHTNING_CODES:\n", - " return f\"FORD F-150 LIGHTNING 1ST GEN\"\n", + " return \"FORD F-150 LIGHTNING 1ST GEN\"\n", " else:\n", - " return f\"FORD F-150 14TH GEN\"\n", + " return \"FORD F-150 14TH GEN\"\n", " elif vin.startswith('3FM'):\n", " if vin_positions_567 in MACHE_CODES:\n", - " return f\"FORD MUSTANG MACH-E 1ST GEN\"\n", + " return \"FORD MUSTANG MACH-E 1ST GEN\"\n", " elif vin.startswith('5LM'):\n", " pass\n", "\n", @@ -147,7 +147,8 @@ "source": [ "for vin, real_fingerprint in VINS_TO_CHECK:\n", " determined_fingerprint = ford_vin_fingerprint(vin)\n", - " print(f\"vin: {vin} real platform: {real_fingerprint: <30} determined platform: {determined_fingerprint: <30} correct: {real_fingerprint == determined_fingerprint}\")" + " print(f\"vin: {vin} real platform: {real_fingerprint: <30} \" +\n", + " f\"determined platform: {determined_fingerprint: <30} correct: {real_fingerprint == determined_fingerprint}\")" ] } ], diff --git a/tools/car_porting/examples/hkg_canfd_gear_message.ipynb b/tools/car_porting/examples/hkg_canfd_gear_message.ipynb index f0bca8decc..ec902b7d10 100644 --- a/tools/car_porting/examples/hkg_canfd_gear_message.ipynb +++ b/tools/car_porting/examples/hkg_canfd_gear_message.ipynb @@ -21,9 +21,7 @@ } ], "source": [ - "from opendbc.car import structs\n", "from opendbc.car.hyundai.values import CAR, HyundaiFlags\n", - "from opendbc.car.hyundai.fingerprints import FW_VERSIONS\n", "\n", "TEST_PLATFORMS = set(CAR.with_flags(HyundaiFlags.CANFD)) & set(CAR.with_flags(HyundaiFlags.EV)) # CAN-FD electric vehicles only\n", "#TEST_PLATFORMS = set(CAR.with_flags(HyundaiFlags.CANFD)) - set(CAR.with_flags(HyundaiFlags.EV)) # CAN-FD hybrid and ICE vehicles only\n", @@ -190,15 +188,13 @@ ], "source": [ "import copy\n", - "import matplotlib.pyplot as plt\n", - "import numpy as np\n", "\n", "from opendbc.can.parser import CANParser\n", "from opendbc.car.hyundai.values import DBC\n", "from opendbc.car.hyundai.hyundaicanfd import CanBus\n", "\n", "from openpilot.selfdrive.pandad import can_capnp_to_list\n", - "from openpilot.tools.lib.logreader import LogReader, comma_car_segments_source\n", + "from openpilot.tools.lib.logreader import comma_car_segments_source\n", "\n", "message_names = [\"GEAR_SHIFTER\", \"ACCELERATOR\", \"GEAR\", \"GEAR_ALT\", \"GEAR_ALT_2\"]\n", "\n", @@ -229,11 +225,11 @@ " for i, parsed_messages in enumerate(parsed_message_history):\n", " gear = parsed_messages[name][\"GEAR\"]\n", " if gear != gear_prev:\n", - " print(f\" *** Signal transition found! ***\")\n", + " print(\" *** Signal transition found! ***\")\n", " examples.append(i)\n", " gear_prev = gear\n", "\n", - "print(f\"Analysis finished\")\n" + "print(\"Analysis finished\")\n" ] }, { diff --git a/tools/car_porting/test_car_model.py b/tools/car_porting/test_car_model.py index 20e7d136ea..61ec2e15ed 100755 --- a/tools/car_porting/test_car_model.py +++ b/tools/car_porting/test_car_model.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 import argparse import sys -import unittest # noqa: TID251 +import unittest from opendbc.car.tests.routes import CarTestRoute from openpilot.selfdrive.car.tests.test_models import TestCarModel from openpilot.tools.lib.route import SegmentRange From ecac2d386b7f8cb2f17e75dc046c78a2f70f2fcd Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Sun, 19 Jul 2026 09:02:02 -0700 Subject: [PATCH 18/35] these go in tools --- pyproject.toml | 9 ++++----- uv.lock | 21 +++++++++------------ 2 files changed, 13 insertions(+), 17 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 597f7a4489..da04c77f1e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,16 +22,12 @@ dependencies = [ "numpy >=2.0", # vendored native dependencies - "comma-deps-bzip2", - "comma-deps-bootstrap-icons", "comma-deps-capnproto", "comma-deps-catch2", "comma-deps-acados", "comma-deps-ffmpeg", "comma-deps-zstd", - "comma-deps-ncurses", "comma-deps-zeromq", - "comma-deps-libusb", "comma-deps-json11", "comma-deps-git-lfs", "comma-deps-gcc-arm-none-eabi", @@ -86,6 +82,10 @@ dev = [ tools = [ "comma-deps-imgui", + "comma-deps-bzip2", + "comma-deps-bootstrap-icons", + "comma-deps-libusb", + "comma-deps-ncurses", # this can be added back once it's stripped down some more #"metadrive-simulator @ git+https://github.com/commaai/metadrive.git@minimal ; (platform_machine != 'aarch64')", @@ -200,7 +200,6 @@ python-preference = "only-managed" default-groups = ["standalone"] override-dependencies = [ "opendbc", # panda pins opendbc from git for standalone use; always use our submodule - "av", # teleoprtc's av<13 pin is stale ] [tool.uv.sources] diff --git a/uv.lock b/uv.lock index cb6f859b50..9aef9c9a01 100644 --- a/uv.lock +++ b/uv.lock @@ -3,10 +3,7 @@ revision = 3 requires-python = ">=3.12.3, <3.13" [manifest] -overrides = [ - { name = "av" }, - { name = "opendbc", editable = "opendbc_repo" }, -] +overrides = [{ name = "opendbc", editable = "opendbc_repo" }] [[package]] name = "attrs" @@ -704,16 +701,12 @@ source = { editable = "." } dependencies = [ { name = "cffi" }, { name = "comma-deps-acados" }, - { name = "comma-deps-bootstrap-icons" }, - { name = "comma-deps-bzip2" }, { name = "comma-deps-capnproto" }, { name = "comma-deps-catch2" }, { name = "comma-deps-ffmpeg" }, { name = "comma-deps-gcc-arm-none-eabi" }, { name = "comma-deps-git-lfs" }, { name = "comma-deps-json11" }, - { name = "comma-deps-libusb" }, - { name = "comma-deps-ncurses" }, { name = "comma-deps-raylib" }, { name = "comma-deps-zeromq" }, { name = "comma-deps-zstd" }, @@ -765,7 +758,11 @@ testing = [ { name = "ty" }, ] tools = [ + { name = "comma-deps-bootstrap-icons" }, + { name = "comma-deps-bzip2" }, { name = "comma-deps-imgui" }, + { name = "comma-deps-libusb" }, + { name = "comma-deps-ncurses" }, ] [package.dev-dependencies] @@ -778,8 +775,8 @@ requires-dist = [ { name = "cffi" }, { name = "codespell", marker = "extra == 'testing'" }, { name = "comma-deps-acados" }, - { name = "comma-deps-bootstrap-icons" }, - { name = "comma-deps-bzip2" }, + { name = "comma-deps-bootstrap-icons", marker = "extra == 'tools'" }, + { name = "comma-deps-bzip2", marker = "extra == 'tools'" }, { name = "comma-deps-capnproto" }, { name = "comma-deps-catch2" }, { name = "comma-deps-ffmpeg" }, @@ -787,8 +784,8 @@ requires-dist = [ { name = "comma-deps-git-lfs" }, { name = "comma-deps-imgui", marker = "extra == 'tools'" }, { name = "comma-deps-json11" }, - { name = "comma-deps-libusb" }, - { name = "comma-deps-ncurses" }, + { name = "comma-deps-libusb", marker = "extra == 'tools'" }, + { name = "comma-deps-ncurses", marker = "extra == 'tools'" }, { name = "comma-deps-raylib" }, { name = "comma-deps-zeromq" }, { name = "comma-deps-zstd" }, From 19ecc37de83f44dd2d5e352beb701369430a8915 Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Sun, 19 Jul 2026 09:45:46 -0700 Subject: [PATCH 19/35] more ty (#38378) * enable no matching overload * enable call non callable * enable unsupported-operator * enable not subscriptable * refactor pass --- .../cereal/messaging/tests/test_pub_sub_master.py | 1 + openpilot/common/swaglog.py | 2 +- openpilot/selfdrive/car/tests/test_models.py | 2 +- .../locationd/test/test_locationd_scenarios.py | 4 ++-- .../selfdrive/ui/mici/layouts/offroad_alerts.py | 13 +++++++------ openpilot/selfdrive/ui/mici/widgets/button.py | 3 +++ .../ui/translations/update_translations.py | 8 ++++---- openpilot/system/loggerd/tests/test_loggerd.py | 4 +++- openpilot/system/qcomgpsd/nmeaport.py | 8 +++++--- openpilot/system/ui/lib/application.py | 1 + openpilot/system/ui/widgets/network.py | 1 + openpilot/tools/clip/run.py | 11 ++++++----- openpilot/tools/lib/comma_car_segments.py | 2 +- .../tools/sim/bridge/metadrive/metadrive_world.py | 2 +- pyproject.toml | 4 ---- 15 files changed, 37 insertions(+), 29 deletions(-) diff --git a/openpilot/cereal/messaging/tests/test_pub_sub_master.py b/openpilot/cereal/messaging/tests/test_pub_sub_master.py index 02f944b69f..20ff855fda 100644 --- a/openpilot/cereal/messaging/tests/test_pub_sub_master.py +++ b/openpilot/cereal/messaging/tests/test_pub_sub_master.py @@ -92,6 +92,7 @@ class TestSubMaster: for service, (max_freq, min_freq) in checks.items(): if max_freq is not None: + assert min_freq is not None assert sm._check_avg_freq(service) assert sm.freq_tracker[service].max_freq == max_freq*1.2 assert sm.freq_tracker[service].min_freq == min_freq*0.8 diff --git a/openpilot/common/swaglog.py b/openpilot/common/swaglog.py index ac64230eed..8b629b3fba 100644 --- a/openpilot/common/swaglog.py +++ b/openpilot/common/swaglog.py @@ -27,7 +27,7 @@ class SwaglogRotatingFileHandler(BaseRotatingHandler): self.log_files = self.get_existing_logfiles() log_indexes = [f.split(".")[-1] for f in self.log_files] self.last_file_idx = max([int(i) for i in log_indexes if i.isdigit()] or [-1]) - self.last_rollover = None + self.last_rollover = 0.0 self.doRollover() def _open(self): diff --git a/openpilot/selfdrive/car/tests/test_models.py b/openpilot/selfdrive/car/tests/test_models.py index 436de261e6..cda94b3158 100644 --- a/openpilot/selfdrive/car/tests/test_models.py +++ b/openpilot/selfdrive/car/tests/test_models.py @@ -250,7 +250,7 @@ class TestCarModelBase(unittest.TestCase): # Don't check relay malfunction on disabled routes (relay closed), # or before fingerprinting is done (elm327 and noOutput) - if self.openpilot_enabled and t / 1e4 > self.car_safety_mode_frame: + if self.car_safety_mode_frame is not None and t / 1e4 > self.car_safety_mode_frame: self.assertFalse(self.safety.get_relay_malfunction()) else: self.safety.set_relay_malfunction(False) diff --git a/openpilot/selfdrive/locationd/test/test_locationd_scenarios.py b/openpilot/selfdrive/locationd/test/test_locationd_scenarios.py index 69f2ca2821..04d2d6b55b 100644 --- a/openpilot/selfdrive/locationd/test/test_locationd_scenarios.py +++ b/openpilot/selfdrive/locationd/test/test_locationd_scenarios.py @@ -37,9 +37,9 @@ def get_select_fields_data(logs): def sig_smooth(signal): return masked_symmetric_moving_average(signal, np.ones_like(signal), 5, 1.0) def get_nested_keys(msg, keys): - val = None + val = msg for key in keys: - val = getattr(msg if val is None else val, key) if isinstance(key, str) else val[key] + val = getattr(val, key) if isinstance(key, str) else val[key] return val lp = [x.livePose for x in logs if x.which() == 'livePose'] data = defaultdict(list) diff --git a/openpilot/selfdrive/ui/mici/layouts/offroad_alerts.py b/openpilot/selfdrive/ui/mici/layouts/offroad_alerts.py index 47a2ddde95..c27e05f752 100644 --- a/openpilot/selfdrive/ui/mici/layouts/offroad_alerts.py +++ b/openpilot/selfdrive/ui/mici/layouts/offroad_alerts.py @@ -250,12 +250,12 @@ class MiciOffroadAlerts(Scroller): {alert_data.key: self.params.get(alert_data.key) for alert_data in self.sorted_alerts}) time.sleep(REFRESH_INTERVAL) - def _refresh(self) -> int: + def _refresh(self, pending_params: dict) -> int: """Refresh alerts from params and return active count.""" active_count = 0 # Handle UpdateAvailable alert specially - update_available = self._pending_params["UpdateAvailable"] + update_available = pending_params["UpdateAvailable"] update_alert_data = next((alert_data for alert_data in self.sorted_alerts if alert_data.key == "UpdateAvailable"), None) if update_alert_data: @@ -263,7 +263,7 @@ class MiciOffroadAlerts(Scroller): version_string = "" # Get new version description and parse version and date - new_desc = self._pending_params["UpdaterNewDescription"] or "" + new_desc = pending_params["UpdaterNewDescription"] or "" if new_desc: # format: "version / branch / commit / date" parts = new_desc.split(" / ") @@ -284,7 +284,7 @@ class MiciOffroadAlerts(Scroller): continue # Skip, already handled above text = "" - alert_json = self._pending_params[alert_data.key] + alert_json = pending_params[alert_data.key] if alert_json: text = alert_json.get("text", "").replace("%1", alert_json.get("extra", "")) @@ -311,8 +311,9 @@ class MiciOffroadAlerts(Scroller): def _update_state(self): """Periodically refresh alerts.""" # Refresh alerts when thread updates params - if self._pending_params is not None: - self._refresh() + pending_params = self._pending_params + if pending_params is not None: + self._refresh(pending_params) self._pending_params = None def _render(self, rect: rl.Rectangle): diff --git a/openpilot/selfdrive/ui/mici/widgets/button.py b/openpilot/selfdrive/ui/mici/widgets/button.py index 1dceb79691..3dd7ad9a8a 100644 --- a/openpilot/selfdrive/ui/mici/widgets/button.py +++ b/openpilot/selfdrive/ui/mici/widgets/button.py @@ -375,6 +375,7 @@ class GreyBigButton(BigButton): class BigMultiParamToggle(BigMultiToggle): def __init__(self, text: str, param: str, options: list[str], toggle_callback: Callable | None = None, select_callback: Callable | None = None): + assert Params is not None super().__init__(text, options, toggle_callback, select_callback) self._param = param @@ -392,6 +393,7 @@ class BigMultiParamToggle(BigMultiToggle): class BigParamControl(BigToggle): def __init__(self, text: str, param: str, toggle_callback: Callable | None = None): + assert Params is not None super().__init__(text, "", toggle_callback=toggle_callback) self.param = param self.params = Params() @@ -409,6 +411,7 @@ class BigParamControl(BigToggle): class BigCircleParamControl(BigCircleToggle): def __init__(self, icon: rl.Texture, param: str, toggle_callback: Callable | None = None, icon_offset: tuple[int, int] = (0, 0)): + assert Params is not None super().__init__(icon, toggle_callback, icon_offset=icon_offset) self._param = param self.params = Params() diff --git a/openpilot/selfdrive/ui/translations/update_translations.py b/openpilot/selfdrive/ui/translations/update_translations.py index 6ff3667d8a..9b0d63ee12 100755 --- a/openpilot/selfdrive/ui/translations/update_translations.py +++ b/openpilot/selfdrive/ui/translations/update_translations.py @@ -12,9 +12,9 @@ POT_FILE = os.path.join(str(TRANSLATIONS_DIR), "app.pot") def update_translations(): files = [] for root, _, filenames in chain(os.walk(SYSTEM_UI_DIR), - os.walk(os.path.join(UI_DIR, "widgets")), - os.walk(os.path.join(UI_DIR, "layouts")), - os.walk(os.path.join(UI_DIR, "onroad"))): + os.walk(os.path.join(str(UI_DIR), "widgets")), + os.walk(os.path.join(str(UI_DIR), "layouts")), + os.walk(os.path.join(str(UI_DIR), "onroad"))): for filename in filenames: if filename.endswith(".py"): files.append(os.path.relpath(os.path.join(root, filename), BASEDIR)) @@ -25,7 +25,7 @@ def update_translations(): # Generate/update translation files for each language for name in multilang.languages.values(): - po_file = os.path.join(TRANSLATIONS_DIR, f"app_{name}.po") + po_file = os.path.join(str(TRANSLATIONS_DIR), f"app_{name}.po") if os.path.exists(po_file): merge_po(po_file, POT_FILE) else: diff --git a/openpilot/system/loggerd/tests/test_loggerd.py b/openpilot/system/loggerd/tests/test_loggerd.py index 5d8f635d96..666b56dc03 100644 --- a/openpilot/system/loggerd/tests/test_loggerd.py +++ b/openpilot/system/loggerd/tests/test_loggerd.py @@ -277,7 +277,9 @@ class TestLoggerd: assert recv_cnt == 0, f"got {recv_cnt} {s} msgs in qlog" else: # check logged message count matches decimation - expected_cnt = (len(msgs) - 1) // SERVICE_LIST[s].decimation + 1 + decimation = SERVICE_LIST[s].decimation + assert decimation is not None + expected_cnt = (len(msgs) - 1) // decimation + 1 assert recv_cnt == expected_cnt, f"expected {expected_cnt} msgs for {s}, got {recv_cnt}" def test_rlog(self): diff --git a/openpilot/system/qcomgpsd/nmeaport.py b/openpilot/system/qcomgpsd/nmeaport.py index 0e695b65e5..ba7cf117b1 100644 --- a/openpilot/system/qcomgpsd/nmeaport.py +++ b/openpilot/system/qcomgpsd/nmeaport.py @@ -3,7 +3,7 @@ import sys from dataclasses import dataclass, fields from subprocess import check_output, CalledProcessError from time import sleep -from typing import NoReturn +from typing import NoReturn, cast DEBUG = int(os.environ.get("DEBUG", "0")) @@ -30,7 +30,8 @@ class GnssClockNmeaPort: def __post_init__(self): for field in fields(self): val = getattr(self, field.name) - setattr(self, field.name, field.type(val) if val else None) + field_type = cast(type, field.type) + setattr(self, field.name, field_type(val) if val else None) @dataclass class GnssMeasNmeaPort: @@ -73,7 +74,8 @@ class GnssMeasNmeaPort: def __post_init__(self): for field in fields(self): val = getattr(self, field.name) - setattr(self, field.name, field.type(val) if val else None) + field_type = cast(type, field.type) + setattr(self, field.name, field_type(val) if val else None) def nmea_checksum_ok(s): checksum = 0 diff --git a/openpilot/system/ui/lib/application.py b/openpilot/system/ui/lib/application.py index bd2c9dfcd3..3fa47ebd20 100644 --- a/openpilot/system/ui/lib/application.py +++ b/openpilot/system/ui/lib/application.py @@ -823,6 +823,7 @@ class GuiApplication: import pstats self._render_profiler.disable() + assert self._render_profile_start_time is not None elapsed_ms = (time.monotonic() - self._render_profile_start_time) * 1e3 avg_frame_time = elapsed_ms / self._frame if self._frame > 0 else 0 diff --git a/openpilot/system/ui/widgets/network.py b/openpilot/system/ui/widgets/network.py index f104ba6add..710202c54f 100644 --- a/openpilot/system/ui/widgets/network.py +++ b/openpilot/system/ui/widgets/network.py @@ -105,6 +105,7 @@ class NetworkUI(Widget): class AdvancedNetworkSettings(Widget): def __init__(self, wifi_manager: WifiManager): + assert Params is not None super().__init__() self._wifi_manager = wifi_manager self._wifi_manager.add_callbacks(networks_updated=self._on_network_updated) diff --git a/openpilot/tools/clip/run.py b/openpilot/tools/clip/run.py index fa4b203862..594383f7fd 100755 --- a/openpilot/tools/clip/run.py +++ b/openpilot/tools/clip/run.py @@ -11,6 +11,7 @@ import itertools import numpy as np import tqdm from argparse import ArgumentParser +from collections.abc import Callable from pathlib import Path from concurrent.futures import ThreadPoolExecutor, as_completed @@ -138,7 +139,7 @@ def iter_segment_frames(camera_paths, start_time, end_time, fps=20, use_qcam=Fal frames_per_seg = fps * 60 start_frame, end_frame = int(start_time * fps), int(end_time * fps) current_seg: int = -1 - seg_frames: FrameReader | np.ndarray | None = None + get_frame: Callable[[int], np.ndarray] | None = None for global_idx in range(start_frame, end_frame): seg_idx, local_idx = global_idx // frames_per_seg, global_idx % frames_per_seg @@ -157,12 +158,12 @@ def iter_segment_frames(camera_paths, start_time, end_time, fps=20, use_qcam=Fal if result.returncode != 0: raise RuntimeError(f"ffmpeg failed: {result.stderr.decode()}") seg_frames = np.frombuffer(result.stdout, dtype=np.uint8).reshape(-1, w * h * 3 // 2) + get_frame = seg_frames.__getitem__ else: - seg_frames = FrameReader(path, pix_fmt="nv12") + get_frame = FrameReader(path, pix_fmt="nv12").get - assert seg_frames is not None - frame = seg_frames[local_idx] if use_qcam else seg_frames.get(local_idx) - yield global_idx, frame + assert get_frame is not None + yield global_idx, get_frame(local_idx) class FrameQueue: diff --git a/openpilot/tools/lib/comma_car_segments.py b/openpilot/tools/lib/comma_car_segments.py index cd19356d66..b27887ed29 100644 --- a/openpilot/tools/lib/comma_car_segments.py +++ b/openpilot/tools/lib/comma_car_segments.py @@ -74,7 +74,7 @@ def get_repo_url(path): response = requests.head(get_repo_raw_url(path)) - if "text/plain" in response.headers.get("content-type"): + if "text/plain" in response.headers.get("content-type", ""): # This is an LFS pointer, so download the raw data from lfs response = requests.get(get_repo_raw_url(path)) assert response.status_code == 200 diff --git a/openpilot/tools/sim/bridge/metadrive/metadrive_world.py b/openpilot/tools/sim/bridge/metadrive/metadrive_world.py index c5111289d0..54b461a46c 100644 --- a/openpilot/tools/sim/bridge/metadrive/metadrive_world.py +++ b/openpilot/tools/sim/bridge/metadrive/metadrive_world.py @@ -97,7 +97,7 @@ class MetaDriveWorld(World): self.op_engaged.set() # check moving 5 seconds after engaged, doesn't move right away - after_engaged_check = is_engaged and time.monotonic() - self.first_engage >= 5 and self.test_run + after_engaged_check = is_engaged and self.first_engage is not None and time.monotonic() - self.first_engage >= 5 and self.test_run x_dist = abs(curr_pos[0] - self.vehicle_last_pos[0]) y_dist = abs(curr_pos[1] - self.vehicle_last_pos[1]) diff --git a/pyproject.toml b/pyproject.toml index da04c77f1e..5c9f16d33a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -189,11 +189,7 @@ unresolved-attribute = "ignore" # many from capnp and Cython modules invalid-method-override = "ignore" # signature variance issues possibly-missing-attribute = "ignore" # too many false positives invalid-assignment = "ignore" # often intentional monkey-patching -no-matching-overload = "ignore" # numpy/ctypes overload matching issues invalid-argument-type = "ignore" # many false positives from raylib, ctypes, numpy -call-non-callable = "ignore" # false positives from dynamic types -unsupported-operator = "ignore" # false positives from dynamic types -not-subscriptable = "ignore" # false positives from dynamic types [tool.uv] python-preference = "only-managed" From f0d93eb32db9683986cfcafebec6d0f74a2a95da Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Sun, 19 Jul 2026 11:02:00 -0700 Subject: [PATCH 20/35] more ty, part 2 (#38379) --- openpilot/common/api.py | 2 + openpilot/common/hardware/tici/agnos.py | 2 +- openpilot/common/hardware/tici/hardware.py | 16 ++--- openpilot/common/hardware/tici/modem.py | 2 +- openpilot/common/pid.py | 12 ++-- openpilot/common/transformations/camera.py | 3 +- openpilot/selfdrive/car/tests/test_models.py | 3 +- openpilot/selfdrive/locationd/helpers.py | 5 +- openpilot/selfdrive/locationd/locationd.py | 2 +- openpilot/selfdrive/locationd/torqued.py | 9 +-- openpilot/selfdrive/modeld/modeld.py | 2 +- .../selfdrive/modeld/parse_model_outputs.py | 2 +- .../selfdrive/pandad/tests/test_pandad.py | 2 +- .../selfdrived/tests/test_state_machine.py | 8 +-- .../test/process_replay/process_replay.py | 8 +-- openpilot/selfdrive/ui/body/animations.py | 11 +-- openpilot/selfdrive/ui/layouts/main.py | 6 +- openpilot/selfdrive/ui/layouts/sidebar.py | 2 +- openpilot/selfdrive/ui/mici/layouts/home.py | 2 +- openpilot/selfdrive/ui/mici/layouts/main.py | 2 +- .../ui/mici/layouts/settings/device.py | 4 +- .../selfdrive/ui/mici/onroad/cameraview.py | 2 +- .../ui/mici/onroad/model_renderer.py | 4 +- .../selfdrive/ui/onroad/model_renderer.py | 4 +- .../selfdrive/ui/tests/diff/replay_script.py | 2 +- .../selfdrive/ui/tests/profile_onroad.py | 2 +- .../selfdrive/ui/translations/potools.py | 22 +++--- openpilot/system/athena/athenad.py | 5 +- openpilot/system/athena/tests/test_athenad.py | 4 +- openpilot/system/hardware/power_monitoring.py | 2 +- .../hardware/tests/test_power_monitoring.py | 2 +- .../loggerd/tests/loggerd_tests_common.py | 4 +- .../system/loggerd/tests/test_deleter.py | 2 +- .../system/loggerd/tests/test_loggerd.py | 5 +- openpilot/system/qcomgpsd/nmeaport.py | 72 +++++++++---------- .../system/sensord/tests/test_sensord.py | 2 +- openpilot/system/ubloxd/binary_struct.py | 2 +- openpilot/system/ui/lib/utils.py | 3 +- openpilot/system/ui/lib/wifi_manager.py | 2 +- openpilot/system/ui/tici_reset.py | 6 +- openpilot/system/ui/widgets/__init__.py | 29 +++++--- openpilot/system/ui/widgets/label.py | 3 +- openpilot/system/ui/widgets/nav_widget.py | 2 +- openpilot/system/ui/widgets/network.py | 19 ++--- openpilot/system/ui/widgets/scroller.py | 12 ++-- openpilot/system/ui/widgets/scroller_tici.py | 3 +- openpilot/system/updated/updated.py | 2 +- .../webrtc/tests/test_stream_session.py | 6 +- openpilot/system/webrtc/webrtcd.py | 2 +- openpilot/tools/lib/auth.py | 2 +- openpilot/tools/lib/file_sources.py | 8 +-- openpilot/tools/lib/tests/test_caching.py | 2 +- .../longitudinal_maneuvers/maneuversd.py | 2 +- openpilot/tools/replay/lib/ui_helpers.py | 3 +- openpilot/tools/sim/bridge/common.py | 2 +- openpilot/tools/sim/lib/common.py | 6 +- pyproject.toml | 4 -- 57 files changed, 194 insertions(+), 165 deletions(-) diff --git a/openpilot/common/api.py b/openpilot/common/api.py index c97f56c4b5..3e3733c3ad 100644 --- a/openpilot/common/api.py +++ b/openpilot/common/api.py @@ -27,6 +27,8 @@ class Api: return api_get(endpoint, method=method, timeout=timeout, access_token=access_token, **params) def get_token(self, payload_extra=None, expiry_hours=1): + if self.private_key is None: + raise RuntimeError("private key is not configured") now = datetime.now(UTC).replace(tzinfo=None) payload = { 'identity': self.dongle_id, diff --git a/openpilot/common/hardware/tici/agnos.py b/openpilot/common/hardware/tici/agnos.py index e1f62c841c..b3b5e05176 100755 --- a/openpilot/common/hardware/tici/agnos.py +++ b/openpilot/common/hardware/tici/agnos.py @@ -19,7 +19,7 @@ class StreamingDecompressor: def __init__(self, url: str) -> None: self.buf = b"" - self.req = requests.get(url, stream=True, headers={'Accept-Encoding': None}, timeout=60) + self.req = requests.get(url, stream=True, headers={'Accept-Encoding': 'identity'}, timeout=60) self.it = self.req.iter_content(chunk_size=1024 * 1024) self.decompressor = lzma.LZMADecompressor(format=lzma.FORMAT_AUTO) self.eof = False diff --git a/openpilot/common/hardware/tici/hardware.py b/openpilot/common/hardware/tici/hardware.py index 773cbd4bed..aa125ed97d 100644 --- a/openpilot/common/hardware/tici/hardware.py +++ b/openpilot/common/hardware/tici/hardware.py @@ -339,7 +339,7 @@ class Tici(HardwareBase): # Ensure fan gpio is enabled so fan runs until shutdown, also turned on at boot by the ABL gpio_init(GPIO.SOM_ST_IO, True) - gpio_set(GPIO.SOM_ST_IO, 1) + gpio_set(GPIO.SOM_ST_IO, True) # *** IRQ config *** @@ -389,21 +389,21 @@ class Tici(HardwareBase): gpio_init(GPIO.STM_RST_N, True) gpio_init(GPIO.STM_BOOT0, True) - gpio_set(GPIO.STM_RST_N, 1) - gpio_set(GPIO.STM_BOOT0, 0) + gpio_set(GPIO.STM_RST_N, True) + gpio_set(GPIO.STM_BOOT0, False) time.sleep(0.01) - gpio_set(GPIO.STM_RST_N, 0) + gpio_set(GPIO.STM_RST_N, False) def recover_internal_panda(self): gpio_init(GPIO.STM_RST_N, True) gpio_init(GPIO.STM_BOOT0, True) - gpio_set(GPIO.STM_RST_N, 1) - gpio_set(GPIO.STM_BOOT0, 1) + gpio_set(GPIO.STM_RST_N, True) + gpio_set(GPIO.STM_BOOT0, True) time.sleep(0.01) - gpio_set(GPIO.STM_RST_N, 0) + gpio_set(GPIO.STM_RST_N, False) time.sleep(0.01) - gpio_set(GPIO.STM_BOOT0, 0) + gpio_set(GPIO.STM_BOOT0, False) def booted(self): # this normally boots within 8s, but on rare occasions takes 30+s diff --git a/openpilot/common/hardware/tici/modem.py b/openpilot/common/hardware/tici/modem.py index 0d30c49328..54251d4815 100755 --- a/openpilot/common/hardware/tici/modem.py +++ b/openpilot/common/hardware/tici/modem.py @@ -52,7 +52,7 @@ PPPD_CMD = [ "novj", "novjccomp", "ipcp-accept-local", "ipcp-accept-remote", "nomagic", "user", '""', "password", '""', ] -INITIAL_STATE = { +INITIAL_STATE: dict[str, object] = { "seconds_since_boot": 0, "state": "INITIALIZING", "connected": False, "ip_address": "", diff --git a/openpilot/common/pid.py b/openpilot/common/pid.py index b3d64d6fcd..f541baf05c 100644 --- a/openpilot/common/pid.py +++ b/openpilot/common/pid.py @@ -1,11 +1,13 @@ import numpy as np -from numbers import Number +from collections.abc import Sequence + +Gain = int | float | tuple[Sequence[float], Sequence[float]] | list[list[float]] class PIDController: - def __init__(self, k_p, k_i, k_d=0., pos_limit=1e308, neg_limit=-1e308, rate=100): - self._k_p: list[list[float]] = [[0], [k_p]] if isinstance(k_p, Number) else k_p - self._k_i: list[list[float]] = [[0], [k_i]] if isinstance(k_i, Number) else k_i - self._k_d: list[list[float]] = [[0], [k_d]] if isinstance(k_d, Number) else k_d + def __init__(self, k_p: Gain, k_i: Gain, k_d: Gain = 0., pos_limit=1e308, neg_limit=-1e308, rate=100): + self._k_p = ([0], [k_p]) if isinstance(k_p, (int, float)) else k_p + self._k_i = ([0], [k_i]) if isinstance(k_i, (int, float)) else k_i + self._k_d = ([0], [k_d]) if isinstance(k_d, (int, float)) else k_d self.set_limits(pos_limit, neg_limit) diff --git a/openpilot/common/transformations/camera.py b/openpilot/common/transformations/camera.py index 2e68b5e37c..ada9c5b398 100644 --- a/openpilot/common/transformations/camera.py +++ b/openpilot/common/transformations/camera.py @@ -52,7 +52,7 @@ _ar_ox_config = DeviceCameraConfig(CameraConfig(1928, 1208, 2648.0), _ar_ox_fish _os_config = DeviceCameraConfig(CameraConfig(2688 // 2, 1520 // 2, 1522.0 * 3 / 4), _os_fisheye, _os_fisheye) _neo_config = DeviceCameraConfig(CameraConfig(1164, 874, 910.0), CameraConfig(816, 612, 650.0), _NoneCameraConfig()) -DEVICE_CAMERAS = { +DEVICE_CAMERAS: dict[tuple[str, str], DeviceCameraConfig] = { # A "device camera" is defined by a device type and sensor # sensor type was never set on eon/neo/two @@ -176,4 +176,3 @@ def img_from_device(pt_device): pt_img = pt_view/pt_view[:, 2:3] return pt_img.reshape(input_shape)[:, :2] - diff --git a/openpilot/selfdrive/car/tests/test_models.py b/openpilot/selfdrive/car/tests/test_models.py index cda94b3158..5ad31f93ab 100644 --- a/openpilot/selfdrive/car/tests/test_models.py +++ b/openpilot/selfdrive/car/tests/test_models.py @@ -21,6 +21,7 @@ from openpilot.selfdrive.pandad import can_capnp_to_list from openpilot.selfdrive.test.helpers import read_segment_list from openpilot.common.hardware.hw import DEFAULT_DOWNLOAD_CACHE_ROOT from openpilot.tools.lib.logreader import LogReader, LogsUnavailable, openpilotci_source, internal_source, comma_api_source +from openpilot.tools.lib.file_sources import Source from openpilot.tools.lib.route import SegmentName SafetyModel = car.CarParams.SafetyModel @@ -130,7 +131,7 @@ class TestCarModelBase(unittest.TestCase): segment_range = f"{cls.test_route.route}/{seg}" try: - sources = [internal_source] if len(INTERNAL_SEG_LIST) else [openpilotci_source, comma_api_source] + sources: list[Source] = [internal_source] if len(INTERNAL_SEG_LIST) else [openpilotci_source, comma_api_source] lr = LogReader(segment_range, sources=sources, sort_by_time=True) return cls.get_testing_data_from_logreader(lr) except (LogsUnavailable, AssertionError): diff --git a/openpilot/selfdrive/locationd/helpers.py b/openpilot/selfdrive/locationd/helpers.py index 849b950de2..5e3fa16027 100644 --- a/openpilot/selfdrive/locationd/helpers.py +++ b/openpilot/selfdrive/locationd/helpers.py @@ -1,4 +1,5 @@ import numpy as np +from collections.abc import Sequence from typing import Any from functools import cache @@ -68,7 +69,7 @@ class NPQueue: class PointBuckets: - def __init__(self, x_bounds: list[tuple[float, float]], min_points: list[float], min_points_total: int, points_per_bucket: int, rowsize: int) -> None: + def __init__(self, x_bounds: list[tuple[float, float]], min_points: Sequence[float], min_points_total: int, points_per_bucket: int, rowsize: int) -> None: self._rng = np.random.default_rng() self.x_bounds = x_bounds self.buckets = {bounds: NPQueue(maxlen=points_per_bucket, rowsize=rowsize) for bounds in x_bounds} @@ -101,7 +102,7 @@ class PointBuckets: return points return points[self._rng.choice(np.arange(len(points)), min(len(points), num_points), replace=False)] - def load_points(self, points: list[list[float]]) -> None: + def load_points(self, points: Sequence[Sequence[float]]) -> None: for point in points: self.add_point(*point) diff --git a/openpilot/selfdrive/locationd/locationd.py b/openpilot/selfdrive/locationd/locationd.py index 8e03995d13..eb3c42fce2 100755 --- a/openpilot/selfdrive/locationd/locationd.py +++ b/openpilot/selfdrive/locationd/locationd.py @@ -66,7 +66,7 @@ class LocationEstimator: self.observations = {kind: np.zeros(3, dtype=np.float32) for kind in obs_kinds} self.observation_errors = {kind: np.zeros(3, dtype=np.float32) for kind in obs_kinds} - def reset(self, t: float, x_initial: np.ndarray = PoseKalman.initial_x, P_initial: np.ndarray = PoseKalman.initial_P): + def reset(self, t: float | None, x_initial: np.ndarray = PoseKalman.initial_x, P_initial: np.ndarray = PoseKalman.initial_P): self.kf.init_state(x_initial, covs=P_initial, filter_time=t) def _validate_sensor_source(self, source: log.SensorEventData.SensorSource): diff --git a/openpilot/selfdrive/locationd/torqued.py b/openpilot/selfdrive/locationd/torqued.py index 6321461e8b..d36684563c 100755 --- a/openpilot/selfdrive/locationd/torqued.py +++ b/openpilot/selfdrive/locationd/torqued.py @@ -57,14 +57,14 @@ class TorqueEstimator(ParameterEstimator): self.lag = 0.0 self.track_all_points = track_all_points # for offline analysis, without max lateral accel or max steer torque filters if decimated: - self.min_bucket_points = MIN_BUCKET_POINTS / 10 + self.min_bucket_points: list[float] = (MIN_BUCKET_POINTS / 10).tolist() self.min_points_total = MIN_POINTS_TOTAL_QLOG self.fit_points = FIT_POINTS_TOTAL_QLOG self.factor_sanity = FACTOR_SANITY_QLOG self.friction_sanity = FRICTION_SANITY_QLOG else: - self.min_bucket_points = MIN_BUCKET_POINTS + self.min_bucket_points = MIN_BUCKET_POINTS.tolist() self.min_points_total = MIN_POINTS_TOTAL self.fit_points = FIT_POINTS_TOTAL self.factor_sanity = FACTOR_SANITY @@ -112,9 +112,10 @@ class TorqueEstimator(ParameterEstimator): 'latAccelOffset': cache_ltp.latAccelOffsetFiltered, 'frictionCoefficient': cache_ltp.frictionCoefficientFiltered } - initial_params['points'] = cache_ltp.points + cached_points: list[list[float]] = [list(point) for point in cache_ltp.points] + initial_params['points'] = cached_points self.decay = cache_ltp.decay - self.filtered_points.load_points(initial_params['points']) + self.filtered_points.load_points(cached_points) cloudlog.info("restored torque params from cache") except Exception: cloudlog.exception("failed to restore cached torque params") diff --git a/openpilot/selfdrive/modeld/modeld.py b/openpilot/selfdrive/modeld/modeld.py index 7c70cdb14d..39fcc0725c 100755 --- a/openpilot/selfdrive/modeld/modeld.py +++ b/openpilot/selfdrive/modeld/modeld.py @@ -89,7 +89,7 @@ class ModelState: self.frame_skip = ModelConstants.MODEL_RUN_FREQ // ModelConstants.MODEL_CONTEXT_FREQ self.input_queues, self.npy = make_input_queues(self.input_shapes, self.frame_skip, device=self.QUEUE_DEV) self.full_frames: dict[str, Tensor] = {} - self._blob_cache: dict[int, Tensor] = {} + self._blob_cache: dict[tuple[str, int], Tensor] = {} self.parser = Parser() self.frame_buf_params = {k: get_nv12_info(cam_w, cam_h) for k in ('img', 'big_img')} self.run_policy = jits['run_policy'] diff --git a/openpilot/selfdrive/modeld/parse_model_outputs.py b/openpilot/selfdrive/modeld/parse_model_outputs.py index 26c138b8ec..839c20f7cc 100644 --- a/openpilot/selfdrive/modeld/parse_model_outputs.py +++ b/openpilot/selfdrive/modeld/parse_model_outputs.py @@ -41,7 +41,7 @@ class Parser: raw = outs[name] outs[name] = sigmoid(raw) - def parse_mdn(self, name, outs, in_N=0, out_N=1, out_shape=None): + def parse_mdn(self, name, outs, in_N=0, out_N=1, out_shape=()): if self.check_missing(outs, name): return raw = outs[name] diff --git a/openpilot/selfdrive/pandad/tests/test_pandad.py b/openpilot/selfdrive/pandad/tests/test_pandad.py index e7a7107dcd..0f8fd9fc1a 100644 --- a/openpilot/selfdrive/pandad/tests/test_pandad.py +++ b/openpilot/selfdrive/pandad/tests/test_pandad.py @@ -60,7 +60,7 @@ class TestPandad: def test_in_reset(self): gpio_init(GPIO.STM_RST_N, True) - gpio_set(GPIO.STM_RST_N, 1) + gpio_set(GPIO.STM_RST_N, True) assert not Panda.list() self._run_test() diff --git a/openpilot/selfdrive/selfdrived/tests/test_state_machine.py b/openpilot/selfdrive/selfdrived/tests/test_state_machine.py index 8139e53d2e..ec8f068039 100644 --- a/openpilot/selfdrive/selfdrived/tests/test_state_machine.py +++ b/openpilot/selfdrive/selfdrived/tests/test_state_machine.py @@ -13,11 +13,11 @@ ALL_STATES = tuple(State.schema.enumerants.values()) ENABLE_EVENT_TYPES = (ET.ENABLE, ET.PRE_ENABLE, ET.OVERRIDE_LATERAL, ET.OVERRIDE_LONGITUDINAL) -def make_event(event_types): - event = {} +def make_event(event_types: list[str | None]): + EVENTS[0] = {} for ev in event_types: - event[ev] = NormalPermanentAlert("alert") - EVENTS[0] = event + if ev is not None: + EVENTS[0][ev] = NormalPermanentAlert("alert") return 0 diff --git a/openpilot/selfdrive/test/process_replay/process_replay.py b/openpilot/selfdrive/test/process_replay/process_replay.py index b34535e96b..90eb397996 100755 --- a/openpilot/selfdrive/test/process_replay/process_replay.py +++ b/openpilot/selfdrive/test/process_replay/process_replay.py @@ -215,7 +215,7 @@ class ProcessContainer: def _start_process(self): if self.capture is not None: - self.process.launcher = LauncherWithCapture(self.capture, self.process.launcher) + self.process.launcher = LauncherWithCapture(self.capture, self.process.launcher) # ty: ignore[invalid-assignment] # intentional wrapper self.process.prepare() self.process.start() @@ -631,10 +631,10 @@ def replay_process( fingerprint: str | None = None, return_all_logs: bool = False, custom_params: dict[str, Any] | None = None, captured_output_store: dict[str, dict[str, str]] | None = None, disable_progress: bool = False ) -> list[capnp._DynamicStructReader]: - if isinstance(cfg, Iterable): - cfgs = list(cfg) - else: + if isinstance(cfg, ProcessConfig): cfgs = [cfg] + else: + cfgs = list(cfg) all_msgs = migrate_all(lr, manager_states=True, diff --git a/openpilot/selfdrive/ui/body/animations.py b/openpilot/selfdrive/ui/body/animations.py index c40f7ecdef..302f8989bf 100644 --- a/openpilot/selfdrive/ui/body/animations.py +++ b/openpilot/selfdrive/ui/body/animations.py @@ -204,7 +204,10 @@ class FaceAnimator: frames_back = round(rewind_elapsed / self._animation.frame_duration) frame_index = self._rewind_from - frames_back if frame_index <= 0: - return self._switch_to_next(now) + if self._next is None: + self._rewinding = False + return self._animation.frames[0] + return self._switch_to_next(now, self._next) return self._animation.frames[frame_index] # Play starting frames first (once) @@ -223,7 +226,7 @@ class FaceAnimator: if self._next is not None: if frame_index == 0 and (len(self._animation.frames) == 1 or self._seen_nonzero): - return self._switch_to_next(now) + return self._switch_to_next(now, self._next) # No natural return to frame 0 — start rewinding if self._animation.mode in (AnimationMode.ONCE_FORWARD, AnimationMode.REPEAT_FORWARD): self._rewinding = True @@ -232,8 +235,8 @@ class FaceAnimator: return self._animation.frames[frame_index] - def _switch_to_next(self, now: float) -> list[tuple[int, int]]: - self._animation = self._next + def _switch_to_next(self, now: float, animation: Animation) -> list[tuple[int, int]]: + self._animation = animation self._next = None self._rewinding = False self._seen_nonzero = False diff --git a/openpilot/selfdrive/ui/layouts/main.py b/openpilot/selfdrive/ui/layouts/main.py index 47a52280f2..277b6f1404 100644 --- a/openpilot/selfdrive/ui/layouts/main.py +++ b/openpilot/selfdrive/ui/layouts/main.py @@ -31,7 +31,11 @@ class MainLayout(Widget): # Initialize layouts self._home_layout = HomeLayout() self._home_body_layout = BodyLayout() - self._layouts = {MainState.HOME: self._home_layout, MainState.SETTINGS: SettingsLayout(), MainState.ONROAD: AugmentedRoadView()} + self._layouts: dict[MainState, Widget] = { + MainState.HOME: self._home_layout, + MainState.SETTINGS: SettingsLayout(), + MainState.ONROAD: AugmentedRoadView(), + } self._sidebar_rect = rl.Rectangle(0, 0, 0, 0) self._content_rect = rl.Rectangle(0, 0, 0, 0) diff --git a/openpilot/selfdrive/ui/layouts/sidebar.py b/openpilot/selfdrive/ui/layouts/sidebar.py index eabf5cc008..4a7c2856bb 100644 --- a/openpilot/selfdrive/ui/layouts/sidebar.py +++ b/openpilot/selfdrive/ui/layouts/sidebar.py @@ -65,7 +65,7 @@ class MetricData: class Sidebar(Widget): def __init__(self): super().__init__() - self._net_type = NETWORK_TYPES.get(NetworkType.none) + self._net_type = NETWORK_TYPES[NetworkType.none] self._net_strength = 0 self._temp_status = MetricData(tr_noop("TEMP"), tr_noop("GOOD"), Colors.GOOD) diff --git a/openpilot/selfdrive/ui/mici/layouts/home.py b/openpilot/selfdrive/ui/mici/layouts/home.py index adb02fb731..e7e99b38ba 100644 --- a/openpilot/selfdrive/ui/mici/layouts/home.py +++ b/openpilot/selfdrive/ui/mici/layouts/home.py @@ -251,7 +251,7 @@ class MiciHomeLayout(Widget): self._egpu_icon.set_visible(ui_state.usbgpu and ui_state.usbgpu_compiled) self._egpu_icon_gray.set_visible(ui_state.usbgpu and not ui_state.usbgpu_compiled) self._mic_icon.set_visible(ui_state.recording_audio) - self._body_icon.set_visible(ui_state.is_body) + self._body_icon.set_visible(bool(ui_state.is_body)) footer_rect = rl.Rectangle(self.rect.x + HOME_PADDING, self.rect.y + self.rect.height - 48, self.rect.width - HOME_PADDING, 48) self._status_bar_layout.render(footer_rect) diff --git a/openpilot/selfdrive/ui/mici/layouts/main.py b/openpilot/selfdrive/ui/mici/layouts/main.py index 26ec5555be..e592253544 100644 --- a/openpilot/selfdrive/ui/mici/layouts/main.py +++ b/openpilot/selfdrive/ui/mici/layouts/main.py @@ -146,4 +146,4 @@ class MiciMainLayout(Scroller): def _on_body_changed(self): self._car_onroad_layout.set_visible(not ui_state.is_body) - self._body_onroad_layout.set_visible(ui_state.is_body) + self._body_onroad_layout.set_visible(bool(ui_state.is_body)) diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/device.py b/openpilot/selfdrive/ui/mici/layouts/settings/device.py index 0adcf53752..cd85ef3add 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/device.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/device.py @@ -16,7 +16,7 @@ from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.widgets import Widget from openpilot.selfdrive.ui.ui_state import device, ui_state from openpilot.system.ui.widgets.label import UnifiedLabel -from openpilot.system.ui.widgets.html_render import HtmlModal, HtmlRenderer +from openpilot.system.ui.widgets.html_render import HtmlRenderer from openpilot.system.athena.registration import UNREGISTERED_DONGLE_ID @@ -160,7 +160,7 @@ class DeviceLayoutMici(NavScroller): def __init__(self): super().__init__() - self._fcc_dialog: HtmlModal | None = None + self._fcc_dialog: MiciFccModal | None = None def power_off_callback(): ui_state.params.put_bool("DoShutdown", True, block=True) diff --git a/openpilot/selfdrive/ui/mici/onroad/cameraview.py b/openpilot/selfdrive/ui/mici/onroad/cameraview.py index 991349dbf0..82e4865c76 100644 --- a/openpilot/selfdrive/ui/mici/onroad/cameraview.py +++ b/openpilot/selfdrive/ui/mici/onroad/cameraview.py @@ -219,7 +219,7 @@ class CameraView(Widget): [0.0, 0.0, 1.0] ]) - def _render(self, rect: rl.Rectangle): + def _render(self, rect: rl.Rectangle, /): if self._switching: self._handle_switch() diff --git a/openpilot/selfdrive/ui/mici/onroad/model_renderer.py b/openpilot/selfdrive/ui/mici/onroad/model_renderer.py index adf9814364..4d19850769 100644 --- a/openpilot/selfdrive/ui/mici/onroad/model_renderer.py +++ b/openpilot/selfdrive/ui/mici/onroad/model_renderer.py @@ -44,8 +44,8 @@ class ModelPoints: @dataclass class LeadVehicle: - glow: list[float] = field(default_factory=list) - chevron: list[float] = field(default_factory=list) + glow: list[tuple[float, float]] = field(default_factory=list) + chevron: list[tuple[float, float]] = field(default_factory=list) fill_alpha: int = 0 diff --git a/openpilot/selfdrive/ui/onroad/model_renderer.py b/openpilot/selfdrive/ui/onroad/model_renderer.py index 50ff3b1c53..8a40c90025 100644 --- a/openpilot/selfdrive/ui/onroad/model_renderer.py +++ b/openpilot/selfdrive/ui/onroad/model_renderer.py @@ -37,8 +37,8 @@ class ModelPoints: @dataclass class LeadVehicle: - glow: list[float] = field(default_factory=list) - chevron: list[float] = field(default_factory=list) + glow: list[tuple[float, float]] = field(default_factory=list) + chevron: list[tuple[float, float]] = field(default_factory=list) fill_alpha: int = 0 diff --git a/openpilot/selfdrive/ui/tests/diff/replay_script.py b/openpilot/selfdrive/ui/tests/diff/replay_script.py index 8517f0dece..109f32e47a 100644 --- a/openpilot/selfdrive/ui/tests/diff/replay_script.py +++ b/openpilot/selfdrive/ui/tests/diff/replay_script.py @@ -357,7 +357,7 @@ def build_mici_script(pm: PubMaster, main_layout, script: Script) -> None: params = Params() main_layout._alerts_layout._pending_params = ({"UpdaterNewDescription": params.get("UpdaterNewDescription")} | {alert_data.key: params.get(alert_data.key) for alert_data in main_layout._alerts_layout.sorted_alerts}) - main_layout._alerts_layout._refresh() + main_layout._alerts_layout._update_state() swipe_right(width, wait_after=WAIT_SHORT) # open alerts script.setup(setup_offroad_alerts_and_refresh) # show alerts diff --git a/openpilot/selfdrive/ui/tests/profile_onroad.py b/openpilot/selfdrive/ui/tests/profile_onroad.py index 29e44d6260..ec15e71c35 100755 --- a/openpilot/selfdrive/ui/tests/profile_onroad.py +++ b/openpilot/selfdrive/ui/tests/profile_onroad.py @@ -49,7 +49,7 @@ def patch_submaster(message_chunks): sm.recv_frame[service] = sm.frame sm.valid[service] = True sm.frame += 1 - ui_state.sm.update = mock_update + ui_state.sm.update = mock_update # ty: ignore[invalid-assignment] # profiling hook if __name__ == "__main__": diff --git a/openpilot/selfdrive/ui/translations/potools.py b/openpilot/selfdrive/ui/translations/potools.py index ac4dafb988..15da4c586c 100644 --- a/openpilot/selfdrive/ui/translations/potools.py +++ b/openpilot/selfdrive/ui/translations/potools.py @@ -67,22 +67,22 @@ def parse_po(path: str | Path) -> tuple[POEntry | None, list[POEntry]]: cur_field: str | None = None plural_idx = 0 - def finish(): - nonlocal cur, header - if cur is None: + def finish(entry: POEntry | None): + nonlocal header + if entry is None: return - if cur.msgid == "" and cur.msgstr: - header = cur - elif cur.msgid != "" or cur.is_plural: - entries.append(cur) - cur = None + if entry.msgid == "" and entry.msgstr: + header = entry + elif entry.msgid != "" or entry.is_plural: + entries.append(entry) for raw in lines: line = raw.rstrip('\n') stripped = line.strip() if not stripped: - finish() + finish(cur) + cur = None cur_field = None continue @@ -123,6 +123,8 @@ def parse_po(path: str | Path) -> tuple[POEntry | None, list[POEntry]]: continue if stripped.startswith('msgstr '): + if cur is None: + cur = POEntry() cur.msgstr = _parse_quoted(stripped[len('msgstr '):]) cur_field = 'msgstr' continue @@ -138,7 +140,7 @@ def parse_po(path: str | Path) -> tuple[POEntry | None, list[POEntry]]: elif cur_field == 'msgstr_plural': cur.msgstr_plural[plural_idx] += val - finish() + finish(cur) return header, entries diff --git a/openpilot/system/athena/athenad.py b/openpilot/system/athena/athenad.py index cf590afb40..91b12fc0a7 100755 --- a/openpilot/system/athena/athenad.py +++ b/openpilot/system/athena/athenad.py @@ -669,7 +669,10 @@ def log_handler(end_event: threading.Event) -> None: def ws_proxy_recv(ws: WebSocket, local_sock: socket.socket, ssock: socket.socket, end_event: threading.Event, global_end_event: threading.Event) -> None: while not (end_event.is_set() or global_end_event.is_set()): try: - r = select.select((ws.sock,), (), (), 30) + sock = ws.sock + if sock is None: + return + r = select.select((sock,), (), (), 30) if r[0]: data = ws.recv() if isinstance(data, str): diff --git a/openpilot/system/athena/tests/test_athenad.py b/openpilot/system/athena/tests/test_athenad.py index accf0784ab..8c090b93af 100644 --- a/openpilot/system/athena/tests/test_athenad.py +++ b/openpilot/system/athena/tests/test_athenad.py @@ -60,7 +60,7 @@ class TestAthenadMethods: @classmethod def setup_class(cls): cls.SOCKET_PORT = 45454 - athenad.Api = MockApi + athenad.Api = MockApi # ty: ignore[invalid-assignment] # test double athenad.LOCAL_PORT_WHITELIST = {cls.SOCKET_PORT} def setup_method(self): @@ -351,6 +351,7 @@ class TestAthenadMethods: assert items[0] == asdict(item) assert not items[0]['current'] + assert item.id is not None athenad.cancelled_uploads.add(item.id) items = dispatcher["listUploadQueue"]() assert len(items) == 0 @@ -363,6 +364,7 @@ class TestAthenadMethods: athenad.upload_queue.put_nowait(item2) # Ensure canceled items are not persisted + assert item2.id is not None athenad.cancelled_uploads.add(item2.id) # serialize item diff --git a/openpilot/system/hardware/power_monitoring.py b/openpilot/system/hardware/power_monitoring.py index 72a8c6848c..816d5e37dd 100644 --- a/openpilot/system/hardware/power_monitoring.py +++ b/openpilot/system/hardware/power_monitoring.py @@ -34,7 +34,7 @@ class PowerMonitoring: self.car_battery_capacity_uWh = max((CAR_BATTERY_CAPACITY_uWh / 10), car_battery_capacity_uWh) # Calculation tick - def calculate(self, voltage: int | None, ignition: bool): + def calculate(self, voltage: float | None, ignition: bool): try: now = time.monotonic() diff --git a/openpilot/system/hardware/tests/test_power_monitoring.py b/openpilot/system/hardware/tests/test_power_monitoring.py index 5e1b751a0f..f3ffb98e09 100644 --- a/openpilot/system/hardware/tests/test_power_monitoring.py +++ b/openpilot/system/hardware/tests/test_power_monitoring.py @@ -35,7 +35,7 @@ class TestPowerMonitoring: def test_panda_state_present(self): pm = PowerMonitoring() for _ in range(10): - pm.calculate(None, None) + pm.calculate(None, False) assert pm.get_power_used() == 0 assert pm.get_car_battery_capacity() == (CAR_BATTERY_CAPACITY_uWh / 10) diff --git a/openpilot/system/loggerd/tests/loggerd_tests_common.py b/openpilot/system/loggerd/tests/loggerd_tests_common.py index 757b61d17e..55f5fbb816 100644 --- a/openpilot/system/loggerd/tests/loggerd_tests_common.py +++ b/openpilot/system/loggerd/tests/loggerd_tests_common.py @@ -63,10 +63,10 @@ class UploaderTestCase: seg_dir: str def set_ignore(self): - uploader.Api = MockApiIgnore + uploader.Api = MockApiIgnore # ty: ignore[invalid-assignment] # test double def setup_method(self): - uploader.Api = MockApi + uploader.Api = MockApi # ty: ignore[invalid-assignment] # test double uploader.fake_upload = True uploader.force_wifi = True uploader.allow_sleep = False diff --git a/openpilot/system/loggerd/tests/test_deleter.py b/openpilot/system/loggerd/tests/test_deleter.py index 6222ea253b..8f18c8ceee 100644 --- a/openpilot/system/loggerd/tests/test_deleter.py +++ b/openpilot/system/loggerd/tests/test_deleter.py @@ -19,7 +19,7 @@ class TestDeleter(UploaderTestCase): self.f_type = "fcamera.hevc" super().setup_method() self.fake_stats = Stats(f_bavail=0, f_blocks=10, f_frsize=4096) - deleter.os.statvfs = self.fake_statvfs + deleter.os.statvfs = self.fake_statvfs # ty: ignore[invalid-assignment] # test double def start_thread(self): self.end_event = threading.Event() diff --git a/openpilot/system/loggerd/tests/test_loggerd.py b/openpilot/system/loggerd/tests/test_loggerd.py index 666b56dc03..3678ca95be 100644 --- a/openpilot/system/loggerd/tests/test_loggerd.py +++ b/openpilot/system/loggerd/tests/test_loggerd.py @@ -5,6 +5,7 @@ import random import string import subprocess import time +from collections.abc import Collection from collections import defaultdict from pathlib import Path import pytest @@ -74,8 +75,8 @@ class TestLoggerd: end_type = SentinelType.endOfRoute if route else SentinelType.endOfSegment assert msgs[-1].sentinel.type == end_type - def _publish_random_messages(self, services: list[str]) -> dict[str, list]: - pm = messaging.PubMaster(services) + def _publish_random_messages(self, services: Collection[str]) -> dict[str, list]: + pm = messaging.PubMaster(list(services)) managed_processes["loggerd"].start() for s in services: diff --git a/openpilot/system/qcomgpsd/nmeaport.py b/openpilot/system/qcomgpsd/nmeaport.py index ba7cf117b1..db1fe5552d 100644 --- a/openpilot/system/qcomgpsd/nmeaport.py +++ b/openpilot/system/qcomgpsd/nmeaport.py @@ -1,9 +1,9 @@ import os import sys -from dataclasses import dataclass, fields +from dataclasses import dataclass from subprocess import check_output, CalledProcessError from time import sleep -from typing import NoReturn, cast +from typing import NoReturn DEBUG = int(os.environ.get("DEBUG", "0")) @@ -17,27 +17,27 @@ class GnssClockNmeaPort: # 0x10 = bias_uncertainty_ns valid # 0x20 = drift_nsps valid # 0x40 = drift_uncertainty_nsps valid - flags: int - leap_seconds: int - time_ns: int - time_uncertainty_ns: int # 1-sigma - full_bias_ns: int - bias_ns: float - bias_uncertainty_ns: float # 1-sigma - drift_nsps: float - drift_uncertainty_nsps: float # 1-sigma + flags: int | None + leap_seconds: int | None + time_ns: int | None + time_uncertainty_ns: int | None # 1-sigma + full_bias_ns: int | None + bias_ns: float | None + bias_uncertainty_ns: float | None # 1-sigma + drift_nsps: float | None + drift_uncertainty_nsps: float | None # 1-sigma - def __post_init__(self): - for field in fields(self): - val = getattr(self, field.name) - field_type = cast(type, field.type) - setattr(self, field.name, field_type(val) if val else None) + @classmethod + def from_fields(cls, values: list[str]) -> 'GnssClockNmeaPort': + ints = [int(value) if value else None for value in values[:5]] + floats = [float(value) if value else None for value in values[5:9]] + return cls(*ints, *floats) @dataclass class GnssMeasNmeaPort: - messageCount: int - messageNum: int - svCount: int + messageCount: int | None + messageNum: int | None + svCount: int | None # constellation enum: # 1 = GPS # 2 = SBAS @@ -45,10 +45,10 @@ class GnssMeasNmeaPort: # 4 = QZSS # 5 = BEIDOU # 6 = GALILEO - constellation: int - svId: int - flags: int # always zero - time_offset_ns: int + constellation: int | None + svId: int | None + flags: int | None # always zero + time_offset_ns: int | None # state bit mask: # 0x0001 = CODE LOCK # 0x0002 = BIT SYNC @@ -64,18 +64,18 @@ class GnssMeasNmeaPort: # 0x0800 = GALILEO E1C 2ND CODE LOCK # 0x1000 = GALILEO E1B PAGE SYNC # 0x2000 = GALILEO E1B PAGE SYNC - state: int - time_of_week_ns: int - time_of_week_uncertainty_ns: int # 1-sigma - carrier_to_noise_ratio: float - pseudorange_rate: float - pseudorange_rate_uncertainty: float # 1-sigma + state: int | None + time_of_week_ns: int | None + time_of_week_uncertainty_ns: int | None # 1-sigma + carrier_to_noise_ratio: float | None + pseudorange_rate: float | None + pseudorange_rate_uncertainty: float | None # 1-sigma - def __post_init__(self): - for field in fields(self): - val = getattr(self, field.name) - field_type = cast(type, field.type) - setattr(self, field.name, field_type(val) if val else None) + @classmethod + def from_fields(cls, values: list[str]) -> 'GnssMeasNmeaPort': + ints = [int(value) if value else None for value in values[:10]] + floats = [float(value) if value else None for value in values[10:13]] + return cls(*ints, *floats) def nmea_checksum_ok(s): checksum = 0 @@ -109,11 +109,11 @@ def process_nmea_port_messages(device:str="/dev/ttyUSB1") -> NoReturn: match fields[0]: case "$GNCLK": # fields at end are reserved (not used) - gnss_clock = GnssClockNmeaPort(*fields[1:10]) + gnss_clock = GnssClockNmeaPort.from_fields(fields[1:10]) print(gnss_clock) case "$GNMEAS": # fields at end are reserved (not used) - gnss_meas = GnssMeasNmeaPort(*fields[1:14]) + gnss_meas = GnssMeasNmeaPort.from_fields(fields[1:14]) print(gnss_meas) except Exception as e: print(e) diff --git a/openpilot/system/sensord/tests/test_sensord.py b/openpilot/system/sensord/tests/test_sensord.py index fc4e3061bc..dc96886e4a 100644 --- a/openpilot/system/sensord/tests/test_sensord.py +++ b/openpilot/system/sensord/tests/test_sensord.py @@ -20,7 +20,7 @@ SENSOR_CONFIGS = ( ) SENSOR_CONFIGS_BY_MEASUREMENT = {config.measurement: config for config in SENSOR_CONFIGS} -def get_irq_count(irq: int): +def get_irq_count(irq: str): with open(f"/sys/kernel/irq/{irq}/per_cpu_count") as f: per_cpu = map(int, f.read().split(",")) return sum(per_cpu) diff --git a/openpilot/system/ubloxd/binary_struct.py b/openpilot/system/ubloxd/binary_struct.py index c144bd5696..5bc05094f3 100644 --- a/openpilot/system/ubloxd/binary_struct.py +++ b/openpilot/system/ubloxd/binary_struct.py @@ -184,7 +184,7 @@ class BinaryStruct: setattr(obj, name, value) return obj - cls._read = _read + cls._read = _read # ty: ignore[invalid-assignment] # installed dynamically for each subclass @classmethod def from_bytes(cls: type[T], data: bytes) -> T: diff --git a/openpilot/system/ui/lib/utils.py b/openpilot/system/ui/lib/utils.py index 77035d0da0..e97b3ba9d9 100644 --- a/openpilot/system/ui/lib/utils.py +++ b/openpilot/system/ui/lib/utils.py @@ -1,8 +1,9 @@ import pyray as rl +from collections.abc import Sequence class GuiStyleContext: - def __init__(self, styles: list[tuple[int, int, int]]): + def __init__(self, styles: Sequence[tuple[int, int, int]]): """styles is a list of tuples (control, prop, new_value)""" self.styles = styles self.prev_styles: list[tuple[int, int, int]] = [] diff --git a/openpilot/system/ui/lib/wifi_manager.py b/openpilot/system/ui/lib/wifi_manager.py index 147666bf85..c52e5a2ddf 100644 --- a/openpilot/system/ui/lib/wifi_manager.py +++ b/openpilot/system/ui/lib/wifi_manager.py @@ -257,7 +257,7 @@ class WifiManager: def add_callbacks(self, need_auth: Callable[[str], None] | None = None, activated: Callable[[], None] | None = None, - forgotten: Callable[[str], None] | None = None, + forgotten: Callable[[str | None], None] | None = None, networks_updated: Callable[[list[Network]], None] | None = None, disconnected: Callable[[], None] | None = None): if need_auth is not None: diff --git a/openpilot/system/ui/tici_reset.py b/openpilot/system/ui/tici_reset.py index 569faf45fe..5c175dbfa6 100755 --- a/openpilot/system/ui/tici_reset.py +++ b/openpilot/system/ui/tici_reset.py @@ -37,7 +37,11 @@ class Reset(Widget): self._reset_state = ResetState.NONE self._cancel_button = Button("Cancel", gui_app.request_close) self._confirm_button = Button("Confirm", self._confirm, button_style=ButtonStyle.PRIMARY) - self._reboot_button = Button("Reboot", lambda: subprocess.run("sudo reboot", shell=True)) + self._reboot_button = Button("Reboot", self._reboot) + + @staticmethod + def _reboot() -> None: + subprocess.run("sudo reboot", shell=True) def _do_erase(self): if PC: diff --git a/openpilot/system/ui/widgets/__init__.py b/openpilot/system/ui/widgets/__init__.py index 4ce1c1b694..f8cb04d8a7 100644 --- a/openpilot/system/ui/widgets/__init__.py +++ b/openpilot/system/ui/widgets/__init__.py @@ -3,16 +3,25 @@ from __future__ import annotations import abc import pyray as rl from enum import IntEnum -from typing import TypeVar +from typing import Protocol, TypeVar from collections.abc import Callable from openpilot.system.ui.lib.application import gui_app, MousePos, MAX_TOUCH_SLOTS, MouseEvent -try: - from openpilot.selfdrive.ui.ui_state import device -except ImportError: - class Device: - awake = True - device = Device() +class DeviceLike(Protocol): + awake: bool + + +def _get_device() -> DeviceLike: + try: + from openpilot.selfdrive.ui.ui_state import device + return device + except ImportError: + class Device: + awake = True + return Device() + + +device = _get_device() W = TypeVar('W', bound='Widget') @@ -185,16 +194,16 @@ class Widget(abc.ABC): """Optionally update the widget's non-layout state. This is called before rendering.""" @abc.abstractmethod - def _render(self, rect: rl.Rectangle) -> bool | int | None: + def _render(self, rect: rl.Rectangle, /) -> bool | int | None: """Render the widget within the given rectangle.""" def _update_layout_rects(self) -> None: """Optionally update any layout rects on Widget rect change.""" - def _handle_mouse_press(self, mouse_pos: MousePos) -> None: + def _handle_mouse_press(self, mouse_pos: MousePos, /) -> None: """Optionally handle mouse press events.""" - def _handle_mouse_release(self, mouse_pos: MousePos) -> None: + def _handle_mouse_release(self, mouse_pos: MousePos, /) -> None: """Optionally handle mouse release events.""" if self._click_delay is not None: self._click_release_time = rl.get_time() + self._click_delay diff --git a/openpilot/system/ui/widgets/label.py b/openpilot/system/ui/widgets/label.py index 7052b2c2a9..fdaf6f3148 100644 --- a/openpilot/system/ui/widgets/label.py +++ b/openpilot/system/ui/widgets/label.py @@ -1,7 +1,6 @@ import math from enum import IntEnum from collections.abc import Callable -from itertools import zip_longest from typing import Union import pyray as rl @@ -210,7 +209,7 @@ class Label(Widget): icon_x = self._rect.x + (self._rect.width - self._icon.width) / 2 rl.draw_texture_v(self._icon, rl.Vector2(icon_x, icon_y), rl.WHITE) - for text, text_size, emojis in zip_longest(self._text_wrapped, self._text_size, self._emojis, fillvalue=[]): + for text, text_size, emojis in zip(self._text_wrapped, self._text_size, self._emojis, strict=True): line_pos = rl.Vector2(text_pos.x, text_pos.y) if self._text_alignment == rl.GuiTextAlignment.TEXT_ALIGN_LEFT: line_pos.x += self._text_padding diff --git a/openpilot/system/ui/widgets/nav_widget.py b/openpilot/system/ui/widgets/nav_widget.py index 11770bbe5d..58ff8123bb 100644 --- a/openpilot/system/ui/widgets/nav_widget.py +++ b/openpilot/system/ui/widgets/nav_widget.py @@ -78,7 +78,7 @@ class NavWidget(Widget, abc.ABC): # the top of a vertical scroll panel to prevent erroneous swipes return True - def set_back_callback(self, callback: Callable[[], None]) -> None: + def set_back_callback(self, callback: Callable[[], None] | None) -> None: self._back_callback = callback def set_shown_callback(self, callback: Callable[[], None] | None) -> None: diff --git a/openpilot/system/ui/widgets/network.py b/openpilot/system/ui/widgets/network.py index 710202c54f..4068d552b9 100644 --- a/openpilot/system/ui/widgets/network.py +++ b/openpilot/system/ui/widgets/network.py @@ -15,16 +15,6 @@ from openpilot.system.ui.widgets.label import gui_label from openpilot.system.ui.widgets.scroller_tici import Scroller from openpilot.system.ui.widgets.list_view import ButtonAction, ListItem, MultipleButtonAction, ToggleAction, button_item, text_item -# These are only used for AdvancedNetworkSettings, standalone apps just need WifiManagerUI -try: - from openpilot.common.params import Params - from openpilot.selfdrive.ui.ui_state import ui_state - from openpilot.selfdrive.ui.lib.prime_state import PrimeType -except Exception: - Params = None - ui_state = None - PrimeType = None - NM_DEVICE_STATE_NEED_AUTH = 60 MIN_PASSWORD_LENGTH = 8 MAX_PASSWORD_LENGTH = 64 @@ -105,11 +95,16 @@ class NetworkUI(Widget): class AdvancedNetworkSettings(Widget): def __init__(self, wifi_manager: WifiManager): - assert Params is not None + # AdvancedNetworkSettings needs the full openpilot environment, standalone apps just use WifiManagerUI + from openpilot.common.params import Params + from openpilot.selfdrive.ui.ui_state import ui_state + from openpilot.selfdrive.ui.lib.prime_state import PrimeType super().__init__() self._wifi_manager = wifi_manager self._wifi_manager.add_callbacks(networks_updated=self._on_network_updated) self._params = Params() + self._prime_state = ui_state.prime_state + self._cell_prime_types = (PrimeType.NONE, PrimeType.LITE) self._keyboard = Keyboard(max_text_size=MAX_PASSWORD_LENGTH, min_text_size=MIN_PASSWORD_LENGTH, show_password_toggle=True) @@ -254,7 +249,7 @@ class AdvancedNetworkSettings(Widget): self._wifi_manager.process_callbacks() # If not using prime SIM, show GSM settings and enable IPv4 forwarding - show_cell_settings = ui_state.prime_state.get_type() in (PrimeType.NONE, PrimeType.LITE) + show_cell_settings = self._prime_state.get_type() in self._cell_prime_types self._wifi_manager.set_ipv4_forward(show_cell_settings) self._roaming_btn.set_visible(show_cell_settings) self._apn_btn.set_visible(show_cell_settings) diff --git a/openpilot/system/ui/widgets/scroller.py b/openpilot/system/ui/widgets/scroller.py index b7b6bf5932..55195b30fb 100644 --- a/openpilot/system/ui/widgets/scroller.py +++ b/openpilot/system/ui/widgets/scroller.py @@ -1,6 +1,6 @@ import pyray as rl import numpy as np -from collections.abc import Callable +from collections.abc import Callable, Sequence from openpilot.common.filter_simple import FirstOrderFilter, BounceFilter from openpilot.common.swaglog import cloudlog @@ -40,7 +40,7 @@ class ScrollIndicator(Widget): self._content_size = content_size self._viewport = viewport - def _render(self, _): + def _render(self, _, /): # scale indicator width based on content size indicator_w = float(np.interp(self._content_size, [1000, 3000], [300, 100])) @@ -69,7 +69,7 @@ class ScrollIndicator(Widget): class _Scroller(Widget): """Should use wrapper below to reduce boilerplate""" - def __init__(self, items: list[Widget], horizontal: bool = True, snap_items: bool = False, spacing: int = ITEM_SPACING, + def __init__(self, items: Sequence[Widget], horizontal: bool = True, snap_items: bool = False, spacing: int = ITEM_SPACING, pad: int = ITEM_SPACING, scroll_indicator: bool = True, edge_shadows: bool = True): super().__init__() self._items: list[Widget] = [] @@ -150,7 +150,7 @@ class _Scroller(Widget): and not self.moving_items and (original_touch_valid_callback() if original_touch_valid_callback else True)) - def add_widgets(self, items: list[Widget]) -> None: + def add_widgets(self, items: Sequence[Widget]) -> None: for item in items: self.add_widget(item) @@ -332,7 +332,7 @@ class _Scroller(Widget): else: item.render() - def _render(self, _): + def _render(self, _, /): rl.begin_scissor_mode(int(self._rect.x), int(self._rect.y), int(self._rect.width), int(self._rect.height)) @@ -397,7 +397,7 @@ class Scroller(Widget): # pass down enabled to child widget for nav stack self._scroller.set_enabled(lambda: self.enabled) - def _render(self, _): + def _render(self, _, /): self._scroller.render(self._rect) diff --git a/openpilot/system/ui/widgets/scroller_tici.py b/openpilot/system/ui/widgets/scroller_tici.py index a843010d56..fc81e1b079 100644 --- a/openpilot/system/ui/widgets/scroller_tici.py +++ b/openpilot/system/ui/widgets/scroller_tici.py @@ -1,4 +1,5 @@ import pyray as rl +from collections.abc import Sequence from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel from openpilot.system.ui.widgets import Widget @@ -23,7 +24,7 @@ class LineSeparator(Widget): class Scroller(Widget): - def __init__(self, items: list[Widget], spacing: int = ITEM_SPACING, line_separator: bool = False, pad_end: bool = True): + def __init__(self, items: Sequence[Widget], spacing: int = ITEM_SPACING, line_separator: bool = False, pad_end: bool = True): super().__init__() self._items: list[Widget] = [] self._spacing = spacing diff --git a/openpilot/system/updated/updated.py b/openpilot/system/updated/updated.py index ac8111d5c5..9610785861 100755 --- a/openpilot/system/updated/updated.py +++ b/openpilot/system/updated/updated.py @@ -357,7 +357,7 @@ class Updater: setup_git_options(OVERLAY_MERGED) output = run(["git", "ls-remote", "--heads"], OVERLAY_MERGED) - self.branches = defaultdict(lambda: None) + self.branches.clear() for line in output.split('\n'): ls_remotes_re = r'(?P\b[0-9a-f]{5,40}\b)(\s+)(refs\/heads\/)(?P.*$)' x = re.fullmatch(ls_remotes_re, line.strip()) diff --git a/openpilot/system/webrtc/tests/test_stream_session.py b/openpilot/system/webrtc/tests/test_stream_session.py index b83e1223d2..1f7bd5747a 100644 --- a/openpilot/system/webrtc/tests/test_stream_session.py +++ b/openpilot/system/webrtc/tests/test_stream_session.py @@ -55,9 +55,11 @@ class TestStreamSession: mocked_pubmaster.send.assert_called_once() mt, md = mocked_pubmaster.send.call_args.args - assert mt == msg["type"] + msg_type = msg["type"] + assert isinstance(msg_type, str) + assert mt == msg_type assert isinstance(md, capnp._DynamicStructBuilder) - assert hasattr(md, msg["type"]) + assert hasattr(md, msg_type) mocked_pubmaster.reset_mock() diff --git a/openpilot/system/webrtc/webrtcd.py b/openpilot/system/webrtc/webrtcd.py index 82ebb7b018..7901f9847f 100755 --- a/openpilot/system/webrtc/webrtcd.py +++ b/openpilot/system/webrtc/webrtcd.py @@ -519,7 +519,7 @@ class WebrtcdHandler(BaseHTTPRequestHandler): def do_OPTIONS(self) -> None: self._dispatch_request() - def log_message(self, fmt, *args) -> None: + def log_message(self, format: str, *args: object) -> None: # noqa: A002 # stdlib override # silence default access logging; errors are logged explicitly in _dispatch_request pass diff --git a/openpilot/tools/lib/auth.py b/openpilot/tools/lib/auth.py index 5988397d0a..9139d8b42d 100755 --- a/openpilot/tools/lib/auth.py +++ b/openpilot/tools/lib/auth.py @@ -54,7 +54,7 @@ class ClientRedirectHandler(BaseHTTPRequestHandler): self.end_headers() self.wfile.write(b'Return to the CLI to continue') - def log_message(self, *args): + def log_message(self, format: str, *args: object) -> None: # noqa: A002 # stdlib override pass # this prevent http server from dumping messages to stdout diff --git a/openpilot/tools/lib/file_sources.py b/openpilot/tools/lib/file_sources.py index cb7bf15114..2d2bfccfd7 100755 --- a/openpilot/tools/lib/file_sources.py +++ b/openpilot/tools/lib/file_sources.py @@ -12,7 +12,7 @@ Source = Callable[[SegmentRange, list[int], FileNames], dict[int, str]] InternalUnavailableException = Exception("Internal source not available") -def comma_api_source(sr: SegmentRange, seg_idxs: list[int], fns: FileNames) -> dict[int, str]: +def comma_api_source(sr: SegmentRange, seg_idxs: list[int], fns: FileNames, /) -> dict[int, str]: route = Route(sr.route_name) # comma api will have already checked if the file exists @@ -22,7 +22,7 @@ def comma_api_source(sr: SegmentRange, seg_idxs: list[int], fns: FileNames) -> d return {seg: route.qlog_paths()[seg] for seg in seg_idxs if route.qlog_paths()[seg] is not None} -def internal_source(sr: SegmentRange, seg_idxs: list[int], fns: FileNames, endpoint_url: str = DATA_ENDPOINT) -> dict[int, str]: +def internal_source(sr: SegmentRange, seg_idxs: list[int], fns: FileNames, /, endpoint_url: str = DATA_ENDPOINT) -> dict[int, str]: if not internal_source_available(endpoint_url): raise InternalUnavailableException @@ -32,11 +32,11 @@ def internal_source(sr: SegmentRange, seg_idxs: list[int], fns: FileNames, endpo return eval_source({seg: [get_internal_url(sr, seg, fn) for fn in fns] for seg in seg_idxs}) -def openpilotci_source(sr: SegmentRange, seg_idxs: list[int], fns: FileNames) -> dict[int, str]: +def openpilotci_source(sr: SegmentRange, seg_idxs: list[int], fns: FileNames, /) -> dict[int, str]: return eval_source({seg: [get_url(sr.route_name, seg, fn) for fn in fns] for seg in seg_idxs}) -def comma_car_segments_source(sr: SegmentRange, seg_idxs: list[int], fns: FileNames) -> dict[int, str]: +def comma_car_segments_source(sr: SegmentRange, seg_idxs: list[int], fns: FileNames, /) -> dict[int, str]: return eval_source({seg: get_comma_segments_url(sr.route_name, seg) for seg in seg_idxs}) diff --git a/openpilot/tools/lib/tests/test_caching.py b/openpilot/tools/lib/tests/test_caching.py index 0753ef1d3c..c70d7fc81b 100644 --- a/openpilot/tools/lib/tests/test_caching.py +++ b/openpilot/tools/lib/tests/test_caching.py @@ -16,7 +16,7 @@ class CachingTestRequestHandler(http.server.BaseHTTPRequestHandler): def do_GET(self): if self.FILE_EXISTS: - self.send_response(206 if "Range" in self.headers else 200, b'1234') + self.send_response(206 if "Range" in self.headers else 200, '1234') else: self.send_response(404) self.end_headers() diff --git a/openpilot/tools/longitudinal_maneuvers/maneuversd.py b/openpilot/tools/longitudinal_maneuvers/maneuversd.py index a9a5ba6304..8cdc2f26cf 100755 --- a/openpilot/tools/longitudinal_maneuvers/maneuversd.py +++ b/openpilot/tools/longitudinal_maneuvers/maneuversd.py @@ -59,7 +59,7 @@ class Maneuver: return float(action_accel) - def get_accel(self, v_ego: float, long_active: bool, standstill: bool, cruise_standstill: bool) -> float: + def get_accel(self, v_ego: float, long_active: bool, standstill: bool, cruise_standstill: bool, /) -> float: ready = abs(v_ego - self.initial_speed) < 0.3 and long_active and not cruise_standstill if self.initial_speed < 0.01: ready = ready and standstill diff --git a/openpilot/tools/replay/lib/ui_helpers.py b/openpilot/tools/replay/lib/ui_helpers.py index 8f0c14983f..b9da0d225f 100644 --- a/openpilot/tools/replay/lib/ui_helpers.py +++ b/openpilot/tools/replay/lib/ui_helpers.py @@ -5,6 +5,7 @@ import numpy as np import pyray as rl from matplotlib.backends.backend_agg import FigureCanvasAgg +from matplotlib.artist import Artist from matplotlib.offsetbox import AnchoredOffsetbox, HPacker, TextArea from openpilot.common.transformations.camera import get_view_frame_from_calib_frame @@ -119,7 +120,7 @@ def init_plots(arr, name_to_arr_idx, plot_xlims, plot_ylims, plot_names, plot_co idxs.append(name_to_arr_idx[item]) plot_select.append(i) # Build colored title: each label colored to match its plot line - title_texts = [] + title_texts: list[Artist] = [] for j2, (nm, cl) in enumerate(zip(pl_list, plot_colors[i], strict=False)): if j2 > 0: title_texts.append(TextArea(", ", textprops={"color": "white", "fontsize": 10})) diff --git a/openpilot/tools/sim/bridge/common.py b/openpilot/tools/sim/bridge/common.py index 048bf6cb11..1d00e3b0f9 100644 --- a/openpilot/tools/sim/bridge/common.py +++ b/openpilot/tools/sim/bridge/common.py @@ -94,7 +94,7 @@ Ignition: {self.simulator_state.ignition} Engaged: {self.simulator_state.is_enga """) @abstractmethod - def spawn_world(self, q: Queue) -> World: + def spawn_world(self, q: Queue, /) -> World: pass def _run(self, q: Queue): diff --git a/openpilot/tools/sim/lib/common.py b/openpilot/tools/sim/lib/common.py index 1324932131..eb29119fb4 100644 --- a/openpilot/tools/sim/lib/common.py +++ b/openpilot/tools/sim/lib/common.py @@ -40,7 +40,7 @@ class SimulatorState: self.is_engaged = False self.ignition = True - self.velocity: vec3 = None + self.velocity = vec3(0, 0, 0) self.bearing: float = 0 self.gps = GPSState() self.imu = IMUState() @@ -72,7 +72,7 @@ class World(ABC): self.exit_event = multiprocessing.Event() @abstractmethod - def apply_controls(self, steer_sim, throttle_out, brake_out): + def apply_controls(self, steer_sim, throttle_out, brake_out, /): pass @abstractmethod @@ -84,7 +84,7 @@ class World(ABC): pass @abstractmethod - def read_sensors(self, simulator_state: SimulatorState): + def read_sensors(self, simulator_state: SimulatorState, /): pass @abstractmethod diff --git a/pyproject.toml b/pyproject.toml index 5c9f16d33a..5895a2a095 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -186,10 +186,6 @@ quote-style = "preserve" [tool.ty.rules] unresolved-import = "ignore" # Cython-compiled modules (.pyx) unresolved-attribute = "ignore" # many from capnp and Cython modules -invalid-method-override = "ignore" # signature variance issues -possibly-missing-attribute = "ignore" # too many false positives -invalid-assignment = "ignore" # often intentional monkey-patching -invalid-argument-type = "ignore" # many false positives from raylib, ctypes, numpy [tool.uv] python-preference = "only-managed" From 39e12c8bb1ab6e4ff47f1a4e3660eb3a97077c10 Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Sun, 19 Jul 2026 11:14:35 -0700 Subject: [PATCH 21/35] rm cffi, it's a raylib transitive dep --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 5895a2a095..6d9f3923a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,6 @@ dependencies = [ "tqdm", # cars (fw_versions.py) on start + many one-off uses # core - "cffi", "scons", "pycapnp==2.1.0", # 2.2 introduces a memory leak due to cyclic references "Cython", From 24893ebadbc77b9fe0451c7600acf0c4fc7bdfe5 Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Sun, 19 Jul 2026 12:10:48 -0700 Subject: [PATCH 22/35] rm pre-commit-hooks (#38382) * rm pre-commit-hooks * rm test for the tests * lil more --- pyproject.toml | 1 - scripts/lint/check_added_large_files.py | 47 +++++++++++++++++ .../check_shebang_scripts_are_executable.py | 50 +++++++++++++++++++ scripts/lint/lint.sh | 4 +- uv.lock | 23 --------- 5 files changed, 99 insertions(+), 26 deletions(-) create mode 100755 scripts/lint/check_added_large_files.py create mode 100755 scripts/lint/check_shebang_scripts_are_executable.py diff --git a/pyproject.toml b/pyproject.toml index 6d9f3923a1..c3769f0401 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,7 +72,6 @@ testing = [ "pytest-mock", "ruff", "codespell", - "pre-commit-hooks", ] dev = [ diff --git a/scripts/lint/check_added_large_files.py b/scripts/lint/check_added_large_files.py new file mode 100755 index 0000000000..c1aa820181 --- /dev/null +++ b/scripts/lint/check_added_large_files.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +import argparse +import math +import os +import subprocess + + +def lfs_files(filenames: list[str]) -> set[str]: + if not filenames: + return set() + + result = subprocess.run( + ("git", "check-attr", "filter", "-z", "--stdin"), + input="\0".join(filenames), + check=True, + capture_output=True, + text=True, + ) + fields = result.stdout.rstrip("\0").split("\0") if result.stdout else [] + return {fields[i] for i in range(0, len(fields), 3) if fields[i + 2] == "lfs"} + + +def check_added_large_files(filenames: list[str], max_kb: int) -> int: + failed = False + ignored = lfs_files(filenames) + for filename in filenames: + if filename in ignored: + continue + + size_kb = math.ceil(os.stat(filename).st_size / 1024) + if size_kb > max_kb: + print(f"{filename} ({size_kb} KB) exceeds {max_kb} KB.") + failed = True + + return int(failed) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Check that tracked files do not exceed a size limit.") + parser.add_argument("filenames", nargs="*") + parser.add_argument("--maxkb", type=int, default=500, help="maximum allowable size in KiB") + args = parser.parse_args() + return check_added_large_files(args.filenames, args.maxkb) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/lint/check_shebang_scripts_are_executable.py b/scripts/lint/check_shebang_scripts_are_executable.py new file mode 100755 index 0000000000..7288640091 --- /dev/null +++ b/scripts/lint/check_shebang_scripts_are_executable.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +import argparse +import shlex +import subprocess +import sys + + +def staged_modes(filenames: list[str]) -> list[tuple[str, str]]: + if not filenames: + return [] + + result = subprocess.run( + ("git", "ls-files", "-z", "--stage", "--", *filenames), + check=True, + capture_output=True, + text=True, + ) + entries = result.stdout.rstrip("\0").split("\0") if result.stdout else [] + return [(entry.split(" ", 1)[0], entry.split("\t", 1)[1]) for entry in entries] + + +def has_shebang(filename: str) -> bool: + with open(filename, "rb") as f: + return f.read(2) == b"#!" + + +def check_shebang_scripts_are_executable(filenames: list[str]) -> int: + failed = False + for mode, filename in staged_modes(filenames): + if mode != "100755" and has_shebang(filename): + quoted = shlex.quote(filename) + print("\n".join(( + f"{filename}: has a shebang but is not marked executable!", + f" If it is supposed to be executable, try: `chmod +x {quoted}`", + " If it is not supposed to be executable, double-check its shebang is wanted.\n", + )), file=sys.stderr) + failed = True + + return int(failed) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Check that tracked files with shebangs are executable.") + parser.add_argument("filenames", nargs="*") + args = parser.parse_args() + return check_shebang_scripts_are_executable(args.filenames) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/lint/lint.sh b/scripts/lint/lint.sh index e01e7b37c2..c678379b42 100755 --- a/scripts/lint/lint.sh +++ b/scripts/lint/lint.sh @@ -46,8 +46,8 @@ function run_tests() { PYTHON_FILES=$2 run "ruff" ruff check openpilot --quiet - run "check_added_large_files" python3 -m pre_commit_hooks.check_added_large_files --enforce-all $ALL_FILES --maxkb=120 - run "check_shebang_scripts_are_executable" python3 -m pre_commit_hooks.check_shebang_scripts_are_executable $ALL_FILES + run "check_added_large_files" $DIR/check_added_large_files.py --maxkb=120 $ALL_FILES + run "check_shebang_scripts_are_executable" $DIR/check_shebang_scripts_are_executable.py $ALL_FILES run "check_shebang_format" $DIR/check_shebang_format.sh $ALL_FILES run "check_nomerge_comments" $DIR/check_nomerge_comments.sh $ALL_FILES diff --git a/uv.lock b/uv.lock index 9aef9c9a01..d03a44160e 100644 --- a/uv.lock +++ b/uv.lock @@ -748,7 +748,6 @@ testing = [ { name = "codespell" }, { name = "coverage" }, { name = "hypothesis" }, - { name = "pre-commit-hooks" }, { name = "pytest" }, { name = "pytest-cpp" }, { name = "pytest-mock" }, @@ -800,7 +799,6 @@ requires-dist = [ { name = "opendbc", marker = "extra == 'submodules'", editable = "opendbc_repo" }, { name = "pandacan", marker = "extra == 'submodules'", editable = "panda" }, { name = "pillow" }, - { name = "pre-commit-hooks", marker = "extra == 'testing'" }, { name = "pycapnp", specifier = "==2.1.0" }, { name = "pyjwt", extras = ["crypto"] }, { name = "pytest", marker = "extra == 'testing'" }, @@ -897,18 +895,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] -[[package]] -name = "pre-commit-hooks" -version = "6.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "ruamel-yaml" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/36/4d/93e63e48f8fd16d6c1e4cef5dabadcade4d1325c7fd6f29f075a4d2284f3/pre_commit_hooks-6.0.0.tar.gz", hash = "sha256:76d8370c006f5026cdd638a397a678d26dda735a3c88137e05885a020f824034", size = 28293, upload-time = "2025-08-09T19:25:04.6Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/12/46/eba9be9daa403fa94854ce16a458c29df9a01c6c047931c3d8be6016cd9a/pre_commit_hooks-6.0.0-py2.py3-none-any.whl", hash = "sha256:76161b76d321d2f8ee2a8e0b84c30ee8443e01376121fd1c90851e33e3bd7ee2", size = 41338, upload-time = "2025-08-09T19:25:03.513Z" }, -] - [[package]] name = "pycapnp" version = "2.1.0" @@ -1171,15 +1157,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] -[[package]] -name = "ruamel-yaml" -version = "0.19.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/3b/ebda527b56beb90cb7652cb1c7e4f91f48649fbcd8d2eb2fb6e77cd3329b/ruamel_yaml-0.19.1.tar.gz", hash = "sha256:53eb66cd27849eff968ebf8f0bf61f46cdac2da1d1f3576dd4ccee9b25c31993", size = 142709, upload-time = "2026-01-02T16:50:31.84Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b8/0c/51f6841f1d84f404f92463fc2b1ba0da357ca1e3db6b7fbda26956c3b82a/ruamel_yaml-0.19.1-py3-none-any.whl", hash = "sha256:27592957fedf6e0b62f281e96effd28043345e0e66001f97683aa9a40c667c93", size = 118102, upload-time = "2026-01-02T16:50:29.201Z" }, -] - [[package]] name = "ruff" version = "0.15.20" From caa9e770cc359775621a2638c73ee7a7d13393f5 Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Sun, 19 Jul 2026 12:20:25 -0700 Subject: [PATCH 23/35] manager: remove preimport stage (#38381) --- .../test/process_replay/process_replay.py | 1 - openpilot/system/manager/manager.py | 5 ----- openpilot/system/manager/process.py | 15 --------------- 3 files changed, 21 deletions(-) diff --git a/openpilot/selfdrive/test/process_replay/process_replay.py b/openpilot/selfdrive/test/process_replay/process_replay.py index 90eb397996..2a90dfc295 100755 --- a/openpilot/selfdrive/test/process_replay/process_replay.py +++ b/openpilot/selfdrive/test/process_replay/process_replay.py @@ -216,7 +216,6 @@ class ProcessContainer: def _start_process(self): if self.capture is not None: self.process.launcher = LauncherWithCapture(self.capture, self.process.launcher) # ty: ignore[invalid-assignment] # intentional wrapper - self.process.prepare() self.process.start() def start( diff --git a/openpilot/system/manager/manager.py b/openpilot/system/manager/manager.py index 2d090b37f0..86fca2723f 100755 --- a/openpilot/system/manager/manager.py +++ b/openpilot/system/manager/manager.py @@ -87,11 +87,6 @@ def manager_init() -> None: dirty=build_metadata.openpilot.is_dirty, device=HARDWARE.get_device_type()) - # preimport all processes - for p in managed_processes.values(): - p.prepare() - - def manager_cleanup() -> None: # send signals to kill all procs for p in managed_processes.values(): diff --git a/openpilot/system/manager/process.py b/openpilot/system/manager/process.py index a0b878d0f2..7f7eabd7be 100644 --- a/openpilot/system/manager/process.py +++ b/openpilot/system/manager/process.py @@ -70,10 +70,6 @@ class ManagerProcess(ABC): shutting_down = False restart_if_crash = False - @abstractmethod - def prepare(self) -> None: - pass - @abstractmethod def start(self) -> None: pass @@ -150,9 +146,6 @@ class NativeProcess(ManagerProcess): self.sigkill = sigkill self.launcher = nativelauncher - def prepare(self) -> None: - pass - def start(self) -> None: # In case we only tried a non blocking stop we need to stop it before restarting if self.shutting_down: @@ -178,11 +171,6 @@ class PythonProcess(ManagerProcess): self.launcher = launcher self.restart_if_crash = restart_if_crash - def prepare(self) -> None: - if self.enabled: - cloudlog.info(f"preimporting {self.module}") - importlib.import_module(self.module) - def start(self) -> None: # In case we only tried a non blocking stop we need to stop it before restarting if self.shutting_down: @@ -211,9 +199,6 @@ class DaemonProcess(ManagerProcess): def should_run(started, params, CP): return True - def prepare(self) -> None: - pass - def start(self) -> None: if self.params is None: self.params = Params() From 157c7080ce4771407bacea59620e11011e50b75c Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Sun, 19 Jul 2026 12:21:04 -0700 Subject: [PATCH 24/35] bump opendbc (#38370) --- opendbc_repo | 2 +- openpilot/selfdrive/test/process_replay/migration.py | 8 ++++---- openpilot/tools/replay/ui.py | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/opendbc_repo b/opendbc_repo index f95568e03c..78a1c9e73d 160000 --- a/opendbc_repo +++ b/opendbc_repo @@ -1 +1 @@ -Subproject commit f95568e03cc5de650b7935c18ac56ee347caaae2 +Subproject commit 78a1c9e73de2e0a081cb2bb41f7f10281b9eeabf diff --git a/openpilot/selfdrive/test/process_replay/migration.py b/openpilot/selfdrive/test/process_replay/migration.py index a6e466bef5..87064f5017 100644 --- a/openpilot/selfdrive/test/process_replay/migration.py +++ b/openpilot/selfdrive/test/process_replay/migration.py @@ -295,7 +295,7 @@ def migrate_carOutput(msgs): co = messaging.new_message('carOutput') co.valid = msg.valid co.logMonoTime = msg.logMonoTime - co.carOutput.actuatorsOutput = msg.carControl.actuatorsOutputDEPRECATED + co.carOutput.actuatorsOutput = msg.carControl.deprecated.actuatorsOutput add_ops.append(as_reader(co)) return [], add_ops, [] @@ -321,10 +321,10 @@ def migrate_pandaStates(msgs): safety_param = safety_param_migration[fingerprint].value elif len(CP.safetyConfigs): safety_param = CP.safetyConfigs[0].safetyParam - if CP.safetyConfigs[0].safetyParamDEPRECATED != 0: - safety_param = CP.safetyConfigs[0].safetyParamDEPRECATED + if CP.safetyConfigs[0].deprecated.safetyParam != 0: + safety_param = CP.safetyConfigs[0].deprecated.safetyParam else: - safety_param = CP.safetyParamDEPRECATED + safety_param = CP.deprecated.safetyParam ops = [] for index, msg in msgs: diff --git a/openpilot/tools/replay/ui.py b/openpilot/tools/replay/ui.py index f5bd3f1d78..4fbede2be9 100755 --- a/openpilot/tools/replay/ui.py +++ b/openpilot/tools/replay/ui.py @@ -173,7 +173,7 @@ def ui_thread(addr): plot_arr[-1, name_to_arr_idx['angle_steers']] = sm['carState'].steeringAngleDeg plot_arr[-1, name_to_arr_idx['angle_steers_des']] = sm['carControl'].actuators.steeringAngleDeg plot_arr[-1, name_to_arr_idx['angle_steers_k']] = angle_steers_k - plot_arr[-1, name_to_arr_idx['gas']] = sm['carState'].gasDEPRECATED + plot_arr[-1, name_to_arr_idx['gas']] = sm['carState'].deprecated.gas # TODO gas is deprecated plot_arr[-1, name_to_arr_idx['computer_gas']] = np.clip(sm['carControl'].actuators.accel / 4.0, 0.0, 1.0) plot_arr[-1, name_to_arr_idx['user_brake']] = sm['carState'].brakePressed From fef29ad22534aea01851bd3706182f582fbb4e02 Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Sun, 19 Jul 2026 14:33:26 -0700 Subject: [PATCH 25/35] start porting tests to unittest style (#38384) --- .../controls/tests/test_longcontrol.py | 52 ++++---- .../tests/test_torqued_lat_accel_offset.py | 25 ++-- .../selfdrive/locationd/test/test_torqued.py | 35 ++--- .../ui/mici/tests/test_widget_leaks.py | 125 +++++++++--------- .../selfdrive/ui/tests/test_raylib_ui.py | 9 +- .../selfdrive/ui/tests/test_translations.py | 84 ++++++------ .../tools/jotpluggler/test_jotpluggler.py | 9 +- 7 files changed, 171 insertions(+), 168 deletions(-) diff --git a/openpilot/selfdrive/controls/tests/test_longcontrol.py b/openpilot/selfdrive/controls/tests/test_longcontrol.py index da916b156f..f69c54a3cd 100644 --- a/openpilot/selfdrive/controls/tests/test_longcontrol.py +++ b/openpilot/selfdrive/controls/tests/test_longcontrol.py @@ -27,30 +27,30 @@ class TestLongControlStateTransition: should_stop=False, brake_pressed=False, cruise_standstill=False) assert next_state == LongCtrlState.off -def test_engage(): - CP = car.CarParams.new_message() - active = True - current_state = LongCtrlState.off - next_state = long_control_state_trans(CP, active, current_state, v_ego=0.1, - should_stop=True, brake_pressed=False, cruise_standstill=False) - assert next_state == LongCtrlState.stopping - next_state = long_control_state_trans(CP, active, current_state, v_ego=0.1, - should_stop=False, brake_pressed=True, cruise_standstill=False) - assert next_state == LongCtrlState.stopping - next_state = long_control_state_trans(CP, active, current_state, v_ego=0.1, - should_stop=False, brake_pressed=False, cruise_standstill=True) - assert next_state == LongCtrlState.stopping - next_state = long_control_state_trans(CP, active, current_state, v_ego=0.1, - should_stop=False, brake_pressed=False, cruise_standstill=False) - assert next_state == LongCtrlState.pid + def test_engage(self): + CP = car.CarParams.new_message() + active = True + current_state = LongCtrlState.off + next_state = long_control_state_trans(CP, active, current_state, v_ego=0.1, + should_stop=True, brake_pressed=False, cruise_standstill=False) + assert next_state == LongCtrlState.stopping + next_state = long_control_state_trans(CP, active, current_state, v_ego=0.1, + should_stop=False, brake_pressed=True, cruise_standstill=False) + assert next_state == LongCtrlState.stopping + next_state = long_control_state_trans(CP, active, current_state, v_ego=0.1, + should_stop=False, brake_pressed=False, cruise_standstill=True) + assert next_state == LongCtrlState.stopping + next_state = long_control_state_trans(CP, active, current_state, v_ego=0.1, + should_stop=False, brake_pressed=False, cruise_standstill=False) + assert next_state == LongCtrlState.pid -def test_starting(): - CP = car.CarParams.new_message(startingState=True, vEgoStarting=0.5) - active = True - current_state = LongCtrlState.starting - next_state = long_control_state_trans(CP, active, current_state, v_ego=0.1, - should_stop=False, brake_pressed=False, cruise_standstill=False) - assert next_state == LongCtrlState.starting - next_state = long_control_state_trans(CP, active, current_state, v_ego=1.0, - should_stop=False, brake_pressed=False, cruise_standstill=False) - assert next_state == LongCtrlState.pid + def test_starting(self): + CP = car.CarParams.new_message(startingState=True, vEgoStarting=0.5) + active = True + current_state = LongCtrlState.starting + next_state = long_control_state_trans(CP, active, current_state, v_ego=0.1, + should_stop=False, brake_pressed=False, cruise_standstill=False) + assert next_state == LongCtrlState.starting + next_state = long_control_state_trans(CP, active, current_state, v_ego=1.0, + should_stop=False, brake_pressed=False, cruise_standstill=False) + assert next_state == LongCtrlState.pid diff --git a/openpilot/selfdrive/controls/tests/test_torqued_lat_accel_offset.py b/openpilot/selfdrive/controls/tests/test_torqued_lat_accel_offset.py index 7a63da23a5..7599eb1e7b 100644 --- a/openpilot/selfdrive/controls/tests/test_torqued_lat_accel_offset.py +++ b/openpilot/selfdrive/controls/tests/test_torqued_lat_accel_offset.py @@ -56,16 +56,17 @@ def simulate_straight_road_msgs(est): for which, msg in (('carControl', carControl), ('carOutput', carOutput), ('carState', carState), ('livePose', livePose)): est.handle_log(t, which, msg) -def test_estimated_offset(): - steer_torques, lat_accels = generate_inputs(TORQUE_TUNE_BIASED, la_err_std=LA_ERR_STD, input_noise_std=INPUT_NOISE_STD) - est = get_warmed_up_estimator(steer_torques, lat_accels) - msg = est.get_msg() - # TODO add lataccelfactor and friction check when we have more accurate estimates - assert abs(msg.liveTorqueParameters.latAccelOffsetRaw - TORQUE_TUNE_BIASED.latAccelOffset) < 0.1 +class TestTorquedLatAccelOffset: + def test_estimated_offset(self): + steer_torques, lat_accels = generate_inputs(TORQUE_TUNE_BIASED, la_err_std=LA_ERR_STD, input_noise_std=INPUT_NOISE_STD) + est = get_warmed_up_estimator(steer_torques, lat_accels) + msg = est.get_msg() + # TODO add lataccelfactor and friction check when we have more accurate estimates + assert abs(msg.liveTorqueParameters.latAccelOffsetRaw - TORQUE_TUNE_BIASED.latAccelOffset) < 0.1 -def test_straight_road_roll_bias(): - steer_torques, lat_accels = generate_inputs(TORQUE_TUNE, la_err_std=LA_ERR_STD, input_noise_std=INPUT_NOISE_STD) - est = get_warmed_up_estimator(steer_torques, lat_accels) - simulate_straight_road_msgs(est) - msg = est.get_msg() - assert (msg.liveTorqueParameters.latAccelOffsetRaw < -0.05) and np.isfinite(msg.liveTorqueParameters.latAccelOffsetRaw) + def test_straight_road_roll_bias(self): + steer_torques, lat_accels = generate_inputs(TORQUE_TUNE, la_err_std=LA_ERR_STD, input_noise_std=INPUT_NOISE_STD) + est = get_warmed_up_estimator(steer_torques, lat_accels) + simulate_straight_road_msgs(est) + msg = est.get_msg() + assert (msg.liveTorqueParameters.latAccelOffsetRaw < -0.05) and np.isfinite(msg.liveTorqueParameters.latAccelOffsetRaw) diff --git a/openpilot/selfdrive/locationd/test/test_torqued.py b/openpilot/selfdrive/locationd/test/test_torqued.py index ac8e40fc30..4d337178a0 100644 --- a/openpilot/selfdrive/locationd/test/test_torqued.py +++ b/openpilot/selfdrive/locationd/test/test_torqued.py @@ -2,24 +2,25 @@ from opendbc.car.structs import car from openpilot.selfdrive.locationd.torqued import TorqueEstimator -def test_cal_percent(): - est = TorqueEstimator(car.CarParams()) - msg = est.get_msg() - assert msg.liveTorqueParameters.calPerc == 0 +class TestTorqued: + def test_cal_percent(self): + est = TorqueEstimator(car.CarParams()) + msg = est.get_msg() + assert msg.liveTorqueParameters.calPerc == 0 - for (low, high), min_pts in zip(est.filtered_points.buckets.keys(), - est.filtered_points.buckets_min_points.values(), strict=True): - for _ in range(int(min_pts)): - est.filtered_points.add_point((low + high) / 2.0, 0.0) + for (low, high), min_pts in zip(est.filtered_points.buckets.keys(), + est.filtered_points.buckets_min_points.values(), strict=True): + for _ in range(int(min_pts)): + est.filtered_points.add_point((low + high) / 2.0, 0.0) - # enough bucket points, but not enough total points - msg = est.get_msg() - assert msg.liveTorqueParameters.calPerc == (len(est.filtered_points) / est.min_points_total * 100 + 100) / 2 + # enough bucket points, but not enough total points + msg = est.get_msg() + assert msg.liveTorqueParameters.calPerc == (len(est.filtered_points) / est.min_points_total * 100 + 100) / 2 - # add enough points to bucket with most capacity - key = list(est.filtered_points.buckets)[0] - for _ in range(est.min_points_total - len(est.filtered_points)): - est.filtered_points.add_point((key[0] + key[1]) / 2.0, 0.0) + # add enough points to bucket with most capacity + key = list(est.filtered_points.buckets)[0] + for _ in range(est.min_points_total - len(est.filtered_points)): + est.filtered_points.add_point((key[0] + key[1]) / 2.0, 0.0) - msg = est.get_msg() - assert msg.liveTorqueParameters.calPerc == 100 + msg = est.get_msg() + assert msg.liveTorqueParameters.calPerc == 100 diff --git a/openpilot/selfdrive/ui/mici/tests/test_widget_leaks.py b/openpilot/selfdrive/ui/mici/tests/test_widget_leaks.py index 09142a8e11..68c61f4ff3 100755 --- a/openpilot/selfdrive/ui/mici/tests/test_widget_leaks.py +++ b/openpilot/selfdrive/ui/mici/tests/test_widget_leaks.py @@ -41,80 +41,81 @@ def get_child_widgets(widget) -> list: return children -@pytest.mark.skip(reason="segfaults") -def test_dialogs_do_not_leak(): - import pyray as rl - rl.set_config_flags(rl.ConfigFlags.FLAG_WINDOW_HIDDEN) - from openpilot.system.ui.lib.application import gui_app +class TestWidgetLeaks: + @pytest.mark.skip(reason="segfaults") + def test_dialogs_do_not_leak(self): + import pyray as rl + rl.set_config_flags(rl.ConfigFlags.FLAG_WINDOW_HIDDEN) + from openpilot.system.ui.lib.application import gui_app - # mici dialogs - from openpilot.selfdrive.ui.mici.layouts.onboarding import TrainingGuide as MiciTrainingGuide, OnboardingWindow as MiciOnboardingWindow - from openpilot.selfdrive.ui.mici.onroad.driver_camera_dialog import DriverCameraDialog as MiciDriverCameraDialog - from openpilot.selfdrive.ui.mici.widgets.pairing_dialog import PairingDialog as MiciPairingDialog - from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigConfirmationDialog, BigInputDialog - from openpilot.selfdrive.ui.mici.layouts.settings.device import MiciFccModal + # mici dialogs + from openpilot.selfdrive.ui.mici.layouts.onboarding import TrainingGuide as MiciTrainingGuide, OnboardingWindow as MiciOnboardingWindow + from openpilot.selfdrive.ui.mici.onroad.driver_camera_dialog import DriverCameraDialog as MiciDriverCameraDialog + from openpilot.selfdrive.ui.mici.widgets.pairing_dialog import PairingDialog as MiciPairingDialog + from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigConfirmationDialog, BigInputDialog + from openpilot.selfdrive.ui.mici.layouts.settings.device import MiciFccModal - # tici dialogs - from openpilot.selfdrive.ui.onroad.driver_camera_dialog import DriverCameraDialog as TiciDriverCameraDialog - from openpilot.selfdrive.ui.layouts.onboarding import OnboardingWindow as TiciOnboardingWindow - from openpilot.selfdrive.ui.widgets.pairing_dialog import PairingDialog as TiciPairingDialog - from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog - from openpilot.system.ui.widgets.option_dialog import MultiOptionDialog - from openpilot.system.ui.widgets.html_render import HtmlModal - from openpilot.system.ui.widgets.keyboard import Keyboard + # tici dialogs + from openpilot.selfdrive.ui.onroad.driver_camera_dialog import DriverCameraDialog as TiciDriverCameraDialog + from openpilot.selfdrive.ui.layouts.onboarding import OnboardingWindow as TiciOnboardingWindow + from openpilot.selfdrive.ui.widgets.pairing_dialog import PairingDialog as TiciPairingDialog + from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog + from openpilot.system.ui.widgets.option_dialog import MultiOptionDialog + from openpilot.system.ui.widgets.html_render import HtmlModal + from openpilot.system.ui.widgets.keyboard import Keyboard - gui_app.init_window("ref-test") + gui_app.init_window("ref-test") - leaked_widgets = set() + leaked_widgets = set() - for ctor in ( - # mici - MiciDriverCameraDialog, MiciPairingDialog, - lambda: MiciTrainingGuide(lambda: None), - lambda: MiciOnboardingWindow(lambda: None), - lambda: BigDialog("test", "test"), - lambda: BigConfirmationDialog("test", gui_app.texture("icons_mici/settings/network/new/trash.png", 54, 64), lambda: None), - lambda: BigInputDialog("test"), - lambda: MiciFccModal(text="test"), - # tici - TiciDriverCameraDialog, TiciOnboardingWindow, TiciPairingDialog, Keyboard, - lambda: ConfirmDialog("test", "ok"), - lambda: MultiOptionDialog("test", ["a", "b"]), - lambda: HtmlModal(text="test"), - ): - widget = ctor() - all_refs = [weakref.ref(w) for w in get_child_widgets(widget) + [widget]] + for ctor in ( + # mici + MiciDriverCameraDialog, MiciPairingDialog, + lambda: MiciTrainingGuide(lambda: None), + lambda: MiciOnboardingWindow(lambda: None), + lambda: BigDialog("test", "test"), + lambda: BigConfirmationDialog("test", gui_app.texture("icons_mici/settings/network/new/trash.png", 54, 64), lambda: None), + lambda: BigInputDialog("test"), + lambda: MiciFccModal(text="test"), + # tici + TiciDriverCameraDialog, TiciOnboardingWindow, TiciPairingDialog, Keyboard, + lambda: ConfirmDialog("test", "ok"), + lambda: MultiOptionDialog("test", ["a", "b"]), + lambda: HtmlModal(text="test"), + ): + widget = ctor() + all_refs = [weakref.ref(w) for w in get_child_widgets(widget) + [widget]] - del widget + del widget - for ref in all_refs: - if ref() is not None: - obj = ref() - name = f"{type(obj).__module__}.{type(obj).__qualname__}" - leaked_widgets.add(name) + for ref in all_refs: + if ref() is not None: + obj = ref() + name = f"{type(obj).__module__}.{type(obj).__qualname__}" + leaked_widgets.add(name) - print(f"\n=== Widget {name} alive after del") - print(" Referrers:") - for r in gc.get_referrers(obj): - if r is obj: - continue + print(f"\n=== Widget {name} alive after del") + print(" Referrers:") + for r in gc.get_referrers(obj): + if r is obj: + continue - if hasattr(r, '__self__') and r.__self__ is not obj: - print(f" bound method: {type(r.__self__).__qualname__}.{r.__name__}") - elif hasattr(r, '__func__'): - print(f" method: {r.__name__}") - else: - print(f" {type(r).__module__}.{type(r).__qualname__}") - del obj + if hasattr(r, '__self__') and r.__self__ is not obj: + print(f" bound method: {type(r.__self__).__qualname__}.{r.__name__}") + elif hasattr(r, '__func__'): + print(f" method: {r.__name__}") + else: + print(f" {type(r).__module__}.{type(r).__qualname__}") + del obj - gui_app.close() + gui_app.close() - unexpected = leaked_widgets - KNOWN_LEAKS - assert not unexpected, f"New leaked widgets: {unexpected}" + unexpected = leaked_widgets - KNOWN_LEAKS + assert not unexpected, f"New leaked widgets: {unexpected}" - fixed = KNOWN_LEAKS - leaked_widgets - assert not fixed, f"These leaks are fixed, remove from KNOWN_LEAKS: {fixed}" + fixed = KNOWN_LEAKS - leaked_widgets + assert not fixed, f"These leaks are fixed, remove from KNOWN_LEAKS: {fixed}" if __name__ == "__main__": - test_dialogs_do_not_leak() + TestWidgetLeaks().test_dialogs_do_not_leak() diff --git a/openpilot/selfdrive/ui/tests/test_raylib_ui.py b/openpilot/selfdrive/ui/tests/test_raylib_ui.py index 69ba946dcd..d04c940cde 100644 --- a/openpilot/selfdrive/ui/tests/test_raylib_ui.py +++ b/openpilot/selfdrive/ui/tests/test_raylib_ui.py @@ -2,7 +2,8 @@ import time from openpilot.selfdrive.test.helpers import with_processes -@with_processes(["ui"]) -def test_raylib_ui(): - """Test initialization of the UI widgets is successful.""" - time.sleep(1) +class TestRaylibUi: + @with_processes(["ui"]) + def test_raylib_ui(self): + """Test initialization of the UI widgets is successful.""" + time.sleep(1) diff --git a/openpilot/selfdrive/ui/tests/test_translations.py b/openpilot/selfdrive/ui/tests/test_translations.py index fba595acad..9eae072f21 100644 --- a/openpilot/selfdrive/ui/tests/test_translations.py +++ b/openpilot/selfdrive/ui/tests/test_translations.py @@ -46,61 +46,59 @@ def load_po_text(po_path: Path) -> str: return po_path.read_text(encoding='utf-8') -@pytest.mark.parametrize("language_code", sorted(TRANSLATION_LANGUAGES.values())) -def test_translation_file_exists(language_code: str): - po_path = PO_DIR / f"app_{language_code}.po" - assert po_path.exists(), f"missing translation file: {po_path}" +class TestTranslations: + @pytest.mark.parametrize("language_code", sorted(TRANSLATION_LANGUAGES.values())) + def test_translation_file_exists(self, language_code: str): + po_path = PO_DIR / f"app_{language_code}.po" + assert po_path.exists(), f"missing translation file: {po_path}" + @pytest.mark.parametrize("po_path", sorted(PO_DIR.glob("app_*.po")), ids=lambda p: p.name) + def test_translation_placeholders_are_preserved(self, po_path: Path): + _, entries = parse_po(po_path) + language = po_path.stem.removeprefix("app_") -@pytest.mark.parametrize("po_path", sorted(PO_DIR.glob("app_*.po")), ids=lambda p: p.name) -def test_translation_placeholders_are_preserved(po_path: Path): - _, entries = parse_po(po_path) - language = po_path.stem.removeprefix("app_") + for entry in entries: + source_placeholders = extract_placeholders(entry.msgid) - for entry in entries: - source_placeholders = extract_placeholders(entry.msgid) + if entry.is_plural: + plural_placeholders = extract_placeholders(entry.msgid_plural) + message = ( + f"{language}: source plural placeholders do not match singular for " + + f"{entry.msgid!r}: {source_placeholders} vs {plural_placeholders}" + ) + assert plural_placeholders == source_placeholders, message - if entry.is_plural: - plural_placeholders = extract_placeholders(entry.msgid_plural) - message = ( - f"{language}: source plural placeholders do not match singular for " - + f"{entry.msgid!r}: {source_placeholders} vs {plural_placeholders}" - ) - assert plural_placeholders == source_placeholders, message + for idx, msgstr in sorted(entry.msgstr_plural.items()): + if not msgstr: + continue - for idx, msgstr in sorted(entry.msgstr_plural.items()): - if not msgstr: + translated_placeholders = extract_placeholders(msgstr) + message = ( + f"{language}: plural form {idx} changes placeholders for {entry.msgid!r}: " + + f"expected {source_placeholders}, got {translated_placeholders}" + ) + assert translated_placeholders == source_placeholders, message + else: + if not entry.msgstr: continue - translated_placeholders = extract_placeholders(msgstr) + translated_placeholders = extract_placeholders(entry.msgstr) message = ( - f"{language}: plural form {idx} changes placeholders for {entry.msgid!r}: " + f"{language}: translation changes placeholders for {entry.msgid!r}: " + f"expected {source_placeholders}, got {translated_placeholders}" ) assert translated_placeholders == source_placeholders, message - else: - if not entry.msgstr: - continue - translated_placeholders = extract_placeholders(entry.msgstr) - message = ( - f"{language}: translation changes placeholders for {entry.msgid!r}: " - + f"expected {source_placeholders}, got {translated_placeholders}" + @pytest.mark.parametrize("po_path", sorted(PO_DIR.glob("app_*.po")), ids=lambda p: p.name) + def test_translation_refs_do_not_include_line_numbers(self, po_path: Path): + for line in load_po_text(po_path).splitlines(): + assert not LINE_NUMBER_REF_RE.match(line), ( + f"{po_path.name}: line-number source reference found: {line}" ) - assert translated_placeholders == source_placeholders, message - -@pytest.mark.parametrize("po_path", sorted(PO_DIR.glob("app_*.po")), ids=lambda p: p.name) -def test_translation_refs_do_not_include_line_numbers(po_path: Path): - for line in load_po_text(po_path).splitlines(): - assert not LINE_NUMBER_REF_RE.match(line), ( - f"{po_path.name}: line-number source reference found: {line}" + @pytest.mark.parametrize("po_path", sorted(PO_DIR.glob("app_*.po")), ids=lambda p: p.name) + def test_translation_entities_are_valid(self, po_path: Path): + matches = BAD_ENTITY_RE.findall(load_po_text(po_path)) + assert not matches, ( + f"{po_path.name}: found '@...;' entity typo(s): {', '.join(sorted(set(matches)))}" ) - - -@pytest.mark.parametrize("po_path", sorted(PO_DIR.glob("app_*.po")), ids=lambda p: p.name) -def test_translation_entities_are_valid(po_path: Path): - matches = BAD_ENTITY_RE.findall(load_po_text(po_path)) - assert not matches, ( - f"{po_path.name}: found '@...;' entity typo(s): {', '.join(sorted(set(matches)))}" - ) diff --git a/openpilot/tools/jotpluggler/test_jotpluggler.py b/openpilot/tools/jotpluggler/test_jotpluggler.py index b9b751c4a5..0729e3b2e3 100644 --- a/openpilot/tools/jotpluggler/test_jotpluggler.py +++ b/openpilot/tools/jotpluggler/test_jotpluggler.py @@ -5,7 +5,8 @@ from pathlib import Path JOTPLUGGLER_DIR = Path(__file__).parent -def test_help(): - result = subprocess.run(["./jotpluggler", "-h"], cwd=JOTPLUGGLER_DIR, capture_output=True, text=True) - assert result.returncode == 0, result.stderr - assert "Usage:" in result.stderr +class TestJotpluggler: + def test_help(self): + result = subprocess.run(["./jotpluggler", "-h"], cwd=JOTPLUGGLER_DIR, capture_output=True, text=True) + assert result.returncode == 0, result.stderr + assert "Usage:" in result.stderr From 3dbf02f803235312639be15c078ea6c795af5100 Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Sun, 19 Jul 2026 14:45:26 -0700 Subject: [PATCH 26/35] cereal: gc dead ZMQ branches in tests --- .../cereal/messaging/tests/test_messaging.py | 36 ++++--------------- .../messaging/tests/test_pub_sub_master.py | 16 +-------- 2 files changed, 7 insertions(+), 45 deletions(-) diff --git a/openpilot/cereal/messaging/tests/test_messaging.py b/openpilot/cereal/messaging/tests/test_messaging.py index c2ac1578d4..92d77f1b35 100644 --- a/openpilot/cereal/messaging/tests/test_messaging.py +++ b/openpilot/cereal/messaging/tests/test_messaging.py @@ -1,4 +1,3 @@ -import os import capnp import multiprocessing import numbers @@ -6,7 +5,6 @@ import random import threading import time from openpilot.common.parameterized import parameterized -import pytest from openpilot.cereal import log from opendbc.car.structs import car @@ -24,10 +22,6 @@ def random_socks(num_socks=10): def random_bytes(length=1000): return bytes([random.randrange(0xFF) for _ in range(length)]) -def zmq_sleep(t=1): - if "ZMQ" in os.environ: - time.sleep(t) - # TODO: this should take any capnp struct and returrn a msg with random populated data def random_carstate(): @@ -53,16 +47,6 @@ def delayed_send(delay, sock, dat): class TestMessaging: - def setUp(self): - # TODO: ZMQ tests are too slow; all sleeps will need to be - # replaced with logic to block on the necessary condition - if "ZMQ" in os.environ: - pytest.skip() - - # ZMQ pub socket takes too long to die - # sleep to prevent multiple publishers error between tests - zmq_sleep() - @parameterized.expand(events) def test_new_message(self, evt): try: @@ -89,7 +73,6 @@ class TestMessaging: sock = "carState" pub_sock = messaging.pub_sock(sock) sub_sock = messaging.sub_sock(sock, timeout=1000) - zmq_sleep() # no wait and no msgs in queue msgs = func(sub_sock) @@ -110,7 +93,6 @@ class TestMessaging: sock = "carState" pub_sock = messaging.pub_sock(sock) sub_sock = messaging.sub_sock(sock, timeout=100) - zmq_sleep() # no wait and no msg in queue, socket should timeout recvd = messaging.recv_sock(sub_sock) @@ -129,7 +111,6 @@ class TestMessaging: sock = "carState" pub_sock = messaging.pub_sock(sock) sub_sock = messaging.sub_sock(sock, timeout=1000) - zmq_sleep() # no msg in queue, socket should timeout recvd = messaging.recv_one(sub_sock) @@ -142,12 +123,10 @@ class TestMessaging: assert isinstance(recvd, capnp._DynamicStructReader) assert_carstate(msg.carState, recvd.carState) - @pytest.mark.xfail(condition="ZMQ" in os.environ, reason='ZMQ detected') def test_recv_one_or_none(self): sock = "carState" pub_sock = messaging.pub_sock(sock) sub_sock = messaging.sub_sock(sock) - zmq_sleep() # no msg in queue, socket shouldn't block recvd = messaging.recv_one_or_none(sub_sock) @@ -165,16 +144,13 @@ class TestMessaging: sock_timeout = 0.1 pub_sock = messaging.pub_sock(sock) sub_sock = messaging.sub_sock(sock, timeout=round(sock_timeout*1000)) - zmq_sleep() - # this test doesn't work with ZMQ since multiprocessing interrupts it - if "ZMQ" not in os.environ: - # wait 5 socket timeouts and make sure it's still retrying - p = multiprocessing.Process(target=messaging.recv_one_retry, args=(sub_sock,)) - p.start() - time.sleep(sock_timeout*5) - assert p.is_alive() - p.terminate() + # wait 5 socket timeouts and make sure it's still retrying + p = multiprocessing.Process(target=messaging.recv_one_retry, args=(sub_sock,)) + p.start() + time.sleep(sock_timeout*5) + assert p.is_alive() + p.terminate() # wait 5 socket timeouts before sending msg = random_carstate() diff --git a/openpilot/cereal/messaging/tests/test_pub_sub_master.py b/openpilot/cereal/messaging/tests/test_pub_sub_master.py index 20ff855fda..90e78ef649 100644 --- a/openpilot/cereal/messaging/tests/test_pub_sub_master.py +++ b/openpilot/cereal/messaging/tests/test_pub_sub_master.py @@ -5,18 +5,12 @@ from collections.abc import Sized import openpilot.cereal.messaging as messaging from openpilot.cereal.messaging.tests.test_messaging import events, random_sock, random_socks, \ - random_bytes, random_carstate, assert_carstate, \ - zmq_sleep + random_bytes, random_carstate, assert_carstate from openpilot.cereal.services import SERVICE_LIST class TestSubMaster: - def setup_method(self): - # ZMQ pub socket takes too long to die - # sleep to prevent multiple publishers error between tests - zmq_sleep(3) - def test_init(self): sm = messaging.SubMaster(events) for p in [sm.updated, sm.recv_time, sm.recv_frame, sm.alive, @@ -43,7 +37,6 @@ class TestSubMaster: sock = "carState" pub_sock = messaging.pub_sock(sock) sm = messaging.SubMaster([sock,]) - zmq_sleep() msg = random_carstate() pub_sock.send(msg.to_bytes()) @@ -55,7 +48,6 @@ class TestSubMaster: sock = "carState" pub_sock = messaging.pub_sock(sock) sm = messaging.SubMaster([sock,]) - zmq_sleep() for i in range(10): msg = messaging.new_message(sock) @@ -126,11 +118,6 @@ class TestSubMaster: class TestPubMaster: - def setup_method(self): - # ZMQ pub socket takes too long to die - # sleep to prevent multiple publishers error between tests - zmq_sleep(3) - def test_init(self): messaging.PubMaster(events) @@ -138,7 +125,6 @@ class TestPubMaster: socks = random_socks() pm = messaging.PubMaster(socks) sub_socks = {s: messaging.sub_sock(s, conflate=True, timeout=1000) for s in socks} - zmq_sleep() # PubMaster accepts either a capnp msg builder or bytes for capnp in [True, False]: From e124d6df9bae1fff3da702ecda564afa72239216 Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Sun, 19 Jul 2026 14:52:04 -0700 Subject: [PATCH 27/35] more dead code gc --- .../messaging/tests/test_pub_sub_master.py | 9 ------ openpilot/common/params_keys.h | 1 - openpilot/common/tests/test_params.py | 6 ++-- openpilot/selfdrive/locationd/paramsd.py | 21 ------------- .../selfdrive/locationd/test/test_paramsd.py | 31 +------------------ .../selfdrive/ui/layouts/settings/device.py | 1 - .../ui/mici/layouts/settings/device.py | 1 - .../tools/cabana/streams/replaystream.cc | 3 -- 8 files changed, 4 insertions(+), 69 deletions(-) diff --git a/openpilot/cereal/messaging/tests/test_pub_sub_master.py b/openpilot/cereal/messaging/tests/test_pub_sub_master.py index 90e78ef649..7353764aea 100644 --- a/openpilot/cereal/messaging/tests/test_pub_sub_master.py +++ b/openpilot/cereal/messaging/tests/test_pub_sub_master.py @@ -91,15 +91,6 @@ class TestSubMaster: else: assert not sm._check_avg_freq(service) - def test_alive(self): - pass - - def test_ignore_alive(self): - pass - - def test_valid(self): - pass - # SubMaster should always conflate def test_conflate(self): sock = "carState" diff --git a/openpilot/common/params_keys.h b/openpilot/common/params_keys.h index 0615c02700..6441fc91df 100644 --- a/openpilot/common/params_keys.h +++ b/openpilot/common/params_keys.h @@ -78,7 +78,6 @@ inline static std::unordered_map keys = { {"LastUpdateTime", {PERSISTENT, TIME}}, {"LastUpdateUptimeOnroad", {PERSISTENT, FLOAT, "0.0"}}, {"LiveDelay", {PERSISTENT, BYTES}}, - {"LiveParameters", {PERSISTENT, JSON}}, {"LiveParametersV2", {PERSISTENT, BYTES}}, {"LivestreamEncoderBitrate", {CLEAR_ON_MANAGER_START | DONT_LOG, INT}}, {"LivestreamRequestKeyframe", {CLEAR_ON_MANAGER_START | DONT_LOG, BOOL}}, diff --git a/openpilot/common/tests/test_params.py b/openpilot/common/tests/test_params.py index fcb8e5a185..bbd411bc37 100644 --- a/openpilot/common/tests/test_params.py +++ b/openpilot/common/tests/test_params.py @@ -112,14 +112,14 @@ class TestParams: def test_params_default_value(self): self.params.remove("LanguageSetting") self.params.remove("LongitudinalPersonality") - self.params.remove("LiveParameters") + self.params.remove("LiveParametersV2") assert self.params.get("LanguageSetting") is None assert self.params.get("LanguageSetting", return_default=False) is None assert isinstance(self.params.get("LanguageSetting", return_default=True), str) assert isinstance(self.params.get("LongitudinalPersonality", return_default=True), int) - assert self.params.get("LiveParameters") is None - assert self.params.get("LiveParameters", return_default=True) is None + assert self.params.get("LiveParametersV2") is None + assert self.params.get("LiveParametersV2", return_default=True) is None def test_params_get_type(self): # json diff --git a/openpilot/selfdrive/locationd/paramsd.py b/openpilot/selfdrive/locationd/paramsd.py index a32773bf0c..c1088a8180 100755 --- a/openpilot/selfdrive/locationd/paramsd.py +++ b/openpilot/selfdrive/locationd/paramsd.py @@ -200,25 +200,6 @@ def check_valid_with_hysteresis(current_valid: bool, val: float, threshold: floa return current_valid -# TODO: Remove this function after few releases (added in 0.9.9) -def migrate_cached_vehicle_params_if_needed(params: Params): - last_parameters_data_old = params.get("LiveParameters") - last_parameters_data = params.get("LiveParametersV2") - if last_parameters_data_old is None or last_parameters_data is not None: - return - - try: - last_parameters_msg = messaging.new_message('liveParameters') - last_parameters_msg.liveParameters.valid = True - last_parameters_msg.liveParameters.steerRatio = last_parameters_data_old['steerRatio'] - last_parameters_msg.liveParameters.stiffnessFactor = last_parameters_data_old['stiffnessFactor'] - last_parameters_msg.liveParameters.angleOffsetAverageDeg = last_parameters_data_old['angleOffsetAverageDeg'] - params.put("LiveParametersV2", last_parameters_msg.to_bytes(), block=True) - except Exception as e: - cloudlog.error(f"Failed to perform parameter migration: {e}") - params.remove("LiveParameters") - - def retrieve_initial_vehicle_params(params: Params, CP: car.CarParams, replay: bool, debug: bool): last_parameters_data = params.get("LiveParametersV2") last_carparams_data = params.get("CarParamsPrevRoute") @@ -273,8 +254,6 @@ def main(): params = Params() CP = messaging.log_from_bytes(params.get("CarParams", block=True), car.CarParams) - migrate_cached_vehicle_params_if_needed(params) - steer_ratio, stiffness_factor, angle_offset_deg, pInitial = retrieve_initial_vehicle_params(params, CP, REPLAY, DEBUG) learner = VehicleParamsLearner(CP, steer_ratio, stiffness_factor, np.radians(angle_offset_deg), pInitial) diff --git a/openpilot/selfdrive/locationd/test/test_paramsd.py b/openpilot/selfdrive/locationd/test/test_paramsd.py index 28c3f4acf7..74135cb875 100644 --- a/openpilot/selfdrive/locationd/test/test_paramsd.py +++ b/openpilot/selfdrive/locationd/test/test_paramsd.py @@ -2,7 +2,7 @@ import random import numpy as np from openpilot.cereal import messaging -from openpilot.selfdrive.locationd.paramsd import retrieve_initial_vehicle_params, migrate_cached_vehicle_params_if_needed +from openpilot.selfdrive.locationd.paramsd import retrieve_initial_vehicle_params from openpilot.selfdrive.locationd.models.car_kf import CarKalman from openpilot.selfdrive.locationd.test.test_locationd_scenarios import TEST_ROUTE from openpilot.selfdrive.test.process_replay.migration import migrate, migrate_carParams @@ -30,38 +30,9 @@ class TestParamsd: params.put("LiveParametersV2", msg.to_bytes(), block=True) params.put("CarParamsPrevRoute", CP.as_builder().to_bytes(), block=True) - migrate_cached_vehicle_params_if_needed(params) # this is not tested here but should not mess anything up or throw an error sr, sf, offset, p_init = retrieve_initial_vehicle_params(params, CP, replay=True, debug=True) np.testing.assert_allclose(sr, msg.liveParameters.steerRatio) np.testing.assert_allclose(sf, msg.liveParameters.stiffnessFactor) np.testing.assert_allclose(offset, msg.liveParameters.angleOffsetAverageDeg) np.testing.assert_equal(p_init.shape, CarKalman.P_initial.shape) np.testing.assert_allclose(np.diagonal(p_init), msg.liveParameters.debugFilterState.std) - - # TODO Remove this test after the support for old format is removed - def test_read_saved_params_old_format(self): - params = Params() - - lr = migrate(LogReader(TEST_ROUTE), [migrate_carParams]) - CP = next(m for m in lr if m.which() == "carParams").carParams - - msg = get_random_live_parameters(CP) - params.put("LiveParameters", msg.liveParameters.to_dict(), block=True) - params.put("CarParamsPrevRoute", CP.as_builder().to_bytes(), block=True) - params.remove("LiveParametersV2") - - migrate_cached_vehicle_params_if_needed(params) - sr, sf, offset, _ = retrieve_initial_vehicle_params(params, CP, replay=True, debug=True) - np.testing.assert_allclose(sr, msg.liveParameters.steerRatio) - np.testing.assert_allclose(sf, msg.liveParameters.stiffnessFactor) - np.testing.assert_allclose(offset, msg.liveParameters.angleOffsetAverageDeg) - assert params.get("LiveParametersV2") is not None - - def test_read_saved_params_corrupted_old_format(self): - params = Params() - params.put("LiveParameters", {}, block=True) - params.remove("LiveParametersV2") - - migrate_cached_vehicle_params_if_needed(params) - assert params.get("LiveParameters") is None - assert params.get("LiveParametersV2") is None diff --git a/openpilot/selfdrive/ui/layouts/settings/device.py b/openpilot/selfdrive/ui/layouts/settings/device.py index aa15899ac2..22853b0bd9 100644 --- a/openpilot/selfdrive/ui/layouts/settings/device.py +++ b/openpilot/selfdrive/ui/layouts/settings/device.py @@ -102,7 +102,6 @@ class DeviceLayout(Widget): self._params.remove("CalibrationParams") self._params.remove("LiveTorqueParameters") - self._params.remove("LiveParameters") self._params.remove("LiveParametersV2") self._params.remove("LiveDelay") self._params.put_bool("OnroadCycleRequested", True, block=True) diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/device.py b/openpilot/selfdrive/ui/mici/layouts/settings/device.py index cd85ef3add..7f4a9ab0b8 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/device.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/device.py @@ -172,7 +172,6 @@ class DeviceLayoutMici(NavScroller): params = ui_state.params params.remove("CalibrationParams") params.remove("LiveTorqueParameters") - params.remove("LiveParameters") params.remove("LiveParametersV2") params.remove("LiveDelay") params.put_bool("OnroadCycleRequested", True, block=True) diff --git a/openpilot/tools/cabana/streams/replaystream.cc b/openpilot/tools/cabana/streams/replaystream.cc index c502ea061a..dbd44b54e3 100644 --- a/openpilot/tools/cabana/streams/replaystream.cc +++ b/openpilot/tools/cabana/streams/replaystream.cc @@ -16,10 +16,7 @@ ReplayStream::ReplayStream(QObject *parent) : AbstractStream(parent) { unsetenv("ZMQ"); setenv("COMMA_CACHE", "/tmp/comma_download_cache", 1); - // TODO: Remove when OpenpilotPrefix supports ZMQ -#ifndef __APPLE__ op_prefix = std::make_unique(); -#endif QObject::connect(&settings, &Settings::changed, this, [this]() { if (replay) replay->setSegmentCacheLimit(settings.max_cached_minutes); From d9596fa9985edc3e516174fc0b2de1f35021734e Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Sun, 19 Jul 2026 14:55:42 -0700 Subject: [PATCH 28/35] gc old athena and uploader migration code --- openpilot/system/athena/athenad.py | 4 ---- openpilot/system/loggerd/uploader.py | 4 +--- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/openpilot/system/athena/athenad.py b/openpilot/system/athena/athenad.py index 91b12fc0a7..ef0561b122 100755 --- a/openpilot/system/athena/athenad.py +++ b/openpilot/system/athena/athenad.py @@ -488,10 +488,6 @@ def setRouteViewed(route: str) -> dict[str, int | str]: def startLocalProxy(global_end_event: threading.Event, remote_ws_uri: str, local_port: int) -> dict[str, int]: try: - # migration, can be removed once 0.9.8 is out for a while - if local_port == 8022: - local_port = 22 - if local_port not in LOCAL_PORT_WHITELIST: raise Exception("Requested local port not whitelisted") diff --git a/openpilot/system/loggerd/uploader.py b/openpilot/system/loggerd/uploader.py index e36b12ed6a..81f8ed1ba3 100755 --- a/openpilot/system/loggerd/uploader.py +++ b/openpilot/system/loggerd/uploader.py @@ -46,9 +46,7 @@ class FakeResponse: def get_directory_sort(d: str) -> list[str]: - # ensure old format is sorted sooner - o = ["0", ] if d.startswith("2024-") else ["1", ] - return o + [s.rjust(10, '0') for s in d.rsplit('--', 1)] + return [s.rjust(10, '0') for s in d.rsplit('--', 1)] def listdir_by_creation(d: str) -> list[str]: if not os.path.isdir(d): From 24a9b6dae54ea2b63d326a0972f70312e6f2e971 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Mon, 20 Jul 2026 10:36:09 -0700 Subject: [PATCH 29/35] profile usb gpu link stability (#38368) * profile usb gpu link before using * ci --- openpilot/selfdrive/modeld/compile_modeld.py | 4 +++ openpilot/selfdrive/modeld/modeld.py | 3 ++ openpilot/selfdrive/modeld/usbgpu_link.py | 34 ++++++++++++++++++++ 3 files changed, 41 insertions(+) create mode 100644 openpilot/selfdrive/modeld/usbgpu_link.py diff --git a/openpilot/selfdrive/modeld/compile_modeld.py b/openpilot/selfdrive/modeld/compile_modeld.py index f74ab27b8c..ebc3f21a13 100755 --- a/openpilot/selfdrive/modeld/compile_modeld.py +++ b/openpilot/selfdrive/modeld/compile_modeld.py @@ -13,6 +13,7 @@ from collections import namedtuple import numpy as np from openpilot.selfdrive.modeld.helpers import dump_oob, load_oob +from openpilot.selfdrive.modeld.usbgpu_link import wait_usbgpu_link def _patch_tinygrad_fetch_fw(): import hashlib @@ -311,6 +312,9 @@ if __name__ == "__main__": p.add_argument('--frame-skip', type=int, required=True) args = p.parse_args() + if 'USB+AMD' in os.environ.get('DEV', ''): + wait_usbgpu_link() + model_path = read_file_chunked_to_disk(args.onnx) model_w, model_h = args.model_size diff --git a/openpilot/selfdrive/modeld/modeld.py b/openpilot/selfdrive/modeld/modeld.py index 39fcc0725c..41a1f44e5f 100755 --- a/openpilot/selfdrive/modeld/modeld.py +++ b/openpilot/selfdrive/modeld/modeld.py @@ -25,6 +25,7 @@ from openpilot.selfdrive.modeld.fill_model_msg import fill_model_msg, fill_drivi from openpilot.common.file_chunker import open_file_chunked, get_manifest_path from openpilot.selfdrive.modeld.constants import ModelConstants, Plan from openpilot.selfdrive.modeld.helpers import usbgpu_present, modeld_pkl_path, get_tg_input_devices, load_oob +from openpilot.selfdrive.modeld.usbgpu_link import wait_usbgpu_link PROCESS_NAME = "openpilot.selfdrive.modeld.modeld" SEND_RAW_PRED = os.getenv('SEND_RAW_PRED') @@ -168,6 +169,8 @@ def main(demo=False): if use_extra_client: cloudlog.warning(f"connected extra cam with buffer size: {vipc_client_extra.buffer_len} ({vipc_client_extra.width} x {vipc_client_extra.height})") + if USBGPU: + wait_usbgpu_link() st = time.monotonic() cloudlog.warning("loading model") model = ModelState(vipc_client_main.width, vipc_client_main.height, USBGPU) diff --git a/openpilot/selfdrive/modeld/usbgpu_link.py b/openpilot/selfdrive/modeld/usbgpu_link.py new file mode 100644 index 0000000000..ef12cba939 --- /dev/null +++ b/openpilot/selfdrive/modeld/usbgpu_link.py @@ -0,0 +1,34 @@ +import time +from pathlib import Path + +from openpilot.common.swaglog import cloudlog +from openpilot.common.hardware.usb import CHESTNUT_VENDOR_ID, CHESTNUT_PRODUCT_ID, usb_devices, controller, read_int + +STABLE_SECONDS = 2.0 +STABLE_THRESHOLD = 5.0 # link errors per second + + +def _chestnut_portli() -> Path | None: + for device in usb_devices(): + if read_int(device / "idVendor", 16) == CHESTNUT_VENDOR_ID and \ + read_int(device / "idProduct", 16) == CHESTNUT_PRODUCT_ID: + ctrl = controller(device) + if ctrl is not None and (ctrl / "portli").exists(): + return ctrl / "portli" + return None + + +def wait_usbgpu_link(timeout: float = 30.0) -> None: + portli = _chestnut_portli() + if portli is None: + return + + t0 = time.monotonic() + while time.monotonic() - t0 < timeout: + start = read_int(portli, 0) + time.sleep(STABLE_SECONDS) + rate = (read_int(portli, 0) - start) / STABLE_SECONDS + if rate <= STABLE_THRESHOLD: + return + cloudlog.warning(f"usbgpu link not stable: {rate:.0f} errors/s") + cloudlog.error("usbgpu link never stabilized") From 78909dac7309efd52132def3a7038891667194d2 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Mon, 20 Jul 2026 10:50:18 -0700 Subject: [PATCH 30/35] soundd: add complete sound (#38390) --- openpilot/cereal/log.capnp | 1 + openpilot/selfdrive/assets/sounds/complete.wav | 3 +++ openpilot/selfdrive/ui/soundd.py | 2 ++ 3 files changed, 6 insertions(+) create mode 100644 openpilot/selfdrive/assets/sounds/complete.wav diff --git a/openpilot/cereal/log.capnp b/openpilot/cereal/log.capnp index e1725d1e44..2148e81d3b 100644 --- a/openpilot/cereal/log.capnp +++ b/openpilot/cereal/log.capnp @@ -812,6 +812,7 @@ struct SelfdriveState { promptDistracted @8; preAlert @9; + complete @10; } enum OpenpilotState @0xdbe58b96d2d1ac61 { diff --git a/openpilot/selfdrive/assets/sounds/complete.wav b/openpilot/selfdrive/assets/sounds/complete.wav new file mode 100644 index 0000000000..f85d48f315 --- /dev/null +++ b/openpilot/selfdrive/assets/sounds/complete.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3027da9834adf9c71177bc3086f8de3018ce22e4725b694d635723c5b3c860cd +size 97120 diff --git a/openpilot/selfdrive/ui/soundd.py b/openpilot/selfdrive/ui/soundd.py index d84c98710b..151c49b0ec 100644 --- a/openpilot/selfdrive/ui/soundd.py +++ b/openpilot/selfdrive/ui/soundd.py @@ -45,6 +45,8 @@ sound_list: dict[int, tuple[str, int | None, float]] = { AudibleAlert.preAlert: ("pre_alert.wav", 1, MAX_VOLUME), + AudibleAlert.complete: ("complete.wav", 1, MAX_VOLUME), + AudibleAlert.warningSoft: ("warning_soft.wav", None, MAX_VOLUME), AudibleAlert.warningImmediate: ("warning_immediate.wav", None, MAX_VOLUME), } From 5472e69e351c4c4968ad1c80db88f842ef270f48 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Mon, 20 Jul 2026 14:31:55 -0700 Subject: [PATCH 31/35] New sounds (#38154) * new sounds * soundd: fix sound cutoff * update sounds * add max warning_immediate variant * mici: play sounds button in developer settings * rename sounds * add complete sound * update sounds * raise soundfloor by 5% * update sounds * play unused sounds * update sounds * bump opendbc * Revert "play unused sounds" This reverts commit 47e538da2200e700804714576baa5f8c0f28cdb3. * Revert "mici: play sounds button in developer settings" This reverts commit 149141bb8a21bec7a04dc8f231d0b6b9120cc7c2. * remove unused sounds * raise soundfloor by 5% * opendbc * add space --- .../selfdrive/assets/sounds/critical.wav | 3 +++ .../selfdrive/assets/sounds/disengage.wav | 4 +-- .../selfdrive/assets/sounds/dm_critical.wav | 3 +++ .../selfdrive/assets/sounds/dm_warning.wav | 3 +++ openpilot/selfdrive/assets/sounds/engage.wav | 4 +-- .../selfdrive/assets/sounds/pre_alert.wav | 4 +-- openpilot/selfdrive/assets/sounds/prompt.wav | 3 --- .../assets/sounds/prompt_distracted.wav | 3 --- openpilot/selfdrive/assets/sounds/refuse.wav | 4 +-- openpilot/selfdrive/assets/sounds/warning.wav | 3 +++ .../assets/sounds/warning_immediate.wav | 3 --- .../selfdrive/assets/sounds/warning_soft.wav | 3 --- openpilot/selfdrive/ui/soundd.py | 27 ++++++++++++++----- 13 files changed, 40 insertions(+), 27 deletions(-) create mode 100644 openpilot/selfdrive/assets/sounds/critical.wav create mode 100644 openpilot/selfdrive/assets/sounds/dm_critical.wav create mode 100644 openpilot/selfdrive/assets/sounds/dm_warning.wav delete mode 100644 openpilot/selfdrive/assets/sounds/prompt.wav delete mode 100644 openpilot/selfdrive/assets/sounds/prompt_distracted.wav create mode 100644 openpilot/selfdrive/assets/sounds/warning.wav delete mode 100644 openpilot/selfdrive/assets/sounds/warning_immediate.wav delete mode 100644 openpilot/selfdrive/assets/sounds/warning_soft.wav diff --git a/openpilot/selfdrive/assets/sounds/critical.wav b/openpilot/selfdrive/assets/sounds/critical.wav new file mode 100644 index 0000000000..f36aa1bacd --- /dev/null +++ b/openpilot/selfdrive/assets/sounds/critical.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ca29ff46fbd6d00cc02596de8c9abfbf1cacdc6a7b2f98b27a74d24c790da004 +size 52374 diff --git a/openpilot/selfdrive/assets/sounds/disengage.wav b/openpilot/selfdrive/assets/sounds/disengage.wav index 7bfd97ad71..7db1d31911 100644 --- a/openpilot/selfdrive/assets/sounds/disengage.wav +++ b/openpilot/selfdrive/assets/sounds/disengage.wav @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:42bd04a57b527c787a0555503e02a203f7d672c12d448769a3f41f17befbf013 -size 48044 +oid sha256:6f47633b5082b911e79dd13fc2d1f4c2a9d44d2fe0860f3d390ffc4e3d772f7d +size 97120 diff --git a/openpilot/selfdrive/assets/sounds/dm_critical.wav b/openpilot/selfdrive/assets/sounds/dm_critical.wav new file mode 100644 index 0000000000..9a4171444f --- /dev/null +++ b/openpilot/selfdrive/assets/sounds/dm_critical.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:538271820d618046009bb456e930598f2736dd1a6174c11925ec3912581b05c1 +size 52374 diff --git a/openpilot/selfdrive/assets/sounds/dm_warning.wav b/openpilot/selfdrive/assets/sounds/dm_warning.wav new file mode 100644 index 0000000000..52124b0801 --- /dev/null +++ b/openpilot/selfdrive/assets/sounds/dm_warning.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:634fad6590295ef2e73d5f7e5aecbb5dd245a0f7692f1e300c2ebac95aabb8e3 +size 73026 diff --git a/openpilot/selfdrive/assets/sounds/engage.wav b/openpilot/selfdrive/assets/sounds/engage.wav index 8633b5ac2d..a984e2f3da 100644 --- a/openpilot/selfdrive/assets/sounds/engage.wav +++ b/openpilot/selfdrive/assets/sounds/engage.wav @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b1e177499d9439367179cc57a6301b6162393972e3a136cc35c5fdac026bf10a -size 48044 +oid sha256:a380fe0a022856b302841a0dd19b71eab318c6b08ec851f128942758a7f1631a +size 97120 diff --git a/openpilot/selfdrive/assets/sounds/pre_alert.wav b/openpilot/selfdrive/assets/sounds/pre_alert.wav index 4443bf7d23..d8240a1717 100644 --- a/openpilot/selfdrive/assets/sounds/pre_alert.wav +++ b/openpilot/selfdrive/assets/sounds/pre_alert.wav @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2c0af7f5fe57bb36ab96fae868e20feca763541c97a61b3a3a84a0e7fcb81163 -size 83350 +oid sha256:cc9e67cfaba77e8e4f4049f0add9238660e4cf8f886c6915600d8f47a30c98db +size 97120 diff --git a/openpilot/selfdrive/assets/sounds/prompt.wav b/openpilot/selfdrive/assets/sounds/prompt.wav deleted file mode 100644 index e482c85a62..0000000000 --- a/openpilot/selfdrive/assets/sounds/prompt.wav +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:ad19268e4aaaeac8dd21f6b26c16a121e7b3f50bba867748e7226727643ae682 -size 144642 diff --git a/openpilot/selfdrive/assets/sounds/prompt_distracted.wav b/openpilot/selfdrive/assets/sounds/prompt_distracted.wav deleted file mode 100644 index c11993f20c..0000000000 --- a/openpilot/selfdrive/assets/sounds/prompt_distracted.wav +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:412ef25d2fb103c1ebd55c667313a5921493305fb4e1f4e1dafc08d3b95d86ab -size 73026 diff --git a/openpilot/selfdrive/assets/sounds/refuse.wav b/openpilot/selfdrive/assets/sounds/refuse.wav index 1e0c47697d..10026d7be9 100644 --- a/openpilot/selfdrive/assets/sounds/refuse.wav +++ b/openpilot/selfdrive/assets/sounds/refuse.wav @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4af81cbf1d96a42cc351878b015298aee82874b46baaf1a615ca91ec36c0ced6 -size 83228 +oid sha256:081d64ca28a84a59ff5e6aea4732865b8687585938b8a287bc7e6a49d0646508 +size 97120 diff --git a/openpilot/selfdrive/assets/sounds/warning.wav b/openpilot/selfdrive/assets/sounds/warning.wav new file mode 100644 index 0000000000..bb71a0176a --- /dev/null +++ b/openpilot/selfdrive/assets/sounds/warning.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1bb440f424c989c06e203bfba0b32d39aede8f6c78a9b335e5e778460202b601 +size 73026 diff --git a/openpilot/selfdrive/assets/sounds/warning_immediate.wav b/openpilot/selfdrive/assets/sounds/warning_immediate.wav deleted file mode 100644 index fcbfed79ed..0000000000 --- a/openpilot/selfdrive/assets/sounds/warning_immediate.wav +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:5a390831afca3bfc6ea3c2739b872ebf866e70df8ae30653f8587e5cd3993959 -size 68306 diff --git a/openpilot/selfdrive/assets/sounds/warning_soft.wav b/openpilot/selfdrive/assets/sounds/warning_soft.wav deleted file mode 100644 index 7db30303d6..0000000000 --- a/openpilot/selfdrive/assets/sounds/warning_soft.wav +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:67e636d072703e6b1233a12344c0a6304fd43d64dbb31c66b71c2c8870a339c1 -size 153764 diff --git a/openpilot/selfdrive/ui/soundd.py b/openpilot/selfdrive/ui/soundd.py index 151c49b0ec..5cbb1298f9 100644 --- a/openpilot/selfdrive/ui/soundd.py +++ b/openpilot/selfdrive/ui/soundd.py @@ -22,7 +22,7 @@ ALERT_RAMP_TIME = 4 # seconds to ramp to max volume for warningImmediate SELFDRIVE_STATE_TIMEOUT = 5 # 5 seconds FILTER_DT = 1. / (micd.SAMPLE_RATE / micd.FFT_SAMPLES) -AMBIENT_DB = 24 # DB where MIN_VOLUME is applied +AMBIENT_DB = 26 # DB where MIN_VOLUME is applied DB_SCALE = 30 # AMBIENT_DB + DB_SCALE is where MAX_VOLUME is applied VOLUME_BASE = 20 @@ -39,16 +39,15 @@ sound_list: dict[int, tuple[str, int | None, float]] = { AudibleAlert.disengage: ("disengage.wav", 1, MAX_VOLUME), AudibleAlert.refuse: ("refuse.wav", 1, MAX_VOLUME), - AudibleAlert.prompt: ("prompt.wav", 1, MAX_VOLUME), - AudibleAlert.promptRepeat: ("prompt.wav", None, MAX_VOLUME), - AudibleAlert.promptDistracted: ("prompt_distracted.wav", None, MAX_VOLUME), + AudibleAlert.prompt: ("warning.wav", 1, MAX_VOLUME), + AudibleAlert.promptRepeat: ("warning.wav", None, MAX_VOLUME), + AudibleAlert.promptDistracted: ("dm_warning.wav", None, MAX_VOLUME), AudibleAlert.preAlert: ("pre_alert.wav", 1, MAX_VOLUME), - AudibleAlert.complete: ("complete.wav", 1, MAX_VOLUME), - AudibleAlert.warningSoft: ("warning_soft.wav", None, MAX_VOLUME), - AudibleAlert.warningImmediate: ("warning_immediate.wav", None, MAX_VOLUME), + AudibleAlert.warningSoft: ("critical.wav", None, MAX_VOLUME), + AudibleAlert.warningImmediate: ("dm_critical.wav", None, MAX_VOLUME), } if HARDWARE.get_device_type() == "tizi": sound_list.update({ @@ -78,6 +77,7 @@ class Soundd: self.ramp_start_time = 0. self.selfdrive_timeout_alert = False + self.pending_stop = False self.spl_filter_weighted = FirstOrderFilter(0, 2.5, FILTER_DT, initialized=False) @@ -116,6 +116,10 @@ class Soundd: self.current_sound_frame += frames_to_write current_sound_frame = self.current_sound_frame % len(sound_data) loops = self.current_sound_frame // len(sound_data) + if self.pending_stop and current_sound_frame == 0: + self.current_alert = AudibleAlert.none + self.pending_stop = False + break return ret * self.current_volume @@ -126,6 +130,15 @@ class Soundd: def update_alert(self, new_alert): current_alert_played_once = self.current_alert == AudibleAlert.none or self.current_sound_frame >= len(self.loaded_sounds[self.current_alert]) + # let looping sounds finish the current loop instead of cutting off mid tone + if new_alert == AudibleAlert.none and self.current_alert != AudibleAlert.none and sound_list[self.current_alert][1] is None: + if current_alert_played_once: + self.pending_stop = True + else: + self.current_alert = AudibleAlert.none + self.current_sound_frame = 0 + return + self.pending_stop = False if self.current_alert != new_alert and (new_alert != AudibleAlert.none or current_alert_played_once): if new_alert == AudibleAlert.warningImmediate: self.ramp_start_volume = self.current_volume From c20263d9851daa7c0142b20f095697e8e1910263 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Mon, 20 Jul 2026 14:46:33 -0700 Subject: [PATCH 32/35] speedup chunk reading (#38389) read chunk directly into caller buffer --- openpilot/common/file_chunker.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/openpilot/common/file_chunker.py b/openpilot/common/file_chunker.py index 74f3013f71..2d080c3fff 100755 --- a/openpilot/common/file_chunker.py +++ b/openpilot/common/file_chunker.py @@ -42,25 +42,26 @@ def get_existing_chunks(path): class ChunkStream(io.RawIOBase): def __init__(self, paths): self._paths = iter(paths) - self._buf = memoryview(b'') + self._f = None def readable(self): return True def readinto(self, b): n = 0 + view = memoryview(b) while n < len(b): - if not self._buf: + if self._f is None: p = next(self._paths, None) if p is None: break - with open(p, 'rb') as f: - self._buf = memoryview(f.read()) + self._f = open(p, 'rb') + count = self._f.readinto(view[n:]) + if not count: + self._f.close() + self._f = None continue - take = min(len(b) - n, len(self._buf)) - b[n:n + take] = self._buf[:take] - self._buf = self._buf[take:] - n += take + n += count return n def open_file_chunked(path): From b1f2e638de2d5ec81c88637c0a3236fae8c4d20c Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Mon, 20 Jul 2026 16:00:20 -0700 Subject: [PATCH 33/35] fix branch cleanup burning GH API requests (#38392) * ci: stop branch cleanup from burning API rate limit * simpler --- .github/workflows/repo-maintenance.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/repo-maintenance.yaml b/.github/workflows/repo-maintenance.yaml index 0fb1027a8d..0421efe6e8 100644 --- a/.github/workflows/repo-maintenance.yaml +++ b/.github/workflows/repo-maintenance.yaml @@ -89,6 +89,14 @@ jobs: const { owner, repo } = context.repo; const upstream = `${owner}/${repo}`; + const closed = context.payload.pull_request; + if (closed) { + if (closed.head.repo?.full_name === upstream) { + await github.rest.git.deleteRef({ owner, repo, ref: `heads/${closed.head.ref}` }).catch(console.log); + } + return; + } + for await (const response of github.paginate.iterator(github.rest.pulls.list, { owner, repo, From 031b1ad0a3d4e59e7b4cfb53fb1dbc01fa004cd9 Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Mon, 20 Jul 2026 16:30:42 -0700 Subject: [PATCH 34/35] longcontrol: remove starting state (#38340) --- opendbc_repo | 2 +- .../selfdrive/controls/lib/longcontrol.py | 23 ++------ .../controls/tests/test_longcontrol.py | 56 +++++++------------ 3 files changed, 27 insertions(+), 54 deletions(-) diff --git a/opendbc_repo b/opendbc_repo index 78a1c9e73d..938043e24a 160000 --- a/opendbc_repo +++ b/opendbc_repo @@ -1 +1 @@ -Subproject commit 78a1c9e73de2e0a081cb2bb41f7f10281b9eeabf +Subproject commit 938043e24a0ff1a3a6f81a4c793822d4733f09ef diff --git a/openpilot/selfdrive/controls/lib/longcontrol.py b/openpilot/selfdrive/controls/lib/longcontrol.py index 977add1285..07abd0d5d3 100644 --- a/openpilot/selfdrive/controls/lib/longcontrol.py +++ b/openpilot/selfdrive/controls/lib/longcontrol.py @@ -10,8 +10,7 @@ CONTROL_N_T_IDX = ModelConstants.T_IDXS[:CONTROL_N] LongCtrlState = car.CarControl.Actuators.LongControlState -def long_control_state_trans(CP, active, long_control_state, v_ego, - should_stop, brake_pressed, cruise_standstill): +def long_control_state_trans(active, long_control_state, should_stop, brake_pressed, cruise_standstill): starting_condition = (not should_stop and not cruise_standstill and not brake_pressed) @@ -23,22 +22,17 @@ def long_control_state_trans(CP, active, long_control_state, v_ego, if long_control_state == LongCtrlState.off: if not starting_condition: long_control_state = LongCtrlState.stopping - elif CP.startingState: - long_control_state = LongCtrlState.starting else: long_control_state = LongCtrlState.pid elif long_control_state == LongCtrlState.stopping: - if starting_condition and CP.startingState: - long_control_state = LongCtrlState.starting - elif starting_condition: + if starting_condition: long_control_state = LongCtrlState.pid - elif long_control_state in [LongCtrlState.starting, LongCtrlState.pid]: + elif long_control_state == LongCtrlState.pid: if should_stop: long_control_state = LongCtrlState.stopping - elif v_ego > CP.vEgoStarting: - long_control_state = LongCtrlState.pid + return long_control_state class LongControl: @@ -58,9 +52,8 @@ class LongControl: self.pid.neg_limit = accel_limits[0] self.pid.pos_limit = accel_limits[1] - self.long_control_state = long_control_state_trans(self.CP, active, self.long_control_state, CS.vEgo, - should_stop, CS.brakePressed, - CS.cruiseState.standstill) + self.long_control_state = long_control_state_trans(active, self.long_control_state, should_stop, + CS.brakePressed, CS.cruiseState.standstill) if self.long_control_state == LongCtrlState.off: self.reset() output_accel = 0. @@ -72,10 +65,6 @@ class LongControl: output_accel -= self.CP.stoppingDecelRate * DT_CTRL self.reset() - elif self.long_control_state == LongCtrlState.starting: - output_accel = self.CP.startAccel - self.reset() - else: # LongCtrlState.pid error = a_target - CS.aEgo output_accel = self.pid.update(error, speed=CS.vEgo, diff --git a/openpilot/selfdrive/controls/tests/test_longcontrol.py b/openpilot/selfdrive/controls/tests/test_longcontrol.py index f69c54a3cd..2c52132068 100644 --- a/openpilot/selfdrive/controls/tests/test_longcontrol.py +++ b/openpilot/selfdrive/controls/tests/test_longcontrol.py @@ -1,56 +1,40 @@ -from opendbc.car.structs import car from openpilot.selfdrive.controls.lib.longcontrol import LongCtrlState, long_control_state_trans - - class TestLongControlStateTransition: def test_stay_stopped(self): - CP = car.CarParams.new_message() active = True current_state = LongCtrlState.stopping - next_state = long_control_state_trans(CP, active, current_state, v_ego=0.1, + next_state = long_control_state_trans(active, current_state, should_stop=True, brake_pressed=False, cruise_standstill=False) assert next_state == LongCtrlState.stopping - next_state = long_control_state_trans(CP, active, current_state, v_ego=0.1, + next_state = long_control_state_trans(active, current_state, should_stop=False, brake_pressed=True, cruise_standstill=False) assert next_state == LongCtrlState.stopping - next_state = long_control_state_trans(CP, active, current_state, v_ego=0.1, + next_state = long_control_state_trans(active, current_state, should_stop=False, brake_pressed=False, cruise_standstill=True) assert next_state == LongCtrlState.stopping - next_state = long_control_state_trans(CP, active, current_state, v_ego=1.0, + next_state = long_control_state_trans(active, current_state, should_stop=False, brake_pressed=False, cruise_standstill=False) assert next_state == LongCtrlState.pid active = False - next_state = long_control_state_trans(CP, active, current_state, v_ego=1.0, + next_state = long_control_state_trans(active, current_state, should_stop=False, brake_pressed=False, cruise_standstill=False) assert next_state == LongCtrlState.off - def test_engage(self): - CP = car.CarParams.new_message() - active = True - current_state = LongCtrlState.off - next_state = long_control_state_trans(CP, active, current_state, v_ego=0.1, - should_stop=True, brake_pressed=False, cruise_standstill=False) - assert next_state == LongCtrlState.stopping - next_state = long_control_state_trans(CP, active, current_state, v_ego=0.1, - should_stop=False, brake_pressed=True, cruise_standstill=False) - assert next_state == LongCtrlState.stopping - next_state = long_control_state_trans(CP, active, current_state, v_ego=0.1, - should_stop=False, brake_pressed=False, cruise_standstill=True) - assert next_state == LongCtrlState.stopping - next_state = long_control_state_trans(CP, active, current_state, v_ego=0.1, - should_stop=False, brake_pressed=False, cruise_standstill=False) - assert next_state == LongCtrlState.pid - - def test_starting(self): - CP = car.CarParams.new_message(startingState=True, vEgoStarting=0.5) - active = True - current_state = LongCtrlState.starting - next_state = long_control_state_trans(CP, active, current_state, v_ego=0.1, - should_stop=False, brake_pressed=False, cruise_standstill=False) - assert next_state == LongCtrlState.starting - next_state = long_control_state_trans(CP, active, current_state, v_ego=1.0, - should_stop=False, brake_pressed=False, cruise_standstill=False) - assert next_state == LongCtrlState.pid +def test_engage(): + active = True + current_state = LongCtrlState.off + next_state = long_control_state_trans(active, current_state, + should_stop=True, brake_pressed=False, cruise_standstill=False) + assert next_state == LongCtrlState.stopping + next_state = long_control_state_trans(active, current_state, + should_stop=False, brake_pressed=True, cruise_standstill=False) + assert next_state == LongCtrlState.stopping + next_state = long_control_state_trans(active, current_state, + should_stop=False, brake_pressed=False, cruise_standstill=True) + assert next_state == LongCtrlState.stopping + next_state = long_control_state_trans(active, current_state, + should_stop=False, brake_pressed=False, cruise_standstill=False) + assert next_state == LongCtrlState.pid From fdd1df79fbf80e83e8f7168719c067d527443e72 Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Mon, 20 Jul 2026 19:52:23 -0700 Subject: [PATCH 35/35] longitudinal: remove per-car stopping tunes (#38394) * remove per-car longitudinal stopping tunes * bump opendbc * lil more * lil more * lil more * lil more * revert modeld for now --- opendbc_repo | 2 +- openpilot/selfdrive/controls/lib/drive_helpers.py | 8 +++++--- openpilot/selfdrive/controls/lib/longcontrol.py | 3 ++- openpilot/selfdrive/controls/lib/longitudinal_planner.py | 2 +- openpilot/tools/joystick/joystickd.py | 2 +- openpilot/tools/longitudinal_maneuvers/maneuversd.py | 8 ++++---- 6 files changed, 14 insertions(+), 11 deletions(-) diff --git a/opendbc_repo b/opendbc_repo index 938043e24a..d4c6f68c39 160000 --- a/opendbc_repo +++ b/opendbc_repo @@ -1 +1 @@ -Subproject commit 938043e24a0ff1a3a6f81a4c793822d4733f09ef +Subproject commit d4c6f68c39dbab1c2663bd3c72a0087f93858213 diff --git a/openpilot/selfdrive/controls/lib/drive_helpers.py b/openpilot/selfdrive/controls/lib/drive_helpers.py index 5392ff8875..497f8d1bdf 100644 --- a/openpilot/selfdrive/controls/lib/drive_helpers.py +++ b/openpilot/selfdrive/controls/lib/drive_helpers.py @@ -15,6 +15,9 @@ MAX_LATERAL_JERK = 5.0 # m/s^3 MAX_LATERAL_ACCEL_NO_ROLL = 3.0 # m/s^2 +def should_stop(v_ego: float, a_target: float) -> bool: + return bool(v_ego < 0.25 and a_target < 0.1) + def clamp(val, min_val, max_val): clamped_val = float(np.clip(val, min_val, max_val)) return clamped_val, clamped_val != val @@ -40,7 +43,7 @@ def clip_curvature(v_ego, prev_curvature, new_curvature, roll) -> tuple[float, b return float(new_curvature), limited_accel or limited_max_curv -def get_accel_from_plan(speeds, accels, t_idxs, action_t=DT_MDL, vEgoStopping=0.3): +def get_accel_from_plan(speeds, accels, t_idxs, action_t=DT_MDL): if len(speeds) == len(t_idxs): v_now = speeds[0] a_now = accels[0] @@ -53,8 +56,7 @@ def get_accel_from_plan(speeds, accels, t_idxs, action_t=DT_MDL, vEgoStopping=0. v_now = 0.0 v_target = 0.0 a_target = 0.0 - should_stop = (v_now < vEgoStopping and a_target < 0.1) - return a_target, should_stop + return a_target, should_stop(v_now, a_target) def curv_from_psis(psi_target, psi_rate, vego, action_t): vego = np.clip(vego, MIN_SPEED, np.inf) diff --git a/openpilot/selfdrive/controls/lib/longcontrol.py b/openpilot/selfdrive/controls/lib/longcontrol.py index 07abd0d5d3..437bcef777 100644 --- a/openpilot/selfdrive/controls/lib/longcontrol.py +++ b/openpilot/selfdrive/controls/lib/longcontrol.py @@ -62,7 +62,8 @@ class LongControl: output_accel = self.last_output_accel if output_accel > self.CP.stopAccel: output_accel = min(output_accel, 0.0) - output_accel -= self.CP.stoppingDecelRate * DT_CTRL + # TODO: can we just go straight to stopAccel? + output_accel -= 1.0 * DT_CTRL # m/s^2/s while trying to stop self.reset() else: # LongCtrlState.pid diff --git a/openpilot/selfdrive/controls/lib/longitudinal_planner.py b/openpilot/selfdrive/controls/lib/longitudinal_planner.py index 884b4d00bc..116de14e0e 100755 --- a/openpilot/selfdrive/controls/lib/longitudinal_planner.py +++ b/openpilot/selfdrive/controls/lib/longitudinal_planner.py @@ -129,7 +129,7 @@ class LongitudinalPlanner: action_t = self.CP.longitudinalActuatorDelay + DT_MDL output_a_target_mpc, output_should_stop_mpc = get_accel_from_plan(self.v_desired_trajectory, self.a_desired_trajectory, CONTROL_N_T_IDX, - action_t=action_t, vEgoStopping=self.CP.vEgoStopping) + action_t=action_t) output_a_target_e2e = sm['modelV2'].action.desiredAcceleration output_should_stop_e2e = sm['modelV2'].action.shouldStop diff --git a/openpilot/tools/joystick/joystickd.py b/openpilot/tools/joystick/joystickd.py index a1c00746b6..ac2f99f67e 100755 --- a/openpilot/tools/joystick/joystickd.py +++ b/openpilot/tools/joystick/joystickd.py @@ -48,7 +48,7 @@ def joystickd_thread(): if CC.longActive: actuators.accel = 4.0 * float(np.clip(joystick_axes[0], -1, 1)) - actuators.longControlState = LongCtrlState.pid if sm['carState'].vEgo > CP.vEgoStopping else LongCtrlState.stopping + actuators.longControlState = LongCtrlState.pid if sm['carState'].vEgo > 0.1 else LongCtrlState.stopping CC.cruiseControl.resume = actuators.accel > 0.0 if CC.latActive: diff --git a/openpilot/tools/longitudinal_maneuvers/maneuversd.py b/openpilot/tools/longitudinal_maneuvers/maneuversd.py index 8cdc2f26cf..0e20840274 100755 --- a/openpilot/tools/longitudinal_maneuvers/maneuversd.py +++ b/openpilot/tools/longitudinal_maneuvers/maneuversd.py @@ -3,11 +3,11 @@ import numpy as np from dataclasses import dataclass from openpilot.cereal import messaging -from opendbc.car.structs import car from openpilot.common.constants import CV from openpilot.common.realtime import DT_MDL from openpilot.common.params import Params from openpilot.common.swaglog import cloudlog +from openpilot.selfdrive.controls.lib.drive_helpers import should_stop @dataclass @@ -139,8 +139,8 @@ MANEUVERS = [ def main(): params = Params() - cloudlog.info("joystickd is waiting for CarParams") - CP = messaging.log_from_bytes(params.get("CarParams", block=True), car.CarParams) + cloudlog.info("maneuversd is waiting for CarParams") + params.get("CarParams", block=True) sm = messaging.SubMaster(['carState', 'carControl', 'controlsState', 'selfdriveState', 'modelV2'], poll='modelV2') pm = messaging.PubMaster(['longitudinalPlan', 'driverAssistance', 'alertDebug']) @@ -178,7 +178,7 @@ def main(): pm.send('alertDebug', alert_msg) longitudinalPlan.aTarget = accel - longitudinalPlan.shouldStop = v_ego < CP.vEgoStopping and accel < 1e-2 + longitudinalPlan.shouldStop = should_stop(v_ego, accel) longitudinalPlan.allowBrake = True longitudinalPlan.allowThrottle = True