Retire completed GM cold-MAIN recovery before a new lateral request

Clear prior recovery wait state only after the existing healthy unlatched baseline is accepted. Preserve native acknowledgment, fault, identity and expiry gates; cover actual Card and native permission transitions.
This commit is contained in:
Dom
2026-10-27 22:49:52 -05:00
parent e53ec2605f
commit efe875ffd6
3 changed files with 275 additions and 1 deletions
@@ -0,0 +1,270 @@
"""An accepted idle baseline retires a prior GM cold MAIN wait, not safety gates."""
from dataclasses import replace
from types import SimpleNamespace
import pytest
from opendbc.car.gm.tests.test_bolt_pedal import params
from opendbc.car.gm.values import CAR
from opendbc.car.structs import car
from openpilot.cereal import messaging
from openpilot.starpilot.aol.runtime import current_native, 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
def owner():
cp = params(CAR.CHEVROLET_BOLT_CC_2018_2021, pedal=True, camera=True)
cp.alternativeExperience = 96
assert cp.safetyConfigs[0].safetyParam == 157
cs = car.CarState(canValid=True, gearShifter=car.CarState.GearShifter.drive, vEgo=20.)
cs.cruiseState.available = True
return cp, cs
def snapshot(cp, now, *, kind='idle', faults=0, expired=False, session='session'):
sm = messaging.SubMaster(['aolAxisState', 'aolSafetyWire', 'pandaStates', 'onroadEvents'])
config = cp.safetyConfigs[0]
native = SafetyState(1, True, now, now + 200_000_000, config.safetyModel.raw, config.safetyParam,
False, False, False, False, 'panda', session, now,
(PandaSlot(0, 'panda', config.safetyModel.raw, config.safetyParam, cp.alternativeExperience, False, faults=faults),))
if expired:
native = replace(native, observedMonoTime=now-201_000_000, validUntilMonoTime=now-1)
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)
pandas.pandaStates[0].safetyModel = config.safetyModel
pandas.pandaStates[0].safetyParam = config.safetyParam
pandas.pandaStates[0].alternativeExperience = cp.alternativeExperience
if faults:
pandas.pandaStates[0].faults = ['relayMalfunction']
events = messaging.new_message('onroadEvents', int(kind == 'soft'), valid=True, logMonoTime=now)
if kind == 'soft':
events.onroadEvents[0].name = 'commIssueAvgFreq'
events.onroadEvents[0].softDisable = True
axis = messaging.new_message('aolAxisState', valid=True, logMonoTime=now)
a = axis.aolAxisState
a.sessionId = a.faultSessionId = session
a.sequence = (now-NOW)//10_000_000 + 1
a.sourceCarStateMonoTime = a.observedMonoTime = a.faultEventMonoTime = now
a.validUntilMonoTime = now + 30_000_000
a.intentProducerSessionId = 'producer'
a.intentIntegrityEpoch = 1
a.qualified = True
a.nativeAcknowledged = kind != 'soft'
a.desiredLateral = kind == 'request'
a.faultReason = 'availabilityPause' if kind == 'soft' else 'none'
sm.update_msgs(now/1e9, [wire.as_reader(), pandas.as_reader(), events.as_reader(), axis.as_reader()])
return sm
@pytest.mark.parametrize('bad', ('none', 'fault', 'expired_native'))
def test_idle_recovery_does_not_reject_next_request_using_old_wait(bad):
cp, cs = owner()
feedback = TransportPauseFeedback(cp)
def observe(now, kind, latched, **kwargs):
sm = snapshot(cp, now, kind=kind, **kwargs)
rejected = feedback.observe(sm, cp, cs, now, latched=latched,
intent_producer_session='producer')
return sm, rejected
_, rejected = observe(NOW, 'idle', False)
assert not rejected
_, rejected = observe(NOW+10_000_000, 'soft', True)
assert not rejected and feedback.cold_main_wait_ns
# The previous request is no longer latched; admit a new healthy idle baseline.
sm, rejected = observe(NOW+500_000_000, 'idle', False,
faults=1 if bad == 'fault' else 0, expired=bad == 'expired_native')
if bad != 'none':
assert current_native(sm, cp, now_ns=NOW+500_000_000, axis_session_id='session') is None
assert feedback.cold_main_wait_ns # No invalid proof may retire the wait.
return
assert not rejected and feedback.cold_main_wait_ns == 0
sm, rejected = observe(NOW+510_000_000, 'request', True)
assert not rejected
# Waiting for the native request echo never manufactures output authority.
native = current_native(sm, cp, now_ns=NOW+510_000_000, axis_session_id='session')
assert native is not None and not native.lateralAllowed
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=0.)
assert not decision.lateral_active and not decision.longitudinal_active
def test_recovered_intent_waits_for_native_ack_without_chime_loop():
from opendbc.can import CANPacker
from opendbc.car import Bus
from opendbc.car.gm.gmcan import pedal_crc
from opendbc.car.gm.tests.test_bolt_cc import feed
from opendbc.car.gm.values import DBC
from opendbc.safety.tests.libsafety import libsafety_py
from openpilot.starpilot.aol.intent import AolSettings
from openpilot.starpilot.aol.wire import IntentState, encode_intent
from openpilot.starpilot.audio.axis_alerts import AudibleAlert, AxisAlerts
from openpilot.starpilot.car.gm.aol import GmAolCardIntent
cp, cs = owner()
feedback = TransportPauseFeedback(cp)
assert not feedback.observe(snapshot(cp, NOW), cp, cs, NOW, latched=False, intent_producer_session='producer')
assert not feedback.observe(snapshot(cp, NOW+10_000_000, kind='soft'), cp, cs,
NOW+10_000_000, latched=True, intent_producer_session='producer')
assert feedback.cold_main_wait_ns
intent = GmAolCardIntent(AolSettings(True, 0., 0, 0, (0, 0, 0), (0, 0, 0)), cp)
audio = AxisAlerts()
audio_sm = messaging.SubMaster(['aolIntentWire', 'selfdriveState'])
sounds = []
for tick in range(7):
now = NOW + 500_000_000 + tick * 10_000_000
sm = snapshot(cp, now, kind='idle' if tick == 0 else 'request')
rejected = feedback.observe(sm, cp, cs, now, latched=intent.allowed_latch, intent_producer_session='producer')
intent.update(cs, fault_active=rejected, now_ns=now)
assert intent.allowed_latch and not rejected
native = current_native(sm, cp, now_ns=now, axis_session_id='session')
decision = decide_axes(standard_lateral=False, standard_longitudinal=False,
intent=SimpleNamespace(allowedLatch=intent.allowed_latch, pauseLateral=False, pauseLongitudinal=False),
native=native, car_state=cs, initialized=True, model_ready=True,
no_entry=False, immediate_disable=False, dm_lockout=False, pause_brake_mps=0.)
assert not decision.lateral_active
wire = messaging.new_message('aolIntentWire', 0, valid=True, logMonoTime=now)
wire.aolIntentWire = encode_intent(IntentState('producer', tick+1, now, now, now+200_000_000,
True, False, False, True, True))
state = messaging.new_message('selfdriveState', valid=True, logMonoTime=now)
audio_sm.update_msgs(now/1e9, [wire.as_reader(), state.as_reader()])
sounds.append(audio.update(audio_sm, AudibleAlert.none, now))
assert AudibleAlert.disengage not in sounds
assert sounds.count(AudibleAlert.engage) <= 1
# Earn LAT through real current MAIN/gear/pedal RX and host request, not a grant setter.
safety = libsafety_py.libsafety
safety.init_tests()
safety.set_alternative_experience(cp.alternativeExperience)
assert safety.set_safety_hooks(cp.safetyConfigs[0].safetyModel.raw, 157) == 0
packer = CANPacker(DBC[cp.carFingerprint][Bus.pt])
for tick in range(32):
now = NOW + 600_000_000 + tick * 10_000_000
_, rows = feed(SimpleNamespace(update=lambda _: None), packer, now,
counter=tick % 4, active=False, speed=20., camera=True)
rows = [row for row in rows if row[0] != 0x1F5]
rows.append(packer.make_can_msg('ECMPRDNL2', 0, {'PRNDL2': 6}))
sensor = bytearray(packer.make_can_msg('GAS_SENSOR', 0, {
'INTERCEPTOR_GAS': 0., 'INTERCEPTOR_GAS2': 0., 'STATE': 0, 'COUNTER_PEDAL': tick % 16})[1])
sensor[5] = pedal_crc(sensor)
rows.append((0x201, bytes(sensor), 0))
safety.set_timer(now//1000)
for address, payload, bus in rows:
assert safety.safety_rx_hook(libsafety_py.make_CANPacket(address, bus, payload))
safety.safety_tick()
safety.set_aol_test_heartbeat(True)
safety.aol_set_host_request(1)
mask = safety.aol_get_permission_mask()
assert mask == 1 and not safety.get_controls_allowed()
sm = snapshot(cp, now, kind='request')
receipt = current_native(sm, cp, now_ns=now, axis_session_id='session')
receipt = replace(receipt, requestedLateral=True, lateralAllowed=bool(mask & 1))
decision = decide_axes(standard_lateral=False, standard_longitudinal=False,
intent=SimpleNamespace(allowedLatch=intent.allowed_latch, pauseLateral=False, pauseLongitudinal=False),
native=receipt, 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 and not decision.longitudinal_active
safety.set_alternative_experience(0)
@pytest.mark.parametrize('variation', ('still_latched', 'invalid_axis', 'foreign_producer'))
def test_wait_is_not_retired_without_accepted_unlatched_baseline(variation):
cp, cs = owner()
feedback = TransportPauseFeedback(cp)
assert not feedback.observe(snapshot(cp, NOW), cp, cs, NOW, latched=False, intent_producer_session='producer')
assert not feedback.observe(snapshot(cp, NOW+10_000_000, kind='soft'), cp, cs,
NOW+10_000_000, latched=True, intent_producer_session='producer')
prior = feedback.cold_main_wait_ns
assert prior
now = NOW+500_000_000
sm = snapshot(cp, now)
if variation == 'invalid_axis':
sm.valid['aolAxisState'] = False
if variation == 'foreign_producer':
message = messaging.new_message('aolAxisState', valid=True, logMonoTime=now)
message.aolAxisState = sm['aolAxisState']
message.aolAxisState.intentProducerSessionId = 'foreign'
sm.update_msgs((now+1)/1e9, [message.as_reader()])
feedback.observe(sm, cp, cs, now, latched=variation == 'still_latched', intent_producer_session='producer')
assert feedback.cold_main_wait_ns == prior
def test_actual_card_retires_rejected_cold_wait_before_next_request():
"""Real parser/Card publication stays armed while the first native echo is idle."""
import os
from contextlib import ExitStack
from unittest.mock import patch
from openpilot.common.params import Params
from openpilot.common.prefix import OpenpilotPrefix
from openpilot.selfdrive.car import card as module
from openpilot.starpilot.aol.tests.test_gm import TestGmAol
from openpilot.starpilot.aol.tests.test_gm_held_native import parse
from openpilot.starpilot.aol.wire import decode_intent
with OpenpilotPrefix(), ExitStack() as cleanup, patch.dict(os.environ, {'SIMULATION': '1', 'AOL_REPLAY_RUNTIME': '0'}):
saved = Params()
saved.put_bool('AlwaysOnLateral', True, block=True)
saved.put_bool('AlphaLongitudinalEnabled', True, block=True)
cp, _ = owner()
card = TestGmAol.card(cp, saved)
cleanup.callback(card.vehicle_startup.close)
card.ci_initialized = True
card.publish_sendcan = lambda *args, **kwargs: None
published = {}
card.pm.send = lambda name, message: published.__setitem__(name, message.as_reader())
for tick in range(20):
stamp = NOW-(20-tick)*10_000_000
rows = parse(card, stamp, counter=tick % 4, packets_only=True)
card.CI.update([(stamp, rows)])
def cycle(tick, kind):
now = NOW+tick*10_000_000
peers = snapshot(card.CP, now, kind=kind)
messages = []
for name in ('aolSafetyWire', 'pandaStates', 'aolAxisState', 'onroadEvents'):
size = len(peers[name]) if name in ('pandaStates', 'onroadEvents') else 0 if name == 'aolSafetyWire' else None
message = messaging.new_message(name, size, valid=True, logMonoTime=now)
setattr(message, name, peers[name])
if name == 'aolAxisState':
message.aolAxisState.intentProducerSessionId = card.slc_producer_session
messages.append(message)
if kind == 'soft':
messages[-1].logMonoTime = NOW+10_000_000
messages[-2].aolAxisState.faultEventMonoTime = NOW+10_000_000
if kind == 'critical':
events = messaging.new_message('onroadEvents', 1, valid=True, logMonoTime=now)
events.onroadEvents[0].name = 'controlsMismatch'
events.onroadEvents[0].immediateDisable = True
messages[-1] = events
messages[-2].aolAxisState.faultReason = 'critical'
control = messaging.new_message('carControl', valid=True, logMonoTime=now)
card.sm.update_msgs(now/1e9, [m.as_reader() for m in messages]+[control.as_reader()])
rows = parse(card, now, counter=tick % 4, packets_only=True)
batch = messaging.log_from_bytes(module.can_list_to_can_capnp(rows)).as_builder()
batch.logMonoTime = now
with patch.object(module, 'REPLAY', False), \
patch.object(module.messaging, 'drain_sock_raw', return_value=[batch.to_bytes()]), \
patch.object(module.time, 'monotonic_ns', return_value=now), \
patch.object(module.time, 'monotonic', return_value=now/1e9), \
patch.object(module.time, 'clock_gettime_ns', return_value=now), patch.object(card.sm, 'update'):
card.step()
assert card.CS_prev.canValid and not card.CS_prev.canTimeout
assert not card.last_actuators_output.torqueOutputCan
result = decode_intent(published['aolIntentWire'].aolIntentWire)
assert result is not None
return result
assert cycle(0, 'idle').lateralArmed
assert cycle(1, 'soft').lateralArmed
assert card.aol_transport_feedback.cold_main_wait_ns
for tick in range(2, 40):
assert cycle(tick, 'soft').lateralArmed
assert not cycle(40, 'critical').lateralArmed
assert cycle(41, 'idle').lateralArmed
assert cycle(42, 'request').lateralArmed
assert card.aol_transport_feedback.cold_main_wait_ns == 0
@@ -1702,6 +1702,10 @@ class TransportPauseFeedback:
return False
current = bool(current and (transport_healthy or cancel_native is not None))
if current and baseline and not latched:
# A healthy idle baseline ends the previous cold MAIN recovery. Do not
# reject the next request using a wait owned by the cleared intent.
self.cold_main_wait_ns = 0
self.cold_main_sparse_waiting = False
self.cold_park_waiting = False
self.temporary_eps_event_ns = 0
self.session = ''
+1 -1
View File
@@ -1 +1 @@
{"protected_inventory_sha256": "228fd4bee1b3d8781b7271d86a67df38fbcaf944789d60441d57ee230cb0764b", "schema_version": 1}
{"protected_inventory_sha256": "036562945a2f20e7683f8008c7ac1b6d3151577202f87b585532ba4ccd452e2f", "schema_version": 1}