From 7810b340f7595dafac69396e7e6408e2a94fdf45 Mon Sep 17 00:00:00 2001 From: firestar5683 <168790843+firestar5683@users.noreply.github.com> Date: Fri, 25 Sep 2026 14:53:19 -0500 Subject: [PATCH] Runtime AOL Register vehicle AOL adapters and runtime dispatch after the vehicle factories and native contracts exist. Co-authored-by: Dom <168790843+firestar5683@users.noreply.github.com> --- .../safety/tests/test_honda_aol_family.py | 198 +++ .../car/tests/test_bolt_cruise_event.py | 34 + .../car/tests/test_cruise_intervals.py | 109 ++ .../tests/test_distance_personality_claim.py | 261 ++++ .../selfdrive/car/tests/test_honda_aol.py | 249 ++++ .../selfdrive/car/tests/test_hyundai_aol.py | 410 +++++++ .../car/tests/test_init_lifecycle.py | 115 ++ openpilot/starpilot/aol/defaults.py | 28 + openpilot/starpilot/aol/dm.py | 63 + openpilot/starpilot/aol/intent_companion.py | 171 +++ openpilot/starpilot/aol/runtime.py | 239 ++++ openpilot/starpilot/aol/seatbelt.py | 30 + .../starpilot/aol/source_availability.py | 275 +++++ openpilot/starpilot/aol/tests/__init__.py | 0 .../aol/tests/test_forte_integration.py | 262 ++++ .../starpilot/aol/tests/test_forte_intent.py | 223 ++++ openpilot/starpilot/aol/tests/test_gm_ascm.py | 78 ++ .../aol/tests/test_intent_publication.py | 133 ++ .../aol/tests/test_ioniq6_stock_repair.py | 86 ++ openpilot/starpilot/aol/tests/test_runtime.py | 706 +++++++++++ .../aol/tests/test_stock_forte_factory.py | 71 ++ openpilot/starpilot/aol/tests/test_vehicle.py | 20 + openpilot/starpilot/aol/transport_pause.py | 1083 +++++++++++++++++ openpilot/starpilot/aol/vehicle.py | 96 ++ openpilot/starpilot/car/gm/aol.py | 238 +++- .../starpilot/car/gm/steering_companion.py | 59 + .../car/hyundai/tests/test_lateral_fault.py | 87 ++ 27 files changed, 5320 insertions(+), 4 deletions(-) create mode 100644 opendbc_repo/opendbc/safety/tests/test_honda_aol_family.py create mode 100644 openpilot/selfdrive/car/tests/test_bolt_cruise_event.py create mode 100644 openpilot/selfdrive/car/tests/test_cruise_intervals.py create mode 100644 openpilot/selfdrive/car/tests/test_distance_personality_claim.py create mode 100644 openpilot/selfdrive/car/tests/test_honda_aol.py create mode 100644 openpilot/selfdrive/car/tests/test_hyundai_aol.py create mode 100644 openpilot/selfdrive/car/tests/test_init_lifecycle.py create mode 100644 openpilot/starpilot/aol/defaults.py create mode 100644 openpilot/starpilot/aol/dm.py create mode 100644 openpilot/starpilot/aol/intent_companion.py create mode 100644 openpilot/starpilot/aol/runtime.py create mode 100644 openpilot/starpilot/aol/seatbelt.py create mode 100644 openpilot/starpilot/aol/source_availability.py create mode 100644 openpilot/starpilot/aol/tests/__init__.py create mode 100644 openpilot/starpilot/aol/tests/test_forte_integration.py create mode 100644 openpilot/starpilot/aol/tests/test_forte_intent.py create mode 100644 openpilot/starpilot/aol/tests/test_gm_ascm.py create mode 100644 openpilot/starpilot/aol/tests/test_intent_publication.py create mode 100644 openpilot/starpilot/aol/tests/test_ioniq6_stock_repair.py create mode 100644 openpilot/starpilot/aol/tests/test_runtime.py create mode 100644 openpilot/starpilot/aol/tests/test_stock_forte_factory.py create mode 100644 openpilot/starpilot/aol/tests/test_vehicle.py create mode 100644 openpilot/starpilot/aol/transport_pause.py create mode 100644 openpilot/starpilot/aol/vehicle.py create mode 100644 openpilot/starpilot/car/gm/steering_companion.py create mode 100644 openpilot/starpilot/car/hyundai/tests/test_lateral_fault.py diff --git a/opendbc_repo/opendbc/safety/tests/test_honda_aol_family.py b/opendbc_repo/opendbc/safety/tests/test_honda_aol_family.py new file mode 100644 index 0000000000..406540cacd --- /dev/null +++ b/opendbc_repo/opendbc/safety/tests/test_honda_aol_family.py @@ -0,0 +1,198 @@ +"""Actual classic-Bosch Honda CP/controller frames against the AOL safety hooks.""" + +import unittest +from types import SimpleNamespace + +from opendbc.car import gen_empty_fingerprint, structs +from opendbc.car.honda.carcontroller import CarController +from opendbc.car.honda.interface import CarInterface +from opendbc.car.honda.values import CAR, CarControllerParams, HondaSafetyFlags +from opendbc.safety.tests.libsafety import libsafety_py +from opendbc.safety.tests.test_honda import Btn +from openpilot.starpilot.car.honda.aol import CLASSIC_BOSCH_AOL_CARS, qualified_honda +from openpilot.starpilot.aol.runtime import decide_axes + + +def _frames(candidate, cp, *, lat: bool, long: bool): + cc = structs.CarControl() + cc.enabled = lat or long + cc.latActive = lat + cc.longActive = long + cc.actuators.torque = 0.01 + cc.actuators.accel = 1.0 + state = SimpleNamespace(out=structs.CarState(), v_cruise_factor=1.0, is_metric=True, acc_hud={}, lkas_hud={}) + state.out.vEgo = 20.0 + state.out.cruiseState.available = True + controller = CarController(candidate.config.dbc_dict, cp) + _, sent = controller.update(cc.as_reader(), state, 0) + return {msg[0]: msg for msg in sent if isinstance(msg, tuple)} + + +def _packet(frame): + address, data, bus = frame + return libsafety_py.make_CANPacket(address, bus, data) + + +class TestHondaAolFamily(unittest.TestCase): + def test_real_controller_frames_obey_native_four_axis_masks(self): + if libsafety_py.libsafety.set_safety_hooks(structs.CarParams.SafetyModel.allOutput, 0) != 0: + self.skipTest('Independent AOL axes require ALLOW_DEBUG firmware') + from opendbc.safety.tests.test_honda import TestHondaBoschLongSafety + gas_lookup = list(CarControllerParams.BOSCH_GAS_LOOKUP_V) + try: + for candidate in sorted(CLASSIC_BOSCH_AOL_CARS, key=str): + for detected_alt in ((False, True) if candidate == CAR.HONDA_ACCORD else (False,)): + with self.subTest(candidate=candidate, detected_alt=detected_alt): + CarControllerParams.BOSCH_GAS_LOOKUP_V = list(gas_lookup) + fingerprint = gen_empty_fingerprint() + if detected_alt: + fingerprint[1][0x1BE] = 3 + cp = CarInterface.get_params(candidate, fingerprint, [], True, False, False) + self.assertTrue(qualified_honda(cp)) + self.assertEqual(bool(cp.safetyConfigs[0].safetyParam & HondaSafetyFlags.ALT_BRAKE), + candidate in (CAR.HONDA_CRV_5G, CAR.ACURA_RDX_3G) or detected_alt) + + for mode, lat, long in (("off", False, False), ("lateralOnly", True, False), + ("longitudinalOnly", False, True), ("combined", True, True)): + with self.subTest(mode=mode): + native = SimpleNamespace(requestedLateral=lat, requestedLongitudinal=long, + lateralAllowed=lat, longitudinalAllowed=long) + state = structs.CarState(canValid=True, gearShifter=structs.CarState.GearShifter.drive, vEgo=20.0) + intent = SimpleNamespace(allowedLatch=lat, pauseLateral=not lat, pauseLongitudinal=not long) + decision = decide_axes(standard_lateral=False, standard_longitudinal=long, intent=intent, + native=native, car_state=state, initialized=True, model_ready=True, + no_entry=False, immediate_disable=False, dm_lockout=False, pause_brake_mps=5.0) + self.assertEqual(decision.mode, mode) + frames = _frames(candidate, cp, lat=decision.lateral_active, long=decision.longitudinal_active) + self.assertEqual(frames[0xE4][2], 1) + self.assertEqual(frames[0x1DF][2], 1) + + fixture = TestHondaBoschLongSafety('test_diagnostics') + fixture.setUp() + safety = fixture.safety + safety.set_safety_hooks(structs.CarParams.SafetyModel.hondaBosch, + int(cp.safetyConfigs[0].safetyParam) | int(HondaSafetyFlags.AOL_BOSCH_LONG)) + safety.init_tests() + safety.set_timer(1_000_000) + safety.set_aol_test_heartbeat(True) + fixture._rx(fixture._acc_state_msg(True)) + fixture._rx(fixture._button_msg(Btn.NONE)) + fixture._rx(fixture._speed_msg(20)) + fixture._rx(fixture._powertrain_data_msg()) + if cp.safetyConfigs[0].safetyParam & HondaSafetyFlags.ALT_BRAKE: + fixture._rx(fixture._alt_brake_msg(0)) + if long: + fixture._rx(fixture._button_msg(Btn.SET)) + fixture._rx(fixture._button_msg(Btn.NONE)) + self.assertTrue(safety.get_controls_allowed()) + safety.aol_set_host_request(int(lat) | (int(long) << 1)) + self.assertEqual(safety.aol_get_permission_mask(), int(lat) | (int(long) << 1)) + steer = _packet(frames[0xE4]) + acc = _packet(frames[0x1DF]) + self.assertTrue(safety.safety_tx_hook(steer)) + self.assertTrue(safety.safety_tx_hook(acc)) + + active_steer = _frames(candidate, cp, lat=True, long=False)[0xE4] + active_acc = _frames(candidate, cp, lat=False, long=True)[0x1DF] + if not lat: + self.assertFalse(safety.safety_tx_hook(_packet(active_steer))) + if not long: + self.assertFalse(safety.safety_tx_hook(_packet(active_acc))) + finally: + CarControllerParams.BOSCH_GAS_LOOKUP_V = gas_lookup + + def test_native_main_brake_source_and_health_for_both_safety_params(self): + if libsafety_py.libsafety.set_safety_hooks(structs.CarParams.SafetyModel.allOutput, 0) != 0: + self.skipTest('Independent AOL axes require ALLOW_DEBUG firmware') + from opendbc.safety.tests.test_honda import TestHondaBoschLongSafety + + for alt_brake in (False, True): + with self.subTest(alt_brake=alt_brake): + fixture = TestHondaBoschLongSafety('test_diagnostics') + fixture.setUp() + safety = fixture.safety + safety.set_safety_hooks(structs.CarParams.SafetyModel.hondaBosch, + int(HondaSafetyFlags.BOSCH_LONG | HondaSafetyFlags.AOL_BOSCH_LONG | + (HondaSafetyFlags.ALT_BRAKE if alt_brake else 0))) + safety.init_tests() + safety.set_timer(1_000_000) + safety.set_aol_test_heartbeat(True) + fixture._rx(fixture._acc_state_msg(True)) + fixture._rx(fixture._button_msg(Btn.NONE)) + fixture._rx(fixture._speed_msg(20)) + fixture._rx(fixture._powertrain_data_msg()) + safety.aol_set_host_request(1) + self.assertEqual(safety.aol_get_permission_mask(), 0 if alt_brake else 1) + if alt_brake: + wrong_bus = fixture._alt_brake_msg(0) + wrong_bus[0].bus = 0 + fixture._rx(wrong_bus) + self.assertEqual(safety.aol_get_permission_mask(), 0) + bad_checksum = fixture._alt_brake_msg(0) + bad_checksum[0].data[2] ^= 1 + self.assertFalse(fixture._rx(bad_checksum)) + self.assertEqual(safety.aol_get_permission_mask(), 0) + safety.set_safety_hooks(structs.CarParams.SafetyModel.hondaBosch, + int(HondaSafetyFlags.BOSCH_LONG | HondaSafetyFlags.AOL_BOSCH_LONG | + HondaSafetyFlags.ALT_BRAKE)) + safety.init_tests() + safety.set_timer(1_000_000) + safety.set_aol_test_heartbeat(True) + fixture._rx(fixture._acc_state_msg(True)) + fixture._rx(fixture._button_msg(Btn.NONE)) + fixture._rx(fixture._speed_msg(20)) + fixture._rx(fixture._powertrain_data_msg()) + fixture._rx(fixture._alt_brake_msg(0)) + safety.aol_set_host_request(1) + self.assertEqual(safety.aol_get_permission_mask(), 1) + fixture._rx(fixture._alt_brake_msg(1)) + self.assertTrue(safety.get_brake_pressed_prev()) + self.assertEqual(safety.aol_get_permission_mask(), 1) # native lateral permission is separate from brake-paused host intent + fixture._rx(fixture._alt_brake_msg(0)) + safety.aol_set_host_request(1) + self.assertEqual(safety.aol_get_permission_mask(), 1) + safety.set_timer(1_300_001) + self.assertEqual(safety.aol_get_permission_mask(), 0) + safety.set_timer(1_400_000) + fixture._rx(fixture._acc_state_msg(True)) + fixture._rx(fixture._button_msg(Btn.NONE)) + fixture._rx(fixture._speed_msg(20)) + fixture._rx(fixture._powertrain_data_msg()) + if alt_brake: + fixture._rx(fixture._alt_brake_msg(0)) + safety.aol_set_host_request(1) + self.assertEqual(safety.aol_get_permission_mask(), 1) + fixture._rx(fixture._button_msg(Btn.CANCEL)) + self.assertEqual(safety.aol_get_permission_mask(), 0) + safety.aol_set_host_request(1) + safety.set_aol_test_heartbeat(False) + self.assertEqual(safety.aol_get_permission_mask(), 0) + safety.set_aol_test_heartbeat(True) + fixture._rx(fixture._acc_state_msg(False)) + safety.aol_set_host_request(1) + self.assertEqual(safety.aol_get_permission_mask(), 0) + + +class TestHondaAolFamilyRelease(unittest.TestCase): + def setUp(self): + self.gas_lookup = list(CarControllerParams.BOSCH_GAS_LOOKUP_V) + self.addCleanup(setattr, CarControllerParams, 'BOSCH_GAS_LOOKUP_V', self.gas_lookup) + + def test_release_firmware_never_advertises_family_axis_permission(self): + safety = libsafety_py.libsafety + if safety.set_safety_hooks(structs.CarParams.SafetyModel.allOutput, 0) == 0: + self.skipTest('Release-only denial contract') + for candidate in sorted(CLASSIC_BOSCH_AOL_CARS, key=str): + with self.subTest(candidate=candidate): + CarControllerParams.BOSCH_GAS_LOOKUP_V = list(self.gas_lookup) + cp = CarInterface.get_params(candidate, gen_empty_fingerprint(), [], True, False, False) + self.assertTrue(qualified_honda(cp)) + safety.set_safety_hooks(structs.CarParams.SafetyModel.hondaBosch, + int(cp.safetyConfigs[0].safetyParam) | int(HondaSafetyFlags.AOL_BOSCH_LONG)) + safety.init_tests() + safety.set_aol_test_heartbeat(True) + safety.aol_set_host_request(3) + self.assertEqual(safety.aol_get_permission_mask(), 0) + frames = _frames(candidate, cp, lat=True, long=True) + self.assertFalse(safety.safety_tx_hook(_packet(frames[0xE4]))) + self.assertFalse(safety.safety_tx_hook(_packet(frames[0x1DF]))) diff --git a/openpilot/selfdrive/car/tests/test_bolt_cruise_event.py b/openpilot/selfdrive/car/tests/test_bolt_cruise_event.py new file mode 100644 index 0000000000..a5aaeb2970 --- /dev/null +++ b/openpilot/selfdrive/car/tests/test_bolt_cruise_event.py @@ -0,0 +1,34 @@ +import unittest + +from opendbc.car import structs +from opendbc.car.gm.tests.test_bolt_cc import fixture, feed, control +from opendbc.car.gm.values import CAR +from openpilot.selfdrive.car.car_events import CarEvents, EventName +from openpilot.selfdrive.selfdrived.events import EVENTS, ET + + +SUPPORTED_NON_ACC = (CAR.CHEVROLET_BOLT_CC_2017, CAR.CHEVROLET_BOLT_CC_2018_2021, CAR.CHEVROLET_BOLT_CC_2022_2023) + + +class TestBoltCruiseModeEvent(unittest.TestCase): + def test_actual_bolt_ci_state_does_not_block_engagement_for_wrong_cruise_mode(self): + for identity in SUPPORTED_NON_ACC: + for removed in (False, True): + cp, ci, packer = fixture(identity, removed=removed) + state, _ = feed(ci, packer, 1_000_000_000, active=True, speed=20) + self.assertTrue(state.canValid) + self.assertFalse(cp.pcmCruise) + self.assertTrue(state.cruiseState.enabled) + events = CarEvents(cp).update(state.as_reader(), structs.CarState.new_message().as_reader(), control().as_reader()) + self.assertNotIn(EventName.wrongCruiseMode, events.names, (identity, removed)) + self.assertFalse(state.cruiseState.nonAdaptive) + + def test_wrong_mode_still_produces_stock_owner_no_entry_and_user_disable(self): + cp, _, _ = fixture(CAR.CHEVROLET_BOLT_EUV) + self.assertTrue(cp.pcmCruise) + state = structs.CarState.new_message() + state.cruiseState.nonAdaptive = True + events = CarEvents(cp).update(state.as_reader(), structs.CarState.new_message().as_reader(), control().as_reader()) + self.assertIn(EventName.wrongCruiseMode, events.names) + self.assertIn(ET.NO_ENTRY, EVENTS[EventName.wrongCruiseMode]) + self.assertIn(ET.USER_DISABLE, EVENTS[EventName.wrongCruiseMode]) diff --git a/openpilot/selfdrive/car/tests/test_cruise_intervals.py b/openpilot/selfdrive/car/tests/test_cruise_intervals.py new file mode 100644 index 0000000000..d68a82fdda --- /dev/null +++ b/openpilot/selfdrive/car/tests/test_cruise_intervals.py @@ -0,0 +1,109 @@ +"""Saved cruise intervals alter only Card's non-PCM button increments.""" + +import tempfile +import unittest +from pathlib import Path + +from opendbc.car.structs import car +from openpilot.common.params import Params +from openpilot.selfdrive.car.cruise import CRUISE_LONG_PRESS, IMPERIAL_INCREMENT, SlcPendingConfirmation, VCruiseHelper +from openpilot.starpilot.longitudinal.cruise_intervals import CruiseIntervals, read_cruise_intervals + + +Button = car.CarState.ButtonEvent.Type + + +def state(button=None, pressed=False, *, standstill=False): + events = [] if button is None else [car.CarState.ButtonEvent(type=button, pressed=pressed)] + return car.CarState(cruiseState={"available": True, "standstill": standstill}, buttonEvents=events) + + +class TestCruiseIntervals(unittest.TestCase): + def setUp(self): + temp = tempfile.TemporaryDirectory() + self.addCleanup(temp.cleanup) + self.params = Params(temp.name) + + def test_saved_children_ignore_retired_parent_and_keep_pcm_gate(self): + self.params.put("CustomCruise", 5.0, block=True) + self.params.put("CustomCruiseLong", 1.0, block=True) + self.assertEqual(read_cruise_intervals(self.params, pcm_cruise=False), CruiseIntervals(5, 1)) + self.params.put_bool("QOLLongitudinal", True, block=True) + self.assertEqual(read_cruise_intervals(self.params, pcm_cruise=False), CruiseIntervals(5, 1)) + self.assertEqual(read_cruise_intervals(self.params, pcm_cruise=True), CruiseIntervals()) + self.params.put_bool("QOLLongitudinal", False, block=True) + self.assertEqual(read_cruise_intervals(self.params, pcm_cruise=False), CruiseIntervals(5, 1)) + + def test_invalid_children_fall_back_without_enabling_other_features(self): + self.params.put_bool("QOLLongitudinal", True, block=True) + Path(self.params.get_param_path("CustomCruise")).write_bytes(b"nan") + Path(self.params.get_param_path("CustomCruiseLong")).write_bytes(b"200") + self.assertEqual(read_cruise_intervals(self.params, pcm_cruise=False), CruiseIntervals()) + + def helper(self, *, metric=True, custom=True, initial=67.0, pcm=False): + helper = VCruiseHelper(car.CarParams(pcmCruise=pcm)) + helper.v_cruise_kph = initial + if custom: + helper.intervals = CruiseIntervals(5, 1) + return helper + + @staticmethod + def step(helper, button=None, pressed=False, *, metric=True, pending=None, standstill=False): + helper.update_v_cruise(state(button, pressed, standstill=standstill), True, metric, pending) + + def test_metric_short_and_held_accel_and_decel(self): + for button, short_target, held_target in ((Button.accelCruise, 70, 71), (Button.decelCruise, 65, 64)): + with self.subTest(button=button): + helper = self.helper() + self.step(helper, button, True) + self.step(helper, button, False) + self.assertEqual(helper.v_cruise_kph, short_target) + self.assertEqual(helper.slc_cruise_change[2:], ("accel" if button == Button.accelCruise else "decel", False)) + self.step(helper, button, True) + for _ in range(CRUISE_LONG_PRESS - 1): + self.step(helper) + self.step(helper) + self.assertEqual(helper.v_cruise_kph, held_target) + self.step(helper, button, False) + self.assertEqual(helper.v_cruise_kph, held_target) + self.assertIsNone(helper.slc_cruise_change) + + def test_imperial_display_unit_increments(self): + helper = self.helper(initial=50 * IMPERIAL_INCREMENT) + self.step(helper, Button.accelCruise, True, metric=False) + self.step(helper, Button.accelCruise, False, metric=False) + self.assertAlmostEqual(helper.v_cruise_kph, 55 * IMPERIAL_INCREMENT) + self.step(helper, Button.accelCruise, True, metric=False) + for _ in range(CRUISE_LONG_PRESS): + self.step(helper, metric=False) + self.assertAlmostEqual(helper.v_cruise_kph, 56 * IMPERIAL_INCREMENT) + + def test_default_pcm_standstill_and_slc_ownership(self): + default = self.helper(custom=False, initial=60) + self.step(default, Button.accelCruise, True) + self.step(default, Button.accelCruise, False) + self.assertEqual(default.v_cruise_kph, 61) + + pcm = self.helper(pcm=True, initial=60) + self.step(pcm, Button.accelCruise, True) + self.step(pcm, Button.accelCruise, False) + self.assertEqual(pcm.v_cruise_kph, 255) + for pressed in (True, False): + pcm_state = state(Button.accelCruise, pressed) + pcm_state.cruiseState.speed = 20.0 + pcm_state.cruiseState.speedCluster = 21.0 + pcm.update_v_cruise(pcm_state, True, True) + self.assertEqual(pcm.v_cruise_kph, 72.0) + self.assertAlmostEqual(pcm.v_cruise_cluster_kph, 75.6) + + standstill = self.helper(initial=60) + self.step(standstill, Button.accelCruise, True, standstill=True) + self.step(standstill, Button.accelCruise, False) + self.assertEqual(standstill.v_cruise_kph, 60) + + slc = self.helper(initial=60) + pending = SlcPendingConfirmation("s", 1, 1) + self.step(slc, Button.accelCruise, True, pending=pending) + self.step(slc, Button.accelCruise, False) + self.assertEqual(slc.v_cruise_kph, 60) + self.assertTrue(slc.slc_released_suppressed) diff --git a/openpilot/selfdrive/car/tests/test_distance_personality_claim.py b/openpilot/selfdrive/car/tests/test_distance_personality_claim.py new file mode 100644 index 0000000000..903fe60d69 --- /dev/null +++ b/openpilot/selfdrive/car/tests/test_distance_personality_claim.py @@ -0,0 +1,261 @@ +"""Physical distance gestures reserve only their mapped custom action.""" + +import tempfile +import unittest +from types import SimpleNamespace as NS +from unittest.mock import MagicMock, patch + +from opendbc.car.structs import car +from openpilot.cereal import messaging +from openpilot.common.params import Params +from openpilot.selfdrive.car.card import Car +from openpilot.selfdrive.car.cruise import VCruiseHelper, CRUISE_LONG_PRESS +from openpilot.selfdrive.selfdrived.events import EventName, Events +from openpilot.selfdrive.selfdrived.selfdrived import SelfdriveD +from openpilot.starpilot.aol.intent import AolCardIntent, read_settings as read_aol_settings +from openpilot.selfdrive.car.tests.test_hyundai_aol import candidate as ioniq6_candidate +from openpilot.starpilot.conditional_mode.manual import ButtonTracker + + +ButtonType = car.CarState.ButtonEvent.Type + + +def state(*events): + return car.CarState(canValid=True, buttonEvents=[car.CarState.ButtonEvent(type=ButtonType.gapAdjustCruise, pressed=pressed) + for pressed in events]) + + +class Publisher: + def __init__(self): + self.messages = [] + + def send(self, service, message): + self.messages.append((service, message.to_bytes())) + + +class TestDistancePersonalityClaim(unittest.TestCase): + def setUp(self): + temp = tempfile.TemporaryDirectory() + self.addCleanup(temp.cleanup) + self.params = Params(temp.name) + _, long_cp = ioniq6_candidate(False) + self.cp = long_cp + self.publisher = Publisher() + self.card = Car.__new__(Car) + self.enterContext(patch.multiple(self.card, create=True, params=self.params, CP=self.cp, + distance_personality_tracker=ButtonTracker(), aol_card_intent=None, + sm=NS(frame=1, all_checks=lambda _: True), pm=self.publisher, + slc_replay=False, curve_replay=False, conditional_replay=False, aol_replay=False, + last_actuators_output=car.CarControl.Actuators(), can_rcv_cum_timeout_counter=0, + rk=NS(remaining=0.0), v_cruise_helper=VCruiseHelper(self.cp), slc_receipts=[])) + Car.observe_distance_personality(self.card, state(), 1) + + def observe(self, physical, tick): + Car.observe_distance_personality(self.card, physical, tick) + return physical + + def published(self, physical): + self.publisher.messages.clear() + Car.state_publish(self.card, physical, None) + return next(messaging.log_from_bytes(payload).carState for service, payload in self.publisher.messages + if service == 'carState') + + def test_custom_short_and_conditional_code_reserve_release(self): + for code in (3, 4, 5): + with self.subTest(code=code): + self.params.put('DistanceButtonControl', code, block=True) + self.observe(state(True), 10) + release = self.observe(state(False), 11) + self.assertEqual(len(self.published(release).buttonEvents), 0) + self.assertEqual(len(release.buttonEvents), 1) + self.params.put('DistanceButtonControl', 0, block=True) + self.observe(state(True), 12) + release = self.observe(state(False), 13) + self.assertEqual(len(self.published(release).buttonEvents), 1) + + def test_long_mapping_preserves_short_fallback_and_claims_later_release(self): + self.params.put('LongDistanceButtonControl', 3, block=True) + self.observe(state(True), 10) + self.assertEqual(len(self.published(self.observe(state(False), 11)).buttonEvents), 1) + self.observe(state(True), 20) + for tick in range(CRUISE_LONG_PRESS - 1): + self.observe(state(), 21 + tick) + self.params.put('LongDistanceButtonControl', 0, block=True) + release = self.observe(state(False), 100) + self.assertEqual(len(self.published(release).buttonEvents), 0) + self.observe(state(True), 101) + self.assertEqual(len(self.published(self.observe(state(False), 102)).buttonEvents), 1) + + def test_very_long_and_unqualified_keep_unmapped_fallback(self): + self.params.put('VeryLongDistanceButtonControl', 5, block=True) + self.observe(state(True), 10) + for tick in range(CRUISE_LONG_PRESS * 5 - 1): + self.observe(state(), 11 + tick) + self.assertEqual(len(self.published(self.observe(state(False), 300)).buttonEvents), 0) + stock, _ = ioniq6_candidate(False) + self.card.CP = stock + self.card.distance_personality_tracker = None + self.assertEqual(len(self.published(state(False)).buttonEvents), 1) + + def test_base_ioniq_long_without_aol_profile_reserves_conditional_gesture(self): + self.card.CP.safetyConfigs[0].safetyParam &= ~0x800 + self.params.put('DistanceButtonControl', 5, block=True) + self.observe(state(True), 10) + self.assertEqual(len(self.published(self.observe(state(False), 11)).buttonEvents), 0) + + def test_startup_held_and_invalid_can_do_not_inherit_a_claim(self): + self.params.put('DistanceButtonControl', 3, block=True) + self.params.put_bool('AlwaysOnLateral', True, block=True) + self.card.aol_card_intent = AolCardIntent(read_aol_settings(self.params), explicit_latch=True) + self.card.distance_personality_tracker = ButtonTracker() + pressed = state(True) + self.card.aol_card_intent.update(pressed) + self.observe(pressed, 10) + release = state(False) + self.card.aol_card_intent.update(release) + self.observe(release, 11) + self.assertFalse(self.card.aol_card_intent.pause_lateral) + self.assertEqual(len(self.published(release).buttonEvents), 1) + + self.observe(state(), 20) + self.card.aol_card_intent.update(state()) + self.params.put('LongDistanceButtonControl', 3, block=True) + self.observe(state(True), 21) + for tick in range(CRUISE_LONG_PRESS - 1): + self.observe(state(), 22 + tick) + self.assertIsNotNone(self.card.distance_personality_tracker.distance_claim) + self.observe(car.CarState(canValid=False), 80) + self.assertIsNone(self.card.distance_personality_tracker.distance_claim) + self.assertEqual(len(self.published(self.observe(state(False), 81)).buttonEvents), 1) + self.observe(state(), 82) + self.card.aol_card_intent.update(state()) + self.observe(state(True), 83) + self.card.aol_card_intent.update(state(True)) + self.observe(state(False), 84) + self.card.aol_card_intent.update(state(False)) + self.assertTrue(self.card.aol_card_intent.pause_lateral) + + self.observe(state(True), 90) + self.card.aol_card_intent.update(state(True)) + timeout = car.CarState(canValid=True, canTimeout=True) + self.observe(timeout, 91) + self.card.aol_card_intent.update(timeout) + self.assertIsNone(self.card.distance_personality_tracker.distance_claim) + self.observe(state(False), 92) + self.card.aol_card_intent.update(state(False)) + self.assertTrue(self.card.aol_card_intent.pause_lateral) + + def test_mid_press_saved_change_keeps_sampled_aol_action_reserved(self): + self.params.put('DistanceButtonControl', 3, block=True) + self.params.put_bool('AlwaysOnLateral', True, block=True) + self.card.aol_card_intent = AolCardIntent(read_aol_settings(self.params), explicit_latch=True) + self.card.aol_card_intent.update(state()) + pressed = state(True) + self.card.aol_card_intent.update(pressed) + self.observe(pressed, 10) + self.params.put('DistanceButtonControl', 0, block=True) + release = state(False) + self.card.aol_card_intent.update(release) + self.observe(release, 11) + self.assertTrue(self.card.aol_card_intent.pause_lateral) + self.assertEqual(len(self.published(release).buttonEvents), 0) + + def test_lkas_first_startup_interleaving_does_not_diverge_from_tracker(self): + self.params.put('DistanceButtonControl', 3, block=True) + self.params.put_bool('AlwaysOnLateral', True, block=True) + self.card.aol_card_intent = AolCardIntent(read_aol_settings(self.params), explicit_latch=True) + self.card.distance_personality_tracker = ButtonTracker() + lkas = car.CarState(canValid=True, buttonEvents=[car.CarState.ButtonEvent(type=ButtonType.lkas, pressed=True)]) + self.card.aol_card_intent.update(lkas) + self.observe(lkas, 10) + pressed = state(True) + self.card.aol_card_intent.update(pressed) + self.observe(pressed, 11) + release = state(False) + self.card.aol_card_intent.update(release) + self.observe(release, 12) + self.assertFalse(self.card.aol_card_intent.pause_lateral) + self.assertEqual(len(self.published(release).buttonEvents), 1) + + def test_selfdrived_only_cycles_for_unclaimed_serialized_release(self): + self.params.put('LongDistanceButtonControl', 3, block=True) + self.observe(state(True), 10) + short = self.published(self.observe(state(False), 11)) + self.observe(state(True), 20) + for tick in range(CRUISE_LONG_PRESS - 1): + self.observe(state(), 21 + tick) + claimed = self.published(self.observe(state(False), 100)) + self.assertEqual((len(short.buttonEvents), len(claimed.buttonEvents)), (1, 0)) + drive = SelfdriveD.__new__(SelfdriveD) + drive.CP = car.CarParams(openpilotLongitudinalControl=True, notCar=True) + drive.params = MagicMock() + drive.events = Events() + drive.personality = 1 + drive.initialized = True + drive.startup_event = None + drive.big_model_loading = False + drive.big_model_active = False + drive.big_model_failed = False + drive.big_model_ready_t = 0.0 + drive.enabled = False + drive.nostalgia_enabled = False + drive.CS_prev = car.CarState.new_message() + drive.dm_lockout_set = False + drive.dm_uncertain_alerted = False + drive.distance_traveled = 0.0 + drive.last_functional_fan_frame = 0 + drive.recalibrating_seen = False + drive.is_ldw_enabled = False + drive.calibrated_pose = None + drive.excessive_actuation = False + drive.not_running_prev = None + drive.logged_comm_issue = None + drive.sensor_packets = [] + drive.camera_packets = [] + drive.cruise_mismatch_counter = 0 + drive.last_steering_pressed_frame = 0 + drive.gps_location_service = 'gpsLocationExternal' + services = ('controlsState', 'deviceState', 'driverMonitoringState', 'peripheralState', + 'extrinsicsCalibration', 'modelV2', 'managerState', 'radarState', 'longitudinalPlan', + 'deviceMotion', 'vehicleParameters', 'carControl') + messages = {service: getattr(messaging.new_message(service), service) for service in services} + messages['pandaStates'] = [] + class FakeMaster: + frame = 1 + + def __init__(self): + self.updated = {} + self.seen = {} + self.valid = {} + self.alive = {} + self.freq_ok = {} + self.recv_frame = {} + + def __getitem__(self, key): + return messages[key] + + def all_checks(self, *_): + return True + + def all_alive(self, *_): + return True + + def all_freq_ok(self, *_): + return True + + self.enterContext(patch.multiple(drive, create=True, sm=FakeMaster(), rk=NS(lagging=False), + car_events=NS(update=lambda *_: NS(to_msg=list)))) + for service in (*services, 'userBookmark', 'alertDebug', 'lateralManeuverPlan', 'managerState', + 'gpsLocationExternal', 'deviceMotion', 'vehicleParameters'): + drive.sm.updated[service] = False + drive.sm.seen[service] = False + drive.sm.valid[service] = True + drive.sm.alive[service] = True + drive.sm.freq_ok[service] = True + drive.sm.recv_frame[service] = 0 + drive.update_events(short) + self.assertEqual(drive.personality, 0) + self.assertIn(EventName.personalityChanged, drive.events.names) + drive.update_events(claimed) + self.assertEqual(drive.personality, 0) + self.assertNotIn(EventName.personalityChanged, drive.events.names) diff --git a/openpilot/selfdrive/car/tests/test_honda_aol.py b/openpilot/selfdrive/car/tests/test_honda_aol.py new file mode 100644 index 0000000000..5594064186 --- /dev/null +++ b/openpilot/selfdrive/car/tests/test_honda_aol.py @@ -0,0 +1,249 @@ +"""Honda AOL host qualification and serialized integration tests.""" + +import time +import unittest +from unittest import mock + +from openpilot.cereal import log, messaging +from openpilot.common.prefix import OpenpilotPrefix +from opendbc.car.structs import car +from opendbc.car import gen_empty_fingerprint +from opendbc.car.interfaces import RadarInterfaceBase +from opendbc.car.honda.interface import CarInterface +from opendbc.car.honda.values import CAR, CarControllerParams, HondaFlags +from openpilot.common.params import Params +from openpilot.selfdrive.car.card import Car +from openpilot.selfdrive.selfdrived.selfdrived import SelfdriveD +from openpilot.selfdrive.controls.controlsd import Controls +from openpilot.starpilot.aol.intent import read_settings +from openpilot.starpilot.aol.wire import SafetyState, decode_intent, encode_safety +from openpilot.starpilot.car.honda.aol import CLASSIC_BOSCH_AOL_CARS, qualified_honda + + +def car_state(*, brake=False): + return car.CarState(canValid=True, gearShifter=car.CarState.GearShifter.drive, + vEgo=20.0, brakePressed=brake) + + +class HondaFamilyQualificationTests(unittest.TestCase): + def setUp(self): + # Some upstream interfaces set this shared table while constructing CP. + self.addCleanup(setattr, CarControllerParams, 'BOSCH_GAS_LOOKUP_V', list(CarControllerParams.BOSCH_GAS_LOOKUP_V)) + + def test_exact_classic_bosch_cp_family_and_rejections(self): + self.assertEqual(len(CLASSIC_BOSCH_AOL_CARS), 10) + all_honda = tuple(candidate for candidate in CAR if candidate.config.flags & HondaFlags.BOSCH) + for candidate in all_honda: + with self.subTest(candidate=candidate): + cp = CarInterface.get_params(candidate, gen_empty_fingerprint(), [], True, False, False) + self.assertEqual(qualified_honda(cp), candidate in CLASSIC_BOSCH_AOL_CARS) + if candidate in CLASSIC_BOSCH_AOL_CARS: + self.assertEqual(cp.brand, 'honda') + self.assertTrue(cp.openpilotLongitudinalControl) + self.assertFalse(cp.pcmCruise) + self.assertEqual(len(cp.safetyConfigs), 1) + self.assertIn(cp.safetyConfigs[0].safetyParam, (2, 3)) + cp.safetyConfigs[0].safetyParam |= 32 + self.assertTrue(qualified_honda(cp)) + cp.safetyConfigs[0].safetyParam |= 4 + self.assertFalse(qualified_honda(cp)) + + for candidate in CLASSIC_BOSCH_AOL_CARS: + with self.subTest(stock=candidate): + cp = CarInterface.get_params(candidate, gen_empty_fingerprint(), [], False, False, False) + self.assertFalse(qualified_honda(cp)) + + cp = CarInterface.get_params(CAR.HONDA_ACCORD, gen_empty_fingerprint(), [], True, False, False) + cp.passive = True + self.assertFalse(qualified_honda(cp)) + cp.passive = False + cp.dashcamOnly = True + self.assertFalse(qualified_honda(cp)) + cp.dashcamOnly = False + cp.brand = 'other' + self.assertFalse(qualified_honda(cp)) + cp.brand = 'honda' + cp.notCar = True + self.assertFalse(qualified_honda(cp)) + cp.notCar = False + cp.flags |= int(HondaFlags.BOSCH_ALT_RADAR) + self.assertFalse(qualified_honda(cp)) + cp.flags &= ~int(HondaFlags.BOSCH_ALT_RADAR) + cp.safetyConfigs[0].safetyModel = car.CarParams.SafetyModel.hondaNidec + self.assertFalse(qualified_honda(cp)) + cp.safetyConfigs[0].safetyModel = car.CarParams.SafetyModel.hondaBosch + cp.safetyConfigs = [car.CarParams.SafetyConfig(safetyModel=car.CarParams.SafetyModel.hondaBosch, safetyParam=2), + car.CarParams.SafetyConfig(safetyModel=car.CarParams.SafetyModel.noOutput, safetyParam=0)] + self.assertFalse(qualified_honda(cp)) + + def test_card_serializes_opt_in_only_for_actual_eligible_cp(self): + class FakeHonda: + CC = object() + CS = object() + + def __init__(self, candidate, alpha_long): + self.CP = CarInterface.get_params(candidate, gen_empty_fingerprint(), [], alpha_long, False, False) + + def init(self, _cp, _recv, _send): + pass + + class FakeRadar(RadarInterfaceBase): + def update(self, can_packets): + return None + + for candidate in sorted(CLASSIC_BOSCH_AOL_CARS, key=str): + for alpha_long in (False, True): + with self.subTest(candidate=candidate, alpha_long=alpha_long), OpenpilotPrefix(), \ + mock.patch.dict('os.environ', {'AOL_REPLAY_RUNTIME': '1', 'SIMULATION': '1'}): + messaging.reset_context() + params = Params() + params.put_bool('OpenpilotEnabledToggle', True, block=True) + params.put_bool('AlwaysOnLateral', True, block=True) + honda = FakeHonda(candidate, alpha_long) + card = Car(honda, FakeRadar(honda.CP)) + self.assertEqual(card.aol_qualified, alpha_long) + self.assertEqual(bool(card.CP.safetyConfigs[0].safetyParam & 32), alpha_long) + with car.CarParams.from_bytes(params.get('CarParams')) as published: + self.assertEqual(bool(published.safetyConfigs[0].safetyParam & 32), alpha_long) + + +class HondaHostTests(unittest.TestCase): + def test_real_car_params_selfdrive_controls_chain_fails_closed(self): + class FakeHonda: + CC = object() + CS = object() + + def __init__(self): + self.CP = CarInterface.get_params(CAR.HONDA_CIVIC_BOSCH, gen_empty_fingerprint(), [], True, False, False) + + def update(self, _can): + state = car_state() + state.cruiseState.available = True + return state + + def init(self, _cp, _recv, _send): + pass + + def apply(self, _control, _now_ns): + return car.CarControl.Actuators(), [] + + class FakeRadar(RadarInterfaceBase): + def update(self, can_packets): + return None + + with OpenpilotPrefix(), mock.patch.dict('os.environ', {'AOL_REPLAY_RUNTIME': '1', 'SIMULATION': '1'}): + messaging.reset_context() + params = Params() + params.put_bool('OpenpilotEnabledToggle', True, block=True) + params.put_bool('AlwaysOnLateral', True, block=True) + honda = FakeHonda() + card = Car(honda, FakeRadar(honda.CP)) + self.assertEqual(card.CP.safetyConfigs[-1].safetyParam & 0x20, 0x20) + with car.CarParams.from_bytes(params.get('CarParams')) as published: + self.assertEqual(published.safetyConfigs[-1].safetyParam & 0x20, 0x20) + selfdrive = SelfdriveD() # reads the actual card-published CarParams + controls = Controls() # reads the same serialized CarParams + self.assertTrue(selfdrive.aol_replay and controls.aol_replay) + can = messaging.PubMaster(['can']) + intent = messaging.SubMaster(['aolIntentWire']) + can.send('can', messaging.new_message('can', 1)) + card.step() + intent.update(100) + self.assertTrue(intent.seen['aolIntentWire']) + self.assertTrue(decode_intent(intent['aolIntentWire']).allowedLatch) + selfdrive.step() + controls.sm.update(100) + cc, _ = controls.state_control() + self.assertFalse(cc.latActive or cc.longActive) # no native capability/ack + self.assertEqual(selfdrive.aol_axis_decision.mode, 'off') + + source = messaging.PubMaster(['aolSafetyWire', 'modelV2', 'extrinsicsCalibration', 'driverMonitoringState']) + time.sleep(0.05) # allow native IPC subscriptions to connect before the first publication + model = messaging.new_message('modelV2') + model.valid = True + calibration = messaging.new_message('extrinsicsCalibration') + calibration.valid = True + calibration.extrinsicsCalibration.calStatus = log.ExtrinsicsCalibration.Status.calibrated + dm = messaging.new_message('driverMonitoringState') + dm.valid = True + def publish_native(*, requested_long=False): + native = messaging.new_message('aolSafetyWire', 0) + native.valid = True + observed = time.monotonic_ns() + native.aolSafetyWire = encode_safety(SafetyState( + 1, True, observed, observed + 200_000_000, int(car.CarParams.SafetyModel.hondaBosch), + card.CP.safetyConfigs[-1].safetyParam, True, requested_long, True, requested_long, + 'test-panda', selfdrive.aol_session_id)) + source.send('aolSafetyWire', native) + + for _ in range(3): + source.send('modelV2', model) + model.clear_write_flag() + source.send('extrinsicsCalibration', calibration) + calibration.clear_write_flag() + source.send('driverMonitoringState', dm) + dm.clear_write_flag() + publish_native() + selfdrive.sm.update(100) + controls.sm.update(0) + selfdrive.initialized = True + with mock.patch.object(selfdrive, 'update_events', side_effect=lambda _cs: selfdrive.events.clear()): + can.send('can', messaging.new_message('can', 1)) + card.step() + selfdrive.step() + self.assertEqual(selfdrive.aol_axis_decision.mode, 'lateralOnly') + controls.sm.update(0) + cc, _ = controls.state_control() + self.assertTrue(cc.latActive) + self.assertFalse(cc.longActive) + # The native long request may still reflect a previous combined mode; + # this must not interrupt unchanged lateral permission. + publish_native(requested_long=True) + controls.sm.update(0) + cc, _ = controls.state_control() + self.assertTrue(cc.latActive) + self.assertFalse(cc.longActive) + # On the reverse transition, an old lateral-only acknowledgment keeps + # lateral active while the newly requested longitudinal axis waits. + publish_native() + axis_msg = messaging.new_message('aolAxisState') + axis_msg.valid = True + axis = axis_msg.aolAxisState + axis.sessionId = selfdrive.aol_session_id + axis.qualified = True + axis.desiredLateral = True + axis.desiredLongitudinal = True + axis.lateralActive = True + axis.longitudinalActive = False + axis.nativeAcknowledged = True + axis.observedMonoTime = time.monotonic_ns() + axis.validUntilMonoTime = axis.observedMonoTime + 30_000_000 + selfdrive.pm.send('aolAxisState', axis_msg) + controls.sm.update(0) + cc, _ = controls.state_control() + self.assertTrue(cc.latActive) + self.assertFalse(cc.longActive) + + dm.driverMonitoringState.lockout = True + source.send('driverMonitoringState', dm) + with mock.patch.object(selfdrive, 'update_events', side_effect=lambda _cs: selfdrive.events.clear()): + can.send('can', messaging.new_message('can', 1)) + card.step() + selfdrive.step() + self.assertEqual(selfdrive.aol_axis_decision.mode, 'off') + controls.sm.update(100) + cc, _ = controls.state_control() + self.assertFalse(cc.latActive or cc.longActive) + + # Saved AOL off retains the negotiated protocol while ending extra lateral autonomy. + params.put_bool('AlwaysOnLateral', False, block=True) + assert card.aol_card_intent is not None + card.aol_card_intent.settings = read_settings(params) + selfdrive.aol_settings = read_settings(params) + can.send('can', messaging.new_message('can', 1)) + card.step() + selfdrive.step() + controls.sm.update(100) + cc, _ = controls.state_control() + self.assertFalse(cc.latActive or cc.longActive) + self.assertTrue(card.aol_qualified) diff --git a/openpilot/selfdrive/car/tests/test_hyundai_aol.py b/openpilot/selfdrive/car/tests/test_hyundai_aol.py new file mode 100644 index 0000000000..f045c7a0eb --- /dev/null +++ b/openpilot/selfdrive/car/tests/test_hyundai_aol.py @@ -0,0 +1,410 @@ +"""Ioniq 6 host requests remain gated by a separate exact native AOL profile.""" + +import unittest +import time +from types import SimpleNamespace +from unittest import mock + +from opendbc.can import CANParser +from opendbc.car import Bus, gen_empty_fingerprint +from opendbc.car.hyundai.tests.test_ioniq6_longitudinal import controller_fixture +from opendbc.car.hyundai.interface import CarInterface +from opendbc.car.hyundai.ioniq6_handoff import build_ioniq6_hda2_long_candidate +from opendbc.car.hyundai.values import CAR, DBC, HyundaiFlags +from opendbc.car.structs import car +from openpilot.cereal import messaging +from openpilot.common.params import Params +from openpilot.common.prefix import OpenpilotPrefix +from openpilot.selfdrive.controls.controlsd import Controls +from openpilot.starpilot.aol.intent import AolCardIntent, AolSettings +from openpilot.starpilot.aol.runtime import current_native, decide_axes, monitoring_lateral_engaged +from openpilot.starpilot.aol.vehicle import native_latch_rejected, policy_for +from openpilot.starpilot.aol.wire import SafetyState, encode_safety +from openpilot.starpilot.car.hyundai.aol import ioniq6_settings_capable, qualified_ioniq6 + + +def candidate(alt: bool): + fp = gen_empty_fingerprint() + steering, support = ((0x110, 0x362) if alt else (0x50, 0x2A4)) + fp[2][steering] = 32 if alt else 16 + fp[2][support] = 32 if alt else 24 + fp[0][0x3A5] = 24 + fp[1].update({0x1CF: 8, 0x1AA: 16, 0x35: 32, 0x175: 24, 0xA0: 24, 0xEA: 24, + 0x1BA: 24, 0x1E5: 16, 0x36A: 16}) + stock = CarInterface.get_params(CAR.HYUNDAI_IONIQ_6, fp, [], False, False, False) + long_cp = build_ioniq6_hda2_long_candidate(stock, fp) + assert long_cp is not None + return stock, long_cp + + +def state(): + return car.CarState(canValid=True, gearShifter=car.CarState.GearShifter.drive, vEgo=20.0) + + +class Ioniq6HostTests(unittest.TestCase): + def test_native_token_loss_resynchronizes_card_without_changing_native_safety(self): + from opendbc.safety.tests.test_hyundai_ioniq6_long import TestHyundaiIoniq6Long + + native_test = TestHyundaiIoniq6Long() + native_test.setUp() + if native_test.release: + self.skipTest("RELEASE denies the Ioniq 6 AOL profiles") + from opendbc.can import CANPacker + for alt, raw in ((False, 0x8815), (True, 0x8895)): + with self.subTest(alt=alt), OpenpilotPrefix(): + _, cp = candidate(alt) + cp.safetyConfigs[0].safetyParam = raw + native_test.mode(raw) + safety = native_test.safety + packer = CANPacker(DBC[cp.carFingerprint][Bus.pt]) + native_test.refresh_required_rx(packer, 1, 1_000_000) + safety.set_aol_test_heartbeat(True) + owner = AolCardIntent(AolSettings(True, 0, 0, 0, (0, 0, 0), (0, 0, 0)), explicit_latch=True) + cs = state() + owner.update(cs, now_ns=1_000_000_000) + sm = messaging.SubMaster(['aolSafetyWire']) + + def buttons(counter, pressed, safety=safety, packer=packer, cs=cs, owner=owner): + self.assertTrue(safety.safety_rx_hook(native_test.packet(packer.make_can_msg( + 'CRUISE_BUTTONS', 1, {'COUNTER': counter, 'LDA_BTN': int(pressed)})))) + cs.buttonEvents = [car.CarState.ButtonEvent(type=car.CarState.ButtonEvent.Type.lkas, pressed=pressed)] + owner.update(cs, now_ns=1_000_000_000 + counter * 10_000_000) + + def receipt(stamp, safety=safety, cp=cp, raw=raw, sm=sm): + requested, allowed = safety.aol_get_request_mask(), safety.aol_get_permission_mask() + message = messaging.new_message('aolSafetyWire', 0, valid=True) + message.logMonoTime = stamp + message.aolSafetyWire = encode_safety(SafetyState( + 1, True, stamp, stamp + 200_000_000, int(cp.safetyConfigs[0].safetyModel.raw), raw, + bool(allowed & 1), bool(allowed & 2), bool(requested & 1), bool(requested & 2), 'panda', 'selfdrived')) + sm.update_msgs(stamp / 1e9, [message.as_reader()]) + return current_native(sm, cp, now_ns=stamp) + + buttons(2, True) + safety.aol_set_host_request(1) + self.assertEqual(safety.aol_get_permission_mask(), 1) + buttons(3, False) + self.assertTrue(owner.allowed_latch) + # Brake/gas override affect neither the physical AOL token nor its host latch. + for name, values in (('TCS', {'DriverBraking': 1}), ('ACCELERATOR', {'ACCELERATOR_PEDAL': 100})): + self.assertTrue(safety.safety_rx_hook(native_test.packet(packer.make_can_msg(name, 1, values)))) + self.assertEqual(safety.aol_get_permission_mask(), 1) + native = receipt(1_040_000_000) + self.assertFalse(native_latch_rejected(cp, native)) + cs.buttonEvents = [] + cs.gasPressed, cs.brakePressed = True, True + owner.update(cs, now_ns=1_040_000_000) + self.assertTrue(owner.allowed_latch) + safety.set_aol_test_heartbeat(False) + self.assertEqual(safety.aol_get_permission_mask(), 0) + safety.set_aol_test_heartbeat(True) + safety.aol_set_host_request(1) + native = receipt(1_050_000_000) + self.assertTrue(native_latch_rejected(cp, native)) + owner.update(cs, now_ns=1_050_000_000, native_rejection_ns=native.observedMonoTime) + self.assertFalse(owner.allowed_latch) + self.assertEqual(safety.aol_get_permission_mask(), 0) + # Native neutral after its reset, then one real LKAS edge restores both. + buttons(6, False) + buttons(7, True) + safety.aol_set_host_request(1) + self.assertTrue(owner.allowed_latch) + self.assertEqual(safety.aol_get_permission_mask(), 1) + + def test_latch_feedback_requires_fresh_exact_native_receipt(self): + _, cp = candidate(False) + cp.safetyConfigs[0].safetyParam = 0x8815 + now = 2_000_000_000 + with OpenpilotPrefix(): + sm = messaging.SubMaster(['aolSafetyWire']) + for requested, allowed in ((False, False), (False, True), (True, True), (True, False)): + now += 100_000_000 + msg = messaging.new_message('aolSafetyWire', 0, valid=True) + msg.logMonoTime = now + msg.aolSafetyWire = encode_safety(SafetyState(1, True, now, now + 200_000_000, + int(cp.safetyConfigs[0].safetyModel.raw), 0x8815, allowed, False, requested, False, 'panda', 'session')) + sm.update_msgs(now / 1e9, [msg.as_reader()]) + self.assertEqual(native_latch_rejected(cp, current_native(sm, cp, now_ns=now)), requested and not allowed) + self.assertFalse(native_latch_rejected(cp, current_native(sm, cp, now_ns=now + 201_000_000))) + self.assertFalse(native_latch_rejected(cp, current_native(sm, cp, now_ns=now - 1))) + wrong = cp.as_reader().as_builder() + wrong.safetyConfigs[0].safetyParam = 0x8895 + self.assertFalse(native_latch_rejected(wrong, current_native(sm, wrong, now_ns=now))) + + def test_only_exact_tagged_hda2_long_cp_qualifies(self): + for alt, raw in ((False, 0x8815), (True, 0x8895)): + with self.subTest(alt=alt): + stock, long_cp = candidate(alt) + self.assertFalse(qualified_ioniq6(stock)) + self.assertFalse(qualified_ioniq6(long_cp)) + self.assertFalse(policy_for(long_cp).runtime_supported) + cp = long_cp.as_reader().as_builder() + cp.safetyConfigs[0].safetyParam = raw + self.assertTrue(qualified_ioniq6(cp)) + self.assertTrue(policy_for(cp).runtime_supported) + for wrong in (raw ^ 0x80, raw | 0x20, raw | 0x100, raw & ~0x8000, raw & ~0x800): + cp.safetyConfigs[0].safetyParam = wrong + self.assertFalse(qualified_ioniq6(cp), hex(wrong)) + cp.safetyConfigs[0].safetyParam = raw + for field, value in (('pcmCruise', True), ('openpilotLongitudinalControl', False), + ('radarUnavailable', True), ('notCar', True), ('passive', True), ('dashcamOnly', True)): + altered = cp.as_reader().as_builder() + setattr(altered, field, value) + self.assertFalse(qualified_ioniq6(altered), field) + altered = cp.as_reader().as_builder() + altered.flags |= int(HyundaiFlags.CANFD_ALT_BUTTONS) + self.assertFalse(qualified_ioniq6(altered)) + altered = cp.as_reader().as_builder() + altered.safetyConfigs[0].safetyModel = car.CarParams.SafetyModel.noOutput + self.assertFalse(qualified_ioniq6(altered)) + + def test_stock_settings_capability_is_separate_from_runtime(self): + for alt in (False, True): + stock, long_cp = candidate(alt) + self.assertTrue(ioniq6_settings_capable(stock)) + self.assertFalse(policy_for(stock).runtime_supported) + self.assertTrue(ioniq6_settings_capable(long_cp)) # LONG saved choices do not require AOL's separate bit. + tagged = long_cp.as_reader().as_builder() + tagged.safetyConfigs[0].safetyParam |= 0x800 + self.assertTrue(ioniq6_settings_capable(tagged)) + self.assertTrue(policy_for(tagged).runtime_supported) + changed = stock.as_reader().as_builder() + changed.safetyConfigs[0].safetyParam |= 0x20 + self.assertFalse(ioniq6_settings_capable(changed)) + changed = stock.as_reader().as_builder() + changed.flags |= int(HyundaiFlags.CANFD_ALT_BUTTONS) + self.assertFalse(ioniq6_settings_capable(changed)) + with mock.patch('openpilot.starpilot.car.hyundai.aol.IONIQ6_LONG_PREARM_ENABLED', False): + self.assertFalse(ioniq6_settings_capable(stock)) + + def test_tcs_availability_never_arms_explicit_lateral_latch(self): + settings = AolSettings(True, 0.0, 0, 0, (0, 0, 0), (0, 0, 0)) + owner = AolCardIntent(settings, explicit_latch=True) + cs = state() + cs.cruiseState.available = True + owner.update(cs) + self.assertFalse(owner.output(cs)[0]) + cs.buttonEvents = [car.CarState.ButtonEvent(type=car.CarState.ButtonEvent.Type.lkas, pressed=True)] + owner.update(cs) + self.assertTrue(owner.output(cs)[0]) + cs.buttonEvents = [car.CarState.ButtonEvent(type=car.CarState.ButtonEvent.Type.lkas, pressed=False)] + owner.update(cs) + cs.buttonEvents = [] + cs.brakePressed = True + cs.gasPressed = True + owner.update(cs) + self.assertTrue(owner.output(cs)[0]) + cs.buttonEvents = [car.CarState.ButtonEvent(type=car.CarState.ButtonEvent.Type.cancel, pressed=True)] + owner.update(cs) + self.assertFalse(owner.output(cs)[0]) + cs.buttonEvents = [car.CarState.ButtonEvent(type=car.CarState.ButtonEvent.Type.mainCruise, pressed=True)] + owner.update(cs) + self.assertTrue(owner.output(cs)[0]) + cs.buttonEvents = [] + cs.accFaulted = True + owner.update(cs) + self.assertFalse(owner.output(cs)[0]) + cs.accFaulted = False + cs.canValid = False + owner.update(cs) + self.assertFalse(owner.output(cs)[0]) + + def test_startup_held_and_simultaneous_buttons_have_one_combined_edge(self): + settings = AolSettings(True, 0.0, 0, 0, (0, 0, 0), (0, 0, 0)) + main = car.CarState.ButtonEvent.Type.mainCruise + lkas = car.CarState.ButtonEvent.Type.lkas + cs = state() + owner = AolCardIntent(settings, explicit_latch=True) + cs.buttonEvents = [car.CarState.ButtonEvent(type=main, pressed=True)] + owner.update(cs) + self.assertFalse(owner.output(cs)[0]) # held at initialization is ignored + cs.buttonEvents = [] + owner.update(cs) + self.assertFalse(owner.output(cs)[0]) + cs.buttonEvents = [car.CarState.ButtonEvent(type=main, pressed=False)] + owner.update(cs) + self.assertFalse(owner.output(cs)[0]) + cs.buttonEvents = [car.CarState.ButtonEvent(type=main, pressed=True), + car.CarState.ButtonEvent(type=lkas, pressed=True)] + owner.update(cs) + self.assertTrue(owner.output(cs)[0]) # one combined gesture, not two toggles + cs.buttonEvents = [car.CarState.ButtonEvent(type=main, pressed=False)] + owner.update(cs) + self.assertTrue(owner.output(cs)[0]) + cs.buttonEvents = [car.CarState.ButtonEvent(type=lkas, pressed=False)] + owner.update(cs) + self.assertTrue(owner.output(cs)[0]) + cs.buttonEvents = [car.CarState.ButtonEvent(type=lkas, pressed=True)] + owner.update(cs) + self.assertFalse(owner.output(cs)[0]) + + def test_native_reset_token_never_grants_without_matching_host_request(self): + settings = AolSettings(True, 0.0, 0, 0, (0, 0, 0), (0, 0, 0)) + owner = AolCardIntent(settings, explicit_latch=True) + cs = state() + owner.update(cs) # neutral baseline + button = car.CarState.ButtonEvent.Type.lkas + cs.buttonEvents = [car.CarState.ButtonEvent(type=button, pressed=True)] + owner.update(cs) + self.assertTrue(owner.output(cs)[0]) + cs.buttonEvents = [car.CarState.ButtonEvent(type=button, pressed=False)] + owner.update(cs) + # Native can revoke its idempotent token independently after heartbeat/RX + # loss; host's next deliberate toggle requests off, so a new native token + # cannot grant actuation until another explicit host-on gesture. + cs.buttonEvents = [car.CarState.ButtonEvent(type=button, pressed=True)] + owner.update(cs) + self.assertFalse(owner.output(cs)[0]) + + def test_four_axes_require_exact_native_readback(self): + for alt, raw in ((False, 0x8815), (True, 0x8895)): + _, cp = candidate(alt) + cp.safetyConfigs[0].safetyParam = raw + stamp = 1_000_000_000 + for standard_long, lateral_latch, expected in ((False, False, 'off'), (False, True, 'lateralOnly'), + (True, False, 'longitudinalOnly'), (True, True, 'combined')): + cp.safetyConfigs[0].safetyParam = raw + desired_lat, desired_long = lateral_latch, standard_long + wire = encode_safety(SafetyState(1, True, stamp, stamp + 200_000_000, + int(car.CarParams.SafetyModel.hyundaiCanfd), raw, + desired_lat, desired_long, desired_lat, desired_long, + 'panda', 'drive-session')) + class SafetySM: + valid = {'aolSafetyWire': True} + alive = {'aolSafetyWire': True} + seen = {'aolSafetyWire': True} + logMonoTime = {'aolSafetyWire': stamp} + + def __init__(self, payload): + self.payload = payload + + def __getitem__(self, key): + return self.payload + + sm = SafetySM(wire) + native = current_native(sm, cp, now_ns=stamp + 1_000_000, axis_session_id='drive-session') + self.assertIsNotNone(native) + intent = SimpleNamespace(allowedLatch=lateral_latch, pauseLateral=False, pauseLongitudinal=False) + decision = decide_axes(standard_lateral=False, standard_longitudinal=standard_long, + intent=intent, native=native, car_state=state(), initialized=True, + model_ready=True, no_entry=False, immediate_disable=False, dm_lockout=False, + pause_brake_mps=0.0) + self.assertEqual(decision.mode, expected) + self.assertEqual((decision.desired_lateral, decision.desired_longitudinal), (desired_lat, desired_long)) + cp.safetyConfigs[0].safetyParam = raw ^ 0x800 + self.assertIsNone(current_native(sm, cp, now_ns=stamp + 1_000_000, axis_session_id='drive-session')) + + def test_monitoring_requires_fresh_matching_native_lateral_permission(self): + stamp = 1_000_000_000 + class MonitorSM: + valid = {'aolAxisState': True, 'aolSafetyWire': True} + alive = {'aolAxisState': True, 'aolSafetyWire': True} + seen = {'aolAxisState': True, 'aolSafetyWire': True} + logMonoTime = {'aolAxisState': stamp, 'aolSafetyWire': stamp} + + def __init__(self): + self.axis = SimpleNamespace(qualified=True, sessionId='drive-session', lateralActive=True, + nativeAcknowledged=True, observedMonoTime=stamp, validUntilMonoTime=stamp + 30_000_000) + self.payload = b'' + + def __getitem__(self, key): + return self.axis if key == 'aolAxisState' else self.payload + + sm = MonitorSM() + for raw in (0x8815, 0x8895): + sm.payload = encode_safety(SafetyState(1, True, stamp, stamp + 200_000_000, + int(car.CarParams.SafetyModel.hyundaiCanfd), raw, + True, False, True, False, 'panda', 'drive-session')) + self.assertTrue(monitoring_lateral_engaged(sm, now_ns=stamp + 1_000_000)) + sm.payload = encode_safety(SafetyState(1, True, stamp, stamp + 200_000_000, + int(car.CarParams.SafetyModel.hyundaiCanfd), raw, + False, False, True, False, 'panda', 'drive-session')) + self.assertFalse(monitoring_lateral_engaged(sm, now_ns=stamp + 1_000_000)) + self.assertFalse(monitoring_lateral_engaged(sm, now_ns=stamp + 31_000_000)) + sm.payload = encode_safety(SafetyState(1, True, stamp, stamp + 200_000_000, + int(car.CarParams.SafetyModel.hyundaiCanfd), 0x8015, + True, False, True, False, 'panda', 'drive-session')) + self.assertFalse(monitoring_lateral_engaged(sm, now_ns=stamp + 1_000_000)) + + def test_actual_controls_lateral_only_command_has_active_steering_status(self): + for alt, raw in ((False, 0x8815), (True, 0x8895)): + with self.subTest(alt=alt), OpenpilotPrefix(), mock.patch.dict('os.environ', {'AOL_REPLAY_RUNTIME': '1', 'SIMULATION': '1'}): + cp, controller_cs, controller = controller_fixture(alt, aol=True) + self.assertEqual(cp.safetyConfigs[0].safetyParam, raw) + Params().put('CarParams', cp.to_bytes(), block=True) + controls = Controls() + self.assertTrue(controls.aol_replay) + cs = state() + cs.cruiseState.available = True + controls.sm.data['carState'] = cs.as_reader() + axis = SimpleNamespace(sessionId='drive-session', nativeAcknowledged=True, lateralActive=True, + longitudinalActive=False, desiredLateral=True, desiredLongitudinal=False) + native = SimpleNamespace(requestedLateral=True, requestedLongitudinal=False, + lateralAllowed=True, longitudinalAllowed=False) + with mock.patch('openpilot.selfdrive.controls.controlsd.current_axis', return_value=axis), \ + mock.patch('openpilot.selfdrive.controls.controlsd.current_native', return_value=native): + command, _ = controls.state_control() + self.assertFalse(command.enabled) + self.assertTrue(command.latActive) + self.assertFalse(command.longActive) + _, frames = controller.update(command.as_reader(), controller_cs, 1_000_000_000) + lfa = next(frame for frame in frames if frame[0] == 0x12A) + parser = CANParser(DBC[cp.carFingerprint][Bus.pt], [('LFA', 0)], 1) + parser.update((1_000_000_000, [lfa])) + self.assertEqual(parser.vl['LFA']['LKA_SysIndReq'], 2) + self.assertEqual(parser.vl['LFA']['ActToiSta'], 1) + scc = next(frame for frame in frames if frame[0] == 0x1A0) + parser = CANParser(DBC[cp.carFingerprint][Bus.pt], [('SCC_CONTROL', 0)], 1) + parser.update((1_000_000_000, [scc])) + self.assertEqual(parser.vl['SCC_CONTROL']['ACCMode'], 0) + self.assertEqual(parser.vl['SCC_CONTROL']['aReqValue'], 0.) + self.assertEqual(parser.vl['SCC_CONTROL']['aReqRaw'], 0.) + + def test_serialized_ioniq_axis_and_native_receipts_drive_both_controls_and_expire(self): + with OpenpilotPrefix(), mock.patch.dict('os.environ', {'AOL_REPLAY_RUNTIME': '0', 'SIMULATION': '1'}): + cp, _, _ = controller_fixture(True, aol=True) + params = Params() + params.put('CarParams', cp.to_bytes(), block=True) + params.put_bool('AlwaysOnLateral', True, block=True) + controls = Controls() + self.assertTrue(controls.aol_replay) + base = time.monotonic_ns() + + def command(stamp: int, *, longitudinal: bool, native_receipt: bool = True): + car_msg = messaging.new_message('carState', valid=True) + car_msg.logMonoTime = stamp + car_msg.carState = state() + drive_msg = messaging.new_message('selfdriveState', valid=True) + drive_msg.logMonoTime = stamp + drive_msg.selfdriveState.enabled = longitudinal + axis_msg = messaging.new_message('aolAxisState', valid=True) + axis_msg.logMonoTime = stamp + axis = axis_msg.aolAxisState + axis.qualified = axis.nativeAcknowledged = axis.lateralActive = axis.desiredLateral = True + axis.longitudinalActive = axis.desiredLongitudinal = longitudinal + axis.sessionId = 'ioniq-drive' + axis.sourceCarStateMonoTime = stamp + axis.observedMonoTime = stamp + axis.validUntilMonoTime = stamp + 30_000_000 + events = [car_msg, drive_msg, axis_msg] + if native_receipt: + safety_msg = messaging.new_message('aolSafetyWire', 0, valid=True) + safety_msg.logMonoTime = stamp + safety_msg.aolSafetyWire = encode_safety(SafetyState( + 1, True, stamp, stamp + 200_000_000, + int(car.CarParams.SafetyModel.hyundaiCanfd), 0x8895, + True, longitudinal, True, longitudinal, 'panda', 'ioniq-drive')) + events.append(safety_msg) + controls.sm.update_msgs(stamp / 1e9, [messaging.log_from_bytes(event.to_bytes()) for event in events]) + with mock.patch('openpilot.selfdrive.controls.controlsd.time.monotonic_ns', return_value=stamp + 1_000_000): + return controls.state_control()[0] + + combined = command(base, longitudinal=True) + self.assertTrue(combined.latActive and combined.longActive) + lateral_only = command(base + 10_000_000, longitudinal=False) + self.assertTrue(lateral_only.latActive) + self.assertFalse(lateral_only.longActive) + restored = command(base + 20_000_000, longitudinal=True) + self.assertTrue(restored.latActive and restored.longActive) + lost = command(base + 250_000_000, longitudinal=True, native_receipt=False) + self.assertFalse(lost.latActive or lost.longActive) diff --git a/openpilot/selfdrive/car/tests/test_init_lifecycle.py b/openpilot/selfdrive/car/tests/test_init_lifecycle.py new file mode 100644 index 0000000000..cfe665dafd --- /dev/null +++ b/openpilot/selfdrive/car/tests/test_init_lifecycle.py @@ -0,0 +1,115 @@ +import unittest +from types import SimpleNamespace +from unittest.mock import Mock, patch + +from opendbc.car import gen_empty_fingerprint +from opendbc.car.can_definitions import CanData +from opendbc.car.gm.interface import CarInterface as GMInterface +from opendbc.car.gm.values import CAR as GMCar, GMFlags +from openpilot.cereal import messaging +from openpilot.selfdrive.car.card import Car, EventName +from openpilot.selfdrive.pandad import can_capnp_to_list + + +class CardInitLifecycleTest(unittest.TestCase): + def test_bolt_pedal_waits_for_control_then_initializes_once_before_output(self): + fingerprint = gen_empty_fingerprint() + fingerprint[0][0x201] = 6 + with patch('opendbc.car.gm.interface.Params') as saved: + saved.return_value.get_bool.return_value = True + cp = GMInterface.get_params(GMCar.CHEVROLET_BOLT_ACC_2022_2023_PEDAL, + fingerprint, [], False, False, False) + self.assertTrue(cp.flags & GMFlags.PEDAL_LONG.value) + + order = [] + card = Car.__new__(Car) + card.CP = cp + card.ci_initialized = False + card.initialized_prev = False + card.ioniq6_long_prearmed = False + card.ioniq6_long_selected = False + card.can_callbacks = (lambda wait_for_one=False: [], lambda frames: None) + + def apply(*_): + order.append('apply') + return object(), [CanData(0x200, b'\x00' * 8, 0)] + + fake_ci = SimpleNamespace( + init=Mock(side_effect=lambda *_: order.append('init')), + apply=Mock(side_effect=apply), + ) + self.enterContext(patch.object(card, 'CI', fake_ci, create=True)) + self.enterContext(patch.object(card, 'params', SimpleNamespace(put_bool=lambda key, value: order.append((key, value))), create=True)) + sent = [] + + def publish(service, packet): + assert service == 'sendcan' + sent.append(packet) + order.append(('sendcan', tuple(can_capnp_to_list([packet], msgtype='sendcan')[0][1]), + bool(messaging.log_from_bytes(packet).valid))) + + self.enterContext(patch.object(card, 'pm', SimpleNamespace(send=publish), create=True)) + card.state_publish = Mock(side_effect=lambda *_: order.append('carState')) + cs = SimpleNamespace(canValid=True, canTimeout=False) + card.state_update = Mock(return_value=(cs, None)) + control = object() + + class SubMaster: + valid = {'carControl': False} + alive = {'carControl': False} + seen = {'onroadEvents': False} + events = [] + + def __getitem__(self, key): + return self.events if key == 'onroadEvents' else control + + def all_alive(self, services): + return all(self.alive[name] for name in services) + + sm = SubMaster() + self.enterContext(patch.object(card, 'sm', sm, create=True)) + for _ in range(30): + card.step() + self.assertNotIn('init', order) + sm.seen['onroadEvents'] = True + sm.events = [SimpleNamespace(name=EventName.selfdriveInitializing)] + card.step() + self.assertNotIn('init', order) + + sm.events = [] + card.step() # Controls still have no live producer. + self.assertNotIn('init', order) + sm.valid['carControl'] = True + sm.alive['carControl'] = True + cs.canValid = False + card.step() # A live controller cannot initialize against invalid CAN. + self.assertNotIn('init', order) + + cs.canValid = True + card.step() + self.assertEqual(order[-5:-1], ['carState', 'init', ('ControlsReady', True), 'apply']) + self.assertEqual(order[-1][0], 'sendcan') + self.assertEqual([(frame[0], frame[2]) for frame in order[-1][1]], [(0x200, 0)]) + self.assertTrue(order[-1][2]) + self.assertTrue(all(not can_capnp_to_list([packet], msgtype='sendcan')[0][1] for packet in sent[:-1])) + self.assertTrue(all(not messaging.log_from_bytes(packet).valid for packet in sent[:-1])) + card.step() + fake_ci.init.assert_called_once_with(cp, *card.can_callbacks) + self.assertEqual(order.count(('ControlsReady', True)), 1) + + applied = fake_ci.apply.call_count + sent_before = len(sent) + sm.valid['carControl'] = False + card.step() # A live but invalid native command cannot renew CAN output. + self.assertEqual(fake_ci.apply.call_count, applied) + self.assertEqual(len(sent), sent_before) + sm.valid['carControl'] = True + card.step() + self.assertEqual(fake_ci.apply.call_count, applied + 1) + self.assertEqual(len(sent), sent_before + 1) + self.assertTrue(messaging.log_from_bytes(sent[-1]).valid) + fake_ci.init.assert_called_once_with(cp, *card.can_callbacks) + + +if __name__ == '__main__': + unittest.main() diff --git a/openpilot/starpilot/aol/defaults.py b/openpilot/starpilot/aol/defaults.py new file mode 100644 index 0000000000..abef3205bc --- /dev/null +++ b/openpilot/starpilot/aol/defaults.py @@ -0,0 +1,28 @@ +"""Capability-aware preference enrollment, separate from runtime authority.""" +from pathlib import Path + +from openpilot.starpilot.aol.vehicle import policy_for +from openpilot.starpilot.state_migration import _atomic_write, _params_lock, _read_value + + +def default_enabled(cp) -> bool: + if cp is None or getattr(cp, 'notCar', True) or getattr(cp, 'passive', True) or getattr(cp, 'dashcamOnly', True): + return False + policy = policy_for(cp) + return bool(policy.settings_supported and policy.intent_supported) + + +def enroll_missing_preference(params, cp) -> bool: + if not default_enabled(cp): + return False + try: + namespace = Path(params.get_param_path()).absolute() + with _params_lock(namespace): + try: + _read_value(namespace / 'AlwaysOnLateral') + except FileNotFoundError: + _atomic_write(namespace / 'AlwaysOnLateral', b'1') + return True + except (OSError, ValueError): + return False + return False diff --git a/openpilot/starpilot/aol/dm.py b/openpilot/starpilot/aol/dm.py new file mode 100644 index 0000000000..d442e03733 --- /dev/null +++ b/openpilot/starpilot/aol/dm.py @@ -0,0 +1,63 @@ +def armed_lateral_current(cp, cs, intent, native, *, source_ns, now_ns, axis_session_id) -> bool: + """Retain only a current deliberately armed native-permitted lateral owner.""" + from opendbc.car.structs import car + from openpilot.starpilot.aol.vehicle import policy_for + from openpilot.starpilot.aol.runtime import AXIS_MAX_AGE_NS, SAFETY_MAX_AGE_NS, native_inventory_matches_cp + try: + return bool(policy_for(cp).intent_supported and + cs.canValid and not cs.canTimeout and not cs.accFaulted and not cs.doorOpen and + not cs.steerFaultTemporary and not cs.steerFaultPermanent and cs.cruiseState.available and + cs.gearShifter in (car.CarState.GearShifter.drive, car.CarState.GearShifter.low, + car.CarState.GearShifter.sport, car.CarState.GearShifter.brake) and + intent is not None and intent.settingsQualified and intent.allowedLatch and intent.lateralArmed and + intent.producerSessionId and intent.carStateLogMonoTime == source_ns and + 0 < source_ns <= intent.observedMonoTime <= now_ns <= intent.validUntilMonoTime and + now_ns - source_ns <= AXIS_MAX_AGE_NS and + intent.validUntilMonoTime - intent.observedMonoTime <= SAFETY_MAX_AGE_NS and + native is not None and native.compatible and int(native.protocolVersion) == 1 and + axis_session_id and native.axisSessionId == axis_session_id and + native.requestedLateral and native.lateralAllowed and native.pandaInventory and + native_inventory_matches_cp(native, cp) and + 0 < native.observedMonoTime <= now_ns <= native.validUntilMonoTime and + native.validUntilMonoTime - native.observedMonoTime <= SAFETY_MAX_AGE_NS) + except (AttributeError, KeyError, IndexError, TypeError, ValueError): + return False + + +def lateral_event_context(self, CS, intent, native, now_ns, ordinary_lag): + from openpilot.selfdrive.selfdrived.events import EventName + from openpilot.cereal import log + dm = self.sm['driverMonitoringState'] + needs_exception = bool(dm.alertLevel == log.DriverMonitoringState.AlertLevel.three or + dm.lockout or dm.alwaysOnLockout or EventName.tooDistracted in self.events.names) + retained_dm_lateral = bool(needs_exception and intent is not None and + getattr(self, 'aol_dm_retained_owner', None) == (intent.producerSessionId, self.aol_session_id) and + self.sm.all_checks(['driverMonitoringState']) and + armed_lateral_current(self.CP, CS, intent, native, source_ns=self.aol_car_state_log_ns, + now_ns=now_ns, axis_session_id=self.aol_session_id)) + lateral_events = [name for name in self.events.names if not ( + name == EventName.selfdrivedLagging and ordinary_lag or + name == EventName.tooDistracted and retained_dm_lateral)] + return retained_dm_lateral, lateral_events + + +def observe_owner(self, intent, previous_intent): + from openpilot.cereal import log + AlertLevel = log.DriverMonitoringState.AlertLevel + if (intent is None or not intent.allowedLatch or previous_intent is None or + intent.producerSessionId != previous_intent.producerSessionId): + self.aol_dm_retained_owner = None + elif self.aol_axis_decision.lateral_active and previous_intent.allowedLatch: + self.aol_dm_retained_owner = (intent.producerSessionId, self.aol_session_id) + if (self.sm['driverMonitoringState'].alertLevel == AlertLevel.three or + self.sm['driverMonitoringState'].lockout or self.sm['driverMonitoringState'].alwaysOnLockout): + self.aol_availability_long_inhibit = True + self.aol_dm_lateral_inhibit = False + elif intent is not None and not intent.allowedLatch: + self.aol_dm_lateral_inhibit = False + + +def dm_clear(self): + from openpilot.cereal import log + return (self.sm['driverMonitoringState'].alertLevel != log.DriverMonitoringState.AlertLevel.three and + not self.sm['driverMonitoringState'].lockout and not self.sm['driverMonitoringState'].alwaysOnLockout) diff --git a/openpilot/starpilot/aol/intent_companion.py b/openpilot/starpilot/aol/intent_companion.py new file mode 100644 index 0000000000..9b93eefb4d --- /dev/null +++ b/openpilot/starpilot/aol/intent_companion.py @@ -0,0 +1,171 @@ +"""Original carState companions from Card's nonconflating intent transport.""" +from dataclasses import replace + +from openpilot.cereal import messaging +from openpilot.starpilot.aol.runtime import INTENT_MAX_AGE_NS, SAFETY_MAX_AGE_NS +from openpilot.starpilot.aol.wire import INTENT_SERVICE, decode_intent + +MAX_COMPANIONS = 16 +MAX_DRAIN = 32 + + +class IntentCompanion: + def __init__(self, sock): + self.sock = sock + self.samples = {} + self.latest = None + self.session = None + self.sequence = -1 + self.sequence_stamp = 0 + self.integrity_epoch = 0 + self.verified_sm_projection = None + self.raw_projections = {} + self.last_rejection = None + self.last_drain_count = 0 + self.last_drain_ok = True + + def _observe(self, stamp, valid, raw): + stamp = int(stamp) + intent = decode_intent(raw) + valid = bool(valid and intent is not None and intent.carStateLogMonoTime == stamp) + previous = self.samples.get(stamp) + if self.latest is not None and stamp < self.latest[0] and previous is None: + valid = False + if intent is not None and (self.latest is None or stamp >= self.latest[0]): + if self.session is not None and intent.producerSessionId != self.session: + self.integrity_epoch += 1 + self.verified_sm_projection = None + self.raw_projections.clear() + self.samples.clear() + self.sequence = -1 + self.sequence_stamp = 0 + self.session = intent.producerSessionId + if previous is not None: + valid = bool(valid and previous[1] and previous[2] == intent) + if valid and intent is not None: + if (stamp > self.sequence_stamp and intent.producerSessionId == self.session and + intent.sequence <= self.sequence): + valid = False + for other_stamp, other_valid, other in self.samples.values(): + if other_valid and other is not None and other.producerSessionId == intent.producerSessionId and ( + other_stamp < stamp and other.sequence >= intent.sequence or + other_stamp > stamp and other.sequence <= intent.sequence): + valid = False + if valid and intent is not None and (self.latest is None or stamp >= self.latest[0]): + self.sequence = max(self.sequence, intent.sequence) + self.sequence_stamp = max(self.sequence_stamp, stamp) + if not valid: + self.integrity_epoch += 1 + self.verified_sm_projection = None + self.raw_projections.clear() + sample = (stamp, valid, intent) + self.samples[stamp] = sample + if self.latest is None or stamp >= self.latest[0]: + self.latest = sample + while len(self.samples) > MAX_COMPANIONS: + del self.samples[min(self.samples)] + + @staticmethod + def _fresh(sample, now_ns): + stamp, valid, intent = sample + return bool(valid and intent is not None and intent.settingsQualified and intent.producerSessionId and + 0 < stamp <= now_ns and now_ns - stamp <= INTENT_MAX_AGE_NS and + intent.observedMonoTime <= now_ns <= intent.validUntilMonoTime and + now_ns - intent.observedMonoTime <= INTENT_MAX_AGE_NS) + + def drain(self, *, max_messages=MAX_DRAIN): + """Read bounded transport before sampling the decision clock.""" + self.last_drain_count = 0 + self.last_drain_ok = False + for _ in range(max(0, min(MAX_DRAIN, max_messages))): + message = messaging.recv_one_or_none(self.sock) + if message is None: + break + self.last_drain_count += 1 + self._observe(message.logMonoTime, message.valid, message.aolIntentWire) + sample = self.samples.get(int(message.logMonoTime)) + if sample is not None and sample[1]: + self.raw_projections[sample[0]] = (sample[2], self.integrity_epoch, self.session) + while len(self.raw_projections) > MAX_COMPANIONS: + del self.raw_projections[min(self.raw_projections)] + else: + self.integrity_epoch += 1 + self.verified_sm_projection = None + self.raw_projections.clear() + self.samples.clear() + if self.latest is not None: + self.latest = (self.latest[0], False, self.latest[2]) + self.samples[self.latest[0]] = self.latest + self.last_rejection = 'drain_overrun' + return False + self.last_drain_ok = True + return True + + def _reject(self, reason): + self.last_rejection = reason + return None + + def current(self, sm, *, source_ns, now_ns, drain=True): + self.last_rejection = None + if drain and not self.drain(): + return self._reject('drain_overrun') + if not self.last_drain_ok: + return self._reject('drain_overrun') + projection = None + if sm.seen.get(INTENT_SERVICE, False): + stamp = int(sm.logMonoTime[INTENT_SERVICE]) + raw_verified = self.raw_projections.get(stamp) + raw_current = bool(raw_verified is not None and raw_verified[0].settingsQualified and + raw_verified[0].producerSessionId and 0 < raw_verified[0].observedMonoTime <= now_ns <= + raw_verified[0].validUntilMonoTime) + verified = ((stamp, *raw_verified) if raw_current else self.verified_sm_projection) + if (verified is not None and verified[0] == stamp and verified[2] == self.integrity_epoch and + verified[3] == self.session and sm.valid.get(INTENT_SERVICE, False) and + sm.alive.get(INTENT_SERVICE, False) and 0 < stamp <= now_ns <= stamp + SAFETY_MAX_AGE_NS and + (stamp < source_ns or (source_ns == 0 or source_ns == stamp) and + 0 < stamp <= now_ns <= stamp + SAFETY_MAX_AGE_NS) and + now_ns - stamp > INTENT_MAX_AGE_NS and decode_intent(sm[INTENT_SERVICE]) == verified[1]): + # This exact previously accepted SM identity contributes restrictions + # only; it never replaces the fresh raw source pair or latest sample. + projection = (stamp, True, verified[1]) + else: + self._observe(stamp, sm.valid.get(INTENT_SERVICE, False), sm[INTENT_SERVICE]) + for stamp in tuple(self.samples): + if not 0 < stamp <= now_ns or now_ns - stamp > INTENT_MAX_AGE_NS: + del self.samples[stamp] + for stamp in tuple(self.raw_projections): + if not 0 < stamp <= now_ns or now_ns - stamp > SAFETY_MAX_AGE_NS: + del self.raw_projections[stamp] + if not (sm.seen.get(INTENT_SERVICE, False) and sm.valid.get(INTENT_SERVICE, False) and + sm.alive.get(INTENT_SERVICE, False)): + return self._reject('sm_unavailable') + pair = self.samples.get(source_ns) + sm_sample = projection if projection is not None else self.samples.get(int(sm.logMonoTime[INTENT_SERVICE])) + if pair is None or self.latest is None or sm_sample is None: + return self._reject('missing_pair_latest_or_sm') + fresh_samples = (pair, self.latest) if projection is not None else (pair, self.latest, sm_sample) + for label, sample in zip(('pair', 'latest', 'sm'), fresh_samples, strict=False): + if not self._fresh(sample, now_ns): + stamp, valid, value = sample + reason = ('invalid' if not valid or value is None else + 'settings_or_session' if not value.settingsQualified or not value.producerSessionId else + 'future' if stamp > now_ns or value.observedMonoTime > now_ns else + 'nonpositive_stamp' if stamp <= 0 else + 'expired') + return self._reject(label + '_' + reason) + if self.latest[0] < source_ns: + return self._reject('latest_older_than_source') + intent = pair[2] + for sample in (self.latest, sm_sample): + _, _, latest = sample + if latest.producerSessionId != intent.producerSessionId: + return self._reject('producer_session_mismatch') + intent = replace(intent, allowedLatch=intent.allowedLatch and latest.allowedLatch, + pauseLateral=intent.pauseLateral or latest.pauseLateral, + pauseLongitudinal=intent.pauseLongitudinal or latest.pauseLongitudinal, + lateralArmed=intent.lateralArmed and latest.lateralArmed, + optionalSetRelease=intent.optionalSetRelease and latest.optionalSetRelease) + if projection is None: + # Save only after every ordinary fresh pair/latest/SM guard succeeded. + self.verified_sm_projection = (sm_sample[0], sm_sample[2], self.integrity_epoch, self.session) + return intent diff --git a/openpilot/starpilot/aol/runtime.py b/openpilot/starpilot/aol/runtime.py new file mode 100644 index 0000000000..b9a8618297 --- /dev/null +++ b/openpilot/starpilot/aol/runtime.py @@ -0,0 +1,239 @@ +"""Bounded AOL axis requests and native acknowledgment checks. + +The requester and the actuator are deliberately separate: a native permission +cannot be required before the host sends its first desired-axis request. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from opendbc.car.structs import car +from openpilot.starpilot.aol.vehicle import native_admission_key, native_matches_cp, native_profile_supported +from openpilot.starpilot.aol.wire import INTENT_SERVICE, SAFETY_SERVICE, IntentState, decode_intent, decode_safety + +INTENT_MAX_AGE_NS = 30_000_000 +SAFETY_MAX_AGE_NS = 200_000_000 +AXIS_MAX_AGE_NS = 30_000_000 + + +@dataclass(frozen=True) +class AxisDecision: + desired_lateral: bool = False + desired_longitudinal: bool = False + lateral_active: bool = False + longitudinal_active: bool = False + native_acknowledged: bool = False + + @property + def mode(self) -> str: + if self.lateral_active and self.longitudinal_active: + return 'combined' + if self.lateral_active: + return 'lateralOnly' + if self.longitudinal_active: + return 'longitudinalOnly' + return 'off' + + +def current_intent(sm, *, car_state_ns: int, now_ns: int, previous: IntentState | None = None, companion=None, drain=True): + if companion is not None: + return companion.current(sm, source_ns=car_state_ns, now_ns=now_ns, drain=drain) + if not (sm.valid[INTENT_SERVICE] and sm.alive[INTENT_SERVICE] and sm.seen[INTENT_SERVICE]): + return None + intent = decode_intent(sm[INTENT_SERVICE]) + if intent is None: + return None + # Intent is published before its carState wakeup. If the carState socket + # times out exactly between those sends, only the already accepted companion + # for that *same retained carState* may bridge the publication boundary. + # Revalidate its original lease; never accept a newer intent for older CAN. + if (intent.carStateLogMonoTime > car_state_ns and previous is not None and + previous.carStateLogMonoTime == car_state_ns and intent.settingsQualified and + intent.producerSessionId == previous.producerSessionId and + int(sm.logMonoTime[INTENT_SERVICE]) == intent.carStateLogMonoTime and + intent.observedMonoTime <= now_ns <= intent.validUntilMonoTime and + now_ns - intent.observedMonoTime <= INTENT_MAX_AGE_NS): + intent = previous + message_ns = int(previous.carStateLogMonoTime) + else: + message_ns = int(sm.logMonoTime[INTENT_SERVICE]) + observed = int(intent.observedMonoTime) + produced_for = int(intent.carStateLogMonoTime) + if (not intent.settingsQualified or not intent.producerSessionId or produced_for <= 0 or + message_ns != produced_for or + produced_for > car_state_ns or car_state_ns - produced_for > INTENT_MAX_AGE_NS or + observed > now_ns or now_ns > int(intent.validUntilMonoTime) or + now_ns - observed > INTENT_MAX_AGE_NS): + return None + return intent + + +def native_inventory_matches_cp(state, CP) -> bool: + """Bind the selected serial to the ordered same-read configuration inventory. + + Historical v1 wires retain single-Panda compatibility only; they carry no + inventory qualification. Multi-Panda admission requires coherent production v2 + with the exact configuration and one owner. + The source PandaStates stamp is BOOTTIME provenance, never a monotonic lease. + """ + if not state.pandaInventory: + return state.sourcePandaStatesMonoTime == 0 and len(CP.safetyConfigs) == 1 + try: + slots = state.pandaInventory + configs = CP.safetyConfigs + if state.sourcePandaStatesMonoTime <= 0 or not 1 <= len(slots) <= 2 or len(slots) != len(configs): + return False + serials = [slot.hardwareSerial for slot in slots] + if any(not serial for serial in serials) or len(set(serials)) != len(serials): + return False + owners = [] + for index, (slot, config) in enumerate(zip(slots, configs, strict=True)): + if slot.safetyRxChecksInvalid or slot.heartbeatLost or slot.faults: + return False + if (slot.slotIndex != index or slot.safetyModel != int(config.safetyModel.raw) or + slot.safetyParam != int(config.safetyParam) or slot.alternativeExperience != int(CP.alternativeExperience)): + return False + if str(config.safetyModel) in ('silent', 'noOutput'): + if slot.safetyParam != 0 or slot.controlsAllowed: + return False + else: + owners.append(slot) + return bool(len(owners) == 1 and owners[0].hardwareSerial == state.pandaSerial and + owners[0].safetyModel == state.safetyModel and owners[0].safetyParam == state.safetyParam) + except (AttributeError, TypeError, ValueError, OverflowError, IndexError): + return False + + +def _clear_native_admission(sm): + try: + sm._aol_native_admission = None + except Exception: + # Custom/slotted owners may not support this optional optimization. + pass + + +def _native_admission_matches(sm, CP, state, raw): + key = None + try: + cp_key = native_admission_key(CP) if state.pandaInventory else None + if cp_key is not None and isinstance(raw, bytes): + key = (raw, int(sm.logMonoTime[SAFETY_SERVICE]), cp_key) + if getattr(sm, '_aol_native_admission', None) == key: + return True + except Exception: + # Cache plumbing may never replace or prevent the original validation. + key = None + matches = (native_matches_cp(CP, int(state.safetyModel), int(state.safetyParam)) and + native_inventory_matches_cp(state, CP)) + try: + # Re-read mutable CP on a miss before binding the successful result. + sm._aol_native_admission = key if matches and key is not None and native_admission_key(CP) == key[2] else None + except Exception: + _clear_native_admission(sm) + return matches + + +def current_native(sm, CP, *, now_ns: int, axis_session_id: str | None = None): + if not (sm.valid[SAFETY_SERVICE] and sm.alive[SAFETY_SERVICE] and sm.seen[SAFETY_SERVICE]): + _clear_native_admission(sm) + return None + raw = sm[SAFETY_SERVICE] + raw = bytes(raw) if isinstance(raw, (bytes, bytearray, memoryview)) else raw + state = decode_safety(raw) + if state is None: + _clear_native_admission(sm) + return None + if (not _native_admission_matches(sm, CP, state, raw) or + not state.compatible or int(state.protocolVersion) != 1 or not state.pandaSerial or not state.axisSessionId or + (axis_session_id is not None and str(state.axisSessionId) != axis_session_id) or + int(sm.logMonoTime[SAFETY_SERVICE]) > now_ns or + now_ns - int(sm.logMonoTime[SAFETY_SERVICE]) > SAFETY_MAX_AGE_NS or + int(state.observedMonoTime) > now_ns or now_ns > int(state.validUntilMonoTime)): + _clear_native_admission(sm) + return None + if state.pandaInventory and (int(sm.logMonoTime[SAFETY_SERVICE]) != state.observedMonoTime or + state.observedMonoTime <= 0 or + state.validUntilMonoTime - state.observedMonoTime > SAFETY_MAX_AGE_NS): + _clear_native_admission(sm) + return None + return state + + +def decide_axes(*, standard_lateral: bool, standard_longitudinal: bool, intent, + native, car_state, initialized: bool, model_ready: bool, + no_entry: bool, immediate_disable: bool, dm_lockout: bool, + pause_brake_mps: float, lateral_inhibit: bool = False) -> AxisDecision: + if intent is None or not initialized or not car_state.canValid or immediate_disable or no_entry or dm_lockout: + return AxisDecision() + gear = car_state.gearShifter + driving = gear not in (car.CarState.GearShifter.neutral, car.CarState.GearShifter.park, + car.CarState.GearShifter.reverse, car.CarState.GearShifter.unknown) + lateral_safe = (driving and model_ready and not lateral_inhibit and not car_state.steerFaultPermanent and + not car_state.steerFaultTemporary and + (pause_brake_mps <= 0 or not car_state.brakePressed or car_state.vEgo >= pause_brake_mps or car_state.standstill)) + desired_lateral = bool(lateral_safe and not intent.pauseLateral and (standard_lateral or intent.allowedLatch)) + desired_longitudinal = bool(driving and standard_longitudinal and not car_state.seatbeltUnlatched and not intent.pauseLongitudinal) + lateral_ack = native is not None and bool(native.requestedLateral) == desired_lateral + longitudinal_ack = native is not None and bool(native.requestedLongitudinal) == desired_longitudinal + return AxisDecision( + desired_lateral=desired_lateral, desired_longitudinal=desired_longitudinal, + lateral_active=bool(desired_lateral and lateral_ack and native.lateralAllowed), + longitudinal_active=bool(desired_longitudinal and longitudinal_ack and native.longitudinalAllowed), + native_acknowledged=bool(native is not None), + ) + + +def current_axis(sm, *, now_ns: int): + if not (sm.valid['aolAxisState'] and sm.alive['aolAxisState'] and sm.seen['aolAxisState']): + return None + state = sm['aolAxisState'] + if (not state.qualified or not state.sessionId or + int(sm.logMonoTime['aolAxisState']) > now_ns or + now_ns - int(sm.logMonoTime['aolAxisState']) > AXIS_MAX_AGE_NS or + int(state.observedMonoTime) > now_ns or now_ns > int(state.validUntilMonoTime)): + return None + return state + + +def monitoring_lateral_engaged(sm, *, now_ns: int) -> bool: + axis = current_axis(sm, now_ns=now_ns) + if axis is None or not axis.lateralActive or not axis.nativeAcknowledged: + return False + if not (sm.valid[SAFETY_SERVICE] and sm.seen[SAFETY_SERVICE] and sm.alive[SAFETY_SERVICE]): + return False + native = decode_safety(sm[SAFETY_SERVICE]) + if native is None: + return False + return bool(native.compatible and int(native.protocolVersion) == 1 and + native.axisSessionId == axis.sessionId and + native_profile_supported(int(native.safetyModel), int(native.safetyParam)) and + int(sm.logMonoTime[SAFETY_SERVICE]) <= now_ns and + now_ns - int(sm.logMonoTime[SAFETY_SERVICE]) <= SAFETY_MAX_AGE_NS and + int(native.observedMonoTime) <= now_ns <= int(native.validUntilMonoTime) and + native.requestedLateral and native.lateralAllowed) + + +def ordinary_lateral_requested(active: bool, CS, CP) -> bool: + """The standard lateral output gate, shared by request and actuation owners.""" + standstill = abs(CS.vEgo) <= max(CP.minSteerSpeed, 0.3) or CS.standstill + return bool(active and not CS.steerFaultTemporary and not CS.steerFaultPermanent and + (not standstill or CP.steerAtStandstill)) + + +def decide_ordinary_axis(*, requested: bool, native) -> AxisDecision: + """Acknowledgment cannot create driver intent or an independent axis.""" + acknowledged = bool(native is not None and native.requestedLateral == requested and + not native.requestedLongitudinal) + return AxisDecision(desired_lateral=bool(requested), + lateral_active=bool(requested and acknowledged and native.lateralAllowed), + native_acknowledged=acknowledged) + + +def ordinary_axis_acknowledged(sm, CP, *, now_ns: int) -> bool: + axis = current_axis(sm, now_ns=now_ns) + native = current_native(sm, CP, now_ns=now_ns, + axis_session_id=str(axis.sessionId) if axis is not None else None) + return bool(axis is not None and native is not None and axis.nativeAcknowledged and + axis.desiredLateral and axis.lateralActive and not axis.desiredLongitudinal and + native.requestedLateral and not native.requestedLongitudinal and native.lateralAllowed) diff --git a/openpilot/starpilot/aol/seatbelt.py b/openpilot/starpilot/aol/seatbelt.py new file mode 100644 index 0000000000..8acd50a156 --- /dev/null +++ b/openpilot/starpilot/aol/seatbelt.py @@ -0,0 +1,30 @@ +def seatbelt_lateral_soft_event_allowed(cp, cs, intent, native, event, *, source_ns, now_ns, axis_session_id, pending_lateral_request=False) -> bool: + """A fresh native-OFF baseline may arm LAT while the belt remains a LONG lock.""" + from opendbc.car.structs import car + from openpilot.starpilot.aol.vehicle import policy_for + from openpilot.cereal import log + from openpilot.starpilot.aol.runtime import AXIS_MAX_AGE_NS, SAFETY_MAX_AGE_NS, native_inventory_matches_cp + try: + pending_native = bool(pending_lateral_request and native is not None and + not native.requestedLateral and not native.lateralAllowed) + return bool(policy_for(cp).intent_supported and event.name == log.OnroadEvent.EventName.seatbeltNotLatched and event.softDisable and + event.noEntry and not event.immediateDisable and not event.userDisable and + cs.seatbeltUnlatched and cs.canValid and not cs.canTimeout and not cs.accFaulted and not cs.doorOpen and + not cs.steerFaultTemporary and not cs.steerFaultPermanent and cs.cruiseState.available and + cs.gearShifter in (car.CarState.GearShifter.drive, car.CarState.GearShifter.low, + car.CarState.GearShifter.sport, car.CarState.GearShifter.brake) and + intent is not None and intent.settingsQualified and + (intent.allowedLatch and intent.lateralArmed or + pending_native and not intent.allowedLatch and not intent.lateralArmed) and + intent.producerSessionId and intent.carStateLogMonoTime == source_ns and + 0 < source_ns <= intent.observedMonoTime <= now_ns <= intent.validUntilMonoTime and + now_ns - source_ns <= AXIS_MAX_AGE_NS and + intent.validUntilMonoTime - intent.observedMonoTime <= SAFETY_MAX_AGE_NS and + native is not None and native.compatible and int(native.protocolVersion) == 1 and + axis_session_id and native.axisSessionId == axis_session_id and + (native.requestedLateral and native.lateralAllowed or pending_native) and native.pandaInventory and + native_inventory_matches_cp(native, cp) and + 0 < native.observedMonoTime <= now_ns <= native.validUntilMonoTime and + native.validUntilMonoTime - native.observedMonoTime <= SAFETY_MAX_AGE_NS) + except (AttributeError, KeyError, IndexError, TypeError, ValueError): + return False diff --git a/openpilot/starpilot/aol/source_availability.py b/openpilot/starpilot/aol/source_availability.py new file mode 100644 index 0000000000..99e0a6e451 --- /dev/null +++ b/openpilot/starpilot/aol/source_availability.py @@ -0,0 +1,275 @@ +"""Classify ordinary host source loss without granting commands or engagement.""" +from opendbc.car.structs import car +from openpilot.cereal import log +from openpilot.selfdrive.selfdrived.events import ET +from openpilot.starpilot.aol.intent_companion import MAX_COMPANIONS +from openpilot.starpilot.aol.runtime import INTENT_MAX_AGE_NS, SAFETY_MAX_AGE_NS, current_native +from openpilot.starpilot.aol.transport_pause import (cp_identity, healthy_transport, HOST_SOURCE_AVAILABILITY_EVENTS, + host_source_availability_only, stock_lateral_off, stock_lateral_user_event) +from openpilot.starpilot.nostalgia import physical_cancel + +EventName = log.OnroadEvent.EventName +AlertLevel = log.DriverMonitoringState.AlertLevel + + +def stock_lateral_role(self, CS, native, intent=None): + """Current stock USER role while an established independent LAT owner remains.""" + rules = self.events.event_rules + stock_user = stock_lateral_user_event(self.CP) + users = [name for name in self.events.names if ET.USER_DISABLE in rules.get(name, {})] + if stock_user is None or not users or any(name != stock_user or + ET.SOFT_DISABLE in rules.get(name, {}) or ET.IMMEDIATE_DISABLE in rules.get(name, {}) for name in users): + return False + anchor = getattr(self, 'aol_ordinary_source_anchor', None) + if (anchor is None or not anchor[0][8] or anchor[0][7] or anchor[0][9] or self.enabled or self.active or + self.state_machine.state != log.SelfdriveState.OpenpilotState.disabled or + anchor[0][0] != self.aol_session_id or cp_identity(self.CP) != anchor[3] or + getattr(self, 'aol_dm_lateral_inhibit', False) or + not self.sm.all_checks(['modelV2', 'extrinsicsCalibration', 'driverMonitoringState']) or + self.sm['extrinsicsCalibration'].calStatus != log.ExtrinsicsCalibration.Status.calibrated or + self.sm['driverMonitoringState'].alertLevel == AlertLevel.three or + self.sm['driverMonitoringState'].lockout or self.sm['driverMonitoringState'].alwaysOnLockout or + not stock_lateral_off(self.CP, CS, native)): + return False + accepted = intent if intent is not None else anchor[2] + companion = getattr(self, 'aol_intent_companion', None) + return bool(accepted is not None and accepted.settingsQualified and accepted.allowedLatch and accepted.lateralArmed and + not accepted.pauseLateral and not accepted.pauseLongitudinal and + accepted.producerSessionId == anchor[0][4] and companion is not None and companion.last_drain_ok and + companion.integrity_epoch == anchor[0][5]) + + +def stock_lateral_fault_proof(self, CS, message, now_ns): + """Capture the stock role of this successfully published inactive fault.""" + published = self.aol_published_fault_proof + intent = getattr(self, 'aol_last_intent', None) + native = getattr(self, 'aol_decision_native', None) + anchor = getattr(self, 'aol_ordinary_source_anchor', None) + axis = message.aolAxisState + if (published is None or anchor is None or anchor[1] is None or not message.valid or not axis.qualified or published[1] != 'critical' or + any(published[4:]) or not stock_lateral_role(self, CS, native, intent) or + self.aol_authority_lost or getattr(self, 'aol_availability_long_inhibit', False) or + getattr(self, 'car_state_queue_invalid', False) or getattr(self, 'aol_selected_pair_future', False) or + not getattr(self, 'aol_step_intent_drain_ok', True) or + getattr(self, 'aol_step_intent_drain_count', 0) > MAX_COMPANIONS or + not getattr(self, 'conditional_car_state_valid', False) or intent != anchor[2] or CS is not anchor[4] or + axis.sourceCarStateMonoTime != intent.carStateLogMonoTime or + not 0 < axis.sourceCarStateMonoTime <= axis.observedMonoTime <= now_ns <= axis.validUntilMonoTime or + now_ns - axis.sourceCarStateMonoTime > INTENT_MAX_AGE_NS or + native is not getattr(self.aol_transport_pause, 'observed_native', None) or + not 0 < native.observedMonoTime <= now_ns <= native.validUntilMonoTime or + native.observedMonoTime < anchor[1].observedMonoTime or native.pandaSerial != anchor[1].pandaSerial or + tuple(p.hardwareSerial for p in native.pandaInventory) != tuple(p.hardwareSerial for p in anchor[1].pandaInventory) or + any(self.events.contains(kind) for kind in (ET.ENABLE, ET.OVERRIDE_LATERAL, ET.OVERRIDE_LONGITUDINAL))): + return None + events = self.events.to_msg() + stock_user = stock_lateral_user_event(self.CP) + host = [e for e in events if not (e.name == stock_user and e.userDisable and + not e.softDisable and not e.immediateDisable)] + if not host_source_availability_only(host) or not any(e.name == EventName.selfdrivedLagging for e in host): + return None + samples = getattr(self, 'car_state_samples', ()) or ((CS, True),) + if any(not valid or not sample.canValid or sample.canTimeout or sample.steerFaultPermanent or + sample.steerFaultTemporary or sample.accFaulted or sample.doorOpen or sample.seatbeltUnlatched or + sample.brakePressed or sample.regenBraking or sample.gasPressed or physical_cancel(sample) or + sample.gearShifter not in (car.CarState.GearShifter.drive, car.CarState.GearShifter.sport, + car.CarState.GearShifter.low, car.CarState.GearShifter.brake) for sample, valid in samples): + return None + return (published, stock_user) + + +def ordinary_source_availability(self, CS, now_ns, native=None, *, replay: bool, native_provider=current_native): + """Classify lost host source; never renew a command or change engagement.""" + anchor = getattr(self, 'aol_ordinary_source_anchor', None) + companion = getattr(self, 'aol_intent_companion', None) + events = self.events.to_msg() + stock_user = None + if any(event.name in (EventName.pcmDisable, EventName.cruiseDisabled) and event.userDisable for event in events): + # Resolve the native value once, as this classifier already does below. + # Normal healthy frames never enter this nominal stock-role branch. + native = native or native_provider(self.sm, self.CP, now_ns=now_ns, axis_session_id=self.aol_session_id) + if stock_lateral_role(self, CS, native): + stock_user = stock_lateral_user_event(self.CP) + events = [event for event in events if not (event.name == stock_user and event.userDisable and + not event.softDisable and not event.immediateDisable)] + if (replay or not self.aol_replay or not self.axis_transport_required or anchor is None or + not self.initialized or getattr(self, 'aol_availability_long_inhibit', False) or self.aol_authority_lost or + getattr(self, 'aol_fault_reason', 'none') not in ('none', 'availabilityPause', 'critical') or + any(event.userDisable or event.immediateDisable for event in events) or + any(e.softDisable and e.name not in HOST_SOURCE_AVAILABILITY_EVENTS + for e in events) or self.events.contains(ET.ENABLE) or + getattr(self, 'car_state_queue_invalid', False) or + not getattr(self, 'aol_step_intent_drain_ok', True) or + getattr(self, 'aol_step_intent_drain_count', 0) > MAX_COMPANIONS or companion is None or not companion.last_drain_ok or + getattr(self, 'aol_selected_pair_future', False) or + not self.sm.all_checks(['modelV2', 'extrinsicsCalibration', 'driverMonitoringState']) or + self.sm['extrinsicsCalibration'].calStatus != log.ExtrinsicsCalibration.Status.calibrated or + self.sm['driverMonitoringState'].alertLevel == AlertLevel.three or + self.sm['driverMonitoringState'].lockout or self.sm['driverMonitoringState'].alwaysOnLockout): + return False + proof, prior_native, accepted, identity, accepted_state = anchor + if accepted is None: + return False + # A source admitted by normal runtime in THIS step can expire only while + # publishing its first host-SOFT fault. It has no prior critical publication. + # Require the exact admitted object/state and original completed baseline; + # stale historical inputs cannot enter this path or grant a new command. + admitted_expiry = bool(getattr(self, 'aol_published_fault_reason', '') == 'none' and + getattr(self, 'aol_step_input_proof', None) == proof and + getattr(self, 'aol_last_intent', None) is accepted and + getattr(self, 'conditional_car_state_valid', False) and CS is accepted_state and + self.aol_car_state_log_ns == accepted.carStateLogMonoTime and + host_source_availability_only(events)) + if getattr(self, 'aol_fault_reason', 'none') == 'critical' and not admitted_expiry: + # A prior OFF/critical proof may describe ordinary SOFT loop lag rather + # than an authority fault. Prove that exact published episode; never infer + # it from a current healthy native snapshot or clear authority_lost here. + published = getattr(self, 'aol_published_fault_proof', None) + if (published is None or len(published) != 9 or published[0] != self.aol_session_id or + published[1] != 'critical' or published[2] != getattr(self, 'aol_fault_event_ns', 0) or + published[3] != tuple(getattr(self, 'events_prev', ()))): + return False + prior_names = published[3] + stock_record = getattr(self, 'aol_published_stock_lateral_proof', None) + if (stock_user is not None and stock_record is not None and len(stock_record) == 2 and + stock_record[0] == published and stock_record[1] == stock_user): + # This USER was qualified at the actual completed prior publication, + # not inferred from today's healthy physical/native snapshot. + prior_names = tuple(name for name in prior_names if name != stock_user) + rules = self.events.event_rules + prior_faults = [name for name in prior_names if any(kind in rules.get(name, {}) + for kind in (ET.SOFT_DISABLE, ET.IMMEDIATE_DISABLE, ET.USER_DISABLE))] + if (getattr(self, 'aol_published_fault_reason', '') != 'critical' or + not getattr(self, 'aol_fault_event_ns', 0) or + EventName.selfdrivedLagging not in prior_faults or + not all(name in HOST_SOURCE_AVAILABILITY_EVENTS and + not any(kind in rules.get(name, {}) for kind in (ET.IMMEDIATE_DISABLE, ET.USER_DISABLE)) + for name in prior_faults) or not host_source_availability_only(events) or + any(published[4:])): + return False + source = self.aol_car_state_log_ns or getattr(self, 'car_state_retained_source_ns', 0) + held_metadata = bool(not getattr(self, 'car_state_samples', ()) and CS is accepted_state and + getattr(self, 'aol_publication_retained_intent', None) is accepted) + if not source and held_metadata: + source = int(accepted.carStateLogMonoTime) + if (proof[0] != self.aol_session_id or not (proof[8] or proof[9]) or accepted is None or source != int(accepted.carStateLogMonoTime) or + not 0 < source <= now_ns or now_ns - source <= INTENT_MAX_AGE_NS or + cp_identity(self.CP) != identity or companion.integrity_epoch != proof[5] or + accepted is None or accepted.producerSessionId != proof[4] or + prior_native is None or not prior_native.pandaInventory): + return False + samples = getattr(self, 'car_state_samples', ()) or ((CS, + bool(getattr(self, 'car_state_retained_valid', False) or held_metadata)),) + if any(not valid or not sample.canValid or sample.canTimeout or sample.steerFaultPermanent or + sample.steerFaultTemporary or sample.accFaulted or sample.doorOpen or sample.seatbeltUnlatched or + sample.brakePressed or sample.regenBraking or sample.gasPressed or physical_cancel(sample) or + sample.gearShifter not in (car.CarState.GearShifter.drive, car.CarState.GearShifter.sport, + car.CarState.GearShifter.low, car.CarState.GearShifter.brake) + for sample, valid in samples): + return False + def valid_metadata(stamp, value): + if (value is None or value.carStateLogMonoTime != stamp or + value.producerSessionId != proof[4] or not value.settingsQualified or + value.allowedLatch != accepted.allowedLatch or value.lateralArmed != accepted.lateralArmed or + value.pauseLongitudinal or value.pauseLateral and (proof[8] or not accepted.pauseLateral)): + return False + # Only the exact originally accepted tuple is identity after expiry. + # Every newer raw/SM tuple keeps its own original shape and 200ms lease; + # none supplies an active command or a renewed source/output deadline. + return bool(stamp == accepted.carStateLogMonoTime and value == accepted or + 0 < stamp <= value.observedMonoTime <= now_ns <= value.validUntilMonoTime and + 0 < value.validUntilMonoTime - value.observedMonoTime <= SAFETY_MAX_AGE_NS) + + # The raw-only cache retains every distinct tuple in a <=16-message step. + # Its exact validated receipts remain metadata when current() legitimately + # prunes the separate 30ms source-pair cache. Check every original restriction + # directly, including one no longer present in the conflated SM/sample cache. + accounted = 0 + for stamp, (value, epoch, producer) in companion.raw_projections.items(): + if stamp < accepted.carStateLogMonoTime: + continue + if epoch != proof[5] or producer != proof[4] or not valid_metadata(stamp, value): + return False + accounted += 1 + if accounted < getattr(self, 'aol_step_intent_drain_count', 0): + return False + # A newer conflated SM sample may add a restriction but cannot remove any + # already checked raw restriction. Invalid/reordered bursts also retain the + # existing integrity-epoch and cumulative-drain vetoes above. + for stamp, valid, value in companion.samples.values(): + if stamp > accepted.carStateLogMonoTime and (not valid or not valid_metadata(stamp, value)): + return False + latest = companion.latest + raw = latest[2] if latest is not None and latest[1] else None + if (latest is None or not accepted.carStateLogMonoTime <= latest[0] <= now_ns or + not valid_metadata(latest[0], raw)): + return False + native = native or native_provider(self.sm, self.CP, now_ns=now_ns, axis_session_id=self.aol_session_id) + lateral_intent_only = bool(proof[8] and not proof[9] and not self.enabled) + if (native is None or lateral_intent_only and not (accepted.allowedLatch and accepted.lateralArmed and + not accepted.pauseLateral and not getattr(self, 'aol_dm_lateral_inhibit', False) and + native.requestedLateral and native.lateralAllowed) or + not healthy_transport(self.sm, self.CP, CS, self.aol_session_id, now_ns, native) or + native.observedMonoTime < prior_native.observedMonoTime or + native.pandaSerial != prior_native.pandaSerial or + tuple(p.hardwareSerial for p in native.pandaInventory) != tuple(p.hardwareSerial for p in prior_native.pandaInventory)): + return False + self.aol_ordinary_source_native = native + return True + + +def fresh_ordinary_lag_continuation(self, CS, intent, native, now_ns): + """Continue established standard axes; native/inputs still own every grant.""" + if (EventName.selfdrivedLagging not in self.events.names or not self.enabled or not self.active or + self.state_machine.state != log.SelfdriveState.OpenpilotState.softDisabling or + not self.initialized or not self.aol_replay or not self.axis_transport_required or + self.aol_authority_lost or getattr(self, 'aol_availability_long_inhibit', False)): + return False + rules = self.events.event_rules + faults = [name for name in self.events.names if any(kind in rules.get(name, {}) + for kind in (ET.USER_DISABLE, ET.SOFT_DISABLE, ET.IMMEDIATE_DISABLE))] + lag_rules = rules.get(EventName.selfdrivedLagging, {}) + if (faults != [EventName.selfdrivedLagging] or ET.SOFT_DISABLE not in lag_rules or + ET.IMMEDIATE_DISABLE in lag_rules or ET.USER_DISABLE in lag_rules or self.events.contains(ET.ENABLE) or + self.events.contains(ET.OVERRIDE_LATERAL) or self.events.contains(ET.OVERRIDE_LONGITUDINAL) or + any(name != EventName.selfdrivedLagging and ET.NO_ENTRY in self.events.event_rules.get(name, {}) + for name in self.events.names)): + return False + anchor = getattr(self, 'aol_ordinary_source_anchor', None) + companion = getattr(self, 'aol_intent_companion', None) + if (anchor is None or intent is None or companion is None or not (anchor[0][8] or anchor[0][9]) or + anchor[0][0] != self.aol_session_id or companion.integrity_epoch != anchor[0][5] or + intent != anchor[2] or CS is not anchor[4] or intent.producerSessionId != anchor[0][4] or + cp_identity(self.CP) != anchor[3] or not getattr(self, 'conditional_car_state_valid', False) or + not 0 < self.aol_car_state_log_ns == intent.carStateLogMonoTime <= now_ns or + now_ns - self.aol_car_state_log_ns > INTENT_MAX_AGE_NS or + getattr(self, 'car_state_queue_invalid', False) or getattr(self, 'aol_selected_pair_future', False) or + not getattr(self, 'aol_step_intent_drain_ok', True) or not companion.last_drain_ok or + getattr(self, 'aol_step_intent_drain_count', 0) > MAX_COMPANIONS or + not self.sm.all_checks(['modelV2', 'extrinsicsCalibration', 'driverMonitoringState']) or + self.sm['extrinsicsCalibration'].calStatus != log.ExtrinsicsCalibration.Status.calibrated or + self.sm['driverMonitoringState'].alertLevel == AlertLevel.three or + self.sm['driverMonitoringState'].lockout or self.sm['driverMonitoringState'].alwaysOnLockout or + native is None or native is not getattr(self.aol_transport_pause, 'observed_native', None) or + native.observedMonoTime < anchor[1].observedMonoTime or native.pandaSerial != anchor[1].pandaSerial or + tuple(p.hardwareSerial for p in native.pandaInventory) != tuple(p.hardwareSerial for p in anchor[1].pandaInventory)): + return False + if intent.pauseLongitudinal or intent.pauseLateral and anchor[0][8]: + return False + for stamp, (value, epoch, producer) in companion.raw_projections.items(): + if stamp < anchor[0][1]: + continue + if (epoch != anchor[0][5] or producer != anchor[0][4] or value is None or + value.producerSessionId != anchor[0][4] or not value.settingsQualified or + value.allowedLatch != intent.allowedLatch or value.lateralArmed != intent.lateralArmed or + value.pauseLongitudinal or value.pauseLateral and (anchor[0][8] or not intent.pauseLateral) or + value.carStateLogMonoTime != stamp or not 0 < stamp <= value.observedMonoTime <= now_ns <= value.validUntilMonoTime or + not 0 < value.validUntilMonoTime - value.observedMonoTime <= SAFETY_MAX_AGE_NS): + return False + samples = getattr(self, 'car_state_samples', ()) or ((CS, True),) + return not any(not valid or not sample.canValid or sample.canTimeout or sample.steerFaultPermanent or + sample.steerFaultTemporary or sample.accFaulted or sample.doorOpen or sample.seatbeltUnlatched or + sample.brakePressed or sample.regenBraking or sample.gasPressed or physical_cancel(sample) or + sample.gearShifter not in (car.CarState.GearShifter.drive, car.CarState.GearShifter.sport, + car.CarState.GearShifter.low, car.CarState.GearShifter.brake) + for sample, valid in samples) diff --git a/openpilot/starpilot/aol/tests/__init__.py b/openpilot/starpilot/aol/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openpilot/starpilot/aol/tests/test_forte_integration.py b/openpilot/starpilot/aol/tests/test_forte_integration.py new file mode 100644 index 0000000000..527ab0a806 --- /dev/null +++ b/openpilot/starpilot/aol/tests/test_forte_integration.py @@ -0,0 +1,262 @@ +"""Synthetic parsed CAN and native axis/controller projection, not vehicle replay.""" +from dataclasses import replace +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +from opendbc.can import CANPacker, CANParser +from opendbc.car import Bus, structs +from opendbc.car.hyundai.interface import CarInterface +from opendbc.car.hyundai.values import CAR, DBC, HyundaiFlags +from opendbc.car.vehicle_model import VehicleModel +from opendbc.safety.tests.common import make_msg +from opendbc.safety.tests.libsafety import libsafety_py +from opendbc.safety.tests.test_hyundai import checksum +from openpilot.starpilot.aol.tests.test_forte_intent import params, settings +from openpilot.starpilot.car.hyundai.aol import create_intent, policy_for +from openpilot.starpilot.aol.runtime import current_native, decide_axes +from openpilot.starpilot.aol.wire import SAFETY_SERVICE, SafetyState, encode_safety +from openpilot.cereal import log +from openpilot.selfdrive.controls.lib.latcontrol_torque import LatControlTorque +from openpilot.starpilot.lateral.controller_selection import ControllerMode + + +class Samples(dict): + def __init__(self, native, stamp): + super().__init__({SAFETY_SERVICE: encode_safety(native)}) + self.valid = self.alive = self.seen = {SAFETY_SERVICE: True} + self.logMonoTime = {SAFETY_SERVICE: stamp} + + +class ForteStream: + def __init__(self, *, main_action=0, source=0x391, aol=True, car=CAR.KIA_FORTE_2019_NON_SCC, controller_mode=None): + self.source = source + self.cp = params(car, source=source) + policy = policy_for(self.cp) + if aol: + self.cp.safetyConfigs[0].safetyParam |= policy.safety_param_addition + self.cp.alternativeExperience |= policy.alternative_experience_addition + self.ci = CarInterface(self.cp) + self.ci.update([]) + self.packer = CANPacker(DBC[self.cp.carFingerprint][Bus.pt]) + self.output_parser = CANParser(DBC[self.cp.carFingerprint][Bus.pt], [('LKAS11', 100)], 0) + self.lateral_controller = (LatControlTorque(self.cp.as_reader(), self.ci, .01, controller_mode=controller_mode) + if controller_mode is not None else None) + self.vehicle_model = VehicleModel(self.cp) + self.vehicle_parameters = log.VehicleParameters.new_message(angleOffsetDeg=0., roll=0.) + self.desired_curvature = 0. + self.last_torque = 0. + self.last_normalized_torque = 0. + self.intent = create_intent(self.cp, settings(main=main_action)) + self.intent.settings = replace(self.intent.settings, enabled=aol) + self.safety = libsafety_py.libsafety # Harness preloads the qualified candidate. + self.now = 1_000_000_000 + self.count = 0 + self.reset() + + def reset(self): + self.safety.init_tests() + self.safety.set_alternative_experience(self.cp.alternativeExperience) + assert self.safety.set_safety_hooks(int(self.cp.safetyConfigs[0].safetyModel.raw), + self.cp.safetyConfigs[0].safetyParam) == 0 + self.safety.set_timer(self.now // 1000) + self.safety.set_aol_test_heartbeat(True) # Synthetic transport envelope only. + + def tick(self, *, main=False, cruise=False, bcm=False, clu=False, gear='D', eps=False, + fatal=False, rejection=0, cruise_button=0): + self.now += 10_000_000 + n = self.count + self.count += 1 + gears = self.ci.CS.shifter_values + gear_code = next(code for code, name in gears.items() if name == gear) + values: dict[str, dict[str, float]] = { + 'EMS16': {'CRUISE_LAMP_M': int(main), 'CRUISE_LAMP_S': int(cruise), 'AliveCounter': n % 4}, + 'WHL_SPD11': {'WHL_SPD_FL': 72, 'WHL_SPD_FR': 72, 'WHL_SPD_RL': 72, 'WHL_SPD_RR': 72, + 'WHL_SPD_AliveCounter_LSB': n % 4, 'WHL_SPD_AliveCounter_MSB': (n // 4) % 4}, + 'TCS13': {'AliveCounterTCS': n % 8}, + 'MDPS12': {'CF_Mdps_ToiUnavail': int(eps), 'CR_Mdps_StrColTq': 0., 'CR_Mdps_OutTq': 0.}, + 'SAS11': {'SAS_Angle': 0., 'SAS_Speed': 0.}, + 'CLU11': {'CF_Clu_AliveCnt1': n % 16, 'CF_Clu_CruiseSwState': cruise_button}, 'LVR12': {'CF_Lvr_Gear': gear_code}, + 'BCM_PO_11': {'LDA_BTN': int(bcm)}, 'CLU13': {'CF_Clu_LdwsLkasSW': int(clu)}, + 'CGW1': {'CF_Gway_DrvSeatBeltSw': 1}, + } + if self.source == 0x50c: + del values['BCM_PO_11'] + frames = [] + for name in ('EMS12', 'TCS11', 'TCS15', 'CLU15', 'ESP12', 'CGW2', *values): + frame = self.packer.make_can_msg(name, 0, values.get(name, {})) + if name in ('EMS16', 'WHL_SPD11', 'TCS13'): + frame = checksum(frame) + frames.append(frame) + frames.append(self.packer.make_can_msg('LKAS11', 2, {'CF_Lkas_MsgCount': n % 16})) + if not self.cp.flags & HyundaiFlags.NON_SCC_NO_FCA: + frames.append(self.packer.make_can_msg('FCA11', 2, {})) + self.safety.set_timer(self.now // 1000) + for address, data, bus in frames: + self.safety.safety_rx_hook(make_msg(bus, address, len(data), data)) + self.safety.safety_tick() + with patch('opendbc.car.hyundai.non_scc_aol.time.CLOCK_BOOTTIME', 7, create=True), \ + patch('opendbc.car.hyundai.non_scc_aol.time.clock_gettime_ns', return_value=self.now): + self.cs = self.ci.update([(self.now, frames)]) + self.intent.update(self.cs, now_ns=self.now, fault_active=fatal, native_rejection_ns=rejection) + return self.axis() + + def axis(self, receipt=None): + intent = SimpleNamespace(allowedLatch=self.intent.allowed_latch, pauseLateral=self.intent.pause_lateral, + pauseLongitudinal=self.intent.pause_longitudinal) + kwargs: dict = {'standard_lateral': False, 'standard_longitudinal': False, 'intent': intent, + 'car_state': self.cs, 'initialized': True, 'model_ready': True, 'no_entry': False, + 'immediate_disable': False, 'dm_lockout': False, 'pause_brake_mps': 0.} + desired = decide_axes(native=None, **kwargs) + self.safety.aol_set_host_request(int(desired.desired_lateral)) + permission = self.safety.aol_get_permission_mask() + native = SafetyState(1, True, self.now, self.now + 30_000_000, + int(self.cp.safetyConfigs[0].safetyModel.raw), self.cp.safetyConfigs[0].safetyParam, + bool(permission & 1), False, bool(self.safety.aol_get_request_mask() & 1), False, 'synthetic-panda', 'session') + if receipt is not None: + native = receipt(native) + acknowledged = current_native(Samples(native, self.now), self.cp, now_ns=self.now, axis_session_id='session') + decision = decide_axes(native=acknowledged, **kwargs) + command = structs.CarControl() + command.latActive = decision.lateral_active + if self.lateral_controller is not None: + torque, _, _ = self.lateral_controller.update( + decision.lateral_active, self.cs, self.vehicle_model, self.vehicle_parameters, False, + self.desired_curvature, False, self.cp.steerActuatorDelay) + command.actuators.torque = float(torque) + else: + command.actuators.torque = 0.01 if decision.lateral_active else 0. + self.last_normalized_torque = float(command.actuators.torque) + _, packets = self.ci.apply(command.as_reader(), self.now) + lkas = [packet for packet in packets if packet[0] == 0x340] + assert lkas, 'Actual controller must emit LKAS' + self.output_parser.update((self.now, lkas)) + self.last_torque = self.output_parser.vl['LKAS11']['CR_Lkas_StrToqReq'] + for address, data, bus in lkas: + assert self.safety.safety_tx_hook(make_msg(bus, address, len(data), data)), 'Actual LKAS rejected' + return decision + + +class TestForteIntegration(unittest.TestCase): + def tearDown(self): + libsafety_py.libsafety.set_alternative_experience(0) + + def warm(self, stream, **kwargs): + for _ in range(12): + result = stream.tick(**kwargs) + self.assertTrue(stream.cs.canValid) + return result + + def test_both_forte_controllers_reach_native_with_original_torque_limit(self): + for car in (CAR.KIA_FORTE_2019_NON_SCC, CAR.KIA_FORTE_2021_NON_SCC): + for source in (0, 0x391): + for mode in (ControllerMode.STANDARD, ControllerMode.STARPILOT): + for direction in (-1., 1.): + with self.subTest(car=car, source=source, mode=mode, direction=direction): + case = f'{car}, source={source:#x}, controller={mode}, direction={direction}' + stream = ForteStream(car=car, source=source, main_action=9, controller_mode=mode) + self.assertEqual(stream.cp.safetyConfigs[0].safetyParam, 0x1c00 if source else 0x1400) + self.assertEqual(stream.cp.alternativeExperience, 32) + self.assertEqual(stream.lateral_controller.controller_mode, mode) + self.assertEqual(bool(stream.lateral_controller.starpilot_extension), mode == ControllerMode.STARPILOT) + self.assertTrue(self.warm(stream, main=True).lateral_active, case) + self.assertGreater(stream.cs.vEgo, 19., case) + self.assertEqual(stream.cs.steeringAngleDeg, 0., case) + self.assertEqual(stream.cs.steeringTorque, 0., case) + self.assertFalse(stream.cs.steeringPressed, case) + stream.desired_curvature = direction * .01 + for _ in range(180): + self.assertTrue(stream.tick(main=True).lateral_active, case) + self.assertLessEqual(abs(stream.last_torque), 255, case) + context = (f'{case}; vEgo={stream.cs.vEgo}, angle={stream.cs.steeringAngleDeg}, ' + + f'driverTorque={stream.cs.steeringTorque}, normalized={stream.last_normalized_torque}, ' + + f'sentTorque={stream.last_torque}, factor={stream.lateral_controller.torque_params.latAccelFactor}') + self.assertGreater(abs(stream.last_torque), 200, context) + self.assertLess(stream.last_torque * direction, 0., context) + if mode == ControllerMode.STANDARD: + self.assertEqual(stream.last_torque, -direction * 255, context) + self.assertFalse(stream.tick(main=False).lateral_active, case) + self.assertEqual(stream.last_torque, 0, case) + + def test_native_reset_requires_neutral_and_new_physical_gesture(self): + s = ForteStream() + self.warm(s) + self.assertTrue(s.tick(bcm=True).lateral_active) + s.reset() + self.assertFalse(self.warm(s, bcm=True).lateral_active) + s.tick(bcm=True, rejection=s.now + 10_000_000) + self.assertFalse(s.tick(bcm=True).lateral_active) + s.tick() + self.assertTrue(s.tick(bcm=True).lateral_active) + + def test_main_boot_both_button_orders_and_combined_sources(self): + s = ForteStream(main_action=9) + self.assertTrue(self.warm(s, main=True).lateral_active) + self.assertFalse(s.tick(main=True, cruise=True, bcm=True).lateral_active) + s.tick(main=True, cruise=True) + self.assertTrue(s.tick(main=True, cruise=True, clu=True).lateral_active) + t = ForteStream() + self.warm(t) + self.assertTrue(t.tick(bcm=True).lateral_active) + self.assertTrue(t.tick(bcm=True, clu=False).lateral_active) + self.assertTrue(t.tick(bcm=True, clu=False).lateral_active) + t.tick() + self.assertFalse(t.tick(clu=True).lateral_active) + + def test_wrong_session_stale_receipt_and_output_pause(self): + s = ForteStream(main_action=9) + self.assertTrue(self.warm(s, main=True).lateral_active) + self.assertFalse(s.axis(lambda n: replace(n, axisSessionId='old')).lateral_active) + self.assertFalse(s.axis(lambda n: replace(n, validUntilMonoTime=s.now - 1)).lateral_active) + self.assertFalse(s.tick(main=True, eps=True).lateral_active) + self.assertTrue(s.intent.allowed_latch) + self.assertFalse(s.tick(main=True, gear='R').lateral_active) + self.assertTrue(s.tick(main=True).lateral_active) + self.assertFalse(s.tick(main=True, fatal=True).lateral_active) + self.assertFalse(s.tick(main=True).lateral_active) + s.tick(main=False) + self.assertTrue(s.tick(main=True).lateral_active) + + def test_50c_only_ordinary_profile_does_not_require_absent_bcm(self): + s = ForteStream(source=0x50c, aol=False) + self.assertEqual(s.cp.safetyConfigs[0].safetyParam, 0x1000) + self.assertEqual(s.cp.alternativeExperience, 0) + self.assertFalse(self.warm(s, main=True).lateral_active) + s.tick(main=True, cruise_button=2) # Actual SET interaction precedes cruise rising. + self.assertFalse(s.tick(main=True, cruise=True).lateral_active) + self.assertTrue(s.cs.cruiseState.available) + self.assertTrue(s.cs.cruiseState.enabled) + self.assertTrue(s.safety.get_controls_allowed()) + self.assertEqual(s.safety.aol_get_permission_mask(), 0) + + def test_invalid_main_off_cannot_rearm_and_old_denial_preserves_new_edge(self): + s = ForteStream(main_action=9) + self.warm(s, main=True) + s.tick(main=True, fatal=True) + # A malformed/unhealthy state envelope is not confirmed main-OFF evidence. + # Parsed CAN remains the source; the health failure is explicitly injected. + s.cs.canValid = False + s.cs.cruiseState.available = False + s.intent.update(s.cs, now_ns=s.now + 1, fault_active=False) + self.assertFalse(s.tick(main=True).lateral_active) + s.tick(main=False) + self.assertTrue(s.tick(main=True).lateral_active) + t = ForteStream() + self.warm(t) + old_denial = t.now + self.assertTrue(t.tick(bcm=True).lateral_active) + self.assertTrue(t.tick(bcm=True, rejection=old_denial).lateral_active) + + def test_unselected_clu_source_grants_only_after_its_own_fresh_neutral(self): + s = ForteStream() + # BCM arrives first and remains the required native-health alternative. + # CLU's independent neutral and press must still reach the port owner. + self.warm(s) + self.assertFalse(s.safety.get_controls_allowed()) + self.assertTrue(s.tick(clu=True).lateral_active) + self.assertTrue(s.intent.allowed_latch) + self.assertEqual(s.safety.aol_get_permission_mask(), 1) + self.assertFalse(s.safety.get_controls_allowed()) + self.assertTrue(s.tick(clu=True, bcm=False).lateral_active) + s.tick() + self.assertFalse(s.tick(clu=True).lateral_active) diff --git a/openpilot/starpilot/aol/tests/test_forte_intent.py b/openpilot/starpilot/aol/tests/test_forte_intent.py new file mode 100644 index 0000000000..cc646ccc5a --- /dev/null +++ b/openpilot/starpilot/aol/tests/test_forte_intent.py @@ -0,0 +1,223 @@ +import unittest +from unittest.mock import patch + +from opendbc.can import CANPacker +from opendbc.car import Bus, gen_empty_fingerprint, structs +from opendbc.car.hyundai.interface import CarInterface +from opendbc.car.hyundai.forte_aol import FORTE_IDS, FORTE_AOL_MARKER, FORTE_AOL_EXPERIENCE, ForteLkasSources +from opendbc.car.hyundai.values import CAR, DBC, HyundaiFlags +from openpilot.starpilot.aol.intent import AolSettings +from openpilot.starpilot.aol.runtime import decide_axes +from openpilot.starpilot.car.hyundai.aol import policy_for, create_intent, native_profile_supported, native_accepts_cp +from types import SimpleNamespace + +Button = structs.CarState.ButtonEvent.Type + + +def params(car=CAR.KIA_FORTE_2019_NON_SCC, source=0x391): + fp = gen_empty_fingerprint() + if source: + fp[0][source] = 8 + return CarInterface.get_params(car, fp, [], False, False, False) + + +def settings(lkas=9, main=0): + return AolSettings(True, 0., lkas, main, (0, 0, 0), (0, 0, 0)) + + +def state(main=False, cruise=False, events=()): + cs = structs.CarState() + cs.canValid = True + cs.gearShifter = 'drive' + cs.cruiseState.available, cs.cruiseState.enabled = main, cruise + cs.buttonEvents = [structs.CarState.ButtonEvent(type=button, pressed=pressed) for button, pressed in events] + return cs + + +class TestForteIntent(unittest.TestCase): + def test_actual_cp_profile_additions_and_exact_family_isolation(self): + for car in FORTE_IDS: + for source in (0, 0x391, 0x50c): + cp = params(car, source) + policy = policy_for(cp) + self.assertTrue(policy.intent_supported) + self.assertEqual(policy.safety_param_addition, FORTE_AOL_MARKER | (0x800 if source else 0)) + self.assertEqual(cp.safetyConfigs[0].safetyParam, 0x1800 if source == 0x391 else 0x1000) + self.assertEqual(policy.alternative_experience_addition, FORTE_AOL_EXPERIENCE) + cp.safetyConfigs[0].safetyParam |= policy.safety_param_addition + cp.alternativeExperience |= policy.alternative_experience_addition + self.assertTrue(native_profile_supported(int(cp.safetyConfigs[0].safetyModel.raw), cp.safetyConfigs[0].safetyParam)) + self.assertTrue(native_accepts_cp(cp, int(cp.safetyConfigs[0].safetyModel.raw), cp.safetyConfigs[0].safetyParam)) + bad = cp.as_reader().as_builder() + bad.safetyConfigs[0].safetyParam ^= 0x800 + self.assertFalse(policy_for(bad).intent_supported) + for flag in (HyundaiFlags.LEGACY, HyundaiFlags.CAMERA_SCC, HyundaiFlags.CANFD, HyundaiFlags.FCEV, HyundaiFlags.CCNC): + bad = cp.as_reader().as_builder() + bad.flags |= int(flag) + self.assertFalse(policy_for(bad).intent_supported) + for field in ('passive', 'dashcamOnly', 'notCar'): + bad = cp.as_reader().as_builder() + setattr(bad, field, True) + self.assertFalse(policy_for(bad).intent_supported) + from opendbc.car.hyundai.non_scc_aol import qualified + self.assertFalse(qualified(params(CAR.KIA_FORTE))) + self.assertFalse(native_profile_supported(int(structs.CarParams.SafetyModel.hyundai), 0x1000)) + + def test_main_boot_and_assignment_defaults_and_symmetric_combined_pause(self): + for car in FORTE_IDS: + managed = create_intent(params(car), settings(main=9)) + managed.update(state(main=True), now_ns=1) + self.assertTrue(managed.allowed_latch) + managed.update(state(main=True, cruise=True, events=((Button.lkas, False),)), now_ns=2) + for tick in (3, 5): + managed.update(state(main=True, cruise=True, events=((Button.lkas, True),)), now_ns=tick) + self.assertEqual(managed.pause_lateral, tick == 3) + self.assertTrue(managed.allowed_latch) + managed.update(state(main=True, cruise=True, events=((Button.lkas, False),)), now_ns=tick + 1) + for source, expected in ((0, True), (0x391, False), (0x50c, False)): + implicit = create_intent(params(car, source), settings(lkas=0)) + implicit.update(state(main=True), now_ns=1) + self.assertEqual(implicit.allowed_latch, expected) + managed.settings = settings(lkas=0, main=0) + managed.update(state(main=True), now_ns=8) + self.assertFalse(managed.allowed_latch) + managed.settings = settings(lkas=0, main=9) + managed.update(state(main=True), now_ns=9) + self.assertTrue(managed.allowed_latch) + + def test_lkas_native_denial_fatal_rearm_and_temporary_output_pause(self): + intent = create_intent(params(), settings()) + intent.update(state(events=((Button.lkas, False),)), now_ns=1) + intent.update(state(events=((Button.lkas, True),)), now_ns=2) + self.assertTrue(intent.allowed_latch) + cs = state() + cs.steerFaultTemporary = True + intent.update(cs, now_ns=3) + self.assertTrue(intent.allowed_latch) + cs.gearShifter = 'reverse' + intent.update(cs, now_ns=4) + self.assertTrue(intent.allowed_latch) + self.assertFalse(intent.output(cs)[0]) + intent.update(state(), now_ns=5, native_rejection_ns=5) + self.assertFalse(intent.allowed_latch) + intent.update(state(events=((Button.lkas, True),)), now_ns=6) + self.assertFalse(intent.allowed_latch) + intent.update(state(events=((Button.lkas, False),)), now_ns=7) + intent.update(state(events=((Button.lkas, True),)), now_ns=8) + self.assertTrue(intent.allowed_latch) + intent.update(state(), now_ns=9, fault_active=True) + self.assertFalse(intent.allowed_latch) + intent.update(state(), now_ns=10) + self.assertFalse(intent.allowed_latch) + + intent.update(state(events=((Button.lkas, False),)), now_ns=11) + intent.update(state(events=((Button.lkas, True),)), now_ns=20) + self.assertTrue(intent.allowed_latch) + intent.update(state(), now_ns=21, native_rejection_ns=19) + self.assertTrue(intent.allowed_latch) # Older denial cannot revoke a newer physical gesture. + managed = create_intent(params(), settings(main=9)) + managed.update(state(main=True), now_ns=1) + managed.update(state(main=True), now_ns=2, fault_active=True) + bad = state(main=False) + bad.canValid = False + managed.update(bad, now_ns=3) + managed.update(state(main=True), now_ns=4) + self.assertFalse(managed.allowed_latch) + managed.settings = settings(lkas=0, main=9) + managed.update(state(main=True, events=((Button.lkas, False),)), now_ns=5) + managed.update(state(main=True, events=((Button.lkas, True),)), now_ns=6) + self.assertFalse(managed.allowed_latch) + managed.update(state(main=False), now_ns=7) + managed.update(state(main=True), now_ns=8) + self.assertTrue(managed.allowed_latch) + + def test_transient_fault_preserves_released_input_without_replaying_held_input(self): + for fault in ('invalid', 'fatal', 'rejected'): + for held in (False, True): + with self.subTest(fault=fault, held=held): + intent = create_intent(params(), settings()) + intent.update(state(events=((Button.lkas, False),)), now_ns=1) + if held: + intent.update(state(events=((Button.lkas, True),)), now_ns=2) + self.assertTrue(intent.allowed_latch) + failed = state() + failed.canValid = fault != 'invalid' + intent.update(failed, now_ns=3, fault_active=fault == 'fatal', native_rejection_ns=3 if fault == 'rejected' else 0) + self.assertFalse(intent.allowed_latch) + intent.update(state(), now_ns=4, fault_active=False) + self.assertFalse(intent.allowed_latch) + intent.update(state(events=((Button.lkas, True),)), now_ns=5, fault_active=False) + self.assertEqual(intent.allowed_latch, not held) + if held: + intent.update(state(events=((Button.lkas, False),)), now_ns=6, fault_active=False) + intent.update(state(events=((Button.lkas, True),)), now_ns=7, fault_active=False) + self.assertTrue(intent.allowed_latch) + + def test_actual_ci_ordered_physical_or_and_held_initialization(self): + for car in FORTE_IDS: + cp = params(car) + ci = CarInterface(cp) + packer = CANPacker(DBC[car][Bus.pt]) + ci.update([]) + def packet(address, pressed, packer=packer): + return packer.make_can_msg('BCM_PO_11' if address == 0x391 else 'CLU13', 0, + {'LDA_BTN' if address == 0x391 else 'CF_Clu_LdwsLkasSW': int(pressed)}) + clock = 1_000_000_000 + def update(samples, ci=ci, packet=packet): + nonlocal clock + packets = [] + for address, pressed in samples: + clock += 1_000_000 + packets.append((clock, [packet(address, pressed)])) + with patch('opendbc.car.hyundai.non_scc_aol.time.CLOCK_BOOTTIME', 7, create=True), \ + patch('opendbc.car.hyundai.non_scc_aol.time.clock_gettime_ns', return_value=clock): + ret = ci.update(packets) + return [event.pressed for event in ret.buttonEvents if event.type == Button.lkas] + self.assertEqual(update(((0x391, True), (0x50c, False))), []) + self.assertEqual(update(((0x391, False),)), [False]) + self.assertEqual(update(((0x391, True), (0x50c, False), (0x391, True))), [True]) + self.assertEqual(update(((0x391, False),)), [False]) + self.assertEqual(update(((0x50c, True), (0x50c, False))), [True, False]) + clock += 300_000_001 + self.assertEqual(update(((0x391, True),)), []) + self.assertEqual(update(((0x391, True),)), []) + self.assertEqual(update(((0x391, False), (0x391, True), (0x391, False))), [False, True, False]) + + def test_new_or_expired_source_must_show_own_neutral_without_phantom_edges(self): + owner = ForteLkasSources() + clock = 1_000_000_000 + def update(samples): + nonlocal clock + packets = [] + for address, pressed in samples: + clock += 1_000_000 + data = bytearray(8) + data[0 if address == 0x391 else 7] = 0x10 if address == 0x391 and pressed else int(pressed) + packets.append((clock, [(address, bytes(data), 0)])) + with patch('opendbc.car.hyundai.non_scc_aol.time.CLOCK_BOOTTIME', 7, create=True), \ + patch('opendbc.car.hyundai.non_scc_aol.time.clock_gettime_ns', return_value=clock): + owner.update(packets) + return owner.edges + self.assertEqual(update(((0x391, False),)), [False]) + self.assertEqual(update(((0x50c, True),)), []) + self.assertEqual(update(((0x50c, False),)), [False]) + self.assertEqual(update(((0x50c, True), (0x50c, False))), [True, False]) + self.assertEqual(update(((0x391, True),)), [True]) + clock += 300_000_001 # Lost release: expiry cannot fabricate a replacement gesture. + self.assertEqual(update(((0x391, True), (0x50c, False))), []) + self.assertEqual(update(((0x391, False), (0x391, True))), [False, True]) + + def test_unacknowledged_native_denial_and_wrong_request_remain_neutral(self): + cs = state(main=True) + intent = SimpleNamespace(pauseLateral=False, pauseLongitudinal=False, allowedLatch=True) + common: dict = {'standard_lateral': False, 'standard_longitudinal': False, 'intent': intent, 'car_state': cs, + 'initialized': True, 'model_ready': True, 'no_entry': False, 'immediate_disable': False, 'dm_lockout': False, 'pause_brake_mps': 0.} + for native in (None, SimpleNamespace(requestedLateral=True, requestedLongitudinal=False, lateralAllowed=False), + SimpleNamespace(requestedLateral=False, requestedLongitudinal=False, lateralAllowed=True)): + result = decide_axes(native=native, **common) + self.assertTrue(result.desired_lateral) + self.assertFalse(result.lateral_active) + native = SimpleNamespace(requestedLateral=True, requestedLongitudinal=False, lateralAllowed=True) + self.assertTrue(decide_axes(native=native, **common).lateral_active) + cs.steerFaultTemporary = True + self.assertFalse(decide_axes(native=native, **common).lateral_active) diff --git a/openpilot/starpilot/aol/tests/test_gm_ascm.py b/openpilot/starpilot/aol/tests/test_gm_ascm.py new file mode 100644 index 0000000000..0dc5b88bd5 --- /dev/null +++ b/openpilot/starpilot/aol/tests/test_gm_ascm.py @@ -0,0 +1,78 @@ +"""Independent axes use the finalized ordinary ASCM vehicle owner.""" + +import unittest + +from opendbc.car.gm.aol import qualified_gm +from opendbc.car.gm.tests.test_ascm_intercept import params +from opendbc.car.gm.values import ORDINARY_ASCM_CAR +from openpilot.starpilot.aol.vehicle import policy_for, create_intent +from openpilot.starpilot.aol.intent import AolSettings + + +class TestGmAscmAol(unittest.TestCase): + def test_actual_finalized_cp_and_default_off(self): + settings = AolSettings(False, 0., 0, 0, (0, 0, 0), (0, 0, 0)) + for identity in ORDINARY_ASCM_CAR: + for release in (False, True): + for alpha in (False, True): + for c9 in (False, True): + for radar in (False, True): + cp = params(identity, sascm=True, alpha=alpha, release=release, accelerator=not c9, radar=radar) + self.assertTrue(qualified_gm(cp)) + self.assertEqual(cp.alternativeExperience, 0) + policy = policy_for(cp) + self.assertTrue(policy.normal_runtime_supported) + intent = create_intent(cp, settings, policy) + self.assertFalse(intent.allowed_latch) + cp.alternativeExperience = 32 + self.assertTrue(qualified_gm(cp)) + cp.alternativeExperience = 33 + self.assertFalse(qualified_gm(cp)) + + def test_profile_integrity_and_finite_tune_boundaries(self): + for identity in ORDINARY_ASCM_CAR: + cp = params(identity, sascm=True) + for field in ('passive', 'dashcamOnly', 'notCar'): + setattr(cp, field, True) + self.assertFalse(qualified_gm(cp)) + setattr(cp, field, False) + cp.safetyConfigs[0].safetyParam |= 4 + self.assertFalse(qualified_gm(cp)) + cp = params(identity, sascm=True) + if cp.lateralTuning.which() == 'torque': + cp.lateralTuning.torque.friction = float('nan') + else: + cp.lateralTuning.pid.kpV = [float('nan')] + self.assertFalse(qualified_gm(cp)) + + def test_actual_controller_cruise_off_lateral_only_and_default_off(self): + from opendbc.can import CANPacker + from opendbc.car import Bus, structs + from opendbc.car.gm.interface import CarInterface + from opendbc.car.gm.tests.test_volt_camera_control import feed_camera + from opendbc.car.gm.values import DBC + for identity in ORDINARY_ASCM_CAR: + for alpha in (False, True): + for alternative in (0, 32): + cp = params(identity, sascm=True, alpha=alpha) + cp.alternativeExperience = alternative + ci = CarInterface(cp) + packer = CANPacker(DBC[identity][Bus.pt]) + observed = [] + for tick in range(6): + now = 1_000_000_000 + tick * 40_000_000 + out, sources = feed_camera(ci, packer, now, counter=tick % 4, active=False) + sources = [packet for packet in sources if packet[0] != 0x370] + sources.append(packer.make_can_msg('ASCMActiveCruiseControlStatus', 2, {'ACCCruiseState': 0})) + out = ci.update([(now + 1, sources)]) + now += 1 + self.assertTrue(out.canValid) + self.assertTrue(out.cruiseState.available) + self.assertFalse(out.cruiseState.enabled) + command = structs.CarControl(enabled=False, latActive=alternative == 32, longActive=False) + command.actuators.torque = .1 + _, packets = ci.apply(command.as_reader(), now) + observed.extend(packet for packet in packets if packet[0] == 0x180) + self.assertTrue(observed) + nonneutral = any(((packet[1][0] & 7) << 8 | packet[1][1]) != 0 for packet in observed) + self.assertEqual(nonneutral, alternative == 32) diff --git a/openpilot/starpilot/aol/tests/test_intent_publication.py b/openpilot/starpilot/aol/tests/test_intent_publication.py new file mode 100644 index 0000000000..c4f15bdbe1 --- /dev/null +++ b/openpilot/starpilot/aol/tests/test_intent_publication.py @@ -0,0 +1,133 @@ +"""Exercise the real cross-socket wakeup between card and selfdrived.""" + +import time +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from opendbc.car.structs import car +from openpilot.cereal import messaging +from openpilot.common.prefix import OpenpilotPrefix +from openpilot.selfdrive.car.card import Car +from openpilot.selfdrive.selfdrived.selfdrived import SelfdriveD +from openpilot.starpilot.aol.runtime import current_intent, current_native, decide_axes +from openpilot.starpilot.aol.wire import IntentState, SafetyState, encode_intent, encode_safety + + +@pytest.mark.parametrize('pause_lateral,qualified,expected', [(False, True, 'combined'), (True, True, 'longitudinalOnly'), + (False, False, 'off')]) +def test_card_companion_is_available_when_car_state_wakes_selfdrive(pause_lateral, qualified, expected): + with OpenpilotPrefix(): + messaging.reset_context() + pm = messaging.PubMaster(['carState', 'carOutput', 'aolIntentWire', 'aolSafetyWire']) + sd = SelfdriveD.__new__(SelfdriveD) + sd.sm = messaging.SubMaster(['aolIntentWire', 'aolSafetyWire']) + sd.car_state_sock = messaging.sub_sock('carState', timeout=100) + sd.initialized, sd.enabled = True, False + sd.aol_car_state_log_ns, sd.conditional_car_state_valid = 0, False + sd.CS_prev = car.CarState.new_message(canValid=True, gearShifter=car.CarState.GearShifter.drive, vEgo=20.) + stamp = time.monotonic_ns() + prior = stamp - 29_350_000 # Route: 29 ms CAN gap plus 2.7 ms consumer latency. + old = messaging.new_message('aolIntentWire', 0, valid=True) + old.logMonoTime = prior + old.aolIntentWire = encode_intent(IntentState('card', 1, prior, prior, prior + 200_000_000, + True, False, False, True, True)) + pm.send('aolIntentWire', old) + sd.sm.update(100) + assert sd.sm.seen['aolIntentWire'] + # Native permission is current and unchanged throughout the race. + cp = car.CarParams.new_message() + cp.safetyConfigs = [car.CarParams.SafetyConfig(safetyModel=car.CarParams.SafetyModel.hondaBosch, safetyParam=34)] + native = messaging.new_message('aolSafetyWire', 0, valid=True) + native.logMonoTime = stamp + native.aolSafetyWire = encode_safety(SafetyState(1, True, stamp, stamp + 200_000_000, + int(car.CarParams.SafetyModel.hondaBosch), 34, True, True, True, True, 'panda', 'drive')) + pm.send('aolSafetyWire', native) + sd.sm.update(100) + assert sd.sm.seen['aolSafetyWire'] + decisions = [] + + def send(service, message): + pm.send(service, message) + if service == 'carState': + # Force the consumer to run at the earliest possible publication + # boundary, before card can execute its next line. Both receive paths + # are real sockets and data_sample retains its nonblocking SM update. + state = sd.data_sample() + now = stamp + 2_800_000 + intent = current_intent(sd.sm, car_state_ns=sd.aol_car_state_log_ns, now_ns=now) + ack = current_native(sd.sm, cp, now_ns=now, axis_session_id='drive') + assert ack is not None + if qualified: + assert intent is not None + assert intent.carStateLogMonoTime == sd.aol_car_state_log_ns == stamp + else: + assert intent is None + decisions.append(decide_axes(standard_lateral=True, standard_longitudinal=True, + intent=intent, native=ack, car_state=state, initialized=True, model_ready=True, + no_entry=False, immediate_disable=False, dm_lockout=False, pause_brake_mps=0)) + # The original 30 ms contract remains exact; no arrival-side renewal. + assert current_intent(sd.sm, car_state_ns=stamp, now_ns=stamp + 30_000_001) is None + + card = Car.__new__(Car) + card.__dict__.update(sm=SimpleNamespace(frame=1, all_checks=lambda _: True), pm=SimpleNamespace(send=send), + CP=cp, car_params_published=True, slc_replay=False, curve_replay=False, conditional_replay=False, + last_actuators_output=car.CarControl.Actuators.new_message(), can_rcv_cum_timeout_counter=0, + rk=SimpleNamespace(remaining=0), aol_replay=True, aol_qualified=qualified, aol_sequence=1, + slc_producer_session='card', aol_card_intent=SimpleNamespace(allowed_latch=True, output=lambda _: (True, pause_lateral, False)), + v_cruise_helper=SimpleNamespace(slc_cruise_change=None)) + create = messaging.new_message + + def stamped(service, *args, **kwargs): + message = create(service, *args, **kwargs) + if service == 'carState': + message.logMonoTime = stamp + return message + + with patch('openpilot.selfdrive.car.card.messaging.new_message', side_effect=stamped): + card.state_publish(sd.CS_prev, None) + assert len(decisions) == 1 + assert decisions[0].mode == expected + + +def test_future_companion_only_reuses_same_car_state_original_lease(): + with OpenpilotPrefix(): + messaging.reset_context() + pm = messaging.PubMaster(['carState', 'aolIntentWire']) + sd = SelfdriveD.__new__(SelfdriveD) + sd.sm = messaging.SubMaster(['aolIntentWire']) + sd.car_state_sock = messaging.sub_sock('carState', timeout=1) + sd.initialized, sd.enabled = True, False + sd.aol_car_state_log_ns, sd.conditional_car_state_valid = 0, False + sd.CS_prev = car.CarState.new_message(canValid=True, gearShifter=car.CarState.GearShifter.drive, vEgo=20.) + stamp = time.monotonic_ns() + + def publish_intent(at, *, valid=True, qualified=True, session='card'): + message = messaging.new_message('aolIntentWire', 0, valid=valid) + message.logMonoTime = at + message.aolIntentWire = encode_intent(IntentState(session, 1, at, at, at + 200_000_000, + True, True, False, qualified, True)) + pm.send('aolIntentWire', message) + + publish_intent(stamp) + state = messaging.new_message('carState', valid=True) + state.logMonoTime, state.carState = stamp, sd.CS_prev + pm.send('carState', state) + sd.CS_prev = sd.data_sample() + previous = current_intent(sd.sm, car_state_ns=stamp, now_ns=stamp + 1_000_000) + assert previous is not None + # Card has sent the next intent, but not its carState. Exercise the real + # carState socket timeout and the real nonblocking SubMaster receive. + publish_intent(stamp + 20_000_000) + with patch('openpilot.selfdrive.selfdrived.selfdrived.time.monotonic_ns', return_value=stamp + 23_000_000): + assert sd.data_sample() is sd.CS_prev + assert sd.aol_car_state_log_ns == stamp + assert current_intent(sd.sm, car_state_ns=stamp, now_ns=stamp + 23_000_000) is None + assert current_intent(sd.sm, car_state_ns=stamp, now_ns=stamp + 23_000_000, previous=previous) == previous + assert current_intent(sd.sm, car_state_ns=stamp, now_ns=stamp + 30_000_001, previous=previous) is None + assert current_intent(sd.sm, car_state_ns=stamp - 1, now_ns=stamp + 23_000_000, previous=previous) is None + for changes in ({'valid': False}, {'qualified': False}, {'session': 'new-card'}): + publish_intent(stamp + 20_000_000, **changes) + sd.sm.update(100) + assert current_intent(sd.sm, car_state_ns=stamp, now_ns=stamp + 23_000_000, previous=previous) is None diff --git a/openpilot/starpilot/aol/tests/test_ioniq6_stock_repair.py b/openpilot/starpilot/aol/tests/test_ioniq6_stock_repair.py new file mode 100644 index 0000000000..31dc575990 --- /dev/null +++ b/openpilot/starpilot/aol/tests/test_ioniq6_stock_repair.py @@ -0,0 +1,86 @@ +"""Exact stock Ioniq AOL admission and saved-request cleanup regression.""" +import ast +from pathlib import Path +from types import SimpleNamespace +import pytest +from opendbc.car.structs import car +from opendbc.car.hyundai.values import CAR, HyundaiFlags +from openpilot.starpilot.car.hyundai.aol import (policy_for, qualified_ioniq6, ioniq6_settings_capable, + native_profile_supported, native_accepts_cp) +from openpilot.starpilot.aol.intent import AolCardIntent, AolSettings + + +def stock(alternate): + cp = car.CarParams.new_message() + cp.brand = 'hyundai' + cp.carFingerprint = CAR.HYUNDAI_IONIQ_6 + cp.flags = int(HyundaiFlags.CANFD | HyundaiFlags.EV | HyundaiFlags.CANFD_LKA_STEER_MSG) + if alternate: + cp.flags |= int(HyundaiFlags.CANFD_LKA_STEER_MSG_ALT) + cp.pcmCruise = True + cp.openpilotLongitudinalControl = False + cp.radarUnavailable = False + cp.safetyConfigs = [{'safetyModel': car.CarParams.SafetyModel.hyundaiCanfd, + 'safetyParam': 0x91 if alternate else 0x11}] + return cp + + +@pytest.mark.parametrize('alternate', [False, True]) +def test_stock_marker_is_lateral_only_and_native_exact(alternate): + cp = stock(alternate) + original = int(cp.safetyConfigs[0].safetyParam) + policy = policy_for(cp) + assert policy.intent_supported and policy.runtime_supported and policy.normal_runtime_supported + assert policy.explicit_latch and policy.safety_param_addition == 0x0800 + assert not policy.distance_personality + assert ioniq6_settings_capable(cp) and not qualified_ioniq6(cp) + assert not native_accepts_cp(cp, int(car.CarParams.SafetyModel.hyundaiCanfd), original) + intent = AolCardIntent(AolSettings(True, 0., 0, 0, (0,0,0), (0,0,0)), explicit_latch=policy.explicit_latch) + assert intent.explicit_latch and not intent.allowed_latch + cp.safetyConfigs[0].safetyParam |= policy.safety_param_addition + assert qualified_ioniq6(cp) and ioniq6_settings_capable(cp) + assert native_profile_supported(int(car.CarParams.SafetyModel.hyundaiCanfd), original | 0x800) + assert native_accepts_cp(cp, int(car.CarParams.SafetyModel.hyundaiCanfd), original | 0x800) + assert cp.pcmCruise and not cp.openpilotLongitudinalControl and not cp.alphaLongitudinalAvailable + cp.flags |= int(HyundaiFlags.CANFD_ALT_BUTTONS) + assert not policy_for(cp).runtime_supported and not qualified_ioniq6(cp) + + +@pytest.mark.parametrize('alternate', [False, True]) +def test_existing_long_profile_retained(alternate): + cp = stock(alternate) + cp.pcmCruise = False + cp.openpilotLongitudinalControl = True + cp.safetyConfigs[0].safetyParam = 0x8895 if alternate else 0x8815 + assert qualified_ioniq6(cp) + assert policy_for(cp).intent_supported and policy_for(cp).explicit_latch + + +def cleanup(cp): + # Execute the actual saved-request cleanup nodes without initializing processes. + source = Path(__file__).parents[3] / 'selfdrive/selfdrived/selfdrived.py' + tree = ast.parse(source.read_text()) + cls = next(node for node in tree.body if isinstance(node, ast.ClassDef) and node.name == 'SelfdriveD') + method = next(node for node in cls.body if isinstance(node, ast.FunctionDef) and node.name == '__init__') + # Execute the real startup cleanup condition; constructor side effects stay absent. + nodes: list[ast.stmt] = [node for node in method.body if isinstance(node, ast.If) and any( + isinstance(call, ast.Call) and isinstance(call.func, ast.Attribute) and call.func.attr == 'remove' + and call.args and isinstance(call.args[0], ast.Constant) + and call.args[0].value in ('AlphaLongitudinalEnabled', 'ExperimentalMode') for call in ast.walk(node))] + calls = [] + host = SimpleNamespace(CP=cp, params=SimpleNamespace(remove=calls.append)) + exec(compile(ast.Module(body=nodes, type_ignores=[]), str(source), 'exec'), {'self': host}) + return calls + + +def test_actual_cleanup_preserves_request_without_enabling_long(): + cp = stock(True) + assert cleanup(cp) == ['ExperimentalMode'] + cp.passive = True + cp.dashcamOnly = True + assert not qualified_ioniq6(cp) and not ioniq6_settings_capable(cp) + assert cleanup(cp) == ['ExperimentalMode'] + cp.carFingerprint = CAR.HYUNDAI_IONIQ_5 + assert cleanup(cp) == ['ExperimentalMode'] + cp.openpilotLongitudinalControl = True + assert cleanup(cp) == [] diff --git a/openpilot/starpilot/aol/tests/test_runtime.py b/openpilot/starpilot/aol/tests/test_runtime.py new file mode 100644 index 0000000000..f5a01e7733 --- /dev/null +++ b/openpilot/starpilot/aol/tests/test_runtime.py @@ -0,0 +1,706 @@ +import time +import unittest +from unittest import mock +from types import SimpleNamespace +from pathlib import Path + +from openpilot.cereal import log, messaging +from openpilot.common.prefix import OpenpilotPrefix +from opendbc.car.structs import car +from openpilot.selfdrive.car.cruise import CRUISE_LONG_PRESS +from openpilot.common.params import Params +from openpilot.selfdrive.selfdrived.selfdrived import SelfdriveD +from openpilot.selfdrive.selfdrived.alertmanager import AlertManager +from openpilot.selfdrive.selfdrived.events import Events, ET +from openpilot.selfdrive.selfdrived.state import StateMachine +from openpilot.starpilot.aol.intent import AolCardIntent, AolSettings, disarming_fault, read_settings +from openpilot.starpilot.aol.runtime import AxisDecision, current_intent, current_native, decide_axes +from openpilot.starpilot.aol.wire import IntentState, SafetyState, encode_intent, encode_safety + + +def car_state(*, brake=False): + return car.CarState(canValid=True, gearShifter=car.CarState.GearShifter.drive, + vEgo=20.0, brakePressed=brake) + + +class CardIntentTests(unittest.TestCase): + def test_temporary_eps_and_known_gears_preserve_latch_with_zero_steering(self): + owner = AolCardIntent(AolSettings(True, 0, 0, 0, (0, 0, 0), (0, 0, 0)), explicit_latch=True) + state = car_state() + owner.update(state) + state.buttonEvents = [car.CarState.ButtonEvent(type=car.CarState.ButtonEvent.Type.lkas, pressed=True)] + owner.update(state) + state.buttonEvents = [car.CarState.ButtonEvent(type=car.CarState.ButtonEvent.Type.lkas, pressed=False)] + owner.update(state) + state.buttonEvents = [] + for gear, temporary, name in ( + (car.CarState.GearShifter.drive, True, log.OnroadEvent.EventName.steerTempUnavailable), + (car.CarState.GearShifter.neutral, False, log.OnroadEvent.EventName.wrongGear), + (car.CarState.GearShifter.reverse, False, log.OnroadEvent.EventName.wrongGear), + (car.CarState.GearShifter.park, False, log.OnroadEvent.EventName.wrongGear)): + with self.subTest(gear=gear, temporary=temporary): + state.gearShifter = gear + state.steerFaultTemporary = temporary + events = Events() + events.add(name) + fault = disarming_fault(events.to_msg(), state) + self.assertFalse(fault) + for _ in range(100): + owner.update(state, fault_active=fault) + self.assertTrue(owner.allowed_latch) + allowed, pause_lat, pause_long = owner.output(state) + decision = decide_axes(standard_lateral=False, standard_longitudinal=False, + intent=SimpleNamespace(allowedLatch=allowed, pauseLateral=pause_lat, pauseLongitudinal=pause_long), + native=SimpleNamespace(requestedLateral=False, requestedLongitudinal=False, lateralAllowed=False, longitudinalAllowed=False), + car_state=state, initialized=True, model_ready=True, no_entry=True, immediate_disable=False, + dm_lockout=False, pause_brake_mps=0) + self.assertFalse(decision.desired_lateral) + self.assertFalse(decision.lateral_active) + state.gearShifter = car.CarState.GearShifter.drive + state.steerFaultTemporary = False + owner.update(state, fault_active=False) + self.assertTrue(owner.output(state)[0]) + decision = decide_axes(standard_lateral=False, standard_longitudinal=False, + intent=SimpleNamespace(allowedLatch=owner.output(state)[0], pauseLateral=False, pauseLongitudinal=False), + native=SimpleNamespace(requestedLateral=True, requestedLongitudinal=False, lateralAllowed=True, longitudinalAllowed=False), + car_state=state, initialized=True, model_ready=True, no_entry=False, immediate_disable=False, + dm_lockout=False, pause_brake_mps=0) + self.assertTrue(decision.lateral_active) + + def test_low_speed_brake_suppresses_output_without_destroying_explicit_latch(self): + owner = AolCardIntent(AolSettings(True, 5.0, 0, 0, (0, 0, 0), (0, 0, 0)), explicit_latch=True) + state = car_state() + owner.update(state) + state.buttonEvents = [car.CarState.ButtonEvent(type=car.CarState.ButtonEvent.Type.lkas, pressed=True)] + owner.update(state) + state.buttonEvents = [] + events = Events() + events.add(log.OnroadEvent.EventName.pedalPressed) + for speed, brake, expected in ((2., True, False), (2., False, True), (6., True, True)): + state.vEgo, state.brakePressed = speed, brake + owner.update(state, fault_active=disarming_fault(events.to_msg(), state)) + self.assertTrue(owner.allowed_latch) + allowed, pause_lat, pause_long = owner.output(state) + axes = decide_axes(standard_lateral=False, standard_longitudinal=False, + intent=SimpleNamespace(allowedLatch=allowed, pauseLateral=pause_lat, pauseLongitudinal=pause_long), + native=SimpleNamespace(requestedLateral=expected, requestedLongitudinal=False, + lateralAllowed=expected, longitudinalAllowed=False), + car_state=state, initialized=True, model_ready=True, no_entry=False, immediate_disable=False, + dm_lockout=False, pause_brake_mps=5.0) + self.assertEqual(axes.lateral_active, expected) + self.assertFalse(axes.longitudinal_active) + + def test_permanent_and_unrelated_faults_still_disarm(self): + state = car_state() + for name in (log.OnroadEvent.EventName.steerUnavailable, log.OnroadEvent.EventName.controlsMismatch, + log.OnroadEvent.EventName.overheat): + events = Events() + events.add(name) + self.assertTrue(disarming_fault(events.to_msg(), state)) + events = Events() + events.add(log.OnroadEvent.EventName.wrongGear) + self.assertFalse(disarming_fault(events.to_msg(), state)) # Events may lag the return to Drive. + state.gearShifter = car.CarState.GearShifter.unknown + self.assertTrue(disarming_fault(events.to_msg(), state)) + state.steerFaultPermanent = True + self.assertTrue(disarming_fault([], state)) + owner = AolCardIntent(AolSettings(True, 0, 0, 0, (0, 0, 0), (0, 0, 0)), explicit_latch=True) + owner.allowed_latch = True + owner.update(state) + self.assertFalse(owner.allowed_latch) + + def test_native_rejection_rearms_with_one_new_press_without_replaying_old_receipt(self): + owner = AolCardIntent(AolSettings(True, 0, 0, 0, (0, 0, 0), (0, 0, 0)), explicit_latch=True) + state = car_state() + lkas = car.CarState.ButtonEvent.Type.lkas + owner.update(state, now_ns=100) + state.buttonEvents = [car.CarState.ButtonEvent(type=lkas, pressed=True)] + owner.update(state, now_ns=200) + self.assertTrue(owner.allowed_latch) + state.buttonEvents = [car.CarState.ButtonEvent(type=lkas, pressed=False)] + owner.update(state, now_ns=300) + state.buttonEvents = [] + owner.update(state, now_ns=400, native_rejection_ns=350) + self.assertFalse(owner.allowed_latch) + state.buttonEvents = [car.CarState.ButtonEvent(type=lkas, pressed=True)] + owner.update(state, now_ns=500, native_rejection_ns=350) + self.assertTrue(owner.allowed_latch) + state.buttonEvents = [] + owner.update(state, now_ns=600, native_rejection_ns=450) + self.assertTrue(owner.allowed_latch) # Delayed denial predates the new press. + owner.update(state, now_ns=700, native_rejection_ns=650) + self.assertFalse(owner.allowed_latch) + owner.update(state, now_ns=800) + self.assertFalse(owner.allowed_latch) # The held physical button cannot rearm. + state.buttonEvents = [car.CarState.ButtonEvent(type=lkas, pressed=False)] + owner.update(state, now_ns=900) + state.buttonEvents = [car.CarState.ButtonEvent(type=lkas, pressed=True)] + owner.update(state, now_ns=1000) + self.assertTrue(owner.allowed_latch) + + def test_native_rejection_and_fresh_press_same_frame_preserve_fault_inhibit(self): + for fault in (False, True): + owner = AolCardIntent(AolSettings(True, 0, 0, 0, (0, 0, 0), (0, 0, 0)), explicit_latch=True) + state = car_state() + owner.update(state, now_ns=100) + owner.allowed_latch = True + state.buttonEvents = [car.CarState.ButtonEvent(type=car.CarState.ButtonEvent.Type.lkas, pressed=True)] + owner.update(state, now_ns=200, native_rejection_ns=150, fault_active=fault) + self.assertEqual(owner.allowed_latch, not fault) + + def test_explicit_latch_requires_new_gesture_after_fault(self): + owner = AolCardIntent(AolSettings(True, 0, 0, 0, (0, 0, 0), (0, 0, 0)), explicit_latch=True) + state = car_state() + lkas = car.CarState.ButtonEvent.Type.lkas + owner.update(state, fault_active=False) + state.buttonEvents = [car.CarState.ButtonEvent(type=lkas, pressed=True)] + owner.update(state) + self.assertTrue(owner.allowed_latch) + + state.buttonEvents = [] + owner.update(state, fault_active=True) + self.assertFalse(owner.allowed_latch) + state.buttonEvents = [car.CarState.ButtonEvent(type=lkas, pressed=False)] + owner.update(state, fault_active=True) + state.buttonEvents = [car.CarState.ButtonEvent(type=lkas, pressed=True)] + owner.update(state, fault_active=True) + self.assertFalse(owner.allowed_latch) + state.buttonEvents = [car.CarState.ButtonEvent(type=lkas, pressed=False)] + owner.update(state) + state.buttonEvents = [car.CarState.ButtonEvent(type=lkas, pressed=True)] + owner.update(state) # No fresh clear event yet. + self.assertFalse(owner.allowed_latch) + + # A held button at recovery is not a new request. The driver must release it first. + owner.update(state, fault_active=False) + self.assertFalse(owner.allowed_latch) + state.buttonEvents = [car.CarState.ButtonEvent(type=lkas, pressed=False)] + owner.update(state) + state.buttonEvents = [car.CarState.ButtonEvent(type=lkas, pressed=True)] + owner.update(state) + self.assertTrue(owner.allowed_latch) + + def test_persisted_preferences_and_fresh_defaults(self): + with OpenpilotPrefix(): + params = Params() + fresh = read_settings(params) + self.assertFalse(fresh.enabled) + self.assertEqual(fresh.pause_brake_mps, 0.0) + self.assertEqual(fresh.distance_actions, (0, 0, 0)) + params.put_bool('AlwaysOnLateral', True, block=True) + params.put('PauseAOLOnBrake', 10.0, block=True) + params.put('DistanceButtonControl', 3, block=True) + saved = read_settings(params) + self.assertTrue(saved.enabled) + self.assertEqual(saved.pause_brake_mps, 10.0) + self.assertEqual(saved.distance_actions, (3, 0, 0)) + + def test_brake_pause_units_preserve_legacy_until_explicit_si_override(self): + with OpenpilotPrefix(): + params = Params() + params.put_bool('AlwaysOnLateral', True, block=True) + params.put('PauseAOLOnBrake', 10.0, block=True) + for metric in (False, True): + params.put_bool('IsMetric', metric, block=True) + self.assertEqual(read_settings(params).pause_brake_mps, 10.0) + params.put('AolBrakePauseSpeedMps', 4.4704, block=True) + self.assertAlmostEqual(read_settings(params).pause_brake_mps, 4.4704) + self.assertTrue(read_settings(params).enabled) + params.put('AolBrakePauseSpeedMps', 0.0, block=True) + self.assertEqual(read_settings(params).pause_brake_mps, 0.0) + params.put('AolBrakePauseSpeedMps', -1.0, block=True) + self.assertFalse(read_settings(params).enabled) + self.assertTrue(params.get_bool('AlwaysOnLateral')) + params.remove('AolBrakePauseSpeedMps') + self.assertEqual(read_settings(params).pause_brake_mps, 10.0) + + def test_invalid_threshold_never_falls_back_to_permissive_value(self): + with OpenpilotPrefix(): + params = Params() + params.put_bool('AlwaysOnLateral', True, block=True) + params.put('PauseAOLOnBrake', 10.0, block=True) + for key in ('AolBrakePauseSpeedMps', 'PauseAOLOnBrake'): + path = Path(params.get_param_path(key)) + for value in (b'', b'bad', b'\xff', b'nan', b'inf', b'-1', b'100.1'): + with self.subTest(key=key, value=value): + path.write_bytes(value) + self.assertFalse(read_settings(params).enabled) + self.assertTrue(params.get_bool('AlwaysOnLateral')) + path.unlink() + self.assertTrue(read_settings(params).enabled) + self.assertEqual(read_settings(params).pause_brake_mps, 0.0) + + def test_saved_threshold_controls_actual_brake_boundary(self): + with OpenpilotPrefix(): + params = Params() + params.put_bool('AlwaysOnLateral', True, block=True) + params.put('PauseAOLOnBrake', 10.0, block=True) + state = car_state(brake=True) + native = SimpleNamespace(requestedLateral=True, requestedLongitudinal=False, + lateralAllowed=True, longitudinalAllowed=False) + intent = SimpleNamespace(allowedLatch=True, lateralArmed=True, pauseLateral=False, pauseLongitudinal=False) + for metric in (False, True): + params.put_bool('IsMetric', metric, block=True) + for speed, mode in ((9.9, 'off'), (10.0, 'lateralOnly')): + state.vEgo = speed + result = decide_axes(standard_lateral=False, standard_longitudinal=False, intent=intent, + native=native, car_state=state, initialized=True, model_ready=True, + no_entry=False, immediate_disable=False, dm_lockout=False, + pause_brake_mps=read_settings(params).pause_brake_mps) + self.assertEqual(result.mode, mode) + + def test_honda_main_lkas_pause_and_cancel_ownership(self): + settings = AolSettings(True, 5.0, 9, 0, (3, 4, 0), (3, 0, 0)) + owner = AolCardIntent(settings) + state = car_state() + state.cruiseState.available = True + owner.update(state) + self.assertFalse(owner.output(state)[0]) # LKAS button owns the latch + + state.buttonEvents = [car.CarState.ButtonEvent(type=car.CarState.ButtonEvent.Type.lkas, pressed=True)] + owner.update(state) + self.assertTrue(owner.output(state)[0]) + + state.buttonEvents = [car.CarState.ButtonEvent(type=car.CarState.ButtonEvent.Type.cancel, pressed=True)] + owner.update(state) + self.assertFalse(owner.pause_lateral) # Honda cancel never doubles as pause + + state.buttonEvents = [car.CarState.ButtonEvent(type=car.CarState.ButtonEvent.Type.gapAdjustCruise, pressed=True)] + owner.update(state) + state.buttonEvents = [car.CarState.ButtonEvent(type=car.CarState.ButtonEvent.Type.gapAdjustCruise, pressed=False)] + owner.update(state) + self.assertTrue(owner.pause_lateral) + self.assertEqual(owner.output(state), (True, True, False)) + + def test_invalid_can_and_settings_disable_intent(self): + owner = AolCardIntent(AolSettings(True, 0, 0, 0, (0, 0, 0), (0, 0, 0))) + state = car_state() + state.cruiseState.available = True + owner.update(state) + self.assertTrue(owner.output(state)[0]) + state.canValid = False + owner.update(state) + self.assertFalse(owner.output(state)[0]) + + def test_interrupted_distance_hold_does_not_replay_and_keeps_deliberate_pause(self): + settings = AolSettings(True, 0, 0, 0, (3, 4, 0), (0, 0, 0)) + owner = AolCardIntent(settings) + state = car_state() + state.cruiseState.available = True + button = car.CarState.ButtonEvent.Type.gapAdjustCruise + owner.update(state) + state.buttonEvents = [car.CarState.ButtonEvent(type=button, pressed=True)] + owner.update(state) + state.buttonEvents = [] + for _ in range(5): + owner.update(state) + state.canValid = False + owner.update(state) + self.assertFalse(owner._held[int(button)]) + self.assertEqual(owner._timers[int(button)], 0) + state.canValid = True + state.buttonEvents = [car.CarState.ButtonEvent(type=button, pressed=False)] + owner.update(state) + self.assertFalse(owner.pause_lateral or owner.pause_longitudinal) + + # A complete user pause remains latched through CAN loss. + state.buttonEvents = [car.CarState.ButtonEvent(type=button, pressed=True)] + owner.update(state) + state.buttonEvents = [car.CarState.ButtonEvent(type=button, pressed=False)] + owner.update(state) + self.assertTrue(owner.pause_lateral) + state.canValid = False + owner.update(state) + self.assertTrue(owner.pause_lateral) + + # If another owner consumed a release, it cannot complete the held gesture. + state.canValid = True + state.buttonEvents = [car.CarState.ButtonEvent(type=button, pressed=True)] + owner.update(state) + state.buttonEvents = [car.CarState.ButtonEvent(type=button, pressed=False)] + owner.update(state, consumed_buttons=frozenset((int(button),))) + self.assertFalse(owner._held[int(button)]) + self.assertTrue(owner.pause_lateral) + + # CAN loss just before the long-press boundary cannot finish that gesture. + state.buttonEvents = [car.CarState.ButtonEvent(type=button, pressed=True)] + owner.update(state) + state.buttonEvents = [] + for _ in range(CRUISE_LONG_PRESS - 2): + owner.update(state) + self.assertFalse(owner.pause_longitudinal) + state.canValid = False + owner.update(state) + state.canValid = True + for _ in range(3): + owner.update(state) + self.assertFalse(owner.pause_longitudinal) + self.assertTrue(owner.pause_lateral) + + # An owner can reserve the button on a tick with no new button event. + state.buttonEvents = [car.CarState.ButtonEvent(type=button, pressed=True)] + owner.update(state) + state.buttonEvents = [] + for _ in range(CRUISE_LONG_PRESS - 2): + owner.update(state) + owner.update(state, consumed_buttons=frozenset((int(button),))) + state.buttonEvents = [car.CarState.ButtonEvent(type=button, pressed=False)] + owner.update(state) + self.assertFalse(owner.pause_longitudinal) + self.assertTrue(owner.pause_lateral) + + def test_held_at_start_distance_waits_for_neutral_before_all_press_lengths(self): + button = car.CarState.ButtonEvent.Type.gapAdjustCruise + for held_ticks, actions in ((1, (3, 0, 0)), (CRUISE_LONG_PRESS, (0, 4, 0)), + (CRUISE_LONG_PRESS * 5, (0, 0, 3))): + with self.subTest(held_ticks=held_ticks): + settings = AolSettings(True, 0, 0, 0, actions, (0, 0, 0)) + owner = AolCardIntent(settings, explicit_latch=True) + state = car_state() + state.buttonEvents = [car.CarState.ButtonEvent(type=button, pressed=True)] + owner.update(state) + state.buttonEvents = [] + for _ in range(held_ticks): + owner.update(state) + state.buttonEvents = [car.CarState.ButtonEvent(type=button, pressed=False)] + owner.update(state) + self.assertFalse(owner.pause_lateral or owner.pause_longitudinal) + state.buttonEvents = [car.CarState.ButtonEvent(type=button, pressed=True)] + owner.update(state) + state.buttonEvents = [] + for _ in range(held_ticks - 1): + owner.update(state) + state.buttonEvents = [car.CarState.ButtonEvent(type=button, pressed=False)] + owner.update(state) + self.assertEqual((owner.pause_lateral, owner.pause_longitudinal), + (held_ticks != CRUISE_LONG_PRESS, held_ticks == CRUISE_LONG_PRESS)) + + + + +class IpcAxisContractTests(unittest.TestCase): + def test_car_state_timeout_retains_only_original_fresh_valid_sample(self): + sd = SelfdriveD.__new__(SelfdriveD) + sd.car_state_sock = object() + sd.CS_prev = car_state() + sd.aol_car_state_log_ns = 0 + sd.conditional_car_state_valid = False + sd.initialized = True + sd.enabled = False + self.enterContext(mock.patch.object(sd, 'sm', SimpleNamespace(update=mock.Mock()), create=True)) + stamp = 1_000_000_000 + message = messaging.new_message('carState') + message.logMonoTime = stamp + message.valid = True + message.carState = sd.CS_prev + with (mock.patch('openpilot.selfdrive.selfdrived.selfdrived.messaging.recv_one', return_value=message) as recv, + mock.patch('openpilot.selfdrive.selfdrived.selfdrived.time.monotonic_ns', return_value=stamp) as now): + sd.CS_prev = sd.data_sample() + self.assertEqual(sd.aol_car_state_log_ns, stamp) + recv.return_value = None # 20 ms socket timeout, still inside the existing 30 ms lease. + now.return_value = stamp + 23_000_000 + self.assertIs(sd.data_sample(), sd.CS_prev) + self.assertEqual(sd.aol_car_state_log_ns, stamp) + self.assertTrue(sd.conditional_car_state_valid) + now.return_value = stamp + 30_000_001 + sd.data_sample() + self.assertEqual(sd.aol_car_state_log_ns, 0) + self.assertFalse(sd.conditional_car_state_valid) + + # A next-drive/new process has no retained source, even if CS_prev exists. + sd.data_sample() + self.assertEqual(sd.aol_car_state_log_ns, 0) + message.logMonoTime = stamp + 24_000_000 + recv.return_value = message + sd.CS_prev = sd.data_sample() + self.assertEqual(sd.aol_car_state_log_ns, stamp + 24_000_000) + for invalid_kind in ('valid', 'canValid', 'canTimeout', 'clock'): + with self.subTest(invalid_kind=invalid_kind): + message.valid = invalid_kind != 'valid' + message.carState.canValid = invalid_kind != 'canValid' + message.carState.canTimeout = invalid_kind == 'canTimeout' + recv.return_value = message + sd.CS_prev = sd.data_sample() + recv.return_value = None + now.return_value = message.logMonoTime + (-1 if invalid_kind == 'clock' else 20_000_000) + sd.data_sample() + self.assertEqual(sd.aol_car_state_log_ns, 0) + self.assertFalse(sd.conditional_car_state_valid) + + def test_lateral_only_receipt_loss_keeps_take_control_alert(self): + class SM: + frame = 0 + + def __getitem__(self, service): + if service == 'driverMonitoringState': + return SimpleNamespace(alertLevel=0, lockout=False, alwaysOnLockout=False) + if service == 'extrinsicsCalibration': + return SimpleNamespace(calStatus=log.ExtrinsicsCalibration.Status.calibrated) + raise KeyError(service) + + def all_checks(self, _services): + return True + + sd = SelfdriveD.__new__(SelfdriveD) + sd.CP = SimpleNamespace(passive=False, openpilotLongitudinalControl=True) + self.enterContext(mock.patch.object(sd, 'sm', SM(), create=True)) + sd.events = Events() + sd.state_machine = StateMachine() + sd.AM = AlertManager() + sd.personality = 1 + sd.is_metric = False + sd.enabled = sd.active = False + sd.initialized = True + sd.aol_replay = True + sd.aol_car_state_log_ns = 0 + sd.aol_session_id = 'drive-session' + sd.aol_axis_decision = AxisDecision() + sd.aol_dm_lateral_inhibit = False + sd.aol_settings = None + sd.nostalgia_paddle_cancel = False + self.enterContext(mock.patch.object(sd, 'data_sample', car_state)) + self.enterContext(mock.patch.object(sd, 'update_events', lambda _cs: sd.events.clear())) + sd.update_conditional_mode = mock.Mock() + sd.publish_selfdriveState = mock.Mock() + + native = SimpleNamespace(requestedLateral=True, requestedLongitudinal=False, + lateralAllowed=True, longitudinalAllowed=False) + intent = SimpleNamespace(allowedLatch=True, lateralArmed=True, pauseLateral=False, pauseLongitudinal=False) + with mock.patch('openpilot.selfdrive.selfdrived.selfdrived.current_intent', return_value=intent), \ + mock.patch('openpilot.selfdrive.selfdrived.selfdrived.current_native', side_effect=(None, native, None, None)): + sd.sm.frame = 1 + sd.step() + self.assertEqual(sd.aol_axis_decision.mode, 'off') + self.assertFalse(sd.enabled) + self.assertNotIn(ET.IMMEDIATE_DISABLE, sd.state_machine.current_alert_types) + self.assertNotEqual(sd.AM.current_alert.alert_text_1, 'TAKE CONTROL IMMEDIATELY') + + sd.sm.frame = 2 + sd.step() + self.assertEqual(sd.aol_axis_decision.mode, 'lateralOnly') + self.assertFalse(sd.enabled) + + sd.sm.frame = 3 + sd.step() + self.assertEqual(sd.aol_axis_decision.mode, 'off') + self.assertFalse(sd.enabled) + self.assertIn(ET.IMMEDIATE_DISABLE, sd.state_machine.current_alert_types) + self.assertEqual(sd.AM.current_alert.alert_text_1, 'TAKE CONTROL IMMEDIATELY') + self.assertEqual(sd.AM.current_alert.alert_text_2, 'Controls Mismatch') + alert_end_frame = sd.AM.alerts['controlsMismatch/immediateDisable'].end_frame + self.assertGreater(alert_end_frame, sd.sm.frame) + + sd.sm.frame = 4 + sd.step() + self.assertNotIn(ET.IMMEDIATE_DISABLE, sd.state_machine.current_alert_types) + self.assertEqual(sd.AM.current_alert.alert_text_1, 'TAKE CONTROL IMMEDIATELY') + self.assertEqual(sd.AM.alerts['controlsMismatch/immediateDisable'].end_frame, alert_end_frame) + + def test_lateral_only_temporary_fault_uses_existing_orange_warning(self): + class SM: + frame = 1 + + def __getitem__(self, service): + if service == 'driverMonitoringState': + return SimpleNamespace(alertLevel=0, lockout=False, alwaysOnLockout=False) + if service == 'extrinsicsCalibration': + return SimpleNamespace(calStatus=log.ExtrinsicsCalibration.Status.calibrated) + raise KeyError(service) + + def all_checks(self, _services): + return True + + sd = SelfdriveD.__new__(SelfdriveD) + sd.CP = SimpleNamespace(passive=False, openpilotLongitudinalControl=True) + self.enterContext(mock.patch.object(sd, 'sm', SM(), create=True)) + sd.events, sd.state_machine, sd.AM = Events(), StateMachine(), AlertManager() + sd.personality, sd.is_metric = 1, False + sd.enabled = sd.active = False + sd.initialized = sd.aol_replay = True + sd.aol_car_state_log_ns = 0 + sd.aol_session_id = 'drive-session' + sd.aol_axis_decision = AxisDecision() + sd.aol_dm_lateral_inhibit = False + sd.aol_settings = None + sd.nostalgia_paddle_cancel = False + state = car_state() + state.steerFaultTemporary = True + self.enterContext(mock.patch.object(sd, 'data_sample', return_value=state)) + self.enterContext(mock.patch.object(sd, 'update_events', lambda _cs: sd.events.clear())) + sd.update_conditional_mode = mock.Mock() + sd.publish_selfdriveState = mock.Mock() + native = SimpleNamespace(requestedLateral=False, requestedLongitudinal=False, + lateralAllowed=False, longitudinalAllowed=False) + intent = SimpleNamespace(allowedLatch=True, lateralArmed=True, pauseLateral=False, pauseLongitudinal=False) + with (mock.patch('openpilot.selfdrive.selfdrived.selfdrived.current_intent', return_value=intent), + mock.patch('openpilot.selfdrive.selfdrived.selfdrived.current_native', return_value=native)): + sd.step() + self.assertFalse(sd.enabled) + self.assertFalse(sd.aol_axis_decision.desired_lateral) + self.assertFalse(sd.aol_axis_decision.lateral_active) + self.assertEqual(sd.AM.current_alert.alert_type, 'steerTempUnavailableSilent/warning') + self.assertEqual(sd.AM.current_alert.alert_status, log.SelfdriveState.AlertStatus.userPrompt) + self.assertEqual(sd.AM.current_alert.alert_text_1, 'Steering Assist Temporarily Unavailable') + state.steerFaultTemporary = False + native.requestedLateral = native.lateralAllowed = True + sd.sm.frame += 1 + sd.step() + self.assertTrue(sd.aol_axis_decision.lateral_active) + self.assertNotIn(log.OnroadEvent.EventName.steerTempUnavailableSilent, sd.events.names) + + def test_native_receipt_gates_engagement_and_disables_on_loss(self): + class SM: + def __getitem__(self, service): + if service == 'driverMonitoringState': + return SimpleNamespace(alertLevel=0, lockout=False, alwaysOnLockout=False) + if service == 'extrinsicsCalibration': + return SimpleNamespace(calStatus=log.ExtrinsicsCalibration.Status.calibrated) + raise KeyError(service) + + def all_checks(self, _services): + return True + + def driver(aol_enabled): + sd = SelfdriveD.__new__(SelfdriveD) + sd.CP = SimpleNamespace(passive=False, openpilotLongitudinalControl=True) + self.enterContext(mock.patch.object(sd, 'sm', SM(), create=True)) + sd.events = Events() + sd.state_machine = StateMachine() + sd.enabled = sd.active = False + sd.initialized = True + sd.aol_replay = aol_enabled + sd.aol_car_state_log_ns = 0 + sd.aol_session_id = 'drive-session' + sd.aol_axis_decision = AxisDecision() + sd.aol_dm_lateral_inhibit = False + sd.aol_settings = None + sd.nostalgia_paddle_cancel = False + self.enterContext(mock.patch.object(sd, 'data_sample', car_state)) + sd.update_alerts = mock.Mock() + sd.update_conditional_mode = mock.Mock() + sd.publish_selfdriveState = mock.Mock() + self.enterContext(mock.patch.object(sd, 'update_events', + lambda _cs: (sd.events.clear(), sd.events.add(log.OnroadEvent.EventName.buttonEnable)))) + return sd + + native = SimpleNamespace(requestedLateral=True, requestedLongitudinal=True, + lateralAllowed=True, longitudinalAllowed=True) + intent = SimpleNamespace(allowedLatch=True, lateralArmed=True, pauseLateral=False, pauseLongitudinal=False) + sd = driver(True) + with mock.patch('openpilot.selfdrive.selfdrived.selfdrived.current_intent', return_value=intent), \ + mock.patch('openpilot.selfdrive.selfdrived.selfdrived.current_native', side_effect=(None, native, None)): + sd.step() + self.assertFalse(sd.enabled) + self.assertTrue(sd.events.contains(ET.NO_ENTRY)) + self.assertEqual(sd.aol_axis_decision.mode, 'off') + sd.publish_selfdriveState.assert_called_once() # disabled bootstrap still publishes the zero-axis request + sd.step() + self.assertTrue(sd.enabled and sd.active) + self.assertEqual(sd.aol_axis_decision.mode, 'combined') + sd.step() + self.assertFalse(sd.enabled or sd.active) + self.assertTrue(sd.events.contains(ET.IMMEDIATE_DISABLE)) + alerts = sd.events.create_alerts(sd.state_machine.current_alert_types) + self.assertTrue(any(alert.alert_text_1 == 'TAKE CONTROL IMMEDIATELY' and + alert.alert_text_2 == 'Controls Mismatch' for alert in alerts)) + + stock = driver(False) + with mock.patch('openpilot.selfdrive.selfdrived.selfdrived.current_native') as read_native: + stock.step() + read_native.assert_not_called() + self.assertTrue(stock.enabled and stock.active) + + def test_independent_ack_keeps_unchanged_axis(self): + state = car_state() + old_combined = SimpleNamespace(requestedLateral=True, requestedLongitudinal=True, + lateralAllowed=True, longitudinalAllowed=True) + intent = SimpleNamespace(allowedLatch=True, lateralArmed=True, pauseLateral=False, pauseLongitudinal=False) + def decide(standard_long, native): + return decide_axes(standard_lateral=False, standard_longitudinal=standard_long, + intent=intent, native=native, car_state=state, initialized=True, + model_ready=True, no_entry=False, immediate_disable=False, + dm_lockout=False, pause_brake_mps=5.0) + self.assertEqual(decide(True, old_combined).mode, 'combined') + self.assertEqual(decide(False, old_combined).mode, 'lateralOnly') + old_lateral = SimpleNamespace(requestedLateral=True, requestedLongitudinal=False, + lateralAllowed=True, longitudinalAllowed=False) + self.assertEqual(decide(True, old_lateral).mode, 'lateralOnly') + intent.pauseLateral = True + self.assertEqual(decide(True, old_combined).mode, 'longitudinalOnly') + intent.pauseLateral = False + intent.pauseLongitudinal = True + self.assertEqual(decide(True, old_combined).mode, 'lateralOnly') + intent.pauseLongitudinal = False + old_long = SimpleNamespace(requestedLateral=False, requestedLongitudinal=True, + lateralAllowed=False, longitudinalAllowed=True) + self.assertEqual(decide(True, old_long).mode, 'longitudinalOnly') + + + def test_card_selfdrive_panda_cadence_and_four_modes(self): + with OpenpilotPrefix(): + messaging.reset_context() + pm = messaging.PubMaster(['carState', 'aolIntentWire', 'aolAxisState', 'aolSafetyWire']) + sm = messaging.SubMaster(['carState', 'aolIntentWire', 'aolAxisState', 'aolSafetyWire']) + base = time.monotonic_ns() - 50_000_000 + CP = car.CarParams() + CP.safetyConfigs = [car.CarParams.SafetyConfig(safetyModel=car.CarParams.SafetyModel.hondaBosch, safetyParam=34)] + + def publish_intent(stamp, *, pause_lat=False, pause_long=False): + message = messaging.new_message('aolIntentWire', 0) + message.logMonoTime = stamp + message.valid = True + message.aolIntentWire = encode_intent(IntentState( + 'card-session', stamp - base + 1, stamp, stamp, stamp + 100_000_000, + True, pause_lat, pause_long, True)) + pm.send('aolIntentWire', message) + + def publish_native(stamp, lat, long, session='drive-session'): + message = messaging.new_message('aolSafetyWire', 0) + message.logMonoTime = stamp + message.valid = True + message.aolSafetyWire = encode_safety(SafetyState( + 1, True, stamp, stamp + 200_000_000, int(car.CarParams.SafetyModel.hondaBosch), + 34, lat, long, lat, long, 'panda', session)) + pm.send('aolSafetyWire', message) + + for index, (standard_long, pause_lat, pause_long, expected) in enumerate(( + (False, False, False, 'lateralOnly'), + (True, False, False, 'combined'), + (True, True, False, 'longitudinalOnly'), + (False, True, False, 'off'), + )): + stamp = base + index * 10_000_000 + state_msg = messaging.new_message('carState') + state_msg.logMonoTime = stamp + state_msg.valid = True + pm.send('carState', state_msg) + publish_intent(stamp, pause_lat=pause_lat, pause_long=pause_long) + desired_lat = not pause_lat + desired_long = standard_long and not pause_long + publish_native(stamp, desired_lat, desired_long) + sm.update(100) + intent = current_intent(sm, car_state_ns=stamp, now_ns=stamp + 1_000_000) + native = current_native(sm, CP, now_ns=stamp + 1_000_000, axis_session_id='drive-session') + decision = decide_axes(standard_lateral=False, standard_longitudinal=standard_long, + intent=intent, native=native, car_state=car_state(), + initialized=True, model_ready=True, no_entry=False, + immediate_disable=False, dm_lockout=False, pause_brake_mps=5.0) + self.assertEqual(decision.mode, expected) + + self.assertIsNone(current_native(sm, CP, now_ns=base + 500_000_000, axis_session_id='drive-session')) + self.assertIsNone(current_native(sm, CP, now_ns=base + 31_000_000, axis_session_id='new-session')) + + def test_no_native_ack_still_requests_but_does_not_actuate(self): + intent = SimpleNamespace(pauseLateral=False, pauseLongitudinal=False, allowedLatch=True) + decision = decide_axes(standard_lateral=False, standard_longitudinal=True, + intent=intent, native=None, car_state=car_state(), initialized=True, + model_ready=True, no_entry=False, immediate_disable=False, + dm_lockout=False, pause_brake_mps=5.0) + self.assertTrue(decision.desired_lateral) + self.assertTrue(decision.desired_longitudinal) + self.assertEqual(decision.mode, 'off') + self.assertFalse(decision.native_acknowledged) + + +if __name__ == '__main__': + unittest.main() diff --git a/openpilot/starpilot/aol/tests/test_stock_forte_factory.py b/openpilot/starpilot/aol/tests/test_stock_forte_factory.py new file mode 100644 index 0000000000..62dc933265 --- /dev/null +++ b/openpilot/starpilot/aol/tests/test_stock_forte_factory.py @@ -0,0 +1,71 @@ +"""Joined adapter-factory regression for stock CAN-FD and classic lateral policies.""" +import unittest + +from opendbc.car.structs import car +from opendbc.car.hyundai.values import CAR, HyundaiFlags +from openpilot.starpilot.aol.intent import AolSettings +from openpilot.starpilot.aol.vehicle import create_intent, native_matches_cp, policy_for +from openpilot.starpilot.car.hyundai.forte_intent import ForteCardIntent +from openpilot.starpilot.aol.tests.test_forte_intent import params as forte_params, state as forte_state + +Button = car.CarState.ButtonEvent.Type + + +def stock(alternate): + cp = car.CarParams.new_message() + cp.brand, cp.carFingerprint = 'hyundai', CAR.HYUNDAI_IONIQ_6 + cp.flags = int(HyundaiFlags.CANFD | HyundaiFlags.EV | HyundaiFlags.CANFD_LKA_STEER_MSG) + if alternate: + cp.flags |= int(HyundaiFlags.CANFD_LKA_STEER_MSG_ALT) + cp.pcmCruise, cp.openpilotLongitudinalControl, cp.radarUnavailable = True, False, False + cp.safetyConfigs = [{'safetyModel': car.CarParams.SafetyModel.hyundaiCanfd, + 'safetyParam': 0x91 if alternate else 0x11}] + return cp + + +def settings(lkas=9, main=0): + return AolSettings(True, 0., lkas, main, (0, 0, 0), (0, 0, 0)) + + +class TestStockForteFactory(unittest.TestCase): + def test_actual_dispatcher_stock_factory_retains_latch_before_marker(self): + for alternate in (False, True): + with self.subTest(alternate=alternate): + cp = stock(alternate) + policy = policy_for(cp) + intent = create_intent(cp, settings(), policy) + self.assertTrue(policy.intent_supported and policy.explicit_latch) + self.assertTrue(intent.explicit_latch) + self.assertFalse(intent.allowed_latch) + model, original = int(cp.safetyConfigs[0].safetyModel.raw), int(cp.safetyConfigs[0].safetyParam) + self.assertFalse(native_matches_cp(cp, model, original)) + cp.safetyConfigs[0].safetyParam |= policy.safety_param_addition + serialized = cp.as_reader().as_builder() + self.assertEqual(serialized.safetyConfigs[0].safetyParam, original | 0x0800) + self.assertTrue(native_matches_cp(serialized, model, original | 0x0800)) + self.assertFalse(native_matches_cp(serialized, model, original)) + self.assertTrue(serialized.pcmCruise) + self.assertFalse(serialized.openpilotLongitudinalControl) + disabled = create_intent(stock(alternate), AolSettings(False, 0., 9, 0, (0, 0, 0), (0, 0, 0)), policy) + self.assertTrue(disabled.explicit_latch) + self.assertFalse(disabled.allowed_latch) + + def test_forte_dispatcher_retains_direct_button_state_machine(self): + for platform in (CAR.KIA_FORTE_2019_NON_SCC, CAR.KIA_FORTE_2021_NON_SCC): + for source in (0, 0x391, 0x50c): + for main_action in (0, 9): + with self.subTest(platform=platform, source=source, main_action=main_action): + cp = forte_params(platform, source) + configured = settings(main=main_action) + dispatched = create_intent(cp, configured, policy_for(cp)) + direct = ForteCardIntent(cp, configured) + self.assertIs(type(dispatched), ForteCardIntent) + for tick, events in enumerate(((), ((Button.lkas, False),), ((Button.lkas, True),), + ((Button.lkas, False),), ((Button.lkas, True),), + ((Button.lkas, False),)), 1): + cs = forte_state(main=True, cruise=tick > 1, events=events) + for intent in (dispatched, direct): + intent.update(cs, now_ns=tick) + for attribute in ('allowed_latch', 'pause_lateral', 'pause_longitudinal', + 'physical_latch', 'fault_rearm', 'neutral_seen'): + self.assertEqual(getattr(dispatched, attribute), getattr(direct, attribute), attribute) diff --git a/openpilot/starpilot/aol/tests/test_vehicle.py b/openpilot/starpilot/aol/tests/test_vehicle.py new file mode 100644 index 0000000000..4f725beac1 --- /dev/null +++ b/openpilot/starpilot/aol/tests/test_vehicle.py @@ -0,0 +1,20 @@ +"""Unknown vehicles have no implicit AOL capability or native receipt.""" + +import unittest +from types import SimpleNamespace + +from openpilot.starpilot.aol.vehicle import AolVehiclePolicy, native_matches_cp, native_profile_supported, policy_for + + +class GenericVehiclePolicyTests(unittest.TestCase): + def test_unknown_and_malformed_cp_fail_closed(self): + denied = AolVehiclePolicy() + for cp in (None, SimpleNamespace(), SimpleNamespace(brand='ford'), + SimpleNamespace(brand='honda'), SimpleNamespace(brand='hyundai')): + with self.subTest(cp=cp): + self.assertEqual(policy_for(cp), denied) + self.assertFalse(native_matches_cp(cp, 0, 0)) + + def test_no_unregistered_native_profile(self): + self.assertFalse(native_profile_supported(0, 0)) + self.assertFalse(native_profile_supported(999, 0x8815)) diff --git a/openpilot/starpilot/aol/transport_pause.py b/openpilot/starpilot/aol/transport_pause.py new file mode 100644 index 0000000000..923ce6d286 --- /dev/null +++ b/openpilot/starpilot/aol/transport_pause.py @@ -0,0 +1,1083 @@ +"""Reason-qualified intent retention; this module never grants an axis.""" +from openpilot.cereal import log +from openpilot.starpilot.aol.runtime import AXIS_MAX_AGE_NS, SAFETY_MAX_AGE_NS, current_native, native_inventory_matches_cp +from openpilot.starpilot.aol.wire import SafetyState, decode_intent + + +def cp_identity(cp): + return (str(cp.brand), str(cp.carFingerprint), int(cp.flags), int(cp.alternativeExperience), + bool(cp.passive), bool(cp.dashcamOnly), bool(cp.notCar), + bool(cp.openpilotLongitudinalControl), bool(cp.pcmCruise), + tuple((int(c.safetyModel.raw), int(c.safetyParam)) for c in cp.safetyConfigs)) + + +def _configured_transport_native(sm, cp, session, now_ns, native=None, *, require_longitudinal=True): + """Validate the exact live native configuration and every Panda.""" + try: + validated_here = native is None + native = native or current_native(sm, cp, now_ns=now_ns, axis_session_id=session) + if (not session or native is None or str(native.axisSessionId) != session or + native.requestedLateral and not native.lateralAllowed or + require_longitudinal and native.requestedLongitudinal and not native.longitudinalAllowed): + return None + coherent = bool(native.pandaInventory) + if coherent and (int(sm.logMonoTime['aolSafetyWire']) != native.observedMonoTime or + not 0 < native.observedMonoTime <= now_ns <= native.validUntilMonoTime or + now_ns - native.observedMonoTime > SAFETY_MAX_AGE_NS or + native.validUntilMonoTime - native.observedMonoTime > SAFETY_MAX_AGE_NS): + return None + # current_native already checked this exact inventory in this call. + # Supplied native values still need structural validation here. + if coherent and (not validated_here and not native_inventory_matches_cp(native, cp) or + any(p.safetyRxChecksInvalid or p.heartbeatLost or p.faults or + (require_longitudinal and native.requestedLongitudinal and str(config.safetyModel) not in ('silent', 'noOutput') and + not p.controlsAllowed) + for p, config in zip(native.pandaInventory, cp.safetyConfigs, strict=True))): + return None + # v2 carries exact producer-read health under its monotonic 200ms lease. + # The independently conflated BOOTTIME PandaStates is a latest veto only. + stamp = int(sm.logMonoTime['pandaStates']) + if not (sm.seen['pandaStates'] and sm.valid['pandaStates'] and sm.alive['pandaStates'] and + stamp > 0 and (coherent or stamp <= now_ns and now_ns - stamp <= SAFETY_MAX_AGE_NS)): + return None + pandas = sm['pandaStates'] + if not cp.safetyConfigs or len(pandas) != len(cp.safetyConfigs): + return None + return native if all(int(p.safetyModel.raw) == int(c.safetyModel.raw) and p.safetyParam == c.safetyParam and + p.alternativeExperience == cp.alternativeExperience and not p.safetyRxChecksInvalid and + not p.heartbeatLost and not p.faults and + (not require_longitudinal or not native.requestedLongitudinal or str(c.safetyModel) in ('silent', 'noOutput') or p.controlsAllowed) + for p, c in zip(pandas, cp.safetyConfigs, strict=True)) else None + except (AttributeError, KeyError, IndexError, TypeError, ValueError): + return None + + +def _healthy_transport_native(sm, cp, cs, session, now_ns, native=None, *, require_longitudinal=True): + """Require both current vehicle health and the exact live native configuration.""" + try: + if not cs.canValid or cs.canTimeout or cs.steerFaultTemporary or cs.steerFaultPermanent: + return None + return _configured_transport_native(sm, cp, session, now_ns, native, + require_longitudinal=require_longitudinal) + except (AttributeError, KeyError, IndexError, TypeError, ValueError): + return None + + +def independent_lateral_health(sm, cp, cs, session, now_ns): + """Structural health for retaining LAT intent; never grant either output axis.""" + native = _healthy_transport_native(sm, cp, cs, session, now_ns, require_longitudinal=False) + return native if native is not None and native.requestedLateral and native.lateralAllowed else None + + +def healthy_transport(sm, cp, cs, session, now_ns, native=None): + """Require the live, exact native configuration, including every Panda.""" + return _healthy_transport_native(sm, cp, cs, session, now_ns, native) is not None + + +AVAILABILITY_EVENTS = (log.OnroadEvent.EventName.commIssue, log.OnroadEvent.EventName.commIssueAvgFreq) + + +def availability_only(events): + faults = [event for event in events if event.immediateDisable or event.softDisable] + return bool(faults and all(event.name in AVAILABILITY_EVENTS and event.softDisable and + not event.immediateDisable for event in faults)) + + +HOST_SOURCE_AVAILABILITY_EVENTS = (*AVAILABILITY_EVENTS, log.OnroadEvent.EventName.selfdrivedLagging) + + +def host_source_availability_only(events): + """Closed host SOFT set for qualified inactive source-zero intent only.""" + faults = [event for event in events if event.immediateDisable or event.softDisable] + return bool(faults and not any(event.userDisable for event in events) and + all(event.name in HOST_SOURCE_AVAILABILITY_EVENTS and event.softDisable and + not event.immediateDisable for event in faults)) + + +def stock_lateral_user_event(cp): + """Current ordinary-owner USER event for an established stock LAT role.""" + if not cp.pcmCruise: + return None + if cp.brand == 'honda': + from opendbc.car.honda.stock_aol import qualified + return log.OnroadEvent.EventName.cruiseDisabled if qualified(cp, marked_only=True) else None + return log.OnroadEvent.EventName.pcmDisable + + +def stock_lateral_off(cp, cs, native): + """Physical stock-cruise OFF role; callers still prove accepted LAT identity.""" + from openpilot.starpilot.nostalgia import physical_cancel + return bool(cp.pcmCruise and cs.cruiseState.available and not cs.cruiseState.enabled and + cs.canValid and not cs.canTimeout and not cs.steerFaultPermanent and not cs.steerFaultTemporary and + not cs.accFaulted and not cs.doorOpen and not cs.seatbeltUnlatched and + not cs.brakePressed and not cs.regenBraking and not cs.gasPressed and not physical_cancel(cs) and + native is not None and native.requestedLateral and native.lateralAllowed and not native.requestedLongitudinal) + + +class AvailabilityPause: + """Retain an accepted armed producer; this proof never enables output.""" + def __init__(self, cp, session): + self.cp, self.session = cp_identity(cp), session + self.producer = '' + self.epoch = None + self.inventory = None + self.native_ns = 0 + self.baseline_ns = 0 + self.active = False + + def observe(self, *, cp, session, sm, cs, native, now_ns, companion, intent, + events, model_ready, source_current, prior_lateral_active, hard_fault, healthy_native, source_expired=False): + try: + healthy = healthy_native + eligible = bool(cp_identity(cp) == self.cp and + session == self.session and not hard_fault and model_ready and (source_current or source_expired) and companion is not None and + companion.last_drain_ok and intent is not None and intent.allowedLatch and intent.lateralArmed and + healthy is not None and healthy.requestedLateral and healthy.lateralAllowed and healthy.pandaInventory) + if not eligible: + self.active = False + self.baseline_ns = 0 + return False + if source_expired: + latest = companion.latest + raw = latest[2] if latest is not None and latest[1] else None + verified = companion.verified_sm_projection + sm_intent = decode_intent(sm['aolIntentWire']) + projection = bool(verified is not None and int(sm.logMonoTime['aolIntentWire']) == verified[0] and + verified[2] == companion.integrity_epoch and verified[3] == companion.session and + sm_intent == verified[1] and 0 < verified[0] <= now_ns <= verified[0] + SAFETY_MAX_AGE_NS and + sm_intent.settingsQualified and sm_intent.allowedLatch and sm_intent.lateralArmed and + sm_intent.producerSessionId == intent.producerSessionId) + if (raw is None or not raw.settingsQualified or not raw.allowedLatch or not raw.lateralArmed or + raw.producerSessionId != intent.producerSessionId or + not 0 < intent.carStateLogMonoTime <= now_ns or + now_ns - intent.carStateLogMonoTime > SAFETY_MAX_AGE_NS or + not 0 < raw.carStateLogMonoTime <= now_ns or raw.observedMonoTime > now_ns or + now_ns - raw.carStateLogMonoTime > SAFETY_MAX_AGE_NS or + (int(sm.logMonoTime['aolIntentWire']) != raw.carStateLogMonoTime and not projection) or + not sm.seen['aolIntentWire'] or not sm.valid['aolIntentWire'] or not sm.alive['aolIntentWire']): + self.active = False + return False + inventory = (healthy.pandaSerial, tuple(p.hardwareSerial for p in healthy.pandaInventory)) + faults = [event for event in events if event.immediateDisable or event.softDisable] + if not faults: + recovery_handoff = bool(self.active and source_current and not source_expired and self.baseline_ns > 0 and + self.producer == intent.producerSessionId and self.epoch == companion.integrity_epoch and + self.inventory == inventory and healthy.observedMonoTime >= self.native_ns) + self.active = False + if prior_lateral_active or recovery_handoff: + self.producer, self.epoch = intent.producerSessionId, companion.integrity_epoch + self.inventory, self.native_ns, self.baseline_ns = inventory, healthy.observedMonoTime, now_ns + return False + matched = bool(availability_only(events) and self.producer == intent.producerSessionId and + self.epoch == companion.integrity_epoch and self.inventory == inventory and + healthy.observedMonoTime >= self.native_ns and self.baseline_ns > 0 and + (self.active or 0 <= now_ns - self.baseline_ns <= + (SAFETY_MAX_AGE_NS if source_expired else AXIS_MAX_AGE_NS))) + self.active = matched + if matched: + self.native_ns = healthy.observedMonoTime + else: + self.baseline_ns = 0 + return matched + except (AttributeError, KeyError, IndexError, TypeError, ValueError): + self.active = False + self.baseline_ns = 0 + return False + + +class TransportPause: + def __init__(self, cp, session): + self.cp = cp_identity(cp) + self.session = session + self.active = False + self.producer = '' + self.epoch = None + + def observe(self, *, cp, session, sm, cs, native, now_ns, companion, previous_intent, + intent, expired_healthy_source, previously_active, hard_fault, model_ready, user_disable): + """Return (temporary inhibit, hard upgrade); require a prior accepted pair.""" + # Overwritten on every observation; consumed only by the adjacent producer. + self.observed_native = None + context_current = bool(cp_identity(cp) == self.cp and session == self.session and model_ready and companion is not None) + if context_current: + self.observed_native = _healthy_transport_native(sm, cp, cs, session, now_ns, native) + eligible = bool(context_current and not hard_fault and self.observed_native is not None) + latest = companion.latest if companion is not None else None + raw = decode_intent(sm['aolIntentWire']) if sm.seen.get('aolIntentWire', False) else None + previous = previous_intent + producer = self.producer if self.active else str(previous.producerSessionId) if previous is not None else '' + eligible = bool(eligible and latest is not None and latest[1] and raw is not None and + sm.valid['aolIntentWire'] and sm.alive['aolIntentWire'] and + raw.settingsQualified and raw.producerSessionId == producer and + latest[2].producerSessionId == producer and + int(sm.logMonoTime['aolIntentWire']) == raw.carStateLogMonoTime and + 0 < raw.carStateLogMonoTime <= now_ns and raw.observedMonoTime <= now_ns and + (self.epoch is None or self.epoch == companion.integrity_epoch)) + if self.active: + if not eligible: + self.active = False + return False, True + if user_disable or intent is not None and not intent.allowedLatch: + self.active = False + self.epoch = companion.integrity_epoch + return False, False + return True, False + if (eligible and expired_healthy_source and previously_active and previous is not None and + previous.allowedLatch and previous.lateralArmed and raw.allowedLatch and raw.lateralArmed and + not user_disable): + self.active = True + self.producer = producer + self.epoch = companion.integrity_epoch + return True, False + if intent is not None and companion is not None: + self.epoch = companion.integrity_epoch + return False, False + + +class TransportPauseFeedback: + """Card accepts pause provenance only after a healthy same-session baseline.""" + def __init__(self, cp): + self.cp = cp_identity(cp) + self.session = '' + self.sequence = 0 + self.signature = None + self.paused = False + self.qualified = False + self.pending_event = 0 + self.awaiting = False + self.failed = False + self.lateral_baseline_ns = 0 + self.lateral_only_baseline = False + self.lateral_source_ns = 0 + self.lateral_inventory = None + self.lateral_native_ns = 0 + self.longitudinal_cancel_ns = 0 + self.last_cancel_source_ns = 0 + self.honda_brake_ns = 0 + self.gm_withdrawal_ns = 0 + self.retention_qualified = False + self.retention_pending = False + self.retention_event_ns = 0 + self.retention_started_ns = 0 + self.retention_verified = False + self.availability_producer = '' + self.availability_epoch = None + self.source_retention_started_ns = 0 + self.healthy_feedback_ns = 0 + self.healthy_native_ns = 0 + self.healthy_inventory = None + + def _temporary_host_intent_current(self, sm, cp, cs, axis, stamp, now_ns, *, latched, producer): + """A fresh inactive proof may retain intent, never native/output authority.""" + from openpilot.starpilot.nostalgia import physical_cancel + if physical_cancel(cs) or cs.steeringDisengage: + return False + events = sm['onroadEvents'] + faults = [event for event in events if event.immediateDisable or event.softDisable] + names = (log.OnroadEvent.EventName.selfdrivedLagging, log.OnroadEvent.EventName.locationdTemporaryError) + temporary = bool(len(faults) == 1 and faults[0].name in names and faults[0].softDisable and not faults[0].immediateDisable) + eps_recovery = bool(not cs.steerFaultTemporary and getattr(self, 'temporary_eps_event_ns', 0) and + axis.faultEventMonoTime == self.temporary_eps_event_ns and + all(not event.immediateDisable and + (not event.softDisable or event.name == log.OnroadEvent.EventName.steerTempUnavailable) for event in events)) + users = [event for event in events if event.userDisable] + pedal = bool(cs.brakePressed and users and all(event.name == log.OnroadEvent.EventName.pedalPressed and + not event.softDisable and not event.immediateDisable for event in users) and not faults) + location_only = bool(temporary and faults[0].name == log.OnroadEvent.EventName.locationdTemporaryError) + if axis.longitudinalActive and not location_only or not (temporary or eps_recovery or pedal): + return False + if (not latched or self.failed or self.paused or self.pending_event or self.retention_started_ns or + not self.session or str(axis.sessionId) != self.session or + not producer or producer != self.availability_producer or + str(getattr(axis, 'intentProducerSessionId', '')) != producer or + int(getattr(axis, 'intentIntegrityEpoch', 0)) != self.availability_epoch or + not self.lateral_inventory or not self.lateral_baseline_ns or + not cs.canValid or cs.canTimeout or cs.steerFaultPermanent or cs.accFaulted or cs.steerFaultTemporary or + users and not pedal or str(axis.faultReason) not in ('none', 'critical') or + not 0 < self.lateral_source_ns <= axis.sourceCarStateMonoTime <= stamp <= now_ns or + now_ns - axis.sourceCarStateMonoTime > AXIS_MAX_AGE_NS or + not sm.seen['onroadEvents'] or not sm.valid['onroadEvents'] or not sm.alive['onroadEvents']): + return False + event_ns = int(sm.logMonoTime['onroadEvents']) + if not 0 < event_ns <= now_ns or now_ns - event_ns > 1_500_000_000: + return False + withdrawn = not axis.desiredLateral and not axis.lateralActive and not axis.desiredLongitudinal and not axis.longitudinalActive + event_first = bool(temporary and str(axis.faultReason) == 'none' and + self.signature is not None and self.signature[9] and self.signature[11] and self.signature[13] and + axis.sequence >= self.sequence and stamp >= self.signature[2] and + temporary and axis.desiredLateral and axis.lateralActive and axis.nativeAcknowledged and + 0 < axis.faultEventMonoTime < event_ns and now_ns - event_ns <= AXIS_MAX_AGE_NS) + if not (event_first or temporary and axis.faultEventMonoTime == event_ns and + (withdrawn or str(axis.faultReason) == 'none' and axis.desiredLateral and + axis.lateralActive and axis.nativeAcknowledged) or eps_recovery and withdrawn or + pedal and str(axis.faultReason) == 'none' and axis.desiredLateral and axis.lateralActive and + not axis.desiredLongitudinal and not axis.longitudinalActive and + axis.nativeAcknowledged and axis.faultEventMonoTime == event_ns): + return False + native = _configured_transport_native(sm, cp, self.session, now_ns, require_longitudinal=not pedal) + if (native is None or not native.pandaInventory or native.observedMonoTime < self.lateral_native_ns or + (native.pandaSerial, tuple(p.hardwareSerial for p in native.pandaInventory)) != self.lateral_inventory or + native.requestedLateral and not native.lateralAllowed or + pedal and not (native.requestedLateral and native.lateralAllowed) or + axis.lateralActive and not (native.requestedLateral and native.lateralAllowed) or + axis.longitudinalActive and not (native.requestedLongitudinal and native.longitudinalAllowed)): + return False + from openpilot.starpilot.aol.vehicle import policy_for + if not policy_for(cp).intent_supported: + return False + self.temporary_host_qualified = True + return True + + def _temporary_lag_intent_current(self, sm, cp, cs, axis, stamp, now_ns, *, latched, producer, signature): + """Retain intent only through isolated lag or its bounded critical/event join.""" + reason = str(axis.faultReason) + events = sm['onroadEvents'] + faults = [event for event in events if event.immediateDisable or event.softDisable] + isolated = bool(len(faults) == 1 and faults[0].name == log.OnroadEvent.EventName.selfdrivedLagging and + faults[0].softDisable and not faults[0].immediateDisable) + # Healthy cycles never reach policy/native/provenance work. A critical + # proof can precede its event, but cannot yet establish a lag exemption. + if not isolated and (reason != 'critical' or faults): + return False + previous = self.signature + joining = bool(self.pending_event and previous is not None and previous[5] == 'critical' and + previous[6] == self.pending_event) + if (not latched or self.failed or self.paused or self.pending_event and not joining or self.retention_started_ns or + not self.session or str(axis.sessionId) != self.session or reason not in ('none', 'critical') or + not producer or producer != self.availability_producer or + str(getattr(axis, 'intentProducerSessionId', '')) != producer or + int(getattr(axis, 'intentIntegrityEpoch', 0)) != self.availability_epoch or + not self.lateral_baseline_ns or not self.lateral_inventory or + not cs.canValid or cs.canTimeout or cs.steerFaultTemporary or cs.steerFaultPermanent or cs.accFaulted or + not 0 < axis.sourceCarStateMonoTime <= stamp <= now_ns or + now_ns - axis.sourceCarStateMonoTime > AXIS_MAX_AGE_NS): + return False + event_ns = int(sm.logMonoTime['onroadEvents']) + if (not sm.seen['onroadEvents'] or not sm.valid['onroadEvents'] or not sm.alive['onroadEvents'] or + not 0 < event_ns <= now_ns or now_ns - event_ns > 1_500_000_000 or + joining and + (axis.faultEventMonoTime != self.pending_event or now_ns - self.pending_event > AXIS_MAX_AGE_NS)): + return False + withdrawn = bool(reason == 'critical' and not axis.desiredLateral and not axis.desiredLongitudinal and + not axis.lateralActive and not axis.longitudinalActive) + matched = bool(withdrawn and isolated and axis.faultEventMonoTime == event_ns <= stamp) + held_original = bool(reason == 'none' and isolated and signature == previous and + axis.desiredLateral and axis.lateralActive and axis.nativeAcknowledged and + 0 < axis.faultEventMonoTime < event_ns and now_ns - event_ns <= AXIS_MAX_AGE_NS) + known_lag = bool(isolated and previous is not None and previous[5] == 'critical' and previous[6] == event_ns) + pending = bool(withdrawn and event_ns < axis.faultEventMonoTime <= stamp and + now_ns - axis.faultEventMonoTime <= AXIS_MAX_AGE_NS and + (joining or known_lag or not faults and previous is not None and previous[5] == 'none' and + 0 <= now_ns - self.lateral_baseline_ns <= AXIS_MAX_AGE_NS)) + if not (matched or held_original or pending): + return False + from openpilot.starpilot.aol.vehicle import policy_for + if not policy_for(cp).intent_supported: + return False + native = _healthy_transport_native(sm, cp, cs, self.session, now_ns) + if (native is None or not native.pandaInventory or + held_original and not (native.requestedLateral and native.lateralAllowed) or + native.observedMonoTime < self.lateral_native_ns or + (native.pandaSerial, tuple(p.hardwareSerial for p in native.pandaInventory)) != self.lateral_inventory): + return False + users = [event for event in events if event.userDisable] + if users: + stock_user = stock_lateral_user_event(cp) + if (not getattr(self, 'lateral_only_baseline', False) or stock_user is None or + any(event.name != stock_user or event.softDisable or event.immediateDisable for event in users) or + not stock_lateral_off(cp, cs, native)): + return False + if pending: + if not self.pending_event: + self.pending_event = int(axis.faultEventMonoTime) + self.awaiting = True + elif joining: + self.pending_event = 0 + return True + + def _temporary_steering_intent_current(self, sm, cp, cs, axis, stamp, now_ns, *, latched, producer, signature): + """Keep an existing armed intent during isolated temporary EPS withdrawal.""" + if (not latched or self.failed or self.paused or self.retention_started_ns or + not self.session or str(axis.sessionId) != self.session or not cs.steerFaultTemporary or + cs.steerFaultPermanent or not cs.canValid or cs.canTimeout or + str(axis.faultReason) != 'none' or + not producer or producer != self.availability_producer or + str(getattr(axis, 'intentProducerSessionId', '')) != producer or + int(getattr(axis, 'intentIntegrityEpoch', 0)) != self.availability_epoch or + not self.lateral_baseline_ns or not self.lateral_inventory or + not 0 < axis.sourceCarStateMonoTime <= stamp <= now_ns or + now_ns - axis.sourceCarStateMonoTime > AXIS_MAX_AGE_NS): + return False + withdrawn = not axis.desiredLateral and not axis.lateralActive + previous = self.signature + pending = bool(self.pending_event) + all_off = bool(withdrawn and not axis.desiredLongitudinal and not axis.longitudinalActive and str(axis.mode) == 'off') + if pending and (not all_off or previous is None or previous[5] != 'none' or + previous[0] != self.session or previous[7] != self.session or + previous[1] != self.sequence or previous[6] != self.pending_event or + previous[9] or previous[10] or previous[11] or previous[12] or + previous[14] != producer or previous[15] != self.availability_epoch or + axis.faultEventMonoTime != self.pending_event or + axis.sourceCarStateMonoTime < previous[8] or stamp < previous[2] or + not 0 < self.pending_event <= now_ns or now_ns - self.pending_event > AXIS_MAX_AGE_NS): + return False + # A fresh ACTIVE proof from the preceding healthy sample can arrive in + # Card's first temporary-EPS cycle. Accept its identity only while the + # previously accepted ACTIVE proof still has its original source/output + # lease. No permission deadline or healthy baseline is renewed here. + fresh_active_successor = bool(previous is not None and axis.sequence > self.sequence and + previous[5] == 'none' and previous[9] and previous[11] and previous[13] and + previous[6] == axis.faultEventMonoTime and previous[14] == producer and + previous[15] == self.availability_epoch and + axis.sourceCarStateMonoTime >= previous[8] and stamp >= previous[2] and + 0 < previous[8] <= previous[2] <= now_ns <= previous[4] and + now_ns - previous[8] <= AXIS_MAX_AGE_NS and + 0 < self.lateral_source_ns <= now_ns and + now_ns - self.lateral_source_ns <= AXIS_MAX_AGE_NS) + held_original = bool(axis.desiredLateral and axis.lateralActive and axis.nativeAcknowledged and + (signature == previous or fresh_active_successor)) + if not withdrawn and not held_original: + return False + event_ns = int(sm.logMonoTime['onroadEvents']) + events = sm['onroadEvents'] + temporary_names = (log.OnroadEvent.EventName.steerTempUnavailable, + log.OnroadEvent.EventName.steerTempUnavailableSilent) + if (not sm.seen['onroadEvents'] or not sm.valid['onroadEvents'] or not sm.alive['onroadEvents'] or + not 0 < event_ns <= now_ns or now_ns - event_ns > 1_500_000_000 or + any(event.immediateDisable or event.softDisable and event.name != temporary_names[0] for event in events) or + pending and any(event.userDisable for event in events)): + return False + temporary_event = any(event.name in temporary_names for event in events) + matched_withdrawal = bool(temporary_event and axis.faultEventMonoTime == event_ns <= stamp) + original_active = bool(previous is not None and previous[0] == self.session and + previous[7] == self.session and previous[5] == 'none' and + previous[9] and previous[11] and previous[13] and previous[6] == event_ns and + axis.sourceCarStateMonoTime >= previous[8] and stamp >= previous[2] and + previous[14] == producer and previous[15] == self.availability_epoch and + 0 < self.lateral_source_ns <= now_ns and now_ns - self.lateral_source_ns <= AXIS_MAX_AGE_NS) + pending_withdrawal = bool(all_off and not any(event.softDisable or event.userDisable for event in events) and + not temporary_event and 0 < event_ns < axis.faultEventMonoTime <= stamp and + now_ns - axis.faultEventMonoTime <= AXIS_MAX_AGE_NS and + (pending or original_active and axis.sequence > self.sequence)) + if withdrawn and not (matched_withdrawal or pending_withdrawal): + return False + if held_original and not ( + axis.faultEventMonoTime == event_ns <= stamp and not any(event.softDisable for event in events) or + temporary_event and 0 < axis.faultEventMonoTime < event_ns and now_ns - event_ns <= AXIS_MAX_AGE_NS): + return False + # This exception validates intent only. The normal vehicle-health helper + # continues to reject temporary EPS for transport and availability proofs. + native = _configured_transport_native(sm, cp, self.session, now_ns) + if (native is None or not native.pandaInventory or + held_original and not (native.requestedLateral and native.lateralAllowed) or + axis.longitudinalActive and not native.longitudinalAllowed or + native.observedMonoTime < self.lateral_native_ns or + (native.pandaSerial, tuple(p.hardwareSerial for p in native.pandaInventory)) != self.lateral_inventory): + return False + if cs.steerFaultTemporary and matched_withdrawal: + self.temporary_eps_event_ns = int(axis.faultEventMonoTime) + if pending_withdrawal: + # Keep only intent while this exact physical-EPS occurrence awaits its + # event socket. Duplicate/newer proofs cannot move the original anchor. + if not pending: + self.pending_event = int(axis.faultEventMonoTime) + self.awaiting = True + elif pending: + self.pending_event = 0 + return True + + def _honda_brake_lateral_current(self, sm, cp, cs, axis, now_ns, *, latched, brake_source_ns): + """Preserve existing LAT intent through brake's LONG withdrawal, never output.""" + from opendbc.car.honda.stock_aol import qualified + from opendbc.car.structs import car + + source_ns = self.honda_brake_ns or (brake_source_ns if cs.brakePressed else 0) + if (not qualified(cp, marked_only=True) or not latched or self.failed or self.paused or self.pending_event or + not self.session or str(axis.sessionId) != self.session or + not self.healthy_feedback_ns or not 0 <= now_ns - self.healthy_feedback_ns <= AXIS_MAX_AGE_NS or + not 0 < source_ns <= now_ns or now_ns - source_ns > SAFETY_MAX_AGE_NS or + (not self.honda_brake_ns and now_ns - source_ns > AXIS_MAX_AGE_NS) or + cs.accFaulted or cs.seatbeltUnlatched or + cs.gearShifter not in (car.CarState.GearShifter.drive, car.CarState.GearShifter.sport, + car.CarState.GearShifter.low, car.CarState.GearShifter.brake) or + str(axis.faultReason) != 'none' or not axis.desiredLateral or not axis.lateralActive or + (not axis.nativeAcknowledged and (axis.desiredLongitudinal or axis.longitudinalActive)) or + not 0 < axis.sourceCarStateMonoTime <= axis.observedMonoTime or + axis.observedMonoTime - axis.sourceCarStateMonoTime > AXIS_MAX_AGE_NS): + return None + event_ns = int(sm.logMonoTime['onroadEvents']) + if (not sm.seen['onroadEvents'] or not sm.valid['onroadEvents'] or not sm.alive['onroadEvents'] or + not 0 < event_ns <= now_ns or now_ns - event_ns > 1_500_000_000 or + any(e.immediateDisable or e.softDisable for e in sm['onroadEvents'])): + return None + native = independent_lateral_health(sm, cp, cs, self.session, now_ns) + if (native is None or not native.requestedLateral or not native.lateralAllowed or + not native.requestedLongitudinal or native.observedMonoTime < self.healthy_native_ns or + (native.pandaSerial, tuple(p.hardwareSerial for p in native.pandaInventory)) != self.healthy_inventory): + return None + self.honda_brake_ns = source_ns + return native + + def _longitudinal_cancel_current(self, sm, cp, cs, axis, now_ns, *, latched, cancel_source_ns, withdrawal_required=True) -> SafetyState | None: + """Retain existing intent during Cancel's bounded LONG withdrawal handshake.""" + from opendbc.car.hyundai.ev6_aol import qualified + from opendbc.car.structs import car + + if (not latched or self.failed or self.paused or self.pending_event or + not self.session or str(axis.sessionId) != self.session or not qualified(cp, marked_only=True) or + not self.lateral_baseline_ns or not 0 <= now_ns - self.lateral_baseline_ns <= AXIS_MAX_AGE_NS or + str(axis.faultReason) != 'none' or not axis.nativeAcknowledged or + not axis.desiredLateral or not axis.lateralActive or cs.accFaulted or + not cs.canValid or cs.canTimeout or cs.steerFaultTemporary or cs.steerFaultPermanent): + return None + cancel = any(e.type == car.CarState.ButtonEvent.Type.cancel and e.pressed for e in cs.buttonEvents) + fresh_cancel = bool(cancel and self.last_cancel_source_ns < cancel_source_ns <= now_ns and + now_ns - cancel_source_ns <= AXIS_MAX_AGE_NS) + if not self.longitudinal_cancel_ns and not fresh_cancel: + return None + if not withdrawal_required and (self.longitudinal_cancel_ns or not fresh_cancel): + return None + if self.longitudinal_cancel_ns and not 0 <= now_ns - self.longitudinal_cancel_ns <= SAFETY_MAX_AGE_NS: + return None + event_ns = int(sm.logMonoTime['onroadEvents']) + if (not sm.seen['onroadEvents'] or not sm.valid['onroadEvents'] or not sm.alive['onroadEvents'] or + not 0 < event_ns <= now_ns or now_ns - event_ns > 1_500_000_000 or + any(e.immediateDisable or e.softDisable for e in sm['onroadEvents'])): + return None + native = current_native(sm, cp, now_ns=now_ns, axis_session_id=self.session) + if (native is None or not native.pandaInventory or not native.requestedLateral or not native.lateralAllowed or + not native.requestedLongitudinal or withdrawal_required and native.longitudinalAllowed or + native.observedMonoTime < self.lateral_native_ns or + not native_inventory_matches_cp(native, cp) or + (native.pandaSerial, tuple(p.hardwareSerial for p in native.pandaInventory)) != self.lateral_inventory): + return None + stamp = int(sm.logMonoTime['pandaStates']) + pandas = sm['pandaStates'] + if (not sm.seen['pandaStates'] or not sm.valid['pandaStates'] or not sm.alive['pandaStates'] or stamp <= 0 or + len(pandas) != len(cp.safetyConfigs)): + return None + # controlsAllowed represents the withdrawn normal/LONG axis here. Preserve + # every latest raw configuration/fault veto and exact producer-read inventory. + current_pandas = all(int(p.safetyModel.raw) == int(c.safetyModel.raw) and p.safetyParam == c.safetyParam and + p.alternativeExperience == cp.alternativeExperience and not p.safetyRxChecksInvalid and + not p.heartbeatLost and not p.faults and + (str(c.safetyModel) not in ('silent', 'noOutput') or not p.controlsAllowed) + for p, c in zip(pandas, cp.safetyConfigs, strict=True)) + return native if current_pandas else None + + + def _availability_retention(self, sm, cp, cs, axis, stamp, now_ns, *, latched, current, continuity, intent_producer_session): + reason = str(axis.faultReason) + events = sm['onroadEvents'] + if reason not in ('none', 'availabilityPause') or reason == 'none' and not availability_only(events): + return False + if not (latched and current and continuity and not self.paused and not self.pending_event and + cp_identity(cp) == self.cp and intent_producer_session and + getattr(axis, 'intentProducerSessionId', '') == intent_producer_session == self.availability_producer and + int(getattr(axis, 'intentIntegrityEpoch', 0)) == self.availability_epoch and + self.session and + self.lateral_inventory and axis.desiredLateral and + (str(axis.faultReason) == 'none' or not axis.desiredLongitudinal) and + axis.nativeAcknowledged and ( + 0 < axis.sourceCarStateMonoTime <= stamp and stamp - axis.sourceCarStateMonoTime <= AXIS_MAX_AGE_NS or + reason == 'availabilityPause' and axis.sourceCarStateMonoTime == 0 and + not axis.lateralActive and not axis.longitudinalActive)): + return False + native = _healthy_transport_native(sm, cp, cs, str(axis.sessionId), now_ns) + if (native is None or not native.requestedLateral or not native.lateralAllowed or + native.observedMonoTime < self.lateral_native_ns or + (native.pandaSerial, tuple(p.hardwareSerial for p in native.pandaInventory)) != self.lateral_inventory): + return False + # The qualified stock USER event describes the already disabled LONG + # owner, not an independent LAT cancellation. Qualify only this inactive source-zero + # proof against its accepted LAT-only baseline and current real native grant. + previous = self.signature + stock_user = stock_lateral_user_event(cp) + stock_lateral = bool(reason == 'availabilityPause' and axis.sourceCarStateMonoTime == 0 and + not axis.lateralActive and not axis.longitudinalActive and not axis.desiredLongitudinal and + stock_lateral_off(cp, cs, native) and previous is not None and + previous[0] == self.session and previous[7] == self.session and + previous[14] == intent_producer_session and previous[15] == self.availability_epoch and + (previous[11] and not previous[12] and previous[13] and not previous[10] and + (previous[9] and previous[5] == 'none' or (self.retention_verified or self.retention_started_ns) and + previous[5] == 'availabilityPause' and previous[8] == 0 and not previous[9] and + previous[6] == axis.faultEventMonoTime) or + # Only the matched temporary-lag branch can accept a critical signature. + # Unmatched joins retain pending_event; every other critical path fails + # and clears the LAT-only baseline before changing the signature. + self.lateral_only_baseline and not self.failed and not self.pending_event and + previous[5] == 'critical' and previous[1] == self.sequence and not any(previous[9:14]) and + 0 < previous[6] <= previous[2] <= axis.faultEventMonoTime and + 0 < self.lateral_source_ns <= previous[8] <= previous[2] and + previous[2] - previous[8] <= AXIS_MAX_AGE_NS and + 0 <= now_ns - previous[8] <= SAFETY_MAX_AGE_NS)) + if stock_lateral: + users = [event for event in events if event.userDisable] + if any(event.name != stock_user or event.softDisable or event.immediateDisable + for event in users): + return False + events = [event for event in events if not (event.name == stock_user and + event.userDisable and not event.softDisable and not event.immediateDisable)] + event_ns = int(sm.logMonoTime['onroadEvents']) + faults = [event for event in events if event.immediateDisable or event.softDisable] + live_event = bool(sm.valid['onroadEvents'] and sm.alive['onroadEvents'] and + 0 < event_ns <= now_ns and now_ns - event_ns <= 1_500_000_000) + host_source_off = bool(reason == 'availabilityPause' and axis.sourceCarStateMonoTime == 0 and + not axis.lateralActive and not axis.longitudinalActive) + issue = live_event and (availability_only(events) or host_source_off and host_source_availability_only(events)) + if reason not in ('none', 'availabilityPause') or faults and not issue: + return False + typed = bool(reason == 'availabilityPause' and not axis.lateralActive and not axis.longitudinalActive and + 0 < axis.faultEventMonoTime <= stamp) + source_expired = bool(typed and axis.sourceCarStateMonoTime == 0) + # The 200ms original source bound admits an initial episode, never an + # active command. Once its exact event/proof pair was verified, continuing + # fresh OFF source-zero proofs may retain driver intent with a CURRENT real + # native LAT grant and the same producer/epoch/configuration/inventory. + # Missing/mismatched events still use the unchanged fixed 30ms join below; + # every other output/source/native deadline and branch remains unchanged. + continuing_intent = bool(source_expired and self.retention_verified and issue) + if source_expired and not (0 < self.lateral_source_ns <= now_ns and + (now_ns <= self.lateral_source_ns + SAFETY_MAX_AGE_NS or continuing_intent)): + return False + baseline_age_limit = SAFETY_MAX_AGE_NS if source_expired else AXIS_MAX_AGE_NS + matched = typed and issue and axis.faultEventMonoTime == event_ns + occurrence = max(int(axis.faultEventMonoTime) if typed else 0, event_ns if issue else 0) + if not occurrence or occurrence < self.retention_event_ns: + return False + if matched: + if not self.retention_verified and not 0 <= now_ns - self.lateral_baseline_ns <= baseline_age_limit: + return False + if self.retention_started_ns and now_ns - self.retention_started_ns > AXIS_MAX_AGE_NS: + return False + self.retention_verified = True + self.retention_started_ns = 0 + self.retention_event_ns = occurrence + else: + if not self.retention_started_ns: + if not self.retention_verified and not 0 <= now_ns - self.lateral_baseline_ns <= baseline_age_limit: + return False + self.retention_started_ns = now_ns + self.retention_event_ns = occurrence + if occurrence != self.retention_event_ns or now_ns - self.retention_started_ns > AXIS_MAX_AGE_NS: + return False + self.retention_qualified, self.retention_pending = matched, not matched + self.lateral_native_ns = native.observedMonoTime + return True + + def _availability_recovery_current(self, sm, cp, cs, axis, stamp, now_ns, *, latched, current, continuity, producer): + """Join a verified pause's healthy recovery pair; this never grants output.""" + event_ns = int(sm.logMonoTime['onroadEvents']) + if (not latched or self.failed or self.paused or self.pending_event or not self.retention_verified or + not self.retention_started_ns or not 0 <= now_ns - self.retention_started_ns <= AXIS_MAX_AGE_NS or + not current or not continuity or str(axis.sessionId) != self.session or + not producer or producer != self.availability_producer or + str(getattr(axis, 'intentProducerSessionId', '')) != producer or + int(getattr(axis, 'intentIntegrityEpoch', 0)) != self.availability_epoch or + str(axis.faultReason) != 'none' or not axis.nativeAcknowledged or + not axis.desiredLateral or not axis.lateralActive or + not 0 < axis.sourceCarStateMonoTime <= stamp <= now_ns or + now_ns - axis.sourceCarStateMonoTime > AXIS_MAX_AGE_NS or + not sm.seen['onroadEvents'] or not sm.valid['onroadEvents'] or not sm.alive['onroadEvents'] or + not 0 < event_ns == axis.faultEventMonoTime <= stamp): + return False + events = sm['onroadEvents'] + faults = [event for event in events if event.userDisable or event.softDisable or event.immediateDisable] + combined = bool(axis.desiredLongitudinal and axis.longitudinalActive) + if combined: + # An event-first receiver has already recorded this NEW occurrence while + # holding the verified inactive source-zero pause. Close that exact join; + # neither the pending deadline nor any old command lease is renewed. + previous = self.signature + if (previous is None or previous[0] != self.session or previous[5] != 'availabilityPause' or + previous[7] != self.session or previous[8] != 0 or previous[9] or previous[10] or + not previous[11] or previous[12] or not previous[13] or + previous[14] != producer or previous[15] != self.availability_epoch or + not 0 < previous[6] < event_ns or self.retention_event_ns != event_ns or + len(faults) != 1 or faults[0].name != log.OnroadEvent.EventName.selfdrivedLagging or + not faults[0].softDisable or faults[0].userDisable or faults[0].immediateDisable or + any(event.noEntry and event.name != log.OnroadEvent.EventName.selfdrivedLagging for event in events)): + return False + elif (axis.desiredLongitudinal or axis.longitudinalActive or + not self.retention_event_ns < event_ns or faults): + return False + native = _healthy_transport_native(sm, cp, cs, self.session, now_ns) + return bool(native is not None and native.requestedLateral and native.lateralAllowed and + (not combined or native.requestedLongitudinal and native.longitudinalAllowed) and + native.observedMonoTime >= self.lateral_native_ns and + (native.pandaSerial, tuple(p.hardwareSerial for p in native.pandaInventory)) == self.lateral_inventory) + + def _new_expired_combined_source(self, sm, cp, axis, stamp, now_ns, signature): + """An established combined producer advanced while Card missed its output lease.""" + previous = self.signature + if (now_ns <= signature[4] or previous is None or self.retention_started_ns or + not (sm.seen['aolAxisState'] and sm.valid['aolAxisState'] and sm.alive['aolAxisState']) or + not axis.desiredLongitudinal or not axis.longitudinalActive or + not all(previous[index] for index in (9, 10, 11, 12, 13)) or previous[5] != 'none' or + signature[1] <= self.sequence or stamp <= previous[2] or + not 0 < self.lateral_source_ns <= previous[8] < signature[8] <= stamp or + stamp - signature[8] > AXIS_MAX_AGE_NS or + signature[4] != stamp + AXIS_MAX_AGE_NS): + return False + # Only this missed, already-expired Ioniq6 combined proof needs the extra + # path. Its original source/native retention clocks are checked below and + # never advanced; this function supplies no current output or LONG enable. + from openpilot.starpilot.car.hyundai.aol import qualified_ioniq6 + return qualified_ioniq6(cp) + + def _source_availability_retention(self, sm, cp, cs, axis, stamp, now_ns, *, latched, continuity, producer, signature): + # Output freshness withdraws commands, not previously accepted driver intent. + # Held output or a strictly forward, initially fresh combined proof may + # retain only an already established intent under its original bounds. + held = bool(signature == self.signature and not self.retention_started_ns) + output_missing = not (sm.seen['aolAxisState'] and sm.valid['aolAxisState'] and sm.alive['aolAxisState']) + # The same exclusive matched-lag signature invariant as typed retention: + # unknown critical joins keep pending_event; other critical paths fail. + matched_lag = bool(str(axis.faultReason) == 'critical' and signature == self.signature and + self.lateral_only_baseline and not self.failed and not self.pending_event and + signature[0] == self.session and signature[7] == self.session and signature[1] == self.sequence and + signature[14] == producer == self.availability_producer and signature[15] == self.availability_epoch and + not any(signature[9:14]) and 0 < signature[6] <= stamp and + 0 < self.lateral_source_ns <= signature[8] <= stamp and stamp - signature[8] <= AXIS_MAX_AGE_NS) + if (not latched or self.failed or self.paused or self.pending_event or not self.session or + cp_identity(cp) != self.cp or not continuity or + str(axis.sessionId) != self.session or str(axis.faultSessionId) != self.session or + not axis.qualified or (str(axis.faultReason) != 'none' or not axis.nativeAcknowledged or + not axis.desiredLateral or not axis.lateralActive) and not matched_lag or + (axis.desiredLongitudinal or axis.longitudinalActive) and not held and + not self._new_expired_combined_source(sm, cp, axis, stamp, now_ns, signature) or + (not sm.seen['aolAxisState'] or not sm.valid['aolAxisState']) and not held or + int(axis.observedMonoTime) != stamp or not 0 < axis.sourceCarStateMonoTime <= stamp <= now_ns or + not 0 <= axis.validUntilMonoTime - stamp <= AXIS_MAX_AGE_NS or + now_ns - axis.sourceCarStateMonoTime > SAFETY_MAX_AGE_NS or + not (stamp - axis.sourceCarStateMonoTime > AXIS_MAX_AGE_NS or now_ns > axis.validUntilMonoTime or + held and output_missing) or + not producer or getattr(axis, 'intentProducerSessionId', '') != producer or + producer != self.availability_producer or + int(getattr(axis, 'intentIntegrityEpoch', 0)) != self.availability_epoch): + return False + if not 0 < self.lateral_source_ns <= now_ns <= self.lateral_source_ns + SAFETY_MAX_AGE_NS: + return False + if not self.source_retention_started_ns: + if not self.lateral_baseline_ns or not 0 <= now_ns - self.lateral_baseline_ns <= SAFETY_MAX_AGE_NS: + return False + self.source_retention_started_ns = now_ns + if not 0 <= now_ns - self.source_retention_started_ns <= SAFETY_MAX_AGE_NS: + return False + event_ns = int(sm.logMonoTime['onroadEvents']) + events = sm['onroadEvents'] + faults = [e for e in events if e.immediateDisable or e.softDisable] + users = [e for e in events if e.userDisable] + stock_user = stock_lateral_user_event(cp) + nominal_users = bool(stock_user is not None and users and all(e.name == stock_user and + not e.softDisable and not e.immediateDisable for e in users)) + host_events = [e for e in events if not (e.name == stock_user and e.userDisable and + not e.softDisable and not e.immediateDisable)] + stock_event_first = bool(signature == self.signature and + not axis.desiredLongitudinal and not axis.longitudinalActive and + now_ns > axis.validUntilMonoTime and nominal_users and host_source_availability_only(host_events) and + 0 < axis.faultEventMonoTime < event_ns <= now_ns and now_ns - event_ns <= AXIS_MAX_AGE_NS) + # Once this exact old LAT-only proof is waiting for a newer event's half, + # any changed/non-nominal USER/fault vector terminates that fixed join. + stock_pending = bool(cp.pcmCruise and self.retention_started_ns and signature == self.signature and + not axis.desiredLongitudinal and not axis.longitudinalActive and + 0 < axis.faultEventMonoTime < self.retention_event_ns) + if (not sm.seen['onroadEvents'] or not sm.valid['onroadEvents'] or not sm.alive['onroadEvents'] or + not 0 < event_ns <= now_ns or now_ns - event_ns > 1_500_000_000 or + bool(faults) and not stock_event_first or stock_pending and not stock_event_first or + matched_lag and not stock_event_first): + return False + native = _healthy_transport_native(sm, cp, cs, self.session, now_ns) + if (native is None or not native.pandaInventory or not native.requestedLateral or not native.lateralAllowed or + (axis.desiredLongitudinal or axis.longitudinalActive) and not held and + not (native.requestedLongitudinal and native.longitudinalAllowed) or + native.observedMonoTime < self.lateral_native_ns or + (native.pandaSerial, tuple(p.hardwareSerial for p in native.pandaInventory)) != self.lateral_inventory): + return False + if stock_event_first: + if not stock_lateral_off(cp, cs, native): + return False + if self.retention_started_ns: + if (self.retention_event_ns != event_ns or + not 0 <= now_ns - self.retention_started_ns <= AXIS_MAX_AGE_NS): + return False + else: + self.retention_started_ns = now_ns + self.retention_event_ns = event_ns + self.retention_pending = True + # Only receive identity is held. Every original source, native, output + # and healthy baseline timestamp remains unchanged until the typed pair. + return True + + def _event_first_retention(self, sm, cp, axis, stamp, now_ns, *, latched, signature, native): + # A held source-capped proof may expire before the matching typed pause arrives. + # Reuse only its previously accepted identity for intent; its output lease stays expired. + if now_ns <= axis.validUntilMonoTime or signature != self.signature: + return False + if (not latched or self.failed or self.paused or not self.session or native is None or + cp_identity(cp) != self.cp or + str(axis.sessionId) != self.session or + str(axis.faultSessionId) != self.session or str(axis.faultReason) != 'none' or + not axis.qualified or not axis.nativeAcknowledged or not axis.desiredLateral or not axis.lateralActive or + not sm.seen['aolAxisState'] or not sm.valid['aolAxisState'] or not sm.alive['aolAxisState'] or + not 0 < axis.sourceCarStateMonoTime <= stamp <= now_ns or + not self.pending_event and now_ns - stamp > AXIS_MAX_AGE_NS or + stamp - axis.sourceCarStateMonoTime > AXIS_MAX_AGE_NS or + axis.validUntilMonoTime != axis.sourceCarStateMonoTime + AXIS_MAX_AGE_NS or + not stamp <= axis.validUntilMonoTime < stamp + AXIS_MAX_AGE_NS or + native.observedMonoTime < self.healthy_native_ns or + (native.pandaSerial, tuple(p.hardwareSerial for p in native.pandaInventory)) != self.healthy_inventory): + return False + event_ns = int(sm.logMonoTime['onroadEvents']) + faults = [event for event in sm['onroadEvents'] if event.immediateDisable or event.softDisable] + isolated = bool(len(faults) == 1 and faults[0].name == log.OnroadEvent.EventName.controlsMismatch and + faults[0].immediateDisable) + if (not isolated or not sm.seen['onroadEvents'] or not sm.valid['onroadEvents'] or not sm.alive['onroadEvents'] or + not 0 < axis.faultEventMonoTime < event_ns <= now_ns or now_ns - event_ns > AXIS_MAX_AGE_NS): + return False + if not self.pending_event: + if not self.healthy_feedback_ns or not 0 <= now_ns - self.healthy_feedback_ns <= AXIS_MAX_AGE_NS: + return False + self.pending_event = event_ns + if event_ns != self.pending_event or now_ns - self.pending_event > AXIS_MAX_AGE_NS: + return False + self.awaiting = True + return True + + def observe(self, sm, cp, cs, now_ns, *, latched=True, cancel_source_ns=0, brake_source_ns=0, gm_withdrawal_source_ns=0, intent_producer_session=''): + self.temporary_host_qualified = False + self.qualified = False + self.awaiting = False + self.retention_qualified = False + self.retention_pending = False + try: + axis = sm['aolAxisState'] + if not latched or cp_identity(cp) != self.cp or self.session and str(axis.sessionId) != self.session: + self.temporary_eps_event_ns = 0 + stamp = int(sm.logMonoTime['aolAxisState']) + reason = str(axis.faultReason) + signature = (str(axis.sessionId), int(axis.sequence), stamp, int(axis.observedMonoTime), + int(axis.validUntilMonoTime), reason, int(axis.faultEventMonoTime), str(axis.faultSessionId), + int(axis.sourceCarStateMonoTime), bool(axis.lateralActive), bool(axis.longitudinalActive), + bool(axis.desiredLateral), bool(axis.desiredLongitudinal), bool(axis.nativeAcknowledged), + str(getattr(axis, 'intentProducerSessionId', '')), int(getattr(axis, 'intentIntegrityEpoch', 0))) + current = bool(sm.seen['aolAxisState'] and sm.valid['aolAxisState'] and sm.alive['aolAxisState'] and + axis.qualified and axis.sessionId and axis.sequence > 0 and stamp == axis.observedMonoTime and + 0 < stamp <= now_ns <= axis.validUntilMonoTime and now_ns - stamp <= AXIS_MAX_AGE_NS and + axis.validUntilMonoTime - stamp <= AXIS_MAX_AGE_NS and axis.faultSessionId == axis.sessionId and + cp_identity(cp) == self.cp) + continuity = bool(not self.session or axis.sessionId == self.session and + (axis.sequence > self.sequence or axis.sequence == self.sequence and signature == self.signature)) + if current and continuity and (reason == 'critical' or + getattr(sm, 'updated', {}).get('onroadEvents', False)) and self._temporary_lag_intent_current(sm, cp, cs, axis, stamp, now_ns, + latched=latched, producer=intent_producer_session, signature=signature): + # Received identity only; every original permission/output deadline and + # healthy baseline timestamp remains unchanged. + self.sequence, self.signature = int(axis.sequence), signature + return False + if current and continuity and self._temporary_host_intent_current(sm, cp, cs, axis, stamp, now_ns, + latched=latched, producer=intent_producer_session): + self.sequence, self.signature = int(axis.sequence), signature + return False + if current and continuity and self._temporary_steering_intent_current(sm, cp, cs, axis, stamp, now_ns, + latched=latched, producer=intent_producer_session, signature=signature): + # Advance only received sequence identity; do not renew the prior output, + # native, source, or healthy lateral baseline leases. + self.sequence, self.signature = int(axis.sequence), signature + return False + brake_native = (self._honda_brake_lateral_current(sm, cp, cs, axis, now_ns, + latched=latched, brake_source_ns=brake_source_ns) if current and continuity else None) + if brake_native is not None: + # Keep only existing intent. The original native lease and source stamps + # remain unchanged; decide_axes/native TX independently withdraw LONG. + self.healthy_feedback_ns = stamp + self.healthy_native_ns = int(brake_native.observedMonoTime) + self.sequence, self.signature = int(axis.sequence), signature + return False + if current and continuity and (gm_withdrawal_source_ns or self.gm_withdrawal_ns): + from openpilot.starpilot.car.gm.aol import retained_physical_longitudinal_intent + gm_native = retained_physical_longitudinal_intent(self, sm, cp, cs, axis, now_ns, + latched=latched, source_ns=gm_withdrawal_source_ns, producer=intent_producer_session) + if gm_native is not None: + # Existing GM intent only; independent runtime/native TX still owns + # both axes and every original source/native/output deadline. + self.healthy_feedback_ns = stamp + self.healthy_native_ns = int(gm_native.observedMonoTime) + self.sequence, self.signature = int(axis.sequence), signature + return False + if self._source_availability_retention(sm, cp, cs, axis, stamp, now_ns, + latched=latched, continuity=continuity, producer=intent_producer_session, signature=signature): + self.sequence, self.signature = int(axis.sequence), signature + return False + if not self.failed and self._availability_retention(sm, cp, cs, axis, stamp, now_ns, + latched=latched, current=current, continuity=continuity, intent_producer_session=intent_producer_session): + self.sequence, self.signature = int(axis.sequence), signature + return False + if self._availability_recovery_current(sm, cp, cs, axis, stamp, now_ns, + latched=latched, current=current, continuity=continuity, producer=intent_producer_session): + # Separate sockets may deliver either half first. A matched healthy + # recovery resolves that pending join before ordinary baseline handling. + self.retention_started_ns = self.retention_event_ns = 0 + self.retention_verified = False + if self.retention_started_ns: + # This unmatched episode terminates fail closed. Leave failure set; + # only the existing healthy, unlatched baseline may clear it later. + self.retention_started_ns = self.retention_event_ns = 0 + self.retention_verified = False + self.failed = True + self.lateral_only_baseline = False + return True + if reason != 'availabilityPause' and not availability_only(sm['onroadEvents']): + self.retention_started_ns = self.retention_event_ns = 0 + self.retention_verified = False + baseline = bool(reason == 'none' and axis.nativeAcknowledged and + 0 < axis.sourceCarStateMonoTime <= stamp and + stamp - axis.sourceCarStateMonoTime <= AXIS_MAX_AGE_NS) + native = _healthy_transport_native(sm, cp, cs, str(axis.sessionId), now_ns) + transport_healthy = native is not None + cancel_native = (self._longitudinal_cancel_current(sm, cp, cs, axis, now_ns, latched=latched, + cancel_source_ns=cancel_source_ns) + if current and continuity and baseline and not transport_healthy else None) + if self._event_first_retention(sm, cp, axis, stamp, now_ns, latched=latched, + signature=signature, native=native): + return False + current = bool(current and (transport_healthy or cancel_native is not None)) + if current and baseline and not latched: + self.temporary_eps_event_ns = 0 + self.session = '' + self.pending_event = 0 + self.failed = False + self.longitudinal_cancel_ns = 0 + continuity = True + if self.failed: + self.longitudinal_cancel_ns = 0 + return True + if cancel_native is not None: + # This is neither transport-pause provenance nor an event exemption. + # Runtime still requires an exact independent native ACK for each axis. + if not self.longitudinal_cancel_ns: + self.longitudinal_cancel_ns = int(cancel_source_ns) + self.last_cancel_source_ns = int(cancel_source_ns) + self.lateral_baseline_ns = now_ns + self.lateral_source_ns = int(axis.sourceCarStateMonoTime) + self.lateral_native_ns = int(cancel_native.observedMonoTime) + self.sequence = int(axis.sequence) + self.signature = signature + return False + if transport_healthy: + if not latched or native is not None and not native.requestedLongitudinal: + self.gm_withdrawal_ns = 0 + if native is not None and not native.requestedLongitudinal: + self.honda_brake_ns = 0 + if (not latched or native is not None and not native.requestedLongitudinal or + self.longitudinal_cancel_ns and now_ns - self.longitudinal_cancel_ns > SAFETY_MAX_AGE_NS): + self.longitudinal_cancel_ns = 0 + if not current or not continuity or reason not in ('none', 'transportPause'): + self.longitudinal_cancel_ns = 0 + hard = (self.paused or bool(self.pending_event) or reason == 'critical' or + bool(self.session and (latched or not continuity))) + self.paused = False + self.pending_event = 0 + self.failed = hard + self.lateral_only_baseline = bool(self.lateral_only_baseline and not hard) + return hard + known = bool(self.session) + if reason == 'none': + if not baseline: + hard = self.paused or bool(self.pending_event) or bool(self.session and latched) + self.paused = False + self.pending_event = 0 + self.failed = hard + self.lateral_only_baseline = bool(self.lateral_only_baseline and not hard) + return hard + event_ns = int(sm.logMonoTime['onroadEvents']) + faults = [e for e in sm['onroadEvents'] if e.immediateDisable or e.softDisable] + isolated = bool(len(faults) == 1 and faults[0].name == log.OnroadEvent.EventName.controlsMismatch and + faults[0].immediateDisable) + if known and isolated and sm.valid['onroadEvents'] and 0 < axis.faultEventMonoTime < event_ns <= now_ns: + # Separate sockets can deliver the immediate event before its typed + # proof. Defer only the persistent-intent decision against this + # previously healthy baseline; no permission or axis is granted. + if not self.pending_event: + self.pending_event = event_ns + if event_ns != self.pending_event or now_ns - self.pending_event > AXIS_MAX_AGE_NS: + self.pending_event = 0 + self.paused = False + self.failed = True + self.lateral_only_baseline = False + return True + self.awaiting = True + return False + if self.pending_event: + self.pending_event = 0 + self.paused = False + self.failed = True + self.lateral_only_baseline = False + return True + # Remember a real physical occurrence before its native wire arrives. + # Duplicate Card observations and held/released samples cannot renew it. + cancel_pending = self._longitudinal_cancel_current(sm, cp, cs, axis, now_ns, latched=latched, + cancel_source_ns=cancel_source_ns, withdrawal_required=False) + if cancel_pending is not None: + self.longitudinal_cancel_ns = int(cancel_source_ns) + self.last_cancel_source_ns = int(cancel_source_ns) + self.source_retention_started_ns = 0 + self.healthy_feedback_ns = now_ns + self.healthy_native_ns = int(native.observedMonoTime) if native is not None else 0 + self.healthy_inventory = ((native.pandaSerial, tuple(p.hardwareSerial for p in native.pandaInventory)) + if native is not None else None) + self.session = str(axis.sessionId) + lateral_baseline = bool(axis.desiredLateral and axis.lateralActive and native is not None and + native.pandaInventory and native.requestedLateral and native.lateralAllowed) + self.lateral_baseline_ns = now_ns if lateral_baseline else 0 + self.lateral_source_ns = int(axis.sourceCarStateMonoTime) if lateral_baseline else 0 + self.availability_producer = (str(getattr(axis, 'intentProducerSessionId', '')) if lateral_baseline and + intent_producer_session and getattr(axis, 'intentProducerSessionId', '') == intent_producer_session else '') + self.availability_epoch = int(getattr(axis, 'intentIntegrityEpoch', 0)) if self.availability_producer else None + self.lateral_only_baseline = bool(lateral_baseline and self.availability_producer and + not axis.desiredLongitudinal and not axis.longitudinalActive and not native.requestedLongitudinal) + if lateral_baseline and native is not None: + self.temporary_eps_event_ns = 0 + self.lateral_native_ns = int(native.observedMonoTime) + self.lateral_inventory = (native.pandaSerial, tuple(p.hardwareSerial for p in native.pandaInventory)) + else: + self.lateral_native_ns = 0 + self.lateral_inventory = None + self.paused = False + else: + provenance = bool(known and not axis.lateralActive and not axis.longitudinalActive and + not axis.desiredLongitudinal and 0 < axis.faultEventMonoTime <= stamp) + pending = bool(provenance and axis.faultEventMonoTime > sm.logMonoTime['onroadEvents']) + if pending and not self.pending_event: + self.pending_event = int(axis.faultEventMonoTime) + self.qualified = bool(provenance and + axis.faultEventMonoTime == sm.logMonoTime['onroadEvents'] and + sm.valid['onroadEvents'] and axis.faultEventMonoTime <= now_ns) + if self.pending_event and (axis.faultEventMonoTime != self.pending_event or + now_ns - self.pending_event > AXIS_MAX_AGE_NS): + self.pending_event = 0 + self.paused = False + self.failed = True + self.lateral_only_baseline = False + return True + # The producer sends the typed envelope before its event occurrence. + # Remember that fresh provenance, but exempt no event until the exact + # occurrence arrives. Missing/old senders cannot establish this pair. + if not self.qualified and not pending: + self.failed = True + self.lateral_only_baseline = False + return True + if self.qualified: + self.pending_event = 0 + self.paused = True + self.sequence = int(axis.sequence) + self.signature = signature + return False + except (AttributeError, KeyError, IndexError, TypeError, ValueError): + self.longitudinal_cancel_ns = 0 + hard = self.paused or bool(self.pending_event) or bool(self.session and latched) + self.paused = False + self.pending_event = 0 + self.failed = hard + self.lateral_only_baseline = bool(self.lateral_only_baseline and not hard) + return hard diff --git a/openpilot/starpilot/aol/vehicle.py b/openpilot/starpilot/aol/vehicle.py new file mode 100644 index 0000000000..093c775ff0 --- /dev/null +++ b/openpilot/starpilot/aol/vehicle.py @@ -0,0 +1,96 @@ +"""Fail-closed dispatch to vehicle-owned AOL capabilities.""" + +from openpilot.starpilot.aol.policy import AolVehiclePolicy +from openpilot.starpilot.aol.intent import AolCardIntent +from openpilot.starpilot.car.honda import aol as honda +from openpilot.starpilot.car.hyundai import aol as hyundai +from openpilot.starpilot.car.gm import aol as gm +from openpilot.starpilot.car.ford import aol as ford +from openpilot.starpilot.car.mazda import aol as mazda +from openpilot.starpilot.car.tesla import aol as tesla +from openpilot.starpilot.car.toyota import aol as toyota + +_PORTS = {'honda': honda, 'hyundai': hyundai, 'gm': gm, 'ford': ford, 'mazda': mazda, 'tesla': tesla, 'toyota': toyota} + + +def policy_for(CP) -> AolVehiclePolicy: + try: + port = _PORTS.get(CP.brand) + return port.policy_for(CP) if port is not None else AolVehiclePolicy() + except (AttributeError, TypeError, ValueError, OverflowError, IndexError): + return AolVehiclePolicy() + + +def native_profile_supported(model: int, param: int) -> bool: + return any(port.native_profile_supported(model, param) for port in _PORTS.values()) + + +def native_matches_cp(CP, model: int, param: int) -> bool: + try: + if CP.brand == 'hyundai': + config = hyundai.aol_safety_config(CP) + return bool(config is not None and hyundai.native_accepts_cp(CP, model, param) and + model == int(config.safetyModel.raw) and param == int(config.safetyParam)) + return bool(any(port.native_accepts_cp(CP, model, param) for port in _PORTS.values()) and + model == int(CP.safetyConfigs[0].safetyModel.raw) and + param == int(CP.safetyConfigs[-1].safetyParam)) + except (AttributeError, TypeError, ValueError, OverflowError, IndexError): + return False + + +def native_admission_key(CP): + """Observation reuse is scoped to the exact Ioniq single-Panda contract.""" + try: + return hyundai.native_admission_key(CP) if CP.brand == 'hyundai' else None + except (AttributeError, TypeError, ValueError, OverflowError, IndexError): + return None + + +def native_latch_rejected(CP, native, *, state=None) -> bool: + if native is None: + return False + try: + port = _PORTS.get(CP.brand) + retain = getattr(port, 'retain_on_native_denial', None) + rejected = getattr(port, 'native_latch_rejected', None) + if retain is None and rejected is None: + return False + matched = native_matches_cp(CP, native.safetyModel, native.safetyParam) + if retain is not None and matched and retain(CP, native, state): + return False + return bool(rejected is not None and matched and rejected(CP, native)) + except (AttributeError, TypeError, ValueError, OverflowError, IndexError): + return False + + +def create_intent(CP, settings, policy): + port = _PORTS.get(CP.brand) + factory = getattr(port, 'create_intent', None) + if factory is not None: + return factory(CP, settings) + return AolCardIntent(settings, explicit_latch=policy.explicit_latch) + + +def ordinary_axis_request_allowed(CP, CS) -> bool: + """Required ordinary transport has no permission without its port owner.""" + try: + port = _PORTS.get(CP.brand) + allowed = getattr(port, 'ordinary_axis_request_allowed', None) + return bool(allowed is not None and allowed(CP, CS)) + except (AttributeError, TypeError, ValueError, OverflowError, IndexError): + return False + + +def allow_lateral_onset(CP, *, requested, normal_enabled, steering_pressed, previous_active): + """Optional vehicle-owned onset restriction; never creates permission.""" + port = _PORTS.get(CP.brand) + owner = getattr(port, 'allow_lateral_onset', None) + if owner is None: + return bool(requested) + return bool(requested and owner(CP, requested=requested, normal_enabled=normal_enabled, + steering_pressed=steering_pressed, previous_active=previous_active)) + + +def configuration_settings_policy(CP): + from openpilot.starpilot.car.hyundai.settings import configuration_wheel_policy + return configuration_wheel_policy(CP) or toyota.configuration_settings_policy(CP) or gm.configuration_settings_policy(CP) diff --git a/openpilot/starpilot/car/gm/aol.py b/openpilot/starpilot/car/gm/aol.py index 4fa5ddfd28..333b9f4245 100644 --- a/openpilot/starpilot/car/gm/aol.py +++ b/openpilot/starpilot/car/gm/aol.py @@ -1,15 +1,20 @@ """GM-owned AOL capabilities; exact CP admission lives with the vehicle.""" +import math + from opendbc.car.structs import CarParams +from opendbc.car.gm.values import CAR, control_flags, camera_acc_pedal_profile, is_volt_ascm_longitudinal from opendbc.car.gm.aol import GM_AOL_ALTERNATIVE_EXPERIENCE, GM_AOL_WORDS, qualified_gm from openpilot.starpilot.aol.policy import AolVehiclePolicy -from openpilot.starpilot.aol.intent import AolCardIntent +from openpilot.starpilot.aol.intent import AOL_TOGGLE, AolCardIntent def policy_for(cp) -> AolVehiclePolicy: qualified = qualified_gm(cp) return AolVehiclePolicy(intent_supported=qualified, settings_supported=qualified, runtime_supported=qualified, normal_runtime_supported=qualified, + lkas_button_supported=False, + full_axis_runtime_required=qualified and int(cp.alternativeExperience) == GM_AOL_ALTERNATIVE_EXPERIENCE, alternative_experience_addition=GM_AOL_ALTERNATIVE_EXPERIENCE if qualified else 0) @@ -25,10 +30,13 @@ def native_accepts_cp(cp, model: int, param: int) -> bool: class GmAolCardIntent(AolCardIntent): """Main-following intent with explicit main-cycle recovery after fatal faults.""" - def __init__(self, settings): - super().__init__(settings) + def __init__(self, settings, cp=None): + super().__init__(settings, lkas_button_supported=False) + self.cp = cp self.requires_fault_observation = True self._main_cycle_required = False + self.startup_calibration_pending = True + self._startup_incomplete = False def update(self, CS, *, consumed_buttons=frozenset(), fault_active=None, now_ns=0, native_rejection_ns=0, standard_enabled=False): @@ -42,7 +50,229 @@ class GmAolCardIntent(AolCardIntent): standard_enabled=standard_enabled) if self._main_cycle_required: self.allowed_latch = False + self.startup_calibration_pending = False + + def observe_calibration(self, CS, *, events=None, standard_enabled=False): + from openpilot.cereal import log + # Cold calibration readiness is not a previously revoked driver gesture. + # The original calibration inhibit still withdraws all optional output. + prior_incomplete = self._startup_incomplete + cold_safe = True + if events is not None: + from openpilot.starpilot.aol.intent import disarming_fault + remaining = [event for event in _intent_fault_events(self.cp, events) if + event.name != log.OnroadEvent.EventName.calibrationIncomplete or event.immediateDisable] + cold_safe = not disarming_fault(remaining, CS, + temporary_selfdrive_lagging=qualified_gm(self.cp)) + self._startup_incomplete = bool(cold_safe and + any(event.name == log.OnroadEvent.EventName.calibrationIncomplete for event in events)) + cold_readiness = bool(self.startup_calibration_pending and cold_safe and + (prior_incomplete or self._startup_incomplete) and + self.settings.enabled and self.settings.main_action != AOL_TOGGLE and + not CS.steerFaultPermanent and not self._main_cycle_required) + if cold_readiness: + self._calibration_rearm_required = False + super().observe_calibration(CS, events=events, standard_enabled=standard_enabled) + if cold_readiness: + self._calibration_rearm_required = False + if (events is not None and CS.canValid and not CS.canTimeout and not self._startup_incomplete and + not any(event.name == log.OnroadEvent.EventName.selfdriveInitializing for event in events)): + self.startup_calibration_pending = False + + def output(self, CS): + allowed, pause_lateral, pause_longitudinal = super().output(CS) + return (bool(allowed and not CS.canTimeout and CS.cruiseState.available), + pause_lateral, pause_longitudinal) def create_intent(cp, settings): - return GmAolCardIntent(settings) if qualified_gm(cp) else None + return GmAolCardIntent(settings, cp) if qualified_gm(cp) else None + + +def configuration_settings_policy(cp): + """Saving stock Silverado lateral intent does not establish its physical source graph.""" + try: + if (cp.brand == 'gm' and cp.carFingerprint == CAR.CHEVROLET_MALIBU_HYBRID_CC and + not cp.notCar and not cp.passive and cp.lateralTuning.which() == 'torque' and + cp.steerControlType == CarParams.SteerControlType.torque and + int(cp.alternativeExperience) in (0, GM_AOL_ALTERNATIVE_EXPERIENCE)): + return AolVehiclePolicy(settings_supported=True, lkas_button_supported=False) + if (cp.brand != 'gm' or cp.carFingerprint != CAR.CHEVROLET_SILVERADO_CC or + cp.notCar or cp.passive or cp.networkLocation != CarParams.NetworkLocation.gateway or + cp.transmissionType != CarParams.TransmissionType.automatic or not cp.radarUnavailable or + not cp.pcmCruise or cp.openpilotLongitudinalControl or cp.alphaLongitudinalAvailable or + control_flags(cp) != 0 or int(cp.alternativeExperience) not in (0, GM_AOL_ALTERNATIVE_EXPERIENCE) or + len(cp.safetyConfigs) != 1 or cp.safetyConfigs[0].safetyModel != CarParams.SafetyModel.gm or + int(cp.safetyConfigs[0].safetyParam) != 16 or cp.steerControlType != CarParams.SteerControlType.torque or + cp.lateralTuning.which() != 'torque'): + return None + tune = cp.lateralTuning.torque + if (not all(math.isfinite(value) for value in (tune.latAccelFactor, tune.latAccelOffset, tune.friction)) or + tune.latAccelFactor <= 0 or tune.friction < 0): + return None + return AolVehiclePolicy(settings_supported=True, lkas_button_supported=False) + except (AttributeError, IndexError, TypeError, ValueError, OverflowError): + return None + + +def _intent_fault_events(cp, events): + from openpilot.cereal import log + if qualified_gm(cp): + availability = (log.OnroadEvent.EventName.commIssue, log.OnroadEvent.EventName.commIssueAvgFreq) + events = [event for event in events if not + (event.name in availability and event.softDisable and not event.immediateDisable)] + if qualified_gm(cp) and camera_acc_pedal_profile(cp) is None and is_volt_ascm_longitudinal(cp): + events = [event for event in events if event.name != log.OnroadEvent.EventName.accFaulted] + return events + + +def intent_disarming_fault(cp, events, state, **context): + """An ASCM Volt ACC fault withdraws axes without erasing main-armed intent.""" + from openpilot.cereal import log + from openpilot.starpilot.aol.intent import disarming_fault + cold_calibration = context.pop('startup_calibration_pending', False) + if qualified_gm(cp) and cold_calibration and not state.steerFaultPermanent: + events = [event for event in events if not ( + event.name == log.OnroadEvent.EventName.calibrationIncomplete and not event.immediateDisable)] + return disarming_fault(_intent_fault_events(cp, events), state, **context) + + +def physical_longitudinal_withdrawal_source(cp, cs, parser, observation, now_ns) -> int: + """Bind a Bolt pedal-owner withdrawal to its parsed CAN and local receipt.""" + from opendbc.car.gm.values import CruiseButtons, is_bolt_present_no_acc_pedal_profile + from opendbc.car.structs import car + try: + if (not qualified_gm(cp) or not is_bolt_present_no_acc_pedal_profile(cp) or + int(cp.alternativeExperience) != GM_AOL_ALTERNATIVE_EXPERIENCE or + observation is None or observation[0] is not cs or not cs.canValid or cs.canTimeout): + return 0 + fields = [] + if cs.brakePressed and parser.vl['ECMEngineStatus']['BrakePressed']: + fields.append(('ECMEngineStatus', 'BrakePressed')) + if cs.regenBraking and parser.vl['EBCMRegenPaddle']['RegenPaddle']: + fields.append(('EBCMRegenPaddle', 'RegenPaddle')) + if (parser.vl['ASCMSteeringButton']['ACCButtons'] == CruiseButtons.CANCEL and + any(e.type == car.CarState.ButtonEvent.Type.cancel and e.pressed for e in cs.buttonEvents)): + fields.append(('ASCMSteeringButton', 'ACCButtons')) + _, received_ns, can_source_ns, boot_ns = observation + if not fields or not 0 < received_ns <= now_ns or now_ns - received_ns > 30_000_000: + return 0 + # Parser/CAN batch use BOOTTIME. The fixed deadline uses the original + # local MONOTONIC receipt; neither clock is converted or restamped. + stamps = [int(parser.ts_nanos[message][signal]) for message, signal in fields] + if not all(0 < stamp <= can_source_ns <= boot_ns and boot_ns - stamp <= 30_000_000 and + can_source_ns - stamp <= 30_000_000 for stamp in stamps): + return 0 + return received_ns + except (AttributeError, KeyError, IndexError, TypeError, ValueError): + return 0 + + +def retained_physical_longitudinal_intent(feedback, sm, cp, cs, axis, now_ns, *, latched, source_ns, producer): + """Retain accepted LAT intent through a bounded physical LONG withdrawal.""" + from opendbc.car.gm.values import is_bolt_present_no_acc_pedal_profile + from opendbc.car.structs import car + from openpilot.cereal import log + from openpilot.starpilot.aol.runtime import AXIS_MAX_AGE_NS, SAFETY_MAX_AGE_NS + from openpilot.starpilot.aol.transport_pause import independent_lateral_health + occurrence = feedback.gm_withdrawal_ns or source_ns + if (not qualified_gm(cp) or not is_bolt_present_no_acc_pedal_profile(cp) or + int(cp.alternativeExperience) != GM_AOL_ALTERNATIVE_EXPERIENCE or + not latched or feedback.failed or feedback.paused or feedback.pending_event or + feedback.retention_started_ns or not feedback.session or str(axis.sessionId) != feedback.session or + not feedback.healthy_feedback_ns or not 0 <= now_ns - feedback.healthy_feedback_ns <= AXIS_MAX_AGE_NS or + not 0 < occurrence <= now_ns or now_ns - occurrence > SAFETY_MAX_AGE_NS or + not feedback.gm_withdrawal_ns and now_ns - occurrence > AXIS_MAX_AGE_NS or + not producer or producer != feedback.availability_producer or + str(axis.intentProducerSessionId) != producer or int(axis.intentIntegrityEpoch) != feedback.availability_epoch or + cs.accFaulted or cs.doorOpen or not cs.cruiseState.available or + cs.gearShifter not in (car.CarState.GearShifter.drive, car.CarState.GearShifter.low, + car.CarState.GearShifter.sport, car.CarState.GearShifter.brake) or + str(axis.faultReason) != 'none' or not axis.desiredLateral or not axis.lateralActive or + not axis.nativeAcknowledged or + not 0 < axis.sourceCarStateMonoTime <= axis.observedMonoTime <= now_ns or + now_ns - axis.sourceCarStateMonoTime > AXIS_MAX_AGE_NS): + return None + # The saved LAT contract treats a parsed unlatched belt as a LONG lock. + # After rebuckling, accept only a prior producer's matching belt event and + # LAT-only proof within the already established physical occurrence. Events + # publish sparsely, so their stamp binds to faultEventMonoTime, not the newer + # car-state source. The original axis/native leases remain unchanged. + event_ns = int(sm.logMonoTime['onroadEvents']) + prior_belt_proof = bool(feedback.gm_withdrawal_ns and + feedback.gm_withdrawal_ns <= event_ns <= axis.observedMonoTime < now_ns and + event_ns == axis.faultEventMonoTime and str(axis.faultSessionId) == feedback.session and + int(sm.logMonoTime['aolAxisState']) == axis.observedMonoTime and + now_ns <= axis.validUntilMonoTime <= axis.observedMonoTime + AXIS_MAX_AGE_NS and + not axis.desiredLongitudinal and not axis.longitudinalActive) + if (not sm.seen['onroadEvents'] or not sm.valid['onroadEvents'] or not sm.alive['onroadEvents'] or + not 0 < event_ns <= now_ns or now_ns - event_ns > 1_500_000_000 or + any(e.immediateDisable or e.softDisable and not + ((cs.seatbeltUnlatched or prior_belt_proof) and e.name == log.OnroadEvent.EventName.seatbeltNotLatched and + e.noEntry and not e.userDisable) for e in sm['onroadEvents'])): + return None + native = independent_lateral_health(sm, cp, cs, feedback.session, now_ns) + if (native is None or not native.pandaInventory or not native.requestedLongitudinal or + not any(not p.controlsAllowed for p in sm['pandaStates']) or + native.observedMonoTime < feedback.healthy_native_ns or + (native.pandaSerial, tuple(p.hardwareSerial for p in native.pandaInventory)) != feedback.healthy_inventory): + return None + # One occurrence spans the held native request. Held/released samples and + # duplicate Card calls cannot renew it; output/native leases remain original. + feedback.gm_withdrawal_ns = occurrence + return native + + +def physical_cancel_lateral_allowed(cp, cs, intent, native, *, source_ns, now_ns) -> bool: + """Exact armed Bolt Cancel removes only the lateral NO_ENTRY restriction.""" + from opendbc.car.gm.values import is_bolt_present_no_acc_pedal_profile + from opendbc.car.structs import car + from openpilot.starpilot.aol.runtime import AXIS_MAX_AGE_NS, SAFETY_MAX_AGE_NS, native_inventory_matches_cp + try: + return bool(qualified_gm(cp) and is_bolt_present_no_acc_pedal_profile(cp) and + int(cp.alternativeExperience) == GM_AOL_ALTERNATIVE_EXPERIENCE and + intent is not None and intent.settingsQualified and intent.allowedLatch and intent.lateralArmed and + intent.producerSessionId and intent.carStateLogMonoTime == source_ns and + 0 < source_ns <= intent.observedMonoTime <= now_ns <= intent.validUntilMonoTime and + now_ns - source_ns <= AXIS_MAX_AGE_NS and + # Card declares a 200ms wire lease; admission and this source remain 30ms fresh. + intent.validUntilMonoTime - intent.observedMonoTime <= SAFETY_MAX_AGE_NS and + cs.canValid and not cs.canTimeout and not cs.steerFaultTemporary and not cs.steerFaultPermanent and + any(e.type == car.CarState.ButtonEvent.Type.cancel for e in cs.buttonEvents) and + native is not None and native.requestedLateral and native.lateralAllowed and + native.pandaInventory and native_inventory_matches_cp(native, cp) and + 0 < native.observedMonoTime <= now_ns <= native.validUntilMonoTime) + except (AttributeError, KeyError, IndexError, TypeError, ValueError): + return False + + +def seatbelt_lateral_soft_event_allowed(cp, cs, intent, native, event, *, source_ns, now_ns, axis_session_id) -> bool: + """The qualified saved LAT owner treats only its ordinary belt event as a LONG lock.""" + if not qualified_gm(cp): + return False + from opendbc.car.gm.values import is_bolt_present_no_acc_pedal_profile + from opendbc.car.structs import car + from openpilot.cereal import log + from openpilot.starpilot.aol.runtime import AXIS_MAX_AGE_NS, SAFETY_MAX_AGE_NS, native_inventory_matches_cp + try: + return bool(is_bolt_present_no_acc_pedal_profile(cp) and + int(cp.alternativeExperience) == GM_AOL_ALTERNATIVE_EXPERIENCE and + event.name == log.OnroadEvent.EventName.seatbeltNotLatched and event.softDisable and + event.noEntry and not event.immediateDisable and not event.userDisable and + cs.seatbeltUnlatched and cs.canValid and not cs.canTimeout and not cs.accFaulted and not cs.doorOpen and + not cs.steerFaultTemporary and not cs.steerFaultPermanent and cs.cruiseState.available and + cs.gearShifter in (car.CarState.GearShifter.drive, car.CarState.GearShifter.low, + car.CarState.GearShifter.sport, car.CarState.GearShifter.brake) and + intent is not None and intent.settingsQualified and intent.allowedLatch and intent.lateralArmed and + intent.producerSessionId and intent.carStateLogMonoTime == source_ns and + 0 < source_ns <= intent.observedMonoTime <= now_ns <= intent.validUntilMonoTime and + now_ns - source_ns <= AXIS_MAX_AGE_NS and + intent.validUntilMonoTime - intent.observedMonoTime <= SAFETY_MAX_AGE_NS and + native is not None and native.compatible and int(native.protocolVersion) == 1 and + axis_session_id and native.axisSessionId == axis_session_id and + native.requestedLateral and native.lateralAllowed and native.pandaInventory and + native_inventory_matches_cp(native, cp) and + 0 < native.observedMonoTime <= now_ns <= native.validUntilMonoTime and + native.validUntilMonoTime - native.observedMonoTime <= SAFETY_MAX_AGE_NS) + except (AttributeError, KeyError, IndexError, TypeError, ValueError): + return False diff --git a/openpilot/starpilot/car/gm/steering_companion.py b/openpilot/starpilot/car/gm/steering_companion.py new file mode 100644 index 0000000000..fa6ba770fe --- /dev/null +++ b/openpilot/starpilot/car/gm/steering_companion.py @@ -0,0 +1,59 @@ +"""Exact carState companions from the nonconflating steering-authority transport.""" +from openpilot.cereal import messaging +from openpilot.starpilot.aol.runtime import INTENT_MAX_AGE_NS + +MAX_COMPANIONS = 16 +MAX_DRAIN = 32 +SERVICE = 'starpilotCarState' + + +class SteeringCompanion: + def __init__(self, sock=None): + self.sock = sock + self.samples = {} + self.latest = None + + def _observe(self, stamp, valid, companion): + sample = (int(stamp), bool(valid), int(companion.sourceCarStateMonoTime), + bool(companion.lateralAuthorityUnavailable)) + previous = self.samples.get(sample[0]) + if previous is not None: + sample = (sample[0], previous[1] and sample[1] and previous[2] == sample[2], + sample[2], previous[3] or sample[3]) + self.samples[sample[0]] = sample + if self.latest is None or sample[0] >= self.latest[0]: + self.latest = sample + while len(self.samples) > MAX_COMPANIONS: + del self.samples[min(self.samples)] + + def current(self, sm, *, source_ns, now_ns): + if self.sock is not None: + for _ in range(MAX_DRAIN): + message = messaging.recv_one_or_none(self.sock) + if message is None: + break + self._observe(message.logMonoTime, message.valid, message.starpilotCarState) + else: + return None + elif sm.seen.get(SERVICE, False): + self._observe(sm.logMonoTime[SERVICE], sm.valid.get(SERVICE, False), sm[SERVICE]) + + for stamp in tuple(self.samples): + if not 0 < stamp <= now_ns or now_ns - stamp > INTENT_MAX_AGE_NS: + del self.samples[stamp] + if not (sm.seen.get(SERVICE, False) and sm.valid.get(SERVICE, False) and sm.alive.get(SERVICE, False)): + return None + sm_stamp = int(sm.logMonoTime[SERVICE]) + sm_companion = sm[SERVICE] + if not (int(sm_companion.sourceCarStateMonoTime) == sm_stamp and + 0 < source_ns <= sm_stamp <= now_ns and now_ns - sm_stamp <= INTENT_MAX_AGE_NS): + return None + latest = self.latest + pair = self.samples.get(source_ns) + if latest is None or pair is None: + return None + stamp, valid, source, unavailable = latest + if not (valid and source == stamp and source_ns <= stamp <= now_ns and + now_ns - stamp <= INTENT_MAX_AGE_NS and pair[1] and pair[2] == source_ns): + return None + return bool(pair[3] or unavailable or sm_companion.lateralAuthorityUnavailable) diff --git a/openpilot/starpilot/car/hyundai/tests/test_lateral_fault.py b/openpilot/starpilot/car/hyundai/tests/test_lateral_fault.py new file mode 100644 index 0000000000..a4c54e0960 --- /dev/null +++ b/openpilot/starpilot/car/hyundai/tests/test_lateral_fault.py @@ -0,0 +1,87 @@ +import os +import unittest +from unittest.mock import patch + +from opendbc.car.car_helpers import interfaces +from opendbc.car.hyundai.values import CAR +from openpilot.cereal import messaging +from openpilot.common.params import Params +from openpilot.common.prefix import OpenpilotPrefix +from openpilot.selfdrive.controls.controlsd import Controls +from openpilot.starpilot.car.hyundai.lateral_fault import LateralFaultLatch +from openpilot.starpilot.lateral.tests.test_lane_runtime import feed + + +class TestHyundaiLateralFault(unittest.TestCase): + def test_actual_controls_requires_rearm_after_elantra_hybrid_fault(self): + for model in (CAR.HYUNDAI_ELANTRA_HEV_2024, CAR.HYUNDAI_ELANTRA_2024, CAR.HYUNDAI_IONIQ_6): + with self.subTest(model=model), OpenpilotPrefix(), \ + patch.dict(os.environ, {'REPLAY': '1', 'AOL_REPLAY_RUNTIME': '0'}), \ + patch('openpilot.selfdrive.controls.controlsd.messaging.PubMaster'): + cp = interfaces[model].get_non_essential_params(model) + Params().put('CarParams', cp.to_bytes(), block=True) + controls = Controls() + latched_model = model == CAR.HYUNDAI_ELANTRA_HEV_2024 + cases = [(True, False, False, False, True), + (True, True, False, False, False), + (True, False, False, False, not latched_model), + (True, False, True, False, True), + (True, True, True, False, False), + (False, False, False, False, False), + (True, False, False, False, True), + (True, False, False, True, False)] + for tick, (requested, temporary, cruise, permanent, expected) in enumerate(cases): + now_ns = 1_000_000_000 + tick * 10_000_000 + feed(controls, now_ns, tick, active=requested, enabled=requested, fault=temporary) + event = messaging.new_message('carState', valid=True, logMonoTime=now_ns) + event.carState = controls.sm['carState'] + event.carState.cruiseState.enabled = cruise + event.carState.steerFaultPermanent = permanent + controls.sm.data['carState'] = event.carState.as_reader() + command, _ = controls.state_control() + self.assertEqual(command.latActive, expected) + self.assertEqual(command.enabled, requested) + if not expected: + self.assertEqual(command.actuators.torque, 0.0) + + def test_actual_axis_intent_survives_fault_and_missing_permission_without_admission(self): + from openpilot.starpilot.aol.runtime import current_axis, current_native + + with OpenpilotPrefix(), patch.dict(os.environ, {'REPLAY': '1', 'AOL_REPLAY_RUNTIME': '0'}), \ + patch('openpilot.selfdrive.controls.controlsd.messaging.PubMaster'): + cp = interfaces[CAR.HYUNDAI_ELANTRA_HEV_2024].get_non_essential_params(CAR.HYUNDAI_ELANTRA_HEV_2024) + Params().put('CarParams', cp.to_bytes(), block=True) + controls = Controls() + self.assertFalse(controls.aol_replay) + # Exercise the reader boundary without granting this vehicle AOL safety. + controls.aol_replay = True + controls.sm = messaging.SubMaster([*controls.sm.services, 'aolAxisState', 'aolSafetyWire'], frequency=100) + for tick, (desired, active, fault, fresh, expected_latch) in enumerate( + ((True, True, True, True, True), (True, False, False, True, True), + (True, False, False, False, True), (False, False, False, True, False))): + now_ns = 1_000_000_000 + tick * 10_000_000 + feed(controls, now_ns, tick, fault=fault, aol_lat_only=True) + event = messaging.new_message('aolAxisState', valid=True, logMonoTime=now_ns) + event.aolAxisState = controls.sm['aolAxisState'] + event.aolAxisState.desiredLateral = desired + event.aolAxisState.lateralActive = active + controls.sm.data['aolAxisState'] = event.aolAxisState.as_reader() + controls.sm.valid['aolAxisState'] = fresh + axis = current_axis(controls.sm, now_ns=now_ns) + self.assertEqual(axis is not None, fresh) + self.assertIsNone(current_native(controls.sm, cp, now_ns=now_ns)) + command, _ = controls.state_control() + self.assertEqual(controls.hyundai_lateral_fault.faulted, expected_latch) + self.assertFalse(command.latActive) + self.assertEqual(command.actuators.torque, 0.0) + + def test_fault_at_cruise_rising_edge_still_suppresses_output(self): + latch = LateralFaultLatch(CAR.HYUNDAI_ELANTRA_HEV_2024) + self.assertFalse(latch.update(requested=True, temporary_fault=True, cruise_enabled=True)) + self.assertTrue(latch.update(requested=True, temporary_fault=True, cruise_enabled=True)) + self.assertTrue(latch.update(requested=True, temporary_fault=False, cruise_enabled=True)) + self.assertFalse(latch.update(requested=False, temporary_fault=False, cruise_enabled=True)) + + +if __name__ == '__main__': + unittest.main()