diff --git a/openpilot/starpilot/aol/runtime.py b/openpilot/starpilot/aol/runtime.py index 74714ae709..7824f29573 100644 --- a/openpilot/starpilot/aol/runtime.py +++ b/openpilot/starpilot/aol/runtime.py @@ -9,7 +9,7 @@ from __future__ import annotations from dataclasses import dataclass from opendbc.car.structs import car -from openpilot.starpilot.aol.vehicle import native_matches_cp, native_profile_supported +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 @@ -105,23 +105,57 @@ def native_inventory_matches_cp(state, CP) -> bool: 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 - state = decode_safety(sm[SAFETY_SERVICE]) + 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_matches_cp(CP, int(state.safetyModel), int(state.safetyParam)) or - not native_inventory_matches_cp(state, CP) or + 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 diff --git a/openpilot/starpilot/aol/tests/test_native_observation_admission.py b/openpilot/starpilot/aol/tests/test_native_observation_admission.py new file mode 100644 index 0000000000..e1ac8cac86 --- /dev/null +++ b/openpilot/starpilot/aol/tests/test_native_observation_admission.py @@ -0,0 +1,293 @@ +"""Reuse structural admission only; every live native gate remains per call.""" +from dataclasses import replace +from unittest import mock + +import pytest + +from openpilot.cereal import messaging +from openpilot.starpilot.aol import runtime, transport_pause +from openpilot.starpilot.aol.tests.test_panda_inventory import NOW, bound_state, configured_cp, sm_for +from openpilot.starpilot.aol.tests.test_runtime import car_state +from openpilot.starpilot.aol.wire import encode_safety + + +def receive(sm, state, *, stamp=None, valid=True, raw=None): + stamp = state.observedMonoTime if stamp is None else stamp + message = messaging.new_message('aolSafetyWire', 0, valid=valid, logMonoTime=stamp) + message.aolSafetyWire = encode_safety(state) if raw is None else raw + sm.update_msgs(max(NOW, stamp) / 1e9, [message.as_reader()]) + + +def current(sm, cp, now=NOW, session=None): + return runtime.current_native(sm, cp, now_ns=now, axis_session_id=session) + + +def test_twenty_observations_per_two_hundred_live_checks_and_owner_isolation(): + cp = configured_cp() + state = bound_state(cp) + sm = sm_for(cp, state) + other = sm_for(cp, state) + with mock.patch.object(runtime, 'native_matches_cp', wraps=runtime.native_matches_cp) as profile, \ + mock.patch.object(runtime, 'native_inventory_matches_cp', wraps=runtime.native_inventory_matches_cp) as inventory: + for tick in range(200): + now = NOW + tick * 10_000_000 + if tick % 10 == 0: + state = bound_state(cp, now=now) + receive(sm, state) + assert current(sm, cp, now, state.axisSessionId if tick % 2 else None) == state + assert profile.call_count == inventory.call_count == 20 + assert current(other, cp) is not None + assert profile.call_count == inventory.call_count == 21 + assert len(sm._aol_native_admission) == 3 + + +@pytest.mark.parametrize('field,value', [ + ('brand', 'mock'), ('carFingerprint', 'mock'), ('flags', 0), ('radarUnavailable', True), + ('notCar', True), ('passive', True), ('dashcamOnly', True), + ('openpilotLongitudinalControl', False), ('pcmCruise', True), ('alternativeExperience', 32), + ('safetyModel', 0), ('safetyParam', 0), ('extra_slot', True), +]) +def test_every_cp_admission_input_invalidates_a_hot_entry(field, value): + cp = configured_cp() + state = bound_state(cp) + sm = sm_for(cp, state) + assert current(sm, cp) is not None + original = cp.as_reader().as_builder() + if field == 'extra_slot': + cp.safetyConfigs = [original.safetyConfigs[0], original.safetyConfigs[0]] + elif field in ('safetyModel', 'safetyParam'): + setattr(cp.safetyConfigs[0], field, value) + else: + setattr(cp, field, value) + assert current(sm, cp) is None + assert sm._aol_native_admission is None + if field == 'extra_slot': + cp.safetyConfigs = [original.safetyConfigs[0]] + elif field in ('safetyModel', 'safetyParam'): + setattr(cp.safetyConfigs[0], field, getattr(original.safetyConfigs[0], field)) + else: + setattr(cp, field, getattr(original, field)) + with mock.patch.object(runtime, 'native_matches_cp', wraps=runtime.native_matches_cp) as profile: + assert current(sm, cp) is not None + assert profile.call_count == 1 + + +@pytest.mark.parametrize('field', ['valid', 'alive', 'seen']) +def test_live_envelope_health_invalidates_hot_entry(field): + cp = configured_cp() + state = bound_state(cp) + sm = sm_for(cp, state) + assert current(sm, cp) is not None + getattr(sm, field)['aolSafetyWire'] = False + assert current(sm, cp) is None + assert sm._aol_native_admission is None + getattr(sm, field)['aolSafetyWire'] = True + with mock.patch.object(runtime, 'native_matches_cp', wraps=runtime.native_matches_cp) as profile: + assert current(sm, cp) is not None + assert profile.call_count == 1 + + +@pytest.mark.parametrize('change', ['session', 'future_clock', 'expired', 'future_source', 'changed_source', 'bad_byte']) +def test_live_identity_and_original_lease_reject_hot_entry(change): + cp = configured_cp() + state = bound_state(cp) + sm = sm_for(cp, state) + assert current(sm, cp) is not None + now, session = NOW, state.axisSessionId + if change == 'session': + session = 'foreign' + elif change == 'future_clock': + now -= 1 + elif change == 'expired': + now += 200_000_001 + elif change in ('future_source', 'changed_source'): + sm.logMonoTime['aolSafetyWire'] += 1 if change == 'future_source' else -1 + else: + raw = bytearray(encode_safety(state)) + raw[0] ^= 1 + receive(sm, state, raw=bytes(raw)) + assert current(sm, cp, now, session) is None + assert sm._aol_native_admission is None + + +@pytest.mark.parametrize('change', ['lateral', 'longitudinal', 'rx', 'heartbeat', 'fault', 'serial', 'inventory', 'compatible', 'protocol']) +def test_changed_wire_cannot_borrow_previous_native_permission_or_health(change): + cp = configured_cp() + state = bound_state(cp) + sm = sm_for(cp, state) + assert current(sm, cp) is not None + values = {'lateral': {'lateralAllowed': False}, 'longitudinal': {'longitudinalAllowed': False}, + 'serial': {'pandaSerial': 'foreign'}, 'compatible': {'compatible': False}, 'protocol': {'protocolVersion': 2}} + if change in values: + updated = replace(state, **values[change]) + else: + slot = state.pandaInventory[0] + fields = {'rx': {'safetyRxChecksInvalid': True}, 'heartbeat': {'heartbeatLost': True}, + 'fault': {'faults': 1}, 'inventory': {'safetyParam': 0}} + updated = replace(state, pandaInventory=(replace(slot, **fields[change]),)) + receive(sm, updated) + result = current(sm, cp) + if change in ('lateral', 'longitudinal'): + assert result == updated + assert not getattr(result, 'lateralAllowed' if change == 'lateral' else 'longitudinalAllowed') + else: + assert result is None + assert sm._aol_native_admission is None + + +def test_latest_panda_fault_is_still_checked_on_a_native_cache_hit(): + cp = configured_cp() + state = bound_state(cp) + sm = sm_for(cp, state) + assert transport_pause.healthy_transport(sm, cp, car_state(), state.axisSessionId, NOW) + pandas = messaging.new_message('pandaStates', 1, valid=True, logMonoTime=state.sourcePandaStatesMonoTime) + pandas.pandaStates[0] = sm['pandaStates'][0] + pandas.pandaStates[0].heartbeatLost = True + sm.update_msgs(NOW / 1e9, [pandas.as_reader()]) + assert current(sm, cp) is not None + assert not transport_pause.healthy_transport(sm, cp, car_state(), state.axisSessionId, NOW) + + +def test_same_builder_mutation_during_miss_cannot_store_an_old_key(): + cp = configured_cp() + state = bound_state(cp) + sm = sm_for(cp, state) + original = runtime.native_inventory_matches_cp + + def validate_then_mutate(native, params): + result = original(native, params) + params.radarUnavailable = True + return result + + with mock.patch.object(runtime, 'native_inventory_matches_cp', side_effect=validate_then_mutate): + assert current(sm, cp) is not None # Same result as the uncached validation call. + assert sm._aol_native_admission is None + assert current(sm, cp) is None + + +@pytest.mark.parametrize('legacy', [False, True]) +def test_unsupported_owner_or_legacy_wire_keeps_full_validation(legacy): + cp = configured_cp() + state = bound_state(cp) + if legacy: + state = replace(state, sourcePandaStatesMonoTime=0, pandaInventory=()) + original = sm_for(cp, state) + + class SlottedOwner: + __slots__ = ('valid', 'alive', 'seen', 'logMonoTime') + + def __init__(self): + for name in self.__slots__: + setattr(self, name, getattr(original, name)) + + def __getitem__(self, name): + return original[name] + + sm = original if legacy else SlottedOwner() + with mock.patch.object(runtime, 'native_matches_cp', wraps=runtime.native_matches_cp) as profile: + assert current(sm, cp) is not None + assert current(sm, cp) is not None + assert profile.call_count == 2 + + +@pytest.mark.parametrize('profile_name', ['honda', 'ev6_two_pandas']) +def test_other_admitted_profiles_keep_full_validation(profile_name): + if profile_name == 'honda': + from opendbc.car import gen_empty_fingerprint + from opendbc.car.honda.interface import CarInterface + from opendbc.car.honda.values import CAR + fingerprint = gen_empty_fingerprint() + fingerprint[1][0x1A3] = 8 + cp = CarInterface.get_params(CAR.HONDA_ACCORD, fingerprint, [], False, False, False) + cp.alternativeExperience = 32 + else: + from openpilot.starpilot.tests.test_ev6_startup import params + cp = params() + cp.openpilotLongitudinalControl, cp.pcmCruise, cp.alternativeExperience = True, False, 32 + cp.safetyConfigs = [{'safetyModel': 'noOutput', 'safetyParam': 0}, + {'safetyModel': 'hyundaiCanfd', 'safetyParam': 0x815}] + state = bound_state(cp) + sm = sm_for(cp, state) + with mock.patch.object(runtime, 'native_matches_cp', wraps=runtime.native_matches_cp) as profile: + assert current(sm, cp) == state + assert current(sm, cp) == state + assert profile.call_count == 2 + assert sm._aol_native_admission is None + + +def test_rejected_observation_then_valid_recovery_cannot_reuse_a_failed_verdict(): + cp = configured_cp() + good = bound_state(cp) + bad = replace(good, pandaSerial='other') + sm = sm_for(cp, bad) + with mock.patch.object(runtime, 'native_matches_cp', wraps=runtime.native_matches_cp) as profile: + assert current(sm, cp) is None + assert current(sm, cp) is None + assert profile.call_count == 2 + assert sm._aol_native_admission is None + receive(sm, good) + assert current(sm, cp) == good + assert current(sm, cp) == good + assert profile.call_count == 3 + + +def test_cache_key_failure_falls_back_to_complete_validation(): + cp = configured_cp() + state = bound_state(cp) + sm = sm_for(cp, state) + assert current(sm, cp) == state + with mock.patch.object(runtime, 'native_admission_key', side_effect=RuntimeError('cache failure')), \ + mock.patch.object(runtime, 'native_matches_cp', wraps=runtime.native_matches_cp) as profile: + assert current(sm, cp) == state + assert current(sm, cp) == state + assert profile.call_count == 2 + assert sm._aol_native_admission is None + + +@pytest.mark.parametrize('field,value', [('canValid', False), ('canTimeout', True), + ('steerFaultTemporary', True), ('steerFaultPermanent', True)]) +def test_live_car_state_veto_remains_after_native_cache_hit(field, value): + cp = configured_cp() + state = bound_state(cp) + sm = sm_for(cp, state) + cs = car_state() + assert transport_pause.healthy_transport(sm, cp, cs, state.axisSessionId, NOW) + setattr(cs, field, value) + assert current(sm, cp) == state + assert not transport_pause.healthy_transport(sm, cp, cs, state.axisSessionId, NOW) + + +@pytest.mark.parametrize('field,value', [('controlsAllowed', False), ('safetyParam', 0), + ('alternativeExperience', 32), ('safetyRxChecksInvalid', True)]) +def test_latest_panda_permission_and_configuration_veto_a_native_cache_hit(field, value): + cp = configured_cp() + state = bound_state(cp) + sm = sm_for(cp, state) + assert transport_pause.healthy_transport(sm, cp, car_state(), state.axisSessionId, NOW) + pandas = messaging.new_message('pandaStates', 1, valid=True, logMonoTime=state.sourcePandaStatesMonoTime) + pandas.pandaStates[0] = sm['pandaStates'][0] + setattr(pandas.pandaStates[0], field, value) + sm.update_msgs(NOW / 1e9, [pandas.as_reader()]) + assert current(sm, cp) == state + assert not transport_pause.healthy_transport(sm, cp, car_state(), state.axisSessionId, NOW) + + +def test_cache_hit_uses_exact_original_inclusive_deadline(): + cp = configured_cp() + state = bound_state(cp) + sm = sm_for(cp, state) + with mock.patch.object(runtime, 'native_matches_cp', wraps=runtime.native_matches_cp) as profile: + assert current(sm, cp) == state + assert current(sm, cp, state.validUntilMonoTime) == state + assert profile.call_count == 1 + assert current(sm, cp, state.validUntilMonoTime + 1) is None + assert sm._aol_native_admission is None + + +def test_invalid_profile_keeps_original_short_circuit_with_missing_owner_stamp(): + cp = configured_cp() + state = bound_state(cp) + sm = sm_for(cp, state) + cp.radarUnavailable = True + sm.logMonoTime.pop('aolSafetyWire') + assert current(sm, cp) is None diff --git a/openpilot/starpilot/aol/vehicle.py b/openpilot/starpilot/aol/vehicle.py index aacbd72948..d82d253e7f 100644 --- a/openpilot/starpilot/aol/vehicle.py +++ b/openpilot/starpilot/aol/vehicle.py @@ -38,6 +38,14 @@ def native_matches_cp(CP, model: int, param: int) -> bool: 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 diff --git a/openpilot/starpilot/car/hyundai/aol.py b/openpilot/starpilot/car/hyundai/aol.py index c8c55a4a60..d97db58dcd 100644 --- a/openpilot/starpilot/car/hyundai/aol.py +++ b/openpilot/starpilot/car/hyundai/aol.py @@ -73,6 +73,20 @@ def qualified_ioniq6(CP) -> bool: return raw == (0x8895 if flags & int(HyundaiFlags.CANFD_LKA_STEER_MSG_ALT) else 0x8815) +def native_admission_key(CP): + """Complete mutable CP inputs to exact one-slot Ioniq native admission. + + Keep this in sync with qualified_ioniq6 and native inventory qualification. + This key is not an admission decision and never contains live authority. + """ + if CP.brand != 'hyundai' or CP.carFingerprint != HyundaiCAR.HYUNDAI_IONIQ_6 or len(CP.safetyConfigs) != 1: + return None + return (str(CP.brand), str(CP.carFingerprint), int(CP.flags), bool(CP.radarUnavailable), + bool(CP.notCar), bool(CP.passive), bool(CP.dashcamOnly), + bool(CP.openpilotLongitudinalControl), bool(CP.pcmCruise), int(CP.alternativeExperience), + tuple((int(c.safetyModel.raw), int(c.safetyParam)) for c in CP.safetyConfigs)) + + def ioniq6_settings_capable(CP) -> bool: # Stock ACC lateral settings are independent of the experimental LONG opt-in. return _stock_ioniq6(CP) or (IONIQ6_LONG_PREARM_ENABLED and ioniq6_long_eligible(CP))