From 7d145ee95282d91b1f723edbc51b115728894786 Mon Sep 17 00:00:00 2001 From: firestar5683 <168790843+firestar5683@users.noreply.github.com> Date: Mon, 19 Oct 2026 10:42:53 -0500 Subject: [PATCH] Enable Global Driving Features --- .../tests/test_conditional_runtime_loop.py | 15 +- openpilot/starpilot/aol/policy.py | 1 + openpilot/starpilot/car/hyundai/settings.py | 1 + openpilot/starpilot/car/toyota/aol.py | 4 + .../car/toyota/tests/test_wheel_actions.py | 334 ++++++++++++++++++ .../starpilot/controllers/wheel_actions.py | 17 +- openpilot/starpilot/curve_speed/host.py | 2 +- .../curve_speed/tests/test_status.py | 7 +- openpilot/starpilot/feature_capabilities.py | 16 + openpilot/starpilot/feature_runtime.py | 16 +- openpilot/starpilot/galaxy/settings.py | 2 +- .../starpilot/galaxy/tests/test_settings.py | 48 ++- .../tests/test_profile_runtime.py | 11 +- .../speed_limits/tests/test_maps_runtime.py | 5 + .../starpilot/speed_limits/vision_gate.py | 5 +- .../starpilot/tests/test_feature_runtime.py | 137 +++---- .../tests/test_global_optional_features.py | 226 ++++++++++++ .../starpilot/ui/feature_settings_owner.py | 5 +- .../starpilot/ui/long_profile_feature.py | 2 +- openpilot/starpilot/ui/runtime_app.py | 2 +- openpilot/starpilot/ui/runtime_snapshot.py | 2 +- .../ui/tests/test_conditional_feature.py | 11 +- .../tests/test_feature_settings_navigation.py | 9 +- openpilot/starpilot/ui/wheel_feature.py | 12 +- 24 files changed, 780 insertions(+), 110 deletions(-) create mode 100644 openpilot/starpilot/car/toyota/tests/test_wheel_actions.py create mode 100644 openpilot/starpilot/feature_capabilities.py create mode 100644 openpilot/starpilot/tests/test_global_optional_features.py diff --git a/openpilot/selfdrive/controls/tests/test_conditional_runtime_loop.py b/openpilot/selfdrive/controls/tests/test_conditional_runtime_loop.py index 58713c114d..304064aa0b 100644 --- a/openpilot/selfdrive/controls/tests/test_conditional_runtime_loop.py +++ b/openpilot/selfdrive/controls/tests/test_conditional_runtime_loop.py @@ -34,14 +34,20 @@ class TestConditionalRuntimeLoop(unittest.TestCase): with tempfile.TemporaryDirectory() as directory: params = Params(directory) params.put('CarParams', cp.to_bytes(), block=True) + params.put_bool('SpeedLimitController', False, block=True) + params.put_bool('ShowSpeedLimits', False, block=True) + params.put_bool('CurveSpeedController', False, block=True) for enabled in (False, True): + params.put('ConditionalModeConfig', json.loads(encode_preferences(SavedPreferences( + mode=ModeChoice.CEM if enabled else ModeChoice.STOCK))), block=True) + params.put_bool('SafeMode', not enabled, block=True) params.put_bool('UseStarPilotLongitudinalPlanner', enabled, block=True) with self.subTest(enabled=enabled): sm = Mock() sm.update.side_effect = EndLoop - settings = {'CONDITIONAL_MODE_REPLAY_RUNTIME': '1' if enabled else '0', + settings = {'CONDITIONAL_MODE_REPLAY_RUNTIME': '0', 'SLC_REPLAY_RUNTIME': '0', 'CURVE_REPLAY_RUNTIME': '0', 'LONG_PLANNER_REPLAY_RUNTIME': '0'} - with patch.dict(os.environ, settings), patch.object(plannerd, 'Params', return_value=params), \ + with patch.dict(os.environ, settings, clear=True), patch.object(plannerd, 'Params', return_value=params), \ patch.object(plannerd, 'config_realtime_process'), \ patch.object(plannerd, 'ConditionalPlannerHost', wraps=ConditionalPlannerHost) as conditional_ctor, \ patch.object(messaging, 'PubMaster') as publisher, \ @@ -52,9 +58,10 @@ class TestConditionalRuntimeLoop(unittest.TestCase): call('carState', conflate=False), call('slcAction', conflate=False), call('slcCruiseEvent', conflate=False)]) - # The typed manual-mode owner publishes status even without CEM. + # Status and transport are reserved independently of saved SLC/CEM + # admission, so a later saved change can attach without restarting. self.assertEqual(publisher.call_args.args[0], ['longitudinalPlan', 'starpilotLongitudinalPlan', - 'driverAssistance', 'slcState']) + 'driverAssistance', 'slcState', 'slcCruiseCommand']) self.assertEqual(conditional_ctor.call_count, int(enabled)) if enabled: self.assertIs(conditional_ctor.call_args.args[0], params) diff --git a/openpilot/starpilot/aol/policy.py b/openpilot/starpilot/aol/policy.py index 9428c66207..5206074ee7 100644 --- a/openpilot/starpilot/aol/policy.py +++ b/openpilot/starpilot/aol/policy.py @@ -15,6 +15,7 @@ class AolVehiclePolicy: distance_personality: bool = False fixed_cruise_buttons: bool = False lkas_button_supported: bool = True + auxiliary_lkas_supported: bool = False physical_stalk_owner: bool = False distance_pause_only: bool = False paddle_pause: bool = False diff --git a/openpilot/starpilot/car/hyundai/settings.py b/openpilot/starpilot/car/hyundai/settings.py index ebe1cd633c..221e4f1ce1 100644 --- a/openpilot/starpilot/car/hyundai/settings.py +++ b/openpilot/starpilot/car/hyundai/settings.py @@ -12,6 +12,7 @@ class WheelSettingsPolicy: paddle_pause: bool = True runtime_supported: bool = False lkas_button_supported: bool = False + auxiliary_lkas_supported: bool = False def supports_ioniq6_configuration(cp) -> bool: diff --git a/openpilot/starpilot/car/toyota/aol.py b/openpilot/starpilot/car/toyota/aol.py index 7313a11de6..d711b5019a 100644 --- a/openpilot/starpilot/car/toyota/aol.py +++ b/openpilot/starpilot/car/toyota/aol.py @@ -52,10 +52,14 @@ def qualified(cp, *, marked_only=False): def policy_for(cp): + from openpilot.starpilot.controllers.wheel_actions import button_reported, eligible + admitted = qualified(cp) + auxiliary_lkas = bool(eligible(cp) and cp.brand == 'toyota' and button_reported(cp, 'LKASButtonControl')) return AolVehiclePolicy(intent_supported=admitted, settings_supported=admitted, runtime_supported=admitted, normal_runtime_supported=admitted, ordinary_axis_ack_required=qualified(cp, marked_only=True), full_axis_runtime_required=admitted, fixed_cruise_buttons=admitted, distance_pause_only=admitted, + auxiliary_lkas_supported=auxiliary_lkas, alternative_experience_addition=32 if admitted else 0) diff --git a/openpilot/starpilot/car/toyota/tests/test_wheel_actions.py b/openpilot/starpilot/car/toyota/tests/test_wheel_actions.py new file mode 100644 index 0000000000..88c63ae7ed --- /dev/null +++ b/openpilot/starpilot/car/toyota/tests/test_wheel_actions.py @@ -0,0 +1,334 @@ +"""Factory Toyota CAN evidence reaches existing auxiliary action owners.""" +import json +from types import SimpleNamespace as NS + +import pytest + +from opendbc.can import CANPacker +from opendbc.car import Bus, structs +from opendbc.car.toyota.carstate import CarState +from opendbc.car.toyota.interface import CarInterface +from opendbc.car.toyota.values import CAR, DBC +from openpilot.cereal import messaging +from openpilot.common.params import Params +from openpilot.starpilot.aol.intent import AolSettings +from openpilot.starpilot.aol.vehicle import policy_for +from openpilot.starpilot.car.toyota.aol import ToyotaCardIntent +from openpilot.starpilot.conditional_mode.card_input import conditional_manual_candidate +from openpilot.starpilot.conditional_mode.manual import ButtonTracker +from openpilot.starpilot.conditional_mode.policy import ModeChoice +from openpilot.starpilot.conditional_mode.preferences import SavedPreferences, encode_preferences +from openpilot.starpilot.conditional_mode.runtime_settings import ConditionalSettingsOwner +from openpilot.starpilot.controllers.coast import CoastRuntime +from openpilot.starpilot.controllers.tests import test_native_wheel_callbacks as native_callbacks +from openpilot.starpilot.controllers.tests.test_wheel_actions import Publisher, services, state, candidate, DRIVE, NOW +from openpilot.starpilot.controllers.wheel_actions import WheelConsumer, WheelPublisher +from openpilot.starpilot.ui.feature_settings_state import FeatureSettingsRequest +from openpilot.starpilot.ui.wheel_feature import WheelFeature + +native_session = native_callbacks.native_session + +KEY = 'LKASButtonControl' +TYPE = structs.CarState.ButtonEvent.Type + + +def factory(candidate=CAR.TOYOTA_RAV4_TSS2): + return CarInterface.get_params(candidate, {0: {}, 1: {}, 2: {}}, [], False, False, False) + + +class ToyotaButtons: + def __init__(self, cp): + self.state = CarState(cp) + self.parsers = CarState.get_can_parsers(cp) + self.packer = CANPacker(DBC[cp.carFingerprint][Bus.pt]) + self.tick = 0 + + def sample(self, lda=0): + self.tick += 1 + frame = self.packer.make_can_msg('LKAS_HUD', 2, {'LDA_ON_MESSAGE': lda}) + self.parsers[Bus.cam].update([(self.tick * 10_000_000, [frame])]) + result = self.state.update(self.parsers) + # Isolated edge bench: transport health is a synthetic premise here. + # The full CarInterface tests below verify real CAN validity without overrides. + result.canValid, result.canTimeout = True, False + return result + + +def emit(params, cp, code): + params.put(KEY, code, block=True) + source, parser = WheelPublisher(params), ToyotaButtons(cp) + source.observe(params, cp, parser.sample(), now_ns=NOW - 10_000_000, drive_id=DRIVE) + physical = parser.sample(1) + assert [(e.type, e.pressed) for e in physical.buttonEvents] == [(TYPE.lkas, True), (TYPE.lkas, False)] + commands = source.observe(params, cp, physical, now_ns=NOW, drive_id=DRIVE) + assert commands == ((KEY, code),) + assert not any(action for _, action in source.observe(params, cp, parser.sample(1), now_ns=NOW + 1, drive_id=DRIVE)) + sender = Publisher() + source.publish(commands, cp, sender, now_ns=NOW, drive_id=DRIVE, source_car_ns=NOW, source_control_ns=NOW) + return messaging.log_from_bytes(sender.events[-1][1]) + + +def feature(params, cp): + return WheelFeature(NS(params=params, vehicle_params=lambda: cp, configuration_vehicle=lambda: False, + configuration_longitudinal=lambda: cp.openpilotLongitudinalControl, + longitudinal_available=lambda: cp.openpilotLongitudinalControl, authority=lambda _: True)) + + +def test_factory_settings_allow_auxiliary_lkas_hide_unreported_cancel_and_keep_ccmain_fixed(tmp_path): + params, cp = Params(str(tmp_path)), factory() + policy = policy_for(cp) + assert policy.fixed_cruise_buttons and policy.auxiliary_lkas_supported + wheel = feature(params, cp) + rows = {row.key: row for row in wheel.rows()} + row = rows['wheel:' + KEY] + assert {'Off', 'Bookmark', 'Traffic mode', 'Cycle personality', 'Experimental override'} <= set(row.choices) + assert not {'Pause steering', 'Pause longitudinal', 'Toggle AOL'} & set(row.choices) + assert not any('Cancel' in key or 'MainCruise' in key for key in rows) + assert wheel.apply(FeatureSettingsRequest(row.key, row.source, 'Bookmark', capability=row.capability)) + assert params.get(KEY) == 8 + assert not wheel.apply(FeatureSettingsRequest(row.key, b'8', 'Toggle AOL', capability=row.capability)) + assert not wheel.apply(FeatureSettingsRequest('wheel:CancelButtonControl', None, 'Bookmark', capability=row.capability)) + + +@pytest.mark.parametrize('code', (1, 5, 6, 8)) +def test_factory_can_parser_to_publisher_to_consumer(tmp_path, code): + params, cp = Params(str(tmp_path)), factory() + event = emit(params, cp, code) + consumer = WheelConsumer() + command = consumer.accept(event, params, cp, services(cp), now_ns=NOW) + assert (command.key, command.action) == (KEY, code) + assert consumer.accept(event, params, cp, services(cp), now_ns=NOW) is None + + +@pytest.mark.parametrize('code', (3, 4, 9)) +def test_axis_assignments_cannot_take_native_lkas_or_ccmain_ownership(tmp_path, code): + params, cp = Params(str(tmp_path)), factory() + params.put(KEY, code, block=True) + params.put('MainCruiseButtonControl', code, block=True) + native = ToyotaCardIntent(AolSettings(True, 0., code, code, (0, 0, 0), (0, 0, 0))) + assert native.settings.lkas_action == native.settings.main_action == 0 + source, parser = WheelPublisher(params), ToyotaButtons(cp) + source.observe(params, cp, parser.sample(), now_ns=NOW - 10_000_000, drive_id=DRIVE) + assert not any(action for _, action in source.observe(params, cp, parser.sample(1), now_ns=NOW, drive_id=DRIVE)) + + +@pytest.mark.parametrize('key', ('CancelButtonControl', 'LongCancelButtonControl', 'VeryLongCancelButtonControl')) +def test_unreported_toyota_button_receipts_are_rejected(tmp_path, key): + params, cp = Params(str(tmp_path)), factory() + code = 8 + params.put(key, code, block=True) + sender, source = Publisher(), WheelPublisher(params) + source.publish(((key, code),), cp, sender, now_ns=NOW, drive_id=DRIVE, source_car_ns=NOW, source_control_ns=NOW) + event = messaging.log_from_bytes(sender.events[-1][1]) + assert WheelConsumer().accept(event, params, cp, services(cp), now_ns=NOW) is None + source.observe(params, cp, state(), now_ns=NOW - 20_000_000, drive_id=DRIVE) + button = TYPE.cancel + source.observe(params, cp, state(True, button=button), now_ns=NOW - 10_000_000, drive_id=DRIVE) + assert not any(action for _, action in source.observe(params, cp, state(False, button=button), now_ns=NOW, drive_id=DRIVE)) + + +def test_non_toyota_cancel_contract_preserved(tmp_path): + params = Params(str(tmp_path)) + _, cp = candidate(False) + assert 'Bookmark' in feature(params, cp).choices('CancelButtonControl') + params.put('CancelButtonControl', 8, block=True) + source = WheelPublisher(params) + source.observe(params, cp, state(), now_ns=NOW - 20_000_000, drive_id=DRIVE) + source.observe(params, cp, state(True, button=TYPE.cancel), now_ns=NOW - 10_000_000, drive_id=DRIVE) + assert source.observe(params, cp, state(False, button=TYPE.cancel), now_ns=NOW, drive_id=DRIVE) == (('CancelButtonControl', 8),) + + +@pytest.mark.parametrize('code', (5, 8)) +def test_factory_lkas_reaches_actual_native_action_callback(native_session, monkeypatch, code): + runtime, session, ui, _, _ = native_session + ui.CP = factory() + ui.has_longitudinal_control = ui.CP.openpilotLongitudinalControl + calls = [] + session._native_favorite_actions = lambda: session.native_favorite_actions( + lambda: calls.append('bookmark') or True, lambda _: True, lambda: calls.append('experimental') or True) + queue = [emit(ui.params, ui.CP, code)] + monkeypatch.setattr(runtime.messaging, 'recv_one_or_none', lambda _: queue.pop(0) if queue else None) + session._poll_wheel(NOW) + session._poll_wheel(NOW) + assert calls == ['bookmark' if code == 8 else 'experimental'] + + +def test_factory_lkas_traffic_reaches_existing_controller_owner(native_session): + _, _, ui, _, _ = native_session + ui.CP = factory() + event = emit(ui.params, ui.CP, 6) + runtime = CoastRuntime() + assert runtime.receive(event, ui.params, ui.CP, ui.sm, now_ns=NOW) + assert runtime.traffic_toggles == 1 + assert not runtime.receive(event, ui.params, ui.CP, ui.sm, now_ns=NOW) + assert runtime.traffic_toggles == 1 + + +def test_factory_lkas_cem_assignment_uses_existing_card_arbitration(tmp_path): + params, cp = Params(str(tmp_path)), factory() + params.put(KEY, 5, block=True) + params.put('ConditionalModeConfig', json.loads(encode_preferences(SavedPreferences(mode=ModeChoice.CEM))), block=True) + settings, tracker, parser = ConditionalSettingsOwner(params), ButtonTracker(), ToyotaButtons(cp) + sm = services(cp) + conditional_manual_candidate(parser.sample(), tracker, params, settings, cp, sm, now_ns=NOW - 10_000_000) + physical = parser.sample(1) + receipt = conditional_manual_candidate(physical, tracker, params, settings, cp, sm, now_ns=NOW) + assert receipt is not None and receipt[3] == ModeChoice.CEM + source = WheelPublisher(params) + source.observe(params, cp, state(), now_ns=NOW - 10_000_000, drive_id=DRIVE) + assert not any(action for _, action in source.observe(params, cp, physical, now_ns=NOW, + drive_id=DRIVE, blocked_keys={KEY})) + + +def test_factory_lkas_personality_reaches_native_saved_personality_callback(native_session, monkeypatch): + runtime, session, ui, _, _ = native_session + ui.CP = factory() + ui.has_longitudinal_control = ui.CP.openpilotLongitudinalControl + ui.params.put('LongitudinalPersonality', 1, block=True) + def save(value): + ui.params.put('LongitudinalPersonality', value, block=True) + return True + session._native_favorite_actions = lambda: session.native_favorite_actions(lambda: True, save, lambda: True) + queue = [emit(ui.params, ui.CP, 1)] + monkeypatch.setattr(runtime.messaging, 'recv_one_or_none', lambda _: queue.pop(0) if queue else None) + session._poll_wheel(NOW) + assert ui.params.get('LongitudinalPersonality') == 0 + session._poll_wheel(NOW) + assert ui.params.get('LongitudinalPersonality') == 0 + + +@pytest.mark.parametrize('case', ('invalid_can', 'stale_source', 'remapped', 'wrong_vehicle')) +def test_factory_lkas_consumer_rechecks_current_source_and_assignment(tmp_path, case): + params, cp = Params(str(tmp_path)), factory() + event = emit(params, cp, 8) + sm = services(cp) + if case == 'invalid_can': + sm['carState'].canValid = False + elif case == 'stale_source': + sm.recv_time['carState'] = (NOW - 300_000_000) / 1e9 + elif case == 'remapped': + params.put(KEY, 5, block=True) + else: + cp = CarInterface.get_params(CAR.TOYOTA_COROLLA_TSS2, {0: {}, 1: {}, 2: {}}, [], False, False, False) + assert WheelConsumer().accept(event, params, cp, sm, now_ns=NOW) is None + + +class FactoryToyotaInterface: + """Real CarInterface transport validity with complete factory parser messages.""" + def __init__(self, cp, *, complete): + self.interface = CarInterface(cp) + self.interface.update([]) + self.packer = CANPacker(DBC[cp.carFingerprint][Bus.pt]) + self.tracked = {bus: tuple(parser.vl) for bus, parser in self.interface.can_parsers.items()} + self.complete = complete + + def sample(self, now_ns, lda=0): + values = {'PCM_CRUISE': {'GAS_RELEASED': 1}, 'PCM_CRUISE_2': {'MAIN_ON': 1}, + 'GEAR_PACKET': {'GEAR': 0}, 'EPS_STATUS': {'LKA_STATE': 1}, + 'BODY_CONTROL_STATE': {}, 'LKAS_HUD': {'LDA_ON_MESSAGE': lda}} + frames = [self.packer.make_can_msg(name, 0 if bus == Bus.pt else 2, values.get(name, {})) + for bus, names in self.tracked.items() for name in names + if self.complete or bus == Bus.cam and name == 'LKAS_HUD'] + return self.interface.update([(now_ns, frames)]) + + +@pytest.mark.parametrize('complete', (True, False)) +def test_actual_factory_interface_can_validity_gates_lkas_delivery(tmp_path, complete): + params, cp = Params(str(tmp_path)), factory() + params.put(KEY, 8, block=True) + bench = FactoryToyotaInterface(cp, complete=complete) + for tick in range(8): + physical = bench.sample(NOW - 90_000_000 + tick * 10_000_000) + assert physical.canValid == complete + if complete: + assert not physical.canTimeout + assert physical.cruiseState.available + assert physical.gearShifter == structs.CarState.GearShifter.drive + assert not (physical.doorOpen or physical.seatbeltUnlatched or physical.steerFaultPermanent) + source = WheelPublisher(params) + neutral = bench.sample(NOW - 10_000_000) + source.observe(params, cp, neutral, now_ns=NOW - 10_000_000, drive_id=DRIVE) + physical = bench.sample(NOW, lda=1) + assert physical.canValid == complete # Never override parser or interface health. + assert [(e.type, e.pressed) for e in physical.buttonEvents] == [(TYPE.lkas, True), (TYPE.lkas, False)] + commands = source.observe(params, cp, physical, now_ns=NOW, drive_id=DRIVE) + assert commands == (((KEY, 8),) if complete else ()) + sender = Publisher() + # For missing CAN, even a forged publication cannot pass current source health. + source.publish(commands if complete else ((KEY, 8),), cp, sender, now_ns=NOW, drive_id=DRIVE, + source_car_ns=NOW, source_control_ns=NOW) + sm = services(cp) + sm['carState'] = physical + accepted = WheelConsumer().accept(messaging.log_from_bytes(sender.events[-1][1]), params, cp, sm, now_ns=NOW) + if complete: + assert (accepted.key, accepted.action) == (KEY, 8) + else: + assert accepted is None + + +@pytest.mark.parametrize('vehicle', (CAR.TOYOTA_SIENNA_4TH_GEN, CAR.TOYOTA_RAV4_TSS2_2023)) +def test_parser_supported_toyota_auxiliary_lkas_does_not_require_aol_admission(tmp_path, vehicle): + params, cp = Params(str(tmp_path)), factory(vehicle) + baseline = cp.to_dict() + policy = policy_for(cp) + assert not (policy.intent_supported or policy.settings_supported or policy.runtime_supported or + policy.normal_runtime_supported or policy.full_axis_runtime_required) + assert policy.alternative_experience_addition == policy.safety_param_addition == 0 + assert policy.auxiliary_lkas_supported + wheel = feature(params, cp) + rows = {row.key: row for row in wheel.rows()} + row = rows['wheel:' + KEY] + assert 'Bookmark' in row.choices + assert not {'Pause steering', 'Pause longitudinal', 'Toggle AOL'} & set(row.choices) + assert not any('MainCruise' in key or 'Cancel' in key for key in rows) + assert wheel.apply(FeatureSettingsRequest(row.key, row.source, 'Bookmark', capability=row.capability)) + assert not wheel.apply(FeatureSettingsRequest(row.key, b'8', 'Toggle AOL', capability=row.capability)) + # This bench uses full factory parser transport, without forcing CAN validity. + bench = FactoryToyotaInterface(cp, complete=True) + for tick in range(8): + physical = bench.sample(NOW - 90_000_000 + tick * 10_000_000) + assert physical.canValid and not physical.canTimeout + source = WheelPublisher(params) + source.observe(params, cp, bench.sample(NOW - 10_000_000), now_ns=NOW - 10_000_000, drive_id=DRIVE) + physical = bench.sample(NOW, lda=1) + assert physical.canValid and not physical.canTimeout + assert [(e.type, e.pressed) for e in physical.buttonEvents] == [(TYPE.lkas, True), (TYPE.lkas, False)] + commands = source.observe(params, cp, physical, now_ns=NOW, drive_id=DRIVE) + assert commands == ((KEY, 8),) + sender = Publisher() + source.publish(commands, cp, sender, now_ns=NOW, drive_id=DRIVE, source_car_ns=NOW, source_control_ns=NOW) + sm = services(cp) + sm['carState'] = physical + accepted = WheelConsumer().accept(messaging.log_from_bytes(sender.events[-1][1]), params, cp, sm, now_ns=NOW) + assert (accepted.key, accepted.action) == (KEY, 8) + assert cp.to_dict() == baseline # Auxiliary settings grant no CP/native authority. + + +@pytest.mark.parametrize('vehicle', (CAR.TOYOTA_SIENNA_4TH_GEN, CAR.TOYOTA_RAV4_TSS2_2023)) +def test_aol_unsupported_toyota_bookmark_reaches_real_native_callback(native_session, monkeypatch, vehicle): + runtime, session, ui, _, _ = native_session + ui.CP = factory(vehicle) + ui.has_longitudinal_control = ui.CP.openpilotLongitudinalControl + assert not policy_for(ui.CP).intent_supported + calls = [] + session._native_favorite_actions = lambda: session.native_favorite_actions( + lambda: calls.append('bookmark') or True, lambda _: True, lambda: True) + queue = [emit(ui.params, ui.CP, 8)] + monkeypatch.setattr(runtime.messaging, 'recv_one_or_none', lambda _: queue.pop(0) if queue else None) + session._poll_wheel(NOW) + session._poll_wheel(NOW) + assert calls == ['bookmark'] + + +@pytest.mark.parametrize('field', ('notCar', 'passive', 'dashcamOnly')) +def test_toyota_auxiliary_lkas_keeps_existing_eligibility_limits(tmp_path, field): + cp = factory(CAR.TOYOTA_SIENNA_4TH_GEN) + setattr(cp, field, True) + assert not policy_for(cp).auxiliary_lkas_supported + assert feature(Params(str(tmp_path)), cp).rows() == [] + + +def test_toyota_without_parser_lkas_source_has_no_auxiliary_row(tmp_path): + cp = factory(CAR.TOYOTA_SIENNA) + assert not policy_for(cp).auxiliary_lkas_supported + assert feature(Params(str(tmp_path)), cp).choices(KEY) == () diff --git a/openpilot/starpilot/controllers/wheel_actions.py b/openpilot/starpilot/controllers/wheel_actions.py index 149bee4613..8c3e90f12b 100644 --- a/openpilot/starpilot/controllers/wheel_actions.py +++ b/openpilot/starpilot/controllers/wheel_actions.py @@ -35,6 +35,19 @@ def eligible(cp): return False +def button_reported(cp, key): + """Match known parser omissions without changing other ports' button contracts.""" + if cp.brand == 'toyota': + # Toyota CarState emits LKAS and distance events, never Cancel. + if key in KEYS[5:8]: + return False + if key == 'LKASButtonControl': + from opendbc.car.toyota.values import CAR, ToyotaFlags + return bool(cp.flags & (ToyotaFlags.TSS2 | ToyotaFlags.LONG_FILTER) or + cp.carFingerprint == CAR.TOYOTA_PRIUS_RETROFIT) + return True + + def cp_fingerprint(cp): reader = cp.as_reader() if hasattr(cp, "as_reader") else cp return hashlib.sha256(reader.as_builder().to_bytes()).hexdigest() @@ -154,7 +167,7 @@ class WheelPublisher: if index < len(keys): gestures.append((keys[index], gesture)) commands = [(key, mapping[key]) for key, _ in gestures if key is not None and key not in blocked_keys and - key not in self.axis_keys and mapping[key] in DELIVERED_ACTIONS and + key not in self.axis_keys and button_reported(cp, key) and mapping[key] in DELIVERED_ACTIONS and (key != "MainCruiseButtonControl" or mapping[key] == 10)] if commands and not audit: current = capture(params) @@ -231,7 +244,7 @@ class WheelConsumer: if action == 0: if key: return None - elif key not in KEYS or action not in DELIVERED_ACTIONS: + elif key not in KEYS or action not in DELIVERED_ACTIONS or not button_reported(cp, key): return None else: mapping = capture(params) diff --git a/openpilot/starpilot/curve_speed/host.py b/openpilot/starpilot/curve_speed/host.py index dbce5faf81..c736c1dde9 100644 --- a/openpilot/starpilot/curve_speed/host.py +++ b/openpilot/starpilot/curve_speed/host.py @@ -46,7 +46,7 @@ def frame_from_submaster(sm, CP, now_ns: int, *, drive_id: int = 0) -> Frame: car_state, car_control = sm['carState'], sm['carControl'] controls, selfdrive = sm['controlsState'], sm['selfdriveState'] cruise_mps = float(car_state.vCruise) / 3.6 - system_longitudinal = bool(CP.openpilotLongitudinalControl) and not bool(CP.pcmCruise) + system_longitudinal = bool(CP.openpilotLongitudinalControl) and not any(getattr(CP, flag, False) for flag in ('passive', 'dashcamOnly', 'notCar')) return Frame(now_ns=now_ns, model_ns=model_ns, inputs_fresh=current and bool(car_state.canValid) and not bool(car_state.canTimeout), profile=profile, system_longitudinal=system_longitudinal, long_active=bool(car_control.longActive), enabled=bool(selfdrive.enabled), force_decel=bool(controls.forceDecel), cruise_mps=cruise_mps, diff --git a/openpilot/starpilot/curve_speed/tests/test_status.py b/openpilot/starpilot/curve_speed/tests/test_status.py index 4e5d945df7..9f3faa1e25 100644 --- a/openpilot/starpilot/curve_speed/tests/test_status.py +++ b/openpilot/starpilot/curve_speed/tests/test_status.py @@ -107,7 +107,10 @@ class TestCurveStatus(unittest.TestCase): with self.subTest(slc=slc_on, curve=curve_on), tempfile.TemporaryDirectory() as directory, ExitStack() as stack: params = Params(directory) params.put('CarParams', cp_bytes, block=True) - params.put_bool(MASTER_KEY, True, block=True) + # Saved requests, not a replay flag set to zero, choose the hosts. + params.put_bool(MASTER_KEY, curve_on, block=True) + params.put_bool('SpeedLimitController', slc_on, block=True) + params.put_bool('ShowSpeedLimits', slc_on, block=True) sm = bus(NOW) for name in ('deviceState', 'slcDashboardObservation'): sm.messages[name] = getattr(messaging.new_message(name), name) @@ -161,7 +164,7 @@ class TestCurveStatus(unittest.TestCase): plannerd.main() counts = Counter(name for name, _raw in sent) self.assertEqual(counts['longitudinalPlan'], 2) - self.assertEqual(counts['slcState'], 2) # Display-only signs still publish with both controllers off. + self.assertEqual(counts['slcState'], 2) # Shared optional status still publishes with both controllers off. self.assertEqual(sum(name == 'slcCruiseEvent' for name, _kw in subscriptions), 1) self.assertTrue(all(not kw['conflate'] for _name, kw in subscriptions)) for name, raw in sent: diff --git a/openpilot/starpilot/feature_capabilities.py b/openpilot/starpilot/feature_capabilities.py new file mode 100644 index 0000000000..4e744020c2 --- /dev/null +++ b/openpilot/starpilot/feature_capabilities.py @@ -0,0 +1,16 @@ +"""Generic optional-feature admission from finalized native CarParams. + +Admission creates a host; the host still checks live axis and source authority. +PCM cruise describes engagement/set-speed integration, not who controls acceleration. +""" + + +def display_supported(cp) -> bool: + return bool(cp is not None and getattr(cp, 'carFingerprint', '') and + getattr(cp, 'passive', None) is False and + getattr(cp, 'dashcamOnly', None) is False and + getattr(cp, 'notCar', None) is False) + + +def longitudinal_supported(cp) -> bool: + return display_supported(cp) and getattr(cp, 'openpilotLongitudinalControl', None) is True diff --git a/openpilot/starpilot/feature_runtime.py b/openpilot/starpilot/feature_runtime.py index 2fbf709042..c71312fd6f 100644 --- a/openpilot/starpilot/feature_runtime.py +++ b/openpilot/starpilot/feature_runtime.py @@ -7,12 +7,12 @@ each owner still verifies current vehicle, source, clock, and axis authority. from collections.abc import Mapping from dataclasses import replace -from opendbc.car.gm.feature_capabilities import longitudinal_supported as gm_long_supported, display_supported as gm_display_supported +from opendbc.car.gm.feature_capabilities import longitudinal_supported as gm_long_supported from openpilot.starpilot.conditional_mode.preferences import PreferenceError, decode_preferences from openpilot.starpilot.aol.intent import independent_axis_requested, read_settings as read_aol_settings from openpilot.starpilot.aol.vehicle import policy_for as aol_policy_for -from openpilot.starpilot.longitudinal.ioniq6_start import eligible as ioniq6_long_eligible +from openpilot.starpilot.feature_capabilities import display_supported, longitudinal_supported from openpilot.starpilot.longitudinal.profile_runtime import read_settings as read_profile_settings, selected_profiles_requested, read_global_powertrain_preset from openpilot.starpilot.longitudinal.profile_preferences import read_document_value from openpilot.starpilot.saved_source import read_saved @@ -56,7 +56,7 @@ def requested(params, feature: str, *, include_auxiliary: bool = False, saved_sl if feature in ('slc', 'vision'): settings = read_slc_settings(params) if saved_slc is None else saved_slc return (settings.enabled or settings.display) if feature == 'slc' else ( - settings.display and Source.VISION in settings.selection.slots) + (settings.enabled or settings.display) and Source.VISION in settings.selection.slots) raise ValueError('unknown optional feature') @@ -78,9 +78,7 @@ def enabled(params, cp, feature: str, environment: Mapping[str, str], *, saved_s return True if feature != 'vision' and environment.get(flags[feature]) == '1': return True - capable = cp is not None and (ioniq6_long_eligible(cp) or - (feature in ('conditional', 'curve', 'slc', 'profile') and gm_long_supported(cp)) or - (feature in ('slc', 'vision') and gm_display_supported(cp))) + capable = display_supported(cp) if feature in ('slc', 'vision') else longitudinal_supported(cp) powertrain_requested = (feature == 'profile' and cp is not None and gm_long_supported(cp) and read_document_value(params).valid and read_global_powertrain_preset(params, cp) is not None) return bool(capable and (requested(params, feature, saved_slc=saved_slc) or powertrain_requested)) @@ -89,13 +87,13 @@ def enabled(params, cp, feature: str, environment: Mapping[str, str], *, saved_s def slc_transport_capable(cp, environment: Mapping[str, str]) -> bool: """Reserve optional transport without admitting a saved request or control.""" return bool(environment.get('SLC_REPLAY_RUNTIME') == '1' or - (cp is not None and (ioniq6_long_eligible(cp) or gm_long_supported(cp) or gm_display_supported(cp)))) + display_supported(cp)) def slc_runtime_settings(params, cp, environment: Mapping[str, str], *, saved_slc: SlcSettings | None = None): """Stock display startup cannot inherit a saved speed-control request.""" settings = read_slc_settings(params) if saved_slc is None else saved_slc - if environment.get('SLC_REPLAY_RUNTIME') != '1' and gm_display_supported(cp) and not gm_long_supported(cp): + if environment.get('SLC_REPLAY_RUNTIME') != '1' and not longitudinal_supported(cp): return replace(settings, enabled=False, display=settings.display or settings.enabled, acceptance=replace(settings.acceptance, display_only=True)) return settings @@ -103,5 +101,5 @@ def slc_runtime_settings(params, cp, environment: Mapping[str, str], *, saved_sl def vision_control_enabled(params, cp, *, saved_slc: SlcSettings | None = None) -> bool: """Source selection never turns stock-cruise or display-only sessions into control.""" settings = read_slc_settings(params) if saved_slc is None else saved_slc - return bool(cp is not None and (ioniq6_long_eligible(cp) or gm_long_supported(cp)) and + return bool(longitudinal_supported(cp) and settings.enabled and Source.VISION in settings.selection.slots) diff --git a/openpilot/starpilot/galaxy/settings.py b/openpilot/starpilot/galaxy/settings.py index 3f562a8335..bca2a74e5d 100644 --- a/openpilot/starpilot/galaxy/settings.py +++ b/openpilot/starpilot/galaxy/settings.py @@ -105,7 +105,7 @@ def _qualified(ctx: AuthorityContext, group: str) -> bool: return bool((cp.openpilotLongitudinalControl or ctx.configuration_longitudinal) and not cp.notCar and not cp.dashcamOnly and not cp.passive) if group == "long": - return bool(((cp.openpilotLongitudinalControl and not cp.pcmCruise) or ctx.configuration_longitudinal) and + return bool((cp.openpilotLongitudinalControl or ctx.configuration_longitudinal) and not cp.notCar and not cp.dashcamOnly and not cp.passive) if group in ("aol", "aol_wheel") and ctx.configuration_vehicle and ctx.parked: from openpilot.starpilot.aol.vehicle import configuration_settings_policy diff --git a/openpilot/starpilot/galaxy/tests/test_settings.py b/openpilot/starpilot/galaxy/tests/test_settings.py index 72b3fcc7ec..673f699da7 100644 --- a/openpilot/starpilot/galaxy/tests/test_settings.py +++ b/openpilot/starpilot/galaxy/tests/test_settings.py @@ -432,7 +432,7 @@ class SettingsGatewayTest(unittest.TestCase): vehicle = HondaInterface.get_params(HondaCAR.HONDA_CIVIC_BOSCH, gen_empty_fingerprint(), [], True, False, False) self.assertTrue(vehicle.openpilotLongitudinalControl and not vehicle.pcmCruise) - self.assertFalse(diagnostic_choice_enabled({}, vehicle)) + self.assertTrue(diagnostic_choice_enabled({}, vehicle)) self.context.value = AuthorityContext(True, vehicle, vehicle.to_bytes()) self.params.put('SLCPriority1', 'Dashboard', block=True) self.params.put('SLCPriority2', 'Dashboard', block=True) @@ -444,22 +444,56 @@ class SettingsGatewayTest(unittest.TestCase): rows = self.page("slc")["rows"] primary = next(row for row in rows if row["label"] == "Primary source") secondary = next(row for row in rows if row["label"] == "Secondary source") - self.assertEqual("Vision" in primary["choices"], available) - self.assertEqual("Vision" in secondary["choices"], available) + self.assertIn("Vision", primary["choices"]) + self.assertIn("Vision", secondary["choices"]) self.assertNotIn("require your confirmation", primary["reason"]) confirmation = next(row for row in rows if row["label"] == "Require confirmation") self.assertEqual(confirmation["value"], "Off") self.assertIn("first speed limit", confirmation["reason"]) self.assertIn("any source", confirmation["reason"]) - with mock.patch.dict(os.environ, {"SLC_REPLAY_RUNTIME": "1", "SLC_VISION_DEVELOPMENT": "1"}): + with mock.patch.dict(os.environ, {"SLC_REPLAY_RUNTIME": "0", "SLC_VISION_DEVELOPMENT": "0"}): + # A normal finalized car offers Vision without development/replay flags. page = self.page("slc") index = next(i for i, row in enumerate(page["rows"]) if row["label"] == "Primary source") intent = self.gateway.preview(page["view"], index, 1, self.session, self.generation) self.assertEqual(intent["proposed"], "Vision") - with mock.patch.dict(os.environ, {"SLC_VISION_DEVELOPMENT": "0"}): - self.assertFalse(self.gateway.confirm(intent["intent"], self.session, self.generation)) - self.assertEqual(read_saved(self.params, "SLCPriority1", 128)[0], b"Dashboard") + self.assertTrue(self.gateway.confirm(intent["intent"], self.session, self.generation)) + self.assertEqual(read_saved(self.params, "SLCPriority1", 128)[0], b"Vision") + self.params.put('SLCPriority1', 'Dashboard', block=True) + original_context = self.context.value + for withdrawal in ('passive', 'unavailable'): + with self.subTest(withdrawal=withdrawal): + self.context.value = original_context + page = self.page("slc") + index = next(i for i, row in enumerate(page["rows"]) if row["label"] == "Primary source") + intent = self.gateway.preview(page["view"], index, 1, self.session, self.generation) + self.assertEqual(intent["proposed"], "Vision") + if withdrawal == 'passive': + withdrawn = vehicle.as_reader().as_builder() + withdrawn.passive = True + self.context.value = AuthorityContext(True, withdrawn, withdrawn.to_bytes()) + else: + self.context.value = AuthorityContext(True, None, None) + with self.assertRaises(SettingsChanged): + self.gateway.confirm(intent["intent"], self.session, self.generation) + self.assertEqual(read_saved(self.params, "SLCPriority1", 128)[0], b"Dashboard") + + # Source choice is display access on a native stock-ACC car, never a + # grant of longitudinal control from a remembered controller switch. + from openpilot.starpilot.feature_runtime import slc_runtime_settings, vision_control_enabled + stock = HondaInterface.get_params(HondaCAR.HONDA_CIVIC_BOSCH, gen_empty_fingerprint(), [], False, False, False) + self.assertFalse(stock.openpilotLongitudinalControl) + self.context.value = AuthorityContext(True, stock, stock.to_bytes()) + self.params.put_bool('SpeedLimitController', True, block=True) + page = self.page("slc") + index = next(i for i, row in enumerate(page["rows"]) if row["label"] == "Primary source") + intent = self.gateway.preview(page["view"], index, 1, self.session, self.generation) + self.assertEqual(intent["proposed"], "Vision") + self.assertTrue(self.gateway.confirm(intent["intent"], self.session, self.generation)) + settings = slc_runtime_settings(self.params, stock, {}) + self.assertTrue(settings.display and settings.acceptance.display_only) + self.assertFalse(settings.enabled or vision_control_enabled(self.params, stock)) def test_pip_saved_choices_no_vehicle_cp_and_one_use_confirmation(self): from openpilot.starpilot.ui.pip_preferences import BLINKER, MASK, read_pip diff --git a/openpilot/starpilot/longitudinal/tests/test_profile_runtime.py b/openpilot/starpilot/longitudinal/tests/test_profile_runtime.py index 84a1dee9ee..946ec21ef1 100644 --- a/openpilot/starpilot/longitudinal/tests/test_profile_runtime.py +++ b/openpilot/starpilot/longitudinal/tests/test_profile_runtime.py @@ -488,19 +488,22 @@ class ProfileRuntimeTests(unittest.TestCase): tuple(message.longitudinalPlan.speeds))) self.params.put('CarParams', self.cp.to_bytes(), block=True) - self.params.put_bool('CustomPersonalities', True, block=True) self.params.put('StandardFollow', 2.5, block=True) self.params.put('StandardFollowHigh', 2.5, block=True) results = [] for enabled in (False, True): + self.params.put_bool('CustomPersonalities', enabled, block=True) + # Explicit native response removes the separate default Comfort request; + # this comparison isolates the saved following/jerk master switch. + self.params.put('LongitudinalPersonalityProfiles', current_profile_document( + default_personality_profiles(False), enabled=enabled, + selected_deceleration_profile='dom_default'), block=True) data, _ = messages(lead=True) data['carState'].canValid = True data['carControl'].longActive = True sm = LoopSM(data) publisher = Publisher() - environment = {} - if enabled: - environment['LONG_PLANNER_REPLAY_RUNTIME'] = '1' + environment = {} # Normal admission follows the actual saved request. with (mock.patch.object(plannerd, 'Params', return_value=self.params), mock.patch.object(plannerd, 'config_realtime_process'), mock.patch.object(plannerd, 'LeadApproachPreferences', return_value=None), diff --git a/openpilot/starpilot/speed_limits/tests/test_maps_runtime.py b/openpilot/starpilot/speed_limits/tests/test_maps_runtime.py index d147279d7d..f0942599d8 100644 --- a/openpilot/starpilot/speed_limits/tests/test_maps_runtime.py +++ b/openpilot/starpilot/speed_limits/tests/test_maps_runtime.py @@ -227,7 +227,12 @@ def test_cached_provider_verification_invalidates_on_replacement_and_source_addi os.utime(binary, ns=(prior.st_atime_ns, prior.st_mtime_ns + 1_000_000_000)) assert binary.stat().st_mtime_ns != prior.st_mtime_ns assert ready() and len(calls) == 3 + prior_source = source.stat() (source / 'new.go').write_bytes(b'package main') + # Directory additions can share a metadata timestamp tick too. The cache + # watches directory identity before enumerating newly added source files. + os.utime(source, ns=(prior_source.st_atime_ns, prior_source.st_mtime_ns + 1_000_000_000)) + assert source.stat().st_mtime_ns != prior_source.st_mtime_ns assert not ready() and len(calls) == 4 diff --git a/openpilot/starpilot/speed_limits/vision_gate.py b/openpilot/starpilot/speed_limits/vision_gate.py index 8ca75bcdb6..c9eaed690c 100644 --- a/openpilot/starpilot/speed_limits/vision_gate.py +++ b/openpilot/starpilot/speed_limits/vision_gate.py @@ -1,11 +1,10 @@ """Availability of the optional camera speed-limit source.""" from collections.abc import Mapping -from openpilot.starpilot.car.hyundai.aol import ioniq6_settings_capable -from opendbc.car.gm.feature_capabilities import display_supported as gm_display_supported +from openpilot.starpilot.feature_capabilities import display_supported def diagnostic_choice_enabled(environment: Mapping[str, str], cp=None) -> bool: # A saved source choice does not itself authorize Vision speed control. return ((environment.get("SLC_REPLAY_RUNTIME") == "1" and environment.get("SLC_VISION_DEVELOPMENT") == "1") or - (cp is not None and (ioniq6_settings_capable(cp) or gm_display_supported(cp)))) + display_supported(cp)) diff --git a/openpilot/starpilot/tests/test_feature_runtime.py b/openpilot/starpilot/tests/test_feature_runtime.py index f547fc8de4..7f4b229ac5 100644 --- a/openpilot/starpilot/tests/test_feature_runtime.py +++ b/openpilot/starpilot/tests/test_feature_runtime.py @@ -1,9 +1,8 @@ -"""Saved Ioniq runtime startup does not replace live driving authority.""" +"""Saved generic runtime startup does not replace live driving authority.""" from pathlib import Path import json from tempfile import TemporaryDirectory -from unittest.mock import patch from openpilot.starpilot.conditional_mode.policy import ModeChoice from openpilot.starpilot.conditional_mode.preferences import SavedPreferences, encode_preferences @@ -12,6 +11,22 @@ from openpilot.starpilot.longitudinal.profile_document import default_personalit from openpilot.starpilot.speed_limits.vision_gate import diagnostic_choice_enabled +def long_cp(): + from opendbc.car.toyota.interface import CarInterface + from opendbc.car.toyota.values import CAR + cp = CarInterface.get_non_essential_params(CAR.TOYOTA_RAV4_TSS2) + assert cp.openpilotLongitudinalControl and cp.pcmCruise + return cp + + +def stock_cp(): + from opendbc.car.honda.interface import CarInterface + from opendbc.car.honda.values import CAR + cp = CarInterface.get_non_essential_params(CAR.HONDA_CIVIC_BOSCH) + assert not cp.openpilotLongitudinalControl + return cp + + class Files: def __init__(self, root: Path): self.root = root @@ -24,83 +39,79 @@ class Files: return b'0' if key == 'CustomPersonalities' else {} -def test_readable_absence_starts_factory_cem_only_on_exact_eligible_cp(): +def test_readable_absence_starts_factory_cem_only_on_native_long_cp(): with TemporaryDirectory() as temporary: params = Files(Path(temporary)) - with patch('openpilot.starpilot.feature_runtime.ioniq6_long_eligible', side_effect=lambda cp: cp == 'tagged'): - assert requested(params, 'conditional') - assert enabled(params, 'tagged', 'conditional', {}) - assert not enabled(params, 'other', 'conditional', {}) - for name in ('curve', 'slc', 'vision', 'profile'): - assert not enabled(params, object(), name, {}) - (params.root / 'SafeMode').write_bytes(b'1') - assert not requested(params, 'conditional') - (params.root / 'SafeMode').write_bytes(b'0') - (params.root / 'ConditionalModeConfig').write_bytes(b'{broken') - (params.root / 'CurveSpeedController').write_bytes(b'true') - assert not requested(params, 'conditional') - assert not requested(params, 'curve') - (params.root / 'ConditionalModeConfig').unlink() - (params.root / 'ConditionalModeConfig').mkdir() - assert not requested(params, 'conditional') # Read error is never factory absence. + assert requested(params, 'conditional') + assert enabled(params, long_cp(), 'conditional', {}) + assert not enabled(params, stock_cp(), 'conditional', {}) + for name in ('curve', 'slc', 'vision', 'profile'): + assert not enabled(params, object(), name, {}) + (params.root / 'SafeMode').write_bytes(b'1') + assert not requested(params, 'conditional') + (params.root / 'SafeMode').write_bytes(b'0') + (params.root / 'ConditionalModeConfig').write_bytes(b'{broken') + (params.root / 'CurveSpeedController').write_bytes(b'true') + assert not requested(params, 'conditional') + assert not requested(params, 'curve') + (params.root / 'ConditionalModeConfig').unlink() + (params.root / 'ConditionalModeConfig').mkdir() + assert not requested(params, 'conditional') # Read error is never factory absence. -def test_exact_tagged_cp_and_saved_document_start_owner_without_granting_other_cars(): +def test_native_long_cp_and_saved_document_start_owner_without_granting_stock_control(): with TemporaryDirectory() as temporary: params = Files(Path(temporary)) (params.root / 'ConditionalModeConfig').write_bytes(encode_preferences(SavedPreferences(mode=ModeChoice.STOCK))) (params.root / 'CurveSpeedController').write_bytes(b'1') - with patch('openpilot.starpilot.feature_runtime.ioniq6_long_eligible', side_effect=lambda cp: cp == 'tagged'): - for name in ('conditional', 'curve'): - assert enabled(params, 'tagged', name, {}) - assert not enabled(params, 'stock', name, {}) - (params.root / 'SafeMode').write_bytes(b'1') - assert not enabled(params, 'tagged', 'conditional', {}) + for name in ('conditional', 'curve'): + assert enabled(params, long_cp(), name, {}) + assert not enabled(params, stock_cp(), name, {}) + (params.root / 'SafeMode').write_bytes(b'1') + assert not enabled(params, long_cp(), 'conditional', {}) def test_global_braking_choice_starts_only_qualified_profile_owner_without_custom_master(): with TemporaryDirectory() as temporary: params = Files(Path(temporary)) source = params.root / 'LongitudinalPersonalityProfiles' - with patch('openpilot.starpilot.feature_runtime.ioniq6_long_eligible', side_effect=lambda cp: cp == 'tagged'): - assert requested(params, 'profile') - assert enabled(params, 'tagged', 'profile', {}) - assert not enabled(params, 'stock', 'profile', {}) - for response in ('eco', 'sport'): - source.write_text(json.dumps(profile_document(default_personality_profiles(False), enabled=False, - global_braking_response=response))) - assert requested(params, 'profile') - assert enabled(params, 'tagged', 'profile', {}) - assert not enabled(params, 'stock', 'profile', {}) + assert requested(params, 'profile') + assert enabled(params, long_cp(), 'profile', {}) + assert not enabled(params, stock_cp(), 'profile', {}) + for response in ('eco', 'sport'): source.write_text(json.dumps(profile_document(default_personality_profiles(False), enabled=False, - selected_deceleration_profile='dom_default'))) - assert not requested(params, 'profile') - bad = profile_document(default_personality_profiles(False), enabled=False) - bad['globalBrakingResponse'] = 'unsupported' - source.write_text(json.dumps(bad)) - assert not requested(params, 'profile') - source.write_text('{invalid') - assert not requested(params, 'profile') - (params.root / 'SafeMode').write_bytes(b'corrupt') - assert not enabled(params, 'tagged', 'conditional', {}) + global_braking_response=response))) + assert requested(params, 'profile') + assert enabled(params, long_cp(), 'profile', {}) + assert not enabled(params, stock_cp(), 'profile', {}) + source.write_text(json.dumps(profile_document(default_personality_profiles(False), enabled=False, + selected_deceleration_profile='dom_default'))) + assert not requested(params, 'profile') + bad = profile_document(default_personality_profiles(False), enabled=False) + bad['globalBrakingResponse'] = 'unsupported' + source.write_text(json.dumps(bad)) + assert not requested(params, 'profile') + source.write_text('{invalid') + assert not requested(params, 'profile') + (params.root / 'SafeMode').write_bytes(b'corrupt') + assert not enabled(params, long_cp(), 'conditional', {}) -def test_vision_control_needs_saved_master_and_vision_source_on_exact_long_cp(): +def test_vision_control_needs_saved_master_and_vision_source_on_native_long_cp(): with TemporaryDirectory() as temporary: params = Files(Path(temporary)) (params.root / 'SpeedLimitController').write_bytes(b'1') (params.root / 'SLCPriority1').write_bytes(b'Dashboard') (params.root / 'SLCPriority2').write_bytes(b'Vision') - with patch('openpilot.starpilot.feature_runtime.ioniq6_long_eligible', side_effect=lambda cp: cp == 'tagged'): - from openpilot.starpilot.feature_runtime import vision_control_enabled - assert requested(params, 'vision') - assert enabled(params, 'tagged', 'vision', {}) - assert not enabled(params, 'stock', 'vision', {}) - assert vision_control_enabled(params, 'tagged') - assert not vision_control_enabled(params, 'stock') - (params.root / 'SpeedLimitController').write_bytes(b'0') - assert enabled(params, 'tagged', 'vision', {}) # Default-on display still needs its selected Vision producer. - assert not vision_control_enabled(params, 'tagged') + from openpilot.starpilot.feature_runtime import vision_control_enabled + assert requested(params, 'vision') + assert enabled(params, long_cp(), 'vision', {}) + assert enabled(params, stock_cp(), 'vision', {}) # Stock owner may display selected Vision. + assert vision_control_enabled(params, long_cp()) + assert not vision_control_enabled(params, stock_cp()) + (params.root / 'SpeedLimitController').write_bytes(b'0') + assert enabled(params, long_cp(), 'vision', {}) # Default-on display still needs its selected Vision producer. + assert not vision_control_enabled(params, long_cp()) def test_explicit_replay_flags_remain_available_for_existing_replay_fixtures(): @@ -113,13 +124,11 @@ def test_explicit_replay_flags_remain_available_for_existing_replay_fixtures(): assert not enabled(params, None, 'vision', {'SLC_VISION_DEVELOPMENT': '1'}) -def test_saved_vision_choice_is_offered_only_for_supported_vehicle_or_explicit_replay(): - with patch('openpilot.starpilot.speed_limits.vision_gate.ioniq6_settings_capable', - side_effect=lambda cp: cp == 'reviewed_ioniq'): - assert diagnostic_choice_enabled({}, 'reviewed_ioniq') - assert not diagnostic_choice_enabled({}, 'stock_or_other') - assert not diagnostic_choice_enabled({}) - assert diagnostic_choice_enabled({'SLC_REPLAY_RUNTIME': '1', 'SLC_VISION_DEVELOPMENT': '1'}) +def test_saved_vision_choice_is_offered_for_native_display_vehicle_or_explicit_replay(): + assert diagnostic_choice_enabled({}, long_cp()) + assert diagnostic_choice_enabled({}, stock_cp()) # Source visibility grants no speed authority. + assert not diagnostic_choice_enabled({}) + assert diagnostic_choice_enabled({'SLC_REPLAY_RUNTIME': '1', 'SLC_VISION_DEVELOPMENT': '1'}) def test_gm_saved_profiles_require_existing_longitudinal_owner(): diff --git a/openpilot/starpilot/tests/test_global_optional_features.py b/openpilot/starpilot/tests/test_global_optional_features.py new file mode 100644 index 0000000000..ca6c278759 --- /dev/null +++ b/openpilot/starpilot/tests/test_global_optional_features.py @@ -0,0 +1,226 @@ +"""Generic admission reaches real Toyota hosts and native planner application.""" +from types import SimpleNamespace as NS +from unittest.mock import Mock, patch + +import pytest + +from opendbc.car.toyota.interface import CarInterface +from opendbc.car.toyota.values import CAR +from openpilot.common.params import Params +from openpilot.selfdrive.controls import plannerd +from openpilot.selfdrive.controls.lib.longitudinal_planner import LongitudinalPlanner +from openpilot.selfdrive.controls.tests.test_curve_runtime_loop import bus, NOW +from openpilot.starpilot.curve_speed.host import CurveHost +from openpilot.starpilot.feature_runtime import enabled, slc_runtime_settings, vision_control_enabled +from openpilot.starpilot.longitudinal.cruise_ceiling import CruiseCeiling +from openpilot.starpilot.longitudinal.profile_document import default_personality_profiles, profile_document +from openpilot.starpilot.longitudinal.profile_runtime import ProfileHost +from openpilot.starpilot.speed_limits.acceptance import Authority, LongitudinalOwner, Mode +from openpilot.starpilot.speed_limits.selection import Source +from openpilot.starpilot.speed_limits.vision_gate import diagnostic_choice_enabled +from openpilot.starpilot.ui.feature_settings_owner import FeatureSettingsOwner + + +def toyota(): + cp = CarInterface.get_non_essential_params(CAR.TOYOTA_RAV4_TSS2) + assert cp.openpilotLongitudinalControl and cp.pcmCruise + return cp + + +@pytest.mark.parametrize('brand', ['toyota', 'honda', 'hyundai', 'subaru', 'ford', 'volkswagen', 'mazda', 'nissan', 'tesla', 'rivian', 'future']) +def test_generic_admission_is_brand_independent_and_stock_is_display_only(tmp_path, brand): + params = Params(str(tmp_path)) + params.put_bool('CurveSpeedController', True, block=True) + params.put_bool('SpeedLimitController', True, block=True) + cp = NS(brand=brand, carFingerprint=brand + '_native', openpilotLongitudinalControl=True, + pcmCruise=True, passive=False, dashcamOnly=False, notCar=False) + for feature in ('conditional', 'curve', 'profile', 'slc', 'vision'): + assert enabled(params, cp, feature, {}) + assert diagnostic_choice_enabled({}, cp) + assert vision_control_enabled(params, cp) + cp.openpilotLongitudinalControl = False + for feature in ('conditional', 'curve', 'profile'): + assert not enabled(params, cp, feature, {}) + settings = slc_runtime_settings(params, cp, {}) + assert settings.display and not settings.enabled and settings.acceptance.display_only + assert enabled(params, cp, 'slc', {}) and enabled(params, cp, 'vision', {}) + assert not vision_control_enabled(params, cp) + for field in ('passive', 'dashcamOnly', 'notCar'): + setattr(cp, field, True) + for feature in ('conditional', 'curve', 'profile', 'slc', 'vision'): + assert not enabled(params, cp, feature, {}) + setattr(cp, field, False) + + +def test_factory_defaults_and_hidden_vision_control_producer(tmp_path): + params, cp = Params(str(tmp_path)), toyota() + settings = slc_runtime_settings(params, cp, {}) + assert settings.selection.slots[:2] == (Source.VISION, Source.DASHBOARD) + assert not settings.enabled and settings.display + assert enabled(params, cp, 'conditional', {}) + assert enabled(params, cp, 'slc', {}) and enabled(params, cp, 'vision', {}) + assert not enabled(params, cp, 'curve', {}) + params.put_bool('ShowSpeedLimits', False, block=True) + params.put_bool('SpeedLimitController', True, block=True) + assert enabled(params, cp, 'vision', {}) and vision_control_enabled(params, cp) + + +def test_toyota_actual_default_plannerd_constructs_requested_hosts(tmp_path): + params, cp = Params(str(tmp_path)), toyota() + params.put('CarParams', cp.to_bytes(), block=True) + params.put_bool('CurveSpeedController', True, block=True) + class EndLoop(Exception): + pass + sm = Mock() + sm.update.side_effect = EndLoop + with (patch.object(plannerd, 'Params', return_value=params), + patch.object(plannerd, 'config_realtime_process'), patch.object(plannerd, 'set_core_affinity'), + patch.object(plannerd.messaging, 'sub_sock'), + patch.object(plannerd.messaging, 'SubMaster', return_value=sm) as subscriber, + patch.object(plannerd.messaging, 'PubMaster') as publisher, + patch.object(plannerd, 'ConditionalPlannerHost', wraps=plannerd.ConditionalPlannerHost) as conditional, + patch.object(plannerd, 'ProfileHost', wraps=ProfileHost) as profiles, + patch.object(plannerd, 'CurveHost', wraps=CurveHost), + patch.dict('os.environ', {}, clear=True)): + with pytest.raises(EndLoop): + plannerd.main() + conditional.assert_called_once() + profiles.assert_called_once() + assert 'slcState' in publisher.call_args.args[0] + assert 'slcCruiseCommand' in publisher.call_args.args[0] + for source in ('slcDashboardObservation', 'slcVisionObservation', 'mapdOut'): + assert source in subscriber.call_args.args[0] + assert plannerd.SavedSlcLifecycle(params, cp, {}).refresh(NOW)[0] is not None + + +def test_toyota_curve_host_cap_reaches_actual_native_mpc_and_inactive_axis_denies_it(): + cp, host = toyota(), CurveHost(enabled=True, replay=True) + planner = LongitudinalPlanner(cp, init_v=25.0) + applied = [] + for tick in range(1, 31): + stamp = NOW + tick * 50_000_000 + sm = bus(stamp) + result = plannerd.update_curve_frame(planner, sm, cp, stamp, host=host) + assert result.ceiling_mps is not None + applied.append(planner.last_curve_ceiling_applied) + assert any(applied) + sm.messages['carControl'] = sm.messages['carControl'].as_builder() + sm.messages['carControl'].longActive = False + result = plannerd.update_curve_frame(planner, sm, cp, stamp, host=host) + assert result.ceiling_mps is None and not planner.last_curve_ceiling_applied + + +def test_toyota_profile_host_and_slc_ceiling_reach_actual_native_planner(tmp_path): + params, cp = Params(str(tmp_path)), toyota() + params.put_bool('CustomPersonalities', True, block=True) + params.put('LongitudinalPersonalityProfiles', profile_document(default_personality_profiles(True), enabled=True), block=True) + host = ProfileHost(params) + planner = LongitudinalPlanner(cp, init_v=25.0) + ceiling = CruiseCeiling(10.0, Authority(Mode.LONGITUDINAL_ONLY, LongitudinalOwner.SYSTEM, False, True, False, False)) + sm = bus(NOW) + target = plannerd.profile_for_frame(host, sm, cp, NOW) + assert target is not None + planner.update(sm, profile_tuning=target, cruise_ceiling=ceiling, now_ns=NOW) + assert planner.last_profile is not None + assert planner.last_cruise_ceiling_status == 'applied' + sm.messages['carControl'] = sm.messages['carControl'].as_builder() + sm.messages['carControl'].longActive = False + planner.update(sm, profile_tuning=target, now_ns=NOW) + assert planner.last_profile is None + owner = FeatureSettingsOwner(params, lambda group: True, vehicle_fingerprint=lambda: cp.carFingerprint, vehicle_params=lambda: cp) + assert owner.long_profiles.capability() is not None + + +@pytest.fixture +def mode_transport(): + from openpilot.starpilot.conditional_mode.tests import test_status_consumer as fixture + with patch.object(fixture, 'CP', toyota()): + case = fixture.TestModeTransport('test_actual_selfdrived_owner_preserves_stock_and_separates_axes') + case.setUp() + try: + yield case + finally: + case.doCleanups() + + +def test_toyota_actual_selfdrived_acceptance_keeps_current_axis_and_freshness(mode_transport): + from openpilot.cereal import messaging + from openpilot.selfdrive.selfdrived.selfdrived import SelfdriveD + from openpilot.starpilot.conditional_mode.policy import ManualIntent + from openpilot.starpilot.conditional_mode.tests.test_projection import MONO, BOOT + case = mode_transport + drive = SelfdriveD.__new__(SelfdriveD) + drive.conditional_replay = False + drive.requested_experimental_mode = True + drive.update_conditional_mode(None) + case.assertTrue(drive.experimental_mode) + case.params.put_bool('ExperimentalMode', True, block=True) + case.owner.refresh(MONO, force=True) + drive.conditional_replay = True + drive.conditional_settings = case.owner + drive.conditional_consumer = case.consumer + drive.conditional_car_state_valid = True + drive.initialized = True + drive.enabled = True + drive.aol_replay = True + axes = NS(longitudinal_active=True, lateral_active=False) + case.enterContext(patch.object(drive, "aol_axis_decision", axes, create=True)) + drive.CP = toyota() + for index, intent in enumerate((ManualIntent.FORCE_CHILL, ManualIntent.FORCE_EXPERIMENTAL), start=1): + now = MONO + index * 50_000_000 + event, _, sm = case.produce(now, intent=intent) + device = messaging.new_message('deviceState', valid=True) + device.deviceState.started = True + device.deviceState.startedMonoTime = case.drive_id + sm.payloads['deviceState'] = messaging.log_from_bytes(device.to_bytes()).deviceState + sm.payloads['slcState'] = messaging.log_from_bytes(event.as_reader().as_builder().to_bytes()).slcState + for service in ('deviceState', 'slcState'): + sm.logMonoTime[service] = now + sm.recv_time[service] = now / 1e9 + sm.seen[service] = sm.alive[service] = sm.valid[service] = True + sm.valid['slcState'] = False # Unavailable SLC does not invalidate this nested proposal. + drive.sm = sm + drive.aol_car_state_log_ns = now - 2_000_000 + with patch('openpilot.selfdrive.selfdrived.selfdrived.paired_clocks_ns', return_value=(now, BOOT + now - MONO, 1000)): + drive.update_conditional_mode(sm['carState']) + case.assertEqual(drive.experimental_mode, intent is ManualIntent.FORCE_EXPERIMENTAL) + axes.longitudinal_active = False + drive.update_conditional_mode(sm['carState']) + case.assertTrue(drive.experimental_mode) # Exact existing stock request; no conditional override. + axes.longitudinal_active = True + + +def test_toyota_actual_conditional_planner_host_uses_native_mpc_and_adjacent_radar(): + # Reuse the serialized fresh-source integration trace with Toyota's unchanged + # native CarParams, including pcmCruise=True, rather than a modified CP stub. + from openpilot.selfdrive.controls.tests import test_conditional_runtime_loop as fixture + case = fixture.TestConditionalRuntimeLoop('test_automatic_chill_joins_native_mpc_radar_and_disabled_slc') + with patch.object(fixture, 'CarInterface', NS(get_non_essential_params=lambda candidate: toyota())): + case.test_automatic_chill_joins_native_mpc_radar_and_disabled_slc() + + +def test_toyota_galaxy_long_authority_and_native_curve_rows(tmp_path): + from openpilot.starpilot.galaxy.settings import AuthorityContext, _qualified + cp = toyota() + assert _qualified(AuthorityContext(parked=True, cp=cp, cp_raw=cp.to_bytes()), 'long') + params = Params(str(tmp_path)) + owner = FeatureSettingsOwner(params, lambda group: True, vehicle_fingerprint=lambda: cp.carFingerprint, vehicle_params=lambda: cp) + snapshot = owner.snapshot('curve', parked=True, system_long=True, lateral_context=True, metric=True) + master = next(row for row in snapshot.rows if row.key == 'CurveSpeedController') + assert master.available and 'not supported' not in master.reason + + +def test_invalid_saved_sources_and_explicit_replay_optins(tmp_path): + from pathlib import Path + params, cp = Params(str(tmp_path)), toyota() + for key, value, feature in (('ConditionalModeConfig', b'{bad', 'conditional'), + ('CurveSpeedController', b'true', 'curve'), + ('LongitudinalPersonalityProfiles', b'{bad', 'profile')): + Path(params.get_param_path(key)).write_bytes(value) + assert not enabled(params, cp, feature, {}) + for feature, flag in (('conditional', 'CONDITIONAL_MODE_REPLAY_RUNTIME'), + ('curve', 'CURVE_REPLAY_RUNTIME'), ('profile', 'LONG_PLANNER_REPLAY_RUNTIME'), + ('slc', 'SLC_REPLAY_RUNTIME')): + assert enabled(params, None, feature, {flag: '1'}) + assert enabled(params, None, 'vision', {'SLC_REPLAY_RUNTIME': '1', 'SLC_VISION_DEVELOPMENT': '1'}) + assert not enabled(params, None, 'vision', {'SLC_VISION_DEVELOPMENT': '1'}) diff --git a/openpilot/starpilot/ui/feature_settings_owner.py b/openpilot/starpilot/ui/feature_settings_owner.py index 998a554791..005605683c 100644 --- a/openpilot/starpilot/ui/feature_settings_owner.py +++ b/openpilot/starpilot/ui/feature_settings_owner.py @@ -37,8 +37,7 @@ from openpilot.starpilot.ui.feature_settings_state import ( ) from openpilot.starpilot.speed_limits.runtime_settings import MPH_TO_MPS from openpilot.starpilot.aol.vehicle import policy_for as aol_policy_for -from openpilot.starpilot.longitudinal.ioniq6_start import eligible as ioniq6_long_eligible -from opendbc.car.gm.feature_capabilities import longitudinal_supported as gm_longitudinal_supported +from openpilot.starpilot.feature_capabilities import longitudinal_supported as feature_longitudinal_supported from openpilot.starpilot.lateral.torque_settings import DOCUMENT_KEY, LEGACY_KEYS from openpilot.starpilot.ui.torque_feature import TorqueFeature from openpilot.starpilot.ui.controller_feature import ControllerFeature, SETUP_ACTION @@ -812,7 +811,7 @@ class FeatureSettingsOwner: title = "Curve Speed Controller" allowed = configurable and system_long and self.authority("long") vehicle_cp = self.vehicle_params() - curve_supported = vehicle_cp is not None and (ioniq6_long_eligible(vehicle_cp) or gm_longitudinal_supported(vehicle_cp)) + curve_supported = feature_longitudinal_supported(vehicle_cp) learning = learning_snapshot(self.params) document_raw = self._raw(CURVE_DOCUMENT_KEY) dependencies = ((CURVE_DOCUMENT_KEY, document_raw),) diff --git a/openpilot/starpilot/ui/long_profile_feature.py b/openpilot/starpilot/ui/long_profile_feature.py index 0e7cd750a7..99ea6f15f3 100644 --- a/openpilot/starpilot/ui/long_profile_feature.py +++ b/openpilot/starpilot/ui/long_profile_feature.py @@ -64,7 +64,7 @@ class LongProfileFeature: def capability(self) -> tuple | None: cp = self.owner.vehicle_params() try: - if (cp is None or not cp.carFingerprint or not self.owner.longitudinal_available() or (cp.pcmCruise and not self.owner.configuration_longitudinal()) or + if (cp is None or not cp.carFingerprint or not self.owner.longitudinal_available() or cp.passive or cp.notCar or cp.dashcamOnly): return None return (str(cp.carFingerprint), self.owner.longitudinal_available(), bool(cp.pcmCruise and not self.owner.configuration_longitudinal()), diff --git a/openpilot/starpilot/ui/runtime_app.py b/openpilot/starpilot/ui/runtime_app.py index 96806a223c..3479f8a55c 100644 --- a/openpilot/starpilot/ui/runtime_app.py +++ b/openpilot/starpilot/ui/runtime_app.py @@ -668,7 +668,7 @@ class StarShellSession: return bool(cp.openpilotLongitudinalControl and not cp.notCar and not cp.passive and not cp.dashcamOnly) if group in ("aol_wheel", "lane", "torque", "aol"): return not cp.notCar and not cp.passive and not cp.dashcamOnly - return bool(cp.openpilotLongitudinalControl and not cp.pcmCruise and not cp.notCar and + return bool(cp.openpilotLongitudinalControl and not cp.notCar and not cp.passive and not cp.dashcamOnly) return False diff --git a/openpilot/starpilot/ui/runtime_snapshot.py b/openpilot/starpilot/ui/runtime_snapshot.py index c0a78f424b..fc0649fe06 100644 --- a/openpilot/starpilot/ui/runtime_snapshot.py +++ b/openpilot/starpilot/ui/runtime_snapshot.py @@ -562,7 +562,7 @@ class RuntimeSnapshotAdapter: cruise = None stock_cruise = bool(display_car_valid and bool(getattr(getattr(display_car, "cruiseState", None), "enabled", False)) and - cp is not None and (not cp.openpilotLongitudinalControl or cp.pcmCruise)) + cp is not None and not cp.openpilotLongitudinalControl) observation = speed_limit_from_message(slc) if slc is not None and (slc.enabled or slc.displayOnly) else SpeedLimitObservation() if slc is None and started: observation = SpeedLimitObservation(kind=ObservationKind.STALE) diff --git a/openpilot/starpilot/ui/tests/test_conditional_feature.py b/openpilot/starpilot/ui/tests/test_conditional_feature.py index 3428044cb0..c9e61e526e 100644 --- a/openpilot/starpilot/ui/tests/test_conditional_feature.py +++ b/openpilot/starpilot/ui/tests/test_conditional_feature.py @@ -314,11 +314,20 @@ class ConditionalFeatureTests(unittest.TestCase): with patch.object(shell, "confirmed_offroad", return_value=True), \ patch.object(runtime_app.ui_state, "CP", self.cp): self.assertTrue(shell._feature_authority("conditional")) - self.assertFalse(shell._feature_authority("long")) + self.assertTrue(shell._feature_authority("long")) assert self.cp is not None self.cp.openpilotLongitudinalControl = False self.assertFalse(shell._feature_authority("conditional")) + self.assertFalse(shell._feature_authority("long")) self.assertTrue(shell._feature_authority("preferences")) + self.cp.openpilotLongitudinalControl = True + for field in ('passive', 'dashcamOnly', 'notCar'): + with self.subTest(field=field): + setattr(self.cp, field, True) + self.assertFalse(shell._feature_authority("conditional")) + self.assertFalse(shell._feature_authority("long")) + self.assertTrue(shell._feature_authority("preferences")) + setattr(self.cp, field, False) def test_native_saved_actions_use_settings_mode_and_supported_current_car(self): from openpilot.starpilot.ui import runtime_app diff --git a/openpilot/starpilot/ui/tests/test_feature_settings_navigation.py b/openpilot/starpilot/ui/tests/test_feature_settings_navigation.py index 164cfa49b8..bf73da3f71 100644 --- a/openpilot/starpilot/ui/tests/test_feature_settings_navigation.py +++ b/openpilot/starpilot/ui/tests/test_feature_settings_navigation.py @@ -123,7 +123,7 @@ class FeatureNavigationTests(unittest.TestCase): session.feature_owner = FeatureSettingsOwner(params, session._feature_authority, vehicle_fingerprint=lambda: vehicle.carFingerprint, vehicle_params=lambda: vehicle) with patch.object(runtime_app, 'ui_state', NS(CP=vehicle)): - self.assertFalse(session._feature_authority('long')) + self.assertTrue(session._feature_authority('long')) self.assertTrue(session._feature_authority('toyota_cruise')) for expected in (True, False): state = session.feature_owner.snapshot('profiles', parked=True, system_long=False, @@ -144,7 +144,7 @@ class FeatureNavigationTests(unittest.TestCase): other = HondaCarInterface.get_non_essential_params(HONDA_CAR.HONDA_CIVIC) with patch.object(runtime_app, 'ui_state', NS(CP=other)): self.assertFalse(session._feature_authority('toyota_cruise')) - self.assertFalse(session._feature_authority('long')) + self.assertTrue(session._feature_authority('long')) def test_native_saved_preferences_need_settings_or_favorite_context_not_cp(self): from openpilot.starpilot.ui import runtime_app @@ -154,9 +154,14 @@ class FeatureNavigationTests(unittest.TestCase): session = runtime_app.StarShellSession.__new__(runtime_app.StarShellSession) session._mode = runtime_app.ShellMode.SETTINGS with patch.object(session, "confirmed_offroad", return_value=True), patch.object(runtime_app, "ui_state", NS(CP=cp)): + self.assertTrue(session._feature_authority("long")) + self.assertTrue(session._feature_authority("preferences")) + cp.openpilotLongitudinalControl = False self.assertFalse(session._feature_authority("long")) self.assertTrue(session._feature_authority("preferences")) + cp.openpilotLongitudinalControl = True cp.passive = True + self.assertFalse(session._feature_authority("long")) self.assertTrue(session._feature_authority("preferences")) with patch.object(session, "confirmed_offroad", return_value=True), patch.object(runtime_app, "ui_state", NS(CP=None)): self.assertTrue(session._feature_authority("preferences")) diff --git a/openpilot/starpilot/ui/wheel_feature.py b/openpilot/starpilot/ui/wheel_feature.py index c47c47cf45..745f2a3a32 100644 --- a/openpilot/starpilot/ui/wheel_feature.py +++ b/openpilot/starpilot/ui/wheel_feature.py @@ -2,7 +2,7 @@ import hashlib from openpilot.starpilot.aol.vehicle import policy_for from openpilot.starpilot.conditional_mode.manual import ioniq6_media_eligible -from openpilot.starpilot.controllers.wheel_actions import ACTIONS, DEFAULTS, KEYS, eligible +from openpilot.starpilot.controllers.wheel_actions import ACTIONS, DEFAULTS, KEYS, button_reported, eligible from openpilot.starpilot.saved_document import commit_exact from openpilot.starpilot.saved_source import read_saved from openpilot.starpilot.ui.feature_settings_state import FeatureRow @@ -38,9 +38,12 @@ class WheelFeature: from openpilot.starpilot.car.hyundai.settings import configuration_wheel_policy, configuration_media_supported policy = configuration_wheel_policy(cp) or policy media = configuration_media_supported(cp) or media - if key == "LKASButtonControl" and not policy.lkas_button_supported: + if not button_reported(cp, key): return () - if key in KEYS[:2] and policy.fixed_cruise_buttons: + if key == "LKASButtonControl" and not (policy.lkas_button_supported or policy.auxiliary_lkas_supported): + return () + if key in KEYS[:2] and policy.fixed_cruise_buttons and not ( + key == "LKASButtonControl" and policy.auxiliary_lkas_supported): return () if key in KEYS[5:8] and cp.brand == 'gm': from opendbc.car.gm.cancel_remap import supported @@ -48,7 +51,8 @@ class WheelFeature: return () if key in KEYS[8:] and not media: return () - if key in KEYS[:5] and not policy.settings_supported: + if key in KEYS[:5] and not policy.settings_supported and not ( + key == "LKASButtonControl" and policy.auxiliary_lkas_supported): return () codes = {0, 8, 11, 12, 13} if self.owner.longitudinal_available():