diff --git a/opendbc_repo/opendbc/car/chrysler/aol_contract.py b/opendbc_repo/opendbc/car/chrysler/aol_contract.py index 9deb5b5595..63f2ec30c3 100644 --- a/opendbc_repo/opendbc/car/chrysler/aol_contract.py +++ b/opendbc_repo/opendbc/car/chrysler/aol_contract.py @@ -20,11 +20,14 @@ def qualified(cp, *, marked_only=False): cp.alternativeExperience not in ((CHRYSLER_AOL_EXPERIENCE,) if marked_only else (0, CHRYSLER_AOL_EXPERIENCE))): return False flags = ChryslerFlags.HIGHER_MIN_STEERING_SPEED | ChryslerFlags.HAS_BSM | ChryslerFlags.STEERING_SPEED_BYPASS + jeep = cp.carFingerprint in (CAR.JEEP_GRAND_CHEROKEE, CAR.JEEP_GRAND_CHEROKEE_2019) + if jeep: + flags |= ChryslerFlags.JEEP_BRAKE_HOLD_AVAILABLE expected = (1 if cp.carFingerprint == CAR.RAM_1500_5TH_GEN else 8 if cp.carFingerprint == CAR.CHRYSLER_PACIFICA_2019_HYBRID and cp.alternativeExperience == 32 else 0) return bool(not int(cp.flags) & ~int(flags) and cp.safetyConfigs[0].safetyModel == structs.CarParams.SafetyModel.chrysler and - int(cp.safetyConfigs[0].safetyParam) == expected and + int(cp.safetyConfigs[0].safetyParam) in ((0, 4) if jeep else (expected,)) and cp.lateralTuning.which() in (('torque',) if cp.carFingerprint == CAR.RAM_1500_5TH_GEN else ('pid', 'torque'))) diff --git a/opendbc_repo/opendbc/car/chrysler/brake_hold.py b/opendbc_repo/opendbc/car/chrysler/brake_hold.py new file mode 100644 index 0000000000..f567c045bd --- /dev/null +++ b/opendbc_repo/opendbc/car/chrysler/brake_hold.py @@ -0,0 +1,25 @@ +"""Optional stock-ACC stop retention for exactly the two Grand Cherokee ports.""" + + +def brake_hold_supported(cp): + from opendbc.car import structs + from opendbc.car.chrysler.values import CAR, ChryslerFlags + return bool(cp is not None and cp.brand == "chrysler" and + cp.carFingerprint in (CAR.JEEP_GRAND_CHEROKEE, CAR.JEEP_GRAND_CHEROKEE_2019) and + cp.flags & ChryslerFlags.JEEP_BRAKE_HOLD_AVAILABLE and cp.pcmCruise and + not cp.openpilotLongitudinalControl and not cp.passive and not cp.dashcamOnly and not cp.notCar and + len(cp.safetyConfigs) == 1 and cp.safetyConfigs[0].safetyModel == structs.CarParams.SafetyModel.chrysler and + cp.safetyConfigs[0].safetyParam in (0, 4)) + + +def apply_brake_hold(cp, requested): + if brake_hold_supported(cp): + cp.safetyConfigs[0].safetyParam = 4 if requested else 0 + + +def create_das_3_command(packer, counter_offset, brake_decel, das_3): + values = das_3.copy() + values.update(ACC_AVAILABLE=1, ACC_ACTIVE=1, ACC_GO=0, ACC_STANDSTILL=0, ACC_DECEL_REQ=1, + ACC_DECEL=brake_decel, ACC_BRK_PREP=0, ENGINE_TORQUE_REQUEST_MAX=0, GR_MAX_REQ=2, + COUNTER=(int(das_3["COUNTER"]) + counter_offset) % 16) + return packer.make_can_msg("DAS_3", 0, values) diff --git a/opendbc_repo/opendbc/car/chrysler/carcontroller.py b/opendbc_repo/opendbc/car/chrysler/carcontroller.py index ea4815c75d..b3e5c2a072 100644 --- a/opendbc_repo/opendbc/car/chrysler/carcontroller.py +++ b/opendbc_repo/opendbc/car/chrysler/carcontroller.py @@ -1,8 +1,12 @@ +import os +import time + from opendbc.can import CANPacker from opendbc.car import Bus, DT_CTRL from opendbc.car.lateral import apply_meas_steer_torque_limits from opendbc.car.chrysler import chryslercan from opendbc.car.chrysler.aol_contract import qualified as aol_qualified +from opendbc.car.chrysler.brake_hold import brake_hold_supported, create_das_3_command from opendbc.car.chrysler.values import CUSW_CARS, RAM_CARS, RAM_DT, CarControllerParams, ChryslerFlags from opendbc.car.structs import CarState from opendbc.car.interfaces import CarControllerBase @@ -17,6 +21,11 @@ class CarController(CarControllerBase): self.last_lkas_falling_edge = 0 self.lkas_control_bit_prev = False self.last_button_frame = 0 + self.jeep_hold_replay = "REPLAY" in os.environ + self.jeep_decel = -2.0 + self.last_das_3_counter = -1 + self.jeep_hold_cancelled = False + self.jeep_cruise_was_active = False self.packer = CANPacker(dbc_names[Bus.pt]) self.params = CarControllerParams(CP) @@ -100,6 +109,14 @@ class CarController(CarControllerBase): if _projection is not None: _projection.emit('chrysler.LKAS_COMMAND', apply_torque, lkas_control_bit, can_sends[-1]) + if brake_hold_supported(self.CP) and self.CP.safetyConfigs[0].safetyParam == 4: + withdraw = bool(aol_qualified(self.CP, marked_only=True) and CS.aol_pause_longitudinal and + 0 < CS.aol_pause_longitudinal_stamp_ns <= now_nanos and + now_nanos - CS.aol_pause_longitudinal_stamp_ns <= 30_000_000) + self.update_jeep_brake_hold(CC, CS, now_nanos, can_sends, withdraw=withdraw) + else: + CS.brake_hold = False + self.frame += 1 new_actuators = CC.actuators.as_builder() @@ -109,3 +126,43 @@ class CarController(CarControllerBase): if _projection is not None: _projection.finish(new_actuators.torque) return new_actuators, can_sends + + def update_jeep_brake_hold(self, CC, CS, now_nanos, can_sends, *, withdraw=False): + # CAN Event/parser timestamps are BOOTTIME. Card passes MONOTONIC for + # ordinary live Chrysler controllers, and original CAN time during replay. + # Select the established replay contract explicitly; never infer an offset + # or renew the parser's original source timestamp at command time. + now_boot_ns = now_nanos if self.jeep_hold_replay else time.clock_gettime_ns(time.CLOCK_BOOTTIME) + if (not CS.das_3 or not CS.out.canValid or CS.out.canTimeout or + not 0 <= now_boot_ns - CS.das_3_timestamp <= 100_000_000): + CS.brake_hold = False + return + counter = int(CS.das_3["COUNTER"]) + changed = counter != self.last_das_3_counter + self.last_das_3_counter = counter + active = CS.out.cruiseState.enabled + if active and not self.jeep_cruise_was_active: + self.jeep_hold_cancelled = False + self.jeep_cruise_was_active = active + if CC.cruiseControl.cancel or CS.jeep_driver_cancel or withdraw: + self.jeep_hold_cancelled = True + if self.jeep_hold_cancelled: + CS.brake_hold = False + return + decel = float(CS.das_3["ACC_DECEL"]) + if not CS.brake_hold and CS.out.cruiseState.enabled and decel < -0.5 and CS.out.standstill: + CS.brake_hold = True + self.jeep_decel = -2.0 + if (CC.cruiseControl.cancel or CS.jeep_driver_cancel or withdraw or CS.out.gasPressed or CS.out.brakePressed or + CS.out.gearShifter != CarState.GearShifter.drive or not CS.out.standstill or + not CS.out.cruiseState.available or CS.out.accFaulted): + CS.brake_hold = False + return + if not CS.brake_hold: + return + if CS.out.cruiseState.enabled: + self.jeep_decel = max(-3.0, min(-0.5, min(self.jeep_decel, decel))) + return + can_sends.append(create_das_3_command(self.packer, 2 if changed else 3, self.jeep_decel, CS.das_3)) + if self.frame % 10 == 0: + can_sends.append(chryslercan.create_cruise_buttons(self.packer, CS.button_counter + 1, 0, resume=True)) diff --git a/opendbc_repo/opendbc/car/chrysler/carstate.py b/opendbc_repo/opendbc/car/chrysler/carstate.py index 028cde345a..f1974f63f0 100644 --- a/opendbc_repo/opendbc/car/chrysler/carstate.py +++ b/opendbc_repo/opendbc/car/chrysler/carstate.py @@ -27,6 +27,10 @@ class CarState(CarStateBase): self.aol_pause_longitudinal = False self.aol_pause_longitudinal_stamp_ns = 0 self.aol_buttons = {ButtonType.mainCruise: 0, ButtonType.cancel: 0} + self.brake_hold = False + self.das_3 = {} + self.das_3_timestamp = 0 + self.jeep_driver_cancel = False def update(self, can_parsers) -> structs.CarState: cp = can_parsers[Bus.pt] @@ -84,6 +88,11 @@ class CarState(CarStateBase): ret.cruiseState.nonAdaptive = cp_cruise.vl["DAS_4"]["ACC_STATE"] in (1, 2) # 1 NormalCCOn and 2 NormalCCSet ret.cruiseState.standstill = cp_cruise.vl["DAS_3"]["ACC_STANDSTILL"] == 1 ret.accFaulted = cp_cruise.vl["DAS_3"]["ACC_FAULTED"] != 0 + if self.CP.flags & ChryslerFlags.JEEP_BRAKE_HOLD_AVAILABLE: + self.das_3 = dict(cp_cruise.vl["DAS_3"]) + self.jeep_driver_cancel = bool(cp.vl["CRUISE_BUTTONS"]["ACC_Cancel"]) + self.das_3_timestamp = cp_cruise.ts_nanos["DAS_3"]["COUNTER"] + ret.brakeHoldActive = self.brake_hold if self.CP.carFingerprint in RAM_CARS: # Auto High Beam isn't Located in this message on chrysler or jeep currently located in 729 message diff --git a/opendbc_repo/opendbc/car/chrysler/interface.py b/opendbc_repo/opendbc/car/chrysler/interface.py index 4bf4be16ff..c91f2537f9 100755 --- a/opendbc_repo/opendbc/car/chrysler/interface.py +++ b/opendbc_repo/opendbc/car/chrysler/interface.py @@ -57,6 +57,7 @@ class CarInterface(CarInterfaceBase): elif candidate in (CAR.JEEP_GRAND_CHEROKEE, CAR.JEEP_GRAND_CHEROKEE_2019): ret.steerActuatorDelay = 0.2 + ret.flags |= ChryslerFlags.JEEP_BRAKE_HOLD_AVAILABLE.value ret.lateralTuning.init('pid') ret.lateralTuning.pid.kpBP, ret.lateralTuning.pid.kiBP = [[9., 20.], [9., 20.]] diff --git a/opendbc_repo/opendbc/car/chrysler/tests/test_jeep_brake_hold.py b/opendbc_repo/opendbc/car/chrysler/tests/test_jeep_brake_hold.py new file mode 100644 index 0000000000..a00a94b501 --- /dev/null +++ b/opendbc_repo/opendbc/car/chrysler/tests/test_jeep_brake_hold.py @@ -0,0 +1,235 @@ +"""Real Jeep parser/controller stock-stop retention; no system speed authority.""" + +import os +import unittest +from unittest.mock import patch +from opendbc.can import CANPacker, CANParser +from opendbc.car import Bus, gen_empty_fingerprint, structs +from opendbc.car.chrysler.brake_hold import apply_brake_hold, brake_hold_supported +from opendbc.car.chrysler.interface import CarInterface +from opendbc.car.chrysler.values import CAR, DBC + + +def params(candidate=CAR.JEEP_GRAND_CHEROKEE, *, enabled=True): + cp = CarInterface.get_params(candidate, gen_empty_fingerprint(), [], False, False, False) + apply_brake_hold(cp, enabled) + return cp + + +class PhysicalCan: + def __init__(self, ci): + ci.update([]) + self.ci = ci + self.packers = {bus: CANPacker(parser.dbc.name) for bus, parser in ci.can_parsers.items()} + + def frames(self, tick, *, active=True, decel=-2.0, main=True, gas=0.0, brake=0, speed=0.0, gear=4, fault=0, omit=()): + values = { + 'DAS_3': { + 'ACC_ACTIVE': int(active), + 'ACC_AVAILABLE': int(main), + 'ACC_DECEL': decel, + 'ACC_STANDSTILL': 1, + 'ACC_FAULTED': fault, + 'ENGINE_TORQUE_REQUEST': 13.25, + 'DISABLE_FUEL_SHUTOFF': 1, + 'COLLISION_BRK_PREP': 1, + 'COUNTER': (14 + tick // 2) % 16, + }, + 'DAS_4': {'ACC_SET_SPEED_KPH': 100, 'ACC_STATE': 3}, + 'GEAR': {'PRNDL': gear}, + 'ECM_5': {'Accelerator_Position': gas}, + 'ESP_1': {'Brake_Pedal_State': brake}, + 'SPEED_1': {'SPEED_LEFT': speed, 'SPEED_RIGHT': speed}, + } + return [ + self.packers[bus].make_can_msg(parser.dbc.addr_to_msg[address].name, parser.bus, values.get(parser.dbc.addr_to_msg[address].name, {})) + for bus, parser in self.ci.can_parsers.items() + for address in sorted(parser.addresses) + if parser.dbc.addr_to_msg[address].name not in omit + ] + + +def apply_at_boot(ci, command, now): + with patch('opendbc.car.chrysler.carcontroller.time.clock_gettime_ns', return_value=now): + return ci.apply(command.as_reader(), now) + + +class TestJeepBrakeHold(unittest.TestCase): + def setUp(self): + prior = os.environ.pop('REPLAY', None) + + def restore(): + if prior is None: + os.environ.pop('REPLAY', None) + else: + os.environ['REPLAY'] = prior + + self.addCleanup(restore) + + def ready(self, candidate=CAR.JEEP_GRAND_CHEROKEE): + ci = CarInterface(params(candidate)) + stream = PhysicalCan(ci) + cc = structs.CarControl(enabled=True) + for tick in range(20): + ci.update([(100_000_000_000 + tick * 10_000_000, stream.frames(tick))]) + apply_at_boot(ci, cc, 100_000_000_000 + tick * 10_000_000) + self.assertTrue(ci.CS.out.canValid and ci.CS.brake_hold) + return ci, stream, cc + + def test_two_exact_platforms_stock_ownership_default_off_and_siblings(self): + for candidate in CAR: + for release in (False, True): + cp = CarInterface.get_params(candidate, gen_empty_fingerprint(), [], True, release, False) + expected = candidate in (CAR.JEEP_GRAND_CHEROKEE, CAR.JEEP_GRAND_CHEROKEE_2019) + self.assertEqual(brake_hold_supported(cp), expected) + before = cp.to_dict() + apply_brake_hold(cp, True) + self.assertEqual(cp.safetyConfigs[0].safetyParam, 4 if expected else before['safetyConfigs'][0]['safetyParam']) + self.assertFalse(cp.openpilotLongitudinalControl) + self.assertTrue(cp.pcmCruise) + apply_brake_hold(cp, False) + self.assertEqual(cp.to_dict(), before) + + def test_real_parser_copied_fields_decel_counter_wrap_and_repeat(self): + for candidate in (CAR.JEEP_GRAND_CHEROKEE, CAR.JEEP_GRAND_CHEROKEE_2019): + ci, stream, cc = self.ready(candidate) + decoder = CANParser(DBC[candidate][Bus.pt], [('DAS_3', 0)], 0) + for tick in range(20, 55): + now = 100_000_000_000 + tick * 10_000_000 + previous = ci.CC.last_das_3_counter + ci.update([(now, stream.frames(tick, active=False))]) + _, frames = apply_at_boot(ci, cc, now) + hold = [frame for frame in frames if frame[0] == 0x1F4] + self.assertEqual(len(hold), 1) + decoder.update([(now, hold)]) + parsed = decoder.vl['DAS_3'] + stock = ci.CS.das_3 + self.assertEqual(parsed['COUNTER'], (int(stock['COUNTER']) + (2 if int(stock['COUNTER']) != previous else 3)) % 16) + for name in ('ENGINE_TORQUE_REQUEST', 'DISABLE_FUEL_SHUTOFF', 'COLLISION_BRK_PREP', 'ACC_FAULTED'): + self.assertEqual(parsed[name], stock[name]) + for name, expected in { + 'ACC_ACTIVE': 1, + 'ACC_AVAILABLE': 1, + 'ACC_GO': 0, + 'ACC_STANDSTILL': 0, + 'ACC_DECEL_REQ': 1, + 'GR_MAX_REQ': 2, + 'ACC_BRK_PREP': 0, + 'ENGINE_TORQUE_REQUEST_MAX': 0, + }.items(): + self.assertEqual(parsed[name], expected) + self.assertAlmostEqual(parsed['ACC_DECEL'], -2.0, delta=0.004885) + resumes = [frame for frame in frames if frame[0] == 0x23B] + self.assertEqual(bool(resumes), tick % 10 == 0) + + def test_driver_main_fault_and_source_withdrawal(self): + for stimulus in ('gas', 'brake', 'speed', 'gear', 'main', 'fault', 'cancel', 'stale'): + ci, stream, cc = self.ready() + args = ( + {stimulus: {'gas': 1.0, 'brake': 1, 'speed': 1.0, 'gear': 1, 'main': False, 'fault': 1}.get(stimulus)} if stimulus not in ('cancel', 'stale') else {} + ) + now = 100_200_000_000 + if stimulus == 'cancel': + cc.cruiseControl.cancel = True + ci.update([(now, stream.frames(20, active=False, **args))]) + if stimulus == 'stale': + now += 100_000_001 + _, frames = apply_at_boot(ci, cc, now) + self.assertFalse(ci.CS.brake_hold, stimulus) + self.assertFalse(any(frame[0] == 0x1F4 for frame in frames), stimulus) + + def test_stock_braking_strength_clamped_to_original_envelope(self): + ci, stream, cc = self.ready() + for tick in range(20, 30): + now = 100_000_000_000 + tick * 10_000_000 + ci.update([(now, stream.frames(tick, decel=-4.0))]) + apply_at_boot(ci, cc, now) + self.assertEqual(ci.CC.jeep_decel, -3.0) + + def test_real_parser_crc_fault_withdraws_hold(self): + ci, stream, cc = self.ready() + for tick in range(20, 32): + now = 100_000_000_000 + tick * 10_000_000 + frames = stream.frames(tick, active=False) + for index, (address, data, bus) in enumerate(frames): + if address == 0x1F4: + invalid = bytearray(data) + invalid[-1] ^= 1 + frames[index] = (address, bytes(invalid), bus) + ci.update([(now, frames)]) + _, emitted = apply_at_boot(ci, cc, now) + self.assertGreater(now - ci.CS.das_3_timestamp, 100_000_000) + self.assertFalse(ci.CS.brake_hold) + self.assertFalse(any(frame[0] == 0x1F4 for frame in emitted)) + + def test_commanded_cancel_requires_fresh_stock_acc_engagement(self): + ci, stream, cc = self.ready() + cc.cruiseControl.cancel = True + ci.update([(100_200_000_000, stream.frames(20))]) + apply_at_boot(ci, cc, 100_200_000_000) + cc.cruiseControl.cancel = False + for tick in range(21, 25): + now = 100_000_000_000 + tick * 10_000_000 + ci.update([(now, stream.frames(tick))]) + apply_at_boot(ci, cc, now) + self.assertFalse(ci.CS.brake_hold) + ci.update([(100_250_000_000, stream.frames(25, active=False))]) + apply_at_boot(ci, cc, 100_250_000_000) + ci.update([(100_260_000_000, stream.frames(26))]) + apply_at_boot(ci, cc, 100_260_000_000) + self.assertTrue(ci.CS.brake_hold) + + def test_actual_factory_default_off_and_siblings_emit_no_hold(self): + for candidate in CAR: + for requested in (False, True): + if requested and candidate in (CAR.JEEP_GRAND_CHEROKEE, CAR.JEEP_GRAND_CHEROKEE_2019): + continue + ci = CarInterface(params(candidate, enabled=requested)) + stream = PhysicalCan(ci) + cc = structs.CarControl() + for tick in range(20): + now = 100_000_000_000 + tick * 10_000_000 + ci.update([(now, stream.frames(tick, active=False))]) + _, frames = apply_at_boot(ci, cc, now) + self.assertFalse(ci.CS.brake_hold) + self.assertFalse(any(frame[0] == 0x1F4 for frame in frames)) + + def test_actual_parser_reverse_then_drive_does_not_restore_disabled_stock_long(self): + ci, stream, cc = self.ready() + ci.update([(100_200_000_000, stream.frames(20, active=False, gear=2))]) + _, frames = apply_at_boot(ci, cc, 100_200_000_000) + self.assertEqual(ci.CS.out.gearShifter, structs.CarState.GearShifter.reverse) + self.assertFalse(ci.CS.brake_hold) + self.assertFalse(any(frame[0] == 0x1F4 for frame in frames)) + ci.update([(100_210_000_000, stream.frames(21, active=False, gear=4))]) + _, frames = apply_at_boot(ci, cc, 100_210_000_000) + self.assertFalse(ci.CS.brake_hold) + self.assertFalse(any(frame[0] == 0x1F4 for frame in frames)) + + def test_original_can_boot_age_is_independent_of_controller_monotonic_time(self): + ci, stream, cc = self.ready() + boot_ns = 100_200_000_000 + ci.update([(boot_ns, stream.frames(20, active=False))]) + with patch('opendbc.car.chrysler.carcontroller.time.clock_gettime_ns', return_value=boot_ns): + _, frames = ci.apply(cc.as_reader(), boot_ns - 9_000_000_000) + self.assertTrue(ci.CS.brake_hold) + self.assertTrue(any(frame[0] == 0x1F4 for frame in frames)) + + def test_original_boot_source_future_and_stale_are_not_restamped(self): + for offset in (-1, 100_000_001): + ci, stream, cc = self.ready() + source = 100_200_000_000 + ci.update([(source, stream.frames(20, active=False))]) + with patch('opendbc.car.chrysler.carcontroller.time.clock_gettime_ns', return_value=source + offset): + _, frames = ci.apply(cc.as_reader(), source) + self.assertFalse(ci.CS.brake_hold) + self.assertFalse(any(frame[0] == 0x1F4 for frame in frames)) + + def test_replay_uses_original_can_time_in_the_explicit_replay_domain(self): + with patch.dict(os.environ, {'REPLAY': '1'}): + ci, stream, cc = self.ready() + source = 100_200_000_000 + ci.update([(source, stream.frames(20, active=False))]) + with patch('opendbc.car.chrysler.carcontroller.time.clock_gettime_ns', side_effect=AssertionError('no host clock in original-time replay')): + _, frames = ci.apply(cc.as_reader(), source) + self.assertTrue(any(frame[0] == 0x1F4 for frame in frames)) diff --git a/opendbc_repo/opendbc/car/chrysler/values.py b/opendbc_repo/opendbc/car/chrysler/values.py index 703c1ad66a..e151b0e284 100644 --- a/opendbc_repo/opendbc/car/chrysler/values.py +++ b/opendbc_repo/opendbc/car/chrysler/values.py @@ -13,6 +13,7 @@ class ChryslerSafetyFlags(IntFlag): RAM_DT = 1 RAM_HD = 2 AOL_PACIFICA_HYBRID_SET_GUARD = 8 + JEEP_BRAKE_HOLD = 4 class ChryslerFlags(IntFlag): @@ -20,6 +21,7 @@ class ChryslerFlags(IntFlag): HIGHER_MIN_STEERING_SPEED = 1 HAS_BSM = 2 # blind spot monitoring STEERING_SPEED_BYPASS = 4 + JEEP_BRAKE_HOLD_AVAILABLE = 8 @dataclass diff --git a/opendbc_repo/opendbc/safety/modes/chrysler.h b/opendbc_repo/opendbc/safety/modes/chrysler.h index cc906fb915..b22ac706c7 100644 --- a/opendbc_repo/opendbc/safety/modes/chrysler.h +++ b/opendbc_repo/opendbc/safety/modes/chrysler.h @@ -39,6 +39,31 @@ typedef enum { CHRYSLER_PACIFICA, // plus Jeep } ChryslerPlatform; static ChryslerPlatform chrysler_platform; +static bool chrysler_jeep_hold; +static bool chrysler_hold_armed; +static bool chrysler_hold_cancelled; +static uint8_t chrysler_hold_stock[8]; +static bool chrysler_hold_drive; +static bool chrysler_hold_gear_seen; +static bool chrysler_hold_gear_initialized; +static uint8_t chrysler_hold_gear_counter; +static uint32_t chrysler_hold_gear_timestamp; +static uint32_t chrysler_hold_stock_timestamp; + +static bool chrysler_hold_ready(void) { + const uint32_t now = microsecond_timer_get(); + bool healthy = !safety_rx_checks_invalid; + for (int i = 0; i < current_safety_config.rx_checks_len; i++) { + const RxStatus *status = ¤t_safety_config.rx_checks[i].status; + healthy &= status->msg_seen && status->valid_checksum && status->valid_quality_flag && + status->wrong_counters < MAX_WRONG_COUNTERS && !status->lagging && + safety_get_ts_elapsed(now, status->last_timestamp) <= 1000000U; + } + return chrysler_jeep_hold && healthy && chrysler_hold_gear_seen && chrysler_hold_drive && + safety_get_ts_elapsed(now, chrysler_hold_gear_timestamp) <= 100000U && + safety_get_ts_elapsed(now, chrysler_hold_stock_timestamp) <= 100000U && acc_main_on && !vehicle_moving && !gas_pressed && !brake_pressed && + ((chrysler_hold_stock[5] & 0xC0U) == 0U); +} #define CHRYSLER_ADDR(name) ((uint32_t)((chrysler_platform == CHRYSLER_RAM_DT) ? CHRYSLER_RAM_DT_##name : \ ((chrysler_platform == CHRYSLER_RAM_HD) ? CHRYSLER_RAM_HD_##name : CHRYSLER_##name))) @@ -62,6 +87,17 @@ static void chrysler_rx_hook(const CANPacket_t *msg) { if (msg_matches(msg, CHRYSLER_ADDR(DAS_3), das_3_bus)) { bool cruise_engaged = GET_BIT(msg, 21U); pcm_cruise_check(cruise_engaged); + if (chrysler_jeep_hold) { + chrysler_hold_stock_timestamp = microsecond_timer_get(); + const bool previous_active = (chrysler_hold_stock[2] & 0x20U) != 0U; + if (cruise_engaged && !previous_active) { chrysler_hold_cancelled = false; } + acc_main_on = GET_BIT(msg, 20U); + for (int i = 0; i < 8; i++) { chrysler_hold_stock[i] = msg->data[i]; } + const unsigned int raw_decel = ((msg->data[2] & 0xFU) << 8) | msg->data[3]; + if (chrysler_hold_ready() && !chrysler_hold_cancelled && cruise_engaged && (raw_decel < 3173U)) { + chrysler_hold_armed = true; + } + } } // TODO: use the same message for both @@ -84,6 +120,29 @@ static void chrysler_rx_hook(const CANPacket_t *msg) { if (msg_matches(msg, CHRYSLER_ADDR(ESP_1), 0U)) { brake_pressed = ((msg->data[0U] & 0xFU) >> 2U) == 1U; } + if (chrysler_jeep_hold && !chrysler_hold_ready()) { chrysler_hold_armed = false; } +} + +static void chrysler_hold_cancel_rx(const CANPacket_t *msg) { + if (chrysler_jeep_hold && msg_matches(msg, 746U, 0U)) { + // GEAR_CHECKSUM has no checksum algorithm in the Chrysler DBC. Require + // its known size and advancing counter without inventing a CRC contract. + const uint8_t counter = (uint8_t)(msg->data[3] >> 4); + const bool valid = (GET_LEN(msg) == 5U) && + (!chrysler_hold_gear_initialized || counter == ((chrysler_hold_gear_counter + 1U) & 0xFU)); + chrysler_hold_gear_initialized = true; + chrysler_hold_gear_counter = counter; + chrysler_hold_gear_seen = valid; + chrysler_hold_drive = valid && ((msg->data[0] & 7U) == 4U); + if (valid) { chrysler_hold_gear_timestamp = microsecond_timer_get(); } + if (!chrysler_hold_ready()) { chrysler_hold_armed = false; } + } + if (chrysler_jeep_hold && msg_matches(msg, CHRYSLER_CRUISE_BUTTONS, 0U)) { + if ((GET_LEN(msg) != 3U) || (chrysler_get_checksum(msg) != chrysler_compute_checksum(msg)) || GET_BIT(msg, 0U)) { + chrysler_hold_armed = false; + chrysler_hold_cancelled = true; + } + } } static bool chrysler_tx_hook(const CANPacket_t *msg) { @@ -135,17 +194,46 @@ static bool chrysler_tx_hook(const CANPacket_t *msg) { if (msg->addr == CHRYSLER_ADDR(CRUISE_BUTTONS)) { const bool is_cancel = msg->data[0] == 1U; const bool is_resume = msg->data[0] == 0x10U; - const bool allowed = is_cancel || (is_resume && controls_allowed); + if (is_cancel && chrysler_jeep_hold) { + chrysler_hold_armed = false; + chrysler_hold_cancelled = true; + } + const bool hold_resume = chrysler_hold_armed && chrysler_hold_ready() && ((chrysler_hold_stock[2] & 0x20U) == 0U) && + (chrysler_get_checksum(msg) == chrysler_compute_checksum(msg)); + const bool allowed = is_cancel || (is_resume && (controls_allowed || hold_resume)); if (!allowed) { tx = false; } } + if (msg->addr == CHRYSLER_DAS_3) { + const unsigned int raw_decel = ((msg->data[2] & 0xFU) << 8) | msg->data[3]; + const unsigned int stock_counter = chrysler_hold_stock[6] >> 4; + const unsigned int counter = msg->data[6] >> 4; + tx &= chrysler_hold_armed && chrysler_hold_ready() && ((chrysler_hold_stock[2] & 0x20U) == 0U) && + (raw_decel >= 2661U) && (raw_decel <= 3173U) && + ((counter == ((stock_counter + 2U) & 0xFU)) || (counter == ((stock_counter + 3U) & 0xFU))) && + (msg->data[0] == (chrysler_hold_stock[0] & 0x1FU)) && (msg->data[1] == chrysler_hold_stock[1]) && + ((msg->data[2] & 0xF0U) == ((chrysler_hold_stock[2] & 0x80U) | 0x30U)) && + (msg->data[4] == 0x12U) && (msg->data[5] == (chrysler_hold_stock[5] & 0xC0U)) && + ((msg->data[6] & 0xFU) == (chrysler_hold_stock[6] & 1U)) && + (chrysler_get_checksum(msg) == chrysler_compute_checksum(msg)); + } return tx; } static safety_config chrysler_init(uint16_t param) { const uint32_t CHRYSLER_PARAM_RAM_DT = 1U; // set for Ram DT platform + chrysler_jeep_hold = param == 4U; + chrysler_hold_armed = false; + chrysler_hold_cancelled = false; + chrysler_hold_drive = false; + chrysler_hold_gear_seen = false; + chrysler_hold_gear_initialized = false; + chrysler_hold_gear_counter = 0U; + chrysler_hold_gear_timestamp = 0U; + chrysler_hold_stock_timestamp = 0U; + for (int i = 0; i < 8; i++) { chrysler_hold_stock[i] = 0U; } static RxCheck chrysler_ram_dt_rx_checks[] = { {.msg = {{CHRYSLER_RAM_DT_EPS_2, 0, 8, 100U, .max_counter = 15U, .ignore_quality_flag = true}, { 0 }, { 0 }}}, @@ -169,6 +257,13 @@ static safety_config chrysler_init(uint16_t param) { {CHRYSLER_DAS_6, 0, 8, .check_relay = true}, }; + static const CanMsg CHRYSLER_JEEP_HOLD_TX_MSGS[] = { + {CHRYSLER_CRUISE_BUTTONS, 0, 3, .check_relay = false}, + {CHRYSLER_LKAS_COMMAND, 0, 6, .check_relay = true}, + {CHRYSLER_DAS_6, 0, 8, .check_relay = true}, + {CHRYSLER_DAS_3, 0, 8, .check_relay = false}, + }; + static const CanMsg CHRYSLER_RAM_DT_TX_MSGS[] = { {CHRYSLER_RAM_DT_CRUISE_BUTTONS, 2, 3, .check_relay = false}, {CHRYSLER_RAM_DT_LKAS_COMMAND, 0, 8, .check_relay = true}, @@ -209,6 +304,7 @@ static safety_config chrysler_init(uint16_t param) { } else { chrysler_platform = CHRYSLER_PACIFICA; ret = BUILD_SAFETY_CFG(chrysler_rx_checks, CHRYSLER_TX_MSGS); + if (chrysler_jeep_hold) { SET_TX_MSGS(CHRYSLER_JEEP_HOLD_TX_MSGS, ret); } } chrysler_aol_configure(param); // The allocated Hybrid guard never falls back to ordinary or Ram output. @@ -219,10 +315,15 @@ static safety_config chrysler_init(uint16_t param) { return ret; } +static void chrysler_optional_rx(const CANPacket_t *msg) { + chrysler_aol_optional_rx(msg); + chrysler_hold_cancel_rx(msg); +} + const safety_hooks chrysler_hooks = { .init = chrysler_init, .rx = chrysler_rx_hook, - .optional_rx = chrysler_aol_optional_rx, + .optional_rx = chrysler_optional_rx, .tx = chrysler_tx_hook, .get_counter = chrysler_get_counter, .get_checksum = chrysler_get_checksum, diff --git a/opendbc_repo/opendbc/safety/modes/chrysler_aol.h b/opendbc_repo/opendbc/safety/modes/chrysler_aol.h index 9a2de891c7..f2513f2e2c 100644 --- a/opendbc_repo/opendbc/safety/modes/chrysler_aol.h +++ b/opendbc_repo/opendbc/safety/modes/chrysler_aol.h @@ -173,7 +173,7 @@ static void chrysler_aol_optional_rx(const CANPacket_t *msg) { static void chrysler_aol_configure(uint16_t param) { chrysler_aol_reset(); chrysler_aol_set_guard = param == 8U; - chrysler_aol_enabled = (alternative_experience == 32) && ((param == 0U) || (param == 1U) || (param == 8U)); + chrysler_aol_enabled = (alternative_experience == 32) && ((param == 0U) || (param == 1U) || (param == 4U) || (param == 8U)); if (chrysler_aol_enabled) { static const AolSafetyPolicy policy = { .reset = chrysler_aol_reset, diff --git a/opendbc_repo/opendbc/safety/tests/test_chrysler_jeep_brake_hold.py b/opendbc_repo/opendbc/safety/tests/test_chrysler_jeep_brake_hold.py new file mode 100644 index 0000000000..d6238c92a2 --- /dev/null +++ b/opendbc_repo/opendbc/safety/tests/test_chrysler_jeep_brake_hold.py @@ -0,0 +1,205 @@ +"""Exact opt-in Jeep native retention, physical permission and copied payload.""" + +import unittest +from opendbc.can import CANPacker +from opendbc.car import structs +from opendbc.car.chrysler.brake_hold import create_das_3_command +from opendbc.safety.tests.libsafety import libsafety_py + + +class TestJeepBrakeHoldSafety(unittest.TestCase): + def setUp(self): + self.safety = libsafety_py.libsafety + + def select(self, word=4): + self.safety.init_tests() + self.safety.set_alternative_experience(0) + self.assertEqual(self.safety.set_safety_hooks(structs.CarParams.SafetyModel.chrysler, word), 0) + self.packer = CANPacker('chrysler_pacifica_2017_hybrid_generated') + self.stock = { + 'ACC_AVAILABLE': 1, + 'ACC_ACTIVE': 1, + 'ACC_STANDSTILL': 1, + 'ACC_DECEL': -2.0, + 'ENGINE_TORQUE_REQUEST': 13.25, + 'DISABLE_FUEL_SHUTOFF': 1, + 'COLLISION_BRK_PREP': 1, + 'COUNTER': 0, + } + + def packet(self, frame): + return libsafety_py.make_CANPacket(frame[0], frame[2], frame[1]) + + def gear(self, value): + return self.packer.make_can_msg('GEAR', 0, {'PRNDL': value}) + + def feed(self, tick, *, active=True, main=True, gas=0.0, brake=0, speed=0.0, fault=0, gear=4, omit_gear=False): + self.safety.set_timer(tick * 10_000) + self.stock.update(ACC_ACTIVE=int(active), ACC_AVAILABLE=int(main), ACC_FAULTED=fault, COUNTER=tick % 16) + if not omit_gear: + self.safety.safety_rx_hook(self.packet(self.gear(gear))) + for name, values in [ + ('EPS_2', {}), + ('ESP_1', {'Brake_Pedal_State': brake}), + ('SPEED_1', {'SPEED_LEFT': speed, 'SPEED_RIGHT': speed}), + ('ECM_5', {'Accelerator_Position': gas}), + ('DAS_3', self.stock), + ]: + self.assertTrue(self.safety.safety_rx_hook(self.packet(self.packer.make_can_msg(name, 0, values)))) + self.safety.safety_tick() + + def ready(self): + self.select() + for tick in range(20): + self.feed(tick) + self.assertTrue(self.safety.get_controls_allowed()) + self.feed(20, active=False) + self.assertTrue(self.safety.safety_config_valid()) + self.assertFalse(self.safety.get_controls_allowed()) + + def hold(self, offset=2, decel=-2.0): + return create_das_3_command(self.packer, offset, decel, self.stock) + + def test_physical_acc_braking_arms_retention_and_disengaged_resume(self): + self.ready() + for offset in (2, 3): + self.assertTrue(self.safety.safety_tx_hook(self.packet(self.hold(offset)))) + button = self.packer.make_can_msg('CRUISE_BUTTONS', 0, {'ACC_Resume': 1}) + self.assertTrue(self.safety.safety_tx_hook(self.packet(button))) + for name in ('ACC_Cancel', 'ACC_Resume'): + self.assertFalse(self.safety.safety_tx_hook(self.packet(self.packer.make_can_msg('CRUISE_BUTTONS', 0, {name: 1, 'ACC_Distance_Dec': 1})))) + + def test_driver_main_stale_and_unarmed_withdrawal(self): + for change in ('gas', 'brake', 'speed', 'main', 'stale', 'unarmed'): + self.ready() + if change == 'stale': + self.safety.set_timer(1_200_001) + self.safety.safety_tick() + elif change == 'unarmed': + self.select() + self.feed(20, active=False) + else: + self.feed(21, active=False, **{change: {'gas': 1.0, 'brake': 1, 'speed': 1.0, 'main': False}[change]}) + self.assertFalse(self.safety.safety_tx_hook(self.packet(self.hold())), change) + self.assertFalse(self.safety.safety_tx_hook(self.packet(self.packer.make_can_msg('CRUISE_BUTTONS', 0, {'ACC_Resume': 1}))), change) + + def test_decel_counter_checksum_bus_length_and_copied_field_denials(self): + self.ready() + for decel in (-3.0, -0.5): + self.assertTrue(self.safety.safety_tx_hook(self.packet(self.hold(decel=decel)))) + for decel in (-3.01, -0.49, 0.0, 1.0): + self.assertFalse(self.safety.safety_tx_hook(self.packet(self.hold(decel=decel)))) + for offset in (0, 1, 4): + self.assertFalse(self.safety.safety_tx_hook(self.packet(self.hold(offset)))) + address, data, bus = self.hold() + for invalid_bus, invalid_data in ((1, data), (2, data), (0, data[:7]), (0, data + b'0000')): + self.assertFalse(self.safety.safety_tx_hook(self.packet((address, invalid_data, invalid_bus)))) + for signal in ('ACC_GO', 'ACC_STANDSTILL', 'ENGINE_TORQUE_REQUEST_MAX', 'ACC_BRK_PREP'): + fields = self.stock.copy() + fields.update( + ACC_AVAILABLE=1, + ACC_ACTIVE=1, + ACC_GO=0, + ACC_STANDSTILL=0, + ACC_DECEL_REQ=1, + ACC_BRK_PREP=0, + ENGINE_TORQUE_REQUEST_MAX=0, + GR_MAX_REQ=2, + COUNTER=(fields['COUNTER'] + 2) % 16, + ) + fields[signal] = 1 + self.assertFalse(self.safety.safety_tx_hook(self.packet(self.packer.make_can_msg('DAS_3', 0, fields))), signal) + changed = bytearray(data) + changed[-1] ^= 1 + self.assertFalse(self.safety.safety_tx_hook(self.packet((address, bytes(changed), bus)))) + + def test_permission_word_and_lifecycle_isolation(self): + for word in (0, 1, 2, 3, 5, 6, 7, 8, 12): + self.select(word) + self.assertFalse(self.safety.safety_tx_hook(self.packet(self.hold())), word) + self.ready() + self.select() + self.assertFalse(self.safety.safety_tx_hook(self.packet(self.hold()))) + + def test_physical_cancel_and_crc_health_revoke_retention(self): + self.ready() + button = self.packer.make_can_msg('CRUISE_BUTTONS', 0, {'ACC_Cancel': 1, 'ACC_Distance_Dec': 1}) + self.safety.safety_rx_hook(self.packet(button)) + self.assertFalse(self.safety.safety_tx_hook(self.packet(self.hold()))) + for tick in range(21, 25): + self.feed(tick, active=False) + self.assertFalse(self.safety.safety_tx_hook(self.packet(self.hold()))) + for tick in range(25, 28): + self.feed(tick, active=True) + self.feed(28, active=False) + self.assertTrue(self.safety.safety_tx_hook(self.packet(self.hold()))) + address, data, bus = self.packer.make_can_msg('DAS_3', 0, self.stock) + invalid = bytearray(data) + invalid[-1] ^= 1 + self.assertFalse(self.safety.safety_rx_hook(self.packet((address, bytes(invalid), bus)))) + self.safety.safety_tick() + self.assertFalse(self.safety.safety_tx_hook(self.packet(self.hold()))) + + def test_crc_valid_inactive_acc_fault_releases_hold_until_reengagement(self): + for fault in (1, 2, 3): + with self.subTest(fault=fault): + self.ready() + self.assertTrue(self.safety.safety_tx_hook(self.packet(self.hold()))) + self.feed(21, active=False, fault=fault) + self.assertTrue(self.safety.safety_config_valid()) + self.assertFalse(self.safety.get_controls_allowed()) + resume = self.packer.make_can_msg('CRUISE_BUTTONS', 0, {'ACC_Resume': 1}) + self.assertFalse(self.safety.safety_tx_hook(self.packet(self.hold()))) + self.assertFalse(self.safety.safety_tx_hook(self.packet(resume))) + self.feed(22, active=False, fault=0) + self.assertTrue(self.safety.safety_config_valid()) + self.assertFalse(self.safety.safety_tx_hook(self.packet(self.hold()))) + self.assertFalse(self.safety.safety_tx_hook(self.packet(resume))) + self.feed(23, active=True, fault=0) + self.assertTrue(self.safety.get_controls_allowed()) + self.feed(24, active=False, fault=0) + self.assertFalse(self.safety.get_controls_allowed()) + self.assertTrue(self.safety.safety_tx_hook(self.packet(self.hold()))) + self.assertTrue(self.safety.safety_tx_hook(self.packet(resume))) + + def test_native_reverse_pause_and_fresh_drive_reengagement(self): + self.ready() + self.feed(21, active=False, gear=2) + self.assertFalse(self.safety.safety_tx_hook(self.packet(self.hold()))) + self.feed(22, active=False, gear=4) + self.assertFalse(self.safety.safety_tx_hook(self.packet(self.hold()))) + self.feed(23, active=True, gear=4) + self.feed(24, active=False, gear=4) + self.assertTrue(self.safety.safety_tx_hook(self.packet(self.hold()))) + + def test_native_gear_and_das_source_expire_independently(self): + self.ready() + for tick in range(21, 33): + self.feed(tick, active=False, omit_gear=True) + self.assertFalse(self.safety.safety_tx_hook(self.packet(self.hold()))) + self.ready() + self.safety.set_timer(300_001) + self.assertFalse(self.safety.safety_tx_hook(self.packet(self.hold()))) + + def test_native_bad_gear_counter_wrong_bus_and_commanded_cancel(self): + self.ready() + address, data, bus = self.gear(1) + self.safety.safety_rx_hook(self.packet((address, data, 1))) + self.assertTrue(self.safety.safety_tx_hook(self.packet(self.hold()))) + invalid = bytearray(data) + invalid[3] ^= 0x20 + self.safety.safety_rx_hook(self.packet((address, bytes(invalid), bus))) + self.assertFalse(self.safety.safety_tx_hook(self.packet(self.hold()))) + self.ready() + cancel = self.packer.make_can_msg('CRUISE_BUTTONS', 0, {'ACC_Cancel': 1}) + self.assertTrue(self.safety.safety_tx_hook(self.packet(cancel))) + self.assertFalse(self.safety.safety_tx_hook(self.packet(self.hold()))) + + def test_repeated_invalid_gear_does_not_renew_permission(self): + self.ready() + address, data, bus = self.gear(4) + self.safety.safety_rx_hook(self.packet((address, data, bus))) + # Same counter is invalid, including when repeated after invalidation. + for _ in range(3): + self.safety.safety_rx_hook(self.packet((address, data, bus))) + self.assertFalse(self.safety.safety_tx_hook(self.packet(self.hold()))) diff --git a/openpilot/common/libcommon.a b/openpilot/common/libcommon.a index 7aa717926d..ab52133caf 100644 Binary files a/openpilot/common/libcommon.a and b/openpilot/common/libcommon.a differ diff --git a/openpilot/common/libparams_c.so b/openpilot/common/libparams_c.so index 165c9b9e22..9196e851d5 100755 Binary files a/openpilot/common/libparams_c.so and b/openpilot/common/libparams_c.so differ diff --git a/openpilot/common/params_keys.h b/openpilot/common/params_keys.h index 4d55276dbf..944a76108a 100644 --- a/openpilot/common/params_keys.h +++ b/openpilot/common/params_keys.h @@ -22,6 +22,7 @@ inline static std::unordered_map keys = { {"AlwaysOnLateral", {PERSISTENT, BOOL, "1"}}, {"NostalgiaMode", {PERSISTENT, BOOL, "0"}}, {"ToyotaAutoHold", {PERSISTENT, BOOL, "0"}}, + {"JeepBrakeHold", {PERSISTENT, BOOL, "0"}}, {"ToyotaRadarMode", {PERSISTENT, INT, "0"}}, {"RemoteStartBootsComma", {PERSISTENT, BOOL, "0"}}, {"HKGRemoteStartBootsComma", {PERSISTENT, BOOL, "0"}}, diff --git a/openpilot/selfdrive/pandad/tests/test_aol_protocol.cc b/openpilot/selfdrive/pandad/tests/test_aol_protocol.cc index b4f6900f56..3e8355a669 100644 --- a/openpilot/selfdrive/pandad/tests/test_aol_protocol.cc +++ b/openpilot/selfdrive/pandad/tests/test_aol_protocol.cc @@ -91,7 +91,7 @@ int main() { auto chrysler = status(); chrysler.safety_mode = 9U; chrysler.safety_param = static_cast(word); - assert(aol_capable(chrysler, 9U, VEHICLE_REGISTRY) == ((word == 0U) || (word == 1U) || (word == 8U))); + assert(aol_capable(chrysler, 9U, VEHICLE_REGISTRY) == ((word == 0U) || (word == 1U) || (word == 4U) || (word == 8U))); chrysler.capability_flags = 0U; assert(!aol_capable(chrysler, 9U, VEHICLE_REGISTRY)); } diff --git a/openpilot/starpilot/car/chrysler/aol.py b/openpilot/starpilot/car/chrysler/aol.py index 1e73a09eb2..8060bcda6c 100644 --- a/openpilot/starpilot/car/chrysler/aol.py +++ b/openpilot/starpilot/car/chrysler/aol.py @@ -19,7 +19,7 @@ def policy_for(cp): def native_profile_supported(model, param): - return model == int(structs.CarParams.SafetyModel.chrysler) and param in (0, 1, 8) + return model == int(structs.CarParams.SafetyModel.chrysler) and param in (0, 1, 4, 8) def native_accepts_cp(cp, model, param): diff --git a/openpilot/starpilot/car/chrysler/aol_policy.h b/openpilot/starpilot/car/chrysler/aol_policy.h index e98ebe5d4f..132c48d01f 100644 --- a/openpilot/starpilot/car/chrysler/aol_policy.h +++ b/openpilot/starpilot/car/chrysler/aol_policy.h @@ -3,7 +3,7 @@ #include "selfdrive/pandad/aol_protocol.h" inline bool chrysler_aol_param(uint16_t param) { - return (param == 0U) || (param == 1U) || (param == 8U); + return (param == 0U) || (param == 1U) || (param == 4U) || (param == 8U); } inline constexpr AolSafetyProfile CHRYSLER_AOL_PROFILE{9U, chrysler_aol_param, true}; diff --git a/openpilot/starpilot/galaxy/vehicle_configuration.py b/openpilot/starpilot/galaxy/vehicle_configuration.py index d3a8894dc7..a69815ef79 100644 --- a/openpilot/starpilot/galaxy/vehicle_configuration.py +++ b/openpilot/starpilot/galaxy/vehicle_configuration.py @@ -5,7 +5,8 @@ from openpilot.starpilot.vehicle_selection import read_selection STARTUP_KEYS = ('AlphaLongitudinalEnabled', 'IsReleaseBranch', 'OpenpilotEnabledToggle', 'SafeMode', - 'DisableOpenpilotLongitudinal', 'ToyotaAutoHold', 'ToyotaRadarMode', 'VoltSNG', 'GMAutoHold', 'VoltOnePedalMode', 'TurnAssist', 'LongPitch', + 'DisableOpenpilotLongitudinal', 'ToyotaAutoHold', 'JeepBrakeHold', + 'ToyotaRadarMode', 'VoltSNG', 'GMAutoHold', 'VoltOnePedalMode', 'TurnAssist', 'LongPitch', 'HondaBoschARadar', 'NAPPedalEnabled', 'NAPRadarEnabled', 'NAPRadarBehindNosecone') diff --git a/openpilot/starpilot/ui/feature_settings_owner.py b/openpilot/starpilot/ui/feature_settings_owner.py index de0c0cc6bc..325927e210 100644 --- a/openpilot/starpilot/ui/feature_settings_owner.py +++ b/openpilot/starpilot/ui/feature_settings_owner.py @@ -74,7 +74,7 @@ from openpilot.starpilot.controllers.toyota_cruise import capability as toyota_c BOOL_DEFAULTS = { "GMPedalLongitudinal": False, "ForceStops": False, "AlwaysAllowUploads": False, "TurnAssist": True, - "ReverseCruise": False, "ToyotaAutoHold": False, "VoltSNG": False, "GMAutoHold": False, "VoltOnePedalMode": False, + "ReverseCruise": False, "ToyotaAutoHold": False, "JeepBrakeHold": False, "VoltSNG": False, "GMAutoHold": False, "VoltOnePedalMode": False, "RemapCancelToDistance": False, "LongPitch": True, "DisableOpenpilotLongitudinal": False, "SpeedLimitController": False, "ShowSpeedLimits": True, "SLCConfirmation": False, @@ -318,6 +318,28 @@ class FeatureSettingsOwner: return commit_exact(self.params, key=request.key, max_bytes=128, raw=b"1" if request.value == "On" else b"0", expected=request.expected, authorized=authorized, temp_prefix=".gm-stop-").verified + def _jeep_hold_capability(self): + from opendbc.car.chrysler.brake_hold import brake_hold_supported + cp = self.vehicle_params() + try: + if not brake_hold_supported(cp): + return None + return (str(cp.carFingerprint), int(cp.flags), + tuple((str(config.safetyModel), int(config.safetyParam)) for config in cp.safetyConfigs), + str(cp.carVin) if cp.carVin else None) + except (AttributeError, TypeError, ValueError): + return None + + def _apply_jeep_hold(self, request): + if not request.confirmation or request.value not in ("Off", "On") or request.dependencies: + return False + def authorized(): + return (bool(request.vehicle_fingerprint) and self.vehicle_fingerprint() == request.vehicle_fingerprint and + request.capability is not None and request.capability == self._jeep_hold_capability() and + self.authority("vehicle") and self.authority("parked_preferences") and request.expected in (None, b"0", b"1")) + return commit_exact(self.params, key="JeepBrakeHold", max_bytes=128, raw=b"1" if request.value == "On" else b"0", + expected=request.expected, authorized=authorized, temp_prefix=".jeep-hold-").verified + def _auto_hold_capability(self) -> tuple | None: from opendbc.car.toyota.interface import toyota_auto_hold_supported cp = self.vehicle_params() @@ -713,7 +735,8 @@ class FeatureSettingsOwner: FeatureRow("", "Always On Lateral", "Keep steering assistance active independently of cruise control", page=FeaturePage.AOL, available=True), FeatureRow("", "Wheel Controls", "Assign steering-wheel buttons and cruise behavior", page=FeaturePage.WHEEL, available=True)] if (self._long_pitch_capability() is not None or self._bolt_disable_capability() is not None or - self._auto_hold_capability() is not None or self._toyota_radar_capability() is not None or self._pedal_setup_capability() is not None or + self._auto_hold_capability() is not None or self._jeep_hold_capability() is not None or + self._toyota_radar_capability() is not None or self._pedal_setup_capability() is not None or self._gm_stop_capability("VoltSNG") is not None or self._gm_stop_capability("GMAutoHold") is not None or self.tesla_screen.capability() is not None or self.gm_tune.capability() is not None or self.gm_truck.capability() is not None or @@ -740,6 +763,12 @@ class FeatureSettingsOwner: reason=("Optional RAV4 2022/2023 receiver. Off retains the original radar choice. Applies after startup; " + "native safety and ECU configuration are unchanged."), capability=radar_capability, default_value="Off")) + jeep_capability = self._jeep_hold_capability() + if jeep_capability is not None: + row = self._bool_row("JeepBrakeHold", "Jeep Brake Hold", + configurable and parked and self.authority("vehicle") and self.authority("parked_preferences"), + "Retains a cruise-controlled stop until you press a pedal or cancel cruise. Applies after the next startup") + rows.append(replace(row, available=row.available and self._readable("JeepBrakeHold"), capability=jeep_capability)) rows.extend(self.tesla_screen.rows(parked)) rows.extend(self.gm_cancel_remap.rows(parked)) @@ -1200,6 +1229,8 @@ class FeatureSettingsOwner: return self._apply_gm_stop(request) if key == "LongPitch": return self._apply_long_pitch(request) + if key == "JeepBrakeHold": + return self._apply_jeep_hold(request) if key == "ToyotaAutoHold": return self._apply_auto_hold(request) if key == "ToyotaRadarMode": diff --git a/openpilot/starpilot/ui/tests/test_jeep_brake_hold_preference.py b/openpilot/starpilot/ui/tests/test_jeep_brake_hold_preference.py new file mode 100644 index 0000000000..3d045c6bcd --- /dev/null +++ b/openpilot/starpilot/ui/tests/test_jeep_brake_hold_preference.py @@ -0,0 +1,87 @@ +"""Saved Jeep setting reaches exact startup permission and settings revision.""" + +from dataclasses import replace +import tempfile +import json +import unittest +from unittest.mock import patch +from opendbc.car import gen_empty_fingerprint +from opendbc.car.chrysler.interface import CarInterface +from opendbc.car.chrysler.values import CAR +from openpilot.common.params import Params, params_put, params_get, _copy_string +from openpilot.starpilot.ui.feature_settings_owner import FeatureSettingsOwner +from openpilot.starpilot.ui.feature_settings_state import row_change +from openpilot.starpilot.vehicle_preferences import VehicleStartupPreferences +from openpilot.starpilot.galaxy import vehicle_configuration as configuration +from openpilot.starpilot.vehicle_selection import encode + + +def put_stored(params, key, value): + assert type(value) is bytes + if key == 'VehicleSelection': + params.put(key, json.loads(value), block=True) + elif value in (b'0', b'1'): + params.put_bool(key, value == b'1', block=True) + else: + assert params_put(params.p, params.check_key(key), value, len(value), True) == 0 + + +def get_raw(params, key): + return _copy_string(params_get(params.p, params.check_key(key), False)) + + +class TestJeepBrakeHoldPreference(unittest.TestCase): + def test_real_owner_saved_setting_and_strict_next_startup(self): + with tempfile.TemporaryDirectory() as directory: + saved = Params(directory) + cp = CarInterface.get_params(CAR.JEEP_GRAND_CHEROKEE, gen_empty_fingerprint(), [], False, False, False) + groups = {'vehicle': True, 'parked_preferences': True} + owner = FeatureSettingsOwner(saved, lambda group: groups.get(group, True), vehicle_fingerprint=lambda: cp.carFingerprint, vehicle_params=lambda: cp) + row = next( + row for row in owner.snapshot('vehicle', parked=True, system_long=False, lateral_context=True, metric=False).rows if row.key == 'JeepBrakeHold' + ) + request = replace(row_change(row), confirmation=True) + for group in groups: + groups[group] = False + self.assertFalse(owner.apply(request)) + self.assertIsNone(saved.get('JeepBrakeHold')) + groups[group] = True + self.assertTrue(owner.apply(request)) + self.assertEqual(get_raw(saved, 'JeepBrakeHold'), b'1') + for raw, safe, enabled, word in ( + (b'1', b'0', True, 4), + (b'0', b'0', True, 0), + (b'bad', b'0', True, 0), + (b'1', b'1', True, 0), + (b'1', b'bad', True, 0), + (b'1', b'0', False, 0), + ): + put_stored(saved, 'JeepBrakeHold', raw) + put_stored(saved, 'SafeMode', safe) + target = CarInterface.get_params(CAR.JEEP_GRAND_CHEROKEE, gen_empty_fingerprint(), [], False, False, False) + VehicleStartupPreferences.read(saved, enabled=enabled).prepare(target) + self.assertEqual(target.safetyConfigs[0].safetyParam, word) + self.assertEqual(get_raw(saved, 'JeepBrakeHold'), raw) + cp.carFingerprint = CAR.DODGE_DURANGO + self.assertFalse(owner.apply(request)) + + def test_actual_galaxy_startup_interleaving_and_physical_revision(self): + with tempfile.TemporaryDirectory() as directory: + saved = Params(directory) + put_stored(saved, 'VehicleSelection', encode(str(CAR.JEEP_GRAND_CHEROKEE))) + saved.put_bool('OpenpilotEnabledToggle', True, block=True) + saved.put_bool('JeepBrakeHold', False, block=True) + real = configuration._factory_context + + def interleaved(*args): + result = real(*args) + saved.put_bool('JeepBrakeHold', True, block=True) + return result + + with patch.object(configuration, '_factory_context', side_effect=interleaved): + self.assertEqual(configuration.configuration_context(saved, None, None), (None, None)) + cp = CarInterface.get_params(CAR.JEEP_GRAND_CHEROKEE, gen_empty_fingerprint(), [], False, False, False) + _, first = configuration.configuration_context(saved, cp, b'physical') + saved.put_bool('JeepBrakeHold', False, block=True) + _, second = configuration.configuration_context(saved, cp, b'physical') + self.assertNotEqual(first, second) diff --git a/openpilot/starpilot/vehicle_preferences.py b/openpilot/starpilot/vehicle_preferences.py index 03f946e5b8..620bedd40d 100644 --- a/openpilot/starpilot/vehicle_preferences.py +++ b/openpilot/starpilot/vehicle_preferences.py @@ -19,6 +19,7 @@ def bolt_disable_supported(cp) -> bool: @dataclass(frozen=True) class VehicleStartupPreferences: toyota_auto_hold: bool = False + jeep_brake_hold: bool = False turn_assist: bool = False gm_long_pitch: bool = True disable_bolt_long: bool = False @@ -65,6 +66,8 @@ class VehicleStartupPreferences: requested = read_saved(params, "ToyotaAutoHold", 8) == (b"1", True) safe, readable = read_saved(params, "SafeMode", 8) toyota = bool(enabled and requested and readable and safe in (None, b"0")) + jeep_hold = bool(enabled and read_saved(params, "JeepBrakeHold", 8) == (b"1", True) and + readable and safe in (None, b"0")) volt_sng = bool(enabled and read_saved(params, "VoltSNG", 8) == (b"1", True) and readable and safe in (None, b"0")) gm_auto_hold = bool(enabled and not disable_bolt and read_saved(params, "GMAutoHold", 8) == (b"1", True) and @@ -91,7 +94,7 @@ class VehicleStartupPreferences: screen_brake = read_saved(params, 'TeslaAOLDisengageOnBrake', 8) == (b'1', True) return cls(toyota_radar_mode=toyota_radar_mode(params, enabled=enabled), ford_lateral_mode=ford_mode(params, enabled=enabled), tesla_screen=screen, - tesla_screen_brake=screen_brake, toyota_auto_hold=toyota, + tesla_screen_brake=screen_brake, toyota_auto_hold=toyota, jeep_brake_hold=jeep_hold, gm_longitudinal_tune=selected_tune(params, enabled=enabled), toyota_lock_doors=bool(enabled and readable and safe in (None, b"0") and read_saved(params, "LockDoors", 8) == (b"1", True)), @@ -159,6 +162,8 @@ class VehicleStartupPreferences: from opendbc.car.tesla.screen_button import apply_screen_button apply_screen_button(cp, self.tesla_screen, self.tesla_screen_brake) self._prepare_honda_radar(cp) + from opendbc.car.chrysler.brake_hold import apply_brake_hold + apply_brake_hold(cp, self.jeep_brake_hold) if not self.toyota_filter: from opendbc.car.toyota.prius_longitudinal import prepare_stock as prepare_prius_stock prepare_prius_stock(cp) @@ -186,6 +191,8 @@ class VehicleStartupPreferences: from opendbc.car.tesla.screen_button import apply_screen_button apply_screen_button(cp, self.tesla_screen, self.tesla_screen_brake) self._prepare_honda_radar(cp) + from opendbc.car.chrysler.brake_hold import apply_brake_hold + apply_brake_hold(cp, self.jeep_brake_hold) if not self.toyota_filter: from opendbc.car.toyota.prius_longitudinal import prepare_stock as prepare_prius_stock prepare_prius_stock(cp)