Files
StarPilot/openpilot/starpilot/aol/tests/test_temporary_steering_sequence.py
T
2026-10-25 12:25:17 -05:00

346 lines
18 KiB
Python

"""Temporary EPS retains intent through fresh proof and bounded event joins."""
from contextlib import ExitStack
from dataclasses import replace
import pytest
from openpilot.cereal import log, messaging
from openpilot.common.prefix import OpenpilotPrefix
from openpilot.selfdrive.selfdrived.events import Events
from openpilot.starpilot.aol.tests import test_ioniq_availability as fixture
from opendbc.car.structs import car
def setup_case():
case = fixture.IoniqAvailabilityTests(methodName='runTest')
return case
def baseline(case):
sd, controls, drive, card, receive = case._ioniq_availability_callers(full_op=False)
for tick in range(2, 152):
axis, _, command = drive(tick, gas=tick >= 151, native_request_long=False)
assert axis.lateralActive and command.latActive and not command.longActive
assert receive(10_000_000_000 + tick * 10_000_000, axis=sd.pm.messages['aolAxisState'],
event=sd.pm.messages['onroadEvents'])
return sd, controls, drive, card, receive
def leases(feedback):
return (feedback.lateral_baseline_ns, feedback.lateral_source_ns, feedback.lateral_native_ns,
feedback.healthy_feedback_ns, feedback.healthy_native_ns)
@pytest.mark.parametrize('new_axis', [False, True], ids=['already_received_active', 'fresh_active_first_received_with_eps'])
@pytest.mark.parametrize('silent', [False, True])
def test_first_temporary_eps_keeps_intent_without_renewing_prior_leases(new_axis, silent):
case = setup_case()
with ExitStack() as cleanup:
cleanup.callback(case.doCleanups)
cleanup.enter_context(OpenpilotPrefix())
sd, _, drive, card, receive = baseline(case)
feedback = card.aol_transport_feedback
original = leases(feedback)
old_signature = feedback.signature
axis, _, healthy_command = drive(152, gas=True, native_request_long=False)
proof, event = sd.pm.messages['aolAxisState'], sd.pm.messages['onroadEvents']
assert axis.lateralActive and healthy_command.latActive
assert axis.sequence > old_signature[1]
if not new_axis:
assert receive(11_520_000_000, axis=proof, event=event)
original = leases(feedback)
temporary = fixture.car_state()
temporary.gasPressed = temporary.steerFaultTemporary = True
temporary.standstill = silent
temporary.vEgo = 3.323399305343628
temporary.steeringAngleDeg = -305.79998779296875
# Card receives the preceding healthy axis with its first EPS sample,
# before SelfdriveD publishes the physical withdrawal event.
assert receive(11_529_500_000, axis=proof, event=event, state=temporary)
assert card.aol_card_intent.allowed_latch and not feedback.failed
assert leases(feedback) == original
assert feedback.signature[4] == proof.aolAxisState.validUntilMonoTime
for tick in range(153, 159):
active_native = tick < 155
withdrawn, published_state, command = drive(tick, gas=True, observed_state=temporary,
allowed=active_native, native_request_lat=active_native,
native_request_long=False)
assert not withdrawn.lateralActive and not withdrawn.longitudinalActive
assert not command.latActive and not command.longActive and not sd.enabled
assert published_state.alertType == 'steerTempUnavailableSilent/warning'
assert str(published_state.alertSound) == 'prompt'
assert not published_state.enabled and not published_state.active
assert receive(10_000_000_000 + tick * 10_000_000, axis=sd.pm.messages['aolAxisState'],
event=sd.pm.messages['onroadEvents'], state=temporary)
assert card.aol_card_intent.allowed_latch and not feedback.failed
assert leases(feedback) == original
recovered, _, command = drive(159, gas=True, native_request_lat=True, native_request_long=False)
assert recovered.lateralActive and command.latActive
assert not recovered.longitudinalActive and not command.longActive and not sd.enabled
assert receive(11_590_000_000, axis=sd.pm.messages['aolAxisState'], event=sd.pm.messages['onroadEvents'])
assert card.aol_card_intent.allowed_latch
@pytest.mark.parametrize('fault', ['permanent_eps', 'invalid_can', 'can_timeout', 'invalid_native',
'producer', 'epoch', 'source_age', 'old_output_expired', 'cancel', 'settings_disabled', 'hard_event', 'mixed_soft',
'source_reordered', 'stamp_reordered', 'session', 'fault_session'])
def test_fresh_active_eps_never_masks_hard_fault_or_bad_original_provenance(fault):
case = setup_case()
with ExitStack() as cleanup:
cleanup.callback(case.doCleanups)
cleanup.enter_context(OpenpilotPrefix())
sd, _, drive, card, receive = baseline(case)
drive(152, gas=True, native_request_long=False)
proof = sd.pm.messages['aolAxisState'].as_builder()
event = sd.pm.messages['onroadEvents']
temporary = fixture.car_state()
temporary.gasPressed = temporary.steerFaultTemporary = True
if fault == 'permanent_eps':
temporary.steerFaultPermanent = True
elif fault == 'invalid_can':
temporary.canValid = False
elif fault == 'can_timeout':
temporary.canTimeout = True
elif fault == 'invalid_native':
sd.sm.valid['aolSafetyWire'] = False
elif fault == 'producer':
proof.aolAxisState.intentProducerSessionId = 'other-card'
elif fault == 'epoch':
proof.aolAxisState.intentIntegrityEpoch += 1
elif fault == 'source_age':
proof.aolAxisState.sourceCarStateMonoTime -= 30_000_001
elif fault == 'source_reordered':
proof.aolAxisState.sourceCarStateMonoTime = card.aol_transport_feedback.signature[8] - 1
elif fault == 'stamp_reordered':
stamp = card.aol_transport_feedback.signature[2] - 1
proof.logMonoTime = stamp
proof.aolAxisState.observedMonoTime = stamp
proof.aolAxisState.validUntilMonoTime = stamp + 30_000_000
proof.aolAxisState.sourceCarStateMonoTime = stamp - 1
elif fault == 'session':
proof.aolAxisState.sessionId = proof.aolAxisState.faultSessionId = 'other-selfdrive'
elif fault == 'fault_session':
proof.aolAxisState.faultSessionId = 'other-selfdrive'
elif fault == 'cancel':
temporary.buttonEvents = [car.CarState.ButtonEvent(type=car.CarState.ButtonEvent.Type.cancel, pressed=True)]
elif fault == 'settings_disabled':
card.aol_card_intent.settings = replace(card.aol_card_intent.settings, enabled=False)
elif fault in ('hard_event', 'mixed_soft'):
events = Events()
events.add(log.OnroadEvent.EventName.controlsMismatch if fault == 'hard_event' else log.OnroadEvent.EventName.commIssue)
message = messaging.new_message('onroadEvents', len(events), valid=True, logMonoTime=event.logMonoTime)
message.onroadEvents = events.to_msg()
event = message.as_reader()
now = 11_540_000_001 if fault == 'old_output_expired' else 11_529_500_000
assert not receive(now, axis=proof.as_reader(), event=event, state=temporary)
assert not card.aol_card_intent.allowed_latch
def test_two_fresh_active_eps_receipts_cannot_chain_past_original_source_lease():
case = setup_case()
with ExitStack() as cleanup:
cleanup.callback(case.doCleanups)
cleanup.enter_context(OpenpilotPrefix())
sd, _, drive, card, receive = baseline(case)
feedback = card.aol_transport_feedback
original = leases(feedback)
temporary = fixture.car_state()
temporary.gasPressed = temporary.steerFaultTemporary = True
for tick in (152, 153):
# SD has not yet received EPS; each real publication uses a fresh
# healthy source. Card must keep only intent inside its fixed baseline.
axis, _, command = drive(tick, gas=True, native_request_long=False)
assert axis.lateralActive and command.latActive
now = 10_000_000_000 + tick * 10_000_000 + 9_500_000
assert receive(now, axis=sd.pm.messages['aolAxisState'], event=sd.pm.messages['onroadEvents'], state=temporary)
assert leases(feedback) == original and not feedback.failed
axis, _, _ = drive(154, gas=True, native_request_long=False)
assert axis.lateralActive
now = 11_540_000_001
assert now - original[1] > 30_000_000
assert now <= sd.pm.messages['aolAxisState'].aolAxisState.validUntilMonoTime
assert not receive(now, axis=sd.pm.messages['aolAxisState'], event=sd.pm.messages['onroadEvents'], state=temporary)
assert not card.aol_card_intent.allowed_latch
assert leases(feedback) == original
@pytest.mark.parametrize('order', ['proof_first', 'event_first', 'same_cycle'])
def test_actual_eps_withdrawal_delivery_orders_keep_only_existing_intent(order):
case = fixture.IoniqAvailabilityTests(methodName='runTest')
with ExitStack() as cleanup:
cleanup.callback(case.doCleanups)
cleanup.enter_context(OpenpilotPrefix())
sd, _, drive, card, receive = baseline(case)
feedback = card.aol_transport_feedback
original = leases(feedback)
drive(152, gas=True, native_request_long=False)
temporary = fixture.car_state()
temporary.gasPressed = temporary.steerFaultTemporary = True
assert receive(11_529_500_000, axis=sd.pm.messages['aolAxisState'],
event=sd.pm.messages['onroadEvents'], state=temporary)
assert leases(feedback) == original and not feedback.failed
withdrawn, published_state, command = drive(153, gas=True, observed_state=temporary,
native_request_lat=True, native_request_long=False)
assert not withdrawn.lateralActive and not withdrawn.longitudinalActive
assert not command.latActive and not command.longActive and not sd.enabled
assert published_state.alertType == 'steerTempUnavailableSilent/warning'
assert str(published_state.alertSound) == 'prompt'
assert not published_state.enabled and not published_state.active
proof, event = sd.pm.messages['aolAxisState'], sd.pm.messages['onroadEvents']
assert proof.aolAxisState.faultEventMonoTime == event.logMonoTime
deliveries = [{'axis': proof}, {'event': event}] if order == 'proof_first' else \
[{'event': event}, {'axis': proof}] if order == 'event_first' else [{'axis': proof, 'event': event}]
for index, delivery in enumerate(deliveries):
assert receive(11_530_000_000 + index * 100_000, state=temporary, **delivery)
assert card.aol_card_intent.allowed_latch and not feedback.failed
assert leases(feedback) == original
recovered, _, recovered_command = drive(154, gas=True, native_request_lat=True, native_request_long=False)
assert recovered.lateralActive and recovered_command.latActive
assert not recovered.longitudinalActive and not recovered_command.longActive and not sd.enabled
assert receive(11_540_000_000, axis=sd.pm.messages['aolAxisState'], event=sd.pm.messages['onroadEvents'])
assert card.aol_card_intent.allowed_latch and not feedback.failed
@pytest.mark.parametrize('fault', ['missing_event', 'late_event', 'duplicate_timeout', 'higher_sequence_timeout',
'changed_occurrence', 'hard_event', 'mixed_soft', 'user_event', 'invalid_native', 'native_inventory',
'producer', 'epoch', 'permanent_eps', 'can_timeout', 'cancel', 'settings_disabled', 'stale_source'])
def test_pending_eps_join_cannot_extend_or_hide_faults(fault):
case = fixture.IoniqAvailabilityTests(methodName='runTest')
with ExitStack() as cleanup:
cleanup.callback(case.doCleanups)
cleanup.enter_context(OpenpilotPrefix())
sd, _, drive, card, receive = baseline(case)
feedback = card.aol_transport_feedback
original = leases(feedback)
drive(152, gas=True, native_request_long=False)
temporary = fixture.car_state()
temporary.gasPressed = temporary.steerFaultTemporary = True
assert receive(11_529_500_000, axis=sd.pm.messages['aolAxisState'], event=sd.pm.messages['onroadEvents'], state=temporary)
axis, _, command = drive(153, gas=True, observed_state=temporary,
native_request_lat=True, native_request_long=False)
assert not axis.lateralActive and not axis.longitudinalActive
assert not command.latActive and not command.longActive
proof = sd.pm.messages['aolAxisState']
event = sd.pm.messages['onroadEvents']
anchor = proof.aolAxisState.faultEventMonoTime
assert receive(11_530_000_000, axis=proof, state=temporary)
assert feedback.pending_event == anchor and feedback.awaiting and not feedback.failed
assert leases(feedback) == original
now = 11_530_100_000
delivery = {}
if fault in ('missing_event', 'late_event', 'duplicate_timeout', 'higher_sequence_timeout'):
if fault == 'duplicate_timeout':
assert receive(11_550_000_000, axis=proof, state=temporary)
assert feedback.pending_event == anchor
elif fault == 'higher_sequence_timeout':
for tick in (154, 155):
axis, _, command = drive(tick, gas=True, observed_state=temporary, allowed=False,
native_request_lat=False, native_request_long=False)
assert not command.latActive and not command.longActive
assert sd.pm.messages['aolAxisState'].aolAxisState.faultEventMonoTime == anchor
assert receive(10_000_000_000 + tick * 10_000_000, axis=sd.pm.messages['aolAxisState'], state=temporary)
assert feedback.pending_event == anchor
assert leases(feedback) == original
axis, _, command = drive(156, gas=True, observed_state=temporary, allowed=False,
native_request_lat=False, native_request_long=False)
proof = sd.pm.messages['aolAxisState']
assert not axis.lateralActive and not command.latActive and not command.longActive
assert proof.aolAxisState.faultEventMonoTime == anchor
now = anchor + 30_000_001
assert now <= proof.aolAxisState.validUntilMonoTime
delivery = {'axis': proof}
if fault == 'late_event':
delivery['event'] = event
elif fault == 'changed_occurrence':
changed = proof.as_builder()
changed.aolAxisState.faultEventMonoTime += 1
delivery = {'axis': changed.as_reader()}
elif fault in ('hard_event', 'mixed_soft', 'user_event'):
events = Events()
events.add(log.OnroadEvent.EventName.steerTempUnavailable)
events.add({'hard_event': log.OnroadEvent.EventName.controlsMismatch,
'mixed_soft': log.OnroadEvent.EventName.commIssue,
'user_event': log.OnroadEvent.EventName.buttonCancel}[fault])
message = messaging.new_message('onroadEvents', len(events), valid=True, logMonoTime=anchor)
message.onroadEvents = events.to_msg()
delivery = {'event': message.as_reader()}
elif fault == 'invalid_native':
sd.sm.valid['aolSafetyWire'] = False
elif fault == 'native_inventory':
message = messaging.new_message('pandaStates', len(sd.sm['pandaStates']), valid=True,
logMonoTime=sd.sm.logMonoTime['pandaStates'])
message.pandaStates = sd.sm['pandaStates']
message.pandaStates[-1].safetyParam ^= 1
sd.sm.update_msgs(now / 1e9, [message.as_reader()])
elif fault in ('producer', 'epoch', 'stale_source'):
changed = proof.as_builder()
if fault == 'producer':
changed.aolAxisState.intentProducerSessionId = 'other-card'
elif fault == 'epoch':
changed.aolAxisState.intentIntegrityEpoch += 1
else:
changed.aolAxisState.sourceCarStateMonoTime -= 30_000_001
delivery = {'axis': changed.as_reader()}
elif fault == 'permanent_eps':
temporary.steerFaultPermanent = True
elif fault == 'can_timeout':
temporary.canTimeout = True
elif fault == 'cancel':
temporary.buttonEvents = [car.CarState.ButtonEvent(type=car.CarState.ButtonEvent.Type.cancel, pressed=True)]
elif fault == 'settings_disabled':
card.aol_card_intent.settings = replace(card.aol_card_intent.settings, enabled=False)
assert not receive(now, state=temporary, **delivery)
assert not card.aol_card_intent.allowed_latch
assert leases(feedback) == original
def test_initial_eps_off_cannot_reorder_the_preceding_accepted_active_source():
case = fixture.IoniqAvailabilityTests(methodName='runTest')
with ExitStack() as cleanup:
cleanup.callback(case.doCleanups)
cleanup.enter_context(OpenpilotPrefix())
sd, _, drive, card, receive = baseline(case)
drive(152, gas=True, native_request_long=False)
temporary = fixture.car_state()
temporary.gasPressed = temporary.steerFaultTemporary = True
assert receive(11_529_500_000, axis=sd.pm.messages['aolAxisState'], event=sd.pm.messages['onroadEvents'], state=temporary)
previous = card.aol_transport_feedback.signature
axis, _, command = drive(153, gas=True, observed_state=temporary,
native_request_lat=True, native_request_long=False)
assert not axis.lateralActive and not command.latActive and not command.longActive
proof = sd.pm.messages['aolAxisState'].as_builder()
proof.aolAxisState.sourceCarStateMonoTime = previous[8] - 1
assert proof.aolAxisState.sourceCarStateMonoTime <= proof.aolAxisState.observedMonoTime
assert 11_530_000_000 - proof.aolAxisState.sourceCarStateMonoTime < 30_000_000
assert not receive(11_530_000_000, axis=proof.as_reader(), state=temporary)
assert not card.aol_card_intent.allowed_latch
def test_axis_first_eps_join_uses_last_accepted_active_proof_without_renewing_leases():
case = fixture.IoniqAvailabilityTests(methodName='runTest')
with OpenpilotPrefix(), ExitStack() as cleanup:
cleanup.callback(case.doCleanups)
sd, _, drive, card, receive = baseline(case)
feedback = card.aol_transport_feedback
original = leases(feedback)
drive(152, gas=True, native_request_long=False)
temporary = fixture.car_state()
temporary.gasPressed = temporary.steerFaultTemporary = True
assert receive(11_529_500_000, axis=sd.pm.messages['aolAxisState'],
event=sd.pm.messages['onroadEvents'], state=temporary)
active = feedback.signature
assert leases(feedback) == original
withdrawn, _, command = drive(153, gas=True, observed_state=temporary,
native_request_lat=True, native_request_long=False)
assert not withdrawn.lateralActive and not withdrawn.longitudinalActive
assert not command.latActive and not command.longActive
proof, event = sd.pm.messages['aolAxisState'], sd.pm.messages['onroadEvents']
now = 11_541_000_000
assert now - original[1] > 30_000_000
assert now - active[8] <= 30_000_000 and now <= active[4]
assert receive(now, axis=proof, state=temporary)
assert card.aol_card_intent.allowed_latch and not feedback.failed
assert leases(feedback) == original
assert receive(now + 100_000, event=event, state=temporary)
assert card.aol_card_intent.allowed_latch and leases(feedback) == original