mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-10-08 07:13:59 +08:00
9798511e2e
Keep the PiP side-camera contribution from PR #83 opt-in and off-road configurable. Credit to Prabhaav Pillai for the original implementation.
7869 lines
286 KiB
Python
7869 lines
286 KiB
Python
#!/usr/bin/env python3
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
import importlib
|
|
import math
|
|
import numbers
|
|
import os
|
|
import sys
|
|
import tarfile
|
|
|
|
from io import BytesIO
|
|
from pathlib import Path
|
|
|
|
import base64
|
|
import errno
|
|
import hashlib
|
|
import json
|
|
import re
|
|
import requests
|
|
import secrets
|
|
import selectors
|
|
import shutil
|
|
import signal
|
|
import subprocess
|
|
import numpy as np
|
|
from msgq.visionipc import VisionIpcClient, VisionStreamType
|
|
from PIL import Image
|
|
import threading
|
|
import time
|
|
import traceback
|
|
from urllib.parse import quote
|
|
|
|
from cereal import car, custom, log, messaging
|
|
from opendbc.can.parser import CANParser
|
|
from opendbc.car.gm.values import GMFlags
|
|
from opendbc.car.toyota.carcontroller import LOCK_CMD, UNLOCK_CMD
|
|
from opendbc.car.toyota.values import ToyotaStarPilotFlags
|
|
from openpilot.common.constants import CV
|
|
from openpilot.common.params import ParamKeyFlag, ParamKeyType, Params
|
|
from openpilot.common.realtime import DT_HW
|
|
from openpilot.common.time_helpers import system_time_valid
|
|
from openpilot.system.hardware import HARDWARE, PC
|
|
from openpilot.system.hardware.hw import Paths
|
|
from openpilot.system.loggerd.deleter import PRESERVE_ATTR_NAME, PRESERVE_ATTR_VALUE, PRESERVE_COUNT
|
|
from openpilot.system.version import get_build_metadata
|
|
from openpilot.tools.longitudinal_maneuvers.capabilities import get_longitudinal_maneuver_support
|
|
from panda import Panda
|
|
|
|
from openpilot.starpilot.assets.model_manager import canonical_model_key, is_builtin_model_key, model_key_aliases
|
|
from openpilot.starpilot.assets.theme_manager import HOLIDAY_THEME_PATH, THEME_COMPONENT_PARAMS
|
|
from openpilot.starpilot.common.accel_profile import (
|
|
CUSTOM_ACCEL_PROFILE_INITIALIZED_KEY,
|
|
CUSTOM_ACCEL_PROFILE_PARAM_KEYS,
|
|
build_custom_accel_profile_defaults,
|
|
custom_accel_profile_is_initialized,
|
|
normalize_acceleration_profile,
|
|
)
|
|
from openpilot.starpilot.common.maps_catalog import (
|
|
MAPS_CATALOG,
|
|
MAP_SCHEDULE_OPTIONS,
|
|
get_selected_map_entries,
|
|
sanitize_selected_locations_csv,
|
|
schedule_label,
|
|
schedule_param_value,
|
|
)
|
|
from openpilot.starpilot.common.maps_download_progress import load_size_cache, nonnegative_int, selection_key
|
|
from openpilot.starpilot.common.experimental_state import sync_persist_chill_state, sync_persist_experimental_state
|
|
from openpilot.starpilot.common.favorite_slots import (
|
|
FAVORITE_ACTION_OPTIONS,
|
|
FAVORITE_SLOTS_PARAM,
|
|
is_favorite_action_key,
|
|
normalize_favorite_slots,
|
|
trigger_favorite_action,
|
|
)
|
|
from openpilot.starpilot.common.lateral_delay import full_lateral_delay
|
|
from openpilot.starpilot.common.starpilot_utilities import delete_file, get_lock_status, run_cmd
|
|
from openpilot.starpilot.common.starpilot_variables import ACTIVE_THEME_PATH, ERROR_LOGS_PATH, EXCLUDED_KEYS, LEGACY_STARPILOT_PARAM_RENAMES, MAPS_PATH, MODELS_PATH, RESOURCES_REPO, SCREEN_RECORDINGS_PATH, STOCK_THEME_PATH, THEME_SAVE_PATH,\
|
|
default_ev_tuning_enabled, migrate_cancel_button_controls, update_starpilot_toggles
|
|
from openpilot.starpilot.common.testing_grounds import (
|
|
DEFAULT_TESTING_GROUND_VARIANT as SHARED_DEFAULT_TESTING_GROUND_VARIANT,
|
|
TESTING_GROUND_VARIANT_LABELS as SHARED_TESTING_GROUND_VARIANT_LABELS,
|
|
TESTING_GROUND_VARIANTS as SHARED_TESTING_GROUND_VARIANTS,
|
|
TESTING_GROUNDS_SCHEMA_VERSION as SHARED_TESTING_GROUNDS_SCHEMA_VERSION,
|
|
TESTING_GROUNDS_SLOT_DEFINITIONS as SHARED_TESTING_GROUNDS_SLOT_DEFINITIONS,
|
|
TESTING_GROUNDS_STATE_PATH as SHARED_TESTING_GROUNDS_STATE_PATH,
|
|
)
|
|
from openpilot.starpilot.navigation.destination_store import normalize_destination_payload, update_recent_destinations
|
|
from openpilot.starpilot.system.the_galaxy.factory_reset import remove_path as _run_factory_reset_delete
|
|
from openpilot.starpilot.system.the_galaxy import flm_workspace, utilities
|
|
from openpilot.starpilot.system.the_galaxy.update_recovery import inspect_interrupted_update, public_recovery_status, recover_interrupted_update
|
|
|
|
DISCORD_WEBHOOK_URL = os.getenv("DISCORD_WEBHOOK_URL")
|
|
|
|
GITLAB_API = "https://gitlab.com/api/v4"
|
|
GITLAB_SUBMISSIONS_PROJECT_ID = "71992109"
|
|
GITLAB_TOKEN = os.environ.get("GITLAB_TOKEN", "")
|
|
LEGACY_LATERAL_METHOD_API_PREFIX = "/api/" + "".join(("f", "t", "m"))
|
|
VASM_CONFIGURATION_KEYS = {"VASMEnabled", "VASMConfidenceThreshold", "VASMSmoothSeconds", "VASMAnnotationConfig"}
|
|
PIP_PREVIEW_CONFIGURATION_KEYS = {"PIPPreviewEnabled", "PIPPreviewMask", "PIPPreviewShowOnBlinker", "PIPPreviewShowOnBSM"}
|
|
MODEL_SMOOTHING_KEYS = {"LatSmoothSeconds", "LongSmoothSeconds"}
|
|
|
|
GALAXY_DEPS_PATH = "/data/galaxy_deps"
|
|
LEGACY_GALAXY_DEPS_PATH = "/data/" + "".join(chr(code) for code in (112, 111, 110, 100)) + "_deps"
|
|
GALAXY_DEPS_PATHS = (GALAXY_DEPS_PATH, LEGACY_GALAXY_DEPS_PATH)
|
|
for deps_path in GALAXY_DEPS_PATHS:
|
|
if os.path.isdir(deps_path) and deps_path not in sys.path:
|
|
sys.path.insert(0, deps_path)
|
|
|
|
REPO_THIRD_PARTY_PATH = Path(__file__).resolve().parents[2] / "third_party"
|
|
if REPO_THIRD_PARTY_PATH.is_dir() and str(REPO_THIRD_PARTY_PATH) not in sys.path:
|
|
sys.path.insert(0, str(REPO_THIRD_PARTY_PATH))
|
|
|
|
Flask = None
|
|
Response = None
|
|
jsonify = None
|
|
make_response = None
|
|
render_template = None
|
|
request = None
|
|
send_file = None
|
|
send_from_directory = None
|
|
|
|
_GALAXY_WEB_DEPS_READY = False
|
|
_GALAXY_WEB_DEPS_ERROR = None
|
|
|
|
_TESTING_GROUND_CUSTOM_RESERVED_SERVICE = "customReserved9"
|
|
_TESTING_GROUND_CUSTOM_RESERVED_INTERVAL_S = 15.0
|
|
_TESTING_GROUND_CUSTOM_RESERVED_PM = None
|
|
_TESTING_GROUND_CUSTOM_RESERVED_LOCK = threading.Lock()
|
|
_TESTING_GROUND_CUSTOM_RESERVED_LAST_PUBLISH_MONO = 0.0
|
|
PANDA_FIRMWARE_TOGGLE_KEYS = {"IgnoreIgnitionLine", "RemoteStartBootsComma", "HKGRemoteStartBootsComma"}
|
|
PANDA_FIRMWARE_CONFIRMATION_FIELD = "confirmedPandaFirmwareFlash"
|
|
_PANDA_FLASH_REBOOT_LOCK = threading.Lock()
|
|
|
|
|
|
def _flash_panda_then_reboot() -> None:
|
|
with _PANDA_FLASH_REBOOT_LOCK:
|
|
params_memory.put_bool("FlashPanda", True)
|
|
while params_memory.get_bool("FlashPanda"):
|
|
time.sleep(0.1)
|
|
HARDWARE.reboot()
|
|
|
|
|
|
def _is_comma_device_runtime() -> bool:
|
|
"""Robust runtime device check.
|
|
|
|
`PC` is derived from `/TICI`, which can be missing in edge boot/update states.
|
|
For Galaxy routing we must keep on-device Galaxy on 8082.
|
|
"""
|
|
if not PC:
|
|
return True
|
|
|
|
if os.path.isfile("/TICI") or os.path.isfile("/AGNOS"):
|
|
return True
|
|
|
|
model_path = "/sys/firmware/devicetree/base/model"
|
|
try:
|
|
with open(model_path) as f:
|
|
model = f.read().strip("\x00").lower()
|
|
return "comma " in model
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _raylib_ui_toggle_affects_device() -> bool:
|
|
try:
|
|
return HARDWARE.get_device_type() in ("tici", "tizi")
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _get_param_key_type(params_obj, key):
|
|
getter = getattr(params_obj, "get_key_type", None)
|
|
if getter is None:
|
|
getter = getattr(params_obj, "get_type", None)
|
|
if getter is None:
|
|
return ParamKeyType.STRING
|
|
return getter(key)
|
|
|
|
|
|
def _import_galaxy_web_symbols():
|
|
global Flask, Response, jsonify, make_response, render_template, request, send_file, send_from_directory, _GALAXY_WEB_DEPS_ERROR
|
|
|
|
try:
|
|
from flask import Flask as _Flask
|
|
from flask import Response as _Response
|
|
from flask import jsonify as _jsonify
|
|
from flask import make_response as _make_response
|
|
from flask import render_template as _render_template
|
|
from flask import request as _request
|
|
from flask import send_file as _send_file
|
|
from flask import send_from_directory as _send_from_directory
|
|
except ModuleNotFoundError as error:
|
|
_GALAXY_WEB_DEPS_ERROR = error
|
|
return False
|
|
|
|
Flask = _Flask
|
|
Response = _Response
|
|
jsonify = _jsonify
|
|
make_response = _make_response
|
|
render_template = _render_template
|
|
request = _request
|
|
send_file = _send_file
|
|
send_from_directory = _send_from_directory
|
|
_GALAXY_WEB_DEPS_ERROR = None
|
|
return True
|
|
|
|
|
|
def _install_galaxy_web_deps():
|
|
global _GALAXY_WEB_DEPS_ERROR
|
|
|
|
if not _is_comma_device_runtime():
|
|
return False
|
|
|
|
# Local-only dependency policy: prefer bundled repo deps, then existing local deps.
|
|
# Do not hit pip/network at runtime.
|
|
if REPO_THIRD_PARTY_PATH.is_dir() and str(REPO_THIRD_PARTY_PATH) not in sys.path:
|
|
sys.path.insert(0, str(REPO_THIRD_PARTY_PATH))
|
|
for deps_path in GALAXY_DEPS_PATHS:
|
|
if os.path.isdir(deps_path) and deps_path not in sys.path:
|
|
sys.path.insert(0, deps_path)
|
|
|
|
importlib.invalidate_caches()
|
|
if _import_galaxy_web_symbols():
|
|
return True
|
|
|
|
_GALAXY_WEB_DEPS_ERROR = RuntimeError(
|
|
"Missing local Flask deps (expected in starpilot/third_party or local Galaxy deps)."
|
|
)
|
|
return False
|
|
|
|
|
|
def _ensure_galaxy_web_deps():
|
|
global _GALAXY_WEB_DEPS_READY
|
|
|
|
if _GALAXY_WEB_DEPS_READY:
|
|
return True
|
|
if _import_galaxy_web_symbols():
|
|
_GALAXY_WEB_DEPS_READY = True
|
|
return True
|
|
if _install_galaxy_web_deps() and _import_galaxy_web_symbols():
|
|
_GALAXY_WEB_DEPS_READY = True
|
|
return True
|
|
return False
|
|
|
|
|
|
def extract_tar(archive_path, destination):
|
|
destination_path = Path(destination).resolve()
|
|
|
|
with tarfile.open(archive_path, "r:gz") as tar:
|
|
for member in tar.getmembers():
|
|
member_path = (destination_path / member.name).resolve()
|
|
if destination_path not in member_path.parents and member_path != destination_path:
|
|
raise RuntimeError(f"Unsafe tar member path: {member.name}")
|
|
tar.extractall(destination_path)
|
|
|
|
class ParamsCompat:
|
|
MODEL_KEY_ALIASES = {
|
|
"Model": "DrivingModel",
|
|
"ModelVersion": "DrivingModelVersion",
|
|
"SecOCKey": "SecOCKeys",
|
|
}
|
|
MIRRORED_PARAM_GROUPS = {
|
|
"Model": ("Model", "DrivingModel"),
|
|
"DrivingModel": ("Model", "DrivingModel"),
|
|
"ModelVersion": ("ModelVersion", "DrivingModelVersion"),
|
|
"DrivingModelVersion": ("ModelVersion", "DrivingModelVersion"),
|
|
}
|
|
|
|
def __init__(self, params_obj):
|
|
self._params = params_obj
|
|
|
|
def _key(self, key):
|
|
return self.MODEL_KEY_ALIASES.get(key, key)
|
|
|
|
@staticmethod
|
|
def _to_text(value):
|
|
if value is None:
|
|
return ""
|
|
if isinstance(value, bytes):
|
|
return value.decode("utf-8", errors="replace")
|
|
return str(value)
|
|
|
|
def _default_text(self, key):
|
|
try:
|
|
return self._to_text(self._params.get_default_value(key)).strip()
|
|
except Exception:
|
|
return ""
|
|
|
|
def _get_raw(self, key, block=False):
|
|
try:
|
|
return self._params.get(key, block=block)
|
|
except TypeError:
|
|
try:
|
|
return self._params.get(key)
|
|
except Exception:
|
|
return None
|
|
except Exception:
|
|
return None
|
|
|
|
def _resolve_mirrored_text(self, primary_key, secondary_key, block=False):
|
|
primary_raw = self._get_raw(primary_key, block=block)
|
|
secondary_raw = self._get_raw(secondary_key, block=block)
|
|
|
|
if primary_raw is None and secondary_raw is None:
|
|
return None
|
|
|
|
primary_val = self._to_text(primary_raw).strip()
|
|
secondary_val = self._to_text(secondary_raw).strip()
|
|
|
|
if primary_val == secondary_val:
|
|
return secondary_val or primary_val
|
|
|
|
primary_default = self._default_text(primary_key)
|
|
secondary_default = self._default_text(secondary_key)
|
|
primary_non_default = bool(primary_val) and primary_val != primary_default
|
|
secondary_non_default = bool(secondary_val) and secondary_val != secondary_default
|
|
|
|
if secondary_non_default and not primary_non_default:
|
|
return secondary_val
|
|
if primary_non_default and not secondary_non_default:
|
|
return primary_val
|
|
|
|
# Prefer DrivingModel* values on conflicts since runtime reads those keys.
|
|
return secondary_val or primary_val
|
|
|
|
def _put_single(self, key, value):
|
|
expected_type = _get_param_key_type(self._params, key)
|
|
|
|
typed_value = value
|
|
if expected_type == ParamKeyType.BOOL:
|
|
if isinstance(value, bool):
|
|
typed_value = value
|
|
elif isinstance(value, (int, float)):
|
|
typed_value = value != 0
|
|
else:
|
|
typed_value = str(value).strip().lower() in ("1", "true", "yes", "on")
|
|
elif expected_type == ParamKeyType.INT:
|
|
typed_value = int(float(value)) if value not in (None, "", b"") else 0
|
|
elif expected_type == ParamKeyType.FLOAT:
|
|
typed_value = float(value) if value not in (None, "", b"") else 0.0
|
|
elif expected_type == ParamKeyType.STRING:
|
|
if isinstance(value, bytes):
|
|
typed_value = value.decode("utf-8", errors="replace")
|
|
elif value is None:
|
|
typed_value = ""
|
|
else:
|
|
typed_value = str(value)
|
|
elif expected_type == ParamKeyType.JSON:
|
|
if isinstance(value, bytes):
|
|
value = value.decode("utf-8", errors="replace")
|
|
|
|
if isinstance(value, str):
|
|
stripped = value.strip()
|
|
if not stripped:
|
|
typed_value = self._params.get_default_value(key)
|
|
else:
|
|
typed_value = json.loads(stripped)
|
|
elif isinstance(value, tuple):
|
|
typed_value = list(value)
|
|
else:
|
|
typed_value = value
|
|
|
|
self._params.put(key, typed_value)
|
|
|
|
@staticmethod
|
|
def _coerce_legacy(value, encoding=None):
|
|
# Preserve legacy Params.get behavior:
|
|
# - no encoding => bytes-like payload
|
|
# - encoding set => decoded string payload
|
|
if isinstance(value, bytes):
|
|
if encoding is None:
|
|
return value
|
|
return value.decode(encoding, errors="replace")
|
|
|
|
if isinstance(value, (dict, list)):
|
|
serialized = json.dumps(value, separators=(",", ":"))
|
|
if encoding is None:
|
|
return serialized.encode("utf-8")
|
|
return serialized
|
|
|
|
if isinstance(value, str):
|
|
if encoding is None:
|
|
return value.encode("utf-8")
|
|
return value
|
|
|
|
if isinstance(value, (bool, int, float)):
|
|
text = str(value)
|
|
if encoding is None:
|
|
return text.encode("utf-8")
|
|
return text
|
|
|
|
return value
|
|
|
|
def get(self, key, encoding=None, default=None, block=False):
|
|
mirrored_keys = self.MIRRORED_PARAM_GROUPS.get(key)
|
|
if mirrored_keys:
|
|
value = self._resolve_mirrored_text(mirrored_keys[0], mirrored_keys[1], block=block)
|
|
if value is None:
|
|
return default
|
|
return self._coerce_legacy(value, encoding)
|
|
|
|
resolved_key = self._key(key)
|
|
value = self._get_raw(resolved_key, block=block)
|
|
|
|
if value is None:
|
|
return default
|
|
return self._coerce_legacy(value, encoding)
|
|
|
|
def get_bool(self, key):
|
|
return self._params.get_bool(self._key(key))
|
|
|
|
def put(self, key, value):
|
|
mirrored_keys = self.MIRRORED_PARAM_GROUPS.get(key)
|
|
if mirrored_keys:
|
|
for mirrored_key in dict.fromkeys(mirrored_keys):
|
|
self._put_single(mirrored_key, value)
|
|
return
|
|
|
|
self._put_single(self._key(key), value)
|
|
|
|
def put_bool(self, key, value):
|
|
if key == "LeadIndicator":
|
|
enabled = bool(value)
|
|
self._params.put_bool("LeadIndicator", enabled)
|
|
self._params.put_bool("HideLeadMarker", not enabled)
|
|
return
|
|
|
|
if key == "HideLeadMarker":
|
|
hidden = bool(value)
|
|
self._params.put_bool("HideLeadMarker", hidden)
|
|
self._params.put_bool("LeadIndicator", not hidden)
|
|
return
|
|
|
|
self._params.put_bool(self._key(key), bool(value))
|
|
|
|
def remove(self, key):
|
|
mirrored_keys = self.MIRRORED_PARAM_GROUPS.get(key)
|
|
if mirrored_keys:
|
|
for mirrored_key in dict.fromkeys(mirrored_keys):
|
|
self._params.remove(mirrored_key)
|
|
return
|
|
|
|
self._params.remove(self._key(key))
|
|
|
|
def __getattr__(self, attr):
|
|
return getattr(self._params, attr)
|
|
|
|
_params_raw = Params(return_defaults=True)
|
|
_params_live_raw = Params()
|
|
_params_memory_raw = Params(memory=True)
|
|
|
|
def _normalize_default_value(value):
|
|
if isinstance(value, bytes):
|
|
try:
|
|
return value.decode("utf-8")
|
|
except Exception:
|
|
return value
|
|
return value
|
|
|
|
def _sanitize_json_value(value):
|
|
if value is None or isinstance(value, bool):
|
|
return value
|
|
|
|
if isinstance(value, dict):
|
|
return {key: _sanitize_json_value(inner_value) for key, inner_value in value.items()}
|
|
|
|
if isinstance(value, (list, tuple)):
|
|
return [_sanitize_json_value(item) for item in value]
|
|
|
|
if isinstance(value, datetime):
|
|
return value.isoformat()
|
|
|
|
if isinstance(value, bytes):
|
|
try:
|
|
return value.decode("utf-8")
|
|
except Exception:
|
|
return value.decode("utf-8", errors="replace")
|
|
|
|
if isinstance(value, numbers.Integral):
|
|
return int(value)
|
|
|
|
# Flask emits invalid JSON for NaN/inf, so normalize them before jsonify.
|
|
if isinstance(value, numbers.Real):
|
|
numeric_value = float(value)
|
|
return numeric_value if math.isfinite(numeric_value) else None
|
|
|
|
return value
|
|
|
|
def _build_default_params():
|
|
defaults = []
|
|
for raw_key in _params_raw.all_keys():
|
|
key = raw_key.decode() if isinstance(raw_key, bytes) else str(raw_key)
|
|
defaults.append((
|
|
key,
|
|
_normalize_default_value(_params_raw.get_default_value(raw_key)),
|
|
_get_param_key_type(_params_raw, raw_key),
|
|
_params_raw.get_tuning_level(raw_key),
|
|
))
|
|
return defaults
|
|
|
|
starpilot_default_params = _build_default_params()
|
|
|
|
TOGGLE_BACKUP_FORMAT = "starpilot-toggle-backup"
|
|
TOGGLE_BACKUP_VERSION = 1
|
|
TOGGLE_BACKUP_MAX_ENCODED_BYTES = 2_000_000
|
|
TOGGLE_BACKUP_NO_DEFAULT_KEYS = {
|
|
"AdbEnabled",
|
|
"AlphaLongitudinalEnabled",
|
|
"AlwaysOnDM",
|
|
"ExperimentalMode",
|
|
"ExperimentalModeConfirmed",
|
|
"IsLdwEnabled",
|
|
"IsMetric",
|
|
"IsRHD",
|
|
"IsRHDOverride",
|
|
"RecordAudio",
|
|
"RecordFront",
|
|
"SshEnabled",
|
|
}
|
|
|
|
|
|
def _get_toggle_backup_keys():
|
|
keys = set()
|
|
for key, default_value, _, _ in starpilot_default_params:
|
|
if key in EXCLUDED_KEYS:
|
|
continue
|
|
if default_value is None and key not in TOGGLE_BACKUP_NO_DEFAULT_KEYS:
|
|
continue
|
|
|
|
try:
|
|
flags = _params_raw.get_key_flag(key)
|
|
except Exception:
|
|
continue
|
|
|
|
if not flags & ParamKeyFlag.PERSISTENT or flags & ParamKeyFlag.DONT_LOG:
|
|
continue
|
|
|
|
keys.add(key)
|
|
|
|
return keys
|
|
|
|
|
|
def _coerce_toggle_restore_value(key, value):
|
|
value_type = _get_param_key_type(_params_raw, key)
|
|
|
|
if value_type == ParamKeyType.BOOL:
|
|
if isinstance(value, bool):
|
|
return value
|
|
if isinstance(value, numbers.Real) and value in (0, 1):
|
|
return bool(value)
|
|
if isinstance(value, str):
|
|
normalized = value.strip().lower()
|
|
if normalized in {"1", "true", "yes", "on"}:
|
|
return True
|
|
if normalized in {"0", "false", "no", "off", ""}:
|
|
return False
|
|
raise ValueError(f"Invalid boolean value for {key}")
|
|
|
|
if value_type == ParamKeyType.INT:
|
|
if isinstance(value, bool):
|
|
raise ValueError(f"Invalid integer value for {key}")
|
|
return int(float(value))
|
|
|
|
if value_type == ParamKeyType.FLOAT:
|
|
if isinstance(value, bool):
|
|
raise ValueError(f"Invalid numeric value for {key}")
|
|
result = float(value)
|
|
if not math.isfinite(result):
|
|
raise ValueError(f"Invalid numeric value for {key}")
|
|
return result
|
|
|
|
if value_type == ParamKeyType.JSON:
|
|
if isinstance(value, str):
|
|
value = json.loads(value)
|
|
if not isinstance(value, (dict, list)):
|
|
raise ValueError(f"Invalid JSON value for {key}")
|
|
return value
|
|
|
|
if value_type == ParamKeyType.BYTES:
|
|
if isinstance(value, bytes):
|
|
return value
|
|
if isinstance(value, str):
|
|
return value.encode("utf-8")
|
|
raise ValueError(f"Invalid byte value for {key}")
|
|
|
|
if value_type == ParamKeyType.TIME:
|
|
if isinstance(value, datetime):
|
|
return value
|
|
if isinstance(value, str):
|
|
return datetime.fromisoformat(value)
|
|
raise ValueError(f"Invalid time value for {key}")
|
|
|
|
if value is None or isinstance(value, (dict, list)):
|
|
raise ValueError(f"Invalid string value for {key}")
|
|
return str(value)
|
|
|
|
params = ParamsCompat(_params_raw)
|
|
params_memory = ParamsCompat(_params_memory_raw)
|
|
STATS_RESPONSE_CACHE_SECONDS = 2.0
|
|
_STATS_RESPONSE_CACHE = {
|
|
"updated_at": 0.0,
|
|
"payload": None,
|
|
}
|
|
|
|
try:
|
|
FOOTAGE_PATHS = [
|
|
Paths.log_root(HD=True, raw=True),
|
|
Paths.log_root(konik=True, raw=True),
|
|
Paths.log_root(raw=True),
|
|
]
|
|
except TypeError:
|
|
FOOTAGE_PATHS = [
|
|
"/data/media/0/realdata_HD/",
|
|
"/data/media/0/realdata_konik/",
|
|
str(Paths.log_root()),
|
|
]
|
|
|
|
KEYS = {
|
|
"amap1": ("amap1", "", "AMapKey1", "AMap / Gaode key #1", 39),
|
|
"amap2": ("amap2", "", "AMapKey2", "AMap / Gaode key #2", 39),
|
|
"public": ("public", "pk.", "MapboxPublicKey", "Public key", 80),
|
|
"secret": ("secret", "sk.", "MapboxSecretKey", "Secret key", 80),
|
|
}
|
|
|
|
GALAXY_COOKIE_NAME = "galaxy_session"
|
|
GALAXY_PLAY_STORE_URL = "https://play.google.com/store/apps/details?id=com.embaucha.galaxynav&hl=en-US&ah=9FldHJ99kxL8oNbSlO5F4sQqwC4"
|
|
|
|
NAVIGATION_MEMORY_LOCATION_STALE_SECONDS = 10.0
|
|
NAVIGATION_PERSISTED_LOCATION_FUTURE_SKEW_SECONDS = 60.0
|
|
NAVIGATION_PERSISTED_LOCATION_MAX_AGE_SECONDS = 24 * 60 * 60
|
|
|
|
TMUX_LOGS_PATH = Path("/data/tmux_logs")
|
|
|
|
MODEL_DOWNLOAD_PARAM = "ModelToDownload"
|
|
MODEL_DOWNLOAD_ALL_PARAM = "DownloadAllModels"
|
|
MODEL_DOWNLOAD_PROGRESS_PARAM = "ModelDownloadProgress"
|
|
MODEL_CANCEL_DOWNLOAD_PARAM = "CancelModelDownload"
|
|
MODEL_SORT_MODE_PARAM = "ModelSortMode"
|
|
MODEL_USER_FAVORITES_PARAM = "UserFavorites"
|
|
MAPS_DOWNLOAD_PARAM = "DownloadMaps"
|
|
MAPS_CANCEL_DOWNLOAD_PARAM = "CancelDownloadMaps"
|
|
MAPS_DOWNLOAD_PROGRESS_PARAM = "MapsDownloadProgress"
|
|
MAPS_DOWNLOAD_SIZE_CACHE_PARAM = "MapsDownloadSizeCache"
|
|
|
|
|
|
def _get_galaxy_dir():
|
|
if override := os.getenv("SP_GALAXY_DIR"):
|
|
return Path(override)
|
|
return Path(Paths.comma_home()) / "starpilot" / "data" / "galaxy" if PC else Path("/data/galaxy")
|
|
|
|
|
|
def _read_galaxy_text(path):
|
|
try:
|
|
return path.read_text().strip() if path.is_file() else ""
|
|
except Exception:
|
|
return ""
|
|
|
|
|
|
def _build_galaxy_session_value(slug, token):
|
|
if not slug or not token:
|
|
return ""
|
|
return quote(f"{slug}:{token}", safe="")
|
|
|
|
|
|
def _parse_last_gps_position(raw_value):
|
|
if not raw_value:
|
|
return None
|
|
|
|
try:
|
|
payload = json.loads(raw_value)
|
|
except (TypeError, ValueError, json.JSONDecodeError):
|
|
return None
|
|
|
|
if not isinstance(payload, dict):
|
|
return None
|
|
|
|
latitude = payload.get("latitude")
|
|
longitude = payload.get("longitude")
|
|
has_fix = payload.get("hasFix", False)
|
|
try:
|
|
latitude = float(latitude or 0.0)
|
|
longitude = float(longitude or 0.0)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
if not has_fix or (abs(latitude) < 1e-6 and abs(longitude) < 1e-6):
|
|
return None
|
|
|
|
payload["latitude"] = latitude
|
|
payload["longitude"] = longitude
|
|
return payload
|
|
|
|
|
|
def _last_gps_position_is_live(payload):
|
|
if not isinstance(payload, dict):
|
|
return False
|
|
|
|
try:
|
|
updated_at_monotonic = float(payload.get("updatedAtMonotonic", 0.0) or 0.0)
|
|
except (TypeError, ValueError):
|
|
updated_at_monotonic = 0.0
|
|
|
|
if updated_at_monotonic <= 0.0:
|
|
return False
|
|
|
|
return (time.monotonic() - updated_at_monotonic) <= NAVIGATION_MEMORY_LOCATION_STALE_SECONDS
|
|
|
|
|
|
def _last_gps_position_is_recent(payload):
|
|
if not isinstance(payload, dict):
|
|
return False
|
|
|
|
try:
|
|
updated_at_sec = float(payload.get("updatedAtSec", 0.0) or 0.0)
|
|
except (TypeError, ValueError):
|
|
updated_at_sec = 0.0
|
|
|
|
if updated_at_sec <= 0.0:
|
|
return False
|
|
|
|
now_sec = time.time()
|
|
if updated_at_sec > (now_sec + NAVIGATION_PERSISTED_LOCATION_FUTURE_SKEW_SECONDS):
|
|
return False
|
|
|
|
if not system_time_valid():
|
|
return True
|
|
|
|
age_sec = now_sec - updated_at_sec
|
|
return 0.0 <= age_sec <= NAVIGATION_PERSISTED_LOCATION_MAX_AGE_SECONDS
|
|
|
|
|
|
def _get_navigation_last_position():
|
|
memory_position = _parse_last_gps_position(params_memory.get("LastGPSPosition", encoding="utf8") or "")
|
|
if _last_gps_position_is_live(memory_position):
|
|
return memory_position
|
|
|
|
persisted_position = _parse_last_gps_position(params.get("LastGPSPosition", encoding="utf8") or "")
|
|
if _last_gps_position_is_recent(persisted_position):
|
|
return persisted_position
|
|
|
|
return None
|
|
|
|
FINGERPRINT_MAKE_LABELS = [
|
|
"Acura",
|
|
"Audi",
|
|
"Buick",
|
|
"Cadillac",
|
|
"Chevrolet",
|
|
"Chrysler",
|
|
"CUPRA",
|
|
"Dodge",
|
|
"Ford",
|
|
"Genesis",
|
|
"GMC",
|
|
"Holden",
|
|
"Honda",
|
|
"Hyundai",
|
|
"Jeep",
|
|
"Kia",
|
|
"Lexus",
|
|
"Lincoln",
|
|
"MAN",
|
|
"Mazda",
|
|
"Nissan",
|
|
"Peugeot",
|
|
"Ram",
|
|
"Rivian",
|
|
"SEAT",
|
|
"\u0160koda",
|
|
"Subaru",
|
|
"Tesla",
|
|
"Toyota",
|
|
"Volkswagen",
|
|
]
|
|
|
|
FINGERPRINT_MAKE_TO_VALUES_DIR = {
|
|
"acura": "honda",
|
|
"audi": "volkswagen",
|
|
"buick": "gm",
|
|
"cadillac": "gm",
|
|
"chevrolet": "gm",
|
|
"chrysler": "chrysler",
|
|
"cupra": "volkswagen",
|
|
"dodge": "chrysler",
|
|
"ford": "ford",
|
|
"genesis": "hyundai",
|
|
"gmc": "gm",
|
|
"holden": "gm",
|
|
"honda": "honda",
|
|
"hyundai": "hyundai",
|
|
"jeep": "chrysler",
|
|
"kia": "hyundai",
|
|
"lexus": "toyota",
|
|
"lincoln": "ford",
|
|
"man": "volkswagen",
|
|
"mazda": "mazda",
|
|
"nissan": "nissan",
|
|
"peugeot": "psa",
|
|
"ram": "chrysler",
|
|
"rivian": "rivian",
|
|
"seat": "volkswagen",
|
|
"\u0161koda": "volkswagen",
|
|
"subaru": "subaru",
|
|
"tesla": "tesla",
|
|
"toyota": "toyota",
|
|
"volkswagen": "volkswagen",
|
|
}
|
|
|
|
_FINGERPRINT_CARDOCS_RE = re.compile(r'\w*CarDocs\w*\(\s*"([^"]+)"')
|
|
_FINGERPRINT_PLATFORM_RE = re.compile(r'(\w+)\s*=\s*\w+\s*\(\s*\[([\s\S]*?)\]\s*,')
|
|
_FINGERPRINT_PLATFORM_NAME_RE = re.compile(r'^[A-Z0-9_]+$')
|
|
_FINGERPRINT_VALID_NAME_RE = re.compile(r'^[A-Za-z0-9 \u0160.(),&\-]+$')
|
|
|
|
_openpilot_root_cache = None
|
|
_fingerprint_catalog_cache = None
|
|
|
|
_fast_update_lock = threading.Lock()
|
|
_FAST_UPDATE_TOTAL_STEPS = 5
|
|
_FAST_UPDATE_PROGRESS_UPDATE_INTERVAL_S = 5.0
|
|
_FAST_UPDATE_REBOOT_NOTICE_SECONDS = 6.0
|
|
_FAST_UPDATE_FETCH_TIMEOUT_S = 60
|
|
_FAST_BRANCH_SWITCH_FETCH_TIMEOUT_S = 60
|
|
_FAST_ROLLBACK_FETCH_TIMEOUT_S = 60
|
|
_AGNOS_MANIFEST_PATH = "system/hardware/tici/agnos.json"
|
|
_AGNOS_REMOTE_MANIFEST_TIMEOUT_S = 8
|
|
_AGNOS_UPDATE_ESTIMATED_DOWNLOAD_MB = 900
|
|
_GIT_PROGRESS_PERCENT_RE = re.compile(r'([A-Za-z][A-Za-z /_-]+):\s*([0-9]{1,3})%')
|
|
_GIT_SUBMODULE_SECTION_RE = re.compile(r'^\s*\[submodule\s+"[^"]+"\]\s*$', re.MULTILINE)
|
|
_ROLLBACK_REF = "refs/starpilot/rollback"
|
|
_ROLLBACK_BRANCH_CONFIG_KEY = "starpilot.rollbackbranch"
|
|
_ROLLBACK_RECORDED_AT_CONFIG_KEY = "starpilot.rollbackrecordedat"
|
|
_TESTING_GROUNDS_SCHEMA_VERSION = SHARED_TESTING_GROUNDS_SCHEMA_VERSION
|
|
_TESTING_GROUNDS_SLOT_COUNT = len(SHARED_TESTING_GROUNDS_SLOT_DEFINITIONS)
|
|
_TESTING_GROUNDS_DEFAULT_VARIANT = SHARED_DEFAULT_TESTING_GROUND_VARIANT
|
|
_TESTING_GROUNDS_VARIANTS = set(SHARED_TESTING_GROUND_VARIANTS) or {_TESTING_GROUNDS_DEFAULT_VARIANT}
|
|
_TESTING_GROUNDS_LOCK = threading.Lock()
|
|
_TESTING_GROUNDS_STATE_PATH = SHARED_TESTING_GROUNDS_STATE_PATH
|
|
# Slot labels live in starpilot/common/testing_grounds.py.
|
|
_TESTING_GROUNDS_SLOT_DEFINITIONS = [dict(slot) for slot in SHARED_TESTING_GROUNDS_SLOT_DEFINITIONS]
|
|
_TESTING_GROUNDS_VARIANT_LABELS_BY_SLOT = {
|
|
str(slot_id or "").strip(): dict(labels or {})
|
|
for slot_id, labels in SHARED_TESTING_GROUND_VARIANT_LABELS.items()
|
|
}
|
|
_fast_update_state = {
|
|
"running": False,
|
|
"stage": "idle",
|
|
"message": "",
|
|
"lastError": "",
|
|
"lastBranch": "",
|
|
"lastMode": "",
|
|
"startedAt": 0.0,
|
|
"finishedAt": 0.0,
|
|
"progressStep": 0,
|
|
"progressTotalSteps": _FAST_UPDATE_TOTAL_STEPS,
|
|
"progressStepPercent": 0.0,
|
|
"progressPercent": 0.0,
|
|
"progressLabel": "Idle",
|
|
"progressDetail": "",
|
|
}
|
|
|
|
_FACTORY_RESET_WIPE_PATHS = [
|
|
"/data/params",
|
|
"/cache/starpilot/params",
|
|
"/cache/params",
|
|
"/data/media/0/realdata",
|
|
"/data/media/0/realdata_HD",
|
|
"/data/media/0/realdata_konik",
|
|
"/data/models",
|
|
"/data/toggle_backups",
|
|
"/data/backups",
|
|
"/data/themes",
|
|
"/data/media/0/osm/offline",
|
|
"/cache/use_HD",
|
|
"/cache/use_konik",
|
|
]
|
|
|
|
_PLOTS_POLL_INTERVAL_S = 0.75
|
|
_PLOTS_BOOT_STABILIZATION_WINDOW_S = 45.0
|
|
_PLOTS_BOOT_POLL_INTERVAL_S = 1.0
|
|
_PLOTS_CLIENT_IDLE_TIMEOUT_S = 6.0
|
|
_PLOTS_SAMPLE_STALE_AFTER_S = 1.5
|
|
|
|
_plots_lock = threading.Lock()
|
|
_plots_worker_thread = None
|
|
_plots_last_client_request_ts = 0.0
|
|
_plots_state = {
|
|
"timestamp": 0.0,
|
|
"desiredLateralAccel": 0.0,
|
|
"actualLateralAccel": 0.0,
|
|
"desiredLongitudinalAccel": 0.0,
|
|
"actualLongitudinalAccel": 0.0,
|
|
"controlsActive": False,
|
|
"longitudinalControlActive": False,
|
|
"lateralP": 0.0,
|
|
"lateralI": 0.0,
|
|
"lateralD": 0.0,
|
|
"lateralF": 0.0,
|
|
"longitudinalUpAccelCmd": 0.0,
|
|
"longitudinalUiAccelCmd": 0.0,
|
|
"longitudinalUfAccelCmd": 0.0,
|
|
"speed": 0.0,
|
|
"lateralSource": "curvature",
|
|
"longitudinalSource": "controlsState + livePose",
|
|
"lateralTermsSource": "unknown",
|
|
"longitudinalTermsSource": "controlsState",
|
|
"sampleIndex": 0,
|
|
"lastError": "",
|
|
}
|
|
|
|
_TROUBLESHOOT_PERSONALITY_KEYS = [
|
|
"CustomPersonalities",
|
|
"TrafficPersonalityProfile",
|
|
"TrafficFollow",
|
|
"TrafficJerkAcceleration",
|
|
"TrafficJerkDeceleration",
|
|
"TrafficJerkDanger",
|
|
"TrafficJerkSpeedDecrease",
|
|
"TrafficJerkSpeed",
|
|
"AggressivePersonalityProfile",
|
|
"AggressiveFollow",
|
|
"AggressiveFollowHigh",
|
|
"AggressiveJerkAcceleration",
|
|
"AggressiveJerkDeceleration",
|
|
"AggressiveJerkDanger",
|
|
"AggressiveJerkSpeedDecrease",
|
|
"AggressiveJerkSpeed",
|
|
"StandardPersonalityProfile",
|
|
"StandardFollow",
|
|
"StandardFollowHigh",
|
|
"StandardJerkAcceleration",
|
|
"StandardJerkDeceleration",
|
|
"StandardJerkDanger",
|
|
"StandardJerkSpeedDecrease",
|
|
"StandardJerkSpeed",
|
|
"RelaxedPersonalityProfile",
|
|
"RelaxedFollow",
|
|
"RelaxedFollowHigh",
|
|
"RelaxedJerkAcceleration",
|
|
"RelaxedJerkDeceleration",
|
|
"RelaxedJerkDanger",
|
|
"RelaxedJerkSpeedDecrease",
|
|
"RelaxedJerkSpeed",
|
|
]
|
|
|
|
_TROUBLESHOOT_CEM_KEYS = [
|
|
"ConditionalExperimental",
|
|
"CESpeed",
|
|
"CESpeedLead",
|
|
"CECurves",
|
|
"CELead",
|
|
"CESlowerLead",
|
|
"CEStoppedLead",
|
|
"CEModelStopTime",
|
|
"CESignalSpeed",
|
|
"ShowCEMStatus",
|
|
]
|
|
|
|
_TROUBLESHOOT_ADVANCED_LATERAL_KEYS = [
|
|
"AdvancedLateralTune",
|
|
"UseAutoSteerDelay",
|
|
"SteerDelay",
|
|
"SteerFriction",
|
|
"SteerOffset",
|
|
"SteerKP",
|
|
"SteerLatAccel",
|
|
"SteerRatio",
|
|
"ForceAutoTune",
|
|
"ForceAutoTuneOff",
|
|
"ForceTorqueController",
|
|
"CameraOffset",
|
|
"LaneCentering",
|
|
"LaneCenteringPauseOnSignal",
|
|
"LaneCenteringE2EAuthority",
|
|
"LaneCenterOffset",
|
|
]
|
|
|
|
_TROUBLESHOOT_ADVANCED_LONGITUDINAL_KEYS = [
|
|
"AdvancedLongitudinalTune",
|
|
"EVTuning",
|
|
"TruckTuning",
|
|
"TrailerLoad",
|
|
"CustomAccelProfile",
|
|
*CUSTOM_ACCEL_PROFILE_PARAM_KEYS,
|
|
"LongitudinalActuatorDelay",
|
|
"StartAccel",
|
|
"VEgoStarting",
|
|
"StopAccel",
|
|
"StoppingDecelRate",
|
|
"VEgoStopping",
|
|
]
|
|
|
|
_RUNTIME_DEFAULT_STOCK_KEYS = {
|
|
"SteerDelay": "SteerDelayStock",
|
|
"SteerFriction": "SteerFrictionStock",
|
|
"SteerOffset": "SteerOffsetStock",
|
|
"SteerKP": "SteerKPStock",
|
|
"SteerLatAccel": "SteerLatAccelStock",
|
|
"SteerRatio": "SteerRatioStock",
|
|
"LongitudinalActuatorDelay": "LongitudinalActuatorDelayStock",
|
|
"StartAccel": "StartAccelStock",
|
|
"StopAccel": "StopAccelStock",
|
|
"StoppingDecelRate": "StoppingDecelRateStock",
|
|
"VEgoStarting": "VEgoStartingStock",
|
|
"VEgoStopping": "VEgoStoppingStock",
|
|
}
|
|
|
|
_RUNTIME_DEFAULT_ZERO_OK_KEYS = {
|
|
"SteerOffset",
|
|
}
|
|
|
|
_TROUBLESHOOT_SECTION_DEFINITIONS = [
|
|
{
|
|
"id": "personality_settings",
|
|
"title": "Personality Profile Settings",
|
|
"keys": _TROUBLESHOOT_PERSONALITY_KEYS,
|
|
},
|
|
{
|
|
"id": "cem_settings",
|
|
"title": "CEM Settings",
|
|
"keys": _TROUBLESHOOT_CEM_KEYS,
|
|
},
|
|
{
|
|
"id": "advanced_lateral_tuning",
|
|
"title": "Advanced Lateral Tuning",
|
|
"keys": _TROUBLESHOOT_ADVANCED_LATERAL_KEYS,
|
|
},
|
|
{
|
|
"id": "advanced_longitudinal_tuning",
|
|
"title": "Advanced Longitudinal Tuning",
|
|
"keys": _TROUBLESHOOT_ADVANCED_LONGITUDINAL_KEYS,
|
|
},
|
|
]
|
|
|
|
_TROUBLESHOOT_SECTION_BY_ID = {
|
|
section["id"]: section
|
|
for section in _TROUBLESHOOT_SECTION_DEFINITIONS
|
|
}
|
|
|
|
_TROUBLESHOOT_NON_RESETTABLE_SECTION_KEYS = {
|
|
"CustomPersonalities",
|
|
"TrafficPersonalityProfile",
|
|
"AggressivePersonalityProfile",
|
|
"StandardPersonalityProfile",
|
|
"RelaxedPersonalityProfile",
|
|
}
|
|
|
|
def _normalize_fingerprint_make_key(make_value):
|
|
return str(make_value or "").strip().lower()
|
|
|
|
def _safe_float(value, default=0.0):
|
|
try:
|
|
return float(value)
|
|
except Exception:
|
|
return float(default)
|
|
|
|
def _get_param_int_value(key, default=0):
|
|
try:
|
|
raw_value = params.get(key)
|
|
if isinstance(raw_value, bytes):
|
|
raw_value = raw_value.decode("utf-8", errors="replace")
|
|
return int(float(str(raw_value or default)))
|
|
except Exception:
|
|
return int(default)
|
|
|
|
def _get_system_uptime_seconds():
|
|
try:
|
|
with open("/proc/uptime", "r", encoding="utf-8") as uptime_file:
|
|
return _safe_float((uptime_file.read().split() or ["0"])[0], 0.0)
|
|
except Exception:
|
|
return 0.0
|
|
|
|
def _is_plots_boot_stabilizing():
|
|
if not params.get_bool("IsOnroad"):
|
|
return False
|
|
return _get_system_uptime_seconds() < _PLOTS_BOOT_STABILIZATION_WINDOW_S
|
|
|
|
def _extract_plots_speed_mps(controls_state, live_pose):
|
|
try:
|
|
velocity_device = getattr(live_pose, "velocityDevice", None)
|
|
if velocity_device and getattr(velocity_device, "valid", False):
|
|
# Use forward device-frame velocity for plot gating without adding any new subscriptions.
|
|
return abs(_safe_float(getattr(velocity_device, "x", 0.0), 0.0))
|
|
except Exception:
|
|
pass
|
|
|
|
return abs(_safe_float(getattr(controls_state, "vPid", 0.0), 0.0))
|
|
|
|
def _extract_lateral_accel_values(controls_state, speed_mps):
|
|
v_ego = max(0.0, _safe_float(speed_mps))
|
|
speed_sq = v_ego * v_ego
|
|
|
|
try:
|
|
lateral_state = controls_state.lateralControlState
|
|
if lateral_state.which() == "torqueState":
|
|
torque_state = lateral_state.torqueState
|
|
desired = _safe_float(getattr(torque_state, "desiredLateralAccel", 0.0))
|
|
actual = _safe_float(getattr(torque_state, "actualLateralAccel", 0.0))
|
|
if abs(desired) > 1e-3 or abs(actual) > 1e-3:
|
|
return desired, actual, "torqueState"
|
|
except Exception:
|
|
pass
|
|
|
|
desired_curvature = _safe_float(getattr(controls_state, "desiredCurvature", 0.0))
|
|
actual_curvature = _safe_float(getattr(controls_state, "curvature", 0.0))
|
|
return desired_curvature * speed_sq, actual_curvature * speed_sq, "curvature"
|
|
|
|
def _extract_longitudinal_accel_values(controls_state, live_pose):
|
|
desired = _safe_float(getattr(controls_state, "aTarget", 0.0))
|
|
source = "controlsState.aTarget + livePose"
|
|
|
|
actual = 0.0
|
|
try:
|
|
acceleration_device = getattr(live_pose, "accelerationDevice", None)
|
|
if acceleration_device and getattr(acceleration_device, "valid", False):
|
|
actual = _safe_float(getattr(acceleration_device, "x", 0.0), 0.0)
|
|
except Exception:
|
|
source = "controlsState.aTarget"
|
|
|
|
# Fallback only if aTarget is unavailable/legacy-zero while PID terms are present.
|
|
if abs(desired) < 1e-6:
|
|
up = _safe_float(getattr(controls_state, "upAccelCmd", 0.0))
|
|
ui = _safe_float(getattr(controls_state, "uiAccelCmd", 0.0))
|
|
uf = _safe_float(getattr(controls_state, "ufAccelCmd", 0.0))
|
|
pid_sum = up + ui + uf
|
|
if abs(pid_sum) > 1e-6:
|
|
desired = pid_sum
|
|
source = "controlsState PID sum + livePose"
|
|
|
|
return desired, actual, source
|
|
|
|
def _extract_lateral_controller_terms(controls_state):
|
|
terms = {
|
|
"lateralP": 0.0,
|
|
"lateralI": 0.0,
|
|
"lateralD": 0.0,
|
|
"lateralF": 0.0,
|
|
}
|
|
source = "unknown"
|
|
|
|
try:
|
|
lateral_state = controls_state.lateralControlState
|
|
which = lateral_state.which()
|
|
if which == "torqueState":
|
|
torque_state = lateral_state.torqueState
|
|
terms["lateralP"] = _safe_float(getattr(torque_state, "p", 0.0))
|
|
terms["lateralI"] = _safe_float(getattr(torque_state, "i", 0.0))
|
|
terms["lateralD"] = _safe_float(getattr(torque_state, "d", 0.0))
|
|
terms["lateralF"] = _safe_float(getattr(torque_state, "f", 0.0))
|
|
source = "torqueState"
|
|
elif which == "pidState":
|
|
pid_state = lateral_state.pidState
|
|
terms["lateralP"] = _safe_float(getattr(pid_state, "p", 0.0))
|
|
terms["lateralI"] = _safe_float(getattr(pid_state, "i", 0.0))
|
|
terms["lateralF"] = _safe_float(getattr(pid_state, "f", 0.0))
|
|
source = "pidState"
|
|
elif which:
|
|
source = which
|
|
except Exception:
|
|
pass
|
|
|
|
return terms, source
|
|
|
|
def _extract_longitudinal_controller_terms(controls_state):
|
|
terms = {
|
|
"longitudinalUpAccelCmd": _safe_float(getattr(controls_state, "upAccelCmd", 0.0)),
|
|
"longitudinalUiAccelCmd": _safe_float(getattr(controls_state, "uiAccelCmd", 0.0)),
|
|
"longitudinalUfAccelCmd": _safe_float(getattr(controls_state, "ufAccelCmd", 0.0)),
|
|
}
|
|
return terms, "controlsState"
|
|
|
|
def _plots_worker():
|
|
global _plots_worker_thread
|
|
|
|
try:
|
|
sm = messaging.SubMaster(["controlsState", "livePose"], poll="controlsState")
|
|
except Exception as exception:
|
|
with _plots_lock:
|
|
_plots_state["lastError"] = str(exception)
|
|
_plots_worker_thread = None
|
|
return
|
|
|
|
while True:
|
|
with _plots_lock:
|
|
idle_for = time.monotonic() - _plots_last_client_request_ts
|
|
|
|
if idle_for >= _PLOTS_CLIENT_IDLE_TIMEOUT_S:
|
|
break
|
|
|
|
try:
|
|
sm.update(0)
|
|
|
|
controls_state = sm["controlsState"]
|
|
live_pose = sm["livePose"]
|
|
speed = _extract_plots_speed_mps(controls_state, live_pose)
|
|
controls_active = bool(getattr(controls_state, "active", False))
|
|
long_control_state = int(_safe_float(getattr(controls_state, "longControlState", 0)))
|
|
longitudinal_control_active = controls_active and long_control_state != 0
|
|
|
|
desired_lateral, actual_lateral, lateral_source = _extract_lateral_accel_values(controls_state, speed)
|
|
desired_longitudinal, actual_longitudinal, longitudinal_source = _extract_longitudinal_accel_values(controls_state, live_pose)
|
|
lateral_terms, lateral_terms_source = _extract_lateral_controller_terms(controls_state)
|
|
longitudinal_terms, longitudinal_terms_source = _extract_longitudinal_controller_terms(controls_state)
|
|
|
|
with _plots_lock:
|
|
_plots_state.update({
|
|
"timestamp": time.time(),
|
|
"desiredLateralAccel": round(desired_lateral, 4),
|
|
"actualLateralAccel": round(actual_lateral, 4),
|
|
"desiredLongitudinalAccel": round(desired_longitudinal, 4),
|
|
"actualLongitudinalAccel": round(actual_longitudinal, 4),
|
|
"controlsActive": controls_active,
|
|
"longitudinalControlActive": longitudinal_control_active,
|
|
"lateralP": round(lateral_terms["lateralP"], 4),
|
|
"lateralI": round(lateral_terms["lateralI"], 4),
|
|
"lateralD": round(lateral_terms["lateralD"], 4),
|
|
"lateralF": round(lateral_terms["lateralF"], 4),
|
|
"longitudinalUpAccelCmd": round(longitudinal_terms["longitudinalUpAccelCmd"], 4),
|
|
"longitudinalUiAccelCmd": round(longitudinal_terms["longitudinalUiAccelCmd"], 4),
|
|
"longitudinalUfAccelCmd": round(longitudinal_terms["longitudinalUfAccelCmd"], 4),
|
|
"speed": round(speed, 4),
|
|
"lateralSource": lateral_source,
|
|
"longitudinalSource": longitudinal_source,
|
|
"lateralTermsSource": lateral_terms_source,
|
|
"longitudinalTermsSource": longitudinal_terms_source,
|
|
"sampleIndex": int(_plots_state.get("sampleIndex", 0)) + 1,
|
|
"lastError": "",
|
|
})
|
|
except Exception as exception:
|
|
with _plots_lock:
|
|
_plots_state["lastError"] = str(exception)
|
|
|
|
sleep_interval = _PLOTS_POLL_INTERVAL_S
|
|
if _is_plots_boot_stabilizing():
|
|
sleep_interval = max(_PLOTS_POLL_INTERVAL_S, _PLOTS_BOOT_POLL_INTERVAL_S)
|
|
time.sleep(sleep_interval)
|
|
|
|
with _plots_lock:
|
|
_plots_worker_thread = None
|
|
|
|
def _ensure_plots_worker():
|
|
global _plots_worker_thread, _plots_last_client_request_ts
|
|
|
|
with _plots_lock:
|
|
_plots_last_client_request_ts = time.monotonic()
|
|
if _plots_worker_thread and _plots_worker_thread.is_alive():
|
|
return
|
|
_plots_worker_thread = threading.Thread(target=_plots_worker, daemon=True)
|
|
_plots_worker_thread.start()
|
|
|
|
def _set_fast_update_state(**kwargs):
|
|
with _fast_update_lock:
|
|
_fast_update_state.update(kwargs)
|
|
|
|
def _get_fast_update_state():
|
|
with _fast_update_lock:
|
|
return dict(_fast_update_state)
|
|
|
|
def _get_interrupted_update_recovery(repo_path, state_data):
|
|
recovery_status = inspect_interrupted_update(
|
|
repo_path,
|
|
is_onroad=_safe_params_get_bool("IsOnroad"),
|
|
update_running=bool(state_data.get("running")),
|
|
updater_state=_safe_params_get("UpdaterState", encoding="utf-8", default=""),
|
|
)
|
|
return public_recovery_status(recovery_status)
|
|
|
|
def _set_fast_update_progress(step, label, step_percent=0.0, detail=""):
|
|
safe_step = max(1, min(_FAST_UPDATE_TOTAL_STEPS, int(step)))
|
|
safe_step_percent = float(max(0.0, min(100.0, step_percent)))
|
|
overall_percent = (((safe_step - 1) + (safe_step_percent / 100.0)) / _FAST_UPDATE_TOTAL_STEPS) * 100.0
|
|
|
|
_set_fast_update_state(
|
|
progressStep=safe_step,
|
|
progressTotalSteps=_FAST_UPDATE_TOTAL_STEPS,
|
|
progressStepPercent=round(safe_step_percent, 1),
|
|
progressPercent=round(overall_percent, 1),
|
|
progressLabel=label,
|
|
progressDetail=detail,
|
|
)
|
|
|
|
def _parse_git_progress_line(raw_line):
|
|
text = str(raw_line or "").replace("\x1b", "").strip()
|
|
while text.startswith("remote:"):
|
|
text = text[len("remote:"):].strip()
|
|
|
|
if not text:
|
|
return None, "", ""
|
|
|
|
match = _GIT_PROGRESS_PERCENT_RE.search(text)
|
|
if not match:
|
|
return None, text, ""
|
|
|
|
try:
|
|
percent = float(match.group(2))
|
|
except Exception:
|
|
percent = None
|
|
|
|
phase = str(match.group(1) or "").strip().lower()
|
|
return percent, text, phase
|
|
|
|
def _normalize_git_phase_percent(phase, percent):
|
|
safe_percent = max(0.0, min(100.0, float(percent)))
|
|
phase_text = str(phase or "").strip().lower()
|
|
|
|
# Git progress lines are per-phase and can hit 100% multiple times before the
|
|
# command actually exits. Map known phases to a monotonic 0..99% envelope.
|
|
if "counting objects" in phase_text:
|
|
return min(20.0, safe_percent * 0.20)
|
|
if "compressing objects" in phase_text:
|
|
return min(45.0, 20.0 + (safe_percent * 0.25))
|
|
if "receiving objects" in phase_text:
|
|
return min(85.0, 45.0 + (safe_percent * 0.40))
|
|
if "resolving deltas" in phase_text:
|
|
return min(99.0, 85.0 + (safe_percent * 0.14))
|
|
|
|
# Unknown phase: keep below 100 until the process exits.
|
|
return min(99.0, safe_percent)
|
|
|
|
_GIT_CA_BUNDLE_CANDIDATES = (
|
|
"/etc/ssl/certs/ca-certificates.crt",
|
|
"/etc/ssl/cert.pem",
|
|
"/usr/local/etc/openssl/cert.pem",
|
|
"/opt/homebrew/etc/openssl@3/cert.pem",
|
|
)
|
|
|
|
def _resolve_git_ca_bundle():
|
|
for env_key in ("GIT_SSL_CAINFO", "SSL_CERT_FILE", "CURL_CA_BUNDLE", "REQUESTS_CA_BUNDLE"):
|
|
candidate = os.environ.get(env_key, "")
|
|
if candidate and os.path.isfile(candidate):
|
|
return candidate
|
|
|
|
for candidate in _GIT_CA_BUNDLE_CANDIDATES:
|
|
if os.path.isfile(candidate):
|
|
return candidate
|
|
|
|
try:
|
|
import certifi # type: ignore
|
|
candidate = certifi.where()
|
|
if candidate and os.path.isfile(candidate):
|
|
return candidate
|
|
except Exception:
|
|
pass
|
|
|
|
return ""
|
|
|
|
def _git_base_cmd():
|
|
cmd = ["git"]
|
|
ca_bundle = _resolve_git_ca_bundle()
|
|
if ca_bundle:
|
|
cmd += ["-c", f"http.sslCAInfo={ca_bundle}", "-c", "http.sslVerify=true"]
|
|
return cmd
|
|
|
|
def _git_command_env():
|
|
env = os.environ.copy()
|
|
env["GIT_TERMINAL_PROMPT"] = "0"
|
|
env["GIT_ASKPASS"] = "/bin/false"
|
|
env["SSH_ASKPASS"] = "/bin/false"
|
|
env["GCM_INTERACTIVE"] = "Never"
|
|
if not env.get("GIT_SSH_COMMAND"):
|
|
env["GIT_SSH_COMMAND"] = "ssh -oBatchMode=yes"
|
|
ca_bundle = _resolve_git_ca_bundle()
|
|
if ca_bundle:
|
|
env["GIT_SSL_CAINFO"] = ca_bundle
|
|
env["SSL_CERT_FILE"] = ca_bundle
|
|
env["CURL_CA_BUNDLE"] = ca_bundle
|
|
return env
|
|
|
|
def _remote_git_check_allowed():
|
|
try:
|
|
return system_time_valid()
|
|
except Exception:
|
|
return True
|
|
|
|
def _is_deferred_tls_error(exception):
|
|
error_text = str(exception or "").lower()
|
|
if not error_text:
|
|
return False
|
|
|
|
tls_markers = (
|
|
"certificate verification failed",
|
|
"ssl certificate problem",
|
|
"x509",
|
|
"cafile",
|
|
"ca cert",
|
|
)
|
|
if any(marker in error_text for marker in tls_markers):
|
|
return not _remote_git_check_allowed()
|
|
|
|
return False
|
|
|
|
def _get_remote_branch_commit(repo_path, branch):
|
|
remote_commit = ""
|
|
remote_error = ""
|
|
branch_name = str(branch or "").strip()
|
|
if not branch_name or not _remote_git_check_allowed():
|
|
return remote_commit, remote_error
|
|
|
|
try:
|
|
remote_raw = _git_stdout(repo_path, ["ls-remote", "--heads", "origin", branch_name], timeout=20)
|
|
if remote_raw:
|
|
remote_commit = remote_raw.split()[0]
|
|
except Exception as exception:
|
|
if not _is_deferred_tls_error(exception):
|
|
remote_error = str(exception)
|
|
|
|
return remote_commit, remote_error
|
|
|
|
def _base_agnos_update_status(target_branch="", local_commit="", remote_commit=""):
|
|
return {
|
|
"available": False,
|
|
"checked": False,
|
|
"targetBranch": str(target_branch or "").strip(),
|
|
"manifestPath": _AGNOS_MANIFEST_PATH,
|
|
"localCommit": str(local_commit or "").strip(),
|
|
"remoteCommit": str(remote_commit or "").strip(),
|
|
"localManifestHash": "",
|
|
"remoteManifestHash": "",
|
|
"changedPartitions": [],
|
|
"estimatedDownloadMb": _AGNOS_UPDATE_ESTIMATED_DOWNLOAD_MB,
|
|
"warnings": [
|
|
"This AGNOS firmware update will take much longer than a normal software update.",
|
|
"You must be able to physically access the device to press the on-device update button.",
|
|
"It downloads about 900 MB of data, so Wi-Fi is recommended.",
|
|
],
|
|
"source": "",
|
|
"error": "",
|
|
}
|
|
|
|
def _canonical_agnos_manifest_text(manifest_text):
|
|
text = str(manifest_text or "").strip()
|
|
if not text:
|
|
return ""
|
|
|
|
try:
|
|
return json.dumps(json.loads(text), sort_keys=True, separators=(",", ":"))
|
|
except Exception:
|
|
return text
|
|
|
|
def _agnos_manifest_hash(manifest_text):
|
|
canonical = _canonical_agnos_manifest_text(manifest_text)
|
|
if not canonical:
|
|
return ""
|
|
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
|
|
|
def _agnos_partition_fingerprints(manifest_text):
|
|
try:
|
|
manifest = json.loads(str(manifest_text or ""))
|
|
except Exception:
|
|
return {}
|
|
|
|
if not isinstance(manifest, list):
|
|
return {}
|
|
|
|
partitions = {}
|
|
for item in manifest:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
|
|
name = str(item.get("name") or "").strip()
|
|
if not name:
|
|
continue
|
|
|
|
partitions[name] = {
|
|
"hash": str(item.get("hash") or ""),
|
|
"hashRaw": str(item.get("hash_raw") or ""),
|
|
"url": str(item.get("url") or ""),
|
|
"size": str(item.get("size") or ""),
|
|
}
|
|
|
|
return partitions
|
|
|
|
def _agnos_changed_partitions(local_manifest_text, remote_manifest_text):
|
|
local_partitions = _agnos_partition_fingerprints(local_manifest_text)
|
|
remote_partitions = _agnos_partition_fingerprints(remote_manifest_text)
|
|
partition_names = sorted(set(local_partitions) | set(remote_partitions))
|
|
return [
|
|
name for name in partition_names
|
|
if local_partitions.get(name) != remote_partitions.get(name)
|
|
]
|
|
|
|
def _git_show_file_text(repo_path, ref, file_path):
|
|
safe_ref = str(ref or "").strip()
|
|
if not safe_ref:
|
|
raise RuntimeError("Missing git ref")
|
|
return _git_stdout(repo_path, ["show", f"{safe_ref}:{file_path}"], timeout=10)
|
|
|
|
def _github_raw_file_url(origin_remote, ref, file_path):
|
|
remote = utilities.normalize_github_remote(origin_remote)
|
|
if not remote:
|
|
return ""
|
|
|
|
slug = remote.split("https://github.com/", 1)[1]
|
|
parts = slug.split("/", 1)
|
|
if len(parts) != 2 or not parts[0] or not parts[1]:
|
|
return ""
|
|
|
|
owner = quote(parts[0], safe="")
|
|
repo = quote(parts[1], safe="")
|
|
quoted_ref = quote(str(ref or "").strip(), safe="")
|
|
quoted_path = "/".join(quote(part, safe="") for part in str(file_path or "").split("/") if part)
|
|
if not quoted_ref or not quoted_path:
|
|
return ""
|
|
|
|
return f"https://raw.githubusercontent.com/{owner}/{repo}/{quoted_ref}/{quoted_path}"
|
|
|
|
def _fetch_remote_file_text(origin_remote, ref, file_path):
|
|
raw_url = _github_raw_file_url(origin_remote, ref, file_path)
|
|
if not raw_url:
|
|
raise RuntimeError("AGNOS manifest comparison is only available for GitHub remotes when the remote commit is not available locally.")
|
|
|
|
response = requests.get(raw_url, timeout=_AGNOS_REMOTE_MANIFEST_TIMEOUT_S)
|
|
response.raise_for_status()
|
|
return response.text, raw_url
|
|
|
|
def _build_agnos_update_status(repo_path, origin_remote, local_commit, remote_commit, target_branch=""):
|
|
status = _base_agnos_update_status(target_branch, local_commit, remote_commit)
|
|
safe_local_commit = str(local_commit or "").strip()
|
|
safe_remote_commit = str(remote_commit or "").strip()
|
|
|
|
if not safe_local_commit or not safe_remote_commit:
|
|
status["error"] = "Missing commit information for AGNOS manifest comparison."
|
|
return status
|
|
|
|
if safe_local_commit == safe_remote_commit:
|
|
status["checked"] = True
|
|
status["source"] = "same-commit"
|
|
return status
|
|
|
|
try:
|
|
local_manifest_text = _git_show_file_text(repo_path, safe_local_commit, _AGNOS_MANIFEST_PATH)
|
|
except Exception as exception:
|
|
status["error"] = f"Unable to read local AGNOS manifest: {exception}"
|
|
return status
|
|
|
|
remote_manifest_text = ""
|
|
if _git_has_commit(repo_path, safe_remote_commit):
|
|
try:
|
|
remote_manifest_text = _git_show_file_text(repo_path, safe_remote_commit, _AGNOS_MANIFEST_PATH)
|
|
status["source"] = "git"
|
|
except Exception as exception:
|
|
status["error"] = f"Unable to read remote AGNOS manifest from git: {exception}"
|
|
return status
|
|
else:
|
|
try:
|
|
remote_manifest_text, raw_url = _fetch_remote_file_text(origin_remote, safe_remote_commit, _AGNOS_MANIFEST_PATH)
|
|
status["source"] = raw_url
|
|
except Exception as exception:
|
|
status["error"] = f"Unable to fetch remote AGNOS manifest: {exception}"
|
|
return status
|
|
|
|
local_hash = _agnos_manifest_hash(local_manifest_text)
|
|
remote_hash = _agnos_manifest_hash(remote_manifest_text)
|
|
changed_partitions = _agnos_changed_partitions(local_manifest_text, remote_manifest_text)
|
|
status.update({
|
|
"checked": True,
|
|
"available": bool(local_hash and remote_hash and local_hash != remote_hash),
|
|
"localManifestHash": local_hash,
|
|
"remoteManifestHash": remote_hash,
|
|
"changedPartitions": changed_partitions,
|
|
"error": "",
|
|
})
|
|
return status
|
|
|
|
def _build_shallow_fetch_args(branch):
|
|
return [
|
|
"-c", "gc.auto=0",
|
|
"-c", "maintenance.auto=false",
|
|
"fetch",
|
|
"--progress",
|
|
"--depth=1",
|
|
"--no-recurse-submodules",
|
|
"origin",
|
|
branch,
|
|
]
|
|
|
|
def _build_shallow_fetch_commit_args(commit):
|
|
return [
|
|
"-c", "gc.auto=0",
|
|
"-c", "maintenance.auto=false",
|
|
"fetch",
|
|
"--progress",
|
|
"--depth=1",
|
|
"--no-recurse-submodules",
|
|
"origin",
|
|
commit,
|
|
]
|
|
|
|
def _run_git_with_progress(repo_path, args, timeout, step, label):
|
|
cmd = [*_git_base_cmd(), *args]
|
|
|
|
process = subprocess.Popen(
|
|
cmd,
|
|
cwd=repo_path,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
env=_git_command_env(),
|
|
)
|
|
|
|
if process.stdout is None:
|
|
raise RuntimeError("Failed to open git output stream")
|
|
|
|
fd = process.stdout.fileno()
|
|
os.set_blocking(fd, False)
|
|
|
|
selector = selectors.DefaultSelector()
|
|
selector.register(fd, selectors.EVENT_READ)
|
|
|
|
started_at = time.monotonic()
|
|
last_activity_at = started_at
|
|
last_emit_at = 0.0
|
|
last_percent = None
|
|
last_detail = ""
|
|
output_tail = []
|
|
buffer = ""
|
|
|
|
def consume_text(text):
|
|
nonlocal buffer
|
|
for char in text:
|
|
if char in ("\r", "\n"):
|
|
if buffer:
|
|
handle_line(buffer)
|
|
buffer = ""
|
|
else:
|
|
buffer += char
|
|
|
|
def append_tail(text):
|
|
if not text:
|
|
return
|
|
output_tail.append(text)
|
|
if len(output_tail) > 180:
|
|
del output_tail[:-180]
|
|
|
|
def handle_line(text):
|
|
nonlocal last_activity_at, last_emit_at, last_percent, last_detail
|
|
|
|
percent, detail, phase = _parse_git_progress_line(text)
|
|
append_tail(detail or text)
|
|
|
|
now = time.monotonic()
|
|
last_activity_at = now
|
|
should_emit = False
|
|
|
|
if percent is not None:
|
|
safe_percent = _normalize_git_phase_percent(phase, percent)
|
|
if safe_percent in (0.0, 99.0):
|
|
should_emit = True
|
|
elif now - last_emit_at >= _FAST_UPDATE_PROGRESS_UPDATE_INTERVAL_S:
|
|
should_emit = last_percent is None or abs(safe_percent - last_percent) >= 1.0
|
|
|
|
if should_emit:
|
|
_set_fast_update_progress(step, label, safe_percent, detail or label)
|
|
last_emit_at = now
|
|
last_percent = safe_percent
|
|
last_detail = detail or label
|
|
else:
|
|
if last_percent is None:
|
|
last_percent = safe_percent
|
|
else:
|
|
if detail and now - last_emit_at >= _FAST_UPDATE_PROGRESS_UPDATE_INTERVAL_S and detail != last_detail:
|
|
fallback_percent = last_percent if last_percent is not None else 0.0
|
|
_set_fast_update_progress(step, label, fallback_percent, detail)
|
|
last_emit_at = now
|
|
last_detail = detail
|
|
|
|
try:
|
|
while True:
|
|
now = time.monotonic()
|
|
if timeout and (now - last_activity_at) > timeout:
|
|
try:
|
|
process.kill()
|
|
except Exception:
|
|
pass
|
|
tail = output_tail[-1] if output_tail else ""
|
|
suffix = f" (last output: {tail})" if tail else ""
|
|
raise TimeoutError(f"git {' '.join(args)} stalled for {int(timeout)}s without output{suffix}")
|
|
|
|
events = selector.select(timeout=0.5)
|
|
if not events:
|
|
if process.poll() is not None:
|
|
try:
|
|
trailing = os.read(fd, 4096)
|
|
except BlockingIOError:
|
|
trailing = b""
|
|
if trailing:
|
|
last_activity_at = time.monotonic()
|
|
consume_text(trailing.decode("utf-8", errors="replace"))
|
|
continue
|
|
break
|
|
|
|
# Heartbeat: if git is quiet (no progress lines), still surface activity.
|
|
now = time.monotonic()
|
|
if now - last_emit_at >= _FAST_UPDATE_PROGRESS_UPDATE_INTERVAL_S:
|
|
if timeout:
|
|
inferred_percent = min(95.0, max(0.0, ((now - started_at) / timeout) * 95.0))
|
|
else:
|
|
inferred_percent = min(95.0, (last_percent or 0.0) + 1.0)
|
|
if last_percent is None or inferred_percent > last_percent:
|
|
last_percent = inferred_percent
|
|
heartbeat_detail = last_detail or f"{label}..."
|
|
_set_fast_update_progress(step, label, last_percent or 0.0, heartbeat_detail)
|
|
last_emit_at = now
|
|
continue
|
|
|
|
reached_eof = False
|
|
for _, _ in events:
|
|
try:
|
|
chunk = os.read(fd, 4096)
|
|
except BlockingIOError:
|
|
chunk = b""
|
|
|
|
if not chunk:
|
|
# Selector can keep reporting readability on EOF; exit once process ended.
|
|
if process.poll() is not None:
|
|
reached_eof = True
|
|
break
|
|
continue
|
|
|
|
last_activity_at = time.monotonic()
|
|
consume_text(chunk.decode("utf-8", errors="replace"))
|
|
|
|
if reached_eof:
|
|
break
|
|
|
|
if buffer:
|
|
handle_line(buffer)
|
|
|
|
return_code = process.wait(timeout=2)
|
|
finally:
|
|
try:
|
|
selector.unregister(fd)
|
|
except Exception:
|
|
pass
|
|
selector.close()
|
|
try:
|
|
process.stdout.close()
|
|
except Exception:
|
|
pass
|
|
|
|
if return_code == 0:
|
|
_set_fast_update_progress(step, label, 100.0, last_detail or "Done")
|
|
|
|
return return_code, "\n".join(output_tail[-40:])
|
|
|
|
def _run_git(repo_path, args, timeout=30):
|
|
return subprocess.run(
|
|
[*_git_base_cmd(), *args],
|
|
cwd=repo_path,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=timeout,
|
|
check=False,
|
|
env=_git_command_env(),
|
|
)
|
|
|
|
def _git_stdout(repo_path, args, timeout=15):
|
|
result = _run_git(repo_path, args, timeout=timeout)
|
|
if result.returncode != 0:
|
|
stderr = (result.stderr or "").strip() or (result.stdout or "").strip() or "git command failed"
|
|
raise RuntimeError(stderr)
|
|
return (result.stdout or "").strip()
|
|
|
|
def _git_config_get(repo_path, key):
|
|
try:
|
|
return _git_stdout(repo_path, ["config", "--local", "--get", key], timeout=10)
|
|
except Exception:
|
|
return ""
|
|
|
|
def _git_config_set(repo_path, key, value):
|
|
result = _run_git(repo_path, ["config", "--local", "--replace-all", key, str(value)], timeout=15)
|
|
if result.returncode != 0:
|
|
raise RuntimeError((result.stderr or result.stdout or f"git config failed for {key}").strip())
|
|
|
|
def _git_config_unset(repo_path, key):
|
|
result = _run_git(repo_path, ["config", "--local", "--unset-all", key], timeout=15)
|
|
if result.returncode not in (0, 5):
|
|
raise RuntimeError((result.stderr or result.stdout or f"git config unset failed for {key}").strip())
|
|
|
|
def _git_update_ref(repo_path, ref_name, commit):
|
|
result = _run_git(repo_path, ["update-ref", ref_name, commit], timeout=15)
|
|
if result.returncode != 0:
|
|
raise RuntimeError((result.stderr or result.stdout or f"git update-ref failed for {ref_name}").strip())
|
|
|
|
def _git_delete_ref(repo_path, ref_name):
|
|
result = _run_git(repo_path, ["update-ref", "-d", ref_name], timeout=15)
|
|
if result.returncode not in (0, 1):
|
|
raise RuntimeError((result.stderr or result.stdout or f"git update-ref delete failed for {ref_name}").strip())
|
|
|
|
def _git_has_commit(repo_path, commit):
|
|
result = _run_git(repo_path, ["cat-file", "-e", f"{commit}^{{commit}}"], timeout=15)
|
|
return result.returncode == 0
|
|
|
|
def _save_rollback_target(repo_path, branch, commit):
|
|
safe_commit = str(commit or "").strip()
|
|
if not safe_commit:
|
|
raise RuntimeError("Missing rollback commit")
|
|
|
|
safe_branch = str(branch or "").strip()
|
|
if safe_branch and not _is_valid_git_branch_name(repo_path, safe_branch):
|
|
raise RuntimeError(f"Invalid rollback branch '{safe_branch}'")
|
|
|
|
_git_update_ref(repo_path, _ROLLBACK_REF, safe_commit)
|
|
if safe_branch:
|
|
_git_config_set(repo_path, _ROLLBACK_BRANCH_CONFIG_KEY, safe_branch)
|
|
else:
|
|
_git_config_unset(repo_path, _ROLLBACK_BRANCH_CONFIG_KEY)
|
|
_git_config_set(repo_path, _ROLLBACK_RECORDED_AT_CONFIG_KEY, datetime.now(timezone.utc).isoformat())
|
|
|
|
def _clear_rollback_target(repo_path):
|
|
_git_delete_ref(repo_path, _ROLLBACK_REF)
|
|
_git_config_unset(repo_path, _ROLLBACK_BRANCH_CONFIG_KEY)
|
|
_git_config_unset(repo_path, _ROLLBACK_RECORDED_AT_CONFIG_KEY)
|
|
|
|
def _load_rollback_target(repo_path):
|
|
commit = ""
|
|
try:
|
|
commit = _git_stdout(repo_path, ["rev-parse", "--verify", f"{_ROLLBACK_REF}^{{commit}}"], timeout=10)
|
|
except Exception:
|
|
pass
|
|
|
|
branch = _git_config_get(repo_path, _ROLLBACK_BRANCH_CONFIG_KEY)
|
|
recorded_at = _git_config_get(repo_path, _ROLLBACK_RECORDED_AT_CONFIG_KEY)
|
|
|
|
current_branch = ""
|
|
current_commit = ""
|
|
try:
|
|
current_branch = _git_stdout(repo_path, ["rev-parse", "--abbrev-ref", "HEAD"], timeout=10)
|
|
current_commit = _git_stdout(repo_path, ["rev-parse", "HEAD"], timeout=10)
|
|
except Exception:
|
|
pass
|
|
|
|
available = bool(commit) and (commit != current_commit or (branch and branch != current_branch))
|
|
return {
|
|
"rollbackBranch": branch,
|
|
"rollbackCommit": commit,
|
|
"rollbackRecordedAt": recorded_at,
|
|
"rollbackAvailable": available,
|
|
}
|
|
|
|
def _is_valid_git_branch_name(repo_path, branch_name):
|
|
branch = str(branch_name or "").strip()
|
|
if not branch or branch.startswith("-") or "\x00" in branch:
|
|
return False
|
|
|
|
result = _run_git(repo_path, ["check-ref-format", "--branch", branch], timeout=10)
|
|
return result.returncode == 0
|
|
|
|
def _list_origin_branches(repo_path, include_remote=True):
|
|
branches = set()
|
|
remote_error = ""
|
|
|
|
if include_remote and _remote_git_check_allowed():
|
|
try:
|
|
remote_heads = _git_stdout(repo_path, ["ls-remote", "--heads", "origin"], timeout=25)
|
|
for line in remote_heads.splitlines():
|
|
parts = line.split()
|
|
if len(parts) < 2:
|
|
continue
|
|
|
|
ref = parts[1].strip()
|
|
if not ref.startswith("refs/heads/"):
|
|
continue
|
|
|
|
branch = ref[len("refs/heads/"):].strip()
|
|
if branch:
|
|
branches.add(branch)
|
|
except Exception as exception:
|
|
if not _is_deferred_tls_error(exception):
|
|
remote_error = str(exception)
|
|
|
|
if not branches:
|
|
try:
|
|
local_refs = _git_stdout(
|
|
repo_path,
|
|
["for-each-ref", "--format=%(refname:short)", "refs/remotes/origin"],
|
|
timeout=15,
|
|
)
|
|
for line in local_refs.splitlines():
|
|
ref = line.strip()
|
|
if not ref or ref in ("origin/HEAD",):
|
|
continue
|
|
if ref.startswith("origin/"):
|
|
ref = ref[len("origin/"):]
|
|
if ref.endswith("/HEAD"):
|
|
continue
|
|
if ref:
|
|
branches.add(ref)
|
|
except Exception as exception:
|
|
if not remote_error:
|
|
remote_error = str(exception)
|
|
|
|
return sorted(branches, key=lambda branch: branch.lower()), remote_error
|
|
|
|
def _repo_has_submodule_entries(repo_path):
|
|
gitmodules_path = Path(repo_path) / ".gitmodules"
|
|
if not gitmodules_path.is_file():
|
|
return False
|
|
|
|
try:
|
|
content = gitmodules_path.read_text(encoding="utf-8", errors="replace")
|
|
except Exception:
|
|
# If we cannot inspect .gitmodules, stay conservative and try syncing.
|
|
return True
|
|
|
|
return bool(_GIT_SUBMODULE_SECTION_RE.search(content))
|
|
|
|
def _run_submodule_update_if_needed(repo_path, step=4):
|
|
if not _repo_has_submodule_entries(repo_path):
|
|
_set_fast_update_progress(step, "Updating submodules", 100.0, "No submodules configured.")
|
|
return
|
|
|
|
_set_fast_update_progress(step, "Updating submodules", 0.0, "Syncing submodules...")
|
|
submodule_rc, submodule_output = _run_git_with_progress(
|
|
repo_path,
|
|
["submodule", "update", "--init", "--recursive", "--depth=1", "--progress"],
|
|
timeout=240,
|
|
step=step,
|
|
label="Updating submodules",
|
|
)
|
|
if submodule_rc != 0:
|
|
raise RuntimeError(submodule_output.strip() or "git submodule update failed")
|
|
|
|
def _finish_update_and_reboot(message):
|
|
_set_fast_update_progress(5, "Rebooting device", 100.0, "Update complete. Please wait for device to reboot.")
|
|
_set_fast_update_state(
|
|
running=False,
|
|
stage="rebooting",
|
|
message=message,
|
|
finishedAt=time.time(),
|
|
)
|
|
# Keep the service online briefly so the UI can fetch and render the reboot notice.
|
|
time.sleep(_FAST_UPDATE_REBOOT_NOTICE_SECONDS)
|
|
HARDWARE.reboot()
|
|
|
|
def _set_fast_update_error_state(message, exception):
|
|
error_text = str(exception).strip() or "Unknown error"
|
|
_set_fast_update_state(
|
|
running=False,
|
|
stage="error",
|
|
message=message,
|
|
lastError=error_text,
|
|
finishedAt=time.time(),
|
|
progressStep=0,
|
|
progressTotalSteps=_FAST_UPDATE_TOTAL_STEPS,
|
|
progressStepPercent=0.0,
|
|
progressPercent=0.0,
|
|
progressLabel="Failed",
|
|
progressDetail="Update failed. See Last Error below.",
|
|
)
|
|
|
|
def _factory_reset_worker():
|
|
started_at = time.time()
|
|
|
|
try:
|
|
_set_fast_update_progress(1, "Preparing factory reset", 10.0, "Cleaning up legacy device state...")
|
|
_set_fast_update_state(
|
|
running=True,
|
|
stage="factory-resetting",
|
|
message="Factory reset started. Wiping device state...",
|
|
lastError="",
|
|
lastMode="factory-reset",
|
|
startedAt=started_at,
|
|
finishedAt=0.0,
|
|
)
|
|
_set_fast_update_progress(1, "Preparing factory reset", 100.0, "Factory reset initialized.")
|
|
|
|
total_paths = max(1, len(_FACTORY_RESET_WIPE_PATHS))
|
|
for index, path in enumerate(_FACTORY_RESET_WIPE_PATHS, start=1):
|
|
step_percent = ((index - 1) / total_paths) * 100.0
|
|
_set_fast_update_progress(2, "Wiping device data", step_percent, f"Removing {path}...")
|
|
_run_factory_reset_delete(path)
|
|
_set_fast_update_progress(2, "Wiping device data", (index / total_paths) * 100.0, f"Removed {path}.")
|
|
|
|
_set_fast_update_progress(3, "Resetting factory state", 100.0, "Legacy device state removed.")
|
|
|
|
_set_fast_update_progress(4, "Finalizing reset", 50.0, "Syncing filesystem before reboot...")
|
|
subprocess.run(["sync"], capture_output=True, text=True, timeout=60, check=False)
|
|
_set_fast_update_progress(4, "Finalizing reset", 100.0, "Filesystem sync complete.")
|
|
|
|
_finish_update_and_reboot(
|
|
"Factory reset complete. Device is rebooting now. Please wait for reconnection."
|
|
)
|
|
except Exception as exception:
|
|
_set_fast_update_error_state("Factory reset failed.", exception)
|
|
|
|
def _collect_fast_update_info(include_remote=True):
|
|
repo_path = str(_get_openpilot_root())
|
|
|
|
branch = ""
|
|
local_commit = ""
|
|
remote_commit = ""
|
|
update_available = False
|
|
remote_error = ""
|
|
origin_remote = ""
|
|
commits_url = ""
|
|
rollback_data = _load_rollback_target(repo_path)
|
|
agnos_update = _base_agnos_update_status()
|
|
|
|
try:
|
|
branch = _git_stdout(repo_path, ["rev-parse", "--abbrev-ref", "HEAD"])
|
|
local_commit = _git_stdout(repo_path, ["rev-parse", "HEAD"])
|
|
try:
|
|
origin_remote = _git_stdout(repo_path, ["config", "--get", "remote.origin.url"])
|
|
except Exception:
|
|
origin_remote = ""
|
|
except Exception as exception:
|
|
return {
|
|
"repoPath": repo_path,
|
|
"branch": branch,
|
|
"localCommit": local_commit,
|
|
"remoteCommit": remote_commit,
|
|
"updateAvailable": False,
|
|
"remoteError": str(exception),
|
|
"originRemote": origin_remote,
|
|
"commitsUrl": commits_url,
|
|
**rollback_data,
|
|
"agnosUpdate": agnos_update,
|
|
}
|
|
|
|
agnos_update["targetBranch"] = branch
|
|
agnos_update["localCommit"] = local_commit
|
|
|
|
if origin_remote:
|
|
remote = origin_remote.strip()
|
|
if remote.startswith("git@github.com:"):
|
|
remote = "https://github.com/" + remote.split(":", 1)[1]
|
|
elif remote.startswith("ssh://git@github.com/"):
|
|
remote = "https://github.com/" + remote.split("ssh://git@github.com/", 1)[1]
|
|
elif remote.startswith("http://github.com/"):
|
|
remote = "https://github.com/" + remote.split("http://github.com/", 1)[1]
|
|
|
|
if remote.startswith("https://github.com/"):
|
|
remote = remote.rstrip("/")
|
|
if remote.endswith(".git"):
|
|
remote = remote[:-4]
|
|
if branch:
|
|
commits_url = f"{remote}/commits/{quote(branch, safe='')}/"
|
|
|
|
if branch and include_remote and _remote_git_check_allowed():
|
|
remote_commit, remote_error = _get_remote_branch_commit(repo_path, branch)
|
|
update_available = bool(local_commit and remote_commit and local_commit != remote_commit)
|
|
if remote_commit:
|
|
agnos_update = _build_agnos_update_status(repo_path, origin_remote, local_commit, remote_commit, branch)
|
|
elif not include_remote:
|
|
agnos_update = _base_agnos_update_status(branch, local_commit, "")
|
|
|
|
return {
|
|
"repoPath": repo_path,
|
|
"branch": branch,
|
|
"localCommit": local_commit,
|
|
"remoteCommit": remote_commit,
|
|
"updateAvailable": update_available,
|
|
"remoteError": remote_error,
|
|
"originRemote": origin_remote,
|
|
"commitsUrl": commits_url,
|
|
"agnosUpdate": agnos_update,
|
|
**rollback_data,
|
|
}
|
|
|
|
def _fast_update_worker():
|
|
started_at = time.time()
|
|
repo_path = str(_get_openpilot_root())
|
|
|
|
try:
|
|
_set_fast_update_progress(1, "Preparing update", 10.0, "Resolving active branch...")
|
|
branch = _git_stdout(repo_path, ["rev-parse", "--abbrev-ref", "HEAD"])
|
|
current_commit = _git_stdout(repo_path, ["rev-parse", "HEAD"])
|
|
try:
|
|
_save_rollback_target(repo_path, branch, current_commit)
|
|
except Exception as exception:
|
|
print(f"Fast update rollback target save failed: {exception}")
|
|
_set_fast_update_progress(1, "Preparing update", 100.0, f"Branch: {branch}")
|
|
_set_fast_update_state(
|
|
running=True,
|
|
stage="updating",
|
|
message=f"Applying shallow update on '{branch}'...",
|
|
lastError="",
|
|
lastBranch=branch,
|
|
lastMode="fetch-reset",
|
|
startedAt=started_at,
|
|
finishedAt=0.0,
|
|
)
|
|
|
|
_set_fast_update_progress(2, "Fetching branch snapshot", 0.0, "Fetching latest shallow commit...")
|
|
fetch_rc, fetch_output = _run_git_with_progress(
|
|
repo_path,
|
|
_build_shallow_fetch_args(branch),
|
|
timeout=_FAST_UPDATE_FETCH_TIMEOUT_S,
|
|
step=2,
|
|
label="Fetching branch snapshot",
|
|
)
|
|
if fetch_rc != 0:
|
|
raise RuntimeError(fetch_output.strip() or "git fetch failed")
|
|
|
|
_set_fast_update_progress(3, "Applying fetched commit", 20.0, "Resetting repository to fetched head...")
|
|
reset = _run_git(repo_path, ["reset", "--hard", "FETCH_HEAD"], timeout=120)
|
|
if reset.returncode != 0:
|
|
raise RuntimeError((reset.stderr or reset.stdout or "git reset failed").strip())
|
|
_set_fast_update_progress(3, "Applying fetched commit", 100.0, "Repository reset complete.")
|
|
|
|
_run_submodule_update_if_needed(repo_path, step=4)
|
|
_finish_update_and_reboot(
|
|
"Update successful. Device is rebooting now. Please wait for reconnection."
|
|
)
|
|
except Exception as exception:
|
|
_set_fast_update_error_state("Fast update failed.", exception)
|
|
|
|
def _branch_switch_worker(target_branch):
|
|
started_at = time.time()
|
|
repo_path = str(_get_openpilot_root())
|
|
|
|
try:
|
|
_set_fast_update_progress(1, "Preparing branch switch", 10.0, f"Target: {target_branch}")
|
|
current_branch = _git_stdout(repo_path, ["rev-parse", "--abbrev-ref", "HEAD"])
|
|
current_commit = _git_stdout(repo_path, ["rev-parse", "HEAD"])
|
|
try:
|
|
_save_rollback_target(repo_path, current_branch, current_commit)
|
|
except Exception as exception:
|
|
print(f"Branch switch rollback target save failed: {exception}")
|
|
_set_fast_update_progress(1, "Preparing branch switch", 100.0, f"{current_branch} -> {target_branch}")
|
|
_set_fast_update_state(
|
|
running=True,
|
|
stage="switching",
|
|
message=f"Switching to '{target_branch}' with shallow fetch...",
|
|
lastError="",
|
|
lastBranch=target_branch,
|
|
lastMode="branch-switch",
|
|
startedAt=started_at,
|
|
finishedAt=0.0,
|
|
)
|
|
|
|
_set_fast_update_progress(2, "Fetching branch snapshot", 0.0, f"Fetching '{target_branch}' from origin...")
|
|
fetch_rc, fetch_output = _run_git_with_progress(
|
|
repo_path,
|
|
_build_shallow_fetch_args(target_branch),
|
|
timeout=_FAST_BRANCH_SWITCH_FETCH_TIMEOUT_S,
|
|
step=2,
|
|
label="Fetching branch snapshot",
|
|
)
|
|
if fetch_rc != 0:
|
|
raise RuntimeError(fetch_output.strip() or f"git fetch failed for '{target_branch}'")
|
|
|
|
_set_fast_update_progress(3, "Switching branch", 20.0, f"Checking out '{target_branch}'...")
|
|
checkout = _run_git(repo_path, ["checkout", "--force", "-B", target_branch, "FETCH_HEAD"], timeout=120)
|
|
if checkout.returncode != 0:
|
|
raise RuntimeError((checkout.stderr or checkout.stdout or "git checkout failed").strip())
|
|
|
|
reset = _run_git(repo_path, ["reset", "--hard", "FETCH_HEAD"], timeout=120)
|
|
if reset.returncode != 0:
|
|
raise RuntimeError((reset.stderr or reset.stdout or "git reset failed").strip())
|
|
|
|
_run_git(repo_path, ["branch", "--set-upstream-to", f"origin/{target_branch}", target_branch], timeout=30)
|
|
_set_fast_update_progress(3, "Switching branch", 100.0, f"Now on '{target_branch}'.")
|
|
|
|
_run_submodule_update_if_needed(repo_path, step=4)
|
|
_finish_update_and_reboot(
|
|
f"Switched to '{target_branch}'. Device is rebooting now. Please wait for reconnection."
|
|
)
|
|
except Exception as exception:
|
|
_set_fast_update_error_state("Fast branch switch failed.", exception)
|
|
|
|
def _rollback_worker():
|
|
started_at = time.time()
|
|
repo_path = str(_get_openpilot_root())
|
|
|
|
try:
|
|
rollback_state = _load_rollback_target(repo_path)
|
|
target_branch = str(rollback_state.get("rollbackBranch") or "").strip()
|
|
target_commit = str(rollback_state.get("rollbackCommit") or "").strip()
|
|
if not target_commit:
|
|
raise RuntimeError("No previous installed version has been recorded yet.")
|
|
|
|
current_branch = _git_stdout(repo_path, ["rev-parse", "--abbrev-ref", "HEAD"])
|
|
current_commit = _git_stdout(repo_path, ["rev-parse", "HEAD"])
|
|
if target_commit == current_commit and (not target_branch or target_branch == current_branch):
|
|
raise RuntimeError("Current install already matches the saved rollback target.")
|
|
|
|
if not target_branch:
|
|
raise RuntimeError("Saved rollback branch is missing.")
|
|
if not _is_valid_git_branch_name(repo_path, target_branch):
|
|
raise RuntimeError(f"Saved rollback branch '{target_branch}' is invalid.")
|
|
|
|
short_commit = target_commit[:10]
|
|
_set_fast_update_progress(1, "Preparing rollback", 10.0, f"Target: {target_branch} @ {short_commit}")
|
|
_set_fast_update_state(
|
|
running=True,
|
|
stage="rolling-back",
|
|
message=f"Rolling back to the previous installed version on '{target_branch}'...",
|
|
lastError="",
|
|
lastBranch=target_branch,
|
|
lastMode="rollback",
|
|
startedAt=started_at,
|
|
finishedAt=0.0,
|
|
)
|
|
_set_fast_update_progress(1, "Preparing rollback", 100.0, f"{current_branch} -> {target_branch} @ {short_commit}")
|
|
|
|
if _git_has_commit(repo_path, target_commit):
|
|
_set_fast_update_progress(2, "Resolving rollback target", 100.0, "Previous installed version is already available locally.")
|
|
else:
|
|
_set_fast_update_progress(2, "Resolving rollback target", 0.0, f"Fetching saved version {short_commit} from origin...")
|
|
fetch_rc, fetch_output = _run_git_with_progress(
|
|
repo_path,
|
|
_build_shallow_fetch_commit_args(target_commit),
|
|
timeout=_FAST_ROLLBACK_FETCH_TIMEOUT_S,
|
|
step=2,
|
|
label="Resolving rollback target",
|
|
)
|
|
if fetch_rc != 0 or not _git_has_commit(repo_path, target_commit):
|
|
raise RuntimeError(fetch_output.strip() or f"Unable to fetch rollback commit {short_commit}")
|
|
|
|
_set_fast_update_progress(3, "Applying rollback target", 20.0, f"Checking out {target_branch} @ {short_commit}...")
|
|
checkout = _run_git(repo_path, ["checkout", "--force", "-B", target_branch, target_commit], timeout=120)
|
|
if checkout.returncode != 0:
|
|
raise RuntimeError((checkout.stderr or checkout.stdout or "git checkout failed").strip())
|
|
|
|
reset = _run_git(repo_path, ["reset", "--hard", target_commit], timeout=120)
|
|
if reset.returncode != 0:
|
|
raise RuntimeError((reset.stderr or reset.stdout or "git reset failed").strip())
|
|
|
|
_run_git(repo_path, ["branch", "--set-upstream-to", f"origin/{target_branch}", target_branch], timeout=30)
|
|
_set_fast_update_progress(3, "Applying rollback target", 100.0, f"Now on {target_branch} @ {short_commit}.")
|
|
|
|
_run_submodule_update_if_needed(repo_path, step=4)
|
|
try:
|
|
_clear_rollback_target(repo_path)
|
|
except Exception as exception:
|
|
print(f"Rollback target clear failed: {exception}")
|
|
_finish_update_and_reboot(
|
|
f"Rolled back to the previous installed version on '{target_branch}'. Automatic updates were disabled and the device is rebooting now."
|
|
)
|
|
except Exception as exception:
|
|
_set_fast_update_error_state("Rollback failed.", exception)
|
|
|
|
def _get_openpilot_root():
|
|
global _openpilot_root_cache
|
|
if _openpilot_root_cache is not None:
|
|
return _openpilot_root_cache
|
|
|
|
for parent in Path(__file__).resolve().parents:
|
|
if (parent / "opendbc" / "car").is_dir() or (parent / "selfdrive" / "car").is_dir():
|
|
_openpilot_root_cache = parent
|
|
return _openpilot_root_cache
|
|
|
|
# Fallback to repo root shape used in this tree.
|
|
_openpilot_root_cache = Path(__file__).resolve().parents[3]
|
|
return _openpilot_root_cache
|
|
|
|
def _extract_fingerprint_models_for_make(make_key):
|
|
source_make = FINGERPRINT_MAKE_TO_VALUES_DIR.get(make_key, make_key)
|
|
root = _get_openpilot_root()
|
|
values_candidates = [
|
|
root / "opendbc" / "car" / source_make / "values.py",
|
|
root / "selfdrive" / "car" / source_make / "values.py",
|
|
]
|
|
values_path = next((path for path in values_candidates if path.is_file()), None)
|
|
if values_path is None:
|
|
return []
|
|
|
|
try:
|
|
content = values_path.read_text(encoding="utf-8", errors="replace")
|
|
except Exception:
|
|
return []
|
|
|
|
content = re.sub(r'#[^\n]*', "", content)
|
|
content = re.sub(r'footnotes=\[[^\]]*\],\s*', "", content)
|
|
|
|
models = []
|
|
seen = set()
|
|
|
|
for platform_match in _FINGERPRINT_PLATFORM_RE.finditer(content):
|
|
platform_name = platform_match.group(1)
|
|
if not _FINGERPRINT_PLATFORM_NAME_RE.match(platform_name):
|
|
continue
|
|
|
|
platform_section = platform_match.group(2)
|
|
for name_match in _FINGERPRINT_CARDOCS_RE.finditer(platform_section):
|
|
car_name = name_match.group(1).strip()
|
|
if " " not in car_name:
|
|
continue
|
|
if not _FINGERPRINT_VALID_NAME_RE.match(car_name):
|
|
continue
|
|
|
|
if car_name.split(" ", 1)[0].lower() != make_key:
|
|
continue
|
|
|
|
dedupe_key = (car_name, platform_name)
|
|
if dedupe_key in seen:
|
|
continue
|
|
seen.add(dedupe_key)
|
|
models.append({"value": platform_name, "label": car_name})
|
|
|
|
models.sort(key=lambda entry: entry["label"].lower())
|
|
return models
|
|
|
|
def _get_fingerprint_catalog():
|
|
global _fingerprint_catalog_cache
|
|
if _fingerprint_catalog_cache is not None:
|
|
return _fingerprint_catalog_cache
|
|
|
|
make_options = [{"value": label, "label": label} for label in FINGERPRINT_MAKE_LABELS]
|
|
make_keys = [_normalize_fingerprint_make_key(label) for label in FINGERPRINT_MAKE_LABELS]
|
|
make_label_by_key = {key: label for key, label in zip(make_keys, FINGERPRINT_MAKE_LABELS)}
|
|
|
|
models_by_make = {}
|
|
all_models = []
|
|
seen_all = set()
|
|
model_to_label = {}
|
|
model_to_make = {}
|
|
label_to_model = {}
|
|
|
|
for make_key in make_keys:
|
|
make_label = make_label_by_key.get(make_key, make_key.title())
|
|
entries = _extract_fingerprint_models_for_make(make_key)
|
|
|
|
models_by_make[make_key] = entries
|
|
for entry in entries:
|
|
model_value = entry["value"]
|
|
model_label = entry["label"]
|
|
|
|
model_to_label.setdefault(model_value, model_label)
|
|
model_to_make.setdefault(model_value, make_label)
|
|
label_to_model.setdefault(model_label, model_value)
|
|
|
|
dedupe_key = (model_label, model_value)
|
|
if dedupe_key in seen_all:
|
|
continue
|
|
seen_all.add(dedupe_key)
|
|
|
|
all_models.append({
|
|
"value": model_value,
|
|
"label": model_label,
|
|
"make": make_label,
|
|
})
|
|
|
|
all_models.sort(key=lambda entry: entry["label"].lower())
|
|
|
|
_fingerprint_catalog_cache = {
|
|
"makes": make_options,
|
|
"models_by_make": models_by_make,
|
|
"all_models": all_models,
|
|
"make_label_by_key": make_label_by_key,
|
|
"model_to_label": model_to_label,
|
|
"model_to_make": model_to_make,
|
|
"label_to_model": label_to_model,
|
|
}
|
|
return _fingerprint_catalog_cache
|
|
|
|
def read_legacy_param_file(key, default_value=""):
|
|
try:
|
|
value_path = Path(params.get_param_path(key))
|
|
if value_path.is_file():
|
|
return value_path.read_text(encoding="utf-8").strip() or default_value
|
|
except Exception:
|
|
pass
|
|
return default_value
|
|
|
|
def write_legacy_param_file(key, value):
|
|
value_path = Path(params.get_param_path(key))
|
|
value_path.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp_path = value_path.with_name(f".tmp_{value_path.name}")
|
|
tmp_path.write_text(str(value), encoding="utf-8")
|
|
os.replace(tmp_path, value_path)
|
|
|
|
_layout_type_overrides = None
|
|
_layout_param_metadata = None
|
|
_favorite_slot_options = None
|
|
|
|
def _get_layout_param_metadata():
|
|
global _layout_param_metadata
|
|
if _layout_param_metadata is None:
|
|
try:
|
|
layout_path = os.path.join(os.path.dirname(__file__), "assets", "components", "tools", "device_settings_layout.json")
|
|
with open(layout_path) as f:
|
|
layout_data = json.load(f)
|
|
_layout_param_metadata = {
|
|
p["key"]: p
|
|
for section in layout_data
|
|
for p in section.get("params", [])
|
|
if "key" in p
|
|
}
|
|
except Exception:
|
|
_layout_param_metadata = {}
|
|
return _layout_param_metadata
|
|
|
|
def _get_layout_type_overrides():
|
|
global _layout_type_overrides
|
|
if _layout_type_overrides is None:
|
|
layout_param_metadata = _get_layout_param_metadata()
|
|
_layout_type_overrides = {
|
|
key: param_data.get("data_type")
|
|
for key, param_data in layout_param_metadata.items()
|
|
if param_data.get("data_type")
|
|
}
|
|
return _layout_type_overrides
|
|
|
|
def _get_favorite_slot_options():
|
|
global _favorite_slot_options
|
|
if _favorite_slot_options is not None:
|
|
return _favorite_slot_options
|
|
|
|
allowed_keys, value_types = _get_param_type_info()
|
|
options = []
|
|
options.extend(dict(option) for option in FAVORITE_ACTION_OPTIONS)
|
|
try:
|
|
layout_path = os.path.join(os.path.dirname(__file__), "assets", "components", "tools", "device_settings_layout.json")
|
|
with open(layout_path) as f:
|
|
layout_data = json.load(f)
|
|
|
|
seen = set()
|
|
for section in layout_data:
|
|
section_name = section.get("name", "")
|
|
for param_data in section.get("params", []):
|
|
key = str(param_data.get("key") or "").strip()
|
|
if not key or key in seen:
|
|
continue
|
|
if key not in allowed_keys or value_types.get(key) is not bool:
|
|
continue
|
|
if param_data.get("ui_type") != "toggle" or param_data.get("data_type") != "bool":
|
|
continue
|
|
|
|
seen.add(key)
|
|
options.append({
|
|
"key": key,
|
|
"label": str(param_data.get("label") or key),
|
|
"description": str(param_data.get("description") or ""),
|
|
"section": section_name,
|
|
})
|
|
except Exception:
|
|
options = []
|
|
|
|
options.sort(key=lambda option: (str(option.get("label") or option.get("key") or "").casefold(), str(option.get("key") or "").casefold()))
|
|
_favorite_slot_options = options
|
|
return _favorite_slot_options
|
|
|
|
def _favorite_slot_values(options):
|
|
return {
|
|
option["key"]: _safe_params_get_bool(option["key"])
|
|
for option in options
|
|
if option.get("key") and not is_favorite_action_key(option.get("key"))
|
|
}
|
|
|
|
def _configured_favorite_slot_values(slots):
|
|
return {
|
|
slot["key"]: _safe_params_get_bool(slot["key"])
|
|
for slot in slots
|
|
if slot.get("key") and not is_favorite_action_key(slot.get("key"))
|
|
}
|
|
|
|
_cached_allowed_keys = None
|
|
_cached_param_types = None
|
|
_cached_default_values = None
|
|
_cached_static_default_values = None
|
|
|
|
GALAXY_MANUAL_BOOL_PARAM_KEYS = {"IsRHD", "IsRHDOverride"}
|
|
|
|
def _get_param_type_info():
|
|
global _cached_allowed_keys, _cached_param_types
|
|
if _cached_allowed_keys is None:
|
|
_cached_allowed_keys = {k for k, _, _, _ in starpilot_default_params if k not in EXCLUDED_KEYS}
|
|
|
|
types = {}
|
|
for k, default_val, _, _ in starpilot_default_params:
|
|
if k in _cached_allowed_keys:
|
|
if default_val in ("0", "1", b"0", b"1") or isinstance(default_val, bool):
|
|
types[k] = bool
|
|
elif isinstance(default_val, float) or (isinstance(default_val, str) and "." in default_val and default_val.replace(".", "", 1).isdigit()):
|
|
types[k] = float
|
|
elif isinstance(default_val, int) or (isinstance(default_val, str) and default_val.isdigit()):
|
|
types[k] = int
|
|
else:
|
|
types[k] = str
|
|
|
|
for k, dt in _get_layout_type_overrides().items():
|
|
if k in types and dt in ("int", "float") and types[k] == bool:
|
|
types[k] = float if dt == "float" else int
|
|
elif k in types and dt == "bool":
|
|
types[k] = bool
|
|
|
|
for k in GALAXY_MANUAL_BOOL_PARAM_KEYS:
|
|
if k in _cached_allowed_keys:
|
|
types[k] = bool
|
|
|
|
# Keep legacy aliases editable for older payloads/UI clients.
|
|
alias_to_key = {
|
|
"Model": "DrivingModel",
|
|
"ModelVersion": "DrivingModelVersion",
|
|
"SecOCKey": "SecOCKeys",
|
|
}
|
|
for alias_key, real_key in alias_to_key.items():
|
|
if real_key in _cached_allowed_keys:
|
|
_cached_allowed_keys.add(alias_key)
|
|
types[alias_key] = types.get(real_key, str)
|
|
|
|
_cached_param_types = types
|
|
return _cached_allowed_keys, _cached_param_types
|
|
|
|
def _get_static_default_param_values():
|
|
global _cached_static_default_values
|
|
if _cached_static_default_values is None:
|
|
_cached_static_default_values = {
|
|
key: default_val
|
|
for key, default_val, _, _ in starpilot_default_params
|
|
if key not in EXCLUDED_KEYS
|
|
}
|
|
return _cached_static_default_values
|
|
|
|
def _get_default_param_values():
|
|
default_values = dict(_get_static_default_param_values())
|
|
default_values.update(_get_runtime_default_param_overrides())
|
|
return default_values
|
|
|
|
def _coerce_param_value(raw_value, value_type):
|
|
safe_type = value_type or str
|
|
|
|
if safe_type == bool:
|
|
if isinstance(raw_value, bool):
|
|
return raw_value
|
|
if isinstance(raw_value, bytes):
|
|
raw_value = raw_value.decode("utf-8", errors="replace")
|
|
return str(raw_value or "").strip() in ("1", "true", "True")
|
|
|
|
if safe_type == float:
|
|
if raw_value in (None, "", b""):
|
|
return 0.0
|
|
try:
|
|
if isinstance(raw_value, bytes):
|
|
raw_value = raw_value.decode("utf-8", errors="replace")
|
|
return float(str(raw_value).strip())
|
|
except Exception:
|
|
return 0.0
|
|
|
|
if safe_type == int:
|
|
if raw_value in (None, "", b""):
|
|
return 0
|
|
try:
|
|
if isinstance(raw_value, bytes):
|
|
raw_value = raw_value.decode("utf-8", errors="replace")
|
|
return int(float(str(raw_value).strip()))
|
|
except Exception:
|
|
return 0
|
|
|
|
if isinstance(raw_value, bytes):
|
|
return raw_value.decode("utf-8", errors="replace")
|
|
return str(raw_value or "")
|
|
|
|
def _safe_params_get(key, encoding=None, default=None):
|
|
try:
|
|
if encoding is not None:
|
|
return params.get(key, encoding=encoding)
|
|
return params.get(key)
|
|
except Exception:
|
|
return default
|
|
|
|
def _safe_params_get_live_raw(key, default=None, block=False):
|
|
try:
|
|
return _params_live_raw.get(key, block=block)
|
|
except Exception:
|
|
return default
|
|
|
|
def _safe_params_get_bool(key, default=False):
|
|
try:
|
|
return params.get_bool(key)
|
|
except Exception:
|
|
return bool(default)
|
|
|
|
def _normalize_vasm_config(data):
|
|
if not isinstance(data, dict):
|
|
raise ValueError("Configuration must be a JSON object.")
|
|
|
|
try:
|
|
width = int(data.get("width", 0))
|
|
height = int(data.get("height", 0))
|
|
except (TypeError, ValueError) as exc:
|
|
raise ValueError("Invalid camera dimensions.") from exc
|
|
if not (1 <= width <= 8192 and 1 <= height <= 8192):
|
|
raise ValueError("Camera dimensions are out of range.")
|
|
|
|
def normalize_polygon(key):
|
|
polygon = data.get(key, [])
|
|
if not isinstance(polygon, list) or len(polygon) > 64:
|
|
raise ValueError(f"{key} must contain at most 64 points.")
|
|
if polygon and len(polygon) < 3:
|
|
raise ValueError(f"{key} requires at least 3 points.")
|
|
|
|
normalized = []
|
|
for point in polygon:
|
|
if not isinstance(point, (list, tuple)) or len(point) != 2:
|
|
raise ValueError(f"{key} contains an invalid point.")
|
|
try:
|
|
x, y = float(point[0]), float(point[1])
|
|
except (TypeError, ValueError) as exc:
|
|
raise ValueError(f"{key} contains a non-numeric point.") from exc
|
|
if not (math.isfinite(x) and math.isfinite(y) and 0 <= x <= width and 0 <= y <= height):
|
|
raise ValueError(f"{key} contains a point outside the camera frame.")
|
|
normalized.append([round(x), round(y)])
|
|
return normalized
|
|
|
|
config = {
|
|
"width": width,
|
|
"height": height,
|
|
"poly_left": normalize_polygon("poly_left"),
|
|
"poly_right": normalize_polygon("poly_right"),
|
|
}
|
|
if not config["poly_left"] and not config["poly_right"]:
|
|
raise ValueError("At least one window polygon is required.")
|
|
return config
|
|
|
|
|
|
def _normalize_pip_preview_config(data):
|
|
if not isinstance(data, dict):
|
|
raise ValueError("Configuration must be a JSON object.")
|
|
|
|
try:
|
|
width = int(data.get("width", 0))
|
|
height = int(data.get("height", 0))
|
|
except (TypeError, ValueError) as exc:
|
|
raise ValueError("Invalid camera dimensions.") from exc
|
|
if not (1 <= width <= 8192 and 1 <= height <= 8192):
|
|
raise ValueError("Camera dimensions are out of range.")
|
|
|
|
try:
|
|
crop_size = int(data.get("crop_size", 0))
|
|
except (TypeError, ValueError) as exc:
|
|
raise ValueError("Invalid crop size.") from exc
|
|
if not (10 <= crop_size <= 8192):
|
|
raise ValueError("Crop size is out of range.")
|
|
|
|
def normalize_center(key):
|
|
point = data.get(key)
|
|
if not point:
|
|
return []
|
|
if not isinstance(point, (list, tuple)) or len(point) != 2:
|
|
raise ValueError(f"{key} requires an (x, y) center point.")
|
|
try:
|
|
x, y = float(point[0]), float(point[1])
|
|
except (TypeError, ValueError) as exc:
|
|
raise ValueError(f"{key} contains a non-numeric point.") from exc
|
|
if not (math.isfinite(x) and math.isfinite(y) and 0 <= x <= width and 0 <= y <= height):
|
|
raise ValueError(f"{key} center is outside the camera frame.")
|
|
return [round(x), round(y)]
|
|
|
|
config = {
|
|
"width": width,
|
|
"height": height,
|
|
"center_left": normalize_center("center_left"),
|
|
"center_right": normalize_center("center_right"),
|
|
"crop_size": crop_size,
|
|
}
|
|
if not config["center_left"] and not config["center_right"]:
|
|
raise ValueError("At least one window center is required.")
|
|
return config
|
|
|
|
|
|
def _decode_json_object(value):
|
|
if isinstance(value, bytes):
|
|
value = value.decode("utf-8", errors="replace")
|
|
if isinstance(value, str):
|
|
try:
|
|
value = json.loads(value)
|
|
except json.JSONDecodeError:
|
|
return {}
|
|
return value if isinstance(value, dict) else {}
|
|
|
|
|
|
def _is_blank_param_raw(raw_value):
|
|
if raw_value is None:
|
|
return True
|
|
if isinstance(raw_value, bytes):
|
|
return len(raw_value.strip()) == 0
|
|
if isinstance(raw_value, str):
|
|
return len(raw_value.strip()) == 0
|
|
return False
|
|
|
|
def _get_use_old_ui_enabled():
|
|
if not _raylib_ui_toggle_affects_device():
|
|
return False
|
|
|
|
raw_value = _safe_params_get_live_raw("UseOldUI")
|
|
if _is_blank_param_raw(raw_value):
|
|
legacy_raw_value = _safe_params_get_live_raw("TryRaylibUI")
|
|
if not _is_blank_param_raw(legacy_raw_value):
|
|
return not _coerce_param_value(legacy_raw_value, bool)
|
|
|
|
return _coerce_param_value(raw_value, bool)
|
|
|
|
def _has_runtime_default_value(key, raw_value):
|
|
if _is_blank_param_raw(raw_value):
|
|
return False
|
|
|
|
try:
|
|
if isinstance(raw_value, bytes):
|
|
raw_value = raw_value.decode("utf-8", errors="replace")
|
|
numeric_value = float(str(raw_value).strip())
|
|
if not math.isfinite(numeric_value):
|
|
return False
|
|
if key in _RUNTIME_DEFAULT_ZERO_OK_KEYS:
|
|
return True
|
|
return numeric_value != 0.0
|
|
except Exception:
|
|
return True
|
|
|
|
def _get_runtime_default_param_overrides():
|
|
overrides = {}
|
|
static_defaults = _get_static_default_param_values()
|
|
|
|
for key, stock_key in _RUNTIME_DEFAULT_STOCK_KEYS.items():
|
|
stock_raw = _safe_params_get_live_raw(stock_key)
|
|
if _has_runtime_default_value(key, stock_raw):
|
|
overrides[key] = stock_raw
|
|
overrides[stock_key] = stock_raw
|
|
|
|
cp_bytes = _safe_params_get_live_raw("CarParamsPersistent")
|
|
if cp_bytes:
|
|
try:
|
|
with car.CarParams.from_bytes(cp_bytes) as cp:
|
|
overrides["EVTuning"] = default_ev_tuning_enabled(cp)
|
|
|
|
car_param_defaults = {
|
|
"SteerDelay": full_lateral_delay(getattr(cp, "steerActuatorDelay", 0.0)),
|
|
"SteerRatio": getattr(cp, "steerRatio", None),
|
|
"LongitudinalActuatorDelay": getattr(cp, "longitudinalActuatorDelay", None),
|
|
"StartAccel": getattr(cp, "startAccel", None),
|
|
"StopAccel": getattr(cp, "stopAccel", None),
|
|
"StoppingDecelRate": getattr(cp, "stoppingDecelRate", None),
|
|
"VEgoStarting": getattr(cp, "vEgoStarting", None),
|
|
"VEgoStopping": getattr(cp, "vEgoStopping", None),
|
|
}
|
|
|
|
for key, value in car_param_defaults.items():
|
|
if key in overrides or value is None:
|
|
continue
|
|
numeric_value = float(value)
|
|
if not math.isfinite(numeric_value):
|
|
continue
|
|
if key not in _RUNTIME_DEFAULT_ZERO_OK_KEYS and numeric_value == 0.0:
|
|
continue
|
|
overrides[key] = value
|
|
except Exception:
|
|
pass
|
|
|
|
ev_tuning_raw = _safe_params_get_live_raw("EVTuning")
|
|
truck_tuning_raw = _safe_params_get_live_raw("TruckTuning")
|
|
acceleration_profile_raw = _safe_params_get_live_raw("AccelerationProfile")
|
|
|
|
ev_tuning = _coerce_param_value(
|
|
ev_tuning_raw if not _is_blank_param_raw(ev_tuning_raw) else overrides.get("EVTuning", static_defaults.get("EVTuning", "0")),
|
|
bool,
|
|
)
|
|
truck_tuning = _coerce_param_value(
|
|
truck_tuning_raw if not _is_blank_param_raw(truck_tuning_raw) else static_defaults.get("TruckTuning", "0"),
|
|
bool,
|
|
)
|
|
if truck_tuning:
|
|
ev_tuning = False
|
|
|
|
acceleration_profile = normalize_acceleration_profile(
|
|
acceleration_profile_raw if not _is_blank_param_raw(acceleration_profile_raw) else static_defaults.get("AccelerationProfile", "0")
|
|
)
|
|
overrides.update(build_custom_accel_profile_defaults(acceleration_profile, ev_tuning, truck_tuning))
|
|
|
|
return overrides
|
|
|
|
def _get_current_param_value(key, value_type, defaults_lookup=None):
|
|
if key == "UseOldUI":
|
|
return _get_use_old_ui_enabled()
|
|
if key == "TryRaylibUI":
|
|
return _raylib_ui_toggle_affects_device() and not _get_use_old_ui_enabled()
|
|
|
|
if key == CUSTOM_ACCEL_PROFILE_INITIALIZED_KEY:
|
|
return _get_custom_accel_profile_initialized()
|
|
|
|
if key == "LeadIndicator":
|
|
return _get_lead_indicator_enabled(defaults_lookup)
|
|
|
|
if key == "IsRHD" and not _safe_params_get_bool("IsRHDOverride"):
|
|
return _safe_params_get_bool("IsRhdDetected")
|
|
|
|
if key in CUSTOM_ACCEL_PROFILE_PARAM_KEYS and not _get_custom_accel_profile_initialized():
|
|
if defaults_lookup is None:
|
|
defaults_lookup = _get_default_param_values()
|
|
return _coerce_param_value(defaults_lookup.get(key), value_type)
|
|
|
|
raw_value = _safe_params_get_live_raw(key)
|
|
if _is_blank_param_raw(raw_value):
|
|
if defaults_lookup is None:
|
|
defaults_lookup = _get_default_param_values()
|
|
raw_value = defaults_lookup.get(key)
|
|
value = _coerce_param_value(raw_value, value_type)
|
|
if key in ("Model", "DrivingModel") and isinstance(value, str):
|
|
return canonical_model_key(value)
|
|
return value
|
|
|
|
|
|
def _get_lead_indicator_enabled(defaults_lookup=None):
|
|
if defaults_lookup is None:
|
|
defaults_lookup = _get_default_param_values()
|
|
|
|
hide_raw = _safe_params_get_live_raw("HideLeadMarker")
|
|
if _is_blank_param_raw(hide_raw):
|
|
hide_raw = defaults_lookup.get("HideLeadMarker", "0")
|
|
|
|
return not _coerce_param_value(hide_raw, bool)
|
|
|
|
|
|
def _get_custom_accel_profile_initialized():
|
|
raw_values = {
|
|
key: _safe_params_get_live_raw(key)
|
|
for key in CUSTOM_ACCEL_PROFILE_PARAM_KEYS
|
|
}
|
|
return custom_accel_profile_is_initialized(
|
|
_safe_params_get_live_raw(CUSTOM_ACCEL_PROFILE_INITIALIZED_KEY),
|
|
raw_values,
|
|
)
|
|
|
|
def _serialize_param_write_value(raw_value):
|
|
if isinstance(raw_value, bool):
|
|
return "1" if raw_value else "0"
|
|
if isinstance(raw_value, bytes):
|
|
return raw_value.decode("utf-8", errors="replace")
|
|
return str(raw_value or "")
|
|
|
|
def _offroad_excessive_actuation_type():
|
|
alert = _safe_params_get_live_raw("Offroad_ExcessiveActuation")
|
|
if not alert:
|
|
return ""
|
|
|
|
if isinstance(alert, bytes):
|
|
try:
|
|
alert = json.loads(alert.decode("utf-8", errors="replace"))
|
|
except json.JSONDecodeError:
|
|
return ""
|
|
elif isinstance(alert, str):
|
|
try:
|
|
alert = json.loads(alert)
|
|
except json.JSONDecodeError:
|
|
return ""
|
|
|
|
if not isinstance(alert, dict):
|
|
return ""
|
|
|
|
extra = alert.get("extra", "")
|
|
if isinstance(extra, bytes):
|
|
extra = extra.decode("utf-8", errors="replace")
|
|
return str(extra).strip().lower()
|
|
|
|
def _apply_cellular_metered_setting(metered_enabled):
|
|
"""Apply GsmMetered changes to active NetworkManager GSM profiles."""
|
|
if not shutil.which("nmcli"):
|
|
return {"profiles": [], "warnings": ["nmcli not found; parameter saved but modem profile was not updated."]}
|
|
|
|
metered_mode = "unknown" if bool(metered_enabled) else "no"
|
|
updated_profiles = []
|
|
warnings = []
|
|
|
|
try:
|
|
list_result = subprocess.run(
|
|
["nmcli", "-t", "-f", "NAME,TYPE", "connection", "show"],
|
|
capture_output=True, text=True, timeout=10, check=False
|
|
)
|
|
except Exception as error:
|
|
return {"profiles": [], "warnings": [f"Failed to list network profiles: {error}"]}
|
|
|
|
if list_result.returncode != 0:
|
|
stderr = (list_result.stderr or "").strip()
|
|
return {"profiles": [], "warnings": [f"Failed to list network profiles: {stderr or 'unknown error'}"]}
|
|
|
|
gsm_profiles = []
|
|
for line in (list_result.stdout or "").splitlines():
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
|
|
try:
|
|
name, conn_type = line.rsplit(":", 1)
|
|
except ValueError:
|
|
continue
|
|
|
|
if conn_type.strip() == "gsm" and name.strip():
|
|
gsm_profiles.append(name.strip())
|
|
|
|
for profile_name in gsm_profiles:
|
|
try:
|
|
result = subprocess.run(
|
|
["nmcli", "connection", "modify", profile_name, "connection.metered", metered_mode],
|
|
capture_output=True, text=True, timeout=10, check=False
|
|
)
|
|
if result.returncode == 0:
|
|
updated_profiles.append(profile_name)
|
|
else:
|
|
stderr = (result.stderr or "").strip()
|
|
warnings.append(f"Failed to update '{profile_name}': {stderr or 'unknown error'}")
|
|
except Exception as error:
|
|
warnings.append(f"Failed to update '{profile_name}': {error}")
|
|
|
|
# Re-activate active GSM profiles so the new metered setting takes effect immediately.
|
|
try:
|
|
active_result = subprocess.run(
|
|
["nmcli", "-t", "-f", "NAME,TYPE,STATE", "connection", "show", "--active"],
|
|
capture_output=True, text=True, timeout=10, check=False
|
|
)
|
|
|
|
if active_result.returncode == 0:
|
|
for line in (active_result.stdout or "").splitlines():
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
name, conn_type, state = line.rsplit(":", 2)
|
|
except ValueError:
|
|
continue
|
|
|
|
if conn_type.strip() != "gsm" or state.strip() != "activated":
|
|
continue
|
|
|
|
profile_name = name.strip()
|
|
if not profile_name:
|
|
continue
|
|
|
|
subprocess.run(["nmcli", "connection", "down", profile_name], capture_output=True, text=True, timeout=10, check=False)
|
|
subprocess.run(["nmcli", "connection", "up", profile_name], capture_output=True, text=True, timeout=20, check=False)
|
|
except Exception as error:
|
|
warnings.append(f"Failed to cycle active GSM connection: {error}")
|
|
|
|
return {"profiles": updated_profiles, "warnings": warnings}
|
|
|
|
def _format_longitudinal_personality(value):
|
|
mapping = {
|
|
"0": "Aggressive",
|
|
"1": "Standard",
|
|
"2": "Relaxed",
|
|
}
|
|
text = str(value or "").strip()
|
|
if text in mapping:
|
|
return mapping[text]
|
|
return f"Unknown ({text})" if text else "Unknown"
|
|
|
|
def _resolve_troubleshoot_current_value(key, value_type, default_values):
|
|
safe_type = value_type or str
|
|
|
|
if safe_type == bool:
|
|
raw_value = _safe_params_get_live_raw(key)
|
|
if not _is_blank_param_raw(raw_value):
|
|
return _coerce_param_value(raw_value, safe_type)
|
|
|
|
default_raw_value = default_values.get(key)
|
|
if not _is_blank_param_raw(default_raw_value):
|
|
return _coerce_param_value(default_raw_value, safe_type)
|
|
|
|
return _safe_params_get_bool(key)
|
|
|
|
raw_value = _safe_params_get_live_raw(key)
|
|
if not _is_blank_param_raw(raw_value):
|
|
return _coerce_param_value(raw_value, safe_type)
|
|
|
|
stock_key = f"{key}Stock"
|
|
if stock_key in default_values:
|
|
stock_raw_value = _safe_params_get_live_raw(stock_key)
|
|
if not _is_blank_param_raw(stock_raw_value):
|
|
return _coerce_param_value(stock_raw_value, safe_type)
|
|
|
|
default_raw_value = default_values.get(key)
|
|
if not _is_blank_param_raw(default_raw_value):
|
|
return _coerce_param_value(default_raw_value, safe_type)
|
|
|
|
return _coerce_param_value(raw_value, safe_type)
|
|
|
|
def _resolve_troubleshoot_default_value(key, value_type, default_values):
|
|
safe_type = value_type or str
|
|
default_raw_value = default_values.get(key)
|
|
if not _is_blank_param_raw(default_raw_value):
|
|
return _coerce_param_value(default_raw_value, safe_type)
|
|
|
|
stock_key = f"{key}Stock"
|
|
if stock_key in default_values:
|
|
stock_current_raw = _safe_params_get_live_raw(stock_key)
|
|
if not _is_blank_param_raw(stock_current_raw):
|
|
return _coerce_param_value(stock_current_raw, safe_type)
|
|
|
|
stock_default_raw = default_values.get(stock_key)
|
|
if not _is_blank_param_raw(stock_default_raw):
|
|
return _coerce_param_value(stock_default_raw, safe_type)
|
|
|
|
return _coerce_param_value(default_raw_value, safe_type)
|
|
|
|
def _normalize_troubleshoot_current_display_value(key, current_value, default_value):
|
|
if key != "SteerDelay":
|
|
return current_value
|
|
|
|
try:
|
|
full_current_delay = full_lateral_delay(float(current_value))
|
|
numeric_default = float(default_value)
|
|
except (TypeError, ValueError):
|
|
return current_value
|
|
|
|
if math.isfinite(full_current_delay) and math.isfinite(numeric_default) and math.isclose(full_current_delay, numeric_default, abs_tol=1e-6):
|
|
return default_value
|
|
return current_value
|
|
|
|
def _normalize_live_delay_status(status):
|
|
status_text = str(status or "").strip().lower()
|
|
if status_text in {"estimated", "unestimated", "invalid"}:
|
|
return status_text
|
|
|
|
try:
|
|
if status == log.LiveDelayData.Status.estimated:
|
|
return "estimated"
|
|
if status == log.LiveDelayData.Status.unestimated:
|
|
return "unestimated"
|
|
if status == log.LiveDelayData.Status.invalid:
|
|
return "invalid"
|
|
except Exception:
|
|
pass
|
|
|
|
return status_text
|
|
|
|
def _get_steer_delay_learned_text():
|
|
live_delay_bytes = _safe_params_get_live_raw("LiveDelay")
|
|
if not live_delay_bytes:
|
|
return "Unavailable"
|
|
|
|
current_cp_bytes = _safe_params_get_live_raw("CarParamsPersistent")
|
|
previous_cp_bytes = _safe_params_get_live_raw("CarParamsPrevRoute")
|
|
if current_cp_bytes and previous_cp_bytes:
|
|
try:
|
|
with car.CarParams.from_bytes(current_cp_bytes) as current_cp, car.CarParams.from_bytes(previous_cp_bytes) as previous_cp:
|
|
current_fingerprint = str(getattr(current_cp, "carFingerprint", "") or "")
|
|
previous_fingerprint = str(getattr(previous_cp, "carFingerprint", "") or "")
|
|
if current_fingerprint and previous_fingerprint and current_fingerprint != previous_fingerprint:
|
|
return "Unavailable"
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
live_delay = messaging.log_from_bytes(live_delay_bytes, log.Event).liveDelay
|
|
except Exception:
|
|
return "Unavailable"
|
|
|
|
estimate = _safe_float(getattr(live_delay, "lateralDelayEstimate", 0.0), 0.0)
|
|
cal_perc = int(max(0, min(100, _safe_float(getattr(live_delay, "calPerc", 0), 0))))
|
|
status = _normalize_live_delay_status(getattr(live_delay, "status", ""))
|
|
|
|
if status == "estimated":
|
|
return f"Complete ({estimate:.2f}s)"
|
|
if status == "invalid":
|
|
return f"Invalid ({estimate:.2f}s)"
|
|
if status == "unestimated":
|
|
return f"Learning {cal_perc}% ({estimate:.2f}s)"
|
|
|
|
return f"{estimate:.2f}s"
|
|
|
|
def _get_troubleshoot_learned_values():
|
|
return {
|
|
"SteerDelay": _get_steer_delay_learned_text(),
|
|
}
|
|
|
|
def _get_safety_snapshot_text():
|
|
cp_bytes = params.get("CarParamsPersistent")
|
|
if not cp_bytes:
|
|
return "Unavailable"
|
|
|
|
try:
|
|
with car.CarParams.from_bytes(cp_bytes) as cp:
|
|
safety_configs = list(getattr(cp, "safetyConfigs", []))
|
|
if not safety_configs:
|
|
return "Unavailable"
|
|
|
|
entries = []
|
|
for config in safety_configs:
|
|
model = str(getattr(config, "safetyModel", "unknown"))
|
|
safety_param = int(getattr(config, "safetyParam", 0))
|
|
entries.append(f"{model} ({safety_param} / 0x{safety_param:X})")
|
|
|
|
return ", ".join(entries) if entries else "Unavailable"
|
|
except Exception:
|
|
return "Unavailable"
|
|
|
|
def _get_fingerprint_snapshot_text():
|
|
cp_bytes = params.get("CarParamsPersistent")
|
|
cp_fingerprint = ""
|
|
try:
|
|
if cp_bytes:
|
|
with car.CarParams.from_bytes(cp_bytes) as cp:
|
|
cp_fingerprint = str(getattr(cp, "carFingerprint", "") or "").strip()
|
|
except Exception:
|
|
cp_fingerprint = ""
|
|
|
|
model_name = str(params.get("CarModelName", encoding="utf-8") or "").strip()
|
|
model_value = str(params.get("CarModel", encoding="utf-8") or "").strip()
|
|
|
|
if model_name and model_value:
|
|
return f"{model_name} ({model_value})"
|
|
if model_name:
|
|
return model_name
|
|
if model_value:
|
|
return model_value
|
|
if cp_fingerprint:
|
|
return cp_fingerprint
|
|
return "Unknown"
|
|
|
|
def _snapshot_bool_text(value):
|
|
if value is True:
|
|
return "Yes"
|
|
if value is False:
|
|
return "No"
|
|
return "Unavailable"
|
|
|
|
def _build_vehicle_fault_status():
|
|
unavailable_items = [
|
|
{"label": "Cruise Fault", "value": "Unavailable", "severity": "neutral"},
|
|
{"label": "LKAS Fault", "value": "Unavailable", "severity": "neutral"},
|
|
{"label": "CAN Valid", "value": "Unavailable", "severity": "neutral"},
|
|
{"label": "Cruise Available", "value": "Unavailable", "severity": "neutral"},
|
|
{"label": "Cruise Engaged", "value": "Unavailable", "severity": "neutral"},
|
|
]
|
|
|
|
is_onroad = params.get_bool("IsOnroad")
|
|
unavailable_summary = "Vehicle fault status is unavailable while offroad."
|
|
unavailable_severity = "neutral"
|
|
if is_onroad:
|
|
unavailable_summary = "Waiting for live vehicle fault status..."
|
|
unavailable_severity = "warn"
|
|
|
|
try:
|
|
sm = messaging.SubMaster(["carState"], poll="carState")
|
|
sm.update(100)
|
|
has_live_car_state = sm.seen["carState"] and sm.alive["carState"] and sm.valid["carState"]
|
|
if not has_live_car_state:
|
|
return {
|
|
"available": False,
|
|
"summary": unavailable_summary,
|
|
"summarySeverity": unavailable_severity,
|
|
"items": unavailable_items,
|
|
}
|
|
|
|
car_state = sm["carState"]
|
|
cruise_state = getattr(car_state, "cruiseState", None)
|
|
|
|
cruise_faulted = bool(getattr(car_state, "accFaulted", False))
|
|
steer_fault_temporary = bool(getattr(car_state, "steerFaultTemporary", False))
|
|
steer_fault_permanent = bool(getattr(car_state, "steerFaultPermanent", False))
|
|
can_valid = bool(getattr(car_state, "canValid", False))
|
|
cruise_available = bool(getattr(cruise_state, "available", False)) if cruise_state is not None else None
|
|
cruise_enabled = bool(getattr(cruise_state, "enabled", False)) if cruise_state is not None else None
|
|
|
|
if steer_fault_permanent:
|
|
lkas_fault_value = "Permanent"
|
|
lkas_fault_severity = "fault"
|
|
elif steer_fault_temporary:
|
|
lkas_fault_value = "Temporary"
|
|
lkas_fault_severity = "warn"
|
|
else:
|
|
lkas_fault_value = "Clear"
|
|
lkas_fault_severity = "ok"
|
|
|
|
active_statuses = []
|
|
if cruise_faulted:
|
|
active_statuses.append("cruise fault")
|
|
if steer_fault_permanent:
|
|
active_statuses.append("permanent LKAS fault")
|
|
elif steer_fault_temporary:
|
|
active_statuses.append("temporary LKAS fault")
|
|
if not can_valid:
|
|
active_statuses.append("CAN invalid")
|
|
|
|
if active_statuses:
|
|
summary = "Active status: " + ", ".join(active_statuses) + "."
|
|
summary_severity = "fault" if ("cruise fault" in active_statuses or "permanent LKAS fault" in active_statuses or "CAN invalid" in active_statuses) else "warn"
|
|
else:
|
|
summary = "No active cruise or LKAS faults detected."
|
|
summary_severity = "ok"
|
|
|
|
return {
|
|
"available": True,
|
|
"summary": summary,
|
|
"summarySeverity": summary_severity,
|
|
"items": [
|
|
{"label": "Cruise Fault", "value": "Faulted" if cruise_faulted else "Clear", "severity": "fault" if cruise_faulted else "ok"},
|
|
{"label": "LKAS Fault", "value": lkas_fault_value, "severity": lkas_fault_severity},
|
|
{"label": "CAN Valid", "value": "Yes" if can_valid else "No", "severity": "ok" if can_valid else "fault"},
|
|
{"label": "Cruise Available", "value": "Yes" if cruise_available else "No", "severity": "ok" if cruise_available else "neutral"},
|
|
{"label": "Cruise Engaged", "value": "Yes" if cruise_enabled else "No", "severity": "ok" if cruise_enabled else "neutral"},
|
|
],
|
|
}
|
|
except Exception:
|
|
return {
|
|
"available": False,
|
|
"summary": unavailable_summary,
|
|
"summarySeverity": unavailable_severity,
|
|
"items": unavailable_items,
|
|
}
|
|
|
|
def _get_starpilot_toggles_snapshot():
|
|
raw_toggles = _safe_params_get_live_raw("StarPilotToggles")
|
|
if not raw_toggles:
|
|
return {}
|
|
|
|
try:
|
|
if isinstance(raw_toggles, bytes):
|
|
raw_toggles = raw_toggles.decode("utf-8", errors="replace")
|
|
parsed = json.loads(str(raw_toggles) or "{}")
|
|
return parsed if isinstance(parsed, dict) else {}
|
|
except Exception:
|
|
return {}
|
|
|
|
def _get_has_radar():
|
|
cp_bytes = _safe_params_get_live_raw("CarParamsPersistent")
|
|
if not cp_bytes:
|
|
return False
|
|
|
|
try:
|
|
with car.CarParams.from_bytes(cp_bytes) as cp:
|
|
return not bool(getattr(cp, "radarUnavailable", False))
|
|
except Exception:
|
|
return False
|
|
|
|
def _get_hardware_snapshot_items():
|
|
starpilot_toggles = _get_starpilot_toggles_snapshot()
|
|
|
|
has_bsm = None
|
|
has_openpilot_longitudinal = None
|
|
has_pedal = False
|
|
has_sascm = bool(starpilot_toggles.get("has_sascm", False))
|
|
has_radar = None
|
|
has_sdsu = bool(starpilot_toggles.get("has_sdsu", False))
|
|
has_sng = None
|
|
has_zss = bool(starpilot_toggles.get("has_zss", False))
|
|
can_use_pedal = None
|
|
can_use_sdsu = None
|
|
is_bolt = False
|
|
|
|
cp_bytes = _safe_params_get_live_raw("CarParamsPersistent")
|
|
if cp_bytes:
|
|
try:
|
|
with car.CarParams.from_bytes(cp_bytes) as cp:
|
|
car_fingerprint = str(getattr(cp, "carFingerprint", "") or "")
|
|
car_make = str(getattr(cp, "brand", "") or getattr(cp, "carName", "") or "")
|
|
|
|
has_bsm = bool(getattr(cp, "enableBsm", False))
|
|
has_openpilot_longitudinal = bool(getattr(cp, "openpilotLongitudinalControl", False))
|
|
has_pedal = bool(getattr(cp, "enableGasInterceptorDEPRECATED", False))
|
|
has_sascm = car_make == "gm" and bool(getattr(cp, "flags", 0) & GMFlags.SASCM.value)
|
|
has_radar = not bool(getattr(cp, "radarUnavailable", False))
|
|
has_sdsu = bool(starpilot_toggles.get("has_sdsu", has_sdsu))
|
|
has_sng = bool(getattr(cp, "autoResumeSng", False))
|
|
has_zss = bool(starpilot_toggles.get("has_zss", has_zss))
|
|
is_bolt = car_fingerprint.startswith("CHEVROLET_BOLT")
|
|
|
|
if PC and (car_make == "mock" or car_fingerprint == "MOCK"):
|
|
fallback_make = str(starpilot_toggles.get("car_make", "") or "gm")
|
|
fallback_model = str(starpilot_toggles.get("car_model", "") or "CHEVROLET_BOLT_ACC_2022_2023")
|
|
has_pedal = bool(starpilot_toggles.get("has_pedal", True))
|
|
has_sascm = bool(starpilot_toggles.get("has_sascm", has_sascm))
|
|
has_sdsu = bool(starpilot_toggles.get("has_sdsu", has_sdsu))
|
|
has_zss = bool(starpilot_toggles.get("has_zss", has_zss))
|
|
is_bolt = fallback_model.startswith("CHEVROLET_BOLT")
|
|
if fallback_make == "gm" and has_openpilot_longitudinal is None:
|
|
has_openpilot_longitudinal = True
|
|
except Exception:
|
|
pass
|
|
elif PC:
|
|
fallback_model = str(starpilot_toggles.get("car_model", "") or "CHEVROLET_BOLT_ACC_2022_2023")
|
|
has_pedal = bool(starpilot_toggles.get("has_pedal", True))
|
|
has_sascm = bool(starpilot_toggles.get("has_sascm", has_sascm))
|
|
has_sdsu = bool(starpilot_toggles.get("has_sdsu", has_sdsu))
|
|
has_zss = bool(starpilot_toggles.get("has_zss", has_zss))
|
|
is_bolt = fallback_model.startswith("CHEVROLET_BOLT")
|
|
|
|
if can_use_pedal is None:
|
|
can_use_pedal = has_pedal or is_bolt
|
|
if can_use_sdsu is None:
|
|
can_use_sdsu = has_sdsu
|
|
|
|
fpcp_bytes = _safe_params_get_live_raw("StarPilotCarParamsPersistent")
|
|
if fpcp_bytes:
|
|
try:
|
|
fpcp = messaging.log_from_bytes(fpcp_bytes, custom.StarPilotCarParams)
|
|
fpcp_flags = int(getattr(fpcp, "flags", 0))
|
|
has_sdsu = bool(fpcp_flags & ToyotaStarPilotFlags.SMART_DSU.value)
|
|
has_zss = bool(fpcp_flags & ToyotaStarPilotFlags.ZSS.value)
|
|
can_use_pedal = bool(getattr(fpcp, "canUsePedal", can_use_pedal))
|
|
can_use_sdsu = bool(getattr(fpcp, "canUseSDSU", can_use_sdsu))
|
|
except Exception:
|
|
pass
|
|
|
|
detected = []
|
|
if has_pedal:
|
|
detected.append("comma Pedal")
|
|
if has_sascm:
|
|
detected.append("SASCM")
|
|
if has_sdsu:
|
|
detected.append("SDSU")
|
|
if has_zss:
|
|
detected.append("ZSS")
|
|
|
|
return [
|
|
{"id": "hardware_detected", "label": "Hardware Detected", "value": ", ".join(detected) if detected else "None", "resettable": False},
|
|
{"id": "pedal_detected", "label": "Pedal Detected", "value": _snapshot_bool_text(has_pedal), "resettable": False},
|
|
{"id": "sascm_detected", "label": "SASCM Detected", "value": _snapshot_bool_text(has_sascm), "resettable": False},
|
|
{"id": "sdsu_detected", "label": "SDSU Detected", "value": _snapshot_bool_text(has_sdsu), "resettable": False},
|
|
{"id": "zss_detected", "label": "ZSS Detected", "value": _snapshot_bool_text(has_zss), "resettable": False},
|
|
{"id": "blind_spot_support", "label": "Blind Spot Support", "value": _snapshot_bool_text(has_bsm), "resettable": False},
|
|
{"id": "openpilot_longitudinal_support", "label": "openpilot Longitudinal Support", "value": _snapshot_bool_text(has_openpilot_longitudinal), "resettable": False},
|
|
{"id": "pedal_support", "label": "comma Pedal Support", "value": _snapshot_bool_text(can_use_pedal), "resettable": False},
|
|
{"id": "radar_support", "label": "Radar Support", "value": _snapshot_bool_text(has_radar), "resettable": False},
|
|
{"id": "sdsu_support", "label": "SDSU Support", "value": _snapshot_bool_text(can_use_sdsu), "resettable": False},
|
|
{"id": "sng_support", "label": "Stop-and-Go Support", "value": _snapshot_bool_text(has_sng), "resettable": False},
|
|
]
|
|
|
|
def _build_troubleshoot_section_payload(section_definition, value_types, default_values, layout_metadata, learned_values):
|
|
section_keys = [str(key).strip() for key in section_definition.get("keys", []) if str(key).strip()]
|
|
items = []
|
|
|
|
for key in section_keys:
|
|
param_metadata = layout_metadata.get(key, {}) if isinstance(layout_metadata.get(key, {}), dict) else {}
|
|
value_type = value_types.get(key, str)
|
|
data_type = str(param_metadata.get("data_type") or "").strip().lower()
|
|
if data_type == "float":
|
|
value_type = float
|
|
elif data_type == "int":
|
|
value_type = int
|
|
elif data_type == "bool":
|
|
value_type = bool
|
|
|
|
label = str(param_metadata.get("label") or key)
|
|
try:
|
|
current_value = _resolve_troubleshoot_current_value(key, value_type, default_values)
|
|
default_value = _resolve_troubleshoot_default_value(key, value_type, default_values)
|
|
current_value = _normalize_troubleshoot_current_display_value(key, current_value, default_value)
|
|
except Exception:
|
|
current_value = "Unavailable"
|
|
default_value = "n/a"
|
|
|
|
items.append({
|
|
"key": key,
|
|
"label": label,
|
|
"value": _sanitize_json_value(current_value),
|
|
"defaultValue": _sanitize_json_value(default_value),
|
|
"learnedValue": _sanitize_json_value(learned_values.get(key)),
|
|
})
|
|
|
|
return {
|
|
"id": section_definition["id"],
|
|
"title": section_definition["title"],
|
|
"resettable": True,
|
|
"hasLearnedColumn": any(item.get("learnedValue") not in (None, "") for item in items),
|
|
"items": items,
|
|
}
|
|
|
|
def _build_troubleshoot_payload():
|
|
_, value_types = _get_param_type_info()
|
|
default_values = _get_default_param_values()
|
|
learned_values = _get_troubleshoot_learned_values()
|
|
layout_metadata = _get_layout_param_metadata()
|
|
|
|
longitudinal_personality_raw = _safe_params_get("LongitudinalPersonality", encoding="utf-8", default="") or ""
|
|
snapshot_items = [
|
|
{
|
|
"id": "safety_param",
|
|
"label": "Safety Param",
|
|
"value": _get_safety_snapshot_text(),
|
|
"resettable": False,
|
|
},
|
|
{
|
|
"id": "fingerprint",
|
|
"label": "Fingerprint",
|
|
"value": _get_fingerprint_snapshot_text(),
|
|
"resettable": False,
|
|
},
|
|
{
|
|
"id": "lan_ip",
|
|
"label": "LAN IP",
|
|
"value": utilities.get_current_lan_ip() or "Unavailable",
|
|
"resettable": False,
|
|
},
|
|
*_get_hardware_snapshot_items(),
|
|
{
|
|
"id": "driving_model",
|
|
"label": "Current Driving Model",
|
|
"value": str(_safe_params_get("Model", encoding="utf-8", default="") or "Unknown"),
|
|
"resettable": False,
|
|
},
|
|
{
|
|
"id": "selected_personality_profile",
|
|
"label": "Selected Personality Profile",
|
|
"value": _format_longitudinal_personality(longitudinal_personality_raw),
|
|
"resettable": False,
|
|
},
|
|
]
|
|
|
|
sections = [
|
|
_build_troubleshoot_section_payload(section_definition, value_types, default_values, layout_metadata, learned_values)
|
|
for section_definition in _TROUBLESHOOT_SECTION_DEFINITIONS
|
|
]
|
|
|
|
return _sanitize_json_value({
|
|
"vehicleStatus": _build_vehicle_fault_status(),
|
|
"snapshot": snapshot_items,
|
|
"sections": sections,
|
|
"isOnroad": params.get_bool("IsOnroad"),
|
|
})
|
|
|
|
def _reset_troubleshoot_section(section_id):
|
|
section_definition = _TROUBLESHOOT_SECTION_BY_ID.get(str(section_id or "").strip())
|
|
if section_definition is None:
|
|
raise ValueError("Unknown troubleshoot section.")
|
|
|
|
allowed_keys, _ = _get_param_type_info()
|
|
default_values = _get_default_param_values()
|
|
is_onroad = params.get_bool("IsOnroad")
|
|
blocked_onroad_keys = {"Model", "AlwaysOnLateral", "ForceTorqueController", "NNFF", "NNFFLite"}
|
|
|
|
updated_keys = []
|
|
skipped_keys = []
|
|
|
|
for key in section_definition.get("keys", []):
|
|
if key in _TROUBLESHOOT_NON_RESETTABLE_SECTION_KEYS:
|
|
skipped_keys.append({"key": key, "reason": "preserved by design"})
|
|
continue
|
|
|
|
if key not in allowed_keys:
|
|
skipped_keys.append({"key": key, "reason": "not editable"})
|
|
continue
|
|
|
|
if is_onroad and key in blocked_onroad_keys:
|
|
skipped_keys.append({"key": key, "reason": "blocked while onroad"})
|
|
continue
|
|
|
|
if key not in default_values:
|
|
skipped_keys.append({"key": key, "reason": "default unavailable"})
|
|
continue
|
|
|
|
params.put(key, _serialize_param_write_value(default_values[key]))
|
|
updated_keys.append(key)
|
|
|
|
if updated_keys:
|
|
update_starpilot_toggles()
|
|
|
|
return {
|
|
"sectionId": section_definition["id"],
|
|
"sectionTitle": section_definition["title"],
|
|
"updatedKeys": updated_keys,
|
|
"skippedKeys": skipped_keys,
|
|
"updatedCount": len(updated_keys),
|
|
"skippedCount": len(skipped_keys),
|
|
}
|
|
|
|
def _extract_testing_ground_variant_labels(slot_data, include_default=True):
|
|
labels = {}
|
|
if not isinstance(slot_data, dict):
|
|
slot_data = {}
|
|
|
|
raw_variant_labels = slot_data.get("variantLabels")
|
|
if isinstance(raw_variant_labels, dict):
|
|
for raw_variant, raw_label in raw_variant_labels.items():
|
|
variant = str(raw_variant or "").strip().upper()
|
|
label = str(raw_label or "").strip()
|
|
if len(variant) == 1 and variant.isalpha() and label:
|
|
labels[variant] = label
|
|
|
|
for key, value in slot_data.items():
|
|
if not isinstance(key, str) or not key.endswith("Label"):
|
|
continue
|
|
variant = key[:-5].strip().upper()
|
|
if len(variant) != 1 or not variant.isalpha():
|
|
continue
|
|
label = str(value or "").strip()
|
|
if label:
|
|
labels[variant] = label
|
|
|
|
if include_default and _TESTING_GROUNDS_DEFAULT_VARIANT not in labels:
|
|
labels[_TESTING_GROUNDS_DEFAULT_VARIANT] = _TESTING_GROUNDS_DEFAULT_VARIANT
|
|
|
|
return dict(sorted(labels.items()))
|
|
|
|
def _get_testing_ground_variant_labels(slot_id, slot=None):
|
|
normalized_slot_id = str(slot_id or "").strip()
|
|
labels = {}
|
|
|
|
shared_labels = _TESTING_GROUNDS_VARIANT_LABELS_BY_SLOT.get(normalized_slot_id, {})
|
|
if shared_labels:
|
|
labels.update({
|
|
str(variant or "").strip().upper(): str(label or "").strip()
|
|
for variant, label in shared_labels.items()
|
|
if len(str(variant or "").strip().upper()) == 1 and str(variant or "").strip().upper().isalpha() and str(label or "").strip()
|
|
})
|
|
else:
|
|
labels.update(_extract_testing_ground_variant_labels(slot if isinstance(slot, dict) else {}, include_default=False))
|
|
|
|
if _TESTING_GROUNDS_DEFAULT_VARIANT not in labels:
|
|
labels[_TESTING_GROUNDS_DEFAULT_VARIANT] = _TESTING_GROUNDS_DEFAULT_VARIANT
|
|
|
|
return dict(sorted(labels.items()))
|
|
|
|
def _normalize_testing_ground_variant(slot_id, variant, slot=None):
|
|
allowed_variants = set(_get_testing_ground_variant_labels(slot_id, slot).keys()) or set(_TESTING_GROUNDS_VARIANTS)
|
|
normalized_variant = str(variant or "").strip().upper()
|
|
return normalized_variant if normalized_variant in allowed_variants else _TESTING_GROUNDS_DEFAULT_VARIANT
|
|
|
|
def _set_testing_ground_variant_fields(slot, variant_labels):
|
|
for key in list(slot.keys()):
|
|
if not isinstance(key, str) or not key.endswith("Label"):
|
|
continue
|
|
variant = key[:-5].strip()
|
|
if len(variant) == 1 and variant.isalpha():
|
|
slot.pop(key, None)
|
|
|
|
slot["variantLabels"] = variant_labels
|
|
for variant, label in variant_labels.items():
|
|
slot[f"{variant.lower()}Label"] = label
|
|
|
|
return slot
|
|
|
|
def _get_first_selectable_testing_ground_slot_id(slots):
|
|
for slot in slots:
|
|
if _is_unused_testing_ground_slot(slot):
|
|
continue
|
|
|
|
slot_id = str(slot.get("id") or "").strip()
|
|
if slot_id:
|
|
return slot_id
|
|
|
|
return "1"
|
|
|
|
def _build_testing_ground_fallback_slots():
|
|
definitions_by_id = {}
|
|
|
|
for definition in _TESTING_GROUNDS_SLOT_DEFINITIONS:
|
|
if not isinstance(definition, dict):
|
|
continue
|
|
|
|
slot_id = str(definition.get("id") or "").strip()
|
|
if not slot_id:
|
|
continue
|
|
|
|
variant_labels = _get_testing_ground_variant_labels(slot_id, definition)
|
|
slot = {
|
|
"id": slot_id,
|
|
"name": str(definition.get("name") or "Unused").strip() or "Unused",
|
|
"description": str(definition.get("description") or "").strip(),
|
|
}
|
|
definitions_by_id[slot_id] = _set_testing_ground_variant_fields(slot, variant_labels)
|
|
|
|
slots = []
|
|
for slot_number in range(1, _TESTING_GROUNDS_SLOT_COUNT + 1):
|
|
slot_id = str(slot_number)
|
|
fallback_slot = definitions_by_id.get(slot_id, {
|
|
"id": slot_id,
|
|
"name": "Unused",
|
|
"description": "",
|
|
})
|
|
slot = dict(fallback_slot)
|
|
slot_variant_labels = _get_testing_ground_variant_labels(slot_id, slot)
|
|
slots.append(_set_testing_ground_variant_fields(slot, slot_variant_labels))
|
|
|
|
return slots
|
|
|
|
def _default_testing_grounds_state():
|
|
slots = _build_testing_ground_fallback_slots()
|
|
return {
|
|
"schemaVersion": _TESTING_GROUNDS_SCHEMA_VERSION,
|
|
"activeSlot": _get_first_selectable_testing_ground_slot_id(slots),
|
|
"activeVariant": _TESTING_GROUNDS_DEFAULT_VARIANT,
|
|
"slots": slots,
|
|
}
|
|
|
|
def _normalize_testing_ground_slot(raw_slot, fallback_slot):
|
|
slot = dict(fallback_slot)
|
|
if not isinstance(raw_slot, dict):
|
|
return slot
|
|
|
|
# Slot metadata (name/description) should always come from shared definitions.
|
|
# Persisted state only owns active selection and per-slot variant labels.
|
|
slot["name"] = str(slot.get("name") or "Unused").strip() or "Unused"
|
|
slot["description"] = str(slot.get("description") or "").strip()
|
|
|
|
variant_labels = _get_testing_ground_variant_labels(slot.get("id"), raw_slot)
|
|
if not variant_labels:
|
|
variant_labels = _get_testing_ground_variant_labels(slot.get("id"), slot)
|
|
return _set_testing_ground_variant_fields(slot, variant_labels)
|
|
|
|
def _load_testing_grounds_state_unlocked():
|
|
state = _default_testing_grounds_state()
|
|
fallback_slots = state["slots"]
|
|
fallback_slot_ids = {slot["id"] for slot in fallback_slots}
|
|
needs_write = False
|
|
|
|
raw_state = {}
|
|
try:
|
|
raw_state = json.loads(_TESTING_GROUNDS_STATE_PATH.read_text(encoding="utf-8"))
|
|
if not isinstance(raw_state, dict):
|
|
raw_state = {}
|
|
needs_write = True
|
|
except FileNotFoundError:
|
|
needs_write = True
|
|
except Exception:
|
|
needs_write = True
|
|
|
|
if raw_state.get("schemaVersion") != _TESTING_GROUNDS_SCHEMA_VERSION:
|
|
needs_write = True
|
|
|
|
raw_slots = raw_state.get("slots")
|
|
if isinstance(raw_slots, list):
|
|
raw_by_id = {}
|
|
for index, raw_slot in enumerate(raw_slots, start=1):
|
|
if not isinstance(raw_slot, dict):
|
|
needs_write = True
|
|
continue
|
|
|
|
slot_id = str(raw_slot.get("id") or "").strip() or str(index)
|
|
if slot_id not in fallback_slot_ids:
|
|
needs_write = True
|
|
continue
|
|
|
|
raw_by_id[slot_id] = raw_slot
|
|
|
|
normalized_slots = []
|
|
for fallback_slot in fallback_slots:
|
|
slot_id = fallback_slot["id"]
|
|
normalized_slots.append(_normalize_testing_ground_slot(raw_by_id.get(slot_id), fallback_slot))
|
|
if slot_id not in raw_by_id:
|
|
needs_write = True
|
|
|
|
state["slots"] = normalized_slots
|
|
else:
|
|
needs_write = True
|
|
|
|
selectable_slot_ids = {
|
|
str(slot.get("id") or "").strip()
|
|
for slot in state["slots"]
|
|
if not _is_unused_testing_ground_slot(slot)
|
|
}
|
|
default_slot_id = _get_first_selectable_testing_ground_slot_id(state["slots"])
|
|
active_slot = str(raw_state.get("activeSlot") or "").strip()
|
|
active_slot_migrated = active_slot not in fallback_slot_ids or active_slot not in selectable_slot_ids
|
|
if active_slot_migrated:
|
|
active_slot = default_slot_id
|
|
needs_write = True
|
|
state["activeSlot"] = active_slot
|
|
|
|
active_slot_data = _find_testing_ground_slot(state, active_slot)
|
|
raw_active_variant = str(raw_state.get("activeVariant") or "").strip().upper()
|
|
if active_slot_migrated:
|
|
active_variant = _TESTING_GROUNDS_DEFAULT_VARIANT
|
|
else:
|
|
active_variant = _normalize_testing_ground_variant(active_slot, raw_active_variant, active_slot_data)
|
|
if raw_active_variant != active_variant:
|
|
needs_write = True
|
|
state["activeVariant"] = active_variant
|
|
|
|
return state, needs_write
|
|
|
|
def _write_testing_grounds_state_unlocked(state):
|
|
_TESTING_GROUNDS_STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp_path = _TESTING_GROUNDS_STATE_PATH.with_name(f".tmp_{_TESTING_GROUNDS_STATE_PATH.name}")
|
|
tmp_path.write_text(json.dumps(state, indent=2), encoding="utf-8")
|
|
os.replace(tmp_path, _TESTING_GROUNDS_STATE_PATH)
|
|
|
|
def _get_testing_grounds_state():
|
|
with _TESTING_GROUNDS_LOCK:
|
|
state, needs_write = _load_testing_grounds_state_unlocked()
|
|
if needs_write:
|
|
try:
|
|
_write_testing_grounds_state_unlocked(state)
|
|
except Exception:
|
|
pass
|
|
return state
|
|
|
|
def _is_unused_testing_ground_slot(slot):
|
|
name = str(slot.get("name") or "").strip().lower()
|
|
return name == "unused" or name.startswith("unused ")
|
|
|
|
def _find_testing_ground_slot(state, slot_id):
|
|
for slot in state.get("slots", []):
|
|
if str(slot.get("id") or "").strip() == slot_id:
|
|
return slot
|
|
return {}
|
|
|
|
def _serialize_testing_grounds_state(state):
|
|
slots = state.get("slots", [])
|
|
active_slot_id = str(state.get("activeSlot") or "").strip()
|
|
active_slot = _find_testing_ground_slot(state, active_slot_id)
|
|
active_variant = _normalize_testing_ground_variant(active_slot_id, state.get("activeVariant"), active_slot)
|
|
active_variant_labels = _get_testing_ground_variant_labels(active_slot_id, active_slot)
|
|
|
|
return {
|
|
"schemaVersion": state.get("schemaVersion", _TESTING_GROUNDS_SCHEMA_VERSION),
|
|
"activeSlot": active_slot_id,
|
|
"activeVariant": active_variant,
|
|
"activeVariantLabel": active_variant_labels.get(active_variant, active_variant),
|
|
"activeSlotName": active_slot.get("name", active_slot_id),
|
|
"slots": slots,
|
|
"slotSummaryLines": [f"{slot.get('id', '?')}. {slot.get('name', 'Unused')}" for slot in slots],
|
|
"selectableSlots": [slot for slot in slots if not _is_unused_testing_ground_slot(slot)],
|
|
}
|
|
|
|
def _get_testing_ground_custom_reserved_pm():
|
|
global _TESTING_GROUND_CUSTOM_RESERVED_PM
|
|
|
|
with _TESTING_GROUND_CUSTOM_RESERVED_LOCK:
|
|
if _TESTING_GROUND_CUSTOM_RESERVED_PM is None:
|
|
_TESTING_GROUND_CUSTOM_RESERVED_PM = messaging.PubMaster([_TESTING_GROUND_CUSTOM_RESERVED_SERVICE])
|
|
return _TESTING_GROUND_CUSTOM_RESERVED_PM
|
|
|
|
def _build_testing_ground_custom_reserved_payload(state, reason):
|
|
serialized = _serialize_testing_grounds_state(state)
|
|
return {
|
|
"slotId": serialized["activeSlot"],
|
|
"slotName": serialized["activeSlotName"],
|
|
"variant": serialized["activeVariant"],
|
|
"variantLabel": serialized["activeVariantLabel"],
|
|
"reason": reason,
|
|
"wallTimeNanos": time.time_ns(),
|
|
}
|
|
|
|
def _publish_testing_ground_custom_reserved(state, reason):
|
|
global _TESTING_GROUND_CUSTOM_RESERVED_LAST_PUBLISH_MONO
|
|
|
|
payload = _build_testing_ground_custom_reserved_payload(state, reason)
|
|
message_valid = str(payload.get("variant") or "").strip().upper() == "B"
|
|
|
|
try:
|
|
message = messaging.new_message(_TESTING_GROUND_CUSTOM_RESERVED_SERVICE, valid=message_valid)
|
|
message.customReserved9.slotId = payload["slotId"]
|
|
message.customReserved9.slotName = payload["slotName"]
|
|
message.customReserved9.variant = payload["variant"]
|
|
message.customReserved9.variantLabel = payload["variantLabel"]
|
|
message.customReserved9.reason = payload["reason"]
|
|
message.customReserved9.wallTimeNanos = payload["wallTimeNanos"]
|
|
_get_testing_ground_custom_reserved_pm().send(_TESTING_GROUND_CUSTOM_RESERVED_SERVICE, message)
|
|
except Exception:
|
|
return
|
|
|
|
with _TESTING_GROUND_CUSTOM_RESERVED_LOCK:
|
|
_TESTING_GROUND_CUSTOM_RESERVED_LAST_PUBLISH_MONO = time.monotonic()
|
|
|
|
def _testing_ground_custom_reserved_worker():
|
|
while True:
|
|
with _TESTING_GROUND_CUSTOM_RESERVED_LOCK:
|
|
last_publish_mono = _TESTING_GROUND_CUSTOM_RESERVED_LAST_PUBLISH_MONO
|
|
|
|
sleep_s = _TESTING_GROUND_CUSTOM_RESERVED_INTERVAL_S - (time.monotonic() - last_publish_mono)
|
|
if sleep_s > 0:
|
|
time.sleep(min(sleep_s, 1.0))
|
|
continue
|
|
|
|
try:
|
|
_publish_testing_ground_custom_reserved(_get_testing_grounds_state(), "heartbeat")
|
|
except Exception:
|
|
time.sleep(1.0)
|
|
|
|
def _set_testing_ground_selection(slot_id, variant):
|
|
normalized_slot_id = str(slot_id or "").strip()
|
|
requested_variant = str(variant or "").strip().upper()
|
|
|
|
with _TESTING_GROUNDS_LOCK:
|
|
state, _ = _load_testing_grounds_state_unlocked()
|
|
previous_slot_id = str(state.get("activeSlot") or "").strip()
|
|
previous_variant = _normalize_testing_ground_variant(previous_slot_id, state.get("activeVariant"), _find_testing_ground_slot(state, previous_slot_id))
|
|
slot_ids = {slot["id"] for slot in state["slots"]}
|
|
if normalized_slot_id not in slot_ids:
|
|
raise ValueError(f"Unknown testing ground slot '{normalized_slot_id}'.")
|
|
|
|
slot = _find_testing_ground_slot(state, normalized_slot_id)
|
|
if _is_unused_testing_ground_slot(slot):
|
|
raise ValueError(f"Testing ground slot '{normalized_slot_id}' is unavailable.")
|
|
|
|
allowed_variant_labels = _get_testing_ground_variant_labels(normalized_slot_id, slot)
|
|
if requested_variant not in allowed_variant_labels:
|
|
allowed_variants = ", ".join(sorted(allowed_variant_labels.keys()))
|
|
raise ValueError(f"Variant must be one of: {allowed_variants}.")
|
|
|
|
normalized_variant = _normalize_testing_ground_variant(normalized_slot_id, requested_variant, slot)
|
|
changed = normalized_slot_id != previous_slot_id or normalized_variant != previous_variant
|
|
state["activeSlot"] = normalized_slot_id
|
|
state["activeVariant"] = normalized_variant
|
|
if changed:
|
|
_write_testing_grounds_state_unlocked(state)
|
|
return state, changed
|
|
|
|
def _default_longitudinal_maneuver_status():
|
|
return {
|
|
"state": "idle",
|
|
"phase": "",
|
|
"paddleMode": "auto",
|
|
"maneuver": "",
|
|
"runIndex": 0,
|
|
"runTotal": 0,
|
|
"stepIndex": 0,
|
|
"stepTotal": 0,
|
|
"phaseStepIndex": 0,
|
|
"phaseStepTotal": 0,
|
|
"uiShow": False,
|
|
"uiSize": "small",
|
|
"uiText1": "Long Maneuvers",
|
|
"uiText2": "",
|
|
"updatedAtSec": 0.0,
|
|
"support": {},
|
|
"caveats": [],
|
|
"skippedManeuvers": [],
|
|
"history": [],
|
|
}
|
|
|
|
def _load_longitudinal_maneuver_status():
|
|
status = _default_longitudinal_maneuver_status()
|
|
raw = params.get("LongitudinalManeuverStatus", encoding="utf-8") or ""
|
|
if raw:
|
|
try:
|
|
payload = json.loads(raw)
|
|
if isinstance(payload, dict):
|
|
status.update(payload)
|
|
except Exception:
|
|
pass
|
|
|
|
history = status.get("history")
|
|
if not isinstance(history, list):
|
|
history = []
|
|
status["history"] = [str(line) for line in history if str(line).strip()][-120:]
|
|
|
|
try:
|
|
status["updatedAtSec"] = float(status.get("updatedAtSec") or 0.0)
|
|
except Exception:
|
|
status["updatedAtSec"] = 0.0
|
|
|
|
return status
|
|
|
|
def _save_longitudinal_maneuver_status(status):
|
|
status_copy = dict(status)
|
|
history = status_copy.get("history")
|
|
if not isinstance(history, list):
|
|
history = []
|
|
status_copy["history"] = [str(line) for line in history if str(line).strip()][-120:]
|
|
status_copy["updatedAtSec"] = float(status_copy.get("updatedAtSec") or time.monotonic())
|
|
params.put("LongitudinalManeuverStatus", status_copy)
|
|
return status_copy
|
|
|
|
def _append_longitudinal_maneuver_history(status, line):
|
|
if not line:
|
|
return status
|
|
history = list(status.get("history", []))
|
|
history.append(str(line))
|
|
status["history"] = history[-120:]
|
|
return status
|
|
|
|
def _get_longitudinal_maneuver_support_snapshot():
|
|
cp_bytes = _safe_params_get_live_raw("CarParamsPersistent")
|
|
if not cp_bytes:
|
|
return None
|
|
|
|
try:
|
|
with car.CarParams.from_bytes(cp_bytes) as cp:
|
|
return get_longitudinal_maneuver_support(cp).to_dict()
|
|
except Exception:
|
|
return None
|
|
|
|
def _serialize_longitudinal_maneuver_status(status):
|
|
updated_at = _safe_float(status.get("updatedAtSec"), 0.0)
|
|
age_seconds = max(0.0, time.monotonic() - updated_at) if updated_at > 0 else None
|
|
mode_enabled = params.get_bool("LongitudinalManeuverMode")
|
|
paddle_mode = params.get("LongitudinalManeuverPaddleMode", encoding="utf-8") or str(status.get("paddleMode") or "auto")
|
|
support = _get_longitudinal_maneuver_support_snapshot() or status.get("support") or {}
|
|
caveats = support.get("caveats", status.get("caveats") or [])
|
|
skipped_maneuvers = support.get("skippedManeuvers", status.get("skippedManeuvers") or [])
|
|
return {
|
|
**status,
|
|
"modeEnabled": mode_enabled,
|
|
"paddleMode": paddle_mode,
|
|
"support": support,
|
|
"caveats": list(caveats),
|
|
"skippedManeuvers": list(skipped_maneuvers),
|
|
"isOnroad": params.get_bool("IsOnroad"),
|
|
"isEngaged": params.get_bool("IsEngaged"),
|
|
"updatedAgeSec": age_seconds,
|
|
}
|
|
|
|
def _set_longitudinal_maneuver_mode(enabled):
|
|
status = _load_longitudinal_maneuver_status()
|
|
if enabled:
|
|
params.put_bool("LongitudinalManeuverMode", True)
|
|
params.put("LongitudinalManeuverPaddleMode", "auto")
|
|
status.update({
|
|
"state": "armed",
|
|
"phase": "",
|
|
"maneuver": "",
|
|
"runIndex": 0,
|
|
"runTotal": 0,
|
|
"stepIndex": 0,
|
|
"phaseStepIndex": 0,
|
|
"uiShow": True,
|
|
"uiSize": "small",
|
|
"uiText1": "Long Maneuvers Armed",
|
|
"uiText2": "Engage with SET to start the test suite.",
|
|
"updatedAtSec": time.monotonic(),
|
|
})
|
|
_append_longitudinal_maneuver_history(status, "Armed from The Galaxy. Engage with SET to start.")
|
|
else:
|
|
params.put_bool("LongitudinalManeuverMode", False)
|
|
params.put("LongitudinalManeuverPaddleMode", "auto")
|
|
status.update({
|
|
"state": "stopped",
|
|
"uiShow": True,
|
|
"uiSize": "small",
|
|
"uiText1": "Long Maneuvers Stopped",
|
|
"uiText2": "Test mode disabled.",
|
|
"updatedAtSec": time.monotonic(),
|
|
})
|
|
_append_longitudinal_maneuver_history(status, "Stopped from The Galaxy.")
|
|
|
|
return _save_longitudinal_maneuver_status(status)
|
|
|
|
|
|
def _default_lateral_maneuver_status():
|
|
return {
|
|
"state": "idle",
|
|
"phase": "",
|
|
"maneuver": "",
|
|
"runIndex": 0,
|
|
"runTotal": 0,
|
|
"stepIndex": 0,
|
|
"stepTotal": 0,
|
|
"phaseStepIndex": 0,
|
|
"phaseStepTotal": 0,
|
|
"uiShow": False,
|
|
"uiSize": "small",
|
|
"uiText1": "Lateral Maneuvers",
|
|
"uiText2": "",
|
|
"updatedAtSec": 0.0,
|
|
"history": [],
|
|
}
|
|
|
|
|
|
def _load_lateral_maneuver_status():
|
|
status = _default_lateral_maneuver_status()
|
|
raw = params.get("LateralManeuverStatus", encoding="utf-8") or ""
|
|
if raw:
|
|
try:
|
|
payload = json.loads(raw)
|
|
if isinstance(payload, dict):
|
|
status.update(payload)
|
|
except Exception:
|
|
pass
|
|
|
|
history = status.get("history")
|
|
if not isinstance(history, list):
|
|
history = []
|
|
status["history"] = [str(line) for line in history if str(line).strip()][-120:]
|
|
|
|
try:
|
|
status["updatedAtSec"] = float(status.get("updatedAtSec") or 0.0)
|
|
except Exception:
|
|
status["updatedAtSec"] = 0.0
|
|
|
|
return status
|
|
|
|
|
|
def _save_lateral_maneuver_status(status):
|
|
status_copy = dict(status)
|
|
history = status_copy.get("history")
|
|
if not isinstance(history, list):
|
|
history = []
|
|
status_copy["history"] = [str(line) for line in history if str(line).strip()][-120:]
|
|
status_copy["updatedAtSec"] = float(status_copy.get("updatedAtSec") or time.monotonic())
|
|
params.put("LateralManeuverStatus", status_copy)
|
|
return status_copy
|
|
|
|
|
|
def _append_lateral_maneuver_history(status, line):
|
|
if not line:
|
|
return status
|
|
history = list(status.get("history", []))
|
|
history.append(str(line))
|
|
status["history"] = history[-120:]
|
|
return status
|
|
|
|
|
|
def _serialize_lateral_maneuver_status(status):
|
|
updated_at = _safe_float(status.get("updatedAtSec"), 0.0)
|
|
age_seconds = max(0.0, time.monotonic() - updated_at) if updated_at > 0 else None
|
|
return {
|
|
**status,
|
|
"modeEnabled": params.get_bool("LateralManeuverMode"),
|
|
"isOnroad": params.get_bool("IsOnroad"),
|
|
"isEngaged": params.get_bool("IsEngaged"),
|
|
"updatedAgeSec": age_seconds,
|
|
}
|
|
|
|
|
|
def _set_lateral_maneuver_mode(enabled):
|
|
status = _load_lateral_maneuver_status()
|
|
if enabled:
|
|
params.put_bool("LateralManeuverMode", True)
|
|
params.put_bool("LongitudinalManeuverMode", False)
|
|
status.update({
|
|
"state": "armed",
|
|
"phase": "",
|
|
"maneuver": "",
|
|
"runIndex": 0,
|
|
"runTotal": 0,
|
|
"stepIndex": 0,
|
|
"stepTotal": 0,
|
|
"phaseStepIndex": 0,
|
|
"phaseStepTotal": 0,
|
|
"uiShow": True,
|
|
"uiSize": "small",
|
|
"uiText1": "Lateral Maneuvers Armed",
|
|
"uiText2": "Stabilize on a straight, flat road to start.",
|
|
"updatedAtSec": time.monotonic(),
|
|
})
|
|
_append_lateral_maneuver_history(status, "Armed from The Galaxy. Stabilize on a straight, flat road to start.")
|
|
else:
|
|
params.put_bool("LateralManeuverMode", False)
|
|
status.update({
|
|
"state": "stopped",
|
|
"uiShow": True,
|
|
"uiSize": "small",
|
|
"uiText1": "Lateral Maneuvers Stopped",
|
|
"uiText2": "Test mode disabled.",
|
|
"updatedAtSec": time.monotonic(),
|
|
})
|
|
_append_lateral_maneuver_history(status, "Stopped from The Galaxy.")
|
|
|
|
return _save_lateral_maneuver_status(status)
|
|
|
|
def setup(app):
|
|
model_status_debug = {
|
|
"last_signature": None,
|
|
"last_log_time": 0.0,
|
|
"last_empty_catalog_log_time": 0.0,
|
|
}
|
|
|
|
@app.after_request
|
|
def disable_device_settings_asset_cache(response):
|
|
if request.path in {
|
|
"/assets/components/router.js",
|
|
"/assets/components/home/home.js",
|
|
"/assets/components/home/home.css",
|
|
"/assets/components/tools/device_settings.js",
|
|
"/assets/components/tools/device_settings.css",
|
|
"/assets/components/tools/device_settings_layout.json",
|
|
"/assets/components/tools/v_asm.js",
|
|
"/assets/components/tools/v_asm.css",
|
|
"/assets/components/tools/pip_sidecam.js",
|
|
"/assets/components/tools/pip_sidecam.css",
|
|
"/assets/components/tools/toggles.js",
|
|
}:
|
|
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
|
|
response.headers["Pragma"] = "no-cache"
|
|
response.headers["Expires"] = "0"
|
|
return response
|
|
|
|
@app.errorhandler(404)
|
|
def not_found(_):
|
|
response = make_response(render_template("index.html"))
|
|
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
|
|
response.headers["Pragma"] = "no-cache"
|
|
response.headers["Expires"] = "0"
|
|
return response
|
|
|
|
@app.route("/", methods=["GET"])
|
|
def index():
|
|
response = make_response(render_template("index.html"))
|
|
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
|
|
response.headers["Pragma"] = "no-cache"
|
|
response.headers["Expires"] = "0"
|
|
return response
|
|
|
|
@app.route("/manifest.json", methods=["GET"])
|
|
@app.route("/assets/manifest.json", methods=["GET"])
|
|
def manifest():
|
|
manifest_path = Path(app.static_folder) / "manifest.json"
|
|
if manifest_path.is_file():
|
|
return send_file(str(manifest_path), mimetype="application/manifest+json")
|
|
|
|
# Fallback so the browser doesn't keep logging noisy 404s.
|
|
return jsonify({
|
|
"name": "Galaxy",
|
|
"short_name": "Galaxy",
|
|
"display": "standalone",
|
|
"start_url": "/",
|
|
"background_color": "#000000",
|
|
"theme_color": "#8b6cc5",
|
|
"icons": [],
|
|
}), 200
|
|
|
|
@app.route("/api/car_features_check", methods=["GET"])
|
|
def car_features_check():
|
|
tool = request.args.get("tool")
|
|
try:
|
|
with car.CarParams.from_bytes(params.get("CarParamsPersistent")) as cp:
|
|
if tool == "doors":
|
|
return jsonify({"result": HARDWARE.get_device_type() != "tici" and cp.carName == "toyota"})
|
|
elif tool == "tsk":
|
|
return jsonify({"result": cp.secOcRequired})
|
|
except Exception:
|
|
pass
|
|
return jsonify({"result": False})
|
|
|
|
@app.route("/api/doors/lock", methods=["POST"])
|
|
def lock_doors():
|
|
can_parser = CANParser("toyota_nodsu_pt_generated", [("DOOR_LOCKS", 3)], bus=0)
|
|
can_sock = messaging.sub_sock("can", timeout=100)
|
|
|
|
while True:
|
|
with Panda(disable_checks=True) as panda:
|
|
if not params.get_bool("IsOnroad"):
|
|
panda.set_safety_mode(panda.SAFETY_TOYOTA)
|
|
panda.can_send(0x750, LOCK_CMD, 0)
|
|
|
|
time.sleep(1)
|
|
|
|
lock_status = get_lock_status(can_parser, can_sock)
|
|
if lock_status == 0:
|
|
break
|
|
|
|
return {"message": "Doors locked!"}
|
|
|
|
@app.route("/api/doors/unlock", methods=["POST"])
|
|
def unlock_doors():
|
|
can_parser = CANParser("toyota_nodsu_pt_generated", [("DOOR_LOCKS", 3)], bus=0)
|
|
can_sock = messaging.sub_sock("can", timeout=100)
|
|
|
|
while True:
|
|
with Panda(disable_checks=True) as panda:
|
|
if not params.get_bool("IsOnroad"):
|
|
panda.set_safety_mode(panda.SAFETY_TOYOTA)
|
|
panda.can_send(0x750, UNLOCK_CMD, 0)
|
|
|
|
time.sleep(1)
|
|
|
|
lock_status = get_lock_status(can_parser, can_sock)
|
|
if lock_status != 0:
|
|
break
|
|
|
|
return {"message": "Doors unlocked!"}
|
|
|
|
@app.route("/api/error_logs", methods=["GET"])
|
|
def get_error_logs():
|
|
if request.accept_mimetypes["text/html"]:
|
|
return render_template("v2/error-logs.jinja", active="error_logs")
|
|
|
|
if request.accept_mimetypes["application/json"]:
|
|
files = utilities.list_file(ERROR_LOGS_PATH)
|
|
filtered = [file for file in files if not file.startswith("error")]
|
|
return filtered, 200
|
|
|
|
@app.route("/api/error_logs/delete_all", methods=["DELETE"])
|
|
def delete_all_error_logs():
|
|
for f in os.listdir(ERROR_LOGS_PATH):
|
|
delete_file(os.path.join(ERROR_LOGS_PATH, f))
|
|
return {"message": "All error logs deleted!"}, 200
|
|
|
|
@app.route("/api/error_logs/<filename>", methods=["DELETE"])
|
|
def delete_error_log(filename):
|
|
delete_file(os.path.join(ERROR_LOGS_PATH, filename))
|
|
return {"message": "Error log deleted!"}
|
|
|
|
@app.route("/api/error_logs/<filename>", methods=["GET"])
|
|
def get_error_log(filename):
|
|
with open(os.path.join(ERROR_LOGS_PATH, filename)) as file:
|
|
return file.read(), 200, {"Content-Type": "text/plain; charset=utf-8"}
|
|
|
|
@app.route("/api/navigation", methods=["DELETE"])
|
|
def clear_navigation():
|
|
params.remove("NavDestination")
|
|
params_memory.remove("NavInstructionState")
|
|
params_memory.remove("NavInstructionCollapsed")
|
|
return {"message": "Destination cleared"}
|
|
|
|
@app.route("/api/navigation", methods=["GET"])
|
|
def navigation():
|
|
last_position = _get_navigation_last_position() or {}
|
|
|
|
return {
|
|
"amap1Key": params.get("AMapKey1", encoding="utf8") or "",
|
|
"amap2Key": params.get("AMapKey2", encoding="utf8") or "",
|
|
"destination": params.get("NavDestination", encoding="utf8") or "",
|
|
"isMetric": params.get_bool("IsMetric"),
|
|
"language": params.get("LanguageSetting", encoding="utf8") or "",
|
|
"lastPosition": {
|
|
"latitude": str(last_position.get("latitude", "")),
|
|
"longitude": str(last_position.get("longitude", ""))
|
|
},
|
|
"mapboxPublic": params.get("MapboxPublicKey", encoding="utf8") or "",
|
|
"mapboxSecret": params.get("MapboxSecretKey", encoding="utf8") or "",
|
|
"previousDestinations": params.get("ApiCache_NavDestinations", encoding="utf8") or "",
|
|
}
|
|
|
|
@app.route("/api/navigation", methods=["POST"])
|
|
def set_navigation():
|
|
destination = normalize_destination_payload(request.json)
|
|
if destination is None:
|
|
return {"message": "Invalid destination payload"}, 400
|
|
|
|
recent_destinations = update_recent_destinations(
|
|
params.get("ApiCache_NavDestinations", encoding="utf8") or "",
|
|
destination,
|
|
)
|
|
params.put("NavDestination", json.dumps(destination))
|
|
params.put("ApiCache_NavDestinations", recent_destinations)
|
|
return {"message": "Destination set"}
|
|
|
|
@app.route("/api/navigation/favorite", methods=["DELETE"])
|
|
def remove_favorite_destination():
|
|
to_remove = request.json or {}
|
|
|
|
existing = json.loads(params.get("FavoriteDestinations", encoding="utf8") or "[]")
|
|
fid = to_remove.get("id")
|
|
if fid:
|
|
favorites = [f for f in existing if f.get("id") != fid]
|
|
else:
|
|
favorites = [
|
|
f for f in existing
|
|
if not (
|
|
f.get("routeId") == to_remove.get("routeId") and
|
|
f.get("latitude") == to_remove.get("latitude") and
|
|
f.get("longitude") == to_remove.get("longitude") and
|
|
f.get("name") == to_remove.get("name")
|
|
)
|
|
]
|
|
|
|
params.put("FavoriteDestinations", favorites)
|
|
return jsonify(message="Destination removed from favorites!")
|
|
|
|
@app.route("/api/navigation/favorite", methods=["GET"])
|
|
def list_favorite_destinations():
|
|
favorites = json.loads(params.get("FavoriteDestinations", encoding="utf8") or "[]")
|
|
changed = False
|
|
for f in favorites:
|
|
if "id" not in f:
|
|
raw = f"{f.get('longitude')},{f.get('latitude')}|{f.get('routeId') or ''}|{f.get('name') or ''}"
|
|
f["id"] = hashlib.sha1(raw.encode()).hexdigest()
|
|
changed = True
|
|
if changed:
|
|
params.put("FavoriteDestinations", favorites)
|
|
return jsonify(favorites=favorites)
|
|
|
|
@app.route("/api/navigation/favorite", methods=["POST"])
|
|
def add_favorite_destination():
|
|
new_fav = request.json or {}
|
|
|
|
if "id" not in new_fav:
|
|
raw = f"{new_fav.get('longitude')},{new_fav.get('latitude')}|{new_fav.get('routeId') or ''}|{new_fav.get('name') or ''}"
|
|
new_fav["id"] = hashlib.sha1(raw.encode()).hexdigest()
|
|
|
|
existing = json.loads(params.get("FavoriteDestinations", encoding="utf8") or "[]")
|
|
if not any(f.get("id") == new_fav["id"] for f in existing):
|
|
existing.append(new_fav)
|
|
|
|
params.put("FavoriteDestinations", existing)
|
|
return {"message": "Destination added to favorites!"}
|
|
|
|
@app.route("/api/navigation/favorite/rename", methods=["POST"])
|
|
def rename_favorite_destination():
|
|
data = request.json or {}
|
|
fid = data.get("id")
|
|
route_id_to_rename = data.get("routeId")
|
|
new_name = data.get("name")
|
|
is_home = data.get("is_home")
|
|
is_work = data.get("is_work")
|
|
|
|
if not fid and not route_id_to_rename:
|
|
return jsonify({"error": "Missing id or routeId"}), 400
|
|
|
|
existing_favorites = json.loads(params.get("FavoriteDestinations", encoding="utf8") or "[]")
|
|
|
|
if is_home:
|
|
for favorite in existing_favorites:
|
|
favorite.pop("is_home", None)
|
|
if is_work:
|
|
for favorite in existing_favorites:
|
|
favorite.pop("is_work", None)
|
|
|
|
found = False
|
|
for favorite in existing_favorites:
|
|
if (fid and favorite.get("id") == fid) or (not fid and favorite.get("routeId") == route_id_to_rename):
|
|
if new_name:
|
|
favorite["name"] = new_name
|
|
|
|
if is_home is not None:
|
|
if is_home:
|
|
favorite["is_home"] = True
|
|
favorite.pop("is_work", None)
|
|
else:
|
|
favorite.pop("is_home", None)
|
|
|
|
if is_work is not None:
|
|
if is_work:
|
|
favorite["is_work"] = True
|
|
favorite.pop("is_home", None)
|
|
else:
|
|
favorite.pop("is_work", None)
|
|
|
|
found = True
|
|
break
|
|
|
|
if not found:
|
|
return jsonify({"error": "Favorite not found"}), 404
|
|
|
|
params.put("FavoriteDestinations", existing_favorites)
|
|
return jsonify(message="Favorite updated successfully!")
|
|
|
|
@app.route("/api/navigation_key", methods=["DELETE"])
|
|
def delete_navigation_key():
|
|
meta = KEYS.get(request.args.get("type"))
|
|
params.remove(meta[2])
|
|
return jsonify(message=f"{meta[3]} deleted successfully!")
|
|
|
|
@app.route("/api/navigation_key", methods=["POST"])
|
|
def set_navigation_keys():
|
|
data = request.get_json() or {}
|
|
|
|
saved = []
|
|
for meta in KEYS.values():
|
|
raw = (data.get(meta[0]) or "").strip()
|
|
if not raw:
|
|
continue
|
|
|
|
full = raw if raw.startswith(meta[1]) else meta[1] + raw
|
|
if len(full) < meta[4]:
|
|
return jsonify(error=f"{meta[3]} is invalid or too short..."), 400
|
|
|
|
params.put(meta[2], full)
|
|
saved.append(meta[3])
|
|
|
|
if not saved:
|
|
return jsonify(error="Nothing to update..."), 400
|
|
|
|
return jsonify(message=f"{', '.join(saved)} saved successfully!")
|
|
|
|
@app.route("/api/fingerprints/makes", methods=["GET"])
|
|
def get_fingerprint_makes():
|
|
return jsonify(_get_fingerprint_catalog()["makes"]), 200
|
|
|
|
@app.route("/api/fingerprints/models", methods=["GET"])
|
|
def get_fingerprint_models():
|
|
catalog = _get_fingerprint_catalog()
|
|
make_key = _normalize_fingerprint_make_key(
|
|
request.args.get("make") or params.get("CarMake", encoding="utf-8") or ""
|
|
)
|
|
|
|
models = catalog["models_by_make"].get(make_key) if make_key else catalog["all_models"]
|
|
if not models:
|
|
models = catalog["all_models"]
|
|
|
|
return jsonify(models), 200
|
|
|
|
@app.route("/api/favorites/slots", methods=["GET", "PUT"])
|
|
def favorite_slots():
|
|
options = _get_favorite_slot_options()
|
|
option_by_key = {option["key"]: option for option in options}
|
|
eligible_keys = set(option_by_key)
|
|
|
|
if request.method == "PUT":
|
|
data = request.get_json() or {}
|
|
raw_slots = data.get("slots", data) if isinstance(data, dict) else data
|
|
if isinstance(raw_slots, dict):
|
|
raw_slots = raw_slots.get("slots", [])
|
|
if not isinstance(raw_slots, list):
|
|
return jsonify(error="Favorite slots payload must be a list."), 400
|
|
|
|
for idx, raw_slot in enumerate(raw_slots[:3]):
|
|
if not isinstance(raw_slot, dict):
|
|
continue
|
|
key = str(raw_slot.get("key") or "").strip()
|
|
if key and key not in eligible_keys:
|
|
return jsonify(error=f"Favorite #{idx + 1} must use a Galaxy-exposed toggle or action."), 400
|
|
|
|
slots = normalize_favorite_slots(raw_slots, params=params, eligible_keys=eligible_keys)
|
|
|
|
for slot in slots:
|
|
key = slot.get("key")
|
|
if not key:
|
|
continue
|
|
slot["label"] = option_by_key[key]["label"]
|
|
|
|
params.put(FAVORITE_SLOTS_PARAM, slots)
|
|
update_starpilot_toggles()
|
|
return jsonify({
|
|
"message": "Favorite slots saved.",
|
|
"slots": slots,
|
|
"options": options,
|
|
"values": _favorite_slot_values(options),
|
|
}), 200
|
|
|
|
slots = normalize_favorite_slots(params.get(FAVORITE_SLOTS_PARAM), params=params, eligible_keys=eligible_keys)
|
|
for slot in slots:
|
|
key = slot.get("key")
|
|
if key in option_by_key:
|
|
slot["label"] = option_by_key[key]["label"]
|
|
|
|
return jsonify({
|
|
"slots": slots,
|
|
"options": options,
|
|
"values": _favorite_slot_values(options),
|
|
}), 200
|
|
|
|
@app.route("/api/favorites/values", methods=["GET"])
|
|
def favorite_values():
|
|
options = _get_favorite_slot_options()
|
|
eligible_keys = {option["key"] for option in options}
|
|
slots = normalize_favorite_slots(params.get(FAVORITE_SLOTS_PARAM), params=params, eligible_keys=eligible_keys)
|
|
return jsonify({"values": _configured_favorite_slot_values(slots)}), 200
|
|
|
|
@app.route("/api/favorites/action", methods=["POST"])
|
|
def favorite_action():
|
|
data = request.get_json() or {}
|
|
key = str(data.get("key") or "").strip()
|
|
if not is_favorite_action_key(key):
|
|
return jsonify({"error": "Unknown favorite action."}), 400
|
|
if not trigger_favorite_action(key, params_memory):
|
|
return jsonify({"error": "Favorite action failed."}), 400
|
|
return jsonify({"message": "Favorite action sent."}), 200
|
|
|
|
@app.route("/api/params", methods=["GET", "PUT"])
|
|
def get_param():
|
|
if request.method == "PUT":
|
|
data = request.get_json()
|
|
if not data or "key" not in data or "value" not in data:
|
|
return jsonify({"error": "Missing 'key' or 'value' in request body."}), 400
|
|
|
|
key = str(data["key"]).strip()
|
|
if key.lower() == FAVORITE_SLOTS_PARAM.lower():
|
|
key = FAVORITE_SLOTS_PARAM
|
|
raw_slots = data["value"]
|
|
if isinstance(raw_slots, dict):
|
|
raw_slots = raw_slots.get("slots", raw_slots)
|
|
if not isinstance(raw_slots, list):
|
|
return jsonify({"error": "Favorite slots must be configured with the Favorites editor."}), 400
|
|
|
|
options = _get_favorite_slot_options()
|
|
option_by_key = {option["key"]: option for option in options}
|
|
eligible_keys = set(option_by_key)
|
|
slots = normalize_favorite_slots(raw_slots, params=params, eligible_keys=eligible_keys)
|
|
for slot in slots:
|
|
slot_key = slot.get("key")
|
|
if slot_key in option_by_key:
|
|
slot["label"] = option_by_key[slot_key]["label"]
|
|
|
|
params.put(FAVORITE_SLOTS_PARAM, slots)
|
|
update_starpilot_toggles()
|
|
return jsonify({
|
|
"message": "Favorite slots saved.",
|
|
"updated": {FAVORITE_SLOTS_PARAM: slots},
|
|
}), 200
|
|
|
|
key = {
|
|
"model": "Model",
|
|
"modelversion": "ModelVersion",
|
|
"drivingmodel": "DrivingModel",
|
|
"drivingmodelversion": "DrivingModelVersion",
|
|
}.get(key.lower(), key)
|
|
if key in MODEL_SMOOTHING_KEYS:
|
|
if not params.get_bool("DeveloperUI"):
|
|
return jsonify({"error": "Model smoothing is available only with Developer UI enabled."}), 403
|
|
try:
|
|
numeric = float(data["value"])
|
|
except (TypeError, ValueError):
|
|
return jsonify({"error": f"{key} must be numeric."}), 400
|
|
if not math.isfinite(numeric) or numeric < 0.005 or numeric > 2.0:
|
|
return jsonify({"error": f"{key} must be between 0.005 and 2.0 seconds."}), 400
|
|
data["value"] = round(numeric / 0.005) * 0.005
|
|
val = data["value"]
|
|
selected_label_input = str(data.get("label") or "").strip()
|
|
|
|
# Python json parses true/false as boolean
|
|
if isinstance(val, bool):
|
|
str_val = "1" if val else "0"
|
|
else:
|
|
str_val = str(val)
|
|
|
|
allowed_keys, _ = _get_param_type_info()
|
|
if key not in allowed_keys:
|
|
return jsonify({"error": f"Parameter '{key}' is not editable."}), 403
|
|
|
|
if key in {"UseOldUI", "TryRaylibUI"}:
|
|
enabled = str_val.strip() in ("1", "true", "True")
|
|
use_old_ui = enabled if key == "UseOldUI" else not enabled
|
|
updated = {"UseOldUI": use_old_ui, "TryRaylibUI": not use_old_ui}
|
|
if not _raylib_ui_toggle_affects_device():
|
|
return jsonify({
|
|
"message": "Use Old UI is only available on tici/tizi devices.",
|
|
"updated": {"UseOldUI": False, "TryRaylibUI": False},
|
|
}), 200
|
|
|
|
if params.get_bool("IsOnroad"):
|
|
return jsonify({"error": "Cannot change Use Old UI while driving."}), 403
|
|
|
|
params.put_bool("UseOldUI", use_old_ui)
|
|
params.put_bool("TryRaylibUI", not use_old_ui)
|
|
return jsonify({
|
|
"message": f"{'Old' if use_old_ui else 'Raylib'} UI selected. UI will restart shortly.",
|
|
"updated": updated,
|
|
}), 200
|
|
|
|
# 1. Prevent changing the model or reboot-required toggles while the car is actively driving
|
|
reboot_keys = {"Model", "DrivingModel", "AlwaysOnLateral", "DisableOpenpilotLongitudinal", "ForceTorqueController", "NNFF", "NNFFLite"}
|
|
if key in reboot_keys and params.get_bool("IsOnroad"):
|
|
friendly_names = {
|
|
"Model": "Driving Model",
|
|
"DrivingModel": "Driving Model",
|
|
"AlwaysOnLateral": "Always On Lateral",
|
|
"DisableOpenpilotLongitudinal": "Disable openpilot Longitudinal",
|
|
"ForceTorqueController": "Force Torque Controller",
|
|
"NNFF": "NNFF",
|
|
"NNFFLite": "NNFF-Lite"
|
|
}
|
|
name = friendly_names.get(key, key)
|
|
return jsonify({"error": f"Cannot change {name} while the car is driving. A reboot is required."}), 403
|
|
|
|
if key == "AutomaticUpdates" and params.get_bool("IsOnroad"):
|
|
return jsonify({"error": "Cannot change Automatic Updates while driving."}), 403
|
|
|
|
if key in VASM_CONFIGURATION_KEYS and params.get_bool("IsOnroad"):
|
|
return jsonify({"error": "Cannot change V-ASM configuration while driving."}), 403
|
|
|
|
if key in PIP_PREVIEW_CONFIGURATION_KEYS:
|
|
if not params.get_bool("GalaxyDeveloperMode"):
|
|
return jsonify({"error": "PiP Side Camera is available only with Galaxy Developer Mode enabled."}), 403
|
|
if params.get_bool("IsOnroad"):
|
|
return jsonify({"error": "Cannot change PiP Side Camera configuration while driving."}), 403
|
|
|
|
if key in PANDA_FIRMWARE_TOGGLE_KEYS and params.get_bool("IsOnroad"):
|
|
return jsonify({"error": "Cannot flash Panda firmware while driving."}), 403
|
|
if key in PANDA_FIRMWARE_TOGGLE_KEYS and data.get(PANDA_FIRMWARE_CONFIRMATION_FIELD) is not True:
|
|
return jsonify({"error": "Panda firmware changes require confirmation before flashing."}), 409
|
|
|
|
if key in {"LeadIndicator", "HideLeadMarker"}:
|
|
enabled = str_val.strip() in ("1", "true", "True")
|
|
if key == "LeadIndicator":
|
|
params.put_bool("LeadIndicator", enabled)
|
|
updated = {"LeadIndicator": enabled, "HideLeadMarker": not enabled}
|
|
else:
|
|
params.put_bool("HideLeadMarker", enabled)
|
|
updated = {"HideLeadMarker": enabled, "LeadIndicator": not enabled}
|
|
|
|
update_starpilot_toggles()
|
|
return jsonify({
|
|
"message": f"Parameter '{key}' updated successfully.",
|
|
"updated": updated,
|
|
}), 200
|
|
|
|
if key == "AllowImpossibleAcceleration":
|
|
enabled = str_val.strip() in ("1", "true", "True")
|
|
params.put_bool(key, enabled)
|
|
if enabled and _offroad_excessive_actuation_type() == "longitudinal":
|
|
params.remove("Offroad_ExcessiveActuation")
|
|
|
|
update_starpilot_toggles()
|
|
return jsonify({
|
|
"message": f"Parameter '{key}' updated successfully.",
|
|
"updated": {key: enabled},
|
|
}), 200
|
|
|
|
if key in {"EVTuning", "TruckTuning"}:
|
|
enabled = str_val.strip() in ("1", "true", "True")
|
|
params.put_bool(key, enabled)
|
|
|
|
updated = {key: enabled}
|
|
if enabled:
|
|
other_key = "TruckTuning" if key == "EVTuning" else "EVTuning"
|
|
params.put_bool(other_key, False)
|
|
updated[other_key] = False
|
|
|
|
update_starpilot_toggles()
|
|
return jsonify({
|
|
"message": f"Parameter '{key}' updated successfully.",
|
|
"updated": updated,
|
|
}), 200
|
|
|
|
if key in {"DynamicPedalsOnUI", "StaticPedalsOnUI"}:
|
|
enabled = str_val.strip() in ("1", "true", "True")
|
|
params.put_bool(key, enabled)
|
|
|
|
updated = {key: enabled}
|
|
if enabled:
|
|
other_key = "StaticPedalsOnUI" if key == "DynamicPedalsOnUI" else "DynamicPedalsOnUI"
|
|
params.put_bool(other_key, False)
|
|
updated[other_key] = False
|
|
|
|
update_starpilot_toggles()
|
|
return jsonify({
|
|
"message": f"Parameter '{key}' updated successfully.",
|
|
"updated": updated,
|
|
}), 200
|
|
|
|
if key in {"ConditionalExperimental", "ConditionalChill"}:
|
|
enabled = str_val.strip() in ("1", "true", "True")
|
|
params.put_bool(key, enabled)
|
|
|
|
updated = {key: enabled}
|
|
if enabled:
|
|
other_key = "ConditionalChill" if key == "ConditionalExperimental" else "ConditionalExperimental"
|
|
params.put_bool(other_key, False)
|
|
updated[other_key] = False
|
|
|
|
update_starpilot_toggles()
|
|
return jsonify({
|
|
"message": f"Parameter '{key}' updated successfully.",
|
|
"updated": updated,
|
|
}), 200
|
|
|
|
if key == "CustomAccelProfile":
|
|
enabled = str_val.strip() in ("1", "true", "True")
|
|
params.put_bool(key, enabled)
|
|
|
|
updated = {key: enabled}
|
|
if enabled and not _get_custom_accel_profile_initialized():
|
|
defaults_lookup = _get_default_param_values()
|
|
for custom_key in CUSTOM_ACCEL_PROFILE_PARAM_KEYS:
|
|
custom_value = defaults_lookup[custom_key]
|
|
params.put(custom_key, _serialize_param_write_value(custom_value))
|
|
updated[custom_key] = float(custom_value)
|
|
params.put_bool(CUSTOM_ACCEL_PROFILE_INITIALIZED_KEY, True)
|
|
|
|
update_starpilot_toggles()
|
|
return jsonify({
|
|
"message": f"Parameter '{key}' updated successfully.",
|
|
"updated": updated,
|
|
}), 200
|
|
|
|
if key == "PersistExperimentalState":
|
|
enabled = str_val.strip() in ("1", "true", "True")
|
|
sync_persist_experimental_state(params, params_memory, enabled)
|
|
update_starpilot_toggles()
|
|
return jsonify({
|
|
"message": f"Parameter '{key}' updated successfully.",
|
|
"updated": {
|
|
"PersistExperimentalState": enabled,
|
|
"PersistedCEStatus": params.get_int("PersistedCEStatus", default=0),
|
|
},
|
|
}), 200
|
|
|
|
if key == "PersistChillState":
|
|
enabled = str_val.strip() in ("1", "true", "True")
|
|
sync_persist_chill_state(params, params_memory, enabled)
|
|
update_starpilot_toggles()
|
|
return jsonify({
|
|
"message": f"Parameter '{key}' updated successfully.",
|
|
"updated": {
|
|
"PersistChillState": enabled,
|
|
"PersistedCCStatus": params.get_int("PersistedCCStatus", default=0),
|
|
},
|
|
}), 200
|
|
|
|
if key == "IsRHD":
|
|
enabled = str_val.strip() in ("1", "true", "True")
|
|
params.put_bool("IsRHD", enabled)
|
|
params.put_bool("IsRHDOverride", True)
|
|
return jsonify({
|
|
"message": "Right Hand Driving override updated successfully.",
|
|
"updated": {
|
|
"IsRHD": enabled,
|
|
"IsRHDOverride": True,
|
|
},
|
|
}), 200
|
|
|
|
if key == "IsRHDOverride":
|
|
enabled = str_val.strip() in ("1", "true", "True")
|
|
params.put_bool("IsRHDOverride", enabled)
|
|
updated = {"IsRHDOverride": enabled}
|
|
if not enabled:
|
|
auto_rhd = params.get_bool("IsRhdDetected")
|
|
params.put_bool("IsRHD", auto_rhd)
|
|
updated["IsRHD"] = auto_rhd
|
|
|
|
return jsonify({
|
|
"message": "Right Hand Driving auto detection restored." if not enabled else "Right Hand Driving override enabled.",
|
|
"updated": updated,
|
|
}), 200
|
|
|
|
if key == "CarMake":
|
|
catalog = _get_fingerprint_catalog()
|
|
normalized_make = _normalize_fingerprint_make_key(str_val)
|
|
stored_make = catalog["make_label_by_key"].get(normalized_make, str_val.strip())
|
|
params.put("CarMake", stored_make)
|
|
update_starpilot_toggles()
|
|
return jsonify({
|
|
"message": "Car make updated successfully.",
|
|
"updated": {"CarMake": stored_make},
|
|
}), 200
|
|
|
|
if key == "CarModel":
|
|
selected_model = str_val.strip()
|
|
if not selected_model:
|
|
return jsonify({"error": "Car model cannot be empty."}), 400
|
|
|
|
catalog = _get_fingerprint_catalog()
|
|
if selected_label_input and any(
|
|
entry["value"] == selected_model and entry["label"] == selected_label_input
|
|
for entry in catalog["all_models"]
|
|
):
|
|
model_label = selected_label_input
|
|
else:
|
|
model_label = catalog["model_to_label"].get(selected_model)
|
|
make_label = catalog["model_to_make"].get(selected_model)
|
|
|
|
params.put("CarModel", selected_model)
|
|
updated = {"CarModel": selected_model}
|
|
|
|
if model_label:
|
|
params.put("CarModelName", model_label)
|
|
updated["CarModelName"] = model_label
|
|
else:
|
|
params.remove("CarModelName")
|
|
updated["CarModelName"] = ""
|
|
|
|
if make_label:
|
|
params.put("CarMake", make_label)
|
|
updated["CarMake"] = make_label
|
|
|
|
update_starpilot_toggles()
|
|
return jsonify({
|
|
"message": f"Fingerprint set to '{model_label or selected_model}'.",
|
|
"updated": updated,
|
|
}), 200
|
|
|
|
if key in ("Model", "DrivingModel"):
|
|
selected_model = canonical_model_key(str_val.strip())
|
|
if not selected_model:
|
|
return jsonify({"error": "Driving model cannot be empty."}), 400
|
|
|
|
params.put("Model", selected_model)
|
|
params.put("DrivingModel", selected_model)
|
|
|
|
available_models = [entry.strip() for entry in (params.get("AvailableModels", encoding="utf-8") or "").split(",")]
|
|
available_names = [entry.strip() for entry in (params.get("AvailableModelNames", encoding="utf-8") or "").split(",")]
|
|
model_versions = [entry.strip() for entry in (params.get("ModelVersions", encoding="utf-8") or "").split(",")]
|
|
|
|
selected_index = next((i for i, model_key in enumerate(available_models) if canonical_model_key(model_key) == selected_model), -1)
|
|
if selected_index != -1:
|
|
if selected_index < len(available_names) and available_names[selected_index]:
|
|
params.put("DrivingModelName", available_names[selected_index])
|
|
elif is_builtin_model_key(selected_model):
|
|
params.put("DrivingModelName", _default_model_name())
|
|
|
|
if selected_index < len(model_versions) and model_versions[selected_index]:
|
|
resolved_version = model_versions[selected_index]
|
|
params.put("ModelVersion", resolved_version)
|
|
params.put("DrivingModelVersion", resolved_version)
|
|
elif is_builtin_model_key(selected_model):
|
|
resolved_version = _default_model_version()
|
|
params.put("ModelVersion", resolved_version)
|
|
params.put("DrivingModelVersion", resolved_version)
|
|
elif is_builtin_model_key(selected_model):
|
|
params.put("DrivingModelName", _default_model_name())
|
|
resolved_version = _default_model_version()
|
|
params.put("ModelVersion", resolved_version)
|
|
params.put("DrivingModelVersion", resolved_version)
|
|
else:
|
|
# Fallback to cached version map if this model isn't in the current manifest list yet.
|
|
try:
|
|
with open(MODELS_PATH / ".model_versions.json", "r") as f:
|
|
versions = json.load(f)
|
|
for alias in model_key_aliases(selected_model):
|
|
if alias not in versions:
|
|
continue
|
|
|
|
resolved_version = str(versions[alias]).strip()
|
|
if resolved_version:
|
|
params.put("ModelVersion", resolved_version)
|
|
params.put("DrivingModelVersion", resolved_version)
|
|
break
|
|
except Exception:
|
|
pass
|
|
elif key in ("ModelVersion", "DrivingModelVersion"):
|
|
params.put("ModelVersion", str_val)
|
|
params.put("DrivingModelVersion", str_val)
|
|
elif key in CUSTOM_ACCEL_PROFILE_PARAM_KEYS:
|
|
params.put(key, str_val)
|
|
params.put_bool(CUSTOM_ACCEL_PROFILE_INITIALIZED_KEY, True)
|
|
else:
|
|
params.put(key, str_val)
|
|
|
|
gsm_metered_apply_result = None
|
|
if key == "GsmMetered":
|
|
metered_enabled = str_val.strip() in ("1", "true", "True")
|
|
gsm_metered_apply_result = _apply_cellular_metered_setting(metered_enabled)
|
|
|
|
migrated_cancel_buttons = migrate_cancel_button_controls(params)
|
|
update_starpilot_toggles()
|
|
|
|
response = {"message": f"Parameter '{key}' updated successfully."}
|
|
updated = {}
|
|
if key in PANDA_FIRMWARE_TOGGLE_KEYS:
|
|
threading.Thread(target=_flash_panda_then_reboot, daemon=True).start()
|
|
response["message"] = f"Parameter '{key}' updated successfully. Panda flashing started; device will reboot when finished."
|
|
if key == "RemapCancelToDistance" and params.get_bool("RemapCancelToDistance"):
|
|
updated["RemapCancelToDistance"] = True
|
|
response["message"] = "Remap Cancel Button enabled."
|
|
if migrated_cancel_buttons:
|
|
if key == "RemapCancelToDistance" and params.get_bool("RemapCancelToDistance"):
|
|
response["message"] = "Remap Cancel Button enabled. Existing distance mappings were copied to the new cancel button."
|
|
for source_key, target_key in (
|
|
("DistanceButtonControl", "CancelButtonControl"),
|
|
("LongDistanceButtonControl", "LongCancelButtonControl"),
|
|
("VeryLongDistanceButtonControl", "VeryLongCancelButtonControl"),
|
|
):
|
|
updated[target_key] = _get_param_int_value(target_key, _get_param_int_value(source_key, 0))
|
|
|
|
if gsm_metered_apply_result is not None:
|
|
updated["GsmMetered"] = str_val.strip() in ("1", "true", "True")
|
|
response["networkProfilesUpdated"] = gsm_metered_apply_result.get("profiles", [])
|
|
warnings = gsm_metered_apply_result.get("warnings", [])
|
|
if warnings:
|
|
response["warning"] = " ".join(warnings)
|
|
if updated:
|
|
response["updated"] = updated
|
|
|
|
return jsonify(response), 200
|
|
|
|
request_key = request.args.get("key")
|
|
if request_key in CUSTOM_ACCEL_PROFILE_PARAM_KEYS and not _get_custom_accel_profile_initialized():
|
|
defaults_lookup = _get_default_param_values()
|
|
return _serialize_param_write_value(defaults_lookup.get(request_key)), 200
|
|
if request_key == CUSTOM_ACCEL_PROFILE_INITIALIZED_KEY:
|
|
return _serialize_param_write_value(_get_custom_accel_profile_initialized()), 200
|
|
if request_key == "LeadIndicator":
|
|
return _serialize_param_write_value(_get_lead_indicator_enabled()), 200
|
|
if request_key == "UseOldUI":
|
|
return ("1" if _get_use_old_ui_enabled() else "0"), 200
|
|
if request_key == "TryRaylibUI":
|
|
return ("1" if _raylib_ui_toggle_affects_device() and not _get_use_old_ui_enabled() else "0"), 200
|
|
if request_key == "IsRHD" and not params.get_bool("IsRHDOverride"):
|
|
return ("1" if params.get_bool("IsRhdDetected") else "0"), 200
|
|
value = params.get(request_key) or ""
|
|
if request_key in ("Model", "DrivingModel"):
|
|
if isinstance(value, bytes):
|
|
value = value.decode("utf-8", errors="replace")
|
|
return canonical_model_key(str(value).strip()), 200
|
|
return value, 200
|
|
|
|
@app.route("/api/curve_speed_controller/reset", methods=["POST"])
|
|
def reset_curve_speed_controller_data():
|
|
if params.get_bool("IsOnroad"):
|
|
return jsonify({"error": "Curve Speed Controller data can only be reset while parked."}), 403
|
|
|
|
params.put("CalibratedLateralAcceleration", 2.0)
|
|
params.remove("CalibrationProgress")
|
|
params.remove("CurvatureData")
|
|
|
|
return jsonify({
|
|
"message": "Curve Speed Controller data reset. Training will restart on the next drive.",
|
|
"updated": {
|
|
"CalibratedLateralAcceleration": 2.0,
|
|
"CalibrationProgress": 0.0,
|
|
},
|
|
}), 200
|
|
|
|
@app.route("/api/params/all", methods=["GET"])
|
|
def get_all_params():
|
|
migrate_cancel_button_controls(params)
|
|
allowed_keys, types = _get_param_type_info()
|
|
defaults_lookup = _get_default_param_values()
|
|
|
|
result = {}
|
|
for key in allowed_keys:
|
|
t = types.get(key, str)
|
|
try:
|
|
result[key] = _get_current_param_value(key, t, defaults_lookup)
|
|
except Exception:
|
|
result[key] = None
|
|
|
|
result["HasRadar"] = _get_has_radar()
|
|
|
|
return jsonify(_sanitize_json_value(result)), 200
|
|
|
|
@app.route("/api/params/defaults", methods=["GET"])
|
|
def get_default_params():
|
|
allowed_keys, types = _get_param_type_info()
|
|
defaults_lookup = _get_default_param_values()
|
|
|
|
result = {}
|
|
for key in allowed_keys:
|
|
t = types.get(key, str)
|
|
default_val = defaults_lookup.get(key)
|
|
|
|
try:
|
|
if key == "UseOldUI":
|
|
result[key] = False
|
|
elif key == "TryRaylibUI":
|
|
result[key] = _raylib_ui_toggle_affects_device()
|
|
elif t == bool:
|
|
if isinstance(default_val, bytes):
|
|
default_str = default_val.decode("utf-8", errors="replace")
|
|
else:
|
|
default_str = str(default_val or "")
|
|
result[key] = default_str in ("1", "true", "True")
|
|
elif t == float:
|
|
result[key] = float(default_val)
|
|
elif t == int:
|
|
result[key] = int(float(default_val))
|
|
else:
|
|
if isinstance(default_val, bytes):
|
|
result[key] = default_val.decode("utf-8", errors="replace")
|
|
else:
|
|
result[key] = str(default_val or "")
|
|
except Exception:
|
|
result[key] = None
|
|
|
|
return jsonify(_sanitize_json_value(result)), 200
|
|
|
|
@app.route("/api/troubleshoot", methods=["GET"])
|
|
def get_troubleshoot_data():
|
|
try:
|
|
return jsonify(_build_troubleshoot_payload()), 200
|
|
except Exception as exception:
|
|
return jsonify({"error": str(exception)}), 500
|
|
|
|
@app.route("/api/troubleshoot/reset", methods=["POST"])
|
|
def reset_troubleshoot_section():
|
|
request_data = request.get_json() or {}
|
|
section_id = str(request_data.get("sectionId") or "").strip()
|
|
if not section_id:
|
|
return jsonify({"error": "Missing 'sectionId' in request body."}), 400
|
|
|
|
try:
|
|
result = _reset_troubleshoot_section(section_id)
|
|
message = f"{result['sectionTitle']} reset to defaults."
|
|
if result["skippedCount"] > 0:
|
|
message += f" Updated {result['updatedCount']} setting(s), skipped {result['skippedCount']}."
|
|
return jsonify({
|
|
"message": message,
|
|
**result,
|
|
}), 200
|
|
except ValueError as exception:
|
|
return jsonify({"error": str(exception)}), 400
|
|
except Exception as exception:
|
|
return jsonify({"error": str(exception)}), 500
|
|
|
|
@app.route("/api/models/installed", methods=["GET"])
|
|
def get_installed_models():
|
|
catalog = get_model_catalog()
|
|
installed = [{"value": model["value"], "label": model["label"]} for model in catalog if model["installed"]]
|
|
|
|
# Keep current model selectable even if local files are currently inconsistent.
|
|
current_model = _current_model_key()
|
|
if current_model and all(model["value"] != current_model for model in installed):
|
|
for model in catalog:
|
|
if model["value"] == current_model:
|
|
installed.append({"value": model["value"], "label": model["label"]})
|
|
break
|
|
|
|
return jsonify(installed), 200
|
|
|
|
@app.route("/api/models/catalog", methods=["GET"])
|
|
def get_models_catalog():
|
|
models = get_model_catalog()
|
|
return jsonify({
|
|
"models": models,
|
|
"currentModel": _current_model_key(),
|
|
"summary": {
|
|
"installed": sum(1 for model in models if model["installed"]),
|
|
"missing": sum(1 for model in models if not model["installed"]),
|
|
"total": len(models),
|
|
},
|
|
}), 200
|
|
|
|
@app.route("/api/models/preferences", methods=["GET", "PUT"])
|
|
def get_or_set_models_preferences():
|
|
if request.method == "GET":
|
|
return jsonify({
|
|
"sortMode": read_legacy_param_file(MODEL_SORT_MODE_PARAM, "alphabetical"),
|
|
"userFavorites": [entry for entry in (params.get(MODEL_USER_FAVORITES_PARAM, encoding="utf-8") or "").split(",") if entry],
|
|
}), 200
|
|
|
|
data = request.get_json() or {}
|
|
changed = []
|
|
|
|
if "sortMode" in data:
|
|
sort_mode = str(data.get("sortMode") or "alphabetical").strip() or "alphabetical"
|
|
write_legacy_param_file(MODEL_SORT_MODE_PARAM, sort_mode)
|
|
changed.append("sort mode")
|
|
|
|
if "userFavorites" in data:
|
|
incoming = data.get("userFavorites")
|
|
if isinstance(incoming, list):
|
|
favorites = ",".join(entry.strip() for entry in incoming if str(entry).strip())
|
|
else:
|
|
favorites = ",".join(entry.strip() for entry in str(incoming or "").split(",") if entry.strip())
|
|
params.put(MODEL_USER_FAVORITES_PARAM, favorites)
|
|
changed.append("favorites")
|
|
|
|
if not changed:
|
|
return jsonify({"error": "No preferences provided."}), 400
|
|
|
|
return jsonify({"message": f"Updated model {' and '.join(changed)}."}), 200
|
|
|
|
@app.route("/api/models/status", methods=["GET"])
|
|
def get_models_status():
|
|
models = get_model_catalog()
|
|
model_to_download = canonical_model_key(params_memory.get(MODEL_DOWNLOAD_PARAM, encoding="utf-8") or "")
|
|
download_all = params_memory.get_bool(MODEL_DOWNLOAD_ALL_PARAM)
|
|
progress = params_memory.get(MODEL_DOWNLOAD_PROGRESS_PARAM, encoding="utf-8") or ""
|
|
cancelling = params_memory.get_bool(MODEL_CANCEL_DOWNLOAD_PARAM)
|
|
|
|
downloading = bool(model_to_download) or download_all
|
|
current_model = _current_model_key()
|
|
sort_mode = read_legacy_param_file(MODEL_SORT_MODE_PARAM, "alphabetical")
|
|
terminal = progress in ("Downloaded!", "All models downloaded!") or bool(re.search(r"cancelled|exists|failed|offline|invalid|error", progress, re.IGNORECASE))
|
|
summary = {
|
|
"installed": sum(1 for model in models if model["installed"]),
|
|
"missing": sum(1 for model in models if not model["installed"]),
|
|
"total": len(models),
|
|
}
|
|
|
|
now = time.monotonic()
|
|
signature = (
|
|
summary["total"],
|
|
summary["installed"],
|
|
summary["missing"],
|
|
model_to_download,
|
|
download_all,
|
|
downloading,
|
|
cancelling,
|
|
progress,
|
|
current_model,
|
|
sort_mode,
|
|
terminal,
|
|
bool(params.get_bool("IsOnroad")),
|
|
)
|
|
if model_status_debug["last_signature"] != signature or now - model_status_debug["last_log_time"] >= 15:
|
|
print(
|
|
f"[ModelStatus] addr={request.remote_addr or 'unknown'} total={summary['total']} "
|
|
f"installed={summary['installed']} missing={summary['missing']} downloading={downloading} "
|
|
f"download_all={download_all} model='{model_to_download or '-'}' current='{current_model or '-'}' "
|
|
f"progress='{progress or 'Idle'}' cancelling={cancelling} onroad={params.get_bool('IsOnroad')} terminal={terminal}"
|
|
)
|
|
model_status_debug["last_signature"] = signature
|
|
model_status_debug["last_log_time"] = now
|
|
|
|
if summary["total"] == 0 and now - model_status_debug["last_empty_catalog_log_time"] >= 15:
|
|
available_models = params.get("AvailableModels", encoding="utf-8") or ""
|
|
available_names = params.get("AvailableModelNames", encoding="utf-8") or ""
|
|
available_models_count = len([item for item in available_models.split(",") if item.strip()])
|
|
available_names_count = len([item for item in available_names.split(",") if item.strip()])
|
|
print(
|
|
f"[ModelStatus] WARNING empty catalog available_models={available_models_count} "
|
|
f"available_names={available_names_count} raw_available_models='{available_models[:120]}'"
|
|
)
|
|
model_status_debug["last_empty_catalog_log_time"] = now
|
|
|
|
return jsonify({
|
|
"modelToDownload": model_to_download,
|
|
"downloadAll": download_all,
|
|
"downloading": downloading,
|
|
"cancelling": cancelling,
|
|
"progress": progress,
|
|
"isOnroad": params.get_bool("IsOnroad"),
|
|
"terminal": terminal,
|
|
"models": models,
|
|
"currentModel": current_model,
|
|
"summary": summary,
|
|
"sortMode": sort_mode,
|
|
}), 200
|
|
|
|
@app.route("/api/models/refresh_manifest", methods=["POST"])
|
|
def refresh_models_manifest():
|
|
if params.get_bool("IsOnroad"):
|
|
return jsonify({"error": "Cannot refresh model manifest while driving."}), 403
|
|
|
|
if params_memory.get_bool(MODEL_DOWNLOAD_ALL_PARAM) or (params_memory.get(MODEL_DOWNLOAD_PARAM, encoding="utf-8") or ""):
|
|
return jsonify({"error": "Cannot refresh model manifest while a download is in progress."}), 409
|
|
|
|
try:
|
|
from openpilot.starpilot.assets.model_manager import ModelManager
|
|
|
|
# ModelManager expects raw Params semantics (encoding-less get -> str, not legacy bytes).
|
|
manager = ModelManager(_params_raw, _params_memory_raw)
|
|
manager.update_models(False)
|
|
except Exception as exception:
|
|
return jsonify({"error": f"Failed to refresh model manifest: {exception}"}), 500
|
|
|
|
return jsonify({"message": "Model manifest refreshed."}), 200
|
|
|
|
@app.route("/api/models/download", methods=["POST"])
|
|
def start_model_download():
|
|
if params.get_bool("IsOnroad"):
|
|
return jsonify({"error": "Cannot download models while driving."}), 403
|
|
|
|
if params_memory.get_bool(MODEL_DOWNLOAD_ALL_PARAM) or (params_memory.get(MODEL_DOWNLOAD_PARAM, encoding="utf-8") or ""):
|
|
return jsonify({"error": "A model download is already in progress."}), 409
|
|
|
|
data = request.get_json() or {}
|
|
model_key = canonical_model_key((data.get("model") or "").strip())
|
|
if not model_key:
|
|
return jsonify({"error": "Missing model key."}), 400
|
|
|
|
catalog = {model["value"]: model for model in get_model_catalog()}
|
|
model = catalog.get(model_key)
|
|
if model is None:
|
|
return jsonify({"error": f"Unknown model '{model_key}'."}), 404
|
|
|
|
if model["installed"]:
|
|
return jsonify({"message": f"\"{model['label']}\" is already installed."}), 200
|
|
|
|
params_memory.remove(MODEL_CANCEL_DOWNLOAD_PARAM)
|
|
params_memory.remove(MODEL_DOWNLOAD_ALL_PARAM)
|
|
params_memory.put(MODEL_DOWNLOAD_PARAM, model_key)
|
|
params_memory.put(MODEL_DOWNLOAD_PROGRESS_PARAM, "Downloading...")
|
|
|
|
return jsonify({"message": f"Started downloading \"{model['label']}\"."}), 200
|
|
|
|
@app.route("/api/models/download_all", methods=["POST"])
|
|
def start_models_download_all():
|
|
if params.get_bool("IsOnroad"):
|
|
return jsonify({"error": "Cannot download models while driving."}), 403
|
|
|
|
if params_memory.get_bool(MODEL_DOWNLOAD_ALL_PARAM) or (params_memory.get(MODEL_DOWNLOAD_PARAM, encoding="utf-8") or ""):
|
|
return jsonify({"error": "A model download is already in progress."}), 409
|
|
|
|
missing_models = [model for model in get_model_catalog() if not model["installed"]]
|
|
if not missing_models:
|
|
return jsonify({"message": "All models are already installed."}), 200
|
|
|
|
params_memory.remove(MODEL_CANCEL_DOWNLOAD_PARAM)
|
|
params_memory.remove(MODEL_DOWNLOAD_PARAM)
|
|
params_memory.put_bool(MODEL_DOWNLOAD_ALL_PARAM, True)
|
|
params_memory.put(MODEL_DOWNLOAD_PROGRESS_PARAM, "Downloading...")
|
|
|
|
return jsonify({"message": f"Started downloading {len(missing_models)} model(s)."}), 200
|
|
|
|
@app.route("/api/models/cancel", methods=["POST"])
|
|
def cancel_model_download():
|
|
model_to_download = params_memory.get(MODEL_DOWNLOAD_PARAM, encoding="utf-8") or ""
|
|
download_all = params_memory.get_bool(MODEL_DOWNLOAD_ALL_PARAM)
|
|
if not model_to_download and not download_all:
|
|
return jsonify({"message": "No active model download to cancel."}), 200
|
|
|
|
params_memory.put_bool(MODEL_CANCEL_DOWNLOAD_PARAM, True)
|
|
return jsonify({"message": "Cancellation requested."}), 200
|
|
|
|
@app.route("/api/models/delete", methods=["POST"])
|
|
def delete_model_files():
|
|
if params.get_bool("IsOnroad"):
|
|
return jsonify({"error": "Cannot delete model files while driving."}), 403
|
|
|
|
if params_memory.get_bool(MODEL_DOWNLOAD_ALL_PARAM) or (params_memory.get(MODEL_DOWNLOAD_PARAM, encoding="utf-8") or ""):
|
|
return jsonify({"error": "Cannot delete model files while a download is in progress."}), 409
|
|
|
|
data = request.get_json() or {}
|
|
model_key = canonical_model_key((data.get("model") or "").strip())
|
|
if not model_key:
|
|
return jsonify({"error": "Missing model key."}), 400
|
|
|
|
current_model = _current_model_key()
|
|
if model_key == current_model:
|
|
return jsonify({"error": "Cannot delete the currently active model."}), 409
|
|
|
|
catalog = {model["value"]: model for model in get_model_catalog()}
|
|
model = catalog.get(model_key)
|
|
if model is None:
|
|
return jsonify({"error": f"Unknown model '{model_key}'."}), 404
|
|
if model.get("builtin"):
|
|
return jsonify({"error": "Cannot delete the built-in default model."}), 409
|
|
|
|
models_dir = MODELS_PATH
|
|
if not models_dir.is_dir():
|
|
return jsonify({"message": "No model directory exists yet."}), 200
|
|
|
|
deleted = []
|
|
for item in models_dir.iterdir():
|
|
name = item.name
|
|
is_match = (
|
|
name == f"{model_key}.thneed" or
|
|
name == f"{model_key}.pkl" or
|
|
name.startswith(f"{model_key}_")
|
|
)
|
|
|
|
if not is_match:
|
|
continue
|
|
|
|
try:
|
|
if item.is_dir():
|
|
shutil.rmtree(item)
|
|
else:
|
|
item.unlink(missing_ok=True)
|
|
deleted.append(name)
|
|
except Exception as exception:
|
|
return jsonify({"error": f"Failed deleting '{name}': {exception}"}), 500
|
|
|
|
if not deleted:
|
|
return jsonify({"message": f"No files found for \"{model['label']}\"."}), 200
|
|
|
|
return jsonify({"message": f"Deleted {len(deleted)} file(s) for \"{model['label']}\"."}), 200
|
|
|
|
def _get_maps_status_payload():
|
|
current_selected = params.get("MapsSelected", encoding="utf-8") or ""
|
|
selected_raw = sanitize_selected_locations_csv(current_selected)
|
|
if selected_raw != current_selected:
|
|
params.put("MapsSelected", selected_raw)
|
|
|
|
selected_entries = get_selected_map_entries(selected_raw)
|
|
selected_locations = [entry["token"] for entry in selected_entries]
|
|
maps_present = MAPS_PATH.exists() and any(path.is_file() for path in MAPS_PATH.rglob("*"))
|
|
storage_bytes = 0
|
|
if MAPS_PATH.exists():
|
|
try:
|
|
storage_bytes = sum(path.stat().st_size for path in MAPS_PATH.rglob("*") if path.is_file())
|
|
except Exception:
|
|
storage_bytes = 0
|
|
|
|
selected_key = selection_key(selected_locations)
|
|
raw_progress = params_memory.get(MAPS_DOWNLOAD_PROGRESS_PARAM, encoding="utf-8") or ""
|
|
try:
|
|
download_progress = json.loads(raw_progress) if raw_progress else {}
|
|
except (TypeError, ValueError):
|
|
download_progress = {}
|
|
if not isinstance(download_progress, dict):
|
|
download_progress = {}
|
|
|
|
if not params_memory.get_bool(MAPS_DOWNLOAD_PARAM) and selected_key and download_progress.get("selectedKey") != selected_key:
|
|
size_cache = load_size_cache(params.get(MAPS_DOWNLOAD_SIZE_CACHE_PARAM, encoding="utf-8") or "")
|
|
cached_entry = size_cache.get(selected_key, {})
|
|
cached_bytes = nonnegative_int(cached_entry.get("downloadBytes", 0)) if isinstance(cached_entry, dict) else 0
|
|
if cached_bytes > 0:
|
|
download_progress = {
|
|
"active": False,
|
|
"cancelled": False,
|
|
"completed": False,
|
|
"downloadedBytes": 0,
|
|
"downloadedFiles": 0,
|
|
"estimatedDownloadBytes": cached_bytes,
|
|
"estimateSource": "previous_download",
|
|
"etaSeconds": 0,
|
|
"percent": 0,
|
|
"phase": "idle",
|
|
"primaryLocation": "",
|
|
"selectedKey": selected_key,
|
|
"selectedLocations": selected_locations,
|
|
"storageBytes": storage_bytes,
|
|
"totalFiles": nonnegative_int(cached_entry.get("totalFiles", 0)),
|
|
"updatedAt": cached_entry.get("updatedAt", ""),
|
|
"bytesPerSecond": 0,
|
|
}
|
|
else:
|
|
download_progress = {
|
|
"active": False,
|
|
"cancelled": False,
|
|
"completed": False,
|
|
"downloadedBytes": 0,
|
|
"downloadedFiles": 0,
|
|
"estimatedDownloadBytes": 0,
|
|
"estimateSource": "",
|
|
"etaSeconds": 0,
|
|
"percent": 0,
|
|
"phase": "idle",
|
|
"primaryLocation": "",
|
|
"selectedKey": selected_key,
|
|
"selectedLocations": selected_locations,
|
|
"storageBytes": storage_bytes,
|
|
"totalFiles": 0,
|
|
"updatedAt": "",
|
|
"bytesPerSecond": 0,
|
|
}
|
|
|
|
return {
|
|
"selectedLocations": selected_locations,
|
|
"selectedEntries": selected_entries,
|
|
"selectedCount": len(selected_locations),
|
|
"hasSelection": bool(selected_locations),
|
|
"downloading": params_memory.get_bool(MAPS_DOWNLOAD_PARAM),
|
|
"cancelling": params_memory.get_bool(MAPS_CANCEL_DOWNLOAD_PARAM),
|
|
"isOnroad": params.get_bool("IsOnroad"),
|
|
"lastUpdate": params.get("LastMapsUpdate", encoding="utf-8") or "Never",
|
|
"mapsPresent": maps_present,
|
|
"scheduleLabel": schedule_label(params.get("PreferredSchedule")),
|
|
"scheduleOptions": MAP_SCHEDULE_OPTIONS,
|
|
"scheduleValue": schedule_param_value(params.get("PreferredSchedule")),
|
|
"storageBytes": storage_bytes,
|
|
"downloadProgress": download_progress,
|
|
}
|
|
|
|
@app.route("/api/maps/catalog", methods=["GET"])
|
|
def get_maps_catalog():
|
|
return jsonify({
|
|
"sections": MAPS_CATALOG,
|
|
"scheduleOptions": MAP_SCHEDULE_OPTIONS,
|
|
}), 200
|
|
|
|
@app.route("/api/maps/status", methods=["GET"])
|
|
def get_maps_status():
|
|
return jsonify(_get_maps_status_payload()), 200
|
|
|
|
@app.route("/api/maps/selection", methods=["POST"])
|
|
def set_maps_selection():
|
|
payload = request.get_json(silent=True) or {}
|
|
selected_raw = sanitize_selected_locations_csv(payload.get("selectedLocations"))
|
|
params.put("MapsSelected", selected_raw)
|
|
return jsonify({
|
|
"message": f"Saved {len([entry for entry in selected_raw.split(',') if entry])} selected map region(s).",
|
|
"status": _get_maps_status_payload(),
|
|
}), 200
|
|
|
|
@app.route("/api/maps/schedule", methods=["POST"])
|
|
def set_maps_schedule():
|
|
payload = request.get_json(silent=True) or {}
|
|
schedule_value = schedule_param_value(payload.get("schedule"))
|
|
params.put("PreferredSchedule", schedule_value)
|
|
return jsonify({
|
|
"message": f"Map auto-update schedule set to {schedule_label(schedule_value)}.",
|
|
"status": _get_maps_status_payload(),
|
|
}), 200
|
|
|
|
@app.route("/api/maps/download", methods=["POST"])
|
|
def start_maps_download():
|
|
payload = request.get_json(silent=True) or {}
|
|
|
|
if params.get_bool("IsOnroad"):
|
|
return jsonify({"error": "Cannot download maps while driving."}), 403
|
|
|
|
if params_memory.get_bool(MAPS_DOWNLOAD_PARAM):
|
|
return jsonify({"error": "A map download is already in progress."}), 409
|
|
|
|
if "selectedLocations" in payload:
|
|
params.put("MapsSelected", sanitize_selected_locations_csv(payload.get("selectedLocations")))
|
|
|
|
if "schedule" in payload:
|
|
params.put("PreferredSchedule", schedule_param_value(payload.get("schedule")))
|
|
|
|
selected_raw = sanitize_selected_locations_csv(params.get("MapsSelected", encoding="utf-8") or "")
|
|
if not selected_raw:
|
|
return jsonify({"error": "No map regions are selected."}), 400
|
|
|
|
params.put("MapsSelected", selected_raw)
|
|
params_memory.remove(MAPS_CANCEL_DOWNLOAD_PARAM)
|
|
params_memory.put_bool(MAPS_DOWNLOAD_PARAM, True)
|
|
|
|
return jsonify({
|
|
"message": f"Started downloading {len([entry for entry in selected_raw.split(',') if entry])} selected map region(s).",
|
|
"status": _get_maps_status_payload(),
|
|
}), 200
|
|
|
|
@app.route("/api/maps/cancel", methods=["POST"])
|
|
def cancel_maps_download():
|
|
if not params_memory.get_bool(MAPS_DOWNLOAD_PARAM):
|
|
return jsonify({"message": "No active map download to cancel.", "status": _get_maps_status_payload()}), 200
|
|
|
|
params_memory.put_bool(MAPS_CANCEL_DOWNLOAD_PARAM, True)
|
|
return jsonify({"message": "Map download cancellation requested.", "status": _get_maps_status_payload()}), 200
|
|
|
|
@app.route("/api/maps/remove", methods=["POST"])
|
|
def remove_maps_data():
|
|
if params.get_bool("IsOnroad"):
|
|
return jsonify({"error": "Cannot remove maps while driving."}), 403
|
|
|
|
if params_memory.get_bool(MAPS_DOWNLOAD_PARAM):
|
|
return jsonify({"error": "Cannot remove maps while a download is in progress."}), 409
|
|
|
|
if MAPS_PATH.exists():
|
|
shutil.rmtree(MAPS_PATH, ignore_errors=True)
|
|
|
|
return jsonify({"message": "Maps removed.", "status": _get_maps_status_payload()}), 200
|
|
|
|
@app.route("/api/params_memory", methods=["GET"])
|
|
def get_param_memory():
|
|
return params_memory.get(request.args.get("key")) or "", 200
|
|
|
|
def _param_text(value):
|
|
if value is None:
|
|
return ""
|
|
if isinstance(value, bytes):
|
|
return value.decode("utf-8", errors="ignore").strip()
|
|
return str(value).strip()
|
|
|
|
def _default_model_key():
|
|
default_key = _param_text(params.get_default_value("Model") or params.get_default_value("DrivingModel"))
|
|
return canonical_model_key(default_key) or "sc2"
|
|
|
|
def _default_model_name():
|
|
return _param_text(params.get_default_value("DrivingModelName")) or "South Carolina"
|
|
|
|
def _default_model_version():
|
|
default_version = _param_text(params.get_default_value("ModelVersion") or params.get_default_value("DrivingModelVersion"))
|
|
return default_version or "v11"
|
|
|
|
def _current_model_key():
|
|
current_model = _param_text(params.get("Model", encoding="utf-8") or params.get("DrivingModel", encoding="utf-8"))
|
|
return canonical_model_key(current_model) or _default_model_key()
|
|
|
|
def is_model_installed(model_key, model_version, on_disk_files):
|
|
del model_version
|
|
if is_builtin_model_key(model_key):
|
|
return True
|
|
|
|
return f"{model_key}_driving_tinygrad.pkl" in on_disk_files
|
|
|
|
def get_model_catalog():
|
|
available = [model.strip() for model in (params.get("AvailableModels", encoding="utf-8") or "").split(",")]
|
|
names = [name.strip() for name in (params.get("AvailableModelNames", encoding="utf-8") or "").split(",")]
|
|
series = [entry.strip() for entry in (params.get("AvailableModelSeries", encoding="utf-8") or "").split(",")]
|
|
versions = [entry.strip() for entry in (params.get("ModelVersions", encoding="utf-8") or "").split(",")]
|
|
artifact_formats = [entry.strip() for entry in (params.get("AvailableModelArtifactFormats", encoding="utf-8") or "").split(",")]
|
|
released_dates = [entry.strip() for entry in (params.get("ModelReleasedDates", encoding="utf-8") or "").split(",")]
|
|
|
|
community_favorites = {canonical_model_key(entry.strip()) for entry in (params.get("CommunityFavorites", encoding="utf-8") or "").split(",") if entry.strip()}
|
|
user_favorites = {canonical_model_key(entry.strip()) for entry in (params.get(MODEL_USER_FAVORITES_PARAM, encoding="utf-8") or "").split(",") if entry.strip()}
|
|
|
|
try:
|
|
on_disk_files = {entry.name for entry in MODELS_PATH.iterdir()} if MODELS_PATH.is_dir() else set()
|
|
except Exception:
|
|
on_disk_files = set()
|
|
|
|
models_by_key = {}
|
|
for i, key in enumerate(available):
|
|
canonical_key = canonical_model_key(key)
|
|
if not canonical_key:
|
|
continue
|
|
|
|
label = names[i] if i < len(names) and names[i] else key
|
|
model_version = versions[i] if i < len(versions) else ""
|
|
artifact_format = artifact_formats[i] if i < len(artifact_formats) else ""
|
|
model_series = series[i] if i < len(series) and series[i] else "Custom Series"
|
|
released = released_dates[i] if i < len(released_dates) else ""
|
|
|
|
existing = models_by_key.get(canonical_key)
|
|
if existing is None:
|
|
models_by_key[canonical_key] = {
|
|
"value": canonical_key,
|
|
"label": label,
|
|
"series": model_series,
|
|
"version": model_version,
|
|
"artifactFormat": artifact_format,
|
|
"released": released,
|
|
"builtin": is_builtin_model_key(canonical_key),
|
|
"communityFavorite": canonical_key in community_favorites,
|
|
"userFavorite": canonical_key in user_favorites,
|
|
}
|
|
continue
|
|
|
|
if (not existing["label"] or existing["label"] == existing["value"]) and label:
|
|
existing["label"] = label
|
|
if (not existing["series"] or existing["series"] == "Custom Series") and model_series:
|
|
existing["series"] = model_series
|
|
if not existing["version"] and model_version:
|
|
existing["version"] = model_version
|
|
if not existing.get("artifactFormat") and artifact_format:
|
|
existing["artifactFormat"] = artifact_format
|
|
if not existing["released"] and released:
|
|
existing["released"] = released
|
|
existing["builtin"] = existing["builtin"] or is_builtin_model_key(canonical_key)
|
|
existing["communityFavorite"] = existing["communityFavorite"] or canonical_key in community_favorites
|
|
existing["userFavorite"] = existing["userFavorite"] or canonical_key in user_favorites
|
|
|
|
default_key = _default_model_key()
|
|
default_entry = models_by_key.setdefault(default_key, {
|
|
"value": default_key,
|
|
"label": _default_model_name(),
|
|
"series": "Custom Series",
|
|
"version": _default_model_version(),
|
|
"artifactFormat": "tinygrad_single_v1",
|
|
"released": "",
|
|
"builtin": True,
|
|
"communityFavorite": default_key in community_favorites,
|
|
"userFavorite": default_key in user_favorites,
|
|
})
|
|
default_entry["builtin"] = True
|
|
if not default_entry["label"] or default_entry["label"] == default_entry["value"]:
|
|
default_entry["label"] = _default_model_name()
|
|
if not default_entry["version"]:
|
|
default_entry["version"] = _default_model_version()
|
|
|
|
models = []
|
|
for key, model in models_by_key.items():
|
|
installed = is_model_installed(key, model["version"], on_disk_files)
|
|
partial = (not model["builtin"]) and (not installed) and any(file.startswith(f"{key}.") or file.startswith(f"{key}_") for file in on_disk_files)
|
|
models.append({
|
|
**model,
|
|
"installed": installed,
|
|
"partial": partial,
|
|
})
|
|
|
|
models.sort(key=lambda model: (model["series"].lower(), model["label"].lower()))
|
|
return models
|
|
|
|
@app.route("/api/routes", methods=["GET"])
|
|
def list_routes():
|
|
def generate():
|
|
routes = [
|
|
(path, name, segment_count)
|
|
for path in FOOTAGE_PATHS
|
|
for name, segment_count in utilities.get_routes_with_segment_counts(path)
|
|
]
|
|
total = len(routes)
|
|
connect_dongle_id = params.get("StockDongleId", encoding="utf-8") or params.get("DongleId", encoding="utf-8") or ""
|
|
yield f"data: {json.dumps({'progress': 0, 'total': total, 'connectDongleId': connect_dongle_id})}\n\n"
|
|
|
|
with ThreadPoolExecutor(max_workers=10) as executor:
|
|
futures = {
|
|
executor.submit(utilities.process_route, path, name, segment_count): (path, name)
|
|
for path, name, segment_count in routes
|
|
}
|
|
for processed, future in enumerate(as_completed(futures), start=1):
|
|
try:
|
|
result = future.result()
|
|
yield f"data: {json.dumps({'routes': [result]})}\n\n"
|
|
except Exception as exception:
|
|
print(f"Error processing route: {exception}")
|
|
yield f"data: {json.dumps({'progress': processed, 'total': total})}\n\n"
|
|
|
|
return Response(generate(), mimetype="text/event-stream")
|
|
|
|
@app.route("/api/routes/<name>", methods=["DELETE"])
|
|
def delete_route(name):
|
|
for footage_path in FOOTAGE_PATHS:
|
|
for segment in os.listdir(footage_path):
|
|
if segment.startswith(name):
|
|
delete_file(os.path.join(footage_path, segment))
|
|
return {"message": "Route deleted!"}, 200
|
|
|
|
@app.route("/api/routes/delete_all", methods=["DELETE"])
|
|
def delete_all_routes():
|
|
route_names = set()
|
|
for footage_path in FOOTAGE_PATHS:
|
|
if os.path.exists(footage_path):
|
|
for segment in os.listdir(footage_path):
|
|
route_names.add(segment.split("--")[0])
|
|
|
|
for route_name in sorted(list(route_names)):
|
|
for footage_path in FOOTAGE_PATHS:
|
|
if os.path.exists(footage_path):
|
|
for segment in os.listdir(footage_path):
|
|
if segment.startswith(route_name):
|
|
delete_file(os.path.join(footage_path, segment))
|
|
|
|
return {"message": "All routes deleted!"}, 200
|
|
|
|
@app.route("/api/routes/<name>/preserve", methods=["POST"])
|
|
def preserve_route(name):
|
|
preserved_routes = 0
|
|
for footage_path in FOOTAGE_PATHS:
|
|
for segment in os.listdir(footage_path):
|
|
if segment.endswith("--0"):
|
|
segment_path = os.path.join(footage_path, segment)
|
|
if PRESERVE_ATTR_NAME in os.listxattr(segment_path) and os.getxattr(segment_path, PRESERVE_ATTR_NAME) == PRESERVE_ATTR_VALUE:
|
|
preserved_routes += 1
|
|
|
|
if preserved_routes >= PRESERVE_COUNT:
|
|
return {"error": f"Maximum of {PRESERVE_COUNT} preserved routes reached..."}, 400
|
|
|
|
for footage_path in FOOTAGE_PATHS:
|
|
route_path = os.path.join(footage_path, f"{name}--0")
|
|
if os.path.exists(route_path):
|
|
os.setxattr(route_path, PRESERVE_ATTR_NAME, PRESERVE_ATTR_VALUE)
|
|
return {"message": "Route preserved!!"}, 200
|
|
|
|
return {"error": "Route not found"}, 404
|
|
|
|
@app.route("/api/routes/<name>/preserve", methods=["DELETE"])
|
|
def un_preserve_route(name):
|
|
for footage_path in FOOTAGE_PATHS:
|
|
route_path = os.path.join(footage_path, f"{name}--0")
|
|
if PRESERVE_ATTR_NAME in os.listxattr(route_path):
|
|
os.removexattr(route_path, PRESERVE_ATTR_NAME)
|
|
return {"message": "Route unpreserved!"}, 200
|
|
return {"error": "Route not found"}, 404
|
|
|
|
@app.route("/video/<name>/combined", methods=["GET"])
|
|
def get_combined_route_video(name):
|
|
camera = request.args.get("camera", "forward")
|
|
for footage_path in FOOTAGE_PATHS:
|
|
segments = utilities.get_segments_in_route(name, footage_path)
|
|
if segments:
|
|
cam_file = {
|
|
"forward": "fcamera.hevc",
|
|
"wide": "ecamera.hevc",
|
|
"driver": "dcamera.hevc",
|
|
}.get(camera, "fcamera.hevc")
|
|
|
|
input_files = [
|
|
os.path.join(footage_path, seg, cam_file)
|
|
for seg in segments
|
|
if os.path.exists(os.path.join(footage_path, seg, cam_file))
|
|
]
|
|
|
|
if not input_files:
|
|
return {"error": "No video files found"}, 404
|
|
|
|
mp4_file = utilities.ffmpeg_concat_segments_to_mp4(input_files, cache_key=f"{name}-{camera}")
|
|
return send_file(mp4_file, mimetype="video/mp4")
|
|
|
|
return {"error": "Route not found"}, 404
|
|
|
|
@app.route("/api/routes/<name>", methods=["GET"])
|
|
def get_route(name):
|
|
for footage_path in FOOTAGE_PATHS:
|
|
base_path = f"{footage_path}{name}--0"
|
|
if os.path.exists(base_path):
|
|
segments = utilities.get_segments_in_route(name, footage_path)
|
|
if not segments:
|
|
break
|
|
|
|
segment_urls = [f"/video/{segment}" for segment in segments]
|
|
total_duration = sum(utilities.get_video_duration(f"{footage_path}{name}--{i}/fcamera.hevc") for i in range(len(segment_urls)))
|
|
return {
|
|
"name": name,
|
|
"segment_urls": segment_urls,
|
|
"total_duration": round(total_duration),
|
|
"date": utilities.get_route_start_time(base_path),
|
|
"available_cameras": utilities.get_available_cameras(base_path),
|
|
}, 200
|
|
return {"error": "Route not found"}, 404
|
|
|
|
@app.route("/api/routes/clear_name", methods=["POST"])
|
|
def clear_route_name():
|
|
data = request.get_json()
|
|
route_name = data.get("name")
|
|
|
|
if not route_name:
|
|
return jsonify({"error": "Missing route name"}), 400
|
|
|
|
cleared = False
|
|
original_timestamp = None
|
|
for footage_path in FOOTAGE_PATHS:
|
|
if not os.path.exists(footage_path):
|
|
continue
|
|
|
|
segments_to_process = [s for s in os.listdir(footage_path) if s.startswith(route_name) and os.path.isdir(os.path.join(footage_path, s))]
|
|
if not segments_to_process:
|
|
continue
|
|
|
|
for segment in segments_to_process:
|
|
segment_dir = os.path.join(footage_path, segment)
|
|
for item in os.listdir(segment_dir):
|
|
if not item.endswith((".hevc", ".ts", ".png", ".gif")) and item not in utilities.LOG_CANDIDATES:
|
|
try:
|
|
os.remove(os.path.join(segment_dir, item))
|
|
cleared = True
|
|
except OSError:
|
|
pass
|
|
|
|
if cleared:
|
|
route_timestamp_dt = utilities.get_route_start_time(segment_dir)
|
|
original_timestamp = route_timestamp_dt.isoformat() if route_timestamp_dt else None
|
|
|
|
if cleared:
|
|
return jsonify({"message": "Route name cleared successfully!", "timestamp": original_timestamp}), 200
|
|
else:
|
|
return jsonify({"error": "Route not found or no custom name to clear"}), 404
|
|
|
|
@app.route("/api/routes/rename", methods=["POST"])
|
|
def rename_route():
|
|
data = request.get_json()
|
|
old_name = data.get("old")
|
|
new_name_raw = data.get("new")
|
|
|
|
if not old_name or not new_name_raw:
|
|
return jsonify({"error": "Missing old or new name"}), 400
|
|
|
|
new_name = utilities.secure_filename(new_name_raw)
|
|
renamed = False
|
|
|
|
for footage_path in FOOTAGE_PATHS:
|
|
if not os.path.exists(footage_path):
|
|
continue
|
|
|
|
segments_to_process = [s for s in os.listdir(footage_path) if s.startswith(old_name) and os.path.isdir(os.path.join(footage_path, s))]
|
|
if not segments_to_process:
|
|
continue
|
|
|
|
for segment in segments_to_process:
|
|
segment_dir = os.path.join(footage_path, segment)
|
|
for item in os.listdir(segment_dir):
|
|
if not item.endswith((".hevc", ".ts", ".png", ".gif", "rlog")):
|
|
try:
|
|
os.remove(os.path.join(segment_dir, item))
|
|
except OSError:
|
|
pass
|
|
|
|
for segment in segments_to_process:
|
|
segment_dir = os.path.join(footage_path, segment)
|
|
new_name_file_path = os.path.join(segment_dir, new_name)
|
|
|
|
try:
|
|
with open(new_name_file_path, "a"):
|
|
os.utime(new_name_file_path, None)
|
|
renamed = True
|
|
except OSError as e:
|
|
return jsonify({"error": f"Error creating new name file: {e}"}), 500
|
|
|
|
if renamed:
|
|
return jsonify({"message": "Route renamed successfully!"}), 200
|
|
else:
|
|
return jsonify({"error": "Route not found"}), 404
|
|
|
|
@app.route("/api/screen_recordings/delete/<path:filename>", methods=["DELETE"])
|
|
def delete_screen_recording(filename):
|
|
mp4_path = SCREEN_RECORDINGS_PATH / filename
|
|
if not mp4_path.exists():
|
|
return {"error": "File not found"}, 404
|
|
|
|
delete_file(str(mp4_path))
|
|
|
|
for ext in (".png", ".gif"):
|
|
thumb = mp4_path.with_suffix(ext)
|
|
if thumb.exists():
|
|
delete_file(str(thumb))
|
|
|
|
return {"message": "Deleted"}, 200
|
|
|
|
@app.route("/api/screen_recordings/delete_all", methods=["DELETE"])
|
|
def delete_all_screen_recordings():
|
|
files_to_delete = [f for f in os.listdir(SCREEN_RECORDINGS_PATH) if f.endswith(".mp4")]
|
|
for filename in files_to_delete:
|
|
delete_file(os.path.join(SCREEN_RECORDINGS_PATH, filename))
|
|
for ext in (".png", ".gif"):
|
|
thumb = os.path.join(SCREEN_RECORDINGS_PATH, filename.replace(".mp4", ext))
|
|
if os.path.exists(thumb):
|
|
delete_file(thumb)
|
|
return {"message": "All screen recordings deleted!"}, 200
|
|
|
|
@app.route("/api/screen_recordings/download/<path:filename>", methods=["GET"])
|
|
def download_screen_recording(filename):
|
|
return send_from_directory(SCREEN_RECORDINGS_PATH, filename, as_attachment=True)
|
|
|
|
@app.route("/api/screen_recordings/list", methods=["GET"])
|
|
def list_screen_recordings():
|
|
def generate():
|
|
recordings = sorted(
|
|
[recording for recording in SCREEN_RECORDINGS_PATH.glob("*.mp4") if not Path(f"{recording}.lock").exists()],
|
|
key=lambda p: p.stat().st_mtime,
|
|
reverse=True
|
|
)
|
|
total = len(recordings)
|
|
|
|
yield f"data: {json.dumps({'progress': 0, 'total': total})}\n\n"
|
|
|
|
with ThreadPoolExecutor(max_workers=10) as executor:
|
|
futures = {executor.submit(utilities.process_screen_recording, mp4): mp4 for mp4 in recordings}
|
|
for processed, future in enumerate(as_completed(futures), start=1):
|
|
try:
|
|
result = future.result()
|
|
yield f"data: {json.dumps({'recordings': [result]})}\n\n"
|
|
except Exception as exception:
|
|
print(f"Error processing recording: {exception}")
|
|
|
|
yield f"data: {json.dumps({'progress': processed, 'total': total})}\n\n"
|
|
|
|
return Response(generate(), mimetype="text/event-stream")
|
|
|
|
@app.route("/screen_recordings/<path:filename>", methods=["GET"])
|
|
def serve_screen_recording_asset(filename):
|
|
return send_from_directory(SCREEN_RECORDINGS_PATH, filename)
|
|
|
|
@app.route("/api/screen_recordings/rename", methods=["POST"])
|
|
def rename_screen_recording():
|
|
data = request.get_json() or {}
|
|
old = data.get("old")
|
|
new_raw = data.get("new")
|
|
|
|
if not old or not new_raw:
|
|
return {"error": "Missing filenames"}, 400
|
|
|
|
new = utilities.secure_filename(new_raw)
|
|
old_path = SCREEN_RECORDINGS_PATH / old
|
|
new_path = SCREEN_RECORDINGS_PATH / new
|
|
|
|
if not old_path.exists():
|
|
return {"error": "Original file not found"}, 404
|
|
|
|
if new_path.exists():
|
|
return {"error": "Target file already exists"}, 400
|
|
|
|
old_path.rename(new_path)
|
|
for extension in (".png", ".gif"):
|
|
old_thumb = old_path.with_suffix(extension)
|
|
new_thumb = new_path.with_suffix(extension)
|
|
|
|
if old_thumb.exists():
|
|
old_thumb.rename(new_thumb)
|
|
|
|
return {"message": "Renamed"}, 200
|
|
|
|
@app.route("/api/speed_limits", methods=["GET"])
|
|
def speed_limits():
|
|
data = json.loads(params.get("SpeedLimitsFiltered") or "[]")
|
|
|
|
buffer = BytesIO(json.dumps(data, indent=2).encode())
|
|
buffer.seek(0)
|
|
return send_file(buffer, as_attachment=True, download_name="speed_limits.json", mimetype="application/json")
|
|
|
|
def _speed_limits_status_payload():
|
|
status = params_memory.get("UpdateSpeedLimitsStatus", encoding="utf-8") or ""
|
|
processing = bool(status and status != "Completed!")
|
|
enabled = params.get_bool("SpeedLimitFiller")
|
|
is_onroad = params.get_bool("IsOnroad")
|
|
time_valid = system_time_valid()
|
|
is_metric = params.get_bool("IsMetric")
|
|
|
|
vision_enabled = params.get_bool("VisionSpeedLimitDetection")
|
|
vision_speed_limit = params_memory.get_float("VisionSpeedLimit") if vision_enabled else 0
|
|
vision_confidence = params_memory.get_float("VisionSpeedLimitConfidence") if vision_enabled else 0
|
|
vision_bookmark_count = params_memory.get_int("VisionSpeedLimitBookmarkCount") if vision_enabled else 0
|
|
vision_debug_session = params_memory.get("VisionSpeedLimitDebugSession", encoding="utf-8") if vision_enabled else ""
|
|
vision_last_event = params_memory.get("VisionSpeedLimitLastEvent", encoding="utf-8") if vision_enabled else ""
|
|
vision_status = params_memory.get("VisionSpeedLimitStatus", encoding="utf-8") or ("Idle" if vision_enabled else "Disabled")
|
|
vision_stream = params_memory.get("VisionSpeedLimitStream", encoding="utf-8") if vision_enabled else ""
|
|
vision_speed_unit = "km/h" if is_metric else "mph"
|
|
vision_display_speed = round(vision_speed_limit * (CV.MS_TO_KPH if is_metric else CV.MS_TO_MPH)) if vision_speed_limit > 0 else 0
|
|
|
|
network_connected = True
|
|
try:
|
|
network_connected = HARDWARE.get_network_type() != log.DeviceState.NetworkType.none
|
|
except Exception:
|
|
pass
|
|
|
|
overpass_requests = {}
|
|
try:
|
|
overpass_requests = json.loads(params.get("OverpassRequests", encoding="utf-8") or "{}")
|
|
except Exception:
|
|
pass
|
|
|
|
current_day = datetime.now(timezone.utc).day
|
|
saved_day = int(overpass_requests.get("day", current_day) or current_day)
|
|
total_requests = int(overpass_requests.get("total_requests", 0) or 0)
|
|
max_requests = int(overpass_requests.get("max_requests", 10000) or 10000)
|
|
if saved_day != current_day:
|
|
total_requests = 0
|
|
api_limit_hit = total_requests >= max_requests
|
|
|
|
reason = ""
|
|
if not enabled:
|
|
reason = "Enable Speed Limit Filler on the device first."
|
|
elif processing:
|
|
reason = status
|
|
elif is_onroad:
|
|
reason = "Processing is only available while parked."
|
|
elif not time_valid:
|
|
reason = "System time is not valid yet."
|
|
elif not network_connected:
|
|
reason = "Connect the device to the internet first."
|
|
elif api_limit_hit:
|
|
reason = "Today's Overpass API request limit has been reached."
|
|
|
|
return {
|
|
"apiLimitHit": api_limit_hit,
|
|
"canProcessNow": enabled and not processing and not is_onroad and time_valid and network_connected and not api_limit_hit,
|
|
"enabled": enabled,
|
|
"isOnroad": is_onroad,
|
|
"networkConnected": network_connected,
|
|
"processing": processing,
|
|
"reason": reason,
|
|
"status": status or "Idle",
|
|
"timeValid": time_valid,
|
|
"totalRequests": total_requests,
|
|
"maxRequests": max_requests,
|
|
"visionConfidence": vision_confidence,
|
|
"visionBookmarkCount": vision_bookmark_count,
|
|
"visionDebugSession": vision_debug_session,
|
|
"visionDisplaySpeed": vision_display_speed,
|
|
"visionEnabled": vision_enabled,
|
|
"visionLastEvent": vision_last_event,
|
|
"visionSpeedUnit": vision_speed_unit,
|
|
"visionStatus": vision_status,
|
|
"visionStream": vision_stream,
|
|
}
|
|
|
|
@app.route("/api/speed_limits/status", methods=["GET"])
|
|
def speed_limits_status():
|
|
return jsonify(_speed_limits_status_payload()), 200
|
|
|
|
@app.route("/api/speed_limits/process", methods=["POST"])
|
|
def process_speed_limits():
|
|
payload = _speed_limits_status_payload()
|
|
if not payload["canProcessNow"]:
|
|
return jsonify({"error": payload["reason"] or "Speed limit processing is unavailable right now."}), 409
|
|
|
|
params_memory.put("UpdateSpeedLimitsStatus", "Calculating...")
|
|
params_memory.put_bool("UpdateSpeedLimits", True)
|
|
|
|
return jsonify({"message": "Speed limit processing started.", "status": "Calculating..."}), 202
|
|
|
|
@app.route("/api/stats", methods=["GET"])
|
|
def get_stats():
|
|
cache_now = time.monotonic()
|
|
cached_payload = _STATS_RESPONSE_CACHE.get("payload")
|
|
if cached_payload is not None and cache_now - _STATS_RESPONSE_CACHE.get("updated_at", 0.0) < STATS_RESPONSE_CACHE_SECONDS:
|
|
return cached_payload
|
|
|
|
build_metadata = get_build_metadata()
|
|
|
|
short_branch = build_metadata.channel
|
|
if short_branch == "StarPilot":
|
|
galaxy_label = "Stable"
|
|
elif short_branch == "Dom":
|
|
galaxy_label = "Testing"
|
|
else:
|
|
galaxy_label = "Experimental"
|
|
|
|
software_info = {
|
|
"branchName": build_metadata.channel,
|
|
"buildEnvironment": galaxy_label,
|
|
"changelogUrl": utilities.get_github_changelog_url(build_metadata.openpilot.git_normalized_origin, build_metadata.channel),
|
|
"commitHash": build_metadata.openpilot.git_commit,
|
|
"commitUrl": utilities.get_github_commit_url(build_metadata.openpilot.git_normalized_origin, build_metadata.openpilot.git_commit),
|
|
"forkMaintainer": utilities.get_repo_owner(build_metadata.openpilot.git_normalized_origin),
|
|
"updateAvailable": "Yes" if params.get_bool("UpdaterFetchAvailable") else "No",
|
|
"versionDate": utilities.format_git_date(build_metadata.openpilot.git_commit_date),
|
|
}
|
|
|
|
try:
|
|
dashboard_stats = utilities.get_dashboard_stats(FOOTAGE_PATHS, params)
|
|
except Exception:
|
|
dashboard_stats = utilities.get_dashboard_stats([], params)
|
|
|
|
payload = {
|
|
"diskUsage": utilities.get_disk_usage(),
|
|
"driveStats": utilities.get_drive_stats(),
|
|
"softwareInfo": software_info,
|
|
"dashboard": dashboard_stats,
|
|
}
|
|
_STATS_RESPONSE_CACHE.update({
|
|
"updated_at": cache_now,
|
|
"payload": payload,
|
|
})
|
|
return payload
|
|
|
|
@app.route("/api/stats/ignore_drive", methods=["POST"])
|
|
def ignore_drive_stats():
|
|
request_data = request.get_json() or {}
|
|
route_names = request_data.get("routeNames", [])
|
|
if not isinstance(route_names, list):
|
|
return jsonify({"error": "routeNames must be a list."}), 400
|
|
|
|
try:
|
|
ignored_routes = utilities.ignore_dashboard_routes(params, route_names)
|
|
except ValueError as exception:
|
|
return jsonify({"error": str(exception)}), 400
|
|
|
|
_STATS_RESPONSE_CACHE.update({
|
|
"updated_at": 0.0,
|
|
"payload": None,
|
|
})
|
|
return jsonify({
|
|
"message": "Drive statistics ignored.",
|
|
"routeNames": ignored_routes,
|
|
}), 200
|
|
|
|
@app.route("/api/stats/include_drive", methods=["POST"])
|
|
def include_drive_stats():
|
|
request_data = request.get_json() or {}
|
|
route_names = request_data.get("routeNames", [])
|
|
if not isinstance(route_names, list):
|
|
return jsonify({"error": "routeNames must be a list."}), 400
|
|
|
|
try:
|
|
included_routes = utilities.include_dashboard_routes(params, route_names)
|
|
except ValueError as exception:
|
|
return jsonify({"error": str(exception)}), 400
|
|
|
|
_STATS_RESPONSE_CACHE.update({
|
|
"updated_at": 0.0,
|
|
"payload": None,
|
|
})
|
|
return jsonify({
|
|
"message": "Drive statistics included.",
|
|
"routeNames": included_routes,
|
|
}), 200
|
|
|
|
@app.route("/api/plots/live", methods=["GET"])
|
|
def get_live_plots():
|
|
_ensure_plots_worker()
|
|
with _plots_lock:
|
|
payload = dict(_plots_state)
|
|
|
|
timestamp = _safe_float(payload.get("timestamp", 0.0), 0.0)
|
|
age_seconds = max(0.0, time.time() - timestamp) if timestamp else 999.0
|
|
|
|
return jsonify({
|
|
**payload,
|
|
"isOnroad": params.get_bool("IsOnroad"),
|
|
"bootStabilizing": _is_plots_boot_stabilizing(),
|
|
"sampleAgeSeconds": round(age_seconds, 3),
|
|
"stale": age_seconds > _PLOTS_SAMPLE_STALE_AFTER_S,
|
|
}), 200
|
|
|
|
@app.route("/api/testing_grounds", methods=["GET"])
|
|
def get_testing_grounds():
|
|
state = _get_testing_grounds_state()
|
|
return jsonify({
|
|
**_serialize_testing_grounds_state(state),
|
|
"isOnroad": params.get_bool("IsOnroad"),
|
|
}), 200
|
|
|
|
@app.route("/api/testing_grounds/select", methods=["POST"])
|
|
def select_testing_ground():
|
|
request_data = request.get_json() or {}
|
|
slot_id = str(request_data.get("slotId") or "").strip()
|
|
variant = str(request_data.get("variant") or "").strip().upper()
|
|
|
|
if not slot_id:
|
|
return jsonify({"error": "Missing 'slotId' in request body."}), 400
|
|
|
|
try:
|
|
state, changed = _set_testing_ground_selection(slot_id, variant)
|
|
except ValueError as exception:
|
|
return jsonify({"error": str(exception)}), 400
|
|
except Exception as exception:
|
|
return jsonify({"error": str(exception)}), 500
|
|
|
|
if changed:
|
|
_publish_testing_ground_custom_reserved(state, "manual_change")
|
|
|
|
slot = _find_testing_ground_slot(state, slot_id)
|
|
slot_name = slot.get("name", f"Testing Ground {slot_id}")
|
|
selected_variant = str(state.get("activeVariant") or _TESTING_GROUNDS_DEFAULT_VARIANT)
|
|
return jsonify({
|
|
"message": f"{slot_name} set to variant {selected_variant}.",
|
|
**_serialize_testing_grounds_state(state),
|
|
"isOnroad": params.get_bool("IsOnroad"),
|
|
}), 200
|
|
|
|
@app.route("/api/longitudinal_maneuvers/status", methods=["GET"])
|
|
def get_longitudinal_maneuvers_status():
|
|
status = _load_longitudinal_maneuver_status()
|
|
return jsonify(_serialize_longitudinal_maneuver_status(status)), 200
|
|
|
|
@app.route("/api/longitudinal_maneuvers/start", methods=["POST"])
|
|
def start_longitudinal_maneuvers():
|
|
if params.get_bool("LateralManeuverMode"):
|
|
_set_lateral_maneuver_mode(False)
|
|
status = _set_longitudinal_maneuver_mode(True)
|
|
return jsonify({
|
|
"message": "Longitudinal maneuver mode armed. Engage with SET to start.",
|
|
**_serialize_longitudinal_maneuver_status(status),
|
|
}), 200
|
|
|
|
@app.route("/api/longitudinal_maneuvers/stop", methods=["POST"])
|
|
def stop_longitudinal_maneuvers():
|
|
status = _set_longitudinal_maneuver_mode(False)
|
|
return jsonify({
|
|
"message": "Longitudinal maneuver mode disabled.",
|
|
**_serialize_longitudinal_maneuver_status(status),
|
|
}), 200
|
|
|
|
@app.route("/api/lateral_maneuvers/status", methods=["GET"])
|
|
def get_lateral_maneuvers_status():
|
|
status = _load_lateral_maneuver_status()
|
|
return jsonify(_serialize_lateral_maneuver_status(status)), 200
|
|
|
|
@app.route("/api/lateral_maneuvers/start", methods=["POST"])
|
|
def start_lateral_maneuvers():
|
|
if params.get_bool("LongitudinalManeuverMode"):
|
|
_set_longitudinal_maneuver_mode(False)
|
|
status = _set_lateral_maneuver_mode(True)
|
|
return jsonify({
|
|
"message": "Lateral maneuver mode armed. Stabilize on a straight, flat road to start.",
|
|
**_serialize_lateral_maneuver_status(status),
|
|
}), 200
|
|
|
|
@app.route("/api/lateral_maneuvers/stop", methods=["POST"])
|
|
def stop_lateral_maneuvers():
|
|
status = _set_lateral_maneuver_mode(False)
|
|
return jsonify({
|
|
"message": "Lateral maneuver mode disabled.",
|
|
**_serialize_lateral_maneuver_status(status),
|
|
}), 200
|
|
|
|
@app.route(f"{LEGACY_LATERAL_METHOD_API_PREFIX}/status", methods=["GET"])
|
|
@app.route("/api/flm/status", methods=["GET"])
|
|
def get_flm_status():
|
|
is_onroad = params.get_bool("IsOnroad")
|
|
if is_onroad:
|
|
flm_workspace.cancel_flm_if_onroad()
|
|
workspace = flm_workspace.list_workspace()
|
|
return jsonify({
|
|
"isOnroad": is_onroad,
|
|
"status": flm_workspace.read_flm_status(),
|
|
"activeTrial": workspace.get("activeTrial"),
|
|
"reports": workspace.get("reports", [])[:10],
|
|
"savedTunes": workspace.get("savedTunes", []),
|
|
}), 200
|
|
|
|
@app.route(f"{LEGACY_LATERAL_METHOD_API_PREFIX}/analyze", methods=["POST"])
|
|
@app.route("/api/flm/analyze", methods=["POST"])
|
|
def start_flm_analysis():
|
|
if params.get_bool("IsOnroad"):
|
|
return jsonify({"error": "FLM analysis can only run offroad."}), 409
|
|
|
|
data = request.get_json(silent=True) or {}
|
|
route_names = [str(route).strip() for route in data.get("routes", []) if str(route).strip()]
|
|
if not route_names:
|
|
return jsonify({"error": "No routes were selected."}), 400
|
|
|
|
try:
|
|
segment_ranges = flm_workspace.normalize_segment_ranges(route_names, data.get("segmentRanges", {}))
|
|
except (TypeError, ValueError) as error:
|
|
return jsonify({"error": str(error)}), 400
|
|
|
|
started = flm_workspace.start_flm_background_analysis(route_names, FOOTAGE_PATHS, segment_ranges)
|
|
if not started:
|
|
return jsonify({"error": "Failed to start FLM analysis."}), 500
|
|
|
|
return jsonify({
|
|
"message": f"Started FLM analysis for {len(route_names[:flm_workspace.FLM_ANALYZER_ROUTE_LIMIT])} route(s).",
|
|
"status": flm_workspace.read_flm_status(),
|
|
}), 200
|
|
|
|
@app.route(f"{LEGACY_LATERAL_METHOD_API_PREFIX}/analyze/stop", methods=["POST"])
|
|
@app.route("/api/flm/analyze/stop", methods=["POST"])
|
|
def stop_flm_analysis():
|
|
stopped = flm_workspace.stop_flm_background_analysis()
|
|
return jsonify({
|
|
"message": "Stopped FLM analysis." if stopped else "No active FLM analysis was running.",
|
|
"stopped": bool(stopped),
|
|
"status": flm_workspace.read_flm_status(),
|
|
}), 200
|
|
|
|
@app.route(f"{LEGACY_LATERAL_METHOD_API_PREFIX}/report/<report_id>", methods=["GET"])
|
|
@app.route("/api/flm/report/<report_id>", methods=["GET"])
|
|
def get_flm_report(report_id):
|
|
try:
|
|
return jsonify(flm_workspace.load_report(report_id)), 200
|
|
except FileNotFoundError:
|
|
return jsonify({"error": "FLM report not found."}), 404
|
|
|
|
@app.route(f"{LEGACY_LATERAL_METHOD_API_PREFIX}/report/<report_id>", methods=["DELETE"])
|
|
@app.route("/api/flm/report/<report_id>", methods=["DELETE"])
|
|
def delete_flm_report(report_id):
|
|
try:
|
|
return jsonify(flm_workspace.delete_report(report_id)), 200
|
|
except FileNotFoundError:
|
|
return jsonify({"error": "FLM report not found."}), 404
|
|
except RuntimeError as error:
|
|
return jsonify({"error": str(error)}), 409
|
|
|
|
@app.route(f"{LEGACY_LATERAL_METHOD_API_PREFIX}/report/<report_id>/path", methods=["POST"])
|
|
@app.route("/api/flm/report/<report_id>/path", methods=["POST"])
|
|
def select_flm_report_path(report_id):
|
|
data = request.get_json(silent=True) or {}
|
|
path_key = str(data.get("pathKey") or "").strip()
|
|
if not path_key:
|
|
return jsonify({"error": "pathKey is required."}), 400
|
|
|
|
try:
|
|
return jsonify(flm_workspace.select_report_path(report_id, path_key)), 200
|
|
except FileNotFoundError:
|
|
return jsonify({"error": "FLM report not found."}), 404
|
|
except ValueError as error:
|
|
return jsonify({"error": str(error)}), 400
|
|
|
|
@app.route(f"{LEGACY_LATERAL_METHOD_API_PREFIX}/workspace", methods=["GET"])
|
|
@app.route("/api/flm/workspace", methods=["GET"])
|
|
def get_flm_workspace():
|
|
return jsonify(flm_workspace.list_workspace()), 200
|
|
|
|
@app.route(f"{LEGACY_LATERAL_METHOD_API_PREFIX}/workspace/clear", methods=["POST"])
|
|
@app.route("/api/flm/workspace/clear", methods=["POST"])
|
|
def clear_flm_workspace():
|
|
try:
|
|
return jsonify(flm_workspace.clear_workspace()), 200
|
|
except RuntimeError as error:
|
|
return jsonify({"error": str(error)}), 409
|
|
|
|
@app.route(f"{LEGACY_LATERAL_METHOD_API_PREFIX}/saved-tunes", methods=["POST"])
|
|
@app.route("/api/flm/saved-tunes", methods=["POST"])
|
|
def save_flm_tune():
|
|
data = request.get_json(silent=True) or {}
|
|
try:
|
|
return jsonify(flm_workspace.save_active_trial_as_tune(str(data.get("name") or ""))), 200
|
|
except ValueError as error:
|
|
return jsonify({"error": str(error)}), 400
|
|
except RuntimeError as error:
|
|
return jsonify({"error": str(error)}), 409
|
|
|
|
@app.route(f"{LEGACY_LATERAL_METHOD_API_PREFIX}/saved-tunes/<tune_id>/apply", methods=["POST"])
|
|
@app.route("/api/flm/saved-tunes/<tune_id>/apply", methods=["POST"])
|
|
def apply_flm_saved_tune(tune_id):
|
|
try:
|
|
return jsonify(flm_workspace.apply_saved_tune(tune_id)), 200
|
|
except FileNotFoundError:
|
|
return jsonify({"error": "Saved FLM tune not found."}), 404
|
|
except RuntimeError as error:
|
|
return jsonify({"error": str(error)}), 409
|
|
|
|
@app.route(f"{LEGACY_LATERAL_METHOD_API_PREFIX}/saved-tunes/<tune_id>/submit", methods=["POST"])
|
|
@app.route("/api/flm/saved-tunes/<tune_id>/submit", methods=["POST"])
|
|
def submit_flm_saved_tune(tune_id):
|
|
data = request.get_json(silent=True) or {}
|
|
try:
|
|
return jsonify(flm_workspace.submit_saved_tune(tune_id, str(data.get("discordUsername") or ""))), 200
|
|
except FileNotFoundError:
|
|
return jsonify({"error": "Saved FLM tune not found."}), 404
|
|
except ValueError as error:
|
|
return jsonify({"error": str(error)}), 400
|
|
except RuntimeError as error:
|
|
return jsonify({"error": str(error)}), 409
|
|
|
|
@app.route(f"{LEGACY_LATERAL_METHOD_API_PREFIX}/saved-tunes/<tune_id>", methods=["PATCH"])
|
|
@app.route("/api/flm/saved-tunes/<tune_id>", methods=["PATCH"])
|
|
def rename_flm_saved_tune(tune_id):
|
|
data = request.get_json(silent=True) or {}
|
|
try:
|
|
return jsonify(flm_workspace.rename_saved_tune(tune_id, str(data.get("name") or ""))), 200
|
|
except FileNotFoundError:
|
|
return jsonify({"error": "Saved FLM tune not found."}), 404
|
|
except ValueError as error:
|
|
return jsonify({"error": str(error)}), 400
|
|
|
|
@app.route(f"{LEGACY_LATERAL_METHOD_API_PREFIX}/saved-tunes/<tune_id>", methods=["DELETE"])
|
|
@app.route("/api/flm/saved-tunes/<tune_id>", methods=["DELETE"])
|
|
def delete_flm_saved_tune(tune_id):
|
|
try:
|
|
return jsonify(flm_workspace.delete_saved_tune(tune_id)), 200
|
|
except FileNotFoundError:
|
|
return jsonify({"error": "Saved FLM tune not found."}), 404
|
|
except RuntimeError as error:
|
|
return jsonify({"error": str(error)}), 409
|
|
|
|
@app.route(f"{LEGACY_LATERAL_METHOD_API_PREFIX}/trials/apply", methods=["POST"])
|
|
@app.route("/api/flm/trials/apply", methods=["POST"])
|
|
def apply_flm_trial():
|
|
data = request.get_json(silent=True) or {}
|
|
report_id = str(data.get("reportId") or "").strip()
|
|
profile_id = str(data.get("profileId") or "").strip()
|
|
if not report_id or not profile_id:
|
|
return jsonify({"error": "Both reportId and profileId are required."}), 400
|
|
|
|
try:
|
|
result = flm_workspace.apply_trial_profile(report_id, profile_id)
|
|
except FileNotFoundError:
|
|
return jsonify({"error": "FLM profile not found."}), 404
|
|
except RuntimeError as error:
|
|
return jsonify({"error": str(error)}), 409
|
|
|
|
return jsonify(result), 200
|
|
|
|
@app.route(f"{LEGACY_LATERAL_METHOD_API_PREFIX}/trials/revert", methods=["POST"])
|
|
@app.route("/api/flm/trials/revert", methods=["POST"])
|
|
def revert_flm_trial():
|
|
try:
|
|
result = flm_workspace.revert_trial_profile()
|
|
except FileNotFoundError:
|
|
return jsonify({"error": "No active FLM trial snapshot was found."}), 404
|
|
except Exception as error:
|
|
return jsonify({"error": f"{type(error).__name__}: {error}"}), 500
|
|
|
|
return jsonify(result), 200
|
|
|
|
@app.route(f"{LEGACY_LATERAL_METHOD_API_PREFIX}/trials/accept", methods=["POST"])
|
|
@app.route("/api/flm/trials/accept", methods=["POST"])
|
|
def accept_flm_trial():
|
|
try:
|
|
result = flm_workspace.accept_trial_as_baseline()
|
|
except FileNotFoundError:
|
|
return jsonify({"error": "No active FLM trial was found."}), 404
|
|
|
|
return jsonify(result), 200
|
|
|
|
@app.route(f"{LEGACY_LATERAL_METHOD_API_PREFIX}/feedback", methods=["POST"])
|
|
@app.route("/api/flm/feedback", methods=["POST"])
|
|
def save_flm_feedback():
|
|
data = request.get_json(silent=True) or {}
|
|
report_id = str(data.get("reportId") or "").strip()
|
|
if not report_id:
|
|
return jsonify({"error": "reportId is required."}), 400
|
|
|
|
feedback = {
|
|
"acceptedDimensions": data.get("acceptedDimensions", []),
|
|
"ignoredDimensions": data.get("ignoredDimensions", []),
|
|
"notes": data.get("notes", ""),
|
|
}
|
|
try:
|
|
result = flm_workspace.record_feedback(report_id, feedback)
|
|
except FileNotFoundError:
|
|
return jsonify({"error": "FLM report not found."}), 404
|
|
|
|
return jsonify(result), 200
|
|
|
|
@app.route("/api/update/fast/status", methods=["GET"])
|
|
def get_fast_update_status():
|
|
state_data = _get_fast_update_state()
|
|
repo_path = str(_get_openpilot_root())
|
|
git_data = _collect_fast_update_info(include_remote=not state_data.get("running", False))
|
|
return jsonify({
|
|
**state_data,
|
|
**git_data,
|
|
"isOnroad": _safe_params_get_bool("IsOnroad"),
|
|
"automaticUpdates": _safe_params_get_bool("AutomaticUpdates"),
|
|
"interruptedUpdateRecovery": _get_interrupted_update_recovery(repo_path, state_data),
|
|
"warning": "Fast update skips backup creation and finalization safeguards.",
|
|
}), 200
|
|
|
|
@app.route("/api/update/recover", methods=["POST"])
|
|
def recover_update():
|
|
if _safe_params_get_bool("IsOnroad"):
|
|
return jsonify({"error": "Cannot recover an interrupted update while driving."}), 409
|
|
|
|
repo_path = str(_get_openpilot_root())
|
|
with _fast_update_lock:
|
|
if _fast_update_state.get("running"):
|
|
return jsonify({"error": "An update action is still in progress."}), 409
|
|
|
|
recovered, recovery_status = recover_interrupted_update(
|
|
repo_path,
|
|
is_onroad=False,
|
|
update_running=False,
|
|
updater_state=_safe_params_get("UpdaterState", encoding="utf-8", default=""),
|
|
)
|
|
if not recovered:
|
|
return jsonify({
|
|
"error": recovery_status.get("reason") or "The interrupted update could not be recovered safely.",
|
|
"interruptedUpdateRecovery": recovery_status,
|
|
}), 409
|
|
|
|
_fast_update_state.update({
|
|
"running": False,
|
|
"stage": "idle",
|
|
"message": "Interrupted update recovered. Ready to retry.",
|
|
"lastError": "",
|
|
"finishedAt": time.time(),
|
|
"progressStep": 0,
|
|
"progressTotalSteps": _FAST_UPDATE_TOTAL_STEPS,
|
|
"progressStepPercent": 0.0,
|
|
"progressPercent": 0.0,
|
|
"progressLabel": "Ready",
|
|
"progressDetail": "Abandoned update lock cleared safely.",
|
|
})
|
|
|
|
return jsonify({
|
|
"message": "Interrupted update recovered. Retrying now...",
|
|
"interruptedUpdateRecovery": recovery_status,
|
|
}), 200
|
|
|
|
@app.route("/api/update/branches", methods=["GET"])
|
|
def get_update_branches():
|
|
state_data = _get_fast_update_state()
|
|
repo_path = str(_get_openpilot_root())
|
|
|
|
try:
|
|
current_branch = _git_stdout(repo_path, ["rev-parse", "--abbrev-ref", "HEAD"])
|
|
except Exception as exception:
|
|
return jsonify({"error": str(exception)}), 500
|
|
|
|
branches, remote_error = _list_origin_branches(repo_path, include_remote=not state_data.get("running", False))
|
|
if current_branch and current_branch not in branches:
|
|
branches = sorted([*branches, current_branch], key=lambda branch: branch.lower())
|
|
|
|
return jsonify({
|
|
"currentBranch": current_branch,
|
|
"branches": branches,
|
|
"remoteError": remote_error,
|
|
"isOnroad": _safe_params_get_bool("IsOnroad"),
|
|
"running": state_data.get("running", False),
|
|
}), 200
|
|
|
|
@app.route("/api/update/agnos_status", methods=["GET"])
|
|
def get_agnos_update_status():
|
|
state_data = _get_fast_update_state()
|
|
if state_data.get("running", False):
|
|
return jsonify({"error": "Cannot check AGNOS update status while an update action is running."}), 409
|
|
|
|
repo_path = str(_get_openpilot_root())
|
|
try:
|
|
current_branch = _git_stdout(repo_path, ["rev-parse", "--abbrev-ref", "HEAD"])
|
|
local_commit = _git_stdout(repo_path, ["rev-parse", "HEAD"])
|
|
origin_remote = _git_stdout(repo_path, ["config", "--get", "remote.origin.url"])
|
|
except Exception as exception:
|
|
return jsonify({"error": str(exception)}), 500
|
|
|
|
target_branch = str(request.args.get("branch") or current_branch or "").strip()
|
|
if not target_branch:
|
|
return jsonify({"error": "Missing target branch."}), 400
|
|
if not _is_valid_git_branch_name(repo_path, target_branch):
|
|
return jsonify({"error": "Invalid branch name."}), 400
|
|
if not _remote_git_check_allowed():
|
|
agnos_update = _base_agnos_update_status(target_branch, local_commit, "")
|
|
agnos_update["error"] = "Remote checks are deferred until system time is valid."
|
|
return jsonify({
|
|
"currentBranch": current_branch,
|
|
"targetBranch": target_branch,
|
|
"localCommit": local_commit,
|
|
"remoteCommit": "",
|
|
"agnosUpdate": agnos_update,
|
|
}), 200
|
|
|
|
remote_commit, remote_error = _get_remote_branch_commit(repo_path, target_branch)
|
|
if not remote_commit:
|
|
agnos_update = _base_agnos_update_status(target_branch, local_commit, "")
|
|
agnos_update["error"] = remote_error or f"Remote branch '{target_branch}' was not found."
|
|
else:
|
|
agnos_update = _build_agnos_update_status(repo_path, origin_remote, local_commit, remote_commit, target_branch)
|
|
|
|
return jsonify({
|
|
"currentBranch": current_branch,
|
|
"targetBranch": target_branch,
|
|
"localCommit": local_commit,
|
|
"remoteCommit": remote_commit,
|
|
"agnosUpdate": agnos_update,
|
|
}), 200
|
|
|
|
@app.route("/api/update/fast", methods=["POST"])
|
|
def run_fast_update():
|
|
if params.get_bool("IsOnroad"):
|
|
return jsonify({"error": "Cannot run a fast update while driving."}), 409
|
|
|
|
with _fast_update_lock:
|
|
if _fast_update_state.get("running"):
|
|
return jsonify({"error": "Fast update already in progress."}), 409
|
|
_fast_update_state.update({
|
|
"running": True,
|
|
"stage": "starting",
|
|
"message": "Starting fast update...",
|
|
"lastError": "",
|
|
"startedAt": time.time(),
|
|
"finishedAt": 0.0,
|
|
"progressStep": 1,
|
|
"progressTotalSteps": _FAST_UPDATE_TOTAL_STEPS,
|
|
"progressStepPercent": 0.0,
|
|
"progressPercent": 0.0,
|
|
"progressLabel": "Preparing update",
|
|
"progressDetail": "Initializing update process...",
|
|
})
|
|
|
|
threading.Thread(target=_fast_update_worker, daemon=True).start()
|
|
|
|
return jsonify({
|
|
"message": "Fast update started. Device will reboot when complete.",
|
|
"warning": "Fast update skips backup creation and finalization safeguards.",
|
|
}), 202
|
|
|
|
@app.route("/api/update/branch", methods=["POST"])
|
|
def run_branch_switch():
|
|
if params.get_bool("IsOnroad"):
|
|
return jsonify({"error": "Cannot switch branches while driving."}), 409
|
|
|
|
request_data = request.get_json() or {}
|
|
target_branch = str(request_data.get("branch") or "").strip()
|
|
if not target_branch:
|
|
return jsonify({"error": "Missing 'branch' in request body."}), 400
|
|
|
|
repo_path = str(_get_openpilot_root())
|
|
if not _is_valid_git_branch_name(repo_path, target_branch):
|
|
return jsonify({"error": "Invalid branch name."}), 400
|
|
|
|
with _fast_update_lock:
|
|
if _fast_update_state.get("running"):
|
|
return jsonify({"error": "Another update action is already in progress."}), 409
|
|
_fast_update_state.update({
|
|
"running": True,
|
|
"stage": "starting",
|
|
"message": f"Starting branch switch to '{target_branch}'...",
|
|
"lastError": "",
|
|
"lastBranch": target_branch,
|
|
"lastMode": "branch-switch",
|
|
"startedAt": time.time(),
|
|
"finishedAt": 0.0,
|
|
"progressStep": 1,
|
|
"progressTotalSteps": _FAST_UPDATE_TOTAL_STEPS,
|
|
"progressStepPercent": 0.0,
|
|
"progressPercent": 0.0,
|
|
"progressLabel": "Preparing branch switch",
|
|
"progressDetail": "Initializing branch switch...",
|
|
})
|
|
|
|
threading.Thread(target=_branch_switch_worker, args=(target_branch,), daemon=True).start()
|
|
|
|
return jsonify({
|
|
"message": f"Branch switch started for '{target_branch}'. Device will reboot when complete.",
|
|
"warning": "Fast update skips backup creation and finalization safeguards.",
|
|
}), 202
|
|
|
|
@app.route("/api/update/rollback", methods=["POST"])
|
|
def run_update_rollback():
|
|
if params.get_bool("IsOnroad"):
|
|
return jsonify({"error": "Cannot roll back while driving."}), 409
|
|
|
|
repo_path = str(_get_openpilot_root())
|
|
rollback_state = _load_rollback_target(repo_path)
|
|
target_branch = str(rollback_state.get("rollbackBranch") or "").strip()
|
|
target_commit = str(rollback_state.get("rollbackCommit") or "").strip()
|
|
rollback_available = bool(rollback_state.get("rollbackAvailable"))
|
|
|
|
if not target_commit:
|
|
return jsonify({"error": "No previous installed version has been recorded yet."}), 409
|
|
if not target_branch:
|
|
return jsonify({"error": "Saved rollback branch is missing."}), 409
|
|
if not rollback_available:
|
|
return jsonify({"error": "Current install already matches the saved previous version."}), 409
|
|
|
|
with _fast_update_lock:
|
|
if _fast_update_state.get("running"):
|
|
return jsonify({"error": "Another update action is already in progress."}), 409
|
|
_fast_update_state.update({
|
|
"running": True,
|
|
"stage": "starting",
|
|
"message": f"Starting rollback to '{target_branch}'...",
|
|
"lastError": "",
|
|
"lastBranch": target_branch,
|
|
"lastMode": "rollback",
|
|
"startedAt": time.time(),
|
|
"finishedAt": 0.0,
|
|
"progressStep": 1,
|
|
"progressTotalSteps": _FAST_UPDATE_TOTAL_STEPS,
|
|
"progressStepPercent": 0.0,
|
|
"progressPercent": 0.0,
|
|
"progressLabel": "Preparing rollback",
|
|
"progressDetail": "Initializing rollback...",
|
|
})
|
|
|
|
params.put_bool("AutomaticUpdates", False)
|
|
threading.Thread(target=_rollback_worker, daemon=True).start()
|
|
|
|
return jsonify({
|
|
"message": f"Rollback started for the previous installed version on '{target_branch}'. Automatic updates were disabled and the device will reboot when complete.",
|
|
"warning": "Rollback restores the previously installed version recorded before the last Galaxy update.",
|
|
}), 202
|
|
|
|
@app.route("/api/update/factory_reset", methods=["POST"])
|
|
def run_factory_reset():
|
|
if _safe_params_get_bool("IsOnroad"):
|
|
return jsonify({"error": "Cannot run a factory reset while driving."}), 409
|
|
|
|
with _fast_update_lock:
|
|
if _fast_update_state.get("running"):
|
|
return jsonify({"error": "Another update action is already in progress."}), 409
|
|
_fast_update_state.update({
|
|
"running": True,
|
|
"stage": "starting",
|
|
"message": "Starting factory reset...",
|
|
"lastError": "",
|
|
"lastBranch": "",
|
|
"lastMode": "factory-reset",
|
|
"startedAt": time.time(),
|
|
"finishedAt": 0.0,
|
|
"progressStep": 1,
|
|
"progressTotalSteps": _FAST_UPDATE_TOTAL_STEPS,
|
|
"progressStepPercent": 0.0,
|
|
"progressPercent": 0.0,
|
|
"progressLabel": "Preparing factory reset",
|
|
"progressDetail": "Initializing factory reset...",
|
|
})
|
|
|
|
threading.Thread(target=_factory_reset_worker, daemon=True).start()
|
|
|
|
return jsonify({
|
|
"message": "Factory reset started. Device will reboot when complete.",
|
|
"warning": "This wipes local params, backups, themes, models, maps, and route data.",
|
|
}), 202
|
|
|
|
# ── Galaxy pairing (mirrors settings.cc L262-282) ──────────────────
|
|
GALAXY_DIR = _get_galaxy_dir()
|
|
GALAXY_AUTH_FILE = GALAXY_DIR / "glxyauth"
|
|
GALAXY_SESSION_FILE = GALAXY_DIR / "glxysession"
|
|
GALAXY_SLUG_FILE = GALAXY_DIR / "glxyslug"
|
|
|
|
@app.route("/api/galaxy/status", methods=["GET"])
|
|
def galaxy_status():
|
|
paired = len(_read_galaxy_text(GALAXY_AUTH_FILE)) == 64
|
|
slug = _read_galaxy_text(GALAXY_SLUG_FILE)
|
|
return jsonify({
|
|
"paired": paired,
|
|
"url": f"https://galaxy.firestar.link/{slug}" if slug else "",
|
|
})
|
|
|
|
@app.route("/api/galaxy/session", methods=["GET"])
|
|
def galaxy_session():
|
|
slug = _read_galaxy_text(GALAXY_SLUG_FILE)
|
|
token = _read_galaxy_text(GALAXY_SESSION_FILE)
|
|
paired = len(_read_galaxy_text(GALAXY_AUTH_FILE)) == 64 and bool(slug and token)
|
|
return jsonify({
|
|
"appUrl": GALAXY_PLAY_STORE_URL,
|
|
"cookieName": GALAXY_COOKIE_NAME,
|
|
"paired": paired,
|
|
"sessionToken": _build_galaxy_session_value(slug, token),
|
|
})
|
|
|
|
@app.route("/api/galaxy/pair", methods=["POST"])
|
|
def galaxy_pair():
|
|
data = request.get_json() or {}
|
|
password = (data.get("password") or "").strip()
|
|
if len(password) < 6:
|
|
return jsonify({"error": "Password must be at least 6 characters."}), 400
|
|
|
|
pw_hash = hashlib.sha256(password.encode()).hexdigest()
|
|
GALAXY_DIR.mkdir(parents=True, exist_ok=True)
|
|
GALAXY_AUTH_FILE.write_text(pw_hash)
|
|
|
|
# Generate 256-bit secure session token
|
|
GALAXY_SESSION_FILE.write_text(secrets.token_hex(32))
|
|
|
|
# Generate 16-character alphanumeric routing slug
|
|
charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
|
slug = ''.join(secrets.choice(charset) for _ in range(16))
|
|
GALAXY_SLUG_FILE.write_text(slug)
|
|
|
|
return jsonify({
|
|
"message": "Pairing successful!",
|
|
"url": f"https://galaxy.firestar.link/{slug}",
|
|
})
|
|
|
|
@app.route("/api/galaxy/unpair", methods=["POST"])
|
|
def galaxy_unpair():
|
|
for f in ["glxyauth", "glxysession", "glxyslug"]:
|
|
file_path = GALAXY_DIR / f
|
|
if file_path.is_file():
|
|
file_path.unlink()
|
|
return jsonify({"message": "Galaxy unpaired successfully."})
|
|
|
|
@app.route("/api/tailscale/installed", methods=["GET"])
|
|
def tailscale_installed():
|
|
base = "/data/tailscale"
|
|
tailscale_binary = f"{base}/tailscale"
|
|
tailscaled_binary = f"{base}/tailscaled"
|
|
|
|
systemd_unit = "/etc/systemd/system/tailscaled.service"
|
|
|
|
if os.path.exists(tailscale_binary) and os.path.exists(tailscaled_binary) and os.path.exists(systemd_unit):
|
|
return jsonify({"installed": True})
|
|
|
|
result = subprocess.run(["which", "tailscale"], capture_output=True, text=True)
|
|
if result.returncode == 0:
|
|
return jsonify({"installed": True})
|
|
|
|
return jsonify({"installed": False})
|
|
|
|
@app.route("/api/tailscale/setup", methods=["POST"])
|
|
def tailscale_setup():
|
|
arch = "arm64"
|
|
base = "/data/tailscale"
|
|
|
|
result = subprocess.run(
|
|
"curl -s https://pkgs.tailscale.com/stable/ | grep -oP 'tailscale_\\K[0-9]+\\.[0-9]+\\.[0-9]+' | sort -V | tail -1",
|
|
shell=True, capture_output=True, text=True
|
|
)
|
|
|
|
version = result.stdout.strip() or "1.84.0"
|
|
|
|
bin_dir = f"{base}/tailscale_{version}_{arch}"
|
|
state = f"{base}/state"
|
|
socket = f"{base}/tailscaled.sock"
|
|
tgz_path = f"{base}/tailscale.tgz"
|
|
|
|
tgz_url = f"https://pkgs.tailscale.com/stable/tailscale_{version}_{arch}.tgz"
|
|
|
|
os.makedirs(state, exist_ok=True)
|
|
|
|
run_cmd(["curl", "-fsSL", tgz_url, "-o", tgz_path], "Downloaded Tailscale archive.", "Failed to download Tailscale archive.")
|
|
|
|
extract_tar(tgz_path, base)
|
|
|
|
run_cmd(["cp", f"{bin_dir}/tailscale", f"{base}/tailscale"], "Copied tailscale binary.", "Failed to copy tailscale binary.")
|
|
run_cmd(["cp", f"{bin_dir}/tailscaled", f"{base}/tailscaled"], "Copied tailscaled binary.", "Failed to copy tailscaled binary.")
|
|
run_cmd(["chmod", "+x", f"{base}/tailscale", f"{base}/tailscaled"], "Made binaries executable.", "Failed to chmod binaries.")
|
|
|
|
systemd_unit = f"""[Unit]
|
|
Description=Tailscale node agent
|
|
After=network.target
|
|
|
|
[Service]
|
|
ExecStart={base}/tailscaled \\
|
|
--tun=userspace-networking \\
|
|
--socks5-server=localhost:1055 \\
|
|
--state={state}/tailscaled.state \\
|
|
--socket={socket} \\
|
|
--statedir={state}
|
|
Restart=on-failure
|
|
RestartSec=5
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
"""
|
|
unit_tmp = f"{base}/tailscaled.service"
|
|
with open(unit_tmp, "w") as f:
|
|
f.write(systemd_unit)
|
|
|
|
run_cmd(["sudo", "mount", "-o", "remount,rw", "/"], "Remounted / as read-write.", "Failed to remount / as read-write.")
|
|
run_cmd(["sudo", "install", "-m", "644", unit_tmp, "/etc/systemd/system/tailscaled.service"], "Installed systemd unit.", "Failed to install systemd unit.")
|
|
run_cmd(["sudo", "systemctl", "daemon-reload"], "Reloaded systemd daemon.", "Failed to reload systemd daemon.")
|
|
run_cmd(["sudo", "systemctl", "enable", "/etc/systemd/system/tailscaled.service"], "Enabled tailscaled service.", "Failed to enable tailscaled service.")
|
|
run_cmd(["sudo", "systemctl", "restart", "tailscaled"], "Started tailscaled service.", "Failed to start tailscaled service.")
|
|
|
|
proc = subprocess.Popen(
|
|
["sudo", f"{base}/tailscale", "--socket", socket, "up", "--hostname", f"{HARDWARE.get_device_type()}-the-galaxy"],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
text=True,
|
|
preexec_fn=os.setsid
|
|
)
|
|
|
|
auth_url = None
|
|
for line in proc.stdout:
|
|
match = re.search(r"https://login\.tailscale\.com/\S+", line)
|
|
if match and not auth_url:
|
|
auth_url = match.group(0)
|
|
run_cmd(["sudo", "kill", "-TERM", f"-{proc.pid}"], "Sent SIGTERM to Tailscale setup process.", "Failed to send SIGTERM to Tailscale setup process.")
|
|
proc.wait(timeout=5)
|
|
break
|
|
|
|
return jsonify({
|
|
"message": "Tailscale setup started. Please authenticate in your browser.",
|
|
"auth_url": auth_url
|
|
}), 200
|
|
|
|
@app.route("/api/tailscale/uninstall", methods=["POST"])
|
|
def tailscale_uninstall():
|
|
base = "/data/tailscale"
|
|
state = f"{base}/state"
|
|
unit_path = "/etc/systemd/system/tailscaled.service"
|
|
local_unit = f"{base}/tailscaled.service"
|
|
|
|
run_cmd(["sudo", "mount", "-o", "remount,rw", "/"], "Remounted / as read-write.", "Failed to remount /.")
|
|
run_cmd(["sudo", "systemctl", "stop", "tailscaled"], "Stopped tailscaled.", "Failed to stop tailscaled.")
|
|
run_cmd(["sudo", "systemctl", "disable", "tailscaled"], "Disabled tailscaled.", "Failed to disable tailscaled.")
|
|
|
|
if os.path.exists(unit_path):
|
|
run_cmd(["sudo", "rm", unit_path], "Removed systemd unit file.", "Failed to remove systemd unit file.")
|
|
run_cmd(["sudo", "systemctl", "daemon-reload"], "Reloaded systemd daemon.", "Failed to reload systemd.")
|
|
|
|
delete_file(local_unit)
|
|
|
|
for filename in ["tailscale", "tailscaled", "tailscale.tgz"]:
|
|
delete_file(os.path.join(base, filename))
|
|
|
|
for item in os.listdir(base):
|
|
if item.startswith("tailscale_"):
|
|
item_path = os.path.join(base, item)
|
|
if os.path.isdir(item_path):
|
|
run_cmd(["sudo", "rm", "-rf", item_path], f"Removed {item_path}.", f"Failed to remove {item_path}.")
|
|
|
|
if os.path.exists(state):
|
|
run_cmd(["sudo", "rm", "-rf", state], "Removed tailscale state dir.", "Failed to remove tailscale state dir.")
|
|
|
|
if os.path.exists(base):
|
|
run_cmd(["sudo", "rm", "-rf", base], "Removed tailscale dir.", "Failed to remove tailscale dir.")
|
|
|
|
return jsonify({"message": "Tailscale uninstalled!"}), 200
|
|
|
|
@app.route("/api/themes", methods=["POST"])
|
|
def save_theme_route():
|
|
theme_path, error = utilities.create_theme(request.form, request.files)
|
|
if error:
|
|
return jsonify({"message": error}), 400
|
|
return jsonify({"message": f'Theme "{request.form.get("themeName")}" saved!'}), 200
|
|
|
|
@app.route("/api/themes/download_asset", methods=["POST"])
|
|
def start_download_asset():
|
|
data = request.get_json() or {}
|
|
raw_component = (data.get("component") or "").strip()
|
|
display_name = (data.get("name") or "").strip()
|
|
if not raw_component or not display_name:
|
|
return jsonify({"error": "Missing component or name"}), 400
|
|
|
|
component = "steering_wheels" if raw_component == "steering_wheel" else ("signals" if raw_component == "turn_signals" else raw_component)
|
|
mem_key = THEME_COMPONENT_PARAMS.get(component)
|
|
if not mem_key:
|
|
return jsonify({"error": "Unknown component"}), 400
|
|
|
|
slug = display_name.lower().replace("(", "").replace(")", "").replace(" ", "_")
|
|
|
|
params_memory.put(mem_key, slug)
|
|
params_memory.put("ThemeDownloadProgress", "Downloading...")
|
|
|
|
return jsonify({"message": "Download started", "component": component, "param": mem_key, "slug": slug}), 200
|
|
|
|
@app.route("/api/themes/apply", methods=["POST"])
|
|
def apply_theme():
|
|
try:
|
|
form_data = request.form.to_dict(flat=True)
|
|
files = request.files
|
|
|
|
if not form_data.get("themeName"):
|
|
form_data["themeName"] = f"tmp_{secrets.token_hex(8)}"
|
|
|
|
temp_path, error = utilities.create_theme(form_data, files, temporary=True)
|
|
if error:
|
|
return jsonify({"error": error}), 400
|
|
|
|
save_checklist = json.loads(form_data.get("saveChecklist", "{}"))
|
|
selected_theme_sources = json.loads(form_data.get("selectedThemeSources", "{}"))
|
|
|
|
theme_param_map = {
|
|
"colors": "ColorScheme",
|
|
"distance_icons": "DistanceIconPack",
|
|
"icons": "IconPack",
|
|
"sounds": "SoundPack",
|
|
"turn_signals": "SignalAnimation",
|
|
"steering_wheel": "WheelIcon",
|
|
}
|
|
|
|
def get_selected_theme_value(asset_type):
|
|
source = selected_theme_sources.get(asset_type)
|
|
if not isinstance(source, dict):
|
|
return None
|
|
|
|
source_type = str(source.get("type") or "").strip().lower()
|
|
source_path = str(source.get("path") or "").strip()
|
|
if not source_path:
|
|
return None
|
|
|
|
if source_type == "stock":
|
|
return "stock"
|
|
if source_type == "stock_none":
|
|
return "none"
|
|
if source_path == "__stock__":
|
|
return "stock"
|
|
if source_path == "__stock_none__":
|
|
return "none"
|
|
if asset_type == "steering_wheel":
|
|
return Path(source_path).stem
|
|
return source_path
|
|
|
|
for asset_type, param_key in theme_param_map.items():
|
|
if save_checklist.get(asset_type):
|
|
selected_value = get_selected_theme_value(asset_type)
|
|
if selected_value is not None:
|
|
params.put(param_key, selected_value)
|
|
|
|
if save_checklist.get("colors") and temp_path is not None:
|
|
asset_location = temp_path / "colors"
|
|
save_location = ACTIVE_THEME_PATH / "colors"
|
|
if save_location.exists() or save_location.is_symlink():
|
|
delete_file(save_location)
|
|
if asset_location.exists():
|
|
save_location.parent.mkdir(parents=True, exist_ok=True)
|
|
save_location.symlink_to(asset_location, target_is_directory=True)
|
|
|
|
if save_checklist.get("distance_icons") and temp_path is not None:
|
|
asset_location = temp_path / "distance_icons"
|
|
save_location = ACTIVE_THEME_PATH / "distance_icons"
|
|
if save_location.exists() or save_location.is_symlink():
|
|
delete_file(save_location)
|
|
if asset_location.exists():
|
|
save_location.parent.mkdir(parents=True, exist_ok=True)
|
|
save_location.symlink_to(asset_location, target_is_directory=True)
|
|
|
|
if save_checklist.get("icons") and temp_path is not None:
|
|
asset_location = temp_path / "icons"
|
|
save_location = ACTIVE_THEME_PATH / "icons"
|
|
if save_location.exists() or save_location.is_symlink():
|
|
delete_file(save_location)
|
|
if asset_location.exists():
|
|
save_location.parent.mkdir(parents=True, exist_ok=True)
|
|
save_location.symlink_to(asset_location, target_is_directory=True)
|
|
|
|
if save_checklist.get("sounds") and temp_path is not None:
|
|
asset_location = temp_path / "sounds"
|
|
save_location = ACTIVE_THEME_PATH / "sounds"
|
|
if save_location.exists() or save_location.is_symlink():
|
|
delete_file(save_location)
|
|
if asset_location.exists():
|
|
save_location.parent.mkdir(parents=True, exist_ok=True)
|
|
save_location.symlink_to(asset_location, target_is_directory=True)
|
|
|
|
if save_checklist.get("turn_signals") and temp_path is not None:
|
|
asset_location = temp_path / "signals"
|
|
save_location = ACTIVE_THEME_PATH / "signals"
|
|
if save_location.exists() or save_location.is_symlink():
|
|
delete_file(save_location)
|
|
if asset_location.exists():
|
|
save_location.parent.mkdir(parents=True, exist_ok=True)
|
|
save_location.symlink_to(asset_location, target_is_directory=True)
|
|
|
|
wheel_location = (temp_path / "WheelIcon") if temp_path is not None else None
|
|
wheel_save_location = ACTIVE_THEME_PATH / "steering_wheel"
|
|
if wheel_location is not None and wheel_location.exists():
|
|
if wheel_save_location.exists():
|
|
delete_file(wheel_save_location)
|
|
|
|
wheel_save_location.mkdir(parents=True, exist_ok=True)
|
|
for file in wheel_location.iterdir():
|
|
destination_file = wheel_save_location / file.name
|
|
delete_file(destination_file)
|
|
destination_file.symlink_to(file)
|
|
|
|
params.put_bool("CustomThemes", True)
|
|
params_memory.put_bool("UseActiveTheme", True)
|
|
|
|
update_starpilot_toggles()
|
|
return jsonify({"message": "Theme applied successfully!"}), 200
|
|
except Exception as e:
|
|
return jsonify({"error": f"Failed to apply theme: {e}"}), 500
|
|
|
|
def _resolve_stock_theme_asset_path(asset_path):
|
|
stock_asset_path = STOCK_THEME_PATH / asset_path
|
|
if stock_asset_path.exists():
|
|
return stock_asset_path
|
|
|
|
stock_fallbacks = {
|
|
"icons/button_home.png": STOCK_THEME_PATH.parents[2] / "selfdrive" / "assets" / "images" / "button_home.png",
|
|
"icons/button_settings.png": STOCK_THEME_PATH.parents[2] / "selfdrive" / "assets" / "images" / "button_settings.png",
|
|
"sounds/disengage.wav": STOCK_THEME_PATH.parents[2] / "selfdrive" / "assets" / "sounds" / "disengage.wav",
|
|
"sounds/engage.wav": STOCK_THEME_PATH.parents[2] / "selfdrive" / "assets" / "sounds" / "engage.wav",
|
|
"sounds/prompt.wav": STOCK_THEME_PATH.parents[2] / "selfdrive" / "assets" / "sounds" / "prompt.wav",
|
|
# Stock openpilot has no dedicated startup clip; runtime falls back to engage.
|
|
"sounds/startup.wav": STOCK_THEME_PATH.parents[2] / "selfdrive" / "assets" / "sounds" / "engage.wav",
|
|
"steering_wheel/wheel.png": STOCK_THEME_PATH.parents[2] / "selfdrive" / "assets" / "icons" / "chffr_wheel.png",
|
|
}
|
|
|
|
fallback_path = stock_fallbacks.get(asset_path)
|
|
if fallback_path is not None and fallback_path.exists():
|
|
return fallback_path
|
|
|
|
return stock_asset_path
|
|
|
|
@app.route("/api/themes/asset/<path:theme>/<path:asset_path>")
|
|
def get_theme_asset(theme, asset_path):
|
|
theme_type = request.args.get("type", "")
|
|
|
|
if theme_type == "active" or theme == "__active__":
|
|
file_path = ACTIVE_THEME_PATH / asset_path
|
|
elif theme_type == "stock" or theme == "__stock__":
|
|
file_path = _resolve_stock_theme_asset_path(asset_path)
|
|
elif asset_path.startswith("steering_wheels/"):
|
|
file_path = THEME_SAVE_PATH / asset_path
|
|
elif asset_path.startswith("steering_wheel/") and "holiday" in theme_type:
|
|
file_path = HOLIDAY_THEME_PATH / theme / asset_path
|
|
else:
|
|
base_dir = HOLIDAY_THEME_PATH / theme if "holiday" in theme_type else THEME_SAVE_PATH / "theme_packs" / theme
|
|
file_path = base_dir / asset_path
|
|
|
|
if not file_path.exists():
|
|
return "File not found", 404
|
|
|
|
return send_file(file_path, as_attachment=False)
|
|
|
|
@app.route("/api/themes/delete/<path:theme_path_str>", methods=["DELETE"])
|
|
def delete_theme(theme_path_str):
|
|
theme_type = request.args.get("type", "user")
|
|
component = (request.args.get("component") or "").strip()
|
|
|
|
if theme_type == "holiday":
|
|
return jsonify({"message": "Cannot delete holiday themes."}), 403
|
|
|
|
if theme_type == "steering_wheel":
|
|
wheel_path = THEME_SAVE_PATH / "steering_wheels" / theme_path_str
|
|
if wheel_path.exists():
|
|
delete_file(wheel_path)
|
|
return jsonify({"message": f'Steering wheel "{utilities.normalize_theme_name(wheel_path.stem)}" deleted!'}), 200
|
|
return jsonify({"message": "Steering wheel not found..."}), 404
|
|
|
|
theme_path = THEME_SAVE_PATH / "theme_packs" / theme_path_str
|
|
if not theme_path.is_dir():
|
|
return jsonify({"message": "Theme not found..."}), 404
|
|
|
|
if component:
|
|
allowed = {"colors", "distance_icons", "icons", "sounds", "signals"}
|
|
if component not in allowed:
|
|
return jsonify({"message": "Unknown component..."}), 400
|
|
|
|
target = theme_path / component
|
|
if not target.exists():
|
|
return jsonify({"message": f'Component "{component}" not found in theme...'}), 404
|
|
|
|
delete_file(target)
|
|
|
|
return jsonify({"message": f'Removed {component.replace("_", " ")} from "{utilities.normalize_theme_name(theme_path.name)}"!'}), 200
|
|
|
|
delete_file(theme_path)
|
|
return jsonify({"message": f'Theme "{utilities.normalize_theme_name(theme_path.name)}" deleted!'}), 200
|
|
|
|
@app.route("/api/themes/default", methods=["GET"])
|
|
def get_default_theme():
|
|
theme_data = {
|
|
"colors": {},
|
|
"images": {},
|
|
"sounds": {},
|
|
"turnSignalLength": 100,
|
|
"turnSignalType": "Single Image",
|
|
"sequentialImages": [],
|
|
"theme_names": {}
|
|
}
|
|
|
|
if not params.get_bool("CustomThemes"):
|
|
theme_data["theme_names"] = {
|
|
"colors": "Stock",
|
|
"distanceIcons": "Stock",
|
|
"icons": "Stock",
|
|
"sounds": "Stock",
|
|
"turnSignals": "Stock",
|
|
"steeringWheel": "Stock"
|
|
}
|
|
else:
|
|
theme_param_map = {
|
|
"ColorScheme": "colors",
|
|
"DistanceIconPack": "distanceIcons",
|
|
"IconPack": "icons",
|
|
"SoundPack": "sounds",
|
|
"SignalAnimation": "turnSignals",
|
|
"WheelIcon": "steeringWheel"
|
|
}
|
|
for param, theme_key in theme_param_map.items():
|
|
param_value = params.get(param, encoding="utf-8")
|
|
if param_value:
|
|
theme_data["theme_names"][theme_key] = utilities.normalize_theme_name(param_value)
|
|
|
|
colors_path = ACTIVE_THEME_PATH / "colors" / "colors.json"
|
|
if colors_path.exists():
|
|
with open(colors_path, "r") as f:
|
|
theme_data["colors"] = json.load(f)
|
|
|
|
signals_dir = ACTIVE_THEME_PATH / "signals"
|
|
if signals_dir.exists():
|
|
sequential_files = sorted([f.name for f in signals_dir.glob("turn_signal_*.png") if "blindspot" not in f.name.lower()])
|
|
if sequential_files:
|
|
theme_data["sequentialImages"] = sequential_files
|
|
theme_data["turnSignalType"] = "Sequential"
|
|
|
|
theme_data["turnSignalStyle"] = "Traditional"
|
|
theme_data["turnSignalLength"] = 100
|
|
|
|
for file in os.listdir(signals_dir):
|
|
if not any(file.endswith(ext) for ext in [".png", ".gif", ".jpg", ".jpeg"]):
|
|
parts = file.split("_")
|
|
if len(parts) == 2:
|
|
theme_data["turnSignalStyle"] = parts[0].capitalize()
|
|
try:
|
|
theme_data["turnSignalLength"] = int(parts[1])
|
|
except ValueError:
|
|
pass
|
|
break
|
|
|
|
exts = [".png", ".gif", ".jpg", ".jpeg"]
|
|
for ext in exts:
|
|
p = signals_dir / f"turn_signal{ext}"
|
|
if p.exists():
|
|
theme_data["images"]["turnSignal"] = f"turn_signal{ext}"
|
|
break
|
|
for ext in exts:
|
|
p = signals_dir / f"turn_signal_blindspot{ext}"
|
|
if p.exists():
|
|
theme_data["images"]["turnSignalBlindspot"] = f"turn_signal_blindspot{ext}"
|
|
break
|
|
|
|
icons_path = ACTIVE_THEME_PATH / "icons"
|
|
if icons_path.exists() and icons_path.is_dir():
|
|
for file in os.listdir(icons_path):
|
|
if Path(file).stem == "button_settings":
|
|
theme_data["images"]["settingsButton"] = file
|
|
elif Path(file).stem == "button_home":
|
|
theme_data["images"]["homeButton"] = file
|
|
|
|
wheel_path = ACTIVE_THEME_PATH / "steering_wheel"
|
|
if wheel_path.exists() and wheel_path.is_dir():
|
|
wheel_files = list(wheel_path.glob("wheel.*"))
|
|
if wheel_files:
|
|
theme_data["images"]["steeringWheel"] = wheel_files[0].name
|
|
|
|
distance_icons_path = ACTIVE_THEME_PATH / "distance_icons"
|
|
if distance_icons_path.exists() and distance_icons_path.is_dir():
|
|
theme_data["images"]["distanceIcons"] = {}
|
|
for file in os.listdir(distance_icons_path):
|
|
key = Path(file).stem
|
|
if key in ["traffic", "aggressive", "standard", "relaxed"]:
|
|
theme_data["images"]["distanceIcons"][key] = file
|
|
|
|
sounds_path = ACTIVE_THEME_PATH / "sounds"
|
|
if sounds_path.exists() and sounds_path.is_dir():
|
|
valid_sound_keys = ["engage", "disengage", "prompt", "startup"]
|
|
for file in os.listdir(sounds_path):
|
|
stem = Path(file).stem
|
|
if stem in valid_sound_keys:
|
|
theme_data["sounds"][stem] = file
|
|
|
|
return jsonify(theme_data)
|
|
|
|
@app.route("/api/themes/download", methods=["POST"])
|
|
def download_theme_route():
|
|
theme_path, error = utilities.create_theme(request.form, request.files, temporary=True)
|
|
if error:
|
|
return jsonify({"message": error}), 400
|
|
|
|
sane_theme_name = utilities.normalize_theme_name(request.form.get("themeName"), for_path=True)
|
|
|
|
archive_path = shutil.make_archive(str(theme_path.parent / sane_theme_name), "zip", theme_path.parent, sane_theme_name)
|
|
|
|
memory_file = BytesIO()
|
|
with open(archive_path, "rb") as f:
|
|
memory_file.write(f.read())
|
|
memory_file.seek(0)
|
|
|
|
delete_file(theme_path.parent)
|
|
|
|
return send_file(memory_file, download_name=f'{sane_theme_name}.zip', as_attachment=True)
|
|
|
|
@app.route("/api/themes/list", methods=["GET"])
|
|
def list_themes():
|
|
all_themes = []
|
|
themes_path = THEME_SAVE_PATH / "theme_packs"
|
|
|
|
if themes_path.exists():
|
|
for theme_dir in themes_path.iterdir():
|
|
if theme_dir.is_dir():
|
|
is_user_created = "-user_created" in theme_dir.name
|
|
components = utilities.check_theme_components(theme_dir)
|
|
all_themes.append({
|
|
"name": utilities.normalize_theme_name(theme_dir.name),
|
|
"path": theme_dir.name,
|
|
"type": "user" if is_user_created else "standard",
|
|
"is_user_created": is_user_created,
|
|
**components
|
|
})
|
|
|
|
if HOLIDAY_THEME_PATH.exists():
|
|
for theme_dir in HOLIDAY_THEME_PATH.iterdir():
|
|
if theme_dir.is_dir():
|
|
components = utilities.check_theme_components(theme_dir)
|
|
all_themes.append({
|
|
"name": utilities.normalize_theme_name(theme_dir.name),
|
|
"path": theme_dir.name,
|
|
"type": "holiday",
|
|
"is_user_created": False,
|
|
**components
|
|
})
|
|
|
|
wheels_path = THEME_SAVE_PATH / "steering_wheels"
|
|
if wheels_path.exists():
|
|
for wheel_file in wheels_path.iterdir():
|
|
all_themes.append({
|
|
"name": utilities.normalize_theme_name(wheel_file.stem),
|
|
"path": wheel_file.name,
|
|
"type": "steering_wheel",
|
|
"is_user_created": "-user_created" in wheel_file.name,
|
|
"hasSteeringWheel": True,
|
|
})
|
|
|
|
return jsonify({"themes": sorted(all_themes, key=lambda x: x['name'])})
|
|
|
|
@app.route("/api/themes/load/<path:theme_path>")
|
|
def load_theme(theme_path):
|
|
theme_type = request.args.get("type", "")
|
|
if theme_type == "stock" or theme_path == "__stock__":
|
|
theme_dir = STOCK_THEME_PATH
|
|
else:
|
|
theme_dir = HOLIDAY_THEME_PATH / theme_path if "holiday" in theme_type else THEME_SAVE_PATH / "theme_packs" / theme_path
|
|
|
|
response_data = {
|
|
"colors": None,
|
|
"images": {},
|
|
"sounds": {},
|
|
"sequentialImages": [],
|
|
"turnSignalType": "Single Image",
|
|
"turnSignalStyle": "Static",
|
|
"turnSignalLength": 100
|
|
}
|
|
|
|
colors_file = theme_dir / "colors" / "colors.json"
|
|
if colors_file.exists():
|
|
with open(colors_file) as f:
|
|
response_data["colors"] = json.load(f)
|
|
|
|
icons_dir = theme_dir / "icons"
|
|
if icons_dir.exists():
|
|
for base_name, response_key in [("button_home", "homeButton"), ("button_settings", "settingsButton")]:
|
|
for ext in [".gif", ".png", ".jpg", ".jpeg"]:
|
|
filename = f"{base_name}{ext}"
|
|
if (icons_dir / filename).exists():
|
|
response_data["images"][response_key] = {
|
|
"filename": filename,
|
|
"path": f"icons/{filename}"
|
|
}
|
|
break
|
|
elif theme_type == "stock" or theme_path == "__stock__":
|
|
for filename, response_key in [("button_home.png", "homeButton"), ("button_settings.png", "settingsButton")]:
|
|
if _resolve_stock_theme_asset_path(f"icons/{filename}").exists():
|
|
response_data["images"][response_key] = {
|
|
"filename": filename,
|
|
"path": f"icons/{filename}",
|
|
}
|
|
|
|
distance_dir = theme_dir / "distance_icons"
|
|
if distance_dir.exists():
|
|
response_data["images"]["distanceIcons"] = {}
|
|
exts = [".png", ".gif", ".jpg", ".jpeg"]
|
|
for name in ["aggressive", "relaxed", "standard", "traffic"]:
|
|
for ext in exts:
|
|
p = distance_dir / f"{name}{ext}"
|
|
if p.exists():
|
|
response_data["images"]["distanceIcons"][name] = {
|
|
"filename": f"{name}{ext}",
|
|
"path": f"distance_icons/{name}{ext}"
|
|
}
|
|
break
|
|
|
|
signals_dir = theme_dir / "signals"
|
|
if signals_dir.exists():
|
|
sequential_files = sorted([f.name for f in signals_dir.glob("turn_signal_*.png") if "blindspot" not in f.name.lower()])
|
|
if sequential_files:
|
|
response_data["sequentialImages"] = sequential_files
|
|
response_data["turnSignalType"] = "Sequential"
|
|
|
|
response_data["turnSignalStyle"] = "Traditional"
|
|
response_data["turnSignalLength"] = 100
|
|
|
|
for file in os.listdir(signals_dir):
|
|
if not any(file.endswith(ext) for ext in [".png", ".gif", ".jpg", ".jpeg"]):
|
|
parts = file.split("_")
|
|
if len(parts) == 2:
|
|
response_data["turnSignalStyle"] = parts[0].capitalize()
|
|
try:
|
|
response_data["turnSignalLength"] = int(parts[1])
|
|
except ValueError:
|
|
pass
|
|
break
|
|
|
|
exts = [".png", ".gif", ".jpg", ".jpeg"]
|
|
for ext in exts:
|
|
p = signals_dir / f"turn_signal{ext}"
|
|
if p.exists():
|
|
response_data["images"]["turnSignal"] = {
|
|
"filename": f"turn_signal{ext}",
|
|
"path": f"signals/turn_signal{ext}",
|
|
}
|
|
break
|
|
for ext in exts:
|
|
p = signals_dir / f"turn_signal_blindspot{ext}"
|
|
if p.exists():
|
|
response_data["images"]["turnSignalBlindspot"] = {
|
|
"filename": f"turn_signal_blindspot{ext}",
|
|
"path": f"signals/turn_signal_blindspot{ext}",
|
|
}
|
|
break
|
|
|
|
sounds_dir = theme_dir / "sounds"
|
|
if sounds_dir.exists():
|
|
for name in ["engage", "disengage", "startup", "prompt"]:
|
|
file_path = sounds_dir / f"{name}.wav"
|
|
if file_path.exists():
|
|
response_data["sounds"][name] = {
|
|
"filename": f"{name}.wav",
|
|
"path": f"sounds/{name}.wav"
|
|
}
|
|
elif theme_type == "stock" or theme_path == "__stock__":
|
|
for name in ["engage", "disengage", "startup", "prompt"]:
|
|
if _resolve_stock_theme_asset_path(f"sounds/{name}.wav").exists():
|
|
response_data["sounds"][name] = {
|
|
"filename": f"{name}.wav",
|
|
"path": f"sounds/{name}.wav"
|
|
}
|
|
|
|
steering_wheel_path = None
|
|
if theme_type == "stock" or theme_path == "__stock__":
|
|
if _resolve_stock_theme_asset_path("steering_wheel/wheel.png").exists():
|
|
steering_wheel_path = "steering_wheel/wheel.png"
|
|
elif "holiday" in theme_type:
|
|
steering_dir = theme_dir / "steering_wheel"
|
|
if steering_dir.exists() and steering_dir.is_dir():
|
|
for file in steering_dir.iterdir():
|
|
if file.is_file() and file.suffix.lower() in [".png", ".jpg", ".jpeg", ".gif"]:
|
|
steering_wheel_path = f"steering_wheel/{file.name}"
|
|
break
|
|
else:
|
|
steering_wheels_dir = THEME_SAVE_PATH / "steering_wheels"
|
|
if steering_wheels_dir.exists():
|
|
for file in steering_wheels_dir.iterdir():
|
|
if file.is_file() and file.stem.lower() == theme_path.lower() and file.suffix.lower() in [".png", ".jpg", ".jpeg", ".gif"]:
|
|
steering_wheel_path = f"steering_wheels/{file.name}"
|
|
break
|
|
|
|
if steering_wheel_path:
|
|
response_data["images"]["steeringWheel"] = {
|
|
"filename": steering_wheel_path.split("/")[-1],
|
|
"path": steering_wheel_path
|
|
}
|
|
|
|
return jsonify(response_data)
|
|
|
|
@app.route("/api/themes/submit", methods=["POST"])
|
|
def submit_theme():
|
|
if not GITLAB_TOKEN:
|
|
return jsonify({"error": "Missing GitLab token"}), 500
|
|
|
|
try:
|
|
theme_name = request.form.get("themeName")
|
|
if not theme_name:
|
|
return jsonify({"error": "Missing theme name"}), 400
|
|
|
|
discord_username = request.form.get("discordUsername") or "Unknown"
|
|
|
|
theme_path, error = utilities.create_theme(request.form, request.files, temporary=True)
|
|
if error:
|
|
return jsonify({"message": error}), 400
|
|
|
|
safe_theme_name = utilities.normalize_theme_name(theme_name, for_path=True)
|
|
combined_name = f"{safe_theme_name}~{discord_username}"
|
|
timestamp = int(time.time())
|
|
|
|
def gitlab_post(project_id, endpoint, payload):
|
|
url = f"{GITLAB_API}/projects/{project_id}/{endpoint}"
|
|
resp = requests.post(url, headers={"PRIVATE-TOKEN": GITLAB_TOKEN}, json=payload)
|
|
if resp.status_code not in (200, 201):
|
|
raise RuntimeError(f"GitLab API error {resp.status_code}: {resp.text}")
|
|
return resp.json()
|
|
|
|
def encode_file_base64(path):
|
|
with open(path, "rb") as f:
|
|
return base64.b64encode(f.read()).decode("utf-8")
|
|
|
|
def send_discord_notification(username, theme_name, asset_types):
|
|
if not DISCORD_WEBHOOK_URL:
|
|
return
|
|
|
|
message = (
|
|
f"🎨 **New Theme Submission**\n"
|
|
f"User: `{username}`\n"
|
|
f"Theme: `{theme_name}`\n"
|
|
f"Assets: {', '.join(asset_types)}\n"
|
|
f"[View Submissions Repo](https://gitlab.com/{RESOURCES_REPO}-Submissions)\n"
|
|
f"<@263565721336807424>"
|
|
)
|
|
payload = {"content": message}
|
|
try:
|
|
resp = requests.post(DISCORD_WEBHOOK_URL, json=payload)
|
|
if resp.status_code not in (200, 204):
|
|
print(f"Discord notification failed: {resp.status_code} {resp.text}")
|
|
except Exception as exception:
|
|
print(f"Error sending Discord message: {exception}")
|
|
|
|
asset_types = []
|
|
submission_urls = {}
|
|
|
|
distance_icons_path = theme_path / "distance_icons"
|
|
if distance_icons_path.exists() and any(distance_icons_path.iterdir()):
|
|
zip_path = shutil.make_archive(str(distance_icons_path), "zip", distance_icons_path)
|
|
encoded = encode_file_base64(zip_path)
|
|
file_name = f"{combined_name}.zip"
|
|
actions = [
|
|
{
|
|
"action": "create",
|
|
"file_path": file_name,
|
|
"content": encoded,
|
|
"encoding": "base64"
|
|
}
|
|
]
|
|
commit_payload = {
|
|
"branch": "Distance-Icons",
|
|
"commit_message": f"Added Distance Icons: {combined_name}",
|
|
"actions": actions
|
|
}
|
|
gitlab_post(GITLAB_SUBMISSIONS_PROJECT_ID, "repository/commits", commit_payload)
|
|
asset_types.append("Distance Icons")
|
|
submission_urls["distance_icons"] = f"https://gitlab.com/{RESOURCES_REPO}-Submissions/-/tree/Distance-Icons"
|
|
|
|
theme_actions = []
|
|
for folder in ["colors", "icons", "signals", "sounds"]:
|
|
folder_path = theme_path / folder
|
|
if folder_path.exists() and any(folder_path.iterdir()):
|
|
zip_path = shutil.make_archive(str(folder_path), "zip", folder_path)
|
|
encoded = encode_file_base64(zip_path)
|
|
file_path = f"{combined_name}/{folder}.zip"
|
|
theme_actions.append({
|
|
"action": "create",
|
|
"file_path": file_path,
|
|
"content": encoded,
|
|
"encoding": "base64"
|
|
})
|
|
|
|
if theme_actions:
|
|
commit_payload = {
|
|
"branch": "Themes",
|
|
"commit_message": f"Added Theme: {combined_name}",
|
|
"actions": theme_actions
|
|
}
|
|
gitlab_post(GITLAB_SUBMISSIONS_PROJECT_ID, "repository/commits", commit_payload)
|
|
asset_types.append("Theme")
|
|
submission_urls["theme"] = f"https://gitlab.com/{RESOURCES_REPO}-Submissions/-/tree/Themes"
|
|
|
|
wheel_file = request.files.get("steeringWheel")
|
|
if wheel_file and wheel_file.filename:
|
|
suffix = Path(wheel_file.filename).suffix
|
|
file_name = f"{combined_name}{suffix}"
|
|
wheel_file.seek(0)
|
|
encoded_wheel = base64.b64encode(wheel_file.read()).decode("utf-8")
|
|
actions = [
|
|
{
|
|
"action": "create",
|
|
"file_path": file_name,
|
|
"content": encoded_wheel,
|
|
"encoding": "base64"
|
|
}
|
|
]
|
|
commit_payload = {
|
|
"branch": "Steering-Wheels",
|
|
"commit_message": f"Added Steering Wheel: {combined_name}",
|
|
"actions": actions
|
|
}
|
|
gitlab_post(GITLAB_SUBMISSIONS_PROJECT_ID, "repository/commits", commit_payload)
|
|
asset_types.append("Steering Wheel")
|
|
submission_urls["steering_wheel"] = f"https://gitlab.com/{RESOURCES_REPO}-Submissions/-/tree/Steering-Wheels"
|
|
|
|
if not submission_urls:
|
|
return jsonify({"error": "No valid theme data or steering wheel file provided"}), 400
|
|
|
|
send_discord_notification(discord_username, theme_name, asset_types)
|
|
|
|
return jsonify({
|
|
"message": "Submission successful!",
|
|
"branches": submission_urls
|
|
}), 200
|
|
|
|
except Exception as exception:
|
|
return jsonify({"error": str(exception)}), 500
|
|
|
|
finally:
|
|
if "theme_path" in locals() and theme_path.parent.exists():
|
|
delete_file(theme_path.parent)
|
|
|
|
@app.route("/api/tmux_log/capture", methods=["POST"])
|
|
def capture_tmux_log_route():
|
|
TMUX_LOGS_PATH.mkdir(parents=True, exist_ok=True)
|
|
|
|
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
|
log_filename = f"tmux_log_{timestamp}.json"
|
|
log_path = TMUX_LOGS_PATH / log_filename
|
|
|
|
run_cmd(["tmux", "capture-pane", "-J", "-S", "-"], "Captured tmux pane.", "Failed to capture tmux pane.")
|
|
|
|
result = subprocess.run(["tmux", "show-buffer"], capture_output=True, text=True, check=True)
|
|
log_path.write_text(result.stdout, encoding="utf-8")
|
|
|
|
run_cmd(["tmux", "delete-buffer"], "Deleted tmux buffer.", "Failed to delete tmux buffer.")
|
|
return jsonify({"message": "Captured console log successfully!", "log_file": log_filename}), 200
|
|
|
|
@app.route("/api/tmux_log/delete/<filename>", methods=["DELETE"])
|
|
def delete_tmux_log(filename):
|
|
file_path = TMUX_LOGS_PATH / filename
|
|
if file_path.exists():
|
|
delete_file(file_path)
|
|
return jsonify({"message": f"{filename} deleted!"}), 200
|
|
|
|
return jsonify({"error": "File not found"}), 404
|
|
|
|
@app.route("/api/tmux_log/delete_all", methods=["DELETE"])
|
|
def delete_all_tmux_logs():
|
|
if TMUX_LOGS_PATH.exists():
|
|
|
|
delete_file(TMUX_LOGS_PATH)
|
|
|
|
TMUX_LOGS_PATH.mkdir(parents=True, exist_ok=True)
|
|
return jsonify({"message": "All tmux logs deleted!"}), 200
|
|
|
|
@app.route("/api/tmux_log/download/<path:filename>", methods=["GET"])
|
|
def download_tmux_log(filename):
|
|
return send_from_directory(str(TMUX_LOGS_PATH), filename, as_attachment=True)
|
|
|
|
@app.route("/api/tmux_log/list", methods=["GET"])
|
|
def list_tmux_logs():
|
|
TMUX_LOGS_PATH.mkdir(parents=True, exist_ok=True)
|
|
files = sorted(TMUX_LOGS_PATH.glob("*.json"), key=lambda file: file.stat().st_mtime, reverse=True)
|
|
return jsonify([{"filename": file.name, "timestamp": file.stat().st_mtime} for file in files])
|
|
|
|
@app.route("/api/tmux_log/live", methods=["GET"])
|
|
def stream_tmux_log():
|
|
if subprocess.run(["tmux", "has-session", "-t", "comma"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode != 0:
|
|
run_cmd(["tmux", "new-session", "-d", "-s", "comma", "-x", "240", "-y", "70", "bash"], "Started tmux session", "Failed to start tmux session")
|
|
else:
|
|
run_cmd(["tmux", "resize-window", "-t", "comma:0", "-x", "240", "-y", "70"], "Resized tmux window", "Failed to resize tmux window")
|
|
|
|
def generate():
|
|
last_output = ""
|
|
last_keepalive = 0.0
|
|
while True:
|
|
output = subprocess.check_output(["tmux", "capture-pane", "-t", "comma:0", "-p", "-S", "-1000"], text=True)
|
|
|
|
if output != last_output:
|
|
yield "data: " + "\n".join(reversed(output.splitlines())).replace("\n", "\ndata: ") + "\n\n"
|
|
last_output = output
|
|
last_keepalive = time.monotonic()
|
|
elif (time.monotonic() - last_keepalive) >= 5.0:
|
|
# Keep SSE alive through proxies/tunnels even when output is unchanged.
|
|
yield ": keepalive\n\n"
|
|
last_keepalive = time.monotonic()
|
|
|
|
time.sleep(0.5)
|
|
response = Response(generate(), mimetype="text/event-stream")
|
|
response.headers["Cache-Control"] = "no-cache"
|
|
response.headers["X-Accel-Buffering"] = "no"
|
|
return response
|
|
|
|
@app.route("/api/tmux_log/snapshot", methods=["GET"])
|
|
def snapshot_tmux_log():
|
|
try:
|
|
output = subprocess.check_output(["tmux", "capture-pane", "-t", "comma:0", "-p", "-S", "-1000"], text=True)
|
|
except subprocess.CalledProcessError:
|
|
run_cmd(["tmux", "new-session", "-d", "-s", "comma", "-x", "240", "-y", "70", "bash"], "Started tmux session", "Failed to start tmux session")
|
|
output = subprocess.check_output(["tmux", "capture-pane", "-t", "comma:0", "-p", "-S", "-1000"], text=True)
|
|
except Exception as e:
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
try:
|
|
live_text = "\n".join(reversed(output.splitlines()))
|
|
return jsonify({"data": live_text}), 200
|
|
except Exception as e:
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route("/api/tmux_log/rename/<old>/<new>", methods=["PUT"])
|
|
def rename_tmux_log_path_params(old, new):
|
|
old_path = TMUX_LOGS_PATH / old
|
|
new_safe = utilities.secure_filename(new)
|
|
new_path = TMUX_LOGS_PATH / new_safe
|
|
|
|
if not old_path.exists():
|
|
return jsonify({"error": "Original file not found"}), 404
|
|
|
|
if new_path.exists():
|
|
return jsonify({"error": "Target file already exists"}), 400
|
|
|
|
old_path.rename(new_path)
|
|
|
|
return jsonify({"message": f"Renamed {old} to {new_safe}!"}), 200
|
|
|
|
|
|
@app.route("/api/tsk_keys", methods=["DELETE"])
|
|
def delete_secoc_key():
|
|
name = request.args.get("name")
|
|
keys = json.loads(params.get("SecOCKeys") or "[]")
|
|
keys = [key for key in keys if key.get("name") != name]
|
|
params.put("SecOCKeys", json.dumps(keys))
|
|
return jsonify(keys)
|
|
|
|
@app.route("/api/tsk_keys", methods=["GET"])
|
|
def get_secoc_keys():
|
|
return jsonify(json.loads(params.get("SecOCKeys", encoding="utf-8", default="[]")))
|
|
|
|
@app.route("/api/tsk_keys", methods=["POST"])
|
|
def save_secoc_keys():
|
|
keys = request.get_json() or []
|
|
params.put("SecOCKeys", json.dumps(keys))
|
|
|
|
return jsonify(keys)
|
|
|
|
@app.route("/api/tsk_key_set", methods=["POST"])
|
|
def set_secoc_key():
|
|
data = request.get_json()
|
|
if not data or "value" not in data:
|
|
return jsonify({"error": "Missing key value"}), 400
|
|
|
|
value = data["value"]
|
|
if not isinstance(value, str):
|
|
return jsonify({"error": "Key value must be a string"}), 400
|
|
|
|
params.put("SecOCKey", value)
|
|
|
|
return "", 204
|
|
|
|
@app.route("/api/toggles/backup", methods=["POST"])
|
|
def backup_toggle_values():
|
|
toggle_values = {}
|
|
default_values = _get_static_default_param_values()
|
|
for key in sorted(_get_toggle_backup_keys()):
|
|
raw_value = _params_raw.get(key)
|
|
if raw_value is None:
|
|
raw_value = default_values.get(key)
|
|
if raw_value is None:
|
|
continue
|
|
value = _sanitize_json_value(raw_value)
|
|
if not isinstance(value, (str, int, float, bool, dict, list)):
|
|
value = str(value)
|
|
|
|
toggle_values[key] = value
|
|
|
|
encoded = utilities.encode_parameters(toggle_values)
|
|
wrapped = json.dumps({
|
|
"format": TOGGLE_BACKUP_FORMAT,
|
|
"version": TOGGLE_BACKUP_VERSION,
|
|
"createdAt": datetime.now(timezone.utc).isoformat(),
|
|
"settingsCount": len(toggle_values),
|
|
"data": encoded,
|
|
}, indent=2)
|
|
|
|
buffer = BytesIO(wrapped.encode("utf-8"))
|
|
buffer.seek(0)
|
|
|
|
return send_file(buffer, as_attachment=True, download_name="toggle_backup.json", mimetype="application/json")
|
|
|
|
@app.route("/api/toggles/restore", methods=["POST"])
|
|
def restore_toggle_values():
|
|
request_data = request.get_json(silent=True)
|
|
if not isinstance(request_data, dict):
|
|
return jsonify({"success": False, "message": "Invalid toggle backup file."}), 400
|
|
|
|
backup_format = request_data.get("format")
|
|
if backup_format not in (None, TOGGLE_BACKUP_FORMAT):
|
|
return jsonify({"success": False, "message": "This file is not a Galaxy toggle backup."}), 400
|
|
|
|
backup_version = request_data.get("version", 1)
|
|
if not isinstance(backup_version, int) or backup_version > TOGGLE_BACKUP_VERSION:
|
|
return jsonify({"success": False, "message": "This toggle backup requires a newer Galaxy version."}), 400
|
|
|
|
encoded_data = request_data.get("data")
|
|
if not isinstance(encoded_data, str) or not encoded_data.strip():
|
|
return jsonify({"success": False, "message": "Toggle backup data is missing."}), 400
|
|
if len(encoded_data.encode("utf-8")) > TOGGLE_BACKUP_MAX_ENCODED_BYTES:
|
|
return jsonify({"success": False, "message": "Toggle backup file is too large."}), 413
|
|
|
|
try:
|
|
toggle_values = utilities.decode_parameters(encoded_data)
|
|
except Exception:
|
|
return jsonify({"success": False, "message": "Toggle backup data is damaged or invalid."}), 400
|
|
if not isinstance(toggle_values, dict):
|
|
return jsonify({"success": False, "message": "Toggle backup does not contain settings."}), 400
|
|
|
|
allowed_keys = _get_toggle_backup_keys()
|
|
restored_count = 0
|
|
skipped_count = 0
|
|
for key, value in toggle_values.items():
|
|
if not isinstance(key, str):
|
|
skipped_count += 1
|
|
continue
|
|
|
|
mapped_key = LEGACY_STARPILOT_PARAM_RENAMES.get(key, key)
|
|
if mapped_key not in allowed_keys:
|
|
skipped_count += 1
|
|
continue
|
|
|
|
try:
|
|
_params_raw.put(mapped_key, _coerce_toggle_restore_value(mapped_key, value))
|
|
restored_count += 1
|
|
except (TypeError, ValueError, json.JSONDecodeError):
|
|
skipped_count += 1
|
|
|
|
if restored_count == 0:
|
|
return jsonify({"success": False, "message": "No compatible toggle settings were found in this backup."}), 400
|
|
|
|
update_starpilot_toggles()
|
|
message = f"Restored {restored_count} toggle settings."
|
|
if skipped_count:
|
|
message += f" Skipped {skipped_count} incompatible or unavailable settings."
|
|
return jsonify({
|
|
"success": True,
|
|
"message": message,
|
|
"restoredCount": restored_count,
|
|
"skippedCount": skipped_count,
|
|
})
|
|
|
|
@app.route("/api/toggles/reset_default", methods=["POST"])
|
|
def reset_toggle_values():
|
|
for raw_key in _params_raw.all_keys():
|
|
key = raw_key.decode() if isinstance(raw_key, bytes) else str(raw_key)
|
|
if key in EXCLUDED_KEYS:
|
|
continue
|
|
|
|
default_value = _params_raw.get_default_value(raw_key)
|
|
if default_value is not None:
|
|
_params_raw.put(raw_key, default_value)
|
|
|
|
update_starpilot_toggles()
|
|
HARDWARE.reboot()
|
|
return jsonify({"success": True, "message": "Toggles reset to default StarPilot values. Rebooting..."})
|
|
|
|
@app.route("/api/v_asm/snapshot", methods=["GET"])
|
|
def v_asm_snapshot():
|
|
jpeg = _get_live_driver_jpeg()
|
|
if jpeg is not None:
|
|
return Response(jpeg, mimetype="image/jpeg")
|
|
return jsonify({"error": "Unable to capture live frame from driver camera."}), 503
|
|
|
|
|
|
def _get_live_driver_jpeg():
|
|
from openpilot.system.manager.process_config import managed_processes
|
|
started = False
|
|
try:
|
|
try:
|
|
subprocess.check_call(["pgrep", "camerad"])
|
|
except subprocess.CalledProcessError:
|
|
managed_processes['camerad'].start()
|
|
started = True
|
|
|
|
client = VisionIpcClient("camerad", VisionStreamType.VISION_STREAM_DRIVER, True)
|
|
if not client.connect(True):
|
|
return None
|
|
|
|
if started:
|
|
settle_deadline = time.monotonic() + 4.0
|
|
while time.monotonic() < settle_deadline:
|
|
client.recv(timeout_ms=100)
|
|
|
|
buf = client.recv(timeout_ms=5000)
|
|
if buf is None:
|
|
return None
|
|
|
|
y = np.array(buf.data[:buf.uv_offset], dtype=np.uint8).reshape((-1, buf.stride))[:buf.height, :buf.width]
|
|
u = np.array(buf.data[buf.uv_offset::2], dtype=np.uint8).reshape((-1, buf.stride // 2))[:buf.height // 2, :buf.width // 2]
|
|
v = np.array(buf.data[buf.uv_offset + 1::2], dtype=np.uint8).reshape((-1, buf.stride // 2))[:buf.height // 2, :buf.width // 2]
|
|
|
|
ul = np.repeat(np.repeat(u, 2).reshape(u.shape[0], y.shape[1]), 2, axis=0).reshape(y.shape)
|
|
vl = np.repeat(np.repeat(v, 2).reshape(v.shape[0], y.shape[1]), 2, axis=0).reshape(y.shape)
|
|
|
|
yuv = np.dstack((y, ul, vl)).astype(np.int16)
|
|
yuv[:, :, 1:] -= 128
|
|
|
|
m = np.array([
|
|
[1.00000, 1.00000, 1.00000],
|
|
[0.00000, -0.39465, 2.03211],
|
|
[1.13983, -0.58060, 0.00000],
|
|
])
|
|
rgb = np.dot(yuv, m).clip(0, 255).astype(np.uint8)
|
|
|
|
img = Image.fromarray(rgb)
|
|
buf_io = BytesIO()
|
|
img.save(buf_io, format="JPEG", quality=85)
|
|
return buf_io.getvalue()
|
|
except Exception:
|
|
return None
|
|
finally:
|
|
if started:
|
|
managed_processes['camerad'].stop()
|
|
|
|
@app.route("/api/v_asm/config", methods=["GET"])
|
|
def v_asm_get_config():
|
|
return jsonify(_decode_json_object(params.get("VASMAnnotationConfig")))
|
|
|
|
@app.route("/api/v_asm/config", methods=["POST"])
|
|
def v_asm_save_config():
|
|
if params.get_bool("IsOnroad"):
|
|
return jsonify({"error": "Cannot change V-ASM configuration while driving."}), 409
|
|
try:
|
|
config = _normalize_vasm_config(request.get_json(silent=True))
|
|
except ValueError as exc:
|
|
return jsonify({"error": str(exc)}), 400
|
|
|
|
params.put("VASMAnnotationConfig", config)
|
|
params.put_bool("VASMEnabled", True)
|
|
update_starpilot_toggles()
|
|
return jsonify({"success": True, "message": "Annotation config saved. V-ASM enabled."})
|
|
|
|
@app.route("/api/v_asm/config", methods=["DELETE"])
|
|
def v_asm_delete_config():
|
|
if params.get_bool("IsOnroad"):
|
|
return jsonify({"error": "Cannot change V-ASM configuration while driving."}), 409
|
|
params.put_bool("VASMEnabled", False)
|
|
params.put("VASMAnnotationConfig", {})
|
|
update_starpilot_toggles()
|
|
return jsonify({"success": True, "message": "Annotation config cleared. V-ASM disabled."})
|
|
|
|
@app.route("/api/pip_preview/snapshot", methods=["GET"])
|
|
def pip_preview_snapshot():
|
|
if not params.get_bool("GalaxyDeveloperMode"):
|
|
return jsonify({"error": "PiP Side Camera is available only with Galaxy Developer Mode enabled."}), 403
|
|
if params.get_bool("IsOnroad"):
|
|
return jsonify({"error": "Camera snapshots are unavailable while driving."}), 403
|
|
jpeg = _get_live_driver_jpeg()
|
|
if jpeg is not None:
|
|
return Response(jpeg, mimetype="image/jpeg")
|
|
return jsonify({"error": "Unable to capture live frame from driver camera."}), 503
|
|
|
|
@app.route("/api/pip_preview/config", methods=["GET"])
|
|
def pip_preview_get_config():
|
|
if not params.get_bool("GalaxyDeveloperMode"):
|
|
return jsonify({"error": "PiP Side Camera is available only with Galaxy Developer Mode enabled."}), 403
|
|
return jsonify(_decode_json_object(params.get("PIPPreviewMask")))
|
|
|
|
@app.route("/api/pip_preview/config", methods=["POST"])
|
|
def pip_preview_save_config():
|
|
if not params.get_bool("GalaxyDeveloperMode"):
|
|
return jsonify({"error": "PiP Side Camera is available only with Galaxy Developer Mode enabled."}), 403
|
|
if params.get_bool("IsOnroad"):
|
|
return jsonify({"error": "Cannot change PiP Side Camera configuration while driving."}), 403
|
|
try:
|
|
config = _normalize_pip_preview_config(request.get_json(silent=True))
|
|
except ValueError as exc:
|
|
return jsonify({"error": str(exc)}), 400
|
|
|
|
params.put("PIPPreviewMask", config)
|
|
update_starpilot_toggles()
|
|
return jsonify({"success": True, "message": "PiP Preview mask saved."})
|
|
|
|
@app.route("/api/pip_preview/config", methods=["DELETE"])
|
|
def pip_preview_delete_config():
|
|
if not params.get_bool("GalaxyDeveloperMode"):
|
|
return jsonify({"error": "PiP Side Camera is available only with Galaxy Developer Mode enabled."}), 403
|
|
if params.get_bool("IsOnroad"):
|
|
return jsonify({"error": "Cannot change PiP Side Camera configuration while driving."}), 403
|
|
params.put("PIPPreviewMask", {})
|
|
params.put_bool("PIPPreviewEnabled", False)
|
|
update_starpilot_toggles()
|
|
return jsonify({"success": True, "message": "PiP Preview mask cleared."})
|
|
|
|
@app.route("/mapbox-help/<path:filename>", methods=["GET"])
|
|
def serve_mapbox_help(filename):
|
|
return send_from_directory("/data/openpilot/starpilot/navigation/navigation_training", filename)
|
|
|
|
@app.route("/playground", methods=["GET"])
|
|
def playground():
|
|
return render_template("playground.html")
|
|
|
|
@app.route("/thumbnails/<path:file_path>", methods=["GET"])
|
|
def get_thumbnail(file_path):
|
|
for footage_path in FOOTAGE_PATHS:
|
|
if os.path.exists(os.path.join(footage_path, file_path)):
|
|
return send_from_directory(footage_path, file_path, as_attachment=True)
|
|
return {"error": "Thumbnail not found"}, 404
|
|
|
|
@app.route("/video/<path>", methods=["GET"])
|
|
def get_video(path):
|
|
camera = request.args.get("camera")
|
|
filename = {"driver": "dcamera.hevc", "wide": "ecamera.hevc"}.get(camera, "fcamera.hevc")
|
|
for footage_path in FOOTAGE_PATHS:
|
|
filepath = f"{footage_path}{path}/{filename}"
|
|
if os.path.exists(filepath):
|
|
file_handle = utilities.ffmpeg_mp4_wrap_process_builder(filepath)
|
|
|
|
file_handle.seek(0, 2)
|
|
file_size = file_handle.tell()
|
|
file_handle.seek(0)
|
|
|
|
range_header = request.headers.get('Range', None)
|
|
if range_header:
|
|
byte_start = 0
|
|
byte_end = file_size - 1
|
|
|
|
if range_header.startswith('bytes='):
|
|
range_spec = range_header[6:]
|
|
if '-' in range_spec:
|
|
start, end = range_spec.split('-', 1)
|
|
if start:
|
|
byte_start = max(0, int(start))
|
|
if end:
|
|
byte_end = min(file_size - 1, int(end))
|
|
|
|
if byte_start >= file_size:
|
|
file_handle.close()
|
|
return Response("Requested Range Not Satisfiable", 416)
|
|
|
|
byte_end = max(byte_start, byte_end)
|
|
|
|
file_handle.seek(byte_start)
|
|
read_length = byte_end - byte_start + 1
|
|
data = file_handle.read(read_length)
|
|
|
|
response = Response(
|
|
data,
|
|
206,
|
|
headers={
|
|
'Content-Range': f'bytes {byte_start}-{byte_end}/{file_size}',
|
|
'Accept-Ranges': 'bytes',
|
|
'Content-Length': str(len(data)),
|
|
'Content-Type': 'video/mp4'
|
|
}
|
|
)
|
|
else:
|
|
data = file_handle.read()
|
|
response = Response(
|
|
data,
|
|
200,
|
|
headers={
|
|
'Accept-Ranges': 'bytes',
|
|
'Content-Length': str(file_size),
|
|
'Content-Type': 'video/mp4'
|
|
}
|
|
)
|
|
|
|
file_handle.close()
|
|
return response
|
|
return {"error": "Video not found"}, 404
|
|
|
|
def main():
|
|
while not _ensure_galaxy_web_deps():
|
|
print(f"The Galaxy waiting for Flask dependency ({_GALAXY_WEB_DEPS_ERROR}); retrying in 60s.")
|
|
time.sleep(60)
|
|
|
|
app = Flask(__name__, static_folder="assets", static_url_path="/assets")
|
|
setup(app)
|
|
threading.Thread(target=_testing_ground_custom_reserved_worker, daemon=True).start()
|
|
|
|
# Desktop-only debug mode. On-device must stay on 8082 to match Galaxy FRP routing.
|
|
on_device = _is_comma_device_runtime()
|
|
debug = False if on_device else os.getenv("SP_GALAXY_DEBUG", "1").lower() in {"1", "true", "yes", "on"}
|
|
port = 8082 if on_device else int(os.getenv("SP_GALAXY_PORT", "8083"))
|
|
host = "0.0.0.0" if on_device else os.getenv("SP_GALAXY_HOST", "0.0.0.0")
|
|
use_reloader = False if on_device else os.getenv("SP_GALAXY_RELOAD", "0" if not debug else "1").lower() in {"1", "true", "yes", "on"}
|
|
|
|
if debug:
|
|
print("\"The Galaxy\" is not running on a comma device, enabling debug mode")
|
|
|
|
app.secret_key = secrets.token_hex(32)
|
|
app.run(host=host, port=port, debug=debug, use_reloader=use_reloader)
|
|
|
|
if __name__ == "__main__":
|
|
main()
|