Compare commits

..

1 Commits

Author SHA1 Message Date
whoisdomi cbfdb65719 C3X Cooling Curve 2026-09-09 15:43:26 -05:00
86 changed files with 319 additions and 10413 deletions
Binary file not shown.
-2
View File
@@ -110,7 +110,6 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"LocationFilterInitialState", {PERSISTENT, BYTES}},
{"LongitudinalManeuverMode", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, BOOL}},
{"LongitudinalPersonality", {PERSISTENT, INT, std::to_string(static_cast<int>(cereal::LongitudinalPersonality::STANDARD))}},
{"LongitudinalPersonalityProfiles", {PERSISTENT | DONT_LOG, JSON, "{}", "{}"}},
{"NetworkMetered", {PERSISTENT, BOOL}},
{"ObdMultiplexingChanged", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, BOOL}},
{"ObdMultiplexingEnabled", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, BOOL}},
@@ -466,7 +465,6 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"LeadDetectionThreshold", {PERSISTENT, INT, "35", "50", 3}},
{"LeadIndicator", {PERSISTENT, BOOL, "0", "0", 2, SETTINGS_SIMPLE}},
{"LeadInfo", {PERSISTENT, BOOL, "0", "0", 3}},
{"LeadInfoMode", {PERSISTENT, INT, "2", "2", 3}},
{"LKASButtonControl", {PERSISTENT, INT, "5", "0", 2, SETTINGS_SIMPLE}},
{"LockDoors", {PERSISTENT, BOOL, "1", "0", 0}},
{"LockDoorsTimer", {PERSISTENT, INT, "0", "0", 0}},
Binary file not shown.
+1 -26
View File
@@ -5,7 +5,7 @@ import threading
import time
import uuid
from openpilot.common.params import Params, ParamKeyFlag, ParamKeyType, UnknownKeyName
from openpilot.common.params import Params, ParamKeyFlag, UnknownKeyName
class TestParams:
def setup_method(self):
@@ -128,31 +128,6 @@ class TestParams:
assert self.params.get("LiveParameters") is None
assert self.params.get("LiveParameters", return_default=True) is None
def test_longitudinal_personality_profiles_json_round_trip(self):
key = "LongitudinalPersonalityProfiles"
value = {
"schemaVersion": 1,
"enabled": False,
"axes": {
"acceleration": {
"speed": {"unit": "mph", "values": [0.0, 11.184681, 22.369363, 33.554044, 44.738726, 55.923407, 89.477452]},
"value": {"unit": "m/s^2", "meaning": "maximum_requested_acceleration"},
},
"braking": {
"speed": {"unit": "mph", "values": [0.0, 11.184681, 22.369363, 33.554044, 44.738726, 55.923407, 89.477452]},
"value": {"unit": "m/s^2", "meaning": "cruise_slc_deceleration_magnitude"},
},
"following": {"speed": {"unit": "mph", "values": [0, 10, 20, 30, 40, 50, 60, 70, 80, 90]}, "value": {"unit": "s", "meaning": "base_time_headway"}},
},
"profiles": {},
}
self.params.remove(key)
assert self.params.get_type(key) == ParamKeyType.JSON
assert self.params.get(key) is None
self.params.put(key, value)
assert self.params.get(key) == value
def test_params_get_type(self):
# json
self.params.put("ApiCache_DriveStats", {"a": 0})
@@ -1497,7 +1497,7 @@ BO_ 913 BCM_PO_11: 8 Vector__XXX
SG_ BCM_Door_Dri_Status : 5|1@0+ (1,0) [0|1] "" PT_ESC_ABS
SG_ BCM_Shift_R_MT_SW_Status : 39|2@0+ (1,0) [0|3] "" PT_ESC_ABS
SG_ LDA_BTN : 4|1@0+ (1,0) [0|1] "" XXX
SG_ RAY_LKAS_BTN : 4|1@0+ (1,0) [0|1] "" XXX
SG_ RAY_LKAS_BTN : 0|2@1+ (1,0) [0|3] "" XXX
BO_ 1426 LABEL11: 8 XXX
SG_ CC_React : 34|1@1+ (1,0) [0|1] "" XXX
+4 -96
View File
@@ -19,7 +19,6 @@ import shlex
import shutil
import subprocess
import sys
import tempfile
import urllib.error
import urllib.parse
import urllib.request
@@ -620,70 +619,6 @@ def update_manifest(repo: Path, info: ReleaseInfo, result: dict, manifest_versio
return path
def manifest_models(payload: object) -> list[dict]:
models = payload.get("models") if isinstance(payload, dict) else payload
if not isinstance(models, list) or not models:
raise ReleaseError("Unsupported or empty Hugging Face manifest")
if any(not isinstance(model, dict) or not str(model.get("id") or "").strip() for model in models):
raise ReleaseError("Hugging Face manifest contains an invalid model entry")
model_ids = [str(model["id"]).strip() for model in models]
if len(model_ids) != len(set(model_ids)):
raise ReleaseError("Hugging Face manifest contains duplicate model IDs")
return models
def accelerator_artifact_map(payload: object) -> dict[tuple[str, str], dict]:
artifacts: dict[tuple[str, str], dict] = {}
for model in manifest_models(payload):
model_id = str(model.get("id") or "").strip()
model_artifacts = model.get("accelerator_artifacts")
if not model_id or not isinstance(model_artifacts, dict):
continue
for accelerator, metadata in model_artifacts.items():
if isinstance(metadata, dict):
artifacts[(model_id, str(accelerator))] = metadata
return artifacts
def validate_manifest_update(before: object, after: object, replacing_model_id: str) -> None:
before_by_id = {str(model["id"]).strip(): model for model in manifest_models(before)}
after_by_id = {str(model["id"]).strip(): model for model in manifest_models(after)}
before_ids = set(before_by_id)
after_ids = set(after_by_id)
expected_ids = before_ids | {replacing_model_id}
if after_ids != expected_ids:
missing = sorted(expected_ids - after_ids)
unexpected = sorted(after_ids - expected_ids)
raise ReleaseError(
"Refusing to publish a manifest with an unexpected model set"
+ (f"; missing: {', '.join(missing)}" if missing else "")
+ (f"; unexpected: {', '.join(unexpected)}" if unexpected else "")
)
before_artifacts = accelerator_artifact_map(before)
after_artifacts = accelerator_artifact_map(after)
regressions = [
f"{model_id}:{accelerator}"
for (model_id, accelerator), metadata in before_artifacts.items()
if model_id != replacing_model_id and after_artifacts.get((model_id, accelerator)) != metadata
]
if regressions:
raise ReleaseError(
"Refusing to publish a manifest that removes or changes existing accelerator metadata for: "
+ ", ".join(sorted(regressions))
)
unrelated_changes = sorted(
model_id for model_id, model in before_by_id.items()
if model_id != replacing_model_id and after_by_id[model_id] != model
)
if unrelated_changes:
raise ReleaseError(
"Refusing to publish a manifest that changes unrelated model entries: "
+ ", ".join(unrelated_changes)
)
def find_hf() -> str:
candidates = [shutil.which("hf"), str(Path.home() / ".local/bin/hf")]
for candidate in candidates:
@@ -698,42 +633,15 @@ def hf_copy(source: Path, bucket: str, remote_path: str) -> None:
run([hf, "buckets", "cp", str(source), destination, "--format", "quiet"])
def refresh_huggingface_manifest(manifest: Path, bucket: str) -> dict:
remote_path = f"manifests/{manifest.name}"
source = f"hf://buckets/{bucket}/{remote_path}"
manifest.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix=".model-release-", dir=manifest.parent) as temporary_dir:
candidate = Path(temporary_dir) / manifest.name
run([find_hf(), "buckets", "cp", source, str(candidate), "--format", "quiet"])
try:
payload = json.loads(candidate.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
raise ReleaseError(f"Invalid live Hugging Face manifest: {error}") from error
accelerator_artifact_map(payload)
candidate.replace(manifest)
return payload
def prepare_huggingface_manifest(manifest: Path, info: ReleaseInfo, result: dict,
bucket: str, manifest_version: str) -> Path:
live_payload = refresh_huggingface_manifest(manifest, bucket)
updated_manifest = update_manifest(manifest.parent, info, result, manifest_version)
updated_payload = json.loads(updated_manifest.read_text(encoding="utf-8"))
validate_manifest_update(live_payload, updated_payload, info.model_id)
return updated_manifest
def upload_huggingface(info: ReleaseInfo, result: dict, workspace: Path, bucket: str, manifest_version: str,
manifest: Path, upload_onnx: bool, source: Path) -> Path:
manifest: Path, upload_onnx: bool, source: Path) -> None:
artifact_dir = Path(result["path"])
for filename in result["files"]:
hf_copy(artifact_dir / filename, bucket, f"models/{manifest_version}/{info.model_id}/{filename}")
if upload_onnx:
hf_copy(source, bucket, f"onnx/{info.model_id}/{source.name}")
manifest = prepare_huggingface_manifest(manifest, info, result, bucket, manifest_version)
hf_copy(manifest, bucket, f"manifests/{manifest.name}")
print(f"Hugging Face upload complete: {bucket}/models/{manifest_version}/{info.model_id}/")
return manifest
def git_output(repo: Path, args: list[str]) -> str:
@@ -869,9 +777,9 @@ def main() -> int:
result = remote_compile(info, source, ip, workspace, args.keep_device_files)
resources_repo = args.resources_repo.expanduser().resolve()
check_resources_repo(resources_repo, args.resources_branch)
manifest = resources_repo / f"model_names_{args.manifest_version}.json"
manifest = upload_huggingface(info, result, workspace, args.hf_bucket, args.manifest_version,
manifest, not args.no_onnx_upload, source)
manifest = update_manifest(resources_repo, info, result, args.manifest_version)
upload_huggingface(info, result, workspace, args.hf_bucket, args.manifest_version,
manifest, not args.no_onnx_upload, source)
push_github(info, result, resources_repo, args.manifest_version, manifest, args.resources_branch, args.force)
print("\nRelease complete.")
print(f" local artifact: {result['path']}")
+1 -148
View File
@@ -3,20 +3,8 @@ from __future__ import annotations
import json
from pathlib import Path
import pytest
from scripts import model_release
from scripts.model_release import (
ReleaseError,
parse_lfs_pointer,
parse_pasted_release,
prepare_huggingface_manifest,
refresh_huggingface_manifest,
runtime_file,
update_manifest,
upload_huggingface,
validate_manifest_update,
)
from scripts.model_release import parse_lfs_pointer, parse_pasted_release, runtime_file, update_manifest
RELEASE_TEXT = """
@@ -107,138 +95,3 @@ def test_update_manifest_replaces_one_entry(tmp_path: Path):
assert entry["artifact_size"] == 123
assert entry["artifact_chunk_count"] == 2
assert entry["uses_external_gpu"]
def test_prepare_manifest_refreshes_live_copy_before_adding_model(tmp_path: Path, monkeypatch):
manifest = tmp_path / "model_names_v25.json"
manifest.write_text(json.dumps({"models": [{"id": "stale"}]}) + "\n")
live_payload = {
"models": [{
"id": "small-model",
"model_lab_eligible": True,
"accelerator_artifacts": {
"chestnut": {
"artifact_filename": "small-model_driving_chestnut_tinygrad.pkl",
"artifact_sha256": "b" * 64,
"execution_device": "AMD",
},
},
}],
}
def fake_refresh(path, bucket):
assert bucket == "StarPilot-Driving/StarPilot-Resources"
path.write_text(json.dumps(live_payload) + "\n")
return live_payload
monkeypatch.setattr(model_release, "refresh_huggingface_manifest", fake_refresh)
info = parse_pasted_release(RELEASE_TEXT, "bmrlnapv4", "v16")
prepared = prepare_huggingface_manifest(
manifest,
info,
{"size": 123, "sha256": "a" * 64, "chunk_count": 2},
"StarPilot-Driving/StarPilot-Resources",
"v25",
)
models = {entry["id"]: entry for entry in json.loads(prepared.read_text())["models"]}
assert set(models) == {"small-model", "bmrlnapv4"}
assert models["small-model"] == live_payload["models"][0]
assert models["bmrlnapv4"]["artifact_sha256"] == "a" * 64
def test_manifest_guard_rejects_unrelated_accelerator_metadata_regression():
chestnut = {"execution_device": "AMD", "artifact_sha256": "a" * 64}
before = {"models": [
{"id": "keep", "accelerator_artifacts": {"chestnut": chestnut}},
{"id": "replace", "accelerator_artifacts": {"chestnut": chestnut}},
]}
after = {"models": [{"id": "keep"}, {"id": "replace"}]}
with pytest.raises(ReleaseError, match="keep:chestnut"):
validate_manifest_update(before, after, "replace")
validate_manifest_update(
{"models": [{"id": "replace", "accelerator_artifacts": {"chestnut": chestnut}}]},
{"models": [{"id": "replace"}]},
"replace",
)
with pytest.raises(ReleaseError, match="unrelated model entries: keep"):
validate_manifest_update(
{"models": [{"id": "keep", "model_lab_eligible": True}]},
{"models": [{"id": "keep", "model_lab_eligible": False}, {"id": "replace"}]},
"replace",
)
def test_refresh_manifest_is_atomic_and_uses_hugging_face_bucket(tmp_path: Path, monkeypatch):
manifest = tmp_path / "model_names_v25.json"
manifest.write_text(json.dumps({"models": [{"id": "stale"}]}) + "\n")
live_payload = {"models": [{"id": "live"}]}
commands = []
monkeypatch.setattr(model_release, "find_hf", lambda: "/usr/bin/hf")
def fake_run(command, **kwargs):
commands.append(command)
Path(command[4]).write_text(json.dumps(live_payload) + "\n")
monkeypatch.setattr(model_release, "run", fake_run)
assert refresh_huggingface_manifest(manifest, "owner/resources") == live_payload
assert json.loads(manifest.read_text()) == live_payload
assert commands[0][0:4] == [
"/usr/bin/hf",
"buckets",
"cp",
"hf://buckets/owner/resources/manifests/model_names_v25.json",
]
def test_refresh_manifest_keeps_local_copy_when_live_json_is_invalid(tmp_path: Path, monkeypatch):
manifest = tmp_path / "model_names_v25.json"
original = {"models": [{"id": "safe"}]}
manifest.write_text(json.dumps(original) + "\n")
monkeypatch.setattr(model_release, "find_hf", lambda: "/usr/bin/hf")
monkeypatch.setattr(model_release, "run", lambda command, **kwargs: Path(command[4]).write_text("{"))
with pytest.raises(ReleaseError, match="Invalid live Hugging Face manifest"):
refresh_huggingface_manifest(manifest, "owner/resources")
assert json.loads(manifest.read_text()) == original
def test_upload_refreshes_manifest_after_artifacts(tmp_path: Path, monkeypatch):
artifact_dir = tmp_path / "artifacts"
artifact_dir.mkdir()
manifest = tmp_path / "resources" / "model_names_v25.json"
manifest.parent.mkdir()
manifest.write_text(json.dumps({"models": [{"id": "old"}]}) + "\n")
source = tmp_path / "model.onnx"
calls = []
info = parse_pasted_release(RELEASE_TEXT, "bmrlnapv4", "v16")
result = {
"path": str(artifact_dir),
"files": ["bmrlnapv4_driving_tinygrad.pkl"],
"size": 123,
"sha256": "a" * 64,
"chunk_count": 0,
}
monkeypatch.setattr(model_release, "hf_copy", lambda source, bucket, remote: calls.append(("copy", remote)))
def fake_prepare(path, release_info, release_result, bucket, version):
calls.append(("prepare", path.name))
return path
monkeypatch.setattr(model_release, "prepare_huggingface_manifest", fake_prepare)
returned = upload_huggingface(
info, result, tmp_path, "owner/resources", "v25", manifest, True, source,
)
assert returned == manifest
assert calls == [
("copy", "models/v25/bmrlnapv4/bmrlnapv4_driving_tinygrad.pkl"),
("copy", "onnx/bmrlnapv4/model.onnx"),
("prepare", "model_names_v25.json"),
("copy", "manifests/model_names_v25.json"),
]
+1 -1
View File
@@ -262,7 +262,7 @@ class LongControl:
output_accel = min(output_accel, 0.0)
output_accel -= starpilot_toggles.stoppingDecelRate * DT_CTRL
output_accel = self.vehicle_tuning.shape_stopping_accel(
output_accel, a_target, should_stop, CS.vEgo, has_lead, starpilot_toggles.stopAccel, leads=leads,
output_accel, a_target, should_stop, CS.vEgo, has_lead, starpilot_toggles.stopAccel,
)
output_accel = self._apply_moving_stop_target_follow(output_accel, a_target, should_stop, CS, starpilot_toggles)
self.reset(preserve_stop_release=True)
@@ -3,7 +3,6 @@ import numpy as np
from opendbc.car.gm.values import CAR, GMFlags
from opendbc.car.subaru.values import CAR as SUBARU_CAR
from opendbc.car.toyota.values import CAR as TOYOTA_CAR
from opendbc.car.volkswagen.values import CAR as VOLKSWAGEN_CAR
from openpilot.common.realtime import DT_CTRL
from openpilot.starpilot.common.testing_grounds import testing_ground
@@ -64,12 +63,6 @@ HYUNDAI_ELANTRA_FINAL_STOP_URGENCY_MARGIN = 0.45
HYUNDAI_SANTA_FE_FINAL_STOP_MAX_SPEED = 1.0
HYUNDAI_SANTA_FE_FINAL_STOP_CAP_BP = [0.0, 0.2, 0.5, HYUNDAI_SANTA_FE_FINAL_STOP_MAX_SPEED]
HYUNDAI_SANTA_FE_FINAL_STOP_CAP_V = [-0.25, -0.30, -0.50, -0.90]
VOLKSWAGEN_TAOS_COMFORT_STOP_MAX_SPEED = 4.5
VOLKSWAGEN_TAOS_COMFORT_STOP_MIN_DISTANCE = 5.0
VOLKSWAGEN_TAOS_COMFORT_STOP_MIN_TTC = 4.0
VOLKSWAGEN_TAOS_COMFORT_STOP_MAX_CLOSING_SPEED = 1.5
VOLKSWAGEN_TAOS_COMFORT_STOP_CAP_BP = [0.0, 0.5, 1.0, 2.0, 3.5, VOLKSWAGEN_TAOS_COMFORT_STOP_MAX_SPEED]
VOLKSWAGEN_TAOS_COMFORT_STOP_CAP_V = [-0.45, -0.55, -0.65, -0.80, -0.95, -1.10]
def get_bolt_acc_pedal_friction_bias(output_accel, a_target, v_ego):
@@ -159,10 +152,6 @@ class LongControlVehicleTuning:
self.is_hyundai_santa_fe_2022 = bool(
CP.brand == "hyundai" and str(getattr(CP, "carFingerprint", "")) == "HYUNDAI_SANTA_FE_2022"
)
self.is_volkswagen_taos = bool(
CP.brand == "volkswagen" and
str(getattr(CP, "carFingerprint", "")) == str(VOLKSWAGEN_CAR.VOLKSWAGEN_TAOS_MK1)
)
self.is_bolt_acc_pedal_friction_car = bool(
CP.brand == "gm" and
CP.enableGasInterceptorDEPRECATED and
@@ -183,32 +172,8 @@ class LongControlVehicleTuning:
self.bolt_start_handoff_frames = 0
self.subaru_stop_release_frames = 0
def shape_stopping_accel(self, output_accel, a_target, should_stop, v_ego, has_lead, stop_accel, leads=None):
def shape_stopping_accel(self, output_accel, a_target, should_stop, v_ego, has_lead, stop_accel):
"""Shape low-speed stop braking without overriding urgent targets."""
if self.is_volkswagen_taos and should_stop and has_lead and v_ego < VOLKSWAGEN_TAOS_COMFORT_STOP_MAX_SPEED:
comfort_lead = next((
lead for lead in (leads or ())
if bool(getattr(lead, "status", False)) and
abs(float(getattr(lead, "yRel", 0.0))) <= 1.75 and
float(getattr(lead, "dRel", 0.0)) > 0.0
), None)
if comfort_lead is not None:
lead_distance = float(getattr(comfort_lead, "dRel", 0.0))
lead_speed = max(0.0, float(getattr(comfort_lead, "vLead", 0.0)))
closing_speed = max(0.0, float(v_ego) - lead_speed)
ttc = lead_distance / max(closing_speed, 0.1) if closing_speed > 0.1 else float("inf")
if (
lead_distance >= VOLKSWAGEN_TAOS_COMFORT_STOP_MIN_DISTANCE and
ttc >= VOLKSWAGEN_TAOS_COMFORT_STOP_MIN_TTC and
closing_speed <= VOLKSWAGEN_TAOS_COMFORT_STOP_MAX_CLOSING_SPEED
):
comfort_cap = float(interp(
v_ego,
VOLKSWAGEN_TAOS_COMFORT_STOP_CAP_BP,
VOLKSWAGEN_TAOS_COMFORT_STOP_CAP_V,
))
return max(float(output_accel), comfort_cap)
if (
self.is_hyundai_elantra_2021 and
should_stop and
@@ -1,117 +0,0 @@
"""Exact class and planner branch, fake clocks/scene/Params; no native runtime."""
import ast
from pathlib import Path
from types import SimpleNamespace as NS
import pytest
ROOT = Path(__file__).resolve().parents[3]
def make_modes():
now = [100.0]
statuses = {'OFF': 0, 'LEAD': 1, 'SPEED': 2, 'USER_EXPERIMENTAL': 99, 'USER_OVERRIDDEN': 99}
memory = {}
params = NS(get_bool=lambda _: False)
planner = NS(params=params, params_memory=NS(put_int=lambda k,v: memory.update({k:v})))
ns = {'time': NS(monotonic=lambda: now[0]), 'CV': NS(MPH_TO_MS=0.44704), 'CCStatus': statuses, 'CEStatus': statuses,
'restore_persisted_cc_state': lambda *_: memory.get('manual', 0), 'restore_persisted_ce_state': lambda *_: memory.get('manual', 0),
'is_manual_cc_status': lambda s: s == 99, 'is_manual_ce_status': lambda s: s == 99,
'FirstOrderFilter': lambda *args: NS(x=0), 'DT_MDL': .05}
for file, name in [('conditional_chill_mode.py','ConditionalChillMode'), ('conditional_experimental_mode.py','ConditionalExperimentalMode')]:
path = ROOT/'starpilot/controls/lib'/file
cls = next(n for n in ast.parse(path.read_text()).body if isinstance(n, ast.ClassDef) and n.name == name)
exec(compile(ast.Module(body=[cls], type_ignores=[]), str(path), 'exec'), ns)
cem = ns['ConditionalExperimentalMode'](planner)
ccm = ns['ConditionalChillMode'](planner, cem)
planner.starpilot_cem, planner.starpilot_ccm = cem, ccm
ccm._refresh_detector = lambda *_: None
ccm._get_chill_status = lambda *_: (1, False)
ccm._has_hard_veto = lambda *a, **k: False
cem.update_conditions = lambda *_: None
cem.check_conditions = lambda *_: False
cem.stop_sign_and_light = lambda *_: None
return planner, now, memory
def branch(planner, mode):
path = ROOT/'starpilot/controls/starpilot_planner.py'
node = next(n for n in ast.walk(ast.parse(path.read_text())) if isinstance(n, ast.If) and ast.unparse(n.test).startswith('conditional_tracking_active and'))
exec(compile(ast.Module(body=[node],type_ignores=[]), str(path), 'exec'),
{'self': planner, 'conditional_tracking_active': True, 'starpilot_toggles': NS(conditional_experimental_mode=mode=='cem', conditional_chill_mode=mode=='ccm'), 'v_ego':20, 'v_cruise':30, 'sm':{}, 'PLANNER_TIME':10})
@pytest.mark.parametrize('absence', [.1, 100])
@pytest.mark.parametrize('other', ['fixed', 'cem'])
def test_ccm_reentry_requires_new_confirmation(absence, other):
p, now, _ = make_modes()
p.starpilot_ccm.update(20,30,{},NS())
original_update = p.starpilot_cem.update
p.starpilot_cem.update = lambda *_: None
branch(p, other)
p.starpilot_cem.update = original_update
now[0] += absence
p.starpilot_ccm.update(20,30,{},NS())
assert p.starpilot_ccm.experimental_mode
assert p.starpilot_ccm._candidate_since == now[0]
now[0] += 1.01
p.starpilot_ccm.update(20,30,{},NS())
assert not p.starpilot_ccm.experimental_mode
@pytest.mark.parametrize('other', ['fixed', 'ccm'])
def test_cem_reentry_does_not_inherit_mode_hold(other):
p, now, _ = make_modes()
cem = p.starpilot_cem
cem.prev_experimental_mode = True
cem.mode_hold_until = now[0] + .5
cem.slow_lead_mode_hold_until = now[0] + 1.5
original_update = p.starpilot_ccm.update
p.starpilot_ccm.update = lambda *_: None
branch(p, other)
p.starpilot_ccm.update = original_update
now[0] += .1
cem.update(20, {'carState': NS(standstill=False)}, NS(conditional_lead=False,conditional_open_road=False))
assert not cem.experimental_mode
@pytest.mark.parametrize('other', ['fixed', 'cem'])
def test_ccm_manual_override_survives_deactivation(other):
p, _, memory = make_modes()
memory['manual'] = 99
p.starpilot_cem.update = lambda *_: None
branch(p, other)
p.starpilot_ccm.update(20,30,{},NS())
assert p.starpilot_ccm.experimental_mode
assert memory['manual'] == 99
def test_cem_deactivation_retains_shared_hazard_detector_state():
p, _, _ = make_modes()
cem = p.starpilot_cem
cem.stop_light_detected = True
cem.stop_light_filter.x = .9
cem.standstill_stop_reason = 'sign'
branch(p, 'fixed')
assert cem.stop_light_detected and cem.stop_light_filter.x == .9
assert cem.standstill_stop_reason == 'sign'
@pytest.mark.parametrize('condition', ['none', 'veto', 'safe'])
def test_ccm_reentry_still_honors_current_scene_and_safety(condition):
p, now, _ = make_modes()
ccm = p.starpilot_ccm
ccm.update(20,30,{},NS())
branch(p, 'fixed')
now[0] += 100
if condition == 'none':
ccm._get_chill_status = lambda *_: (0, False)
elif condition == 'veto':
ccm._has_hard_veto = lambda *a, **k: True
else:
p.params.get_bool = lambda key: key == 'SafeMode'
ccm.update(20,30,{},NS())
assert ccm.experimental_mode == (condition != 'safe')
assert ccm._candidate_since == 0
def test_cem_manual_override_survives_inactive_interval():
p, _, memory = make_modes()
memory['manual'] = 99
branch(p, 'fixed')
p.starpilot_cem.update(20, {'carState': NS(standstill=False)}, NS())
assert p.starpilot_cem.experimental_mode
assert memory['manual'] == 99
@@ -8,7 +8,6 @@ import openpilot.selfdrive.controls.lib.longcontrol_vehicle_tunes as vehicle_tun
from opendbc.car.gm.values import CAR, GMFlags
from opendbc.car.subaru.values import CAR as SUBARU_CAR
from opendbc.car.toyota.values import CAR as TOYOTA_CAR
from opendbc.car.volkswagen.values import CAR as VOLKSWAGEN_CAR
from openpilot.common.realtime import DT_CTRL
from openpilot.selfdrive.controls.lib.longcontrol import (
LongControl,
@@ -1237,34 +1236,6 @@ def test_santa_fe_final_stop_cap_softens_only_last_kmh():
assert tuning.shape_stopping_accel(-2.0, 0.3, False, 0.2, False, -2.0) == pytest.approx(-2.0)
def test_taos_comfort_stop_cap_softens_non_urgent_moving_lead():
CP = make_longcontrol_cp(
brand="volkswagen",
carFingerprint=VOLKSWAGEN_CAR.VOLKSWAGEN_TAOS_MK1,
)
tuning = vehicle_tunes.LongControlVehicleTuning(CP)
moving_lead = SimpleNamespace(status=True, dRel=7.0, vLead=2.6, yRel=0.0)
output = tuning.shape_stopping_accel(
-1.88, -2.12, True, 3.4, True, -0.55, leads=(moving_lead,)
)
assert output == pytest.approx(-0.94)
def test_taos_comfort_stop_cap_preserves_urgent_lead_braking():
CP = make_longcontrol_cp(
brand="volkswagen",
carFingerprint=VOLKSWAGEN_CAR.VOLKSWAGEN_TAOS_MK1,
)
tuning = vehicle_tunes.LongControlVehicleTuning(CP)
stopped_lead = SimpleNamespace(status=True, dRel=6.0, vLead=0.2, yRel=0.0)
assert tuning.shape_stopping_accel(
-1.88, -2.12, True, 3.4, True, -0.55, leads=(stopped_lead,)
) == pytest.approx(-1.88)
def test_toyota_sienna_target_filter_smooths_mild_high_speed_handoffs():
CP = make_longcontrol_cp(brand="toyota", carFingerprint=TOYOTA_CAR.TOYOTA_SIENNA_4TH_GEN)
tuning = vehicle_tunes.LongControlVehicleTuning(CP)
-10
View File
@@ -99,7 +99,6 @@ def _should_publish_model_output(model_output, vipc_dropped_frames: int, externa
MIN_LAT_CONTROL_SPEED = 0.3
BIG_MODEL_LOAD_WAIT_TIMEOUT_MS = 30000
BIG_MODEL_RUN_WAIT_TIMEOUT_MS = 3000
EXTERNAL_GPU_TEST_POWER_LIMIT_W = 60
EXTERNAL_GPU_POWER_READY_MV = 10000
EXTERNAL_GPU_EGMP_READY_MV = 12500
EXTERNAL_GPU_POWER_STABLE_SECONDS = 3.0
@@ -116,12 +115,6 @@ def _set_hcq_wait_timeout(timeout_ms: int) -> None:
getenv.cache_clear()
def _set_external_gpu_power_limit(watts: int) -> None:
os.environ["AM_POWER_LIMIT"] = str(watts)
from tinygrad.helpers import getenv
getenv.cache_clear()
def _external_gpu_power_voltage(device_type: str, panda_states, peripheral_state) -> int | None:
if device_type == "tici":
voltage = int(peripheral_state.voltage)
@@ -1060,9 +1053,6 @@ def main(demo=False):
external_artifact = MODELS_PATH / f"{big_model_id}_driving_tinygrad.pkl"
external_artifact_ready = external_model_selected and file_chunked_exists(external_artifact)
external_gpu_requested = usbgpu_present_now and (bool(big_model_id) or model_lab_ready)
if external_gpu_requested:
_set_external_gpu_power_limit(EXTERNAL_GPU_TEST_POWER_LIMIT_W)
cloudlog.warning(f"external GPU test power limit set to {EXTERNAL_GPU_TEST_POWER_LIMIT_W} W")
params.put_bool("UsbGpuPresent", usbgpu_present_now)
params.put_bool("UsbGpuCompiled", external_artifact_ready or model_lab_ready)
params.put_bool("UsbGpuActive", False)
@@ -1,5 +1,4 @@
import io
import os
import struct
from types import MethodType
from types import SimpleNamespace
@@ -40,17 +39,6 @@ def test_external_gpu_uses_a_longer_load_watchdog():
assert modeld.BIG_MODEL_RUN_WAIT_TIMEOUT_MS == 3000
def test_external_gpu_test_power_limit(monkeypatch):
from tinygrad.helpers import getenv
monkeypatch.delenv("AM_POWER_LIMIT", raising=False)
getenv.cache_clear()
modeld._set_external_gpu_power_limit(modeld.EXTERNAL_GPU_TEST_POWER_LIMIT_W)
assert os.environ["AM_POWER_LIMIT"] == "60"
assert getenv("AM_POWER_LIMIT", 0.0) == 60.0
def test_external_gpu_voltage_uses_hardware_specific_source():
panda_type = modeld.log.PandaState.PandaType
panda_states = [SimpleNamespace(pandaType=panda_type.dos, voltage=230)]
+8 -12
View File
@@ -35,7 +35,6 @@ from openpilot.system.hardware import HARDWARE
from openpilot.starpilot.common.starpilot_utilities import contains_event_type
from openpilot.starpilot.common.starpilot_variables import get_starpilot_toggles
from openpilot.starpilot.common.lateral_only_experimental import experimental_mode_available
from openpilot.starpilot.common.longitudinal_mode import request_mode_refresh
from openpilot.starpilot.common.vision_bsm import get_fresh_vasm_state
from openpilot.starpilot.system.wheel_controls import (
CONTROLLER_ACTION_COUNTERS,
@@ -846,10 +845,10 @@ class SelfdriveD:
self.starpilot_events.add_from_msg(self.sm['starpilotPlan'].starpilotEvents)
self.experimental_mode = (not self.safe_mode and experimental_mode_available(self.CP) and (
self.sm['starpilotPlan'].experimentalMode if not REPLAY or self.starpilot_toggles.conditional_experimental_mode
or getattr(self.starpilot_toggles, "conditional_chill_mode", False)
else self.experimental_mode or self.sm['starpilotPlan'].experimentalMode))
if self.starpilot_toggles.conditional_experimental_mode or getattr(self.starpilot_toggles, "conditional_chill_mode", False):
self.experimental_mode = self.sm['starpilotPlan'].experimentalMode
else:
self.experimental_mode |= self.sm['starpilotPlan'].experimentalMode
def data_sample(self):
_car_state = messaging.recv_one(self.car_state_sock)
@@ -996,13 +995,10 @@ class SelfdriveD:
self.is_metric = self.params.get_bool("IsMetric")
self.is_ldw_enabled = self.params.get_bool("IsLdwEnabled")
self.disengage_on_accelerator = self.params.get_bool("DisengageOnAccelerator")
if REPLAY:
if self.safe_mode:
self.experimental_mode = False
elif not self.starpilot_toggles.conditional_experimental_mode:
self.experimental_mode = self.params.get_bool("ExperimentalMode") and experimental_mode_available(self.CP)
else:
request_mode_refresh(self.params, self.params_memory, self.starpilot_toggles)
if self.safe_mode:
self.experimental_mode = False
elif not self.starpilot_toggles.conditional_experimental_mode:
self.experimental_mode = self.params.get_bool("ExperimentalMode") and experimental_mode_available(self.CP)
self.personality = log.LongitudinalPersonality.relaxed if self.safe_mode else self.params.get("LongitudinalPersonality", return_default=True)
time.sleep(0.1)
+1 -4
View File
@@ -39,7 +39,6 @@ class TogglesLayout(Widget):
def __init__(self):
super().__init__()
self._params = ui_state.ui_params
self._personality_seen = None
self._sync_rhd_toggle()
# param, title, desc, icon, needs_restart
@@ -157,14 +156,12 @@ class TogglesLayout(Widget):
def _update_state(self):
if ui_state.sm.updated["selfdriveState"]:
personality = PERSONALITY_TO_INT[ui_state.sm["selfdriveState"].personality]
if ui_state.started and personality != self._personality_seen:
if personality != ui_state.personality and ui_state.started:
self._long_personality_setting.action_item.set_selected_button(personality)
self._personality_seen = personality
ui_state.personality = personality
def show_event(self):
self._scroller.show_event()
self._personality_seen = None
self._update_toggles()
def _update_toggles(self):
-20
View File
@@ -1,7 +1,6 @@
from __future__ import annotations
import math
from enum import IntEnum
import pyray as rl
@@ -12,12 +11,6 @@ _BORDER_ROUNDNESS = 0.12
_BORDER_RADIUS_MULTIPLE = 3.0
class LeadInfoMode(IntEnum):
OFF = 0
DISTANCE = 1
SPEED = 2
def get_border_roundness(rect: rl.Rectangle, border_width: float) -> float:
"""Keep a rectangular camera inset inside the rounded frame at thin widths."""
min_dimension = max(1.0, min(rect.width, rect.height))
@@ -50,16 +43,3 @@ def lead_indicator_enabled(params: Params | None = None, *, hide_by_default: boo
if active_params.get("HideLeadMarker") is None:
return not hide_by_default
return not active_params.get_bool("HideLeadMarker")
def lead_info_mode(params: Params | None = None) -> LeadInfoMode:
active_params = params if params is not None else Params()
if not active_params.get_bool("LeadInfo"):
return LeadInfoMode.OFF
try:
mode = LeadInfoMode(active_params.get_int("LeadInfoMode", return_default=True, default=LeadInfoMode.SPEED))
except ValueError:
mode = LeadInfoMode.SPEED
return LeadInfoMode.SPEED if mode == LeadInfoMode.OFF else mode
@@ -12,7 +12,6 @@ PERSONALITY_TO_INT = log.LongitudinalPersonality.schema.enumerants
class TogglesLayoutMici(NavScroller):
def __init__(self):
super().__init__()
self._personality_seen = None
self._sync_rhd_toggle()
def rhd_toggle_callback(checked: bool):
@@ -71,14 +70,12 @@ class TogglesLayoutMici(NavScroller):
if ui_state.sm.updated["selfdriveState"]:
personality = PERSONALITY_TO_INT[ui_state.sm["selfdriveState"].personality]
if ui_state.started and personality != self._personality_seen:
if personality != ui_state.personality and ui_state.started:
self._personality_toggle.set_value(self._personality_toggle._options[personality])
self._personality_seen = personality
ui_state.personality = personality
def show_event(self):
super().show_event()
self._personality_seen = None
self._update_toggles()
def _update_toggles(self):
+2 -39
View File
@@ -1,16 +1,11 @@
from openpilot.common.params import Params
from openpilot.selfdrive.ui.lib.starpilot_visuals import LeadInfoMode, lead_indicator_enabled, lead_info_mode
from openpilot.selfdrive.ui.lib.starpilot_visuals import lead_indicator_enabled
from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigParamControl, BigToggle
from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigMultiOptionDialog
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.widgets.scroller import NavScroller
CAMERA_VIEW_LABELS = ["Auto", "Driver", "Standard", "Wide", "None"]
LEAD_INFO_LABELS = {
LeadInfoMode.OFF: "Off",
LeadInfoMode.DISTANCE: "Distance",
LeadInfoMode.SPEED: "Speed",
}
class CameraViewBigButton(BigButton):
@@ -58,37 +53,6 @@ class LeadIndicatorBigButton(BigToggle):
self.set_checked(lead_indicator_enabled(self.params, hide_by_default=True))
class LeadInfoBigButton(BigButton):
def __init__(self):
super().__init__("lead info", "", gui_app.texture("icons_mici/onroad/eye_fill.png", 64, 64))
self.params = Params()
self.set_click_callback(self._show_selector)
self.refresh()
def refresh(self):
self.set_value(LEAD_INFO_LABELS[lead_info_mode(self.params)].lower())
def _show_selector(self):
current_mode = lead_info_mode(self.params)
options = list(LEAD_INFO_LABELS.values())
dialog_holder: dict[str, BigMultiOptionDialog] = {}
def on_confirm():
selected = dialog_holder["dialog"].get_selected_option()
try:
selected_mode = next(mode for mode, label in LEAD_INFO_LABELS.items() if label == selected)
except StopIteration:
gui_app.push_widget(BigDialog("", "Invalid lead info mode"))
return
self.params.put_int("LeadInfoMode", int(selected_mode))
self.params.put_bool("LeadInfo", selected_mode != LeadInfoMode.OFF)
self.refresh()
dialog = BigMultiOptionDialog(options=options, default=LEAD_INFO_LABELS[current_mode], right_btn_callback=on_confirm)
dialog_holder["dialog"] = dialog
gui_app.push_widget(dialog)
class VisualsLayoutMici(NavScroller):
def __init__(self):
super().__init__()
@@ -99,7 +63,7 @@ class VisualsLayoutMici(NavScroller):
self._torque_bar_btn = BigParamControl("torque bar", "EnableTorqueBarWidget")
self._rainbow_path_btn = BigParamControl("rainbow road", "RainbowPath")
self._lead_indicator_btn = LeadIndicatorBigButton()
self._lead_info_btn = LeadInfoBigButton()
self._lead_info_btn = BigParamControl("show lead speed", "LeadInfo")
self._speed_limit_signs_btn = BigParamControl("show speed limits", "ShowSpeedLimits")
self._slc_confirmation_btn = BigParamControl("confirm new speed limits", "SLCConfirmation")
self._slc_confirmation_lower_btn = BigParamControl("confirm lower limits", "SLCConfirmationLower")
@@ -131,7 +95,6 @@ class VisualsLayoutMici(NavScroller):
def _refresh(self):
self._camera_view_btn.refresh()
self._lead_indicator_btn.refresh()
self._lead_info_btn.refresh()
self._lead_info_btn.set_enabled(lead_indicator_enabled(self._lead_info_btn.params, hide_by_default=True))
confirmation_enabled = self._slc_confirmation_btn.params.get_bool("SLCConfirmation")
self._slc_confirmation_lower_btn.set_visible(confirmation_enabled)
@@ -652,7 +652,6 @@ class AugmentedRoadView(CameraView):
def _sidebar_personality_touch_enabled(self) -> bool:
return (
ui_state.started and
ui_state.has_longitudinal_control and
self._sidebar_widgets_visible() and
not ui_state.ui_params.get_bool("SafeMode")
)
+11 -20
View File
@@ -10,7 +10,7 @@ from openpilot.selfdrive.controls.lib.lane_centering import get_lane_centering_v
from openpilot.selfdrive.locationd.calibrationd import HEIGHT_INIT
from openpilot.selfdrive.ui.lib.starpilot_theme import get_param_color, get_theme_color, get_visual_color, is_stock_color_scheme, with_alpha
from openpilot.selfdrive.ui.onroad.starpilot.rainbow_path import RainbowPath
from openpilot.selfdrive.ui.lib.starpilot_visuals import LeadInfoMode, blend_colors, lead_indicator_enabled, lead_info_mode
from openpilot.selfdrive.ui.lib.starpilot_visuals import blend_colors, lead_indicator_enabled
from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus
from openpilot.selfdrive.ui.mici.onroad.starpilot_status import get_border_color
from openpilot.system.ui.lib.application import gui_app, FontWeight
@@ -67,7 +67,7 @@ class ModelRenderer(Widget):
self._lane_line_probs = np.zeros(4, dtype=np.float32)
self._road_edge_stds = np.zeros(2, dtype=np.float32)
self._lead_vehicles = [LeadVehicle(), LeadVehicle()]
self._lead_info_mode = LeadInfoMode.OFF
self._lead_info_enabled = False
self._path_offset_z = HEIGHT_INIT[0]
# Initialize ModelPoints objects
@@ -138,7 +138,7 @@ class ModelRenderer(Widget):
model = sm['modelV2']
radar_state = sm['radarState'] if sm.valid['radarState'] else None
lead_one = radar_state.leadOne if radar_state else None
self._lead_info_mode = lead_info_mode(self._params)
self._lead_info_enabled = self._params.get_bool("LeadInfo")
render_lead_indicator = self._should_render_lead_indicator(radar_state)
# Update model data when needed
@@ -512,15 +512,8 @@ class ModelRenderer(Widget):
rl.draw_triangle_fan(lead.chevron, len(lead.chevron), with_alpha(lead_color, lead.fill_alpha))
lead_one = radar_state.leadOne
if self._lead_info_mode != LeadInfoMode.OFF and lead_one and lead_one.status:
self._draw_lead_info(lead_one)
@staticmethod
def _format_lead_distance(lead_distance: float, is_metric: bool, use_si_metrics: bool) -> str:
lead_distance = max(float(lead_distance), 0.0)
if is_metric or use_si_metrics:
return f"{round(lead_distance)} m"
return f"{round(lead_distance * CV.METER_TO_FOOT)} ft"
if self._lead_info_enabled and lead_one and lead_one.status:
self._draw_lead_speed(lead_one)
@staticmethod
def _format_lead_speed(lead_speed: float, is_metric: bool, use_si_metrics: bool) -> str:
@@ -531,16 +524,14 @@ class ModelRenderer(Widget):
return f"{round(lead_speed * CV.MS_TO_KPH)} km/h"
return f"{round(lead_speed * CV.MS_TO_MPH)} mph"
def _format_lead_info(self, lead_data, is_metric: bool, use_si_metrics: bool) -> str:
if self._lead_info_mode == LeadInfoMode.DISTANCE:
return self._format_lead_distance(getattr(lead_data, "dRel", 0.0), is_metric, use_si_metrics)
return self._format_lead_speed(getattr(lead_data, "vLead", 0.0), is_metric, use_si_metrics)
def _draw_lead_info(self, lead_data) -> None:
def _draw_lead_speed(self, lead_data) -> None:
from openpilot.selfdrive.ui.onroad.starpilot.path import _draw_text_with_outline
use_si_metrics = ui_state.starpilot_toggles.get("UseSiMetrics", False)
text = self._format_lead_info(lead_data, ui_state.is_metric, use_si_metrics)
text = self._format_lead_speed(
getattr(lead_data, "vLead", 0.0),
ui_state.is_metric,
ui_state.starpilot_toggles.get("UseSiMetrics", False),
)
font = gui_app.font(FontWeight.SEMI_BOLD)
font_size = 40
text_size = measure_text_cached(font, text, font_size)
+6 -53
View File
@@ -6,10 +6,9 @@ import openpilot.selfdrive.ui.mici.onroad.model_renderer as model_renderer
class _FakeParams:
def __init__(self, enabled: bool, lead_info: bool = False, lead_info_mode: int = model_renderer.LeadInfoMode.SPEED):
def __init__(self, enabled: bool, lead_info: bool = False):
self.enabled = enabled
self.lead_info = lead_info
self.lead_info_mode = lead_info_mode
def get(self, key):
assert key == "HideLeadMarker"
@@ -22,10 +21,6 @@ class _FakeParams:
return self.lead_info
raise AssertionError(key)
def get_int(self, key, **_kwargs):
assert key == "LeadInfoMode"
return self.lead_info_mode
def test_lead_indicator_renders_in_aol_without_longitudinal_control(monkeypatch):
monkeypatch.setattr(model_renderer, "ui_state", SimpleNamespace(always_on_lateral_active=True))
@@ -52,59 +47,17 @@ def test_lead_indicator_still_honors_disabled_setting():
(False, True, "10 m/s"),
],
)
def test_lead_speed_uses_selected_units(is_metric, use_si_metrics, expected):
def test_lead_speed_uses_c3_units(is_metric, use_si_metrics, expected):
assert model_renderer.ModelRenderer._format_lead_speed(10.0, is_metric, use_si_metrics) == expected
@pytest.mark.parametrize(
("is_metric", "use_si_metrics", "expected"),
[
(False, False, "33 ft"),
(True, False, "10 m"),
(False, True, "10 m"),
],
)
def test_lead_distance_uses_selected_units(is_metric, use_si_metrics, expected):
assert model_renderer.ModelRenderer._format_lead_distance(10.0, is_metric, use_si_metrics) == expected
@pytest.mark.parametrize(
("lead_info", "configured_mode", "expected"),
[
(False, model_renderer.LeadInfoMode.SPEED, model_renderer.LeadInfoMode.OFF),
(True, model_renderer.LeadInfoMode.DISTANCE, model_renderer.LeadInfoMode.DISTANCE),
(True, model_renderer.LeadInfoMode.SPEED, model_renderer.LeadInfoMode.SPEED),
(True, model_renderer.LeadInfoMode.OFF, model_renderer.LeadInfoMode.SPEED),
(True, 99, model_renderer.LeadInfoMode.SPEED),
],
)
def test_lead_info_mode_preserves_legacy_speed_behavior(lead_info, configured_mode, expected):
params = _FakeParams(enabled=True, lead_info=lead_info, lead_info_mode=configured_mode)
assert model_renderer.lead_info_mode(params) == expected
@pytest.mark.parametrize(
("mode", "expected"),
[
(model_renderer.LeadInfoMode.DISTANCE, "82 ft"),
(model_renderer.LeadInfoMode.SPEED, "27 mph"),
],
)
def test_lead_info_formats_selected_metric(mode, expected):
renderer = object.__new__(model_renderer.ModelRenderer)
renderer._lead_info_mode = mode
lead = SimpleNamespace(dRel=25.0, vLead=12.0)
assert renderer._format_lead_info(lead, is_metric=False, use_si_metrics=False) == expected
def test_lead_metrics_draw_selected_value_when_enabled(monkeypatch):
def test_lead_metrics_draw_only_speed_when_enabled(monkeypatch):
drawn_metrics = []
monkeypatch.setattr(model_renderer, "get_theme_color", lambda *_args: model_renderer.rl.RED)
monkeypatch.setattr(model_renderer.rl, "draw_triangle_fan", lambda *_args: None)
renderer = object.__new__(model_renderer.ModelRenderer)
renderer._lead_info_mode = model_renderer.LeadInfoMode.DISTANCE
renderer._lead_info_enabled = True
renderer._lead_vehicles = [
model_renderer.LeadVehicle(
glow=[(1.0, 2.0)] * 3,
@@ -113,8 +66,8 @@ def test_lead_metrics_draw_selected_value_when_enabled(monkeypatch):
),
model_renderer.LeadVehicle(),
]
renderer._draw_lead_info = drawn_metrics.append
lead_one = SimpleNamespace(status=True, dRel=25.0, vLead=10.0)
renderer._draw_lead_speed = drawn_metrics.append
lead_one = SimpleNamespace(status=True, vLead=10.0)
renderer._draw_lead_indicator(SimpleNamespace(leadOne=lead_one, leadTwo=SimpleNamespace(status=False)))
@@ -1,212 +0,0 @@
"""Host regressions for actual native methods; graphics/Params are synthetic.
Run without root native conftest: pytest -c /dev/null --confcutdir=selfdrive/ui/tests
These tests do not simulate vehicle dynamics or prove on-device touch delivery.
"""
import ast
from pathlib import Path
from types import SimpleNamespace
import pytest
ROOT = Path(__file__).resolve().parents[3]
OPTIONS = ["aggressive", "standard", "relaxed"]
def methods(path, class_name, names, env):
tree = ast.parse((ROOT / path).read_text())
cls = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == class_name)
cls.body = [n for n in cls.body if isinstance(n, ast.FunctionDef) and n.name in names]
cls.bases = [ast.Name(id="Base", ctx=ast.Load())]
module = ast.fix_missing_locations(ast.Module(body=[cls], type_ignores=[]))
namespace = {"Base": Base, **env}
exec(compile(module, str(ROOT / path), "exec"), namespace)
return namespace[class_name]
class Base:
def _update_state(self):
pass
def _handle_mouse_release(self, _):
pass
class Params:
def __init__(self, **values):
self.values, self.writes = values, []
def get_bool(self, key):
return bool(self.values.get(key, False))
def get_int(self, key, **kwargs):
return self.values.get(key, kwargs.get("default", 0))
get = get_int
def remove(self, key):
self.values.pop(key, None)
def put_int(self, key, value):
self.values[key] = value
self.writes.append((key, value))
put = put_bool = put_nonblocking = put_int
class Choice:
def __init__(self):
self.value, self.selected_button = "standard", 1
self._options = OPTIONS
self.action_item = self
def set_value(self, value):
self.value = value
def set_selected_button(self, value):
self.selected_button = value
def set_enabled(self, value):
self.enabled = value
def set_visible(self, value):
self.visible = value
def set_state(self, value):
self.state = value
set_checked = set_state
def set_description(self, _):
pass
@pytest.mark.parametrize("mici", [False, True], ids=["comma3", "comma4"])
@pytest.mark.parametrize("selected", [0, 2])
def test_onroad_readback_repairs_stale_widget_even_when_shared_cache_matches(mici, selected):
sm = {"selfdriveState": SimpleNamespace(personality=OPTIONS[selected])}
class SubMaster(dict):
updated = {"selfdriveState": True}
state = SimpleNamespace(sm=SubMaster(sm), personality=selected, started=True)
path = "selfdrive/ui/" + ("mici/" if mici else "") + "layouts/settings/toggles.py"
cls = methods(path, "TogglesLayoutMici" if mici else "TogglesLayout", {"_update_state"},
{"ui_state": state, "PERSONALITY_TO_INT": dict(zip(OPTIONS, range(3), strict=True))})
layout = cls()
layout._personality_seen = None
choice = Choice()
layout._personality_toggle = layout._long_personality_setting = choice
layout._longitudinal_mode = SimpleNamespace(update=lambda: None, label="Chill")
layout._experimental_btn = Choice()
layout._sync_mode_selection = lambda: None
layout._update_state()
assert (choice.value if mici else choice.selected_button) == (OPTIONS[selected] if mici else selected)
# UI feedback for a queued write must survive repeated pre-write messages.
next_selected = (selected + 1) % 3
choice.set_value(OPTIONS[next_selected])
choice.set_selected_button(next_selected)
layout._update_state()
assert (choice.value if mici else choice.selected_button) == (OPTIONS[next_selected] if mici else next_selected)
# A new selfdrived result still wins, including a safety-enforced reversion.
state.sm["selfdriveState"].personality = OPTIONS[(selected + 2) % 3]
layout._update_state()
assert (choice.value if mici else choice.selected_button) == (OPTIONS[(selected + 2) % 3] if mici else (selected + 2) % 3)
@pytest.mark.parametrize("selected", range(3))
def test_comma4_settings_cycles_existing_selection_without_profile_writes(selected):
# Execute the real BigMultiToggle and BigMultiParamToggle release chain.
multi = methods("selfdrive/ui/mici/widgets/button.py", "BigMultiToggle", {"_handle_mouse_release"}, {"MousePos": object})
param = methods("selfdrive/ui/mici/widgets/button.py", "BigMultiParamToggle", {"_handle_mouse_release"},
{"Base": multi, "MousePos": object})
button = param()
button.value, button._options, button._select_callback = OPTIONS[selected], OPTIONS, None
button.set_value = lambda value: setattr(button, "value", value)
button._param, button._params = "LongitudinalPersonality", Params(IsOnroad=True, IsOffroad=False)
button._handle_mouse_release(None)
assert button._params.writes == [("LongitudinalPersonality", (selected + 1) % 3)]
@pytest.mark.parametrize("selected", range(3))
def test_comma3_settings_selects_existing_personality_onroad(selected):
cls = methods("selfdrive/ui/layouts/settings/toggles.py", "TogglesLayout", {"_set_longitudinal_personality"}, {})
layout = cls()
layout._personality_seen = None
layout._params = Params(IsOnroad=True, IsOffroad=False)
layout._set_longitudinal_personality(selected)
assert layout._params.writes == [("LongitudinalPersonality", selected)]
@pytest.mark.parametrize("started,capable,safe,visible,allowed", [
(True, True, False, True, True),
(False, True, False, True, False),
(True, False, False, True, False),
(True, True, True, True, False),
(True, True, False, False, False),
])
def test_comma4_sidebar_requires_longitudinal_control(started, capable, safe, visible, allowed):
params = Params(SafeMode=safe, LongitudinalPersonality=1)
state = SimpleNamespace(started=started, has_longitudinal_control=capable, ui_params=params, personality=1)
enum = SimpleNamespace(aggressive=0, standard=1, relaxed=2)
cls = methods("selfdrive/ui/mici/onroad/augmented_road_view.py", "AugmentedRoadView",
{"_sidebar_personality_touch_enabled", "_cycle_personality_profile", "_handle_mouse_release"},
{"ui_state": state, "log": SimpleNamespace(LongitudinalPersonality=enum), "MousePos": object})
view = cls()
view._sidebar_widgets_visible = lambda: visible
view._touch_in_sidebar = lambda _: True
view._sidebar_personality_pressed = True
assert view._sidebar_personality_touch_enabled() is allowed
view._handle_mouse_release(None)
assert params.writes == ([("LongitudinalPersonality", 2)] if allowed else [])
@pytest.mark.parametrize("mici", [False, True], ids=["comma3", "comma4"])
@pytest.mark.parametrize("safe,capable", [(False, True), (True, True), (False, False), (True, False)])
def test_settings_enable_selection_onroad_but_preserve_safety_gates(mici, safe, capable):
params = Params(SafeMode=safe, LongitudinalPersonality=1, IsOnroad=True, IsOffroad=False)
state = SimpleNamespace(params=params, update_params=lambda: None, engaged=True,
CP=SimpleNamespace(alphaLongitudinalAvailable=False),
has_longitudinal_control=capable, experimental_mode_available=capable)
path = "selfdrive/ui/" + ("mici/" if mici else "") + "layouts/settings/toggles.py"
cls = methods(path, "TogglesLayoutMici" if mici else "TogglesLayout", {"_update_toggles"},
{"ui_state": state, "tr": lambda s: s,
"log": SimpleNamespace(LongitudinalPersonality=SimpleNamespace(relaxed=2))})
layout = cls()
layout._personality_seen = None
layout._params = params
choice = Choice()
layout._personality_toggle = layout._long_personality_setting = choice
layout._experimental_btn = Choice()
layout._toggles = {"ExperimentalMode": Choice()}
layout._toggle_defs = {}
layout._refresh_toggles = []
layout._sync_rhd_toggle = layout._update_experimental_mode_icon = lambda: None
layout._update_toggles()
assert choice.enabled is (capable and not safe)
assert all(key in {"ExperimentalMode", "LongitudinalPersonality"} for key, _ in params.writes)
def test_comma4_stale_standard_tile_does_not_reselect_active_relaxed():
# Sidebar has selected Relaxed and updated the shared cache. The settings
# widget still says Standard. Before the fix its next tap wrote Relaxed again.
class SubMaster(dict):
updated = {"selfdriveState": True}
state = SimpleNamespace(sm=SubMaster(selfdriveState=SimpleNamespace(personality="relaxed")),
personality=2, started=True)
cls = methods("selfdrive/ui/mici/layouts/settings/toggles.py", "TogglesLayoutMici", {"_update_state"},
{"ui_state": state, "PERSONALITY_TO_INT": dict(zip(OPTIONS, range(3), strict=True))})
multi = methods("selfdrive/ui/mici/widgets/button.py", "BigMultiToggle", {"_handle_mouse_release"}, {"MousePos": object})
param = methods("selfdrive/ui/mici/widgets/button.py", "BigMultiParamToggle", {"_handle_mouse_release"},
{"Base": multi, "MousePos": object})
button = param()
button.value, button._options, button._select_callback = "standard", OPTIONS, None
button.set_value = lambda value: setattr(button, "value", value)
button._param, button._params = "LongitudinalPersonality", Params(LongitudinalPersonality=2, IsOnroad=True, IsOffroad=False)
layout = cls()
layout._personality_seen = None
layout._personality_toggle = button
layout._longitudinal_mode = SimpleNamespace(update=lambda: None, label="Chill")
layout._experimental_btn = Choice()
layout._update_state()
assert not button._params.writes
button._handle_mouse_release(None)
assert button._params.writes == [("LongitudinalPersonality", 0)]
@@ -1203,9 +1203,8 @@
},
{
"key": "CustomPersonalities",
"requires_offroad": true,
"label": "Driving Personalities",
"description": "Customize braking, acceleration, and following distance for each profile.",
"description": "Customize the \"Driving Personalities\" to better match your driving style.",
"picker_description": "Customizes driving personalities to match your style.",
"data_type": "bool",
"ui_type": "toggle",
@@ -1214,7 +1213,6 @@
},
{
"key": "TrafficPersonalityProfile",
"requires_offroad": true,
"label": "Traffic Mode",
"description": "Customize the \"Traffic Mode\" personality profile. Designed for stop-and-go driving.",
"picker_description": "Customizes Traffic Mode for stop-and-go driving.",
@@ -1226,7 +1224,6 @@
},
{
"key": "AggressivePersonalityProfile",
"requires_offroad": true,
"label": "Aggressive",
"description": "Customize the \"Aggressive\" personality profile. Designed for assertive driving with tighter gaps.",
"picker_description": "Customizes Aggressive Mode for assertive driving.",
@@ -1238,7 +1235,6 @@
},
{
"key": "StandardPersonalityProfile",
"requires_offroad": true,
"label": "Standard",
"description": "Customize the \"Standard\" personality profile. Designed for balanced driving with moderate gaps.",
"picker_description": "Customizes Standard Mode for balanced driving.",
@@ -1250,7 +1246,6 @@
},
{
"key": "RelaxedPersonalityProfile",
"requires_offroad": true,
"label": "Relaxed",
"description": "Customize the \"Relaxed\" personality profile. Designed for smoother, more comfortable driving with larger gaps.",
"picker_description": "Customizes Relaxed Mode for smoother driving.",
@@ -1262,9 +1257,8 @@
},
{
"key": "TrafficFollow",
"requires_offroad": true,
"label": "Following Distance",
"description": "The minimum following distance to the lead vehicle. openpilot blends between this value and the \"Relaxed\" profile as speed increases. Increase for more space; decrease for tighter gaps.",
"description": "The minimum following distance to the lead vehicle in \"Traffic Mode\". openpilot blends between this value and the \"Relaxed\" profile as speed increases. Increase for more space; decrease for tighter gaps.",
"data_type": "float",
"ui_type": "numeric",
"min": 0.5,
@@ -1275,72 +1269,66 @@
},
{
"key": "TrafficJerkAcceleration",
"requires_offroad": true,
"label": "Acceleration Smoothness",
"description": "How smoothly openpilot accelerates. Increase for gentler starts; decrease for faster but more abrupt takeoffs.",
"description": "How smoothly openpilot accelerates in \"Traffic Mode\". Increase for gentler starts; decrease for faster but more abrupt takeoffs.",
"data_type": "float",
"ui_type": "numeric",
"min": 25,
"max": 200,
"step": 1,
"min": 0.5,
"max": 3.0,
"step": 0.01,
"parent_key": "TrafficPersonalityProfile",
"settings_tier": "advanced"
},
{
"key": "TrafficJerkDeceleration",
"requires_offroad": true,
"label": "Braking Smoothness",
"description": "How smoothly openpilot brakes. Increase for gentler stops; decrease for quicker but sharper braking.",
"description": "How smoothly openpilot brakes in \"Traffic Mode\". Increase for gentler stops; decrease for quicker but sharper braking.",
"data_type": "float",
"ui_type": "numeric",
"min": 25,
"max": 200,
"step": 1,
"min": 0.5,
"max": 3.0,
"step": 0.01,
"parent_key": "TrafficPersonalityProfile",
"settings_tier": "advanced"
},
{
"key": "TrafficJerkDanger",
"requires_offroad": true,
"label": "Safety Gap Bias",
"description": "How much extra space openpilot keeps from the vehicle ahead. Increase for larger gaps and more cautious following; decrease for tighter gaps and closer following.",
"description": "How much extra space openpilot keeps from the vehicle ahead in \"Traffic Mode\". Increase for larger gaps and more cautious following; decrease for tighter gaps and closer following.",
"data_type": "float",
"ui_type": "numeric",
"min": 25,
"max": 200,
"step": 1,
"min": 0.5,
"max": 3.0,
"step": 0.01,
"parent_key": "TrafficPersonalityProfile",
"settings_tier": "advanced"
},
{
"key": "TrafficJerkSpeedDecrease",
"requires_offroad": true,
"label": "Slowdown Response",
"description": "How smoothly openpilot slows down. Increase for more gradual deceleration; decrease for faster but sharper slowdowns.",
"description": "How smoothly openpilot slows down in \"Traffic Mode\". Increase for more gradual deceleration; decrease for faster but sharper slowdowns.",
"data_type": "float",
"ui_type": "numeric",
"min": 25,
"max": 200,
"step": 1,
"min": 0.5,
"max": 3.0,
"step": 0.01,
"parent_key": "TrafficPersonalityProfile",
"settings_tier": "advanced"
},
{
"key": "TrafficJerkSpeed",
"requires_offroad": true,
"label": "Speed-Up Response",
"description": "How smoothly openpilot speeds up. Increase for more gradual acceleration; decrease for quicker but more jolting acceleration.",
"description": "How smoothly openpilot speeds up in \"Traffic Mode\". Increase for more gradual acceleration; decrease for quicker but more jolting acceleration.",
"data_type": "float",
"ui_type": "numeric",
"min": 25,
"max": 200,
"step": 1,
"min": 0.5,
"max": 3.0,
"step": 0.01,
"parent_key": "TrafficPersonalityProfile",
"settings_tier": "advanced"
},
{
"key": "AggressiveFollow",
"requires_offroad": true,
"label": "Following Distance",
"description": "How many seconds openpilot follows behind lead vehicles when using the \"Aggressive\" profile. Increase for more space; decrease for tighter gaps.\n\nDefault: 1.25 seconds.",
"data_type": "float",
@@ -1353,7 +1341,6 @@
},
{
"key": "AggressiveFollowHigh",
"requires_offroad": true,
"label": "High Speed Following Distance",
"description": "Following distance for higher speeds in the \"Aggressive\" profile. openpilot smoothly blends from the base value to this value as speed rises.",
"data_type": "float",
@@ -1366,72 +1353,66 @@
},
{
"key": "AggressiveJerkAcceleration",
"requires_offroad": true,
"label": "Acceleration Smoothness",
"description": "How smoothly openpilot accelerates. Increase for gentler starts; decrease for faster but more abrupt takeoffs.",
"description": "How smoothly openpilot accelerates with the \"Aggressive\" profile. Increase for gentler starts; decrease for faster but more abrupt takeoffs.",
"data_type": "float",
"ui_type": "numeric",
"min": 25,
"max": 200,
"step": 1,
"min": 0.5,
"max": 3.0,
"step": 0.01,
"parent_key": "AggressivePersonalityProfile",
"settings_tier": "advanced"
},
{
"key": "AggressiveJerkDeceleration",
"requires_offroad": true,
"label": "Braking Smoothness",
"description": "How smoothly openpilot brakes. Increase for gentler stops; decrease for quicker but sharper braking.",
"description": "How smoothly openpilot brakes with the \"Aggressive\" profile. Increase for gentler stops; decrease for quicker but sharper braking.",
"data_type": "float",
"ui_type": "numeric",
"min": 25,
"max": 200,
"step": 1,
"min": 0.5,
"max": 3.0,
"step": 0.01,
"parent_key": "AggressivePersonalityProfile",
"settings_tier": "advanced"
},
{
"key": "AggressiveJerkDanger",
"requires_offroad": true,
"label": "Safety Gap Bias",
"description": "How much extra space openpilot keeps from the vehicle ahead. Increase for larger gaps and more cautious following; decrease for tighter gaps and closer following.",
"description": "How much extra space openpilot keeps from the vehicle ahead with the \"Aggressive\" profile. Increase for larger gaps and more cautious following; decrease for tighter gaps and closer following.",
"data_type": "float",
"ui_type": "numeric",
"min": 25,
"max": 200,
"step": 1,
"min": 0.5,
"max": 3.0,
"step": 0.01,
"parent_key": "AggressivePersonalityProfile",
"settings_tier": "advanced"
},
{
"key": "AggressiveJerkSpeedDecrease",
"requires_offroad": true,
"label": "Slowdown Response",
"description": "How smoothly openpilot slows down. Increase for more gradual deceleration; decrease for faster but sharper slowdowns.",
"description": "How smoothly openpilot slows down with the \"Aggressive\" profile. Increase for more gradual deceleration; decrease for faster but sharper slowdowns.",
"data_type": "float",
"ui_type": "numeric",
"min": 25,
"max": 200,
"step": 1,
"min": 0.5,
"max": 3.0,
"step": 0.01,
"parent_key": "AggressivePersonalityProfile",
"settings_tier": "advanced"
},
{
"key": "AggressiveJerkSpeed",
"requires_offroad": true,
"label": "Speed-Up Response",
"description": "How smoothly openpilot speeds up. Increase for more gradual acceleration; decrease for quicker but more jolting acceleration.",
"description": "How smoothly openpilot speeds up with the \"Aggressive\" profile. Increase for more gradual acceleration; decrease for quicker but more jolting acceleration.",
"data_type": "float",
"ui_type": "numeric",
"min": 25,
"max": 200,
"step": 1,
"min": 0.5,
"max": 3.0,
"step": 0.01,
"parent_key": "AggressivePersonalityProfile",
"settings_tier": "advanced"
},
{
"key": "StandardFollow",
"requires_offroad": true,
"label": "Following Distance",
"description": "How many seconds openpilot follows behind lead vehicles when using the \"Standard\" profile. Increase for more space; decrease for tighter gaps.\n\nDefault: 1.45 seconds.",
"data_type": "float",
@@ -1444,7 +1425,6 @@
},
{
"key": "StandardFollowHigh",
"requires_offroad": true,
"label": "High Speed Following Distance",
"description": "Following distance for higher speeds in the \"Standard\" profile. openpilot smoothly blends from the base value to this value as speed rises.",
"data_type": "float",
@@ -1457,72 +1437,66 @@
},
{
"key": "StandardJerkAcceleration",
"requires_offroad": true,
"label": "Acceleration Smoothness",
"description": "How smoothly openpilot accelerates. Increase for gentler starts; decrease for faster but more abrupt takeoffs.",
"description": "How smoothly openpilot accelerates with the \"Standard\" profile. Increase for gentler starts; decrease for faster but more abrupt takeoffs.",
"data_type": "float",
"ui_type": "numeric",
"min": 25,
"max": 200,
"step": 1,
"min": 0.5,
"max": 3.0,
"step": 0.01,
"parent_key": "StandardPersonalityProfile",
"settings_tier": "advanced"
},
{
"key": "StandardJerkDeceleration",
"requires_offroad": true,
"label": "Braking Smoothness",
"description": "How smoothly openpilot brakes. Increase for gentler stops; decrease for quicker but sharper braking.",
"description": "How smoothly openpilot brakes with the \"Standard\" profile. Increase for gentler stops; decrease for quicker but sharper braking.",
"data_type": "float",
"ui_type": "numeric",
"min": 25,
"max": 200,
"step": 1,
"min": 0.5,
"max": 3.0,
"step": 0.01,
"parent_key": "StandardPersonalityProfile",
"settings_tier": "advanced"
},
{
"key": "StandardJerkDanger",
"requires_offroad": true,
"label": "Safety Gap Bias",
"description": "How much extra space openpilot keeps from the vehicle ahead. Increase for larger gaps and more cautious following; decrease for tighter gaps and closer following.",
"description": "How much extra space openpilot keeps from the vehicle ahead with the \"Standard\" profile. Increase for larger gaps and more cautious following; decrease for tighter gaps and closer following.",
"data_type": "float",
"ui_type": "numeric",
"min": 25,
"max": 200,
"step": 1,
"min": 0.5,
"max": 3.0,
"step": 0.01,
"parent_key": "StandardPersonalityProfile",
"settings_tier": "advanced"
},
{
"key": "StandardJerkSpeedDecrease",
"requires_offroad": true,
"label": "Slowdown Response",
"description": "How smoothly openpilot slows down. Increase for more gradual deceleration; decrease for faster but sharper slowdowns.",
"description": "How smoothly openpilot slows down with the \"Standard\" profile. Increase for more gradual deceleration; decrease for faster but sharper slowdowns.",
"data_type": "float",
"ui_type": "numeric",
"min": 25,
"max": 200,
"step": 1,
"min": 0.5,
"max": 3.0,
"step": 0.01,
"parent_key": "StandardPersonalityProfile",
"settings_tier": "advanced"
},
{
"key": "StandardJerkSpeed",
"requires_offroad": true,
"label": "Speed-Up Response",
"description": "How smoothly openpilot speeds up. Increase for more gradual acceleration; decrease for quicker but more jolting acceleration.",
"description": "How smoothly openpilot speeds up with the \"Standard\" profile. Increase for more gradual acceleration; decrease for quicker but more jolting acceleration.",
"data_type": "float",
"ui_type": "numeric",
"min": 25,
"max": 200,
"step": 1,
"min": 0.5,
"max": 3.0,
"step": 0.01,
"parent_key": "StandardPersonalityProfile",
"settings_tier": "advanced"
},
{
"key": "RelaxedFollow",
"requires_offroad": true,
"label": "Following Distance",
"description": "How many seconds openpilot follows behind lead vehicles when using the \"Relaxed\" profile. Increase for more space; decrease for tighter gaps.\n\nDefault: 1.6 seconds.",
"data_type": "float",
@@ -1535,7 +1509,6 @@
},
{
"key": "RelaxedFollowHigh",
"requires_offroad": true,
"label": "High Speed Following Distance",
"description": "Following distance for higher speeds in the \"Relaxed\" profile. openpilot smoothly blends from the base value to this value as speed rises.",
"data_type": "float",
@@ -1548,66 +1521,61 @@
},
{
"key": "RelaxedJerkAcceleration",
"requires_offroad": true,
"label": "Acceleration Smoothness",
"description": "How smoothly openpilot accelerates. Increase for gentler starts; decrease for faster but more abrupt takeoffs.",
"description": "How smoothly openpilot accelerates with the \"Relaxed\" profile. Increase for gentler starts; decrease for faster but more abrupt takeoffs.",
"data_type": "float",
"ui_type": "numeric",
"min": 25,
"max": 200,
"step": 1,
"min": 0.5,
"max": 3.0,
"step": 0.01,
"parent_key": "RelaxedPersonalityProfile",
"settings_tier": "advanced"
},
{
"key": "RelaxedJerkDeceleration",
"requires_offroad": true,
"label": "Braking Smoothness",
"description": "How smoothly openpilot brakes. Increase for gentler stops; decrease for quicker but sharper braking.",
"description": "How smoothly openpilot brakes with the \"Relaxed\" profile. Increase for gentler stops; decrease for quicker but sharper braking.",
"data_type": "float",
"ui_type": "numeric",
"min": 25,
"max": 200,
"step": 1,
"min": 0.5,
"max": 3.0,
"step": 0.01,
"parent_key": "RelaxedPersonalityProfile",
"settings_tier": "advanced"
},
{
"key": "RelaxedJerkDanger",
"requires_offroad": true,
"label": "Safety Gap Bias",
"description": "How much extra space openpilot keeps from the vehicle ahead. Increase for larger gaps and more cautious following; decrease for tighter gaps and closer following.",
"description": "How much extra space openpilot keeps from the vehicle ahead with the \"Relaxed\" profile. Increase for larger gaps and more cautious following; decrease for tighter gaps and closer following.",
"data_type": "float",
"ui_type": "numeric",
"min": 25,
"max": 200,
"step": 1,
"min": 0.5,
"max": 3.0,
"step": 0.01,
"parent_key": "RelaxedPersonalityProfile",
"settings_tier": "advanced"
},
{
"key": "RelaxedJerkSpeedDecrease",
"requires_offroad": true,
"label": "Slowdown Response",
"description": "How smoothly openpilot slows down. Increase for more gradual deceleration; decrease for faster but sharper slowdowns.",
"description": "How smoothly openpilot slows down with the \"Relaxed\" profile. Increase for more gradual deceleration; decrease for faster but sharper slowdowns.",
"data_type": "float",
"ui_type": "numeric",
"min": 25,
"max": 200,
"step": 1,
"min": 0.5,
"max": 3.0,
"step": 0.01,
"parent_key": "RelaxedPersonalityProfile",
"settings_tier": "advanced"
},
{
"key": "RelaxedJerkSpeed",
"requires_offroad": true,
"label": "Speed-Up Response",
"description": "How smoothly openpilot speeds up. Increase for more gradual acceleration; decrease for quicker but more jolting acceleration.",
"description": "How smoothly openpilot speeds up with the \"Relaxed\" profile. Increase for more gradual acceleration; decrease for quicker but more jolting acceleration.",
"data_type": "float",
"ui_type": "numeric",
"min": 25,
"max": 200,
"step": 1,
"min": 0.5,
"max": 3.0,
"step": 0.01,
"parent_key": "RelaxedPersonalityProfile",
"settings_tier": "advanced"
},
+2 -8
View File
@@ -8,10 +8,6 @@ from pathlib import Path
from typing import Any
from openpilot.common.params import ParamKeyType, Params
from openpilot.starpilot.common.longitudinal_personality_profiles import (
PERSONALITY_PARKED_PARAM_KEYS,
PERSONALITY_PROFILES_PARAM,
)
FAVORITE_SLOTS_PARAM = "StarPilotFavoriteSlots"
@@ -49,7 +45,6 @@ FAVORITE_ACTION_OPTIONS = (
FAVORITE_ACTION_KEYS = {option["key"] for option in FAVORITE_ACTION_OPTIONS}
FAVORITE_ACTION_LABELS = {option["key"]: option["label"] for option in FAVORITE_ACTION_OPTIONS}
SETTINGS_CATALOG_PATH = Path(__file__).resolve().parent / "assets" / "device_settings_layout.json"
PERSONALITY_FAVORITE_BLOCKED_KEYS = PERSONALITY_PARKED_PARAM_KEYS | {PERSONALITY_PROFILES_PARAM}
BLOCKED_ONROAD_KEYS = {
@@ -139,7 +134,7 @@ def build_favorite_slot_options(is_eligible_param: Callable[[str], bool], *,
options = [dict(option) for option in FAVORITE_ACTION_OPTIONS]
for key, param_data in catalog_map.items():
if param_data.get("galaxy_only") or key in PERSONALITY_FAVORITE_BLOCKED_KEYS:
if param_data.get("galaxy_only"):
continue
ui_type = str(param_data.get("ui_type") or "")
@@ -398,7 +393,7 @@ def is_favorite_action_key(key: str | None) -> bool:
def favorite_key_is_valid(params: Params, key: str | None, eligible_keys: Iterable[str] | None = None) -> bool:
if not key or key in PERSONALITY_FAVORITE_BLOCKED_KEYS:
if not key:
return False
if is_favorite_action_key(key):
@@ -436,7 +431,6 @@ def normalize_favorite_slots(raw_slots: Any, params: Params | None = None,
if key and is_favorite_action_key(key):
pass
elif key and (
key in PERSONALITY_FAVORITE_BLOCKED_KEYS or
(eligible is not None and key not in eligible) or
(params is not None and not favorite_key_is_valid(params, key, eligible_keys=eligible))
):
-39
View File
@@ -1,39 +0,0 @@
"""Coherent mode Params reads for participating Python writers/readers.
The sidecar lock is outside the Params key directory (no registry addition).
Never unlink/replace it while processes are running. All locks are nonblocking:
background refreshers retry and keep the last complete toggle object on failure.
Legacy Dom writers are deliberately not excluded; their settled values remain
visible, but advisory locking cannot make their multi-key writes atomic.
"""
from contextlib import contextmanager
import fcntl
import os
from pathlib import Path
MODE_KEYS = ("ExperimentalMode", "ConditionalChill", "ConditionalExperimental")
@contextmanager
def mode_lock(params, *, exclusive=False):
directory = Path(params.get_param_path()).parent
fd = os.open(directory / ".longitudinal_mode.lock", os.O_CREAT | os.O_RDWR | os.O_CLOEXEC, 0o660)
try:
fcntl.flock(fd, (fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH) | fcntl.LOCK_NB)
yield
finally:
os.close(fd)
def read_mode_values(params):
with mode_lock(params):
return {key: params.get_bool(key) for key in MODE_KEYS}
def request_mode_refresh(params, params_memory, toggles):
try:
values = read_mode_values(params)
except OSError:
return
if values != getattr(toggles, "longitudinal_mode_values", None):
params_memory.put_bool("StarPilotTogglesUpdated", True)
@@ -1,573 +0,0 @@
#!/usr/bin/env python3
"""Versioned, fail-closed longitudinal acceleration/braking profiles."""
from __future__ import annotations
from copy import deepcopy
import json
import math
import numbers
PERSONALITY_PROFILES_PARAM = "LongitudinalPersonalityProfiles"
PROFILE_SCHEMA_VERSION = 2
PERSONALITY_IDS = ("traffic", "aggressive", "standard", "relaxed")
TRUCK_FINGERPRINT_TOKENS = (
" RAM 1500 ",
" RAM HD ",
" F 150 ",
" MAVERICK ",
" RANGER ",
" SILVERADO ",
" RIDGELINE ",
" SANTA CRUZ ",
)
ACCELERATION_SPEEDS_MPH = tuple(range(0, 91, 10))
BRAKING_SPEEDS_MPH = ACCELERATION_SPEEDS_MPH
FOLLOWING_SPEEDS_MPH = ACCELERATION_SPEEDS_MPH
_NATIVE_ACCELERATION_SPEEDS_MS = (0.0, 5.0, 10.0, 15.0, 20.0, 25.0, 40.0)
_V1_ACCELERATION_SPEEDS_MPH = (0.0, 11.184681, 22.369363, 33.554044, 44.738726, 55.923407, 89.477452)
def is_truck_fingerprint(fingerprint: object) -> bool:
if not isinstance(fingerprint, str) or not fingerprint.strip():
return False
normalized = f" {fingerprint.strip().upper().replace('_', ' ').replace('-', ' ')} "
return any(token in normalized for token in TRUCK_FINGERPRINT_TOKENS)
ACCELERATION_PRESETS = ("dom_default", "standard", "eco", "sport", "sport_plus", "custom")
BRAKING_PRESETS = ("dom_default", "standard", "eco", "sport", "custom")
FOLLOWING_PRESETS = ("dom_default", "close", "medium", "far", "custom")
CURVE_BOUNDS = {
"acceleration": (0.0, 3.5),
"braking": (0.5, 2.0),
"following": (0.75, 3.0),
}
_V2_CURVE_BOUNDS = {
"acceleration": (0.0, 6.0),
"braking": (0.5, 2.0),
"following": (0.75, 3.0),
}
_V1_CURVE_BOUNDS = dict(_V2_CURVE_BOUNDS)
PERSONALITY_ADVANCED_PARAM_KEYS = frozenset(
f"{profile}{suffix}"
for profile in ("Traffic", "Aggressive", "Standard", "Relaxed")
for suffix in ("JerkAcceleration", "JerkDeceleration", "JerkDanger", "JerkSpeedDecrease", "JerkSpeed")
)
PERSONALITY_FOLLOW_PARAM_KEYS = frozenset({
"TrafficFollow",
"AggressiveFollow", "AggressiveFollowHigh",
"StandardFollow", "StandardFollowHigh",
"RelaxedFollow", "RelaxedFollowHigh",
})
PERSONALITY_PROFILE_ENABLE_RUNTIME_KEYS = (
("traffic_personality_profile", "TrafficPersonalityProfile"),
("aggressive_personality_profile", "AggressivePersonalityProfile"),
("standard_personality_profile", "StandardPersonalityProfile"),
("relaxed_personality_profile", "RelaxedPersonalityProfile"),
)
PERSONALITY_PROFILE_ENABLE_PARAM_KEYS = frozenset(
param_key for _runtime_key, param_key in PERSONALITY_PROFILE_ENABLE_RUNTIME_KEYS
)
PERSONALITY_PARKED_PARAM_KEYS = (
PERSONALITY_ADVANCED_PARAM_KEYS
| PERSONALITY_FOLLOW_PARAM_KEYS
| PERSONALITY_PROFILE_ENABLE_PARAM_KEYS
| {"CustomPersonalities"}
)
def load_personality_profile_enable_values(get_value) -> dict[str, bool]:
return {
runtime_key: get_value(param_key)
for runtime_key, param_key in PERSONALITY_PROFILE_ENABLE_RUNTIME_KEYS
}
def validate_personality_follow_value(raw_value) -> float:
if not isinstance(raw_value, numbers.Real) or isinstance(raw_value, bool):
raise ValueError("Following values must be JSON numbers.")
value = float(raw_value)
if not math.isfinite(value) or value < 0.5 or value > 3.0:
raise ValueError("Following values must be between 0.5 and 3.0.")
return round(value, 4)
def validate_personality_advanced_value(raw_value) -> float:
if not isinstance(raw_value, numbers.Real) or isinstance(raw_value, bool):
raise ValueError("Advanced personality values must be JSON numbers.")
value = float(raw_value)
if not math.isfinite(value) or value < 25.0 or value > 200.0:
raise ValueError("Advanced personality values must be between 25 and 200.")
return round(value, 4)
_CATEGORY_SPECS = {
"acceleration": (ACCELERATION_PRESETS, len(ACCELERATION_SPEEDS_MPH)),
"braking": (BRAKING_PRESETS, len(BRAKING_SPEEDS_MPH)),
"following": (FOLLOWING_PRESETS, len(FOLLOWING_SPEEDS_MPH)),
}
_BRAKING_PRESET_CURVES = {
"eco": (0.5,) * len(BRAKING_SPEEDS_MPH),
"standard": (1.0,) * len(BRAKING_SPEEDS_MPH),
"sport": (2.0,) * len(BRAKING_SPEEDS_MPH),
}
FOLLOWING_PRESET_CURVES = {
"close": (1.25,) * len(FOLLOWING_SPEEDS_MPH),
"medium": (1.45,) * len(FOLLOWING_SPEEDS_MPH),
"far": (1.75,) * len(FOLLOWING_SPEEDS_MPH),
}
PROFILE_AXES = {
"acceleration": {
"speed": {"unit": "mph", "values": list(ACCELERATION_SPEEDS_MPH)},
"value": {"unit": "m/s^2", "meaning": "maximum_requested_acceleration"},
},
"braking": {
"speed": {"unit": "mph", "values": list(BRAKING_SPEEDS_MPH)},
"value": {"unit": "m/s^2", "meaning": "cruise_slc_deceleration_magnitude"},
},
"following": {
"speed": {"unit": "mph", "values": list(FOLLOWING_SPEEDS_MPH)},
"value": {"unit": "s", "meaning": "base_time_headway"},
},
}
_CATEGORY_SPEEDS_MPH = {
"acceleration": ACCELERATION_SPEEDS_MPH,
"braking": BRAKING_SPEEDS_MPH,
"following": FOLLOWING_SPEEDS_MPH,
}
_ACCELERATION_PROFILE_IDS = {
"standard": 0,
"eco": 1,
"sport": 2,
"sport_plus": 3,
}
_PERSONALITY_REFERENCE_PRESETS = {
"traffic": {"acceleration": "eco", "braking": "standard", "following": "close"},
"aggressive": {"acceleration": "sport_plus", "braking": "sport", "following": "close"},
"standard": {"acceleration": "standard", "braking": "standard", "following": "medium"},
"relaxed": {"acceleration": "eco", "braking": "eco", "following": "far"},
}
_V1_PROFILE_AXES = {
"acceleration": {
"speed": {"unit": "mph", "values": list(_V1_ACCELERATION_SPEEDS_MPH)},
"value": {"unit": "m/s^2", "meaning": "maximum_requested_acceleration"},
},
"braking": {
"speed": {"unit": "mph", "values": list(_V1_ACCELERATION_SPEEDS_MPH)},
"value": {"unit": "m/s^2", "meaning": "cruise_slc_deceleration_magnitude"},
},
"following": {
"speed": {"unit": "mph", "values": list(FOLLOWING_SPEEDS_MPH)},
"value": {"unit": "s", "meaning": "base_time_headway"},
},
}
def _acceleration_preset_curve(preset: str, ev_tuning: bool, truck_tuning: bool) -> list[float]:
from openpilot.starpilot.common.accel_profile import get_accel_profile_curve_values
return get_accel_profile_curve_values(
_ACCELERATION_PROFILE_IDS[preset], bool(ev_tuning), bool(truck_tuning) and not bool(ev_tuning)
)
def default_personality_profiles(ev_tuning: bool, truck_tuning: bool = False) -> dict[str, dict]:
del ev_tuning, truck_tuning
return {
personality: {
"acceleration": {"preset": "dom_default", "curve": []},
"braking": {"preset": "dom_default", "curve": []},
"following": {"preset": "dom_default", "curve": []},
}
for personality in PERSONALITY_IDS
}
def profile_document(profiles: dict[str, dict], *, enabled: bool) -> dict:
if type(enabled) is not bool:
raise ValueError("enabled must be a JSON boolean")
return {
"schemaVersion": PROFILE_SCHEMA_VERSION,
"enabled": enabled,
"axes": deepcopy(PROFILE_AXES),
"profiles": deepcopy(profiles),
}
def _decode_json(raw):
if isinstance(raw, bytes):
try:
raw = raw.decode("utf-8", errors="strict")
except UnicodeDecodeError:
return None
if isinstance(raw, str):
try:
raw = json.loads(raw)
except (TypeError, ValueError, json.JSONDecodeError):
return None
return raw
def _validated_category_with_length(
category: str,
raw_category,
expected_length: int,
curve_bounds: dict[str, tuple[float, float]],
legacy_curve_bounds: dict[str, tuple[float, float]] | None = None,
) -> dict | None:
if category not in _CATEGORY_SPECS or not isinstance(raw_category, dict):
return None
keys = set(raw_category)
has_legacy_curve = "legacyCurve" in keys
if keys != ({"preset", "curve", "legacyCurve"} if has_legacy_curve else {"preset", "curve"}):
return None
presets, _ = _CATEGORY_SPECS[category]
preset = raw_category.get("preset")
curve = raw_category.get("curve")
if not isinstance(preset, str) or preset not in presets or not isinstance(curve, list):
return None
if preset != "custom":
return {"preset": preset, "curve": []} if not curve and not has_legacy_curve else None
if len(curve) != expected_length:
return None
minimum, maximum = curve_bounds[category]
values = []
for raw_value in curve:
if isinstance(raw_value, bool) or not isinstance(raw_value, numbers.Real):
return None
value = float(raw_value)
if not math.isfinite(value) or not minimum <= value <= maximum:
return None
values.append(round(value, 4))
validated = {"preset": preset, "curve": values}
if has_legacy_curve:
legacy_curve = raw_category.get("legacyCurve")
if category not in ("acceleration", "braking") or expected_length != len(ACCELERATION_SPEEDS_MPH) or not isinstance(legacy_curve, list):
return None
if len(legacy_curve) != len(_V1_ACCELERATION_SPEEDS_MPH):
return None
legacy_minimum, legacy_maximum = (legacy_curve_bounds or curve_bounds)[category]
legacy_values = []
for raw_value in legacy_curve:
if isinstance(raw_value, bool) or not isinstance(raw_value, numbers.Real):
return None
value = float(raw_value)
if not math.isfinite(value) or not legacy_minimum <= value <= legacy_maximum:
return None
legacy_values.append(round(value, 4))
validated["legacyCurve"] = legacy_values
return validated
def _validated_category(category: str, raw_category) -> dict | None:
expected_length = _CATEGORY_SPECS.get(category, ((), 0))[1]
return _validated_category_with_length(category, raw_category, expected_length, _V2_CURVE_BOUNDS, _V1_CURVE_BOUNDS)
def _schema_values_equal(actual, expected) -> bool:
if type(actual) is not type(expected):
return False
if isinstance(expected, dict):
return set(actual) == set(expected) and all(_schema_values_equal(actual[key], expected[key]) for key in expected)
if isinstance(expected, list):
return len(actual) == len(expected) and all(_schema_values_equal(value, reference) for value, reference in zip(actual, expected, strict=True))
return actual == expected
def _strict_document(
raw_document,
schema_version: int,
axes: dict,
category_lengths: dict[str, int],
curve_bounds: dict[str, tuple[float, float]],
legacy_curve_bounds: dict[str, tuple[float, float]] | None = None,
) -> dict | None:
decoded = _decode_json(raw_document)
if not isinstance(decoded, dict) or set(decoded) != {"schemaVersion", "enabled", "axes", "profiles"}:
return None
if type(decoded["schemaVersion"]) is not int or decoded["schemaVersion"] != schema_version:
return None
if type(decoded["enabled"]) is not bool or not _schema_values_equal(decoded["axes"], axes):
return None
raw_profiles = decoded["profiles"]
if not isinstance(raw_profiles, dict) or set(raw_profiles) != set(PERSONALITY_IDS):
return None
profiles = {}
for personality in PERSONALITY_IDS:
raw_profile = raw_profiles.get(personality)
if not isinstance(raw_profile, dict) or set(raw_profile) != set(_CATEGORY_SPECS):
return None
profile = {}
for category in _CATEGORY_SPECS:
validated = _validated_category_with_length(
category, raw_profile.get(category), category_lengths[category], curve_bounds, legacy_curve_bounds,
)
if validated is None:
return None
profile[category] = validated
profiles[personality] = profile
return {
"schemaVersion": schema_version,
"enabled": decoded["enabled"],
"axes": deepcopy(axes),
"profiles": profiles,
}
def strict_profile_document(raw_document) -> dict | None:
return _strict_document(
raw_document,
PROFILE_SCHEMA_VERSION,
PROFILE_AXES,
{category: expected_length for category, (_, expected_length) in _CATEGORY_SPECS.items()},
_V2_CURVE_BOUNDS,
_V1_CURVE_BOUNDS,
)
def migrate_profile_document(raw_document) -> dict | None:
current = strict_profile_document(raw_document)
if current is not None:
return current
legacy = _strict_document(
raw_document,
1,
_V1_PROFILE_AXES,
{"acceleration": len(_V1_ACCELERATION_SPEEDS_MPH), "braking": len(_V1_ACCELERATION_SPEEDS_MPH), "following": len(FOLLOWING_SPEEDS_MPH)},
_V1_CURVE_BOUNDS,
)
if legacy is None:
return None
migrated_profiles = deepcopy(legacy["profiles"])
for profile in migrated_profiles.values():
for category in ("acceleration", "braking"):
config = profile[category]
if config["preset"] != "custom":
continue
legacy_curve = list(config["curve"])
minimum, maximum = CURVE_BOUNDS[category]
config["curve"] = [
round(min(max(_linear_interp(float(speed_mph), _V1_ACCELERATION_SPEEDS_MPH, config["curve"]), minimum), maximum), 4)
for speed_mph in ACCELERATION_SPEEDS_MPH
]
config["legacyCurve"] = legacy_curve
return strict_profile_document(profile_document(migrated_profiles, enabled=legacy["enabled"]))
def is_unconfigured_profile_document(raw_document) -> bool:
if raw_document is None:
return True
decoded = _decode_json(raw_document)
return isinstance(decoded, dict) and not decoded
def synchronise_profile_document_enabled(
raw_document, enabled: bool, ev_tuning: bool, truck_tuning: bool = False,
) -> dict | None:
if type(enabled) is not bool:
raise ValueError("enabled must be a JSON boolean")
document = migrate_profile_document(raw_document)
if document is None:
if not is_unconfigured_profile_document(raw_document) or not enabled:
return None
return profile_document(default_personality_profiles(ev_tuning, truck_tuning), enabled=True)
document["enabled"] = enabled
return strict_profile_document(document)
def strict_personality_profiles(raw_document) -> dict[str, dict] | None:
document = migrate_profile_document(raw_document)
if document is None or not document["enabled"]:
return None
return deepcopy(document["profiles"])
def load_personality_profiles(raw_document, ev_tuning: bool, truck_tuning: bool = False) -> dict[str, dict]:
document = migrate_profile_document(raw_document)
return deepcopy(document["profiles"]) if document is not None else default_personality_profiles(ev_tuning, truck_tuning)
def serialize_personality_profiles(profiles, ev_tuning: bool, truck_tuning: bool = False, *, enabled: bool) -> str:
del ev_tuning, truck_tuning
document = profile_document(profiles, enabled=enabled)
canonical = strict_profile_document(document)
if canonical is None:
raise ValueError("Longitudinal personality profiles must be complete and valid.")
return json.dumps(canonical, separators=(",", ":"), sort_keys=True, allow_nan=False)
def update_personality_profile(
profiles, personality: str, category: str, preset: str, curve, ev_tuning: bool, truck_tuning: bool = False,
) -> dict[str, dict]:
if personality not in PERSONALITY_IDS:
raise ValueError(f"Unknown personality: {personality}")
if category not in _CATEGORY_SPECS:
raise ValueError(f"Unknown profile category: {category}")
base_document = profile_document(profiles, enabled=True)
canonical = strict_profile_document(base_document)
validated = _validated_category(category, {"preset": preset, "curve": curve})
if validated is not None and preset == "custom":
minimum, maximum = CURVE_BOUNDS[category]
previous = canonical["profiles"][personality][category] if canonical is not None else None
for index, value in enumerate(curve):
if not minimum <= value <= maximum and (
previous is None or previous["preset"] != "custom" or value != previous["curve"][index]
):
validated = None
break
if validated is None:
minimum, maximum = CURVE_BOUNDS[category]
presets, expected_length = _CATEGORY_SPECS[category]
message = f"Invalid {category} profile: preset must be one of {', '.join(presets)} and curve must contain "
message += f"{expected_length} finite numeric values between {minimum} and {maximum}."
raise ValueError(message)
if canonical is None:
base = default_personality_profiles(ev_tuning, truck_tuning)
else:
base = canonical["profiles"]
updated = deepcopy(base)
previous = updated[personality][category]
if preset == "custom" and previous["preset"] == "custom" and validated["curve"] == previous["curve"]:
return updated
updated[personality][category] = validated
return updated
def active_personality_id(traffic_mode: bool, personality) -> str | None:
if type(traffic_mode) is not bool:
return None
if traffic_mode:
return "traffic"
if isinstance(personality, bool):
return None
raw = getattr(personality, "raw", personality)
if isinstance(raw, bool) or not isinstance(raw, numbers.Integral):
return None
return {0: "aggressive", 1: "standard", 2: "relaxed"}.get(int(raw))
def resolve_personality_profile(raw_document, traffic_mode: bool, personality) -> dict | None:
profiles = strict_personality_profiles(raw_document)
personality_id = active_personality_id(traffic_mode, personality)
if profiles is None or personality_id is None:
return None
return deepcopy(profiles[personality_id])
def resolve_personality_category(raw_document, traffic_mode: bool, personality, category: str) -> dict | None:
profile = resolve_personality_profile(raw_document, traffic_mode, personality)
if profile is None or category not in _CATEGORY_SPECS:
return None
config = profile[category]
return None if config["preset"] == "dom_default" else deepcopy(config)
def category_curve(category: str, config: dict, ev_tuning: bool, truck_tuning: bool = False) -> list[float]:
validated = _validated_category(category, config)
if validated is None:
raise ValueError(f"Invalid {category} profile configuration.")
preset = validated["preset"]
if preset == "dom_default":
raise ValueError("Dom default resolves through the legacy controller path")
if preset == "custom":
return list(validated["curve"])
if category == "acceleration":
return _acceleration_preset_curve(preset, ev_tuning, truck_tuning)
if category == "braking":
return list(_BRAKING_PRESET_CURVES[preset])
return list(FOLLOWING_PRESET_CURVES[preset])
def _sample_config_on_custom_axis(
category: str, config: dict, ev_tuning: bool, truck_tuning: bool,
) -> list[float]:
return [
round(interpolate_category_curve(category, speed_mph * 0.44704, config, ev_tuning, truck_tuning), 4)
for speed_mph in _CATEGORY_SPEEDS_MPH[category]
]
def personality_reference_curves(ev_tuning: bool, truck_tuning: bool = False) -> dict[str, dict[str, list[float]]]:
return {
personality: {
category: _sample_config_on_custom_axis(
category,
{"preset": preset, "curve": []},
ev_tuning,
truck_tuning,
)
for category, preset in presets.items()
}
for personality, presets in _PERSONALITY_REFERENCE_PRESETS.items()
}
def initial_custom_curve(
category: str,
current_config: dict,
ev_tuning: bool,
truck_tuning: bool,
*,
legacy_curve: list[float] | None = None,
) -> list[float]:
if category not in _CATEGORY_SPECS or not isinstance(current_config, dict):
raise ValueError("Unknown or malformed profile category")
preset = current_config.get("preset")
if preset == "dom_default":
candidate = legacy_curve
if category in ("acceleration", "braking") and isinstance(candidate, list) and len(candidate) == len(_V1_ACCELERATION_SPEEDS_MPH):
candidate = [
round(_linear_interp(float(speed_mph), _V1_ACCELERATION_SPEEDS_MPH, candidate), 4)
for speed_mph in _CATEGORY_SPEEDS_MPH[category]
]
elif preset == "custom":
candidate = current_config.get("curve")
elif isinstance(preset, str):
candidate = _sample_config_on_custom_axis(category, {"preset": preset, "curve": []}, ev_tuning, truck_tuning)
minimum, maximum = CURVE_BOUNDS[category]
candidate = [round(min(max(value, minimum), maximum), 4) for value in candidate]
else:
candidate = None
validated = _validated_category(category, {"preset": "custom", "curve": candidate})
if validated is None:
raise ValueError(f"Cannot initialize Custom {category} from the current selection")
return validated["curve"]
def _linear_interp(value: float, breakpoints: tuple[float, ...], values: list[float]) -> float:
if value <= breakpoints[0]:
return float(values[0])
if value >= breakpoints[-1]:
return float(values[-1])
index = next(index for index, point in enumerate(breakpoints[1:], start=1) if point >= value) - 1
t = (value - breakpoints[index]) / float(breakpoints[index + 1] - breakpoints[index])
return float(values[index] + t * (values[index + 1] - values[index]))
def interpolate_category_curve(
category: str, v_ego: float, config: dict, ev_tuning: bool, truck_tuning: bool = False,
) -> float:
if not isinstance(v_ego, numbers.Real) or isinstance(v_ego, bool) or not math.isfinite(float(v_ego)):
raise ValueError("Vehicle speed must be finite")
validated = _validated_category(category, config)
if validated is None:
raise ValueError(f"Invalid {category} profile configuration.")
values = category_curve(category, validated, ev_tuning, truck_tuning)
if "legacyCurve" in validated:
return _linear_interp(float(v_ego), _NATIVE_ACCELERATION_SPEEDS_MS, validated["legacyCurve"])
if category == "acceleration":
from openpilot.starpilot.common.accel_profile import interpolate_accel_profile
breakpoints = _NATIVE_ACCELERATION_SPEEDS_MS if validated["preset"] != "custom" else tuple(
speed * 0.44704 for speed in ACCELERATION_SPEEDS_MPH
)
return interpolate_accel_profile(float(v_ego), values, breakpoints)
return _linear_interp(float(v_ego) / 0.44704, _CATEGORY_SPEEDS_MPH[category], values)
+22 -40
View File
@@ -183,58 +183,40 @@ def _read_profile(slot: str, profile_root: Path | None = None) -> dict:
return payload
def prepare_profile(params, slot: str, *, allowed_keys: set[str] | None = None, profile_root: Path | None = None,
legacy_renames: dict[str, str] | None = None) -> dict:
"""Decode a slot without writes so callers can apply their validated restore policy."""
def load_profile(params, slot: str, *, allowed_keys: set[str] | None = None, profile_root: Path | None = None,
legacy_renames: dict[str, str] | None = None) -> dict:
normalized = _normalize_slot(slot)
with _PROFILE_LOCK:
payload = _read_profile(normalized, profile_root)
keys = eligible_profile_keys(params) if allowed_keys is None else set(allowed_keys)
renames = legacy_renames or {}
settings = {}
skipped_count = 0
for saved_key, entry in payload["settings"].items():
key = renames.get(saved_key, saved_key)
if not isinstance(key, str) or key not in keys or not isinstance(entry, dict):
skipped_count += 1
continue
try:
current_type = ParamKeyType(params.get_type(key))
saved_type = ParamKeyType(entry.get("type"))
if saved_type != current_type or "value" not in entry:
raise ValueError("setting type changed")
settings[saved_key] = _deserialize_value(current_type, entry["value"])
except (KeyError, TypeError, ValueError, OverflowError):
skipped_count += 1
keys = eligible_profile_keys(params) if allowed_keys is None else set(allowed_keys)
renames = legacy_renames or {}
restored_count = 0
skipped_count = 0
for saved_key, entry in payload["settings"].items():
key = renames.get(saved_key, saved_key)
if not isinstance(key, str) or key not in keys or not isinstance(entry, dict):
skipped_count += 1
continue
try:
current_type = ParamKeyType(params.get_type(key))
saved_type = ParamKeyType(entry.get("type"))
if saved_type != current_type or "value" not in entry:
raise ValueError("setting type changed")
params.put(key, _deserialize_value(current_type, entry["value"]))
restored_count += 1
except (KeyError, TypeError, ValueError, OverflowError):
skipped_count += 1
if not settings:
if restored_count == 0:
raise ParamProfileError("No compatible settings were found in this profile.")
return {
"slot": normalized,
"label": PROFILE_SLOTS[normalized],
"settings": settings,
"restoredCount": restored_count,
"skippedCount": skipped_count,
}
def load_profile(params, slot: str, *, allowed_keys: set[str] | None = None, profile_root: Path | None = None,
legacy_renames: dict[str, str] | None = None) -> dict:
result = prepare_profile(params, slot, allowed_keys=allowed_keys, profile_root=profile_root, legacy_renames=legacy_renames)
settings = result.pop("settings")
renames = legacy_renames or {}
restored_count = 0
with _PROFILE_LOCK:
for saved_key, value in settings.items():
try:
params.put(renames.get(saved_key, saved_key), value)
restored_count += 1
except (KeyError, TypeError, ValueError, OverflowError):
result["skippedCount"] += 1
if restored_count == 0:
raise ParamProfileError("No compatible settings were found in this profile.")
return {**result, "restoredCount": restored_count}
def profile_status(slot: str, *, profile_root: Path | None = None) -> dict:
normalized = _normalize_slot(slot)
status = {
-15
View File
@@ -8,11 +8,6 @@ from openpilot.starpilot.common.accel_profile import (
CUSTOM_ACCEL_PROFILE_BREAKPOINTS_INITIALIZED_KEY,
CUSTOM_ACCEL_PROFILE_CURVE_PARAM_KEYS,
)
from openpilot.starpilot.common.longitudinal_personality_profiles import (
PERSONALITY_PROFILES_PARAM,
default_personality_profiles,
profile_document,
)
SAFE_MODE_PARAM = "SafeMode"
SAFE_MODE_BACKUP_PARAM = "SafeModeBackup"
@@ -174,7 +169,6 @@ SAFE_MODE_MANAGED_KEYS = (
"VisionSpeedLimitLowLimitThreshold",
"VASMEnabled",
"CustomPersonalities",
PERSONALITY_PROFILES_PARAM,
"TrafficPersonalityProfile",
"AggressivePersonalityProfile",
"StandardPersonalityProfile",
@@ -231,7 +225,6 @@ SAFE_MODE_FIXED_VALUES = {
"UseAutoSteerDelay": True,
"SubaruStopStartOff": False,
"SubaruRedneckCruise": False,
PERSONALITY_PROFILES_PARAM: profile_document(default_personality_profiles(False), enabled=False),
}
SAFE_MODE_STOCK_PARAM_MAP = {
@@ -343,14 +336,6 @@ def apply_safe_mode(params: Params, params_raw: Params, params_memory: Params |
def restore_safe_mode(params_raw: Params, params_memory: Params | None = None) -> bool:
changed = False
if params_raw.get(SAFE_MODE_BACKUP_PARAM) is not None:
try:
confirmed_offroad = not params_raw.get_bool("IsOnroad") and params_raw.get_bool("IsOffroad")
except Exception:
return False
if not confirmed_offroad:
return False
backup = _load_backup(params_raw)
if not backup:
+2 -27
View File
@@ -31,7 +31,6 @@ from openpilot.selfdrive.modeld.constants import ModelConstants
from openpilot.starpilot.common.model_versions import is_tinygrad_model_version
from openpilot.starpilot.common.lateral_delay import full_lateral_delay
from openpilot.starpilot.common.lateral_only_experimental import lateral_only_experimental_available
from openpilot.starpilot.common.longitudinal_mode import read_mode_values
from openpilot.starpilot.common.accel_profile import (
ACCELERATION_PROFILES,
A_CRUISE_MAX_BP_CUSTOM,
@@ -50,12 +49,6 @@ from openpilot.starpilot.common.accel_profile import (
normalize_deceleration_profile,
parse_custom_accel_profile_curve,
)
from openpilot.starpilot.common.longitudinal_personality_profiles import (
PERSONALITY_PROFILES_PARAM,
is_truck_fingerprint,
load_personality_profile_enable_values,
migrate_profile_document,
)
from openpilot.system.hardware import HARDWARE
from openpilot.system.hardware.hw import Paths
from openpilot.system.hardware.power_monitoring import VBATT_PAUSE_CHARGING
@@ -624,14 +617,6 @@ class StarPilotVariables:
def update(self, holiday_theme="stock", started=False, clear_update_flag=True):
toggle = self.starpilot_toggles
try:
mode_values = read_mode_values(self.params)
except OSError:
self.params_memory.put_bool("StarPilotTogglesUpdated", True)
if hasattr(toggle, "longitudinal_mode_values"):
return
mode_values = {"ExperimentalMode": False, "ConditionalChill": False, "ConditionalExperimental": False}
clear_update_flag = False
# CarParams uses this value to select the matching Panda safety configuration.
toggle.tesla_cooperative_steering = self.params.get_bool("TeslaCoopSteering")
toggle.rivian_angle_control = self.params.get_bool("RivianAngleControl")
@@ -821,10 +806,6 @@ class StarPilotVariables:
# Seed powertrain-based defaults once, but always honor persisted user overrides.
toggle.ev_tuning = ev_tuning_param
toggle.truck_tuning = truck_tuning_param
toggle.personality_ev_tuning = bool(ev_vehicle)
toggle.personality_truck_tuning = (
is_truck_fingerprint(CP.carFingerprint) or truck_tuning_param
) and not toggle.personality_ev_tuning
toggle.trailer_load_kg = self.get_value("TrailerLoad", cast=float, condition=advanced_longitudinal_tuning,
default=0.0, conversion=CV.LB_TO_KG, min=0, max=15000 * CV.LB_TO_KG)
toggle.longitudinalActuatorDelay = self.get_value("LongitudinalActuatorDelay", cast=float, condition=advanced_longitudinal_tuning, default=longitudinalActuatorDelay, min=0, max=1)
@@ -882,10 +863,8 @@ class StarPilotVariables:
self.migrate_prius_cluster_offset(str(toggle.car_model))
toggle.cluster_offset = self.get_value("ClusterOffset", cast=float, condition=toggle.car_make == "toyota")
toggle.longitudinal_mode_values = mode_values
toggle.experimental_mode = toggle.experimental_mode_available and not toggle.safe_mode and mode_values["ExperimentalMode"]
toggle.conditional_experimental_mode = toggle.openpilot_longitudinal and not toggle.safe_mode and mode_values["ConditionalExperimental"]
toggle.conditional_chill_mode = toggle.openpilot_longitudinal and not toggle.safe_mode and not toggle.conditional_experimental_mode and mode_values["ConditionalChill"]
toggle.conditional_experimental_mode = toggle.openpilot_longitudinal and self.get_value("ConditionalExperimental")
toggle.conditional_chill_mode = toggle.openpilot_longitudinal and not toggle.conditional_experimental_mode and self.get_value("ConditionalChill")
toggle.conditional_curves = self.get_value("CECurves", condition=toggle.conditional_experimental_mode)
toggle.conditional_curves_lead = self.get_value("CECurvesLead", condition=toggle.conditional_curves)
toggle.conditional_lead = self.get_value("CELead", condition=toggle.conditional_experimental_mode)
@@ -922,10 +901,6 @@ class StarPilotVariables:
toggle.speed_limit_changed_alert = self.get_value("SpeedLimitChangedAlert")
toggle.custom_personalities = toggle.openpilot_longitudinal and self.get_value("CustomPersonalities")
for runtime_key, enabled in load_personality_profile_enable_values(self.get_value).items():
setattr(toggle, runtime_key, enabled)
profile_settings_raw = self.params_raw.get(PERSONALITY_PROFILES_PARAM)
toggle.longitudinal_personality_profiles = migrate_profile_document(profile_settings_raw) or {}
toggle.aggressive_jerk_acceleration = self.get_value("AggressiveJerkAcceleration", cast=float, condition=toggle.custom_personalities, conversion=0.01, min=0.25, max=2.0)
toggle.aggressive_jerk_deceleration = self.get_value("AggressiveJerkDeceleration", cast=float, condition=toggle.custom_personalities, conversion=0.01, min=0.25, max=2.0)
toggle.aggressive_jerk_danger = self.get_value("AggressiveJerkDanger", cast=float, condition=toggle.custom_personalities, conversion=0.01, min=0.25, max=2.0)
+1 -41
View File
@@ -1,7 +1,6 @@
import json
from typing import cast
from openpilot.common.params import ParamKeyType, Params
from openpilot.common.params import ParamKeyType
from openpilot.starpilot.common.favorite_slots import (
FAVORITE_ACTION_ACCEL_COUNTER,
FAVORITE_ACTION_DECEL_COUNTER,
@@ -17,17 +16,10 @@ from openpilot.starpilot.common.favorite_slots import (
filter_favorite_slot_options,
load_settings_catalog,
load_favorite_slots,
normalize_favorite_slots,
save_favorite_slots,
toggle_favorite_slot,
unassign_favorite_slot,
)
from openpilot.starpilot.common.longitudinal_personality_profiles import (
PERSONALITY_ADVANCED_PARAM_KEYS,
PERSONALITY_PARKED_PARAM_KEYS,
PERSONALITY_PROFILE_ENABLE_PARAM_KEYS,
PERSONALITY_PROFILES_PARAM,
)
class FakeParams:
@@ -35,14 +27,11 @@ class FakeParams:
self.store = {}
self.types = {
FAVORITE_SLOTS_PARAM: ParamKeyType.JSON,
PERSONALITY_PROFILES_PARAM: ParamKeyType.JSON,
"AlphaLongitudinalEnabled": ParamKeyType.BOOL,
"ForceOffroad": ParamKeyType.BOOL,
"RedneckCruise": ParamKeyType.BOOL,
"NotBool": ParamKeyType.INT,
}
self.types.update(dict.fromkeys(PERSONALITY_PROFILE_ENABLE_PARAM_KEYS | {"CustomPersonalities"}, ParamKeyType.BOOL))
self.types.update(dict.fromkeys(PERSONALITY_ADVANCED_PARAM_KEYS, ParamKeyType.INT))
def get(self, key):
return self.store.get(key)
@@ -111,35 +100,6 @@ def test_galaxy_only_ford_controls_are_not_available_to_device_favorites():
assert ford_keys.isdisjoint({option["key"] for option in options})
def test_parked_only_personality_keys_are_never_exposed_or_mutated_as_favorites():
blocked_keys = PERSONALITY_PARKED_PARAM_KEYS | {PERSONALITY_PROFILES_PARAM}
options = build_favorite_slot_options(lambda _key: True, alpha_longitudinal_available=True)
assert blocked_keys.isdisjoint({option["key"] for option in options})
params = FakeParams()
typed_params = cast(Params, params)
params_memory = cast(Params, FakeParams())
for key in blocked_keys:
original = {"schemaVersion": 1, "enabled": False} if key == PERSONALITY_PROFILES_PARAM else 100
params.put(key, original)
params.put(FAVORITE_SLOTS_PARAM, [{"enabled": True, "show_onroad": True, "key": key, "label": "Profiles"}])
slots = load_favorite_slots(typed_params, eligible_keys={key})
assert slots[0]["key"] is None
assert toggle_favorite_slot(0, typed_params, params_memory, eligible_keys={key}) is False
assert params.get(key) == original
def test_parked_only_personality_keys_are_removed_without_a_param_store_even_when_eligible():
blocked_keys = PERSONALITY_PARKED_PARAM_KEYS | {PERSONALITY_PROFILES_PARAM}
for key in blocked_keys:
slots = normalize_favorite_slots(
[{"enabled": True, "show_onroad": True, "key": key, "label": "Profiles"}],
eligible_keys={key},
)
assert slots[0]["key"] is None
def test_load_favorite_slots_filters_non_bool_keys():
params = FakeParams()
params.put(FAVORITE_SLOTS_PARAM, [
@@ -1,616 +0,0 @@
import json
import math
from pathlib import Path
import numpy as np
import pytest
import openpilot.starpilot.common.longitudinal_personality_profiles as lpp
from openpilot.starpilot.common.accel_profile import (
ACCELERATION_PROFILES,
get_accel_profile_curve_values,
interpolate_accel_profile,
)
from openpilot.starpilot.common.longitudinal_personality_profiles import (
ACCELERATION_SPEEDS_MPH,
BRAKING_SPEEDS_MPH,
CURVE_BOUNDS,
FOLLOWING_PRESET_CURVES,
FOLLOWING_SPEEDS_MPH,
PERSONALITY_IDS,
PROFILE_SCHEMA_VERSION,
active_personality_id,
category_curve,
default_personality_profiles,
initial_custom_curve,
is_truck_fingerprint,
interpolate_category_curve,
load_personality_profiles,
profile_document,
resolve_personality_profile,
serialize_personality_profiles,
strict_personality_profiles,
update_personality_profile,
)
def test_document_is_versioned_disabled_and_declares_exact_axes_and_units():
document = profile_document(default_personality_profiles(False), enabled=False)
assert document["schemaVersion"] == PROFILE_SCHEMA_VERSION == 2
assert document["enabled"] is False
assert document["axes"] == {
"acceleration": {
"speed": {"unit": "mph", "values": list(ACCELERATION_SPEEDS_MPH)},
"value": {"unit": "m/s^2", "meaning": "maximum_requested_acceleration"},
},
"braking": {
"speed": {"unit": "mph", "values": list(BRAKING_SPEEDS_MPH)},
"value": {"unit": "m/s^2", "meaning": "cruise_slc_deceleration_magnitude"},
},
"following": {
"speed": {"unit": "mph", "values": list(FOLLOWING_SPEEDS_MPH)},
"value": {"unit": "s", "meaning": "base_time_headway"},
},
}
assert set(document["profiles"]) == set(PERSONALITY_IDS)
for profile in document["profiles"].values():
assert set(profile) == {"acceleration", "braking", "following"}
@pytest.mark.parametrize("fingerprint", [
"RAM 1500 5TH GEN",
"RAM HD 5TH GEN",
"FORD F-150 14TH GEN",
"FORD MAVERICK 1ST GEN",
"FORD RANGER 2ND GEN",
"CHEVROLET SILVERADO 1500 2020",
"HONDA RIDGELINE 2017",
"HYUNDAI SANTA CRUZ 2025",
])
def test_supported_truck_fingerprints_select_the_truck_curve(fingerprint):
assert is_truck_fingerprint(fingerprint) is True
@pytest.mark.parametrize("fingerprint", [None, "", "HONDA CIVIC 2022", "FORD EXPLORER 6TH GEN"])
def test_non_truck_fingerprints_do_not_select_the_truck_curve(fingerprint):
assert is_truck_fingerprint(fingerprint) is False
def test_enabling_without_a_stored_document_preserves_dom_defaults():
document = lpp.synchronise_profile_document_enabled(None, True, ev_tuning=False, truck_tuning=False)
assert document == profile_document(default_personality_profiles(False), enabled=True)
def test_fresh_profiles_do_not_override_legacy_personality_until_selected():
document = lpp.synchronise_profile_document_enabled(None, True, False, False)
for personality in PERSONALITY_IDS:
assert all(document["profiles"][personality][category] == {"preset": "dom_default", "curve": []}
for category in ("acceleration", "braking", "following"))
assert lpp.resolve_personality_category(document, False, 0, "acceleration") is None
assert lpp.resolve_personality_category(document, False, 1, "braking") is None
assert lpp.resolve_personality_category(document, False, 2, "following") is None
def test_enabling_does_not_overwrite_a_malformed_stored_document():
assert lpp.synchronise_profile_document_enabled({"schemaVersion": 99}, True, False, False) is None
def test_disabling_without_a_stored_document_does_not_create_one():
assert lpp.synchronise_profile_document_enabled(None, False, ev_tuning=False, truck_tuning=False) is None
def test_every_state_affecting_personality_param_is_parked_only():
assert lpp.PERSONALITY_PARKED_PARAM_KEYS == (
lpp.PERSONALITY_ADVANCED_PARAM_KEYS
| lpp.PERSONALITY_FOLLOW_PARAM_KEYS
| lpp.PERSONALITY_PROFILE_ENABLE_PARAM_KEYS
| {"CustomPersonalities"}
)
assert len(lpp.PERSONALITY_PARKED_PARAM_KEYS) == 32
def test_legacy_follow_values_reject_coercion_and_out_of_range_inputs():
for invalid in (True, "1.25", math.nan, math.inf, 0.49, 3.01):
with pytest.raises(ValueError):
lpp.validate_personality_follow_value(invalid)
assert lpp.validate_personality_follow_value(0.5) == 0.5
assert lpp.validate_personality_follow_value(1.25) == 1.25
assert lpp.validate_personality_follow_value(3) == 3.0
def test_advanced_personality_values_reject_coercion_and_out_of_range_inputs():
for invalid in (True, "50", math.nan, math.inf, 24.9, 200.1):
with pytest.raises(ValueError):
lpp.validate_personality_advanced_value(invalid)
assert lpp.validate_personality_advanced_value(50) == 50.0
assert lpp.validate_personality_advanced_value(100.0) == 100.0
assert lpp.validate_personality_advanced_value(72.34567) == 72.3457
def test_runtime_loader_uses_detected_truck_curve_without_changing_legacy_truck_flag():
source = (Path(__file__).parents[1] / "starpilot_variables.py").read_text(encoding="utf-8")
assert "toggle.longitudinal_personality_profiles = migrate_profile_document(profile_settings_raw) or {}" in source
assert "is_truck_fingerprint(CP.carFingerprint) or truck_tuning_param" in source
assert ") and not toggle.personality_ev_tuning" in source
assert "toggle.truck_tuning = truck_tuning_param" in source
def test_runtime_loader_maps_each_personality_enable_param_to_the_exact_runtime_boolean():
loader = getattr(lpp, "load_personality_profile_enable_values", None)
assert callable(loader)
persisted = {
"TrafficPersonalityProfile": True,
"AggressivePersonalityProfile": False,
"StandardPersonalityProfile": True,
"RelaxedPersonalityProfile": False,
}
requested = []
def get_value(key):
requested.append(key)
return persisted[key]
assert loader(get_value) == {
"traffic_personality_profile": True,
"aggressive_personality_profile": False,
"standard_personality_profile": True,
"relaxed_personality_profile": False,
}
assert requested == list(persisted)
def test_acceleration_presets_select_truck_automatically_and_ev_wins_if_both_are_true():
config = {"preset": "sport", "curve": []}
assert category_curve("acceleration", config, False, True) == get_accel_profile_curve_values(2, False, True)
assert category_curve("acceleration", config, True, True) == get_accel_profile_curve_values(2, True, False)
def test_declared_custom_axes_use_exact_ten_mph_breakpoints():
assert ACCELERATION_SPEEDS_MPH == tuple(range(0, 91, 10))
assert BRAKING_SPEEDS_MPH == ACCELERATION_SPEEDS_MPH
def test_boolean_axis_values_are_not_accepted_as_numeric_breakpoints():
invalid = profile_document(default_personality_profiles(False), enabled=True)
invalid["axes"]["acceleration"]["speed"]["values"][0] = False
assert lpp.strict_profile_document(invalid) is None
def test_strict_document_rejects_unversioned_partial_extra_or_axis_changes():
valid = profile_document(default_personality_profiles(False), enabled=True)
assert strict_personality_profiles(valid) == valid["profiles"]
assert strict_personality_profiles(json.dumps(valid)) == valid["profiles"]
invalid_documents = [
valid["profiles"],
{**valid, "schemaVersion": 99},
{**valid, "enabled": 1},
{**valid, "extra": True},
{key: value for key, value in valid.items() if key != "axes"},
]
wrong_axis = json.loads(json.dumps(valid))
wrong_axis["axes"]["acceleration"]["speed"]["values"][0] = 1
invalid_documents.append(wrong_axis)
partial = json.loads(json.dumps(valid))
del partial["profiles"]["standard"]["braking"]
invalid_documents.append(partial)
for invalid in invalid_documents:
assert strict_personality_profiles(invalid) is None
def test_strict_document_rejects_boolean_non_finite_fractional_and_out_of_range_values():
for value in (True, False, math.nan, math.inf, -math.inf, "1.0", 6.1):
invalid = profile_document(default_personality_profiles(False), enabled=True)
invalid["profiles"]["standard"]["acceleration"] = {"preset": "custom", "curve": [1.0] * 10}
invalid["profiles"]["standard"]["acceleration"]["curve"][0] = value
assert strict_personality_profiles(invalid) is None
def test_custom_curve_bounds_preserve_low_acceleration_and_enforce_requested_ceilings():
valid = profile_document(default_personality_profiles(False), enabled=True)
valid["profiles"]["standard"]["acceleration"] = {"preset": "custom", "curve": [0.35] + [3.5] * 9}
valid["profiles"]["standard"]["braking"] = {"preset": "custom", "curve": [2.0] * 10}
assert strict_personality_profiles(valid) == valid["profiles"]
# A saved v2 curve can exceed the new-authoring ceiling; new points cannot.
with pytest.raises(ValueError):
update_personality_profile(valid["profiles"], "standard", "acceleration", "custom", [3.51] * 10, False)
invalid_braking = json.loads(json.dumps(valid))
invalid_braking["profiles"]["standard"]["braking"]["curve"][0] = 2.01
assert strict_personality_profiles(invalid_braking) is None
@pytest.mark.parametrize("value", [3.51, 4.0, 5.0, 6.0])
@pytest.mark.parametrize("enabled", [False, True])
def test_saved_v2_high_acceleration_keeps_schema_and_runtime_behaviour(value, enabled):
document = profile_document(default_personality_profiles(False), enabled=enabled)
curve = [value, 3.5, 2.0, 1.5, 1.0, 0.8, 0.6, 0.4, 0.2, 0.0]
document["profiles"]["aggressive"]["acceleration"] = {"preset": "custom", "curve": curve}
raw = json.dumps(document)
assert lpp.strict_profile_document(raw) == document
assert lpp.migrate_profile_document(raw) == document
assert load_personality_profiles(raw, False) == document["profiles"]
assert json.loads(serialize_personality_profiles(document["profiles"], False, enabled=enabled)) == document
assert lpp.synchronise_profile_document_enabled(raw, not enabled, False) == {**document, "enabled": not enabled}
resolved = resolve_personality_profile(raw, False, 0)
assert resolved == (document["profiles"]["aggressive"] if enabled else None)
if enabled:
assert resolved is not None
for speed_mph in (-1.0, 0.0, 2.5, 5.0, 10.0, 25.0, 90.0, 100.0):
assert interpolate_category_curve("acceleration", speed_mph * 0.44704, resolved["acceleration"], False) == pytest.approx(
interpolate_accel_profile(speed_mph * 0.44704, curve, [speed * 0.44704 for speed in ACCELERATION_SPEEDS_MPH])
)
assert json.dumps(document) == raw
@pytest.mark.parametrize("value", [3.51, 4.0, 5.0, 6.0])
def test_edit_saved_v2_high_point_preserves_other_points_and_profiles(value):
profiles = default_personality_profiles(False)
profiles["aggressive"]["acceleration"] = {"preset": "custom", "curve": [value] * 10}
raw = json.dumps(profiles)
curve = [3.0] + [value] * 9
updated = update_personality_profile(profiles, "aggressive", "acceleration", "custom", curve, False)
assert updated["aggressive"]["acceleration"] == {"preset": "custom", "curve": curve}
assert json.dumps(profiles) == raw
for profile_id in ("traffic", "standard", "relaxed"):
assert updated[profile_id] == profiles[profile_id]
with pytest.raises(ValueError):
update_personality_profile(updated, "aggressive", "acceleration", "custom", [value] * 10, False)
def test_saved_high_points_cannot_be_created_moved_increased_or_rounded_into_permission():
profiles = default_personality_profiles(False)
profiles["aggressive"]["acceleration"] = {"preset": "custom", "curve": [4.0] + [1.0] * 9}
for curve in ([4.1] + [1.0] * 9, [4.00001] + [1.0] * 9, [1.0, 4.0] + [1.0] * 8):
with pytest.raises(ValueError):
update_personality_profile(profiles, "aggressive", "acceleration", "custom", curve, False)
with pytest.raises(ValueError):
update_personality_profile(profiles, "standard", "acceleration", "custom", [4.0] + [1.0] * 9, False)
malformed = json.loads(json.dumps(profiles))
malformed["relaxed"]["following"]["curve"] = [True]
with pytest.raises(ValueError):
update_personality_profile(malformed, "aggressive", "acceleration", "custom", [4.0] + [1.0] * 9, False)
def test_disabled_document_never_resolves_an_override():
disabled = profile_document(default_personality_profiles(False), enabled=False)
for traffic, personality in ((True, 0), (False, 0), (False, 1), (False, 2)):
assert resolve_personality_profile(disabled, traffic, personality) is None
def test_context_mapping_is_traffic_first_then_cereal_zero_one_two():
assert active_personality_id(True, 99) == "traffic"
assert active_personality_id(False, 0) == "aggressive"
assert active_personality_id(False, 1) == "standard"
assert active_personality_id(False, 2) == "relaxed"
for invalid in (-1, 3, 0.5, True, False, math.nan, math.inf, "1", None):
assert active_personality_id(False, invalid) is None
for malformed_traffic in (1, 0, "1", "0", "true", "false", None):
assert active_personality_id(malformed_traffic, 0) is None
def test_enabled_document_resolves_each_profile_and_revalidates_runtime_boundary():
document = profile_document(default_personality_profiles(False), enabled=True)
assert resolve_personality_profile(document, True, 2) == document["profiles"]["traffic"]
assert resolve_personality_profile(document, False, 0) == document["profiles"]["aggressive"]
assert resolve_personality_profile(document, False, 1) == document["profiles"]["standard"]
assert resolve_personality_profile(document, False, 2) == document["profiles"]["relaxed"]
malformed = json.loads(json.dumps(document))
malformed["profiles"]["standard"]["acceleration"]["curve"] = [math.nan] * 7
assert resolve_personality_profile(malformed, False, 1) is None
assert resolve_personality_profile(document, False, 1.0) is None
def test_acceleration_presets_match_dom_curves_for_gas_ev_and_truck():
profile_ids = {
"standard": ACCELERATION_PROFILES["STANDARD"],
"eco": ACCELERATION_PROFILES["ECO"],
"sport": ACCELERATION_PROFILES["SPORT"],
"sport_plus": ACCELERATION_PROFILES["SPORT_PLUS"],
}
for ev_tuning, truck_tuning in ((False, False), (True, False), (False, True)):
for preset, profile_id in profile_ids.items():
config = {"preset": preset, "curve": []}
assert category_curve("acceleration", config, ev_tuning, truck_tuning) == get_accel_profile_curve_values(
profile_id, ev_tuning, truck_tuning
)
def test_custom_initialisation_seeds_from_selected_acceleration_preset():
current = {"preset": "sport", "curve": []}
assert initial_custom_curve("acceleration", current, ev_tuning=False, truck_tuning=False) == \
lpp._sample_config_on_custom_axis("acceleration", current, False, False)
truck_curve = lpp._sample_config_on_custom_axis("acceleration", current, False, True)
assert max(truck_curve) > CURVE_BOUNDS["acceleration"][1]
assert initial_custom_curve("acceleration", current, ev_tuning=False, truck_tuning=True) == [
min(max(value, CURVE_BOUNDS["acceleration"][0]), CURVE_BOUNDS["acceleration"][1])
for value in truck_curve
]
def test_custom_initialisation_uses_ev_over_truck_when_both_flags_are_set():
current = {"preset": "standard", "curve": []}
assert initial_custom_curve("acceleration", current, ev_tuning=True, truck_tuning=True) == \
lpp._sample_config_on_custom_axis("acceleration", current, True, False)
def test_truck_detection_accepts_live_canonical_fingerprint_identifiers():
for fingerprint in (
"RAM_1500_5TH_GEN",
"RAM_HD_5TH_GEN",
"FORD_F_150_MK14",
"FORD_MAVERICK_MK1",
"FORD_RANGER_MK2",
"CHEVROLET_SILVERADO",
"HONDA_RIDGELINE",
"HYUNDAI_SANTA_CRUZ_2025",
):
assert is_truck_fingerprint(fingerprint), fingerprint
assert not is_truck_fingerprint("HYUNDAI_SANTA_FE_2022")
assert not is_truck_fingerprint(None)
def test_custom_initialisation_seeds_braking_from_selected_preset():
assert initial_custom_curve(
"braking", {"preset": "eco", "curve": []}, ev_tuning=True, truck_tuning=True
) == [0.5] * 10
assert initial_custom_curve(
"braking", {"preset": "sport", "curve": []}, ev_tuning=False, truck_tuning=False
) == [2.0] * 10
def test_dom_default_custom_initialisation_uses_effective_legacy_curve():
legacy_curve = [1.1, 1.0, 0.9, 0.8, 0.7, 0.6, 0.5]
current = {"preset": "dom_default", "curve": []}
assert initial_custom_curve("acceleration", current, True, True, legacy_curve=legacy_curve) == [
round(lpp._linear_interp(speed, lpp._V1_ACCELERATION_SPEEDS_MPH, legacy_curve), 4)
for speed in ACCELERATION_SPEEDS_MPH
]
def test_existing_custom_curve_is_never_reseeded():
curve = [round(1.0 + index * 0.1, 4) for index in range(10)]
current = {"preset": "custom", "curve": curve}
assert initial_custom_curve("acceleration", current, True, True) == curve
def test_profile_update_is_atomic_and_accepts_bounded_following_category():
profiles = default_personality_profiles(True)
curve = [round(1.0 + index * 0.1, 4) for index in range(10)]
updated = update_personality_profile(profiles, "standard", "acceleration", "custom", curve, True, False)
assert profiles["standard"]["acceleration"]["preset"] == "dom_default"
assert updated["standard"]["acceleration"] == {"preset": "custom", "curve": curve}
following = [0.75 + index * 0.1 for index in range(10)]
updated = update_personality_profile(updated, "standard", "following", "custom", following, True, False)
assert profiles["standard"]["following"]["preset"] == "dom_default"
assert updated["standard"]["following"] == {"preset": "custom", "curve": [round(value, 4) for value in following]}
for invalid in ([0.74] * 10, [3.01] * 10, [math.nan] * 10, [True] * 10, [1.0] * 9):
with pytest.raises(ValueError):
update_personality_profile(updated, "standard", "following", "custom", invalid, True, False)
def test_serialization_requires_explicit_enabled_state_and_preserves_it():
profiles = default_personality_profiles(False)
with pytest.raises(TypeError):
serialize_personality_profiles(profiles, False)
encoded = serialize_personality_profiles(profiles, False, enabled=False)
document = json.loads(encoded)
assert document == profile_document(profiles, enabled=False)
assert strict_personality_profiles(encoded) is None
assert " " not in encoded
def test_loader_is_ui_only_fallback_and_does_not_partially_repair_persisted_document():
defaults = default_personality_profiles(False)
assert load_personality_profiles(None, False) == defaults
malformed = profile_document(defaults, enabled=True)
malformed["profiles"]["standard"]["acceleration"] = {"preset": "custom", "curve": [1.0] * 7}
malformed["profiles"]["standard"]["acceleration"]["curve"][0] = math.nan
assert load_personality_profiles(malformed, False) == defaults
assert strict_personality_profiles(malformed) is None
def test_custom_interpolation_uses_ten_mph_dom_segments_and_clamps_endpoints():
config = {"preset": "custom", "curve": [1.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0]}
breakpoints = [speed * 0.44704 for speed in ACCELERATION_SPEEDS_MPH]
assert interpolate_category_curve("acceleration", -1.0, config, False, False) == 1.0
assert interpolate_category_curve("acceleration", 2.5 * 0.44704, config, False, False) == pytest.approx(
interpolate_accel_profile(2.5 * 0.44704, config["curve"], breakpoints)
)
assert interpolate_category_curve("acceleration", 5.0 * 0.44704, config, False, False) == pytest.approx(1.5)
assert interpolate_category_curve("acceleration", 100.0, config, False, False) == pytest.approx(2.0)
def test_named_presets_are_canonical_without_unused_curve_points():
profiles = default_personality_profiles(False)
updated = update_personality_profile(profiles, "traffic", "acceleration", "eco", [], False, False)
assert updated["traffic"]["acceleration"] == {"preset": "eco", "curve": []}
document = profile_document(updated, enabled=True)
assert strict_personality_profiles(document) == updated
def test_following_presets_and_custom_curve_use_exact_ten_mph_linear_axis():
for preset, curve in FOLLOWING_PRESET_CURVES.items():
assert category_curve("following", {"preset": preset, "curve": []}, False, False) == list(curve)
assert FOLLOWING_SPEEDS_MPH == tuple(range(0, 91, 10))
config = {"preset": "custom", "curve": [0.75 + 0.1 * index for index in range(10)]}
assert interpolate_category_curve("following", 0.0, config, False, False) == pytest.approx(0.75)
assert interpolate_category_curve("following", 5.0 * 0.44704, config, False, False) == pytest.approx(0.80)
assert interpolate_category_curve("following", 90.0 * 0.44704, config, False, False) == pytest.approx(1.65)
def test_following_custom_initialisation_uses_effective_legacy_curve():
legacy_curve = [1.0 + index * 0.05 for index in range(10)]
current = {"preset": "dom_default", "curve": []}
assert initial_custom_curve("following", current, False, False, legacy_curve=legacy_curve) == legacy_curve
def test_v2_uses_one_shared_ten_mph_custom_axis():
assert PROFILE_SCHEMA_VERSION == 2
expected = tuple(range(0, 91, 10))
assert ACCELERATION_SPEEDS_MPH == expected
assert BRAKING_SPEEDS_MPH == expected
assert FOLLOWING_SPEEDS_MPH == expected
def test_fresh_profiles_start_with_dom_defaults():
profiles = default_personality_profiles(False)
for profile in profiles.values():
assert profile == {
"acceleration": {"preset": "dom_default", "curve": []},
"braking": {"preset": "dom_default", "curve": []},
"following": {"preset": "dom_default", "curve": []},
}
def test_following_presets_match_stock_dom_personalities_exactly():
assert FOLLOWING_PRESET_CURVES == {
"close": (1.25,) * 10,
"medium": (1.45,) * 10,
"far": (1.75,) * 10,
}
@pytest.mark.parametrize("category", ["acceleration", "braking"])
def test_custom_longitudinal_curves_interpolate_on_exact_ten_mph_points(category):
curve = [0.75 + index * 0.1 for index in range(10)]
config = {"preset": "custom", "curve": curve}
assert interpolate_category_curve(category, 20 * 0.44704, config, False, False) == pytest.approx(curve[2])
assert interpolate_category_curve(category, 25 * 0.44704, config, False, False) == pytest.approx((curve[2] + curve[3]) / 2)
def test_named_acceleration_presets_keep_native_dom_interpolation():
config = {"preset": "sport", "curve": []}
native_curve = get_accel_profile_curve_values(ACCELERATION_PROFILES["SPORT"], False, False)
for speed_mps in (0.0, 2.5, 7.5, 17.5, 32.0, 45.0):
assert interpolate_category_curve("acceleration", speed_mps, config, False, False) == pytest.approx(
interpolate_accel_profile(speed_mps, native_curve)
)
def test_reference_curves_are_profile_specific_and_use_the_custom_axis():
references = lpp.personality_reference_curves(False, False)
assert references["traffic"]["acceleration"] != references["aggressive"]["acceleration"]
assert references["aggressive"]["following"] == [1.25] * 10
assert references["standard"]["following"] == [1.45] * 10
assert references["relaxed"]["following"] == [1.75] * 10
for profile in references.values():
for curve in profile.values():
assert len(curve) == 10
assert all(math.isfinite(value) for value in curve)
def test_exact_v1_document_migrates_whole_or_not_at_all():
legacy_axes = {
"acceleration": {
"speed": {"unit": "mph", "values": [0.0, 11.184681, 22.369363, 33.554044, 44.738726, 55.923407, 89.477452]},
"value": {"unit": "m/s^2", "meaning": "maximum_requested_acceleration"},
},
"braking": {
"speed": {"unit": "mph", "values": [0.0, 11.184681, 22.369363, 33.554044, 44.738726, 55.923407, 89.477452]},
"value": {"unit": "m/s^2", "meaning": "cruise_slc_deceleration_magnitude"},
},
"following": {
"speed": {"unit": "mph", "values": list(range(0, 91, 10))},
"value": {"unit": "s", "meaning": "base_time_headway"},
},
}
legacy_profiles = {
personality: {
"acceleration": {"preset": "standard", "curve": []},
"braking": {"preset": "standard", "curve": []},
"following": {"preset": "medium", "curve": []},
}
for personality in PERSONALITY_IDS
}
legacy_profiles["aggressive"]["acceleration"] = {"preset": "custom", "curve": [1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0]}
legacy = {"schemaVersion": 1, "enabled": True, "axes": legacy_axes, "profiles": legacy_profiles}
migrated = lpp.migrate_profile_document(legacy)
assert migrated is not None
assert migrated["schemaVersion"] == 2
assert migrated["enabled"] is True
migrated_acceleration = migrated["profiles"]["aggressive"]["acceleration"]
assert migrated_acceleration["preset"] == "custom"
assert len(migrated_acceleration["curve"]) == 10
assert max(migrated_acceleration["curve"]) == CURVE_BOUNDS["acceleration"][1]
assert migrated_acceleration["legacyCurve"] == legacy_profiles["aggressive"]["acceleration"]["curve"]
assert interpolate_category_curve("acceleration", 40.0, migrated_acceleration, False, False) == 4.0
assert migrated["profiles"]["standard"] == legacy_profiles["standard"]
malformed = json.loads(json.dumps(legacy))
malformed["profiles"]["aggressive"]["acceleration"]["curve"][0] = True
assert lpp.migrate_profile_document(malformed) is None
def test_migrated_custom_acceleration_and_braking_preserve_v1_runtime_behaviour():
source_axis_ms = [0.0, 5.0, 10.0, 15.0, 20.0, 25.0, 40.0]
for category, legacy_curve in (
("acceleration", [1.0, 1.4, 1.8, 2.2, 2.6, 3.0, 3.4]),
("braking", [0.5, 0.65, 0.8, 0.95, 1.1, 1.25, 1.4]),
):
display_curve = [round(float(value), 4) for value in np.interp(np.array(ACCELERATION_SPEEDS_MPH) * 0.44704, source_axis_ms, legacy_curve)]
config = {"preset": "custom", "curve": display_curve, "legacyCurve": legacy_curve}
for speed_mps in np.linspace(0.0, 40.0, 161):
expected = float(np.interp(speed_mps, source_axis_ms, legacy_curve))
assert interpolate_category_curve(category, float(speed_mps), config, False, False) == pytest.approx(expected)
def test_v2_legacy_curve_is_strictly_scoped_to_valid_custom_acceleration_and_braking():
profiles = default_personality_profiles(False)
profiles["aggressive"]["acceleration"] = {
"preset": "custom", "curve": [1.0] * 10, "legacyCurve": [1.0] * 7,
}
assert lpp.strict_profile_document(profile_document(profiles, enabled=True)) is not None
invalid_named = json.loads(json.dumps(profiles))
invalid_named["aggressive"]["acceleration"] = {"preset": "sport", "curve": [], "legacyCurve": [1.0] * 7}
assert lpp.strict_profile_document(profile_document(invalid_named, enabled=True)) is None
invalid_following = json.loads(json.dumps(profiles))
invalid_following["aggressive"]["following"] = {
"preset": "custom", "curve": [1.25] * 10, "legacyCurve": [1.25] * 7,
}
assert lpp.strict_profile_document(profile_document(invalid_following, enabled=True)) is None
invalid_boolean = json.loads(json.dumps(profiles))
invalid_boolean["aggressive"]["acceleration"]["legacyCurve"][0] = True
assert lpp.strict_profile_document(profile_document(invalid_boolean, enabled=True)) is None
def test_noop_custom_update_keeps_saved_legacy_runtime_curve():
profiles = default_personality_profiles(False)
profiles["aggressive"]["acceleration"] = {
"preset": "custom", "curve": [3.5] * 10, "legacyCurve": [4.0] * 7,
}
updated = update_personality_profile(profiles, "aggressive", "acceleration", "custom", [3.5] * 10, False)
assert updated == profiles
assert interpolate_category_curve("acceleration", 10.0, updated["aggressive"]["acceleration"], False) == 4.0
def test_editing_a_migrated_custom_curve_retires_the_legacy_runtime_contract():
profiles = default_personality_profiles(False)
profiles["aggressive"]["acceleration"] = {
"preset": "custom", "curve": [1.0] * 10, "legacyCurve": [1.0] * 7,
}
updated = update_personality_profile(
profiles, "aggressive", "acceleration", "custom", [1.2] * 10, False, False,
)
assert updated["aggressive"]["acceleration"] == {"preset": "custom", "curve": [1.2] * 10}
def test_initial_custom_curve_resamples_named_preset_to_custom_axis():
curve = initial_custom_curve("acceleration", {"preset": "sport", "curve": []}, False, False)
assert len(curve) == 10
config = {"preset": "sport", "curve": []}
for speed_mph, value in zip(ACCELERATION_SPEEDS_MPH, curve, strict=True):
assert value == pytest.approx(interpolate_category_curve("acceleration", speed_mph * 0.44704, config, False, False), abs=5e-5)
@@ -1,20 +0,0 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[3]
def test_longitudinal_profiles_is_a_persistent_nonlogged_json_param():
source = (ROOT / "common/params_keys.h").read_text(encoding="utf-8")
declaration = '{"LongitudinalPersonalityProfiles", {PERSISTENT | DONT_LOG, JSON, "{}", "{}"}}'
assert declaration in source
assert source.count('{"LongitudinalPersonalityProfiles"') == 1
def test_longitudinal_profiles_is_listed_as_feasible_without_removing_legacy_keys():
feasible = (ROOT / "tools/StarPilot/feasibleparams.txt").read_text(encoding="utf-8")
keys = set(feasible.splitlines())
assert "LongitudinalPersonalityProfiles" in keys
assert {"TrafficPersonalityProfile", "AggressivePersonalityProfile", "StandardPersonalityProfile", "RelaxedPersonalityProfile"} <= keys
assert "Total globally registered C++ keys: 546" in feasible
assert "Total Editable/Toggleable targets: 391" in feasible
@@ -72,24 +72,6 @@ def test_profile_slots_round_trip_only_eligible_settings(tmp_path):
assert params.values["TransientSetting"] is False
def test_prepare_profile_decodes_without_writes_and_preserves_type_filtering(tmp_path):
params = FakeParams()
param_profiles.save_profile(params, "a", profile_root=tmp_path)
params.values.clear()
params.definitions["NumericSetting"] = (0, ParamKeyType.INT, ParamKeyFlag.PERSISTENT)
prepared = param_profiles.prepare_profile(params, "a", profile_root=tmp_path)
assert params.values == {}
assert prepared["settings"] == {"BooleanSetting": False, "JsonSetting": {"mode": "custom"}}
assert prepared["skippedCount"] == 1
assert prepared["slot"] == "a"
result = param_profiles.load_profile(params, "a", profile_root=tmp_path)
assert result["restoredCount"] == 2
assert result["skippedCount"] == 1
assert params.values == prepared["settings"]
def test_profile_slots_report_missing_and_damaged_profiles(tmp_path):
params = FakeParams()
-115
View File
@@ -1,8 +1,3 @@
import ast
from pathlib import Path
import pytest
from openpilot.common.params import UnknownKeyName
from openpilot.starpilot.common.safe_mode import (
SAFE_MODE_BACKUP_PARAM,
@@ -11,12 +6,6 @@ from openpilot.starpilot.common.safe_mode import (
restore_safe_mode,
_apply_value,
)
from openpilot.starpilot.common.longitudinal_personality_profiles import (
PERSONALITY_PROFILES_PARAM,
default_personality_profiles,
profile_document,
strict_profile_document,
)
class RemovedParamStore:
@@ -34,9 +23,6 @@ class FakeParamStore:
def get_stock_value(self, key):
return None
def get_bool(self, key):
return bool(self.values.get(key, False))
def put(self, key, value):
self.values[key] = value
@@ -81,104 +67,3 @@ def test_safe_mode_restore_ignores_stale_manual_fingerprint_backup():
restore_safe_mode(params_raw)
assert params_raw.get("ForceFingerprint") is True
def test_safe_mode_backs_up_and_enforces_a_valid_disabled_profile_document_repeatedly():
profiles = default_personality_profiles(False)
profiles["standard"]["acceleration"] = {"preset": "sport", "curve": []}
original = profile_document(profiles, enabled=True)
params = FakeParamStore()
params_raw = FakeParamStore({PERSONALITY_PROFILES_PARAM: original})
assert apply_safe_mode(params, params_raw)
safe_document = strict_profile_document(params_raw.get(PERSONALITY_PROFILES_PARAM))
assert safe_document is not None and safe_document["enabled"] is False
assert params_raw.get(SAFE_MODE_BACKUP_PARAM)[PERSONALITY_PROFILES_PARAM] == {
"present": True, "value": original,
}
params_raw.put(PERSONALITY_PROFILES_PARAM, original)
assert apply_safe_mode(params, params_raw)
assert strict_profile_document(params_raw.get(PERSONALITY_PROFILES_PARAM))["enabled"] is False
assert params_raw.get(SAFE_MODE_BACKUP_PARAM)[PERSONALITY_PROFILES_PARAM]["value"] == original
def test_safe_mode_restores_profile_document_exactly():
original = profile_document(default_personality_profiles(False), enabled=True)
params = FakeParamStore()
params_raw = FakeParamStore({
PERSONALITY_PROFILES_PARAM: original,
"IsOnroad": False,
"IsOffroad": True,
})
apply_safe_mode(params, params_raw)
assert restore_safe_mode(params_raw)
assert params_raw.get(PERSONALITY_PROFILES_PARAM) == original
assert params_raw.get(SAFE_MODE_BACKUP_PARAM) is None
def test_safe_mode_restore_waits_for_confirmed_offroad_state():
original = profile_document(default_personality_profiles(False), enabled=True)
for road_state in (
{"IsOnroad": True, "IsOffroad": True},
{"IsOnroad": False, "IsOffroad": False},
{"IsOnroad": True, "IsOffroad": False},
):
params = FakeParamStore()
params_raw = FakeParamStore({PERSONALITY_PROFILES_PARAM: original, **road_state})
apply_safe_mode(params, params_raw)
safe_document = params_raw.get(PERSONALITY_PROFILES_PARAM)
backup = params_raw.get(SAFE_MODE_BACKUP_PARAM)
assert restore_safe_mode(params_raw) is False
assert params_raw.get(PERSONALITY_PROFILES_PARAM) == safe_document
assert params_raw.get(SAFE_MODE_BACKUP_PARAM) == backup
@pytest.mark.parametrize("unreadable_key", ["IsOnroad", "IsOffroad"])
def test_safe_mode_restore_fails_closed_when_either_road_state_cannot_be_read(unreadable_key):
class UnreadableRoadStateParamStore(FakeParamStore):
def get_bool(self, key):
if key == unreadable_key:
raise RuntimeError(f"cannot read {key}")
return super().get_bool(key)
backup = {PERSONALITY_PROFILES_PARAM: {"present": True, "value": {}}}
params_raw = UnreadableRoadStateParamStore({
SAFE_MODE_BACKUP_PARAM: backup,
"IsOnroad": False,
"IsOffroad": True,
})
assert restore_safe_mode(params_raw) is False
assert params_raw.get(SAFE_MODE_BACKUP_PARAM) == backup
def test_starpilot_process_retries_restore_while_backup_remains_including_after_restart():
process_path = Path(__file__).resolve().parents[2] / "starpilot_process.py"
tree = ast.parse(process_path.read_text(encoding="utf-8"), filename=str(process_path))
function = next(
(node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name == "update_safe_mode_state"),
None,
)
assert function is not None
restore_calls = []
namespace = {
"SAFE_MODE_BACKUP_PARAM": SAFE_MODE_BACKUP_PARAM,
"safe_mode_enabled": lambda _params: False,
"apply_safe_mode": lambda *_args, **_kwargs: None,
"restore_safe_mode": lambda *_args: restore_calls.append(True),
}
exec(compile(ast.Module(body=[function], type_ignores=[]), str(process_path), "exec"), namespace)
update_safe_mode_state = namespace["update_safe_mode_state"]
backup = {PERSONALITY_PROFILES_PARAM: {"present": True, "value": {}}}
params_raw = FakeParamStore({SAFE_MODE_BACKUP_PARAM: backup})
safe_mode_active = update_safe_mode_state(None, params_raw, None, False)
safe_mode_active = update_safe_mode_state(None, params_raw, None, safe_mode_active)
assert safe_mode_active is True
assert restore_calls == [True, True]
@@ -138,11 +138,6 @@ class ConditionalChillMode:
self._write_status(self.status_value if not self.experimental_mode else CCStatus["OFF"])
def deactivate(self):
self._reset_timers()
self.experimental_mode = True
self._prev_cc_status = None
def _reset_timers(self):
self._active_auto_status = CCStatus["OFF"]
self._candidate_since = 0.0
@@ -127,17 +127,6 @@ class ConditionalExperimentalMode:
self.prev_open_road_triggered = False
self.open_road_lead_hold_until = 0.0
def deactivate(self):
self.experimental_mode = False
self.prev_experimental_mode = False
self.mode_hold_until = 0.0
self.mode_false_since = 0.0
self.slow_lead_mode_hold_until = 0.0
self.open_road_triggered = False
self.prev_open_road_triggered = False
self.open_road_lead_hold_until = 0.0
self._prev_ce_status = None
def update(self, v_ego, sm, starpilot_toggles, v_cruise=None):
now = time.monotonic()
standstill = bool(sm["carState"].standstill)
+57 -132
View File
@@ -19,7 +19,6 @@ from openpilot.starpilot.common.accel_profile import (
interpolate_accel_profile,
normalize_deceleration_profile,
)
from openpilot.starpilot.common.longitudinal_personality_profiles import active_personality_id, interpolate_category_curve, resolve_personality_category
from openpilot.starpilot.controls.lib.starpilot_vcruise import get_active_slc_control_target
def cubic_interp(x, xp, fp):
@@ -87,17 +86,12 @@ PULSE_GLIDE_COAST_MIN_ACCEL = -0.03
PULSE_GLIDE_HILL_ENTER_PITCH = math.radians(3.0)
PULSE_GLIDE_HILL_EXIT_PITCH = math.radians(2.5)
# Drive mode -> profile mapping used by the map_acceleration / map_deceleration toggles.
GEAR_STATE_PROFILES = {
"eco": (ACCELERATION_PROFILES["ECO"], DECELERATION_PROFILES["ECO"]),
"sport": (ACCELERATION_PROFILES["SPORT_PLUS"], DECELERATION_PROFILES["SPORT"]),
"normal": (ACCELERATION_PROFILES["STANDARD"], DECELERATION_PROFILES["STANDARD"]),
}
PERSONALITY_DECELERATION_PROFILES = {
"eco": DECELERATION_PROFILES["ECO"],
"standard": DECELERATION_PROFILES["STANDARD"],
"sport": DECELERATION_PROFILES["SPORT"],
"custom": DECELERATION_PROFILES["STANDARD"],
}
def get_max_accel_eco(v_ego, ev_tuning=True, truck_tuning=False):
return interpolate_accel_profile(v_ego, get_accel_profile_curve_values(ACCELERATION_PROFILES["ECO"], ev_tuning, truck_tuning))
@@ -259,123 +253,18 @@ class StarPilotAcceleration:
return self.pulse_glide_coasting
def _shape_min_accel_for_slc(self, v_ego, sm, starpilot_toggles, deceleration_profile, full_brake_floor):
raw_v_cruise_kph = 0.0 if sm["carState"].vCruise == V_CRUISE_UNSET else min(sm["carState"].vCruise, V_CRUISE_MAX)
if 0 < raw_v_cruise_kph < V_CRUISE_UNSET and getattr(starpilot_toggles, "set_speed_offset", 0) > 0:
raw_v_cruise_kph += starpilot_toggles.set_speed_offset
raw_v_cruise = raw_v_cruise_kph * CV.KPH_TO_MS
v_ego_cluster = getattr(sm["carState"], "vEgoCluster", v_ego)
if v_ego_cluster is None:
v_ego_cluster = v_ego
v_ego_cluster = max(v_ego_cluster, v_ego)
v_ego_diff = v_ego_cluster - v_ego
effective_slc_target = get_active_slc_control_target(
getattr(starpilot_toggles, "speed_limit_controller", False),
getattr(starpilot_toggles, "set_speed_limit", False),
getattr(self.starpilot_planner.starpilot_vcruise, "slc_target", 0.0),
getattr(self.starpilot_planner.starpilot_vcruise, "slc_offset", 0.0),
getattr(getattr(self.starpilot_planner.starpilot_vcruise, "slc", None), "overridden_speed", 0.0),
v_ego_diff,
allow_lower_override=(getattr(starpilot_toggles, "redneck_cruise", False) and
getattr(starpilot_toggles, "speed_limit_controller_override_set_speed", False)),
)
v_target = float(self.starpilot_planner.v_cruise or raw_v_cruise)
if effective_slc_target > 0.0:
v_target = min(v_target, effective_slc_target)
slc_limited = effective_slc_target > 0.0 and abs(v_target - effective_slc_target) <= SLC_TARGET_EPS and effective_slc_target < raw_v_cruise - SLC_TARGET_EPS
has_relevant_lead = any(lead_is_braking_relevant(lead, v_ego) for lead in (sm["radarState"].leadOne, sm["radarState"].leadTwo))
stop_context = (
sm["carState"].standstill or
getattr(sm["controlsState"], "forceDecel", False) or
getattr(self.starpilot_planner.starpilot_cem, "stop_light_detected", False) or
getattr(self.starpilot_planner.starpilot_vcruise, "forcing_stop", False) or
getattr(self.starpilot_planner.starpilot_following, "disable_throttle", False)
)
if (getattr(starpilot_toggles, "speed_limit_controller", False) and
v_ego > SLC_COAST_MIN_SPEED and
v_ego > v_target + 0.05 and
slc_limited and
not has_relevant_lead and
not stop_context):
return get_slc_shaped_min_accel(v_ego, v_target, deceleration_profile, full_brake_floor)
return full_brake_floor
def _shape_personality_min_accel_for_cruise(
self, v_ego, sm, starpilot_toggles, deceleration_profile, requested_floor, baseline_floor,
):
raw_v_cruise_kph = 0.0 if sm["carState"].vCruise == V_CRUISE_UNSET else min(sm["carState"].vCruise, V_CRUISE_MAX)
if 0 < raw_v_cruise_kph < V_CRUISE_UNSET and getattr(starpilot_toggles, "set_speed_offset", 0) > 0:
raw_v_cruise_kph += starpilot_toggles.set_speed_offset
raw_v_cruise = raw_v_cruise_kph * CV.KPH_TO_MS
v_ego_cluster = getattr(sm["carState"], "vEgoCluster", v_ego)
if v_ego_cluster is None:
v_ego_cluster = v_ego
effective_slc_target = get_active_slc_control_target(
getattr(starpilot_toggles, "speed_limit_controller", False),
getattr(starpilot_toggles, "set_speed_limit", False),
getattr(self.starpilot_planner.starpilot_vcruise, "slc_target", 0.0),
getattr(self.starpilot_planner.starpilot_vcruise, "slc_offset", 0.0),
getattr(getattr(self.starpilot_planner.starpilot_vcruise, "slc", None), "overridden_speed", 0.0),
max(v_ego_cluster, v_ego) - v_ego,
allow_lower_override=(getattr(starpilot_toggles, "redneck_cruise", False) and
getattr(starpilot_toggles, "speed_limit_controller_override_set_speed", False)),
)
v_target = float(self.starpilot_planner.v_cruise or raw_v_cruise)
if effective_slc_target > 0.0:
v_target = min(v_target, effective_slc_target)
hazard_context = (
any(getattr(lead, "status", False) for lead in (sm["radarState"].leadOne, sm["radarState"].leadTwo)) or
sm["carState"].standstill or
getattr(sm["controlsState"], "forceDecel", False) or
getattr(self.starpilot_planner.starpilot_cem, "stop_light_detected", False) or
getattr(self.starpilot_planner.starpilot_vcruise, "forcing_stop", False) or
getattr(self.starpilot_planner.starpilot_following, "disable_throttle", False)
)
if hazard_context or v_target <= 0.0 or v_ego <= v_target + 0.05:
return baseline_floor
return self._shape_min_accel_for_slc(
v_ego, sm, starpilot_toggles, deceleration_profile, requested_floor
)
def update(self, v_ego, sm, starpilot_toggles):
eco_gear = sm["starpilotCarState"].ecoGear
sport_gear = sm["starpilotCarState"].sportGear
ev_tuning = getattr(starpilot_toggles, "ev_tuning", True)
personality_ev_tuning = getattr(starpilot_toggles, "personality_ev_tuning", ev_tuning)
truck_tuning = getattr(starpilot_toggles, "truck_tuning", False)
personality_truck_tuning = (
getattr(starpilot_toggles, "personality_truck_tuning", truck_tuning) and not personality_ev_tuning
)
custom_accel_profile = getattr(starpilot_toggles, "custom_accel_profile", False)
custom_accel_profile_values = getattr(starpilot_toggles, "custom_accel_profile_values", [])
custom_accel_profile_breakpoints = getattr(starpilot_toggles, "custom_accel_profile_breakpoints", A_CRUISE_MAX_BP_CUSTOM)
deceleration_profile = normalize_deceleration_profile(
getattr(starpilot_toggles, "deceleration_profile", DECELERATION_PROFILES["STANDARD"])
)
traffic_mode = sm["starpilotCarState"].trafficModeEnabled
personality_document = getattr(starpilot_toggles, "longitudinal_personality_profiles", {})
personality_acceleration = None
personality_braking = None
personality_id = active_personality_id(traffic_mode, sm["selfdriveState"].personality)
profile_enabled = personality_id is not None and getattr(
starpilot_toggles, f"{personality_id}_personality_profile", True
)
if getattr(starpilot_toggles, "custom_personalities", False) and profile_enabled:
personality_acceleration = resolve_personality_category(
personality_document, traffic_mode, sm["selfdriveState"].personality, "acceleration"
)
personality_braking = resolve_personality_category(
personality_document, traffic_mode, sm["selfdriveState"].personality, "braking"
)
if personality_acceleration is not None and (traffic_mode or not starpilot_toggles.map_acceleration):
self.max_accel = interpolate_category_curve(
"acceleration", v_ego, personality_acceleration, personality_ev_tuning, personality_truck_tuning
)
elif traffic_mode:
if sm["starpilotCarState"].trafficModeEnabled:
self.max_accel = get_max_accel_traffic(v_ego)
elif custom_accel_profile:
self.max_accel = get_max_accel_custom(
@@ -387,6 +276,10 @@ class StarPilotAcceleration:
custom_accel_profile_breakpoints,
)
elif starpilot_toggles.map_acceleration:
# Drive mode is authoritative while mapping is on, normal gear included. Letting
# normal fall through to the profile param instead leaves the car on a stale eco
# or sport curve for the rest of the ignition cycle once the driver selects it
# again, because the param resync below cannot be observed any sooner.
if eco_gear:
self.max_accel = get_max_accel_eco(v_ego, ev_tuning, truck_tuning)
elif sport_gear:
@@ -411,24 +304,7 @@ class StarPilotAcceleration:
self.min_accel = A_CRUISE_MIN_ECO
elif pulse_glide_coasting:
self.min_accel = PULSE_GLIDE_COAST_MIN_ACCEL
elif personality_braking is not None and (traffic_mode or not starpilot_toggles.map_deceleration):
requested_floor = -interpolate_category_curve(
"braking", v_ego, personality_braking, personality_ev_tuning, personality_truck_tuning
)
baseline_floor = A_CRUISE_MIN_TRAFFIC if traffic_mode else A_CRUISE_MIN
profile_deceleration = PERSONALITY_DECELERATION_PROFILES.get(
personality_braking.get("preset"), DECELERATION_PROFILES["STANDARD"]
)
if personality_braking["preset"] != "custom" and not traffic_mode:
self.min_accel = self._shape_min_accel_for_slc(
v_ego, sm, starpilot_toggles, profile_deceleration,
get_profile_min_accel_floor(profile_deceleration),
)
else:
self.min_accel = self._shape_personality_min_accel_for_cruise(
v_ego, sm, starpilot_toggles, profile_deceleration, requested_floor, baseline_floor
)
elif traffic_mode:
elif sm["starpilotCarState"].trafficModeEnabled:
self.min_accel = A_CRUISE_MIN_TRAFFIC
elif starpilot_toggles.map_deceleration and (eco_gear or sport_gear):
if eco_gear:
@@ -437,12 +313,58 @@ class StarPilotAcceleration:
self.min_accel = A_CRUISE_MIN_SPORT
else:
if starpilot_toggles.map_deceleration:
# Same reasoning as the acceleration side, but resolved through the profile so
# normal gear keeps the SLC-shaped floor below.
deceleration_profile = DECELERATION_PROFILES["STANDARD"]
self.min_accel = get_profile_min_accel_floor(deceleration_profile)
self.min_accel = self._shape_min_accel_for_slc(v_ego, sm, starpilot_toggles, deceleration_profile, self.min_accel)
raw_v_cruise_kph = 0.0 if sm["carState"].vCruise == V_CRUISE_UNSET else min(sm["carState"].vCruise, V_CRUISE_MAX)
if 0 < raw_v_cruise_kph < V_CRUISE_UNSET and getattr(starpilot_toggles, "set_speed_offset", 0) > 0:
raw_v_cruise_kph += starpilot_toggles.set_speed_offset
raw_v_cruise = raw_v_cruise_kph * CV.KPH_TO_MS
v_ego_cluster = getattr(sm["carState"], "vEgoCluster", v_ego)
if v_ego_cluster is None:
v_ego_cluster = v_ego
v_ego_cluster = max(v_ego_cluster, v_ego)
v_ego_diff = v_ego_cluster - v_ego
effective_slc_target = get_active_slc_control_target(
getattr(starpilot_toggles, "speed_limit_controller", False),
getattr(starpilot_toggles, "set_speed_limit", False),
getattr(self.starpilot_planner.starpilot_vcruise, "slc_target", 0.0),
getattr(self.starpilot_planner.starpilot_vcruise, "slc_offset", 0.0),
getattr(getattr(self.starpilot_planner.starpilot_vcruise, "slc", None), "overridden_speed", 0.0),
v_ego_diff,
allow_lower_override=(getattr(starpilot_toggles, "redneck_cruise", False) and
getattr(starpilot_toggles, "speed_limit_controller_override_set_speed", False)),
)
v_target = float(self.starpilot_planner.v_cruise or raw_v_cruise)
if effective_slc_target > 0.0:
v_target = min(v_target, effective_slc_target)
slc_limited = effective_slc_target > 0.0 and abs(v_target - effective_slc_target) <= SLC_TARGET_EPS and effective_slc_target < raw_v_cruise - SLC_TARGET_EPS
has_relevant_lead = any(lead_is_braking_relevant(lead, v_ego) for lead in (sm["radarState"].leadOne, sm["radarState"].leadTwo))
stop_context = (
sm["carState"].standstill or
getattr(sm["controlsState"], "forceDecel", False) or
getattr(self.starpilot_planner.starpilot_cem, "stop_light_detected", False) or
getattr(self.starpilot_planner.starpilot_vcruise, "forcing_stop", False) or
getattr(self.starpilot_planner.starpilot_following, "disable_throttle", False)
)
if (getattr(starpilot_toggles, "speed_limit_controller", False) and
v_ego > SLC_COAST_MIN_SPEED and
v_ego > v_target + 0.05 and
slc_limited and
not has_relevant_lead and
not stop_context):
self.min_accel = get_slc_shaped_min_accel(v_ego, v_target, deceleration_profile, self.min_accel)
# Sync AccelerationProfile and DecelerationProfile params so the UI reflects the active drive mode
# Eco → Eco, Normal → Standard, Sport → Sport+
gear_state = "eco" if eco_gear else ("sport" if sport_gear else "normal")
mapping_enabled = starpilot_toggles.map_acceleration or starpilot_toggles.map_deceleration
# Latch only once a mapping is actually enabled. Consuming the transition while both
# toggles are still off would skip the resync for the life of the process, since gear
# state never changes again on a drive that stays in one mode.
if gear_state != self.last_gear_state and mapping_enabled:
self.last_gear_state = gear_state
mapped_acceleration_profile, mapped_deceleration_profile = GEAR_STATE_PROFILES[gear_state]
@@ -450,4 +372,7 @@ class StarPilotAcceleration:
self.params.put_nonblocking("AccelerationProfile", mapped_acceleration_profile)
if starpilot_toggles.map_deceleration:
self.params.put_nonblocking("DecelerationProfile", mapped_deceleration_profile)
# The planner reads the toggles blob rather than these params, and that blob is only
# rebuilt when this flag is set. Without it the write stays invisible until the next
# ignition cycle and the UI disagrees with what the planner is actually running.
self.params_memory.put_bool("StarPilotTogglesUpdated", True)
+11 -31
View File
@@ -7,7 +7,6 @@ from openpilot.common.realtime import DT_MDL
from openpilot.selfdrive.controls.lib.lead_behavior import should_disable_far_lead_throttle
from openpilot.selfdrive.controls.lib.longitudinal_mpc_lib.long_mpc import COMFORT_BRAKE, LEAD_DANGER_FACTOR, desired_follow_distance, get_jerk_factor, get_T_FOLLOW
from openpilot.starpilot.common.longitudinal_personality_profiles import active_personality_id, interpolate_category_curve, resolve_personality_category
from openpilot.starpilot.common.starpilot_variables import CITY_SPEED_LIMIT, MAX_T_FOLLOW
TRAFFIC_MODE_BP = [0., CITY_SPEED_LIMIT]
@@ -54,21 +53,8 @@ class StarPilotFollowing:
def update(self, long_control_active, v_ego, sm, starpilot_toggles):
personality = get_longitudinal_personality(sm)
traffic_mode = sm["starpilotCarState"].trafficModeEnabled
personality_following = None
personality_id = active_personality_id(traffic_mode, personality)
profile_enabled = personality_id is not None and getattr(
starpilot_toggles, f"{personality_id}_personality_profile", True
)
if getattr(starpilot_toggles, "custom_personalities", False) and profile_enabled:
personality_following = resolve_personality_category(
getattr(starpilot_toggles, "longitudinal_personality_profiles", {}),
traffic_mode,
personality,
"following",
)
if long_control_active and traffic_mode:
if long_control_active and sm["starpilotCarState"].trafficModeEnabled:
if sm["carState"].aEgo >= 0:
self.base_acceleration_jerk = np.interp(v_ego, TRAFFIC_MODE_BP, starpilot_toggles.traffic_mode_jerk_acceleration)
self.base_speed_jerk = np.interp(v_ego, TRAFFIC_MODE_BP, starpilot_toggles.traffic_mode_jerk_speed)
@@ -77,10 +63,7 @@ class StarPilotFollowing:
self.base_speed_jerk = np.interp(v_ego, TRAFFIC_MODE_BP, starpilot_toggles.traffic_mode_jerk_speed_decrease)
self.base_danger_jerk = np.interp(v_ego, TRAFFIC_MODE_BP, starpilot_toggles.traffic_mode_jerk_danger)
if personality_following is not None:
self.t_follow = interpolate_category_curve("following", v_ego, personality_following, False, False)
else:
self.t_follow = np.interp(v_ego, TRAFFIC_MODE_BP, starpilot_toggles.traffic_mode_follow)
self.t_follow = np.interp(v_ego, TRAFFIC_MODE_BP, starpilot_toggles.traffic_mode_follow)
elif long_control_active:
if sm["carState"].aEgo >= 0:
self.base_acceleration_jerk, self.base_danger_jerk, self.base_speed_jerk = get_jerk_factor(
@@ -97,19 +80,16 @@ class StarPilotFollowing:
starpilot_toggles.custom_personalities, personality
)
if personality_following is not None:
self.t_follow = interpolate_category_curve("following", v_ego, personality_following, False, False)
self.t_follow = get_T_FOLLOW(
starpilot_toggles.aggressive_follow,
starpilot_toggles.standard_follow,
starpilot_toggles.relaxed_follow,
starpilot_toggles.custom_personalities, personality
)
if isinstance(self.t_follow, (list, tuple)):
self.t_follow = float(np.interp(v_ego, PERSONALITY_BP, self.t_follow))
else:
self.t_follow = get_T_FOLLOW(
starpilot_toggles.aggressive_follow,
starpilot_toggles.standard_follow,
starpilot_toggles.relaxed_follow,
starpilot_toggles.custom_personalities, personality
)
if isinstance(self.t_follow, (list, tuple)):
self.t_follow = float(np.interp(v_ego, PERSONALITY_BP, self.t_follow))
else:
self.t_follow = float(self.t_follow)
self.t_follow = float(self.t_follow)
else:
self.base_acceleration_jerk = 0
self.base_danger_jerk = 0
+5 -5
View File
@@ -227,13 +227,13 @@ class StarPilotPlanner:
if conditional_tracking_active and bool(getattr(starpilot_toggles, "conditional_experimental_mode", False)):
# Keep CEM's filters warm in AOL so engagement can inherit the current scene.
self.starpilot_cem.update(v_ego, sm, starpilot_toggles, v_cruise)
self.starpilot_ccm.deactivate()
self.starpilot_ccm.experimental_mode = True
elif conditional_tracking_active and bool(getattr(starpilot_toggles, "conditional_chill_mode", False)):
self.starpilot_ccm.update(v_ego, v_cruise, sm, starpilot_toggles)
self.starpilot_cem.deactivate()
self.starpilot_cem.experimental_mode = False
else:
self.starpilot_ccm.deactivate()
self.starpilot_cem.deactivate()
self.starpilot_ccm.experimental_mode = True
self.starpilot_cem.experimental_mode = False
self.starpilot_cem.curve_detected = False
self.starpilot_cem.stop_sign_and_light(v_ego, sm, PLANNER_TIME - 2)
@@ -341,7 +341,7 @@ class StarPilotPlanner:
starpilotPlan.pulseGlideCoasting = self.starpilot_acceleration.pulse_glide_coasting
starpilotPlan.trackingLead = self.tracking_lead
conditional_experimental_mode = bool(getattr(starpilot_toggles, "experimental_mode", False))
conditional_experimental_mode = False
if starpilot_toggles.conditional_experimental_mode:
conditional_experimental_mode = self.starpilot_cem.experimental_mode
elif starpilot_toggles.conditional_chill_mode:
@@ -1,378 +0,0 @@
#!/usr/bin/env python3
import math
import numpy as np
from openpilot.common.constants import CV
from openpilot.common.params import Params
from openpilot.selfdrive.car.cruise import V_CRUISE_MAX, V_CRUISE_UNSET
from openpilot.selfdrive.controls.lib.longitudinal_planner import A_CRUISE_MIN, get_max_accel
from openpilot.starpilot.common.accel_profile import (
ACCELERATION_PROFILES,
A_CRUISE_MAX_BP_CUSTOM,
A_CRUISE_MAX_VALS_TRAFFIC_ALL,
DECELERATION_PROFILES,
coerce_custom_accel_profile_values,
get_accel_profile_curve_values,
get_max_allowed_accel as get_profile_max_allowed_accel,
interpolate_accel_profile,
normalize_deceleration_profile,
)
from openpilot.starpilot.controls.lib.starpilot_vcruise import get_active_slc_control_target
def cubic_interp(x, xp, fp):
"""Cubic interpolation using NumPy's native operations for speed."""
# Boundary conditions
if x <= xp[0]:
return fp[0]
elif x >= xp[-1]:
return fp[-1]
# Find interval
i = np.searchsorted(xp, x) - 1
i = max(0, min(i, len(xp)-2)) # clamp the index
# Normalized position
t = (x - xp[i]) / float(xp[i+1] - xp[i])
# Hermite cubic formula
return fp[i]*(1 - 3*t**2 + 2*t**3) + fp[i+1]*(3*t**2 - 2*t**3)
def akima_interp(x, xp, fp):
"""Akima-inspired interpolation with reduced overshoot characteristics."""
if x <= xp[0]:
return fp[0]
elif x >= xp[-1]:
return fp[-1]
i = np.searchsorted(xp, x) - 1
i = max(0, min(i, len(xp)-2)) # clamp the index
t = (x - xp[i]) / float(xp[i+1] - xp[i])
# Quintic polynomial to reduce overshoot
t2 = t*t
t4 = t2*t2
t3 = t2*t
return (fp[i]*(1 - 10*t3 + 15*t4 - 6*t3*t2)
+ fp[i+1]*(10*t3 - 15*t4 + 6*t3*t2))
A_CRUISE_MIN_ECO = A_CRUISE_MIN / 2
A_CRUISE_MIN_SPORT = A_CRUISE_MIN * 2
A_CRUISE_MIN_TRAFFIC = A_CRUISE_MIN * 0.35 # cruise-decel floor only; MPC lead braking keeps full ACCEL_MIN authority
SLC_COAST_WINDOW_BP = [0.0, 10.0, 20.0, 35.0]
SLC_COAST_WINDOW_BASE = [0.20, 0.40, 0.65, 1.10]
SLC_EXCESS_SCALE_BP = [0.0, 10.0, 20.0, 35.0]
SLC_EXCESS_SCALE_V = [0.8, 1.8, 3.5, 5.5]
SLC_COAST_WINDOW_MULTIPLIER = {
DECELERATION_PROFILES["ECO"]: 1.20,
DECELERATION_PROFILES["STANDARD"]: 1.00,
DECELERATION_PROFILES["SPORT"]: 0.75,
}
SLC_COAST_FLOOR = {
DECELERATION_PROFILES["ECO"]: -0.02,
DECELERATION_PROFILES["STANDARD"]: -0.03,
DECELERATION_PROFILES["SPORT"]: -0.04,
}
SLC_COAST_MIN_SPEED = 4.0
SLC_TARGET_EPS = 0.15
RELEVANT_LEAD_MIN_CLOSING_SPEED = 0.5
RELEVANT_LEAD_MIN_BRAKE = -0.4
PULSE_GLIDE_MIN_TARGET_SPEED = 5.0
PULSE_GLIDE_MIN_LOWER_SPEED = 3.0
PULSE_GLIDE_HYSTERESIS = 0.25
PULSE_GLIDE_COAST_MIN_ACCEL = -0.03
PULSE_GLIDE_HILL_ENTER_PITCH = math.radians(3.0)
PULSE_GLIDE_HILL_EXIT_PITCH = math.radians(2.5)
# Drive mode -> profile mapping used by the map_acceleration / map_deceleration toggles.
GEAR_STATE_PROFILES = {
"eco": (ACCELERATION_PROFILES["ECO"], DECELERATION_PROFILES["ECO"]),
"sport": (ACCELERATION_PROFILES["SPORT_PLUS"], DECELERATION_PROFILES["SPORT"]),
"normal": (ACCELERATION_PROFILES["STANDARD"], DECELERATION_PROFILES["STANDARD"]),
}
def get_max_accel_eco(v_ego, ev_tuning=True, truck_tuning=False):
return interpolate_accel_profile(v_ego, get_accel_profile_curve_values(ACCELERATION_PROFILES["ECO"], ev_tuning, truck_tuning))
def get_max_accel_sport(v_ego, ev_tuning=True, truck_tuning=False):
return interpolate_accel_profile(v_ego, get_accel_profile_curve_values(ACCELERATION_PROFILES["SPORT"], ev_tuning, truck_tuning))
def get_max_accel_standard(v_ego, ev_tuning=True, truck_tuning=False):
return interpolate_accel_profile(v_ego, get_accel_profile_curve_values(ACCELERATION_PROFILES["STANDARD"], ev_tuning, truck_tuning))
def get_max_accel_traffic(v_ego):
return interpolate_accel_profile(v_ego, A_CRUISE_MAX_VALS_TRAFFIC_ALL)
def get_max_accel_custom(v_ego, custom_curve, acceleration_profile, ev_tuning=True, truck_tuning=False, custom_breakpoints=None):
curve_breakpoints = A_CRUISE_MAX_BP_CUSTOM if custom_breakpoints is None else custom_breakpoints
curve_values = coerce_custom_accel_profile_values(
custom_curve,
acceleration_profile,
ev_tuning,
truck_tuning,
point_count=len(curve_breakpoints),
)
return interpolate_accel_profile(v_ego, curve_values, curve_breakpoints)
def get_max_allowed_accel(v_ego, ev_tuning=True, truck_tuning=False):
return float(get_profile_max_allowed_accel(v_ego, ev_tuning, truck_tuning))
def get_profile_min_accel_floor(deceleration_profile):
if deceleration_profile == DECELERATION_PROFILES["ECO"]:
return A_CRUISE_MIN_ECO
if deceleration_profile == DECELERATION_PROFILES["SPORT"]:
return A_CRUISE_MIN_SPORT
return A_CRUISE_MIN
def lead_is_braking_relevant(lead, v_ego):
if lead is None or not getattr(lead, "status", False):
return False
closing_speed = float(v_ego - getattr(lead, "vLead", 0.0))
if closing_speed > RELEVANT_LEAD_MIN_CLOSING_SPEED:
return True
if float(getattr(lead, "aLeadK", 0.0)) < RELEVANT_LEAD_MIN_BRAKE:
return True
return float(getattr(lead, "dRel", 1e6)) < max(18.0, 2.0 * float(v_ego))
def get_slc_shaped_min_accel(v_ego, v_target, deceleration_profile, full_brake_floor):
profile = DECELERATION_PROFILES["STANDARD"] if deceleration_profile is None else deceleration_profile
coast_floor = SLC_COAST_FLOOR.get(profile, SLC_COAST_FLOOR[DECELERATION_PROFILES["STANDARD"]])
coast_window = float(akima_interp(v_ego, SLC_COAST_WINDOW_BP, SLC_COAST_WINDOW_BASE))
coast_window *= SLC_COAST_WINDOW_MULTIPLIER.get(profile, 1.0)
excess_scale = float(akima_interp(v_ego, SLC_EXCESS_SCALE_BP, SLC_EXCESS_SCALE_V))
excess_scale = max(excess_scale, coast_window + 0.1)
excess = max(0.0, float(v_ego) - float(v_target))
if excess <= coast_window:
return coast_floor
t = float(np.clip((excess - coast_window) / (excess_scale - coast_window), 0.0, 1.0)) ** 2
return coast_floor + t * (full_brake_floor - coast_floor)
class StarPilotAcceleration:
def __init__(self, StarPilotPlanner):
self.starpilot_planner = StarPilotPlanner
self.params = Params()
self.params_memory = Params(memory=True)
self.max_accel = 0
self.min_accel = 0
self.last_gear_state = "init"
self.pulse_glide_coasting = False
self.pulse_glide_target = None
self.pulse_glide_hill_paused = False
def _update_pulse_glide_hill_pause(self, sm):
try:
orientation_ned = sm["carControl"].orientationNED
if len(orientation_ned) < 2:
return self.pulse_glide_hill_paused
abs_pitch = abs(float(orientation_ned[1]))
except (KeyError, IndexError, TypeError, ValueError, AttributeError):
return self.pulse_glide_hill_paused
if not math.isfinite(abs_pitch):
return self.pulse_glide_hill_paused
if self.pulse_glide_hill_paused:
if abs_pitch <= PULSE_GLIDE_HILL_EXIT_PITCH:
self.pulse_glide_hill_paused = False
elif abs_pitch >= PULSE_GLIDE_HILL_ENTER_PITCH:
self.pulse_glide_hill_paused = True
return self.pulse_glide_hill_paused
def _update_pulse_glide(self, v_ego, sm, starpilot_toggles):
self.pulse_glide_target = None
pulse_glide_enabled = bool(getattr(sm["starpilotCarState"], "pulseAndGlide", False))
if not pulse_glide_enabled:
self.pulse_glide_coasting = False
self.pulse_glide_hill_paused = False
return False
if self._update_pulse_glide_hill_pause(sm):
self.pulse_glide_coasting = False
return False
raw_v_cruise_kph = 0.0 if sm["carState"].vCruise == V_CRUISE_UNSET else min(sm["carState"].vCruise, V_CRUISE_MAX)
if 0 < raw_v_cruise_kph < V_CRUISE_UNSET and getattr(starpilot_toggles, "set_speed_offset", 0) > 0:
raw_v_cruise_kph += starpilot_toggles.set_speed_offset
raw_v_cruise = raw_v_cruise_kph * CV.KPH_TO_MS
if raw_v_cruise <= 0.0:
self.pulse_glide_coasting = False
return False
effective_slc_target = get_active_slc_control_target(
getattr(starpilot_toggles, "speed_limit_controller", False),
getattr(starpilot_toggles, "set_speed_limit", False),
getattr(self.starpilot_planner.starpilot_vcruise, "slc_target", 0.0),
getattr(self.starpilot_planner.starpilot_vcruise, "slc_offset", 0.0),
getattr(getattr(self.starpilot_planner.starpilot_vcruise, "slc", None), "overridden_speed", 0.0),
max(float(getattr(sm["carState"], "vEgoCluster", v_ego) or v_ego), v_ego) - v_ego,
allow_lower_override=(getattr(starpilot_toggles, "redneck_cruise", False) and
getattr(starpilot_toggles, "speed_limit_controller_override_set_speed", False)),
)
v_target = float(self.starpilot_planner.v_cruise or raw_v_cruise)
if effective_slc_target > 0.0:
v_target = min(v_target, effective_slc_target)
delta = max(0.0, float(getattr(starpilot_toggles, "pulse_glide_speed_delta", 0.0)))
lower_target = v_target - delta
if (delta <= 0.0 or
v_target <= PULSE_GLIDE_MIN_TARGET_SPEED or
lower_target < PULSE_GLIDE_MIN_LOWER_SPEED):
self.pulse_glide_coasting = False
return False
has_relevant_lead = any(lead_is_braking_relevant(lead, v_ego) for lead in (sm["radarState"].leadOne, sm["radarState"].leadTwo))
stop_context = (
sm["carState"].standstill or
getattr(sm["controlsState"], "forceDecel", False) or
getattr(self.starpilot_planner.starpilot_cem, "stop_light_detected", False) or
getattr(self.starpilot_planner.starpilot_vcruise, "forcing_stop", False) or
getattr(self.starpilot_planner.starpilot_following, "disable_throttle", False)
)
if has_relevant_lead or stop_context:
self.pulse_glide_coasting = False
return False
if self.pulse_glide_coasting:
if v_ego <= lower_target + PULSE_GLIDE_HYSTERESIS:
self.pulse_glide_coasting = False
elif v_ego >= v_target - PULSE_GLIDE_HYSTERESIS:
self.pulse_glide_coasting = True
if self.pulse_glide_coasting:
self.pulse_glide_target = lower_target
return self.pulse_glide_coasting
def update(self, v_ego, sm, starpilot_toggles):
eco_gear = sm["starpilotCarState"].ecoGear
sport_gear = sm["starpilotCarState"].sportGear
ev_tuning = getattr(starpilot_toggles, "ev_tuning", True)
truck_tuning = getattr(starpilot_toggles, "truck_tuning", False)
custom_accel_profile = getattr(starpilot_toggles, "custom_accel_profile", False)
custom_accel_profile_values = getattr(starpilot_toggles, "custom_accel_profile_values", [])
custom_accel_profile_breakpoints = getattr(starpilot_toggles, "custom_accel_profile_breakpoints", A_CRUISE_MAX_BP_CUSTOM)
deceleration_profile = normalize_deceleration_profile(
getattr(starpilot_toggles, "deceleration_profile", DECELERATION_PROFILES["STANDARD"])
)
if sm["starpilotCarState"].trafficModeEnabled:
self.max_accel = get_max_accel_traffic(v_ego)
elif custom_accel_profile:
self.max_accel = get_max_accel_custom(
v_ego,
custom_accel_profile_values,
starpilot_toggles.acceleration_profile,
ev_tuning,
truck_tuning,
custom_accel_profile_breakpoints,
)
elif starpilot_toggles.map_acceleration:
# Drive mode is authoritative while mapping is on, normal gear included. Letting
# normal fall through to the profile param instead leaves the car on a stale eco
# or sport curve for the rest of the ignition cycle once the driver selects it
# again, because the param resync below cannot be observed any sooner.
if eco_gear:
self.max_accel = get_max_accel_eco(v_ego, ev_tuning, truck_tuning)
elif sport_gear:
self.max_accel = get_max_allowed_accel(v_ego, ev_tuning, truck_tuning)
else:
self.max_accel = get_max_accel_standard(v_ego, ev_tuning, truck_tuning)
else:
if starpilot_toggles.acceleration_profile == ACCELERATION_PROFILES["ECO"]:
self.max_accel = get_max_accel_eco(v_ego, ev_tuning, truck_tuning)
elif starpilot_toggles.acceleration_profile == ACCELERATION_PROFILES["SPORT"]:
self.max_accel = get_max_accel_sport(v_ego, ev_tuning, truck_tuning)
elif starpilot_toggles.acceleration_profile == ACCELERATION_PROFILES["SPORT_PLUS"]:
self.max_accel = get_max_allowed_accel(v_ego, ev_tuning, truck_tuning)
else:
self.max_accel = get_max_accel_standard(v_ego, ev_tuning, truck_tuning)
if self.starpilot_planner.starpilot_weather.weather_id != 0:
self.max_accel -= self.max_accel * self.starpilot_planner.starpilot_weather.reduce_acceleration
pulse_glide_coasting = self._update_pulse_glide(v_ego, sm, starpilot_toggles)
if sm["starpilotCarState"].forceCoast:
self.min_accel = A_CRUISE_MIN_ECO
elif pulse_glide_coasting:
self.min_accel = PULSE_GLIDE_COAST_MIN_ACCEL
elif sm["starpilotCarState"].trafficModeEnabled:
self.min_accel = A_CRUISE_MIN_TRAFFIC
elif starpilot_toggles.map_deceleration and (eco_gear or sport_gear):
if eco_gear:
self.min_accel = A_CRUISE_MIN_ECO
else:
self.min_accel = A_CRUISE_MIN_SPORT
else:
if starpilot_toggles.map_deceleration:
# Same reasoning as the acceleration side, but resolved through the profile so
# normal gear keeps the SLC-shaped floor below.
deceleration_profile = DECELERATION_PROFILES["STANDARD"]
self.min_accel = get_profile_min_accel_floor(deceleration_profile)
raw_v_cruise_kph = 0.0 if sm["carState"].vCruise == V_CRUISE_UNSET else min(sm["carState"].vCruise, V_CRUISE_MAX)
if 0 < raw_v_cruise_kph < V_CRUISE_UNSET and getattr(starpilot_toggles, "set_speed_offset", 0) > 0:
raw_v_cruise_kph += starpilot_toggles.set_speed_offset
raw_v_cruise = raw_v_cruise_kph * CV.KPH_TO_MS
v_ego_cluster = getattr(sm["carState"], "vEgoCluster", v_ego)
if v_ego_cluster is None:
v_ego_cluster = v_ego
v_ego_cluster = max(v_ego_cluster, v_ego)
v_ego_diff = v_ego_cluster - v_ego
effective_slc_target = get_active_slc_control_target(
getattr(starpilot_toggles, "speed_limit_controller", False),
getattr(starpilot_toggles, "set_speed_limit", False),
getattr(self.starpilot_planner.starpilot_vcruise, "slc_target", 0.0),
getattr(self.starpilot_planner.starpilot_vcruise, "slc_offset", 0.0),
getattr(getattr(self.starpilot_planner.starpilot_vcruise, "slc", None), "overridden_speed", 0.0),
v_ego_diff,
allow_lower_override=(getattr(starpilot_toggles, "redneck_cruise", False) and
getattr(starpilot_toggles, "speed_limit_controller_override_set_speed", False)),
)
v_target = float(self.starpilot_planner.v_cruise or raw_v_cruise)
if effective_slc_target > 0.0:
v_target = min(v_target, effective_slc_target)
slc_limited = effective_slc_target > 0.0 and abs(v_target - effective_slc_target) <= SLC_TARGET_EPS and effective_slc_target < raw_v_cruise - SLC_TARGET_EPS
has_relevant_lead = any(lead_is_braking_relevant(lead, v_ego) for lead in (sm["radarState"].leadOne, sm["radarState"].leadTwo))
stop_context = (
sm["carState"].standstill or
getattr(sm["controlsState"], "forceDecel", False) or
getattr(self.starpilot_planner.starpilot_cem, "stop_light_detected", False) or
getattr(self.starpilot_planner.starpilot_vcruise, "forcing_stop", False) or
getattr(self.starpilot_planner.starpilot_following, "disable_throttle", False)
)
if (getattr(starpilot_toggles, "speed_limit_controller", False) and
v_ego > SLC_COAST_MIN_SPEED and
v_ego > v_target + 0.05 and
slc_limited and
not has_relevant_lead and
not stop_context):
self.min_accel = get_slc_shaped_min_accel(v_ego, v_target, deceleration_profile, self.min_accel)
# Sync AccelerationProfile and DecelerationProfile params so the UI reflects the active drive mode
# Eco → Eco, Normal → Standard, Sport → Sport+
gear_state = "eco" if eco_gear else ("sport" if sport_gear else "normal")
mapping_enabled = starpilot_toggles.map_acceleration or starpilot_toggles.map_deceleration
# Latch only once a mapping is actually enabled. Consuming the transition while both
# toggles are still off would skip the resync for the life of the process, since gear
# state never changes again on a drive that stays in one mode.
if gear_state != self.last_gear_state and mapping_enabled:
self.last_gear_state = gear_state
mapped_acceleration_profile, mapped_deceleration_profile = GEAR_STATE_PROFILES[gear_state]
if starpilot_toggles.map_acceleration:
self.params.put_nonblocking("AccelerationProfile", mapped_acceleration_profile)
if starpilot_toggles.map_deceleration:
self.params.put_nonblocking("DecelerationProfile", mapped_deceleration_profile)
# The planner reads the toggles blob rather than these params, and that blob is only
# rebuilt when this flag is set. Without it the write stays invisible until the next
# ignition cycle and the UI disagrees with what the planner is actually running.
self.params_memory.put_bool("StarPilotTogglesUpdated", True)
@@ -1,49 +0,0 @@
"""Host-only regression: real controller, explicit synthetic Params/messages.
Run separately from following tests (their native import stubs are incompatible).
The oracle is the unmodified Dom controller at 249b03a3f5, not a reimplementation.
"""
from pathlib import Path
import pytest
from test_personality_longitudinal_profiles import (
StarPilotAcceleration, _document, _planner, _sm, _toggles,
)
def _upstream():
path = Path(__file__).parent / "fixtures/dom_249b03a3_starpilot_acceleration.py.txt"
namespace = {"__name__": "dom_249b03a3_acceleration"}
exec(compile(path.read_text(), str(path), "exec"), namespace)
return namespace["StarPilotAcceleration"]
@pytest.mark.parametrize("preset,profile_id", [("eco", 1), ("standard", 0), ("sport", 2)])
@pytest.mark.parametrize("personality", [0, 1, 2])
@pytest.mark.parametrize("speed", [0.0, 5.0, 19.999, 20.0, 20.049999, 20.05, 20.050001, 25.0, 40.0])
@pytest.mark.parametrize("hazard", ["none", "lead", "force_decel"])
def test_named_braking_is_stock_dom_not_custom_overspeed_gate(preset, profile_id, personality, speed, hazard):
document = _document()
profile = ("aggressive", "standard", "relaxed")[personality]
document["profiles"][profile]["braking"] = {"preset": preset, "curve": []}
toggles = _toggles(document)
sm = _sm(personality=personality, lead=hazard == "lead", force_decel=hazard == "force_decel")
actual = StarPilotAcceleration(_planner())
actual.update(speed, sm, toggles)
toggles.custom_personalities = False
toggles.deceleration_profile = profile_id
expected = _upstream()(_planner())
expected.update(speed, sm, toggles)
assert actual.min_accel == expected.min_accel
def test_named_eco_has_no_added_threshold_cap_step():
document = _document()
document["profiles"]["standard"]["braking"] = {"preset": "eco", "curve": []}
controller = StarPilotAcceleration(_planner())
outputs = []
for speed in (20.049999, 20.050001, 20.049999):
controller.update(speed, _sm(), _toggles(document))
outputs.append(controller.min_accel)
assert outputs == [-0.5, -0.5, -0.5]
@@ -1,349 +0,0 @@
import ast
import sys
from enum import IntEnum
from pathlib import Path
from types import CodeType, FunctionType, ModuleType, SimpleNamespace
import pytest
from openpilot.starpilot.common.longitudinal_personality_profiles import default_personality_profiles, profile_document
def _module(name, **attributes):
module = ModuleType(name)
for key, value in attributes.items():
setattr(module, key, value)
return module
def _faithful_get_t_follow(
aggressive_follow=1.25, standard_follow=1.45, relaxed_follow=1.75,
custom_personalities=False, personality=1,
):
configured = (aggressive_follow, standard_follow, relaxed_follow)
defaults = (1.25, 1.45, 1.75)
return (configured if custom_personalities else defaults)[int(personality)]
class LaneChangeState(IntEnum):
off = 0
preLaneChange = 1
laneChangeStarting = 2
laneChangeFinishing = 3
class LaneChangeDirection(IntEnum):
none = 0
left = 1
right = 2
sys.modules["cereal"] = _module(
"cereal",
log=SimpleNamespace(LaneChangeState=LaneChangeState, LaneChangeDirection=LaneChangeDirection),
)
sys.modules["openpilot.common.constants"] = _module(
"openpilot.common.constants", CV=SimpleNamespace(MPH_TO_MS=0.44704),
)
sys.modules["openpilot.common.realtime"] = _module("openpilot.common.realtime", DT_MDL=0.05)
sys.modules["openpilot.selfdrive.controls.lib.lead_behavior"] = _module(
"openpilot.selfdrive.controls.lib.lead_behavior", should_disable_far_lead_throttle=lambda *_args: False,
)
sys.modules["openpilot.selfdrive.controls.lib.longitudinal_mpc_lib.long_mpc"] = _module(
"openpilot.selfdrive.controls.lib.longitudinal_mpc_lib.long_mpc",
COMFORT_BRAKE=2.5,
LEAD_DANGER_FACTOR=0.8,
desired_follow_distance=lambda v_ego, _v_lead, t_follow: v_ego * t_follow,
get_jerk_factor=lambda *_args: (1.0, 1.0, 1.0),
get_T_FOLLOW=_faithful_get_t_follow,
)
sys.modules["openpilot.starpilot.common.starpilot_variables"] = _module(
"openpilot.starpilot.common.starpilot_variables", CITY_SPEED_LIMIT=11.176, MAX_T_FOLLOW=3.0,
)
import openpilot.starpilot.controls.lib.starpilot_following as following_module
StarPilotFollowing = following_module.StarPilotFollowing
class Personality(IntEnum):
aggressive = 0
standard = 1
relaxed = 2
def _real_get_jerk_factor():
source_path = Path(__file__).resolve().parents[3] / "selfdrive/controls/lib/longitudinal_mpc_lib/long_mpc.py"
tree = ast.parse(source_path.read_text(encoding="utf-8"), filename=str(source_path))
function = next(node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name == "get_jerk_factor")
module = ast.fix_missing_locations(ast.Module(body=[function], type_ignores=[]))
module_code = compile(module, str(source_path), "exec")
function_code = next(code for code in module_code.co_consts if isinstance(code, CodeType) and code.co_name == function.name)
np_stub = SimpleNamespace(interp=lambda value, breakpoints, values: values[0] if value <= breakpoints[0] else values[-1])
return FunctionType(function_code, {"log": SimpleNamespace(LongitudinalPersonality=Personality), "np": np_stub})
def _planner(*, weather_id=0, weather_increase=0.0):
lead = SimpleNamespace(status=False, dRel=1000.0, vLead=0.0, aLeadK=0.0)
return SimpleNamespace(
lead_one=lead,
starpilot_weather=SimpleNamespace(weather_id=weather_id, increase_following_distance=weather_increase),
tracking_lead=False,
)
def _sm(*, traffic=False, personality=Personality.standard):
return {
"carState": SimpleNamespace(aEgo=0.0, standstill=False, leftBlindspot=False, rightBlindspot=False),
"selfdriveState": SimpleNamespace(personality=personality),
"starpilotCarState": SimpleNamespace(trafficModeEnabled=traffic),
}
def _toggles(document):
return SimpleNamespace(
aggressive_follow=1.25,
aggressive_jerk_acceleration=1.0,
aggressive_jerk_danger=1.0,
aggressive_jerk_deceleration=1.0,
aggressive_jerk_speed=1.0,
aggressive_jerk_speed_decrease=1.0,
conditional_slower_lead=False,
custom_personalities=True,
lane_change_close_gap=False,
lane_change_close_gap_seconds=0.75,
longitudinal_personality_profiles=document,
minimum_lane_change_speed=0.0,
personality_ev_tuning=False,
relaxed_follow=1.6,
relaxed_jerk_acceleration=1.0,
relaxed_jerk_danger=1.0,
relaxed_jerk_deceleration=1.0,
relaxed_jerk_speed=1.0,
relaxed_jerk_speed_decrease=1.0,
standard_follow=1.45,
standard_jerk_acceleration=1.0,
standard_jerk_danger=1.0,
standard_jerk_deceleration=1.0,
standard_jerk_speed=1.0,
standard_jerk_speed_decrease=1.0,
traffic_mode_follow=[0.75, 1.0],
traffic_mode_jerk_acceleration=[1.0, 1.0],
traffic_mode_jerk_danger=[1.0, 1.0],
traffic_mode_jerk_deceleration=[1.0, 1.0],
traffic_mode_jerk_speed=[1.0, 1.0],
traffic_mode_jerk_speed_decrease=[1.0, 1.0],
)
def _document(*, enabled=True):
return profile_document(default_personality_profiles(False), enabled=enabled)
def test_explicit_following_curve_selects_active_personality_and_linear_speed_point():
document = _document()
document["profiles"]["standard"]["following"] = {
"preset": "custom",
"curve": [0.75 + 0.1 * index for index in range(10)],
}
controller = StarPilotFollowing(_planner())
controller.update(True, 5.0 * 0.44704, _sm(), _toggles(document))
assert controller.t_follow == pytest.approx(0.80)
assert controller.base_acceleration_jerk == 1.0
@pytest.mark.parametrize(
("profile", "personality", "traffic_mode", "following"),
[
("aggressive", Personality.aggressive, False, 0.90),
("standard", Personality.standard, False, 1.20),
("relaxed", Personality.relaxed, False, 1.50),
("traffic", Personality.aggressive, True, 1.80),
],
)
def test_each_explicit_profile_following_override_reaches_runtime(profile, personality, traffic_mode, following):
document = _document()
document["profiles"][profile]["following"] = {"preset": "custom", "curve": [following] * 10}
controller = StarPilotFollowing(_planner())
controller.update(True, 10.0, _sm(traffic=traffic_mode, personality=personality), _toggles(document))
assert controller.t_follow == pytest.approx(following)
def test_master_toggle_disables_following_document_override():
document = _document()
document["profiles"]["standard"]["following"] = {"preset": "custom", "curve": [0.9] * 10}
toggles = _toggles(document)
toggles.custom_personalities = False
controller = StarPilotFollowing(_planner())
controller.update(True, 10.0, _sm(), toggles)
assert controller.t_follow == pytest.approx(1.45)
@pytest.mark.parametrize(
("profile", "personality", "traffic_mode", "legacy_follow"),
[
("traffic", Personality.aggressive, True, 0.75),
("aggressive", Personality.aggressive, False, 1.25),
("standard", Personality.standard, False, 1.45),
("relaxed", Personality.relaxed, False, 1.6),
],
)
def test_disabled_active_profile_keeps_legacy_following_path(profile, personality, traffic_mode, legacy_follow):
document = _document()
document["profiles"][profile]["following"] = {"preset": "custom", "curve": [0.9] * 10}
toggles = _toggles(document)
setattr(toggles, f"{profile}_personality_profile", False)
controller = StarPilotFollowing(_planner())
controller.update(True, 0.0, _sm(traffic=traffic_mode, personality=personality), toggles)
assert controller.t_follow == pytest.approx(legacy_follow)
def test_traffic_profile_wins_over_cereal_personality_without_changing_jerk():
document = _document()
document["profiles"]["traffic"]["following"] = {"preset": "far", "curve": []}
controller = StarPilotFollowing(_planner())
controller.update(True, 0.0, _sm(traffic=True, personality=Personality.aggressive), _toggles(document))
assert controller.t_follow == pytest.approx(1.75)
assert controller.base_acceleration_jerk == 1.0
@pytest.mark.parametrize("document", [None, {}, _document(enabled=False)])
def test_absent_malformed_or_disabled_document_keeps_legacy_standard_follow(document):
controller = StarPilotFollowing(_planner())
controller.update(True, 0.0, _sm(), _toggles(document))
assert controller.t_follow == pytest.approx(1.45)
def test_dom_default_category_keeps_legacy_traffic_follow():
document = _document()
document["profiles"]["traffic"]["following"] = {"preset": "dom_default", "curve": []}
controller = StarPilotFollowing(_planner())
controller.update(True, 0.0, _sm(traffic=True), _toggles(document))
assert controller.t_follow == pytest.approx(0.75)
def test_existing_weather_modifier_runs_after_profile_and_retains_maximum_bound():
document = _document()
document["profiles"]["relaxed"]["following"] = {"preset": "custom", "curve": [2.9] * 10}
controller = StarPilotFollowing(_planner(weather_id=1, weather_increase=0.5))
controller.update(True, 10.0, _sm(personality=Personality.relaxed), _toggles(document))
assert controller.t_follow == pytest.approx(3.0)
@pytest.mark.parametrize(
("personality", "prefix"),
[
(Personality.aggressive, "aggressive"),
(Personality.standard, "standard"),
(Personality.relaxed, "relaxed"),
],
)
@pytest.mark.parametrize(
("a_ego", "expected_suffix"),
[(1.0, "acceleration"), (-1.0, "deceleration")],
)
def test_every_nontraffic_advanced_jerk_value_reaches_runtime(monkeypatch, personality, prefix, a_ego, expected_suffix):
toggles = _toggles(_document())
values = {
"acceleration": 0.31,
"deceleration": 0.47,
"danger": 0.63,
"speed": 0.79,
"speed_decrease": 0.95,
}
for suffix, value in values.items():
setattr(toggles, f"{prefix}_jerk_{suffix}", value)
monkeypatch.setattr(following_module, "get_jerk_factor", _real_get_jerk_factor())
sm = _sm(personality=personality)
sm["carState"].aEgo = a_ego
controller = StarPilotFollowing(_planner())
controller.update(True, 10.0, sm, toggles)
assert controller.base_acceleration_jerk == pytest.approx(values[expected_suffix])
assert controller.base_danger_jerk == pytest.approx(values["danger"])
assert controller.base_speed_jerk == pytest.approx(values["speed" if a_ego >= 0 else "speed_decrease"])
@pytest.mark.parametrize(
("a_ego", "expected_suffix"),
[(1.0, "acceleration"), (-1.0, "deceleration")],
)
def test_every_traffic_advanced_jerk_value_reaches_low_speed_runtime(monkeypatch, a_ego, expected_suffix):
toggles = _toggles(_document())
values = {
"acceleration": 0.31,
"deceleration": 0.47,
"danger": 0.63,
"speed": 0.79,
"speed_decrease": 0.95,
}
for suffix, value in values.items():
setattr(toggles, f"traffic_mode_jerk_{suffix}", [value, 1.75])
monkeypatch.setattr(following_module, "get_jerk_factor", _real_get_jerk_factor())
sm = _sm(traffic=True, personality=Personality.standard)
sm["carState"].aEgo = a_ego
controller = StarPilotFollowing(_planner())
controller.update(True, 0.0, sm, toggles)
assert controller.base_acceleration_jerk == pytest.approx(values[expected_suffix])
assert controller.base_danger_jerk == pytest.approx(values["danger"])
assert controller.base_speed_jerk == pytest.approx(values["speed" if a_ego >= 0 else "speed_decrease"])
def test_every_advanced_param_maps_to_runtime_attribute_with_hundredth_conversion():
source_path = Path(__file__).resolve().parents[2] / "common/starpilot_variables.py"
tree = ast.parse(source_path.read_text(encoding="utf-8"), filename=str(source_path))
expected = {
f"{profile}Jerk{suffix}": f"{attribute_prefix}_jerk_{attribute_suffix}"
for profile, attribute_prefix in (
("Aggressive", "aggressive"),
("Standard", "standard"),
("Relaxed", "relaxed"),
("Traffic", "traffic_mode"),
)
for suffix, attribute_suffix in (
("Acceleration", "acceleration"),
("Deceleration", "deceleration"),
("Danger", "danger"),
("Speed", "speed"),
("SpeedDecrease", "speed_decrease"),
)
}
discovered = {}
for assignment in (node for node in ast.walk(tree) if isinstance(node, ast.Assign)):
if len(assignment.targets) != 1 or not isinstance(assignment.targets[0], ast.Attribute):
continue
target = assignment.targets[0].attr
for call in (node for node in ast.walk(assignment.value) if isinstance(node, ast.Call)):
if not call.args or not isinstance(call.args[0], ast.Constant) or call.args[0].value not in expected:
continue
discovered[call.args[0].value] = (
target,
{keyword.arg: ast.literal_eval(keyword.value) for keyword in call.keywords if keyword.arg in {"conversion", "min", "max"}},
)
assert set(discovered) == set(expected)
for key, expected_attribute in expected.items():
attribute, keywords = discovered[key]
assert attribute == expected_attribute
assert keywords["conversion"] == 0.01
assert keywords["min"] == 0.25
assert keywords["max"] == 2.0
@@ -1,270 +0,0 @@
import math
import sys
from enum import IntEnum
from types import ModuleType, SimpleNamespace
import pytest
from openpilot.starpilot.common.accel_profile import ACCELERATION_PROFILES, DECELERATION_PROFILES, get_accel_profile_curve_values
from openpilot.starpilot.common.longitudinal_personality_profiles import default_personality_profiles, profile_document
def _module(name, **attributes):
module = ModuleType(name)
for key, value in attributes.items():
setattr(module, key, value)
return module
class _Params:
def __init__(self, *args, **kwargs):
self.writes = []
def put_nonblocking(self, key, value):
self.writes.append((key, value))
def put_bool(self, key, value):
self.writes.append((key, value))
sys.modules["openpilot.common.constants"] = _module(
"openpilot.common.constants", CV=SimpleNamespace(KPH_TO_MS=1 / 3.6, MPH_TO_MS=0.44704),
)
sys.modules["openpilot.common.params"] = _module("openpilot.common.params", Params=_Params)
sys.modules["openpilot.selfdrive.car.cruise"] = _module(
"openpilot.selfdrive.car.cruise", V_CRUISE_MAX=145, V_CRUISE_UNSET=255,
)
sys.modules["openpilot.selfdrive.controls.lib.longitudinal_planner"] = _module(
"openpilot.selfdrive.controls.lib.longitudinal_planner", A_CRUISE_MIN=-1.0, get_max_accel=lambda _v_ego: 2.0,
)
sys.modules["openpilot.starpilot.controls.lib.starpilot_vcruise"] = _module(
"openpilot.starpilot.controls.lib.starpilot_vcruise",
get_active_slc_control_target=lambda enabled, set_speed_limit, target, offset, overridden_speed, *_args, **_kwargs: (
float(overridden_speed or target) + float(offset) if enabled and set_speed_limit else 0.0
),
)
from openpilot.starpilot.controls.lib.starpilot_acceleration import StarPilotAcceleration
class Personality(IntEnum):
aggressive = 0
standard = 1
relaxed = 2
def _sm(*, traffic=False, personality=Personality.standard, lead=False, force_decel=False):
lead_state = SimpleNamespace(status=lead, vLead=0.0, aLeadK=-1.0 if lead else 0.0, dRel=10.0 if lead else 1000.0)
return {
"carControl": SimpleNamespace(orientationNED=[0.0, 0.0, 0.0]),
"carState": SimpleNamespace(vCruise=80.0, vEgoCluster=0.0, standstill=False),
"controlsState": SimpleNamespace(forceDecel=force_decel),
"radarState": SimpleNamespace(leadOne=lead_state, leadTwo=SimpleNamespace(status=False, vLead=0.0, aLeadK=0.0, dRel=1000.0)),
"selfdriveState": SimpleNamespace(personality=personality),
"starpilotCarState": SimpleNamespace(
ecoGear=False, forceCoast=False, pulseAndGlide=False, sportGear=False, trafficModeEnabled=traffic,
),
}
def _planner(v_cruise=20.0):
return SimpleNamespace(
starpilot_cem=SimpleNamespace(stop_light_detected=False),
starpilot_following=SimpleNamespace(disable_throttle=False),
starpilot_vcruise=SimpleNamespace(slc_target=0.0, slc_offset=0.0, slc=SimpleNamespace(overridden_speed=0.0), forcing_stop=False),
starpilot_weather=SimpleNamespace(weather_id=0, reduce_acceleration=0.0),
v_cruise=v_cruise,
)
def _toggles(document):
return SimpleNamespace(
acceleration_profile=ACCELERATION_PROFILES["STANDARD"],
custom_accel_profile=False,
custom_accel_profile_breakpoints=[0.0, 5.0, 10.0, 15.0, 20.0, 25.0, 40.0],
custom_accel_profile_values=[],
custom_personalities=True,
deceleration_profile=DECELERATION_PROFILES["STANDARD"],
ev_tuning=False,
longitudinal_personality_profiles=document,
map_acceleration=False,
map_deceleration=False,
personality_ev_tuning=False,
personality_truck_tuning=False,
pulse_glide_speed_delta=0.0,
redneck_cruise=False,
set_speed_limit=False,
set_speed_offset=0.0,
speed_limit_controller=False,
speed_limit_controller_override_set_speed=False,
truck_tuning=False,
)
def _document(*, enabled=True):
return profile_document(default_personality_profiles(False), enabled=enabled)
@pytest.mark.parametrize(
("profile", "personality", "traffic_mode", "acceleration", "braking"),
[
("aggressive", Personality.aggressive, False, 1.10, 0.60),
("standard", Personality.standard, False, 1.25, 0.75),
("relaxed", Personality.relaxed, False, 1.40, 0.90),
("traffic", Personality.aggressive, True, 1.55, 1.05),
],
)
def test_real_enum_shaped_personality_selects_each_explicit_profile_override(
profile, personality, traffic_mode, acceleration, braking,
):
document = _document()
document["profiles"][profile]["acceleration"] = {"preset": "custom", "curve": [acceleration] * 10}
document["profiles"][profile]["braking"] = {"preset": "custom", "curve": [braking] * 10}
controller = StarPilotAcceleration(_planner(v_cruise=5.0))
controller.update(10.0, _sm(traffic=traffic_mode, personality=personality), _toggles(document))
assert controller.max_accel == pytest.approx(acceleration)
assert controller.min_accel == pytest.approx(-braking)
def test_master_toggle_disables_profile_document_overrides():
document = _document()
document["profiles"]["standard"]["acceleration"] = {"preset": "custom", "curve": [1.25] * 10}
toggles = _toggles(document)
toggles.custom_personalities = False
controller = StarPilotAcceleration(_planner())
controller.update(0.0, _sm(personality=Personality.standard), toggles)
assert controller.max_accel == pytest.approx(2.0)
@pytest.mark.parametrize(
("profile", "personality", "traffic_mode", "legacy_max_accel", "legacy_min_accel"),
[
("traffic", Personality.aggressive, True, 1.1, -0.35),
("aggressive", Personality.aggressive, False, 2.0, -1.0),
("standard", Personality.standard, False, 2.0, -1.0),
("relaxed", Personality.relaxed, False, 2.0, -1.0),
],
)
def test_disabled_active_profile_keeps_legacy_acceleration_path(
profile, personality, traffic_mode, legacy_max_accel, legacy_min_accel,
):
document = _document()
document["profiles"][profile]["acceleration"] = {"preset": "custom", "curve": [1.25] * 10}
document["profiles"][profile]["braking"] = {"preset": "custom", "curve": [0.75] * 10}
toggles = _toggles(document)
setattr(toggles, f"{profile}_personality_profile", False)
controller = StarPilotAcceleration(_planner())
controller.update(0.0, _sm(traffic=traffic_mode, personality=personality), toggles)
assert controller.max_accel == pytest.approx(legacy_max_accel)
assert controller.min_accel == pytest.approx(legacy_min_accel)
def test_detected_truck_curve_is_used_without_enabling_legacy_truck_tuning():
document = _document()
document["profiles"]["standard"]["acceleration"] = {"preset": "sport_plus", "curve": []}
toggles = _toggles(document)
toggles.personality_truck_tuning = True
controller = StarPilotAcceleration(_planner())
controller.update(0.0, _sm(personality=Personality.standard), toggles)
expected = get_accel_profile_curve_values(ACCELERATION_PROFILES["SPORT_PLUS"], False, True)[0]
assert controller.max_accel == pytest.approx(expected)
def test_fresh_profile_defaults_keep_legacy_acceleration_and_braking():
document = _document()
toggles = _toggles(document)
toggles.custom_accel_profile = True
toggles.custom_accel_profile_values = [3.0] * 7
controller = StarPilotAcceleration(_planner())
controller.update(0.0, _sm(personality=Personality.aggressive), toggles)
assert controller.max_accel == pytest.approx(3.0)
assert controller.min_accel == pytest.approx(-1.0)
controller.update(0.0, _sm(traffic=True), toggles)
assert controller.max_accel == pytest.approx(1.1)
assert controller.min_accel == pytest.approx(-0.35)
def test_absent_disabled_malformed_partial_wrong_version_and_nonfinite_use_legacy_path():
candidates = [None, {}, _document(enabled=False), _document(), _document(), _document()]
candidates[3]["schemaVersion"] = 99
del candidates[4]["profiles"]["standard"]["braking"]
candidates[5]["profiles"]["standard"]["acceleration"] = {"preset": "custom", "curve": [math.nan] * 10}
for candidate in candidates:
toggles = _toggles(candidate)
toggles.custom_accel_profile = True
toggles.custom_accel_profile_values = [3.0] * 7
controller = StarPilotAcceleration(_planner())
controller.update(0.0, _sm(), toggles)
assert controller.max_accel == pytest.approx(3.0)
assert controller.min_accel == pytest.approx(-1.0)
def test_map_gear_force_coast_and_weather_precedence_remains_explicit():
document = _document()
document["profiles"]["standard"]["acceleration"] = {"preset": "custom", "curve": [3.5] * 10}
document["profiles"]["standard"]["braking"] = {"preset": "custom", "curve": [2.0] * 10}
toggles = _toggles(document)
toggles.map_acceleration = True
toggles.map_deceleration = True
sm = _sm()
sm["starpilotCarState"].ecoGear = True
planner = _planner()
planner.starpilot_weather.weather_id = 1
planner.starpilot_weather.reduce_acceleration = 0.25
controller = StarPilotAcceleration(planner)
controller.update(0.0, sm, toggles)
assert controller.max_accel == pytest.approx(1.5 * 0.75)
assert controller.min_accel == pytest.approx(-0.5)
assert all(key != "LongitudinalPersonalityProfiles" for key, _value in controller.params.writes)
sm["starpilotCarState"].forceCoast = True
controller.update(0.0, sm, toggles)
assert controller.min_accel == pytest.approx(-0.5)
def test_custom_acceleration_and_braking_use_the_selected_twenty_mph_point():
document = _document()
document["profiles"]["standard"]["acceleration"] = {
"preset": "custom", "curve": [1.0 + 0.1 * index for index in range(10)],
}
document["profiles"]["standard"]["braking"] = {
"preset": "custom", "curve": [0.75 + 0.1 * index for index in range(10)],
}
controller = StarPilotAcceleration(_planner(v_cruise=5.0))
controller.update(20.0 * 0.44704, _sm(), _toggles(document))
assert controller.max_accel == pytest.approx(1.2)
assert controller.min_accel == pytest.approx(-0.95)
def test_custom_braking_only_shapes_explicit_cruise_deceleration_and_never_reduces_hazard_authority():
document = _document()
document["profiles"]["standard"]["braking"] = {"preset": "custom", "curve": [0.5] * 10}
toggles = _toggles(document)
controller = StarPilotAcceleration(_planner(v_cruise=30.0))
controller.update(10.0, _sm(lead=False), toggles)
assert controller.min_accel <= -1.0
controller = StarPilotAcceleration(_planner(v_cruise=5.0))
controller.update(10.0, _sm(lead=False), toggles)
assert controller.min_accel == pytest.approx(-0.5)
controller.update(10.0, _sm(lead=True), toggles)
assert controller.min_accel <= -1.0
controller.update(10.0, _sm(force_decel=True), toggles)
assert controller.min_accel <= -1.0
@@ -1,102 +0,0 @@
"""Contracts, not comfort claims. These exercise real Python bodies, no solver."""
import ast
from pathlib import Path
from types import SimpleNamespace
import numpy as np
import pytest
from test_personality_longitudinal_profiles import StarPilotAcceleration, _document, _planner, _sm, _toggles
from openpilot.starpilot.common.longitudinal_personality_profiles import interpolate_category_curve
ROOT = Path(__file__).resolve().parents[3]
def _method(relative_path, class_name, name):
tree = ast.parse((ROOT / relative_path).read_text())
scope = {'np': np}
# Only literal/numeric top-level constants, never imports/native initialisation.
for node in tree.body:
if isinstance(node, ast.Assign):
try:
exec(compile(ast.Module(body=[node], type_ignores=[]), '<source-constant>', 'exec'), scope)
except (NameError, AttributeError, TypeError):
pass
cls = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == class_name)
method = next(n for n in cls.body if isinstance(n, ast.FunctionDef) and n.name == name)
scope['DT_MDL'] = .05
exec(compile(ast.Module(body=[method], type_ignores=[]), '<real-source-method>', 'exec'), scope)
return scope[name]
@pytest.mark.parametrize('preset', ['eco', 'sport'])
def test_named_lead_presence_transition_matches_stock_floor(preset):
doc = _document(); doc['profiles']['standard']['braking'] = {'preset': preset, 'curve': []}
c = StarPilotAcceleration(_planner(v_cruise=5.0))
outputs = []
for lead in (False, True, False):
c.update(10.0, _sm(lead=lead), _toggles(doc)); outputs.append(c.min_accel)
assert outputs == ([-.5] * 3 if preset == 'eco' else [-2.] * 3)
def test_saved_custom_gate_is_preserved_not_silently_retuned():
doc = _document(); doc['profiles']['standard']['braking'] = {'preset': 'custom', 'curve': [.5] * 10}
c = StarPilotAcceleration(_planner())
outputs = []
for speed, lead in [(20.049999, False), (20.050001, False), (20.050001, True), (20.050001, False)]:
c.update(speed, _sm(lead=lead), _toggles(doc)); outputs.append(c.min_accel)
assert outputs == [-1., -.5, -1., -.5]
def test_curve_switch_and_reloaded_document_take_effect_next_update_without_hidden_filter():
doc = _document(); c = StarPilotAcceleration(_planner()); t = _toggles(doc)
c.update(0., _sm(), t); assert c.max_accel == 2.
replacement = _document(); replacement['profiles']['standard']['acceleration'] = {'preset': 'sport_plus', 'curve': []}
t.longitudinal_personality_profiles = replacement
c.update(0., _sm(), t); assert c.max_accel == 3.5
# Original saved document is not mutated by resolving another document.
assert doc['profiles']['standard']['acceleration']['preset'] == 'dom_default'
t.custom_personalities = False
c.update(0., _sm(), t); assert c.max_accel == 2.
@pytest.mark.parametrize('bad', [float('nan'), float('inf'), -float('inf'), True])
def test_profile_curve_nonfinite_speed_is_explicitly_rejected(bad):
with pytest.raises(ValueError):
interpolate_category_curve('acceleration', bad, {'preset': 'eco', 'curve': []}, True)
@pytest.mark.parametrize('mode', [(False, False), (True, False), (False, True)])
@pytest.mark.parametrize('preset', ['eco', 'standard', 'sport', 'sport_plus'])
def test_named_acceleration_continuous_bounded_and_no_overshoot(mode, preset):
ev, truck = mode
config = {'preset': preset, 'curve': []}
axis = [0., 5., 10., 15., 20., 25., 40.]
for lo, hi in zip(axis[:-1], axis[1:]):
ends = [interpolate_category_curve('acceleration', v, config, ev, truck) for v in (lo, hi)]
samples = [interpolate_category_curve('acceleration', v, config, ev, truck) for v in np.linspace(lo, hi, 201)]
assert all(np.isfinite(v) and min(ends) - 1e-12 <= v <= max(ends) + 1e-12 and v >= 0 for v in samples)
for v in axis:
left = interpolate_category_curve('acceleration', v - 1e-6, config, ev, truck)
right = interpolate_category_curve('acceleration', v + 1e-6, config, ev, truck)
assert abs(right - left) < 1e-10
def test_existing_headway_limiters_do_not_imply_symmetric_personality_slew():
lane = _method('starpilot/controls/lib/starpilot_following.py', 'StarPilotFollowing', 'update_lane_change_gap')
dynamic = _method('selfdrive/controls/lib/longitudinal_planner.py', 'LongitudinalPlanner', 'get_dynamic_t_follow')
state = SimpleNamespace(t_follow=1.75, lane_change_t_follow=None)
downstream = SimpleNamespace(effective_t_follow=None, dt=.05)
result = []
for base in [1.75, 1.25, 1.75]:
state.t_follow = base
lane(state, True, 20., {}, SimpleNamespace(lane_change_close_gap=False))
effective = dynamic(downstream, state.t_follow, None, 20.)
result.append((state.t_follow, effective))
assert result[0] == (1.75, 1.75)
# min(base, lane-ramp) allows a shorter base immediately; downstream dynamic
# follow retains and slowly releases its previous larger value.
assert result[1][0] == 1.25
assert 1.25 < result[1][1] < 1.75
# Do not add another filter without defining interaction with both existing ones.
assert result[2][1] >= result[2][0]
+10 -25
View File
@@ -26,7 +26,6 @@ from openpilot.starpilot.assets.model_manager import (
from openpilot.starpilot.assets.theme_manager import THEME_COMPONENT_PARAMS, ThemeManager
from openpilot.starpilot.common.starpilot_functions import update_maps, update_openpilot
from openpilot.starpilot.common.safe_mode import (
SAFE_MODE_BACKUP_PARAM,
SAFE_MODE_ENFORCE_FRAMES,
apply_safe_mode,
restore_safe_mode,
@@ -256,25 +255,8 @@ def update_toggles_in_background(result, starpilot_variables, started, theme_man
result["update"] = (updated_variables, updated_toggles)
except Exception:
result["failed"] = True
starpilot_variables.params_memory.put_bool("StarPilotTogglesUpdated", True)
raise
def update_safe_mode_state(params, params_raw, params_memory, safe_mode_active, *, enforce=False):
current_safe_mode = safe_mode_enabled(params_raw)
restore_pending = not current_safe_mode and params_raw.get(SAFE_MODE_BACKUP_PARAM) is not None
if current_safe_mode != safe_mode_active or restore_pending:
if current_safe_mode:
apply_safe_mode(params, params_raw, params_memory)
return True
restore_safe_mode(params_raw, params_memory)
return params_raw.get(SAFE_MODE_BACKUP_PARAM) is not None
if current_safe_mode and enforce:
apply_safe_mode(params, params_raw, params_memory, ensure_backup=False)
return safe_mode_active
def starpilot_thread():
rate_keeper = Ratekeeper(1 / DT_MDL, None)
@@ -398,13 +380,16 @@ def starpilot_thread():
if rate_keeper.frame % ASSET_CHECK_RATE == 0:
check_assets(now, model_manager, theme_manager, thread_manager, params, params_memory, starpilot_toggles)
safe_mode_active = update_safe_mode_state(
params,
params_raw,
params_memory,
safe_mode_active,
enforce=(params_memory.get_bool("StarPilotTogglesUpdated") or rate_keeper.frame % SAFE_MODE_ENFORCE_FRAMES == 0),
)
current_safe_mode = safe_mode_enabled(params_raw)
safe_mode_changed = current_safe_mode != safe_mode_active
if safe_mode_changed:
if current_safe_mode:
apply_safe_mode(params, params_raw, params_memory)
else:
restore_safe_mode(params_raw, params_memory)
safe_mode_active = current_safe_mode
elif current_safe_mode and (params_memory.get_bool("StarPilotTogglesUpdated") or rate_keeper.frame % SAFE_MODE_ENFORCE_FRAMES == 0):
apply_safe_mode(params, params_raw, params_memory, ensure_backup=False)
completed_toggle_update = toggle_update_result.pop("update", None)
if completed_toggle_update is not None:
@@ -4,43 +4,6 @@
padding: var(--padding-base) var(--padding-lg) var(--padding-xxl);
}
.ds-dev-mode-notice {
align-items: center;
background: var(--input-bg);
border: var(--border-style-main);
border-radius: var(--border-radius-lg);
color: var(--text-color);
display: flex;
gap: var(--padding-sm);
margin-bottom: var(--margin-lg);
padding: var(--padding-sm) var(--padding-base);
}
.ds-dev-mode-notice > i {
color: var(--main-fg);
flex: 0 0 auto;
}
.ds-dev-mode-notice > span {
flex: 1;
}
.ds-dev-mode-notice-btn {
background: var(--main-fg);
border: 0;
border-radius: var(--border-radius-base);
color: var(--color-black);
cursor: pointer;
font-family: var(--font-body);
font-size: var(--font-size-sm);
padding: 0.55rem 0.8rem;
white-space: nowrap;
}
.ds-dev-mode-notice-btn:hover {
filter: brightness(1.08);
}
/* ――― Section Tabs ――― */
.ds-tabs {
display: flex;
@@ -263,10 +226,6 @@
}
/* ――― Child Row Modifier (Sub-menus) ――― */
.ds-setting-children {
display: contents;
}
.ds-child-modifier {
border-left: 2px solid var(--color-gray-200);
margin-left: 1rem;
@@ -808,530 +767,6 @@
min-width: 0;
}
/* ――― Driving Personality Profiles ――― */
.ds-personality-profiles {
container-type: inline-size;
display: grid;
gap: var(--gap-base);
margin: 0.75rem 0 0 1rem;
padding-left: 1rem;
border-left: 2px solid var(--color-gray-200);
}
.ds-personality-card {
container-type: inline-size;
--personality-accent: #38bdf8;
--personality-accent-bg: rgba(56, 189, 248, 0.12);
--personality-accent-border: rgba(56, 189, 248, 0.35);
background: rgba(3, 7, 18, 0.48);
border: 1px solid var(--sidebar-border-color);
border-radius: 0.75rem;
overflow: hidden;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.ds-personality-card[data-profile="traffic"] {
--personality-accent: #fbbf24;
--personality-accent-bg: rgba(251, 191, 36, 0.12);
--personality-accent-border: rgba(251, 191, 36, 0.4);
}
.ds-personality-card[data-profile="aggressive"] {
--personality-accent: #fb7185;
--personality-accent-bg: rgba(251, 113, 133, 0.12);
--personality-accent-border: rgba(251, 113, 133, 0.4);
}
.ds-personality-card[data-profile="standard"] {
--personality-accent: #38bdf8;
--personality-accent-bg: rgba(56, 189, 248, 0.12);
--personality-accent-border: rgba(56, 189, 248, 0.4);
}
.ds-personality-card[data-profile="relaxed"] {
--personality-accent: #4ade80;
--personality-accent-bg: rgba(74, 222, 128, 0.12);
--personality-accent-border: rgba(74, 222, 128, 0.4);
}
.ds-personality-summary {
gap: 0.75rem;
justify-content: space-between;
flex-wrap: wrap;
align-items: center;
background: transparent;
box-sizing: border-box;
color: var(--text-color);
display: flex;
min-width: 0;
padding: 0.65rem 0.75rem;
text-align: left;
width: 100%;
}
.ds-personality-summary .ds-personality-profile-toggle {
border: 0;
padding: 0;
margin: 0;
width: auto;
gap: 0.5rem;
flex: 0 1 auto;
min-width: 0;
}
@container (min-width: 1100px) {
.ds-personality-card {
display: grid;
grid-template-columns: minmax(190px, 1fr) minmax(0, 4fr);
align-items: start;
}
.ds-personality-card > .ds-personality-body {
border-top: 0;
border-left: 1px solid var(--sidebar-border-color);
min-width: 0;
padding: 0.65rem 0.75rem;
}
}
.ds-personality-name {
align-items: center;
display: flex;
gap: 0.75rem;
min-width: 0;
}
.ds-personality-name h3 {
font-size: var(--font-size-base);
margin: 0;
}
.ds-personality-disclosure {
align-items: center;
background: var(--input-bg);
border: 1px solid var(--sidebar-border-color);
border-radius: 0.45rem;
color: var(--text-muted);
cursor: pointer;
display: inline-flex;
font: inherit;
font-size: 0.68rem;
font-weight: var(--font-weight-semibold);
gap: 0.45rem;
justify-content: center;
margin-top: 0.5rem;
padding: 0.5rem 0.65rem;
width: 100%;
}
.ds-personality-primary {
display: grid;
gap: 0.5rem;
padding: 0 0.75rem 0.75rem;
}
.ds-visually-hidden {
clip: rect(0 0 0 0);
clip-path: inset(50%);
height: 1px;
overflow: hidden;
position: absolute;
white-space: nowrap;
width: 1px;
}
.ds-personality-name strong,
.ds-personality-name small {
display: block;
}
.ds-personality-name small {
color: var(--text-muted);
font-size: 0.7rem;
margin-top: 0.15rem;
}
.ds-personality-badge {
align-items: center;
background: var(--personality-accent-bg);
border: 1px solid var(--personality-accent-border);
border-radius: 0.55rem;
color: var(--personality-accent);
display: inline-flex;
flex: 0 0 2.1rem;
font-size: 0.75rem;
font-weight: var(--font-weight-bold);
height: 2.1rem;
justify-content: center;
}
.ds-personality-body {
border-top: 1px solid var(--sidebar-border-color);
display: grid;
gap: var(--gap-base);
padding: 1rem;
}
.ds-personality-settings {
display: grid;
gap: 0.5rem;
}
.ds-personality-settings[hidden],
.ds-personality-disabled-note[hidden] {
display: none;
}
.ds-personality-fields {
display: grid;
gap: 0.5rem;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr));
}
.ds-personality-field {
background: var(--input-bg);
border: 1px solid var(--sidebar-border-color);
border-radius: var(--border-radius-base);
display: flex;
flex-direction: column;
gap: 0.4rem;
justify-content: space-between;
margin: 0;
min-width: 0;
padding: 0.55rem;
}
.ds-personality-field h4 {
color: var(--text-color);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
margin: 0;
}
.ds-personality-field p {
color: var(--text-muted);
font-size: 0.68rem;
line-height: 1.35;
margin: 0;
}
.ds-personality-options {
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 0.45rem;
display: flex;
gap: 0;
overflow: hidden;
}
.ds-personality-option {
background: rgba(15, 23, 42, 0.72);
border: 0;
border-left: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 0;
color: var(--text-muted);
cursor: pointer;
flex: 1 1 auto;
font: inherit;
font-size: 0.64rem;
font-weight: var(--font-weight-semibold);
min-height: 1.9rem;
min-width: 0;
padding: 0.3rem 0.25rem;
transition: background var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast);
white-space: nowrap;
}
.ds-personality-option:first-child {
border-left: 0;
}
.ds-personality-option:hover:not(:disabled) {
background: rgba(51, 65, 85, 0.72);
color: var(--text-color);
}
.ds-personality-option[aria-pressed="true"] {
background: rgba(226, 232, 240, 0.14);
border-color: rgba(226, 232, 240, 0.72);
color: #f8fafc;
}
.ds-personality-option:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.ds-personality-option:focus-visible,
.ds-personality-advanced > button:focus-visible,
.ds-personality-advanced-choice:focus-visible,
.ds-personality-value input:focus-visible,
.ds-personality-custom-number input:focus-visible {
outline: 2px solid #e2e8f0;
outline-offset: 2px;
}
.ds-personality-curve {
background: rgba(15, 23, 42, 0.66);
border: 1px solid var(--sidebar-border-color);
border-radius: var(--border-radius-base);
padding: 0.85rem;
}
.ds-personality-curve-head {
align-items: flex-start;
display: flex;
gap: var(--gap-base);
justify-content: space-between;
margin-bottom: 0.65rem;
}
.ds-personality-curve-head h4 {
color: var(--text-color);
font-size: var(--font-size-sm);
margin: 0;
}
.ds-personality-curve-head p,
.ds-personality-curve-note {
color: var(--text-muted);
font-size: 0.68rem;
line-height: 1.4;
margin: 0.18rem 0 0;
}
.ds-personality-reference-key {
align-items: center;
color: var(--text-muted);
display: flex;
font-size: 0.68rem;
gap: 0.42rem;
margin-top: 0.38rem;
}
.ds-personality-reference-key > span {
border-top: 2px dashed rgba(226, 232, 240, 0.34);
display: inline-block;
width: 1.7rem;
}
.ds-personality-curve-actions {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
justify-content: flex-end;
}
.ds-personality-curve-actions button:disabled {
cursor: not-allowed;
opacity: var(--disabled-opacity);
}
.ds-personality-graph-layout {
display: grid;
gap: 0.75rem;
grid-template-columns: minmax(0, 1fr) 190px;
}
.ds-personality-chart {
background: rgba(2, 6, 23, 0.55);
border: 1px solid var(--sidebar-border-color);
border-radius: 0.5rem;
box-sizing: border-box;
height: 240px;
min-width: 0;
touch-action: none;
width: 100%;
}
.ds-personality-grid-line {
stroke: rgba(148, 163, 184, 0.13);
stroke-width: 1;
}
.ds-personality-axis-label {
fill: var(--text-muted);
font-family: var(--font-body);
font-size: 9px;
}
.ds-personality-curve-area {
fill: rgba(56, 189, 248, 0.10);
}
.ds-personality-curve-line {
fill: none;
stroke: #38bdf8;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 3;
}
.ds-personality-curve-point {
cursor: ns-resize;
fill: #07111d;
outline: none;
stroke: #7dd3fc;
stroke-width: 3;
}
.ds-personality-curve-point:hover,
.ds-personality-curve-point:focus {
fill: #38bdf8;
stroke: #e0f2fe;
}
.ds-personality-values {
display: grid;
gap: 0.35rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
max-height: 245px;
overflow-y: auto;
}
.ds-personality-value {
background: var(--input-bg);
border: 1px solid var(--sidebar-border-color);
border-radius: 0.4rem;
display: grid;
gap: 0.15rem;
padding: 0.35rem 0.45rem;
}
.ds-personality-value span {
color: var(--text-muted);
font-size: 0.62rem;
}
.ds-personality-value input {
background: transparent;
border: 0;
color: var(--text-color);
font-family: var(--font-body);
font-size: 0.78rem;
min-width: 0;
outline: none;
width: 100%;
}
.ds-personality-value b {
display: none;
}
.ds-personality-advanced {
border-top: 1px solid var(--sidebar-border-color);
padding-top: 0.6rem;
}
.ds-personality-advanced > .ds-manage-btn {
margin-top: 0;
}
.ds-personality-advanced-rows {
display: grid;
gap: 0.55rem;
margin-top: 0.45rem;
}
.ds-personality-advanced-rows[hidden],
.ds-personality-custom-number[hidden] {
display: none;
}
.ds-personality-warning {
background: rgba(245, 158, 11, 0.08);
border: 1px solid rgba(245, 158, 11, 0.32);
border-radius: var(--border-radius-base);
color: #fcd34d;
font-size: 0.7rem;
line-height: 1.4;
padding: 0.65rem 0.75rem;
}
.ds-personality-advanced-value {
align-items: center;
background: var(--input-bg);
border: 1px solid var(--sidebar-border-color);
border-radius: var(--border-radius-base);
display: grid;
gap: 0.75rem;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr);
min-width: 0;
padding: 0.7rem 0.75rem;
}
.ds-personality-advanced-copy {
display: flex;
flex-direction: column;
gap: 0.2rem;
min-width: 0;
}
.ds-personality-advanced-copy strong {
color: var(--text-color);
font-size: 0.75rem;
}
.ds-personality-advanced-copy small,
.ds-personality-custom-number span {
color: var(--text-muted);
font-size: 0.65rem;
line-height: 1.35;
}
.ds-personality-advanced-control {
display: grid;
gap: 0.45rem;
min-width: 0;
}
.ds-personality-advanced-options {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.ds-personality-custom-number {
align-items: center;
display: flex;
gap: 0.5rem;
justify-content: flex-end;
min-width: 0;
}
.ds-personality-custom-number span {
flex: 0 0 auto;
white-space: nowrap;
}
.ds-personality-custom-number input {
background: rgba(15, 23, 42, 0.75);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: 0.4rem;
box-sizing: border-box;
color: var(--text-color);
font: inherit;
max-width: 100%;
min-width: 0;
padding: 0.42rem 0.5rem;
width: 7rem;
}
.ds-personality-error {
border: 1px solid rgba(248, 113, 113, 0.4);
border-radius: var(--border-radius-base);
color: #fca5a5;
margin: 0.75rem 0 0 1rem;
padding: 0.8rem;
}
.ds-personality-migration-warning {
background: rgba(245, 158, 11, 0.08);
border: 1px solid rgba(245, 158, 11, 0.42);
border-radius: var(--border-radius-base);
color: #fcd34d;
font-size: 0.78rem;
line-height: 1.45;
padding: 0.75rem 0.9rem;
}
/* ――― Mobile ――― */
@media only screen and (max-width: 768px) and (orientation: portrait) {
.ds-wrapper {
@@ -1368,34 +803,4 @@
.ds-favorite-switch {
justify-content: space-between;
}
.ds-personality-profiles {
margin-left: 0;
padding-left: 0;
border-left: 0;
}
}
/* Respond to the card's available space, including landscape and embeds. */
@container (max-width: 850px) {
.ds-personality-graph-layout,
.ds-personality-advanced-value {
grid-template-columns: minmax(0, 1fr);
}
.ds-personality-values {
grid-template-columns: repeat(auto-fit, minmax(90px, 1fr));
max-height: none;
}
}
@container (max-width: 400px) {
.ds-personality-curve-head {
flex-wrap: wrap;
}
.ds-personality-curve-actions {
justify-content: flex-start;
}
}
File diff suppressed because it is too large Load Diff
@@ -1,43 +0,0 @@
export const LONGITUDINAL_MODE_KEY = "LongitudinalControlMode"
export const LONGITUDINAL_MODES = [
{ value: "chill", label: "Chill" },
{ value: "experimental", label: "Experimental" },
{ value: "conditional_experimental", label: "Conditional Experimental" },
{ value: "conditional_chill", label: "Conditional Chill" },
]
export function longitudinalModeLayout(layout) {
return layout.map(section => {
const params = section.params || []
if (!params.some(p => p.key === "ConditionalExperimental")) return section
const byKey = new Map(params.map(p => [p.key, p]))
function owner(p) {
const seen = new Set()
while (p?.parent_key && !seen.has(p.parent_key)) {
if (p.parent_key === "ConditionalExperimental") return "conditional_experimental"
if (p.parent_key === "ConditionalChill") return "conditional_chill"
seen.add(p.parent_key)
p = byKey.get(p.parent_key)
}
return null
}
return { ...section, params: params.flatMap(p => {
if (p.key === "ConditionalChill" || p.key === "ExperimentalMode") return []
if (p.key === "ConditionalExperimental") return [{
key: LONGITUDINAL_MODE_KEY, label: "Longitudinal control mode", data_type: "string",
ui_type: "dropdown", settings_tier: "simple", options: LONGITUDINAL_MODES, is_parent_toggle: true,
description: "Chill: conventional speed control. Experimental: model-controlled gas and brakes. Conditional Experimental: Chill, switching to Experimental under your chosen conditions. Conditional Chill: Experimental, switching to Chill for simple cruising.",
}]
const mode = owner(p)
return [{ ...p, ...(mode ? { longitudinal_mode: mode, settings_tier: "simple" } : {}),
...(["ConditionalExperimental", "ConditionalChill"].includes(p.parent_key) ? { parent_key: LONGITUDINAL_MODE_KEY } : {}) }]
}) }
})
}
export function validLongitudinalSnapshot(data) {
return !!data && LONGITUDINAL_MODES.some(mode => mode.value === data.mode) &&
typeof data.locked === "boolean" && typeof data.reason === "string" &&
typeof data.experimental_confirmed === "boolean" &&
["ExperimentalMode", "ConditionalExperimental", "ConditionalChill"].every(key => typeof data.values?.[key] === "boolean")
}
@@ -1,37 +0,0 @@
export function formatProfileSpeed(speedMph, isMetric) {
const numeric = Number(speedMph);
if (!Number.isFinite(numeric)) return "—";
if (!isMetric) return Number.isInteger(numeric) ? String(numeric) : numeric.toFixed(1).replace(/\.0$/, "");
return (numeric * 1.609344).toFixed(1).replace(/\.0$/, "");
}
export function profileSpeedUnit(isMetric) {
return isMetric ? "km/h" : "mph";
}
const PERSONALITY_PROFILE_PARAM_KEYS = Object.freeze({
traffic: "TrafficPersonalityProfile",
aggressive: "AggressivePersonalityProfile",
standard: "StandardPersonalityProfile",
relaxed: "RelaxedPersonalityProfile",
});
export function personalityProfileParamKey(profileId) {
return PERSONALITY_PROFILE_PARAM_KEYS[String(profileId || "")] || "";
}
export function shouldSubmitPersonalityPreset(currentPreset, selectedPreset) {
return String(currentPreset || "") !== String(selectedPreset || "");
}
export function valueFromPointer(clientY, rect, minimum, maximum, step) {
const height = Number(rect?.height);
const top = Number(rect?.top);
if (!Number.isFinite(clientY) || !Number.isFinite(height) || height <= 0 || !Number.isFinite(top)) {
return Number(minimum);
}
const ratio = Math.max(0, Math.min(1, 1 - ((clientY - top) / height)));
const raw = Number(minimum) + ratio * (Number(maximum) - Number(minimum));
const snapped = Math.round(raw / Number(step)) * Number(step);
return Number(Math.max(Number(minimum), Math.min(Number(maximum), snapped)).toFixed(4));
}
@@ -16,9 +16,6 @@ function showSnackbar(msg, level, timeout = 3500, options = {}) {
const setSnackbarContent = (snackbar) => {
snackbar.innerHTML = msg
snackbar.className = "snackbar show"
snackbar.setAttribute("role", level === "error" ? "alert" : "status")
snackbar.setAttribute("aria-live", level === "error" ? "assertive" : "polite")
snackbar.setAttribute("aria-atomic", "true")
if (level === "error") {
snackbar.style.backgroundColor = "#f44336"
} else {
@@ -1,30 +1,3 @@
.gx-personalities { overflow-anchor: none; }
#gx-personality-settings { padding: 0 var(--sp-4) var(--sp-4); }
.gx-personalities__live { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.gx-personalities__draft-notice { margin: 0 var(--sp-4) var(--sp-3); }
.gx-personalities h3, .gx-personalities h4 { margin: 0 0 10px; }
.gx-personalities p { color: var(--text-muted); font-size: 0.85rem; line-height: 1.5; }
.gx-personalities__heading, .gx-personalities__toggle { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
.gx-personalities__heading { margin: 0; }
.gx-personalities__grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 340px), 1fr)); gap: 16px; margin-top: 16px; }
.gx-personalities__profile { padding: 16px; min-width: 0; margin: 0; }
.gx-personalities__toggle { min-height: 44px; }
.gx-personalities__options [role="status"] { flex-basis: 100%; font-size: var(--fs-sm); color: var(--on-surface-variant); }
.gx-personalities__category { margin-top: 18px; }
.gx-personalities__options { display: flex; flex-wrap: wrap; gap: 6px; margin: 10px 0; }
.gx-personalities__options .gx-btn { min-height: 44px; padding: 8px 12px; font-size: 0.85rem; }
.gx-personalities__options [aria-pressed="true"] { background: var(--primary); color: var(--on-primary); }
.gx-personalities summary { cursor: pointer; padding: 12px 0; min-height: 44px; }
.gx-personalities__plot { overflow-x: auto; max-width: 100%; border-radius: 12px; }
.gx-personalities svg { display: block; width: 100%; min-width: 320px; max-height: 180px; background: var(--surface-variant); border-radius: 12px; }
.gx-personalities__points { display: grid; grid-template-columns: repeat(auto-fit, minmax(80px, 1fr)); gap: 8px; }
.gx-personalities__points label { font-size: 0.75rem; }
.gx-personalities input[type="number"] { display: block; box-sizing: border-box; min-width: 0; width: 100%; min-height: 44px; border: 1px solid var(--outline); border-radius: 8px; background: var(--surface-variant); color: var(--on-surface); padding: 8px; margin-top: 4px; }
.gx-personalities__error { color: var(--error) !important; }
.gx-personalities :disabled { opacity: 0.5; cursor: not-allowed; }
.gx-personalities :focus-visible { outline: 2px solid var(--primary); outline-offset: 3px; }
:root {
color-scheme: dark;
--primary: #9d72ff;
@@ -800,14 +773,6 @@ body.is-scrolling .gx-tile {
.gx-row--favorites { align-items: stretch; flex-direction: column; }
.gx-row--favorites .gx-row__info { flex: none; }
.gx-row--stack { align-items: stretch; flex-direction: column; }
/* Keep the native keyboard/touch picker, but wrap its verified label instead
of clipping Conditional Experimental at enlarged phone UI scales. */
.gx-mode-select { position: relative; width: 100%; }
.gx-mode-select__label { display: flex; align-items: center; justify-content: space-between; gap: 12px; white-space: normal; }
.gx-mode-select__label span { min-width: 0; overflow-wrap: anywhere; }
.gx-mode-select__label i { flex-shrink: 0; }
.gx-mode-select select { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; }
.gx-mode-select:focus-within { outline: 2px solid var(--primary); outline-offset: 3px; border-radius: 16px; }
.gx-row--stack .gx-row__info { flex: none; }
.gx-row--stack .gx-field,
.gx-row--stack .gx-slider-row { width: 100%; }
@@ -52,10 +52,6 @@ export const api = {
.filter((section) => (section.params || []).length > 0)
},
getPersonalityProfiles() { return request("/api/personality_profiles", { cache: "no-store" }) },
savePersonalityProfile(data) { return request("/api/personality_profiles", { method: "PUT", data }) },
migratePersonalityProfiles() { return request("/api/personality_profiles/migrate", { method: "POST" }) },
getParams() { return request("/api/params/all") },
async getDefaults() {
const res = await fetch("/api/params/defaults")
@@ -363,9 +359,6 @@ export function showSnackbar(message, level = "info") {
}
const el = document.createElement("div")
el.className = "snackbar show"
el.setAttribute("role", level === "error" ? "alert" : "status")
el.setAttribute("aria-live", level === "error" ? "assertive" : "polite")
el.setAttribute("aria-atomic", "true")
el.style.background = level === "error" ? "var(--error)" : "var(--color-confirm, #8b6cc5)"
el.style.borderRadius = "var(--border-radius-base, 5px)"
el.style.color = "var(--text-color, #fff)"
@@ -14,7 +14,6 @@ export const GalaxyToggleCard = {
value: { default: undefined },
values: { type: Object, default: () => ({}) },
locked: { type: Boolean, default: false },
lockMessage: { type: String, default: "This setting can only be changed while parked." },
manageable: { type: Boolean, default: false },
manageOpen: { type: Boolean, default: false },
},
@@ -73,7 +72,6 @@ export const GalaxyToggleCard = {
labelOf(el) { return el?.options?.[el.selectedIndex]?.textContent || "" },
rollback(prev) { this.$emit("change", { key: this.param.key, value: prev }) },
async commit(nextValue) {
if (this.locked || this.updating) return
const prev = this.value
const label = this.lastLabel || ""
this.$emit("change", { key: this.param.key, value: nextValue })
@@ -181,7 +179,7 @@ export const GalaxyToggleCard = {
<span v-if="displayParam.settings_tier === 'advanced'" class="gx-chip gx-chip--advanced">Advanced</span>
</span>
<span v-if="displayParam.description" class="gx-row__desc">{{ displayParam.description }}</span>
<div v-if="locked" class="gx-row__desc"><strong>Locked:</strong> {{ lockMessage }}</div>
<div v-if="locked" class="gx-row__desc"><strong>Locked:</strong> This setting can only be changed while parked.</div>
</div>
<label v-if="isSwitch" class="gx-switch">
@@ -1,85 +0,0 @@
import { LONGITUDINAL_MODE_KEY, LONGITUDINAL_MODES, validLongitudinalSnapshot } from "/assets/components/tools/longitudinal_mode.mjs"
import { SettingTree } from "./SettingTree.js"
import { isSettingVisible } from "../params.js"
export const LongitudinalMode = {
name: "LongitudinalMode",
components: { SettingTree },
props: { section: { type: Object, required: true }, values: { type: Object, required: true } },
emits: ["change"],
data() { return { snapshot: null, pending: false, reading: false, expanded: {}, open: false, error: "", generation: 0, timer: null, disposed: false, modes: LONGITUDINAL_MODES } },
computed: {
mode() { return this.snapshot?.mode || "" },
label() { return this.modes.find(m => m.value === this.mode)?.label || "Unavailable" },
reason() { return this.snapshot?.locked ? this.snapshot.reason : this.snapshot ? "" : "Speed control state unavailable." },
locked() { return this.pending || !this.snapshot || this.snapshot.locked },
conditional() { return this.mode === "conditional_experimental" || this.mode === "conditional_chill" },
description() { return this.section.params.find(p => p.key === LONGITUDINAL_MODE_KEY)?.description || "" },
children() { return this.section.params.filter(p => p.longitudinal_mode === this.mode && isSettingVisible(this.section, p, this.values)) },
},
methods: {
async request(init) {
const response = await fetch("/api/longitudinal_mode", { cache: "no-store", ...init })
const data = await response.json()
if (!response.ok || !validLongitudinalSnapshot(data)) throw new Error(data.error || "Speed control state unavailable.")
return data
},
async refresh() {
if (this.pending || this.reading || this.disposed) return
const generation = this.generation
this.reading = true
try {
const snapshot = await this.request()
if (!this.disposed && generation === this.generation) this.snapshot = snapshot
} catch (_) {
if (!this.disposed && generation === this.generation) this.snapshot = null
} finally { this.reading = false }
},
async select(event) {
const target = event.target.value
event.target.value = this.mode
if (this.locked || target === this.mode || !this.modes.some(m => m.value === target)) return
++this.generation // A pre-write GET may finish after this write; never publish it.
this.pending = true
this.error = ""
try {
const snapshot = await this.request({ method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mode: target, expected: this.snapshot.values, acknowledged: true }) })
if (!this.disposed) this.snapshot = snapshot
} catch (error) {
this.error = error.message
// A failed HTTP response can follow a successful storage write. Reconcile,
// rather than guessing that the old selection is still authoritative.
try { this.snapshot = await this.request() } catch (_) { this.snapshot = null }
} finally { this.pending = false }
},
childLock() { return this.pending ? "Speed control update in progress." : this.reason },
manage(key) { this.expanded = { ...this.expanded, [key]: !this.expanded[key] } },
},
mounted() { this.refresh(); this.timer = setInterval(() => this.refresh(), 1500) },
beforeUnmount() { this.disposed = true; ++this.generation; clearInterval(this.timer) },
template: `
<div class="gx-tree-node gx-longitudinal-mode">
<div class="gx-row gx-row--stack" :class="{ disabled: locked }">
<div class="gx-row__info">
<label class="gx-row__label" for="gx-longitudinal-mode">Longitudinal control mode</label>
<span id="gx-longitudinal-description" class="gx-row__desc">{{ description }}</span>
<span v-if="reason" class="gx-row__desc" role="status">{{ reason }}</span>
<span v-if="error" class="gx-row__desc" role="alert">{{ error }}</span>
</div>
<div class="gx-mode-select">
<div class="gx-field gx-mode-select__label" aria-hidden="true"><span>{{ label }}</span><i class="bi bi-chevron-down"></i></div>
<select id="gx-longitudinal-mode" :value="mode" :disabled="locked" aria-describedby="gx-longitudinal-description" @change="select">
<option v-if="!snapshot" value="">Unavailable</option>
<option v-for="m in modes" :key="m.value" :value="m.value">{{ m.label }}</option>
</select>
</div>
</div>
<button v-if="conditional" type="button" class="gx-manage-btn" :aria-expanded="open" aria-controls="gx-longitudinal-children" @click="open = !open">
{{ open ? 'Close' : 'Manage' }}<i class="bi" aria-hidden="true" :class="open ? 'bi-chevron-up' : 'bi-chevron-down'"></i>
</button>
<div v-if="conditional && open" id="gx-longitudinal-children" class="gx-tree-children">
<SettingTree :params="children" parent-key="LongitudinalControlMode" :depth="1" :values="values" :expanded="expanded" :lock-reason="childLock" @change="$emit('change', $event)" @manage="manage" />
</div>
</div>
`,
}
@@ -1,369 +0,0 @@
import { api, showSnackbar } from "../api.js"
import { numericBounds } from "../params.js"
import { formatProfileSpeed, profileSpeedUnit, personalityProfileParamKey } from "../../../components/tools/personality_profiles.mjs"
const PROFILES = ["traffic", "aggressive", "standard", "relaxed"]
const CATEGORIES = { acceleration: "Acceleration", braking: "Braking", following: "Following" }
export const PersonalityProfiles = {
name: "PersonalityProfiles",
props: { manageOpen: { default: null } },
emits: ["change", "manage"],
data() {
return { PROFILES, CATEGORIES, data: null, values: {}, meta: {}, busy: false, ready: false,
error: "", notice: "", localExpanded: false, advancedOpen: {}, drafts: {}, curvePending: false, recovery: false, curveText: {}, advancedText: {}, curveErrors: {}, advancedErrors: {}, drag: null, advancedCustom: {}, contextPending: false, contextRequest: null, loadPending: false, timer: null, disposed: false }
},
computed: {
expanded: { get() { return this.manageOpen ?? this.localExpanded }, set(value) { this.localExpanded = value; this.$emit("manage") } },
offroad() { return [false, "", "0", "False", "false"].includes(this.values.IsOnroad) && [true, "1", "True", "true"].includes(this.values.IsOffroad) },
locked() { return !this.ready || this.busy || !this.offroad },
editingLocked() { return this.locked || this.curvePending || !!this.data?.migration_required },
},
async mounted() {
await this.load()
if (!this.disposed) this.timer = setInterval(() => this.ready ? this.refreshContext() : this.load(), 4000)
},
beforeUnmount() {
this.disposed = true; clearInterval(this.timer)
this.drag = null; this.drafts = {}; this.curveText = {}
},
methods: {
enabled(value) { return [true, 1, "1", "True", "true"].includes(value) },
label(value) { return value.split("_").map(s => s === "plus" ? "+" : s[0].toUpperCase() + s.slice(1)).join(" ").replace(" +", "+") },
key: personalityProfileParamKey,
speed(value) { return formatProfileSpeed(value, this.enabled(this.values.IsMetric)) },
speedUnit() { return profileSpeedUnit(this.enabled(this.values.IsMetric)) },
bounds(param) { return numericBounds(param, this.values) },
options(category) { return (category === "following" ? ["close", "medium", "far", "custom"] : ["eco", "standard", "sport", "sport_plus", "custom"]).filter(x => this.data.options[category].includes(x)) },
advancedParams(profile) {
return Object.values(this.meta).filter(p => p.parent_key === this.key(profile) && p.key.includes("Jerk"))
},
advancedMode(key) {
if (this.advancedCustom[key]) return "custom"
const value = Number(this.values[key])
if (value === 100) return "standard"
if (!key.endsWith("JerkDanger") && value === 50) return "chill"
return "custom"
},
async advancedPreset(param, mode) {
if (this.paramLocked(param.key)) return
if (mode === "custom") { this.advancedCustom[param.key] = true; return }
if (await this.setAdvanced(param, mode === "chill" ? 50 : 100)) delete this.advancedCustom[param.key]
},
paramLocked(key) {
const p = this.meta[key]
return this.editingLocked || !p ||
(p.requires_parked && !this.values.VehicleParked) ||
(p.requires_capability && !this.values[p.requires_capability]) ||
(p.disabled_when_key_true && !!this.values[p.disabled_when_key_true]) ||
(p.requires_nonempty_key && (!this.values[p.requires_nonempty_key] || this.values[p.requires_nonempty_key] === "{}"))
},
validate(data) {
for (const profile of PROFILES) for (const category of Object.keys(CATEGORIES)) {
const config = data?.profiles?.[profile]?.[category]
const speeds = data?.speed_breakpoints_mph?.[category]
const bounds = data?.bounds?.[category]
const reference = data?.reference_curves?.[profile]?.[category]
if (!config || !Array.isArray(config.curve) || !Array.isArray(speeds) || !speeds.length ||
!speeds.every((v, i) => Number.isFinite(v) && v >= 0 && (!i || v > speeds[i - 1])) || speeds.at(-1) <= 0 ||
!Array.isArray(bounds) || bounds.length !== 2 || !bounds.every(Number.isFinite) || bounds[0] >= bounds[1] ||
!Array.isArray(reference) || reference.length !== speeds.length || !reference.every(Number.isFinite) ||
!Array.isArray(data?.options?.[category]) || !data.options[category].includes(config.preset) ||
(config.preset === "custom" && config.curve.length !== speeds.length) || !config.curve.every(Number.isFinite) ||
!Array.isArray(data?.bounds?.[category]) || !Array.isArray(data?.options?.[category])) {
throw new Error("Profile data is unavailable or malformed. Retrying automatically…")
}
}
return data
},
acceptData(data) {
const next = this.validate(data)
for (const profile of PROFILES) for (const category of Object.keys(CATEGORIES)) {
const key = profile + category
if (this.drafts[key] && JSON.stringify(this.data?.profiles?.[profile]?.[category]) !== JSON.stringify(next.profiles[profile][category])) {
this.discard(profile, category)
if (!this.busy && !this.curvePending) { this.drag = null; this.notice = "Saved profiles changed. The affected preview was cancelled." }
}
}
this.data = next
},
async load() {
if (this.busy || this.loadPending || this.contextPending) return
this.loadPending = true
this.ready = false
try {
const [data, values, layout] = await Promise.all([api.getPersonalityProfiles(), api.getParams(), api.getLayout()])
if (this.disposed) return
if (!values || typeof values !== "object" || Array.isArray(values) || !Array.isArray(layout) || !layout.every(s => Array.isArray(s.params))) throw new Error("Settings metadata is unavailable. Retrying automatically…")
this.acceptData(data)
this.values = values
this.meta = Object.fromEntries(layout.flatMap(s => s.params).map(p => [p.key, p]))
const required = ["CustomPersonalities", ...PROFILES.flatMap(profile => [this.key(profile), ...["Acceleration", "Deceleration", "Danger", "SpeedDecrease", "Speed"].map(suffix => this.label(profile) + "Jerk" + suffix)])]
if (required.some(key => !this.meta[key])) throw new Error("Personality metadata is incomplete. Retrying automatically…")
this.ready = true
this.error = ""
if (this.recovery) { this.notice = "Save could not be confirmed. Showing verified saved state; review it before editing again."; this.recovery = false }
} catch (e) { this.error = e.message }
finally { this.loadPending = false }
},
async refreshContext() {
if (this.busy || !this.ready || this.contextPending) return
this.contextPending = true
try {
this.contextRequest = api.getParams()
const values = await this.contextRequest
if (this.disposed) return
if (!this.busy) this.values = values
if (!this.offroad) { this.drag = null; this.drafts = {}; this.curveText = {} }
} catch (e) { this.ready = false; this.error = "Connection lost. Reconnecting…" }
finally { this.contextPending = false; this.contextRequest = null }
},
async write(action, check = () => !this.editingLocked) {
if (this.contextPending) { try { await this.contextRequest } catch { return } }
if (this.disposed || !check()) return
this.busy = true
this.error = ""
this.notice = ""
try {
await action()
if (this.disposed) return
const [data, values] = await Promise.all([api.getPersonalityProfiles(), api.getParams()])
if (this.disposed) return
this.acceptData(data)
this.values = values
this.$emit("change", values)
showSnackbar("Driving personalities saved.")
return true
} catch (e) {
if (this.disposed) return
this.error = e.message + " Rechecking saved state…"
this.ready = false
this.recovery = true
this.drafts = {}; this.curveText = {}
} finally { this.busy = false }
if (!this.disposed) await this.load()
},
migrate() { return this.write(() => api.migratePersonalityProfiles(), () => !this.locked) },
toggle(key, event) {
const value = event.target.checked
event.target.checked = this.enabled(this.values[key])
return this.write(() => api.updateParam({ key, value }), () => !this.paramLocked(key))
},
async preset(profile, category, preset) {
if (this.data.profiles[profile][category].preset === preset) return
if (await this.write(() => api.savePersonalityProfile({ profile, category, preset, curve: [], expected: this.data.profiles[profile][category] }))) { this.discard(profile, category); this.notice = ""; if (preset === "custom") this.advancedOpen[profile] = true }
},
draft(profile, category) { return this.drafts[profile + category] || this.data.profiles[profile][category].curve },
point(profile, category, index, event, preview = false) {
if (this.editingLocked) return
const raw = event.target.value
delete this.curveText[profile + category + index]
const value = Number(raw)
const [min, max] = this.data.bounds[category]
if (!raw.trim() || !Number.isFinite(value) || value < min || value > max || Math.abs(value / 0.05 - Math.round(value / 0.05)) > 1e-7) {
event.target.value = this.draft(profile, category)[index]
this.curveErrors[profile + category] = `Edited points must be between ${min} and ${max}, in 0.05 increments.`
return
}
const curve = [...this.draft(profile, category)]
curve[index] = value
this.drafts = { ...this.drafts, [profile + category]: curve }
delete this.curveErrors[profile + category]
if (!preview) return this.saveCurve(profile, category)
},
discard(profile, category) { delete this.drafts[profile + category]; delete this.curveErrors[profile + category] },
async saveCurve(profile, category, reset = false) {
if (this.editingLocked || this.disposed) return
const curve = reset ? this.data.reference_curves?.[profile]?.[category] : this.draft(profile, category)
if (!Array.isArray(curve)) return
const snapshot = [...curve]
this.curvePending = true
try {
if (this.contextPending) { try { await this.contextRequest } catch { return } }
if (this.disposed) return
if (await this.write(() => api.savePersonalityProfile({ profile, category, preset: "custom", curve: snapshot, expected: this.data.profiles[profile][category] }), () => !this.locked && !this.data?.migration_required)) this.notice = ""
} finally {
this.discard(profile, category)
this.curvePending = false
}
},
async setAdvanced(param, raw) {
if (this.contextPending) { try { await this.contextRequest } catch { return } }
if (this.disposed) return
const value = Number(raw)
const { min, max, step } = this.bounds(param)
if (String(raw).trim() === "" || !Number.isFinite(value) || value < min || value > max || Math.abs((value - min) / step - Math.round((value - min) / step)) > 1e-7) {
this.advancedErrors[param.key] = `Enter ${min}${max}% in increments of ${step}.`; return
}
delete this.advancedErrors[param.key]
return this.write(() => api.updateParam({ key: param.key, value }), () => !this.paramLocked(param.key))
},
graphMax(profile, category) {
if (this.drag?.profile === profile && this.drag.category === category) return this.drag.max
return Math.max(this.data.bounds[category][1], ...this.draft(profile, category), ...(this.data.reference_curves?.[profile]?.[category] || []))
},
graphMin(profile, category) { return this.drag?.profile === profile && this.drag.category === category ? this.drag.min : this.data.bounds[category][0] },
graphPoints(profile, category, reference = false) {
const curve = reference ? this.data.reference_curves?.[profile]?.[category] : this.draft(profile, category)
const max = this.graphMax(profile, category)
const min = this.graphMin(profile, category)
const speeds = this.data.speed_breakpoints_mph[category]
return (curve || []).map((v, i) => ({ x: 10 + speeds[i] / speeds[speeds.length - 1] * 280, y: 90 - (v - min) / (max - min) * 80 }))
},
graph(profile, category, reference = false) {
return this.graphPoints(profile, category, reference).map(p => `${p.x},${p.y}`).join(" ")
},
startDrag(profile, category, index, event) {
if (this.editingLocked || this.drag || event.button !== 0) return
const svg = event.currentTarget.ownerSVGElement || event.currentTarget
svg.setPointerCapture(event.pointerId)
this.drag = { profile, category, index, pointerId: event.pointerId, min: this.graphMin(profile, category), max: this.graphMax(profile, category), previous: this.drafts[profile + category] ? [...this.drafts[profile + category]] : null }
event.preventDefault()
this.moveDrag(event)
},
pickPoint(profile, category, event) {
const matrix = event.currentTarget.getScreenCTM()
if (!matrix) return
const x = new DOMPoint(event.clientX, event.clientY).matrixTransform(matrix.inverse()).x
const points = this.graphPoints(profile, category)
const index = points.reduce((best, p, i) => Math.abs(p.x - x) < Math.abs(points[best].x - x) ? i : best, 0)
this.startDrag(profile, category, index, event)
},
moveDrag(event) {
const d = this.drag
if (!d || d.pointerId !== event.pointerId) return
if (this.editingLocked) { this.endDrag(event); return }
const matrix = event.currentTarget.getScreenCTM()
if (!matrix) return
const position = new DOMPoint(event.clientX, event.clientY).matrixTransform(matrix.inverse())
const [min, max] = this.data.bounds[d.category]
const value = Math.max(min, Math.min(max, Math.round((d.min + (90 - position.y) / 80 * (d.max - d.min)) * 20) / 20))
this.point(d.profile, d.category, d.index, { target: { value: String(value) } }, true)
event.preventDefault()
},
endDrag(event) {
if (!this.drag || this.drag.pointerId !== event.pointerId) return
const { profile, category } = this.drag
const commit = event.type === "pointerup" && !this.editingLocked
if (event.type === "pointercancel" || event.type === "lostpointercapture" || this.editingLocked) {
const key = this.drag.profile + this.drag.category
if (this.drag.previous) this.drafts[key] = this.drag.previous
else delete this.drafts[key]
}
this.drag = null
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId)
if (commit) return this.saveCurve(profile, category)
},
},
template: `
<section class="gx-personalities" aria-label="Driving personalities">
<div class="gx-row gx-personalities__heading">
<div class="gx-row__info"><span class="gx-row__label">Driving Personalities</span><span class="gx-row__desc">Acceleration, braking and following for each driving style.</span></div>
<label class="gx-switch">
<input type="checkbox" aria-label="Custom personalities" :checked="enabled(values.CustomPersonalities)" :disabled="paramLocked('CustomPersonalities')" @change="toggle('CustomPersonalities', $event)" />
<span class="gx-switch__track"></span><span class="gx-switch__thumb"></span>
</label>
</div>
<button type="button" class="gx-manage-btn" :aria-expanded="expanded" aria-controls="gx-personality-settings" @click="expanded = !expanded">{{ expanded ? 'Close' : 'Manage' }}<i class="bi" :class="expanded ? 'bi-chevron-up' : 'bi-chevron-down'" aria-hidden="true"></i></button>
<div id="gx-personality-settings" v-show="expanded">
<p v-if="error" role="alert" class="gx-personalities__error">{{ error }}</p>
<button v-if="error && !ready" type="button" class="gx-btn gx-btn--tonal" :disabled="busy || loadPending || contextPending" @click="load">Retry loading</button>
<p v-if="notice" role="status">{{ notice }}</p>
<p v-if="busy" role="status" class="gx-personalities__live">Saving</p>
<p v-if="!data && !error" role="status">Loading profiles</p>
<template v-if="data">
<p v-if="!offroad" role="note">Active driving personality can be switched on-road. Saved profile tuning is available off-road.</p>
<div v-if="data.migration_required" role="alert" class="gx-personalities__error">
<p>Stored profiles need migration before editing.</p>
<button type="button" class="gx-btn" :disabled="locked" @click="migrate">Migrate profiles</button>
</div>
<p v-if="!enabled(values.CustomPersonalities)">Enable to configure profiles. Existing defaults remain active while off.</p>
<div class="gx-personalities__grid">
<article v-for="profile in PROFILES" :key="profile" class="gx-card gx-personalities__profile">
<div class="gx-personalities__toggle"><strong>{{ profile === 'traffic' ? 'Traffic Mode' : label(profile) }}</strong>
<label class="gx-switch"><input type="checkbox" :aria-label="label(profile) + ' profile'" :checked="enabled(values[key(profile)])" :disabled="paramLocked(key(profile))" @change="toggle(key(profile), $event)" /><span class="gx-switch__track"></span><span class="gx-switch__thumb"></span></label>
</div>
<p v-if="!enabled(values[key(profile)])">Turn on to configure this profile.</p>
<template v-else>
<section v-for="(title, category) in CATEGORIES" :key="category" class="gx-personalities__category">
<h4>{{ title }}</h4>
<div class="gx-personalities__options" role="group" :aria-label="label(profile) + ' ' + title">
<button v-for="option in options(category)" :key="option" type="button" class="gx-btn gx-btn--tonal"
:aria-pressed="data.profiles[profile][category].preset === option" :disabled="editingLocked"
@click="preset(profile, category, option)">{{ label(option) }}</button>
</div>
<p v-if="data.profiles[profile][category].preset === 'dom_default'">Using existing Dom default.</p>
</section>
<details class="gx-personalities__advanced" :open="advancedOpen[profile]" @toggle="advancedOpen[profile] = $event.target.open">
<summary>Advanced</summary>
<template v-for="(title, category) in CATEGORIES" :key="category">
<details v-if="data.profiles[profile][category].preset === 'custom'" open class="gx-personalities__curve">
<summary>Custom {{ title.toLowerCase() }} graph</summary>
<p>{{ category === 'following' ? 'Seconds' : 'm/s²' }} · {{ speedUnit() }}. Dashed: default.</p>
<div class="gx-personalities__plot" tabindex="0" :aria-label="label(profile) + ' ' + title + ' graph; scroll horizontally on narrow screens'">
<svg viewBox="-30 -12 340 140" role="group" :aria-disabled="editingLocked" :aria-label="label(profile) + ' ' + title + ' editable curve; exact values below'"
style="touch-action: pan-x" @pointerdown="pickPoint(profile, category, $event)" @pointermove="moveDrag" @pointerup="endDrag" @pointercancel="endDrag" @lostpointercapture="endDrag">
<g fill="currentColor" font-size="10" style="pointer-events: none">
<text x="10" y="-3">{{ category === 'following' ? 'Seconds' : 'm/s²' }}</text>
<g v-for="tick in [0, 1, 2, 3, 4]" :key="'y' + tick">
<line x1="10" x2="290" :y1="90 - tick * 20" :y2="90 - tick * 20" stroke="currentColor" opacity="0.18" />
<text x="5" :y="93 - tick * 20" text-anchor="end">{{ Number((graphMin(profile, category) + (graphMax(profile, category) - graphMin(profile, category)) * tick / 4).toFixed(2)) }}</text>
</g>
<g v-for="tick in [0, 1, 2, 3, 4]" :key="'x' + tick">
<line :x1="10 + tick * 70" :x2="10 + tick * 70" y1="10" y2="94" stroke="currentColor" opacity="0.18" />
<text :x="10 + tick * 70" y="105" text-anchor="middle">{{ speed(data.speed_breakpoints_mph[category].at(-1) * tick / 4) }}</text>
</g>
<text x="150" y="121" text-anchor="middle">Speed ({{ speedUnit() }})</text>
</g>
<polyline :points="graph(profile, category, true)" fill="none" stroke="currentColor" stroke-dasharray="4 4" opacity="0.5" />
<polyline :points="graph(profile, category)" fill="none" stroke="var(--primary)" stroke-width="2" />
<g v-for="(p, i) in graphPoints(profile, category)" :key="i">
<circle :cx="p.x" :cy="p.y" r="3" fill="var(--primary)" stroke="currentColor" stroke-width="0.5" />
<circle :cx="p.x" :cy="p.y" r="9" fill="transparent" :style="{ cursor: editingLocked ? 'not-allowed' : 'ns-resize' }"
><title>{{ speed(data.speed_breakpoints_mph[category][i]) }} {{ speedUnit() }}: {{ draft(profile, category)[i] }}</title></circle>
</g>
</svg>
</div>
<p v-if="draft(profile, category).some(v => v > data.bounds[category][1])" role="note">Saved values above {{ data.bounds[category][1] }} are shown at their original scale. Only edited points use the current authoring bounds.</p>
<p v-if="curveErrors[profile + category]" :id="'curve-error-' + profile + category" role="alert" class="gx-personalities__error">{{ curveErrors[profile + category] }}</p>
<div class="gx-personalities__points">
<label v-for="(value, i) in draft(profile, category)" :key="i">{{ speed(data.speed_breakpoints_mph[category][i]) }} {{ speedUnit() }}
<input type="number" step="0.05" :min="data.bounds[category][0]" :max="data.bounds[category][1]" :value="curveText[profile + category + i] ?? value" :disabled="editingLocked"
@input="curveText[profile + category + i] = $event.target.value"
:aria-invalid="!!curveErrors[profile + category]" :aria-describedby="curveErrors[profile + category] ? 'curve-error-' + profile + category : undefined"
:aria-label="label(profile) + ' ' + title + ' at ' + speed(data.speed_breakpoints_mph[category][i]) + ' ' + speedUnit() + ', ' + (category === 'following' ? 'seconds' : 'm/s²')" @change="point(profile, category, i, $event)" />
</label>
</div>
<div class="gx-personalities__options">
<button type="button" class="gx-btn gx-btn--tonal" :disabled="editingLocked" @click="saveCurve(profile, category, true)">Reset to default</button>
</div>
</details>
</template>
<p>Custom values are untested and may not be supported by the developer.</p>
<div v-for="param in advancedParams(profile)" :key="param.key" class="gx-personalities__category">
<label>{{ param.label }}
<span class="gx-row__desc">{{ param.description }}</span>
<input v-if="advancedMode(param.key) === 'custom'" type="number" :value="advancedText[param.key] ?? values[param.key]" :min="bounds(param).min" :max="bounds(param).max" :step="bounds(param).step"
@input="advancedText[param.key] = $event.target.value"
:aria-label="label(profile) + ' ' + param.label + ' custom percentage'"
:aria-invalid="!!advancedErrors[param.key]" :aria-describedby="advancedErrors[param.key] ? 'advanced-error-' + param.key : undefined"
:disabled="paramLocked(param.key)" @change="async event => { await setAdvanced(param, event.target.value); delete advancedText[param.key]; event.target.value = values[param.key] }" />
<span v-if="advancedMode(param.key) === 'custom'" class="gx-row__desc">{{ bounds(param).min }}{{ bounds(param).max }}% · step {{ bounds(param).step }}</span>
</label>
<p v-if="advancedErrors[param.key]" :id="'advanced-error-' + param.key" role="alert" class="gx-personalities__error">{{ advancedErrors[param.key] }}</p>
<div class="gx-personalities__options" role="group" :aria-label="label(profile) + ' advanced ' + param.label + ' percentage'">
<button v-if="!param.key.endsWith('JerkDanger')" class="gx-btn gx-btn--tonal" :aria-pressed="advancedMode(param.key) === 'chill'" :disabled="paramLocked(param.key)" @click="advancedPreset(param, 'chill')">Chill</button>
<button class="gx-btn gx-btn--tonal" :aria-pressed="advancedMode(param.key) === 'standard'" :disabled="paramLocked(param.key)" @click="advancedPreset(param, 'standard')">Standard</button>
<button class="gx-btn gx-btn--tonal" :aria-pressed="advancedMode(param.key) === 'custom'" :disabled="paramLocked(param.key)" @click="advancedPreset(param, 'custom')">Custom</button>
</div>
</div>
</details>
</template>
</article>
</div>
</template>
</div>
</section>
`,
}
@@ -1,10 +1,9 @@
import { PersonalityProfiles } from "./PersonalityProfiles.js"
import { GalaxyToggleCard } from "./GalaxyToggleCard.js"
import { hasChildParams, isGroupParam, isParamEnabledForChildren } from "../params.js"
export const SettingTree = {
name: "SettingTree",
components: { GalaxyToggleCard, PersonalityProfiles },
components: { GalaxyToggleCard },
props: {
params: { type: Array, required: true },
parentKey: { default: null },
@@ -30,14 +29,13 @@ export const SettingTree = {
},
template: `
<template v-for="p in children" :key="p.key">
<PersonalityProfiles v-if="p.key === 'CustomPersonalities'" :manage-open="isExpanded(p)" @manage="$emit('manage', p.key)" @change="$emit('change', $event)" />
<div v-else class="gx-tree-node" :class="{ 'gx-tree-node--child': depth > 0 }" :style="'--gx-depth:' + depth">
<GalaxyToggleCard :param="p" :value="values[p.key]" :values="values" :locked="lockReason(p) !== ''" :lock-message="lockReason(p)"
<div class="gx-tree-node" :class="{ 'gx-tree-node--child': depth > 0 }" :style="'--gx-depth:' + depth">
<GalaxyToggleCard :param="p" :value="values[p.key]" :values="values" :locked="lockReason(p) !== ''"
:manageable="manageable(p)" :manage-open="manageOpen(p)"
@change="$emit('change', $event)" @manage="$emit('manage', $event)" />
</div>
<transition name="gx-collapse">
<div v-if="p.key !== 'CustomPersonalities' && showChildren(p)" class="gx-tree-children">
<div v-if="showChildren(p)" class="gx-tree-children">
<SettingTree :params="params" :parent-key="p.key" :depth="depth + 1"
:values="values" :expanded="expanded" :lock-reason="lockReason"
@change="$emit('change', $event)" @manage="$emit('manage', $event)" />
@@ -1,5 +1,3 @@
import { longitudinalModeLayout, LONGITUDINAL_MODE_KEY } from "/assets/components/tools/longitudinal_mode.mjs"
import { LongitudinalMode } from "../components/LongitudinalMode.js"
import { api, showSnackbar } from "../api.js"
import { navigate, store } from "../store.js"
import {
@@ -7,22 +5,13 @@ import {
resolveVehicleUnitParam, slugifySectionName,
} from "../params.js"
import { SettingTree } from "../components/SettingTree.js"
import { PersonalityProfiles } from "../components/PersonalityProfiles.js"
import { GalaxyToggleCard } from "../components/GalaxyToggleCard.js"
import { GalaxySection } from "../components/GalaxySection.js"
import { DevModeBanner } from "../components/DevModeBanner.js"
const LEGACY_PERSONALITY_KEYS = new Set([
"AccelerationProfile", "AggressiveFollow", "AggressiveFollowHigh", "CustomAccelProfile",
"CustomAccelProfile0MPH", "CustomAccelProfile11MPH", "CustomAccelProfile22MPH", "CustomAccelProfile34MPH",
"CustomAccelProfile45MPH", "CustomAccelProfile56MPH", "CustomAccelProfile89MPH", "DecelerationProfile",
"EVTuning", "HumanAcceleration", "RelaxedFollow", "RelaxedFollowHigh", "StandardFollow",
"StandardFollowHigh", "TrafficFollow", "TruckTuning",
])
export const Settings = {
name: "Settings",
components: { SettingTree, PersonalityProfiles, GalaxyToggleCard, GalaxySection, DevModeBanner, LongitudinalMode },
components: { SettingTree, GalaxyToggleCard, GalaxySection, DevModeBanner },
data() {
return {
layout: [],
@@ -41,7 +30,7 @@ export const Settings = {
.filter((s) => s.name !== "Model & Customization")
.map((s) => ({
...s,
params: (s.params || []).filter((p) => !LEGACY_PERSONALITY_KEYS.has(p.key) && isSettingVisible(s, p, this.values)),
params: (s.params || []).filter((p) => isSettingVisible(s, p, this.values)),
slug: slugifySectionName(s.name),
}))
.filter((s) => s.params.length > 0)
@@ -58,25 +47,17 @@ export const Settings = {
searchResults() {
if (!this.searchActive) return []
return this.sections
.map((s) => {
const descendants = new Set(["CustomPersonalities"])
let size
do { size = descendants.size; s.params.forEach(p => { if (descendants.has(p.parent_key)) descendants.add(p.key) }) } while (size !== descendants.size)
return { ...s, matches: s.params.filter(p => p.key === "CustomPersonalities" ? s.params.some(child => descendants.has(child.key) && this.matchesFilter(child)) : !descendants.has(p.key) && this.matchesFilter(p)) }
})
.map((s) => ({ ...s, matches: s.params.filter((p) => this.matchesFilter(p)) }))
.filter((s) => s.matches.length > 0)
},
},
methods: {
isModeParam(p) { return p.key === LONGITUDINAL_MODE_KEY || !!p.longitudinal_mode },
modeSection(s) { return this.layout.find(section => section.name === s.name && section.params.some(p => p.key === LONGITUDINAL_MODE_KEY)) },
ordinaryParams(s) { return s.params.filter(p => !this.isModeParam(p)) },
async load() {
try {
const [layout, values, defaults] = await Promise.all([
api.getLayout(), api.getParams(), api.getDefaults(),
])
this.layout = longitudinalModeLayout(layout)
this.layout = layout
this.values = values || {}
this.defaults = defaults || {}
if (!this.activeSectionSlug && this.sections.length) {
@@ -152,10 +133,8 @@ export const Settings = {
</div>
<template v-for="section in searchResults" :key="section.slug">
<GalaxySection :title="section.name + ' (' + section.matches.length + ')'" :icon="section.icon || 'bi-search'" :default-open="false">
<LongitudinalMode v-if="section.matches.some(isModeParam)" :section="modeSection(section)" :values="values" @change="onParamChange" />
<template v-for="p in section.matches" :key="p.key">
<PersonalityProfiles v-if="p.key === 'CustomPersonalities'" :manage-open="!!expanded[p.key]" @manage="toggleManage(p.key)" @change="onParamChange" />
<GalaxyToggleCard v-else-if="!isModeParam(p)" :param="p" :value="values[p.key]" :values="values" :locked="lockReason(p) !== ''"
<GalaxyToggleCard :param="p" :value="values[p.key]" :values="values" :locked="lockReason(p) !== ''"
@change="onParamChange" />
</template>
</GalaxySection>
@@ -176,8 +155,7 @@ export const Settings = {
<i class="bi" :class="activeSection.icon"></i>
<span class="gx-section__title">{{ activeSection.name }}</span>
</div>
<LongitudinalMode v-if="modeSection(activeSection)" :section="modeSection(activeSection)" :values="values" @change="onParamChange" />
<SettingTree :params="ordinaryParams(activeSection)" :parent-key="null" :values="values"
<SettingTree :params="activeSection.params" :parent-key="null" :values="values"
:expanded="expanded" :lock-reason="lockReason" @change="onParamChange" @manage="toggleManage" />
<div v-if="!activeSection.params.length" class="gx-empty">No settings in this section.</div>
</div>
@@ -1,112 +0,0 @@
"""Adapter over existing mode Params, with coherent participating runtime reads.
Enable a conditional target before clearing its competitor; for fixed modes,
set the fallback before disabling conditional flags. Every stored write boundary
selects either the old or requested mode, including when a write fails. The
shared sidecar lock keeps participating readers on their complete snapshot.
Legacy nonparticipating writers remain advisory-lock exceptions, not
transactions. API success confirms storage, not plan activation.
"""
from contextlib import contextmanager
from threading import RLock
from openpilot.starpilot.common.longitudinal_mode import MODE_KEYS, mode_lock
MODES = {"chill": None, "experimental": "ExperimentalMode",
"conditional_experimental": "ConditionalExperimental", "conditional_chill": "ConditionalChill"}
WRITE_LOCK = RLock()
class ModeError(ValueError):
def __init__(self, message, status=409):
super().__init__(message)
self.status = status
def selected_mode(values):
if values["ConditionalExperimental"]:
return "conditional_experimental"
if values["ConditionalChill"]:
return "conditional_chill"
return "experimental" if values["ExperimentalMode"] else "chill"
def lock_reason(params, capable):
def boolean(key):
value = params.get(key)
if value in (True, "1", b"1", "True", b"True"):
return True
if value in (False, "0", b"0", "False", b"False"):
return False
return None
offroad, onroad = boolean("IsOffroad"), boolean("IsOnroad")
if offroad is None or onroad is None or offroad == onroad:
return "Longitudinal control mode requires a known, consistent road state."
if boolean("SafeMode") is not False:
return "Longitudinal control mode is locked by Safe Mode or unavailable safety state."
if capable is not True:
return "openpilot longitudinal control is unavailable for the detected vehicle."
return ""
def snapshot(params, capable):
try:
with mode_lock(params):
return _snapshot(params, capable)
except OSError as error:
raise ModeError("Longitudinal mode is busy or unavailable. Refresh before retrying.", 503) from error
def _snapshot(params, capable):
values = {key: params.get_bool(key) for key in MODE_KEYS}
reason = lock_reason(params, capable)
return {"mode": selected_mode(values), "values": values, "locked": bool(reason), "reason": reason,
"experimental_confirmed": params.get_bool("ExperimentalModeConfirmed")}
@contextmanager
def _write_lock(params):
try:
with mode_lock(params, exclusive=True):
yield
except OSError as error:
raise ModeError("Longitudinal mode is busy or unavailable. Refresh before retrying.", 503) from error
def set_mode(params, target, expected, capability, acknowledged=False):
if not isinstance(target, str) or target not in MODES:
raise ModeError("Unknown longitudinal control mode.", 400)
if not isinstance(expected, dict) or any(type(expected.get(key)) is not bool for key in MODE_KEYS):
raise ModeError("An exact previous mode snapshot is required.", 400)
with WRITE_LOCK, _write_lock(params):
try:
current = _snapshot(params, capability())
if current["locked"]:
raise ModeError(current["reason"], 403)
if current["values"] != {key: expected[key] for key in MODE_KEYS}:
raise ModeError("Longitudinal mode changed elsewhere. Refresh before retrying.")
if current["mode"] == target:
return current # Preserve dormant flags, defaults and manual override state.
if target == "experimental" and not current["experimental_confirmed"] and acknowledged is not True:
raise ModeError("Experimental Mode requires explicit acknowledgement before enabling.")
if target == "conditional_experimental":
writes = [("ConditionalExperimental", True), ("ConditionalChill", False), ("ExperimentalMode", False)]
elif target == "conditional_chill":
writes = [("ConditionalChill", True), ("ConditionalExperimental", False), ("ExperimentalMode", False)]
else:
writes = [("ExperimentalMode", target == "experimental"), ("ConditionalChill", False), ("ConditionalExperimental", False)]
for key, value in writes:
reason = lock_reason(params, capability())
if reason:
raise ModeError(reason, 403)
params.put_bool(key, value)
if params.get_bool(key) is not value:
raise ModeError("Mode write could not be verified; refresh before retrying.", 500)
result = _snapshot(params, capability())
desired = {key: key == MODES[target] for key in MODE_KEYS}
if result["values"] != desired or result["locked"]:
raise ModeError("Mode changed during the update; refresh before retrying.")
return result
except ModeError:
raise
except Exception as error:
raise ModeError("Longitudinal mode update failed; refresh to inspect the stored state.", 500) from error
@@ -1,22 +0,0 @@
# Big Dipper personality browser regression
This launches real Chromium against the checked-in Vue component, CSS, Settings and SettingTree. All HTTP is intercepted with a **synthetic** personality fixture and checked-in layout. It never contacts a Comma and does not establish physical-device acceptance.
Install Playwright in your normal test environment, then run from any directory:
```sh
node starpilot/system/the_galaxy/tests/browser/personality_profiles.cjs
```
If Playwright is outside this repository, use `NODE_PATH` to its `node_modules`. Optional environment variables:
- `CHROMIUM_EXECUTABLE`: existing Chromium/headless-shell executable; otherwise Playwright's installed browser is used.
- `PERSONALITY_BROWSER_OUTPUT`: screenshot and JSON result directory (defaults to a temporary-directory subfolder).
- `PERSONALITY_DPR`: device scale factor (default 1; verification also runs 2 and 3 with touch capability).
- `PERSONALITY_POLL_ONLY=1`: focused polling-flicker regression (`personality_poll.cjs`): stable computed button appearance over delayed reads, clicks wait for fresh context, road transitions reject queued writes, overlapping clicks serialize and unmount cancels waiting actions.
Coverage: every profile/category graph, numeric commit/reset, graph scales and units, historical high-point preservation, mouse/touch/cancel, pending context and focused number preservation, failed-save verified recovery, malformed graph metadata, all advanced numeric controls and integer validation, Custom-only inputs, master-off profile visibility, Settings deep links, replacement search, dark/light screenshots, and viewport/zoom drag checks. The imported `personality_lifecycle.cjs` adds delayed HTTP and readback failures, duplicate-write prevention, off-road transitions during gestures/pending changes, lost capture, and unmount during drag/poll/PUT (including remount readback and suppressed late effects).
Graph editing matches classic Galaxy: pointer movement previews locally, release commits once, pointercancel/lost capture restores the saved curve without writing, and numeric changes commit immediately. No explicit Save/Discard or route draft cache remains. Pending context reads finish before the unchanged write guards are rechecked; editing is locked during pending writes. Failed/uncertain writes reload authoritative saved state, remain locked if readback fails, and explain recovery only after verification. Already-sent writes cannot be cancelled by unmount; remount reads their actual result. Advanced percentages retain commit-on-change. Custom selection sends an empty curve so the unchanged backend chooses the effective starting curve (including legacy data); Reset sends the reference explicitly. The fixture simplifies backend preset initialization, so server initialization semantics are verified by `test_personality_profiles_api.py`, not this fixture.
Screenshot checks cover CSS zoom, not browser chrome zoom. DPR and touch are browser emulation, not a physical screen. The harness waits for real transient snackbars to disappear before precision drag checks, since a toast can cover the target at extreme zoom.
@@ -1 +0,0 @@
{"profiles": {"traffic": {"acceleration": {"preset": "standard", "curve": []}, "braking": {"preset": "standard", "curve": []}, "following": {"preset": "medium", "curve": []}}, "aggressive": {"acceleration": {"preset": "standard", "curve": []}, "braking": {"preset": "standard", "curve": []}, "following": {"preset": "medium", "curve": []}}, "standard": {"acceleration": {"preset": "standard", "curve": []}, "braking": {"preset": "standard", "curve": []}, "following": {"preset": "medium", "curve": []}}, "relaxed": {"acceleration": {"preset": "standard", "curve": []}, "braking": {"preset": "standard", "curve": []}, "following": {"preset": "medium", "curve": []}}}, "reference_curves": {"traffic": {"acceleration": [1.5, 1.302, 1.1135, 0.9375, 0.8039, 0.6611, 0.547, 0.4797, 0.3781, 0.35], "braking": [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0], "following": [1.25, 1.25, 1.25, 1.25, 1.25, 1.25, 1.25, 1.25, 1.25, 1.25]}, "aggressive": {"acceleration": [3.5, 3.203, 2.827, 2.4343, 2.0616, 1.7444, 1.5441, 1.4093, 1.2063, 1.15], "braking": [2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0], "following": [1.25, 1.25, 1.25, 1.25, 1.25, 1.25, 1.25, 1.25, 1.25, 1.25]}, "standard": {"acceleration": [2.0, 1.802, 1.5669, 1.3468, 1.1398, 0.9611, 0.8456, 0.7445, 0.5922, 0.55], "braking": [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0], "following": [1.45, 1.45, 1.45, 1.45, 1.45, 1.45, 1.45, 1.45, 1.45, 1.45]}, "relaxed": {"acceleration": [1.5, 1.302, 1.1135, 0.9375, 0.8039, 0.6611, 0.547, 0.4797, 0.3781, 0.35], "braking": [0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5], "following": [1.75, 1.75, 1.75, 1.75, 1.75, 1.75, 1.75, 1.75, 1.75, 1.75]}}, "bounds": {"acceleration": [0.0, 3.5], "braking": [0.5, 2.0], "following": [0.75, 3.0]}, "options": {"acceleration": ["dom_default", "standard", "eco", "sport", "sport_plus", "custom"], "braking": ["dom_default", "standard", "eco", "sport", "custom"], "following": ["dom_default", "close", "medium", "far", "custom"]}, "speed_breakpoints_mph": {"acceleration": [0, 10, 20, 30, 40, 50, 60, 70, 80, 90], "braking": [0, 10, 20, 30, 40, 50, 60, 70, 80, 90], "following": [0, 10, 20, 30, 40, 50, 60, 70, 80, 90]}, "migration_required": false}
@@ -1,50 +0,0 @@
// Fault-injection extension of the real Chromium synthetic-HTTP harness.
const assert=require('assert');
module.exports=async({page,curve,data,values,faults,counts,errors})=>{
const results=[];
const gate=key=>{let release;const promise=new Promise(r=>release=r);faults[key]={promise};return()=>{delete faults[key];release();};};
const idle=()=>page.waitForFunction(()=>{const v=document.querySelector('#app').__vue_app__._instance.proxy;return v.ready&&!v.busy&&!v.curvePending&&!v.contextPending;});
const poll=()=>page.evaluate(()=>{document.querySelector('#app').__vue_app__._instance.proxy.refreshContext();});
const edit=async value=>{const n=curve.locator('input').nth(2);await n.fill(value);await n.press('Tab');};
const remount=async()=>{
await page.evaluate(async()=>{const {createApp}=await import('/assets/vendor/vue/vue.esm-browser.js');const {PersonalityProfiles}=await import('/assets/mobile/js/components/PersonalityProfiles.js');createApp(PersonalityProfiles).mount('#app');});
await idle();await page.getByRole('button',{name:'Manage',exact:true}).click();await page.locator('.gx-personalities__advanced').first().locator(':scope > summary').click();
await page.evaluate(()=>clearInterval(document.querySelector('#app').__vue_app__._instance.proxy.timer));
};
const unmount=()=>page.evaluate(()=>{const app=document.querySelector('#app').__vue_app__;window.oldPersonality=app._instance.proxy;window.lateEmits=0;window.oldPersonality.$.emit=()=>window.lateEmits++;app.unmount();});
await idle();await page.evaluate(()=>clearInterval(document.querySelector('#app').__vue_app__._instance.proxy.timer));
await page.evaluate(async()=>await document.querySelector('#app').__vue_app__._instance.proxy.load());
// Change survives a pending context request and is sent only after it resolves.
let release=gate('params');await poll();const n=curve.locator('input').nth(2);await n.fill('1.65');assert.equal(await n.inputValue(),'1.65');assert(await n.isEnabled());
let before=counts().attempts;await n.press('Tab');assert.equal(counts().attempts,before);
assert(await n.isDisabled());release();await idle();assert.equal(counts().attempts,before+1);assert.equal(data.profiles.traffic.acceleration.curve[2],1.65);results.push('numeric pending poll commits once');
// Off-road state changing while the change waits must prevent the PUT.
release=gate('params');await poll();before=counts().attempts;await edit('1.7');values.IsOnroad='True';values.IsOffroad='';release();
await page.waitForFunction(()=>!document.querySelector('#app').__vue_app__._instance.proxy.curvePending);
assert.equal(counts().attempts,before);assert.equal(await n.inputValue(),'1.65');assert(await n.isDisabled());results.push('pending change rechecks offroad');
values.IsOnroad='';values.IsOffroad='True';await poll();await idle();
// In-flight PUT blocks repeat authoring until verified readback.
release=gate('put');before=counts().attempts;await edit('1.75');await page.waitForFunction(()=>document.querySelector('#app').__vue_app__._instance.proxy.busy);
assert(await n.isDisabled());await page.evaluate(()=>document.querySelector('#app').__vue_app__._instance.proxy.point('traffic','acceleration',2,{target:{value:'2'}}));
assert.equal(counts().attempts,before+1);release();await idle();assert.equal(await n.inputValue(),'1.75');results.push('pending PUT rejects duplicate edit');
// PUT accepted but both immediate readbacks fail: remain locked, no rollback claim.
faults.readFailures=2;await edit('1.8');await page.getByRole('button',{name:'Retry loading',exact:true}).waitFor();
assert(await n.isDisabled());assert.equal(data.profiles.traffic.acceleration.curve[2],1.8);
assert(!(await page.getByText('Save could not be confirmed. Showing verified saved state; review it before editing again.',{exact:true}).isVisible()));
await page.getByRole('button',{name:'Retry loading',exact:true}).click();await idle();assert.equal(await n.inputValue(),'1.8');
await page.getByText('Save could not be confirmed. Showing verified saved state; review it before editing again.',{exact:true}).waitFor();results.push('uncertain PUT readback failure locks until verified retry');
// Pointer preview cancels on loss of capture or a road-state transition.
const drag=async()=>{await page.locator('.snackbar').waitFor({state:'detached'});const h=curve.locator('circle[fill="transparent"]').nth(2);await h.scrollIntoViewIfNeeded();const b=await h.boundingBox();await page.mouse.move(b.x+b.width/2,b.y+b.height/2);await page.mouse.down();await page.mouse.move(b.x+b.width/2,b.y+b.height/2-8,{steps:3});};
faults.failPut=true;before=counts().attempts;await drag();await page.mouse.up();await idle();assert.equal(counts().attempts,before+1);assert.equal(await n.inputValue(),'1.8');await page.getByText('Save could not be confirmed. Showing verified saved state; review it before editing again.',{exact:true}).waitFor();results.push('failed pointer PUT restores verified state');
before=counts().attempts;await drag();await curve.locator('svg').evaluate(svg=>svg.releasePointerCapture(document.querySelector('#app').__vue_app__._instance.proxy.drag.pointerId));await page.mouse.up();assert.equal(await n.inputValue(),'1.8');assert.equal(counts().attempts,before);results.push('lost capture rolls back without PUT');
await drag();values.IsOnroad='True';values.IsOffroad='';await poll();await page.waitForFunction(()=>!document.querySelector('#app').__vue_app__._instance.proxy.contextPending);await page.mouse.up();assert.equal(await n.inputValue(),'1.8');assert.equal(counts().attempts,before);results.push('mid-drag road transition rolls back');
values.IsOnroad='';values.IsOffroad='True';await poll();await idle();
release=gate('params');await poll();await drag();await page.mouse.up();assert.equal(counts().attempts,before);release();await idle();assert.equal(counts().attempts,before+1);results.push('pointer release waits for pending poll');
// Unmount before release cancels preview; remount only loads server state.
before=counts().attempts;const saved=data.profiles.traffic.acceleration.curve[2];await drag();await unmount();await page.mouse.up();assert.equal(counts().attempts,before);await remount();assert.equal(Number(await n.inputValue()),saved);results.push('unmount drag does not save or resurrect preview');
// Unmount while waiting for poll prevents an edit from being sent afterward.
release=gate('params');await poll();await edit('1.95');await unmount();release();await page.waitForFunction(()=>!window.oldPersonality.curvePending);assert.equal(counts().attempts,before);assert.equal(await page.evaluate(()=>window.lateEmits),0);await remount();assert.equal(Number(await n.inputValue()),saved);results.push('unmount pending poll sends no PUT');
// An already sent PUT may complete, but disposed component must not emit/read/snack.
await page.locator('.snackbar').waitFor({state:'detached'});release=gate('put');await edit('2.05');await page.waitForFunction(()=>document.querySelector('#app').__vue_app__._instance.proxy.busy);const reads=counts().profileReads;await unmount();release();await page.waitForFunction(()=>!window.oldPersonality.busy);assert.equal(counts().profileReads,reads);assert.equal(await page.evaluate(()=>window.lateEmits),0);assert.equal(await page.locator('.snackbar').count(),0);await remount();assert.equal(await n.inputValue(),'2.05');results.push('unmount in-flight PUT suppresses late effects; remount reads saved result');
assert.deepEqual(errors,[]);console.log(`PASS: ${results.length} lifecycle fault-injection scenarios.`);return results;
};
@@ -1,46 +0,0 @@
// Real shipped Vue/CSS; delayed synthetic HTTP only, never device writes.
const assert = require('assert');
module.exports = async ({page, data, values, faults, counts, errors}) => {
await page.waitForFunction(() => document.querySelector('#app').__vue_app__._instance.proxy.ready);
await page.evaluate(() => clearInterval(document.querySelector('#app').__vue_app__._instance.proxy.timer));
const group = page.getByRole('group', {name:'Traffic Acceleration', exact:true});
const eco = group.getByRole('button', {name:'Eco', exact:true});
const custom = group.getByRole('button', {name:'Custom', exact:true});
const appearance = () => eco.evaluate(b => ({disabled:b.disabled, opacity:getComputedStyle(b).opacity, color:getComputedStyle(b).color, background:getComputedStyle(b).backgroundColor}));
const gate = async () => {
let release; faults.params = {promise:new Promise(r => release=r)};
await page.evaluate(() => { document.querySelector('#app').__vue_app__._instance.proxy.refreshContext(); });
await page.waitForFunction(() => document.querySelector('#app').__vue_app__._instance.proxy.contextPending);
return () => {delete faults.params; release();};
};
const idle = () => page.waitForFunction(() => {const v=document.querySelector('#app').__vue_app__._instance.proxy;return !v.contextPending&&!v.busy;});
const before = await appearance();
for(let i=0;i<3;i++) {
const release = await gate();
assert.deepEqual(await appearance(), before, 'routine polling must not dim/disable preset buttons');
release(); await idle(); assert.deepEqual(await appearance(), before);
}
let release = await gate(); let attempts = counts().attempts;
await eco.click(); assert.equal(counts().attempts, attempts, 'click waits for context');
release(); await page.waitForFunction(() => {const v=document.querySelector('#app').__vue_app__._instance.proxy;return !v.busy&&v.data.profiles.traffic.acceleration.preset==='eco';});
assert.equal(counts().attempts, attempts+1);
// A road-state change during the read must reject the queued click.
release = await gate(); attempts = counts().attempts;
await custom.click(); values.IsOnroad='True'; values.IsOffroad=''; release(); await idle();
assert.equal(counts().attempts, attempts); assert(await custom.isDisabled());
values.IsOnroad=''; values.IsOffroad='True';
await page.evaluate(async () => await document.querySelector('#app').__vue_app__._instance.proxy.refreshContext());
// Multiple clicks waiting for one poll cannot produce overlapping PUTs.
release = await gate(); let releasePut; faults.put={promise:new Promise(r=>releasePut=r)};
await custom.click(); await group.getByRole('button', {name:'Standard', exact:true}).click(); release();
await page.waitForFunction(() => document.querySelector('#app').__vue_app__._instance.proxy.busy);
assert.equal(counts().attempts, attempts+1); assert(await custom.isDisabled());
delete faults.put; releasePut(); await idle();
assert.equal(data.profiles.traffic.acceleration.preset, 'custom');
// Disposed editors cannot send a delayed action.
release = await gate(); attempts = counts().attempts; await eco.click();
await page.evaluate(() => document.querySelector('#app').__vue_app__.unmount());
release(); await page.waitForTimeout(100); assert.equal(counts().attempts, attempts);
assert.deepEqual(errors, []);
console.log('PASS: stable appearance across 3 polls; deferred click saves once; road transition blocks; pending PUT blocks overlaps; unmount cancels.');
};
@@ -1,220 +0,0 @@
// Real Chromium, synthetic API only. Never contacts a device or writes real Params.
// NODE_PATH=<playwright node_modules> CHROMIUM_EXECUTABLE=<optional browser> node this-file
const {chromium}=require('playwright');
const fs=require('fs');const path=require('path');const assert=require('assert');
const root=path.resolve(__dirname,'../../../../..');
const output=process.env.PERSONALITY_BROWSER_OUTPUT || path.join(require('os').tmpdir(),'bigdipper-personality-browser');fs.mkdirSync(output,{recursive:true});
(async()=>{
const browser=await chromium.launch({headless:true, executablePath:process.env.CHROMIUM_EXECUTABLE || undefined,args:['--no-sandbox']});
try {
const dpr=Number(process.env.PERSONALITY_DPR || 1);const page=await browser.newPage({deviceScaleFactor:dpr,hasTouch:dpr>1});const errors=[]; page.on('pageerror',e=>errors.push(e.message));
const data=JSON.parse(fs.readFileSync(path.join(__dirname,'fixtures/personality_profiles.json')));const writes=[];let failWrite=false;
const faults={};let attempts=0,profileReads=0;const waitGate=async key=>{if(faults[key])await faults[key].promise;};
const values={IsOnroad:'',IsOffroad:'True',IsMetric:true,VehicleParked:true,CustomPersonalities:true,AggressivePersonalityProfile:true,StandardPersonalityProfile:true,RelaxedPersonalityProfile:true,TrafficPersonalityProfile:true};
const layout=JSON.parse(fs.readFileSync(root+'/starpilot/common/assets/device_settings_layout.json'));
for(const p of layout.flatMap(s=>s.params)) if(p.key.includes('Jerk')) values[p.key]=100;
await page.route('http://bigdipper.test/**',async route=>{
const u=new URL(route.request().url()); let file;
if(u.pathname==='/')return route.fulfill({contentType:'text/html',body:`<script type="importmap">{"imports":{"vue":"/assets/vendor/vue/vue.esm-browser.js"}}</script><link rel="stylesheet" href="/assets/vendor/bootstrap-icons/bootstrap-icons.min.css"><link rel="stylesheet" href="/assets/mobile/css/material.css"><div id="app"></div><div id="snackbar_wrapper"></div><script type="module">import {createApp} from '/assets/vendor/vue/vue.esm-browser.js';import {PersonalityProfiles} from '/assets/mobile/js/components/PersonalityProfiles.js';createApp(PersonalityProfiles).mount('#app');</script>`});
if(u.pathname==='/api/params/all'){await waitGate('params');return route.fulfill({json:values});}
if(u.pathname==='/api/params/defaults')return route.fulfill({json:{}});
if(u.pathname==='/api/params'){const d=route.request().postDataJSON();values[d.key]=d.value;return route.fulfill({json:{success:true}});}
if(u.pathname==='/api/personality_profiles'){
if(route.request().method()==='PUT'){attempts++;await waitGate('put');if(failWrite||faults.failPut){failWrite=false;faults.failPut=false;return route.fulfill({status:503,json:{error:'Synthetic save failure'}});}const d=route.request().postDataJSON();writes.push(d);data.profiles[d.profile][d.category]={preset:d.preset,curve:d.curve.length?d.curve:[...data.reference_curves[d.profile][d.category]]};}
else {profileReads++;if(faults.readFailures){faults.readFailures--;return route.fulfill({status:503,json:{error:'Synthetic readback failure'}});}}
return route.fulfill({json:data});}
if(u.pathname.endsWith('device_settings_layout.json'))file=root+'/starpilot/common/assets/device_settings_layout.json';
else if(u.pathname.startsWith('/assets/'))file=root+'/starpilot/system/the_galaxy'+u.pathname;
if(file&&fs.existsSync(file))return route.fulfill({body:fs.readFileSync(file),contentType:file.endsWith('.css')?'text/css':file.endsWith('.json')?'application/json':'text/javascript'});
errors.push(`Unexpected synthetic request: ${route.request().method()} ${u.pathname}`);return route.abort();
});
await page.goto('http://bigdipper.test/');
await page.getByRole('button',{name:'Manage',exact:true}).waitFor();
assert(!(await page.locator('#gx-personality-settings').isVisible()));
await page.getByRole('button',{name:'Manage',exact:true}).click();
await page.waitForSelector('.gx-personalities__profile');
await page.locator('.gx-manage-btn').click();
assert(!(await page.locator('#gx-personality-settings').isVisible()));
await page.getByRole('button',{name:'Manage',exact:true}).click();
assert.equal(await page.locator('.gx-personalities__profile').count(),4);
if(process.env.PERSONALITY_POLL_ONLY){
await require('./personality_poll.cjs')({page,data,values,faults,counts:()=>({attempts}),errors});
return;
}
assert.equal(await page.locator('.gx-manage-btn').getAttribute('class'),'gx-manage-btn');
const master=page.getByRole('checkbox',{name:'Custom personalities',exact:true});assert(await master.isChecked());
values.CustomPersonalities='False';await page.waitForTimeout(4200);assert(!(await master.isChecked()),'text False not checked');assert.equal(await page.locator('.gx-personalities__profile').count(),4);
values.CustomPersonalities=true;await page.waitForSelector('.gx-personalities__profile');
for(const width of [320,390,768,1280]){
await page.setViewportSize({width,height:900});
assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),`overflow ${width}`);
await page.screenshot({path:path.join(output,`overview-${width}.png`),fullPage:true});
}
await page.locator('.gx-personalities__profile').first().getByRole('button',{name:'Custom',exact:true}).first().click();
// A click completes before the HTTP write/readback. Assert the same exact
// write count and payload only after the saved Custom state is rendered.
await page.waitForFunction(()=>{const vm=document.querySelector('#app').__vue_app__._instance.proxy;return !vm.busy && vm.data.profiles.traffic.acceleration.preset==='custom';});
assert.equal(writes.length,1);assert.deepEqual(writes[0].curve,[]);
await page.waitForSelector('.gx-personalities__curve');
assert(await page.locator('.gx-personalities__curve svg').first().isVisible());
const curve=page.locator('.gx-personalities__curve').first();
assert.equal(await curve.locator('svg text').count(),12);
assert((await curve.locator('svg').textContent()).includes('Speed (km/h)'));
console.log('PASS: collapsed by default; Manage/Close toggles section; numeric axes and units rendered.');
for(const width of [390,1280]) {
await page.setViewportSize({width,height:900});
const handle=curve.locator('circle[fill="transparent"]').nth(2);
await handle.scrollIntoViewIfNeeded();
const before=Number(await curve.locator('input').nth(2).inputValue());
const box=await handle.boundingBox();
const writesBeforeDrag=writes.length;
await page.mouse.move(box.x+box.width/2,box.y+box.height/2);await page.mouse.down();
await page.mouse.move(box.x+box.width/2,box.y+box.height/2+15,{steps:5});assert.equal(writes.length,writesBeforeDrag,'preview never writes');await page.mouse.up();
const after=Number(await curve.locator('input').nth(2).inputValue());
assert.notEqual(after,before,'drag updates numeric draft');
await page.waitForFunction(()=>!document.querySelector('#app').__vue_app__._instance.proxy.busy);
assert.equal(writes.length,width===390?2:3,'release commits exactly once');
}
const touch=await page.context().newCDPSession(page);
const handle=curve.locator('circle[fill="transparent"]').nth(2);await handle.scrollIntoViewIfNeeded();
const box=await handle.boundingBox();const x=box.x+box.width/2,y=box.y+box.height/2;
const beforeTouch=await curve.locator('input').nth(2).inputValue();
await touch.send('Input.dispatchTouchEvent',{type:'touchStart',touchPoints:[{x,y}]});
await touch.send('Input.dispatchTouchEvent',{type:'touchMove',touchPoints:[{x,y:y-12}]});
await touch.send('Input.dispatchTouchEvent',{type:'touchEnd',touchPoints:[]});
await page.waitForFunction(()=>{const vm=document.querySelector('#app').__vue_app__._instance.proxy;return !vm.drag&&!vm.curvePending&&!vm.busy;});
assert.notEqual(await curve.locator('input').nth(2).inputValue(),beforeTouch,'touch drag updates draft');
assert.equal(writes.length,4);
console.log('PASS: browser touch release commits once.');
const beforeCancel=await curve.locator('input').nth(2).inputValue();
await touch.send('Input.dispatchTouchEvent',{type:'touchStart',touchPoints:[{x,y}]});
await touch.send('Input.dispatchTouchEvent',{type:'touchMove',touchPoints:[{x,y:y+10}]});
await touch.send('Input.dispatchTouchEvent',{type:'touchCancel',touchPoints:[]});
// CDP acknowledgement can precede pointercancel delivery and Vue's DOM update.
await page.waitForFunction(()=>!document.querySelector('#app').__vue_app__._instance.proxy.drag);
assert.equal(await curve.locator('input').nth(2).inputValue(),beforeCancel,'cancel restores pre-drag draft');
assert.equal(writes.length,4,'cancel does not write');
assert.equal(await page.getByRole('button',{name:'Save curve',exact:true}).count(),0);
const input=curve.locator('input').nth(2);await input.fill('1.234');await input.press('Tab');
assert((await curve.getByRole('alert').textContent()).includes('0.05'),'step errors inline');assert.equal(writes.length,4);
await input.fill('1.25');await input.press('Tab');
await page.waitForFunction(()=>!document.querySelector('#app').__vue_app__._instance.proxy.curvePending);
assert.equal(writes.length,5);assert.equal(data.profiles.traffic.acceleration.curve[2],1.25);
console.log('PASS: mouse release and numeric change persist without Save.');
assert.equal(await page.getByRole('button',{name:'Refresh',exact:true}).count(),0);
const advanced=page.locator('.gx-personalities__advanced').first();
if(!(await advanced.getAttribute('open') !== null)) await advanced.locator(':scope > summary').click();
assert.equal(await advanced.locator(':scope > .gx-personalities__category input[type=number]').count(),0);
const row=advanced.locator('.gx-personalities__category').first();
await row.getByRole('button',{name:'Custom',exact:true}).click();
assert(await row.locator('input').isVisible());
await row.getByRole('button',{name:'Chill',exact:true}).click();
await page.waitForFunction(()=>document.querySelector('.gx-personalities__advanced .gx-personalities__category input')===null);
assert.equal(await row.getByRole('button',{name:'Chill',exact:true}).getAttribute('aria-pressed'),'true');
values.IsOnroad='True';values.IsOffroad='';
await page.waitForFunction(()=>document.querySelector('.gx-personalities__options button').disabled);
assert.deepEqual(errors,[]);
values.IsOnroad='';values.IsOffroad='True';
await page.waitForFunction(()=>!document.querySelector('.gx-personalities__options button').disabled);
console.log('PASS: real API road-state encodings unlock parked controls; Custom graph opens; no Refresh; advanced inputs Custom-only; on-road tuning guard retained.');
assert.deepEqual(errors,[]);
console.log('PASS: four profile cards; no horizontal overflow at 320/390/768/1280; Custom delegates initial curve to backend; no browser errors. Synthetic API only.');
const results=[];
for(let pi=0;pi<4;pi++) for(let ci=0;ci<3;ci++) {
const card=page.locator('.gx-personalities__profile').nth(pi),category=card.locator(':scope > .gx-personalities__category').nth(ci);
await category.getByRole('button',{name:'Custom',exact:true}).click();
const graph=card.locator('.gx-personalities__curve').nth(ci);await graph.waitFor();
assert.equal(await graph.locator('svg text').count(),12);assert.equal(await graph.locator('input').count(),10);
const n=graph.locator('input').nth(2);await n.fill('1.5');
if(pi===0&&ci===0){await page.evaluate(async()=>await document.querySelector('#app').__vue_app__._instance.proxy.refreshContext());assert.equal(await n.inputValue(),'1.5','poll preserves focused curve text');}
await n.press('Tab');
await page.waitForFunction(()=>!document.querySelector('#app').__vue_app__._instance.proxy.curvePending);
assert.equal(Number(await n.inputValue()),1.5);
await graph.getByRole('button',{name:'Reset to default',exact:true}).click();
await page.waitForFunction(()=>!document.querySelector('#app').__vue_app__._instance.proxy.busy);
const p=['traffic','aggressive','standard','relaxed'][pi],c=['acceleration','braking','following'][ci];
assert.deepEqual(await graph.locator('input').evaluateAll(ns=>ns.map(n=>Number(n.value))),data.reference_curves[p][c]);
results.push({profile:p,category:c,axes:true,numericSave:true,reset:true});
}
const advancedResults=[];
for(let pi=0;pi<4;pi++){
const rows=page.locator('.gx-personalities__profile').nth(pi).locator('.gx-personalities__advanced > .gx-personalities__category');assert.equal(await rows.count(),5);
for(let i=0;i<5;i++){
const r=rows.nth(i);const label=await r.locator('label').textContent();
await r.getByRole('button',{name:'Custom',exact:true}).click();const n=r.locator('input');
assert.equal(await n.getAttribute('min'),'25');assert.equal(await n.getAttribute('max'),'200');assert.equal(await n.getAttribute('step'),'1');
await n.fill('125.5');await n.press('Tab');await r.getByRole('alert').waitFor({state:'visible'});
await n.fill('125');
if(pi===0&&i===0){await page.evaluate(async()=>await document.querySelector('#app').__vue_app__._instance.proxy.refreshContext());assert.equal(await n.inputValue(),'125','poll preserves focused advanced text');}
await n.press('Tab');await page.waitForFunction(()=>!document.querySelector('#app').__vue_app__._instance.proxy.busy);assert.equal(await n.inputValue(),'125');
await r.getByRole('button',{name:'Standard',exact:true}).click();await n.waitFor({state:'detached'});advancedResults.push({profile:pi,row:i,label,customSave:true,stepError:true,standard:true});
}
}
// Failed writes restore verified server state, not a silently retained draft.
const savedBeforeFailure=data.profiles.traffic.acceleration.curve[2];failWrite=true;
await curve.locator('input').nth(2).fill('1.5');await curve.locator('input').nth(2).press('Tab');
await page.getByText('Save could not be confirmed. Showing verified saved state; review it before editing again.',{exact:true}).waitFor();
await page.waitForFunction(()=>!document.querySelector('#app').__vue_app__._instance.proxy.curvePending);
assert.equal(Number(await curve.locator('input').nth(2).inputValue()),savedBeforeFailure);
// Historical high points must stay visible and unchanged while another point is authored.
data.profiles.traffic.acceleration.curve[0]=6;
await page.evaluate(async()=>await document.querySelector('#app').__vue_app__._instance.proxy.load());
assert.equal(await curve.locator('input').first().inputValue(),'6');assert.equal(await curve.locator('input').first().getAttribute('max'),'3.5');
assert((await curve.locator('svg text').allTextContents()).includes('6'));
await curve.locator('input').nth(2).fill('1.5');await curve.locator('input').nth(2).press('Tab');
await page.waitForFunction(()=>!document.querySelector('#app').__vue_app__._instance.proxy.busy);assert.equal(data.profiles.traffic.acceleration.curve[0],6);
values.IsMetric='False';await page.evaluate(async()=>await document.querySelector('#app').__vue_app__._instance.proxy.refreshContext());
assert((await curve.locator('svg').textContent()).includes('Speed (mph)'));
const matrix=[];
await page.locator('.snackbar').waitFor({state:'detached'});
for(const width of [320,375,390,768,1280,1920]) for(const zoom of [.75,1,1.25,1.5,2]) {
await page.setViewportSize({width,height:1000});await page.evaluate(z=>document.documentElement.style.zoom=z,zoom);
assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth / Number(document.documentElement.style.zoom)+1),`overflow width ${width} zoom ${zoom}`);
await curve.scrollIntoViewIfNeeded();
await page.screenshot({path:path.join(output,`curve-${width}-${zoom}.png`)});
const h=curve.locator('circle[fill="transparent"]').nth(3);await h.scrollIntoViewIfNeeded();const b=await h.boundingBox();
const old=await curve.locator('input').nth(3).inputValue();
await page.mouse.move(b.x+b.width/2,b.y+b.height/2);await page.mouse.down();await page.mouse.move(b.x+b.width/2,b.y+b.height/2-5,{steps:3});await page.mouse.up();
assert.notEqual(await curve.locator('input').nth(3).inputValue(),old,`drag ${width}/${zoom}`);
await page.waitForFunction(()=>!document.querySelector('#app').__vue_app__._instance.proxy.curvePending);
await curve.locator('input').nth(3).fill(old);await curve.locator('input').nth(3).press('Tab');
await page.waitForFunction(()=>!document.querySelector('#app').__vue_app__._instance.proxy.curvePending);
await page.locator('.snackbar').waitFor({state:'detached'});matrix.push({width,zoom,overflow:false,drag:true});
}
await page.evaluate(()=>document.documentElement.style.zoom=1);await page.setViewportSize({width:1280,height:900});
// Verify validation and draft reconciliation on the actual Vue instance, without API writes.
const edgeCases=await page.evaluate(async()=>{
const v=document.querySelector('#app').__vue_app__._instance.proxy;const copy=()=>JSON.parse(JSON.stringify(v.data));
const cases=[];for(const mutate of [d=>delete d.reference_curves.traffic.acceleration,d=>d.speed_breakpoints_mph.braking[1]=NaN,d=>d.bounds.following=[3,1],d=>d.profiles.standard.braking.preset='bogus']){
const d=copy();mutate(d);let rejected=false;try{v.validate(d)}catch{rejected=true}if(!rejected)throw Error('malformed metadata accepted');cases.push('malformed rejected');
}
for(const c of ['braking','following'])if(v.graphMin('traffic',c)!==v.data.bounds[c][0])throw Error('nonzero lower bound');
v.point('traffic','acceleration',2,{target:{value:'1.5'}},true);const d=copy();d.profiles.traffic.acceleration.curve[2]=1.4;v.acceptData(d);if(v.drafts.trafficacceleration)throw Error('stale preview retained');
for(const p of v.PROFILES){const suffixes=['Acceleration','Deceleration','Danger','SpeedDecrease','Speed'];if(JSON.stringify(v.advancedParams(p).map(p=>p.key).sort())!==JSON.stringify(suffixes.map(s=>v.label(p)+'Jerk'+s).sort()))throw Error('advanced key set');}
return cases;
});
const lifecycle=await require('./personality_lifecycle.cjs')({page,curve,data,values,faults,counts:()=>({attempts,profileReads}),errors});
// Actual Settings -> SettingTree integration, not a hand-built replacement row.
values.GalaxyDeveloperMode=true;
await page.evaluate(async()=>{
document.querySelector('#app').__vue_app__.unmount();
const {createApp}=await import('/assets/vendor/vue/vue.esm-browser.js');const {Settings}=await import('/assets/mobile/js/views/Settings.js');const {store}=await import('/assets/mobile/js/store.js');
store.route='/settings/longitudinal-speed-following';store.params={open:'CustomPersonalities'};store.search='';createApp(Settings).mount('#app');
});
await page.locator('.gx-personalities__grid').waitFor();assert(await page.locator('#gx-personality-settings').isVisible());
assert.equal(await page.locator('.gx-longitudinal-mode').count(),0,'personality-only settings do not introduce unified mode');
for(const theme of ['dark','light']) {
await page.evaluate(t=>document.documentElement.dataset.theme=t,theme);
await page.locator('.gx-personalities__heading').scrollIntoViewIfNeeded();
await page.screenshot({path:path.join(output,`settings-${theme}.png`)});
}
await page.evaluate(async()=>{const {store}=await import('/assets/mobile/js/store.js');store.search='TrafficJerkAcceleration'});
await page.waitForFunction(()=>document.querySelector('#app').__vue_app__._instance.proxy.searchResults.length>0);
assert.equal(await page.locator('.gx-personalities').count(),1,'search shows editor once');
assert.deepEqual(await page.evaluate(()=>document.querySelector('#app').__vue_app__._instance.proxy.searchResults.flatMap(s=>s.matches.map(p=>p.key))),['CustomPersonalities']);
assert.deepEqual(errors,[]);fs.writeFileSync(path.join(output,'browser-results.json'),JSON.stringify({syntheticAPI:true,dpr,curves:results,advanced:advancedResults,matrix,edgeCases,lifecycle,settingsIntegration:true,search:true,browserErrors:errors},null,2));
console.log(`PASS: ${results.length} profile/category combinations axes, numeric save/reset; ${matrix.length} viewport/zoom drag cases; failed-write verified recovery; historical 6.0 preservation; textual imperial units.`);
}finally{await browser.close();}
})().catch(e=>{console.error(e);process.exitCode=1;});
@@ -1,143 +0,0 @@
// Real Chromium, synthetic API only. Never contacts a device or writes real Params.
// NODE_PATH=<playwright node_modules> CHROMIUM_EXECUTABLE=<optional browser> node this-file
const {chromium}=require('playwright');
const fs=require('fs');const path=require('path');const assert=require('assert');
const root=path.resolve(__dirname,'../../../../..');
const output=process.env.PERSONALITY_BROWSER_OUTPUT || path.join(require('os').tmpdir(),'bigdipper-personality-browser');fs.mkdirSync(output,{recursive:true});
(async()=>{
const browser=await chromium.launch({headless:true, executablePath:process.env.CHROMIUM_EXECUTABLE || undefined,args:['--no-sandbox']});
try {
const dpr=Number(process.env.PERSONALITY_DPR || 1);const page=await browser.newPage({deviceScaleFactor:dpr,hasTouch:dpr>1});const errors=[]; page.on('pageerror',e=>errors.push(e.message));
const data=JSON.parse(fs.readFileSync(path.join(__dirname,'fixtures/personality_profiles.json')));const writes=[];let failWrite=false;
const faults={};let attempts=0,profileReads=0;const waitGate=async key=>{if(faults[key])await faults[key].promise;};
const values={IsOnroad:'',IsOffroad:'True',IsMetric:true,VehicleParked:true,CustomPersonalities:true,AggressivePersonalityProfile:true,StandardPersonalityProfile:true,RelaxedPersonalityProfile:true,TrafficPersonalityProfile:true};
const layout=JSON.parse(fs.readFileSync(root+'/starpilot/common/assets/device_settings_layout.json'));
for(const p of layout.flatMap(s=>s.params)) if(p.key.includes('Jerk')) values[p.key]=100;
await page.route('http://bigdipper.test/**',async route=>{
const u=new URL(route.request().url()); let file;
if(u.pathname==='/')return route.fulfill({contentType:'text/html',body:`<script type="importmap">{"imports":{"vue":"/assets/vendor/vue/vue.esm-browser.js"}}</script><link rel="stylesheet" href="/assets/vendor/bootstrap-icons/bootstrap-icons.min.css"><link rel="stylesheet" href="/assets/mobile/css/material.css"><div id="app"></div><div id="snackbar_wrapper"></div><script type="module">import {createApp} from '/assets/vendor/vue/vue.esm-browser.js';import {PersonalityProfiles} from '/assets/mobile/js/components/PersonalityProfiles.js';createApp(PersonalityProfiles).mount('#app');</script>`});
if(u.pathname==='/api/params/all'){await waitGate('params');return route.fulfill({json:values});}
if(u.pathname==='/api/params/defaults')return route.fulfill({json:{}});
if(u.pathname==='/api/params'){const d=route.request().postDataJSON();values[d.key]=d.value;return route.fulfill({json:{success:true}});}
if(u.pathname==='/api/personality_profiles'){
if(route.request().method()==='PUT'){attempts++;await waitGate('put');if(failWrite||faults.failPut){failWrite=false;faults.failPut=false;return route.fulfill({status:503,json:{error:'Synthetic save failure'}});}const d=route.request().postDataJSON();writes.push(d);data.profiles[d.profile][d.category]={preset:d.preset,curve:d.curve.length?d.curve:[...data.reference_curves[d.profile][d.category]]};}
else {profileReads++;if(faults.readFailures){faults.readFailures--;return route.fulfill({status:503,json:{error:'Synthetic readback failure'}});}}
return route.fulfill({json:data});}
if(u.pathname.endsWith('device_settings_layout.json'))file=root+'/starpilot/common/assets/device_settings_layout.json';
else if(u.pathname.startsWith('/assets/'))file=root+'/starpilot/system/the_galaxy'+u.pathname;
if(file&&fs.existsSync(file))return route.fulfill({body:fs.readFileSync(file),contentType:file.endsWith('.css')?'text/css':file.endsWith('.json')?'application/json':'text/javascript'});
errors.push(`Unexpected synthetic request: ${route.request().method()} ${u.pathname}`);return route.abort();
});
await page.goto('http://bigdipper.test/');
await page.getByRole('button',{name:'Manage',exact:true}).waitFor();
assert(!(await page.locator('#gx-personality-settings').isVisible()));
await page.getByRole('button',{name:'Manage',exact:true}).click();
await page.waitForSelector('.gx-personalities__profile');
await page.locator('.gx-manage-btn').click();
assert(!(await page.locator('#gx-personality-settings').isVisible()));
await page.getByRole('button',{name:'Manage',exact:true}).click();
assert.equal(await page.locator('.gx-personalities__profile').count(),4);
assert.equal(await page.locator('.gx-manage-btn').getAttribute('class'),'gx-manage-btn');
const master=page.getByRole('checkbox',{name:'Custom personalities',exact:true});assert(await master.isChecked());
values.CustomPersonalities='False';await page.waitForTimeout(4200);assert(!(await master.isChecked()),'text False not checked');assert.equal(await page.locator('.gx-personalities__profile').count(),4);
values.CustomPersonalities=true;await page.waitForSelector('.gx-personalities__profile');
for(const width of [320,390,768,1280]){
await page.setViewportSize({width,height:900});
assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),`overflow ${width}`);
await page.screenshot({path:path.join(output,`overview-${width}.png`),fullPage:true});
}
await page.locator('.gx-personalities__profile').first().getByRole('button',{name:'Custom',exact:true}).first().click();
// A click completes before the HTTP write/readback. Assert the same exact
// write count and payload only after the saved Custom state is rendered.
await page.waitForFunction(()=>{const vm=document.querySelector('#app').__vue_app__._instance.proxy;return !vm.busy && vm.data.profiles.traffic.acceleration.preset==='custom';});
assert.equal(writes.length,1);assert.deepEqual(writes[0].curve,[]);
await page.waitForSelector('.gx-personalities__curve');
assert(await page.locator('.gx-personalities__curve svg').first().isVisible());
const curve=page.locator('.gx-personalities__curve').first();
assert.equal(await curve.locator('svg text').count(),12);
assert((await curve.locator('svg').textContent()).includes('Speed (km/h)'));
console.log('PASS: collapsed by default; Manage/Close toggles section; numeric axes and units rendered.');
for(const width of [390,1280]) {
await page.setViewportSize({width,height:900});
const handle=curve.locator('circle[fill="transparent"]').nth(2);
await handle.scrollIntoViewIfNeeded();
const before=Number(await curve.locator('input').nth(2).inputValue());
const box=await handle.boundingBox();
const writesBeforeDrag=writes.length;
await page.mouse.move(box.x+box.width/2,box.y+box.height/2);await page.mouse.down();
await page.mouse.move(box.x+box.width/2,box.y+box.height/2+15,{steps:5});assert.equal(writes.length,writesBeforeDrag,'preview never writes');await page.mouse.up();
const after=Number(await curve.locator('input').nth(2).inputValue());
assert.notEqual(after,before,'drag updates numeric draft');
await page.waitForFunction(()=>!document.querySelector('#app').__vue_app__._instance.proxy.busy);
assert.equal(writes.length,width===390?2:3,'release commits exactly once');
}
const touch=await page.context().newCDPSession(page);
const handle=curve.locator('circle[fill="transparent"]').nth(2);await handle.scrollIntoViewIfNeeded();
const box=await handle.boundingBox();const x=box.x+box.width/2,y=box.y+box.height/2;
const beforeTouch=await curve.locator('input').nth(2).inputValue();
await touch.send('Input.dispatchTouchEvent',{type:'touchStart',touchPoints:[{x,y}]});
await touch.send('Input.dispatchTouchEvent',{type:'touchMove',touchPoints:[{x,y:y-12}]});
await touch.send('Input.dispatchTouchEvent',{type:'touchEnd',touchPoints:[]});
await page.waitForFunction(()=>{const vm=document.querySelector('#app').__vue_app__._instance.proxy;return !vm.drag&&!vm.curvePending&&!vm.busy;});
assert.notEqual(await curve.locator('input').nth(2).inputValue(),beforeTouch,'touch drag updates draft');
assert.equal(writes.length,4);
console.log('PASS: browser touch release commits once.');
const beforeCancel=await curve.locator('input').nth(2).inputValue();
await touch.send('Input.dispatchTouchEvent',{type:'touchStart',touchPoints:[{x,y}]});
await touch.send('Input.dispatchTouchEvent',{type:'touchMove',touchPoints:[{x,y:y+10}]});
await touch.send('Input.dispatchTouchEvent',{type:'touchCancel',touchPoints:[]});
// CDP acknowledgement can precede pointercancel delivery and Vue's DOM update.
await page.waitForFunction(()=>!document.querySelector('#app').__vue_app__._instance.proxy.drag);
assert.equal(await curve.locator('input').nth(2).inputValue(),beforeCancel,'cancel restores pre-drag draft');
assert.equal(writes.length,4,'cancel does not write');
assert.equal(await page.getByRole('button',{name:'Save curve',exact:true}).count(),0);
const input=curve.locator('input').nth(2);await input.fill('1.234');await input.press('Tab');
assert((await curve.getByRole('alert').textContent()).includes('0.05'),'step errors inline');assert.equal(writes.length,4);
await input.fill('1.25');await input.press('Tab');
await page.waitForFunction(()=>!document.querySelector('#app').__vue_app__._instance.proxy.curvePending);
assert.equal(writes.length,5);assert.equal(data.profiles.traffic.acceleration.curve[2],1.25);
console.log('PASS: mouse release and numeric change persist without Save.');
assert.equal(await page.getByRole('button',{name:'Refresh',exact:true}).count(),0);
const advanced=page.locator('.gx-personalities__advanced').first();
if(!(await advanced.getAttribute('open') !== null)) await advanced.locator(':scope > summary').click();
assert.equal(await advanced.locator(':scope > .gx-personalities__category input[type=number]').count(),0);
const row=advanced.locator('.gx-personalities__category').first();
await row.getByRole('button',{name:'Custom',exact:true}).click();
assert(await row.locator('input').isVisible());
await row.getByRole('button',{name:'Chill',exact:true}).click();
await page.waitForFunction(()=>document.querySelector('.gx-personalities__advanced .gx-personalities__category input')===null);
assert.equal(await row.getByRole('button',{name:'Chill',exact:true}).getAttribute('aria-pressed'),'true');
values.IsOnroad='True';values.IsOffroad='';
await page.waitForFunction(()=>document.querySelector('.gx-personalities__options button').disabled);
assert.deepEqual(errors,[]);
values.IsOnroad='';values.IsOffroad='True';
await page.waitForFunction(()=>!document.querySelector('.gx-personalities__options button').disabled);
console.log('PASS: real API road-state encodings unlock parked controls; Custom graph opens; no Refresh; advanced inputs Custom-only; on-road tuning guard retained.');
assert.deepEqual(errors,[]);
console.log('PASS: four profile cards; no horizontal overflow at 320/390/768/1280; Custom delegates initial curve to backend; no browser errors. Synthetic API only.');
await page.setViewportSize({width:320,height:1000});await page.evaluate(()=>document.documentElement.style.zoom=2);
await page.locator('.snackbar').waitFor({state:'detached'});
const plot=curve.locator('.gx-personalities__plot');await plot.scrollIntoViewIfNeeded();
const beforeScrollWrites=writes.length;
await plot.evaluate(el=>{el.scrollLeft=0;el.focus();});
await page.keyboard.press('ArrowRight');
await page.waitForFunction(()=>document.querySelector('.gx-personalities__plot').scrollLeft>0);
const keyboardScroll=await plot.evaluate(el=>el.scrollLeft);
await plot.evaluate(el=>el.scrollLeft=0);
const boxScroll=await plot.boundingBox(); const sx=boxScroll.x+boxScroll.width*.85, sy=boxScroll.y+boxScroll.height*.5;
await touch.send('Input.dispatchTouchEvent',{type:'touchStart',touchPoints:[{x:sx,y:sy}]});
for(let i=1;i<=6;i++) await touch.send('Input.dispatchTouchEvent',{type:'touchMove',touchPoints:[{x:sx-i*15,y:sy}]});
await touch.send('Input.dispatchTouchEvent',{type:'touchEnd',touchPoints:[]});
await page.waitForFunction(()=>document.querySelector('.gx-personalities__plot').scrollLeft>0);
await page.waitForFunction(()=>!document.querySelector('#app').__vue_app__._instance.proxy.drag);
assert.equal(writes.length,beforeScrollWrites,'horizontal pan must not save a curve');
const touchScroll=await plot.evaluate(el=>el.scrollLeft);
const inputs=curve.locator('input');assert.equal(await inputs.count(),10);
for(let i=0;i<10;i++) {await inputs.nth(i).scrollIntoViewIfNeeded();await inputs.nth(i).focus();assert(await inputs.nth(i).isVisible());assert(await inputs.nth(i).isEnabled());assert(await inputs.nth(i).evaluate(el=>document.activeElement===el));}
assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth/2+1),'whole-page horizontal overflow');
await plot.evaluate(el=>el.scrollLeft=el.scrollWidth);await plot.scrollIntoViewIfNeeded();
await page.screenshot({path:path.join(output,'320-200-percent-scrolled.png')});
const result={syntheticAPI:true,width:320,zoom:2,dpr,keyboardScroll,touchScroll,numericPoints:10,panWrites:writes.length-beforeScrollWrites,wholePageOverflow:false,errors};
fs.writeFileSync(path.join(output,'scroll-results.json'),JSON.stringify(result,null,2));console.log(JSON.stringify(result));
}finally{await browser.close();}
})().catch(e=>{console.error(e);process.exitCode=1;});
@@ -1,148 +0,0 @@
// Real Vue Settings renderer + shipped CSS; synthetic API, no device access.
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const {chromium} = require(process.env.PLAYWRIGHT_MODULE || 'playwright')
const repo = path.resolve(__dirname, '../../../..')
const assets = path.join(repo, 'starpilot/system/the_galaxy/assets')
const layout = JSON.parse(fs.readFileSync(path.join(repo,'starpilot/common/assets/device_settings_layout.json')))
const out = process.env.MODE_EVIDENCE || '/opt/data/workspace/speed-control-big-dipper-evidence'
fs.mkdirSync(out,{recursive:true})
const fixture = `
import {createApp} from 'vue';
import {Settings} from '/assets/mobile/js/views/Settings.js';
import {store} from '/assets/mobile/js/store.js';
window.store=store; store.route='/settings/longitudinal-speed-following';
const layout=${JSON.stringify(layout)};
const values=Object.fromEntries(layout.flatMap(s=>s.params||[]).map(p=>[p.key,p.data_type==='bool'?false:(p.default??p.min??0)]));
Object.assign(values,{IsOnroad:'True',IsOffroad:'',SafeMode:false,HasRadar:true,GalaxyDeveloperMode:false});
window.writes=[]; window.paramWrites=[]; window.failWrite=false; window.failRead=false; window.holdWrite=false; window.holdRead=false;
window.state={mode:'conditional_experimental',values:{ExperimentalMode:true,ConditionalExperimental:true,ConditionalChill:false},locked:false,reason:'',experimental_confirmed:false};
window.externalMode=mode=>{window.state={...window.state,mode,values:{ExperimentalMode:mode==='experimental',ConditionalExperimental:mode==='conditional_experimental',ConditionalChill:mode==='conditional_chill'}}};
window.fetch=async(input,init={})=>{
const url=new URL(input,location.href); const json=(data,status=200)=>new Response(JSON.stringify(data),{status});
if(url.pathname==='/api/longitudinal_mode') {
if(init.method==='PUT') {
const body=JSON.parse(init.body); window.writes.push(body);
if(window.holdWrite) await new Promise(r=>window.releaseWrite=r);
if(window.failWrite) return json({error:'Injected write failure'},500);
if(JSON.stringify(body.expected)!==JSON.stringify(window.state.values)) return json({error:'Changed elsewhere'},409);
window.externalMode(body.mode);
} else if(window.holdRead) {const captured=structuredClone(window.state); await new Promise(r=>window.releaseRead=r); return json(captured)}
if(window.failRead) return json({},503);
return json(window.state);
}
if(url.pathname.endsWith('device_settings_layout.json')) return json(layout);
if(url.pathname==='/api/params/all') return json(values);
if(url.pathname==='/api/params/defaults') return json({});
if(url.pathname==='/api/params' && init.method==='PUT') {const body=JSON.parse(init.body);window.paramWrites.push(body);return json({updated:{[body.key]:body.value}})}
throw new Error('Unmocked request '+url.pathname);
};
window.app=createApp(Settings); window.vm=window.app.mount('#app');
`
;(async()=>{
const browser=await chromium.launch({headless:true,executablePath:process.env.CHROMIUM_EXECUTABLE,args:['--no-sandbox']})
const reports=[]
try {
for(const cfg of [{width:1440,height:1000,scale:1,touch:false},{width:1100,height:900,scale:1.25,touch:false},{width:390,height:844,scale:1,touch:true},{width:360,height:800,scale:1.5,touch:true},{width:768,height:1024,scale:2,touch:true}]) {
const page=await browser.newPage({viewport:{width:cfg.width,height:cfg.height},hasTouch:cfg.touch,deviceScaleFactor:cfg.scale})
const errors=[];page.on('pageerror',e=>errors.push(e.message))
await page.route('**/*',async route=>{
const url=new URL(route.request().url());assert.equal(url.hostname,'offline.invalid')
if(url.pathname==='/') return route.fulfill({contentType:'text/html',body:`<html data-theme="dark"><head><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="stylesheet" href="/assets/vendor/bootstrap-icons/bootstrap-icons.min.css"><link rel="stylesheet" href="/assets/mobile/css/material.css"><link rel="stylesheet" href="/assets/mobile/css/home.css"><script type="importmap">{"imports":{"vue":"/assets/vendor/vue/vue.esm-browser.js"}}</script></head><body><main id="app" style="max-width:1100px;margin:auto;padding:16px"></main><script type="module" src="/setup.js"></script></body></html>`})
if(url.pathname==='/setup.js') return route.fulfill({contentType:'text/javascript',body:fixture})
const file=path.join(assets,url.pathname.replace(/^\/assets\//,''));if(fs.existsSync(file)&&fs.statSync(file).isFile()) return route.fulfill({path:file})
return route.fulfill({status:404,body:'not found'})
})
await page.goto('http://offline.invalid/')
await page.evaluate(scale=>{document.documentElement.style.zoom=String(scale)},cfg.scale)
const select=page.locator('#gx-longitudinal-mode'), manage=page.locator('[aria-controls="gx-longitudinal-children"]')
const waitMode=mode=>page.waitForFunction(mode=>{const e=document.querySelector('#gx-longitudinal-mode');return e?.value===mode&&!e.disabled},mode)
await waitMode('conditional_experimental')
assert.equal(await page.locator('.gx-mode-select__label span').innerText(),'Conditional Experimental')
assert.equal(await page.locator('.gx-mode-select__label span').evaluate(e=>e.scrollWidth<=e.clientWidth+1),true)
await select.focus()
assert.equal(await page.locator('.gx-mode-select').evaluate(e=>getComputedStyle(e).outlineStyle),'solid')
assert.deepEqual(await select.locator('option').allTextContents(),['Chill','Experimental','Conditional Experimental','Conditional Chill'])
assert.equal(await page.evaluate(()=>writes.length),0)
assert.equal(await manage.getAttribute('aria-expanded'),'false')
assert.equal(await page.locator('#gx-longitudinal-children').count(),0)
assert.ok((await page.locator('#gx-longitudinal-description').innerText()).includes('model-controlled gas and brakes'))
await (cfg.touch?manage.tap():manage.click())
const children=page.locator('#gx-longitudinal-children')
assert.ok((await children.innerText()).includes('Persist Experimental State'))
assert.ok(!(await children.innerText()).includes('Persist Chill State'))
// Every direct classic child appears with identical description and native control.
for(const owner of ['ConditionalExperimental','ConditionalChill']) {
const target=owner==='ConditionalExperimental'?'conditional_experimental':'conditional_chill'
if(target!=='conditional_experimental') {await select.selectOption(target);await waitMode(target)}
await page.screenshot({path:path.join(out,`${cfg.width}-${cfg.scale}-${target}.png`),fullPage:true})
for(const p of layout.flatMap(s=>s.params||[]).filter(p=>p.parent_key===owner)) {
assert.ok((await children.innerText()).includes(p.label),p.key)
const card=children.locator('.gx-row').filter({has:page.locator('.gx-row__label',{hasText:p.label})}).first()
assert.ok(await card.count(),p.key)
if(p.description) assert.equal(await card.locator('.gx-row__desc').first().textContent(),p.description)
}
if(owner==='ConditionalExperimental') {
const lead=children.locator('.gx-tree-node').filter({has:page.locator('.gx-row__label',{hasText:'Lead Detected Ahead'})}).first()
await lead.locator('input[type=checkbox]').check()
await lead.locator('.gx-manage-btn').click()
for(const p of layout.flatMap(s=>s.params||[]).filter(p=>p.parent_key==='CELead')) {
const row=children.locator('.gx-row').filter({has:page.locator('.gx-row__label',{hasText:p.label})})
assert.equal(await row.isVisible(),true,p.key)
if(p.description) assert.equal(await row.locator('.gx-row__desc').innerText(),p.description)
await row.locator('input[type=checkbox]').check()
}
}
}
const slider=children.locator('input[type=range]').first()
await slider.focus();await slider.press('ArrowRight');await slider.press('Tab')
assert.ok(await page.evaluate(()=>paramWrites.length>0))
for(const target of ['chill','experimental']) {
await select.selectOption(target);await waitMode(target);assert.equal(await manage.count(),0);assert.equal(await children.count(),0)
await page.screenshot({path:path.join(out,`${cfg.width}-${cfg.scale}-${target}.png`),fullPage:true})
}
assert.equal(await page.evaluate(()=>writes.at(-1).acknowledged),true)
await page.evaluate(()=>{holdWrite=true});await select.selectOption('conditional_chill')
await page.waitForFunction(()=>!!window.releaseWrite)
assert.equal(await select.inputValue(),'experimental');assert.equal(await select.isDisabled(),true)
await page.evaluate(()=>{holdWrite=false;releaseWrite()});await waitMode('conditional_chill')
await page.evaluate(()=>{failWrite=true});await select.selectOption('chill');await waitMode('conditional_chill')
assert.ok((await page.locator('[role=alert]').innerText()).includes('Injected'))
await page.evaluate(()=>{failWrite=false;externalMode('conditional_experimental')});await waitMode('conditional_experimental')
// Routine polling never dims an available control; stale pre-write GET is ignored.
await page.evaluate(()=>{holdRead=true});await page.waitForFunction(()=>!!window.releaseRead)
assert.equal(await select.isEnabled(),true)
await select.selectOption('conditional_chill');await waitMode('conditional_chill')
await page.evaluate(()=>{holdRead=false;releaseRead()});await page.waitForTimeout(100)
assert.equal(await select.inputValue(),'conditional_chill')
for(const reason of ['Locked by Safe Mode.','openpilot longitudinal unavailable.']) {
await page.evaluate(reason=>{state={...state,locked:true,reason}},reason)
await page.waitForFunction(()=>document.querySelector('#gx-longitudinal-mode').disabled)
assert.ok((await page.locator('.gx-longitudinal-mode').innerText()).includes(reason))
assert.equal(await children.locator('input:not(:disabled),select:not(:disabled)').count(),0)
await page.evaluate(()=>{state={...state,locked:false,reason:''}});await waitMode('conditional_chill')
}
// CSS zoom covers enlarged UI/text separately from DPR.
await page.evaluate(scale=>{document.documentElement.style.zoom=String(scale)},cfg.scale)
const overflow=await page.evaluate(()=>document.documentElement.scrollWidth>document.documentElement.clientWidth+1)
assert.equal(overflow,false,'horizontal overflow '+JSON.stringify(cfg))
await page.screenshot({path:path.join(out,`${cfg.width}-${cfg.scale}-dark.png`),fullPage:true})
await page.evaluate(()=>document.documentElement.setAttribute('data-theme','light'))
await page.screenshot({path:path.join(out,`${cfg.width}-${cfg.scale}-light.png`),fullPage:true})
await page.evaluate(()=>{failRead=true});await page.waitForFunction(()=>document.querySelector('#gx-longitudinal-mode').value==='')
assert.equal(await select.isDisabled(),true)
await page.evaluate(()=>{failRead=false});await waitMode('conditional_chill')
// Search must use the same guarded selector, never generic Params for virtual key.
await page.evaluate(()=>{store.search='Longitudinal control mode'})
await page.locator('.gx-section__header').last().click()
await waitMode('conditional_chill');await select.selectOption('chill');await waitMode('chill')
assert.equal(await page.evaluate(()=>paramWrites.some(p=>p.key==='LongitudinalControlMode')),false)
assert.deepEqual(errors,[])
reports.push({...cfg,passed:true,writes:await page.evaluate(()=>writes.length)})
fs.writeFileSync(path.join(out,'results.json'),JSON.stringify(reports,null,2))
await page.close()
}
console.log('PASS Big Dipper Settings: '+JSON.stringify(reports))
} finally {await browser.close()}
})().catch(e=>{console.error(e);process.exitCode=1})
@@ -1,91 +0,0 @@
// Exact classic functions with deterministic synthetic network ordering; no Params/device access.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const { test } = require('node:test');
const source = fs.readFileSync(path.join(__dirname, '../assets/components/tools/device_settings.js'), 'utf8');
const start = source.indexOf('function applyLongitudinalMode(data)');
const end = source.indexOf('async function fetchDefaultValues()', start);
assert(start > 0 && end > start);
const snapshot = mode => ({ mode, values: { ExperimentalMode: mode === 'experimental', ConditionalExperimental: mode === 'conditional_experimental', ConditionalChill: mode === 'conditional_chill' }, locked: false, reason: '', experimental_confirmed: true });
const reply = (data, ok = true) => ({ ok, json: async () => data });
function harness() {
const old = snapshot('experimental');
const requests = [];
const state = { longitudinalMode: old, longitudinalModeUpdating: false, longitudinalModeRequestId: 0, values: { ...old.values, LongitudinalControlMode: old.mode } };
const env = { state, LONGITUDINAL_MODE_KEY: 'LongitudinalControlMode', validLongitudinalSnapshot: () => true,
scheduleSyncInputs: () => {}, document: { getElementById: () => null },
getSettingLockReason: () => state.longitudinalModeUpdating ? 'Updating' : '', showParamSnackbar: () => {},
fetch: (_url, options) => new Promise((resolve, reject) => requests.push({ resolve, reject, options })) };
vm.createContext(env);
vm.runInContext(source.slice(start, end), env);
return { env, state, requests, old };
}
const flush = () => new Promise(resolve => setImmediate(resolve));
function check(state, mode, pending) {
assert.equal(state.longitudinalMode?.mode ?? null, mode);
assert.equal(state.values.LongitudinalControlMode, mode ?? '');
assert.equal(state.longitudinalModeUpdating, pending);
if (mode) for (const [key, value] of Object.entries(snapshot(mode).values)) assert.equal(state.values[key], value);
}
for (const outcome of ['success', 'http failure', 'network failure']) {
for (const phase of ['write pending', 'reconcile pending', 'completed']) {
for (const writeOK of [true, false]) {
test(`obsolete GET ${outcome}, ${phase}, PUT ${writeOK ? 'succeeds' : 'fails'}`, async () => {
const { env, state, requests, old } = harness();
const read = env.fetchLongitudinalMode();
const write = env.updateLongitudinalMode('chill');
check(state, 'experimental', true);
await env.fetchLongitudinalMode();
assert.equal(requests.length, 2, 'ordinary polling stays suppressed during write');
assert.equal(requests[1].options.method, 'PUT');
assert.deepEqual(JSON.parse(requests[1].options.body).expected, old.values);
if (phase !== 'write pending') {
requests[1].resolve(reply(writeOK ? snapshot('chill') : { error: 'uncertain write' }, writeOK));
await flush();
assert.equal(requests.length, 3, 'always reconcile, including failed PUT');
if (phase === 'completed') { requests[2].resolve(reply(snapshot('chill'))); await write; }
}
if (outcome === 'network failure') requests[0].reject(new Error('offline'));
else requests[0].resolve(reply(old, outcome === 'success'));
await read;
check(state, phase === 'completed' || (phase === 'reconcile pending' && writeOK) ? 'chill' : 'experimental', phase !== 'completed');
if (phase === 'write pending') {
requests[1].resolve(reply(writeOK ? snapshot('chill') : { error: 'uncertain write' }, writeOK));
await flush();
}
if (phase !== 'completed') { requests[2].resolve(reply(snapshot('chill'))); await write; }
check(state, 'chill', false);
const poll = env.fetchLongitudinalMode();
requests[3].resolve(reply(snapshot('conditional_chill')));
await poll;
check(state, 'conditional_chill', false);
});
}
}
test(`obsolete forced read ${outcome} cannot supersede newer read`, async () => {
const { env, state, requests, old } = harness();
const first = env.fetchLongitudinalMode(true);
const second = env.fetchLongitudinalMode(true);
requests[1].resolve(reply(snapshot('conditional_experimental')));
await second;
if (outcome === 'network failure') requests[0].reject(new Error('offline'));
else requests[0].resolve(reply(old, outcome === 'success'));
await first;
check(state, 'conditional_experimental', false);
});
}
test('current reconciliation failure still clears selection and unlocks polling', async () => {
const { env, state, requests } = harness();
const write = env.updateLongitudinalMode('chill');
requests[0].resolve(reply(snapshot('chill')));
await flush();
requests[1].reject(new Error('offline'));
await write;
check(state, null, false);
const poll = env.fetchLongitudinalMode();
requests[2].resolve(reply(snapshot('chill')));
await poll;
check(state, 'chill', false);
});
@@ -1,103 +0,0 @@
// Local-only real DOM smoke. Requires Playwright + its Chromium; no live API.
// PLAYWRIGHT_MODULE can point at an existing isolated Playwright installation.
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright')
const repo = path.resolve(__dirname, '../../../..')
const assets = path.join(repo, 'starpilot/system/the_galaxy/assets')
// All API responses below are synthetic. Layout/assets come from this checkout.
const layoutFixture = JSON.parse(fs.readFileSync(path.join(repo, 'starpilot/common/assets/device_settings_layout.json'), 'utf8'))
const fixture = `
const layoutFixture = ${JSON.stringify(layoutFixture)};
const paramsFixture = Object.fromEntries(layoutFixture.flatMap(section => section.params || []).map(param =>
[param.key, param.data_type === 'bool' ? false : (param.default ?? param.min ?? 0)]));
const jsonResponse = value => new Response(JSON.stringify(value), {status:200});
window.showSnackbar=()=>{};
window.fetch=async (input, init={}) => {
const url=new URL(input,location.href);
const method=init.method || 'GET';
if(method !== 'GET') throw new Error('Unexpected fixture write: '+url.pathname);
if(url.pathname==='/assets/components/tools/device_settings_layout.json') return jsonResponse(layoutFixture);
if(url.pathname==='/api/params/defaults' || url.pathname==='/api/flm/workspace') return jsonResponse({});
if(url.pathname==='/api/params/all') return jsonResponse(paramsFixture);
if(url.pathname==='/api/favorites/slots') return jsonResponse({options:[],slots:[null,null,null],values:{}});
if(url.pathname==='/api/favorites/values') return jsonResponse({values:{}});
if(url.pathname==='/api/params') return new Response(String(paramsFixture[url.searchParams.get('key')] ?? false));
throw new Error('Unmocked request: '+method+' '+url.pathname);
};
paramsFixture.IsOnroad = ${process.env.GALAXY_DOM_ONROAD === '1'};
paramsFixture.GalaxyDeveloperMode = true;
const profilesFixture = ${JSON.stringify(JSON.parse(fs.readFileSync(path.join(__dirname,'browser/fixtures/personality_profiles.json'))))};
for (const [id,profile] of Object.entries(profilesFixture.profiles)) {
for (const [category,config] of Object.entries(profile)) {config.preset='custom';config.curve=[...profilesFixture.reference_curves[id][category]];}
}
window.profileWrites=[];
paramsFixture.IsOffroad=true;paramsFixture.CustomPersonalities=true;
for(const key of ['TrafficPersonalityProfile','AggressivePersonalityProfile','StandardPersonalityProfile','RelaxedPersonalityProfile'])paramsFixture[key]=true;
const baseFetch=window.fetch;
window.fetch=async(input,init={})=>{
const url=new URL(input,location.href);
if(url.pathname!='/api/personality_profiles')return baseFetch(input,init);
if(init.method==='PUT') {const body=JSON.parse(init.body);window.profileWrites.push(body);profilesFixture.profiles[body.profile][body.category]={preset:body.preset,curve:body.curve};}
return jsonResponse(profilesFixture);
};
import {DeviceSettings} from '/assets/components/tools/device_settings.js';
DeviceSettings({params:{section:'longitudinal-speed-following'}})(document.querySelector('#app'));
`
;(async () => {
const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROMIUM_EXECUTABLE || undefined, args: ['--no-sandbox'] })
try {
const page = await browser.newPage({ hasTouch:true, deviceScaleFactor: Number(process.env.CLASSIC_WIDTH || 1280)<900 ? 3 : 1, viewport: { width: Number(process.env.CLASSIC_WIDTH || 1280), height: 900 } })
const touch = await page.context().newCDPSession(page);
const errors = []
const dialogs = []
page.on('dialog', dialog => { dialogs.push(dialog.message()); dialog.dismiss() })
page.on('pageerror', error => errors.push(error.message))
await page.route('**/*', async route => {
const url = new URL(route.request().url())
if (url.hostname !== 'offline.invalid') throw new Error('External access blocked')
if (url.pathname === '/device_settings') return route.fulfill({contentType:'text/html',body:'<html><head><link rel="stylesheet" href="/assets/components/main.css"><link rel="stylesheet" href="/assets/components/settings.css"><link rel="stylesheet" href="/assets/components/tools/device_settings.css"></head><body><main id="app"></main><script type="module" src="/setup.js"></script></body></html>'})
if (url.pathname === '/setup.js') return route.fulfill({contentType:'text/javascript',body:fixture})
if (url.pathname.endsWith('/device_settings.js')) return route.fulfill({contentType:'text/javascript',body:fs.readFileSync(path.join(assets,'components/tools/device_settings.js'),'utf8')+'\nwindow.__auditState=state;'});
if (url.pathname.startsWith('/assets/')) {
const file = path.join(assets, url.pathname.slice('/assets/'.length))
if (fs.existsSync(file) && fs.statSync(file).isFile()) return route.fulfill({path:file})
}
return route.fulfill({status:404,body:'not found'})
})
await page.goto('http://offline.invalid/device_settings')
await page.getByRole('button', {name:'Longitudinal (Speed & Following)',exact:true}).click()
await page.locator('[aria-controls="personality-profiles-panel"]').click();
await page.locator('.ds-personality-card').first().waitFor();
assert.equal(await page.locator('.ds-personality-card').count(),4);
for(const profile of ['traffic','aggressive','standard','relaxed']) {
const card=page.locator(`.ds-personality-card[data-profile="${profile}"]`);
await card.locator('.ds-personality-advanced-toggle').click();
for(const category of ['acceleration','braking','following']) {
const input=page.locator(`#personality-input-${profile}-${category}-0`);
await input.waitFor({state:'visible'});
await page.waitForFunction(()=>!window.__auditState.personalityProfilesLoading && !Object.keys(window.__auditState.personalityUpdating).length);
const before=await page.evaluate(()=>window.profileWrites.length);
await input.fill('1.40');await input.press('Tab');
await page.waitForFunction(n=>window.profileWrites.length===n+1,before,{timeout:5000}).catch(async e=>{console.log(await page.locator('body').innerText());console.log(await page.evaluate(()=>({onroad:window.__auditState.values.IsOnroad,loading:window.__auditState.personalityProfilesLoading,error:window.__auditState.personalityProfilesError,updating:window.__auditState.personalityUpdating,migration:window.__auditState.personalityMigrationRequired})));console.log(errors);throw e;});
assert.equal(await page.evaluate(()=>window.profileWrites.at(-1).curve[0]),1.4);
assert.ok(await page.evaluate(()=>Object.hasOwn(window.profileWrites.at(-1),'expected')));
await page.waitForFunction(()=>!Object.keys(window.__auditState.personalityUpdating).length);
const canvas=page.locator(`#personality-chart-${profile}-${category}`);await canvas.scrollIntoViewIfNeeded();
const r=await canvas.boundingBox(); const x=r.x+r.width/2,y=r.y+r.height/2;const phone=Number(process.env.CLASSIC_WIDTH || 1280)<900;
if(phone) {
await touch.send('Input.dispatchTouchEvent',{type:'touchStart',touchPoints:[{x,y}]});
await touch.send('Input.dispatchTouchEvent',{type:'touchMove',touchPoints:[{x,y:y-10}]});
} else {await page.mouse.move(x,y);await page.mouse.down();await page.mouse.move(x,y-10);}
assert.equal(await page.evaluate(()=>window.profileWrites.length),before+1,'drag preview wrote before release');
if(phone) await touch.send('Input.dispatchTouchEvent',{type:'touchEnd',touchPoints:[]});else await page.mouse.up();
await page.waitForFunction(n=>window.profileWrites.length===n+2,before);
}
}
await page.locator('#personality-chart-relaxed-following').screenshot({path:(process.env.CLASSIC_SCREENSHOT || '/tmp/classic-personality.png').replace('.png','-graph.png')});
await page.screenshot({path:process.env.CLASSIC_SCREENSHOT || '/tmp/classic-personality.png',fullPage:true});
assert.deepEqual(errors,[]);
console.log(JSON.stringify({surface:'classic',categories:12,numericAutosaves:12,dragAutosaves:12,errors}));
} finally { await browser.close() }
})().catch(error => { console.error(error); process.exitCode=1 })
@@ -1,52 +0,0 @@
// Exact shipped functions, synthetic transport only; no device or real Params.
const fs=require('fs'),vm=require('vm'),assert=require('assert');
const path=require('path');
const source=fs.readFileSync(path.resolve(__dirname,'../assets/components/tools/device_settings.js'),'utf8');
function setup(mode){
let stored=[1,1,1], calls=[],failed=false;
const c={window:{location:{pathname:'/device_settings'}},state:{values:{IsOnroad:false,IsOffroad:true},personalityMigrationRequired:false,personalityUpdating:{},personalityProfiles:{standard:{acceleration:{preset:'custom',curve:[1,1,1]}}}},uiContextPollInflight:null,personalityViewGeneration:0,personalityUpdateKey:(p,k)=>p+':'+k,showParamSnackbar:()=>{},PERSONALITY_CATEGORY_DEFINITIONS:{acceleration:{label:'Acceleration'}},fetch:async(url,opts={})=>{
calls.push(opts.method||'GET');
if(opts.method==='PUT'){
stored=JSON.parse(opts.body).curve;
if(!failed){failed=true;if(mode==='navigate')c.window.location.pathname='/models';if(mode==='malformed')return {ok:true,json:async()=>{throw Error('malformed')}};throw Error('accepted response lost');}
}
if(mode==='readFailure'&&opts.method!=='PUT')throw Error('read failed');
return {ok:true,json:async()=>url.includes('params/all')?{IsOnroad:false,IsOffroad:true}:{profiles:{standard:{acceleration:{preset:'custom',curve:stored}}},bounds:{},options:{},speed_breakpoints_mph:{acceleration:[0,10,20]} }};
}};
vm.createContext(c);
// Keep helper and save together to exercise production recovery, not a test copy.
const start=source.indexOf('async function recoverPersonalitySave(');
vm.runInContext(source.slice(start<0?source.indexOf('async function savePersonalityCategory('):start,source.indexOf('\nfunction updatePersonalityPreset(')),c);
return {c,calls,stored:()=>stored};
}
(async()=>{
for(const mode of ['lost','malformed']){
const {c,calls,stored}=setup(mode);
assert.equal(await c.savePersonalityCategory('standard','acceleration','custom',[2,1,1]),false);
assert.deepEqual(c.state.personalityProfiles.standard.acceleration.curve,[2,1,1],mode+' must read saved state');
assert(calls.includes('GET'));
const next=[...c.state.personalityProfiles.standard.acceleration.curve];next[1]=3;
assert.equal(await c.savePersonalityCategory('standard','acceleration','custom',next),true);
assert.deepEqual(stored(),[2,3,1]);
}
const {c,calls}=setup('readFailure');
await c.savePersonalityCategory('standard','acceleration','custom',[2,1,1]);
assert(c.state.personalityProfilesError,'failed recovery locks editor');
await c.savePersonalityCategory('standard','acceleration','custom',[1,3,1]);
assert.equal(calls.filter(x=>x==='PUT').length,1,'no second write until recovery');
const nav=setup('navigate');await nav.c.savePersonalityCategory('standard','acceleration','custom',[2,1,1]);
assert.deepEqual(nav.calls,['PUT'],'no late navigation readback');
assert(nav.c.state.personalityProfilesError,'remount cannot author stale state');
nav.c.window.location.pathname='/device_settings';nav.c.personalityViewGeneration++;
assert.equal(await nav.c.recoverPersonalitySave(),true);
assert.deepEqual(nav.c.state.personalityProfiles.standard.acceleration.curve,[2,1,1]);
for(const reason of ['onroad','navigation']){
const pending=setup('lost');let release;
pending.c.uiContextPollInflight=new Promise(resolve=>release=resolve);
const save=pending.c.savePersonalityCategory('standard','acceleration','custom',[2,1,1]);
if(reason==='onroad')pending.c.state.values.IsOnroad=true;
else pending.c.personalityViewGeneration++;
release();assert.equal(await save,false);assert.deepEqual(pending.calls,[],'pre-send context recheck');
}
console.log('PASS lost response, malformed response, locked recovery failure, navigation/remount and pending-context suppression');
})().catch(e=>{console.error(e);process.exitCode=1});
@@ -1,264 +0,0 @@
"""Isolated real AST seams + real OS advisory locks, never native/device Params."""
import ast
import copy
import multiprocessing
from itertools import product
from pathlib import Path
from types import SimpleNamespace as NS
import pytest
from test_longitudinal_mode import Params, mode
from openpilot.starpilot.common.longitudinal_mode import mode_lock, read_mode_values, request_mode_refresh
ROOT = Path(__file__).resolve().parents[4]
def nodes_in(path):
return ast.parse((ROOT / path).read_text())
def execute(nodes, env):
exec(compile(ast.Module(body=nodes, type_ignores=[]), '<runtime AST>', 'exec'), env)
def load_toggles(params, *, capable=True, safe=False):
tree = nodes_in('starpilot/common/starpilot_variables.py')
assignments = sorted((node for node in ast.walk(tree) if isinstance(node, ast.Assign)), key=lambda node: node.lineno)
start = next(node.lineno for node in assignments if isinstance(node.targets[0], ast.Attribute) and node.targets[0].attr == 'longitudinal_mode_values')
end = next(node.lineno for node in assignments if isinstance(node.targets[0], ast.Attribute) and node.targets[0].attr == 'conditional_chill_launch_assist')
toggle = NS(openpilot_longitudinal=capable, experimental_mode_available=capable, safe_mode=safe)
get_value = lambda key, condition=True, **kwargs: params.get(key) if condition else False
execute([node for node in assignments if start <= node.lineno <= end],
dict(toggle=toggle, self=NS(params=params, get_value=get_value), mode_values=read_mode_values(params), speed_conversion=1))
return toggle
def plan_result(toggles, cem=True, ccm=True, slc=False):
tree = nodes_in('starpilot/controls/starpilot_planner.py')
method = next(node for node in ast.walk(tree) if isinstance(node, ast.FunctionDef) and node.name == 'publish')
start = next(i for i, node in enumerate(method.body) if isinstance(node, ast.Assign)
and isinstance(node.targets[0], ast.Name) and node.targets[0].id == 'conditional_experimental_mode')
plan = NS()
execute(method.body[start:start + 3], dict(starpilot_toggles=toggles, starpilotPlan=plan,
self=NS(starpilot_cem=NS(experimental_mode=cem), starpilot_ccm=NS(experimental_mode=ccm),
starpilot_vcruise=NS(slc=NS(experimental_mode=slc)))))
return plan.experimentalMode
def selfdrive_result(plan, *, previous=False, cached=None, safe=False, capable=True, replay=False):
tree = nodes_in('selfdrive/selfdrived/selfdrived.py')
method = next(node for node in ast.walk(tree) if isinstance(node, ast.FunctionDef) and node.name == 'update_events')
nodes = [node for node in method.body if isinstance(node, ast.Assign)
and isinstance(node.targets[0], ast.Attribute) and node.targets[0].attr == 'experimental_mode']
assert len(nodes) == 1
state = NS(experimental_mode=previous, starpilot_toggles=cached or NS(conditional_experimental_mode=False, conditional_chill_mode=False), safe_mode=safe, CP=object(),
sm={'starpilotPlan': NS(experimentalMode=plan)})
execute(nodes, dict(self=state, experimental_mode_available=lambda cp: capable, REPLAY=replay))
return state.experimental_mode
@pytest.mark.parametrize('bits', list(product([False, True], repeat=3)))
@pytest.mark.parametrize('target', mode.MODES)
def test_onroad_transitions_never_publish_clear_all_intermediates(bits, target):
params = Params(dict(zip(mode.MODE_KEYS, bits)) | {'IsOffroad': False, 'IsOnroad': True, 'CECurves': True, 'CCMLaunchAssist': True})
published = load_toggles(params)
old_values = published.longitudinal_mode_values.copy()
observed = []
put = params.put_bool
def interleaved_write(key, value):
nonlocal published
put(key, value)
# This is the real loader selection seam attempted at each write boundary.
with pytest.raises(BlockingIOError):
load_toggles(params)
assert published.longitudinal_mode_values == old_values
observed.append(selfdrive_result(plan_result(published)))
params.put_bool = interleaved_write
result = mode.set_mode(params, target, old_values, lambda: True)
assert observed == [selfdrive_result(plan_result(published))] * len(params.writes)
published = load_toggles(params)
assert published.longitudinal_mode_values == result['values']
assert published.conditional_curves is published.conditional_experimental_mode
assert published.conditional_chill_launch_assist is published.conditional_chill_mode
assert selfdrive_result(plan_result(published)) is (target != 'chill')
def child_read(path, pipe):
params = NS(get_param_path=lambda: path, get_bool=lambda key: True)
try:
read_mode_values(params)
pipe.send('read')
except BlockingIOError:
pipe.send('busy')
finally:
pipe.close()
def test_lock_is_cross_process_not_only_a_python_mutex():
params = Params()
ctx = multiprocessing.get_context('fork')
receive, send = ctx.Pipe(duplex=False)
with mode_lock(params, exclusive=True):
child = ctx.Process(target=child_read, args=(params.get_param_path(), send))
child.start()
assert receive.poll(3), 'nonblocking read hung'
assert receive.recv() == 'busy'
child.join(3)
assert child.exitcode == 0
assert read_mode_values(params)['ConditionalExperimental'] is True
send.close()
receive.close()
def test_reader_excludes_participating_writer_between_key_reads():
params = Params()
before = {key: params.get_bool(key) for key in mode.MODE_KEYS}
get = params.get_bool
attempts = []
def read(key):
if key in mode.MODE_KEYS:
with pytest.raises(mode.ModeError) as error:
mode.set_mode(params, 'chill', before, lambda: True)
assert error.value.status == 503
attempts.append(key)
return get(key)
params.get_bool = read
assert read_mode_values(params) == before
assert attempts == list(mode.MODE_KEYS)
assert not params.writes
@pytest.mark.parametrize('safe,capable', [(True, True), (False, False)])
def test_effective_plan_cannot_bypass_safety_or_vehicle_capability(safe, capable):
assert selfdrive_result(True, safe=safe, capable=capable) is False
params = Params({'ConditionalExperimental': False, 'ExperimentalMode': True})
assert load_toggles(params, safe=safe, capable=capable).experimental_mode is False
params.values.update(ConditionalExperimental=True, ConditionalChill=True)
loaded = load_toggles(params, safe=safe, capable=capable)
assert not loaded.conditional_experimental_mode and not loaded.conditional_chill_mode
@pytest.mark.parametrize('previous,plan,cached_cem,cached_ccm', list(product([False, True], repeat=4)))
def test_current_plan_wins_over_all_stale_cached_flags(previous, plan, cached_cem, cached_ccm):
cached = NS(conditional_experimental_mode=cached_cem, conditional_chill_mode=cached_ccm)
assert selfdrive_result(plan, previous=previous, cached=cached) is plan
def test_conditional_defaults_and_slc_override_preserved():
params = Params({'ExperimentalMode': True})
assert plan_result(load_toggles(params), cem=False) is False # CEM masks dormant EXP
assert plan_result(load_toggles(params), cem=False, slc=True) is True
params.values.update(ConditionalExperimental=False, ConditionalChill=True)
assert plan_result(load_toggles(params), ccm=False) is False
assert plan_result(load_toggles(params), ccm=True) is True
params.values.update(ConditionalChill=False)
assert plan_result(load_toggles(params), cem=False, ccm=False) is True
def test_poll_retries_until_published_and_honors_unnotified_external_writes():
params, memory = Params(), Params()
published = load_toggles(params)
request_mode_refresh(params, memory, published)
assert not memory.writes
# Dom/nonparticipating writer, without calling the Galaxy notification helper.
params.values.update(ConditionalExperimental=False, ExperimentalMode=True)
request_mode_refresh(params, memory, published)
assert memory.writes[-1] == ('StarPilotTogglesUpdated', True)
in_flight = load_toggles(params)
params.values.update(ExperimentalMode=False, ConditionalChill=True)
memory.values['StarPilotTogglesUpdated'] = False # Worker consumes first signal.
request_mode_refresh(params, memory, published)
assert memory.get_bool('StarPilotTogglesUpdated')
# Even publication of the older in-flight update must not suppress retry.
memory.values['StarPilotTogglesUpdated'] = False
request_mode_refresh(params, memory, in_flight)
assert memory.get_bool('StarPilotTogglesUpdated')
published = load_toggles(params)
memory.writes.clear()
request_mode_refresh(params, memory, published)
assert not memory.writes
with mode_lock(params, exclusive=True):
request_mode_refresh(params, memory, published)
assert not memory.writes
def test_background_failure_preserves_entire_old_object_and_requeues():
params, memory = Params({'ExperimentalMode': True}), Params()
published = load_toggles(params)
variables = NS(starpilot_toggles=published, params_memory=memory)
node = next(node for node in nodes_in('starpilot/starpilot_process.py').body
if isinstance(node, ast.FunctionDef) and node.name == 'update_toggles_in_background')
def reload(updated, *args, **kwargs):
updated.starpilot_toggles.experimental_mode = False
load_toggles(params)
env = dict(copy=copy, update_toggles=reload)
execute([node], env)
result = {}
with mode_lock(params, exclusive=True), pytest.raises(BlockingIOError):
env['update_toggles_in_background'](result, variables, True, None, None, True, params, published)
assert result == {'failed': True}
assert variables.starpilot_toggles is published
assert published.experimental_mode is True
assert published.conditional_experimental_mode
assert memory.get_bool('StarPilotTogglesUpdated')
@pytest.mark.parametrize('fail_at', [1, 2, 3])
def test_failed_write_releases_lock_without_enabling_or_rolling_back(fail_at):
params = Params({'IsOffroad': False, 'IsOnroad': True}, fail_at=fail_at)
before = read_mode_values(params)
with pytest.raises(mode.ModeError):
mode.set_mode(params, 'experimental', before, lambda: True)
assert len(params.writes) == fail_at
assert mode.selected_mode(params.values) in {mode.selected_mode(before), 'experimental'}
assert read_mode_values(params) == {key: params.values[key] for key in mode.MODE_KEYS}
def test_lock_unavailable_fails_closed_without_writes(tmp_path):
params = Params({'IsOffroad': False, 'IsOnroad': True})
params.get_param_path = lambda: str(tmp_path / 'missing' / 'd')
with pytest.raises(mode.ModeError) as error:
mode.set_mode(params, 'experimental', params.values, lambda: True)
assert error.value.status == 503
assert not params.writes
def test_params_thread_no_longer_overwrites_experimental_from_live_params():
method = next(node for node in ast.walk(nodes_in('selfdrive/selfdrived/selfdrived.py'))
if isinstance(node, ast.FunctionDef) and node.name == 'params_thread')
replay = next(node for node in ast.walk(method) if isinstance(node, ast.If) and isinstance(node.test, ast.Name) and node.test.id == 'REPLAY')
assert not any(isinstance(node, ast.Attribute) and isinstance(node.ctx, ast.Store)
and node.attr == 'experimental_mode' for branch in replay.orelse for node in ast.walk(branch))
assert any(isinstance(node, ast.Call) and isinstance(node.func, ast.Name)
and node.func.id == 'request_mode_refresh' for node in ast.walk(method))
@pytest.mark.parametrize('guard,capable', [({'SafeMode': True}, True), ({'SafeMode': None}, True),
({'IsOffroad': None}, True), ({'IsOnroad': None}, True), ({'IsOffroad': True}, True), ({}, False)])
def test_onroad_safety_state_and_capability_fail_closed(guard, capable):
params = Params({'IsOffroad': False, 'IsOnroad': True, **guard})
with pytest.raises(mode.ModeError) as error:
mode.set_mode(params, 'experimental', params.values, lambda: capable)
assert error.value.status == 403
assert not params.writes
def test_onroad_api_accepts_exact_snapshot_but_rejects_stale_and_busy():
from test_longitudinal_mode_api import client_for
params = Params({'IsOffroad': False, 'IsOnroad': True})
client, signals = client_for(params)
before = client.get('/api/longitudinal_mode').json
assert before['locked'] is False
response = client.put('/api/longitudinal_mode', json={'mode': 'conditional_chill', 'expected': before['values']})
assert response.status_code == 200
assert response.json['mode'] == 'conditional_chill'
writes = params.writes.copy()
response = client.put('/api/longitudinal_mode', json={'mode': 'experimental', 'expected': before['values']})
assert response.status_code == 409
assert params.writes == writes
with mode_lock(params, exclusive=True):
assert client.get('/api/longitudinal_mode').status_code == 503
assert client.get('/api/longitudinal_mode').json['mode'] == 'conditional_chill'
@@ -201,48 +201,14 @@ def _install_server_import_stubs():
theme_manager.THEME_COMPONENT_PARAMS = {}
def parse_custom_accel_profile_curve(count, breakpoints, values):
numeric_count = float(count)
if not numeric_count.is_integer():
raise ValueError("Breakpoint count must be a whole number")
point_count = int(numeric_count)
point_count = int(count)
active_breakpoints = [float(value) for value in breakpoints[:point_count]]
if any(current <= previous for previous, current in zip(active_breakpoints, active_breakpoints[1:], strict=False)):
raise ValueError("Breakpoint speeds must be strictly increasing")
return [value * 0.44704 for value in active_breakpoints], [float(value) for value in values[:point_count]]
def get_accel_profile_curve_values(profile, ev_tuning=False, truck_tuning=False):
gas = {
0: [2.00, 1.80, 1.55, 1.30, 1.05, 0.85, 0.55],
1: [1.50, 1.30, 1.10, 0.90, 0.75, 0.55, 0.35],
2: [2.50, 2.25, 1.95, 1.60, 1.30, 1.05, 0.75],
3: [3.50, 3.20, 2.80, 2.35, 1.90, 1.55, 1.15],
}
ev = {
0: [2.00, 1.84, 1.64, 1.44, 1.24, 1.08, 0.84],
1: [1.50, 1.34, 1.18, 1.02, 0.90, 0.74, 0.58],
2: [2.50, 2.30, 2.06, 1.78, 1.54, 1.34, 1.10],
3: [3.50, 3.26, 2.94, 2.58, 2.22, 1.94, 1.62],
}
return list((ev if ev_tuning and not truck_tuning else gas)[int(profile or 0)])
def interpolate_accel_profile(v_ego, accel_curve, breakpoints=None):
curve_breakpoints = [0.0, 5.0, 10.0, 15.0, 20.0, 25.0, 40.0] if breakpoints is None else list(breakpoints)
speed = float(v_ego)
if speed <= curve_breakpoints[0]:
return float(accel_curve[0])
if speed >= curve_breakpoints[-1]:
return float(accel_curve[-1])
for index, upper in enumerate(curve_breakpoints[1:], start=1):
if speed <= upper:
lower = curve_breakpoints[index - 1]
ratio = (speed - lower) / (upper - lower)
smooth_ratio = ratio ** 3 * (10.0 - 15.0 * ratio + 6.0 * ratio * ratio)
return float(accel_curve[index - 1] + (accel_curve[index] - accel_curve[index - 1]) * smooth_ratio)
raise AssertionError("unreachable")
return active_breakpoints, [float(value) for value in values[:point_count]]
sys.modules["openpilot.starpilot.common.accel_profile"] = _simple_module(
"openpilot.starpilot.common.accel_profile",
A_CRUISE_MAX_BP_CUSTOM=[0.0, 5.0, 10.0, 15.0, 20.0, 25.0, 40.0],
CUSTOM_ACCEL_PROFILE_BREAKPOINT_PARAM_KEYS=[f"CustomAccelProfileBreakpoint{index}MPH" for index in range(1, 13)],
CUSTOM_ACCEL_PROFILE_BREAKPOINTS_INITIALIZED_KEY="CustomAccelProfileBreakpointsInitialized",
CUSTOM_ACCEL_PROFILE_CURVE_PARAM_KEYS=[
@@ -253,18 +219,13 @@ def _install_server_import_stubs():
CUSTOM_ACCEL_PROFILE_DEFAULT_BREAKPOINTS_MPH=[0.0, 11.2, 22.4, 33.6, 44.7, 55.9, 89.5, 100.7, 111.8, 123.0, 134.2, 145.4],
CUSTOM_ACCEL_PROFILE_DEFAULT_POINT_COUNT=7,
CUSTOM_ACCEL_PROFILE_INITIALIZED_KEY="CustomAccelProfileInitialized",
CUSTOM_ACCEL_PROFILE_PARAM_KEYS=[f"CustomAccelProfile{mph}MPH" for mph in (0, 11, 22, 34, 45, 56, 89)],
CUSTOM_ACCEL_PROFILE_PARAM_KEYS=[],
CUSTOM_ACCEL_PROFILE_POINT_COUNT_KEY="CustomAccelProfilePointCount",
CUSTOM_ACCEL_PROFILE_POINT_VALUE_PARAM_KEYS=[f"CustomAccelProfilePoint{index}Accel" for index in range(1, 13)],
CUSTOM_ACCEL_PROFILE_VALUE_MAX=6.0,
CUSTOM_ACCEL_PROFILE_VALUE_MIN=0.0,
build_custom_accel_profile_defaults=lambda *args, **kwargs: {},
custom_accel_profile_is_initialized=lambda flag, values: bool(flag) or all(value is not None for value in values.values()),
get_accel_profile_curve_values=get_accel_profile_curve_values,
interpolate_accel_profile=interpolate_accel_profile,
custom_accel_profile_is_initialized=lambda *args, **kwargs: False,
get_custom_accel_profile_curve_defaults=lambda *args, **kwargs: {},
normalize_acceleration_profile=lambda value: int(value or 0),
normalize_deceleration_profile=lambda value: int(value or 0),
normalize_acceleration_profile=lambda value: value,
parse_custom_accel_profile_curve=parse_custom_accel_profile_curve,
)
sys.modules["openpilot.starpilot.common.maps_catalog"] = _simple_module(
@@ -2335,200 +2296,6 @@ def test_toggle_backup_restore_round_trip_filters_non_settings(monkeypatch):
assert update_calls == [True]
@pytest.mark.parametrize("device_state", [
{"IsOnroad": True, "IsOffroad": False},
{"IsOnroad": False, "IsOffroad": False},
])
def test_toggle_restore_rejects_without_confirmed_offroad_for_parked_personality_key_without_mutation(monkeypatch, device_state):
server = _load_server_module()
assert server._import_galaxy_web_symbols()
parked_key = "StandardFollow"
definitions = {
parked_key: (1.45, server.ParamKeyType.FLOAT, server.ParamKeyFlag.PERSISTENT),
"EnabledSetting": (False, server.ParamKeyType.BOOL, server.ParamKeyFlag.PERSISTENT),
}
class ToggleParams:
def __init__(self):
self.values = {**device_state, parked_key: 1.45, "EnabledSetting": False}
def get(self, key, block=False):
del block
return self.values.get(key)
def get_bool(self, key):
return bool(self.values.get(key, False))
def get_default_value(self, key):
return definitions[key][0]
def get_key_flag(self, key):
return definitions[key][2]
def get_type(self, key):
return definitions[key][1]
def put(self, key, value):
self.values[key] = value
raw_params = ToggleParams()
server.starpilot_default_params = [
(key, default, value_type, 0)
for key, (default, value_type, _) in definitions.items()
]
monkeypatch.setattr(server, "_params_raw", raw_params)
monkeypatch.setattr(server, "params", server.ParamsCompat(raw_params))
monkeypatch.setattr(server, "EXCLUDED_KEYS", set())
monkeypatch.setattr(server, "update_starpilot_toggles", lambda: pytest.fail("restore side effect ran"))
app = server.Flask(
"toggle_restore_onroad_test",
template_folder=str(MODULE_DIR / "templates"),
static_folder=str(MODULE_DIR / "assets"),
)
server.setup(app)
client = app.test_client()
before = dict(raw_params.values)
encoded_data = utilities.encode_parameters({"EnabledSetting": True, parked_key: 1.25})
response = client.post("/api/toggles/restore", json={"data": encoded_data})
assert response.status_code == 403
assert "parked" in response.get_json()["message"].lower()
assert raw_params.values == before
@pytest.mark.parametrize("invalid_value", [99.0, "false"])
def test_toggle_restore_rejects_invalid_personality_value_without_mutation(monkeypatch, invalid_value):
server = _load_server_module()
assert server._import_galaxy_web_symbols()
parked_key = "StandardFollow" if isinstance(invalid_value, float) else "CustomPersonalities"
definitions = {
parked_key: (1.45, server.ParamKeyType.FLOAT, server.ParamKeyFlag.PERSISTENT),
"EnabledSetting": (False, server.ParamKeyType.BOOL, server.ParamKeyFlag.PERSISTENT),
}
if parked_key == "CustomPersonalities":
definitions[parked_key] = (False, server.ParamKeyType.BOOL, server.ParamKeyFlag.PERSISTENT)
class ToggleParams:
def __init__(self):
self.values = {"IsOnroad": False, "IsOffroad": True, parked_key: 1.45, "EnabledSetting": False}
def get(self, key, block=False):
del block
return self.values.get(key)
def get_bool(self, key):
return bool(self.values.get(key, False))
def get_default_value(self, key):
return definitions[key][0]
def get_key_flag(self, key):
return definitions[key][2]
def get_type(self, key):
return definitions[key][1]
def put(self, key, value):
self.values[key] = value
raw_params = ToggleParams()
server.starpilot_default_params = [
(key, default, value_type, 0)
for key, (default, value_type, _) in definitions.items()
]
monkeypatch.setattr(server, "_params_raw", raw_params)
monkeypatch.setattr(server, "params", server.ParamsCompat(raw_params))
monkeypatch.setattr(server, "EXCLUDED_KEYS", set())
monkeypatch.setattr(server, "update_starpilot_toggles", lambda: pytest.fail("restore side effect ran"))
app = server.Flask(
"toggle_restore_personality_bounds_test",
template_folder=str(MODULE_DIR / "templates"),
static_folder=str(MODULE_DIR / "assets"),
)
server.setup(app)
client = app.test_client()
before = dict(raw_params.values)
encoded_data = utilities.encode_parameters({"EnabledSetting": True, parked_key: invalid_value})
response = client.post("/api/toggles/restore", json={"data": encoded_data})
assert response.status_code == 400
assert "invalid" in response.get_json()["message"].lower()
assert raw_params.values == before
def test_toggle_restore_enables_master_only_after_installing_a_strict_profile_document(monkeypatch):
server = _load_server_module()
assert server._import_galaxy_web_symbols()
definitions = {
"CustomPersonalities": (False, server.ParamKeyType.BOOL, server.ParamKeyFlag.PERSISTENT),
server.PERSONALITY_PROFILES_PARAM: (
{}, server.ParamKeyType.JSON, server.ParamKeyFlag.PERSISTENT | server.ParamKeyFlag.DONT_LOG,
),
}
class ToggleParams:
def __init__(self):
self.values = {"IsOnroad": False, "IsOffroad": True, "CustomPersonalities": False}
self.writes = []
def get(self, key, block=False):
del block
return self.values.get(key)
def get_bool(self, key):
return bool(self.values.get(key, False))
def get_default_value(self, key):
return definitions[key][0]
def get_key_flag(self, key):
return definitions[key][2]
def get_type(self, key):
return definitions[key][1]
def put(self, key, value):
self.values[key] = value
self.writes.append((key, value))
def put_bool(self, key, value):
self.put(key, bool(value))
raw_params = ToggleParams()
server.starpilot_default_params = [
(key, default, value_type, 0)
for key, (default, value_type, _) in definitions.items()
]
monkeypatch.setattr(server, "_params_raw", raw_params)
monkeypatch.setattr(server, "params", server.ParamsCompat(raw_params))
monkeypatch.setattr(server, "EXCLUDED_KEYS", set())
monkeypatch.setattr(server, "update_starpilot_toggles", lambda: None)
app = server.Flask(
"toggle_restore_personality_master_test",
template_folder=str(MODULE_DIR / "templates"),
static_folder=str(MODULE_DIR / "assets"),
)
server.setup(app)
response = app.test_client().post(
"/api/toggles/restore",
json={"data": utilities.encode_parameters({"CustomPersonalities": True})},
)
assert response.status_code == 200, response.get_json()
document = server.strict_profile_document(raw_params.values[server.PERSONALITY_PROFILES_PARAM])
assert document is not None and document["enabled"] is True
assert raw_params.values["CustomPersonalities"] is True
assert [key for key, _ in raw_params.writes] == [server.PERSONALITY_PROFILES_PARAM, "CustomPersonalities"]
def test_toggle_restore_reports_invalid_and_unavailable_settings(monkeypatch):
server = _load_server_module()
assert server._import_galaxy_web_symbols()
@@ -2626,7 +2393,7 @@ def test_toggle_profile_slots_save_and_load_the_same_filtered_settings(monkeypat
for key, (default, value_type, _) in definitions.items()
]
monkeypatch.setattr(server, "_params_raw", raw_params)
monkeypatch.setattr(server, "params", FakeParams({"IsOnroad": False, "IsOffroad": True}))
monkeypatch.setattr(server, "params", FakeParams({"IsOnroad": False}))
monkeypatch.setattr(server, "EXCLUDED_KEYS", set())
monkeypatch.setattr(server, "TOGGLE_BACKUPS", tmp_path)
update_calls = []
@@ -14,6 +14,7 @@ def test_device_settings_surfaces_hidden_advanced_settings_count():
source = _device_settings()
assert "countAdvancedHiddenByDeveloperMode" in source
assert "isAdvancedHiddenByDeveloperMode" in source
assert "hiddenAdvancedCount" in source
@@ -34,8 +35,7 @@ def test_developer_mode_notice_navigates_to_developer_section():
def test_advanced_settings_hidden_count_shown_in_status_bar():
source = _device_settings()
assert "advanced setting" in source
assert "hidden" in source
assert "advanced hidden" in source
def test_device_settings_uses_the_params_api_and_layout_json():
@@ -1,114 +0,0 @@
"""Host-only tests: no device Params, controller, or service imports."""
import importlib.util
from itertools import product
from pathlib import Path
from tempfile import TemporaryDirectory
import pytest
MODULE = Path(__file__).resolve().parents[1] / "longitudinal_mode.py"
spec = importlib.util.spec_from_file_location("longitudinal_mode", MODULE)
mode = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mode)
class Params:
def __init__(self, values=None, fail_at=None):
self.directory = TemporaryDirectory(prefix="starpilot-mode-test-")
self.values = {"IsOffroad": True, "IsOnroad": False, "SafeMode": False,
"ExperimentalModeConfirmed": True, "ExperimentalMode": False, "ConditionalExperimental": True, "ConditionalChill": False, **(values or {})}
self.writes = []
self.fail_at = fail_at
def get_param_path(self):
return str(Path(self.directory.name) / "d")
def get(self, key):
return self.values.get(key)
def get_bool(self, key):
return self.values.get(key, False)
def put_bool(self, key, value):
self.writes.append((key, value))
if len(self.writes) == self.fail_at:
raise OSError("injected write failure")
self.values[key] = value
@pytest.mark.parametrize("cem,ccm,experimental", list(product([False, True], repeat=3)))
def test_read_precedence_without_writes(cem, ccm, experimental):
params = Params(dict(zip(mode.MODE_KEYS, [experimental, ccm, cem])))
result = mode.snapshot(params, True)
assert result["mode"] == ("conditional_experimental" if cem else "conditional_chill" if ccm else "experimental" if experimental else "chill")
assert not params.writes
@pytest.mark.parametrize("target", ["chill", "experimental", "conditional_experimental", "conditional_chill"])
@pytest.mark.parametrize("cem,ccm,experimental", list(product([False, True], repeat=3)))
def test_all_transitions(target, cem, ccm, experimental):
params = Params(dict(zip(mode.MODE_KEYS, [experimental, ccm, cem])))
before = params.values.copy()
result = mode.set_mode(params, target, before, lambda: True)
assert result["mode"] == target
if target == mode.selected_mode(before):
assert not params.writes # A no-op never normalizes dormant flags.
else:
intermediate = before.copy()
for key, value in params.writes:
intermediate[key] = value
assert mode.selected_mode(intermediate) in {mode.selected_mode(before), target}
assert sum(params.values[key] for key in mode.MODE_KEYS) == (target != "chill")
@pytest.mark.parametrize("values,capable", [({"IsOffroad": False}, True), ({"IsOffroad": None}, True),
({"IsOnroad": True}, True), ({"IsOnroad": None}, True), ({"SafeMode": True}, True),
({"SafeMode": None}, True), ({}, False)])
def test_guards_fail_closed(values, capable):
params = Params(values)
with pytest.raises(mode.ModeError):
mode.set_mode(params, "experimental", params.values.copy(), lambda: capable)
assert not params.writes
@pytest.mark.parametrize("target", [None, True, 1, [], {}, "Experimental", ""])
def test_invalid_modes_never_write(target):
params = Params()
with pytest.raises(mode.ModeError):
mode.set_mode(params, target, params.values.copy(), lambda: True)
assert not params.writes
def test_stale_or_missing_expected_state_never_writes():
params = Params()
for expected in [None, {}, {key: False for key in mode.MODE_KEYS}]:
with pytest.raises(mode.ModeError):
mode.set_mode(params, "experimental", expected, lambda: True)
assert not params.writes
@pytest.mark.parametrize("fail_at", [1, 2, 3])
def test_failed_write_keeps_old_or_requested_mode(fail_at):
params = Params(fail_at=fail_at)
with pytest.raises(mode.ModeError):
mode.set_mode(params, "experimental", params.values.copy(), lambda: True)
assert mode.selected_mode(params.values) in {"conditional_experimental", "experimental"}
assert len(params.writes) == fail_at # No unsafe rollback or later enabling.
def test_readback_failure_stops_without_further_writes():
params = Params()
params.put_bool = lambda key, value: params.writes.append((key, value))
with pytest.raises(mode.ModeError):
mode.set_mode(params, "experimental", params.values.copy(), lambda: True)
assert params.writes == [("ExperimentalMode", True)]
assert mode.selected_mode(params.values) == "conditional_experimental"
def test_guard_rechecked_before_every_write():
params = Params()
def capable():
return len(params.writes) < 2
with pytest.raises(mode.ModeError):
mode.set_mode(params, "experimental", params.values.copy(), capable)
assert params.writes == [("ExperimentalMode", True), ("ConditionalChill", False)]
@@ -1,134 +0,0 @@
"""Exercise the actual Flask handlers in isolation, without native/device imports."""
import ast
from pathlib import Path
from types import SimpleNamespace
import pytest
from flask import Flask, jsonify, request
from test_longitudinal_mode import Params, mode
SOURCE = Path(__file__).resolve().parents[1] / "the_galaxy.py"
def client_for(params, capable=True):
tree = ast.parse(SOURCE.read_text())
setup = next(node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name == "setup")
routes = [node for node in setup.body if isinstance(node, ast.FunctionDef) and node.name in {"longitudinal_mode", "get_param"}]
signals = []
app = Flask(__name__)
env = dict(app=app, request=request, jsonify=jsonify, params=params,
LONGITUDINAL_MODE_LOCK=mode.WRITE_LOCK, LONGITUDINAL_MODE_KEYS=mode.MODE_KEYS,
ModeError=mode.ModeError, set_longitudinal_mode=mode.set_mode,
longitudinal_mode_snapshot=mode.snapshot, _get_longitudinal_mode_capable=lambda: capable,
update_starpilot_toggles=lambda: signals.append(True),
PERSONALITY_PROFILES_PARAM="LongitudinalPersonalityProfiles",
PERSONALITY_PARKED_PARAM_KEYS=set(), PERSONALITY_PROFILE_ENABLE_PARAM_KEYS=set(),
FAVORITE_SLOTS_PARAM="StarPilotFavoriteSlots")
exec(compile(ast.Module(body=routes, type_ignores=[]), str(SOURCE), "exec"), env)
return app.test_client(), signals
def test_live_snapshot_precedence_no_load_writes():
params = Params({"ConditionalExperimental": True, "ConditionalChill": False, "ExperimentalMode": True})
client, signals = client_for(params)
response = client.get("/api/longitudinal_mode")
assert response.status_code == 200
assert response.json["mode"] == "conditional_experimental"
assert response.json["values"]["ExperimentalMode"] is True
assert not params.writes and not signals
def test_put_readback_and_failed_partial_write_signal():
params = Params(fail_at=3)
client, signals = client_for(params)
response = client.put("/api/longitudinal_mode", json={"mode": "experimental", "expected": params.values.copy()})
assert response.status_code == 500
assert signals == [True]
assert len(params.writes) == 3
assert client.get("/api/longitudinal_mode").json["values"] == {key: params.values[key] for key in mode.MODE_KEYS}
@pytest.mark.parametrize("body", [None, [], {}, {"mode": "bad"}, {"mode": "experimental", "expected": {}}])
def test_bad_request_rejected(body):
params = Params()
client, _ = client_for(params)
assert client.put("/api/longitudinal_mode", json=body).status_code == 400
assert not params.writes
@pytest.mark.parametrize("guard,capable", [({"IsOnroad": True}, True), ({"SafeMode": True}, True), ({}, False)])
def test_api_guards_and_legacy_favorites(guard, capable):
params = Params(guard)
client, _ = client_for(params, capable)
assert client.put("/api/longitudinal_mode", json={"mode": "conditional_chill", "expected": params.values.copy()}).status_code == 403
assert client.put("/api/params", json={"key": "ConditionalChill", "value": True}).status_code == 403
assert not params.writes
def test_legacy_favorite_uses_guarded_adapter():
params = Params()
client, signals = client_for(params)
response = client.put("/api/params", json={"key": "ConditionalChill", "value": True})
assert response.status_code == 200
assert response.json["updated"] == {"ConditionalChill": True, "ConditionalExperimental": False, "ExperimentalMode": False}
assert signals == [True]
assert client.put("/api/params", json={"key": "ExperimentalMode", "value": "true"}).status_code == 400
def test_experimental_requires_acknowledgement_does_not_mark_confirmed():
params = Params({"ExperimentalModeConfirmed": False})
client, _ = client_for(params)
body = {"mode": "experimental", "expected": params.values.copy()}
assert client.put("/api/longitudinal_mode", json=body).status_code == 409
assert not params.writes
assert client.put("/api/longitudinal_mode", json={**body, "acknowledged": "true"}).status_code == 409
assert client.put("/api/longitudinal_mode", json={**body, "acknowledged": True}).status_code == 200
assert params.values["ExperimentalModeConfirmed"] is False
assert all(key != "ExperimentalModeConfirmed" for key, _ in params.writes)
@pytest.mark.parametrize("cp,values,expected", [
(None, {}, False),
(SimpleNamespace(alphaLongitudinalAvailable=False, openpilotLongitudinalControl=False), {}, False),
(SimpleNamespace(alphaLongitudinalAvailable=False, openpilotLongitudinalControl=True), {}, True),
(SimpleNamespace(alphaLongitudinalAvailable=True, openpilotLongitudinalControl=True), {"AlphaLongitudinalEnabled": False}, False),
(SimpleNamespace(alphaLongitudinalAvailable=True, openpilotLongitudinalControl=True), {"AlphaLongitudinalEnabled": True}, True),
(SimpleNamespace(alphaLongitudinalAvailable=False, openpilotLongitudinalControl=True), {"DisableOpenpilotLongitudinal": True}, False),
])
def test_capability_pending_vehicle_guards(cp, values, expected):
from contextlib import nullcontext
node = next(node for node in ast.parse(SOURCE.read_text()).body if isinstance(node, ast.FunctionDef) and node.name == "_get_longitudinal_mode_capable")
env = {"_safe_params_get_bool": lambda key, default=False: values.get(key, False),
"_safe_params_get_live_raw": lambda key: b"cp" if cp else None,
"car": SimpleNamespace(CarParams=SimpleNamespace(from_bytes=lambda raw: nullcontext(cp)))}
exec(compile(ast.Module(body=[node], type_ignores=[]), str(SOURCE), "exec"), env)
assert env[node.name]() is expected
def test_real_params_compat_missing_and_failed_reads_fail_closed():
tree = ast.parse(SOURCE.read_text())
compat = next(node for node in tree.body if isinstance(node, ast.ClassDef) and node.name == "ParamsCompat")
env = {}
exec(compile(ast.Module(body=[compat], type_ignores=[]), str(SOURCE), "exec"), env)
params = Params()
wrapped = env["ParamsCompat"](params)
# Fake get lacks the optional block keyword, exercising the real fallback.
assert mode.snapshot(wrapped, True)["locked"] is False
params.values.pop("IsOffroad")
assert mode.snapshot(wrapped, True)["locked"] is True
def failed_read(*args, **kwargs):
raise OSError("unreadable Params")
params.get = failed_read
assert mode.snapshot(wrapped, True)["locked"] is True
def test_manual_and_persisted_overrides_and_child_values_untouched():
overrides = {"PersistExperimentalState": True, "PersistedCEStatus": 2,
"PersistChillState": True, "PersistedCCStatus": 3, "CESpeed": 27, "CCMSpeed": 43}
params = Params(overrides)
client, _ = client_for(params)
response = client.put("/api/longitudinal_mode", json={"mode": "conditional_chill", "expected": params.values.copy()})
assert response.status_code == 200
assert {key: params.values[key] for key in overrides} == overrides
assert all(key in mode.MODE_KEYS for key, _ in params.writes)
@@ -1,123 +0,0 @@
// Local-only real DOM smoke. Requires Playwright + its Chromium; no live API.
// PLAYWRIGHT_MODULE can point at an existing isolated Playwright installation.
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright')
const repo = path.resolve(__dirname, '../../../..')
const assets = path.join(repo, 'starpilot/system/the_galaxy/assets')
// All API responses below are synthetic. Layout/assets come from this checkout.
const layoutFixture = JSON.parse(fs.readFileSync(path.join(repo, 'starpilot/common/assets/device_settings_layout.json'), 'utf8'))
const fixture = `
const layoutFixture = ${JSON.stringify(layoutFixture)};
const paramsFixture = Object.fromEntries(layoutFixture.flatMap(section => section.params || []).map(param =>
[param.key, param.data_type === 'bool' ? false : (param.default ?? param.min ?? 0)]));
const jsonResponse = value => new Response(JSON.stringify(value), {status:200});
window.showSnackbar=()=>{};
window.fetch=async (input, init={}) => {
const url=new URL(input,location.href);
const method=init.method || 'GET';
if(method !== 'GET') throw new Error('Unexpected fixture write: '+url.pathname);
if(url.pathname==='/assets/components/tools/device_settings_layout.json') return jsonResponse(layoutFixture);
if(url.pathname==='/api/params/defaults' || url.pathname==='/api/flm/workspace') return jsonResponse({});
if(url.pathname==='/api/params/all') return jsonResponse(paramsFixture);
if(url.pathname==='/api/favorites/slots') return jsonResponse({options:[],slots:[null,null,null],values:{}});
if(url.pathname==='/api/favorites/values') return jsonResponse({values:{}});
if(url.pathname==='/api/params') return new Response(String(paramsFixture[url.searchParams.get('key')] ?? false));
throw new Error('Unmocked request: '+method+' '+url.pathname);
};
paramsFixture.IsOnroad = ${process.env.GALAXY_DOM_ONROAD === '1'};
paramsFixture.GalaxyDeveloperMode = false;
let modeState = {mode:'conditional_experimental', values:{ExperimentalMode:true,ConditionalExperimental:true,ConditionalChill:false},locked:false,reason:'',experimental_confirmed:false};
window.modeWrites=[]; window.failModeWrite=false; window.failModeRead=false;
window.holdModeWrite=false;
const fixtureFetch=window.fetch;
window.fetch=async (input, init={}) => {
const url=new URL(input,location.href);
if(url.pathname!='/api/longitudinal_mode') return fixtureFetch(input,init);
if(init.method==='PUT') {
const body=JSON.parse(init.body); window.modeWrites.push(body);
if(window.holdModeWrite) await new Promise(resolve => {window.releaseModeWrite=resolve});
if(window.failModeWrite) return new Response(JSON.stringify({error:'Injected failure'}),{status:500});
modeState={...modeState,mode:body.mode,values:{ExperimentalMode:body.mode==='experimental',ConditionalExperimental:body.mode==='conditional_experimental',ConditionalChill:body.mode==='conditional_chill'}};
}
if(window.failModeRead) return new Response('{}',{status:503});
return new Response(JSON.stringify(modeState),{status:200});
};
window.lockMode=()=>{modeState={...modeState,locked:true,reason:'Safe Mode locked'};};
import {DeviceSettings} from '/assets/components/tools/device_settings.js';
DeviceSettings({params:{section:'longitudinal-speed-following'}})(document.querySelector('#app'));
`
;(async () => {
const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROMIUM_EXECUTABLE, args: ['--no-sandbox'] })
try {
const page = await browser.newPage({ viewport: { width: 1100, height: 900 } })
const errors = []
const dialogs = []
page.on('dialog', dialog => { dialogs.push(dialog.message()); dialog.dismiss() })
page.on('pageerror', error => errors.push(error.message))
await page.route('**/*', async route => {
const url = new URL(route.request().url())
if (url.hostname !== 'offline.invalid') throw new Error('External access blocked')
if (url.pathname === '/device_settings') return route.fulfill({contentType:'text/html',body:'<html><head><link rel="stylesheet" href="/assets/components/main.css"><link rel="stylesheet" href="/assets/components/settings.css"><link rel="stylesheet" href="/assets/components/tools/device_settings.css"></head><body><main id="app"></main><script type="module" src="/setup.js"></script></body></html>'})
if (url.pathname === '/setup.js') return route.fulfill({contentType:'text/javascript',body:fixture})
if (url.pathname.startsWith('/assets/')) {
const file = path.join(assets, url.pathname.slice('/assets/'.length))
if (fs.existsSync(file) && fs.statSync(file).isFile()) return route.fulfill({path:file})
}
return route.fulfill({status:404,body:'not found'})
})
await page.goto('http://offline.invalid/device_settings')
await page.getByRole('button', {name:'Longitudinal (Speed & Following)',exact:true}).click()
const select = page.locator('#ds-LongitudinalControlMode')
await select.waitFor()
await page.waitForFunction(() => document.querySelector('#ds-LongitudinalControlMode')?.value === 'conditional_experimental')
assert.equal(await select.isEnabled(), true)
assert.deepEqual(await select.locator('option').allTextContents(), ['Chill','Experimental','Conditional Experimental','Conditional Chill'])
assert.equal(await page.locator('#ds-ConditionalExperimental, #ds-ConditionalChill').count(), 0)
assert.equal(await page.locator('#ds-manual-CESpeed').count(), 0)
const manage = page.locator('[aria-controls="ds-LongitudinalControlMode-children"]')
assert.equal(await manage.getAttribute('aria-expanded'), 'false')
if(process.env.GALAXY_DOM_SCREENSHOT) await page.screenshot({path:process.env.GALAXY_DOM_SCREENSHOT.replace('.png','-collapsed.png'),fullPage:true})
await manage.click()
assert.equal(await page.locator('#ds-manual-CESpeed').isVisible(), true)
await manage.click()
assert.equal(await page.locator('#ds-manual-CESpeed').count(), 0)
assert.equal(await select.isVisible(), true)
await manage.click()
assert.equal(await page.locator('#ds-manual-CCMSpeed').count(), 0)
assert.equal(await page.evaluate(() => window.modeWrites.length), 0)
for (const target of ['conditional_chill','chill']) {
await select.selectOption(target)
await page.waitForFunction(target => document.querySelector('#ds-LongitudinalControlMode')?.value === target && !document.querySelector('#ds-LongitudinalControlMode')?.disabled, target)
assert.equal(await page.locator('#ds-manual-CESpeed').count(), 0)
assert.equal(await manage.count(), target === 'chill' ? 0 : 1)
assert.equal(await page.locator('#ds-manual-CCMSpeed').count(), target === 'conditional_chill' ? 1 : 0)
}
const beforeExperimental = await page.evaluate(() => window.modeWrites.length)
await page.evaluate(() => { window.holdModeWrite=true })
await select.selectOption('experimental')
await page.waitForFunction(() => !!window.releaseModeWrite && document.querySelector('#ds-LongitudinalControlMode')?.disabled)
await page.evaluate(() => { window.holdModeWrite=false; window.releaseModeWrite() })
await page.waitForFunction(() => document.querySelector('#ds-LongitudinalControlMode')?.value === 'experimental' && !document.querySelector('#ds-LongitudinalControlMode')?.disabled)
assert.equal(await page.evaluate(() => window.modeWrites.at(-1).acknowledged), true)
assert.equal(await page.evaluate(() => window.modeWrites.length), beforeExperimental + 1)
assert.deepEqual(dialogs, [])
assert.equal(await manage.count(), 0)
assert.equal(await page.locator('#ds-manual-CESpeed, #ds-manual-CCMSpeed').count(), 0)
await page.evaluate(() => { window.failModeWrite = true })
await select.selectOption('conditional_chill')
await page.waitForFunction(() => document.querySelector('#ds-LongitudinalControlMode')?.value === 'experimental' && !document.querySelector('#ds-LongitudinalControlMode')?.disabled)
await page.evaluate(() => { window.failModeWrite = false })
await select.selectOption('conditional_experimental')
await page.waitForFunction(() => document.querySelector('#ds-manual-CESpeed'))
await page.screenshot({path:process.env.GALAXY_DOM_SCREENSHOT || '/tmp/galaxy-longitudinal-mode.png',fullPage:true})
await page.evaluate(() => window.lockMode())
await page.waitForFunction(() => document.querySelector('#ds-LongitudinalControlMode')?.disabled)
await page.evaluate(() => { window.failModeRead=true })
await page.waitForFunction(() => document.querySelector('#ds-LongitudinalControlMode')?.value === '')
assert.equal(await select.isDisabled(), true)
assert.deepEqual(errors, [])
console.log('PASS: real DOM initial CEM precedence/no writes; collapsed Manage disclosure/visible dropdown; all four choices; mode-specific children; no experimental dialog; request acknowledgement; failed-write readback; Safe Mode and missing-state locks; zero page errors')
} finally { await browser.close() }
})().catch(error => { console.error(error); process.exitCode=1 })
@@ -1,157 +0,0 @@
"""Host-only evidence for residual nonparticipating readers/writers.
Execute the actual runtime selector functions/assignments via AST, without
importing native Params or starting services. Legacy UI readers and external
Dom writers still don't share the adapter lock. They cannot gain atomicity from
write ordering. Driving handoff coverage is in test_coherent_mode_handoff.py.
"""
import ast
from itertools import combinations_with_replacement, permutations, product
from pathlib import Path
from types import SimpleNamespace
import pytest
from test_longitudinal_mode import Params, mode
from openpilot.starpilot.common.longitudinal_mode import read_mode_values
ROOT = Path(__file__).resolve().parents[4]
def runtime_requested():
path = ROOT / 'starpilot/common/experimental_state.py'
tree = ast.parse(path.read_text())
# Future annotations make Params only a type annotation. Do not import it.
tree.body = [node for node in tree.body if not (
isinstance(node, ast.ImportFrom) and node.module == 'openpilot.common.params')]
namespace = {}
exec(compile(tree, str(path), 'exec'), namespace)
return namespace['requested_experimental_mode']
REQUESTED = runtime_requested()
def runtime_toggles(params):
path = ROOT / 'starpilot/common/starpilot_variables.py'
tree = ast.parse(path.read_text())
names = {'conditional_experimental_mode', 'conditional_chill_mode'}
nodes = [node for node in ast.walk(tree) if isinstance(node, ast.Assign)
and len(node.targets) == 1 and isinstance(node.targets[0], ast.Attribute)
and isinstance(node.targets[0].value, ast.Name)
and node.targets[0].value.id == 'toggle' and node.targets[0].attr in names]
assert len(nodes) == 2
nodes.sort(key=lambda node: node.lineno)
toggle = SimpleNamespace(openpilot_longitudinal=True, safe_mode=False)
selection = ast.Module(body=[], type_ignores=[])
first = next(node for node in ast.walk(tree) if isinstance(node, ast.Assign)
and isinstance(node.targets[0], ast.Name) and node.targets[0].id == 'mode_values')
selection.body.extend([first, *nodes])
exec(compile(selection, str(path), 'exec'),
{'toggle': toggle, 'self': SimpleNamespace(params=params), 'read_mode_values': read_mode_values})
return toggle
class InterleavedParams(Params):
def __init__(self, values, writes, schedule, manual=True):
super().__init__(values)
self.pending = list(writes)
self.schedule = iter(schedule)
self.applied = 0
self.trace = []
self.manual = manual
def get_bool(self, key):
if key in mode.MODE_KEYS:
until = next(self.schedule, self.applied)
while self.applied < until:
name, enabled = self.pending[self.applied]
self.values[name] = enabled
self.applied += 1
self.trace.append((key, self.values[key]))
return super().get_bool(key)
def get_int(self, key, default=0):
# Both conditional modes request EXP for this manual override fixture.
return {'PersistedCEStatus': 2, 'PersistedCCStatus': 1}.get(key, default) if self.manual else default
def observed_branch(params):
for key, enabled in params.trace:
if enabled:
return next(name for name, value in mode.MODES.items() if value == key)
return 'chill'
def outcomes(values, writes):
result = set()
# Reader visits at most three mode keys. Include every relative placement
# of the ordered writer operations before/between those reads.
for schedule in combinations_with_replacement(range(len(writes) + 1), 3):
params = InterleavedParams(values, writes, schedule)
REQUESTED(params)
result.add(observed_branch(params))
return result
@pytest.mark.parametrize('bits', list(product([False, True], repeat=3)))
@pytest.mark.parametrize('target', list(mode.MODES))
def test_adapter_cannot_protect_nonparticipating_ui_readers(bits, target):
params = Params(dict(zip(mode.MODE_KEYS, bits)))
before = params.values.copy()
old = mode.selected_mode(before)
result = mode.set_mode(params, target, before, lambda: True)
seen = outcomes(before, params.writes)
assert result['mode'] == target
if old == target:
assert not params.writes
assert seen == {old}
else:
assert old in seen and target in seen
# Nonparticipating legacy readers may still observe ordinary fallback;
# the exact torn-read counterexample below is retained, not concealed.
assert seen <= {old, target, 'chill', 'experimental'}
@pytest.mark.parametrize('writes', list(permutations([
('ConditionalExperimental', True), ('ConditionalChill', False)])))
def test_no_order_of_required_ccm_to_cem_writes_fixes_runtime_reader(writes):
values = {'ConditionalExperimental': False, 'ConditionalChill': True, 'ExperimentalMode': False}
assert outcomes(values, writes) == {'conditional_experimental', 'conditional_chill', 'chill'}
def test_dom_target_first_is_coherent_at_write_boundaries_but_not_between_reads():
values = {'ConditionalExperimental': False, 'ConditionalChill': True, 'ExperimentalMode': False}
writes = [('ConditionalExperimental', True), ('ConditionalChill', False)]
params = Params(values)
stored = [mode.selected_mode(params.values)]
for key, value in writes:
params.put_bool(key, value)
stored.append(mode.selected_mode(params.values))
assert stored == ['conditional_chill', 'conditional_experimental', 'conditional_experimental']
# CEM read before both writes, CCM and EXP after both writes: a branch which
# never existed in storage. Manual EXP in both endpoints becomes false.
params = InterleavedParams(values, writes, [0, 2, 2])
assert REQUESTED(params) is False
assert observed_branch(params) == 'chill'
for endpoint in [values, params.values]:
assert REQUESTED(InterleavedParams(endpoint, [], [])) is True
def test_nonparticipating_writer_can_still_tear_a_shared_reader():
params = InterleavedParams(
{'ConditionalExperimental': True, 'ConditionalChill': False, 'ExperimentalMode': False},
[('ConditionalChill', True), ('ConditionalExperimental', False)], [0, 0, 2])
toggles = runtime_toggles(params)
assert not toggles.conditional_experimental_mode
assert not toggles.conditional_chill_mode
assert mode.selected_mode(params.values) == 'conditional_chill'
def test_upstream_manual_and_default_semantics_are_not_aliases():
cem = {'ConditionalExperimental': True, 'ConditionalChill': False, 'ExperimentalMode': True}
ccm = {'ConditionalExperimental': False, 'ConditionalChill': True, 'ExperimentalMode': False}
assert REQUESTED(InterleavedParams(cem, [], [], manual=False)) is False
assert REQUESTED(InterleavedParams(ccm, [], [], manual=False)) is True
assert REQUESTED(InterleavedParams(cem, [], [], manual=True)) is True
assert REQUESTED(InterleavedParams({**ccm, 'SafeMode': True}, [], [], manual=True)) is False
@@ -1,95 +0,0 @@
"""Concrete regressions from the on-road handoff review; fake Params only."""
import ast
import copy
from itertools import product
from types import SimpleNamespace as NS
import pytest
from test_longitudinal_mode import Params, mode
from test_coherent_mode_handoff import nodes_in, execute, selfdrive_result, mode_lock, read_mode_values
@pytest.mark.parametrize('bits', list(product([False, True], repeat=3)))
@pytest.mark.parametrize('target', list(mode.MODES))
def test_failed_write_at_every_boundary_keeps_old_or_requested_mode(bits, target):
before = dict(zip(mode.MODE_KEYS, bits))
old = mode.selected_mode(before)
if old == target:
return # No-op normalisation is covered separately.
for fail_at, after_write in product([1, 2, 3], [False, True]):
params = Params(before | {'IsOffroad': False, 'IsOnroad': True})
put = params.put_bool
stored = []
def failing(key, value):
if len(params.writes) + 1 == fail_at and not after_write:
params.writes.append((key, value))
raise OSError('before write')
put(key, value)
stored.append(mode.selected_mode(params.values))
if len(params.writes) == fail_at:
raise OSError('after write')
params.put_bool = failing
with pytest.raises(mode.ModeError):
mode.set_mode(params, target, before, lambda: True)
assert len(params.writes) == fail_at
assert set(stored) <= {old, target}
assert mode.selected_mode(read_mode_values(params)) in {old, target}
def update_prefix():
method = copy.deepcopy(next(node for node in ast.walk(nodes_in('starpilot/common/starpilot_variables.py'))
if isinstance(node, ast.FunctionDef) and node.name == 'update'))
# Execute the real pre-mutation branch, including its early return.
assert isinstance(method.body[0], ast.Assign) and isinstance(method.body[1], ast.Try)
method.body = method.body[:2] + ast.parse('return mode_values, clear_update_flag').body
ast.fix_missing_locations(method)
env = {'read_mode_values': read_mode_values}
execute([method], env)
return env['update']
@pytest.mark.parametrize('existing', [False, True])
@pytest.mark.parametrize('failure', ['busy', 'unavailable'])
def test_startup_and_sync_refresh_dont_crash_or_mutate_old_snapshot(existing, failure, tmp_path):
params, memory = Params(), Params()
old = NS(longitudinal_mode_values={key: True for key in mode.MODE_KEYS}, sentinel={'untouched': True}) if existing else NS()
before = copy.deepcopy(vars(old))
variables = NS(params=params, params_memory=memory, starpilot_toggles=old)
run = update_prefix()
if failure == 'busy':
with mode_lock(params, exclusive=True):
result = run(variables)
else:
params.get_param_path = lambda: str(tmp_path/'missing'/'d')
result = run(variables)
assert vars(old) == before
assert memory.get_bool('StarPilotTogglesUpdated')
if existing:
assert result is None # Return before the first shared-object mutation.
else:
values, clear_update_flag = result
assert values == {key: False for key in mode.MODE_KEYS}
assert clear_update_flag is False # Complete startup in Chill, retry later.
@pytest.mark.parametrize('previous,plan,conditional', list(product([False, True], repeat=3)))
def test_old_replay_without_complete_plan_retains_dom_behaviour(previous, plan, conditional):
cached = NS(conditional_experimental_mode=conditional, conditional_chill_mode=False)
assert selfdrive_result(plan, previous=previous, cached=cached, replay=True) is (plan if conditional else previous or plan)
assert selfdrive_result(plan, previous=previous, cached=cached, replay=True, safe=True) is False
def test_live_params_thread_never_uses_replay_fallback():
method = next(node for node in ast.walk(nodes_in('selfdrive/selfdrived/selfdrived.py'))
if isinstance(node, ast.FunctionDef) and node.name == 'params_thread')
branch = next(node for node in ast.walk(method) if isinstance(node, ast.If) and isinstance(node.test, ast.Name) and node.test.id == 'REPLAY')
calls = []
state = NS(params=Params({'ExperimentalMode': True}), params_memory=Params(), safe_mode=False,
experimental_mode=False, starpilot_toggles=NS(conditional_experimental_mode=False), CP=object())
env = dict(self=state, REPLAY=False, request_mode_refresh=lambda *args: calls.append('refresh'), experimental_mode_available=lambda cp: True)
execute([branch], env)
assert state.experimental_mode is False and calls == ['refresh']
env['REPLAY'] = True
execute([branch], env)
assert state.experimental_mode is True and calls == ['refresh']
@@ -1,33 +0,0 @@
"""Mode writes stop immediately when safety/road-state eligibility changes."""
import pytest
from test_longitudinal_mode import Params, mode
@pytest.mark.parametrize('after_write', [1, 2, 3])
@pytest.mark.parametrize('key,value', [('IsOnroad', True), ('IsOffroad', False), ('SafeMode', True)])
def test_guard_transition_during_write_stops_further_writes(after_write, key, value):
params = Params()
original_put = params.put_bool
def put(name, enabled):
original_put(name, enabled)
if len(params.writes) == after_write:
params.values[key] = value
params.put_bool = put
with pytest.raises(mode.ModeError):
mode.set_mode(params, 'experimental', params.values.copy(), lambda: True, acknowledged=True)
assert len(params.writes) == after_write
assert mode.selected_mode(params.values) in {'conditional_experimental', 'experimental'}
def test_multi_key_write_boundaries_never_expose_intermediate_chill():
# Target first: partial failure cannot select an unrelated stored mode.
params = Params({'ConditionalExperimental': True, 'ConditionalChill': False, 'ExperimentalMode': False})
observed = []
original_put = params.put_bool
def put(name, enabled):
original_put(name, enabled)
observed.append(mode.selected_mode({key: params.get_bool(key) for key in mode.MODE_KEYS}))
params.put_bool = put
mode.set_mode(params, 'conditional_chill', params.values.copy(), lambda: True)
assert observed == ['conditional_experimental', 'conditional_chill', 'conditional_chill']
@@ -1,36 +0,0 @@
// Exact Big Dipper methods with a synthetic CAS server and real layout/fixture.
const fs=require('fs'),path=require('path'),vm=require('vm'),assert=require('assert');
const root=path.resolve(__dirname,'../../../..');
const source=fs.readFileSync(path.join(__dirname,'../assets/mobile/js/components/PersonalityProfiles.js'),'utf8').replace(/^import .*\n/gm,'').replace('export const PersonalityProfiles =','globalThis.PersonalityProfiles =');
const clone=x=>JSON.parse(JSON.stringify(x));
const data=JSON.parse(fs.readFileSync(path.join(__dirname,'browser/fixtures/personality_profiles.json')));
const layout=JSON.parse(fs.readFileSync(path.join(root,'starpilot/common/assets/device_settings_layout.json')));
let server=clone(data),puts=0;
const values={IsOnroad:false,IsOffroad:true};
const context={personalityProfileParamKey:p=>p[0].toUpperCase()+p.slice(1)+'PersonalityProfile',showSnackbar:()=>{},api:{getParams:async()=>values,getLayout:async()=>layout,getPersonalityProfiles:async()=>clone(server),savePersonalityProfile:async payload=>{
puts++;
assert(payload.expected,'shipped editor must opt into CAS');
if(JSON.stringify(payload.expected)!==JSON.stringify(server.profiles[payload.profile][payload.category]))throw Error('409 Saved profile changed');
server.profiles[payload.profile][payload.category]={preset:payload.preset,curve:payload.curve};
}}};
vm.createContext(context);vm.runInContext(source,context);
const component=context.PersonalityProfiles;
const instance={...component.data(),...component.methods,$emit:()=>{}};
for(const [name,get] of Object.entries(component.computed))if(typeof get==='function')Object.defineProperty(instance,name,{get});
(async()=>{
await instance.load();assert(instance.ready);
// Another editor/restore changes the category after this editor loaded.
server.profiles.standard.acceleration={preset:'custom',curve:[2,...Array(9).fill(1)]};
instance.drafts.standardacceleration=[1,3,...Array(8).fill(1)];
await instance.saveCurve('standard','acceleration');
assert.equal(puts,1);assert.equal(server.profiles.standard.acceleration.curve[0],2);
assert.deepEqual(instance.data.profiles.standard.acceleration,server.profiles.standard.acceleration);
assert(instance.ready&&!instance.busy&&!instance.curvePending);
assert(/verified saved state/.test(instance.notice));
assert(!instance.drafts.standardacceleration);
// A subsequent edit uses the reconciled snapshot and preserves the other point.
instance.drafts.standardacceleration=[2,3,...Array(8).fill(1)];
await instance.saveCurve('standard','acceleration');
assert.equal(puts,2);assert.deepEqual(server.profiles.standard.acceleration.curve.slice(0,2),[2,3]);
console.log('PASS exact Big Dipper saveCurve/write/load CAS conflict reconciliation and subsequent preserved edit');
})().catch(e=>{console.error(e);process.exitCode=1});
@@ -1,67 +0,0 @@
"""Category CAS is opt-in; legacy clients retain the original PUT contract."""
import copy
import pytest
from test_personality_profiles_api import _client, default_personality_profiles, profile_document, PERSONALITY_PROFILES_PARAM, the_galaxy
@pytest.mark.parametrize('writer', ['classic', 'big_dipper', 'slot_restore'])
def test_stale_category_rejected_without_overwrite(monkeypatch, writer):
profiles = default_personality_profiles(False)
profiles['standard']['acceleration'] = {'preset': 'custom', 'curve': [1.0] * 10}
client, params = _client(monkeypatch, {PERSONALITY_PROFILES_PARAM: profile_document(profiles, enabled=True)})
original = client.get('/api/personality_profiles').get_json()['profiles']['standard']['acceleration']
first = copy.deepcopy(original)
first['curve'][0] = 2.0
if writer == 'slot_restore':
# Same persisted document seam used by a successful validated slot restore.
profiles['standard']['acceleration'] = first
params.put(PERSONALITY_PROFILES_PARAM, profile_document(profiles, enabled=True))
else:
assert client.put('/api/personality_profiles', json={'profile': 'standard', 'category': 'acceleration', **first, 'expected': original}).status_code == 200
second = copy.deepcopy(original)
second['curve'][1] = 3.0
response = client.put('/api/personality_profiles', json={'profile': 'standard', 'category': 'acceleration', **second, 'expected': original})
assert response.status_code == 409
assert client.get('/api/personality_profiles').get_json()['profiles']['standard']['acceleration'] == first
def test_legacy_put_contract_unchanged(monkeypatch):
client, _ = _client(monkeypatch, {})
response = client.put('/api/personality_profiles', json={'profile': 'standard', 'category': 'acceleration', 'preset': 'custom', 'curve': []})
assert response.status_code == 200
@pytest.mark.parametrize('expected', [None, True, [], {'preset': 'custom', 'curve': [True] * 10}])
def test_invalid_expected_rejected(monkeypatch, expected):
client, _ = _client(monkeypatch, {})
response = client.put('/api/personality_profiles', json={'profile': 'standard', 'category': 'acceleration', 'preset': 'custom', 'curve': [], 'expected': expected})
assert response.status_code in (400, 409)
def test_precondition_reads_inside_lock(monkeypatch):
profiles = default_personality_profiles(False)
original = {'preset': 'custom', 'curve': [1.0] * 10}
profiles['standard']['acceleration'] = original
client, params = _client(monkeypatch, {PERSONALITY_PROFILES_PARAM: profile_document(profiles, enabled=True)})
class ChangedWhileWaiting:
def __enter__(self):
profiles['standard']['acceleration'] = {'preset': 'custom', 'curve': [2.0] * 10}
params.values[PERSONALITY_PROFILES_PARAM] = profile_document(profiles, enabled=True)
def __exit__(self, *_):
pass
monkeypatch.setattr(the_galaxy, '_PERSONALITY_PROFILES_WRITE_LOCK', ChangedWhileWaiting())
response = client.put('/api/personality_profiles', json={'profile':'standard', 'category':'acceleration', **original, 'expected':original})
assert response.status_code == 409
assert params.writes == []
@pytest.mark.parametrize('boolean_expected', [False, True])
def test_numeric_roundtrip_and_historical_point_preservation(monkeypatch, boolean_expected):
profiles = default_personality_profiles(False)
original = {'preset':'custom', 'curve':[6.0] + [1.0] * 9}
profiles['standard']['acceleration'] = original
client, _ = _client(monkeypatch, {PERSONALITY_PROFILES_PARAM: profile_document(profiles, enabled=True)})
expected = {'preset':'custom', 'curve':[6] + [True if boolean_expected else 1] * 9}
edited = [6] + [2] + [1] * 8
response = client.put('/api/personality_profiles', json={'profile':'standard', 'category':'acceleration', 'preset':'custom', 'curve':edited, 'expected':expected})
assert response.status_code == (409 if boolean_expected else 200)
if not boolean_expected:
assert response.get_json()['profiles']['standard']['acceleration']['curve'] == edited
@@ -1,904 +0,0 @@
import json
import numpy as np
import pytest
from openpilot.starpilot.common.accel_profile import A_CRUISE_MAX_BP_CUSTOM, ACCELERATION_PROFILES, interpolate_accel_profile
from openpilot.starpilot.common.longitudinal_personality_profiles import (
FOLLOWING_SPEEDS_MPH,
PERSONALITY_ADVANCED_PARAM_KEYS,
PERSONALITY_FOLLOW_PARAM_KEYS,
PERSONALITY_PARKED_PARAM_KEYS,
PERSONALITY_PROFILE_ENABLE_PARAM_KEYS,
PERSONALITY_PROFILES_PARAM,
PROFILE_SCHEMA_VERSION,
default_personality_profiles,
initial_custom_curve,
migrate_profile_document,
profile_document,
strict_profile_document,
)
from test_navigation_params import _params_client, the_galaxy
def _client(monkeypatch, values=None, *, ev_tuning=False, truck_tuning=False):
device_values = dict(values or {})
device_values.setdefault("IsOnroad", False)
device_values.setdefault("IsOffroad", not device_values["IsOnroad"])
client, params = _params_client(monkeypatch, device_values, "tici")
personality_keys = set(PERSONALITY_PARKED_PARAM_KEYS)
personality_bool_keys = set(PERSONALITY_PROFILE_ENABLE_PARAM_KEYS) | {"CustomPersonalities"}
base_types = {"AlphaLongitudinalEnabled": bool, "ForceOffroad": bool, "FordLateralMode": int}
monkeypatch.setattr(
the_galaxy, "_get_param_type_info",
lambda: (
set(base_types) | personality_keys,
base_types | dict.fromkeys(personality_bool_keys, bool)
| dict.fromkeys(personality_keys - personality_bool_keys, float),
),
)
monkeypatch.setattr(the_galaxy, "_get_detected_ev_tuning", lambda: ev_tuning)
monkeypatch.setattr(the_galaxy, "_get_detected_truck_tuning", lambda: truck_tuning, raising=False)
monkeypatch.setattr(the_galaxy, "_safe_params_get_live_raw", lambda key, default=None, block=False: params.values.get(key, default))
return client, params
def _slot_client(monkeypatch, tmp_path, settings, values=None):
client, params = _client(monkeypatch, values)
types = {key: the_galaxy.ParamKeyType.BOOL if key == "CustomPersonalities" else the_galaxy.ParamKeyType.FLOAT
for key in settings}
monkeypatch.setattr(params, "get_type", lambda key: types[key], raising=False)
monkeypatch.setattr(the_galaxy, "_params_raw", params)
monkeypatch.setattr(the_galaxy, "TOGGLE_BACKUPS", tmp_path)
monkeypatch.setattr(the_galaxy, "_get_toggle_backup_keys", lambda: set(settings))
monkeypatch.setattr(the_galaxy, "update_starpilot_toggles", lambda: None)
(tmp_path / ".params-profile-a.json").write_text(json.dumps({
"format": the_galaxy.param_profiles.PROFILE_FORMAT, "version": 1, "slot": "a",
"settings": {key: {"type": int(types[key]), "value": value} for key, value in settings.items()},
}))
return client, params
@pytest.mark.parametrize("state", [{"IsOnroad": True}, {"IsOffroad": False}, {"IsOffroad": None}])
def test_slot_load_requires_confirmed_offroad(monkeypatch, tmp_path, state):
client, params = _slot_client(monkeypatch, tmp_path, {"UnrelatedSetting": 2.0}, state)
assert client.post("/api/toggles/profiles/a/load").status_code == 403
assert params.writes == []
def test_slot_load_rechecks_offroad_after_acquiring_shared_lock(monkeypatch, tmp_path):
client, params = _slot_client(monkeypatch, tmp_path, {"UnrelatedSetting": 2.0})
class StateChangingLock:
def __enter__(self):
params.values["IsOffroad"] = False
def __exit__(self, *args):
pass
monkeypatch.setattr(the_galaxy, "_PERSONALITY_PROFILES_WRITE_LOCK", StateChangingLock())
assert client.post("/api/toggles/profiles/a/load").status_code == 403
assert params.writes == []
def test_slot_load_preserves_native_types_renames_and_skips(monkeypatch, tmp_path):
from datetime import datetime
client, params = _slot_client(monkeypatch, tmp_path, {"BytesSetting": 0, "TimeSetting": 0, "ChangedSetting": 0})
types = {"BytesSetting": the_galaxy.ParamKeyType.BYTES, "TimeSetting": the_galaxy.ParamKeyType.TIME,
"ChangedSetting": the_galaxy.ParamKeyType.BOOL}
monkeypatch.setattr(params, "get_type", lambda key: types[key])
monkeypatch.setattr(the_galaxy, "LEGACY_STARPILOT_PARAM_RENAMES", {"OldBytesSetting": "BytesSetting"})
path = tmp_path / ".params-profile-a.json"
payload = json.loads(path.read_text())
payload["settings"] = {
"OldBytesSetting": {"type": int(types["BytesSetting"]), "value": "AP8="},
"TimeSetting": {"type": int(types["TimeSetting"]), "value": "2026-01-01T00:00:00+00:00"},
"ChangedSetting": {"type": int(the_galaxy.ParamKeyType.FLOAT), "value": 2.0},
"UnavailableSetting": {"type": int(the_galaxy.ParamKeyType.FLOAT), "value": 3.0},
}
path.write_text(json.dumps(payload))
response = client.post("/api/toggles/profiles/a/load")
assert response.status_code == 200
assert response.get_json()["restoredCount"] == 2
assert response.get_json()["skippedCount"] == 2
assert params.values["BytesSetting"] == b"\x00\xff"
assert params.values["TimeSetting"] == datetime.fromisoformat("2026-01-01T00:00:00+00:00")
assert {key for key, _ in params.writes} == {"BytesSetting", "TimeSetting"}
@pytest.mark.parametrize("key,value", [
("CustomPersonalities", "true"),
(sorted(PERSONALITY_ADVANCED_PARAM_KEYS)[0], 200.1),
(sorted(PERSONALITY_FOLLOW_PARAM_KEYS)[0], 99),
])
def test_slot_load_validates_personality_before_any_writes(monkeypatch, tmp_path, key, value):
client, params = _slot_client(monkeypatch, tmp_path, {"UnrelatedSetting": 2.0, key: value})
assert client.post("/api/toggles/profiles/a/load").status_code == 400
assert params.writes == []
@pytest.mark.parametrize("enabled", [False, True])
def test_slot_load_rejects_incompatible_document_before_any_writes(monkeypatch, tmp_path, enabled):
client, params = _slot_client(monkeypatch, tmp_path, {"UnrelatedSetting": 2.0, "CustomPersonalities": enabled}, {
PERSONALITY_PROFILES_PARAM: {"schemaVersion": 99},
})
assert client.post("/api/toggles/profiles/a/load").status_code == 409
assert params.writes == []
@pytest.mark.parametrize("enabled", [False, True])
def test_slot_load_syncs_master_preserves_historical_curves_under_shared_lock(monkeypatch, tmp_path, enabled):
profiles = default_personality_profiles(False)
profiles["aggressive"]["acceleration"] = {"preset": "custom", "curve": [6.0] * 10}
client, params = _slot_client(monkeypatch, tmp_path, {"UnrelatedSetting": 2.0, "CustomPersonalities": enabled}, {
PERSONALITY_PROFILES_PARAM: profile_document(profiles, enabled=not enabled),
})
original_put = params.put
original_put_bool = params.put_bool
def locked_put(key, value):
assert the_galaxy._PERSONALITY_PROFILES_WRITE_LOCK.locked()
original_put(key, value)
def locked_put_bool(key, value):
assert the_galaxy._PERSONALITY_PROFILES_WRITE_LOCK.locked()
original_put_bool(key, value)
monkeypatch.setattr(params, "put", locked_put)
monkeypatch.setattr(params, "put_bool", locked_put_bool)
response = client.post("/api/toggles/profiles/a/load")
assert response.status_code == 200
assert response.get_json()["restoredCount"] == 2
assert response.get_json()["slot"] == "a"
assert params.values["CustomPersonalities"] is enabled
document = strict_profile_document(params.values[PERSONALITY_PROFILES_PARAM])
assert document is not None
assert document["enabled"] is enabled
assert document["profiles"] == profiles
assert params.values["UnrelatedSetting"] == 2.0
@pytest.mark.parametrize("value", [3.51, 4.0, 5.0, 6.0])
def test_saved_v2_high_curve_read_migrate_edit_and_master_round_trip(monkeypatch, value):
profiles = default_personality_profiles(False)
profiles["aggressive"]["acceleration"] = {"preset": "custom", "curve": [value] * 10}
raw = json.dumps(profile_document(profiles, enabled=True))
client, params = _client(monkeypatch, {PERSONALITY_PROFILES_PARAM: raw, "CustomPersonalities": True})
response = client.get("/api/personality_profiles")
assert response.status_code == 200
assert response.get_json()["profiles"] == profiles
assert response.get_json()["bounds"]["acceleration"] == [0.0, 3.5]
assert response.get_json()["migration_required"] is False
assert client.post("/api/personality_profiles/migrate").status_code == 200
assert params.values[PERSONALITY_PROFILES_PARAM] == raw
assert params.writes == []
assert client.put("/api/personality_profiles", json={
"profile": "relaxed", "category": "braking", "preset": "eco", "curve": [],
}).status_code == 200
stored = strict_profile_document(params.values[PERSONALITY_PROFILES_PARAM])
assert stored is not None
assert stored["profiles"]["aggressive"] == profiles["aggressive"]
curve = [3.0] + [value] * 9
response = client.put("/api/personality_profiles", json={
"profile": "aggressive", "category": "acceleration", "preset": "custom", "curve": curve,
})
assert response.status_code == 200
assert response.get_json()["profiles"]["aggressive"]["acceleration"]["curve"] == curve
for enabled in (False, True):
assert client.put("/api/params", json={"key": "CustomPersonalities", "value": enabled}).status_code == 200
stored = strict_profile_document(params.values[PERSONALITY_PROFILES_PARAM])
assert stored is not None
assert stored["enabled"] is enabled
assert stored["profiles"]["aggressive"]["acceleration"]["curve"] == curve
before = json.dumps(params.values, sort_keys=True)
writes = list(params.writes)
assert client.put("/api/personality_profiles", json={
"profile": "aggressive", "category": "acceleration", "preset": "custom", "curve": [value] * 10,
}).status_code == 400
assert json.dumps(params.values, sort_keys=True) == before
assert params.writes == writes
@pytest.mark.parametrize("state", [{"IsOnroad": True}, {"IsOnroad": False, "IsOffroad": False}])
def test_saved_v2_high_curve_never_bypasses_parked_write_guard(monkeypatch, state):
profiles = default_personality_profiles(False)
profiles["aggressive"]["acceleration"] = {"preset": "custom", "curve": [6.0] * 10}
raw = json.dumps(profile_document(profiles, enabled=True))
client, params = _client(monkeypatch, {PERSONALITY_PROFILES_PARAM: raw, **state})
assert client.get("/api/personality_profiles").status_code == 200
assert client.put("/api/personality_profiles", json={
"profile": "aggressive", "category": "acceleration", "preset": "custom", "curve": [3.0] + [6.0] * 9,
}).status_code == 403
assert client.post("/api/personality_profiles/migrate").status_code == 403
assert client.put("/api/params", json={"key": "CustomPersonalities", "value": False}).status_code == 403
assert params.values[PERSONALITY_PROFILES_PARAM] == raw
assert params.writes == []
def test_get_returns_disabled_standard_defaults_and_explicit_graph_metadata(monkeypatch):
client, _ = _client(monkeypatch)
response = client.get("/api/personality_profiles")
assert response.status_code == 200
body = response.get_json()
assert body["schema_version"] == PROFILE_SCHEMA_VERSION
assert body["configured"] is False
assert body["profiles"] == default_personality_profiles(False)
assert set(body["options"]) == {"acceleration", "braking", "following"}
assert set(body["speed_breakpoints_mph"]) == {"acceleration", "braking", "following"}
for speeds in body["speed_breakpoints_mph"].values():
assert speeds == list(FOLLOWING_SPEEDS_MPH)
assert body["reference_curves"]["aggressive"]["following"] == [1.25] * 10
assert body["reference_curves"]["standard"]["following"] == [1.45] * 10
def test_legacy_master_without_document_remains_enabled_when_first_profile_is_saved(monkeypatch):
client, params = _client(monkeypatch, {"IsOnroad": False, "CustomPersonalities": True})
readback = client.get("/api/personality_profiles").get_json()
response = client.put("/api/personality_profiles", json={
"profile": "standard", "category": "acceleration", "preset": "eco", "curve": [],
})
assert readback["enabled"] is True
assert response.status_code == 200
document = strict_profile_document(params.values[PERSONALITY_PROFILES_PARAM])
assert document is not None and document["enabled"] is True
def test_first_save_persists_one_atomic_versioned_document_with_other_categories_standard(monkeypatch):
client, params = _client(monkeypatch)
response = client.put("/api/personality_profiles", json={
"profile": "standard", "category": "braking", "preset": "sport", "curve": [2.0] * 10,
})
assert response.status_code == 200
stored = params.values[PERSONALITY_PROFILES_PARAM]
document = strict_profile_document(stored)
assert document is not None and document["enabled"] is False
assert document["profiles"]["standard"]["braking"]["preset"] == "sport"
for profile_id, profile in document["profiles"].items():
for category, config in profile.items():
if (profile_id, category) != ("standard", "braking"):
assert config == {
"preset": "medium" if category == "following" else "standard", "curve": [],
}
assert len([write for write in params.writes if write[0] == PERSONALITY_PROFILES_PARAM]) == 1
def test_profile_read_modify_write_endpoint_is_serialized():
source = (the_galaxy.Path(the_galaxy.__file__)).read_text(encoding="utf-8")
endpoint = source.split('@app.route("/api/personality_profiles"', 1)[1].split('@app.route(', 1)[0]
serializer = source.split("def _serialize_personality_profile_writes", 1)[1].split("\n\ndef ", 1)[0]
assert "@_serialize_personality_profile_writes" in endpoint
assert 'request.method not in ("PUT", "POST")' in serializer
def test_selecting_custom_is_seeded_server_side_from_current_ev_preset_with_ev_over_truck(monkeypatch):
profiles = default_personality_profiles(False)
profiles["aggressive"]["acceleration"] = {"preset": "sport", "curve": []}
stored = profile_document(profiles, enabled=True)
client, params = _client(monkeypatch, {
"IsOnroad": False, "TruckTuning": True, PERSONALITY_PROFILES_PARAM: stored,
}, ev_tuning=True)
response = client.put("/api/personality_profiles", json={
"profile": "aggressive", "category": "acceleration", "preset": "custom", "curve": [0.0] * 10,
})
assert response.status_code == 200
document = strict_profile_document(params.values[PERSONALITY_PROFILES_PARAM])
assert document["profiles"]["aggressive"]["acceleration"] == {
"preset": "custom",
"curve": initial_custom_curve(
"acceleration", {"preset": "sport", "curve": []}, ev_tuning=True, truck_tuning=False,
),
}
def test_selecting_custom_uses_the_automatically_detected_truck_curve(monkeypatch):
profiles = default_personality_profiles(False)
profiles["aggressive"]["acceleration"] = {"preset": "sport", "curve": []}
client, params = _client(monkeypatch, {
"IsOnroad": False,
PERSONALITY_PROFILES_PARAM: profile_document(profiles, enabled=True),
}, truck_tuning=True)
assert the_galaxy._get_detected_truck_tuning() is True
original_initializer = the_galaxy.initial_custom_curve
observed = {}
def capture_initializer(category, current_config, ev_tuning, truck_tuning, *, legacy_curve=None):
curve = original_initializer(category, current_config, ev_tuning, truck_tuning, legacy_curve=legacy_curve)
observed.update(ev_tuning=ev_tuning, truck_tuning=truck_tuning, curve=curve)
return curve
monkeypatch.setattr(the_galaxy, "initial_custom_curve", capture_initializer)
response = client.put("/api/personality_profiles", json={
"profile": "aggressive", "category": "acceleration", "preset": "custom", "curve": [],
})
assert response.status_code == 200
assert observed["ev_tuning"] is False
assert observed["truck_tuning"] is True
document = strict_profile_document(params.values[PERSONALITY_PROFILES_PARAM])
assert document is not None
assert document["profiles"]["aggressive"]["acceleration"]["curve"] == observed["curve"]
def test_custom_braking_is_seeded_from_selected_deceleration_preset(monkeypatch):
profiles = default_personality_profiles(False)
profiles["relaxed"]["braking"] = {"preset": "eco", "curve": []}
client, params = _client(monkeypatch, {
"IsOnroad": False, PERSONALITY_PROFILES_PARAM: profile_document(profiles, enabled=True),
})
response = client.put("/api/personality_profiles", json={
"profile": "relaxed", "category": "braking", "preset": "custom", "curve": [2.0] * 10,
})
assert response.status_code == 200
document = strict_profile_document(params.values[PERSONALITY_PROFILES_PARAM])
assert document["profiles"]["relaxed"]["braking"] == {"preset": "custom", "curve": [0.5] * 10}
def test_dom_default_custom_acceleration_seeds_from_effective_legacy_custom_curve(monkeypatch):
profiles = default_personality_profiles(False)
profiles["traffic"]["acceleration"] = {"preset": "dom_default", "curve": []}
values = {
"IsOnroad": False,
"CustomAccelProfile": True,
"CustomAccelProfileInitialized": True,
PERSONALITY_PROFILES_PARAM: profile_document(profiles, enabled=False),
**{
f"CustomAccelProfile{mph}MPH": value
for mph, value in zip((0, 11, 22, 34, 45, 56, 89), (1.1, 1.0, 0.9, 0.8, 0.7, 0.6, 0.5), strict=True)
},
}
client, params = _client(monkeypatch, values)
response = client.put("/api/personality_profiles", json={
"profile": "traffic", "category": "acceleration", "preset": "custom", "curve": [3.5] * 10,
})
assert response.status_code == 200
document = strict_profile_document(params.values[PERSONALITY_PROFILES_PARAM])
expected = [
round(interpolate_accel_profile(speed * 0.44704, [1.1, 1.0, 0.9, 0.8, 0.7, 0.6, 0.5], A_CRUISE_MAX_BP_CUSTOM), 4)
for speed in FOLLOWING_SPEEDS_MPH
]
assert document["profiles"]["traffic"]["acceleration"]["curve"] == expected
def test_existing_custom_category_persists_subsequent_graph_edits_exactly(monkeypatch):
profiles = default_personality_profiles(False)
profiles["standard"]["acceleration"] = {"preset": "custom", "curve": [1.0] * 10}
client, params = _client(monkeypatch, {
"IsOnroad": False, PERSONALITY_PROFILES_PARAM: profile_document(profiles, enabled=True),
})
edited = [round(1.0 + 0.1 * index, 4) for index in range(10)]
response = client.put("/api/personality_profiles", json={
"profile": "standard", "category": "acceleration", "preset": "custom", "curve": edited,
})
assert response.status_code == 200
document = strict_profile_document(params.values[PERSONALITY_PROFILES_PARAM])
assert document["profiles"]["standard"]["acceleration"]["curve"] == edited
def test_dom_default_custom_seed_resamples_valid_dynamic_curve_and_malformed_dynamic_falls_back(monkeypatch):
profiles = default_personality_profiles(False)
profiles["standard"]["acceleration"] = {"preset": "dom_default", "curve": []}
dynamic = {
"IsOnroad": False,
"CustomAccelProfile": True,
PERSONALITY_PROFILES_PARAM: profile_document(profiles, enabled=False),
"CustomAccelProfileBreakpointsInitialized": True,
"CustomAccelProfilePointCount": 3,
"CustomAccelProfileBreakpoint1MPH": 0,
"CustomAccelProfileBreakpoint2MPH": 40,
"CustomAccelProfileBreakpoint3MPH": 90,
"CustomAccelProfilePoint1Accel": 1.0,
"CustomAccelProfilePoint2Accel": 2.0,
"CustomAccelProfilePoint3Accel": 3.0,
}
client, params = _client(monkeypatch, dynamic)
response = client.put("/api/personality_profiles", json={
"profile": "standard", "category": "acceleration", "preset": "custom", "curve": [0.0] * 10,
})
assert response.status_code == 200
document = strict_profile_document(params.values[PERSONALITY_PROFILES_PARAM])
dynamic_axis_ms = np.array([0.0, 40.0, 90.0]) * 0.44704
expected = [
round(interpolate_accel_profile(speed * 0.44704, [1.0, 2.0, 3.0], dynamic_axis_ms), 4)
for speed in FOLLOWING_SPEEDS_MPH
]
assert document["profiles"]["standard"]["acceleration"]["curve"] == expected
malformed_client, malformed_params = _client(monkeypatch, {
**dynamic, "CustomAccelProfilePointCount": 3.5, "AccelerationProfile": ACCELERATION_PROFILES["ECO"],
})
response = malformed_client.put("/api/personality_profiles", json={
"profile": "standard", "category": "acceleration", "preset": "custom", "curve": [3.5] * 10,
})
assert response.status_code == 200
document = strict_profile_document(malformed_params.values[PERSONALITY_PROFILES_PARAM])
assert document["profiles"]["standard"]["acceleration"]["curve"] == initial_custom_curve(
"acceleration", {"preset": "eco", "curve": []}, False, False,
)
def test_following_custom_seeds_from_legacy_profile_and_then_persists_edits(monkeypatch):
profiles = default_personality_profiles(False)
profiles["standard"]["following"] = {"preset": "dom_default", "curve": []}
client, params = _client(monkeypatch, {
"IsOnroad": False,
"CustomPersonalities": True,
PERSONALITY_PROFILES_PARAM: profile_document(profiles, enabled=True),
"StandardFollow": 1.4,
"StandardFollowHigh": 1.1,
})
response = client.put("/api/personality_profiles", json={
"profile": "standard", "category": "following", "preset": "custom", "curve": [3.0] * 10,
})
assert response.status_code == 200
document = strict_profile_document(params.values[PERSONALITY_PROFILES_PARAM])
expected = [round(float(value), 4) for value in np.interp(FOLLOWING_SPEEDS_MPH, [45.0, 70.0], [1.4, 1.1])]
assert document["profiles"]["standard"]["following"] == {"preset": "custom", "curve": expected}
edited = [0.75 + index * 0.1 for index in range(10)]
response = client.put("/api/personality_profiles", json={
"profile": "standard", "category": "following", "preset": "custom", "curve": edited,
})
assert response.status_code == 200
document = strict_profile_document(params.values[PERSONALITY_PROFILES_PARAM])
assert document["profiles"]["standard"]["following"]["curve"] == [round(value, 4) for value in edited]
def test_fresh_following_custom_seeds_from_selected_medium_even_when_legacy_custom_is_off(monkeypatch):
client, params = _client(monkeypatch, {
"IsOnroad": False,
"CustomPersonalities": False,
"RelaxedFollow": 1.1,
"RelaxedFollowHigh": 0.9,
})
response = client.put("/api/personality_profiles", json={
"profile": "relaxed", "category": "following", "preset": "custom", "curve": [],
})
assert response.status_code == 200
document = strict_profile_document(params.values[PERSONALITY_PROFILES_PARAM])
assert document["profiles"]["relaxed"]["following"]["curve"] == [1.45] * len(FOLLOWING_SPEEDS_MPH)
def test_traffic_following_seed_matches_legacy_runtime_speed_units(monkeypatch):
profiles = default_personality_profiles(False)
profiles["traffic"]["following"] = {"preset": "dom_default", "curve": []}
client, params = _client(monkeypatch, {
"IsOnroad": False,
PERSONALITY_PROFILES_PARAM: profile_document(profiles, enabled=False),
"TrafficFollow": 0.8,
"RelaxedFollow": 1.6,
})
response = client.put("/api/personality_profiles", json={
"profile": "traffic", "category": "following", "preset": "custom", "curve": [],
})
assert response.status_code == 200
document = strict_profile_document(params.values[PERSONALITY_PROFILES_PARAM])
expected = [
round(float(value), 4)
for value in np.interp(np.array(FOLLOWING_SPEEDS_MPH) * 0.44704, [0.0, 25.0], [0.8, 1.6])
]
assert document["profiles"]["traffic"]["following"]["curve"] == expected
def test_invalid_payload_never_writes(monkeypatch):
client, params = _client(monkeypatch)
for payload in (
{"profile": "standard", "category": "braking", "preset": "custom", "curve": [True] * 10},
{"profile": "standard", "category": "following", "preset": "custom", "curve": [0.74] * 10},
):
response = client.put("/api/personality_profiles", json=payload)
assert response.status_code == 400
assert PERSONALITY_PROFILES_PARAM not in params.values
def test_api_exposes_and_enforces_requested_acceleration_and_braking_bounds(monkeypatch):
client, params = _client(monkeypatch, {"IsOnroad": False})
bounds = client.get("/api/personality_profiles").get_json()["bounds"]
assert bounds["acceleration"] == [0.0, 3.5]
assert bounds["braking"] == [0.5, 2.0]
accepted = client.put("/api/personality_profiles", json={
"profile": "standard", "category": "braking", "preset": "custom", "curve": [2.0] * 10,
})
assert accepted.status_code == 200
stored = params.values[PERSONALITY_PROFILES_PARAM]
rejected = client.put("/api/personality_profiles", json={
"profile": "standard", "category": "acceleration", "preset": "custom", "curve": [3.51] * 10,
})
assert rejected.status_code == 400
assert params.values[PERSONALITY_PROFILES_PARAM] == stored
@pytest.mark.parametrize("device_state", [
{"IsOnroad": True, "IsOffroad": False},
{"IsOnroad": False, "IsOffroad": False},
])
def test_dedicated_and_generic_profile_mutations_require_confirmed_offroad(monkeypatch, device_state):
original = profile_document(default_personality_profiles(False), enabled=False)
client, params = _client(monkeypatch, {**device_state, PERSONALITY_PROFILES_PARAM: original})
before = json.loads(json.dumps(params.values))
dedicated = client.put("/api/personality_profiles", json={
"profile": "traffic", "category": "acceleration", "preset": "eco", "curve": [1.0] * 7,
})
generic = client.put("/api/params", json={"key": PERSONALITY_PROFILES_PARAM, "value": {"enabled": True}})
legacy_parent = client.put("/api/params", json={"key": "CustomPersonalities", "value": True})
assert dedicated.status_code == 403
assert generic.status_code == 403
assert legacy_parent.status_code == 403
assert "parked" in legacy_parent.get_json()["error"].lower()
assert params.values == before
def test_generic_profile_mutation_is_also_rejected_while_parked(monkeypatch):
client, params = _client(monkeypatch, {"IsOnroad": False})
response = client.put("/api/params", json={"key": PERSONALITY_PROFILES_PARAM, "value": {"enabled": True}})
assert response.status_code == 403
assert PERSONALITY_PROFILES_PARAM not in params.values
def test_dedicated_enable_mutation_is_rejected(monkeypatch):
client, params = _client(monkeypatch, {"IsOnroad": False})
response = client.put("/api/personality_profiles", json={"enabled": True})
assert response.status_code == 400
assert PERSONALITY_PROFILES_PARAM not in params.values
def test_enabling_master_without_document_creates_standard_medium_defaults(monkeypatch):
client, params = _client(monkeypatch, {"IsOnroad": False})
response = client.put("/api/params", json={"key": "CustomPersonalities", "value": True})
assert response.status_code == 200
document = strict_profile_document(params.values[PERSONALITY_PROFILES_PARAM])
assert document is not None
assert document["enabled"] is True
assert document["profiles"] == default_personality_profiles(False)
def test_master_toggle_synchronises_the_profile_document_enable_bit(monkeypatch):
original = profile_document(default_personality_profiles(False), enabled=False)
client, params = _client(monkeypatch, {
"IsOnroad": False, "CustomPersonalities": False, PERSONALITY_PROFILES_PARAM: original,
})
response = client.put("/api/params", json={"key": "CustomPersonalities", "value": True})
assert response.status_code == 200
assert params.get_bool("CustomPersonalities") is True
document = strict_profile_document(params.values[PERSONALITY_PROFILES_PARAM])
assert document is not None and document["enabled"] is True
@pytest.mark.parametrize(("enabled", "expected_order"), [
(True, [PERSONALITY_PROFILES_PARAM, "CustomPersonalities"]),
(False, ["CustomPersonalities", PERSONALITY_PROFILES_PARAM]),
])
def test_master_toggle_writes_in_fail_closed_order(monkeypatch, enabled, expected_order):
original = profile_document(default_personality_profiles(False), enabled=not enabled)
client, params = _client(monkeypatch, {
"IsOnroad": False, "CustomPersonalities": not enabled, PERSONALITY_PROFILES_PARAM: original,
})
response = client.put("/api/params", json={"key": "CustomPersonalities", "value": enabled})
assert response.status_code == 200
assert [key for key, _ in params.writes] == expected_order
@pytest.mark.parametrize("enabled", [False, True])
@pytest.mark.parametrize("road_change", [{"IsOnroad": True}, {"IsOffroad": False}])
def test_master_toggle_rechecks_parked_state_after_waiting_for_profile_lock(monkeypatch, enabled, road_change):
original = profile_document(default_personality_profiles(False), enabled=not enabled)
client, params = _client(monkeypatch, {
"CustomPersonalities": not enabled, PERSONALITY_PROFILES_PARAM: original,
})
notifications = []
class StateChangingLock:
def __enter__(self):
params.values.update(road_change)
def __exit__(self, *_):
return False
monkeypatch.setattr(the_galaxy, "_PERSONALITY_PROFILES_WRITE_LOCK", StateChangingLock())
monkeypatch.setattr(the_galaxy, "update_starpilot_toggles", lambda: notifications.append(True))
response = client.put("/api/params", json={"key": "CustomPersonalities", "value": enabled})
assert response.status_code == 403
assert params.writes == []
assert params.values[PERSONALITY_PROFILES_PARAM] == original
assert params.values["CustomPersonalities"] is not enabled
assert notifications == []
def test_profile_document_write_failure_never_enables_master(monkeypatch):
original = profile_document(default_personality_profiles(False), enabled=False)
client, params = _client(monkeypatch, {
"IsOnroad": False, "CustomPersonalities": False, PERSONALITY_PROFILES_PARAM: original,
})
original_put = params.put
def fail_profile_document_write(key, value):
if key == PERSONALITY_PROFILES_PARAM:
raise OSError("injected profile document write failure")
original_put(key, value)
monkeypatch.setattr(params, "put", fail_profile_document_write)
response = client.put("/api/params", json={"key": "CustomPersonalities", "value": True})
assert response.status_code == 500
assert params.get_bool("CustomPersonalities") is False
assert params.values[PERSONALITY_PROFILES_PARAM] == original
def test_unverified_profile_document_write_never_enables_master(monkeypatch):
client, params = _client(monkeypatch, {"IsOnroad": False, "CustomPersonalities": False})
monkeypatch.setattr(the_galaxy, "_safe_params_get_live_raw", lambda key, default=None, block=False: None)
response = client.put("/api/params", json={"key": "CustomPersonalities", "value": True})
assert response.status_code == 500
assert params.get_bool("CustomPersonalities") is False
document = strict_profile_document(params.values[PERSONALITY_PROFILES_PARAM])
assert document is not None and document["enabled"] is True
def test_master_write_failure_leaves_master_false_after_verified_document_write(monkeypatch):
original = profile_document(default_personality_profiles(False), enabled=False)
client, params = _client(monkeypatch, {
"IsOnroad": False, "CustomPersonalities": False, PERSONALITY_PROFILES_PARAM: original,
})
original_put_bool = params.put_bool
def fail_master_write(key, value):
if key == "CustomPersonalities":
raise OSError("injected master write failure")
original_put_bool(key, value)
monkeypatch.setattr(params, "put_bool", fail_master_write)
response = client.put("/api/params", json={"key": "CustomPersonalities", "value": True})
assert response.status_code == 500
assert params.get_bool("CustomPersonalities") is False
document = strict_profile_document(params.values[PERSONALITY_PROFILES_PARAM])
assert document is not None and document["enabled"] is True
def test_every_state_affecting_personality_write_is_rejected_onroad(monkeypatch):
client, params = _client(monkeypatch, {"IsOnroad": True})
before = json.loads(json.dumps(params.values))
for key in PERSONALITY_PARKED_PARAM_KEYS:
value = False if key in PERSONALITY_PROFILE_ENABLE_PARAM_KEYS or key == "CustomPersonalities" else 50
response = client.put("/api/params", json={"key": key, "value": value})
assert response.status_code == 403, key
assert params.values == before
def test_every_state_affecting_personality_write_is_rejected_until_offroad_is_confirmed(monkeypatch):
client, params = _client(monkeypatch, {"IsOnroad": False, "IsOffroad": False})
before = json.loads(json.dumps(params.values))
for key in PERSONALITY_PARKED_PARAM_KEYS:
value = False if key in PERSONALITY_PROFILE_ENABLE_PARAM_KEYS or key == "CustomPersonalities" else 50
response = client.put("/api/params", json={"key": key, "value": value})
assert response.status_code == 403, key
assert params.values == before
@pytest.mark.parametrize("device_state", [
{"IsOnroad": True, "IsOffroad": False},
{"IsOnroad": False, "IsOffroad": False},
])
def test_reset_defaults_requires_confirmed_offroad_without_side_effects(monkeypatch, device_state):
personality_key = "StandardJerkAcceleration"
client, params = _client(monkeypatch, {**device_state, personality_key: 99.0})
monkeypatch.setattr(params, "all_keys", lambda: [personality_key], raising=False)
monkeypatch.setattr(params, "get_default_value", lambda key: 50.0, raising=False)
monkeypatch.setattr(the_galaxy, "_params_raw", params)
toggle_updates = []
reboots = []
monkeypatch.setattr(the_galaxy, "update_starpilot_toggles", lambda: toggle_updates.append(True))
monkeypatch.setattr(the_galaxy.HARDWARE, "reboot", lambda: reboots.append(True))
before = json.loads(json.dumps(params.values))
response = client.post("/api/toggles/reset_default")
assert response.status_code == 403
assert params.values == before
assert params.writes == []
assert toggle_updates == []
assert reboots == []
@pytest.mark.parametrize("device_state", [
{"IsOnroad": True, "IsOffroad": False},
{"IsOnroad": False, "IsOffroad": False},
])
def test_troubleshoot_reset_skips_every_parked_personality_key_without_confirmed_offroad(monkeypatch, device_state):
boolean_keys = set(PERSONALITY_PROFILE_ENABLE_PARAM_KEYS) | {"CustomPersonalities"}
original_values = {
key: True if key in boolean_keys else 99.0
for key in PERSONALITY_PARKED_PARAM_KEYS
}
client, params = _client(monkeypatch, {**device_state, **original_values})
monkeypatch.setattr(the_galaxy, "_get_default_param_values", lambda: {
key: False if key in boolean_keys else 50.0
for key in PERSONALITY_PARKED_PARAM_KEYS
})
before = json.loads(json.dumps(params.values))
response = client.post("/api/troubleshoot/reset", json={"sectionId": "personality_settings"})
assert response.status_code == 200
body = response.get_json()
skipped_by_key = {item["key"]: item["reason"] for item in body["skippedKeys"]}
assert set(skipped_by_key) == set(PERSONALITY_PARKED_PARAM_KEYS)
for key in set(PERSONALITY_ADVANCED_PARAM_KEYS) | set(PERSONALITY_FOLLOW_PARAM_KEYS):
assert skipped_by_key[key] == "blocked until required off-road state is confirmed"
assert body["updatedKeys"] == []
assert body["updatedCount"] == 0
assert body["skippedCount"] == len(PERSONALITY_PARKED_PARAM_KEYS)
assert params.values == before
assert params.writes == []
@pytest.mark.parametrize("key", sorted(PERSONALITY_ADVANCED_PARAM_KEYS))
def test_advanced_personality_values_require_numbers_in_supported_range(monkeypatch, key):
client, params = _client(monkeypatch, {"IsOnroad": False})
for invalid in (True, "50", 24.9, 200.1):
assert client.put("/api/params", json={"key": key, "value": invalid}).status_code == 400
assert key not in params.values
assert client.put("/api/params", json={"key": key, "value": 50}).status_code == 200
assert float(params.values[key]) == 50.0
def test_legacy_follow_values_require_numbers_in_supported_range(monkeypatch):
client, params = _client(monkeypatch, {"IsOnroad": False})
key = "AggressiveFollow"
for invalid in (True, "1.25", 0.49, 3.01):
assert client.put("/api/params", json={"key": key, "value": invalid}).status_code == 400
assert key not in params.values
assert client.put("/api/params", json={"key": key, "value": 1.25}).status_code == 200
assert float(params.values[key]) == 1.25
@pytest.mark.parametrize("key", sorted(PERSONALITY_PROFILE_ENABLE_PARAM_KEYS))
@pytest.mark.parametrize("invalid_value", ["true", 1, 1.0, [True], {"enabled": True}, None])
def test_profile_enable_params_require_json_booleans(monkeypatch, key, invalid_value):
client, params = _client(monkeypatch, {"IsOnroad": False})
response = client.put("/api/params", json={"key": key, "value": invalid_value})
assert response.status_code == 400
assert "boolean" in response.get_json()["error"].lower()
assert key not in params.values
def test_master_toggle_rejects_malformed_profile_document_without_mutation(monkeypatch):
malformed = {"schemaVersion": 99}
client, params = _client(monkeypatch, {
"IsOnroad": False, "CustomPersonalities": False, PERSONALITY_PROFILES_PARAM: malformed,
})
response = client.put("/api/params", json={"key": "CustomPersonalities", "value": True})
assert response.status_code == 409
assert params.get_bool("CustomPersonalities") is False
assert params.values[PERSONALITY_PROFILES_PARAM] == malformed
def _known_v1_document():
legacy = profile_document(default_personality_profiles(False), enabled=True)
legacy["schemaVersion"] = 1
legacy["axes"] = {
"acceleration": {
"speed": {"unit": "mph", "values": [0.0, 11.184681, 22.369363, 33.554044, 44.738726, 55.923407, 89.477452]},
"value": {"unit": "m/s^2", "meaning": "maximum_requested_acceleration"},
},
"braking": {
"speed": {"unit": "mph", "values": [0.0, 11.184681, 22.369363, 33.554044, 44.738726, 55.923407, 89.477452]},
"value": {"unit": "m/s^2", "meaning": "cruise_slc_deceleration_magnitude"},
},
"following": {"speed": {"unit": "mph", "values": list(range(0, 91, 10))}, "value": {"unit": "s", "meaning": "base_time_headway"}},
}
legacy["profiles"]["standard"]["acceleration"] = {"preset": "custom", "curve": [1.0] * 7}
return legacy
def test_known_v1_document_is_migrated_for_readback(monkeypatch):
legacy = _known_v1_document()
client, _ = _client(monkeypatch, {"IsOnroad": False, PERSONALITY_PROFILES_PARAM: legacy})
body = client.get("/api/personality_profiles").get_json()
assert body["configured"] is True
assert body["migration_required"] is True
assert body["schema_version"] == 2
assert len(body["profiles"]["standard"]["acceleration"]["curve"]) == 10
assert body["profiles"]["standard"]["acceleration"]["legacyCurve"] == [1.0] * 7
def test_known_v1_document_can_be_installed_by_explicit_offroad_migration(monkeypatch):
legacy = _known_v1_document()
client, params = _client(monkeypatch, {
"IsOnroad": False,
"IsOffroad": True,
"CustomPersonalities": True,
PERSONALITY_PROFILES_PARAM: legacy,
})
response = client.post("/api/personality_profiles/migrate")
assert response.status_code == 200
stored = strict_profile_document(params.values[PERSONALITY_PROFILES_PARAM])
assert stored is not None
assert stored["enabled"] is True
assert stored["profiles"]["standard"]["acceleration"]["legacyCurve"] == [1.0] * 7
assert len([write for write in params.writes if write[0] == PERSONALITY_PROFILES_PARAM]) == 1
def test_verified_v2_migration_remains_editable_and_preserves_other_legacy_curves(monkeypatch):
migrated = migrate_profile_document(_known_v1_document())
assert migrated is not None
client, params = _client(monkeypatch, {"IsOnroad": False, PERSONALITY_PROFILES_PARAM: migrated})
readback = client.get("/api/personality_profiles")
assert readback.status_code == 200
assert readback.get_json()["migration_required"] is False
response = client.put("/api/personality_profiles", json={
"profile": "aggressive", "category": "braking", "preset": "sport", "curve": [],
})
assert response.status_code == 200
stored = strict_profile_document(params.values[PERSONALITY_PROFILES_PARAM])
assert stored is not None
assert stored["profiles"]["standard"]["acceleration"]["legacyCurve"] == [1.0] * 7
assert stored["profiles"]["aggressive"]["braking"] == {"preset": "sport", "curve": []}
def test_known_v1_document_cannot_be_overwritten_before_a_verified_migration(monkeypatch):
legacy = _known_v1_document()
client, params = _client(monkeypatch, {"IsOnroad": False, PERSONALITY_PROFILES_PARAM: legacy})
profile_response = client.put("/api/personality_profiles", json={
"profile": "standard", "category": "acceleration", "preset": "eco", "curve": [],
})
master_response = client.put("/api/params", json={"key": "CustomPersonalities", "value": False})
assert profile_response.status_code == 409
assert master_response.status_code == 409
assert params.values[PERSONALITY_PROFILES_PARAM] == legacy
def test_malformed_document_is_not_overwritten_by_profile_edit(monkeypatch):
malformed = {"schemaVersion": 99}
client, params = _client(monkeypatch, {"IsOnroad": False, PERSONALITY_PROFILES_PARAM: malformed})
response = client.put("/api/personality_profiles", json={
"profile": "standard", "category": "acceleration", "preset": "eco", "curve": [],
})
assert response.status_code == 409
assert params.values[PERSONALITY_PROFILES_PARAM] == malformed
def test_malformed_stored_document_readback_fails_closed(monkeypatch):
client, _ = _client(monkeypatch, {"IsOnroad": False, PERSONALITY_PROFILES_PARAM: {"schemaVersion": 99}})
response = client.get("/api/personality_profiles")
assert response.status_code == 409
assert "malformed" in response.get_json()["error"].lower()
@@ -1,660 +0,0 @@
import json
import subprocess
from pathlib import Path
MODULE_PATH = Path(__file__).resolve().parents[1] / "assets/components/tools/personality_profiles.mjs"
DEVICE_SETTINGS_PATH = MODULE_PATH.with_name("device_settings.js")
DEVICE_SETTINGS_CSS_PATH = MODULE_PATH.with_name("device_settings.css")
DEVICE_SETTINGS_LAYOUT_PATH = MODULE_PATH.parents[5] / "common/assets/device_settings_layout.json"
SNACKBAR_PATH = MODULE_PATH.parents[2] / "js/snackbar.js"
def _run_node(script):
harness = f"""
import * as profiles from {json.dumps(MODULE_PATH.as_uri())};
const {{ formatProfileSpeed, profileSpeedUnit, valueFromPointer }} = profiles;
{script}
"""
result = subprocess.run(["node", "--input-type=module"], input=harness, capture_output=True, text=True, timeout=30)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def test_graph_speed_labels_follow_the_selected_unit_system():
result = _run_node("""
console.log(JSON.stringify([
formatProfileSpeed(10, false),
formatProfileSpeed(10, true),
profileSpeedUnit(false),
profileSpeedUnit(true),
]));
""")
assert result == ["10", "16.1", "mph", "km/h"]
def test_graph_pointer_values_are_clamped_and_snapped():
result = _run_node("""
console.log(JSON.stringify([
valueFromPointer(100, { top: 100, height: 200 }, 0.5, 2.0, 0.05),
valueFromPointer(200, { top: 100, height: 200 }, 0.5, 2.0, 0.05),
valueFromPointer(350, { top: 100, height: 200 }, 0.5, 2.0, 0.05),
]));
""")
assert result == [2.0, 1.25, 0.5]
def test_saved_high_curve_points_remain_inside_the_graph_without_widening_authoring_limits():
source = DEVICE_SETTINGS_PATH.read_text(encoding="utf-8")
geometry_function = "function graphGeometry" + source.split("function graphGeometry", 1)[1].split("\n}\n", 1)[0] + "\n}"
result = _run_node("""
const state = { personalityMeta: { bounds: { acceleration: [0, 3.5] },
speedBreakpointsMph: { acceleration: [0,10,20,30,40,50,60,70,80,90] } } };
""" + geometry_function + """
const curve = [6, 4, 3.51, 3.5, 3, 2, 1, 0.8, 0.4, 0];
const geometry = graphGeometry("acceleration", curve);
console.log(JSON.stringify({
visible: curve.every(value => geometry.y(value) >= geometry.top && geometry.y(value) <= geometry.height - geometry.bottom),
authoringBounds: state.personalityMeta.bounds.acceleration,
displayBounds: geometry.bounds,
curve,
}));
""")
assert result["visible"] is True
assert result["displayBounds"] == [0, 6]
assert result["authoringBounds"] == [0, 3.5]
assert result["curve"] == [6, 4, 3.51, 3.5, 3, 2, 1, 0.8, 0.4, 0]
def test_saved_high_curve_plot_does_not_raise_number_input_limits():
source = DEVICE_SETTINGS_PATH.read_text(encoding="utf-8")
functions = "\n".join(
"function " + name + source.split("function " + name, 1)[1].split("\n}\n", 1)[0] + "\n}"
for name in ("graphGeometry", "renderPersonalityCurve")
)
result = _run_node("""
const state = { values: {}, personalityCurveErrors: {}, personalityMeta: {
bounds: { acceleration: [0, 3.5] }, speedBreakpointsMph: { acceleration: [0,10,20,30,40,50,60,70,80,90] }
} };
const PERSONALITY_CATEGORY_DEFINITIONS = { acceleration: {label: "Acceleration", valueUnit: "m/s²", step: 0.01} };
const personalityUpdateKey = (profile, category) => `${profile}-${category}`;
const requestAnimationFrame = () => {};
const html = (parts, ...values) => parts.reduce((text, part, i) => text + part + (values[i] ?? ""), "");
""" + functions + """
const rendered = renderPersonalityCurve({id: "aggressive", label: "Aggressive"}, "acceleration", {preset:"custom",curve:[6,4,3.51,3,2,1,1,1,1,1]});
console.log(JSON.stringify({maxima:[...rendered.matchAll(/max="([^"]+)"/g)].map(match=>match[1]), warning:rendered.includes("Saved values above") }));
""")
assert result["maxima"] == ["3.5"] * 10
assert result["warning"] is True
def test_drag_on_expanded_saved_curve_uses_plot_scale_but_caps_only_edited_point():
source = DEVICE_SETTINGS_PATH.read_text(encoding="utf-8")
functions = "\n".join(
"function " + name + source.split("function " + name, 1)[1].split("\n}\n", 1)[0] + "\n}"
for name in ("graphGeometry", "beginPersonalityCurveDrag")
)
result = _run_node("""
const state = { personalityUpdating:{}, personalityProfiles:{aggressive:{acceleration:{preset:"custom",curve:[6,4,1,1,1,1,1,1,1,1]}}},
personalityMeta:{bounds:{acceleration:[0,3.5]},speedBreakpointsMph:{acceleration:[0,10,20,30,40,50,60,70,80,90]}} };
const PERSONALITY_CATEGORY_DEFINITIONS = { acceleration:{label:"Acceleration",step:0.01} };
const personalityUpdateKey = (p,c) => `${p}-${c}`;
const updates=[]; const saves=[];
const updateDraggedCurveVisual = (canvas,p,c,curve,geometry) => updates.push({curve:[...curve],bounds:geometry?.bounds});
const restorePersonalityCurveVisual = () => {};
const savePersonalityCategory = async (p,c,preset,curve) => {saves.push([...curve]);return true;};
class HTMLCanvasElement {
constructor(){this.listeners={};this.dataset={};}
getBoundingClientRect(){return {left:0,top:0,width:660,height:240};}
setPointerCapture(){} hasPointerCapture(){return false;}
addEventListener(name,fn){this.listeners[name]=fn;}
removeEventListener(name){delete this.listeners[name];}
}
""" + functions + """
const canvas=new HTMLCanvasElement();
beginPersonalityCurveDrag({currentTarget:canvas,clientX:46,clientY:111,pointerId:1,preventDefault(){}},"aggressive","acceleration");
canvas.listeners.pointermove({clientY:18});
await canvas.listeners.pointerup({pointerId:1});
console.log(JSON.stringify({updates,saves,original:state.personalityProfiles.aggressive.acceleration.curve}));
""")
assert result["updates"][0]["curve"] == [3, 4] + [1] * 8
assert result["updates"][1]["curve"] == [3.5, 4] + [1] * 8
assert all(update["bounds"] == [0, 6] for update in result["updates"])
assert result["saves"] == [[3.5, 4] + [1] * 8]
assert result["original"] == [6, 4] + [1] * 8
def test_rendered_editor_has_parked_locks_units_and_all_three_profile_categories():
source = DEVICE_SETTINGS_PATH.read_text(encoding="utf-8")
assert 'disabled="${() => !!state.values.IsOnroad' in source
assert 'aria-disabled="${() => !!state.values.IsOnroad || !!state.personalityMigrationRequired}"' in source
assert "profileSpeedUnit" in source
assert "m/s²" in source
for category in ("acceleration", "braking", "following"):
assert f'renderPersonalityCategoryField(profile, "{category}"' in source
assert 'following: { label: "Following"' in source
assert 'param.key === "CustomPersonalities" && state.expanded[param.key]' in source
assert 'param.key === "CustomPersonalities" && isParamEnabledForChildren(param)' not in source
assert '<button type="button" class="ds-manage-btn"' in source
def test_acceleration_and_braking_presets_render_from_weakest_to_strongest():
source = DEVICE_SETTINGS_PATH.read_text(encoding="utf-8")
assert 'acceleration: ["eco", "standard", "sport", "sport_plus", "custom"]' in source
assert 'braking: ["eco", "standard", "sport", "custom"]' in source
def test_profile_master_and_advanced_controls_declare_parked_only_metadata():
layout = json.loads(DEVICE_SETTINGS_LAYOUT_PATH.read_text(encoding="utf-8"))
params = {param["key"]: param for section in layout for param in section.get("params", [])}
keys = {
"CustomPersonalities",
*{f"{profile}PersonalityProfile" for profile in ("Traffic", "Aggressive", "Standard", "Relaxed")},
"TrafficFollow",
"AggressiveFollow",
"AggressiveFollowHigh",
"StandardFollow",
"StandardFollowHigh",
"RelaxedFollow",
"RelaxedFollowHigh",
*{
f"{profile}{suffix}"
for profile in ("Traffic", "Aggressive", "Standard", "Relaxed")
for suffix in ("JerkAcceleration", "JerkDeceleration", "JerkDanger", "JerkSpeedDecrease", "JerkSpeed")
},
}
assert all(params[key].get("requires_offroad") is True for key in keys)
def test_profile_errors_are_escaped_before_the_legacy_html_snackbar_sink():
source = DEVICE_SETTINGS_PATH.read_text(encoding="utf-8")
helper = source.split("function showParamSnackbar", 1)[1].split("}\n", 1)[0]
assert "escapeSnackbarText(message)" in helper
def test_personality_cards_replace_legacy_follow_rows_without_changing_their_runtime_keys():
source = DEVICE_SETTINGS_PATH.read_text(encoding="utf-8")
advanced = source.split("const PERSONALITY_ADVANCED_KEYS = {", 1)[1].split("}\n", 1)[0]
hidden = source.split("const HIDDEN_SETTING_KEYS = new Set([", 1)[1].split("]);", 1)[0]
for key in (
"TrafficFollow",
"AggressiveFollow",
"AggressiveFollowHigh",
"StandardFollow",
"StandardFollowHigh",
"RelaxedFollow",
"RelaxedFollowHigh",
):
assert f'"{key}"' not in advanced
assert f'"{key}"' in hidden
def test_each_personality_card_maps_to_its_persisted_enable_toggle():
result = _run_node("""
const paramKey = profiles.personalityProfileParamKey;
console.log(JSON.stringify(typeof paramKey === "function" ?
["traffic", "aggressive", "standard", "relaxed"].map(paramKey) : ["missing helper"]));
""")
assert result == [
"TrafficPersonalityProfile",
"AggressivePersonalityProfile",
"StandardPersonalityProfile",
"RelaxedPersonalityProfile",
]
def test_each_personality_card_exposes_an_accessible_parked_only_enable_toggle():
source = DEVICE_SETTINGS_PATH.read_text(encoding="utf-8")
assert "function renderPersonalityProfileToggle" in source
toggle = source.split("function renderPersonalityProfileToggle", 1)[1].split("\n}", 1)[0]
assert "personalityProfileParamKey(profile.id)" in toggle
assert 'aria-label="${param.label}"' in toggle
assert 'checked="${() => !!state.values[param.key]}"' in toggle
assert 'disabled="${() => lockReason() !== ""}"' in toggle
assert 'updateParam(param.key, "checkbox")' in toggle
card = source.split("function renderPersonalityCardSnapshot", 1)[1].split("\n}", 1)[0]
assert "renderPersonalityProfileToggle(profile)" in card
def test_each_profile_enable_toggle_controls_only_its_card_editor_visibility():
source = DEVICE_SETTINGS_PATH.read_text(encoding="utf-8")
card = source.split("function renderPersonalityCardSnapshot", 1)[1].split("\n}", 1)[0]
assert 'class="ds-personality-settings"' in card
assert 'hidden="${() => !state.values[personalityProfileParamKey(profile.id)]}"' in card
assert 'hidden="${() => !!state.values[personalityProfileParamKey(profile.id)]}"' in card
assert "Turn on ${profile.label} to configure its profile." in card
assert "settingsVisible ? html`" not in card
def test_profile_enable_toggles_remain_suppressed_from_the_generic_setting_list():
source = DEVICE_SETTINGS_PATH.read_text(encoding="utf-8")
marker = "const PROFILE_HIDDEN_LAYOUT_KEYS = new Set(["
assert marker in source
hidden = source.split(marker, 1)[1].split("]);", 1)[0]
for key in (
"TrafficPersonalityProfile",
"AggressivePersonalityProfile",
"StandardPersonalityProfile",
"RelaxedPersonalityProfile",
):
assert f'"{key}"' in hidden
visibility = source.split("function isSettingVisible", 1)[1].split("\n}", 1)[0]
assert "PROFILE_HIDDEN_LAYOUT_KEYS.has(param.key)" in visibility
def test_profile_presets_are_direct_neutral_buttons_not_dropdowns():
source = DEVICE_SETTINGS_PATH.read_text(encoding="utf-8")
field = source.split("function renderPersonalityCategoryField", 1)[1].split("\n}", 1)[0]
assert "<select" not in field
assert 'aria-pressed="${() => config.preset === option ? "true" : "false"}"' in field
assert "updatePersonalityPreset(profile.id, category, option)" in field
def test_reselecting_custom_preset_is_a_noop_but_changed_presets_submit():
result = _run_node("""
const shouldSubmit = profiles.shouldSubmitPersonalityPreset;
console.log(JSON.stringify(typeof shouldSubmit === "function" ? [
shouldSubmit("custom", "custom"),
shouldSubmit("standard", "custom"),
] : ["missing helper"]));
""")
assert result == [False, True]
def test_switching_to_custom_seeds_a_complete_reference_curve():
source = DEVICE_SETTINGS_PATH.read_text(encoding="utf-8")
update = source.split("function updatePersonalityPreset", 1)[1].split("\n}\n\nfunction resetPersonalityCurve", 1)[0]
assert "state.personalityReferenceCurves?.[profileId]?.[category]" in update
assert "Array.isArray(referenceCurve)" in update
assert "selectedPreset, curve" in update
def test_graph_edits_still_submit_custom_curve_writes():
source = DEVICE_SETTINGS_PATH.read_text(encoding="utf-8")
drag = source.split("function beginPersonalityCurveDrag", 1)[1].split("\n}\n\nfunction setPersonalityCurveError", 1)[0]
adjust = source.split("function adjustPersonalityCurvePoint", 1)[1].split("\n}\n\nfunction renderPersonalityCurve", 1)[0]
assert 'savePersonalityCategory(profileId, category, "custom", curve' in drag
assert 'savePersonalityCategory(profileId, category, "custom", curve' in adjust
def test_successful_profile_save_updates_existing_reactive_category():
source = DEVICE_SETTINGS_PATH.read_text(encoding="utf-8")
saver = source.split("async function savePersonalityCategory", 1)[1].split("\n}", 1)[0]
assert "currentConfig.preset = savedConfig.preset" in saver
assert "currentConfig.curve = [...savedConfig.curve]" in saver
assert "state.personalityProfiles = data.profiles" not in saver
def test_first_successful_switch_to_custom_opens_the_profile_advanced_panel():
source = DEVICE_SETTINGS_PATH.read_text(encoding="utf-8")
saver = source.split("async function savePersonalityCategory", 1)[1].split("\n}", 1)[0]
assert 'const wasCustom = currentConfig.preset === "custom"' in saver
assert 'if (!wasCustom && savedConfig.preset === "custom")' in saver
assert "state.personalityAdvancedExpanded = {" in saver
assert "[profileId]: true" in saver
def test_personality_selectors_are_visible_without_profile_level_disclosure():
source = DEVICE_SETTINGS_PATH.read_text(encoding="utf-8")
card = source.split("function renderPersonalityCardSnapshot", 1)[1].split("\n}", 1)[0]
assert "function renderPersonalitySummaryMeter" not in source
assert "function togglePersonalityCard" not in source
assert "ds-personality-pills" not in card
assert "ds-personality-manage" not in card
assert "${isOpen ? html`" not in card
for category in ("acceleration", "braking", "following"):
assert f'renderPersonalityCategoryField(profile, "{category}"' in card
def test_custom_graphs_render_only_inside_the_advanced_panel():
source = DEVICE_SETTINGS_PATH.read_text(encoding="utf-8")
card = source.split("function renderPersonalityCardSnapshot", 1)[1].split("\n}", 1)[0]
advanced_rows = source.split("function renderPersonalityAdvancedRows", 1)[1].split("\n}", 1)[0]
advanced = source.split("function renderPersonalityAdvanced(profile", 1)[1].split("\n}", 1)[0]
assert "renderPersonalityCurve" not in card
assert "renderPersonalityAdvanced(profile, config)" in card
assert "renderPersonalityAdvancedRows(profile, config)" in advanced
for category in ("acceleration", "braking", "following"):
assert f'${{() => config.{category}.preset === "custom" ? renderPersonalityCurve(profile, "{category}", config.{category}) : ""}}' in advanced_rows
def test_personality_cards_remove_segmented_summary_and_manage_layout():
css = DEVICE_SETTINGS_CSS_PATH.read_text(encoding="utf-8")
summary = css.split(".ds-personality-summary {", 1)[1].split("}", 1)[0]
for selector in (
".ds-personality-card.open",
".ds-personality-manage",
".ds-personality-pills",
".ds-personality-summary-meter",
".ds-personality-summary-bar",
):
assert selector not in css
assert "display: flex" in summary
assert "grid-template" not in summary
def test_personality_controls_have_visible_keyboard_focus_styles():
css = DEVICE_SETTINGS_CSS_PATH.read_text(encoding="utf-8")
for selector in (
".ds-personality-option:focus-visible",
".ds-personality-advanced > button:focus-visible",
".ds-personality-advanced-choice:focus-visible",
".ds-personality-value input:focus-visible",
".ds-personality-custom-number input:focus-visible",
):
assert selector in css
def test_custom_graph_has_reference_line_and_only_reset_action():
source = DEVICE_SETTINGS_PATH.read_text(encoding="utf-8")
curve = source.split("function renderPersonalityCurve", 1)[1].split("\n}", 1)[0]
draw = source.split("function drawPersonalityCurve", 1)[1].split("\n}", 1)[0]
assert "referenceCurve" in curve
expected_label = "".join([
'aria-label="${profile.label} ${definition.label} at ${formatProfileSpeed(geometry.speeds[index], !!state.values.IsMetric)} ',
'${profileSpeedUnit(!!state.values.IsMetric)}, ${definition.valueUnit}"',
])
assert expected_label in curve
assert "referenceCurve" in draw
assert "context.setLineDash([" in draw
assert 'class="ds-personality-reference-key"' in curve
assert "Dom default" in curve
css = DEVICE_SETTINGS_CSS_PATH.read_text(encoding="utf-8")
assert ".ds-personality-reference-key" in css
assert "resetPersonalityCurve" in curve
assert ">Reset<" in curve
assert ">Copy<" not in curve
assert ">Paste<" not in curve
def test_advanced_values_use_supported_presets_without_retired_warning_copy():
source = DEVICE_SETTINGS_PATH.read_text(encoding="utf-8")
advanced_rows = source.split("function renderPersonalityAdvancedRows", 1)[1].split("\n}", 1)[0]
value_editor = source.split("function renderPersonalityAdvancedValue", 1)[1].split("\n}", 1)[0]
option_resolver = source.split("function personalityAdvancedOptions", 1)[1].split("\n}", 1)[0]
advanced = advanced_rows + value_editor + option_resolver
assert "Custom values are untested" not in advanced
assert "Chill" in advanced
assert "Standard" in advanced
assert "Custom" in advanced
assert 'key.endsWith("JerkDanger")' in option_resolver
assert '[["standard", "Standard"], ["custom", "Custom"]]' in option_resolver
assert "renderSettingRow" not in advanced
assert "updatePersonalityAdvancedPreset" in source
assert "ds-personality-advanced-choice" in value_editor
assert 'min="${bounds.min}"' in value_editor
assert 'max="${bounds.max}"' in value_editor
assert 'step="${bounds.step}"' in value_editor
assert "resolveCurrentNumericValue(param, bounds)" in value_editor
def test_profile_descriptions_are_removed():
source = DEVICE_SETTINGS_PATH.read_text(encoding="utf-8")
assert "Stop-and-go driving" not in source
assert "Assertive driving with tighter gaps" not in source
assert "Balanced everyday driving" not in source
assert "Smoother driving with larger gaps" not in source
def test_advanced_disclosure_uses_the_concise_advanced_label():
source = DEVICE_SETTINGS_PATH.read_text(encoding="utf-8")
advanced = source.split("function renderPersonalityAdvanced(profile, config)", 1)[1].split("\n}", 1)[0]
assert "${isOpen ? \"Hide\" : \"Show\"} existing smoothness & response controls" not in advanced
assert "\n Advanced\n" in advanced
def test_advanced_disclosure_updates_in_place_without_rerendering_the_card():
source = DEVICE_SETTINGS_PATH.read_text(encoding="utf-8")
advanced = source.split("function renderPersonalityAdvanced(profile, config)", 1)[1].split("\n}", 1)[0]
assert "const isOpen =" not in advanced
assert 'aria-expanded="${() => state.personalityAdvancedExpanded[profile.id] ? "true" : "false"}"' in advanced
assert "${renderPersonalityAdvancedRows(profile, config)}" in advanced
rows = source.split("function renderPersonalityAdvancedRows(profile, config)", 1)[1].split("\n}", 1)[0]
assert 'hidden="${() => !state.personalityAdvancedExpanded[profile.id]}"' in rows
assert "PERSONALITY_ADVANCED_KEYS[profile.id]" in rows
assert "renderPersonalityAdvancedValue" in rows
assert "renderSettingRow" not in rows
def test_profiles_panel_omits_the_redundant_enabled_intro_and_toggle():
source = DEVICE_SETTINGS_PATH.read_text(encoding="utf-8")
panel = source.split("function renderPersonalityProfilesPanel()", 1)[1].split("\n}", 1)[0]
assert "ds-personality-intro" not in panel
assert "Use per-personality longitudinal profiles" not in panel
assert "Acceleration, cruise/SLC braking" not in panel
def test_dom_default_is_not_offered_in_profile_selectors():
source = DEVICE_SETTINGS_PATH.read_text(encoding="utf-8")
field = source.split("function renderPersonalityCategoryField", 1)[1].split("\n}", 1)[0]
assert '.filter(option => option !== "dom_default")' in field
def test_schema_migration_state_is_visible_and_blocks_profile_writes():
source = DEVICE_SETTINGS_PATH.read_text(encoding="utf-8")
assert "personalityMigrationRequired: false" in source
assert "state.personalityMigrationRequired = !!data.migration_required" in source
assert "This profile data requires a verified migration before it can be edited." in source
assert "!!state.personalityMigrationRequired" in source
assert 'param?.key === "CustomPersonalities" && state.personalityMigrationRequired' in source
assert 'fetch("/api/personality_profiles/migrate", { method: "POST" })' in source
assert "Migrate profiles" in source
migration_warning = source.split('class="ds-personality-migration-warning"', 1)[1].split("</div>", 1)[0]
assert '!state.values.IsOffroad' not in migration_warning
assert '!!state.values.IsOnroad || state.personalityMigrationInProgress' in migration_warning
css = DEVICE_SETTINGS_CSS_PATH.read_text(encoding="utf-8")
assert ".ds-personality-migration-warning" in css
def test_all_personality_cards_start_collapsed():
source = DEVICE_SETTINGS_PATH.read_text(encoding="utf-8")
state_block = source.split("const state = reactive({", 1)[1].split("})", 1)[0]
assert "personalityExpanded: {}," in state_block
assert "personalityExpanded: { traffic: true }" not in state_block
def test_advanced_rows_are_hidden_by_author_css_when_collapsed():
css = DEVICE_SETTINGS_CSS_PATH.read_text(encoding="utf-8")
assert ".ds-personality-advanced-rows[hidden]" in css
hidden_rule = css.split(".ds-personality-advanced-rows[hidden]", 1)[1].split("}", 1)[0]
assert "display: none;" in hidden_rule
def test_personality_cards_keep_distinct_symbols_but_selectors_are_not_profile_coloured():
source = DEVICE_SETTINGS_PATH.read_text(encoding="utf-8")
css = DEVICE_SETTINGS_CSS_PATH.read_text(encoding="utf-8")
for icon in ("bi-stoplights-fill", "bi-lightning-charge-fill", "bi-speedometer2", "bi-feather"):
assert icon in source
assert '<i class="${profile.icon}" aria-hidden="true"></i>' in source
assert ".ds-personality-option[aria-pressed=\"true\"]" in css
assert ".ds-personality-option[data-profile=" not in css
def test_custom_personalities_panel_excludes_its_legacy_subtree_from_rendering_and_search():
source = DEVICE_SETTINGS_PATH.read_text(encoding="utf-8")
sections = source.split("function getSectionsWithSlug()", 1)[1].split("\n}", 1)[0]
tree = source.split("function renderSettingTree", 1)[1].split("\n}", 1)[0]
assert "personalityLegacySubtreeKeys" in sections
assert "!personalityLegacySubtreeKeys.has(param.key)" in sections
assert 'if (param.key === "CustomPersonalities") continue' in tree
def test_device_settings_polls_driving_state_and_units_while_visible():
source = DEVICE_SETTINGS_PATH.read_text(encoding="utf-8")
assert "function ensureUiContextPolling" in source
refresh = source.split("async function refreshUiContextValues", 1)[1].split("\n}", 1)[0]
assert '["IsOnroad", "IsMetric"]' in refresh
assert '`/api/params?key=${encodeURIComponent(key)}`' in refresh
polling = source.split("function ensureUiContextPolling", 1)[1].split("\n}", 1)[0]
assert 'document.visibilityState === "visible"' in polling
component = source.split("export function DeviceSettings", 1)[1]
assert "ensureUiContextPolling()" in component
def test_profile_load_errors_are_accurate_persistent_and_do_not_clear_migration_block():
source = DEVICE_SETTINGS_PATH.read_text(encoding="utf-8")
fetcher = source.split("async function fetchPersonalityProfiles", 1)[1].split("\n}", 1)[0]
assert "personalityProfilesError" in fetcher
assert "returned malformed data" in fetcher
assert "state.personalityMigrationRequired = false" not in fetcher
panel = source.split("function renderPersonalityProfilesPanel", 1)[1].split("\n}", 1)[0]
assert "state.personalityProfilesError" in panel
assert 'role="alert"' in panel
assert 'aria-live="assertive"' in panel
def test_personality_cards_and_advanced_disclosures_have_unique_accessible_relationships():
source = DEVICE_SETTINGS_PATH.read_text(encoding="utf-8")
card = source.split("function renderPersonalityCardSnapshot", 1)[1].split("\n}", 1)[0]
assert 'aria-labelledby="personality-heading-${profile.id}"' in card
assert '<strong id="personality-heading-${profile.id}">${profile.label}</strong>' in card
assert 'aria-controls="personality-body-${profile.id}"' not in card
assert 'id="personality-body-${profile.id}"' in card
assert "ds-personality-manage" not in card
advanced = source.split("function renderPersonalityAdvanced(profile, config)", 1)[1].split("\n}", 1)[0]
assert 'aria-controls="personality-advanced-${profile.id}"' in advanced
assert 'id="personality-advanced-${profile.id}"' in source
assert 'aria-hidden="true"' in advanced
manage = source.split('${() => p.is_parent_toggle', 1)[1].split("` : \"\"}", 1)[0]
assert 'aria-controls="${p.key === "CustomPersonalities" ? "personality-profiles-panel"' in manage
assert 'aria-expanded="${() => state.expanded[p.key] ? "true" : "false"}"' in manage
def test_nested_manage_panels_render_through_a_reactive_child_expression():
source = DEVICE_SETTINGS_PATH.read_text(encoding="utf-8")
tree = source.split("function renderSettingTree(paramsList, parentKey = null)", 1)[1].split("\n}", 1)[0]
assert "${() => renderSettingTree(paramsList, param.key)}" in tree
def test_personality_control_names_include_profile_category_and_units():
source = DEVICE_SETTINGS_PATH.read_text(encoding="utf-8")
curve = source.split("function renderPersonalityCurve", 1)[1].split("\n}", 1)[0]
assert 'aria-label="Reset ${profile.label} ${definition.label} graph to Dom default"' in curve
assert '${definition.valueUnit}' in curve.split('aria-label="${profile.label} ${definition.label} at', 1)[1].split('"', 1)[0]
advanced = source.split("function renderPersonalityAdvancedValue", 1)[1].split("\n}", 1)[0]
assert "profile.label" in advanced
assert "percentage" in advanced
assert 'aria-label="${profile.label} ${param.label} custom percentage"' in advanced
def test_snackbars_expose_polite_status_and_assertive_error_live_regions():
source = SNACKBAR_PATH.read_text(encoding="utf-8")
assert 'level === "error" ? "alert" : "status"' in source
assert 'level === "error" ? "assertive" : "polite"' in source
def test_graph_number_edits_use_native_validity_and_keep_persistent_inline_errors():
source = DEVICE_SETTINGS_PATH.read_text(encoding="utf-8")
adjust = source.split("function adjustPersonalityCurvePoint", 1)[1].split("\n}", 1)[0]
assert "input.valueAsNumber" in adjust
assert "input.validity.valid" in adjust
assert "Number.isFinite" in adjust
invalid_branch = adjust.split("if (!raw || !input.validity.valid || !Number.isFinite(parsed)) {", 1)[1].split("return\n }", 1)[0]
assert "savePersonalityCategory" not in invalid_branch
assert "setPersonalityCurveError" in invalid_branch
curve = source.split("function renderPersonalityCurve", 1)[1].split("\n}", 1)[0]
assert "state.personalityCurveErrors[updateKey]" in curve
assert 'role="alert"' in curve
assert 'aria-live="assertive"' in curve
assert '@change="${event => adjustPersonalityCurvePoint(profile.id, category, index, event.currentTarget)}"' in curve
def test_failed_graph_put_restores_persisted_curve_inputs_and_canvas_for_edit_and_drag():
source = DEVICE_SETTINGS_PATH.read_text(encoding="utf-8")
assert "function restorePersonalityCurveVisual" in source
restore = source.split("function restorePersonalityCurveVisual", 1)[1].split("\n}", 1)[0]
assert "drawPersonalityCurve" in restore
assert "personality-input-${profileId}-${category}-${index}" in restore
assert "personality-value-${profileId}-${category}-${index}" in restore
drag = source.split("function beginPersonalityCurveDrag", 1)[1].split("\n}\n\nfunction setPersonalityCurveError", 1)[0]
adjust = source.split("function adjustPersonalityCurvePoint", 1)[1].split("\n}\n\nfunction renderPersonalityCurve", 1)[0]
for caller in (drag, adjust):
assert 'if (!saved && !state.personalityProfilesError' in caller
assert 'restorePersonalityCurveVisual(profileId, category, state.personalityProfiles[profileId][category].curve)' in caller
assert 'restorePersonalityCurveVisual(profileId, category, config.curve)' not in caller
def test_personality_jerk_layout_metadata_matches_stored_percentage_range():
layout = json.loads(DEVICE_SETTINGS_LAYOUT_PATH.read_text(encoding="utf-8"))
jerk_params = [
param
for section in layout
for param in section.get("params", [])
if any(param.get("key", "").startswith(profile) for profile in ("Traffic", "Aggressive", "Standard", "Relaxed"))
and "Jerk" in param.get("key", "")
]
assert len(jerk_params) == 20
assert all((param.get("min"), param.get("max"), param.get("step")) == (25, 200, 1) for param in jerk_params)
def test_graph_geometry_accepts_rendered_width_without_changing_saved_bounds():
source = DEVICE_SETTINGS_PATH.read_text(encoding="utf-8")
geometry_function = "function graphGeometry" + source.split("function graphGeometry", 1)[1].split("\n}\n", 1)[0] + "\n}"
result = _run_node("""
const state = {personalityMeta:{bounds:{acceleration:[0,3.5]},speedBreakpointsMph:{acceleration:[0,90]}}};
""" + geometry_function + """
console.log(JSON.stringify([224,280,660,750].map(width => {
const g=graphGeometry("acceleration", [6,6], width);
return {width:g.width, endpoints:[g.x(0),g.x(1)], bounds:g.bounds};
})));
""")
assert result == [{"width": width, "endpoints": [46, width - 22], "bounds": [0, 6]} for width in (224, 280, 660, 750)]
def test_personality_responsive_layout_uses_available_card_width():
css = DEVICE_SETTINGS_CSS_PATH.read_text(encoding="utf-8")
assert "container-type: inline-size" in css
assert "@container (max-width: 850px)" in css
assert "repeat(auto-fit, minmax(min(100%, 260px), 1fr))" in css
source = DEVICE_SETTINGS_PATH.read_text(encoding="utf-8")
assert "canvas.clientWidth" in source
assert 'context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0)' in source
def test_personality_save_blocks_onroad_even_for_synthetic_events():
source = DEVICE_SETTINGS_PATH.read_text(encoding="utf-8")
save = "async function savePersonalityCategory" + source.split("async function savePersonalityCategory", 1)[1].split("\n}\n", 1)[0] + "\n}"
result = _run_node("""
const state = {values:{IsOnroad:true}};
const fetch = () => {throw new Error("On-road write attempted")};
""" + save + """
console.log(JSON.stringify(await savePersonalityCategory("standard", "acceleration", "eco", [])));
""")
assert result is False
def test_responsive_canvas_keeps_metric_endpoint_labels_separate_and_scales_bitmap():
source = DEVICE_SETTINGS_PATH.read_text(encoding="utf-8")
functions = "\n".join(
"function " + name + source.split("function " + name, 1)[1].split("\n}\n", 1)[0] + "\n}"
for name in ("graphGeometry", "curveTicks", "drawPersonalityCurve")
)
result = _run_node("""
const state = {values:{IsMetric:true},personalityMeta:{bounds:{acceleration:[0,3.5]},
speedBreakpointsMph:{acceleration:[0,10,20,30,40,50,60,70,80,90]}}};
const PERSONALITY_CATEGORY_DEFINITIONS={acceleration:{valueUnit:"m/s²",step:0.01}};
const window={devicePixelRatio:2};
const labels=[], transforms=[];
const context=new Proxy({
measureText:text=>({width:String(text).length*5}),
fillText:(text,x,y)=>{if(y===227) labels.push({text,x,width:String(text).length*5});},
setTransform:(...args)=>transforms.push(args),
},{get:(target,key)=>target[key] || (()=>{})});
class HTMLCanvasElement {clientWidth=261; getContext(){return context;}}
""" + functions + """
const canvas=new HTMLCanvasElement(), curve=Array(10).fill(6);
drawPersonalityCurve(canvas,"acceleration",curve);
console.log(JSON.stringify({labels,transforms,width:canvas.width,height:canvas.height,curve}));
""")
assert (result["width"], result["height"]) == (522, 480)
assert result["transforms"] == [[2, 0, 0, 2, 0, 0]]
assert result["curve"] == [6] * 10
labels = result["labels"]
assert [labels[0]["text"], labels[-1]["text"]] == ["0", "144.8"]
for left, right in zip(labels, labels[1:]):
assert left["x"] + left["width"] / 2 + 6 <= right["x"] - right["width"] / 2
@@ -1,45 +0,0 @@
"""Registry JSON {} is unconfigured, not a malformed saved profile."""
import pytest
from test_personality_profiles_api import _client, _slot_client
from openpilot.starpilot.common.longitudinal_personality_profiles import PERSONALITY_PROFILES_PARAM, strict_profile_document
@pytest.mark.parametrize('raw', [{}, '{}', b'{}'])
def test_registry_empty_object_get_and_enable(monkeypatch, raw):
client, params = _client(monkeypatch, {PERSONALITY_PROFILES_PARAM: raw, 'CustomPersonalities':False})
before = dict(params.values)
response = client.get('/api/personality_profiles')
assert response.status_code == 200
assert not response.get_json()['configured']
assert params.values == before
response = client.put('/api/params', json={'key':'CustomPersonalities','value':True})
assert response.status_code == 200
saved = strict_profile_document(params.values[PERSONALITY_PROFILES_PARAM])
assert saved is not None and saved['enabled']
assert all(profile == {
'acceleration': {'preset':'standard','curve':[]},
'braking': {'preset':'standard','curve':[]},
'following': {'preset':'medium','curve':[]},
} for profile in saved['profiles'].values())
@pytest.mark.parametrize('raw', [{}, '{}', b'{}'])
@pytest.mark.parametrize('enabled', [False, True])
def test_first_run_slot_master_restore_recognises_registry_sentinel(monkeypatch, tmp_path, raw, enabled):
client, params = _slot_client(monkeypatch, tmp_path, {'CustomPersonalities':enabled}, {
PERSONALITY_PROFILES_PARAM:raw, 'CustomPersonalities':False,
})
assert client.post('/api/toggles/profiles/a/load').status_code == 200
assert params.values['CustomPersonalities'] is enabled
if enabled:
saved = strict_profile_document(params.values[PERSONALITY_PROFILES_PARAM])
assert saved is not None and saved['enabled']
else:
assert params.values[PERSONALITY_PROFILES_PARAM] == raw
@pytest.mark.parametrize('raw', ['null', '[]', '', '{broken', {'schemaVersion':99}, {'unexpected':1}])
def test_nonempty_or_nonobject_malformed_document_stays_blocked(monkeypatch, raw):
client, params = _client(monkeypatch, {PERSONALITY_PROFILES_PARAM:raw, 'CustomPersonalities':False})
before = dict(params.values)
assert client.get('/api/personality_profiles').status_code == 409
assert client.put('/api/params', json={'key':'CustomPersonalities','value':True}).status_code == 409
assert params.values == before
@@ -100,9 +100,7 @@ def test_ui_restores_hierarchical_sub_toggle_rendering():
assert "hasChildParams" in params
assert "SettingTree" in settings
assert '<SettingTree :params="ordinaryParams(activeSection)"' in settings
assert '<LongitudinalMode v-if="modeSection(activeSection)"' in settings
assert 's.params.filter(p => !this.isModeParam(p))' in settings
assert '<SettingTree :params="activeSection.params"' in settings
# SettingTree recursively reveals children; subpanels are collapsed by default
# (classic Galaxy behavior) and expand only when the user taps Manage/Close.
+41 -495
View File
@@ -1,7 +1,6 @@
#!/usr/bin/env python3
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timedelta, timezone
from functools import wraps
import importlib
import math
@@ -77,7 +76,6 @@ from openpilot.starpilot.common.model_lab import (
from openpilot.starpilot.assets.theme_manager import HOLIDAY_THEME_PATH, THEME_COMPONENT_PARAMS
from openpilot.starpilot.common import param_profiles
from openpilot.starpilot.common.accel_profile import (
A_CRUISE_MAX_BP_CUSTOM,
CUSTOM_ACCEL_PROFILE_BREAKPOINT_PARAM_KEYS,
CUSTOM_ACCEL_PROFILE_BREAKPOINTS_INITIALIZED_KEY,
CUSTOM_ACCEL_PROFILE_CURVE_PARAM_KEYS,
@@ -87,15 +85,10 @@ from openpilot.starpilot.common.accel_profile import (
CUSTOM_ACCEL_PROFILE_PARAM_KEYS,
CUSTOM_ACCEL_PROFILE_POINT_COUNT_KEY,
CUSTOM_ACCEL_PROFILE_POINT_VALUE_PARAM_KEYS,
CUSTOM_ACCEL_PROFILE_VALUE_MAX,
CUSTOM_ACCEL_PROFILE_VALUE_MIN,
build_custom_accel_profile_defaults,
custom_accel_profile_is_initialized,
get_accel_profile_curve_values,
get_custom_accel_profile_curve_defaults,
interpolate_accel_profile,
normalize_acceleration_profile,
normalize_deceleration_profile,
parse_custom_accel_profile_curve,
)
from openpilot.starpilot.common.maps_catalog import (
@@ -113,10 +106,6 @@ from openpilot.starpilot.common.maps_download_progress import (
selection_key,
)
from openpilot.starpilot.common.experimental_state import sync_persist_chill_state, sync_persist_experimental_state
from openpilot.starpilot.system.the_galaxy.longitudinal_mode import (
MODE_KEYS as LONGITUDINAL_MODE_KEYS, ModeError, WRITE_LOCK as LONGITUDINAL_MODE_LOCK,
set_mode as set_longitudinal_mode, snapshot as longitudinal_mode_snapshot,
)
from openpilot.starpilot.common.favorite_slots import (
FAVORITE_SLOTS_PARAM,
SETTINGS_CATALOG_PATH,
@@ -129,33 +118,6 @@ from openpilot.starpilot.common.favorite_slots import (
trigger_favorite_action,
)
from openpilot.starpilot.common.lateral_delay import full_lateral_delay
from openpilot.starpilot.common.longitudinal_personality_profiles import (
ACCELERATION_PRESETS,
ACCELERATION_SPEEDS_MPH,
BRAKING_PRESETS,
BRAKING_SPEEDS_MPH,
CURVE_BOUNDS,
FOLLOWING_PRESETS,
FOLLOWING_SPEEDS_MPH,
PERSONALITY_PROFILES_PARAM,
PERSONALITY_ADVANCED_PARAM_KEYS,
PERSONALITY_FOLLOW_PARAM_KEYS,
PERSONALITY_PARKED_PARAM_KEYS,
PERSONALITY_PROFILE_ENABLE_PARAM_KEYS,
PROFILE_SCHEMA_VERSION,
default_personality_profiles,
is_unconfigured_profile_document,
initial_custom_curve,
is_truck_fingerprint,
migrate_profile_document,
personality_reference_curves,
profile_document,
strict_profile_document,
synchronise_profile_document_enabled,
update_personality_profile,
validate_personality_advanced_value,
validate_personality_follow_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, BUTTON_FUNCTIONS, ERROR_LOGS_PATH, EXCLUDED_KEYS, LEGACY_STARPILOT_PARAM_RENAMES, MAPS_PATH, MODELS_PATH, RESOURCES_REPO, SCREEN_RECORDINGS_PATH, STOCK_THEME_PATH, THEME_SAVE_PATH, TOGGLE_BACKUPS,\
default_ev_tuning_enabled, migrate_cancel_button_controls, update_starpilot_toggles
@@ -3500,9 +3462,6 @@ def _safe_params_get_bool(key, default=False):
except Exception:
return bool(default)
def _personality_settings_write_locked():
return _safe_params_get_bool("IsOnroad", default=True) or not _safe_params_get_bool("IsOffroad", default=False)
def _normalize_vasm_config(data):
if not isinstance(data, dict):
raise ValueError("Configuration must be a JSON object.")
@@ -3627,117 +3586,6 @@ def _has_runtime_default_value(key, raw_value):
except Exception:
return True
_PERSONALITY_PROFILES_WRITE_LOCK = threading.Lock()
def _serialize_personality_profile_writes(view):
@wraps(view)
def wrapped(*args, **kwargs):
if request.method not in ("PUT", "POST"):
return view(*args, **kwargs)
with _PERSONALITY_PROFILES_WRITE_LOCK:
return view(*args, **kwargs)
return wrapped
def _get_detected_ev_tuning():
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 default_ev_tuning_enabled(cp)
except Exception:
return False
def _get_detected_truck_tuning():
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 is_truck_fingerprint(cp.carFingerprint)
except Exception:
return False
def _get_effective_legacy_custom_accel_curve(ev_tuning: bool, truck_tuning: bool) -> list[float]:
target_axis = np.array(ACCELERATION_SPEEDS_MPH, dtype=float) * 0.44704
def sample(values, breakpoints):
return [round(interpolate_accel_profile(float(speed), values, breakpoints), 4) for speed in target_axis]
preset_curve = get_accel_profile_curve_values(
normalize_acceleration_profile(_safe_params_get_live_raw("AccelerationProfile")),
ev_tuning,
truck_tuning,
)
if not _safe_params_get_bool("CustomAccelProfile"):
return sample(preset_curve, A_CRUISE_MAX_BP_CUSTOM)
raw_legacy = {key: _safe_params_get_live_raw(key) for key in CUSTOM_ACCEL_PROFILE_PARAM_KEYS}
if custom_accel_profile_is_initialized(_safe_params_get_live_raw(CUSTOM_ACCEL_PROFILE_INITIALIZED_KEY), raw_legacy):
try:
legacy_values = [float(raw_legacy[key]) for key in CUSTOM_ACCEL_PROFILE_PARAM_KEYS]
if all(math.isfinite(value) and CUSTOM_ACCEL_PROFILE_VALUE_MIN <= value <= CUSTOM_ACCEL_PROFILE_VALUE_MAX for value in legacy_values):
preset_curve = legacy_values
except (TypeError, ValueError):
pass
if _get_custom_accel_profile_breakpoints_initialized():
try:
breakpoints, values = parse_custom_accel_profile_curve(
_safe_params_get_live_raw(CUSTOM_ACCEL_PROFILE_POINT_COUNT_KEY),
[_safe_params_get_live_raw(key) for key in CUSTOM_ACCEL_PROFILE_BREAKPOINT_PARAM_KEYS],
[_safe_params_get_live_raw(key) for key in CUSTOM_ACCEL_PROFILE_POINT_VALUE_PARAM_KEYS],
)
return sample(values, breakpoints)
except (TypeError, ValueError):
pass
return sample(preset_curve, A_CRUISE_MAX_BP_CUSTOM)
def _get_effective_legacy_following_curve(profile_id: str) -> list[float]:
builtin_follow = {
"aggressive": 1.25,
"standard": 1.45,
"relaxed": 1.75,
}
if profile_id in builtin_follow and not _safe_params_get_bool("CustomPersonalities"):
return [builtin_follow[profile_id]] * len(FOLLOWING_SPEEDS_MPH)
defaults = {
"TrafficFollow": 0.75,
"AggressiveFollow": 1.25,
"AggressiveFollowHigh": 1.0,
"StandardFollow": 1.45,
"StandardFollowHigh": 1.2,
"RelaxedFollow": 1.6,
"RelaxedFollowHigh": 1.4,
}
def follow_value(key: str) -> float:
try:
parsed = float(_safe_params_get_live_raw(key, defaults[key]))
except (TypeError, ValueError):
parsed = defaults[key]
if not math.isfinite(parsed):
parsed = defaults[key]
return float(np.clip(parsed, *CURVE_BOUNDS["following"]))
if profile_id == "traffic":
breakpoints = (0.0, 25.0 / CV.MPH_TO_MS)
values = (follow_value("TrafficFollow"), follow_value("RelaxedFollow"))
elif profile_id in ("aggressive", "standard", "relaxed"):
prefix = profile_id.capitalize()
breakpoints = (45.0, 70.0)
values = (follow_value(f"{prefix}Follow"), follow_value(f"{prefix}FollowHigh"))
else:
raise ValueError(f"Unknown personality: {profile_id}")
return [round(float(point), 4) for point in np.interp(FOLLOWING_SPEEDS_MPH, breakpoints, values)]
def _get_runtime_default_param_overrides():
overrides = {}
static_defaults = _get_static_default_param_values()
@@ -4299,23 +4147,6 @@ def _get_vehicle_parked():
except Exception:
return False
def _get_longitudinal_mode_capable():
# Do not authorize from a default or a stale toggle snapshot. Pending disable
# also blocks selection until the driving stack has regenerated CarParams.
if _safe_params_get_bool("DisableOpenpilotLongitudinal", default=True):
return False
cp_bytes = _safe_params_get_live_raw("CarParamsPersistent")
if not cp_bytes:
return False
try:
with car.CarParams.from_bytes(cp_bytes) as cp:
if cp.alphaLongitudinalAvailable and not _safe_params_get_bool("AlphaLongitudinalEnabled", default=False):
return False
return bool(cp.openpilotLongitudinalControl)
except Exception:
return False
def _get_alpha_longitudinal_available():
cp_bytes = _safe_params_get_live_raw("CarParamsPersistent")
if not cp_bytes:
@@ -4532,10 +4363,7 @@ def _reset_troubleshoot_section(section_id):
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",
}
personality_writes_locked = _personality_settings_write_locked()
blocked_onroad_keys = {"Model", "AlwaysOnLateral", "ForceTorqueController", "NNFF", "NNFFLite"}
updated_keys = []
skipped_keys = []
@@ -4549,9 +4377,8 @@ def _reset_troubleshoot_section(section_id):
skipped_keys.append({"key": key, "reason": "not editable"})
continue
if ((is_onroad and key in blocked_onroad_keys) or
(personality_writes_locked and key in PERSONALITY_PARKED_PARAM_KEYS)):
skipped_keys.append({"key": key, "reason": "blocked until required off-road state is confirmed"})
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:
@@ -5843,143 +5670,6 @@ def setup(app):
return jsonify({"error": "Favorite action failed."}), 400
return jsonify({"message": "Favorite action sent."}), 200
@app.route("/api/personality_profiles/migrate", methods=["POST"])
@_serialize_personality_profile_writes
def migrate_personality_profiles():
if _personality_settings_write_locked():
return jsonify({"error": "Longitudinal personality profiles can only be migrated while off-road."}), 403
raw_profiles = _safe_params_get_live_raw(PERSONALITY_PROFILES_PARAM)
if raw_profiles is None:
return jsonify({"error": "No stored longitudinal personality profiles require migration."}), 404
if strict_profile_document(raw_profiles) is not None:
return jsonify({"message": "Longitudinal personality profiles are already current.", "migration_required": False}), 200
migrated_document = migrate_profile_document(raw_profiles)
if migrated_document is None or strict_profile_document(migrated_document) is None:
return jsonify({"error": "Stored longitudinal personality profiles are malformed and were not overwritten."}), 409
params.put(PERSONALITY_PROFILES_PARAM, migrated_document)
installed_document = strict_profile_document(_safe_params_get_live_raw(PERSONALITY_PROFILES_PARAM))
if installed_document != migrated_document:
return jsonify({"error": "Migrated longitudinal personality profiles did not verify after installation."}), 500
update_starpilot_toggles()
return jsonify({
"message": "Longitudinal personality profiles migrated successfully.",
"migration_required": False,
"schema_version": PROFILE_SCHEMA_VERSION,
}), 200
@app.route("/api/personality_profiles", methods=["GET", "PUT"])
@_serialize_personality_profile_writes
def personality_profiles():
ev_tuning = _get_detected_ev_tuning()
truck_tuning = (_get_detected_truck_tuning() or params.get_bool("TruckTuning")) and not ev_tuning
raw_profiles = _safe_params_get_live_raw(PERSONALITY_PROFILES_PARAM)
current_document = strict_profile_document(raw_profiles)
stored_document = migrate_profile_document(raw_profiles)
configured = stored_document is not None
migration_required = configured and current_document is None
enabled = params.get_bool("CustomPersonalities")
if not is_unconfigured_profile_document(raw_profiles) and stored_document is None:
return jsonify({"error": "Stored longitudinal personality profiles are malformed and were not overwritten."}), 409
profiles = stored_document["profiles"] if configured else default_personality_profiles(ev_tuning, truck_tuning)
if request.method == "PUT":
if _personality_settings_write_locked():
return jsonify({"error": "Longitudinal personality profiles can only be changed while off-road."}), 403
if current_document is None and stored_document is not None:
return jsonify({"error": "Stored longitudinal personality profiles require a verified migration before editing."}), 409
data = request.get_json(silent=True)
required_fields = {"profile", "category", "preset", "curve"}
if not isinstance(data, dict) or set(data) not in (required_fields, required_fields | {"expected"}):
return jsonify({"error": "Expected profile, category, preset, curve, and optional expected category."}), 400
try:
current_config = profiles[data["profile"]][data["category"]]
if "expected" in data and (data["expected"] != current_config or any(
isinstance(value, bool) for key in ("curve", "legacyCurve") for value in data["expected"].get(key, [])
)):
return jsonify({"error": "Saved profile changed. Reload and review it before editing again."}), 409
curve = data["curve"]
if data["preset"] == "custom" and current_config.get("preset") != "custom":
if curve != []:
update_personality_profile(
profiles, data["profile"], data["category"], "custom", curve, ev_tuning, truck_tuning
)
legacy_curve = None
if current_config.get("preset") == "dom_default":
if data["category"] == "acceleration":
legacy_curve = _get_effective_legacy_custom_accel_curve(ev_tuning, truck_tuning)
elif data["category"] == "braking":
legacy_curve = {
0: [1.0] * len(BRAKING_SPEEDS_MPH),
1: [0.5] * len(BRAKING_SPEEDS_MPH),
2: [2.0] * len(BRAKING_SPEEDS_MPH),
}[normalize_deceleration_profile(_safe_params_get_live_raw("DecelerationProfile"))]
else:
legacy_curve = _get_effective_legacy_following_curve(data["profile"])
curve = initial_custom_curve(
data["category"], current_config, ev_tuning, truck_tuning, legacy_curve=legacy_curve
)
elif data["preset"] != "custom":
curve = []
profiles = update_personality_profile(
profiles,
data["profile"],
data["category"],
data["preset"],
curve,
ev_tuning,
truck_tuning,
)
except (KeyError, TypeError, ValueError) as error:
return jsonify({"error": str(error)}), 400
params.put(PERSONALITY_PROFILES_PARAM, profile_document(profiles, enabled=enabled))
configured = True
migration_required = False
update_starpilot_toggles()
return jsonify({
"bounds": {key: list(value) for key, value in CURVE_BOUNDS.items()},
"configured": configured,
"default_profiles": default_personality_profiles(ev_tuning, truck_tuning),
"enabled": enabled,
"migration_required": migration_required,
"options": {
"acceleration": list(ACCELERATION_PRESETS),
"braking": list(BRAKING_PRESETS),
"following": list(FOLLOWING_PRESETS),
},
"profiles": profiles,
"reference_curves": personality_reference_curves(ev_tuning, truck_tuning),
"schema_version": PROFILE_SCHEMA_VERSION,
"speed_breakpoints_mph": {
"acceleration": list(ACCELERATION_SPEEDS_MPH),
"braking": list(BRAKING_SPEEDS_MPH),
"following": list(FOLLOWING_SPEEDS_MPH),
},
}), 200
@app.route("/api/longitudinal_mode", methods=["GET", "PUT"])
def longitudinal_mode():
with LONGITUDINAL_MODE_LOCK:
try:
if request.method == "GET":
return jsonify(longitudinal_mode_snapshot(params, _get_longitudinal_mode_capable())), 200
data = request.get_json(silent=True)
if not isinstance(data, dict):
return jsonify({"error": "Expected a JSON object."}), 400
try:
result = set_longitudinal_mode(params, data.get("mode"), data.get("expected"), _get_longitudinal_mode_capable, data.get("acknowledged") is True)
finally:
update_starpilot_toggles()
return jsonify(result), 200
except ModeError as error:
return jsonify({"error": str(error)}), error.status
except Exception:
return jsonify({"error": "Longitudinal mode state is unavailable. Refresh before retrying."}), 503
@app.route("/api/params", methods=["GET", "PUT"])
def get_param():
if request.method == "PUT":
@@ -5988,33 +5678,6 @@ def setup(app):
return jsonify({"error": "Missing 'key' or 'value' in request body."}), 400
key = str(data["key"]).strip()
if key.lower() == PERSONALITY_PROFILES_PARAM.lower():
return jsonify({"error": "Longitudinal personality profiles must be changed with the Driving Personalities editor."}), 403
if key in PERSONALITY_PARKED_PARAM_KEYS and _personality_settings_write_locked():
return jsonify({"error": "Driving personality settings can only be changed while parked."}), 403
if key in PERSONALITY_PROFILE_ENABLE_PARAM_KEYS and type(data["value"]) is not bool:
return jsonify({"error": f"{key} must be a JSON boolean."}), 400
if key in LONGITUDINAL_MODE_KEYS:
if type(data["value"]) is not bool:
return jsonify({"error": "Mode settings require a JSON boolean."}), 400
with LONGITUDINAL_MODE_LOCK:
try:
before = longitudinal_mode_snapshot(params, _get_longitudinal_mode_capable())
candidate = {**before["values"], key: data["value"]}
if data["value"] and key in {"ConditionalExperimental", "ConditionalChill"}:
candidate["ConditionalChill" if key == "ConditionalExperimental" else "ConditionalExperimental"] = False
target = ("conditional_experimental" if candidate["ConditionalExperimental"] else
"conditional_chill" if candidate["ConditionalChill"] else
"experimental" if candidate["ExperimentalMode"] else "chill")
try:
result = set_longitudinal_mode(params, target, before["values"], _get_longitudinal_mode_capable, data.get("acknowledged") is True)
finally:
update_starpilot_toggles()
return jsonify({"updated": result["values"], "message": "Longitudinal control mode updated."}), 200
except ModeError as error:
return jsonify({"error": str(error)}), error.status
except Exception:
return jsonify({"error": "Longitudinal mode state is unavailable."}), 503
if key.lower() == FAVORITE_SLOTS_PARAM.lower():
key = FAVORITE_SLOTS_PARAM
raw_slots = data["value"]
@@ -6055,16 +5718,6 @@ def setup(app):
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
if key in PERSONALITY_ADVANCED_PARAM_KEYS:
try:
data["value"] = validate_personality_advanced_value(data["value"])
except ValueError as error:
return jsonify({"error": str(error)}), 400
elif key in PERSONALITY_FOLLOW_PARAM_KEYS:
try:
data["value"] = validate_personality_follow_value(data["value"])
except ValueError as error:
return jsonify({"error": str(error)}), 400
val = data["value"]
selected_label_input = str(data.get("label") or "").strip()
@@ -6078,41 +5731,6 @@ def setup(app):
if key not in allowed_keys:
return jsonify({"error": f"Parameter '{key}' is not editable."}), 403
if key == "CustomPersonalities":
if type(data["value"]) is not bool:
return jsonify({"error": "CustomPersonalities must be a JSON boolean."}), 400
enabled = data["value"]
with _PERSONALITY_PROFILES_WRITE_LOCK:
if _personality_settings_write_locked():
return jsonify({"error": "Driving personality settings can only be changed while parked."}), 403
ev_tuning = _get_detected_ev_tuning()
truck_tuning = (_get_detected_truck_tuning() or params.get_bool("TruckTuning")) and not ev_tuning
raw_document = _safe_params_get_live_raw(PERSONALITY_PROFILES_PARAM)
if not is_unconfigured_profile_document(raw_document) and strict_profile_document(raw_document) is None:
return jsonify({"error": "Stored longitudinal personality profiles require a verified migration before changing the master control."}), 409
document = synchronise_profile_document_enabled(
raw_document, enabled, ev_tuning, truck_tuning,
)
updated = {"CustomPersonalities": enabled}
if enabled:
if document is None:
return jsonify({"error": "Longitudinal personality profiles could not be prepared for enabling."}), 500
params.put(PERSONALITY_PROFILES_PARAM, document)
if strict_profile_document(_safe_params_get_live_raw(PERSONALITY_PROFILES_PARAM)) != document:
return jsonify({"error": "Longitudinal personality profiles could not be verified after writing."}), 500
updated[PERSONALITY_PROFILES_PARAM] = document
params.put_bool("CustomPersonalities", True)
else:
params.put_bool("CustomPersonalities", False)
if document is not None:
params.put(PERSONALITY_PROFILES_PARAM, document)
updated[PERSONALITY_PROFILES_PARAM] = document
update_starpilot_toggles()
return jsonify({
"message": "Driving personalities updated.",
"updated": updated,
}), 200
if key == "PulseGlideSpeedDelta" or (key in PULSE_GLIDE_BUTTON_KEYS and str_val.strip() == str(BUTTON_FUNCTIONS["PULSE_AND_GLIDE"])):
if not params.get_bool("GalaxyDeveloperMode"):
return jsonify({"error": "Pulse and Glide is available only with Galaxy Developer Mode enabled."}), 403
@@ -6287,6 +5905,22 @@ def setup(app):
"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)
@@ -10041,119 +9675,29 @@ def setup(app):
if not isinstance(toggle_values, dict):
return jsonify({"success": False, "message": "Toggle backup does not contain settings."}), 400
return _restore_toggle_values(toggle_values)
def _restore_toggle_values(toggle_values, *, profile=None):
parked_personality_keys = {
LEGACY_STARPILOT_PARAM_RENAMES.get(key, key)
for key in toggle_values
if isinstance(key, str)
} & PERSONALITY_PARKED_PARAM_KEYS
if parked_personality_keys and _personality_settings_write_locked():
return jsonify({
"success": False,
"message": "Driving personality settings can only be restored while parked with off-road state confirmed.",
}), 403
allowed_keys = _get_toggle_backup_keys()
validated_personality_values = {}
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 or mapped_key not in PERSONALITY_PARKED_PARAM_KEYS:
if mapped_key not in allowed_keys:
skipped_count += 1
continue
try:
if mapped_key in PERSONALITY_PROFILE_ENABLE_PARAM_KEYS or mapped_key == "CustomPersonalities":
if type(value) is not bool:
raise ValueError(f"{mapped_key} must be a JSON boolean")
coerced_value = _coerce_toggle_restore_value(mapped_key, value)
if mapped_key in PERSONALITY_ADVANCED_PARAM_KEYS:
coerced_value = validate_personality_advanced_value(coerced_value)
elif mapped_key in PERSONALITY_FOLLOW_PARAM_KEYS:
coerced_value = validate_personality_follow_value(coerced_value)
validated_personality_values[key] = coerced_value
except (TypeError, ValueError, json.JSONDecodeError):
return jsonify({
"success": False,
"message": f"Invalid driving personality setting in backup: {mapped_key}.",
}), 400
restored_count = 0
skipped_count = profile["skippedCount"] if profile is not None else 0
master_restore_requested = any(
LEGACY_STARPILOT_PARAM_RENAMES.get(key, key) == "CustomPersonalities"
for key in validated_personality_values
)
master_restore_enabled = next((
value
for key, value in validated_personality_values.items()
if LEGACY_STARPILOT_PARAM_RENAMES.get(key, key) == "CustomPersonalities"
), None)
with _PERSONALITY_PROFILES_WRITE_LOCK:
if (profile is not None or parked_personality_keys) and _personality_settings_write_locked():
return jsonify({"success": False, "message": "Settings can only be restored while parked with off-road state confirmed."}), 403
if master_restore_requested:
ev_tuning = _get_detected_ev_tuning()
truck_tuning = (_get_detected_truck_tuning() or params.get_bool("TruckTuning")) and not ev_tuning
raw_document = _params_raw.get(PERSONALITY_PROFILES_PARAM)
if not is_unconfigured_profile_document(raw_document) and strict_profile_document(raw_document) is None:
return jsonify({
"success": False,
"message": "Stored longitudinal personality profiles require a verified migration before restoring the master control.",
}), 409
document = synchronise_profile_document_enabled(
raw_document, master_restore_enabled, ev_tuning, truck_tuning,
)
if master_restore_enabled:
if document is None:
return jsonify({"success": False, "message": "Driving personality profiles could not be prepared for enabling."}), 500
params.put(PERSONALITY_PROFILES_PARAM, document)
if strict_profile_document(_params_raw.get(PERSONALITY_PROFILES_PARAM)) != document:
return jsonify({"success": False, "message": "Driving personality profiles could not be verified after writing."}), 500
params.put_bool("CustomPersonalities", True)
else:
params.put_bool("CustomPersonalities", False)
if document is not None:
params.put(PERSONALITY_PROFILES_PARAM, document)
_params_raw.put(mapped_key, _coerce_toggle_restore_value(mapped_key, value))
restored_count += 1
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
if mapped_key == "CustomPersonalities":
continue
try:
restore_value = validated_personality_values.get(key, value)
# Slot values already use the native Params type (including BYTES and TIME).
if profile is None or mapped_key in PERSONALITY_PARKED_PARAM_KEYS:
restore_value = _coerce_toggle_restore_value(mapped_key, restore_value)
_params_raw.put(mapped_key, restore_value)
restored_count += 1
except (KeyError, TypeError, ValueError, OverflowError):
skipped_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()
if profile is not None:
message = f"Loaded {profile['label']} ({restored_count} settings)."
if skipped_count:
message += f" Skipped {skipped_count} incompatible settings."
return jsonify({
"success": True, "message": message,
"slot": profile["slot"], "label": profile["label"],
"restoredCount": restored_count, "skippedCount": skipped_count,
})
message = f"Restored {restored_count} toggle settings."
if skipped_count:
message += f" Skipped {skipped_count} incompatible or unavailable settings."
@@ -10192,10 +9736,10 @@ def setup(app):
@app.route("/api/toggles/profiles/<slot>/load", methods=["POST"])
def load_toggle_profile(slot):
if _personality_settings_write_locked():
return jsonify({"success": False, "message": "Settings profiles can only be loaded while parked with off-road state confirmed."}), 403
if _safe_params_get_bool("IsOnroad"):
return jsonify({"success": False, "message": "Settings profiles can only be loaded while parked."}), 403
try:
profile = param_profiles.prepare_profile(
result = param_profiles.load_profile(
_params_raw,
slot,
allowed_keys=_get_toggle_backup_keys(),
@@ -10205,16 +9749,18 @@ def setup(app):
except param_profiles.ParamProfileError as error:
return jsonify({"success": False, "message": str(error)}), 400
return _restore_toggle_values(profile["settings"], profile=profile)
update_starpilot_toggles()
message = f"Loaded {result['label']} ({result['restoredCount']} settings)."
if result["skippedCount"]:
message += f" Skipped {result['skippedCount']} incompatible settings."
return jsonify({
"success": True,
"message": message,
**result,
})
@app.route("/api/toggles/reset_default", methods=["POST"])
def reset_toggle_values():
if _personality_settings_write_locked():
return jsonify({
"success": False,
"message": "Toggles can only be reset while parked.",
}), 403
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:
+8 -5
View File
@@ -7,9 +7,12 @@ from openpilot.common.swaglog import cloudlog
from openpilot.common.pid import PIDController
from openpilot.system.hardware import HARDWARE
# raise fan setpoint on tici/tizi to reduce noise
# after raising LMH threshold in AGNOS 18.1 to prevent CPU throttling
OFFSET = 0 if HARDWARE.get_device_type() == "mici" else 5
# comma 3/3X (tici/tizi) run a more aggressive, cooler-targeting curve than comma 4 (mici)
IS_MICI = HARDWARE.get_device_type() == "mici"
OFFSET = 0 if IS_MICI else -5
K_P = 0 if IS_MICI else 1.0
FF_LOW = 60.0 if IS_MICI else 55.0
FF_HIGH = 100.0 if IS_MICI else 80.0
class BaseFanController(ABC):
@abstractmethod
@@ -23,7 +26,7 @@ class TiciFanController(BaseFanController):
cloudlog.info("Setting up TICI fan handler")
self.last_ignition = False
self.controller = PIDController(k_p=0, k_i=4e-3, rate=(1 / DT_HW))
self.controller = PIDController(k_p=K_P, k_i=4e-3, rate=(1 / DT_HW))
def update(self, cur_temp: float, ignition: bool) -> int:
self.controller.pos_limit = 100 if ignition else 30
@@ -35,7 +38,7 @@ class TiciFanController(BaseFanController):
error = cur_temp - (75 + OFFSET)
fan_pwr_out = int(self.controller.update(
error=error,
feedforward=np.interp(cur_temp, [60.0 + OFFSET, 100.0 + OFFSET], [0, 100])
feedforward=np.interp(cur_temp, [FF_LOW, FF_HIGH], [0, 100])
))
self.last_ignition = ignition
+2 -4
View File
@@ -1,9 +1,9 @@
Dynamically Derived Feasible Param Candidates (The Golden List)
===============================================================
Total globally registered C++ keys: 546
Total globally registered C++ keys: 545
Total explicit UI string references: 426
Total Editable/Toggleable targets: 391
Total Editable/Toggleable targets: 390
AccelerationPath
AccelerationProfile
@@ -166,7 +166,6 @@ LateralTune
LeadDepartingAlert
LeadDetectionThreshold
LeadInfo
LeadInfoMode
LiveDelay
LiveParameters
LiveParametersV2
@@ -181,7 +180,6 @@ LongStarButtonControl
LongitudinalActuatorDelay
LongitudinalActuatorDelayStock
LongitudinalPersonality
LongitudinalPersonalityProfiles
LongitudinalTune
LoudBlindspotAlert
LoudBlindspotAlertWhenDisengaged