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 3c3b1b17..96292e26 100644
--- a/starpilot/system/the_pond/assets/components/tools/longitudinal_maneuvers.js
+++ b/starpilot/system/the_pond/assets/components/tools/longitudinal_maneuvers.js
@@ -156,13 +156,41 @@ export function LongitudinalManeuvers() {
Popup Text: ${state.data.uiText1 || ""} ${state.data.uiText2 ? `| ${state.data.uiText2}` : ""}
+
+ ${statusLine("openpilot Long", state.data.support?.openpilotLongitudinalControl ? "Yes" : "No")}
+ ${statusLine("Full Stop + Go", state.data.support?.fullStopAndGo ? "Yes" : "No")}
+ ${statusLine("Auto Resume", state.data.support?.autoResumeFromStop ? "Yes" : "No")}
+ ${statusLine("Resume Assist", state.data.support?.requiresResumeAssist ? "Yes" : "No")}
+ ${statusLine("Expected Zero", state.data.support?.expectedToReachZero ? "Yes" : "No")}
+ ${statusLine("Min Enable", Number.isFinite(Number(state.data.support?.minEnableSpeed)) ? `${Number(state.data.support.minEnableSpeed).toFixed(2)} m/s` : "n/a")}
+ ${statusLine("Stop Accel", Number.isFinite(Number(state.data.support?.stopAccel)) ? `${Number(state.data.support.stopAccel).toFixed(2)} m/s²` : "n/a")}
+
+
+ ${(state.data.caveats || []).length ? html`
+
+
Platform Caveats
+
+ ${(state.data.caveats || []).map((line) => html`- ${line}
`)}
+
+
+ ` : ""}
+
+ ${(state.data.skippedManeuvers || []).length ? html`
+
+
Expected Skips
+
+ ${(state.data.skippedManeuvers || []).map((line) => html`- ${line}
`)}
+
+
+ ` : ""}
+
Quick Guide
- Find a large, empty, straight road or lot with no traffic.
- Press Start / Arm here, then engage openpilot with SET.
- Keep full supervision and be ready to disengage at all times.
- - For GM pedal-long cars, the suite runs both pedal-only and pedal+paddle phases automatically.
+ - Review the platform caveats before starting. Low-speed maneuvers can be skipped automatically on cars that do not fully stop.
- When the status says complete, collect logs and generate your HTML report.
diff --git a/starpilot/system/the_pond/the_pond.py b/starpilot/system/the_pond/the_pond.py
index 1b76dc28..21ab25fa 100644
--- a/starpilot/system/the_pond/the_pond.py
+++ b/starpilot/system/the_pond/the_pond.py
@@ -40,6 +40,7 @@ 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.theme_manager import HOLIDAY_THEME_PATH, THEME_COMPONENT_PARAMS
@@ -2824,6 +2825,9 @@ def _default_longitudinal_maneuver_status():
"uiText1": "Long Maneuvers",
"uiText2": "",
"updatedAtSec": 0.0,
+ "support": {},
+ "caveats": [],
+ "skippedManeuvers": [],
"history": [],
}
@@ -2868,15 +2872,32 @@ def _append_longitudinal_maneuver_history(status, 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.time() - 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,
diff --git a/tools/longitudinal_maneuvers/README.md b/tools/longitudinal_maneuvers/README.md
index 643af7fd..98d81b5e 100644
--- a/tools/longitudinal_maneuvers/README.md
+++ b/tools/longitudinal_maneuvers/README.md
@@ -18,9 +18,9 @@ Test your vehicle's longitudinal control tuning with this tool. The tool will te

-5. Ensure the road ahead is clear, as openpilot will not brake for any obstructions in this mode. Once you are ready, press "Set" on your steering wheel to start the tests. The tests will run for about 4 minutes. If you need to pause the tests, press "Cancel" on your steering wheel. You can resume the tests by pressing "Resume" on your steering wheel.
+5. Ensure the road ahead is clear, as openpilot will not brake for any obstructions in this mode. Once you are ready, press "Set" on your steering wheel to start the tests. The tests will run for about 4 minutes. If you need to pause the tests, press "Cancel" on your steering wheel. You can resume the tests by pressing "Resume" on your steering wheel.
- **Note:** For GM cars, it is recommended to hold down the resume button for all low-speed tests (starting, stopping and creep) to avoid the car entering standstill.
+ **Note:** The suite now reads vehicle stop/go capability from `CarParams`. Cars that can reach a stop but need resume assistance will allow standstill during zero-speed tests, and cars that are not expected to reach zero will skip stop/start/creep maneuvers automatically.

diff --git a/tools/longitudinal_maneuvers/capabilities.py b/tools/longitudinal_maneuvers/capabilities.py
new file mode 100644
index 00000000..fc54850f
--- /dev/null
+++ b/tools/longitudinal_maneuvers/capabilities.py
@@ -0,0 +1,75 @@
+from __future__ import annotations
+
+from dataclasses import asdict, dataclass
+from typing import Any
+
+
+LOW_SPEED_MANEUVER_DESCRIPTIONS = (
+ "come to stop",
+ "start from stop",
+ "creep: alternate between +1m/s^2 and -1m/s^2",
+)
+
+
+@dataclass(frozen=True)
+class LongitudinalManeuverSupport:
+ openpilotLongitudinalControl: bool
+ fullStopAndGo: bool
+ autoResumeFromStop: bool
+ requiresResumeAssist: bool
+ expectedToReachZero: bool
+ minEnableSpeed: float
+ stopAccel: float
+ caveats: tuple[str, ...]
+ skippedManeuvers: tuple[str, ...]
+
+ def to_dict(self) -> dict[str, Any]:
+ return asdict(self)
+
+
+def get_longitudinal_maneuver_support(CP: Any) -> LongitudinalManeuverSupport:
+ openpilot_longitudinal = bool(getattr(CP, "openpilotLongitudinalControl", False))
+ auto_resume_supported = bool(getattr(CP, "autoResumeSng", 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.
+ full_stop_and_go = min_enable_speed <= 0.0
+ 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
+
+ caveats: list[str] = []
+ 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:
+ 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.")
+
+ skipped_maneuvers = LOW_SPEED_MANEUVER_DESCRIPTIONS if not expected_to_reach_zero else ()
+
+ return LongitudinalManeuverSupport(
+ openpilotLongitudinalControl=openpilot_longitudinal,
+ fullStopAndGo=full_stop_and_go,
+ autoResumeFromStop=auto_resume_from_stop,
+ requiresResumeAssist=requires_resume_assist,
+ expectedToReachZero=expected_to_reach_zero,
+ minEnableSpeed=min_enable_speed,
+ stopAccel=stop_accel,
+ caveats=tuple(caveats),
+ skippedManeuvers=tuple(skipped_maneuvers),
+ )
+
+
+def get_maneuver_skip_reason(description: str, support: LongitudinalManeuverSupport) -> str | None:
+ if not support.openpilotLongitudinalControl:
+ return "openpilot longitudinal is disabled"
+
+ if description in LOW_SPEED_MANEUVER_DESCRIPTIONS and not support.expectedToReachZero:
+ return "vehicle is not expected to reach a true standstill"
+
+ return None
diff --git a/tools/longitudinal_maneuvers/generate_report.py b/tools/longitudinal_maneuvers/generate_report.py
index 8c16e30d..fb17556d 100755
--- a/tools/longitudinal_maneuvers/generate_report.py
+++ b/tools/longitudinal_maneuvers/generate_report.py
@@ -12,6 +12,7 @@ import matplotlib.pyplot as plt
from tabulate import tabulate
from openpilot.tools.lib.logreader import LogReader
+from openpilot.tools.longitudinal_maneuvers.capabilities import get_longitudinal_maneuver_support
from openpilot.system.hardware.hw import Paths
@@ -19,11 +20,42 @@ def format_car_params(CP):
return pprint.pformat({k: v for k, v in CP.to_dict().items() if not k.endswith('DEPRECATED')}, indent=2)
+def format_support_section(support):
+ rows = [
+ ("openpilot longitudinal", "yes" if support.openpilotLongitudinalControl else "no"),
+ ("full stop and go", "yes" if support.fullStopAndGo else "no"),
+ ("auto resume from stop", "yes" if support.autoResumeFromStop else "no"),
+ ("resume assist expected", "yes" if support.requiresResumeAssist else "no"),
+ ("expected to reach zero", "yes" if support.expectedToReachZero else "no"),
+ ("min enable speed", f"{support.minEnableSpeed:.2f} m/s"),
+ ("stop accel", f"{support.stopAccel:.2f} m/s^2"),
+ ]
+
+ builder = ["Platform Caveats
\n"]
+ builder.append(tabulate(rows, headers=("capability", "value"), tablefmt="html") + "\n")
+
+ if support.caveats:
+ builder.append("Caveats
\n")
+ for caveat in support.caveats:
+ builder.append(f"- {caveat}
\n")
+ builder.append("
\n")
+
+ if support.skippedManeuvers:
+ builder.append("Expected skipped maneuvers
\n")
+ for description in support.skippedManeuvers:
+ builder.append(f"- {description}
\n")
+ builder.append("
\n")
+
+ builder.append(" \n")
+ return ''.join(builder)
+
+
def report(platform, route, _description, CP, ID, maneuvers):
output_path = Path(__file__).resolve().parent / "longitudinal_reports"
output_fn = output_path / f"{platform}_{route.replace('/', '_')}.html"
output_path.mkdir(exist_ok=True)
target_cross_times = defaultdict(list)
+ support = get_longitudinal_maneuver_support(CP)
builder = [
"\n",
@@ -35,6 +67,7 @@ def report(platform, route, _description, CP, ID, maneuvers):
if _description is not None:
builder.append(f"Description: {_description}
\n")
builder.append(f"CarParams
{format_car_params(CP)} \n")
+ builder.append(format_support_section(support))
builder.append('{ summary }') # to be replaced below
for description, runs in maneuvers:
print(f'plotting maneuver: {description}, runs: {len(runs)}')
@@ -56,7 +89,7 @@ def report(platform, route, _description, CP, ID, maneuvers):
# maneuver validity
longActive = [m.longActive for m in carControl]
- maneuver_valid = all(longActive) and (not any(cs.cruiseState.standstill for cs in carState) or CP.autoResumeSng)
+ maneuver_valid = all(longActive)
_open = 'open' if maneuver_valid else ''
title = f'Run #{int(run)+1}' + (' (invalid maneuver!)' if not maneuver_valid else '')
diff --git a/tools/longitudinal_maneuvers/maneuversd.py b/tools/longitudinal_maneuvers/maneuversd.py
index c17ae237..7ca6b4db 100755
--- a/tools/longitudinal_maneuvers/maneuversd.py
+++ b/tools/longitudinal_maneuvers/maneuversd.py
@@ -7,6 +7,10 @@ from openpilot.common.constants import CV
from openpilot.common.realtime import DT_MDL
from openpilot.common.params import Params
from openpilot.common.swaglog import cloudlog
+from openpilot.tools.longitudinal_maneuvers.capabilities import (
+ get_longitudinal_maneuver_support,
+ get_maneuver_skip_reason,
+)
@dataclass
@@ -33,9 +37,11 @@ class Maneuver:
_repeated: int = 0
def get_accel(self, v_ego: float, long_active: bool, standstill: bool, cruise_standstill: bool) -> float:
- ready = abs(v_ego - self.initial_speed) < 0.3 and long_active and not cruise_standstill
+ ready = abs(v_ego - self.initial_speed) < 0.3 and long_active
if self.initial_speed < 0.01:
ready = ready and standstill
+ else:
+ ready = ready and not cruise_standstill
self._ready_cnt = (self._ready_cnt + 1) if ready else 0
if self._ready_cnt > (3. / DT_MDL):
@@ -76,65 +82,75 @@ class Maneuver:
return self._active
-MANEUVERS = [
- Maneuver(
- "come to stop",
- [Action([-0.5], [12])],
- repeat=2,
- initial_speed=5.,
- ),
- Maneuver(
- "start from stop",
- [Action([1.5], [6])],
- repeat=2,
- initial_speed=0.,
- ),
- Maneuver(
- "creep: alternate between +1m/s^2 and -1m/s^2",
- [
- Action([1], [3]), Action([-1], [3]),
- Action([1], [3]), Action([-1], [3]),
- Action([1], [3]), Action([-1], [3]),
- ],
- repeat=2,
- initial_speed=0.,
- ),
- Maneuver(
- "brake step response: -1m/s^2 from 20mph",
- [Action([-1], [3])],
- repeat=2,
- initial_speed=20. * CV.MPH_TO_MS,
- ),
- Maneuver(
- "brake step response: -4m/s^2 from 20mph",
- [Action([-4], [3])],
- repeat=2,
- initial_speed=20. * CV.MPH_TO_MS,
- ),
- Maneuver(
- "gas step response: +1m/s^2 from 20mph",
- [Action([1], [3])],
- repeat=2,
- initial_speed=20. * CV.MPH_TO_MS,
- ),
- Maneuver(
- "gas step response: +4m/s^2 from 20mph",
- [Action([4], [3])],
- repeat=2,
- initial_speed=20. * CV.MPH_TO_MS,
- ),
-]
+def build_maneuvers():
+ return [
+ Maneuver(
+ "come to stop",
+ [Action([-0.5], [12])],
+ repeat=2,
+ initial_speed=5.,
+ ),
+ Maneuver(
+ "start from stop",
+ [Action([1.5], [6])],
+ repeat=2,
+ initial_speed=0.,
+ ),
+ Maneuver(
+ "creep: alternate between +1m/s^2 and -1m/s^2",
+ [
+ Action([1], [3]), Action([-1], [3]),
+ Action([1], [3]), Action([-1], [3]),
+ Action([1], [3]), Action([-1], [3]),
+ ],
+ repeat=2,
+ initial_speed=0.,
+ ),
+ Maneuver(
+ "brake step response: -1m/s^2 from 20mph",
+ [Action([-1], [3])],
+ repeat=2,
+ initial_speed=20. * CV.MPH_TO_MS,
+ ),
+ Maneuver(
+ "brake step response: -4m/s^2 from 20mph",
+ [Action([-4], [3])],
+ repeat=2,
+ initial_speed=20. * CV.MPH_TO_MS,
+ ),
+ Maneuver(
+ "gas step response: +1m/s^2 from 20mph",
+ [Action([1], [3])],
+ repeat=2,
+ initial_speed=20. * CV.MPH_TO_MS,
+ ),
+ Maneuver(
+ "gas step response: +4m/s^2 from 20mph",
+ [Action([4], [3])],
+ repeat=2,
+ initial_speed=20. * CV.MPH_TO_MS,
+ ),
+ ]
def main():
params = Params()
cloudlog.info("joystickd is waiting for CarParams")
CP = messaging.log_from_bytes(params.get("CarParams", block=True), car.CarParams)
+ support = get_longitudinal_maneuver_support(CP)
+
+ supported_maneuvers = []
+ for maneuver in build_maneuvers():
+ skip_reason = get_maneuver_skip_reason(maneuver.description, support)
+ if skip_reason is not None:
+ cloudlog.warning(f"Skipping longitudinal maneuver '{maneuver.description}': {skip_reason}")
+ continue
+ supported_maneuvers.append(maneuver)
sm = messaging.SubMaster(['carState', 'carControl', 'controlsState', 'selfdriveState', 'modelV2'], poll='modelV2')
pm = messaging.PubMaster(['longitudinalPlan', 'driverAssistance', 'alertDebug'])
- maneuvers = iter(MANEUVERS)
+ maneuvers = iter(supported_maneuvers)
maneuver = None
while True: