From f50aaa1d6dace266220b7f23b4ff85db54b8ea83 Mon Sep 17 00:00:00 2001 From: firestar5683 <168790843+firestar5683@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:03:05 -0500 Subject: [PATCH] Handle Hyundai longitudinal withdrawal before brake receipt --- openpilot/selfdrive/selfdrived/selfdrived.py | 3 +- .../tests/test_hkg_early_long_withdrawal.py | 156 ++++++++++++++++++ openpilot/starpilot/aol/transport_pause.py | 4 +- openpilot/starpilot/car/hyundai/aol.py | 6 + prebuilt.json | 2 +- tools/ci/run_vehicle_tests.py | 1 + 6 files changed, 168 insertions(+), 4 deletions(-) mode change 100755 => 100644 openpilot/selfdrive/selfdrived/selfdrived.py create mode 100644 openpilot/starpilot/aol/tests/test_hkg_early_long_withdrawal.py diff --git a/openpilot/selfdrive/selfdrived/selfdrived.py b/openpilot/selfdrive/selfdrived/selfdrived.py old mode 100755 new mode 100644 index 97ac62c7a6..7b2f26f732 --- a/openpilot/selfdrive/selfdrived/selfdrived.py +++ b/openpilot/selfdrive/selfdrived/selfdrived.py @@ -1748,6 +1748,7 @@ class SelfdriveD: return False from openpilot.starpilot.car.gm.aol import native_accepts_cp as gm_native_accepts_cp from openpilot.starpilot.car.toyota.aol import qualified as toyota_aol_qualified + from openpilot.starpilot.car.hyundai.aol import early_native_long_withdrawal_supported # Pandad reads health before its final native permission read. A GM driver # withdrawal between those reads legitimately leaves the earlier health # controlsAllowed true. For an admitted OP-long GM profile, the current @@ -1760,7 +1761,7 @@ class SelfdriveD: # Retain only its already granted LAT; all proof, health and LONG withdrawal gates remain. # The exact vehicle policies and all proof/health checks above remain required. return bool(gm_native_accepts_cp(self.CP, native.safetyModel, native.safetyParam) or - qualified_ioniq6(self.CP) or + early_native_long_withdrawal_supported(self.CP) or toyota_aol_qualified(self.CP, marked_only=True) or not native.pandaInventory[owners[0]].controlsAllowed and not self.sm['pandaStates'][owners[0]].controlsAllowed) diff --git a/openpilot/starpilot/aol/tests/test_hkg_early_long_withdrawal.py b/openpilot/starpilot/aol/tests/test_hkg_early_long_withdrawal.py new file mode 100644 index 0000000000..753f3a5ea3 --- /dev/null +++ b/openpilot/starpilot/aol/tests/test_hkg_early_long_withdrawal.py @@ -0,0 +1,156 @@ +"""EV6 brake receipt ordering retains only native-granted lateral permission.""" +from contextlib import ExitStack +from dataclasses import replace + +import pytest + +from openpilot.cereal import log, messaging +from openpilot.common.prefix import OpenpilotPrefix +from openpilot.starpilot.aol.runtime import current_native +from openpilot.starpilot.aol.transport_pause import TransportPauseFeedback +from openpilot.starpilot.aol.wire import encode_safety +from openpilot.starpilot.aol.tests import test_ioniq_availability as fixture +from openpilot.starpilot.aol.tests.test_ev6_physical_authorization import ev6_params + +BASE = 10_000_000_000 + + +def wire(sd, now, *, health_controls=True, lateral=True): + prior = current_native(sd.sm, sd.CP, now_ns=now - 10_000_000, axis_session_id=sd.aol_session_id) + assert prior is not None + state = replace(prior, observedMonoTime=now, validUntilMonoTime=now + 200_000_000, + lateralAllowed=lateral, longitudinalAllowed=False, sourcePandaStatesMonoTime=now, + pandaInventory=tuple(replace(slot, controlsAllowed=health_controls) for slot in prior.pandaInventory)) + message = messaging.new_message('aolSafetyWire', 0, valid=True, logMonoTime=now) + message.aolSafetyWire = encode_safety(state) + return message + + +def callers(case): + return case._authority_callers(True, cp=ev6_params(), genuine_events=True) + + +def test_ev6_early_brake_mask_is_not_an_immediate_fault(): + case = fixture.IoniqAvailabilityTests(methodName='runTest') + with ExitStack() as cleanup: + cleanup.callback(case.doCleanups) + cleanup.enter_context(OpenpilotPrefix()) + sd, _, drive = callers(case) + for tick in range(10): + axis, state, command = drive(tick) + assert state.enabled and axis.lateralActive and axis.longitudinalActive + assert command.latActive and command.longActive + now = BASE + 100_000_000 + # Native health is read before its mask; latest PandaStates may already + # report the brake, while parsed carState reaches selfdrive one tick later. + native = wire(sd, now) + axis, state, command = drive(10, native_request_lat=True, native_request_long=True, + long_allowed=False, panda_controls_allowed=False, safety_message=native) + assert log.OnroadEvent.EventName.controlsMismatch not in sd.events.names, 'EV6_EARLY_NATIVE_LONG_WITHDRAWAL_MISCLASSIFIED' + assert state.enabled and axis.lateralActive and command.latActive + assert not axis.longitudinalActive and not command.longActive + assert sd.mismatch_counter > 0 + axis, state, command = drive(11, brake=True, native_request_lat=True, native_request_long=True, + long_allowed=False, panda_controls_allowed=False) + assert not state.enabled and not state.active + assert axis.lateralActive and command.latActive + assert not axis.longitudinalActive and not command.longActive + assert log.OnroadEvent.EventName.controlsMismatch not in sd.events.names + + +def test_card_feedback_accepts_only_existing_lateral_grant_during_early_long_drop(): + case = fixture.IoniqAvailabilityTests(methodName='runTest') + with ExitStack() as cleanup: + cleanup.callback(case.doCleanups) + cleanup.enter_context(OpenpilotPrefix()) + sd, _, drive = callers(case) + feedback = TransportPauseFeedback(sd.CP) + sm = messaging.SubMaster(['aolAxisState', 'onroadEvents', 'aolSafetyWire', 'pandaStates']) + for tick in range(10): + drive(tick) + now = BASE + tick * 10_000_000 + frames = [sd.pm.messages['aolAxisState'], sd.pm.messages['onroadEvents']] + for name in ('aolSafetyWire', 'pandaStates'): + event = messaging.new_message(name, len(sd.sm[name]), valid=sd.sm.valid[name], logMonoTime=sd.sm.logMonoTime[name]) + setattr(event, name, sd.sm[name]) + frames.append(event.as_reader()) + sm.update_msgs(now / 1e9, frames) + assert not feedback.observe(sm, sd.CP, sd.CS_prev, now, latched=True, intent_producer_session='authority-card') + now = BASE + 100_000_000 + sm.update_msgs(now / 1e9, [wire(sd, now).as_reader()]) + assert not feedback.observe(sm, sd.CP, sd.CS_prev, now, latched=True, + intent_producer_session='authority-card'), 'EV6_CARD_EARLY_LONG_WITHDRAWAL_CLEARED_INTENT' + assert not feedback.failed + + +@pytest.mark.parametrize('fault', ('lateral_denied', 'invalid', 'rx', 'heartbeat', 'inventory', 'session', 'expired')) +def test_early_long_classification_never_retains_denied_or_invalid_lateral(fault): + case = fixture.IoniqAvailabilityTests(methodName='runTest') + with ExitStack() as cleanup: + cleanup.callback(case.doCleanups) + cleanup.enter_context(OpenpilotPrefix()) + sd, _, drive = callers(case) + for tick in range(2): + axis, _, command = drive(tick) + assert axis.lateralActive and command.latActive and command.longActive + now = BASE + 20_000_000 + event = wire(sd, now, lateral=fault != 'lateral_denied') + from openpilot.starpilot.aol.wire import decode_safety + native = decode_safety(event.aolSafetyWire) + assert native is not None + if fault == 'invalid': + event.valid = False + elif fault in ('rx', 'heartbeat', 'inventory'): + field, value = {'rx': ('safetyRxChecksInvalid', True), 'heartbeat': ('heartbeatLost', True), + 'inventory': ('safetyParam', 0)}[fault] + native = replace(native, pandaInventory=(replace(native.pandaInventory[0], **{field: value}),)) + elif fault == 'session': + native = replace(native, axisSessionId='foreign') + elif fault == 'expired': + native = replace(native, observedMonoTime=now - 200_000_001, validUntilMonoTime=now - 1) + event.logMonoTime = native.observedMonoTime + event.aolSafetyWire = encode_safety(native) + axis, state, command = drive(2, native_request_lat=True, native_request_long=True, + long_allowed=False, panda_controls_allowed=False, safety_message=event) + assert not axis.lateralActive and not command.latActive + assert not axis.longitudinalActive and not command.longActive + assert not state.enabled + assert log.OnroadEvent.EventName.controlsMismatch in sd.events.names + + +def test_ev6_persistent_controls_denial_keeps_original_mismatch_counter(): + case = fixture.IoniqAvailabilityTests(methodName='runTest') + with ExitStack() as cleanup: + cleanup.callback(case.doCleanups) + cleanup.enter_context(OpenpilotPrefix()) + sd, _, drive = callers(case) + for tick in range(10): + drive(tick) + for tick in range(10, 214): + axis, state, command = drive(tick, native_request_lat=True, native_request_long=True, + long_allowed=False, panda_controls_allowed=False) + assert not axis.longitudinalActive and not command.longActive + if log.OnroadEvent.EventName.controlsMismatch in sd.events.names: + break + assert axis.lateralActive and command.latActive and state.enabled + assert sd.mismatch_counter >= 200 + assert not state.enabled and not axis.lateralActive and not command.latActive + + +@pytest.mark.parametrize('change', ('none', 'unmarked', 'wrong_word', 'stock', 'passive', 'wrong_identity')) +def test_exact_torque_long_profile_boundary(change): + from openpilot.starpilot.car.hyundai.aol import early_native_long_withdrawal_supported + cp = ev6_params() + cp.safetyConfigs[0].safetyParam = 0x815 + cp.alternativeExperience = 32 + if change == 'unmarked': + cp.alternativeExperience = 0 + elif change == 'wrong_word': + cp.safetyConfigs[0].safetyParam = 0x8815 + elif change == 'stock': + cp.openpilotLongitudinalControl, cp.pcmCruise = False, True + elif change == 'passive': + cp.passive = True + elif change == 'wrong_identity': + cp.carFingerprint = 'KIA_EV9' + assert early_native_long_withdrawal_supported(cp) == (change == 'none') diff --git a/openpilot/starpilot/aol/transport_pause.py b/openpilot/starpilot/aol/transport_pause.py index 793dc8a07f..6488c71e4d 100644 --- a/openpilot/starpilot/aol/transport_pause.py +++ b/openpilot/starpilot/aol/transport_pause.py @@ -314,12 +314,12 @@ class TransportPauseFeedback: """ from openpilot.starpilot.nostalgia import physical_cancel from openpilot.starpilot.car.gm.aol import native_accepts_cp - from openpilot.starpilot.car.hyundai.aol import qualified_ioniq6 + from openpilot.starpilot.car.hyundai.aol import early_native_long_withdrawal_supported gm = native_accepts_cp(cp, int(cp.safetyConfigs[0].safetyModel.raw), int(cp.safetyConfigs[0].safetyParam)) from openpilot.starpilot.car.hyundai.aol import qualified_ccnc_long_aol as ev9_qualified from opendbc.car import structs ev9 = ev9_qualified(cp, marked_only=True) - early_long = gm or cp.openpilotLongitudinalControl and qualified_ioniq6(cp) or ev9 + early_long = gm or early_native_long_withdrawal_supported(cp) or ev9 from openpilot.starpilot.car.gm.aol import sdgm_alpha_pedal_withdrawal sdgm_pedal = sdgm_alpha_pedal_withdrawal(cp, cs) event_ns = int(sm.logMonoTime['onroadEvents']) diff --git a/openpilot/starpilot/car/hyundai/aol.py b/openpilot/starpilot/car/hyundai/aol.py index bd0f063a28..5b6e410a62 100644 --- a/openpilot/starpilot/car/hyundai/aol.py +++ b/openpilot/starpilot/car/hyundai/aol.py @@ -318,6 +318,12 @@ def retain_on_native_denial(CP, native, state): native.requestedLateral and not native.lateralAllowed and temporary_restriction(CP, state)) +def early_native_long_withdrawal_supported(CP) -> bool: + """Exact LONG profiles whose native policy grants lateral independently.""" + return bool(CP.openpilotLongitudinalControl and + (qualified_ioniq6(CP) or qualified_canfd_long(CP, marked_only=True))) + + def source_deadline_supported(CP) -> bool: """Admit only an already marked HKG axis owner to original-proof waits.""" from openpilot.starpilot.aol.vehicle import policy_for as vehicle_policy_for diff --git a/prebuilt.json b/prebuilt.json index 377fa5a466..9e9159fec9 100644 --- a/prebuilt.json +++ b/prebuilt.json @@ -1 +1 @@ -{"protected_inventory_sha256": "b62ac613ee4e66f5782643aff34ead6342241e888d716d437c4e70f75c09d6b3", "schema_version": 1} +{"protected_inventory_sha256": "f66ce7991c8b276625982b5f71707ca60643482518ba9cc76b415cae47e59edc", "schema_version": 1} diff --git a/tools/ci/run_vehicle_tests.py b/tools/ci/run_vehicle_tests.py index f301c2ac29..1a86785096 100644 --- a/tools/ci/run_vehicle_tests.py +++ b/tools/ci/run_vehicle_tests.py @@ -24,6 +24,7 @@ from tools.ci import lifecycle_shards, vehicle_pytest # Representative production callers plus their source/native refusal cases. # The emitted selected_test_ids record exact parametrized configurations. AOL_LIFECYCLE_TARGETS = ( + 'openpilot/starpilot/aol/tests/test_hkg_early_long_withdrawal.py', 'openpilot/starpilot/car/toyota/tests/test_prius_retrofit_stock.py', 'openpilot/starpilot/car/toyota/tests/test_prius_retrofit_stock_callers.py', 'openpilot/starpilot/aol/tests/test_crossmake_native_admission.py',