diff --git a/common/params_keys.h b/common/params_keys.h
index f874a3795..c03e5839c 100644
--- a/common/params_keys.h
+++ b/common/params_keys.h
@@ -510,6 +510,9 @@ inline static std::unordered_map keys = {
{"ModeButtonControl", {PERSISTENT, INT, "0", "0", 2, SETTINGS_SIMPLE}},
{"ModelDownloadProgress", {CLEAR_ON_MANAGER_START, STRING, "", ""}},
{"ModelDrivesAndScores", {PERSISTENT, JSON, "{}", "{}"}},
+ {"ModelLabConfig", {PERSISTENT, JSON, "{}", "{}"}},
+ {"ModelLabModelToDownload", {CLEAR_ON_MANAGER_START, STRING, "", ""}},
+ {"ModelLabRuntime", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, JSON, "{}", "{}"}},
{"ModelReleasedDates", {PERSISTENT, STRING, "", "", 1}},
{"ModelRandomizer", {PERSISTENT, BOOL, "0", "0", 2}},
{"LatSmoothSeconds", {PERSISTENT, FLOAT, "0.1", "0.1", 3}},
@@ -705,7 +708,6 @@ inline static std::unordered_map keys = {
{"SubaruSNG", {PERSISTENT, BOOL, "1", "0", 2, SETTINGS_SIMPLE}},
{"SubaruSNGManualParkingBrake", {PERSISTENT, BOOL, "0", "0", 2, SETTINGS_SIMPLE}},
{"SubaruStopStartOff", {PERSISTENT, BOOL, "0", "0", 2, SETTINGS_SIMPLE}},
- {"SubaruAvhOnAtStartup", {PERSISTENT, BOOL, "0", "0", 2, SETTINGS_SIMPLE}},
{"TacoTune", {PERSISTENT, BOOL, "0", "0", 2}},
{"TeslaCoopSteering", {PERSISTENT, BOOL, "0", "0", 2, SETTINGS_SIMPLE}},
{"TestAlert", {CLEAR_ON_MANAGER_START, STRING, "", ""}},
diff --git a/opendbc_repo/opendbc/car/gm/gmcan.py b/opendbc_repo/opendbc/car/gm/gmcan.py
index 3f8a672c9..6a13e83b3 100644
--- a/opendbc_repo/opendbc/car/gm/gmcan.py
+++ b/opendbc_repo/opendbc/car/gm/gmcan.py
@@ -28,6 +28,9 @@ BOLT_CC_BUTTON_CARS = {
BOLT_CC_TARGET_DEADBAND_MPH = 0.75
BOLT_CC_REVERSE_CONFIRM_S = 0.6
BOLT_CC_DIRECTION_MEMORY_S = 1.5
+VOLT_CC_CARS = {
+ CAR.CHEVROLET_VOLT_CC,
+}
def malibu_phase_map_for_button(button):
@@ -336,6 +339,28 @@ def stabilize_bolt_cc_button(controller, CP, requested_button):
return requested_button
+def _create_volt_cc_spam_command(CS, actuators, ms_convert):
+ accel = float(actuators.accel)
+ speed_setpoint = int(round(CS.out.cruiseState.speed * ms_convert))
+ ego_speed = CS.out.vEgo * ms_convert
+
+ if accel == 0.0:
+ return CruiseButtons.INIT, float("inf")
+
+ if accel < 0.0:
+ if speed_setpoint > ego_speed + 3.0:
+ rate = 0.2
+ else:
+ rate = max(1.0 / (-accel * ms_convert), 0.2)
+ return CruiseButtons.DECEL_SET, rate
+
+ if speed_setpoint < ego_speed - 3.0:
+ rate = 0.2
+ else:
+ rate = max(1.0 / (accel * ms_convert), 0.2)
+ return CruiseButtons.RES_ACCEL, rate
+
+
def create_gm_cc_spam_command(packer, controller, CS, actuators, starpilot_toggles):
accel = actuators.accel
v_ego = CS.out.vEgo
@@ -350,12 +375,15 @@ def create_gm_cc_spam_command(packer, controller, CS, actuators, starpilot_toggl
target_deadband = BOLT_CC_TARGET_DEADBAND_MPH * (CV.MPH_TO_KPH if is_metric else 1.0) if bolt_cc else 0.0
comparison_setpoint = projected_setpoint if bolt_cc else desired_setpoint
- if CS.CP.minEnableSpeed - (desired_setpoint / ms_convert) > 3.25:
- cruise_btn = CruiseButtons.CANCEL
- elif comparison_setpoint < speed_setpoint - target_deadband and speed_setpoint > CS.CP.minEnableSpeed * ms_convert + 1:
- cruise_btn = CruiseButtons.DECEL_SET
- elif comparison_setpoint > speed_setpoint + target_deadband:
- cruise_btn = CruiseButtons.RES_ACCEL
+ if CS.CP.carFingerprint in VOLT_CC_CARS:
+ cruise_btn, rate = _create_volt_cc_spam_command(CS, actuators, ms_convert)
+ else:
+ if CS.CP.minEnableSpeed - (desired_setpoint / ms_convert) > 3.25:
+ cruise_btn = CruiseButtons.CANCEL
+ elif comparison_setpoint < speed_setpoint - target_deadband and speed_setpoint > CS.CP.minEnableSpeed * ms_convert + 1:
+ cruise_btn = CruiseButtons.DECEL_SET
+ elif comparison_setpoint > speed_setpoint + target_deadband:
+ cruise_btn = CruiseButtons.RES_ACCEL
cruise_btn = stabilize_bolt_cc_button(controller, CS.CP, cruise_btn)
if cruise_btn == CruiseButtons.CANCEL:
diff --git a/opendbc_repo/opendbc/car/gm/tests/test_gm.py b/opendbc_repo/opendbc/car/gm/tests/test_gm.py
index b8cf95273..1d0040bde 100644
--- a/opendbc_repo/opendbc/car/gm/tests/test_gm.py
+++ b/opendbc_repo/opendbc/car/gm/tests/test_gm.py
@@ -657,6 +657,60 @@ class TestGMCarController:
assert [msg[2] for msg in msgs] == [0, 2]
+ def test_volt_cc_redneck_holds_setpoint_without_planner_acceleration(self):
+ packer = CANPacker(DBC[CAR.CHEVROLET_VOLT_CC][Bus.pt])
+ controller = SimpleNamespace(frame=int(2.0 / DT_CTRL), last_button_frame=0, apply_speed=0, malibu_button_phase=0)
+ cs = SimpleNamespace(
+ CP=SimpleNamespace(
+ carFingerprint=CAR.CHEVROLET_VOLT_CC,
+ flags=GMFlags.NO_CAMERA.value,
+ networkLocation=structs.CarParams.NetworkLocation.gateway,
+ minEnableSpeed=0.0,
+ ),
+ buttons_counter=2,
+ out=SimpleNamespace(
+ vEgo=60.0 * CV.KPH_TO_MS,
+ cruiseState=SimpleNamespace(speed=60.0 * CV.KPH_TO_MS),
+ ),
+ )
+
+ msgs = gmcan.create_gm_cc_spam_command(
+ packer, controller, cs, SimpleNamespace(accel=0.0), SimpleNamespace(is_metric=True),
+ )
+
+ assert msgs == []
+ assert controller.apply_speed == 60
+
+ def test_volt_cc_redneck_rate_limits_setpoint_changes_by_planner_acceleration(self):
+ packer = CANPacker(DBC[CAR.CHEVROLET_VOLT_CC][Bus.pt])
+ controller = SimpleNamespace(frame=int(0.5 / DT_CTRL), last_button_frame=0, apply_speed=0, malibu_button_phase=0)
+ cs = SimpleNamespace(
+ CP=SimpleNamespace(
+ carFingerprint=CAR.CHEVROLET_VOLT_CC,
+ flags=GMFlags.NO_CAMERA.value,
+ networkLocation=structs.CarParams.NetworkLocation.gateway,
+ minEnableSpeed=0.0,
+ ),
+ buttons_counter=2,
+ out=SimpleNamespace(
+ vEgo=60.0 * CV.KPH_TO_MS,
+ cruiseState=SimpleNamespace(speed=60.0 * CV.KPH_TO_MS),
+ ),
+ )
+
+ msgs = gmcan.create_gm_cc_spam_command(
+ packer, controller, cs, SimpleNamespace(accel=0.5), SimpleNamespace(is_metric=True),
+ )
+
+ assert msgs == []
+
+ controller.frame = int(0.7 / DT_CTRL)
+ msgs = gmcan.create_gm_cc_spam_command(
+ packer, controller, cs, SimpleNamespace(accel=0.5), SimpleNamespace(is_metric=True),
+ )
+
+ assert len(msgs) == 1
+
def test_volt_cc_no_camera_redneck_spam_stays_on_powertrain_bus(self):
packer = CANPacker(DBC[CAR.CHEVROLET_VOLT_CC][Bus.pt])
controller = SimpleNamespace(frame=int(0.3 / DT_CTRL), last_button_frame=0, apply_speed=0, malibu_button_phase=0)
diff --git a/opendbc_repo/opendbc/car/hyundai/carcontroller.py b/opendbc_repo/opendbc/car/hyundai/carcontroller.py
index 22d15a9a7..ce2f3fae1 100644
--- a/opendbc_repo/opendbc/car/hyundai/carcontroller.py
+++ b/opendbc_repo/opendbc/car/hyundai/carcontroller.py
@@ -24,6 +24,9 @@ LongCtrlState = structs.CarControl.Actuators.LongControlState
MAX_ANGLE = 85
MAX_ANGLE_FRAMES = 89
MAX_ANGLE_CONSECUTIVE_FRAMES = 2
+
+CANCEL_BUTTON_DELAY_FRAMES = 10
+
CANFD_BLINDSPOT_STATUS_STALE_NS = 200_000_000
CANFD_CAMERA_LEAD_STALE_NS = 300_000_000
CANFD_LEAD_MIN_DISTANCE = 0.1
@@ -454,6 +457,7 @@ class CarController(CarControllerBase):
self.apply_angle_last = 0.0
self.car_fingerprint = CP.carFingerprint
self.last_button_frame = 0
+ self.cancel_counter = 0
self.redneck_button_frame = 0
self.ecu_disable_failed = False
self._ecu_disable_checked = False
@@ -717,6 +721,8 @@ class CarController(CarControllerBase):
if self.CP.flags & HyundaiFlags.ENABLE_BLINKERS:
can_sends.append(make_tester_present_msg(0x7b1, self.CAN.ECAN, suppress_response=True))
+ self.cancel_counter = self.cancel_counter + 1 if CC.cruiseControl.cancel else 0
+
# *** CAN/CAN FD specific ***
if self.CP.flags & HyundaiFlags.CANFD:
can_sends.extend(self.create_canfd_msgs(now_nanos, apply_steer_req, apply_torque, apply_angle, set_speed_in_units, accel,
@@ -782,7 +788,7 @@ class CarController(CarControllerBase):
# Button messages
if not self.long_active_ecu:
- if CC.cruiseControl.cancel:
+ if self.cancel_counter > CANCEL_BUTTON_DELAY_FRAMES:
can_sends.append(hyundaican.create_clu11(self.packer, self.frame, CS.clu11, Buttons.CANCEL, self.CP))
elif CC.cruiseControl.resume:
# send resume at a max freq of 10Hz
@@ -1046,7 +1052,7 @@ class CarController(CarControllerBase):
if self.CP.flags & HyundaiFlags.CANFD_ALT_BUTTONS:
can_sends.append(hyundaicanfd.create_acc_cancel(self.packer, self.CP, self.CAN, CS.cruise_info))
self.last_button_frame = self.frame
- else:
+ elif self.cancel_counter > CANCEL_BUTTON_DELAY_FRAMES:
for _ in range(20):
can_sends.append(hyundaicanfd.create_buttons(self.packer, self.CP, self.CAN, CS.buttons_counter + 1, Buttons.CANCEL))
self.last_button_frame = self.frame
diff --git a/opendbc_repo/opendbc/car/hyundai/interface.py b/opendbc_repo/opendbc/car/hyundai/interface.py
index b101b68f3..ac312fb92 100644
--- a/opendbc_repo/opendbc/car/hyundai/interface.py
+++ b/opendbc_repo/opendbc/car/hyundai/interface.py
@@ -48,7 +48,7 @@ def apply_platform_longitudinal_params(ret: structs.CarParams) -> None:
def apply_kia_ev6_gt_line_longitudinal_params(ret: structs.CarParams) -> None:
ret.startAccel = 1.4
- ret.longitudinalActuatorDelay = 0.35
+ ret.longitudinalActuatorDelay = 0.5
ret.vEgoStarting = 0.5
diff --git a/opendbc_repo/opendbc/car/hyundai/tests/test_hyundai.py b/opendbc_repo/opendbc/car/hyundai/tests/test_hyundai.py
index 0548750d4..22bf3ca56 100644
--- a/opendbc_repo/opendbc/car/hyundai/tests/test_hyundai.py
+++ b/opendbc_repo/opendbc/car/hyundai/tests/test_hyundai.py
@@ -7,7 +7,7 @@ from opendbc.can import CANPacker, CANParser
from opendbc.car import Bus, ButtonType, gen_empty_fingerprint, structs
from opendbc.car.structs import CarControl, CarParams
from opendbc.car.fw_versions import build_fw_dict, match_fw_to_car
-from opendbc.car.hyundai.carcontroller import CarController, Ioniq6LongitudinalTuningState, GenesisG90LongitudinalTuningState, \
+from opendbc.car.hyundai.carcontroller import CarController, CANCEL_BUTTON_DELAY_FRAMES, Ioniq6LongitudinalTuningState, GenesisG90LongitudinalTuningState, \
EV9LongitudinalTuningState, update_ev9_longitudinal_tuning, \
BlindspotWarningState, update_blindspot_warning, \
reset_egmp_longitudinal_tuning, \
@@ -783,6 +783,36 @@ class TestHyundaiFingerprint:
assert not any(addr == 0x340 for addr, _, _ in first)
assert any(addr == 0x340 for addr, _, _ in second)
+ def test_stock_scc_cancel_waits_for_factory_disengagement(self):
+ CP = CarInterface.get_params(CAR.HYUNDAI_SANTA_FE_2022, gen_empty_fingerprint(), [], False, False, False, None)
+ controller = CarController(DBC[CP.carFingerprint], CP)
+ parser = CANParser(DBC[CP.carFingerprint][Bus.pt], [("LKAS11", 0), ("CLU11", 0)], 0)
+
+ hud_control = SimpleNamespace(
+ visualAlert=CarControl.HUDControl.VisualAlert.none,
+ leftLaneVisible=True,
+ rightLaneVisible=True,
+ leftLaneDepart=False,
+ rightLaneDepart=False,
+ )
+ CS = SimpleNamespace(
+ lkas11=parser.vl["LKAS11"],
+ clu11=parser.vl["CLU11"],
+ redneck_send_button=Buttons.NONE,
+ is_metric=False,
+ )
+ CC = SimpleNamespace(enabled=False, cruiseControl=SimpleNamespace(cancel=True, resume=False))
+ actuators = SimpleNamespace(longControlState=LongCtrlState.off)
+
+ for counter in range(1, CANCEL_BUTTON_DELAY_FRAMES + 1):
+ controller.cancel_counter = counter
+ msgs = controller.create_can_msgs(True, 0, False, 0.0, 0.0, False, hud_control, actuators, CS, CC, 2, 2)
+ assert not any(addr == 0x4F1 for addr, _, _ in msgs)
+
+ controller.cancel_counter = CANCEL_BUTTON_DELAY_FRAMES + 1
+ msgs = controller.create_can_msgs(True, 0, False, 0.0, 0.0, False, hud_control, actuators, CS, CC, 2, 2)
+ assert any(addr == 0x4F1 for addr, _, _ in msgs)
+
@pytest.mark.parametrize("candidate", (CAR.HYUNDAI_ELANTRA_2024, CAR.HYUNDAI_ELANTRA_HEV_2024))
def test_hyundai_can_refresh_platforms_use_refresh_dbc_and_safety_param(self, candidate):
CP = CarInterface.get_params(candidate, gen_empty_fingerprint(), [], False, False, False, None)
@@ -1281,7 +1311,7 @@ class TestHyundaiFingerprint:
assert CP.startAccel == pytest.approx(1.4)
assert CP.vEgoStarting == pytest.approx(0.5)
- assert CP.longitudinalActuatorDelay == pytest.approx(0.35)
+ assert CP.longitudinalActuatorDelay == pytest.approx(0.5)
assert CP.vEgoStopping == pytest.approx(0.3)
assert CP.stoppingDecelRate == pytest.approx(0.4)
assert kia_ev6_gt_line_longitudinal_tuning(CP.carFingerprint, CP.carVin)
@@ -1310,7 +1340,7 @@ class TestHyundaiFingerprint:
assert CP.startAccel == pytest.approx(1.4)
assert CP.vEgoStarting == pytest.approx(0.5)
- assert CP.longitudinalActuatorDelay == pytest.approx(0.35)
+ assert CP.longitudinalActuatorDelay == pytest.approx(0.5)
assert kia_ev6_gt_line_longitudinal_tuning(CP.carFingerprint, CP.carVin, testing_ground_active=True)
assert not kia_ev6_gt_line_longitudinal_tuning(CAR.KIA_EV6_2025, CP.carVin, testing_ground_active=True)
diff --git a/opendbc_repo/opendbc/car/subaru/carcontroller.py b/opendbc_repo/opendbc/car/subaru/carcontroller.py
index 3815dfd77..bde3669b9 100644
--- a/opendbc_repo/opendbc/car/subaru/carcontroller.py
+++ b/opendbc_repo/opendbc/car/subaru/carcontroller.py
@@ -4,7 +4,7 @@ from opendbc.car import Bus, DT_CTRL, make_tester_present_msg, structs
from opendbc.car.lateral import apply_driver_steer_torque_limits, apply_std_steer_angle_limits, apply_steer_angle_limits_vm, common_fault_avoidance
from opendbc.car.interfaces import CarControllerBase
from opendbc.car.subaru import subarucan
-from opendbc.car.subaru.values import CAR, DBC, GLOBAL_ES_ADDR, SUBARU_AVH_CARS, SUBARU_STOP_START_CARS, CanBus, CarControllerParams, SubaruFlags
+from opendbc.car.subaru.values import CAR, DBC, GLOBAL_ES_ADDR, SUBARU_STOP_START_CARS, CanBus, CarControllerParams, SubaruFlags
from opendbc.car.vehicle_model import VehicleModel
# FIXME: These limits aren't exact. The real limit is more than likely over a larger time period and
@@ -37,9 +37,6 @@ _STOP_START_STARTUP_DELAY_FRAMES = 100
_STOP_START_STARTUP_DEADLINE_FRAMES = 1000
_STOP_START_PULSE_FRAMES = 30
_STOP_START_PULSE_PERIOD_FRAMES = 5
-_AVH_STARTUP_DELAY_FRAMES = _STOP_START_STARTUP_DELAY_FRAMES
-_AVH_STARTUP_DEADLINE_FRAMES = _STOP_START_STARTUP_DEADLINE_FRAMES
-_AVH_PULSE_MESSAGES = 15 # Match the native 10 Hz AVH frame for roughly 1.5 seconds
def get_safety_CP():
@@ -90,10 +87,6 @@ class CarController(CarControllerBase):
self.stop_start_initial_state = None
self.stop_start_counter = 0
self.stop_start_acknowledged = False
- self.avh_attempted = False
- self.avh_request_started = False
- self.avh_last_counter = None
- self.avh_messages_sent = 0
def _stop_start_off_request(self, CC, CS, starpilot_toggles):
"""Send one bounded Subaru Stop/Start OFF request after ignition.
@@ -150,55 +143,6 @@ class CarController(CarControllerBase):
self.stop_start_counter = (self.stop_start_counter + 1) % 0x10
return msg
- def _avh_on_request(self, CC, CS, starpilot_toggles):
- """Send a bounded Subaru AVH ON pulse after ignition.
-
- The AVH button frame was identified on the 2025 Legacy only. Keep this
- independent from Stop/Start so the existing Outback request is unchanged.
- """
- if self.CP.carFingerprint not in SUBARU_AVH_CARS or \
- not getattr(starpilot_toggles, "subaru_avh_on", False) or self.avh_attempted:
- return None
-
- if self.frame > _AVH_STARTUP_DEADLINE_FRAMES or getattr(CC, "enabled", False):
- self.avh_attempted = True
- return None
-
- if self.frame < _AVH_STARTUP_DELAY_FRAMES or not getattr(getattr(CS, "out", None), "canValid", True):
- return None
-
- out = CS.out
- if not getattr(out, "standstill", False) or out.gearShifter not in (
- structs.CarState.GearShifter.park,
- structs.CarState.GearShifter.neutral,
- ):
- return None
-
- avh_msg = getattr(CS, "avh_msg", None)
- avh_dat = getattr(CS, "avh_dat", None)
- if not avh_msg or not avh_dat:
- return None
-
- if not self.avh_request_started:
- self.avh_request_started = True
- self.avh_last_counter = int(avh_msg.get("COUNTER", 0)) % 0x10
-
- if self.avh_messages_sent >= _AVH_PULSE_MESSAGES:
- self.avh_attempted = True
- return None
-
- counter = int(avh_msg.get("COUNTER", 0)) % 0x10
- if counter == self.avh_last_counter:
- return None
-
- msg = subarucan.create_avh_control(
- self.packer, avh_msg, raw_dat=avh_dat,
- counter=counter, bus=CanBus.alt_for_cp(self.CP),
- )
- self.avh_last_counter = counter
- self.avh_messages_sent += 1
- return msg
-
def _reset_legacy_2025_handoff(self):
self.driver_override = False
self.angle_override_confirm_frames = 0
@@ -472,10 +416,6 @@ class CarController(CarControllerBase):
if stop_start_msg is not None:
can_sends.append(stop_start_msg)
- avh_msg = self._avh_on_request(CC, CS, starpilot_toggles)
- if avh_msg is not None:
- can_sends.append(avh_msg)
-
# *** steering ***
if (self.frame % self.p.STEER_STEP) == 0:
if self.CP.flags & SubaruFlags.LKAS_ANGLE:
diff --git a/opendbc_repo/opendbc/car/subaru/carstate.py b/opendbc_repo/opendbc/car/subaru/carstate.py
index 985ef31f0..cee89d2df 100644
--- a/opendbc_repo/opendbc/car/subaru/carstate.py
+++ b/opendbc_repo/opendbc/car/subaru/carstate.py
@@ -4,7 +4,7 @@ from opendbc.can import CANDefine, CANParser
from opendbc.car import Bus, structs
from opendbc.car.common.conversions import Conversions as CV
from opendbc.car.interfaces import CarStateBase
-from opendbc.car.subaru.values import DBC, CanBus, SUBARU_AVH_CARS, SUBARU_STOP_START_CARS, SubaruFlags
+from opendbc.car.subaru.values import DBC, CanBus, SUBARU_STOP_START_CARS, SubaruFlags
from opendbc.car import CanSignalRateCalculator
@@ -18,8 +18,6 @@ class CarState(CarStateBase):
self.dashlights_msg = {}
self.dashlights_dat = b""
self.stop_start_state = 0
- self.avh_msg = {}
- self.avh_dat = b""
def update(self, can_parsers, starpilot_toggles) -> structs.CarState:
cp = can_parsers[Bus.pt]
@@ -35,11 +33,6 @@ class CarState(CarStateBase):
self.dashlights_dat = stop_start_cp.vl_raw["Dashlights"]
self.stop_start_state = stop_start_cp.vl["Engine_Stop_Start"]["STOP_START_STATE"]
- if self.CP.carFingerprint in SUBARU_AVH_CARS:
- avh_cp = cp_alt if self.CP.flags & SubaruFlags.GLOBAL_GEN2 else cp
- self.avh_msg = copy.copy(avh_cp.vl["AVH"])
- self.avh_dat = avh_cp.vl_raw["AVH"]
-
throttle_msg = cp.vl["Throttle"] if not (self.CP.flags & SubaruFlags.HYBRID) else cp_alt.vl["Throttle_Hybrid"]
ret.gasPressed = throttle_msg["Throttle_Pedal"] > 1e-5
if self.CP.flags & SubaruFlags.PREGLOBAL:
@@ -163,11 +156,10 @@ class CarState(CarStateBase):
@staticmethod
def get_can_parsers(CP):
- avh_messages = [("AVH", 0)] if CP.carFingerprint in SUBARU_AVH_CARS else []
parsers = {
Bus.pt: CANParser(DBC[CP.carFingerprint][Bus.pt], [], CanBus.main_for_cp(CP)),
Bus.cam: CANParser(DBC[CP.carFingerprint][Bus.pt], [], CanBus.camera),
- Bus.alt: CANParser(DBC[CP.carFingerprint][Bus.pt], avh_messages, CanBus.alt_for_cp(CP))
+ Bus.alt: CANParser(DBC[CP.carFingerprint][Bus.pt], [], CanBus.alt_for_cp(CP))
}
if CP.flags & SubaruFlags.D_PLATFORM:
parsers[Bus.main] = CANParser(DBC[CP.carFingerprint][Bus.pt], [], CanBus.main)
diff --git a/opendbc_repo/opendbc/car/subaru/interface.py b/opendbc_repo/opendbc/car/subaru/interface.py
index 35f04d2b3..741551a0a 100644
--- a/opendbc_repo/opendbc/car/subaru/interface.py
+++ b/opendbc_repo/opendbc/car/subaru/interface.py
@@ -3,7 +3,7 @@ from opendbc.car.disable_ecu import disable_ecu
from opendbc.car.interfaces import CarInterfaceBase
from opendbc.car.subaru.carcontroller import CarController
from opendbc.car.subaru.carstate import CarState
-from opendbc.car.subaru.values import CAR, CanBus, GLOBAL_ES_ADDR, SUBARU_AVH_CARS, SUBARU_STOP_START_CARS, SubaruFlags, SubaruSafetyFlags
+from opendbc.car.subaru.values import CAR, CanBus, GLOBAL_ES_ADDR, SUBARU_STOP_START_CARS, SubaruFlags, SubaruSafetyFlags
class CarInterface(CarInterfaceBase):
@@ -42,8 +42,6 @@ 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 SUBARU_AVH_CARS:
- ret.safetyConfigs[0].safetyParam |= SubaruSafetyFlags.AVH_BUTTON.value
if candidate in (CAR.SUBARU_LEGACY_2025, CAR.SUBARU_ASCENT_2023):
ret.safetyConfigs[0].safetyParam |= SubaruSafetyFlags.FIXED_ANGLE_LIMITS.value
diff --git a/opendbc_repo/opendbc/car/subaru/subarucan.py b/opendbc_repo/opendbc/car/subaru/subarucan.py
index 6e53ccbc9..1d2d04d2e 100644
--- a/opendbc_repo/opendbc/car/subaru/subarucan.py
+++ b/opendbc_repo/opendbc/car/subaru/subarucan.py
@@ -208,31 +208,6 @@ def create_stop_start_control(packer, dashlights_msg, raw_dat=None, counter=None
return packer.make_can_msg("Dashlights", bus, values)
-def create_avh_control(packer, avh_msg, raw_dat=None, counter=None, bus=CanBus.alt):
- """Create the supported Subaru Legacy AVH ON request.
-
- AVH is carried in the live 0x32b frame. Preserve the other bytes and update
- only the rolling counter, AVH bit, and Subaru additive checksum.
- """
- if raw_dat:
- dat = bytearray(raw_dat)
- if len(dat) != 8:
- raise ValueError(f"AVH frame must be 8 bytes, got {len(dat)}")
- if counter is None:
- counter = (int(avh_msg.get("COUNTER", 0)) + 1) % 0x10
- dat[1] = (dat[1] & 0xF0) | (counter % 0x10)
- dat[5] |= 0x20 # AVH, big-endian bit 45
- dat[0] = ((0x32B & 0xFF) + ((0x32B >> 8) & 0xFF) + sum(dat[1:])) & 0xFF
- return 0x32B, bytes(dat), bus
-
- values = dict(avh_msg)
- if counter is None:
- counter = (int(values.get("COUNTER", 0)) + 1) % 0x10
- values["COUNTER"] = counter % 0x10
- values["AVH"] = 1
- return packer.make_can_msg("AVH", bus, values)
-
-
def create_es_brake(packer, frame, es_brake_msg, long_enabled, long_active, brake_value, bus=CanBus.main):
values = {s: es_brake_msg[s] for s in [
"CHECKSUM",
diff --git a/opendbc_repo/opendbc/car/subaru/tests/test_subaru.py b/opendbc_repo/opendbc/car/subaru/tests/test_subaru.py
index 752b8b277..d6ce08e54 100644
--- a/opendbc_repo/opendbc/car/subaru/tests/test_subaru.py
+++ b/opendbc_repo/opendbc/car/subaru/tests/test_subaru.py
@@ -194,7 +194,6 @@ 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.AVH_BUTTON)
assert not (CP.safetyConfigs[0].safetyParam & SubaruSafetyFlags.LEGACY_2025_ANGLE_LIMITS)
assert CanBus.main_for_cp(CP) == CanBus.alt
assert CanBus.angle_for_cp(CP) == CanBus.main
@@ -225,21 +224,6 @@ def test_stop_start_inputs_are_captured_for_supported_models(platform):
assert car_state.stop_start_state == 3
-def test_avh_inputs_are_captured_for_legacy_2025():
- CP = CarInterface.get_non_essential_params(CAR.SUBARU_LEGACY_2025)
- car_state = CarState(CP, None)
- parsers = car_state.get_can_parsers(CP)
- raw_avh = bytes.fromhex("230f1c4208800000")
- parsers[Bus.alt].vl["AVH"]["COUNTER"] = 15
- parsers[Bus.alt].vl["AVH"]["AVH"] = 0
- parsers[Bus.alt].vl_raw["AVH"] = raw_avh
-
- car_state.update(parsers, SimpleNamespace(subaru_sng=False))
-
- assert car_state.avh_msg["COUNTER"] == 15
- assert car_state.avh_dat == raw_avh
-
-
@pytest.mark.parametrize("platform, expected_bus, start_frame", [
(CAR.SUBARU_OUTBACK_2023, CanBus.alt, 101),
(CAR.SUBARU_LEGACY_2025, CanBus.alt, 401),
@@ -292,94 +276,6 @@ def test_stop_start_request_is_bounded_and_uses_live_dashlights(platform, expect
assert controller.stop_start_acknowledged
-def test_avh_request_sets_observed_bit_and_pulses_at_native_rate():
- CP = CarInterface.get_non_essential_params(CAR.SUBARU_LEGACY_2025)
- controller = CarController({}, CP)
- controller.frame = 101
-
- class TestActuators:
- steeringAngleDeg = 0.0
-
- def as_builder(self):
- return SimpleNamespace(steeringAngleDeg=self.steeringAngleDeg)
-
- CC = SimpleNamespace(
- enabled=False,
- latActive=False,
- longActive=False,
- actuators=TestActuators(),
- hudControl=SimpleNamespace(leadVisible=False),
- cruiseControl=SimpleNamespace(cancel=False),
- )
- CS = SimpleNamespace(
- canValid=True,
- avh_msg={"COUNTER": 15, "AVH": 0},
- avh_dat=bytes.fromhex("230f1c4208800000"),
- out=SimpleNamespace(
- standstill=True,
- gearShifter=structs.CarState.GearShifter.park,
- vEgoRaw=0.0,
- steeringAngleDeg=0.0,
- ),
- )
- toggles = SimpleNamespace(subaru_stop_start_off=False, subaru_avh_on=True, subaru_sng=False)
-
- # Start the request from the current live counter. AVH is a native 10 Hz
- # frame, so the controller waits for each next live counter before sending
- # its matching button frame.
- _, can_sends = controller.update(CC, CS, 0, toggles)
- avh_msgs = [msg for msg in can_sends if msg[0] == 0x32b]
- assert not avh_msgs
-
- CS.avh_msg["COUNTER"] = 0
- CS.avh_dat = bytes.fromhex("14001c4208800000")
- controller.frame = 103
- _, can_sends = controller.update(CC, CS, 0, toggles)
- avh_msgs = [msg for msg in can_sends if msg[0] == 0x32b]
- assert avh_msgs == [(0x32b, bytes.fromhex("34001c4208a00000"), CanBus.alt)]
-
- parser = CANParser(DBC[CP.carFingerprint][Bus.pt], [("AVH", 0)], CanBus.alt)
- parser.update([(CanBus.alt, avh_msgs)])
- assert parser.vl["AVH"]["AVH"] == 1
- assert parser.vl["AVH"]["COUNTER"] == 0
-
- controller.frame = 104
- _, can_sends = controller.update(CC, CS, 0, toggles)
- assert not any(msg[0] == 0x32b for msg in can_sends)
-
- avh_msgs = []
- for counter in range(1, 15):
- CS.avh_msg["COUNTER"] = counter
- raw_dat = bytearray.fromhex("14001c4208800000")
- raw_dat[1] = counter
- raw_dat[0] = ((0x32B & 0xFF) + ((0x32B >> 8) & 0xFF) + sum(raw_dat[1:])) & 0xFF
- CS.avh_dat = bytes(raw_dat)
- controller.frame = 103 + (counter * 10)
- _, can_sends = controller.update(CC, CS, 0, toggles)
- sent = [msg for msg in can_sends if msg[0] == 0x32b]
- assert len(sent) == 1
- avh_msgs.extend(sent)
-
- assert len(avh_msgs) == 14
- assert [msg[1][1] & 0x0F for msg in avh_msgs] == list(range(1, 15))
- assert all(msg[1][5] & 0x20 for msg in avh_msgs)
- assert not controller.avh_attempted
-
- CS.avh_msg["COUNTER"] = 15
- CS.avh_dat = bytes.fromhex("230f1c4208800000")
- controller.frame = 253
- _, can_sends = controller.update(CC, CS, 0, toggles)
- assert not any(msg[0] == 0x32b for msg in can_sends)
- assert controller.avh_attempted
-
- CS.avh_msg["COUNTER"] = 0
- CS.avh_dat = bytes.fromhex("14001c4208800000")
- controller.frame = 131
- _, can_sends = controller.update(CC, CS, 0, toggles)
- assert not any(msg[0] == 0x32b for msg in can_sends)
- assert controller.avh_attempted
-
-
def test_legacy_2025_uses_gen2_angle_bus_layout():
CP = CarInterface.get_non_essential_params(CAR.SUBARU_LEGACY_2025)
parsers = CarState.get_can_parsers(CP)
@@ -391,7 +287,6 @@ def test_legacy_2025_uses_gen2_angle_bus_layout():
assert not (CP.safetyConfigs[0].safetyParam & SubaruSafetyFlags.D_PLATFORM_CAMERA)
assert CP.safetyConfigs[0].safetyParam & SubaruSafetyFlags.FIXED_ANGLE_LIMITS
assert CP.safetyConfigs[0].safetyParam & SubaruSafetyFlags.STOP_START_BUTTON
- assert CP.safetyConfigs[0].safetyParam & SubaruSafetyFlags.AVH_BUTTON
assert CanBus.main_for_cp(CP) == CanBus.main
assert CanBus.angle_for_cp(CP) == CanBus.main
assert parsers[Bus.pt].bus == CanBus.main
diff --git a/opendbc_repo/opendbc/car/subaru/values.py b/opendbc_repo/opendbc/car/subaru/values.py
index c7caa7d69..4bf94c610 100644
--- a/opendbc_repo/opendbc/car/subaru/values.py
+++ b/opendbc_repo/opendbc/car/subaru/values.py
@@ -89,7 +89,6 @@ class SubaruSafetyFlags(IntFlag):
D_PLATFORM_CAMERA = 64
FIXED_ANGLE_LIMITS = 128
STOP_START_BUTTON = 256
- AVH_BUTTON = 512
LEGACY_2025_ANGLE_LIMITS = FIXED_ANGLE_LIMITS
@@ -276,11 +275,6 @@ SUBARU_STOP_START_CARS = (
CAR.SUBARU_LEGACY_2025,
)
-SUBARU_AVH_CARS = (
- CAR.SUBARU_LEGACY_2025,
-)
-
-
SUBARU_VERSION_REQUEST = bytes([uds.SERVICE_TYPE.READ_DATA_BY_IDENTIFIER]) + \
p16(uds.DATA_IDENTIFIER_TYPE.APPLICATION_DATA_IDENTIFICATION)
SUBARU_VERSION_RESPONSE = bytes([uds.SERVICE_TYPE.READ_DATA_BY_IDENTIFIER + 0x40]) + \
diff --git a/opendbc_repo/opendbc/car/toyota/carstate.py b/opendbc_repo/opendbc/car/toyota/carstate.py
index bff965f5a..635e29695 100644
--- a/opendbc_repo/opendbc/car/toyota/carstate.py
+++ b/opendbc_repo/opendbc/car/toyota/carstate.py
@@ -75,6 +75,7 @@ class CarState(CarStateBase):
self.distance_button = 0
self.pcm_follow_distance = 0
+ self.pcm_acc_status = 0
self.acc_type = 1
self.lkas_hud = {}
@@ -208,6 +209,7 @@ class CarState(CarStateBase):
if self.CP.openpilotLongitudinalControl:
ret.accFaulted = ret.accFaulted or cp.vl["PCM_CRUISE_2"]["LOW_SPEED_LOCKOUT"] == 2
+ prev_pcm_acc_status = self.pcm_acc_status
self.pcm_acc_status = cp.vl["PCM_CRUISE"]["CRUISE_STATE"]
if self.CP.carFingerprint not in (NO_STOP_TIMER_CAR - TSS2_CAR):
# ignore standstill state in certain vehicles, since pcm allows to restart with just an acceleration request
@@ -264,8 +266,8 @@ class CarState(CarStateBase):
buttonEvents += create_button_events(self.distance_button, prev_distance_button, {1: ButtonType.gapAdjustCruise})
buttonEvents += [
- *create_button_events(self.pcm_acc_status == 9, False, {1: ButtonType.accelCruise}),
- *create_button_events(self.pcm_acc_status == 10, False, {1: ButtonType.decelCruise}),
+ *create_button_events(self.pcm_acc_status == 9, prev_pcm_acc_status == 9, {1: ButtonType.accelCruise}),
+ *create_button_events(self.pcm_acc_status == 10, prev_pcm_acc_status == 10, {1: ButtonType.decelCruise}),
]
fp_ret.dashboardSpeedLimit = calculate_speed_limit(cp_cam)
diff --git a/opendbc_repo/opendbc/car/volvo/carstate.py b/opendbc_repo/opendbc/car/volvo/carstate.py
index cab17ee8e..9198e480b 100644
--- a/opendbc_repo/opendbc/car/volvo/carstate.py
+++ b/opendbc_repo/opendbc/car/volvo/carstate.py
@@ -11,7 +11,6 @@ TransmissionType = structs.CarParams.TransmissionType
# Must match VOLVO_SPEED_TO_MS in opendbc/safety/modes/volvo.h.
SPEED_TO_MS = 0.003977
STEERING_PRESSED_THRESHOLD = 2
-STEERING_DISENGAGE_THRESHOLD = 5
class CarState(CarStateBase):
@@ -75,11 +74,9 @@ class CarState(CarStateBase):
ret.steeringAngleDeg = cp_party.vl['PSCM']['PSCM_ANGLE_SENSOR'] # openpilot expects a negative value for a right turn
#ret.steeringAngleDeg = cp_party.vl['SAS']['SAS_ANGLE_SENSOR']
- # Driver steering torque feedback (used for driver override detection)
ret.steeringTorque = -cp_party.vl['DRIVER_INPUT']['STEERING_DRIVER_INPUT'] # Car right turn is negative, openpilot right turn is positive
driver_input = abs(cp_party.vl['DRIVER_INPUT']['STEERING_DRIVER_INPUT'])
ret.steeringPressed = driver_input > STEERING_PRESSED_THRESHOLD
- ret.steeringDisengage = driver_input > STEERING_DISENGAGE_THRESHOLD
# EPS status - placeholder until actual signal is found
self.eps_active = True # Assume EPS is active for now
diff --git a/opendbc_repo/opendbc/dbc/generator/subaru/subaru_global_2017.dbc b/opendbc_repo/opendbc/dbc/generator/subaru/subaru_global_2017.dbc
index ce7001f60..83a36ff8f 100644
--- a/opendbc_repo/opendbc/dbc/generator/subaru/subaru_global_2017.dbc
+++ b/opendbc_repo/opendbc/dbc/generator/subaru/subaru_global_2017.dbc
@@ -1,10 +1,5 @@
CM_ "IMPORT _subaru_global.dbc";
-BO_ 811 AVH: 8 XXX
- SG_ CHECKSUM : 0|8@1+ (1,0) [0|255] "" XXX
- SG_ COUNTER : 8|4@1+ (1,0) [0|15] "" XXX
- SG_ AVH : 45|1@0+ (1,0) [0|1] "" XXX
-
BO_ 72 Transmission: 8 XXX
SG_ CHECKSUM : 0|8@1+ (1,0) [0|255] "" XXX
SG_ COUNTER : 8|4@1+ (1,0) [0|15] "" XXX
diff --git a/opendbc_repo/opendbc/dbc/subaru_global_2017_generated.dbc b/opendbc_repo/opendbc/dbc/subaru_global_2017_generated.dbc
index 7ec223270..9732feae1 100644
--- a/opendbc_repo/opendbc/dbc/subaru_global_2017_generated.dbc
+++ b/opendbc_repo/opendbc/dbc/subaru_global_2017_generated.dbc
@@ -307,11 +307,6 @@ VAL_ 544 AEB_Status 12 "AEB related" 8 "AEB actuation" 4 "AEB related" 0 "No AEB
CM_ "subaru_global_2017.dbc starts here";
-BO_ 811 AVH: 8 XXX
- SG_ CHECKSUM : 0|8@1+ (1,0) [0|255] "" XXX
- SG_ COUNTER : 8|4@1+ (1,0) [0|15] "" XXX
- SG_ AVH : 45|1@0+ (1,0) [0|1] "" XXX
-
BO_ 72 Transmission: 8 XXX
SG_ CHECKSUM : 0|8@1+ (1,0) [0|255] "" XXX
SG_ COUNTER : 8|4@1+ (1,0) [0|15] "" XXX
diff --git a/opendbc_repo/opendbc/safety/modes/subaru.h b/opendbc_repo/opendbc/safety/modes/subaru.h
index 726212f46..ba5f05845 100644
--- a/opendbc_repo/opendbc/safety/modes/subaru.h
+++ b/opendbc_repo/opendbc/safety/modes/subaru.h
@@ -42,7 +42,6 @@
#define MSG_SUBARU_ES_STATIC_1 0x22aU
#define MSG_SUBARU_ES_STATIC_2 0x325U
#define MSG_SUBARU_Dashlights 0x390U
-#define MSG_SUBARU_AVH 0x32bU
#define SUBARU_MAIN_BUS 0U
#define SUBARU_ALT_BUS 1U
@@ -66,13 +65,6 @@
#define SUBARU_STOP_START_TX_MSGS(bus) \
{MSG_SUBARU_Dashlights, bus, 8, .check_relay = false}, \
-#define SUBARU_AVH_TX_MSGS(bus) \
- {MSG_SUBARU_AVH, bus, 8, .check_relay = false}, \
-
-#define SUBARU_STOP_START_AVH_TX_MSGS(bus) \
- SUBARU_STOP_START_TX_MSGS(bus) \
- SUBARU_AVH_TX_MSGS(bus)
-
#define SUBARU_COMMON_LONG_TX_MSGS(alt_bus) \
{MSG_SUBARU_ES_Distance, alt_bus, 8, .check_relay = true}, \
{MSG_SUBARU_ES_Brake, alt_bus, 8, .check_relay = true}, \
@@ -121,7 +113,6 @@ static bool subaru_lkas_angle = false;
static bool subaru_d_platform = false;
static bool subaru_fixed_angle_limits = false;
static bool subaru_stop_start_button = false;
-static bool subaru_avh_button = false;
static uint32_t subaru_get_checksum(const CANPacket_t *msg) {
return (uint8_t)msg->data[0];
@@ -306,13 +297,6 @@ static bool subaru_tx_hook(const CANPacket_t *msg) {
violation |= subaru_get_checksum(msg) != subaru_compute_checksum(msg);
}
- if (msg->addr == MSG_SUBARU_AVH) {
- violation |= !subaru_avh_button;
- violation |= msg->bus != (subaru_gen2 ? SUBARU_ALT_BUS : SUBARU_MAIN_BUS);
- violation |= !GET_BIT(msg, 45U);
- violation |= subaru_get_checksum(msg) != subaru_compute_checksum(msg);
- }
-
if (violation){
tx = false;
}
@@ -363,12 +347,6 @@ static safety_config subaru_init(uint16_t param) {
SUBARU_STOP_START_TX_MSGS(SUBARU_ALT_BUS)
};
- static const CanMsg SUBARU_GEN2_LKAS_ANGLE_STOP_START_AVH_TX_MSGS[] = {
- SUBARU_BASE_TX_MSGS(SUBARU_ALT_BUS, MSG_SUBARU_ES_LKAS_ANGLE)
- SUBARU_COMMON_TX_MSGS(SUBARU_ALT_BUS)
- SUBARU_STOP_START_AVH_TX_MSGS(SUBARU_ALT_BUS)
- };
-
static const CanMsg SUBARU_D_PLATFORM_ANGLE_MAIN_TX_MSGS[] = {
SUBARU_D_PLATFORM_ANGLE_TX_MSGS(SUBARU_MAIN_BUS)
SUBARU_COMMON_TX_MSGS(SUBARU_ALT_BUS)
@@ -380,12 +358,6 @@ static safety_config subaru_init(uint16_t param) {
SUBARU_STOP_START_TX_MSGS(SUBARU_ALT_BUS)
};
- static const CanMsg SUBARU_D_PLATFORM_ANGLE_STOP_START_AVH_MAIN_TX_MSGS[] = {
- SUBARU_D_PLATFORM_ANGLE_TX_MSGS(SUBARU_MAIN_BUS)
- SUBARU_COMMON_TX_MSGS(SUBARU_ALT_BUS)
- SUBARU_STOP_START_AVH_TX_MSGS(SUBARU_ALT_BUS)
- };
-
static const CanMsg SUBARU_D_PLATFORM_ANGLE_CAMERA_TX_MSGS[] = {
SUBARU_D_PLATFORM_ANGLE_TX_MSGS(SUBARU_CAM_BUS)
SUBARU_COMMON_TX_MSGS(SUBARU_ALT_BUS)
@@ -433,9 +405,6 @@ static safety_config subaru_init(uint16_t param) {
const uint16_t SUBARU_PARAM_STOP_START_BUTTON = 256;
subaru_stop_start_button = GET_FLAG(param, SUBARU_PARAM_STOP_START_BUTTON);
- const uint16_t SUBARU_PARAM_AVH_BUTTON = 512;
- subaru_avh_button = GET_FLAG(param, SUBARU_PARAM_AVH_BUTTON);
-
#ifdef ALLOW_DEBUG
const uint16_t SUBARU_PARAM_LONGITUDINAL = 2;
subaru_longitudinal = GET_FLAG(param, SUBARU_PARAM_LONGITUDINAL);
@@ -443,12 +412,10 @@ static safety_config subaru_init(uint16_t param) {
safety_config ret;
if (subaru_lkas_angle) {
- ret = subaru_d_platform ? (subaru_stop_start_button ? (subaru_avh_button ? BUILD_SAFETY_CFG(subaru_d_platform_angle_rx_checks, SUBARU_D_PLATFORM_ANGLE_STOP_START_AVH_MAIN_TX_MSGS) : \
- BUILD_SAFETY_CFG(subaru_d_platform_angle_rx_checks, SUBARU_D_PLATFORM_ANGLE_STOP_START_MAIN_TX_MSGS)) : \
+ ret = subaru_d_platform ? (subaru_stop_start_button ? BUILD_SAFETY_CFG(subaru_d_platform_angle_rx_checks, SUBARU_D_PLATFORM_ANGLE_STOP_START_MAIN_TX_MSGS) : \
(subaru_d_platform_camera ? BUILD_SAFETY_CFG(subaru_d_platform_angle_rx_checks, SUBARU_D_PLATFORM_ANGLE_CAMERA_TX_MSGS) : \
BUILD_SAFETY_CFG(subaru_d_platform_angle_rx_checks, SUBARU_D_PLATFORM_ANGLE_MAIN_TX_MSGS))) : \
- subaru_gen2 ? (subaru_stop_start_button ? (subaru_avh_button ? BUILD_SAFETY_CFG(subaru_gen2_lkas_angle_rx_checks, SUBARU_GEN2_LKAS_ANGLE_STOP_START_AVH_TX_MSGS) : \
- BUILD_SAFETY_CFG(subaru_gen2_lkas_angle_rx_checks, SUBARU_GEN2_LKAS_ANGLE_STOP_START_TX_MSGS)) : \
+ subaru_gen2 ? (subaru_stop_start_button ? BUILD_SAFETY_CFG(subaru_gen2_lkas_angle_rx_checks, SUBARU_GEN2_LKAS_ANGLE_STOP_START_TX_MSGS) : \
BUILD_SAFETY_CFG(subaru_gen2_lkas_angle_rx_checks, SUBARU_GEN2_LKAS_ANGLE_TX_MSGS)) : \
BUILD_SAFETY_CFG(subaru_lkas_angle_rx_checks, SUBARU_LKAS_ANGLE_TX_MSGS);
} else if (subaru_gen2) {
diff --git a/opendbc_repo/opendbc/safety/modes/volvo.h b/opendbc_repo/opendbc/safety/modes/volvo.h
index 24b9ac4de..e1a11676a 100644
--- a/opendbc_repo/opendbc/safety/modes/volvo.h
+++ b/opendbc_repo/opendbc/safety/modes/volvo.h
@@ -43,7 +43,6 @@
#define VOLVO_ANGLE_DEG_TO_CAN 17.869907f
#define VOLVO_MAX_ANGLE_CAN 9650
#define VOLVO_RELAY_ANGLE_TOLERANCE 54 // approximately 3 degrees
-#define VOLVO_DRIVER_OVERRIDE 5
// CAN bus definitions for Volvo
@@ -83,8 +82,6 @@ static const AngleSteeringLimits VOLVO_ANGLE_STEERING_LIMITS = {
};
static void volvo_rx_hook(const CANPacket_t *msg) {
- // Monitor the vehicle state required for cruise, disengagement, and angle
- // safety. All steering TX frames are separately constrained in volvo_tx_hook.
// Main bus (bus 0) messages
if (msg->bus == VOLVO_MAIN_BUS) {
@@ -148,13 +145,11 @@ static void volvo_rx_hook(const CANPacket_t *msg) {
// DRIVER_INPUT is the signal consumed by carstate.py for driver torque.
// The PSCM frame's DRIVER_INPUT_DEVIATION is a different signal and must
- // not be substituted here: doing so leaves the hardware disengage path blind.
if (msg->addr == VOLVO_DRIVER_INPUT) {
// STEERING_DRIVER_INPUT is a Motorola signal starting at bit 55. The
// DBC also carries a +1 offset, so its raw byte is data[6].
const int driver_input = to_signed(msg->data[6], 8) + 1;
update_sample(&torque_driver, driver_input);
- steering_disengage = SAFETY_ABS(driver_input) > VOLVO_DRIVER_OVERRIDE;
}
}
diff --git a/opendbc_repo/opendbc/safety/tests/test_subaru.py b/opendbc_repo/opendbc/safety/tests/test_subaru.py
index ebd7b1bf7..826c1d3c2 100755
--- a/opendbc_repo/opendbc/safety/tests/test_subaru.py
+++ b/opendbc_repo/opendbc/safety/tests/test_subaru.py
@@ -37,7 +37,6 @@ class SubaruMsg(enum.IntEnum):
ES_STATIC_1 = 0x22a
ES_STATIC_2 = 0x325
Dashlights = 0x390
- AVH = 0x32b
SUBARU_MAIN_BUS = 0
@@ -386,20 +385,6 @@ class TestSubaruGen2FixedAngleStopStartSafety(TestSubaruGen2FixedAngleSafety):
self.assertFalse(self._tx(self._stop_start_msg(False)))
-class TestSubaruGen2FixedAngleStopStartAvhSafety(TestSubaruGen2FixedAngleStopStartSafety):
- FLAGS = TestSubaruGen2FixedAngleStopStartSafety.FLAGS | SubaruSafetyFlags.AVH_BUTTON
- TX_MSGS = TestSubaruGen2FixedAngleStopStartSafety.TX_MSGS + [[SubaruMsg.AVH, SUBARU_ALT_BUS]]
-
- def _avh_msg(self, pressed):
- return self.packer.make_can_msg_safety(
- "AVH", SUBARU_ALT_BUS, {"COUNTER": 0, "AVH": pressed},
- )
-
- def test_avh_tx_requires_pressed_bit(self):
- self.assertTrue(self._tx(self._avh_msg(True)))
- self.assertFalse(self._tx(self._avh_msg(False)))
-
-
class TestSubaruDPlatformAngleSafety(TestSubaruStockLongitudinalSafetyBase, TestSubaruAngleSafetyBase):
FLAGS = SubaruSafetyFlags.GEN2 | SubaruSafetyFlags.LKAS_ANGLE | SubaruSafetyFlags.D_PLATFORM
ALT_MAIN_BUS = SUBARU_ALT_BUS
diff --git a/opendbc_repo/opendbc/safety/tests/test_volvo.py b/opendbc_repo/opendbc/safety/tests/test_volvo.py
index 810211752..43b9ae192 100644
--- a/opendbc_repo/opendbc/safety/tests/test_volvo.py
+++ b/opendbc_repo/opendbc/safety/tests/test_volvo.py
@@ -211,21 +211,17 @@ class TestVolvoSafetyBase(common.CarSafetyTest):
self.assertTrue(self._tx(valid))
self.assertFalse(self._tx(invalid))
- def test_driver_override_disengages_controls(self):
+ def test_driver_input_is_a_normal_override(self):
def driver_input_msg(value):
return self.mid_packer.make_can_msg_safety(
"DRIVER_INPUT", VOLVO_PARTY_BUS, {"STEERING_DRIVER_INPUT": value})
- for value in (2, 3, 5):
+ for value in (2, 3, 5, 6, 20, -20):
self._rx(driver_input_msg(0))
self.safety.set_controls_allowed(True)
self._rx(driver_input_msg(value))
- self.assertTrue(self.safety.get_controls_allowed(), f"unexpected disengage at {value=}")
-
- self._rx(driver_input_msg(0))
- self.safety.set_controls_allowed(True)
- self._rx(driver_input_msg(6))
- self.assertFalse(self.safety.get_controls_allowed())
+ self.assertTrue(self.safety.get_controls_allowed(), f"unexpected safety disengage at {value=}")
+ self.assertFalse(self.safety.get_steering_disengage_prev())
# ---- Volvo-specific consistency tests ----
diff --git a/scripts/model_lab_chestnut_batch.py b/scripts/model_lab_chestnut_batch.py
new file mode 100644
index 000000000..38bdb9ddc
--- /dev/null
+++ b/scripts/model_lab_chestnut_batch.py
@@ -0,0 +1,590 @@
+#!/usr/bin/env python3
+"""Build and publish Chestnut/AMD variants for every small manifest model.
+
+The queue is intentionally sequential. It keeps only one ONNX source and one
+compiler output on the comma, copies each verified artifact back to the host,
+uploads it, then publishes a freshly merged manifest. The state file makes an
+interrupted run resumable without rebuilding completed models.
+"""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import os
+import re
+import shlex
+import shutil
+import subprocess
+import sys
+import time
+from pathlib import Path
+
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+SCRIPTS_DIR = REPO_ROOT / "scripts"
+if str(SCRIPTS_DIR) not in sys.path:
+ sys.path.insert(0, str(SCRIPTS_DIR))
+
+from model_compiler import detect_component
+from model_rebuild_pipeline import ensure_workspace, extract_model, find_model_paths, ensure_git_ref
+
+
+DEFAULT_REMOTE = os.environ.get("STAR_PILOT_MODEL_REMOTE", "comma@192.168.3.110")
+DEFAULT_BUCKET = os.environ.get("STARPILOT_HF_BUCKET", "StarPilot-Driving/StarPilot-Resources")
+DEFAULT_ARTIFACT_DIR = Path.home() / "StarPilot-Model-Lab-Artifacts" / "v25"
+DEFAULT_MANIFEST = DEFAULT_ARTIFACT_DIR / "model_names_v25.json"
+DEFAULT_SOURCE_MAP = SCRIPTS_DIR / "model_source_map_v25.json"
+DEFAULT_OPENPILOT = Path.home() / "openpilot"
+REMOTE_ROOT = "/data/openpilot"
+SSH_OPTIONS = (
+ "-o", "ConnectTimeout=10",
+ "-o", "ConnectionAttempts=1",
+ "-o", "ServerAliveInterval=30",
+ "-o", "ServerAliveCountMax=600",
+)
+RSYNC_SSH = "ssh -o ConnectTimeout=10 -o ConnectionAttempts=1 -o ServerAliveInterval=30 -o ServerAliveCountMax=600"
+SAFE_MODEL_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
+COMPONENT_FILENAMES = {
+ "driving_supercombo": "driving_supercombo.onnx",
+ "driving_vision": "driving_vision.onnx",
+ "driving_policy": "driving_policy.onnx",
+ "driving_on_policy": "driving_on_policy.onnx",
+ "driving_off_policy": "driving_off_policy.onnx",
+}
+
+
+def utc_now() -> str:
+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
+
+
+def load_json(path: Path):
+ return json.loads(path.read_text(encoding="utf-8"))
+
+
+def write_json(path: Path, payload: object) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ temporary = path.with_suffix(path.suffix + ".tmp")
+ temporary.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
+ temporary.replace(path)
+
+
+def sha256_file(path: Path) -> str:
+ digest = hashlib.sha256()
+ with path.open("rb") as source:
+ for chunk in iter(lambda: source.read(1024 * 1024), b""):
+ digest.update(chunk)
+ return digest.hexdigest()
+
+
+def run(command: list[str], *, capture: bool = False, check: bool = True, timeout: int | None = None,
+ stdout=None, stderr=None) -> subprocess.CompletedProcess:
+ return subprocess.run(
+ command,
+ text=capture,
+ capture_output=capture,
+ check=check,
+ timeout=timeout,
+ stdout=stdout,
+ stderr=stderr,
+ )
+
+
+def validate_model_id(model_id: str) -> str:
+ if not SAFE_MODEL_ID.fullmatch(model_id):
+ raise ValueError(f"Unsafe model ID: {model_id!r}")
+ return model_id
+
+
+class Batch:
+ def __init__(self, args: argparse.Namespace):
+ self.args = args
+ self.hf = shutil.which("hf")
+ if not self.hf:
+ raise FileNotFoundError("Hugging Face CLI (hf) is not installed")
+ self.artifact_dir = args.artifact_dir.expanduser().resolve()
+ self.workspace = self.artifact_dir / "batch"
+ self.sources_workspace = self.workspace / "source-workspace"
+ self.sources_dir = self.sources_workspace / "onnx"
+ self.logs_dir = self.workspace / "logs"
+ self.results_dir = self.workspace / "results"
+ self.state_path = self.results_dir / "chestnut_batch_state.json"
+ self.manifest_path = args.manifest.expanduser().resolve()
+ self.source_map = load_json(args.source_map.expanduser().resolve())
+ self.manifest = load_json(self.manifest_path)
+ self.models = self.manifest.get("models", self.manifest)
+ if not isinstance(self.models, list):
+ raise ValueError("Manifest must contain a models list")
+ self.models_by_id = {str(model.get("id") or ""): model for model in self.models}
+ ensure_workspace(self.sources_workspace)
+ self.logs_dir.mkdir(parents=True, exist_ok=True)
+ self.results_dir.mkdir(parents=True, exist_ok=True)
+ self.state = self._load_state()
+ self.inventory = self._load_inventory()
+
+ @property
+ def bucket_root(self) -> str:
+ return f"hf://buckets/{self.args.bucket}"
+
+ def _load_state(self) -> dict:
+ if self.state_path.is_file():
+ state = load_json(self.state_path)
+ state.setdefault("models", {})
+ state["resumed_at"] = utc_now()
+ return state
+ return {
+ "remote": self.args.remote,
+ "bucket": self.args.bucket,
+ "started_at": utc_now(),
+ "models": {},
+ }
+
+ def save_state(self) -> None:
+ self.state["updated_at"] = utc_now()
+ write_json(self.state_path, self.state)
+
+ def _load_inventory(self) -> dict[str, list[dict]]:
+ result = run(
+ [self.hf, "buckets", "ls", "-R", f"{self.bucket_root}/onnx/", "--format", "json"],
+ capture=True,
+ )
+ entries = json.loads(result.stdout)
+ inventory: dict[str, list[dict]] = {}
+ for entry in entries:
+ path = str(entry.get("path") or "")
+ parts = Path(path).parts
+ if len(parts) == 3 and parts[0] == "onnx" and path.endswith(".onnx"):
+ inventory.setdefault(parts[1], []).append(entry)
+ return inventory
+
+ def selected_models(self) -> list[dict]:
+ requested = {validate_model_id(value) for value in self.args.ids.split(",") if value} if self.args.ids else set()
+ selected = [model for model in self.models if not bool(model.get("uses_external_gpu", False))]
+ if requested:
+ unknown = requested - self.models_by_id.keys()
+ if unknown:
+ raise ValueError(f"Unknown manifest model IDs: {', '.join(sorted(unknown))}")
+ selected = [model for model in selected if model["id"] in requested]
+ if self.args.limit:
+ selected = selected[:self.args.limit]
+ return selected
+
+ def source_plan(self, model: dict) -> dict:
+ model_id = validate_model_id(model["id"])
+ source = self.source_map.get(model_id)
+ if not isinstance(source, dict):
+ raise KeyError(f"No source mapping for small model {model_id}")
+ source_id = validate_model_id(str(source.get("source_id") or model_id))
+ archived = self.inventory.get(source_id, [])
+ if archived:
+ components = [detect_component(Path(entry["path"])) for entry in archived]
+ if None in components or len(set(components)) != len(components):
+ raise ValueError(f"Ambiguous archived ONNX components for {model_id} ({source_id})")
+ self._validate_components(model_id, source["input_format"], set(components))
+ signature_payload = {
+ "input_format": source["input_format"],
+ "version": str(model.get("version") or ""),
+ "files": sorted(
+ (detect_component(Path(entry["path"])), str(entry.get("xet_hash") or ""), int(entry.get("size") or 0))
+ for entry in archived
+ ),
+ }
+ signature = hashlib.sha256(json.dumps(signature_payload, sort_keys=True).encode()).hexdigest()
+ return {"kind": "archive", "source_id": source_id, "files": archived, "signature": signature, **source}
+
+ repo = self.args.openpilot.expanduser().resolve()
+ ensure_git_ref(repo, source["ref"])
+ paths = find_model_paths(repo, source["ref"], source["input_format"], False)
+ components = {detect_component(Path(path)) for path in paths}
+ self._validate_components(model_id, source["input_format"], components)
+ signature = hashlib.sha256(
+ f"git:{source['ref']}:{source['input_format']}:{model.get('version', '')}".encode()
+ ).hexdigest()
+ return {"kind": "git", "source_id": source_id, "files": paths, "signature": signature, **source}
+
+ @staticmethod
+ def _validate_components(model_id: str, input_format: str, components: set[str | None]) -> None:
+ if input_format == "supercombo" and components != {"driving_supercombo"}:
+ raise ValueError(f"{model_id} needs one supercombo source, found {sorted(str(c) for c in components)}")
+ if input_format == "split" and (
+ "driving_vision" not in components or not {"driving_policy", "driving_on_policy"} & components
+ ):
+ raise ValueError(f"{model_id} has incomplete split sources: {sorted(str(c) for c in components)}")
+
+ def audit(self) -> dict:
+ selected = self.selected_models()
+ report = {"total": len(selected), "archive": [], "git": [], "failures": {}}
+ for model in selected:
+ model_id = model["id"]
+ try:
+ plan = self.source_plan(model)
+ report[plan["kind"]].append(model_id)
+ except Exception as error:
+ report["failures"][model_id] = str(error)
+ report["ready"] = report["total"] - len(report["failures"])
+ print(json.dumps(report, indent=2), flush=True)
+ return report
+
+ def remote(self, command: str, *, capture: bool = False, check: bool = True,
+ timeout: int | None = None, stdout=None, stderr=None) -> subprocess.CompletedProcess:
+ return run(
+ ["ssh", *SSH_OPTIONS, self.args.remote, command],
+ capture=capture,
+ check=check,
+ timeout=timeout,
+ stdout=stdout,
+ stderr=stderr,
+ )
+
+ def hardware_preflight(self) -> None:
+ command = (
+ f"set -eu; cd {shlex.quote(REMOTE_ROOT)}; "
+ "test \"$(cat /data/params/d/IsOffroad 2>/dev/null)\" = 1; "
+ "/usr/local/venv/bin/python3 -c "
+ + shlex.quote("from openpilot.system.hardware.chestnut.flash import link_up; raise SystemExit(0 if link_up() else 1)")
+ + "; test -x /data/openpilot/models"
+ )
+ result = self.remote(command, capture=True, check=False, timeout=20)
+ if result.returncode:
+ raise RuntimeError("Comma must be reachable, offroad, and connected to an active Chestnut PCIe link")
+
+ def active_remote_compiles(self) -> list[str]:
+ result = self.remote("pgrep -af '[c]ompile_modeld.py' || true", capture=True, check=False, timeout=20)
+ if result.returncode and not result.stdout:
+ raise RuntimeError(f"Could not inspect remote compiler: {result.stderr.strip()}")
+ return [line for line in result.stdout.splitlines() if line.strip()]
+
+ def wait_for_remote_idle(self) -> None:
+ active = self.active_remote_compiles()
+ while active:
+ print(f"REMOTE_BUSY processes={len(active)}", flush=True)
+ time.sleep(30)
+ active = self.active_remote_compiles()
+
+ def _source_dir(self, model_id: str) -> Path:
+ return self.sources_dir / validate_model_id(model_id)
+
+ def prepare_source(self, model: dict, plan: dict) -> Path:
+ model_id = model["id"]
+ source_dir = self._source_dir(model_id)
+ if source_dir.is_dir():
+ shutil.rmtree(source_dir)
+ source_dir.mkdir(parents=True)
+ if plan["kind"] == "archive":
+ for entry in plan["files"]:
+ component = detect_component(Path(entry["path"]))
+ if component is None:
+ raise ValueError(f"Unknown source component: {entry['path']}")
+ destination = source_dir / f"{model_id}_{COMPONENT_FILENAMES[component]}"
+ run([
+ self.hf, "buckets", "cp",
+ f"{self.bucket_root}/{entry['path']}", str(destination), "--format", "quiet",
+ ])
+ expected_size = int(entry.get("size") or 0)
+ if expected_size and destination.stat().st_size != expected_size:
+ raise ValueError(f"Downloaded source size mismatch for {destination.name}")
+ else:
+ extract_model(model_id, self.source_map[model_id], self.args.openpilot.expanduser().resolve(), self.sources_workspace)
+ for path in sorted(source_dir.glob("*.onnx")):
+ component = detect_component(path)
+ if component is None:
+ raise ValueError(f"Unknown extracted source component: {path.name}")
+ archive_name = f"{plan['source_id']}_{COMPONENT_FILENAMES[component]}"
+ destination = f"{self.bucket_root}/onnx/{plan['source_id']}/{archive_name}"
+ run([self.hf, "buckets", "cp", str(path), destination, "--format", "quiet"])
+ print(f"SOURCE_ARCHIVED id={model_id} source_id={plan['source_id']}", flush=True)
+ return source_dir
+
+ def remote_paths(self, model_id: str) -> tuple[str, str]:
+ validate_model_id(model_id)
+ return (
+ f"{REMOTE_ROOT}/uncompiledmodels/{model_id}",
+ f"{REMOTE_ROOT}/compiledmodels/{model_id}_driving_tinygrad.pkl",
+ )
+
+ def cleanup_remote(self, model_id: str, *, source: bool = True, output: bool = True) -> None:
+ remote_source, remote_output = self.remote_paths(model_id)
+ targets = []
+ if source:
+ targets.append(shlex.quote(remote_source))
+ if output:
+ targets.append(shlex.quote(remote_output))
+ if targets:
+ self.remote("rm -rf -- " + " ".join(targets), check=False, timeout=30)
+
+ def stage_source(self, model_id: str, source_dir: Path) -> None:
+ remote_source, _ = self.remote_paths(model_id)
+ self.cleanup_remote(model_id)
+ self.remote(f"mkdir -p {shlex.quote(remote_source)} {shlex.quote(REMOTE_ROOT + '/compiledmodels')}")
+ run([
+ "rsync", "-az", "-e", RSYNC_SSH, "--exclude=._*",
+ f"{source_dir}/", f"{self.args.remote}:{remote_source}/",
+ ])
+
+ def compile(self, model: dict, plan: dict) -> Path:
+ model_id = model["id"]
+ remote_source, remote_output = self.remote_paths(model_id)
+ command = " ".join([
+ f"cd {shlex.quote(REMOTE_ROOT)} && ./models",
+ "--model", shlex.quote(model_id),
+ "--input-dir", shlex.quote(remote_source),
+ "--output-dir", shlex.quote(REMOTE_ROOT + "/compiledmodels"),
+ "--input-format", shlex.quote(plan["input_format"]),
+ "--version", shlex.quote(str(model.get("version") or "")),
+ "--gpu", "--no-split",
+ ])
+ log_path = self.logs_dir / f"{model_id}.log"
+ print(f"COMPILE_START id={model_id} source={plan['kind']} version={model.get('version', '')}", flush=True)
+ started = time.monotonic()
+ with log_path.open("ab") as log:
+ log.write(f"\n=== START {utc_now()} ===\n".encode())
+ result = self.remote(command, check=False, stdout=log, stderr=subprocess.STDOUT)
+ if result.returncode:
+ self.wait_for_remote_idle()
+ if not self.remote_file_exists(remote_output):
+ raise RuntimeError(f"Chestnut compilation failed; see {log_path}")
+ elapsed = time.monotonic() - started
+ print(f"COMPILE_DONE id={model_id} seconds={elapsed:.1f}", flush=True)
+ return self.pull_artifact(model_id)
+
+ def remote_file_exists(self, path: str) -> bool:
+ result = self.remote(f"test -f {shlex.quote(path)}", check=False, timeout=20)
+ return result.returncode == 0
+
+ def pull_artifact(self, model_id: str) -> Path:
+ _, remote_output = self.remote_paths(model_id)
+ destination = self.artifact_dir / f"{model_id}_driving_chestnut_tinygrad.pkl"
+ incoming = destination.with_suffix(destination.suffix + ".incoming")
+ incoming.unlink(missing_ok=True)
+ run(["rsync", "-az", "-e", RSYNC_SSH, f"{self.args.remote}:{remote_output}", str(incoming)])
+ if not incoming.is_file() or incoming.stat().st_size == 0:
+ raise FileNotFoundError(f"No compiler output for {model_id}")
+ incoming.replace(destination)
+ destination.chmod(0o644)
+ return destination
+
+ def artifact_metadata(self, model_id: str, artifact: Path) -> dict:
+ expected_name = f"{model_id}_driving_chestnut_tinygrad.pkl"
+ if artifact.name != expected_name or not artifact.is_file():
+ raise ValueError(f"Invalid local Chestnut artifact path for {model_id}: {artifact}")
+ return {
+ "artifact_format": "tinygrad_single_v1",
+ "artifact_filename": artifact.name,
+ "artifact_size": artifact.stat().st_size,
+ "artifact_sha256": sha256_file(artifact),
+ "artifact_chunk_count": 0,
+ "execution_device": "AMD",
+ }
+
+ def upload_artifact(self, model_id: str, artifact: Path) -> None:
+ destination = f"{self.bucket_root}/models/v25/{model_id}/{artifact.name}"
+ run([self.hf, "buckets", "cp", str(artifact), destination, "--format", "quiet"])
+ listing = run([self.hf, "buckets", "ls", "-R", destination, "--format", "json"], capture=True)
+ entries = json.loads(listing.stdout)
+ if len(entries) != 1 or int(entries[0].get("size") or 0) != artifact.stat().st_size:
+ raise RuntimeError(f"Uploaded artifact verification failed for {model_id}")
+
+ def completed_metadata(self) -> dict[str, dict]:
+ completed = {}
+ for model_id, record in self.state.get("models", {}).items():
+ if record.get("status") == "published" and isinstance(record.get("artifact"), dict):
+ completed[model_id] = record["artifact"]
+ return completed
+
+ def publish_manifest(self) -> None:
+ incoming = self.workspace / "live_manifest.json"
+ run([
+ self.hf, "buckets", "cp",
+ f"{self.bucket_root}/manifests/model_names_v25.json", str(incoming), "--format", "quiet",
+ ])
+ payload = load_json(incoming)
+ models = payload.get("models", payload)
+ completed = self.completed_metadata()
+ for model in models:
+ if bool(model.get("uses_external_gpu", False)):
+ continue
+ model["model_size"] = "small"
+ model["model_lab_eligible"] = True
+ if model["id"] in completed:
+ artifacts = model.get("accelerator_artifacts")
+ if not isinstance(artifacts, dict):
+ artifacts = {}
+ artifacts["chestnut"] = completed[model["id"]]
+ model["accelerator_artifacts"] = artifacts
+ write_json(self.manifest_path, payload if isinstance(payload, dict) else {"models": models})
+ run([
+ self.hf, "buckets", "cp", str(self.manifest_path),
+ f"{self.bucket_root}/manifests/model_names_v25.json", "--format", "quiet",
+ ])
+ print(f"MANIFEST_PUBLISHED completed={len(completed)}", flush=True)
+
+ def valid_existing_artifact(self, model_id: str) -> tuple[Path, dict] | None:
+ path = self.artifact_dir / f"{model_id}_driving_chestnut_tinygrad.pkl"
+ if not path.is_file():
+ return None
+ metadata = self.artifact_metadata(model_id, path)
+ manifest_artifact = (
+ self.models_by_id[model_id].get("accelerator_artifacts", {}).get("chestnut", {})
+ if isinstance(self.models_by_id[model_id].get("accelerator_artifacts"), dict) else {}
+ )
+ if (manifest_artifact.get("artifact_size") == metadata["artifact_size"]
+ and manifest_artifact.get("artifact_sha256") == metadata["artifact_sha256"]):
+ return path, metadata
+ state_artifact = self.state.get("models", {}).get(model_id, {}).get("artifact", {})
+ if (state_artifact.get("artifact_size") == metadata["artifact_size"]
+ and state_artifact.get("artifact_sha256") == metadata["artifact_sha256"]):
+ return path, metadata
+ return None
+
+ @staticmethod
+ def source_record(plan: dict) -> dict:
+ return {
+ "kind": plan["kind"],
+ "source_id": plan["source_id"],
+ "ref": plan["ref"],
+ "signature": plan["signature"],
+ }
+
+ def equivalent_artifact(self, model_id: str, plan: dict) -> tuple[str, Path] | None:
+ """Find a completed artifact built from byte-identical ONNXs and ABI."""
+ for candidate_id, record in self.state.get("models", {}).items():
+ if candidate_id == model_id or record.get("status") != "published":
+ continue
+ candidate_signature = record.get("source", {}).get("signature")
+ if not candidate_signature and candidate_id in self.models_by_id:
+ try:
+ candidate_signature = self.source_plan(self.models_by_id[candidate_id])["signature"]
+ except Exception:
+ continue
+ candidate_path = self.artifact_dir / f"{candidate_id}_driving_chestnut_tinygrad.pkl"
+ if candidate_signature == plan["signature"] and candidate_path.is_file():
+ return candidate_id, candidate_path
+ return None
+
+ def process_model(self, model: dict) -> None:
+ model_id = model["id"]
+ plan = self.source_plan(model)
+ existing = self.valid_existing_artifact(model_id)
+ if existing:
+ artifact, metadata = existing
+ if self.state.get("models", {}).get(model_id, {}).get("status") != "published":
+ self.upload_artifact(model_id, artifact)
+ self.state["models"][model_id] = {
+ "status": "published",
+ "source": self.source_record(plan),
+ "artifact": metadata,
+ "completed_at": utc_now(),
+ }
+ self.save_state()
+ print(f"SKIP_VERIFIED id={model_id} bytes={metadata['artifact_size']}", flush=True)
+ return
+
+ equivalent = self.equivalent_artifact(model_id, plan)
+ if equivalent:
+ source_model_id, source_artifact = equivalent
+ artifact = self.artifact_dir / f"{model_id}_driving_chestnut_tinygrad.pkl"
+ shutil.copy2(source_artifact, artifact)
+ metadata = self.artifact_metadata(model_id, artifact)
+ self.upload_artifact(model_id, artifact)
+ self.state["models"][model_id] = {
+ "status": "published",
+ "source": self.source_record(plan),
+ "derived_from": source_model_id,
+ "artifact": metadata,
+ "completed_at": utc_now(),
+ }
+ self.save_state()
+ print(f"PUBLISHED_DEDUP id={model_id} identical_to={source_model_id} bytes={metadata['artifact_size']}", flush=True)
+ return
+
+ self.hardware_preflight()
+ self.wait_for_remote_idle()
+ source_dir = self.prepare_source(model, plan)
+ try:
+ self.stage_source(model_id, source_dir)
+ artifact = self.compile(model, plan)
+ metadata = self.artifact_metadata(model_id, artifact)
+ self.upload_artifact(model_id, artifact)
+ self.state["models"][model_id] = {
+ "status": "published",
+ "source": self.source_record(plan),
+ "artifact": metadata,
+ "completed_at": utc_now(),
+ }
+ self.save_state()
+ print(f"PUBLISHED id={model_id} bytes={metadata['artifact_size']} sha256={metadata['artifact_sha256']}", flush=True)
+ finally:
+ self.cleanup_remote(model_id)
+ if source_dir.is_dir():
+ shutil.rmtree(source_dir)
+
+ def run_queue(self) -> int:
+ selected = self.selected_models()
+ audit = self.audit()
+ if audit["failures"]:
+ raise RuntimeError(f"Source audit failed for {len(audit['failures'])} small models")
+ if self.args.dry_run:
+ return 0
+
+ failures = 0
+ for index, model in enumerate(selected, 1):
+ model_id = model["id"]
+ print(f"QUEUE index={index}/{len(selected)} id={model_id}", flush=True)
+ try:
+ self.process_model(model)
+ except Exception as error:
+ failures += 1
+ self.state["models"][model_id] = {
+ "status": "failed",
+ "error": str(error),
+ "failed_at": utc_now(),
+ }
+ self.save_state()
+ print(f"FAILED id={model_id} error={error}", file=sys.stderr, flush=True)
+ if self.args.stop_on_failure:
+ break
+ continue
+ if not self.args.no_publish:
+ try:
+ self.publish_manifest()
+ except Exception as error:
+ failures += 1
+ self.state["manifest_error"] = {"error": str(error), "at": utc_now(), "after_model": model_id}
+ self.save_state()
+ print(f"MANIFEST_FAILED after={model_id} error={error}", file=sys.stderr, flush=True)
+ if self.args.stop_on_failure:
+ break
+ self.state["finished_at"] = utc_now()
+ self.state["failures"] = failures
+ self.save_state()
+ return 1 if failures else 0
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("command", choices=("audit", "run"), nargs="?", default="run")
+ parser.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST)
+ parser.add_argument("--source-map", type=Path, default=DEFAULT_SOURCE_MAP)
+ parser.add_argument("--artifact-dir", type=Path, default=DEFAULT_ARTIFACT_DIR)
+ parser.add_argument("--openpilot", type=Path, default=DEFAULT_OPENPILOT)
+ parser.add_argument("--remote", default=DEFAULT_REMOTE)
+ parser.add_argument("--bucket", default=DEFAULT_BUCKET)
+ parser.add_argument("--ids", default="", help="Optional comma-separated manifest model IDs")
+ parser.add_argument("--limit", type=int, default=0)
+ parser.add_argument("--dry-run", action="store_true")
+ parser.add_argument("--no-publish", action="store_true")
+ parser.add_argument("--stop-on-failure", action="store_true")
+ return parser.parse_args()
+
+
+def main() -> int:
+ args = parse_args()
+ batch = Batch(args)
+ if args.command == "audit":
+ return 1 if batch.audit()["failures"] else 0
+ return batch.run_queue()
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/model_source_map_v25.json b/scripts/model_source_map_v25.json
index c9fb50389..d39669344 100644
--- a/scripts/model_source_map_v25.json
+++ b/scripts/model_source_map_v25.json
@@ -359,6 +359,11 @@
"input_format": "supercombo",
"source_id": "deeprl3v2"
},
+ "deeprl3v2": {
+ "ref": "702fa71ad4dd8de08425eb11a1a42aaeb64892c9",
+ "input_format": "supercombo",
+ "source_id": "deeprl3v2"
+ },
"rh3": {
"ref": "93f5aa469a72b7621aef7da7901c100e0113e4d9",
"input_format": "supercombo",
@@ -379,6 +384,26 @@
"input_format": "supercombo",
"source_id": "rdf2"
},
+ "rdf33": {
+ "ref": "ea2151ba4b82854277f37f03b949f15fe2733dc8",
+ "input_format": "supercombo",
+ "source_id": "rdf3"
+ },
+ "rdf43": {
+ "ref": "a5a6412d08474cffb49a69afb910756afdee123e",
+ "input_format": "supercombo",
+ "source_id": "rdf4"
+ },
+ "rdf53": {
+ "ref": "7fb03ca474f03e95e59ec0c8a6c5fba831bd5fd1",
+ "input_format": "supercombo",
+ "source_id": "rdf5"
+ },
+ "rdf63": {
+ "ref": "35703097905a122c9f3ddf0d12889b4873d7e2a2",
+ "input_format": "supercombo",
+ "source_id": "rdf6"
+ },
"tsf": {
"ref": "4d911346cde4e0d2978a625f31679808284cc19d",
"input_format": "supercombo",
@@ -426,4 +451,3 @@
"source_id": "bmrlnapv6"
}
}
-
diff --git a/selfdrive/car/cruise.py b/selfdrive/car/cruise.py
index b929d5e8b..0e8a06a31 100644
--- a/selfdrive/car/cruise.py
+++ b/selfdrive/car/cruise.py
@@ -71,6 +71,10 @@ class VCruiseHelper:
long_interval = self._get_cruise_delta_interval(getattr(starpilot_toggles, "cruise_increase_long", None))
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 or getattr(self.CP, "openpilotLongitudinalControl", False))
+
@property
def v_cruise_initialized(self):
return self.v_cruise_kph != V_CRUISE_UNSET
@@ -90,7 +94,7 @@ class VCruiseHelper:
self.v_cruise_kph_last = self.v_cruise_kph
if CS.cruiseState.available:
- if self.gm_cc_only or self.redneck_non_pcm or not self.CP.pcmCruise:
+ if self._uses_software_cruise():
# if stock cruise is completely disabled, then we can use our own set speed logic
self._update_v_cruise_non_pcm(CS, enabled, is_metric, speed_limit_changed, starpilot_toggles, starpilot_car_state,
slc_target_with_offset)
@@ -206,8 +210,7 @@ class VCruiseHelper:
def initialize_v_cruise(self, CS, experimental_mode: bool, resume_prev_button: bool,
starpilot_toggles: SimpleNamespace, desired_speed_limit: float = 0.0) -> None:
- # initializing is handled by the PCM
- if self.CP.pcmCruise and not (self.gm_cc_only or self.redneck_non_pcm):
+ if self.CP.pcmCruise and not self._uses_software_cruise():
return
engage_floor_kph = max(V_CRUISE_MIN, 7.0 * CV.MPH_TO_KPH)
diff --git a/selfdrive/car/tests/test_cruise_speed.py b/selfdrive/car/tests/test_cruise_speed.py
index eec2b5b45..90944c2fb 100644
--- a/selfdrive/car/tests/test_cruise_speed.py
+++ b/selfdrive/car/tests/test_cruise_speed.py
@@ -482,6 +482,50 @@ class TestVCruiseHelper:
assert self.v_cruise_helper.v_cruise_kph == pytest.approx(initial_v_cruise_kph + IMPERIAL_INCREMENT)
+ 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,
+ is_metric=True,
+ set_speed_limit=False,
+ )
+
+ helper.initialize_v_cruise(car.CarState(vEgo=40 * CV.KPH_TO_MS), False, False, toggles)
+ initial_v_cruise_kph = helper.v_cruise_kph
+
+ pressed_cs = car.CarState(cruiseState={"available": True})
+ pressed_cs.buttonEvents = [ButtonEvent(type=ButtonType.accelCruise, pressed=True)]
+ helper.update_v_cruise(pressed_cs, True, True, False, toggles)
+
+ released_cs = car.CarState(cruiseState={"available": True})
+ released_cs.buttonEvents = [ButtonEvent(type=ButtonType.accelCruise, pressed=False)]
+ helper.update_v_cruise(released_cs, True, True, False, toggles)
+ assert helper.v_cruise_kph == pytest.approx(initial_v_cruise_kph + 5)
+
+ pressed_cs.buttonEvents = [ButtonEvent(type=ButtonType.accelCruise, pressed=True)]
+ helper.update_v_cruise(pressed_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_v_cruise_kph + 6)
+
+ def test_stock_pcm_cruise_still_uses_pcm_speed(self):
+ CP = car.CarParams(pcmCruise=True, openpilotLongitudinalControl=False)
+ helper = VCruiseHelper(CP)
+ toggles = SimpleNamespace(cruise_increase=5, cruise_increase_long=1)
+ pcm_speed_kph = 72.0
+ cs = car.CarState(
+ cruiseState={
+ "available": True,
+ "speed": pcm_speed_kph * CV.KPH_TO_MS,
+ "speedCluster": pcm_speed_kph * CV.KPH_TO_MS,
+ },
+ )
+
+ helper.update_v_cruise(cs, True, True, False, toggles)
+ assert helper.v_cruise_kph == pytest.approx(pcm_speed_kph)
+
class TestVCruiseHelperRedneck:
def setup_method(self):
diff --git a/selfdrive/controls/lib/latcontrol_torque.py b/selfdrive/controls/lib/latcontrol_torque.py
index 799d7fe6f..d9299a490 100644
--- a/selfdrive/controls/lib/latcontrol_torque.py
+++ b/selfdrive/controls/lib/latcontrol_torque.py
@@ -519,7 +519,9 @@ class LatControlTorque(LatControl):
CS.vEgo, setpoint, prius_deadzone_max,
)
elif genesis_g70_active:
- vehicle_friction_jerk_deadzone = get_genesis_g70_friction_jerk_deadzone(CS.vEgo, setpoint)
+ vehicle_friction_jerk_deadzone = get_genesis_g70_friction_jerk_deadzone(
+ CS.vEgo, setpoint, desired_lateral_jerk,
+ )
elif self.is_genesis_gv70:
vehicle_friction_jerk_deadzone = get_genesis_gv70_friction_jerk_deadzone(CS.vEgo, setpoint)
elif kia_carnival_active:
@@ -652,6 +654,9 @@ class LatControlTorque(LatControl):
output_torque *= get_genesis_gv70_high_speed_error_scale(
setpoint, measurement, desired_lateral_jerk, CS.vEgo,
)
+ output_torque *= get_genesis_gv70_reversal_output_scale(
+ setpoint, measurement, desired_lateral_jerk, CS.vEgo,
+ )
elif sonata_hybrid_active:
output_torque *= sonata_hybrid_center_taper
output_torque *= sonata_hybrid_center_output_taper
diff --git a/selfdrive/controls/lib/latcontrol_vehicle_tunes.py b/selfdrive/controls/lib/latcontrol_vehicle_tunes.py
index 05c1093b9..2beaef92c 100644
--- a/selfdrive/controls/lib/latcontrol_vehicle_tunes.py
+++ b/selfdrive/controls/lib/latcontrol_vehicle_tunes.py
@@ -243,6 +243,13 @@ GENESIS_GV70_HIGH_SPEED_ERROR_DAMPING_ERROR = 0.18
GENESIS_GV70_HIGH_SPEED_ERROR_DAMPING_ERROR_WIDTH = 0.15
GENESIS_GV70_HIGH_SPEED_ERROR_DAMPING_JERK = 0.15
GENESIS_GV70_HIGH_SPEED_ERROR_DAMPING_JERK_WIDTH = 0.10
+GENESIS_GV70_REVERSAL_OUTPUT_DAMPING_MAX = 0.28
+GENESIS_GV70_REVERSAL_OUTPUT_DAMPING_SPEED = 25.0 * CV.MPH_TO_MS
+GENESIS_GV70_REVERSAL_OUTPUT_DAMPING_SPEED_WIDTH = 5.0 * CV.MPH_TO_MS
+GENESIS_GV70_REVERSAL_OUTPUT_DAMPING_ERROR = 0.30
+GENESIS_GV70_REVERSAL_OUTPUT_DAMPING_ERROR_WIDTH = 0.16
+GENESIS_GV70_REVERSAL_OUTPUT_DAMPING_JERK = 0.20
+GENESIS_GV70_REVERSAL_OUTPUT_DAMPING_JERK_WIDTH = 0.10
GENESIS_GV70_LOW_SPEED_CENTER_OVERSHOOT_MAX = 0.28
GENESIS_GV70_LOW_SPEED_CENTER_OVERSHOOT_SPEED = 18.0 * CV.MPH_TO_MS
GENESIS_GV70_LOW_SPEED_CENTER_OVERSHOOT_SPEED_WIDTH = 3.5 * CV.MPH_TO_MS
@@ -268,6 +275,15 @@ 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.16
+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_JERK = 0.20
+GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_JERK_WIDTH = 0.12
GENESIS_G70_CENTER_OUTPUT_TAPER_MAX = 0.22
GENESIS_G70_CENTER_OUTPUT_TAPER_LAT = 0.30
GENESIS_G70_CENTER_OUTPUT_TAPER_LAT_WIDTH = 0.10
@@ -655,8 +671,8 @@ PALISADE_FF_CUTOFF = 1.25
PALISADE_FF_CUTOFF_WIDTH = 0.36
PALISADE_TRANSITION_SPEED = 9.0
PALISADE_PHASE_SCALE = 0.11
-PALISADE_TURN_IN_BOOST_LEFT = 0.34
-PALISADE_TURN_IN_BOOST_RIGHT = 0.24
+PALISADE_TURN_IN_BOOST_LEFT = 0.44
+PALISADE_TURN_IN_BOOST_RIGHT = 0.34
PALISADE_UNWIND_TAPER_LEFT = 0.18
PALISADE_UNWIND_TAPER_RIGHT = 0.30
PALISADE_FRICTION_MULT = 1.02
@@ -675,11 +691,11 @@ PALISADE_CENTER_TAPER_LAT = 0.28
PALISADE_CENTER_TAPER_LAT_WIDTH = 0.055
PALISADE_CENTER_TAPER_SPEED = 12.0
PALISADE_CENTER_TAPER_SPEED_WIDTH = 2.5
-PALISADE_CENTER_OUTPUT_TAPER_MAX = 0.10
+PALISADE_CENTER_OUTPUT_TAPER_MAX = 0.12
PALISADE_CENTER_OUTPUT_TAPER_LAT = 0.28
PALISADE_CENTER_OUTPUT_TAPER_LAT_WIDTH = 0.055
-PALISADE_CENTER_OUTPUT_TAPER_SPEED = 18.0
-PALISADE_CENTER_OUTPUT_TAPER_SPEED_WIDTH = 2.5
+PALISADE_CENTER_OUTPUT_TAPER_SPEED = 15.0
+PALISADE_CENTER_OUTPUT_TAPER_SPEED_WIDTH = 3.0
GENESIS_G90_LATERAL_TESTING_GROUND_ID = testing_ground.id_4
GENESIS_G90_FF_GAIN_LEFT = 0.32
@@ -3131,6 +3147,24 @@ def get_genesis_gv70_high_speed_error_scale(setpoint: float, measured_lateral_ac
return 1.0 - reduction
+def get_genesis_gv70_reversal_output_scale(setpoint: float, measured_lateral_accel: float,
+ desired_lateral_jerk: float, v_ego: float) -> float:
+ commanded_unwind = setpoint * desired_lateral_jerk < 0.0
+ measured_reversal = setpoint * measured_lateral_accel < 0.0
+ if not commanded_unwind and not measured_reversal:
+ return 1.0
+
+ tracking_error = abs(measured_lateral_accel - setpoint)
+ speed_weight = _sigmoid((v_ego - GENESIS_GV70_REVERSAL_OUTPUT_DAMPING_SPEED) /
+ GENESIS_GV70_REVERSAL_OUTPUT_DAMPING_SPEED_WIDTH)
+ error_weight = _sigmoid((tracking_error - GENESIS_GV70_REVERSAL_OUTPUT_DAMPING_ERROR) /
+ GENESIS_GV70_REVERSAL_OUTPUT_DAMPING_ERROR_WIDTH)
+ jerk_weight = _sigmoid((abs(desired_lateral_jerk) - GENESIS_GV70_REVERSAL_OUTPUT_DAMPING_JERK) /
+ GENESIS_GV70_REVERSAL_OUTPUT_DAMPING_JERK_WIDTH)
+ reduction = (GENESIS_GV70_REVERSAL_OUTPUT_DAMPING_MAX * speed_weight * error_weight * jerk_weight)
+ return 1.0 - reduction
+
+
def get_genesis_gv70_low_speed_center_overshoot_scale(setpoint: float, measured_lateral_accel: float,
v_ego: float) -> float:
if abs(setpoint) > 0.08 and setpoint * measured_lateral_accel < 0.0:
@@ -3164,12 +3198,34 @@ def get_genesis_g70_friction_threshold(v_ego: float, desired_lateral_accel: floa
return base_threshold * (1.0 + gain)
-def get_genesis_g70_friction_jerk_deadzone(v_ego: float, desired_lateral_accel: float) -> float:
+def get_genesis_g70_friction_jerk_deadzone(v_ego: float, desired_lateral_accel: float,
+ desired_lateral_jerk: float = 0.0) -> float:
speed_weight = _sigmoid((v_ego - GENESIS_G70_FRICTION_JERK_DEADZONE_SPEED) /
GENESIS_G70_FRICTION_JERK_DEADZONE_SPEED_WIDTH)
center_weight = _sigmoid((GENESIS_G70_FRICTION_JERK_DEADZONE_LAT - abs(desired_lateral_accel)) /
GENESIS_G70_FRICTION_JERK_DEADZONE_LAT_WIDTH)
- return GENESIS_G70_FRICTION_JERK_DEADZONE_MAX * speed_weight * center_weight
+ deadzone = GENESIS_G70_FRICTION_JERK_DEADZONE_MAX * speed_weight * center_weight
+
+ if desired_lateral_accel * desired_lateral_jerk < 0.0:
+ curve_speed_weight = _sigmoid(
+ (max(v_ego, 0.0) - GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_SPEED) /
+ GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_SPEED_WIDTH
+ )
+ curve_onset_weight = _sigmoid(
+ (abs(desired_lateral_accel) - GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_LAT) /
+ GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_LAT_WIDTH
+ )
+ curve_cutoff_weight = _sigmoid(
+ (GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_LAT_CUTOFF - abs(desired_lateral_accel)) /
+ GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_LAT_CUTOFF_WIDTH
+ )
+ jerk_weight = _sigmoid(
+ (abs(desired_lateral_jerk) - GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_JERK) /
+ GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_JERK_WIDTH
+ )
+ deadzone += (GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_MAX * curve_speed_weight *
+ curve_onset_weight * curve_cutoff_weight * jerk_weight)
+ return deadzone
def get_genesis_g70_center_output_scale(desired_lateral_accel: float, v_ego: float) -> float:
diff --git a/selfdrive/controls/tests/test_latcontrol.py b/selfdrive/controls/tests/test_latcontrol.py
index f1e1f8bc8..a23b1e8ee 100644
--- a/selfdrive/controls/tests/test_latcontrol.py
+++ b/selfdrive/controls/tests/test_latcontrol.py
@@ -99,6 +99,7 @@ from openpilot.selfdrive.controls.lib.latcontrol_torque import (
get_genesis_gv70_friction_jerk_deadzone,
get_genesis_gv70_friction_threshold,
get_genesis_gv70_high_speed_error_scale,
+ get_genesis_gv70_reversal_output_scale,
get_genesis_gv70_unwind_ff_scale,
get_honda_accord_ff_scale,
get_elantra_non_scc_ff_scale,
@@ -956,6 +957,15 @@ class TestLatControl:
assert get_genesis_gv70_high_speed_error_scale(-0.7, 0.58, -0.8, 20.0) > \
get_genesis_gv70_high_speed_error_scale(-0.7, 0.58, -0.8, 33.5)
+ def test_genesis_gv70_reversal_damping_is_medium_speed_and_phase_gated(self):
+ same_direction = get_genesis_gv70_reversal_output_scale(0.7, 0.9, 0.8, 16.0)
+ low_speed = get_genesis_gv70_reversal_output_scale(-0.7, 0.7, -0.8, 8.0)
+ route_speed = get_genesis_gv70_reversal_output_scale(-0.7, 0.7, -0.8, 15.0)
+
+ assert same_direction == pytest.approx(1.0)
+ assert route_speed < 1.0
+ assert route_speed < low_speed
+
def test_genesis_gv70_low_speed_center_overshoot_damping(self):
center_overshoot = get_genesis_gv70_low_speed_center_overshoot_scale(0.02, 0.45, 22.0 * 0.44704)
clean_center = get_genesis_gv70_low_speed_center_overshoot_scale(0.02, 0.02, 22.0 * 0.44704)
@@ -992,6 +1002,10 @@ class TestLatControl:
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)
assert get_genesis_g70_friction_jerk_deadzone(25.0, 0.0) > 0.25
+ hwy_unwind_deadzone = get_genesis_g70_friction_jerk_deadzone(68.0 * 0.44704, 0.8, -0.6)
+ hwy_turn_in_deadzone = get_genesis_g70_friction_jerk_deadzone(68.0 * 0.44704, 0.8, 0.6)
+ assert hwy_unwind_deadzone > hwy_turn_in_deadzone
+ assert hwy_unwind_deadzone > 0.08
assert get_genesis_g70_unwind_ff_scale(-0.7, -0.95, 0.5, 25.0) < 0.90
assert get_genesis_g70_unwind_ff_scale(-0.7, -0.95, -0.5, 25.0) == 1.0
assert get_genesis_g70_unwind_ff_scale(-0.7, 0.2, 0.5, 25.0) == 1.0
diff --git a/selfdrive/modeld/modeld.py b/selfdrive/modeld/modeld.py
index a832a25cd..bb57c2186 100755
--- a/selfdrive/modeld/modeld.py
+++ b/selfdrive/modeld/modeld.py
@@ -2,6 +2,7 @@
from collections.abc import Callable
import ctypes
from functools import cached_property
+import json
import os
import struct
from openpilot.system.hardware import HARDWARE, TICI
@@ -49,7 +50,21 @@ from openpilot.selfdrive.modeld.compile_modeld import (
)
from openpilot.selfdrive.modeld.helpers import get_tg_input_devices, load_oob, tinygrad_dev_config, usbgpu_present
from openpilot.selfdrive.modeld.usbgpu_link import wait_usbgpu_link
-from openpilot.starpilot.assets.model_manager import ModelManager, model_uses_external_gpu
+from openpilot.starpilot.assets.model_manager import (
+ ModelManager,
+ load_model_artifact_metadata,
+ model_accelerator_artifact_available,
+ model_accelerator_artifact_installed,
+ model_accelerator_artifact_path,
+ model_uses_external_gpu,
+)
+from openpilot.starpilot.common.model_lab import (
+ MODEL_LAB_RUNTIME_PARAM,
+ compose_model_outputs,
+ hybrid_action_values,
+ load_model_lab_config,
+ model_lab_manifest_eligible,
+)
from openpilot.starpilot.common.model_versions import is_tinygrad_model_version
from openpilot.starpilot.common.starpilot_variables import get_starpilot_toggles, MODELS_PATH, params_memory
@@ -478,28 +493,34 @@ class ModelState:
return numpy_inputs, prev_desired_curv_key
def __init__(self, cam_w: int, cam_h: int, external_gpu_active: bool = False,
- model_id_override: str | None = None, write_model_version: bool = True):
+ model_id_override: str | None = None, write_model_version: bool = True,
+ model_version_override: str | None = None, model_path_override: Path | None = None,
+ force_external_gpu: bool = False):
params = Params()
selected_model = model_id_override or _resolve_mirrored_param(params, "Model", "DrivingModel") or BUILTIN_MODEL_KEY
model_id = _canonical_model_id(selected_model)
requires_external_gpu = model_uses_external_gpu(model_id)
+ if force_external_gpu and not external_gpu_active:
+ raise RuntimeError("an external GPU artifact cannot run without Chestnut")
if requires_external_gpu and not external_gpu_active:
cloudlog.error(f"Model {model_id} requires an external GPU; falling back to {BUILTIN_MODEL_KEY}")
model_id = BUILTIN_MODEL_KEY
- use_builtin = model_id == BUILTIN_MODEL_KEY
+ use_builtin = model_id == BUILTIN_MODEL_KEY and model_path_override is None
loaded_builtin = use_builtin
- if use_builtin:
+ if model_path_override is not None:
+ model_path = Path(model_path_override)
+ elif use_builtin:
model_path = Path(__file__).parent / "models" / "driving_tinygrad.pkl"
else:
model_path = MODELS_PATH / f"{model_id}_driving_tinygrad.pkl"
- if not file_chunked_exists(model_path) and not use_builtin:
+ if not file_chunked_exists(model_path) and not use_builtin and model_path_override is None:
cloudlog.error(f"Missing model artifact {model_path}, downloading {model_id}...")
try:
ModelManager(params, params_memory).download_model(model_id)
except Exception:
cloudlog.exception(f"Failed to download model {model_id}")
- if not file_chunked_exists(model_path) and not use_builtin:
+ if not file_chunked_exists(model_path) and not use_builtin and model_path_override is None:
fallback_path = Path(__file__).parent / "models" / "driving_tinygrad.pkl"
if file_chunked_exists(fallback_path):
cloudlog.error(f"Falling back to builtin model artifact after {model_id} download failed")
@@ -509,7 +530,8 @@ class ModelState:
if not file_chunked_exists(model_path):
raise FileNotFoundError(model_path)
- self.uses_external_gpu = external_gpu_active and requires_external_gpu and not loaded_builtin
+ self.model_id = BUILTIN_MODEL_KEY if loaded_builtin else model_id
+ self.uses_external_gpu = external_gpu_active and (requires_external_gpu or force_external_gpu) and not loaded_builtin
artifact = _normalize_model_artifact(_load_model_artifact(model_path))
self.model_type = artifact["model_type"]
@@ -549,14 +571,15 @@ class ModelState:
self.frame_buf_size = get_nv12_info(cam_w, cam_h)[3]
self._blob_cache: dict[tuple[str, int], Tensor] = {}
- model_version = _resolve_mirrored_param(params, "ModelVersion", "DrivingModelVersion")
+ model_version = str(model_version_override or "").strip()
+ if not model_version:
+ model_version = _resolve_mirrored_param(params, "ModelVersion", "DrivingModelVersion")
if not model_version:
model_version = str(artifact.get("behavior_version") or "")
if not model_version:
versions_path = MODELS_PATH / ".model_versions.json"
if versions_path.is_file():
try:
- import json
model_version = str(json.loads(versions_path.read_text()).get(model_id) or "")
except Exception:
pass
@@ -791,6 +814,147 @@ def _load_external_gpu_model(cam_w: int, cam_h: int, selected_model: str,
_set_hcq_wait_timeout(BIG_MODEL_RUN_WAIT_TIMEOUT_MS)
+def _model_versions() -> dict[str, str]:
+ versions_path = MODELS_PATH / ".model_versions.json"
+ try:
+ payload = json.loads(versions_path.read_text())
+ return {str(key): str(value) for key, value in payload.items()} if isinstance(payload, dict) else {}
+ except (OSError, TypeError, ValueError):
+ return {}
+
+
+def _load_model_lab_model(cam_w: int, cam_h: int, model_id: str, version: str) -> ModelState:
+ if not model_accelerator_artifact_available(model_id) or not model_accelerator_artifact_installed(model_id):
+ raise RuntimeError(f"Model Laboratory AMD artifact is unavailable for {model_id}")
+ candidate = ModelState(
+ cam_w,
+ cam_h,
+ True,
+ model_id_override=model_id,
+ write_model_version=False,
+ model_version_override=version,
+ model_path_override=model_accelerator_artifact_path(model_id),
+ force_external_gpu=True,
+ )
+ if candidate.model_id != _canonical_model_id(model_id) or not candidate.uses_external_gpu:
+ raise RuntimeError(f"Model Laboratory failed to load {model_id} on AMD")
+ return candidate
+
+
+def _isolate_next_model_artifact_load() -> int:
+ from tinygrad.uop.ops import Ops, UOpMetaClass
+
+ buffer_keys = [key for key in UOpMetaClass.ucache if key[0] is Ops.BUFFER]
+ for key in buffer_keys:
+ UOpMetaClass.ucache.pop(key, None)
+ return len(buffer_keys)
+
+
+def _load_model_lab_models(cam_w: int, cam_h: int, lateral_id: str, longitudinal_id: str,
+ version: str, CP=None, demo: bool = False) -> tuple[ModelState, ModelState] | None:
+ try:
+ if not demo:
+ wait_for_external_gpu_power_ready(CP)
+ _set_hcq_wait_timeout(BIG_MODEL_LOAD_WAIT_TIMEOUT_MS)
+ wait_usbgpu_link()
+ _isolate_next_model_artifact_load()
+ lateral = _load_model_lab_model(cam_w, cam_h, lateral_id, version)
+ lateral.warmup()
+ evicted = _isolate_next_model_artifact_load()
+ cloudlog.info(f"Model Laboratory isolated {evicted} realized buffer UOps before loading the second model")
+ longitudinal = _load_model_lab_model(cam_w, cam_h, longitudinal_id, version)
+ longitudinal.warmup()
+ return lateral, longitudinal
+ except Exception:
+ cloudlog.exception("Model Laboratory AMD model load or warmup failed")
+ return None
+ finally:
+ _close_tinygrad_disk_cache_connection()
+ _set_hcq_wait_timeout(BIG_MODEL_RUN_WAIT_TIMEOUT_MS)
+
+
+def _model_outputs_finite(*outputs: dict[str, np.ndarray]) -> bool:
+ return all(
+ np.isfinite(value).all()
+ for output in outputs
+ for value in output.values()
+ if isinstance(value, np.ndarray)
+ )
+
+def _model_lab_runtime_request(params: Params, chestnut_ready: bool) -> tuple[dict, str | None]:
+ config = load_model_lab_config(params)
+ if not config["enabled"]:
+ return config, None
+ if not chestnut_ready:
+ return config, "Chestnut is not connected and firmware-ready"
+
+ lateral_id = _canonical_model_id(config["lateralModel"])
+ longitudinal_id = _canonical_model_id(config["longitudinalModel"])
+ config.update({"lateralModel": lateral_id, "longitudinalModel": longitudinal_id})
+ if not lateral_id or not longitudinal_id:
+ return config, "both model roles must be selected"
+ if lateral_id == longitudinal_id:
+ return config, "the lateral and longitudinal models must be different"
+
+ versions = _model_versions()
+ for role, model_id in (("lateral", lateral_id), ("longitudinal", longitudinal_id)):
+ metadata = load_model_artifact_metadata(model_id)
+ version = versions.get(model_id, "")
+ if not model_lab_manifest_eligible(metadata, version):
+ return config, f"{role} model {model_id} is not a compatible small model"
+ if not model_accelerator_artifact_available(model_id):
+ return config, f"{role} model {model_id} has no precompiled AMD artifact in the manifest"
+ if not model_accelerator_artifact_installed(model_id):
+ return config, f"{role} model {model_id} AMD artifact is not installed by Model Manager"
+ if versions[lateral_id] != versions[longitudinal_id]:
+ return config, "the two models must use the same behavior version"
+ return config, None
+
+
+def _set_model_lab_runtime(params: Params, *, requested: bool, active: bool,
+ config: dict | None = None, error: str = "") -> None:
+ config = config or {}
+ params.put(MODEL_LAB_RUNTIME_PARAM, {
+ "requested": bool(requested),
+ "active": bool(active),
+ "lateralModel": str(config.get("lateralModel") or ""),
+ "longitudinalModel": str(config.get("longitudinalModel") or ""),
+ "schedule": "sequential_20hz" if requested else "",
+ "executionDevice": "AMD" if active else "",
+ "error": str(error or ""),
+ })
+
+
+def _runner_frame_args(model: ModelState, buf_main, buf_extra,
+ model_transform_main: np.ndarray, model_transform_extra: np.ndarray,
+ vec_desire: np.ndarray, traffic_convention: np.ndarray,
+ lat_action_t: float, long_action_t: float,
+ prev_action: log.ModelDataV2.Action, v_ego: float,
+ lateral_control_params: np.ndarray) -> tuple[dict, dict, dict[str, np.ndarray]]:
+ bufs = {
+ model.road_key: buf_main,
+ model.wide_key: buf_extra,
+ }
+ transforms = {
+ model.road_key: model_transform_main,
+ model.wide_key: model_transform_extra,
+ }
+ inputs: dict[str, np.ndarray] = {
+ model.desire_key: vec_desire,
+ "traffic_convention": traffic_convention,
+ }
+ if "action_t" in model.numpy_inputs or (model.off_policy_enabled and "action_t" in model.off_policy_numpy_inputs):
+ inputs["action_t"] = np.array([lat_action_t, long_action_t], dtype=np.float32)
+ if "prev_action" in model.numpy_inputs or (model.off_policy_enabled and "prev_action" in model.off_policy_numpy_inputs):
+ inputs["prev_action"] = np.array([
+ prev_action.desiredCurvature * max(1.0, v_ego) ** 2,
+ prev_action.desiredAcceleration,
+ ], dtype=np.float32)
+ if "lateral_control_params" in model.numpy_inputs:
+ inputs["lateral_control_params"] = lateral_control_params
+ return bufs, transforms, inputs
+
+
def main(demo=False):
cloudlog.warning("modeld init")
@@ -802,14 +966,24 @@ def main(demo=False):
params = Params()
selected_model = _canonical_model_id(_resolve_mirrored_param(params, "Model", "DrivingModel") or BUILTIN_MODEL_KEY)
usbgpu_present_now = usbgpu_present()
+ model_lab_config, model_lab_error = _model_lab_runtime_request(params, usbgpu_present_now)
+ model_lab_requested = bool(model_lab_config["enabled"])
+ model_lab_ready = model_lab_requested and model_lab_error is None
external_model_selected = model_uses_external_gpu(selected_model)
external_artifact = MODELS_PATH / f"{selected_model}_driving_tinygrad.pkl"
external_artifact_ready = external_model_selected and file_chunked_exists(external_artifact)
- external_gpu_requested = usbgpu_present_now and external_model_selected
+ external_gpu_requested = usbgpu_present_now and (external_model_selected or model_lab_ready)
params.put_bool("UsbGpuPresent", usbgpu_present_now)
- params.put_bool("UsbGpuCompiled", external_artifact_ready)
+ params.put_bool("UsbGpuCompiled", external_artifact_ready or model_lab_ready)
params.put_bool("UsbGpuActive", False)
params.put_bool("UsbGpuLoading", external_gpu_requested)
+ _set_model_lab_runtime(
+ params,
+ requested=model_lab_requested,
+ active=False,
+ config=model_lab_config,
+ error=model_lab_error or "",
+ )
# visionipc clients
while True:
@@ -839,8 +1013,47 @@ def main(demo=False):
model = None
small_model = None
big_model = None
+ model_lab_longitudinal = None
+ model_lab_active = False
+ model_lab_timings: list[float] = []
CP = None
- if external_gpu_requested:
+ if model_lab_ready:
+ if demo:
+ CP = get_demo_car_params()
+ else:
+ CP = messaging.log_from_bytes(params.get("CarParams", block=True), car.CarParams)
+ small_model = ModelState(
+ vipc_client_main.width,
+ vipc_client_main.height,
+ False,
+ model_id_override=BUILTIN_MODEL_KEY,
+ write_model_version=False,
+ )
+ versions = _model_versions()
+ lateral_id = model_lab_config["lateralModel"]
+ longitudinal_id = model_lab_config["longitudinalModel"]
+ pair = _load_model_lab_models(
+ vipc_client_main.width,
+ vipc_client_main.height,
+ lateral_id,
+ longitudinal_id,
+ versions[lateral_id],
+ CP,
+ demo,
+ )
+ if pair is not None:
+ model, model_lab_longitudinal = pair
+ model_lab_active = True
+ params.put("ModelVersion", model.policy_generation)
+ params.put("DrivingModelVersion", model.policy_generation)
+ else:
+ model_lab_error = "one or both precompiled AMD models failed to load; using the built-in model"
+ cloudlog.error(f"Model Laboratory unavailable: {model_lab_error}")
+ model = small_model
+ params.put("ModelVersion", model.policy_generation)
+ params.put("DrivingModelVersion", model.policy_generation)
+
+ elif external_gpu_requested:
if demo:
CP = get_demo_car_params()
else:
@@ -868,10 +1081,17 @@ def main(demo=False):
else:
model = _load_model_state(vipc_client_main.width, vipc_client_main.height, selected_model, False, params)
- external_gpu_active = model.uses_external_gpu
- params.put_bool("UsbGpuCompiled", external_model_selected and file_chunked_exists(external_artifact))
+ external_gpu_active = model_lab_active or model.uses_external_gpu
+ params.put_bool("UsbGpuCompiled", external_artifact_ready or model_lab_ready)
params.put_bool("UsbGpuActive", external_gpu_active)
params.put_bool("UsbGpuLoading", False)
+ _set_model_lab_runtime(
+ params,
+ requested=model_lab_requested,
+ active=model_lab_active,
+ config=model_lab_config,
+ error=model_lab_error or "",
+ )
cloudlog.warning(f"models loaded in {time.monotonic() - start_time:.1f}s, modeld starting")
# messaging
@@ -995,39 +1215,41 @@ def main(demo=False):
frames_dropped = 0.
run_count = run_count + 1
- frame_drop_ratio = frames_dropped / (1 + frames_dropped)
- prepare_only = model.can_prepare_only and vipc_dropped_frames > 0
- if prepare_only:
- cloudlog.error(f"skipping model eval. Dropped {vipc_dropped_frames} frames")
+ if model_lab_active and run_count % ModelConstants.MODEL_FREQ == 0 and not usbgpu_present():
+ model_lab_active = False
+ model_lab_longitudinal = None
+ if small_model is None:
+ raise RuntimeError("Model Laboratory has no built-in fallback model")
+ model = small_model
+ external_gpu_active = False
+ model_lab_error = "Chestnut disconnected; using the built-in model"
+ params.put_bool("UsbGpuPresent", False)
+ params.put_bool("UsbGpuActive", False)
+ params.put("ModelVersion", model.policy_generation)
+ params.put("DrivingModelVersion", model.policy_generation)
+ _set_model_lab_runtime(
+ params,
+ requested=model_lab_requested,
+ active=False,
+ config=model_lab_config,
+ error=model_lab_error,
+ )
+ if chestnut_state is not None:
+ chestnut_state.big = False
+ cloudlog.error(f"Model Laboratory stopped: {model_lab_error}")
- bufs = {
- model.road_key: buf_main,
- model.wide_key: buf_extra,
- }
- transforms = {
- model.road_key: model_transform_main,
- model.wide_key: model_transform_extra,
- }
+ frame_drop_ratio = frames_dropped / (1 + frames_dropped)
+ dropped_frame = vipc_dropped_frames > 0
+ if dropped_frame and (model.can_prepare_only or (model_lab_longitudinal is not None and model_lab_longitudinal.can_prepare_only)):
+ cloudlog.error(f"skipping model eval. Dropped {vipc_dropped_frames} frames")
frame_delay = DT_MDL # Average time elapsed since the current frame finished exposing.
action_delay = DT_MDL / 2 # Target the midpoint between current output and the next model step.
lat_action_t = lat_delay + frame_delay + action_delay
long_action_t = long_delay + frame_delay + action_delay
- inputs:dict[str, np.ndarray] = {
- model.desire_key: vec_desire,
- 'traffic_convention': traffic_convention,
- }
- if 'action_t' in model.numpy_inputs or (model.off_policy_enabled and 'action_t' in model.off_policy_numpy_inputs):
- inputs['action_t'] = np.array([lat_action_t, long_action_t], dtype=np.float32)
- if 'prev_action' in model.numpy_inputs or (model.off_policy_enabled and 'prev_action' in model.off_policy_numpy_inputs):
- inputs['prev_action'] = np.array([
- prev_action.desiredCurvature * max(1.0, v_ego) ** 2,
- prev_action.desiredAcceleration,
- ], dtype=np.float32)
- # Include optional inputs only if the loaded model expects them
- if 'lateral_control_params' in model.numpy_inputs:
- inputs['lateral_control_params'] = lateral_control_params
+ lateral_model_output = None
+ longitudinal_model_output = None
mt1 = time.perf_counter()
try:
@@ -1035,20 +1257,78 @@ def main(demo=False):
chestnut_state is not None and
run_count % round(ModelConstants.MODEL_FREQ / SERVICE_LIST["chestnutState"].frequency) == 0
)
- model_output = model.run(
- bufs,
- transforms,
- inputs,
- prepare_only,
- chestnut_state.send if send_chestnut else None,
- )
+ if model_lab_longitudinal is not None:
+ lateral_bufs, lateral_transforms, lateral_inputs = _runner_frame_args(
+ model, buf_main, buf_extra, model_transform_main, model_transform_extra,
+ vec_desire, traffic_convention, lat_action_t, long_action_t,
+ prev_action, v_ego, lateral_control_params,
+ )
+ lateral_model_output = model.run(
+ lateral_bufs,
+ lateral_transforms,
+ lateral_inputs,
+ model.can_prepare_only and dropped_frame,
+ )
+ longitudinal_bufs, longitudinal_transforms, longitudinal_inputs = _runner_frame_args(
+ model_lab_longitudinal, buf_main, buf_extra, model_transform_main, model_transform_extra,
+ vec_desire, traffic_convention, lat_action_t, long_action_t,
+ prev_action, v_ego, lateral_control_params,
+ )
+ longitudinal_model_output = model_lab_longitudinal.run(
+ longitudinal_bufs,
+ longitudinal_transforms,
+ longitudinal_inputs,
+ model_lab_longitudinal.can_prepare_only and dropped_frame,
+ chestnut_state.send if send_chestnut else None,
+ )
+ if (
+ lateral_model_output is not None
+ and longitudinal_model_output is not None
+ and not _model_outputs_finite(lateral_model_output, longitudinal_model_output)
+ ):
+ raise RuntimeError("Model Laboratory produced non-finite output")
+ model_output = (
+ compose_model_outputs(lateral_model_output, longitudinal_model_output, longitudinal_model_output)
+ if lateral_model_output is not None and longitudinal_model_output is not None
+ else None
+ )
+ else:
+ bufs, transforms, inputs = _runner_frame_args(
+ model, buf_main, buf_extra, model_transform_main, model_transform_extra,
+ vec_desire, traffic_convention, lat_action_t, long_action_t,
+ prev_action, v_ego, lateral_control_params,
+ )
+ model_output = model.run(
+ bufs,
+ transforms,
+ inputs,
+ model.can_prepare_only and dropped_frame,
+ chestnut_state.send if send_chestnut else None,
+ )
+ lateral_model_output = model_output
except Exception:
- if not external_gpu_active or small_model is None:
- raise
- cloudlog.exception("external GPU model failed, falling back to builtin model")
+ if model_lab_active:
+ cloudlog.exception("Model Laboratory inference failed, falling back to the built-in model")
+ if small_model is None:
+ raise RuntimeError("Model Laboratory has no built-in fallback model") from None
+ model = small_model
+ model_lab_longitudinal = None
+ model_lab_active = False
+ model_lab_error = "Model Laboratory inference failed; using the built-in model"
+ _set_model_lab_runtime(
+ params,
+ requested=model_lab_requested,
+ active=False,
+ config=model_lab_config,
+ error=model_lab_error,
+ )
+ else:
+ if not external_gpu_active or small_model is None:
+ raise
+ cloudlog.exception("external GPU model failed, falling back to builtin model")
+ model = small_model
+ big_model = None
params.put_bool("UsbGpuActive", False)
- model = small_model
- big_model = None
external_gpu_active = False
params.put("ModelVersion", model.policy_generation)
params.put("DrivingModelVersion", model.policy_generation)
@@ -1060,6 +1340,16 @@ def main(demo=False):
mt2 = time.perf_counter()
model_execution_time = mt2 - mt1
+ if model_lab_active and model_lab_longitudinal is not None:
+ model_lab_timings.append(model_execution_time * 1000)
+ if run_count % (ModelConstants.MODEL_FREQ * 10) == 0:
+ timing_summary = "/".join((
+ f"p50:{np.percentile(model_lab_timings, 50):.1f}",
+ f"p95:{np.percentile(model_lab_timings, 95):.1f}",
+ f"max:{max(model_lab_timings):.1f}ms",
+ ))
+ cloudlog.warning(f"Model Laboratory timing (two AMD models at 20 Hz): {timing_summary}")
+ model_lab_timings = []
if model_output is not None and vipc_dropped_frames > 0:
cloudlog.error(f"suppressing model output after dropping {vipc_dropped_frames} frames")
@@ -1070,13 +1360,31 @@ def main(demo=False):
drivingdata_send = messaging.new_message('drivingModelData')
posenet_send = messaging.new_message('cameraOdometry')
- action = get_action_from_model(
- model_output, prev_action,
- lat_action_t,
- long_action_t,
- v_ego, model.mlsim, model.is_v9, model.is_v14, model.is_v15, starpilot_toggles,
- lat_smooth_seconds, long_smooth_seconds, is_v16=model.is_v16,
- )
+ if model_lab_active and longitudinal_model_output is not None:
+ lateral_action = get_action_from_model(
+ lateral_model_output, prev_action,
+ lat_action_t,
+ long_action_t,
+ v_ego, model.mlsim, model.is_v9, model.is_v14, model.is_v15, starpilot_toggles,
+ lat_smooth_seconds, long_smooth_seconds, is_v16=model.is_v16,
+ )
+ longitudinal_action = get_action_from_model(
+ longitudinal_model_output, prev_action,
+ lat_action_t,
+ long_action_t,
+ v_ego, model_lab_longitudinal.mlsim, model_lab_longitudinal.is_v9,
+ model_lab_longitudinal.is_v14, model_lab_longitudinal.is_v15, starpilot_toggles,
+ lat_smooth_seconds, long_smooth_seconds, is_v16=model_lab_longitudinal.is_v16,
+ )
+ action = log.ModelDataV2.Action(**hybrid_action_values(lateral_action, longitudinal_action))
+ else:
+ action = get_action_from_model(
+ model_output, prev_action,
+ lat_action_t,
+ long_action_t,
+ v_ego, model.mlsim, model.is_v9, model.is_v14, model.is_v15, starpilot_toggles,
+ lat_smooth_seconds, long_smooth_seconds, is_v16=model.is_v16,
+ )
prev_action = action
fill_model_msg(drivingdata_send, modelv2_send, model_output, action,
publish_state, meta_main.frame_id, meta_extra.frame_id, frame_id,
diff --git a/selfdrive/modeld/tests/test_model_laboratory.py b/selfdrive/modeld/tests/test_model_laboratory.py
new file mode 100644
index 000000000..7c5274e59
--- /dev/null
+++ b/selfdrive/modeld/tests/test_model_laboratory.py
@@ -0,0 +1,209 @@
+import json
+from types import SimpleNamespace
+
+import numpy as np
+from tinygrad.uop.ops import Ops, UOpMetaClass
+
+from openpilot.selfdrive.modeld import modeld
+
+
+class FakeParams:
+ def __init__(self, config):
+ self.config = config
+ self.values = {}
+
+ def get(self, key):
+ if key == "ModelLabConfig":
+ return self.config
+ return None
+
+ def put(self, key, value):
+ self.values[key] = value
+
+
+def test_runtime_request_accepts_only_two_ready_small_same_version_models(tmp_path, monkeypatch):
+ config = {"enabled": True, "lateralModel": "lat", "longitudinalModel": "long"}
+ params = FakeParams(config)
+ (tmp_path / ".model_versions.json").write_text(json.dumps({"lat": "v15", "long": "v15"}))
+ (tmp_path / "lat_driving_tinygrad.pkl").write_bytes(b"lat")
+ (tmp_path / "long_driving_tinygrad.pkl").write_bytes(b"long")
+ monkeypatch.setattr(modeld, "MODELS_PATH", tmp_path)
+ monkeypatch.setattr(
+ modeld,
+ "load_model_artifact_metadata",
+ lambda model_id: {"model_size": "small", "model_lab_eligible": model_id in {"lat", "long"}},
+ )
+ monkeypatch.setattr(modeld, "model_accelerator_artifact_available", lambda model_id: model_id in {"lat", "long"})
+ monkeypatch.setattr(modeld, "model_accelerator_artifact_installed", lambda model_id: model_id in {"lat", "long"})
+
+ normalized, error = modeld._model_lab_runtime_request(params, chestnut_ready=True)
+
+ assert error is None
+ assert normalized == config
+
+
+def test_runtime_request_revalidates_hardware_version_and_size(tmp_path, monkeypatch):
+ params = FakeParams({"enabled": True, "lateralModel": "lat", "longitudinalModel": "long"})
+ (tmp_path / ".model_versions.json").write_text(json.dumps({"lat": "v15", "long": "v9"}))
+ for model_id in ("lat", "long"):
+ (tmp_path / f"{model_id}_driving_tinygrad.pkl").write_bytes(b"artifact")
+ monkeypatch.setattr(modeld, "MODELS_PATH", tmp_path)
+ monkeypatch.setattr(modeld, "load_model_artifact_metadata", lambda _model_id: {"model_size": "small"})
+ monkeypatch.setattr(modeld, "model_accelerator_artifact_available", lambda _model_id: True)
+ monkeypatch.setattr(modeld, "model_accelerator_artifact_installed", lambda _model_id: True)
+
+ assert "Chestnut" in modeld._model_lab_runtime_request(params, chestnut_ready=False)[1]
+ assert "same behavior version" in modeld._model_lab_runtime_request(params, chestnut_ready=True)[1]
+
+ (tmp_path / ".model_versions.json").write_text(json.dumps({"lat": "v15", "long": "v15"}))
+ monkeypatch.setattr(
+ modeld,
+ "load_model_artifact_metadata",
+ lambda model_id: {"model_size": "chestnut" if model_id == "long" else "small"},
+ )
+ assert "compatible small model" in modeld._model_lab_runtime_request(params, chestnut_ready=True)[1]
+
+
+def test_model_lab_loader_uses_installed_artifact_and_manifest_version(monkeypatch):
+ calls = []
+
+ def fake_model_state(cam_w, cam_h, external_gpu_active, **kwargs):
+ calls.append((cam_w, cam_h, external_gpu_active, kwargs))
+ return SimpleNamespace(model_id="lat", uses_external_gpu=True)
+
+ monkeypatch.setattr(modeld, "ModelState", fake_model_state)
+ monkeypatch.setattr(modeld, "model_accelerator_artifact_available", lambda _model_id: True)
+ monkeypatch.setattr(modeld, "model_accelerator_artifact_installed", lambda _model_id: True)
+ monkeypatch.setattr(modeld, "model_accelerator_artifact_path", lambda _model_id: modeld.Path("/models/lat-amd.pkl"))
+ loaded = modeld._load_model_lab_model(1928, 1208, "lat", "v11")
+
+ assert loaded.model_id == "lat"
+ assert calls == [(1928, 1208, True, {
+ "model_id_override": "lat",
+ "write_model_version": False,
+ "model_version_override": "v11",
+ "model_path_override": modeld.Path("/models/lat-amd.pkl"),
+ "force_external_gpu": True,
+ })]
+
+
+def test_model_lab_finite_output_guard_checks_both_models():
+ assert modeld._model_outputs_finite({"plan": np.zeros(2)}, {"lead": np.ones(2)})
+ assert not modeld._model_outputs_finite({"plan": np.array([np.nan])}, {"lead": np.ones(2)})
+
+
+def test_model_lab_isolates_only_realized_buffer_uops(monkeypatch):
+ buffer_key = (Ops.BUFFER, "serialized-model-buffer")
+ shape_key = (Ops.RESHAPE, "shared-input-shape")
+ buffer_value, shape_value = object(), object()
+ monkeypatch.setattr(UOpMetaClass, "ucache", {buffer_key: buffer_value, shape_key: shape_value})
+
+ assert modeld._isolate_next_model_artifact_load() == 1
+ assert UOpMetaClass.ucache == {shape_key: shape_value}
+
+
+def test_model_lab_loads_and_warms_both_amd_models_before_returning(monkeypatch):
+ calls = []
+
+ class FakeModel:
+ def __init__(self, model_id):
+ self.model_id = model_id
+
+ def warmup(self):
+ calls.append(("warmup", self.model_id))
+
+ monkeypatch.setattr(modeld, "wait_for_external_gpu_power_ready", lambda CP: calls.append(("power", CP)))
+ monkeypatch.setattr(modeld, "wait_usbgpu_link", lambda: calls.append("link"))
+ monkeypatch.setattr(modeld, "_set_hcq_wait_timeout", lambda timeout: calls.append(("timeout", timeout)))
+ monkeypatch.setattr(modeld, "_close_tinygrad_disk_cache_connection", lambda: calls.append("close_cache"))
+ monkeypatch.setattr(
+ modeld,
+ "_isolate_next_model_artifact_load",
+ lambda: calls.append("isolate_buffers") or 7,
+ )
+ monkeypatch.setattr(
+ modeld,
+ "_load_model_lab_model",
+ lambda _w, _h, model_id, version: calls.append(("load", model_id, version)) or FakeModel(model_id),
+ )
+
+ pair = modeld._load_model_lab_models(1928, 1208, "lat", "long", "v15", "car-params")
+
+ assert [model.model_id for model in pair] == ["lat", "long"]
+ assert calls == [
+ ("power", "car-params"),
+ ("timeout", modeld.BIG_MODEL_LOAD_WAIT_TIMEOUT_MS),
+ "link",
+ "isolate_buffers",
+ ("load", "lat", "v15"),
+ ("warmup", "lat"),
+ "isolate_buffers",
+ ("load", "long", "v15"),
+ ("warmup", "long"),
+ "close_cache",
+ ("timeout", modeld.BIG_MODEL_RUN_WAIT_TIMEOUT_MS),
+ ]
+
+
+def test_each_runner_receives_its_own_input_names_and_shared_frame_data():
+ model = SimpleNamespace(
+ road_key="road",
+ wide_key="wide",
+ desire_key="desire_pulse",
+ numpy_inputs={"action_t": object(), "prev_action": object(), "lateral_control_params": object()},
+ off_policy_enabled=False,
+ off_policy_numpy_inputs={},
+ )
+ previous_action = SimpleNamespace(desiredCurvature=0.25, desiredAcceleration=-0.5)
+ road_buffer, wide_buffer = object(), object()
+ road_transform = np.eye(3, dtype=np.float32)
+ wide_transform = np.eye(3, dtype=np.float32) * 2
+ desire = np.arange(8, dtype=np.float32)
+ traffic = np.array([1, 0], dtype=np.float32)
+ lateral_control = np.array([10.0, 0.2], dtype=np.float32)
+
+ buffers, transforms, inputs = modeld._runner_frame_args(
+ model,
+ road_buffer,
+ wide_buffer,
+ road_transform,
+ wide_transform,
+ desire,
+ traffic,
+ 0.3,
+ 0.6,
+ previous_action,
+ 10.0,
+ lateral_control,
+ )
+
+ assert buffers == {"road": road_buffer, "wide": wide_buffer}
+ np.testing.assert_array_equal(transforms["road"], road_transform)
+ np.testing.assert_array_equal(transforms["wide"], wide_transform)
+ np.testing.assert_array_equal(inputs["desire_pulse"], desire)
+ np.testing.assert_allclose(inputs["action_t"], [0.3, 0.6])
+ np.testing.assert_allclose(inputs["prev_action"], [25.0, -0.5])
+ np.testing.assert_array_equal(inputs["lateral_control_params"], lateral_control)
+
+
+def test_runtime_status_records_requested_pair_and_fallback_error():
+ params = FakeParams({})
+ config = {"lateralModel": "lat", "longitudinalModel": "long"}
+
+ modeld._set_model_lab_runtime(
+ params,
+ requested=True,
+ active=False,
+ config=config,
+ error="synthetic fallback",
+ )
+
+ assert params.values["ModelLabRuntime"] == {
+ "requested": True,
+ "active": False,
+ "lateralModel": "lat",
+ "longitudinalModel": "long",
+ "schedule": "sequential_20hz",
+ "executionDevice": "",
+ "error": "synthetic fallback",
+ }
diff --git a/selfdrive/selfdrived/tests/test_state_machine.py b/selfdrive/selfdrived/tests/test_state_machine.py
index 62cc8d2bf..991af75dd 100644
--- a/selfdrive/selfdrived/tests/test_state_machine.py
+++ b/selfdrive/selfdrived/tests/test_state_machine.py
@@ -94,3 +94,13 @@ class TestStateMachine:
self.update()
assert self.state_machine.state == state
self.events.clear()
+
+ def test_lateral_override_returns_to_enabled_after_release(self):
+ self.state_machine.state = State.enabled
+ self.events.add(make_event([ET.OVERRIDE_LATERAL]))
+ self.update()
+ assert self.state_machine.state == State.overriding
+
+ self.events.clear()
+ self.update()
+ assert self.state_machine.state == State.enabled
diff --git a/selfdrive/ui/layouts/settings/starpilot/longitudinal.py b/selfdrive/ui/layouts/settings/starpilot/longitudinal.py
index 46d7d69c8..b37124b5b 100644
--- a/selfdrive/ui/layouts/settings/starpilot/longitudinal.py
+++ b/selfdrive/ui/layouts/settings/starpilot/longitudinal.py
@@ -726,17 +726,19 @@ class StarPilotLongitudinalLayout(_SettingsPage):
self._daily_rows = [
SettingRow("CustomCruise", "value", tr_noop("Cruise Interval"),
subtitle="",
- get_value=lambda: f"{max(1, self._params.get_int('CustomCruise'))}{self._speed_unit()}",
+ get_value=lambda: f"{max(1, self._params.get_float('CustomCruise')):g}{self._speed_unit()}",
on_click=lambda: self._show_slider("CustomCruise", 1, 150 if self._is_metric() else 99,
unit=self._speed_unit(),
- current_value=max(1, self._params.get_int("CustomCruise"))),
+ value_type="float",
+ current_value=max(1, self._params.get_float("CustomCruise"))),
visible=lambda: self._params.get_bool("QOLLongitudinal")),
SettingRow("CustomCruiseLong", "value", tr_noop("Cruise Long"),
subtitle="",
- get_value=lambda: f"{max(1, self._params.get_int('CustomCruiseLong'))}{self._speed_unit()}",
+ get_value=lambda: f"{max(1, self._params.get_float('CustomCruiseLong')):g}{self._speed_unit()}",
on_click=lambda: self._show_slider("CustomCruiseLong", 1, 150 if self._is_metric() else 99,
unit=self._speed_unit(),
- current_value=max(1, self._params.get_int("CustomCruiseLong"))),
+ value_type="float",
+ current_value=max(1, self._params.get_float("CustomCruiseLong"))),
visible=lambda: self._params.get_bool("QOLLongitudinal")),
SettingRow("ForceStops", "toggle", tr_noop("Force Stops"),
subtitle="",
diff --git a/selfdrive/ui/layouts/settings/starpilot/system_settings.py b/selfdrive/ui/layouts/settings/starpilot/system_settings.py
index 0786351fe..05f3605f9 100644
--- a/selfdrive/ui/layouts/settings/starpilot/system_settings.py
+++ b/selfdrive/ui/layouts/settings/starpilot/system_settings.py
@@ -56,7 +56,9 @@ from openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid import (
TOGGLE_MIN_HEIGHT,
TOGGLE_ROW_HEIGHT,
)
+from openpilot.starpilot.common import param_profiles
from openpilot.starpilot.common.connect_server import prepare_konik_server_switch
+from openpilot.starpilot.common.starpilot_variables import EXCLUDED_KEYS as STARPILOT_EXCLUDED_KEYS, TOGGLE_BACKUPS, update_starpilot_toggles
LEGACY_STARPILOT_PARAM_RENAMES = {
"FrogPilotApiToken": "StarPilotApiToken",
@@ -611,7 +613,7 @@ class AetherBackupsCareDialog(Widget):
self._buttons = [
{"id": "system_backups", "text": tr("System Backups"), "danger": False},
- {"id": "toggle_snapshots", "text": tr("Toggle Snapshots"), "danger": False},
+ {"id": "toggle_snapshots", "text": tr("Profiles & Snapshots"), "danger": False},
{"id": "report_issue", "text": tr("Report Issue"), "danger": False},
{"id": "flash_panda", "text": tr("Flash Panda"), "danger": False},
{"id": "clear_data", "text": tr("Clear Driving Data"), "danger": True},
@@ -901,27 +903,36 @@ class StarPilotSystemLayout(_SettingsPage):
options = [tr("Create Backup"), tr("Restore Backup"), tr("Delete Backup")]
title = tr("System Backups")
else:
- options = [tr("Save Toggle Snapshot"), tr("Restore Toggle Snapshot"), tr("Delete Toggle Snapshot")]
- title = tr("Toggle Snapshots")
+ options = [
+ tr("Profile Slot A"),
+ tr("Profile Slot B"),
+ tr("Save Named Snapshot"),
+ tr("Restore Named Snapshot"),
+ tr("Delete Named Snapshot"),
+ ]
+ title = tr("Settings Profiles & Snapshots")
def on_select(res):
if res != DialogResult.CONFIRM or not dialog.selection:
return
selection = dialog.selection
- if selection == options[0]:
- if backup_kind == "system":
+ if backup_kind == "system":
+ if selection == options[0]:
self._on_create_backup()
- else:
- self._on_create_toggle_backup()
- elif selection == options[1]:
- if backup_kind == "system":
+ elif selection == options[1]:
self._on_restore_backup()
- else:
- self._on_restore_toggle_backup()
- elif selection == options[2]:
- if backup_kind == "system":
+ elif selection == options[2]:
self._on_delete_backup()
- else:
+ else:
+ if selection == options[0]:
+ self._open_param_profile("a")
+ elif selection == options[1]:
+ self._open_param_profile("b")
+ elif selection == options[2]:
+ self._on_create_toggle_backup()
+ elif selection == options[3]:
+ self._on_restore_toggle_backup()
+ elif selection == options[4]:
self._on_delete_toggle_backup()
dialog = MultiOptionDialog(title, options, callback=on_select)
@@ -1182,6 +1193,70 @@ class StarPilotSystemLayout(_SettingsPage):
self._keyboard.set_callback(lambda result: on_name(result, self._keyboard.text))
gui_app.push_widget(self._keyboard)
+ def _open_param_profile(self, slot: str):
+ status = param_profiles.profile_status(slot, profile_root=TOGGLE_BACKUPS)
+ options = [tr("Save Current Settings")]
+ if status["saved"] and not status.get("invalid"):
+ options.append(tr("Load Saved Settings"))
+
+ def _on_select(res):
+ if res != DialogResult.CONFIRM or not dialog.selection:
+ return
+ if dialog.selection == options[0]:
+ if ui_state.started:
+ gui_app.push_widget(alert_dialog(tr("Settings profiles can only be saved while parked.")))
+ return
+ if status["saved"]:
+ gui_app.push_widget(ConfirmDialog(
+ tr("Overwrite {} with your current settings?").format(status["label"]),
+ tr("Overwrite"),
+ callback=lambda confirm_res: self._save_param_profile(slot) if confirm_res == DialogResult.CONFIRM else None,
+ ))
+ else:
+ self._save_param_profile(slot)
+ elif len(options) > 1 and dialog.selection == options[1]:
+ if ui_state.started:
+ gui_app.push_widget(alert_dialog(tr("Settings profiles can only be loaded while parked.")))
+ return
+ gui_app.push_widget(ConfirmDialog(
+ tr("Load {} and overwrite your current settings?").format(status["label"]),
+ tr("Load"),
+ callback=lambda confirm_res: self._load_param_profile(slot) if confirm_res == DialogResult.CONFIRM else None,
+ ))
+
+ dialog = MultiOptionDialog(status["label"], options, callback=_on_select)
+ gui_app.push_widget(dialog)
+
+ def _save_param_profile(self, slot: str):
+ try:
+ status = param_profiles.save_profile(
+ self._params,
+ slot,
+ allowed_keys=param_profiles.eligible_profile_keys(self._params, excluded_keys=STARPILOT_EXCLUDED_KEYS),
+ profile_root=TOGGLE_BACKUPS,
+ )
+ except param_profiles.ParamProfileError as error:
+ gui_app.push_widget(alert_dialog(str(error)))
+ return
+ gui_app.push_widget(alert_dialog(tr("Saved current settings to {}.").format(status["label"])))
+
+ def _load_param_profile(self, slot: str):
+ try:
+ result = param_profiles.load_profile(
+ self._params,
+ slot,
+ allowed_keys=param_profiles.eligible_profile_keys(self._params, excluded_keys=STARPILOT_EXCLUDED_KEYS),
+ profile_root=TOGGLE_BACKUPS,
+ legacy_renames=LEGACY_STARPILOT_PARAM_RENAMES,
+ )
+ except param_profiles.ParamProfileError as error:
+ gui_app.push_widget(alert_dialog(str(error)))
+ return
+ update_starpilot_toggles()
+ gui_app.push_widget(alert_dialog(
+ tr("Loaded {} settings from {}.").format(result["restoredCount"], result["label"])
+ ))
+
def _on_restore_toggle_backup(self):
backups = self._get_backups("toggle_backups")
if not backups:
diff --git a/starpilot/assets/model_manager.py b/starpilot/assets/model_manager.py
index 2a66b636c..c3fe49e77 100644
--- a/starpilot/assets/model_manager.py
+++ b/starpilot/assets/model_manager.py
@@ -22,6 +22,7 @@ from openpilot.starpilot.common.model_versions import (
driving_artifact_filename,
is_supported_artifact_format,
)
+from openpilot.starpilot.common.model_lab import load_model_lab_config
from openpilot.starpilot.common.starpilot_utilities import delete_file
from openpilot.starpilot.common.starpilot_variables import MODELS_PATH
from openpilot.common.file_chunker import file_chunked_exists, get_existing_chunks, get_manifest_path
@@ -52,8 +53,11 @@ CANCEL_DOWNLOAD_PARAM = "CancelModelDownload"
DOWNLOAD_PROGRESS_PARAM = "ModelDownloadProgress"
MODEL_DOWNLOAD_PARAM = "ModelToDownload"
MODEL_DOWNLOAD_ALL_PARAM = "DownloadAllModels"
+MODEL_LAB_DOWNLOAD_PARAM = "ModelLabModelToDownload"
ALLOW_GPU_DOWNLOAD_WITHOUT_GPU_PARAM = "AllowGpuModelDownloadWithoutGpu"
UPDATE_TINYGRAD_PARAM = "UpdateTinygrad"
+MODEL_LAB_ACCELERATOR = "chestnut"
+MODEL_LAB_EXECUTION_DEVICE = "AMD"
def _clean_model_name(name: str) -> str:
@@ -109,6 +113,38 @@ def model_uses_external_gpu(model_key: str) -> bool:
return bool(load_model_artifact_metadata(model_key).get("uses_external_gpu", False))
+def model_accelerator_artifact_metadata(model_key: str, accelerator: str = MODEL_LAB_ACCELERATOR) -> dict:
+ metadata = load_model_artifact_metadata(model_key)
+ artifacts = metadata.get("accelerator_artifacts", {})
+ if not isinstance(artifacts, dict):
+ return {}
+ artifact = artifacts.get(str(accelerator or "").strip().lower(), {})
+ return artifact if isinstance(artifact, dict) else {}
+
+
+def model_accelerator_artifact_filename(model_key: str, accelerator: str = MODEL_LAB_ACCELERATOR) -> str:
+ model_key = canonical_model_key(model_key)
+ accelerator = str(accelerator or "").strip().lower()
+ return f"{model_key}_driving_{accelerator}_tinygrad.pkl"
+
+
+def model_accelerator_artifact_path(model_key: str, accelerator: str = MODEL_LAB_ACCELERATOR) -> Path:
+ return MODELS_PATH / model_accelerator_artifact_filename(model_key, accelerator)
+
+
+def model_accelerator_artifact_available(model_key: str, accelerator: str = MODEL_LAB_ACCELERATOR) -> bool:
+ artifact = model_accelerator_artifact_metadata(model_key, accelerator)
+ execution_device = str(artifact.get("execution_device") or artifact.get("device") or "").strip().upper()
+ artifact_format = str(artifact.get("artifact_format") or UNIFIED_ARTIFACT_FORMAT).strip()
+ return bool(artifact) and execution_device == MODEL_LAB_EXECUTION_DEVICE and is_supported_artifact_format(artifact_format)
+
+
+def model_accelerator_artifact_installed(model_key: str, accelerator: str = MODEL_LAB_ACCELERATOR) -> bool:
+ return model_accelerator_artifact_available(model_key, accelerator) and file_chunked_exists(
+ model_accelerator_artifact_path(model_key, accelerator)
+ )
+
+
def external_gpu_available() -> bool:
"""Return whether the supported external GPU link is ready for modeld."""
try:
@@ -337,6 +373,31 @@ class ModelManager:
return artifact_url_map
+ @staticmethod
+ def _normalize_accelerator_artifacts(model: dict) -> dict[str, dict]:
+ raw_artifacts = model.get("accelerator_artifacts")
+ if not isinstance(raw_artifacts, dict):
+ return {}
+
+ artifacts: dict[str, dict] = {}
+ for accelerator, raw_artifact in raw_artifacts.items():
+ accelerator = str(accelerator or "").strip().lower()
+ if not accelerator or not isinstance(raw_artifact, dict):
+ continue
+ artifact_format = str(raw_artifact.get("artifact_format") or UNIFIED_ARTIFACT_FORMAT).strip()
+ if not is_supported_artifact_format(artifact_format):
+ continue
+ artifacts[accelerator] = {
+ "artifact_format": artifact_format,
+ "artifact_filename": str(raw_artifact.get("artifact_filename") or "").strip(),
+ "artifact_size": int(raw_artifact.get("artifact_size") or 0),
+ "artifact_sha256": str(raw_artifact.get("artifact_sha256") or "").strip().lower(),
+ "artifact_chunk_count": int(raw_artifact.get("artifact_chunk_count") or 0),
+ "artifact_url": str(raw_artifact.get("artifact_url") or raw_artifact.get("download_url") or "").strip(),
+ "execution_device": str(raw_artifact.get("execution_device") or raw_artifact.get("device") or "").strip().upper(),
+ }
+ return artifacts
+
def _build_artifact_metadata_map(self, model_info: list[dict]) -> dict[str, dict]:
metadata: dict[str, dict] = {}
for model in model_info:
@@ -344,6 +405,9 @@ class ModelManager:
artifact_format = str(model.get("artifact_format") or UNIFIED_ARTIFACT_FORMAT).strip()
if not model_key or not is_supported_artifact_format(artifact_format):
continue
+ uses_external_gpu = bool(model.get("uses_external_gpu", False))
+ model_size_declared = bool(model.get("model_size") or model.get("size_class"))
+ model_size = str(model.get("model_size") or model.get("size_class") or ("chestnut" if uses_external_gpu else "small")).strip()
metadata[model_key] = {
"artifact_format": artifact_format,
"artifact_filename": str(model.get("artifact_filename") or "").strip(),
@@ -351,7 +415,11 @@ class ModelManager:
"artifact_sha256": str(model.get("artifact_sha256") or "").strip().lower(),
"artifact_chunk_count": int(model.get("artifact_chunk_count") or 0),
"artifact_url": str(model.get("artifact_url") or model.get("download_url") or "").strip(),
- "uses_external_gpu": bool(model.get("uses_external_gpu", False)),
+ "uses_external_gpu": uses_external_gpu,
+ "model_size": model_size,
+ "model_size_declared": model_size_declared,
+ "model_lab_eligible": bool(model.get("model_lab_eligible", not uses_external_gpu)),
+ "accelerator_artifacts": self._normalize_accelerator_artifacts(model),
}
return metadata
@@ -424,6 +492,9 @@ class ModelManager:
def randomize_selected_model(self) -> str | None:
if not self._param_bool("ModelRandomizer"):
return None
+ if load_model_lab_config(self.params)["enabled"]:
+ print("Model Randomizer skipped while Model Laboratory is enabled.")
+ return None
choices = self._installed_model_choices()
if not choices:
@@ -615,6 +686,9 @@ class ModelManager:
"community_favorite": False,
"artifact_format": UNIFIED_ARTIFACT_FORMAT,
"uses_external_gpu": bool(info.get("uses_external_gpu", False)),
+ "model_size": str(info.get("model_size") or "small").strip(),
+ "model_lab_eligible": bool(info.get("model_lab_eligible", not info.get("uses_external_gpu", False))),
+ "accelerator_artifacts": info.get("accelerator_artifacts", {}),
}
return list(discovered.values())
@@ -740,6 +814,132 @@ class ModelManager:
finally:
self.params_memory.remove(ALLOW_GPU_DOWNLOAD_WITHOUT_GPU_PARAM)
+ def _download_artifact_to_path(self, model_key: str, file_path: Path, remote_filename: str,
+ artifact_metadata: dict, artifact_urls: dict[str, str],
+ resource_urls: list[str]) -> bool:
+ manifest_version = self._param_text("ModelManifestVersion") or MANIFEST_CANDIDATES[0]
+ candidate_urls: list[tuple[str, bool, bool]] = []
+ custom_url = (
+ artifact_urls.get(file_path.name)
+ or artifact_urls.get(remote_filename)
+ or artifact_metadata.get("artifact_url")
+ or ""
+ ).strip()
+ if custom_url:
+ candidate_urls.append((custom_url, True, False))
+
+ for resource_url in resource_urls:
+ for artifact_url in self._artifact_source_urls(resource_url, manifest_version, model_key, remote_filename):
+ if not any(existing[0] == artifact_url for existing in candidate_urls):
+ candidate_urls.append((artifact_url, False, True))
+
+ for candidate_url, allow_unknown_size, allow_multipart in candidate_urls:
+ chunk_count = int(artifact_metadata.get("artifact_chunk_count") or 0)
+ if chunk_count and download_chunked_file(
+ CANCEL_DOWNLOAD_PARAM,
+ file_path,
+ DOWNLOAD_PROGRESS_PARAM,
+ candidate_url,
+ self.params_memory,
+ expected_size=artifact_metadata.get("artifact_size"),
+ expected_sha256=artifact_metadata.get("artifact_sha256"),
+ expected_chunk_count=chunk_count,
+ ):
+ return True
+
+ download_file(
+ CANCEL_DOWNLOAD_PARAM,
+ file_path,
+ DOWNLOAD_PROGRESS_PARAM,
+ candidate_url,
+ MODEL_DOWNLOAD_PARAM,
+ self.params_memory,
+ allow_unknown_size=allow_unknown_size,
+ suppress_errors=True,
+ )
+ if self.params_memory.get_bool(CANCEL_DOWNLOAD_PARAM):
+ return False
+
+ if verify_download(
+ file_path,
+ candidate_url,
+ allow_unknown_size=allow_unknown_size,
+ expected_size=artifact_metadata.get("artifact_size"),
+ expected_sha256=artifact_metadata.get("artifact_sha256"),
+ ):
+ return True
+ delete_file(file_path, print_error=False)
+
+ if not chunk_count and download_chunked_file(
+ CANCEL_DOWNLOAD_PARAM,
+ file_path,
+ DOWNLOAD_PROGRESS_PARAM,
+ candidate_url,
+ self.params_memory,
+ ):
+ return True
+
+ if allow_multipart and download_multipart_file(
+ CANCEL_DOWNLOAD_PARAM,
+ file_path,
+ DOWNLOAD_PROGRESS_PARAM,
+ candidate_url,
+ MODEL_DOWNLOAD_PARAM,
+ self.params_memory,
+ ):
+ return True
+
+ delete_chunked_artifact(file_path)
+ return False
+
+ def download_model_accelerator(self, model_key: str, accelerator: str = MODEL_LAB_ACCELERATOR) -> bool:
+ self.downloading_model = True
+ 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...",
+ f"The manifest has no precompiled {MODEL_LAB_EXECUTION_DEVICE} artifact for {model_key}.",
+ MODEL_LAB_DOWNLOAD_PARAM, DOWNLOAD_PROGRESS_PARAM, self.params_memory)
+ return False
+
+ resource_urls = get_resource_urls()
+ if not resource_urls:
+ handle_error(None, "Hugging Face and GitHub are offline...", "Repository unavailable",
+ MODEL_LAB_DOWNLOAD_PARAM, DOWNLOAD_PROGRESS_PARAM, self.params_memory)
+ return False
+
+ artifact_urls = self._load_artifact_url_map().get(model_key, {})
+ local_path = model_accelerator_artifact_path(model_key, accelerator)
+ remote_filename = str(artifact_metadata.get("artifact_filename") or local_path.name).strip()
+ if Path(remote_filename).name != remote_filename:
+ handle_error(None, "Invalid accelerator artifact filename...", "Model download failed",
+ MODEL_LAB_DOWNLOAD_PARAM, DOWNLOAD_PROGRESS_PARAM, self.params_memory)
+ return False
+
+ if not self._download_artifact_to_path(
+ model_key, local_path, remote_filename, artifact_metadata, artifact_urls, resource_urls,
+ ):
+ if self.params_memory.get_bool(CANCEL_DOWNLOAD_PARAM):
+ handle_error(None, "Download cancelled...", "Download cancelled...",
+ MODEL_LAB_DOWNLOAD_PARAM, DOWNLOAD_PROGRESS_PARAM, self.params_memory)
+ else:
+ handle_error(local_path, "Verification failed...", f"Verification failed for {remote_filename}",
+ MODEL_LAB_DOWNLOAD_PARAM, DOWNLOAD_PROGRESS_PARAM, self.params_memory)
+ return False
+
+ self.params_memory.put(DOWNLOAD_PROGRESS_PARAM, "Chestnut artifact downloaded!")
+ return True
+ finally:
+ self.params_memory.remove(MODEL_LAB_DOWNLOAD_PARAM)
+ self.downloading_model = False
+
def _download_model(self, model_to_download: str, allow_gpu_without_gpu: bool):
self.downloading_model = True
model_to_download = self._canonical_model_key(model_to_download)
@@ -787,83 +987,15 @@ class ModelManager:
for filename in required_files:
file_path = MODELS_PATH / filename
remote_filename = str(artifact_metadata.get("artifact_filename") or filename).strip()
- manifest_version = self._param_text("ModelManifestVersion") or MANIFEST_CANDIDATES[0]
- candidate_urls: list[tuple[str, bool, bool]] = []
+ download_succeeded = self._download_artifact_to_path(
+ model_to_download, file_path, remote_filename, artifact_metadata, artifact_urls, resource_urls,
+ )
- custom_url = (artifact_urls.get(filename) or artifact_urls.get(remote_filename) or artifact_metadata.get("artifact_url") or "").strip()
- if custom_url:
- candidate_urls.append((custom_url, True, False))
-
- for resource_url in resource_urls:
- for artifact_url in self._artifact_source_urls(resource_url, manifest_version, model_to_download, remote_filename):
- if not any(existing[0] == artifact_url for existing in candidate_urls):
- candidate_urls.append((artifact_url, False, True))
-
- download_succeeded = False
- for candidate_url, allow_unknown_size, allow_multipart in candidate_urls:
- chunk_count = int(artifact_metadata.get("artifact_chunk_count") or 0)
- if chunk_count and download_chunked_file(
- CANCEL_DOWNLOAD_PARAM,
- file_path,
- DOWNLOAD_PROGRESS_PARAM,
- candidate_url,
- self.params_memory,
- expected_size=artifact_metadata.get("artifact_size"),
- expected_sha256=artifact_metadata.get("artifact_sha256"),
- expected_chunk_count=chunk_count,
- ):
- download_succeeded = True
- break
-
- download_file(
- CANCEL_DOWNLOAD_PARAM,
- file_path,
- DOWNLOAD_PROGRESS_PARAM,
- candidate_url,
- MODEL_DOWNLOAD_PARAM,
- self.params_memory,
- allow_unknown_size=allow_unknown_size,
- suppress_errors=True,
- )
+ if not download_succeeded:
if self.params_memory.get_bool(CANCEL_DOWNLOAD_PARAM):
handle_error(None, "Download cancelled...", "Download cancelled...", MODEL_DOWNLOAD_PARAM, DOWNLOAD_PROGRESS_PARAM, self.params_memory)
self.downloading_model = False
return
-
- if verify_download(
- file_path,
- candidate_url,
- allow_unknown_size=allow_unknown_size,
- expected_size=artifact_metadata.get("artifact_size"),
- expected_sha256=artifact_metadata.get("artifact_sha256"),
- ):
- download_succeeded = True
- break
- delete_file(file_path, print_error=False)
-
- if not chunk_count and download_chunked_file(
- CANCEL_DOWNLOAD_PARAM,
- file_path,
- DOWNLOAD_PROGRESS_PARAM,
- candidate_url,
- self.params_memory,
- ):
- download_succeeded = True
- break
-
- if allow_multipart and download_multipart_file(
- CANCEL_DOWNLOAD_PARAM,
- file_path,
- DOWNLOAD_PROGRESS_PARAM,
- candidate_url,
- MODEL_DOWNLOAD_PARAM,
- self.params_memory,
- ):
- download_succeeded = True
- break
-
- if not download_succeeded:
- delete_chunked_artifact(file_path)
handle_error(file_path, "Verification failed...", f"Verification failed for {filename}", MODEL_DOWNLOAD_PARAM, DOWNLOAD_PROGRESS_PARAM, self.params_memory)
self.downloading_model = False
return
diff --git a/starpilot/assets/tests/test_model_pipeline.py b/starpilot/assets/tests/test_model_pipeline.py
index 7776e0f17..bc57e798e 100644
--- a/starpilot/assets/tests/test_model_pipeline.py
+++ b/starpilot/assets/tests/test_model_pipeline.py
@@ -141,6 +141,110 @@ def test_external_gpu_requirement_is_cached_from_manifest(tmp_path, monkeypatch)
assert not model_manager.model_uses_external_gpu("missing")
+def test_manifest_metadata_classifies_model_lab_candidates_and_accelerator_artifacts(tmp_path, monkeypatch):
+ monkeypatch.setattr(model_manager, "MODELS_PATH", tmp_path)
+ manager = object.__new__(ModelManager)
+ metadata = manager._build_artifact_metadata_map([
+ {"id": "legacy-small", "version": "v15"},
+ {
+ "id": "declared-small",
+ "version": "v15",
+ "model_size": "small",
+ "model_lab_eligible": True,
+ "accelerator_artifacts": {
+ "chestnut": {
+ "artifact_filename": "declared-small-amd.pkl",
+ "artifact_size": 123,
+ "artifact_sha256": "a" * 64,
+ "artifact_chunk_count": 2,
+ "execution_device": "AMD",
+ },
+ },
+ },
+ {"id": "chestnut", "version": "v16", "uses_external_gpu": True},
+ ])
+ (tmp_path / model_manager.ARTIFACT_METADATA_CACHE).write_text(json.dumps(metadata))
+
+ assert metadata["legacy-small"]["model_size"] == "small"
+ assert metadata["legacy-small"]["model_size_declared"] is False
+ assert metadata["legacy-small"]["model_lab_eligible"] is True
+ assert metadata["declared-small"]["model_size_declared"] is True
+ assert metadata["declared-small"]["accelerator_artifacts"]["chestnut"] == {
+ "artifact_format": UNIFIED_ARTIFACT_FORMAT,
+ "artifact_filename": "declared-small-amd.pkl",
+ "artifact_size": 123,
+ "artifact_sha256": "a" * 64,
+ "artifact_chunk_count": 2,
+ "artifact_url": "",
+ "execution_device": "AMD",
+ }
+ assert metadata["chestnut"]["model_size"] == "chestnut"
+ assert metadata["chestnut"]["model_lab_eligible"] is False
+ assert model_manager.model_accelerator_artifact_available("declared-small")
+ assert not model_manager.model_accelerator_artifact_available("legacy-small")
+ assert model_manager.model_accelerator_artifact_path("declared-small") == (
+ tmp_path / "declared-small_driving_chestnut_tinygrad.pkl"
+ )
+
+
+def test_model_manager_downloads_precompiled_accelerator_variant_without_compiling(tmp_path, monkeypatch):
+ monkeypatch.setattr(model_manager, "MODELS_PATH", tmp_path)
+ manager = object.__new__(ModelManager)
+
+ class FakeParams:
+ def __init__(self, values=None):
+ self.values = values or {}
+
+ def get(self, key):
+ return self.values.get(key)
+
+ def get_bool(self, key):
+ return bool(self.values.get(key, False))
+
+ def put(self, key, value):
+ self.values[key] = value
+
+ def remove(self, key):
+ self.values.pop(key, None)
+
+ manager.params = FakeParams({"ModelManifestVersion": "v25"})
+ manager.params_memory = FakeParams({model_manager.MODEL_LAB_DOWNLOAD_PARAM: "lat"})
+ manager.downloading_model = False
+ metadata = manager._build_artifact_metadata_map([{
+ "id": "lat",
+ "accelerator_artifacts": {
+ "chestnut": {
+ "artifact_filename": "lat-amd.pkl",
+ "artifact_size": 456,
+ "execution_device": "AMD",
+ },
+ },
+ }])
+ (tmp_path / model_manager.ARTIFACT_METADATA_CACHE).write_text(json.dumps(metadata))
+ monkeypatch.setattr(model_manager, "external_gpu_available", lambda: True)
+ monkeypatch.setattr(model_manager, "get_resource_urls", lambda: ["https://models.example"])
+ monkeypatch.setattr(manager, "_load_artifact_url_map", lambda: {})
+ calls = []
+
+ def fake_download(model_key, path, remote_filename, artifact_metadata, artifact_urls, resource_urls):
+ calls.append((model_key, path, remote_filename, artifact_metadata, artifact_urls, resource_urls))
+ path.write_bytes(b"precompiled-amd")
+ return True
+
+ monkeypatch.setattr(manager, "_download_artifact_to_path", fake_download)
+
+ assert manager.download_model_accelerator("lat")
+ assert calls[0][0:3] == (
+ "lat",
+ tmp_path / "lat_driving_chestnut_tinygrad.pkl",
+ "lat-amd.pkl",
+ )
+ 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 model_manager.MODEL_LAB_DOWNLOAD_PARAM not in manager.params_memory.values
+
+
def test_local_gpu_compile_persists_runtime_metadata(tmp_path, monkeypatch):
models_path = tmp_path / "models"
compiled_path = tmp_path / "compiled" / "local-large_driving_tinygrad.pkl"
diff --git a/starpilot/car/ford/lateral.py b/starpilot/car/ford/lateral.py
index 8a9049dcc..0436fb37e 100644
--- a/starpilot/car/ford/lateral.py
+++ b/starpilot/car/ford/lateral.py
@@ -32,7 +32,6 @@ CURVATURE_LOOKAHEAD_MAX = 0.40
FORD_CURVATURE_LOOKAHEAD = {
CAR.FORD_EXPLORER_MK6: 0.20,
}
-ANGLE_HANDOFF_PRESS_SECONDS = 0.5
ANGLE_HANDOFF_RECOVERY_SECONDS = 0.75
HANDOFF_PAUSE_MIN_FRAMES = 3
HANDOFF_PAUSE_FRAMES = 6
@@ -129,12 +128,9 @@ class FordLateralController:
self.curvature_samples = deque(maxlen=max(2, round(0.3 / STEER_DT)))
self.path_angle_last = 0.0
self.curvature_last = 0.0
- self.handoff_press_timer = 0.0
- self.handoff_driver_override = False
self.angle_pause_frames = 0
self.angle_pause_cooldown = 0.0
self.angle_handoff_recovery = 0.0
- self.angle_handoff_rebase = False
self.angle_stall_timer = 0.0
self.angle_stall_recoveries = 0
self._frame = 0
@@ -225,36 +221,19 @@ class FordLateralController:
self.human_turn_enabled, CS.out.steeringPressed, CS.out.steeringAngleDeg)
def _reset_handoff(self):
- self.handoff_press_timer = 0.0
- self.handoff_driver_override = False
self.angle_pause_frames = 0
self.angle_pause_cooldown = 0.0
self.angle_handoff_recovery = 0.0
- self.angle_handoff_rebase = False
self.angle_stall_timer = 0.0
self.angle_stall_recoveries = 0
- def _angle_handoff_pause_active(self, CS) -> bool:
- if not self.human_turn_enabled:
- self._reset_handoff()
- return False
+ def _update_angle_driver_override(self, steering_pressed: bool) -> bool:
+ if steering_pressed:
+ self.angle_handoff_recovery = ANGLE_HANDOFF_RECOVERY_SECONDS
+ return steering_pressed
+ def _angle_stall_pause_active(self, CS) -> bool:
self.angle_pause_cooldown = max(0.0, self.angle_pause_cooldown - STEER_DT)
- if CS.out.steeringPressed:
- self.angle_handoff_recovery = 0.0
- self.angle_handoff_rebase = False
- self.handoff_press_timer += STEER_DT
- self.handoff_driver_override |= self.handoff_press_timer + 1e-9 >= ANGLE_HANDOFF_PRESS_SECONDS
- else:
- if self.handoff_driver_override:
- self.angle_handoff_recovery = ANGLE_HANDOFF_RECOVERY_SECONDS
- self.angle_handoff_rebase = True
- if (self.angle_pause_cooldown <= 0.0 and self.angle_pause_frames <= 0
- and abs(self.path_angle_last) < HANDOFF_MAX_PATH_ANGLE):
- self.angle_pause_frames = HANDOFF_PAUSE_FRAMES
- self.handoff_driver_override = False
- self.handoff_press_timer = 0.0
-
if self.angle_pause_frames > 0:
pause_frames_sent = HANDOFF_PAUSE_FRAMES - self.angle_pause_frames
pscm_available = getattr(CS, "lateral_control_status", None) == LAT_CTL_STATUS_AVAILABLE
@@ -347,9 +326,8 @@ class FordLateralController:
self._reset_handoff()
return self._inactive_angle_result(current)
- manual_turn = self._manual_turn(CC, CS)
- handoff_pause = self._angle_handoff_pause_active(CS)
- if manual_turn or handoff_pause:
+ driver_override = self._update_angle_driver_override(bool(CS.out.steeringPressed))
+ if self._angle_stall_pause_active(CS):
return self._inactive_angle_result(current)
v_ego = float(CS.out.vEgoRaw)
@@ -369,7 +347,7 @@ class FordLateralController:
measured_curvature = float(getattr(CC, "currentCurvature", current))
if not np.isfinite(measured_curvature):
measured_curvature = current
- requested = self._recover_angle_handoff(requested, measured_curvature)
+ requested = measured_curvature if driver_override else self._recover_angle_handoff(requested, measured_curvature)
low_gain_high_speed, high_gain_high_speed = self._platform_angle_gains()
low_gain = float(np.interp(v_ego, [13.5, 26.82],
@@ -381,10 +359,7 @@ class FordLateralController:
path_angle = float(np.clip(requested * v_ego * gain, PATH_ANGLE_MIN, PATH_ANGLE_MAX))
max_delta = float(np.interp(v_ego, [9.0, 10.0, 15.0, 25.0], [0.055, 0.055, 0.0425, 0.009]))
- if self.angle_handoff_rebase:
- self.angle_handoff_rebase = False
- else:
- path_angle = float(np.clip(path_angle, self.path_angle_last - max_delta, self.path_angle_last + max_delta))
+ path_angle = float(np.clip(path_angle, self.path_angle_last - max_delta, self.path_angle_last + max_delta))
self.path_angle_last = path_angle
lane_change = self._lane_change()[0]
diff --git a/starpilot/car/ford/tests/test_lateral.py b/starpilot/car/ford/tests/test_lateral.py
index bfd7c3b29..23108fe7f 100644
--- a/starpilot/car/ford/tests/test_lateral.py
+++ b/starpilot/car/ford/tests/test_lateral.py
@@ -106,15 +106,16 @@ def test_angle_strategy_uses_path_angle_and_shadow(controller):
assert result.shadow_curvature == pytest.approx(0.0005)
-def test_manual_turn_releases_lateral(controller):
+def test_manual_turn_keeps_angle_session_active(controller):
controller.human_turn_enabled = True
- CC = SimpleNamespace(latActive=True)
- CS = car_state(steering_pressed=True, steering_angle=50.0)
- actuators = SimpleNamespace(curvature=0.001)
+ measured_curvature = 0.004
+ CC = SimpleNamespace(latActive=True, currentCurvature=measured_curvature)
+ CS = car_state(speed=8.0, curvature=measured_curvature, steering_pressed=True, steering_angle=50.0)
+ actuators = SimpleNamespace(curvature=-0.005)
for _ in range(61):
result = controller.update_angle(CC, CS, actuators)
- assert not result.active
- assert result.path_angle == 0.0
+ assert result.active
+ assert result.path_angle == pytest.approx(measured_curvature * 8.0 * 1.3)
def test_curvature_control_stays_active_during_driver_correction(controller):
@@ -144,19 +145,33 @@ def test_curvature_manual_turn_keeps_session_active_with_neutral_command(control
assert result.path_angle == 0.0
-def test_angle_control_pulses_inactive_after_sustained_driver_correction(controller):
+def test_angle_control_stays_active_after_sustained_driver_correction(controller):
controller.human_turn_enabled = True
- CC = SimpleNamespace(latActive=True)
- actuators = SimpleNamespace(curvature=0.001)
+ measured_curvature = 0.001
+ CC = SimpleNamespace(latActive=True, currentCurvature=measured_curvature)
+ actuators = SimpleNamespace(curvature=-0.001)
- for _ in range(10):
- assert controller.update_angle(
- CC, car_state(steering_pressed=True, steering_angle=10.0), actuators).active
+ for _ in range(20):
+ result = controller.update_angle(
+ CC, car_state(curvature=measured_curvature, steering_pressed=True, steering_angle=10.0), actuators)
+ assert result.active
+ assert result.path_angle > 0.0
for _ in range(HANDOFF_PAUSE_FRAMES):
- assert not controller.update_angle(CC, car_state(), actuators).active
+ assert controller.update_angle(CC, car_state(curvature=measured_curvature), actuators).active
- assert controller.update_angle(CC, car_state(), actuators).active
+
+def test_angle_driver_override_is_handoff_safe_with_human_turn_detection_disabled(controller):
+ controller.human_turn_enabled = False
+ measured_curvature = 0.002
+ result = controller.update_angle(
+ SimpleNamespace(latActive=True, currentCurvature=measured_curvature),
+ car_state(curvature=measured_curvature, steering_pressed=True),
+ SimpleNamespace(curvature=-0.002),
+ )
+
+ assert result.active
+ assert result.path_angle > 0.0
def test_short_driver_correction_does_not_pause_angle_control(controller):
@@ -171,36 +186,34 @@ def test_short_driver_correction_does_not_pause_angle_control(controller):
assert controller.update_angle(CC, car_state(), actuators).active
-def test_angle_control_resumes_after_pscm_acknowledges_pause(controller):
+def test_angle_driver_handoff_does_not_depend_on_pscm_mode_reset(controller):
controller.human_turn_enabled = True
- CC = SimpleNamespace(latActive=True)
- actuators = SimpleNamespace(curvature=0.001)
+ measured_curvature = 0.001
+ CC = SimpleNamespace(latActive=True, currentCurvature=measured_curvature)
+ actuators = SimpleNamespace(curvature=-0.001)
for _ in range(10):
assert controller.update_angle(
- CC, car_state(steering_pressed=True, steering_angle=10.0), actuators).active
+ CC, car_state(curvature=measured_curvature, steering_pressed=True, steering_angle=10.0), actuators).active
for _ in range(HANDOFF_PAUSE_MIN_FRAMES):
- assert not controller.update_angle(
+ assert controller.update_angle(
CC, car_state(lateral_control_status=1), actuators).active
- assert controller.update_angle(
- CC, car_state(lateral_control_status=1), actuators).active
-
-def test_long_manual_turn_still_resets_angle_control_on_release(controller):
+def test_long_manual_turn_hands_angle_control_back_without_disabling(controller):
controller.human_turn_enabled = True
- CC = SimpleNamespace(latActive=True, currentCurvature=0.0)
- actuators = SimpleNamespace(curvature=0.001)
+ measured_curvature = 0.004
+ CC = SimpleNamespace(latActive=True, currentCurvature=measured_curvature)
+ actuators = SimpleNamespace(curvature=-0.005)
for _ in range(40):
- controller.update_angle(
- CC, car_state(steering_pressed=True, steering_angle=50.0), actuators)
+ assert controller.update_angle(
+ CC, car_state(speed=8.0, curvature=measured_curvature, steering_pressed=True, steering_angle=50.0), actuators).active
for _ in range(HANDOFF_PAUSE_FRAMES):
- assert not controller.update_angle(CC, car_state(), actuators).active
-
- assert controller.update_angle(CC, car_state(), actuators).active
+ assert controller.update_angle(
+ CC, car_state(speed=8.0, curvature=measured_curvature), actuators).active
def test_angle_handoff_reenters_from_measured_curvature(controller):
@@ -213,9 +226,6 @@ def test_angle_handoff_reenters_from_measured_curvature(controller):
for _ in range(10):
controller.update_angle(
CC, car_state(speed=8.0, curvature=measured_curvature, steering_pressed=True, steering_angle=10.0), actuators)
- for _ in range(HANDOFF_PAUSE_FRAMES):
- assert not controller.update_angle(
- CC, car_state(speed=8.0, curvature=measured_curvature), actuators).active
resumed = controller.update_angle(CC, car_state(speed=8.0, curvature=measured_curvature), actuators)
assert resumed.active
diff --git a/starpilot/common/assets/device_settings_layout.json b/starpilot/common/assets/device_settings_layout.json
index 8c69209b0..dde3e088d 100644
--- a/starpilot/common/assets/device_settings_layout.json
+++ b/starpilot/common/assets/device_settings_layout.json
@@ -3686,16 +3686,6 @@
"galaxy_only": true,
"settings_tier": "simple"
},
- {
- "key": "SubaruAvhOnAtStartup",
- "label": "AVH On at Startup",
- "description": "For supported Subaru Legacy 2025 vehicles, send one momentary Auto Vehicle Hold request after ignition while stationary and in Park or Neutral.",
- "picker_description": "Requests Auto Vehicle Hold ON once after ignition on the supported Legacy.",
- "data_type": "bool",
- "ui_type": "toggle",
- "galaxy_only": true,
- "settings_tier": "simple"
- },
{
"key": "ClusterOffset",
"label": "Dashboard Speed Offset",
diff --git a/starpilot/common/model_lab.py b/starpilot/common/model_lab.py
new file mode 100644
index 000000000..0c8135fdf
--- /dev/null
+++ b/starpilot/common/model_lab.py
@@ -0,0 +1,205 @@
+from __future__ import annotations
+
+import json
+from typing import Any
+
+import numpy as np
+
+MODEL_LAB_CONFIG_PARAM = "ModelLabConfig"
+MODEL_LAB_RUNTIME_PARAM = "ModelLabRuntime"
+MODEL_LAB_MIN_MODEL_VERSION = 8
+
+LATERAL_OUTPUT_KEYS = (
+ "desired_curvature",
+ "desired_curvature_stds",
+ "lat_planner_solution",
+ "lat_planner_solution_stds",
+ "lane_lines",
+ "lane_lines_stds",
+ "lane_lines_prob",
+ "road_edges",
+ "road_edges_stds",
+ "desire_state",
+ "desire_pred",
+)
+
+CURRENT_FRAME_OUTPUT_KEYS = (
+ "pose",
+ "pose_stds",
+ "wide_from_device_euler",
+ "wide_from_device_euler_stds",
+ "road_transform",
+ "road_transform_stds",
+)
+
+LATERAL_PLAN_COLUMNS = (1, 4, 7, 11, 14)
+
+
+def parse_model_version(version: Any) -> int | None:
+ text = str(version or "").strip().lower()
+ if not text.startswith("v") or not text[1:].isdigit():
+ return None
+ return int(text[1:])
+
+
+def model_lab_version_supported(version: Any) -> bool:
+ parsed = parse_model_version(version)
+ return parsed is not None and parsed >= MODEL_LAB_MIN_MODEL_VERSION
+
+
+def is_small_model_metadata(metadata: dict[str, Any] | None) -> bool:
+ metadata = metadata if isinstance(metadata, dict) else {}
+ if bool(metadata.get("uses_external_gpu", False)):
+ return False
+ size_class = str(metadata.get("model_size") or metadata.get("size_class") or "").strip().lower()
+ if size_class:
+ return size_class in {"small", "standard", "on_device", "on-device"}
+
+ return True
+
+
+def model_lab_manifest_eligible(metadata: dict[str, Any] | None, version: Any) -> bool:
+ metadata = metadata if isinstance(metadata, dict) else {}
+ explicit = metadata.get("model_lab_eligible")
+ if explicit is not None and not bool(explicit):
+ return False
+ return is_small_model_metadata(metadata) and model_lab_version_supported(version)
+
+
+def normalize_model_lab_config(value: Any) -> dict[str, Any]:
+ if isinstance(value, bytes):
+ value = value.decode("utf-8", errors="ignore")
+ if isinstance(value, str):
+ try:
+ value = json.loads(value) if value.strip() else {}
+ except (TypeError, ValueError):
+ value = {}
+ if not isinstance(value, dict):
+ value = {}
+
+ return {
+ "enabled": bool(value.get("enabled", False)),
+ "lateralModel": str(value.get("lateralModel") or "").strip(),
+ "longitudinalModel": str(value.get("longitudinalModel") or "").strip(),
+ }
+
+
+def load_model_lab_config(params) -> dict[str, Any]:
+ try:
+ return normalize_model_lab_config(params.get(MODEL_LAB_CONFIG_PARAM))
+ except Exception:
+ return normalize_model_lab_config(None)
+
+
+def validate_model_lab_selection(
+ config: Any,
+ catalog: dict[str, dict[str, Any]],
+ *,
+ chestnut_ready: bool,
+ require_installed: bool = True,
+) -> str | None:
+ normalized = normalize_model_lab_config(config)
+ if not normalized["enabled"]:
+ return None
+ if not chestnut_ready:
+ return "Chestnut is not connected and firmware-ready."
+
+ lateral_id = normalized["lateralModel"]
+ longitudinal_id = normalized["longitudinalModel"]
+ if not lateral_id or not longitudinal_id:
+ return "Choose both a lateral and a longitudinal model."
+ if lateral_id == longitudinal_id:
+ return "Choose two different small models."
+
+ for role, model_id in (("Lateral", lateral_id), ("Longitudinal", longitudinal_id)):
+ model = catalog.get(model_id)
+ if model is None:
+ return f"{role} model '{model_id}' is not in the current manifest."
+ if not bool(model.get("small", False)):
+ return f"{role} model '{model_id}' is Chestnut-class, not a small model."
+ if not bool(model.get("modelLabEligible", False)):
+ return f"{role} model '{model_id}' is not compatible with Model Laboratory."
+ if not bool(model.get("modelLabArtifactAvailable", False)):
+ return f"{role} model '{model_id}' has no precompiled AMD artifact in the current manifest."
+ if require_installed and not bool(model.get("modelLabArtifactInstalled", False)):
+ return f"{role} model '{model_id}' has not downloaded its precompiled AMD artifact."
+
+ lateral_version = str(catalog[lateral_id].get("version") or "").strip()
+ longitudinal_version = str(catalog[longitudinal_id].get("version") or "").strip()
+ if lateral_version != longitudinal_version:
+ return "Choose models from the same behavior version; the longitudinal planner currently has one shared version contract."
+ return None
+
+
+def _merge_plan_tensor(lateral: np.ndarray, longitudinal: np.ndarray) -> np.ndarray:
+ if lateral.shape != longitudinal.shape or lateral.ndim < 2 or lateral.shape[-1] < 15:
+ raise ValueError(
+ f"Model Laboratory plan tensors are incompatible: lateral={lateral.shape}, longitudinal={longitudinal.shape}"
+ )
+ merged = longitudinal.copy()
+ merged[..., LATERAL_PLAN_COLUMNS] = lateral[..., LATERAL_PLAN_COLUMNS]
+ return merged
+
+
+def _merge_action_tensor(lateral: np.ndarray, longitudinal: np.ndarray) -> np.ndarray:
+ if lateral.shape != longitudinal.shape or lateral.ndim < 1 or lateral.shape[-1] < 2:
+ raise ValueError(
+ f"Model Laboratory action tensors are incompatible: lateral={lateral.shape}, longitudinal={longitudinal.shape}"
+ )
+ merged = longitudinal.copy()
+ merged[..., 0] = lateral[..., 0]
+ return merged
+
+
+def compose_model_outputs(
+ lateral_output: dict[str, np.ndarray],
+ longitudinal_output: dict[str, np.ndarray],
+ current_frame_output: dict[str, np.ndarray] | None = None,
+) -> dict[str, np.ndarray]:
+ """Compose normalized model outputs without mutating either runner's state."""
+ if "plan" not in lateral_output or "plan" not in longitudinal_output:
+ raise ValueError("Model Laboratory requires a plan output from both models.")
+
+ composed = dict(longitudinal_output)
+ composed["plan"] = _merge_plan_tensor(lateral_output["plan"], longitudinal_output["plan"])
+
+ if ("plan_stds" in lateral_output) != ("plan_stds" in longitudinal_output):
+ raise ValueError("Model Laboratory requires matching plan uncertainty outputs.")
+ if "plan_stds" in lateral_output and "plan_stds" in longitudinal_output:
+ composed["plan_stds"] = _merge_plan_tensor(lateral_output["plan_stds"], longitudinal_output["plan_stds"])
+ else:
+ composed.pop("plan_stds", None)
+
+ if ("action" in lateral_output) != ("action" in longitudinal_output):
+ raise ValueError("Model Laboratory requires matching action outputs.")
+ if "action" in lateral_output and "action" in longitudinal_output:
+ composed["action"] = _merge_action_tensor(lateral_output["action"], longitudinal_output["action"])
+ else:
+ composed.pop("action", None)
+ if ("action_stds" in lateral_output) != ("action_stds" in longitudinal_output):
+ raise ValueError("Model Laboratory requires matching action uncertainty outputs.")
+ if "action_stds" in lateral_output and "action_stds" in longitudinal_output:
+ composed["action_stds"] = _merge_action_tensor(lateral_output["action_stds"], longitudinal_output["action_stds"])
+ else:
+ composed.pop("action_stds", None)
+
+ for key in LATERAL_OUTPUT_KEYS:
+ if key in lateral_output:
+ composed[key] = lateral_output[key]
+ else:
+ composed.pop(key, None)
+ current_frame_output = lateral_output if current_frame_output is None else current_frame_output
+ for key in CURRENT_FRAME_OUTPUT_KEYS:
+ if key in current_frame_output:
+ composed[key] = current_frame_output[key]
+ else:
+ composed.pop(key, None)
+ return composed
+
+
+def hybrid_action_values(lateral_action: Any, longitudinal_action: Any) -> dict[str, Any]:
+ return {
+ "desiredCurvature": float(lateral_action.desiredCurvature),
+ "desiredAcceleration": float(longitudinal_action.desiredAcceleration),
+ "shouldStop": bool(longitudinal_action.shouldStop),
+ }
diff --git a/starpilot/common/param_profiles.py b/starpilot/common/param_profiles.py
new file mode 100644
index 000000000..0a60da91e
--- /dev/null
+++ b/starpilot/common/param_profiles.py
@@ -0,0 +1,245 @@
+from __future__ import annotations
+
+import base64
+import json
+import math
+import threading
+from datetime import UTC, datetime
+from pathlib import Path
+
+from openpilot.common.params import ParamKeyFlag, ParamKeyType
+
+
+PROFILE_FORMAT = "starpilot-params-profile"
+PROFILE_VERSION = 1
+PROFILE_MAX_BYTES = 2_000_000
+DEFAULT_PROFILE_ROOT = Path("/data/toggle_backups")
+PROFILE_SLOTS = {
+ "a": "Profile Slot A",
+ "b": "Profile Slot B",
+}
+PROFILE_NO_DEFAULT_KEYS = {
+ "AdbEnabled",
+ "AlphaLongitudinalEnabled",
+ "AlwaysOnDM",
+ "ExperimentalMode",
+ "ExperimentalModeConfirmed",
+ "IsLdwEnabled",
+ "IsMetric",
+ "IsRHD",
+ "IsRHDOverride",
+ "RecordAudio",
+ "RecordFront",
+ "SshEnabled",
+}
+
+_PROFILE_LOCK = threading.Lock()
+
+
+class ParamProfileError(ValueError):
+ pass
+
+
+def _normalize_slot(slot: str) -> str:
+ normalized = str(slot or "").strip().lower()
+ if normalized not in PROFILE_SLOTS:
+ raise ParamProfileError("Unknown settings profile slot.")
+ return normalized
+
+
+def _profile_path(slot: str, profile_root: Path | None = None) -> Path:
+ normalized = _normalize_slot(slot)
+ root = Path(profile_root) if profile_root is not None else DEFAULT_PROFILE_ROOT
+ return root / f".params-profile-{normalized}.json"
+
+
+def _key_text(raw_key) -> str:
+ return raw_key.decode("utf-8") if isinstance(raw_key, bytes) else str(raw_key)
+
+
+def eligible_profile_keys(params, *, excluded_keys: set[str] | None = None) -> set[str]:
+ excluded = excluded_keys or set()
+ keys = set()
+ for raw_key in params.all_keys():
+ key = _key_text(raw_key)
+ if key in excluded:
+ continue
+
+ try:
+ flags = params.get_key_flag(raw_key)
+ default_value = params.get_default_value(raw_key)
+ except Exception:
+ continue
+
+ if not flags & ParamKeyFlag.PERSISTENT or flags & ParamKeyFlag.DONT_LOG:
+ continue
+ if default_value is None and key not in PROFILE_NO_DEFAULT_KEYS:
+ continue
+ keys.add(key)
+ return keys
+
+
+def _get_current_value(params, key: str):
+ try:
+ return params.get(key, return_default=True)
+ except TypeError:
+ return params.get(key)
+
+
+def _serialize_value(value_type: ParamKeyType, value):
+ if value_type == ParamKeyType.BYTES:
+ raw_value = value if isinstance(value, bytes) else str(value).encode("utf-8")
+ return base64.b64encode(raw_value).decode("ascii")
+ if value_type == ParamKeyType.TIME:
+ return value.isoformat() if isinstance(value, datetime) else str(value)
+ if isinstance(value, tuple):
+ return list(value)
+ if isinstance(value, float) and not math.isfinite(value):
+ raise ValueError("non-finite numeric value")
+ return value
+
+
+def _deserialize_value(value_type: ParamKeyType, value):
+ if value_type == ParamKeyType.BYTES:
+ if not isinstance(value, str):
+ raise ValueError("invalid bytes value")
+ return base64.b64decode(value.encode("ascii"), validate=True)
+ if value_type == ParamKeyType.TIME:
+ if not isinstance(value, str):
+ raise ValueError("invalid time value")
+ return datetime.fromisoformat(value)
+ return value
+
+
+def _build_profile_payload(params, slot: str, allowed_keys: set[str] | None = None) -> dict:
+ normalized = _normalize_slot(slot)
+ keys = eligible_profile_keys(params) if allowed_keys is None else set(allowed_keys)
+ settings = {}
+ for key in sorted(keys):
+ try:
+ value = _get_current_value(params, key)
+ if value is None:
+ continue
+ value_type = ParamKeyType(params.get_type(key))
+ serialized_value = _serialize_value(value_type, value)
+ json.dumps(serialized_value, allow_nan=False)
+ settings[key] = {
+ "type": int(value_type),
+ "value": serialized_value,
+ }
+ except (TypeError, ValueError, OverflowError):
+ continue
+
+ if not settings:
+ raise ParamProfileError("No compatible settings were available to save.")
+
+ return {
+ "format": PROFILE_FORMAT,
+ "version": PROFILE_VERSION,
+ "slot": normalized,
+ "createdAt": datetime.now(UTC).isoformat(),
+ "settingsCount": len(settings),
+ "settings": settings,
+ }
+
+
+def save_profile(params, slot: str, *, allowed_keys: set[str] | None = None, profile_root: Path | None = None) -> dict:
+ normalized = _normalize_slot(slot)
+ payload = _build_profile_payload(params, normalized, allowed_keys)
+ encoded = json.dumps(payload, indent=2, allow_nan=False).encode("utf-8")
+ if len(encoded) > PROFILE_MAX_BYTES:
+ raise ParamProfileError("The settings profile is too large to save.")
+
+ path = _profile_path(normalized, profile_root)
+ temp_path = path.with_suffix(".tmp")
+ with _PROFILE_LOCK:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ temp_path.write_bytes(encoded)
+ temp_path.chmod(0o600)
+ temp_path.replace(path)
+ return profile_status(normalized, profile_root=profile_root)
+
+
+def _read_profile(slot: str, profile_root: Path | None = None) -> dict:
+ normalized = _normalize_slot(slot)
+ path = _profile_path(normalized, profile_root)
+ if not path.is_file():
+ raise ParamProfileError(f"{PROFILE_SLOTS[normalized]} has not been saved yet.")
+ if path.stat().st_size > PROFILE_MAX_BYTES:
+ raise ParamProfileError("The saved settings profile is too large.")
+
+ try:
+ payload = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, UnicodeError, json.JSONDecodeError) as error:
+ raise ParamProfileError("The saved settings profile is damaged.") from error
+
+ if not isinstance(payload, dict) or payload.get("format") != PROFILE_FORMAT:
+ raise ParamProfileError("The saved settings profile is invalid.")
+ version = payload.get("version")
+ if not isinstance(version, int) or version > PROFILE_VERSION:
+ raise ParamProfileError("The saved settings profile requires a newer StarPilot version.")
+ if payload.get("slot") != normalized or not isinstance(payload.get("settings"), dict):
+ raise ParamProfileError("The saved settings profile is invalid.")
+ return payload
+
+
+def load_profile(params, slot: str, *, allowed_keys: set[str] | None = None, profile_root: Path | None = None,
+ legacy_renames: dict[str, str] | None = None) -> dict:
+ normalized = _normalize_slot(slot)
+ with _PROFILE_LOCK:
+ payload = _read_profile(normalized, profile_root)
+ keys = eligible_profile_keys(params) if allowed_keys is None else set(allowed_keys)
+ renames = legacy_renames or {}
+ restored_count = 0
+ skipped_count = 0
+ for saved_key, entry in payload["settings"].items():
+ key = renames.get(saved_key, saved_key)
+ if not isinstance(key, str) or key not in keys or not isinstance(entry, dict):
+ skipped_count += 1
+ continue
+ try:
+ current_type = ParamKeyType(params.get_type(key))
+ saved_type = ParamKeyType(entry.get("type"))
+ if saved_type != current_type or "value" not in entry:
+ raise ValueError("setting type changed")
+ params.put(key, _deserialize_value(current_type, entry["value"]))
+ restored_count += 1
+ except (KeyError, TypeError, ValueError, OverflowError):
+ skipped_count += 1
+
+ if restored_count == 0:
+ raise ParamProfileError("No compatible settings were found in this profile.")
+ return {
+ "slot": normalized,
+ "label": PROFILE_SLOTS[normalized],
+ "restoredCount": restored_count,
+ "skippedCount": skipped_count,
+ }
+
+
+def profile_status(slot: str, *, profile_root: Path | None = None) -> dict:
+ normalized = _normalize_slot(slot)
+ status = {
+ "slot": normalized,
+ "label": PROFILE_SLOTS[normalized],
+ "saved": False,
+ "createdAt": None,
+ "settingsCount": 0,
+ }
+ path = _profile_path(normalized, profile_root)
+ if not path.is_file():
+ return status
+ try:
+ payload = _read_profile(normalized, profile_root)
+ except ParamProfileError:
+ return {**status, "saved": True, "invalid": True}
+ return {
+ **status,
+ "saved": True,
+ "createdAt": payload.get("createdAt"),
+ "settingsCount": len(payload["settings"]),
+ }
+
+
+def list_profiles(*, profile_root: Path | None = None) -> list[dict]:
+ return [profile_status(slot, profile_root=profile_root) for slot in PROFILE_SLOTS]
diff --git a/starpilot/common/safe_mode.py b/starpilot/common/safe_mode.py
index 062db2353..f96e72ceb 100644
--- a/starpilot/common/safe_mode.py
+++ b/starpilot/common/safe_mode.py
@@ -22,6 +22,7 @@ SAFE_MODE_MANAGED_KEYS = (
"DrivingModelName",
"ModelVersion",
"DrivingModelVersion",
+ "ModelLabConfig",
"ModelRandomizer",
"LatSmoothSeconds",
"LongSmoothSeconds",
@@ -198,7 +199,6 @@ SAFE_MODE_MANAGED_KEYS = (
"SubaruSNG",
"SubaruSNGManualParkingBrake",
"SubaruStopStartOff",
- "SubaruAvhOnAtStartup",
"VoltSNG",
"JeepBrakeHold",
"GMAutoHold",
@@ -217,7 +217,6 @@ SAFE_MODE_FIXED_VALUES = {
"LongitudinalPersonality": int(log.LongitudinalPersonality.relaxed),
"UseAutoSteerDelay": True,
"SubaruStopStartOff": False,
- "SubaruAvhOnAtStartup": False,
}
SAFE_MODE_STOCK_PARAM_MAP = {
diff --git a/starpilot/common/starpilot_variables.py b/starpilot/common/starpilot_variables.py
index c98f0d896..b72dbd11e 100644
--- a/starpilot/common/starpilot_variables.py
+++ b/starpilot/common/starpilot_variables.py
@@ -20,7 +20,7 @@ from opendbc.car.gm.values import CAR as GM_CAR, EV_CAR as GM_EV_CAR, GMFlags
from opendbc.car.hyundai.values import CAR as HYUNDAI_CAR, EV_CAR as HYUNDAI_EV_CAR, HyundaiFlags, HyundaiStarPilotSafetyFlags
from opendbc.car.interfaces import TORQUE_SUBSTITUTE_PATH, CarInterfaceBase, GearShifter
from opendbc.car.mock.values import CAR as MOCK
-from opendbc.car.subaru.values import SUBARU_AVH_CARS, SUBARU_STOP_START_CARS, SubaruFlags
+from opendbc.car.subaru.values import SUBARU_STOP_START_CARS, SubaruFlags
from opendbc.car.tesla.values import CAR as TESLA_CAR
from opendbc.car.toyota.values import CAR as TOYOTA_CAR, ToyotaStarPilotFlags
from openpilot.common.basedir import BASEDIR
@@ -1545,10 +1545,6 @@ class StarPilotVariables:
toggle.subaru_stop_start_off = self.get_value(
"SubaruStopStartOff", condition=toggle.car_model in SUBARU_STOP_START_CARS,
)
- toggle.subaru_avh_on = self.get_value(
- "SubaruAvhOnAtStartup", condition=toggle.car_model in SUBARU_AVH_CARS,
- )
-
toggle.jeep_brake_hold = self.get_value(
"JeepBrakeHold",
condition=toggle.car_make == "chrysler" and toggle.car_model in CHRYSLER_JEEPS,
diff --git a/starpilot/common/tests/test_model_lab.py b/starpilot/common/tests/test_model_lab.py
new file mode 100644
index 000000000..48ab94e49
--- /dev/null
+++ b/starpilot/common/tests/test_model_lab.py
@@ -0,0 +1,160 @@
+from types import SimpleNamespace
+
+import numpy as np
+import pytest
+
+from openpilot.starpilot.common.model_lab import (
+ LATERAL_PLAN_COLUMNS,
+ compose_model_outputs,
+ hybrid_action_values,
+ is_small_model_metadata,
+ model_lab_manifest_eligible,
+ normalize_model_lab_config,
+ validate_model_lab_selection,
+)
+
+
+def _catalog_model(version="v15", *, small=True, eligible=True, artifact_available=True, artifact_installed=True):
+ return {
+ "version": version,
+ "small": small,
+ "modelLabEligible": eligible,
+ "modelLabArtifactAvailable": artifact_available,
+ "modelLabArtifactInstalled": artifact_installed,
+ }
+
+
+def test_manifest_eligibility_uses_explicit_size_and_legacy_gpu_inference():
+ assert not is_small_model_metadata({"model_size": "small", "uses_external_gpu": True})
+ assert not is_small_model_metadata({"model_size": "chestnut", "uses_external_gpu": False})
+ assert is_small_model_metadata({"uses_external_gpu": False})
+ assert not is_small_model_metadata({"uses_external_gpu": True})
+ assert model_lab_manifest_eligible({"uses_external_gpu": False}, "v15")
+ assert not model_lab_manifest_eligible({"uses_external_gpu": True}, "v16")
+ assert not model_lab_manifest_eligible({"model_lab_eligible": False}, "v15")
+ assert not model_lab_manifest_eligible({"uses_external_gpu": False}, "v7")
+
+
+def test_config_normalization_is_closed_by_default():
+ assert normalize_model_lab_config("not-json") == {
+ "enabled": False,
+ "lateralModel": "",
+ "longitudinalModel": "",
+ }
+ assert normalize_model_lab_config('{"enabled": true, "lateralModel": " lat ", "longitudinalModel": "long"}') == {
+ "enabled": True,
+ "lateralModel": "lat",
+ "longitudinalModel": "long",
+ }
+
+
+@pytest.mark.parametrize(
+ ("chestnut_ready", "catalog", "lateral", "longitudinal", "expected"),
+ [
+ (False, {"lat": _catalog_model(), "long": _catalog_model()}, "lat", "long", "Chestnut"),
+ (True, {"lat": _catalog_model()}, "lat", "missing", "current manifest"),
+ (True, {"lat": _catalog_model(small=False), "long": _catalog_model()}, "lat", "long", "Chestnut-class"),
+ (True, {"lat": _catalog_model(artifact_available=False), "long": _catalog_model()}, "lat", "long", "no precompiled AMD"),
+ (True, {"lat": _catalog_model(artifact_installed=False), "long": _catalog_model()}, "lat", "long", "not downloaded"),
+ (True, {"lat": _catalog_model("v15"), "long": _catalog_model("v9")}, "lat", "long", "same behavior version"),
+ ],
+)
+def test_selection_validation_rejects_unsafe_pairs(chestnut_ready, catalog, lateral, longitudinal, expected):
+ error = validate_model_lab_selection(
+ {"enabled": True, "lateralModel": lateral, "longitudinalModel": longitudinal},
+ catalog,
+ chestnut_ready=chestnut_ready,
+ )
+ assert expected in error
+
+
+def test_selection_validation_accepts_distinct_ready_small_same_version_models():
+ catalog = {"lat": _catalog_model(), "long": _catalog_model()}
+ assert validate_model_lab_selection(
+ {"enabled": True, "lateralModel": "lat", "longitudinalModel": "long"},
+ catalog,
+ chestnut_ready=True,
+ ) is None
+ assert validate_model_lab_selection({"enabled": False}, {}, chestnut_ready=False) is None
+
+
+def test_composition_assigns_lateral_and_longitudinal_outputs_without_mutation():
+ lateral_plan = np.full((1, 33, 15), 11.0, dtype=np.float32)
+ longitudinal_plan = np.full((1, 33, 15), 22.0, dtype=np.float32)
+ lateral_action = np.array([[1.5, 2.5]], dtype=np.float32)
+ longitudinal_action = np.array([[3.5, 4.5]], dtype=np.float32)
+ lateral = {
+ "plan": lateral_plan,
+ "plan_stds": lateral_plan + 1,
+ "action": lateral_action,
+ "action_stds": lateral_action + 1,
+ "lane_lines": np.array([111.0]),
+ "pose": np.array([113.0]),
+ "lead": np.array([112.0]),
+ }
+ longitudinal = {
+ "plan": longitudinal_plan,
+ "plan_stds": longitudinal_plan + 2,
+ "action": longitudinal_action,
+ "action_stds": longitudinal_action + 2,
+ "lane_lines": np.array([221.0]),
+ "pose": np.array([223.0]),
+ "lead": np.array([222.0]),
+ }
+
+ composed = compose_model_outputs(lateral, longitudinal)
+
+ lateral_columns = set(LATERAL_PLAN_COLUMNS)
+ for column in range(15):
+ expected = 11.0 if column in lateral_columns else 22.0
+ np.testing.assert_array_equal(composed["plan"][..., column], expected)
+ assert composed["action"][0, 0] == lateral_action[0, 0]
+ assert composed["action"][0, 1] == longitudinal_action[0, 1]
+ assert composed["lane_lines"] is lateral["lane_lines"]
+ assert composed["pose"] is lateral["pose"]
+ assert composed["lead"] is longitudinal["lead"]
+ np.testing.assert_array_equal(lateral_plan, 11.0)
+ np.testing.assert_array_equal(longitudinal_plan, 22.0)
+
+ composed_on_longitudinal_frame = compose_model_outputs(lateral, longitudinal, longitudinal)
+ assert composed_on_longitudinal_frame["pose"] is longitudinal["pose"]
+
+
+def test_composition_fails_closed_for_incompatible_plan_contracts():
+ with pytest.raises(ValueError, match="incompatible"):
+ compose_model_outputs(
+ {"plan": np.zeros((1, 33, 15))},
+ {"plan": np.zeros((1, 32, 15))},
+ )
+
+
+def test_composition_does_not_leak_longitudinal_values_into_lateral_only_fields():
+ composed = compose_model_outputs(
+ {"plan": np.zeros((1, 33, 15))},
+ {
+ "plan": np.zeros((1, 33, 15)),
+ "desired_curvature": np.ones((1, 1)),
+ "lane_lines": np.ones((1, 4, 33, 2)),
+ },
+ )
+ assert "action" not in composed
+ assert "desired_curvature" not in composed
+ assert "lane_lines" not in composed
+
+
+def test_composition_rejects_asymmetric_action_contracts():
+ with pytest.raises(ValueError, match="matching action outputs"):
+ compose_model_outputs(
+ {"plan": np.zeros((1, 33, 15))},
+ {"plan": np.zeros((1, 33, 15)), "action": np.ones((1, 2))},
+ )
+
+
+def test_hybrid_action_uses_only_the_assigned_responsibility():
+ lateral = SimpleNamespace(desiredCurvature=0.125, desiredAcceleration=99, shouldStop=True)
+ longitudinal = SimpleNamespace(desiredCurvature=88, desiredAcceleration=-0.75, shouldStop=False)
+ assert hybrid_action_values(lateral, longitudinal) == {
+ "desiredCurvature": 0.125,
+ "desiredAcceleration": -0.75,
+ "shouldStop": False,
+ }
diff --git a/starpilot/common/tests/test_param_profiles.py b/starpilot/common/tests/test_param_profiles.py
new file mode 100644
index 000000000..e4b78aac7
--- /dev/null
+++ b/starpilot/common/tests/test_param_profiles.py
@@ -0,0 +1,86 @@
+import json
+
+import pytest
+
+from openpilot.common.params import ParamKeyFlag, ParamKeyType
+from openpilot.starpilot.common import param_profiles
+
+
+class FakeParams:
+ def __init__(self):
+ persistent = ParamKeyFlag.PERSISTENT
+ self.definitions = {
+ "BooleanSetting": (True, ParamKeyType.BOOL, persistent),
+ "NumericSetting": (1.5, ParamKeyType.FLOAT, persistent),
+ "JsonSetting": ({"mode": "default"}, ParamKeyType.JSON, persistent),
+ "SecretSetting": ("", ParamKeyType.STRING, persistent | ParamKeyFlag.DONT_LOG),
+ "TransientSetting": (False, ParamKeyType.BOOL, ParamKeyFlag.CLEAR_ON_MANAGER_START),
+ }
+ self.values = {
+ "BooleanSetting": False,
+ "NumericSetting": 2.75,
+ "JsonSetting": {"mode": "custom"},
+ "SecretSetting": "secret",
+ "TransientSetting": True,
+ }
+
+ def all_keys(self):
+ return list(self.definitions)
+
+ def get(self, key, return_default=False):
+ default = self.definitions[key][0] if return_default else None
+ return self.values.get(key, default)
+
+ def get_default_value(self, key):
+ return self.definitions[key][0]
+
+ def get_key_flag(self, key):
+ return self.definitions[key][2]
+
+ def get_type(self, key):
+ return self.definitions[key][1]
+
+ def put(self, key, value):
+ self.values[key] = value
+
+
+def test_profile_slots_round_trip_only_eligible_settings(tmp_path):
+ params = FakeParams()
+
+ status = param_profiles.save_profile(params, "a", profile_root=tmp_path)
+ payload = json.loads((tmp_path / ".params-profile-a.json").read_text())
+
+ assert status["saved"] is True
+ assert status["settingsCount"] == 3
+ assert set(payload["settings"]) == {"BooleanSetting", "NumericSetting", "JsonSetting"}
+
+ params.values.update({
+ "BooleanSetting": True,
+ "NumericSetting": 9.0,
+ "JsonSetting": {"mode": "changed"},
+ "SecretSetting": "new-secret",
+ "TransientSetting": False,
+ })
+ result = param_profiles.load_profile(params, "a", profile_root=tmp_path)
+
+ assert result["restoredCount"] == 3
+ assert result["skippedCount"] == 0
+ assert params.values["BooleanSetting"] is False
+ assert params.values["NumericSetting"] == 2.75
+ assert params.values["JsonSetting"] == {"mode": "custom"}
+ assert params.values["SecretSetting"] == "new-secret"
+ assert params.values["TransientSetting"] is False
+
+
+def test_profile_slots_report_missing_and_damaged_profiles(tmp_path):
+ params = FakeParams()
+
+ with pytest.raises(param_profiles.ParamProfileError, match="has not been saved"):
+ param_profiles.load_profile(params, "b", profile_root=tmp_path)
+ with pytest.raises(param_profiles.ParamProfileError, match="Unknown"):
+ param_profiles.save_profile(params, "c", profile_root=tmp_path)
+
+ (tmp_path / ".params-profile-b.json").write_text("not json")
+ assert param_profiles.profile_status("b", profile_root=tmp_path)["invalid"] is True
+ with pytest.raises(param_profiles.ParamProfileError, match="damaged"):
+ param_profiles.load_profile(params, "b", profile_root=tmp_path)
diff --git a/starpilot/starpilot_process.py b/starpilot/starpilot_process.py
index c44d68950..0db249cac 100644
--- a/starpilot/starpilot_process.py
+++ b/starpilot/starpilot_process.py
@@ -17,7 +17,12 @@ from openpilot.system.sentry import capture_flm_tune_submission, capture_report
from openpilot.system.athena.registration import UNREGISTERED_DONGLE_ID
from openpilot.system.hardware.hw import Paths
-from openpilot.starpilot.assets.model_manager import MODEL_DOWNLOAD_ALL_PARAM, MODEL_DOWNLOAD_PARAM, ModelManager
+from openpilot.starpilot.assets.model_manager import (
+ MODEL_DOWNLOAD_ALL_PARAM,
+ MODEL_DOWNLOAD_PARAM,
+ MODEL_LAB_DOWNLOAD_PARAM,
+ ModelManager,
+)
from openpilot.starpilot.assets.theme_manager import THEME_COMPONENT_PARAMS, ThemeManager
from openpilot.starpilot.common.starpilot_functions import update_maps, update_openpilot
from openpilot.starpilot.common.safe_mode import (
@@ -102,6 +107,12 @@ def check_assets(now, model_manager, theme_manager, thread_manager, params, para
model_to_download = model_to_download.decode("utf-8", errors="replace")
if model_to_download:
thread_manager.run_with_lock(model_manager.download_model, (model_to_download,))
+ else:
+ lab_model_to_download = params_memory.get(MODEL_LAB_DOWNLOAD_PARAM)
+ if isinstance(lab_model_to_download, bytes):
+ lab_model_to_download = lab_model_to_download.decode("utf-8", errors="replace")
+ if lab_model_to_download:
+ thread_manager.run_with_lock(model_manager.download_model_accelerator, (lab_model_to_download,))
for asset_type, asset_param in THEME_COMPONENT_PARAMS.items():
asset_to_download = params_memory.get(asset_param)
diff --git a/starpilot/system/the_galaxy/assets/components/router.js b/starpilot/system/the_galaxy/assets/components/router.js
index 8e92e4a64..e22adaf8d 100644
--- a/starpilot/system/the_galaxy/assets/components/router.js
+++ b/starpilot/system/the_galaxy/assets/components/router.js
@@ -21,6 +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=20260825a"
+import { ModelLaboratory } from "/assets/components/tools/model_laboratory.js?v=model-lab-1"
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"
@@ -87,6 +88,7 @@ function Root() {
createRoute("settings", "/settings/:section/:subsection?", SettingsView),
createRoute("speed_limits", "/download_speed_limits", SpeedLimits),
createRoute("model_manager", "/manage_models", ModelManager),
+ createRoute("model_laboratory", "/model_laboratory", ModelLaboratory),
createRoute("tuning", "/tuning", Tuning),
createRoute("lateral_maneuvers", "/lateral_maneuvers", Tuning),
createRoute("longitudinal_maneuvers", "/longitudinal_maneuvers", LongitudinalManeuvers),
diff --git a/starpilot/system/the_galaxy/assets/components/sidebar.js b/starpilot/system/the_galaxy/assets/components/sidebar.js
index f3e080769..53b53b105 100644
--- a/starpilot/system/the_galaxy/assets/components/sidebar.js
+++ b/starpilot/system/the_galaxy/assets/components/sidebar.js
@@ -23,6 +23,7 @@ const MENU_ITEMS = {
{ name: "Navigation", link: "/set_navigation_destination", icon: "bi-geo-alt-fill" },
{ name: "App Keys", link: "/manage_navigation_keys", icon: "bi-key-fill" },
{ name: "Model Manager", link: "/manage_models", icon: "bi-cpu" },
+ { name: "Model Laboratory", link: "/model_laboratory", icon: "bi-bezier2" },
{ name: "Plots", link: "/plots", icon: "bi-graph-up-arrow" },
{ name: "Testing Ground", link: "/testing_ground", icon: "bi-bezier2" },
{ name: "Troubleshoot", link: "/troubleshoot", icon: "bi-tools" },
diff --git a/starpilot/system/the_galaxy/assets/components/tools/device_settings.js b/starpilot/system/the_galaxy/assets/components/tools/device_settings.js
index bd58be84e..08840a7d5 100644
--- a/starpilot/system/the_galaxy/assets/components/tools/device_settings.js
+++ b/starpilot/system/the_galaxy/assets/components/tools/device_settings.js
@@ -40,7 +40,6 @@ const VEHICLE_SETTING_MAKES = {
SubaruSNG: ["Subaru"],
SubaruSNGManualParkingBrake: ["Subaru"],
SubaruStopStartOff: ["Subaru"],
- SubaruAvhOnAtStartup: ["Subaru"],
ClusterOffset: ["Lexus", "Toyota"],
SNGHack: ["Lexus", "Toyota"],
ToyotaAutoHold: ["Lexus", "Toyota"],
@@ -523,6 +522,10 @@ function numericBounds(param) {
return { min: 1, max: 101, step: 1 }
}
+ if (param.key === "LaneCenterOffset") {
+ return { min: -0.3, max: 0.3, step: 0.01 }
+ }
+
// Personality jerk params are stored as percentage-style integers (25..200).
// Layout metadata currently uses normalized 0.5..3.0 ranges, which breaks
// the +/- stepper and clamps values like 50 down to 3.
@@ -1053,6 +1056,18 @@ function stepNumericParam(param, direction) {
updateNumericParam(param, next)
}
+function canStepNumericParam(param, direction) {
+ const bounds = numericBounds(param)
+ const min = Number(bounds.min)
+ const max = Number(bounds.max)
+ const current = resolveCurrentNumericValue(param, bounds)
+ const precision = stepPrecision(bounds.step, param.precision)
+ const epsilon = Math.pow(10, -(precision + 2))
+
+ if (!Number.isFinite(min) || !Number.isFinite(max) || !Number.isFinite(current)) return false
+ return direction < 0 ? current > min + epsilon : current < max - epsilon
+}
+
function applyManualNumericParam(param) {
if (isNumericUpdating(param.key)) return
@@ -1613,8 +1628,6 @@ function renderSettingRow(p) {
const precision = stepPrecision(bounds.step, p.precision)
const epsilon = Math.pow(10, -(precision + 2))
const updating = isNumericUpdating(p.key)
- const canDecrease = !updating && currentNumeric > (Number(bounds.min) + epsilon)
- const canIncrease = !updating && currentNumeric < (Number(bounds.max) - epsilon)
const defaultNumeric = resolveDefaultNumericValue(p, bounds)
const defaultLabel = defaultNumeric !== null
? formatSliderValue(defaultNumeric, String(bounds.step), p.precision, p.key)
@@ -1625,7 +1638,7 @@ function renderSettingRow(p) {
stepNumericParam(p, -1)}">-
${formatSliderValue(bounds.min, String(bounds.step), p.precision, p.key)} to ${formatSliderValue(bounds.max, String(bounds.step), p.precision, p.key)}
@@ -1658,7 +1671,7 @@ function renderSettingRow(p) {
stepNumericParam(p, 1)}">+
`
})()}
diff --git a/starpilot/system/the_galaxy/assets/components/tools/model_laboratory.css b/starpilot/system/the_galaxy/assets/components/tools/model_laboratory.css
new file mode 100644
index 000000000..030380245
--- /dev/null
+++ b/starpilot/system/the_galaxy/assets/components/tools/model_laboratory.css
@@ -0,0 +1,268 @@
+.ml-wrapper {
+ color: var(--text-color);
+ max-width: 1120px;
+ padding: 0 var(--padding-base) var(--padding-base) 0;
+}
+
+.ml-hero,
+.ml-card-heading,
+.ml-actions,
+.ml-chips,
+.ml-preview {
+ align-items: center;
+ display: flex;
+ flex-wrap: wrap;
+}
+
+.ml-hero {
+ gap: var(--gap-lg);
+ justify-content: space-between;
+ margin-bottom: var(--margin-base);
+}
+
+.ml-hero h2,
+.ml-card h3 {
+ margin: 0;
+}
+
+.ml-hero p,
+.ml-card-heading p {
+ color: var(--text-muted);
+ margin: 0.3rem 0 0;
+}
+
+.ml-kicker {
+ color: var(--accent-color);
+ font-size: 0.75rem;
+ font-weight: 700;
+ letter-spacing: 0.12em;
+ margin-bottom: 0.2rem;
+ text-transform: uppercase;
+}
+
+.ml-card {
+ background: var(--card-bg);
+ border: 1px solid var(--sidebar-border-color);
+ border-radius: var(--border-radius-lg);
+ margin-bottom: var(--margin-base);
+ padding: var(--padding-base);
+}
+
+.ml-card-heading {
+ gap: var(--gap-md);
+ justify-content: space-between;
+ margin-bottom: var(--margin-base);
+}
+
+.ml-chip,
+.ml-state {
+ background: var(--secondary-bg);
+ border: 1px solid var(--sidebar-border-color);
+ border-radius: 999px;
+ color: var(--text-muted);
+ display: inline-flex;
+ font-size: 0.78rem;
+ padding: 0.2rem 0.55rem;
+}
+
+.ml-chips {
+ gap: var(--gap-xs);
+}
+
+.ml-chip-good,
+.ml-state.is-enabled {
+ background: rgba(72, 187, 120, 0.16);
+ border-color: rgba(72, 187, 120, 0.5);
+ color: var(--success-bg);
+}
+
+.ml-chip-warning {
+ background: rgba(224, 85, 119, 0.12);
+ border-color: rgba(224, 85, 119, 0.45);
+ color: var(--danger-fg);
+}
+
+.ml-pair {
+ align-items: end;
+ display: grid;
+ gap: var(--gap-md);
+ grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
+}
+
+.ml-pair label {
+ display: grid;
+ gap: 0.35rem;
+}
+
+.ml-pair label > span {
+ font-weight: 700;
+}
+
+.ml-pair small,
+.ml-muted {
+ color: var(--text-muted);
+}
+
+.ml-select {
+ background: var(--input-bg);
+ border: 1px solid var(--sidebar-border-color);
+ border-radius: var(--border-radius-base);
+ color: var(--text-color);
+ font-size: var(--font-size-base);
+ min-height: 2.7rem;
+ padding: 0.45rem 0.6rem;
+ width: 100%;
+}
+
+.ml-plus {
+ color: var(--accent-color);
+ font-size: 1.8rem;
+ line-height: 2.7rem;
+}
+
+.ml-preview {
+ background: var(--secondary-bg);
+ border-radius: var(--border-radius-base);
+ gap: var(--gap-sm);
+ margin-top: var(--margin-base);
+ padding: 0.7rem 0.8rem;
+}
+
+.ml-preview span {
+ color: var(--text-muted);
+}
+
+.ml-actions {
+ gap: var(--gap-sm);
+ margin-top: var(--margin-base);
+}
+
+.ml-button {
+ background: var(--sidebar-active-bg);
+ border: 0;
+ border-radius: var(--border-radius-base);
+ color: var(--text-color);
+ min-height: 2.4rem;
+ padding: 0.5rem 0.85rem;
+}
+
+.ml-button-primary {
+ background: var(--success-bg);
+ color: var(--color-black);
+}
+
+.ml-button:disabled {
+ cursor: not-allowed;
+ opacity: 0.55;
+}
+
+.ml-validation {
+ color: var(--danger-fg);
+ margin: 0.75rem 0 0;
+}
+
+.ml-alert {
+ border: 1px solid;
+ border-radius: var(--border-radius-base);
+ margin-bottom: var(--margin-base);
+ padding: 0.65rem 0.8rem;
+}
+
+.ml-alert-error {
+ background: rgba(224, 85, 119, 0.12);
+ border-color: rgba(224, 85, 119, 0.45);
+ color: var(--danger-fg);
+}
+
+.ml-alert-good {
+ background: rgba(72, 187, 120, 0.12);
+ border-color: rgba(72, 187, 120, 0.45);
+}
+
+.ml-runtime-grid {
+ display: grid;
+ gap: var(--gap-md);
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ margin-bottom: var(--margin-base);
+}
+
+.ml-runtime-grid > div {
+ background: var(--secondary-bg);
+ border-radius: var(--border-radius-base);
+ display: grid;
+ gap: 0.25rem;
+ padding: 0.7rem;
+}
+
+.ml-runtime-grid span {
+ color: var(--text-muted);
+ font-size: 0.78rem;
+}
+
+.ml-model-list {
+ border: 1px solid var(--sidebar-border-color);
+ border-radius: var(--border-radius-base);
+ max-height: 24rem;
+ overflow: auto;
+}
+
+.ml-model {
+ align-items: center;
+ border-bottom: 1px solid var(--sidebar-border-color);
+ display: flex;
+ gap: var(--gap-md);
+ justify-content: space-between;
+ padding: 0.65rem 0.75rem;
+}
+
+.ml-model:last-child {
+ border-bottom: 0;
+}
+
+.ml-note {
+ color: var(--text-muted);
+ margin-top: var(--margin-base);
+}
+
+.ml-note code {
+ color: var(--text-color);
+ overflow-wrap: anywhere;
+}
+
+.ml-findings {
+ display: grid;
+ gap: var(--gap-md);
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.ml-findings ul {
+ color: var(--text-muted);
+ margin-bottom: 0;
+ padding-left: 1.2rem;
+}
+
+.ml-findings li + li {
+ margin-top: 0.45rem;
+}
+
+@media (max-width: 760px) {
+ .ml-wrapper {
+ padding-right: 0;
+ }
+
+ .ml-pair,
+ .ml-runtime-grid,
+ .ml-findings {
+ grid-template-columns: 1fr;
+ }
+
+ .ml-plus {
+ line-height: 1;
+ text-align: center;
+ }
+
+ .ml-model {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+}
diff --git a/starpilot/system/the_galaxy/assets/components/tools/model_laboratory.js b/starpilot/system/the_galaxy/assets/components/tools/model_laboratory.js
new file mode 100644
index 000000000..8efb23c11
--- /dev/null
+++ b/starpilot/system/the_galaxy/assets/components/tools/model_laboratory.js
@@ -0,0 +1,384 @@
+import { html, reactive } from "/assets/vendor/arrow-core.js"
+
+const state = reactive({
+ loading: true,
+ saving: false,
+ error: "",
+ message: "",
+ chestnutReady: false,
+ isOnroad: false,
+ configuration: { enabled: false, lateralModel: "", longitudinalModel: "" },
+ runtime: {},
+ download: {},
+ models: [],
+ summary: {},
+ manifest: { version: "unknown", shortcomings: [], opportunities: [] },
+})
+
+let initialized = false
+let pollHandle = null
+
+function modelById(modelId) {
+ return state.models.find(model => model.value === modelId)
+}
+
+function modelLabel(modelId) {
+ return modelById(modelId)?.label || modelId || "not selected"
+}
+
+function readyModels() {
+ return state.models.filter(model => model.modelLabArtifactAvailable)
+}
+
+function candidateModels(role) {
+ const ready = readyModels()
+ if (role !== "longitudinal") return ready
+ const lateral = modelById(state.configuration.lateralModel)
+ if (!lateral) return ready
+ return ready.filter(model => model.value !== lateral.value && model.version === lateral.version)
+}
+
+function selectionError() {
+ if (!state.chestnutReady) return "Connect a firmware-ready Chestnut first."
+ if (state.isOnroad) return "Park before changing the laboratory 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.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."
+ }
+ if (lateral.version !== longitudinal.version) return "Both models must use the same behavior version."
+ return ""
+}
+
+function applyPayload(payload) {
+ state.chestnutReady = Boolean(payload?.chestnutReady)
+ state.isOnroad = Boolean(payload?.isOnroad)
+ state.configuration = {
+ enabled: Boolean(payload?.configuration?.enabled),
+ lateralModel: String(payload?.configuration?.lateralModel || ""),
+ longitudinalModel: String(payload?.configuration?.longitudinalModel || ""),
+ }
+ state.runtime = payload?.runtime && typeof payload.runtime === "object" ? payload.runtime : {}
+ 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
+ }
+ if (!modelById(state.configuration.longitudinalModel) && ready.length > 1) {
+ state.configuration.longitudinalModel = ready.find(model => (
+ model.value !== state.configuration.lateralModel &&
+ model.version === modelById(state.configuration.lateralModel)?.version
+ ))?.value || ""
+ }
+}
+
+async function requestJson(url, options = {}) {
+ const response = await fetch(url, { cache: "no-store", ...options })
+ let payload = {}
+ try {
+ payload = await response.json()
+ } catch {
+ }
+ if (!response.ok) throw new Error(payload.error || `Request failed (${response.status})`)
+ return payload
+}
+
+async function refresh() {
+ try {
+ applyPayload(await requestJson("/api/model-laboratory"))
+ } catch (error) {
+ state.error = error?.message || String(error)
+ } finally {
+ state.loading = false
+ setTimeout(bindControls, 0)
+ }
+}
+
+async function save(enabled) {
+ if (state.saving) return
+ if (enabled) {
+ const error = selectionError()
+ if (error) {
+ state.error = error
+ return
+ }
+ }
+
+ state.saving = true
+ state.error = ""
+ state.message = ""
+ try {
+ const payload = await requestJson("/api/model-laboratory", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ enabled,
+ lateralModel: state.configuration.lateralModel,
+ longitudinalModel: state.configuration.longitudinalModel,
+ }),
+ })
+ applyPayload(payload)
+ state.message = String(payload.message || "Model Laboratory configuration saved.")
+ } catch (error) {
+ state.error = error?.message || String(error)
+ } finally {
+ state.saving = false
+ }
+}
+
+async function prepareModel(modelId) {
+ if (state.saving || !modelId) return
+ state.saving = true
+ state.error = ""
+ state.message = ""
+ try {
+ const payload = await requestJson("/api/model-laboratory/download", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ model: modelId }),
+ })
+ state.message = String(payload.message || "Chestnut artifact download queued.")
+ await refresh()
+ } 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")
+ const enable = document.getElementById("ml-enable")
+ const disable = document.getElementById("ml-disable")
+ const refreshButton = document.getElementById("ml-refresh")
+ document.querySelectorAll("[data-ml-download]").forEach(button => {
+ if (button.dataset.bound === "1") return
+ button.dataset.bound = "1"
+ button.addEventListener("click", () => prepareModel(button.dataset.mlDownload))
+ })
+
+ if (lateral) {
+ lateral.value = state.configuration.lateralModel
+ if (lateral.dataset.bound !== "1") {
+ lateral.dataset.bound = "1"
+ lateral.addEventListener("change", event => {
+ state.configuration.lateralModel = event.target.value
+ const long = modelById(state.configuration.longitudinalModel)
+ const lat = modelById(event.target.value)
+ if (long && lat && (long.value === lat.value || long.version !== lat.version)) {
+ state.configuration.longitudinalModel = candidateModels("longitudinal")[0]?.value || ""
+ if (longitudinal) longitudinal.value = state.configuration.longitudinalModel
+ }
+ })
+ }
+ }
+ if (longitudinal) {
+ longitudinal.value = state.configuration.longitudinalModel
+ if (longitudinal.dataset.bound !== "1") {
+ longitudinal.dataset.bound = "1"
+ longitudinal.addEventListener("change", event => { state.configuration.longitudinalModel = event.target.value })
+ }
+ }
+ if (enable && enable.dataset.bound !== "1") {
+ enable.dataset.bound = "1"
+ enable.addEventListener("click", () => save(true))
+ }
+ if (disable && disable.dataset.bound !== "1") {
+ disable.dataset.bound = "1"
+ disable.addEventListener("click", () => save(false))
+ }
+ if (refreshButton && refreshButton.dataset.bound !== "1") {
+ refreshButton.dataset.bound = "1"
+ refreshButton.addEventListener("click", refresh)
+ }
+}
+
+function ensurePolling() {
+ if (pollHandle) return
+ const poll = async () => {
+ if (window.location.pathname !== "/model_laboratory") {
+ pollHandle = null
+ return
+ }
+ await refresh()
+ pollHandle = setTimeout(poll, 5000)
+ }
+ pollHandle = setTimeout(poll, 5000)
+}
+
+function renderModel(model) {
+ const artifactStatus = model.modelLabArtifactInstalled
+ ? "AMD ready"
+ : model.modelLabArtifactAvailable ? "AMD download needed" : "AMD not published"
+ return html`
+
+
+
${model.label}
+
${model.value} · ${model.series || "Unknown series"}
+
+
+ ${model.version || "unknown version"}
+ ${model.modelSize || "small"}
+
+ ${artifactStatus}
+
+ ${model.modelLabArtifactAvailable && !model.modelLabArtifactInstalled ? html`
+
+ Prepare for Chestnut
+
+ ` : ""}
+
+
+ `
+}
+
+export function ModelLaboratory() {
+ if (!initialized) {
+ initialized = true
+ refresh()
+ }
+ ensurePolling()
+ setTimeout(bindControls, 0)
+
+ return html`
+
+
+
+ ${() => state.error ? html`
${state.error}
` : ""}
+ ${() => state.message ? html`
${state.message}
` : ""}
+ ${() => state.loading ? html`
Loading laboratory status…
` : ""}
+
+ ${() => !state.loading ? html`
+
+
+
+
Compose a pair
+
Both precompiled small models stay resident and run every camera frame on Chestnut's AMD GPU.
+
+
+ ${() => state.configuration.enabled ? "Enabled" : "Disabled"}
+
+
+
+
+
+ Lateral model
+ Path shape, curvature, lane geometry, and driving desire
+
+ Choose a model
+ ${() => candidateModels("lateral").map(model => html`
+
+ ${model.label} · ${model.version}
+
+ `)}
+
+
+
+
+
+ Longitudinal model
+ Speed, acceleration, stopping, leads, and scene confidence
+
+ Choose a model
+ ${() => candidateModels("longitudinal").map(model => html`
+
+ ${model.label} · ${model.version}
+
+ `)}
+
+
+
+
+
+ ${() => modelLabel(state.configuration.lateralModel)}
+ steers
+
+ ${() => modelLabel(state.configuration.longitudinalModel)}
+ paces
+
+
+ ${() => selectionError() ? html`${selectionError()}
` : ""}
+
+
+ Enable for next drive
+
+
+ Disable
+
+ Refresh
+
+
+
+
+
+
+
Runtime
+
The configuration activates when modeld starts for a drive.
+
+
+ ${() => state.runtime?.active ? "Pair active" : state.runtime?.requested ? "Pair requested" : "Inactive"}
+
+
+
+
Lateral ${() => modelLabel(state.runtime?.lateralModel)}
+
Longitudinal ${() => modelLabel(state.runtime?.longitudinalModel)}
+
+ ${() => state.runtime?.error ? html`${state.runtime.error}
` : ""}
+ Both roles evaluate the same frame at 20 Hz. A runtime failure suppresses that frame and falls back to the built-in QCOM model.
+
+
+
+
+
+
Small-model readiness
+
${state.summary.ready || 0} AMD-ready · ${state.summary.published || 0} published · ${state.summary.eligible || 0} eligible small models.
+
+
Manifest ${state.manifest.version || "unknown"}
+
+ ${() => state.models.map(renderModel)}
+
+ Model Manager downloads the manifest's precompiled AMD variants. Nothing is compiled on the comma.
+ A normal installed model may still need its separate Chestnut artifact.
+
+
+
+
+
+ Manifest shortcomings
+ ${(state.manifest.shortcomings || []).map(item => html`${item} `)}
+
+
+ Opportunities
+ ${(state.manifest.opportunities || []).map(item => html`${item} `)}
+
+
+ ` : ""}
+
+ `
+}
diff --git a/starpilot/system/the_galaxy/assets/components/tools/toggles.css b/starpilot/system/the_galaxy/assets/components/tools/toggles.css
index dbe9c6335..6fe68610d 100644
--- a/starpilot/system/the_galaxy/assets/components/tools/toggles.css
+++ b/starpilot/system/the_galaxy/assets/components/tools/toggles.css
@@ -75,6 +75,46 @@
width: 100%;
}
+.toggle-profile-section {
+ border-top: 1px solid var(--track-color);
+ margin-top: var(--padding-lg);
+ padding-top: var(--padding-lg);
+ width: 100%;
+}
+
+.toggle-profile-row {
+ background: var(--input-bg);
+ border-radius: var(--border-radius-lg);
+ margin-top: var(--padding-sm);
+ padding: var(--padding-sm);
+}
+
+.toggle-profile-heading,
+.toggle-profile-actions {
+ align-items: center;
+ display: flex;
+ gap: var(--padding-sm);
+ justify-content: space-between;
+}
+
+.toggle-profile-heading span {
+ color: var(--text-color);
+ font-size: var(--font-size-sm);
+}
+
+.toggle-profile-actions {
+ margin-top: var(--padding-sm);
+}
+
+.toggle-profile-actions .toggle-control-button + .toggle-control-button {
+ margin-top: 0;
+}
+
+.toggle-profile-warning {
+ color: var(--danger-fg);
+ text-align: center;
+}
+
.toggle-control-wrapper {
display: flex;
justify-content: center;
diff --git a/starpilot/system/the_galaxy/assets/components/tools/toggles.js b/starpilot/system/the_galaxy/assets/components/tools/toggles.js
index 9d7b40b98..c29544bb1 100644
--- a/starpilot/system/the_galaxy/assets/components/tools/toggles.js
+++ b/starpilot/system/the_galaxy/assets/components/tools/toggles.js
@@ -11,6 +11,10 @@ const state = reactive({
factoryResetBusy: false,
routeDeleteBusy: false,
factoryResetStatus: null,
+ profiles: [],
+ profileBusy: "",
+ profileIsOnroad: false,
+ profileConfirm: null,
})
let initialized = false
@@ -98,6 +102,18 @@ async function fetchFactoryResetStatus() {
}
}
+async function fetchToggleProfiles() {
+ try {
+ const response = await fetch("/api/toggles/profiles", { cache: "no-store" })
+ const payload = await response.json().catch(() => ({}))
+ if (!response.ok) throw new Error(payload.message || "Failed to load settings profiles.")
+ state.profiles = Array.isArray(payload.slots) ? payload.slots : []
+ state.profileIsOnroad = !!payload.isOnroad
+ } catch (error) {
+ state.profiles = []
+ }
+}
+
async function restoreToggles(event) {
const uploadedFile = event.target.files[0]
if (!uploadedFile) return
@@ -155,6 +171,7 @@ function initialize() {
export function ToggleControl() {
initialize()
fetchFactoryResetStatus()
+ fetchToggleProfiles()
async function backupToggles() {
try {
@@ -197,6 +214,33 @@ export function ToggleControl() {
fileInput.click()
}
+ function confirmProfileAction(profile, action) {
+ if (state.profileBusy || state.profileIsOnroad) return
+ state.profileConfirm = { profile, action }
+ }
+
+ async function runProfileAction() {
+ const pending = state.profileConfirm
+ state.profileConfirm = null
+ if (!pending || state.profileBusy) return
+
+ const { profile, action } = pending
+ state.profileBusy = `${action}-${profile.slot}`
+ try {
+ const response = await fetch(`/api/toggles/profiles/${encodeURIComponent(profile.slot)}/${action}`, { method: "POST" })
+ const payload = await response.json().catch(() => ({}))
+ if (!response.ok || payload.success === false) {
+ throw new Error(payload.message || `Failed to ${action} settings profile.`)
+ }
+ showSnackbar(payload.message || `${profile.label} ${action === "save" ? "saved" : "loaded"}.`)
+ await fetchToggleProfiles()
+ } catch (error) {
+ showSnackbar(error?.message || `Failed to ${action} settings profile.`, "error")
+ } finally {
+ state.profileBusy = ""
+ }
+ }
+
function confirmSaveMe() {
state.showSaveMeModal = true;
}
@@ -266,6 +310,35 @@ export function ToggleControl() {
Backup Toggles
Restore Toggles
+
+
Settings Profiles
+
+ Keep two local configurations for different vehicles, drivers, or troubleshooting. Pairing and sensitive device data are not included.
+
+ ${() => state.profileIsOnroad ? html`
Park the vehicle to save or load a profile.
` : ""}
+ ${() => state.profiles.map(profile => html`
+
+
+ ${profile.label}
+ ${profile.invalid ? "Damaged" : profile.saved ? `${profile.settingsCount} settings` : "Empty"}
+
+
+ confirmProfileAction(profile, "save")}"
+ disabled="${() => !!state.profileBusy || state.profileIsOnroad}">
+ ${() => state.profileBusy === `save-${profile.slot}` ? "Saving..." : profile.saved ? "Overwrite" : "Save Current"}
+
+ confirmProfileAction(profile, "load")}"
+ disabled="${() => !!state.profileBusy || state.profileIsOnroad || !profile.saved || profile.invalid}">
+ ${() => state.profileBusy === `load-${profile.slot}` ? "Loading..." : "Load"}
+
+
+
+ `)}
+
@@ -337,6 +410,15 @@ export function ToggleControl() {
onConfirm: deleteAllRoutes,
onCancel: () => { state.showDeleteRoutesModal = false; },
confirmText: "Delete Routes"
+ }) : ""}
+ ${() => state.profileConfirm ? Modal({
+ title: `${state.profileConfirm.action === "save" ? (state.profileConfirm.profile.saved ? "Overwrite" : "Save") : "Load"} ${state.profileConfirm.profile.label}?`,
+ message: state.profileConfirm.action === "save"
+ ? "This stores the current persistent StarPilot settings in this local slot."
+ : "This applies every saved setting in the slot to the device.",
+ onConfirm: runProfileAction,
+ onCancel: () => { state.profileConfirm = null; },
+ confirmText: state.profileConfirm.action === "save" ? "Save Settings" : "Load Settings"
}) : ""}
`
}
diff --git a/starpilot/system/the_galaxy/assets/mobile/js/api.js b/starpilot/system/the_galaxy/assets/mobile/js/api.js
index 5bcb69b15..db4bbf89f 100644
--- a/starpilot/system/the_galaxy/assets/mobile/js/api.js
+++ b/starpilot/system/the_galaxy/assets/mobile/js/api.js
@@ -37,6 +37,10 @@ async function delOk(url) {
return (await fetch(url, { method: "DELETE" })).ok
}
+function postOk(url, opts = {}) {
+ return fetch(url, initFor({ ...opts, method: "POST" })).then((res) => res.ok)
+}
+
export const api = {
postAction(endpoint) { return request(endpoint, { method: "POST" }) },
getOptions(endpoint) { return request(endpoint) },
@@ -209,6 +213,10 @@ export const api = {
return data
},
+ getToggleProfiles() { return request("/api/toggles/profiles", { cache: "no-store" }) },
+ saveToggleProfile(slot) { return request(`/api/toggles/profiles/${encodeURIComponent(slot)}/save`, { method: "POST" }) },
+ loadToggleProfile(slot) { return request(`/api/toggles/profiles/${encodeURIComponent(slot)}/load`, { method: "POST" }) },
+
selectTestingGround(body) { return request("/api/testing_grounds/select", { method: "POST", data: body }) },
getSentryStatus() { return requestOk("/api/sentry/status", { cache: "no-store" }) },
diff --git a/starpilot/system/the_galaxy/assets/mobile/js/components/AppShell.js b/starpilot/system/the_galaxy/assets/mobile/js/components/AppShell.js
index 48d7fed9e..d7297c785 100644
--- a/starpilot/system/the_galaxy/assets/mobile/js/components/AppShell.js
+++ b/starpilot/system/the_galaxy/assets/mobile/js/components/AppShell.js
@@ -14,6 +14,9 @@ const NAV = {
{ name: "Navigation & Maps", link: "/navigation", icon: "bi-map" },
{ name: "Sentry Mode", link: "/sentry", icon: "bi-shield-exclamation" },
{ name: "System Tools", link: "/system", icon: "bi-arrow-repeat" },
+ { name: "Model Laboratory", link: "/model_laboratory", icon: "bi-bezier2" },
+ { name: "Plots", link: "/plots", icon: "bi-graph-up-arrow" },
+ { name: "Testing Ground", link: "/testing_ground", icon: "bi-bezier2" },
{ name: "Theme Maker", link: "/theme_maker", icon: "bi-palette-fill" },
{ name: "Tuning, Plots & Testing", link: "/tuning", icon: "bi-sign-turn-right" },
{ name: "Vehicle Controls", link: "/vehicle", icon: "bi-car-front" },
diff --git a/starpilot/system/the_galaxy/assets/mobile/js/params.js b/starpilot/system/the_galaxy/assets/mobile/js/params.js
index f0a0dcce6..ab0140d11 100644
--- a/starpilot/system/the_galaxy/assets/mobile/js/params.js
+++ b/starpilot/system/the_galaxy/assets/mobile/js/params.js
@@ -28,7 +28,6 @@ const VEHICLE_SETTING_MAKES = {
SubaruSNG: ["Subaru"],
SubaruSNGManualParkingBrake: ["Subaru"],
SubaruStopStartOff: ["Subaru"],
- SubaruAvhOnAtStartup: ["Subaru"],
ClusterOffset: ["Lexus", "Toyota"],
SNGHack: ["Lexus", "Toyota"],
ToyotaAutoHold: ["Lexus", "Toyota"],
@@ -101,6 +100,9 @@ export function numericBounds(param, values) {
if (param.key === "ScreenBrightness" || param.key === "ScreenBrightnessOnroad") {
return { min: 1, max: 101, step: 1 }
}
+ if (param.key === "LaneCenterOffset") {
+ return { min: -0.3, max: 0.3, step: 0.01 }
+ }
if (/^(Traffic|Aggressive|Standard|Relaxed)Jerk(Acceleration|Deceleration|Danger|SpeedDecrease|Speed)$/.test(String(param.key || ""))) {
return { min: 25, max: 200, step: 1 }
}
diff --git a/starpilot/system/the_galaxy/assets/mobile/js/views/SystemTools.js b/starpilot/system/the_galaxy/assets/mobile/js/views/SystemTools.js
index 4a335b678..b0595a02e 100644
--- a/starpilot/system/the_galaxy/assets/mobile/js/views/SystemTools.js
+++ b/starpilot/system/the_galaxy/assets/mobile/js/views/SystemTools.js
@@ -22,13 +22,16 @@ export const SystemTools = {
branches: [],
currentBranch: "",
branchLoading: true,
+ isOnroad: false,
fastStatus: null,
checkedForUpdates: false,
busy: "",
+ profiles: [],
+ profileBusy: "",
}
},
created() { this.poll = usePolling(() => this.loadFastStatus(), { interval: 3000 }); this.poll.start() },
- mounted() { this.loadBranches() },
+ mounted() { this.loadBranches(); this.loadProfiles() },
beforeUnmount() { this.poll?.destroy() },
computed: {
updateAvailable() { return !!this.fastStatus?.updateAvailable && !this.fastStatus?.running },
@@ -79,6 +82,50 @@ export const SystemTools = {
showSnackbar(e?.message || "Backup failed.", "error")
}
},
+ async loadProfiles() {
+ try {
+ const data = await api.getToggleProfiles()
+ this.profiles = Array.isArray(data?.slots) ? data.slots : []
+ this.isOnroad = !!data?.isOnroad
+ } catch (e) {
+ this.profiles = []
+ }
+ },
+ async saveProfile(profile) {
+ if (this.profileBusy || this.isOnroad) return
+ if (profile.saved && !(await GalaxyConfirm({
+ title: `Overwrite ${profile.label}?`,
+ message: "This replaces the settings currently stored in this slot.",
+ confirmLabel: "Overwrite",
+ }))) return
+ this.profileBusy = `save-${profile.slot}`
+ try {
+ const result = await api.saveToggleProfile(profile.slot)
+ showSnackbar(result?.message || `Saved ${profile.label}.`)
+ await this.loadProfiles()
+ } catch (e) {
+ showSnackbar(e?.message || "Failed to save settings profile.", "error")
+ } finally {
+ this.profileBusy = ""
+ }
+ },
+ async loadProfile(profile) {
+ if (this.profileBusy || this.isOnroad || !profile.saved || profile.invalid) return
+ if (!(await GalaxyConfirm({
+ title: `Load ${profile.label}?`,
+ message: "This applies every saved setting in the slot to the device.",
+ confirmLabel: "Load Settings",
+ }))) return
+ this.profileBusy = `load-${profile.slot}`
+ try {
+ const result = await api.loadToggleProfile(profile.slot)
+ showSnackbar(result?.message || `Loaded ${profile.label}.`)
+ } catch (e) {
+ showSnackbar(e?.message || "Failed to load settings profile.", "error")
+ } finally {
+ this.profileBusy = ""
+ }
+ },
onRestoreFile(e) {
const file = e.target.files[0]
e.target.value = ""
@@ -256,6 +303,27 @@ export const SystemTools = {
+
+
Settings Profiles
+
Keep two local configurations for different vehicles, drivers, or troubleshooting. Profiles never include pairing or sensitive device data.
+
+
+
+
+ {{ profile.label }}
+ {{ profile.invalid ? 'Damaged' : profile.saved ? profile.settingsCount + ' settings' : 'Empty' }}
+
+
+
+ {{ profileBusy === 'save-' + profile.slot ? 'Saving...' : profile.saved ? 'Overwrite' : 'Save Current' }}
+
+
+ {{ profileBusy === 'load-' + profile.slot ? 'Loading...' : 'Load' }}
+
+
+
+
+
Backup Toggles
Restore Toggles
diff --git a/starpilot/system/the_galaxy/assets/mobile/js/views/ToolEmbed.js b/starpilot/system/the_galaxy/assets/mobile/js/views/ToolEmbed.js
index 3e9095b34..7ca802d94 100644
--- a/starpilot/system/the_galaxy/assets/mobile/js/views/ToolEmbed.js
+++ b/starpilot/system/the_galaxy/assets/mobile/js/views/ToolEmbed.js
@@ -9,6 +9,7 @@ export const ToolEmbed = {
title() {
const map = {
"/manage_models": "Model Manager",
+ "/model_laboratory": "Model Laboratory",
"/galaxy": "Galaxy",
"/sentry": "Sentry Mode",
"/plots": "Live Plots",
diff --git a/starpilot/system/the_galaxy/assets/mobile/js/views/Tools.js b/starpilot/system/the_galaxy/assets/mobile/js/views/Tools.js
index 1b181ef82..af7be1939 100644
--- a/starpilot/system/the_galaxy/assets/mobile/js/views/Tools.js
+++ b/starpilot/system/the_galaxy/assets/mobile/js/views/Tools.js
@@ -5,6 +5,7 @@ const TOOLS = [
{ name: "Galaxy & App Install", link: "/galaxy", icon: "bi-globe2", desc: "Remote access, pairing, & app install" },
{ name: "Logs & Diagnostics", link: "/logs", icon: "bi-exclamation-triangle", desc: "Error logs, tmux, troubleshoot" },
{ name: "Model Manager", link: "/manage_models", icon: "bi-cpu", desc: "Install/swap models" },
+ { name: "Model Laboratory", link: "/model_laboratory", icon: "bi-bezier2", desc: "Pair lateral and longitudinal models" },
{ name: "Navigation & Maps", link: "/navigation", icon: "bi-map", desc: "Offline maps & destinations" },
{ name: "Sentry Mode", link: "/sentry", icon: "bi-shield-exclamation", desc: "Sentry alerts & security" },
{ name: "System Tools", link: "/system", icon: "bi-arrow-repeat", desc: "Backup, restore, updates" },
diff --git a/starpilot/system/the_galaxy/templates/index.html b/starpilot/system/the_galaxy/templates/index.html
index c47ed408c..cca2f9932 100644
--- a/starpilot/system/the_galaxy/templates/index.html
+++ b/starpilot/system/the_galaxy/templates/index.html
@@ -30,6 +30,7 @@
+
diff --git a/starpilot/system/the_galaxy/tests/test_dashboard_stats.py b/starpilot/system/the_galaxy/tests/test_dashboard_stats.py
index 277b0c976..b1c75e8aa 100644
--- a/starpilot/system/the_galaxy/tests/test_dashboard_stats.py
+++ b/starpilot/system/the_galaxy/tests/test_dashboard_stats.py
@@ -41,9 +41,11 @@ loggerd_uploader.listdir_by_creation = lambda path: [
sys.modules.setdefault("openpilot.system.loggerd.uploader", loggerd_uploader)
model_manager = ModuleType("openpilot.starpilot.assets.model_manager")
+model_manager.MODEL_LAB_DOWNLOAD_PARAM = "ModelLabModelToDownload"
model_manager.canonical_model_key = lambda value: str(value or "").strip().lower().replace(" ", "-")
model_manager.external_gpu_available = lambda: False
model_manager.is_builtin_model_key = lambda key: False
+model_manager.model_accelerator_artifact_filename = lambda key: f"{key}_driving_chestnut_tinygrad.pkl"
model_manager.model_key_aliases = lambda key: ()
model_manager.model_uses_external_gpu = lambda key: False
sys.modules.setdefault("openpilot.starpilot.assets.model_manager", model_manager)
@@ -310,6 +312,7 @@ def _install_server_import_stubs():
"SCREEN_RECORDINGS_PATH": Path("/tmp/dashboard-test-recordings"),
"STOCK_THEME_PATH": Path("/tmp/dashboard-test-stock-theme"),
"THEME_SAVE_PATH": Path("/tmp/dashboard-test-themes"),
+ "TOGGLE_BACKUPS": Path("/tmp/dashboard-test-toggle-backups"),
}.items():
setattr(starpilot_variables, name, value)
starpilot_variables.default_ev_tuning_enabled = lambda *args, **kwargs: False
@@ -387,6 +390,9 @@ class FakeParams:
def put(self, key, value):
self.values[key] = value
+ def remove(self, key):
+ self.values.pop(key, None)
+
class FailingPutParams(FakeParams):
def put(self, key, value):
@@ -1772,6 +1778,119 @@ def _load_server_module():
return module
+def test_model_laboratory_api_uses_installed_models_and_enforces_hardware_size_version_guards(monkeypatch, tmp_path):
+ server = _load_server_module()
+ assert server._import_galaxy_web_symbols()
+
+ class ModelLabParams(FakeParams):
+ defaults = {
+ "Model": "rdf43",
+ "DrivingModel": "rdf43",
+ "DrivingModelName": "Regret Driven Framework V4",
+ "ModelVersion": "v15",
+ "DrivingModelVersion": "v15",
+ }
+
+ def get_default_value(self, key):
+ return self.defaults.get(key)
+
+ params = ModelLabParams({
+ "AvailableModels": "lat,long,old,big",
+ "AvailableModelNames": "Lateral Ace,Longitudinal Ace,Old Generation,Chestnut One Billion",
+ "AvailableModelSeries": "Lab,Lab,Legacy,Large",
+ "AvailableModelArtifactFormats": "tinygrad_single_v1,tinygrad_single_v1,tinygrad_single_v1,tinygrad_single_v1",
+ "ModelVersions": "v15,v15,v9,v16",
+ "ModelReleasedDates": "2026-01-01,2026-01-02,2025-01-01,2026-08-01",
+ "ModelManifestVersion": "v25",
+ "Model": "rdf43",
+ "DrivingModel": "rdf43",
+ })
+ metadata = {
+ "lat": {"model_size": "small", "model_size_declared": True, "model_lab_eligible": True,
+ "accelerator_artifacts": {"chestnut": {"execution_device": "AMD"}}},
+ "long": {"model_size": "small", "model_size_declared": True, "model_lab_eligible": True,
+ "accelerator_artifacts": {"chestnut": {"execution_device": "AMD"}}},
+ "old": {"model_size": "small", "model_size_declared": True, "model_lab_eligible": True,
+ "accelerator_artifacts": {"chestnut": {"execution_device": "AMD"}}},
+ "big": {"model_size": "chestnut", "model_size_declared": True, "uses_external_gpu": True},
+ }
+ (tmp_path / ".model_artifacts.json").write_text(json.dumps(metadata))
+ (tmp_path / "lat_driving_tinygrad.pkl").write_bytes(b"lat")
+ (tmp_path / "long_driving_tinygrad.pkl").write_bytes(b"long")
+ (tmp_path / "old_driving_tinygrad.pkl").write_bytes(b"old")
+ (tmp_path / "lat_driving_chestnut_tinygrad.pkl").write_bytes(b"lat-amd")
+ (tmp_path / "long_driving_chestnut_tinygrad.pkl").write_bytes(b"long-amd")
+ (tmp_path / "old_driving_chestnut_tinygrad.pkl").write_bytes(b"old-amd")
+
+ app = server.Flask(
+ "model_lab_test",
+ template_folder=str(MODULE_DIR / "templates"),
+ static_folder=str(MODULE_DIR / "assets"),
+ )
+ server.setup(app)
+ monkeypatch.setattr(server, "params", params)
+ params_memory = FakeParams()
+ monkeypatch.setattr(server, "params_memory", params_memory)
+ monkeypatch.setattr(server, "MODELS_PATH", tmp_path)
+ monkeypatch.setattr(server, "external_gpu_available", lambda: True)
+ monkeypatch.setattr(server, "model_uses_external_gpu", lambda key: key == "big")
+ client = app.test_client()
+
+ status = client.get("/api/model-laboratory")
+ status_payload = status.get_json()
+ assert status.status_code == 200
+ assert status_payload["chestnutReady"] is True
+ assert {model["value"] for model in status_payload["models"]} == {"rdf43", "lat", "long", "old"}
+ assert status_payload["summary"]["ready"] == 3
+ assert status_payload["summary"]["published"] == 3
+
+ enabled = client.put("/api/model-laboratory", json={
+ "enabled": True,
+ "lateralModel": "lat",
+ "longitudinalModel": "long",
+ })
+ assert enabled.status_code == 200
+ assert params.values["ModelLabConfig"]["enabled"] is True
+ assert params.values["Model"] == params.values["DrivingModel"] == "lat"
+ assert params.values["ModelVersion"] == params.values["DrivingModelVersion"] == "v15"
+
+ mismatched = client.put("/api/model-laboratory", json={
+ "enabled": True,
+ "lateralModel": "lat",
+ "longitudinalModel": "old",
+ })
+ assert mismatched.status_code == 409
+ assert "same behavior version" in mismatched.get_json()["error"]
+
+ oversized = client.put("/api/model-laboratory", json={
+ "enabled": True,
+ "lateralModel": "lat",
+ "longitudinalModel": "big",
+ })
+ assert oversized.status_code == 409
+ assert "Chestnut-class" in oversized.get_json()["error"]
+
+ (tmp_path / "old_driving_chestnut_tinygrad.pkl").unlink()
+ 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"]
+ params_memory.remove("ModelLabModelToDownload")
+
+ monkeypatch.setattr(server, "external_gpu_available", lambda: False)
+ no_chestnut = client.put("/api/model-laboratory", json={
+ "enabled": True,
+ "lateralModel": "lat",
+ "longitudinalModel": "long",
+ })
+ assert no_chestnut.status_code == 409
+ assert "Chestnut" in no_chestnut.get_json()["error"]
+
+ params.values["IsOnroad"] = True
+ onroad = client.put("/api/model-laboratory", json={"enabled": False})
+ assert onroad.status_code == 403
+
+
def test_clear_generated_build_state_preserves_prebuilts_and_user_data(tmp_path):
server = _load_server_module()
sconsign = tmp_path / ".sconsign.dblite"
@@ -2098,3 +2217,85 @@ def test_toggle_restore_reports_invalid_and_unavailable_settings(monkeypatch):
assert damaged_response.get_json()["success"] is False
assert wrong_format_response.status_code == 400
assert wrong_format_response.get_json()["success"] is False
+
+
+def test_toggle_profile_slots_save_and_load_the_same_filtered_settings(monkeypatch, tmp_path):
+ server = _load_server_module()
+ assert server._import_galaxy_web_symbols()
+
+ definitions = {
+ "EnabledSetting": (True, server.ParamKeyType.BOOL, server.ParamKeyFlag.PERSISTENT),
+ "NumericSetting": (1.5, server.ParamKeyType.FLOAT, server.ParamKeyFlag.PERSISTENT),
+ "SensitiveSetting": ("", server.ParamKeyType.STRING, server.ParamKeyFlag.PERSISTENT | server.ParamKeyFlag.DONT_LOG),
+ }
+
+ class ToggleParams:
+ def __init__(self):
+ self.values = {
+ "EnabledSetting": False,
+ "NumericSetting": 2.75,
+ "SensitiveSetting": "secret",
+ }
+
+ def get(self, key, block=False):
+ del block
+ return self.values.get(key, definitions[key][0])
+
+ def get_default_value(self, key):
+ return definitions[key][0]
+
+ def get_key_flag(self, key):
+ return definitions[key][2]
+
+ def get_type(self, key):
+ return definitions[key][1]
+
+ def put(self, key, value):
+ self.values[key] = value
+
+ raw_params = ToggleParams()
+ server.starpilot_default_params = [
+ (key, default, value_type, 0)
+ for key, (default, value_type, _) in definitions.items()
+ ]
+ monkeypatch.setattr(server, "_params_raw", raw_params)
+ monkeypatch.setattr(server, "params", FakeParams({"IsOnroad": False}))
+ monkeypatch.setattr(server, "EXCLUDED_KEYS", set())
+ monkeypatch.setattr(server, "TOGGLE_BACKUPS", tmp_path)
+ update_calls = []
+ monkeypatch.setattr(server, "update_starpilot_toggles", lambda: update_calls.append(True))
+
+ app = server.Flask(
+ "toggle_profile_test",
+ template_folder=str(MODULE_DIR / "templates"),
+ static_folder=str(MODULE_DIR / "assets"),
+ )
+ server.setup(app)
+ client = app.test_client()
+
+ initial = client.get("/api/toggles/profiles").get_json()
+ saved = client.post("/api/toggles/profiles/a/save")
+ assert initial["slots"][0]["saved"] is False
+ assert saved.status_code == 200
+ assert saved.get_json()["profile"]["settingsCount"] == 2
+
+ raw_params.values.update({
+ "EnabledSetting": True,
+ "NumericSetting": 9.0,
+ "SensitiveSetting": "new-secret",
+ })
+ loaded = client.post("/api/toggles/profiles/a/load")
+ assert loaded.status_code == 200
+ assert loaded.get_json()["restoredCount"] == 2
+ assert raw_params.values["EnabledSetting"] is False
+ assert raw_params.values["NumericSetting"] == 2.75
+ assert raw_params.values["SensitiveSetting"] == "new-secret"
+ assert update_calls == [True]
+
+ missing = client.post("/api/toggles/profiles/b/load")
+ assert missing.status_code == 400
+ assert "has not been saved" in missing.get_json()["message"]
+
+ server.params.values["IsOnroad"] = True
+ onroad = client.post("/api/toggles/profiles/a/load")
+ assert onroad.status_code == 403
diff --git a/starpilot/system/the_galaxy/tests/test_device_settings_frontend.py b/starpilot/system/the_galaxy/tests/test_device_settings_frontend.py
index 199917d90..bf6e0455a 100644
--- a/starpilot/system/the_galaxy/tests/test_device_settings_frontend.py
+++ b/starpilot/system/the_galaxy/tests/test_device_settings_frontend.py
@@ -46,6 +46,14 @@ def test_device_settings_uses_the_params_api_and_layout_json():
assert 'fetch("/assets/components/tools/device_settings_layout.json?v=settings-tier-1"' in source
+def test_lane_center_offset_can_step_below_zero():
+ source = _device_settings()
+
+ assert 'if (param.key === "LaneCenterOffset")' in source
+ assert "return { min: -0.3, max: 0.3, step: 0.01 }" in source
+ assert "canStepNumericParam(p, -1)" in source
+
+
def test_developer_mode_notice_has_styles():
css = DEVICE_SETTINGS_CSS_PATH.read_text(encoding="utf-8")
diff --git a/starpilot/system/the_galaxy/tests/test_frontend_module_graph.py b/starpilot/system/the_galaxy/tests/test_frontend_module_graph.py
index aac3b6a2e..7b9c2866e 100644
--- a/starpilot/system/the_galaxy/tests/test_frontend_module_graph.py
+++ b/starpilot/system/the_galaxy/tests/test_frontend_module_graph.py
@@ -8,6 +8,7 @@ INDEX_PATH = REPO_ROOT / "starpilot/system/the_galaxy/templates/index.html"
BLUETOOTH_PATH = REPO_ROOT / "starpilot/system/the_galaxy/assets/components/tools/bluetooth.js"
CONTROLLERS_PATH = REPO_ROOT / "starpilot/system/the_galaxy/assets/components/tools/wheel_controls.js"
SIDEBAR_PATH = REPO_ROOT / "starpilot/system/the_galaxy/assets/components/sidebar.js"
+MODEL_LAB_PATH = REPO_ROOT / "starpilot/system/the_galaxy/assets/components/tools/model_laboratory.js"
def test_settings_does_not_create_a_second_router_module():
@@ -95,3 +96,29 @@ def test_bluetooth_and_controllers_sidebar_order():
sentry = source.index('{ name: "Sentry Mode"')
controllers = source.index('{ name: "Controllers"')
assert toggles < bluetooth < sentry < controllers
+
+def test_model_laboratory_is_wired_into_classic_and_mobile_navigation():
+ router = ROUTER_PATH.read_text(encoding="utf-8")
+ sidebar = SIDEBAR_PATH.read_text(encoding="utf-8")
+ template = INDEX_PATH.read_text(encoding="utf-8")
+ mobile_tools = (REPO_ROOT / "starpilot/system/the_galaxy/assets/mobile/js/views/Tools.js").read_text(encoding="utf-8")
+ mobile_embed = (REPO_ROOT / "starpilot/system/the_galaxy/assets/mobile/js/views/ToolEmbed.js").read_text(encoding="utf-8")
+
+ assert MODEL_LAB_PATH.is_file()
+ assert 'createRoute("model_laboratory", "/model_laboratory", ModelLaboratory)' in router
+ assert '{ name: "Model Laboratory", link: "/model_laboratory"' in sidebar
+ assert "/assets/components/tools/model_laboratory.css" in template
+ assert '{ name: "Model Laboratory", link: "/model_laboratory"' in mobile_tools
+ assert '"/model_laboratory": "Model Laboratory"' in mobile_embed
+
+
+def test_model_laboratory_frontend_exposes_guards_and_role_copy():
+ source = MODEL_LAB_PATH.read_text(encoding="utf-8")
+ 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 'lateral.version !== longitudinal.version' in source
+ assert "Path shape, curvature, lane geometry" in source
+ assert "Speed, acceleration, stopping, leads" in source
diff --git a/starpilot/system/the_galaxy/tests/test_ui_vue_frontend.py b/starpilot/system/the_galaxy/tests/test_ui_vue_frontend.py
index bafae41e0..ced2e64b2 100644
--- a/starpilot/system/the_galaxy/tests/test_ui_vue_frontend.py
+++ b/starpilot/system/the_galaxy/tests/test_ui_vue_frontend.py
@@ -110,7 +110,9 @@ def test_ui_ports_all_tool_views():
"js/views/Tuning.js": ["LateralTuningPanel", "LongitudinalManeuvers"],
"js/views/Navigation.js": ["getNavigation", "setNavigation", "MapsPanel", "NavigationKeysPanel"],
"js/views/ToolEmbed.js": ["/manage_maps", "/manage_navigation_keys"],
- "js/views/SystemTools.js": ["backupToggles", "restoreToggles", "getUpdateBranches", "factoryReset"],
+ "js/views/SystemTools.js": [
+ "backupToggles", "restoreToggles", "getToggleProfiles", "saveToggleProfile", "loadToggleProfile", "getUpdateBranches", "factoryReset",
+ ],
"js/components/WheelControls.js": ["getWheelControlsStatus"],
"js/components/BluetoothPanel.js": ["getBluetoothStatus"],
}
@@ -501,6 +503,10 @@ assert(P.countAdvancedHiddenByDeveloperMode([sec], { GalaxyDeveloperMode: true }
const slider = { key: "DeviceShutdown", data_type: "int", min: 1, max: 30, step: 1 }
assert(P.snapNumericToBoundsAndStep(17.9, P.numericBounds(slider, {}), 0) === 18, "snap")
assert(P.formatSliderValue(6, "1", 0, "DeviceShutdown") === "6 hours", "format")
+const laneOffset = { key: "LaneCenterOffset", data_type: "float", min: 0, max: 0.3, step: 0.01 }
+const laneBounds = P.numericBounds(laneOffset, {})
+assert(laneBounds.min === -0.3, "lane offset keeps signed lower bound")
+assert(P.snapNumericToBoundsAndStep(-0.01, laneBounds, 2) === -0.01, "lane offset snaps below zero")
console.log("params.js logic OK")
""",
encoding="utf-8",
diff --git a/starpilot/system/the_galaxy/the_galaxy.py b/starpilot/system/the_galaxy/the_galaxy.py
index 984fa69b2..74456a7dc 100644
--- a/starpilot/system/the_galaxy/the_galaxy.py
+++ b/starpilot/system/the_galaxy/the_galaxy.py
@@ -39,7 +39,7 @@ from opendbc.car.gm.values import GMFlags
from opendbc.car.toyota.carcontroller import LOCK_CMD, UNLOCK_CMD
from opendbc.car.toyota.values import ToyotaStarPilotFlags
from openpilot.common.constants import CV
-from openpilot.common.file_chunker import get_chunk_name, get_manifest_path
+from openpilot.common.file_chunker import file_chunked_exists, get_chunk_name, get_manifest_path
from openpilot.common.params import ParamKeyFlag, ParamKeyType, Params
from openpilot.common.realtime import DT_HW
from openpilot.common.swaglog import cloudlog
@@ -52,13 +52,24 @@ from openpilot.tools.longitudinal_maneuvers.capabilities import get_longitudinal
from panda import Panda
from openpilot.starpilot.assets.model_manager import (
+ MODEL_LAB_DOWNLOAD_PARAM,
canonical_model_key,
external_gpu_available,
is_builtin_model_key,
+ model_accelerator_artifact_filename,
model_key_aliases,
model_uses_external_gpu,
)
+from openpilot.starpilot.common.model_lab import (
+ MODEL_LAB_CONFIG_PARAM,
+ MODEL_LAB_RUNTIME_PARAM,
+ is_small_model_metadata,
+ model_lab_manifest_eligible,
+ normalize_model_lab_config,
+ validate_model_lab_selection,
+)
from openpilot.starpilot.assets.theme_manager import HOLIDAY_THEME_PATH, THEME_COMPONENT_PARAMS
+from openpilot.starpilot.common import param_profiles
from openpilot.starpilot.common.accel_profile import (
CUSTOM_ACCEL_PROFILE_BREAKPOINT_PARAM_KEYS,
CUSTOM_ACCEL_PROFILE_BREAKPOINTS_INITIALIZED_KEY,
@@ -103,7 +114,7 @@ from openpilot.starpilot.common.favorite_slots import (
)
from openpilot.starpilot.common.lateral_delay import full_lateral_delay
from openpilot.starpilot.common.starpilot_utilities import delete_file, get_lock_status, run_cmd
-from openpilot.starpilot.common.starpilot_variables import ACTIVE_THEME_PATH, BUTTON_FUNCTIONS, ERROR_LOGS_PATH, EXCLUDED_KEYS, LEGACY_STARPILOT_PARAM_RENAMES, MAPS_PATH, MODELS_PATH, RESOURCES_REPO, SCREEN_RECORDINGS_PATH, STOCK_THEME_PATH, THEME_SAVE_PATH,\
+from openpilot.starpilot.common.starpilot_variables import ACTIVE_THEME_PATH, BUTTON_FUNCTIONS, ERROR_LOGS_PATH, EXCLUDED_KEYS, LEGACY_STARPILOT_PARAM_RENAMES, MAPS_PATH, MODELS_PATH, RESOURCES_REPO, SCREEN_RECORDINGS_PATH, STOCK_THEME_PATH, THEME_SAVE_PATH, TOGGLE_BACKUPS,\
default_ev_tuning_enabled, migrate_cancel_button_controls, update_starpilot_toggles
from openpilot.starpilot.common.testing_grounds import (
DEFAULT_TESTING_GROUND_VARIANT as SHARED_DEFAULT_TESTING_GROUND_VARIANT,
@@ -1164,20 +1175,7 @@ def _dispatch_sentry_event(event: dict, *, bypass_rate_limit: bool = False) -> N
TOGGLE_BACKUP_FORMAT = "starpilot-toggle-backup"
TOGGLE_BACKUP_VERSION = 1
TOGGLE_BACKUP_MAX_ENCODED_BYTES = 2_000_000
-TOGGLE_BACKUP_NO_DEFAULT_KEYS = {
- "AdbEnabled",
- "AlphaLongitudinalEnabled",
- "AlwaysOnDM",
- "ExperimentalMode",
- "ExperimentalModeConfirmed",
- "IsLdwEnabled",
- "IsMetric",
- "IsRHD",
- "IsRHDOverride",
- "RecordAudio",
- "RecordFront",
- "SshEnabled",
-}
+TOGGLE_BACKUP_NO_DEFAULT_KEYS = param_profiles.PROFILE_NO_DEFAULT_KEYS
def _get_toggle_backup_keys():
@@ -4988,6 +4986,8 @@ def setup(app):
"/assets/components/tools/pip_sidecam.js",
"/assets/components/tools/pip_sidecam.css",
"/assets/components/tools/toggles.js",
+ "/assets/components/tools/model_laboratory.js",
+ "/assets/components/tools/model_laboratory.css",
"/assets/components/tools/bluetooth.js",
"/assets/components/tools/bluetooth.css",
"/assets/components/tools/wheel_controls.js",
@@ -5966,6 +5966,12 @@ def setup(app):
if model_uses_external_gpu(selected_model) and not external_gpu_available():
return jsonify({"error": "This model requires a detected external GPU."}), 409
+ lab_config = normalize_model_lab_config(params.get(MODEL_LAB_CONFIG_PARAM, encoding="utf-8") or "")
+ if lab_config["enabled"]:
+ lab_config["enabled"] = False
+ params.put(MODEL_LAB_CONFIG_PARAM, lab_config)
+ params.remove(MODEL_LAB_RUNTIME_PARAM)
+
params.put("Model", selected_model)
params.put("DrivingModel", selected_model)
@@ -6213,6 +6219,136 @@ def setup(app):
},
}), 200
+ def _model_lab_status_payload():
+ models = get_model_catalog()
+ model_by_key = {model["value"]: model for model in models}
+ config = normalize_model_lab_config(params.get(MODEL_LAB_CONFIG_PARAM, encoding="utf-8") or "")
+ config["lateralModel"] = canonical_model_key(config["lateralModel"])
+ config["longitudinalModel"] = canonical_model_key(config["longitudinalModel"])
+ chestnut_ready = external_gpu_available()
+ runtime = {}
+ try:
+ runtime_value = params.get(MODEL_LAB_RUNTIME_PARAM, encoding="utf-8") or ""
+ runtime = json.loads(runtime_value) if isinstance(runtime_value, str) and runtime_value else runtime_value
+ if not isinstance(runtime, dict):
+ runtime = {}
+ except (TypeError, ValueError):
+ runtime = {}
+
+ eligible_models = [model for model in models if model.get("modelLabEligible")]
+ ready_models = [model for model in eligible_models if model.get("modelLabArtifactInstalled")]
+ lab_model_to_download = params_memory.get(MODEL_LAB_DOWNLOAD_PARAM, encoding="utf-8") or ""
+ configuration_error = validate_model_lab_selection(
+ config,
+ model_by_key,
+ chestnut_ready=chestnut_ready,
+ require_installed=True,
+ )
+ return {
+ "chestnutReady": chestnut_ready,
+ "isOnroad": params.get_bool("IsOnroad"),
+ "configuration": config,
+ "configurationError": configuration_error or "",
+ "runtime": runtime,
+ "download": {
+ "model": lab_model_to_download,
+ "progress": params_memory.get(MODEL_DOWNLOAD_PROGRESS_PARAM, encoding="utf-8") or "",
+ },
+ "models": eligible_models,
+ "summary": {
+ "eligible": len(eligible_models),
+ "ready": len(ready_models),
+ "published": sum(1 for model in eligible_models if model.get("modelLabArtifactAvailable")),
+ "declaredSize": sum(1 for model in eligible_models if model.get("manifestDeclaredSize")),
+ },
+ "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.",
+ ],
+ },
+ }
+
+ @app.route("/api/model-laboratory", methods=["GET", "PUT"])
+ def model_laboratory():
+ if request.method == "GET":
+ return jsonify(_model_lab_status_payload()), 200
+
+ if params.get_bool("IsOnroad"):
+ return jsonify({"error": "Model Laboratory can only be configured while parked."}), 403
+
+ data = request.get_json(silent=True) or {}
+ config = normalize_model_lab_config({
+ "enabled": data.get("enabled", False),
+ "lateralModel": canonical_model_key(str(data.get("lateralModel") or "")),
+ "longitudinalModel": canonical_model_key(str(data.get("longitudinalModel") or "")),
+ })
+ models = get_model_catalog()
+ model_by_key = {model["value"]: model for model in models}
+ error = validate_model_lab_selection(
+ config,
+ model_by_key,
+ chestnut_ready=external_gpu_available(),
+ require_installed=True,
+ )
+ if error:
+ return jsonify({"error": error}), 409
+
+ params.put(MODEL_LAB_CONFIG_PARAM, config)
+ params.remove(MODEL_LAB_RUNTIME_PARAM)
+ if config["enabled"]:
+ lateral = model_by_key[config["lateralModel"]]
+ params.put("Model", lateral["value"])
+ params.put("DrivingModel", lateral["value"])
+ params.put("DrivingModelName", lateral["label"])
+ if lateral.get("version"):
+ params.put("ModelVersion", lateral["version"])
+ params.put("DrivingModelVersion", lateral["version"])
+ message = "Model Laboratory enabled. The pair will load on the next drive."
+ else:
+ message = "Model Laboratory disabled."
+
+ return jsonify({"message": message, **_model_lab_status_payload()}), 200
+
+ @app.route("/api/model-laboratory/download", methods=["POST"])
+ 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 "")
+ or (params_memory.get(MODEL_LAB_DOWNLOAD_PARAM, encoding="utf-8") or "")
+ ):
+ return jsonify({"error": "A model download is already 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("modelLabEligible"):
+ return jsonify({"error": "Only compatible small models can be prepared for Model Laboratory."}), 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
+
+ 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
+
@app.route("/api/models/preferences", methods=["GET", "PUT"])
def get_or_set_models_preferences():
if request.method == "GET":
@@ -6247,11 +6383,12 @@ def setup(app):
def get_models_status():
models = get_model_catalog()
model_to_download = canonical_model_key(params_memory.get(MODEL_DOWNLOAD_PARAM, encoding="utf-8") or "")
+ lab_model_to_download = canonical_model_key(params_memory.get(MODEL_LAB_DOWNLOAD_PARAM, encoding="utf-8") or "")
download_all = params_memory.get_bool(MODEL_DOWNLOAD_ALL_PARAM)
progress = params_memory.get(MODEL_DOWNLOAD_PROGRESS_PARAM, encoding="utf-8") or ""
cancelling = params_memory.get_bool(MODEL_CANCEL_DOWNLOAD_PARAM)
- downloading = bool(model_to_download) or download_all
+ downloading = bool(model_to_download or lab_model_to_download) or download_all
current_model = _current_model_key()
sort_mode = read_legacy_param_file(MODEL_SORT_MODE_PARAM, DEFAULT_MODEL_SORT_MODE)
terminal = progress in ("Downloaded!", "All models downloaded!") or bool(re.search(r"cancelled|exists|failed|offline|invalid|error", progress, re.IGNORECASE))
@@ -6267,6 +6404,7 @@ def setup(app):
summary["installed"],
summary["missing"],
model_to_download,
+ lab_model_to_download,
download_all,
downloading,
cancelling,
@@ -6299,6 +6437,7 @@ def setup(app):
return jsonify({
"modelToDownload": model_to_download,
+ "modelLabModelToDownload": lab_model_to_download,
"downloadAll": download_all,
"downloading": downloading,
"cancelling": cancelling,
@@ -6316,7 +6455,11 @@ def setup(app):
if params.get_bool("IsOnroad"):
return jsonify({"error": "Cannot refresh model manifest while driving."}), 403
- if params_memory.get_bool(MODEL_DOWNLOAD_ALL_PARAM) or (params_memory.get(MODEL_DOWNLOAD_PARAM, encoding="utf-8") or ""):
+ 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 refresh model manifest while a download is in progress."}), 409
try:
@@ -6335,7 +6478,11 @@ def setup(app):
if params.get_bool("IsOnroad"):
return jsonify({"error": "Cannot download models while driving."}), 403
- if params_memory.get_bool(MODEL_DOWNLOAD_ALL_PARAM) or (params_memory.get(MODEL_DOWNLOAD_PARAM, encoding="utf-8") or ""):
+ 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": "A model download is already in progress."}), 409
data = request.get_json() or {}
@@ -6367,7 +6514,11 @@ def setup(app):
if params.get_bool("IsOnroad"):
return jsonify({"error": "Cannot download models while driving."}), 403
- if params_memory.get_bool(MODEL_DOWNLOAD_ALL_PARAM) or (params_memory.get(MODEL_DOWNLOAD_PARAM, encoding="utf-8") or ""):
+ 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": "A model download is already in progress."}), 409
data = request.get_json(silent=True) or {}
@@ -6390,8 +6541,9 @@ def setup(app):
@app.route("/api/models/cancel", methods=["POST"])
def cancel_model_download():
model_to_download = params_memory.get(MODEL_DOWNLOAD_PARAM, encoding="utf-8") or ""
+ lab_model_to_download = params_memory.get(MODEL_LAB_DOWNLOAD_PARAM, encoding="utf-8") or ""
download_all = params_memory.get_bool(MODEL_DOWNLOAD_ALL_PARAM)
- if not model_to_download and not download_all:
+ if not model_to_download and not lab_model_to_download and not download_all:
return jsonify({"message": "No active model download to cancel."}), 200
params_memory.put_bool(MODEL_CANCEL_DOWNLOAD_PARAM, True)
@@ -6402,7 +6554,11 @@ def setup(app):
if params.get_bool("IsOnroad"):
return jsonify({"error": "Cannot delete model files while driving."}), 403
- if params_memory.get_bool(MODEL_DOWNLOAD_ALL_PARAM) or (params_memory.get(MODEL_DOWNLOAD_PARAM, encoding="utf-8") or ""):
+ 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 model files while a download is in progress."}), 409
data = request.get_json() or {}
@@ -6692,6 +6848,12 @@ def setup(app):
except Exception:
on_disk_files = set()
+ try:
+ metadata_payload = json.loads((MODELS_PATH / ".model_artifacts.json").read_text())
+ artifact_metadata = metadata_payload if isinstance(metadata_payload, dict) else {}
+ except (OSError, TypeError, ValueError):
+ artifact_metadata = {}
+
external_gpu_present = external_gpu_available()
models_by_key = {}
for i, key in enumerate(available):
@@ -6706,7 +6868,20 @@ def setup(app):
released = released_dates[i] if i < len(released_dates) else ""
requires_external_gpu = model_uses_external_gpu(canonical_key)
gpu_available = not requires_external_gpu or external_gpu_present
-
+ metadata = artifact_metadata.get(canonical_key, {})
+ metadata = metadata if isinstance(metadata, dict) else {}
+ small_model = is_small_model_metadata({**metadata, "uses_external_gpu": requires_external_gpu})
+ lab_eligible = model_lab_manifest_eligible({**metadata, "uses_external_gpu": requires_external_gpu}, model_version)
+ accelerator_artifacts = metadata.get("accelerator_artifacts", {})
+ accelerator_artifacts = accelerator_artifacts if isinstance(accelerator_artifacts, dict) else {}
+ chestnut_artifact = accelerator_artifacts.get("chestnut", {})
+ chestnut_artifact = chestnut_artifact if isinstance(chestnut_artifact, dict) else {}
+ lab_artifact_available = (
+ bool(chestnut_artifact)
+ and str(chestnut_artifact.get("execution_device") or chestnut_artifact.get("device") or "").strip().upper() == "AMD"
+ )
+ lab_artifact_path = MODELS_PATH / model_accelerator_artifact_filename(canonical_key)
+ lab_artifact_installed = lab_artifact_available and file_chunked_exists(lab_artifact_path)
existing = models_by_key.get(canonical_key)
if existing is None:
models_by_key[canonical_key] = {
@@ -6717,6 +6892,12 @@ def setup(app):
"artifactFormat": artifact_format,
"requiresGpu": requires_external_gpu,
"gpuAvailable": gpu_available,
+ "small": small_model,
+ "modelSize": str(metadata.get("model_size") or ("small (inferred)" if small_model else "chestnut (inferred)")),
+ "manifestDeclaredSize": bool(metadata.get("model_size_declared", metadata.get("size_class"))),
+ "modelLabEligible": lab_eligible,
+ "modelLabArtifactAvailable": lab_artifact_available,
+ "modelLabArtifactInstalled": lab_artifact_installed,
"released": released,
"builtin": is_builtin_model_key(canonical_key),
"communityFavorite": canonical_key in community_favorites,
@@ -6739,6 +6920,10 @@ def setup(app):
existing["userFavorite"] = existing["userFavorite"] or canonical_key in user_favorites
existing["requiresGpu"] = existing["requiresGpu"] or requires_external_gpu
existing["gpuAvailable"] = not existing["requiresGpu"] or external_gpu_present
+ existing["small"] = existing["small"] and small_model
+ existing["modelLabEligible"] = existing["modelLabEligible"] and lab_eligible
+ existing["modelLabArtifactAvailable"] = existing["modelLabArtifactAvailable"] and lab_artifact_available
+ existing["modelLabArtifactInstalled"] = existing["modelLabArtifactInstalled"] and lab_artifact_installed
default_key = _default_model_key()
default_entry = models_by_key.setdefault(default_key, {
@@ -6749,6 +6934,12 @@ def setup(app):
"artifactFormat": "tinygrad_single_v1",
"requiresGpu": False,
"gpuAvailable": True,
+ "small": True,
+ "modelSize": "small (inferred)",
+ "manifestDeclaredSize": False,
+ "modelLabEligible": model_lab_manifest_eligible(artifact_metadata.get(default_key, {}), _default_model_version()),
+ "modelLabArtifactAvailable": False,
+ "modelLabArtifactInstalled": False,
"released": "",
"builtin": True,
"communityFavorite": default_key in community_favorites,
@@ -9322,6 +9513,57 @@ def setup(app):
"skippedCount": skipped_count,
})
+ @app.route("/api/toggles/profiles", methods=["GET"])
+ def get_toggle_profiles():
+ return jsonify({
+ "slots": param_profiles.list_profiles(profile_root=TOGGLE_BACKUPS),
+ "isOnroad": _safe_params_get_bool("IsOnroad"),
+ })
+
+ @app.route("/api/toggles/profiles//save", methods=["POST"])
+ def save_toggle_profile(slot):
+ if _safe_params_get_bool("IsOnroad"):
+ return jsonify({"success": False, "message": "Settings profiles can only be saved while parked."}), 403
+ try:
+ status = param_profiles.save_profile(
+ _params_raw,
+ slot,
+ allowed_keys=_get_toggle_backup_keys(),
+ profile_root=TOGGLE_BACKUPS,
+ )
+ except param_profiles.ParamProfileError as error:
+ return jsonify({"success": False, "message": str(error)}), 400
+ return jsonify({
+ "success": True,
+ "message": f"Saved current settings to {status['label']}.",
+ "profile": status,
+ })
+
+ @app.route("/api/toggles/profiles//load", methods=["POST"])
+ def load_toggle_profile(slot):
+ if _safe_params_get_bool("IsOnroad"):
+ return jsonify({"success": False, "message": "Settings profiles can only be loaded while parked."}), 403
+ try:
+ result = param_profiles.load_profile(
+ _params_raw,
+ slot,
+ allowed_keys=_get_toggle_backup_keys(),
+ profile_root=TOGGLE_BACKUPS,
+ legacy_renames=LEGACY_STARPILOT_PARAM_RENAMES,
+ )
+ except param_profiles.ParamProfileError as error:
+ return jsonify({"success": False, "message": str(error)}), 400
+
+ update_starpilot_toggles()
+ message = f"Loaded {result['label']} ({result['restoredCount']} settings)."
+ if result["skippedCount"]:
+ message += f" Skipped {result['skippedCount']} incompatible settings."
+ return jsonify({
+ "success": True,
+ "message": message,
+ **result,
+ })
+
@app.route("/api/toggles/reset_default", methods=["POST"])
def reset_toggle_values():
for raw_key in _params_raw.all_keys():
diff --git a/system/webrtc/tests/test_webrtcd.py b/system/webrtc/tests/test_webrtcd.py
index 9fb6a42e5..92af60ff4 100644
--- a/system/webrtc/tests/test_webrtcd.py
+++ b/system/webrtc/tests/test_webrtcd.py
@@ -4,7 +4,7 @@ import pytest
pytest.importorskip("libdatachannel", reason="the upstream WebRTC backend requires Python 3.12")
-from openpilot.system.webrtc.webrtcd import ServerState, handle_get_schema, handle_post_notify, on_shutdown
+from openpilot.system.webrtc.webrtcd import ServerState, handle_get_schema, handle_get_stream, handle_post_notify, on_shutdown
@pytest.mark.asyncio
@@ -22,6 +22,13 @@ async def test_get_schema_rejects_unknown_service():
await handle_get_schema(ServerState(), "notARealService")
+@pytest.mark.asyncio
+async def test_stream_rejects_non_json_content_type():
+ response = await handle_get_stream(ServerState(), b"{}", "text/plain")
+
+ assert response == (415, b'{"error": "unsupported media type"}', "application/json; charset=utf-8")
+
+
@pytest.mark.asyncio
async def test_notify_and_shutdown_active_stream(mocker):
state = ServerState()
diff --git a/system/webrtc/webrtcd.py b/system/webrtc/webrtcd.py
index cdc4be387..d291c4b4e 100644
--- a/system/webrtc/webrtcd.py
+++ b/system/webrtc/webrtcd.py
@@ -329,9 +329,11 @@ class StreamSession:
async def run(self):
try:
self.params.put("LivestreamRequestKeyframe", True)
+
+ self.stream.set_message_handler(self.message_handler)
+
await asyncio.wait_for(self.stream.wait_for_connection(), timeout=15)
if self.stream.has_messaging_channel():
- self.stream.set_message_handler(self.message_handler)
if self.incoming_bridge is not None:
await self.shared_pub_master.add_services_if_needed(self.incoming_bridge_services)
if self.outgoing_bridge is not None:
@@ -395,7 +397,10 @@ def _text_response(text: str, status: int = 200) -> tuple[int, bytes, str]:
return (status, text.encode(), "text/plain; charset=utf-8")
-async def handle_get_stream(state: ServerState, raw_body: bytes) -> tuple[int, bytes, str]:
+async def handle_get_stream(state: ServerState, raw_body: bytes, content_type: str) -> tuple[int, bytes, str]:
+ if content_type != "application/json":
+ return _json_response({"error": "unsupported media type"}, status=415)
+
stream_dict = state.streams
parsed_dict = json.loads(raw_body)
valid_fields = {f.name for f in StreamRequestBody.__dataclass_fields__.values()}
@@ -511,7 +516,7 @@ class WebrtcdHandler(BaseHTTPRequestHandler):
services = parse_qs(parsed.query).get("services", [""])[0]
result = self._run(handle_get_schema(self.server.state, services))
elif parsed.path == "/stream":
- result = self._run(handle_get_stream(self.server.state, self._read_body()))
+ result = self._run(handle_get_stream(self.server.state, self._read_body(), self.headers.get_content_type()))
else: # /notify
try:
payload = json.loads(self._read_body())
@@ -614,7 +619,7 @@ def webrtcd_thread(host: str, port: int):
def main():
parser = argparse.ArgumentParser(description="WebRTC daemon")
- parser.add_argument("--host", type=str, default="0.0.0.0", help="Host to listen on")
+ parser.add_argument("--host", type=str, default="127.0.0.1", help="Host to listen on")
parser.add_argument("--port", type=int, default=5001, help="Port to listen on")
args = parser.parse_args()