mirror of
https://github.com/sunnypilot/sunnypilot.git
synced 2026-02-18 21:14:01 +08:00
sunnylink: Settings backup & restore (#681)
* Add AES encryption and utility methods This commit introduces a new AESCipher class that can be used for AES encryption with support for both AES-128 and AES-256. It also adds a set of utility functions, including methods for RSA to AES key derivation, file decryption and compression, and encryption and decompression. These changes provide fundamental cryptographic functionalities for data security within the system. * Revised backup management system with new structures This update introduces important revisions to the backup management system. A new struct named 'BackupManagerSP' has been integrated into the 'custom.capnp' file, replacing 'CustomReserved6'. This new struct includes several variables that help keep track of backup and restore status, progress and related messages. Additional code modifications were made in 'base.py' and 'api.py' to include a 'json' parameter in the 'api_get' method. Lastly, 'manager.py' has been updated with functions to manage device configuration backups to and from sunnylink. The changes promote better handling and management of data backup and restoration processes. The update is specifically designed to ensure that the backup information is well integrated into the system, with properly tracked status and progress. * Refactor `allKeys` to filter by parameter key type Updated the `allKeys` method to support filtering by `ParamKeyType`, allowing more specific key retrieval. Added a default value for backward compatibility and updated related bindings and keys to reflect this change. * Improve Backup and Restore mechanisms The commit refactors and improves several aspects of the backup and restore mechanisms in the `BackupManagerSP` class. These improvements include removing redundant status tracking variables and replacing them with unified ones, updating the messaging system to handle all changes correctly, and including an enumeration `OperationType` to keep track of the type of operation currently being processed. This commit also applies stricter conditions for restore operations, such that it only restores parameters that are currently marked as backupable, and skips those that are not. This is a preventive measure against potential issues when restoring parameters that are no longer relevant or could conflict with current versions. Also, the encryption and decryption methods were updated to use AES-256 for more security. These changes have increased the robustness and reliability of the backup and restoration processes. * copyright * Add backup_manager process to offroad sunnypilot tasks The backup_manager process is introduced to handle backups during offroad mode when SunnyLink is ready. This ensures proper backup management functionality integrated into the system. * Simplify backup endpoint construction in restore method Replaced conditional expression with a more concise and readable `or` operation for constructing the backup endpoint. This makes the code cleaner and easier to maintain while preserving functionality. * Added support for backing up and restoring sunnypilot settings An update to the sunnypilot functionality now provides two new features that allow users to backup and restore their sunnypilot settings. The changes include the addition of UI controls for initiating backup and restore operations, and the creation of a system-wide state management function for tracking these operations. This enhancement significantly improves the user experience by providing a safety net for user settings in case of software failures, bugs, or unintended changes. * Refactor type hints to use PEP 604 syntax for clarity Replaced `Optional` and `Dict` type hints with `|` and `dict` syntax for improved readability and compliance with Python 3.10+. Updated related imports and adjusted list comprehension for cleaner code. * Update import path for hardware module in utils.py Replaced the import path for the `Paths` module to align with the new directory structure under `openpilot`. This ensures compatibility with recent project reorganization and avoids import errors. * Improve RSA key handling and fix backup status comparison Added explicit RSA key type checks to handle invalid key formats. Enhanced type safety in `manager.py` by ensuring the backup status comparison returns a boolean. These changes improve robustness and error handling in backups. * format * more * Improve backup and restore flow with progress tracking and fixes Added proper progress tracking and cleanup logic during restore operations. Enhanced restore experience by resetting progress after completion and introducing confirmation dialogs for errors and completion. Updated API compatibility with a version query parameter for backups. * Enable backup button only when restore process is complete Previously, the backup button could be enabled during a restore operation, which might cause unintended behavior. This update introduces a check to ensure the backup button remains disabled while a restore process is active. This improves user experience and prevents potential conflicts. * Fix restore button state handling during restore process Ensure the restore button is disabled consistently when a restore is in progress. This prevents user interaction issues and aligns the button state with the restore operation status. * "Refactor restore logic and improve button state handling" Replaced `is_restoring` with `restore_request_pending` for clarity and better state management. Adjusted button behavior to immediately disable upon user action, ensuring improved UX and preventing repeat inputs. Refined restore completion flow for better consistency and reliability. * Refine restore process logic for SunnyLink settings. Introduced `restore_request_started` to improve handling of restore states and ensure accurate UI updates during the process. Adjusted case handling to enhance clarity and maintain proper behavior when restoring settings, especially during ongoing or completed requests. * revert * move around * fix enabled states for different statuses * add prompt to notify backup is complete * same states as restore * disable buttons if sunnylink is off * can use the same texts --------- Co-authored-by: Jason Wen <haibin.wen3@gmail.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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()
|
||||
|
||||
34
sunnypilot/sunnylink/backups/AESCipher.py
Normal file
34
sunnypilot/sunnylink/backups/AESCipher.py
Normal file
@@ -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
sunnypilot/sunnylink/backups/__init__.py
Normal file
0
sunnypilot/sunnylink/backups/__init__.py
Normal file
268
sunnypilot/sunnylink/backups/manager.py
Normal file
268
sunnypilot/sunnylink/backups/manager.py
Normal file
@@ -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()
|
||||
170
sunnypilot/sunnylink/backups/utils.py
Normal file
170
sunnypilot/sunnylink/backups/utils.py
Normal file
@@ -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