sunnypilot v2026.002.000

version: sunnypilot v2026.002.000 (staging)
date: 2026-07-21T07:44:02
master commit: 2c334ede443d7391d27575af8c854a095ba702a8
This commit is contained in:
github-actions[bot] committed 2026-07-21 07:44:02 +00:00
commit de77c89cf6
3995 files changed
+1260688

No files matched your search

@@ -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]
Whitespace-only changes.
@@ -0,0 +1,286 @@
"""
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 requests
import time
from enum import Enum
from typing import Any
from openpilot.common.git import get_branch
from openpilot.common.params import Params, ParamKeyFlag
from openpilot.common.realtime import Ratekeeper
from openpilot.common.swaglog import cloudlog
from openpilot.common.version import get_version
from openpilot.cereal import messaging, custom
from openpilot.sunnypilot.sunnylink.api import SunnylinkApi
from openpilot.sunnypilot.sunnylink.backups.utils import decrypt_compressed_data, encrypt_compressed_data, SnakeCaseEncoder
from openpilot.sunnypilot.sunnylink.utils import get_param_as_byte, save_param_from_base64_encoded_string
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")
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 = ""
self._session = requests.Session() # reuse session to reduce SSL handshake overhead
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(ParamKeyFlag.BACKUP)]
for param in params_to_backup:
value = get_param_as_byte(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_compressed_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)
cloudlog.debug(f"Uploading backup with payload: {json.dumps(payload)}")
# Upload to sunnylink
result = self.api.api_get(
f"backup/{self.device_id}",
method='PUT',
access_token=self.api.get_token(),
json=payload,
session=self._session
)
if result:
self.backup_status = custom.BackupManagerSP.Status.completed
self._update_progress(100.0, OperationType.BACKUP)
cloudlog.info("Backup successfully created and uploaded")
else:
self.backup_status = custom.BackupManagerSP.Status.failed
self.last_error = "Failed to upload backup"
cloudlog.error(result)
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(), session=self._session)
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
self._apply_config(config_data)
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]) -> None:
"""Applies configuration data from a backup, but only for parameters marked as backupable."""
backupable_params = [k.decode('utf-8') for k in self.params.all_keys(ParamKeyFlag.BACKUP)]
backupable_set_lower = {p.lower() for p in backupable_params}
restored_count = 0
skipped_count = 0
for param, encoded_value in config_data.items():
if param.lower() in backupable_set_lower:
# Find real param name (with correct casing)
real_param = next(p for p in backupable_params if p.lower() == param.lower())
try:
save_param_from_base64_encoded_string(real_param, encoded_value)
restored_count += 1
except Exception as e:
cloudlog.error(f"Failed to restore param {param}: {str(e)}")
else:
skipped_count += 1
cloudlog.info(f"Skipped restoring param {param}: not marked for backup in current version")
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_str = get_version()
version_parts = version_str.split('-') # For when version is like "1.2.3-456"
version_nums = version_parts[0].split('.')
# Extract build number from hyphen format or as 4th version component
build = 0
if len(version_parts) > 1 and version_parts[1].isdigit():
build = int(version_parts[1])
elif len(version_nums) > 3 and version_nums[3].isdigit():
build = int(version_nums[3])
# Set version components with safer defaults
version_obj.major = int(version_nums[0]) if len(version_nums) > 0 and version_nums[0].isdigit() else 0
version_obj.minor = int(version_nums[1]) if len(version_nums) > 1 and version_nums[1].isdigit() else 0
version_obj.patch = int(version_nums[2]) if len(version_nums) > 2 and version_nums[2].isdigit() else 0
version_obj.build = build
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:
if await self.create_backup():
reset_progress = True
finally:
self.params.remove("BackupManager_CreateBackup")
# Check for restore command
restore_version = self.params.get("BackupManager_RestoreVersion")
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,188 @@
"""
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 os
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, ec
from openpilot.common.api.base import KEYS
from openpilot.sunnypilot.sunnylink.backups.AESCipher import AESCipher
from openpilot.common.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(key_path: str, use_aes_256: bool) -> tuple[bytes, bytes]:
key_pem: bytes = KeyDerivation._load_key(key_path)
key_plain = key_pem.decode(errors="ignore")
if "private" in key_plain.lower():
private_key = serialization.load_pem_private_key(key_pem, password=None, backend=default_backend())
if isinstance(private_key, (rsa.RSAPrivateKey, ec.EllipticCurvePrivateKey)):
public_key = private_key.public_key()
else:
raise ValueError("Invalid key format: Unable to determine if key is public or private.")
elif "public" in key_plain.lower():
public_key = serialization.load_pem_public_key(key_pem, backend=default_backend())
if not isinstance(public_key, (rsa.RSAPublicKey, ec.EllipticCurvePublicKey)):
raise ValueError("Invalid key format: Unable to determine if key is public or private.")
else:
raise ValueError("Invalid key format: Unable to determine if key is public or private.")
if isinstance(public_key, rsa.RSAPublicKey):
der_data = public_key.public_bytes(encoding=serialization.Encoding.DER, format=serialization.PublicFormat.PKCS1)
elif isinstance(public_key, ec.EllipticCurvePublicKey):
der_data = public_key.public_bytes(encoding=serialization.Encoding.DER, format=serialization.PublicFormat.SubjectPublicKeyInfo)
else:
raise ValueError("Unsupported key type.")
if use_aes_256:
# AES-256-CBC
key = hashlib.sha256(der_data).digest()
iv = hashlib.md5(der_data).digest()
else:
# AES-128-CBC
key = hashlib.md5(der_data).digest()
iv = hashlib.md5(der_data).digest() # Insecure IV reuse, kept for compatibility
return key, iv
def uncompress_dat(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 compress_dat(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 get_key_path(use_aes_256=False) -> str:
key_path = ""
for key in KEYS:
if os.path.isfile(Paths.persist_root() + f'/comma/{key}') and os.path.isfile(Paths.persist_root() + f'/comma/{key}.pub'):
key_path = str(Path(Paths.persist_root() + f'/comma/{key}') if use_aes_256 else Path(Paths.persist_root() + f'/comma/{key}.pub'))
break
if not key_path:
raise FileNotFoundError("No valid key pair found in persist storage.")
return key_path
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
"""
try:
# Decode base64
encrypted_data = base64.b64decode(encrypted_base64)
# Decrypt
key, iv = KeyDerivation.derive_aes_key_iv(get_key_path(use_aes_256), use_aes_256)
cipher = AESCipher(key, iv)
decrypted_data = cipher.decrypt(encrypted_data)
# Decompress
decompressed_data = uncompress_dat(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_compressed_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
"""
try:
# Encode to UTF-8
text_bytes = text.encode('utf-8')
# Compress
compressed_data = compress_dat(text_bytes)
# Encrypt
key, iv = KeyDerivation.derive_aes_key_iv(get_key_path(use_aes_256), 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)