mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-08 13:33:42 +08:00
361 lines
20 KiB
Python
361 lines
20 KiB
Python
"""Temporary lag preserves armed intent without retaining command authority."""
|
|
from contextlib import ExitStack
|
|
from unittest import mock
|
|
import pytest
|
|
|
|
from openpilot.cereal import log
|
|
from openpilot.common.prefix import OpenpilotPrefix
|
|
from openpilot.starpilot.aol.tests import test_ioniq_availability as fixture
|
|
from openpilot.starpilot.aol.wire import decode_safety
|
|
|
|
|
|
@pytest.mark.parametrize(('full_op', 'gas'), [(False, True), (True, False)],
|
|
ids=['lateral_only_accelerator_override', 'ordinary_combined'])
|
|
@pytest.mark.parametrize('order', ['axis_first', 'event_first', 'together'])
|
|
def test_actual_isolated_lag_keeps_only_existing_card_intent(full_op, gas, order):
|
|
case = fixture.IoniqAvailabilityTests(methodName='runTest')
|
|
with ExitStack() as cleanup:
|
|
cleanup.callback(case.doCleanups)
|
|
cleanup.enter_context(OpenpilotPrefix())
|
|
sd, _, drive, card, receive = case._ioniq_availability_callers(full_op=full_op)
|
|
feedback = card.aol_transport_feedback
|
|
baseline = (feedback.lateral_baseline_ns, feedback.lateral_source_ns,
|
|
feedback.lateral_native_ns, feedback.healthy_feedback_ns, feedback.healthy_native_ns)
|
|
assert card.aol_card_intent.allowed_latch and feedback.session
|
|
with mock.patch.object(type(sd.rk), 'lagging', new_callable=mock.PropertyMock, return_value=True):
|
|
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 '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
|
|
assert sd.events.contains(fixture.ET.SOFT_DISABLE)
|
|
assert log.OnroadEvent.EventName.selfdrivedLagging in sd.events.names
|
|
assert state.enabled == full_op
|
|
if full_op:
|
|
assert state.state == log.SelfdriveState.OpenpilotState.softDisabling
|
|
assert state.alertType == 'selfdrivedLagging/softDisable'
|
|
assert state.active and sd.state_machine.soft_disable_timer == 300
|
|
proof, event = sd.pm.messages['aolAxisState'], sd.pm.messages['onroadEvents']
|
|
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 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)
|
|
if full_op:
|
|
# Only an actually received fresh active proof establishes a new healthy
|
|
# baseline; duplicate reception does not change its source/native stamps.
|
|
received_ns = 10_020_000_000 + (len(deliveries) - 1) * 100_000
|
|
assert observed == (received_ns, axis.sourceCarStateMonoTime, 10_020_000_000,
|
|
received_ns, 10_020_000_000)
|
|
assert all(new >= old for new, old in zip(observed, baseline, strict=True))
|
|
assert feedback.signature[4] == proof.aolAxisState.validUntilMonoTime
|
|
else:
|
|
assert baseline == observed
|
|
assert not feedback.qualified and not feedback.awaiting and not feedback.retention_qualified
|
|
assert proof.aolAxisState.validUntilMonoTime == 10_050_000_000
|
|
# Clearing temporary lag does not supply an ordinary enable or native ACK.
|
|
recovered, next_state, next_command = drive(3, allowed=False, native_request_lat=False,
|
|
native_request_long=False)
|
|
assert not recovered.lateralActive and not recovered.longitudinalActive
|
|
assert not next_command.latActive and not next_command.longActive
|
|
# Card has received no replacement proof/native grant; retained intent
|
|
# cannot turn these independently blocked commands into output.
|
|
assert card.aol_card_intent.allowed_latch
|
|
if not full_op:
|
|
assert not next_state.enabled
|
|
|
|
|
|
@pytest.mark.parametrize('fault', ['controls_mismatch', 'process_failure', 'overheat', 'permanent_eps',
|
|
'invalid_can', 'can_timeout', 'invalid_native', 'native_denial', 'panda_rx', 'producer', 'epoch',
|
|
'event_stamp', 'axis_deadline', 'source_age', 'cancel', 'settings_disabled'])
|
|
def test_temporary_lag_never_masks_real_fault_or_invalid_proof(fault):
|
|
case = fixture.IoniqAvailabilityTests(methodName='runTest')
|
|
with ExitStack() as cleanup:
|
|
cleanup.callback(case.doCleanups)
|
|
cleanup.enter_context(OpenpilotPrefix())
|
|
sd, _, drive, card, receive = case._ioniq_availability_callers(full_op=False)
|
|
actual_events = sd.update_events
|
|
def events(cs):
|
|
result = actual_events(cs)
|
|
extra = {'controls_mismatch': log.OnroadEvent.EventName.controlsMismatch,
|
|
'process_failure': log.OnroadEvent.EventName.processNotRunning,
|
|
'overheat': log.OnroadEvent.EventName.overheat}.get(fault)
|
|
if extra is not None:
|
|
sd.events.add(extra)
|
|
return result
|
|
with mock.patch.object(type(sd.rk), 'lagging', new_callable=mock.PropertyMock, return_value=True), \
|
|
mock.patch.object(sd, 'update_events', side_effect=events):
|
|
axis, _, command = drive(2, allowed=fault != 'native_denial',
|
|
native_valid=fault != 'invalid_native', panda_fault=fault == 'panda_rx', native_request_long=False)
|
|
proof = sd.pm.messages['aolAxisState'].as_builder()
|
|
event = sd.pm.messages['onroadEvents'].as_builder()
|
|
state = fixture.car_state()
|
|
if fault == 'permanent_eps':
|
|
state.steerFaultPermanent = True
|
|
elif fault == 'invalid_can':
|
|
state.canValid = False
|
|
elif fault == 'can_timeout':
|
|
state.canTimeout = True
|
|
elif fault == 'producer':
|
|
proof.aolAxisState.intentProducerSessionId = 'other-card'
|
|
elif fault == 'epoch':
|
|
proof.aolAxisState.intentIntegrityEpoch += 1
|
|
elif fault == 'event_stamp':
|
|
event.logMonoTime += 1
|
|
elif fault == 'source_age':
|
|
proof.aolAxisState.sourceCarStateMonoTime -= 30_000_001
|
|
elif fault == 'cancel':
|
|
from opendbc.car.structs import car
|
|
state.buttonEvents = [car.CarState.ButtonEvent(type=car.CarState.ButtonEvent.Type.cancel, pressed=True)]
|
|
elif fault == 'settings_disabled':
|
|
from dataclasses import replace
|
|
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
|
|
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():
|
|
case = fixture.IoniqAvailabilityTests(methodName='runTest')
|
|
with ExitStack() as cleanup:
|
|
cleanup.callback(case.doCleanups)
|
|
cleanup.enter_context(OpenpilotPrefix())
|
|
sd, _, drive, card, receive = case._ioniq_availability_callers(full_op=True)
|
|
with mock.patch.object(type(sd.rk), 'lagging', new_callable=mock.PropertyMock, return_value=True):
|
|
for tick in range(2, 304):
|
|
axis, state, command = drive(tick, native_request_long=False, panda_controls_allowed=True)
|
|
native = decode_safety(sd.sm['aolSafetyWire'])
|
|
assert not native.requestedLongitudinal
|
|
assert all(p.controlsAllowed for p in native.pandaInventory)
|
|
assert all(p.controlsAllowed for p in sd.sm['pandaStates'])
|
|
assert sd.mismatch_counter == 0
|
|
assert log.OnroadEvent.EventName.controlsMismatch not in sd.events.names
|
|
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 '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
|
|
# requestedLongitudinal False through the real 300-tick countdown.
|
|
assert not axis.longitudinalActive and not command.longActive
|
|
assert axis.sourceCarStateMonoTime == 10_000_000_000 + tick*10_000_000
|
|
assert axis.validUntilMonoTime == axis.observedMonoTime + 30_000_000
|
|
assert not sd.events.contains(fixture.ET.ENABLE)
|
|
assert receive(10_000_000_000 + tick*10_000_000,
|
|
axis=sd.pm.messages['aolAxisState'], event=sd.pm.messages['onroadEvents'])
|
|
assert not state.enabled and not state.active and sd.state_machine.soft_disable_timer == 0
|
|
assert card.aol_card_intent.allowed_latch
|
|
axis, state, command = drive(304, native_request_long=False, panda_controls_allowed=True)
|
|
assert not state.enabled and not command.longActive and not axis.longitudinalActive
|
|
# A newly current lateral native ACK alone can restore lateral output.
|
|
assert axis.lateralActive and command.latActive
|
|
|
|
|
|
def test_persistent_lag_does_not_mask_native_controls_mismatch():
|
|
case = fixture.IoniqAvailabilityTests(methodName='runTest')
|
|
with ExitStack() as cleanup:
|
|
cleanup.callback(case.doCleanups)
|
|
cleanup.enter_context(OpenpilotPrefix())
|
|
sd, _, drive, card, receive = case._ioniq_availability_callers(full_op=True)
|
|
with mock.patch.object(type(sd.rk), 'lagging', new_callable=mock.PropertyMock, return_value=True):
|
|
for tick in range(2, 210):
|
|
axis, state, command = drive(tick, native_request_long=False, panda_controls_allowed=False)
|
|
native = decode_safety(sd.sm['aolSafetyWire'])
|
|
assert not native.requestedLongitudinal
|
|
assert not any(p.controlsAllowed for p in native.pandaInventory)
|
|
# The real 10 Hz Panda publication first reports denial at tick 10.
|
|
assert sd.mismatch_counter == max(0, tick - 9)
|
|
assert sd.state_machine.soft_disable_timer == 302 - tick
|
|
assert state.enabled == state.active == (tick < 209)
|
|
assert not axis.longitudinalActive and not command.longActive
|
|
if tick < 209:
|
|
assert not sd.events.contains(fixture.ET.IMMEDIATE_DISABLE)
|
|
assert receive(10_000_000_000 + tick*10_000_000,
|
|
axis=sd.pm.messages['aolAxisState'], event=sd.pm.messages['onroadEvents'])
|
|
else:
|
|
assert sd.mismatch_counter == 200 and sd.state_machine.soft_disable_timer == 93
|
|
assert log.OnroadEvent.EventName.controlsMismatch in sd.events.names
|
|
assert sd.events.contains(fixture.ET.IMMEDIATE_DISABLE)
|
|
assert not axis.lateralActive and not command.latActive
|
|
assert not receive(10_000_000_000 + tick*10_000_000,
|
|
axis=sd.pm.messages['aolAxisState'], event=sd.pm.messages['onroadEvents'])
|
|
assert not card.aol_card_intent.allowed_latch
|
|
|
|
|
|
@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:
|
|
cleanup.callback(case.doCleanups)
|
|
cleanup.enter_context(OpenpilotPrefix())
|
|
sd, _, drive, card, receive = case._ioniq_availability_callers(full_op=False)
|
|
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
|
|
event = sd.pm.messages['onroadEvents'].as_builder()
|
|
now = 10_020_100_000
|
|
if failure == 'missing_event':
|
|
now = anchor + 30_000_001
|
|
delivery = {'axis': proof.as_reader()}
|
|
elif failure == 'hard_event':
|
|
from openpilot.cereal import messaging
|
|
event = messaging.new_message('onroadEvents', 1, valid=True, logMonoTime=anchor)
|
|
event.onroadEvents[0].name = 'controlsMismatch'
|
|
event.onroadEvents[0].immediateDisable = True
|
|
delivery = {'event': event.as_reader()}
|
|
elif failure == 'wrong_event':
|
|
event.logMonoTime += 1
|
|
delivery = {'event': event.as_reader()}
|
|
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
|
|
from openpilot.starpilot.aol.wire import decode_safety, encode_safety
|
|
native = decode_safety(sd.sm['aolSafetyWire'])
|
|
bad = messaging.new_message('aolSafetyWire', 0, valid=True, logMonoTime=native.observedMonoTime)
|
|
bad.aolSafetyWire = encode_safety(replace(native, pandaSerial='changed-native',
|
|
pandaInventory=tuple(replace(slot, hardwareSerial='changed-native') for slot in native.pandaInventory)))
|
|
sd.sm.update_msgs(10.0201, [bad.as_reader()])
|
|
delivery = {'axis': proof.as_reader(), 'event': event.as_reader()}
|
|
assert not command.latActive and not command.longActive
|
|
assert not receive(now, **delivery)
|
|
assert not card.aol_card_intent.allowed_latch
|
|
|
|
|
|
@pytest.mark.parametrize('dm', ['alert_three', 'lockout'])
|
|
def test_dm_preserves_armed_lateral_after_lag_but_long_remains_disabled(dm):
|
|
case = fixture.IoniqAvailabilityTests(methodName='runTest')
|
|
with ExitStack() as cleanup:
|
|
cleanup.callback(case.doCleanups)
|
|
cleanup.enter_context(OpenpilotPrefix())
|
|
sd, _, drive, card, receive = case._ioniq_availability_callers(full_op=False)
|
|
# Fixture DM is produced at its actual 20Hz cadence. Keep the established
|
|
# Card proof current until the next real DM publication at tick5 (10.05s).
|
|
for tick in range(2, 5):
|
|
axis, _, command = drive(tick, native_request_long=False)
|
|
assert axis.lateralActive and command.latActive
|
|
assert receive(10_000_000_000 + tick*10_000_000,
|
|
axis=sd.pm.messages['aolAxisState'], event=sd.pm.messages['onroadEvents'])
|
|
actual_update = sd.sm.update_msgs
|
|
dm_current = [True]
|
|
delivered = []
|
|
def update(timestamp, messages):
|
|
selected = []
|
|
for message in messages:
|
|
if message.which() == 'driverMonitoringState':
|
|
delivered.append(int(message.logMonoTime))
|
|
if dm_current[0]:
|
|
message = message.as_builder()
|
|
message.driverMonitoringState.activePolicy = 'vision'
|
|
if dm == 'alert_three':
|
|
message.driverMonitoringState.alertLevel = 'three'
|
|
else:
|
|
message.driverMonitoringState.lockout = True
|
|
message = message.as_reader()
|
|
selected.append(message)
|
|
return actual_update(timestamp, selected)
|
|
with mock.patch.object(sd.sm, 'update_msgs', side_effect=update):
|
|
with mock.patch.object(type(sd.rk), 'lagging', new_callable=mock.PropertyMock, return_value=True):
|
|
axis, _, command = drive(5, native_request_long=False)
|
|
assert delivered == [10_050_000_000]
|
|
assert sd.sm.logMonoTime['driverMonitoringState'] == 10_050_000_000
|
|
if dm == 'alert_three':
|
|
assert sd.sm['driverMonitoringState'].alertLevel == log.DriverMonitoringState.AlertLevel.three
|
|
else:
|
|
assert sd.sm['driverMonitoringState'].lockout
|
|
assert not axis.lateralActive and not axis.longitudinalActive
|
|
assert not command.latActive and not command.longActive
|
|
assert sd.aol_availability_long_inhibit and not sd.aol_dm_lateral_inhibit
|
|
expected = log.OnroadEvent.EventName.driverDistracted3 if dm == 'alert_three' else log.OnroadEvent.EventName.tooDistracted
|
|
assert expected in sd.events.names
|
|
assert receive(10_050_000_000, axis=sd.pm.messages['aolAxisState'], event=sd.pm.messages['onroadEvents'])
|
|
assert card.aol_card_intent.allowed_latch
|
|
dm_current[0] = False
|
|
# DM remains current until its next cadence sample; neither clearing lag
|
|
# nor a new positive native ACK reenables ordinary LONG.
|
|
for tick in range(6, 11):
|
|
axis, state, command = drive(tick, native_request_long=False)
|
|
assert sd.aol_availability_long_inhibit and not sd.aol_dm_lateral_inhibit
|
|
assert axis.lateralActive and not axis.longitudinalActive
|
|
assert command.latActive and not command.longActive and not state.enabled
|
|
assert not sd.events.contains(fixture.ET.ENABLE)
|
|
assert delivered == [10_050_000_000, 10_100_000_000]
|
|
assert sd.sm.logMonoTime['driverMonitoringState'] == 10_100_000_000
|
|
assert sd.sm['driverMonitoringState'].alertLevel == log.DriverMonitoringState.AlertLevel.none
|
|
assert not sd.sm['driverMonitoringState'].lockout
|
|
|
|
|
|
@pytest.mark.parametrize('qualification', ['passive', 'unknown_brand'])
|
|
def test_unqualified_cp_cannot_use_temporary_lag_intent_exception(qualification):
|
|
from openpilot.starpilot.aol.vehicle import policy_for
|
|
case = fixture.IoniqAvailabilityTests(methodName='runTest')
|
|
with ExitStack() as cleanup:
|
|
cleanup.callback(case.doCleanups)
|
|
cleanup.enter_context(OpenpilotPrefix())
|
|
sd, _, drive, card, receive = case._ioniq_availability_callers(full_op=False)
|
|
assert policy_for(card.CP).intent_supported
|
|
with mock.patch.object(type(sd.rk), 'lagging', new_callable=mock.PropertyMock, return_value=True):
|
|
axis, _, command = drive(2, native_request_long=False)
|
|
changed = card.CP.as_reader().as_builder()
|
|
if qualification == 'passive':
|
|
changed.passive = True
|
|
else:
|
|
changed.brand = 'unregistered_vehicle'
|
|
assert not policy_for(changed).intent_supported
|
|
with mock.patch.object(card, 'CP', changed.as_reader()):
|
|
assert not receive(10_020_000_000, axis=sd.pm.messages['aolAxisState'], event=sd.pm.messages['onroadEvents'])
|
|
assert not card.aol_card_intent.allowed_latch
|
|
assert not command.latActive and not command.longActive
|
|
|
|
|
|
@pytest.mark.parametrize('cp', [None, {}, 'missing_brand', 'malformed_registered_brand'])
|
|
def test_missing_or_malformed_vehicle_policy_grants_no_intent_capability(cp):
|
|
from types import SimpleNamespace
|
|
from openpilot.starpilot.aol.vehicle import policy_for
|
|
if cp == 'missing_brand':
|
|
cp = SimpleNamespace()
|
|
elif cp == 'malformed_registered_brand':
|
|
cp = SimpleNamespace(brand='hyundai')
|
|
assert not policy_for(cp).intent_supported
|