diff --git a/starpilot/system/device_syncd.py b/starpilot/system/device_syncd.py
index 42695cb8..8297a0cf 100644
--- a/starpilot/system/device_syncd.py
+++ b/starpilot/system/device_syncd.py
@@ -203,9 +203,10 @@ def pond_thread():
parked = sm["starpilotCarState"].isParked
started = sm["deviceState"].started
+ long_maneuver_mode = params.get_bool("LongitudinalManeuverMode")
state_changed = started != previous_started or parked != previous_parked
- if params.get_bool("PondPaired"):
+ if params.get_bool("PondPaired") and not long_maneuver_mode:
presence_interval = POND_PRESENCE_INTERVAL_ACTIVE if started or pond_active else POND_PRESENCE_INTERVAL_IDLE
ping_pond_presence(presence_interval, parked, started, state_changed)
@@ -219,13 +220,15 @@ def pond_thread():
if state_changed and parked:
next_toggle_check_at = 0.0
- if boot_sync_complete and now >= next_toggle_check_at:
+ if long_maneuver_mode:
+ next_toggle_check_at = max(next_toggle_check_at, now + REMOTE_TOGGLE_CHECK_INTERVAL_IDLE)
+ elif boot_sync_complete and now >= next_toggle_check_at:
latest_pond_active = check_toggles(started, params, sm)
if latest_pond_active is not None:
pond_active = latest_pond_active
next_toggle_check_at = now + REMOTE_TOGGLE_CHECK_INTERVAL_ACTIVE if pond_active else REMOTE_TOGGLE_CHECK_INTERVAL_IDLE
- if params.get_bool("PondUploadPending"):
+ if params.get_bool("PondUploadPending") and not long_maneuver_mode:
if not params.get_bool("PondPaired"):
params.put_bool("PondUploadPending", False)
elif upload_toggles(params):
diff --git a/starpilot/system/the_pond/assets/components/tools/longitudinal_maneuvers.js b/starpilot/system/the_pond/assets/components/tools/longitudinal_maneuvers.js
index 9dba78fb..474c0b54 100644
--- a/starpilot/system/the_pond/assets/components/tools/longitudinal_maneuvers.js
+++ b/starpilot/system/the_pond/assets/components/tools/longitudinal_maneuvers.js
@@ -9,7 +9,7 @@ const state = reactive({
let initialized = false
let pollHandle = null
-const POLL_INTERVAL_MS = 1000
+const POLL_INTERVAL_MS = 3000
function isLongitudinalManeuversRouteActive() {
return window.location.pathname === "/longitudinal_maneuvers"
diff --git a/starpilot/system/the_pond/assets/components/tools/model_manager.js b/starpilot/system/the_pond/assets/components/tools/model_manager.js
index 927c46d3..ac5c1f30 100644
--- a/starpilot/system/the_pond/assets/components/tools/model_manager.js
+++ b/starpilot/system/the_pond/assets/components/tools/model_manager.js
@@ -432,7 +432,9 @@ function renderActions(model) {
if (model.installed) {
return html`
-
+ ${model.builtin
+ ? ""
+ : html``}
`;
}
@@ -451,6 +453,7 @@ function renderModelRow(model) {
${key}
+ ${model.builtin ? html`Built-in` : ""}
${state.sortMode === "release_date" ? "" : model.series ? html`${safeText(model.series)}` : ""}
${model.version ? html`Version ${safeText(model.version)}` : ""}
${model.released ? html`Released ${safeText(model.released)}` : ""}
diff --git a/starpilot/system/the_pond/the_pond.py b/starpilot/system/the_pond/the_pond.py
index 21ab25fa..ff6493ce 100644
--- a/starpilot/system/the_pond/the_pond.py
+++ b/starpilot/system/the_pond/the_pond.py
@@ -43,6 +43,7 @@ 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,
@@ -60,7 +61,7 @@ from openpilot.starpilot.common.maps_catalog import (
schedule_param_value,
)
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, RESOURCES_REPO, SCREEN_RECORDINGS_PATH, STOCK_THEME_PATH, THEME_SAVE_PATH,\
+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, update_starpilot_toggles
from openpilot.starpilot.common.testing_grounds import (
DEFAULT_TESTING_GROUND_VARIANT as SHARED_DEFAULT_TESTING_GROUND_VARIANT,
@@ -1971,7 +1972,10 @@ def _get_current_param_value(key, value_type, defaults_lookup=None):
if defaults_lookup is None:
defaults_lookup = _get_default_param_values()
raw_value = defaults_lookup.get(key)
- return _coerce_param_value(raw_value, value_type)
+ 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_custom_accel_profile_initialized():
@@ -3355,7 +3359,7 @@ def setup(app):
}), 200
if key in ("Model", "DrivingModel"):
- selected_model = str_val.strip()
+ selected_model = canonical_model_key(str_val.strip())
if not selected_model:
return jsonify({"error": "Driving model cannot be empty."}), 400
@@ -3366,25 +3370,40 @@ def setup(app):
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(",")]
- if selected_model in available_models:
- selected_index = available_models.index(selected_model)
+ 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("/data/models/.model_versions.json", "r") as f:
+ with open(MODELS_PATH / ".model_versions.json", "r") as f:
versions = json.load(f)
- if selected_model in versions:
- resolved_version = str(versions[selected_model]).strip()
+ 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"):
@@ -3434,7 +3453,12 @@ def setup(app):
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
- return params.get(request_key) or "", 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/params/all", methods=["GET"])
def get_all_params():
@@ -3517,7 +3541,7 @@ def setup(app):
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 = params.get("Model", encoding="utf-8") or ""
+ 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:
@@ -3531,7 +3555,7 @@ def setup(app):
models = get_model_catalog()
return jsonify({
"models": models,
- "currentModel": params.get("Model", encoding="utf-8") or "",
+ "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"]),
@@ -3572,13 +3596,13 @@ def setup(app):
@app.route("/api/models/status", methods=["GET"])
def get_models_status():
models = get_model_catalog()
- model_to_download = params_memory.get(MODEL_DOWNLOAD_PARAM, encoding="utf-8") or ""
+ 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 = params.get("Model", encoding="utf-8") or ""
+ 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 = {
@@ -3665,7 +3689,7 @@ def setup(app):
return jsonify({"error": "A model download is already in progress."}), 409
data = request.get_json() or {}
- model_key = (data.get("model") or "").strip()
+ model_key = canonical_model_key((data.get("model") or "").strip())
if not model_key:
return jsonify({"error": "Missing model key."}), 400
@@ -3722,11 +3746,11 @@ def setup(app):
return jsonify({"error": "Cannot delete model files while a download is in progress."}), 409
data = request.get_json() or {}
- model_key = (data.get("model") or "").strip()
+ model_key = canonical_model_key((data.get("model") or "").strip())
if not model_key:
return jsonify({"error": "Missing model key."}), 400
- current_model = params.get("Model", encoding="utf-8") or ""
+ current_model = _current_model_key()
if model_key == current_model:
return jsonify({"error": "Cannot delete the currently active model."}), 409
@@ -3734,8 +3758,10 @@ def setup(app):
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 = Path("/data/models")
+ models_dir = MODELS_PATH
if not models_dir.is_dir():
return jsonify({"message": "No model directory exists yet."}), 200
@@ -3882,7 +3908,32 @@ def setup(app):
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):
+ if is_builtin_model_key(model_key):
+ return True
+
if f"{model_key}.thneed" in on_disk_files:
return True
@@ -3913,18 +3964,18 @@ def setup(app):
versions = [entry.strip() for entry in (params.get("ModelVersions", encoding="utf-8") or "").split(",")]
released_dates = [entry.strip() for entry in (params.get("ModelReleasedDates", encoding="utf-8") or "").split(",")]
- community_favorites = {entry.strip() for entry in (params.get("CommunityFavorites", encoding="utf-8") or "").split(",") if entry.strip()}
- user_favorites = {entry.strip() for entry in (params.get(MODEL_USER_FAVORITES_PARAM, encoding="utf-8") or "").split(",") if entry.strip()}
+ 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()}
- models_dir = "/data/models"
try:
- on_disk_files = set(os.listdir(models_dir)) if os.path.isdir(models_dir) else set()
+ 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 = []
+ models_by_key = {}
for i, key in enumerate(available):
- if not key:
+ canonical_key = canonical_model_key(key)
+ if not canonical_key:
continue
label = names[i] if i < len(names) and names[i] else key
@@ -3932,19 +3983,57 @@ def setup(app):
model_series = series[i] if i < len(series) and series[i] else "Custom Series"
released = released_dates[i] if i < len(released_dates) else ""
- installed = is_model_installed(key, model_version, on_disk_files)
- partial = not installed and any(file.startswith(f"{key}.") or file.startswith(f"{key}_") for file in on_disk_files)
+ 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,
+ "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["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(),
+ "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({
- "value": key,
- "label": label,
- "series": model_series,
- "version": model_version,
- "released": released,
+ **model,
"installed": installed,
"partial": partial,
- "communityFavorite": key in community_favorites,
- "userFavorite": key in user_favorites,
})
models.sort(key=lambda model: (model["series"].lower(), model["label"].lower()))
diff --git a/tools/longitudinal_maneuvers/capabilities.py b/tools/longitudinal_maneuvers/capabilities.py
index fc54850f..c6c7181a 100644
--- a/tools/longitudinal_maneuvers/capabilities.py
+++ b/tools/longitudinal_maneuvers/capabilities.py
@@ -3,6 +3,8 @@ from __future__ import annotations
from dataclasses import asdict, dataclass
from typing import Any
+from opendbc.car.gm.values import GMFlags
+
LOW_SPEED_MANEUVER_DESCRIPTIONS = (
"come to stop",
@@ -31,12 +33,27 @@ def get_longitudinal_maneuver_support(CP: Any) -> LongitudinalManeuverSupport:
openpilot_longitudinal = bool(getattr(CP, "openpilotLongitudinalControl", False))
auto_resume_supported = bool(getattr(CP, "autoResumeSng", False))
+ brand = str(getattr(CP, "brand", "") or getattr(CP, "carName", "") or "").lower()
+ fingerprint = str(getattr(CP, "carFingerprint", "") or "")
+ flags = int(getattr(CP, "flags", 0) or 0)
+ has_pedal = bool(getattr(CP, "enableGasInterceptorDEPRECATED", False))
+
min_enable_speed = float(getattr(CP, "minEnableSpeed", 0.0) or 0.0)
stop_accel = float(getattr(CP, "stopAccel", 0.0) or 0.0)
- # CarParams does not expose a dedicated "won't fully brake to zero" flag,
- # so low-speed engagement support is the closest reliable proxy.
+ is_gm = brand == "gm"
+ is_volt = fingerprint.startswith("CHEVROLET_VOLT")
+ has_sascm = is_gm and bool(flags & GMFlags.SASCM.value)
+
+ # CarParams does not expose a dedicated "won't fully brake to zero" flag.
+ # For most platforms, low-speed engagement support is the best proxy.
full_stop_and_go = min_enable_speed <= 0.0
+
+ # GM Volt without pedal can often engage below 0 mph but still creep instead of
+ # reliably achieving a true stop in these canned maneuvers, especially on SASCM paths.
+ if is_volt and not has_pedal:
+ full_stop_and_go = False
+
auto_resume_from_stop = full_stop_and_go and auto_resume_supported
expected_to_reach_zero = full_stop_and_go
requires_resume_assist = expected_to_reach_zero and not auto_resume_from_stop
@@ -45,10 +62,14 @@ def get_longitudinal_maneuver_support(CP: Any) -> LongitudinalManeuverSupport:
if not openpilot_longitudinal:
caveats.append("openpilot longitudinal is disabled, so the maneuver suite cannot drive longitudinal tests on this platform.")
- if not expected_to_reach_zero:
+ if is_volt and not has_pedal:
+ caveats.append("Volt without pedal is not expected to reach a true standstill in the maneuver suite. Stop, start, and creep maneuvers will be skipped.")
+ elif not expected_to_reach_zero:
caveats.append("This car is not expected to reach a true standstill in the suite. Stop, start, and creep maneuvers will be skipped.")
elif requires_resume_assist:
caveats.append("This car can reach a stop, but restart-from-stop needs resume assistance. Zero-speed maneuvers will allow cruise standstill instead of treating it as setup failure.")
+ elif has_sascm and full_stop_and_go:
+ caveats.append("SASCM is present on this GM platform. If a real standstill is reached, restart behavior can still depend on resume handling.")
skipped_maneuvers = LOW_SPEED_MANEUVER_DESCRIPTIONS if not expected_to_reach_zero else ()
diff --git a/tools/longitudinal_maneuvers/maneuversd.py b/tools/longitudinal_maneuvers/maneuversd.py
index 7ca6b4db..5abed7c3 100755
--- a/tools/longitudinal_maneuvers/maneuversd.py
+++ b/tools/longitudinal_maneuvers/maneuversd.py
@@ -4,7 +4,7 @@ from dataclasses import dataclass
from cereal import messaging, car
from openpilot.common.constants import CV
-from openpilot.common.realtime import DT_MDL
+from openpilot.common.realtime import DT_MDL, Priority, config_realtime_process
from openpilot.common.params import Params
from openpilot.common.swaglog import cloudlog
from openpilot.tools.longitudinal_maneuvers.capabilities import (
@@ -134,6 +134,8 @@ def build_maneuvers():
def main():
+ config_realtime_process(5, Priority.CTRL_LOW)
+
params = Params()
cloudlog.info("joystickd is waiting for CarParams")
CP = messaging.log_from_bytes(params.get("CarParams", block=True), car.CarParams)
@@ -147,7 +149,7 @@ def main():
continue
supported_maneuvers.append(maneuver)
- sm = messaging.SubMaster(['carState', 'carControl', 'controlsState', 'selfdriveState', 'modelV2'], poll='modelV2')
+ sm = messaging.SubMaster(['carState', 'carControl', 'modelV2'], poll='modelV2')
pm = messaging.PubMaster(['longitudinalPlan', 'driverAssistance', 'alertDebug'])
maneuvers = iter(supported_maneuvers)
@@ -163,7 +165,7 @@ def main():
alert_msg.valid = True
plan_send = messaging.new_message('longitudinalPlan')
- plan_send.valid = sm.all_checks()
+ plan_send.valid = sm.all_checks(['carState', 'carControl', 'modelV2'])
longitudinalPlan = plan_send.longitudinalPlan
accel = 0
@@ -184,6 +186,8 @@ def main():
longitudinalPlan.aTarget = accel
longitudinalPlan.shouldStop = v_ego < CP.vEgoStopping and accel < 1e-2
+ longitudinalPlan.modelMonoTime = sm.logMonoTime['modelV2']
+ longitudinalPlan.processingDelay = (plan_send.logMonoTime / 1e9) - sm.logMonoTime['modelV2']
longitudinalPlan.allowBrake = True
longitudinalPlan.allowThrottle = True
@@ -194,7 +198,7 @@ def main():
pm.send('longitudinalPlan', plan_send)
assistance_send = messaging.new_message('driverAssistance')
- assistance_send.valid = True
+ assistance_send.valid = sm.all_checks(['carState', 'carControl', 'modelV2'])
pm.send('driverAssistance', assistance_send)
if maneuver is not None and maneuver.finished: