mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-09-10 10:13:45 +08:00
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:
committed by
firestar5683
parent
b7775991bf
commit
a19beda327
Binary file not shown.
@@ -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.
@@ -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})
|
||||
|
||||
@@ -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,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)
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
+378
@@ -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]
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user