diff --git a/common/libcommon.a b/common/libcommon.a index 4d8ac4398..b566e9dad 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 a8bf00c3a..eab732600 100644 --- a/common/params_keys.h +++ b/common/params_keys.h @@ -126,6 +126,7 @@ inline static std::unordered_map keys = { {"SecOCKey", {PERSISTENT | DONT_LOG, STRING}}, {"ShowDebugInfo", {PERSISTENT, BOOL}}, {"ShowAllToggles", {PERSISTENT, BOOL, "0", "0", 3}}, + {"TryRaylibUI", {PERSISTENT, BOOL, "0"}}, {"UsePrebuilt", {PERSISTENT, BOOL, "1"}}, {"RouteCount", {PERSISTENT, INT, "0"}}, {"SnoozeUpdate", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, BOOL}}, diff --git a/common/params_pyx.so b/common/params_pyx.so index 3a74bb864..194c25da5 100755 Binary files a/common/params_pyx.so and b/common/params_pyx.so differ diff --git a/starpilot/system/the_galaxy/assets/components/tools/device_settings_layout.json b/starpilot/system/the_galaxy/assets/components/tools/device_settings_layout.json index 19a1951e1..3c6f47bcf 100644 --- a/starpilot/system/the_galaxy/assets/components/tools/device_settings_layout.json +++ b/starpilot/system/the_galaxy/assets/components/tools/device_settings_layout.json @@ -3632,6 +3632,13 @@ "name": "Developer", "icon": "bi-exclamation-triangle", "params": [ + { + "key": "TryRaylibUI", + "label": "Try raylib UI", + "description": "Use the beta raylib UI instead of the default Qt UI on tici/tizi devices. This setting has no effect on C4/mici devices.", + "data_type": "bool", + "ui_type": "toggle" + }, { "key": "DisableWideRoad", "label": "Disable Wide Road Camera", diff --git a/starpilot/system/the_galaxy/tests/test_navigation_params.py b/starpilot/system/the_galaxy/tests/test_navigation_params.py index 849b0edd1..04e3a7139 100644 --- a/starpilot/system/the_galaxy/tests/test_navigation_params.py +++ b/starpilot/system/the_galaxy/tests/test_navigation_params.py @@ -43,6 +43,43 @@ class FakeParamsBackend: return self.values.get(key) +class WritableFakeParams: + def __init__(self, values=None): + self.values = dict(values or {}) + self.writes = [] + + def get(self, key, encoding=None, default=None, block=False): + del encoding, block + return self.values.get(key, default) + + def get_bool(self, key): + value = self.values.get(key, False) + if isinstance(value, bool): + return value + return str(value).strip().lower() in ("1", "true", "yes", "on") + + def put(self, key, value): + self.writes.append((key, value)) + self.values[key] = value + + def put_bool(self, key, value): + self.writes.append((key, bool(value))) + self.values[key] = bool(value) + + +def _params_client(monkeypatch, values, device_type): + fake_params = WritableFakeParams(values) + monkeypatch.setattr(the_galaxy, "params", fake_params) + monkeypatch.setattr(the_galaxy, "_get_param_type_info", lambda: ({"TryRaylibUI"}, {"TryRaylibUI": bool})) + monkeypatch.setattr(the_galaxy.HARDWARE, "get_device_type", lambda: device_type) + monkeypatch.setattr(the_galaxy.Paths, "comma_home", lambda: "/tmp/dashboard-test-home", raising=False) + + assert the_galaxy._import_galaxy_web_symbols() + app = the_galaxy.Flask(f"params_test_{device_type}") + the_galaxy.setup(app) + return app.test_client(), fake_params + + def test_params_compat_accepts_json_strings_for_json_keys(): backend = FakeParamsBackend( key_types={"FavoriteDestinations": ParamKeyType.JSON}, @@ -117,3 +154,38 @@ def test_galaxy_session_value_matches_cookie_format(): "testGalaxySlug01", "a" * 64, ) == f"testGalaxySlug01%3A{'a' * 64}" + + +def test_try_raylib_ui_is_noop_on_c4_mici(monkeypatch): + client, fake_params = _params_client(monkeypatch, {"TryRaylibUI": False, "IsOnroad": False}, "mici") + + response = client.put("/api/params", json={"key": "TryRaylibUI", "value": True}) + payload = response.get_json() + + assert response.status_code == 200 + assert payload["updated"] == {"TryRaylibUI": False} + assert fake_params.values["TryRaylibUI"] is False + assert fake_params.writes == [] + + +def test_try_raylib_ui_writes_on_big_device_offroad(monkeypatch): + client, fake_params = _params_client(monkeypatch, {"TryRaylibUI": False, "IsOnroad": False}, "tici") + + response = client.put("/api/params", json={"key": "TryRaylibUI", "value": True}) + payload = response.get_json() + + assert response.status_code == 200 + assert payload["updated"] == {"TryRaylibUI": True} + assert fake_params.values["TryRaylibUI"] is True + assert fake_params.writes == [("TryRaylibUI", True)] + + +def test_try_raylib_ui_rejects_big_device_onroad_change(monkeypatch): + client, fake_params = _params_client(monkeypatch, {"TryRaylibUI": False, "IsOnroad": True}, "tici") + + response = client.put("/api/params", json={"key": "TryRaylibUI", "value": True}) + + assert response.status_code == 403 + assert response.get_json()["error"] == "Cannot change Try raylib UI while driving." + assert fake_params.values["TryRaylibUI"] is False + assert fake_params.writes == [] diff --git a/starpilot/system/the_galaxy/the_galaxy.py b/starpilot/system/the_galaxy/the_galaxy.py index 913bfaed4..de83632b8 100644 --- a/starpilot/system/the_galaxy/the_galaxy.py +++ b/starpilot/system/the_galaxy/the_galaxy.py @@ -147,6 +147,13 @@ def _is_comma_device_runtime() -> bool: return False +def _raylib_ui_toggle_affects_device() -> bool: + try: + return HARDWARE.get_device_type() in ("tici", "tizi") + except Exception: + return False + + def _get_param_key_type(params_obj, key): getter = getattr(params_obj, "get_key_type", None) if getter is None: @@ -4163,6 +4170,24 @@ def setup(app): if key not in allowed_keys: return jsonify({"error": f"Parameter '{key}' is not editable."}), 403 + if key == "TryRaylibUI": + enabled = str_val.strip() in ("1", "true", "True") + if not _raylib_ui_toggle_affects_device(): + current_enabled = params.get_bool("TryRaylibUI") + return jsonify({ + "message": "Try raylib UI is only available on tici/tizi devices.", + "updated": {"TryRaylibUI": current_enabled}, + }), 200 + + if params.get_bool("IsOnroad"): + return jsonify({"error": "Cannot change Try raylib UI while driving."}), 403 + + params.put_bool("TryRaylibUI", enabled) + return jsonify({ + "message": f"{'Raylib' if enabled else 'Qt'} UI selected. UI will restart shortly.", + "updated": {"TryRaylibUI": enabled}, + }), 200 + # 1. Prevent changing the model or reboot-required toggles while the car is actively driving reboot_keys = {"Model", "DrivingModel", "AlwaysOnLateral", "DisableOpenpilotLongitudinal", "ForceTorqueController", "NNFF", "NNFFLite"} if key in reboot_keys and params.get_bool("IsOnroad"): diff --git a/system/manager/process_config.py b/system/manager/process_config.py index 6f6bee01b..2dd15c05b 100644 --- a/system/manager/process_config.py +++ b/system/manager/process_config.py @@ -1,6 +1,7 @@ import os import operator import platform +import sys from types import SimpleNamespace @@ -83,6 +84,86 @@ def run_speed_limit_vision(started: bool, params: Params, CP: car.CarParams, sta def run_navigationd(started: bool, params: Params, CP: car.CarParams, starpilot_toggles: SimpleNamespace) -> bool: return started and params.get("NavDestination") is not None + +class BigDeviceUIProcess: + name = "ui" + enabled = True + sigkill = False + daemon = False + + def __init__(self, should_run, watchdog_max_dt=None): + self.should_run_fn = should_run + self.watchdog_max_dt = watchdog_max_dt + self._started = False + self._params = None + self._active_process = None + self._qt_process = NativeProcess("ui", "selfdrive/ui", ["./ui"], should_run, watchdog_max_dt=watchdog_max_dt) + self._raylib_process = NativeProcess( + "ui", + ".", + ["/usr/bin/env", "BIG=1", sys.executable, "-m", "openpilot.selfdrive.ui.ui"], + should_run, + watchdog_max_dt=watchdog_max_dt, + ) + + @property + def proc(self): + return self._active_process.proc if self._active_process is not None else None + + @property + def shutting_down(self): + return self._active_process.shutting_down if self._active_process is not None else False + + def prepare(self) -> None: + self._qt_process.prepare() + + def should_run(self, started: bool, params: Params, CP: car.CarParams, starpilot_toggles: SimpleNamespace) -> bool: + self._started = started + self._params = params + return self.should_run_fn(started, params, CP, starpilot_toggles) + + def _desired_process(self): + return self._raylib_process if self._params is not None and self._params.get_bool("TryRaylibUI") else self._qt_process + + def start(self) -> None: + desired_process = self._desired_process() + + # Never swap UI implementations mid-drive. Direct param writes while onroad + # take effect the next time the device is offroad. + if self._started and self._active_process is not None and self._active_process is not desired_process: + desired_process = self._active_process + + if self._active_process is not None and self._active_process is not desired_process: + self._active_process.stop() + + for process in (self._qt_process, self._raylib_process): + if process is not desired_process and process.proc is not None: + process.stop() + + self._active_process = desired_process + self._active_process.start() + + def stop(self, retry: bool = True, block: bool = True, sig=None): + ret = None + for process in (self._qt_process, self._raylib_process): + process_ret = process.stop(retry=retry, block=block, sig=sig) + if process is self._active_process: + ret = process_ret + return ret + + def restart(self) -> None: + self.stop() + self.start() + + def check_watchdog(self, started: bool) -> None: + if self._active_process is not None: + self._active_process.check_watchdog(started) + + def get_process_state_msg(self): + process = self._active_process or self._qt_process + return process.get_process_state_msg() + + procs = [ DaemonProcess("manage_athenad", "system.athena.manage_athenad", "AthenadPid"), @@ -145,9 +226,9 @@ procs += [ device_type = HARDWARE.get_device_type() if device_type in ("tici", "tizi"): - procs.append(NativeProcess("ui", "selfdrive/ui", ["./ui"], always_run, watchdog_max_dt=UI_WATCHDOG_MAX_DT)) + procs.append(BigDeviceUIProcess(always_run, watchdog_max_dt=UI_WATCHDOG_MAX_DT)) else: - # C4 (mici) runs the Python raylib UI path. + # C4 (mici) already runs the Python raylib UI path; TryRaylibUI must not affect it. procs.append(PythonProcess("ui", "selfdrive.ui.ui", always_run, watchdog_max_dt=UI_WATCHDOG_MAX_DT)) procs += [ diff --git a/system/manager/test/test_manager.py b/system/manager/test/test_manager.py index fb6447de2..dd5f3e582 100644 --- a/system/manager/test/test_manager.py +++ b/system/manager/test/test_manager.py @@ -4,12 +4,13 @@ import signal import time from pathlib import Path import json +from types import SimpleNamespace from cereal import car from openpilot.common.params import Params import openpilot.system.manager.manager as manager from openpilot.system.manager.process import ensure_running -from openpilot.system.manager.process_config import managed_processes, procs +from openpilot.system.manager.process_config import BigDeviceUIProcess, managed_processes, procs from openpilot.system.hardware import HARDWARE os.environ['FAKEUPLOAD'] = "1" @@ -71,6 +72,40 @@ class FileBackedFakeParams: self.put(key, float(value)) +class FakeManagedProcess: + def __init__(self): + self.proc = None + self.shutting_down = False + self.starts = 0 + self.stops = 0 + + def prepare(self): + pass + + def start(self): + if self.proc is not None: + return + + self.starts += 1 + self.shutting_down = False + self.proc = SimpleNamespace(exitcode=None, pid=self.starts, is_alive=lambda: True) + + def stop(self, retry=True, block=True, sig=None): + if self.proc is None: + return None + + self.stops += 1 + self.shutting_down = False + self.proc = None + return 0 + + def check_watchdog(self, started): + pass + + def get_process_state_msg(self): + return SimpleNamespace(name="ui") + + class TestManager: def setup_method(self): HARDWARE.set_power_save(False) @@ -96,6 +131,34 @@ class TestManager: assert names.index("the_galaxy") < ui_idx assert names.index("galaxy") < ui_idx + def test_big_device_ui_process_swaps_offroad_only(self, tmp_path): + ui_process = BigDeviceUIProcess(lambda *args: True) + qt_process = FakeManagedProcess() + raylib_process = FakeManagedProcess() + ui_process._qt_process = qt_process + ui_process._raylib_process = raylib_process + + params = FileBackedFakeParams(tmp_path / "params", {"TryRaylibUI": False}) + + assert ui_process.should_run(False, params, car.CarParams.new_message(), SimpleNamespace()) + ui_process.start() + assert ui_process.proc is qt_process.proc + assert qt_process.starts == 1 + assert raylib_process.starts == 0 + + params.put_bool("TryRaylibUI", True) + assert ui_process.should_run(True, params, car.CarParams.new_message(), SimpleNamespace()) + ui_process.start() + assert ui_process.proc is qt_process.proc + assert qt_process.stops == 0 + assert raylib_process.starts == 0 + + assert ui_process.should_run(False, params, car.CarParams.new_message(), SimpleNamespace()) + ui_process.start() + assert qt_process.stops == 1 + assert raylib_process.starts == 1 + assert ui_process.proc is raylib_process.proc + def test_blacklisted_procs(self): # TODO: ensure there are blacklisted procs until we have a dedicated test assert len(BLACKLIST_PROCS), "No blacklisted procs to test not_run"