Preserve exact ASCM Volt steering receipts across receive ordering

This commit is contained in:
firestar5683
2026-10-23 00:14:03 -05:00
parent b2296b4844
commit c8d2a8aaf0
11 changed files with 402 additions and 28 deletions
+2 -2
View File
@@ -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
@@ -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)
@@ -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
@@ -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)
@@ -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)
@@ -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
@@ -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'])
+38 -8
View File
@@ -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
+7 -1
View File
@@ -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))
+1 -1
View File
@@ -1 +1 @@
{"protected_inventory_sha256": "4e1b9257513a6301cec583c3e21e83ebbdd48f727008bcf0294fc8efdeab75a3", "schema_version": 1}
{"protected_inventory_sha256": "0eb5221a586bbcd3f7934af7c77f79e13e19de328900878acec417e5c55a54dd", "schema_version": 1}
+1
View File
@@ -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',