From 23123aefb4898aba0fd34d8ef3e6052cd0c80ca1 Mon Sep 17 00:00:00 2001 From: firestar5683 <168790843+firestar5683@users.noreply.github.com> Date: Sat, 31 Oct 2026 17:25:44 -0500 Subject: [PATCH] Keep radar failures from disabling independent lateral steering --- openpilot/selfdrive/selfdrived/selfdrived.py | 26 +-- .../aol/tests/test_radar_lateral_isolation.py | 170 ++++++++++++++++++ openpilot/starpilot/nostalgia.py | 6 +- prebuilt.json | 2 +- 4 files changed, 192 insertions(+), 12 deletions(-) mode change 100755 => 100644 openpilot/selfdrive/selfdrived/selfdrived.py create mode 100644 openpilot/starpilot/aol/tests/test_radar_lateral_isolation.py diff --git a/openpilot/selfdrive/selfdrived/selfdrived.py b/openpilot/selfdrive/selfdrived/selfdrived.py old mode 100755 new mode 100644 index 5ecb1a3404..485a59afd6 --- a/openpilot/selfdrive/selfdrived/selfdrived.py +++ b/openpilot/selfdrive/selfdrived/selfdrived.py @@ -57,7 +57,7 @@ from openpilot.starpilot.conditional_mode.projection import paired_clocks_ns from openpilot.starpilot.conditional_mode.runtime_settings import (ConditionalSettingsOwner, experimental_requested, read_mode_sources) from openpilot.starpilot.conditional_mode.status import settings_fingerprint from openpilot.starpilot.feature_runtime import enabled as feature_enabled -from openpilot.starpilot.nostalgia import aol_no_entry, paddle_cancel, physical_cancel, saved_enabled as nostalgia_saved_enabled +from openpilot.starpilot.nostalgia import LONGITUDINAL_ONLY_EVENTS, aol_no_entry, paddle_cancel, physical_cancel, saved_enabled as nostalgia_saved_enabled from openpilot.starpilot.lateral.low_speed_advisory import LowSpeedAdvisory, advisory_eligible from openpilot.starpilot.lateral.lane_change_status_wire import alert_wording, decode as decode_lane_status, fresh_for_model from openpilot.selfdrive.selfdrived.alertmanager import AlertManager, set_offroad_alert @@ -265,7 +265,7 @@ class SelfdriveD: if not_running != self.not_running_prev: cloudlog.event("process_not_running", not_running=not_running, error=True) self.not_running_prev = not_running - self.events.add(EventName.processNotRunning) + self.events.add(EventName.radarFault if set(not_running) == {"radard"} else EventName.processNotRunning) return True return False @@ -506,7 +506,7 @@ class SelfdriveD: self.events.add(EventName.selfdrivedLagging) if self.CP.openpilotLongitudinalControl: if self.sm['radarState'].radarErrors.canError: - self.events.add(EventName.canError) + self.events.add(EventName.radarFault) elif self.sm['radarState'].radarErrors.radarUnavailableTemporary: self.events.add(EventName.radarTempUnavailable) elif any(self.sm['radarState'].radarErrors.to_dict().values()): @@ -621,8 +621,11 @@ class SelfdriveD: no_system_errors = (not has_disable_events) or (hardware_events_added == 0 and len(self.events) == num_can_events) warmup_sec = 5. big_model_settling = self.big_model_loading or time.monotonic() < self.big_model_ready_t + warmup_sec - if not self.sm.all_checks() and no_system_errors and not big_model_settling: # the load holds modelV2 and friends back on purpose - if not self.sm.all_alive(): + if (not self.sm.all_checks() and not big_model_settling and + (no_system_errors or any(event in LONGITUDINAL_ONLY_EVENTS for event in self.events.names))): # the load holds modelV2 and friends back on purpose + if self.sm.all_checks([service for service in self.sm.services if service != 'radarState']): + self.events.add(EventName.radarFault) + elif not self.sm.all_alive(): self.events.add(EventName.commIssue) elif not self.sm.all_freq_ok(): self.events.add(EventName.commIssueAvgFreq) @@ -1707,6 +1710,7 @@ class SelfdriveD: lateral_events = [event for event in lateral_events if event not in (EventName.wrongGear, EventName.reverseGear)] return decide_axes( standard_lateral=self.active, standard_longitudinal=bool(self.enabled and self.CP.openpilotLongitudinalControl and + not any(event in LONGITUDINAL_ONLY_EVENTS for event in self.events.names) and not any(sample.seatbeltUnlatched or sample.doorOpen for sample, _ in getattr(self, 'car_state_samples', ()))), intent=intent, native=native, car_state=CS, initialized=self.initialized, model_ready=bool(self.sm.all_checks(['modelV2', 'extrinsicsCalibration']) and @@ -1989,7 +1993,7 @@ class SelfdriveD: expired_healthy_source=getattr(self, 'aol_expired_healthy_source', False), previously_active=previous.lateral_active, hard_fault=bool( self.aol_selected_pair_future or self.aol_authority_lost or lost_active_aol or lost_permission or self.events.contains(ET.IMMEDIATE_DISABLE) or - self.events.contains(ET.SOFT_DISABLE)), + any(event.softDisable and event.name not in LONGITUDINAL_ONLY_EVENTS for event in self.events.to_msg())), model_ready=bool(self.sm.all_checks(['modelV2', 'extrinsicsCalibration']) and self.sm['extrinsicsCalibration'].calStatus == log.ExtrinsicsCalibration.Status.calibrated), user_disable=self.events.contains(ET.USER_DISABLE)) @@ -2057,7 +2061,7 @@ class SelfdriveD: from openpilot.starpilot.aol.seatbelt import seatbelt_lateral_soft_event_allowed reverse_pause = known_reverse_pause(CS, getattr(self, 'car_state_samples', ())) aol_hard_soft_fault = any(event.softDisable and not ( - event.name == EventName.doorOpen or + event.name == EventName.doorOpen or event.name in LONGITUDINAL_ONLY_EVENTS or event.name == EventName.wrongGear and reverse_pause or event.name == EventName.steerTempUnavailable and CS.steerFaultTemporary and not CS.steerFaultPermanent or seatbelt_lateral_soft_event_allowed(self.CP, CS, intent, native, event, @@ -2075,7 +2079,8 @@ class SelfdriveD: self.events.add(EventName.controlsMismatch) lost_active_aol |= pause was_enabled = self.enabled - if self.aol_replay and source_current and CS.doorOpen and was_enabled: + if self.aol_replay and source_current and was_enabled and ( + CS.doorOpen or any(event in LONGITUDINAL_ONLY_EVENTS for event in self.events.names)): # Ordinary cruise is withdrawn; its legacy permission is no longer expected. self.aol_availability_long_inhibit = True if not self.CP.passive and self.initialized: @@ -2146,9 +2151,10 @@ class SelfdriveD: previous.lateral_active and previous.native_acknowledged and not self.aol_axis_decision.lateral_active and self.aol_fault_reason in ('critical', 'availabilityPause') and not self.aol_authority_lost and intent is not None and intent.allowedLatch and - any(event.softDisable and event.name != EventName.doorOpen for event in self.events.to_msg()) and + any(event.softDisable and event.name not in (EventName.doorOpen, *LONGITUDINAL_ONLY_EVENTS) + for event in self.events.to_msg()) and not self.events.contains(ET.IMMEDIATE_DISABLE)): - # Door-open does not inhibit independent steering; ordinary LONG keeps its alert. + # Lateral-only operation keeps ordinary longitudinal faults visible. self.aol_soft_takeover_alert = True if (self.aol_replay and intent is not None and intent.lateralArmed and CS.steerFaultTemporary and not CS.steerFaultPermanent and not CS.steeringPressed): diff --git a/openpilot/starpilot/aol/tests/test_radar_lateral_isolation.py b/openpilot/starpilot/aol/tests/test_radar_lateral_isolation.py new file mode 100644 index 0000000000..13286b20e5 --- /dev/null +++ b/openpilot/starpilot/aol/tests/test_radar_lateral_isolation.py @@ -0,0 +1,170 @@ +"""Production shared event/axis/Controls callers with controlled native peers.""" +from contextlib import ExitStack +import os +from unittest import mock + +import pytest +from openpilot.cereal import messaging +from openpilot.common.prefix import OpenpilotPrefix +from openpilot.selfdrive.selfdrived.events import EventName +from openpilot.starpilot.aol.tests import test_ioniq_availability as fixture +from openpilot.starpilot.aol.tests.test_vehicle_policy_cache import profile + +FAMILIES = ('sdgm', 'ascm', 'flashed', 'bolt2017', 'bolt2018', 'bolt2022', 'boltacc', + 'honda', 'hyundai', 'toyota', 'ford', 'mazda', 'tesla') + + +def params(family): + from opendbc.car.gm.values import CAR + if family == 'sdgm': + from opendbc.car.gm.tests.test_volt_sdgm_control import sdgm_params + return sdgm_params(alpha=True, sascm=True, radar=True) + if family == 'ascm': + from opendbc.car.gm.tests.test_bolt_volt_configurations import ordinary_params + return ordinary_params(CAR.CHEVROLET_VOLT_ASCM, alpha=True, sascm=True, radar=True) + if family == 'flashed': + from opendbc.car.gm.tests.test_volt_flashed_obd import params as flashed + return flashed() + if family.startswith('bolt'): + from opendbc.car.gm.tests.test_bolt_pedal import params as bolt + candidate = {'bolt2017': CAR.CHEVROLET_BOLT_CC_2017, 'bolt2018': CAR.CHEVROLET_BOLT_CC_2018_2021, + 'bolt2022': CAR.CHEVROLET_BOLT_CC_2022_2023, 'boltacc': CAR.CHEVROLET_BOLT_ACC_2022_2023_PEDAL}[family] + return bolt(candidate, pedal=True, alpha_long=True, camera=True) + return profile(family) + + +def callers(cleanup, family, full_op=False): + case = fixture.IoniqAvailabilityTests(methodName='runTest') + cleanup.callback(case.doCleanups) + cleanup.enter_context(OpenpilotPrefix()) + cleanup.enter_context(mock.patch.dict(os.environ, {'SIMULATION': '0', 'AOL_REPLAY_RUNTIME': '1'})) + return case._authority_callers(full_op, cp=params(family), genuine_events=True) + + +def radar_fault(sd, error, *, other=None): + original = sd.update_events + def update(cs): + radar = messaging.new_message('radarState', valid=True) + setattr(radar.radarState.radarErrors, error, True) + sd.sm.data['radarState'] = radar.radarState.as_reader() + original(cs) + # Stock-long ports do not consume radarState, but share lateral event policy. + if not sd.CP.openpilotLongitudinalControl: + sd.events.add(EventName.radarTempUnavailable if error == 'radarUnavailableTemporary' else EventName.radarFault) + if other is not None: + sd.events.add(other) + return mock.patch.object(sd, 'update_events', side_effect=update) + + +@pytest.mark.parametrize('family', FAMILIES) +@pytest.mark.parametrize('error', ('radarFault', 'radarUnavailableTemporary', 'canError')) +def test_radar_only_fault_keeps_lateral_for_every_port(family, error): + with ExitStack() as cleanup: + sd, _, step = callers(cleanup, family) + for tick in (0, 1): + axis, _, command = step(tick, native_request_long=False, panda_controls_allowed=False) + assert axis.lateralActive and command.latActive + expected = EventName.radarTempUnavailable if error == 'radarUnavailableTemporary' else EventName.radarFault + with radar_fault(sd, error): + for tick in range(2, 22): + axis, host, command = step(tick, native_request_long=False, panda_controls_allowed=False) + assert expected in sd.events.names + assert axis.lateralActive and command.latActive + assert not host.enabled and not command.longActive and not axis.longitudinalActive + assert not sd.aol_authority_lost and str(axis.faultReason) == 'none' + assert EventName.controlsMismatch not in sd.events.names + + +@pytest.mark.parametrize('family', ('sdgm', 'ascm', 'bolt2018', 'boltacc', 'honda')) +def test_radar_withdraws_long_immediately_without_lateral_loss_or_auto_resume(family): + with ExitStack() as cleanup: + sd, _, step = callers(cleanup, family, full_op=True) + assert sd.CP.openpilotLongitudinalControl + cs = fixture.car_state() + cs.cruiseState.enabled = True + for tick in (0, 1): + axis, host, command = step(tick, observed_state=cs) + assert host.enabled and command.longActive and command.latActive + with radar_fault(sd, 'canError'): + for tick in range(2, 322): + axis, host, command = step(tick, observed_state=cs, native_request_long=False, + long_allowed=False, panda_controls_allowed=False) + assert EventName.radarFault in sd.events.names and EventName.canError not in sd.events.names + assert not host.enabled and not command.longActive and not axis.longitudinalActive + assert axis.lateralActive and command.latActive + assert EventName.controlsMismatch not in sd.events.names + for tick in range(322, 330): + axis, host, command = step(tick, native_request_long=False, long_allowed=False, panda_controls_allowed=False) + assert command.latActive and not command.longActive and not host.enabled + + +@pytest.mark.parametrize('fault', ('canError', 'canBusMissing', 'lowMemory', 'steerUnavailable', 'modeldLagging')) +def test_radar_never_hides_other_faults(fault): + with ExitStack() as cleanup: + sd, _, step = callers(cleanup, 'sdgm') + step(0, native_request_long=False) + with radar_fault(sd, 'canError', other=getattr(EventName, fault)): + axis, _, command = step(1, native_request_long=False) + assert not command.latActive and not axis.lateralActive + assert EventName.radarFault in sd.events.names and getattr(EventName, fault) in sd.events.names + + +@pytest.mark.parametrize('negative', ({'allowed': False}, {'native_valid': False}, {'panda_fault': True}, {'calibrated': False})) +def test_radar_cannot_bypass_native_or_model_health(negative): + with ExitStack() as cleanup: + sd, _, step = callers(cleanup, 'sdgm') + step(0, native_request_long=False) + with radar_fault(sd, 'canError'): + for tick in range(1, 31): + axis, _, command = step(tick, native_request_long=False, **negative) + assert not axis.lateralActive and not command.latActive + + +@pytest.mark.parametrize('service', ('radarState', 'modelV2')) +@pytest.mark.parametrize('health', ('valid', 'alive', 'freq_ok')) +def test_only_radar_service_failure_is_longitudinal(service, health): + with ExitStack() as cleanup: + sd, _, step = callers(cleanup, 'sdgm') + step(0, native_request_long=False) + original = sd.update_events + def update(cs): + getattr(sd.sm, health)[service] = False + original(cs) + with mock.patch.object(sd, 'update_events', side_effect=update): + axis, _, command = step(1, native_request_long=False) + if service == 'radarState': + assert EventName.radarFault in sd.events.names + assert axis.lateralActive and command.latActive + else: + assert not axis.lateralActive and not command.latActive + + +@pytest.mark.parametrize('names,expected', ((['radard'], EventName.radarFault), + (['radard', 'modeld'], EventName.processNotRunning))) +def test_dead_radard_does_not_hide_other_processes(names, expected): + with ExitStack() as cleanup: + sd, _, step = callers(cleanup, 'sdgm') + step(0, native_request_long=False) + with mock.patch('openpilot.selfdrive.selfdrived.selfdrived.driving_process_failures', return_value=names): + axis, _, command = step(1, native_request_long=False) + assert expected in sd.events.names + assert bool(axis.lateralActive and command.latActive) == (names == ['radard']) + + +@pytest.mark.parametrize('family', ('sdgm', 'honda')) +def test_radar_fault_present_at_start_does_not_require_prior_engagement(family): + with ExitStack() as cleanup: + sd, _, step = callers(cleanup, family) + with radar_fault(sd, 'radarFault'): + for tick in range(20): + axis, host, command = step(tick, native_request_long=False, panda_controls_allowed=False) + assert axis.lateralActive and command.latActive and not host.enabled and not command.longActive + + +def test_radar_exception_does_not_arm_unrequested_aol(): + with ExitStack() as cleanup: + sd, _, step = callers(cleanup, 'sdgm', full_op=True) + step(0, latched=False) + with radar_fault(sd, 'radarFault'): + axis, host, command = step(1, latched=False, native_request_long=False, panda_controls_allowed=False) + assert not axis.lateralActive and not command.latActive and not host.enabled and not command.longActive diff --git a/openpilot/starpilot/nostalgia.py b/openpilot/starpilot/nostalgia.py index 9e3f0c5898..fd15d2d498 100644 --- a/openpilot/starpilot/nostalgia.py +++ b/openpilot/starpilot/nostalgia.py @@ -7,6 +7,9 @@ from openpilot.starpilot.longitudinal.ioniq6_start import eligible as ioniq6_lon from openpilot.starpilot.saved_source import read_saved +LONGITUDINAL_ONLY_EVENTS = (EventName.radarFault, EventName.radarTempUnavailable) + + ButtonType = car.CarState.ButtonEvent.Type @@ -40,7 +43,8 @@ def physical_cancel(cs, cp=None) -> bool: def aol_no_entry(event_names, cs, *, paddle_only_cancel: bool, cruise_main_required: bool = True, allow_below_engage_speed: bool = False, allow_lateral_cancel: bool = False) -> bool: """Apply vehicle-owned lateral exceptions without changing ordinary cruise events.""" - return any(event not in (EventName.pedalPressed, EventName.seatbeltNotLatched, EventName.resumeBlocked, EventName.doorOpen) and + return any(event not in (EventName.pedalPressed, EventName.seatbeltNotLatched, EventName.resumeBlocked, EventName.doorOpen, + *LONGITUDINAL_ONLY_EVENTS) and ET.NO_ENTRY in EVENTS.get(event, {}) and (event != EventName.belowEngageSpeed or not allow_below_engage_speed) and (event != EventName.wrongCarMode or cruise_main_required) and diff --git a/prebuilt.json b/prebuilt.json index 2838af9608..8cfd586f0c 100644 --- a/prebuilt.json +++ b/prebuilt.json @@ -1 +1 @@ -{"protected_inventory_sha256": "59cca55bbc8f9f8d42bee954161022f1014f9087f480d4b0b77ff6b27116542a", "schema_version": 1} +{"protected_inventory_sha256": "fbedd0567ba8be7ec8af9fb677658ab0c903b46a4a4975b74ebcefd02289d021", "schema_version": 1}