mirror of
https://github.com/sunnypilot/sunnypilot.git
synced 2026-08-24 06:33:48 +08:00
Merge remote-tracking branch 'origin/HKG-long-tune' into HKG-long-tune
This commit is contained in:
+40
-1
@@ -156,7 +156,46 @@ struct CarControlSP @0xa5cd762cd951a455 {
|
||||
mads @0 :ModularAssistiveDrivingSystem;
|
||||
}
|
||||
|
||||
struct CustomReserved6 @0xf98d843bfd7004a3 {
|
||||
struct BackupManagerSP @0xf98d843bfd7004a3 {
|
||||
backupStatus @0 :Status;
|
||||
restoreStatus @1 :Status;
|
||||
backupProgress @2 :Float32;
|
||||
restoreProgress @3 :Float32;
|
||||
lastError @4 :Text;
|
||||
currentBackup @5 :BackupInfo;
|
||||
backupHistory @6 :List(BackupInfo);
|
||||
|
||||
enum Status {
|
||||
idle @0;
|
||||
inProgress @1;
|
||||
completed @2;
|
||||
failed @3;
|
||||
}
|
||||
|
||||
struct Version {
|
||||
major @0 :UInt16;
|
||||
minor @1 :UInt16;
|
||||
patch @2 :UInt16;
|
||||
build @3 :UInt16;
|
||||
branch @4 :Text;
|
||||
}
|
||||
|
||||
struct MetadataEntry {
|
||||
key @0 :Text;
|
||||
value @1 :Text;
|
||||
tags @2 :List(Text);
|
||||
}
|
||||
|
||||
struct BackupInfo {
|
||||
deviceId @0 :Text;
|
||||
version @1 :UInt32;
|
||||
config @2 :Text;
|
||||
isEncrypted @3 :Bool;
|
||||
createdAt @4 :Text; # ISO timestamp
|
||||
updatedAt @5 :Text; # ISO timestamp
|
||||
sunnypilotVersion @6 :Version;
|
||||
backupMetadata @7 :List(MetadataEntry);
|
||||
}
|
||||
}
|
||||
|
||||
struct CustomReserved7 @0xb86e6369214c01c8 {
|
||||
|
||||
+1
-1
@@ -2582,7 +2582,7 @@ struct Event {
|
||||
onroadEventsSP @110 :List(Custom.OnroadEventSP);
|
||||
carParamsSP @111 :Custom.CarParamsSP;
|
||||
carControlSP @112 :Custom.CarControlSP;
|
||||
customReserved6 @113 :Custom.CustomReserved6;
|
||||
backupManagerSP @113 :Custom.BackupManagerSP;
|
||||
customReserved7 @114 :Custom.CustomReserved7;
|
||||
customReserved8 @115 :Custom.CustomReserved8;
|
||||
customReserved9 @116 :Custom.CustomReserved9;
|
||||
|
||||
@@ -76,6 +76,7 @@ _services: dict[str, tuple] = {
|
||||
|
||||
# sunnypilot
|
||||
"modelManagerSP": (False, 1., 1),
|
||||
"backupManagerSP": (False, 1., 1),
|
||||
"selfdriveStateSP": (True, 100., 10),
|
||||
"longitudinalPlanSP": (True, 20., 10),
|
||||
"onroadEventsSP": (True, 1., 1),
|
||||
|
||||
+2
-2
@@ -45,7 +45,7 @@ class BaseApi:
|
||||
ascii_encoded_text = normalized_text.encode('ascii', 'ignore')
|
||||
return ascii_encoded_text.decode()
|
||||
|
||||
def api_get(self, endpoint, method='GET', timeout=None, access_token=None, **params):
|
||||
def api_get(self, endpoint, method='GET', timeout=None, access_token=None, json=None, **params):
|
||||
headers = {}
|
||||
if access_token is not None:
|
||||
headers['Authorization'] = "JWT " + access_token
|
||||
@@ -53,4 +53,4 @@ class BaseApi:
|
||||
version = self.remove_non_ascii_chars(get_version())
|
||||
headers['User-Agent'] = self.user_agent + version
|
||||
|
||||
return requests.request(method, f"{self.api_host}/{endpoint}", timeout=timeout, headers=headers, params=params)
|
||||
return requests.request(method, f"{self.api_host}/{endpoint}", timeout=timeout, headers=headers, json=json, params=params)
|
||||
|
||||
@@ -153,6 +153,10 @@ inline static std::unordered_map<std::string, uint32_t> keys = {
|
||||
{"SunnylinkdPid", PERSISTENT},
|
||||
{"SunnylinkEnabled", PERSISTENT},
|
||||
|
||||
// Backup Manager params
|
||||
{"BackupManager_CreateBackup", PERSISTENT},
|
||||
{"BackupManager_RestoreVersion", PERSISTENT},
|
||||
|
||||
// sunnypilot car specific params
|
||||
{"HyundaiRadarTracks", PERSISTENT},
|
||||
{"HyundaiRadarTracksConfirmed", PERSISTENT},
|
||||
|
||||
@@ -70,9 +70,7 @@ SoftwarePanel::SoftwarePanel(QWidget* parent) : ListWidget(parent) {
|
||||
checkForUpdates();
|
||||
}
|
||||
});
|
||||
if (!params.getBool("IsTestedBranch")) {
|
||||
addItem(targetBranchBtn);
|
||||
}
|
||||
addItem(targetBranchBtn);
|
||||
|
||||
// uninstall button
|
||||
auto uninstallBtn = new ButtonControl(tr("Uninstall %1").arg(getBrand()), tr("UNINSTALL"));
|
||||
|
||||
@@ -7,8 +7,10 @@
|
||||
|
||||
#include "selfdrive/ui/sunnypilot/qt/offroad/settings/sunnylink_panel.h"
|
||||
|
||||
#include "common/watchdog.h"
|
||||
#include "selfdrive/ui/sunnypilot/qt/util.h"
|
||||
#include "selfdrive/ui/sunnypilot/qt/widgets/controls.h"
|
||||
#include <QtConcurrent>
|
||||
|
||||
SunnylinkPanel::SunnylinkPanel(QWidget *parent) : QFrame(parent) {
|
||||
main_layout = new QStackedLayout(this);
|
||||
@@ -75,7 +77,6 @@ SunnylinkPanel::SunnylinkPanel(QWidget *parent) : QFrame(parent) {
|
||||
description = "<font color='SeaGreen'>"+ tr("🎉Welcome back! We're excited to see you've enabled sunnylink again! 🚀")+ "</font>";
|
||||
} else {
|
||||
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(description);
|
||||
@@ -83,7 +84,38 @@ SunnylinkPanel::SunnylinkPanel(QWidget *parent) : QFrame(parent) {
|
||||
updatePanel();
|
||||
});
|
||||
|
||||
// Backup Settings
|
||||
backupSettings = new PushButtonSP(tr("Backup Settings"), 730, this);
|
||||
backupSettings->setObjectName("backup_btn");
|
||||
connect(backupSettings, &QPushButton::clicked, [=]() {
|
||||
backupSettings->setEnabled(false);
|
||||
if (ConfirmationDialog::confirm(tr("Are you sure you want to backup sunnypilot settings?"), tr("Back Up"), this)) {
|
||||
params.putBool("BackupManager_CreateBackup", true);
|
||||
backup_request_pending = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Restore Settings
|
||||
restoreSettings = new PushButtonSP(tr("Restore Settings"), 730, this);
|
||||
restoreSettings->setObjectName("restore_btn");
|
||||
connect(restoreSettings, &QPushButton::clicked, [=]() {
|
||||
restoreSettings->setEnabled(false);
|
||||
if (ConfirmationDialog::confirm(tr("Are you sure you want to restore the last backed up sunnypilot settings?"), tr("Restore"), this)) {
|
||||
params.put("BackupManager_RestoreVersion", "latest");
|
||||
restore_request_pending = true;
|
||||
}
|
||||
});
|
||||
// Settings Restore and Settings Backup in the same horizontal space
|
||||
auto settings_layout = new QHBoxLayout;
|
||||
settings_layout->setContentsMargins(0, 0, 0, 30);
|
||||
settings_layout->addWidget(backupSettings);
|
||||
settings_layout->addSpacing(10);
|
||||
settings_layout->addWidget(restoreSettings);
|
||||
settings_layout->setAlignment(Qt::AlignLeft);
|
||||
list->addItem(settings_layout);
|
||||
|
||||
QObject::connect(uiState(), &UIState::offroadTransition, this, &SunnylinkPanel::updatePanel);
|
||||
QObject::connect(uiStateSP(), &UIStateSP::uiUpdate, this, &SunnylinkPanel::updatePanel);
|
||||
|
||||
sunnylinkScroller = new ScrollViewSP(list, this);
|
||||
vlayout->addWidget(sunnylinkScroller);
|
||||
@@ -95,6 +127,76 @@ SunnylinkPanel::SunnylinkPanel(QWidget *parent) : QFrame(parent) {
|
||||
}
|
||||
}
|
||||
|
||||
void SunnylinkPanel::updateBackupManagerState() {
|
||||
const SubMaster &sm = *(uiStateSP()->sm);
|
||||
backup_manager = sm["backupManagerSP"].getBackupManagerSP();
|
||||
}
|
||||
|
||||
void SunnylinkPanel::handleBackupProgress() {
|
||||
auto backup_status = backup_manager.getBackupStatus();
|
||||
auto restore_status = backup_manager.getRestoreStatus();
|
||||
auto backup_progress = backup_manager.getBackupProgress();
|
||||
auto restore_progress = backup_manager.getRestoreProgress();
|
||||
|
||||
switch (backup_status) {
|
||||
case cereal::BackupManagerSP::Status::IN_PROGRESS:
|
||||
backup_request_pending = false;
|
||||
backup_request_started = true;
|
||||
backupSettings->setEnabled(false);
|
||||
backupSettings->setText(QString(tr("Backup in progress %1%").arg(backup_progress)));
|
||||
break;
|
||||
case cereal::BackupManagerSP::Status::FAILED:
|
||||
backup_request_pending = false;
|
||||
backup_request_started = false;
|
||||
backupSettings->setEnabled(!is_onroad);
|
||||
backupSettings->setText(tr("Backup Failed"));
|
||||
break;
|
||||
case cereal::BackupManagerSP::Status::COMPLETED:
|
||||
backup_request_pending = false;
|
||||
break;
|
||||
default:
|
||||
if (!backup_request_pending && backup_request_started) {
|
||||
backup_request_started = false;
|
||||
ConfirmationDialog::alert(tr("Settings backup completed."), this);
|
||||
} else {
|
||||
backupSettings->setEnabled(!is_onroad && !backup_request_pending && is_sunnylink_enabled);
|
||||
}
|
||||
backupSettings->setText(tr("Backup Settings"));
|
||||
break;
|
||||
}
|
||||
|
||||
switch (restore_status) {
|
||||
case cereal::BackupManagerSP::Status::IN_PROGRESS:
|
||||
restore_request_pending = false;
|
||||
restore_request_started = true;
|
||||
restoreSettings->setEnabled(false);
|
||||
restoreSettings->setText(QString(tr("Restore in progress %1%").arg(restore_progress)));
|
||||
break;
|
||||
case cereal::BackupManagerSP::Status::FAILED:
|
||||
restore_request_pending = false;
|
||||
restore_request_started = false;
|
||||
restoreSettings->setEnabled(!is_onroad);
|
||||
restoreSettings->setText(tr("Restore Failed"));
|
||||
ConfirmationDialog::alert(tr("Unable to restore the settings, try again later."), this);
|
||||
break;
|
||||
case cereal::BackupManagerSP::Status::COMPLETED:
|
||||
restore_request_pending = false;
|
||||
break;
|
||||
default:
|
||||
if (!restore_request_pending && restore_request_started) {
|
||||
restore_request_started = false;
|
||||
if (ConfirmationDialog::alert(tr("Settings restored. Confirm to restart the interface."), this)) {
|
||||
qApp->exit(18);
|
||||
watchdog_kick(0);
|
||||
}
|
||||
} else {
|
||||
restoreSettings->setEnabled(!is_onroad && !restore_request_pending && is_sunnylink_enabled);
|
||||
}
|
||||
restoreSettings->setText(tr("Restore Settings"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
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") {
|
||||
@@ -128,7 +230,7 @@ void SunnylinkPanel::stopSunnylink() const {
|
||||
void SunnylinkPanel::showEvent(QShowEvent *event) {
|
||||
updatePanel();
|
||||
if (is_sunnylink_enabled) {
|
||||
startSunnylink();
|
||||
startSunnylink();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,6 +239,8 @@ void SunnylinkPanel::updatePanel() {
|
||||
return;
|
||||
}
|
||||
|
||||
updateBackupManagerState();
|
||||
handleBackupProgress();
|
||||
const auto sunnylinkDongleId = getSunnylinkDongleId().value_or(tr("N/A"));
|
||||
sunnylinkEnabledBtn->setEnabled(!is_onroad);
|
||||
|
||||
@@ -152,13 +256,12 @@ void SunnylinkPanel::updatePanel() {
|
||||
sunnylinkEnabledBtn->setValue(tr("Device ID") + " " + sunnylinkDongleId);
|
||||
|
||||
sponsorBtn->setEnabled(!is_onroad && is_sunnylink_enabled);
|
||||
sponsorBtn->setText(is_sub ? tr("THANKS ♥")/* + " ♥️"*/ : tr("SPONSOR"));
|
||||
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("");
|
||||
|
||||
@@ -19,7 +19,9 @@ 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);
|
||||
void paramsRefresh(const QString ¶m_name, const QString ¶m_value);
|
||||
void updateBackupManagerState();
|
||||
void handleBackupProgress();
|
||||
|
||||
public slots:
|
||||
void updatePanel();
|
||||
@@ -31,17 +33,23 @@ private:
|
||||
ScrollViewSP *sunnylinkScroller = nullptr;
|
||||
SunnylinkSponsorPopup *status_popup;
|
||||
SunnylinkSponsorPopup *pair_popup;
|
||||
ButtonControlSP* sponsorBtn;
|
||||
ButtonControlSP* pairSponsorBtn;
|
||||
SunnylinkClient* sunnylink_client;
|
||||
ButtonControlSP *sponsorBtn;
|
||||
ButtonControlSP *pairSponsorBtn;
|
||||
SunnylinkClient *sunnylink_client;
|
||||
cereal::BackupManagerSP::Reader backup_manager;
|
||||
|
||||
ParamControl *sunnylinkEnabledBtn;
|
||||
bool is_onroad = false;
|
||||
bool is_backup = false;
|
||||
bool is_restore = false;
|
||||
bool is_sunnylink_enabled = false;
|
||||
bool backup_request_pending = false;
|
||||
bool backup_request_started = false;
|
||||
bool restore_request_pending = false;
|
||||
bool restore_request_started = false;
|
||||
ParamWatcher *param_watcher;
|
||||
QString sunnylinkBtnDescription;
|
||||
PushButtonSP *restoreSettings;
|
||||
PushButtonSP *backupSettings;
|
||||
|
||||
void stopSunnylink() const;
|
||||
void startSunnylink() const;
|
||||
};
|
||||
|
||||
@@ -18,7 +18,7 @@ UIStateSP::UIStateSP(QObject *parent) : UIState(parent) {
|
||||
"modelV2", "controlsState", "liveCalibration", "radarState", "deviceState",
|
||||
"pandaStates", "carParams", "driverMonitoringState", "carState", "driverStateV2",
|
||||
"wideRoadCameraState", "managerState", "selfdriveState", "longitudinalPlan",
|
||||
"modelManagerSP", "selfdriveStateSP", "longitudinalPlanSP",
|
||||
"modelManagerSP", "selfdriveStateSP", "longitudinalPlanSP", "backupManagerSP"
|
||||
});
|
||||
|
||||
// update timer
|
||||
|
||||
@@ -24,11 +24,11 @@ class SunnylinkApi(BaseApi):
|
||||
self.spinner = None
|
||||
self.params = Params()
|
||||
|
||||
def api_get(self, endpoint, method='GET', timeout=10, access_token=None, **kwargs):
|
||||
def api_get(self, endpoint, method='GET', timeout=10, access_token=None, json=None, **kwargs):
|
||||
if not self.params.get_bool("SunnylinkEnabled"):
|
||||
return None
|
||||
|
||||
return super().api_get(endpoint, method, timeout, access_token, **kwargs)
|
||||
return super().api_get(endpoint, method, timeout, access_token, json, **kwargs)
|
||||
|
||||
def resume_queued(self, timeout=10, **kwargs):
|
||||
sunnylinkId, commaId = self._resolve_dongle_ids()
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
from Crypto.Cipher import AES
|
||||
|
||||
|
||||
class AESCipher:
|
||||
def __init__(self, key: bytes, iv: bytes):
|
||||
if len(key) not in (16, 32):
|
||||
raise ValueError("Key must be 16 bytes (AES-128) or 32 bytes (AES-256).")
|
||||
if len(iv) != 16:
|
||||
raise ValueError("IV must be 16 bytes.")
|
||||
|
||||
self.key = key
|
||||
self.iv = iv
|
||||
|
||||
def encrypt(self, data: bytes) -> bytes:
|
||||
block_size = 16
|
||||
padding_length = block_size - (len(data) % block_size)
|
||||
padding = bytes([padding_length]) * padding_length
|
||||
padded_data = data + padding
|
||||
|
||||
cipher = AES.new(self.key, AES.MODE_CBC, self.iv)
|
||||
return cipher.encrypt(padded_data)
|
||||
|
||||
def decrypt(self, encrypted_data: bytes) -> bytes:
|
||||
cipher = AES.new(self.key, AES.MODE_CBC, self.iv)
|
||||
decrypted_data = cipher.decrypt(encrypted_data)
|
||||
padding_length = decrypted_data[-1]
|
||||
return decrypted_data[:-padding_length]
|
||||
@@ -0,0 +1,268 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from openpilot.common.git import get_branch
|
||||
from openpilot.common.params import Params, ParamKeyType
|
||||
from openpilot.common.realtime import Ratekeeper
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
from openpilot.system.version import get_version
|
||||
|
||||
from cereal import messaging, custom
|
||||
from sunnypilot.sunnylink.api import SunnylinkApi
|
||||
from sunnypilot.sunnylink.backups.utils import decrypt_compressed_data, encrypt_compress_data, SnakeCaseEncoder
|
||||
|
||||
|
||||
class OperationType(Enum):
|
||||
BACKUP = "backup"
|
||||
RESTORE = "restore"
|
||||
|
||||
|
||||
class BackupManagerSP:
|
||||
"""Manages device configuration backups to/from sunnylink"""
|
||||
|
||||
def __init__(self):
|
||||
self.params = Params()
|
||||
self.device_id = self.params.get("SunnylinkDongleId", encoding="utf8")
|
||||
self.api = SunnylinkApi(self.device_id)
|
||||
self.pm = messaging.PubMaster(["backupManagerSP"])
|
||||
|
||||
# Status tracking
|
||||
self.backup_status = custom.BackupManagerSP.Status.idle
|
||||
self.restore_status = custom.BackupManagerSP.Status.idle
|
||||
|
||||
# Unified progress & operation type (only one operation runs at a time)
|
||||
self.progress = 0.0
|
||||
self.operation: OperationType | None = None
|
||||
|
||||
self.last_error = ""
|
||||
|
||||
def _report_status(self) -> None:
|
||||
"""Reports current backup manager state through the messaging system."""
|
||||
msg = messaging.new_message('backupManagerSP', valid=True)
|
||||
backup_state = msg.backupManagerSP
|
||||
|
||||
backup_state.backupStatus = self.backup_status
|
||||
backup_state.restoreStatus = self.restore_status
|
||||
# Both progress fields use the unified progress value
|
||||
backup_state.backupProgress = self.progress
|
||||
backup_state.restoreProgress = self.progress
|
||||
backup_state.lastError = self.last_error
|
||||
|
||||
# Optionally, add a field for operation type if supported:
|
||||
# backup_state.operationType = self.operation.value if self.operation else "none"
|
||||
|
||||
self.pm.send('backupManagerSP', msg)
|
||||
|
||||
def _update_progress(self, progress: float, op_type: OperationType) -> None:
|
||||
"""Updates the unified progress and operation type, then reports status."""
|
||||
self.progress = progress
|
||||
self.operation = op_type
|
||||
self._report_status()
|
||||
|
||||
def _collect_config_data(self) -> dict[str, Any]:
|
||||
"""Collects configuration data to be backed up."""
|
||||
config_data = {}
|
||||
params_to_backup = [k.decode('utf-8') for k in self.params.all_keys(ParamKeyType.BACKUP)]
|
||||
for param in params_to_backup:
|
||||
value = self.params.get(param)
|
||||
if value is not None:
|
||||
config_data[param] = base64.b64encode(value).decode('utf-8')
|
||||
return config_data
|
||||
|
||||
def _get_metadata_value(self, metadata_list, key, default_value=None):
|
||||
return next((entry.get("value") for entry in metadata_list if entry.get("key") == key), default_value)
|
||||
|
||||
async def create_backup(self) -> bool:
|
||||
"""Creates and uploads a new backup to sunnylink."""
|
||||
try:
|
||||
self.backup_status = custom.BackupManagerSP.Status.inProgress
|
||||
self._update_progress(0.0, OperationType.BACKUP)
|
||||
|
||||
# Collect configuration data
|
||||
config_data = self._collect_config_data()
|
||||
self._update_progress(25.0, OperationType.BACKUP)
|
||||
|
||||
# Serialize and encrypt config data
|
||||
config_json = json.dumps(config_data)
|
||||
encrypted_config = encrypt_compress_data(config_json, use_aes_256=True)
|
||||
self._update_progress(50.0, OperationType.BACKUP)
|
||||
|
||||
backup_info = custom.BackupManagerSP.BackupInfo()
|
||||
backup_info.deviceId = self.device_id
|
||||
backup_info.config = encrypted_config
|
||||
backup_info.isEncrypted = True
|
||||
backup_info.createdAt = time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime())
|
||||
backup_info.updatedAt = backup_info.createdAt
|
||||
backup_info.sunnypilotVersion = self._get_current_version()
|
||||
backup_info.backupMetadata = [
|
||||
custom.BackupManagerSP.MetadataEntry(key="creator", value="BackupManagerSP"),
|
||||
custom.BackupManagerSP.MetadataEntry(key="all_values_encoded", value="True"),
|
||||
custom.BackupManagerSP.MetadataEntry(key="AES", value="256")
|
||||
]
|
||||
|
||||
payload = json.loads(json.dumps(backup_info.to_dict(), cls=SnakeCaseEncoder))
|
||||
self._update_progress(75.0, OperationType.BACKUP)
|
||||
|
||||
# Upload to sunnylink
|
||||
result = self.api.api_get(
|
||||
f"backup/{self.device_id}",
|
||||
method='PUT',
|
||||
access_token=self.api.get_token(),
|
||||
json=payload
|
||||
)
|
||||
|
||||
if result:
|
||||
self.backup_status = custom.BackupManagerSP.Status.completed
|
||||
self._update_progress(100.0, OperationType.BACKUP)
|
||||
else:
|
||||
self.backup_status = custom.BackupManagerSP.Status.failed
|
||||
self.last_error = "Failed to upload backup"
|
||||
self._report_status()
|
||||
|
||||
return bool(self.backup_status == custom.BackupManagerSP.Status.completed)
|
||||
|
||||
except Exception as e:
|
||||
cloudlog.exception(f"Error creating backup: {str(e)}")
|
||||
self.backup_status = custom.BackupManagerSP.Status.failed
|
||||
self.last_error = str(e)
|
||||
self._report_status()
|
||||
return False
|
||||
|
||||
async def restore_backup(self, version: int | None = None) -> bool:
|
||||
"""Restores a backup from sunnylink."""
|
||||
try:
|
||||
self.restore_status = custom.BackupManagerSP.Status.inProgress
|
||||
self._update_progress(0.0, OperationType.RESTORE)
|
||||
|
||||
# Get backup data from API for the specified version
|
||||
endpoint = f"backup/{self.device_id}" + f"/{version or ''}" + "?api-version=1"
|
||||
backup_data = self.api.api_get(endpoint, access_token=self.api.get_token())
|
||||
if not backup_data:
|
||||
raise Exception(f"No backup found for device {self.device_id}")
|
||||
|
||||
self._update_progress(25.0, OperationType.RESTORE)
|
||||
|
||||
data = backup_data.json()
|
||||
backup_metadata = data.get("backup_metadata", [])
|
||||
encrypted_config = data.get("config", "")
|
||||
if not encrypted_config:
|
||||
raise Exception("Empty backup configuration")
|
||||
self._update_progress(50.0, OperationType.RESTORE)
|
||||
|
||||
# Decrypt config and load data
|
||||
use_aes_256 = self._get_metadata_value(backup_metadata, "AES", "128") == "256"
|
||||
config_json = decrypt_compressed_data(encrypted_config, use_aes_256)
|
||||
if not config_json:
|
||||
raise Exception("Failed to decrypt backup configuration")
|
||||
|
||||
config_data = json.loads(config_json)
|
||||
self._update_progress(75.0, OperationType.RESTORE)
|
||||
|
||||
# Apply configuration
|
||||
all_values_encoded = self._get_metadata_value(backup_metadata, "all_values_encoded", "false")
|
||||
self._apply_config(config_data, str(all_values_encoded).lower() == "true")
|
||||
|
||||
self.restore_status = custom.BackupManagerSP.Status.completed
|
||||
self._update_progress(100.0, OperationType.RESTORE)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
cloudlog.exception(f"Error restoring backup: {str(e)}")
|
||||
self.restore_status = custom.BackupManagerSP.Status.failed
|
||||
self.last_error = str(e)
|
||||
self._report_status()
|
||||
return False
|
||||
|
||||
def _apply_config(self, config_data: dict[str, str], all_values_encoded: bool = False) -> None:
|
||||
"""Applies configuration data from a backup, but only for parameters marked as backupable."""
|
||||
# Get the current list of parameters that can be backed up
|
||||
backupable_params = [k.decode('utf-8') for k in self.params.all_keys(ParamKeyType.BACKUP)]
|
||||
|
||||
# Count for logging/reporting
|
||||
restored_count = 0
|
||||
skipped_count = 0
|
||||
|
||||
for param, encoded_value in config_data.items():
|
||||
try:
|
||||
# Only restore parameters that are currently marked as backupable
|
||||
if param in backupable_params:
|
||||
value = base64.b64decode(encoded_value) if all_values_encoded else encoded_value
|
||||
self.params.put(param, value)
|
||||
restored_count += 1
|
||||
else:
|
||||
skipped_count += 1
|
||||
cloudlog.info(f"Skipped restoring param {param}: not marked for backup in current version")
|
||||
except Exception as e:
|
||||
cloudlog.error(f"Failed to restore param {param}: {str(e)}")
|
||||
|
||||
cloudlog.info(f"Restore complete: {restored_count} params restored, {skipped_count} params skipped")
|
||||
|
||||
def _get_current_version(self) -> custom.BackupManagerSP.Version:
|
||||
"""Gets current sunnypilot version information."""
|
||||
version_obj = custom.BackupManagerSP.Version()
|
||||
version_parts = get_version().split('.')
|
||||
version_obj.major = int(version_parts[0]) if len(version_parts) > 0 else 0
|
||||
version_obj.minor = int(version_parts[1]) if len(version_parts) > 1 else 0
|
||||
version_obj.patch = int(version_parts[2]) if len(version_parts) > 2 else 0
|
||||
version_obj.build = int(version_parts[3]) if len(version_parts) > 3 else 0
|
||||
version_obj.branch = get_branch()
|
||||
return version_obj
|
||||
|
||||
async def main_thread(self) -> None:
|
||||
"""Main thread for backup management."""
|
||||
rk = Ratekeeper(1, print_delay_threshold=None)
|
||||
reset_progress = False
|
||||
|
||||
while True:
|
||||
try:
|
||||
if reset_progress:
|
||||
self.progress = 100.0
|
||||
self.operation = None
|
||||
self.restore_status = custom.BackupManagerSP.Status.idle
|
||||
self.backup_status = custom.BackupManagerSP.Status.idle
|
||||
|
||||
# Check for backup command
|
||||
if self.params.get_bool("BackupManager_CreateBackup"):
|
||||
try:
|
||||
await self.create_backup()
|
||||
reset_progress = True
|
||||
finally:
|
||||
self.params.remove("BackupManager_CreateBackup")
|
||||
|
||||
# Check for restore command
|
||||
restore_version = self.params.get("BackupManager_RestoreVersion", encoding="utf8")
|
||||
if restore_version:
|
||||
try:
|
||||
version = int(restore_version) if restore_version.isdigit() else None
|
||||
await self.restore_backup(version)
|
||||
reset_progress = True
|
||||
finally:
|
||||
self.params.remove("BackupManager_RestoreVersion")
|
||||
|
||||
self._report_status()
|
||||
rk.keep_time()
|
||||
|
||||
except Exception as e:
|
||||
cloudlog.exception(f"Error in backup manager main thread: {str(e)}")
|
||||
self.last_error = str(e)
|
||||
self._report_status()
|
||||
rk.keep_time()
|
||||
|
||||
|
||||
def main():
|
||||
import asyncio
|
||||
asyncio.run(BackupManagerSP().main_thread())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,170 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import zlib
|
||||
import re
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
|
||||
from sunnypilot.sunnylink.backups.AESCipher import AESCipher
|
||||
from openpilot.system.hardware.hw import Paths
|
||||
|
||||
|
||||
class KeyDerivation:
|
||||
@staticmethod
|
||||
def _load_key(file_path: str) -> bytes:
|
||||
with open(file_path, 'rb') as f:
|
||||
return f.read()
|
||||
|
||||
@staticmethod
|
||||
def derive_aes_key_iv_from_rsa(key_path: str, use_aes_256: bool) -> tuple[bytes, bytes]:
|
||||
rsa_key_pem: bytes = KeyDerivation._load_key(key_path)
|
||||
key_plain = rsa_key_pem.decode(errors="ignore")
|
||||
|
||||
if "private" in key_plain.lower():
|
||||
private_key = serialization.load_pem_private_key(rsa_key_pem, password=None, backend=default_backend())
|
||||
if not isinstance(private_key, rsa.RSAPrivateKey):
|
||||
raise ValueError("Invalid RSA key format: Unable to determine if key is public or private.")
|
||||
|
||||
der_data = private_key.private_bytes(
|
||||
encoding=serialization.Encoding.DER,
|
||||
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
||||
encryption_algorithm=serialization.NoEncryption()
|
||||
)
|
||||
elif "public" in key_plain.lower():
|
||||
public_key = serialization.load_pem_public_key(rsa_key_pem, backend=default_backend())
|
||||
if not isinstance(public_key, rsa.RSAPublicKey):
|
||||
raise ValueError("Invalid RSA key format: Unable to determine if key is public or private.")
|
||||
|
||||
der_data = public_key.public_bytes(encoding=serialization.Encoding.DER, format=serialization.PublicFormat.PKCS1)
|
||||
else:
|
||||
raise ValueError("Unknown key format: Unable to determine if key is public or private.")
|
||||
|
||||
sha256_hash = hashlib.sha256(der_data).digest()
|
||||
aes_key = sha256_hash[:32] if use_aes_256 else sha256_hash[:16]
|
||||
aes_iv = sha256_hash[16:32]
|
||||
|
||||
return aes_key, aes_iv
|
||||
|
||||
|
||||
def qUncompress(data):
|
||||
"""
|
||||
Decompress data using zlib.
|
||||
|
||||
Args:
|
||||
data (bytes): Compressed data
|
||||
|
||||
Returns:
|
||||
bytes: Decompressed data
|
||||
"""
|
||||
data_stripped_4 = data[4:]
|
||||
return zlib.decompress(data_stripped_4)
|
||||
|
||||
|
||||
def qCompress(data):
|
||||
"""
|
||||
Compress data using zlib.
|
||||
|
||||
Args:
|
||||
data (bytes): Data to compress
|
||||
|
||||
Returns:
|
||||
bytes: Compressed data
|
||||
"""
|
||||
compressed_data = zlib.compress(data, level=9)
|
||||
return b"ZLIB" + compressed_data
|
||||
|
||||
|
||||
def decrypt_compressed_data(encrypted_base64, use_aes_256=False):
|
||||
"""
|
||||
Decrypt and decompress data from base64 string.
|
||||
|
||||
Args:
|
||||
encrypted_base64 (str): Base64 encoded encrypted data
|
||||
key_path (str, optional): Path to RSA public key
|
||||
|
||||
Returns:
|
||||
str: Decrypted and decompressed string
|
||||
"""
|
||||
key_path = Path(f"{Paths.persist_root()}/comma/id_rsa") if use_aes_256 else Path(f"{Paths.persist_root()}/comma/id_rsa.pub")
|
||||
try:
|
||||
# Decode base64
|
||||
encrypted_data = base64.b64decode(encrypted_base64)
|
||||
|
||||
# Decrypt
|
||||
key, iv = KeyDerivation.derive_aes_key_iv_from_rsa(str(key_path), use_aes_256)
|
||||
cipher = AESCipher(key, iv)
|
||||
decrypted_data = cipher.decrypt(encrypted_data)
|
||||
|
||||
# Decompress
|
||||
decompressed_data = qUncompress(decrypted_data)
|
||||
|
||||
# Decode UTF-8
|
||||
result = decompressed_data.decode('utf-8')
|
||||
return result
|
||||
except Exception as e:
|
||||
print(f"Decryption and decompression failed: {e}")
|
||||
return ""
|
||||
|
||||
|
||||
def encrypt_compress_data(text, use_aes_256=True):
|
||||
"""
|
||||
Compress and encrypt string data to base64.
|
||||
|
||||
Args:
|
||||
text (str): Text to compress and encrypt
|
||||
key_path (str, optional): Path to RSA public key
|
||||
|
||||
Returns:
|
||||
str: Base64 encoded encrypted data
|
||||
"""
|
||||
key_path = Path(f"{Paths.persist_root()}/comma/id_rsa") if use_aes_256 else Path(f"{Paths.persist_root()}/comma/id_rsa.pub")
|
||||
try:
|
||||
# Encode to UTF-8
|
||||
text_bytes = text.encode('utf-8')
|
||||
|
||||
# Compress
|
||||
compressed_data = qCompress(text_bytes)
|
||||
|
||||
# Encrypt
|
||||
key, iv = KeyDerivation.derive_aes_key_iv_from_rsa(str(key_path), use_aes_256)
|
||||
cipher = AESCipher(key, iv)
|
||||
encrypted_data = cipher.encrypt(compressed_data)
|
||||
|
||||
# Encode to base64
|
||||
result = base64.b64encode(encrypted_data).decode('utf-8')
|
||||
return result
|
||||
except Exception as e:
|
||||
print(f"Compression and encryption failed: {e}")
|
||||
return ""
|
||||
|
||||
|
||||
def camel_to_snake(name):
|
||||
"""Convert camelCase to snake_case."""
|
||||
name = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name)
|
||||
return re.sub('([a-z0-9])([A-Z])', r'\1_\2', name).lower()
|
||||
|
||||
|
||||
def transform_dict(obj):
|
||||
"""Recursively transform dictionary keys from camelCase to snake_case."""
|
||||
if isinstance(obj, dict):
|
||||
return {camel_to_snake(k): transform_dict(v) for k, v in obj.items()}
|
||||
elif isinstance(obj, list):
|
||||
return [transform_dict(item) for item in obj]
|
||||
return obj
|
||||
|
||||
|
||||
class SnakeCaseEncoder(json.JSONEncoder):
|
||||
def encode(self, obj):
|
||||
transformed_obj = transform_dict(obj)
|
||||
return super().encode(transformed_obj)
|
||||
@@ -148,9 +148,13 @@ procs = [
|
||||
|
||||
# sunnypilot
|
||||
procs += [
|
||||
# Models
|
||||
PythonProcess("models_manager", "sunnypilot.models.manager", only_offroad),
|
||||
NativeProcess("modeld_snpe", "sunnypilot/modeld", ["./modeld"], and_(only_onroad, is_snpe_model)),
|
||||
NativeProcess("modeld_tinygrad", "sunnypilot/modeld_v2", ["./modeld"], and_(only_onroad, is_tinygrad_model)),
|
||||
|
||||
# Backup
|
||||
PythonProcess("backup_manager", "sunnypilot.sunnylink.backups.manager", and_(only_offroad, sunnylink_ready_shim)),
|
||||
]
|
||||
|
||||
if os.path.exists("./github_runner.sh"):
|
||||
|
||||
Reference in New Issue
Block a user