diff --git a/CHANGELOGS.md b/CHANGELOGS.md index bd301b3232..3325da70f1 100644 --- a/CHANGELOGS.md +++ b/CHANGELOGS.md @@ -6,6 +6,11 @@ sunnypilot - 0.9.5.2 (2023-xx-xx) * NEW❗: Neural Network Lateral Controller * Formerly known as "NNFF", this replaces the lateral "torque" controller with one using a neural network trained on each car's (actually, each separate EPS firmware) driving data for increased controls accuracy * Contact @twilsonco in the sunnypilot Discord server with feedback, or to provide log data for your car if your car is currently unsupported +* NEW❗: Driving Model Selector + * Easily switch between driving models without reinstalling branches. Offering immediate access to the latest models upon release + * An internet connection is required for downloading models. Each model switch currently involves downloading the model again. Future updates may allow for offline switching + * Warning is displayed for metered connections to avoid unexpected data usage if on cellular data + * Change driving models via **Settings -> Software -> Current Driving Model**. * NEW❗: Hyundai CAN longitudinal: * NEW❗: Enable radar tracks for certain Santa Fe platforms * Internal Combustion Engine (ICE) 2021-23 diff --git a/common/params.cc b/common/params.cc index 5505ff5b88..ec2ced3256 100644 --- a/common/params.cc +++ b/common/params.cc @@ -226,6 +226,7 @@ std::unordered_map keys = { {"CarModelText", PERSISTENT}, {"ChevronInfo", PERSISTENT}, {"CustomBootScreen", PERSISTENT}, + {"CustomDrivingModel", PERSISTENT}, {"CustomMapboxTokenPk", PERSISTENT}, {"CustomMapboxTokenSk", PERSISTENT}, {"CustomOffsets", PERSISTENT}, @@ -234,6 +235,9 @@ std::unordered_map keys = { {"DevUIInfo", PERSISTENT}, {"DisableOnroadUploads", PERSISTENT}, {"DisengageLateralOnBrake", PERSISTENT}, + {"DrivingModelName", PERSISTENT}, + {"DrivingModelText", PERSISTENT}, + {"DrivingModelUrl", PERSISTENT}, {"DynamicExperimentalControl", PERSISTENT}, {"DynamicLaneProfile", PERSISTENT}, {"EnableAmap", PERSISTENT}, diff --git a/selfdrive/modeld/modeld.py b/selfdrive/modeld/modeld.py index ff3dc1f010..ca920314a3 100755 --- a/selfdrive/modeld/modeld.py +++ b/selfdrive/modeld/modeld.py @@ -32,6 +32,9 @@ MODEL_PATHS = { METADATA_PATH = Path(__file__).parent / 'models/supercombo_metadata.pkl' +CUSTOM_MODEL_PATH = "/data/media/0/models" + + class FrameMeta: frame_id: int = 0 timestamp_sof: int = 0 @@ -70,7 +73,15 @@ class ModelState: self.output = np.zeros(net_output_size, dtype=np.float32) self.parser = Parser() - self.model = ModelRunner(MODEL_PATHS, self.output, Runtime.GPU, False, context) + self.param_s = Params() + + if self.param_s.get_bool("CustomDrivingModel"): + _model_name = self.param_s.get("DrivingModelText", encoding="utf8") + _model_paths = { + ModelRunner.THNEED: f"{CUSTOM_MODEL_PATH}/supercombo-{_model_name}.thneed"} + else: + _model_paths = MODEL_PATHS + self.model = ModelRunner(_model_paths, self.output, Runtime.GPU, False, context) self.model.addInput("input_imgs", None) self.model.addInput("big_input_imgs", None) for k,v in self.inputs.items(): diff --git a/selfdrive/ui/SConscript b/selfdrive/ui/SConscript index cc1d014f2d..1c2f2235c0 100644 --- a/selfdrive/ui/SConscript +++ b/selfdrive/ui/SConscript @@ -32,7 +32,8 @@ widgets_src += ["qt/offroad/sunnypilot/display_settings.cc", "qt/offroad/sunnypi "qt/offroad/sunnypilot/trips_settings.cc", "qt/offroad/sunnypilot/mads_settings.cc", "qt/offroad/sunnypilot/lane_change_settings.cc", "qt/offroad/sunnypilot/speed_limit_control_settings.cc", "qt/offroad/sunnypilot/monitoring_settings.cc", "qt/offroad/sunnypilot/osm_settings.cc", - "qt/offroad/sunnypilot/custom_offsets_settings.cc", "qt/widgets/sunnypilot/drive_stats.cc"] + "qt/offroad/sunnypilot/custom_offsets_settings.cc", "qt/widgets/sunnypilot/drive_stats.cc", + "qt/offroad/sunnypilot/software_settings_sp.cc", "qt/offroad/sunnypilot/models_fetcher.cc"] qt_env['CPPDEFINES'] = [] if maps: diff --git a/selfdrive/ui/qt/offroad/settings.cc b/selfdrive/ui/qt/offroad/settings.cc index 3ed049e67d..2c1469c1e9 100644 --- a/selfdrive/ui/qt/offroad/settings.cc +++ b/selfdrive/ui/qt/offroad/settings.cc @@ -545,7 +545,7 @@ SettingsWindow::SettingsWindow(QWidget *parent) : QFrame(parent) { PanelInfo(" " + tr("Device"), device, "../assets/navigation/icon_home.svg"), PanelInfo(" " + tr("Network"), new Networking(this), "../assets/offroad/icon_network.png"), PanelInfo(" " + tr("Toggles"), toggles, "../assets/offroad/icon_toggle.png"), - PanelInfo(" " + tr("Software"), new SoftwarePanel(this), "../assets/offroad/icon_software.png"), + PanelInfo(" " + tr("Software"), new SoftwarePanelSP(this), "../assets/offroad/icon_software.png"), PanelInfo(" " + tr("sunnypilot"), new SunnypilotPanel(this), "../assets/offroad/icon_openpilot.png"), PanelInfo(" " + tr("OSM"), new OsmPanel(this), "../assets/offroad/icon_map.png"), PanelInfo(" " + tr("Monitoring"), new MonitoringPanel(this), "../assets/offroad/icon_monitoring.png"), diff --git a/selfdrive/ui/qt/offroad/settings.h b/selfdrive/ui/qt/offroad/settings.h index ae86093129..3ef846eb83 100644 --- a/selfdrive/ui/qt/offroad/settings.h +++ b/selfdrive/ui/qt/offroad/settings.h @@ -94,9 +94,9 @@ class SoftwarePanel : public ListWidget { public: explicit SoftwarePanel(QWidget* parent = nullptr); -private: +protected: void showEvent(QShowEvent *event) override; - void updateLabels(); + virtual void updateLabels(); void checkForUpdates(); bool is_onroad = false; diff --git a/selfdrive/ui/qt/offroad/software_settings.cc b/selfdrive/ui/qt/offroad/software_settings.cc index 4980d70ea5..633b085c9c 100644 --- a/selfdrive/ui/qt/offroad/software_settings.cc +++ b/selfdrive/ui/qt/offroad/software_settings.cc @@ -22,7 +22,7 @@ void SoftwarePanel::checkForUpdates() { } SoftwarePanel::SoftwarePanel(QWidget* parent) : ListWidget(parent) { - currentModelLbl = new LabelControl(tr("Current Driving Model"), CURRENT_MODEL); + currentModelLbl = new LabelControl(tr("Driving Model"), CURRENT_MODEL); addItem(currentModelLbl); onroadLbl = new QLabel(tr("Updates are only downloaded while the car is off.")); diff --git a/selfdrive/ui/qt/offroad/sunnypilot/json_fetcher.h b/selfdrive/ui/qt/offroad/sunnypilot/json_fetcher.h index 22864877de..f82041e36e 100644 --- a/selfdrive/ui/qt/offroad/sunnypilot/json_fetcher.h +++ b/selfdrive/ui/qt/offroad/sunnypilot/json_fetcher.h @@ -20,7 +20,7 @@ public: QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); loop.exec(); - if(reply->error() != QNetworkReply::NoError) { + if (reply->error() != QNetworkReply::NoError) { qWarning() << "Failed to fetch data from URL: " << reply->errorString(); return QJsonObject(); } @@ -32,4 +32,4 @@ public: reply->deleteLater(); return json; } -}; \ No newline at end of file +}; diff --git a/selfdrive/ui/qt/offroad/sunnypilot/models_fetcher.cc b/selfdrive/ui/qt/offroad/sunnypilot/models_fetcher.cc new file mode 100644 index 0000000000..4d3504bf96 --- /dev/null +++ b/selfdrive/ui/qt/offroad/sunnypilot/models_fetcher.cc @@ -0,0 +1,82 @@ +#include "selfdrive/ui/qt/offroad/sunnypilot/models_fetcher.h" + +ModelsFetcher::ModelsFetcher(QObject* parent) : QObject(parent) { + manager = new QNetworkAccessManager(this); +} + +void ModelsFetcher::download(const QUrl& url, const QString& filename, const QString& destinationPath) { + if (!QDir(destinationPath).exists() && !QDir().mkpath(destinationPath)) { + LOGE("Failed to create directory: [%s]", destinationPath.toStdString().c_str()); + } + const QNetworkRequest request(url); + QNetworkReply* reply = manager->get(request); + connect(reply, &QNetworkReply::downloadProgress, this, &ModelsFetcher::onDownloadProgress); + connect(reply, &QNetworkReply::finished, this, [this, reply, destinationPath, filename]() { + onFinished(reply, destinationPath, filename); + }); +} + +QString extractFileName(const QString& contentDisposition) { + const QString filenameTag = "filename="; + const int idx = contentDisposition.indexOf(filenameTag); + if (idx < 0) { + return QString(); + } + + QString filename = contentDisposition.mid(idx + filenameTag.length()); + if (filename.startsWith("\"") && filename.endsWith("\"")) { + return filename.mid(1, filename.size() - 2); + } + + return filename; +} + +void ModelsFetcher::onFinished(QNetworkReply* reply, const QString& destinationPath, const QString& filename) { + // Handle download error + if (reply->error()) { + return; + } + + const QByteArray data = reply->readAll(); + + QString finalFilename = filename; + if (finalFilename.isEmpty()) { + finalFilename = extractFileName(reply->header(QNetworkRequest::ContentDispositionHeader).toString()); + } + + QString finalPath = QDir(destinationPath).filePath(finalFilename); + + // handle file open error + QFile file(finalPath); + if (!file.open(QIODevice::WriteOnly)) { + return; + } + + file.write(data); + file.close(); + + emit downloadComplete(data); +} + +void ModelsFetcher::onDownloadProgress(qint64 bytesReceived, qint64 bytesTotal) { + const double progress = (bytesReceived * 100.0) / bytesTotal; + emit downloadProgress(progress); +} + +std::vector ModelsFetcher::getModelsFromURL(const QUrl&url) { + std::vector models; + JsonFetcher fetcher; + QJsonObject json = fetcher.getJsonFromURL(url.toString()); + for (auto it = json.begin(); it != json.end(); ++it) { + models.push_back(Model(it.value().toObject())); + } + return models; +} + +std::vector ModelsFetcher::getModelsFromURL(const QString&url) { + return getModelsFromURL(QUrl(url)); +} + +std::vector ModelsFetcher::getModelsFromURL() { + return getModelsFromURL("https://docs.sunnypilot.ai/models.json"); +} diff --git a/selfdrive/ui/qt/offroad/sunnypilot/models_fetcher.h b/selfdrive/ui/qt/offroad/sunnypilot/models_fetcher.h new file mode 100644 index 0000000000..07d76169c8 --- /dev/null +++ b/selfdrive/ui/qt/offroad/sunnypilot/models_fetcher.h @@ -0,0 +1,65 @@ +#pragma once + +#include // for std::sort +#include +#include +#include +#include +#include + +#include "common/swaglog.h" +#include "common/util.h" +#include "selfdrive/ui/ui.h" +#include "selfdrive/ui/qt/util.h" +#include "selfdrive/ui/qt/offroad/sunnypilot/json_fetcher.h" +#include "selfdrive/ui/qt/widgets/controls.h" +#include "system/hardware/hw.h" + +static const QString MODELS_PATH = Hardware::PC() ? QDir::homePath() + "/.comma/media/0/models/" : "/data/media/0/models/"; + +// New class ModelsFetcher with a new function that handles web requests and JSON parsing for the new JSON structure +class Model { + +public: + explicit Model(const QJsonObject&json) { + displayName = json["display_name"].toString(); + fullName = json["full_name"].toString(); + fileName = json["file_name"].toString(); + downloadUri = json["download_uri"].toString(); + } + + QJsonObject toJson() const { + QJsonObject json; + json["display_name"] = displayName; + json["full_name"] = fullName; + json["file_name"] = fileName; + json["download_uri"] = downloadUri; + return json; + } + + QString displayName; + QString fullName; + QString fileName; + QString downloadUri; +}; + +class ModelsFetcher : public QObject { + Q_OBJECT + +public: + explicit ModelsFetcher(QObject* parent = nullptr); + void download(const QUrl&url, const QString& filename = "", const QString&destinationPath = MODELS_PATH); + static std::vector getModelsFromURL(const QUrl&url); + static std::vector getModelsFromURL(const QString&url); + static std::vector getModelsFromURL(); + +signals: + void downloadProgress(double percentage); + void downloadComplete(const QByteArray&data); + +private: + void onDownloadProgress(qint64 bytesReceived, qint64 bytesTotal); + void onFinished(QNetworkReply* reply, const QString&destinationPath, const QString&filename); + + QNetworkAccessManager* manager; +}; diff --git a/selfdrive/ui/qt/offroad/sunnypilot/software_settings_sp.cc b/selfdrive/ui/qt/offroad/sunnypilot/software_settings_sp.cc new file mode 100644 index 0000000000..6a79cd363f --- /dev/null +++ b/selfdrive/ui/qt/offroad/sunnypilot/software_settings_sp.cc @@ -0,0 +1,125 @@ +#include "selfdrive/ui/qt/offroad/sunnypilot/software_settings_sp.h" + +SoftwarePanelSP::SoftwarePanelSP(QWidget* parent) : SoftwarePanel(parent) { + // Get current model name and create new ButtonControl + const auto current_model = GetModelName(); + currentModelLblBtn = new ButtonControl(tr("Driving Model"), tr("SELECT"), current_model); + currentModelLblBtn->setValue(current_model); + + // Connect downloadProgress from models_fetcher to local slot + connect(&models_fetcher, &ModelsFetcher::downloadProgress, this, [=](const double progress) { + modelDownloadProgress = progress; + HandleModelDownloadProgressReport(); + }); + + // Connect click event from currentModelLblBtn to local slot + connect(currentModelLblBtn, &ButtonControl::clicked, this, &SoftwarePanelSP::handleCurrentModelLblBtnClicked); + + ReplaceOrAddWidget(currentModelLbl, currentModelLblBtn); +} + +QString SoftwarePanelSP::GetModelName() { + if (selectedModelToDownload.has_value()) { + return selectedModelToDownload->displayName; + } + + if (params.getBool("CustomDrivingModel")) { + return QString::fromStdString(params.get("DrivingModelName")); + } + + return CURRENT_MODEL; +} + +void SoftwarePanelSP::HandleModelDownloadProgressReport() { + const auto _progress_str = QString::number(modelDownloadProgress, 'f', 2); + const auto description = isDownloadingModel() ? QString("Downloading [%1]... (%2%)") : QString("%1 downloaded"); + + // Update UI with new description + currentModelLblBtn->setDescription(description.arg(GetModelName(), _progress_str)); + currentModelLblBtn->showDescription(); + currentModelLblBtn->setEnabled(!(is_onroad || isDownloadingModel())); + + // If not downloading and there is a selected model, update parameters + if (!isDownloadingModel() && selectedModelToDownload.has_value()) { + params.put("DrivingModelText", selectedModelToDownload->fullName.toStdString()); + params.put("DrivingModelName", selectedModelToDownload->displayName.toStdString()); + //params.put("DrivingModelUrl", selectedModelToDownload->downloadUri.toStdString()); // TODO: Placeholder for future implementations + selectedModelToDownload.reset(); + params.putBool("CustomDrivingModel", true); + } +} + +void SoftwarePanelSP::handleCurrentModelLblBtnClicked() { + // Disabling label button and displaying fetching message + currentModelLblBtn->setEnabled(false); + currentModelLblBtn->setValue("Fetching models..."); + + checkNetwork(); + const auto currentModelName = QString::fromStdString(params.get("DrivingModelName")); + const auto models = models_fetcher.getModelsFromURL(); + QStringList modelNames; + + // Collecting model names + for (const auto &model: models) { + modelNames.push_back(model.displayName); + } + + currentModelLblBtn->setEnabled(!is_onroad); + currentModelLblBtn->setValue(GetModelName()); + + const QString selectedModelName = MultiOptionDialog::getSelection(tr("Select a Driving Model"), modelNames, currentModelName, this); + + // Bail if the user doesn't want to continue while on metered + if (!canContinueOnMeteredDialog()) { + return; + } + + // Finding and setting the selected model + for (auto &model: models) { + if (model.displayName == selectedModelName) { + selectedModelToDownload = model; + params.putBool("CustomDrivingModel", false); + break; + } + } + + // If decision is to download and there is a selected model, update UI and begin downloading + if (selectedModelToDownload.has_value()) { + currentModelLblBtn->setValue(selectedModelToDownload->displayName); + currentModelLblBtn->setDescription(selectedModelToDownload->displayName); + models_fetcher.download(selectedModelToDownload->downloadUri, selectedModelToDownload->fileName); + + // Disable select button until download completes + currentModelLblBtn->setEnabled(false); + showResetParamsDialog(); + } +} + +void SoftwarePanelSP::checkNetwork() { + const SubMaster &sm = *(uiState()->sm); + const auto device_state = sm["deviceState"].getDeviceState(); + const auto network_type = device_state.getNetworkType(); + is_wifi = network_type == cereal::DeviceState::NetworkType::WIFI; + is_metered = device_state.getNetworkMetered(); +} + +void SoftwarePanelSP::updateLabels() { + if (!isVisible()) { + return; + } + + checkNetwork(); + currentModelLblBtn->setEnabled(!is_onroad); + SoftwarePanel::updateLabels(); +} + +void SoftwarePanelSP::showResetParamsDialog() { + const auto confirmMsg = tr("Download has started in the background.\nWe STRONGLY suggest you to reset calibration, would you like to do that now?"); + const auto button_text = tr("Reset Calibration"); + + // If user confirms, remove specified parameters + if (showConfirmationDialog(confirmMsg, button_text, false)) { + params.remove("CalibrationParams"); + params.remove("LiveTorqueParameters"); + } +} diff --git a/selfdrive/ui/qt/offroad/sunnypilot/software_settings_sp.h b/selfdrive/ui/qt/offroad/sunnypilot/software_settings_sp.h new file mode 100644 index 0000000000..ca5c06c2ea --- /dev/null +++ b/selfdrive/ui/qt/offroad/sunnypilot/software_settings_sp.h @@ -0,0 +1,49 @@ +#pragma once + +#include "common/model.h" +#include "selfdrive/ui/ui.h" +#include "selfdrive/ui/qt/offroad/settings.h" +#include "selfdrive/ui/qt/offroad/sunnypilot/models_fetcher.h" + +class SoftwarePanelSP final : public SoftwarePanel { + Q_OBJECT + +public: + explicit SoftwarePanelSP(QWidget *parent = nullptr); + +private: + QString GetModelName(); + void checkNetwork(); + bool isDownloadingModel() const { + return selectedModelToDownload.has_value() && modelDownloadProgress > 0.0 && modelDownloadProgress < 100.0; + } + + // UI update related methods + void updateLabels() override; + void handleCurrentModelLblBtnClicked(); + void HandleModelDownloadProgressReport(); + void showResetParamsDialog(); + bool canContinueOnMeteredDialog() { + if (!is_metered) return true; + return showConfirmationDialog(QString(), QString(), is_metered); + } + + inline bool showConfirmationDialog(const QString &message = QString(), const QString &confirmButtonText = QString(), const bool show_metered_warning = false) { + return showConfirmationDialog(this, message, confirmButtonText, show_metered_warning); + } + + static inline bool showConfirmationDialog(QWidget *parent, const QString &message = QString(), const QString &confirmButtonText = QString(), const bool show_metered_warning = false) { + const QString warning_message = show_metered_warning ? tr("Warning: You are on a metered connection!") : QString(); + const QString final_message = QString("%1%2").arg(!message.isEmpty() ? message+"\n" : QString(), warning_message); + const QString final_buttonText = !confirmButtonText.isEmpty() ? confirmButtonText : QString("Continue%1").arg(show_metered_warning ? " on Metered" : ""); + + return ConfirmationDialog::confirm(final_message, final_buttonText, parent); + } + + bool is_metered; + bool is_wifi; + double modelDownloadProgress = 0.0; + std::optional selectedModelToDownload; + ButtonControl *currentModelLblBtn; + ModelsFetcher models_fetcher; +}; diff --git a/selfdrive/ui/qt/offroad/sunnypilot_main.h b/selfdrive/ui/qt/offroad/sunnypilot_main.h index 3ec64b88ac..4f8ace83b5 100644 --- a/selfdrive/ui/qt/offroad/sunnypilot_main.h +++ b/selfdrive/ui/qt/offroad/sunnypilot_main.h @@ -7,3 +7,4 @@ #include "selfdrive/ui/qt/offroad/sunnypilot/trips_settings.h" #include "selfdrive/ui/qt/offroad/sunnypilot/monitoring_settings.h" #include "selfdrive/ui/qt/offroad/sunnypilot/osm_settings.h" +#include "selfdrive/ui/qt/offroad/sunnypilot/software_settings_sp.h" diff --git a/selfdrive/ui/qt/widgets/controls.h b/selfdrive/ui/qt/widgets/controls.h index a2956e65d9..077cb135b9 100644 --- a/selfdrive/ui/qt/widgets/controls.h +++ b/selfdrive/ui/qt/widgets/controls.h @@ -373,6 +373,23 @@ class ListWidget : public QWidget { inline void addItem(QLayout *layout) { inner_layout.addLayout(layout); } inline void setSpacing(int spacing) { inner_layout.setSpacing(spacing); } + inline void AddWidgetAt(const int index, QWidget *new_widget) { inner_layout.insertWidget(index, new_widget); } + inline void RemoveWidgetAt(const int index) { + if (QLayoutItem* item; (item = inner_layout.takeAt(index)) != nullptr) { + if(item->widget()) delete item->widget(); + delete item; + } + } + + inline void ReplaceOrAddWidget(QWidget *old_widget, QWidget *new_widget) { + if (const int index = inner_layout.indexOf(old_widget); index != -1) { + RemoveWidgetAt(index); + AddWidgetAt(index, new_widget); + } else { + addItem(new_widget); + } + } + private: void paintEvent(QPaintEvent *) override { QPainter p(this);