mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-09-10 10:13:45 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cbfdb65719 |
Binary file not shown.
@@ -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.
@@ -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
|
||||
|
||||
@@ -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']}")
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
|
||||
@@ -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,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"
|
||||
},
|
||||
|
||||
@@ -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))
|
||||
):
|
||||
|
||||
@@ -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)
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
-378
@@ -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]
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user