From 3fb2bcdcee208d5621957690dadc73db4b3593ec Mon Sep 17 00:00:00 2001 From: firestar5683 <168790843+firestar5683@users.noreply.github.com> Date: Sun, 30 Aug 2026 20:59:58 -0500 Subject: [PATCH] 60kW ?! k --- common/params_keys.h | 1 + modelgrab | 5 + .../opendbc/car/hyundai/tests/test_hyundai.py | 14 + opendbc_repo/opendbc/car/hyundai/values.py | 8 +- .../opendbc/car/subaru/carcontroller.py | 63 +- opendbc_repo/opendbc/car/subaru/carstate.py | 12 +- opendbc_repo/opendbc/car/subaru/interface.py | 4 +- opendbc_repo/opendbc/car/subaru/subarucan.py | 25 + .../opendbc/car/subaru/tests/test_subaru.py | 62 ++ opendbc_repo/opendbc/car/subaru/values.py | 5 + opendbc_repo/opendbc/car/volvo/carstate.py | 6 +- .../generator/subaru/subaru_global_2017.dbc | 5 + .../dbc/subaru_global_2017_generated.dbc | 5 + opendbc_repo/opendbc/safety/modes/subaru.h | 37 +- opendbc_repo/opendbc/safety/modes/volvo.h | 2 +- .../opendbc/safety/tests/test_subaru.py | 15 + .../opendbc/safety/tests/test_volvo.py | 15 +- scripts/MODEL_RELEASE.md | 57 ++ scripts/model_release.py | 786 ++++++++++++++++++ scripts/tests/test_model_release.py | 96 +++ .../common/assets/device_settings_layout.json | 10 + starpilot/common/safe_mode.py | 2 + starpilot/common/starpilot_variables.py | 5 +- .../the_galaxy/assets/components/router.js | 2 +- .../assets/components/tools/bluetooth.js | 88 +- .../components/tools/device_settings.js | 1 + .../tests/test_frontend_module_graph.py | 5 + .../tests/test_navigation_params.py | 1 + starpilot/system/the_galaxy/the_galaxy.py | 4 + 29 files changed, 1302 insertions(+), 39 deletions(-) create mode 100755 modelgrab create mode 100644 scripts/MODEL_RELEASE.md create mode 100755 scripts/model_release.py create mode 100644 scripts/tests/test_model_release.py diff --git a/common/params_keys.h b/common/params_keys.h index 7f8c40170..92e72ec83 100644 --- a/common/params_keys.h +++ b/common/params_keys.h @@ -676,6 +676,7 @@ inline static std::unordered_map keys = { {"SubaruSNG", {PERSISTENT, BOOL, "1", "0", 2, SETTINGS_SIMPLE}}, {"SubaruSNGManualParkingBrake", {PERSISTENT, BOOL, "0", "0", 2, SETTINGS_SIMPLE}}, {"SubaruStopStartOff", {PERSISTENT, BOOL, "0", "0", 2, SETTINGS_SIMPLE}}, + {"SubaruAvhOnAtStartup", {PERSISTENT, BOOL, "0", "0", 2, SETTINGS_SIMPLE}}, {"TacoTune", {PERSISTENT, BOOL, "0", "0", 2}}, {"TeslaCoopSteering", {PERSISTENT, BOOL, "0", "0", 2, SETTINGS_SIMPLE}}, {"TestAlert", {CLEAR_ON_MANAGER_START, STRING, "", ""}}, diff --git a/modelgrab b/modelgrab new file mode 100755 index 000000000..c63f5419f --- /dev/null +++ b/modelgrab @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +exec python3 "$DIR/scripts/model_release.py" "$@" diff --git a/opendbc_repo/opendbc/car/hyundai/tests/test_hyundai.py b/opendbc_repo/opendbc/car/hyundai/tests/test_hyundai.py index 2024b2f53..e248faa3d 100644 --- a/opendbc_repo/opendbc/car/hyundai/tests/test_hyundai.py +++ b/opendbc_repo/opendbc/car/hyundai/tests/test_hyundai.py @@ -128,6 +128,20 @@ def get_test_toggles() -> SimpleNamespace: class TestHyundaiFingerprint: + def test_carnival_hev_low_speed_torque_rate_limits(self): + CP = CarInterface.get_params(CAR.KIA_CARNIVAL_HEV_4TH_GEN, gen_empty_fingerprint(), [], + False, False, False, None) + carnival_2025_cp = CarInterface.get_params(CAR.KIA_CARNIVAL_2025, gen_empty_fingerprint(), [], + False, False, False, None) + + low_speed = CarControllerParams(CP, 10.0) + high_speed = CarControllerParams(CP, 20.0) + carnival_2025_low_speed = CarControllerParams(carnival_2025_cp, 10.0) + + assert (low_speed.STEER_DELTA_UP, low_speed.STEER_DELTA_DOWN) == (2, 3) + assert (high_speed.STEER_DELTA_UP, high_speed.STEER_DELTA_DOWN) == (2, 3) + assert (carnival_2025_low_speed.STEER_DELTA_UP, carnival_2025_low_speed.STEER_DELTA_DOWN) == (10, 8) + @pytest.mark.parametrize("candidate", (CAR.KIA_CARNIVAL_4TH_GEN, CAR.KIA_CARNIVAL_2025, CAR.KIA_CARNIVAL_HEV_4TH_GEN)) def test_carnival_uses_clean_canfd_lfa_status(self, candidate): assert not preserve_stock_canfd_lfa_status(candidate) diff --git a/opendbc_repo/opendbc/car/hyundai/values.py b/opendbc_repo/opendbc/car/hyundai/values.py index 8ded9ecf5..46320a59a 100644 --- a/opendbc_repo/opendbc/car/hyundai/values.py +++ b/opendbc_repo/opendbc/car/hyundai/values.py @@ -45,8 +45,12 @@ class CarControllerParams: self.STEER_DRIVER_MULTIPLIER = 2 self.STEER_THRESHOLD = 100 if vEgoRaw < 15.0: # below ~34 mph - more aggressive for tight turns - self.STEER_DELTA_UP = 10 - self.STEER_DELTA_DOWN = 8 + if CP.carFingerprint == CAR.KIA_CARNIVAL_HEV_4TH_GEN: + self.STEER_DELTA_UP = 2 + self.STEER_DELTA_DOWN = 3 + else: + self.STEER_DELTA_UP = 10 + self.STEER_DELTA_DOWN = 8 else: self.STEER_DELTA_UP = 2 self.STEER_DELTA_DOWN = 3 diff --git a/opendbc_repo/opendbc/car/subaru/carcontroller.py b/opendbc_repo/opendbc/car/subaru/carcontroller.py index bde3669b9..bbde849b9 100644 --- a/opendbc_repo/opendbc/car/subaru/carcontroller.py +++ b/opendbc_repo/opendbc/car/subaru/carcontroller.py @@ -4,7 +4,7 @@ from opendbc.car import Bus, DT_CTRL, make_tester_present_msg, structs from opendbc.car.lateral import apply_driver_steer_torque_limits, apply_std_steer_angle_limits, apply_steer_angle_limits_vm, common_fault_avoidance from opendbc.car.interfaces import CarControllerBase from opendbc.car.subaru import subarucan -from opendbc.car.subaru.values import CAR, DBC, GLOBAL_ES_ADDR, SUBARU_STOP_START_CARS, CanBus, CarControllerParams, SubaruFlags +from opendbc.car.subaru.values import CAR, DBC, GLOBAL_ES_ADDR, SUBARU_AVH_CARS, 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,6 +37,10 @@ _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_FRAMES = _STOP_START_PULSE_FRAMES +_AVH_PULSE_PERIOD_FRAMES = _STOP_START_PULSE_PERIOD_FRAMES def get_safety_CP(): @@ -87,6 +91,10 @@ 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_request_frame = 0 + self.avh_counter = 0 def _stop_start_off_request(self, CC, CS, starpilot_toggles): """Send one bounded Subaru Stop/Start OFF request after ignition. @@ -143,6 +151,55 @@ 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 one bounded Subaru AVH ON request 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_request_frame = self.frame + self.avh_counter = (int(avh_msg.get("COUNTER", 0)) + 1) % 0x10 + + elapsed = self.frame - self.avh_request_frame + if elapsed >= _AVH_PULSE_FRAMES: + self.avh_attempted = True + return None + + if elapsed % _AVH_PULSE_PERIOD_FRAMES != 0: + return None + + msg = subarucan.create_avh_control( + self.packer, avh_msg, raw_dat=avh_dat, + counter=self.avh_counter, bus=CanBus.alt_for_cp(self.CP), + ) + self.avh_counter = (self.avh_counter + 1) % 0x10 + return msg + def _reset_legacy_2025_handoff(self): self.driver_override = False self.angle_override_confirm_frames = 0 @@ -416,6 +473,10 @@ class CarController(CarControllerBase): if stop_start_msg is not None: can_sends.append(stop_start_msg) + avh_msg = self._avh_on_request(CC, CS, starpilot_toggles) + if avh_msg is not None: + can_sends.append(avh_msg) + # *** steering *** if (self.frame % self.p.STEER_STEP) == 0: if self.CP.flags & SubaruFlags.LKAS_ANGLE: diff --git a/opendbc_repo/opendbc/car/subaru/carstate.py b/opendbc_repo/opendbc/car/subaru/carstate.py index cee89d2df..985ef31f0 100644 --- a/opendbc_repo/opendbc/car/subaru/carstate.py +++ b/opendbc_repo/opendbc/car/subaru/carstate.py @@ -4,7 +4,7 @@ from opendbc.can import CANDefine, CANParser from opendbc.car import Bus, structs from opendbc.car.common.conversions import Conversions as CV from opendbc.car.interfaces import CarStateBase -from opendbc.car.subaru.values import DBC, CanBus, SUBARU_STOP_START_CARS, SubaruFlags +from opendbc.car.subaru.values import DBC, CanBus, SUBARU_AVH_CARS, SUBARU_STOP_START_CARS, SubaruFlags from opendbc.car import CanSignalRateCalculator @@ -18,6 +18,8 @@ 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] @@ -33,6 +35,11 @@ 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: @@ -156,10 +163,11 @@ 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], [], CanBus.alt_for_cp(CP)) + Bus.alt: CANParser(DBC[CP.carFingerprint][Bus.pt], avh_messages, CanBus.alt_for_cp(CP)) } if CP.flags & SubaruFlags.D_PLATFORM: parsers[Bus.main] = CANParser(DBC[CP.carFingerprint][Bus.pt], [], CanBus.main) diff --git a/opendbc_repo/opendbc/car/subaru/interface.py b/opendbc_repo/opendbc/car/subaru/interface.py index 741551a0a..35f04d2b3 100644 --- a/opendbc_repo/opendbc/car/subaru/interface.py +++ b/opendbc_repo/opendbc/car/subaru/interface.py @@ -3,7 +3,7 @@ from opendbc.car.disable_ecu import disable_ecu from opendbc.car.interfaces import CarInterfaceBase from opendbc.car.subaru.carcontroller import CarController from opendbc.car.subaru.carstate import CarState -from opendbc.car.subaru.values import CAR, CanBus, GLOBAL_ES_ADDR, SUBARU_STOP_START_CARS, SubaruFlags, SubaruSafetyFlags +from opendbc.car.subaru.values import CAR, CanBus, GLOBAL_ES_ADDR, SUBARU_AVH_CARS, SUBARU_STOP_START_CARS, SubaruFlags, SubaruSafetyFlags class CarInterface(CarInterfaceBase): @@ -42,6 +42,8 @@ class CarInterface(CarInterfaceBase): ret.safetyConfigs[0].safetyParam |= SubaruSafetyFlags.D_PLATFORM_CAMERA.value if candidate in SUBARU_STOP_START_CARS: ret.safetyConfigs[0].safetyParam |= SubaruSafetyFlags.STOP_START_BUTTON.value + if candidate in SUBARU_AVH_CARS: + ret.safetyConfigs[0].safetyParam |= SubaruSafetyFlags.AVH_BUTTON.value if candidate in (CAR.SUBARU_LEGACY_2025, CAR.SUBARU_ASCENT_2023): ret.safetyConfigs[0].safetyParam |= SubaruSafetyFlags.FIXED_ANGLE_LIMITS.value diff --git a/opendbc_repo/opendbc/car/subaru/subarucan.py b/opendbc_repo/opendbc/car/subaru/subarucan.py index 1d2d04d2e..6e53ccbc9 100644 --- a/opendbc_repo/opendbc/car/subaru/subarucan.py +++ b/opendbc_repo/opendbc/car/subaru/subarucan.py @@ -208,6 +208,31 @@ def create_stop_start_control(packer, dashlights_msg, raw_dat=None, counter=None return packer.make_can_msg("Dashlights", bus, values) +def create_avh_control(packer, avh_msg, raw_dat=None, counter=None, bus=CanBus.alt): + """Create the supported Subaru Legacy AVH ON request. + + AVH is carried in the live 0x32b frame. Preserve the other bytes and update + only the rolling counter, AVH bit, and Subaru additive checksum. + """ + if raw_dat: + dat = bytearray(raw_dat) + if len(dat) != 8: + raise ValueError(f"AVH frame must be 8 bytes, got {len(dat)}") + if counter is None: + counter = (int(avh_msg.get("COUNTER", 0)) + 1) % 0x10 + dat[1] = (dat[1] & 0xF0) | (counter % 0x10) + dat[5] |= 0x20 # AVH, big-endian bit 45 + dat[0] = ((0x32B & 0xFF) + ((0x32B >> 8) & 0xFF) + sum(dat[1:])) & 0xFF + return 0x32B, bytes(dat), bus + + values = dict(avh_msg) + if counter is None: + counter = (int(values.get("COUNTER", 0)) + 1) % 0x10 + values["COUNTER"] = counter % 0x10 + values["AVH"] = 1 + return packer.make_can_msg("AVH", bus, values) + + def create_es_brake(packer, frame, es_brake_msg, long_enabled, long_active, brake_value, bus=CanBus.main): values = {s: es_brake_msg[s] for s in [ "CHECKSUM", diff --git a/opendbc_repo/opendbc/car/subaru/tests/test_subaru.py b/opendbc_repo/opendbc/car/subaru/tests/test_subaru.py index d6ce08e54..1d1e78c17 100644 --- a/opendbc_repo/opendbc/car/subaru/tests/test_subaru.py +++ b/opendbc_repo/opendbc/car/subaru/tests/test_subaru.py @@ -194,6 +194,7 @@ def test_outback_2023_uses_d_platform_bus_layout(): assert CP.flags & SubaruFlags.D_PLATFORM assert CP.safetyConfigs[0].safetyParam & SubaruSafetyFlags.D_PLATFORM assert CP.safetyConfigs[0].safetyParam & SubaruSafetyFlags.STOP_START_BUTTON + assert not (CP.safetyConfigs[0].safetyParam & SubaruSafetyFlags.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 @@ -224,6 +225,21 @@ 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), @@ -276,6 +292,51 @@ 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_is_bounded(): + 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, + ), + ) + toggles = SimpleNamespace(subaru_stop_start_off=False, subaru_avh_on=True, subaru_sng=False) + + _, 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 = 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) @@ -287,6 +348,7 @@ def test_legacy_2025_uses_gen2_angle_bus_layout(): assert not (CP.safetyConfigs[0].safetyParam & SubaruSafetyFlags.D_PLATFORM_CAMERA) assert CP.safetyConfigs[0].safetyParam & SubaruSafetyFlags.FIXED_ANGLE_LIMITS assert CP.safetyConfigs[0].safetyParam & SubaruSafetyFlags.STOP_START_BUTTON + assert CP.safetyConfigs[0].safetyParam & SubaruSafetyFlags.AVH_BUTTON assert CanBus.main_for_cp(CP) == CanBus.main assert CanBus.angle_for_cp(CP) == CanBus.main assert parsers[Bus.pt].bus == CanBus.main diff --git a/opendbc_repo/opendbc/car/subaru/values.py b/opendbc_repo/opendbc/car/subaru/values.py index b766791a9..c7caa7d69 100644 --- a/opendbc_repo/opendbc/car/subaru/values.py +++ b/opendbc_repo/opendbc/car/subaru/values.py @@ -89,6 +89,7 @@ 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 @@ -275,6 +276,10 @@ 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) diff --git a/opendbc_repo/opendbc/car/volvo/carstate.py b/opendbc_repo/opendbc/car/volvo/carstate.py index f1423e43b..cab17ee8e 100644 --- a/opendbc_repo/opendbc/car/volvo/carstate.py +++ b/opendbc_repo/opendbc/car/volvo/carstate.py @@ -10,6 +10,8 @@ TransmissionType = structs.CarParams.TransmissionType # main-bus SPEED (0x60) is raw counts in the DBC; measured against GPS ground speed. # 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,7 +77,9 @@ class CarState(CarStateBase): # 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 - ret.steeringPressed = abs(cp_party.vl['DRIVER_INPUT']['STEERING_DRIVER_INPUT']) > 2 + driver_input = abs(cp_party.vl['DRIVER_INPUT']['STEERING_DRIVER_INPUT']) + ret.steeringPressed = driver_input > STEERING_PRESSED_THRESHOLD + ret.steeringDisengage = driver_input > STEERING_DISENGAGE_THRESHOLD # EPS status - placeholder until actual signal is found self.eps_active = True # Assume EPS is active for now diff --git a/opendbc_repo/opendbc/dbc/generator/subaru/subaru_global_2017.dbc b/opendbc_repo/opendbc/dbc/generator/subaru/subaru_global_2017.dbc index 83a36ff8f..ce7001f60 100644 --- a/opendbc_repo/opendbc/dbc/generator/subaru/subaru_global_2017.dbc +++ b/opendbc_repo/opendbc/dbc/generator/subaru/subaru_global_2017.dbc @@ -1,5 +1,10 @@ CM_ "IMPORT _subaru_global.dbc"; +BO_ 811 AVH: 8 XXX + SG_ CHECKSUM : 0|8@1+ (1,0) [0|255] "" XXX + SG_ COUNTER : 8|4@1+ (1,0) [0|15] "" XXX + SG_ AVH : 45|1@0+ (1,0) [0|1] "" XXX + BO_ 72 Transmission: 8 XXX SG_ CHECKSUM : 0|8@1+ (1,0) [0|255] "" XXX SG_ COUNTER : 8|4@1+ (1,0) [0|15] "" XXX diff --git a/opendbc_repo/opendbc/dbc/subaru_global_2017_generated.dbc b/opendbc_repo/opendbc/dbc/subaru_global_2017_generated.dbc index 9732feae1..7ec223270 100644 --- a/opendbc_repo/opendbc/dbc/subaru_global_2017_generated.dbc +++ b/opendbc_repo/opendbc/dbc/subaru_global_2017_generated.dbc @@ -307,6 +307,11 @@ VAL_ 544 AEB_Status 12 "AEB related" 8 "AEB actuation" 4 "AEB related" 0 "No AEB CM_ "subaru_global_2017.dbc starts here"; +BO_ 811 AVH: 8 XXX + SG_ CHECKSUM : 0|8@1+ (1,0) [0|255] "" XXX + SG_ COUNTER : 8|4@1+ (1,0) [0|15] "" XXX + SG_ AVH : 45|1@0+ (1,0) [0|1] "" XXX + BO_ 72 Transmission: 8 XXX SG_ CHECKSUM : 0|8@1+ (1,0) [0|255] "" XXX SG_ COUNTER : 8|4@1+ (1,0) [0|15] "" XXX diff --git a/opendbc_repo/opendbc/safety/modes/subaru.h b/opendbc_repo/opendbc/safety/modes/subaru.h index ba5f05845..726212f46 100644 --- a/opendbc_repo/opendbc/safety/modes/subaru.h +++ b/opendbc_repo/opendbc/safety/modes/subaru.h @@ -42,6 +42,7 @@ #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 @@ -65,6 +66,13 @@ #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}, \ @@ -113,6 +121,7 @@ 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]; @@ -297,6 +306,13 @@ 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; } @@ -347,6 +363,12 @@ 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) @@ -358,6 +380,12 @@ 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) @@ -405,6 +433,9 @@ 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); @@ -412,10 +443,12 @@ static safety_config subaru_init(uint16_t param) { safety_config ret; if (subaru_lkas_angle) { - 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) : \ + 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)) : \ (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 ? BUILD_SAFETY_CFG(subaru_gen2_lkas_angle_rx_checks, SUBARU_GEN2_LKAS_ANGLE_STOP_START_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)) : \ BUILD_SAFETY_CFG(subaru_gen2_lkas_angle_rx_checks, SUBARU_GEN2_LKAS_ANGLE_TX_MSGS)) : \ BUILD_SAFETY_CFG(subaru_lkas_angle_rx_checks, SUBARU_LKAS_ANGLE_TX_MSGS); } else if (subaru_gen2) { diff --git a/opendbc_repo/opendbc/safety/modes/volvo.h b/opendbc_repo/opendbc/safety/modes/volvo.h index 7cc5b0992..0c4576de1 100644 --- a/opendbc_repo/opendbc/safety/modes/volvo.h +++ b/opendbc_repo/opendbc/safety/modes/volvo.h @@ -43,7 +43,7 @@ #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 2 +#define VOLVO_DRIVER_OVERRIDE 5 // CAN bus definitions for Volvo diff --git a/opendbc_repo/opendbc/safety/tests/test_subaru.py b/opendbc_repo/opendbc/safety/tests/test_subaru.py index 826c1d3c2..ebd7b1bf7 100755 --- a/opendbc_repo/opendbc/safety/tests/test_subaru.py +++ b/opendbc_repo/opendbc/safety/tests/test_subaru.py @@ -37,6 +37,7 @@ class SubaruMsg(enum.IntEnum): ES_STATIC_1 = 0x22a ES_STATIC_2 = 0x325 Dashlights = 0x390 + AVH = 0x32b SUBARU_MAIN_BUS = 0 @@ -385,6 +386,20 @@ class TestSubaruGen2FixedAngleStopStartSafety(TestSubaruGen2FixedAngleSafety): self.assertFalse(self._tx(self._stop_start_msg(False))) +class TestSubaruGen2FixedAngleStopStartAvhSafety(TestSubaruGen2FixedAngleStopStartSafety): + FLAGS = TestSubaruGen2FixedAngleStopStartSafety.FLAGS | SubaruSafetyFlags.AVH_BUTTON + TX_MSGS = TestSubaruGen2FixedAngleStopStartSafety.TX_MSGS + [[SubaruMsg.AVH, SUBARU_ALT_BUS]] + + def _avh_msg(self, pressed): + return self.packer.make_can_msg_safety( + "AVH", SUBARU_ALT_BUS, {"COUNTER": 0, "AVH": pressed}, + ) + + def test_avh_tx_requires_pressed_bit(self): + self.assertTrue(self._tx(self._avh_msg(True))) + self.assertFalse(self._tx(self._avh_msg(False))) + + class TestSubaruDPlatformAngleSafety(TestSubaruStockLongitudinalSafetyBase, TestSubaruAngleSafetyBase): FLAGS = SubaruSafetyFlags.GEN2 | SubaruSafetyFlags.LKAS_ANGLE | SubaruSafetyFlags.D_PLATFORM ALT_MAIN_BUS = SUBARU_ALT_BUS diff --git a/opendbc_repo/opendbc/safety/tests/test_volvo.py b/opendbc_repo/opendbc/safety/tests/test_volvo.py index 3716d63be..239f15fa2 100644 --- a/opendbc_repo/opendbc/safety/tests/test_volvo.py +++ b/opendbc_repo/opendbc/safety/tests/test_volvo.py @@ -201,10 +201,19 @@ class TestVolvoSafetyBase(common.CarSafetyTest): self.assertFalse(self._tx(invalid)) def test_driver_override_disengages_controls(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): + 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) - msg = self.mid_packer.make_can_msg_safety( - "DRIVER_INPUT", VOLVO_PARTY_BUS, {"STEERING_DRIVER_INPUT": 6}) - self._rx(msg) + self._rx(driver_input_msg(6)) self.assertFalse(self.safety.get_controls_allowed()) # ---- Volvo-specific consistency tests ---- diff --git a/scripts/MODEL_RELEASE.md b/scripts/MODEL_RELEASE.md new file mode 100644 index 000000000..32f43c362 --- /dev/null +++ b/scripts/MODEL_RELEASE.md @@ -0,0 +1,57 @@ +# Model Release Tool + +`./scripts/model_release.py` automates the single-supercombo release path: + +1. Paste the model bot message. +2. The tool parses the branch, ONNX path, model name, release date, model ID, and commit SHAs. +3. It scans every listed upstream commit for `tinygrad`, `modeld`, or Chestnut runtime changes. +4. It downloads the Git LFS object without loading it into shell variables. +5. It asks for the comma IP, transfers the ONNX, and runs the device compiler. +6. It verifies the returned PKL or multipart checksum. +7. It uploads `models//`, `onnx//`, and `manifests/` to Hugging Face. +8. It pushes GitHub one artifact part per commit and the manifest last. + +Run interactively: + +```sh +./scripts/model_release.py +``` + +Paste the complete bot message, press `Ctrl-D`, then enter the comma IP. The +default runtime behavior version is `v16`; the `Model vN` line is treated as +the model iteration/name, not the runtime behavior version. Override an +ambiguous ID with `--model-id`, or the runtime contract with +`--behavior-version v15`. + +For the normal model bot flow, a commit SHA alone is also accepted: + +```sh +./modelgrab f877d7a0ccc3cce943c76e285214c020cd65c899 +``` + +The tool resolves the commit's changed driving ONNX, associated pull-request +branch/title, GPU status, and commit date through GitHub. If GitHub cannot +associate a human model title with the SHA, it derives the name from the ONNX +filename; use `--model-id` when an exact local naming convention is required. + +Useful non-interactive form: + +```sh +./scripts/model_release.py \ + --text-file release-message.txt \ + --ip 192.168.3.110 +``` + +The tool refuses `192.168.3.109`. It also stops before downloading or compiling +if any supplied commit changes runtime code. Review the warning first, then +rerun with `--allow-runtime-changes` only after Firestar approves it. + +The default resources checkout is `~/StarPilot-Resources` on branch `Models`. +It must be clean and synchronized before starting. The default Hugging Face +bucket is `firestar4430/StarPilot-Resources`; authenticate with `hf auth login` +before use. Use `--dry-run` to test parsing and the runtime scan without +touching the device or either resource store. + +Failed runs leave the source, compiled parts, logs, and result JSON in the +workspace so the cause can be inspected. A rerun requires `--force` when the +same source path already exists. diff --git a/scripts/model_release.py b/scripts/model_release.py new file mode 100755 index 000000000..52b682c2e --- /dev/null +++ b/scripts/model_release.py @@ -0,0 +1,786 @@ +#!/usr/bin/env python3 +"""Release one upstream model through the device compiler and resource stores. + +The command is intentionally fail-closed when the supplied upstream commits +touch tinygrad or modeld runtime code. A model source update is safe to build +only after that runtime change has been reviewed separately. +""" + +from __future__ import annotations + +import argparse +import datetime as dt +import hashlib +import ipaddress +import json +import os +import re +import shlex +import shutil +import subprocess +import sys +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass +from typing import Any +from pathlib import Path + + +OPENPILOT_REPO = "commaai/openpilot" +RESOURCES_REPO = os.environ.get("STARPILOT_RESOURCES_REPO", "firestar5683/StarPilot-Resources") +HF_BUCKET = os.environ.get("STARPILOT_HF_BUCKET", "firestar4430/StarPilot-Resources") +RESOURCE_BRANCH = "Models" +MANIFEST_VERSION = "v24" +DEFAULT_BEHAVIOR_VERSION = "v16" +DEVICE_ROOT = "/data/openpilot" +REPOSITORY_FILE_LIMIT = 100_000_000 +LFS_POINTER_PREFIX = b"version https://git-lfs.github.com/spec/v1" +CHUNK_SUFFIX_RE = re.compile(r"\.p\d{2}$") +SHA_RE = re.compile(r"(? Path: + t5 = Path("/Volumes/T5") + if t5.is_dir(): + return t5 / "StarPilot-Model-Releases" + return Path.home() / "Desktop" / "StarPilot-Model-Releases" + + +def run(command: list[str], *, cwd: Path | None = None, capture: bool = False) -> subprocess.CompletedProcess: + print("$ " + " ".join(shlex.quote(part) for part in command)) + return subprocess.run( + command, + cwd=cwd, + check=True, + text=capture, + capture_output=capture, + ) + + +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 http_request(url: str, *, method: str = "GET", payload: bytes | None = None, headers: dict[str, str] | None = None): + request_headers = {"User-Agent": "StarPilot-model-release/1.0", **(headers or {})} + token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") + if token and "api.github.com" in url: + request_headers["Authorization"] = f"Bearer {token}" + request = urllib.request.Request(url, data=payload, headers=request_headers, method=method) + try: + return urllib.request.urlopen(request, timeout=30) + except urllib.error.HTTPError as error: + detail = error.read(500).decode("utf-8", errors="replace") + raise ReleaseError(f"HTTP {error.code} from {url}: {detail}") from error + except urllib.error.URLError as error: + raise ReleaseError(f"Unable to reach {url}: {error.reason}") from error + + +def get_json_value(url: str) -> Any: + with http_request(url, headers={"Accept": "application/vnd.github+json"}) as response: + try: + return json.loads(response.read().decode("utf-8")) + except json.JSONDecodeError as error: + raise ReleaseError(f"Invalid JSON response from {url}") from error + + +def get_json(url: str) -> dict: + payload = get_json_value(url) + if not isinstance(payload, dict): + raise ReleaseError(f"Unexpected JSON response from {url}") + return payload + + +def parse_lfs_pointer(data: bytes) -> tuple[str, int] | None: + if not data.startswith(LFS_POINTER_PREFIX): + return None + fields: dict[str, str] = {} + for line in data.decode("ascii", errors="strict").splitlines(): + if " " in line: + key, value = line.split(" ", 1) + fields[key] = value + oid = fields.get("oid", "").removeprefix("sha256:") + size = fields.get("size", "") + if not re.fullmatch(r"[0-9a-f]{64}", oid) or not size.isdigit(): + raise ReleaseError("Malformed Git LFS pointer") + return oid, int(size) + + +def stream_response(response, destination: Path, prefix: bytes = b"") -> tuple[int, str]: + digest = hashlib.sha256() + size = 0 + with destination.open("wb") as output: + if prefix: + output.write(prefix) + digest.update(prefix) + size += len(prefix) + for chunk in iter(lambda: response.read(1024 * 1024), b""): + output.write(chunk) + digest.update(chunk) + size += len(chunk) + return size, digest.hexdigest() + + +def download_lfs_object(oid: str, expected_size: int, ref: str, destination: Path) -> tuple[int, str]: + batch_url = f"https://github.com/{OPENPILOT_REPO}.git/info/lfs/objects/batch" + payload = json.dumps({ + "operation": "download", + "transfers": ["basic"], + "objects": [{"oid": oid, "size": expected_size}], + "ref": {"name": ref}, + }).encode("utf-8") + with http_request( + batch_url, + method="POST", + payload=payload, + headers={"Accept": "application/vnd.git-lfs+json", "Content-Type": "application/vnd.git-lfs+json"}, + ) as response: + batch = json.loads(response.read().decode("utf-8")) + + objects = batch.get("objects", []) if isinstance(batch, dict) else [] + if not objects or "error" in objects[0]: + raise ReleaseError(f"Git LFS download was not available for {oid}") + action = objects[0].get("actions", {}).get("download") + if not action or not action.get("href"): + raise ReleaseError(f"Git LFS returned no download action for {oid}") + headers = {str(key): str(value) for key, value in action.get("header", {}).items()} + with http_request(action["href"], headers=headers) as response: + size, digest = stream_response(response, destination) + if size != expected_size or digest != oid: + raise ReleaseError(f"LFS object verification failed: size {size}/{expected_size}, sha256 {digest}/{oid}") + return size, digest + + +def download_source(ref: str, git_path: str, destination: Path, force: bool) -> dict: + destination.parent.mkdir(parents=True, exist_ok=True) + if destination.exists() and not force: + raise ReleaseError(f"Source already exists: {destination}; use --force to fetch the requested commit") + + encoded_ref = urllib.parse.quote(ref, safe="/") + encoded_path = "/".join(urllib.parse.quote(part, safe="") for part in git_path.split("/")) + raw_url = f"https://raw.githubusercontent.com/{OPENPILOT_REPO}/{encoded_ref}/{encoded_path}" + temporary = destination.with_name(destination.name + ".part") + temporary.unlink(missing_ok=True) + + with http_request(raw_url) as response: + prefix = response.read(512) + pointer = parse_lfs_pointer(prefix) + if pointer is None: + size, digest = stream_response(response, temporary, prefix) + else: + temporary.unlink(missing_ok=True) + size, digest = download_lfs_object(pointer[0], pointer[1], ref, temporary) + + temporary.replace(destination) + print(f"Downloaded source: {size} bytes, sha256 {digest}") + return {"path": str(destination), "size": size, "sha256": digest, "url": raw_url, "ref": ref, "git_path": git_path} + + +def clean_markdown(value: str) -> str: + value = re.sub(r"\[([^]]+)\]\([^)]*\)", r"\1", value) + value = re.sub(r"[*_`]+", "", value) + return " ".join(value.split()).strip() + + +def slug_model_id(name: str) -> str: + tokens = re.findall(r"[a-z0-9]+", name.lower()) + return "".join(tokens) + + +def parse_release_date(text: str) -> str | None: + match = DATE_RE.search(text) + if not match: + return None + for fmt in ("%B %d, %Y", "%b %d, %Y"): + try: + return dt.datetime.strptime(match.group(1), fmt).date().isoformat() + except ValueError: + continue + return None + + +def commit_local_date(payload: dict) -> str: + commit_data = payload.get("commit", {}) + for author_key in ("committer", "author"): + timestamp = commit_data.get(author_key, {}).get("date") if isinstance(commit_data, dict) else None + if not timestamp: + continue + try: + return dt.datetime.fromisoformat(str(timestamp).replace("Z", "+00:00")).astimezone().date().isoformat() + except ValueError: + continue + return dt.date.today().isoformat() + + +def source_name_from_path(source_path: str) -> str: + stem = Path(source_path).stem + stem = re.sub(r"^(?:big_)?driving_supercombo$", "", stem, flags=re.IGNORECASE) + stem = re.sub(r"_driving_(?:supercombo|vision|policy)$", "", stem, flags=re.IGNORECASE) + stem = stem.strip("_-") + return stem.replace("_", " ").replace("-", " ").title() or "Model" + + +def select_model_source(files: list[Any], commit: str) -> str: + paths = [ + str(item.get("filename", "")) + for item in files + if isinstance(item, dict) and str(item.get("filename", "")).lower().endswith(".onnx") + ] + preferred = [path for path in paths if Path(path).name.lower().endswith("driving_supercombo.onnx")] + candidates = preferred or paths + if len(candidates) != 1: + if not candidates: + raise ReleaseError(f"Commit {commit} does not change a driving ONNX file") + raise ReleaseError(f"Commit {commit} changes multiple ONNX files; provide the full bot message to disambiguate") + return candidates[0] + + +def resolve_commit_release(commit: str, model_id_override: str | None, behavior_version: str) -> ReleaseInfo: + commit_payload = get_json(f"https://api.github.com/repos/{OPENPILOT_REPO}/commits/{commit}") + files = commit_payload.get("files", []) + if not isinstance(files, list): + raise ReleaseError(f"GitHub returned no file list for commit {commit}") + source_path = select_model_source(files, commit) + + branch = "" + display_name = "" + pulls = get_json_value(f"https://api.github.com/repos/{OPENPILOT_REPO}/commits/{commit}/pulls") + if isinstance(pulls, list): + for pull in pulls: + if not isinstance(pull, dict): + continue + head = pull.get("head", {}) + if isinstance(head, dict) and str(head.get("sha", "")).lower() == commit: + branch = str(head.get("ref") or "") + display_name = clean_markdown(str(pull.get("title") or "")) + break + + if not branch: + branches = get_json_value( + f"https://api.github.com/repos/{OPENPILOT_REPO}/commits/{commit}/branches-where-head" + ) + if isinstance(branches, list): + names = [str(item.get("name")) for item in branches if isinstance(item, dict) and item.get("name")] + branch = next((name for name in names if name.lower() != "master"), names[0] if names else "") + branch = branch or commit + + if not display_name or display_name.lower() in {"big", "model", "update model"}: + display_name = source_name_from_path(source_path) + model_id = model_id_override or slug_model_id(display_name) + if not MODEL_ID_RE.fullmatch(model_id): + raise ReleaseError(f"Invalid model ID {model_id!r}; use lowercase letters, digits, '-' or '_'") + uses_external_gpu = Path(source_path).name.lower().startswith("big_") + model_iteration_match = re.search(r"\b(v\d+)\b", display_name, flags=re.IGNORECASE) + return ReleaseInfo( + model_id=model_id, + display_name=display_name, + release_date=commit_local_date(commit_payload), + branch=branch, + source_ref=commit, + source_path=source_path, + input_format="supercombo" if "supercombo" in Path(source_path).name else "split", + behavior_version=behavior_version, + uses_external_gpu=uses_external_gpu, + commits=[commit], + model_iteration=model_iteration_match.group(1).lower() if model_iteration_match else "", + ) + + +def parse_pasted_release(text: str, model_id_override: str | None, behavior_version: str) -> ReleaseInfo: + cleaned_text = text.replace("\\u00a0", " ") + commits = list(dict.fromkeys(match.lower() for match in SHA_RE.findall(cleaned_text))) + if re.fullmatch(r"\s*[0-9a-f]{40}\s*", cleaned_text, flags=re.IGNORECASE): + return resolve_commit_release(commits[0], model_id_override, behavior_version) + + source_match = re.search( + r"https?://github\.com/commaai/openpilot/(?:blob|raw)/([^/\s]+)/([^\s)\]]+)", + cleaned_text, + flags=re.IGNORECASE, + ) + if source_match: + branch = urllib.parse.unquote(source_match.group(1)) + source_path = urllib.parse.unquote(source_match.group(2)).rstrip(".,") + else: + branch_match = re.search(r"\[([^]]+)\]\s*\(Branch\s+v?\d+\)", cleaned_text, flags=re.IGNORECASE) + branch = clean_markdown(branch_match.group(1)) if branch_match else "" + family_match = re.search(r"\[([a-z0-9_-]+)\]\s*\(Branch", cleaned_text, flags=re.IGNORECASE) + family = family_match.group(1).lower() if family_match else "" + source_path = f"openpilot/selfdrive/modeld/models/{family}_driving_supercombo.onnx" if family else "" + + if not branch: + raise ReleaseError("Could not parse the openpilot branch from the pasted release block") + if not source_path or not source_path.endswith(".onnx"): + raise ReleaseError("Could not parse the upstream ONNX path from the pasted release block") + + name = "" + release_date = None + for line in cleaned_text.splitlines(): + plain = clean_markdown(line) + date_match = DATE_RE.search(plain) + if date_match: + candidate = plain[:date_match.start()].strip(" :-(\t") + candidate = re.sub(r"^(?:model name|name)\s*[:=-]?\s*", "", candidate, flags=re.IGNORECASE) + if candidate and "next model version" not in candidate.lower(): + name = candidate + release_date = parse_release_date(plain) + break + if not name: + family_match = re.search(r"\[([a-z0-9_-]+)\]\s*\(Branch", cleaned_text, flags=re.IGNORECASE) + name = family_match.group(1).replace("-", " ").title() if family_match else Path(source_path).stem + release_date = dt.date.today().isoformat() + release_date = release_date or dt.date.today().isoformat() + + iteration_match = re.search(r"\b(v\d+)\b", name, flags=re.IGNORECASE) + model_iteration = iteration_match.group(1).lower() if iteration_match else "" + model_id = model_id_override or slug_model_id(name) + if not MODEL_ID_RE.fullmatch(model_id): + raise ReleaseError(f"Invalid model ID {model_id!r}; use lowercase letters, digits, '-' or '_'") + + uses_external_gpu = bool( + re.search(r"\[big\]|\bbig[_ -]model\b", cleaned_text, flags=re.IGNORECASE) + or Path(source_path).name.startswith("big_") + ) + source_ref = commits[0] if commits else branch + input_format = "supercombo" if "supercombo" in Path(source_path).name else "split" + return ReleaseInfo( + model_id=model_id, + display_name=name, + release_date=release_date, + branch=branch, + source_ref=source_ref, + source_path=source_path, + input_format=input_format, + behavior_version=behavior_version, + uses_external_gpu=uses_external_gpu, + commits=commits, + model_iteration=model_iteration, + ) + + +def resolve_branch_commit(branch: str) -> str: + url = f"https://api.github.com/repos/{OPENPILOT_REPO}/commits/{urllib.parse.quote(branch, safe='')}" + payload = get_json(url) + sha = str(payload.get("sha") or "") + if not SHA_RE.fullmatch(sha): + raise ReleaseError(f"Could not resolve branch head for {branch}") + return sha + + +def runtime_file(path: str) -> bool: + normalized = path.lstrip("./") + if normalized.lower().endswith(".onnx"): + return False + return normalized.startswith(RUNTIME_PATH_PREFIXES) + + +def scan_runtime_changes(info: ReleaseInfo) -> list[dict]: + commits = info.commits or [resolve_branch_commit(info.branch)] + findings: list[dict] = [] + for commit in commits: + url = f"https://api.github.com/repos/{OPENPILOT_REPO}/commits/{commit}" + payload = get_json(url) + files = payload.get("files", []) + if not isinstance(files, list): + raise ReleaseError(f"GitHub returned no file list for commit {commit}") + changed = [str(item.get("filename", "")) for item in files if isinstance(item, dict)] + runtime_paths = [path for path in changed if runtime_file(path)] + if runtime_paths: + findings.append({"commit": commit, "message": str(payload.get("commit", {}).get("message", "")).splitlines()[0], "paths": runtime_paths}) + return findings + + +def print_summary(info: ReleaseInfo) -> None: + print("\nRelease summary") + print(f" model ID: {info.model_id}") + print(f" display name: {info.display_name}") + print(f" release date: {info.release_date}") + print(f" behavior: {info.behavior_version}") + print(f" source ref: {info.source_ref}") + print(f" source path: {info.source_path}") + print(f" input format: {info.input_format}") + print(f" external GPU: {info.uses_external_gpu}") + + +def print_runtime_warning(findings: list[dict]) -> None: + print("\n" + "!" * 88) + print("STOP: UPSTREAM TINYGRAD/MODELD RUNTIME CHANGES DETECTED") + print("Ask Firestar to review these changes before compiling this model.") + for finding in findings: + print(f" {finding['commit'][:12]} {finding['message']}") + for path in finding["paths"]: + print(f" - {path}") + print("The release tool will not compile until --allow-runtime-changes is supplied.") + print("!" * 88 + "\n") + + +def choose_device_ip(args: argparse.Namespace) -> str: + if args.ip: + value = args.ip.strip() + elif sys.stdin.isatty(): + value = input("Comma IP [192.168.3.110]: ").strip() or "192.168.3.110" + else: + raise ReleaseError("Pass --ip when the release text is piped on stdin") + try: + address = ipaddress.ip_address(value) + except ValueError as error: + raise ReleaseError(f"Invalid comma IP: {value}") from error + if str(address).split(".")[-1] == "109": + raise ReleaseError("Refusing to use .109. This workflow is restricted to the requested device, not 192.168.3.109.") + return str(address) + + +def ssh_base(ip: str) -> list[str]: + return ["ssh", "-o", "ConnectTimeout=10", "-o", "ConnectionAttempts=1", "-o", "ServerAliveInterval=30", f"comma@{ip}"] + + +def scp_base(ip: str) -> list[str]: + return ["scp", "-p", "-o", "ConnectTimeout=10", "-o", "ConnectionAttempts=1"] + + +def remote_compile(info: ReleaseInfo, source: Path, ip: str, workspace: Path, keep_device_files: bool) -> dict: + if info.input_format != "supercombo": + raise ReleaseError("The release tool currently requires a single supercombo ONNX source") + + input_dir = f"{DEVICE_ROOT}/uncompiledmodels" + output_dir = f"{DEVICE_ROOT}/compiledmodels" + remote_source = f"{input_dir}/{info.model_id}_driving_supercombo.onnx" + artifact_prefix = f"{info.model_id}_driving_tinygrad.pkl" + safe_id = shlex.quote(info.model_id) + cleanup_command = f"rm -f {shlex.quote(remote_source)} {shlex.quote(output_dir)}/{artifact_prefix}*" + run(ssh_base(ip) + [f"mkdir -p {shlex.quote(input_dir)} {shlex.quote(output_dir)} && {cleanup_command}"]) + run(scp_base(ip) + [str(source), f"comma@{ip}:{remote_source}"]) + + command = [ + f"cd {shlex.quote(DEVICE_ROOT)} && ./models", + f"--model {safe_id}", + f"--input-dir {shlex.quote(input_dir)}", + f"--output-dir {shlex.quote(output_dir)}", + f"--input-format supercombo", + f"--version {shlex.quote(info.behavior_version)}", + ] + if info.uses_external_gpu: + command.append("--gpu") + remote_command = " ".join(command) + log_path = workspace / "logs" / f"{info.model_id}.log" + log_path.parent.mkdir(parents=True, exist_ok=True) + print(f"\nCompiling on comma@{ip}. Output is also logged to {log_path}") + with log_path.open("w", encoding="utf-8") as log: + process = subprocess.Popen( + ssh_base(ip) + [remote_command], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + assert process.stdout is not None + for line in process.stdout: + print(f"[device] {line}", end="") + log.write(line) + return_code = process.wait() + if return_code != 0: + raise ReleaseError(f"Device compilation failed; see {log_path}") + + list_command = f"for f in {shlex.quote(output_dir)}/{artifact_prefix}*; do [ -f \"$f\" ] && basename \"$f\"; done" + listed = run(ssh_base(ip) + [list_command], capture=True).stdout.splitlines() + remote_files = sorted(name for name in listed if name.startswith(artifact_prefix)) + if not remote_files: + raise ReleaseError(f"Device compiler produced no {artifact_prefix} output") + + artifact_dir = workspace / "compiled" / info.model_id + artifact_dir.mkdir(parents=True, exist_ok=True) + for stale in artifact_dir.iterdir(): + if stale.is_file(): + stale.unlink() + for filename in remote_files: + run(scp_base(ip) + [f"comma@{ip}:{output_dir}/{filename}", str(artifact_dir / filename)]) + + parts = sorted(artifact_dir.glob(f"{artifact_prefix}.p[0-9][0-9]")) + full_artifact = artifact_dir / artifact_prefix + checksum_path = artifact_dir / f"{artifact_prefix}.sha256" + if parts: + if full_artifact.exists() or not checksum_path.is_file(): + raise ReleaseError("Device returned invalid multipart output") + expected = checksum_path.read_text(encoding="utf-8").split()[0].lower() + digest = hashlib.sha256() + size = 0 + for part in parts: + with part.open("rb") as source_part: + for chunk in iter(lambda: source_part.read(1024 * 1024), b""): + digest.update(chunk) + size += len(chunk) + actual = digest.hexdigest() + if actual != expected: + raise ReleaseError(f"Multipart checksum mismatch: {actual} != {expected}") + artifact_files = [*parts, checksum_path] + elif full_artifact.is_file(): + size = full_artifact.stat().st_size + actual = sha256_file(full_artifact) + artifact_files = [full_artifact] + expected = actual + else: + raise ReleaseError("Device returned no usable artifact") + + if not keep_device_files: + run(ssh_base(ip) + [cleanup_command]) + result = { + "id": info.model_id, + "status": "compiled", + "size": size, + "sha256": expected, + "multipart": bool(parts), + "files": [path.name for path in artifact_files], + "path": str(artifact_dir), + } + (workspace / "results").mkdir(parents=True, exist_ok=True) + (workspace / "results" / f"{info.model_id}.json").write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8") + return result + + +def manifest_entry(info: ReleaseInfo, result: dict) -> dict: + display_name = info.display_name + if "👀" not in display_name and "📡" not in display_name: + display_name += " 👀📡" + return { + "id": info.model_id, + "name": display_name, + "version": info.behavior_version, + "series": "OP Series", + "released": info.release_date, + "community_favorite": False, + "artifact_format": "tinygrad_single_v1", + "artifact_size": result["size"], + "artifact_sha256": result["sha256"], + "uses_external_gpu": info.uses_external_gpu, + } + + +def update_manifest(repo: Path, info: ReleaseInfo, result: dict, manifest_version: str) -> Path: + path = repo / f"model_names_{manifest_version}.json" + if not path.is_file(): + raise ReleaseError(f"Manifest not found: {path}") + payload = json.loads(path.read_text(encoding="utf-8")) + models = payload.get("models", payload) if isinstance(payload, dict) else payload + if not isinstance(models, list): + raise ReleaseError(f"Unsupported manifest shape: {path}") + entry = manifest_entry(info, result) + replaced = False + updated = [] + for model in models: + if isinstance(model, dict) and model.get("id") == info.model_id: + updated.append(entry) + replaced = True + else: + updated.append(model) + if not replaced: + updated.append(entry) + output = {**payload, "models": updated} if isinstance(payload, dict) else {"models": updated} + path.write_text(json.dumps(output, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + return path + + +def find_hf() -> str: + candidates = [shutil.which("hf"), str(Path.home() / ".local/bin/hf")] + for candidate in candidates: + if candidate and Path(candidate).is_file(): + return candidate + raise ReleaseError("Hugging Face CLI not found; install/authenticate `hf` first") + + +def hf_copy(source: Path, bucket: str, remote_path: str) -> None: + hf = find_hf() + destination = f"hf://buckets/{bucket}/{remote_path}" + run([hf, "buckets", "cp", str(source), destination, "--format", "quiet"]) + + +def upload_huggingface(info: ReleaseInfo, result: dict, workspace: Path, bucket: str, manifest: Path, upload_onnx: bool, source: Path) -> None: + artifact_dir = Path(result["path"]) + for filename in result["files"]: + hf_copy(artifact_dir / filename, bucket, f"models/{info.model_id}/{filename}") + if upload_onnx: + hf_copy(source, bucket, f"onnx/{info.model_id}/{source.name}") + hf_copy(manifest, bucket, f"manifests/{manifest.name}") + print(f"Hugging Face upload complete: {bucket}/models/{info.model_id}/") + + +def git_output(repo: Path, args: list[str]) -> str: + return run(["git", "-C", str(repo), *args], capture=True).stdout.strip() + + +def check_resources_repo(repo: Path, branch: str) -> None: + if not (repo / ".git").exists(): + raise ReleaseError(f"GitHub resources checkout not found: {repo}") + status = git_output(repo, ["status", "--porcelain"]) + if status: + raise ReleaseError(f"Resources checkout is dirty; refusing to modify it:\n{status}") + current_branch = git_output(repo, ["branch", "--show-current"]) + if current_branch != branch: + raise ReleaseError(f"Resources checkout is on {current_branch!r}, expected {branch!r}") + remote_head = git_output(repo, ["rev-parse", f"origin/{branch}"]) + local_head = git_output(repo, ["rev-parse", "HEAD"]) + if remote_head != local_head: + raise ReleaseError("Resources checkout has unpushed or missing remote commits; sync it before releasing") + + +def push_github(info: ReleaseInfo, result: dict, resources_repo: Path, manifest: Path, branch: str, force: bool) -> None: + artifact_dir = Path(result["path"]) + artifact_names = list(result["files"]) + destination_paths = [] + stale_relative: list[str] = [] + for filename in artifact_names: + destination = resources_repo / filename + if destination.exists() and not force: + raise ReleaseError(f"Artifact already exists in GitHub checkout: {destination}; use --force to replace it") + shutil.copy2(artifact_dir / filename, destination) + destination_paths.append(destination) + + prefix = f"{info.model_id}_driving_tinygrad.pkl" + if force: + allowed = {path.name for path in destination_paths} + for stale in resources_repo.glob(f"{prefix}*"): + if stale.name not in allowed and stale.is_file(): + stale.unlink() + stale_relative.append(str(stale.relative_to(resources_repo))) + + for index, destination in enumerate(destination_paths): + relative = destination.relative_to(resources_repo) + paths_to_stage = [str(relative)] + if index == 0: + paths_to_stage.extend(stale_relative) + run(["git", "-C", str(resources_repo), "add", "-A", "--", *paths_to_stage]) + run(["git", "-C", str(resources_repo), "commit", "-m", f"Add {info.model_id} artifact {destination.name}", "--", *paths_to_stage]) + run(["git", "-C", str(resources_repo), "push", "origin", f"HEAD:{branch}"]) + + run(["git", "-C", str(resources_repo), "add", "--", str(manifest.relative_to(resources_repo))]) + if git_output(resources_repo, ["diff", "--cached", "--name-only"]): + run(["git", "-C", str(resources_repo), "commit", "-m", f"Add {info.display_name} to {manifest.name}", "--", str(manifest.relative_to(resources_repo))]) + run(["git", "-C", str(resources_repo), "push", "origin", f"HEAD:{branch}"]) + print(f"GitHub upload complete: {RESOURCES_REPO}/{branch}") + + +def read_release_text(args: argparse.Namespace) -> str: + if args.commit: + return args.commit + if args.text_file: + return args.text_file.read_text(encoding="utf-8") + if args.text: + return args.text + if not sys.stdin.isatty(): + return sys.stdin.read() + print("Paste the model bot message. Press Ctrl-D when finished.") + return sys.stdin.read() + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Download, device-compile, verify, and publish one upstream model.") + parser.add_argument("commit", nargs="?", help="A single openpilot commit SHA; metadata is resolved from GitHub.") + parser.add_argument("--text", help="Release text, otherwise paste it into stdin.") + parser.add_argument("--text-file", type=Path, help="Read the pasted release text from a file.") + parser.add_argument("--model-id", help="Override the ID parsed from the model name.") + parser.add_argument("--behavior-version", default=DEFAULT_BEHAVIOR_VERSION, help="Runtime behavior version (default: v16).") + parser.add_argument("--ip", help="Comma IP; prompted interactively when omitted.") + parser.add_argument("--workspace", type=Path, default=default_workspace()) + parser.add_argument("--resources-repo", type=Path, default=Path.home() / "StarPilot-Resources") + parser.add_argument("--resources-branch", default=RESOURCE_BRANCH) + parser.add_argument("--manifest-version", default=MANIFEST_VERSION) + parser.add_argument("--hf-bucket", default=HF_BUCKET) + gpu = parser.add_mutually_exclusive_group() + gpu.add_argument("--gpu", dest="gpu", action="store_true", help="Force external-GPU compilation.") + gpu.add_argument("--no-gpu", dest="gpu", action="store_false", help="Disable external-GPU compilation.") + parser.set_defaults(gpu=None) + parser.add_argument("--allow-runtime-changes", action="store_true", help="Continue only after reviewing the runtime-change warning.") + parser.add_argument("--no-onnx-upload", action="store_true", help="Do not archive the source ONNX in Hugging Face.") + parser.add_argument("--keep-device-files", action="store_true", help="Leave the staged source and compiled output on the comma.") + parser.add_argument("--force", action="store_true", help="Replace an existing source/artifact/model ID.") + parser.add_argument("--dry-run", action="store_true", help="Parse and scan only; do not download, compile, or publish.") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + try: + if not re.fullmatch(r"v\d+", args.behavior_version.strip(), flags=re.IGNORECASE): + raise ReleaseError("--behavior-version must look like v16") + text = read_release_text(args) + if not text.strip(): + raise ReleaseError("No release text was supplied") + info = parse_pasted_release(text, args.model_id, args.behavior_version.strip().lower()) + if args.gpu is not None: + info.uses_external_gpu = args.gpu + print_summary(info) + + findings = scan_runtime_changes(info) + if findings: + print_runtime_warning(findings) + if not args.allow_runtime_changes: + return 2 + else: + print("Runtime scan: no tinygrad/modeld runtime files changed in the supplied commits.") + + if args.dry_run: + print("Dry run complete; no device or repository changes made.") + return 0 + + ip = choose_device_ip(args) + workspace = args.workspace / info.model_id + for relative in ("onnx", "compiled", "logs", "results"): + (workspace / relative).mkdir(parents=True, exist_ok=True) + source = workspace / "onnx" / f"{info.model_id}_driving_supercombo.onnx" + source_result = download_source(info.source_ref, info.source_path, source, args.force) + (workspace / "release.txt").write_text(text, encoding="utf-8") + (workspace / "source.json").write_text(json.dumps({**source_result, "model": info.__dict__}, indent=2) + "\n", encoding="utf-8") + + result = remote_compile(info, source, ip, workspace, args.keep_device_files) + resources_repo = args.resources_repo.expanduser().resolve() + check_resources_repo(resources_repo, args.resources_branch) + manifest = update_manifest(resources_repo, info, result, args.manifest_version) + upload_huggingface(info, result, workspace, args.hf_bucket, manifest, not args.no_onnx_upload, source) + push_github(info, result, resources_repo, manifest, args.resources_branch, args.force) + print("\nRelease complete.") + print(f" local artifact: {result['path']}") + print(f" Hugging Face: {args.hf_bucket}/models/{info.model_id}/") + print(f" GitHub: {RESOURCES_REPO}/{args.resources_branch}") + return 0 + except (ReleaseError, subprocess.CalledProcessError) as error: + print(f"ERROR: {error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/tests/test_model_release.py b/scripts/tests/test_model_release.py new file mode 100644 index 000000000..9a6d0e228 --- /dev/null +++ b/scripts/tests/test_model_release.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from scripts import model_release +from scripts.model_release import parse_lfs_pointer, parse_pasted_release, runtime_file, update_manifest + + +RELEASE_TEXT = """ +**BRANCH UPDATED — BIG** +[remove-avgpool](https://github.com/commaai/openpilot/pull/38681) #38681 [big] (Branch v5) +**Changed Files** +[big_driving_supercombo](https://github.com/commaai/openpilot/blob/remove-avgpool/openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx) +**Recent Commits [f877]** +[f877d7a0ccc3cce943c76e285214c020cd65c899](https://github.com/commaai/openpilot/commit/f877d7a0ccc3cce943c76e285214c020cd65c899) +[faster compile](https://github.com/commaai/openpilot/commit/83a461eb4f0cb5737132c24b7a5ad5de46fc0fbb) +**Next Model Version [big]** +Model v4 + +Bmrlnap v4 (August 30, 2026) f877 +""" + + +def test_parse_release_block(): + info = parse_pasted_release(RELEASE_TEXT, None, "v16") + assert info.model_id == "bmrlnapv4" + assert info.display_name == "Bmrlnap v4" + assert info.release_date == "2026-08-30" + assert info.branch == "remove-avgpool" + assert info.source_ref == "f877d7a0ccc3cce943c76e285214c020cd65c899" + assert info.source_path.endswith("big_driving_supercombo.onnx") + assert info.input_format == "supercombo" + assert info.uses_external_gpu + assert info.commits == [ + "f877d7a0ccc3cce943c76e285214c020cd65c899", + "83a461eb4f0cb5737132c24b7a5ad5de46fc0fbb", + ] + + +def test_lfs_pointer_parser(): + pointer = ( + b"version https://git-lfs.github.com/spec/v1\n" + b"oid sha256:" + b"a" * 64 + b"\n" + b"size 123\n" + ) + assert parse_lfs_pointer(pointer) == ("a" * 64, 123) + + +def test_sha_only_resolves_commit_metadata(monkeypatch): + commit = "f877d7a0ccc3cce943c76e285214c020cd65c899" + payloads = { + f"https://api.github.com/repos/commaai/openpilot/commits/{commit}": { + "commit": {"committer": {"date": "2026-08-31T00:41:06Z"}}, + "files": [{"filename": "openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx"}], + }, + f"https://api.github.com/repos/commaai/openpilot/commits/{commit}/pulls": [ + {"title": "BMRLNAP", "head": {"sha": commit, "ref": "remove-avgpool"}}, + ], + } + + monkeypatch.setattr(model_release, "get_json_value", lambda url: payloads[url]) + info = parse_pasted_release(commit, None, "v16") + + assert info.model_id == "bmrlnap" + assert info.display_name == "BMRLNAP" + assert info.release_date == "2026-08-30" + assert info.branch == "remove-avgpool" + assert info.source_ref == commit + assert info.source_path.endswith("big_driving_supercombo.onnx") + assert info.uses_external_gpu + + +def test_runtime_scan_excludes_model_weights_but_flags_runtime_code(): + assert not runtime_file("openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx") + assert runtime_file("openpilot/selfdrive/modeld/compile_modeld.py") + assert runtime_file("tinygrad/engine/jit.py") + assert not runtime_file("README.md") + + +def test_update_manifest_replaces_one_entry(tmp_path: Path): + manifest = tmp_path / "model_names_v24.json" + manifest.write_text(json.dumps({"models": [{"id": "old"}]}) + "\n") + info = parse_pasted_release(RELEASE_TEXT, "bmrlnapv4", "v16") + path = update_manifest( + tmp_path, + info, + {"size": 123, "sha256": "a" * 64}, + "v24", + ) + payload = json.loads(path.read_text()) + assert len(payload["models"]) == 2 + entry = payload["models"][1] + assert entry["id"] == "bmrlnapv4" + assert entry["artifact_size"] == 123 + assert entry["uses_external_gpu"] diff --git a/starpilot/common/assets/device_settings_layout.json b/starpilot/common/assets/device_settings_layout.json index 10a73b6ee..8d5a6e4f0 100644 --- a/starpilot/common/assets/device_settings_layout.json +++ b/starpilot/common/assets/device_settings_layout.json @@ -3413,6 +3413,16 @@ "galaxy_only": true, "settings_tier": "simple" }, + { + "key": "SubaruAvhOnAtStartup", + "label": "AVH On at Startup", + "description": "For supported Subaru Legacy 2025 vehicles, send one momentary Auto Vehicle Hold request after ignition while stationary and in Park or Neutral.", + "picker_description": "Requests Auto Vehicle Hold ON once after ignition on the supported Legacy.", + "data_type": "bool", + "ui_type": "toggle", + "galaxy_only": true, + "settings_tier": "simple" + }, { "key": "ClusterOffset", "label": "Dashboard Speed Offset", diff --git a/starpilot/common/safe_mode.py b/starpilot/common/safe_mode.py index ab9680b53..03006edb0 100644 --- a/starpilot/common/safe_mode.py +++ b/starpilot/common/safe_mode.py @@ -192,6 +192,7 @@ SAFE_MODE_MANAGED_KEYS = ( "SubaruSNG", "SubaruSNGManualParkingBrake", "SubaruStopStartOff", + "SubaruAvhOnAtStartup", "VoltSNG", "JeepBrakeHold", "GMAutoHold", @@ -210,6 +211,7 @@ SAFE_MODE_FIXED_VALUES = { "LongitudinalPersonality": int(log.LongitudinalPersonality.relaxed), "UseAutoSteerDelay": True, "SubaruStopStartOff": False, + "SubaruAvhOnAtStartup": False, } SAFE_MODE_STOCK_PARAM_MAP = { diff --git a/starpilot/common/starpilot_variables.py b/starpilot/common/starpilot_variables.py index da62b845c..f28dab342 100644 --- a/starpilot/common/starpilot_variables.py +++ b/starpilot/common/starpilot_variables.py @@ -20,7 +20,7 @@ from opendbc.car.gm.values import CAR as GM_CAR, EV_CAR as GM_EV_CAR, GMFlags from opendbc.car.hyundai.values import CAR as HYUNDAI_CAR, EV_CAR as HYUNDAI_EV_CAR, HyundaiFlags, HyundaiStarPilotSafetyFlags from opendbc.car.interfaces import TORQUE_SUBSTITUTE_PATH, CarInterfaceBase, GearShifter from opendbc.car.mock.values import CAR as MOCK -from opendbc.car.subaru.values import SUBARU_STOP_START_CARS, SubaruFlags +from opendbc.car.subaru.values import SUBARU_AVH_CARS, 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 @@ -1490,6 +1490,9 @@ 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", diff --git a/starpilot/system/the_galaxy/assets/components/router.js b/starpilot/system/the_galaxy/assets/components/router.js index 80982c0f1..028cc284e 100644 --- a/starpilot/system/the_galaxy/assets/components/router.js +++ b/starpilot/system/the_galaxy/assets/components/router.js @@ -2,7 +2,7 @@ import { html, reactive } from "/assets/vendor/arrow-core.js" import { createBrowserHistory, createRouter } from "/assets/vendor/remix-router-1.3.1.js" import { hideSidebar } from "/assets/js/utils.js" import { DeviceSettings } from "/assets/components/tools/device_settings.js?v=favorite-c4-hint-1" -import { Bluetooth } from "/assets/components/tools/bluetooth.js?v=bluetooth-4" +import { Bluetooth } from "/assets/components/tools/bluetooth.js?v=bluetooth-5" import { WheelControls } from "/assets/components/tools/wheel_controls.js?v=controllers-2" import { ErrorLogs } from "/assets/components/tools/error_logs.js" import { VehicleFeatures } from "/assets/components/tools/vehicle_features.js" diff --git a/starpilot/system/the_galaxy/assets/components/tools/bluetooth.js b/starpilot/system/the_galaxy/assets/components/tools/bluetooth.js index 9b5e74526..f3289cd4a 100644 --- a/starpilot/system/the_galaxy/assets/components/tools/bluetooth.js +++ b/starpilot/system/the_galaxy/assets/components/tools/bluetooth.js @@ -1,4 +1,5 @@ import { html, reactive } from "/assets/vendor/arrow-core.js" +import { galaxyPath } from "/assets/js/utils.js" const state = reactive({ loading: true, @@ -20,6 +21,27 @@ const state = reactive({ let initialized = false let lastPromptId = "" let audioTestTimer = null +let pollTimer = null +let refreshRequested = false +let refreshPromise = null + +function bluetoothPageActive() { + const currentPath = window.location.pathname.replace(/\/+$/, "") + const bluetoothPath = galaxyPath("/bluetooth").replace(/\/+$/, "") + return currentPath === bluetoothPath +} + +function pollDelay() { + return state.busy || state.discovering || state.pairingAddress ? 500 : 2000 +} + +function schedulePoll(delay = pollDelay()) { + if (pollTimer !== null) clearTimeout(pollTimer) + pollTimer = setTimeout(async () => { + if (bluetoothPageActive()) await refresh() + schedulePoll() + }, delay) +} function startAudioTestCountdown(address, delayMs, requestStartedAt) { if (audioTestTimer !== null) clearInterval(audioTestTimer) @@ -47,9 +69,11 @@ function startAudioTestCountdown(address, delayMs, requestStartedAt) { async function request(operation, body = {}) { const requestStartedAt = performance.now() state.busy = operation + schedulePoll(250) try { - const response = await fetch(`/api/bluetooth/${operation}`, { + const response = await fetch(galaxyPath(`/api/bluetooth/${operation}`), { method: "POST", + cache: "no-store", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }) @@ -64,6 +88,7 @@ async function request(operation, body = {}) { state.error = error?.message || "Bluetooth operation failed" } finally { state.busy = "" + schedulePoll(250) } } @@ -87,36 +112,51 @@ async function handlePrompt(prompt) { await request("pairing_response", { prompt_id: prompt.id, accepted, value }) } +async function refreshOnce() { + const statusUrl = `${galaxyPath("/api/bluetooth/status")}?_=${Date.now()}` + const response = await fetch(statusUrl, { cache: "no-store" }) + const payload = await response.json() + state.available = !!payload.available + state.enabled = !!payload.enabled + state.powered = !!payload.powered + state.discovering = !!payload.discovering + state.offroad = !!payload.offroad + state.selectedAudio = String(payload.selected_audio || "") + state.pairingAddress = String(payload.pairing_address || "") + state.devices = Array.isArray(payload.devices) ? payload.devices : [] + state.prompt = payload.prompt || null + state.error = payload.error || (response.ok ? "" : "Bluetooth service unavailable") + handlePrompt(state.prompt) +} + async function refresh() { + refreshRequested = true + if (refreshPromise !== null) return refreshPromise + + refreshPromise = (async () => { + while (refreshRequested) { + refreshRequested = false + try { + await refreshOnce() + } catch (error) { + state.available = false + state.error = error?.message || "Bluetooth service unavailable" + } finally { + state.loading = false + } + } + })() + try { - const response = await fetch("/api/bluetooth/status", { cache: "no-store" }) - const payload = await response.json() - state.available = !!payload.available - state.enabled = !!payload.enabled - state.powered = !!payload.powered - state.discovering = !!payload.discovering - state.offroad = !!payload.offroad - state.selectedAudio = String(payload.selected_audio || "") - state.pairingAddress = String(payload.pairing_address || "") - state.devices = Array.isArray(payload.devices) ? payload.devices : [] - state.prompt = payload.prompt || null - state.error = payload.error || (response.ok ? "" : "Bluetooth service unavailable") - handlePrompt(state.prompt) - } catch (error) { - state.available = false - state.error = error?.message || "Bluetooth service unavailable" + await refreshPromise } finally { - state.loading = false + refreshPromise = null } } function initialize() { - if (initialized) return - initialized = true - refresh() - setInterval(() => { - if (window.location.pathname === "/bluetooth") refresh() - }, 2000) + if (!initialized) initialized = true + schedulePoll(0) } function normalizedAddress(device) { diff --git a/starpilot/system/the_galaxy/assets/components/tools/device_settings.js b/starpilot/system/the_galaxy/assets/components/tools/device_settings.js index 771cf75e7..bd58be84e 100644 --- a/starpilot/system/the_galaxy/assets/components/tools/device_settings.js +++ b/starpilot/system/the_galaxy/assets/components/tools/device_settings.js @@ -40,6 +40,7 @@ const VEHICLE_SETTING_MAKES = { SubaruSNG: ["Subaru"], SubaruSNGManualParkingBrake: ["Subaru"], SubaruStopStartOff: ["Subaru"], + SubaruAvhOnAtStartup: ["Subaru"], ClusterOffset: ["Lexus", "Toyota"], SNGHack: ["Lexus", "Toyota"], ToyotaAutoHold: ["Lexus", "Toyota"], diff --git a/starpilot/system/the_galaxy/tests/test_frontend_module_graph.py b/starpilot/system/the_galaxy/tests/test_frontend_module_graph.py index 147eca188..5822eaa77 100644 --- a/starpilot/system/the_galaxy/tests/test_frontend_module_graph.py +++ b/starpilot/system/the_galaxy/tests/test_frontend_module_graph.py @@ -42,6 +42,11 @@ def test_bluetooth_actions_use_reactive_disabled_bindings(): assert "bluetoothForgetButton" in source assert "bi-trash3" in source assert "state.pairingAddress" in source + assert 'galaxyPath("/bluetooth")' in source + assert 'window.location.pathname === "/bluetooth"' not in source + assert "schedulePoll(250)" in source + assert "while (refreshRequested)" in source + assert 'cache: "no-store"' in source def test_controller_test_mode_has_explicit_start_and_stop(): diff --git a/starpilot/system/the_galaxy/tests/test_navigation_params.py b/starpilot/system/the_galaxy/tests/test_navigation_params.py index 0515e8709..c93360016 100644 --- a/starpilot/system/the_galaxy/tests/test_navigation_params.py +++ b/starpilot/system/the_galaxy/tests/test_navigation_params.py @@ -142,6 +142,7 @@ def test_bluetooth_status_api(monkeypatch): response = client.get("/api/bluetooth/status") assert response.status_code == 200 + assert response.headers["Cache-Control"] == "no-store, no-cache, must-revalidate, max-age=0" assert response.get_json() == { "available": True, "devices": [], diff --git a/starpilot/system/the_galaxy/the_galaxy.py b/starpilot/system/the_galaxy/the_galaxy.py index 64938aa8f..c39b86860 100644 --- a/starpilot/system/the_galaxy/the_galaxy.py +++ b/starpilot/system/the_galaxy/the_galaxy.py @@ -4939,6 +4939,10 @@ def setup(app): response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" response.headers["Pragma"] = "no-cache" response.headers["Expires"] = "0" + if request.path == "/api/bluetooth/status" or request.path.startswith("/api/bluetooth/"): + response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" + response.headers["Pragma"] = "no-cache" + response.headers["Expires"] = "0" return response @app.errorhandler(404)