Merge branch 'upstream/openpilot/master' into sync-20251114

# Conflicts:
#	.github/workflows/ci_weekly_run.yaml
#	.github/workflows/raylib_ui_preview.yaml
#	.github/workflows/tests.yaml
#	.gitmodules
#	README.md
#	SConstruct
#	common/api.py
#	common/params_keys.h
#	docs/CARS.md
#	msgq_repo
#	opendbc_repo
#	panda
#	selfdrive/car/tests/test_car_interfaces.py
#	selfdrive/controls/controlsd.py
#	selfdrive/controls/lib/latcontrol.py
#	selfdrive/controls/lib/latcontrol_angle.py
#	selfdrive/controls/lib/latcontrol_pid.py
#	selfdrive/controls/lib/latcontrol_torque.py
#	selfdrive/controls/tests/test_latcontrol.py
#	selfdrive/monitoring/helpers.py
#	selfdrive/ui/SConscript
#	selfdrive/ui/main.cc
#	selfdrive/ui/qt/body.h
#	selfdrive/ui/qt/home.cc
#	selfdrive/ui/qt/home.h
#	selfdrive/ui/qt/network/networking.cc
#	selfdrive/ui/qt/network/networking.h
#	selfdrive/ui/qt/network/wifi_manager.cc
#	selfdrive/ui/qt/offroad/developer_panel.cc
#	selfdrive/ui/qt/offroad/developer_panel.h
#	selfdrive/ui/qt/offroad/experimental_mode.cc
#	selfdrive/ui/qt/offroad/firehose.cc
#	selfdrive/ui/qt/offroad/firehose.h
#	selfdrive/ui/qt/offroad/onboarding.cc
#	selfdrive/ui/qt/offroad/onboarding.h
#	selfdrive/ui/qt/offroad/settings.cc
#	selfdrive/ui/qt/offroad/settings.h
#	selfdrive/ui/qt/offroad/software_settings.cc
#	selfdrive/ui/qt/onroad/alerts.cc
#	selfdrive/ui/qt/onroad/annotated_camera.h
#	selfdrive/ui/qt/onroad/buttons.cc
#	selfdrive/ui/qt/onroad/buttons.h
#	selfdrive/ui/qt/onroad/driver_monitoring.cc
#	selfdrive/ui/qt/onroad/hud.cc
#	selfdrive/ui/qt/onroad/hud.h
#	selfdrive/ui/qt/onroad/model.cc
#	selfdrive/ui/qt/onroad/model.h
#	selfdrive/ui/qt/onroad/onroad_home.cc
#	selfdrive/ui/qt/onroad/onroad_home.h
#	selfdrive/ui/qt/request_repeater.h
#	selfdrive/ui/qt/sidebar.cc
#	selfdrive/ui/qt/sidebar.h
#	selfdrive/ui/qt/util.cc
#	selfdrive/ui/qt/widgets/cameraview.h
#	selfdrive/ui/qt/widgets/controls.cc
#	selfdrive/ui/qt/widgets/controls.h
#	selfdrive/ui/qt/widgets/input.cc
#	selfdrive/ui/qt/widgets/input.h
#	selfdrive/ui/qt/widgets/prime.cc
#	selfdrive/ui/qt/widgets/prime.h
#	selfdrive/ui/qt/widgets/ssh_keys.h
#	selfdrive/ui/qt/widgets/toggle.h
#	selfdrive/ui/qt/widgets/wifi.cc
#	selfdrive/ui/qt/widgets/wifi.h
#	selfdrive/ui/qt/window.cc
#	selfdrive/ui/qt/window.h
#	selfdrive/ui/tests/cycle_offroad_alerts.py
#	selfdrive/ui/tests/test_ui/run.py
#	selfdrive/ui/translations/main_ar.ts
#	selfdrive/ui/translations/main_de.ts
#	selfdrive/ui/translations/main_es.ts
#	selfdrive/ui/translations/main_fr.ts
#	selfdrive/ui/translations/main_ja.ts
#	selfdrive/ui/translations/main_ko.ts
#	selfdrive/ui/translations/main_nl.ts
#	selfdrive/ui/translations/main_pl.ts
#	selfdrive/ui/translations/main_pt-BR.ts
#	selfdrive/ui/translations/main_th.ts
#	selfdrive/ui/translations/main_tr.ts
#	selfdrive/ui/translations/main_zh-CHS.ts
#	selfdrive/ui/translations/main_zh-CHT.ts
#	selfdrive/ui/ui.cc
#	selfdrive/ui/ui.h
#	system/manager/build.py
#	system/version.py
This commit is contained in:
Jason Wen
2025-11-16 02:00:29 -05:00
496 changed files with 22367 additions and 59319 deletions
+58 -5
View File
@@ -1,7 +1,60 @@
Import('qt_env', 'arch', 'common', 'messaging', 'visionipc', 'replay_lib', 'cereal', 'widgets')
import subprocess
import os
Import('env', 'arch', 'common', 'messaging', 'visionipc', 'replay_lib', 'cereal')
qt_env = env.Clone()
qt_modules = ["Widgets", "Gui", "Core", "Network", "Concurrent", "DBus", "Xml"]
qt_libs = []
if arch == "Darwin":
brew_prefix = subprocess.check_output(['brew', '--prefix'], encoding='utf8').strip()
qt_env['QTDIR'] = f"{brew_prefix}/opt/qt@5"
qt_dirs = [
os.path.join(qt_env['QTDIR'], "include"),
]
qt_dirs += [f"{qt_env['QTDIR']}/include/Qt{m}" for m in qt_modules]
qt_env["LINKFLAGS"] += ["-F" + os.path.join(qt_env['QTDIR'], "lib")]
qt_env["FRAMEWORKS"] += [f"Qt{m}" for m in qt_modules] + ["OpenGL"]
qt_env.AppendENVPath('PATH', os.path.join(qt_env['QTDIR'], "bin"))
else:
qt_install_prefix = subprocess.check_output(['qmake', '-query', 'QT_INSTALL_PREFIX'], encoding='utf8').strip()
qt_install_headers = subprocess.check_output(['qmake', '-query', 'QT_INSTALL_HEADERS'], encoding='utf8').strip()
qt_env['QTDIR'] = qt_install_prefix
qt_dirs = [
f"{qt_install_headers}",
]
qt_gui_path = os.path.join(qt_install_headers, "QtGui")
qt_gui_dirs = [d for d in os.listdir(qt_gui_path) if os.path.isdir(os.path.join(qt_gui_path, d))]
qt_dirs += [f"{qt_install_headers}/QtGui/{qt_gui_dirs[0]}/QtGui", ] if qt_gui_dirs else []
qt_dirs += [f"{qt_install_headers}/Qt{m}" for m in qt_modules]
qt_libs = [f"Qt5{m}" for m in qt_modules]
if arch == "larch64":
qt_libs += ["GLESv2", "wayland-client"]
qt_env.PrependENVPath('PATH', Dir("#third_party/qt5/larch64/bin/").abspath)
elif arch != "Darwin":
qt_libs += ["GL"]
qt_env['QT3DIR'] = qt_env['QTDIR']
qt_env.Tool('qt3')
qt_env['CPPPATH'] += qt_dirs + ["#third_party/qrcode"]
qt_flags = [
"-D_REENTRANT",
"-DQT_NO_DEBUG",
"-DQT_WIDGETS_LIB",
"-DQT_GUI_LIB",
"-DQT_CORE_LIB",
"-DQT_MESSAGELOGCONTEXT",
]
qt_env['CXXFLAGS'] += qt_flags
qt_env['LIBPATH'] += ['#selfdrive/ui', ]
qt_env['LIBS'] = qt_libs
base_frameworks = qt_env['FRAMEWORKS']
base_libs = [common, messaging, cereal, visionipc, 'qt_util', 'm', 'ssl', 'crypto', 'pthread'] + qt_env["LIBS"]
base_libs = [common, messaging, cereal, visionipc, 'm', 'ssl', 'crypto', 'pthread'] + qt_env["LIBS"]
if arch == "Darwin":
base_frameworks.append('OpenCL')
@@ -12,11 +65,11 @@ else:
base_libs.append('Qt5Charts')
base_libs.append('Qt5SerialBus')
qt_libs = ['qt_util'] + base_libs
qt_libs = base_libs
cabana_env = qt_env.Clone()
cabana_libs = [widgets, cereal, messaging, visionipc, replay_lib, 'avutil', 'avcodec', 'avformat', 'bz2', 'zstd', 'curl', 'yuv', 'usb-1.0'] + qt_libs
cabana_libs = [cereal, messaging, visionipc, replay_lib, 'avutil', 'avcodec', 'avformat', 'bz2', 'zstd', 'curl', 'yuv', 'usb-1.0'] + qt_libs
opendbc_path = '-DOPENDBC_FILE_PATH=\'"%s"\'' % (cabana_env.Dir("../../opendbc/dbc").abspath)
cabana_env['CXXFLAGS'] += [opendbc_path]
@@ -28,7 +81,7 @@ cabana_env.Depends(assets, Glob('/assets/*', exclude=[assets, assets_src, "asset
cabana_lib = cabana_env.Library("cabana_lib", ['mainwin.cc', 'streams/socketcanstream.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',
'utils/export.cc', 'utils/util.cc',
'utils/export.cc', 'utils/util.cc', 'utils/elidedlabel.cc', 'utils/api.cc',
'chart/chartswidget.cc', 'chart/chart.cc', 'chart/signalselector.cc', 'chart/tiplabel.cc', 'chart/sparkline.cc',
'commands.cc', 'messageswidget.cc', 'streamselector.cc', 'settings.cc', 'panda.cc',
'cameraview.cc', 'detailwidget.cc', 'tools/findsimilarbits.cc', 'tools/findsignal.cc', 'tools/routeinfo.cc'], LIBS=cabana_libs, FRAMEWORKS=base_frameworks)
-1
View File
@@ -1,7 +1,6 @@
#include <QApplication>
#include <QCommandLineParser>
#include "selfdrive/ui/qt/util.h"
#include "tools/cabana/mainwin.h"
#include "tools/cabana/streams/devicestream.h"
#include "tools/cabana/streams/pandastream.h"
+5 -3
View File
@@ -324,7 +324,8 @@ void ChartView::updateSeries(const cabana::Signal *sig, const MessageEventsMap *
if (!can->liveStreaming()) {
s.segment_tree.build(s.vals);
}
s.series->replace(QVector<QPointF>::fromStdVector(series_type == SeriesType::StepLine ? s.step_vals : s.vals));
const auto &points = series_type == SeriesType::StepLine ? s.step_vals : s.vals;
s.series->replace(QVector<QPointF>(points.cbegin(), points.cend()));
}
}
updateAxisY();
@@ -382,7 +383,7 @@ void ChartView::updateAxisY() {
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.width(QString::number(value, 'f', n)));
max_label_width = std::max(max_label_width, fm.horizontalAdvance(QString::number(value, 'f', n)));
}
int title_spacing = unit.isEmpty() ? 0 : QFontMetrics(axis_y->titleFont()).size(Qt::TextSingleLine, unit).height();
@@ -838,7 +839,8 @@ void ChartView::setSeriesType(SeriesType type) {
}
for (auto &s : sigs) {
s.series = createSeries(series_type, s.sig->color);
s.series->replace(QVector<QPointF>::fromStdVector(series_type == SeriesType::StepLine ? s.step_vals : s.vals));
const auto &points = series_type == SeriesType::StepLine ? s.step_vals : s.vals;
s.series->replace(QVector<QPointF>(points.cbegin(), points.cend()));
}
updateSeriesPoints();
updateTitle();
+1
View File
@@ -3,6 +3,7 @@
#include <QFormLayout>
#include <QMenu>
#include <QRadioButton>
#include <QPushButton>
#include <QToolBar>
#include "tools/cabana/commands.h"
+1 -1
View File
@@ -6,11 +6,11 @@
#include <QTextEdit>
#include <set>
#include "selfdrive/ui/qt/widgets/controls.h"
#include "tools/cabana/binaryview.h"
#include "tools/cabana/chart/chartswidget.h"
#include "tools/cabana/historylog.h"
#include "tools/cabana/signalview.h"
#include "tools/cabana/utils/elidedlabel.h"
class EditMessageDialog : public QDialog {
public:
+1 -1
View File
@@ -122,7 +122,7 @@ void MainWindow::createActions() {
auto undo_act = UndoStack::instance()->createUndoAction(this, tr("&Undo"));
undo_act->setShortcuts(QKeySequence::Undo);
edit_menu->addAction(undo_act);
auto redo_act = UndoStack::instance()->createRedoAction(this, tr("&Rndo"));
auto redo_act = UndoStack::instance()->createRedoAction(this, tr("&Redo"));
redo_act->setShortcuts(QKeySequence::Redo);
edit_menu->addAction(redo_act);
edit_menu->addSeparator();
+6 -6
View File
@@ -265,7 +265,7 @@ QSize SignalItemDelegate::sizeHint(const QStyleOptionViewItem &option, const QMo
text += item->sig->type == cabana::Signal::Type::Multiplexor ? QString(" M ") : QString(" m%1 ").arg(item->sig->multiplex_value);
spacing += (option.widget->style()->pixelMetric(QStyle::PM_FocusFrameHMargin) + 1) * 2;
}
width = std::min<int>(option.widget->size().width() / 3.0, option.fontMetrics.width(text) + spacing);
width = std::min<int>(option.widget->size().width() / 3.0, option.fontMetrics.horizontalAdvance(text) + spacing);
}
return {width, option.fontMetrics.height() + option.widget->style()->pixelMetric(QStyle::PM_FocusFrameVMargin) * 2};
}
@@ -308,7 +308,7 @@ void SignalItemDelegate::paint(QPainter *painter, const QStyleOptionViewItem &op
// multiplexer indicator
if (item->sig->type != cabana::Signal::Type::Normal) {
QString indicator = item->sig->type == cabana::Signal::Type::Multiplexor ? QString(" M ") : QString(" m%1 ").arg(item->sig->multiplex_value);
QRect indicator_rect{rect.x(), rect.y(), option.fontMetrics.width(indicator), rect.height()};
QRect indicator_rect{rect.x(), rect.y(), option.fontMetrics.horizontalAdvance(indicator), rect.height()};
painter->setBrush(Qt::gray);
painter->setPen(Qt::NoPen);
painter->drawRoundedRect(indicator_rect, 3, 3);
@@ -342,13 +342,13 @@ void SignalItemDelegate::paint(QPainter *painter, const QStyleOptionViewItem &op
painter->drawText(rect, Qt::AlignLeft | Qt::AlignTop, max);
painter->drawText(rect, Qt::AlignLeft | Qt::AlignBottom, min);
QFontMetrics fm(minmax_font);
value_adjust = std::max(fm.width(min), fm.width(max)) + 5;
value_adjust = std::max(fm.horizontalAdvance(min), fm.horizontalAdvance(max)) + 5;
} else if (!item->sparkline.isEmpty() && item->sig->type == cabana::Signal::Type::Multiplexed) {
// display freq of multiplexed signal
painter->setFont(label_font);
QString freq = QString("%1 hz").arg(item->sparkline.freq(), 0, 'g', 2);
painter->drawText(rect.adjusted(5, 0, 0, 0), Qt::AlignLeft | Qt::AlignVCenter, freq);
value_adjust = QFontMetrics(label_font).width(freq) + 10;
value_adjust = QFontMetrics(label_font).horizontalAdvance(freq) + 10;
}
// signal value
painter->setFont(option.font);
@@ -622,13 +622,13 @@ void SignalView::updateState(const std::set<MessageId> *msgs) {
double value = 0;
if (item->sig->getValue(last_msg.dat.data(), last_msg.dat.size(), &value)) {
item->sig_val = item->sig->formatValue(value);
max_value_width = std::max(max_value_width, fontMetrics().width(item->sig_val));
max_value_width = std::max(max_value_width, fontMetrics().horizontalAdvance(item->sig_val));
}
}
auto [first_visible, last_visible] = visibleSignalRange();
if (first_visible.isValid() && last_visible.isValid()) {
const static int min_max_width = QFontMetrics(delegate->minmax_font).width("-000.00") + 5;
const static int min_max_width = QFontMetrics(delegate->minmax_font).horizontalAdvance("-000.00") + 5;
int available_width = value_column_width - delegate->button_size.width();
int value_width = std::min<int>(max_value_width + min_max_width, available_width / 2);
QSize size(available_width - value_width,
+1 -2
View File
@@ -2,8 +2,7 @@
#include <QComboBox>
#include <QDialog>
#include "selfdrive/ui/qt/api.h"
#include "tools/cabana/utils/api.h"
class RouteListWidget;
class OneShotHttpRequest;
+171
View File
@@ -0,0 +1,171 @@
#include "tools/cabana/utils/api.h"
#include <openssl/evp.h>
#include <openssl/pem.h>
#include <QApplication>
#include <QCryptographicHash>
#include <QDateTime>
#include <QDebug>
#include <QJsonDocument>
#include <QNetworkRequest>
#include <memory>
#include <string>
#include "common/params.h"
#include "common/util.h"
#include "system/hardware/hw.h"
#include "tools/cabana/utils/util.h"
QString getVersion() {
static QString version = QString::fromStdString(Params().get("Version"));
return version;
}
QString getUserAgent() {
return "openpilot-" + getVersion();
}
std::optional<QString> getDongleId() {
std::string id = Params().get("DongleId");
if (!id.empty() && (id != "UnregisteredDevice")) {
return QString::fromStdString(id);
} else {
return {};
}
}
namespace CommaApi {
EVP_PKEY *get_private_key() {
static std::unique_ptr<EVP_PKEY, decltype(&EVP_PKEY_free)> pkey(nullptr, EVP_PKEY_free);
if (!pkey) {
FILE *fp = fopen(Path::rsa_file().c_str(), "rb");
if (!fp) {
qDebug() << "No private key found, please run manager.py or registration.py";
return nullptr;
}
pkey.reset(PEM_read_PrivateKey(fp, nullptr, nullptr, nullptr));
fclose(fp);
}
return pkey.get();
}
QByteArray rsa_sign(const QByteArray &data) {
EVP_PKEY *pkey = get_private_key();
if (!pkey) return {};
EVP_MD_CTX *mdctx = EVP_MD_CTX_new();
if (!mdctx) return {};
QByteArray sig(EVP_PKEY_size(pkey), Qt::Uninitialized);
size_t sig_len = sig.size();
int ret = EVP_DigestSignInit(mdctx, nullptr, EVP_sha256(), nullptr, pkey);
ret &= EVP_DigestSignUpdate(mdctx, data.data(), data.size());
ret &= EVP_DigestSignFinal(mdctx, (unsigned char*)sig.data(), &sig_len);
EVP_MD_CTX_free(mdctx);
if (ret != 1) return {};
sig.resize(sig_len);
return sig;
}
QString create_jwt(const QJsonObject &payloads, int expiry) {
QJsonObject header = {{"alg", "RS256"}};
auto t = QDateTime::currentSecsSinceEpoch();
QJsonObject payload = {{"identity", getDongleId().value_or("")}, {"nbf", t}, {"iat", t}, {"exp", t + expiry}};
for (auto it = payloads.begin(); it != payloads.end(); ++it) {
payload.insert(it.key(), it.value());
}
auto b64_opts = QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals;
QString jwt = QJsonDocument(header).toJson(QJsonDocument::Compact).toBase64(b64_opts) + '.' +
QJsonDocument(payload).toJson(QJsonDocument::Compact).toBase64(b64_opts);
auto hash = QCryptographicHash::hash(jwt.toUtf8(), QCryptographicHash::Sha256);
return jwt + "." + rsa_sign(hash).toBase64(b64_opts);
}
} // namespace CommaApi
HttpRequest::HttpRequest(QObject *parent, bool create_jwt, int timeout) : create_jwt(create_jwt), QObject(parent) {
networkTimer = new QTimer(this);
networkTimer->setSingleShot(true);
networkTimer->setInterval(timeout);
connect(networkTimer, &QTimer::timeout, this, &HttpRequest::requestTimeout);
}
bool HttpRequest::active() const {
return reply != nullptr;
}
bool HttpRequest::timeout() const {
return reply && reply->error() == QNetworkReply::OperationCanceledError;
}
void HttpRequest::sendRequest(const QString &requestURL, const HttpRequest::Method method) {
if (active()) {
qDebug() << "HttpRequest is active";
return;
}
QString token;
if (create_jwt) {
token = CommaApi::create_jwt();
} else {
QString token_json = QString::fromStdString(util::read_file(util::getenv("HOME") + "/.comma/auth.json"));
QJsonDocument json_d = QJsonDocument::fromJson(token_json.toUtf8());
token = json_d["access_token"].toString();
}
QNetworkRequest request;
request.setUrl(QUrl(requestURL));
request.setRawHeader("User-Agent", getUserAgent().toUtf8());
if (!token.isEmpty()) {
request.setRawHeader(QByteArray("Authorization"), ("JWT " + token).toUtf8());
}
if (method == HttpRequest::Method::GET) {
reply = nam()->get(request);
} else if (method == HttpRequest::Method::DELETE) {
reply = nam()->deleteResource(request);
}
networkTimer->start();
connect(reply, &QNetworkReply::finished, this, &HttpRequest::requestFinished);
}
void HttpRequest::requestTimeout() {
reply->abort();
}
void HttpRequest::requestFinished() {
networkTimer->stop();
if (reply->error() == QNetworkReply::NoError) {
emit requestDone(reply->readAll(), true, reply->error());
} else {
QString error;
if (reply->error() == QNetworkReply::OperationCanceledError) {
nam()->clearAccessCache();
nam()->clearConnectionCache();
error = "Request timed out";
} else {
error = reply->errorString();
}
emit requestDone(error, false, reply->error());
}
reply->deleteLater();
reply = nullptr;
}
QNetworkAccessManager *HttpRequest::nam() {
static QNetworkAccessManager *networkAccessManager = new QNetworkAccessManager(qApp);
return networkAccessManager;
}
+47
View File
@@ -0,0 +1,47 @@
#pragma once
#include <QJsonObject>
#include <QNetworkReply>
#include <QString>
#include <QTimer>
#include "common/util.h"
namespace CommaApi {
const QString BASE_URL = util::getenv("API_HOST", "https://api.commadotai.com").c_str();
QByteArray rsa_sign(const QByteArray &data);
QString create_jwt(const QJsonObject &payloads = {}, int expiry = 3600);
} // namespace CommaApi
/**
* Makes a request to the request endpoint.
*/
class HttpRequest : public QObject {
Q_OBJECT
public:
enum class Method {GET, DELETE};
explicit HttpRequest(QObject* parent, bool create_jwt = true, int timeout = 20000);
void sendRequest(const QString &requestURL, const Method method = Method::GET);
bool active() const;
bool timeout() const;
signals:
void requestDone(const QString &response, bool success, QNetworkReply::NetworkError error);
protected:
QNetworkReply *reply = nullptr;
private:
static QNetworkAccessManager *nam();
QTimer *networkTimer = nullptr;
bool create_jwt;
private slots:
void requestTimeout();
void requestFinished();
};
+29
View File
@@ -0,0 +1,29 @@
#include "tools/cabana/utils/elidedlabel.h"
#include <QPainter>
#include <QStyleOption>
ElidedLabel::ElidedLabel(QWidget *parent) : ElidedLabel({}, parent) {}
ElidedLabel::ElidedLabel(const QString &text, QWidget *parent) : QLabel(text.trimmed(), parent) {
setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Preferred);
setMinimumWidth(1);
}
void ElidedLabel::resizeEvent(QResizeEvent* event) {
QLabel::resizeEvent(event);
lastText_ = elidedText_ = "";
}
void ElidedLabel::paintEvent(QPaintEvent *event) {
const QString curText = text();
if (curText != lastText_) {
elidedText_ = fontMetrics().elidedText(curText, Qt::ElideRight, contentsRect().width());
lastText_ = curText;
}
QPainter painter(this);
drawFrame(&painter);
QStyleOption opt;
opt.initFrom(this);
style()->drawItemText(&painter, contentsRect(), alignment(), opt.palette, isEnabled(), elidedText_, foregroundRole());
}
+25
View File
@@ -0,0 +1,25 @@
#pragma once
#include <QLabel>
#include <QMouseEvent>
class ElidedLabel : public QLabel {
Q_OBJECT
public:
explicit ElidedLabel(QWidget *parent = 0);
explicit ElidedLabel(const QString &text, QWidget *parent = 0);
signals:
void clicked();
protected:
void paintEvent(QPaintEvent *event) override;
void resizeEvent(QResizeEvent* event) override;
void mouseReleaseEvent(QMouseEvent *event) override {
if (rect().contains(event->pos())) {
emit clicked();
}
}
QString lastText_, elidedText_;
};
+84 -2
View File
@@ -10,11 +10,16 @@
#include <QColor>
#include <QDateTime>
#include <QDir>
#include <QFontDatabase>
#include <QLocale>
#include <QPixmapCache>
#include "selfdrive/ui/qt/util.h"
#include <QSurfaceFormat>
#include <QFileInfo>
#include <QPainterPath>
#include <QTextStream>
#include <QtXml/QDomDocument>
#include "common/util.h"
// SegmentTree
@@ -276,3 +281,80 @@ QString signalToolTip(const cabana::Signal *sig) {
)").arg(sig->name).arg(sig->start_bit).arg(sig->size).arg(sig->msb).arg(sig->lsb)
.arg(sig->is_little_endian ? "Y" : "N").arg(sig->is_signed ? "Y" : "N");
}
void 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();
}
void initApp(int argc, char *argv[], bool disable_hidpi) {
// setup signal handlers to exit gracefully
std::signal(SIGINT, sigTermHandler);
std::signal(SIGTERM, sigTermHandler);
QString app_dir;
#ifdef __APPLE__
// Get the devicePixelRatio, and scale accordingly to maintain 1:1 rendering
QApplication tmp(argc, argv);
app_dir = QCoreApplication::applicationDirPath();
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();
#endif
qputenv("QT_DBL_CLICK_DIST", QByteArray::number(150));
// ensure the current dir matches the exectuable's directory
QDir::setCurrent(app_dir);
setSurfaceFormat();
}
static QHash<QString, QByteArray> load_bootstrap_icons() {
QHash<QString, QByteArray> icons;
QFile f(":/bootstrap-icons.svg");
if (f.open(QIODevice::ReadOnly | QIODevice::Text)) {
QDomDocument xml;
xml.setContent(&f);
QDomNode n = xml.documentElement().firstChild();
while (!n.isNull()) {
QDomElement e = n.toElement();
if (!e.isNull() && e.hasAttribute("id")) {
QString svg_str;
QTextStream stream(&svg_str);
n.save(stream, 0);
svg_str.replace("<symbol", "<svg");
svg_str.replace("</symbol>", "</svg>");
icons[e.attribute("id")] = svg_str.toUtf8();
}
n = n.nextSibling();
}
}
return icons;
}
QPixmap bootstrapPixmap(const QString &id) {
static QHash<QString, QByteArray> icons = load_bootstrap_icons();
QPixmap pixmap;
if (auto it = icons.find(id); it != icons.end()) {
pixmap.loadFromData(it.value(), "svg");
}
return pixmap;
}
+2
View File
@@ -166,3 +166,5 @@ private:
int num_decimals(double num);
QString signalToolTip(const cabana::Signal *sig);
inline QString toHexString(int value) { return QString("0x%1").arg(QString::number(value, 16).toUpper(), 2, '0'); }
void initApp(int argc, char *argv[], bool disable_hidpi = true);
QPixmap bootstrapPixmap(const QString &id);
+1 -1
View File
@@ -134,7 +134,7 @@ void VideoWidget::createSpeedDropdown(QToolBar *toolbar) {
QFont font = speed_btn->font();
font.setBold(true);
speed_btn->setFont(font);
speed_btn->setMinimumWidth(speed_btn->fontMetrics().width("0.05x ") + style()->pixelMetric(QStyle::PM_MenuButtonIndicator));
speed_btn->setMinimumWidth(speed_btn->fontMetrics().horizontalAdvance("0.05x ") + style()->pixelMetric(QStyle::PM_MenuButtonIndicator));
}
QWidget *VideoWidget::createCameraWidget() {
+2 -2
View File
@@ -39,7 +39,7 @@ Decode the stream with `compressed_vipc.py`:
To actually display the stream, run `watch3` in separate terminal:
```cd ~/openpilot/selfdrive/ui/ && ./watch3```
```cd ~/openpilot/selfdrive/ui/ && ./watch3.py```
## compressed_vipc.py usage
```
@@ -62,5 +62,5 @@ options:
## Example:
```
cd ~/openpilot/tools/camerastream && ./compressed_vipc.py comma-ffffffff --cams 0
cd ~/openpilot/selfdrive/ui/ && ./watch3
cd ~/openpilot/selfdrive/ui/ && ./watch3.py
```
+1 -1
View File
@@ -17,7 +17,7 @@ from cereal.messaging import SubMaster
from openpilot.common.basedir import BASEDIR
from openpilot.common.params import Params, UnknownKeyName
from openpilot.common.prefix import OpenpilotPrefix
from openpilot.common.run import managed_proc
from openpilot.common.utils import managed_proc
from openpilot.tools.lib.route import Route
from openpilot.tools.lib.logreader import LogReader
+17 -11
View File
@@ -20,18 +20,25 @@ fi
# Install common packages
function install_ubuntu_common_requirements() {
$SUDO apt-get update
# normal stuff, mostly for the bare docker image
$SUDO apt-get install -y --no-install-recommends \
ca-certificates \
clang \
build-essential \
gcc-arm-none-eabi \
liblzma-dev \
capnproto \
libcapnp-dev \
curl \
libssl-dev \
libcurl4-openssl-dev \
locales \
git \
git-lfs \
xvfb
# TODO: vendor the rest of these in third_party/
$SUDO apt-get install -y --no-install-recommends \
gcc-arm-none-eabi \
capnproto \
libcapnp-dev \
ffmpeg \
libavformat-dev \
libavcodec-dev \
@@ -41,20 +48,16 @@ function install_ubuntu_common_requirements() {
libbz2-dev \
libeigen3-dev \
libffi-dev \
libglew-dev \
libgles2-mesa-dev \
libglfw3-dev \
libglib2.0-0 \
libjpeg-dev \
libqt5charts5-dev \
libncurses5-dev \
libssl-dev \
libusb-1.0-0-dev \
libzmq3-dev \
libzstd-dev \
libsqlite3-dev \
libsystemd-dev \
locales \
opencl-headers \
ocl-icd-libopencl1 \
ocl-icd-opencl-dev \
@@ -64,7 +67,7 @@ function install_ubuntu_common_requirements() {
libqt5serialbus5-dev \
libqt5x11extras5-dev \
libqt5opengl5-dev \
xvfb
gettext
}
# Install Ubuntu 24.04 LTS packages
@@ -74,8 +77,6 @@ function install_ubuntu_lts_latest_requirements() {
$SUDO apt-get install -y --no-install-recommends \
g++-12 \
qtbase5-dev \
qtchooser \
qt5-qmake \
qtbase5-dev-tools \
python3-dev \
python3-venv
@@ -115,6 +116,11 @@ SUBSYSTEM=="usb", ATTRS{idVendor}=="3801", ATTRS{idProduct}=="ddcc", MODE="0666"
SUBSYSTEM=="usb", ATTRS{idVendor}=="3801", ATTRS{idProduct}=="ddee", MODE="0666"
SUBSYSTEM=="usb", ATTRS{idVendor}=="bbaa", ATTRS{idProduct}=="ddcc", MODE="0666"
SUBSYSTEM=="usb", ATTRS{idVendor}=="bbaa", ATTRS{idProduct}=="ddee", MODE="0666"
EOF
# Setup adb udev rules
$SUDO tee /etc/udev/rules.d/50-comma-adb.rules > /dev/null <<EOF
SUBSYSTEM=="usb", ATTR{idVendor}=="04d8", ATTR{idProduct}=="1234", ENV{adb_user}="yes"
EOF
$SUDO udevadm control --reload-rules && $SUDO udevadm trigger || true
+67
View File
@@ -0,0 +1,67 @@
# JotPluggler
JotPluggler is a tool to quickly visualize openpilot logs.
## Usage
```
$ ./jotpluggler/pluggle.py -h
usage: pluggle.py [-h] [--demo] [--layout LAYOUT] [route]
A tool for visualizing openpilot logs.
positional arguments:
route Optional route name to load on startup.
options:
-h, --help show this help message and exit
--demo Use the demo route instead of providing one
--layout LAYOUT Path to YAML layout file to load on startup
```
Example using route name:
`./pluggle.py "a2a0ccea32023010/2023-07-27--13-01-19"`
Examples using segment:
`./pluggle.py "a2a0ccea32023010/2023-07-27--13-01-19/1"`
`./pluggle.py "a2a0ccea32023010/2023-07-27--13-01-19/1/q" # use qlogs`
Example using segment range:
`./pluggle.py "a2a0ccea32023010/2023-07-27--13-01-19/0:1"`
## Demo
For a quick demo, run this command:
`./pluggle.py --demo --layout=layouts/torque-controller.yaml`
## Basic Usage/Features:
- The text box to load a route is a the top left of the page, accepts standard openpilot format routes (e.g. `a2a0ccea32023010/2023-07-27--13-01-19/0:1`, `https://connect.comma.ai/a2a0ccea32023010/2023-07-27--13-01-19/`)
- The Play/Pause button is at the bottom of the screen, you can drag the bottom slider to seek. The timeline in timeseries plots are synced with the slider.
- The Timeseries List sidebar has several dropdowns, the fields each show the field name and value, synced with the timeline (will show N/A until the time of the first message in that field is reached).
- There is a search bar for the timeseries list, you can search for structs or fields, or both by separating with a "/"
- You can drag and drop any numeric/boolean field from the timeseries list into a timeseries panel.
- You can create more panels with the split buttons (buttons with two rectangles, either horizontal or vertical). You can resize the panels by dragging the grip in between any panel.
- You can load and save layouts with the corresponding buttons. Layouts will save all tabs, panels, titles, timeseries, etc.
## Layouts
If you create a layout that's useful for others, consider upstreaming it.
## Plot Interaction Controls
- **Left click and drag within the plot area** to pan X
- Left click and drag on an axis to pan an individual axis (disabled for Y-axis)
- **Scroll in the plot area** to zoom in X axes, Y-axis is autofit
- Scroll on an axis to zoom an individual axis
- **Right click and drag** to select data and zoom into the selected data
- Left click while box selecting to cancel the selection
- **Double left click** to fit all visible data
- Double left click on an axis to fit the individual axis (disabled for Y-axis, always autofit)
- **Double right click** to open the plot context menu
- **Click legend label icons** to show/hide plot items
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:248b71eafd1b42b0861da92114da3d625221cd88121fff01e0514bf3d79ff3b1
size 1364
+9 -1
View File
@@ -5,6 +5,7 @@ import bisect
from collections import defaultdict
from tqdm import tqdm
from openpilot.common.swaglog import cloudlog
from openpilot.selfdrive.test.process_replay.migration import migrate_all
from openpilot.tools.lib.logreader import _LogFileReader, LogReader
@@ -199,7 +200,8 @@ def msgs_to_time_series(msgs):
def _process_segment(segment_identifier: str):
try:
lr = _LogFileReader(segment_identifier, sort_by_time=True)
return msgs_to_time_series(lr)
migrated_msgs = migrate_all(lr)
return msgs_to_time_series(migrated_msgs)
except Exception as e:
cloudlog.warning(f"Warning: Failed to process segment {segment_identifier}: {e}")
return {}, 0.0, 0.0
@@ -312,6 +314,12 @@ class DataManager:
cloudlog.warning(f"Warning: No log segments found for route: {route}")
return
total_segments = len(lr.logreader_identifiers)
with self._lock:
observers = self._observers.copy()
for callback in observers:
callback({'metadata_loaded': True, 'total_segments': total_segments})
num_processes = max(1, multiprocessing.cpu_count() // 2)
with multiprocessing.Pool(processes=num_processes) as pool, tqdm(total=len(lr.logreader_identifiers), desc="Processing Segments") as pbar:
for segment_result, start_time, end_time in pool.imap(_process_segment, lr.logreader_identifiers):
+2 -2
View File
@@ -67,7 +67,7 @@ class DataTree:
with self._ui_lock:
if self._char_width is None:
if size := dpg.get_text_size(" ", font=font):
self._char_width = size[0]
self._char_width = size[0] / 2 # we scale font 2x and downscale to fix hidpi bug
if self._new_data:
self._process_path_change()
@@ -154,7 +154,7 @@ class DataTree:
for i, part in enumerate(parts):
current_path_prefix = f"{current_path_prefix}/{part}" if current_path_prefix else part
if i < len(parts) - 1:
if i < len(parts):
parent_nodes_to_recheck.add(current_node) # for incremental changes from new data
if part not in current_node.children:
current_node.children[part] = DataTreeNode(name=part, full_path=current_path_prefix, parent=current_node)
+227 -22
View File
@@ -5,15 +5,156 @@ from openpilot.tools.jotpluggler.views import TimeSeriesPanel
GRIP_SIZE = 4
MIN_PANE_SIZE = 60
class PlotLayoutManager:
def __init__(self, data_manager: DataManager, playback_manager, worker_manager, scale: float = 1.0):
class LayoutManager:
def __init__(self, data_manager, playback_manager, worker_manager, scale: float = 1.0):
self.data_manager = data_manager
self.playback_manager = playback_manager
self.worker_manager = worker_manager
self.scale = scale
self.container_tag = "plot_layout_container"
self.tab_bar_tag = "tab_bar_container"
self.tab_content_tag = "tab_content_area"
self.active_tab = 0
initial_panel_layout = PanelLayoutManager(data_manager, playback_manager, worker_manager, scale)
self.tabs: dict = {0: {"name": "Tab 1", "panel_layout": initial_panel_layout}}
self._next_tab_id = self.active_tab + 1
def to_dict(self) -> dict:
return {
"tabs": {
str(tab_id): {
"name": tab_data["name"],
"panel_layout": tab_data["panel_layout"].to_dict()
}
for tab_id, tab_data in self.tabs.items()
}
}
def clear_and_load_from_dict(self, data: dict):
tab_ids_to_close = list(self.tabs.keys())
for tab_id in tab_ids_to_close:
self.close_tab(tab_id, force=True)
for tab_id_str, tab_data in data["tabs"].items():
tab_id = int(tab_id_str)
panel_layout = PanelLayoutManager.load_from_dict(
tab_data["panel_layout"], self.data_manager, self.playback_manager,
self.worker_manager, self.scale
)
self.tabs[tab_id] = {
"name": tab_data["name"],
"panel_layout": panel_layout
}
self.active_tab = min(self.tabs.keys()) if self.tabs else 0
self._next_tab_id = max(self.tabs.keys()) + 1 if self.tabs else 1
def create_ui(self, parent_tag: str):
if dpg.does_item_exist(self.container_tag):
dpg.delete_item(self.container_tag)
with dpg.child_window(tag=self.container_tag, parent=parent_tag, border=False, width=-1, height=-1, no_scrollbar=True, no_scroll_with_mouse=True):
self._create_tab_bar()
self._create_tab_content()
dpg.bind_item_theme(self.tab_bar_tag, "tab_bar_theme")
def _create_tab_bar(self):
text_size = int(13 * self.scale)
with dpg.child_window(tag=self.tab_bar_tag, parent=self.container_tag, height=(text_size + 8), border=False, horizontal_scrollbar=True):
with dpg.group(horizontal=True, tag="tab_bar_group"):
for tab_id, tab_data in self.tabs.items():
self._create_tab_ui(tab_id, tab_data["name"])
dpg.add_image_button(texture_tag="plus_texture", callback=self.add_tab, width=text_size, height=text_size, tag="add_tab_button")
dpg.bind_item_theme("add_tab_button", "inactive_tab_theme")
def _create_tab_ui(self, tab_id: int, tab_name: str):
text_size = int(13 * self.scale)
tab_width = int(140 * self.scale)
with dpg.child_window(width=tab_width, height=-1, border=False, no_scrollbar=True, tag=f"tab_window_{tab_id}", parent="tab_bar_group"):
with dpg.group(horizontal=True, tag=f"tab_group_{tab_id}"):
dpg.add_input_text(
default_value=tab_name, width=tab_width - text_size - 16, callback=lambda s, v, u: self.rename_tab(u, v), user_data=tab_id, tag=f"tab_input_{tab_id}"
)
dpg.add_image_button(
texture_tag="x_texture", callback=lambda s, a, u: self.close_tab(u), user_data=tab_id, width=text_size, height=text_size, tag=f"tab_close_{tab_id}"
)
with dpg.item_handler_registry(tag=f"tab_handler_{tab_id}"):
dpg.add_item_clicked_handler(callback=lambda s, a, u: self.switch_tab(u), user_data=tab_id)
dpg.bind_item_handler_registry(f"tab_group_{tab_id}", f"tab_handler_{tab_id}")
theme_tag = "active_tab_theme" if tab_id == self.active_tab else "inactive_tab_theme"
dpg.bind_item_theme(f"tab_window_{tab_id}", theme_tag)
def _create_tab_content(self):
with dpg.child_window(tag=self.tab_content_tag, parent=self.container_tag, border=False, width=-1, height=-1, no_scrollbar=True, no_scroll_with_mouse=True):
if self.active_tab in self.tabs:
active_panel_layout = self.tabs[self.active_tab]["panel_layout"]
active_panel_layout.create_ui()
def add_tab(self):
new_panel_layout = PanelLayoutManager(self.data_manager, self.playback_manager, self.worker_manager, self.scale)
new_tab = {"name": f"Tab {self._next_tab_id + 1}", "panel_layout": new_panel_layout}
self.tabs[self._next_tab_id] = new_tab
self._create_tab_ui(self._next_tab_id, new_tab["name"])
dpg.move_item("add_tab_button", parent="tab_bar_group") # move plus button to end
self.switch_tab(self._next_tab_id)
self._next_tab_id += 1
def close_tab(self, tab_id: int, force = False):
if len(self.tabs) <= 1 and not force:
return # don't allow closing the last tab
tab_to_close = self.tabs[tab_id]
tab_to_close["panel_layout"].destroy_ui()
for suffix in ["window", "group", "input", "close", "handler"]:
tag = f"tab_{suffix}_{tab_id}"
if dpg.does_item_exist(tag):
dpg.delete_item(tag)
del self.tabs[tab_id]
if self.active_tab == tab_id and self.tabs: # switch to another tab if we closed the active one
self.active_tab = next(iter(self.tabs.keys()))
self._switch_tab_content()
dpg.bind_item_theme(f"tab_window_{self.active_tab}", "active_tab_theme")
def switch_tab(self, tab_id: int):
if tab_id == self.active_tab or tab_id not in self.tabs:
return
current_panel_layout = self.tabs[self.active_tab]["panel_layout"]
current_panel_layout.destroy_ui()
dpg.bind_item_theme(f"tab_window_{self.active_tab}", "inactive_tab_theme") # deactivate old tab
self.active_tab = tab_id
dpg.bind_item_theme(f"tab_window_{tab_id}", "active_tab_theme") # activate new tab
self._switch_tab_content()
def _switch_tab_content(self):
dpg.delete_item(self.tab_content_tag, children_only=True)
active_panel_layout = self.tabs[self.active_tab]["panel_layout"]
active_panel_layout.create_ui()
active_panel_layout.update_all_panels()
def rename_tab(self, tab_id: int, new_name: str):
if tab_id in self.tabs:
self.tabs[tab_id]["name"] = new_name
def update_all_panels(self):
self.tabs[self.active_tab]["panel_layout"].update_all_panels()
def on_viewport_resize(self):
self.tabs[self.active_tab]["panel_layout"].on_viewport_resize()
class PanelLayoutManager:
def __init__(self, data_manager: DataManager, playback_manager, worker_manager, scale: float = 1.0):
self.data_manager = data_manager
self.playback_manager = playback_manager
self.worker_manager = worker_manager
self.scale = scale
self.active_panels: list = []
self.parent_tag = "tab_content_area"
self._queue_resize = False
self._created_handler_tags: set[str] = set()
self.grip_size = int(GRIP_SIZE * self.scale)
self.min_pane_size = int(MIN_PANE_SIZE * self.scale)
@@ -21,13 +162,69 @@ class PlotLayoutManager:
initial_panel = TimeSeriesPanel(data_manager, playback_manager, worker_manager)
self.layout: dict = {"type": "panel", "panel": initial_panel}
def create_ui(self, parent_tag: str):
if dpg.does_item_exist(self.container_tag):
dpg.delete_item(self.container_tag)
def to_dict(self) -> dict:
return self._layout_to_dict(self.layout)
with dpg.child_window(tag=self.container_tag, parent=parent_tag, border=False, width=-1, height=-1, no_scrollbar=True, no_scroll_with_mouse=True):
container_width, container_height = dpg.get_item_rect_size(self.container_tag)
self._create_ui_recursive(self.layout, self.container_tag, [], container_width, container_height)
def _layout_to_dict(self, layout: dict) -> dict:
if layout["type"] == "panel":
return {
"type": "panel",
"panel": layout["panel"].to_dict()
}
else: # split
return {
"type": "split",
"orientation": layout["orientation"],
"proportions": layout["proportions"],
"children": [self._layout_to_dict(child) for child in layout["children"]]
}
@classmethod
def load_from_dict(cls, data: dict, data_manager, playback_manager, worker_manager, scale: float = 1.0):
manager = cls(data_manager, playback_manager, worker_manager, scale)
manager.layout = manager._dict_to_layout(data)
return manager
def _dict_to_layout(self, data: dict) -> dict:
if data["type"] == "panel":
panel_data = data["panel"]
if panel_data["type"] == "timeseries":
panel = TimeSeriesPanel.load_from_dict(
panel_data, self.data_manager, self.playback_manager, self.worker_manager
)
return {"type": "panel", "panel": panel}
else:
# Handle future panel types here or make a general mapping
raise ValueError(f"Unknown panel type: {panel_data['type']}")
else: # split
return {
"type": "split",
"orientation": data["orientation"],
"proportions": data["proportions"],
"children": [self._dict_to_layout(child) for child in data["children"]]
}
def create_ui(self):
self.active_panels.clear()
if dpg.does_item_exist(self.parent_tag):
dpg.delete_item(self.parent_tag, children_only=True)
self._cleanup_all_handlers()
container_width, container_height = dpg.get_item_rect_size(self.parent_tag)
if container_width == 0 and container_height == 0:
self._queue_resize = True
self._create_ui_recursive(self.layout, self.parent_tag, [], container_width, container_height)
def destroy_ui(self):
self._cleanup_ui_recursive(self.layout, [])
self._cleanup_all_handlers()
self.active_panels.clear()
def _cleanup_all_handlers(self):
for handler_tag in list(self._created_handler_tags):
if dpg.does_item_exist(handler_tag):
dpg.delete_item(handler_tag)
self._created_handler_tags.clear()
def _create_ui_recursive(self, layout: dict, parent_tag: str, path: list[int], width: int, height: int):
if layout["type"] == "panel":
@@ -35,18 +232,19 @@ class PlotLayoutManager:
else:
self._create_split_ui(layout, parent_tag, path, width, height)
def _create_panel_ui(self, layout: dict, parent_tag: str, path: list[int], width: int, height:int):
def _create_panel_ui(self, layout: dict, parent_tag: str, path: list[int], width: int, height: int):
panel_tag = self._path_to_tag(path, "panel")
panel = layout["panel"]
self.active_panels.append(panel)
text_size = int(13 * self.scale)
bar_height = (text_size+24) if width < int(279 * self.scale + 80) else (text_size+8) # adjust height to allow for scrollbar
bar_height = (text_size + 24) if width < int(329 * self.scale + 64) else (text_size + 8) # adjust height to allow for scrollbar
with dpg.child_window(parent=parent_tag, border=True, width=-1, height=-1, no_scrollbar=True):
with dpg.child_window(parent=parent_tag, border=False, width=-1, height=-1, no_scrollbar=True):
with dpg.group(horizontal=True):
with dpg.child_window(tag=panel_tag, width=-(text_size + 16), height=bar_height, horizontal_scrollbar=True, no_scroll_with_mouse=True, border=False):
with dpg.group(horizontal=True):
dpg.add_input_text(default_value=panel.title, width=int(100 * self.scale), callback=lambda s, v: setattr(panel, "title", v))
# if you change the widths make sure to change the sum of widths (currently 329 * scale)
dpg.add_input_text(default_value=panel.title, width=int(150 * self.scale), callback=lambda s, v: setattr(panel, "title", v))
dpg.add_combo(items=["Time Series"], default_value="Time Series", width=int(100 * self.scale))
dpg.add_button(label="Clear", callback=lambda: self.clear_panel(panel), width=int(40 * self.scale))
dpg.add_image_button(texture_tag="split_h_texture", callback=lambda: self.split_panel(path, 0), width=text_size, height=text_size)
@@ -67,7 +265,7 @@ class PlotLayoutManager:
for i, child_layout in enumerate(layout["children"]):
child_path = path + [i]
container_tag = self._path_to_tag(child_path, "container")
pane_width, pane_height = [(pane_sizes[i], -1), (-1, pane_sizes[i])][orientation] # fill 2nd dim up to the border
pane_width, pane_height = [(pane_sizes[i], -1), (-1, pane_sizes[i])][orientation] # fill 2nd dim up to the border
with dpg.child_window(tag=container_tag, width=pane_width, height=pane_height, border=False, no_scrollbar=True):
child_width, child_height = [(pane_sizes[i], height), (width, pane_sizes[i])][orientation]
self._create_ui_recursive(child_layout, container_tag, child_path, child_width, child_height)
@@ -137,7 +335,7 @@ class PlotLayoutManager:
if path:
container_tag = self._path_to_tag(path, "container")
else: # Root update
container_tag = self.container_tag
container_tag = self.parent_tag
self._cleanup_ui_recursive(layout, path)
dpg.delete_item(container_tag, children_only=True)
@@ -155,11 +353,16 @@ class PlotLayoutManager:
handler_tag = f"{self._path_to_tag(path, f'grip_{i}')}_handler"
if dpg.does_item_exist(handler_tag):
dpg.delete_item(handler_tag)
self._created_handler_tags.discard(handler_tag)
for i, child in enumerate(layout["children"]):
self._cleanup_ui_recursive(child, path + [i])
def update_all_panels(self):
if self._queue_resize:
if (size := dpg.get_item_rect_size(self.parent_tag)) != [0, 0]:
self._queue_resize = False
self._resize_splits_recursive(self.layout, [], *size)
for panel in self.active_panels:
panel.update()
@@ -181,17 +384,17 @@ class PlotLayoutManager:
dpg.configure_item(container_tag, **{size_properties[orientation]: pane_sizes[i]})
child_width, child_height = [(pane_sizes[i], available_sizes[1]), (available_sizes[0], pane_sizes[i])][orientation]
self._resize_splits_recursive(child_layout, child_path, child_width, child_height)
else: # leaf node/panel - adjust bar height to allow for scrollbar
else: # leaf node/panel - adjust bar height to allow for scrollbar
panel_tag = self._path_to_tag(path, "panel")
if width is not None and width < int(279 * self.scale + 80): # scaled widths of the elements in top bar + fixed 8 padding on left and right of each item
dpg.configure_item(panel_tag, height=(int(13*self.scale) + 24))
if width is not None and width < int(329 * self.scale + 64): # scaled widths of the elements in top bar + fixed 8 padding on left and right of each item
dpg.configure_item(panel_tag, height=(int(13 * self.scale) + 24))
else:
dpg.configure_item(panel_tag, height=(int(13*self.scale) + 8))
dpg.configure_item(panel_tag, height=(int(13 * self.scale) + 8))
def _get_split_geometry(self, layout: dict, available_size: tuple[int, int]) -> tuple[int, int, list[int]]:
orientation = layout["orientation"]
num_grips = len(layout["children"]) - 1
usable_size = max(self.min_pane_size, available_size[orientation] - (num_grips * (self.grip_size + 8 * (2-orientation)))) # approximate, scaling is weird
usable_size = max(self.min_pane_size, available_size[orientation] - (num_grips * (self.grip_size + 8 * (2 - orientation)))) # approximate, scaling is weird
pane_sizes = [max(self.min_pane_size, int(usable_size * prop)) for prop in layout["proportions"]]
return orientation, usable_size, pane_sizes
@@ -217,16 +420,18 @@ class PlotLayoutManager:
def _create_grip(self, parent_tag: str, path: list[int], grip_index: int, orientation: int):
grip_tag = self._path_to_tag(path, f"grip_{grip_index}")
handler_tag = f"{grip_tag}_handler"
width, height = [(self.grip_size, -1), (-1, self.grip_size)][orientation]
with dpg.child_window(tag=grip_tag, parent=parent_tag, width=width, height=height, no_scrollbar=True, border=False):
button_tag = dpg.add_button(label="", width=-1, height=-1)
with dpg.item_handler_registry(tag=f"{grip_tag}_handler"):
with dpg.item_handler_registry(tag=handler_tag):
user_data = (path, grip_index, orientation)
dpg.add_item_active_handler(callback=self._on_grip_drag, user_data=user_data)
dpg.add_item_deactivated_handler(callback=self._on_grip_end, user_data=user_data)
dpg.bind_item_handler_registry(button_tag, f"{grip_tag}_handler")
dpg.bind_item_handler_registry(button_tag, handler_tag)
self._created_handler_tags.add(handler_tag)
def _on_grip_drag(self, sender, app_data, user_data):
path, grip_index, orientation = user_data
@@ -0,0 +1,128 @@
tabs:
'0':
name: Lateral Plan Conformance
panel_layout:
type: split
orientation: 1
proportions:
- 0.3333333333333333
- 0.3333333333333333
- 0.3333333333333333
children:
- type: panel
panel:
type: timeseries
title: desired vs actual
series_paths:
- controlsState/lateralControlState/torqueState/desiredLateralAccel
- controlsState/lateralControlState/torqueState/actualLateralAccel
- type: panel
panel:
type: timeseries
title: ff vs output
series_paths:
- controlsState/lateralControlState/torqueState/f
- carState/steeringPressed
- carControl/actuators/torque
- type: panel
panel:
type: timeseries
title: vehicle speed
series_paths:
- carState/vEgo
'1':
name: Actuator Performance
panel_layout:
type: split
orientation: 1
proportions:
- 0.3333333333333333
- 0.3333333333333333
- 0.3333333333333333
children:
- type: panel
panel:
type: timeseries
title: calc vs learned latAccelFactor
series_paths:
- liveTorqueParameters/latAccelFactorFiltered
- liveTorqueParameters/latAccelFactorRaw
- carParams/lateralTuning/torque/latAccelFactor
- type: panel
panel:
type: timeseries
title: learned latAccelOffset
series_paths:
- liveTorqueParameters/latAccelOffsetRaw
- liveTorqueParameters/latAccelOffsetFiltered
- type: panel
panel:
type: timeseries
title: calc vs learned friction
series_paths:
- liveTorqueParameters/frictionCoefficientFiltered
- liveTorqueParameters/frictionCoefficientRaw
- carParams/lateralTuning/torque/friction
'2':
name: Vehicle Dynamics
panel_layout:
type: split
orientation: 1
proportions:
- 0.3333333333333333
- 0.3333333333333333
- 0.3333333333333333
children:
- type: panel
panel:
type: timeseries
title: initial vs learned steerRatio
series_paths:
- carParams/steerRatio
- liveParameters/steerRatio
- type: panel
panel:
type: timeseries
title: initial vs learned tireStiffnessFactor
series_paths:
- carParams/tireStiffnessFactor
- liveParameters/stiffnessFactor
- type: panel
panel:
type: timeseries
title: live steering angle offsets
series_paths:
- liveParameters/angleOffsetDeg
- liveParameters/angleOffsetAverageDeg
'3':
name: Controller PIF Terms
panel_layout:
type: split
orientation: 1
proportions:
- 0.3333333333333333
- 0.3333333333333333
- 0.3333333333333333
children:
- type: panel
panel:
type: timeseries
title: ff vs output
series_paths:
- carControl/actuators/torque
- controlsState/lateralControlState/torqueState/f
- carState/steeringPressed
- type: panel
panel:
type: timeseries
title: PIF terms
series_paths:
- controlsState/lateralControlState/torqueState/f
- controlsState/lateralControlState/torqueState/p
- controlsState/lateralControlState/torqueState/i
- type: panel
panel:
type: timeseries
title: road roll angle
series_paths:
- liveParameters/roll
+135 -17
View File
@@ -7,10 +7,12 @@ import dearpygui.dearpygui as dpg
import multiprocessing
import uuid
import signal
import yaml # type: ignore
from openpilot.common.swaglog import cloudlog
from openpilot.common.basedir import BASEDIR
from openpilot.tools.jotpluggler.data import DataManager
from openpilot.tools.jotpluggler.datatree import DataTree
from openpilot.tools.jotpluggler.layout import PlotLayoutManager
from openpilot.tools.jotpluggler.layout import LayoutManager
DEMO_ROUTE = "a2a0ccea32023010|2023-07-27--13-01-19"
@@ -64,6 +66,11 @@ class PlaybackManager:
self.is_playing = False
self.current_time_s = 0.0
self.duration_s = 0.0
self.num_segments = 0
self.x_axis_bounds = (0.0, 0.0) # (min_time, max_time)
self.x_axis_observers = [] # callbacks for x-axis changes
self._updating_x_axis = False
def set_route_duration(self, duration: float):
self.duration_s = duration
@@ -87,6 +94,33 @@ class PlaybackManager:
dpg.configure_item("play_pause_button", texture_tag="play_texture")
return self.current_time_s
def set_x_axis_bounds(self, min_time: float, max_time: float, source_panel=None):
if self._updating_x_axis:
return
new_bounds = (min_time, max_time)
if new_bounds == self.x_axis_bounds:
return
self.x_axis_bounds = new_bounds
self._updating_x_axis = True # prevent recursive updates
try:
for callback in self.x_axis_observers:
try:
callback(min_time, max_time, source_panel)
except Exception as e:
print(f"Error in x-axis sync callback: {e}")
finally:
self._updating_x_axis = False
def add_x_axis_observer(self, callback):
if callback not in self.x_axis_observers:
self.x_axis_observers.append(callback)
def remove_x_axis_observer(self, callback):
if callback in self.x_axis_observers:
self.x_axis_observers.remove(callback)
class MainController:
def __init__(self, scale: float = 1.0):
@@ -96,29 +130,45 @@ class MainController:
self.worker_manager = WorkerManager()
self._create_global_themes()
self.data_tree = DataTree(self.data_manager, self.playback_manager)
self.plot_layout_manager = PlotLayoutManager(self.data_manager, self.playback_manager, self.worker_manager, scale=self.scale)
self.layout_manager = LayoutManager(self.data_manager, self.playback_manager, self.worker_manager, scale=self.scale)
self.data_manager.add_observer(self.on_data_loaded)
self._total_segments = 0
def _create_global_themes(self):
with dpg.theme(tag="global_line_theme"):
with dpg.theme(tag="line_theme"):
with dpg.theme_component(dpg.mvLineSeries):
scaled_thickness = max(1.0, self.scale)
dpg.add_theme_style(dpg.mvPlotStyleVar_LineWeight, scaled_thickness, category=dpg.mvThemeCat_Plots)
with dpg.theme(tag="global_timeline_theme"):
with dpg.theme(tag="timeline_theme"):
with dpg.theme_component(dpg.mvInfLineSeries):
scaled_thickness = max(1.0, self.scale)
dpg.add_theme_style(dpg.mvPlotStyleVar_LineWeight, scaled_thickness, category=dpg.mvThemeCat_Plots)
dpg.add_theme_color(dpg.mvPlotCol_Line, (255, 0, 0, 128), category=dpg.mvThemeCat_Plots)
for tag, color in (("active_tab_theme", (37, 37, 38, 255)), ("inactive_tab_theme", (70, 70, 75, 255))):
with dpg.theme(tag=tag):
for cmp, target in ((dpg.mvChildWindow, dpg.mvThemeCol_ChildBg), (dpg.mvInputText, dpg.mvThemeCol_FrameBg), (dpg.mvImageButton, dpg.mvThemeCol_Button)):
with dpg.theme_component(cmp):
dpg.add_theme_color(target, color)
with dpg.theme(tag="tab_bar_theme"):
with dpg.theme_component(dpg.mvChildWindow):
dpg.add_theme_color(dpg.mvThemeCol_ChildBg, (51, 51, 55, 255))
def on_data_loaded(self, data: dict):
duration = data.get('duration', 0.0)
self.playback_manager.set_route_duration(duration)
if data.get('reset'):
if data.get('metadata_loaded'):
self.playback_manager.num_segments = data.get('total_segments', 0)
self._total_segments = data.get('total_segments', 0)
dpg.set_value("load_status", f"Loading... 0/{self._total_segments} segments processed")
elif data.get('reset'):
self.playback_manager.current_time_s = 0.0
self.playback_manager.duration_s = 0.0
self.playback_manager.is_playing = False
self._total_segments = 0
dpg.set_value("load_status", "Loading...")
dpg.set_value("timeline_slider", 0.0)
dpg.configure_item("timeline_slider", max_value=0.0)
@@ -130,35 +180,93 @@ class MainController:
dpg.configure_item("load_button", enabled=True)
elif data.get('segment_added'):
segment_count = data.get('segment_count', 0)
dpg.set_value("load_status", f"Loading... {segment_count} segments processed")
dpg.set_value("load_status", f"Loading... {segment_count}/{self._total_segments} segments processed")
dpg.configure_item("timeline_slider", max_value=duration)
def save_layout_to_yaml(self, filepath: str):
layout_dict = self.layout_manager.to_dict()
with open(filepath, 'w') as f:
yaml.dump(layout_dict, f, default_flow_style=False, sort_keys=False)
def load_layout_from_yaml(self, filepath: str):
with open(filepath) as f:
layout_dict = yaml.safe_load(f)
self.layout_manager.clear_and_load_from_dict(layout_dict)
self.layout_manager.create_ui("main_plot_area")
def save_layout_dialog(self):
if dpg.does_item_exist("save_layout_dialog"):
dpg.delete_item("save_layout_dialog")
with dpg.file_dialog(
callback=self._save_layout_callback, tag="save_layout_dialog", width=int(700 * self.scale), height=int(400 * self.scale),
default_filename="layout", default_path=os.path.join(os.path.dirname(os.path.realpath(__file__)), "layouts")
):
dpg.add_file_extension(".yaml")
def load_layout_dialog(self):
if dpg.does_item_exist("load_layout_dialog"):
dpg.delete_item("load_layout_dialog")
with dpg.file_dialog(
callback=self._load_layout_callback, tag="load_layout_dialog", width=int(700 * self.scale), height=int(400 * self.scale),
default_path=os.path.join(os.path.dirname(os.path.realpath(__file__)), "layouts")
):
dpg.add_file_extension(".yaml")
def _save_layout_callback(self, sender, app_data):
filepath = app_data['file_path_name']
try:
self.save_layout_to_yaml(filepath)
dpg.set_value("load_status", f"Layout saved to {os.path.basename(filepath)}")
except Exception:
dpg.set_value("load_status", "Error saving layout")
cloudlog.exception(f"Error saving layout to {filepath}")
dpg.delete_item("save_layout_dialog")
def _load_layout_callback(self, sender, app_data):
filepath = app_data['file_path_name']
try:
self.load_layout_from_yaml(filepath)
dpg.set_value("load_status", f"Layout loaded from {os.path.basename(filepath)}")
except Exception:
dpg.set_value("load_status", "Error loading layout")
cloudlog.exception(f"Error loading layout from {filepath}:")
dpg.delete_item("load_layout_dialog")
def setup_ui(self):
with dpg.texture_registry():
script_dir = os.path.dirname(os.path.realpath(__file__))
for image in ["play", "pause", "x", "split_h", "split_v"]:
for image in ["play", "pause", "x", "split_h", "split_v", "plus"]:
texture = dpg.load_image(os.path.join(script_dir, "assets", f"{image}.png"))
dpg.add_static_texture(width=texture[0], height=texture[1], default_value=texture[3], tag=f"{image}_texture")
with dpg.window(tag="Primary Window"):
with dpg.group(horizontal=True):
# Left panel - Data tree
with dpg.child_window(label="Sidebar", width=300 * self.scale, tag="sidebar_window", border=True, resizable_x=True):
with dpg.child_window(label="Sidebar", width=int(300 * self.scale), tag="sidebar_window", border=True, resizable_x=True):
with dpg.group(horizontal=True):
dpg.add_input_text(tag="route_input", width=-75 * self.scale, hint="Enter route name...")
dpg.add_input_text(tag="route_input", width=int(-75 * self.scale), hint="Enter route name...")
dpg.add_button(label="Load", callback=self.load_route, tag="load_button", width=-1)
dpg.add_text("Ready to load route", tag="load_status")
dpg.add_separator()
with dpg.table(header_row=False, policy=dpg.mvTable_SizingStretchProp):
dpg.add_table_column(init_width_or_weight=0.5)
dpg.add_table_column(init_width_or_weight=0.5)
with dpg.table_row():
dpg.add_button(label="Save Layout", callback=self.save_layout_dialog, width=-1)
dpg.add_button(label="Load Layout", callback=self.load_layout_dialog, width=-1)
dpg.add_separator()
self.data_tree.create_ui("sidebar_window")
# Right panel - Plots and timeline
with dpg.group(tag="right_panel"):
with dpg.child_window(label="Plot Window", border=True, height=-(32 + 13 * self.scale), tag="main_plot_area"):
self.plot_layout_manager.create_ui("main_plot_area")
with dpg.child_window(label="Plot Window", border=True, height=int(-(32 + 13 * self.scale)), tag="main_plot_area"):
self.layout_manager.create_ui("main_plot_area")
with dpg.child_window(label="Timeline", border=True):
with dpg.table(header_row=False, borders_innerH=False, borders_innerV=False, borders_outerH=False, borders_outerV=False):
with dpg.table(header_row=False):
btn_size = int(13 * self.scale)
dpg.add_table_column(width_fixed=True, init_width_or_weight=(btn_size + 8)) # Play button
dpg.add_table_column(width_stretch=True) # Timeline slider
@@ -174,7 +282,7 @@ class MainController:
dpg.set_primary_window("Primary Window", True)
def on_plot_resize(self, sender, app_data, user_data):
self.plot_layout_manager.on_viewport_resize()
self.layout_manager.on_viewport_resize()
def load_route(self):
route_name = dpg.get_value("route_input").strip()
@@ -196,7 +304,7 @@ class MainController:
if not dpg.is_item_active("timeline_slider"):
dpg.set_value("timeline_slider", new_time)
self.plot_layout_manager.update_all_panels()
self.layout_manager.update_all_panels()
dpg.set_value("fps_counter", f"{dpg.get_frame_rate():.1f} FPS")
@@ -204,7 +312,7 @@ class MainController:
self.worker_manager.shutdown()
def main(route_to_load=None):
def main(route_to_load=None, layout_to_load=None):
dpg.create_context()
# TODO: find better way of calculating display scaling
@@ -215,8 +323,9 @@ def main(route_to_load=None):
scale = 1
with dpg.font_registry():
default_font = dpg.add_font(os.path.join(BASEDIR, "selfdrive/assets/fonts/JetBrainsMono-Medium.ttf"), int(13 * scale))
default_font = dpg.add_font(os.path.join(BASEDIR, "selfdrive/assets/fonts/JetBrainsMono-Medium.ttf"), int(13 * scale * 2)) # 2x then scale for hidpi
dpg.bind_font(default_font)
dpg.set_global_font_scale(0.5)
viewport_width, viewport_height = int(1200 * scale), int(800 * scale)
mouse_x, mouse_y = pyautogui.position() # TODO: find better way of creating the window where the user is (default dpg behavior annoying on multiple displays)
@@ -228,6 +337,14 @@ def main(route_to_load=None):
controller = MainController(scale=scale)
controller.setup_ui()
if layout_to_load:
try:
controller.load_layout_from_yaml(layout_to_load)
print(f"Loaded layout from {layout_to_load}")
except Exception as e:
print(f"Failed to load layout from {layout_to_load}: {e}")
cloudlog.exception(f"Error loading layout from {layout_to_load}")
if route_to_load:
dpg.set_value("route_input", route_to_load)
controller.load_route()
@@ -246,7 +363,8 @@ def main(route_to_load=None):
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="A tool for visualizing openpilot logs.")
parser.add_argument("--demo", action="store_true", help="Use the demo route instead of providing one")
parser.add_argument("--layout", type=str, help="Path to YAML layout file to load on startup")
parser.add_argument("route", nargs='?', default=None, help="Optional route name to load on startup.")
args = parser.parse_args()
route = DEMO_ROUTE if args.demo else args.route
main(route_to_load=route)
main(route_to_load=route, layout_to_load=args.layout)
+110 -11
View File
@@ -33,6 +33,15 @@ class ViewPanel(ABC):
def update(self):
pass
@abstractmethod
def to_dict(self) -> dict:
pass
@classmethod
@abstractmethod
def load_from_dict(cls, data: dict, data_manager, playback_manager, worker_manager):
pass
class TimeSeriesPanel(ViewPanel):
def __init__(self, data_manager, playback_manager, worker_manager, panel_id: str | None = None):
@@ -51,18 +60,37 @@ class TimeSeriesPanel(ViewPanel):
self._update_lock = threading.RLock()
self._results_deque: deque[tuple[str, list, list]] = deque()
self._new_data = False
self._last_x_limits = (0.0, 0.0)
self._queued_x_sync: tuple | None = None
self._queued_reallow_x_zoom = False
self._total_segments = self.playback_manager.num_segments
def to_dict(self) -> dict:
return {
"type": "timeseries",
"title": self.title,
"series_paths": list(self._series_data.keys())
}
@classmethod
def load_from_dict(cls, data: dict, data_manager, playback_manager, worker_manager):
panel = cls(data_manager, playback_manager, worker_manager)
panel.title = data.get("title", "Time Series Plot")
panel._series_data = {path: (np.array([]), np.array([])) for path in data.get("series_paths", [])}
return panel
def create_ui(self, parent_tag: str):
self.data_manager.add_observer(self.on_data_loaded)
self.playback_manager.add_x_axis_observer(self._on_x_axis_sync)
with dpg.plot(height=-1, width=-1, tag=self.plot_tag, parent=parent_tag, drop_callback=self._on_series_drop, payload_type="TIMESERIES_PAYLOAD"):
dpg.add_plot_legend()
dpg.add_plot_axis(dpg.mvXAxis, no_label=True, tag=self.x_axis_tag)
dpg.add_plot_axis(dpg.mvYAxis, no_label=True, tag=self.y_axis_tag)
timeline_series_tag = dpg.add_inf_line_series(x=[0], label="Timeline", parent=self.y_axis_tag, tag=self.timeline_indicator_tag)
dpg.bind_item_theme(timeline_series_tag, "global_timeline_theme")
dpg.bind_item_theme(timeline_series_tag, "timeline_theme")
for series_path in list(self._series_data.keys()):
self.add_series(series_path)
self._new_data = True
self._queued_x_sync = self.playback_manager.x_axis_bounds
self._ui_created = True
def update(self):
@@ -70,11 +98,42 @@ class TimeSeriesPanel(ViewPanel):
if not self._ui_created:
return
if self._queued_x_sync:
min_time, max_time = self._queued_x_sync
self._queued_x_sync = None
dpg.set_axis_limits(self.x_axis_tag, min_time, max_time)
self._last_x_limits = (min_time, max_time)
self._fit_y_axis(min_time, max_time)
self._queued_reallow_x_zoom = True # must wait a frame before allowing user changes so that axis limits take effect
return
if self._queued_reallow_x_zoom:
self._queued_reallow_x_zoom = False
if tuple(dpg.get_axis_limits(self.x_axis_tag)) == self._last_x_limits:
dpg.set_axis_limits_auto(self.x_axis_tag)
else:
self._queued_x_sync = self._last_x_limits # retry, likely too early
return
if self._new_data: # handle new data in main thread
self._new_data = False
if self._total_segments > 0:
dpg.set_axis_limits_constraints(self.x_axis_tag, -10, self._total_segments * 60 + 10)
self._fit_y_axis(*dpg.get_axis_limits(self.x_axis_tag))
for series_path in list(self._series_data.keys()):
self.add_series(series_path, update=True)
current_limits = dpg.get_axis_limits(self.x_axis_tag)
# downsample if plot zoom changed significantly
plot_duration = current_limits[1] - current_limits[0]
if plot_duration > self._last_plot_duration * 2 or plot_duration < self._last_plot_duration * 0.5:
self._downsample_all_series(plot_duration)
# sync x-axis if changed by user
if self._last_x_limits != current_limits:
self.playback_manager.set_x_axis_bounds(current_limits[0], current_limits[1], source_panel=self)
self._last_x_limits = current_limits
self._fit_y_axis(current_limits[0], current_limits[1])
while self._results_deque: # handle downsampled results in main thread
results = self._results_deque.popleft()
for series_path, downsampled_time, downsampled_values in results:
@@ -96,10 +155,45 @@ class TimeSeriesPanel(ViewPanel):
if dpg.does_item_exist(series_tag):
dpg.configure_item(series_tag, label=f"{series_path}: {formatted_value}")
# downsample if plot zoom changed significantly
plot_duration = dpg.get_axis_limits(self.x_axis_tag)[1] - dpg.get_axis_limits(self.x_axis_tag)[0]
if plot_duration > self._last_plot_duration * 2 or plot_duration < self._last_plot_duration * 0.5:
self._downsample_all_series(plot_duration)
def _on_x_axis_sync(self, min_time: float, max_time: float, source_panel):
with self._update_lock:
if source_panel != self:
self._queued_x_sync = (min_time, max_time)
def _fit_y_axis(self, x_min: float, x_max: float):
if not self._series_data:
dpg.set_axis_limits(self.y_axis_tag, -1, 1)
return
global_min = float('inf')
global_max = float('-inf')
found_data = False
for time_array, value_array in self._series_data.values():
if len(time_array) == 0:
continue
start_idx, end_idx = np.searchsorted(time_array, [x_min, x_max])
end_idx = min(end_idx, len(time_array) - 1)
if start_idx <= end_idx:
y_slice = value_array[start_idx:end_idx + 1]
series_min, series_max = np.min(y_slice), np.max(y_slice)
global_min = min(global_min, series_min)
global_max = max(global_max, series_max)
found_data = True
if not found_data:
dpg.set_axis_limits(self.y_axis_tag, -1, 1)
return
if global_min == global_max:
padding = max(abs(global_min) * 0.1, 1.0)
y_min, y_max = global_min - padding, global_max + padding
else:
range_size = global_max - global_min
padding = range_size * 0.1
y_min, y_max = global_min - padding, global_max + padding
dpg.set_axis_limits(self.y_axis_tag, y_min, y_max)
def _downsample_all_series(self, plot_duration):
plot_width = dpg.get_item_rect_size(self.plot_tag)[0]
@@ -136,15 +230,15 @@ class TimeSeriesPanel(ViewPanel):
dpg.set_value(series_tag, (time_array, value_array.astype(float)))
else:
line_series_tag = dpg.add_line_series(x=time_array, y=value_array.astype(float), label=series_path, parent=self.y_axis_tag, tag=series_tag)
dpg.bind_item_theme(line_series_tag, "global_line_theme")
dpg.fit_axis_data(self.x_axis_tag)
dpg.fit_axis_data(self.y_axis_tag)
dpg.bind_item_theme(line_series_tag, "line_theme")
self._fit_y_axis(*dpg.get_axis_limits(self.x_axis_tag))
plot_duration = dpg.get_axis_limits(self.x_axis_tag)[1] - dpg.get_axis_limits(self.x_axis_tag)[0]
self._downsample_all_series(plot_duration)
def destroy_ui(self):
with self._update_lock:
self.data_manager.remove_observer(self.on_data_loaded)
self.playback_manager.remove_x_axis_observer(self._on_x_axis_sync)
if dpg.does_item_exist(self.plot_tag):
dpg.delete_item(self.plot_tag)
self._ui_created = False
@@ -165,7 +259,12 @@ class TimeSeriesPanel(ViewPanel):
del self._series_data[series_path]
def on_data_loaded(self, data: dict):
self._new_data = True
with self._update_lock:
self._new_data = True
if data.get('metadata_loaded'):
self._total_segments = data.get('total_segments', 0)
limits = (-10, self._total_segments * 60 + 10)
self._queued_x_sync = limits
def _on_series_drop(self, sender, app_data, user_data):
self.add_series(app_data)
+1 -1
View File
@@ -2,7 +2,7 @@ import os
import posixpath
import socket
from functools import cache
from openpilot.common.retry import retry
from openpilot.common.utils import retry
from urllib.parse import urlparse
from openpilot.tools.lib.url_file import URLFile
+1
View File
@@ -72,6 +72,7 @@ class TestLogReader:
(f"https://useradmin.comma.ai/?onebox={TEST_ROUTE.replace('/', '|')}", ALL_SEGS),
(f"https://useradmin.comma.ai/?onebox={TEST_ROUTE.replace('/', '%7C')}", ALL_SEGS),
])
@pytest.mark.skip("this got flaky. internet tests are stupid.")
def test_indirect_parsing(self, identifier, expected):
parsed = parse_indirect(identifier)
sr = SegmentRange(parsed)
+1 -1
View File
@@ -7,7 +7,7 @@ from urllib3 import PoolManager, Retry
from urllib3.response import BaseHTTPResponse
from urllib3.util import Timeout
from openpilot.common.file_helpers import atomic_write_in_dir
from openpilot.common.utils import atomic_write_in_dir
from openpilot.system.hardware.hw import Paths
# Cache chunk size
-3
View File
@@ -34,13 +34,11 @@ fi
brew bundle --file=- <<-EOS
brew "git-lfs"
brew "zlib"
brew "capnp"
brew "coreutils"
brew "eigen"
brew "ffmpeg"
brew "glfw"
brew "libarchive"
brew "libusb"
brew "libtool"
brew "llvm"
@@ -50,7 +48,6 @@ brew "zeromq"
cask "gcc-arm-embedded"
brew "portaudio"
brew "gcc@13"
cask "font-noto-color-emoji"
EOS
echo "[ ] finished brew install t=$SECONDS"
+3 -1
View File
@@ -284,7 +284,7 @@ function op_venv() {
function op_adb() {
op_before_cmd
op_run_command tools/scripts/adb_ssh.sh
op_run_command tools/scripts/adb_ssh.sh "$@"
}
function op_ssh() {
@@ -366,9 +366,11 @@ function op_switch() {
BRANCH="$1"
git config --replace-all remote.origin.fetch "+refs/heads/*:refs/remotes/origin/*"
git submodule deinit --all --force
git fetch "$REMOTE" "$BRANCH"
git checkout -f FETCH_HEAD
git checkout -B "$BRANCH" --track "$REMOTE"/"$BRANCH"
git submodule deinit --all --force
git reset --hard "${REMOTE}/${BRANCH}"
git clean -df
git submodule update --init --recursive
+2 -2
View File
@@ -88,7 +88,7 @@ To visualize the replay within the openpilot UI, run the following commands:
```bash
tools/replay/replay <route-name>
cd selfdrive/ui && ./ui
cd selfdrive/ui && ./ui.py
```
## Work with plotjuggler
@@ -110,7 +110,7 @@ simply replay a route using the `--dcam` and `--ecam` flags:
cd tools/replay && ./replay --demo --dcam --ecam
# then start watch3
cd selfdrive/ui && ./watch3
cd selfdrive/ui && ./watch3.py
```
![](https://i.imgur.com/IeaOdAb.png)
+1 -1
View File
@@ -40,7 +40,7 @@ struct DecoderManager {
std::unique_ptr<VideoDecoder> decoder;
#ifndef __APPLE__
if (Hardware::TICI() && hw_decoder) {
if (!Hardware::PC() && hw_decoder) {
decoder = std::make_unique<QcomVideoDecoder>();
} else
#endif
+1 -1
View File
@@ -30,7 +30,7 @@ Options:
--qcam Load qcamera
--no-hw-decoder Disable HW video decoding
--no-vipc Do not output video
--all Output all messages including uiDebug, userBookmark
--all Output all messages including bookmarkButton, uiDebug, userBookmark
-h, --help Show this help message
)";
+1 -1
View File
@@ -20,7 +20,7 @@ Replay::Replay(const std::string &route, std::vector<std::string> allow, std::ve
std::signal(SIGUSR1, interrupt_sleep_handler);
if (!(flags_ & REPLAY_FLAG_ALL_SERVICES)) {
block.insert(block.end(), {"uiDebug", "userBookmark"});
block.insert(block.end(), {"bookmarkButton", "uiDebug", "userBookmark"});
}
setupServices(allow, block);
setupSegmentManager(!allow.empty() || !block.empty());
+39 -4
View File
@@ -1,7 +1,42 @@
#!/usr/bin/env bash
set -e
set -euo pipefail
# this is a little nicer than "adb shell" since
# "adb shell" doesn't do full terminal emulation
# Forward all openpilot service ports
mapfile -t SERVICE_PORTS < <(python3 - <<'PY'
from cereal.services import SERVICE_LIST
FNV_PRIME = 0x100000001b3
FNV_OFFSET_BASIS = 0xcbf29ce484222325
START_PORT = 8023
MAX_PORT = 65535
PORT_RANGE = MAX_PORT - START_PORT
MASK = 0xffffffffffffffff
def fnv1a(endpoint: str) -> int:
h = FNV_OFFSET_BASIS
for b in endpoint.encode():
h ^= b
h = (h * FNV_PRIME) & MASK
return h
ports = set()
for name in SERVICE_LIST.keys():
port = START_PORT + fnv1a(name) % PORT_RANGE
ports.add((name, port))
for name, port in sorted(ports):
print(f"{name} {port}")
PY
)
for entry in "${SERVICE_PORTS[@]}"; do
name="${entry% *}"
port="${entry##* }"
adb forward "tcp:${port}" "tcp:${port}" > /dev/null
done
# Forward SSH port first for interactive shell access.
adb forward tcp:2222 tcp:22
ssh comma@localhost -p 2222
# SSH!
ssh comma@localhost -p 2222 "$@"
+1 -1
View File
@@ -50,7 +50,7 @@ if __name__ == "__main__":
if args.debug:
command += ["-v"]
command += [
f"comma@{dongle_id}",
f"comma@comma-{dongle_id}",
]
if args.debug:
print(" ".join([f"'{c}'" if " " in c else c for c in command]))