From 2106e717daa975677a14a4e747ac06853bc12d6b Mon Sep 17 00:00:00 2001 From: firestar5683 <168790843+firestar5683@users.noreply.github.com> Date: Mon, 19 Oct 2026 10:14:04 -0500 Subject: [PATCH] Restore GM Cancel Remapping --- opendbc_repo/opendbc/car/gm/aol.py | 5 +- opendbc_repo/opendbc/car/gm/cancel_remap.py | 64 +++++ .../opendbc/car/gm/tests/test_cancel_remap.py | 79 ++++++ opendbc_repo/opendbc/safety/__init__.py | 1 + opendbc_repo/opendbc/safety/modes/gm.h | 20 +- opendbc_repo/opendbc/safety/modes/gm_aol.h | 13 +- .../opendbc/safety/modes/gm_bolt_cc.h | 8 + .../safety/tests/test_gm_cancel_remap.py | 165 +++++++++++++ openpilot/common/params_keys.h | 2 + openpilot/selfdrive/car/car_events.py | 3 +- openpilot/selfdrive/car/card.py | 6 +- openpilot/selfdrive/selfdrived/selfdrived.py | 6 +- openpilot/starpilot/aol/panda_faults.py | 3 +- .../starpilot/aol/source_availability.py | 6 +- .../aol/tests/test_gm_cancel_remap.py | 233 ++++++++++++++++++ openpilot/starpilot/aol/transport_pause.py | 6 +- openpilot/starpilot/car/gm/aol.py | 21 +- .../conditional_mode/button_actions.py | 5 +- .../starpilot/conditional_mode/card_input.py | 8 +- .../starpilot/conditional_mode/manual.py | 12 +- .../conditional_mode/tests/test_manual.py | 5 +- .../starpilot/conditional_mode/traffic.py | 2 +- .../conditional_mode/traffic_launch.py | 5 +- .../starpilot/controllers/mode_actions.py | 2 +- .../starpilot/controllers/wheel_actions.py | 5 + openpilot/starpilot/longitudinal/inputs.py | 2 +- openpilot/starpilot/nostalgia.py | 5 +- openpilot/starpilot/ui/conditional_feature.py | 2 +- .../starpilot/ui/feature_settings_owner.py | 7 +- .../starpilot/ui/gm_cancel_remap_feature.py | 35 +++ openpilot/starpilot/ui/wheel_feature.py | 4 + openpilot/starpilot/vehicle_preferences.py | 7 + 32 files changed, 697 insertions(+), 50 deletions(-) create mode 100644 opendbc_repo/opendbc/car/gm/cancel_remap.py create mode 100644 opendbc_repo/opendbc/car/gm/tests/test_cancel_remap.py create mode 100644 opendbc_repo/opendbc/safety/tests/test_gm_cancel_remap.py create mode 100644 openpilot/starpilot/aol/tests/test_gm_cancel_remap.py create mode 100644 openpilot/starpilot/ui/gm_cancel_remap_feature.py diff --git a/opendbc_repo/opendbc/car/gm/aol.py b/opendbc_repo/opendbc/car/gm/aol.py index e4fd5db1fb..dee847ec6c 100644 --- a/opendbc_repo/opendbc/car/gm/aol.py +++ b/opendbc_repo/opendbc/car/gm/aol.py @@ -1,6 +1,7 @@ """Independent lateral axes for exact source-qualified Bolt and Volt owners.""" import math +from opendbc.car.gm.cancel_remap import aol_enabled, valid_alternative_experience from opendbc.car.structs import CarParams from opendbc.car.gm.values import (malibu_hybrid_profile, volt_cc_pedal_profile, control_flags, @@ -44,7 +45,7 @@ def qualified_gm(cp) -> bool: cp.steerControlType != CarParams.SteerControlType.torque or len(cp.safetyConfigs) != 1 or cp.safetyConfigs[0].safetyModel != CarParams.SafetyModel.gm or int(cp.safetyConfigs[0].safetyParam) not in GM_AOL_WORDS or - int(cp.alternativeExperience) not in (0, GM_AOL_ALTERNATIVE_EXPERIENCE) or + not valid_alternative_experience(cp) or cp.lateralTuning.which() not in ('pid', 'torque')): return False if cp.lateralTuning.which() == 'torque': @@ -95,7 +96,7 @@ def qualified_gm(cp) -> bool: def lateral_request(cp, cc) -> bool: """Only a finalized independent-axis owner may relax its cruise-active lateral gate.""" - return (qualified_gm(cp) and int(cp.alternativeExperience) == GM_AOL_ALTERNATIVE_EXPERIENCE and cc.latActive) + return (qualified_gm(cp) and aol_enabled(cp) and cc.latActive) def native_bootstrap_supported(cp) -> bool: diff --git a/opendbc_repo/opendbc/car/gm/cancel_remap.py b/opendbc_repo/opendbc/car/gm/cancel_remap.py new file mode 100644 index 0000000000..abe79f6fd7 --- /dev/null +++ b/opendbc_repo/opendbc/car/gm/cancel_remap.py @@ -0,0 +1,64 @@ +"""Exact optional Bolt Cancel secondary-input configuration, selected at startup.""" +from opendbc.car.gm.values import BOLT_PEDAL_WORDS, is_bolt_pedal_profile + +GM_REMAP_CANCEL_TO_DISTANCE = 64 +KEY = "RemapCancelToDistance" + + +def supported(cp): + try: + return bool(cp.carFingerprint in BOLT_PEDAL_WORDS and is_bolt_pedal_profile(cp)) + except (AttributeError, IndexError, TypeError, ValueError): + return False + + +def valid_alternative_experience(cp): + try: + value = int(cp.alternativeExperience) + return value in (0, 32) or value in (64, 96) and supported(cp) + except (AttributeError, TypeError, ValueError): + return False + + +def enabled(cp): + try: + return bool(supported(cp) and int(cp.alternativeExperience) in (64, 96)) + except (AttributeError, TypeError, ValueError): + return False + + +def aol_enabled(cp): + return valid_alternative_experience(cp) and int(cp.alternativeExperience) in (32, 96) + + +def prepare(cp, selected): + if cp.brand != 'gm' or cp.carFingerprint not in BOLT_PEDAL_WORDS: + return + cp.alternativeExperience &= ~GM_REMAP_CANCEL_TO_DISTANCE + if selected and supported(cp): + cp.alternativeExperience |= GM_REMAP_CANCEL_TO_DISTANCE + + +def migrate_assignments(params, cp): + from openpilot.starpilot.saved_source import read_saved + if not enabled(cp): + return False + marker, readable = read_saved(params, 'CancelButtonControlsMigrated', 8) + if not readable or marker not in (None, b'0'): + return False + pairs = (('DistanceButtonControl', 'CancelButtonControl', 1), + ('LongDistanceButtonControl', 'LongCancelButtonControl', 5), + ('VeryLongDistanceButtonControl', 'VeryLongCancelButtonControl', 6)) + pending = [] + valid = tuple(str(value).encode() for value in range(15)) + for source, target, default in pairs: + raw, readable = read_saved(params, target, 8) + previous, source_readable = read_saved(params, source, 8) + if not readable or raw is not None and raw not in valid or not source_readable or previous is not None and previous not in valid: + return False + if raw is None: + pending.append((target, int(previous) if previous is not None else default)) + for key, value in pending: + params.put(key, value, block=True) + params.put_bool('CancelButtonControlsMigrated', True, block=True) + return True diff --git a/opendbc_repo/opendbc/car/gm/tests/test_cancel_remap.py b/opendbc_repo/opendbc/car/gm/tests/test_cancel_remap.py new file mode 100644 index 0000000000..c63ab82669 --- /dev/null +++ b/opendbc_repo/opendbc/car/gm/tests/test_cancel_remap.py @@ -0,0 +1,79 @@ +"""Exact optional secondary Cancel profiles; no unsupported alternative bits.""" +import pytest +from opendbc.car.gm.cancel_remap import supported, enabled, aol_enabled, valid_alternative_experience, prepare +from opendbc.car.gm.tests.test_bolt_pedal import params +from opendbc.car.gm.values import CAR + +IDS = (CAR.CHEVROLET_BOLT_CC_2017, CAR.CHEVROLET_BOLT_CC_2018_2021, + CAR.CHEVROLET_BOLT_CC_2022_2023, CAR.CHEVROLET_BOLT_ACC_2022_2023_PEDAL) + + +@pytest.mark.parametrize('candidate', IDS) +@pytest.mark.parametrize('removed', [False, True]) +@pytest.mark.parametrize('aol', [False, True]) +def test_exact_configuration_keeps_optional_default_off(candidate, removed, aol): + cp = params(candidate, pedal=True, camera=True, removed=removed) + cp.alternativeExperience = 32 if aol else 0 + before = cp.as_reader().to_dict() + assert supported(cp) and not enabled(cp) and valid_alternative_experience(cp) + prepare(cp, True) + assert cp.alternativeExperience == (96 if aol else 64) + assert enabled(cp) and aol_enabled(cp) is aol and valid_alternative_experience(cp) + after = cp.as_reader().to_dict() + after['alternativeExperience'] = before['alternativeExperience'] + assert after == before + prepare(cp, False) + assert cp.alternativeExperience == (32 if aol else 0) + assert not enabled(cp) + + +@pytest.mark.parametrize('mutation', ['stock', 'no_pedal', 'wrong_word', 'unknown_bits', 'passive']) +def test_unqualified_configuration_cannot_remap(mutation): + cp = params(CAR.CHEVROLET_BOLT_CC_2018_2021, pedal=mutation != 'no_pedal', camera=True) + if mutation == 'stock': + cp.openpilotLongitudinalControl = False + elif mutation == 'wrong_word': + cp.safetyConfigs[0].safetyParam = 0 + elif mutation == 'passive': + cp.passive = True + cp.alternativeExperience = 96 | (1 if mutation == 'unknown_bits' else 0) + assert not valid_alternative_experience(cp) + assert not enabled(cp) + + +def test_assignment_migration_keeps_explicit_values_and_runs_once(): + from tempfile import TemporaryDirectory + from openpilot.common.params import Params + from opendbc.car.gm.cancel_remap import migrate_assignments + cp = params(CAR.CHEVROLET_BOLT_CC_2018_2021, pedal=True, camera=True) + prepare(cp, True) + with TemporaryDirectory() as directory: + saved = Params(directory) + saved.put('CancelButtonControl', 8, block=True) + saved.put('LongDistanceButtonControl', 3, block=True) + assert migrate_assignments(saved, cp) + assert saved.get('CancelButtonControl') == 8 + assert saved.get('LongCancelButtonControl') == 3 + assert saved.get('VeryLongCancelButtonControl') == 6 + saved.put('LongDistanceButtonControl', 4, block=True) + assert not migrate_assignments(saved, cp) + assert saved.get('LongCancelButtonControl') == 3 + + +@pytest.mark.parametrize('remap', [False, True]) +def test_cancel_experimental_assignment_does_not_invalidate_unrelated_map_for_exact_owner(remap): + from tempfile import TemporaryDirectory + from openpilot.common.params import Params + from openpilot.starpilot.conditional_mode.manual import read_button_map + from openpilot.starpilot.conditional_mode.button_actions import capture_sources, runtime_map_ready + cp = params(CAR.CHEVROLET_BOLT_CC_2018_2021, pedal=True, camera=True) + cp.alternativeExperience = 32 + prepare(cp, remap) + with TemporaryDirectory() as directory: + saved = Params(directory) + saved.put('LongCancelButtonControl', 5, block=True) + assert read_button_map(saved) is None + assert (read_button_map(saved, cp=cp) is not None) is remap + sources = capture_sources(saved, None, None) + assert not runtime_map_ready(sources) + assert runtime_map_ready(sources, cp=cp) is remap diff --git a/opendbc_repo/opendbc/safety/__init__.py b/opendbc_repo/opendbc/safety/__init__.py index 250f943292..d30b244551 100644 --- a/opendbc_repo/opendbc/safety/__init__.py +++ b/opendbc_repo/opendbc/safety/__init__.py @@ -8,5 +8,6 @@ class ALTERNATIVE_EXPERIENCE: DISABLE_STOCK_AEB = 2 RAISE_LONGITUDINAL_LIMITS_TO_ISO_MAX = 8 ALLOW_AEB = 16 + GM_REMAP_CANCEL_TO_DISTANCE = 64 TOYOTA_AUTO_HOLD = 128 TOYOTA_AEB_HOLD = 256 diff --git a/opendbc_repo/opendbc/safety/modes/gm.h b/opendbc_repo/opendbc/safety/modes/gm.h index 45615dc392..b6f8ac3acc 100644 --- a/opendbc_repo/opendbc/safety/modes/gm.h +++ b/opendbc_repo/opendbc/safety/modes/gm.h @@ -204,8 +204,12 @@ static void gm_emit_paddle_after_stock(uint32_t now_us, uint32_t addr, uint8_t d if (gm_paddle_sched && feed->valid) { const bool expired = safety_get_ts_elapsed(now_us, feed->last_feed_us) > 100000U; if (expired) { feed->valid = false; } // Retain the command through its bounded host-feed lifetime. + const bool applied = (addr == 0xBDU) ? (feed->bytes[0] == 0x20U) : + ((feed->bytes[3] & 0xFU) == (gm_bolt_gen2 ? 5U : 7U)) && (feed->bytes[5] == 2U); + const bool main_ready = (!(gm_pedal_acc || gm_cancel_remap_enabled) || gm_pedal_main_ready()); + const bool inactive_release = gm_cancel_remap_enabled && !applied; if (!expired && gm_pedal_regen_gear_ready() && !regen_braking && - (!gm_pedal_acc || gm_pedal_main_ready())) { + (main_ready || inactive_release)) { CANPacket_t packet = {0}; packet.addr = addr; packet.bus = 0U; @@ -390,7 +394,7 @@ static void gm_rx_hook(const CANPacket_t *msg) { } // exit controls on cancel press - if (button == GM_BTN_CANCEL) { + if ((button == GM_BTN_CANCEL) && !gm_cancel_remap_enabled) { controls_allowed = false; } @@ -421,7 +425,7 @@ static void gm_rx_hook(const CANPacket_t *msg) { if ((msg_matches(msg, 0xC9U, 0U)) && (((gm_hw == GM_CAM) && !gm_camera_gateway) || (gm_camera_gateway && !gm_camera_pedal_f1)) && (!gm_ascm_intercept || gm_ascm_brake_c9) && (!gm_sdgm || gm_sdgm_brake_c9)) { brake_pressed = GET_BIT(msg, 40U); - if (gm_pedal_acc) { + if ((gm_pedal_acc || (gm_cancel_remap_enabled && gm_pedal_long)) && (GET_LEN(msg) == 8U)) { acc_main_on = GET_BIT(msg, 29U); gm_pedal_main_seen = true; gm_pedal_main_last_us = microsecond_timer_get(); @@ -611,6 +615,10 @@ static bool gm_tx_hook(const CANPacket_t *msg) { bool steer_req = GET_BIT(msg, 3U); + if (gm_cancel_remap_enabled && ((desired_torque != 0) || steer_req) && !gm_pedal_main_ready()) { + tx = false; + } + if ((gm_volt_cc_long && ((desired_torque != 0) || steer_req)) || (gm_ordinary_cc_long && (desired_torque != 0))) { tx &= gm_volt_cc_sources_current() && gm_volt_cc_main && (gm_volt_cc_active || gm_aol_lateral_allowed()) && gm_volt_cc_forward_gear; } if (steer_torque_cmd_checks(desired_torque, steer_req, gm_bolt_2017 ? GM_BOLT_2017_STEERING_LIMITS : GM_STEERING_LIMITS)) { tx = false; @@ -664,7 +672,7 @@ static bool gm_tx_hook(const CANPacket_t *msg) { const int pair_delta = track1 - (2 * track2); const bool inactive = !enabled && (track1 == 0) && (track2 == 0); const bool active = enabled && gm_pedal_sensor_current() && gm_pedal_owns_longitudinal() && gm_pedal_drive_ready() && - (!gm_pedal_acc || gm_pedal_main_ready()) && + (!(gm_pedal_acc || gm_cancel_remap_enabled) || gm_pedal_main_ready()) && get_longitudinal_allowed() && !brake_pressed_prev && !regen_braking && (track1 >= 604) && (track1 <= 2633) && (track2 >= 304) && (track2 <= 1316) && (pair_delta >= -16) && (pair_delta <= 16); @@ -682,7 +690,7 @@ static bool gm_tx_hook(const CANPacket_t *msg) { const bool applied = msg->data[0] == 0x20U; for (uint8_t i = 1U; i < 7U; i++) { if (msg->data[i] != 0U) { tx = false; } } if (!shape || (applied && !(gm_pedal_sensor_current() && gm_pedal_owns_longitudinal() && - (!gm_pedal_acc || gm_pedal_main_ready()) && + (!(gm_pedal_acc || gm_cancel_remap_enabled) || gm_pedal_main_ready()) && gm_pedal_regen_gear_ready() && !regen_braking && get_longitudinal_allowed()))) { tx = false; } @@ -703,7 +711,7 @@ static bool gm_tx_hook(const CANPacket_t *msg) { (msg->data[3] == prndl) && (msg->data[4] == 0U) && (msg->data[6] == 1U) && (msg->data[7] == 0U) && (applied || released); if (!shape || (applied && !(gm_pedal_sensor_current() && gm_pedal_owns_longitudinal() && - (!gm_pedal_acc || gm_pedal_main_ready()) && + (!(gm_pedal_acc || gm_cancel_remap_enabled) || gm_pedal_main_ready()) && gm_pedal_regen_gear_ready() && !regen_braking && get_longitudinal_allowed()))) { tx = false; } diff --git a/opendbc_repo/opendbc/safety/modes/gm_aol.h b/opendbc_repo/opendbc/safety/modes/gm_aol.h index d3c492e2b6..c6c21e1972 100644 --- a/opendbc_repo/opendbc/safety/modes/gm_aol.h +++ b/opendbc_repo/opendbc/safety/modes/gm_aol.h @@ -3,6 +3,14 @@ static void gm_cruise_status_clear(void); #define GM_ALT_EXP_ALWAYS_ON_LATERAL 32U +#define GM_ALT_EXP_REMAP_CANCEL_TO_DISTANCE 64U + +static bool gm_cancel_remap_enabled = false; + +static bool gm_cancel_remap_word(uint16_t word) { + return (word == 0xBDU) || (word == 0x9DU) || (word == 0x19DU) || (word == 0x1CDU) || + (word == 0xE700U) || (word == 0xE701U) || (word == 0xE702U) || (word == 0xE703U); +} #define GM_AOL_MAIN_TIMEOUT_US 300000U static bool gm_aol_enabled = false; @@ -44,7 +52,7 @@ static uint8_t gm_aol_permission_mask(void) { if (main_current && gear_current) { permission = request & 0x1U; } - if (!gm_aol_stock_only && controls_allowed && ((request & 0x2U) != 0U)) { + if (!gm_aol_stock_only && controls_allowed && (!gm_cancel_remap_enabled || main_current) && ((request & 0x2U) != 0U)) { permission |= 0x2U; } } @@ -79,7 +87,8 @@ static void gm_aol_observe(const CANPacket_t *msg) { // The caller supplies exact profile acceptance after its normal mode initialization. static void gm_aol_initialize(bool profile_supported) { gm_aol_reset(); - gm_aol_enabled = profile_supported && ((unsigned int)alternative_experience == GM_ALT_EXP_ALWAYS_ON_LATERAL); + gm_aol_enabled = profile_supported && (((unsigned int)alternative_experience == GM_ALT_EXP_ALWAYS_ON_LATERAL) || + (gm_cancel_remap_enabled && ((unsigned int)alternative_experience == (GM_ALT_EXP_ALWAYS_ON_LATERAL | GM_ALT_EXP_REMAP_CANCEL_TO_DISTANCE)))); if (gm_aol_enabled) { static const AolSafetyPolicy gm_aol_policy = { .reset = gm_aol_reset, diff --git a/opendbc_repo/opendbc/safety/modes/gm_bolt_cc.h b/opendbc_repo/opendbc/safety/modes/gm_bolt_cc.h index fc629a5a63..3f0d8a2c26 100644 --- a/opendbc_repo/opendbc/safety/modes/gm_bolt_cc.h +++ b/opendbc_repo/opendbc/safety/modes/gm_bolt_cc.h @@ -213,6 +213,14 @@ static unsigned int gm_bolt_cc_tag(uint16_t word) { static bool gm_bolt_cc_selected; static safety_config gm_bolt_cc_init(uint16_t word) { + const unsigned int experience = (unsigned int)alternative_experience; + gm_cancel_remap_enabled = ((experience == GM_ALT_EXP_REMAP_CANCEL_TO_DISTANCE) || + (experience == (GM_ALT_EXP_ALWAYS_ON_LATERAL | GM_ALT_EXP_REMAP_CANCEL_TO_DISTANCE))) && gm_cancel_remap_word(word); + if (((experience & GM_ALT_EXP_REMAP_CANCEL_TO_DISTANCE) != 0U) && !gm_cancel_remap_enabled) { + gm_bolt_cc_selected = false; + gm_hybrid_selected = false; + return nooutput_init(0U); + } gm_hybrid_rejected = ((word & 0xFF00U) == 0xE800U) && ((word > 0xE805U) || ((alternative_experience != 0) && (alternative_experience != 32))); gm_hybrid_selected = (word >= 0xE800U) && (word <= 0xE805U); diff --git a/opendbc_repo/opendbc/safety/tests/test_gm_cancel_remap.py b/opendbc_repo/opendbc/safety/tests/test_gm_cancel_remap.py new file mode 100644 index 0000000000..c5e518f88a --- /dev/null +++ b/opendbc_repo/opendbc/safety/tests/test_gm_cancel_remap.py @@ -0,0 +1,165 @@ +"""Known Dom bit64 is admitted only for exact supported pedal-long Bolt words.""" +import pytest +from opendbc.safety.tests.libsafety import libsafety_py +from opendbc.car.gm.tests.test_bolt_cc import native + + +@pytest.fixture(autouse=True) +def restore_native_configuration(): + safety = libsafety_py.libsafety + safety.set_alternative_experience(0) + try: + yield + finally: + safety.set_alternative_experience(0) + assert safety.set_safety_hooks(19, 0) == 0 + + +@pytest.mark.parametrize('word', [0, 5, 16, 0xE710, 0xE711, 0xE712, 0xE713, 0xC140, 0xE800]) +@pytest.mark.parametrize('experience', [64, 96]) +def test_remap_bit_on_unqualified_profile_has_no_native_output(word, experience): + safety = libsafety_py.libsafety + safety.init_tests() + safety.set_alternative_experience(experience) + assert safety.set_safety_hooks(4, word) == 0 + safety.set_aol_test_heartbeat(True) + safety.aol_set_host_request(3) + safety.set_controls_allowed(True) + assert safety.aol_get_permission_mask() == 0 + assert not native('tx', (0x180, bytes(4), 0), 1_000_000) + assert not native('tx', (0x200, bytes(6), 0), 1_000_000) + + +@pytest.mark.parametrize('experience', [65, 66, 97, 98, 127, 255]) +@pytest.mark.parametrize('word', [0xBD, 0x9D, 0x19D, 0x1CD, 0xE700, 0xE701, 0xE702, 0xE703]) +def test_known_profile_does_not_allow_unknown_bits_with_remap(word, experience): + safety = libsafety_py.libsafety + safety.init_tests() + safety.set_alternative_experience(experience) + assert safety.set_safety_hooks(4, word) == 0 + safety.set_aol_test_heartbeat(True) + safety.aol_set_host_request(3) + safety.set_controls_allowed(True) + assert safety.aol_get_permission_mask() == 0 + assert not native('tx', (0x180, bytes(4), 0), 1_000_000) + assert not native('tx', (0x200, bytes(6), 0), 1_000_000) + + +@pytest.mark.parametrize('word', [0x9D, 0x1CD, 0xE701, 0xE703]) +@pytest.mark.parametrize('experience', [64, 96]) +@pytest.mark.parametrize('condition', ['on', 'absent', 'off', 'stale']) +def test_remap_requires_fresh_physical_main_for_all_active_outputs(word, experience, condition): + from opendbc.can import CANPacker + from opendbc.car import Bus + from opendbc.car.gm.gmcan import create_pedal_command, create_steering_control, create_bolt_regen_paddle, create_bolt_regen_gear + from opendbc.car.gm.values import CAR, DBC + from opendbc.car.gm.tests.test_cc_gateway_stock import pt_frames + from opendbc.car.gm.tests.test_bolt_pedal import TestBoltPedalMessages + safety = libsafety_py.libsafety + gen2 = word in (0x1CD, 0xE703) + packer = CANPacker(DBC[CAR.CHEVROLET_BOLT_CC_2018_2021][Bus.pt]) + safety.init_tests() + safety.set_alternative_experience(experience) + assert safety.set_safety_hooks(4, word) == 0 + safety.reset_recorded_can() + now = 1_000_000 + + def feed(stamp, main, counter): + safety.set_timer(stamp) + packets = pt_frames(packer, main=main is not False, cruise=False, counter=counter % 4, acc_cruise=0) + packets = [packet for packet in packets if packet[0] not in (0x1F5, 0xC9)] + if main is not None: + packets.append(packer.make_can_msg('ECMEngineStatus', 0, {'CruiseMainOn': int(main)})) + packets.extend([packer.make_can_msg('ECMPRDNL2', 0, {'PRNDL2': 6}), + packer.make_can_msg('EBCMRegenPaddle', 0, {'RegenPaddle': 0}), + TestBoltPedalMessages.sensor(packer, 0., counter % 16), + (0x3D1, bytes(8), 0)]) + for packet in packets: + assert native('rx', packet, stamp) + safety.set_aol_test_heartbeat(True) + if experience == 96: + safety.aol_set_host_request(3) + feed(now, None if condition == 'absent' else True, 0) + feed(now + 10_000, None if condition == 'absent' else True, 1) + safety.set_controls_allowed(True) # Established full engagement before the Main transition. + if condition == 'off': + feed(now + 20_000, False, 2) + assert not safety.get_controls_allowed() + elif condition == 'stale': + # Refresh every other physical source so only the original Main receipt expires. + feed(now + 310_001, None, 2) + now += 310_001 if condition == 'stale' else 20_000 + safety.set_timer(now) + safety.set_torque_driver(0, 0) + safety.set_desired_torque_last(1) + safety.set_rt_torque_last(1) + expected = condition == 'on' + assert bool(native('tx', create_steering_control(packer, 0, 1, 0, True), now)) is expected + assert bool(native('tx', create_pedal_command(packer, .2, 1), now)) is expected + assert native('tx', create_steering_control(packer, 0, 0, 1, False), now) + assert native('tx', create_pedal_command(packer, 0., 2), now) + # Host paddle feeds are never sent directly. Stock RX invokes the checked scheduler. + safety.reset_recorded_can() + assert not native('tx', create_bolt_regen_paddle(packer, True), now) + assert not native('tx', create_bolt_regen_gear(packer, True, gen2), now) + native('rx', packer.make_can_msg('EBCMRegenPaddle', 0, {'RegenPaddle': 0}), now) + native('rx', packer.make_can_msg('ECMPRDNL2', 0, {'PRNDL2': 6}), now) + assert bool(safety.get_recorded_can_count()) is expected + if experience == 96 and not expected: + assert safety.aol_get_permission_mask() == 0 + + +@pytest.mark.parametrize('word', [0x9D, 0x1CD, 0xE701, 0xE703]) +@pytest.mark.parametrize('experience', [64, 96]) +@pytest.mark.parametrize('condition', ['off', 'stale']) +def test_previously_accepted_applied_paddle_feed_cannot_outlive_main(word, experience, condition): + from opendbc.can import CANPacker + from opendbc.car import Bus + from opendbc.car.gm.gmcan import create_bolt_regen_paddle, create_bolt_regen_gear + from opendbc.car.gm.values import CAR, DBC + from opendbc.car.gm.tests.test_cc_gateway_stock import pt_frames + from opendbc.car.gm.tests.test_bolt_pedal import TestBoltPedalMessages + safety = libsafety_py.libsafety + gen2 = word in (0x1CD, 0xE703) + packer = CANPacker(DBC[CAR.CHEVROLET_BOLT_CC_2018_2021][Bus.pt]) + safety.init_tests() + safety.set_alternative_experience(experience) + assert safety.set_safety_hooks(4, word) == 0 + + def feed(stamp, main, counter): + packets = pt_frames(packer, cruise=False, counter=counter % 4, acc_cruise=0) + packets = [packet for packet in packets if packet[0] not in (0xC9, 0x1F5)] + packets.extend([packer.make_can_msg('ECMPRDNL2', 0, {'PRNDL2': 6}), + packer.make_can_msg('EBCMRegenPaddle', 0, {'RegenPaddle': 0}), + TestBoltPedalMessages.sensor(packer, 0., counter % 16), (0x3D1, bytes(8), 0)]) + if main is not None: + packets.append(packer.make_can_msg('ECMEngineStatus', 0, {'CruiseMainOn': int(main)})) + for packet in packets: + assert native('rx', packet, stamp) + safety.set_aol_test_heartbeat(True) + if experience == 96: + safety.aol_set_host_request(3) + feed(1_000_000, True, 0) + # Main remains fresh at 280ms; the host feed lease remains fresh when Main later expires. + feed(1_280_000, None, 1) + safety.set_controls_allowed(True) + assert safety.aol_get_permission_mask() == (3 if experience == 96 else 0) + assert not native('tx', create_bolt_regen_paddle(packer, True), 1_280_000) + assert not native('tx', create_bolt_regen_gear(packer, True, gen2), 1_280_000) + # Prove the same feed actually emits while Main is fresh before testing withdrawal. + safety.reset_recorded_can() + native('rx', packer.make_can_msg('EBCMRegenPaddle', 0, {'RegenPaddle': 0}), 1_280_001) + native('rx', packer.make_can_msg('ECMPRDNL2', 0, {'PRNDL2': 6}), 1_280_001) + assert safety.get_recorded_can_count() == 2 + withdrawn = 1_290_000 if condition == 'off' else 1_310_001 + feed(withdrawn, False if condition == 'off' else None, 2) + safety.reset_recorded_can() + native('rx', packer.make_can_msg('EBCMRegenPaddle', 0, {'RegenPaddle': 0}), withdrawn + 1) + native('rx', packer.make_can_msg('ECMPRDNL2', 0, {'PRNDL2': 6}), withdrawn + 1) + assert safety.get_recorded_can_count() == 0 + # A new inactive feed can still release the synthetic paddle while Main is absent. + assert not native('tx', create_bolt_regen_paddle(packer, False), withdrawn + 2) + assert not native('tx', create_bolt_regen_gear(packer, False, gen2), withdrawn + 2) + native('rx', packer.make_can_msg('EBCMRegenPaddle', 0, {'RegenPaddle': 0}), withdrawn + 3) + native('rx', packer.make_can_msg('ECMPRDNL2', 0, {'PRNDL2': 6}), withdrawn + 3) + assert safety.get_recorded_can_count() == 2 diff --git a/openpilot/common/params_keys.h b/openpilot/common/params_keys.h index 00d8f11720..fac802b11e 100644 --- a/openpilot/common/params_keys.h +++ b/openpilot/common/params_keys.h @@ -150,6 +150,8 @@ inline static std::unordered_map keys = { {"StarButtonControl", {PERSISTENT, INT, "0"}}, {"LongStarButtonControl", {PERSISTENT, INT, "0"}}, {"VeryLongStarButtonControl", {PERSISTENT, INT, "0"}}, + {"CancelButtonControlsMigrated", {PERSISTENT, BOOL, "0"}}, + {"RemapCancelToDistance", {PERSISTENT, BOOL, "0"}}, {"CancelButtonControl", {PERSISTENT, INT, "0"}}, {"LongCancelButtonControl", {PERSISTENT, INT, "0"}}, {"VeryLongCancelButtonControl", {PERSISTENT, INT, "0"}}, diff --git a/openpilot/selfdrive/car/car_events.py b/openpilot/selfdrive/car/car_events.py index f3f397ad24..2d2b91fec8 100644 --- a/openpilot/selfdrive/car/car_events.py +++ b/openpilot/selfdrive/car/car_events.py @@ -159,7 +159,8 @@ class CarEvents: for b in CS.buttonEvents: # Disable on rising and falling edge of cancel for both stock and OP long # TODO: only check the cancel button with openpilot longitudinal on all brands to match panda safety - if b.type == ButtonType.cancel and (allow_button_cancel or not self.CP.pcmCruise): + from opendbc.car.gm.cancel_remap import enabled as gm_cancel_remap_enabled + if b.type == ButtonType.cancel and not gm_cancel_remap_enabled(self.CP) and (allow_button_cancel or not self.CP.pcmCruise): events.add(EventName.buttonCancel) # Handle permanent and temporary steering faults diff --git a/openpilot/selfdrive/car/card.py b/openpilot/selfdrive/car/card.py index ad9f0d4bdc..605f307af2 100755 --- a/openpilot/selfdrive/car/card.py +++ b/openpilot/selfdrive/car/card.py @@ -308,7 +308,7 @@ class Car: self.switchback_button_tracker = ButtonTracker() if self.switchback_capable else None self.switchback_settings_owner = ConditionalSettingsOwner(self.params) if self.switchback_capable else None self.gm_distance = GMDistanceButtons() if self.conditional_replay and gm_profiles_supported(self.CP) else None - self.gm_distance_map = WheelMapCache(include_ioniq_media=False) if self.gm_distance is not None else None + self.gm_distance_map = WheelMapCache(include_ioniq_media=False, cp=self.CP) if self.gm_distance is not None else None self.gm_distance_claim_tracker = ButtonTracker() if self.gm_distance is not None else None controller_available = self.CI.CC is not None and openpilot_enabled_toggle and not self.CP.dashcamOnly self.CP.passive = not controller_available or self.CP.dashcamOnly @@ -318,6 +318,8 @@ class Car: self.CP.safetyConfigs = [safety_config] startup_preferences.finalize(self.CP) + from opendbc.car.gm.cancel_remap import migrate_assignments as migrate_gm_cancel_assignments + migrate_gm_cancel_assignments(self.params, self.CP) startup_preferences.configure_controller(self.CI) configure_controller(self.CI, self.params) self.vehicle_startup.configure(self.CI) @@ -481,7 +483,7 @@ class Car: if (getattr(self.CI.CS, 'ev6_aol_source_healthy', False) or qualified_honda_stock_aol(self.CP, marked_only=True) or qualified_gm(self.CP) and is_bolt_present_no_acc_pedal_profile(self.CP) and - int(self.CP.alternativeExperience) == 32) else 0) + int(self.CP.alternativeExperience) in (32, 96)) else 0) self.observe_ioniq6_long_authority(can_list, CS) media_owner = getattr(self, 'ioniq6_media', None) media_observation = media_owner.update(can_list) if media_owner is not None else None diff --git a/openpilot/selfdrive/selfdrived/selfdrived.py b/openpilot/selfdrive/selfdrived/selfdrived.py index eb14254350..51651d610b 100755 --- a/openpilot/selfdrive/selfdrived/selfdrived.py +++ b/openpilot/selfdrive/selfdrived/selfdrived.py @@ -303,7 +303,7 @@ class SelfdriveD: self.events.add(EventName.belowSteerSpeed) paddle_pressed = paddle_cancel(self.CP, CS, enabled=self.enabled, saved=self.nostalgia_enabled) self.nostalgia_paddle_cancel |= bool(paddle_pressed and EventName.buttonCancel not in self.events.names and - not physical_cancel(CS)) + not physical_cancel(CS, self.CP)) if paddle_pressed: self.events.add(EventName.buttonCancel) @@ -549,7 +549,7 @@ class SelfdriveD: self.event_car_state = CS if reconcile_input: self._update_steering_authority(CS) - if any(physical_cancel(sample) for sample, _ in samples): + if any(physical_cancel(sample, self.CP) for sample, _ in samples): self.nostalgia_paddle_cancel = False if CS.canValid: if self.tesla_stock_consumer.binding is not None: @@ -884,7 +884,7 @@ class SelfdriveD: if any(not valid or not sample.canValid or sample.canTimeout or sample.steerFaultTemporary or sample.steerFaultPermanent or (sample.brakePressed or sample.regenBraking) and not held_brake or active_longitudinal and sample.gasPressed and not held_gas or - sample.seatbeltUnlatched or sample.accFaulted or sample.doorOpen or physical_cancel(sample) or + sample.seatbeltUnlatched or sample.accFaulted or sample.doorOpen or physical_cancel(sample, self.CP) or sample.gearShifter not in (car.CarState.GearShifter.drive, car.CarState.GearShifter.sport, car.CarState.GearShifter.low, car.CarState.GearShifter.brake) for sample, valid in samples): diff --git a/openpilot/starpilot/aol/panda_faults.py b/openpilot/starpilot/aol/panda_faults.py index 13bb9c1a63..cebcfcede6 100644 --- a/openpilot/starpilot/aol/panda_faults.py +++ b/openpilot/starpilot/aol/panda_faults.py @@ -4,13 +4,14 @@ Register divergence remains a protected-register diagnostic, not a benign-fault claim. Raw fault reports are retained. Every unlisted configuration/bit blocks. """ from openpilot.cereal import log +from opendbc.car.gm.cancel_remap import aol_enabled def _diagnostic_mask(cp): try: from opendbc.car.gm.aol import qualified_gm from opendbc.car.gm.values import CAR, is_volt_sdgm_profile, is_bolt_present_no_acc_pedal_profile - if cp.brand != 'gm' or not qualified_gm(cp) or int(cp.alternativeExperience) != 32 or len(cp.safetyConfigs) != 1: + if cp.brand != 'gm' or not qualified_gm(cp) or not aol_enabled(cp) or len(cp.safetyConfigs) != 1: return 0 word = int(cp.safetyConfigs[0].safetyParam) if (cp.carFingerprint == CAR.CHEVROLET_VOLT_2019 and word == 0x1005 and diff --git a/openpilot/starpilot/aol/source_availability.py b/openpilot/starpilot/aol/source_availability.py index 99e0a6e451..66c7576fed 100644 --- a/openpilot/starpilot/aol/source_availability.py +++ b/openpilot/starpilot/aol/source_availability.py @@ -71,7 +71,7 @@ def stock_lateral_fault_proof(self, CS, message, now_ns): samples = getattr(self, 'car_state_samples', ()) or ((CS, True),) if any(not valid or not sample.canValid or sample.canTimeout or sample.steerFaultPermanent or sample.steerFaultTemporary or sample.accFaulted or sample.doorOpen or sample.seatbeltUnlatched or - sample.brakePressed or sample.regenBraking or sample.gasPressed or physical_cancel(sample) or + sample.brakePressed or sample.regenBraking or sample.gasPressed or physical_cancel(sample, self.CP) or sample.gearShifter not in (car.CarState.GearShifter.drive, car.CarState.GearShifter.sport, car.CarState.GearShifter.low, car.CarState.GearShifter.brake) for sample, valid in samples): return None @@ -162,7 +162,7 @@ def ordinary_source_availability(self, CS, now_ns, native=None, *, replay: bool, bool(getattr(self, 'car_state_retained_valid', False) or held_metadata)),) if any(not valid or not sample.canValid or sample.canTimeout or sample.steerFaultPermanent or sample.steerFaultTemporary or sample.accFaulted or sample.doorOpen or sample.seatbeltUnlatched or - sample.brakePressed or sample.regenBraking or sample.gasPressed or physical_cancel(sample) or + sample.brakePressed or sample.regenBraking or sample.gasPressed or physical_cancel(sample, self.CP) or sample.gearShifter not in (car.CarState.GearShifter.drive, car.CarState.GearShifter.sport, car.CarState.GearShifter.low, car.CarState.GearShifter.brake) for sample, valid in samples): @@ -269,7 +269,7 @@ def fresh_ordinary_lag_continuation(self, CS, intent, native, now_ns): samples = getattr(self, 'car_state_samples', ()) or ((CS, True),) return not any(not valid or not sample.canValid or sample.canTimeout or sample.steerFaultPermanent or sample.steerFaultTemporary or sample.accFaulted or sample.doorOpen or sample.seatbeltUnlatched or - sample.brakePressed or sample.regenBraking or sample.gasPressed or physical_cancel(sample) or + sample.brakePressed or sample.regenBraking or sample.gasPressed or physical_cancel(sample, self.CP) or sample.gearShifter not in (car.CarState.GearShifter.drive, car.CarState.GearShifter.sport, car.CarState.GearShifter.low, car.CarState.GearShifter.brake) for sample, valid in samples) diff --git a/openpilot/starpilot/aol/tests/test_gm_cancel_remap.py b/openpilot/starpilot/aol/tests/test_gm_cancel_remap.py new file mode 100644 index 0000000000..91eb62b6a2 --- /dev/null +++ b/openpilot/starpilot/aol/tests/test_gm_cancel_remap.py @@ -0,0 +1,233 @@ +"""Actual parser/native/ordinary event boundaries for optional GM Cancel mapping.""" +from contextlib import ExitStack +import pytest +from opendbc.car.gm.tests.test_bolt_cc import native +from opendbc.car.gm.tests.test_bolt_pedal import params +from opendbc.car.gm.values import CAR +from opendbc.safety.tests.libsafety import libsafety_py +from openpilot.cereal import log +from openpilot.common.params import Params +from openpilot.common.prefix import OpenpilotPrefix +from openpilot.selfdrive.car.car_events import CarEvents +from openpilot.starpilot.aol.tests.test_gm import TestGmAol as GmCardFixture +from openpilot.starpilot.aol.tests.test_gm_held_native import NOW, parse as source_fixture +from openpilot.starpilot.nostalgia import physical_cancel + +IDS = (CAR.CHEVROLET_BOLT_CC_2017, CAR.CHEVROLET_BOLT_CC_2018_2021, + CAR.CHEVROLET_BOLT_CC_2022_2023, CAR.CHEVROLET_BOLT_ACC_2022_2023_PEDAL) + + +@pytest.fixture(autouse=True) +def restore_native_configuration(): + safety = libsafety_py.libsafety + safety.set_alternative_experience(0) + try: + yield + finally: + safety.set_alternative_experience(0) + assert safety.set_safety_hooks(19, 0) == 0 + + +def parse(card, now, gesture=None, counter=0, *, belt_unlatched=False, packets_only=False): + from opendbc.can import CANPacker + from opendbc.car import Bus + from opendbc.car.gm.values import DBC + packets = source_fixture(card, now, gesture, counter, belt_unlatched=belt_unlatched, packets_only=True) + if card.CP.carFingerprint == CAR.CHEVROLET_BOLT_ACC_2022_2023_PEDAL: + packer = CANPacker(DBC[card.CP.carFingerprint][Bus.pt]) + packets.append(packer.make_can_msg('ASCMActiveCruiseControlStatus', 2, {'ACCCmdActive': 0})) + if packets_only: + return packets + if now == NOW: + card.CI.update([(now - 1_000_000, packets)]) + observed = card.CI.update([(now, packets)]) + assert observed.canValid and not observed.canTimeout + assert observed.brakePressed == (gesture == 'brake') + assert observed.regenBraking == (gesture == 'paddle') + assert observed.seatbeltUnlatched == belt_unlatched + card._aol_can_observation = (observed, now, now, now) + return observed + + +@pytest.mark.parametrize('candidate', IDS) +@pytest.mark.parametrize('remap', [False, True]) +def test_actual_card_parser_native_and_event_cancel_contract(candidate, remap): + with ExitStack() as cleanup: + cleanup.enter_context(OpenpilotPrefix()) + saved = Params() + saved.put_bool('AlwaysOnLateral', True, block=True) + saved.put_bool('RemapCancelToDistance', remap, block=True) + cp = params(candidate, pedal=True, camera=True) + card = GmCardFixture.card(cp, saved) + cleanup.callback(card.vehicle_startup.close) + assert card.CP.alternativeExperience == (96 if remap else 32) + safety = libsafety_py.libsafety + safety.init_tests() + safety.set_alternative_experience(card.CP.alternativeExperience) + assert safety.set_safety_hooks(4, card.CP.safetyConfigs[0].safetyParam) == 0 + for tick in range(10): + now = NOW + tick * 10_000_000 + for packet in parse(card, now, counter=tick % 4, packets_only=True): + assert native('rx', packet, now // 1000) + parse(card, now, counter=tick % 4) + safety.set_aol_test_heartbeat(True) + safety.aol_set_host_request(3) + safety.set_controls_allowed(True) + assert safety.aol_get_permission_mask() == 3 + cancel_packet = (0x1E1, bytes.fromhex('00000000016490'), 0) + assert native('rx', cancel_packet, (NOW + 95_000_000) // 1000) + assert bool(safety.get_controls_allowed()) is remap + assert safety.aol_get_permission_mask() == (3 if remap else 1) + previous = parse(card, NOW + 100_000_000, counter=2) + observed = parse(card, NOW + 110_000_000, gesture='cancel', counter=3) + # Raw parser input stays visible for the existing secondary-action tracker. + assert any(str(event.type) == 'cancel' for event in observed.buttonEvents) + assert physical_cancel(observed, card.CP) is (not remap) + events = CarEvents(card.CP).update(observed, previous, card.CC_prev) + assert (log.OnroadEvent.EventName.buttonCancel in events.names) is (not remap) + card.aol_card_intent.update(previous, fault_active=False, now_ns=NOW + 100_000_000) + assert card.aol_card_intent.allowed_latch + card.aol_card_intent.update(observed, fault_active=False, now_ns=NOW + 110_000_000) + assert card.aol_card_intent.allowed_latch + # The remap never exempts a real regen withdrawal. + assert native('rx', (0xBD, bytes.fromhex('20000000000000'), 0), (NOW + 115_000_000) // 1000) + assert not safety.get_controls_allowed() and safety.aol_get_permission_mask() == 1 + + +@pytest.mark.parametrize('candidate', IDS) +@pytest.mark.parametrize('remap', [False, True]) +def test_actual_selfdrived_controls_preserve_full_op_only_for_configured_remap(candidate, remap): + from openpilot.starpilot.aol.tests import test_ioniq_availability as fixture + from opendbc.car.gm.cancel_remap import prepare + case = fixture.IoniqAvailabilityTests(methodName='runTest') + with ExitStack() as cleanup: + cleanup.callback(case.doCleanups) + cleanup.enter_context(OpenpilotPrefix()) + saved = Params() + saved.put_bool('RemapCancelToDistance', remap, block=True) + cp = params(candidate, pedal=True, camera=True) + prepare(cp, remap) + sd, _, step = case._authority_callers(True, cp=cp, genuine_events=True) + card = GmCardFixture.card(cp, saved) + cleanup.callback(card.vehicle_startup.close) + for tick in range(10): + parse(card, NOW + tick * 10_000_000, counter=tick % 4) + axis, state, command = step(tick) + assert state.enabled and axis.lateralActive and axis.longitudinalActive + assert command.latActive and command.longActive + observed = parse(card, NOW + 100_000_000, gesture='cancel', counter=2) + axis, state, command = step(10, observed_state=observed, allowed=True, + native_request_lat=True, native_request_long=True, long_allowed=remap, + panda_controls_allowed=remap) + assert bool(state.enabled) is remap + assert bool(axis.longitudinalActive) is remap and bool(command.longActive) is remap + assert log.OnroadEvent.EventName.controlsMismatch not in sd.events.names + if remap: + assert axis.lateralActive and command.latActive + + +@pytest.mark.parametrize('candidate', [IDS[1], IDS[3]]) +@pytest.mark.parametrize('remap', [False, True]) +@pytest.mark.parametrize('slot,frames', [(0, 1), (1, 50), (2, 250)]) +def test_actual_card_parser_dispatches_auxiliary_action_only_when_remapped(candidate, remap, slot, frames): + from openpilot.starpilot.aol.intent import read_settings + with ExitStack() as cleanup: + cleanup.enter_context(OpenpilotPrefix()) + saved = Params() + saved.put_bool('AlwaysOnLateral', True, block=True) + saved.put_bool('RemapCancelToDistance', remap, block=True) + keys = ('CancelButtonControl', 'LongCancelButtonControl', 'VeryLongCancelButtonControl') + for index, key in enumerate(keys): + saved.put(key, 3 if index == slot else 0, block=True) + card = GmCardFixture.card(params(candidate, pedal=True, camera=True), saved) + cleanup.callback(card.vehicle_startup.close) + owner = card.aol_card_intent + assert owner.settings == read_settings(saved) + neutral = parse(card, NOW, counter=0) + owner.update_auxiliary(neutral) + for tick in range(frames): + observed = parse(card, NOW + (tick + 1) * 10_000_000, gesture='cancel', counter=(tick + 1) % 4) + before = observed.to_dict() + owner.update_auxiliary(observed) + assert observed.to_dict() == before + release = parse(card, NOW + (frames + 1) * 10_000_000, counter=(frames + 1) % 4) + owner.update_auxiliary(release) + assert owner.pause_lateral is remap + + +@pytest.mark.parametrize('candidate', [IDS[1], IDS[3]]) +@pytest.mark.parametrize('remap', [False, True]) +@pytest.mark.parametrize('slot,frames', [(0, 1), (1, 50), (2, 250)]) +def test_actual_card_parser_and_wheel_publisher_dispatch_once(candidate, remap, slot, frames): + from openpilot.starpilot.controllers.wheel_actions import WheelPublisher + with ExitStack() as cleanup: + cleanup.enter_context(OpenpilotPrefix()) + saved = Params() + saved.put_bool('RemapCancelToDistance', remap, block=True) + keys = ('CancelButtonControl', 'LongCancelButtonControl', 'VeryLongCancelButtonControl') + for index, key in enumerate(keys): + saved.put(key, 8 if index == slot else 0, block=True) + card = GmCardFixture.card(params(candidate, pedal=True, camera=True), saved) + cleanup.callback(card.vehicle_startup.close) + publisher = WheelPublisher(saved) + commands = [] + drive_id = NOW - 1_000_000 + neutral = parse(card, NOW, counter=0) + publisher.observe(saved, card.CP, neutral, now_ns=NOW, drive_id=drive_id) + for tick in range(frames): + now = NOW + (tick + 1) * 10_000_000 + observed = parse(card, now, gesture='cancel', counter=(tick + 1) % 4) + commands.extend(publisher.observe(saved, card.CP, observed, now_ns=now, drive_id=drive_id)) + now = NOW + (frames + 1) * 10_000_000 + released = parse(card, now, counter=(frames + 1) % 4) + commands.extend(publisher.observe(saved, card.CP, released, now_ns=now, drive_id=drive_id)) + actions = [(key, action) for key, action in commands if key] + assert actions == ([(keys[slot], 8)] if remap else []) + assert all((key, action) == ('', 0) for key, action in commands if not key) + + +@pytest.mark.parametrize('candidate', IDS) +@pytest.mark.parametrize('remap', [False, True]) +def test_actual_card_parser_native_and_event_cancel_contract_aol_off(candidate, remap): + """Ordinary Card/native operation has no AOL owner or request at experience0/64.""" + with ExitStack() as cleanup: + cleanup.enter_context(OpenpilotPrefix()) + saved = Params() + saved.put_bool('AlwaysOnLateral', False, block=True) + saved.put_bool('RemapCancelToDistance', remap, block=True) + cp = params(candidate, pedal=True, camera=True) + card = GmCardFixture.card(cp, saved) + cleanup.callback(card.vehicle_startup.close) + assert not saved.get_bool('AlwaysOnLateral') + assert card.CP.alternativeExperience == (64 if remap else 0) + assert not card.aol_qualified and card.aol_card_intent is None + safety = libsafety_py.libsafety + safety.init_tests() + safety.set_alternative_experience(card.CP.alternativeExperience) + assert safety.set_safety_hooks(4, card.CP.safetyConfigs[0].safetyParam) == 0 + for tick in range(10): + now = NOW + tick * 10_000_000 + for packet in parse(card, now, counter=tick % 4, packets_only=True): + assert native('rx', packet, now // 1000) + parse(card, now, counter=tick % 4) + # The established ordinary full-engagement starting condition is explicit; + # unlike the AOL cases, this path never issues an AOL host request. + safety.set_controls_allowed(True) + assert safety.aol_get_request_mask() == 0 + assert safety.aol_get_permission_mask() == 0 + cancel_packet = (0x1E1, bytes.fromhex('00000000016490'), 0) + assert native('rx', cancel_packet, (NOW + 95_000_000) // 1000) + assert bool(safety.get_controls_allowed()) is remap + assert safety.aol_get_request_mask() == 0 + assert safety.aol_get_permission_mask() == 0 + previous = parse(card, NOW + 100_000_000, counter=2) + observed = parse(card, NOW + 110_000_000, gesture='cancel', counter=3) + assert any(str(event.type) == 'cancel' for event in observed.buttonEvents) + assert physical_cancel(observed, card.CP) is (not remap) + events = CarEvents(card.CP).update(observed, previous, card.CC_prev) + assert (log.OnroadEvent.EventName.buttonCancel in events.names) is (not remap) + assert card.aol_card_intent is None + assert native('rx', (0xBD, bytes.fromhex('20000000000000'), 0), (NOW + 115_000_000) // 1000) + assert not safety.get_controls_allowed() + assert safety.aol_get_request_mask() == 0 + assert safety.aol_get_permission_mask() == 0 diff --git a/openpilot/starpilot/aol/transport_pause.py b/openpilot/starpilot/aol/transport_pause.py index 11b6c4376e..fe225804d9 100644 --- a/openpilot/starpilot/aol/transport_pause.py +++ b/openpilot/starpilot/aol/transport_pause.py @@ -111,7 +111,7 @@ def stock_lateral_off(cp, cs, native): return bool(cp.pcmCruise and cs.cruiseState.available and not cs.cruiseState.enabled and cs.canValid and not cs.canTimeout and not cs.steerFaultPermanent and not cs.steerFaultTemporary and not cs.accFaulted and not cs.doorOpen and not cs.seatbeltUnlatched and - not cs.brakePressed and not cs.regenBraking and not cs.gasPressed and not physical_cancel(cs) and + not cs.brakePressed and not cs.regenBraking and not cs.gasPressed and not physical_cancel(cs, cp) and native is not None and native.requestedLateral and native.lateralAllowed and not native.requestedLongitudinal) @@ -286,7 +286,7 @@ class TransportPauseFeedback: not 0 < self.lateral_source_ns <= axis.sourceCarStateMonoTime <= axis.observedMonoTime <= now_ns or now_ns - axis.sourceCarStateMonoTime > AXIS_MAX_AGE_NS or not cs.canValid or cs.canTimeout or cs.steerFaultTemporary or cs.steerFaultPermanent or cs.accFaulted or - cs.steeringDisengage or physical_cancel(cs) or + cs.steeringDisengage or physical_cancel(cs, cp) or not sm.seen['onroadEvents'] or not sm.valid['onroadEvents'] or not sm.alive['onroadEvents'] or not 0 < int(sm.logMonoTime['onroadEvents']) <= now_ns or now_ns - int(sm.logMonoTime['onroadEvents']) > 1_500_000_000 or @@ -305,7 +305,7 @@ class TransportPauseFeedback: def _temporary_host_intent_current(self, sm, cp, cs, axis, stamp, now_ns, *, latched, producer): """A fresh inactive proof may retain intent, never native/output authority.""" from openpilot.starpilot.nostalgia import physical_cancel - if physical_cancel(cs) or cs.steeringDisengage: + if physical_cancel(cs, cp) or cs.steeringDisengage: return False events = sm['onroadEvents'] faults = [event for event in events if event.immediateDisable or event.softDisable] diff --git a/openpilot/starpilot/car/gm/aol.py b/openpilot/starpilot/car/gm/aol.py index 6e568a11c0..76ca76d84b 100644 --- a/openpilot/starpilot/car/gm/aol.py +++ b/openpilot/starpilot/car/gm/aol.py @@ -1,6 +1,7 @@ """GM-owned AOL capabilities; exact CP admission lives with the vehicle.""" import math +from opendbc.car.gm.cancel_remap import aol_enabled, enabled as cancel_remap_enabled, supported as cancel_remap_supported from opendbc.car.structs import CarParams from opendbc.car.gm.values import CAR, control_flags, camera_acc_pedal_profile, is_volt_ascm_longitudinal @@ -14,7 +15,7 @@ def policy_for(cp) -> AolVehiclePolicy: return AolVehiclePolicy(intent_supported=qualified, settings_supported=qualified, runtime_supported=qualified, normal_runtime_supported=qualified, lkas_button_supported=False, - full_axis_runtime_required=qualified and int(cp.alternativeExperience) == GM_AOL_ALTERNATIVE_EXPERIENCE, + full_axis_runtime_required=qualified and aol_enabled(cp), alternative_experience_addition=GM_AOL_ALTERNATIVE_EXPERIENCE if qualified else 0) @@ -23,7 +24,7 @@ def native_profile_supported(model: int, param: int) -> bool: def native_accepts_cp(cp, model: int, param: int) -> bool: - return (qualified_gm(cp) and int(cp.alternativeExperience) == GM_AOL_ALTERNATIVE_EXPERIENCE and + return (qualified_gm(cp) and aol_enabled(cp) and native_profile_supported(model, param)) @@ -40,6 +41,14 @@ class GmAolCardIntent(AolCardIntent): self.startup_calibration_pending = True self._startup_incomplete = False + def update_auxiliary(self, CS, *, media=None, media_eligible=False, fault_active=None): + semantic = CS + if (cancel_remap_supported(self.cp) and not cancel_remap_enabled(self.cp) and + any(str(event.type) == 'cancel' for event in CS.buttonEvents)): + semantic = CS.as_builder() if hasattr(CS, 'as_builder') else CS.as_reader().as_builder() + semantic.buttonEvents = [event for event in CS.buttonEvents if str(event.type) != 'cancel'] + super().update_auxiliary(semantic, media=media, media_eligible=media_eligible, fault_active=fault_active) + @property def main_action(self): # GM MAIN reports a persistent physical level, not an independent AOL toggle. @@ -209,7 +218,7 @@ def physical_longitudinal_withdrawal_source(cp, cs, parser, observation, now_ns) from opendbc.car.structs import car try: if (not qualified_gm(cp) or not is_bolt_present_no_acc_pedal_profile(cp) or - int(cp.alternativeExperience) != GM_AOL_ALTERNATIVE_EXPERIENCE or + not aol_enabled(cp) or observation is None or observation[0] is not cs or not cs.canValid or cs.canTimeout): return 0 fields = [] @@ -243,7 +252,7 @@ def retained_physical_longitudinal_intent(feedback, sm, cp, cs, axis, now_ns, *, from openpilot.starpilot.aol.transport_pause import independent_lateral_health occurrence = feedback.gm_withdrawal_ns or source_ns if (not qualified_gm(cp) or not is_bolt_present_no_acc_pedal_profile(cp) or - int(cp.alternativeExperience) != GM_AOL_ALTERNATIVE_EXPERIENCE or + not aol_enabled(cp) or not latched or feedback.failed or feedback.paused or feedback.pending_event or feedback.retention_started_ns or not feedback.session or str(axis.sessionId) != feedback.session or not feedback.healthy_feedback_ns or not 0 <= now_ns - feedback.healthy_feedback_ns <= AXIS_MAX_AGE_NS or @@ -296,7 +305,7 @@ def physical_cancel_lateral_allowed(cp, cs, intent, native, *, source_ns, now_ns from openpilot.starpilot.aol.runtime import AXIS_MAX_AGE_NS, SAFETY_MAX_AGE_NS, native_inventory_matches_cp try: return bool(qualified_gm(cp) and is_bolt_present_no_acc_pedal_profile(cp) and - int(cp.alternativeExperience) == GM_AOL_ALTERNATIVE_EXPERIENCE and + aol_enabled(cp) and intent is not None and intent.settingsQualified and intent.allowedLatch and intent.lateralArmed and intent.producerSessionId and intent.carStateLogMonoTime == source_ns and 0 < source_ns <= intent.observedMonoTime <= now_ns <= intent.validUntilMonoTime and @@ -322,7 +331,7 @@ def seatbelt_lateral_soft_event_allowed(cp, cs, intent, native, event, *, source from openpilot.starpilot.aol.runtime import AXIS_MAX_AGE_NS, SAFETY_MAX_AGE_NS, native_inventory_matches_cp try: return bool(is_bolt_present_no_acc_pedal_profile(cp) and - int(cp.alternativeExperience) == GM_AOL_ALTERNATIVE_EXPERIENCE and + aol_enabled(cp) and event.name == log.OnroadEvent.EventName.seatbeltNotLatched and event.softDisable and event.noEntry and not event.immediateDisable and not event.userDisable and cs.seatbeltUnlatched and cs.canValid and not cs.canTimeout and not cs.accFaulted and not cs.doorOpen and diff --git a/openpilot/starpilot/conditional_mode/button_actions.py b/openpilot/starpilot/conditional_mode/button_actions.py index 27f6b13f3b..3ec5e7d2e6 100644 --- a/openpilot/starpilot/conditional_mode/button_actions.py +++ b/openpilot/starpilot/conditional_mode/button_actions.py @@ -81,12 +81,13 @@ def display_action(raw: bytes | None) -> tuple[str, tuple[str, ...], str]: return "Unsupported saved action", (), "Off" -def runtime_map_ready(sources: ButtonSources) -> bool: +def runtime_map_ready(sources: ButtonSources, *, cp=None) -> bool: """Mirror the runtime map's scalar admission using the already captured bytes.""" if not sources.readable or any(raw is not None and raw not in _CANONICAL_ACTIONS for _, raw in sources.dependencies[2:]): return False - return all(sources.raw(key) != b"5" for key in + from opendbc.car.gm.cancel_remap import enabled as gm_cancel_remap_enabled + return gm_cancel_remap_enabled(cp) or all(sources.raw(key) != b"5" for key in ("CancelButtonControl", "LongCancelButtonControl", "VeryLongCancelButtonControl")) diff --git a/openpilot/starpilot/conditional_mode/card_input.py b/openpilot/starpilot/conditional_mode/card_input.py index 25f10f1033..ff0aa6e873 100644 --- a/openpilot/starpilot/conditional_mode/card_input.py +++ b/openpilot/starpilot/conditional_mode/card_input.py @@ -60,7 +60,7 @@ def conditional_manual_candidate(CS: car.CarState, tracker: ButtonTracker, param if not media.valid: tracker.observe_media(media) elif media.samples: - buttons = media_buttons if media_map_captured else read_button_map(params, include_ioniq_media=True) + buttons = media_buttons if media_map_captured else read_button_map(params, include_ioniq_media=True, cp=CP) if buttons is None: tracker.invalidate_media() return None @@ -73,9 +73,9 @@ def conditional_manual_candidate(CS: car.CarState, tracker: ButtonTracker, param return None if gesture.button in (Button.LKAS, Button.DISTANCE): # Read the saved mapping on each physical gesture so live remaps apply without 0x448. - buttons = read_button_map(params, include_ioniq_media=ioniq_media) + buttons = read_button_map(params, include_ioniq_media=ioniq_media, cp=CP) elif buttons is None: - buttons = media_buttons if media_map_captured and ioniq_media else read_button_map(params, include_ioniq_media=ioniq_media) + buttons = media_buttons if media_map_captured and ioniq_media else read_button_map(params, include_ioniq_media=ioniq_media, cp=CP) if buttons is None or not buttons.assigned(gesture): return None return gesture, drive_id, fingerprint, choice, now_ns @@ -130,7 +130,7 @@ def conditional_traffic_candidate(CS: car.CarState, tracker: ButtonTracker, para if tracker.media_context is not None and tracker.media_context[:2] != (drive_id, fingerprint): tracker.invalidate_media() return None - buttons = media_buttons if media_map_captured else read_button_map(params, include_ioniq_media=not gm_distance) + buttons = media_buttons if media_map_captured else read_button_map(params, include_ioniq_media=not gm_distance, cp=CP) if buttons is None: tracker.invalidate_media() return None diff --git a/openpilot/starpilot/conditional_mode/manual.py b/openpilot/starpilot/conditional_mode/manual.py index e91470dbcf..f72b97f82d 100644 --- a/openpilot/starpilot/conditional_mode/manual.py +++ b/openpilot/starpilot/conditional_mode/manual.py @@ -102,8 +102,8 @@ def _action(params, key: str) -> int | None: return int(raw) -def read_button_map(params, *, include_ioniq_media: bool = False) -> ButtonMap | None: - """Read registered slots; frozen GM-only cancel remap stays inert here.""" +def read_button_map(params, *, include_ioniq_media: bool = False, cp=None) -> ButtonMap | None: + """Read non-Cancel slots; the exact GM owner dispatches remapped Cancel separately.""" keys = ('LKASButtonControl', 'DistanceButtonControl', 'LongDistanceButtonControl', 'VeryLongDistanceButtonControl', 'CancelButtonControl', 'LongCancelButtonControl', 'VeryLongCancelButtonControl') @@ -115,7 +115,8 @@ def read_button_map(params, *, include_ioniq_media: bool = False) -> ButtonMap | return None assert all(value is not None for value in values) # Cancel-to-distance is GM Pedal-only; this gesture source must not reinterpret cancel. - if EXPERIMENTAL_MODE_ACTION in values[4:7]: + from opendbc.car.gm.cancel_remap import enabled as gm_cancel_remap_enabled + if EXPERIMENTAL_MODE_ACTION in values[4:7] and not gm_cancel_remap_enabled(cp): return None return ButtonMap(*(cast(int, value) for value in (*values[:4], *values[7:]))) @@ -128,8 +129,9 @@ class WheelMapCache: consumer can interpret that source packet. """ - def __init__(self, *, include_ioniq_media=True): + def __init__(self, *, include_ioniq_media=True, cp=None): self.include_ioniq_media = include_ioniq_media + self.cp = cp self.buttons: ButtonMap | None = None self.checked_ns = 0 self.held: tuple[bool, ...] | None = None @@ -169,7 +171,7 @@ class WheelMapCache: self.last_sample_ns = sample.source_boot_ns if (changed_epoch or self.checked_ns == 0 or edge or boundary or now_ns < self.checked_ns or now_ns - self.checked_ns >= 1_000_000_000): - self.buttons = read_button_map(params, include_ioniq_media=self.include_ioniq_media) + self.buttons = read_button_map(params, include_ioniq_media=self.include_ioniq_media, cp=self.cp) self.checked_ns = now_ns return self.buttons diff --git a/openpilot/starpilot/conditional_mode/tests/test_manual.py b/openpilot/starpilot/conditional_mode/tests/test_manual.py index 357f506fbb..c4002d3ed9 100644 --- a/openpilot/starpilot/conditional_mode/tests/test_manual.py +++ b/openpilot/starpilot/conditional_mode/tests/test_manual.py @@ -30,9 +30,10 @@ class ManualSettingsTests(unittest.TestCase): original = read_button_map calls = [] - def read_once(source, *, include_ioniq_media=False): + def read_once(source, *, include_ioniq_media=False, cp=None): + self.assertIsNone(cp) calls.append(include_ioniq_media) - return original(source, include_ioniq_media=include_ioniq_media) + return original(source, include_ioniq_media=include_ioniq_media, cp=cp) def packet(mode, custom, stamp, now): media = MediaObservation((MediaSample(mode, custom, stamp),), stamp, 0, True) diff --git a/openpilot/starpilot/conditional_mode/traffic.py b/openpilot/starpilot/conditional_mode/traffic.py index 97944fc983..99a43129b6 100644 --- a/openpilot/starpilot/conditional_mode/traffic.py +++ b/openpilot/starpilot/conditional_mode/traffic.py @@ -138,7 +138,7 @@ class TrafficOwner: if (self.cached_buttons is None or incoming_toggle or (incoming_map and incoming_map != self.map_fingerprint) or now_mono_ns - self.map_checked_ns >= 1_000_000_000): - buttons = read_button_map(params, include_ioniq_media=not gm_distance) + buttons = read_button_map(params, include_ioniq_media=not gm_distance, cp=cp) self.map_checked_ns = now_mono_ns if buttons is None: prior = self.requested diff --git a/openpilot/starpilot/conditional_mode/traffic_launch.py b/openpilot/starpilot/conditional_mode/traffic_launch.py index e9eed66b83..e7b8cec4ba 100644 --- a/openpilot/starpilot/conditional_mode/traffic_launch.py +++ b/openpilot/starpilot/conditional_mode/traffic_launch.py @@ -7,8 +7,9 @@ from openpilot.starpilot.conditional_mode.traffic_status import TrafficDisplayPr class TrafficLaunchState: - def __init__(self, params): + def __init__(self, params, *, cp=None): self.params = params + self.cp = cp self.settings = ConditionalSettingsOwner(params) self.projector = TrafficDisplayProjector() self.buttons = None @@ -16,7 +17,7 @@ class TrafficLaunchState: def sample(self, sm, *, now_ns, boot_ns, drive_id): if now_ns < self.checked_ns or now_ns - self.checked_ns >= 1_000_000_000: - self.buttons = read_button_map(self.params) + self.buttons = read_button_map(self.params, cp=self.cp) self.checked_ns = now_ns if self.buttons is None: return None diff --git a/openpilot/starpilot/controllers/mode_actions.py b/openpilot/starpilot/controllers/mode_actions.py index ca5de1c384..e2d3d7b2bb 100644 --- a/openpilot/starpilot/controllers/mode_actions.py +++ b/openpilot/starpilot/controllers/mode_actions.py @@ -221,7 +221,7 @@ def apply_switchback_gesture(owner, event, *, params, settings, sm, cp, now_ns: snapshot = settings.refresh(now_ns) verdict = settings.verdict(snapshot, now_mono_ns=now_ns, drive_id=drive) fingerprint = settings_fingerprint(snapshot) - buttons = read_button_map(params, include_ioniq_media=True) + buttons = read_button_map(params, include_ioniq_media=True, cp=cp) if verdict.status != 'ready' or verdict.safe_mode is not False or fingerprint is None or buttons is None: return False session, sequence = str(wire.sessionId), int(wire.sequence) diff --git a/openpilot/starpilot/controllers/wheel_actions.py b/openpilot/starpilot/controllers/wheel_actions.py index c6330cf503..149bee4613 100644 --- a/openpilot/starpilot/controllers/wheel_actions.py +++ b/openpilot/starpilot/controllers/wheel_actions.py @@ -138,6 +138,11 @@ class WheelPublisher: state, media if ioniq6_media_eligible(cp) else None)] for tracker, source, keys in ((self.cancel_tracker, structs.CarState.ButtonEvent.Type.cancel, KEYS[5:8]), (self.main_tracker, structs.CarState.ButtonEvent.Type.mainCruise, KEYS[1:2])): + if source == structs.CarState.ButtonEvent.Type.cancel and cp.brand == 'gm': + from opendbc.car.gm.cancel_remap import enabled as gm_cancel_remap_enabled, supported as gm_cancel_remap_supported + if gm_cancel_remap_supported(cp) and not gm_cancel_remap_enabled(cp): + self.cancel_tracker = ButtonTracker() + continue if not any(mapping[key] in DELIVERED_ACTIONS for key in keys): continue copy = structs.CarState(canValid=state.canValid, canTimeout=state.canTimeout) diff --git a/openpilot/starpilot/longitudinal/inputs.py b/openpilot/starpilot/longitudinal/inputs.py index 5b9f830003..0392391b43 100644 --- a/openpilot/starpilot/longitudinal/inputs.py +++ b/openpilot/starpilot/longitudinal/inputs.py @@ -115,7 +115,7 @@ class LongitudinalInputs: if self.gm_start_enabled: self.gm_boot_offset_ns = None self.gm_source_floor_ns = 0 - self.gm_traffic_state = TrafficLaunchState(self.params) + self.gm_traffic_state = TrafficLaunchState(self.params, cp=self.CP) self.gm_profile_host = ProfileHost(self.params) if feature_enabled(self.params, self.CP, 'profile', os.environ) else None if self.toyota_sienna_replay or self.toyota_sienna_production or self.toyota_corolla_stop: self.toyota_boot_offset_ns: int | None = None diff --git a/openpilot/starpilot/nostalgia.py b/openpilot/starpilot/nostalgia.py index 0ee26b2884..0e2b3bb535 100644 --- a/openpilot/starpilot/nostalgia.py +++ b/openpilot/starpilot/nostalgia.py @@ -23,7 +23,10 @@ def paddle_cancel(cp, cs, *, enabled: bool, saved: bool) -> bool: any(event.type == ButtonType.altButton2 and event.pressed for event in cs.buttonEvents)) -def physical_cancel(cs) -> bool: +def physical_cancel(cs, cp=None) -> bool: + from opendbc.car.gm.cancel_remap import enabled as gm_cancel_remap_enabled + if cp is not None and gm_cancel_remap_enabled(cp): + return False return any(event.type == ButtonType.cancel for event in cs.buttonEvents) diff --git a/openpilot/starpilot/ui/conditional_feature.py b/openpilot/starpilot/ui/conditional_feature.py index e38b5cd5c2..6ed22e2f9c 100644 --- a/openpilot/starpilot/ui/conditional_feature.py +++ b/openpilot/starpilot/ui/conditional_feature.py @@ -101,7 +101,7 @@ class ConditionalFeature: sources = capture_sources(self.owner.params, document, safe) enabled = (self.owner.authority("switchback_wheel" if switchback_only else "conditional_wheel") and config_valid and safe_readable and safe in (None, b"0", b"1") and sources.readable) - ready = runtime_map_ready(sources) and all(display_action(sources.raw(key))[0] != "Unsupported saved action" + ready = runtime_map_ready(sources, cp=cp) and all(display_action(sources.raw(key))[0] != "Unsupported saved action" for key in keys) rows = [] if ready else [FeatureRow("", "Button assignments", "Review saved actions", diff --git a/openpilot/starpilot/ui/feature_settings_owner.py b/openpilot/starpilot/ui/feature_settings_owner.py index 8286bb8292..998a554791 100644 --- a/openpilot/starpilot/ui/feature_settings_owner.py +++ b/openpilot/starpilot/ui/feature_settings_owner.py @@ -75,7 +75,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, - "LongPitch": True, "DisableOpenpilotLongitudinal": False, + "RemapCancelToDistance": False, "LongPitch": True, "DisableOpenpilotLongitudinal": False, "SpeedLimitController": False, "ShowSpeedLimits": True, "SLCConfirmation": False, "PauseLateralOnSignal": False, @@ -151,6 +151,8 @@ class FeatureSettingsOwner: configuration_longitudinal=self.configuration_longitudinal) self.long_profiles = LongProfileFeature(self) self.tesla_screen = TeslaScreenFeature(self) + from openpilot.starpilot.ui.gm_cancel_remap_feature import GmCancelRemapFeature + self.gm_cancel_remap = GmCancelRemapFeature(self) self.gm_tune = GmTuneFeature(self) self.gm_truck = GmTruckFeature(self) self.gm_ev_preset = GmEvPresetFeature(self) @@ -737,6 +739,7 @@ class FeatureSettingsOwner: "native safety and ECU configuration are unchanged."), capability=radar_capability, default_value="Off")) rows.extend(self.tesla_screen.rows(parked)) + rows.extend(self.gm_cancel_remap.rows(parked)) rows.extend(self.gm_tune.rows(parked)) rows.extend(self.gm_truck.rows(parked)) rows.extend(self.gm_ev_preset.rows(parked)) @@ -1169,6 +1172,8 @@ class FeatureSettingsOwner: return self.controller.apply_setup(request) if key == 'ForceAutoTuneOff': return self.controller.apply_learning(request) + if key == "RemapCancelToDistance": + return self.gm_cancel_remap.apply(request) if key == "GMPedalLongitudinal": return self._apply_pedal_setup(request) if key in TESLA_SCREEN_KEYS: diff --git a/openpilot/starpilot/ui/gm_cancel_remap_feature.py b/openpilot/starpilot/ui/gm_cancel_remap_feature.py new file mode 100644 index 0000000000..336263c9db --- /dev/null +++ b/openpilot/starpilot/ui/gm_cancel_remap_feature.py @@ -0,0 +1,35 @@ +"""Saved next-drive secondary Cancel input on exact supported pedal-long Bolts.""" +import hashlib +from dataclasses import replace +from opendbc.car.gm.cancel_remap import KEY, supported +from openpilot.starpilot.saved_document import commit_exact + + +class GmCancelRemapFeature: + def __init__(self, owner): + self.owner = owner + + def capability(self): + cp = self.owner.vehicle_params() + if not supported(cp): + return None + reader = cp.as_reader() if hasattr(cp, "as_reader") else cp + return (str(cp.carFingerprint), hashlib.sha256(reader.as_builder().to_bytes()).hexdigest()) + + def rows(self, parked): + capability = self.capability() + if capability is None: + return [] + row = self.owner._bool_row(KEY, "Use Cancel as Secondary Button", parked and self.owner.authority("parked_preferences"), + "Use the Cancel button assignments in Wheel Controls instead of cancelling cruise. Applies next drive.") + return [replace(row, capability=capability, available=row.available and self.owner._readable(KEY))] + + def apply(self, request): + capability = self.capability() + if capability is None or request.capability != capability or request.value not in ("Off", "On"): + return False + def authorized(): + return self.owner.authority("parked_preferences") and self.capability() == capability + result = commit_exact(self.owner.params, key=KEY, max_bytes=8, expected=request.expected, + raw=b"1" if request.value == "On" else b"0", authorized=authorized, temp_prefix=".gm-cancel-") + return result.committed and result.verified diff --git a/openpilot/starpilot/ui/wheel_feature.py b/openpilot/starpilot/ui/wheel_feature.py index e78e66ab63..c47c47cf45 100644 --- a/openpilot/starpilot/ui/wheel_feature.py +++ b/openpilot/starpilot/ui/wheel_feature.py @@ -42,6 +42,10 @@ class WheelFeature: return () if key in KEYS[:2] and policy.fixed_cruise_buttons: return () + if key in KEYS[5:8] and cp.brand == 'gm': + from opendbc.car.gm.cancel_remap import supported + if supported(cp) and read_saved(self.owner.params, 'RemapCancelToDistance', 8) != (b'1', True): + return () if key in KEYS[8:] and not media: return () if key in KEYS[:5] and not policy.settings_supported: diff --git a/openpilot/starpilot/vehicle_preferences.py b/openpilot/starpilot/vehicle_preferences.py index f06680026c..ff2f283b4f 100644 --- a/openpilot/starpilot/vehicle_preferences.py +++ b/openpilot/starpilot/vehicle_preferences.py @@ -24,6 +24,7 @@ class VehicleStartupPreferences: tesla_preap_stock: bool = True volt_sng: bool = False gm_auto_hold: bool = False + gm_cancel_remap: bool = False volt_one_pedal: bool = False tesla_screen: bool = False tesla_screen_brake: bool = False @@ -87,6 +88,8 @@ class VehicleStartupPreferences: read_saved(params, "LockDoors", 8) == (b"1", True)), toyota_unlock_doors=bool(enabled and readable and safe in (None, b"0") and read_saved(params, "UnlockDoors", 8) == (b"1", True)), + gm_cancel_remap=bool(enabled and not disable_bolt and readable and safe in (None, b"0") and + read_saved(params, "RemapCancelToDistance", 8) == (b"1", True)), volt_sng=volt_sng, gm_auto_hold=gm_auto_hold, volt_one_pedal=volt_one_pedal, gm_camera_pedal=bool(enabled and not disable_bolt and readable and safe in (None, b"0")), toyota_filter=bool(enabled and not disable_bolt and readable and safe in (None, b"0")), @@ -154,6 +157,8 @@ class VehicleStartupPreferences: self._prepare_camera_pedal(cp) apply_gm_auto_hold(cp, self.gm_auto_hold) apply_volt_one_pedal(cp, self.volt_one_pedal and not self.disable_bolt_long, self.gm_auto_hold) + from opendbc.car.gm.cancel_remap import prepare as prepare_cancel_remap + prepare_cancel_remap(cp, self.gm_cancel_remap) if cp.brand == "toyota": apply_toyota_auto_hold(cp, self.toyota_auto_hold) from opendbc.car.toyota.ars510_preferences import apply_radar_mode @@ -179,6 +184,8 @@ class VehicleStartupPreferences: self._prepare_camera_pedal(cp) apply_gm_auto_hold(cp, self.gm_auto_hold and admitted_hold) apply_volt_one_pedal(cp, self.volt_one_pedal and admitted_one_pedal and not self.disable_bolt_long, self.gm_auto_hold and admitted_hold) + from opendbc.car.gm.cancel_remap import prepare as prepare_cancel_remap + prepare_cancel_remap(cp, self.gm_cancel_remap) if cp.brand == "toyota": admitted = bool(cp.flags & ToyotaFlags.AUTO_BRAKE_HOLD) apply_toyota_auto_hold(cp, self.toyota_auto_hold and admitted)