import os import unittest from unittest import mock from openpilot.cereal import log, messaging from openpilot.common.params import Params from openpilot.common.prefix import OpenpilotPrefix from opendbc.car.structs import car from openpilot.selfdrive.selfdrived.selfdrived import SelfdriveD from openpilot.selfdrive.selfdrived.alertmanager import AlertManager from openpilot.selfdrive.selfdrived.events import Events, ET from openpilot.selfdrive.selfdrived.state import StateMachine from openpilot.starpilot.aol.runtime import AxisDecision, current_native from openpilot.starpilot.aol.wire import IntentState, PandaSlot, SafetyState, encode_intent, encode_safety def car_state(*, brake=False): return car.CarState(canValid=True, gearShifter=car.CarState.GearShifter.drive, vEgo=20.0, brakePressed=brake) def assert_ordinary_source_pause(sd, *, publication_only=False): """Host loss withdraws commands while the existing state machine owns life.""" assert sd.enabled and sd.active if publication_only: assert sd.state_machine.state == log.SelfdriveState.OpenpilotState.enabled assert sd.state_machine.soft_disable_timer == 0 else: assert sd.state_machine.state == log.SelfdriveState.OpenpilotState.softDisabling assert 0 < sd.state_machine.soft_disable_timer <= 300 assert log.OnroadEvent.EventName.commIssue in sd.events.names assert log.OnroadEvent.EventName.controlsMismatch not in sd.events.names assert not sd.events.contains(ET.ENABLE) assert not sd.aol_authority_lost and not getattr(sd, 'aol_availability_long_inhibit', False) assert sd.aol_fault_reason == 'availabilityPause' assert sd.aol_car_state_log_ns == 0 and not sd.conditional_car_state_valid assert not sd.aol_axis_decision.lateral_active and not sd.aol_axis_decision.longitudinal_active def assert_fresh_ordinary_recovery(sd, axis, command, source): """Only a fresh exact input plus its current native grant resumes commands.""" assert sd.enabled and sd.active assert sd.state_machine.state == log.SelfdriveState.OpenpilotState.enabled assert axis.sourceCarStateMonoTime == source assert 0 <= axis.observedMonoTime - source <= 30_000_000 assert axis.validUntilMonoTime == axis.observedMonoTime + 30_000_000 assert axis.longitudinalActive and command.longActive native = current_native(sd.sm, sd.CP, now_ns=axis.observedMonoTime, axis_session_id=sd.aol_session_id) assert native is not None and native.requestedLongitudinal and native.longitudinalAllowed assert not sd.events.contains(ET.ENABLE) assert not sd.aol_authority_lost and not getattr(sd, 'aol_availability_long_inhibit', False) class IoniqAvailabilityTests(unittest.TestCase): def _authority_callers(self, full_op, *, cp=None, intent_companion=None, genuine_events=False, initial_panda_config=None): self.enterContext(mock.patch('openpilot.selfdrive.selfdrived.selfdrived.REPLAY', False)) from opendbc.car.gm.tests.test_bolt_volt_configurations import ordinary_params from opendbc.car.gm.values import CAR from openpilot.selfdrive.controls.controlsd import Controls from openpilot.starpilot.aol.vehicle import policy_for from openpilot.starpilot.controllers.mode_actions import SwitchbackCooldown from openpilot.starpilot.lateral.tests.test_lane_runtime import feed params = Params() params.put_bool('OpenpilotEnabledToggle', True, block=True) params.put_bool('SafeMode', False, block=True) params.put_bool('AlwaysOnLateral', True, block=True) supplied_cp = cp is not None if cp is None: cp = ordinary_params(CAR.CHEVROLET_VOLT_ASCM, alpha=True, sascm=True, radar=True) policy = policy_for(cp) cp.safetyConfigs[-1].safetyParam |= policy.safety_param_addition cp.alternativeExperience |= policy.alternative_experience_addition params.put('CarParams', cp.to_bytes(), block=True) controls = Controls() sd = SelfdriveD(CP=cp.as_reader()) if supplied_cp else SelfdriveD.__new__(SelfdriveD) sd.params = params sd.CP = cp.as_reader() services = ['deviceState', 'pandaStates', 'driverMonitoringState', 'extrinsicsCalibration', 'modelV2', 'carState', 'carControl', 'aolIntentWire', 'aolSafetyWire', 'starpilotCarState'] if not genuine_events: self.enterContext(mock.patch.object(sd, 'sm', messaging.SubMaster(services, ignore_avg_freq=services), create=True)) sd.sm.update = mock.Mock() # Preserve the real constructor's CP-qualified family event rules. if not (supplied_cp and genuine_events): sd.events = Events() sd.state_machine, sd.AM = StateMachine(), AlertManager() sd.state_machine.state = log.SelfdriveState.OpenpilotState.enabled if full_op else log.SelfdriveState.OpenpilotState.disabled sd.enabled = sd.active = full_op sd.mismatch_counter = 0 sd.events_prev = [] sd.experimental_mode = sd.requested_experimental_mode = False sd.initialized = sd.aol_replay = True sd.axis_transport_required = sd.aol_replay or policy.ordinary_axis_ack_required sd.aol_authority_lost = sd.aol_dm_lateral_inhibit = False sd.aol_axis_decision = AxisDecision() sd.aol_settings = None sd.aol_car_state_log_ns = 0 sd.conditional_car_state_valid = False sd.aol_last_intent = None if intent_companion is not None or not supplied_cp: sd.aol_intent_companion = intent_companion sd.aol_session_id, sd.aol_sequence = 'authority-session', 0 from openpilot.starpilot.aol.transport_pause import TransportPause sd.aol_transport_pause = TransportPause(sd.CP, sd.aol_session_id) sd.conditional_replay = False sd.nostalgia_paddle_cancel = False sd.switchback_capable = False sd.switchback_cooldown = SwitchbackCooldown() sd.switchback_setting_ns, sd.switchback_cooldown_ns = 0, 300_000_000_000 sd.personality, sd.is_metric = 1, False sd.CS_prev = car_state() sd.car_state_sock = object() original_optional_receive = messaging.recv_one_or_none steering_receipts = [] def optional_receive(sock): if getattr(sd, 'steering_companion', None) is not None and sock is sd.steering_companion.sock: return steering_receipts.pop(0) if steering_receipts else None return None if sock is sd.car_state_sock else original_optional_receive(sock) self.enterContext(mock.patch('openpilot.selfdrive.selfdrived.selfdrived.messaging.recv_one_or_none', side_effect=optional_receive)) sd.update_conditional_mode = mock.Mock() if not genuine_events: self.enterContext(mock.patch.object(sd, 'update_events', lambda cs: (sd.events.clear(), sd._update_steering_authority(cs)))) else: self.assertTrue(supplied_cp) self.assertFalse(sd.sm.simulation) sd.startup_event = None # Retain the real 100 Hz Ratekeeper created by SelfdriveD(CP=...). class Publisher: def __init__(self): self.messages = {} def send(self, service, message): self.messages[service] = messaging.log_from_bytes(message.to_bytes()) self.enterContext(mock.patch.object(sd, 'pm', Publisher(), create=True)) self.enterContext(mock.patch.object(controls, 'pm', Publisher())) clock = self.enterContext(mock.patch('openpilot.selfdrive.selfdrived.selfdrived.time.monotonic_ns', return_value=10_000_000_000)) recv = self.enterContext(mock.patch('openpilot.selfdrive.selfdrived.selfdrived.messaging.recv_one')) base = 10_000_000_000 seconds = (self.enterContext(mock.patch('openpilot.selfdrive.selfdrived.selfdrived.time.monotonic', return_value=base / 1e9)) if genuine_events else None) original_message = messaging.new_message def clocked_message(*args, **kwargs): message = original_message(*args, **kwargs) if 'logMonoTime' not in kwargs: message.logMonoTime = clock.return_value return message self.enterContext(mock.patch('openpilot.selfdrive.selfdrived.selfdrived.messaging.new_message', clocked_message)) # The opt-in path keeps the constructor's production subscriptions and # frequency/validity checks. Only receive transport and the clock are synthetic. previous_control = [car.CarControl(enabled=full_op, latActive=full_op, longActive=full_op).as_reader()] # Production subscribes before the first Controls packet arrives. Preserve # that unseen state, then feed only the preceding actual published command. previous_control_message = [None] due = {} controls_panda_due = [base] def feed_event_sources(stamp, companions=(), *, car_output_valid=True): from openpilot.cereal.services import SERVICE_LIST names = ['deviceState', 'pandaStates', 'peripheralState', 'modelV2', 'extrinsicsCalibration', 'carOutput', 'driverMonitoringState', 'longitudinalPlan', 'deviceMotion', 'lateralDelay', 'managerState', 'vehicleParameters', 'radarState', 'lateralTorqueParameters', 'controlsState', 'carControl', 'driverAssistance', *sd.camera_packets, *sd.sensor_packets] supplied = {sample.which(): sample for sample in companions} samples = [] for name in dict.fromkeys([*names, *supplied]): frequency = SERVICE_LIST[name].frequency # Ignored authority wires remain fresh on every 100 Hz source tick. period = round(1e9 / min(frequency, 100)) if frequency > 0 else 0 if (stamp < due.get(name, 0) and name not in ('aolIntentWire', 'aolSafetyWire', 'starpilotCarState') and not (name == 'pandaStates' and initial_panda_config is None and name in supplied and any(p.safetyRxChecksInvalid or p.faults for p in supplied[name].pandaStates))): continue due[name] = stamp + period sample = supplied.get(name) if sample is None: sample = messaging.new_message(name, len(cp.safetyConfigs) if name == 'pandaStates' else None, valid=name != 'carOutput' or car_output_valid, logMonoTime=stamp) value = getattr(sample, name) if name == 'deviceState': value.started, value.startedMonoTime, value.freeSpacePercent = True, base - 1_000_000_000, 50. elif name == 'pandaStates': for panda, config in zip(value, cp.safetyConfigs, strict=True): if initial_panda_config is None: panda.safetyModel, panda.safetyParam = config.safetyModel, config.safetyParam panda.alternativeExperience, panda.controlsAllowed = cp.alternativeExperience, full_op else: model, word, experience, controls_allowed, rx_invalid = initial_panda_config panda.safetyModel, panda.safetyParam, panda.alternativeExperience = model, word, experience panda.controlsAllowed, panda.safetyRxChecksInvalid = controls_allowed, rx_invalid elif name == 'extrinsicsCalibration': value.calStatus, value.rpyCalib = 'calibrated', [0., 0., 0.] elif name == 'deviceMotion': value.inputsOK = value.posenetOK = True elif name == 'vehicleParameters': value.valid = True elif name == 'controlsState': value.lateralControlState.init('torqueState') elif name == 'carControl': sample.carControl = previous_control[0] sample = sample.as_reader() samples.append(sample) sd.sm.update_msgs(stamp / 1e9, samples) if genuine_events: # Prime actual cadence trackers before the initialized onroad baseline; # this advances fixture time only and neither sleeps nor skips a check. for prime in range(-110, 0): feed_event_sources(base + prime * 10_000_000) self.assertTrue(sd.sm.all_checks()) def step(tick, *, timeout=False, old_intent=False, allowed=True, pause=False, latched=True, brake=False, steering=False, gas=False, user_disable=False, observed_state=None, authority_unavailable=False, companion=True, long_allowed=True, pause_long=False, state_stamp=None, intent_message=None, offset_ns=0, native_request_lat=True, native_request_long=None, native_valid=True, panda_fault=False, panda_controls_allowed=None, critical_event=None, calibrated=True, car_output_valid=True, relay_fault=False, car_state_message=None, car_output_message=None, authority_message=None, panda_state_message=None, safety_message=None): now = base + tick * 10_000_000 + offset_ns clock.return_value = now if seconds is not None: seconds.return_value = now / 1e9 if car_state_message is None: state = car_state(brake=brake) if observed_state is None else observed_state state.cruiseState.available = True state.steeringPressed, state.gasPressed = steering, gas else: # Opt-in producer-envelope path: do not reconstruct/modify raw state. state = car_state_message.carState if panda_controls_allowed is None: panda_controls_allowed = full_op if native_request_long is None else native_request_long if car_state_message is None: state_event = messaging.new_message('carState', valid=True, logMonoTime=now if state_stamp is None else state_stamp) state_event.carState = state else: state_event = car_state_message recv.return_value = None if timeout else state_event.as_reader() frames = [] for service in ('deviceState', 'driverMonitoringState', 'extrinsicsCalibration', 'modelV2'): message = messaging.new_message(service, valid=True, logMonoTime=now) if service == 'deviceState': message.deviceState.started, message.deviceState.startedMonoTime = True, base - 1_000_000_000 if genuine_events: message.deviceState.freeSpacePercent = 50. elif service == 'extrinsicsCalibration': message.extrinsicsCalibration.calStatus = 'calibrated' if calibrated else 'uncalibrated' if genuine_events: message.extrinsicsCalibration.rpyCalib = [0., 0., 0.] frames.append(message.as_reader()) pandas = messaging.new_message('pandaStates', len(cp.safetyConfigs), valid=True, logMonoTime=now) for panda, config in zip(pandas.pandaStates, cp.safetyConfigs, strict=True): panda.safetyModel, panda.safetyParam = config.safetyModel, config.safetyParam panda.alternativeExperience = cp.alternativeExperience panda.controlsAllowed = panda_controls_allowed panda.safetyRxChecksInvalid = panda_fault if relay_fault: panda.faults = ['relayMalfunction'] if panda_state_message is not None: pandas = panda_state_message frames.append(pandas.as_reader()) if car_output_message is not None: frames.append(car_output_message.as_reader()) intent_ns = base if old_intent else now intent = messaging.new_message('aolIntentWire', 0, valid=True, logMonoTime=intent_ns) intent.aolIntentWire = encode_intent(IntentState('authority-card', tick + 1, intent_ns, intent_ns, intent_ns + 200_000_000, latched, pause, pause_long, True, latched)) if intent_message is not None: intent = intent_message native = messaging.new_message('aolSafetyWire', 0, valid=native_valid, logMonoTime=now) native.aolSafetyWire = encode_safety(SafetyState(1, True, now, now + 200_000_000, int(cp.safetyConfigs[-1].safetyModel.raw), cp.safetyConfigs[-1].safetyParam, allowed, (full_op or native_request_long is True) and long_allowed, native_request_lat, full_op if native_request_long is None else native_request_long, 'authority-panda', sd.aol_session_id, now if genuine_events else 0, tuple( PandaSlot(index, 'authority-panda' if index == len(cp.safetyConfigs) - 1 else f'silent-panda-{index}', int(config.safetyModel.raw), config.safetyParam, cp.alternativeExperience, panda_controls_allowed, panda_fault, False, 1 if relay_fault else 0) for index, config in enumerate(cp.safetyConfigs)) if genuine_events else ())) if safety_message is not None: native = safety_message if authority_message is not None: frames.append(authority_message.as_reader()) elif companion: authority = messaging.new_message('starpilotCarState', valid=companion != 'invalid', logMonoTime=now - 1 if companion == 'wrong_stamp' else now) authority.starpilotCarState.sourceCarStateMonoTime = now - 1 if companion == 'wrong_source' else now authority.starpilotCarState.lateralAuthorityUnavailable = authority_unavailable frames.append(authority.as_reader()) # Mirror this producer envelope on the real nonconflating receipt, # as Card does. The conflating SubMaster view alone proves no receipt. if getattr(sd, 'steering_companion', None) is not None: steering_receipts.append(authority.as_reader()) if genuine_events: feed_event_sources(now, frames + [intent.as_reader(), native.as_reader()], car_output_valid=car_output_valid) else: if previous_control_message[0] is not None: frames.append(previous_control_message[0]) sd.sm.update_msgs(now / 1e9, frames + [intent.as_reader(), native.as_reader()]) if user_disable: self.enterContext(mock.patch.object(sd, 'update_events', lambda cs: ( sd.events.clear(), sd._update_steering_authority(cs), sd.events.add(log.OnroadEvent.EventName.pedalPressed)))) if critical_event is not None: self.enterContext(mock.patch.object(sd, 'update_events', lambda cs: ( sd.events.clear(), sd._update_steering_authority(cs), sd.events.add(critical_event)))) sd.step() control_frames = [] with mock.patch.object(controls.sm, 'update_msgs', side_effect=lambda _timestamp, messages: control_frames.extend(messages)): feed(controls, now, tick * 5, active=False, enabled=False) model = messaging.new_message('modelV2', valid=True, logMonoTime=now) model.modelV2 = next(message.modelV2 for message in control_frames if message.which() == 'modelV2') model.modelV2.action.desiredCurvature = 0. if car_state_message is None: control_state = messaging.new_message('carState', valid=sd.conditional_car_state_valid, logMonoTime=sd.aol_car_state_log_ns) control_state.carState = sd.CS_prev else: control_state = car_state_message if car_output_message is not None: control_frames = [sample for sample in control_frames if sample.which() != 'carOutput'] control_frames.append(car_output_message.as_reader()) if panda_state_message is not None and 'pandaStates' in controls.sm.services: control_frames = [sample for sample in control_frames if sample.which() != 'pandaStates'] if now >= controls_panda_due[0]: control_frames.append(panda_state_message.as_reader()) controls_panda_due[0] = now + 100_000_000 replaced = ('carState', 'aolSafetyWire', 'modelV2', 'aolAxisState', 'selfdriveState') controls.sm.update_msgs(now / 1e9, [message for message in control_frames if message.which() not in replaced] + [control_state.as_reader(), native.as_reader(), model.as_reader(), sd.pm.messages['aolAxisState'], sd.pm.messages['selfdriveState']]) command, lac_log = controls.state_control() controls.publish(command, lac_log) previous_control_message[0] = controls.pm.messages['carControl'] if genuine_events: previous_control[0] = command.as_reader() return sd.pm.messages['aolAxisState'].aolAxisState, sd.pm.messages['selfdriveState'].selfdriveState, command return sd, controls, step def _ioniq_availability_callers(self, full_op=True, *, initially_armed=True, stock_angle=False): from opendbc.car.hyundai.interface import CarInterface from opendbc.car.hyundai.radar_interface import RadarInterface from openpilot.selfdrive.car.card import Car from openpilot.selfdrive.car.tests.test_hyundai_aol import candidate previous_simulation = os.environ.get('SIMULATION') def restore_simulation(): if previous_simulation is None: os.environ.pop('SIMULATION', None) else: os.environ['SIMULATION'] = previous_simulation self.addCleanup(restore_simulation) os.environ['SIMULATION'] = '0' if stock_angle: from opendbc.car.hyundai.tests.test_stock_angle_aol import source_profile from opendbc.car.hyundai.values import CAR cp = source_profile(CAR.HYUNDAI_IONIQ_5_PE if stock_angle is True else CAR(stock_angle)) else: _, cp = candidate(True) cp.safetyConfigs[-1].safetyParam |= 0x0800 sd, controls, step = self._authority_callers(full_op, cp=cp, genuine_events=True) card_cp = cp.as_reader().as_builder() card = Car(CI=CarInterface(card_cp), RI=RadarInterface(card_cp)) self.assertEqual(card.CP.to_dict(), sd.CP.to_dict()) self.assertEqual(card.CP.safetyConfigs[-1].safetyParam, cp.safetyConfigs[-1].safetyParam if stock_angle else 0x8895) self.assertTrue(card.aol_card_intent.explicit_latch) card.slc_producer_session = 'authority-card' cs = car_state() cs.cruiseState.available = True card.aol_card_intent.update(cs, now_ns=9_980_000_000) if initially_armed: cs.buttonEvents = [car.CarState.ButtonEvent(type=car.CarState.ButtonEvent.Type.lkas, pressed=True)] card.aol_card_intent.update(cs, now_ns=9_990_000_000) cs.buttonEvents = [] self.assertEqual(card.aol_card_intent.allowed_latch, initially_armed) def receive(now, *, axis=None, event=None, state=None): frames = [] for service in ('pandaStates', 'aolSafetyWire', 'deviceState', 'carControl'): message = messaging.new_message(service, len(sd.sm[service]) if service == 'pandaStates' else len(sd.sm[service]) if service == 'aolSafetyWire' else None, valid=sd.sm.valid[service], logMonoTime=sd.sm.logMonoTime[service]) setattr(message, service, sd.sm[service]) # Card receives the exact envelope bytes, including validity and stamps. frames.append(messaging.log_from_bytes(message.to_bytes())) card.sm.update_msgs(now / 1e9, frames + [m for m in (axis, event, sd.pm.messages.get('selfdriveState')) if m is not None]) observed = state if state is not None else car_state() if stock_angle and state is None: observed.vEgoRaw = observed.vEgo observed.cruiseState.available = True card.CC_prev = card.sm['carControl'] with mock.patch('openpilot.selfdrive.car.card.messaging.drain_sock_raw', return_value=[b'fixture']), \ mock.patch('openpilot.selfdrive.car.card.can_capnp_to_list', return_value=[]), \ mock.patch('openpilot.selfdrive.car.card.time.monotonic_ns', return_value=now), \ mock.patch('openpilot.selfdrive.car.card.time.clock_gettime_ns', return_value=now), \ mock.patch('openpilot.selfdrive.car.card.REPLAY', False), \ mock.patch.object(card.CI, 'update', return_value=observed), \ mock.patch.object(card.RI, 'update', return_value=None), mock.patch.object(card.sm, 'update'): actual, _ = card.state_update() self.assertIs(actual, observed) return card.aol_card_intent.allowed_latch def drive(tick, **kwargs): kwargs.setdefault('latched', card.aol_card_intent.allowed_latch) if stock_angle and 'observed_state' not in kwargs: state = car_state(brake=kwargs.get('brake', False)) state.vEgoRaw = state.vEgo kwargs['observed_state'] = state return step(tick, **kwargs) for tick in (0, 1): axis, _, command = drive(tick) if full_op: self.assertTrue(axis.lateralActive and axis.longitudinalActive and command.latActive and command.longActive) else: self.assertEqual(bool(axis.lateralActive and command.latActive), initially_armed) self.assertFalse(sd.enabled or sd.active or axis.longitudinalActive or command.longActive) self.assertEqual(receive(10_000_000_000 + tick * 10_000_000, axis=sd.pm.messages['aolAxisState'], event=sd.pm.messages['onroadEvents']), initially_armed) return sd, controls, drive, card, receive def test_full_set_engagement_arms_aol_and_brake_keeps_lateral_without_lkas(self): from opendbc.car.hyundai.values import Buttons with OpenpilotPrefix(): sd, _, step, card, receive = self._ioniq_availability_callers(full_op=False, initially_armed=False) self.assertFalse(card.aol_card_intent.allowed_latch) for tick, previous, current in ((2, Buttons.NONE, Buttons.SET_DECEL), (3, Buttons.SET_DECEL, Buttons.NONE)): cs = car_state() cs.buttonEvents = card.CI.CS.create_cruise_button_events(current, previous) cs.buttonEnable = card.CI.CS.update_button_enable(cs.buttonEvents) step(tick, observed_state=cs, native_request_long=False) receive(10_000_000_000 + tick * 10_000_000, state=cs, axis=sd.pm.messages['aolAxisState'], event=sd.pm.messages['onroadEvents']) self.assertIn(log.OnroadEvent.EventName.buttonEnable, sd.events.names) self.assertTrue(sd.enabled and sd.active) # PandaStates updates at 10 Hz. Grant LONG only once its actual fresh # health observation agrees, retaining the production latest-Panda veto. for tick in range(4, 13): granted = tick >= 10 axis, _, command = step(tick, native_request_long=granted) self.assertTrue(axis.lateralActive and command.latActive) self.assertEqual(bool(axis.longitudinalActive and command.longActive), granted) receive(10_000_000_000 + tick * 10_000_000, axis=sd.pm.messages['aolAxisState'], event=sd.pm.messages['onroadEvents']) self.assertTrue(card.aol_card_intent.allowed_latch) for tick in (13, 14, 15): axis, _, command = step(tick, brake=tick == 13, native_request_long=False) self.assertTrue(axis.lateralActive and command.latActive) self.assertFalse(sd.enabled or sd.active or axis.longitudinalActive or command.longActive) self.assertTrue(receive(10_000_000_000 + tick * 10_000_000, state=car_state(brake=tick == 13), axis=sd.pm.messages['aolAxisState'], event=sd.pm.messages['onroadEvents'])) self.assertFalse(any(e.type == car.CarState.ButtonEvent.Type.lkas for e in card.CI.CS.out.buttonEvents)) def test_reverse_keeps_actual_card_armed_without_output_and_drive_requires_ack(self): from openpilot.starpilot.aol.wire import decode_intent with OpenpilotPrefix(): sd, _, step, card, receive = self._ioniq_availability_callers(full_op=False) card.pm = type(sd.pm)() card.aol_sequence = 2 for tick in range(2, 45): reverse = tick < 42 cs = car_state(brake=reverse) cs.gearShifter = car.CarState.GearShifter.reverse if reverse else car.CarState.GearShifter.drive now = 10_000_000_000 + tick * 10_000_000 original_message = messaging.new_message def current_message(*args, _original=original_message, _now=now, **kwargs): message = _original(*args, **kwargs) message.logMonoTime = _now return message with mock.patch('openpilot.selfdrive.car.card.messaging.new_message', side_effect=current_message): card.state_publish(cs, None) intent_message = card.pm.messages['aolIntentWire'].as_builder() intent = decode_intent(bytes(intent_message.aolIntentWire)) self.assertTrue(intent.lateralArmed) self.assertEqual(intent.allowedLatch, not reverse) requested = tick == 2 or tick >= 43 axis, state, command = step(tick, observed_state=cs, intent_message=intent_message, native_request_lat=requested, allowed=requested, native_request_long=False) self.assertEqual(bool(axis.lateralActive), tick >= 43) self.assertEqual(bool(command.latActive), tick >= 43) self.assertFalse(axis.longitudinalActive or command.longActive or sd.enabled or sd.active) self.assertTrue(axis.nativeAcknowledged, (tick, sd.events.names, [(str(sample.gearShifter), sample.canValid, sample.canTimeout) for sample, _ in sd.car_state_samples], axis.to_dict())) self.assertEqual(str(axis.faultReason), 'none') if reverse: self.assertIn(log.OnroadEvent.EventName.reverseGear, sd.events.names) self.assertIn(log.OnroadEvent.EventName.wrongGear, sd.events.names) self.assertFalse(axis.desiredLateral or axis.desiredLongitudinal) self.assertTrue(receive(now, state=cs, axis=sd.pm.messages['aolAxisState'], event=sd.pm.messages['onroadEvents'])) def test_ioniq_caroutput_communication_fault_joined_delivery_and_recovery(self): for order in ('axis_first', 'event_first', 'together'): with self.subTest(order=order), OpenpilotPrefix(): sd, _, step, card, receive = self._ioniq_availability_callers() axis, state, command = step(2, car_output_valid=False) self.assertFalse(sd.sm.valid['carOutput']) self.assertTrue(sd.sm.all_checks(['carControl'])) self.assertTrue(sd.CS_prev.canValid and not sd.CS_prev.canTimeout) self.assertIn(log.OnroadEvent.EventName.commIssue, sd.events.names) self.assertTrue(sd.events.contains(ET.SOFT_DISABLE)) self.assertFalse(axis.lateralActive or axis.longitudinalActive or command.latActive or command.longActive) self.assertEqual(state.alertType, 'commIssue/softDisable') self.assertEqual(state.alertText2, 'Communication Issue Between Processes') # The shared SOFT owner retains armed intent while both output axes # are withdrawn; source/event joins still have their original deadline. proof, event = sd.pm.messages['aolAxisState'], sd.pm.messages['onroadEvents'] self.assertEqual(proof.aolAxisState.faultEventMonoTime, event.logMonoTime) now = 10_020_000_000 if order == 'axis_first': self.assertTrue(receive(now, axis=proof)) self.assertTrue(receive(now + 1_000_000, event=event)) elif order == 'event_first': self.assertTrue(receive(now, event=event)) self.assertTrue(receive(now + 1_000_000, axis=proof)) else: self.assertTrue(receive(now, axis=proof, event=event)) self.assertEqual(str(axis.faultReason), 'availabilityPause') self.assertFalse(sd.aol_authority_lost) self.assertTrue(card.aol_transport_feedback.ordinary_warning_qualified) self.assertFalse(card.aol_transport_feedback.retention_qualified) self.assertFalse(card.aol_transport_feedback.qualified) self.assertEqual(proof.aolAxisState.intentProducerSessionId, card.slc_producer_session) self.assertEqual(proof.aolAxisState.intentIntegrityEpoch, sd.aol_intent_companion.integrity_epoch) recovered, _, command = step(3, native_request_long=False) self.assertTrue(recovered.lateralActive and command.latActive) self.assertFalse(recovered.desiredLongitudinal or recovered.longitudinalActive or command.longActive) self.assertTrue(receive(10_030_000_000, axis=sd.pm.messages['aolAxisState'], event=sd.pm.messages['onroadEvents'])) def test_ioniq_availability_reengagement_requires_real_set_release_and_fresh_long_ack(self): from opendbc.car.hyundai.values import Buttons with OpenpilotPrefix(): sd, _, step, card, receive = self._ioniq_availability_callers() occurrence = 0 # Qualified availability keeps published ordinary state disabled; # retained lateral intent does not itself reengage LONG. for tick in range(2, 313): axis, _, command = step(tick, car_output_valid=False) self.assertFalse(axis.lateralActive or axis.longitudinalActive or command.latActive or command.longActive) published = sd.pm.messages['selfdriveState'].selfdriveState self.assertFalse(sd.enabled or sd.active or published.enabled or published.active) event = sd.pm.messages['onroadEvents'] if axis.faultEventMonoTime != occurrence else None occurrence = axis.faultEventMonoTime self.assertTrue(receive(10_000_000_000 + tick * 10_000_000, axis=sd.pm.messages['aolAxisState'], event=event)) self.assertFalse(sd.enabled or sd.active) recovered, _, command = step(313, native_request_long=False) self.assertTrue(recovered.lateralActive and command.latActive) published = sd.pm.messages['selfdriveState'].selfdriveState self.assertFalse(sd.enabled or sd.active or published.enabled or published.active) self.assertFalse(recovered.desiredLongitudinal or recovered.longitudinalActive or command.longActive) self.assertTrue(receive(13_130_000_000, axis=sd.pm.messages['aolAxisState'], event=sd.pm.messages['onroadEvents'])) pressed = car_state() pressed.cruiseState.available = True pressed.buttonEvents = card.CI.CS.create_cruise_button_events(Buttons.SET_DECEL, Buttons.NONE) pressed.buttonEnable = card.CI.CS.update_button_enable(pressed.buttonEvents) self.assertFalse(pressed.buttonEnable) axis, _, command = step(314, observed_state=pressed, native_request_long=False) self.assertNotIn(log.OnroadEvent.EventName.buttonEnable, sd.events.names) self.assertFalse(sd.enabled or axis.longitudinalActive or command.longActive) self.assertTrue(receive(13_140_000_000, axis=sd.pm.messages['aolAxisState'], event=sd.pm.messages['onroadEvents'], state=pressed)) released = car_state() released.cruiseState.available = True released.buttonEvents = card.CI.CS.create_cruise_button_events(Buttons.NONE, Buttons.SET_DECEL) released.buttonEnable = card.CI.CS.update_button_enable(released.buttonEvents) self.assertTrue(released.buttonEnable) pending, _, command = step(315, observed_state=released, native_request_long=False) self.assertIn(log.OnroadEvent.EventName.buttonEnable, sd.events.names) self.assertTrue(sd.events.contains(ET.ENABLE)) self.assertFalse(sd.events.contains(ET.NO_ENTRY)) self.assertTrue(sd.enabled and sd.active and pending.desiredLongitudinal) self.assertTrue(pending.lateralActive and command.latActive) self.assertFalse(pending.longitudinalActive or command.longActive) self.assertTrue(receive(13_150_000_000, axis=sd.pm.messages['aolAxisState'], event=sd.pm.messages['onroadEvents'], state=released)) combined, _, command = step(316, native_request_long=True) native = current_native(sd.sm, sd.CP, now_ns=13_160_000_000, axis_session_id=sd.aol_session_id) self.assertIsNotNone(native) self.assertTrue(native.requestedLongitudinal and native.longitudinalAllowed) self.assertEqual(native.observedMonoTime, 13_160_000_000) self.assertTrue(combined.lateralActive and combined.longitudinalActive and command.latActive and command.longActive) self.assertTrue(receive(13_160_000_000, axis=sd.pm.messages['aolAxisState'], event=sd.pm.messages['onroadEvents'])) def test_ioniq_aol_only_availability_prompt_reaches_controls_and_clears_on_recovery(self): with OpenpilotPrefix(): sd, controls, step, card, receive = self._ioniq_availability_callers(full_op=False) axis, state, command = step(2, car_output_valid=False) self.assertEqual(str(axis.faultReason), 'availabilityPause') self.assertEqual(state.alertType, 'commIssue/softDisable') self.assertEqual((state.alertText1, state.alertText2), ('TAKE CONTROL IMMEDIATELY', 'Communication Issue Between Processes')) self.assertEqual(str(state.alertSound), 'warningImmediate') delivered = controls.sm['selfdriveState'] self.assertEqual(delivered.alertType, state.alertType) self.assertEqual(str(delivered.alertSound), 'warningImmediate') self.assertFalse(sd.enabled or sd.active or state.enabled or state.active or axis.lateralActive or axis.longitudinalActive or command.latActive or command.longActive) self.assertTrue(receive(10_020_000_000, axis=sd.pm.messages['aolAxisState'], event=sd.pm.messages['onroadEvents'])) for tick in range(3, 45): axis, state, command = step(tick, car_output_valid=False) self.assertEqual(state.alertType, 'commIssue/softDisable') self.assertFalse(sd.enabled or sd.active or state.enabled or state.active) self.assertFalse(axis.lateralActive or axis.longitudinalActive or command.latActive or command.longActive) self.assertTrue(receive(10_000_000_000 + tick * 10_000_000, axis=sd.pm.messages['aolAxisState'])) # The normal takeover alert has its original four-second lifetime; # it outranks the optional 0.4-second availability prompt. self.assertEqual(str(state.alertSound), 'warningImmediate') self.assertEqual(str(controls.sm['selfdriveState'].alertSound), 'warningImmediate') for tick in range(45, 408): recovered, state, command = step(tick, native_request_long=False) self.assertTrue(recovered.lateralActive and command.latActive) self.assertFalse(sd.enabled or sd.active or state.enabled or state.active or recovered.desiredLongitudinal or recovered.longitudinalActive or command.longActive) self.assertTrue(receive(10_000_000_000 + tick * 10_000_000, axis=sd.pm.messages['aolAxisState'], event=sd.pm.messages['onroadEvents'])) self.assertNotEqual(state.alertType, 'commIssue/softDisable') self.assertNotEqual(controls.sm['selfdriveState'].alertType, 'commIssue/softDisable') self.assertEqual(str(state.alertSound), 'none') self.assertEqual(str(controls.sm['selfdriveState'].alertSound), 'none') def test_ioniq_availability_sustained_advancing_proof_keeps_warning_and_no_long(self): with OpenpilotPrefix(): sd, _, step, card, receive = self._ioniq_availability_callers() occurrences = set() for tick in range(2, 413): axis, state, command = step(tick, car_output_valid=False) now = 10_000_000_000 + tick * 10_000_000 self.assertEqual(str(axis.faultReason), 'availabilityPause') self.assertTrue(axis.desiredLateral and axis.nativeAcknowledged) self.assertFalse(axis.lateralActive or axis.desiredLongitudinal or axis.longitudinalActive or command.latActive or command.longActive) self.assertIn(log.OnroadEvent.EventName.commIssue, sd.events.names) self.assertTrue(state.alertText1 or state.alertText2) proof, event = sd.pm.messages['aolAxisState'], sd.pm.messages['onroadEvents'] advanced = axis.faultEventMonoTime not in occurrences occurrences.add(axis.faultEventMonoTime) self.assertTrue(receive(now, axis=proof, event=event if advanced else None)) self.assertTrue(card.aol_transport_feedback.ordinary_warning_qualified) self.assertFalse(card.aol_transport_feedback.retention_qualified) # Real periodic SD publications advance the matched occurrence beyond the # initial 30 ms pairing window; they do not extend an unmatched occurrence. self.assertGreaterEqual(len(occurrences), 4) self.assertFalse(sd.enabled or sd.active) self.assertEqual(state.alertType, 'aolAvailability/warning') self.assertEqual((state.alertText1, state.alertText2), ('Steering Paused', 'Communication Issue')) recovered, _, command = step(413, native_request_long=False) self.assertTrue(recovered.lateralActive and command.latActive) self.assertFalse(recovered.desiredLongitudinal or recovered.longitudinalActive or command.longActive) self.assertFalse(sd.enabled or sd.active) self.assertTrue(receive(14_130_000_000, axis=sd.pm.messages['aolAxisState'], event=sd.pm.messages['onroadEvents'])) def test_ioniq_availability_missing_pair_has_fixed_deadline(self): for missing in ('axis', 'event'): with self.subTest(missing=missing), OpenpilotPrefix(): sd, _, step, card, receive = self._ioniq_availability_callers() step(2, car_output_valid=False) proof, event = sd.pm.messages['aolAxisState'], sd.pm.messages['onroadEvents'] now = 10_020_000_000 self.assertTrue(receive(now, axis=proof if missing == 'event' else None, event=event if missing == 'axis' else None)) step(4, car_output_valid=False, native_request_long=False) renewed = sd.pm.messages['aolAxisState'] self.assertTrue(receive(now + 20_000_000, axis=renewed if missing == 'event' else None, event=event if missing == 'axis' else None)) self.assertFalse(receive(now + 31_000_000, axis=renewed if missing == 'event' else None)) self.assertFalse(receive(now + 32_000_000, axis=proof, event=event)) self.assertFalse(card.aol_card_intent.allowed_latch) def test_ioniq_availability_never_masks_native_can_or_integrity_fault(self): for fault in ('native_missing', 'native_denied', 'permanent_eps', 'can_invalid', 'relay', 'intent_invalid', 'producer_restart'): with self.subTest(fault=fault), OpenpilotPrefix(): sd, _, step, card, receive = self._ioniq_availability_callers() step(2, car_output_valid=False) self.assertTrue(receive(10_020_000_000, axis=sd.pm.messages['aolAxisState'], event=sd.pm.messages['onroadEvents'])) kwargs = {'car_output_valid': False, 'native_request_long': False} observed = car_state() if fault == 'native_missing': kwargs['native_valid'] = False elif fault == 'native_denied': kwargs['allowed'] = False elif fault == 'permanent_eps': observed.steerFaultPermanent = True elif fault == 'can_invalid': observed.canValid = False elif fault == 'relay': kwargs['relay_fault'] = True else: stamp = 10_030_000_000 intent = messaging.new_message('aolIntentWire', 0, valid=fault != 'intent_invalid', logMonoTime=stamp) intent.aolIntentWire = encode_intent(IntentState( 'restarted-card' if fault == 'producer_restart' else card.slc_producer_session, 1, stamp, stamp, stamp + 200_000_000, True, False, False, True, True)) kwargs['intent_message'] = intent epoch_before = sd.aol_intent_companion.integrity_epoch axis, _, command = step(3, observed_state=observed, **kwargs) self.assertFalse(axis.lateralActive or axis.longitudinalActive or command.latActive or command.longActive) if fault == 'native_denied': native = current_native(sd.sm, sd.CP, now_ns=10_030_000_000, axis_session_id=sd.aol_session_id) self.assertIsNotNone(native) self.assertTrue(native.requestedLateral) self.assertFalse(native.lateralAllowed) self.assertEqual(str(axis.faultReason), 'availabilityPause') self.assertFalse(receive(10_030_000_000, axis=sd.pm.messages['aolAxisState'], event=sd.pm.messages['onroadEvents'], state=observed)) self.assertFalse(card.aol_card_intent.allowed_latch) else: if fault in ('intent_invalid', 'producer_restart'): self.assertGreater(sd.aol_intent_companion.integrity_epoch, epoch_before) self.assertEqual(str(axis.faultReason), 'availabilityPause') else: self.assertEqual(str(axis.faultReason), 'critical') self.assertFalse(receive(10_030_000_000, axis=sd.pm.messages['aolAxisState'], event=sd.pm.messages['onroadEvents'], state=observed)) self.assertFalse(card.aol_card_intent.allowed_latch) def test_neutral_reverse_neutral_drive_preserves_actual_card_latch(self): from openpilot.starpilot.aol.wire import decode_intent with OpenpilotPrefix(): sd, _, step, card, receive = self._ioniq_availability_callers() card.pm = type(sd.pm)() card.aol_sequence = 2 gears = ['neutral'] * 3 + ['reverse'] * 40 + ['neutral'] * 3 + ['drive'] * 4 for tick, gear in enumerate(gears, 2): cs = car_state(brake=True) cs.gearShifter = gear now = 10_000_000_000 + tick * 10_000_000 original_message = messaging.new_message def current_message(*args, _original=original_message, _now=now, **kwargs): message = _original(*args, **kwargs) message.logMonoTime = _now return message with mock.patch('openpilot.selfdrive.car.card.messaging.new_message', side_effect=current_message): card.state_publish(cs, None) intent_message = card.pm.messages['aolIntentWire'].as_builder() intent = decode_intent(bytes(intent_message.aolIntentWire)) self.assertTrue(intent.lateralArmed) requested = gear == 'drive' axis, state, command = step(tick, observed_state=cs, intent_message=intent_message, native_request_lat=requested, allowed=requested, native_request_long=False) if gear != 'drive': self.assertFalse(axis.desiredLateral or axis.lateralActive or command.latActive) self.assertFalse(axis.longitudinalActive or command.longActive) self.assertEqual(str(axis.faultReason), 'none') self.assertTrue(receive(now, state=cs, axis=sd.pm.messages['aolAxisState'], event=sd.pm.messages['onroadEvents'])) self.assertTrue(axis.lateralActive and command.latActive) self.assertFalse(state.enabled or state.active)