mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-09-06 08:03:44 +08:00
246 lines
7.9 KiB
Python
246 lines
7.9 KiB
Python
from __future__ import annotations
|
|
|
|
import base64
|
|
import json
|
|
import math
|
|
import threading
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
|
|
from openpilot.common.params import ParamKeyFlag, ParamKeyType
|
|
|
|
|
|
PROFILE_FORMAT = "starpilot-params-profile"
|
|
PROFILE_VERSION = 1
|
|
PROFILE_MAX_BYTES = 2_000_000
|
|
DEFAULT_PROFILE_ROOT = Path("/data/toggle_backups")
|
|
PROFILE_SLOTS = {
|
|
"a": "Profile Slot A",
|
|
"b": "Profile Slot B",
|
|
}
|
|
PROFILE_NO_DEFAULT_KEYS = {
|
|
"AdbEnabled",
|
|
"AlphaLongitudinalEnabled",
|
|
"AlwaysOnDM",
|
|
"ExperimentalMode",
|
|
"ExperimentalModeConfirmed",
|
|
"IsLdwEnabled",
|
|
"IsMetric",
|
|
"IsRHD",
|
|
"IsRHDOverride",
|
|
"RecordAudio",
|
|
"RecordFront",
|
|
"SshEnabled",
|
|
}
|
|
|
|
_PROFILE_LOCK = threading.Lock()
|
|
|
|
|
|
class ParamProfileError(ValueError):
|
|
pass
|
|
|
|
|
|
def _normalize_slot(slot: str) -> str:
|
|
normalized = str(slot or "").strip().lower()
|
|
if normalized not in PROFILE_SLOTS:
|
|
raise ParamProfileError("Unknown settings profile slot.")
|
|
return normalized
|
|
|
|
|
|
def _profile_path(slot: str, profile_root: Path | None = None) -> Path:
|
|
normalized = _normalize_slot(slot)
|
|
root = Path(profile_root) if profile_root is not None else DEFAULT_PROFILE_ROOT
|
|
return root / f".params-profile-{normalized}.json"
|
|
|
|
|
|
def _key_text(raw_key) -> str:
|
|
return raw_key.decode("utf-8") if isinstance(raw_key, bytes) else str(raw_key)
|
|
|
|
|
|
def eligible_profile_keys(params, *, excluded_keys: set[str] | None = None) -> set[str]:
|
|
excluded = excluded_keys or set()
|
|
keys = set()
|
|
for raw_key in params.all_keys():
|
|
key = _key_text(raw_key)
|
|
if key in excluded:
|
|
continue
|
|
|
|
try:
|
|
flags = params.get_key_flag(raw_key)
|
|
default_value = params.get_default_value(raw_key)
|
|
except Exception:
|
|
continue
|
|
|
|
if not flags & ParamKeyFlag.PERSISTENT or flags & ParamKeyFlag.DONT_LOG:
|
|
continue
|
|
if default_value is None and key not in PROFILE_NO_DEFAULT_KEYS:
|
|
continue
|
|
keys.add(key)
|
|
return keys
|
|
|
|
|
|
def _get_current_value(params, key: str):
|
|
try:
|
|
return params.get(key, return_default=True)
|
|
except TypeError:
|
|
return params.get(key)
|
|
|
|
|
|
def _serialize_value(value_type: ParamKeyType, value):
|
|
if value_type == ParamKeyType.BYTES:
|
|
raw_value = value if isinstance(value, bytes) else str(value).encode("utf-8")
|
|
return base64.b64encode(raw_value).decode("ascii")
|
|
if value_type == ParamKeyType.TIME:
|
|
return value.isoformat() if isinstance(value, datetime) else str(value)
|
|
if isinstance(value, tuple):
|
|
return list(value)
|
|
if isinstance(value, float) and not math.isfinite(value):
|
|
raise ValueError("non-finite numeric value")
|
|
return value
|
|
|
|
|
|
def _deserialize_value(value_type: ParamKeyType, value):
|
|
if value_type == ParamKeyType.BYTES:
|
|
if not isinstance(value, str):
|
|
raise ValueError("invalid bytes value")
|
|
return base64.b64decode(value.encode("ascii"), validate=True)
|
|
if value_type == ParamKeyType.TIME:
|
|
if not isinstance(value, str):
|
|
raise ValueError("invalid time value")
|
|
return datetime.fromisoformat(value)
|
|
return value
|
|
|
|
|
|
def _build_profile_payload(params, slot: str, allowed_keys: set[str] | None = None) -> dict:
|
|
normalized = _normalize_slot(slot)
|
|
keys = eligible_profile_keys(params) if allowed_keys is None else set(allowed_keys)
|
|
settings = {}
|
|
for key in sorted(keys):
|
|
try:
|
|
value = _get_current_value(params, key)
|
|
if value is None:
|
|
continue
|
|
value_type = ParamKeyType(params.get_type(key))
|
|
serialized_value = _serialize_value(value_type, value)
|
|
json.dumps(serialized_value, allow_nan=False)
|
|
settings[key] = {
|
|
"type": int(value_type),
|
|
"value": serialized_value,
|
|
}
|
|
except (TypeError, ValueError, OverflowError):
|
|
continue
|
|
|
|
if not settings:
|
|
raise ParamProfileError("No compatible settings were available to save.")
|
|
|
|
return {
|
|
"format": PROFILE_FORMAT,
|
|
"version": PROFILE_VERSION,
|
|
"slot": normalized,
|
|
"createdAt": datetime.now(UTC).isoformat(),
|
|
"settingsCount": len(settings),
|
|
"settings": settings,
|
|
}
|
|
|
|
|
|
def save_profile(params, slot: str, *, allowed_keys: set[str] | None = None, profile_root: Path | None = None) -> dict:
|
|
normalized = _normalize_slot(slot)
|
|
payload = _build_profile_payload(params, normalized, allowed_keys)
|
|
encoded = json.dumps(payload, indent=2, allow_nan=False).encode("utf-8")
|
|
if len(encoded) > PROFILE_MAX_BYTES:
|
|
raise ParamProfileError("The settings profile is too large to save.")
|
|
|
|
path = _profile_path(normalized, profile_root)
|
|
temp_path = path.with_suffix(".tmp")
|
|
with _PROFILE_LOCK:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
temp_path.write_bytes(encoded)
|
|
temp_path.chmod(0o600)
|
|
temp_path.replace(path)
|
|
return profile_status(normalized, profile_root=profile_root)
|
|
|
|
|
|
def _read_profile(slot: str, profile_root: Path | None = None) -> dict:
|
|
normalized = _normalize_slot(slot)
|
|
path = _profile_path(normalized, profile_root)
|
|
if not path.is_file():
|
|
raise ParamProfileError(f"{PROFILE_SLOTS[normalized]} has not been saved yet.")
|
|
if path.stat().st_size > PROFILE_MAX_BYTES:
|
|
raise ParamProfileError("The saved settings profile is too large.")
|
|
|
|
try:
|
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
except (OSError, UnicodeError, json.JSONDecodeError) as error:
|
|
raise ParamProfileError("The saved settings profile is damaged.") from error
|
|
|
|
if not isinstance(payload, dict) or payload.get("format") != PROFILE_FORMAT:
|
|
raise ParamProfileError("The saved settings profile is invalid.")
|
|
version = payload.get("version")
|
|
if not isinstance(version, int) or version > PROFILE_VERSION:
|
|
raise ParamProfileError("The saved settings profile requires a newer StarPilot version.")
|
|
if payload.get("slot") != normalized or not isinstance(payload.get("settings"), dict):
|
|
raise ParamProfileError("The saved settings profile is invalid.")
|
|
return payload
|
|
|
|
|
|
def load_profile(params, slot: str, *, allowed_keys: set[str] | None = None, profile_root: Path | None = None,
|
|
legacy_renames: dict[str, str] | None = None) -> dict:
|
|
normalized = _normalize_slot(slot)
|
|
with _PROFILE_LOCK:
|
|
payload = _read_profile(normalized, profile_root)
|
|
keys = eligible_profile_keys(params) if allowed_keys is None else set(allowed_keys)
|
|
renames = legacy_renames or {}
|
|
restored_count = 0
|
|
skipped_count = 0
|
|
for saved_key, entry in payload["settings"].items():
|
|
key = renames.get(saved_key, saved_key)
|
|
if not isinstance(key, str) or key not in keys or not isinstance(entry, dict):
|
|
skipped_count += 1
|
|
continue
|
|
try:
|
|
current_type = ParamKeyType(params.get_type(key))
|
|
saved_type = ParamKeyType(entry.get("type"))
|
|
if saved_type != current_type or "value" not in entry:
|
|
raise ValueError("setting type changed")
|
|
params.put(key, _deserialize_value(current_type, entry["value"]))
|
|
restored_count += 1
|
|
except (KeyError, TypeError, ValueError, OverflowError):
|
|
skipped_count += 1
|
|
|
|
if restored_count == 0:
|
|
raise ParamProfileError("No compatible settings were found in this profile.")
|
|
return {
|
|
"slot": normalized,
|
|
"label": PROFILE_SLOTS[normalized],
|
|
"restoredCount": restored_count,
|
|
"skippedCount": skipped_count,
|
|
}
|
|
|
|
|
|
def profile_status(slot: str, *, profile_root: Path | None = None) -> dict:
|
|
normalized = _normalize_slot(slot)
|
|
status = {
|
|
"slot": normalized,
|
|
"label": PROFILE_SLOTS[normalized],
|
|
"saved": False,
|
|
"createdAt": None,
|
|
"settingsCount": 0,
|
|
}
|
|
path = _profile_path(normalized, profile_root)
|
|
if not path.is_file():
|
|
return status
|
|
try:
|
|
payload = _read_profile(normalized, profile_root)
|
|
except ParamProfileError:
|
|
return {**status, "saved": True, "invalid": True}
|
|
return {
|
|
**status,
|
|
"saved": True,
|
|
"createdAt": payload.get("createdAt"),
|
|
"settingsCount": len(payload["settings"]),
|
|
}
|
|
|
|
|
|
def list_profiles(*, profile_root: Path | None = None) -> list[dict]:
|
|
return [profile_status(slot, profile_root=profile_root) for slot in PROFILE_SLOTS]
|