"""Real GM parser/Card sources with controlled native peers; no transport claim.""" from dataclasses import replace from types import SimpleNamespace from unittest.mock import patch import pytest from opendbc.can import CANPacker from opendbc.car import Bus from opendbc.car.gm.bolt_cc import button_bytes from opendbc.car.gm.gmcan import pedal_crc from opendbc.car.gm.tests.test_bolt_cc import feed from opendbc.car.gm.tests.test_bolt_pedal import params from opendbc.car.gm.values import CAR, DBC, CruiseButtons from openpilot.cereal import messaging from openpilot.common.params import Params from openpilot.common.prefix import OpenpilotPrefix from openpilot.starpilot.aol.runtime import decide_axes from openpilot.starpilot.aol.transport_pause import TransportPauseFeedback from openpilot.starpilot.aol.wire import PandaSlot, SafetyState, encode_safety NOW = 10_000_000_000 class Peers: def __init__(self, messages): self.messages = {m.which(): m for m in messages} self.logMonoTime = {n: int(m.logMonoTime) for n, m in self.messages.items()} self.seen = dict.fromkeys(self.messages, True) self.valid = dict(self.seen) self.alive = dict(self.seen) def __getitem__(self, name): return getattr(self.messages[name], name) def parse(card, now, gesture=None, counter=0, *, belt_unlatched=False, packets_only=False): packer = CANPacker(DBC[card.CP.carFingerprint][Bus.pt]) _, packets = feed(SimpleNamespace(update=lambda _: None), packer, now, counter=counter, active=False, speed=20., brake=gesture == 'brake', regen=gesture == 'paddle') packets = [p for p in packets if p[0] not in (0x1E1, 0x1F5)] packets += [packer.make_can_msg('BCMBlindSpotMonitor', 0, {}), packer.make_can_msg('ECMPRDNL2', 0, {'PRNDL2': 6}), (0x1E1, button_bytes(CruiseButtons.CANCEL if gesture == 'cancel' else CruiseButtons.UNPRESS, counter), 0)] if belt_unlatched: # Real physical BCMDoorBeltStatus (0x12A), not a CarState assignment. packets = [p for p in packets if p[0] != 0x12A] packets.append(packer.make_can_msg('BCMDoorBeltStatus', 0, {'LeftSeatBelt': 0})) sensor = bytearray(packer.make_can_msg('GAS_SENSOR', 0, {'INTERCEPTOR_GAS': 0., 'INTERCEPTOR_GAS2': 0., 'STATE': 0, 'COUNTER_PEDAL': counter})[1]) sensor[5] = pedal_crc(sensor) packets.append((0x201, bytes(sensor), 0)) if card.CP.carFingerprint == CAR.CHEVROLET_BOLT_ACC_2022_2023_PEDAL and int(card.CP.safetyConfigs[0].safetyParam) == 0x1CD: if now % 40_000_000 < 10_000_000: packets.append(packer.make_can_msg('EBCMFrictionBrakeCmd', 2, {'RollingCounter': (now // 40_000_000) % 4})) if packets_only: return packets if now == NOW: card.CI.update([(now - 1_000_000, packets)]) cs = card.CI.update([(now, packets)]) assert cs.canValid and not cs.canTimeout assert cs.brakePressed == (gesture == 'brake') assert cs.regenBraking == (gesture == 'paddle') assert cs.seatbeltUnlatched == belt_unlatched card._aol_can_observation = (cs, now, now, now) return cs def peers(cp, producer, now, *, controls=True, native=None, sequence=1, long_active=True): config = cp.safetyConfigs[0] if native is None: slot = PandaSlot(0, 'gm-unit', config.safetyModel.raw, config.safetyParam, 32, True) native = SafetyState(1, True, now, now + 200_000_000, config.safetyModel.raw, config.safetyParam, True, True, True, True, 'gm-unit', 'gm-session', sourcePandaStatesMonoTime=now, pandaInventory=(slot,)) wire = messaging.new_message('aolSafetyWire', 0, valid=True, logMonoTime=native.observedMonoTime) wire.aolSafetyWire = encode_safety(native) pandas = messaging.new_message('pandaStates', 1, valid=True, logMonoTime=now) p = pandas.pandaStates[0] p.safetyModel, p.safetyParam, p.alternativeExperience = config.safetyModel, config.safetyParam, 32 p.controlsAllowed = controls axis = messaging.new_message('aolAxisState', valid=True, logMonoTime=now) a = axis.aolAxisState a.sessionId = a.faultSessionId = 'gm-session' a.sequence, a.qualified, a.faultReason = sequence, True, 'none' a.observedMonoTime, a.validUntilMonoTime, a.sourceCarStateMonoTime = now, now + 30_000_000, now a.desiredLateral = a.lateralActive = a.nativeAcknowledged = True a.desiredLongitudinal = a.longitudinalActive = long_active a.intentProducerSessionId, a.intentIntegrityEpoch = producer, 1 events = messaging.new_message('onroadEvents', 0, valid=True, logMonoTime=now) return Peers([wire.as_reader(), pandas.as_reader(), axis.as_reader(), events.as_reader()]), native def baseline(): from openpilot.starpilot.aol.tests.test_gm import TestGmAol settings = Params() settings.put_bool('AlwaysOnLateral', True, block=True) settings.put_bool('AlphaLongitudinalEnabled', True, block=True) cp = params(CAR.CHEVROLET_BOLT_CC_2018_2021, pedal=True, camera=True) card = TestGmAol.card(cp, settings) cs = parse(card, NOW) card.aol_card_intent.update(cs, fault_active=False, now_ns=NOW) assert card.aol_card_intent.allowed_latch sm, native = peers(card.CP, card.slc_producer_session, NOW) feedback = TransportPauseFeedback(card.CP) assert not feedback.observe(sm, card.CP, cs, NOW, latched=True, intent_producer_session=card.slc_producer_session) assert feedback.session == 'gm-session' and feedback.healthy_inventory return card, feedback, native @pytest.mark.parametrize('health_allowed', [False, True]) def test_native_long_withdrawal_before_parser_keeps_existing_card_intent(health_allowed): with OpenpilotPrefix(): card, feedback, prior = baseline() now = NOW + 10_000_000 cs = parse(card, now, counter=1) native = replace(prior, observedMonoTime=now, validUntilMonoTime=now + 200_000_000, longitudinalAllowed=False, pandaInventory=(replace(prior.pandaInventory[0], controlsAllowed=health_allowed),)) sm, _ = peers(card.CP, card.slc_producer_session, now, controls=health_allowed, native=native, sequence=2) # The host's last full-axis result precedes the native paddle/brake edge. reset = feedback.observe(sm, card.CP, cs, now, latched=True, intent_producer_session=card.slc_producer_session) card.aol_card_intent.update(cs, fault_active=reset, now_ns=now) assert not reset and not feedback.failed and card.aol_card_intent.allowed_latch assert not native.longitudinalAllowed @pytest.mark.parametrize('gesture', ['brake', 'paddle', 'cancel']) def test_real_physical_withdrawal_retains_latch_with_held_long_wire(gesture): with OpenpilotPrefix(): card, feedback, native = baseline() now = NOW + 10_000_000 cs = parse(card, now, gesture, counter=1) source = card.aol_gm_withdrawal_source(cs, now) assert source == now sm, _ = peers(card.CP, card.slc_producer_session, now, controls=False, native=native, sequence=2) reset = feedback.observe(sm, card.CP, cs, now, latched=True, gm_withdrawal_source_ns=source, intent_producer_session=card.slc_producer_session) card.aol_card_intent.update(cs, fault_active=reset, now_ns=now) assert not reset and not feedback.failed and card.aol_card_intent.allowed_latch assert feedback.gm_withdrawal_ns == source # The prior LONG wire and latest raw withdrawal are untouched. Ordinary # runtime keeps LONG off even though accepted driver LAT intent survives. intent = SimpleNamespace(allowedLatch=True, pauseLateral=False, pauseLongitudinal=False) decision = decide_axes(standard_lateral=False, standard_longitudinal=False, intent=intent, native=native, car_state=cs, initialized=True, model_ready=True, no_entry=False, immediate_disable=False, dm_lockout=False, pause_brake_mps=5.) assert decision.lateral_active and not decision.longitudinal_active assert native.requestedLongitudinal and native.longitudinalAllowed assert native.validUntilMonoTime == NOW + 200_000_000 # Held and release samples retain one original occurrence; the actual # native LAT-only acknowledgement ends the exception on the normal path. for offset, physical in ((20_000_000, gesture), (30_000_000, None)): later = NOW + offset cs = parse(card, later, physical, counter=2) sm, _ = peers(card.CP, card.slc_producer_session, later, controls=False, native=native, sequence=offset // 10_000_000 + 1, long_active=False) assert not feedback.observe(sm, card.CP, cs, later, latched=True, gm_withdrawal_source_ns=card.aol_gm_withdrawal_source(cs, later), intent_producer_session=card.slc_producer_session) assert feedback.gm_withdrawal_ns == source lat_only = replace(native, observedMonoTime=NOW + 40_000_000, validUntilMonoTime=NOW + 240_000_000, requestedLongitudinal=False, longitudinalAllowed=False, pandaInventory=(replace(native.pandaInventory[0], controlsAllowed=False),)) sm, _ = peers(card.CP, card.slc_producer_session, NOW + 40_000_000, controls=False, native=lat_only, sequence=5, long_active=False) assert not feedback.observe(sm, card.CP, cs, NOW + 40_000_000, latched=True, intent_producer_session=card.slc_producer_session) assert feedback.gm_withdrawal_ns == 0 @pytest.mark.parametrize('fault', ['no_source', 'stale_source', 'future_source', 'producer', 'epoch', 'session', 'serial', 'inventory', 'owner', 'config', 'rx_fault', 'heartbeat', 'lat_denied', 'no_lat', 'native_expired', 'can_invalid', 'eps', 'event', 'axis_expired', 'sequence', 'main', 'seatbelt', 'seatbelt_event', 'door', 'gear']) def test_physical_withdrawal_keeps_identity_freshness_and_health_vetoes(fault): with OpenpilotPrefix(): card, feedback, native = baseline() now = NOW + 10_000_000 cs = parse(card, now, 'brake', counter=1) source = card.aol_gm_withdrawal_source(cs, now) if fault == 'no_source': source = 0 elif fault == 'stale_source': source = now - 30_000_001 elif fault == 'future_source': source = now + 1 elif fault == 'session': native = replace(native, axisSessionId='other') elif fault == 'serial': native = replace(native, pandaSerial='other') elif fault == 'inventory': native = replace(native, pandaInventory=(replace(native.pandaInventory[0], hardwareSerial='other'),)) elif fault == 'owner': card.CP.alternativeExperience = 0 elif fault == 'no_lat': native = replace(native, requestedLateral=False) elif fault == 'native_expired': native = replace(native, validUntilMonoTime=now - 1) elif fault == 'main': cs.cruiseState.available = False elif fault == 'lat_denied': native = replace(native, lateralAllowed=False) elif fault == 'can_invalid': cs.canValid = False elif fault == 'eps': cs.steerFaultPermanent = True elif fault == 'seatbelt': cs.seatbeltUnlatched = True elif fault == 'door': cs.doorOpen = True elif fault == 'gear': from opendbc.car.structs import car cs.gearShifter = car.CarState.GearShifter.park sm, _ = peers(card.CP, card.slc_producer_session, now, controls=False, native=native, sequence=2) axis = sm.messages['aolAxisState'].as_builder() pandas = sm.messages['pandaStates'].as_builder() events = sm.messages['onroadEvents'].as_builder() if fault == 'producer': axis.aolAxisState.intentProducerSessionId = 'other' elif fault == 'epoch': axis.aolAxisState.intentIntegrityEpoch = 2 elif fault == 'sequence': axis.aolAxisState.sequence = 1 elif fault == 'axis_expired': axis.aolAxisState.validUntilMonoTime = now - 1 elif fault == 'config': pandas.pandaStates[0].safetyParam ^= 1 elif fault == 'rx_fault': pandas.pandaStates[0].safetyRxChecksInvalid = True elif fault == 'heartbeat': pandas.pandaStates[0].heartbeatLost = True elif fault == 'seatbelt_event': events.init('onroadEvents', 1) events.onroadEvents[0].name, events.onroadEvents[0].softDisable = 'seatbeltNotLatched', True elif fault == 'event': events.init('onroadEvents', 1) events.onroadEvents[0].name, events.onroadEvents[0].immediateDisable = 'controlsMismatch', True sm = Peers([sm.messages['aolSafetyWire'], pandas.as_reader(), axis.as_reader(), events.as_reader()]) reset = feedback.observe(sm, card.CP, cs, now, latched=True, gm_withdrawal_source_ns=source, intent_producer_session=card.slc_producer_session) if fault == 'seatbelt': # Keep the original input, but correct its demonstrated contract: an # unlatched belt locks LONG and does not erase accepted LAT intent. card.aol_card_intent.update(cs, fault_active=reset, now_ns=now) assert not reset and not feedback.failed and card.aol_card_intent.allowed_latch intent = SimpleNamespace(allowedLatch=True, pauseLateral=False, pauseLongitudinal=False) decision = decide_axes(standard_lateral=False, standard_longitudinal=True, intent=intent, native=native, car_state=cs, initialized=True, model_ready=True, no_entry=False, immediate_disable=False, dm_lockout=False, pause_brake_mps=0.) assert decision.lateral_active assert not decision.desired_longitudinal and not decision.longitudinal_active else: assert reset assert feedback.failed def test_held_and_duplicate_samples_cannot_extend_withdrawal_deadline(): with OpenpilotPrefix(): card, feedback, native = baseline() occurrence = NOW + 10_000_000 for tick in range(1, 23): now = NOW + tick * 10_000_000 cs = parse(card, now, 'brake', counter=tick % 4) if tick > 1: native = replace(native, observedMonoTime=now, validUntilMonoTime=now + 200_000_000) sm, _ = peers(card.CP, card.slc_producer_session, now, controls=False, native=native, sequence=tick + 1, long_active=tick == 1) source = card.aol_gm_withdrawal_source(cs, now) for _ in range(2): failed = feedback.observe(sm, card.CP, cs, now, latched=True, gm_withdrawal_source_ns=source, intent_producer_session=card.slc_producer_session) assert failed == (tick == 22) assert feedback.gm_withdrawal_ns == occurrence assert feedback.failed @pytest.mark.parametrize('fault', ['different_cs', 'old_receipt', 'future_receipt', 'stale_parser', 'future_parser']) def test_physical_receipt_requires_same_parsed_batch_and_separate_clock_freshness(fault): with OpenpilotPrefix(): card, _, _ = baseline() now = NOW + 10_000_000 cs = parse(card, now, 'brake', counter=1) assert card.aol_gm_withdrawal_source(cs, now) == now if fault == 'different_cs': card._aol_can_observation = (cs.as_reader().as_builder(), now, now, now) elif fault == 'old_receipt': card._aol_can_observation = (cs, now - 30_000_001, now, now) elif fault == 'future_receipt': card._aol_can_observation = (cs, now + 1, now, now) elif fault == 'stale_parser': card._aol_can_observation = (cs, now, now + 30_000_001, now + 30_000_001) elif fault == 'future_parser': card._aol_can_observation = (cs, now, now - 1, now) assert card.aol_gm_withdrawal_source(cs, now) == 0 @pytest.mark.parametrize('owner', ['qualified', 'unmarked', 'wrong_profile']) def test_actual_card_state_update_captures_boot_clock_only_for_physical_gm_owner(owner): # Stop immediately after the production clock gate. The real joined Card.step # transport cases separately exercise installation/consumption of the receipt. class ClockBoundaryReached(Exception): pass def stop_after_clock(*_): raise ClockBoundaryReached with OpenpilotPrefix(): card, _, _ = baseline() cs = parse(card, NOW + 10_000_000, 'brake', counter=1) if owner == 'unmarked': card.CP.alternativeExperience = 0 elif owner == 'wrong_profile': card.CP.flags = 0 with patch('openpilot.selfdrive.car.card.messaging.drain_sock_raw', return_value=[]), \ patch('openpilot.selfdrive.car.card.can_capnp_to_list', return_value=[(NOW + 10_000_000, [])]), \ patch('openpilot.selfdrive.car.card.time.monotonic_ns', return_value=NOW + 10_000_000), \ patch('openpilot.selfdrive.car.card.time.clock_gettime_ns', return_value=NOW + 10_000_000) as clock, \ patch.object(card.CI, 'update', return_value=cs), \ patch.object(card, 'observe_ioniq6_long_authority', side_effect=stop_after_clock): with pytest.raises(ClockBoundaryReached): card.state_update() if owner == 'qualified': from openpilot.selfdrive.car.card import time clock.assert_called_once_with(time.CLOCK_BOOTTIME) else: clock.assert_not_called() @pytest.mark.parametrize('fault', [None, 'unmarked', 'wrong_owner', 'unlatched', 'unarmed', 'source_mismatch', 'source_expired', 'future_source', 'no_cancel', 'can_invalid', 'eps', 'no_native', 'lat_denied', 'native_inventory']) def test_gm_cancel_exempts_only_current_armed_lateral_no_entry(fault): from openpilot.starpilot.aol.wire import IntentState from openpilot.starpilot.car.gm.aol import physical_cancel_lateral_allowed from openpilot.selfdrive.selfdrived.events import ET, EVENTS, EventName from openpilot.starpilot.nostalgia import aol_no_entry with OpenpilotPrefix(): card, _, native = baseline() now = NOW + 10_000_000 cs = parse(card, now, 'cancel', counter=1) intent = IntentState(card.slc_producer_session, 2, now, now, now + 30_000_000, True, False, False, True, True) source = now if fault == 'unmarked': card.CP.alternativeExperience = 0 elif fault == 'wrong_owner': card.CP.flags = 0 elif fault == 'unlatched': intent = replace(intent, allowedLatch=False) elif fault == 'unarmed': intent = replace(intent, lateralArmed=False) elif fault == 'source_mismatch': source -= 1 elif fault == 'source_expired': now += 30_000_001 elif fault == 'future_source': source += 1 elif fault == 'no_cancel': cs.buttonEvents = [] elif fault == 'can_invalid': cs.canValid = False elif fault == 'eps': cs.steerFaultPermanent = True elif fault == 'no_native': native = None elif fault == 'lat_denied': native = replace(native, lateralAllowed=False) elif fault == 'native_inventory': native = replace(native, pandaInventory=(replace(native.pandaInventory[0], hardwareSerial='other'),)) allowed = physical_cancel_lateral_allowed(card.CP, cs, intent, native, source_ns=source, now_ns=now) assert allowed == (fault is None) assert aol_no_entry([EventName.buttonCancel], cs, paddle_only_cancel=False) assert aol_no_entry([EventName.buttonCancel], cs, paddle_only_cancel=False, allow_lateral_cancel=allowed) == (fault is not None) assert ET.USER_DISABLE in EVENTS[EventName.buttonCancel] assert aol_no_entry([EventName.buttonCancel, EventName.doorOpen], cs, paddle_only_cancel=False, allow_lateral_cancel=allowed) def test_native_lat_only_after_long_belt_loss_keeps_saved_lateral_intent(): from openpilot.starpilot.aol.intent import disarming_fault from openpilot.starpilot.nostalgia import aol_no_entry from openpilot.selfdrive.selfdrived.events import Events, EventName with OpenpilotPrefix(): card, feedback, native = baseline() for tick, unlatched in ((1, True), (2, True), (3, False)): now = NOW + tick * 10_000_000 cs = parse(card, now, counter=tick) cs.seatbeltUnlatched = unlatched native = replace(native, observedMonoTime=now, validUntilMonoTime=now + 200_000_000, requestedLongitudinal=False, longitudinalAllowed=False, pandaInventory=(replace(native.pandaInventory[0], controlsAllowed=False),)) sm, _ = peers(card.CP, card.slc_producer_session, now, controls=False, native=native, sequence=tick + 1, long_active=False) events = Events() if unlatched: events.add(EventName.seatbeltNotLatched) wire_events = sm.messages['onroadEvents'].as_builder() wire_events.onroadEvents = events.to_msg() sm = Peers([sm.messages['aolSafetyWire'], sm.messages['pandaStates'], sm.messages['aolAxisState'], wire_events.as_reader()]) reset = feedback.observe(sm, card.CP, cs, now, latched=True, intent_producer_session=card.slc_producer_session) card.aol_card_intent.update(cs, fault_active=reset or disarming_fault(events.to_msg(), cs), now_ns=now) assert not reset and not feedback.failed and card.aol_card_intent.allowed_latch allowed, pause_lat, pause_long = card.aol_card_intent.output(cs) decision = decide_axes(standard_lateral=True, standard_longitudinal=unlatched, intent=SimpleNamespace(allowedLatch=allowed, pauseLateral=pause_lat, pauseLongitudinal=pause_long), native=native, car_state=cs, initialized=True, model_ready=True, no_entry=aol_no_entry(events.names, cs, paddle_only_cancel=False), immediate_disable=False, dm_lockout=False, pause_brake_mps=0.) assert decision.lateral_active assert not decision.desired_longitudinal and not decision.longitudinal_active assert feedback.gm_withdrawal_ns == 0 @pytest.mark.parametrize('fault', [None, 'stale_source', 'future_source', 'future_observation', 'excessive_lease', 'expired', 'no_expiry']) def test_real_card_published_cancel_intent_preserves_producer_lease_and_freshness(fault): from openpilot.starpilot.aol.runtime import current_intent from openpilot.starpilot.aol.wire import decode_intent from openpilot.starpilot.car.gm.aol import physical_cancel_lateral_allowed class PublishedCarState(Exception): pass with OpenpilotPrefix(): card, _, native = baseline() now = NOW + 10_000_000 cs = parse(card, now, 'cancel', counter=1) published = {} real_new_message = messaging.new_message def stamped_message(service=None, size=None, **kwargs): return real_new_message(service, size, **({'logMonoTime': now} | kwargs)) def capture(service, message): wire = message if isinstance(message, bytes) else message.to_bytes() published[service] = messaging.log_from_bytes(wire) if service == 'carState': raise PublishedCarState with patch('openpilot.selfdrive.car.card.messaging.new_message', side_effect=stamped_message), \ patch.object(card.pm, 'send', side_effect=capture): with pytest.raises(PublishedCarState): card.state_publish(cs, None) source = int(published['carState'].logMonoTime) cs = published['carState'].carState intent = decode_intent(published['aolIntentWire'].aolIntentWire) assert intent is not None assert intent.carStateLogMonoTime == intent.observedMonoTime == source == now assert intent.validUntilMonoTime - intent.observedMonoTime == 200_000_000 admitted = current_intent(Peers([published['aolIntentWire']]), car_state_ns=source, now_ns=now) assert admitted == intent # Faults modify only the negative cases after the actual producer boundary. if fault == 'stale_source': now += 30_000_001 elif fault == 'future_source': source += 1 elif fault == 'future_observation': intent = replace(intent, observedMonoTime=now + 1) elif fault == 'excessive_lease': intent = replace(intent, validUntilMonoTime=intent.observedMonoTime + 200_000_001) elif fault == 'expired': intent = replace(intent, validUntilMonoTime=now - 1) elif fault == 'no_expiry': intent = replace(intent, validUntilMonoTime=0) assert physical_cancel_lateral_allowed(card.CP, cs, intent, native, source_ns=source, now_ns=now) == (fault is None) @pytest.mark.parametrize('gesture', ['brake', 'paddle', 'cancel']) def test_real_belt_loss_with_physical_withdrawal_retains_only_existing_lateral_intent(gesture): from openpilot.starpilot.aol.intent import disarming_fault from openpilot.starpilot.nostalgia import aol_no_entry from openpilot.selfdrive.selfdrived.events import Events, EventName with OpenpilotPrefix(): card, feedback, native = baseline() occurrence = NOW + 10_000_000 for tick, unlatched, physical in ((1, True, gesture), (2, True, gesture), (3, True, None), (4, False, None)): now = NOW + tick * 10_000_000 cs = parse(card, now, physical, counter=tick % 4, belt_unlatched=unlatched) sm, _ = peers(card.CP, card.slc_producer_session, now, controls=False, native=native, sequence=tick + 1, long_active=tick == 1) events = Events() # At the physical boundary Card consumes the prior event publication; # the following call consumes the real seatbelt soft-disable classification. if unlatched and tick > 1: events.add(EventName.seatbeltNotLatched) event_wire = sm.messages['onroadEvents'].as_builder() event_wire.onroadEvents = events.to_msg() sm = Peers([sm.messages['aolSafetyWire'], sm.messages['pandaStates'], sm.messages['aolAxisState'], event_wire.as_reader()]) source = card.aol_gm_withdrawal_source(cs, now) assert source == (now if physical is not None and (gesture != 'cancel' or tick == 1) else 0) reset = feedback.observe(sm, card.CP, cs, now, latched=True, gm_withdrawal_source_ns=source, intent_producer_session=card.slc_producer_session) card.aol_card_intent.update(cs, fault_active=reset or disarming_fault(events.to_msg(), cs), now_ns=now) assert not reset and not feedback.failed and card.aol_card_intent.allowed_latch assert feedback.gm_withdrawal_ns == occurrence assert native.requestedLongitudinal and native.longitudinalAllowed assert native.observedMonoTime == NOW and native.validUntilMonoTime == NOW + 200_000_000 allowed, pause_lat, pause_long = card.aol_card_intent.output(cs) intent = SimpleNamespace(allowedLatch=allowed, pauseLateral=pause_lat, pauseLongitudinal=pause_long) no_entry = aol_no_entry(events.names, cs, paddle_only_cancel=False) decision = decide_axes(standard_lateral=True, standard_longitudinal=unlatched, intent=intent, native=native, car_state=cs, initialized=True, model_ready=True, no_entry=no_entry, immediate_disable=False, dm_lockout=False, pause_brake_mps=0.) assert decision.desired_lateral and decision.lateral_active # Belt loss locks LONG even with a standard LONG request. Rebuckling # supplies no new standard engagement and cannot resume LONG automatically. assert not decision.desired_longitudinal and not decision.longitudinal_active locked = decide_axes(standard_lateral=True, standard_longitudinal=unlatched, intent=intent, native=native, car_state=cs, initialized=True, model_ready=True, no_entry=no_entry, immediate_disable=False, dm_lockout=True, pause_brake_mps=0.) assert not locked.lateral_active and not locked.longitudinal_active ack_ns = NOW + 50_000_000 cs = parse(card, ack_ns, counter=1) lat_only = replace(native, observedMonoTime=ack_ns, validUntilMonoTime=ack_ns + 200_000_000, requestedLongitudinal=False, longitudinalAllowed=False, pandaInventory=(replace(native.pandaInventory[0], controlsAllowed=False),)) sm, _ = peers(card.CP, card.slc_producer_session, ack_ns, controls=False, native=lat_only, sequence=6, long_active=False) assert not feedback.observe(sm, card.CP, cs, ack_ns, latched=True, intent_producer_session=card.slc_producer_session) assert not feedback.failed and feedback.gm_withdrawal_ns == 0 @pytest.mark.parametrize('fault', ['belt_immediate', 'belt_user_disable', 'belt_missing_noentry', 'wrong_belt_name', 'other_soft', 'mixed_soft', 'stale_events', 'future_events', 'door', 'eps']) def test_combined_belt_withdrawal_preserves_exact_event_and_vehicle_fault_vetoes(fault): from openpilot.selfdrive.selfdrived.events import Events, EventName with OpenpilotPrefix(): card, feedback, native = baseline() now = NOW + 10_000_000 cs = parse(card, now, 'brake', counter=1, belt_unlatched=True) source = card.aol_gm_withdrawal_source(cs, now) assert source == now sm, _ = peers(card.CP, card.slc_producer_session, now, controls=False, native=native, sequence=2) events = Events() events.add(EventName.seatbeltNotLatched) if fault in ('other_soft', 'mixed_soft'): if fault == 'other_soft': events.clear() events.add(EventName.processNotRunning) event_wire = sm.messages['onroadEvents'].as_builder() event_wire.onroadEvents = events.to_msg() if fault == 'belt_immediate': event_wire.onroadEvents[0].immediateDisable = True elif fault == 'belt_user_disable': event_wire.onroadEvents[0].userDisable = True elif fault == 'belt_missing_noentry': event_wire.onroadEvents[0].noEntry = False elif fault == 'wrong_belt_name': event_wire.onroadEvents[0].name = EventName.steerTempUnavailable elif fault == 'stale_events': event_wire.logMonoTime = now - 1_500_000_001 elif fault == 'future_events': event_wire.logMonoTime = now + 1 elif fault == 'door': cs.doorOpen = True elif fault == 'eps': cs.steerFaultPermanent = True sm = Peers([sm.messages['aolSafetyWire'], sm.messages['pandaStates'], sm.messages['aolAxisState'], event_wire.as_reader()]) assert feedback.observe(sm, card.CP, cs, now, latched=True, gm_withdrawal_source_ns=source, intent_producer_session=card.slc_producer_session) assert feedback.failed @pytest.mark.parametrize('gesture', ['brake', 'paddle', 'cancel']) @pytest.mark.parametrize('fault', [None, 'other_soft', 'immediate']) def test_actual_sd_belt_classification_wire_reaches_next_card_cycle(gesture, fault): from contextlib import ExitStack from openpilot.selfdrive.selfdrived.events import Events, EventName from openpilot.starpilot.aol.intent_companion import IntentCompanion from openpilot.starpilot.aol.tests.test_ioniq_availability import IoniqAvailabilityTests from openpilot.starpilot.aol.transport_pause import AvailabilityPause, TransportPause from openpilot.starpilot.aol.wire import decode_intent class PublishedCarState(Exception): pass case = IoniqAvailabilityTests(methodName='runTest') with ExitStack() as cleanup, OpenpilotPrefix(): cleanup.callback(case.doCleanups) card, _, native = baseline() sock, queued = object(), [] companion = IntentCompanion(sock) sd, _, step = case._authority_callers(True, cp=card.CP.as_reader().as_builder(), intent_companion=companion) sd.aol_session_id = 'gm-session' sd.aol_transport_pause = TransportPause(sd.CP, sd.aol_session_id) sd.aol_availability_pause = AvailabilityPause(sd.CP, sd.aol_session_id) original_receive = messaging.recv_one_or_none cleanup.enter_context(patch('openpilot.starpilot.aol.intent_companion.messaging.recv_one_or_none', side_effect=lambda target: queued.pop(0) if target is sock and queued else None if target is sock else original_receive(target))) current = {'now': NOW, 'controls': True, 'events': Events()} original_update = sd.sm.update_msgs def receive_native(timestamp, messages): controlled, _ = peers(card.CP, card.slc_producer_session, current['now'], controls=current['controls'], native=native) originals = [m for m in messages if m.which() not in ('aolSafetyWire', 'pandaStates')] original_update(timestamp, originals + [controlled.messages[n] for n in ('aolSafetyWire', 'pandaStates')]) cleanup.enter_context(patch.object(sd.sm, 'update_msgs', side_effect=receive_native)) def receive_events(cs): sd.events = current['events'] sd._update_steering_authority(cs) cleanup.enter_context(patch.object(sd, 'update_events', side_effect=receive_events)) real_new_message = messaging.new_message def publish_card(cs, now): published = {} def capture(service, message): published[service] = messaging.log_from_bytes(message if isinstance(message, bytes) else message.to_bytes()) if service == 'carState': raise PublishedCarState with patch('openpilot.selfdrive.car.card.messaging.new_message', side_effect=lambda service=None, size=None, **kwargs: real_new_message(service, size, **({'logMonoTime': now} | kwargs))), \ patch.object(card.pm, 'send', side_effect=capture): with pytest.raises(PublishedCarState): card.state_publish(cs, None) assert decode_intent(published['aolIntentWire'].aolIntentWire).validUntilMonoTime == now + 200_000_000 queued.append(published['aolIntentWire']) return published['aolIntentWire'].as_builder() # First establish the baseline from actual SD.step and its real publisher. healthy = parse(card, NOW) initial = publish_card(healthy, NOW) axis, _, _ = step(0, observed_state=healthy, intent_message=initial) assert str(axis.faultReason) == 'none' and axis.lateralActive and axis.longitudinalActive card.aol_transport_feedback = TransportPauseFeedback(card.CP) def receive_card(now, physical, unlatched, counter): controlled, _ = peers(card.CP, card.slc_producer_session, now, controls=current['controls'], native=native) frames = [controlled.messages[n] for n in ('aolSafetyWire', 'pandaStates')] frames += [sd.pm.messages[n] for n in ('aolAxisState', 'onroadEvents')] card.sm.update_msgs(now / 1e9, frames) packets = parse(card, now, physical, counter=counter, belt_unlatched=unlatched, packets_only=True) with patch('openpilot.selfdrive.car.card.messaging.drain_sock_raw', return_value=[b'controlled-can-transport']), \ patch('openpilot.selfdrive.car.card.can_capnp_to_list', return_value=[(now, packets)]), \ patch('openpilot.selfdrive.car.card.time.monotonic_ns', return_value=now), \ patch('openpilot.selfdrive.car.card.time.clock_gettime_ns', return_value=now), \ patch('openpilot.selfdrive.car.card.REPLAY', False), patch.object(card.sm, 'update'): cs, _ = card.state_update() assert cs.canValid and cs.seatbeltUnlatched == unlatched return cs receive_card(NOW + 1_000, None, False, 1) assert card.aol_transport_feedback.session == 'gm-session' and card.aol_transport_feedback.healthy_inventory assert card.aol_transport_feedback.availability_epoch == companion.integrity_epoch for tick in (1, 2, 3): now = NOW + tick * 10_000_000 current['now'], current['controls'] = now, False cs = receive_card(now, gesture if tick < 3 else None, tick < 3, (tick + 1) % 4) if tick == 2 and fault == 'immediate': assert card.aol_transport_feedback.failed and not card.aol_card_intent.allowed_latch return assert not card.aol_transport_feedback.failed and card.aol_card_intent.allowed_latch assert card.aol_transport_feedback.gm_withdrawal_ns == NOW + 10_000_000 events = Events() if cs.seatbeltUnlatched: events.add(EventName.seatbeltNotLatched) if cs.brakePressed or cs.regenBraking: events.add(EventName.pedalPressed) elif tick == 1 and gesture == 'cancel': events.add(EventName.buttonCancel) if tick == 1 and fault is not None: events.add(EventName.overheat if fault == 'other_soft' else EventName.controlsMismatch) current['events'] = events produced = publish_card(cs, now) axis, _, command = step(tick, observed_state=cs, intent_message=produced) assert queued == [] and sd.aol_last_intent.producerSessionId == card.slc_producer_session assert axis.intentIntegrityEpoch == companion.integrity_epoch assert axis.intentProducerSessionId == card.slc_producer_session assert axis.sourceCarStateMonoTime == axis.observedMonoTime == now assert axis.validUntilMonoTime == now + 30_000_000 assert native.observedMonoTime == NOW and native.validUntilMonoTime == NOW + 200_000_000 assert native.requestedLateral and native.lateralAllowed and native.requestedLongitudinal and native.longitudinalAllowed assert not axis.longitudinalActive and not command.longActive if fault == 'immediate': assert str(axis.faultReason) == 'critical' elif fault == 'other_soft' and tick == 1: assert str(axis.faultReason) == 'availabilityPause' assert not axis.lateralActive and not command.latActive else: assert str(axis.faultReason) == 'none' and axis.lateralActive assert not sd.enabled # Pedal/Cancel still disables ordinary LONG; rebuckling cannot resume it. @pytest.mark.parametrize('fault', [None, 'owner', 'profile', 'stale_source', 'future_source', 'future_observation', 'intent_expired', 'excessive_lease', 'unarmed', 'unqualified', 'native_missing', 'native_lateral', 'native_fault', 'native_session', 'native_expired', 'belt_mismatch', 'event_user', 'event_no_entry']) def test_belt_soft_classification_keeps_current_owner_intent_native_and_source_guards(fault): from openpilot.selfdrive.selfdrived.events import Events, EventName from openpilot.starpilot.aol.wire import IntentState from openpilot.starpilot.car.gm.aol import seatbelt_lateral_soft_event_allowed with OpenpilotPrefix(): card, _, native = baseline() now = NOW + 10_000_000 cs = parse(card, now, 'brake', counter=1, belt_unlatched=True) intent = IntentState(card.slc_producer_session, 1, now, now, now + 200_000_000, True, False, False, True, True) source = now events = Events() events.add(EventName.seatbeltNotLatched) event = events.to_msg()[0] if fault == 'owner': card.CP.alternativeExperience = 0 elif fault == 'profile': card.CP.carFingerprint = CAR.CHEVROLET_VOLT elif fault == 'stale_source': now += 30_000_001 elif fault == 'future_source': source += 1 elif fault == 'future_observation': intent = replace(intent, observedMonoTime=now + 1) elif fault == 'intent_expired': intent = replace(intent, validUntilMonoTime=now - 1) elif fault == 'excessive_lease': intent = replace(intent, validUntilMonoTime=intent.observedMonoTime + 200_000_001) elif fault == 'unarmed': intent = replace(intent, allowedLatch=False) elif fault == 'unqualified': intent = replace(intent, settingsQualified=False) elif fault == 'native_missing': native = None elif fault == 'native_lateral': native = replace(native, lateralAllowed=False) elif fault == 'native_fault': native = replace(native, pandaInventory=(replace(native.pandaInventory[0], faults=1),)) elif fault == 'native_session': native = replace(native, axisSessionId='other') elif fault == 'native_expired': native = replace(native, validUntilMonoTime=now - 1) elif fault == 'belt_mismatch': cs.seatbeltUnlatched = False elif fault == 'event_user': event.userDisable = True elif fault == 'event_no_entry': event.noEntry = False assert seatbelt_lateral_soft_event_allowed(card.CP, cs, intent, native, event, source_ns=source, now_ns=now, axis_session_id='gm-session') == (fault is None) @pytest.mark.parametrize('fault', [None, 'sparse_event', 'no_occurrence', 'same_cycle', 'event_mismatch', 'publisher_delay', 'future_event', 'axis_stamp', 'fault_session', 'stale_source', 'future_source', 'axis_expired', 'axis_excessive_lease', 'long_desired', 'long_active', 'event_user', 'event_immediate', 'event_no_entry', 'mixed_soft', 'producer', 'epoch', 'native_expired', 'occurrence_expired']) def test_rebuckled_belt_retention_requires_prior_paired_lat_only_proof(fault): from openpilot.selfdrive.selfdrived.events import Events, EventName with OpenpilotPrefix(): card, feedback, native = baseline() occurrence = NOW + 10_000_000 cs = parse(card, occurrence, 'brake', counter=1, belt_unlatched=True) sm, _ = peers(card.CP, card.slc_producer_session, occurrence, controls=False, native=native, sequence=2) assert not feedback.observe(sm, card.CP, cs, occurrence, latched=True, gm_withdrawal_source_ns=card.aol_gm_withdrawal_source(cs, occurrence), intent_producer_session=card.slc_producer_session) assert feedback.gm_withdrawal_ns == occurrence now, prior = NOW + 30_000_000, NOW + 20_000_000 cs = parse(card, now, counter=2) assert not cs.seatbeltUnlatched and not cs.brakePressed and not cs.regenBraking assert card.aol_gm_withdrawal_source(cs, now) == 0 sm, _ = peers(card.CP, card.slc_producer_session, prior, controls=False, native=native, sequence=3, long_active=False) axis = sm.messages['aolAxisState'].as_builder() events = sm.messages['onroadEvents'].as_builder() ordinary = Events() ordinary.add(EventName.seatbeltNotLatched) events.onroadEvents = ordinary.to_msg() axis.aolAxisState.faultEventMonoTime = events.logMonoTime if fault == 'sparse_event': events.logMonoTime = axis.aolAxisState.faultEventMonoTime = occurrence elif fault == 'no_occurrence': feedback.gm_withdrawal_ns = 0 elif fault == 'same_cycle': events.logMonoTime = now axis.logMonoTime = now axis.aolAxisState.sourceCarStateMonoTime = axis.aolAxisState.observedMonoTime = now axis.aolAxisState.validUntilMonoTime = now + 30_000_000 axis.aolAxisState.faultEventMonoTime = now elif fault == 'event_mismatch': axis.aolAxisState.faultEventMonoTime -= 1 elif fault == 'publisher_delay': axis.aolAxisState.sourceCarStateMonoTime = events.logMonoTime - 1_000 elif fault == 'future_event': events.logMonoTime = axis.aolAxisState.faultEventMonoTime = prior + 1 elif fault == 'axis_stamp': axis.logMonoTime -= 1 elif fault == 'fault_session': axis.aolAxisState.faultSessionId = 'other-session' elif fault == 'stale_source': axis.aolAxisState.sourceCarStateMonoTime = now - 30_000_001 elif fault == 'future_source': axis.aolAxisState.sourceCarStateMonoTime = now + 1 elif fault == 'axis_expired': axis.aolAxisState.validUntilMonoTime = now - 1 elif fault == 'axis_excessive_lease': axis.aolAxisState.validUntilMonoTime = prior + 30_000_001 elif fault == 'long_desired': axis.aolAxisState.desiredLongitudinal = True elif fault == 'long_active': axis.aolAxisState.longitudinalActive = True elif fault == 'event_user': events.onroadEvents[0].userDisable = True elif fault == 'event_immediate': events.onroadEvents[0].immediateDisable = True elif fault == 'event_no_entry': events.onroadEvents[0].noEntry = False elif fault == 'mixed_soft': ordinary.add(EventName.overheat) events.onroadEvents = ordinary.to_msg() elif fault == 'producer': axis.aolAxisState.intentProducerSessionId = 'other-producer' elif fault == 'epoch': axis.aolAxisState.intentIntegrityEpoch += 1 elif fault == 'native_expired': native = replace(native, validUntilMonoTime=now - 1) sm, _ = peers(card.CP, card.slc_producer_session, prior, controls=False, native=native, sequence=3, long_active=False) elif fault == 'occurrence_expired': now = occurrence + 200_000_001 prior = now - 10_000_000 cs = parse(card, now, counter=3) axis.logMonoTime = prior axis.aolAxisState.sourceCarStateMonoTime = axis.aolAxisState.observedMonoTime = prior axis.aolAxisState.validUntilMonoTime = prior + 30_000_000 events.logMonoTime = axis.aolAxisState.faultEventMonoTime = prior native = replace(native, observedMonoTime=prior, validUntilMonoTime=prior + 200_000_000) sm, _ = peers(card.CP, card.slc_producer_session, prior, controls=False, native=native, sequence=3, long_active=False) sm = Peers([sm.messages['aolSafetyWire'], sm.messages['pandaStates'], axis.as_reader(), events.as_reader()]) reset = feedback.observe(sm, card.CP, cs, now, latched=True, gm_withdrawal_source_ns=0, intent_producer_session=card.slc_producer_session) card.aol_card_intent.update(cs, fault_active=reset, now_ns=now) if fault in (None, 'sparse_event', 'publisher_delay'): assert not reset and not feedback.failed and card.aol_card_intent.allowed_latch assert feedback.gm_withdrawal_ns == occurrence assert native.observedMonoTime == NOW and native.validUntilMonoTime == NOW + 200_000_000 assert native.requestedLongitudinal and native.longitudinalAllowed intent = SimpleNamespace(allowedLatch=True, pauseLateral=False, pauseLongitudinal=False) decision = decide_axes(standard_lateral=False, standard_longitudinal=False, intent=intent, native=native, car_state=cs, initialized=True, model_ready=True, no_entry=False, immediate_disable=False, dm_lockout=False, pause_brake_mps=5.) assert decision.lateral_active and not decision.desired_longitudinal and not decision.longitudinal_active else: assert reset and feedback.failed and not card.aol_card_intent.allowed_latch def _sdgm_pedal_state(card, stamp, tick, gesture=None, manual=False): from opendbc.car.gm.tests.test_volt_camera_control import feed_camera packer = CANPacker(DBC[card.CP.carFingerprint][Bus.pt]) empty = SimpleNamespace(update=lambda _: None) _, frames = feed_camera(empty, packer, stamp, counter=tick % 4, speed=16.0, brake=gesture == 'brake', regen=gesture == 'paddle', active=True, eps_status=1) # Same recorded forward manual-mode report during Mary paddle withdrawal. frames = [f for f in frames if f[0] not in (0x1F5, 0xBE, 0xC9)] frames += [ packer.make_can_msg('ECMAcceleratorPos', 0, {'BrakePedalPos': 8 if gesture == 'brake' else 0}), packer.make_can_msg('ECMEngineStatus', 0, {'CruiseMainOn': 1, 'BrakePressed': int(gesture == 'brake')}), ] frames.append(packer.make_can_msg('ECMPRDNL2', 0, {'PRNDL2': 4, 'ManualMode': int(manual)})) if tick == 0: card.CI.update([(stamp - 1_000_000, frames)]) cs = card.CI.update([(stamp, frames)]) assert cs.canValid and not cs.canTimeout assert cs.regenBraking == (gesture == 'paddle') assert cs.brakePressed == (gesture == 'brake') assert str(cs.gearShifter) == ('manumatic' if manual else 'drive') return cs @pytest.mark.parametrize('c9', (False, True)) @pytest.mark.parametrize('gesture', ('brake', 'paddle')) @pytest.mark.parametrize('event_first', (False, True)) @pytest.mark.parametrize('health_allowed', (False, True)) def test_sdgm_alpha_pedal_does_not_erase_armed_lateral(c9, gesture, event_first, health_allowed): from opendbc.car.gm.tests.test_volt_sdgm_control import sdgm_params from openpilot.starpilot.aol.tests.test_gm import TestGmAol as GmFixture from openpilot.starpilot.aol.wire import IntentState with OpenpilotPrefix(): saved = Params() saved.put_bool('AlwaysOnLateral', True, block=True) card = GmFixture.card(sdgm_params(alpha=True, brake_c9=c9), saved) try: cp = card.CP feedback = TransportPauseFeedback(cp) cs = _sdgm_pedal_state(card, NOW, 0) card.aol_card_intent.update(cs, fault_active=False, now_ns=NOW) assert card.aol_card_intent.allowed_latch sm, prior = peers(cp, card.slc_producer_session, NOW) assert not feedback.observe(sm, cp, cs, NOW, latched=True, intent_producer_session=card.slc_producer_session) now = NOW + 10_000_000 cs = _sdgm_pedal_state(card, now, 1, gesture, True) native = replace( prior, observedMonoTime=now, validUntilMonoTime=now + 200_000_000, longitudinalAllowed=False, pandaInventory=(replace(prior.pandaInventory[0], controlsAllowed=health_allowed),), ) sm, _ = peers(cp, card.slc_producer_session, now, controls=health_allowed, native=native, sequence=2, long_active=False) a = sm.messages['aolAxisState'].as_builder() a.aolAxisState.faultEventMonoTime = NOW if event_first else now # Event first: the last full desired request may precede the ordinary disengagement result. a.aolAxisState.desiredLongitudinal = event_first e = sm.messages['onroadEvents'].as_builder() e.onroadEvents = [{'name': 'pedalPressed', 'userDisable': True, 'noEntry': True}] sm = Peers([sm.messages['aolSafetyWire'], sm.messages['pandaStates'], a.as_reader(), e.as_reader()]) fault = feedback.observe(sm, cp, cs, now, latched=True, intent_producer_session=card.slc_producer_session) card.aol_card_intent.update(cs, fault_active=fault, now_ns=now) assert not fault and card.aol_card_intent.allowed_latch, (gesture, event_first, health_allowed, feedback.__dict__) intent = IntentState(card.slc_producer_session, 2, now, now, now + 200_000_000, True, False, False, True, True) d = decide_axes( standard_lateral=False, standard_longitudinal=False, intent=intent, native=native, car_state=cs, initialized=True, model_ready=True, no_entry=False, immediate_disable=False, dm_lockout=False, pause_brake_mps=0.0, ) assert d.lateral_active and not d.longitudinal_active # Fresh independent LAT native acknowledgment after release; LONG stays disabled. later = now + 10_000_000 cs = _sdgm_pedal_state(card, later, 2) native = replace(native, observedMonoTime=later, validUntilMonoTime=later + 200_000_000, requestedLongitudinal=False) sm, _ = peers(cp, card.slc_producer_session, later, controls=False, native=native, sequence=3, long_active=False) assert not feedback.observe(sm, cp, cs, later, latched=True, intent_producer_session=card.slc_producer_session) assert card.aol_card_intent.allowed_latch and not native.longitudinalAllowed finally: card.vehicle_startup.close() def test_factory_brake_clock_packets_are_scoped_and_follow_25hz_cycles(): for candidate in (CAR.CHEVROLET_BOLT_CC_2017, CAR.CHEVROLET_BOLT_CC_2018_2021, CAR.CHEVROLET_BOLT_CC_2022_2023, CAR.CHEVROLET_BOLT_ACC_2022_2023_PEDAL): for removed in (False, True): cp = params(candidate, pedal=True, camera=not removed, removed=removed) card = SimpleNamespace(CP=cp) for tick in range(9): now = NOW + tick * 10_000_000 packets = parse(card, now, counter=tick % 4, packets_only=True) clock = [row for row in packets if row[0] == 0x315] expected = candidate == CAR.CHEVROLET_BOLT_ACC_2022_2023_PEDAL and not removed and tick % 4 == 0 assert len(clock) == int(expected) if expected: assert clock[0][2] == 2 and len(clock[0][1]) == 5 assert clock[0][1][4] & 3 == (now // 40_000_000) % 4