"""Left-paddle longitudinal pause preserves only separately qualified AOL steering.""" from pathlib import Path from tempfile import TemporaryDirectory from types import SimpleNamespace from unittest.mock import patch from opendbc.car import gen_empty_fingerprint from opendbc.car.hyundai.interface import CarInterface as HyundaiInterface from opendbc.car.hyundai.ioniq6_handoff import build_ioniq6_hda2_long_candidate from opendbc.car.hyundai.values import CAR as HyundaiCar from opendbc.car.structs import car from openpilot.cereal import log from openpilot.common.params import Params from openpilot.selfdrive.selfdrived.events import Events, EventName from openpilot.selfdrive.selfdrived.state import StateMachine from openpilot.starpilot.aol.intent import AolCardIntent, AolSettings from openpilot.starpilot.aol.runtime import decide_axes from openpilot.starpilot.feature_runtime import enabled as feature_enabled, requested as feature_requested from openpilot.starpilot.nostalgia import aol_no_entry, paddle_cancel, saved_enabled ButtonType = car.CarState.ButtonEvent.Type class Files: def __init__(self, root: Path): self.root = root def get_param_path(self, key: str) -> str: return str(self.root / key) def _car(*events): return SimpleNamespace(canValid=True, canTimeout=False, buttonEvents=events, gearShifter=car.CarState.GearShifter.drive, steerFaultPermanent=False, steerFaultTemporary=False, brakePressed=False, vEgo=12.0, standstill=False, accFaulted=False, cruiseState=SimpleNamespace(available=True)) def _button(kind, pressed): return car.CarState.ButtonEvent(type=kind, pressed=pressed) def test_saved_preference_is_strict_and_absent_off(): with TemporaryDirectory() as directory: params = Files(Path(directory)) path = Path(params.get_param_path('NostalgiaMode')) assert not saved_enabled(params) for invalid in (b'01', b'true', b'1' * 20): path.write_bytes(invalid) assert not saved_enabled(params) assert path.read_bytes() == invalid path.write_bytes(b'1') assert saved_enabled(params) def test_normal_aol_owner_needs_saved_request_and_exact_tagged_ioniq_cp(): with TemporaryDirectory() as directory: params = Params(directory) fp = gen_empty_fingerprint() fp[2].update({0x50: 16, 0x2A4: 24}) fp[1].update({0x1CF: 8, 0x1AA: 16, 0x35: 32, 0x175: 24, 0xA0: 24, 0xEA: 24, 0x1BA: 24, 0x1E5: 16, 0x36A: 16}) fp[0][0x3A5] = 24 stock = HyundaiInterface.get_params(HyundaiCar.HYUNDAI_IONIQ_6, fp, [], False, False, False) assert not feature_enabled(params, stock, 'aol', {}) Path(params.get_param_path('AlwaysOnLateral')).write_bytes(b'1') assert feature_requested(params, 'aol') assert feature_enabled(params, stock, 'aol', {}) tagged = build_ioniq6_hda2_long_candidate(stock, fp) assert tagged is not None assert not feature_enabled(params, tagged, 'aol', {}) # Card has not requested native AOL bit. tagged.safetyConfigs[0].safetyParam |= 0x800 assert feature_enabled(params, tagged, 'aol', {}) Path(params.get_param_path('AlwaysOnLateral')).write_bytes(b'01') assert not feature_requested(params, 'aol') from openpilot.starpilot.aol.intent import read_settings from openpilot.starpilot.aol.vehicle import policy_for from openpilot.starpilot.car.hyundai.aol import create_intent assert policy_for(tagged).full_axis_runtime_required assert feature_enabled(params, tagged, 'aol', {}) settings = read_settings(params) assert not settings.enabled owner = create_intent(tagged, settings) for state in (_car(), _car(_button(ButtonType.lkas, True)), _car(_button(ButtonType.lkas, False))): owner.update(state) assert not owner.allowed_latch assert not owner.output(state)[0] def test_only_factual_ioniq_press_cancels_longitudinal(): press = _car(_button(ButtonType.altButton2, True)) held = _car() release = _car(_button(ButtonType.altButton2, False)) with patch('openpilot.starpilot.nostalgia.ioniq6_long_eligible', side_effect=lambda cp: cp == 'tagged'): assert paddle_cancel('tagged', press, enabled=True, saved=True) for car_state in (held, release): assert not paddle_cancel('tagged', car_state, enabled=True, saved=True) assert not paddle_cancel('stock', press, enabled=True, saved=True) assert not paddle_cancel('tagged', press, enabled=False, saved=True) assert not paddle_cancel('tagged', press, enabled=True, saved=False) press.canTimeout = True assert not paddle_cancel('tagged', press, enabled=True, saved=True) def test_paddle_user_disable_keeps_only_independently_permitted_lateral(): events = Events() events.add(EventName.buttonCancel) state = StateMachine() state.state = log.SelfdriveState.OpenpilotState.enabled enabled, active = state.update(events) assert not enabled and not active # ordinary longitudinal state machine sees Cancel paddle = _car(_button(ButtonType.altButton2, True)) assert not aol_no_entry(events.names, paddle, paddle_only_cancel=True) native = SimpleNamespace(requestedLateral=True, requestedLongitudinal=False, lateralAllowed=True, longitudinalAllowed=True) intent = SimpleNamespace(allowedLatch=True, pauseLateral=False, pauseLongitudinal=False) decision = decide_axes(standard_lateral=active, standard_longitudinal=enabled, intent=intent, native=native, car_state=paddle, initialized=True, model_ready=True, no_entry=False, immediate_disable=False, dm_lockout=False, pause_brake_mps=0) assert decision.lateral_active and not decision.longitudinal_active # With AOL off, or without its saved latch, both axes stay off after the press. assert not active and not enabled intent.allowedLatch = False decision = decide_axes(standard_lateral=active, standard_longitudinal=enabled, intent=intent, native=native, car_state=paddle, initialized=True, model_ready=True, no_entry=False, immediate_disable=False, dm_lockout=False, pause_brake_mps=0) assert not decision.lateral_active and not decision.longitudinal_active # A real Cancel on the same frame retains the normal all-axis veto. paddle.buttonEvents = (*paddle.buttonEvents, _button(ButtonType.cancel, True)) assert aol_no_entry(events.names, paddle, paddle_only_cancel=True) assert aol_no_entry(events.names, _car(), paddle_only_cancel=False) def test_latch_survives_press_and_next_frame_then_real_cancel_requires_rearm(): intent = AolCardIntent(AolSettings(True, 0.0, 0, 0, (0, 0, 0), (0, 0, 0)), explicit_latch=True) neutral = _car() intent.update(neutral) arm = _car(_button(ButtonType.lkas, True)) intent.update(arm) assert intent.output(arm)[0] intent.update(_car(_button(ButtonType.lkas, False))) state = StateMachine() state.state = log.SelfdriveState.OpenpilotState.enabled press = _car(_button(ButtonType.altButton2, True)) intent.update(press) events = Events() events.add(EventName.buttonCancel) enabled, active = state.update(events) assert not enabled and not active and intent.output(press)[0] assert not aol_no_entry(events.names, press, paddle_only_cancel=True) next_frame = _car() intent.update(next_frame) enabled, active = state.update(Events()) assert not enabled and not active and intent.output(next_frame)[0] cancel = _car(_button(ButtonType.cancel, True)) intent.update(cancel) assert not intent.output(cancel)[0] assert aol_no_entry(events.names, cancel, paddle_only_cancel=True) intent.update(_car(_button(ButtonType.cancel, False))) assert not intent.output(neutral)[0] intent.update(_car(_button(ButtonType.lkas, True))) assert intent.output(neutral)[0] # A new deliberate host gesture rearms steering. def _ioniq5_params(*, alternate=False, long=True): fp = gen_empty_fingerprint() fp[2].update({0x50: 16, 0x2A4: 24}) fp[1][0x1AA if alternate else 0x1CF] = 16 if alternate else 8 cp = HyundaiInterface.get_params(HyundaiCar.HYUNDAI_IONIQ_5, fp, [], long, False, False) if long and not alternate: from opendbc.car.hyundai.torque_ev_startup import candidate cp = candidate(cp, requested=True, is_release=False) assert cp is not None from openpilot.starpilot.car.hyundai.aol import policy_for policy = policy_for(cp) cp.safetyConfigs[-1].safetyParam |= policy.safety_param_addition cp.alternativeExperience |= policy.alternative_experience_addition return cp def test_ioniq5_physical_parser_emits_paddle_edges_with_existing_long_policy(): from opendbc.can import CANPacker from opendbc.car import Bus from opendbc.car.hyundai.hyundaicanfd import CanBus from opendbc.car.hyundai.tests.test_ioniq6_stock_parser import StockParserTests from opendbc.car.hyundai.values import DBC from openpilot.starpilot.car.hyundai.aol import policy_for cp = _ioniq5_params() assert cp.openpilotLongitudinalControl assert policy_for(cp).paddle_pause and policy_for(cp).runtime_supported case = StockParserTests(methodName='runTest') ci = case.host(cp) case.feed(ci, count=5) packer = CANPacker(DBC[cp.carFingerprint][Bus.pt]) for counter, pressed in ((6, True), (7, False)): ci.test_time_ns += 10_000_000 msg = packer.make_can_msg('CRUISE_BUTTONS', CanBus(cp).ECAN, {'COUNTER': counter, 'LEFT_PADDLE': int(pressed)}) state = ci.update([(ci.test_time_ns, [msg])]) events = [e for e in state.buttonEvents if e.type == ButtonType.altButton2] assert len(events) == 1 and events[0].pressed == pressed assert paddle_cancel(cp, state, enabled=True, saved=True) == pressed for cp in (_ioniq5_params(alternate=True), _ioniq5_params(long=False)): assert not policy_for(cp).paddle_pause assert not paddle_cancel(cp, _car(_button(ButtonType.altButton2, True)), enabled=True, saved=True) def test_actual_ioniq5_paddle_withdraws_long_without_cancelling_aol(): from contextlib import ExitStack from openpilot.common.prefix import OpenpilotPrefix from openpilot.starpilot.aol.tests.test_ioniq_availability import IoniqAvailabilityTests, car_state case = IoniqAvailabilityTests(methodName='runTest') with OpenpilotPrefix(), ExitStack() as cleanup: cleanup.callback(case.doCleanups) params = Params() params.put_bool('NostalgiaMode', True, block=True) cp = _ioniq5_params() sd, _, drive = case._authority_callers(True, cp=cp, genuine_events=True) axis, _, command = drive(0) assert command.latActive and command.longActive state = car_state() state.buttonEvents = [_button(ButtonType.altButton2, True)] axis, _, command = drive(1, observed_state=state) assert sd.nostalgia_paddle_cancel assert not sd.enabled and not command.longActive and not axis.longitudinalActive assert command.latActive and axis.lateralActive assert EventName.buttonCancel in sd.events.names axis, _, command = drive(2, native_request_long=False, long_allowed=False) assert axis.lateralActive and command.latActive and not command.longActive state.buttonEvents = [_button(ButtonType.cancel, True)] axis, _, command = drive(3, observed_state=state, native_request_long=False, long_allowed=False) assert not command.latActive and not command.longActive