Merge branch 'master-new' into nnlc-new

This commit is contained in:
Jason Wen
2025-03-16 19:54:02 -04:00
committed by GitHub
38 changed files with 1973 additions and 54 deletions
+4 -9
View File
@@ -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)
+2
View File
@@ -148,6 +148,8 @@ inline static std::unordered_map<std::string, uint32_t> keys = {
// sunnylink params
{"EnableSunnylinkUploader", PERSISTENT | BACKUP},
{"LastSunnylinkPingTime", CLEAR_ON_MANAGER_START},
{"SunnylinkCache_Roles", PERSISTENT},
{"SunnylinkCache_Users", PERSISTENT},
{"SunnylinkDongleId", PERSISTENT},
{"SunnylinkdPid", PERSISTENT},
{"SunnylinkEnabled", PERSISTENT},
+15 -10
View File
@@ -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);
}
+8 -5
View File
@@ -5,6 +5,7 @@
#include <QString>
#include <QTimer>
#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();
};
+11 -1
View File
@@ -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
+63
View File
@@ -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 <QApplication>
#include <QJsonDocument>
#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);
}
+36
View File
@@ -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); }
};
@@ -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 <QJsonObject>
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 <typename T, typename = typename std::enable_if<std::is_base_of<RoleModel, T>::value>::type> T as() const {
return T(m_raw_json_object);
}
};
@@ -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 <QJsonObject>
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); }
};
@@ -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 <QJsonObject>
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;
}
};
@@ -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 &param_name, const QString &param_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;
}
}
@@ -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();
};
@@ -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 <QJsonArray>
#include <QJsonDocument>
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<RoleModel> roles;
for (const auto &value : jsonArray) {
roles.emplace_back(value.toObject());
}
emit rolesReady(roles);
uiStateSP()->setSunnylinkRoles(roles);
}
@@ -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 <vector>
#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<RoleModel> &roles);
protected:
void handleResponse(const QString&response, bool success) override;
private:
QString url = "/roles";
};
@@ -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 <QJsonArray>
#include <QJsonDocument>
#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<UserModel> users;
for (const auto &value : jsonArray) {
users.emplace_back(value.toObject());
}
emit usersReady(users);
uiStateSP()->setSunnylinkDeviceUsers(users);
}
@@ -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 <vector>
#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<UserModel>&users);
protected:
void handleResponse(const QString&response, bool success) override;
private:
QString url = "/users";
};
@@ -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);
}
@@ -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 <QObject>
#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;
};
@@ -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 = "<ol type='1' style='margin-left: 15px;'>";
for (const auto & instruction : instructions) {
instructionsHtml += QString("<li style='margin-bottom: 50px;'>%1</li>").arg(instruction);
}
instructionsHtml += "</ol>";
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);
}
@@ -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 <QrCode.hpp>
#include <QtCore/qjsonobject.h>
#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;
};
@@ -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 &param_name, const QString &param_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 = "<font color='SeaGreen'>"+ tr("🎉Welcome back! We're excited to see you've enabled sunnylink again! 🚀")+ "</font>";
sunnylinkEnabledBtn->showDescription();
sunnylinkEnabledBtn->setDescription(proud_description);
description = "<font color='SeaGreen'>"+ tr("🎉Welcome back! We're excited to see you've enabled sunnylink again! 🚀")+ "</font>";
} else {
auto shame_description = "<font color='orange'>"+ 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 🎉.")+ "</font>";
sunnylinkEnabledBtn->showDescription();
sunnylinkEnabledBtn->setDescription(shame_description);
}
description = "<font color='orange'>"+ 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 🎉.")+ "</font>";
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 &param_name, const QString &param_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();
}
@@ -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&param_name, const QString&param_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;
};
@@ -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);
}
}
@@ -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();
}
};
+10
View File
@@ -48,6 +48,16 @@ UIStateSP *uiStateSP() {
return &ui_state;
}
void UIStateSP::setSunnylinkRoles(const std::vector<RoleModel>& roles) {
sunnylinkRoles = roles;
emit sunnylinkRolesChanged(roles);
}
void UIStateSP::setSunnylinkDeviceUsers(const std::vector<UserModel>& users) {
sunnylinkUsers = users;
emit sunnylinkDeviceUsersChanged(users);
}
DeviceSP *deviceSP() {
static DeviceSP _device;
return &_device;
+47
View File
@@ -7,6 +7,11 @@
#pragma once
#include <optional>
#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<RoleModel> &roles);
void setSunnylinkDeviceUsers(const std::vector<UserModel> &users);
inline std::vector<RoleModel> 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<SponsorRoleModel>().roleTier != SponsorTier::Free;
});
}
inline SponsorRoleModel sunnylinkSponsorRole() const {
std::optional<SponsorRoleModel> sponsorRoleWithHighestTier = std::nullopt;
for (const auto &role : sunnylinkRoles) {
if(role.roleType != RoleType::Sponsor)
continue;
if (auto sponsorRole = role.as<SponsorRoleModel>(); !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<UserModel> 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<RoleModel> roles);
void sunnylinkDeviceUsersChanged(std::vector<UserModel> users);
void uiUpdate(const UIStateSP &s);
private slots:
void update() override;
private:
std::vector<RoleModel> sunnylinkRoles = {};
std::vector<UserModel> sunnylinkUsers = {};
};
UIStateSP *uiStateSP();
+12
View File
@@ -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,
+84 -1
View File
@@ -981,7 +981,7 @@ This may take up to a minute.</source>
</message>
<message>
<source>Firehose</source>
<translation type="unfinished"></translation>
<translation type="unfinished">خرطوم الحريق</translation>
</message>
</context>
<context>
@@ -1450,6 +1450,89 @@ This may take up to a minute.</source>
<source>N/A</source>
<translation type="unfinished">غير متاح</translation>
</message>
<message>
<source>Sponsor Status</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>SPONSOR</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Become a sponsor of sunnypilot to get early access to sunnylink features when they become available.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Pair GitHub Account</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>PAIR</source>
<translation type="unfinished">إقران</translation>
</message>
<message>
<source>Pair your GitHub account to grant your device sponsor benefits, including API access on sunnylink.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>sunnylink Dongle ID not found. This may be due to weak internet connection or sunnylink registration issue. Please reboot and try again.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Not Sponsor</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Paired</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Not Paired</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>THANKS </source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>SunnylinkSponsorPopup</name>
<message>
<source>Scan the QR code to login to your GitHub account</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Follow the prompts to complete the pairing process</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Re-enter the &quot;sunnylink&quot; panel to verify sponsorship status</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>If sponsorship status was not updated, please contact a moderator on Discord at https://discord.gg/sunnypilot</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Scan the QR code to visit sunnyhaibin&apos;s GitHub Sponsors page</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Choose your sponsorship tier and confirm your support</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Join our community on Discord at https://discord.gg/sunnypilot and reach out to a moderator to confirm your sponsor status</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Pair your GitHub account</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Early Access: Become a sunnypilot Sponsor</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>SunnypilotPanel</name>
+83
View File
@@ -1428,6 +1428,89 @@ This may take up to a minute.</source>
<source>N/A</source>
<translation type="unfinished">Nicht verfügbar</translation>
</message>
<message>
<source>Sponsor Status</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>SPONSOR</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Become a sponsor of sunnypilot to get early access to sunnylink features when they become available.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Pair GitHub Account</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>PAIR</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Pair your GitHub account to grant your device sponsor benefits, including API access on sunnylink.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>sunnylink Dongle ID not found. This may be due to weak internet connection or sunnylink registration issue. Please reboot and try again.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Not Sponsor</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Paired</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Not Paired</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>THANKS </source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>SunnylinkSponsorPopup</name>
<message>
<source>Scan the QR code to login to your GitHub account</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Follow the prompts to complete the pairing process</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Re-enter the &quot;sunnylink&quot; panel to verify sponsorship status</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>If sponsorship status was not updated, please contact a moderator on Discord at https://discord.gg/sunnypilot</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Scan the QR code to visit sunnyhaibin&apos;s GitHub Sponsors page</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Choose your sponsorship tier and confirm your support</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Join our community on Discord at https://discord.gg/sunnypilot and reach out to a moderator to confirm your sponsor status</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Pair your GitHub account</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Early Access: Become a sunnypilot Sponsor</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>SunnypilotPanel</name>
+87
View File
@@ -1428,6 +1428,93 @@ Esto puede tardar un minuto.</translation>
<source>N/A</source>
<translation type="unfinished">N/A</translation>
</message>
<message>
<source>Sponsor Status</source>
<translation type="unfinished">Estado de patrocinio</translation>
</message>
<message>
<source>SPONSOR</source>
<translation type="unfinished">PATROCINADOR</translation>
</message>
<message>
<source>Become a sponsor of sunnypilot to get early access to sunnylink features when they become available.</source>
<translation type="unfinished">Conviértete en patrocinador de sunnypilot para obtener acceso anticipado a las funciones de sunnylink cuando estén disponibles.</translation>
</message>
<message>
<source>Pair GitHub Account</source>
<translation type="unfinished">Emparejar cuenta de GitHub</translation>
</message>
<message>
<source>PAIR</source>
<translation type="unfinished">EMPAREJAR</translation>
</message>
<message>
<source>Pair your GitHub account to grant your device sponsor benefits, including API access on sunnylink.</source>
<translation type="unfinished">Empareja tu cuenta de GitHub para otorgar a tu dispositivo beneficios de patrocinador, incluido acceso a la API en sunnylink.</translation>
</message>
<message>
<source>sunnylink Dongle ID not found. This may be due to weak internet connection or sunnylink registration issue. Please reboot and try again.</source>
<translation type="unfinished">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.</translation>
</message>
<message>
<source>THANKS</source>
<translation type="obsolete">GRACIAS</translation>
</message>
<message>
<source>Not Sponsor</source>
<translation type="unfinished">No patrocinador</translation>
</message>
<message>
<source>Paired</source>
<translation type="unfinished">Emparejado</translation>
</message>
<message>
<source>Not Paired</source>
<translation type="unfinished">No emparejado</translation>
</message>
<message>
<source>THANKS </source>
<translation type="unfinished">GRACIAS </translation>
</message>
</context>
<context>
<name>SunnylinkSponsorPopup</name>
<message>
<source>Scan the QR code to login to your GitHub account</source>
<translation type="unfinished">Escanea el código QR para iniciar sesión en tu cuenta de GitHub</translation>
</message>
<message>
<source>Follow the prompts to complete the pairing process</source>
<translation type="unfinished">Sigue las indicaciones para completar el proceso de emparejamiento</translation>
</message>
<message>
<source>Re-enter the &quot;sunnylink&quot; panel to verify sponsorship status</source>
<translation type="unfinished">Vuelve a ingresar al panel de &quot;sunnylink&quot; para verificar el estado de patrocinio</translation>
</message>
<message>
<source>If sponsorship status was not updated, please contact a moderator on Discord at https://discord.gg/sunnypilot</source>
<translation type="unfinished">Si el estado de patrocinio no se actualizó, por favor contacta a un moderador en Discord en https://discord.gg/sunnypilot</translation>
</message>
<message>
<source>Scan the QR code to visit sunnyhaibin&apos;s GitHub Sponsors page</source>
<translation type="unfinished">Escanea el código QR para visitar la página de GitHub Sponsors de sunnyhaibin</translation>
</message>
<message>
<source>Choose your sponsorship tier and confirm your support</source>
<translation type="unfinished">Elige tu nivel de patrocinio y confirma tu apoyo</translation>
</message>
<message>
<source>Join our community on Discord at https://discord.gg/sunnypilot and reach out to a moderator to confirm your sponsor status</source>
<translation type="unfinished">Únete a nuestra comunidad en Discord en https://discord.gg/sunnypilot y contacta a un moderador para confirmar tu estado de patrocinio</translation>
</message>
<message>
<source>Pair your GitHub account</source>
<translation type="unfinished">Empareja tu cuenta de GitHub</translation>
</message>
<message>
<source>Early Access: Become a sunnypilot Sponsor</source>
<translation type="unfinished">Acceso temprano: conviértete en patrocinador de sunnypilot</translation>
</message>
</context>
<context>
<name>SunnypilotPanel</name>
+83
View File
@@ -1428,6 +1428,89 @@ Cela peut prendre jusqu&apos;à une minute.</translation>
<source>N/A</source>
<translation type="unfinished">N/A</translation>
</message>
<message>
<source>Sponsor Status</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>SPONSOR</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Become a sponsor of sunnypilot to get early access to sunnylink features when they become available.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Pair GitHub Account</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>PAIR</source>
<translation type="unfinished">ASSOCIER</translation>
</message>
<message>
<source>Pair your GitHub account to grant your device sponsor benefits, including API access on sunnylink.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>sunnylink Dongle ID not found. This may be due to weak internet connection or sunnylink registration issue. Please reboot and try again.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Not Sponsor</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Paired</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Not Paired</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>THANKS </source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>SunnylinkSponsorPopup</name>
<message>
<source>Scan the QR code to login to your GitHub account</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Follow the prompts to complete the pairing process</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Re-enter the &quot;sunnylink&quot; panel to verify sponsorship status</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>If sponsorship status was not updated, please contact a moderator on Discord at https://discord.gg/sunnypilot</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Scan the QR code to visit sunnyhaibin&apos;s GitHub Sponsors page</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Choose your sponsorship tier and confirm your support</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Join our community on Discord at https://discord.gg/sunnypilot and reach out to a moderator to confirm your sponsor status</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Pair your GitHub account</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Early Access: Become a sunnypilot Sponsor</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>SunnypilotPanel</name>
+84 -1
View File
@@ -954,7 +954,7 @@ This may take up to a minute.</source>
</message>
<message>
<source>Firehose</source>
<translation type="unfinished"></translation>
<translation type="unfinished"></translation>
</message>
</context>
<context>
@@ -1423,6 +1423,89 @@ This may take up to a minute.</source>
<source>N/A</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Sponsor Status</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>SPONSOR</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Become a sponsor of sunnypilot to get early access to sunnylink features when they become available.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Pair GitHub Account</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>PAIR</source>
<translation type="unfinished">OK</translation>
</message>
<message>
<source>Pair your GitHub account to grant your device sponsor benefits, including API access on sunnylink.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>sunnylink Dongle ID not found. This may be due to weak internet connection or sunnylink registration issue. Please reboot and try again.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Not Sponsor</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Paired</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Not Paired</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>THANKS </source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>SunnylinkSponsorPopup</name>
<message>
<source>Scan the QR code to login to your GitHub account</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Follow the prompts to complete the pairing process</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Re-enter the &quot;sunnylink&quot; panel to verify sponsorship status</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>If sponsorship status was not updated, please contact a moderator on Discord at https://discord.gg/sunnypilot</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Scan the QR code to visit sunnyhaibin&apos;s GitHub Sponsors page</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Choose your sponsorship tier and confirm your support</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Join our community on Discord at https://discord.gg/sunnypilot and reach out to a moderator to confirm your sponsor status</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Pair your GitHub account</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Early Access: Become a sunnypilot Sponsor</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>SunnypilotPanel</name>
+84 -1
View File
@@ -956,7 +956,7 @@ This may take up to a minute.</source>
</message>
<message>
<source>Firehose</source>
<translation type="unfinished"></translation>
<translation type="unfinished"></translation>
</message>
</context>
<context>
@@ -1425,6 +1425,89 @@ This may take up to a minute.</source>
<source>N/A</source>
<translation type="unfinished">N/A</translation>
</message>
<message>
<source>Sponsor Status</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>SPONSOR</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Become a sponsor of sunnypilot to get early access to sunnylink features when they become available.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Pair GitHub Account</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>PAIR</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Pair your GitHub account to grant your device sponsor benefits, including API access on sunnylink.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>sunnylink Dongle ID not found. This may be due to weak internet connection or sunnylink registration issue. Please reboot and try again.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Not Sponsor</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Paired</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Not Paired</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>THANKS </source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>SunnylinkSponsorPopup</name>
<message>
<source>Scan the QR code to login to your GitHub account</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Follow the prompts to complete the pairing process</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Re-enter the &quot;sunnylink&quot; panel to verify sponsorship status</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>If sponsorship status was not updated, please contact a moderator on Discord at https://discord.gg/sunnypilot</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Scan the QR code to visit sunnyhaibin&apos;s GitHub Sponsors page</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Choose your sponsorship tier and confirm your support</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Join our community on Discord at https://discord.gg/sunnypilot and reach out to a moderator to confirm your sponsor status</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Pair your GitHub account</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Early Access: Become a sunnypilot Sponsor</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>SunnypilotPanel</name>
+84 -1
View File
@@ -961,7 +961,7 @@ Isso pode levar até um minuto.</translation>
</message>
<message>
<source>Firehose</source>
<translation type="unfinished"></translation>
<translation type="unfinished">Firehose</translation>
</message>
</context>
<context>
@@ -1430,6 +1430,89 @@ Isso pode levar até um minuto.</translation>
<source>N/A</source>
<translation type="unfinished">N/A</translation>
</message>
<message>
<source>Sponsor Status</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>SPONSOR</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Become a sponsor of sunnypilot to get early access to sunnylink features when they become available.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Pair GitHub Account</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>PAIR</source>
<translation type="unfinished">PAREAR</translation>
</message>
<message>
<source>Pair your GitHub account to grant your device sponsor benefits, including API access on sunnylink.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>sunnylink Dongle ID not found. This may be due to weak internet connection or sunnylink registration issue. Please reboot and try again.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Not Sponsor</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Paired</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Not Paired</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>THANKS </source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>SunnylinkSponsorPopup</name>
<message>
<source>Scan the QR code to login to your GitHub account</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Follow the prompts to complete the pairing process</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Re-enter the &quot;sunnylink&quot; panel to verify sponsorship status</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>If sponsorship status was not updated, please contact a moderator on Discord at https://discord.gg/sunnypilot</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Scan the QR code to visit sunnyhaibin&apos;s GitHub Sponsors page</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Choose your sponsorship tier and confirm your support</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Join our community on Discord at https://discord.gg/sunnypilot and reach out to a moderator to confirm your sponsor status</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Pair your GitHub account</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Early Access: Become a sunnypilot Sponsor</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>SunnypilotPanel</name>
+83
View File
@@ -1423,6 +1423,89 @@ This may take up to a minute.</source>
<source>N/A</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Sponsor Status</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>SPONSOR</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Become a sponsor of sunnypilot to get early access to sunnylink features when they become available.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Pair GitHub Account</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>PAIR</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Pair your GitHub account to grant your device sponsor benefits, including API access on sunnylink.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>sunnylink Dongle ID not found. This may be due to weak internet connection or sunnylink registration issue. Please reboot and try again.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Not Sponsor</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Paired</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Not Paired</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>THANKS </source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>SunnylinkSponsorPopup</name>
<message>
<source>Scan the QR code to login to your GitHub account</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Follow the prompts to complete the pairing process</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Re-enter the &quot;sunnylink&quot; panel to verify sponsorship status</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>If sponsorship status was not updated, please contact a moderator on Discord at https://discord.gg/sunnypilot</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Scan the QR code to visit sunnyhaibin&apos;s GitHub Sponsors page</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Choose your sponsorship tier and confirm your support</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Join our community on Discord at https://discord.gg/sunnypilot and reach out to a moderator to confirm your sponsor status</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Pair your GitHub account</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Early Access: Become a sunnypilot Sponsor</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>SunnypilotPanel</name>
+83
View File
@@ -1421,6 +1421,89 @@ This may take up to a minute.</source>
<source>N/A</source>
<translation type="unfinished">N/A</translation>
</message>
<message>
<source>Sponsor Status</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>SPONSOR</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Become a sponsor of sunnypilot to get early access to sunnylink features when they become available.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Pair GitHub Account</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>PAIR</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Pair your GitHub account to grant your device sponsor benefits, including API access on sunnylink.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>sunnylink Dongle ID not found. This may be due to weak internet connection or sunnylink registration issue. Please reboot and try again.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Not Sponsor</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Paired</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Not Paired</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>THANKS </source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>SunnylinkSponsorPopup</name>
<message>
<source>Scan the QR code to login to your GitHub account</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Follow the prompts to complete the pairing process</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Re-enter the &quot;sunnylink&quot; panel to verify sponsorship status</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>If sponsorship status was not updated, please contact a moderator on Discord at https://discord.gg/sunnypilot</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Scan the QR code to visit sunnyhaibin&apos;s GitHub Sponsors page</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Choose your sponsorship tier and confirm your support</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Join our community on Discord at https://discord.gg/sunnypilot and reach out to a moderator to confirm your sponsor status</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Pair your GitHub account</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Early Access: Become a sunnypilot Sponsor</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>SunnypilotPanel</name>
+84 -1
View File
@@ -954,7 +954,7 @@ This may take up to a minute.</source>
</message>
<message>
<source>Firehose</source>
<translation type="unfinished"></translation>
<translation type="unfinished"></translation>
</message>
</context>
<context>
@@ -1423,6 +1423,89 @@ This may take up to a minute.</source>
<source>N/A</source>
<translation type="unfinished">N/A</translation>
</message>
<message>
<source>Sponsor Status</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>SPONSOR</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Become a sponsor of sunnypilot to get early access to sunnylink features when they become available.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Pair GitHub Account</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>PAIR</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Pair your GitHub account to grant your device sponsor benefits, including API access on sunnylink.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>sunnylink Dongle ID not found. This may be due to weak internet connection or sunnylink registration issue. Please reboot and try again.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Not Sponsor</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Paired</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Not Paired</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>THANKS </source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>SunnylinkSponsorPopup</name>
<message>
<source>Scan the QR code to login to your GitHub account</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Follow the prompts to complete the pairing process</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Re-enter the &quot;sunnylink&quot; panel to verify sponsorship status</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>If sponsorship status was not updated, please contact a moderator on Discord at https://discord.gg/sunnypilot</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Scan the QR code to visit sunnyhaibin&apos;s GitHub Sponsors page</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Choose your sponsorship tier and confirm your support</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Join our community on Discord at https://discord.gg/sunnypilot and reach out to a moderator to confirm your sponsor status</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Pair your GitHub account</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Early Access: Become a sunnypilot Sponsor</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>SunnypilotPanel</name>
+84 -1
View File
@@ -954,7 +954,7 @@ This may take up to a minute.</source>
</message>
<message>
<source>Firehose</source>
<translation type="unfinished"></translation>
<translation type="unfinished"></translation>
</message>
</context>
<context>
@@ -1423,6 +1423,89 @@ This may take up to a minute.</source>
<source>N/A</source>
<translation type="unfinished">使</translation>
</message>
<message>
<source>Sponsor Status</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>SPONSOR</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Become a sponsor of sunnypilot to get early access to sunnylink features when they become available.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Pair GitHub Account</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>PAIR</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Pair your GitHub account to grant your device sponsor benefits, including API access on sunnylink.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>sunnylink Dongle ID not found. This may be due to weak internet connection or sunnylink registration issue. Please reboot and try again.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Not Sponsor</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Paired</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Not Paired</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>THANKS </source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>SunnylinkSponsorPopup</name>
<message>
<source>Scan the QR code to login to your GitHub account</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Follow the prompts to complete the pairing process</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Re-enter the &quot;sunnylink&quot; panel to verify sponsorship status</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>If sponsorship status was not updated, please contact a moderator on Discord at https://discord.gg/sunnypilot</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Scan the QR code to visit sunnyhaibin&apos;s GitHub Sponsors page</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Choose your sponsorship tier and confirm your support</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Join our community on Discord at https://discord.gg/sunnypilot and reach out to a moderator to confirm your sponsor status</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Pair your GitHub account</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Early Access: Become a sunnypilot Sponsor</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>SunnypilotPanel</name>