diff --git a/tools/cabana/SConscript b/tools/cabana/SConscript index 6af4ca08f5..2574e3368b 100644 --- a/tools/cabana/SConscript +++ b/tools/cabana/SConscript @@ -26,7 +26,7 @@ cabana_env.Command(assets, assets_src, f"rcc $SOURCES -o $TARGET") cabana_env.Depends(assets, Glob('/assets/*', exclude=[assets, assets_src, "assets/assets.o"])) 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', - 'dbc/dbc.cc', 'dbc/dbcfile.cc', 'dbc/dbcmanager.cc', + 'streams/routes.cc', 'dbc/dbc.cc', 'dbc/dbcfile.cc', 'dbc/dbcmanager.cc', 'utils/export.cc', 'utils/util.cc', 'chart/chartswidget.cc', 'chart/chart.cc', 'chart/signalselector.cc', 'chart/tiplabel.cc', 'chart/sparkline.cc', 'commands.cc', 'messageswidget.cc', 'streamselector.cc', 'settings.cc', 'detailwidget.cc', 'tools/findsimilarbits.cc', 'tools/findsignal.cc'], LIBS=cabana_libs, FRAMEWORKS=base_frameworks) diff --git a/tools/cabana/streams/replaystream.cc b/tools/cabana/streams/replaystream.cc index c75a128a15..3c3e431ce7 100644 --- a/tools/cabana/streams/replaystream.cc +++ b/tools/cabana/streams/replaystream.cc @@ -7,6 +7,7 @@ #include #include "common/timing.h" +#include "tools/cabana/streams/routes.h" ReplayStream::ReplayStream(QObject *parent) : AbstractStream(parent) { unsetenv("ZMQ"); @@ -107,29 +108,36 @@ AbstractOpenStreamWidget *ReplayStream::widget(AbstractStream **stream) { // OpenReplayWidget OpenReplayWidget::OpenReplayWidget(AbstractStream **stream) : AbstractOpenStreamWidget(stream) { - // TODO: get route list from api.comma.ai QGridLayout *grid_layout = new QGridLayout(this); grid_layout->addWidget(new QLabel(tr("Route")), 0, 0); grid_layout->addWidget(route_edit = new QLineEdit(this), 0, 1); - route_edit->setPlaceholderText(tr("Enter remote route name or click browse to select a local route")); - auto file_btn = new QPushButton(tr("Browse..."), this); - grid_layout->addWidget(file_btn, 0, 2); + route_edit->setPlaceholderText(tr("Enter route name or browse for local/remote route")); + auto browse_remote_btn = new QPushButton(tr("Remote route..."), this); + grid_layout->addWidget(browse_remote_btn, 0, 2); + auto browse_local_btn = new QPushButton(tr("Local route..."), this); + grid_layout->addWidget(browse_local_btn, 0, 3); - grid_layout->addWidget(new QLabel(tr("Camera")), 1, 0); QHBoxLayout *camera_layout = new QHBoxLayout(); for (auto c : {tr("Road camera"), tr("Driver camera"), tr("Wide road camera")}) camera_layout->addWidget(cameras.emplace_back(new QCheckBox(c, this))); + cameras[0]->setChecked(true); camera_layout->addStretch(1); grid_layout->addItem(camera_layout, 1, 1); setMinimumWidth(550); - QObject::connect(file_btn, &QPushButton::clicked, [=]() { + QObject::connect(browse_local_btn, &QPushButton::clicked, [=]() { QString dir = QFileDialog::getExistingDirectory(this, tr("Open Local Route"), settings.last_route_dir); if (!dir.isEmpty()) { route_edit->setText(dir); settings.last_route_dir = QFileInfo(dir).absolutePath(); } }); + QObject::connect(browse_remote_btn, &QPushButton::clicked, [this]() { + RoutesDialog route_dlg(this); + if (route_dlg.exec()) { + route_edit->setText(route_dlg.route()); + } + }); } bool OpenReplayWidget::open() { diff --git a/tools/cabana/streams/routes.cc b/tools/cabana/streams/routes.cc new file mode 100644 index 0000000000..c805e7d60d --- /dev/null +++ b/tools/cabana/streams/routes.cc @@ -0,0 +1,123 @@ +#include "tools/cabana/streams/routes.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "system/hardware/hw.h" + +// The RouteListWidget class extends QListWidget to display a custom message when empty +class RouteListWidget : public QListWidget { +public: + RouteListWidget(QWidget *parent = nullptr) : QListWidget(parent) {} + void setEmptyText(const QString &text) { + empty_text_ = text; + viewport()->update(); + } + void paintEvent(QPaintEvent *event) override { + QListWidget::paintEvent(event); + if (count() == 0) { + QPainter painter(viewport()); + painter.drawText(viewport()->rect(), Qt::AlignCenter, empty_text_); + } + } + QString empty_text_ = tr("No items"); +}; + +RoutesDialog::RoutesDialog(QWidget *parent) : QDialog(parent) { + setWindowTitle(tr("Remote routes")); + + QFormLayout *layout = new QFormLayout(this); + layout->addRow(tr("Device"), device_list_ = new QComboBox(this)); + layout->addRow(tr("Duration"), period_selector_ = new QComboBox(this)); + layout->addRow(route_list_ = new RouteListWidget(this)); + auto button_box = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); + layout->addRow(button_box); + + device_list_->addItem(tr("Loading...")); + // Populate period selector with predefined durations + period_selector_->addItem(tr("Last week"), 7); + period_selector_->addItem(tr("Last 2 weeks"), 14); + period_selector_->addItem(tr("Last month"), 30); + period_selector_->addItem(tr("Last 6 months"), 180); + + // Connect signals and slots + connect(device_list_, QOverload::of(&QComboBox::currentIndexChanged), this, &RoutesDialog::fetchRoutes); + connect(period_selector_, QOverload::of(&QComboBox::currentIndexChanged), this, &RoutesDialog::fetchRoutes); + connect(route_list_, &QListWidget::itemDoubleClicked, this, &QDialog::accept); + QObject::connect(button_box, &QDialogButtonBox::accepted, this, &QDialog::accept); + QObject::connect(button_box, &QDialogButtonBox::rejected, this, &QDialog::reject); + + // Send request to fetch devices + HttpRequest *http = new HttpRequest(this, !Hardware::PC()); + QObject::connect(http, &HttpRequest::requestDone, this, &RoutesDialog::parseDeviceList); + http->sendRequest(CommaApi::BASE_URL + "/v1/me/devices/"); +} + +void RoutesDialog::parseDeviceList(const QString &json, bool success, QNetworkReply::NetworkError err) { + if (success) { + device_list_->clear(); + auto devices = QJsonDocument::fromJson(json.toUtf8()).array(); + for (const QJsonValue &device : devices) { + QString dongle_id = device["dongle_id"].toString(); + device_list_->addItem(dongle_id, dongle_id); + } + } else { + bool unauthorized = (err == QNetworkReply::ContentAccessDenied || err == QNetworkReply::AuthenticationRequiredError); + QMessageBox::warning(this, tr("Error"), unauthorized ? tr("Unauthorized, Authenticate with tools/lib/auth.py") : tr("Network error")); + reject(); + } + sender()->deleteLater(); +} + +void RoutesDialog::fetchRoutes() { + if (device_list_->currentIndex() == -1 || device_list_->currentData().isNull()) + return; + + route_list_->clear(); + route_list_->setEmptyText(tr("Loading...")); + + HttpRequest *http = new HttpRequest(this, !Hardware::PC()); + QObject::connect(http, &HttpRequest::requestDone, this, &RoutesDialog::parseRouteList); + + // Construct URL with selected device and date range + auto dongle_id = device_list_->currentData().toString(); + QDateTime current = QDateTime::currentDateTime(); + QString url = QString("%1/v1/devices/%2/routes_segments?start=%3&end=%4") + .arg(CommaApi::BASE_URL).arg(dongle_id) + .arg(current.addDays(-(period_selector_->currentData().toInt())).toMSecsSinceEpoch()) + .arg(current.toMSecsSinceEpoch()); + http->sendRequest(url); +} + +void RoutesDialog::parseRouteList(const QString &json, bool success, QNetworkReply::NetworkError err) { + if (success) { + for (const QJsonValue &route : QJsonDocument::fromJson(json.toUtf8()).array()) { + uint64_t start_time = route["start_time_utc_millis"].toDouble(); + uint64_t end_time = route["end_time_utc_millis"].toDouble(); + auto datetime = QDateTime::fromMSecsSinceEpoch(start_time); + auto item = new QListWidgetItem(QString("%1 %2min").arg(datetime.toString()).arg((end_time - start_time) / (1000 * 60))); + item->setData(Qt::UserRole, route["fullname"].toString()); + route_list_->addItem(item); + } + // Select first route if available + if (route_list_->count() > 0) route_list_->setCurrentRow(0); + } else { + QMessageBox::warning(this, tr("Error"), tr("Failed to fetch routes. Check your network connection.")); + reject(); + } + route_list_->setEmptyText(tr("No items")); + sender()->deleteLater(); +} + +void RoutesDialog::accept() { + if (auto current_item = route_list_->currentItem()) { + route_ = current_item->data(Qt::UserRole).toString(); + } + QDialog::accept(); +} diff --git a/tools/cabana/streams/routes.h b/tools/cabana/streams/routes.h new file mode 100644 index 0000000000..31e42fb075 --- /dev/null +++ b/tools/cabana/streams/routes.h @@ -0,0 +1,26 @@ +#pragma once + +#include +#include + +#include "selfdrive/ui/qt/api.h" + +class RouteListWidget; + +class RoutesDialog : public QDialog { + Q_OBJECT +public: + RoutesDialog(QWidget *parent); + QString route() const { return route_; } + +protected: + void accept() override; + void parseDeviceList(const QString &json, bool success, QNetworkReply::NetworkError err); + void parseRouteList(const QString &json, bool success, QNetworkReply::NetworkError err); + void fetchRoutes(); + + QComboBox *device_list_; + QComboBox *period_selector_; + RouteListWidget *route_list_; + QString route_; +}; diff --git a/tools/cabana/streamselector.cc b/tools/cabana/streamselector.cc index 07755c0fe0..d12f1a5df7 100644 --- a/tools/cabana/streamselector.cc +++ b/tools/cabana/streamselector.cc @@ -13,12 +13,8 @@ StreamSelector::StreamSelector(AbstractStream **stream, QWidget *parent) : QDialog(parent) { setWindowTitle(tr("Open stream")); - QVBoxLayout *main_layout = new QVBoxLayout(this); - - QWidget *w = new QWidget(this); - QVBoxLayout *layout = new QVBoxLayout(w); + QVBoxLayout *layout = new QVBoxLayout(this); tab = new QTabWidget(this); - tab->setTabBarAutoHide(true); layout->addWidget(tab); QHBoxLayout *dbc_layout = new QHBoxLayout(); @@ -35,9 +31,8 @@ StreamSelector::StreamSelector(AbstractStream **stream, QWidget *parent) : QDial line->setFrameStyle(QFrame::HLine | QFrame::Sunken); layout->addWidget(line); - main_layout->addWidget(w); auto btn_box = new QDialogButtonBox(QDialogButtonBox::Open | QDialogButtonBox::Cancel); - main_layout->addWidget(btn_box); + layout->addWidget(btn_box); addStreamWidget(ReplayStream::widget(stream)); addStreamWidget(PandaStream::widget(stream)); @@ -48,14 +43,11 @@ StreamSelector::StreamSelector(AbstractStream **stream, QWidget *parent) : QDial QObject::connect(btn_box, &QDialogButtonBox::rejected, this, &QDialog::reject); QObject::connect(btn_box, &QDialogButtonBox::accepted, [=]() { - btn_box->button(QDialogButtonBox::Open)->setEnabled(false); - w->setEnabled(false); + setEnabled(false); if (((AbstractOpenStreamWidget *)tab->currentWidget())->open()) { accept(); - } else { - btn_box->button(QDialogButtonBox::Open)->setEnabled(true); - w->setEnabled(true); } + setEnabled(true); }); QObject::connect(file_btn, &QPushButton::clicked, [this]() { QString fn = QFileDialog::getOpenFileName(this, tr("Open File"), settings.last_dir, "DBC (*.dbc)");