mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-09-09 01:23:43 +08:00
The Rice Cake
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user