this is my laboratory

This commit is contained in:
firestar5683
2026-09-04 21:56:35 -05:00
parent f55ad9162d
commit b5ab54ab6d
65 changed files with 4053 additions and 584 deletions
+3 -1
View File
@@ -510,6 +510,9 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> 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<std::string, ParamKeyAttributes> 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, "", ""}},
+34 -6
View File
@@ -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:
@@ -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)
@@ -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
@@ -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
@@ -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)
@@ -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:
+2 -10
View File
@@ -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)
+1 -3
View File
@@ -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
@@ -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",
@@ -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
@@ -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]) + \
+4 -2
View File
@@ -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)
@@ -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
@@ -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
@@ -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
+2 -35
View File
@@ -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) {
@@ -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;
}
}
@@ -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
@@ -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 ----
+590
View File
@@ -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())
+25 -1
View File
@@ -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"
}
}
+6 -3
View File
@@ -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)
+44
View File
@@ -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):
+6 -1
View File
@@ -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
@@ -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:
@@ -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
+367 -59
View File
@@ -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,
@@ -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",
}
@@ -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
@@ -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="",
@@ -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:
+205 -73
View File
@@ -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
@@ -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"
+9 -34
View File
@@ -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]
+43 -33
View File
@@ -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
@@ -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",
+205
View File
@@ -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),
}
+245
View File
@@ -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]
+1 -2
View File
@@ -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 = {
+1 -5
View File
@@ -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,
+160
View File
@@ -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,
}
@@ -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)
+12 -1
View File
@@ -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)
@@ -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),
@@ -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" },
@@ -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) {
<div class="ds-stepper">
<button
class="ds-stepper-btn"
disabled="${() => isLocked() || !canDecrease || false}"
disabled="${() => isLocked() || isNumericUpdating(p.key) || !canStepNumericParam(p, -1)}"
@click="${() => stepNumericParam(p, -1)}">-</button>
<div class="ds-stepper-meta">
<span>${formatSliderValue(bounds.min, String(bounds.step), p.precision, p.key)} to ${formatSliderValue(bounds.max, String(bounds.step), p.precision, p.key)}</span>
@@ -1658,7 +1671,7 @@ function renderSettingRow(p) {
</div>
<button
class="ds-stepper-btn"
disabled="${() => isLocked() || !canIncrease || false}"
disabled="${() => isLocked() || isNumericUpdating(p.key) || !canStepNumericParam(p, 1)}"
@click="${() => stepNumericParam(p, 1)}">+</button> </div>
`
})()}
@@ -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;
}
}
@@ -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`
<div class="ml-model">
<div>
<strong>${model.label}</strong>
<div class="ml-muted">${model.value} · ${model.series || "Unknown series"}</div>
</div>
<div class="ml-chips">
<span class="ml-chip">${model.version || "unknown version"}</span>
<span class="ml-chip">${model.modelSize || "small"}</span>
<span class="ml-chip ${model.modelLabArtifactInstalled ? "ml-chip-good" : "ml-chip-warning"}">
${artifactStatus}
</span>
${model.modelLabArtifactAvailable && !model.modelLabArtifactInstalled ? html`
<button class="ml-button" data-ml-download="${model.value}" disabled="${() => state.saving || state.isOnroad}">
Prepare for Chestnut
</button>
` : ""}
</div>
</div>
`
}
export function ModelLaboratory() {
if (!initialized) {
initialized = true
refresh()
}
ensurePolling()
setTimeout(bindControls, 0)
return html`
<div class="ml-wrapper">
<header class="ml-hero">
<div>
<div class="ml-kicker">Chestnut experiment</div>
<h2>Model Laboratory</h2>
<p>Use the lateral judgment of one small model and the longitudinal judgment of another.</p>
</div>
<div class="ml-chips">
<span class="ml-chip ${() => state.chestnutReady ? "ml-chip-good" : "ml-chip-warning"}">
${() => state.chestnutReady ? "Chestnut ready" : "Chestnut required"}
</span>
<span class="ml-chip ${() => state.isOnroad ? "ml-chip-warning" : "ml-chip-good"}">
${() => state.isOnroad ? "Onroad · locked" : "Parked · configurable"}
</span>
</div>
</header>
${() => state.error ? html`<div class="ml-alert ml-alert-error">${state.error}</div>` : ""}
${() => state.message ? html`<div class="ml-alert ml-alert-good">${state.message}</div>` : ""}
${() => state.loading ? html`<div class="ml-card">Loading laboratory status…</div>` : ""}
${() => !state.loading ? html`
<section class="ml-card">
<div class="ml-card-heading">
<div>
<h3>Compose a pair</h3>
<p>Both precompiled small models stay resident and run every camera frame on Chestnut's AMD GPU.</p>
</div>
<span class="ml-state ${() => state.configuration.enabled ? "is-enabled" : ""}">
${() => state.configuration.enabled ? "Enabled" : "Disabled"}
</span>
</div>
<div class="ml-pair">
<label>
<span>Lateral model</span>
<small>Path shape, curvature, lane geometry, and driving desire</small>
<select id="ml-lateral-model" class="ml-select">
<option value="">Choose a model</option>
${() => candidateModels("lateral").map(model => html`
<option value="${model.value}">
${model.label} · ${model.version}
</option>
`)}
</select>
</label>
<div class="ml-plus">+</div>
<label>
<span>Longitudinal model</span>
<small>Speed, acceleration, stopping, leads, and scene confidence</small>
<select id="ml-longitudinal-model" class="ml-select">
<option value="">Choose a model</option>
${() => candidateModels("longitudinal").map(model => html`
<option value="${model.value}">
${model.label} · ${model.version}
</option>
`)}
</select>
</label>
</div>
<div class="ml-preview">
<b>${() => modelLabel(state.configuration.lateralModel)}</b>
<span>steers</span>
<i class="bi bi-arrow-left-right"></i>
<b>${() => modelLabel(state.configuration.longitudinalModel)}</b>
<span>paces</span>
</div>
${() => selectionError() ? html`<p class="ml-validation">${selectionError()}</p>` : ""}
<div class="ml-actions">
<button id="ml-enable" class="ml-button ml-button-primary" disabled="${() => state.saving || Boolean(selectionError())}">
Enable for next drive
</button>
<button id="ml-disable" class="ml-button" disabled="${() => state.saving || state.isOnroad || !state.configuration.enabled}">
Disable
</button>
<button id="ml-refresh" class="ml-button" disabled="${() => state.saving}">Refresh</button>
</div>
</section>
<section class="ml-card">
<div class="ml-card-heading">
<div>
<h3>Runtime</h3>
<p>The configuration activates when modeld starts for a drive.</p>
</div>
<span class="ml-state ${() => state.runtime?.active ? "is-enabled" : ""}">
${() => state.runtime?.active ? "Pair active" : state.runtime?.requested ? "Pair requested" : "Inactive"}
</span>
</div>
<div class="ml-runtime-grid">
<div><span>Lateral</span><b>${() => modelLabel(state.runtime?.lateralModel)}</b></div>
<div><span>Longitudinal</span><b>${() => modelLabel(state.runtime?.longitudinalModel)}</b></div>
</div>
${() => state.runtime?.error ? html`<div class="ml-alert ml-alert-error">${state.runtime.error}</div>` : ""}
<p class="ml-muted">Both roles evaluate the same frame at 20 Hz. A runtime failure suppresses that frame and falls back to the built-in QCOM model.</p>
</section>
<section class="ml-card">
<div class="ml-card-heading">
<div>
<h3>Small-model readiness</h3>
<p>${state.summary.ready || 0} AMD-ready · ${state.summary.published || 0} published · ${state.summary.eligible || 0} eligible small models.</p>
</div>
<span class="ml-chip">Manifest ${state.manifest.version || "unknown"}</span>
</div>
<div class="ml-model-list">${() => state.models.map(renderModel)}</div>
<div class="ml-note">
Model Manager downloads the manifest's precompiled AMD variants. Nothing is compiled on the comma.
A normal installed model may still need its separate Chestnut artifact.
</div>
</section>
<section class="ml-findings">
<article class="ml-card">
<h3>Manifest shortcomings</h3>
<ul>${(state.manifest.shortcomings || []).map(item => html`<li>${item}</li>`)}</ul>
</article>
<article class="ml-card">
<h3>Opportunities</h3>
<ul>${(state.manifest.opportunities || []).map(item => html`<li>${item}</li>`)}</ul>
</article>
</section>
` : ""}
</div>
`
}
@@ -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;
@@ -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() {
</p>
<button class="toggle-control-button" @click="${backupToggles}">Backup Toggles</button>
<button class="toggle-control-button" @click="${triggerRestorePrompt}">Restore Toggles</button>
<div class="toggle-profile-section">
<div class="toggle-control-title">Settings Profiles</div>
<p class="toggle-control-text">
Keep two local configurations for different vehicles, drivers, or troubleshooting. Pairing and sensitive device data are not included.
</p>
${() => state.profileIsOnroad ? html`<p class="toggle-profile-warning">Park the vehicle to save or load a profile.</p>` : ""}
${() => state.profiles.map(profile => html`
<div class="toggle-profile-row">
<div class="toggle-profile-heading">
<strong>${profile.label}</strong>
<span>${profile.invalid ? "Damaged" : profile.saved ? `${profile.settingsCount} settings` : "Empty"}</span>
</div>
<div class="toggle-profile-actions">
<button
class="toggle-control-button"
@click="${() => confirmProfileAction(profile, "save")}"
disabled="${() => !!state.profileBusy || state.profileIsOnroad}">
${() => state.profileBusy === `save-${profile.slot}` ? "Saving..." : profile.saved ? "Overwrite" : "Save Current"}
</button>
<button
class="toggle-control-button"
@click="${() => confirmProfileAction(profile, "load")}"
disabled="${() => !!state.profileBusy || state.profileIsOnroad || !profile.saved || profile.invalid}">
${() => state.profileBusy === `load-${profile.slot}` ? "Loading..." : "Load"}
</button>
</div>
</div>
`)}
</div>
</section>
<section class="toggle-control-widget" style="margin-left: 1.5rem">
@@ -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"
}) : ""}
`
}
@@ -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" }) },
@@ -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" },
@@ -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 }
}
@@ -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 = {
</GalaxySection>
<GalaxySection title="Backup & Restore" icon="bi-arrow-repeat" :collapsible="false">
<div style="padding: var(--sp-3);">
<h4 style="margin:0 0 4px;">Settings Profiles</h4>
<p class="gx-note" style="margin:0 0 10px;">Keep two local configurations for different vehicles, drivers, or troubleshooting. Profiles never include pairing or sensitive device data.</p>
<GxNotice v-if="isOnroad" text="Park the vehicle to save or load a profile." style="margin-bottom:12px;" />
<div style="display:grid; grid-template-columns:repeat(auto-fit,minmax(240px,1fr)); gap:10px; margin-bottom:16px;">
<div v-for="profile in profiles" :key="profile.slot" class="gx-card" style="padding:12px;">
<div style="display:flex; align-items:center; justify-content:space-between; gap:8px; margin-bottom:10px;">
<strong>{{ profile.label }}</strong>
<span class="gx-chip">{{ profile.invalid ? 'Damaged' : profile.saved ? profile.settingsCount + ' settings' : 'Empty' }}</span>
</div>
<div style="display:flex; gap:8px; flex-wrap:wrap;">
<button type="button" class="gx-btn gx-btn--tonal" :disabled="!!profileBusy || isOnroad" @click="saveProfile(profile)">
<i class="bi bi-save"></i> {{ profileBusy === 'save-' + profile.slot ? 'Saving...' : profile.saved ? 'Overwrite' : 'Save Current' }}
</button>
<button type="button" class="gx-btn" :disabled="!!profileBusy || isOnroad || !profile.saved || profile.invalid" @click="loadProfile(profile)">
<i class="bi bi-arrow-down-circle"></i> {{ profileBusy === 'load-' + profile.slot ? 'Loading...' : 'Load' }}
</button>
</div>
</div>
</div>
</div>
<div style="padding: var(--sp-3); display:flex; gap:8px; flex-wrap:wrap;">
<button type="button" class="gx-btn" @click="backupToggles"><i class="bi bi-download"></i> Backup Toggles</button>
<button type="button" class="gx-btn gx-btn--tonal" @click="$refs.restoreInput.click()"><i class="bi bi-upload"></i> Restore Toggles</button>
@@ -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",
@@ -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" },
@@ -30,6 +30,7 @@
<link rel="stylesheet" href="/assets/components/tools/error_logs.css">
<link rel="stylesheet" href="/assets/components/tools/maps.css">
<link rel="stylesheet" href="/assets/components/tools/model_manager.css">
<link rel="stylesheet" href="/assets/components/tools/model_laboratory.css?v=model-lab-1">
<link rel="stylesheet" href="/assets/components/tools/plots.css">
<link rel="stylesheet" href="/assets/components/tools/speed_limits.css">
<link rel="stylesheet" href="/assets/components/tools/theme_maker.css">
@@ -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
@@ -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")
@@ -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
@@ -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",
+265 -23
View File
@@ -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/<slot>/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/<slot>/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():
+8 -1
View File
@@ -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()
+9 -4
View File
@@ -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()