From 0b5ccb31e1de67afd8b1c5a9b31cd243d2a672ad Mon Sep 17 00:00:00 2001 From: firestar5683 <168790843+firestar5683@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:52:51 -0500 Subject: [PATCH] The Rice Cake --- common/params_keys.h | 2 +- .../opendbc/car/subaru/carcontroller.py | 4 +- opendbc_repo/opendbc/car/subaru/interface.py | 2 +- .../opendbc/car/subaru/tests/test_subaru.py | 7 +- .../opendbc/safety/tests/test_subaru.py | 12 ++ scripts/host_tool_runner.sh | 9 +- selfdrive/car/cruise.py | 12 +- selfdrive/car/tests/test_cruise_speed.py | 61 +++++++++- .../controls/lib/latcontrol_vehicle_tunes.py | 13 +- .../controls/lib/longcontrol_vehicle_tunes.py | 20 +++- selfdrive/controls/tests/test_latcontrol.py | 2 +- selfdrive/controls/tests/test_longcontrol.py | 9 ++ selfdrive/ui/mici/layouts/settings/visuals.py | 3 + selfdrive/ui/mici/onroad/model_renderer.py | 38 +++++- .../ui/mici/tests/test_lead_indicator.py | 47 +++++++- starpilot/assets/model_manager.py | 7 +- starpilot/assets/tests/test_model_pipeline.py | 5 +- starpilot/system/bluetooth/bluez.py | 4 +- starpilot/system/bluetooth/daemon.py | 98 +++++++++------ .../system/bluetooth/tests/test_bluetooth.py | 52 +++++++- .../the_galaxy/assets/components/router.js | 2 +- .../components/tools/model_laboratory.css | 5 + .../components/tools/model_laboratory.js | 112 +++++++++++------- .../system/the_galaxy/assets/mobile/js/api.js | 1 + .../assets/mobile/js/views/ModelLaboratory.js | 106 ++++++++++------- .../system/the_galaxy/templates/index.html | 2 +- .../the_galaxy/tests/test_dashboard_stats.py | 36 +++++- .../tests/test_frontend_module_graph.py | 13 +- starpilot/system/the_galaxy/the_galaxy.py | 80 +++++++++---- 29 files changed, 579 insertions(+), 185 deletions(-) diff --git a/common/params_keys.h b/common/params_keys.h index ba07a4f82..4bb275e69 100644 --- a/common/params_keys.h +++ b/common/params_keys.h @@ -464,7 +464,7 @@ inline static std::unordered_map keys = { {"LeadDepartingAlert", {PERSISTENT, BOOL, "0", "0", 0, SETTINGS_SIMPLE}}, {"LeadDetectionThreshold", {PERSISTENT, INT, "35", "50", 3}}, {"LeadIndicator", {PERSISTENT, BOOL, "0", "0", 2, SETTINGS_SIMPLE}}, - {"LeadInfo", {PERSISTENT, BOOL, "1", "0", 3}}, + {"LeadInfo", {PERSISTENT, BOOL, "0", "0", 3}}, {"LKASButtonControl", {PERSISTENT, INT, "5", "0", 2, SETTINGS_SIMPLE}}, {"LockDoors", {PERSISTENT, BOOL, "1", "0", 0}}, {"LockDoorsTimer", {PERSISTENT, INT, "0", "0", 0}}, diff --git a/opendbc_repo/opendbc/car/subaru/carcontroller.py b/opendbc_repo/opendbc/car/subaru/carcontroller.py index 7724e1ce5..3a5f8aa04 100644 --- a/opendbc_repo/opendbc/car/subaru/carcontroller.py +++ b/opendbc_repo/opendbc/car/subaru/carcontroller.py @@ -77,7 +77,7 @@ class CarController(CarControllerBase): self.angle_bus = CanBus.angle_for_cp(CP) self.status_bus = CanBus.camera if CP.flags & SubaruFlags.D_PLATFORM_CAMERA else CanBus.main - if CP.flags & SubaruFlags.LKAS_ANGLE: + if CP.flags & SubaruFlags.LKAS_ANGLE and CP.carFingerprint != CAR.SUBARU_OUTBACK_2023: self.VM = VehicleModel(get_safety_CP()) self.prev_close_distance = 0 @@ -332,7 +332,7 @@ class CarController(CarControllerBase): self.apply_steer_last = CS.out.steeringAngleDeg steer_target = self._angle_reclaim_target(CC.actuators.steeringAngleDeg) if lkas_active else CC.actuators.steeringAngleDeg - if self.CP.carFingerprint == CAR.SUBARU_ASCENT_2023: + if self.CP.carFingerprint in (CAR.SUBARU_ASCENT_2023, CAR.SUBARU_OUTBACK_2023): apply_steer = apply_std_steer_angle_limits( steer_target, self.apply_steer_last, diff --git a/opendbc_repo/opendbc/car/subaru/interface.py b/opendbc_repo/opendbc/car/subaru/interface.py index 741551a0a..3e6b44115 100644 --- a/opendbc_repo/opendbc/car/subaru/interface.py +++ b/opendbc_repo/opendbc/car/subaru/interface.py @@ -42,7 +42,7 @@ class CarInterface(CarInterfaceBase): ret.safetyConfigs[0].safetyParam |= SubaruSafetyFlags.D_PLATFORM_CAMERA.value if candidate in SUBARU_STOP_START_CARS: ret.safetyConfigs[0].safetyParam |= SubaruSafetyFlags.STOP_START_BUTTON.value - if candidate in (CAR.SUBARU_LEGACY_2025, CAR.SUBARU_ASCENT_2023): + if candidate in (CAR.SUBARU_LEGACY_2025, CAR.SUBARU_ASCENT_2023, CAR.SUBARU_OUTBACK_2023): ret.safetyConfigs[0].safetyParam |= SubaruSafetyFlags.FIXED_ANGLE_LIMITS.value ret.steerLimitTimer = 0.4 diff --git a/opendbc_repo/opendbc/car/subaru/tests/test_subaru.py b/opendbc_repo/opendbc/car/subaru/tests/test_subaru.py index 454a8a93a..983aa42fa 100644 --- a/opendbc_repo/opendbc/car/subaru/tests/test_subaru.py +++ b/opendbc_repo/opendbc/car/subaru/tests/test_subaru.py @@ -244,7 +244,7 @@ def test_outback_2023_uses_d_platform_bus_layout(): assert CP.flags & SubaruFlags.D_PLATFORM assert CP.safetyConfigs[0].safetyParam & SubaruSafetyFlags.D_PLATFORM assert CP.safetyConfigs[0].safetyParam & SubaruSafetyFlags.STOP_START_BUTTON - assert not (CP.safetyConfigs[0].safetyParam & SubaruSafetyFlags.LEGACY_2025_ANGLE_LIMITS) + assert CP.safetyConfigs[0].safetyParam & SubaruSafetyFlags.FIXED_ANGLE_LIMITS assert CanBus.main_for_cp(CP) == CanBus.alt assert CanBus.angle_for_cp(CP) == CanBus.main assert parsers[Bus.pt].bus == CanBus.alt @@ -622,8 +622,9 @@ def test_angle_controller_blocks_low_speed_mads_engagement(): assert parser.vl["ES_LKAS_ANGLE"]["LKAS_Request"] == 1 -def test_ascent_angle_controller_uses_fixed_angle_rate_limits(): - CP = CarInterface.get_non_essential_params(CAR.SUBARU_ASCENT_2023) +@pytest.mark.parametrize("platform", (CAR.SUBARU_ASCENT_2023, CAR.SUBARU_OUTBACK_2023)) +def test_angle_controller_uses_fixed_angle_rate_limits(platform): + CP = CarInterface.get_non_essential_params(platform) controller = CarController({}, CP) CC = SimpleNamespace(enabled=True, latActive=True, actuators=SimpleNamespace(steeringAngleDeg=-14.88)) CS = SimpleNamespace(out=SimpleNamespace( diff --git a/opendbc_repo/opendbc/safety/tests/test_subaru.py b/opendbc_repo/opendbc/safety/tests/test_subaru.py index 826c1d3c2..458f50da0 100755 --- a/opendbc_repo/opendbc/safety/tests/test_subaru.py +++ b/opendbc_repo/opendbc/safety/tests/test_subaru.py @@ -417,6 +417,18 @@ class TestSubaruDPlatformAngleSafety(TestSubaruStockLongitudinalSafetyBase, Test return self.packer.make_can_msg_safety("Steering_2", SUBARU_MAIN_BUS, {"Steering_Angle": angle}) +class TestSubaruDPlatformFixedAngleSafety(TestSubaruDPlatformAngleSafety): + FLAGS = SubaruSafetyFlags.GEN2 | SubaruSafetyFlags.LKAS_ANGLE | SubaruSafetyFlags.D_PLATFORM | \ + SubaruSafetyFlags.FIXED_ANGLE_LIMITS + STEER_ANGLE_MAX = 545 + ANGLE_RATE_BP = [0., 5., 35.] + ANGLE_RATE_UP = [5., .8, .15] + ANGLE_RATE_DOWN = [5., .8, .15] + + def test_rt_limits(self): + raise unittest.SkipTest("Breakpoint angle limits do not enforce a real-time message frequency") + + class TestSubaruDPlatformStopStartSafety(TestSubaruDPlatformAngleSafety): FLAGS = SubaruSafetyFlags.GEN2 | SubaruSafetyFlags.LKAS_ANGLE | SubaruSafetyFlags.D_PLATFORM | \ SubaruSafetyFlags.STOP_START_BUTTON diff --git a/scripts/host_tool_runner.sh b/scripts/host_tool_runner.sh index e4537d83d..ad52f5cb4 100755 --- a/scripts/host_tool_runner.sh +++ b/scripts/host_tool_runner.sh @@ -284,19 +284,22 @@ ensure_host_python_extensions() { } sync_host_generated_headers() { - if ! command -v capnpc >/dev/null 2>&1; then + local capnpc="${ROOT_DIR}/.venv/bin/capnpc" + local capnpc_cpp + capnpc_cpp="$(find "${ROOT_DIR}/.venv/lib" -path '*/capnproto/install/bin/capnpc-c++' -type f -print -quit)" + if [[ ! -x "${capnpc}" || ! -x "${capnpc_cpp}" ]]; then return fi ( cd "${WORK_DIR}" mkdir -p cereal/gen/cpp - capnpc --src-prefix=cereal \ + "${capnpc}" --src-prefix=cereal \ cereal/log.capnp \ cereal/car.capnp \ cereal/legacy.capnp \ cereal/custom.capnp \ - -o c++:cereal/gen/cpp/ + -o "${capnpc_cpp}:cereal/gen/cpp/" ) } diff --git a/selfdrive/car/cruise.py b/selfdrive/car/cruise.py index 42c7f2562..36a3c151b 100644 --- a/selfdrive/car/cruise.py +++ b/selfdrive/car/cruise.py @@ -72,7 +72,11 @@ class VCruiseHelper: return short_interval, long_interval def _uses_software_cruise(self) -> bool: - return bool(self.gm_cc_only or self.redneck_non_pcm or not self.CP.pcmCruise) + # Some cars, including Toyota TSS2, keep pcmCruise enabled while + # openpilot owns longitudinal control. In that case the software cruise + # target must be used so custom short/hold intervals are honored. + return bool(self.gm_cc_only or self.redneck_non_pcm or not self.CP.pcmCruise or + getattr(self.CP, "openpilotLongitudinalControl", False)) @property def v_cruise_initialized(self): @@ -225,6 +229,12 @@ class VCruiseHelper: self.v_cruise_kph = float(np.clip(initialized_speed_limit_kph, V_CRUISE_MIN, V_CRUISE_MAX)) elif self.redneck_non_pcm and CS.cruiseState.speedCluster > 0: self.v_cruise_kph = float(np.clip(CS.cruiseState.speedCluster * CV.MS_TO_KPH, V_CRUISE_MIN, V_CRUISE_MAX)) + elif self.CP.pcmCruise and CS.cruiseState.speed > 0: + # Keep PCM/dash set speed as the starting target when software cruise + # takes over, while allowing subsequent button presses to use custom + # intervals. This preserves Toyota's stock engage behavior. + pcm_speed_kph = CS.cruiseState.speed * CV.MS_TO_KPH + self.v_cruise_kph = float(np.clip(pcm_speed_kph, V_CRUISE_MIN, V_CRUISE_MAX)) else: self.v_cruise_kph = int(round(np.clip(CS.vEgo * CV.MS_TO_KPH, engage_floor_kph, V_CRUISE_MAX))) diff --git a/selfdrive/car/tests/test_cruise_speed.py b/selfdrive/car/tests/test_cruise_speed.py index 00f8f4f27..fc4f7f90e 100644 --- a/selfdrive/car/tests/test_cruise_speed.py +++ b/selfdrive/car/tests/test_cruise_speed.py @@ -498,9 +498,8 @@ class TestVCruiseHelper: assert self.v_cruise_helper.v_cruise_kph == pytest.approx(initial_v_cruise_kph + IMPERIAL_INCREMENT) - @pytest.mark.parametrize("openpilot_longitudinal", [False, True]) - def test_pcm_cruise_uses_pcm_speed(self, openpilot_longitudinal): - CP = car.CarParams(pcmCruise=True, openpilotLongitudinalControl=openpilot_longitudinal) + def test_pcm_cruise_uses_pcm_speed(self): + CP = car.CarParams(pcmCruise=True, openpilotLongitudinalControl=False) helper = VCruiseHelper(CP) toggles = SimpleNamespace(cruise_increase=5, cruise_increase_long=1, set_speed_limit=False) pcm_speed_kph = 72.0 @@ -533,6 +532,62 @@ class TestVCruiseHelper: helper.update_v_cruise(next_cs, True, True, False, toggles) assert helper.v_cruise_kph == pytest.approx(next_pcm_speed_kph) + def test_openpilot_longitudinal_pcm_cruise_uses_custom_intervals(self): + CP = car.CarParams(pcmCruise=True, openpilotLongitudinalControl=True) + helper = VCruiseHelper(CP) + toggles = SimpleNamespace(cruise_increase=5, cruise_increase_long=1, set_speed_limit=False) + initial_speed_kph = 40.0 + + helper.initialize_v_cruise(car.CarState(vEgo=initial_speed_kph * CV.KPH_TO_MS), False, False, toggles) + assert helper.v_cruise_kph == pytest.approx(initial_speed_kph) + + press_cs = car.CarState( + cruiseState={"available": True}, + buttonEvents=[{"type": ButtonType.accelCruise, "pressed": True}], + ) + helper.update_v_cruise(press_cs, True, True, False, toggles) + + release_cs = car.CarState( + cruiseState={"available": True}, + buttonEvents=[{"type": ButtonType.accelCruise, "pressed": False}], + ) + helper.update_v_cruise(release_cs, True, True, False, toggles) + assert helper.v_cruise_kph == pytest.approx(initial_speed_kph + 5) + + helper.update_v_cruise(press_cs, True, True, False, toggles) + for _ in range(50): + helper.update_v_cruise(car.CarState(cruiseState={"available": True}), True, True, False, toggles) + + assert helper.v_cruise_kph == pytest.approx(initial_speed_kph + 5 + 1) + + def test_openpilot_longitudinal_pcm_cruise_starts_from_pcm_set_speed(self): + CP = car.CarParams(pcmCruise=True, openpilotLongitudinalControl=True) + helper = VCruiseHelper(CP) + toggles = SimpleNamespace(cruise_increase=5, cruise_increase_long=1, set_speed_limit=False) + pcm_speed_kph = 72.0 + + helper.initialize_v_cruise( + car.CarState( + vEgo=40 * CV.KPH_TO_MS, + cruiseState={"available": True, "speed": pcm_speed_kph * CV.KPH_TO_MS}, + ), + False, + False, + toggles, + ) + assert helper.v_cruise_kph == pytest.approx(pcm_speed_kph) + + helper.update_v_cruise( + car.CarState( + cruiseState={"available": True, "speed": 74 * CV.KPH_TO_MS}, + ), + True, + True, + False, + toggles, + ) + assert helper.v_cruise_kph == pytest.approx(pcm_speed_kph) + class TestVCruiseHelperRedneck: def setup_method(self): diff --git a/selfdrive/controls/lib/latcontrol_vehicle_tunes.py b/selfdrive/controls/lib/latcontrol_vehicle_tunes.py index be4874624..e128ce128 100644 --- a/selfdrive/controls/lib/latcontrol_vehicle_tunes.py +++ b/selfdrive/controls/lib/latcontrol_vehicle_tunes.py @@ -275,16 +275,16 @@ GENESIS_G70_FRICTION_JERK_DEADZONE_LAT = 0.30 GENESIS_G70_FRICTION_JERK_DEADZONE_LAT_WIDTH = 0.08 GENESIS_G70_FRICTION_JERK_DEADZONE_SPEED = 12.0 GENESIS_G70_FRICTION_JERK_DEADZONE_SPEED_WIDTH = 3.5 -GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_MAX = 0.22 +GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_MAX = 0.26 GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_SPEED = 35.0 * CV.MPH_TO_MS GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_SPEED_WIDTH = 8.0 * CV.MPH_TO_MS GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_LAT = 0.35 GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_LAT_WIDTH = 0.15 -GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_LAT_CUTOFF = 1.25 -GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_LAT_CUTOFF_WIDTH = 0.25 +GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_LAT_CUTOFF = 1.75 +GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_LAT_CUTOFF_WIDTH = 0.30 GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_JERK = 0.20 GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_JERK_WIDTH = 0.12 -GENESIS_G70_CENTER_OUTPUT_TAPER_MAX = 0.26 +GENESIS_G70_CENTER_OUTPUT_TAPER_MAX = 0.30 GENESIS_G70_CENTER_OUTPUT_TAPER_LAT = 0.30 GENESIS_G70_CENTER_OUTPUT_TAPER_LAT_WIDTH = 0.10 GENESIS_G70_CENTER_OUTPUT_TAPER_SPEED = 18.0 @@ -307,7 +307,7 @@ GENESIS_G70_LOW_SPEED_OUTPUT_LIMIT_LAT = 0.14 GENESIS_G70_LOW_SPEED_OUTPUT_LIMIT_LAT_WIDTH = 0.05 GENESIS_G70_LOW_SPEED_OUTPUT_LIMIT_SPEED = 6.0 GENESIS_G70_LOW_SPEED_OUTPUT_LIMIT_SPEED_WIDTH = 1.5 -GENESIS_G70_CURVE_UNWIND_OUTPUT_BOOST = 0.00 +GENESIS_G70_CURVE_UNWIND_OUTPUT_REDUCTION_MAX = 0.08 GENESIS_G70_CURVE_UNWIND_SPEED = 18.0 GENESIS_G70_CURVE_UNWIND_SPEED_WIDTH = 3.0 GENESIS_G70_CURVE_UNWIND_LAT = 0.25 @@ -3294,7 +3294,8 @@ def get_genesis_g70_curve_unwind_output_scale(desired_lateral_accel: float, desi GENESIS_G70_CURVE_UNWIND_LAT_WIDTH) jerk_weight = _sigmoid((abs(desired_lateral_jerk) - GENESIS_G70_CURVE_UNWIND_JERK) / GENESIS_G70_CURVE_UNWIND_JERK_WIDTH) - return 1.0 + GENESIS_G70_CURVE_UNWIND_OUTPUT_BOOST * speed_weight * lateral_weight * jerk_weight + reduction = (GENESIS_G70_CURVE_UNWIND_OUTPUT_REDUCTION_MAX * speed_weight * lateral_weight * jerk_weight) + return 1.0 - reduction def get_genesis_g70_unwind_ff_scale(setpoint: float, measured_lateral_accel: float, diff --git a/selfdrive/controls/lib/longcontrol_vehicle_tunes.py b/selfdrive/controls/lib/longcontrol_vehicle_tunes.py index c41f5f9b0..a76292d13 100644 --- a/selfdrive/controls/lib/longcontrol_vehicle_tunes.py +++ b/selfdrive/controls/lib/longcontrol_vehicle_tunes.py @@ -56,6 +56,10 @@ HYUNDAI_ELANTRA_STOPPED_LEAD_MAX_EGO_SPEED = 2.0 HYUNDAI_ELANTRA_STOPPED_LEAD_MAX_SPEED = 0.5 HYUNDAI_ELANTRA_STOPPED_LEAD_MIN_CLOSING_SPEED = 0.25 HYUNDAI_ELANTRA_STOPPED_LEAD_MAX_CREEP_ACCEL = 0.05 +HYUNDAI_ELANTRA_FINAL_STOP_MAX_SPEED = 1.0 +HYUNDAI_ELANTRA_FINAL_STOP_CAP_BP = [0.0, 0.2, 0.5, HYUNDAI_ELANTRA_FINAL_STOP_MAX_SPEED] +HYUNDAI_ELANTRA_FINAL_STOP_CAP_V = [-0.20, -0.25, -0.35, -0.55] +HYUNDAI_ELANTRA_FINAL_STOP_URGENCY_MARGIN = 0.45 HYUNDAI_SANTA_FE_FINAL_STOP_MAX_SPEED = 1.0 HYUNDAI_SANTA_FE_FINAL_STOP_CAP_BP = [0.0, 0.2, 0.5, HYUNDAI_SANTA_FE_FINAL_STOP_MAX_SPEED] HYUNDAI_SANTA_FE_FINAL_STOP_CAP_V = [-0.25, -0.30, -0.50, -0.90] @@ -169,7 +173,21 @@ class LongControlVehicleTuning: self.subaru_stop_release_frames = 0 def shape_stopping_accel(self, output_accel, a_target, should_stop, v_ego, has_lead, stop_accel): - """Release a stale hard lead brake once the stop target has eased.""" + """Shape low-speed stop braking without overriding urgent targets.""" + if ( + self.is_hyundai_elantra_2021 and + should_stop and + v_ego < HYUNDAI_ELANTRA_FINAL_STOP_MAX_SPEED and + a_target <= 0.1 + ): + final_stop_cap = float(interp( + v_ego, + HYUNDAI_ELANTRA_FINAL_STOP_CAP_BP, + HYUNDAI_ELANTRA_FINAL_STOP_CAP_V, + )) + if a_target > final_stop_cap - HYUNDAI_ELANTRA_FINAL_STOP_URGENCY_MARGIN: + return max(float(output_accel), final_stop_cap) + if ( self.is_hyundai_santa_fe_2022 and v_ego <= HYUNDAI_SANTA_FE_FINAL_STOP_MAX_SPEED and diff --git a/selfdrive/controls/tests/test_latcontrol.py b/selfdrive/controls/tests/test_latcontrol.py index 9b1804338..737e2a1b8 100644 --- a/selfdrive/controls/tests/test_latcontrol.py +++ b/selfdrive/controls/tests/test_latcontrol.py @@ -960,7 +960,7 @@ class TestLatControl: assert get_genesis_g70_low_speed_output_limit(0.0, 2.0) < 0.30 assert get_genesis_g70_low_speed_angle_damping(0.0, -20.0, 0.0, 2.0) < 0.0 assert get_genesis_g70_low_speed_angle_damping(0.0, 20.0, 0.0, 2.0) > 0.0 - assert get_genesis_g70_curve_unwind_output_scale(0.7, -0.5, 25.0) == pytest.approx(1.0) + assert 0.90 < get_genesis_g70_curve_unwind_output_scale(0.7, -0.5, 25.0) < 1.0 assert get_genesis_g70_curve_unwind_output_scale(0.7, 0.5, 25.0) == 1.0 assert get_genesis_g70_angle_output_scale(55.0, 1.0) > get_genesis_g70_angle_output_scale(85.0, 1.0) assert get_genesis_g70_angle_output_scale(85.0, -1.0) == pytest.approx(1.0) diff --git a/selfdrive/controls/tests/test_longcontrol.py b/selfdrive/controls/tests/test_longcontrol.py index 8f50702f2..7cf909731 100644 --- a/selfdrive/controls/tests/test_longcontrol.py +++ b/selfdrive/controls/tests/test_longcontrol.py @@ -765,6 +765,15 @@ def test_elantra_lead_stop_releases_stale_hard_brake_after_target_eases(): assert tuning.shape_stopping_accel(-1.20, -0.25, True, 1.0, False, -0.85) == pytest.approx(-1.20) +def test_elantra_final_stop_cap_softens_normal_low_speed_stop(): + CP = make_longcontrol_cp(brand="hyundai", carFingerprint="HYUNDAI_ELANTRA_2021") + tuning = vehicle_tunes.LongControlVehicleTuning(CP) + + assert tuning.shape_stopping_accel(-0.85, -0.25, True, 0.5, False, -0.85) == pytest.approx(-0.35) + assert tuning.shape_stopping_accel(-0.85, -1.25, True, 0.5, False, -0.85) == pytest.approx(-0.85) + assert tuning.shape_stopping_accel(-0.85, -0.25, False, 0.5, False, -0.85) == pytest.approx(-0.85) + + def test_elantra_stopped_lead_handoff_holds_braking_direction_without_touching_brakes(): CP = make_longcontrol_cp(brand="hyundai", carFingerprint="HYUNDAI_ELANTRA_2021") tuning = vehicle_tunes.LongControlVehicleTuning(CP) diff --git a/selfdrive/ui/mici/layouts/settings/visuals.py b/selfdrive/ui/mici/layouts/settings/visuals.py index c5fb036c1..61fdabddd 100644 --- a/selfdrive/ui/mici/layouts/settings/visuals.py +++ b/selfdrive/ui/mici/layouts/settings/visuals.py @@ -63,6 +63,7 @@ class VisualsLayoutMici(NavScroller): self._torque_bar_btn = BigParamControl("torque bar", "EnableTorqueBarWidget") self._rainbow_path_btn = BigParamControl("rainbow road", "RainbowPath") self._lead_indicator_btn = LeadIndicatorBigButton() + self._lead_info_btn = BigParamControl("show lead speed", "LeadInfo") self._speed_limit_signs_btn = BigParamControl("show speed limits", "ShowSpeedLimits") self._slc_confirmation_btn = BigParamControl("confirm new speed limits", "SLCConfirmation") self._slc_confirmation_lower_btn = BigParamControl("confirm lower limits", "SLCConfirmationLower") @@ -76,6 +77,7 @@ class VisualsLayoutMici(NavScroller): self._torque_bar_btn, self._rainbow_path_btn, self._lead_indicator_btn, + self._lead_info_btn, self._speed_limit_signs_btn, self._slc_confirmation_btn, self._slc_confirmation_lower_btn, @@ -93,6 +95,7 @@ class VisualsLayoutMici(NavScroller): def _refresh(self): self._camera_view_btn.refresh() self._lead_indicator_btn.refresh() + self._lead_info_btn.set_enabled(lead_indicator_enabled(self._lead_info_btn.params, hide_by_default=True)) confirmation_enabled = self._slc_confirmation_btn.params.get_bool("SLCConfirmation") self._slc_confirmation_lower_btn.set_visible(confirmation_enabled) self._slc_confirmation_higher_btn.set_visible(confirmation_enabled) diff --git a/selfdrive/ui/mici/onroad/model_renderer.py b/selfdrive/ui/mici/onroad/model_renderer.py index a1da04202..66ec790aa 100644 --- a/selfdrive/ui/mici/onroad/model_renderer.py +++ b/selfdrive/ui/mici/onroad/model_renderer.py @@ -13,8 +13,9 @@ from openpilot.selfdrive.ui.onroad.starpilot.rainbow_path import RainbowPath from openpilot.selfdrive.ui.lib.starpilot_visuals import blend_colors, lead_indicator_enabled from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus from openpilot.selfdrive.ui.mici.onroad.starpilot_status import get_border_color -from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.lib.application import gui_app, FontWeight from openpilot.system.ui.lib.shader_polygon import draw_polygon, Gradient +from openpilot.system.ui.lib.text_measure import measure_text_cached from openpilot.system.ui.widgets import Widget CLIP_MARGIN = 500 @@ -66,6 +67,7 @@ class ModelRenderer(Widget): self._lane_line_probs = np.zeros(4, dtype=np.float32) self._road_edge_stds = np.zeros(2, dtype=np.float32) self._lead_vehicles = [LeadVehicle(), LeadVehicle()] + self._lead_info_enabled = False self._path_offset_z = HEIGHT_INIT[0] # Initialize ModelPoints objects @@ -136,6 +138,7 @@ class ModelRenderer(Widget): model = sm['modelV2'] radar_state = sm['radarState'] if sm.valid['radarState'] else None lead_one = radar_state.leadOne if radar_state else None + self._lead_info_enabled = self._params.get_bool("LeadInfo") render_lead_indicator = self._should_render_lead_indicator(radar_state) # Update model data when needed @@ -159,7 +162,7 @@ class ModelRenderer(Widget): self._draw_path(sm) if render_lead_indicator and radar_state: - self._draw_lead_indicator() + self._draw_lead_indicator(radar_state) def _should_render_lead_indicator(self, radar_state) -> bool: return radar_state is not None and lead_indicator_enabled(self._params, hide_by_default=True) @@ -498,7 +501,7 @@ class ModelRenderer(Widget): ] draw_polygon(self._rect, self._path.projected_points, gradient=self._path_gradient) - def _draw_lead_indicator(self): + def _draw_lead_indicator(self, radar_state): # Draw lead vehicles if available lead_color = get_theme_color("LeadMarker", rl.Color(201, 34, 49, 255)) for lead in self._lead_vehicles: @@ -508,6 +511,35 @@ class ModelRenderer(Widget): rl.draw_triangle_fan(lead.glow, len(lead.glow), rl.Color(218, 202, 37, 255)) rl.draw_triangle_fan(lead.chevron, len(lead.chevron), with_alpha(lead_color, lead.fill_alpha)) + lead_one = radar_state.leadOne + if self._lead_info_enabled and lead_one and lead_one.status: + self._draw_lead_speed(lead_one) + + @staticmethod + def _format_lead_speed(lead_speed: float, is_metric: bool, use_si_metrics: bool) -> str: + lead_speed = max(float(lead_speed), 0.0) + if use_si_metrics: + return f"{round(lead_speed)} m/s" + if is_metric: + return f"{round(lead_speed * CV.MS_TO_KPH)} km/h" + return f"{round(lead_speed * CV.MS_TO_MPH)} mph" + + def _draw_lead_speed(self, lead_data) -> None: + from openpilot.selfdrive.ui.onroad.starpilot.path import _draw_text_with_outline + + text = self._format_lead_speed( + getattr(lead_data, "vLead", 0.0), + ui_state.is_metric, + ui_state.starpilot_toggles.get("UseSiMetrics", False), + ) + font = gui_app.font(FontWeight.SEMI_BOLD) + font_size = 40 + text_size = measure_text_cached(font, text, font_size) + center_x = self._rect.x + self._rect.width / 2 + x = center_x - text_size.x / 2 + y = self._rect.y + 22 + _draw_text_with_outline(text, float(x), float(y), font, font_size) + @staticmethod def _get_path_length_idx(pos_x_array: np.ndarray, path_distance: float) -> int: """Get the index corresponding to the given path height""" diff --git a/selfdrive/ui/mici/tests/test_lead_indicator.py b/selfdrive/ui/mici/tests/test_lead_indicator.py index e617cbdbd..ac5aef794 100644 --- a/selfdrive/ui/mici/tests/test_lead_indicator.py +++ b/selfdrive/ui/mici/tests/test_lead_indicator.py @@ -1,19 +1,25 @@ from types import SimpleNamespace +import pytest + import openpilot.selfdrive.ui.mici.onroad.model_renderer as model_renderer class _FakeParams: - def __init__(self, enabled: bool): + def __init__(self, enabled: bool, lead_info: bool = False): self.enabled = enabled + self.lead_info = lead_info def get(self, key): assert key == "HideLeadMarker" return b"0" if self.enabled else b"1" def get_bool(self, key): - assert key == "HideLeadMarker" - return not self.enabled + if key == "HideLeadMarker": + return not self.enabled + if key == "LeadInfo": + return self.lead_info + raise AssertionError(key) def test_lead_indicator_renders_in_aol_without_longitudinal_control(monkeypatch): @@ -31,3 +37,38 @@ def test_lead_indicator_still_honors_disabled_setting(): assert not renderer._should_render_lead_indicator(SimpleNamespace()) assert not renderer._should_render_lead_indicator(None) + + +@pytest.mark.parametrize( + ("is_metric", "use_si_metrics", "expected"), + [ + (False, False, "22 mph"), + (True, False, "36 km/h"), + (False, True, "10 m/s"), + ], +) +def test_lead_speed_uses_c3_units(is_metric, use_si_metrics, expected): + assert model_renderer.ModelRenderer._format_lead_speed(10.0, is_metric, use_si_metrics) == expected + + +def test_lead_metrics_draw_only_speed_when_enabled(monkeypatch): + drawn_metrics = [] + monkeypatch.setattr(model_renderer, "get_theme_color", lambda *_args: model_renderer.rl.RED) + monkeypatch.setattr(model_renderer.rl, "draw_triangle_fan", lambda *_args: None) + + renderer = object.__new__(model_renderer.ModelRenderer) + renderer._lead_info_enabled = True + renderer._lead_vehicles = [ + model_renderer.LeadVehicle( + glow=[(1.0, 2.0)] * 3, + chevron=[(1.0, 2.0)] * 3, + fill_alpha=255, + ), + model_renderer.LeadVehicle(), + ] + renderer._draw_lead_speed = drawn_metrics.append + lead_one = SimpleNamespace(status=True, vLead=10.0) + + renderer._draw_lead_indicator(SimpleNamespace(leadOne=lead_one, leadTwo=SimpleNamespace(status=False))) + + assert drawn_metrics == [lead_one] diff --git a/starpilot/assets/model_manager.py b/starpilot/assets/model_manager.py index ce574f12f..f174af12c 100644 --- a/starpilot/assets/model_manager.py +++ b/starpilot/assets/model_manager.py @@ -1022,11 +1022,6 @@ class ModelManager: model_key = self._canonical_model_key(model_key) accelerator = str(accelerator or "").strip().lower() try: - if accelerator == MODEL_LAB_ACCELERATOR and not external_gpu_available(): - handle_error(None, "External GPU required...", "Chestnut is not connected and firmware-ready.", - MODEL_LAB_DOWNLOAD_PARAM, DOWNLOAD_PROGRESS_PARAM, self.params_memory) - return False - artifact_metadata = model_accelerator_artifact_metadata(model_key, accelerator) if not model_accelerator_artifact_available(model_key, accelerator): handle_error(None, "Accelerator artifact unavailable...", @@ -1059,7 +1054,7 @@ class ModelManager: MODEL_LAB_DOWNLOAD_PARAM, DOWNLOAD_PROGRESS_PARAM, self.params_memory) return False - self.params_memory.put(DOWNLOAD_PROGRESS_PARAM, "Chestnut artifact downloaded!") + self.params_memory.put(DOWNLOAD_PROGRESS_PARAM, "eGPU variant downloaded!") return True finally: self.params_memory.remove(MODEL_LAB_DOWNLOAD_PARAM) diff --git a/starpilot/assets/tests/test_model_pipeline.py b/starpilot/assets/tests/test_model_pipeline.py index f4ac17371..5e6eedd90 100644 --- a/starpilot/assets/tests/test_model_pipeline.py +++ b/starpilot/assets/tests/test_model_pipeline.py @@ -326,7 +326,8 @@ def test_model_manager_downloads_precompiled_accelerator_variant_without_compili }, }]) (tmp_path / model_manager.ARTIFACT_METADATA_CACHE).write_text(json.dumps(metadata)) - monkeypatch.setattr(model_manager, "external_gpu_available", lambda: True) + # These are precompiled files, so downloading must not require a connected eGPU. + monkeypatch.setattr(model_manager, "external_gpu_available", lambda: False) monkeypatch.setattr(model_manager, "get_resource_urls", lambda: ["https://models.example"]) monkeypatch.setattr(manager, "_load_artifact_url_map", lambda: {}) calls = [] @@ -346,7 +347,7 @@ def test_model_manager_downloads_precompiled_accelerator_variant_without_compili ) assert calls[0][3]["execution_device"] == "AMD" assert calls[0][5] == ["https://models.example"] - assert manager.params_memory.values[model_manager.DOWNLOAD_PROGRESS_PARAM] == "Chestnut artifact downloaded!" + assert manager.params_memory.values[model_manager.DOWNLOAD_PROGRESS_PARAM] == "eGPU variant downloaded!" assert model_manager.MODEL_LAB_DOWNLOAD_PARAM not in manager.params_memory.values diff --git a/starpilot/system/bluetooth/bluez.py b/starpilot/system/bluetooth/bluez.py index b0226a85c..12de66a15 100644 --- a/starpilot/system/bluetooth/bluez.py +++ b/starpilot/system/bluetooth/bluez.py @@ -329,9 +329,9 @@ class BlueZClient: self.set_device_property(address, "Trusted", "b", True) self.agent.clear() - def connect(self, address: str) -> None: + def connect(self, address: str, timeout: float = 30.0) -> None: device = self.device_for_address(address) - self._call(device["path"], DEVICE_IFACE, "Connect", timeout=30.0) + self._call(device["path"], DEVICE_IFACE, "Connect", timeout=timeout) def disconnect(self, address: str) -> None: device = self.device_for_address(address) diff --git a/starpilot/system/bluetooth/daemon.py b/starpilot/system/bluetooth/daemon.py index b8ad4bf48..d23985737 100644 --- a/starpilot/system/bluetooth/daemon.py +++ b/starpilot/system/bluetooth/daemon.py @@ -18,6 +18,7 @@ SCAN_DURATION = 20.0 AUDIO_TEST_START_DELAY = 3.0 AUDIO_TEST_HOLD_TIME = 3.0 RECONNECT_INTERVAL_SECONDS = 15.0 +CONTROLLER_RECONNECT_INTERVAL_SECONDS = 5.0 RECONNECT_MAX_BACKOFF_SECONDS = 300.0 MANUAL_DISCONNECT_SUPPRESSION_SECONDS = 300.0 CONTROLLER_OFFROAD_DISCONNECT_DELAY_SECONDS = 120.0 @@ -227,6 +228,8 @@ class BluetoothController: # report NotConnected, and it must not immediately be auto-reconnected. self._manual_disconnect_until[normalized_address] = time.monotonic() + MANUAL_DISCONNECT_SUPPRESSION_SECONDS self._reconnect_backoff.pop(normalized_address, None) + self._policy_disconnected.discard(normalized_address) + self._policy_disconnect_retry_after.pop(normalized_address, None) try: with self._lock: self._client().disconnect(normalized_address) @@ -238,6 +241,8 @@ class BluetoothController: self._client().remove(address) self._reconnect_backoff.pop(address.upper(), None) self._manual_disconnect_until.pop(address.upper(), None) + self._policy_disconnected.discard(address.upper()) + self._policy_disconnect_retry_after.pop(address.upper(), None) if (self.params.get("BluetoothAudioAddress", encoding="utf-8") or "").upper() == address.upper(): self.params.remove("BluetoothAudioAddress") elif command == "select_audio": @@ -282,7 +287,6 @@ class BluetoothController: if self._policy_disconnected: for address in self._policy_disconnected: self._reconnect_backoff.pop(address, None) - self._policy_disconnected.clear() self._policy_disconnect_retry_after.clear() self._last_reconnect = 0.0 return False @@ -292,7 +296,6 @@ class BluetoothController: if not self.params.get_bool("BluetoothDisconnectControllersOffroad"): if self._policy_disconnected: - self._policy_disconnected.clear() self._policy_disconnect_retry_after.clear() self._last_reconnect = 0.0 return False @@ -322,6 +325,64 @@ class BluetoothController: cloudlog.warning(f"Bluetooth offroad controller disconnect failed for {address}: {error}") return True + def _maintain_reconnects(self, status: dict[str, Any], now: float, suspend_controller_reconnect: bool) -> None: + devices = status["devices"] + devices_by_address = {device["address"].upper(): device for device in devices} + for address in list(self._policy_disconnected): + device = devices_by_address.get(address) + if device is None or not device["paired"] or not device["trusted"]: + self._policy_disconnected.discard(address) + self._reconnect_backoff.pop(address, None) + elif device["connected"]: + self._policy_disconnected.discard(address) + self._reconnect_backoff.pop(address, None) + + if self._pairing_address: + return + + selected = str(status["selected_audio"]) + candidates = [device for device in devices if device["paired"] and device["trusted"] and not device["connected"]] + candidates.sort(key=lambda device: device["address"].upper() != selected.upper()) + controller_candidates = { + device["address"].upper() for device in candidates + if device["controller"] or device["address"].upper() in self._policy_disconnected + } + reconnect_interval = CONTROLLER_RECONNECT_INTERVAL_SECONDS if controller_candidates else RECONNECT_INTERVAL_SECONDS + if now - self._last_reconnect < reconnect_interval: + return + self._last_reconnect = now + + candidate_addresses = {device["address"].upper() for device in candidates} + for address in list(self._manual_disconnect_until): + if address not in candidate_addresses or now >= self._manual_disconnect_until[address]: + self._manual_disconnect_until.pop(address, None) + for address in list(self._reconnect_backoff): + if address not in candidate_addresses: + self._reconnect_backoff.pop(address, None) + + for device in candidates: + address = device["address"].upper() + controller = device["controller"] or address in self._policy_disconnected + if not device["audio"] and not controller: + continue + if suspend_controller_reconnect and controller: + continue + if now < self._manual_disconnect_until.get(address, 0.0): + continue + attempts, retry_after = self._reconnect_backoff.get(address, (0, 0.0)) + if now < retry_after: + continue + try: + with self._lock: + self._client().connect(address, timeout=CONTROLLER_RECONNECT_INTERVAL_SECONDS if controller else 30.0) + self._reconnect_backoff.pop(address, None) + except Exception: + attempts += 1 + delay = (CONTROLLER_RECONNECT_INTERVAL_SECONDS if controller else + min(RECONNECT_INTERVAL_SECONDS * (2 ** (attempts - 1)), RECONNECT_MAX_BACKOFF_SECONDS)) + self._reconnect_backoff[address] = (attempts, now + delay) + cloudlog.warning(f"Bluetooth reconnect failed for {address}; retrying in {delay:.0f}s") + def maintain_connections(self) -> None: while True: time.sleep(2) @@ -335,38 +396,7 @@ class BluetoothController: continue self._maintain_scan(status, now) suspend_controller_reconnect = self._maintain_controller_offroad_policy(status, now) - if self._pairing_address or now - self._last_reconnect < RECONNECT_INTERVAL_SECONDS: - continue - self._last_reconnect = now - selected = str(status["selected_audio"]) - candidates = [device for device in status["devices"] if device["paired"] and device["trusted"] and not device["connected"]] - candidates.sort(key=lambda device: device["address"].upper() != selected.upper()) - candidate_addresses = {device["address"].upper() for device in candidates} - for address in list(self._manual_disconnect_until): - if address not in candidate_addresses or now >= self._manual_disconnect_until[address]: - self._manual_disconnect_until.pop(address, None) - for address in list(self._reconnect_backoff): - if address not in candidate_addresses: - self._reconnect_backoff.pop(address, None) - for device in candidates: - if device["audio"] or device["controller"]: - if suspend_controller_reconnect and device["controller"]: - continue - address = device["address"].upper() - if now < self._manual_disconnect_until.get(address, 0.0): - continue - _attempts, retry_after = self._reconnect_backoff.get(address, (0, 0.0)) - if now < retry_after: - continue - try: - with self._lock: - self._client().connect(address) - self._reconnect_backoff.pop(address, None) - except Exception: - attempts = _attempts + 1 - delay = min(RECONNECT_INTERVAL_SECONDS * (2 ** (attempts - 1)), RECONNECT_MAX_BACKOFF_SECONDS) - self._reconnect_backoff[address] = (attempts, now + delay) - cloudlog.warning(f"Bluetooth reconnect failed for {address}; retrying in {delay:.0f}s") + self._maintain_reconnects(status, now, suspend_controller_reconnect) except Exception: cloudlog.exception("Bluetooth connection maintenance failed") diff --git a/starpilot/system/bluetooth/tests/test_bluetooth.py b/starpilot/system/bluetooth/tests/test_bluetooth.py index b1ad326ba..019d65f31 100644 --- a/starpilot/system/bluetooth/tests/test_bluetooth.py +++ b/starpilot/system/bluetooth/tests/test_bluetooth.py @@ -55,6 +55,8 @@ class FakeBlueZ: self.discovering = False self.closed = False self.actions = [] + self.connect_timeouts = [] + self.connect_error = None self.device = { "path": "/fake/device", "address": "00:11:22:33:44:55", @@ -91,8 +93,11 @@ class FakeBlueZ: def pair(self, address, _device_path=None): self.actions.append(("pair", address)) - def connect(self, address): + def connect(self, address, timeout=30.0): self.actions.append(("connect", address)) + self.connect_timeouts.append(timeout) + if self.connect_error is not None: + raise self.connect_error def disconnect(self, address): self.actions.append(("disconnect", address)) @@ -297,6 +302,7 @@ def test_disconnect_is_idempotent_and_suppresses_auto_reconnect(): params = FakeParams(IsOffroad=False, BluetoothEnabled=True) client = FakeBlueZ() controller = BluetoothController(params, lambda: client, FakeRadio()) + controller._policy_disconnected.add(client.device["address"].upper()) controller.handle({"command": "disconnect", "address": client.device["address"]}) @@ -304,6 +310,7 @@ def test_disconnect_is_idempotent_and_suppresses_auto_reconnect(): assert client.actions == [("disconnect", client.device["address"])] assert address in controller._manual_disconnect_until assert controller._manual_disconnect_until[address] > time.monotonic() + assert address not in controller._policy_disconnected def test_power_off_preserves_saved_audio_selection(): @@ -459,20 +466,57 @@ def test_controller_offroad_disconnect_policy_is_opt_in_and_delayed(): def test_controller_offroad_disconnect_policy_reconnects_onroad(): params = FakeParams(IsOffroad=True, BluetoothEnabled=True, BluetoothDisconnectControllersOffroad=True) - controller = BluetoothController(params, FakeBlueZ, FakeRadio()) + client = FakeBlueZ() + controller = BluetoothController(params, lambda: client, FakeRadio()) + controller._bluez = client address = "00:11:22:33:44:55" controller._offroad_since = 100.0 controller._policy_disconnected.add(address) controller._reconnect_backoff[address] = (3, 500.0) controller._last_reconnect = 210.0 - assert not controller._maintain_controller_offroad_policy({"offroad": False, "devices": []}, 220.0) + disconnected_status = { + "offroad": False, + "selected_audio": "", + "devices": [{**client.device, "controller": False, "connected": False}], + } + assert not controller._maintain_controller_offroad_policy(disconnected_status, 220.0) assert controller._offroad_since is None - assert controller._policy_disconnected == set() + assert controller._policy_disconnected == {address} assert controller._policy_disconnect_retry_after == {} assert address not in controller._reconnect_backoff assert controller._last_reconnect == 0.0 + controller._maintain_reconnects(disconnected_status, 220.0, False) + assert client.actions == [("connect", address)] + assert client.connect_timeouts == [5.0] + + connected_status = { + **disconnected_status, + "devices": [{**client.device, "controller": False, "connected": True}], + } + controller._maintain_reconnects(connected_status, 221.0, False) + assert controller._policy_disconnected == set() + + +def test_controller_auto_reconnect_uses_fixed_short_retry(): + params = FakeParams(IsOffroad=False, BluetoothEnabled=True) + client = FakeBlueZ() + client.connect_error = RuntimeError("Host is down") + controller = BluetoothController(params, lambda: client, FakeRadio()) + controller._bluez = client + status = { + "offroad": False, + "selected_audio": "", + "devices": [{**client.device, "audio": False, "controller": True, "connected": False}], + } + + controller._maintain_reconnects(status, 100.0, False) + assert controller._reconnect_backoff[client.device["address"]] == (1, 105.0) + controller._maintain_reconnects(status, 105.0, False) + assert controller._reconnect_backoff[client.device["address"]] == (2, 110.0) + assert client.connect_timeouts == [5.0, 5.0] + def test_pair_keeps_discovery_until_pair_starts(): params = FakeParams(IsOffroad=True, BluetoothEnabled=True) diff --git a/starpilot/system/the_galaxy/assets/components/router.js b/starpilot/system/the_galaxy/assets/components/router.js index d85e23547..390beed0c 100644 --- a/starpilot/system/the_galaxy/assets/components/router.js +++ b/starpilot/system/the_galaxy/assets/components/router.js @@ -21,7 +21,7 @@ import { Sidebar } from "/assets/components/sidebar.js?v=controllers-nav-1" import { SentryMode } from "/assets/components/tools/sentry.js" import { SpeedLimits } from "/assets/components/tools/speed_limits.js" import { ModelManager } from "/assets/components/tools/model_manager.js?v=20260906a" -import { ModelLaboratory } from "/assets/components/tools/model_laboratory.js?v=model-lab-5" +import { ModelLaboratory } from "/assets/components/tools/model_laboratory.js?v=model-lab-6" import { LivePlots } from "/assets/components/tools/plots.js" import { ThemeMaker } from "/assets/components/tools/theme_maker.js" import { TestingGround } from "/assets/components/tools/testing_ground.js" diff --git a/starpilot/system/the_galaxy/assets/components/tools/model_laboratory.css b/starpilot/system/the_galaxy/assets/components/tools/model_laboratory.css index cdb3a361e..b07df53d7 100644 --- a/starpilot/system/the_galaxy/assets/components/tools/model_laboratory.css +++ b/starpilot/system/the_galaxy/assets/components/tools/model_laboratory.css @@ -151,6 +151,11 @@ color: var(--color-black); } +.ml-button-danger { + background: rgba(224, 85, 119, 0.12); + color: var(--danger-fg); +} + .ml-button:disabled { cursor: not-allowed; opacity: 0.55; diff --git a/starpilot/system/the_galaxy/assets/components/tools/model_laboratory.js b/starpilot/system/the_galaxy/assets/components/tools/model_laboratory.js index f914307be..cecfe44d3 100644 --- a/starpilot/system/the_galaxy/assets/components/tools/model_laboratory.js +++ b/starpilot/system/the_galaxy/assets/components/tools/model_laboratory.js @@ -12,7 +12,6 @@ const state = reactive({ download: {}, models: [], summary: {}, - manifest: { version: "unknown", shortcomings: [], opportunities: [] }, }) let initialized = false @@ -27,31 +26,33 @@ function modelLabel(modelId) { return modelById(modelId)?.label || modelId || "not selected" } -function readyModels() { +function availableModels() { return state.models.filter(model => model.modelLabArtifactAvailable) } +function downloadedModels() { + return availableModels().filter(model => model.modelLabArtifactInstalled) +} + function candidateModels(role) { - const ready = readyModels() - if (role !== "longitudinal") return ready + const downloaded = downloadedModels() + if (role !== "longitudinal") return downloaded const lateral = modelById(state.configuration.lateralModel) - if (!lateral) return ready - return ready.filter(model => model.value !== lateral.value) + if (!lateral) return downloaded + return downloaded.filter(model => model.value !== lateral.value) } function selectionError() { - if (!state.chestnutReady) return "Connect a firmware-ready Chestnut first." if (state.isOnroad) return "Park before changing the laboratory pair." + if (downloadedModels().length < 2) return "Download at least two eGPU variants before composing a pair." const lateral = modelById(state.configuration.lateralModel) const longitudinal = modelById(state.configuration.longitudinalModel) - if (!lateral || !longitudinal) return "Choose two small models with published Chestnut artifacts." + if (!lateral || !longitudinal) return "Choose two downloaded eGPU variants." if (lateral.value === longitudinal.value) return "Lateral and longitudinal models must be different." - if (!lateral.modelLabArtifactAvailable || !longitudinal.modelLabArtifactAvailable) { - return "Both models need a precompiled AMD artifact in the manifest." - } if (!lateral.modelLabArtifactInstalled || !longitudinal.modelLabArtifactInstalled) { - return "Prepare both precompiled AMD artifacts first." + return "Download both eGPU variants first." } + if (!state.chestnutReady) return "Connect a firmware-ready Chestnut to enable this pair." return "" } @@ -75,17 +76,14 @@ function applyPayload(payload) { state.download = payload?.download && typeof payload.download === "object" ? payload.download : {} state.models = Array.isArray(payload?.models) ? payload.models : [] state.summary = payload?.summary && typeof payload.summary === "object" ? payload.summary : {} - state.manifest = payload?.manifest && typeof payload.manifest === "object" - ? payload.manifest - : { version: "unknown", shortcomings: [], opportunities: [] } state.error = String(payload?.configurationError || "") - const ready = readyModels() - if (!modelById(state.configuration.lateralModel) && ready.length > 0) { - state.configuration.lateralModel = ready[0].value + const downloaded = downloadedModels() + if (!downloaded.some(model => model.value === state.configuration.lateralModel)) { + state.configuration.lateralModel = downloaded[0]?.value || "" } - if (!modelById(state.configuration.longitudinalModel) && ready.length > 1) { - state.configuration.longitudinalModel = ready.find(model => ( + if (!downloaded.some(model => model.value === state.configuration.longitudinalModel)) { + state.configuration.longitudinalModel = downloaded.find(model => ( model.value !== state.configuration.lateralModel ))?.value || "" } @@ -157,7 +155,7 @@ async function prepareModel(modelId) { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model: modelId }), }) - state.message = String(payload.message || "Chestnut artifact download queued.") + state.message = String(payload.message || "eGPU variant download queued.") await refresh() } catch (error) { state.error = error?.message || String(error) @@ -166,6 +164,29 @@ async function prepareModel(modelId) { } } +async function deleteModel(modelId) { + if (state.saving || !modelId) return + const model = modelById(modelId) + if (!window.confirm(`Delete the eGPU variant for "${model?.label || modelId}"? The normal on-device model will not be removed.`)) return + state.saving = true + state.error = "" + state.message = "" + try { + const payload = await requestJson("/api/model-laboratory/artifact", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: modelId }), + }) + selectionDirty = false + applyPayload(payload) + state.message = String(payload.message || "eGPU variant deleted.") + } catch (error) { + state.error = error?.message || String(error) + } finally { + state.saving = false + } +} + function bindControls() { const lateral = document.getElementById("ml-lateral-model") const longitudinal = document.getElementById("ml-longitudinal-model") @@ -177,6 +198,11 @@ function bindControls() { button.dataset.bound = "1" button.addEventListener("click", () => prepareModel(button.dataset.mlDownload)) }) + document.querySelectorAll("[data-ml-delete]").forEach(button => { + if (button.dataset.bound === "1") return + button.dataset.bound = "1" + button.addEventListener("click", () => deleteModel(button.dataset.mlDelete)) + }) if (lateral) { lateral.value = state.configuration.lateralModel @@ -226,15 +252,15 @@ function ensurePolling() { return } await refresh() - pollHandle = setTimeout(poll, 5000) + pollHandle = setTimeout(poll, state.download?.model ? 1000 : 5000) } pollHandle = setTimeout(poll, 5000) } function renderModel(model) { const artifactStatus = model.modelLabArtifactInstalled - ? "AMD ready" - : model.modelLabArtifactAvailable ? "AMD download needed" : "AMD not published" + ? "eGPU variant downloaded" + : "eGPU variant not downloaded" return html`
@@ -248,8 +274,15 @@ function renderModel(model) { ${artifactStatus} ${model.modelLabArtifactAvailable && !model.modelLabArtifactInstalled ? html` - + ` : ""} + ${model.modelLabArtifactInstalled ? html` + ` : ""}
@@ -287,11 +320,22 @@ export function ModelLaboratory() { ${() => state.loading ? html`
Loading laboratory status…
` : ""} ${() => !state.loading ? html` +
+
+
+

Available models

+

Download eGPU-compatible small models. These are separate from the small models in Model Manager because they are compiled for the eGPU.

+

${() => `${state.summary.ready || 0} downloaded · ${Math.max((state.summary.published || 0) - (state.summary.ready || 0), 0)} available to download.`}

+
+
+
${() => availableModels().map(renderModel)}
+
+

Compose a pair

-

Both precompiled small models stay resident and run every camera frame on Chestnut's AMD GPU.

+

Choose from downloaded eGPU variant combinations below.

${() => state.configuration.enabled ? "Enabled" : "Disabled"} @@ -364,20 +408,6 @@ export function ModelLaboratory() {

Both roles evaluate the same frame at 20 Hz. A runtime failure suppresses that frame and falls back to the built-in QCOM model.

-
-
-
-

Available models

-

${() => `${state.summary.ready || 0} ready to pair · ${Math.max((state.summary.published || 0) - (state.summary.ready || 0), 0)} available to download.`}

-
-
-
${() => readyModels().map(renderModel)}
-
- Model Manager downloads the manifest's precompiled AMD variants. Nothing is compiled on the comma. - A normal installed model may still need its separate Chestnut artifact. -
-
- ` : ""}
` diff --git a/starpilot/system/the_galaxy/assets/mobile/js/api.js b/starpilot/system/the_galaxy/assets/mobile/js/api.js index 2fb5de5bc..a7c38b7cf 100644 --- a/starpilot/system/the_galaxy/assets/mobile/js/api.js +++ b/starpilot/system/the_galaxy/assets/mobile/js/api.js @@ -136,6 +136,7 @@ export const api = { getModelLab() { return request("/api/model-laboratory", { cache: "no-store" }) }, saveModelLab(config) { return request("/api/model-laboratory", { method: "PUT", data: config }) }, prepareModelLabArtifact(model) { return request("/api/model-laboratory/download", { method: "POST", data: { model } }) }, + deleteModelLabArtifact(model) { return request("/api/model-laboratory/artifact", { method: "DELETE", data: { model } }) }, getErrorLogs() { return request("/api/error_logs", { headers: { Accept: "application/json" } }) }, getErrorLog(filename) { return fetch(`/api/error_logs/${encodeURIComponent(filename)}`).then((r) => r.text()) }, diff --git a/starpilot/system/the_galaxy/assets/mobile/js/views/ModelLaboratory.js b/starpilot/system/the_galaxy/assets/mobile/js/views/ModelLaboratory.js index 3b26d011d..af482000b 100644 --- a/starpilot/system/the_galaxy/assets/mobile/js/views/ModelLaboratory.js +++ b/starpilot/system/the_galaxy/assets/mobile/js/views/ModelLaboratory.js @@ -14,32 +14,34 @@ export const ModelLaboratory = { isOnroad: false, configuration: { enabled: false, lateralModel: "", longitudinalModel: "" }, runtime: {}, + download: {}, summary: {}, models: [], } }, computed: { - readyModels() { + availableModels() { return this.models.filter((m) => m && m.modelLabArtifactAvailable) }, + readyModels() { + return this.availableModels.filter((m) => m.modelLabArtifactInstalled) + }, candidates() { const ready = this.readyModels const lat = this.configuration.lateralModel return ready.filter((m) => !lat || m.value !== lat) }, selectionError() { - if (!this.chestnutReady) return "Connect a firmware-ready Chestnut first." if (this.isOnroad) return "Park before changing the laboratory pair." + if (this.readyModels.length < 2) return "Download at least two eGPU variants before composing a pair." const lat = this.modelById(this.configuration.lateralModel) const lon = this.modelById(this.configuration.longitudinalModel) - if (!lat || !lon) return "Choose two small models with published Chestnut artifacts." + if (!lat || !lon) return "Choose two downloaded eGPU variants." if (lat.value === lon.value) return "Lateral and longitudinal models must be different." - if (!lat.modelLabArtifactAvailable || !lon.modelLabArtifactAvailable) { - return "Both models need a precompiled AMD artifact in the manifest." - } if (!lat.modelLabArtifactInstalled || !lon.modelLabArtifactInstalled) { - return "Prepare both precompiled AMD artifacts first." + return "Download both eGPU variants first." } + if (!this.chestnutReady) return "Connect a firmware-ready Chestnut to enable this pair." return "" }, runtimeState() { @@ -48,7 +50,7 @@ export const ModelLaboratory = { }, }, created() { - this.poll = usePolling(() => this.refresh(), { interval: 5000 }) + this.poll = usePolling(() => this.refresh(), { interval: 2000 }) this.poll.start() }, beforeUnmount() { @@ -62,9 +64,8 @@ export const ModelLaboratory = { return this.modelById(id)?.label || id || "not selected" }, artifactStatus(m) { - if (m.modelLabArtifactInstalled) return { text: "AMD ready", good: true } - if (m.modelLabArtifactAvailable) return { text: "AMD download needed", good: false } - return { text: "AMD not published", good: false } + if (m.modelLabArtifactInstalled) return { text: "eGPU variant downloaded", good: true } + return { text: "eGPU variant not downloaded", good: false } }, async refresh() { try { @@ -82,6 +83,7 @@ export const ModelLaboratory = { this.isOnroad = Boolean(payload.isOnroad) this.error = String(payload.configurationError || "") this.runtime = payload.runtime && typeof payload.runtime === "object" ? payload.runtime : {} + this.download = payload.download && typeof payload.download === "object" ? payload.download : {} this.summary = payload.summary && typeof payload.summary === "object" ? payload.summary : {} this.models = Array.isArray(payload.models) ? payload.models : [] const cfg = payload.configuration && typeof payload.configuration === "object" ? payload.configuration : {} @@ -95,10 +97,10 @@ export const ModelLaboratory = { }, normalizeSelection() { const ready = this.readyModels - if (!this.modelById(this.configuration.lateralModel) && ready.length) { - this.configuration.lateralModel = ready[0].value + if (!ready.some((m) => m.value === this.configuration.lateralModel)) { + this.configuration.lateralModel = ready[0]?.value || "" } - if (!this.modelById(this.configuration.longitudinalModel) && ready.length > 1) { + if (!ready.some((m) => m.value === this.configuration.longitudinalModel)) { const lon = ready.find((m) => m.value !== this.configuration.lateralModel) this.configuration.longitudinalModel = lon?.value || "" } @@ -143,8 +145,8 @@ export const ModelLaboratory = { this.message = "" try { const payload = await api.prepareModelLabArtifact(modelId) - this.message = String(payload?.message || "Chestnut artifact download queued.") - showSnackbar("Chestnut artifact download queued", "info") + this.message = String(payload?.message || "eGPU variant download queued.") + showSnackbar("eGPU variant download queued", "info") await this.refresh() } catch (e) { this.error = e?.message || String(e) @@ -152,6 +154,25 @@ export const ModelLaboratory = { this.saving = false } }, + async deleteModel(modelId) { + if (this.saving || !modelId) return + const model = this.modelById(modelId) + if (!window.confirm(`Delete the eGPU variant for "${model?.label || modelId}"? The normal on-device model will not be removed.`)) return + this.saving = true + this.error = "" + this.message = "" + try { + const payload = await api.deleteModelLabArtifact(modelId) + this.dirty = false + this.applyPayload(payload) + this.message = String(payload?.message || "eGPU variant deleted.") + showSnackbar("eGPU variant deleted", "info") + } catch (e) { + this.error = e?.message || String(e) + } finally { + this.saving = false + } + }, }, template: `
@@ -177,12 +198,41 @@ export const ModelLaboratory = {
+
+
+ + Available models + {{ summary.ready || 0 }} downloaded · {{ Math.max((summary.published || 0) - (summary.ready || 0), 0) }} available to download +
+
+ Download eGPU-compatible small models. These are separate from the small models in Model Manager because they are compiled for the eGPU. +
+
+
+ {{ m.label }} + {{ m.value }} · {{ m.series || 'Unknown series' }} +
+
+ {{ m.version || 'unknown version' }} + {{ m.modelSize || 'small' }} + {{ artifactStatus(m).text }} + + +
+
+
+
Compose a pair {{ configuration.enabled ? 'Enabled' : 'Disabled' }}
+
Choose from downloaded eGPU variant combinations below.
-
-
- - Available models - {{ summary.ready || 0 }} ready to pair · {{ Math.max((summary.published || 0) - (summary.ready || 0), 0) }} available to download -
-
-
- {{ m.label }} - {{ m.value }} · {{ m.series || 'Unknown series' }} -
-
- {{ m.version || 'unknown version' }} - {{ m.modelSize || 'small' }} - {{ artifactStatus(m).text }} - -
-
-
-

Model Manager downloads the manifest's precompiled AMD variants. Nothing is compiled on the comma. A normal installed model may still need its separate Chestnut artifact.

-
-
`, diff --git a/starpilot/system/the_galaxy/templates/index.html b/starpilot/system/the_galaxy/templates/index.html index b6a6dea45..02a85c842 100644 --- a/starpilot/system/the_galaxy/templates/index.html +++ b/starpilot/system/the_galaxy/templates/index.html @@ -30,7 +30,7 @@ - + diff --git a/starpilot/system/the_galaxy/tests/test_dashboard_stats.py b/starpilot/system/the_galaxy/tests/test_dashboard_stats.py index c2904dbdc..9d757f50e 100644 --- a/starpilot/system/the_galaxy/tests/test_dashboard_stats.py +++ b/starpilot/system/the_galaxy/tests/test_dashboard_stats.py @@ -1859,9 +1859,16 @@ def test_model_profiles_can_be_selected_without_external_gpu(monkeypatch, tmp_pa assert status["activeBigModel"] == "big-one" assert status["activeSmallModel"] == "small-one" + monkeypatch.setattr(server, "external_gpu_available", lambda: True) + active_big_response = client.put("/api/models/active", json={"profile": "big", "model": "big-one"}) + assert active_big_response.status_code == 200 + assert params.values["Model"] == params.values["DrivingModel"] == "big-one" + assert params.values["DrivingModelName"] == "Big One" + disabled = client.put("/api/models/active", json={"profile": "big", "model": ""}) assert disabled.status_code == 200 assert params.values["ActiveBigModel"] == "none" + assert params.values["Model"] == params.values["DrivingModel"] == "small-one" assert disabled.get_json()["model"] == "" assert client.get("/api/models/status").get_json()["activeBigModel"] == "" @@ -1899,6 +1906,12 @@ def test_model_laboratory_api_uses_installed_models_and_enforces_hardware_size_v "ModelManifestVersion": "v25", "Model": "rdf43", "DrivingModel": "rdf43", + "ActiveSmallModel": "rdf43", + "ActiveSmallModelName": "Regret Driven Framework V4", + "ActiveSmallModelVersion": "v15", + "ActiveBigModel": "big", + "ActiveBigModelName": "Chestnut One Billion", + "ActiveBigModelVersion": "v16", }) metadata = { "lat": {"model_size": "small", "model_size_declared": True, "model_lab_eligible": True, @@ -1974,10 +1987,16 @@ def test_model_laboratory_api_uses_installed_models_and_enforces_hardware_size_v queued = client.post("/api/model-laboratory/download", json={"model": "old"}) assert queued.status_code == 200 assert params_memory.values["ModelLabModelToDownload"] == "old" - assert "precompiled AMD" in params_memory.values["ModelDownloadProgress"] + assert "eGPU variant" in params_memory.values["ModelDownloadProgress"] params_memory.remove("ModelLabModelToDownload") monkeypatch.setattr(server, "external_gpu_available", lambda: False) + (tmp_path / "old_driving_chestnut_tinygrad.pkl").unlink(missing_ok=True) + queued_without_chestnut = client.post("/api/model-laboratory/download", json={"model": "old"}) + assert queued_without_chestnut.status_code == 200 + assert params_memory.values["ModelLabModelToDownload"] == "old" + params_memory.remove("ModelLabModelToDownload") + no_chestnut = client.put("/api/model-laboratory", json={ "enabled": True, "lateralModel": "lat", @@ -1986,6 +2005,21 @@ def test_model_laboratory_api_uses_installed_models_and_enforces_hardware_size_v assert no_chestnut.status_code == 409 assert "Chestnut" in no_chestnut.get_json()["error"] + monkeypatch.setattr(server, "external_gpu_available", lambda: True) + disabled = client.put("/api/model-laboratory", json={ + "enabled": False, + "lateralModel": "lat", + "longitudinalModel": "long", + }) + assert disabled.status_code == 200 + assert params.values["Model"] == params.values["DrivingModel"] == "big" + assert params.values["DrivingModelName"] == "Chestnut One Billion" + + deleted = client.delete("/api/model-laboratory/artifact", json={"model": "lat"}) + assert deleted.status_code == 200 + assert not (tmp_path / "lat_driving_chestnut_tinygrad.pkl").exists() + assert (tmp_path / "lat_driving_tinygrad.pkl").exists() + params.values["IsOnroad"] = True onroad = client.put("/api/model-laboratory", json={"enabled": False}) assert onroad.status_code == 403 diff --git a/starpilot/system/the_galaxy/tests/test_frontend_module_graph.py b/starpilot/system/the_galaxy/tests/test_frontend_module_graph.py index 31d2d6959..ed53f2521 100644 --- a/starpilot/system/the_galaxy/tests/test_frontend_module_graph.py +++ b/starpilot/system/the_galaxy/tests/test_frontend_module_graph.py @@ -128,8 +128,13 @@ def test_model_laboratory_frontend_exposes_guards_and_role_copy(): assert 'if (!state.chestnutReady)' in source assert 'if (state.isOnroad)' in source assert "model.modelLabArtifactInstalled" in source - assert "Nothing is compiled on the comma" in source - assert "run every camera frame on Chestnut's AMD GPU" in source + assert "Download eGPU-compatible small models" in source + assert "Choose from downloaded eGPU variant combinations below" in source + assert "Download eGPU variant" in source + assert "Delete eGPU variant" in source + assert "Nothing is compiled on the comma" not in source + assert "availableModels().filter(model => model.modelLabArtifactInstalled)" in source + assert source.index("

Available models

") < source.index("

Compose a pair

") assert 'lateral.value === longitudinal.value' in source assert 'lateral.version !== longitudinal.version' not in source assert 'class="ml-chip ${' not in source @@ -146,5 +151,5 @@ def test_model_laboratory_frontend_exposes_guards_and_role_copy(): assert "longitudinalModel: selectionDirty" in source assert source.count("selectionDirty = true") == 2 assert "selectionDirty = false\n applyPayload(payload)" in source - assert 'model_laboratory.js?v=model-lab-5' in ROUTER_PATH.read_text(encoding="utf-8") - assert 'model_laboratory.css?v=model-lab-4' in INDEX_PATH.read_text(encoding="utf-8") + assert 'model_laboratory.js?v=model-lab-6' in ROUTER_PATH.read_text(encoding="utf-8") + assert 'model_laboratory.css?v=model-lab-5' in INDEX_PATH.read_text(encoding="utf-8") diff --git a/starpilot/system/the_galaxy/the_galaxy.py b/starpilot/system/the_galaxy/the_galaxy.py index fda7d7b16..e75baa3ba 100644 --- a/starpilot/system/the_galaxy/the_galaxy.py +++ b/starpilot/system/the_galaxy/the_galaxy.py @@ -6348,21 +6348,24 @@ def setup(app): }, "manifest": { "version": params.get("ModelManifestVersion", encoding="utf-8") or "unknown", - "shortcomings": [ - "The current manifest does not consistently declare model size; legacy non-Chestnut entries are treated as small.", - "The current manifest does not publish AMD-compiled variants for its ordinary small-model downloads.", - "The current manifest does not declare lateral or longitudinal quality/capability tags.", - "The current manifest does not declare output-contract compatibility, memory, or frame-time measurements.", - ], - "opportunities": [ - "Publish model_size and model_lab_eligible for every model.", - "Publish an accelerator_artifacts.chestnut entry pointing to a precompiled AMD pickle for each supported small model.", - "Publish role scores and pairing notes from replay evaluations.", - "Publish architecture, output-contract, peak-memory, and p50/p95 execution metadata.", - ], }, } + def _activate_preferred_model_profile(): + """Restore the model that the normal small/big profile system would run.""" + profile = "big" if external_gpu_available() and _active_model_key("big") else "small" + model_key, model_name, model_version = get_model_profile(params, profile) + if not model_key: + model_key, model_name, model_version = _default_model_key(), _default_model_name(), _default_model_version() + + params.put("Model", model_key) + params.put("DrivingModel", model_key) + params.put("DrivingModelName", model_name or model_key) + if model_version: + params.put("ModelVersion", model_version) + params.put("DrivingModelVersion", model_version) + return model_name or model_key + @app.route("/api/model-laboratory", methods=["GET", "PUT"]) def model_laboratory(): if request.method == "GET": @@ -6401,7 +6404,8 @@ def setup(app): params.put("DrivingModelVersion", lateral["version"]) message = "Model Laboratory enabled. The pair will load on the next drive." else: - message = "Model Laboratory disabled." + restored_model = _activate_preferred_model_profile() + message = f"Model Laboratory disabled. {restored_model} will be used next." return jsonify({"message": message, **_model_lab_status_payload()}), 200 @@ -6409,8 +6413,6 @@ def setup(app): def download_model_laboratory_artifact(): if params.get_bool("IsOnroad"): return jsonify({"error": "Model Laboratory artifacts can only be downloaded while parked."}), 403 - if not external_gpu_available(): - return jsonify({"error": "Chestnut is not connected and firmware-ready."}), 409 if ( params_memory.get_bool(MODEL_DOWNLOAD_ALL_PARAM) or (params_memory.get(MODEL_DOWNLOAD_PARAM, encoding="utf-8") or "") @@ -6424,16 +6426,50 @@ def setup(app): if model is None: return jsonify({"error": f"Unknown model '{model_key}'."}), 404 if not model.get("modelLabEligible"): - return jsonify({"error": "Only compatible small models can be prepared for Model Laboratory."}), 409 + return jsonify({"error": "Only compatible small models have Model Laboratory eGPU variants."}), 409 if not model.get("modelLabArtifactAvailable"): return jsonify({"error": "The manifest does not publish a precompiled AMD artifact for this model."}), 409 if model.get("modelLabArtifactInstalled"): - return jsonify({"message": f"\"{model['label']}\" is already prepared for Chestnut."}), 200 + return jsonify({"message": f"The eGPU variant for \"{model['label']}\" is already downloaded."}), 200 params_memory.remove(MODEL_CANCEL_DOWNLOAD_PARAM) params_memory.put(MODEL_LAB_DOWNLOAD_PARAM, model_key) - params_memory.put(MODEL_DOWNLOAD_PROGRESS_PARAM, "Downloading precompiled AMD artifact...") - return jsonify({"message": f"Started preparing \"{model['label']}\" for Chestnut."}), 200 + params_memory.put(MODEL_DOWNLOAD_PROGRESS_PARAM, "Starting eGPU variant download...") + return jsonify({"message": f"Started downloading the eGPU variant for \"{model['label']}\"."}), 200 + + @app.route("/api/model-laboratory/artifact", methods=["DELETE"]) + def delete_model_laboratory_artifact(): + if params.get_bool("IsOnroad"): + return jsonify({"error": "Model Laboratory eGPU variants can only be deleted while parked."}), 403 + if ( + params_memory.get_bool(MODEL_DOWNLOAD_ALL_PARAM) + or (params_memory.get(MODEL_DOWNLOAD_PARAM, encoding="utf-8") or "") + or (params_memory.get(MODEL_LAB_DOWNLOAD_PARAM, encoding="utf-8") or "") + ): + return jsonify({"error": "Cannot delete an eGPU variant while a model download is in progress."}), 409 + + data = request.get_json(silent=True) or {} + model_key = canonical_model_key(str(data.get("model") or "").strip()) + model = next((entry for entry in get_model_catalog() if entry["value"] == model_key), None) + if model is None: + return jsonify({"error": f"Unknown model '{model_key}'."}), 404 + if not model.get("modelLabArtifactInstalled"): + return jsonify({"message": f"No eGPU variant is downloaded for \"{model['label']}\"."}), 200 + + config = normalize_model_lab_config(params.get(MODEL_LAB_CONFIG_PARAM, encoding="utf-8") or "") + if config["enabled"] and model_key in (config["lateralModel"], config["longitudinalModel"]): + return jsonify({"error": "Disable Model Laboratory or choose a different pair before deleting this eGPU variant."}), 409 + + artifact_path = MODELS_PATH / model_accelerator_artifact_filename(model_key) + try: + artifact_path.unlink(missing_ok=True) + Path(get_manifest_path(artifact_path)).unlink(missing_ok=True) + for chunk_path in artifact_path.parent.glob(f"{artifact_path.name}.chunk*of*"): + chunk_path.unlink(missing_ok=True) + except Exception as exception: + return jsonify({"error": f"Failed deleting the eGPU variant: {exception}"}), 500 + + return jsonify({"message": f"Deleted the eGPU variant for \"{model['label']}\".", **_model_lab_status_payload()}), 200 @app.route("/api/models/preferences", methods=["GET", "PUT"]) def get_or_set_models_preferences(): @@ -6487,8 +6523,9 @@ def setup(app): params.remove(MODEL_LAB_RUNTIME_PARAM) disable_big_model_profile(params) + restored_model = _activate_preferred_model_profile() return jsonify({ - "message": "Active Big disabled. Active Small will be used even when Chestnut is connected.", + "message": f"Active Big disabled. {restored_model} will be used even when Chestnut is connected.", "profile": profile, "model": "", }), 200 @@ -6510,8 +6547,9 @@ def setup(app): params.remove(MODEL_LAB_RUNTIME_PARAM) set_model_profile(params, profile, model_key, model["label"], model["version"]) + active_model = _activate_preferred_model_profile() return jsonify({ - "message": f"Active {profile.title()} set to '{model['label']}'.", + "message": f"Active {profile.title()} set to '{model['label']}'. {active_model} will be used next.", "profile": profile, "model": model_key, }), 200