diff --git a/common/libcommon.a b/common/libcommon.a index f3262fec1..e60356a11 100644 Binary files a/common/libcommon.a and b/common/libcommon.a differ diff --git a/common/params_keys.h b/common/params_keys.h index 334558f13..8cb43c99b 100644 --- a/common/params_keys.h +++ b/common/params_keys.h @@ -331,6 +331,7 @@ inline static std::unordered_map keys = { {"ForceTorqueController", {PERSISTENT, BOOL, "0", "0", 3}}, {"FLMActiveOverrides", {PERSISTENT, JSON, "{}", "{}", 2}}, {"FLMActiveProfileId", {PERSISTENT, STRING, "", "", 2}}, + {"FLMSubmittedTune", {CLEAR_ON_MANAGER_START, JSON, "{}", "{}"}}, {"FLMTrialBaseline", {PERSISTENT | DONT_LOG, JSON, "{}", "{}"}}, {"FLMTrialApplied", {PERSISTENT, BOOL, "0", "0", 2}}, {"FPSCounter", {PERSISTENT, BOOL, "0", "0", 3}}, diff --git a/common/params_pyx.so b/common/params_pyx.so index 363d891a5..b79c0905b 100755 Binary files a/common/params_pyx.so and b/common/params_pyx.so differ diff --git a/starpilot/starpilot_process.py b/starpilot/starpilot_process.py index d46ede7a6..dc4d94f9c 100644 --- a/starpilot/starpilot_process.py +++ b/starpilot/starpilot_process.py @@ -13,7 +13,7 @@ from openpilot.common.gps import get_gps_location_service from openpilot.common.params import Params from openpilot.common.realtime import DT_MDL, Priority, Ratekeeper, config_realtime_process from openpilot.common.time_helpers import system_time_valid -from openpilot.system.sentry import capture_report +from openpilot.system.sentry import capture_flm_tune_submission, capture_report from openpilot.system.athena.registration import UNREGISTERED_DONGLE_ID from openpilot.system.hardware.hw import Paths @@ -116,6 +116,11 @@ def check_assets(now, model_manager, theme_manager, thread_manager, params, para capture_report(report_data["DiscordUser"], report_data["Issue"], vars(starpilot_toggles)) params_memory.remove("IssueReported") + flm_submission = params_memory.get("FLMSubmittedTune") + if flm_submission: + capture_flm_tune_submission(flm_submission) + params_memory.remove("FLMSubmittedTune") + if params_memory.get_bool("DownloadMaps"): thread_manager.run_with_lock(update_maps, (now, params, params_memory, True)) diff --git a/starpilot/system/the_galaxy/assets/components/tools/tuning.js b/starpilot/system/the_galaxy/assets/components/tools/tuning.js index 9d1469324..88ad20660 100644 --- a/starpilot/system/the_galaxy/assets/components/tools/tuning.js +++ b/starpilot/system/the_galaxy/assets/components/tools/tuning.js @@ -512,6 +512,35 @@ async function deleteSavedTune(tune) { } } +async function submitSavedTune(tune) { + if (!tune?.tuneId || state.runningAction) return + const approved = window.confirm( + "Think this FLM tune is genuinely good and worth sharing? Send it to Firestar for review and possible inclusion in future tuning. Only the tune values, car identity, and your Discord username are sent; routes and driving logs are not included." + ) + if (!approved) return + + const discordUsername = window.prompt("Enter your Discord username so Firestar can credit you.", "") + if (discordUsername === null || !discordUsername.trim()) return + + state.runningAction = true + try { + const response = await fetch(`/api/flm/saved-tunes/${encodeURIComponent(tune.tuneId)}/submit`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ discordUsername: discordUsername.trim() }), + }) + const payload = await response.json() + if (!response.ok) throw new Error(payload.error || "Failed to submit saved tune.") + state.error = "" + showSnackbar(payload.message || "Tune submitted to Firestar for review.") + } catch (error) { + state.error = error?.message || "Failed to submit saved tune." + showSnackbar(state.error, "error") + } finally { + state.runningAction = false + } +} + async function selectPath(pathKey) { if (!state.report?.reportId || !pathKey || state.runningAction) return if (pathKey === (state.report.selectedPathKey || state.report.primaryPathKey)) return @@ -1263,6 +1292,9 @@ export function Tuning() {

Save a working FLM trial, switch between vehicle or trailer setups, then use Revert Trial to return to the exact manual settings from before FLM.

+

+ Think a tune is genuinely excellent? Send it to Firestar for review and possible community sharing. Submission includes only tune values, car identity, and your Discord username, not routes or driving logs. +

${() => (state.workspace?.savedTunes || []).length ? state.workspace.savedTunes.map((tune) => html` @@ -1294,6 +1326,12 @@ export function Tuning() { @click="${() => deleteSavedTune(tune)}"> Delete +
`) diff --git a/starpilot/system/the_galaxy/flm_workspace.py b/starpilot/system/the_galaxy/flm_workspace.py index e68ca0d27..2ae2b2ad0 100644 --- a/starpilot/system/the_galaxy/flm_workspace.py +++ b/starpilot/system/the_galaxy/flm_workspace.py @@ -2731,15 +2731,16 @@ def _active_trial_display_state(paths: dict[str, Path], snapshot: Any) -> dict[s def _current_car_identity(params: Params) -> dict[str, str]: cp_bytes = params.get("CarParamsPersistent") if not cp_bytes: - return {"carFingerprint": "", "brand": ""} + return {"carFingerprint": "", "brand": "", "carName": ""} try: with car.CarParams.from_bytes(cp_bytes) as car_params: return { "carFingerprint": str(getattr(car_params, "carFingerprint", "") or "").strip(), "brand": str(getattr(car_params, "brand", "") or "").strip(), + "carName": str(getattr(car_params, "carName", "") or "").strip(), } except Exception: - return {"carFingerprint": "", "brand": ""} + return {"carFingerprint": "", "brand": "", "carName": ""} def _normalize_saved_tune_name(name: str) -> str: @@ -2751,6 +2752,22 @@ def _normalize_saved_tune_name(name: str) -> str: return normalized +def _normalize_discord_username(username: str) -> str: + normalized = " ".join(str(username or "").split()) + if not normalized: + raise ValueError("A Discord username is required to submit a tune.") + if len(normalized) > 64: + raise ValueError("Discord usernames must be 64 characters or fewer.") + if any(ord(character) < 32 for character in normalized): + raise ValueError("Discord username contains an invalid control character.") + return normalized + + +def _saved_tune_car_name(tune: dict[str, Any]) -> str: + raw_name = str(tune.get("carFingerprint", "") or tune.get("carName", "") or tune.get("brand", "") or "Unknown car") + return " ".join(raw_name.replace("_", " ").split()).title() + + def _load_saved_tune(tune_id: str, paths: dict[str, Path] | None = None) -> dict[str, Any]: paths = paths or ensure_flm_workspace() tune = _read_json(paths["savedTunes"] / f"{tune_id}.json", {}) @@ -3153,6 +3170,7 @@ def save_active_trial_as_tune(name: str) -> dict[str, Any]: current_car = _current_car_identity(params) car_fingerprint = current_car["carFingerprint"] or str(report_car.get("carFingerprint", "") or "") brand = current_car["brand"] or str(report_car.get("brand", "") or "") + car_name = current_car.get("carName", "") or str(report_car.get("carName", "") or "") now = time.time() tune_id = f"tune-{time.time_ns()}" tune = { @@ -3163,6 +3181,7 @@ def save_active_trial_as_tune(name: str) -> dict[str, Any]: "updatedAt": now, "carFingerprint": car_fingerprint, "brand": brand, + "carName": car_name, "sourceReportId": report_id, "sourceProfileId": str(display_state.get("profileId", "") or ""), "pathKey": str(display_state.get("pathKey", "") or ""), @@ -3188,6 +3207,44 @@ def save_active_trial_as_tune(name: str) -> dict[str, Any]: } +def submit_saved_tune(tune_id: str, discord_username: str) -> dict[str, Any]: + _require_flm_offroad() + paths = ensure_flm_workspace() + tune = _load_saved_tune(tune_id, paths) + discord_username = _normalize_discord_username(discord_username) + car_name = _saved_tune_car_name(tune) + + # Keep this payload deliberately separate from reports: tune review needs the + # applied values, not route names, log files, camera footage, or device state. + submitted_tune = { + "schemaVersion": tune.get("schemaVersion", 1), + "tuneId": str(tune.get("tuneId", tune_id) or tune_id), + "name": str(tune.get("name", "Saved Tune") or "Saved Tune"), + "carName": car_name, + "carFingerprint": str(tune.get("carFingerprint", "") or ""), + "brand": str(tune.get("brand", "") or ""), + "baselineParams": { + key: value for key, value in (tune.get("baselineParams", {}) or {}).items() + if key in TRIAL_PARAM_SPECS + }, + "genericParams": { + key: value for key, value in (tune.get("genericParams", {}) or {}).items() + if key in FLM_ADVANCED_LATERAL_PARAM_KEYS + }, + "flmOverrides": normalize_flm_overrides(tune.get("flmOverrides", {})), + } + Params(memory=True).put("FLMSubmittedTune", { + "discordUsername": discord_username, + "carName": car_name, + "tune": submitted_tune, + }) + return { + "message": f"Submitted {tune.get('name', 'Saved Tune')} to Firestar for review.", + "tuneId": tune_id, + "carName": car_name, + } + + def apply_saved_tune(tune_id: str) -> dict[str, Any]: paths = ensure_flm_workspace() tune = _load_saved_tune(tune_id, paths) diff --git a/starpilot/system/the_galaxy/tests/test_flm_workspace.py b/starpilot/system/the_galaxy/tests/test_flm_workspace.py index fe288bc52..580e7fdcd 100644 --- a/starpilot/system/the_galaxy/tests/test_flm_workspace.py +++ b/starpilot/system/the_galaxy/tests/test_flm_workspace.py @@ -1230,6 +1230,48 @@ def test_saved_tune_rename_delete_and_vehicle_guard(tmp_path, monkeypatch): assert not tune_path.exists() +def test_submit_saved_tune_queues_credit_and_tune_only(tmp_path): + module, fake_params_cls = _load_flm_workspace_module(tmp_path) + workspace = module.ensure_flm_workspace() + tune_id = "tune-submit" + (workspace["savedTunes"] / f"{tune_id}.json").write_text(json.dumps({ + "schemaVersion": 1, + "tuneId": tune_id, + "name": "Good Curve Tune", + "createdAt": 1.0, + "updatedAt": 1.0, + "carFingerprint": "HYUNDAI_IONIQ_6", + "brand": "hyundai", + "sourceReportId": "report-private", + "pathLabel": "Cleanup Pass", + "baselineParams": {"SteerLatAccel": 2.1}, + "genericParams": {"SteerLatAccel": 2.3}, + "flmOverrides": {"vehicleKnobs": {"turn_in_boost": 0.1}}, + "routeNames": ["must-not-be-submitted"], + }), encoding="utf-8") + fake_params_cls._store = {"IsOnroad": False} + fake_params_cls._memory_store = {} + + result = module.submit_saved_tune(tune_id, "@tuner") + submission = fake_params_cls._memory_store["FLMSubmittedTune"] + + assert result["carName"] == "Hyundai Ioniq 6" + assert submission["discordUsername"] == "@tuner" + assert submission["carName"] == "Hyundai Ioniq 6" + assert submission["tune"]["genericParams"] == {"SteerLatAccel": 2.3} + assert "routeNames" not in submission["tune"] + assert "routes" not in submission["tune"] + assert "sourceReportId" not in submission["tune"] + assert "pathLabel" not in submission["tune"] + + with pytest.raises(ValueError, match="Discord username"): + module.submit_saved_tune(tune_id, "") + + fake_params_cls._store["IsOnroad"] = True + with pytest.raises(module.FLMAnalysisCancelled, match="went onroad"): + module.submit_saved_tune(tune_id, "@tuner") + + def test_saved_tune_car_switch_uses_the_destination_car_baseline(tmp_path, monkeypatch): module, fake_params_cls = _load_flm_workspace_module(tmp_path) workspace = module.ensure_flm_workspace() diff --git a/starpilot/system/the_galaxy/the_galaxy.py b/starpilot/system/the_galaxy/the_galaxy.py index 1b0cc8ebb..15723dc14 100644 --- a/starpilot/system/the_galaxy/the_galaxy.py +++ b/starpilot/system/the_galaxy/the_galaxy.py @@ -6059,6 +6059,19 @@ def setup(app): except RuntimeError as error: return jsonify({"error": str(error)}), 409 + @app.route(f"{LEGACY_LATERAL_METHOD_API_PREFIX}/saved-tunes//submit", methods=["POST"]) + @app.route("/api/flm/saved-tunes//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/", methods=["PATCH"]) @app.route("/api/flm/saved-tunes/", methods=["PATCH"]) def rename_flm_saved_tune(tune_id): diff --git a/system/sentry.py b/system/sentry.py index 2e398cba0..8efebdeeb 100644 --- a/system/sentry.py +++ b/system/sentry.py @@ -107,6 +107,22 @@ def capture_report(discord_user, report, starpilot_toggles): sentry_sdk.flush() +def capture_flm_tune_submission(submission: dict) -> None: + """Send an explicitly submitted FLM tune without attaching drive artifacts.""" + discord_user = str(submission.get("discordUsername", "Unknown") or "Unknown") + car_name = str(submission.get("carName", "Unknown car") or "Unknown car") + tune = submission.get("tune", {}) + if not isinstance(tune, dict): + tune = {} + + capture_message( + f"{car_name} Tune submitted by {discord_user}", + level="info", + tags={"report_type": "flm_tune_submission", "car_name": car_name}, + extras={"flm_tune": tune}, + ) + + def set_tag(key: str, value: str) -> None: sentry_sdk.set_tag(key, value)