Galaxy: add driving personality profiles

Add configurable acceleration, braking, following-distance, and advanced smoothness profiles to Galaxy with off-road writes and readback validation.

Co-authored-by: AngusBell97 <124716116+AngusBell97@users.noreply.github.com>
This commit is contained in:
AngusBell97
2026-09-09 16:07:41 -05:00
committed by firestar5683
parent b7775991bf
commit a19beda327
52 changed files with 8123 additions and 250 deletions
Binary file not shown.
+1
View File
@@ -110,6 +110,7 @@ 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}},
Binary file not shown.
+26 -1
View File
@@ -5,7 +5,7 @@ import threading
import time
import uuid
from openpilot.common.params import Params, ParamKeyFlag, UnknownKeyName
from openpilot.common.params import Params, ParamKeyFlag, ParamKeyType, UnknownKeyName
class TestParams:
def setup_method(self):
@@ -128,6 +128,31 @@ 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})
+4 -1
View File
@@ -39,6 +39,7 @@ 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
@@ -156,12 +157,14 @@ class TogglesLayout(Widget):
def _update_state(self):
if ui_state.sm.updated["selfdriveState"]:
personality = PERSONALITY_TO_INT[ui_state.sm["selfdriveState"].personality]
if personality != ui_state.personality and ui_state.started:
if ui_state.started and personality != self._personality_seen:
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):
@@ -12,6 +12,7 @@ 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):
@@ -70,12 +71,14 @@ class TogglesLayoutMici(NavScroller):
if ui_state.sm.updated["selfdriveState"]:
personality = PERSONALITY_TO_INT[ui_state.sm["selfdriveState"].personality]
if personality != ui_state.personality and ui_state.started:
if ui_state.started and personality != self._personality_seen:
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):
@@ -652,6 +652,7 @@ 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")
)
@@ -0,0 +1,212 @@
"""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,8 +1203,9 @@
},
{
"key": "CustomPersonalities",
"requires_offroad": true,
"label": "Driving Personalities",
"description": "Customize the \"Driving Personalities\" to better match your driving style.",
"description": "Customize braking, acceleration, and following distance for each profile.",
"picker_description": "Customizes driving personalities to match your style.",
"data_type": "bool",
"ui_type": "toggle",
@@ -1213,6 +1214,7 @@
},
{
"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.",
@@ -1224,6 +1226,7 @@
},
{
"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.",
@@ -1235,6 +1238,7 @@
},
{
"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.",
@@ -1246,6 +1250,7 @@
},
{
"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.",
@@ -1257,8 +1262,9 @@
},
{
"key": "TrafficFollow",
"requires_offroad": true,
"label": "Following Distance",
"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.",
"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.",
"data_type": "float",
"ui_type": "numeric",
"min": 0.5,
@@ -1269,66 +1275,72 @@
},
{
"key": "TrafficJerkAcceleration",
"requires_offroad": true,
"label": "Acceleration Smoothness",
"description": "How smoothly openpilot accelerates in \"Traffic Mode\". Increase for gentler starts; decrease for faster but more abrupt takeoffs.",
"description": "How smoothly openpilot accelerates. Increase for gentler starts; decrease for faster but more abrupt takeoffs.",
"data_type": "float",
"ui_type": "numeric",
"min": 0.5,
"max": 3.0,
"step": 0.01,
"min": 25,
"max": 200,
"step": 1,
"parent_key": "TrafficPersonalityProfile",
"settings_tier": "advanced"
},
{
"key": "TrafficJerkDeceleration",
"requires_offroad": true,
"label": "Braking Smoothness",
"description": "How smoothly openpilot brakes in \"Traffic Mode\". Increase for gentler stops; decrease for quicker but sharper braking.",
"description": "How smoothly openpilot brakes. Increase for gentler stops; decrease for quicker but sharper braking.",
"data_type": "float",
"ui_type": "numeric",
"min": 0.5,
"max": 3.0,
"step": 0.01,
"min": 25,
"max": 200,
"step": 1,
"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 in \"Traffic Mode\". 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. Increase for larger gaps and more cautious following; decrease for tighter gaps and closer following.",
"data_type": "float",
"ui_type": "numeric",
"min": 0.5,
"max": 3.0,
"step": 0.01,
"min": 25,
"max": 200,
"step": 1,
"parent_key": "TrafficPersonalityProfile",
"settings_tier": "advanced"
},
{
"key": "TrafficJerkSpeedDecrease",
"requires_offroad": true,
"label": "Slowdown Response",
"description": "How smoothly openpilot slows down in \"Traffic Mode\". Increase for more gradual deceleration; decrease for faster but sharper slowdowns.",
"description": "How smoothly openpilot slows down. Increase for more gradual deceleration; decrease for faster but sharper slowdowns.",
"data_type": "float",
"ui_type": "numeric",
"min": 0.5,
"max": 3.0,
"step": 0.01,
"min": 25,
"max": 200,
"step": 1,
"parent_key": "TrafficPersonalityProfile",
"settings_tier": "advanced"
},
{
"key": "TrafficJerkSpeed",
"requires_offroad": true,
"label": "Speed-Up Response",
"description": "How smoothly openpilot speeds up in \"Traffic Mode\". Increase for more gradual acceleration; decrease for quicker but more jolting acceleration.",
"description": "How smoothly openpilot speeds up. Increase for more gradual acceleration; decrease for quicker but more jolting acceleration.",
"data_type": "float",
"ui_type": "numeric",
"min": 0.5,
"max": 3.0,
"step": 0.01,
"min": 25,
"max": 200,
"step": 1,
"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",
@@ -1341,6 +1353,7 @@
},
{
"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",
@@ -1353,66 +1366,72 @@
},
{
"key": "AggressiveJerkAcceleration",
"requires_offroad": true,
"label": "Acceleration Smoothness",
"description": "How smoothly openpilot accelerates with the \"Aggressive\" profile. Increase for gentler starts; decrease for faster but more abrupt takeoffs.",
"description": "How smoothly openpilot accelerates. Increase for gentler starts; decrease for faster but more abrupt takeoffs.",
"data_type": "float",
"ui_type": "numeric",
"min": 0.5,
"max": 3.0,
"step": 0.01,
"min": 25,
"max": 200,
"step": 1,
"parent_key": "AggressivePersonalityProfile",
"settings_tier": "advanced"
},
{
"key": "AggressiveJerkDeceleration",
"requires_offroad": true,
"label": "Braking Smoothness",
"description": "How smoothly openpilot brakes with the \"Aggressive\" profile. Increase for gentler stops; decrease for quicker but sharper braking.",
"description": "How smoothly openpilot brakes. Increase for gentler stops; decrease for quicker but sharper braking.",
"data_type": "float",
"ui_type": "numeric",
"min": 0.5,
"max": 3.0,
"step": 0.01,
"min": 25,
"max": 200,
"step": 1,
"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 with the \"Aggressive\" profile. 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. Increase for larger gaps and more cautious following; decrease for tighter gaps and closer following.",
"data_type": "float",
"ui_type": "numeric",
"min": 0.5,
"max": 3.0,
"step": 0.01,
"min": 25,
"max": 200,
"step": 1,
"parent_key": "AggressivePersonalityProfile",
"settings_tier": "advanced"
},
{
"key": "AggressiveJerkSpeedDecrease",
"requires_offroad": true,
"label": "Slowdown Response",
"description": "How smoothly openpilot slows down with the \"Aggressive\" profile. Increase for more gradual deceleration; decrease for faster but sharper slowdowns.",
"description": "How smoothly openpilot slows down. Increase for more gradual deceleration; decrease for faster but sharper slowdowns.",
"data_type": "float",
"ui_type": "numeric",
"min": 0.5,
"max": 3.0,
"step": 0.01,
"min": 25,
"max": 200,
"step": 1,
"parent_key": "AggressivePersonalityProfile",
"settings_tier": "advanced"
},
{
"key": "AggressiveJerkSpeed",
"requires_offroad": true,
"label": "Speed-Up Response",
"description": "How smoothly openpilot speeds up with the \"Aggressive\" profile. Increase for more gradual acceleration; decrease for quicker but more jolting acceleration.",
"description": "How smoothly openpilot speeds up. Increase for more gradual acceleration; decrease for quicker but more jolting acceleration.",
"data_type": "float",
"ui_type": "numeric",
"min": 0.5,
"max": 3.0,
"step": 0.01,
"min": 25,
"max": 200,
"step": 1,
"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",
@@ -1425,6 +1444,7 @@
},
{
"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",
@@ -1437,66 +1457,72 @@
},
{
"key": "StandardJerkAcceleration",
"requires_offroad": true,
"label": "Acceleration Smoothness",
"description": "How smoothly openpilot accelerates with the \"Standard\" profile. Increase for gentler starts; decrease for faster but more abrupt takeoffs.",
"description": "How smoothly openpilot accelerates. Increase for gentler starts; decrease for faster but more abrupt takeoffs.",
"data_type": "float",
"ui_type": "numeric",
"min": 0.5,
"max": 3.0,
"step": 0.01,
"min": 25,
"max": 200,
"step": 1,
"parent_key": "StandardPersonalityProfile",
"settings_tier": "advanced"
},
{
"key": "StandardJerkDeceleration",
"requires_offroad": true,
"label": "Braking Smoothness",
"description": "How smoothly openpilot brakes with the \"Standard\" profile. Increase for gentler stops; decrease for quicker but sharper braking.",
"description": "How smoothly openpilot brakes. Increase for gentler stops; decrease for quicker but sharper braking.",
"data_type": "float",
"ui_type": "numeric",
"min": 0.5,
"max": 3.0,
"step": 0.01,
"min": 25,
"max": 200,
"step": 1,
"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 with the \"Standard\" profile. 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. Increase for larger gaps and more cautious following; decrease for tighter gaps and closer following.",
"data_type": "float",
"ui_type": "numeric",
"min": 0.5,
"max": 3.0,
"step": 0.01,
"min": 25,
"max": 200,
"step": 1,
"parent_key": "StandardPersonalityProfile",
"settings_tier": "advanced"
},
{
"key": "StandardJerkSpeedDecrease",
"requires_offroad": true,
"label": "Slowdown Response",
"description": "How smoothly openpilot slows down with the \"Standard\" profile. Increase for more gradual deceleration; decrease for faster but sharper slowdowns.",
"description": "How smoothly openpilot slows down. Increase for more gradual deceleration; decrease for faster but sharper slowdowns.",
"data_type": "float",
"ui_type": "numeric",
"min": 0.5,
"max": 3.0,
"step": 0.01,
"min": 25,
"max": 200,
"step": 1,
"parent_key": "StandardPersonalityProfile",
"settings_tier": "advanced"
},
{
"key": "StandardJerkSpeed",
"requires_offroad": true,
"label": "Speed-Up Response",
"description": "How smoothly openpilot speeds up with the \"Standard\" profile. Increase for more gradual acceleration; decrease for quicker but more jolting acceleration.",
"description": "How smoothly openpilot speeds up. Increase for more gradual acceleration; decrease for quicker but more jolting acceleration.",
"data_type": "float",
"ui_type": "numeric",
"min": 0.5,
"max": 3.0,
"step": 0.01,
"min": 25,
"max": 200,
"step": 1,
"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",
@@ -1509,6 +1535,7 @@
},
{
"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",
@@ -1521,61 +1548,66 @@
},
{
"key": "RelaxedJerkAcceleration",
"requires_offroad": true,
"label": "Acceleration Smoothness",
"description": "How smoothly openpilot accelerates with the \"Relaxed\" profile. Increase for gentler starts; decrease for faster but more abrupt takeoffs.",
"description": "How smoothly openpilot accelerates. Increase for gentler starts; decrease for faster but more abrupt takeoffs.",
"data_type": "float",
"ui_type": "numeric",
"min": 0.5,
"max": 3.0,
"step": 0.01,
"min": 25,
"max": 200,
"step": 1,
"parent_key": "RelaxedPersonalityProfile",
"settings_tier": "advanced"
},
{
"key": "RelaxedJerkDeceleration",
"requires_offroad": true,
"label": "Braking Smoothness",
"description": "How smoothly openpilot brakes with the \"Relaxed\" profile. Increase for gentler stops; decrease for quicker but sharper braking.",
"description": "How smoothly openpilot brakes. Increase for gentler stops; decrease for quicker but sharper braking.",
"data_type": "float",
"ui_type": "numeric",
"min": 0.5,
"max": 3.0,
"step": 0.01,
"min": 25,
"max": 200,
"step": 1,
"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 with the \"Relaxed\" profile. 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. Increase for larger gaps and more cautious following; decrease for tighter gaps and closer following.",
"data_type": "float",
"ui_type": "numeric",
"min": 0.5,
"max": 3.0,
"step": 0.01,
"min": 25,
"max": 200,
"step": 1,
"parent_key": "RelaxedPersonalityProfile",
"settings_tier": "advanced"
},
{
"key": "RelaxedJerkSpeedDecrease",
"requires_offroad": true,
"label": "Slowdown Response",
"description": "How smoothly openpilot slows down with the \"Relaxed\" profile. Increase for more gradual deceleration; decrease for faster but sharper slowdowns.",
"description": "How smoothly openpilot slows down. Increase for more gradual deceleration; decrease for faster but sharper slowdowns.",
"data_type": "float",
"ui_type": "numeric",
"min": 0.5,
"max": 3.0,
"step": 0.01,
"min": 25,
"max": 200,
"step": 1,
"parent_key": "RelaxedPersonalityProfile",
"settings_tier": "advanced"
},
{
"key": "RelaxedJerkSpeed",
"requires_offroad": true,
"label": "Speed-Up Response",
"description": "How smoothly openpilot speeds up with the \"Relaxed\" profile. Increase for more gradual acceleration; decrease for quicker but more jolting acceleration.",
"description": "How smoothly openpilot speeds up. Increase for more gradual acceleration; decrease for quicker but more jolting acceleration.",
"data_type": "float",
"ui_type": "numeric",
"min": 0.5,
"max": 3.0,
"step": 0.01,
"min": 25,
"max": 200,
"step": 1,
"parent_key": "RelaxedPersonalityProfile",
"settings_tier": "advanced"
},
+8 -2
View File
@@ -8,6 +8,10 @@ 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"
@@ -45,6 +49,7 @@ 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 = {
@@ -134,7 +139,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"):
if param_data.get("galaxy_only") or key in PERSONALITY_FAVORITE_BLOCKED_KEYS:
continue
ui_type = str(param_data.get("ui_type") or "")
@@ -393,7 +398,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:
if not key or key in PERSONALITY_FAVORITE_BLOCKED_KEYS:
return False
if is_favorite_action_key(key):
@@ -431,6 +436,7 @@ 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))
):
@@ -0,0 +1,573 @@
#!/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)
+40 -22
View File
@@ -183,40 +183,58 @@ def _read_profile(slot: str, profile_root: Path | None = None) -> dict:
return payload
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:
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."""
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 {}
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
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
if restored_count == 0:
if not settings:
raise ParamProfileError("No compatible settings were found in this profile.")
return {
"slot": normalized,
"label": PROFILE_SLOTS[normalized],
"restoredCount": restored_count,
"settings": settings,
"skippedCount": skipped_count,
}
def load_profile(params, slot: str, *, allowed_keys: set[str] | None = None, profile_root: Path | None = None,
legacy_renames: dict[str, str] | None = None) -> dict:
result = prepare_profile(params, slot, allowed_keys=allowed_keys, profile_root=profile_root, legacy_renames=legacy_renames)
settings = result.pop("settings")
renames = legacy_renames or {}
restored_count = 0
with _PROFILE_LOCK:
for saved_key, value in settings.items():
try:
params.put(renames.get(saved_key, saved_key), value)
restored_count += 1
except (KeyError, TypeError, ValueError, OverflowError):
result["skippedCount"] += 1
if restored_count == 0:
raise ParamProfileError("No compatible settings were found in this profile.")
return {**result, "restoredCount": restored_count}
def profile_status(slot: str, *, profile_root: Path | None = None) -> dict:
normalized = _normalize_slot(slot)
status = {
+15
View File
@@ -8,6 +8,11 @@ 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"
@@ -169,6 +174,7 @@ SAFE_MODE_MANAGED_KEYS = (
"VisionSpeedLimitLowLimitThreshold",
"VASMEnabled",
"CustomPersonalities",
PERSONALITY_PROFILES_PARAM,
"TrafficPersonalityProfile",
"AggressivePersonalityProfile",
"StandardPersonalityProfile",
@@ -225,6 +231,7 @@ 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 = {
@@ -336,6 +343,14 @@ 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:
+14
View File
@@ -49,6 +49,12 @@ 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
@@ -806,6 +812,10 @@ 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)
@@ -901,6 +911,10 @@ 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)
+41 -1
View File
@@ -1,6 +1,7 @@
import json
from typing import cast
from openpilot.common.params import ParamKeyType
from openpilot.common.params import ParamKeyType, Params
from openpilot.starpilot.common.favorite_slots import (
FAVORITE_ACTION_ACCEL_COUNTER,
FAVORITE_ACTION_DECEL_COUNTER,
@@ -16,10 +17,17 @@ 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:
@@ -27,11 +35,14 @@ 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)
@@ -100,6 +111,35 @@ 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, [
@@ -0,0 +1,616 @@
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)
@@ -0,0 +1,20 @@
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,6 +72,24 @@ def test_profile_slots_round_trip_only_eligible_settings(tmp_path):
assert params.values["TransientSetting"] is False
def test_prepare_profile_decodes_without_writes_and_preserves_type_filtering(tmp_path):
params = FakeParams()
param_profiles.save_profile(params, "a", profile_root=tmp_path)
params.values.clear()
params.definitions["NumericSetting"] = (0, ParamKeyType.INT, ParamKeyFlag.PERSISTENT)
prepared = param_profiles.prepare_profile(params, "a", profile_root=tmp_path)
assert params.values == {}
assert prepared["settings"] == {"BooleanSetting": False, "JsonSetting": {"mode": "custom"}}
assert prepared["skippedCount"] == 1
assert prepared["slot"] == "a"
result = param_profiles.load_profile(params, "a", profile_root=tmp_path)
assert result["restoredCount"] == 2
assert result["skippedCount"] == 1
assert params.values == prepared["settings"]
def test_profile_slots_report_missing_and_damaged_profiles(tmp_path):
params = FakeParams()
+115
View File
@@ -1,3 +1,8 @@
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,
@@ -6,6 +11,12 @@ 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:
@@ -23,6 +34,9 @@ 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
@@ -67,3 +81,104 @@ 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]
+132 -57
View File
@@ -19,6 +19,7 @@ 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):
@@ -86,12 +87,17 @@ 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))
@@ -253,18 +259,123 @@ 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"])
)
if sm["starpilotCarState"].trafficModeEnabled:
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:
self.max_accel = get_max_accel_traffic(v_ego)
elif custom_accel_profile:
self.max_accel = get_max_accel_custom(
@@ -276,10 +387,6 @@ 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:
@@ -304,7 +411,24 @@ class StarPilotAcceleration:
self.min_accel = A_CRUISE_MIN_ECO
elif pulse_glide_coasting:
self.min_accel = PULSE_GLIDE_COAST_MIN_ACCEL
elif sm["starpilotCarState"].trafficModeEnabled:
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:
self.min_accel = A_CRUISE_MIN_TRAFFIC
elif starpilot_toggles.map_deceleration and (eco_gear or sport_gear):
if eco_gear:
@@ -313,58 +437,12 @@ 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]
@@ -372,7 +450,4 @@ 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)
+31 -11
View File
@@ -7,6 +7,7 @@ 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]
@@ -53,8 +54,21 @@ 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 sm["starpilotCarState"].trafficModeEnabled:
if long_control_active and traffic_mode:
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)
@@ -63,7 +77,10 @@ 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)
self.t_follow = np.interp(v_ego, TRAFFIC_MODE_BP, starpilot_toggles.traffic_mode_follow)
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)
elif long_control_active:
if sm["carState"].aEgo >= 0:
self.base_acceleration_jerk, self.base_danger_jerk, self.base_speed_jerk = get_jerk_factor(
@@ -80,16 +97,19 @@ class StarPilotFollowing:
starpilot_toggles.custom_personalities, personality
)
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))
if personality_following is not None:
self.t_follow = interpolate_category_curve("following", v_ego, personality_following, False, False)
else:
self.t_follow = float(self.t_follow)
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)
else:
self.base_acceleration_jerk = 0
self.base_danger_jerk = 0
@@ -0,0 +1,378 @@
#!/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)
@@ -0,0 +1,49 @@
"""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]
@@ -0,0 +1,349 @@
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
@@ -0,0 +1,270 @@
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
@@ -0,0 +1,102 @@
"""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]
+24 -10
View File
@@ -26,6 +26,7 @@ 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,
@@ -257,6 +258,22 @@ def update_toggles_in_background(result, starpilot_variables, started, theme_man
result["failed"] = 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)
@@ -380,16 +397,13 @@ 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)
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)
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),
)
completed_toggle_update = toggle_update_result.pop("update", None)
if completed_toggle_update is not None:
@@ -226,6 +226,10 @@
}
/* ――― Child Row Modifier (Sub-menus) ――― */
.ds-setting-children {
display: contents;
}
.ds-child-modifier {
border-left: 2px solid var(--color-gray-200);
margin-left: 1rem;
@@ -767,6 +771,530 @@
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 {
@@ -803,4 +1331,34 @@
.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
@@ -0,0 +1,37 @@
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,6 +16,9 @@ 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,3 +1,30 @@
.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;
@@ -52,6 +52,10 @@ 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")
@@ -359,6 +363,9 @@ 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)"
@@ -0,0 +1,369 @@
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,9 +1,10 @@
import { PersonalityProfiles } from "./PersonalityProfiles.js"
import { GalaxyToggleCard } from "./GalaxyToggleCard.js"
import { hasChildParams, isGroupParam, isParamEnabledForChildren } from "../params.js"
export const SettingTree = {
name: "SettingTree",
components: { GalaxyToggleCard },
components: { GalaxyToggleCard, PersonalityProfiles },
props: {
params: { type: Array, required: true },
parentKey: { default: null },
@@ -29,13 +30,14 @@ export const SettingTree = {
},
template: `
<template v-for="p in children" :key="p.key">
<div class="gx-tree-node" :class="{ 'gx-tree-node--child': depth > 0 }" :style="'--gx-depth:' + depth">
<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) !== ''"
:manageable="manageable(p)" :manage-open="manageOpen(p)"
@change="$emit('change', $event)" @manage="$emit('manage', $event)" />
</div>
<transition name="gx-collapse">
<div v-if="showChildren(p)" class="gx-tree-children">
<div v-if="p.key !== 'CustomPersonalities' && 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)" />
@@ -5,13 +5,24 @@ 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"
// Match classic Galaxy's retired longitudinal controls in this replacement view.
// These are presentation exclusions only; registry values and backend policy stay intact.
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, GalaxyToggleCard, GalaxySection, DevModeBanner },
components: { SettingTree, PersonalityProfiles, GalaxyToggleCard, GalaxySection, DevModeBanner },
data() {
return {
layout: [],
@@ -30,7 +41,7 @@ export const Settings = {
.filter((s) => s.name !== "Model & Customization")
.map((s) => ({
...s,
params: (s.params || []).filter((p) => isSettingVisible(s, p, this.values)),
params: (s.params || []).filter((p) => !LEGACY_PERSONALITY_KEYS.has(p.key) && isSettingVisible(s, p, this.values)),
slug: slugifySectionName(s.name),
}))
.filter((s) => s.params.length > 0)
@@ -47,7 +58,12 @@ export const Settings = {
searchResults() {
if (!this.searchActive) return []
return this.sections
.map((s) => ({ ...s, matches: s.params.filter((p) => this.matchesFilter(p)) }))
.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)) }
})
.filter((s) => s.matches.length > 0)
},
},
@@ -134,7 +150,8 @@ export const Settings = {
<template v-for="section in searchResults" :key="section.slug">
<GalaxySection :title="section.name + ' (' + section.matches.length + ')'" :icon="section.icon || 'bi-search'" :default-open="false">
<template v-for="p in section.matches" :key="p.key">
<GalaxyToggleCard :param="p" :value="values[p.key]" :values="values" :locked="lockReason(p) !== ''"
<PersonalityProfiles v-if="p.key === 'CustomPersonalities'" :manage-open="!!expanded[p.key]" @manage="toggleManage(p.key)" @change="onParamChange" />
<GalaxyToggleCard v-else :param="p" :value="values[p.key]" :values="values" :locked="lockReason(p) !== ''"
@change="onParamChange" />
</template>
</GalaxySection>
@@ -0,0 +1,22 @@
# 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.
@@ -0,0 +1 @@
{"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}
@@ -0,0 +1,50 @@
// 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;
};
@@ -0,0 +1,46 @@
// 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.');
};
@@ -0,0 +1,220 @@
// 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;});
@@ -0,0 +1,143 @@
// 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;});
@@ -0,0 +1,103 @@
// 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 })
@@ -0,0 +1,52 @@
// 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});
@@ -201,14 +201,48 @@ def _install_server_import_stubs():
theme_manager.THEME_COMPONENT_PARAMS = {}
def parse_custom_accel_profile_curve(count, breakpoints, values):
point_count = int(count)
numeric_count = float(count)
if not numeric_count.is_integer():
raise ValueError("Breakpoint count must be a whole number")
point_count = int(numeric_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 active_breakpoints, [float(value) for value in values[:point_count]]
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")
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=[
@@ -219,13 +253,18 @@ 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=[],
CUSTOM_ACCEL_PROFILE_PARAM_KEYS=[f"CustomAccelProfile{mph}MPH" for mph in (0, 11, 22, 34, 45, 56, 89)],
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 *args, **kwargs: False,
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,
get_custom_accel_profile_curve_defaults=lambda *args, **kwargs: {},
normalize_acceleration_profile=lambda value: value,
normalize_acceleration_profile=lambda value: int(value or 0),
normalize_deceleration_profile=lambda value: int(value or 0),
parse_custom_accel_profile_curve=parse_custom_accel_profile_curve,
)
sys.modules["openpilot.starpilot.common.maps_catalog"] = _simple_module(
@@ -2296,6 +2335,200 @@ 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()
@@ -2393,7 +2626,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}))
monkeypatch.setattr(server, "params", FakeParams({"IsOnroad": False, "IsOffroad": True}))
monkeypatch.setattr(server, "EXCLUDED_KEYS", set())
monkeypatch.setattr(server, "TOGGLE_BACKUPS", tmp_path)
update_calls = []
@@ -0,0 +1,36 @@
// 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});
@@ -0,0 +1,67 @@
"""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
@@ -0,0 +1,904 @@
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()
@@ -0,0 +1,660 @@
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
@@ -0,0 +1,45 @@
"""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
+452 -29
View File
@@ -1,6 +1,7 @@
#!/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
@@ -59,6 +60,7 @@ from openpilot.starpilot.assets.model_manager import (
external_gpu_available,
get_model_profile,
is_builtin_model_key,
model_accelerator_catalog_artifact_metadata,
model_accelerator_artifact_filename,
model_key_aliases,
model_uses_external_gpu,
@@ -76,6 +78,7 @@ 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,
@@ -85,10 +88,15 @@ 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 (
@@ -118,6 +126,33 @@ 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
@@ -3462,6 +3497,9 @@ 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.")
@@ -3586,6 +3624,117 @@ 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()
@@ -4363,7 +4512,10 @@ 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"}
blocked_onroad_keys = {
"Model", "AlwaysOnLateral", "ForceTorqueController", "NNFF", "NNFFLite",
}
personality_writes_locked = _personality_settings_write_locked()
updated_keys = []
skipped_keys = []
@@ -4377,8 +4529,9 @@ def _reset_troubleshoot_section(section_id):
skipped_keys.append({"key": key, "reason": "not editable"})
continue
if is_onroad and key in blocked_onroad_keys:
skipped_keys.append({"key": key, "reason": "blocked while onroad"})
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"})
continue
if key not in default_values:
@@ -5670,6 +5823,125 @@ 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/params", methods=["GET", "PUT"])
def get_param():
if request.method == "PUT":
@@ -5678,6 +5950,12 @@ 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.lower() == FAVORITE_SLOTS_PARAM.lower():
key = FAVORITE_SLOTS_PARAM
raw_slots = data["value"]
@@ -5718,6 +5996,16 @@ 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()
@@ -5731,6 +6019,41 @@ 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
@@ -7057,15 +7380,18 @@ def setup(app):
metadata = artifact_metadata.get(canonical_key, {})
metadata = metadata if isinstance(metadata, dict) else {}
small_model = is_small_model_metadata({**metadata, "uses_external_gpu": requires_external_gpu})
lab_eligible = model_lab_manifest_eligible({**metadata, "uses_external_gpu": requires_external_gpu}, model_version)
lab_compatible = model_lab_manifest_eligible({**metadata, "uses_external_gpu": requires_external_gpu}, model_version)
accelerator_artifacts = metadata.get("accelerator_artifacts", {})
accelerator_artifacts = accelerator_artifacts if isinstance(accelerator_artifacts, dict) else {}
chestnut_artifact = accelerator_artifacts.get("chestnut", {})
chestnut_artifact = chestnut_artifact if isinstance(chestnut_artifact, dict) else {}
if not chestnut_artifact:
chestnut_artifact = model_accelerator_catalog_artifact_metadata(canonical_key)
lab_artifact_available = (
bool(chestnut_artifact)
and str(chestnut_artifact.get("execution_device") or chestnut_artifact.get("device") or "").strip().upper() == "AMD"
)
lab_eligible = lab_compatible and lab_artifact_available
lab_artifact_path = MODELS_PATH / model_accelerator_artifact_filename(canonical_key)
lab_artifact_installed = lab_artifact_available and file_chunked_exists(lab_artifact_path)
existing = models_by_key.get(canonical_key)
@@ -7112,6 +7438,11 @@ def setup(app):
existing["modelLabArtifactInstalled"] = existing["modelLabArtifactInstalled"] and lab_artifact_installed
default_key = _default_model_key()
default_chestnut_artifact = model_accelerator_catalog_artifact_metadata(default_key)
default_lab_artifact_available = (
bool(default_chestnut_artifact)
and str(default_chestnut_artifact.get("execution_device") or default_chestnut_artifact.get("device") or "").strip().upper() == "AMD"
)
default_entry = models_by_key.setdefault(default_key, {
"value": default_key,
"label": _default_model_name(),
@@ -7123,9 +7454,13 @@ def setup(app):
"small": True,
"modelSize": "small (inferred)",
"manifestDeclaredSize": False,
"modelLabEligible": model_lab_manifest_eligible(artifact_metadata.get(default_key, {}), _default_model_version()),
"modelLabArtifactAvailable": False,
"modelLabArtifactInstalled": False,
"modelLabEligible": default_lab_artifact_available and model_lab_manifest_eligible(
artifact_metadata.get(default_key, {}), _default_model_version()
),
"modelLabArtifactAvailable": default_lab_artifact_available,
"modelLabArtifactInstalled": default_lab_artifact_available and file_chunked_exists(
MODELS_PATH / model_accelerator_artifact_filename(default_key)
),
"released": "",
"builtin": True,
"communityFavorite": default_key in community_favorites,
@@ -9675,29 +10010,119 @@ 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()
restored_count = 0
skipped_count = 0
validated_personality_values = {}
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
if mapped_key not in allowed_keys or mapped_key not in PERSONALITY_PARKED_PARAM_KEYS:
continue
try:
_params_raw.put(mapped_key, _coerce_toggle_restore_value(mapped_key, value))
restored_count += 1
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):
skipped_count += 1
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)
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
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."
@@ -9736,10 +10161,10 @@ def setup(app):
@app.route("/api/toggles/profiles/<slot>/load", methods=["POST"])
def load_toggle_profile(slot):
if _safe_params_get_bool("IsOnroad"):
return jsonify({"success": False, "message": "Settings profiles can only be loaded while parked."}), 403
if _personality_settings_write_locked():
return jsonify({"success": False, "message": "Settings profiles can only be loaded while parked with off-road state confirmed."}), 403
try:
result = param_profiles.load_profile(
profile = param_profiles.prepare_profile(
_params_raw,
slot,
allowed_keys=_get_toggle_backup_keys(),
@@ -9749,18 +10174,16 @@ def setup(app):
except param_profiles.ParamProfileError as error:
return jsonify({"success": False, "message": str(error)}), 400
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,
})
return _restore_toggle_values(profile["settings"], profile=profile)
@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:
+3 -2
View File
@@ -1,9 +1,9 @@
Dynamically Derived Feasible Param Candidates (The Golden List)
===============================================================
Total globally registered C++ keys: 545
Total globally registered C++ keys: 546
Total explicit UI string references: 426
Total Editable/Toggleable targets: 390
Total Editable/Toggleable targets: 391
AccelerationPath
AccelerationProfile
@@ -180,6 +180,7 @@ LongStarButtonControl
LongitudinalActuatorDelay
LongitudinalActuatorDelayStock
LongitudinalPersonality
LongitudinalPersonalityProfiles
LongitudinalTune
LoudBlindspotAlert
LoudBlindspotAlertWhenDisengaged