diff --git a/common/libcommon.a b/common/libcommon.a index d1b57c07de..b5ca68a3cd 100644 Binary files a/common/libcommon.a and b/common/libcommon.a differ diff --git a/common/params_keys.h b/common/params_keys.h index 1aa462b711..900d87d3d7 100644 --- a/common/params_keys.h +++ b/common/params_keys.h @@ -110,6 +110,7 @@ inline static std::unordered_map keys = { {"LocationFilterInitialState", {PERSISTENT, BYTES}}, {"LongitudinalManeuverMode", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, BOOL}}, {"LongitudinalPersonality", {PERSISTENT, INT, std::to_string(static_cast(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}}, diff --git a/common/params_pyx.so b/common/params_pyx.so index 6531d89e04..c5024761f2 100755 Binary files a/common/params_pyx.so and b/common/params_pyx.so differ diff --git a/common/tests/test_params.py b/common/tests/test_params.py index ddac1941bb..135c609d31 100644 --- a/common/tests/test_params.py +++ b/common/tests/test_params.py @@ -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}) diff --git a/selfdrive/ui/layouts/settings/toggles.py b/selfdrive/ui/layouts/settings/toggles.py index 61fd12a3e4..8fe8e310d6 100644 --- a/selfdrive/ui/layouts/settings/toggles.py +++ b/selfdrive/ui/layouts/settings/toggles.py @@ -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): diff --git a/selfdrive/ui/mici/layouts/settings/toggles.py b/selfdrive/ui/mici/layouts/settings/toggles.py index c311949de2..6ec5418cf6 100644 --- a/selfdrive/ui/mici/layouts/settings/toggles.py +++ b/selfdrive/ui/mici/layouts/settings/toggles.py @@ -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): diff --git a/selfdrive/ui/mici/onroad/augmented_road_view.py b/selfdrive/ui/mici/onroad/augmented_road_view.py index 78eb8c8f23..0923bff743 100644 --- a/selfdrive/ui/mici/onroad/augmented_road_view.py +++ b/selfdrive/ui/mici/onroad/augmented_road_view.py @@ -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") ) diff --git a/selfdrive/ui/tests/test_personality_selection.py b/selfdrive/ui/tests/test_personality_selection.py new file mode 100644 index 0000000000..96d2aaeb93 --- /dev/null +++ b/selfdrive/ui/tests/test_personality_selection.py @@ -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)] diff --git a/starpilot/common/assets/device_settings_layout.json b/starpilot/common/assets/device_settings_layout.json index ac2ae2150e..06cfadbe3c 100644 --- a/starpilot/common/assets/device_settings_layout.json +++ b/starpilot/common/assets/device_settings_layout.json @@ -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" }, diff --git a/starpilot/common/favorite_slots.py b/starpilot/common/favorite_slots.py index da3da12075..59b05b99d4 100644 --- a/starpilot/common/favorite_slots.py +++ b/starpilot/common/favorite_slots.py @@ -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)) ): diff --git a/starpilot/common/longitudinal_personality_profiles.py b/starpilot/common/longitudinal_personality_profiles.py new file mode 100644 index 0000000000..7a6000ccfd --- /dev/null +++ b/starpilot/common/longitudinal_personality_profiles.py @@ -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) diff --git a/starpilot/common/param_profiles.py b/starpilot/common/param_profiles.py index 0a60da91e4..2fdea1cd70 100644 --- a/starpilot/common/param_profiles.py +++ b/starpilot/common/param_profiles.py @@ -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 = { diff --git a/starpilot/common/safe_mode.py b/starpilot/common/safe_mode.py index a4e1705752..5a0569e566 100644 --- a/starpilot/common/safe_mode.py +++ b/starpilot/common/safe_mode.py @@ -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: diff --git a/starpilot/common/starpilot_variables.py b/starpilot/common/starpilot_variables.py index f66d06cbfe..1962fe9865 100644 --- a/starpilot/common/starpilot_variables.py +++ b/starpilot/common/starpilot_variables.py @@ -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) diff --git a/starpilot/common/tests/test_favorite_slots.py b/starpilot/common/tests/test_favorite_slots.py index e4017f6c90..c14651c633 100644 --- a/starpilot/common/tests/test_favorite_slots.py +++ b/starpilot/common/tests/test_favorite_slots.py @@ -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, [ diff --git a/starpilot/common/tests/test_longitudinal_personality_profiles.py b/starpilot/common/tests/test_longitudinal_personality_profiles.py new file mode 100644 index 0000000000..04e95eb533 --- /dev/null +++ b/starpilot/common/tests/test_longitudinal_personality_profiles.py @@ -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) diff --git a/starpilot/common/tests/test_longitudinal_profiles_registry.py b/starpilot/common/tests/test_longitudinal_profiles_registry.py new file mode 100644 index 0000000000..2e9177f1af --- /dev/null +++ b/starpilot/common/tests/test_longitudinal_profiles_registry.py @@ -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 diff --git a/starpilot/common/tests/test_param_profiles.py b/starpilot/common/tests/test_param_profiles.py index e4b78aac7e..620af023c8 100644 --- a/starpilot/common/tests/test_param_profiles.py +++ b/starpilot/common/tests/test_param_profiles.py @@ -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() diff --git a/starpilot/common/tests/test_safe_mode.py b/starpilot/common/tests/test_safe_mode.py index d9b77a4da9..49bf801815 100644 --- a/starpilot/common/tests/test_safe_mode.py +++ b/starpilot/common/tests/test_safe_mode.py @@ -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] diff --git a/starpilot/controls/lib/starpilot_acceleration.py b/starpilot/controls/lib/starpilot_acceleration.py index cf516de939..b90c34f10e 100644 --- a/starpilot/controls/lib/starpilot_acceleration.py +++ b/starpilot/controls/lib/starpilot_acceleration.py @@ -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) diff --git a/starpilot/controls/lib/starpilot_following.py b/starpilot/controls/lib/starpilot_following.py index d7edc1d0a5..f682c91a9a 100644 --- a/starpilot/controls/lib/starpilot_following.py +++ b/starpilot/controls/lib/starpilot_following.py @@ -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 diff --git a/starpilot/controls/tests/fixtures/dom_249b03a3_starpilot_acceleration.py.txt b/starpilot/controls/tests/fixtures/dom_249b03a3_starpilot_acceleration.py.txt new file mode 100644 index 0000000000..cf516de939 --- /dev/null +++ b/starpilot/controls/tests/fixtures/dom_249b03a3_starpilot_acceleration.py.txt @@ -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) diff --git a/starpilot/controls/tests/test_dom_named_braking_parity.py b/starpilot/controls/tests/test_dom_named_braking_parity.py new file mode 100644 index 0000000000..17a057cefa --- /dev/null +++ b/starpilot/controls/tests/test_dom_named_braking_parity.py @@ -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] diff --git a/starpilot/controls/tests/test_personality_following_profiles.py b/starpilot/controls/tests/test_personality_following_profiles.py new file mode 100644 index 0000000000..d402b16d8f --- /dev/null +++ b/starpilot/controls/tests/test_personality_following_profiles.py @@ -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 diff --git a/starpilot/controls/tests/test_personality_longitudinal_profiles.py b/starpilot/controls/tests/test_personality_longitudinal_profiles.py new file mode 100644 index 0000000000..213888fbd9 --- /dev/null +++ b/starpilot/controls/tests/test_personality_longitudinal_profiles.py @@ -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 diff --git a/starpilot/controls/tests/test_personality_transient_contract.py b/starpilot/controls/tests/test_personality_transient_contract.py new file mode 100644 index 0000000000..fa4219b7c0 --- /dev/null +++ b/starpilot/controls/tests/test_personality_transient_contract.py @@ -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=[]), '', '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=[]), '', '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] diff --git a/starpilot/starpilot_process.py b/starpilot/starpilot_process.py index 0db249cac3..eabc68bdf9 100644 --- a/starpilot/starpilot_process.py +++ b/starpilot/starpilot_process.py @@ -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: diff --git a/starpilot/system/the_galaxy/assets/components/tools/device_settings.css b/starpilot/system/the_galaxy/assets/components/tools/device_settings.css index f467958faa..e989c60d66 100644 --- a/starpilot/system/the_galaxy/assets/components/tools/device_settings.css +++ b/starpilot/system/the_galaxy/assets/components/tools/device_settings.css @@ -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; + } } diff --git a/starpilot/system/the_galaxy/assets/components/tools/device_settings.js b/starpilot/system/the_galaxy/assets/components/tools/device_settings.js index ec7c5cbc6c..3e5ca0ce7e 100644 --- a/starpilot/system/the_galaxy/assets/components/tools/device_settings.js +++ b/starpilot/system/the_galaxy/assets/components/tools/device_settings.js @@ -1,5 +1,12 @@ import { html, reactive } from "/assets/vendor/arrow-core.js" -import { formatNumericParamValue, resolveVehicleUnitParam } from "/assets/mobile/js/params.js" +import { + formatProfileSpeed, + personalityProfileParamKey, + profileSpeedUnit, + shouldSubmitPersonalityPreset, + valueFromPointer, +} from "/assets/components/tools/personality_profiles.mjs" +import { formatNumericParamValue, resolveVehicleUnitParam, vehicleSpeedUnit } from "/assets/mobile/js/params.js" const endpointOptionsCache = {} const endpointOptionsInflight = {} @@ -12,7 +19,34 @@ const FAVORITE_OPTION_COLLATOR = new Intl.Collator(undefined, { numeric: true, s const FAVORITE_ACTION_PREFIX = "__starpilot_favorite_action__:" const GALAXY_DEVELOPER_MODE_KEY = "GalaxyDeveloperMode" const HIDDEN_SECTION_NAMES = new Set(["Model & Customization"]) -const HIDDEN_SETTING_KEYS = new Set(["HumanAcceleration"]) +const PROFILE_HIDDEN_LAYOUT_KEYS = new Set([ + "TrafficPersonalityProfile", + "AggressivePersonalityProfile", + "StandardPersonalityProfile", + "RelaxedPersonalityProfile", +]) +const HIDDEN_SETTING_KEYS = new Set([ + "AccelerationProfile", + "AggressiveFollow", + "AggressiveFollowHigh", + "CustomAccelProfile", + "CustomAccelProfile0MPH", + "CustomAccelProfile11MPH", + "CustomAccelProfile22MPH", + "CustomAccelProfile34MPH", + "CustomAccelProfile45MPH", + "CustomAccelProfile56MPH", + "CustomAccelProfile89MPH", + "DecelerationProfile", + "EVTuning", + "HumanAcceleration", + "RelaxedFollow", + "RelaxedFollowHigh", + "StandardFollow", + "StandardFollowHigh", + "TrafficFollow", + "TruckTuning", +]) const GM_MAKES = ["Buick", "Cadillac", "Chevrolet", "GMC", "Holden"] const HKG_MAKES = ["Genesis", "Hyundai", "Kia"] const VEHICLE_SETTING_MAKES = { @@ -34,7 +68,7 @@ const VEHICLE_SETTING_MAKES = { RemoteStartBootsComma: GM_MAKES, HKGRemoteStartBootsComma: HKG_MAKES, VoltSNG: ["Chevrolet", "Holden"], - GMAutoHold: ["Buick", "Chevrolet", "Holden"], + GMAutoHold: ["Chevrolet", "Holden"], VoltOnePedalMode: ["Chevrolet", "Holden"], RemapCancelToDistance: ["Chevrolet", "Holden"], JeepBrakeHold: ["Jeep"], @@ -57,7 +91,32 @@ let favoritePollInflight = null let favoritePollTimer = null let cscCalibrationPollInflight = null let cscCalibrationPollTimer = null +let uiContextPollInflight = null +let uiContextPollTimer = null +let personalityViewGeneration = 0 const DYNAMIC_DEFAULT_DEP_KEYS = new Set(["AccelerationProfile", "EVTuning", "TruckTuning"]) +const PERSONALITY_DEFINITIONS = [ + { id: "traffic", label: "Traffic Mode", icon: "bi bi-stoplights-fill" }, + { id: "aggressive", label: "Aggressive", icon: "bi bi-lightning-charge-fill" }, + { id: "standard", label: "Standard", icon: "bi bi-speedometer2" }, + { id: "relaxed", label: "Relaxed", icon: "bi bi-feather" }, +] +const PERSONALITY_CATEGORY_DEFINITIONS = { + acceleration: { label: "Acceleration", title: "Custom acceleration", description: "maximum acceleration", fieldDescription: "How quickly StarPilot speeds up", unit: "m/s² requested", valueUnit: "m/s²", step: 0.05 }, + braking: { label: "Braking", title: "Custom braking", description: "braking strength", fieldDescription: "Cruise and speed-limit deceleration floor", unit: "m/s² braking", valueUnit: "m/s²", step: 0.05 }, + following: { label: "Following", title: "Custom following", description: "base following time", fieldDescription: "Base time headway before existing dynamic modifiers", unit: "seconds", valueUnit: "s", step: 0.05 }, +} +const PERSONALITY_OPTION_ORDER = { + acceleration: ["eco", "standard", "sport", "sport_plus", "custom"], + braking: ["eco", "standard", "sport", "custom"], + following: ["close", "medium", "far", "custom"], +} +const PERSONALITY_ADVANCED_KEYS = { + traffic: ["TrafficJerkAcceleration", "TrafficJerkDeceleration", "TrafficJerkDanger", "TrafficJerkSpeedDecrease", "TrafficJerkSpeed"], + aggressive: ["AggressiveJerkAcceleration", "AggressiveJerkDeceleration", "AggressiveJerkDanger", "AggressiveJerkSpeedDecrease", "AggressiveJerkSpeed"], + standard: ["StandardJerkAcceleration", "StandardJerkDeceleration", "StandardJerkDanger", "StandardJerkSpeedDecrease", "StandardJerkSpeed"], + relaxed: ["RelaxedJerkAcceleration", "RelaxedJerkDeceleration", "RelaxedJerkDanger", "RelaxedJerkSpeedDecrease", "RelaxedJerkSpeed"], +} const PANDA_FIRMWARE_TOGGLE_KEYS = new Set(["IgnoreIgnitionLine", "RemoteStartBootsComma", "HKGRemoteStartBootsComma"]) const FLM_ADVANCED_LATERAL_KEYS = new Set([ "AdvancedLateralTune", "ForceAutoTune", "ForceAutoTuneOff", "UseAutoSteerDelay", "SteerDelay", @@ -87,6 +146,22 @@ const state = reactive({ favoriteSlots: [], favoriteFilters: ["", "", ""], favoriteValues: {}, + personalityAdvancedCustomOpen: {}, + personalityAdvancedExpanded: {}, + personalityCurveErrors: {}, + personalityConfigured: false, + personalityDefaults: {}, + personalityEnabled: false, + personalityExpanded: {}, + personalityMeta: null, + personalityProfiles: {}, + personalityMigrationRequired: false, + personalityMigrationInProgress: false, + personalityReferenceCurves: {}, + personalityProfilesError: "", + personalityProfilesLoading: true, + personalityRecoveryPending: false, + personalityUpdating: {}, }) function slugifySectionName(name) { @@ -117,8 +192,8 @@ function matchesSettingValueCondition(param) { } function isSettingVisible(section, param) { - // This policy controls Galaxy rendering only; hidden params retain their stored values. - if (HIDDEN_SETTING_KEYS.has(param.key) || !isVehicleSettingVisible(section, param) || !matchesSettingValueCondition(param)) return false + if (PROFILE_HIDDEN_LAYOUT_KEYS.has(param.key) || HIDDEN_SETTING_KEYS.has(param.key) || + !isVehicleSettingVisible(section, param) || !matchesSettingValueCondition(param)) return false if (param.requires_capability && !state.values[param.requires_capability]) return false if (RADAR_REQUIRED_KEYS.has(param.key) && !state.values.HasRadar) return false if (param.key === "AlphaLongitudinalEnabled" && !state.values.AlphaLongitudinalAvailable) return false @@ -126,14 +201,31 @@ function isSettingVisible(section, param) { return section.name === "Favorites" || param.settings_tier === "simple" } +function collectDescendantKeys(params, parentKey) { + const descendants = new Set() + const pending = [parentKey] + while (pending.length) { + const currentParent = pending.pop() + for (const param of params || []) { + if (param.parent_key !== currentParent || descendants.has(param.key)) continue + descendants.add(param.key) + pending.push(param.key) + } + } + return descendants +} + function getSectionsWithSlug() { return state.layout .filter(section => !HIDDEN_SECTION_NAMES.has(section.name)) - .map(section => ({ - ...section, - params: (section.params || []).filter(param => isSettingVisible(section, param)), - slug: slugifySectionName(section.name), - })) + .map(section => { + const personalityLegacySubtreeKeys = collectDescendantKeys(section.params, "CustomPersonalities") + return { + ...section, + params: (section.params || []).filter(param => !personalityLegacySubtreeKeys.has(param.key) && isSettingVisible(section, param)), + slug: slugifySectionName(section.name), + } + }) .filter(section => section.params.length > 0) } @@ -394,6 +486,61 @@ async function refreshParamsAndDefaults() { scheduleSyncInputs() } +async function fetchPersonalityProfiles() { + state.personalityProfilesLoading = true + state.personalityProfilesError = "" + try { + const response = await fetch("/api/personality_profiles", { cache: "no-store" }) + let data + try { + data = await response.json() + } catch (_error) { + throw new Error(response.ok + ? "Driving personalities returned malformed data. Refresh the page to retry." + : `Driving personalities could not be loaded (HTTP ${response.status}). Refresh the page to retry.`) + } + if (!response.ok) throw new Error(data?.error || response.statusText || "Failed to load driving personalities") + if (!data || typeof data !== "object" || !data.profiles || typeof data.profiles !== "object" || + !data.bounds || typeof data.bounds !== "object" || !data.options || typeof data.options !== "object" || + !data.speed_breakpoints_mph || typeof data.speed_breakpoints_mph !== "object") { + throw new Error("Driving personalities returned malformed data. Refresh the page to retry.") + } + state.personalityProfiles = data.profiles + state.personalityConfigured = !!data.configured + state.personalityEnabled = !!data.enabled + state.personalityDefaults = data.default_profiles || {} + state.personalityMigrationRequired = !!data.migration_required + state.personalityReferenceCurves = data.reference_curves || {} + state.personalityMeta = { + bounds: data.bounds, + options: data.options, + speedBreakpointsMph: data.speed_breakpoints_mph, + } + } catch (error) { + console.error("Failed to load longitudinal personality profiles:", error) + state.personalityProfilesError = error?.message || "Driving personalities could not be loaded. Refresh the page to retry." + } finally { + state.personalityProfilesLoading = false + } +} + +async function migratePersonalityProfiles() { + if (!state.personalityMigrationRequired || state.personalityMigrationInProgress) return + state.personalityMigrationInProgress = true + try { + const response = await fetch("/api/personality_profiles/migrate", { method: "POST" }) + const data = await response.json() + if (!response.ok) throw new Error(data?.error || response.statusText || "Failed to migrate driving personalities") + await fetchPersonalityProfiles() + showParamSnackbar(data?.message || "Driving personalities migrated.", "success", 3500) + } catch (error) { + console.error("Failed to migrate longitudinal personality profiles:", error) + showParamSnackbar(error?.message || "Driving personalities could not be migrated.", "error", 5000) + } finally { + state.personalityMigrationInProgress = false + } +} + async function fetchLayoutAndParams() { state.loadingLayout = true state.loadingValues = true @@ -429,7 +576,7 @@ async function fetchLayoutAndParams() { // Pull params once at page load; local state handles subsequent edits. try { - const [defaultsLoaded] = await Promise.all([fetchDefaultValues(), fetchFlmWorkspace(true)]) + const [defaultsLoaded] = await Promise.all([fetchDefaultValues(), fetchFlmWorkspace(true), fetchPersonalityProfiles()]) if (!defaultsLoaded) { state.defaultValues = {} } @@ -490,7 +637,7 @@ function numericBounds(param) { return { min: 1, max: 101, step: 1 } } if (param.key === "ScreenBrightnessOnroad") { - return { min: 0, max: 101, step: 1 } + return { min: 1, max: 101, step: 1 } } if (param.key === "LaneCenterOffset") { @@ -706,6 +853,50 @@ function ensureCscCalibrationPolling() { }, 1000) } +async function refreshUiContextValues() { + if (uiContextPollInflight || state.loadingValues) return uiContextPollInflight + + uiContextPollInflight = Promise.all( + ["IsOnroad", "IsMetric"].map(async key => { + const response = await fetch(`/api/params?key=${encodeURIComponent(key)}`, { cache: "no-store" }) + if (!response.ok) return [key, null] + const raw = (await response.text()).trim().toLowerCase() + if (!["0", "1", "false", "true"].includes(raw)) return [key, null] + return [key, raw === "1" || raw === "true"] + }), + ).then(entries => { + const nextValues = { ...state.values } + let changed = false + for (const [key, value] of entries) { + if (value === null || nextValues[key] === value) continue + nextValues[key] = value + changed = true + } + if (changed) { + state.values = nextValues + scheduleSyncInputs() + } + }).catch(() => {}).finally(() => { + uiContextPollInflight = null + }) + + return uiContextPollInflight +} + +function ensureUiContextPolling() { + if (uiContextPollTimer !== null) return + + refreshUiContextValues() + uiContextPollTimer = setInterval(() => { + if (!window.location.pathname.startsWith("/device_settings")) { + clearInterval(uiContextPollTimer) + uiContextPollTimer = null + return + } + if (document.visibilityState === "visible") refreshUiContextValues() + }, 1000) +} + async function saveFavoriteSlots(slots) { if (state.favoriteSaving) return @@ -882,8 +1073,17 @@ function isNumericUpdating(key) { return !!state.numericUpdating[key] } +function escapeSnackbarText(message) { + return String(message ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'") +} + function showParamSnackbar(message, level, timeout = 2200) { - showSnackbar(message, level, timeout, { + showSnackbar(escapeSnackbarText(message), level, timeout, { key: "device-settings-param-update", replace: true, }) @@ -1289,6 +1489,9 @@ function clearSearchFilter() { const cancelButtonKeys = new Set(["CancelButtonControl", "LongCancelButtonControl", "VeryLongCancelButtonControl"]) function getSettingLockReason(param) { + if (param?.key === "CustomPersonalities" && state.personalityMigrationRequired) { + return "This profile data requires a verified migration before it can be edited." + } if (param?.requires_offroad && state.values.IsOnroad) { return "This setting can only be changed while parked." } @@ -1371,6 +1574,656 @@ function handleSectionTabClick(sectionSlug, event) { } } +function personalityPresetLabel(preset) { + return String(preset || "").split("_").map(part => part === "plus" ? "+" : `${part.charAt(0).toUpperCase()}${part.slice(1)}`).join(" ").replace(" +", "+") +} + +function personalityUpdateKey(profileId, category) { + return `${profileId}:${category}` +} + +function togglePersonalityAdvanced(profileId) { + state.personalityAdvancedExpanded = { + ...state.personalityAdvancedExpanded, + [profileId]: !state.personalityAdvancedExpanded[profileId], + } +} + +async function recoverPersonalitySave() { + if (state.personalityRecoveryPending) return false + state.personalityRecoveryPending = true + const generation = personalityViewGeneration + state.personalityProfilesError = "Save could not be confirmed. Rechecking saved state…" + try { + const responses = await Promise.all([ + fetch("/api/personality_profiles", { cache: "no-store" }), + fetch("/api/params/all", { cache: "no-store" }), + ]) + if (responses.some(response => !response.ok)) throw new Error("Saved state readback failed.") + const [data, values] = await Promise.all(responses.map(response => response.json())) + if (generation !== personalityViewGeneration || !window.location.pathname.startsWith("/device_settings")) return false + for (const [profile, categories] of Object.entries(state.personalityProfiles)) { + for (const category of Object.keys(categories)) { + const config = data?.profiles?.[profile]?.[category] + if (!config || typeof config.preset !== "string" || !Array.isArray(config.curve) || !config.curve.every(Number.isFinite) || + (config.preset === "custom" && config.curve.length !== data.speed_breakpoints_mph?.[category]?.length)) throw new Error("Saved profiles are malformed.") + } + } + const onroad = [false, "", "0", "False", "false"].includes(values?.IsOnroad) ? false : [true, "1", "True", "true"].includes(values?.IsOnroad) ? true : null + const offroad = [true, "1", "True", "true"].includes(values?.IsOffroad) + if (onroad === null || (!onroad && !offroad)) throw new Error("Road state could not be verified.") + state.values = { ...state.values, IsOnroad: onroad, IsOffroad: offroad } + state.personalityProfiles = data.profiles + state.personalityMigrationRequired = !!data.migration_required + state.personalityConfigured = !!data.configured + state.personalityEnabled = !!data.enabled + state.personalityReferenceCurves = data.reference_curves || {} + state.personalityProfilesError = "" + showParamSnackbar("Save could not be confirmed. Showing verified saved state; review it before editing again.", "error") + return true + } catch (error) { + if (window.location.pathname.startsWith("/device_settings")) state.personalityProfilesError = `${error.message} Editing is locked. Retry saved-state readback.` + return false + } finally { + state.personalityRecoveryPending = false + } +} + +async function savePersonalityCategory(profileId, category, preset, curve, successMessage) { + if (state.values.IsOnroad) return false + if (!window.location.pathname.startsWith("/device_settings") || state.personalityProfilesError || state.personalityProfilesLoading) return false + if (state.personalityMigrationRequired) { + showParamSnackbar("This profile data requires a verified migration before it can be edited.", "error") + return false + } + const updateKey = personalityUpdateKey(profileId, category) + if (Object.keys(state.personalityUpdating).length) return false + const generation = personalityViewGeneration + const expected = JSON.parse(JSON.stringify(state.personalityProfiles?.[profileId]?.[category])) + state.personalityUpdating = { ...state.personalityUpdating, [updateKey]: true } + try { + if (uiContextPollInflight) await uiContextPollInflight + if (generation !== personalityViewGeneration || !window.location.pathname.startsWith("/device_settings") || state.values.IsOnroad || state.personalityMigrationRequired) return false + const response = await fetch("/api/personality_profiles", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ profile: profileId, category, preset, curve, expected }), + }) + const data = await response.json() + if (generation !== personalityViewGeneration || !window.location.pathname.startsWith("/device_settings")) { + state.personalityProfilesError = "Save confirmation was interrupted. Retry saved-state readback before editing." + return false + } + if (!response.ok) throw new Error(data.error || response.statusText || "Failed to save driving personality") + const currentConfig = state.personalityProfiles?.[profileId]?.[category] + const savedConfig = data.profiles?.[profileId]?.[category] + if (!currentConfig || !savedConfig || !Array.isArray(savedConfig.curve)) { + throw new Error("Driving personalities returned malformed data. Refresh the page to retry.") + } + const wasCustom = currentConfig.preset === "custom" + currentConfig.preset = savedConfig.preset + currentConfig.curve = [...savedConfig.curve] + if (!wasCustom && savedConfig.preset === "custom") { + state.personalityAdvancedExpanded = { + ...state.personalityAdvancedExpanded, + [profileId]: true, + } + } + state.personalityConfigured = !!data.configured + state.personalityEnabled = !!data.enabled + showParamSnackbar(successMessage || `${PERSONALITY_CATEGORY_DEFINITIONS[category].label} updated.`) + return true + } catch (error) { + if (generation === personalityViewGeneration && window.location.pathname.startsWith("/device_settings")) await recoverPersonalitySave() + else state.personalityProfilesError = "Save confirmation was interrupted. Retry saved-state readback before editing." + return false + } finally { + const next = { ...state.personalityUpdating } + delete next[updateKey] + state.personalityUpdating = next + } +} + +function updatePersonalityPreset(profileId, category, preset) { + const config = state.personalityProfiles?.[profileId]?.[category] + if (!config) return + const selectedPreset = String(preset || "") + if (!shouldSubmitPersonalityPreset(config.preset, selectedPreset)) return + + let curve = [] + if (selectedPreset === "custom") { + const referenceCurve = state.personalityReferenceCurves?.[profileId]?.[category] + const expectedLength = state.personalityMeta?.speedBreakpointsMph?.[category]?.length || 0 + if (!Array.isArray(referenceCurve) || referenceCurve.length !== expectedLength) { + showParamSnackbar("Profile reference graph is unavailable.", "error") + return + } + curve = [...referenceCurve] + } + + savePersonalityCategory( + profileId, category, selectedPreset, curve, + `${PERSONALITY_CATEGORY_DEFINITIONS[category].label} set to ${personalityPresetLabel(selectedPreset)}.`, + ) +} + +function resetPersonalityCurve(profileId, category) { + const referenceCurve = state.personalityReferenceCurves?.[profileId]?.[category] + if (!Array.isArray(referenceCurve)) { + showParamSnackbar("Profile reference graph is unavailable.", "error") + return + } + savePersonalityCategory( + profileId, category, "custom", [...referenceCurve], + `${PERSONALITY_CATEGORY_DEFINITIONS[category].label} graph reset to the ${profileId} profile reference.`, + ) +} + +function graphGeometry(category, curve, width = 660) { + const height = 240 + const speeds = state.personalityMeta?.speedBreakpointsMph?.[category] || [] + const editBounds = state.personalityMeta?.bounds?.[category] || [0, 1] + // Plot saved pre-limit values honestly; this must not widen authoring limits. + const bounds = [Number(editBounds[0]), Math.max(Number(editBounds[1]), ...curve.filter(Number.isFinite))] + const left = 46 + const right = 22 + const top = 18 + const bottom = 36 + const maximumSpeed = Math.max(1, Number(speeds[speeds.length - 1]) || 1) + const x = index => left + (Number(speeds[index]) / maximumSpeed) * (width - left - right) + const y = value => top + (Number(bounds[1]) - Number(value)) / (Number(bounds[1]) - Number(bounds[0])) * (height - top - bottom) + const points = curve.map((value, index) => `${x(index)},${y(value)}`) + return { speeds, bounds, width, height, left, right, top, bottom, x, y, points } +} + +function curveTicks(bounds) { + const minimum = Number(bounds[0]) + const maximum = Number(bounds[1]) + return [0, 1, 2, 3, 4].map(index => minimum + (maximum - minimum) * index / 4) +} + +function drawPersonalityCurve(canvas, category, curve, referenceCurve = [], geometry) { + if (!(canvas instanceof HTMLCanvasElement)) return + const definition = PERSONALITY_CATEGORY_DEFINITIONS[category] + const context = canvas.getContext("2d") + if (!context || !definition) return + + // Keep labels and points in CSS pixels instead of stretching a 660px bitmap. + geometry ||= graphGeometry(category, curve, canvas.clientWidth || 660) + const pixelRatio = window.devicePixelRatio || 1 + canvas.width = Math.round(geometry.width * pixelRatio) + canvas.height = Math.round(geometry.height * pixelRatio) + context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0) + + context.clearRect(0, 0, geometry.width, geometry.height) + context.lineWidth = 1 + context.strokeStyle = "rgba(148, 163, 184, 0.13)" + context.fillStyle = "#94a3b8" + context.font = "9px sans-serif" + + for (const tick of curveTicks(geometry.bounds)) { + const y = geometry.y(tick) + context.beginPath() + context.moveTo(geometry.left, y) + context.lineTo(geometry.width - geometry.right, y) + context.stroke() + context.fillText(Number(tick).toFixed(definition.step < 0.1 ? 2 : 1), 5, y + 3) + } + geometry.speeds.forEach((speed, index) => { + const x = geometry.x(index) + context.beginPath() + context.moveTo(x, geometry.top) + context.lineTo(x, geometry.height - geometry.bottom) + context.stroke() + context.textAlign = "center" + const labelStride = Math.max(1, Math.ceil(geometry.speeds.length * 32 / (geometry.width - geometry.left - geometry.right))) + const label = formatProfileSpeed(speed, !!state.values.IsMetric) + const lastIndex = geometry.speeds.length - 1 + const lastLabel = formatProfileSpeed(geometry.speeds[lastIndex], !!state.values.IsMetric) + const lastLabelLeft = geometry.x(lastIndex) - context.measureText(lastLabel).width / 2 + const clearsEndpoint = x + context.measureText(label).width / 2 + 6 <= lastLabelLeft + if (index === 0 || index === lastIndex || (index % labelStride === 0 && clearsEndpoint)) { + context.fillText(label, x, geometry.height - 13) + } + }) + context.font = "bold 9px sans-serif" + context.textAlign = "left" + context.fillText(definition.valueUnit, 5, 10) + context.textAlign = "right" + context.fillText(profileSpeedUnit(!!state.values.IsMetric), geometry.width - geometry.right, geometry.height - 2) + context.textAlign = "start" + + context.beginPath() + context.moveTo(geometry.left, geometry.height - geometry.bottom) + curve.forEach((value, index) => context.lineTo(geometry.x(index), geometry.y(value))) + context.lineTo(geometry.width - geometry.right, geometry.height - geometry.bottom) + context.closePath() + context.fillStyle = "rgba(56, 189, 248, 0.10)" + context.fill() + + if (Array.isArray(referenceCurve) && referenceCurve.length === curve.length) { + context.beginPath() + referenceCurve.forEach((value, index) => { + const x = geometry.x(index) + const y = geometry.y(value) + if (index === 0) context.moveTo(x, y) + else context.lineTo(x, y) + }) + context.setLineDash([8, 7]) + context.strokeStyle = "rgba(226, 232, 240, 0.34)" + context.lineWidth = 2 + context.stroke() + context.setLineDash([]) + } + + context.beginPath() + curve.forEach((value, index) => { + const x = geometry.x(index) + const y = geometry.y(value) + if (index === 0) context.moveTo(x, y) + else context.lineTo(x, y) + }) + context.strokeStyle = "#38bdf8" + context.lineCap = "round" + context.lineJoin = "round" + context.lineWidth = 3 + context.stroke() + + curve.forEach((value, index) => { + context.beginPath() + context.arc(geometry.x(index), geometry.y(value), 7, 0, Math.PI * 2) + context.fillStyle = "#07111d" + context.fill() + context.strokeStyle = "#7dd3fc" + context.lineWidth = 3 + context.stroke() + }) +} + +function updateDraggedCurveVisual(canvas, profileId, category, curve, geometry) { + const valueUnit = PERSONALITY_CATEGORY_DEFINITIONS[category]?.valueUnit || "" + drawPersonalityCurve(canvas, category, curve, state.personalityReferenceCurves?.[profileId]?.[category] || [], geometry) + curve.forEach((value, index) => { + const valueNode = document.getElementById(`personality-value-${profileId}-${category}-${index}`) + if (valueNode) valueNode.textContent = `${Number(value).toFixed(2)} ${valueUnit}` + }) +} + +function restorePersonalityCurveVisual(profileId, category, curve) { + const canvas = document.getElementById(`personality-chart-${profileId}-${category}`) + const valueUnit = PERSONALITY_CATEGORY_DEFINITIONS[category]?.valueUnit || "" + drawPersonalityCurve(canvas, category, curve, state.personalityReferenceCurves?.[profileId]?.[category] || []) + curve.forEach((value, index) => { + const formatted = Number(value).toFixed(2) + const input = document.getElementById(`personality-input-${profileId}-${category}-${index}`) + const valueNode = document.getElementById(`personality-value-${profileId}-${category}-${index}`) + if (input) input.value = formatted + if (valueNode) valueNode.textContent = `${formatted} ${valueUnit}` + }) +} + +function redrawVisiblePersonalityCurves() { + document.querySelectorAll(".ds-personality-curve canvas").forEach(canvas => { + if (!canvas.clientWidth || canvas.dataset.dragging) return + const { profile, category } = canvas.closest(".ds-personality-curve").dataset + const curve = state.personalityProfiles?.[profile]?.[category]?.curve + if (curve) drawPersonalityCurve(canvas, category, curve, state.personalityReferenceCurves?.[profile]?.[category] || []) + }) +} + +let personalityGraphResizeObserver +window.addEventListener("resize", () => requestAnimationFrame(redrawVisiblePersonalityCurves)) + +function beginPersonalityCurveDrag(event, profileId, category) { + const canvas = event?.currentTarget + const config = state.personalityProfiles?.[profileId]?.[category] + const bounds = state.personalityMeta?.bounds?.[category] + const definition = PERSONALITY_CATEGORY_DEFINITIONS[category] + if (state.personalityMigrationRequired || !(canvas instanceof HTMLCanvasElement) || !config || !bounds || !definition || state.personalityUpdating[personalityUpdateKey(profileId, category)]) return + + event.preventDefault() + const curve = [...config.curve] + const geometry = graphGeometry(category, curve, canvas.clientWidth || 660) + const chartRect = canvas.getBoundingClientRect() + const pointerX = (event.clientX - chartRect.left) * geometry.width / chartRect.width + let pointIndex = 0 + geometry.speeds.forEach((_speed, index) => { + if (Math.abs(geometry.x(index) - pointerX) < Math.abs(geometry.x(pointIndex) - pointerX)) pointIndex = index + }) + const plotRect = { + top: chartRect.top + chartRect.height * (geometry.top / geometry.height), + height: chartRect.height * ((geometry.height - geometry.top - geometry.bottom) / geometry.height), + } + const update = clientY => { + const value = valueFromPointer(clientY, plotRect, geometry.bounds[0], geometry.bounds[1], definition.step) + curve[pointIndex] = Math.max(Number(bounds[0]), Math.min(Number(bounds[1]), value)) + updateDraggedCurveVisual(canvas, profileId, category, curve, geometry) + } + const removeListeners = pointerEvent => { + delete canvas.dataset.dragging + canvas.removeEventListener("pointermove", move) + canvas.removeEventListener("pointerup", finish) + canvas.removeEventListener("pointercancel", cancel) + if (canvas.hasPointerCapture(pointerEvent.pointerId)) canvas.releasePointerCapture(pointerEvent.pointerId) + } + const finish = async pointerEvent => { + removeListeners(pointerEvent) + const saved = await savePersonalityCategory(profileId, category, "custom", curve, `${definition.label} graph updated.`) + if (!saved && !state.personalityProfilesError && window.location.pathname.startsWith("/device_settings")) restorePersonalityCurveVisual(profileId, category, state.personalityProfiles[profileId][category].curve) + } + const cancel = pointerEvent => { + removeListeners(pointerEvent) + updateDraggedCurveVisual(canvas, profileId, category, config.curve) + } + const move = pointerEvent => update(pointerEvent.clientY) + + canvas.dataset.dragging = "true" + canvas.setPointerCapture(event.pointerId) + canvas.addEventListener("pointermove", move) + canvas.addEventListener("pointerup", finish) + canvas.addEventListener("pointercancel", cancel) + update(event.clientY) +} + +function setPersonalityCurveError(profileId, category, message) { + const updateKey = personalityUpdateKey(profileId, category) + const nextErrors = { ...state.personalityCurveErrors } + if (message) nextErrors[updateKey] = message + else delete nextErrors[updateKey] + state.personalityCurveErrors = nextErrors +} + +async function adjustPersonalityCurvePoint(profileId, category, index, input) { + const config = state.personalityProfiles?.[profileId]?.[category] + const bounds = state.personalityMeta?.bounds?.[category] + const definition = PERSONALITY_CATEGORY_DEFINITIONS[category] + if (!config || !bounds || !definition || !input) return + + const raw = String(input.value ?? "").trim() + const parsed = input.valueAsNumber + if (!raw || !input.validity.valid || !Number.isFinite(parsed)) { + let message = `Enter a valid ${definition.label.toLowerCase()} value.` + if (!raw) message = `${definition.label} value is required.` + else if (input.validity.rangeUnderflow || input.validity.rangeOverflow) { + message = `${definition.label} must be from ${bounds[0]} to ${bounds[1]} ${definition.valueUnit}.` + } else if (input.validity.stepMismatch) { + message = `${definition.label} must use ${definition.step} ${definition.valueUnit} increments.` + } + setPersonalityCurveError(profileId, category, message) + return + } + + setPersonalityCurveError(profileId, category, "") + const curve = [...config.curve] + curve[index] = Number(parsed.toFixed(2)) + const saved = await savePersonalityCategory(profileId, category, "custom", curve, `${definition.label} graph updated.`) + if (!saved && !state.personalityProfilesError && window.location.pathname.startsWith("/device_settings")) restorePersonalityCurveVisual(profileId, category, state.personalityProfiles[profileId][category].curve) +} + +function renderPersonalityCurve(profile, category, config) { + const definition = PERSONALITY_CATEGORY_DEFINITIONS[category] + const geometry = graphGeometry(category, config.curve) + const editBounds = state.personalityMeta?.bounds?.[category] || [0, 1] + const updateKey = personalityUpdateKey(profile.id, category) + const referenceCurve = state.personalityReferenceCurves?.[profile.id]?.[category] || [] + const canvasId = `personality-chart-${profile.id}-${category}` + requestAnimationFrame(() => drawPersonalityCurve( + document.getElementById(canvasId), category, config.curve, referenceCurve, + )) + + return html` +
+
+
+

Custom ${definition.label}

+
+
+ +
+
+ ${config.curve.some(value => value > Number(editBounds[1])) ? html` +

Saved values above ${editBounds[1]} ${definition.valueUnit} are preserved. Edited points must be within ${editBounds[0]}–${editBounds[1]} ${definition.valueUnit}; other points stay unchanged.

+ ` : ""} +
+ +
+ ${config.curve.map((value, index) => html` + + `)} +
+
+ +
Default
+
+ ` +} + +function renderPersonalityCategoryField(profile, category, config) { + const definition = PERSONALITY_CATEGORY_DEFINITIONS[category] + const availableOptions = new Set((state.personalityMeta?.options?.[category] || []).filter(option => option !== "dom_default")) + const options = (PERSONALITY_OPTION_ORDER[category] || []).filter(option => availableOptions.has(option)) + const updateKey = personalityUpdateKey(profile.id, category) + return html` +
+

${definition.label}

+
+ ${options.map(option => html` + + `)} +
+ ${() => config.preset === "dom_default" ? html`Using existing StarPilot defaults.` : ""} +
+ ` +} + +function renderPersonalityProfileToggle(profile) { + const param = state.paramMetaByKey[personalityProfileParamKey(profile.id)] + if (!param) return "" + const lockReason = () => getSettingLockReason(param) + return html` +
+
+
+ ${() => { + const reason = lockReason() + return reason ? html`
Locked: ${reason}
` : "" + }} +
+
+ +
+ ` +} + +function personalityAdvancedMode(key) { + if (state.personalityAdvancedCustomOpen[key]) return "custom" + const value = Number(state.values[key]) + if (value === 100) return "standard" + if (!key.endsWith("JerkDanger") && value === 50) return "chill" + return "custom" +} + +function personalityAdvancedOptions(key) { + if (key.endsWith("JerkDanger")) { + return [["standard", "Standard"], ["custom", "Custom"]] + } + return [["chill", "Chill"], ["standard", "Standard"], ["custom", "Custom"]] +} + +function updatePersonalityAdvancedPreset(param, mode) { + if (state.values.IsOnroad || state.numericUpdating[param.key]) return + if (mode === "custom") { + state.personalityAdvancedCustomOpen = { ...state.personalityAdvancedCustomOpen, [param.key]: true } + return + } + const nextOpen = { ...state.personalityAdvancedCustomOpen } + delete nextOpen[param.key] + state.personalityAdvancedCustomOpen = nextOpen + updateNumericParam(param, mode === "chill" ? 50 : 100) +} + +function renderPersonalityAdvancedValue(profile, param) { + const bounds = numericBounds(param) + return html` +
+
+ ${param.label} + ${param.description ? html`${param.description}` : ""} +
+
+
+ ${personalityAdvancedOptions(param.key).map(([mode, label]) => html` + + `)} +
+ +
+
+ ` +} + +function renderPersonalityAdvancedRows(profile, config) { + const rows = (PERSONALITY_ADVANCED_KEYS[profile.id] || []) + .map(key => state.paramMetaByKey[key]) + .filter(Boolean) + .map(param => renderPersonalityAdvancedValue(profile, param)) + return html` + + ` +} + +function renderPersonalityAdvanced(profile, config) { + return html` +
+ + ${renderPersonalityAdvancedRows(profile, config)} +
+ ` +} + +function renderPersonalityCardSnapshot(profile) { + const config = state.personalityProfiles?.[profile.id] + if (!config) return "" + return html` +
+
+ + + ${profile.label} + + ${renderPersonalityProfileToggle(profile)} +
+
+ + +
+
+ ` +} + +function renderPersonalityCard(profile) { + return html`${() => renderPersonalityCardSnapshot(profile)}` +} + +function renderPersonalityProfilesPanel() { + if (state.personalityProfilesLoading) return html`
Loading driving personalities...
` + if (state.personalityProfilesError) return html`` + if (!state.personalityMeta) return html`` + return html` +
+ ${() => state.personalityMigrationRequired ? html` + + ` : ""} + ${PERSONALITY_DEFINITIONS.map(renderPersonalityCard)} +
+ ` +} + function renderFavoriteSlotsPanel() { if (state.favoriteLoading) { return html`
Loading favorite slots...
` @@ -1743,11 +2596,14 @@ function renderSettingRow(p) { ` : ""} - ${() => p.is_parent_toggle && isParamEnabledForChildren(p) ? html` -
+ ${() => p.is_parent_toggle && (p.key === "CustomPersonalities" || isParamEnabledForChildren(p)) ? html` +
+ + ` : ""} ${(isNumeric || isColor || isReadout) ? html`${() => { @@ -1775,10 +2631,15 @@ function renderSettingTree(paramsList, parentKey = null) { const row = renderSettingRow(param) if (row) rendered.push(row) + if (param.key === "CustomPersonalities" && state.expanded[param.key]) { + rendered.push(renderPersonalityProfilesPanel()) + } + if (param.key === "CustomPersonalities") continue + if (!hasChildParams(paramsList, param.key)) continue if (!isParamEnabledForChildren(param) || !state.expanded[param.key]) continue - rendered.push(...renderSettingTree(paramsList, param.key)) + rendered.push(html`
${() => renderSettingTree(paramsList, param.key)}
`) } return rendered @@ -1802,11 +2663,23 @@ function resolveActiveSectionSlug(params) { } export function DeviceSettings({ params }) { + personalityViewGeneration += 1 + if (Object.keys(state.personalityUpdating).length) state.personalityProfilesError = "Save in progress. Retry saved-state readback when it finishes." lastParams = params + requestAnimationFrame(() => { + personalityGraphResizeObserver?.disconnect() + const wrapper = document.querySelector(".ds-wrapper") + if (wrapper && typeof ResizeObserver !== "undefined") { + personalityGraphResizeObserver = new ResizeObserver(redrawVisiblePersonalityCurves) + personalityGraphResizeObserver.observe(wrapper) + } + }) + fetchFlmWorkspace() ensureFavoriteValuePolling() ensureCscCalibrationPolling() + ensureUiContextPolling() if (!state.fetched) { state.fetched = true @@ -1820,6 +2693,11 @@ export function DeviceSettings({ params }) {

Toggles

+
+ + Vehicle-unit speed settings use ${() => vehicleSpeedUnit(state.values)} and follow the comma's Use Metric System toggle. Each control shows its adjustment step. +
+
{ 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 { diff --git a/starpilot/system/the_galaxy/assets/mobile/css/material.css b/starpilot/system/the_galaxy/assets/mobile/css/material.css index 1949ab8d20..488f3b09e2 100644 --- a/starpilot/system/the_galaxy/assets/mobile/css/material.css +++ b/starpilot/system/the_galaxy/assets/mobile/css/material.css @@ -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; diff --git a/starpilot/system/the_galaxy/assets/mobile/js/api.js b/starpilot/system/the_galaxy/assets/mobile/js/api.js index a7c38b7cfb..555c5237cd 100644 --- a/starpilot/system/the_galaxy/assets/mobile/js/api.js +++ b/starpilot/system/the_galaxy/assets/mobile/js/api.js @@ -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)" diff --git a/starpilot/system/the_galaxy/assets/mobile/js/components/PersonalityProfiles.js b/starpilot/system/the_galaxy/assets/mobile/js/components/PersonalityProfiles.js new file mode 100644 index 0000000000..e61e0b2c89 --- /dev/null +++ b/starpilot/system/the_galaxy/assets/mobile/js/components/PersonalityProfiles.js @@ -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: ` +
+
+
Driving PersonalitiesAcceleration, braking and following for each driving style.
+ +
+ +
+ + +

{{ notice }}

+

Saving…

+

Loading profiles…

+ +
+
+ `, +} diff --git a/starpilot/system/the_galaxy/assets/mobile/js/components/SettingTree.js b/starpilot/system/the_galaxy/assets/mobile/js/components/SettingTree.js index 8c95c4deec..0d04da9118 100644 --- a/starpilot/system/the_galaxy/assets/mobile/js/components/SettingTree.js +++ b/starpilot/system/the_galaxy/assets/mobile/js/components/SettingTree.js @@ -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: `