The Rice Cake

This commit is contained in:
firestar5683
2026-09-08 10:52:51 -05:00
parent b91ea3e1da
commit 0b5ccb31e1
29 changed files with 579 additions and 185 deletions
+1 -1
View File
@@ -464,7 +464,7 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> 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}},
@@ -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,
+1 -1
View File
@@ -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
@@ -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(
@@ -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
+6 -3
View File
@@ -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/"
)
}
+11 -1
View File
@@ -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)))
+58 -3
View File
@@ -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):
@@ -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,
@@ -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
+1 -1
View File
@@ -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)
@@ -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)
@@ -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)
+35 -3
View File
@@ -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"""
+44 -3
View File
@@ -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]
+1 -6
View File
@@ -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)
@@ -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
+2 -2
View File
@@ -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)
+64 -34
View File
@@ -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")
@@ -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)
@@ -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"
@@ -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;
@@ -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`
<div class="ml-model">
<div>
@@ -248,8 +274,15 @@ function renderModel(model) {
${artifactStatus}
</span>
${model.modelLabArtifactAvailable && !model.modelLabArtifactInstalled ? html`
<button class="ml-button" data-ml-download="${model.value}" disabled="${() => state.saving || state.isOnroad}">
Prepare for Chestnut
<button class="ml-button" data-ml-download="${model.value}" disabled="${() => state.saving || state.isOnroad || Boolean(state.download?.model)}">
${() => state.download?.model === model.value
? `Downloading · ${state.download?.progress || "starting…"}`
: "Download eGPU variant"}
</button>
` : ""}
${model.modelLabArtifactInstalled ? html`
<button class="ml-button ml-button-danger" data-ml-delete="${model.value}" disabled="${() => state.saving || state.isOnroad || Boolean(state.download?.model)}">
Delete eGPU variant
</button>
` : ""}
</div>
@@ -287,11 +320,22 @@ export function ModelLaboratory() {
${() => state.loading ? html`<div class="ml-card">Loading laboratory status…</div>` : ""}
${() => !state.loading ? html`
<section class="ml-card">
<div class="ml-card-heading">
<div>
<h3>Available models</h3>
<p>Download eGPU-compatible small models. These are separate from the small models in Model Manager because they are compiled for the eGPU.</p>
<p>${() => `${state.summary.ready || 0} downloaded · ${Math.max((state.summary.published || 0) - (state.summary.ready || 0), 0)} available to download.`}</p>
</div>
</div>
<div class="ml-model-list">${() => availableModels().map(renderModel)}</div>
</section>
<section class="ml-card">
<div class="ml-card-heading">
<div>
<h3>Compose a pair</h3>
<p>Both precompiled small models stay resident and run every camera frame on Chestnut's AMD GPU.</p>
<p>Choose from downloaded eGPU variant combinations below.</p>
</div>
<span class="${() => `ml-state ${state.configuration.enabled ? "is-enabled" : ""}`}">
${() => state.configuration.enabled ? "Enabled" : "Disabled"}
@@ -364,20 +408,6 @@ export function ModelLaboratory() {
<p class="ml-muted">Both roles evaluate the same frame at 20 Hz. A runtime failure suppresses that frame and falls back to the built-in QCOM model.</p>
</section>
<section class="ml-card">
<div class="ml-card-heading">
<div>
<h3>Available models</h3>
<p>${() => `${state.summary.ready || 0} ready to pair · ${Math.max((state.summary.published || 0) - (state.summary.ready || 0), 0)} available to download.`}</p>
</div>
</div>
<div class="ml-model-list">${() => readyModels().map(renderModel)}</div>
<div class="ml-note">
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.
</div>
</section>
` : ""}
</div>
`
@@ -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()) },
@@ -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: `
<div class="gx-view">
@@ -177,12 +198,41 @@ export const ModelLaboratory = {
</div>
</div>
<div class="gx-card">
<div class="gx-section__header">
<i class="bi bi-cpu"></i>
<span class="gx-section__title">Available models</span>
<span class="gx-section__count">{{ summary.ready || 0 }} downloaded · {{ Math.max((summary.published || 0) - (summary.ready || 0), 0) }} available to download</span>
</div>
<div style="padding: 0 var(--sp-4) var(--sp-3); color:var(--text-muted); font-size:var(--fs-sm);">
Download eGPU-compatible small models. These are separate from the small models in Model Manager because they are compiled for the eGPU.
</div>
<article v-for="m in availableModels" :key="m.value" class="gx-row">
<div class="gx-row__info">
<span class="gx-row__label">{{ m.label }}</span>
<span class="gx-row__desc">{{ m.value }} · {{ m.series || 'Unknown series' }}</span>
</div>
<div style="display:flex; gap:6px; flex-wrap:wrap; align-items:center;">
<span class="gx-chip">{{ m.version || 'unknown version' }}</span>
<span class="gx-chip">{{ m.modelSize || 'small' }}</span>
<span class="gx-chip" :style="artifactStatus(m).good ? 'color:var(--success);' : 'color:var(--warning);'">{{ artifactStatus(m).text }}</span>
<button v-if="!m.modelLabArtifactInstalled" type="button" class="gx-btn gx-btn--tonal" :disabled="saving || isOnroad || !!download.model" @click="prepareModel(m.value)">
{{ download.model === m.value ? 'Downloading · ' + (download.progress || 'starting…') : 'Download eGPU variant' }}
</button>
<button v-else type="button" class="gx-btn gx-btn--tonal" style="color:var(--error);" :disabled="saving || isOnroad || !!download.model" @click="deleteModel(m.value)">
Delete eGPU variant
</button>
</div>
</article>
</div>
<div class="gx-card">
<div class="gx-section__header">
<i class="bi bi-collection"></i>
<span class="gx-section__title">Compose a pair</span>
<span class="gx-chip" :style="configuration.enabled ? 'background:var(--success);color:var(--on-secondary);' : ''">{{ configuration.enabled ? 'Enabled' : 'Disabled' }}</span>
</div>
<div style="padding: 0 var(--sp-4); color:var(--text-muted); font-size:var(--fs-sm);">Choose from downloaded eGPU variant combinations below.</div>
<div style="padding: var(--sp-4); display:grid; gap:var(--sp-3);">
<label style="display:grid; gap:4px;">
<strong style="font-size:var(--fs-sm);">Lateral model</strong>
@@ -234,30 +284,6 @@ export const ModelLaboratory = {
</div>
</div>
<div class="gx-card">
<div class="gx-section__header">
<i class="bi bi-cpu"></i>
<span class="gx-section__title">Available models</span>
<span class="gx-section__count">{{ summary.ready || 0 }} ready to pair · {{ Math.max((summary.published || 0) - (summary.ready || 0), 0) }} available to download</span>
</div>
<article v-for="m in readyModels" :key="m.value" class="gx-row">
<div class="gx-row__info">
<span class="gx-row__label">{{ m.label }}</span>
<span class="gx-row__desc">{{ m.value }} · {{ m.series || 'Unknown series' }}</span>
</div>
<div style="display:flex; gap:6px; flex-wrap:wrap; align-items:center;">
<span class="gx-chip">{{ m.version || 'unknown version' }}</span>
<span class="gx-chip">{{ m.modelSize || 'small' }}</span>
<span class="gx-chip" :style="artifactStatus(m).good ? 'color:var(--success);' : 'color:var(--warning);'">{{ artifactStatus(m).text }}</span>
<button v-if="m.modelLabArtifactAvailable && !m.modelLabArtifactInstalled" type="button" class="gx-btn gx-btn--tonal" :disabled="saving || isOnroad" @click="prepareModel(m.value)">
Prepare for Chestnut
</button>
</div>
</article>
<div style="padding: var(--sp-3);">
<p class="gx-row__desc" style="margin:0;">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.</p>
</div>
</div>
</template>
</div>
`,
@@ -30,7 +30,7 @@
<link rel="stylesheet" href="/assets/components/tools/error_logs.css">
<link rel="stylesheet" href="/assets/components/tools/maps.css">
<link rel="stylesheet" href="/assets/components/tools/model_manager.css">
<link rel="stylesheet" href="/assets/components/tools/model_laboratory.css?v=model-lab-4">
<link rel="stylesheet" href="/assets/components/tools/model_laboratory.css?v=model-lab-5">
<link rel="stylesheet" href="/assets/components/tools/plots.css">
<link rel="stylesheet" href="/assets/components/tools/speed_limits.css">
<link rel="stylesheet" href="/assets/components/tools/theme_maker.css">
@@ -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
@@ -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("<h3>Available models</h3>") < source.index("<h3>Compose a pair</h3>")
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")
+59 -21
View File
@@ -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