Preserve the original EV6 output proof across an input timeout

This commit is contained in:
firestar5683
2026-10-22 15:43:58 -05:00
parent e72e3a875b
commit e70f7ea88b
5 changed files with 170 additions and 5 deletions
+20 -4
View File
@@ -504,9 +504,11 @@ class SelfdriveD:
self.events.add(EventName.radarFault)
hardware_events_added = len(self.events) - num_events
from opendbc.car.hyundai.ev6_aol import qualified as ev6_deadline_qualified
input_proof = getattr(self, 'aol_step_input_proof', None)
reconcile_input = bool(input_proof is not None and not REPLAY and self.aol_replay and self.axis_transport_required and
self.CP.openpilotLongitudinalControl and qualified_ioniq6(self.CP) and
self.CP.openpilotLongitudinalControl and
(qualified_ioniq6(self.CP) or ev6_deadline_qualified(self.CP, marked_only=True)) and
input_proof[0] == self.aol_session_id and
(input_proof[8] or input_proof[9]))
self.aol_event_input_reconcile = reconcile_input
@@ -806,6 +808,10 @@ class SelfdriveD:
return CS
def source_deadline_deferral_supported(self):
from opendbc.car.hyundai.ev6_aol import qualified as ev6_qualified
return qualified_ioniq6(self.CP) or qualified_gm(self.CP) or ev6_qualified(self.CP, marked_only=True)
def held_axis_override_wait(self, CS, proof, now_ns, *, fresh_successor=False):
"""Recognize an already applied override; never admit a new physical edge."""
if (proof is None or not (qualified_ioniq6(self.CP) or qualified_gm(self.CP)) or not self.sm.seen['carControl'] or
@@ -968,14 +974,24 @@ class SelfdriveD:
def defer_expired_axis_publication(self, CS, initial_state, *, now_ns=None):
"""Keep an existing unexpired proof for one normal tick; issue no new lease."""
if REPLAY or not self.aol_replay or not self.axis_transport_required or not (qualified_ioniq6(self.CP) or qualified_gm(self.CP)):
if REPLAY or not self.aol_replay or not self.axis_transport_required or not self.source_deadline_deferral_supported():
return False
proof = getattr(self, 'aol_step_input_proof', None)
if now_ns is None:
now_ns = time.monotonic_ns()
source = self.aol_car_state_log_ns
source_valid = self.conditional_car_state_valid
from opendbc.car.hyundai.ev6_aol import qualified as ev6_qualified
if (ev6_qualified(self.CP, marked_only=True) and source == 0 and not source_valid and
getattr(self, 'aol_expired_healthy_source', False) and
not getattr(self, 'car_state_samples', ()) and not getattr(self, 'car_state_queue_invalid', False) and
getattr(self, 'car_state_retained_valid', False)):
# A socket timeout cleared the selected source. Validate only the old
# publication's receipt; keep the source invalid and its clocks unchanged.
source = getattr(self, 'car_state_retained_source_ns', 0)
source_valid = True
if (getattr(self, 'aol_step_deferral_checked', False) or proof is None or proof != getattr(self, 'aol_published_input_proof', None) or
not self.conditional_car_state_valid or not 0 < source <= now_ns or
not source_valid or not 0 < source <= now_ns or
now_ns - source <= INTENT_MAX_AGE_NS or source != proof[1] or
now_ns >= proof[3] or self.aol_authority_lost or
getattr(self, 'aol_fault_reason', 'none') != 'none' or
@@ -1443,7 +1459,7 @@ class SelfdriveD:
final_ns = time.monotonic_ns()
if ((axis.lateralActive or axis.longitudinalActive) and
(not 0 < self.aol_car_state_log_ns <= final_ns or final_ns - self.aol_car_state_log_ns > INTENT_MAX_AGE_NS) and
(qualified_ioniq6(self.CP) or qualified_gm(self.CP))):
self.source_deadline_deferral_supported()):
if (source_context is not None and source_context[1] and
self.defer_expired_axis_publication(CS, source_context[0])):
# Serialization crossed the same source boundary. Retain only the
@@ -0,0 +1,142 @@
"""Recorded EV6 8f2e source cutoff keeps only its original unexpired proof."""
from contextlib import ExitStack
from unittest import mock
import pytest
from openpilot.cereal import log, messaging
from openpilot.common.prefix import OpenpilotPrefix
from openpilot.starpilot.aol.tests import test_ioniq_availability as fixture
from openpilot.starpilot.aol.tests.test_ev6_physical_authorization import ev6_params
from openpilot.starpilot.aol.tests.test_ordinary_source_availability import settings
SOURCE = 10_020_000_000
@pytest.mark.parametrize('socket_timeout', [False, True])
@pytest.mark.parametrize('age_ns,published_offset_ns', [
(30_960_101, 4_945_854),
(31_244_572, 5_262_889),
(31_383_633, 5_039_179),
(30_890_079, 4_798_596),
(31_008_968, 5_325_976),
(31_351_205, 5_392_434),
(31_544_229, 5_502_541),
(32_161_269, 6_033_539),
(31_684_749, 5_741_086),
(31_896_523, 5_808_274),
(32_254_551, 5_671_607),
(31_565_476, 5_393_216),
(30_879_839, 5_223_629),
])
def test_ev6_recorded_source_cutoff_retains_only_original_unexpired_output(age_ns, published_offset_ns, socket_timeout):
case = fixture.IoniqAvailabilityTests(methodName='runTest')
with ExitStack() as cleanup:
cleanup.callback(case.doCleanups)
cleanup.enter_context(OpenpilotPrefix())
cp = ev6_params()
sd, controls, drive = case._authority_callers(True, cp=cp, genuine_events=True)
assert sd.CP.carFingerprint == 'KIA_EV6'
assert sd.CP.safetyConfigs[0].safetyParam == 0x815 and sd.CP.alternativeExperience == 32
for tick in (0, 1):
drive(tick, **settings('combined', 10_000_000_000 + tick * 10_000_000, tick + 1))
original, state, command = drive(2, state_stamp=SOURCE, offset_ns=published_offset_ns, **settings('combined', SOURCE, 3))
assert original.lateralActive and original.longitudinalActive and command.latActive and command.longActive
deadline = original.validUntilMonoTime
assert deadline == SOURCE + published_offset_ns + 30_000_000
old = original.to_dict()
timer = sd.state_machine.soft_disable_timer
sd.car_state_poller = mock.Mock()
sd.car_state_poller.poll.return_value = []
axis, state, command = drive(5, timeout=socket_timeout, state_stamp=SOURCE, offset_ns=age_ns - 30_000_000,
**settings('combined', SOURCE, 3))
assert sd.aol_axis_publication_deferred
if socket_timeout:
assert sd.aol_car_state_log_ns == 0 and not sd.conditional_car_state_valid
else:
assert sd.aol_car_state_log_ns == SOURCE and sd.conditional_car_state_valid
assert axis.to_dict() == old and axis.validUntilMonoTime == deadline
assert state.enabled and sd.state_machine.soft_disable_timer == timer
assert log.OnroadEvent.EventName.commIssue not in sd.events.names
# The consumer stops at the original cutoff; retaining a proof never renews it.
with mock.patch('openpilot.selfdrive.controls.controlsd.time.monotonic_ns', return_value=deadline + 1):
expired, _ = controls.state_control()
assert not expired.latActive and not expired.longActive
# A second stale loop cannot repeat the deferral even within that deadline.
next_axis, _, next_command = drive(5, timeout=True, state_stamp=SOURCE, offset_ns=age_ns - 30_000_000 + 100_000,
**settings('combined', SOURCE, 3))
assert not sd.aol_axis_publication_deferred
assert not next_axis.lateralActive and not next_axis.longitudinalActive
assert not next_command.latActive and not next_command.longActive
@pytest.mark.parametrize('fault', ['can', 'epsTemporary', 'epsPermanent', 'acc', 'brake', 'gas',
'pandaRx', 'relay', 'nativeInvalid', 'uncalibrated'])
def test_ev6_unexpired_output_never_defers_a_new_fault(fault):
case = fixture.IoniqAvailabilityTests(methodName='runTest')
with ExitStack() as cleanup:
cleanup.callback(case.doCleanups)
cleanup.enter_context(OpenpilotPrefix())
sd, _, drive = case._authority_callers(True, cp=ev6_params(), genuine_events=True)
for tick in (0, 1, 2):
stamp = 10_000_000_000 + tick * 10_000_000
axis, _, command = drive(tick, state_stamp=stamp, offset_ns=4_945_854 if tick == 2 else 0,
**settings('combined', stamp, tick + 1))
assert axis.lateralActive and axis.longitudinalActive and command.latActive and command.longActive
state = fixture.car_state()
changes = {}
if fault == 'can':
state.canValid = False
elif fault == 'epsTemporary':
state.steerFaultTemporary = True
elif fault == 'epsPermanent':
state.steerFaultPermanent = True
elif fault == 'acc':
state.accFaulted = True
elif fault == 'brake':
changes['brake'] = True
elif fault == 'gas':
changes['gas'] = True
elif fault == 'pandaRx':
changes['panda_fault'] = True
elif fault == 'relay':
changes['relay_fault'] = True
elif fault == 'nativeInvalid':
changes['native_valid'] = False
elif fault == 'uncalibrated':
# Calibration publishes at 1 Hz; the normal fixture cadence correctly
# skips a replacement only 30 ms later. Deliver the new envelope itself.
stamp = SOURCE + 30_960_101
calibration = messaging.new_message('extrinsicsCalibration', valid=True, logMonoTime=stamp)
calibration.extrinsicsCalibration.calStatus = 'uncalibrated'
sd.sm.update_msgs(stamp / 1e9, [calibration.as_reader()])
if fault in ('can', 'epsTemporary', 'epsPermanent', 'acc'):
changes['observed_state'] = state
axis, _, command = drive(5, state_stamp=SOURCE, offset_ns=960_101,
**settings('combined', SOURCE, 3), **changes)
if fault == 'uncalibrated':
assert sd.sm['extrinsicsCalibration'].calStatus == log.ExtrinsicsCalibration.Status.uncalibrated
assert not sd.aol_axis_publication_deferred
# The existing fault/pedal rules select the remaining axes; the previous
# combined proof cannot survive the new restriction.
assert not (axis.lateralActive and axis.longitudinalActive)
assert not (command.latActive and command.longActive)
@pytest.mark.parametrize('timing', ['timely', 'late_poll', 'processing_crosses_deadline'])
def test_ev6_fresh_pair_reconciles_without_extending_the_old_deadline(timing):
from openpilot.starpilot.aol.tests import test_input_deadline_continuity as continuity
def ev6_callers(case, full_op=True, *, stock_angle=False):
assert full_op and not stock_angle
sd, controls, drive = case._authority_callers(True, cp=ev6_params(), genuine_events=True)
assert sd.CP.carFingerprint == 'KIA_EV6'
assert sd.CP.safetyConfigs[0].safetyParam == 0x815 and sd.CP.alternativeExperience == 32
for tick in (0, 1):
stamp = 10_000_000_000 + tick * 10_000_000
drive(tick, **settings('combined', stamp, tick + 1))
return sd, controls, drive, None, None
# Reuse the existing timing stimulus with the captured EV6 configuration;
# production event, source, native and consumer checks remain real.
with mock.patch.object(fixture.IoniqAvailabilityTests, '_ioniq_availability_callers', ev6_callers):
continuity.test_fresh_source_replaces_expired_output_without_restoring_disengagement('ordinary', timing)
+1 -1
View File
@@ -1 +1 @@
{"protected_inventory_sha256": "dc8ad8a50e217c02cb820e05312df6d418b5c0853da58033fd068049e19737fe", "schema_version": 1}
{"protected_inventory_sha256": "f519a895eeac1226e4ab79722e1227633eee2369587b661939cb6e5f10c4ae2d", "schema_version": 1}
+2
View File
@@ -24,6 +24,7 @@ from tools.ci import vehicle_pytest
# Representative production callers plus their source/native refusal cases.
# The emitted selected_test_ids record exact parametrized configurations.
AOL_LIFECYCLE_TARGETS = (
'openpilot/starpilot/aol/tests/test_ev6_source_deadline.py',
"openpilot/starpilot/car/rivian/tests/test_aol_policy.py",
"openpilot/starpilot/car/rivian/tests/test_aol_native.py",
"openpilot/starpilot/car/rivian/tests/test_aol_lifecycle.py",
@@ -114,6 +115,7 @@ DEBUG_SAFETY_CLASS_TARGETS = {
RELEASE_TARGETS = (
'openpilot/starpilot/aol/tests/test_ev6_source_deadline.py',
"openpilot/starpilot/aol/tests/test_ev9_main_withdrawal_guards.py",
"opendbc_repo/opendbc/safety/tests/test_ev9_combined_aol.py::TestEV9CombinedReleaseDenied",
"opendbc_repo/opendbc/car/hyundai/tests/test_ev9_combined_aol.py::TestEV9CombinedProfile",
+5
View File
@@ -46,6 +46,11 @@ class TestVehicleCollection(unittest.TestCase):
}
self.assertLessEqual(expected, set(targets_for('safety-release')))
def test_ev6_source_deadline_refusals_run_in_lifecycle_and_release_ci(self):
target = 'openpilot/starpilot/aol/tests/test_ev6_source_deadline.py'
self.assertIn(target, targets_for('aol-lifecycle'))
self.assertIn(target, targets_for('safety-release'))
def test_ev6_release_ci_exercises_real_alpha_startup_and_physical_owner(self):
expected = {
'openpilot/starpilot/tests/test_ev6_startup.py',