diff --git a/openpilot/selfdrive/selfdrived/selfdrived.py b/openpilot/selfdrive/selfdrived/selfdrived.py index dcd9aaeb45..097a9455fa 100755 --- a/openpilot/selfdrive/selfdrived/selfdrived.py +++ b/openpilot/selfdrive/selfdrived/selfdrived.py @@ -283,9 +283,9 @@ class SelfdriveD: source_ns = self.aol_car_state_log_ns source_current = bool(getattr(self, 'conditional_car_state_valid', False) and CS.canValid and not CS.canTimeout and 0 < source_ns <= now_ns and now_ns - source_ns <= INTENT_MAX_AGE_NS) - from openpilot.starpilot.car.gm.aol import sdgm_alpha_lateral_owner + from openpilot.starpilot.car.gm.aol import steering_raw_projection_owner pair = companion.current(self.sm, source_ns=source_ns, now_ns=now_ns, drain=False, - allow_raw_projection=sdgm_alpha_lateral_owner(self.CP)) if drained else None + allow_raw_projection=steering_raw_projection_owner(self.CP)) if drained else None if not source_current: pair = None previous_axes = self.aol_axis_decision diff --git a/openpilot/starpilot/aol/tests/test_gm_ascm_receive_order.py b/openpilot/starpilot/aol/tests/test_gm_ascm_receive_order.py new file mode 100644 index 0000000000..0dbb9239a0 --- /dev/null +++ b/openpilot/starpilot/aol/tests/test_gm_ascm_receive_order.py @@ -0,0 +1,284 @@ +"""Card's exact raw companion can precede the cached SubMaster projection.""" +from contextlib import ExitStack +from unittest.mock import patch +import pytest +from opendbc.car.gm.tests.test_volt_sdgm_control import sdgm_params +from opendbc.car.gm.tests.test_ascm_intercept import params as ascm_params +from opendbc.car.gm.values import CAR, apply_gm_auto_hold +from opendbc.car.structs import car +from openpilot.cereal import log, messaging +from openpilot.common.prefix import OpenpilotPrefix +from openpilot.common.realtime import Ratekeeper +from openpilot.selfdrive.car.cruise import VCruiseHelper +from openpilot.selfdrive.car.tests.publication_fixture import initialize_publication_sources +from openpilot.starpilot.aol.tests import test_ioniq_availability as fixture +from openpilot.starpilot.car.gm.steering_companion import SteeringCompanion + + +@pytest.mark.parametrize('c9', [False, True]) +@pytest.mark.parametrize('radar', [False, True]) +@pytest.mark.parametrize('hold', [False, True]) +@pytest.mark.parametrize('full_op', [False, True]) +@pytest.mark.parametrize('condition', ['healthy', 'sm_fault', 'sm_invalid', 'sm_wrong_source', + 'sm_expired', 'sm_expired_fault', 'sm_expired_invalid', 'sm_future', 'missing_exact', 'raw_fault', + 'raw_invalid', 'raw_wrong_source', 'raw_future', 'raw_expired', 'source_invalid']) +def test_ascm_exact_card_raw_companion_survives_older_cached_projection(c9, radar, hold, full_op, condition, prepared=False): + from openpilot.selfdrive.car.card import Car + from openpilot.starpilot.aol.tests.test_gm_source_deadline import bind_steering_receipts + actual_recv, actual_optional = messaging.recv_one, messaging.recv_one_or_none + case = fixture.IoniqAvailabilityTests(methodName='runTest') + with ExitStack() as cleanup, OpenpilotPrefix(): + cleanup.callback(case.doCleanups) + messaging.reset_context() + cp = ascm_params(CAR.CHEVROLET_VOLT_ASCM, sascm=True, accelerator=not c9, radar=radar, alpha=True) + apply_gm_auto_hold(cp, hold) + if prepared: + from openpilot.starpilot.vehicle_preferences import VehicleStartupPreferences + VehicleStartupPreferences(volt_one_pedal=True, gm_auto_hold=True, volt_sng=True).prepare(cp) + assert cp.safetyConfigs[0].safetyParam == 0xD114 and cp.flags == 16 + sd, _, step = case._authority_callers(full_op, cp=cp) + from opendbc.car.gm.steering_authority import monitored_profile + assert monitored_profile(sd.CP) and sd.CP.openpilotLongitudinalControl and not sd.CP.pcmCruise + cp_before = sd.CP.to_dict() + step = bind_steering_receipts(sd, step, cleanup) + assert step(0)[0].lateralActive + pm = messaging.PubMaster(['carState', 'carOutput', 'starpilotCarState']) + sd.car_state_sock = messaging.sub_sock('carState', timeout=100) + sd.steering_companion = SteeringCompanion(messaging.sub_sock('starpilotCarState', conflate=False)) + latest = messaging.sub_sock('starpilotCarState', conflate=True, timeout=100) + card = Car.__new__(Car) + card.CP, card.pm = sd.CP, pm + initialize_publication_sources(card) + authority = card.CI.CS.steering_authority + card.sm = messaging.SubMaster(['carControl'], ignore_avg_freq=['carControl']) + neutral = messaging.new_message('carControl', valid=True, logMonoTime=10_000_000_000) + card.sm.update_msgs(10., [neutral.as_reader()]) + card.sm.frame = 1 + card.car_params_published = True + card.last_actuators_output = car.CarControl.Actuators.new_message() + card.slc_replay = card.curve_replay = card.aol_replay = False + card.can_rcv_cum_timeout_counter = 0 + card.rk = Ratekeeper(100., print_delay_threshold=None) + card.v_cruise_helper = VCruiseHelper(card.CP) + source = 10_010_000_000 + now = source + (31_000_000 if condition == 'raw_expired' else 4_000_000) + older = now - (33_000_000 if condition.startswith('sm_expired') else 10_000_000) + original_message = messaging.new_message + + def publish(stamp, *, fault=False, valid=True, enabled=True): + authority.enabled, authority.latched = enabled, fault + cs = fixture.car_state() + cs.canValid = valid + cs.cruiseState.available = True + with patch('openpilot.selfdrive.car.card.messaging.new_message', side_effect=lambda *a, **kw: + original_message(*a, **dict(kw, logMonoTime=stamp))): + card.state_publish(cs, None) + + publish(older, fault=condition in ('sm_fault','sm_expired_fault'), valid=condition not in ('sm_invalid','sm_expired_invalid')) + assert actual_recv(sd.car_state_sock).logMonoTime == older + cached = actual_recv(latest).as_builder() + assert cached.logMonoTime == older + if condition == 'sm_wrong_source': + cached.starpilotCarState.sourceCarStateMonoTime -= 1 + if condition == 'sm_future': + cached.logMonoTime = now + 1 + cached.starpilotCarState.sourceCarStateMonoTime = now + 1 + + def receive_first(sock): + publish(source, fault=condition == 'raw_fault', enabled=condition != 'missing_exact', valid=condition != 'source_invalid') + first = actual_recv(sock) + assert first.logMonoTime == source + return first + + def receive_cached(_timeout): + sd.sm.update_msgs(now / 1e9, [cached.as_reader()]) + + def receive_raw(sock): + if sock is sd.car_state_sock: + return None + message = actual_optional(sock) + if message is not None and message.logMonoTime == source and condition in ('raw_invalid', 'raw_wrong_source', 'raw_future'): + message = message.as_builder() + if condition == 'raw_invalid': + message.valid = False + elif condition == 'raw_wrong_source': + message.starpilotCarState.sourceCarStateMonoTime -= 1 + else: + message.logMonoTime = now + 1 + message.starpilotCarState.sourceCarStateMonoTime = now + 1 + return message.as_reader() + return message + + sd.sm.update.side_effect = receive_cached + with patch('openpilot.selfdrive.selfdrived.selfdrived.messaging.recv_one', side_effect=receive_first), \ + patch('openpilot.starpilot.car.gm.steering_companion.messaging.recv_one_or_none', side_effect=receive_raw), \ + patch('openpilot.selfdrive.selfdrived.selfdrived.time.monotonic_ns', return_value=now): + axis, _, cc = step(1) + assert sd.CP.to_dict() == cp_before + healthy = condition in ('healthy', 'sm_expired') + assert axis.sourceCarStateMonoTime == (0 if condition in ('raw_expired', 'source_invalid') else source) + assert bool(axis.lateralActive) == healthy and bool(cc.latActive) == healthy + assert bool(cc.longActive) == (healthy and full_op) + assert (log.OnroadEvent.EventName.controlsMismatch in sd.events.names) == (not healthy) + if healthy: + assert sd.sm.logMonoTime['starpilotCarState'] == older + assert sd.steering_companion.samples[source][0] == source + assert axis.validUntilMonoTime == axis.observedMonoTime + 30_000_000 + + + +@pytest.mark.parametrize('c9', [False, True]) +@pytest.mark.parametrize('radar', [False, True]) +@pytest.mark.parametrize('hold', [False, True]) +@pytest.mark.parametrize('alternative_experience', [32, 96]) +def test_raw_projection_owner_keeps_exact_nonpedal_scope(c9, radar, hold, alternative_experience): + from openpilot.starpilot.car.gm.aol import steering_raw_projection_owner + cp = ascm_params(CAR.CHEVROLET_VOLT_ASCM, sascm=True, accelerator=not c9, radar=radar, alpha=True) + cp.alternativeExperience = alternative_experience + apply_gm_auto_hold(cp, hold) + original = cp.to_dict() + assert steering_raw_projection_owner(cp) == (alternative_experience == 32) + for fault in ('unknown_ae', 'passive', 'dashcam', 'not_car', 'wrong_word', 'wrong_model', 'wrong_brand', 'wrong_identity', 'extra_panda'): + invalid = cp.as_reader().as_builder() + if fault == 'unknown_ae': + invalid.alternativeExperience = 0xFF + elif fault == 'passive': + invalid.passive = True + elif fault == 'dashcam': + invalid.dashcamOnly = True + elif fault == 'not_car': + invalid.notCar = True + elif fault == 'wrong_word': + invalid.safetyConfigs[0].safetyParam = 0xFFFF + elif fault == 'wrong_model': + invalid.safetyConfigs[0].safetyModel = 'silent' + elif fault == 'wrong_brand': + invalid.brand = 'toyota' + elif fault == 'wrong_identity': + invalid.carFingerprint = 'UNKNOWN_VOLT' + else: + invalid.safetyConfigs = [invalid.safetyConfigs[0].to_dict()] * 2 + assert not steering_raw_projection_owner(invalid), fault + ordinary = cp.as_reader().as_builder() + ordinary.alternativeExperience = 0 + assert steering_raw_projection_owner(ordinary) + assert cp.to_dict() == original + + +@pytest.mark.parametrize('topology', ['camera', 'gateway', 'ascm', 'sdgm']) +@pytest.mark.parametrize('alpha', [False, True]) +@pytest.mark.parametrize('be', [False, True]) +@pytest.mark.parametrize('release', [False, True]) +def test_raw_projection_owner_excludes_separate_pedal_profiles(topology, alpha, be, release): + from opendbc.car.gm.tests import test_volt_transitions as factory + from openpilot.starpilot.car.gm.aol import steering_raw_projection_owner + options = {'alpha': alpha, 'be': be, 'release': release} + if topology in ('ascm', 'sdgm'): + options['sascm'] = True + cp = getattr(factory, 'volt_' + topology + '_pedal_params')(**options) + cp.alternativeExperience = 32 + assert not steering_raw_projection_owner(cp) + + +@pytest.mark.parametrize('sdgm', [False, True]) +@pytest.mark.parametrize('alpha,sascm', [(False, True), (True, False)]) +def test_raw_projection_owner_excludes_stock_and_missing_sascm(sdgm, alpha, sascm): + from openpilot.starpilot.car.gm.aol import steering_raw_projection_owner + cp = sdgm_params(alpha=alpha, sascm=sascm) if sdgm else ascm_params(CAR.CHEVROLET_VOLT_ASCM, alpha=alpha, sascm=sascm) + cp.alternativeExperience = 32 + assert not steering_raw_projection_owner(cp) + + +@pytest.mark.parametrize('full_op', [False, True]) +@pytest.mark.parametrize('condition', ['healthy', 'sm_expired', 'sm_expired_fault', 'raw_fault']) +def test_recorded_sascm_one_pedal_hold_configuration(full_op, condition): + test_ascm_exact_card_raw_companion_survives_older_cached_projection(False, True, True, full_op, condition, prepared=True) + + +@pytest.mark.parametrize('sdgm', [False, True]) +@pytest.mark.parametrize('c9', [False, True]) +@pytest.mark.parametrize('initial_active', [False, True]) +@pytest.mark.parametrize('condition', ['sm_expired', 'sm_expired_fault', 'raw_fault', 'source_invalid', 'raw_expired']) +def test_normal_control_receipts_with_aol_disabled(sdgm, c9, initial_active, condition): + from openpilot.common.params import Params + from openpilot.selfdrive.car.card import Car + from openpilot.selfdrive.controls.controlsd import Controls + from openpilot.selfdrive.selfdrived.selfdrived import SelfdriveD + from openpilot.selfdrive.selfdrived.events import Events + from openpilot.selfdrive.selfdrived.state import StateMachine + from openpilot.starpilot.aol.runtime import AxisDecision + from openpilot.starpilot.lateral.tests.test_lane_runtime import feed + + with OpenpilotPrefix(): + cp = sdgm_params(alpha=True, brake_c9=c9) if sdgm else ascm_params( + CAR.CHEVROLET_VOLT_ASCM, sascm=True, accelerator=not c9, radar=True, alpha=True) + assert cp.alternativeExperience == 0 + saved = Params() + saved.put_bool('OpenpilotEnabledToggle', True, block=True) + saved.put_bool('AlwaysOnLateral', False, block=True) + saved.put('CarParams', cp.to_bytes(), block=True) + controls = Controls() + assert not controls.aol_replay + pm = messaging.PubMaster(['carState', 'carOutput', 'starpilotCarState']) + cs_sock = messaging.sub_sock('carState', timeout=100) + raw_sock = messaging.sub_sock('starpilotCarState', conflate=False) + cached_sock = messaging.sub_sock('starpilotCarState', conflate=True, timeout=100) + card = Car.__new__(Car) + card.CP, card.pm = cp, pm + initialize_publication_sources(card) + card.sm = messaging.SubMaster(['carControl'], ignore_avg_freq=['carControl']) + card.sm.update_msgs(10.0, [messaging.new_message('carControl', valid=True, logMonoTime=10_000_000_000).as_reader()]) + card.sm.frame = 1 + card.car_params_published = True + card.last_actuators_output = car.CarControl.Actuators.new_message() + card.slc_replay = card.curve_replay = card.aol_replay = False + card.can_rcv_cum_timeout_counter = 0 + card.rk = Ratekeeper(100.0, print_delay_threshold=None) + card.v_cruise_helper = VCruiseHelper(cp) + now = 10_100_000_000 + source = now - (31_000_000 if condition == 'raw_expired' else 4_000_000) + old = now - 33_000_000 + actual_message = messaging.new_message + + def publish(stamp, *, fault=False, valid=True): + cs = fixture.car_state() + cs.canValid = valid + card.CI.CS.steering_authority.latched = fault + with patch('openpilot.selfdrive.car.card.messaging.new_message', side_effect=lambda *a, **kw: actual_message(*a, **dict(kw, logMonoTime=stamp))): + card.state_publish(cs, None) + received = messaging.recv_one(cs_sock) + assert received.logMonoTime == stamp + return received + + publish(old, fault=condition == 'sm_expired_fault') + cached = messaging.recv_one(cached_sock) + sm = messaging.SubMaster(['starpilotCarState'], ignore_avg_freq=['starpilotCarState']) + sm.update_msgs(now / 1e9, [cached]) + latest = publish(source, fault=condition == 'raw_fault', valid=condition != 'source_invalid') + sd = SelfdriveD.__new__(SelfdriveD) + sd.CP, sd.sm = cp.as_reader(), sm + sd.active = initial_active + sd.aol_axis_decision = AxisDecision() + sd.aol_car_state_log_ns = source + sd.conditional_car_state_valid = True + sd.steering_authority_unavailable = False + sd.steering_companion = SteeringCompanion(raw_sock) + sd.events = Events() + with patch('openpilot.selfdrive.selfdrived.selfdrived.REPLAY', False), \ + patch('openpilot.selfdrive.selfdrived.selfdrived.time.monotonic_ns', return_value=now): + sd._update_steering_authority(latest.carState) + healthy = condition == 'sm_expired' + machine = StateMachine() + machine.state = log.SelfdriveState.OpenpilotState.enabled if initial_active else log.SelfdriveState.OpenpilotState.disabled + enabled, active = machine.update(sd.events) + for tick in range(10): + feed(controls, now - (10 - tick) * 10_000_000, tick, enabled=False, active=False) + feed(controls, now, 10, enabled=enabled, active=active) + controls.sm.update_msgs(now / 1e9, [latest]) + with patch('openpilot.selfdrive.controls.controlsd.time.monotonic_ns', return_value=now): + command, _ = controls.state_control() + assert command.latActive == command.longActive == (healthy and initial_active) + assert active == enabled == (healthy and initial_active) + assert not controls.CP.alternativeExperience and not saved.get_bool('AlwaysOnLateral') + if initial_active: + assert (log.OnroadEvent.EventName.controlsMismatch in sd.events.names) == (not healthy) diff --git a/openpilot/starpilot/aol/tests/test_gm_lag_episode_recovery.py b/openpilot/starpilot/aol/tests/test_gm_lag_episode_recovery.py index 51869a545e..743dac2187 100644 --- a/openpilot/starpilot/aol/tests/test_gm_lag_episode_recovery.py +++ b/openpilot/starpilot/aol/tests/test_gm_lag_episode_recovery.py @@ -25,7 +25,10 @@ def test_long_isolated_lag_recovery_keeps_only_existing_intent(c9, alpha): axis, _, command = drive(tick, cs, allowed=tick == 2, native_request_lat=tick == 2) assert not axis.lateralActive and not axis.longitudinalActive assert not command.latActive and not command.longActive - assert str(axis.faultReason) == 'critical' + assert str(axis.faultReason) == 'availabilityPause' + assert axis.faultSessionId == sd.aol_session_id and not axis.nativeAcknowledged + assert axis.sourceCarStateMonoTime == BASE + tick * 10_000_000 + assert sd.aol_published_fault_proof is None assert not axis.desiredLateral and not axis.desiredLongitudinal assert axis.validUntilMonoTime == axis.observedMonoTime + 30_000_000 if event_ns is None: @@ -64,7 +67,9 @@ def test_stock_pcm_off_lag_never_exempts_other_rejection(bad): assert card.aol_card_intent.allowed_latch and not command.latActive cs = parse(3, 'cancel' if bad == 'cancel' else 'none', main=bad != 'main_off') cs.doorOpen = bad == 'door' - options = {'critical_event': log.OnroadEvent.EventName.processNotRunning} if bad == 'other_fault' else {} + # processNotRunning is a SOFT availability pause. This negative case must + # inject the actual immediate fault whose disarm behavior it protects. + options = {'critical_event': log.OnroadEvent.EventName.controlsMismatch} if bad == 'other_fault' else {} with mock.patch.object(sd.rk.avg_dt, 'get_average', return_value=.02): axis, _, command = drive(3, cs, allowed=False, native_request_lat=False, native_valid=bad != 'native_invalid', **options) if bad == 'epoch': @@ -73,4 +78,10 @@ def test_stock_pcm_off_lag_never_exempts_other_rejection(bad): sd.pm.messages['aolAxisState'] = wire.as_reader() deliver_card(card, sd, controls, cs, 3) assert not command.latActive and not command.longActive - assert not card.aol_card_intent.allowed_latch + if bad == 'cancel': + # GM's ordinary Cancel withdraws LONG; MAIN still owns lateral intent. + assert card.aol_card_intent.allowed_latch and not card.aol_card_intent._main_cycle_required + assert any(str(event.type) == 'cancel' and event.pressed for event in cs.buttonEvents) + assert not axis.longitudinalActive and not sd.events.contains('enable') + else: + assert not card.aol_card_intent.allowed_latch diff --git a/openpilot/starpilot/aol/tests/test_gm_route18e_recovery.py b/openpilot/starpilot/aol/tests/test_gm_route18e_recovery.py index d551d8177f..39f0b5cae6 100644 --- a/openpilot/starpilot/aol/tests/test_gm_route18e_recovery.py +++ b/openpilot/starpilot/aol/tests/test_gm_route18e_recovery.py @@ -58,7 +58,8 @@ def test_cold_main_waits_for_host_readiness_but_keeps_real_faults(event): original_events = sd.update_events axis, state, command = drive(0, observed_state=cs, native_request_lat=False, native_request_long=False, allowed=False, long_allowed=False, critical_event=event) - temporary = event in (EventName.commIssue, EventName.commIssueAvgFreq, EventName.locationdTemporaryError) + temporary = event != EventName.controlsMismatch + assert any(e.name == event and (e.softDisable if temporary else e.immediateDisable) for e in sd.events.to_msg()) assert str(axis.faultReason) == ('availabilityPause' if temporary else 'critical') assert not command.latActive and not command.longActive and not state.enabled deliver_card(card, sd, controls, cs, 0) diff --git a/openpilot/starpilot/aol/tests/test_stock_lateral_lag_gap_sequence.py b/openpilot/starpilot/aol/tests/test_stock_lateral_lag_gap_sequence.py index 56053314ff..1b745a05ad 100644 --- a/openpilot/starpilot/aol/tests/test_stock_lateral_lag_gap_sequence.py +++ b/openpilot/starpilot/aol/tests/test_stock_lateral_lag_gap_sequence.py @@ -34,11 +34,14 @@ def test_stock_lat_intent_fresh_lag_then_source_gap_then_recovery(family): with mock.patch.object(sd.rk.avg_dt, 'get_average', return_value=0.02): lag, state, command = drive(3, state_stamp=first, **settings('lateral_only', first, 4)) assert not state.enabled and not state.active and not command.latActive and not command.longActive - assert str(lag.faultReason) == 'critical' and lag.sourceCarStateMonoTime == first + assert str(lag.faultReason) == 'availabilityPause' and lag.sourceCarStateMonoTime == first assert not lag.lateralActive and not lag.longitudinalActive and not lag.nativeAcknowledged - witness = sd.aol_published_fault_proof - assert witness is not None and len(witness) == 9 and not any(witness[4:]) - assert witness[0] == sd.aol_session_id and witness[1] == 'critical' and witness[2] == lag.faultEventMonoTime + assert not lag.desiredLateral and not lag.desiredLongitudinal + assert lag.faultSessionId == sd.aol_session_id and lag.faultEventMonoTime == first + assert lag.validUntilMonoTime == lag.observedMonoTime + 30_000_000 + # SOFT availability is not a fabricated critical/user-disable witness. + assert sd.aol_published_fault_proof is None + assert sd.aol_published_stock_lateral_proof is None users = [e for e in sd.events.to_msg() if e.userDisable] expected = log.OnroadEvent.EventName.cruiseDisabled if family == 'honda' else log.OnroadEvent.EventName.pcmDisable assert users and all(e.name == expected and not e.softDisable and not e.immediateDisable for e in users) diff --git a/openpilot/starpilot/aol/tests/test_temporary_lag_intent.py b/openpilot/starpilot/aol/tests/test_temporary_lag_intent.py index 5207d64dd2..bd28a2f1f7 100644 --- a/openpilot/starpilot/aol/tests/test_temporary_lag_intent.py +++ b/openpilot/starpilot/aol/tests/test_temporary_lag_intent.py @@ -26,7 +26,8 @@ def test_actual_isolated_lag_keeps_only_existing_card_intent(full_op, gas, order axis, state, command = drive(2, gas=gas, native_request_long=full_op) # Already engaged ordinary axes follow the normal SOFT countdown with # fresh inputs/native grants; independent AOL still withdraws commands. - assert str(axis.faultReason) == ('none' if full_op else 'critical') + assert str(axis.faultReason) == ('none' if full_op else 'availabilityPause') + assert sd.aol_published_fault_proof is None assert axis.lateralActive == axis.longitudinalActive == full_op assert command.latActive == command.longActive == full_op assert axis.desiredLateral == axis.desiredLongitudinal == axis.nativeAcknowledged == full_op @@ -41,13 +42,16 @@ def test_actual_isolated_lag_keeps_only_existing_card_intent(full_op, gas, order assert proof.aolAxisState.faultEventMonoTime == event.logMonoTime deliveries = [{'axis': proof}, {'event': event}] if order == 'axis_first' else \ [{'event': event}, {'axis': proof}] if order == 'event_first' else [{'axis': proof, 'event': event}] - # Independent critical joins retain only intent under the fixed join. + # Independent warning joins retain only intent under the fixed join. # Ordinary fresh proofs retain their actual current acknowledged axes. for index, delivery in enumerate(deliveries): latched = receive(10_020_000_000 + index * 100_000, **delivery) if not full_op and order == 'axis_first' and index == 0: assert feedback.pending_event == proof.aolAxisState.faultEventMonoTime assert feedback.awaiting + anchor = feedback.pending_event + assert receive(10_020_050_000, axis=proof) + assert feedback.pending_event == anchor and proof.aolAxisState.validUntilMonoTime == 10_050_000_000 assert latched and not feedback.failed observed = (feedback.lateral_baseline_ns, feedback.lateral_source_ns, feedback.lateral_native_ns, feedback.healthy_feedback_ns, feedback.healthy_native_ns) @@ -122,8 +126,14 @@ def test_temporary_lag_never_masks_real_fault_or_invalid_proof(fault): card.aol_card_intent.settings = replace(card.aol_card_intent.settings, enabled=False) now = 10_020_000_000 if fault != 'axis_deadline' else 10_050_000_001 assert not command.latActive and not command.longActive - assert not receive(now, axis=proof.as_reader(), event=event.as_reader(), state=state) - assert not card.aol_card_intent.allowed_latch + retained = receive(now, axis=proof.as_reader(), event=event.as_reader(), state=state) + soft_only = fault in ('process_failure', 'overheat') + assert retained == soft_only and card.aol_card_intent.allowed_latch == soft_only + if soft_only: + assert str(proof.aolAxisState.faultReason) == 'availabilityPause' + assert not axis.lateralActive and not axis.longitudinalActive and not axis.nativeAcknowledged + assert not sd.aol_authority_lost and sd.aol_published_fault_proof is None + assert card.aol_transport_feedback.ordinary_warning_qualified def test_persistent_lag_keeps_latch_but_ordinary_countdown_still_disables(): @@ -144,7 +154,8 @@ def test_persistent_lag_keeps_latch_but_ordinary_countdown_still_disables(): ordinary_active = tick < 302 assert state.enabled == state.active == ordinary_active assert sd.state_machine.soft_disable_timer == max(0, 302 - tick) - assert str(axis.faultReason) == ('none' if ordinary_active else 'critical') + assert str(axis.faultReason) == ('none' if ordinary_active else 'availabilityPause') + assert sd.aol_published_fault_proof is None assert axis.desiredLateral == axis.desiredLongitudinal == axis.nativeAcknowledged == ordinary_active assert axis.lateralActive == command.latActive == ordinary_active # LONG never activates: this fixture deliberately keeps native's @@ -194,7 +205,8 @@ def test_persistent_lag_does_not_mask_native_controls_mismatch(): assert not card.aol_card_intent.allowed_latch -@pytest.mark.parametrize('failure', ['missing_event', 'hard_event', 'wrong_event', 'session', 'inventory']) +@pytest.mark.parametrize('failure', ['missing_event', 'hard_event', 'wrong_event', 'session', 'inventory', + 'producer', 'epoch', 'source_stale', 'new_occurrence']) def test_axis_first_critical_join_never_masks_missing_or_hard_occurrence(failure): case = fixture.IoniqAvailabilityTests(methodName='runTest') with ExitStack() as cleanup: @@ -204,6 +216,8 @@ def test_axis_first_critical_join_never_masks_missing_or_hard_occurrence(failure with mock.patch.object(type(sd.rk), 'lagging', new_callable=mock.PropertyMock, return_value=True): axis, _, command = drive(2, native_request_long=False) proof = sd.pm.messages['aolAxisState'].as_builder() + assert str(proof.aolAxisState.faultReason) == 'availabilityPause' + assert sd.aol_published_fault_proof is None assert receive(10_020_000_000, axis=proof.as_reader()) anchor = card.aol_transport_feedback.pending_event assert anchor == proof.aolAxisState.faultEventMonoTime @@ -224,6 +238,16 @@ def test_axis_first_critical_join_never_masks_missing_or_hard_occurrence(failure elif failure == 'session': proof.aolAxisState.sessionId = proof.aolAxisState.faultSessionId = 'other-session' delivery = {'axis': proof.as_reader(), 'event': event.as_reader()} + elif failure in ('producer', 'epoch', 'source_stale', 'new_occurrence'): + if failure == 'producer': + proof.aolAxisState.intentProducerSessionId = 'other-card' + elif failure == 'epoch': + proof.aolAxisState.intentIntegrityEpoch += 1 + elif failure == 'source_stale': + proof.aolAxisState.sourceCarStateMonoTime -= 30_000_001 + else: + proof.aolAxisState.faultEventMonoTime += 1 + delivery = {'axis': proof.as_reader(), 'event': event.as_reader()} else: from dataclasses import replace from openpilot.cereal import messaging diff --git a/openpilot/starpilot/aol/tests/test_volt_lag_pedal_intent.py b/openpilot/starpilot/aol/tests/test_volt_lag_pedal_intent.py index a97b247edb..ccde2f25dd 100644 --- a/openpilot/starpilot/aol/tests/test_volt_lag_pedal_intent.py +++ b/openpilot/starpilot/aol/tests/test_volt_lag_pedal_intent.py @@ -100,7 +100,10 @@ def test_parsed_pedal_lag_keeps_intent_without_renewing_authority(pedal, order, cs = parse(2, pedal) with mock.patch.object(sd.rk.avg_dt, 'get_average', return_value=.02): axis, _, command = drive(2, cs) - assert not command.latActive and not command.longActive and str(axis.faultReason) == 'critical' + assert not command.latActive and not command.longActive and str(axis.faultReason) == 'availabilityPause' + assert not axis.lateralActive and not axis.longitudinalActive and not axis.nativeAcknowledged + assert axis.faultSessionId == sd.aol_session_id and axis.faultEventMonoTime == BASE + 20_000_000 + assert sd.aol_published_fault_proof is None assert not axis.desiredLateral and not axis.desiredLongitudinal proof, event = sd.pm.messages['aolAxisState'], sd.pm.messages['onroadEvents'] if order != 'together': @@ -183,8 +186,19 @@ def test_pedal_overlap_never_bypasses_other_rejection(bad): setattr(cs, {'can_invalid': 'canValid', 'permanent_eps': 'steerFaultPermanent', 'door': 'doorOpen', 'belt': 'seatbeltUnlatched'}[bad], bad != 'can_invalid') sd.pm.messages['aolAxisState'] = proof.as_reader() + baseline = (card.aol_transport_feedback.lateral_baseline_ns, card.aol_transport_feedback.lateral_source_ns, + card.aol_transport_feedback.lateral_native_ns) deliver_card(card, sd, controls, cs, 2) - assert not card.aol_card_intent.allowed_latch + intent_only = bad in ('commIssue', 'processNotRunning', 'native_long', 'belt') + assert card.aol_card_intent.allowed_latch == intent_only + if intent_only: + # Qualified SOFT withdrawal retains intent, never an output permission. + assert str(proof.aolAxisState.faultReason) == 'availabilityPause' + assert not proof.aolAxisState.lateralActive and not proof.aolAxisState.longitudinalActive + assert not proof.aolAxisState.nativeAcknowledged + assert not card.sm['carControl'].latActive and not card.sm['carControl'].longActive + assert baseline == (card.aol_transport_feedback.lateral_baseline_ns, card.aol_transport_feedback.lateral_source_ns, + card.aol_transport_feedback.lateral_native_ns) @pytest.mark.parametrize('pedal', ['brake', 'regen']) diff --git a/openpilot/starpilot/aol/transport_pause.py b/openpilot/starpilot/aol/transport_pause.py index 86acfd62dd..73032b28c0 100644 --- a/openpilot/starpilot/aol/transport_pause.py +++ b/openpilot/starpilot/aol/transport_pause.py @@ -301,6 +301,7 @@ class TransportPauseFeedback: self.source_retention_started_ns = 0 self.ordinary_warning_retained = False self.ordinary_warning_qualified = False + self.ordinary_warning_pending_baseline = None self.healthy_feedback_ns = 0 self.healthy_native_ns = 0 self.healthy_inventory = None @@ -366,7 +367,9 @@ class TransportPauseFeedback: from openpilot.starpilot.nostalgia import physical_cancel from opendbc.car import structs - if (not latched or self.failed or self.paused or self.pending_event or self.retention_started_ns or + pending_baseline = self.ordinary_warning_pending_baseline + pending = bool(pending_baseline is not None and self.pending_event) + if (not latched or self.failed or self.paused or self.pending_event and not pending or self.retention_started_ns or not self.session or not self.lateral_baseline_ns or not self.lateral_inventory or not policy_for(cp).intent_supported or policy_for(cp).cruise_main_required and not cs.cruiseState.available or @@ -389,7 +392,8 @@ class TransportPauseFeedback: return False warning = any(event.softDisable for event in events) recovering = bool(getattr(self, 'ordinary_warning_retained', False) and not warning) - if not warning and not recovering: + if pending and (axis.faultEventMonoTime != self.pending_event or + not 0 < self.pending_event <= now_ns or now_ns - self.pending_event > AXIS_MAX_AGE_NS): return False stock_user = stock_lateral_user_event(cp) # Physical withdrawals still go through the family button/pedal owner. @@ -400,7 +404,19 @@ class TransportPauseFeedback: cp.brand == 'gm' and 0 < self.gm_withdrawal_ns == event_ns <= now_ns and now_ns - event_ns <= SAFETY_MAX_AGE_NS)) for event in events): return False - prior = self.signature + prior = pending_baseline if pending else self.signature + # The typed SOFT withdrawal may precede its event socket. Hold only intent + # under the fixed original 30ms join until that exact event arrives. + # No duplicate or newer withdrawal can move the pending occurrence. + axis_first = bool(str(axis.faultReason) == 'availabilityPause' and + not (axis.lateralActive or axis.longitudinalActive or axis.desiredLateral or + axis.desiredLongitudinal or axis.nativeAcknowledged) and + prior is not None and prior[5] == 'none' and prior[9] and prior[11] and prior[13] and + prior[14:16] == (producer, self.availability_epoch) and + 0 < prior[8] <= now_ns and (pending or now_ns <= prior[4] and now_ns - prior[8] <= AXIS_MAX_AGE_NS) and + event_ns < axis.faultEventMonoTime <= stamp and now_ns - axis.faultEventMonoTime <= AXIS_MAX_AGE_NS) + if not warning and not recovering and not axis_first: + return False active_join = bool(warning and prior is not None and str(axis.faultReason) == 'none' and axis.lateralActive and axis.desiredLateral and axis.nativeAcknowledged and prior[9] and prior[11] and prior[13] and prior[14:16] == (producer, self.availability_epoch) and @@ -410,16 +426,27 @@ class TransportPauseFeedback: not axis.desiredLongitudinal and (warning and str(axis.faultReason) == 'availabilityPause' and axis.faultEventMonoTime == event_ns <= stamp or recovering and str(axis.faultReason) == 'none')) - if not active_join and not withdrawn: + if not active_join and not withdrawn and not axis_first: + return False + if pending and not axis_first and not (withdrawn and warning and event_ns == self.pending_event): return False native = _configured_transport_native(sm, cp, self.session, now_ns, - allow_restricted_lateral=withdrawn or bool(cs.steerFaultTemporary), - require_longitudinal=not withdrawn) + allow_restricted_lateral=withdrawn or axis_first or bool(cs.steerFaultTemporary), + require_longitudinal=not (withdrawn or axis_first)) if (native is None or not native.pandaInventory or (native.pandaSerial, tuple(slot.hardwareSerial for slot in native.pandaInventory)) != self.lateral_inventory or native.observedMonoTime < self.lateral_native_ns or active_join and not (native.requestedLateral and native.lateralAllowed)): return False + if axis_first: + if not pending: + self.pending_event = int(axis.faultEventMonoTime) + self.ordinary_warning_pending_baseline = prior + self.awaiting = True + return True + if pending: + self.pending_event = 0 + self.ordinary_warning_pending_baseline = None self.ordinary_warning_retained = True return True @@ -879,7 +906,8 @@ class TransportPauseFeedback: # Unmatched joins retain pending_event; every other critical path fails # and clears the LAT-only baseline before changing the signature. self.lateral_only_baseline and not self.failed and not self.pending_event and - previous[5] == 'critical' and previous[1] == self.sequence and not any(previous[9:14]) and + (previous[5] == 'critical' or previous[5] == 'availabilityPause' and self.ordinary_warning_retained) and + previous[1] == self.sequence and not any(previous[9:14]) and 0 < previous[6] <= previous[2] <= axis.faultEventMonoTime and 0 < self.lateral_source_ns <= previous[8] <= previous[2] and previous[2] - previous[8] <= AXIS_MAX_AGE_NS and @@ -1006,7 +1034,8 @@ class TransportPauseFeedback: output_missing = not (sm.seen['aolAxisState'] and sm.valid['aolAxisState'] and sm.alive['aolAxisState']) # The same exclusive matched-lag signature invariant as typed retention: # unknown critical joins keep pending_event; other critical paths fail. - matched_lag = bool(str(axis.faultReason) == 'critical' and signature == self.signature and + matched_lag = bool((str(axis.faultReason) == 'critical' or + str(axis.faultReason) == 'availabilityPause' and self.ordinary_warning_retained) and signature == self.signature and self.lateral_only_baseline and not self.failed and not self.pending_event and signature[0] == self.session and signature[7] == self.session and signature[1] == self.sequence and signature[14] == producer == self.availability_producer and signature[15] == self.availability_epoch and @@ -1167,6 +1196,7 @@ class TransportPauseFeedback: axis = sm['aolAxisState'] if not latched or cp_identity(cp) != self.cp or self.session and str(axis.sessionId) != self.session: self.ordinary_warning_retained = False + self.ordinary_warning_pending_baseline = None self.temporary_eps_event_ns = 0 self.ccnc_main_off_source_ns = 0 self.cold_park_waiting = False diff --git a/openpilot/starpilot/car/gm/aol.py b/openpilot/starpilot/car/gm/aol.py index 1341a59de6..8b45cdf1a7 100644 --- a/openpilot/starpilot/car/gm/aol.py +++ b/openpilot/starpilot/car/gm/aol.py @@ -48,6 +48,12 @@ def sdgm_alpha_lateral_owner(cp) -> bool: return bool(qualified_gm(cp) and aol_enabled(cp) and is_volt_sdgm_accepted_envelope(cp)) +def steering_raw_projection_owner(cp) -> bool: + """Qualified non-pedal Volt owners consume their exact raw steering receipt.""" + from opendbc.car.gm.steering_authority import monitored_profile + return bool(qualified_gm(cp) and monitored_profile(cp)) + + def sdgm_alpha_pedal_withdrawal(cp, cs) -> bool: """Physical LONG withdrawal for the 2019 SASCM Alpha configuration.""" from opendbc.car.gm.values import is_volt_sdgm_accepted_envelope @@ -192,7 +198,7 @@ def cold_main_readiness(cp, cs, native, events, *, recovering=False): """A MAIN request may wait for host startup without acquiring either axis.""" from openpilot.cereal import log names = (log.OnroadEvent.EventName.commIssue, log.OnroadEvent.EventName.commIssueAvgFreq, - log.OnroadEvent.EventName.locationdTemporaryError) + log.OnroadEvent.EventName.locationdTemporaryError, log.OnroadEvent.EventName.processNotRunning) faults = [event for event in events if event.softDisable or event.immediateDisable] host_wait = not faults if recovering else bool(faults and all(event.softDisable and not event.immediateDisable and event.name in names for event in faults)) diff --git a/prebuilt.json b/prebuilt.json index 61011256d4..57ce7bc3ab 100644 --- a/prebuilt.json +++ b/prebuilt.json @@ -1 +1 @@ -{"protected_inventory_sha256": "4e1b9257513a6301cec583c3e21e83ebbdd48f727008bcf0294fc8efdeab75a3", "schema_version": 1} +{"protected_inventory_sha256": "0eb5221a586bbcd3f7934af7c77f79e13e19de328900878acec417e5c55a54dd", "schema_version": 1} diff --git a/tools/ci/run_vehicle_tests.py b/tools/ci/run_vehicle_tests.py index b35a65d894..f8bca1ca27 100644 --- a/tools/ci/run_vehicle_tests.py +++ b/tools/ci/run_vehicle_tests.py @@ -50,6 +50,7 @@ AOL_LIFECYCLE_TARGETS = ( "openpilot/starpilot/aol/tests/test_native_inventory_control_cadence.py", "openpilot/starpilot/aol/tests/test_gm.py", "openpilot/starpilot/aol/tests/test_gm_steering_receive_order.py", + "openpilot/starpilot/aol/tests/test_gm_ascm_receive_order.py", "openpilot/starpilot/aol/tests/test_gm_main_response.py", "openpilot/starpilot/aol/tests/test_gm_held_native.py", 'openpilot/starpilot/longitudinal/tests/test_volt_mode_transition.py',