mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-09-03 22:53:44 +08:00
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:
+58
-5
@@ -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,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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include <QFormLayout>
|
||||
#include <QMenu>
|
||||
#include <QRadioButton>
|
||||
#include <QPushButton>
|
||||
#include <QToolBar>
|
||||
|
||||
#include "tools/cabana/commands.h"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
#include <QComboBox>
|
||||
#include <QDialog>
|
||||
|
||||
#include "selfdrive/ui/qt/api.h"
|
||||
#include "tools/cabana/utils/api.h"
|
||||
|
||||
class RouteListWidget;
|
||||
class OneShotHttpRequest;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
#include "tools/cabana/utils/elidedlabel.h"
|
||||
#include <QPainter>
|
||||
#include <QStyleOption>
|
||||
|
||||
ElidedLabel::ElidedLabel(QWidget *parent) : ElidedLabel({}, parent) {}
|
||||
|
||||
ElidedLabel::ElidedLabel(const QString &text, QWidget *parent) : QLabel(text.trimmed(), parent) {
|
||||
setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Preferred);
|
||||
setMinimumWidth(1);
|
||||
}
|
||||
|
||||
void ElidedLabel::resizeEvent(QResizeEvent* event) {
|
||||
QLabel::resizeEvent(event);
|
||||
lastText_ = elidedText_ = "";
|
||||
}
|
||||
|
||||
void ElidedLabel::paintEvent(QPaintEvent *event) {
|
||||
const QString curText = text();
|
||||
if (curText != lastText_) {
|
||||
elidedText_ = fontMetrics().elidedText(curText, Qt::ElideRight, contentsRect().width());
|
||||
lastText_ = curText;
|
||||
}
|
||||
|
||||
QPainter painter(this);
|
||||
drawFrame(&painter);
|
||||
QStyleOption opt;
|
||||
opt.initFrom(this);
|
||||
style()->drawItemText(&painter, contentsRect(), alignment(), opt.palette, isEnabled(), elidedText_, foregroundRole());
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
#pragma once
|
||||
|
||||
#include <QLabel>
|
||||
#include <QMouseEvent>
|
||||
|
||||
class ElidedLabel : public QLabel {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit ElidedLabel(QWidget *parent = 0);
|
||||
explicit ElidedLabel(const QString &text, QWidget *parent = 0);
|
||||
|
||||
signals:
|
||||
void clicked();
|
||||
|
||||
protected:
|
||||
void paintEvent(QPaintEvent *event) override;
|
||||
void resizeEvent(QResizeEvent* event) override;
|
||||
void mouseReleaseEvent(QMouseEvent *event) override {
|
||||
if (rect().contains(event->pos())) {
|
||||
emit clicked();
|
||||
}
|
||||
}
|
||||
QString lastText_, elidedText_;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:248b71eafd1b42b0861da92114da3d625221cd88121fff01e0514bf3d79ff3b1
|
||||
size 1364
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||

|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)";
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 "$@"
|
||||
|
||||
@@ -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]))
|
||||
|
||||
Reference in New Issue
Block a user