diff --git a/cereal/custom.capnp b/cereal/custom.capnp index 4d245c006b..9c280349c1 100644 --- a/cereal/custom.capnp +++ b/cereal/custom.capnp @@ -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 { diff --git a/cereal/log.capnp b/cereal/log.capnp index 0587ba96fc..0d1fcf19a3 100644 --- a/cereal/log.capnp +++ b/cereal/log.capnp @@ -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; diff --git a/cereal/services.py b/cereal/services.py index 602524a71d..a5ef1e08f7 100755 --- a/cereal/services.py +++ b/cereal/services.py @@ -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), diff --git a/common/api/base.py b/common/api/base.py index 0707c1ccac..d40b47d49d 100644 --- a/common/api/base.py +++ b/common/api/base.py @@ -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) diff --git a/common/params_keys.h b/common/params_keys.h index f95539963d..8237bdabb1 100644 --- a/common/params_keys.h +++ b/common/params_keys.h @@ -153,6 +153,10 @@ inline static std::unordered_map keys = { {"SunnylinkdPid", PERSISTENT}, {"SunnylinkEnabled", PERSISTENT}, + // Backup Manager params + {"BackupManager_CreateBackup", PERSISTENT}, + {"BackupManager_RestoreVersion", PERSISTENT}, + // sunnypilot car specific params {"HyundaiRadarTracks", PERSISTENT}, {"HyundaiRadarTracksConfirmed", PERSISTENT}, diff --git a/selfdrive/ui/sunnypilot/qt/offroad/settings/sunnylink_panel.cc b/selfdrive/ui/sunnypilot/qt/offroad/settings/sunnylink_panel.cc index 706e89ddf0..c9eccb2c42 100644 --- a/selfdrive/ui/sunnypilot/qt/offroad/settings/sunnylink_panel.cc +++ b/selfdrive/ui/sunnypilot/qt/offroad/settings/sunnylink_panel.cc @@ -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 SunnylinkPanel::SunnylinkPanel(QWidget *parent) : QFrame(parent) { main_layout = new QStackedLayout(this); @@ -75,7 +77,6 @@ SunnylinkPanel::SunnylinkPanel(QWidget *parent) : QFrame(parent) { description = ""+ tr("🎉Welcome back! We're excited to see you've enabled sunnylink again! 🚀")+ ""; } else { description = ""+ tr("👋Not going to lie, it's sad to see you disabled sunnylink 😢, but we'll be here when you're ready to come back 🎉.")+ ""; - } sunnylinkEnabledBtn->showDescription(); sunnylinkEnabledBtn->setDescription(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(""); diff --git a/selfdrive/ui/sunnypilot/qt/offroad/settings/sunnylink_panel.h b/selfdrive/ui/sunnypilot/qt/offroad/settings/sunnylink_panel.h index d231757e36..e17c68d3a3 100644 --- a/selfdrive/ui/sunnypilot/qt/offroad/settings/sunnylink_panel.h +++ b/selfdrive/ui/sunnypilot/qt/offroad/settings/sunnylink_panel.h @@ -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; }; diff --git a/selfdrive/ui/sunnypilot/ui.cc b/selfdrive/ui/sunnypilot/ui.cc index e27771b9bb..586a03b1dd 100644 --- a/selfdrive/ui/sunnypilot/ui.cc +++ b/selfdrive/ui/sunnypilot/ui.cc @@ -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 diff --git a/sunnypilot/sunnylink/api.py b/sunnypilot/sunnylink/api.py index 9f82fd2d20..eb9343af9a 100644 --- a/sunnypilot/sunnylink/api.py +++ b/sunnypilot/sunnylink/api.py @@ -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() diff --git a/sunnypilot/sunnylink/backups/AESCipher.py b/sunnypilot/sunnylink/backups/AESCipher.py new file mode 100644 index 0000000000..36cb149bba --- /dev/null +++ b/sunnypilot/sunnylink/backups/AESCipher.py @@ -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] diff --git a/sunnypilot/sunnylink/backups/__init__.py b/sunnypilot/sunnylink/backups/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/sunnypilot/sunnylink/backups/manager.py b/sunnypilot/sunnylink/backups/manager.py new file mode 100644 index 0000000000..7f3eddeaa8 --- /dev/null +++ b/sunnypilot/sunnylink/backups/manager.py @@ -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() diff --git a/sunnypilot/sunnylink/backups/utils.py b/sunnypilot/sunnylink/backups/utils.py new file mode 100644 index 0000000000..bcf7ea8ebb --- /dev/null +++ b/sunnypilot/sunnylink/backups/utils.py @@ -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) diff --git a/system/manager/process_config.py b/system/manager/process_config.py index 1d2ca45944..7a37e59d93 100644 --- a/system/manager/process_config.py +++ b/system/manager/process_config.py @@ -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"):