From deff72c5e47fb8242d5c7af87f263497447cded1 Mon Sep 17 00:00:00 2001 From: firestar5683 <168790843+firestar5683@users.noreply.github.com> Date: Mon, 6 Apr 2026 18:34:30 -0500 Subject: [PATCH] long manuevers / model manager --- starpilot/system/device_syncd.py | 9 +- .../tools/longitudinal_maneuvers.js | 2 +- .../assets/components/tools/model_manager.js | 5 +- starpilot/system/the_pond/the_pond.py | 153 ++++++++++++++---- tools/longitudinal_maneuvers/capabilities.py | 27 +++- tools/longitudinal_maneuvers/maneuversd.py | 12 +- 6 files changed, 164 insertions(+), 44 deletions(-) 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: