diff --git a/common/api/__init__.py b/common/api/__init__.py index 5542760802..3e238ccc7a 100644 --- a/common/api/__init__.py +++ b/common/api/__init__.py @@ -1,13 +1,9 @@ from openpilot.common.api.comma_connect import CommaConnectApi -from sunnypilot.sunnylink.api import SunnylinkApi class Api: - def __init__(self, dongle_id, use_sunnylink=False): - if use_sunnylink: - self.service = SunnylinkApi(dongle_id) - else: - self.service = CommaConnectApi(dongle_id) + def __init__(self, dongle_id): + self.service = CommaConnectApi(dongle_id) def request(self, method, endpoint, **params): return self.service.request(method, endpoint, **params) @@ -22,6 +18,5 @@ class Api: return self.service.get_token(expiry_hours) -def api_get(endpoint, method='GET', timeout=None, access_token=None, use_sunnylink=False, **params): - return SunnylinkApi(None).api_get(endpoint, method, timeout, access_token, **params) if use_sunnylink \ - else CommaConnectApi(None).api_get(endpoint, method, timeout, access_token, **params) +def api_get(endpoint, method='GET', timeout=None, access_token=None, **params): + return CommaConnectApi(None).api_get(endpoint, method, timeout, access_token, **params) diff --git a/common/params_keys.h b/common/params_keys.h index 21aa0da808..aa8c329838 100644 --- a/common/params_keys.h +++ b/common/params_keys.h @@ -148,6 +148,8 @@ inline static std::unordered_map keys = { // sunnylink params {"EnableSunnylinkUploader", PERSISTENT | BACKUP}, {"LastSunnylinkPingTime", CLEAR_ON_MANAGER_START}, + {"SunnylinkCache_Roles", PERSISTENT}, + {"SunnylinkCache_Users", PERSISTENT}, {"SunnylinkDongleId", PERSISTENT}, {"SunnylinkdPid", PERSISTENT}, {"SunnylinkEnabled", PERSISTENT}, diff --git a/selfdrive/ui/qt/api.cc b/selfdrive/ui/qt/api.cc index 6889b40e51..85a3144bd2 100644 --- a/selfdrive/ui/qt/api.cc +++ b/selfdrive/ui/qt/api.cc @@ -79,31 +79,36 @@ 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; - } +QNetworkRequest HttpRequest::prepareRequest(const QString &requestURL) { + QNetworkRequest request; QString token; if (create_jwt) { - token = CommaApi::create_jwt(); + token = GetJwtToken(); } 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()); + request.setRawHeader("User-Agent", GetUserAgent().toUtf8()); if (!token.isEmpty()) { request.setRawHeader(QByteArray("Authorization"), ("JWT " + token).toUtf8()); } + return request; +} - if (method == HttpRequest::Method::GET) { +void HttpRequest::sendRequest(const QString &requestURL, const Method method) { + if (active()) { + qDebug() << "HttpRequest is active"; + return; + } + + QNetworkRequest request = prepareRequest(requestURL); + if (method == Method::GET) { reply = nam()->get(request); - } else if (method == HttpRequest::Method::DELETE) { + } else if (method == Method::DELETE) { reply = nam()->deleteResource(request); } diff --git a/selfdrive/ui/qt/api.h b/selfdrive/ui/qt/api.h index ad64d7e722..80b85f7ca7 100644 --- a/selfdrive/ui/qt/api.h +++ b/selfdrive/ui/qt/api.h @@ -5,6 +5,7 @@ #include #include +#include "util.h" #include "common/util.h" namespace CommaApi { @@ -23,10 +24,11 @@ class HttpRequest : public QObject { Q_OBJECT public: - enum class Method {GET, DELETE}; + enum class Method {GET, DELETE, POST, PUT}; explicit HttpRequest(QObject* parent, bool create_jwt = true, int timeout = 20000); - void sendRequest(const QString &requestURL, const Method method = Method::GET); + virtual void sendRequest(const QString &requestURL, Method method); + void sendRequest(const QString &requestURL) { sendRequest(requestURL, Method::GET);} bool active() const; bool timeout() const; @@ -35,13 +37,14 @@ signals: protected: QNetworkReply *reply = nullptr; - -private: static QNetworkAccessManager *nam(); QTimer *networkTimer = nullptr; bool create_jwt; + virtual QNetworkRequest prepareRequest(const QString& requestURL); + [[nodiscard]] virtual QString GetJwtToken() const { return CommaApi::create_jwt(); } + [[nodiscard]] virtual QString GetUserAgent() const { return getUserAgent(); } -private slots: +protected slots: void requestTimeout(); void requestFinished(); }; diff --git a/selfdrive/ui/sunnypilot/SConscript b/selfdrive/ui/sunnypilot/SConscript index f8b69b2625..72b67dcd18 100644 --- a/selfdrive/ui/sunnypilot/SConscript +++ b/selfdrive/ui/sunnypilot/SConscript @@ -1,4 +1,5 @@ widgets_src = [ + "sunnypilot/qt/request_repeater.cc", "sunnypilot/qt/widgets/toggle.cc", "sunnypilot/qt/widgets/controls.cc", "sunnypilot/qt/widgets/drive_stats.cc", @@ -13,6 +14,7 @@ qt_util = [ qt_src = [ "sunnypilot/ui.cc", + "sunnypilot/qt/api.cc", "sunnypilot/qt/sidebar.cc", "sunnypilot/qt/window.cc", "sunnypilot/qt/home.cc", @@ -21,6 +23,7 @@ qt_src = [ "sunnypilot/qt/offroad/settings/settings.cc", "sunnypilot/qt/offroad/settings/software_panel.cc", "sunnypilot/qt/offroad/settings/sunnylink_panel.cc", + "sunnypilot/qt/offroad/settings/sunnylink/sponsor_widget.cc", "sunnypilot/qt/offroad/settings/sunnypilot_panel.cc", "sunnypilot/qt/offroad/settings/trips_panel.cc", "sunnypilot/qt/offroad/settings/vehicle_panel.cc", @@ -36,11 +39,18 @@ sunnypilot_panel_qt_src = [ "sunnypilot/qt/offroad/settings/sunnypilot/neural_network_lateral_control.cc", ] +network_src = [ + "sunnypilot/qt/network/sunnylink/sunnylink_client.cc", + "sunnypilot/qt/network/sunnylink/services/base_device_service.cc", + "sunnypilot/qt/network/sunnylink/services/role_service.cc", + "sunnypilot/qt/network/sunnylink/services/user_service.cc", +] + vehicle_panel_qt_src = [ "sunnypilot/qt/offroad/settings/vehicle/platform_selector.cc", ] -sp_widgets_src = widgets_src +sp_widgets_src = widgets_src + network_src sp_qt_src = qt_src + sunnypilot_panel_qt_src + vehicle_panel_qt_src sp_qt_util = qt_util diff --git a/selfdrive/ui/sunnypilot/qt/api.cc b/selfdrive/ui/sunnypilot/qt/api.cc new file mode 100644 index 0000000000..d75d8d39f0 --- /dev/null +++ b/selfdrive/ui/sunnypilot/qt/api.cc @@ -0,0 +1,63 @@ +/** + * Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + * + * This file is part of sunnypilot and is licensed under the MIT License. + * See the LICENSE.md file in the root directory for more details. + */ + +#include "selfdrive/ui/sunnypilot/qt/api.h" + +#include +#include + +#include "util.h" +#include "selfdrive/ui/qt/util.h" + +namespace SunnylinkApi { + QString create_jwt(const QJsonObject &payloads, int expiry, bool sunnylink) { + QJsonObject header = {{"alg", "RS256"}}; + + auto t = QDateTime::currentSecsSinceEpoch(); + auto dongle_id = sunnylink ? getSunnylinkDongleId() : getDongleId(); + QJsonObject payload = {{"identity", dongle_id.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 + "." + CommaApi::rsa_sign(hash).toBase64(b64_opts); + } +} // namespace SunnylinkApi + +void HttpRequestSP::sendRequest(const QString& requestURL, Method method, const QByteArray& payload) { + if (active()) { + return; + } + QNetworkRequest request = prepareRequest(requestURL); + + if(!payload.isEmpty() && (method == Method::POST || method == Method::PUT)) { + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + } + + switch (method) { + case Method::GET: + reply = nam()->get(request); + break; + case Method::DELETE: + reply = nam()->deleteResource(request); + break; + case Method::POST: + reply = nam()->post(request, payload); + break; + case Method::PUT: + reply = nam()->put(request, payload); + break; + } + + networkTimer->start(); + connect(reply, &QNetworkReply::finished, this, &HttpRequestSP::requestFinished); +} diff --git a/selfdrive/ui/sunnypilot/qt/api.h b/selfdrive/ui/sunnypilot/qt/api.h new file mode 100644 index 0000000000..592b2e668f --- /dev/null +++ b/selfdrive/ui/sunnypilot/qt/api.h @@ -0,0 +1,36 @@ +/** + * Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + * + * This file is part of sunnypilot and is licensed under the MIT License. + * See the LICENSE.md file in the root directory for more details. + */ + +#pragma once + +#include "selfdrive/ui/qt/api.h" +#include "selfdrive/ui/sunnypilot/qt/util.h" +#include "common/util.h" + +namespace SunnylinkApi { + QByteArray rsa_encrypt(const QByteArray& data); + QByteArray rsa_decrypt(const QByteArray& data); + QString create_jwt(const QJsonObject& payloads = {}, int expiry = 3600, bool sunnylink = false); +} + +class HttpRequestSP : public HttpRequest { + Q_OBJECT + +public: + explicit HttpRequestSP(QObject* parent, bool create_jwt = true, int timeout = 20000, bool sunnylink = false) : + HttpRequest(parent, create_jwt, timeout), sunnylink(sunnylink) {} + + using HttpRequest::sendRequest; + void sendRequest(const QString& requestURL, Method method, const QByteArray& payload); + +private: + bool sunnylink; + +protected: + [[nodiscard]] QString GetJwtToken() const override { return SunnylinkApi::create_jwt({}, 3600, sunnylink); } + [[nodiscard]] QString GetUserAgent() const override { return getUserAgent(sunnylink); } +}; diff --git a/selfdrive/ui/sunnypilot/qt/network/sunnylink/models/role_model.h b/selfdrive/ui/sunnypilot/qt/network/sunnylink/models/role_model.h new file mode 100644 index 0000000000..04b4dccf14 --- /dev/null +++ b/selfdrive/ui/sunnypilot/qt/network/sunnylink/models/role_model.h @@ -0,0 +1,56 @@ +/** + * Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + * + * This file is part of sunnypilot and is licensed under the MIT License. + * See the LICENSE.md file in the root directory for more details. + */ + +#pragma once + +#include + +enum class RoleType { + ReadOnly, + Sponsor, + Admin +}; + +// haha, a role model xD +class RoleModel { +protected: + QJsonObject m_raw_json_object; + +public: + RoleType roleType; + + explicit RoleModel(const RoleType &roleType) : roleType(roleType) { m_raw_json_object = toJson(); } + explicit RoleModel(const QJsonObject &json) : RoleModel(stringToRoleType(json["role_type"].toString())) { m_raw_json_object = json; } + + [[nodiscard]] QJsonObject toJson() const { + QJsonObject json; + json["role_type"] = roleTypeToString(roleType); + return json; + } + + static RoleType stringToRoleType(const QString &roleTypeString) { + if (roleTypeString == "ReadOnly") return RoleType::ReadOnly; + if (roleTypeString == "Sponsor") return RoleType::Sponsor; + + return RoleType::Admin; // Default to Admin + } + + static QString roleTypeToString(const RoleType &roleType) { + switch (roleType) { + case RoleType::ReadOnly: + return "ReadOnly"; + case RoleType::Sponsor: + return "Sponsor"; + default: // RoleType::Admin + return "Admin"; + } + } + + template ::value>::type> T as() const { + return T(m_raw_json_object); + } +}; diff --git a/selfdrive/ui/sunnypilot/qt/network/sunnylink/models/sponsor_role_model.h b/selfdrive/ui/sunnypilot/qt/network/sunnylink/models/sponsor_role_model.h new file mode 100644 index 0000000000..7e1772b591 --- /dev/null +++ b/selfdrive/ui/sunnypilot/qt/network/sunnylink/models/sponsor_role_model.h @@ -0,0 +1,83 @@ +/** + * Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + * + * This file is part of sunnypilot and is licensed under the MIT License. + * See the LICENSE.md file in the root directory for more details. + */ + +#pragma once + +#include + +enum class SponsorTier { + Free, + Novice, + Supporter, + Contributor, + Benefactor, + Guardian, +}; + +// haha, a role model xD +class SponsorRoleModel final : RoleModel { +public: + SponsorTier roleTier; + + explicit SponsorRoleModel(const RoleType &roleType, const SponsorTier &roleTier) : RoleModel(roleType), roleTier(roleTier) {} + explicit SponsorRoleModel(const QJsonObject &json) : RoleModel(json), roleTier(stringToSponsorTier(json["role_tier"].toString())) {} + + [[nodiscard]] QJsonObject toJson() const { + QJsonObject json = RoleModel::toJson(); + json["role_tier"] = sponsorTierToString(roleTier); + return json; + } + + static SponsorTier stringToSponsorTier(const QString &sponsorTierString) { + const auto sponsorTierStringLower = sponsorTierString.toLower(); + if (sponsorTierStringLower == "guardian") return SponsorTier::Guardian; + if (sponsorTierStringLower == "novice") return SponsorTier::Novice; + if (sponsorTierStringLower == "supporter") return SponsorTier::Supporter; + if (sponsorTierStringLower == "contributor") return SponsorTier::Contributor; + if (sponsorTierStringLower == "benefactor") return SponsorTier::Benefactor; + + // Default to Free + return SponsorTier::Free; + } + + static QString sponsorTierToString(const SponsorTier &sponsorTier) { + switch (sponsorTier) { + case SponsorTier::Guardian: + return "Guardian"; + case SponsorTier::Novice: + return "Novice"; + case SponsorTier::Supporter: + return "Supporter"; + case SponsorTier::Contributor: + return "Contributor"; + case SponsorTier::Benefactor: + return "Benefactor"; + + default: // SponsorTier::Free + return "Free"; + } + } + [[nodiscard]] auto getSponsorTierString() const { return sponsorTierToString(roleTier); } + + static QString sponsorTierToColor(const SponsorTier &sponsorTier) { + switch (sponsorTier) { + case SponsorTier::Guardian: + return "gold"; + case SponsorTier::Benefactor: + return "mediumseagreen"; + case SponsorTier::Contributor: + return "steelblue"; + case SponsorTier::Supporter: + return "mediumpurple"; + case SponsorTier::Novice: + return "white"; + default: // SponsorTier::Free + return "silver"; + } + } + [[nodiscard]] auto getSponsorTierColor() const { return sponsorTierToColor(roleTier); } +}; diff --git a/selfdrive/ui/sunnypilot/qt/network/sunnylink/models/user_model.h b/selfdrive/ui/sunnypilot/qt/network/sunnylink/models/user_model.h new file mode 100644 index 0000000000..4d255ac8e3 --- /dev/null +++ b/selfdrive/ui/sunnypilot/qt/network/sunnylink/models/user_model.h @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + * + * This file is part of sunnypilot and is licensed under the MIT License. + * See the LICENSE.md file in the root directory for more details. + */ + +#pragma once + +#include + +class UserModel { +public: + QString device_id; + QString user_id; + qint64 created_at; + qint64 updated_at; + QString token_hash; + + explicit UserModel(const QJsonObject &json) { + device_id = json["device_id"].toString(); + user_id = json["user_id"].toString(); + created_at = json["created_at"].toInt(); + updated_at = json["updated_at"].toInt(); + token_hash = json["token_hash"].toString(); + } + + [[nodiscard]] QJsonObject toJson() const { + QJsonObject json; + json["device_id"] = device_id; + json["user_id"] = user_id; + json["created_at"] = created_at; + json["updated_at"] = updated_at; + json["token_hash"] = token_hash; + return json; + } +}; diff --git a/selfdrive/ui/sunnypilot/qt/network/sunnylink/services/base_device_service.cc b/selfdrive/ui/sunnypilot/qt/network/sunnylink/services/base_device_service.cc new file mode 100644 index 0000000000..3919a1821f --- /dev/null +++ b/selfdrive/ui/sunnypilot/qt/network/sunnylink/services/base_device_service.cc @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + * + * This file is part of sunnypilot and is licensed under the MIT License. + * See the LICENSE.md file in the root directory for more details. + */ + +#include "selfdrive/ui/sunnypilot/qt/network/sunnylink//services/base_device_service.h" + +#include "selfdrive/ui/sunnypilot/qt/request_repeater.h" + +#include "common/swaglog.h" +#include "selfdrive/ui/qt/util.h" +#include "selfdrive/ui/sunnypilot/qt/offroad/settings/sunnylink_panel.h" + +BaseDeviceService::BaseDeviceService(QObject* parent) : QObject(parent) { + param_watcher = new ParamWatcher(this); + connect(param_watcher, &ParamWatcher::paramChanged, [=](const QString ¶m_name, const QString ¶m_value) { + paramsRefresh(); + }); + param_watcher->addParam("SunnylinkEnabled"); +} + +void BaseDeviceService::paramsRefresh() { +} + +void BaseDeviceService::loadDeviceData(const QString &url, bool poll) { + if (!is_sunnylink_enabled()) { + LOGW("Sunnylink is not enabled, refusing to load data."); + return; + } + + auto sl_dongle_id = getSunnylinkDongleId(); + if (!sl_dongle_id.has_value()) + return; + + QString fullUrl = SUNNYLINK_BASE_URL + "/device/" + *sl_dongle_id + url; + if (poll && !isCurrentyPolling()) { + LOGD("Polling %s", qPrintable(fullUrl)); + LOGD("Cache key: SunnylinkCache_%s", qPrintable(QString(getCacheKey()))); + repeater = new RequestRepeaterSP(this, fullUrl, "SunnylinkCache_" + getCacheKey(), 60, false, true); + connect(repeater, &RequestRepeaterSP::requestDone, this, &BaseDeviceService::handleResponse); + } else if (isCurrentyPolling()) { + repeater->ForceUpdate(); + } else { + LOGD("Sending one-time %s", qPrintable(fullUrl)); + initial_request = new HttpRequestSP(this, true, 10000, true); + connect(initial_request, &HttpRequestSP::requestDone, this, &BaseDeviceService::handleResponse); + } +} + +void BaseDeviceService::stopPolling() { + if (repeater != nullptr) { + repeater->deleteLater(); + repeater = nullptr; + } +} diff --git a/selfdrive/ui/sunnypilot/qt/network/sunnylink/services/base_device_service.h b/selfdrive/ui/sunnypilot/qt/network/sunnylink/services/base_device_service.h new file mode 100644 index 0000000000..8ef99d1013 --- /dev/null +++ b/selfdrive/ui/sunnypilot/qt/network/sunnylink/services/base_device_service.h @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + * + * This file is part of sunnypilot and is licensed under the MIT License. + * See the LICENSE.md file in the root directory for more details. + */ + +#pragma once + +#include "selfdrive/ui/sunnypilot/qt/request_repeater.h" +#include "selfdrive/ui/qt/util.h" + +class BaseDeviceService : public QObject { + Q_OBJECT + +protected: + void paramsRefresh(); + void loadDeviceData(const QString &url, bool poll = false); + virtual void handleResponse(const QString &response, bool success) = 0; + + static bool is_sunnylink_enabled() { return Params().getBool("SunnylinkEnabled");} + ParamWatcher* param_watcher; + HttpRequestSP* initial_request = nullptr; + RequestRepeaterSP* repeater = nullptr; + +public: + explicit BaseDeviceService(QObject* parent = nullptr); + virtual QString getCacheKey() const = 0; + bool isCurrentyPolling() {return repeater != nullptr;} + void stopPolling(); +}; diff --git a/selfdrive/ui/sunnypilot/qt/network/sunnylink/services/role_service.cc b/selfdrive/ui/sunnypilot/qt/network/sunnylink/services/role_service.cc new file mode 100644 index 0000000000..bffe32839f --- /dev/null +++ b/selfdrive/ui/sunnypilot/qt/network/sunnylink/services/role_service.cc @@ -0,0 +1,36 @@ +/** + * Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + * + * This file is part of sunnypilot and is licensed under the MIT License. + * See the LICENSE.md file in the root directory for more details. + */ + +#include "selfdrive/ui/sunnypilot/qt/network/sunnylink/services/role_service.h" + +#include +#include + +RoleService::RoleService(QObject* parent) : BaseDeviceService(parent) {} + +void RoleService::load() { + loadDeviceData(url); +} + +void RoleService::startPolling() { + loadDeviceData(url, true); +} + +void RoleService::handleResponse(const QString &response, bool success) { + if (!success) return; + + QJsonDocument doc = QJsonDocument::fromJson(response.toUtf8()); + QJsonArray jsonArray = doc.array(); + + std::vector roles; + for (const auto &value : jsonArray) { + roles.emplace_back(value.toObject()); + } + + emit rolesReady(roles); + uiStateSP()->setSunnylinkRoles(roles); +} diff --git a/selfdrive/ui/sunnypilot/qt/network/sunnylink/services/role_service.h b/selfdrive/ui/sunnypilot/qt/network/sunnylink/services/role_service.h new file mode 100644 index 0000000000..abd0e1ab83 --- /dev/null +++ b/selfdrive/ui/sunnypilot/qt/network/sunnylink/services/role_service.h @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + * + * This file is part of sunnypilot and is licensed under the MIT License. + * See the LICENSE.md file in the root directory for more details. + */ + +#pragma once + +#include + +#include "selfdrive/ui/sunnypilot/qt/network/sunnylink/services/base_device_service.h" +#include "selfdrive/ui/sunnypilot/qt/network/sunnylink/models/role_model.h" + +class RoleService : public BaseDeviceService { + Q_OBJECT + +public: + explicit RoleService(QObject* parent = nullptr); + void load(); + void startPolling(); + [[nodiscard]] QString getCacheKey() const final { return "Roles"; } + +signals: + void rolesReady(const std::vector &roles); + +protected: + void handleResponse(const QString&response, bool success) override; + +private: + QString url = "/roles"; +}; diff --git a/selfdrive/ui/sunnypilot/qt/network/sunnylink/services/user_service.cc b/selfdrive/ui/sunnypilot/qt/network/sunnylink/services/user_service.cc new file mode 100644 index 0000000000..cd16360c26 --- /dev/null +++ b/selfdrive/ui/sunnypilot/qt/network/sunnylink/services/user_service.cc @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + * + * This file is part of sunnypilot and is licensed under the MIT License. + * See the LICENSE.md file in the root directory for more details. + */ + +#include "selfdrive/ui/sunnypilot/qt/network/sunnylink/services/user_service.h" + +#include +#include + +#include "selfdrive/ui/sunnypilot/ui.h" + +UserService::UserService(QObject* parent) : BaseDeviceService(parent) { + url = "/users"; +} + +void UserService::load() { + loadDeviceData(url); +} + +void UserService::startPolling() { + loadDeviceData(url, true); +} + +void UserService::handleResponse(const QString &response, bool success) { + if (!success) { + return; + } + + QJsonDocument doc = QJsonDocument::fromJson(response.toUtf8()); + QJsonArray jsonArray = doc.array(); + + std::vector users; + for (const auto &value : jsonArray) { + users.emplace_back(value.toObject()); + } + + emit usersReady(users); + uiStateSP()->setSunnylinkDeviceUsers(users); +} diff --git a/selfdrive/ui/sunnypilot/qt/network/sunnylink/services/user_service.h b/selfdrive/ui/sunnypilot/qt/network/sunnylink/services/user_service.h new file mode 100644 index 0000000000..90eb354751 --- /dev/null +++ b/selfdrive/ui/sunnypilot/qt/network/sunnylink/services/user_service.h @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + * + * This file is part of sunnypilot and is licensed under the MIT License. + * See the LICENSE.md file in the root directory for more details. + */ + +#pragma once + +#include + +#include "selfdrive/ui/sunnypilot/qt/network/sunnylink/services/base_device_service.h" +#include "selfdrive/ui/sunnypilot/qt/network/sunnylink/models/user_model.h" + +class UserService : public BaseDeviceService { + Q_OBJECT + +public: + explicit UserService(QObject* parent = nullptr); + void load(); + void startPolling(); + [[nodiscard]] QString getCacheKey() const final { return "Users"; }; + +signals: + void usersReady(const std::vector&users); + +protected: + void handleResponse(const QString&response, bool success) override; + +private: + QString url = "/users"; +}; diff --git a/selfdrive/ui/sunnypilot/qt/network/sunnylink/sunnylink_client.cc b/selfdrive/ui/sunnypilot/qt/network/sunnylink/sunnylink_client.cc new file mode 100644 index 0000000000..4428eb55c4 --- /dev/null +++ b/selfdrive/ui/sunnypilot/qt/network/sunnylink/sunnylink_client.cc @@ -0,0 +1,14 @@ +/** + * Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + * + * This file is part of sunnypilot and is licensed under the MIT License. + * See the LICENSE.md file in the root directory for more details. + */ + +#include "selfdrive/ui/sunnypilot/qt/network/sunnylink/sunnylink_client.h" +#include "selfdrive/ui/sunnypilot/qt/network/sunnylink/services/user_service.h" + +SunnylinkClient::SunnylinkClient(QObject* parent) : QObject(parent) { + role_service = new RoleService(parent); + user_service = new UserService(parent); +} diff --git a/selfdrive/ui/sunnypilot/qt/network/sunnylink/sunnylink_client.h b/selfdrive/ui/sunnypilot/qt/network/sunnylink/sunnylink_client.h new file mode 100644 index 0000000000..0961449926 --- /dev/null +++ b/selfdrive/ui/sunnypilot/qt/network/sunnylink/sunnylink_client.h @@ -0,0 +1,22 @@ +/** + * Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + * + * This file is part of sunnypilot and is licensed under the MIT License. + * See the LICENSE.md file in the root directory for more details. + */ + +#pragma once + +#include + +#include "selfdrive/ui/sunnypilot/qt/network/sunnylink/services/role_service.h" +#include "selfdrive/ui/sunnypilot/qt/network/sunnylink/services/user_service.h" + +class SunnylinkClient : public QObject { + Q_OBJECT + +public: + explicit SunnylinkClient(QObject* parent); + RoleService* role_service; + UserService* user_service; +}; diff --git a/selfdrive/ui/sunnypilot/qt/offroad/settings/sunnylink/sponsor_widget.cc b/selfdrive/ui/sunnypilot/qt/offroad/settings/sunnylink/sponsor_widget.cc new file mode 100644 index 0000000000..7b5ce48238 --- /dev/null +++ b/selfdrive/ui/sunnypilot/qt/offroad/settings/sunnylink/sponsor_widget.cc @@ -0,0 +1,142 @@ +/** + * Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + * + * This file is part of sunnypilot and is licensed under the MIT License. + * See the LICENSE.md file in the root directory for more details. + */ + +#include "selfdrive/ui/sunnypilot/qt/offroad/settings/sunnylink/sponsor_widget.h" + +#include "selfdrive/ui/sunnypilot/ui.h" +#include "selfdrive/ui/sunnypilot/qt/api.h" +#include "selfdrive/ui/sunnypilot/qt/util.h" +#include "selfdrive/ui/sunnypilot/qt/network/sunnylink/sunnylink_client.h" + +// Sponsor Upsell +using qrcodegen::QrCode; + +SunnylinkSponsorQRWidget::SunnylinkSponsorQRWidget(bool sponsor_pair, QWidget* parent) : QWidget(parent), sponsor_pair(sponsor_pair) { + timer = new QTimer(this); + connect(timer, &QTimer::timeout, this, &SunnylinkSponsorQRWidget::refresh); +} + +void SunnylinkSponsorQRWidget::showEvent(QShowEvent *event) { + refresh(); + timer->start(5 * 60 * 1000); + device()->setOffroadBrightness(100); +} + +void SunnylinkSponsorQRWidget::hideEvent(QHideEvent *event) { + timer->stop(); + device()->setOffroadBrightness(BACKLIGHT_OFFROAD); +} + +void SunnylinkSponsorQRWidget::refresh() { + QString qrString; + + if (sponsor_pair) { + QString token = SunnylinkApi::create_jwt({}, 3600, true); + auto sl_dongle_id = getSunnylinkDongleId(); + QByteArray payload = QString("1|" + sl_dongle_id.value_or("") + "|" + token).toUtf8().toBase64(); + qrString = SUNNYLINK_BASE_URL + "/sso?state=" + payload; + } else { + qrString = "https://github.com/sponsors/sunnyhaibin"; + } + + this->updateQrCode(qrString); + update(); +} + +void SunnylinkSponsorQRWidget::updateQrCode(const QString &text) { + QrCode qr = QrCode::encodeText(text.toUtf8().data(), QrCode::Ecc::LOW); + qint32 sz = qr.getSize(); + QImage im(sz, sz, QImage::Format_RGB32); + + QRgb black = qRgb(0, 0, 0); + QRgb white = qRgb(255, 255, 255); + for (int y = 0; y < sz; y++) { + for (int x = 0; x < sz; x++) { + im.setPixel(x, y, qr.getModule(x, y) ? black : white); + } + } + + // Integer division to prevent anti-aliasing + int final_sz = ((width() / sz) - 1) * sz; + img = QPixmap::fromImage(im.scaled(final_sz, final_sz, Qt::KeepAspectRatio), Qt::MonoOnly); +} + +void SunnylinkSponsorQRWidget::paintEvent(QPaintEvent *e) { + QPainter p(this); + p.fillRect(rect(), Qt::white); + + QSize s = (size() - img.size()) / 2; + p.drawPixmap(s.width(), s.height(), img); +} + +QStringList SunnylinkSponsorPopup::getInstructions(bool sponsor_pair) { + QStringList instructions; + if (sponsor_pair) { + instructions << tr("Scan the QR code to login to your GitHub account") + << tr("Follow the prompts to complete the pairing process") + << tr("Re-enter the \"sunnylink\" panel to verify sponsorship status") + << tr("If sponsorship status was not updated, please contact a moderator on Discord at https://discord.gg/sunnypilot"); + } else { + instructions << tr("Scan the QR code to visit sunnyhaibin's GitHub Sponsors page") + << tr("Choose your sponsorship tier and confirm your support") + << tr("Join our community on Discord at https://discord.gg/sunnypilot and reach out to a moderator to confirm your sponsor status"); + } + return instructions; +} + +SunnylinkSponsorPopup::SunnylinkSponsorPopup(bool sponsor_pair, QWidget *parent) : DialogBase(parent), sponsor_pair(sponsor_pair) { + auto *hlayout = new QHBoxLayout(this); + auto sunnylink_client = new SunnylinkClient(this); + hlayout->setContentsMargins(0, 0, 0, 0); + hlayout->setSpacing(0); + + setStyleSheet("SunnylinkSponsorPopup { background-color: #E0E0E0; }"); + + // text + auto vlayout = new QVBoxLayout(); + vlayout->setContentsMargins(85, 70, 50, 70); + vlayout->setSpacing(50); + hlayout->addLayout(vlayout, 1); + { + auto close = new QPushButton(QIcon(":/icons/close.svg"), "", this); + close->setIconSize(QSize(80, 80)); + close->setStyleSheet("border: none;"); + vlayout->addWidget(close, 0, Qt::AlignLeft); + connect(close, &QPushButton::clicked, this, [=] { + sunnylink_client->role_service->load(); + sunnylink_client->user_service->load(); + QDialog::reject(); + }); + + //vlayout->addSpacing(30); + + const QString titleText = sponsor_pair ? tr("Pair your GitHub account") : tr("Early Access: Become a sunnypilot Sponsor"); + const auto title = new QLabel(titleText, this); + title->setStyleSheet("font-size: 75px; color: black;"); + title->setWordWrap(true); + vlayout->addWidget(title); + + QStringList instructions = getInstructions(sponsor_pair); + QString instructionsHtml = "
    "; + for (const auto & instruction : instructions) { + instructionsHtml += QString("
  1. %1
  2. ").arg(instruction); + } + instructionsHtml += "
"; + const auto instructionsLabel = new QLabel(instructionsHtml, this); + + + instructionsLabel->setStyleSheet("font-size: 47px; font-weight: bold; color: black;"); + instructionsLabel->setWordWrap(true); + vlayout->addWidget(instructionsLabel); + + vlayout->addStretch(); + } + + // QR code + auto *qr = new SunnylinkSponsorQRWidget(sponsor_pair, this); + hlayout->addWidget(qr, 1); +} diff --git a/selfdrive/ui/sunnypilot/qt/offroad/settings/sunnylink/sponsor_widget.h b/selfdrive/ui/sunnypilot/qt/offroad/settings/sunnylink/sponsor_widget.h new file mode 100644 index 0000000000..9f15a6084f --- /dev/null +++ b/selfdrive/ui/sunnypilot/qt/offroad/settings/sunnylink/sponsor_widget.h @@ -0,0 +1,48 @@ +/** + * Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + * + * This file is part of sunnypilot and is licensed under the MIT License. + * See the LICENSE.md file in the root directory for more details. + */ + +#pragma once + +#include +#include + +#include "common/util.h" +#include "selfdrive/ui/sunnypilot/qt/widgets/controls.h" + +const QString SUNNYLINK_BASE_URL = util::getenv("SUNNYLINK_API_HOST", "https://stg.api.sunnypilot.ai").c_str(); + +class SunnylinkSponsorQRWidget : public QWidget { + Q_OBJECT + +public: + explicit SunnylinkSponsorQRWidget(bool sponsor_pair = false, QWidget* parent = 0); + void paintEvent(QPaintEvent*) override; + +private: + QPixmap img; + QTimer *timer; + void updateQrCode(const QString &text); + void showEvent(QShowEvent *event) override; + void hideEvent(QHideEvent *event) override; + + bool sponsor_pair = false; + +private slots: + void refresh(); +}; + +// sponsor popup widget +class SunnylinkSponsorPopup : public DialogBase { + Q_OBJECT + +public: + explicit SunnylinkSponsorPopup(bool sponsor_pair = false, QWidget* parent = 0); + +private: + static QStringList getInstructions(bool sponsor_pair); + bool sponsor_pair = false; +}; diff --git a/selfdrive/ui/sunnypilot/qt/offroad/settings/sunnylink_panel.cc b/selfdrive/ui/sunnypilot/qt/offroad/settings/sunnylink_panel.cc index 12ae2209f2..706e89ddf0 100644 --- a/selfdrive/ui/sunnypilot/qt/offroad/settings/sunnylink_panel.cc +++ b/selfdrive/ui/sunnypilot/qt/offroad/settings/sunnylink_panel.cc @@ -12,12 +12,26 @@ SunnylinkPanel::SunnylinkPanel(QWidget *parent) : QFrame(parent) { main_layout = new QStackedLayout(this); - ListWidget *list = new ListWidget(this, false); + sunnylink_client = new SunnylinkClient(this); + param_watcher = new ParamWatcher(this); + param_watcher->addParam("SunnylinkEnabled"); + connect(param_watcher, &ParamWatcher::paramChanged, [=](const QString ¶m_name, const QString ¶m_value) { + paramsRefresh(param_name, param_value); + }); + + is_sunnylink_enabled = Params().getBool("SunnylinkEnabled"); + connect(uiStateSP(), &UIStateSP::sunnylinkRolesChanged, this, &SunnylinkPanel::updatePanel); + connect(uiStateSP(), &UIStateSP::sunnylinkDeviceUsersChanged, this, &SunnylinkPanel::updatePanel); + connect(uiStateSP(), &UIStateSP::offroadTransition, [=](bool offroad) { + is_onroad = !offroad; + updatePanel(); + }); sunnylinkScreen = new QWidget(this); - QVBoxLayout *vlayout = new QVBoxLayout(sunnylinkScreen); + auto vlayout = new QVBoxLayout(sunnylinkScreen); vlayout->setContentsMargins(50, 20, 50, 20); + auto *list = new ListWidget(this, false); QString sunnylinkEnabledBtnDesc = tr("This is the master switch, it will allow you to cutoff any sunnylink requests should you want to do that."); sunnylinkEnabledBtn = new ParamControl( "SunnylinkEnabled", @@ -26,23 +40,47 @@ SunnylinkPanel::SunnylinkPanel(QWidget *parent) : QFrame(parent) { ""); list->addItem(sunnylinkEnabledBtn); + status_popup = new SunnylinkSponsorPopup(false, this); + sponsorBtn = new ButtonControlSP( + tr("Sponsor Status"), tr("SPONSOR"), + tr("Become a sponsor of sunnypilot to get early access to sunnylink features when they become available.")); + list->addItem(sponsorBtn); + connect(sponsorBtn, &ButtonControlSP::clicked, [=]() { + status_popup->exec(); + }); + list->addItem(horizontal_line()); + + pair_popup = new SunnylinkSponsorPopup(true, this); + pairSponsorBtn = new ButtonControlSP( + tr("Pair GitHub Account"), tr("PAIR"), + tr("Pair your GitHub account to grant your device sponsor benefits, including API access on sunnylink.") + "🌟"); + list->addItem(pairSponsorBtn); + connect(pairSponsorBtn, &ButtonControlSP::clicked, [=]() { + if (getSunnylinkDongleId().value_or(tr("N/A")) == "N/A") { + ConfirmationDialog::alert(tr("sunnylink Dongle ID not found. This may be due to weak internet connection or sunnylink registration issue. Please reboot and try again."), this); + } else { + pair_popup->exec(); + } + }); + list->addItem(horizontal_line()); + connect(sunnylinkEnabledBtn, &ParamControl::showDescriptionEvent, [=]() { // resets the description to the default one for the Easter egg sunnylinkEnabledBtn->setDescription(sunnylinkEnabledBtnDesc); }); connect(sunnylinkEnabledBtn, &ParamControl::toggleFlipped, [=](bool enabled) { + QString description; if (enabled) { - auto proud_description = ""+ tr("🎉Welcome back! We're excited to see you've enabled sunnylink again! 🚀")+ ""; - sunnylinkEnabledBtn->showDescription(); - sunnylinkEnabledBtn->setDescription(proud_description); + description = ""+ tr("🎉Welcome back! We're excited to see you've enabled sunnylink again! 🚀")+ ""; } else { - auto shame_description = ""+ tr("👋Not going to lie, it's sad to see you disabled sunnylink 😢, but we'll be here when you're ready to come back 🎉.")+ ""; - sunnylinkEnabledBtn->showDescription(); - sunnylinkEnabledBtn->setDescription(shame_description); - } + description = ""+ tr("👋Not going to lie, it's sad to see you disabled sunnylink 😢, but we'll be here when you're ready to come back 🎉.")+ ""; - updatePanel(offroad); + } + sunnylinkEnabledBtn->showDescription(); + sunnylinkEnabledBtn->setDescription(description); + + updatePanel(); }); QObject::connect(uiState(), &UIState::offroadTransition, this, &SunnylinkPanel::updatePanel); @@ -51,22 +89,81 @@ SunnylinkPanel::SunnylinkPanel(QWidget *parent) : QFrame(parent) { vlayout->addWidget(sunnylinkScroller); main_layout->addWidget(sunnylinkScreen); + + if (is_sunnylink_enabled) { + startSunnylink(); + } +} + +void SunnylinkPanel::paramsRefresh(const QString ¶m_name, const QString ¶m_value) { + // We do it on paramsRefresh because the toggleEvent happens before the value is updated + if (param_name == "SunnylinkEnabled" && param_value == "1") { + startSunnylink(); + } else if (param_name == "SunnylinkEnabled" && param_value == "0") { + stopSunnylink(); + } + + updatePanel(); +} + +void SunnylinkPanel::startSunnylink() const { + if (!sunnylink_client->role_service->isCurrentyPolling()) { + sunnylink_client->role_service->startPolling(); + } else { + sunnylink_client->role_service->load(); + } + + if (!sunnylink_client->user_service->isCurrentyPolling()) { + sunnylink_client->user_service->startPolling(); + } else { + sunnylink_client->user_service->load(); + } +} + +void SunnylinkPanel::stopSunnylink() const { + sunnylink_client->role_service->stopPolling(); + sunnylink_client->user_service->stopPolling(); } void SunnylinkPanel::showEvent(QShowEvent *event) { - updatePanel(offroad); + updatePanel(); + if (is_sunnylink_enabled) { + startSunnylink(); + } } -void SunnylinkPanel::updatePanel(bool _offroad) { - QString sunnylink_device_id = tr("Device ID") + " " + getSunnylinkDongleId().value_or(tr("N/A")); - - sunnylinkEnabledBtn->setEnabled(_offroad); - - if (sunnylinkEnabledBtn->isToggled()) { - sunnylinkEnabledBtn->setValue(sunnylink_device_id); - } else { - sunnylinkEnabledBtn->setValue(""); +void SunnylinkPanel::updatePanel() { + if (!isVisible()) { + return; } - offroad = _offroad; + const auto sunnylinkDongleId = getSunnylinkDongleId().value_or(tr("N/A")); + sunnylinkEnabledBtn->setEnabled(!is_onroad); + + is_sunnylink_enabled = Params().getBool("SunnylinkEnabled"); + bool is_sub = uiStateSP()->isSunnylinkSponsor() && is_sunnylink_enabled; + auto max_current_sponsor_rule = uiStateSP()->sunnylinkSponsorRole(); + auto role_name = max_current_sponsor_rule.getSponsorTierString(); + std::optional role_color = max_current_sponsor_rule.getSponsorTierColor(); + bool is_paired = uiStateSP()->isSunnylinkPaired(); + auto paired_users = uiStateSP()->sunnylinkDeviceUsers(); + + sunnylinkEnabledBtn->setEnabled(!is_onroad); + sunnylinkEnabledBtn->setValue(tr("Device ID") + " " + sunnylinkDongleId); + + sponsorBtn->setEnabled(!is_onroad && is_sunnylink_enabled); + sponsorBtn->setText(is_sub ? tr("THANKS ♥")/* + " ♥️"*/ : tr("SPONSOR")); + sponsorBtn->setValue(is_sub ? tr(role_name.toStdString().c_str()) : tr("Not Sponsor"), role_color); + + pairSponsorBtn->setEnabled(!is_onroad && is_sunnylink_enabled); + pairSponsorBtn->setValue(is_paired ? tr("Paired") : tr("Not Paired")); + + + if (!is_sunnylink_enabled) { + sunnylinkEnabledBtn->setValue(""); + sponsorBtn->setValue(""); + pairSponsorBtn->setValue(""); + } + + update(); } diff --git a/selfdrive/ui/sunnypilot/qt/offroad/settings/sunnylink_panel.h b/selfdrive/ui/sunnypilot/qt/offroad/settings/sunnylink_panel.h index 824a081af3..d231757e36 100644 --- a/selfdrive/ui/sunnypilot/qt/offroad/settings/sunnylink_panel.h +++ b/selfdrive/ui/sunnypilot/qt/offroad/settings/sunnylink_panel.h @@ -7,7 +7,10 @@ #pragma once +#include "selfdrive/ui/sunnypilot/qt/network/sunnylink/sunnylink_client.h" + #include "selfdrive/ui/sunnypilot/qt/offroad/settings/settings.h" +#include "selfdrive/ui/sunnypilot/qt/offroad/settings/sunnylink/sponsor_widget.h" #include "selfdrive/ui/sunnypilot/qt/widgets/scrollview.h" class SunnylinkPanel : public QFrame { @@ -16,16 +19,29 @@ class SunnylinkPanel : public QFrame { public: explicit SunnylinkPanel(QWidget *parent = nullptr); void showEvent(QShowEvent *event) override; + void paramsRefresh(const QString¶m_name, const QString¶m_value); public slots: - void updatePanel(bool _offroad); + void updatePanel(); private: Params params; QStackedLayout *main_layout = nullptr; QWidget *sunnylinkScreen = nullptr; ScrollViewSP *sunnylinkScroller = nullptr; - bool offroad; + SunnylinkSponsorPopup *status_popup; + SunnylinkSponsorPopup *pair_popup; + ButtonControlSP* sponsorBtn; + ButtonControlSP* pairSponsorBtn; + SunnylinkClient* sunnylink_client; ParamControl *sunnylinkEnabledBtn; + bool is_onroad = false; + bool is_backup = false; + bool is_restore = false; + bool is_sunnylink_enabled = false; + ParamWatcher *param_watcher; + QString sunnylinkBtnDescription; + void stopSunnylink() const; + void startSunnylink() const; }; diff --git a/selfdrive/ui/sunnypilot/qt/request_repeater.cc b/selfdrive/ui/sunnypilot/qt/request_repeater.cc new file mode 100644 index 0000000000..e137d56172 --- /dev/null +++ b/selfdrive/ui/sunnypilot/qt/request_repeater.cc @@ -0,0 +1,41 @@ +/** + * Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + * + * This file is part of sunnypilot and is licensed under the MIT License. + * See the LICENSE.md file in the root directory for more details. + */ + +#include "selfdrive/ui/sunnypilot/qt/request_repeater.h" + +RequestRepeaterSP::RequestRepeaterSP(QObject *parent, const QString &requestURL, const QString &cacheKey, + int period, bool whileOnroad, bool sunnylink) : HttpRequestSP(parent, true, 20000, sunnylink) { + request_url = requestURL; + while_onroad = whileOnroad; + timer = new QTimer(this); + timer->setTimerType(Qt::VeryCoarseTimer); + connect(timer, &QTimer::timeout, [=]() { this->timerTick(); }); + timer->start(period * 1000); + + if (!cacheKey.isEmpty()) { + prevResp = QString::fromStdString(params.get(cacheKey.toStdString())); + if (!prevResp.isEmpty()) { + QTimer::singleShot(500, [=]() { emit requestDone(prevResp, true, QNetworkReply::NoError); }); + } + connect(this, &HttpRequest::requestDone, [=](const QString &resp, bool success) { + if (success && resp != prevResp) { + params.put(cacheKey.toStdString(), resp.toStdString()); + prevResp = resp; + } + }); + } + + // Don't wait for the timer to fire to send the first request + ForceUpdate(); +} + +void RequestRepeaterSP::timerTick() { + if ((!uiState()->scene.started || while_onroad) && device()->isAwake() && !active()) { + LOGD("Sending request for %s", qPrintable(request_url)); + sendRequest(request_url); + } +} diff --git a/selfdrive/ui/sunnypilot/qt/request_repeater.h b/selfdrive/ui/sunnypilot/qt/request_repeater.h new file mode 100644 index 0000000000..0cdb370f9b --- /dev/null +++ b/selfdrive/ui/sunnypilot/qt/request_repeater.h @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + * + * This file is part of sunnypilot and is licensed under the MIT License. + * See the LICENSE.md file in the root directory for more details. + */ + +#pragma once + +#include "selfdrive/ui/qt/request_repeater.h" + +#include "common/swaglog.h" +#include "common/util.h" +#include "selfdrive/ui/sunnypilot/ui.h" +#include "selfdrive/ui/sunnypilot/qt/api.h" + +class RequestRepeaterSP : public HttpRequestSP { + +private: + Params params; + QTimer *timer; + QString prevResp; + QString request_url; + bool while_onroad; + void timerTick(); + +public: + RequestRepeaterSP(QObject *parent, const QString &requestURL, const QString &cacheKey = "", int period = 0, bool whileOnroad=false, bool sunnylink = false); + void ForceUpdate() { + LOGD("Forcing update for %s", qPrintable(request_url)); + timerTick(); + } +}; diff --git a/selfdrive/ui/sunnypilot/ui.cc b/selfdrive/ui/sunnypilot/ui.cc index 37da83949b..e27771b9bb 100644 --- a/selfdrive/ui/sunnypilot/ui.cc +++ b/selfdrive/ui/sunnypilot/ui.cc @@ -48,6 +48,16 @@ UIStateSP *uiStateSP() { return &ui_state; } +void UIStateSP::setSunnylinkRoles(const std::vector& roles) { + sunnylinkRoles = roles; + emit sunnylinkRolesChanged(roles); +} + +void UIStateSP::setSunnylinkDeviceUsers(const std::vector& users) { + sunnylinkUsers = users; + emit sunnylinkDeviceUsersChanged(users); +} + DeviceSP *deviceSP() { static DeviceSP _device; return &_device; diff --git a/selfdrive/ui/sunnypilot/ui.h b/selfdrive/ui/sunnypilot/ui.h index 65c9237969..08e1d37b0f 100644 --- a/selfdrive/ui/sunnypilot/ui.h +++ b/selfdrive/ui/sunnypilot/ui.h @@ -7,6 +7,11 @@ #pragma once +#include + +#include "selfdrive/ui/sunnypilot/qt/network/sunnylink/models/user_model.h" +#include "selfdrive/ui/sunnypilot/qt/network/sunnylink/models/role_model.h" +#include "selfdrive/ui/sunnypilot/qt/network/sunnylink/models/sponsor_role_model.h" #include "selfdrive/ui/ui.h" class UIStateSP : public UIState { @@ -15,12 +20,54 @@ class UIStateSP : public UIState { public: UIStateSP(QObject *parent = 0); void updateStatus() override; + void setSunnylinkRoles(const std::vector &roles); + void setSunnylinkDeviceUsers(const std::vector &users); + + inline std::vector sunnylinkDeviceRoles() const { return sunnylinkRoles; } + inline bool isSunnylinkAdmin() const { + return std::any_of(sunnylinkRoles.begin(), sunnylinkRoles.end(), [](const RoleModel &role) { + return role.roleType == RoleType::Admin; + }); + } + inline bool isSunnylinkSponsor() const { + return std::any_of(sunnylinkRoles.begin(), sunnylinkRoles.end(), [](const RoleModel &role) { + return role.roleType == RoleType::Sponsor && role.as().roleTier != SponsorTier::Free; + }); + } + inline SponsorRoleModel sunnylinkSponsorRole() const { + std::optional sponsorRoleWithHighestTier = std::nullopt; + for (const auto &role : sunnylinkRoles) { + if(role.roleType != RoleType::Sponsor) + continue; + + if (auto sponsorRole = role.as(); !sponsorRoleWithHighestTier.has_value() || sponsorRoleWithHighestTier->roleTier < sponsorRole.roleTier) { + sponsorRoleWithHighestTier = sponsorRole; + } + } + return sponsorRoleWithHighestTier.value_or(SponsorRoleModel(RoleType::Sponsor, SponsorTier::Free)); + } + inline SponsorTier sunnylinkSponsorTier() const { + return sunnylinkSponsorRole().roleTier; + } + inline std::vector sunnylinkDeviceUsers() const { return sunnylinkUsers; } + inline bool isSunnylinkPaired() const { + return std::any_of(sunnylinkUsers.begin(), sunnylinkUsers.end(), [](const UserModel &user) { + return user.user_id.toLower() != "unregisteredsponsor" && user.user_id.toLower() != "temporarysponsor"; + }); + } signals: + void sunnylinkRoleChanged(bool subscriber); + void sunnylinkRolesChanged(std::vector roles); + void sunnylinkDeviceUsersChanged(std::vector users); void uiUpdate(const UIStateSP &s); private slots: void update() override; + +private: + std::vector sunnylinkRoles = {}; + std::vector sunnylinkUsers = {}; }; UIStateSP *uiStateSP(); diff --git a/selfdrive/ui/tests/test_ui/run.py b/selfdrive/ui/tests/test_ui/run.py index f371c7bbae..f3fd6aa915 100755 --- a/selfdrive/ui/tests/test_ui/run.py +++ b/selfdrive/ui/tests/test_ui/run.py @@ -207,6 +207,16 @@ def setup_settings_sunnylink(click, pm: PubMaster, scroll=None): click(278, 522) time.sleep(UI_DELAY) +def setup_settings_sunnylink_sponsor_button(click, pm: PubMaster, scroll=None): + setup_settings_sunnylink(click, pm) + click(1967, 225) + time.sleep(UI_DELAY) + +def setup_settings_sunnylink_pair_button(click, pm: PubMaster, scroll=None): + setup_settings_sunnylink(click, pm) + click(1967, 432) + time.sleep(UI_DELAY) + def setup_settings_sunnypilot(click, pm: PubMaster, scroll=None): setup_settings_device(click, pm) click(278, 852) @@ -268,6 +278,8 @@ CASES = { CASES.update({ "settings_sunnylink": setup_settings_sunnylink, + "settings_sunnylink_sponsor_button": setup_settings_sunnylink_sponsor_button, + "settings_sunnylink_pair_button": setup_settings_sunnylink_pair_button, "settings_sunnypilot": setup_settings_sunnypilot, "settings_sunnypilot_mads": setup_settings_sunnypilot_mads, "settings_trips": setup_settings_trips, diff --git a/selfdrive/ui/translations/main_ar.ts b/selfdrive/ui/translations/main_ar.ts index 40c61f8cd4..4cb648dc3d 100644 --- a/selfdrive/ui/translations/main_ar.ts +++ b/selfdrive/ui/translations/main_ar.ts @@ -981,7 +981,7 @@ This may take up to a minute. Firehose - + خرطوم الحريق @@ -1450,6 +1450,89 @@ This may take up to a minute. N/A غير متاح + + Sponsor Status + + + + SPONSOR + + + + Become a sponsor of sunnypilot to get early access to sunnylink features when they become available. + + + + Pair GitHub Account + + + + PAIR + إقران + + + Pair your GitHub account to grant your device sponsor benefits, including API access on sunnylink. + + + + sunnylink Dongle ID not found. This may be due to weak internet connection or sunnylink registration issue. Please reboot and try again. + + + + Not Sponsor + + + + Paired + + + + Not Paired + + + + THANKS ♥ + + + + + SunnylinkSponsorPopup + + Scan the QR code to login to your GitHub account + + + + Follow the prompts to complete the pairing process + + + + Re-enter the "sunnylink" panel to verify sponsorship status + + + + If sponsorship status was not updated, please contact a moderator on Discord at https://discord.gg/sunnypilot + + + + Scan the QR code to visit sunnyhaibin's GitHub Sponsors page + + + + Choose your sponsorship tier and confirm your support + + + + Join our community on Discord at https://discord.gg/sunnypilot and reach out to a moderator to confirm your sponsor status + + + + Pair your GitHub account + + + + Early Access: Become a sunnypilot Sponsor + + SunnypilotPanel diff --git a/selfdrive/ui/translations/main_de.ts b/selfdrive/ui/translations/main_de.ts index 773a354d9e..5a994c4ad6 100644 --- a/selfdrive/ui/translations/main_de.ts +++ b/selfdrive/ui/translations/main_de.ts @@ -1428,6 +1428,89 @@ This may take up to a minute. N/A Nicht verfügbar + + Sponsor Status + + + + SPONSOR + + + + Become a sponsor of sunnypilot to get early access to sunnylink features when they become available. + + + + Pair GitHub Account + + + + PAIR + + + + Pair your GitHub account to grant your device sponsor benefits, including API access on sunnylink. + + + + sunnylink Dongle ID not found. This may be due to weak internet connection or sunnylink registration issue. Please reboot and try again. + + + + Not Sponsor + + + + Paired + + + + Not Paired + + + + THANKS ♥ + + + + + SunnylinkSponsorPopup + + Scan the QR code to login to your GitHub account + + + + Follow the prompts to complete the pairing process + + + + Re-enter the "sunnylink" panel to verify sponsorship status + + + + If sponsorship status was not updated, please contact a moderator on Discord at https://discord.gg/sunnypilot + + + + Scan the QR code to visit sunnyhaibin's GitHub Sponsors page + + + + Choose your sponsorship tier and confirm your support + + + + Join our community on Discord at https://discord.gg/sunnypilot and reach out to a moderator to confirm your sponsor status + + + + Pair your GitHub account + + + + Early Access: Become a sunnypilot Sponsor + + SunnypilotPanel diff --git a/selfdrive/ui/translations/main_es.ts b/selfdrive/ui/translations/main_es.ts index 5e3b854f8c..77b1ecb6e4 100644 --- a/selfdrive/ui/translations/main_es.ts +++ b/selfdrive/ui/translations/main_es.ts @@ -1428,6 +1428,93 @@ Esto puede tardar un minuto. N/A N/A + + Sponsor Status + Estado de patrocinio + + + SPONSOR + PATROCINADOR + + + Become a sponsor of sunnypilot to get early access to sunnylink features when they become available. + Conviértete en patrocinador de sunnypilot para obtener acceso anticipado a las funciones de sunnylink cuando estén disponibles. + + + Pair GitHub Account + Emparejar cuenta de GitHub + + + PAIR + EMPAREJAR + + + Pair your GitHub account to grant your device sponsor benefits, including API access on sunnylink. + Empareja tu cuenta de GitHub para otorgar a tu dispositivo beneficios de patrocinador, incluido acceso a la API en sunnylink. + + + sunnylink Dongle ID not found. This may be due to weak internet connection or sunnylink registration issue. Please reboot and try again. + ID del dongle de sunnylink no encontrado. Esto puede deberse a una conexión débil o a un problema de registro en sunnylink. Por favor, reinicia y vuelve a intentarlo. + + + THANKS + GRACIAS + + + Not Sponsor + No patrocinador + + + Paired + Emparejado + + + Not Paired + No emparejado + + + THANKS ♥ + GRACIAS ♥ + + + + SunnylinkSponsorPopup + + Scan the QR code to login to your GitHub account + Escanea el código QR para iniciar sesión en tu cuenta de GitHub + + + Follow the prompts to complete the pairing process + Sigue las indicaciones para completar el proceso de emparejamiento + + + Re-enter the "sunnylink" panel to verify sponsorship status + Vuelve a ingresar al panel de "sunnylink" para verificar el estado de patrocinio + + + If sponsorship status was not updated, please contact a moderator on Discord at https://discord.gg/sunnypilot + Si el estado de patrocinio no se actualizó, por favor contacta a un moderador en Discord en https://discord.gg/sunnypilot + + + Scan the QR code to visit sunnyhaibin's GitHub Sponsors page + Escanea el código QR para visitar la página de GitHub Sponsors de sunnyhaibin + + + Choose your sponsorship tier and confirm your support + Elige tu nivel de patrocinio y confirma tu apoyo + + + Join our community on Discord at https://discord.gg/sunnypilot and reach out to a moderator to confirm your sponsor status + Únete a nuestra comunidad en Discord en https://discord.gg/sunnypilot y contacta a un moderador para confirmar tu estado de patrocinio + + + Pair your GitHub account + Empareja tu cuenta de GitHub + + + Early Access: Become a sunnypilot Sponsor + Acceso temprano: conviértete en patrocinador de sunnypilot + SunnypilotPanel diff --git a/selfdrive/ui/translations/main_fr.ts b/selfdrive/ui/translations/main_fr.ts index 88484e6220..94ebdb0a15 100644 --- a/selfdrive/ui/translations/main_fr.ts +++ b/selfdrive/ui/translations/main_fr.ts @@ -1428,6 +1428,89 @@ Cela peut prendre jusqu'à une minute. N/A N/A + + Sponsor Status + + + + SPONSOR + + + + Become a sponsor of sunnypilot to get early access to sunnylink features when they become available. + + + + Pair GitHub Account + + + + PAIR + ASSOCIER + + + Pair your GitHub account to grant your device sponsor benefits, including API access on sunnylink. + + + + sunnylink Dongle ID not found. This may be due to weak internet connection or sunnylink registration issue. Please reboot and try again. + + + + Not Sponsor + + + + Paired + + + + Not Paired + + + + THANKS ♥ + + + + + SunnylinkSponsorPopup + + Scan the QR code to login to your GitHub account + + + + Follow the prompts to complete the pairing process + + + + Re-enter the "sunnylink" panel to verify sponsorship status + + + + If sponsorship status was not updated, please contact a moderator on Discord at https://discord.gg/sunnypilot + + + + Scan the QR code to visit sunnyhaibin's GitHub Sponsors page + + + + Choose your sponsorship tier and confirm your support + + + + Join our community on Discord at https://discord.gg/sunnypilot and reach out to a moderator to confirm your sponsor status + + + + Pair your GitHub account + + + + Early Access: Become a sunnypilot Sponsor + + SunnypilotPanel diff --git a/selfdrive/ui/translations/main_ja.ts b/selfdrive/ui/translations/main_ja.ts index 0cda3f4c73..1c6ab9a8b4 100644 --- a/selfdrive/ui/translations/main_ja.ts +++ b/selfdrive/ui/translations/main_ja.ts @@ -954,7 +954,7 @@ This may take up to a minute. Firehose - + データ学習 @@ -1423,6 +1423,89 @@ This may take up to a minute. N/A 該当なし + + Sponsor Status + + + + SPONSOR + + + + Become a sponsor of sunnypilot to get early access to sunnylink features when they become available. + + + + Pair GitHub Account + + + + PAIR + OK + + + Pair your GitHub account to grant your device sponsor benefits, including API access on sunnylink. + + + + sunnylink Dongle ID not found. This may be due to weak internet connection or sunnylink registration issue. Please reboot and try again. + + + + Not Sponsor + + + + Paired + + + + Not Paired + + + + THANKS ♥ + + + + + SunnylinkSponsorPopup + + Scan the QR code to login to your GitHub account + + + + Follow the prompts to complete the pairing process + + + + Re-enter the "sunnylink" panel to verify sponsorship status + + + + If sponsorship status was not updated, please contact a moderator on Discord at https://discord.gg/sunnypilot + + + + Scan the QR code to visit sunnyhaibin's GitHub Sponsors page + + + + Choose your sponsorship tier and confirm your support + + + + Join our community on Discord at https://discord.gg/sunnypilot and reach out to a moderator to confirm your sponsor status + + + + Pair your GitHub account + + + + Early Access: Become a sunnypilot Sponsor + + SunnypilotPanel diff --git a/selfdrive/ui/translations/main_ko.ts b/selfdrive/ui/translations/main_ko.ts index 7e190c12b0..3286cc081f 100644 --- a/selfdrive/ui/translations/main_ko.ts +++ b/selfdrive/ui/translations/main_ko.ts @@ -956,7 +956,7 @@ This may take up to a minute. Firehose - + 파이어호스 @@ -1425,6 +1425,89 @@ This may take up to a minute. N/A N/A + + Sponsor Status + + + + SPONSOR + + + + Become a sponsor of sunnypilot to get early access to sunnylink features when they become available. + + + + Pair GitHub Account + + + + PAIR + 동기화 + + + Pair your GitHub account to grant your device sponsor benefits, including API access on sunnylink. + + + + sunnylink Dongle ID not found. This may be due to weak internet connection or sunnylink registration issue. Please reboot and try again. + + + + Not Sponsor + + + + Paired + + + + Not Paired + + + + THANKS ♥ + + + + + SunnylinkSponsorPopup + + Scan the QR code to login to your GitHub account + + + + Follow the prompts to complete the pairing process + + + + Re-enter the "sunnylink" panel to verify sponsorship status + + + + If sponsorship status was not updated, please contact a moderator on Discord at https://discord.gg/sunnypilot + + + + Scan the QR code to visit sunnyhaibin's GitHub Sponsors page + + + + Choose your sponsorship tier and confirm your support + + + + Join our community on Discord at https://discord.gg/sunnypilot and reach out to a moderator to confirm your sponsor status + + + + Pair your GitHub account + + + + Early Access: Become a sunnypilot Sponsor + + SunnypilotPanel diff --git a/selfdrive/ui/translations/main_pt-BR.ts b/selfdrive/ui/translations/main_pt-BR.ts index 742da57160..7a18c35989 100644 --- a/selfdrive/ui/translations/main_pt-BR.ts +++ b/selfdrive/ui/translations/main_pt-BR.ts @@ -961,7 +961,7 @@ Isso pode levar até um minuto. Firehose - + Firehose @@ -1430,6 +1430,89 @@ Isso pode levar até um minuto. N/A N/A + + Sponsor Status + + + + SPONSOR + + + + Become a sponsor of sunnypilot to get early access to sunnylink features when they become available. + + + + Pair GitHub Account + + + + PAIR + PAREAR + + + Pair your GitHub account to grant your device sponsor benefits, including API access on sunnylink. + + + + sunnylink Dongle ID not found. This may be due to weak internet connection or sunnylink registration issue. Please reboot and try again. + + + + Not Sponsor + + + + Paired + + + + Not Paired + + + + THANKS ♥ + + + + + SunnylinkSponsorPopup + + Scan the QR code to login to your GitHub account + + + + Follow the prompts to complete the pairing process + + + + Re-enter the "sunnylink" panel to verify sponsorship status + + + + If sponsorship status was not updated, please contact a moderator on Discord at https://discord.gg/sunnypilot + + + + Scan the QR code to visit sunnyhaibin's GitHub Sponsors page + + + + Choose your sponsorship tier and confirm your support + + + + Join our community on Discord at https://discord.gg/sunnypilot and reach out to a moderator to confirm your sponsor status + + + + Pair your GitHub account + + + + Early Access: Become a sunnypilot Sponsor + + SunnypilotPanel diff --git a/selfdrive/ui/translations/main_th.ts b/selfdrive/ui/translations/main_th.ts index 4a66da1156..abb3088dfe 100644 --- a/selfdrive/ui/translations/main_th.ts +++ b/selfdrive/ui/translations/main_th.ts @@ -1423,6 +1423,89 @@ This may take up to a minute. N/A ไม่มี + + Sponsor Status + + + + SPONSOR + + + + Become a sponsor of sunnypilot to get early access to sunnylink features when they become available. + + + + Pair GitHub Account + + + + PAIR + จับคู่ + + + Pair your GitHub account to grant your device sponsor benefits, including API access on sunnylink. + + + + sunnylink Dongle ID not found. This may be due to weak internet connection or sunnylink registration issue. Please reboot and try again. + + + + Not Sponsor + + + + Paired + + + + Not Paired + + + + THANKS ♥ + + + + + SunnylinkSponsorPopup + + Scan the QR code to login to your GitHub account + + + + Follow the prompts to complete the pairing process + + + + Re-enter the "sunnylink" panel to verify sponsorship status + + + + If sponsorship status was not updated, please contact a moderator on Discord at https://discord.gg/sunnypilot + + + + Scan the QR code to visit sunnyhaibin's GitHub Sponsors page + + + + Choose your sponsorship tier and confirm your support + + + + Join our community on Discord at https://discord.gg/sunnypilot and reach out to a moderator to confirm your sponsor status + + + + Pair your GitHub account + + + + Early Access: Become a sunnypilot Sponsor + + SunnypilotPanel diff --git a/selfdrive/ui/translations/main_tr.ts b/selfdrive/ui/translations/main_tr.ts index 6a344607fd..4e0e93c708 100644 --- a/selfdrive/ui/translations/main_tr.ts +++ b/selfdrive/ui/translations/main_tr.ts @@ -1421,6 +1421,89 @@ This may take up to a minute. N/A N/A + + Sponsor Status + + + + SPONSOR + + + + Become a sponsor of sunnypilot to get early access to sunnylink features when they become available. + + + + Pair GitHub Account + + + + PAIR + + + + Pair your GitHub account to grant your device sponsor benefits, including API access on sunnylink. + + + + sunnylink Dongle ID not found. This may be due to weak internet connection or sunnylink registration issue. Please reboot and try again. + + + + Not Sponsor + + + + Paired + + + + Not Paired + + + + THANKS ♥ + + + + + SunnylinkSponsorPopup + + Scan the QR code to login to your GitHub account + + + + Follow the prompts to complete the pairing process + + + + Re-enter the "sunnylink" panel to verify sponsorship status + + + + If sponsorship status was not updated, please contact a moderator on Discord at https://discord.gg/sunnypilot + + + + Scan the QR code to visit sunnyhaibin's GitHub Sponsors page + + + + Choose your sponsorship tier and confirm your support + + + + Join our community on Discord at https://discord.gg/sunnypilot and reach out to a moderator to confirm your sponsor status + + + + Pair your GitHub account + + + + Early Access: Become a sunnypilot Sponsor + + SunnypilotPanel diff --git a/selfdrive/ui/translations/main_zh-CHS.ts b/selfdrive/ui/translations/main_zh-CHS.ts index dd98792569..caa32b4f2b 100644 --- a/selfdrive/ui/translations/main_zh-CHS.ts +++ b/selfdrive/ui/translations/main_zh-CHS.ts @@ -954,7 +954,7 @@ This may take up to a minute. Firehose - + 训练上传 @@ -1423,6 +1423,89 @@ This may take up to a minute. N/A N/A + + Sponsor Status + + + + SPONSOR + + + + Become a sponsor of sunnypilot to get early access to sunnylink features when they become available. + + + + Pair GitHub Account + + + + PAIR + 配对 + + + Pair your GitHub account to grant your device sponsor benefits, including API access on sunnylink. + + + + sunnylink Dongle ID not found. This may be due to weak internet connection or sunnylink registration issue. Please reboot and try again. + + + + Not Sponsor + + + + Paired + + + + Not Paired + + + + THANKS ♥ + + + + + SunnylinkSponsorPopup + + Scan the QR code to login to your GitHub account + + + + Follow the prompts to complete the pairing process + + + + Re-enter the "sunnylink" panel to verify sponsorship status + + + + If sponsorship status was not updated, please contact a moderator on Discord at https://discord.gg/sunnypilot + + + + Scan the QR code to visit sunnyhaibin's GitHub Sponsors page + + + + Choose your sponsorship tier and confirm your support + + + + Join our community on Discord at https://discord.gg/sunnypilot and reach out to a moderator to confirm your sponsor status + + + + Pair your GitHub account + + + + Early Access: Become a sunnypilot Sponsor + + SunnypilotPanel diff --git a/selfdrive/ui/translations/main_zh-CHT.ts b/selfdrive/ui/translations/main_zh-CHT.ts index 25c5afbdfc..da6e52702a 100644 --- a/selfdrive/ui/translations/main_zh-CHT.ts +++ b/selfdrive/ui/translations/main_zh-CHT.ts @@ -954,7 +954,7 @@ This may take up to a minute. Firehose - + 訓練上傳 @@ -1423,6 +1423,89 @@ This may take up to a minute. N/A 無法使用 + + Sponsor Status + + + + SPONSOR + + + + Become a sponsor of sunnypilot to get early access to sunnylink features when they become available. + + + + Pair GitHub Account + + + + PAIR + 配對 + + + Pair your GitHub account to grant your device sponsor benefits, including API access on sunnylink. + + + + sunnylink Dongle ID not found. This may be due to weak internet connection or sunnylink registration issue. Please reboot and try again. + + + + Not Sponsor + + + + Paired + + + + Not Paired + + + + THANKS ♥ + + + + + SunnylinkSponsorPopup + + Scan the QR code to login to your GitHub account + + + + Follow the prompts to complete the pairing process + + + + Re-enter the "sunnylink" panel to verify sponsorship status + + + + If sponsorship status was not updated, please contact a moderator on Discord at https://discord.gg/sunnypilot + + + + Scan the QR code to visit sunnyhaibin's GitHub Sponsors page + + + + Choose your sponsorship tier and confirm your support + + + + Join our community on Discord at https://discord.gg/sunnypilot and reach out to a moderator to confirm your sponsor status + + + + Pair your GitHub account + + + + Early Access: Become a sunnypilot Sponsor + + SunnypilotPanel